Golang에서 nullable한 값 잘 처리하기
Go언어에는 zero value
라는 것이 있다. 변수를 선언할 때, 따로 특정 값을 할당하지 않으면 자동으로 부여되는 값을 zero value
라고 한다.
string의 경우에는 ""
,
boolean은 false
,
숫자형은 0
,
함수, 포인터, 인터페이스, slice, 채널, 맵은 nil
이 zero value
이다.
보면 알다시피 string, boolean, 숫자 모두 nil
(다른 언어에서의 null
)이 아닌 다른값들이 zero value
로 선언되어 있기 때문에, 따로 초기화 하지 않아도 Nil Pointer Exception 이 나지 않는다. 이런 장점이 있는 반면, 값의 존재여부를 가지고 로직이 짜여있는 경우, 문제가 생길 수 있다. 대표적인 예로 DB가 있겠다. MySQL에 아래와 같은 값이 들어있다고 가정해보자.
SELECT * FROM students;
| id | name | math_score |
|----|--------|------------|
| 1 | billo | 0 |
| 2 | (null) | (null) |
여기서 아래와 같은 struct에 불러온다고 했을 때
type Student struct {
Id int32
Name string
MathScore int32
}
첫번째 row는 쉽게 가져올 수 있을 것이다.
하지만 두번째 row를 가져와 struct에 넣었다고 생각해보자. Name
필드에는 ""
값이 들어가있게 될 것이다. 이렇게 된다면, 실제로 ““을 입력한 값과 구분이 되지 않아 혼란이 생기게 된다. 숫자형 타입은 더 심각하다. 아직 MathScore
가 데이터베이스에 입력되지 않은 것인데, 0점으로 간주하고 로직이 돌아갈 수 도 있다.
이런 혼란을 피하기 위한 방법을 소개하려고 한다.
첫번째는 기본 자료형 대신 database/sql
패키지의 Null*
자료형을 사용하는 것이다.
예를 들어 위에 사용했던 struct는 아래처럼 재정의 할 수 있다.
import (
"database/sql"
)
type Student struct {
Id int32
Name sql.NullString
MathScore sql.NullInt32
}
이 sql.Null* 자료형은 아래처럼 정의되어 있는데, (문서)
type NullString struct {
String string
Valid bool // Valid is true if String is not NULL
}
여기의 Valid 필드가 NULL이 아닌지 여부를 들고 있다.
따라서 아래처럼 사용하면 된다. (문서)
var s NullString
err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&s)
...
if s.Valid {
// use s.String
} else {
// NULL value
}
이렇게 Valid 필드를 이용해서 null 분기를 탈 수 있다.
또다른 방법은 포인터 타입을 이용하는 것이다.
포인터의 zero value
는 nil
인 점을 이용해서, 기본 자료형 대신 기본 자료형의 포인터를 쓰면 된다.
type Student struct {
Id int32
Name *string
MathScore *int32
}
이렇게 포인터로 정의를 하고, 포인터가 nil
인지를 비교하여 값을 존재유무를 판단하는 방식이다.
이렇게 포인터로 정의를 하게 되면 해당 필드에 값을 넣을 때 주의해야 한다.
아래의 예시를 보자. (Playground)
package main
import (
"fmt"
)
type Student struct {
Id int32
Name *string
MathScore *int32
}
func main() {
student := Student{
Id: 1,
Name: nil,
MathScore: &int32(10),
};
fmt.Println(*student.MathScore);
}
문제 없는 코드처럼 보이지만, &int32(10)
(또는 &10
등 & + 값
) 은 잘못된 코드이다. 왜냐하면 &는 Addressable
한 값에 대해서만 사용 가능한 연산자 이기 때문인데, 자세한 설명은 이 질문 으로 갈음한다.
이 문제를 해결하기 위해서 다양한 해결방법이 있고, 이는 위의 질문 에 대답으로 많이 달려있다. 나는 아래와 같은 helper function을 만드는 것을 추천한다.
func Int32(x int32) *int32 {
return &x;
}
이 helper function 을 만들어 놓고 프로젝트에서 돌려 사용하면, 쉽게 포인터 형태의 value를 만들 수 있다.
위의 helper function을 사용하면 아래와 같다. (Playground)
package main
import (
"fmt"
)
type Student struct {
Id int32
Name *string
MathScore *int32
}
func Int32(x int32) *int32 {
return &x;
}
func main() {
student := Student{
Id: 1,
Name: nil,
MathScore: Int32(10),
};
fmt.Println(*student.MathScore);
}
포인터를 이용한 방법은 database/sql
패키지를 쓰는 것에 비해서 쉽게 사용이 가능하다는 장점이 있지만, nil-safeness를 포기하게 되므로 trade-off가 있는 방법이다.
DB 뿐만 아니라 JSON 등 많은 경우에 nullable한 값을 사용하게 될 수 있다. 이 때에도 위의 트릭들을 이용하면 고통받지 않고 처리할 수 있다.