SwiftUI 팀 프로젝트 혼자 리팩토링하기 #5 : API 연결

·2024년 7월 10일
0

리팩토링 일지

목록 보기
6/13

요약

  • 자동 로그인 로직 수정
  • 닉네임 유효성 검사 API 연결

자동 로그인 로직 수정

어제 포스팅에서 다음과 같이 작성했었다.

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

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

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

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

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

밥먹다가 생각해 보니...
이 로직대로 하면, 앱을 지웠다 깔면 isLoggedIn이 false가 되기 때문에 이미 가입된 회원도 프로필 설정 뷰로 전환되게 된다.

그래서 다시 결론은 API 연결 시 받은 응답

  • 로그인 / 가입 성공
  • 회원가입 미완료 (프로필 설정 안 한 사람)
  • 그외 실패

으로 로그인 로직 분기 치기이다.


리팩토링 전 프로젝트 코드

    private func handleResponse<T: Decodable>(_ response: Response, _ responseType: T.Type) throws -> GeneralResponse<T> {
        guard response.statusCode == 200 else {
            let decodedData = try JSONDecoder().decode(ErrorResponse.self, from: response.data)
            throw decodedData
        }
        let decodedData = try JSONDecoder().decode(GeneralResponse<T>.self, from: response.data)
        return decodedData
    }

리팩 전에는 일단 Error일 때 받는 response랑 성공 시 받는 response가 다르게 되어 있다.

struct ErrorResponse: Decodable, Error {
    let status: Int
    let divisionCode: String
    let message: String
}

struct GeneralResponse<T: Decodable>: Decodable {
    var status: Int
    var message: String
    var data: T?

    enum CodingKeys: String, CodingKey {
        case status
        case message
        case data
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        status = (try? values.decode(Int.self, forKey: .status)) ?? 0
        message = (try? values.decode(String.self, forKey: .message)) ?? ""
        data = (try? values.decode(T.self, forKey: .data)) ?? nil
    }
}

이렇게 나눠져 있는데, 에러 발생 시에는 divisionCode가 오는 것 빼고 다른 게 없어서 합쳤다. 옵셔널로 divisionCode 프로퍼티 추가하였음.


    func postAuthorCode() {
        appState.serviceRoot.apimanager
            .requestLogin(authorization: authorization)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let failure):
                    print(failure)
                }
            }, receiveValue: { response in
                if let data = response.data {
                    self.appState.serviceRoot.auth.saveTokens(data.jwtToken)
                    if response.message == "Not Completed SignUp Exception" {
                        UserDefaults.standard.setValue(false, forKey: "haveConsumerType")
                        self.appState.serviceRoot.auth.authState = .unfinishRegister
                        self.showSheet = true
                    } else {
                        UserDefaults.standard.setValue(data.consumerTypeExist, forKey: "haveConsumerType")
                        self.appState.serviceRoot.auth.authState = .loggedIn
                        self.postDeviceToken()
                    }
                }
            })
            .store(in: &bag)
    }

그리고 이렇게 구현되어 있는데, 요약하자면
login request를 날리고, message가 Not Completed SignUp Exception일 때랑 아닐 때를 구분한 것이다.


리팩토링 후 코드

우선 회원 가입 미완료일 때 응답은 다음과 같다.

{
  "status": 400,
  "divisionCode": "E009",
  "message": "Not Completed SignUp Exception",
  "data": {
    "jwtToken": {
      "accessToken":  “”,
      "refreshToken": “”,
      "refreshExpirationTime": “”
    }
  }
}

이 프로젝트에서는 status 대신 divisionCode로 에러를 구분한다.
(status 400인 경우는 많다는 뜻...)

DataSource

그래서 divisionCode가 "E009" 일 때는, APIError의 notCompletedSignUp 에러를 던져줬다.

그리고 해당 에러에는 tokenObject가 들어간다.
에러 안에 값을 넣어도 될까 했지만, 해당 에러가 나도 토큰 저장은 반드시 되어야 하기 때문에 넣어주기로 했다.

func loginWithApple(_ object: AppleUserRequestObject) -> AnyPublisher<AppleUserResponseObject, APIError> {

    provider.requestPublisher(.loginWithApple(object))
        .tryMap { response in
            let decodedResponse = try JSONDecoder().decode(GeneralResponse<AppleUserResponseObject>.self, from: response.data)

            if let divisionCode = decodedResponse.divisionCode,
               let tokens = decodedResponse.data?.jwtToken,
               divisionCode == "E009" {
                let tokens: TokenObject = tokens

                throw APIError.notCompletedSignUp(token: tokens)
            }

            return decodedResponse
        }
        .compactMap { $0.data }
        .mapError { error in
            if let apiError = error as? APIError {
                apiError
            } else {
                APIError.error(error)
            }
        }
        .eraseToAnyPublisher()
}

Repository

나는 Repository를 domain과 data layer 사이를 매핑해주는 계층으로 정의했다.

그래서 DTO를 알맞은 모델로 매핑해 주고, 에러도 Domain에서 사용할 WoteError 로 매핑했다.

따라서 일단 성공 시에는 받은 object를 domain에 필요한 모델로 매핑해 줬다.

그리고 APIError.notCompletedSignUpWoteError.notCompletedSignUp 으로 매핑해 줬다.

도메인에서도 해당 에러에 발생 시에는 프로필 설정으로 이동하도록 하기 위해서이다.

final class AuthRepository: AuthRepositoryType {

    private let authDataSource: AuthDataSourceType

    init(authDataSource: AuthDataSourceType) {
        self.authDataSource = authDataSource
    }

    func loginWithApple(_ authorizationCode: String) -> AnyPublisher<User, WoteError> {
        let object: AppleUserRequestObject = .init(code: authorizationCode, state: "APPLE")

        return authDataSource.loginWithApple(object)
            .map { object in
                let user: User = .init(
                    authenticationState: .authenticated,
                    tokens: object.jwtToken.toToken())

                return user
            }
            .mapError { error -> WoteError in
                switch error {
                case let .notCompletedSignUp(tokenObject):
                    .notCompletedSignUp(token: tokenObject.toToken())

                default:
                    WoteError.error(error)
                }
            }
            .catch { error -> AnyPublisher<User, WoteError> in
                switch error {
                case let .notCompletedSignUp(tokens):
                    let user: User = .init(
                        authenticationState: .notCompletedSetting,
                        tokens: tokens)

                    return Just(user)
                        .setFailureType(to: WoteError.self)
                        .eraseToAnyPublisher()

                default:
                    return Fail(error: WoteError.authenticateFailed)
                        .eraseToAnyPublisher()
                }
            }
            .eraseToAnyPublisher()
    }
}

그리고 Catch 에서 notCompletedSignUp 일 때, 회원가입 미완료 에러와 토큰을 담아 Publisher 방출.
다른 에러일 때는 WoteError.authenticateFailed 로그인 실패를 방출했다.


UseCase

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

        return authRepository.loginWithApple(code)
            .handleEvents(receiveOutput: { user in
                KeychainManager.shared.save(key: TokenType.accessToken, token: user.tokens.accessToken)
                KeychainManager.shared.save(key: TokenType.refreshToken, token: user.tokens.refreshToken)
            })
            .map { $0.authenticationState }
            .eraseToAnyPublisher()
    }

Repository를 호출하여 토큰을 저장한다.
회원인지, 회원가입 미완료 상태인지로 매핑

ViewModel

        case let .appleLoginHandler(result):
            isLoading = true

            switch result {
            case let .success(authorization):
                authUseCase.loginWithApple(authorization)
                    .sink { [weak self] completion in
                        self?.isLoading = false

                        if case .failure(_) = completion {
                            self?.showErrorAlert.toggle()
                        }

                    } receiveValue: { [weak self] authState in

                        self?.isLoading = false

                        guard authState == .notCompletedSetting else {
                            self?.isLoggedIn = true
                            return
                        }

                        self?.showSheet = true
                    }
                    .store(in: &cancellables)

viewModel에서는 값을 받아 회원가입 상태일 때는 바로 로그인되도록,
회원가입 미완료 상태일 때는 프로필 설정으로 넘어가도록 함.

fail됐을 때는 로그인 실패 alert 띄우기.


닉네임 유효성 검사 API 연결

같은 방식으로 닉네임 유효성 검사를 구현했다.

private let provider = MoyaProvider<UserAPI>(session: Session(interceptor: AuthInterceptor()))

이때 session 으로 저번에 만든 인터셉터를 시험으로 넣어봤다.
provider 의존성 문제 해결이 시급하다.....


0개의 댓글

관련 채용 정보