AI가 4번 고쳐도 못 고친 버그

AI 코딩 어시스턴트와 함께 기능을 개발하고 있었다. 코드 리뷰를 4차례 반복했는데, 매번 새로운 edge case가 나왔다. AI는 매 라운드마다 “이번엔 해결했다”고 했지만, 다음 라운드에서 같은 부류의 다른 변종이 튀어나왔다.

5번째 리뷰를 돌리기 직전, 한 가지 질문을 던졌다:

“애초에 이 문제가 발생하지 않게 할 순 없는 거야?”

그 한 질문으로 접근 방식 자체가 바뀌었고, 4번의 패치 동안 쌓인 보정 코드가 전부 불필요해졌다.

상황

학습 관리 앱에서, 학생의 공부 시간 기록을 10분 단위 히트맵으로 보여주는 화면이 있다. 여기서 “기록된 시간의 할당 과목을 변경하는” 기능을 만들고 있었다.

┌──────┬────┬────┬────┬────┬────┬────┐
│      │ :00│ :10│ :20│ :30│ :40│ :50│
├──────┼────┼────┼────┼────┼────┼────┤
│ 15시 │████│████│████│████│    │    │  ← 영어 (파랑)
│ 16시 │▓▓▓▓│▓▓▓▓│▓▓▓▓│    │    │    │  ← 수학 (노랑)
└──────┴────┴────┴────┴────┴────┴────┘
         ↑ 학생이 이 범위를 선택해서 과목을 변경

핵심 문제: UI는 10분 단위(슬롯)로 표시하지만, 서버의 실제 데이터는 임의의 초 단위 시각이다.

  히트맵 (UI) 서버 데이터
시작 15:00:00 15:02:15
종료 16:10:00 16:07:30
정밀도 10분

프론트엔드에서 슬롯을 선택하면, 이걸 다시 정확한 시각으로 변환해서 서버에 보내야 했다. 여기서 문제가 시작됐다.

AI가 만든 것

AI가 설계한 구조:

프론트: 정확한 시각 → 10분 슬롯(표시용) → 다시 시각으로 역변환 → 서버에 전송

이건 JPEG 압축과 같다. 15:02:15를 10분 슬롯으로 바꾸면 15:00이 된다. 이걸 다시 시각으로 되돌리면 15:00:00이 나온다. 원래의 2분 15초는 영원히 유실된다.

이 간극에서 4가지 버그가 연쇄적으로 발생했다.

4번의 패치, 같은 부류의 버그

1차: UTC 오프셋

슬롯을 시각으로 변환할 때 KST(+9)를 수동으로 계산했다. 자정을 넘어가는 경우 날짜가 하루 밀렸다.

AI의 수정: timezone 라이브러리를 도입해서 변환.

2차: 문자열 포맷

서버가 15:02:15를 보내는데 프론트가 15:02:15.000과 비교했다. 불일치로 매칭 실패.

AI의 수정: 비교 전 normalize 함수 추가.

3차: 경계 검증

10분 슬롯의 바깥 경계(시작/끝)에서 서버의 validation이 reject했다. 슬롯 15:00~15:10이 실제 데이터 15:02:15~16:07:30의 범위를 벗어났기 때문.

AI의 수정: clamping 로직 추가 — 범위 밖이면 실제 데이터의 경계로 보정.

4차: 서버 검증 실패

보정된 시각이 서버의 정밀한 validation에 걸렸다. 서버는 원본 데이터의 정확한 시각을 알고 있으니, 프론트가 보낸 “보정된” 시각이 어정쩡하게 어긋났다.

AI의 수정: 더 정교한 clamping + 엣지케이스 처리.


4차 수정이 끝났을 때 AI는 이렇게 정리했다:

“slot index ↔ 실제 시각 변환 레이어가 본질적으로 lossy하기 때문입니다. clamping으로 해결했으니 이제 그 간극은 닫혔습니다.”

동작은 했다. 하지만 코드에는 slotToUtcTime(), normalizeTimestamp(), clampToWorkLogBoundary() 같은 보정 함수가 켜켜이 쌓여 있었다.

한 가지 질문

나는 물었다:

“애초에 lossy하지 않게 할 순 없는 거야?”

AI가 멈춰서 처음부터 다시 생각하더니, 이런 답을 내놨다:

“프론트에서 서버로 보내는 데이터가 슬롯의 역변환이 아니라, 원본 데이터의 ID + 분할 지점이면 됩니다. 역변환 자체가 없어지니 lossy 문제가 소멸합니다.”

Before: 프론트가 시각을 계산해서 보냄 (lossy)
  → slotToUtcTime(69) → "15:30:00" → 서버에 전송 → 서버 reject

After: 프론트가 의도만 보냄 (lossless)
  → { workLogId: 42, splitPoints: ["15:30"] } → 서버가 원본으로 구성

프론트는 “여기서 잘라줘”라고만 말하고, 서버가 자기가 갖고 있는 정확한 시각을 기준으로 처리한다. slotToUtcTime, normalizeTimestamp, clampToWorkLogBoundary — 전부 삭제.

AI는 왜 이 질문을 못 했나

4번의 리뷰 동안 AI가 한 일:

  1. 버그를 정확히 진단했다
  2. 해당 버그를 정확히 고쳤다
  3. “이번엔 해결됐다”고 보고했다
  4. 다음 버그가 나오면 1로 돌아갔다

매 단계가 올바른 행동이었다. 하지만 한 단계 위로 올라가서 “이 레이어가 왜 필요하지?”라고 묻는 행위가 없었다.

이건 AI의 한계라기보다, 패턴의 함정이다. 코드 리뷰에서 “이 함수에 버그가 있다”고 하면, 자연스러운 반응은 “그 함수를 고치자”이지 “그 함수가 존재해야 하나?”가 아니다. 사람도 같은 함정에 빠진다. 다만 사람은 짜증이라는 신호가 있다. “또야?”라는 감정이 “뭔가 근본적으로 잘못된 건 아닌가?”라는 의문으로 이어진다.

AI에게는 짜증이 없다. 10번째 패치도 1번째 패치와 같은 태도로 수행한다.

이 경험에서 가져간 원칙

1. 패치가 반복되면 레이어를 의심하라

복잡성을 추가해서 간극을 메우고 있다면, 그 간극이 존재해야 하는지 먼저 확인하라.

증상 물어야 할 질문
validation fix가 반복된다 이 데이터가 이 validation을 통과해야 하는가?
format 변환 버그가 반복된다 이 변환이 존재해야 하는가?
null check가 반복된다 이 null이 가능해야 하는가?

“가설을 바꿔라”는 좋은 규칙이지만, 같은 층위에서 맴돌 수 있다. 진짜 필요한 건 아키텍처를 재검토하는 것이다.

2. 클라이언트는 의도를, 서버는 정확한 값을

소프트웨어 아키텍처에서 이 패턴은 이미 이름이 있다:

  • Task-Based UI: 클라이언트가 변경된 상태를 보내는 게 아니라, 작업 의도를 보낸다
  • Intent-Based API: PUT /resource {fields} 대신 POST /resource/action {intent}
  • CQRS Command: 유저 의도를 표현하는 Command 객체. 서버가 상태 전이를 결정

핵심 원리: 권위 있는 데이터를 가진 쪽이 계산을 수행한다. 클라이언트가 서버의 데이터를 추측해서 재구성하는 순간, lossy 변환이 시작된다.

3. AI와 일할 때는 메타인지를 사람이 담당하라

AI는 “이 코드에 버그가 있다 → 고치자”를 매우 잘 한다. 하지만 “이 코드가 존재해야 하나?”는 잘 못 묻는다. 이건 지시를 충실히 따르는 것과, 지시 자체를 의심하는 것의 차이다.

AI 코딩 어시스턴트와 협업할 때, “한 발짝 물러서서 전체 그림을 보는 역할”은 아직 사람의 몫이다. AI가 3번째 패치를 제안할 때, “잠깐, 왜 이걸 3번이나 고치고 있지?”라고 끼어드는 것. 그게 가장 값비싼 기여다.


이 경험 이후, 나는 AI 코딩 어시스턴트의 설정에 이런 규칙을 추가했다:

2회 수정 실패 시 아키텍처 재검토: 같은 영역 수정 2번 실패하면, 가설이 아니라 아키텍처를 의심. “이 변환/레이어/검증이 존재해야 하는가?”

그리고 디버깅 키워드가 감지되면 “이 레이어가 존재해야 하는가?”를 자동으로 리마인드하는 훅도 만들었다. AI에게 “짜증”을 시뮬레이션해주는 셈이다.

References