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 valuenil 인 점을 이용해서, 기본 자료형 대신 기본 자료형의 포인터를 쓰면 된다.

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한 값을 사용하게 될 수 있다. 이 때에도 위의 트릭들을 이용하면 고통받지 않고 처리할 수 있다.