Go의 nil 검사, 많을수록 안전할까? 오히려 위험 신호일 수 있습니다

한눈에 보는 핵심
Go에서 nil 검사는 패닉을 막는 든든한 방어막처럼 느껴집니다. 하지만 konradreiche.com의 글은 정반대의 통찰을 던집니다. 잘못된 위치에 흩어진 nil 검사는 안전장치가 아니라, 코드가 "무엇이 nil일 수 있는지"를 스스로 설명하지 못한다는 위험 신호라는 것이죠.
핵심을 먼저 정리하면 이렇습니다.
- 필수 의존성(예: Redis 클라이언트)은 내부 메서드가 아니라 생성·초기화 지점에서 검증해야 합니다.
- 외부에서 들어오는 요청 값은 HTTP 핸들러·RPC·큐 같은 경계 계층에서 검증하고, 내부 로직은 그 보장을 신뢰해야 합니다.
- 불가능해야 할 상태를 조용히 허용하면, 실패가 침묵·지연되고 결국 관측 인프라로 신호를 복원하는 2차 비용을 치르게 됩니다.
왜 내부 메서드의 nil 검사가 문제일까
RateLimiter가 *redis.Client를 필드로 갖고, Allow 메서드 안에서 r.redis != nil을 확인한다고 해봅시다. 겉보기엔 안전합니다. 하지만 생각해보면 이상합니다.
Redis 클라이언트가 nil이라면, 그 문제는
Allow를 호출하는 시점이 아니라 객체를 만든 시점에 이미 발생한 것입니다.
내부 메서드에서 nil을 확인한다는 건, 생성에 실패한 상태로도 계속 돌아가는 것을 "허용 가능한 정상 상태"처럼 취급한다는 뜻입니다. 이건 코드가 객체의 출처, 초기화 책임, 그리고 "nil이면 안 된다"는 불변 조건(invariant)을 잃어버렸다는 신호죠.
생성자 검사만으로도 부족하다
그렇다면 생성자에서 막으면 될까요?
func NewRateLimiter(client *redis.Client) (*RateLimiter, error) {
if client == nil {
return nil, errors.New("client is nil")
}
// ...
}
분명 더 낫습니다. 하지만 완전한 해법은 아닙니다. nil 포인터가 이 함수까지 "전달되었다"는 사실 자체가, 이미 잘못된 상태가 시스템에 들어왔다는 의미이기 때문입니다.
진짜 오류 처리는 초기화 지점에서 이뤄져야 합니다.
redisClient, err := NewRedisClient(addr)
if err != nil {
return nil, fmt.Errorf("NewRedisClient: %w", err)
}
// 이 지점을 통과했다면 redisClient는 항상 유효합니다.
limiter := NewRateLimiter(redisClient)
이렇게 하면 RateLimiter 생성자가 굳이 에러를 반환할 필요조차 없어집니다. 만약 저장소가 일시적으로 사용 불가능한 상태를 허용해야 한다면, nil을 그대로 전파하지 말고 항상 non-nil인 래퍼 타입으로 감싸 재시도·성능 저하(degradation) 로직을 내부에 캡슐화하세요.
이건 데이터베이스의 NOT NULL이나 외래 키 제약과 같은 원리입니다. 잘못된 행이 애초에 존재할 수 없다면, 모든 쿼리가 매번 데이터를 다시 확인할 필요가 없으니까요.
외부 계층 vs 내부 계층
요청 객체처럼 외부에서 매번 들어오는 값은 고정된 의존성과 다릅니다. HTTP 핸들러, RPC, 큐 컨슈머 등 다양한 경로로 들어오죠. 그렇다고 Allow(ctx, req) 내부에서 req == nil을 확인하는 건 앞서 본 의존성 검사와 똑같은 실수입니다.
검증은 신뢰할 수 없는 바이트가 *Request 같은 내부 타입으로 바뀌는 경계 지점에 있어야 합니다.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
req, err := DecodeRequest(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// 여기를 지난 req는 유효합니다. 이후로는 신뢰합니다.
h.limiter.Allow(r.Context(), req)
}
경계를 통과한 데이터는 곧 시스템의 불변 조건이 됩니다. 그래서 가장 안쪽 Allow는 nil 검사 없이 실제 로직에만 집중할 수 있습니다.
조용한 실패의 진짜 비용
작은 변경 때문에 프로그램을 멈추고 싶지 않아 nil 검사나 로그만 남기는 방식은 안정적으로 느껴집니다. 하지만 실제 선택지는 "크래시냐 계속 실행이냐"가 아니라 "큰 소리로 실패하기냐 조용히 실패하기냐" 입니다.
명시적으로 반환된 에러에는 세 가지 미덕이 있습니다.
- 명확함 — 실패가 일어났음을 알 수 있다
- 즉시성 — 원인 가까이에서 알 수 있다
- 귀속성 — 호출자가 실패를 해당 작업과 연결할 수 있다
반대로 삼켜진 에러는 조용히 사라졌다가, 한참 뒤 엉뚱한 증상으로 나타납니다. 잘못된 상태로 살아남는 호출이 늘어날수록 원인과 증상 사이의 간격은 점점 벌어지죠. 게다가 사라진 신호를 감지하려고 메트릭·대시보드·알림 같은 관측 인프라를 새로 만드는 2차 비용까지 떠안게 됩니다.
그래서, nil 검사는 언제 정당한가
nil 검사 자체가 나쁜 건 아닙니다. 다음 두 경우는 정상입니다.
- 문서화된 경계에서 신뢰할 수 없는 입력을 보호할 때
- 의도적인 선택적(optional) 상태를 모델링할 때
반대로 프로그램이 불가능하다고 간주하는 상태를 조용히 처리하는 nil 검사는 의심해야 합니다. 검사가 코드 곳곳에 흩어져 있다면 둘 중 하나입니다. 정당한 경계 보호이거나, 아니면 코드베이스가 불변 조건을 세우지 못한 설계 문제이거나.
댓글에서 나온 인사이트
원문에 달린 토론도 곱씹어 볼 만합니다.
- 에러는 래핑하자:
fmt.Errorf("NewRedisClient: %w", err)처럼 호출 스택이 풀리며 맥락이 누적되게 하면 디버깅이 훨씬 쉬워집니다. 다만 Go에는 표준 스택 추적이 없어, 결국 에러 문자열을 grep하며 유일성을 기대하게 된다는 한계도 지적됩니다. - 언어 차원의 한계: Rust의
Option<T>, C#의 널 가능 타입처럼 명시적 nullability가 있었다면 이 문제는 거의 사라졌을 거라는 의견이 많습니다. Go는 컴파일러가 아니라 모든 프로그래머의 머릿속에서 "이 포인터는 안전한가"를 추론하도록 강제한다는 게 근본 문제죠. - assert 활용론: 임베디드 펌웨어 경험자는 흩뿌린 null 체크 대신 assert를 권합니다. 디버깅이 쉽고 커버리지상 분기로 잡히지 않으며 기대 조건을 명확히 전달하기 때문입니다.
마치며
결국 메시지는 하나로 모입니다. 당장 검사를 추가하는 것보다, 그 검사가 대신하고 있는 불변 조건을 세우고 신뢰 가능한 보장으로 바꾸는 것이 진짜 작업이라는 점입니다. nil 검사를 줄이는 게 목표가 아니라, 애초에 nil일 수 없는 구조를 만드는 것이 핵심입니다.