Go에서 과도한 nil 포인터 검사
1 week ago
12
- 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 검사가 곳곳에 나타난다면 두 경우 중 하나임
- 신뢰할 수 없는 경계 입력을 보호하는 정상적인 코드
- 코드베이스가 불변 조건을 세우지 못한 설계 문제
- 어떤 매개변수도 신뢰할 수 없는 시스템에서는 당장 검사를 추가해야 할 수 있지만, 실제 작업은 그 검사가 대신하고 있는 불변 조건을 세우고 신뢰 가능한 보장으로 바꾸는 것임
-
Homepage
-
개발자
- Go에서 과도한 nil 포인터 검사