ThirdTool 에 prod 프로파일을 처음 도입하면서 배포를 EC2 + RDS 로 옮겼다. 그 과정에서 가장 오래 헤맨 버그 하나, "만약 이게 진짜 운영 데이터가 들어 있는 stateful migration 이었다면" 이라는 가상 시나리오, 그리고 AI 와 빠르게 작업하면서 생긴 바이브 코딩 회고 를 한 편으로 정리한다.
본 과제는 prod 프로파일 첫 도입이라 사실상 빈 DB 마이그레이션 이었다. 컨테이너를 교체해도 잃을 데이터가 없으니 ddl-auto: update 를 가벼운 마음으로 켤 수 있었다 — 바로 그 '가벼운 마음' 이 첫 번째 사고를 불렀다.
Epic 1-3 (Docker Hub CD) 머지 직후 EC2 첫 배포가 성공 했고, /actuator/health 도 UP 이었고, 회원 가입 API 도 200 을 돌려줬다. 겉으로 보면 흠잡을 데가 없었다.
다만 이상한 점이 하나 있었다 — 컨테이너를 한 번 교체하면 직전에 만든 회원이 사라졌다.
원인은 단순했지만 발견까지 시간이 걸렸다. application-prod.yml 의 datasource 는 분명 MySQL 을 가리키고 있었다.
spring:
datasource:
url: jdbc:mysql://${DB_URL}
driver-class-name: com.mysql.cj.jdbc.Driver
그런데 EC2 의 /home/ec2-user/.env 안에는 과거에 임시로 넣어둔 줄 이 살아 있었다.
SPRING_DATASOURCE_DRIVER_CLASS_NAME=org.h2.Driver
SPRING_JPA_HIBERNATE_DDL_AUTO=update
Spring Boot 의 환경변수 binding 규칙(SPRING_DATASOURCE_* → spring.datasource.*)은 yml 보다 우선 한다. 즉 yml 은 MySQL 을 선언했고, env 는 H2 로 조용히 덮어쓰고 있었다. driver 가 H2 이므로 mysql JDBC URL 은 해석조차 되지 않은 채 H2 in-memory 가 떴고, ddl-auto: update 로 매 부팅마다 schema 가 다시 만들어지면서 데이터가 새것으로 시작되었다.
문법적으론 정상, 의미적으론 prod 가 아닌 상태가 한참 유지된 셈이다.
/info 또는 별도 startup probe 에서 datasource.driver-class-name 을 prod 일 때만 MySQL 류로 검증하는 안전망이 필요하다..env 키 재설계(DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, DB_PASSWORD 만)가 이 사고에 대한 응답이다. SPRING_DATASOURCE_* 같은 Spring 전체 binding 키 는 우회 통로가 되므로, .env 와 yml 사이에 동등 계층의 두 진실 소스 를 만들지 말 것.actuator/health: UP 만으로 정합성을 추론하지 말 것 — DataSourceHealthIndicator 는 연결 가능성 만 검증하지, 어느 DB 인지 는 검증하지 않는다.본 과제는 prod 프로파일 첫 도입이라 사실상 빈 DB 마이그레이션 이었다. 컨테이너 교체로 잃을 데이터가 없으니 ddl-auto: update 를 가벼운 마음으로 켤 수 있었다. 하지만 실제 운영 의 RDS 전환은 거의 항상 기존 데이터가 들어 있는 상태 — stateful migration 이다. 이 시나리오를 가상으로 펴 보면 ADR-0014 가 어디서 부서지는지 가 잘 보인다.
ADR-0014 는 "DROP 미발생, 데이터 손실 위험 0" 이라 적었다. 그건 현재 코드의 엔티티 정의 가 현재 DB schema 와 일치할 때만 참이다. 마이그레이션 시점에는 이야기가 달라진다.
VARCHAR(255) 는 character semantics 인 반면, MySQL utf8mb4 의 VARCHAR(255) 는 바이트가 아니라 character — 같지만 다른 자리에서 깨진다.해법은 마이그레이션을 ddl-auto 에게 맡기지 않는 것 이다. Flyway 또는 Liquibase 로 코드화된 schema 를 명시적 ALTER 로 적용하고, 그 ALTER 자체를 ALGORITHM=INPLACE, LOCK=NONE 옵션과 함께 오프타임에 사전 적용 한다. ADR-0014 가 Flyway 를 연기 한 것은 학습 단계의 단순화로 정당하지만, stateful migration 을 만나면 즉시 깨질 결정 이다.
H2 file → MySQL 이관은 어떻게 끊는가 의 문제다. 세 가지 길이 있다.
if (migrationWindow) { writeBoth() } 같은 줄이 들어가고, 끝난 뒤 지우는 PR 을 따로 내야 한다. 잘 지우지 않으면 부채로 남는다.본 과제 규모에서는 (1) stop the world 가 ROI 가 가장 크다. 다만 그러려면 공지 → 트래픽 차단 → dump → restore → 검증 → 트래픽 복귀 를 체크리스트로 미리 적어두어야 한다 — 이게 바로 Runbook 이다.
ADR 이 결정 을 적는 자리라면, Runbook 은 어떻게 안전하게 실행하는지 를 적는 자리다. 현재 repo 에는 Runbook 자리가 없다. 만약 이 과제가 stateful migration 을 포함했다면 docs/runbook/ 디렉토리를 ADR 보다 먼저 만들었어야 한다.
public RDS 에선 로컬에서 mysql -h <endpoint> -u ... 로 즉시 접속 해 schema 확인이 가능하다. private 으로 옮기면 디버깅 자체가 어려워진다.
이 비용을 마이그레이션 전에 미리 지불해 두어야 한다. ADR 형태로 풀어 쓰면 이렇게 된다.
운영 RDS 는 private subnet 에 두되, 디버깅을 위해 SSM Session Manager Port Forwarding 을 항상 가능한 상태로 유지한다. bastion EC2 는 비용 0 (SSM 만 쓰면 됨). 운영자 PC 의
~/.ssh/config또는.aws/config에 접속 단축어를 팀 공유 README 로 적는다.
마이그레이션 당일 에 이걸 처음 세팅하면 현장에서 30분이 사라진다. 평소에 늘 켜 두는 것 이 운영의 핵심이다.
본 과제 전체를 AI 와의 협업으로 빠르게 굴린 결과 흥미로운 자리가 몇 군데 생겼다. 바이브 코딩 — 정의가 사람마다 다르지만, 본 글에선 대화로 의도를 빠르게 만들고, 검증과 정합은 사후에 추적하는 작업 방식 으로 둔다.
CLAUDE.md 가 명시적으로 "코드가 진실 소스" 라 적어 두었다. API · DB schema · 테스트 매트릭스 · 패키지 구조는 별도 명세 없이 코드 자체로 본다는 결정. 이게 대부분의 경우 옳다 — 문서와 코드의 동기화 비용 을 0 으로 만든다.
다만 RDS 마이그레이션처럼 코드가 코드 밖의 시스템 상태에 의존 하는 순간엔 무너진다.
user-data.sh 의 REQUIRED= 줄에 부분적으로만 적혀 있다.즉 코드 안에선 코드가 진실 소스이지만, 코드 밖 인프라의 진실 소스는 AWS Console 의 현재 상태 다. 이 두 진실 소스가 동등하게 책임을 분담 한다는 사실 자체를 어딘가에 적어두지 않으면, 6개월 뒤 누군가가 코드만 보고 "RDS 도 PR 하면 되지 않나" 하고 잘못 합의한다.
CI/CD 가 잘 깔리면 코드 롤백 은 쉽다 — ADR-0013 의 SHA 태깅 전략 덕에 ./deploy.sh <old_sha> 한 줄이면 끝. 하지만 DB schema 롤백 은 완전히 다른 차원의 비용이다. 컬럼 추가는 롤백 쉽지만, 데이터를 동반한 컬럼 삭제 / 타입 변경 은 되돌릴 수 없는 행위 다. ADR 의 T2: 되돌리기 비싼 결정 트리거가 유독 DB 에서 강력하게 발동되는 이유다.
바이브 코딩의 빠른 반복 은 코드만 다룰 때 의 미덕이다. DB 가 끼는 순간 PR 한 번 = 영원 일 수 있다. 본 과제에선 Flyway 를 연기 한 결정(ADR-0014 Option D)이 바로 이 비대칭성에 대한 의식적 회피다 — 학습 단계라 데이터 손실의 비용이 0 이기 때문에. 운영에선 이게 역방향 으로 작용한다. Flyway 없이 stateful migration 을 하면 PR 의 결과를 되돌릴 길 이 사실상 없다.
본 repo 의 ADR 14개를 다시 들여다보면 결정 과 실행 절차 가 섞여 있다.
.env 키 갱신): 어떻게 셋업하는가후자는 Runbook 의 영역 이다. ADR 안에 섞이면 두 가지가 깨진다.
user-data.sh 도입 후 이미 우회 가능 해졌지만 ADR 본문은 그대로다.회고하자면 결정과 실행 절차는 같은 문서에 두지 말 것. ADR 은 왜 를, Runbook 은 어떻게 를. 본 repo 의 차기 PR 에서 docs/runbook/ 분리를 시도할 자리다.
AI 와 빠르게 작업하면 코드 안에 흔적이 남지 않는 결정 이 폭발적으로 많아진다. 변수명, 메서드 시그니처, 패키지 위치, 어노테이션 선택, 로깅 한 줄. 이걸 전부 ADR 에 적으면 노이즈, 하나도 안 적으면 6개월 뒤 "왜 이렇게 했지" 의 무덤이 된다.
본 repo 의 경계선은 .claude/rules/adr.md 의 7가지 트리거다. 다만 작업 중 체감한 추가 기준이 둘 있다.
후자가 특히 약하다. AI 는 대안 비교 를 잘 해 주지만, 제안하지 않은 옵션 은 보이지 않는다. 사람이 "Option E. 이거 안 됩니까?" 라고 던지지 않으면 그 선택지는 결정 트리에서 빠진 채로 진행된다. ADR-0014 의 Option F(Secrets Manager)와 G(IAM 인증)는 사용자가 제기해서 비교에 들어간 자리다. 만약 그 제기가 없었다면 기각된 적도 없는 옵션 이 되어, 6개월 뒤 누군가 IAM 인증을 도입하며 "왜 처음부터 안 했지" 라고 묻는 자리를 만들었을 것이다.
.claude/rules/review.md 의 5관점(Domain / Architecture / API·Exception / Test / Sceptical)은 바이브 코딩의 빠른 사이클 이 놓치기 쉬운 자리 에 대한 응답이다. 특히 Sceptical Reviewer — "정말 Story AC 를 충족하는가, 누락된 엣지 케이스(동시성, 부분 실패, 네트워크 실패, 타임존), 성급한 추상화, 변경 의도와 결과의 괴리" — 가 본 과제에서 가장 비싼 자리 를 막아주었다.
다만 솔직한 회고. 5관점 자체도 Story 단위로만 작동 한다. Epic 횡단 의 결정(Epic 1-3 의 CD 가 RDS 의 필요성을 촉발 한 자리)은 5관점이 잡지 못한다. "왜 이 Epic 을 지금 하는가" 는 사람이 잡아야 할 자리 — AI 가 가장 약한 자리다.
최근에 자주 언급되는 kiro, spec-kit 등 spec-driven 아키텍처로 진행 방식에 대해서도 고민을 해봐야겠다. (여러 장단점을 고려한 끝에 speckit을 선택하기러 했다.)
이번 RDS 도입에서 배운 건 결국 하나로 모인다. 진실 소스가 코드 안에만 있지 않다는 것. 환경변수(.env)와 yml, JPA 엔티티와 실제 RDS schema, ADR 과 Console 의 클릭 상태 — 이 경계가 흐려지는 순간 가장 비싼 버그가 태어난다.
빈 DB 라서 가볍게 넘긴 ddl-auto: update 한 줄이, stateful migration 시나리오에선 metadata lock·되돌릴 수 없는 롤백·warm-up 비용으로 줄줄이 부서졌다. 그래서 다음 repo 의 To-Do 는 명확하다.
docs/runbook/ 디렉토리 분리 — 결정(ADR) 과 실행 절차(Runbook) 의 책임 분리바이브 코딩의 속도는 코드만 다룰 때 의 미덕이다. DB 와 인프라가 끼는 순간, 속도보다 되돌릴 수 있는가 를 먼저 물어야 한다.