본문 바로가기

Combine

[Combine] PassthroughSubject 안전하게 사용하는 방법

안녕하세요?

전역 이벤트를 안전하게 관리하고 디버깅도 쉽게 만들기 위해 고민했던 것을 공유하고자 글 작성합니다.

 

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 의 역할을 하게 됩니다.

 

지금까지 작성한 코드를 빌드하면 좋아요 / 좋아요 취소 기능이 동작하는것을 확인할 수 있습니다.

 

 

좋아요 기능
좋아요 취소 기능