안녕하세요?
전역 이벤트를 안전하게 관리하고 디버깅도 쉽게 만들기 위해 고민했던 것을 공유하고자 글 작성합니다.
1. 왜 PassthroughSubject 만으로는 부족할까?
PassthroughSubject 는 Combine 에서 이벤트를 외부로 전달할 수 있는 가장 기본적인 도구로 볼 수 있습니다.
하지만 직접 노출해서 사용하면 다음과 같은 문제가 생길 수 있습니다.
1. 외부에서 .send() 를 호출 → 의도치 않은 이벤트 발행
2. 로깅이나 디버깅의 어려움
3. 이벤트 흐름을 추적하거나 테스트하기 힘들어짐
4. 중복 구독 / 다중 전송 방지 등의 로직이 흩어짐
let subject = PassthroughSubject<String, Never>()
subject.send("🚨 외부에서 제한없이 호출 가능")
위와 같은 문제를 해결하기 위해 PassthroughSubject 를 감싸는 wrapper 구조를 생각하게 되었습니다.
이를 통해 얻을 수 있는 이점은 이렇게 생각해 볼 수 있습니다.
1. 내부에서만 발행이 가능
2. 외부에는 구독(Publisher)만 노출
3. 로깅 / 디버깅의 자동화
4. 코드 일관성 유지
이러한 구조를 사용할 수 있는 사례를 통해 좀 더 알아가보도록 합시다!
( subject 를 private 으로 만들면 끝나는거 아니야? 라고 생각할 수 있지만 이 부분은 다음 글에서 설명해볼게요! )
제가 작성한 EventPublisher 는 래퍼 클래스 역할을 하게 됩니다.
import Combine
/// Combine 기반의 이벤트 전파용 래퍼 클래스
/// PassthroughSubject를 감싸고 외부에는 구독만 허용하며 전송 시 로깅도 함께 수행합니다.
final class EventPublisher<Output> {
/// 내부 이벤트 전송을 담당하는 PassthroughSubject.
/// Output 타입 이벤트를 발행하고, 오류는 발생시키지 않음 (`Never`).
private let subject = PassthroughSubject<Output, Never>()
/// 이벤트가 전송될 때 호출되는 로깅 클로저.
/// 기본값은 아무 일도 하지 않으며 필요 시 로그를 넣을 수 있습니다.
private let logger: (Output) -> Void
init(logger: @escaping (Output) -> Void = { _ in }) {
self.logger = logger
}
/// 내부 subject 에 값을 보내고 동시에 로깅도 함께 수행됩니다.
func send(_ value: Output) {
logger(value)
subject.send(value)
}
/// 외부에 구독 전용 Publisher 를 제공하는 메서드
/// erasureToAnyPublisher() 로 내부 구현을 숨기고 구독만 가능하게 재현합니다.
func publisher() -> AnyPublisher<Output, Never> {
subject.eraseToAnyPublisher()
}
}
EventPublisher 를 사용하면 PassthroughSubject 를 외부에 직접 노출하지 않고
내부에서는 이벤트를 발행(send) 하고 외부에서는 구독(Subscriber) 만 하는
구조를 만들 수 있습니다.
이렇게 역할을 명확히 분리하면 이벤트 흐름의 통제와 관리가 훨씬 효율적이게 됩니다.
이제 이를 활용할 수 있는 예제를 한번 알아봅시다.
인스타그램 혹은 커뮤니티 앱을 생각해보면 피드 리스트를 보다가 상세 리스트로 들어가게 되는 형태가 많은데요.
"상세 리스트에서 좋아요 클릭 → 피드 리스트 돌아오기" 과정에서
좋아요가 클릭되어 있게 하고 싶다면
위에서 사용했던 EventPublisher 를 사용해볼 수 있습니다.
지금부터 작성되는 예제 코드는 크게 4가지로 구성 될 예정이니 참고해주세요.
- 내부 역할을 담당하는 상세 리스트
- 외부 역할을 담당하는 피드 리스트
- 좋아요 / 좋아요 취소를 위한 enum 타입
- 여러 이벤트를 담을 수 있는 GlobalEventBridge
final class GlobalEventBridge {
static let shared = GlobalEventBridge()
private init() {}
private let likeEvent = EventPublisher<LikeEvent> {
print("👍 Like 이벤트 발생: \($0)")
}
var likePublisher: AnyPublisher<LikeEvent, Never> {
likeEvent.publisher()
}
func sendLikeEvent(_ event: LikeEvent) {
likeEvent.send(event)
}
}
기능이 확장됨에 따라 EventPublisher 가 추가될 수 있는점을 고려하여 GlobalEventBridge 클래스를 만들어줍니다.
이렇게 하면 내부에선 발행만 하고 외부에선 구독만 하는 형태가 완성되었고
enum LikeEvent {
case liked(postID: String)
case unliked(postID: String)
}
LikeEvent 라는 enum 타입을 만들어 좋아요 / 좋아요 취소 기능을 사용할 수 있도록 만들어줍니다.
다음으로는 피드 리스트를 만들어볼건데요.
struct FeedListView: View {
@State private var likedPostIDs: Set<String> = []
@State private var cancellables = Set<AnyCancellable>()
let allPostIDs = ["post1", "post2", "post3"]
var body: some View {
NavigationView {
List(allPostIDs, id: \.self) { id in
NavigationLink(destination: DetailView(postID: id)) {
HStack {
Text("📝 \(id)")
Spacer()
if likedPostIDs.contains(id) {
Text("❤️")
}
}
}
}
.navigationTitle("피드 리스트")
}
.onAppear {
GlobalEventBridge.shared.likePublisher
.receive(on: RunLoop.main)
.sink { event in
switch event {
case let .liked(postID):
likedPostIDs.insert(postID)
case let .unliked(postID):
likedPostIDs.remove(postID)
}
}
.store(in: &cancellables)
}
}
}
좋아요 클릭이 된 게시글을 담을 수 있는 Set 타입의 프로퍼티를 준비해주고
테스트를 위한 mock 데이터를 준비해줍니다.
외부 역할을 담당하는 피드 리스트엔 sink 를 사용하여 구독을 해줍니다.
struct DetailView: View {
let postID: String
var body: some View {
VStack(spacing: 20) {
Button("❤️ 좋아요 누르기") {
GlobalEventBridge.shared.sendLikeEvent(.liked(postID: postID))
}
Button("💔 좋아요 취소") {
GlobalEventBridge.shared.sendLikeEvent(.unliked(postID: postID))
}
}
.padding()
}
}
마지막으로 내부 역할을 담당하는 상세리스트는 send 의 역할을 하게 됩니다.
지금까지 작성한 코드를 빌드하면 좋아요 / 좋아요 취소 기능이 동작하는것을 확인할 수 있습니다.
'Combine' 카테고리의 다른 글
[Combine] ViewModel 에서 Input, Output 구현하기 (0) | 2024.05.06 |
---|---|
[Combine] ViewModel 의 상태변화를 View 에게 알려주고 싶다면 어떻게 해야할까? (0) | 2023.10.25 |
[Combine] SwiftUI 에서는 Publisher 를 어떻게 구독할까? (0) | 2023.10.08 |
[Combine] Subject 알아보기 (PassthroughSubject / CurrentValueSubject) (0) | 2023.10.05 |
[Combine] Publisher 와 Subscriber 는 어떻게 연결할 수 있을까? (0) | 2023.09.28 |