오늘은 JaengYeo 프로젝트의 API 통신 매니저 레이어를 본격적으로 구현했다. 어제까지 Protocol 정의와 DTO 구조체 생성을 마무리해뒀기 때문에, 오늘은 실제로 Supabase SDK를 호출하는 구현체들을 작성하는 데 집중했다. 코드를 작성하면서 단순히 동작하는 코드를 넘어서 유지보수와 가독성까지 고려하는 방향으로 설계를 다듬어 나갔다.
Supabase SDK의 SupabaseClient 인스턴스를 생성하는 팩토리 함수다. React에서 axios.js를 만들어 baseURL과 설정을 잡아두고 export하는 것과 같은 개념이다. Config.xcconfig로 환경변수를 분리하고 Info.plist를 통해 읽어오는 방식으로 구성했다. 강제 언래핑 대신 guard let으로 안전하게 처리했다.
func makeSupabaseClient() -> SupabaseClient {
guard let urlString = Bundle.main.infoDictionary?["SUPABASE_URL"] as? String,
let url = URL(string: urlString),
let key = Bundle.main.infoDictionary?["SUPABASE_ANON_KEY"] as? String else {
fatalError("Supabase URL 또는 Key가 Info.plist에 설정되지 않았습니다.")
}
return SupabaseClient(supabaseURL: url, supabaseKey: key)
}
테스트 시에도 문제가 없다. makeSupabaseClient()는 앱 시작 시 딱 한 번 호출하는 팩토리 함수이고, ViewModel 테스트에서는 ProductManagerProtocol의 mock 구현체를 직접 주입하기 때문에 이 함수 자체가 테스트에 개입하지 않는다.
Supabase Swift SDK는 SQL을 메서드 체이닝으로 표현하는 쿼리 빌더를 제공한다. 내부적으로는 PostgREST API를 HTTP 요청으로 변환해서 실행한다.
| 메서드 | 역할 | SQL 대응 |
|---|---|---|
.from("table") | 대상 테이블 지정 | FROM table |
.select() | 전체 컬럼 조회 | SELECT * |
.insert(dto) | 행 삽입 | INSERT INTO |
.update(dto) | 행 수정 | UPDATE SET |
.eq("col", value:) | 동등 조건 | WHERE col = value |
.is("col", value: nil) | NULL 비교 | WHERE col IS NULL |
.lte("col", value:) | 이하 조건 | WHERE col <= value |
.gte("col", value:) | 이상 조건 | WHERE col >= value |
.order("col", ascending:) | 정렬 | ORDER BY col ASC/DESC |
.limit(n) | 결과 개수 제한 | LIMIT n |
.execute() | 쿼리 실행 | 실행 |
.value | Codable 타입으로 디코딩 | — |
테이블명과 컬럼명을 하드코딩 문자열로 사용하면 오타가 발생해도 컴파일 시점에 잡을 수 없다는 문제가 있다. private enum Table: String과 private enum Column: String으로 관리하면 .rawValue로 꺼내쓰면서 오타를 컴파일 에러로 감지할 수 있다.
private enum Table: String {
case items
}
private enum Column: String {
case id
case mainCategory = "main_category"
case deletedAt = "deleted_at"
// ...
}
enum을 네임스페이스로 사용하는 이유는 struct와 달리 인스턴스 생성이 불가능하기 때문이다. "이 타입은 상수만 담는 그릇"이라는 의도를 코드로 표현할 수 있다.
CategoryManager에서 기본 카테고리(시스템)와 유저 카테고리를 함께 조회할 때 .or()를 사용한다. SDK가 OR 조합을 PostgREST 필터 문법 문자열로만 받도록 설계되어 있어 어쩔 수 없이 문자열로 구성한다.
.or("\(Column.userId.rawValue).is.null,\(Column.userId.rawValue).eq.\(devUserId)")
// WHERE user_id IS NULL OR user_id = '00000000-...'
팀원으로부터 CoreData에 직접 매번 접근하는 것이 부담스럽다는 의견이 들어왔다. 예를 들어 재고화면에서 아이템을 삭제하면 메인화면에도 반영되어야 하는데, 두 ViewModel이 각각 CoreData에 접근해야 한다는 점이다.
이 문제를 해결하는 방식은 두 가지다.
| 방식 | 특징 |
|---|---|
| NSFetchedResultsController | CoreData 변경 시 구독 중인 모든 ViewModel에 자동 push, 레이어 단순 |
| ModelManager (공유 스트림) | BehaviorRelay<[Product]> 보유, CoreData 접근 1회 후 전파 |
RxSwift를 바로 도입하기로 결정했고, ModelManager가 BehaviorRelay로 공유 스트림을 관리하면 CoreData 접근을 최소화하면서 모든 화면이 동시에 업데이트되는 구조를 만들 수 있다. 팀원이 CoreData 직접 접근을 지양하는 방향이라 ModelManager 도입 쪽으로 논의가 기울었다.
Manager 레이어(ProductManager 등)는 async/await를 유지하고, ViewModel에서 RxSwift로 감싸는 방식으로 진행할 예정이다.
// ViewModel에서 감싸는 방식
Observable.create { observer in
Task {
do {
let products = try await self.productManager.fetchAll()
observer.onNext(products)
observer.onCompleted()
} catch {
observer.onError(error)
}
}
return Disposables.create()
}
SyncManager.swift — CoreData 브랜치 머지 후 구현 예정Product 도메인 모델 — CoreData의 ProductEntity 기반으로 생성 예정