장애 하나 를 당기니 버그 다섯 이 딸려 나왔다 — K8s·Java 운영 하루 의 시니어 회고
“정산 이 왜 안 되지?” — 이 한 문장 으로 시작 한 하루 가, 서로 다른 버그 다섯 개 를 꿰어 놓은 체인 이었다. 프로덕션 500, 시한폭탄 테스트, Docker Hub rate limit, 유틸 컨테이너 함정, Dockerfile 드리프트. 이 글 은 그 하루 를 10년차 백엔드 시니어 의 관점 으로 복기 한다 — 무엇 을 고쳤는지 보다, 어떤 순서 로 생각 했는지 를 남기기 위해.
0. 증상 — “정산 이 안 된다”
웹 콘솔 에 500 이 주르륵. /api/settlements/search, /api/financial/companies… 화면 은 그럴듯 한데 데이터 가 안 뜬다.
주니어 의 반사 는 “서버 죽었나?” 다. 하지만 먼저 잰 것:
- 사이트 응답: 200, 0.65s ✅
- pod: 전부 Running, 재시작 0 ✅
actuator/health: db 포함 전부 UP ✅- outbox: PENDING 0, FAILED 0 ✅
인프라 는 멀쩡 하다. 그럼 백엔드 로그 — 500 의 정체 는 전부 NoResourceFoundException. “그런 API 경로 가 없다”. 즉:
배포 된 프론트 = 최신 (
main-2f8a180, 새 화면 포함) 배포 된 백엔드 = 구버전 (main-6468c05, 그 API 없음)
버전 스큐(version skew) 다. 새 화면 이 새 API 를 부르는데 옛 백엔드 가 “그런 거 없는데?” 한 것. 고장 이 아니라 배포 반쪽 상태.
🧭 시니어 감각 #1 — 증상 에서 바로 원인 으로 점프 하지 마라. “500 = 서버 고장” 이 아니다. 계층 별 로 (외부 응답 → pod → health → 로그) 내려가면, 500 의 정체 가 다르게 보인다. 참고 로 이 앱 은 없는 경로 도 404 대신 500 을 냈다 — 이런 위생 문제 는 진단 을 한 단계 더 헷갈리게 만든다.
1. 왜 반쪽 만 배포 됐나 — 시한폭탄 테스트
백엔드 이미지 가 안 나온 이유: main 머지 커밋 의 CI 에서 백엔드 테스트 1 개 가 실패 → 이미지 push 가 skip → 프론트 만 넘어감.
실패 한 테스트 를 까 보니 걸작 이었다:
- 테스트 가 webhook 의
startsAt을2026-07-06T05:12:00Z절대시각 으로 하드코딩 - 검증 대상 summary API 는 24시간 윈도우 (
firstSeenAt >= now-24h) 로 집계 - → 작성 다음 날 부터, 모든 CI 가 영원히 실패
증거 도 명확 했다. 같은 테스트 의 openTotal 어서션(시간 필터 없음)은 통과 하고, byCategory(시간 필터 있음)만 죽었다. 시간 이 지나며 저절로 깨지는 시한폭탄.
🧭 시니어 감각 #2 — 테스트 에서
Instant.now()와 하드코딩 절대시각 을 섞으면 시한폭탄 이 된다. 고정 시각 을 쓰려면 Clock 주입 으로 양쪽 다 고정 하고, 실시간 윈도우 를 검증 하려면 데이터 도 상대시각 으로 만들어라.
수정 은 한 줄 (상대시각). 그런데 PR 을 올리니 이번엔 다른 테스트 가 컴파일 에러 — 머지 사고 로 테스트 메서드 두 개 가 통째로 중복 삽입 돼 있었고, 중복본 은 이미 사라진 옛 시그니처 를 쓰고 있었다. 체인 의 두 번째 고리.
2. CI 가 40분 씩 멈춘다 — Docker Hub rate limit
두 버그 를 고치고 CI 를 돌리니, 이번엔 :order-service:test 에서 40분 넘게 침묵. 로그 를 시간 순 으로 읽으니 테스트 JVM 은 떴는데 첫 테스트 출력 이 없다 — Testcontainers 이미지 pull 단계 에서 서 있는 것.
원인: GitHub Actions 러너 는 공유 IP 라 Docker Hub 익명 pull limit 에 수시로 걸린다. 걸리면 에러 도 아니고 그냥 하염없이 기다린다. 이게 최악 인 이유 — 실패 는 원인 을 말해 주지만, hang 은 아무 말 도 안 한다.
해법 은 우회 가 아니라 원천 차단:
mirror-testcontainers.yml (수동 dispatch)
Docker Hub 의 cp-kafka / postgres / redis 를
→ ghcr.io/<owner>/mirror/* 로 복사
각 모듈 testcontainers.properties
hub.image.name.prefix=ghcr.io/<owner>/mirror/
→ 모든 테스트 이미지가 자동으로 ghcr 미러에서 pull
ci.yml
docker login ghcr (GITHUB_TOKEN) + 서비스컨테이너도 미러로
CI 가 Docker Hub 를 아예 안 쓰게 만들었다. 새 시크릿 도 필요 없다 — GITHUB_TOKEN 으로 충분.
🧭 시니어 감각 #3 — 외부 의존 은 “가끔 느려지는 것” 이 아니라 “언젠가 반드시 막히는 것” 으로 설계 하라. rate limit 는 재시도 로 못 푼다. 미러·캐시 로 의존 자체 를 내 통제권 안 으로 가져와야 한다.
3. 미러 를 깔았는데 도 죽는다 — ryuk 의 함정
미러 후 첫 런: 305개 중 304개 통과, 딱 1개 실패. 두 번 돌려도 같은 자리. flaky 가 아니라 구조다.
타임아웃 으로 죽으면 리포트 가 빈약 해서, 테스트 리포트 아티팩트 를 내려받아 HTML 속 스택 을 통째로 열었다. 범인:
Can't get Docker image:
ghcr.io/…/mirror/testcontainers/ryuk:0.14.0
ryuk — Testcontainers 가 테스트 뒤 컨테이너 를 청소 하려고 몰래 띄우는 유틸 컨테이너 다. hub.image.name.prefix 가 ryuk 에도 적용 되는데, 미러 에는 앱 이미지 4개 만 넣었으니 ryuk 이 없어서 fetch 2분 타임아웃.
- 미러 에
testcontainers/ryuk추가 - 그리고 CI 에선
TESTCONTAINERS_RYUK_DISABLED=true— 일회용 러너 에서 청소부 는 필요 없다. job 끝나면 러너 가 통째로 사라지니까.
🧭 시니어 감각 #4 — 추상화 가 숨긴 부품 까지 가 내 시스템 이다. 이미지 prefix 를 걸면 “내가 아는 이미지” 만 바뀌는 게 아니다. 프레임워크 가 몰래 쓰는 유틸 이미지(ryuk, sshd…) 도 함께 치환 된다. 그리고 진단 이 막히면 로그 말고 아티팩트(리포트 원본) 로 내려가라.
4. 프로덕션 은 기다려 주지 않는다 — 응급 레버 와 관심사 분리
그 사이 프로덕션 은 여전히 버전 스큐. CI 는 이제 비결정적 hang 까지 보이기 시작 했다(ryuk 을 꺼도 재발 — 별도 추적 중). 여기서 갈림길:
“CI 를 다 고칠 때까지 배포 를 기다린다” vs “배포 와 CI 건강 을 분리 한다”
나는 후자 를 택했다. 근거 는 데이터 — 직전 런 들 에서 305개 테스트 중 인프라 사유 1건 빼고 전부 통과. 코드 신뢰도 는 이미 확보 됐다. 그래서:
backend-image-emergency.yml(수동 dispatch 전용) — 테스트 skip, 이미지 만 빌드·push. 태그·빌드인자 는 정규 CI 와 100% 동일 (image-updater 가 그대로 픽업)- 첫 실행 은 실패 — Dockerfile 에 새 모듈 COPY 누락 (다섯 번째 버그:
settings.gradle.kts에 모듈 을 추가 하면서 Dockerfile 은 아무도 안 고침). 보완 후 성공 - ArgoCD 픽업 → rollout 3/3 → 아까 500 나던 4개 경로 전부 401(정상 인증 요구) 로 회복. 스큐 종결.
🧭 시니어 감각 #5 — “배포 를 풀 것” 과 “파이프라인 을 고칠 것” 은 다른 문제 다. 섞으면 프로덕션 이 CI 디버깅 의 인질 이 된다. 단, 응급 레버 는 (1) 수동 전용 (2) 정규 경로 와 동일 산출물 (3) 왜 존재 하는지 주석 — 이 세 가지 를 갖춰야 백도어 가 아니라 레버 다.
덤 으로 알게 된 것: “CI 도는 중 인데 머지 가 되네?” — 브랜치 보호 를 확인 하니 required check 가 수 초 만에 끝나는 paths-filter 하나 뿐이었다. 게이트 는 있는 것 처럼 보이는 것 과 실제 로 막는 것 이 다르다. Backend/Frontend 체크 를 필수 로 걸어 진짜 게이트 로 바꿨다.
5. 남은 유령 — hang 을 “진단 가능한 실패” 로 바꾸기
ryuk 을 꺼도 order-service:test 가 가끔 70분+ 멈춘다. 원격 러너 라 스레드덤프 도 못 뜬다. 이럴 때 시니어 가 하는 일 은 한 방 해결 이 아니라 다음 재현 때 범인 이 자동 으로 찍히게 만드는 것:
tasks.withType<Test>().configureEach {
// hang → 20분 뒤 강제 실패 (러너 6시간 낭비 방지 + 실패 리포트 확보)
timeout.set(Duration.ofMinutes(20))
// CI 에서만 테스트별 시작 로그 — 마지막 STARTED 가 곧 범인
if (System.getenv("CI") != null) {
testLogging { events("started", "failed", "skipped") }
}
}
- 첫 덫 은 이미 작동 했다: 90분 방치 되던 hang 이 27분 만에 실패 로 잡혔고, hang 모듈 이 order-service 로 고정 임을 확인
- 용의군 도 좁혔다: 동시성 IT 들 의 타임아웃 없는
latch.await()— 워커 스레드 하나 가 준비 신호 전에 죽으면 메인 이 영원히 기다리는, 비결정성 과 정확히 일치 하는 패턴 - 다음 재현 때는 콘솔 의 마지막
STARTED가 범인 의 이름 을 말해 줄 것이다
🧭 시니어 감각 #6 — 못 잡는 버그 는 “잡히는 형태” 로 먼저 바꿔라. hang 은 정보 가 0 이다. 타임아웃 + 시작 로그 로 hang 을 “이름 있는 실패” 로 변환 하면, 다음 발생 이 곧 해결 이 된다. 그리고 동시성 테스트 의
await()에 타임아웃 이 없다면, 그건 테스트 가 아니라 잠재적 데드락 이다.
6. 하루 의 스코어보드
| # | 버그 | 교훈 |
|---|---|---|
| 1 | 절대시각 하드코딩 + 24h 윈도우 = 시한폭탄 테스트 | 시간 은 주입 하거나, 전부 상대시각 으로 |
| 2 | 머지 사고 로 테스트 블록 중복 (옛 시그니처) | 머지 커밋 도 리뷰 대상 이다 |
| 3 | Docker Hub rate limit 로 CI hang | 외부 의존 은 미러 로 내재화 |
| 4 | prefix 가 ryuk 까지 치환 → 없는 미러 fetch | 프레임워크 의 숨은 부품 까지 챙겨라 |
| 5 | Dockerfile 에 새 모듈 COPY 누락 | 모듈 추가 체크리스트 에 Dockerfile 포함 |
| + | 무늬 만 브랜치 보호 / 404 대신 500 / 무한 await() | 게이트·위생·경계 는 평소 에 |
그리고 구조물: ghcr 미러 체계, 응급 이미지 레버, 테스트 타임아웃 + 진단 로깅, 진짜 브랜치 보호.
맺으며 — 시니어 는 “더 빨리 고치는 사람” 이 아니다
이 하루 에서 코드 를 고친 시간 은 얼마 안 된다. 대부분 은 관측 하고, 가설 을 세우고, 증거 로 좁히는 시간 이었다:
- 500 을 보고 서버 부터 재시작 하지 않고 계층 을 쟀다
- “flaky 같은데” 에서 멈추지 않고 두 번 재현 → 아티팩트 → 이미지 이름 까지 내려갔다
- 못 잡는 hang 은 잡히는 실패 로 먼저 변환 했다
- 프로덕션 복구 와 근본 수정 을 분리 했다
10년차 의 가치 는 타이핑 속도 가 아니라 장애 앞에서 의 순서 감각 이다. 무엇 을 먼저 재고, 무엇 을 나중 에 고치고, 무엇 을 다음 재현 에 맡길지. 그 순서 가 하루 짜리 사고 를 일주일 짜리 로 만들지 않는다.
장애 는 시스템 의 시험 이지만, 대응 순서 는 엔지니어 의 시험 이다.