Skip to content

nil 포인터 오류

시작

어느 날 코드를 작성하는 과정에서 여러 객체를 닫기 위해 Close() 메서드를 호출해야 했습니다. 아래 코드와 같습니다.

go
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 메서드를 구현했으므로 더 간결하게 작성할 수 있을 것 같아서 슬라이스에 넣고 반복문으로 처리했습니다.

go
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
}

이렇게 하면 더 나아 보였습니다. 그럼 실행해 보겠습니다.

go
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 도 있을 것입니다. 이러한 경우는 주로 인터페이스에서 발생합니다. 아래 간단한 예제를 보겠습니다.

go
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 과 같지 않습니다. 리플렉션을 사용하여 정확히 무엇인지 확인해 보겠습니다.

go
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 이어야 합니다. 인터페이스의 유형은 실제로 포인터이기 때문입니다.

go
type iface struct {
  tab  *itab
  data unsafe.Pointer
}

유형을 우회하여 직접 값이 nil 인지 판단하려면 리플렉션을 사용할 수 있습니다. 아래가 예제입니다.

go
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 도 사용 가능합니다. 아래 예제를 보겠습니다.

go
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 이 발생합니다.

go
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 서버를 시작할 때 다음과 같이 작성할 수 있습니다.

go
http.ListenAndServe(":8080", nil)

nil Handler 를 직접 전달하면 http 라이브러리가 기본 Handler 를 사용하여 HTTP 요청을 처리합니다.

TIP

관심 있는 분들은 이 영상을 보시기 바랍니다 Understanding nil - Gopher Conference 2016, 매우 명확하고 이해하기 쉽게 설명되어 있습니다.

Golang by www.golangdev.cn edit