[내일배움캠프] 클라우드 주차 트러블슈팅 TIL

junsung kim·2026년 5월 25일

"prod" 라고 부르는데 prod 가 아니었다 — RDS 도입 트러블슈팅과 바이브 코딩 회고

ThirdTool 에 prod 프로파일을 처음 도입하면서 배포를 EC2 + RDS 로 옮겼다. 그 과정에서 가장 오래 헤맨 버그 하나, "만약 이게 진짜 운영 데이터가 들어 있는 stateful migration 이었다면" 이라는 가상 시나리오, 그리고 AI 와 빠르게 작업하면서 생긴 바이브 코딩 회고 를 한 편으로 정리한다.

본 과제는 prod 프로파일 첫 도입이라 사실상 빈 DB 마이그레이션 이었다. 컨테이너를 교체해도 잃을 데이터가 없으니 ddl-auto: update 를 가벼운 마음으로 켤 수 있었다 — 바로 그 '가벼운 마음' 이 첫 번째 사고를 불렀다.


목차


1. 가장 오래 헤맨 버그, prod 라고 부르는데 prod 가 아니었다

1-1. 증상과 원인

Epic 1-3 (Docker Hub CD) 머지 직후 EC2 첫 배포가 성공 했고, /actuator/healthUP 이었고, 회원 가입 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 가 아닌 상태가 한참 유지된 셈이다.

1-2. 교훈

  • fail-fast 의 부재가 가장 비싼 버그를 만든다. prod 프로파일이 H2 driver 를 받아들이지 않게 명시적으로 거부 해야 한다.(a매우 중요한 생각인듯) ADR-0008 의 "환경변수 누락 시 부팅 실패" 정책은 값이 없는 경우 만 다룬다 — 잘못된 값(H2 driver 가 prod 에 와 있는 상황)은 잡지 못한다. 부팅 후 actuator 의 /info 또는 별도 startup probe 에서 datasource.driver-class-name 을 prod 일 때만 MySQL 류로 검증하는 안전망이 필요하다.
  • 환경별 override 가 가능한 키는 소수 정예로만 둔다. ADR-0014 의 .env 키 재설계(DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, DB_PASSWORD 만)가 이 사고에 대한 응답이다. SPRING_DATASOURCE_* 같은 Spring 전체 binding 키 는 우회 통로가 되므로, .env 와 yml 사이에 동등 계층의 두 진실 소스 를 만들지 말 것.
  • "왜 작동하는지 모르지만 작동함" 이 일주일 가면 부채로 굳는다. actuator/health: UP 만으로 정합성을 추론하지 말 것 — DataSourceHealthIndicator연결 가능성 만 검증하지, 어느 DB 인지 는 검증하지 않는다.

2. 만약 RDS 가 private subnet 의 stateful migration 이었다면

본 과제는 prod 프로파일 첫 도입이라 사실상 빈 DB 마이그레이션 이었다. 컨테이너 교체로 잃을 데이터가 없으니 ddl-auto: update 를 가벼운 마음으로 켤 수 있었다. 하지만 실제 운영 의 RDS 전환은 거의 항상 기존 데이터가 들어 있는 상태stateful migration 이다. 이 시나리오를 가상으로 펴 보면 ADR-0014 가 어디서 부서지는지 가 잘 보인다.

2-1. 시나리오 가정

  • 운영 중인 H2 file-based DB 또는 다른 region 의 MySQL수만 건의 회원 데이터 가 들어 있다.
  • 목표: 이걸 private subnet 의 RDS MySQL 8 로 옮기고 순단을 최소화 한다.
  • 추가 제약: 회원 가입 API 는 24/7 트래픽이 있다 — 완전 중단 창구 없음.

2-2. 첫 번째 함정, ddl-auto: update 가 데이터를 건드린다

ADR-0014 는 "DROP 미발생, 데이터 손실 위험 0" 이라 적었다. 그건 현재 코드의 엔티티 정의현재 DB schema일치할 때만 참이다. 마이그레이션 시점에는 이야기가 달라진다.

  • H2 와 MySQL 의 동일 컬럼 타입이 미세하게 다르다. 예를 들어 H2 의 VARCHAR(255) 는 character semantics 인 반면, MySQL utf8mb4VARCHAR(255)바이트가 아니라 character — 같지만 다른 자리에서 깨진다.
  • Hibernate 가 기존 컬럼이 자기 기대와 다르면 schema 를 자동으로 ALTER 한다. 데이터를 보존하면서 컬럼 타입을 바꾸는 ALTER 가 일어나는데, 이 시점에 그 테이블 전체에 metadata lock 이 잡힌다. 대형 테이블일수록 metadata lock 이 수십 초에서 수 분 간 API 를 막는다.

해법은 마이그레이션을 ddl-auto 에게 맡기지 않는 것 이다. Flyway 또는 Liquibase 로 코드화된 schema명시적 ALTER 로 적용하고, 그 ALTER 자체를 ALGORITHM=INPLACE, LOCK=NONE 옵션과 함께 오프타임에 사전 적용 한다. ADR-0014 가 Flyway 를 연기 한 것은 학습 단계의 단순화로 정당하지만, stateful migration 을 만나면 즉시 깨질 결정 이다.

2-3. 두 번째 함정, 데이터 이관의 cutover

H2 file → MySQL 이관은 어떻게 끊는가 의 문제다. 세 가지 길이 있다.

  1. Stop the world — 회원 가입 API 를 막고, dump 로 풀 백업, RDS 에 import, env 변경, 재기동. 가장 단순 하다. 다만 수만 건이면 import 만 10분~1시간. 그동안 API 다운.
  2. Dual write — 마이그레이션 윈도 동안 두 DB 에 동시 write 하도록 코드 분기. 읽기는 새 DB. 순단 0. 다만 코드를 일시적으로 더럽힌다 — if (migrationWindow) { writeBoth() } 같은 줄이 들어가고, 끝난 뒤 지우는 PR 을 따로 내야 한다. 잘 지우지 않으면 부채로 남는다.
  3. Logical replication / CDC — Debezium / DMS 로 변경 이벤트 를 새 DB 에 비동기 복제. cutover 는 replication lag 이 0 인 순간DNS 또는 endpoint 만 전환. 가장 순단이 짧다. 가장 인프라가 무겁다.

본 과제 규모에서는 (1) stop the world 가 ROI 가 가장 크다. 다만 그러려면 공지 → 트래픽 차단 → dump → restore → 검증 → 트래픽 복귀체크리스트로 미리 적어두어야 한다 — 이게 바로 Runbook 이다.

ADR 이 결정 을 적는 자리라면, Runbook 은 어떻게 안전하게 실행하는지 를 적는 자리다. 현재 repo 에는 Runbook 자리가 없다. 만약 이 과제가 stateful migration 을 포함했다면 docs/runbook/ 디렉토리를 ADR 보다 먼저 만들었어야 한다.

2-4. 세 번째 함정, private subnet 의 디버깅

public RDS 에선 로컬에서 mysql -h <endpoint> -u ...즉시 접속 해 schema 확인이 가능하다. private 으로 옮기면 디버깅 자체가 어려워진다.

  • 로컬 PC → RDS 직접 접속 불가 (보안 그룹 + private subnet 라우팅).
  • bastion EC2 또는 SSM Session Manager Port Forwarding 으로 우회.
  • 둘 다 세팅이 작업의 절반 — 사고가 났을 때 3분 안에 schema 를 들여다보지 못한다.

이 비용을 마이그레이션 전에 미리 지불해 두어야 한다. ADR 형태로 풀어 쓰면 이렇게 된다.

운영 RDS 는 private subnet 에 두되, 디버깅을 위해 SSM Session Manager Port Forwarding 을 항상 가능한 상태로 유지한다. bastion EC2 는 비용 0 (SSM 만 쓰면 됨). 운영자 PC 의 ~/.ssh/config 또는 .aws/config 에 접속 단축어를 팀 공유 README 로 적는다.

마이그레이션 당일 에 이걸 처음 세팅하면 현장에서 30분이 사라진다. 평소에 늘 켜 두는 것 이 운영의 핵심이다.


3. 바이브 코딩 회고, 스펙과 지식을 어디에 두는가

본 과제 전체를 AI 와의 협업으로 빠르게 굴린 결과 흥미로운 자리가 몇 군데 생겼다. 바이브 코딩 — 정의가 사람마다 다르지만, 본 글에선 대화로 의도를 빠르게 만들고, 검증과 정합은 사후에 추적하는 작업 방식 으로 둔다.

3-1. 코드가 진실 소스 원칙의 한계

CLAUDE.md 가 명시적으로 "코드가 진실 소스" 라 적어 두었다. API · DB schema · 테스트 매트릭스 · 패키지 구조는 별도 명세 없이 코드 자체로 본다는 결정. 이게 대부분의 경우 옳다 — 문서와 코드의 동기화 비용 을 0 으로 만든다.

다만 RDS 마이그레이션처럼 코드가 코드 밖의 시스템 상태에 의존 하는 순간엔 무너진다.

  • 현재 RDS 의 schema 는 코드 어디에도 없다. JPA 엔티티는 기대 일 뿐 실재 가 아니다.
  • 현재 SSM Parameter Store 의 키 목록user-data.shREQUIRED= 줄에 부분적으로만 적혀 있다.
  • 현재 RDS 의 maintenance window 시각, backup retention, multi-az 여부Terraform 도 CloudFormation 도 없으므로 AWS Console 의 클릭 상태 가 유일한 진실 소스다.

코드 안에선 코드가 진실 소스이지만, 코드 밖 인프라의 진실 소스는 AWS Console 의 현재 상태 다. 이 두 진실 소스가 동등하게 책임을 분담 한다는 사실 자체를 어딘가에 적어두지 않으면, 6개월 뒤 누군가가 코드만 보고 "RDS 도 PR 하면 되지 않나" 하고 잘못 합의한다.

3-2. 롤백의 비대칭성

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 의 결과를 되돌릴 길 이 사실상 없다.

3-3. 스펙을 어디에 두는가, ADR vs Runbook vs 코드 주석

본 repo 의 ADR 14개를 다시 들여다보면 결정실행 절차 가 섞여 있다.

  • 결정형 (ADR-0001, 0008, 0011, 0014): 왜 이걸 골랐는가 + 버려진 옵션 + 트레이드오프
  • 실행 절차형 (ADR-0013 의 EC2 사전 준비 5단계, ADR-0014 의 .env 키 갱신): 어떻게 셋업하는가

후자는 Runbook 의 영역 이다. ADR 안에 섞이면 두 가지가 깨진다.

  • ADR 은 한 번 적고 다시 보지 않는 문서 라는 통념 때문에 실행 절차가 stale 해진다. 예를 들어 ADR-0013 의 "EC2 사전 준비" 는 user-data.sh 도입 후 이미 우회 가능 해졌지만 ADR 본문은 그대로다.
  • 결정의 흐름현장 매뉴얼이 끊는다. ADR 을 연대순으로 읽어 결정 흐름을 추적하려는 사람에게는 노이즈다.

회고하자면 결정과 실행 절차는 같은 문서에 두지 말 것. ADR 은 를, Runbook 은 어떻게 를. 본 repo 의 차기 PR 에서 docs/runbook/ 분리를 시도할 자리다.

3-4. 지식의 응집, 사람이 결정한 자리만 적는다

AI 와 빠르게 작업하면 코드 안에 흔적이 남지 않는 결정 이 폭발적으로 많아진다. 변수명, 메서드 시그니처, 패키지 위치, 어노테이션 선택, 로깅 한 줄. 이걸 전부 ADR 에 적으면 노이즈, 하나도 안 적으면 6개월 뒤 "왜 이렇게 했지" 의 무덤이 된다.

본 repo 의 경계선은 .claude/rules/adr.md 의 7가지 트리거다. 다만 작업 중 체감한 추가 기준이 둘 있다.

  • AI 가 제안한 패턴을 사람이 의식적으로 거부한 순간 — 그 거부 자체 가 결정이다. 예: "Spring Cloud Config 쓰는 게 일반적입니다" 라는 제안을 학습 단계 단순화 로 거부한 자리는 ADR-0001 → ADR-0008 로 흔적이 남았다.
  • AI 가 제안하지 않은 옵션을 사람이 떠올려 비교한 순간 — 비교가 기록되지 않으면 두 번 비교한다.

후자가 특히 약하다. AI 는 대안 비교 를 잘 해 주지만, 제안하지 않은 옵션 은 보이지 않는다. 사람이 "Option E. 이거 안 됩니까?" 라고 던지지 않으면 그 선택지는 결정 트리에서 빠진 채로 진행된다. ADR-0014 의 Option F(Secrets Manager)와 G(IAM 인증)는 사용자가 제기해서 비교에 들어간 자리다. 만약 그 제기가 없었다면 기각된 적도 없는 옵션 이 되어, 6개월 뒤 누군가 IAM 인증을 도입하며 "왜 처음부터 안 했지" 라고 묻는 자리를 만들었을 것이다.

3-5. 바이브 코딩의 안전망, Reviewer 5관점

.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 가 가장 약한 자리다.

추후) spec-driven 아키텍처의 도입

최근에 자주 언급되는 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 는 명확하다.

  1. prod 프로파일에 driver 검증 startup probe 추가 (fail-fast 강화)
  2. docs/runbook/ 디렉토리 분리 — 결정(ADR)실행 절차(Runbook) 의 책임 분리
  3. stateful migration 을 진짜로 만나기 전에 Flyway 도입 + SSM Port Forwarding 상시화

바이브 코딩의 속도는 코드만 다룰 때 의 미덕이다. DB 와 인프라가 끼는 순간, 속도보다 되돌릴 수 있는가 를 먼저 물어야 한다.

profile
edit하는 개발자! story 있는 삶

0개의 댓글