브라우저 탭을 닫는다고 해서 4시간 동안의 진행 상황이 사라져서는 안 됩니다. 당연한 말 같지만, 수많은 브라우저 게임들이 localStorage를 뒷전으로 취급하곤 합니다. 플레이어가 최고 점수를 달성하고, 설정을 조정하고, 다음 날 다시 돌아왔을 때 아무것도 남아있지 않은 상황을 상상해 보세요. 더 나쁜 것은, 패치 후에 돌아왔을 때 방금 배포한 코드와 기기에 저장된 파일이 더 이상 일치하지 않아 게임이 에러를 내뱉는 경우입니다. Phaser 4로 서바이버 스타일의 슈팅 게임을 만드는 것은 끊임없이 몰려오는 적의 파도를 상대하는 일이지만, 진짜 장기적인 위협은 바로 여러분의 미래 업데이트입니다.

대부분의 개발자는 객체를 가져와 JSON.stringify를 실행하고 이를 localStorage에 쏟아붓는 방식으로 첫 저장 시스템을 만듭니다. 로드할 때는 이를 파싱하여 게임에 그대로 전달합니다. 첫날에는 잘 작동합니다. 하지만 새로운 설정, 새로운 해금 플래그, 또는 세 번째 계층의 중첩된 구성을 추가하는 순간 깨지기 시작합니다. 만약 복귀한 플레이어의 오래된 저장 파일에 vignette 속성이 없고, 새 코드가 이 속성이 존재할 것으로 예상한다면, 불리언(boolean) 값이 있어야 할 자리에 undefined가 나타나게 됩니다. 이런 문제가 수십 개의 새로운 기능으로 확장되면, 가장 충성도 높은 플레이어들이 가장 먼저 겪게 되는 디버깅 지옥이 펼쳐집니다.

원시 객체가 아닌, 계약(Contract)부터 시작하세요

localStorage를 건드리기 전에, 코드베이스에 기본 저장 스키마(schema)를 정의하세요. 이것이 5분 전에 만들어졌든 5개월 전에 만들어졌든, 모든 저장 파일이 준수해야 하는 '계약'이라고 생각하십시오. 명확한 시작점은 다음과 같을 수 있습니다:

const defaultSave = {
  highScore: 0,
  settings: {
    screenShake: true,
    vignette: true
  }
};

이 객체는 소스 코드에 존재합니다. 게임이 부팅될 때, 여러분은 항상 이 형태를 사용할 수 있습니다. 이는 기준점(baseline)을 제공합니다. 또한 직렬화(serialize)를 하기 전에 구조를 고민하게 만듭니다. 이 단계를 건너뛰고 당시 편리한 상태 객체를 단순히 저장하기만 하면, 키가 일치하지 않거나 필드가 누락되어 오래된 저장 데이터가 예상과 어긋날 때 조용히 오류가 발생하는 상황을 맞이하게 됩니다.

Try/Catch를 이용한 방어적 로딩

localStorage는 데이터베이스가 아닙니다. 브라우저 안에 있는 문자열 보관함일 뿐이며, 무엇이든 들어갈 수 있습니다. 사용자가 값을 수동으로 편집했을 수도 있고, 쓰기 작업이 도중에 중단되었을 수도 있으며, 브라우저 확장 프로그램이 여러분이 지정한 키에 쓰레기 데이터를 집어넣었을 수도 있습니다. 해당 문자열을 다시 가져와 JSON.parse에 넣을 때, 단 하나의 손상된 문자만 있어도 하드 예외(hard exception)가 발생합니다. Phaser 게임에서 이러한 처리되지 않은 에러는 부팅 시퀀스를 멈추거나 플레이어를 빈 화면으로 내몰 수 있습니다.

읽기 및 파싱 로직을 항상 try/catch 블록으로 감싸세요. 실패할 경우 기본 스키마로 되돌아가십시오(fall back). 목표는 간단합니다. 저장 파일을 읽을 수 없다면, 전체 세션을 중단시키는 대신 플레이어를 새로운 사용자로 취급하는 것입니다. 이 작은 습관 하나가 취미 프로젝트와 프로덕션급 빌드를 가릅니다. 구현하는 데 비용이 거의 들지 않으면서도, 재현 불가능한 미스터리한 버그 리포트로부터 여러분을 구해줄 것입니다.

기존 데이터를 기본값과 병합하기

파싱에 성공했다고 해서 안전한 것은 아닙니다. 파싱된 결과로 기본 객체를 통째로 교체하지 마세요. 오래된 저장 파일에는 최신 설정이 포함되어 있지 않을 수 있습니다. screenShake는 저장되어 있지만 vignette는 없을 수도 있습니다. 최신 업데이트와 함께 배포된 vignette가 존재한다고 게임 로직이 가정한다면, 다시 undefined 에러를 쫓아다니게 될 것입니다.

대신, 로드된 데이터를 기본값과 병합하세요. Object.assign을 사용하여 기준 스키마 위에 저장된 값을 층층이 쌓으세요. 기본값이 누락된 모든 빈틈을 자동으로 채워줍니다. 버전 2에서 추가한 새로운 속성은 기본 객체로부터 초기값을 가져옵니다. 플레이어가 실제로 변경한 기존 속성은 저장된 설정값으로 덮어씌워집니다. 모두가 이득입니다. 복귀한 플레이어는 최고 점수를 유지할 수 있고, 게임은 어제 추가한 새로운 토글 기능을 오류 없이 사용할 수 있게 됩니다.

Object.assign은 얕은 병합(shallow merge)을 수행한다는 점을 명심하세요. 시간이 지나면서 설정 객체가 깊게 중첩된다면, 내부 객체들은 조금 더 주의 깊게 다뤄야 할 수도 있습니다. 그럼에도 원칙은 동일합니다. 플레이어의 데이터는 기본값을 완전히 대체하는 것이 아니라, 기본값을 꾸며주는(decorate) 역할을 해야 합니다.

키에 버전을 부여하세요

브라우저는 오래된 localStorage 항목을 자동으로 삭제하지 않습니다. 데이터 구조를 대대적으로 변경한다면, 이전 형식을 깔끔하게 버릴 수 있는 방법이 필요합니다. 저장 키 이름에 버전 접미사를 붙이세요. bitSurvivorsSave_v1은 명확합니다. 어떤 스키마가 해당 파일을 작성했는지 정확히 알려줍니다. 나중에 진행 방식(progression)을 전면 개편하거나 전체 인벤토리 시스템을 추가할 때는 bitSurvivorsSave_v2로 이동하세요.

이는 두 가지 실질적인 이점을 제공합니다. 첫째, v1 블롭(blob)을 v2 로직으로 실수로 파싱하는 일을 방지할 수 있습니다. 둘째, 원한다면 마이그레이션 코드를 작성할 수 있습니다. 부팅 시 v1이 있는지 확인하세요. v1은 존재하지만 v2가 없다면, 기존 데이터를 새로운 구조로 마이그레이션하고 새로운 키에 기록한 뒤 진행하면 됩니다. 마이그레이션을 원하지 않더라도, 최소한 새로운 코드가 이를 무시하는 동안 기존 키는 저장소에 해롭지 않게 남아 있게 됩니다. 어떤 방식이든, 버전 관리는 데이터가 조용히 손상되는 것을 방지합니다.

저장을 눈에 띄지 않게 만드세요

데이터 지속성(Persistence)은 숨 쉬는 것처럼 자연스러워야 합니다. 플레이어가 이를 의식하게 해서는 안 됩니다. 설정 메뉴에 '적용(Apply)' 버튼을 추가하지 마세요. '적용' 버튼은 마찰을 일으키며, 사용자가 자신의 선택이 실제로 반영되었는지 걱정하게 만듭니다. 또한 플레이어가 세 가지 옵션을 변경한 뒤 '적용'을 누르지 않고 탭을 닫아버리면 데이터 손실로 이어질 수도 있습니다.

상호작용이 일어나는 즉시 저장하세요. 플레이어가 화면 흔들림(screen shake)을 비활성화하기 위해 체크박스를 클릭하면, 즉시 쓰기(write) 함수를 호출하세요. 플레이의 실행(run)이 끝나고 최종 점수가 집계될 때, 게임 오버 화면의 애니메이션이 끝나기 전에 새로운 최고 점수를 기록하세요. 이벤트 기반 저장(Event-driven saving)은 데이터가 변경된 동작 바로 옆에 저장이 위치하기 때문에 아키텍처를 예측 가능하게 유지해 줍니다. 중앙 집중식 배치(batching) 함수를 찾아 헤매거나 오래된 상태(stale state)에 대해 걱정할 필요가 없습니다.

이 접근 방식은 사고 모델(mental model)도 단순화합니다. 지속성이 어디에서 발생하는지 정확히 알 수 있습니다. 토글을 처리하는 콜백(callback)과 사망을 처리하는 함수 내에서 말이죠. 코드베이스 곳곳에 정체 모를 쓰기 작업이 흩어져 있는 일은 없을 것입니다.

자신을 위한 리셋 버튼을 만드세요

개발 중에 스스로 저장 데이터를 손상시키는 일이 생길 것입니다. 잘못된 데이터를 쓰거나, 엣지 케이스(edge case)를 테스트하다 보면 빠르게 깨끗한 상태로 돌아가야 할 때가 있습니다. 디버그 메뉴나 숨겨진 키 조합에 리셋 버튼을 만드세요. 그리고 그 리셋 버튼이 다음의 정확한 순서로 두 가지 작업을 수행하도록 만드세요. 먼저 인메모리(in-memory) 상태를 기본 스키마(default schema)로 리셋한 다음, 즉시 로컬 스토리지(local storage)에 기록하는 동일한 저장 함수를 호출하는 것입니다.

로컬 변수만 지우고 쓰기 단계를 건너뛴다면 아무것도 한 것이 아닙니다. 페이지를 새로고침하면 브라우저에서 이전 데이터를 다시 불러와 부활시켜 버리기 때문입니다. 지속(persist)하는 것을 잊은 리셋은 오후 시간을 통째로 날려버리는 버그가 됩니다. 이 순서를 한 번만 제대로 잡아두면, 프로젝트가 끝날 때까지 테스트 루프를 빠르게 유지할 수 있습니다.

핵심 요약

저장은 마지막에 덧붙이는 기능이 아닙니다. 게임이 견고하게 느껴지는지, 그리고 플레이어의 시간을 존중하는지를 결정하는 인프라입니다. Phaser 4 서바이버 슈터(survivor shooter) 게임의 생사는 반복되는 플레이(runs)에 달려 있습니다. 만약 브라우저 탭이 플레이어의 진행 상황을 겨냥한 장전된 총과 같다면, 플레이어는 결국 다시 돌아오지 않을 것입니다. 스키마를 작성하고, 잘못된 데이터로부터 방어하며, 덮어쓰는 대신 병합(merge)하고, 키에 버전을 부여하며, 모든 의미 있는 이벤트마다 저장하세요. 미래의 당신과 다음 업데이트 이후 돌아올 모든 플레이어가 당신에게 감사할 것입니다.