기술 포스트

화면의 숫자가 거짓말을 하고 있었다


Article

앞의 네 편은 기능 이야기였다. 이번 편은 그 기능들을 다 만든 뒤에 드러난 것들이다.

공통점이 있다. 전부 에러를 안 냈다. 로그도 안 남고, 테스트도 초록불이고, 화면도 멀쩡해 보였다. 그냥 조용히 틀려 있었다.

1. 화면에 22건, 실제로는 1,156건

전표 목록에 페이징을 붙여 달라는 요청을 받았다. 작성 중인 전표가 너무 많아서 한참 스크롤해야 아래 표가 보인다는 것이었다.

페이징을 붙이려고 코드를 열었는데, 화면 상단에 **「22건」**이라고 떠 있었다. 그런데 나는 그 직전에 엑셀 3,000행을 이관해서 전표가 1,156건 생긴 걸 알고 있었다.

22 는 어디서 나온 숫자인가
  1. 01

    서버가 최근 50건만 준다

    페이징이 없으니 목록 API 는 적당히 잘라서 준다. 그 자체로는 합리적이다.

  2. 02

    화면이 그 50건 안에서 상태를 거른다

    const drafts = orders.data?.filter((o) => o.status === "DRAFT")
    <span>{drafts.length}건</span>
  3. 03

    50건 중 작성 중이 22건이었다

    그래서 화면에 22 가 찍혔다. 코드는 정확히 시킨 대로 동작했다.

  4. 04

    전체 건수는 서버만 안다

    화면이 받아 온 목록의 길이를 세는 순간 그 숫자는 거짓말이 된다.

이 버그는 예외도, 경고도, 콘솔 로그도 남기지 않는다. 숫자가 그냥 작게 나올 뿐이다.

고친 방법 자체는 평범하다. 서버가 상태로 걸러서 페이지 단위로 주고, 전체 건수도 같이 준다. 그리고 실측으로 대조했다.

서버 응답 vs DB 직접 조회
  • 서버 DRAFT 1,156건 / 193페이지 · DONE 28건 / 5페이지
  • DB DRAFT 1156 · CONFIRMED+CANCELED 28
  • 일치

입고 화면. 작성 중인 전표 1,156건, 1/193 페이지. 화면에는 카드 6장만 떠 있다.

지금 화면이 이렇다. 건수는 1,156건인데 화면에 그려진 카드는 6장이고, 오른쪽 위에 1 / 193이 붙어 있다. 이 셋이 서로 다른 출처에서 온다는 게 요점이다. 건수와 페이지 수는 서버가 세어서 주고, 카드 6장은 이번 페이지의 내용이다. 고치기 전에는 저 세 숫자가 모두 "받아 온 배열 하나"에서 나왔다.

무서운 건 발견 경위다. 페이징을 안 붙였으면 영영 못 봤을 버그다. 기능을 추가하다가 우연히 밟았을 뿐, 이 숫자를 검증하는 테스트는 없었다. 화면에 뜬 숫자를 누가 검산하겠나 싶었던 것이다.

화면의 모든 숫자에는 근거가 있어야 한다. 목록의 길이는 전체 건수의 근거가 아니다.

2. 일괄 치환이 문장을 부쉈다

저장소 전체에서 em-dash()를 다른 기호로 바꾸는 치환을 돌린 적이 있다. 문서 통일 차원이었다. 그리고 그게 두 가지 사고를 냈다.

첫 번째. 며칠 뒤 화면에 이런 게 떠 있었다.

SKU-001 생수현재고 1 BOX

생수현재고가 붙어 있다. 원래 코드는 이랬다.

<b>{row.itemName}</b> — 현재고     // 원래
<b>{row.itemName}</b>현재고        // 치환 후 — 구분자와 공백이 함께 사라졌다

치환 규칙 중 하나가 "라벨 뒤에 붙는 부연의 앞머리 dash를 없앤다"는 의도였는데, </b> — 문장 형태에도 걸렸다. 앞 태그의 > 뒤에 오는 dash와 공백까지 통째로 지웠다.

14곳이었다. 치환 커밋의 diff를 다시 뒤져서 패턴으로 전부 찾아 복구했다.

3. 주석을 고쳤더니 앱이 안 떴다

같은 치환의 두 번째 사고가 더 컸다. 그날 앱이 아예 안 떴다.

Validate failed: Migration checksum mismatch for migration version 2
  -> Applied to database : -478404005
  -> Resolved locally    : -1777580234

DB 마이그레이션 파일의 SQL 주석 안에 있던 em-dash까지 치환됐던 것이다.

바뀐 것과 깨진 것

바뀐 것

주석 한 글자

  • SQL 문법: 그대로
  • 실행 결과: 그대로
  • 테이블 정의: 그대로
  • 바뀐 건 -- 뒤의 설명 문장 하나

깨진 것

앱 전체

  • Flyway 는 파일 전체의 해시를 본다
  • 이미 적용된 기록과 해시가 다르다
  • = "누가 과거를 고쳤다"
  • 기동 거부
Flyway 가 과민한 게 아니다. 이미 남의 DB 에 적용된 마이그레이션이 바뀌는 건 실제로 사고다.

고친 방법은 되돌리는 것이었다. 해당 마이그레이션 파일들을 치환 이전 상태로 복구했다. 그 파일들에는 em-dash가 그대로 남는다. 정합성이 미관보다 위다.

그리고 마이그레이션 폴더에 README를 하나 넣었다. "이 폴더의 파일은 한 글자도 고치지 마세요."

repair 명령으로 해시를 다시 맞출 수는 있다. 그런데 그건 내 DB만 고친다. 이미 배포된 환경, 팀원 로컬, CI는 여전히 깨져 있다. 그래서 처음부터 안 고치는 게 유일한 답이다.

같은 이유로 데이터를 고칠 때도 기존 마이그레이션을 수정하지 않고 새 파일을 추가했다. 화면 메모에 개발자 용어가 남아 있던 걸 고칠 때도, 데모 비밀번호를 교체할 때도 그렇게 했다.

4. 크롬이 데모 비밀번호에 경고를 띄웠다

그 비밀번호 이야기가 나온 김에.

로그인하는 순간 크롬이 경고창을 띄웠다.

"방금 사용한 비밀번호가 정보 유출로 인해 노출된 것으로 확인됩니다."

admin1234였다. 실제로 유출 목록에 올라 있는 비밀번호다. 크롬은 로그인 시 제출된 비밀번호를 그 목록과 대조한다.

데모 계정이니까 아무거나 써도 된다고 생각했다. 그런데 그게 정확히 실무에서 사고가 나는 사고방식이다. 데모용으로 만든 계정이 그대로 스테이징에 가고, 스테이징이 그대로 열려 있는 일이 실제로 일어난다.

그리고 현실적인 이유도 있다. 시연 영상에서 저 경고창이 뜨면 그 자체로 감점이다.

계정 네 개의 비밀번호를 전부 바꿨다. 해시는 손으로 적지 않고 인코더로 직접 뽑았다. 손으로 적으면 반드시 틀린다.

5. 내가 고쳤다고 말했는데, 안 고쳐져 있었다

차트 막대 색을 바꾸는 작업을 했다. 커밋하고 "바꿨다"고 말했다. 그리고 이런 지적을 받았다.

"바 색상 안 변경됐는데? 아직도 칙칙한 약간 어두운 파란색인데?"

브라우저에서 실제로 칠해진 색을 읽어 봤다.

linear-gradient(90deg, rgb(83,121,224), …)   ← #5379e0. 처음부터 그대로였다

사용자가 맞았다. 앞 커밋의 치환이 주석 한 글자가 달라서 조용히 실패했고, 나는 파일을 다시 읽지 않고 커밋했다.

그런데 그 색을 제대로 고치려고 파일을 다시 열었다가, 진짜 버그를 하나 발견했다. 색 정의 옆에 이런 주석이 있었다.

human: "#5379e0",  // (rule 과 같다: 같은 차트에 함께 안 나온다)

그 주석이 틀렸다. 대조 화면의 막대에는 일치(human)와 새 시스템에만(rule)이 함께 나온다. 두 조각이 같은 색으로 나란히 붙어 있었고, 아무도 몰랐다. 범례가 두 개인데 색이 하나였던 것이다.

색을 나누면서 이 버그도 같이 닫혔다. 지적을 안 받았으면 색도 안 바뀌었고 이 버그도 안 보였다.

6. 대조가 차이 9건을 냈는데, 대조가 옳았다

이건 성격이 좀 다르다. 도구가 아니라 내가 순서를 틀린 경우다.

2편에서 구·신 재고를 맞춰서 차이 6건까지 갔었다. 그런데 전표 일괄 확정 버튼을 만들고 전체 흐름을 다시 돌렸더니 차이가 9건이 나왔다. 3건이 늘었다.

추적해 보니 늘어난 3건은 이랬다.

차이 9건의 정체
무리품목구 → 신정체
A (6건)L-001 L-003390 → 890취소 전표. 2편에서 다룬 그 이야기
B (3건)SKU-001~0031 → 37,041엑셀 입고대장 2,864행이 얹은 재고
B 는 버그가 아니다. 대조가 옳게 말한 것이다 — 구 시스템이 모르는 재고가 신 시스템에 생겼으니까.

문제는 순서였다. 실무 컷오버는 신규 활동을 멈추고 → 이관 → 대조 0건 → 컷오버 → 그다음 새 활동 시작 순서로 간다. 나는 엑셀(=새 활동)을 먼저 확정해 버렸고, 그러자 진짜 원인 6건이 노이즈 3건에 묻혔다.

순서를 지켜서 다시 돌리니 정확히 6건이 나왔다.

그런데 여기서 진짜 배운 건 그다음이다. 이걸 데모 대본에만 적어 두면 사람이 또 밟는다. 그래서 화면이 그 함정을 말하게 했다. 엑셀 이관으로 만들어진 전표의 확정 카드에만 경고를 띄운다.

"이 입고는 구 시스템이 모르는 입고입니다. 구 시스템 이관을 먼저 확정하고 → 대조해서 0건을 만든 뒤에 이 전표를 확정하세요."

구 시스템에서 온 전표에는 안 뜬다. 출처로 구분한다. 문서에 적어야 할 규칙을 발견하면, 그걸 화면에 넣을 수 있는지부터 생각하는 게 낫다.

7. 그리고 컨테이너가 아예 안 떴다

마지막이 제일 컸다. 기능을 전부 만든 뒤, 전체 흐름을 처음부터 한 바퀴 돌려 보려고 데이터를 초기화했다. 볼륨째 지우고 다시 띄웠다.

신 시스템 MySQL이 기동에 실패했다.

docker compose up 로그
  • /docker-entrypoint-initdb.d/01-readonly-account.sql
  • ERROR 1146 (42S02) at line 34: Table 'ledger.vendor' doesn't exist
  • → init 실패 → 컨테이너 exit 1
  • → 백엔드는 "Connection refused" 로 죽는다

원인은 순서였다.

컨테이너 초기화 스크립트는 최초 기동 시 돈다. 그 시점에 스키마는 비어 있다. 업무 테이블은 나중에 백엔드가 Flyway로 만든다. 그런데 그 초기화 스크립트가 GRANT SELECT ON ledger.vendor … 처럼 아직 없는 표에 권한을 주려고 했다. MySQL은 없는 표에 GRANT를 거부한다.

왜 이걸 여태 몰랐냐가 핵심이다.

여태 아무도 안 밟은 경로
  1. 4편에서 권한을 좁혔다ledger.* → 업무 테이블 9개. 기밀성 방어
  2. 그때 어떻게 검증했나이미 표가 있는 DB 에 직접 실행해서 확인했다. 잘 됐다
  3. 빠진 경로볼륨을 지우고 처음부터 띄우는 경로 = init 이 Flyway 보다 먼저 도는 경로
  4. 며칠 뒤초기화를 처음 돌렸고, 그날 처음 밟았다
보안을 강화한 변경이 초기화를 깨뜨렸다. 그리고 그 변경을 검증한 방식 때문에 안 드러났다.

고친 방법은 계정 생성과 권한 부여를 가르는 것이었다.

언제 무엇을 하는가
무엇을언제어디서
계정 생성 (CREATE USER)컨테이너 부팅초기화 스크립트
권한 부여 (표 9개 GRANT SELECT)Flyway 가 표를 만든 뒤별도 스크립트 (멱등)
최종 보안 자세는 그대로다. 업무 테이블 9개만 SELECT, DELETEusers 는 여전히 ERROR 1142. 언제 적용하느냐만 바뀌었다.

그럼 이 GRANT를 Flyway 마이그레이션에 넣으면 되지 않나 싶은데, 안 된다. 통합 테스트는 읽기 전용 계정을 아예 만들지 않고 같은 테스트 계정으로 붙는다. Flyway에 GRANT ... TO ledger_ro를 넣으면 테스트 컨테이너에서 **"없는 유저에게 GRANT"**로 전 테스트가 깨진다. 권한 부여는 로컬 도커 구성에만 필요한 일이다.

한 바퀴 돌린 결과

초기화를 고치고 전체 흐름을 실제로 한 바퀴 돌렸다. 목이 아니라 진짜 백엔드, 진짜 모델 호출, 진짜 MySQL 두 대 위에서다.

전체 흐름 점검
  • 로그인/인증/RBAC JWT 발급 · 만료 30분 정상
  • 엑셀 이관 헤더 4행 자동감지 · 비고2 → 0.10 · 성공 26 / 제외 2 / 실패 1
  • 레거시 대조 euckr 읽기 정상 · MATCH 5 / MISMATCH 1 / 누락 39
  • 마스터 병합 27건 스캔 · 한빛 무리 검출 (사업자번호 동일, 1.0)
  • 자연어 조회 생성 90% · 조인 SQL 실행 3행 · 파괴 프롬프트 10% 방어
  • 영수증 Vision 라인 3개 각 0.95 · 공급가액 교차검산 · 자동 매칭
  • 동시 출고 병렬 2건 → 200 / 409 · 재고 1→0 (음수 아님)
  • 원장 재집계 SUM(수불) == 현재고 · mismatch 0
  • 깨진 곳은 초기화 버그 하나. 기능 로직은 전부 그대로 돈다.

다섯 편을 쓰고 남은 것

이 시리즈에서 다룬 버그를 다시 세어 보면 성격이 갈린다.

시끄러운 것들 — 데드락, 체크섬 불일치, 읽기 전용 커넥션 예외, 컨테이너 기동 실패. 전부 요란하게 죽었고, 죽었기 때문에 그날 안에 고쳤다.

조용한 것들 — 화면의 22건, 붙어 버린 문장 14곳, 같은 색으로 그려진 두 범례, 실패한 줄 몰랐던 치환. 이것들은 아무도 안 죽였고, 그래서 우연히 다른 작업을 하다가 발견됐다.

두 종류의 버그

죽는 버그

데드락 · 체크섬 · 기동 실패

  • 즉시 드러난다
  • 원인이 로그에 있다
  • 그날 고친다
  • 무섭지 않다

안 죽는 버그

숫자 · 색 · 문장 · 실패한 치환

  • 테스트도 빌드도 통과한다
  • 화면이 멀쩡해 보인다
  • 누가 우연히 밟아야 보인다
  • 시연 중에 지적당한다
1 편에서 CHECK 위반을 일반 에러가 아니라 경보로 취급하기로 한 것도 같은 이유였다. 조용히 틀리는 것보다 시끄럽게 실패하는 게 낫다.

이 프로젝트를 시작할 때 세운 원칙이 "판단은 AI, 책임은 사람"이었다. 다섯 편을 쓰고 보니 그 원칙이 지켜지는 조건은 결국 하나였다. 사람이 볼 수 있어야 한다.

AI가 모른다고 말해야 사람이 그 칸을 본다. 화면의 숫자가 정직해야 사람이 그 숫자를 믿는다. 대조가 차이를 숫자로 뱉어야 사람이 못 본 규칙을 찾아낸다. 실행될 SQL을 미리 보여줘야 사람이 실행 여부를 정한다.

승인 버튼은 그 다음 문제다. 볼 수 없는 것에는 책임을 질 수 없다.

화면의 숫자가 거짓말을 하고 있었다 — 디코드랩(DCODELAB)