포스트

HP Trivia (12)

SelectBooksView에서 VM 연결하기

실제 결제 기능을 연결하기에 앞서, 기존에 임시로 사용했던 시뮬레이션용 로직들을 정리한다.

1
2
3
4
5
6
7
8
9
10
11
LockedBookView(book: book)
    .onTapGesture {

        showTempAlert.toggle() // 제거
        
        game.bookQuestions.changeStatus(of: book.id, to: .active) // 제거
    }

.alert("You purchased a new question pack. Yay!", isPresented: $showTempAlert) { // 제거
    
}

이전에는 화면 터치 시 즉시 구매가 성공한 것처럼 보이게 하려고 상태 변경과 알림 기능을 사용했지만, 이제는 실제 프로세스를 적용해야 하므로 모두 삭제한다.

삭제한 위치에는 실제 구매 함수를 연결한다. 지운 onTapGesture Modifier 부분에 실제 결제 로직을 추가해주자.

1
2
3
4
5
6
7
8
LockedBookView(book: book)
    .onTapGesture {
        let product = store.products[book.id-4]
        
        Task {
            await store.purchase(product)
        }
    }

이때 상품 배열은 0부터 시작하지만 책 ID는 4부터 시작하므로, 정확한 순서 매칭을 위해 인덱스에서 4를 빼는 보정 작업이 필요하다.

상세 매칭 표는 다음과 같다.

책 ID (Book ID)계산식 (ID - 4)상품 배열 인덱스 (Index)
hp44 - 40
hp55 - 41
hp66 - 42
hp77 - 43

이후 탭을 했을 때 실제 구매 창이 뜨도록 비동기 처리를 해준다.

구매 결과에 따른 UI 동기화

기존 코드는 단순히 책의 상태만 보고 탭을 했을 때 활성/비활성 값만 바꾸게 되어 있었다.

1
2
3
4
5
6
7
8
9
10
11
if book.status == .active {
    ActiveBookView(book: book)
        .onTapGesture {
            game.bookQuestions.changeStatus(of: book.id, to: .inactive)
        }
} else if book.status == .inactive {
    InactiveBookViews(book: book)
        .onTapGesture {
            game.bookQuestions.changeStatus(of: book.id, to: .active)
        }
}

이제는 인앱결제와 함께 연동을 해야 하기에 위 로직도 수정을 해주도록 한다. 그전에 한 가지 생각해야 할 점이 있다. 결제 버튼을 누르자마자 즉시 상태를 활성화(.active)로 바꾸면 안 되는 이유는 무엇일까?

결제 창이 뜨더라도 사용자가 취소할 수도 있고, 잔액 부족이나 부모 승인 대기 등 실제로 구매가 완료되지 않을 변수가 있기 때문이다.

따라서 실제 구매 성공 데이터를 보장받은 뒤 UI를 업데이트하도록 다음과 같은 조건으로 UI를 구분한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 이미 활성화 상태이거나
// 2. 잠금 상태지만 구매 내역(purchased)에 포함된 경우 (방금 막 결제 성공한 시점)
if book.status == .active || (book.status == .locked && store.purchased.contains(book.image)) {
    ActiveBookView(book: book)
        .task {
            // 위 조건으로 ActiveBookView가 그려졌다면 실제 구매가 확인된 것이므로 
            // 데이터 모델의 상태도 .active로 동기화해준다.
            game.bookQuestions.changeStatus(of: book.id, to: .active)
        }
        .onTapGesture { // 활성 책(ActiveBookView) 클릭: 사용자가 이 책의 문제를 풀지 않겠다고 선택 해제하는 행위 → **.inactive**로 변경
            game.bookQuestions.changeStatus(of: book.id, to: .inactive)
        }
}

이 조건에 해당하여 활성 뷰가 그려졌다면 실제 구매가 확인된 것이므로, 그제야 데이터 모델의 상태도 활성(.active)으로 동기화해준다. 또한 이미 활성화된 책을 클릭하는 행위는 사용자가 이 책의 문제를 풀지 않겠다고 선택 해제하는 것이므로 비활성(.inactive) 상태로 변경되도록 구성한다.

앱 재실행 시 데이터 초기화 문제

현재 우리 앱은 실행될 때마다 내부적으로 책 목록을 생성하고 초기 상태를 부여한다.

1
2
3
4
5
6
7
8
9
10
// BookQuestions
private func populateBooks(with questions: [[Question]]) {
    books.append(Book(id: 1, image: "hp1", questions: questions[1], status: .active))
    books.append(Book(id: 2, image: "hp2", questions: questions[2], status: .active))
    books.append(Book(id: 3, image: "hp3", questions: questions[3], status: .inactive))
    books.append(Book(id: 4, image: "hp4", questions: questions[4], status: .locked))
    books.append(Book(id: 5, image: "hp5", questions: questions[5], status: .locked))
    books.append(Book(id: 6, image: "hp6", questions: questions[6], status: .locked))
    books.append(Book(id: 7, image: "hp7", questions: questions[7], status: .locked))
}

여기서 발생하는 결정적인 문제점은 다음과 같다.

  1. 상태 초기화: 사용자가 앱을 사용하면서 특정 책을 활성화하거나 비활성화해도, 앱을 완전히 껐다가 다시 켜면 항상 위에서 설정한 기본값(1~2권은 Active, 3권은 Inactive 등)으로 돌아가 버린다.
  2. 구매 정보 유실(UI상): 가장 심각한 점은 인앱 결제로 공들여 구매한 hp4~hp7 상품들이다. 실제 구매 데이터는 Apple 시스템에 남아있겠지만, 앱을 재실행하면 이 함수가 다시 호출되면서 UI상으로는 모두 다시 .locked 상태로 표시된다.

물론 상품을 다시 클릭하면 StoreKit이 구매 내역을 확인해 다시 열어주겠지만, 사용자 입장에서는 앱을 켤 때마다 내가 산 물건이 잠겨 있는 불쾌한 경험을 하게 된다.

결국 사용자가 설정한 상태와 구매 정보를 앱이 ‘기억’하게 만드는 작업이 필수적이다.

앱 실행 시 현재 상태 로드 하기

위의 문제를 해결하기 위해서 Game에 있던 함수 일부를 BookQuestions에 복사해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// from Game to BookQuestions
let savePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appending(path: "RecentScores")

func saveScores() {
        do {
            let data = try JSONEncoder().encode(recentScores)
            try data.write(to: savePath)
        } catch {
            print("Unable to save data: \(error)")
        }
    }
    
func loadScores() {
    do {
        let data = try Data(contentsOf: savePath)
        recentScores = try JSONDecoder().decode([Int].self, from: data)
    } catch {
        recentScores = [0, 0, 0]
    }
}

savepath, saveScores, loadScores를 복사해주었다. 이제 복사한 3개를 수정해보도록 한다.

1. savePath 수정

1
let savePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appending(path: "BookStatuses")

뒤에 path값만 BookStatuses로 변경해주었다.

2. saveStatus 수정 (saveScores 함수명을 바꿈)

1
2
3
4
5
6
7
8
func saveStatus() {
    do {
        let data = try JSONEncoder().encode(books)
        try data.write(to: savePath)
    } catch {
        print("Unable to save data: \(error)")
    }
}

Image

위와같은 에러가 발생한다. books는 Book 타입인데 이게 Encodable 프로토콜을 채택하지 않아서 생기는 문제이다. 실제로 book을 가보면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
struct Book: Identifiable {
    let id: Int
    let image: String
    let questions: [Question]
    var status: BookStatus
    
    
}

enum BookStatus {
    case active, inactive, locked
}

이전에는 Book 타입이 JsonEncoder에 사용이 되지 않았기에 해당 프로토콜을 채택할 필요가 없었으나, 이제는 값을 저장할때 json으로 저장을 하기에 관련 프로토콜 채택이 필요하다.

struct Book: Codable, Identifiable 이렇게 해줘도 아래와 같은 에러가 발생한다.

Image

빨간 박스를 보면 알겠지만 Book 안에 status가 BookStatus의 enum type 이기에 BookStatus도 바꿔줘야한다. Codable의 경우 상위 집합에서 해당 프로토콜을 채택했다면, 그 집합에 속해 있는 타입 역시 무조건 해당 프로토콜을 같이 채택해야한다.

하나의 에러는 사라졌다. 이제는 Question이다. 위와 똑같이 프로토콜을 채택해주자.

Image

Question의 경우 Decodable이 이미 있는데, Codable은 Encodable, Decodable을 포함한 프로토콜이므로 Decodable에서 Codable로 바꿔주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Book
struct Book: Codable, Identifiable {
    // 생략
}

enum BookStatus: Codable {
    case active, inactive, locked
}

// Question
struct Question: Codable {
    // 생략
}

3. loadStatus 수정 (loadScores 함수명을 바꿈)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
init() {
    loadStatus()
}

func loadStatus() {
    do {
        let data = try Data(contentsOf: savePath)
        books = try JSONDecoder().decode([Book].self, from: data)
    } catch {
        let decodedQuestions = decodeQuestions()
        // Organize Questions
        let organizedQuestions = organizeQuestions(decodedQuestions)
        // Populate Books
        populateBooks(with: organizedQuestions)
    }
}

json 디코딩은 우리가 필요한 파라미터와 프로퍼티로 바꿔주기만 하면 된다. 여기서 봐야할건 catch인데, 어떠한 문제가 생겨서 로드가 안되었을경우 catch 블럭으로 이동해서 코드가 실행되는데, 이때 초기값을 넣어준다. (init에 있던 코드블럭을 옮겨주었다.)

그리고 init에는 loadStatus를 호출하도록 코드를 바꿔준다.

현재 상태 저장하기

데이터 저장 함수를 구현한 후에는 ‘어느 시점에 저장할 것인가’를 결정해야 한다. 본 앱에서는 SelectBooksView에서 모든 설정을 마친 뒤 화면을 나가는 시점에 상태를 저장하도록 구성한다.

1
2
3
4
5
6
Button("Done") {
    game.bookQuestions.saveStatus() // add
    dismiss()
}

하지만 단순히 버튼에만 기능을 넣을 경우, 사용자가 화면을 아래로 쓸어내려 시트를 닫아버리면(Swipe to dismiss) 저장 로직이 실행되지 않는 문제가 발생한다.

이를 방지하기 위해 .interactiveDismissDisabled() 모디파이어를 사용한다. 이 설정은 사용자가 반드시 ‘Done’ 버튼을 통해서만 화면을 나갈 수 있도록 강제하여, 설정한 데이터가 누락 없이 기기에 기록되도록 보장한다. (참고: 괄호 안에 true를 명시하지 않아도 기본값이 true로 적용된다.)

Image


앱 실행하여 최종 점검

모든 로직이 연결되었다면 시뮬레이터를 통해 전체적인 흐름을 점검한다. 특히 이번 강의의 핵심인 인앱 결제와 데이터 유지를 중점적으로 확인한다.

1. 인앱 결제 확인

시뮬레이터 환경에서는 실제 결제는 발생하지 않으며, 테스트용 결제 창을 통해 프로세스를 확인한다. 환경에 따라 결제 창이 뜨기까지 수 초의 시간이 걸릴 수 있으므로 여유를 두고 기다린다.

Image

2. 데이터 유지(Persistence) 확인

구매 완료 후 앱을 완전히 종료했다가 다시 실행하여 구매 내역이 UI에 그대로 반영되어 있는지 확인한다.

Image

게임을 플레이한 후 기록된 점수 역시 RecentScores에 정상적으로 등록되며, 앱을 재실행해도 해당 스코어가 초기 화면에 잘 유지되는 것을 확인할 수 있다.

Image

이로써 인앱 결제부터 데이터 영구 저장까지 포함된 퀴즈 앱의 모든 기능이 성공적으로 완성되었다.

HP Trivia 프로젝트를 마치며: 5가지 코딩 챌린지

프로젝트는 끝났지만, 진정한 iOS 개발자로 거듭나기 위한 5가지 도전 과제가 남아있다. 강사가 제시한 과제와 실제 앱 서비스에 필수적인 기능을 포함하여 정리한다.

1. 오답 사운드 커스텀 (볼드모트의 웃음소리)

  • 내용: 기존의 단순한 오답 효과음 대신 볼드모트의 웃음소리나 사악한 웃음소리 오디오 파일로 교체한다.
  • 목표: 외부 리소스를 프로젝트에 추가하고, 상황에 맞는 오디오 재생 로직을 직접 수정해본다.

2. 플레이어 이름 입력 및 기록 확장

  • 내용: 게임 시작 전 플레이어의 이름을 입력받는 기능을 추가하고, 메인 화면의 최근 점수 옆에 이름을 함께 표시한다.
  • 목표: 텍스트 필드를 통한 사용자 입력 처리와, 단순 배열이었던 데이터 구조를 이름과 점수가 포함된 객체 구조로 확장해본다.

3. 전체 게임 통계 화면 및 정렬 기능

  • 내용: 메인 화면의 최근 점수 섹션을 탭하면 전체 기록을 볼 수 있는 상세 화면으로 이동하며, 점수순이나 최신순으로 정렬하는 기능을 넣는다.
  • 목표: NavigationStack을 활용한 화면 이동과, sort() 메서드를 이용한 데이터 정렬 로직을 실습한다.

4. GameplayView 리팩토링 (가장 높은 난이도)

  • 내용: 코드 양이 많고 복잡한 GameplayView를 여러 개의 작은 컴포넌트 뷰로 쪼개어 가독성을 높인다.
  • 목표: 복잡한 상태(State)와 바인딩(Binding)이 얽힌 화면을 유지보수하기 쉬운 구조로 설계하는 능력을 키운다.

5. 구매 복원(Restore Purchases) 기능 구현

  • 내용: 사용자가 앱을 삭제하거나 기기를 변경했을 때, 이전에 결제한 내역을 다시 불러올 수 있는 버튼과 로직을 추가한다.
  • 목표: StoreKit 2의 기능을 활용해 실제 서비스 환경에서 필수적인 ‘구매 무결성’을 보장하는 방법을 익힌다.

구매 복원 기능의 중요성

테스트 과정에서 앱을 삭제했다가 다시 설치하면 이전에 저장했던 스코어와 인앱 결제 상태가 모두 사라지는 것을 확인할 수 있다. 이것은 테스트 환경에서는 자연스러운 현상이지만, 실제 서비스라면 사용자가 이미 구매한 비소모성(Non-consumable) 상품을 앱 재설치 후에 다시 결제해야 하는 심각한 문제가 된다.

  • Apple의 처리 방식: 다행히 Apple은 비소모성 상품에 대해 이중 결제를 방지한다. 이미 구매한 사용자가 다시 구매 버튼을 누르면 “이미 구매한 항목입니다”라는 메시지와 함께 무료로 다시 다운로드하게 해준다.
  • 개발자 챌린지: 하지만 사용자 경험을 위해 대부분의 앱은 ‘구매 복원(Restore Purchases)’ 버튼을 별도로 제공한다. 강사는 시간 관계상 이 기능을 직접 구현하지는 않았지만, 실제 앱을 출시하려면 반드시 고려해야 할 요소라고 강조하며 챌린지로 남겨두었다.

결국 진정한 앱의 완성은 사용자의 소중한 구매 데이터를 어떤 상황에서도(재설치, 기기 변경 등) 안전하게 지켜주는 데 있다는 점을 시사한다.


번외: 백그라운드 상태에서의 오디오 제어

모든 기능을 구현했지만, 앱을 완전히 종료하지 않고 홈 화면(백그라운드)으로 나갔을 때 배경음악이 계속 재생되는 현상이 발생한다. 사용자 경험을 위해 앱이 화면에서 사라지면 음악도 함께 정지되도록 App Lifecycle을 활용해 해결해본다.

구글링을 하니 App LifeCycle을 활용하여 오디오를 정지시키는 방법이 있어 이를 적용했다.

참고사이트

ScenePhase를 활용한 상태 감지

SwiftUI에서 제공하는 @Environment(\.scenePhase)를 활용하면 앱이 활성(Active), 비활성(Inactive), 백그라운드(Background) 상태인지 실시간으로 감지할 수 있다. 우선 ContentView에 해당 환경 변수를 추가한다.

@Environment(\.scenePhase) var scenePhase

상태 변화에 따른 오디오 정지 및 재개

감지된 상태 값을 바탕으로 뷰 하단에 .onChange 모디파이어를 추가하여 오디오 플레이어를 제어한다.

1
2
3
4
5
6
7
8
.onChange(of: scenePhase) { _, newValue in
    if newValue == .background || newValue == .inactive {
        // 앱이 홈화면으로 나가거나 비활성화되면 음악 정지
        audioPlayer?.pause()
    } else {
        audioPlayer?.play()
    }
}
  • 비활성화/백그라운드 대응: 사용자가 홈 화면으로 나가거나 알림창을 내리는 등의 상황(inactive, background)에서는 오디오를 일시정지(pause())한다.
  • 활성화 대응: 앱이 다시 포그라운드로 돌아오면 음악을 다시 재생(play())하도록 else 구문을 추가한다. 이 처리가 누락되면 앱으로 복귀했을 때 음악이 재생되지 않으므로 반드시 넣어주어야 한다.

이제 테스트를 진행하면 앱을 끄지 않고 홈 화면으로 나가더라도 음악이 문제없이 정지되고, 다시 돌아오면 재개되는 것을 확인할 수 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.