https://explorer89.tistory.com/561
빈 상태 UI(Placeholder)와 Combine 상태 설계 .assign에서 .sink로 전환해야 하는 정확한 순간
1️⃣ 문제 상황 정리 앱을 처음 실행했을 때:감정일기 ❌주간 감정일기 ❌“이번 주 랜덤 일기 1개”를 보여주려 했지만보여줄 데이터 자체가 없음이때 요구사항은 이거다:데이터가 있으면 →
explorer89.tistory.com
Combine으로 ViewModel을 만들다 보면 대부분의 데이터 흐름은 결국 이렇게 귀결됩니다.
- Publisher가 값을 방출한다
- ViewModel의 상태(State) 가 바뀐다
- UI가 상태 변화를 반영한다
이때 가장 자주 쓰는 구독(terminal) 연산자가 .sink 와 .assign 입니다.
두 연산자의 차이는 단순히 “자동/수동”이 아니라, 상태를 어떻게 모델링하느냐(1개 vs 2개 vs 1개의 enum) 에 따라 자연스럽게 결정됩니다.
아래 2가지 방식은 실무에서도 자주 쓰이는 “정석” 패턴입니다.
0. .sink vs .assign 핵심 정리
.assign은 “값 → 프로퍼티” 바인딩
- Publisher의 Output을 특정 프로퍼티에 그대로 대입
- 로직/분기/부작용을 넣기엔 부적합
- “상태가 하나”일 때 가장 깔끔
publisher
.assign(to: &$state)
.sink는 “여러 상태를 동시에 갱신”하거나 “부작용 처리”
- receiveValue에서 원하는 만큼 여러 프로퍼티를 업데이트 가능
- completion/에러 처리 가능
- 상태가 2개 이상으로 나뉘면 .sink가 자연스럽다
publisher
.sink { [weak self] value in
self?.a = ...
self?.b = ...
}
1) map으로 상태를 “한 번에 계산”하고 .sink로 대입하기
상태가 두 개 이상일 때(예: diaries, isEmpty) 가장 깔끔한 방식은:
- map에서 상태를 계산해서 튜플로 만든다
- .sink에서 그 결과를 프로퍼티에 대입만 한다
✅ 포인트
- map은 순수 변환(계산)
- sink는 대입만
- 로직이 한 곳(map)에 모여서 읽기 쉽고 테스트도 쉬움
@MainActor
final class DiaryListViewModel {
@Published private(set) var diaries: [EmotionDiaryModel] = []
@Published private(set) var isEmpty: Bool = false
private let store: DiaryProviding
private var cancellables = Set<AnyCancellable>()
init(store: DiaryProviding) {
self.store = store
bind()
}
private func bind() {
store.diariesPublisher
.map { diaries -> ([EmotionDiaryModel], Bool) in
let isEmpty = diaries.isEmpty
return (diaries, isEmpty)
}
.sink { [weak self] diaries, isEmpty in
self?.diaries = diaries
self?.isEmpty = isEmpty
}
.store(in: &cancellables)
}
}
왜 이 방식이 좋은가?
1) 상태 계산이 한 줄로 명확해짐
isEmpty가 어디서 결정되는지 코드만 봐도 바로 알 수 있습니다.
2) “상태 불일치”를 줄임
diaries를 업데이트했는데 isEmpty를 놓치는 실수를 방지합니다.
(물론 여전히 튜플 기반이라 이론상 실수 가능성은 남아 있음)
3) .sink의 역할이 명확해짐
.sink 내부는 “로직”이 아니라 “대입”만 수행하도록 유지하면 ViewModel이 지저분해지지 않습니다.
2) 상태를 enum으로 묶고 .assign으로 끝내기 (가장 깔끔한 구조)
상태를 두 개(diaries, isEmpty)로 나누는 대신,
“UI가 궁금해하는 상태”를 하나로 정의하면 구조가 더 단단해집니다.
즉, UI 입장에서 중요한 건:
- 비어 있음 → placeholder 보여줘야 함
- 내용 있음 → 리스트 보여줘야 함
이 두 상황을 하나의 타입으로 표현하는 게 enum state 방식입니다.
enum DiaryListState: Equatable {
case empty
case content([EmotionDiaryModel])
}
ViewModel 예시 (✅ .assign 사용)
@MainActor
final class DiaryListViewModel {
@Published private(set) var state: DiaryListState = .empty
private let store: DiaryProviding
private var cancellables = Set<AnyCancellable>()
init(store: DiaryProviding) {
self.store = store
bind()
}
private func bind() {
store.diariesPublisher
.map { diaries -> DiaryListState in
diaries.isEmpty ? .empty : .content(diaries)
}
.assign(to: &$state)
}
}
이 방식이 “가장 깔끔한” 이유
1) 상태 불일치가 원천적으로 불가능
diaries와 isEmpty가 따로 존재하지 않습니다.
따라서 “diaries는 비어 있는데 isEmpty는 false” 같은 상태가 애초에 만들어지지 않습니다.
2) UI 분기가 자연스러워짐
ViewController/SwiftUI View에서는 state만 보면 됩니다.
viewModel.$state
.sink { [weak self] state in
switch state {
case .empty:
self?.showPlaceholder()
case .content(let diaries):
self?.showList(diaries)
}
}
.store(in: &cancellables)
3) .assign으로 끝나서 코드가 짧고 의도가 선명
- 상태는 한 개
- 값이 들어오면 그대로 state에 반영
- ViewModel이 훨씬 단순해집니다
3) 어떤 상황에서 무엇을 쓰면 좋을까?
✅ 튜플 + .sink 패턴이 좋은 경우
- @Published 상태가 2개 이상이고
- UI/비즈니스 요구상 분리된 상태가 실제로 필요할 때
- “상태 계산 로직은 map에 몰고, sink는 대입만” 하고 싶을 때
✅ enum + .assign 패턴이 좋은 경우 (추천)
- UI가 필요로 하는 상태를 하나의 모델로 표현할 수 있을 때
- “빈 상태/내용 상태”처럼 화면 분기가 명확할 때
- 상태 불일치를 구조적으로 막고 싶을 때
4) 결론
- 상태를 두 개로 관리한다면
✅ map에서 한 번에 계산하고 → .sink에서 대입만 하자 - 상태를 하나로 모델링할 수 있다면(특히 화면 분기)
✅ enum state로 묶고 → .assign으로 끝내는 게 가장 깔끔하다
'감정일기(가칭)' 카테고리의 다른 글
| ViewController에서 ViewModel의 데이터를 어디까지 써도 될까? (1) | 2025.12.17 |
|---|---|
| 빈 상태 UI(Placeholder)와 Combine 상태 설계 .assign에서 .sink로 전환해야 하는 정확한 순간 (0) | 2025.12.16 |
| Combine의 .assign vs .sink (0) | 2025.12.16 |
| LemonLog: isSameDay와 bind()로 날짜별 일기 리스트 만들기 (Combine + @MainActor) (0) | 2025.12.16 |
| 📘 diariesPublisher vs fetchDiaryByDay (0) | 2025.12.16 |