로컬은 다 통과하는데 운영만 죽는다
Article
2편과 3편은 화면이 데이터를 배신한 이야기였다. 4편은 운영이 로컬을 배신한 이야기다.
DevTI는 백엔드를 Render에, 데이터베이스를 Aiven MySQL에 올렸다. 둘 다 무료 플랜이다. 로컬 테스트는 H2로 돈다.
이 조합에서 두 번 크게 데였다. 둘 다 로컬에서는 절대 재현되지 않았다.
첫 번째 — 카드를 저장하면 500이 난다
배포하고 카드를 수정해봤다. "서버 내부 오류". 로컬에서는 멀쩡하다. ./gradlew test도 전부 통과다.
Render 로그를 열었다. 첫 단서가 나왔다.
SQLSyntaxErrorException: Table 'defaultdb.dev_card_profile_goal' doesn't exist
없다니. @ElementCollection으로 만든 테이블 셋(스킬·관심사·목표)이 운영에만 안 만들어져 있었다. ddl-auto가 로컬 H2에서는 잘 만들던 것들이다.
그래서 손으로 만들었다. CREATE TABLE 세 줄. 그런데 기동 자체가 크래시했다.
Unable to create or change a table without a primary key,
when 'sql_require_primary_key' is set
여기가 근본 원인이었다.
로컬 H2에는 그런 제약이 없다. 그래서 로컬은 영원히 멀쩡했다.
고친 방법은 복합 기본 키다. 프로필 하나에 같은 코드는 한 번만 들어가니, (profile_id, 코드)를 묶으면 기본 키로 안전하다.
그리고 그 CREATE TABLE IF NOT EXISTS를 기동 시 코드로 보장하게 했다. 시더보다 먼저 도는 @Order(0) 러너다.
- 증상 A
ddl-auto가 컬렉션 테이블을 안 만든다 (조용히) - 증상 B손으로 친
CREATE TABLE이 기동을 죽인다 - 근본 원인Aiven MySQL 의
sql_require_primary_key=ON. PK 없는 테이블 거부 - 고침복합 PK
(profile_id, 코드)를 명시하고, 스키마 보장을 코드에 둔다
교훈: ddl-auto를 매니지드 DB에서 믿지 않는다. 컬렉션·조인 테이블을 추가할 때는 기본 키를 명시하거나 엔티티로 모델링한다.
두 번째 — 502가 반복되는데 로그를 못 본다
더 나빴던 건 두 번째다.
캐릭터 이미지가 전부 화면에 뜨는지 확인하려고, 더미 카드를 128장 격자(MBTI 16 × 성별 2 × 직군 4)로 만드는 시더를 올렸다.
배포하자 운영이 502를 반복했다. 사이트가 떴다가 곧 죽고, 다시 뜨고 다시 죽었다. 크래시 루프다. 더미는 한 장도 안 생겼다.
로컬은? 테스트 전부 통과. 재현이 안 된다.
그리고 여기서 진짜 문제가 있었다. 로그를 제대로 볼 수 없었다. 앱이 기동 중에 죽으니 쓸만한 스택 트레이스가 남기 전에 프로세스가 내려갔다.
원인을 찾기 전에, 원인을 볼 수 있는 상태부터 만들어야 했다.
1단계 — 기동을 죽이지 못하게 격리한다
시더는 ApplicationRunner다. 거기서 예외가 나면 기동 실패로 번진다. 그래서 그 예외가 밖으로 못 나가게 막았다.
@Transactional을 떼고 TransactionTemplate + try/catch(Throwable)로 감쌌다. 시더가 실패해도 앱은 산다.
사이트가 즉시 복구됐다. 헬스 체크가 200으로 안정됐다. 더미는 여전히 0장이지만, 이제 서비스는 살아 있다.
2단계 — 한 장이 죽어도 전부 롤백되지 않게
128장을 한 트랜잭션에 saveAll 하고 있었다. 한 조합이 실패하면 128장이 통째로 롤백된다.
조합마다 독립 트랜잭션으로 바꿨다. 이제 127장이 성공하고 1장이 실패하면, 127장은 남는다. 실패한 하나가 무엇인지도 드러난다.
3단계 — 로그를 못 보면, 볼 수 있는 것을 만든다
여전히 왜 실패하는지 몰랐다. 운영 로그를 실시간으로 파고들 수단이 마땅치 않았다.
그래서 실패 사유를 밖으로 꺼내는 임시 엔드포인트를 만들었다.
// DummySeedReport — 마지막 시드 결과를 담는다
created : 몇 장 성공했나
failed : 몇 장 실패했나
firstError : 첫 실패의 예외 메시지 (근본 원인 체인 포함)
GET /api/v1/health/seed 로 공개하고 curl로 찔렀다.
- 01
기동을 못 죽이게 격리한다
부수 작업의 예외가 서비스를 죽이지 않게. 먼저 살린다.
- 02
실패 범위를 잘게 쪼갠다
한 트랜잭션 128장 → 조합별 독립 트랜잭션. 실패한 하나가 드러난다.
- 03
실패 사유를 원격으로 노출한다
임시 진단 엔드포인트. 로그를 못 보면 볼 수 있는 것을 만든다.
GET /api/v1/health/seed → { created: 0, failed: 128, firstError: "..." } - 04
그제야 근본 원인이 보인다
추측이 아니라 운영이 직접 말해준 문장을 읽는다.
운영이 직접 말해준 문장
curl 한 방에 답이 나왔다.
firstError = Field 'backend_understanding_score' doesn't have a default value
backend_understanding_score. 우리 엔티티에 없는 컬럼이다. git 히스토리를 뒤져도 안 나온다. 더 오래된 스키마의 잔재였다.
운영 dev_card_stat 테이블에 그 컬럼이 NOT NULL · 기본값 없이 남아 있었다. 지금 엔티티는 5축 점수만 INSERT 하니까, 그 컬럼에 값이 안 들어간다. MySQL이 거부한다.
그래서 모든 능력치 저장이 실패하고 있었다.
고친 방법은 화이트리스트 청소다. dev_card_stat의 유효 컬럼 목록을 코드에 두고, information_schema에서 그 밖의 컬럼을 찾아 DROP 한다. 멱등이라 몇 번 돌려도 안전하고, 시더보다 먼저(@Order(0)) 돈다.
배포하고 다시 찔렀다.
created: 128, failed: 0
그리고 예상 못 한 게 하나 딸려왔다. 실사용자의 카드 생성도 함께 고쳐졌다.
능력치 저장이 막혀 있었으니, 사용자가 카드를 만들어도 능력치가 안 들어갔을 것이다. 오픈 전이라 아무도 안 만들어봐서 아무도 몰랐던 잠복 버그다. 더미 128장을 만들려다 그걸 밟은 셈이다.
배운 것
두 사건의 모양이 같다.
로컬 (H2)
매번 새로 만든다
- PK 없는 테이블도 만들어준다
- 스키마 잔재가 쌓일 수 없다
- 그래서 항상 초록불이다
- 테스트 82개 전부 통과
운영 (Aiven MySQL)
한 번 만든 것이 남는다
- PK 없는 테이블을 거부한다
- 지운 컬럼의
NOT NULL잔재가 남아 있다 - 그 잔재가 이후 모든
INSERT를 막는다 - 502 크래시 루프
테스트가 전부 통과했다는 사실이 아무것도 보장하지 않는 영역이 있다. 스키마는 상태고, 상태는 쌓인다. 로컬은 그 상태를 매번 버린다.
그리고 두 번째 사건에서 배운 게 더 크다. 원인을 못 찾겠으면 원인을 찾으려 하지 말고, 원인이 보이는 상태를 먼저 만들어야 한다.
죽는 걸 막고, 실패 범위를 쪼개고, 실패 사유를 밖으로 꺼냈다. 세 단계 모두 원인 찾기가 아니었다. 그런데 그 셋을 하고 나니 원인은 curl 한 방에 나왔다.
2편에서 빌드된 HTML을 cat으로 열었을 때와 같다. 추측을 멈추고 볼 수 있는 것을 봤다. 다만 이번엔 볼 것이 없어서, 볼 것을 먼저 만들어야 했다.
다음 편에서는 완전히 다른 이야기를 쓴다. 카드 상세의 "개인 맞춤 강점 분석"을 외부 AI API를 한 번도 부르지 않고 만든 이야기. 콘텐츠 조각 수십 개와 가중치 규칙만으로 수천 가지 결과를 만드는데, 같은 입력이면 언제나 같은 출력이다. AI를 안 쓴 게 타협이 아니라 선택이었던 이유.