버그 픽스 5번이 상태 플래그 12개로 이어졌다고 나란히 적은 어두운 표지

AI는 버그를 성실하게 고쳤다. 그래서 상태 플래그가 열두 개가 됐다

2026.08.13

지난주, 에디터의 한글 입력 처리 코드를 리팩토링하려다가 이상한 걸 발견했다. 상태 필드 열두 개가 파일 세 개에 흩어져 있었다. “지금 조합 중인가”, “미뤄 둔 키가 있는가”, “예약된 저장이 아직 유효한가” 같은 것들이다. 누가 이렇게 만들었는지 찾으려고 git 이력을 필드 하나하나까지 거슬러 올라갔다.

범인은 없었다. 일곱 달에 걸친 버그 픽스 다섯 번이 각각 한두 개씩 보탰고, 하나하나는 전부 그 순간에 옳은 수정이었다. 그리고 그 수정의 대부분은 AI 에이전트가 썼다.

그런데 그 결과물은 아무도 소유할 수 없는 코드였다. 열두 개를 단위 테스트가 하나도 지키지 않았고, “지금 입력 처리 상태가 뭐냐”라는 질문에 답하려면 파일 세 개를 동시에 읽어야 했다. 이 글은 그 열두 개가 어떤 문제를 풀려고 태어났는지, 왜 아무도 잘못하지 않았는데 이렇게 됐는지, 그리고 어떻게 걷어냈는지에 대한 기록이다.

한글 입력 중에는 끼어들면 안 된다

배경부터. 영문 a는 키를 누르면 곧바로 a가 들어간다. 한글 은 그렇지 않다. ㅎ → 하 → 한 세 단계를 거치고, 그동안 화면에 보이는 글자는 아직 확정되지 않은 임시 상태다. 이 임시 구간을 조합(composition)이라 부르고, 브라우저는 시작과 끝을 이벤트로 알려 준다.

compositionstart          "지금부터 오는 건 임시다"
  beforeinput  ㅎ
  beforeinput  하
  beforeinput  한
compositionend            "한으로 확정한다"

문제는 이 구간에 에디터가 화면의 문서 구조(DOM)를 고치면 조합이 깨진다는 것이다. 글자가 사라지거나, 자모가 중복되거나, 커서가 튄다.

그런데 조합 중에도 요청은 계속 들어온다. Enter가 눌리고, Cmd+Z(되돌리기)가 눌리고, 저장할 때가 된다. 즉시 처리하면 조합이 깨지고, 버리면 사용자의 의도가 사라진다. 남는 선택지는 하나다 — 미뤄 뒀다가 조합이 끝나면 처리한다. 그리고 미룬 일은 어딘가에 적어 둬야 한다.

처음엔 플래그 하나면 됐다.

플래그 둘이 열두 개가 되기까지

2025년 9월. 한글을 조합하는 중에 Cmd+Z를 누르면 조합이 깨지는 버그가 났다. 수정은 단순했다. “지금 조합 중인가”라는 플래그를 하나 만들고, 되돌리기 진입점에 가드를 넣었다. 미뤄 둔 요청을 적을 칸도 하나 딸려 왔다 — 플래그는 “지금은 안 된다”까지만 말해 주고, 조합이 끝났을 때 무엇을 실행할지는 따로 적어 둬야 하기 때문이다.

// 실제 코드(발췌) — 2025-09-24
/** IME 조합 진행 여부 */
private imeComposing = false

/** 조합 종료 후 실행할 Undo/Redo 요청 캐시. 마지막 의도만 유지한다. */
private deferredUndoRedoAction: 'undo' | 'redo' | null = null

async performUndo() {
  if (this.isIMEComposingActive) {
    this.deferUndoRedo('undo')   // 조합 중이면 미룬다
    return
  }
  // ...원래 있던 undo 실행...
}

필드 두 개, 가드 두 줄. 전체 수정이 95줄 추가에 4줄 삭제다. 커밋에는 AI 에이전트에게 준 작업 지시 파일이 그대로 함께 들어 있다 — 버그 하나, 지시 하나, 최소 수정 하나. 깔끔했고, 버그는 고쳐졌다.

석 달 뒤, 플래그가 플래그를 불렀다. 이번엔 조합 중의 Enter였다. 되돌리기와 달리 Enter는 “나중에 실행”으로 끝나지 않는다 — 미뤄 둔 키 이벤트를 조합이 끝난 뒤 다시 발생시켜야(재생) 줄바꿈이 마저 처리된다. 그런데 재생을 도입하는 순간 문제가 두 개 따라온다. 재생한 Enter와 브라우저가 원래 보내려던 Enter가 겹쳐서 줄이 두 번 나뉠 수 있고, 재생한 Enter가 다시 “조합 중이면 미룬다” 가드에 걸려 영원히 순환할 수 있다. 그래서 플래그 하나를 추가하려던 자리에 세 개가 추가됐다: 미뤄 둔 이벤트, 재생 직후 한 번 무시할 키, 그리고 “지금 재생 중”이라는 표시.

2026년 3월. 글자가 하나도 들어오지 않고 끝나는 조합이 있다는 게 밝혀졌다. “이번 조합에서 실제로 입력이 있었는가”라는 플래그가 추가됐다 — 없었다면 조합이 끝나도 변환과 저장을 건너뛰어야 하기 때문이다.

2026년 4월. 더 근본적인 사정이 발견됐다. 이 환경(macOS의 Chromium)에서는 조합이 끝나는 이벤트가 온 직후에도 최종 글자가 DOM에 아직 반영되지 않았을 수 있다. 그래서 조합이 끝나자마자 저장하면 안 되고, 한 프레임 기다렸다가 변환·저장을 실행해야 한다. “잠깐 뒤에 실행”이 생기는 순간 예약이 생기고, 예약이 생기는 순간 “이 예약이 아직 유효한가”를 판정할 정체성이 필요해진다. 상태가 한꺼번에 넷 늘었다: 조합마다 붙이는 번호, 지금 살아 있는 조합이 몇 번인지, 예약된 지연 실행, 그리고 예약들을 서로 구별하는 이름표까지.

5월. 예약 자체에서 버그가 났다. 한글을 빠르게 이어 치면 — 예약된 저장이 실행되기 전에 같은 줄에서 다음 조합이 시작되면 — 낡은 예약이 새 입력을 옛 내용으로 덮어써서 글자가 사라졌다. 예약에 무효화 표시가 추가됐다. 단, 같은 줄의 예약만 무효화하고 다른 줄의 예약은 건드리지 않는다는 조건과 함께.

일곱 달이 지나자 코드가 기억해야 하는 것이 이만큼이 됐다.

한글 입력을 다루기 위해 코드가 들고 있던 규칙 (누적)

  • 조합 중의 되돌리기는 즉시 실행하지 않고 끝난 뒤 실행한다. 여러 번 눌리면 마지막 것만 (2025-09)
  • 조합 중의 Enter는 끝난 뒤 다시 발생시킨다 (2025-12)
  • 재생 직후 브라우저가 또 보내는 같은 키는 한 번 무시한다 (2025-12)
  • 재생 중에는 “조합 중이면 미룬다” 규칙을 적용하지 않는다 (2025-12)
  • 입력이 없었던 조합은 끝나도 변환·저장하지 않는다 (2026-03)
  • 조합이 끝나도 바로 저장하지 않는다. 최종 글자가 늦게 올 수 있으니 한 프레임 기다린다 (2026-04)
  • 조합마다 번호를 붙이고, 지금 살아 있는 조합이 몇 번인지 기억한다 (2026-04)
  • 지연 실행 예약에는 이름표를 붙여 어느 것이 최신인지 구별한다 (2026-04)
  • 예약 실행 전에 같은 줄에서 새 조합이 시작되면 그 예약을 무효화한다. 다른 줄의 예약은 건드리지 않는다 (2026-05)

지어낸 목록이 아니다. 각 줄 옆의 날짜는 git 이력에서 그 규칙을 만든 버그 픽스 커밋의 날짜다. 그리고 규칙 하나가 곧 상태 필드 하나이고, 예약처럼 무거운 규칙은 필드 두셋을 차지한다. 그렇게 열두 개.

더 나쁜 것은, 이 열두 개가 한 파일에 있지도 않았다는 점이다. 이 에디터에서 입력 처리는 세 파일이 나눠 맡는다. 키가 눌리는 순간(keydown)을 받는 키보드 핸들러, 글자 입력과 조합 이벤트를 받는 입력 핸들러, 그리고 저장과 되돌리기의 진입점인 에디터 본체. 플래그는 각 파일이 자기가 급했던 것을 자기 자리에 만들었다. 조합 번호와 지연 실행 예약은 에디터 본체에, 미뤄 둔 키와 재생 관련 플래그는 키보드 핸들러에, “입력이 있었는가”는 입력 핸들러에.

그리고 서로의 것을 읽는다. 키보드 핸들러는 Enter를 미룰지 정하려면 에디터 본체에 “이 줄에 대기 중인 예약이 있느냐”고 물어야 한다. 입력 핸들러는 조합을 시작하고 끝낼 때마다 에디터 본체의 조합 기록을 고쳐야 한다. 지연 실행의 콜백은 에디터 본체의 무효화 표시와 입력 핸들러의 “입력이 있었는가”를 같이 봐야 실행할지 말지 알 수 있다. “지금 입력 처리 상태가 뭐냐”라는 질문 하나에, 파일 세 개를 읽어야 답이 나온다.

리팩토링 착수 시점에 실측해 보니: 이 상태들을 읽고 무언가를 판단하는 자리가 12곳, 상태를 직접 고쳐 쓰는 자리가 35곳이었다. 이 전부를 지키는 단위 테스트는 0개. 확인 수단은 앱을 통째로 부팅해서 돌리는 종단 테스트(e2e) 5개가 전부였다.

어느 수정도 잘못이 없었다

여기가 이 글의 요점이다. 위의 다섯 번의 수정을 하나씩 열어 봐도 비난할 곳이 없다. 형태는 전부 같다 — 필드를 추가하고, 진입점에 가드를 넣고, 끝. 구조는 한 번도 건드리지 않았다. 그리고 그 선택은 매번 옳았다. 세 가지 이유에서다.

첫째, 이 영역은 재현이 가장 비싸다. 한글 조합은 OS의 입력기가 자기 순서로 보내는 이벤트 시퀀스이고, 타이밍이 곧 내용이다. 버그 하나를 재현하려면 손으로 타이밍을 맞춰야 하고, 자동화하려 해도 오랫동안 진짜 조합을 만드는 테스트가 없었다 — 브라우저 엔진에게 실제 조합을 시키는 테스트가 처음 들어온 것이 2026년 5월이다.

둘째, 안전망이 없는 곳에서 구조 변경은 도박이다. 단위 테스트 0개인 상태 기계를 “이참에 정리”하다가 깨뜨리면, 깨진 걸 알아챌 방법조차 없다. 플래그 하나와 가드 두 줄은 영향 범위를 눈으로 검증할 수 있는 크기다.

셋째, 버그마다 요구하는 질문이 조금씩 달랐다. “조합 중인가”로 시작했지만, 다음 버그는 “이번 조합에서 입력이 실제로 있었나”를 물었고, 그다음은 “이 예약이 아직 최신인가”를, 그다음은 “같은 줄의 예약인가”를 물었다. 기존 플래그로 답할 수 없으니 새 플래그가 생긴다. 각각의 순간에는 그게 최소 수정이다.

그리고 네 번째 사정이 앞의 셋을 증폭한다. 이 수정들을 쓴 것이 대부분 AI 에이전트라는 점이다. 에이전트의 시야는 세션 단위다. 몇 달 전에 이전 세션이 같은 자리에 플래그를 하나 추가했다는 감각이 없고, “이 버그를 최소 수정으로 고쳐라”라는 지시에 매번 성실하게 응한다. 성실한 결과가 플래그 추가다.

아이러니는 최소 변경 원칙이 애초에 에이전트를 안전하게 쓰려고 만든 규율이라는 것이다. 에이전트가 버그 픽스를 핑계로 조용히 구조를 뒤엎는 것을 막으려는 장치다. 이 프로젝트의 에이전트 지침에 명문화된 것은 2026년 2월이고, 그 전에도 버그 하나에 지시 하나를 주는 워크플로가 같은 효과를 냈다 — 위 목록의 절반이 명문화 이전에 생겼다. 그 규율이 잘 지켜질수록 플래그는 정확히 하나씩, 아무 마찰 없이 쌓인다. 통제 장치는 각 수정의 폭발 반경을 줄였다. 누적은 그 장치의 관할이 아니었을 뿐이다.

문제는 원칙이 아니라 회계의 부재였다. 플래그 하나를 추가하는 비용은 그 커밋 안에서는 상수다 — 필드 한 줄, 가드 두 줄. 그런데 그 플래그를 읽어야 하는 자리는 기존 플래그들과의 조합으로 늘어난다. 키보드 핸들러가 에디터 본체의 예약을 물어야 하고, 입력 핸들러가 에디터 본체의 기록을 고쳐야 하고, 지연 실행 콜백이 두 파일의 상태를 같이 봐야 하는 순간 — 소유자가 셋이 되고 서로의 상태를 읽기 시작하는 순간 — “지금 상태가 뭐냐”는 질문은 어느 한 파일에서도 답할 수 없게 된다. 각 수정은 O(1)이었는데 이해 비용은 곱으로 자랐고, 그 비용은 어느 커밋에도 청구되지 않았다. 다음 버그를 고치러 온 사람(대부분은 다음 세션의 에이전트)이 매번 이자를 냈을 뿐이다.

걷어내기 — 플래그 열두 개에서 값 하나로

2026년 8월, 이걸 리팩토링했다. 걷어낸 것도 같은 AI와 함께였다 — 달랐던 것은 도구가 아니라 사람이 들고 간 질문이다. “이 버그를 고쳐라”가 아니라 “이 상태 묶음에 소유자를 만들자”였다. 방법의 상세는 별도 글감이라 여기서는 순서와 결과만 적는다. 순서가 이 글의 주제와 직결되기 때문이다.

착수 전에 안전망부터 실측했다. 그리고 예상이 하나 깨졌다 — 문서상 “최상위 안전망”이던 진짜 조합 e2e 3개가, 한 달 전의 제품 변경을 따라오지 못한 낡은 셋업 때문에 조합 입력에 도달조차 못 하고 꺼져 있었다. 고쳐서 되살린 뒤에야 착수했다. 안전망이 있다고 적힌 것과 그 안전망이 지금 도는 것은 다르다.

그다음 세 회전으로 나눠 돌았다. 회전마다 한 종류의 작업만 한다.

  1. 판정 순수화. 상태를 읽어 판단하는 12곳 중 11곳을, 값만 받아 값을 돌려주는 순수 함수로 옮기고 테스트 43개를 붙였다. 상태 자체는 제자리에 뒀다. 이 테스트들이 “바꾸기 전 동작”을 고정한다 — 구조를 옮긴 다음에 쓰는 테스트는 내가 방금 만든 동작을 기록할 뿐이라, 동작 보존의 증명은 반드시 바꾸기 전에 만들어야 한다.
  2. 이름표를 번호로. 예약을 구별하던 이름표가 코드에서는 빈 객체 {}였다 — 내용이 아니라 “서로 다른 물건”이라는 사실만으로 구별하는 자바스크립트의 오래된 기법이다. 이름표의 숨은 비용이 여기서 드러난다. 객체는 서로 구별은 되지만 이름이 없어서 로그에 남길 수가 없다(찍으면 그냥 {}다). 그래서 “한글 입력 중 글자가 사라졌다”는 신고가 와도 어느 예약이 무효화됐는지 추적할 수 없었다. 1씩 증가하는 번호로 바꾸자 “3번 예약이 4번으로 교체돼 무효화됨”이 로그에 남는다.
  3. 상태 통합 + 전이 규칙 하나. 세 파일에 흩어진 열두 개를 하나의 값으로 모으고, 소유자를 하나로 정하고, 모든 상태 변경이 순수 함수 하나 — applyImeSessionEvent(현재 상태, 들어온 사건) → { 다음 상태, 할 일 } — 를 통과하게 했다. 상태를 직접 고쳐 쓰던 35곳이 사라졌으니, 이제 규칙을 바꾸려면 이 함수를 바꾸는 수밖에 없다.

결과:

착수 전완료 후
판단하는 자리12곳이 세 파일에 흩어짐11곳이 순수 함수
상태3개 파일에 12개하나의 값, 소유자 하나
상태 변경35곳에서 직접 고쳐 씀전부 함수 하나를 통과
단위 테스트0개56개 (0.15초)

부수 효과 하나가 이 플래그들의 역사와 정확히 맞물린다. 버그들이 물었던 질문 — “빠르게 이어 치면?”, “다른 줄의 예약은?” — 은 전부 타이밍을 만들어야 재현되는 시나리오라 테스트가 없었다. 상태와 사건이 값이 되자, 사건을 순서대로 적기만 하면 그 시나리오가 된다. 시간이 값이 된 것이다.

// 실제 테스트(발췌) — 예약 실행 전에 다른 줄에서 새 조합이 시작되는 경우
const { state } = run([
  { kind: 'composition-started', lineId: 'A' },
  { kind: 'composition-ended' },
  { kind: 'finalization-scheduled', sessionId: 1, lineId: 'A' },
  { kind: 'composition-started', lineId: 'B' }   // 줄 B에서 새 조합 시작
])
// 줄 A의 예약은 무효화되지 않아야 한다
expect(state.pendingFinalization).toMatchObject({ lineId: 'A', superseded: false })

단언의 성격도 바뀐다. e2e가 잡는 것은 “줄이 두 개가 아니라 세 개가 됐다”는 결과다. 이 테스트가 잡는 것은 “재생이 예약 실행보다 먼저 일어났다”는 원인이다. 실패 메시지가 결과에서 원인으로 바뀐다.

세 회전을 왜 이 순서로 돌았는지, 회전마다 어떤 결함이 하나씩 잡혔는지 — 리팩토링 자체의 기록은 분량이 따로 필요해서 다음 글로 뒀다.

비용과 한계도 적는다

이 리팩토링이 공짜가 아니었다는 것도, 만능이 아니라는 것도 적어야 공정하다.

코드는 늘었다. 순수 모듈과 테스트가 새로 생겼고, 없어진 코드는 그보다 적다. 이 트레이드오프는 예전 글에서 다룬 그대로다 — 총량보다 “내가 이해해야 하는 범위가 얼마나 작고 명확한가”를 본다.

전이 규칙은 순서의 발생을 증명하지 못한다. 새 테스트는 “이 순서로 사건이 오면 이렇게 판단한다”를 고정할 뿐, “브라우저가 실제로 그 순서를 보낸다”는 증명하지 못한다. 그래서 진짜 조합 e2e는 남는다 — 다만 역할이 “순서가 실제로 발생하는가”의 확인으로 좁아진다.

동작은 하나도 바꾸지 않았다. 플래그들이 담고 있던 규칙 — 마지막 되돌리기 의도만 남긴다, 같은 줄의 예약만 무효화한다, 재생 직후 겹친 키는 무시한다 — 은 전부 그대로다. 버그 픽스들이 알아낸 것들은 값진 지식이고, 리팩토링은 그 지식을 버린 게 아니라 한곳에 모아 이름을 붙이고 테스트로 못 박은 것이다.

최소 수정을 버리라는 얘기가 아니다

이력을 다시 보면, 각 시점에 구조 변경을 선택했어야 한다고 말하기 어렵다. 안전망 없는 상태 기계를 버그 픽스 김에 재설계하는 것이야말로 이 코드베이스가 경계하는 도박이다. 최소 수정 원칙은 유효하다.

바뀌어야 하는 것은 회계다. 플래그를 추가하는 수정을 승인할 때, 그 커밋 안의 비용(한 줄)이 아니라 두 개의 숫자를 같이 세는 것이다.

  • 이 상태 묶음의 필드가 몇 개가 되는가.
  • 그것을 읽고 쓰는 자리가 몇 곳이 되는가.

첫째가 선형으로 늘 때 둘째는 곱으로 늘고 있었다는 것이 이 사례의 실측이다. 그리고 신호는 숫자가 되기 전에 문장으로 먼저 온다 — 소유자가 둘이 되는 순간, 서로의 상태를 읽기 시작하는 순간, “지금 상태가 뭐냐”에 파일 두 개 이상이 필요해지는 순간. 그때가 이 상태 묶음에 이름과 소유자를 줄 때다. 우리 경우 그 신호는 2026년 4월(소유자 셋, 예약과 정체성의 등장)에 이미 울리고 있었고, 실제 리팩토링은 8월에 했다. 넉 달치 이자는 냈지만, 안전망을 먼저 세우고 들어간 덕에 원금은 회수했다.

AI 에이전트와 일한다면 이 회계는 더 중요해진다. 버그를 고치는 에이전트는 성실하게 최소 수정을 하고 끝난다 — 누적 감시는 그 세션의 관할이 아니다. 그렇다고 누적을 보는 눈까지 사람이 떠맡아야 하는 것은 아니다. 위의 두 숫자는 기계로 셀 수 있는 값이고, 실제로 이 글의 12곳·35곳도 에이전트가 센 것이다. 이 열두 개의 존재를 드러낸 것도 결국 “전체를 조사하라”는 질문을 받은 에이전트였다.

마침 이런 장치를 부르는 이름도 생기고 있다. Thoughtworks의 Birgitta Böckeler는 에이전트 주변에 규율을 미리 심는 guide와 결과를 감지하는 sensor를 설계하는 일을 하네스 엔지니어링(harness engineering)이라 부르면서 정확히 이 질문을 던진다 — 어떤 drift는 점진적으로 쌓이기 때문에, 변경 단위 검사가 아니라 코드베이스 전체를 상시로 보는 sensor(“outside the change lifecycle”)가 필요하지 않은가. 이 글의 일곱 달을 그 말로 다시 쓰면 한 문장이 된다. 우리에게는 guide(최소 변경 원칙)만 있었고, 누적을 보는 sensor가 없었다.

그래서 사람에게 남는 것은 두 가지다. 그 sensor를 세우기로 결정하는 일 — 버그 픽스 세션은 자기 관할 밖의 장치를 스스로 만들지 않는다. 그리고 sensor가 울렸을 때 “이번엔 플래그 말고 구조를 정리하자”에 시간과 위험을 승인하는 일. 우리 경우 그 결정이 넉 달 늦었다는 것이 이 글의 실측이다.