[SOPT / 앱잼] Alamofire RequestInterceptor 을 이용한 액세스 토큰 재발급 구현

박준혁 - Niro·2024년 2월 13일
1

SOPT 33기

목록 보기
4/5
post-thumbnail

DO SOPT 33기 에서 앱잼 기간동안 TOASTER 라는 iOS 앱을 제작했고 많은 관심 부탁드립니다!

🔗 TOASTER 다운로드하기

안녕하세요 Niro 🚗 입니다!

프로젝트를 하면서 로그인 로직을 담당하다보니 Token 관리까지 하게 되었습니다.

Token 을 관리하면서 어떤 어려움과 고민들이 있었는지 정리하고 회고하는 글을 적어볼까합니다.


🔍 들어가기 앞서서

API 를 통해 서버와 통신을 하게되는데 Access, Refresh Token 두가지를 갖고 사용자 인증을 하게 됩니다.

간단하게 각 Token 에 대해 알아보자면

  • 프로젝트 내에서 API 를 사용하기 위해 주로 AccessToken 을 Header 에 넣어서 데이터를 주고 받게 됩니다.
  • AccessToken 은 인증 만료 시간이 짧기 때문에 재발급이 필요하고 재발급을 위해 RefreshToken 을 통해 AccessToken 을 반환받게 됩니다.

하지만 Access, Refresh Token 두개가 만료된다면 굉장히 복잡해 집니다...

Token 이 유효한지 확인하기 위해선 여러 방법이 존재하겠지만 TOASTER 프로젝트내에는 각 Token 의 유효성을 판단할 수 있는 API 는 없었습니다.

다른 방법으로, API 를 사용할 때 Token 이 만료되어 인증할 수 없다는 401 응답코드를 반환하게 되면 Token 이 만료되었다고 판단할 수 있습니다!

그렇기 때문에 우리는 두개의 Token 을 잘 관리해야겠죠?


🔁 Token 을 주고 받는 순서

이해를 쉽게 하고자 순서대로 API 와 통신하는 순서를 작성해보자면

  1. 사용할 API 호출
  2. 응답 결과 401 반환 (AccessToken 인증 만료)
  3. RefreshToken 으로 AccessToken 재발급 API 호출
  4. 재발급 받은 AccessToken 을 통해 1번에서 호출한 API 를 다시 호출 (URLRequest 의 Header 도 새로운 AccessToken 으로 변경)

로 정리할 수 있습니다.

만약 3번에서 AccessToken 재발급 API 에서도 응답코드가 401 이라면 RefreshToken 도 만료되었다는 뜻이기 때문에 다시 로그인을 해야합니다.


😧 재발급 API, 어디에서 호출해야돼?

현재 프로젝트 개발기간이 2주라는 점에 빠르게 구현하기 위해서 URLSession 이 아닌 쓰기 편안한 Moya 를 선택했고 SOPT 전통 코드인.. MoyaPlugin 을 사용하고 있습니다.

두개의 Token 이 만료되지 않을 때는 전혀 문제가 없었지만

AccessToken 이 만료되었을 때 재발급 API 를 호출하는 것이 첫번째 문제였습니다.

프로젝트 내에서 사용하는 모든 API 에 대해 401 응답 코드가 온다면 재발급 API 를 호출하는 로직을 구현해야했고

모든 API 의 호출 부분에서 재발급 API 를 구현하기에는 효율적이지 못하다고 판단하여

첫번째로 MoyaPlugin 내부에서 구현을 해보았습니다.

MoyaPlugin 이란?

Moya 라이브러리에서 제공하는 플러그인 인터페이스로 네트워크 요청 및 응답에 대한 다양한 작업(요청, 오류처리, 성능 측정)을 수행할 수 있습니다.

응답 코드가 401 일 때 재발급 API 를 호출하도록 MoyaPlugin 내부에서 구현을 하였고 성공적이였습니다!

기존 네트워크 세팅을 건들지 않고 모든 API 에서 공통적으로 사용이 가능했습니다.

하지만 AccessToken 을 재발급 받더라도 URLRequest 의 Header 에 새로운 AccessToken 교환하고 다시 처음 호출한 API 를 다시 실행 시키는 것이 힘들었습니다.....

처음 호출한 API 가 어떤 것인지 따로 알고 있어야 하지만 만약 동시에 여러 API 가 호출 된다고 가정하면 처음 호출한 API 가 바뀌는 현상이 벌어지기 때문에 직접 관리를 해야한다고 생각했습니다.

직접 구현을 하기에는 많은 어려움이 존재했고 효율적이지 못하다고 생각했기 때문에 Alamofire 의 RequestInterceptor 를 찾게되었습니다.


👍 Alamofire, 너 만능이구나...?

Alamofire 는 Interceptor Pattern 을 통해 Request 에 대한 전처리와 후처리를 가능하게 해주는 기능을 갖고 있습니다.

전처리, 후처리 역할을 해주는 RequestInterceptor Protocol 이 존재하고 해당 Protocol 은 RequestAdapter, RequestRetrier 라는 Protocol 을 채택하고 있습니다.

RequestAdapter Protocol 은 adapt 라는 메서드와 request 하기 전에 특정 작업을 수행하고 서버에 보내는 역할을 해줍니다.

RequestRetrier Protocol 은 retry 라는 메서드가 있고 adapt 메서드에서 서버에 보낸 결과값이 fail 일 때 retry 메서드를 호출하게 되고 다시 요청을 시도할지 에러와 함께 반환을 할지 구현할 수있습니다.

RetryResult 는 retry 가 필요한 경우, 지연 후 retry 하는 하는 경우 등 Retry 에 대한 여러 결과를 enum 으로 구현되어 있습니다.


🤷🏻 그럼 어떻게 구현했는데?

먼저 전처리를 위한 adapt 메서드부터 구현을 해보겠습니다.

  • adapt 메서드
/// 서버로 보내기전에 api를 가로채서 전처리를 한 뒤 서버로 보내는 역할
/// 즉, Request가 전송되기 전에 원하는 추가적인 작업을 수행할 수 있도록 하는 함수
/// Header 설정을 해당 함수에서 할 수 있지만 BaseTargetType 에 미리 구현이 되어있어 넣지 않음
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
    print("adapt 진입")
    
    if isTokenRefreshed {
        print("토큰 재발급 후 URLRequset 변경")
        var modifiedRequest = urlRequest
        modifiedRequest.setValue(KeyChainService.loadAccessToken(key: Config.accessTokenKey), forHTTPHeaderField: "accessToken")
        
        isTokenRefreshed = false
        
        completion(.success(modifiedRequest))
    } else {
        completion(.success(urlRequest))
    }
}

adapt 메서드는 URLRequest 를 전송하기 전에 추가적인 작업을 할 수 있는 역할을 합니다. 만약 각 API 호출부에서 Header 를 설정하는 형식이라면 adapt 메서드 내에서 header 를 붙이는 로직을 구현해주면 됩니다.

현 프로젝트에서는 Moya 네트워크 세팅 할때 BaseTargetType 를 만들어 URLRequest 를 설정하기 때문에 adapt 메서드에서 따로 header 를 설정해줄 필요가 없었습니다.

위 코드의 순서를 간단하게 설명하자면

  1. retry 메서드를 통해 재발급 API 가 호출되어 Token 을 발급 받았는지 확인 (if 조건)
  2. 재발급 받았다면 기존의 URLRequest 의 header 를 재발급 받은 AccessToken 으로 교체 후 (modifiedRequest) 다시 실행
  3. 만약 재발급 받지 않았다면 기존의 urlRequest 를 제공

으로 되어있습니다.

  • retry 메서드
/// Request가 전송되고 받은 Response에 따라 수행할 작업을 지정
/// 즉, 통신이 실패했을 때 retry 하는 기능을 제공
/// 리프레쉬 토큰 재발급 API 를 호출하게 되는데 해당 API 도 401 을 반환할 수 있어 계속 adapt, retry 가 무한으로 반복될 가능성이 있음
/// 리프레쉬 토큰 재발급 API 의 반복 호출을 막기 위해 guard let 을 통해 해당 path ( URL ) 에서 token 이라는 String 이 존재한다면 정지
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
    guard let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401, let pathComponents =
            request.request?.url?.pathComponents,
            !pathComponents.contains("token")
    else {
        completion(.doNotRetryWithError(error))
        return
    }

    // 토큰 갱신 API 호출
    NetworkService.shared.authService.postRefreshToken { [weak self] result in
        switch result {
        case .success(let result):
            guard let serverAccessToken = result?.data.accessToken, let serverRefreshToken = result?.data.refreshToken 
            else {
                completion(.doNotRetry)
                return
            }
            
            let keyChainResult = KeyChainService.saveTokens(accessKey: serverAccessToken, refreshKey: serverRefreshToken)
            
            if keyChainResult.accessResult == true && keyChainResult.refreshResult == true {
                self?.isTokenRefreshed = true
                
                guard var urlRequest = request.request
                else {
                    completion(.doNotRetry)
                    return
                }
                
                completion(.retry)
                
            } else {
                completion(.doNotRetry)
            }
        case .unAuthorized:
            completion(.doNotRetry)
        case .decodeErr, .networkFail:
            completion(.doNotRetry)
        default:
            completion(.doNotRetry)
        }
    }
}

retry 메서드의 경우 adapt 메서드 에서 completion 을 통해 전달한 Request 의 결과가 실패가 되면 호출됩니다.

또한 실패한 API 를 재시도를 하거나, 재시도 시 지연을 주거나 재시도를 하지 않도록 설정이 가능합니다.

만약 재발급 API 도 status Code 가 401 일 경우 RefreshToken 도 만료가 되었다는 뜻이기 때문에 다시 로그인이 필요합니다.

guard let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401, let pathComponents =
            request.request?.url?.pathComponents,
            !pathComponents.contains("token")
    else {
        completion(.doNotRetryWithError(error))
        return
    }

잘 생각해보면 위의 코드에서 재발급 API 에 사용되는 RefreshToken 까지 만료가 되었을 때 다시 retry 를 하게 되면 무한 루프에 빠질 수 있습니다.

그러한 상황을 방지하고자 guard let 구문을 통해 API 의 URL 에 token 이라는 String 값이 포함되어 있다면 재발급 API 를 retry 시키지 않고 에러를 반환하도록 설정하였습니다.

반대로 urlRequest 의 url 중 token 이라는 String 값이 없고 statusCode 가 401 일 경우 재발급 API 를 호출하도록 구현되어있습니다.


👀 모든 API 에 적용하기 위해선?

final class AuthAPIService: BaseAPIService, AuthAPIServiceProtocol {
    
    private let provider = MoyaProvider<AuthTargetType>.init(session: Session(interceptor: APIInterceptor.shared), plugins: [MoyaPlugin()])

}

final class UserAPIService: BaseAPIService, UserAPIServiceProtocol {
    
    private let provider = MoyaProvider<UserTargetType>.init(session: Session(interceptor: APIInterceptor.shared), plugins: [MoyaPlugin()])

}

final class ToasterAPIService: BaseAPIService, ToasterAPIServiceProtocol {
    
    private let provider = MoyaProvider<ToasterTargetType>.init(session: Session(interceptor: APIInterceptor.shared), plugins: [MoyaPlugin()])

}

현재 프로젝트에서 Request 를 만들기 위해서 각 API 에는 MoyaProvider 통해 provider 를 할당하게 되어 있습니다.

기존에는 plugin 만 할당하였지만 Interceptor 도 같이 provider 를 할당할 때 같이 선언해주면 모든 API 에서 적용 가능하게 됩니다.

retry 가 안돼요!

다 구현을 하고나서 테스트를 해보았는데 응답코드가 401 이여도 retry 가 호출되지 않는 이슈가 있었습니다.

var validationType: ValidationType {
	return .successCodes
}

다음과 같이 validationType 을 .successCode 로 추가해주어야 합니다. (현재 프로젝트에서는 BaseTargetType 에 추가하였습니다.)

validationType 프로퍼티는 단순히 요청을 보낼 때 서버 응답을 어떻게 처리할지에 대한 것이며 .none 을 반환시 Moya 가 응답을 검증하지 않고 모든 응답을 성공으로 처리하게 됩니다.

만약 .successCodes 를 반환하면 Moya 는 서버 응답증 200 대 응답 코드만을 유효한 응답으로 간주하고 나머지는 실패로 처리하게 됩니다.

기본적으로 Moya 의 validationType.none 으로 설정되어 있기 때문에 retry 로직을 적용하기 위해선 .successCode 로 바꿔주어야만합니다.


📌 정리하자면

Moya 가 Alamofire 의 RequestInterceptor 를 사용하는 경우, 네트워크 요청을 보낼 때 Alamofire 의 인터셉터를 활용하여 요청을 수정하거나 retry 로직을 적용할 수 있음을 의미합니다.

다시 TOASTER 의 토큰 재발급 순서는

  1. AccessToken 으로 사용할 API 를 호출
  2. 응답 코드가 401 이라면 retry 메서드가 실행되고 재발급 API 를 통해 AccessToken 을 재발급 받는다
  3. adapt 메서드에서 기존 Request 의 Header 에 존재하는 만료된 AccessToken 을 재발급 받은 AccessToken 으로 변경
  4. 기존에 요청된 API 를 다시 호출

로 정리할 수 있습니다.

별도 호출 없이 자동으로 adaptretry 메서드가 호출되고 전처리와 후처리가 가능하는 장점을 갖고 있었고

기존 네트워크 세팅을 건들지 않고 모든 API 에 적용할 수 있다는 것이 매우 큰 장점이였습니다.

긴글 읽어주셔서 감사하고 많은 피드백과 질문 대환영입니다!


🖥️ Github PR

🔗 리프레쉬 토큰을 통한 액세스 토큰 재발급 API 연결

profile
📱iOS Developer, 🍎 Apple Developer Academy @ POSTECH 1st, 💻 DO SOPT 33th iOS Part

0개의 댓글