포스트

Final (20)

프로젝트 막바지에는 기능 구현에 쫓겨 블로그 정리를 제대로 하지 못했다. 그래서 이 글은 당시 GitHub 커밋 내역과, 프로젝트 기간에 직접 적어 둔 업무 내역 목록을 AI(Claude Code)에게 보여주고 그걸 바탕으로 내용을 다시 정리했다. 코드는 실제 커밋에 있던 것을 가져왔다.

로그아웃은 문제가 없는데, 회원 탈퇴만 실패하는 경우가 생겼다. 세션 문제라고 판단해서 탈퇴 전에 재인증을 하는 로직을 넣었다. 19일, 20일 이틀에 걸쳐 Google 을 먼저 고치고 Apple 을 고쳤다.


회원 탈퇴 때 재인증

Firebase Auth 는 계정 삭제, 비밀번호 변경처럼 민감한 작업을 할 때 “최근에 로그인했는지”를 본다. 로그인한 지 오래됐으면 user.delete() 가 requiresRecentLogin 에러를 내고 실패한다. 앱을 켜 두고 며칠 쓰다가 탈퇴를 누르면 딱 이 상황이 된다.

그래서 탈퇴 직전에 한 번 더 로그인을 확인받아야 한다. 이게 재인증(reauthenticate)이다. 유저 재인증 Docs에 같은 설명이 있다.


재인증이란?

은행 앱에 이미 로그인해 있어도, 송금 버튼을 누르면 비밀번호를 한 번 더 묻는다. 로그인 상태가 오래됐을 수 있으니, 지금 폰을 들고 있는 사람이 정말 본인인지 다시 확인하는 것이다.

Firebase 의 재인증도 같다. 로그인할 때 썼던 것과 같은 종류의 credential(신원 증명 값)을 다시 만들어서 reauthenticate(with:) 에 넣으면, Firebase 가 “방금 로그인했다”로 기록을 갱신한다. 그 다음에야 delete() 가 통과한다.

문제는 credential 을 어디서 다시 구하느냐다. Google 은 로그인 때 받은 토큰을 저장해 두었다가 쓰면 되고, Apple 은 매번 Apple 로그인 창을 다시 띄워서 새로 받아야 한다.


Google 재인증

Google 은 credential 을 만들 때 ID Token 과 Access Token 이 필요하다. 로그인 시점에 이 둘을 저장해 두도록 했다.

기존에는 SignViewModel 안에서 Google credential 을 만들고 Firebase 로그인까지 했는데, 이 부분을 SignManager.googleSignIn 으로 옮기면서 토큰 저장을 넣었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func googleSignIn(result: GIDSignInResult, completion: @escaping (Result<AuthDataResult?, Error>) -> Void) {
    let idToken = result.user.idToken?.tokenString ?? ""
    let accessToken = result.user.accessToken.tokenString

    if idToken.isEmpty {
        completion(.failure(NSError(domain: "GoogleSignIn", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to fetch Google tokens"])))
        return
    }

    let credential = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken)

    Auth.auth().signIn(with: credential) { result, error in
        if let error = error {
            completion(.failure(error))
        } else {
            // Google ID Token과 Access Token 저장
            UserDefaults.standard.set(idToken, forKey: "googleIDToken")
            UserDefaults.standard.set(accessToken, forKey: "googleAccessToken")
            completion(.success(result))
        }
    }
}

기존 코드는 idToken! 으로 강제 언래핑을 하고 있었는데, 비어 있으면 에러를 돌려주도록 바꿨다.

탈퇴할 때는 저장해 둔 토큰으로 credential 을 다시 만들고, 재인증이 성공하면 삭제로 넘어간다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
case "google.com":
    var credential: AuthCredential?
    if let idToken = UserDefaults.standard.string(forKey: "googleIDToken"),
       let accessToken = UserDefaults.standard.string(forKey: "googleAccessToken") {
        credential = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken)
    }

    guard let authCredential = credential else {
        let error = NSError(domain: "FirebaseService", code: -1, userInfo: [NSLocalizedDescriptionKey: "재인증을 위한 자격 증명을 가져올 수 없습니다."])
        completion(.failure(error))
        return
    }

    // 사용자 재인증
    user.reauthenticate(with: authCredential) { _, error in
        if let error = error {
            completion(.failure(error))
        } else {
            // 재인증 후 사용자 삭제 수행
            self.performDelete(user: user, completion: completion)
        }
    }

실제 삭제는 performDelete 로 따로 뺐다. 지난번에 만든 provider 별 분기(Apple 이면 revoke, Google 이면 로그아웃)와 DB 삭제가 이 안으로 들어갔다. 탈퇴가 끝나면 저장해 둔 Apple refresh token 도 UserDefaults 에서 지우도록 했다.

1
UserDefaults.standard.removeObject(forKey: "appleRefreshToken") // 회원 탈퇴 시 refresh token 삭제

Google 은 이걸로 정상 작동했다.


Apple 재인증

Apple 은 저장해 둘 수 있는 토큰으로 재인증을 할 수가 없었다. Apple 의 ID Token 은 로그인할 때마다 nonce(한 번만 쓰는 임의의 문자열)와 묶여서 새로 발급되기 때문이다. 결국 탈퇴할 때 Apple 로그인 창을 한 번 더 띄워서 새 credential 을 받는 방법을 택했다.

이렇게 하지 않으면 사용자가 직접 로그아웃하고 다시 로그인한 뒤에 탈퇴해야 해서 번거롭다. 재인증 창을 띄우는 쪽이 낫다고 판단했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func reauthenticateAppleUser(completion: @escaping (Result<AuthCredential, Error>) -> Void) {
    let provider = ASAuthorizationAppleIDProvider()
    let request = provider.createRequest()

    let nonce = signManager.randomNonceString()
    currentNonce = nonce
    UserDefaults.standard.set(nonce, forKey: "appleRawNonce")
    request.nonce = signManager.sha256(nonce)
    request.requestedScopes = [.fullName, .email]

    let authorizationController = ASAuthorizationController(authorizationRequests: [request])
    authorizationController.delegate = self
    authorizationController.presentationContextProvider = self

    self.reauthenticationCompletion = { result in
        switch result {
        case .success(let credential):
            completion(.success(credential))
        case .failure(let error):
            completion(.failure(error))
        }
    }

    authorizationController.performRequests()
}

로그인 때와 똑같이 요청을 만들어 Apple 로그인 창을 띄운다. 다른 점은 reauthenticationCompletion 에 completion 을 담아 둔다는 것이다.

Apple 로그인 결과는 로그인이든 재인증이든 같은 delegate 메서드로 들어온다. 그래서 reauthenticationCompletion 이 있으면 재인증으로, 없으면 원래 로그인으로 처리하도록 나눴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
    guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
        reauthenticationCompletion?(.failure(NSError(domain: "AppleSignIn", code: -1, userInfo: [NSLocalizedDescriptionKey: "Apple credential not found"])))
        return
    }

    // ... appleAuthorizedUserIdKey 저장 생략

    let nonce = currentNonce ?? ""

    guard let idToken = credential.identityToken, let idTokenString = String(data: idToken, encoding: .utf8) else {
        reauthenticationCompletion?(.failure(NSError(domain: "AppleSignIn", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to fetch ID token"])))
        return
    }

    if let reauthCompletion = reauthenticationCompletion {
        let appleCredential = OAuthProvider.appleCredential(withIDToken: idTokenString, rawNonce: nonce, fullName: credential.fullName)
        reauthCompletion(.success(appleCredential))
        self.reauthenticationCompletion = nil
    } else {
        signManager.saveApple(appleCredential: credential, nonce: nonce) { [weak self] result in
            // 기존 로그인 처리
        }
    }
}

오전 커밋에는 reauthenticateAppleUser 에 currentNonce = nonce 가 빠져 있었다. 이러면 창은 새 nonce 로 띄우는데 credential 은 이전 로그인 때의 nonce 로 만들어져서 짝이 맞지 않는다. 오후의 “애플 탈퇴 수정” 커밋에서 이 한 줄을 추가했다.

탈퇴 흐름은 SignViewModel.deleteUserAccount 에서 provider 별로 재인증을 하고, 성공하면 SignManager.performDelete 를 부르도록 정리했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func deleteUserAccount(completion: @escaping (Result<Void, Error>) -> Void) {
    guard let user = Auth.auth().currentUser else {
        let error = NSError(domain: "FirebaseService", code: -1, userInfo: [NSLocalizedDescriptionKey: "로그인된 사용자가 없습니다."])
        completion(.failure(error))
        return
    }

    for provider in user.providerData {
        switch provider.providerID {
        case "apple.com":
            reauthenticateAppleUser { [weak self] result in
                switch result {
                case .success(let credential):
                    user.reauthenticate(with: credential) { _, error in
                        if let error = error {
                            completion(.failure(error))
                        } else {
                            self?.signManager.performDelete(user: user, completion: completion)
                        }
                    }
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        case "google.com":
            // 위의 Google 재인증과 같은 코드
            ...
        default:
            break
        }
    }
}

순서를 정리하면 재인증, Apple 이면 refresh token revoke, DB 정보 삭제, Firebase Auth 유저 삭제다.


같이 고친 것

Apple credential 을 만들 때 fullName 에 appleCredential.fullName 을 넣던 것을 nil 로 바꿨다. Apple 은 이름을 최초 로그인 때 한 번만 주고 그 뒤로는 비어 있는 값을 주기 때문에, 굳이 넘길 이유가 없었다.

1
let credential = OAuthProvider.appleCredential(withIDToken: tokenString, rawNonce: nonce, fullName: nil)

지난번에 디버깅하려고 찍어 두었던 JWT, refresh token 출력도 주석 처리했다. 토큰이 콘솔에 그대로 찍히는 건 좋지 않다.

그리고 18일에 TabBar 에서 빠져 있던 커뮤니티 탭을 다시 넣었다.

1
tabbarController.viewControllers = [recommendVC, mapVC, communityVC, mypageVC]

이 부분은 아직 공부가 더 필요하다고 메모해 두었다. 지금 보면 Google 의 ID Token 은 1시간쯤 지나면 만료되기 때문에, 로그인할 때 저장한 토큰으로 며칠 뒤에 재인증하면 실패할 수 있다. 탈퇴 직전에 Google SDK 로 토큰을 새로 받아서 쓰는 게 더 안전하다.


폐기한 자연어처리

21일에는 자연어처리를 시도해 봤다. 약 10000개의 데이터(column)로 학습까지는 마쳤는데, 한국어 특성상 이 정도로는 부족하고 훨씬 많은 데이터셋이 필요해 보였다. 처리는 완료했지만 앱에서 꼭 필요한 기능이라고 느끼지 못해서 폐기했다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.