SwiftData 마이그 중에 디버깅 제일 빡셌던 게 “에러는 안 나는데 결과가 빈” 케이스였다.
| |
DB엔 분명히 scope = .inbox인 row가 100건 들어있다. 근데 predicate는 0건 반환. throw도 없고, 로그도 없음.
원인: SQL 번역 실패
ItemScopeType은 이렇게 생긴 enum이었다.
| |
SwiftData @Model은 이걸 Codable raw value로 저장함. scope = .inbox는 DB에 정수 0으로 들어감. 거기까진 정상.
문제는 #Predicate가 SQL로 컴파일되는 단계. SwiftData는 #Predicate 클로저를 분석해서 SQLite의 WHERE 절로 번역함. 근데 사용자 정의 enum 타입은 SQL 단에서 이해 못 함. SwiftData가 번역을 포기하면 — throw 대신 빈 결과 반환.
| |
표준 타입(Int, String, Bool, Date 등)이면 SQL로 직역되니까 잘 됨. enum, OptionSet, 커스텀 struct 같은 건 침묵 속에 0건을 받게 됨.
어디까지가 되고 어디서부터 안 되는가
같은 enum이라도 사용 위치에 따라 다르다.
| |
#Predicate 자체는 컴파일 통과. 런타임에 SQL로 번역할 때만 실패. 그러니까 컴파일러도, 빌드 시스템도 아무 경고를 못 줌.
OptionSet도 같은 함정에 빠짐.
| |
해결 패턴
세 가지 길이 있다.
1. 결정적 Int 컬럼으로 빼기
자주 조회하는 enum은 raw value를 별도 Int 컬럼으로 두고 그걸 predicate에 쓴다.
| |
중복 컬럼이 거슬리지만, hot path predicate에선 이 방법이 가장 빠르고 안전함.
2. fetch 전체 후 Swift filter
데이터셋이 작거나 메모리 부담이 없으면 그냥 다 가져와서 Swift에서 filter.
| |
문제는 데이터셋 큰 케이스에서 메모리 폭발이랑 throughput 저하 일어남.
3. PK에 enum 정보 인코딩
scope가 2-3개 값으로 제한적이면 PK 자체에 비트로 끼워넣는 게 가능.
| |
같은 itemID라도 scope별로 PK가 다르니 unique 제약 통과하고, predicate는 itemID/scope 조합으로 정확하게 lookup됨. 다만 PK 알고리즘이 도메인을 가지게 되어 결합도가 좀 높아짐.
왜 SwiftData가 throw하지 않는가
확실하지 않지만 가능한 이유:
- 일부 predicate가 SQL로 번역 안 되어도 in-memory evaluation으로 fallback 가능. SwiftData가 이걸 시도 → 결과 없음 → 빈 array 반환.
- Predicate API가 너무 광범위해서 “지원 안 됨"을 컴파일 타임에 잡기 어려움. 런타임에 silent fallback이 차선책.
이유야 어떻든 디버깅 입장에선 진짜 최악. 에러가 나야 할 자리에 빈 결과를 받으면 데이터 자체가 없는 줄 알게 됨.
교훈
#Predicate에는 표준 타입만 넣자. 커스텀 enum, OptionSet, 커스텀 struct는 못 넣는다고 가정하면 마음 편함.
- enum을 조건에 자주 쓴다면 raw value를 별도 컬럼으로 (or PK에 인코딩)
- 한두 번만 쓰는 거면 fetch 후 Swift filter
- “왜 결과가 없지?” 의심 시작했으면 predicate에 표준 외 타입 끼었는지부터 확인
SwiftData는 Swift로 쓰지만 SQL로 실행됨. 두 세상의 경계는 #Predicate. 그 경계에서 뭐가 번역되는지 알아두면 침묵을 미리 피할 수 있다.