유저 데이터 영속성 구현 과정 - boostcampwm2025/iOS01-Hmm GitHub Wiki

Situation (상황):

개발자 키우기 게임에서 유저 데이터(재화, 인벤토리, 게임 기록 등)를 앱 종료 후에도 유지하기 위한 데이터 영속성 전략이 필요했습니다.

프로젝트의 특성상 유저 객체가 actor로 구현되어 있었고, 내부에 지갑, 인벤토리, 기록, 스킬, 경력 등 중첩 구조가 복잡했습니다.
또한 외부 라이브러리 사용 없이 구현해야 했고, 단일 유저 게임이라는 점에서 관계형 쿼리가 필요 없었습니다.
이러한 상황에서 장기 유지보수와 디버깅 용이성을 고려한 영속성 전략을 선택해야 했습니다.

Task (과제):

프로젝트 특성에 맞는 데이터 영속성 전략을 선택하고,

  1. Swift Concurrency(actor)와 자연스럽게 연동되며
  2. 복잡한 중첩 구조를 효과적으로 관리할 수 있고
  3. 디버깅과 유지보수가 용이하며
  4. 외부 라이브러리 없이 구현 가능한 솔루션을 도출하는 것을 목표로 삼았습니다.

Action (행동):

해결 방안 탐색

1월 20일, 팀원들과 첫 번째 논의를 진행했습니다.
SwiftData 사용과 JSON으로 충분하며 DB가 필요 없다는 의견으로 2:2로 나뉘어 결론을 내리지 못하고 다음 주로 미뤘습니다.

1월 23일 금요일, 다음 주 월요일에 최종 결정하기로 하고 각자 영속성 옵션에 대한 자료를 조사해 오기로 했습니다.
이를 통해 4가지 주요 선택지를 도출할 수 있었습니다.

선택지 1: SwiftData

Apple의 최신 데이터 프레임워크모던한 Swift 문법을 지원한다는 장점이 있었습니다.
하지만 iOS 17 이상만 지원하여 호환성 제약이 있었고, actor와의 궁합이 명확하지 않았습니다.
또한 단일 유저 게임에 과도한 복잡도를 가져왔고, 학습 곡선이 존재했습니다.
프로젝트 규모 대비 오버엔지니어링이라는 결론을 내렸습니다.

선택지 2: CoreData

Apple 공식 프레임워크로 강력한 쿼리 기능자동 마이그레이션을 지원한다는 장점이 있었습니다.
하지만 actor와 궁합이 좋지 않았고(@MainActor 요구), 단일 유저 게임에 과도한 복잡도를 가져왔습니다.
또한 보일러플레이트 코드가 많고 스키마 변경 및 마이그레이션 관리 부담이 있었습니다.
프로젝트 규모 대비 오버엔지니어링이라는 결론을 내렸습니다.

선택지 3: UserDefaults

구현이 매우 간단하고 소량 데이터에 적합하다는 장점이 있었습니다.
하지만 복잡한 객체 구조 저장에 부적합했고, 대용량 데이터 처리 시 성능 저하가 발생했습니다.
특히 깊은 중첩 구조 관리가 어렵고 디버깅이 불편(plist/바이너리)하다는 단점이 있어 게임 데이터 구조에 부적합하다고 판단했습니다.

선택지 4: JSON + Codable

Swift 네이티브(Codable)를 사용하며, Foundation만으로 구현 가능하다는 장점이 있었습니다.
디버깅이 용이하고(JSON 파일 직접 확인 가능), 구조 변경에 유연했으며, 타입 안정성을 확보할 수 있고 백업/복원이 간편했습니다.
단점으로는 전체 로드 방식이었지만 단일 유저 게임이라 문제가 없었고, 복잡한 쿼리가 불가능했지만 이 역시 필요하지 않았습니다.

최종 결정 및 이유

1월 26일 월요일, 팀원들과 최종 논의를 진행했습니다.
확장성을 고려하면 DB 혹은 JSON이 적합하다는 의견과 레포지토리 패턴을 적용하자는 의견이 나왔고,
이러한 논의 끝에 JSON + Codable로 최종 결정했습니다.

첫 번째 이유는 구현 복잡도였습니다.
JSON + Codable은 구현이 가장 간단하면서도 프로젝트 요구사항을 충족했습니다.

두 번째 이유는 유지보수와 디버깅이었습니다.
JSON 파일을 직접 확인할 수 있어 디버깅이 용이했고, Swift 타입 시스템을 활용할 수 있었습니다.

세 번째 이유는 프로젝트 특성이었습니다.
단일 유저 게임 구조에 최적이었고, 추후 필요 시 DB 기반 솔루션으로 마이그레이션이 가능했습니다.

구현 과정

1. Actor와 Codable의 호환성 문제 발견 및 해결 방향 수립

구현 중 Actor는 Codable을 직접 채택하기 어렵다는 것을 발견했습니다.
Encodableencode(to:) 메서드가 동기 함수인데, Actor의 프로퍼티에 접근하려면 비동기(await)가 필요하기 때문입니다.
동기 함수 안에서는 await를 호출할 수 없어 컴파일 에러가 발생했습니다.

Actor의 모든 프로퍼티를 상수(let)이나 nonisolated로 선언하면 가능하지만,
상태를 변경(var)하기 위해 Actor를 사용하는 것이므로 이 방식은 Actor 사용 목적에 맞지 않았습니다.

이를 해결하기 위해 DTO(Data Transfer Object) 패턴과 Repository Pattern을 조합하기로 결정했습니다.

2. Repository Pattern으로 데이터 접근 계층 추상화

Repository Pattern을 도입하여 데이터 저장/로드 로직을 추상화했습니다.
프로토콜로 인터페이스를 정의하고, FileManager를 사용한 구현체를 분리했습니다.

protocol UserRepository {
    func save(_ user: User) async throws
    func load() async throws -> User?
}

이를 통해 추후 다른 저장소 구현체로 전환이 용이한 구조를 만들 수 있었고,
테스트 시 Mock Repository를 주입할 수 있어 단위 테스트도 가능해졌습니다.

3. DTO 패턴으로 Actor-Codable 간 브릿지 구현

DTO(Data Transfer Object) 패턴을 통해 Actor와 Codable 간의 브릿지를 구현했습니다.
Actor의 프로퍼티들을 비동기로 읽어 Codable을 채택한 DTO로 변환한 뒤, DTO를 JSON으로 직렬화하는 방식입니다.

// Codable을 채택한 DTO 정의
struct UserDTO: Codable {
    let id: UUID
    let nickname: String
    let career: CareerDTO
    let wallet: WalletDTO
    // ...
}

각 하위 구조(지갑, 인벤토리 등)에 대해서도 개별 DTO를 정의하고 양방향 변환 메서드를 구현하여,
타입 안정성을 유지한 변환 로직을 만들 수 있었습니다.

저장 시에는 Actor → DTO → JSON 순서로 변환하고, 로드 시에는 JSON → DTO → Actor 순서로 역변환하여 데이터를 복원했습니다.

4. 앱 생명주기 연동

앱의 진입점에서 ScenePhase를 관찰하여 생명주기에 맞춰 자동으로 저장/로드되도록 구현했습니다.

.onAppear {
    Task {
        if let loadedUser = try? await repository.load() {
            user = loadedUser
        }
    }
}
.onChange(of: scenePhase) { _, newPhase in
    if newPhase == .background {
        Task {
            try? await repository.save(user)
        }
    }
}

이를 통해 사용자가 명시적으로 저장 버튼을 누르지 않아도 자동으로 데이터가 보존되도록 했습니다.

Result (결과):

JSON + Codable 방식과 Repository Pattern, DTO Pattern을 조합하여 Actor와 Codable의 호환성 문제를 해결하면서도 프로젝트 요구사항을 충족할 수 있었습니다.

아키텍처 측면에서 Repository Pattern을 적용해 데이터 접근 계층을 추상화함으로써, 추후 다른 저장소 구현체로의 전환이 용이한 구조를 만들었습니다.
또한 테스트 가능한 구조를 확보하여 Mock Repository를 주입한 단위 테스트 작성이 가능해졌습니다.

타입 안정성 측면에서 DTO 패턴을 통해 Actor의 격리성을 유지하면서도 데이터 직렬화를 수행할 수 있었고,
Swift 타입 시스템을 활용해 컴파일 타임에 타입 오류를 검증할 수 있었습니다.

사용자 경험 측면에서 앱 생명주기와 연동하여 사용자가 명시적으로 저장하지 않아도 데이터가 자동으로 보존되도록 했고,
이를 통해 데이터 손실 위험을 줄일 수 있었습니다.

관련 링크