본문 바로가기

개발기/플레이톡

2편: 배포 전 스크립트를 끼울 자리가 없다 — 마이그레이션을 서버 기동에 밀어 넣기

반응형

스키마를 다 짰으니 이제 데이터를 옮길 차례입니다. 처음엔 평범하게 CLI 스크립트를 만들었습니다.

 
node src/migrate.js          # DB 파일이 이미 있으면 중단
node src/migrate.js --force  # 덮어쓰기

이걸 배포 직전에 한 번 돌리면 끝 — 이라고 생각했는데, 여기서 막혔습니다.

배포 파이프라인에 손댈 자리가 없었다

PlayTalk은 집에 둔 미니 PC에서 셀프호스티드 러너로 배포됩니다. main에 머지되면 러너가 알아서 빌드하고 컨테이너를 갈아 끼우는 구조예요. 문제는 이 파이프라인이 "main 머지 = 배포 완료" 하나로 굴러가게 만들어져 있어서, 중간에 "이번 배포만 이 스크립트를 먼저 돌려주세요" 같은 단계를 끼워 넣기가 마땅치 않았다는 겁니다.

수동으로 서버에 붙어서 돌리는 것도 가능은 합니다. 하지만 그러면 배포 순서가 사람 손에 달립니다. 컨테이너가 먼저 뜨고 마이그레이션이 나중에 돌면? 새 코드가 빈 DB를 보고 정상 기동해 버리고, 그 사이에 들어온 유저의 기록이 빈 DB에 쌓입니다. 그 다음에 마이그레이션이 돌면서 그걸 덮어쓰면 진짜 데이터 유실이에요.

그래서 방향을 뒤집었습니다. 마이그레이션을 배포 단계가 아니라 서버 기동 코드에 넣는다.

로직은 그대로, 진입점만 둘로

CLI로 쓰던 걸 서버에서도 쓰려면 함수로 꺼내야 합니다. 이전 로직을 migrateFromJson(), 경로 해석을 resolveDataPaths()로 export하고, CLI 진입점(직접 실행 시 파일 존재하면 중단, --force, 요약 출력)은 그대로 남겼습니다. 트랜잭션 처리도, 키 매핑 규칙도 건드리지 않았어요. 이미 검증한 로직이 두 진입점에서 똑같이 도는 게 중요했습니다.

그리고 서버 기동 최상단, getDb()를 처음 부르기 직전에 분기를 넣었습니다.

 
js
// 기동 시 저장소 준비: DB가 있으면 그대로, 없고 users.json이 있으면 JSON→SQLite 자동 이전,
// 둘 다 없으면 빈 DB(신규 설치). 이전 실패 시 빈 DB로 기동하지 않고 종료(exit 1).
{
  const paths = resolveDataPaths();
  if (existsSync(paths.dbFile)) {
    getDb();
  } else if (existsSync(paths.usersFile)) {
    console.log('[startup] DB 없음 + users.json 존재 → JSON→SQLite 자동 이전 시작');
    try {
      printSummary(migrateFromJson(paths));
    } catch (e) {
      console.error('[startup] 자동 이전 실패 → 빈 DB로 기동하지 않고 종료:', e);
      process.exit(1);
    }
    getDb();
  } else {
    console.log('[startup] DB·users.json 모두 없음 → 빈 DB로 신규 기동');
    getDb();
  }
}

세 갈래가 전부입니다.

  1. DB 파일이 있다 → 이미 이전이 끝난 상태. 그냥 연다. (재배포·재시작 때마다 여기로 들어옵니다)
  2. DB는 없는데 users.json이 있다 → 첫 배포. 자동 이전.
  3. 둘 다 없다 → 신규 설치. 빈 DB.

판단 기준을 "DB 파일의 존재"로 잡은 게 핵심입니다. 별도의 버전 테이블이나 플래그 파일 없이도 몇 번을 재시작하든 이전은 정확히 한 번만 일어납니다.

실패했을 때 일부러 안 띄웠다

위 코드에서 제일 중요한 줄은 process.exit(1) 입니다.

보통 서버는 어떻게든 기동시키고 싶잖아요. 근데 여기서는 그러면 안 됩니다. 이전이 실패했는데 빈 DB로 기동해 버리면 그게 최악이거든요. 서비스는 멀쩡히 열려 있고, 유저는 들어와서 "어? 내 전적이 다 사라졌네" 를 보고, 그 위에 새 기록이 쌓입니다. 이 상태에서 원본 JSON으로 복구하려면 그 사이 들어온 기록을 손으로 병합해야 해요.

차라리 서버가 안 뜨는 게 낫습니다. 컨테이너가 죽어 있으면 저는 즉시 알아채고, 원본 JSON은 그대로 남아 있으니 고쳐서 다시 배포하면 그만입니다.

같은 이유로 migrateFromJson()은 이렇게 동작합니다.

  • 입력 JSON은 읽기만 한다. 절대 쓰지 않는다. (원본이 항상 복구 지점으로 남습니다)
  • 전체를 단일 트랜잭션으로 감싼다.
  • 실패하면 부분 생성된 DB 파일을 지우고(-wal, -shm까지) 예외를 다시 던진다.

DB 파일을 지우는 게 중요합니다. 안 지우면 다음 재시작 때 "DB 파일이 있네?" 하고 1번 갈래로 들어가서 반쯤 이전된 DB로 서비스가 열립니다. 이건 빈 DB보다 더 나빠요. 그럴듯하게 동작하니까요.

 
js
function removeDbFiles(dbFile) {
  for (const suffix of ['', '-wal', '-shm']) {
    try { if (existsSync(dbFile + suffix)) unlinkSync(dbFile + suffix); } catch { /* noop */ }
  }
}

기존 모듈은 import하지 않는다

이전 스크립트를 짜면서 지킨 규칙이 하나 더 있습니다. auth/guestStats/friends/rankings 모듈을 import하지 않는다.

편하게 하려면 기존 모듈을 불러서 "읽기 함수로 읽고 새 DB에 쓰기"를 하면 됩니다. 그런데 그 모듈들은 이번 PR에서 내부가 DB로 갈아치워진 파일들이에요. 이전 스크립트가 그걸 import하면, 이전 코드가 새 저장소를 거쳐서 동작하는 이상한 순환이 생깁니다.

그래서 파일 경로 폴백 규칙과 데이터 정규화 규칙을 이전 스크립트 안에서 똑같이 재현했습니다. 중복이지만 의도된 중복이에요. 이전 스크립트는 "옛 포맷을 읽는 코드"고, 그 포맷은 앞으로 절대 바뀌지 않으니까요.

경로 폴백을 옮겨 적다가 함정도 하나 발견했습니다.

 
js
//  db.js         : DB_FILE          || cwd/data/playtalk.db
//  auth.js       : USERS_FILE       || <모듈>/../data/users.json   (모듈 상대경로!)
//  guestStats.js : GUEST_STATS_FILE || cwd/data/guestStats.json

users.json만 모듈 상대 경로를 쓰고 나머지는 cwd 기준이었습니다. 오래 굴린 프로젝트가 으레 그렇듯 그때그때 다르게 짜인 거죠. 이걸 모르고 전부 cwd로 통일했으면 users.json을 못 찾고 "DB도 없고 users.json도 없네 → 빈 DB로 신규 기동" 3번 갈래로 조용히 빠졌을 겁니다. exit 1도 안 뜨고, 서비스는 멀쩡히 열리고, 데이터만 없는 최악의 시나리오였어요.

결과

main에 머지하니 러너가 배포하고, 컨테이너가 뜨면서 로그에 이전 요약이 찍히고, 서비스는 그대로 돌았습니다. 다운타임은 컨테이너 교체 시간뿐이었어요.

그런데 몇 시간 뒤에 서버가 죽기 시작했습니다.

다음 편에서는

전환 자체는 성공했는데, 그 다음날부터 서버가 이따금 죽고 재시작하는 일이 생겼습니다. 로그에는 딱 한 줄만 남아 있었어요. FOREIGN KEY constraint failed. 다음 편은 이 크래시를 두 번 잡은 이야기입니다. 한 번은 응급 처치로, 한 번은 근본 원인으로요.

👉 PlayTalk 에서 직접 플레이해 보실 수 있습니다.

반응형