뭘 하려고 했냐면
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 클라이언트의 메서드를 직접 호출하는 구조로 바꿨더니 크래시가 완전히 사라졌어요.

그러면 @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 마이그레이션 하시는 분들 참고가 되면 좋겠어요.