nil 포인터 오류
시작
어느 날 코드를 작성하는 과정에서 여러 객체를 닫기 위해 Close() 메서드를 호출해야 했습니다. 아래 코드와 같습니다.
type A struct {
b B
c C
d D
}
func (a A) Close() error {
if a.b != nil {
if err := a.b.Close(); err != nil {
return err
}
}
if a.c != nil {
if err := a.c.Close(); err != nil {
return err
}
}
if a.d != nil {
if err := a.d.Close(); err != nil {
return err
}
}
return nil
}하지만 이렇게 많은 if 문으로 판단하는 것이 우아하지 않다고 느꼈습니다. B, C, D 모두 Close 메서드를 구현했으므로 더 간결하게 작성할 수 있을 것 같아서 슬라이스에 넣고 반복문으로 처리했습니다.
func (a A) Close() error {
closers := []io.Closer{
a.b,
a.c,
a.d,
}
for _, closer := range closers {
if closer != nil {
if err := closer.Close(); err != nil {
return err
}
}
}
return nil
}이렇게 하면 더 나아 보였습니다. 그럼 실행해 보겠습니다.
func main() {
var a A
if err := a.Close(); err != nil {
panic(err)
}
fmt.Println("success")
}결과는 뜻밖에도 프로그램이 충돌했습니다. 오류 메시지는 다음과 같으며, nil 수신자에 메서드를 호출할 수 없다는 내용입니다. 반복문의 if closer != nil 이 필터링 역할을 하지 못한 것 같습니다.
panic: value method main.B.Close called using nil *B pointer위 예제는 제가曾经 겪었던 버그를 간소화한 것입니다. 많은 초보자들이 처음에 저와 같은 실수를 할 수 있습니다. 아래에서 어떻게 이런 일이 발생하는지 설명하겠습니다.
인터페이스
이전 장에서 언급했듯이, nil 은 참조 유형의 제로값입니다. 예를 들어 슬라이스, map, 채널, 함수, 포인터, 인터페이스의 제로값입니다. 슬라이스, map, 채널, 함수의 경우 모두 포인터로 간주할 수 있으며, 포인터가 구체적인 구현을 가리킵니다.

하지만 인터페이스만 다릅니다. 인터페이스는 두 가지 요소로 구성됩니다: 유형과 값.

nil 을 변수에 할당하려고 하면 컴파일이 통과되지 않고 다음과 같은 메시지가 표시됩니다.
use of untyped nil in assignment내용은 대략 untyped nil 값을 가진 변수를 선언할 수 없다는 것입니다. untyped nil 이 있다면 당연히 typed nil 도 있을 것입니다. 이러한 경우는 주로 인터페이스에서 발생합니다. 아래 간단한 예제를 보겠습니다.
func main() {
var p *int
fmt.Println(p)
fmt.Println(p == nil)
var pa any
pa = p
fmt.Println(pa)
fmt.Println(pa == nil)
}출력
<nil>
true
<nil>
false결과는 매우 이상합니다. 분명히 pa 의 출력은 nil 인데 nil 과 같지 않습니다. 리플렉션을 사용하여 정확히 무엇인지 확인해 보겠습니다.
func main() {
var p *int
fmt.Println(p)
fmt.Println(p == nil)
var pa any
pa = p
fmt.Println(reflect.TypeOf(pa))
fmt.Println(reflect.ValueOf(pa))
}출력
<nil>
true
*int
<nil>결과에서 볼 수 있듯이, 이는 실제로 (*int)(nil) 입니다. 즉, pa 에 저장된 유형은 *int 이고 실제 값은 nil 입니다. 인터페이스 유형의 값에 대해 같음 연산을 수행할 때 먼저 유형이 같은지 판단하고, 유형이 다르면 바로 같지 않다고 판단한 후 값이 같은지 다시 판단합니다. 이 인터페이스 판단 로직은 cmd/compile/internal/walk.walkCompare 함수를 참조할 수 있습니다.
따라서 인터페이스가 nil 이 되려면 값이 nil 이어야 하고 유형도 nil 이어야 합니다. 인터페이스의 유형은 실제로 포인터이기 때문입니다.
type iface struct {
tab *itab
data unsafe.Pointer
}유형을 우회하여 직접 값이 nil 인지 판단하려면 리플렉션을 사용할 수 있습니다. 아래가 예제입니다.
func main() {
var p *int
fmt.Println(p)
fmt.Println(p == nil)
var pa any
pa = p
fmt.Println(reflect.ValueOf(pa).IsNil())
}IsNil() 을 사용하면 값이 nil 인지 직접 판단할 수 있어 위와 같은 문제가 발생하지 않습니다. 따라서 평소 사용 시 함수의 반환값이 인터페이스 유형이라면 제로값을 반환하고 싶을 때 직접 nil 을 반환하는 것이 좋습니다. 구체적인 구현의 제로값을 반환하지 마십시오. 비록 해당 인터페이스를 구현했더라도 결코 nil 과 같지 않기 때문에 이 예제의 오류가 발생할 수 있습니다.
요약
위 문제를 해결했으니 이제 아래 예제들을 살펴보겠습니다.
구조체의 수신자가 포인터 수신자일 때 nil 도 사용 가능합니다. 아래 예제를 보겠습니다.
type A struct {
}
func (a *A) Do() {
}
func main() {
var a *A
a.Do()
}이 코드는 정상적으로 실행되며 널 포인터 오류가 발생하지 않습니다.
슬라이스가 nil 일 때 길이와 용량을 접근할 수 있으며 요소를 추가할 수도 있습니다.
func main() {
var s []int
fmt.Println(len(s))
fmt.Println(cap(s))
s = append(s, 1)
}map 이 nil 일 때도 접근할 수 있지만 nil map 은 읽기 전용이며 쓰기를 시도하면 panic 이 발생합니다.
func main() {
var s map[string]int
i, ok := s[""]
fmt.Println(i, ok)
fmt.Println(len(s))
// 쓰기를 시도하면 panic 이 발생합니다
s["a"] = 1 // panic: assignment to entry in nil map
}위 예제의 nil 특성은 Go 초보자에게 혼란스러울 수 있습니다. nil 은 위 몇 가지 유형의 제로값, 즉 기본값을 나타냅니다. 기본값은 기본 동작을 보여야 하며, 이는 Go 설계자가 의도한 바입니다: nil 을 더 유용하게 만들고 바로 널 포인터 오류를 던지지 않는 것입니다. 이 철학은 표준 라이브러리에도 반영되어 있습니다. 예를 들어 HTTP 서버를 시작할 때 다음과 같이 작성할 수 있습니다.
http.ListenAndServe(":8080", nil)nil Handler 를 직접 전달하면 http 라이브러리가 기본 Handler 를 사용하여 HTTP 요청을 처리합니다.
TIP
관심 있는 분들은 이 영상을 보시기 바랍니다 Understanding nil - Gopher Conference 2016, 매우 명확하고 이해하기 쉽게 설명되어 있습니다.
