“정산 이 왜 안 되지?” — 이 한 문장 으로 시작 한 하루 가, 서로 다른 버그 다섯 개 를 꿰어 놓은 체인 이었다. 프로덕션 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 의 startsAt2026-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.prefixryuk 에도 적용 되는데, 미러 에는 앱 이미지 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년차 의 가치 는 타이핑 속도 가 아니라 장애 앞에서 의 순서 감각 이다. 무엇 을 먼저 재고, 무엇 을 나중 에 고치고, 무엇 을 다음 재현 에 맡길지. 그 순서 가 하루 짜리 사고 를 일주일 짜리 로 만들지 않는다.

장애 는 시스템 의 시험 이지만, 대응 순서 는 엔지니어 의 시험 이다.


관련: 운영 에서 아픈 설계 6가지 · GitOps 자동배포 의 안전장치 · 쿠버네티스 운영 을 KPI 로