- 출처: https://quii.gitbook.io/learn-go-with-tests/go-fundamentals/pointers-and-errors
- 개요
struct의 상태를 갱신하거나 크기가 큰 객체에 대해 값 복사를 하지 않고 참조하기 위한 pointer를 알아본다. 타 언어에서 null에 해당하는 개념인 nil을 알아본다. 또한 Error 처리를 간단하게 살펴본다.
이번에는 간단한 Bitcoin 입출금 시스템 구현 하면서 위에서 언급한 개념들을 살펴보자.
- Pointer
struct를 사용할 때 마치 Java의 setter처럼 상태를 관리하거나 상태를 변경하도록 메소드를 외부로 노출해야하는 경우가 있다. 이전 struct에서는 field에 직접 접근할 수 있었지만 이번에는 내부 상태를 외부로 노출하지 않고 메소드를 통해서만 제어해보자.
func TestWallet(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(10)
got := wallet.Balance()
want := 10
if got != want {
t.Errorf("got %d want %d", got, want)
}
}
위의 코드는 비트코인처럼 10을 예금하면 잔고를 조회할 시 10이 나와야한다는것을 test코드로 나타내고 있다. go test로 실행하면 Wallet이 정의되어 있지 않다고 나올것이다.
type Wallet struct { }
func (w Wallet) Deposit(amount int) {
}
func (w Wallet) Balance() int {
return 0
}
이 blog에서는 sturct정의와 메소드의 정의를 한번에 작성했지만 TDD정신에 입각한다면 Wallet정의 후 go test를 수행하고 메소드를 정의해야한다. 또한 Balance가 0을 반환하도록 정의하고나서 go test를 수행하여 0반환시 제대로 실패하는지 확인해야 한다.
실패하는것을 확인했으면 테스트를 통과하기 위한 코드를 작성해보자.
type Wallet struct {
balance int
}
여기서 접근제어자의 개념이 나온다. Go 에서는 변수나 type, 함수등과 같은 symbol이 소문자로 시작하면 package-private이다. package밖에서는 접근할 수 없다는 얘기다. 앞에서 가정 했듯 내부 상태는 메소드에 의해서만 변경하도록 제어하므로 소문자로 시작하는 balance로 네이밍을 해준다.
func (w Wallet) Deposit(amount int) {
w.balance += amount
}
func (w Wallet) Balance() int {
return w.balance
}
test가 통과하도록 코드를 수정했으므로 go test를 수행해보자. 분명히 10으로 수정이 되었어야 할 것 같은데 여전히 test는 실패할것이다. Java를 주로 사용해보았다면 조금 혼동이 되는 부분일 수 있다.
Go에서는 함수나 메소드를 호출할 때 인자가 복사된다. func (w Wallet) Deposit(amount int)를 호출했을 때, amount가복사되어도 Wallet struct의 상태값을 갱신해주었는데 왜 반영이 되지 않았을까? Wallet의 w도 복사가 되었기 때문이다. 메소드내에서 참조하는 w는 Test코드에서 호출했을 때의 원본 wallet이 아니라 복사본이다.
fmt.Printf("address of balance in test is %v \n", &wallet.balance)
fmt.Printf("address of balance in Deposit is %v \n", &w.balance)
주소값을 알아보기 위해 &변수명을 출력하는 코드를 test 코드와 메소드내부에 작성하고 실행해보면 서로 참조하는 메모리 주소가 다르게 출력될것이다. 그래서 test가 여전히 실패하고 있는것이다.
이럴때 필요한게 바로 pointer이다. pointer를 이용하면 Wallet이 복사되어 메소드 내부로 전달되지 않고 wallet에 대한 pointer를 받는다.
func (w *Wallet) Deposit(amount int) {
w.balance += amount
}
func (w *Wallet) Balance() int {
return w.balance
}
pointer 사용은 위와 같이 해주면 된다. C언어를 해본적이 있다면 익숙한 기호일것이다. 우리는 Wallet의 상태를 갱신해주어야 하므로 Wallet의 pointer형이라는 의미로 w *Wallet과 같이 receiver를 수정해주면 된다. 이제 test가 통과될것이다.
C언어를 경험해보았다면 여기서 의문점이 생길 수 있는데 왜 pointer형을 참조받고 아래코드처럼 그것을 다시 원래 자료형으로 되돌려주는 코드(dereference)를 하지 않고 일반변수의 속성을 갱신한것처럼 사용했느냐는것이다.
func (w *Wallet) Balance() int {
return (*w).balance
}
Go의 제작자들은 이 표기법을 번거롭게 여겨서 명시적인 dereference 없이 w.balance처럼 사용할 수 있도록 했다.
사실 기술적으로만 보면 Balance 메소드를 작성할때는 굳이 pointer receiver를 사용하지 않아도 된다. 어차피 복사본을 받아서 해당 복사본의 속성을 그대로 반환해도 결과는 똑같기 때문이다. 위처럼 작성한 이유는 메소드의 receiver형을 일관되게 유지하는게 일종의 규약이기 때문이다.
- custom struct(Refactoring)
개요에서 Bitcoin wallet을 만든다고 언급했었다. 하지만 지금까지보면 wallet이라는 개념은 등장했지만 Bitcoin이라는 개념은 등장하지 않았다. 내가 Bitcoin에 대한 잔액을 확인하고 예금을 하는건지 다른 화폐인지가 코드에 나타나있지 않다.
현재 기능적으로는 int형을 써도 충분하다. 예제 에서는 소수점단위로 입출금 하지 않는다고 가정 하였고, 무언가를 새거나 갱신하는데 충분하기 때문이다. 또한 Bitcoin을 표현하기 위해 int형과 같은 struct를 만드는건 오버엔지니어링인 느낌이 들것이다.
Go는 존재하는 type으로 부터 새로운 type을 생성할 수 있다. 이 규칙은 내장된 type들(int, float64와 같은 primitive type들)에도 적용된다. type [이름] [원래type] 처럼 선언하면 된다.
type Bitcoin int
type Wallet struct {
balance Bitcoin
}
func (w *Wallet) Deposit(amount Bitcoin) {
w.balance += amount
}
func (w *Wallet) Balance() Bitcoin {
return w.balance
}
func TestWallet(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(Bitcoin(10))
got := wallet.Balance()
want := Bitcoin(10)
if got != want {
t.Errorf("got %d want %d", got, want)
}
}
기존 int형을 이용해 새로운 Bitcoin type을 생성한다. 그리고 이에 따라 test코드도 변경하였다. struct를 새로 만든셈이므로 Bitcoin에는 새로운 메소드도 선언할 수 있다. 마치 int형에 새로운 메소드를 추가한 효과를 내는 셈이다. 이런 생각을 응용하면 기존에 존재하는 형에 도메인 특화된 기능을 추가해야할 때 유용하게 써먹을 수 있다.
type Stringer interface {
String() string
}
위의 Stringer 인터페이스는 fmt package에 정의되어 있다. Bitcoin이 이 인터페이스를 구현하도록 해보자.
func (b Bitcoin) String() string {
return fmt.Sprintf("%d BTC", b)
}
그리고 위의 format대로 출력되도록 원한다면 test코드도 아래처럼 수정해주어야 한다.
if got != want {
t.Errorf("got %s want %s", got, want)
}
- error
Bitcoin을 인출하는 기능도 추가해보자. Deposit()과 거의 반대되는 기능을 구현하면 된다.
func TestWallet(t *testing.T) {
t.Run("Deposit", func(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(Bitcoin(10))
got := wallet.Balance()
want := Bitcoin(10)
if got != want {
t.Errorf("got %s want %s", got, want)
}
})
t.Run("Withdraw", func(t *testing.T) {
wallet := Wallet{balance: Bitcoin(20)}
wallet.Withdraw(Bitcoin(10))
got := wallet.Balance()
want := Bitcoin(10)
if got != want {
t.Errorf("got %s want %s", got, want)
}
})
}
Withdraw가 정의되어 있지 않아 test가 실패할것이므로 정의해주고 go test가 실패하는것을 확인한 후 pass하도록 내부로 구현해준다.
func (w *Wallet) Withdraw(amount Bitcoin) {
w.balance -= amount
}
test코드에서 assert하는 부분에 중복이 있으므로 코드를 리팩토링 해보자.
func TestWallet(t *testing.T) {
assertBalance := func(t testing.TB, wallet Wallet, want Bitcoin) {
t.Helper()
got := wallet.Balance()
if got != want {
t.Errorf("got %s want %s", got, want)
}
}
t.Run("Deposit", func(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(Bitcoin(10))
assertBalance(t, wallet, Bitcoin(10))
})
t.Run("Withdraw", func(t *testing.T) {
wallet := Wallet{balance: Bitcoin(20)}
wallet.Withdraw(Bitcoin(10))
assertBalance(t, wallet, Bitcoin(10))
})
}
만약 계좌잔액보다 더 많은 금액을 인출하면 어떻게 될까? 지금코드는 잔액보다 많은 금액을 인출하지 않는다고 가정하고 작성한 상태이다. 사실 잔액보다 많은 금액을 Withdraw함수를 이용해서 인출하려고한다면 문제가 있다고 표현하는게 적절하다.
Go에서는 보통 이런 상황에서 함수에서 err를 반환하여 호출자가 확인하고 그에 따른 동작을 하도록 한다. 함수가 err를 반환한다고 가정하고 test코드를 작성하면 아래와 같이 작성할 수 있다.
t.Run("Withdraw insufficient funds", func(t *testing.T) {
startingBalance := Bitcoin(20)
wallet := Wallet{startingBalance}
err := wallet.Withdraw(Bitcoin(100))
assertBalance(t, wallet, startingBalance)
if err == nil {
t.Error("wanted an error but didn't get one")
}
})
잔액보다 많은 금액을 인출하려고 하면 Withdraw에서 error를 반환해야 한다. 만약 반환한 error가 nil이면 test는 실패하였다고 판단한다.
nil은 다른언어에서 null과 같은것이다. error는 nil이 될 수 있는데 Withdraw의 반환형이 error인 interface이기 때문이다. 함수에서 인자나 반환값이 interface라면 해당인자나 반환값은 nill값이 될 수 있다.
nil은 null과 같아서 만약 값에 접근하려고 하면 runtime panic을 던지므로 반드시 nil여부를 확인해줘야 한다. go test를 수행하면 에러가 날것이다. 코드를 아래와 같이 고쳐준다.
func (w *Wallet) Withdraw(amount Bitcoin) error {
w.balance -= amount
return nil
}
위의 코드는 무조건 nil을 반환하므로 test는 항상 실패한다. 하지만 nil이 반환되었을 때 test가 실패하는것을 확인하는것도 중요하다. 이제 통과하기위한 코드를 작성해보자.
func (w *Wallet) Withdraw(amount Bitcoin) error {
if amount > w.balance {
return errors.New("oh no")
}
w.balance -= amount
return nil
}
errors.New는 message를 인자로 받아 새로운 error를 만든다. 이 문법을 쓰려면 errors를 import해야 한다. 이제 test가 통과했으니 리팩토링을 해보자. error체크하는 부분도 helper메소드로 추출할 수 있다.
assertError := func(t testing.TB, err error) {
t.Helper()
if err == nil {
t.Error("wanted an error but didn't get one")
}
}
t.Run("Withdraw insufficient funds", func(t *testing.T) {
startingBalance := Bitcoin(20)
wallet := Wallet{startingBalance}
err := wallet.Withdraw(Bitcoin(100))
assertError(t, err)
assertBalance(t, wallet, startingBalance)
})
Withdraw에서 단순하게 "oh no" error를 반환하는건 실제 코드에서 써먹을 수 없다. error가 사용자에게 반환된다고 가정하고 단순히 error의 존재여부가 아니라 원하는 종류의 message가 잘 반환되었는지 단언하도록 코드를 리팩토링해보자.
assertError := func(t testing.TB, got error, want string) {
t.Helper()
if got == nil {
t.Fatal("didn't get an error but wanted one")
}
if got.Error() != want {
t.Errorf("got %q, want %q", got, want)
}
}
t.Run("Withdraw insufficient funds", func(t *testing.T) {
startingBalance := Bitcoin(20)
wallet := Wallet{startingBalance}
err := wallet.Withdraw(Bitcoin(100))
assertError(t, err, "cannot withdraw, insufficient funds")
assertBalance(t, wallet, startingBalance)
})
helper메소드에서 error의 존재여부를 먼저 체크하고, 그 후 원하는 error 메시지가 반환되었는지를 체크하였다. t.Fatal은 호출하면 test가 멈춘다. 이렇게 작성한 이유는 error가 존재하지 않으면 더이상 단언할 필요가 없기 때문이다. 만약 이렇게 하지 않으면 그 다음 코드에서 nil인 got의 Error()에 접근하려고 하므로 panic이 발생한다.
go test를 수행하면 에러 메시지가 다르다고 test가 실패할것이다. 통과하도록 아래와 같이 Withdraw함수를 변경해주자.
func (w *Wallet) Withdraw(amount Bitcoin) error {
if amount > w.balance {
return errors.New("cannot withdraw, insufficient funds")
}
w.balance -= amount
return nil
}
여기서 리팩토링할 요소가 하나 더 남아있는데 에러 메시지가 test코드와 실제 사용되는 code에 2번 정의되어 있다는점이다. 당장은 별 문제가 없을 수 있지만 만약 에러메시지의 세부내용을 변경해야한다면 두 지점에서 모두 변경해줘야 한다.
Go에서는 error도 값이기 때문에 한곳에서 내용을 참조하도록 리팩토링할 수 있다.
var ErrInsufficientFunds = errors.New("cannot withdraw, insufficient funds")
func (w *Wallet) Withdraw(amount Bitcoin) error {
if amount > w.balance {
return ErrInsufficientFunds
}
w.balance -= amount
return nil
}
var 키워드는 package의 전역값을 정의할 수 있게 해준다. 또 이렇게 하면 Withdraw함수도 더 깔끔해지는 효과가 있다. test코드도 같은 package내에 있으므로 참조가 가능하다.
func TestWallet(t *testing.T) {
t.Run("Deposit", func(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(Bitcoin(10))
assertBalance(t, wallet, Bitcoin(10))
})
t.Run("Withdraw with funds", func(t *testing.T) {
wallet := Wallet{Bitcoin(20)}
wallet.Withdraw(Bitcoin(10))
assertBalance(t, wallet, Bitcoin(10))
})
t.Run("Withdraw insufficient funds", func(t *testing.T) {
wallet := Wallet{Bitcoin(20)}
err := wallet.Withdraw(Bitcoin(100))
assertError(t, err, ErrInsufficientFunds)
assertBalance(t, wallet, Bitcoin(20))
})
}
func assertBalance(t testing.TB, wallet Wallet, want Bitcoin) {
t.Helper()
got := wallet.Balance()
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
func assertError(t testing.TB, got, want error) {
t.Helper()
if got == nil {
t.Fatal("didn't get an error but wanted one")
}
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
또 helper들을 main test 함수밖으로 위치를 옮겼는데, 이렇게 하면 helper 세부사항보다 무엇을 test 하고자 하는지(무엇을 assertion 하고자 하는지) 그리고 어떻게 사용해야 하는지가 더 명확히 드러나게 된다. 또 error 비교도 메시지가 아니라 ErrInsufficientFunds 와의 동등성만 비교해서 간단하게 확인할 수 있다.
- unchecked errors
Go컴파일러가 도움을 주긴 하지만 때로는 error 처리시 놓치는점을 완벽하게 커버할수는 없다. 여태까지 코드를 작성하면서 한 가지 시나리오가 테스트되지 않았는데 터미널에서 errcheck를 설치해보자.
go get -u github.com/kisielk/errcheck
그리고 errcheck . 을 실행하면 wallet_test.go:17:18: wallet.Withdraw(Bitcoin(10)) 이런 메시지가 나올것이다.
위의 메시지는 해당 라인에서 반환된 error가 체크되지 않았다는것을 알려준다. 해당 라인의 withdraw에서 실패하는 시나리오는 체크했지만 성공적으로 수행되어 error가 반환되지 않을때의 상황에 대해서는 체크하지 않았다. 이를 위해 최종적으로 아래와 같이 코드를 작성해준다.
func TestWallet(t *testing.T) {
t.Run("Deposit", func(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(Bitcoin(10))
assertBalance(t, wallet, Bitcoin(10))
})
t.Run("Withdraw with funds", func(t *testing.T) {
wallet := Wallet{Bitcoin(20)}
err := wallet.Withdraw(Bitcoin(10))
assertNoError(t, err)
assertBalance(t, wallet, Bitcoin(10))
})
t.Run("Withdraw insufficient funds", func(t *testing.T) {
wallet := Wallet{Bitcoin(20)}
err := wallet.Withdraw(Bitcoin(100))
assertError(t, err, ErrInsufficientFunds)
assertBalance(t, wallet, Bitcoin(20))
})
}
func assertBalance(t testing.TB, wallet Wallet, want Bitcoin) {
t.Helper()
got := wallet.Balance()
if got != want {
t.Errorf("got %s want %s", got, want)
}
}
func assertNoError(t testing.TB, got error) {
t.Helper()
if got != nil {
t.Fatal("got an error but didn't want one")
}
}
func assertError(t testing.TB, got error, want error) {
t.Helper()
if got == nil {
t.Fatal("didn't get an error but wanted one")
}
if got != want {
t.Errorf("got %s, want %s", got, want)
}
}
'Language > Go' 카테고리의 다른 글
Go - dependency injection (0) | 2021.12.31 |
---|---|
Go - maps (0) | 2021.12.31 |
Go - struct, method, interface (0) | 2021.12.24 |
Go - array, slice, test coverage (0) | 2021.12.04 |
Go - Iteration & Benchmark (0) | 2021.12.04 |
댓글