SwiftUI 팀 프로젝트 혼자 리팩토링하기 #4 : 자동 로그인, interceptor 구현

·2024년 7월 9일
0

리팩토링 일지

목록 보기
5/13

레포지토리 주소
refactoring 브랜치와 refactor/#5에서 작업



오늘 요약

  • keychain에 응답으로 받은 토큰 저장
  • 자동 로그인 위한 분기 처리
  • 토큰 갱신 위한 interceptor 구현


keychain에 응답으로 받은 토큰 저장하기

기존에 구현되어 있던 KeychainManager 을 활용하여,
로그인 후 응답으로 받은 access token, refresh token을 Keychain에 저장하도록 했다.

저장하는 로직은 AuthUseCase 에 담았다.

    func loginWithApple(_ authorization: ASAuthorization) -> AnyPublisher<Void, CustomError> {
        let code = getCredential(authorization)

        return authRepository.loginWithApple(code)
            .map { token in
                KeychainManager.shared.save(key: TokenType.accessToken, token: token.accessToken)
                KeychainManager.shared.save(key: TokenType.refreshToken, token: token.refreshToken)
            }
            .eraseToAnyPublisher()

일단 오래 고민했던 것은, clean architecture 흐름도를 보면 다들 keyChain을 Local DB로써 맨 오른쪽에 위치시켰다. (Repository에서만 접근이 가능하도록)

근데 토큰을 UseCase나 interceptor에서 사용해야 하는 경우가 있어서, 전역으로 사용할 수 있게 기존의 싱글톤 KeychainManager을 사용했다.


자동 로그인을 위한 분기 처리

레퍼런스를 겁나 찾아봤는데, 보통 local DB에 token들이 저장되어 있는지 여부에 따라 Splash에서 분기 처리를 많이 하시더라.

근데 이 프로젝트에서는
Apple Login -> response로 token 받음 -> 프로필 설정하기
ㄴ 여기서 프로필 설정을 완료해야 뷰들에서 서버 통신 시도 시, 에러가 나지 않는다.
ㄴ 프로필 설정을 완료하면 뷰들에서 서버 통신 시, unfinishRegister 가 오도록 구현되어 있다.

프로필 설정(닉네임 체크, 프로필 등록) 시에도 accessToken을 헤더에 실어줘야 하기 때문에, 응답으로 받으면 토큰을 받으면 당연히 keychain에 저장해야 한다고 생각했다.

따라서 localDB에서 토큰 존재 여부로 자동 로그인을 처리하기에는 한계가 있었다.

결론은 그냥 간단하게, isLoggedIn 라는 변수를 만들고 프로필 설정까지 완료되면, 해당 변수를 true로 변경해주기로 했다. 그리고 AppStorage에 저장!
(앱 삭제, 로그아웃, 회원 탈퇴 등에서도 변경 필요)


struct OnboardingView: View {
    @EnvironmentObject private var appDependency: AppDependency
    
    @AppStorage(AppStorageKey.loginState) private var isLoggedIn: Bool = false

    var body: some View {
        if isLoggedIn {
            WoteTabView()
        } else {
            LoginView(viewModel: appDependency.container.resolve(LoginViewModel.self)!)
        }
    }
}

Interceptor 구현

오늘의 최대 고민거리는 바로 이 자식이었다.
아래는 내가 생각해둔 자동 로그인 로직이다.

  • 평소와 같이 서버에게 헤더에 access token을 실어 request를 요청했는데, access token이 만료되었다고 응답이 온다면!
    ㄴ refresh token을 사용하여 access token을 재발급받고, 이를 다시 저장한다.
    ㄴ 토큰을 재발급 받으면 다시 실패했던 request를 진행한다.
  • refresh token까지 만료되었다면, 재로그인 요청을 한다.

모든 request마다 해당 로직을 작성하는 것은 매우 비효율적.
Moya에는 이를 도와줄 RequestInterceptor 가 존재한다.

retry 라는 메서드를 사용하면, completion을 통해 다시 request를 시도할지 등을 지정할 수 있다.


class AuthInterceptor: RequestInterceptor {

    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 호출
    }

401 에러 발생 시, 토큰 재발급 API를 호출하면 된다.
그리고 MoyaProvider 선언 시 session으로 넣어 주기.


  • resissueToken
func reissueToken(completion: @escaping (Bool) -> Void) {
        guard let refreshToken = KeychainManager.shared.read(key: TokenType.refreshToken) else { return }

        let requestTokenObject: RefreshTokenRequestObject = .init(
            refreshToken: refreshToken
        )

        let reissueTokenAPI: AuthAPI = .getNewToken(requestTokenObject)
        

        AF.request(reissueTokenAPI.baseURL,
            method: reissueTokenAPI.method,
            parameters: requestTokenObject.toDictionary(),
            encoding: URLEncoding.default,
            headers: ["Content-Type":"application/json"])
        .response { response in
            switch response.result {

            case let .success(data):
                guard let data = data else { return }

                do {
                    let result = try JSONDecoder().decode(GeneralResponse<TokenObject>.self, from: data)

                    guard let tokens = result.data?.toToken() else { return }

                    KeychainManager.shared.save(key: TokenType.accessToken, token: tokens.accessToken)
                    KeychainManager.shared.save(key: TokenType.refreshToken, token: tokens.refreshToken)

                    completion(true)
                } catch {
                    completion(false)
                }

            case let .failure(error):
                // TODO: - 에러 처리
                print(error.localizedDescription)
                completion(false)
            }
        }
    }

Alamofire request로 API 연결을 진행하고,
재발급 성공 시, keychain에 새로운 토큰 저장 및 completion으로 true 전달
실패 시, completion으로 fail 전달


  • retry
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
    }

    reissueToken { succeed in
        if succeed {
            completion(.retry)
        } else {
            // TODO: - 갱신 실패 시 처리
        }
    }
}

재발급 성공 시 retry 메서드에서는 실패했던 request를 다시 요청하고,
실패 시에는 따로 에러 처리가 필요할 것 같다.


고민했던 부분은, 저기서 바로 어떻게 재발급 API를 호출할 것인가였다.

찾아보니 네트워크 객체를 싱글톤으로 구현한 사람들이 많아서 그냥 바로 접근해서 호출하던데, 일단 나는 그게 불가능했고,

그렇다고 여기서 dataSource를 또 선언하자니 dataSource에서 interceptor을 가진 moya provider을 선언할 텐데...? 상태였다.

final class AuthDataSource: AuthDataSourceType {

    private let provider = MoyaProvider<AuthAPI>()

    func loginWithApple(_ object: AppleUserRequestObject) -> AnyPublisher<AppleUserResponseObject, CustomError> {
        provider.requestPublisher(.loginWithApple(object))
            .tryMap { try JSONDecoder().decode(GeneralResponse<AppleUserResponseObject>.self, from: $0.data) }
            .compactMap { $0.data }
            .mapError { CustomError.error($0) }
            .eraseToAnyPublisher()
    }
}

그래서 그냥 결론은... 직접 구현해서 호출하기였다.....
한 여섯 시간은 고민한 것 같다... 😥
많이 어렵다. !!!!!!!1



오늘의 일지 끗

0개의 댓글

관련 채용 정보