본문으로 건너뛰기

dotgimon 제작기 08. SQLite 저장과 생애 기록 이전

·15 min read·8 / 32

표기 날짜는 작업 순서에 맞춰 배치한 날짜다. 실제 작업일은 확인되지 않으며, 관련 변경은 9월 30일에 저장소에 한꺼번에 남았다.

dotgimon은 앱을 닫아 둔 동안에도 시간이 흐르고, 펫이 죽거나 리셋되면 새 세대가 시작되는 게임이다. 저장에는 현재 펫의 상태와 도감, 최근 통신 세션만 들어 있었다. 새 생애가 시작되면 이전 펫이 어떤 종으로 자랐는지 따로 찾아볼 수 없었다.

생애 기록과 통신 결과를 함께 남기기 위해 저장을 SQLite의 pixelpet.db로 옮겼다. 새 저장을 만드는 것보다 조심한 부분은 이미 키우던 펫을 가져오는 과정이었다. 손상된 옛 저장을 새 계정으로 덮어쓰지 않고, 정상 데이터만 한 번 가져오는 순서를 정했다.

JSON 한 덩어리에 현재 상태만 있었다

이전 저장은 AsyncStorage의 pixelpet2 키에 JSON을 덮어쓰는 방식이었다. 상태는 엔진에서 직렬화하고, 발견한 종과 링크 세션 목록을 같은 객체에 넣었다.

const blob: SaveBlob = {
  state: game.serialize(),
  discovered: [...discoveredRef.current],
  linkLog: linkLogRef.current,
}
AsyncStorage.setItem(KEY, JSON.stringify(blob)).catch(() => {})

구조는 작았지만 저장 실패를 빈 catch로 넘겼다. 쓰지 못한 상태에서 앱이 끝나면 마지막 저장 이후의 진행을 잃을 수 있었다. 현재 상태를 계속 덮어쓰므로 이전 세대의 진화와 대전 시각도 따로 남지 않았다.

통신 세션 목록은 최근 16개만 기억했다. 같은 QR 결과를 다시 쓰지 못하게 하는 용도였지만, 창 밖으로 밀린 오래된 세션은 기억하지 못했다. 통신 결과와 생애 사건, 이미 사용한 거래 ID를 함께 보관할 구조가 필요했다.

하나의 JSON에도 이 값들을 함께 담을 수는 있다. 이번 선택은 JSON이 여러 값을 표현하지 못해서가 아니었다. 개별 이력을 계속 쌓고 거래 기록을 따로 조회하는 구조를 늘리는 대신, 테이블로 나누고 여러 변경을 트랜잭션으로 묶는 쪽을 택했다. 트랜잭션은 그 안의 변경을 함께 확정하거나 실패 시 되돌리는 단위다.

엔진 상태를 모두 컬럼으로 나누지는 않았다

엔진의 SavedState는 필드가 많고 게임 기능에 따라 늘어났다. 모든 값을 DB 컬럼으로 옮기면 상태 필드를 추가할 때마다 스키마도 바꿔야 했다. 엔진 스냅샷은 JSON 텍스트로 유지하고, 기록 조회에 필요한 식별자와 시각만 컬럼으로 꺼냈다.

테이블저장한 내용
profile기기 식별자와 표시 이름
current_save최신 상태와 도감 등이 든 저장 객체
pets세대별 개체, 현재 종, 출생·종료 시각
life_events부화·진화·합체·대전·사망·리셋 사건
used_sessions이미 사용한 통신 세션 ID

프로필과 현재 저장은 각각 한 행만 두었다. 기본키를 1로 제한하는 CHECK(singleton=1) 조건으로 다른 번호의 행이 들어가지 않게 했다. 생애 사건에는 개체 ID를 외래 키로 연결했다. 통신 진행 상태와 기기 설정도 별도 테이블에 두었다.

도감처럼 엔진 저장과 함께 읽는 데이터는 현재 저장 JSON 안에 남겼다. 나누는 기준은 모든 것을 정규화하는 데 있지 않고, 독립적으로 찾고 쌓아야 하는 기록이 무엇인지에 있었다.

같은 종이어도 같은 개체는 아니었다

개체의 기본키를 종 ID로 만들 수는 없었다. 같은 종을 여러 세대에 걸쳐 키울 수 있고, 한 개체도 진화하면 종 ID가 바뀐다. 세대와 출생 시각을 합쳐 개체 키로 썼다.

export function petKey(state: SavedState): string {
  return `${state.generation ?? 1}:${state.bornAt}`
}

진화해도 이 값은 유지되므로 같은 pets 행에 상태를 갱신할 수 있었다. 사건은 그 키를 가리키며 쌓였다. 최신 종과 당시 어떤 종으로 사건이 일어났는지는 개체 행과 사건 행에서 각각 읽었다.

개체를 저장할 때는 없으면 넣고 있으면 갱신하는 upsert를 썼다. 같은 행의 종과 상태 JSON을 함께 바꾸되, 이미 기록된 종료 시각과 사유는 이 upsert에서 우선 유지했다.

ON CONFLICT(id) DO UPDATE SET
  species=excluded.species,
  state=excluded.state,
  endedAt=COALESCE(pets.endedAt,excluded.endedAt),
  endReason=COALESCE(pets.endReason,excluded.endReason)

COALESCE는 앞에서부터 NULL이 아닌 값을 고른다. 종료 값이 채워져 있으면 그 값을 쓰고, 비어 있을 때 새 값을 사용했다. 사망과 리셋 사건을 쓰는 경로의 갱신과는 구별했다.

상태와 기록, 거래 ID를 같이 확정했다

저장할 때 생애 사건에 해당하는 개체를 갱신하고 사건을 넣은 뒤, 현재 개체와 스냅샷을 썼다. 사용한 링크 세션도 같은 저장 작업에서 추가했다. 이 변경들을 withTransactionSync 안에서 실행했다.

진화 사건만 저장되고 현재 스냅샷은 이전 종으로 남는 상태를 피하려 했다. 통신에서도 보상을 받고 사용 거래 ID만 빠지면 같은 결과를 다시 적용할 수 있었다. 성공과 실패를 같은 경계로 맞췄다.

실패 테스트에서는 디스크 오류처럼 세션 기록 쓰기가 예외를 던지게 했다. 상태와 생애 기록, 거래 영수증이 모두 이전 상태로 돌아오는지 검사했다. 같은 영수증을 두 번 적용하면 거부하는 사례도 따로 두었다.

이전 데이터를 검사한 뒤 계정을 만들었다

DB를 열면 프로필 테이블과 행이 있는지 확인했다. 이미 SQLite 초기화가 끝난 상태라면 옛 AsyncStorage 값을 읽지 않았다. 새 저장을 사용하기 시작한 뒤 손상되거나 오래된 옛 데이터가 앱 시작을 막지 않게 했다.

const db = await openDatabaseAsync('pixelpet.db')
const exists = db.getFirstSync(
  "SELECT name FROM sqlite_master WHERE type='table' AND name='profile'",
)
const initialized = exists && db.getFirstSync(
  'SELECT singleton FROM profile WHERE singleton=1',
)
const legacy = initialized ? null : await AsyncStorage.getItem('pixelpet2')

처음 옮기는 경우에는 parseSave로 JSON을 검사했다. 버전과 종 ID, 주요 숫자·불리언 필드, 생애 경로와 도감 목록을 확인했다. 배고픔과 힘은 범위 안인지, 종 ID는 실제 도감에 있는지 검사한 뒤에만 DB에 넣었다.

검사는 계정과 테이블을 만들기 전에 했다. 깨진 저장을 읽고도 새 계정을 만들어 버리면, 사용자에게는 펫이 사라진 채 초기화된 앱으로 보일 수 있었다. 손상된 JSON이 들어왔을 때 예외가 나고 프로필 테이블이 생성되지 않는 테스트를 두었다.

초기화에서도 이미 있는 프로필은 덮어쓰지 않고, 현재 저장이 없고 정상적인 이전 데이터가 있을 때만 가져왔다. 프로필과 펫, 도감, 사용 세션과 가져오기 사건은 같은 트랜잭션에 넣었다. 초기화를 다시 호출해도 이미 옮긴 펫을 옛 값으로 되돌리지 않게 했다.

AsyncStorage의 원본은 지우지 않았다. 이전 코드에 문제가 있으면 돌아갈 데이터가 필요했다. 원본 보존과 한 번만 이전하는 판정을 함께 두어, 남겨 둔 옛 저장을 매번 다시 읽지는 않도록 했다.

옛 저장에 없는 생애는 만들지 않았다

옛 저장에는 현재 펫만 있었다. 이전 세대나 정확한 진화 시각을 나중에 계산해 복원할 수는 없었다. 가져오는 시점에 import 사건 하나를 넣고 현재 상태를 기록의 출발점으로 삼았다.

this.write(migrated, [{
  kind: 'import',
  at: profile.createdAt,
  state: migrated.state,
}])

이미 키운 펫이 새로 태어났다고 기록하지 않았다. 테스트에서도 가져오기 사건 한 건과 현재 펫, 도감, 사용 세션이 유지되는지를 확인했다. 모르는 과거를 그럴듯한 시각으로 채우는 대신 어디서부터 기록이 시작됐는지 남겼다.

DB를 다시 만들기 전에 버전부터 확인했다

PRAGMA user_version이 앱이 아는 버전보다 높으면 예외를 던졌다. 오래된 앱이 새 앱의 저장소를 열고 자기 기준으로 초기화하지 않도록 했다. 더 높은 버전 값을 넣은 테스트에서는 실패 뒤에도 버전이 그대로 남는지 확인했다.

여러 곳에서 저장소를 동시에 요청하는 경우도 묶었다. openPetRepository()가 진행 중인 Promise를 모듈 변수에 보관해 같은 열기 작업을 공유했다. 실패하면 DB 핸들을 닫고 Promise를 비워 다시 시도할 수 있게 했다.

저장 실패를 숨기던 동작도 바꾸었다. 실패한 상태로 계속 진행하다 앱을 닫는 대신 진행을 잠시 멈추고, 기존 DB를 새것으로 바꾸지는 않는 규칙을 두었다. 프로필의 UUID는 공개 식별자일 뿐 인증 토큰으로 취급하지 않았다. 서버 계정과 기기 분실 후 복구는 이번 저장 구조에 넣지 않았다.

테스트에서도 실제 SQLite를 썼다

저장 코드는 RN에서 분리하고 Database 인터페이스 뒤에 두었다. 필요한 메서드는 SQL 실행과 단일·전체 행 조회, 트랜잭션이었다. 앱에서는 expo-sqlite를 사용하고 테스트에서는 Node의 DatabaseSync(':memory:')를 같은 인터페이스로 감쌌다.

테스트 어댑터의 트랜잭션은 BEGIN으로 시작해 성공하면 COMMIT, 예외가 나면 ROLLBACK했다. 모의 객체의 반환값만 확인하는 대신 외래 키와 충돌 처리, 롤백이 실제 SQLite에서 동작하는지 볼 수 있었다.

한 번만 이전하는지, 손상된 저장을 새 계정으로 바꾸지 않는지, 미래 버전을 거부하는지에 각각 테스트를 두었다. 같은 펫이 진화해도 하나의 개체로 남고 세대가 바뀌어도 이전 개체가 보존되는지도 검사했다. 통신 결과는 연출 전에 확정해 재시작 뒤에도 남고 두 번 지급되지 않는 흐름을 확인하도록 했다.

저장 형식을 바꾸면서 과거에 없던 정보를 복구하지는 못했다. 대신 가져온 현재 펫부터 생애가 쌓이기 시작했고, 상태와 기록과 거래가 같은 저장 경계를 갖게 되었다. 저장 실패를 무시하지 않는 것과 옛 원본을 남겨 두는 것도 이전 과정의 일부로 넣었다.