[iOS] 401: Unauthorized, 토큰 만료 대응 코드가 여기저기에 흩어져 있었다
Event Broadcasting (NotificationCenter)으로 로그인 화면 전환 로직 중앙화하기
[iOS] 401: Unauthorized, 토큰 만료 대응 코드가 여기저기에 흩어져 있었다
Event Broadcasting (NotificationCenter)으로 로그인 화면 전환 로직 중앙화하기
안녕하세요 미니입니다😊
<토스터 TOASTER> 프로젝트에 대한 글을 계속 이어서 써보고자 하는데요.
앞선 두 글에서는
networkFlatMap이라는 커스텀 Combine Operator를 통해 네트워크 에러를 구조화된 스트림으로 처리하는 방법과,
Concrete Service 객체에 흩어져 있던 네트워크 실행 로직을 BaseAPIService로 모으면서 Service 계층의 책임을 한 곳으로 정리한 이야기를 해봤는데요.
이 두 글은 아래 링크에서 확인하실 수 있습니다:)
이번 글에서는 두 리팩토링에 이어서 자연스럽게 발생했던 문제를 이야기해보려고 합니다.
개발을 하다 보면 기본적인 기능 구현을 넘어서, 앱의 사소한 디테일 영역에서 퀄리티 차이가 갈린다고 생각합니다. 그리고 동시에 디테일한 영역이라고 생각하기 때문에, 학생 수준에서는 자연스럽게 개발에서 후순위로 밀리거나 지나쳐버리는 지점이 있다고 생각하는데요.
그중 하나가 바로 네트워크 에러 처리였습니다.
저 역시도 처음 앱을 만들 때는 “일단 성공 케이스부터 잘 돌아가게 만들자”라는 마음으로 시작하게 되고, 에러는 보통 “나중에 정리해야지” 하고 미뤄두게 되는데요.
막상 기능이 늘어나고 화면이 많아질수록 나중에 미뤄둔 에러 처리들이 앱 구조 전반에 생각보다 큰 영향을 주고 있다는 걸 뒤늦게 체감하게 됩니다. 제 토스터 앱도 마찬가지였구요.

가장 먼저 이를 체감하게 되는 순간은, 앱스토어에 달린 리뷰를 통해 접하게 되죠.
이번 글에서는 기본적인 앱의 기능과는 별도로, 앱의 퀄리티를 좌우하는 사소한 부분. 토큰 인증 (자동 로그인)과 관련된 에러를 어떻게 효과적으로 처리할 수 있을지에 대한 저의 고민과 해결과정을 소개드리고자 합니다!
🔍 문제를 명확히 정의해보자
“로그인 화면으로 돌아가야 한다"는 신호가 너무 멀리, 그리고 너무 이곳저곳에서 전파되고 있었다.
토스터 앱은 소셜 로그인을 기반으로 하고 있는 서비스이기 때문에 Access Token과 Refresh Token을 기준으로 인증 상태를 유지하고 있습니다.
그렇기에 네트워크 통신 중 401 Unauthorized 에러를 뱉는 경우, (= 사용자의 토큰이 만료되었거나 / 유효하지 않은 토큰을 저장하고 있거나 / 잘못된 토큰 정보로 유저를 인증할 수 없는 상태인 경우)
토스터 앱에서는 사용자를 다시 로그인 화면으로 되돌리는 흐름이 필요했죠. (= 정확히 말하면, KeyChain에 저장된 토큰을 삭제하고 / 현재 플로우를 종료한 뒤 / 로그인 화면으로 사용자를 전환시키는 흐름)

ViewModel: 에러를 감지하고, 로그인 화면으로 가야 함을 방출하는 부분
Input-transform-Output 구조로 설계된 현재 ViewModel 구조에서는 공통적으로 아래와 같은 Output 이벤트를 방출하도록 깔끔하게 구현되어 있었습니다.
struct Output {
let navigateToLogin = PassthroughSubject<Void, Never>()
...
}
해당 Output 이벤트는 커스텀 networkFlatMap Operator의 onError 블록에서 에러가 발생하면 navigateToLogin을 보내는 (send) 구조입니다.
input.requestMainInfo
.networkFlatMap(self, { context, _ in
context.fetchMainPageData()
}, onError: { _ in
output.navigateToLogin.send()
})
즉, ViewModel의 관점에서는 “이 요청시에 인증이 만료된 상태다 → 로그인 화면으로 이동해야 한다” 라는 의미의 신호만 방출하고 있었습니다.
ViewController: Output을 받아 Coordinator에게 전달하는 부분
ViewController에서는 ViewModel이 방출하는 navigateToLogin Output을 구독한 뒤, Coordinator에게 다시 이를 전달하는 중간 전달자 역할을 하고 있었습니다.
var onRootDeleteToken: (() -> Void)?
output.navigateToLogin
.sink { [weak self] _ in
self?.onRootDeleteToken?()
}
.store(in: cancelBag)
화면 전환을 ViewController가 아닌 Coordinator에서 담당하도록 분리한 프로젝트 구조 때문에서 불가피한 선택을 하게 되었죠.
Coordinator에 대해 설명한 예전 글도 있으니, 화면 전환 로직 분리에 대한 내용은 아래 링크를 참조해서 읽어보시면 좋을 듯 합니다:)
Coordinator: 같은 로직을 Concrete Coordinate, VC마다 계속 추가해줘야 했다
문제의 핵심은 Coordinator입니다.
각 Coordinator 파일에는 아래와 같은 코드가 반복해서 추가되어 있었는데요.
이 로직은 화면마다 구분되는 것도 아니고, Coordinator가 포함하고 있는 vc (ViewController 객체)가 생성되는 부분마다 동일한 역할을 명시할 뿐이었습니다.
- KeyChain에서 만료된 토큰 값을 제거하는 코드 (달라질 내용 x)
- 현재 루트 플로우를 종료하는 코드 (달라질 내용 x)
vc.onRootDeleteToken = { [weak self] in
_ = KeyChainService.deleteTokens(
accessKey: Config.accessTokenKey,
refreshKey: Config.refreshTokenKey
)
self?.onFinish?()
}

이런 Coordinator 구조에, Coordinator 각각에 존재하는 VC마다 모두 이 코드를 삽입했어야 했습니다.
결국은 어디에서도 항상 “Root에 해당하는 Login 화면으로 이동"하라는 의미를 주기만 하면 되었는데요.
그럼에도 불구하고, 현재 구조에서는 위 책임을 개별 Coordinator에서 개별적으로 떠안고 있었습니다.
현재 코드는 하나의 이벤트를 처리하기 위해 너무 많은 계층이 관여하고 있었을 뿐만 아니라 + 정책 변경에 취약한 구조라는 큰 문제가 있었습니다.
예를 들어 아래와 같은 정책으로 수정해야한다고 생각해볼까요?
즉시 로그아웃 하지 않고, 토큰 삭제 전에 Alert를 띄워 로그인 화면 전환을 물어봐주세요
이 경우, 해당하는 모든 Coordinator 파일을 열고, 해당하는 ViewController의 동일한 코드를 수정해야할 것입니다.
정말 비효율적인 상황이죠.
하나의 정책에 여러 곳에 흩어진 수정 로직. 과연 어떻게 해야 효율적인 코드로 만들 수 있었을까요?
🧐 어떤 선택지가 있을까
내가 NotificationCenter 기반의 Event Broadcasting 방식을 선택한 이유
가장 먼저 떠올랐던 방식은 델리게이트 패턴 (Delegate Pattern)을 활용하는 코드였습니다.
- 예를 들어 ExpirationTokenHandling과 같은 프로토콜을 정의하고,
- 상위 Coordinator (이 구조에서는 모든 Coordinator의 부모 역할을 하는 BaseCoordinator가 되겠네요)에서 이 프로토콜을 채택한 다음,
- 하위 계층에 속해있는 Coordinator에서 필요한 경우마다 해당 메서드를 호출하는 구조입니다.
하지만, Delegate를 전달하기 위해 또 다시 중간 계층을 거쳐야 (릴레이 구조의 복잡성)한다는 점과 “누가 누구를 알고 있고"가 계속 연결되는 의존성 문제가 복잡해질 거라고 생각했습니다.
근본적으로 이번 문제는 “전역에 가까운 넓은 범위를 커버하기 위해” 필요한 정책이었으니까요.
그래서 별도의 AuthManager와 같은 중앙 객체를 두는 방식도 자연스럽게 고민하게 되었습니다.
AuthManager.shared.handleTokenExpired()
토큰 만료부터 삭제, 플로우 종료, 루트 전환까지 모두 한 번에 중앙에서 처리할 수 있기에 목표에 적합한 방식이라고 생각했습니다.
하지만, 화면 전환 (네비게이션 로직)에 대한 책임이 Auth 계층에 섞인다는 점에서 머리를 감싸쥐었죠😡
토스터 프로젝트 구조에서 저는 아래와 같은 정책을 유지하고 싶었습니다.
- 화면 전환은 Coordinator의 책임
- Auth 계층은 인증 상태의 변화 원인이나. 트리거 정도까지만 제공
만약 AuthManager가 화면 전환까지 책임지기 시작하면, 화면 로직을 담당하기로 한 지난 리팩토링, Coordinator 패턴을 선택한 이유 자체가 흐려진다고 느꼈습니다.
이번 문제에 대한 해결은 중앙화로 진행하지만 / 화면 전환에 대한 역할이 혼재되는 구조로 문제가 이어질 수 있다는 우려가 있었어요.
별도의 구조를 추가하지 않고,
인증 만료 사실을 전달 — 루트 코디네이터에서 알아서 처리하면 되지 않을까?
그래서 채택한 방식이 Event Broadcasting입니다.
- ViewModel은 네트워크 메서드로부터 “인증이 만료되었다”는 이벤트를 방출한다.
- 부모 Coordinator는 해당 이벤트를 구독하고 있다가, 인증 만료에 대한 로직을 처리한다.
이 구조에서 더 이상 ViewController는 중간 릴레이 역할을 수행하지 않아도 되고, Coordinator는 구독이라는 이벤트 처리로 한 곳에서 관리할 수 있다고 판단되었죠.
🧑🏻💻 이젠 리팩토링의 시간
“토큰이 만료되었다”는 사실을 어딘가에 ‘전달'하는 것이 아니라, ‘전파'하는 방식으로
그럼 이제 구체적으로 코드를 어떻게 바꿨는지 하나씩 살펴보겠습니다.
ViewModel부터 살펴보겠습니다. 결론부터 말하자면 수정된 코드가 없습니다.
기존과 동일하게 네트워크 요청 중 401 인증 에러가 발생하면,
아래와 같이 navigateToLogin 이벤트만 방출할 뿐입니다.
뷰 모델에서는 토큰이 어디서 삭제되는지 / 화면이 어떻게 전환되는지 / 누가 이벤트를 받아서 처리하는지 전혀 알 필요가 없이. 그저 “이 요청은 인증이 만료된 상태였다"라는 사실만 알려주는 역할로 유지하는 셈입니다.
output.navigateToLogin.send()
가장 먼저, 기존 ViewController에 존재하던 onRootDeleteToken 클로저는 모두 정리했습니다.
더 이상 클로저를 통해 Coordinator로 직접 전달할 이유가 없어졌기 때문이죠.
이제는 “토큰 만료가 발생했다"는 사실을 앱 전역에 알려주는 역할로 명확하게 정의했습니다. 앱 전역에 알려주기 위해 사용한 기능이 NotficationCenter이었구요.
output.navigateToLogin
.sink {
NotificationCenter.default.post(name: .refreshTokenExpired, object: nil)
}
.store(in: cancelBag)
이 이벤트를 실제로 처리하는 곳은 TabBarCoordinator 단 한 곳, 루트입니다.
TabBarCoordinator가 위의 이벤트를 ‘구독’하고 있는 것이죠.
이제 KeyChain 삭제 정책이 바뀌거나 / 세션만료 등 이벤트가 늘어나는 경우에도 각 Coordinator 파일을 일일이 순회할 필요없이 이곳 루트 부분의 정의한 메서드 부분만 수정하면 됩니다.
func observeDeleteTokenEvent() {
NotificationCenter.default.addObserver(
forName: .refreshTokenExpired,
object: nil,
queue: .main
) { [weak self] _ in
_ = KeyChainService.deleteTokens(
accessKey: Config.accessTokenKey,
refreshKey: Config.refreshTokenKey
)
self?.onFinish?()
}
}
그리고 이 메서드는
TabBarCoordinator.start() 시점에 한 번만 호출됩니다.
override func start() {
if tabBarController == nil {
setupTabBarController()
}
guard let tabBarController else { return }
router.setRoot(tabBarController, animated: false)
observeDeleteTokenEvent()
}
이제 구조가 아주 단순해졌습니다.
어떤 화면에서든, 어떤 ViewModel에서든.
.refreshTokenExpired 이벤트만 발생시키면,
토큰은 항상 동일한 방식으로 삭제되고 / 앱은 항상 동일한 루트 전환 흐름을 따르게 수정되었습니다.
Coordinator는 더 이상 각 VC마다 동일한 코드를 주입하지 않아도 됩니다.
💡 리팩토링을 마무리하며
이번 리팩토링은 단순히 <NotificationCenter를 썼다>가 핵심이 아니었습니다.
- 이 이벤트는 호출이 아니라 전파가 맞을까?
- 지금 내가 리팩토링에서 필요한 정책은 어느 계층의 책임에 해당되는 걸까?
- 각 계층이 지금 구조에서 정말 자기 역할만 하고 있는걸까?
이 질문들 (Guiding Questions)을 끝까지 따라가다 보니, 자연스럽게 Event Broadcasting이라는 선택지에 도달하게 된 흐름이 핵심이라고 볼 수 있습니다.
저 개인적으로는 이번 경험이 재밌는 경험이었습니다 ^__^
더욱 좋은 방식도 있을 수 있을거라 생각합니다. 혹시 글을 보고 더 좋은 제안이 있으시거나, 궁금한 점이 있다면 얼마든지 댓글로 물어봐주세요!
긴 글 읽어주셔서 감사합니다 😊
오늘은 여기까지!
메타데이터
- post_id
- 79bd42ef200a
- slug
- ios-401-unauthorized-토큰-만료-대응-코드가-여기저기에-흩어져-있었다-79bd42ef200a
- url
- https://medium.com/@mini-min/ios-401-unauthorized-%ED%86%A0%ED%81%B0-%EB%A7%8C%EB%A3%8C-%EB%8C%80%EC%9D%91-%EC%BD%94%EB%93%9C%EA%B0%80-%EC%97%AC%EA%B8%B0%EC%A0%80%EA%B8%B0%EC%97%90-%ED%9D%A9%EC%96%B4%EC%A0%B8-%EC%9E%88%EC%97%88%EB%8B%A4-79bd42ef200a
- canonical_url
- https://medium.com/@mini-min/ios-401-unauthorized-%ED%86%A0%ED%81%B0-%EB%A7%8C%EB%A3%8C-%EB%8C%80%EC%9D%91-%EC%BD%94%EB%93%9C%EA%B0%80-%EC%97%AC%EA%B8%B0%EC%A0%80%EA%B8%B0%EC%97%90-%ED%9D%A9%EC%96%B4%EC%A0%B8-%EC%9E%88%EC%97%88%EB%8B%A4-79bd42ef200a
- author_url
- https://medium.com/@mini-min
- status
- ok
- fetched_at
- 2026-07-15 17:30:05