부하 측정: 제어는 3층인데 부하는 1층에서 다 막혔다
목차
개요
유입 제어를 3층으로 쌓아놓고 “재 보고 골랐다”고 문서에 적어뒀습니다. 다시 재 보니 실험이 계정 하나로 돌아가 부하가 1층에서 전부 막혔고, 전역 층은 부하를 받은 적이 없었습니다.
이 글은 그렇게 성립하지 않았던 실험, 그리고 그걸 잡으라고 만든 검사기가 하루에 두 번 헛통과한 것을 다룹니다. 성립하지 않은 실험과 아무것도 안 보는 검사는 겉으로 똑같이 초록색입니다.
| 상황 | 다룬 것 |
|---|---|
| 상황 1 | 부하 실험이 계정 하나로 돌아가고 있었다 |
| 상황 2 | 그러라고 만든 검사기가 헛통과했다 |
개인 프로젝트로 만든 결제 시스템 pay의 개발 기록입니다. 실무 운영 경험이 아닙니다.
상황 1. 부하 실험이 계정 하나로 돌아가고 있었다
유입 제어 실험 이야기입니다. “제어를 켜니 p95가 14배 좋아졌다”고 문서에 적어뒀는데, 그 실험은 계정 하나로 돌린 것이었습니다. 전역 층까지 요청이 도달할 수가 없었습니다. 40계정으로 다시 재니 결론이 바뀌었고, 그 과정에서 아웃박스 테이블이 15만 행인 것도 나왔습니다.
유입 제어를 3층으로 쌓고 “제어를 켜니 p95가 14배 좋아졌다”고 문서에 적어뒀습니다. 그러면 그 “재는 방법”은 맞았나. 다시 들여다보다 산수가 안 맞는 걸 발견했습니다.
산수가 안 맞았다
유입 제어를 3층으로 만들어뒀습니다. 사용자당 초당 5건, 경로 전체 초당 100건, 그리고 대기열 게이트. 셋째 층인 대기열 게이트는 한정판 선착순처럼 순간에 수만 명이 몰리는 상품에만 걸리는 별도 장치라, 이번 실험 대상에서는 뺐습니다. 스파이크 테스트를 돌려서 “제어를 켜니 p95가 14배 좋아졌다”고 문서에 적어뒀습니다.
숫자는 진짜였습니다. 그런데 그 실험은 계정 하나로 돌린 것이었습니다.
사용자당 한도가 5/s입니다. 그 계정은 무슨 짓을 해도 초당 5건까지만 통과합니다. 경로가 둘이니 합쳐서 10/s. 전역 상한은 100/s입니다.
전역 층까지 요청이 도달할 수가 없습니다.
1차 실행 기록을 다시 보니 DB 도달량이 10.2 req/s로 적혀 있었습니다. 정확히 5/s × 2경로입니다. 층을 둘 만들어놓고 바깥 층은 한 번도 부하를 받은 적이 없는데, 문서에는 “유입 제어를 실측으로 검증했다”고 적혀 있었습니다.
전역 층을 깨우려면 계정 수 × 5/s > 100/s, 그러니까 계정이 21개는 있어야 합니다. 40계정으로 잡으면 경로당 200/s, 두 경로를 합치면 이론상 400/s입니다.
먼저 429가 말을 하게 만들었다
다계정 스크립트를 쓰기 전에 고쳐야 할 게 있었습니다. 기존 429는 본문도 헤더도 전부 같아서, 사용자별 층이 잘랐는지 전역 층이 잘랐는지 알 수가 없었습니다.
이건 실험이 결론을 못 내는 문제이기도 하지만, 그보다 운영에서 두 상황을 구분할 수 없다는 게 더 나빴습니다. 대응이 정반대입니다.
user: 그 클라이언트만 물러나면 풀립니다. 재시도가 유효합니다.global: 전역 버킷이 거절했습니다. 그 클라이언트가 얌전해져도 안 풀립니다. 증설이나 원인 규명이 필요합니다
다만 global이 곧 “시스템이 포화”는 아닙니다. 뒤에서 보듯 상한 100/s가 실제 처리 능력(~120/s)보다 낮아서, 포화가 아닌데도 전역 거절이 납니다. 이 헤더가 말해주는 건 “전역 버킷이 잘랐다”까지고, 포화 판단은 성공 요청 지연·CPU·DB 풀 대기·큐 나이를 같이 봐야 합니다.
대시보드에 429 그래프 하나만 그려놓고 “누가 때리는 중”인지 “우리가 감당을 못 하는 중”인지 가릴 수 없었던 것입니다. X-RateLimit-Scope를 붙였습니다.
한 가지 조심할 게 있었습니다. 원래 코드가 이렇게 생겼습니다.
if (!tryAcquire("user:" + ...) || !tryAcquire("global:" + ...)) { reject(response);}이 단축평가가 의미를 지고 있습니다. 사용자별 한도에서 걸리면 전역 카운터를 소비하지 않습니다. 한 명이 스크립트로 때린다고 전체 예산을 태우면, 그 사람 때문에 멀쩡한 다른 사용자가 거절됩니다. 층을 분리해 어느 쪽이 잘랐는지 알리면서도 이 성질은 그대로 뒀습니다.
겸사겸사 보니 로그인·가입의 IP 제한 경로는 테스트가 하나도 없었습니다. 같이 덮었습니다.
다시 재니 기대와 달랐다
40계정(경로당 제공 상한 200/s)으로 200 VU와 600 VU를 각각 제어 ON/OFF로 돌렸습니다.
| 부하 | 제어 | 앱 도달 | 통과 p95 | 5xx |
|---|---|---|---|---|
| 200 VU | OFF | 120.4/s | 1.46s | 0% |
| 200 VU | ON | 109.9/s | 1.68s | 0% |
| 600 VU | OFF | 123.2/s | 3.77s | 0% |
| 600 VU | ON | 115.1/s | 3.11s | 0% |
먼저 이 실험의 한계를 적어야 합니다. 이건 ramping-vus, 그러니까 폐쇄형 부하입니다. VU가 응답을 받아야 다음 요청을 보냅니다. 그런데 뒤에서 확인하듯 429는 1ms 안에 나갑니다. 제어를 켜면 VU가 훨씬 빨리 순환해서 요청을 더 많이 만듭니다. 즉 ON과 OFF에 같은 부하를 준 게 아닙니다.
ON/OFF 성능 차이를 확정하려면 constant-arrival-rate처럼 초당 도착률을 고정한 열린 모델로 다시 재야 합니다. 아래 수치는 그 전 단계의 탐색 실행으로 읽어야 맞습니다.
아이러니하게도 이 글의 주제가 그거입니다. 재는 방법이 틀렸다는 걸 찾고 고쳤는데, 고친 실험에도 같은 종류의 문제가 한 겹 더 남아 있었습니다. 이건 외부 리뷰가 짚어줘서 알았습니다.
전역 층은 확실히 깨어났습니다. 200 VU에서 1,355건, 600 VU에서 3,601건을 scope=global로 잘랐습니다. 이 프로젝트에서 전역 상한이 부하를 받은 건 이번이 처음입니다.
그런데 결론이 내가 적어둔 것과 달랐습니다.
서버가 5xx를 내지 않습니다. 제공 부하를 3배로 올려도 네 조건 모두 5xx가 0%입니다.
다만 “붕괴”를 5xx로만 정의한 게 약합니다. 600 VU에서 p95가 3.77초까지 올라갔는데, SLO에 따라서는 그 자체가 이미 장애입니다. 과부하는 5xx 말고도 타임아웃 직전의 장시간 대기, p99·max 폭증, DB 풀 고갈, 큐 적체로 나타납니다. 정확히 쓰면 “서버는 안 죽고 느려지기만 했다” 까지입니다. “제어가 붕괴를 막는다”는 적어도 이 구간에서는 성립하지 않았습니다.
표의 “앱 도달”이 전역 상한 100/s보다 큰 것은 고정 윈도우 때문입니다. 매초 초입에 버킷이 리셋돼 경계 순간에는 최대 두 배가 통과합니다. 그 버스트가 30초 평균에 섞여 109.9/s로 잡힙니다. 슬라이딩 윈도우면 이 초과가 줄어듭니다.
포화점 아래에서는 제어가 순손실입니다. 200 VU에서 켜면 처리량 −8.7%, p95 +15.1%로 두 축 모두 나빠집니다. 이유는 단순했습니다. 전역 상한 100/s가 측정된 처리 능력(~120/s)보다 낮습니다. 처리할 수 있는 일을 버리고 있었습니다.
100이라는 숫자가 어디서 나왔는지 찾아봤습니다. 그냥 내가 정한 값이었습니다. 측정한 적이 없었습니다.
포화점 위에서는 값을 합니다. 600 VU에서 처리량 −6.6%를 내주고 p95 −17.5%를 얻습니다. 그리고 거절이 쌉니다. 제어 ON의 제공량이 301/s로 OFF(123/s)의 2.4배인데, 이건 429가 1ms 안에 나가서 클라이언트가 훨씬 빨리 순환하기 때문입니다. 서버가 하는 일의 양은 그대로인데 대기열만 사라진 것입니다.
그래서 유입 제어가 실제로 사는 값은 “붕괴 방지”가 아니라 “포화 이후 지연 억제 + 싼 거절”이었습니다.
그럼 상한을 120으로 올렸나
안 올렸습니다.
~120/s는 맥북에서 앱·MySQL·Redis·k6를 한 머신에 올려놓고 잰 값입니다. 운영 하드웨어에서는 다릅니다. 로컬 실측으로 운영 기본값을 바꾸면 짐작을 다른 짐작으로 바꾸는 것뿐입니다.
대신 설정 파일에 이 값의 출처와 재측정 방법을 주석으로 박았습니다. 다음에 이 코드를 보는 사람이 “100이 어디서 나온 숫자냐”고 묻지 않아도 되게. 지금 정직하게 할 수 있는 건 그것까지였습니다.
실험하다 두 번 데었다
첫 번째. 대조군을 돌렸는데 실험군과 수치가 0.2% 차이로 겹쳤습니다. 우연일 리가 없었습니다.
pkill -f bootRun이 Gradle 래퍼만 죽이고 포크된 JVM은 살려둔 것이었습니다. 새 앱은 포트 충돌로 죽었고, 내 헬스체크는 옛 프로세스에 대고 통과했습니다. 그러니까 “제어 OFF 대조군”이 실은 제어 ON 2회차였습니다.
수치가 너무 똑같아서 들켰습니다. 조금만 달랐으면 그대로 문서에 들어갔을 것입니다.
재발을 막는 장치는 아직 없습니다. 지금은 실행 전에 포트를 확인하는 정도고, 헬스 응답에 빌드 ID와 프로필·제어 설정을 실어 “내가 방금 띄운 그 인스턴스가 맞는지”를 스크립트가 확인하게 해야 맞습니다. 이것도 후속으로 남깁니다.
두 번째. 같은 설정을 두 번 돌렸는데 처리량이 75/s와 93/s로 갈렸습니다. p95는 2.98초와 4.92초.
원인은 부하가 아니라 누적된 데이터였습니다. 매 실행이 MySQL에 주문·결제를 수만 건 남기고, 다음 실행은 더 커진 DB 위에서 돕니다. 실행 간 비교가 애초에 성립하지 않았습니다. 매 실행 전 docker compose down -v를 넣고 나서야 비교가 가능해졌습니다.
그러다 진짜를 발견했다: 아웃박스가 누적에 비례해 느려지고 있었다
두 번째 문제를 쫓다가 event_publication을 열어봤습니다. Spring Modulith의 아웃박스 테이블입니다.
150,372행.
두 가지가 겹쳐 있었습니다.
지우는 코드는 있었는데 꺼져 있었다
OutboxCleanupScheduler가 이미 있었습니다. 7일 지난 완료분을 지웁니다. 그런데 기본값이 false입니다. 켜지 않은 환경에서는 아무것도 안 지웁니다. 모든 로컬·개발 실행이 그렇고, 플래그를 빠뜨린 배포도 그렇습니다.
처음엔 이걸 “아무도 안 지운다”고 적었다가 스케줄러를 발견하고 고쳤습니다. 문제의 성질이 다릅니다. 없는 걸 만드는 게 아니라 기본값이 안전하지 않은 쪽으로 잡혀 있는 것입니다.
인덱스가 PK 하나뿐이었다
이쪽이 진짜였습니다. Modulith가 리스너 완료마다 실행하는 쿼리는 이렇습니다.
update event_publication set completion_date = ? where serialized_event = ? and listener_id = ? and completion_date is null두 컬럼 모두 인덱스가 없었습니다.
type: ALL key: NULL rows: 150372그리고 결제 완료 이벤트에는 리스너가 6개입니다. 정산·알림·원장·대사(결제 기록과 PG 정산 파일을 맞춰보는 작업)·에스크로·이상거래. 즉 결제 한 건당 이 풀스캔이 6회 돕니다.
여기서 무서운 건 이거입니다. 결제 지연이 현재 부하가 아니라 “그동안 처리한 이벤트 누적량”에 비례해 나빠집니다. 부하가 일정해도 시간이 갈수록 느려집니다. 그리고 원인이 결제 코드 어디에도 없습니다.
부하 시험을 반복할수록 느려진 게 이것이었고, 운영이었다면 서서히, 되돌리기 어렵게 나빠졌을 것입니다.
다만 고친 뒤 수치가 없습니다. EXPLAIN의 예상 rows: 150372까지만 있고, 인덱스를 넣은 뒤 실행 계획이 어떻게 바뀌었는지, 완료 UPDATE의 p95가 얼마나 줄었는지는 안 쟀습니다. “느려질 것”과 “느렸다”는 다르고, “고쳤다”와 “고쳐진 걸 봤다”도 다릅니다. 이 글의 주제가 그건데 정작 여기서 안 지켰습니다. 후속으로 남깁니다. 그 시점엔 “왜 요즘 결제가 느리지”로 시작해서 한참 헤맸을 것 같습니다.
고친 방식
completion-mode를 archive로 바꿨습니다. 완료분이 아카이브 테이블로 빠지니 핫 테이블에는 “아직 처리 안 된 것”만 남습니다. 정상 소비 중에는 핫 테이블 크기가 전체 이력이 아니라 미완료 적체량에 비례하게 됐습니다. 무한히 안 큰다는 뜻은 아닙니다. 리스너가 죽어 미완료가 계속 쌓이면 그대로 커집니다.
delete가 아니라 archive를 고른 건 “무엇이 언제 발행됐는가”를 잠깐이라도 남겨두고 싶어서입니다. 다만 보존 7일에 접근 통제나 불변성 장치가 없으니 감사 자료가 아니라 운영 추적 자료로 부르는 게 맞습니다. 기존 스케줄러는 대체하지 않고 짝을 이룹니다. 아카이브가 핫 테이블을 작게 유지하고, 스케줄러가 아카이브를 보존기간 뒤에 비웁니다.
인덱스도 넣었습니다. 아카이브가 있어도 필요합니다. 아카이브만 하면 평시엔 빠르지만 장애로 미완료가 쌓인 순간 다시 풀스캔으로 돌아갑니다. 하필 가장 급할 때.
곁가지로 하나 더
아카이브 모드로 바꾸니 핫 테이블 행 수가 곧 처리 대기분이 됐습니다. 부하 직후 22,492건이 남아 있다가 몇 분 뒤 6,174건으로 빠지는 게 눈에 보였습니다.
@ApplicationModuleListener는 커밋 이후 별도 스레드에서 돕니다. 결제가 200으로 응답한 시점에 정산·원장은 아직일 수 있습니다. 이 적체 자체는 정상입니다.
문제는 그게 보이지 않았다는 것입니다. 정상적인 적체와 소비가 멈춘 상태는 겉으로 똑같이 “결제는 되는데 원장이 안 쌓인다”로 보입니다.
게이지를 둘 올리되 알람은 나이에 걸었습니다. 수천 건이 밀려도 빠지는 중이면 정상이고, 10건뿐이어도 30분째 그대로면 사고입니다. 개수는 그 둘을 구분하지 못합니다. 미확정 결제에 payment.unknown.oldest.age를 붙인 것과 같은 판단입니다.
세 번 같은 실수를 했다
오늘 고친 것들 사이에 공통점이 있었습니다.
- 감사 로그 서비스가 만들어져 있는데 호출부가 없었습니다.
- 비밀번호 이관 서비스를 만들고 단위 테스트만 썼습니다. 불렸을 때 뭘 하는지는 검증했는데 불리기는 하는지는 안 봤습니다.
- 결제사 라우팅도 로직 테스트는 7개 있는데, 플래그를 켰을 때 실제로 꽂히는지는 아무도 검증하지 않았습니다.
전부 있는지만 보고 불리는지를 안 본 것입니다. 세 번째에서야 패턴이 보였습니다.
라우팅은 특히 조용하지 않게 실패합니다. 조건이 어긋나 두 후보가 동시에 살면 주입이 모호해져 기동이 실패하고, 둘 다 죽으면 주입 대상이 없어 역시 기동이 실패합니다. 어느 쪽이든 장애 상황에서 이중화를 켜려는 순간에 발견하게 됩니다. 가장 나쁜 시점입니다.
셋 다 배선 테스트를 붙였습니다.
남은 것
전역 상한 100/s는 그대로 뒀습니다. 배포 환경마다 다시 재야 하는 값이고, 그 방법을 주석으로 남겨뒀습니다.
Argon2 파라미터도 마찬가지입니다. 19MiB·t=2는 OWASP 최소값이지만 32ms와 20MB는 이 맥에서 잰 숫자입니다.
세마포어로 동시 해시 수를 직접 묶는 것도 검토했다가 안 했습니다. 다만 “유입 제어가 이미 그 층을 맡는다”는 틀린 이유였습니다. 둘은 제어하는 자원이 다릅니다. RPS(초당 요청 수) 제한은 단위 시간당 시작하는 작업 수를 묶고, 동시성 제한은 동시에 점유하는 메모리 총량을 묶습니다. 초당 100건으로 제한해도 버스트 순간에는 해시가 겹칠 수 있습니다. tryAcquire()로 즉시 거절하면 큐도 안 생깁니다.
정확한 이유는 이거입니다. 동시 로그인 부하와 서버 메모리 예산을 아직 안 재서 상한값을 정할 근거가 없습니다. 후속 과제로 남겼습니다.
배운 게 하나 있다면, 실험이 성립했는지를 실험 자체가 말해주게 만들어야 한다는 것입니다. 다계정 스크립트에는 shed_global > 0을 임계치로 걸어뒀습니다. 이게 0이면 다른 지표가 아무리 초록색이어도 전역 층은 개입한 적이 없는 것이고, 그 실행은 아무것도 증명하지 못합니다.
원래 실험이 딱 그 상태였습니다. 다 초록색이었고, 아무것도 증명하지 않았습니다.
상황 2. 그러라고 만든 검사기가 헛통과했다
앞 상황을 겪고 “검사가 스스로 성립했는지 말하게 하자”는 도구를 만들었습니다. 그 도구로 만든 검사기가 하루에 두 번 통과했습니다. 한 번은 대상을 0개 찾고, 한 번은 아예 실행되지 않고.
같은 실수를 세 번 하고 나서 규칙으로 만들었다
앞 상황은 이렇게 끝났습니다. 감사 로그 서비스에 호출부가 없었고, 비밀번호 이관 서비스는 배선 확인 없이 단위 테스트만 있었고, 결제사 라우팅도 켰을 때 실제로 꽂히는지 아무도 검증하지 않았습니다. 전부 있는지만 보고 불리는지를 안 본 것이었습니다.
셋 다 개별 테스트를 붙여서 막았습니다. 그런데 남은 게 있었습니다.
배치가 열 개입니다. 전부 기본 off인데, 켜려면 두 가지가 같은 프로퍼티로 함께 켜져야 합니다.
@Component@ConditionalOnProperty(name = "app.settlement.enabled", havingValue = "true")class SettlementScheduler { @Scheduled(fixedDelayString = "...") public void run() { ... }}
@Configuration@EnableScheduling@ConditionalOnProperty(name = "app.settlement.enabled", havingValue = "true")class SettlementSchedulingConfig { }둘 중 하나만 켜지면 어떻게 되냐면, 아무 일도 안 일어납니다. 빈은 정상 등록되고, 기동 로그도 깨끗하고, @Scheduled 메서드만 영원히 안 불립니다. 예외도 경고도 없습니다.
정산 배치를 켠 줄 알았는데 정산이 안 되고 있는 상태를, 며칠 뒤 대사에서야 알게 됩니다.
지금은 열 쌍이 전부 맞게 짝지어져 있습니다. 확인했습니다. 문제는 그걸 지켜주는 게 각 클래스의 javadoc뿐이라는 것이었습니다. 스케줄러를 하나 더 추가하거나 프로퍼티 이름을 바꾸는 순간 조용히 어긋납니다.
열 개를 개별 테스트로 막기엔 수가 많습니다. 그래서 인스턴스 대신 규칙 자체를 검사하기로 했습니다. @Scheduled를 가진 모든 클래스에 대해, 같은 게이트를 쓰는 @EnableScheduling 설정이 존재하는가.
그 검사기가 0개를 찾고 통과할 뻔했다
Spring이 클래스패스 스캐너를 제공합니다. 이걸 쓰면 되겠다 싶었습니다.
var provider = new ClassPathScanningCandidateComponentProvider(false);provider.addIncludeFilter((TypeFilter) (reader, factory) -> true);provider.findCandidateComponents("com.beomsu.pay");테스트가 실패했습니다. 스케줄러를 0개 찾았습니다.
원인은 이랬습니다. ClassPathScanningCandidateComponentProvider는 후보를 모으면서 @Conditional을 평가합니다. 그리고 이 프로젝트의 배치는 전부 기본 off입니다. 즉 검사하려는 대상이 정확히 “지금 조건을 만족하지 않는 클래스들”인데, 스캐너가 바로 그것들을 걸러낸 것입니다.
여기서 중요한 건 왜 알아챘느냐입니다.
assertThat(schedulerGates) .as("@Scheduled 스케줄러를 찾지 못했다면 이 테스트는 아무것도 검증하지 못한다") .hasSizeGreaterThanOrEqualTo(10);
assertThat(schedulerGates).allSatisfy((scheduler, gate) -> ...);두 번째 줄만 있었으면 이 테스트는 통과했을 것입니다. 빈 컬렉션에 대해 allSatisfy는 참입니다. 검사 대상이 0개인 검사는 언제나 성공합니다.
그러니까 이건 이렇게 될 뻔했습니다. 이름은 SchedulerGatePairingTest이고, CI에서 초록불이고, 아무것도 검증하지 않습니다. “있는데 안 불리는” 버그를 막으려고 만든 테스트가, “있는데 아무것도 안 보는” 같은 종류의 물건이 될 뻔한 것입니다.
상황 1에서 k6 스크립트에 shed_global > 0을 임계치로 걸어둔 것과 정확히 같은 장치였습니다. 그때는 “실험이 성립했는지를 실험이 스스로 말하게 하자”였고, 여기서는 “검사가 대상을 찾았는지를 검사가 스스로 말하게 하자”였습니다. 우연히도 하루 만에 두 번 같은 도구가 필요했습니다.
고친 방법
조건을 평가하지 않고, 클래스를 로드하지도 않고, 바이트코드의 애너테이션 메타데이터만 읽습니다.
var resolver = new PathMatchingResourcePatternResolver();var factory = new CachingMetadataReaderFactory(resolver);for (var resource : resolver.getResources("classpath*:com/beomsu/pay/**/*.class")) { AnnotationMetadata metadata = factory.getMetadataReader(resource).getAnnotationMetadata(); if (metadata.hasAnnotatedMethods(Scheduled.class.getName())) { ... }}열 개를 다 찾았고 열 쌍 다 맞았습니다.
이 검사가 증명하는 건 좁습니다. @Scheduled 클래스와 @EnableScheduling 설정이 같은 프로퍼티 조건을 갖는다는 정적 배선까지입니다. 실제 프로필에서 프로퍼티가 주입되는지, 컨텍스트에 등록되는지, 메서드가 정말 호출되는지는 별개입니다. 그건 대표 스케줄러 하나를 실제 컨텍스트에서 켜고 호출을 관찰하는 통합 테스트가 있어야 하는데 아직 없습니다.
그리고 일부러 깨뜨려봤습니다. 짝이 되는 설정의 게이트 이름 하나를 오타로 바꿨습니다.
- @ConditionalOnProperty(name = "app.outbox.cleanup.enabled", ...)+ @ConditionalOnProperty(name = "app.outbox.cleanup.TYPO", ...)실패했습니다. 원복하니 다시 통과했습니다. 이 확인을 안 하면 결국 “통과하는 걸 봤다”까지밖에 모릅니다. 초록불이 무엇을 뜻하는지는 빨간불을 한 번 봐야 알 수 있습니다.
기본 off인 배치를 실제로 켜서 돌려보다 앞의 주장 하나가 덤으로 검증되기도 했습니다.
상황 1에서 아웃박스를 고치면서 “아카이브 모드는 기존 정리 스케줄러를 대체하는 게 아니라
짝을 이룬다”고 적었는데, 정리 배치가 deleted=6을 찍었고 그 6건은 아카이브 테이블에서 나왔습니다.
적어둔 대로였습니다. 문서에 적은 걸 나중에 확인해보는 건 생각보다 자주 필요합니다.
적을 때는 맞는 줄 알고 적으니까.
문서가 실재하지 않는 테이블을 설명하고 있었다
여기까지 하고 “이제 없다”고 말하려다가 한 가지만 더 봤습니다.
ERD 문서가 outbox_events라는 채택되지 않은 설계를 설명하고 있는 걸 앞서 발견한 적이 있습니다. 실제로는 Modulith의 event_publication을 쓰는데 문서가 결정 이전 상태에 멈춰 있었던 것입니다. 그때는 그 한 건만 고쳤습니다.
그런데 그게 하나뿐이었을까? 확인해볼 수 있는 일이었습니다. 마이그레이션이 실제로 만드는 테이블 목록과, 문서가 CREATE TABLE로 설명하는 테이블 목록을 대조했습니다.
네 개가 더 있었습니다.
| 문서 | 실제 |
|---|---|
ledger_accounts | 테이블이 아니라 enum AccountType |
payment_cancels | 안 만듦. 취소 이력은 payment_history에 함께 쌓는다 |
pg_transactions | 안 만듦. 대사 중에만 필요해 비영속 record로 흘린다 |
settlement_details | 이름이 settlement_items로 바뀜 |
이런 어긋남은 조용합니다. 코드는 잘 돌고, 테스트도 통과하고, 문서만 틀립니다. 그리고 그 문서를 믿고 쿼리를 짜거나 설계를 설명하는 사람이 틀리게 됩니다. 대개 “그 테이블이 왜 없죠?”라는 질문으로 발견됩니다.
전부 표시하고, 앞에서 한 것과 같은 방식으로 규칙을 검사하게 만들었습니다. ERD 문서의 모든 CREATE TABLE은 실물이거나, 실물이 아님이 같은 줄에 표시되어 있거나 둘 중 하나여야 합니다.
안 만든 설계를 문서에 남기는 것 자체는 막지 않았습니다. “왜 그렇게 안 했는지”가 기록으로 남는 건 가치가 있습니다. 다만 표시 없이 남기는 건 막습니다. 표시가 없으면 읽는 사람이 그게 실물인 줄 압니다.
그 검사가 안 돌고 있었다
앞에서 배운 대로, 만들자마자 깨뜨려봤습니다. (미구현) 표시를 지우고 테스트를 돌렸습니다.
통과했습니다.
BUILD SUCCESSFUL in 613ms613밀리초. 테스트가 아예 실행되지 않은 것입니다. Gradle은 test 태스크의 입력이 무엇인지 압니다. 자바 소스와 리소스입니다. .md 파일은 거기 없습니다. 그러니 문서만 고치면 “바뀐 게 없다”고 판단해 태스크를 통째로 건너뜁니다.
즉 문서와 스키마가 어긋나는 걸 잡으라고 만든 검사가, 정확히 문서만 바뀌었을 때 안 도는 상태였습니다.
이건 증분 빌드 기준입니다. 매번 깨끗한 환경에서 도는 CI라면 태스크가 그냥 실행됩니다. 다만 원격 캐시를 쓰면 같은 문제가 CI에서도 납니다.
CI는 초록불입니다. 검사는 돌지 않습니다. 둘이 동시에 참입니다.
고치는 건 간단했습니다.
inputs.dir('docs').withPropertyName('docs')inputs.dir('src/main/resources/db/migration').withPropertyName('migrations')이제 문서만 고쳐도 재실행됩니다. 다시 표시를 지워보니 이번엔 제대로 실패했습니다.
하루에 두 번입니다. 앞에서는 검사기가 대상을 0개 찾고 통과할 뻔했고, 여기서는 검사기가 아예 실행되지 않고 통과했습니다. 실패하는 방식은 달랐는데 결과는 같았습니다. 초록불이고, 아무것도 검증되지 않았습니다.
“검사를 만들었다”와 “그 검사가 실제로 돈다”는 다른 문장입니다. 전자는 코드를 쓰면 참이 되지만, 후자는 빨간불을 한 번 봐야 참인지 알 수 있습니다.
문서 전체를 코드와 대조했다
ERD에서 다섯 개가 나오고 나니 질문이 남았습니다. 문서가 코드보다 앞서 있는 자리가 ERD뿐일까.
CREATE TABLE만 봤으니 본 것도 그만큼입니다. 그래서 대상을 넓혔습니다. 문서에서 백틱으로 감싼 식별자를 전부 뽑아 코드와 대조하는 스크립트를 썼습니다.
문서 27개 · 코드 파일 335개 검사⚠ 코드에서 못 찾은 심볼 24개24개 중 21개는 정당한 인용이었습니다.
| 분류 | 예 |
|---|---|
| 외부 라이브러리 | ChatModel(Spring AI), DefaultErrorHandler(Spring Kafka) |
| 외부 시스템 스펙 | PAYMENT_STATUS_CHANGED: 토스 웹훅 이벤트명 |
| 채택 안 한 대안 | OrderTimelineContributor: 조립 방식을 정할 때 검토했다 버린 안 |
| ”없다”는 서술 | CAPTURED: “pay에 CAPTURED 같은 상태는 없다” |
| Prometheus 알람 | GlobalRateLimitBinding: Java가 아니라 alert-rules.yml에 실재 |
마지막 건 내 감사 스크립트가 Java만 훑어서 놓친 것입니다. 감사도 감사받아야 합니다.
남은 3개가 진짜였다
API 스펙이 오지 않는 에러 코드를 문서화하고 있었습니다.
| 문서가 말한 것 | 실제로 나가는 것 |
|---|---|
CONCURRENT_MODIFICATION (409) | STOCK_CONCURRENCY / WALLET_CONCURRENCY |
PG_ERROR (502) | 안 나감 |
PG_TIMEOUT (504) | 안 나감 |
클라이언트 입장에서 두 번 손해입니다. 오지 않을 코드로 분기를 만들고, 실제로 오는 코드는 문서에 없어서 못 다룹니다. 결제 API에서 이건 재시도 로직이 통째로 어긋난다는 뜻입니다.
PG 오류에 502/504가 없는 건 사실 설계 의도였습니다. PG가 실패하거나 응답이 없으면 결제를 UNKNOWN으로 보존하고 복구 배치가 조회로 확정합니다(1편). 그래서 클라이언트가 받는 건 별도 HTTP 코드가 아니라 승인 응답의 상태값입니다. 문서만 옛날 계획을 붙들고 있었습니다.
재발을 막는 테스트를 붙였습니다. 스펙 표에서 코드를 뽑아 실제로 던지는 문자열과 대조합니다. 앞의 ERD 문서 대 마이그레이션과 같은 발상입니다. 문서가 코드보다 앞서가는 걸 사람이 알아채길 기다리지 않습니다.
두 상황을 관통하는 것
둘 다 초록불인데 아무것도 증명되지 않은 상태였습니다.
- 상황 1. 다 초록색이었고 전역 층은 개입한 적이 없었습니다. 실험이 성립하지 않았습니다
- 상황 2. CI는 초록불이었고 검사기는 대상을 0개 찾거나 아예 실행되지 않았습니다
상황 1에서 배운 게 상황 2의 도구가 됐습니다. k6 스크립트에 shed_global > 0을 임계치로 건 것과,
검사기에 “대상을 하나라도 찾았는가” 하한을 건 것은 같은 장치입니다.
성립하지 않은 실험과 아무것도 안 보는 검사는 겉으로 똑같이 초록색입니다.
그래서 이제 검사를 하나 만들 때마다 반드시 두 가지를 합니다.
대상을 하나라도 찾았는지 하한을 걸고, 일부러 깨뜨려서 빨간불을 봅니다.
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.