TroubleShooting / Interceptor

iOS 앱개발 공부

목록 보기
28/30

🧠 개요

현재 진행 중인 프로젝트의 로그인 및 인증 관련 기능을 구현하던 중 발생한 트러블 슈팅을 정리한다.
트러블슈팅 과정을 요약하자면 아래와 같다.

  • 문제 상황: AccessToken이 만료되었을 때 서버에 RefreshToken을 통해 토큰 재발급을 받아야 하는데, 사용자가 '자동 로그인'을 등록해둔 상태라면 곧바로 메인 화면으로 진입하게 되고, 메인 화면에서는 Token을 다루는 Service 레이어가 없어 재발급이 어려움

  • 해결 방법: 프로젝트에서 채택 중인 Moya 라이브러리의 RequestInterceptor를 이용하여 모든 API 요청에 대해 Interceptor를 거치도록 구현. 만약 token이 만료된 경우 refresh 요청까지 진행(실패시 자동 로그아웃)

  • 결과: 기존의 프로젝트 구조는 유지하면서 네트워크 계층과 인증 비즈니스 로직 간의 결합도가 낮아져 유지보수성이 향상됨


🚨 문제 발생

프로젝트의 로그인/회원가입 기능을 무사히 구현 완료한 후 테스트를 진행하는데,
하루마다 인증 토큰이 만료되어 서버와 통신이 불가능한 문제를 맞이했다.

이는, 서버에서 발급해주는 AccessToken의 유효기간이 1일로 설정되어 있었기 때문인데, 토큰이 만료되었을 때 재발급을 해주는 로직이 없었기 때문에 토큰이 만료된 채로 앱을 사용하게 되어 통신이 불가능했던 것이다.

다행히 서버에서는 AccessToken과 함께 RefreshToken을 발급해주고, RefreshToken은 유효기간이 7일이기 때문에 7일 이내에 RefreshToken으로 재발급을 요청하면 계속해서 앱을 정상적으로 사용할 수 있었다.

1) AuthService Method 구현

본 프로젝트는 클린아키텍처 기반의 프로젝트로, 네트워크 관리를 위해 Moya 라이브러리를 채택한 상태이다.
네트워크에 관련된 권한은 NetworkManager라는 객체가 관리하며, 해당 객체의 request() 메서드를 통해 requestParameter와 response 객체를 삽입하여 서버와 통신하는 구조로 되어있다.

토큰을 발급/재발급 하는 것은 Auth Service에서 관리해야하는 API라고 생각하여 Auth Service에 재발급 로직을 구현해 주었다.

func executeReissueToken() async throws(AppError) {
	if let token = LocalStorage.refreshToken {
		do {
			let request = RefreshRequest(refreshToken: token)
        
			_ = try await networkManager.request(
				AuthRouter.refresh(dto: request),
				of: BaseResponse<RefreshResponse>.self
			)
            
		} catch {
			throw .default(error.message)
		}
        
	} else {
    	throw .default("RefreshToken이 등록되지 않았습니다.")
    }
}

2) 구조적 문제 발생

메서드를 구현한 것은 좋았는데, 이 메서드를 어느 타이밍에 어떻게 호출해야할지가 문제였다.
본 프로젝트는 '자동 로그인'도 지원하기 때문에 만약 사용자가 '자동 로그인'을 사용한 채로 앱을 실행하게 되면 Auth Layer를 거치지 않고 메인 화면으로 이동한다.
메인 화면에서는 당연히 Auth Service를 관리하지 않기 때문에 토큰을 재발행하는 메서드를 실행할 수 없는 문제가 발생한다.

가장 좋은 방법은 런치 스크린이 보여지는 동안에 내부적으로 토큰의 만료 여부를 파악하고, 만료가 된 경우 재발급을 요청하는 로직을 구현하는 것인데, 이 방법을 구현하기에는 몇 가지 문제가 있다.

  1. 화면전환을 담당하는 Coordinator는 Network Service를 다룰 수 없음
  2. Auth Service는 오직 로그인/회원가입 화면에서만 사용됨 -> 로그인/회원가입 화면이 아니면 사용할 수 없음
  3. 이 방식은 앱을 실행할 때만 토큰을 재발급받을 수 있음 -> 사용자가 앱을 사용 중에 토큰이 만료될 경우 치명적인 UX 문제 발생

때문에 이와 같은 문제점을 모두 커버할 수 있으면서도 프로젝트 구조를 망치지 않는 새로운 방법이 필요했다.


📚 해결 방법 조사

1) 스플래시(Splash) 화면 체류 패턴

이 문제를 해결하기 위해 가장 먼저 떠오른 방법은 토큰 발급 일자를 저장하고, 이를 활용하여 재발급 요청을 하는 것이다.
앱에 로그인/회원가입을 하여 토큰을 발급 받으면 해당 토큰을 발급받은 시간(Date())를 저장해두고,
앱을 실행하여 런치 스크린이 표시되었을 때 현재 날짜와 토큰 발급 일자를 비교하여 발급 일자로부터 1일 이상이 지났다면 재발급을 실행하는 것이다.

다만 이 방법을 실행하기 위해서는 Auth Servie를 모듈로 분리하는 과정이 필요하여 복잡하였고,
무엇보다 문제 3번을 해결할 수 없기 때문에 부적합한 방식이라고 생각했다.

2) Refresh Singleton 패턴

생각해보면 토큰을 재발급하는 기능은 전역적으로 쓰여야 하고, 특수한 상황에서만 사용되는 특수 기능이다.
그렇다면 굳이 Auth Service에 배치하지 말고, 싱글톤 패턴으로 토큰 재발급만을 하도록 구현하면 안되는걸까?

그렇게 생각하고 프로젝트의 구조를 살펴봤는데, 이것은 근본적으로 불가능한 방법이었다.
설령 토큰 재발급 기능을 싱글톤으로 구현하더라도 결국 NetworkManager를 사용해서 API 호출을 해야하는데,
이를 위해서는 DIContainer 내부에 토큰 재발급 객체를 구현해야 한다. (NetworkManagerDIContainer 내부에 구현된 객체이기 때문에)

그리고 구현한 싱글톤 객체를 사용하려면 DIContainer.tokenManager.reissueToken() 이런 식으로 호출하게 될텐데,
이렇게 되면 DIContainer의 단일 책임 원칙이 깨지게 된다.

DIContainer의 역할은 프로젝트에 필요한 객체(Store, UseCase 등)를 소유하고 있다가,
특정 객체에 필요한 객체를 주입해주기 위해 존재하는 것인데,
싱글톤 객체로 인해 그 단일성이 침해되는 것이다.

게다가 여러 화면에서 토큰 재발급을 신청하는 탓에 무한 루프에 빠지거나 리소스 사용량이 너무 커지는 문제가 발생할 수 있기 때문에 별로 사용하고 싶은 방법은 아니었다.

3) API 인터셉터 (Interceptor) 패턴

이 문제를 해결하기 위해 다른 방법을 찾아보던 중, RequestInterceptor 라는 것을 발견했다.

이는 본 프로젝트처럼 네트워크 관리를 하나의 객체에서 진행할 경우 유효한 방법으로,
API 통신을 진행할 때 항상 Interceptor를 거쳐서 실행되도록 하는 방법이다.

프로젝트에서 Moya 혹은 Alamofire를 사용하고 있다면 RequestInterceptor 프로토콜을 활용하여 간단히 구현할 수 있기 때문에 프로젝트에 적합하다고 생각했고, 모든 문제점을 커버할 수 있는 방법이라고 생각하여 이 방법을 채택하게 되었다.


✅ 해결 과정

1) Interceptor 구현

Interceptor를 구현하는 방법은 크게 어렵지 않았다.
클래스를 하나 만든 뒤에 RequestInterceptor 프로토콜을 채택하기만 하면 되었기 때문이다.

import Moya // Moya 혹은 Alamofire 

final class AuthInterceptor: RequestInterceptor { }

RequestInterceptor는 필수적으로 구현해야 하는 프로퍼티나 메서드가 정의되어 있지는 않았다.
필요한 기능들만 구현을 하면 되는 듯 하다.

내가 필요한 것은 API 호출 시 이를 가로채는 기능과, 토큰이 만료되었을 때 재발급을 시도하는 기능이었기 때문에 adapt()retry() 메서드를 구현해 보았다.

final class AuthInterceptor: RequestInterceptor {
	    
    // 1. 모든 요청 전 헤더에 토큰을 삽입 (Adapt)
    func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
        var urlRequest = urlRequest
        
        // 로컬 스토리지에서 최신 액세스 토큰을 가져와 헤더에 주입
        if let token = LocalStorage.accessToken {
            urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        
        completion(.success(urlRequest))
    }
    
    // 2. 통신 실패 시 재시도 여부 결정 (Retry)
    func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
		// 401 Unauthorized 에러인지 확인
        guard let response = request.task?.response as? HTTPURLResponse, 
        	  response.statusCode == 401
		else {
            completion(.doNotRetryWithError(error))
            return
        }
        
        // 토큰 갱신 로직 실행
        Task { ... }
    }
}

문제는 retry() 메서드를 구현하며 발생했는데, 메서드 내부에서 어떻게 토큰 재발급 로직을 실행할 것인지가 문제였다.
그러다가, 어차피 Interceptor는 NetworkManager의 초기화 시에 주입되는 객체이니 토큰 재발급 로직을 init으로 받으면 좋을 것 같다고 생각하여 이를 구현하였다.

final class AuthInterceptor: RequestInterceptor {
	
    private let reissueToken: () async throws -> Void
    
    init(_ reissueToken: @escaping () async throws -> Void) {
        self.reissueToken = reissueToken
    }
	
	// adapt 로직
    // ...
    
    func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
    	// 인증 에러인지 확인하는 로직
        // ...
        
        // 토큰 갱신 로직 실행
        Task { 
        	do {
            	try await reissueToken()
                completion(.retry)
                
            } catch {
            	completion(.doNotRetryWithError(error))
            }
        }
    }
}

init을 통해 Auth Service의 토큰 재발급 함수를 부여받고, 이를 retry() 함수 내부에서 호출하면 될 것 같다고 생각했다.
그런데 이렇게 구현했더니 reissueToken 부분에 오류가 발생했다.

Stored property 'reissueToken' of 'Sendable'-conforming class 'AuthInterceptor' has non-Sendable type '() async throws -> Void'; this is an error in the Swift 6 language mode
A function type must be marked '@Sendable' to conform to 'Sendable'

처음 보는 에러였기 때문에 이에 대해 찾아봤는데, 이는 Swift 6 언어 모드에서 새롭게 도입된 엄격한 동시성 검사(Strict Concurrency Checking) 때문에 발생하는 대표적인 에러라고 한다.
Swift 6부터는 스레드 안정성을 컴파일러가 아주 깐깐하게 따지기 때문에 이를 지키지 않으면 발생하는 오류인 것이다.

2) actor 타입의 proxy 구현

Swift 6의 컴파일러는 내가 만든 AuthInterceptorRequestInterceptor 프로토콜을 채택했기 때문에 여러 스레드를 넘나드는 객체라고 판단한다.
RequestInterceptor가 내부적으로 Sendable을 채택한 프로토콜이기 때문이다.

그런데 Interceptor 내부에 구현한 클로저(reissueToken)는 일반 클로저로, 내부에 스레드 안정성이 없는 변수(non-Sendable)를 캡처할 위험성이 있기 때문에 해당 클로저가 안전하지 않다는 에러를 발생시키는 것이다.

이 문제는 간단하게 해결이 가능한데, 클로저가 Sendable을 준수하고 있다고 명시만 해주면 된다.

final class AuthInterceptor: RequestInterceptor {
    
    private let reissueToken: @Sendable () async throws -> Void
    
    init(_ reissueToken: @escaping @Sendable () async throws -> Void) {
        self.reissueToken = reissueToken
    }
    
}

이렇게 하면 오류는 해결된다.

이렇게 간단히 해결하고 넘어가도 좋겠지만...
해당 오류에 대해 조사하다보니 토큰을 재발급하는 클로저가 동시성을 침해하고 데이터 레이스를 발생시키지는 않을까 걱정이 되었다.
그런 걱정을 하다보니 이를 해결하기 위한 방법을 actor를 떠올리게 되었다.

사실 actor를 직접 사용해본 적은 한번도 없는데, actor를 사용하면 스레드 안정성을 보장하고 데이터 레이스를 방지할 수 있으니 지금 상황에 가장 최적인 방법이 아닐까 생각이 들었다.

그래서 바로 구현을 해보았다.

actor TokenRefreshProxy {
	// @Sendable을 통해 스레드 안정성 보장
    private var refreshHandler: (@Sendable () async throws -> Void)?
    
    // 외부에서 토큰 재발급 함수를 주입하기 위한 함수
    func registerHandler(_ handler: @escaping @Sendable () async throws -> Void) {
        self.refreshHandler = handler
    }
    
    /// AuthInterceptor가 401 에러를 맞았을 때 호출할 함수
    func executeRefresh() async throws {
        guard let handler = refreshHandler else {
            // 아직 로직이 주입되지 않았다면 에러 처리
            throw AppError.default("Token 재발급 함수가 주입되지 않았습니다.")
        }
        try await handler()
    }
}

이전에 Interceptor에서 주입받던 토큰 재발급 함수를 actor에 주입할 수 있도록 수정하고,
actor에서 토큰 재발급 함수를 실행하는 함수를 만들었다.

이를 통해 스레드 안정성을 보장함과 동시에 데이터 레이스를 방지할 수 있는 객체를 만들었으니 이제 Interceptor는 이 actor를 주입받도록 수정하면 된다.

final class AuthInterceptor: RequestInterceptor, @unchecked Sendable {
    private let refreshProxy: TokenRefreshProxy
    
    init(refreshProxy: TokenRefreshProxy) {
        self.refreshProxy = refreshProxy
    }
}

여기서 @uncheckedSendable을 추가로 채택했는데, 이는 컴파일러에게 해당 객체는 스레드 안정성이 보장되니 검사할 필요가 없다고 명시하는 것이다.

내가 구현한 Interceptor는 클래스이지만, 내부에 값이 변하는 변수(var)가 하나도 없다.
오직 상수로 선언된 actor만 존재하고, actor는 스레드 안전성을 보장하기 때문에 Interceptor는 동시성 문제가 절대 발생하지 않는 객체인 것이다.

그러나 컴파일러는 클래스 객체는 동시성 문제를 일으킬 위험성이 있다고 판단하여 에러를 띄울 수 있기 때문에,
이를 방지하기 위한 코드로 @uncheckedSendable 코드를 통해 안전한 객체임을 명시해주는 것이다.

3) 네트워크 엣지 케이스 및 무한 루프 방어

이제 거의 다 완료되었는데, Interceptor에 구현한 retry() 메서드가 불안하다는 것을 확인했다.
위험성이란, Interceptor를 사용하여 토큰 재발급(refresh)를 시도했는데 또 401 에러(인증 실패)가 뜰 경우 다음으로 넘어가지 않고 계속해서 토큰 재발급을 시도하는 무한 재시도 케이스가 발생할 수 있다는 점이다.
또, 로그아웃 상태 등 토큰이 없을 때 빈 인증 헤더가 서버로 전송되어 400 에러를 유발할 수 있는 문제가 있었다.

이를 방어하기 위해 retry 메서드를 조금 수정하였다.

func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) {
	guard let response = request.task?.response as? HTTPURLResponse,
		  response.statusCode == 401, // 인증 에러인지 확인
		  request.retryCount < 1 // retry를 이미 시도했었는지 확인
	else {
		return completion(.doNotRetryWithError(error))
	}
        
	if let urlString = request.request?.url?.absoluteString,
	   urlString.contains("/refresh") // API 호출 url을 확인하여 refresh인데 에러가 떴다면 retry를 시도하지 않도록 방어
	{
		return completion(.doNotRetryWithError(error))
	}
        
	Task {
		do {
			try await refreshProxy.executeRefresh()
			completion(.retry)
                
		} catch {
			completion(.doNotRetryWithError(error))
		}
	}
}

4) NetworkManger 구현

이제 모두 준비가 되었으니 NetworkManager를 수정사항에 맞춰서 수정하면 마무리가 된다.

final class NetworkManager: NetworkManager {
    private let provider: MoyaProvider<MultiTarget>
    
    init(interceptor: AuthInterceptor) {
        let defaultPlugin = NetworkLogginPlugin()
        let session = Session(interceptor: interceptor)

        self.provider = MoyaProvider(
            session: session,
            plugins: [defaultPlugin]
        )
    }
}
// DIContainer 내부

lazy var networkManager: NetworkManager = {
	let refreshProxy = TokenRefreshProxy()
	let interceptor = AuthInterceptor(refreshProxy: refreshProxy)
	let networkManager = DefaultNetworkManager(interceptor: interceptor)
    
    // actor에 토큰 재발급 함수 주입
	Task {
		await refreshProxy.registerHandler { [weak self] in
			guard let self = self else { throw AppError.unknown } // 아직 DIContainer가 초기화되지 않았다면 return
			try await self.authRepository.executeReissueToken()
		}
	}
        
	return networkManager
}()

✒️ 결론

이번 프로젝트를 통해 토큰을 재발급하는 과정을 알게 되었고, actor라는 객체를 처음으로 사용해보게 되었다.
아키텍처에 대해 고민을 하며 작업을 하다보니 간단히 작업을 할 수가 없고 리스크나 여러 위험성을 고려하게 되는 것 같다.
어렵고 시간이 오래 걸리지만, 이러한 과정들이 분명 올바른 길이고 내 성장에 도움이 되리라고 믿는다.

profile
이유있는 코드를 쓰자!!

0개의 댓글