운영 배포에서만 실패한 slug 마이그레이션: 무음 실패를 실패로 보이게 만들기
운영 배포가 계속 성공했다고 나왔지만, 실제로는 마이그레이션이 매번 실패하고 있었다.
문제의 시작은 게시판 slug 변경이었다.
기존 게시판의 slug를 다음처럼 바꾸는 마이그레이션을 만들었다.
boards.slug
diary → PictureDiary
phototoon → Cartoonify
로컬과 CI에서는 문제가 없었다. 하지만 운영 서버에서 배포할 때마다 다음 오류가 발생했다.
Duplicate entry 'PictureDiary' for key 'boards.slug'
더 큰 문제는 이 오류가 배포 실패로 처리되지 않았다는 점이다.
운영 데이터는 개발 데이터와 달랐다
로컬 환경에서는 다음과 같은 상태를 가정했다.
| slug | 상태 |
|---|---|
| diary | 기존 게시판 |
| phototoon | 기존 게시판 |
| PictureDiary | 없음 |
| Cartoonify | 없음 |
따라서 단순한 slug 변경이 정상적으로 동작했다.
$this->db->table('boards')
->where('slug', 'diary')
->update(['slug' => 'PictureDiary']);
하지만 운영에는 이미 어드민에서 PictureDiary와 Cartoonify 게시판을 만들어 둔 상태였다.
운영 데이터는 실제로 다음과 같았다.
| 게시판 | slug | 글 |
|---|---|---|
| 운영 게시판 | PictureDiary | 있음 |
| 마이그레이션이 만든 게시판 | diary | 있음 또는 비어 있음 |
이 상태에서 diary를 PictureDiary로 바꾸면 유일 키 충돌이 발생한다. 문제는 마이그레이션 코드가 아니라, 운영 데이터의 사전 조건을 너무 좁게 가정한 것이었다.
실패했는데 배포는 성공했다
더 곤란했던 부분은 CodeIgniter 4의 spark migrate 동작이었다.
마이그레이션 내부에서 오류가 발생해도 오류 메시지만 출력하고 종료 코드가 0으로 끝나는 상황이 있었다. 배포 스크립트는 set -e를 사용하고 있었지만, 프로세스가 성공 코드 0을 반환하니 실패로 판단하지 못했다.
결과는 다음과 같았다.
-
slug 마이그레이션 실행
-
중복 키 오류 출력
-
명령어는 성공 코드 반환
-
다음 배포 단계 진행
-
애플리케이션 코드와 서버는 갱신
-
이후 마이그레이션은 계속 미적용 상태
-
배포 시스템에는 성공으로 기록
즉, 실제 상태는 실패였지만 관측되는 상태만 성공이었다.
무음 실패는 오류가 없는 상태가 아니다. 오류가 있는데도 시스템이 성공처럼 보고하는 상태다.
해결 1: 이미 존재하는 slug와 데이터 병합
마이그레이션은 두 가지 상태를 모두 처리하도록 바꿨다.
-
새 slug가 없으면 기존처럼 slug를 변경한다.
-
새 slug가 이미 있으면 기존 게시판의 글을 새 게시판으로 옮긴 뒤 기존 게시판을 삭제한다.
핵심 로직은 다음과 같다.
$fromId = $this->findBoardId($from);
if ($fromId === null) {
return;
}
$toId = $this->findBoardId($to);
if ($toId === null) {
$this->db->table('boards')
->where('id', $fromId)
->update(['slug' => $to]);
return;
}
새 slug가 이미 존재할 때는 게시글의 board_id를 먼저 옮긴다.
$db->transException(true);
try {
$db->transStart();
$db->table('posts')
->where('board_id', $fromId)
->update(['board_id' => $toId]);
$db->table('boards')
->where('id', $fromId)
->delete();
$db->transComplete();
} finally {
$db->transException(false);
}
여기서 중요한 점은 두 가지였다.
첫째, 게시글을 잃지 않고 기존 게시판을 통합해야 한다.
둘째, 트랜잭션 내부에서 발생한 쿼리 오류가 조용히 롤백되지 않도록 transException(true)를 명시해야 한다.
해결 2: 마이그레이션 상태를 별도로 확인
migrate의 종료 코드만 믿지 않도록 배포 스크립트에 후속 검증을 추가했다.
php spark migrate --all
MIGRATION_FAILED=0
if php spark migrate:status | grep -- '| ---'; then
echo "::error::적용되지 않은 마이그레이션이 남아 있습니다"
MIGRATION_FAILED=1
fi
그리고 서버 캐시 정리와 Apache reload까지 마친 뒤 마지막에 실패 코드로 종료하도록 했다.
php spark cache:clear
sudo systemctl reload apache2
exit "$MIGRATION_FAILED"
중간에 즉시 종료하지 않은 이유도 있다.
마이그레이션은 실패했지만 애플리케이션 코드가 이미 새 버전으로 변경된 상태에서 배포를 중단하면, 애플리케이션과 데이터베이스가 서로 다른 버전으로 남을 수 있다.
따라서 배포에 필요한 후속 단계를 끝까지 수행한 뒤, 최종 결과만 실패로 보고하도록 했다.
이 방식은 “배포를 성공으로 위장하는 것”이 아니라, 반쪽 배포를 피하면서도 실패를 정확하게 외부에 전달하는 방식이다.
회귀 테스트는 운영 상태를 재현해야 한다
수정 후에는 단순히 diary 게시판만 존재하는 테스트가 아니라, 운영에서 실제로 문제가 발생했던 상태를 재현했다.
-
새 slug 게시판이 이미 존재한다.
-
옛 slug 게시판에도 게시글이 있다.
-
마이그레이션 실행 후 게시글이 새 게시판으로 이동한다.
-
옛 게시판은 삭제된다.
-
이후 마이그레이션도 정상적으로 적용된다.
이 테스트가 중요한 이유는 마이그레이션의 성공 조건이 단순한 문자열 변경이 아니기 때문이다.
진짜 성공 조건은 다음과 같다.
기존 데이터 보존
+ 유일 키 충돌 없음
+ 게시판 참조 정합성 유지
+ 마이그레이션 상태가 적용 완료
+ 후속 마이그레이션 진행 가능
이번 장애에서 배운 것
1. 마이그레이션은 현재 코드가 아니라 현재 데이터 위에서 실행된다
개발자가 예상한 초기 데이터와 운영자가 이미 만들어 둔 데이터는 다를 수 있다.
특히 다음 데이터는 마이그레이션 전에 존재할 수 있다고 가정해야 한다.
-
관리자 화면에서 생성한 게시판
-
수동으로 등록한 카테고리
-
운영 중 추가된 설정값
-
외부 시스템이 생성한 레코드
마이그레이션은 “내가 만든 데이터베이스”가 아니라 “이미 사용 중인 데이터베이스”를 대상으로 실행된다.
2. 종료 코드만으로 성공을 판단하면 안 된다
명령어가 0을 반환했다고 해서 실제 작업이 완료됐다고 단정할 수 없다.
특히 다음 작업은 별도의 결과 검증이 필요하다.
-
데이터베이스 마이그레이션
-
파일 업로드 및 동기화
-
캐시 무효화
-
외부 배포
-
백업
-
메시지 큐 적재
명령 실행 결과와 실제 시스템 상태는 분리해서 확인해야 한다.
3. 무음 실패는 관측 가능성의 문제다
이번 문제의 핵심은 slug 변경 코드 한 줄이 아니었다.
진짜 원인은 다음 조합이었다.
운영 데이터에 대한 좁은 가정
+ 유일 키 충돌
+ 예외의 비정상적인 종료 코드
+ 후속 상태 검증 부재
어느 하나만으로는 장애가 작게 끝났을 수 있다. 네 가지가 겹치면서 실패한 배포가 성공한 배포처럼 기록됐다.
4. 실패를 감추지 않는 것이 복구보다 중요하다
실패를 빠르게 발견하면 수정할 수 있다.
하지만 실패가 성공으로 기록되면 다음 배포까지 문제가 누적되고, 어느 시점부터 데이터베이스와 애플리케이션의 상태가 어긋났는지 추적하기 어려워진다.
배포 시스템이 해야 할 일은 성공률을 높이는 것이 아니라, 실제 상태를 정확하게 보고하는 것이다.
마무리
이번 장애는 운영에서만 발생한 특수한 문제가 아니었다.
로컬에서 통과한 마이그레이션이 운영에서 실패한 이유는 운영 데이터가 달랐기 때문이고, 실패한 배포가 성공으로 남은 이유는 실행 결과와 실제 상태를 따로 검증하지 않았기 때문이다.
최종적으로 해결한 방향은 단순했다.
-
이미 존재하는 slug를 고려한다.
-
데이터 이동은 트랜잭션으로 처리한다.
-
트랜잭션 오류를 예외로 드러낸다.
-
migrate:status로 미적용 상태를 확인한다. -
운영 데이터를 재현한 회귀 테스트를 추가한다.
마이그레이션의 성공은 “명령어가 끝났다”가 아니다.
데이터가 보존되고, 참조가 유지되고, 모든 마이그레이션이 적용되었으며, 그 사실이 배포 시스템에 실패 없이 기록되는 것까지가 성공이다.
댓글 0개