# Go에서 과도한 nil 포인터 검사

> Clean Markdown view of GeekNews topic #30900. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=30900](https://news.hada.io/topic?id=30900)
- GeekNews Markdown: [https://news.hada.io/topic/30900.md](https://news.hada.io/topic/30900.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-06-28T09:18:07+09:00
- Updated: 2026-06-28T09:18:07+09:00
- Original source: [konradreiche.com](https://konradreiche.com/blog/excessive-nil-pointer-checks-in-go/)
- Points: 1
- Comments: 1

## Topic Body

- Go의 **nil 검사**는 패닉을 막을 수 있지만, 반복되는 위치가 잘못되면 코드가 “무엇이 nil일 수 있는가”를 스스로 설명하지 못함
- Redis 클라이언트 같은 **필수 의존성**을 내부 메서드에서 검사하면, 생성 실패를 정상 실행 경로처럼 취급하게 됨
- 생성자에서 nil을 거르는 것만으로는 부족하며, `NewRedisClient(addr)` 같은 **초기화 지점**에서 실패를 즉시 처리해야 함
- 요청 객체처럼 외부에서 들어오는 값은 HTTP 핸들러, RPC 디스패치, 큐 컨슈머 같은 **경계 계층**에서 검증하고 내부 로직은 그 보장을 신뢰해야 함
- 불가능해야 하는 상태를 조용히 허용하면 실패가 **침묵·지연·모호**해지고, 나중에 메트릭·대시보드·알림으로 사라진 신호를 복원하는 비용이 생김

---

### nil 검사가 항상 방어적 프로그래밍은 아님
- 프로덕션에서 패닉을 막으려면 `deferred recover`보다 앞서 입력, 범위, 포인터를 확인하는 **방어적 프로그래밍**이 필요함
- 올바른 위치의 nil 검사는 안전한 코드를 만들지만, 잘못된 위치의 검사는 어떤 값이 nil일 수 있는지 추적하지 못한다는 신호가 됨
- 생성 코드에서 이런 패턴이 더 자주 보이지만, 새 현상도 아니고 AI에만 한정되지도 않음
- nil 검사는 싸고 안전해 보이지만, 다음 독자에게 “이 값은 nil일 수 있다”는 메시지를 남기며 종종 잘못된 의미를 전달함

### 의존성 nil 검사의 문제
- `RateLimiter`가 `*redis.Client`를 필드로 갖고 `Allow` 내부에서 `r.redis != nil`을 확인하는 코드는 겉보기에는 안전해 보임
- Redis 클라이언트가 nil이라면 문제는 `Allow` 실행 시점이 아니라 **생성 시점**에 이미 발생한 것임
- 내부 메서드에서 nil을 확인하면 생성 실패 상태로 계속 동작하는 것이 허용 가능한 상태처럼 처리됨
- 이런 검사는 객체의 출처, 초기화 책임, nil이 불가능해야 하는 **불변 조건**을 코드가 잃어버렸다는 신호가 됨

### 생성자 nil 검사만으로는 부족함
- `NewRateLimiter(client *redis.Client)`에서 `client == nil`이면 에러를 반환하는 방식은 더 낫지만 완전한 해법은 아님
- nil 포인터가 함수까지 전달됐다는 사실 자체가 이미 잘못된 상태가 시스템에 들어왔다는 뜻임
- 실제 오류는 Redis 클라이언트를 만드는 초기화 지점에서 처리해야 함
  - `redisClient, err := NewRedisClient(addr)`에서 에러가 발생하면 즉시 반환해야 함
  - 이후 `NewRateLimiter(redisClient)`에는 유효한 클라이언트만 전달되어야 함
- 이렇게 하면 `RateLimiter` 생성자가 에러를 반환할 필요도 사라짐
- 저장소가 일시적으로 사용할 수 없는 상태를 허용해야 한다면 nil을 전파하지 말고, 항상 non-nil인 외부 타입으로 감싸 재시도나 성능 저하 처리를 내부에 캡슐화해야 함
- 이는 데이터베이스의 `NOT NULL`이나 외래 키 제약과 비슷함
  - 잘못된 행이 처음부터 존재하지 못하면 모든 쿼리가 데이터를 다시 확인하지 않아도 됨
  - 런타임 값도 한 번 불변 조건을 세우면 나머지 코드는 반복 검사를 피할 수 있음

### 조용한 실패의 비용
- 작은 변경 때문에 프로그램을 중단시키고 싶지 않아 nil 검사나 로그만 남기는 방식은 안정적으로 느껴질 수 있음
- 실제 선택지는 “크래시 대 계속 실행”보다 **큰 소리로 실패하기 대 조용히 실패하기**에 가까움
- 명시적으로 반환된 에러에는 세 가지 성질이 있음
  - **명확함**: 실패가 발생했음을 알 수 있음
  - **즉시성**: 원인 가까이에서 실패를 알 수 있음
  - **귀속성**: 호출자가 실패를 해당 작업과 연결할 수 있음
- 삼켜진 에러는 반대로 작동함
  - 실패가 조용히 사라짐
  - 더 많은 코드가 실행된 뒤 나중에 증상으로 나타남
  - 증상이 보일 때는 원인을 식별하기 어려워짐
- 프로그램이 잘못된 상태로 살아남는 호출이 늘어날수록 원인과 증상 사이의 간격도 커짐
- 올바른 수정은 실패를 지역적으로 숨기는 것이 아니라, 에러가 어디로 전파되고 어디에서 요청 거부, 작업 실패, 재시도, 알림, 종료로 바뀌는지 파악하는 것임
- 에러 반환이 시스템의 필요 이상을 중단시킨다면 문제는 해당 함수가 아니라 **에러 처리 경계**에 있음

### 사라진 신호를 다시 만드는 2차 비용
- 실패가 조용해지면 실제로 무슨 일이 일어났는지 알 수 없어 버그가 숨을 수 있음
- 그러면 동작의 부재를 감지하기 위해 메트릭, 대시보드, 알림 같은 관측 인프라를 만들어야 함
- 불가능하거나 처리되지 않은 상태를 허용할 때마다, 버린 신호를 나중에 관측으로 복원하는 **엔지니어링 비용**을 지불하게 됨

### 외부 계층과 내부 계층의 역할
- 실행이 시작되고 외부 데이터가 들어오는 곳은 **외부 계층**이며, 그 호출이 도달하는 더 깊은 코드는 내부 계층임
- 실행 초반에는 아무것도 보장되지 않지만 아직 수행한 작업도 없음
- 초기화 과정에서는 프로그램이 의존하는 요소를 설정하고, 각 요소가 반드시 필요하거나 일시적으로 없어질 수 있는지 결정해야 함
- 설계는 항상 사용 가능한 의존성 쪽으로 기울고, 중간에 사라질 수 있는 의존성은 최소화해야 함

### 요청 범위 데이터는 경계에서 검증해야 함
- 요청 객체, 요청 필드, 요청에서 파생된 값은 고정된 의존성과 다름
- 요청은 HTTP 핸들러, RPC, 큐, 테스트 헬퍼, 다른 패키지 등 외부에서 매 호출마다 들어옴
- `RateLimiter.Allow(ctx, req)` 내부에서 `req == nil`을 확인하는 것도 의존성 nil 검사와 같은 실수임
- 요청은 `Allow`에서 처음 들어온 것이 아니라 더 앞단의 전송 경계에서 들어와 코드 내부를 이동한 값임
- `Allow` 같은 내부 함수에서 다시 검증하면 외부 계층이 보장해야 할 것을 깊은 함수가 재검증하게 되고, 불확실성이 퍼짐

### 경계 검증 후 내부 로직은 불변 조건을 신뢰함
- nil 검사는 신뢰할 수 없는 바이트가 `*Request` 같은 내부 타입으로 바뀌는 **경계 지점**에 있어야 함
- HTTP 핸들러 예시에서는 `DecodeRequest(r)`가 실패하면 `http.StatusBadRequest`로 응답하고 반환함
- 검증이 끝난 뒤의 `req`는 유효한 값이며, 이후 `h.limiter.Allow(r.Context(), req)`는 그 값을 신뢰할 수 있음
- 외부에서 받은 데이터는 제어할 수 없기 때문에 경계에서 nil과 필요한 제약을 검사하는 것이 타당함
- 경계를 통과한 데이터는 내부 타입과 비즈니스 로직으로 매핑되고, 그 이후에는 시스템의 **불변 조건**이 됨
- 최종 `Allow`는 nil 검사 없이 실제 로직에 집중함
  - `userID := GetUserID(req)`
  - `userID == ""`이면 `false, nil` 반환
  - 그렇지 않으면 `r.checkLimit(ctx, userID)` 호출
- 빈 `userID` 검사도 HTTP 계층으로 옮길 수 있지만, 예시에서는 속도 제한기가 해당 정책을 소유하도록 둠

### 반복 nil 검사는 새 분기와 새 동작을 만든다
- 이런 구조의 시스템은 추론하기 쉽고 변경하기도 쉬움
- 반대로 불변 조건이 없는 시스템은 곳곳에 검사를 추가한 뒤 각 검사마다 무엇을 해야 하는지 정해야 함
- 각 nil 검사는 새로운 분기이며, 각 분기는 존재하지 말아야 할 상태에 대한 동작을 새로 정의하게 만듦
- nil 검사는 문서화된 경계를 강제하거나 의도적인 선택적 상태를 모델링할 때 유용함
- 프로그램이 불가능하다고 간주하는 상태를 조용히 처리하는 nil 검사는 의심해야 함
- nil 검사가 곳곳에 나타난다면 두 경우 중 하나임
  - 신뢰할 수 없는 경계 입력을 보호하는 정상적인 코드
  - 코드베이스가 불변 조건을 세우지 못한 설계 문제
- 어떤 매개변수도 신뢰할 수 없는 시스템에서는 당장 검사를 추가해야 할 수 있지만, 실제 작업은 그 검사가 대신하고 있는 불변 조건을 세우고 신뢰 가능한 보장으로 바꾸는 것임

## Comments



### Comment 60550

- Author: neo
- Created: 2026-06-28T09:18:08+09:00
- Points: 1

###### [Lobste.rs 의견들](https://lobste.rs/s/z7eoo7/excessive_nil_pointer_checks_go) 
- 다른 Go 프로그래머들에게 다시 부탁하는데, **에러를 래핑**해 줬으면 함  
  ```go  
  redisClient, err := NewRedisClient(addr)  
  if err != nil {  
    return nil, fmt.Errorf("Couldn't obtain new RedisClient: %w", err)  
  }  
  ```  
  호출 스택이 풀리면서 에러에 대한 맥락이 누적되어야 함
  - 더 관용적인 예시는 이렇게 보임  
    ```go  
    redisClient, err := NewRedisClient(addr)  
    if err != nil {  
      return nil, fmt.Errorf("NewRedisClient: %w", err)  
    }  
    ```  
    이후 각 계층은 에러가 **어디서** 났는지만 덧붙이고, 가장 안쪽 `err`가 **무엇이** 일어났는지를 알려주는 구조가 좋음
  - 아쉽게도 에러에 대한 통일된, 사실상 표준인 **스택 추적**이 없음  
    실제로 “래핑”은 에러 문자열을 `grep`하고, 그 문자열이 유일하길 바라며, 문자열을 유일하게 만들기 위해 억지로 창의력을 발휘하는 일이 되기 쉽다
  - 에러 스택이 너무 길다고 불평하는 사람도 있지만, 대부분은 이런 메시지가 **조치 가능하고 유용**하다고 봄  
    예전에 네트워킹 제품에서 어떤 엔지니어가 수백 개의 에러 메시지를 고치는 데 한 달을 썼는데, 로그에 “What the f-ck?”가 찍히는 건 최종 사용자에게 도움이 안 됐기 때문임  
    그 메시지들을 유용하게 바꾸고, 위와 같은 이유로 에러 스택도 추가해야 했음
  - 요즘 방식은 기억상으로 [errors.Join](https://pkg.go.dev/errors#Join)을 쓰는 쪽임

- Go는 여기서 두 가지 문제를 만든다고 봄  
  1. Go에 명시적인 **널 가능성(nullability)** 이 있었다면 이 문제 자체가 거의 사라졌을 것임  
  2. 이름 붙일 수 있는 타입의 **제로 초기화**를 막을 방법이 없어 보여서, 실수는 언제든 숨어들 수 있음
  - 글의 이 문장이 근본 문제를 잘 드러낸다고 느낌  
    “무엇을 넘겨받을지 통제할 수 없으니, 그 경계에서 `nil`인지 확인하는 건 합리적이다”라는 부분임  
    외부 입력에는 맞는 말이지만, 모든 포인터가 `nil` 가능하면 코드베이스 안에서 안전한 경계를 추적하는 데 추론이 필요함  
    Go의 문제는 이 추론을 컴파일러가 아니라 모든 프로그래머의 머릿속에서 하도록 강제한다는 데 있음

- Rust에는 `Option&lt;T&gt;`가 있고 C#에는 **널 가능 타입**이 있음  
  2026년에 이런 문제를 아직 겪고 있을 필요가 없다고 봄
  - 반대편 입장에서 보자면, “없음”이나 “누락”을 간결하게 표현하는 능력은 특히 JSON 같은 임의 자료구조를 다룰 때 매우 유용함  
    언어에서 문법은 보통 덜 흥미로운 요소지만, 좋아하는 스크립트 언어에서 `foo.bar.baz`를 쓰는 게 Rust의 `foo.unwrap().bar.unwrap().baz`보다 훨씬 쉽다  
    Rust를 좋아하는 입장에서도 그렇고, Go와 Rust가 종종 같은 묶음으로 취급되지만 Go는 C 프로그래머가 다시 만든 스크립트 언어에 훨씬 가깝다고 봄  
    그래도 언어가 null을 쓴다면 기본값은 **널 불가**가 더 낫다. 특히 `?`나 `.?`처럼 짧은 문법이 있다면 큰 프로젝트에서 문법적 부담을 감수할 만함
  - 포인터를 안 쓰면 null도 없으니 만세… 😭

- Go는 **널 불가 객체**를 잘 모델링하는 언어가 아니라고 이해하고 있음  
  이 점에서는 C와 비슷하고, `Option&lt;T&gt;`는 `T*`로 표현될 수 있지만 `T*`가 반드시 `Option&lt;T&gt;`를 뜻하지는 않음  
  전반적으로 글에는 동의함. 임베디드 펌웨어 회사에서 일할 때도 C++ 코드에 null 체크를 여기저기 쓰지 말고 **assert**를 쓰자고 설득했음  
  assert는 디버깅하기 쉽고, 커버리지 관점에서 분기로 잡히지 않으며, 읽는 사람에게 기대 조건을 명확히 전달함. 릴리스 빌드에서는 제외되므로 더 효율적이기도 함  
  다만 Go에서는 nil 역참조가 이미 좋은 디버깅 정보를 주므로 assert의 이점이 C++만큼 크지는 않다고 이해하고 있음
  - Go의 nil 역참조는 C의 null 포인터 역참조보다 낫게 결정적으로 panic을 내지만, 실제 포인터가 역참조될 때에야 에러가 나기 때문에 그렇게 훌륭하진 않음  
    글의 예시라면 `checkLimit` 깊숙한 곳에서 터질 것이고, 거기서 nil의 출처를 역추적해야 함. 시스템이나 아키텍처에 따라 꽤 복잡할 수 있음  
    그래서 `NewRateLimiter` 바로 안에서 assert하는 건 분명 이득이 있음. 예시 코드에서는  
    ```go  
    if client == nil {  
        return nil, errors.New("redis client is nil")  
    }  
    ```  
    를  
    ```go  
    if client == nil {  
        panic("redis client is nil")  
    }  
    ```  
    로 바꾸는 셈임  
    다만 Go 팀은 [assertion에 강하게 반대](https://go.dev/doc/faq#assertions)하고, panic도 이상적이지 않아서 잡히지 않으면 전체 런타임을 크래시시킴
  - null 체크와 assert는 완전히 다르다고 봄  
    assert는 “이 상태는 유효하지 않다”는 뜻이고, assert 매크로는 릴리스 빌드에서 그 null 체크를 무동작으로 만들 수 있음  
    assert 매크로 정의 방식에 따라 정의되지 않은 동작 관련 최적화가 일어나 이후 체크가 제거되고 헷갈리는 크래시로 이어질 수 있음  
    예를 들어 `assert(p); if (!p) { ... }`에서 뒤의 체크가 제거되는 식의 assert 정의를 본 적이 있음  
    무작정 “null 체크하지 말고 assert를 써라”는 건 **상태 불변식**에는 맞을 수 있지만, 에러 확인에는 맞지 않음

- 결론 부분에 좋은 조언이 있음  
  `nil` 체크가 곳곳에 나타난다면 둘 중 하나임. 신뢰할 수 없는 경계 입력을 방어하는 정상적인 코드이거나, 코드베이스가 **불변식**을 세우지 못한 설계 문제임  
  어떤 매개변수도 신뢰할 수 없는 시스템에서 해법은 체크를 더 추가하는 게 아님. 당장은 그래야 할 수 있지만, 진짜 일은 그 체크들이 대신하고 있는 불변식을 세우고, 두려움에서 나온 잡음을 시스템이 의존할 수 있는 보장으로 점차 바꾸는 것임  
  이건 nil 체크를 넘어선다고 봄. 시스템의 “잎” 부분에 체크나 방어 코드를 추가하는 건, 불변식이 부족하거나 제대로 강제되지 않는 증상을 처리하려는 방식으로 자주 나타남  
  “체크 하나 더 추가”는 기본값으로 삼기 쉽지만 확장 한계가 있다. 어느 순간 체크 로직이 기능 로직보다 많아지고 전체 복잡도가 걷잡을 수 없이 커짐  
  버그 한두 개를 막기 위한 추가 체크는 보통 해롭지 않지만, 체크 수와 복잡도가 너무 늘어난다고 느껴질 때는 계속 잎만 고치기보다 한 발 물러서서 **근본 원인**을 찾는 편이 장기적으로 시스템과 유지보수자의 삶에 더 좋았음
  - 불변식을 assert하는 건 처음부터 그렇게 시작하고 계속 유지할 때 훌륭함  
    다만 개발자들이 **방어적 프로그래밍**을 멈추도록 훈련시키는 게 더 어려운 문제임

- 이런 불변식, 여기서는 **널 불가성** 같은 것은 Go보다 표현력이 풍부한 타입 시스템에서 훨씬 잘 모델링할 수 있음  
  이 주제에서 가장 좋아하는 글은 Alexis King의 2019년 글 [Parse, don't validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/)임  
  원칙은 어디에나 적용 가능하지만, Haskell의 타입 시스템에서는 정말 쉬워 보임. TypeScript에서 Alexis의 조언을 몇 년간 따르려 했지만 쉽지 않았음

- 요약하면 문제는 체크가 너무 많은 게 아니라, **nil을 값으로 감싸는 것**임

- 이 문제는 반복해서 나왔는데, **에러 처리가 일급 기능이 아닌 언어**의 결과라고 봄  
  기억상 다른 스레드에서도 나왔듯, 사실상 표준 린터들이 이런 구조를 강제하게 됨  
  이 nil 체크들이 논리적으로 나쁜지는 모르겠음. 많은 언어가 에러 처리를 내장하고 있고, 차이는 전파의 일관성과 단순성 정도임  
  에러를 내는 인터페이스에 대응하는 선택지는 대략 네 가지임: 처리하고 복구하기, 무시하기, 에러 전파하기, 에러를 버리고 자기 에러를 전파하기이며 마지막은 기존 에러를 래핑할 수도 있음  
  에러 처리가 일급 기능인 언어는 보통 2번과 3번을 쉽게 만들고, 현대 언어일수록 그렇다. 그래서 4번도 언어에 따라 꽤 깔끔해질 수 있음  
  1번은 그런 처리가 필요하다는 점을 더 명시적으로 만드는 것 말고는 일급 지원으로도 크게 도울 수 없음  
  근본적으로 함수가 에러를 낼 수 있다면 모든 언어는 구현 여부와 별개로 `{error,result} = functioncall()` 뒤에 `if (error) { ... }`를 하는 셈임  
  Go는 에러 처리가 일급이 아니어서 많은 함수가 선제적으로 `(result, err)` 튜플을 반환하고, 린터가 `err != nil` 체크를 사실상 강제하면서 코드가 그 패턴으로 가득 차는 인상을 줌  
  올바른 에러 처리를 언어가 직접 다루지 않는 건 언어 설계 결함이라고 보지만, 일단 그 위치에 있다면 이 모델이 아마 최선에 가까워 보임  
  Go 코드가 관용적으로 선택적 반환 타입을 써서 기능적으로 무시 가능한 에러와 “신경 써야 하는” 에러를 구분하는지는 잘 모르겠음. 그런 경우에도 항상 에러 타입을 반환하는 게 관용이라면 린터가 늘 이 패턴을 강제할 듯함  
  Go를 싫어하는 건 아니고 설계 선택 하나에 동의하지 않는 것뿐임. 거의 모든 언어의 설계 선택에는 불평할 수 있음  
  Go의 가장 큰 실수는 사실상 모든 곳에서 명시적 `err != nil` 체크가 기능적으로 필수이고, 그래서 린터들도 요구하게 된다는 점이라고 봄

- Go가 처음 나왔을 때도 수백 명이 이 전체 구조가 얼마나 우스운지 짚었음  
  하지만 언어가 큰 인기를 얻었고, Rob Pike가 더 잘 안다는 분위기 속에서 비판은 묵살됐음  
  이제야 사람들이 논리적인 근거로 정상적으로 논의하는 모습을 보니 좋음  
  이게 수십 년 전부터 나쁜 아이디어로 알려져 있지 않았던 것도 아닌데, Google이 하면 좋은 거겠지… 맞지?
  - Go 팬은 아니지만, 이런 프레이밍은 거슬림  
    “우스운 헛소리”라고 부르면 더 보고 싶다고 한 **논리적 사고** 자체를 억누르기 쉽기 때문임  
    어느 Oxide 팟캐스트였는지는 잊었지만 Bryan Cantrill이 “더 잘 싫어하기 위해 이것을 연구하고 싶다”는 식으로 말한 적이 있음  
    그런 의미에서 2010년대에 사람들이 왜 Go에 열광했는지 이해하고 싶음. 일부는 분명 과대광고였고, 당시 직장에서 개발자들이 왜 좋은지 설명하지 못하면서도 열광하는 모습을 직접 봤음  
    하지만 순수한 과대광고만은 아니었을 것임. 그 시절 Go를 쓰자는 가장 강한 [steel-man argument](https://www.merriam-webster.com/slang/steelman)는 무엇이었을까 궁금함
