Skip to main content
네이티브
입문네이티브React Native 플랫폼 · 10

저장 데이터 마이그레이션: 이전 앱의 JSON을 새 앱은 어떻게 읽는가

이전 앱이 저장한 영속 데이터에 형식 버전을 붙이고, 현재 코드의 타입으로 단정하기 전에 읽기·검사·단계 변환·재검사·저장을 수행하는 경계를 익힌다.

마지막 검증 재검증 정책: 제품 버전 의존: 새 주요 버전마다 재검증
목차
표준·구현·측정·해석 표시는 무엇인가요?
  • 표준웹 표준이나 언어 명세가 정한 동작
  • 구현특정 기술이나 브라우저가 실제로 구현한 동작
  • 측정명시한 환경에서 직접 실행해 관찰한 결과
  • 해석앞선 근거에서 도출한 설계 판단
  • 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용

30초 요약

앱 코드는 업데이트되지만 9편에서 저장한 데이터는 같은 설치에 남는다. 따라서 새 코드의 타입은 저장된 값의 실제 구조를 바꾸지 않는다. 형식 버전을 데이터 안에 넣고 다음 순서를 지킨다.

raw 문자열 읽기
→ JSON 파싱
→ 출발 버전 구조 검사
→ 한 단계 변환
→ 현재 버전 구조 재검사
→ 현재 형식 저장

파싱·검사·변환 중 하나라도 실패하면 원본을 자동 삭제하거나 현재 형식으로 덮어쓰지 않는다. 지원하는 모든 과거 버전의 실제 fixture를 출시 입력으로 남긴다.

이 글을 관통하는 상황: V1 초안을 V2 앱이 바로 읽는다

9편의 배송 메모에 금액을 추가했다고 하자. 이전 V1 앱은 다음 문자열을 저장했다.

{
  "version": 1,
  "note": "현관 앞에 놓아주세요",
  "total": 12900
}

V2 앱은 필드 이름과 금액 구조를 바꿨다. 아래 App.tsx는 같은 delivery-note 키에 V1 문자열을 심고, 타입 단정만 한 잘못된 읽기 함수를 실제로 실행한다.

import { useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import { createAsyncStorage } from '@react-native-async-storage/async-storage'
 
type DraftV2 = {
  version: 2
  deliveryNote: string
  total: {
    amount: number
    currency: 'KRW'
  }
}
 
const draftStorage = createAsyncStorage('delivery-draft')
const DRAFT_KEY = 'delivery-note'
const V1_RAW = JSON.stringify({
  version: 1,
  note: '현관 앞에 놓아주세요',
  total: 12900,
})
 
async function loadDraftBad() {
  const raw = await draftStorage.getItem(DRAFT_KEY)
  if (raw === null) return '저장된 초안 없음'
 
  const draft = JSON.parse(raw) as DraftV2
  return `${draft.deliveryNote} / ${draft.total.amount.toLocaleString()}원`
}
 
export default function App() {
  const [result, setResult] = useState('실패 재현을 실행하세요')
 
  async function reproduceFailure() {
    setResult('실행 중')
 
    try {
      await draftStorage.setItem(DRAFT_KEY, V1_RAW)
      setResult(await loadDraftBad())
    } catch (error) {
      setResult(error instanceof Error ? error.name : '알 수 없는 오류')
    }
  }
 
  return (
    <View style={{ padding: 24, gap: 12 }}>
      <Pressable onPress={() => void reproduceFailure()}>
        <Text>V1 초안 읽기</Text>
      </Pressable>
      <Text>결과: {result}</Text>
    </View>
  )
}

실행 전에 예측한다.

  1. JSON.parsenotedeliveryNote로 이름 변경해 줄까?
  2. as DraftV2가 숫자 total{ amount, currency } 객체로 바꿀까?
  3. V1 fixture에서 draft.total.amount는 어떤 값일까?

V1의 deliveryNote는 없고 total은 숫자다. 따라서 draft.total.amountundefined이며, toLocaleString()을 호출하는 지점에서 실행 오류가 난다. 타입 단정이 실제 저장 값을 바꾸지 않았기 때문이다. 버튼을 누르면 화면 결과가 TypeError로 바뀐다. JavaScript 엔진에 따라 뒤의 오류 문구는 달라질 수 있으므로 오류 종류와 V1 원본 구조를 증거로 삼는다.

JSON.parse와 타입 단정은 마이그레이션이 아니다

영속 저장소에서 읽은 문자열은 과거 앱, 손상된 쓰기, 개발 도구나 더 새로운 앱 버전이 만든 값일 수 있다. 현재 앱이 썼다고 기억하는 값도 읽는 순간에는 외부 입력처럼 실제 구조를 확인한다.

데이터 안에 형식 버전을 저장한다

버전 숫자는 어떤 구조 검사와 변환 함수를 적용할지 고르는 표지다. 앱 버전이나 React Native 버전을 대신 저장하지 않는다. 같은 앱 버전에서도 저장 형식만 바뀔 수 있기 때문이다.

type DraftV1 = {
  version: 1
  note: string
  total: number
}
 
type DraftV2 = {
  version: 2
  deliveryNote: string
  total: {
    amount: number
    currency: 'KRW'
  }
}

읽기-검사-변환-재검사-저장 순서를 고정한다

먼저 draft-record.ts를 만든다. unknown은 아직 구조를 믿지 않는 값이라는 TypeScript 타입이다. 각 검사 함수는 실제 필드와 타입을 확인한 뒤에만 더 구체적인 타입으로 범위를 좁힌다.

export type DraftV1 = {
  version: 1
  note: string
  total: number
}
 
export type DraftV2 = {
  version: 2
  deliveryNote: string
  total: {
    amount: number
    currency: 'KRW'
  }
}
 
type MigrationErrorCode =
  | 'INVALID_JSON'
  | 'INVALID_V1'
  | 'INVALID_V2'
  | 'UNSUPPORTED_VERSION'
  | 'MISSING_VERSION'
 
export class DraftMigrationError extends Error {
  constructor(readonly code: MigrationErrorCode) {
    super(code)
    this.name = 'DraftMigrationError'
  }
}
 
function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
}
 
function isDraftV1(value: unknown): value is DraftV1 {
  return (
    isObject(value) &&
    value.version === 1 &&
    typeof value.note === 'string' &&
    typeof value.total === 'number' &&
    Number.isFinite(value.total)
  )
}
 
function isDraftV2(value: unknown): value is DraftV2 {
  return (
    isObject(value) &&
    value.version === 2 &&
    typeof value.deliveryNote === 'string' &&
    isObject(value.total) &&
    typeof value.total.amount === 'number' &&
    Number.isFinite(value.total.amount) &&
    value.total.currency === 'KRW'
  )
}
 
function migrateV1ToV2(source: DraftV1): DraftV2 {
  return {
    version: 2,
    deliveryNote: source.note,
    total: {
      amount: source.total,
      currency: 'KRW',
    },
  }
}
 
export function readCurrentDraft(raw: string): {
  draft: DraftV2
  migratedFrom: 1 | null
} {
  let parsed: unknown
 
  try {
    parsed = JSON.parse(raw)
  } catch {
    throw new DraftMigrationError('INVALID_JSON')
  }
 
  if (!isObject(parsed) || typeof parsed.version !== 'number') {
    throw new DraftMigrationError('MISSING_VERSION')
  }
 
  if (parsed.version === 1) {
    if (!isDraftV1(parsed)) throw new DraftMigrationError('INVALID_V1')
    const migrated = migrateV1ToV2(parsed)
    if (!isDraftV2(migrated)) throw new DraftMigrationError('INVALID_V2')
    return { draft: migrated, migratedFrom: 1 }
  }
 
  if (parsed.version === 2) {
    if (!isDraftV2(parsed)) throw new DraftMigrationError('INVALID_V2')
    return { draft: parsed, migratedFrom: null }
  }
 
  throw new DraftMigrationError('UNSUPPORTED_VERSION')
}

유효한 현재 형식만 다시 저장한다

다음으로 draft-storage.ts를 만든다. 9편의 Async Storage 3.1.1 저장 공간을 이어 쓴다. setItem은 같은 키의 문자열을 덮어쓰므로, 파싱이나 변환이 실패한 원본에는 호출하지 않는다.

import { createAsyncStorage } from '@react-native-async-storage/async-storage'
import { readCurrentDraft, type DraftV2 } from './draft-record'
 
const draftStorage = createAsyncStorage('delivery-draft')
const DRAFT_KEY = 'delivery-note'
 
type DraftStorage = {
  getItem(key: string): Promise<string | null>
  setItem(key: string, value: string): Promise<void>
}
 
export async function loadAndMigrateDraft(
  storage: DraftStorage = draftStorage,
): Promise<DraftV2 | null> {
  const raw = await storage.getItem(DRAFT_KEY)
  if (raw === null) return null
 
  const result = readCurrentDraft(raw)
 
  if (result.migratedFrom !== null) {
    await storage.setItem(DRAFT_KEY, JSON.stringify(result.draft))
  }
 
  return result.draft
}

저장까지 실패하면 메모리에서 변환된 값을 성공으로 확정하지 않는다. 저장소를 다시 읽어 실제로 남은 값을 확인하고 저장 오류를 별도 상태로 보여 준다. 이미 V2인 값에는 불필요한 재저장을 하지 않는다.

지원하는 모든 출발 버전을 fixture로 검증한다

Room은 Android 앱에서 구조화된 로컬 데이터베이스와 그 변경 경로를 다루는 도구다. Room은 한 단계뿐 아니라 앱에 정의된 모든 마이그레이션을 함께 시험하도록 권장한다. 이 원칙을 Async Storage 문자열에도 적용해 다음 입력을 저장한 실제 문자열 그대로 보관한다.

fixture기대 결과원본 다시 쓰기
올바른 V1V2로 변환, 금액 12900·통화 KRWV2 저장
올바른 V2그대로 읽기없음
손상된 JSONINVALID_JSON없음
필드가 빠진 V1INVALID_V1없음
version 99UNSUPPORTED_VERSION없음

이 글의 실행 검증에서는 각 fixture를 delivery-note 키에 저장하고 loadAndMigrateDraft()를 호출한다. 성공 사례는 반환 객체와 저장소의 최종 문자열을 함께 확인한다. 실패 사례는 오류 code를 확인한 뒤 getItem으로 원본 문자열이 그대로인지 다시 읽는다. 저장 입구를 감싼 writeCount는 현재 형식으로 다시 쓴 횟수다.

마지막으로 앞의 실패 재현용 App.tsx를 다음 코드로 바꾼다. 각 버튼은 저장소에 표시된 입력을 먼저 심은 뒤 draft-storage.ts의 같은 읽기·마이그레이션 함수를 실행한다.

import { useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import { createAsyncStorage } from '@react-native-async-storage/async-storage'
import { DraftMigrationError } from './draft-record'
import { loadAndMigrateDraft } from './draft-storage'
 
const draftStorage = createAsyncStorage('delivery-draft')
const DRAFT_KEY = 'delivery-note'
 
type FixtureName =
  | 'V1'
  | 'V2'
  | 'INVALID_JSON'
  | 'INVALID_V1'
  | 'FUTURE_V99'
 
const fixtures: Record<FixtureName, string> = {
  V1: JSON.stringify({
    version: 1,
    note: '현관 앞에 놓아주세요',
    total: 12900,
  }),
  V2: JSON.stringify({
    version: 2,
    deliveryNote: '현관 앞에 놓아주세요',
    total: { amount: 12900, currency: 'KRW' },
  }),
  INVALID_JSON: '{"version": 1',
  INVALID_V1: JSON.stringify({ version: 1, note: '현관 앞에 놓아주세요' }),
  FUTURE_V99: JSON.stringify({ version: 99, payload: 'future' }),
}
 
const fixtureNames: FixtureName[] = [
  'V1',
  'V2',
  'INVALID_JSON',
  'INVALID_V1',
  'FUTURE_V99',
]
 
export default function App() {
  const [running, setRunning] = useState(false)
  const [result, setResult] = useState('fixture를 선택하세요')
 
  async function runFixture(name: FixtureName) {
    setRunning(true)
    const original = fixtures[name]
    let writeCount = 0
    const observedStorage = {
      getItem: (key: string) => draftStorage.getItem(key),
      async setItem(key: string, value: string) {
        writeCount += 1
        await draftStorage.setItem(key, value)
      },
    }
 
    try {
      await draftStorage.setItem(DRAFT_KEY, original)
 
      const draft = await loadAndMigrateDraft(observedStorage)
      const stored = await draftStorage.getItem(DRAFT_KEY)
 
      setResult(
        `${name}: version=${draft?.version ?? 'none'}, ` +
          `amount=${draft?.total.amount ?? 'none'}, ` +
          `writeCount=${writeCount}, stored=${stored}`,
      )
    } catch (error) {
      const stored = await draftStorage.getItem(DRAFT_KEY)
      const code =
        error instanceof DraftMigrationError ? error.code : 'STORAGE_ERROR'
 
      setResult(
        `${name}: error=${code}, writeCount=${writeCount}, ` +
          `originalPreserved=${stored === original}`,
      )
    } finally {
      setRunning(false)
    }
  }
 
  return (
    <View style={{ padding: 24, gap: 12 }}>
      {fixtureNames.map(name => (
        <Pressable
          disabled={running}
          key={name}
          onPress={() => void runFixture(name)}
        >
          <Text>{name} 실행</Text>
        </Pressable>
      ))}
      <Text selectable>{result}</Text>
    </View>
  )
}
V1            → DraftV2(12900, KRW) → writeCount=1, 저장소 version 2
V2            → 같은 DraftV2        → writeCount=0, 원본 유지
손상 JSON      → INVALID_JSON         → writeCount=0, 원본 유지
필드 누락 V1   → INVALID_V1           → writeCount=0, 원본 유지
version 99     → UNSUPPORTED_VERSION  → writeCount=0, 원본 유지

실패한 원본을 자동으로 지우지 않는다

지원하지 않는 version 99는 단순 손상이라고 단정할 수 없다. 더 새로운 앱이 쓴 형식을 이전 앱이 읽는 상황, 즉 앱 롤백일 수도 있다. 원본을 지우고 빈 초안을 보여 주면 호환성 실패가 사용자 데이터 손실로 바뀐다.

화면에는 “이 버전에서 초안을 열 수 없습니다”처럼 행동 가능한 상태를 보여 주고, 관측 신호에는 저장 형식 버전과 오류 code를 남긴다. 배송 메모 원문 같은 사용자 내용은 로그에 넣지 않는다. 새 코드가 해당 버전을 지원하거나 사용자가 명시적으로 삭제하기 전까지 원본은 보존한다.

같은 상황을 다시 진단한다

문제       업데이트 뒤 V1 배송 초안을 V2 화면에서 읽을 때 실행 오류
잘못된 판단 JSON.parse(raw) as DraftV2면 실제 데이터도 V2가 됨
관찰       저장 원본은 version 1, note 문자열, total 숫자
원인       출발 형식 검사와 V1→V2 변환 경로가 없음
행동       version 분기에서 V1 검사→V2 변환→V2 재검사 후 저장
검증       V1·V2·손상·필드 누락·미지원 버전 fixture와 원본 보존 확인

풀 리퀘스트에서는 다음을 확인한다.

  • 앱 버전과 별개인 저장 형식 version이 데이터 안에 있는가?
  • JSON 파싱 결과를 현재 타입으로 바로 단정하지 않고 unknown에서 검사하는가?
  • 지원하는 각 출발 버전에서 다음 버전으로 가는 변환 함수가 있는가?
  • 변환 전 출발 구조와 변환 후 현재 구조를 모두 검사하는가?
  • 현재 구조가 확정되기 전에 원본 키를 덮어쓰거나 삭제하지 않는가?
  • 현재 버전, 가장 오래된 지원 버전과 실패 fixture를 모두 실행하는가?
  • 미지원 버전의 사용자 원문을 로그에 남기지 않고 오류 code만 관찰하는가?
  • 데이터 형식 변경 뒤 이전 앱으로 되돌릴 수 있는지도 출시 전에 판단하는가?

기억할 문장은 짧다.

저장 데이터는 버전으로 식별하고, 검사한 한 단계씩 현재 형식으로 옮긴다.

다음 11편에서는 저장 형식뿐 아니라 React Native와 네이티브 SDK 버전을 함께 올릴 때 호환성 위험을 어떤 순서로 확인할지 다룬다.

Active recall

기억에서 꺼내 보기

답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.

  1. 01`JSON.parse(raw) as DraftV2`라고 쓰면 이전 V1 데이터도 V2 구조로 바뀔까?

    정답

    아니다. JSON 문자열을 JavaScript 값으로 만들 뿐이며 `as DraftV2`는 런타임 검사나 변환을 하지 않는다.

    왜 그런가

    저장 데이터의 실제 필드와 코드가 믿는 타입이 다르면 컴파일은 통과해도 실행 결과가 깨진다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  2. 02저장 형식이 V1에서 V2로 바뀌면 새 앱은 어떤 순서로 읽어야 할까?

    정답

    raw JSON을 파싱하고 V1 구조를 검사한 뒤 V1→V2 함수로 변환하며, 완성된 V2를 다시 검사한 다음에만 현재 형식으로 저장한다.

    왜 그런가

    출발 형식과 도착 형식을 각각 확인해야 손상 값과 지원하지 않는 미래 버전을 정상 마이그레이션으로 오인하지 않는다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  3. 03앱이 모르는 version 99를 읽으면 사용자 데이터를 지우고 빈 초안으로 시작해도 될까?

    정답

    기본 복구로 삭제하면 안 된다. 원본을 보존하고 지원하지 않는 형식으로 분류해 사용자·관측 신호를 남긴다.

    왜 그런가

    미래 버전이나 롤백 뒤 데이터일 수 있어 자동 삭제는 복구 가능한 자료를 영구 손실로 바꿀 수 있다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  4. 04V1→V2 함수 하나가 통과하면 저장 데이터 마이그레이션 검증은 끝날까?

    정답

    아니다. 지원하는 각 과거 버전, 현재 버전, 손상 JSON과 알 수 없는 미래 버전을 실제 fixture로 읽어 결과와 원본 보존을 확인한다.

    왜 그런가

    새 설치의 V2만 시험하면 업데이트 사용자에게만 있는 V1 경로를 놓친다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

출처와 검증 범위

아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.

  1. Using Async StorageReact Native Async Storage · 공식 문서 · 확인 2026-08-09
  2. Async Storage ChangelogReact Native Async Storage · 공식 문서 · 확인 2026-08-09
  3. ECMAScript 2026 — JSON.parseEcma International · 표준 명세 · 확인 2026-08-09
  4. TypeScript Handbook — Type AssertionsTypeScript · 공식 문서 · 확인 2026-08-09
  5. Migrate your Room databaseAndroid Developers · 공식 문서 · 확인 2026-08-09
  6. Migrating your data model automaticallyApple Developer · 공식 문서 · 확인 2026-08-09
이 문서의 마지막까지 읽었습니다.