뭘 하려고 했냐면

iOS 음악 스트리밍 앱을 만들고 있었는데요. NavigationDestination으로 이동하는 상세 화면마다 ViewModel을 두고, API 호출 로직을 클로저로 주입하는 구조를 쓰고 있었어요.

대충 이런 식이었거든요:

@Observable
final class PlaylistDetailVM {
    var songs: [Song] = []
    var isLoading = false

    // API 클라이언트의 메서드를 클로저로 주입
    private let fetchSongs: (String) async throws -> [Song]
    private let addToPlaylist: (String, [String]) async throws -> Void

    init(
        fetchSongs: @escaping (String) async throws -> [Song],
        addToPlaylist: @escaping (String, [String]) async throws -> Void
    ) {
        self.fetchSongs = fetchSongs
        self.addToPlaylist = addToPlaylist
    }
}

NavigationDestination에서 @State로 VM을 생성하고요:

.navigationDestination(for: Playlist.self) { playlist in
    let vm = PlaylistDetailVM(
        fetchSongs: { id in try await client.getPlaylist(id: id).entry },
        addToPlaylist: { id, songIds in try await client.addToPlaylist(id: id, songIds: songIds) }
    )
    PlaylistDetailView(vm: vm)
}

터졌습니다

앱이 바로 크래시 나더라고요. EXC_BAD_ACCESS — 메모리 손상이었어요.

처음에는 “뭔가 잘못 쓴 거겠지” 하고 디버깅을 시작했는데요.

시도 1: @ObservationIgnored

클로저가 Observation 매크로의 추적 대상이 되면서 문제가 생기는 거 아닌가 싶어서, 클로저 프로퍼티에 @ObservationIgnored를 붙여봤어요.

@Observable
final class PlaylistDetailVM {
    var songs: [Song] = []
    @ObservationIgnored private let fetchSongs: (String) async throws -> [Song]
    // ...
}

결과: 여전히 크래시. @ObservationIgnored는 변경 추적만 제외할 뿐, 메모리 레이아웃 문제는 해결하지 못했어요.

시도 2: 클로저를 별도 클래스로 분리

“그러면 클로저를 아예 @Observable 밖으로 빼면 되지 않을까?” 해서 클로저만 담는 별도 클래스를 만들었어요.

final class APIActions {
    let fetchSongs: (String) async throws -> [Song]
    let addToPlaylist: (String, [String]) async throws -> Void
    // ...
}

@Observable
final class PlaylistDetailVM {
    var songs: [Song] = []
    @ObservationIgnored private let actions: APIActions
    // ...
}

결과: 여전히 크래시. @Observable 매크로가 클래스의 메모리 레이아웃을 변경하는 과정에서 참조 타입 프로퍼티가 손상되는 것 같았어요.

해결: ViewModel 패턴을 버렸습니다

결국 “NavigationDestination 내부에서 @Observable VM을 @State로 생성하는 것 자체"가 문제라는 결론에 도달했어요.

해결책은 간단했는데요 — ViewModel을 안 쓰는 거였어요.

struct PlaylistDetailView: View {
    let client: SubsonicClient       // API 클라이언트를 직접 전달
    let playlistId: String

    @State private var songs: [Song] = []   // 상태는 @State 값 타입으로
    @State private var isLoading = false
    @State private var errorMessage: String?

    var body: some View {
        List {
            // ...
        }
        .task {
            // client 메서드를 직접 호출
            isLoading = true
            do {
                let detail = try await client.getPlaylist(id: playlistId)
                songs = detail.entry
            } catch {
                errorMessage = error.localizedDescription
            }
            isLoading = false
        }
    }
}

View에서 @State 값 타입으로 상태를 관리하고, API 클라이언트의 메서드를 직접 호출하는 구조로 바꿨더니 크래시가 완전히 사라졌어요.

풀 플레이어 blur 배경

그러면 @Observable은 언제 쓰냐면

전부 버린 건 아니에요. 앱 전역에서 공유하는 상태에는 여전히 @Observable을 쓰고 있어요.

@Observable
final class PlayerVM {
    var currentSong: Song?
    var queue: [Song] = []
    var isPlaying = false
    // 클로저 프로퍼티 없음!
}

핵심은 @Observable 클래스에 클로저를 저장하지 않는 것이에요. 단순한 값 타입 프로퍼티(String, Int, [Song] 등)만 두면 문제없이 작동하거든요.

배운 점

@Observable + 클로저 프로퍼티 = 메모리 손상. NavigationDestination 내부의 View는 VM 없이 @State 값 타입 + client 직접 호출이 안전하다.

ObservableObject 시절에는 클로저 주입이 흔한 DI 패턴이었는데요, @Observable로 마이그레이션할 때 그대로 옮기면 이런 함정이 있었어요. iOS 17 마이그레이션 하시는 분들 참고가 되면 좋겠어요.