포스트

Final (19)

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

배포 전에 여러 자료를 찾아보다가, Apple 로그인을 쓰는 앱은 회원 탈퇴 때 Apple 쪽 토큰까지 지워야 한다는 걸 알게 됐다. 오늘은 그 토큰 처리를 하고, 화면 곳곳에 색이 빠져 있던 부분을 정리했다.


Apple 로그인 토큰 처리

보안을 중요하게 보는 Apple 정책상, Apple 로그인을 쓰는 앱은 회원 탈퇴 때 Apple 이 발급한 토큰을 반드시 소멸(revoke)시켜야 한다. 계정 삭제 안내 Docs에 이 내용이 있다.

그동안은 탈퇴할 때 Firebase Auth 의 유저와 Realtime Database 의 정보만 지우고 있었다. 이러면 앱에서는 계정이 사라져도, Apple 쪽에는 “이 Apple ID 가 이 앱에 로그인했다”는 연결이 그대로 남는다. 설정 앱의 “Apple로 로그인” 목록에도 계속 보인다.

참고글을 보면서 구현했고, 흐름은 이렇다.

  1. 로그인할 때 받은 authorizationCode 로 Apple 서버에서 refresh token 을 받아 저장해 둔다
  2. 탈퇴할 때 저장해 둔 refresh token 을 revoke API 로 보내 소멸시킨다
  3. 1, 2 모두 client_secret 이 필요한데, 이건 JWT 로 직접 만들어야 한다

Apple 이 주는 토큰이란?

Apple 로그인에는 성격이 다른 값이 여러 개 나온다. 놀이공원에 비유하면 이렇다.

  • authorizationCode: 매표소에서 한 번만 바꿀 수 있는 교환권이다. 5분 안에 한 번만 쓸 수 있다
  • access token: 교환권으로 받은 하루짜리 입장권이다. 금방 만료된다
  • refresh token: 연간 회원권이다. 이걸 내밀면 새 입장권을 계속 받을 수 있다

revoke 는 이 연간 회원권을 반납하는 일이다. 회원권을 반납해야 Apple 이 “이 사용자와 앱의 연결이 끊겼다”고 처리한다. 그래서 탈퇴할 때 쓸 refresh token 을 로그인 시점에 미리 받아서 저장해 둬야 했다.


JWT란?

JWT(JSON Web Token)는 “누가, 누구에게, 언제까지 유효한” 같은 내용을 JSON 으로 적고, 비밀키로 서명한 문자열이다. 도장이 찍힌 위임장이라고 생각하면 된다. 받는 쪽은 내용을 읽고, 도장(서명)이 진짜인지 확인한다.

Apple 서버에 토큰을 요청하거나 revoke 할 때는 고정된 비밀번호 대신, 개발자 계정에서 받은 .p8 키로 서명한 JWT 를 client_secret 으로 보낸다. 위임장에 들어가는 내용은 이렇다.

필드들어가는 값
issApple Team ID (누가 발급했는지)
iat발급 시각
exp만료 시각 (최대 6개월)
audhttps://appleid.apple.com (누구에게 보내는지)
sub앱의 Bundle ID

자세한 규칙은 client secret 만들기 Docs에 있다.


토큰 응답 모델

Apple 서버가 돌려주는 JSON 을 받기 위한 모델을 먼저 만들었다.

1
2
3
4
5
6
7
8
9
import Foundation

struct AppleTokenResponse: Codable {
    let access_token: String?
    let expires_in: Int?
    let id_token: String?
    let refresh_token: String?
    let token_type: String?
}

Apple 이 보내는 key 이름이 snake_case 라서 property 이름도 그대로 맞췄다. 여기서 실제로 쓰는 건 refresh_token 하나다.


makeJWT로 client_secret 만들기

JWT 는 Swift-JWT 라이브러리로 만들었다.

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
33
34
35
36
37
38
39
40
41
42
43
44
import SwiftJWT

// client_secret 생성
func makeJWT() -> String {
    let myHeader = Header(kid: "KEY_ID") // Apple Key ID

    struct MyClaims: Claims {
        let iss: String
        let iat: Int
        let exp: Int
        let aud: String
        let sub: String
    }

    let nowDate = Date()
    let iat = Int(nowDate.timeIntervalSince1970)
    let exp = iat + 3600 // 1 hour expiration

    let myClaims = MyClaims(
        iss: "TEAM_ID", // Apple Team ID
        iat: iat,
        exp: exp,
        aud: "https://appleid.apple.com",
        sub: "com.TeamSwiftbreakers.TteoPpoKki4U" // App Bundle ID
    )

    var myJWT = JWT(header: myHeader, claims: myClaims)

    // Load the private key from .p8 file
    guard let url = Bundle.main.url(forResource: "AuthKey_KEY_ID", withExtension: "p8") else {
        print("Failed to find the .p8 file.")
        return ""
    }

    do {
        let privateKey = try Data(contentsOf: url)
        let jwtSigner = JWTSigner.es256(privateKey: privateKey)
        let signedJWT = try myJWT.sign(using: jwtSigner)
        return signedJWT
    } catch {
        print("Failed to sign JWT: \(error)")
        return ""
    }
}

Header(kid:) 에는 .p8 키의 Key ID 를 넣고, Claims 를 따르는 구조체에 위 표의 내용을 채운다. 앱 번들에 넣어 둔 .p8 파일을 읽어서 JWTSigner.es256 으로 서명하면 client_secret 문자열이 나온다. 만료는 넉넉할 필요가 없어서 1시간으로 잡았다.


로그인할 때 refresh token 받아 두기

기존 saveApple 은 Firebase 로그인이 성공하면 바로 completion(.success(result)) 를 불렀다. 이제는 그 사이에 refresh token 을 받아서 저장하는 과정을 넣었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Auth.auth().signIn(with: credential) { result, error in
    if let error = error {
        completion(.failure(error))
    } else {
        // authorizationCode를 사용하여 refreshToken을 가져와서 저장
        if let code = appleCredential.authorizationCode, let codeString = String(data: code, encoding: .utf8) {
            self.getAppleRefreshToken(code: codeString) { refreshToken in
                guard let refreshToken = refreshToken else {
                    completion(.failure(NSError(domain: "AppleSignIn", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch Apple refresh token"])))
                    return
                }
                UserDefaults.standard.set(refreshToken, forKey: "appleRefreshToken")
                completion(.success(result))
            }
        } else {
            completion(.failure(NSError(domain: "AppleSignIn", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to fetch authorization code"])))
        }
    }
}

authorizationCode 는 Data 로 들어오기 때문에 문자열로 바꿔서 넘긴다. 받은 refresh token 은 UserDefaults(기기에 작은 값을 저장하는 칸)에 넣어 두었다.

실제 요청은 getAppleRefreshToken 이 한다.

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
33
34
35
36
37
38
39
func getAppleRefreshToken(code: String, completionHandler: @escaping (String?) -> Void) {
    let clientSecret = self.makeJWT()
    guard let url = URL(string: "https://appleid.apple.com/auth/token") else {
        completionHandler(nil)
        return
    }

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    let bodyParameters = [
        "client_id": "com.TeamSwiftbreakers.TteoPpoKki4U",
        "client_secret": clientSecret,
        "code": code,
        "grant_type": "authorization_code"
    ]

    request.httpBody = bodyParameters
        .map { "\($0.key)=\($0.value)" }
        .joined(separator: "&")
        .data(using: .utf8)
    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        guard let data = data, error == nil else {
            completionHandler(nil)
            return
        }

        do {
            let decoder = JSONDecoder()
            let response = try decoder.decode(AppleTokenResponse.self, from: data)
            completionHandler(response.refresh_token)
        } catch {
            completionHandler(nil)
        }
    }

    task.resume()
}

https://appleid.apple.com/auth/token 에 grant_type=authorization_code 로 교환권을 보내면 refresh token 이 담긴 JSON 이 돌아온다. body 는 JSON 이 아니라 key=value&key=value 형태(form-urlencoded)로 보내야 한다. 요청 형식은 토큰 발급 Docs에 있다.


탈퇴할 때 revoke 하기

revoke 요청은 Alamofire 로 보냈다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func revokeAppleToken(clientSecret: String, token: String, completionHandler: @escaping () -> Void) {
    let url = "https://appleid.apple.com/auth/revoke?client_id=com.TeamSwiftbreakers.TteoPpoKki4U&client_secret=\(clientSecret)&token=\(token)&token_type_hint=refresh_token"
    let header: HTTPHeaders = ["Content-Type": "application/x-www-form-urlencoded"]

    AF.request(url,
               method: .post,
               headers: header)
    .validate(statusCode: 200..<600)
    .responseData { response in
        guard let statusCode = response.response?.statusCode else { return }
        if statusCode == 200 {
            print("애플 토큰 삭제 성공!")
            completionHandler()
        } else {
            print("애플 토큰 삭제 실패, 상태 코드: \(statusCode)")
        }
    }
}

token_type_hint=refresh_token 으로 “보내는 게 refresh token 이다”라고 알려준다. 성공하면 200 이 오고, 그때만 다음 단계로 넘어간다.

그리고 탈퇴 로직을 SettingViewController 에서 SignManager 로 옮겼다. 기존에는 VC 안에서 user.delete 를 바로 불렀는데, 이제는 로그인한 provider 를 보고 Apple 이면 revoke 를 먼저 한다.

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
func deleteCurrentUser(completion: @escaping (Result<Void, Error>) -> Void) {
    if let user = Auth.auth().currentUser {
        for provider in user.providerData {
            switch provider.providerID {
            case "apple.com":
                signOutApple { error in
                    if let error = error {
                        completion(.failure(error))
                    } else {
                        guard let refreshToken = UserDefaults.standard.string(forKey: "appleRefreshToken") else {
                            completion(.failure(NSError(domain: "AppleSignOut", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to get refresh token"])))
                            return
                        }
                        let clientSecret = self.makeJWT()
                        self.revokeAppleToken(clientSecret: clientSecret, token: refreshToken) {
                            self.deleteUserFromDatabase(uid: user.uid, completion: completion)
                        }
                    }
                }
            case "google.com":
                signOutGoogle { error in
                    if let error = error {
                        completion(.failure(error))
                    } else {
                        self.deleteUserFromDatabase(uid: user.uid, completion: completion)
                    }
                }
            default:
                break
            }
        }
    } else {
        let error = NSError(domain: "FirebaseService", code: -1, userInfo: [NSLocalizedDescriptionKey: "로그인된 사용자가 없습니다."])
        completion(.failure(error))
    }
}

private func deleteUserFromDatabase(uid: String, completion: @escaping (Result<Void, Error>) -> Void) {
    let ref = Database.database().reference()
    ref.child("users").child(uid).removeValue { error, _ in
        if let error = error {
            completion(.failure(error))
        } else {
            if let user = Auth.auth().currentUser {
                user.delete { error in
                    if let error = error {
                        completion(.failure(error))
                    } else {
                        completion(.success(()))
                    }
                }
            } else {
                completion(.failure(NSError(domain: "FirebaseService", code: -1, userInfo: [NSLocalizedDescriptionKey: "로그인된 사용자가 없습니다."])))
            }
        }
    }
}

providerData 에는 이 유저가 어떤 방식으로 로그인했는지가 들어 있어서, providerID 가 "apple.com" 인지 "google.com" 인지로 나눴다. 순서는 revoke, Realtime Database 정보 삭제, Firebase Auth 유저 삭제 순이다. 로그아웃(signOutCurrentUser)의 Apple 분기에도 같은 revoke 를 넣었다.

SettingViewController 는 ViewModel 의 deleteUserAccount 만 부르고, 성공하면 로그인 화면으로 보낸다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
alert.addAction(UIAlertAction(title: "확인", style: .destructive, handler: { _ in
    //firebase 회원탈퇴
    self.viewModel.deleteUserAccount { result in
        switch result {
        case .success:
            let scene = UIApplication.shared.connectedScenes.first
            if let sd: SceneDelegate = (scene?.delegate as? SceneDelegate) {
                sd.switchToGreetingViewController()
            }
        case .failure(let error):
            // 오류 처리
            print("회원 탈퇴 오류: \(error)")
        }
    }
}))

지금 보면 아쉬운 점이 두 가지 있다. .p8 키는 원래 서버에 두고 서버에서 client_secret 을 만들어야 하는데, 서버가 없어서 앱 번들에 넣었다. 그리고 revoke 요청은 parameter 를 URL 에 붙였는데, revoke Docs 처럼 getAppleRefreshToken 과 같이 body 에 넣는 게 맞다. 당시에는 200 이 와서 그대로 두었다.

Google 로그인 탈퇴는 이 시점에는 로그아웃 후 바로 삭제하는 구조였고, 이게 다음 글의 재인증 문제로 이어진다.


색이 빠진 화면 정리

전체 VC 를 하나씩 열어보면서 BackgroundColor, TextColor, TabBar Color 가 지정되지 않은 곳을 찾아서 모두 채웠다.

색을 따로 지정하지 않은 UIKit view 는 system 기본색을 따라간다. 그래서 기기가 다크 모드면 배경은 검게, 글씨는 희게 바뀐다. 우리 앱은 흰 배경에 검은 글씨를 기준으로 디자인했기 때문에, 색을 빠뜨린 곳만 다크 모드에서 글씨가 안 보이거나 배경이 검게 나왔다.

39개 파일을 고쳤는데, 대부분 아래처럼 색을 명시하는 한 줄이다.

1
2
3
label.textColor = .black
tableView.backgroundColor = .white
cell.backgroundColor = .white

TabBar 는 backgroundColor 에 더해 barTintColor 도 흰색으로 지정했다.

1
2
3
tabbarController.tabBar.backgroundColor = .white
tabbarController.tabBar.barTintColor = .white
tabbarController.tabBar.tintColor = ThemeColor.mainOrange

입력창은 앞에서 만든 CustomTextField, CustomTextView 에서 한 번에 처리했다. 배경색과 함께 회색 테두리도 넣어서, 흰 배경 위에서도 입력칸이 구분되게 했다.

1
2
3
4
backgroundColor = .white
layer.borderWidth = 1
layer.cornerRadius = 10
layer.borderColor = #colorLiteral(red: 0.7364070391, green: 0.7364070391, blue: 0.7364070391, alpha: 1).cgColor

LaunchScreen 의 배경도 systemBackgroundColor 에서 흰색으로 바꿨다. 이것도 다크 모드에서는 검게 나오던 부분이다.

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