← Back to list

LinkNavigator에서 이전 화면으로 이벤트 전달하기

안녕하세요! 매 주 돌아온다고 말하던 그는 5월이 되어서야 기어왔습니다…

gaeng2y · 2026-05-19 04:50 · 0 claps · 12.9 min read
#swift #swiftui #ios-development #navigation
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

LinkNavigator에서 이전 화면으로 이벤트 전달하기

Photo by Annie Spratt on Unsplash

Photo by Annie Spratt on Unsplash

안녕하세요! 매 주 돌아온다고 말하던 그는 5월이 되어서야 기어왔습니다…

오늘은 업무 중에 알게된 내용에 대해 포스팅하도록 하려고 합니다!

현재 회사 프로젝트에서 내비게이션을 LinkNavigator를 사용하고 있습니다.

iOS 앱에서 화면 전환을 구현하다 보면 단순히 “다음 화면으로 이동”하는 것만으로 끝나지 않는 경우가 많죠…

예를 들어 다음과 같은 상황이 있습니다.

A 화면 -> B 화면으로 이동
B 화면에서 특정 액션 발생
A 화면이 그 이벤트를 받아서 상태 갱신

대표적인 예시는 다음과 같습니다.

  • B 화면에서 항목을 선택하면 A 화면의 리스트를 갱신한다.
  • B 화면에서 저장을 완료하면 A 화면에 Toast를 띄운다.
  • B 화면에서 설정을 변경하면 A 화면의 ViewModel에 반영한다.
  • B 화면에서 완료 이벤트를 발생시키고 A 화면이 후속 액션을 수행한다.

UIKit 기반이라면 delegate, closure, NotificationCenter, Combine Subject 등 여러 방식이 있겠지만 LinkNavigator를 사용하고 있다면, 이미 제공되는 eventSubscriber를 활용해서 화면 간 이벤트 전달을 처리할 수 있습니다.

eventSubscriber는 무엇인가?

LinkNavigator의 WrappingController는 SwiftUI View를 감싸는 UIHostingController 역할을 합니다.

이 컨트롤러는 matchPatheventSubscriber를 가지고요.

실제 구조는 다음과 같습니다.

public final class WrappingController<Content: View>: UIHostingController<Content>, MatchPathUsable {

  public init(
    matchPath: String,
    title: String? = .none,
    eventSubscriber: LinkNavigatorItemSubscriberProtocol? = .none,
    @ViewBuilder content: () -> Content
  ) {
    self.matchPath = matchPath
    self.eventSubscriber = eventSubscriber
    super.init(rootView: content())
    super.title = title ?? matchPath
  }

  public let matchPath: String
  public let eventSubscriber: LinkNavigatorItemSubscriberProtocol?
}

즉, 각 화면은 matchPath로 식별되고, 선택적으로 eventSubscriber를 가질 수 있습니다. eventSubscriber가 따라야 하는 프로토콜은 매우 단순하죠.

public protocol LinkNavigatorItemSubscriberProtocol {
  func receive(encodedItemString: String)
}

화면 간 전달되는 데이터는 encodedItemString 형태로 넘어옵니다.

문제 상황: B에서 A로 이벤트를 보내고 싶다

상황을 이렇게 가정해보자.

A 화면에서 B 화면으로 next
B 화면에서 저장 완료
A 화면은 저장 완료 이벤트를 받아 리스트를 reload

흐름은 다음과 같습니다.

A -> B
B에서 이벤트 발생
B -> navigator.send(...)
A의 eventSubscriber.receive(...)
AViewModel reload

핵심은 B가 직접 A를 참조하지 않는다는 점입니다. B는 단지 LinkNavigator를 통해 특정 path로 이벤트를 보내고 A는 자신의 eventSubscriber를 통해 그 이벤트를 수신하죠.

A 화면에 Subscriber 붙이기

먼저 A 화면에서 사용할 subscriber를 선언합니다.

final class AEventSubscriber: LinkNavigatorItemSubscriberProtocol {
  private let viewModel: AViewModel

  init(viewModel: AViewModel) {
    self.viewModel = viewModel
  }

  func receive(encodedItemString: String) {
    viewModel.handleEventFromB(encodedItemString)
  }
}

여기서 중요한 점은 subscriber가 View 자체를 직접 변경하지 않는다는 것입니다. SwiftUI View를 직접 조작하기보다는 ViewModel에 이벤트를 전달하고, View는 상태 변화를 관찰해서 갱신되는 구조가 더 자연스럽다.

B -> navigator.send(...)
  -> AEventSubscriber.receive(...)
  -> AViewModel.handleEventFromB(...)
  -> AView 갱신

A RouteBuilder에서 eventSubscriber 주입하기

A 화면을 만드는 RouteBuilder에서 WrappingController에 subscriber를 넘긴다.

struct ARouteBuilder: RouteBuilder {
  var matchPath: String { "a" }

  func build(
    navigator: LinkNavigatorType,
    item: String,
    dependency: DependencyType
  ) -> MatchingViewController? {
    let viewModel = AViewModel()
    let subscriber = AEventSubscriber(viewModel: viewModel)

    return WrappingController(
      matchPath: matchPath,
      eventSubscriber: subscriber
    ) {
      AView(
        navigator: navigator,
        viewModel: viewModel
      )
    }
  }
}

이제 A 화면은 "a"라는 matchPath를 가진다. 그리고 "a" path로 이벤트가 전달되면 AEventSubscriber가 받을 수 있다.

B 화면에서 A로 이벤트 보내기

이제 B 화면에서 특정 이벤트가 발생했을 때 navigator.send(...)를 호출한다.

navigator.send(
  item: .init(
    pathList: ["a"],
    items: [
      "event": "didCompleteSave",
      "id": "123"
    ]
  )
)

여기서 pathList"a"를 넣는 것이 핵심이다. LinkNavigator는 현재 navigation stack에 있는 view controller 중 matchPath"a"인 화면을 찾고, 그 화면의 eventSubscriber를 호출한다.

실제 send(item:) 구현도 이 흐름과 일치한다.

public func send(item: LinkItem) {
  activeController?.viewControllers
    .compactMap { $0 as? MatchPathUsable }
    .filter { matchPathUsable in item.pathList.contains(matchPathUsable.matchPath) }
    .compactMap { $0 }
    .forEach {
      $0.eventSubscriber?.receive(encodedItemString: item.encodedItemString)
    }
}

즉, 현재 active navigation stack 안에서 MatchPathUsable을 찾고, pathList에 포함된 matchPath를 가진 화면의 subscriber로 이벤트를 전달합니다.

전체 흐름 정리

전체 흐름은 다음과 같다.

A 화면 생성
- matchPath: "a"
- eventSubscriber: AEventSubscriber

A -> B로 next

B에서 이벤트 발생

B에서 호출:
navigator.send(
  item: .init(
    pathList: ["a"],
    items: [
      "event": "didCompleteSave"
    ]
  )
)

LinkNavigator가 현재 stack에서 matchPath == "a"인 controller 탐색

AEventSubscriber.receive(encodedItemString:)

AViewModel 액션 수행

이 구조의 장점은 B가 A를 직접 참조하지 않는다는 것이다. B는 “A에게 이벤트를 보낸다”기보다는 “특정 path를 가진 화면에게 item을 보낸다”에 가깝다.

sendrootSend의 차이

주의할 점은 send가 항상 root stack을 대상으로 동작하는 것은 아니라는 점이다.

LinkNavigator에는 여러 send 계열 메서드가 있다.

navigator.send(...)
navigator.rootSend(...)
navigator.allSend(...)
navigator.allRootSend(...)
navigator.mainSend(...)

프로토콜상으로도 각 메서드는 화면 간 item 전달을 위한 용도로 정의되어 있다. send는 현재 navigation stack의 특정 subscriber로 item을 보내는 용도이고, rootSend는 root controller 쪽으로 item을 보내는 용도다.

일반적인 A -> B push 구조라면 send로 충분하다.

A -> B
현재 active stack: [A, B]
B에서 navigator.send(pathList: ["a"])
A가 수신

하지만 B가 sheet 내부에 있는 경우라면 다르게 봐야 한다.

Root stack: [A]
Sheet stack: [B]

이 경우 send는 현재 active stack, 즉 sheet 내부 stack을 바라볼 수 있다. A가 root stack에 있다면 rootSend를 사용해야 할 수 있다.

navigator.rootSend(
  item: .init(
    pathList: ["a"],
    items: [
      "event": "didCompleteSave"
    ]
  )
)

따라서 기준은 다음처럼 잡으면 된다.

A와 B가 같은 navigation stack에 있다면 send
A가 root stack에 있고 B가 sheet/sub stack에 있다면 rootSend

ViewModel과 연결할 때의 형태

실제 앱에서는 encodedItemString을 그대로 ViewModel에 넘기기보다, 이벤트 타입으로 변환해서 처리하는 편이 낫다.

예를 들어 다음과 같이 구성할 수 있다.

enum AExternalEvent {
  case didCompleteSave(id: String)
  case didUpdateProfile
  case unknown
}

subscriber에서는 문자열을 파싱해서 ViewModel에 전달한다.

final class AEventSubscriber: LinkNavigatorItemSubscriberProtocol {
  private let viewModel: AViewModel

  init(viewModel: AViewModel) {
    self.viewModel = viewModel
  }

  func receive(encodedItemString: String) {
    let event = AExternalEventMapper.map(encodedItemString)
    viewModel.handleExternalEvent(event)
  }
}

ViewModel은 이벤트에 따라 필요한 액션을 수행한다.

final class AViewModel: ObservableObject {
  func handleExternalEvent(_ event: AExternalEvent) {
    switch event {
    case let .didCompleteSave(id):
      reload(id: id)

    case .didUpdateProfile:
      refreshProfile()

    case .unknown:
      break
    }
  }

  private func reload(id: String) {
    // reload logic
  }

  private func refreshProfile() {
    // refresh logic
  }
}

이렇게 하면 navigation event와 화면 상태 갱신 로직이 분리된다.

이 방식이 적절한 경우

eventSubscriber 방식은 다음 상황에 잘 맞는다.

  • 이전 화면에 reload 이벤트를 보내야 한다.
  • push된 다음 화면에서 이전 화면에 결과를 알려야 한다.
  • sheet 내부 화면에서 root 화면에 완료 이벤트를 보내야 한다.
  • 화면 간 직접 참조를 만들고 싶지 않다.
  • delegate/closure를 RouteBuilder 구조에 억지로 끼워 넣고 싶지 않다.

반대로 다음 목적이라면 다른 방식이 더 적절할 수 있다.

  • 화면이 표시되는 순간 자동 실행해야 한다.
  • viewDidAppear, viewWillDisappear 같은 lifecycle을 잡아야 한다.
  • 단순히 SwiftUI View의 appear/disappear 이벤트만 필요하다.

이 경우에는 SwiftUI의 .onAppear, .onDisappear 또는 custom WrappingController에서 lifecycle을 처리하는 편이 더 명확하다.

.onAppear {
  viewModel.onAppear()
}
.onDisappear {
  viewModel.onDisappear()
}

결론

LinkNavigator의 eventSubscriber는 화면 간 이벤트 전달에 사용할 수 있다.

특히 다음과 같은 흐름을 만들 수 있다.

A -> B로 이동
B에서 특정 이벤트 발생
B가 navigator.send(...) 호출
A의 eventSubscriber가 receive
AViewModel이 필요한 액션 수행

핵심은 pathList에 이벤트를 받을 화면의 matchPath를 지정하는 것이다.

navigator.send(
  item: .init(
    pathList: ["a"],
    items: [
      "event": "didCompleteSave"
    ]
  )
)

이 구조를 사용하면 B가 A를 직접 참조하지 않아도 된다. A는 자신의 subscriber를 통해 외부 이벤트를 받고, ViewModel을 통해 상태를 갱신한다.

즉, LinkNavigator에서 eventSubscriber는 단순한 부가 기능이라기보다, navigation stack 안의 화면 간 느슨한 이벤트 전달 채널로 활용할 수 있다.

참고

[embed]GitHub - forXifLess/LinkNavigator: 🌊 Easy & Powerful navigation library in SwiftUI 🌊 Easy & Powerful navigation library in SwiftUI. Contribute to forXifLess/LinkNavigator development by creating an…github.com


메타데이터
post_id
35c6f7a3c784
slug
linknavigator에서-이전-화면으로-이벤트-전달하기-35c6f7a3c784
url
https://medium.com/@gaeng2y/linknavigator%EC%97%90%EC%84%9C-%EC%9D%B4%EC%A0%84-%ED%99%94%EB%A9%B4%EC%9C%BC%EB%A1%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%A0%84%EB%8B%AC%ED%95%98%EA%B8%B0-35c6f7a3c784
canonical_url
https://medium.com/@gaeng2y/linknavigator%EC%97%90%EC%84%9C-%EC%9D%B4%EC%A0%84-%ED%99%94%EB%A9%B4%EC%9C%BC%EB%A1%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%A0%84%EB%8B%AC%ED%95%98%EA%B8%B0-35c6f7a3c784
author_url
https://medium.com/@gaeng2y
status
ok
fetched_at
2026-06-09 14:34:10