기술 포스트

로컬은 다 통과하는데 운영만 죽는다


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) 러너다.

한 원인, 두 증상
  1. 증상 Addl-auto 가 컬렉션 테이블을 안 만든다 (조용히)
  2. 증상 B손으로 친 CREATE TABLE 이 기동을 죽인다
  3. 근본 원인Aiven MySQL 의 sql_require_primary_key=ON. PK 없는 테이블 거부
  4. 고침복합 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로 찔렀다.

로그를 볼 수 없는 곳에서 원인을 좁히는 순서
  1. 01

    기동을 못 죽이게 격리한다

    부수 작업의 예외가 서비스를 죽이지 않게. 먼저 살린다.

  2. 02

    실패 범위를 잘게 쪼갠다

    한 트랜잭션 128장 → 조합별 독립 트랜잭션. 실패한 하나가 드러난다.

  3. 03

    실패 사유를 원격으로 노출한다

    임시 진단 엔드포인트. 로그를 못 보면 볼 수 있는 것을 만든다.

    GET /api/v1/health/seed
    → { created: 0, failed: 128, firstError: "..." }
  4. 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를 안 쓴 게 타협이 아니라 선택이었던 이유.