최근 프로젝트를 진행하며 독립적인 네트워크 모듈을 직접 설계하며, 아래와 같은 목표를 세웠습니다.
- 네트워크 관련 서드파티 라이브러리는 네트워크 모듈 내에서만 알도록 할 것
- 모듈 외부에선 NetworkService만 알도록 할 것
- 토큰 리프레쉬 로직과 같이 인증 관련 세부 구현은 숨길 것
이를 위해 NetworkService 내부에서 Alamofire의 AuthenticationInterceptor 를 활용하여 인증을 처리하고 있었습니다.
이렇게 설계하면 사용하는 쪽에선 인증 로직을 신경쓰지 않아도 되게 됩니다.
401 인증 에러 응답을 받으면 Alamofire가 Authenticator.refresh() 를 호출하고, 토큰을 갱신한 뒤 자동으로 요청을 재시도하니까요.
그런데 한가지 문제가 발생했습니다.
의존성 문제
OAuthAuthenticator는 refresh 함수를 통해 토큰 리프레쉬 로직을 수행합니다.
그러나 해당 프로젝트에선 API는 데이터 모듈에 정의되기 때문에 아래와 같은 문제가 발생합니다.
- 네트워크 모듈이 데이터 모듈을 의존해야만 갱신 요청을 보낼 수 있음
- 즉, 네트워크 모듈의 독립성이 깨짐
import Alamofire
/// `OAuthAuthenticator`는 Alamofire의 `Authenticator`를 구현하여 OAuth 인증을 담당하는 클래스입니다.
///
/// 액세스 토큰을 헤더에 추가하고, 401 에러 발생 시 자동으로 리프레시 로직을 실행하여 새 토큰을 발급받아 재시도하도록 지원합니다.
final class OAuthAuthenticator: Authenticator {
func refresh(
_ credential: OAuthCredential,
for session: Session,
completion: @escaping (Result<OAuthCredential, Error>) -> Void
) {
let api = AuthAPI.renewAccessToken(credential.refreshToken) // 데이터 모듈에 존재
}
}
이 상황을 의존성 그래프로 그리면 다음과 같습니다.
[Data 모듈] --> [Network 모듈]
▲ │
└──── 의존성 순환 발생
토큰을 갱신하려면 AuthAPI가 필요하고, AuthAPI는 데이터 모듈에 있으므로 네트워크 모듈이 데이터 모듈을 알아야하기 때문에
순환 의존이 발생하고, 데이터 모듈에 대한 강한 결합이 생기게 됩니다.
처음에는 타입 하나 정도면 괜찮지 않을까? 라고 생각할 수 있지만, 프로젝트가 커질 수록 유지보수와 테스트에 영향을 주게 됩니다. 또한 독립적인 라이브러리로 배포 또한 불가능합니다.
리프레쉬 로직은 누구의 책임일까?
이 문제를 해결하기 위해 고민하다 보니 본질적인 질문으로 돌아오게 되었습니다.
"토큰 갱신은 네트워크 모듈이 처리 해야하는 일인가?"
제가 내린 결론은 "아니다" 였습니다.
- 네트워크 모듈은 "어떻게 인증하는가" 까지만 책임을 가집니다.
- 구체적인 리프레쉬 로직은 서비스마다 다르고, 프로젝트마다 다릅니다.
- 즉, 리프레쉬 로직은 사용하는 쪽에서 자유롭게 구현할 수 있어야 합니다.
의존성 역전으로 문제를 풀다
이 때 적용할 수 있는 설계 원칙이 의존성 역전 원칙 (Dependency Inversion Principle)입니다.
네트워크 모듈 내부에서 리프레쉬 요청을 직접 하지 않고, 외부에서 주입받은 로직을 호출하도록 구조를 바꿔 문제를 해결할 수 있었습니다.
인터페이스 분리
먼저 네트워크 모듈에는 프로토콜 하나를 정의합니다.
/// 토큰 리프레시 동작을 추상화하는 프로토콜입니다.
///
/// `OAuthAuthenticator`는 이 프로토콜을 통해 토큰 갱신 로직을 외부에 위임합니다.
/// 이로써 네트워크 모듈은 구체적인 API 호출을 알 필요 없이, 단순히 새 토큰 정보를 넘겨받아 `Credential`을 갱신합니다.
public protocol TokenRefreshHandler: Sendable {
/// 리프레시 토큰을 이용해 새 토큰을 발급받습니다.
///
/// - Parameters:
/// - refreshToken: 현재 저장되어 있는 리프레시 토큰 문자열입니다.
/// - completion: 토큰 갱신 결과를 비동기로 전달합니다. 성공 시 `TokenRefreshResult`를 반환합니다.
func refresh(refreshToken: String, completion: @escaping (Result<TokenRefreshResult, Error>) -> Void)
}
이 프로토콜은 단순히 refreshToken을 줄테니, 새 액세스 토큰을 만들어줘 라고 요청하는 역할만 수행합니다.
네트워크 모듈은 이 인터페이스만 알면 되고, 내부 구현은 알 필요가 없게 됩니다.
아래와 같이 OAuthtenticator 는 새 액세스 토큰을 받아 인증정보 업데이트만 수행합니다.
import Alamofire
final class OAuthAuthenticator: Authenticator {
private let tokenRefreshHandler: TokenRefreshHandler
init(tokenRefreshHandler: TokenRefreshHandler) {
self.tokenRefreshHandler = tokenRefreshHandler
}
func refresh(
_ credential: OAuthCredential,
for session: Session,
completion: @escaping (Result<OAuthCredential, Error>) -> Void
) {
guard !credential.refreshToken.isEmpty else {
completion(.failure(AuthError.noRefreshToken))
return
}
tokenRefreshHandler.refresh(
refreshToken: credential.refreshToken
) { refreshResult in
switch refreshResult {
case let .success(newToken):
// newToken -> Credential로 가공
completion(.success(newCredential))
case let .failure(error):
completion(.failure(error))
}
}
}
}
로직은 사용자 측에서 자유롭게 구현
이제 리프레쉬 API 요청과 같은 구체적인 로직은 사용하는 쪽에서 구현하면 됩니다.
해당 로직은 프로젝트 아키텍쳐 구조에 맞게 데이터 모듈 혹은 도메인 계층 에서 담당하게 됩니다.
final class TokenRefreshHandlerImpl: TokenRefreshHandler {
func refreshToken(refreshToken: String, completion: @escaping (Result<TokenRefreshResult, Error>) -> Void) {
let api = AuthAPI.renewAccessToken(refreshToken)
let url = api.baseURL + "/" + api.path
AF.request(url, method: .post, parameters: api.parameters)
.validate()
.responseDecodable(of: TokenRefreshResponseDTO.self) { response in
switch response.result {
case let .success(data):
completion(.success(data))
case let .failure(error):
completion(.failure(error))
}
}
}
}
결과
아래와 같이 데이터 모듈과 네트워크 모듈 간의 순환 의존 관계가 끊어지게 되고, 네트워크 모듈은 다른 모듈 간 의존성으로부터 완전히 독립적이게 됩니다.
또한 테스트할 때도 TokenRefreshHandler를 Mocking 하면 되니 유연한 테스트가 가능해집니다.
[Data 모듈] - 구현 -> [TokenRefreshHandler 프로토콜] <- 의존 - [Network 모듈]
느낀 점
처음에는 네트워크 모듈 내부에서 알아서 다해주면 편할 것이라고 간단하게 생각했습니다.
하지만 구체적인 동작까지 모두 내부에서 알아서 수행하기 위해선 네트워크 모듈에 의존성이 걸리게 되고, 변경이나 배포에 어려운 경직적인 구조가 되어가는걸 느낄 수 있었습니다.
이번 경험을 통해 책임을 분리하고, 인터페이스를 통해 구체적인 구현은 사용하는 쪽에게 맡기자는 설계 원칙을 다시 한번 배울 수 있었습니다.
'iOS' 카테고리의 다른 글
WWDC25 - FoundationModels로 엿본 애플이 꿈꾸는 개인화 AI의 미래 (1) | 2025.06.24 |
---|---|
iOS Hang, Hitch 그리고 Render Loop (0) | 2025.06.16 |
Demystify SwiftUI - Identify: SwiftUI는 뷰를 어떻게 구분할까? (0) | 2025.06.09 |
번역) SwiftUI 간단한 뷰 레이아웃 구성하기 (2) | 2025.06.06 |
번역) SwiftUI 커스텀 뷰 선언하기 (0) | 2025.06.06 |