YunDaeHyeon
← Lab

Browser 리팩터링 일지

실험2026.01.03
  • Swift
  • Combine

1. Browser의 콜백을 AsyncStream로 개선하기

현재 Browser는 클로저 기반 콜백 패턴을 사용하고 있으며 총 아래와 같은 콜백을 사용하고 있다.

var onDeviceFound: ((NearbyDevice) -> Void)?
var onDeviceLost: ((NearbyDevice) -> Void)?
var onDeviceConnected: ((NearbyDevice) -> Void)?
var onDeviceConnectionFailed: (() -> Void)?
var onCaptureCommand: (() -> Void)?
var onStartTransferCommand = PassthroughSubject<Void, Never>()
var onSendPhoto: (() -> Void)?
var onRemoteModeCommand: (() -> Void)?
var onSelectedTimerModeCommand: (() -> Void)?
var onHeartbeatTimeout: (() -> Void)?
var onRemoteHeartbeatTimeout: (() -> Void)?

이를 AsyncStream 기반으로 변경해보자.

💡AsyncStream으로 진행하게 된 근거는 아래 문서에 정리했습니다.

Home · boostcampwm2025/iOS03-dolAwang Wiki · GitHub

1-1. 테스트 코드 작성 여부

현재 Browser는 테스트하기 매우 어려운 구조이다.

final class Browser: NSObject {
    private let browser: MCNearbyServiceBrowser
    private var mirroringSession: MCSession?
    // ...
}

Browser가 핵심적으로 의존하고 있는 MCNearbyServiceBrowser는 외부 하드웨어에 의존하며, MCSession의 경우 실제 네트워크가 필요하다.

테스트 가능하게 만들기 위해선 MCSession을 프로토콜로 분리하여 Browser에 주입 가능하게 변경해야한다.

// 1. 프로토콜 추출 필요
protocol MCSessionProtocol {
    var connectedPeers: [MCPeerID] { get }
    func send(_ data: Data, toPeers: [MCPeerID], with mode: MCSessionSendDataMode) throws
    ...
}

protocol MCNearbyServiceBrowserProtocol {
    func startBrowsingForPeers()
    func stopBrowsingForPeers()
    ...
}

// 2. Browser에 주입 가능하게 변경
final class Browser: NSObject {
    private let sessionFactory: () -> MCSessionProtocol
    private let browserFactory: () -> MCNearbyServiceBrowserProtocol
    ...
}

// 3. Mock 구현
class MockMCSession: MCSessionProtocol { ... }

다만, 이러한 작업 자체가 이미 대규모 리팩터링이기 때문에..

하나씩 점진적으로 콜백을 AsyncStream으로 개선해보며 기존 동작의 흐름이 깨지지 않는 것을 확인하며 진행한다.

1-2. 마이그레이션 범위 선정

이벤트현재변경 후
onDeviceFound클로저AsyncStream
onDeviceLost클로저AsyncStream
onDeviceConnected클로저AsyncStream
onDeviceConnectionFailed클로저AsyncStream
onCaptureCommand클로저AsyncStream
onSendPhoto클로저AsyncStream
onRemoteModeCommand클로저AsyncStream
onSelectedTimerModeCommand클로저AsyncStream
onHeartbeatTimeout클로저AsyncStream
onRemoteHeartbeatTimeout클로저AsyncStream
onStartTransferCommandCombineCombine

이때 onStartTasnferCommand는 AsyncStream 으로 개선하는 것이 아닌 Combine 을 사용하는 것으로 유지한다.

현재 프로젝트에서 onStartTransferCommand는 두 곳에서 동시에 필요하다.

// BrowsingStore.swift
browser.onStartTransferCommand
    .sink { self?.watchConnectionManager.sendCaptureComplete() }

// CameraPreviewStore.swift
browser.onStartTransferCommand
    .sink { self.cameraManager.sendAllPhotos(using: self.browser) }

즉, 1개의 이벤트를 N개 이상의 요소에서 구독하고 있다는 의미이며, 이를 AsyncStream으로 개선하기 위해선 별도의 AsyncStream을 2개 생성해야한다.

통일성 측면에서 onStartTransferCommand 또한 AsyncStream 으로 개선하는 게 좋겠지만.. 코드 중복이 예상되고 안전한 리팩터링을 위해 해당 요소는 유지하는 것으로 결정했다.

1-3. 기존 콜백을 AsyncStream 적용을 위해 Events로 분리

// BrowserEvents.swift

/// Browser에서 발생하는 모든 이벤트 타입을 정의합니다.
enum BrowserEvents {
    // 기기 발견/연결
    case deviceFound(NearbyDevice)
    case deviceLost(NearbyDevice)
    case deviceConnected(NearbyDevice)
    case deviceConnectionFailed
    
    // 카메라
    case captureCommand
    case sendPhoto
    
    // 리모트 모드
    case remoteModeCommand
    case selectedTimerModeCommand
    
    // Heartbeat
    case heartbeatTimeout
    case remoteHeartbeatTimeout
}
Browser 콜백BrowserEvents
onDeviceFounddeviceFound
onDeviceLostdeviceLost
onDeviceConnecteddeviceConnected
onDeviceConnectionFaileddeviceConnectionFailed
onCaptureCommandcaptureCommand
onSendPhotosendPhoto
onRemoteModeCommandremoteModeCommand
onSelectedTimerModeCommandselectedTimerModeCommand
onHeartbeatTimeoutheartbeatTimeout
onRemoteHeartbeatTimeoutremoteHeartbeatTimeout

우선, 기존 네이밍과 동일하게 구성했지만 on은 제거한다.

1-4. AsyncStream 정의

AsyncStream을 어떻게 정의하면 좋을지 고민이 들었다.

현재 Browser는 MCSessionDelegate이나 MCNearbyServiceBrowserDelegate 을 통해 Delegate 메서드 안에서 continuation을 호출해야하는 구조다.

그렇다는 것은.. continuation을 프로퍼티로 둬서 관리를 해야한다.

만약, 일반적인 AsyncStream 정의 방식을 사용한다면.. Delegate 메서드 안에서 사용하기 매우 복잡해진다.

// continuation을 Delegate와 같이 외부에서 쓰려면 좀 돌아가야한다.
var continuation: AsyncStream<BrowserEvent>.Continuation!
let eventStream = AsyncStream<BrowserEvent> { cont in
    continuation = cont  // 클로저 안에서 외부 변수에 할당
}

// Delegate에서 사용
func browser(_ browser: MCNearbyServiceBrowser, foundPeer: MCPeerID, ...) {
    continuation.yield(.deviceFound(device))  // 강제 언래핑이 반드시 필요해진다.
}

이를 해결하기 위해 makeStream(of:)를 사용한다.

let eventStream: AsyncStream<BrowserEvent>
private let eventContinuation: AsyncStream<BrowserEvent>.Continuation

init() {
    (eventStream, eventContinuation) = AsyncStream.makeStream(of: BrowserEvent.self)
}

// Delegate에서 사용
func browser(_ browser: MCNearbyServiceBrowser, foundPeer: MCPeerID, ...) {
    eventContinuation.yield(.deviceFound(device))  // 바로 사용
}

makeStream은 Delegate 기반 코드에 AsyncStream을 브릿징하기 위한 용도인데 현재 Browser는 Delegate 기반 코드가 절대 다수이기 때문에 makeStream() 사용이 정답이라고 생각했다.

이때 makeStream은 아래와 같이 생성자가 이뤄져있다.

static func makeStream(
    of elementType: Element.Type = Element.self,
    bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded
) -> (stream: AsyncStream, continuation: AsyncStream.Continuation)

bufferingPolicy의 기본값으로 .unbounded가 설정되어있다.

→ 자세한 내용은 아래 링크를 참고해주세요

→ AsyncStream에 대해 알아보자

이때, 따로 버퍼 프록시 정책을 bufferingOldest나 bufferingNewest로 설정하지 않은 이유는

Browser의 핵심 이벤트들 deviceFound , deviceConnected, captureCommand, heartbeatTimeout 이다.

대부분 1회성 이벤트이기도 하며

for await in 구문을 통해 이벤트가 발생되자마자 즉시 소비 되기 떄문에

  • 버퍼에 무한정 쌓일 가능성이 있을까? → ❌
  • 이벤트가 초당 수백 ~ 수천번 생길까? → ❌
  • 처리 시간이 매우 길까? → ❌
  • 결정적으로 현재 Browser를 구성하는 이벤트들은 단 하나라도 사라지면 안되는 요소 라고 판단했다.

따라서 AsyncStream을 생성할 때 버퍼 정책을 기본값인 .unbounded로 둬서 버퍼에 쌓이는 데이터의 개수 제한이 없게 설정했다.