사이드프로젝트 slecs

타임아웃·경계값·null 입력 버그를 방어 코드로 수정

목차

버그 하나가 묘하게 오래 걸렸다. 정상 케이스로 돌리면 멀쩡한데 간헐적으로 터진다는 리포트가 처음 들어왔을 때, 처음엔 타이밍 이슈나 환경 차이 정도로 생각했다. 재현이 안 되면 대부분 그런 류거든. 근데 이번엔 아니었고, 조건을 좁혀가다 보니 명확한 재현 케이스가 있었다.

결론부터 말하면 비즈니스 로직 영역에서 null 체크 누락과 기본값 처리 결함이 복합적으로 작용하고 있었고, 거기에 타임아웃 상황에서의 상태 불일치까지 얽혀 있었다. 어느 한 쪽만 고쳐서는 해결이 안 되는 구조였다.

재현 조건이 명확해지기까지

처음 몇 시간은 솔직히 헛발질이었다. 로그 없이 코드만 들여다보면서 "여기겠지, 아니면 저기겠지"를 반복했는데 전부 틀렸다. 짐작으로 좁혀지지 않아서 결국 방향을 바꿨다. 입력-출력 데이터 흐름 자체를 따라가는 방식으로.

재현 조건은 세 가지로 정리됐다:

  • 특정 값이 null 또는 빈 값인 경우
  • 경계값 입력 시 - 0, 음수, 또는 처리 가능한 최대값 근방
  • 타임아웃 상황에서 상태가 엇갈리는 경우

겉으로 보면 세 케이스가 독립적인 것 같지만 원인은 하나로 연결돼 있었다. null 체크 누락과 잘못된 기본값 처리가 맞물려 있고, 타임아웃 후에 상태 초기화가 빠져 있어서 오염된 값이 다음 호출까지 이어지는 구조였다.

이런 복합 원인 버그가 제일 잡기 어렵다. 단일 원인이면 고치고 확인하면 되는데, 두 가지 이상이 얽혀 있으면 하나를 수정한 뒤 테스트했을 때 "됐다"고 착각하기 쉽다. 마침 그날 테스트에서 경계값 케이스를 빠뜨렸다면 나머지 재현 조건은 다음 번까지 그냥 남는다. 운이 좋으면 발견하고, 운이 나쁘면 다음 리포트 올 때까지 모른 채로 간다.

이 과정에서 가장 도움이 됐던 건 "가정을 가정으로 두지 않는다"는 원칙이었다. '이 값이 null로 올 리 없다'고 넘어갔으면 원인을 훨씬 늦게 찾았을 거다. 실제로 찍어보니 null이 오고 있었다.

방어 코드를 어디에 어떻게 넣었나

수정 자체는 생각보다 단순했다. 오래 걸린 건 원인을 찾는 과정이었지 코드 변경이 아니었다.

null-safe 처리와 경계값 검증은 이런 패턴으로 정리된다:

def process_value(value):
    # null / 빈 값 방어
    if value is None or value == "":
        return default_result()

    # 경계값 검증
    if value < 0 or value > MAX_THRESHOLD:
        raise ValueError(f"허용 범위를 벗어난 입력: {value}")

    # 정상 로직
    return _do_actual_logic(value)

언어마다 표현은 다르지만 패턴은 같다. 입력을 믿지 말고 실제로 검증하고, 거기서 걸러진 케이스는 명시적으로 처리한다. '아마 정상 범위겠지'를 전제로 로직을 짜면 언젠가 반드시 터진다. 특히 외부에서 들어오는 값이나, 여러 코드 경로에서 공용으로 쓰는 함수일수록 이 원칙이 중요하다.

타임아웃 케이스는 조금 다른 이야기다. 로직이 중간에 끊겼을 때 상태를 어떻게 남기냐의 문제인데, 이 상태가 잘못 남으면 다음 호출에서 오염된 값을 그대로 참조하게 된다. 해결 방향은 두 가지 중 하나다. 타임아웃 후처리에서 상태를 명시적으로 초기화하거나, 시작 시점에 이전 상태를 재설정하고 들어가거나. 어느 쪽이 맞냐는 시스템 특성마다 다른데, 이번엔 후처리 시점에 초기화하는 방식을 택했다. 시작 시점에 재설정하면 타임아웃 여부를 판단하는 로직이 따로 필요해져서 복잡도가 늘어나는 트레이드오프가 있었다.

수정 범위를 정리하면 이렇다:

재현 조건 원인 수정 방식
null / 빈 값 입력 null 체크 누락 null-safe guard 추가
경계값 (0, 음수, max 근방) 기본값 처리 로직 결함 명시적 range 검증
타임아웃 후 상태 불일치 상태 초기화 누락 타임아웃 후처리 보강

수정 후에는 세 케이스 전부 동일 조건으로 재테스트했다. 그리고 비즈니스 로직 영역에서 유사한 패턴이 있는 코드도 같이 훑었다. 같은 실수가 다른 함수에도 있을 가능성이 높고, 하나만 고친 채 두는 건 의미가 없으니까. 비슷한 패턴이 보이는 곳 몇 군데는 선제적으로 같이 정리했다.

로그 보강도 이번 수정에 포함했다. 버그 픽스에만 집중했다면 필요 없을 수 있는데, 재현에 시간이 걸린 이유 중 하나가 중간 상태 기록이 부족했던 거라서 같이 챙겼다. 다음에 비슷한 이슈가 생겼을 때 입출력 값이 남아 있으면 재현 조건 찾는 단계가 훨씬 빨라진다. 당장의 버그 픽스만 생각하면 로그는 부수적이지만, 코드는 계속 돌아가고 다음 이슈는 언제든 생긴다.

이번에 다시 확인한 디버깅 순서

팀에서 종종 나오는 말 중에 "코드를 읽지 말고 데이터를 읽어라"는 게 있다. 이번에도 코드만 보면서 원인을 찾으려다 시간을 날렸고, 데이터 흐름을 따라가는 방식으로 전환하고 나서야 빠르게 좁혀졌다.

디버깅 순서를 억지로 정리하면 이렇다:

  1. 로그부터 본다 - 스택 트레이스와 실제 입출력 값을 먼저 확인한다. 코드 읽기는 그 다음이다. 코드는 의도를 보여주지 실제 동작을 보여주진 않는다.
  2. 재현 조건을 확정한다 - "가끔 된다"는 단서가 될 수 없다. 어떤 입력에서, 어떤 상태에서 발생하는지 딱 잘라야 한다. 이게 확정되기 전엔 수정에 들어가지 않는 게 낫다.
  3. 이진 탐색 - 의심 범위를 절반씩 줄여나간다. 처음부터 전체 코드를 읽으면서 찾으려 하면 효율이 없다. 어느 계층, 어느 함수에서 문제가 생기는지 먼저 좁힌다.
  4. 가정을 실제로 검증한다 - '이 값은 절대 null이 아닐 거야' 같은 말은 찍어보기 전까지는 의견일 뿐이다. 단언문이나 로그로 확인하고 나서 다음 단계로 넘어간다.

이 순서가 체화되면 디버깅 시간이 줄어드는 건 맞다. 근데 막상 급하면 1번부터 건너뛰고 코드부터 보게 되는데, 이번에도 그게 시간을 날린 원인이었다. 급할수록 순서를 지키는 게 결국 더 빠르다는 걸 알면서도 매번 같은 실수를 한다.

방어 코드를 넣는 것 자체는 어렵지 않다. 어렵고 시간이 걸리는 건 어디에 무슨 방어 코드가 필요한지를 파악하는 과정이다. 그게 디버깅이고, 그 과정을 빠르게 하는 건 결국 가정 없이 데이터를 보는 습관이다.

댓글 0

첫 댓글 달아줘.