Golang 디커플링 - 메소드 (Method)

Johnny·2021년 3월 29일
4

Saturday Night 스터디

목록 보기
2/8

📖 이 글은 Saturday Night 스터디에서 The Ultimate Go를 주제로 발표하기 위해 만들어졌습니다.


디커플링 (Decoupling)

소프트웨어 공학에서 결합도(Coupling)란 모듈간 의존도를 나타내는 것을 의미합니다. 반대로 디커플링이란 인터페이스 등을 활용하여 모듈간 의존도를 최소화하여 개발하는 방법을 의미합니다.



메소드 (Method)

Go에서 메소드는 마치 다른 언어에서의 메소드를 흉내내는듯한 함수입니다.

우선 코드를 볼까요? 코드 형태는 다음과 같습니다.

예제코드

type user struct {
    name 	string
    email	string
}

func (u user) notify() {
    fmt.Printf("Sending User Email to %s<%s> \n", u.name, u.email)
}

함수 키워드func과 함수명 notify() 사이에 (u user)라는 구문이 보입니다. 이를 Go진영에서 리시버라고 부르며, 이를 명시함으로써 함수를 메소드로써 작동하게 합니다.



리시버 (Receiver)

Go에서는 리시버라는 개념이 존재하는데요. 위 코드에서 함수 키워드와 함수명 사이에 존재하는 것이 리시버입니다. 리시버는 구조체와 함수를 서로 연결 짓는 매개체 역할을 수행하여 마치 함수가 메소드인 것과 같이 작동합니다.

흔히 메소드는 C++, Java, Python같은 OOP 기반의 언어에서 찾아볼 수 있는데, Go는 OOP 기반의 언어는 아니지만 메소드와 같은 형태를 지원합니다.



값 리시버 (Value receiver)

리시버는 값 리시버와 포인터 리시버로 분류됩니다.
값 리시버를 가지는 메소드는 호출한 구조체가 복사되어 복사본을 기반으로 동작합니다.

func (u user) notify() {
    fmt.Printf("Sending User Email to %s<%s> \n", u.name, u.email)
}

구조체 타입을 명시할 때 *를 붙이지 않으면 이 리시버는 값 리시버로써 작동합니다.



포인터 리시버 (Pointer receiver)

포인터 리시버를 가지는 메소드는 호출한 구조체를 참조하여 동작합니다.

예제코드

func (u *user) changeEmail(email string) {
    u.email = email
    fmt.Printf("Changed User Email to %s<%s> \n", u.name, u.email)
}

구조체 타입을 명시할 때 *를 붙이면 이 리시버는 포인터 리시버로써 작동합니다.



값 리시버와 포인터 리시버를 이용한 호출

user 구조체 타입의 변수는 값 리시버와 포인터 리시버 모두 호출하여 사용할 수 있습니다.

예제코드

johnny := user{ name: "Johnny", email: "johnny@gmail.com" }
johnny.notify()
johnny.changeEmail("change@github.com")
[OUTPUT]
Sending User Email to Johnny<johnny@gmail.com> 
Changed User Email to Johnny<change@github.com> 

johnny라는 user 값 타입의 변수를 생성했습니다. 그리고 포인터 리시버를 가지는 changeEmail(string) 메소드를 호출할 수가 있다는 걸 알 수 있습니다.

user 포인터 타입의 변수도 마찬가지로 값 리시버와 포인터 리시버를 사용하는 모든 메소드를 호출할 수 있습니다.
예제코드

mason := &user{ name: "Mason", email: "mason@gmail.com" }
mason.notify()
mason.changeEmail("change@github.com")
[OUTPUT]
Sending User Email to Mason<mason@gmail.com> 
Changed User Email to Mason<change@github.com> 

위의 코드에서 mason은 user 포인터 타입의 변수입니다. 이 또한 값 리시버로 구현한 notify()를 호출하는게 가능합니다.

mason := &user{ name: "Mason", email: "mason@gmail.com" }
mason.notify()

위 코드에서 user 포인터 타입 변수로 notify() 메소드를 호출하는데 이는 사실 내부적으로 (*user).notify();역참조 형태로 호출하게 됩니다.

📝 mason변수는 user의 포인터 타입으로 선언되었기 때문에 코드로 표현하면 var mason *user와 같아요.
그래서 (*user).notify() == (*mason).notify()
즉, 포인터 타입의 변수인 mason을 역참조하여 값에 접근한 다음 notify()를 호출합니다.
헷갈리지 마세요!🤯



Go는 mason 포인터 변수가 가리키는 값을 찾아서 복사하고 notify()를 호출하여 값에 의한 호출이 가능하게 합니다.

johnny := user{ name: "Johnny", email: "johnny@gmail.com" }
johnny.changeEmail("change@github.com")

반대로 johnny 값 변수도 포인터 리시버로 구현된 changeEmail(string) 메소드를 호출하면 내부적으로 (&user).changeEmail(string);참조 형태로 호출합니다.

📝 코드로 표현하면 var johnny user이고 (&johnny).changeEmail(string)으로 작동합니다.
이제 슬슬 감이 오시죠? 🤓



심화과정

구조체 타입을 가지는 슬라이스가 있다고 가정했을 때, 이를 for range 반복문을 돌리는 경우에 대한 예제입니다.

우선 값 리시버를 가지는 메소드에 대한 심화 내용입니다.

users := []user{
    { name: "Johnny", email: "johnny@gmail.com" },
    { name: "Mason", email: "mason@gmail.com" },
}

for _, u := range users {
    u.notify()
}
[OUTPUT]
Sending User Email To Johnny<johnny@email.com>
Sending User Email To Mason<mason@email.com>

위의 코드는 다음과 같이 작동합니다.
users로부터 각 원소들의 복사본(u)가 만들어지고, 복사본 u변수에서 notify()를 호출하게 되면 u로부터 또 새로운 복사본이 만들어집니다.

📝 u.notify()를 호출하게 되면 새로운 복사본이 만들어지는 이유는 값 리시버파트에서 설명했듯 값 리시버는 복사본을 만들어서 작동하기 때문이에요. 🤪


예제코드에서 각각 변수들의 메모리 주소를 출력해보면 다음과 같습니다.

[OUTPUT]
users[0]: Johnny<0xc0000bc040> // 1. users slice
users[1]: Mason<0xc0000bc060> 

u[0]: Johnny<0xc0000c0000>  // 2. in for range
u.notify(): Johnny<0xc0000c0020>  // 3. in notify() method

u[1]: Mason<0xc0000c0000> 
u.notify(): Mason<0xc0000c0040> 

📝 for range 구문은 원소들을 임시 저장하기 위해 선언한 u변수를 최초 한번 할당한 후 매 반복마다 재사용하므로 메모리 주소 정보가 변하지 않아요. 🤓
Gump님께서 이전 스터디 시간에 지나가듯 설명해주신 적이 있죠! 😉


이번엔 포인터 리시버에 대한 심화 내용입니다.

users := []user{
    { name: "Johnny", email: "johnny@gmail.com" },
    { name: "Mason", email: "mason@gmail.com" },
}

for _, u := range users {
    u.changeEmail("change@github.com")
}

fmt.Printf("%s<%s> \n", users[0].name, users[0].email)
fmt.Printf("%s<%s> \n", users[1].name, users[1].email)
[OUTPUT]
Johnny<johnny@gmail.com> 
Mason<mason@gmail.com> 

for range 구문에서 u는 users 슬라이스로부터 값을 복사하여 할당하게 되고, u.changeEmail(string)을 호출하게 되면 포인터 리시버는 u에 대한 주소를 바라보기 때문에 원본인 users 슬라이스의 원소값들이 수정되지 않습니다.

📝 슬라이스의 원본 요소들을 수정하려고 포인터 리시버로 작동하는 메소드를 호출했는데 의도와 다르게 원본이 수정되지 않으니 주의하세요!


예제코드에서 각각 변수들의 메모리 주소를 출력해보면 다음과 같습니다.

[OUTPUT]
users[0]: Johnny<0xc0000bc040> // 1. users slice 
users[1]: Mason<0xc0000bc060>  

u[0]: Johnny<0xc0000c0000>  // 2. in for range
u.changeEmail(string): Johnny<0xc0000c0000> // 3. in changeEmail() method

u[1]: Mason<0xc0000c0000> 
u.changeEmail(string): Mason<0xc0000c0000> 

출력에서 알 수 있듯이 users의 원소들을 u변수에 복사하고 u를 기반으로 포인터 리시버가 작동합니다.

따라서 위의 코드에서 포인터 리시버를 통해 원본을 수정하려면 역참조를 해야합니다.

// user 타입의 포인터를 값으로 가지는 slice 생성
users := []*user{
    { name: "Johnny", email: "johnny@gmail.com" },
    { name: "Mason", email: "mason@gmail.com" },
}

// users 슬라이스에서 포인터를 u에 복사본으로 할당
for _, u := range users {
    u.changeEmail("change@github.com")
}

fmt.Printf("%s<%s> \n", users[0].name, users[0].email)
fmt.Printf("%s<%s> \n", users[1].name, users[1].email)
[OUTPUT]
Johnny<change@github.com> 
Mason<change@github.com> 

또는 슬라이스에 인덱스로 접근하여 원본을 수정할 수 있습니다.

users := []user{
    { name: "Johnny", email: "johnny@gmail.com" },
    { name: "Mason", email: "mason@gmail.com" },
}

// users 슬라이스에 인덱스로 접근하여 원본을 수정
for i, _ := range users {
    users[i].changeEmail("change@github.com")
}

fmt.Printf("%s<%s> \n", users[0].name, users[0].email)
fmt.Printf("%s<%s> \n", users[1].name, users[1].email)
[OUTPUT]
Johnny<change@github.com> 
Mason<change@github.com> 

📝 Go언어에서는 사용하고 싶지 않은 값은 underscore를 명시하여 할당하지 않을 수 있습니다.


Q. for i, _ := range users에서 슬라이스로부터 원소들을 할당할 필요가 없으므로 underscore로 명시했는데요. 이 경우 스택 영역에 메모리 할당도 안할까요? 🤔


값에 의한 호출과 참조에 의한 호출 (Value and Pointer semantics)

숫자, 문자열, bool과 같은 primitive 타입을 사용하는 경우엔 값에 의한 호출을 사용하는 것을 권장합니다. 일반적으로 가벼운 primitive 타입의 변수를 메모리 누수와 같은 위험이 있는 힙 메모리에 만들 필요는 없습니다.

슬라이스, 맵, 채널, 인터페이스와 같은 참조 타입의 변수들도 역시 기본적으로 값에 의한 호출을 사용하는 걸 권장합니다. 단, json.Unmarshal()과 같이 주소값을 파라미터로 요구하는 함수들을 사용할 때에는 주소값을 사용해야 합니다.



값에 의한 호출

Go의 net 패키지는 IP와 IPMask 타입을 제공하는데, byte 타입의 슬라이스입니다. 아래의 예제들은 참조 타입들을 값에 의한 호출을 통해 사용하는 것을 보여줍니다.

type IP []byte
type IPMask []byte

Mask()는 IP 타입의 값 리시버를 사용하며, IP 타입의 값을 반환합니다. 이 메소드는 IP 값 리서버를 사용해서 값을 반환하므로 값에 의한 호출입니다.

func (ip IP) Mask(mask IPMask) IP {
    if len(mask) == IPv6len && len(ip) == IPv4len && allFF(mask[:12]) {
        mask = mask[12:]
    }
    if len(mask) == IPv4len && len(ip) == IPv6len && bytesEqual(ip[:12], v4InV6Prefix) {
        ip = ip[12:]
    }
    
    n := len(ip)
    if n != len(mask) {
        return nil
    }
    out := make(IP, n)
    for i := 0; i < n; i++ {
        out[i] = ip[i] & mask[i]
    }
	
    return out
}

ipEmptyString()은 IP타입의 값을 파라미터로 받고, 문자열 타입의 값을 반환합니다. 이 함수는 파라미터로 IP 값 타입을 가지고 값을 복사하여 반환하므로 값에 의한 호출입니다.

func ipEmptyString(ip IP) string {
    if len(ip) == 0 {
        return ""
    }
	
    return ip.String()
}

📝 ip파라미터가 IP 값 타입이므로 복사 발생!



참조에 의한 호출

Time 타입은 값에 의한 호출과 참조에 의한 호출 중 어떤 방법을 사용해야 할까요?

type Time struct {
    sec 	int64
    nsec 	int32
    loc 	*Location
}

타입에 대해 어떤 호출을 사용할지 결정하는 가장 좋은 방법은 타입의 생성자 함수를 확인하는 겁니다.
아래의 생성자 함수는 어떤 호출을 사용해야 하는지 알 수 있습니다. Now()함수는 Time 타입의 값을 반환합니다.

func Now() Time {
    sec, nsec := now()
    return Time{ sec + unixToInternal, nsec, Local }
}

반환 시 Time 타입의 값은 복사가 이루어지고, Now()함수를 호출한 곳으로 반환됩니다.
즉, 이 Time 타입의 값은 스택에 저장됩니다. 따라서 값에 의한 호출을 사용하는 것이 권장됩니다.

Add()메소드는 기존의 Time 타입의 값에서 다른 값을 얻기 위한 메소드입니다. 만약 값을 변경할 때 무조건 참조에 의한 호출을 하고, 그렇지 않을 때는 값에 의한 호출을 해야 한다고 가정을 지어버리면 이 메소드의 구현은 잘못되었다고 이야기할 수 있습니다.
하지만 리시버의 타입은 어떤 호출을 사용할지 결정되는 것이지 메소드의 구현을 결정짓지 않습니다.

func (t Time) Add(d Duration) Time {
    t.sec += int64(d / 1e9)
    nsec := int32(t.nsec) + int32(d%1e9)
    if nsec >= 1e9 {
        t.sec++
        nsec -= 1e9
    } else if nsec <0 {
        t.sec--
        nsec += 1e9
    }
    t.nsec = nsec
    return t
}

Add()메소드는 값 리시버를 사용하고 있고, Time 타입의 값을 반환합니다. 이 메소드는 실제 Time 타입 변수의 복사본을 변경하여 원본 값이 변경되지 않고 완전히 새로운 값을 반환하게 됩니다.

📝 철수와 영희가 대화를 나누네요. 👀


🙋🏻‍♂️철수: 값을 변경하는 유형의 메소드는 반드시 포인터 타입의 리시버를 가져야해!
🙅🏻‍♀️영희: 아니야 철수야. 값을 변경하는 유형의 메소드도 목적(요구사항)이 무엇이냐에 따라 값 리시버를 가질 수 있어.


Time 타입에 대한 참조에 의한 호출은 주어진 데이터를 Time 타입으로 변환하여 원본을 수정할 때만 사용합니다.

func (t *Time) UnmarshalBinary(data []type) error {}
func (t *Time) GobDecode(data []byte) error {}
func (t *Time) UnmarshalJSON(data []byte) error {}
func (t *Time) UnmarshalText(data []byte) error {}

아래는 File 타입의 포인터를 반환합니다. 이는 File 타입에 대해서 참조에 의한 호출을 사용하여 값을 공유할 수 있다는 것을 의미합니다.

func Open(name string) (f *File, err error) {
    return OpenFile(name, O_RDONLY, 0)
}

Chdir()메소드는 File 타입의 포인터 리시버를 사용합니다. 이 메소드는 File 타입에 대해 참조에 의한 호출을 사용하고 있습니다.

func (f *File) Chdir() error {
    if f == nil {
        return ErrInvalid
    }
    if e := syscall.Fchdir(f.fd); e != nil {
        return &PathError{ "chdir", f.name, e }
    }
    return nil
}

epipecheck() 메소드는 File 타입의 포인터를 파라미터로 받습니다. 따라서 이 함수는 File 타입에 대해 참조에 의한 호출을 사용하고 있습니다.

func epipecheck(f *File, e error) {
    if e == syscall.EPIPE {
        if atomic.AddInt32(&f.nepipe, 1) >= 10 {
            sigpipe()
        }
    } else {
        atomic.StoreInt32(&f.nepipe, 0)
    }
}


메소드는 단지 함수일 뿐

메소드는 특수한 기능이 아니라 문법적인 필요성으로 인해 만들어졌습니다. 메소드를 사용하면 데이터와 관련된 일부 기능을 외부에서 사용할 수 있는 것처럼 이해하게 만듭니다. 객체지향 프로그래밍에서도 이러한 설계를 권장하는데요. Go에서는 객체지향 프로그래밍을 추구하는 것은 아니지만 데이터와 연관된 동작이 필요하기 때문에 메소드가 만들어졌습니다.

📝 Go언어에서 함수는 일급 함수입니다. 그래서 기본 타입도 존재하고, 함수를 파라미터로 전달하거나 리턴 타입으로 명시하여 사용할 수도 있습니다.


다시 한번 위에서 사용했던 코드를 가져와봅시다.

type user struct {
    name 	string
    email	string
}

func (u user) notify() {
    fmt.Printf("Sending User Email to %s<%s> \n", u.name, u.email)
}

func (u *user) changeEmail(email string) {
    u.email = email
    fmt.Printf("Changed User Email to %s<%s> \n", u.name, u.email)
}


함수형 변수

함수형 변수를 선언하고, 이 변수에 user 타입 변수의 메소드를 할당할 수 있습니다.

f1 := u.notify

위의 코드와 같이 f1 변수에 u.notify() 메소드를 할당할 수 있습니다.
이 때, f1 변수는 포인터(uintptr)이고, 2개의 워드를 가지는 특별한 자료 구조가 됩니다.

예제코드

u := &user{ name: "Johnny", email: "johnny@gmail.com" } // 32byte
	
f1 := u.notify
fmt.Println(unsafe.Sizeof(f1))
	
f1()
[OUTPUT]
8 // f1 byte size (uintptr)
Sending User Email to Johnny<johnny@gmail.com> // user.notify() call througth f1()

f1변수의 내부 구조에서 첫번째 워드는 실행 대상 메소드 u.notify()를 가리킵니다.
notify()메소드는 값 리시버를 사용하기 때문에 실행하기 위해서는 user 타입의 값을 필요로 하므로 u값의 복사본을 만들게 됩니다. 그래서 두번째 워드는 u값의 복사본의 값을 가리킵니다.

📝 u변수의 멤버 변수인 name값을 변경하더라도 f1에는 이런 변경사항이 반영되지 않습니다!



🎸 기타

메소드는 연결할 구조체와 동일한 패키지에서만 정의할 수 있습니다. 패키지 레벨이 다른 경우 메소드를 정의할 수 없습니다.

package model

type User struct {
    Name    string
    Email   string
}

------------------------------

package method

import "saturday-night/src/model"

// 📝 패키지는 타입이 아니기 때문에 리시버에 명시할 수 없어요. 
// 그래서 [package].[struct] 형태로 다른 패키지의 구조체를 명시할 수 없습니다.
// 메소드는 반드시 구조체와 동일한 레벨의 패키지에서만 정의할 수 있습니다.

// Error: Unresolved type 'model'
func (u model.User) notify() {
    fmt.Printf("Sending User Email to %s<%s> \n", u.Name, u.Email)
}
profile
배우면 까먹는 개발자 😵‍💫

2개의 댓글

comment-user-thumbnail
2021년 4월 16일

오우..🤩

1개의 답글