← 개발 로그 목록

website: 어드민 배포 이력 — 두 트랙 라인 차트에서 CD 파이프라인 타임라인으로

/ 10분 분량 / 개발 로그

어드민 `/admin/infra`의 배포 이력 화면을 앱·DB 두 트랙 라인 차트에서, 실제 CD 파이프라인(`cd.yml`)의 job 그래프를 반영한 타임라인으로 바꾼 PR이다.

처음 시작은 배포 이력 API(#451)를 어드민 화면에서 볼 수 있게 하는 작업이었다. 앱 트랙·DB 트랙 두 개의 가로 트랙을 누적 마이그레이션 칸수만큼 나아간 막대로 그리고, 배포 실패나 마이그레이션 미반영 시 뒤처진 트랙만 짧아지는 구조로 첫 버전을 만들었다. 만들고 나서 바로 문제가 보였다. ECharts line 시리즈에서 포인트별 itemStyle.borderColor가 실제로는 렌더링이 안 되고 있었다 — 흰 점 위에 색 테두리를 얹는 2겹 구조였는데 겹침·중복으로 보이던 원인이 바로 이거였다. 이 버그를 고치면서 라벨도 sha·마이그레이션 버전 문자열 대신 배포 시각(KST)으로 바꿨다. 처음 보는 사람 기준으로 뭐가 더 바로 읽히는지가 기준이었다.

그런데 버그를 고치고 나서도 차트 자체의 구조적인 문제가 남아 있었다. 앱=y1, DB=y0 고정 높이 두 직선이라 그 높이 자체엔 의미가 없었고, 결정적으로 "배포는 실패했지만 DB는 안전한 경우"(빌드 실패, 마이그레이션 차단 등)와 "DB가 실제로 어긋난 경우"(롤백 실패)를 똑같은 빨간 점선으로 뭉뚱그리고 있었다. 이 화면을 보는 사람 입장에서 정작 제일 위험한 걸 못 골라내는 구조였다. 라인 차트라는 형식 자체를 갈아엎기로 했다.

job DAG를 outcome 하나로 되짚기

cd.yml의 job 그래프는 config → migration-check → build → deploy·검증 → 사후처리 → record-deploy-status 순으로 흐르고, 최종적으로 record-deploy-status가 if/elif로 outcome(confirmed / rolled_back / rollback_failed / manual_intervention_needed / migration_check_blocked / build_failed / unknown) 값 하나를 판정해서 기록한다. 이 outcome 하나로부터 배포가 어느 단계에서 멈췄는지를 거꾸로 되짚어 보여주는 방식을 처음엔 6단계 점 스트립(deriveStages/StageStrip)으로 구현했다.

만들고 나서 실사용 피드백을 받았는데, "점이 왜 여러 개인지, 뭘 뜻하는지 화면만 보고는 모르겠다"는 지적이었다. 과정을 그대로 노출하는 게 이 화면을 보는 사람(당직·온콜)이 원하는 정보가 아니었다. 필요한 건 "지금 무슨 상태고, 내가 뭘 해야 하는가" 하나뿐이었다. 그래서 job 단위 상태 점 스트립을 걷어내고, cd.yml이 각 갈림길에서 실제로 사람에게 남기는 안내문(rollback/manual-intervention job의 echo 메시지, RUNBOOK 참고)을 그대로 옮겨 outcome 하나당 한 문장으로 압축한 actionGuidance로 바꿨다. 정상 배포면 할 일이 없으니 문장 자체가 안 뜨게 했다.

이 교체 과정에서 리뷰하면서 실측 오류도 하나 발견했다. migration_check_blocked일 때 deploy job은 migration-check와 build 둘 다에 독립적으로 의존한다(deploy: needs: [config, migration-check, build]). migration-check가 막혀도 build는 병렬로 계속 진행되는데, 그 결과가 기록에 안 남는 상태에서 화면은 빌드 단계를 항상 성공(dim)으로 고정 표시하고 있었다. unknown으로 정정했다. manual_intervention_needed도 destructive 여부가 unknown인 경우(workflow_dispatch로 직접 트리거해서 마이그레이션 배열이 비어 있는 경우)에 "삭제·변경형 포함"이라고 단정하는 문구가 고정 노출되고 있었던 것도 같이 고쳤다. 확인 안 된 걸 성공이나 위험으로 단정하지 않는 방향으로 정리한 셈이다.

"빌드는 성공했으니 A거나 B일 수 있어요"를 실제 원인으로

배포 결과와 DB 정합성을 서로 다른 배지로 분리하고, 연속으로 DB가 어긋난 구간은 시작·지속시간·해소 시점을 문장으로 못박은 사고 카드로 묶는 작업까지는 프론트 쪽 작업이었다. 여기서 한 단계 더 들어가서, 실패 안내 문구가 여전히 일반론이라는 게 눈에 띄었다. rolled_back·rollback_failed·manual_intervention_needed는 셋 다 같은 트리거(배포 직후 헬스체크 또는 스모크테스트 실패)에서 갈라지는데, 화면엔 "헬스체크가 안 뜨거나, 스모크테스트가 실패했을 수 있어요" 식으로 가능성만 나열하고 있었다. build job이 이미 성공해야 deploy까지 온 것이므로 빌드 문제는 항상 배제할 수 있는데도 그 정보를 안 쓰고 있던 셈이다.

CD가 실패 시점에 실제로 관찰한 신호는 이미 파이프라인 안에 있었다. deploy job은 헬스체크 타임아웃 시 컨테이너 로그 200줄을 갖고 있고, smoke-test job은 어느 공개 API 경로가 실패했는지(HTTP 상태 포함) 알고 있었다. 이 신호를 화면까지 끌고 오기로 했다. infra/scripts/classify-deploy-failure.sh를 새로 만들어 로그를 필수 env 누락·Flyway 실행 실패·DB 잠금·빈 생성 실패·포트 충돌·OOM·연결 자체 안 됨 등으로 분류하고, 서버에 마커 파일(.last_deploy_diagnosis_*)로 남긴다. smoke-test job은 실패한 경로를 job output으로 넘긴다. record-deploy-status가 이 마커(우선)와 smoke-test 출력(차선)을 읽어 probableCause로 JSONL에 남기고 마커는 지운다 — 기존에 있던 .prev_backend_tag_* 파일 정리 패턴을 그대로 따랐다.

롤백 job도 마찬가지로, 롤백 후 헬스체크가 다시 실패하면 같은 분류를 돌려 [롤백 후에도] 접두로 마커를 덮어쓰게 했다. 방금까지 정상 서비스 중이던 이전 버전조차 다시 못 떴다는 건 새 코드 문제보다 서버 자체(디스크·메모리·포트 충돌 등) 문제일 가능성이 훨씬 크다는, 더 결정적인 사실이라 우선순위를 그렇게 잡았다. 원본 로그 줄은 CD 실행 로그(운영진만 봄)에만 남기고, 어드민 화면에 영구 저장되는 값은 분류된 라벨 한 줄로 제한했다.

이 작업을 하면서 마이그레이션 개수 표시("29")도 손봤다. 개수만으론 몇 개 적용됐는지는 알아도 어느 파일까지 적용됐는지는 알 수 없었는데, flyway_schema_history의 가장 최근 성공 행에서 version+description을 되짚어 실제 파일명(V{버전}__{설명}.sql)을 복원해 latestAppliedMigration으로 같이 남기도록 바꿨다. optional 필드라 이 필드가 생기기 전 옛 기록(null)도 안전하게 처리되는지 백엔드 테스트로 명시적으로 검증했다.

진단 마커가 새는 경우, 그리고 CI 린트

이후 마무리 커밋 몇 개는 이 마커 파일 방식의 구멍을 메우는 작업이었다. record-deploy-status의 SSH 스텝 자체가 네트워크 문제 등으로 실패하면 .last_deploy_diagnosis_* 파일이 안 지워진 채 남는데, 다음 배포가 성공해도 record-deploy-status가 이 옛 파일을 그대로 읽어서 무관한 배포에 옛 실패 원인을 잘못 붙일 수 있는 구조였다. deploy job이 시작될 때 무조건 이 파일을 지우고 시작하도록 고쳐서 막았다. 같은 김에 deploy·rollback job이 헬스체크 실패 로그를 화면 출력용(tail 50)과 분류용(tail 200)으로 docker compose logs를 두 번씩 호출하던 것도 한 번만 읽어서 재사용하게 정리했다.

프론트 쪽에서는 CI에서 react-hooks/set-state-in-effect 린트에 걸렸다. 환경(stage/prod) 전환 시 setRecords(null)/setError('')를 이펙트 본문에서 동기 호출하고 있었는데, 로컬에서는 "기존 코드라 범위 밖"으로 판단했던 규칙이었지만 CI는 dev에 아직 없는 새 파일 기준으로 린트를 돌리기 때문에 이번 PR 책임으로 걸렸다. 같은 문제를 겪는 다른 admin 분석 패널(PopularTimeAnalyticsPanel 등)의 기존 관례를 따라 fetch 결과 콜백 안에서만 상태를 갱신하도록 고쳤다. 전환 직후 잠깐 이전 환경 데이터가 남아있다가 새 데이터로 교체되는 것도 이 레포에서 이미 받아들여진 트레이드오프라 그대로 따랐다.

작업 도중 로컬 풀스택(FE+BE)을 여러 번 띄워 확인해야 했는데, 이 레포에 로컬 실행 문서가 따로 없어서 매번 같은 문제에 걸렸다. application.yml이 기본값 없이 요구하는 환경변수(OCI_STORAGE_* 등)가 .env.example엔 빠져 있고, 프론트 프록시 기본 포트(8081)와 백엔드 기본 포트(8080)가 어긋나 있고, Windows에서는 dev 서버가 떠 있는 채로 npm ci를 돌리면 lightningcss 네이티브 바이너리가 로드된 상태라 EPERM: unlink로 실패하는 문제도 있었다. 이번에 겪은 걸 다음에 또 겪지 않도록 .claude/skills/run-fullstack/SKILL.md에 절차로 남겼다.

로컬에서 outcome 7종과 additive/destructive 마이그레이션이 섞인 샘플 데이터로 브라우저 렌더링을 확인했고, DeployHistoryTimeline.test.ts에 순서 뒤집기·severity 판정·cd.yml 기반 stage 도출·사고 구간 묶기 등 13개 케이스를 새로 추가해 통과시켰다. 대체된 기존 DeployHistoryTrackChart.test.ts는 컴포넌트 자체가 없어졌으므로 함께 제거했다. PR은 2026-08-06에 올라가 같은 날 dev에 병합됐고, 테스트 플랜으로 실제 stage CD 이력이 쌓인 뒤 /admin/infra에서 렌더링을 다시 확인하는 항목을 남겨뒀다.