어제 포스팅에서 다음과 같이 작성했었다.
레퍼런스를 겁나 찾아봤는데, 보통 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인 경우는 많다는 뜻...)
그래서 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를 domain과 data layer 사이를 매핑해주는 계층으로 정의했다.
그래서 DTO를 알맞은 모델로 매핑해 주고, 에러도 Domain에서 사용할 WoteError
로 매핑했다.
따라서 일단 성공 시에는 받은 object를 domain에 필요한 모델로 매핑해 줬다.
그리고 APIError.notCompletedSignUp
은 WoteError.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
로그인 실패를 방출했다.
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를 호출하여 토큰을 저장한다.
회원인지, 회원가입 미완료 상태인지로 매핑
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 띄우기.
같은 방식으로 닉네임 유효성 검사를 구현했다.
private let provider = MoyaProvider<UserAPI>(session: Session(interceptor: AuthInterceptor()))
이때 session 으로 저번에 만든 인터셉터를 시험으로 넣어봤다.
provider 의존성 문제 해결이 시급하다.....