Go에서 nil 포인터를 담은 error가 nil이 아닌 이유

Go 인터페이스 값은 동적 타입과 동적 값을 함께 가집니다. nil 포인터를 error로 반환하면 err != nil이 되는 이유를 명세로 확인하고, 안전한 반환 패턴과 검사 방법의 한계를 정리합니다.

gointerfacenilerror

함수가 실패하지 않았는데도 호출한 쪽의 if err != nil이 참이 되는 경우가 있습니다. 함수 안에서 *MyError 타입 변수를 만들고, 오류가 없으면 그 변수를 nil인 채로 return했을 때입니다. 포인터는 분명 nil인데 error로 받은 값은 nil이 아닙니다.

Go FAQ가 “Why is my nil error value not equal to nil?“라는 항목으로 따로 다루는 문제입니다. 이 글은 언어 명세와 FAQ의 규칙만으로 이 동작을 설명하고, 어디까지 피할 수 있는지 정리합니다. 기준 문서는 2026-10-15에 확인한 Go 언어 명세(Language version go1.27, May 26, 2026)입니다. 아래 규칙은 특정 버전에서 새로 생긴 것이 아니라 인터페이스 값의 기본 정의에서 나옵니다.

인터페이스 값에는 타입도 들어 있다

명세의 “Variables” 절은 인터페이스 타입 변수가 두 가지 타입을 가진다고 설명합니다. 선언에 적힌 정적 타입과, 실행 중에 대입된 값의 (인터페이스가 아닌) 타입인 동적 타입입니다. 대입된 값이 미리 선언된 식별자 nil이면 동적 타입은 없습니다. nil에는 타입이 없기 때문입니다.

명세에 실린 예제가 핵심을 그대로 보여 줍니다.

var x interface{}  // x is nil and has static type interface{}
var v *T           // v has value nil, static type *T
x = 42             // x has value 42 and dynamic type int
x = v              // x has value (*T)(nil) and dynamic type *T

마지막 줄에서 v는 nil 포인터지만, x에는 동적 타입 *T와 값 (*T)(nil)이 함께 들어갑니다.

FAQ는 같은 내용을 구현 쪽 그림으로 설명합니다. 인터페이스는 내부적으로 타입 T와 값 V 두 요소로 이루어지고, 인터페이스 값이 nil인 것은 V와 T가 둘 다 설정되지 않았을 때뿐입니다. nil *int를 인터페이스에 넣으면 포인터 값과 상관없이 내부 타입은 *int가 되므로 (T=*int, V=nil), 그 인터페이스 값은 nil이 아닙니다. 이 두 칸 모델은 FAQ가 “schematically”라고 밝힌 설명용 그림이고, 명세가 요구하는 것은 동적 타입과 동적 값이라는 개념입니다.

== nil 비교가 실제로 묻는 것

명세의 “Comparison operators” 절에 따르면 두 인터페이스 값은 동적 타입이 같고 동적 값이 같거나, 둘 다 값이 nil일 때 같습니다. err == nil에서 오른쪽 nil은 동적 타입이 없는 nil 인터페이스 값으로 비교됩니다. 그래서 동적 타입이 *MyError로 채워진 err은 포인터가 nil이어도 nil과 같지 않습니다.

반환도 같은 규칙을 따릅니다. “Return statements” 절은 반환식이 함수 결과 타입의 해당 요소에 대입 가능해야 한다고 정하고, “Assignability” 절은 x가 인터페이스 T를 구현하면 T에 대입 가능하다고 정합니다. 결과 타입이 error인 함수에서 *MyError 값을 반환하면, 그 값은 동적 타입 *MyError를 가진 error 값이 됩니다.

문제가 생기는 코드

아래 예제는 FAQ의 returnsError 예제와 같은 구조입니다. Go 1.27로 실행하면 false를 출력한 뒤 runtime error: invalid memory address or nil pointer dereference 런타임 패닉으로 멈춥니다.

package main

import "fmt"

type MyError struct {
	Op string
}

func (e *MyError) Error() string {
	return e.Op + " failed"
}

func find(ok bool) error {
	var p *MyError
	if !ok {
		p = &MyError{Op: "find"}
	}
	return p // ok가 true여도 nil이 아닌 error가 반환된다
}

func main() {
	err := find(true)
	fmt.Println(err == nil) // false
	fmt.Println(err.Error()) // run-time panic
}

find(true)는 nil 포인터 p를 반환하지만, 호출한 쪽에서 err의 동적 타입은 *MyError입니다. 따라서 err == nil은 false입니다.

마지막 줄의 패닉은 원인을 착각하기 쉽습니다. 명세의 “Selectors” 절은 nil 인터페이스 값에서 메서드를 호출하면 런타임 패닉이 난다고 정하지만, 여기서 err은 nil 인터페이스가 아닙니다. 그래서 (*MyError).Error가 nil 리시버로 정상 호출되고, 패닉은 메서드 본문의 e.Op에서 납니다. 같은 절은 nil 포인터에서 구조체 필드를 평가하면 런타임 패닉이 난다고 정합니다. 스택 트레이스도 main.(*MyError).Error의 return e.Op + " failed" 줄을 가리킵니다. 메서드가 리시버를 역참조하지 않는다면 패닉 없이 문자열을 반환할 수도 있습니다. 어느 쪽이든 호출한 쪽은 실패하지 않은 함수를 실패한 것으로 처리하게 됩니다.

이름 있는 결과 매개변수를 써도 같습니다. func find() (err error) 안에서 err = p로 nil 포인터를 대입하고 return하면, 대입 시점에 err의 동적 타입이 *MyError가 됩니다.

안전한 반환 패턴

FAQ가 권하는 해법은 성공했을 때 nil을 명시적으로 반환하는 것입니다.

func find(ok bool) error {
	if !ok {
		return &MyError{Op: "find"}
	}
	return nil
}

return nil의 nil은 결과 타입 error에 대입되면서 nil 인터페이스 값이 됩니다. 동적 타입이 채워질 일이 없습니다.

FAQ는 오류를 반환하는 함수의 시그니처에 *MyError 같은 구체 타입 대신 error 타입을 쓰라고도 권합니다. 예로 드는 것이 os.Open입니다. 이 함수는 nil이 아닐 때 항상 구체 타입 *os.PathError를 반환하지만, 시그니처는 error입니다. 구체 포인터 타입을 반환하는 함수의 결과를 다른 곳에서 error 변수에 옮겨 담는 순간 같은 문제가 다시 생기기 때문입니다.

이미 nil일 수 있는 포인터를 받아서 인터페이스로 넘겨야 한다면, 넘기기 전에 포인터 자체를 검사합니다.

func wrap(p *MyError) error {
	if p == nil {
		return nil
	}
	return p
}

p == nil은 포인터끼리의 비교이고, 명세상 두 포인터 값은 둘 다 nil이면 같습니다. 인터페이스로 바뀌기 전에 검사하므로 의도대로 동작합니다.

받는 쪽에서 검사하는 방법과 그 한계

이미 nil 포인터를 담은 인터페이스 값을 받았다면, 명세만으로 할 수 있는 검사는 구체 타입을 알고 있을 때로 한정됩니다.

  • 구체 타입의 nil과 비교: 명세는 인터페이스가 아닌 타입 X의 값 x와 인터페이스 값 t를 비교할 수 있고, t의 동적 타입이 X와 같고 동적 값이 x와 같으면 둘이 같다고 정합니다. 그래서 err == (*MyError)(nil)은 위 예제에서 true입니다.
  • comma-ok 타입 단언: p, ok := err.(*MyError)는 동적 타입이 *MyError이면 ok가 true이고 p에 담긴 포인터가 들어옵니다. 이어서 p == nil로 포인터를 검사할 수 있습니다. 단언이 성립하지 않으면 ok는 false, p는 *MyError의 제로 값이고 패닉은 나지 않습니다.
  • 타입 스위치의 case nil: 명세에 따르면 case nil은 스위치 대상이 nil 인터페이스 값일 때만 선택됩니다. 동적 타입이 *MyError인 값은 case nil이 아니라 case *MyError로 갑니다.

이 방법들은 모두 대상 타입을 하나씩 적어야 합니다. “어떤 포인터 타입이든 nil을 담고 있는가”를 일반적으로 묻는 연산자는 명세에 없습니다. 이 글은 명세와 FAQ 범위만 다루므로 reflect 패키지를 쓰는 검사는 다루지 않습니다. 실용적으로는 받는 쪽에서 걸러 내기보다, 보내는 쪽에서 nil 포인터가 인터페이스로 들어가지 않게 하는 편이 확실합니다.

정리

  • 인터페이스 값은 동적 타입과 동적 값을 함께 가지며, 둘 다 없을 때만 nil입니다.
  • nil 포인터를 인터페이스에 대입하거나 반환하면 동적 타입이 채워지므로 == nil은 false가 됩니다.
  • 성공 경로에서는 return nil을 명시하고, 오류를 반환하는 함수의 시그니처는 error로 둡니다.
  • nil일 수 있는 포인터는 인터페이스로 바꾸기 전에 검사합니다. 바뀐 뒤에는 이 글에서 본 비교, 타입 단언, 타입 스위치로는 구체 타입을 적어야만 잡아낼 수 있습니다.

FAQ의 마지막 문장이 요점을 짧게 정리합니다. 인터페이스에 어떤 구체 값이라도 저장되었다면 그 인터페이스는 nil이 아닙니다.

출처

  • The Go Programming Language Specification (Language version go1.27, May 26, 2026), 2026-10-15 확인: https://go.dev/ref/spec
  • Go FAQ, “Why is my nil error value not equal to nil?”, 2026-10-15 확인: https://go.dev/doc/faq

블로그로