본문 바로가기
iOS/개발

Swift Moya에 Interceptor 적용하여 자동로그인(토큰 갱신) 구현하기

by 바등쪼 2023. 4. 8.

오늘은 iOS에서 로그인을 구현할 때 Interceptor를 사용해 자동으로 토큰을 갱신하는 방법을 알아봅시다! 🏃🏻‍♀️

 

오늘의 주제

현재 진행중인 프로젝트에서는 Apple과 카카오로 로그인하는 소셜 로그인 기능을 제공하고 있습니다.

일반적으로 iOS에서 소셜로그인 플로우는 다음과 같습니다.

  1. Apple이나 카카오와 같은 OAuth Provider로부터 oAuthToken을 받습니다. (oAuthToken은 provider에 따라 accessToken이나 다른 이름으로 불리기도 합니다! 오늘은 oAuthToken으로 통일해서 부르겠습니다.) 이 과정은 이미 많은 곳에서 잘 설명하고 있기 때문에 생략하겠습니다!
  2. 1번에서 받은 oAuthToken을 우리 프로젝트의 서버에 보냅니다.
  3. 서버는 우리가 보낸 oAuthToken을 OAuth Provider로부터 검증하고 우리(앱)에게 response로 accessToken과 refressToken을 전송해줍니다.
  4. 클라이언트는 이 accessToken과 refreshToken을 UserDefaults와 같은 기기 내 저장 공간에 저장합니다. (기기에 이 토큰들이 저장되어 있다면 로그인 뷰를 생략하고 곧바로 메인 뷰로 넘어가도록하여 자동 로그인을 구현합니다.)
  5. 이제 앱을 사용하면서 발생하는 API 통신의 헤더에 이 accessToken을 담아서 보내면 됩니다!

자, 5번까지가 기본적인 토큰 발급과 사용의 흐름이었습니다. 하지만, 오늘 관심을 가지고 알아볼 부분은 5번 이후의 상황입니다..!

분명 우리는 서버로부터 accessToken 뿐만 아니라 refreshToken을 받아왔습니다.

이 refreshToken은 어디에 사용하는 것일까요?

바로 accessToken의 재발급에 사용됩니다.

일반적으로 accessToken은 보안상의 이유로 유효 기간이 매우 짧게 설정됩니다.

이 기간은 보통 팀마다 다르지만 5분~2시간 정도로 길지 않습니다. API 통신마다 accessToken을 담아서 보내야하는데 이 토큰의 기간이 만료된다면 서버 통신에 실패하게 됩니다.

이때 필요한 것이 바로 refresh 토큰입니다. refreshToken은 기본적으로 accessToken 보다 유효기간이 훨씬 길게 설정됩니다.

 

accessToken을 담아서 신나게 통신을 하다가 어느순간 이 토큰의 기간이 만료되어서 사용자를 인증할 수 없다면 서버는 클라이언트에게 401 StatusCode를 보냅니다. 

 

HTTP(하이퍼텍스트 전송 프로토콜) 401 Unauthorized 응답 상태 코드는 요청된 리소스에 대한 유효한 인증 자격 증명이 없기 때문에 클라이언트 요청이 완료되지 않았음을 나타냅니다

 

만약 통신을 하다가 401을 받게 되면 내가 보낸 토큰이 만료되었다고 판단하고 토큰을 새로 발급받는 과정이 필요합니다.

이때, 서버 쪽에서 미리 제공해둔 또다른 API를 이용하여 기존의 만료된 accessToken과 refreshToken을 보내게 됩니다.

서버는 이 토큰들을 받아서 accessToken으로 사용자를 식별하고 refreshToken을 활용하여 이전에 토큰을 발급해준 대상이 맞다면 새로운 토큰들을 생성하여 다시 클라에게 response로 보내줍니다.

 

클라이언트는 이제 새로 받은 토큰들을 다시 기기에 저장하여 앞으로는 이 토큰들을 사용해 API 통신을 하게 되는 것입니다.

이 흐름이 잘 구현되어 있으면 실제 앱 사용자는 이러한 과정이 발생하는지 눈치채지 못하고 끊김 없이 앱을 계속 사용할 수 있게 됩니다.

 


전반적인 흐름은 아마 이해가 되셨을거라고 생각합니다!

그렇다면 이걸 코드로 구현을 해야할텐데, 모든 API 통신마다 StatusCode를 분석하여 401이 오면 토큰 갱신 API를 호출 + 실패했던 API 통신 다시 시도... 이 과정을 모두 코드로 넣는 것은 매우 비효율적입니다.

 

그래서 우리는 오늘 RequestInterceptor를 사용하여 이 과정을 쉽게 구현하고자 합니다!!

구현

RequestInterceptor는 Alamofire에서 제공하는 프로토콜입니다. 모야는 Alamofire를 wrapping한 라이브러리이기 때문에 이 친구를 사용할 수 있습니다!

RequestAdapter과 RequestRetrier를 채택하고 있는 것을 확인할 수 있습니다. 이름 그대로 통신을 Interceptor하여 작업을 수행하는 녀석입니다!

 

이 프로토콜들은 각각 adapt, retry 함수를 요구합니다.

그리고 RequestInterceptor는 extension을 통해 이 adapt와 retry의 기본 구현을 제공합니다.

 

adapt는 Request가 전송되기 전에 원하는 추가적인 작업을 수행할 수 있도록 하는 함수입니다. 즉, 이 함수에 다음과 같이 헤더에 넣을 토큰을 지정해주면 별도로 TargetType마다 헤더에 토큰을 넣어주지 않아도 자동으로 토큰이 들어가게 됩니다.

func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
    guard urlRequest.url?.absoluteString.hasPrefix("http://어쩌구") == true,
          let accessToken = UserManager.shared.accessToken, // 기기에 저장된 토큰들
          let refreshToken = UserManager.shared.refreshToken
    else {
        completion(.success(urlRequest))
        return
    }

    var urlRequest = urlRequest
    urlRequest.setValue(accessToken, forHTTPHeaderField: "accessToken")
    urlRequest.setValue(refreshToken, forHTTPHeaderField: "refreshToken")
    print("adator 적용 \(urlRequest.headers)")
    completion(.success(urlRequest))
}

 

반대로 retry는 Request가 전송되고 받은 Response에 따라 수행할 작업을 지정할 수 있습니다. 이름처럼 통신이 실패했을 때 retry 하는 기능을 제공합니다.

 

우선 이 RequestInterceptor를 채택한 AuthInterceptor를 다음과 같이 생성했습니다.

final class AuthInterceptor: RequestInterceptor {

    static let shared = AuthInterceptor()

    private init() {}

    func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
        guard urlRequest.url?.absoluteString.hasPrefix(Config.baseURL) == true,
              let accessToken = UserManager.shared.accessToken,
              let refreshToken = UserManager.shared.refreshToken
        else {
            completion(.success(urlRequest))
            return
        }
        
        var urlRequest = urlRequest
        urlRequest.setValue(accessToken, forHTTPHeaderField: "accessToken")
        urlRequest.setValue(refreshToken, forHTTPHeaderField: "refreshToken")
        print("adator 적용 \(urlRequest.headers)")
        completion(.success(urlRequest))
    }

    func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
        print("retry 진입")
        guard let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401
        else {
            completion(.doNotRetryWithError(error))
            return
        }

		// 토큰 갱신 API 호출
        UserManager.shared.getNewToken { result in
            switch result {
            case .success:
                print("Retry-토큰 재발급 성공")
                completion(.retry)
            case .failure(let error):
                // 갱신 실패 -> 로그인 화면으로 전환
                completion(.doNotRetryWithError(error))
            }
        }
    }
}
  • adapt를 통해 Request Header에 필요한 토큰을 넣습니다. 서버에서 제공하는 API 명세에 맞게 필요한 토큰들을 넣으면 됩니다!
  • retry를 살펴보면 response의 statusCode가 401인 경우 토큰을 갱신하는 API를 호출하도록 했습니다. (여기서는 getNewToken이 이 API 요청을 담당합니다.)
  • 401이 아닌 경우에는 completion(.doNotRetryWithError(error)) 를 통해 retry를  하지 않도록 했습니다.
  • 토큰 갱신에 성공했다면 completion(.retry) 를 통해 이전에 실패했던 통신을 새 토큰들로 다시 시도합니다.
  • 거의 모든 Provider에 이 AuthInterceptor를 넣을 것이기 때문에 싱글톤을 사용했습니다.

적용

이제 AuthInterceptor 를 Moya 코드에 적용해봅시다!

Alamofire에서는 request 함수의 파라미터로 interceptor를 넣지만 Moya에서는 살짝 다릅니다.

Interceptor를 Provider를 생성할 때 넣어주어야 합니다!

let sampleProvider = MoyaProvider<SampleRouter>(session: Session(interceptor: AuthInterceptor.shared))

이런식으로 Session을 생성하고 여기에 AuthInterceptor를 넣은뒤 MoyaProvider의 생성자에 session을 넣어주면 됩니다.

이렇게 하면 해당 Provider을 통해 request하는 모든 API에 AuthInterceptor가 적용됩니다.

 

여기까지만 해서 빌드를 해봤을 때 adapt는 제대로 호출이되지만 retry가 작동하지 않는 이슈가 있었습니다.

 

enum SampleRouter: TargetType {
    // 생략
}

extension SampleRouter {
  var validationType: ValidationType {
      return .successCodes
  }
}

이렇게 Router에 validationType을 .successCodes로 바꾸니 retry 함수가 잘 실행되었습니다.

 

Moya에서 정의한 TargetType의 확장에서 이 validationType을 .none으로 기본구현했기 때문에 이를 .successCodes로 수정해준 것입니다!!

 

ValidationType은 다음과 같이 정의되어 있습니다.

  • successCodes는 200번대의 statusCode만이 validate한 통신이었다는 것으로 인식하여 200번대가 아닌 statusCode를 받게 되면 retry 함수를 호출하도록 하는 것 같습니다.
  • 이렇게 retry 함수가 호출되면 이전에 AuthInterceptor에서 구현한대로 401인 경우를 체크하여 토큰을 재발급합니다.

retry가 동작하여 토큰 갱신에 성공한 로그..!

여기까지 하면 Moya에서 Interceptor를 적용하여 API 통신 중간에 처리할 작업들을 지정하는 목표를 달성할 수 있습니다!

 

+ 추가

이번 프로젝트에서는 갑작스러운 토큰 갱신 상황을 최소화하기 위해 Splash 단계 (첫번째 VC)에서 한번 토큰을 갱신하는 방법을 사용했습니다. 

extension SplashVC {
	// viewDidLoad에서 호출
    private func checkDidSignIn() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        	// 기기에 토큰이 저장되어 있는 경우 -> 자동 로그인
            if UserManager.shared.hasAccessToken {
            	// 토큰 갱신하고 시작
                UserManager.shared.getNewToken { [weak self] result in
                    switch result {
                    case .success:
                        print("SplashVC-토큰 재발급 성공")
                        self?.pushToTabBarController() // 메인 화면으로 이동
                    case .failure(let error):
                        print(error)
                        self?.pushToSignInView() // 토큰 갱신 실패 -> 로그인 화면으로 이동
                    }
                }
            } else { // 기기에 토큰이 없는 경우 -> 로그인 화면으로 이동
                self.pushToSignInView()
            }
        }
    }
}

 

 

댓글