DevTech
2026년 8월 4일 · 9 min read

코드가 근거로 인용하는 설계문서가 존재하지 않았습니다

0. 발단

finance-tracker의 마이그레이션 파일을 읽다가 이런 주석을 봤습니다.

// migrations/017-audit-triggers.js
// 쓰기 감사 캡처(#299). ADR 0007 의 A+C 혼합 — 트리거가 before/after 를 빠짐없이
// 잡고, 라우트는 action_label 만 얹는다.

ADR 0007을 근거로 인용하고 있습니다. 다른 파일에도 있었습니다.

// src/utils/auditContext.js
// **이건 전제다.** 커넥션 풀이나 워커 스레드가 들어오면 깨진다. 그때는 이 모듈을
// AsyncLocalStorage 로 바꿔야 한다 — #296 의 ADR 에 전제로 적는다.

"ADR에 전제로 적는다"고 예고까지 해두었습니다. 그래서 그 문서를 열어봤습니다.

$ ls docs/decisions/
0001-transaction-table-separation.md
0002-sqlite-choice.md
0003-xlsx-vulnerability-risk-acceptance.md
0004-xlsx-vendored-upgrade.md
0005-authentication-strategy.md
0006-visual-token-system.md
0008-preview-before-mutation.md
0009-installment-fee-monthly-basis.md

0007만 없습니다.

1. 결정은 있었는데 기록만 없었습니다

이슈를 뒤져보니 결정 자체는 멀쩡히 확정돼 있었습니다. 2026-08-03에 "A(트리거) + C(명시 라벨) 혼합으로 확정" 이라고 코멘트까지 달려 있었고, 후속 구현 이슈 네 개가 전부 닫혀 있었습니다. 감사로그 테이블도, 컨텍스트 미들웨어도, 트리거도, 실행취소도 다 들어가 있었습니다.

결정하고, 구현하고, 근거만 안 썼습니다.

이게 왜 문제냐면, 지금 그 코드를 읽는 사람은 "왜 트리거로 잡았는지" 를 알 방법이 없습니다. 주석이 가리키는 문서가 없으니까요. 나중에 누군가 "이거 그냥 라우트에서 부르면 되는 거 아닌가" 라고 생각했을 때 막을 게 없습니다.

2. 후보는 셋이었습니다

없는 문서를 쓰려면 결정을 다시 따라가야 했습니다. 원래 이슈에 정리된 후보는 셋입니다.

방식 빠뜨릴 수 있나 변경 전 값 무슨 작업인지
SQLite 트리거 불가능 엔진이 OLD/NEW로 준다 모름
db 래퍼 어려움 SQL 파싱 필요 앱 컨텍스트 그대로
호출부마다 명시 호출 쉽게 빠뜨림 호출부가 안다 정확

핵심 제약이 하나 있었습니다. 이 앱에는 DAO 계층이 없습니다. 라우트가 db 싱글턴을 직접 씁니다. 훅을 걸 중간 지점이 존재하지 않습니다.

3. 래퍼를 기각한 이유

db.prepare()를 가로채는 방식이 제일 얌전해 보입니다. 호출부를 안 건드려도 되고 앱 컨텍스트를 그대로 쓸 수 있습니다.

문제는 UPDATEDELETE입니다. 변경 전 값을 알려면 대상 행을 미리 읽어야 하는데, 그러려면 SQL의 WHERE 절을 파싱해야 합니다.

$ grep -c 'db\.prepare(' src/routes/transactions.js
26

한 파일에만 26곳이고 쿼리 형태가 제각각입니다. 파서가 못 다루는 형태가 하나 생기면 그 경로의 before 값이 조용히 빕니다.

그리고 그 사실은 감사가 필요해진 시점에야 드러납니다. 정확성의 유일한 근거를 "우리가 우리 SQL을 다 커버했는가" 에 두는 게 이 방식의 진짜 비용입니다.

4. 명시 호출 단독을 기각한 이유

각 핸들러에서 recordChange(...)를 부르는 방식은 의도를 정확히 남길 수 있습니다. "할부 완료처리" 같은 의미 단위로요.

38곳을 손대는 비용 자체는 문제가 아닙니다. 새 엔드포인트가 추가될 때마다 같은 세금을 계속 낸다는 점이 문제입니다. 이 사이클에만 새 엔티티가 셋 들어왔습니다.

더 결정적인 건 실패 방식입니다. 빠뜨리면 그 쓰기가 로그에 아예 없고, 없다는 사실조차 드러나지 않습니다. 부분 로그는 "아무 일도 없었다" 와 "그 대상은 로깅을 안 한다" 를 구별하지 못해서 증거가 되지 못합니다.

5. 그래서 혼합입니다

캡처는 트리거가 전담하고, 라우트는 의미 라벨만 얇게 얹습니다.

CREATE TRIGGER audit_transactions_upd AFTER UPDATE ON "transactions"
BEGIN
  INSERT INTO audit_log (ts, actor, action_id, action_label, op, table_name, row_id, before_json, after_json)
  VALUES (datetime('now','localtime'),
          (SELECT actor FROM _audit_context WHERE id=1),
          ...
          json_object('id', OLD."id", 'date', OLD."date", ...),
          json_object('id', NEW."id", 'date', NEW."date", ...));
END;

라벨을 안 붙여도 로그는 남습니다. 빠뜨려도 구멍이 나지 않는 구조라는 것, 그게 채택 이유의 전부입니다.

6. 전제를 적어야 하는 이유

트리거는 JS 상태를 볼 수 없습니다. "누가 이 쓰기를 했는가" 를 모릅니다. 그래서 단일 행 테이블에서 읽습니다.

CREATE TABLE _audit_context (
  id INTEGER PRIMARY KEY CHECK (id = 1),
  actor TEXT NOT NULL DEFAULT 'system',
  action_id TEXT NOT NULL DEFAULT 'bootstrap',
  action_label TEXT
);

애플리케이션이 쓰기 직전에 이 한 행을 갱신합니다. 요청 처리 중간에 다른 요청의 쿼리가 끼어들 수 없기 때문에 성립하는 방식입니다.

better-sqlite3가 동기이고, 이 앱이 단일 프로세스 단일 커넥션이라 가능합니다. 비동기 드라이버였다면 못 씁니다.

이걸 문서에 안 적으면 나중에 커넥션 풀을 넣는 사람이 자기가 무엇을 깨뜨리는지 모릅니다. 두 요청의 쓰기가 겹치면 컨텍스트 테이블이 서로를 덮어쓰고, 감사로그의 actor가 뒤섞입니다. 그때의 대체 수단이 AsyncLocalStorage라는 것까지 같이 적었습니다.

7. 실제로 새어나간 곳은 다른 데였습니다

문서를 쓰면서 확인하다가 별개 문제를 찾았습니다. 트리거는 생성 시점의 컬럼 목록을 굳힙니다.

컬럼을 하드코딩하지 않으려고 PRAGMA table_info로 읽어서 생성하지만, 그건 생성 시점의 이야기입니다. 나중에 컬럼이 늘면 새 컬럼은 조용히 캡처에서 빠집니다.

그래서 테이블이나 컬럼을 늘리는 마이그레이션은 트리거를 다시 만들어야 했습니다.

// migrations/018-add-accounts.js (당시)
rebuildAuditTriggers(db);

8. 우연히 맞고 있었습니다

018이 이걸 부르고 있어서 실측상 구멍은 없었습니다. 018이 파일명 정렬상 마지막이라 앞선 마이그레이션이 만든 테이블까지 전부 덮였습니다.

스크래치 DB로 확인했습니다.

신규 DB 전체 체인          대상 18테이블 / 트리거 54개  전부 존재
016·018 없던 시절의 DB     대상 16테이블 / 트리거 48개  전부 존재
그 뒤 016·018 적용         대상 18테이블 / 트리거 54개  전부 존재

문제는 018이 그걸 부르는 이유였습니다. 주석을 보면 "새 테이블을 만들었으니" 가 아니라 "payment_methods에 컬럼이 늘어서" 입니다. 우연히 맞은 쪽에 가깝습니다.

다음에 누가 테이블을 만들면서 그 한 줄을 빠뜨리면 새어나갑니다. 그래서 마이그레이션 러너가 체인을 다 적용한 뒤 한 번 재생성하도록 옮겼습니다.

// src/db/migrate.js
if (appliedAny && hasAuditInfrastructure(db)) rebuildAuditTriggers(db);

9. 옮기고 나서도 하나 남았습니다

018에서 호출을 뺐는데, 그 사이 다른 세션이 만든 019가 같은 호출을 들고 있었습니다. 러너로 옮기기 전에 작성된 파일이라서요.

동작은 안 깨집니다. 재생성이 멱등이라 두 번 돌아도 결과가 같습니다. 그래도 지웠습니다. 없애려던 패턴이 마이그레이션 하나에만 남아 있으면, 그게 다시 돌아오는 입구가 됩니다.

지우니까 테스트가 깨졌습니다.

✖ D-1. card_benefits 에도 트리거가 붙는다
✖ D-2. 혜택을 넣으면 감사 로그에 남는다

그 테스트는 019를 체인 없이 고립 적용합니다. 의도적입니다. "019가 무엇에 의존하는지" 를 테스트에 적어두려는 설계였습니다.

이 실패는 회귀가 아니라 테스트가 낡은 계약을 보고 있던 것입니다. 트리거 재생성은 더 이상 019의 책임이 아니니까요. 테스트가 러너의 마지막 단계를 재현하도록 고쳤습니다.

10. 승인 게이트가 세 번 돌았습니다

이 저장소는 docs/decisions/ 아래 문서에 승인 게이트가 걸려 있습니다. 커밋하려면 사람이 내용을 읽고 승인해야 합니다.

[confirm-chain] ⛔ 문서 변경이 승인 게이트를 통과하지 않았습니다.
  현재 상태: {"state": "none", "thread": "doc-5266778e17a8"}

게이트 식별자가 내용 해시입니다. 문서를 한 글자라도 고치면 스레드가 새로 잡히고 기존 승인이 무효가 됩니다.

1차 제출 후 리뷰에서 지적이 왔습니다. 제가 "재생성 책임을 러너로 옮겼다" 라고 단정해 썼는데, 그 PR이 아직 머지 전이었습니다. "옮기기로 했다" 로 낮췄습니다. 스레드가 새로 잡혔습니다.

11. 그 사이 문서가 반대로 낡았습니다

2차 제출을 하고 승인을 기다리는 동안 그 PR이 머지됐습니다. 그러니까 방금 낮춰 쓴 문구가 이번엔 반대 방향으로 틀렸습니다.

특히 나빴던 건 이 줄입니다.

| 재생성 책임 | 지금은 018-add-accounts.js 가 직접 호출 |

이게 들어 있는 절의 제목이 "구현 결과 (2026-08-04 실측)" 였습니다. 실측이라고 써놓고 실측이 아닌 값이 들어가 있었습니다.

3차로 고쳤습니다. 이번엔 019에 남은 중복 호출까지 명시했습니다. "완료" 로만 쓰면 또 같은 종류로 틀리니까요.

12. 결정 시점 수치는 얼리고 갑니다

문서에 수치가 두 종류 들어갑니다.

기준
Context 2026-08-03 결정 시점 실측
구현 결과 2026-08-04 실측

결정 시점 값은 소급해 고치지 않습니다. 결정의 근거로 남기는 값이라, 나중에 코드가 늘었다고 바꾸면 "그때 무엇을 보고 결정했는지" 가 사라집니다. 같은 저장소의 ADR 0008이 먼저 쓰던 관례를 따랐습니다.

실제로 하루 만에 꽤 달라졌습니다. 결정 시점에 쓰기 엔드포인트 38개였던 게 오늘 다시 세면 56개입니다.

13. 설계대로 비어 있는 절반

문서를 쓰면서 실측하다가 이걸 발견했습니다.

$ grep -rn 'setAuditLabel(' src/routes src/services | grep -v 'function '
(결과 없음)

의미 라벨을 설정하는 곳이 프로덕션 경로에 0곳입니다. 함수도 있고 테스트도 있는데 아무도 안 부릅니다.

이게 설계대로입니다. 라벨은 선택이고, 없어도 트리거가 잡으니 로그에 구멍이 안 납니다. 그게 애초에 트리거를 캡처 수단으로 고른 이유였습니다.

다만 화면이 붙고 나니 이야기가 달라집니다. 감사 이력 화면에 "작업 이름" 열이 생겼는데 채울 값이 없습니다.

14. 폴백이 있으면 괜찮은가

화면 쪽 PR을 열어보니 폴백이 이미 있었습니다.

export function describeAction({ label, tables, ops } = {}) {
  if (label) return label;
  const t = (tables || []).map((x) => TABLE_LABELS[x] || x);
  const o = (ops || []).map((x) => OP_LABELS[x] || x);
  if (t.length !== 1 || o.length !== 1) return t.length ? `${t.join('·')} 변경` : '방금 한 작업';
  return `${t[0]} ${o[0]}`;
}

잘 만들어져 있습니다. 문제는 지금 모든 행이 이 폴백을 탄다는 것입니다. 그러면 할부 파생거래 재생성(삭제 12건 + 삽입 6건)이 거래 변경 으로만 보입니다. 되돌릴지 판단하기엔 부족합니다.

그래서 "전부 라벨을 붙이자" 가 아니라 "폴백으로 구별되지 않는 것만" 을 제안으로 남겼습니다. 라벨을 의무로 만들면 아까 기각한 명시 호출 방식의 약점이 그대로 돌아옵니다.

15. 로그가 데이터보다 빨리 늘어납니다

트리거로 잡으면 빠뜨릴 수 없습니다. 그게 장점이자 다음 문제의 원인입니다.

거래 100건 삽입 → 감사로그 +100건
JSON 페이로드 행당 평균 285 bytes

UPDATEbefore_jsonafter_json을 둘 다 담아서 행당 바이트가 대략 두 배입니다. 카드 명세서 임포트는 한 번에 수백 건이고, 실행취소 자체가 쓰기라 되돌려도 로그는 줄지 않습니다.

16. 상한을 행수로 두면 안 되는 이유

정리 정책을 정할 때 행수 상한이 먼저 떠오릅니다. 그런데 그러면 임포트 한 번에 상한을 넘겨 방금 한 작업이 잘려 나갈 수 있습니다.

기간 기준으로 갔습니다. "석 달치 이력을 보관해요" 로 설명되고 사용자가 예측할 수 있습니다. 기본 180일로 뒀습니다. 짧으면 감사로그의 가치 자체가 사라집니다.

17. 지우면 안 되는 것 하나

실행취소는 "가장 최근 미취소 사용자 작업 그룹" 을 찾습니다. 정리가 그 그룹을 지우면 되돌리기가 조용히 불가능해집니다. 버튼은 그대로 있는데 눌러도 아무 일이 안 일어나는 상태가 제일 나쁩니다.

그래서 그 그룹만 보존 기간과 무관하게 남깁니다.

const stmt = keptActionId
  ? db.prepare(`DELETE FROM audit_log WHERE date(ts) < ? AND action_id <> ?`)
  : db.prepare(`DELETE FROM audit_log WHERE date(ts) < ?`);

오래 앱을 안 켠 사용자의 마지막 작업이 기간 밖에 있어도 되돌릴 수 있어야 합니다.

테스트도 그 기준으로 썼습니다. 행이 남았는지가 아니라 되돌리기가 실제로 되는지 를 봅니다. 그룹의 일부만 남으면 행수는 0이 아닌데 역적용이 깨지니까요.

18. 정리하며

없는 문서 하나를 쓰는 일이었는데, 쓰는 과정에서 나온 게 더 많았습니다.

  • 018의 트리거 재생성 호출이 우연히 맞고 있었다는 것
  • 러너로 옮긴 뒤에도 019에 하나 남아 있었다는 것
  • 의미 라벨이 프로덕션에서 한 번도 설정되지 않는다는 것
  • 감사로그에 정리 정책이 없어 무제한으로 는다는 것

문서를 쓰려면 코드를 다시 읽어야 하고, 다시 읽으면 보입니다. ADR을 나중에 쓰는 게 나쁜 이유는 기록이 늦어서가 아니라, 그 검증을 안 하고 넘어가기 때문인 것 같습니다.

그리고 하나 더. 문서가 코드보다 늦으면 문서가 낡는 속도도 빨라집니다. 이번엔 승인을 기다리는 동안에도 두 번 낡았습니다.

참고

Sponsored

댓글