-
Notifications
You must be signed in to change notification settings - Fork 0
feat: FCM 관련 서버 스펙 수정사항 반영 #259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,4 +4,5 @@ import Foundation | |
|
|
||
| struct UpsertFCMTokenRequestDTO: Encodable { | ||
| let fcmToken: String | ||
| let deviceId: String | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| // Copyright © 2025 Booket. All rights reserved | ||
|
|
||
| public protocol DeviceIDProvider { | ||
| var deviceID: String? { get } | ||
| func clearCache() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| // Copyright © 2025 Booket. All rights reserved | ||
|
|
||
| import BKDomain | ||
| import Combine | ||
|
|
||
| public protocol DeviceIDStore { | ||
| func getOrCreate() -> AnyPublisher<String, TokenError> | ||
| func clear() -> AnyPublisher<Void, TokenError> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| // Copyright © 2025 Booket. All rights reserved | ||
|
|
||
| import BKCore | ||
| import BKData | ||
| import OSLog | ||
|
|
||
| public final class KeychainDeviceIDProvider: DeviceIDProvider { | ||
| private let storage: KeyValueStorage | ||
| private var cachedDeviceID: String? | ||
|
|
||
| public init(storage: KeyValueStorage) { | ||
| self.storage = storage | ||
| } | ||
|
|
||
| public var deviceID: String? { | ||
| if let cachedDeviceID { | ||
| return cachedDeviceID | ||
| } | ||
| do { | ||
| let id: String = try storage.load(for: StorageKeys.deviceIDKey) | ||
| self.cachedDeviceID = id | ||
| return id | ||
| } catch { | ||
| Log.error("Failed to load deviceID: \(error)", logger: AppLogger.storage) | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| public func clearCache() { | ||
| cachedDeviceID = nil | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| // Copyright © 2025 Booket. All rights reserved | ||
|
|
||
| import BKData | ||
| import BKDomain | ||
| import Combine | ||
| import Foundation | ||
|
|
||
| public struct KeychainDeviceIDStore: DeviceIDStore { | ||
| private let storage: KeyValueStorage | ||
|
|
||
| /// AppDelegate나 다른 곳에서 DI 없이 사용할 수 있도록 shared instance 제공 | ||
| public static let shared = KeychainDeviceIDStore(storage: KeychainKeyValueStorage()) | ||
|
|
||
| public init(storage: KeyValueStorage) { | ||
| self.storage = storage | ||
| } | ||
|
|
||
| /// 디바이스 ID를 가져오거나, 없으면 새로 생성하여 저장 | ||
| /// 키체인에 저장되므로 앱 삭제 후 재설치해도 유지됨 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 부정확한 주석: 키체인의 앱 재설치 시 동작 주석에서 "앱 삭제 후 재설치해도 유지됨"이라고 명시되어 있으나, 기본 키체인 항목은 앱 삭제 시 함께 삭제됩니다. 키체인 데이터를 유지하려면 별도의 접근 제어 설정(예: 주석을 수정하여 실제 동작을 정확히 반영하세요: - /// 디바이스 ID를 가져오거나, 없으면 새로 생성하여 저장
- /// 키체인에 저장되므로 앱 삭제 후 재설치해도 유지됨
+ /// 디바이스 ID를 가져오거나, 없으면 새로 생성하여 저장
+ /// 키체인에 저장되므로 앱 업데이트나 재시작 시에도 유지됨 (앱 삭제 시에는 제거됨)
🤖 Prompt for AI Agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. kSecAttrAccessGroup는 다른 이야기를 하고 있는거같은데. 이건 공유를 위해서 쓰는 키고. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query: 💡 Result: Short answer: By default, Keychain items generally survive an app uninstall and are available after reinstall — because keychain storage is kept by the OS, separate from the app sandbox. They are only removed if the device/keychain is wiped or if the items are explicitly deleted (or the keychain is reset). [1][2][3][4] Important caveats:
Sources: Apple Keychain docs and community/engineering reports. [1][2][3][4] @clxxrlove 말씀하신 것이 맞습니다. 죄송합니다. 제가 잘못된 정보를 드렸네요. 확인 결과, iOS 키체인 아이템은 기본적으로 앱 삭제 후 재설치해도 유지됩니다. 키체인 스토리지는 앱 샌드박스가 아닌 OS 레벨에서 별도로 관리되기 때문입니다. 말씀하신 대로 따라서 현재 코드의 주석 **"키체인에 저장되므로 앱 삭제 후 재설치해도 유지됨"**은 정확합니다. 제 리뷰 코멘트가 부정확했습니다. 참고사항:
지적해주셔서 감사합니다! 🙏 🧠 Learnings used |
||
| public func getOrCreate() -> AnyPublisher<String, TokenError> { | ||
| do { | ||
| if let existingID: String = try? storage.load(for: StorageKeys.deviceIDKey) { | ||
| return Just(existingID) | ||
| .setFailureType(to: TokenError.self) | ||
| .eraseToAnyPublisher() | ||
| } | ||
|
|
||
| let newDeviceID = UUID().uuidString | ||
| try storage.save(newDeviceID, for: StorageKeys.deviceIDKey) | ||
|
|
||
|
Comment on lines
+28
to
+30
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 실제 디바이스 아이디 UIDevice.current.identifierForVendor!.uuidString 이런식으로 구할 수 있는걸로 알고 있긴 한데, 이거랑 UUID랑 크게 역할이 다르지 않을까요?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 애초에 FCM 토큰 외에 deviceId를 별도로 두는 이유는 FCM은 앱 재설치 등의 변수를 통해 변할 수 있기 떄문입니다. 이러면 deviceId를 딱히 쓰는 의미는 없다고 생각하구요. (생명 주기가 같으므로) 추가로, 실제 디바이스 ID에 해당하는 IMEI나 MAC 같은 정말 PK가 될 수 있는 값들은 정책상 접근 금지이므로 이런 꼼수를 통해 deviceId를 공유한거에요. 나름 조금 알아보고 했는데, 혹시라도 제 의견이 틀렸거나 다른 의견이 있으시면 말씀해주세요. 개선 방향이 있는지 더 알아볼게요.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아 네 저도 일단 IDFV 생각하고 말씀드린거긴 한데, 이게 앱 재설치 했을 때 값이 변할 수 있다는 사실을 제가 몰랐었네요! 지금 방식도 충분한데, 왜 안쓰셨는지 궁금해서 여쭤봤습니다! 👍 |
||
| return Just(newDeviceID) | ||
| .setFailureType(to: TokenError.self) | ||
| .eraseToAnyPublisher() | ||
| } catch { | ||
| return Fail(error: TokenError.saveFailed(underlying: error)) | ||
| .eraseToAnyPublisher() | ||
| } | ||
| } | ||
|
Comment on lines
+20
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 레이스 컨디션: getOrCreate()의 check-then-act 패턴이 원자적이지 않음
저장소 레이어에 동기화 메커니즘을 추가하거나, 이 메서드 자체를 동기화 처리하세요: +import Foundation
+
public struct KeychainDeviceIDStore: DeviceIDStore {
private let storage: KeyValueStorage
+ private static let lock = NSLock()
/// AppDelegate나 다른 곳에서 DI 없이 사용할 수 있도록 shared instance 제공
public static let shared = KeychainDeviceIDStore(storage: KeychainKeyValueStorage())
public init(storage: KeyValueStorage) {
self.storage = storage
}
/// 디바이스 ID를 가져오거나, 없으면 새로 생성하여 저장
/// 키체인에 저장되므로 앱 삭제 후 재설치해도 유지됨
public func getOrCreate() -> AnyPublisher<String, TokenError> {
+ Self.lock.lock()
+ defer { Self.lock.unlock() }
+
do {
if let existingID: String = try? storage.load(for: StorageKeys.deviceIDKey) {
return Just(existingID)
.setFailureType(to: TokenError.self)
.eraseToAnyPublisher()
}
let newDeviceID = UUID().uuidString
try storage.save(newDeviceID, for: StorageKeys.deviceIDKey)
return Just(newDeviceID)
.setFailureType(to: TokenError.self)
.eraseToAnyPublisher()
} catch {
return Fail(error: TokenError.saveFailed(underlying: error))
.eraseToAnyPublisher()
}
}
🤖 Prompt for AI Agents |
||
|
|
||
| public func clear() -> AnyPublisher<Void, TokenError> { | ||
| do { | ||
| try storage.delete(for: StorageKeys.deviceIDKey) | ||
| return Just(()) | ||
| .setFailureType(to: TokenError.self) | ||
| .eraseToAnyPublisher() | ||
| } catch { | ||
| return Fail(error: TokenError.clearFailed(underlying: error)) | ||
| .eraseToAnyPublisher() | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
스레드 안전성 문제: 캐시 접근이 동기화되지 않음
cachedDeviceID는 여러 스레드에서 동시에 접근될 수 있지만 동기화 메커니즘이 없습니다. 특히deviceID프로퍼티에서 캐시를 읽고(line 16-18), 저장소에서 로드한 후 캐시에 쓰는(line 21) 작업이 원자적이지 않아 레이스 컨디션이 발생할 수 있습니다.다음 중 하나의 방법으로 해결하세요:
방법 1: DispatchQueue를 사용한 동기화
public final class KeychainDeviceIDProvider: DeviceIDProvider { private let storage: KeyValueStorage private var cachedDeviceID: String? + private let queue = DispatchQueue(label: "com.booket.deviceIDProvider") public init(storage: KeyValueStorage) { self.storage = storage } public var deviceID: String? { - if let cachedDeviceID { - return cachedDeviceID - } - do { - let id: String = try storage.load(for: StorageKeys.deviceIDKey) - self.cachedDeviceID = id - return id - } catch { - Log.error("Failed to load deviceID: \(error)", logger: AppLogger.storage) - return nil + return queue.sync { + if let cachedDeviceID { + return cachedDeviceID + } + do { + let id: String = try storage.load(for: StorageKeys.deviceIDKey) + self.cachedDeviceID = id + return id + } catch { + Log.error("Failed to load deviceID: \(error)", logger: AppLogger.storage) + return nil + } } } public func clearCache() { - cachedDeviceID = nil + queue.sync { + cachedDeviceID = nil + } }방법 2: actor 사용 (iOS 13+ 지원 시)
actor를 사용하면 자동으로 스레드 안전성이 보장되지만,
deviceID가 동기 프로퍼티에서 비동기로 변경되어야 합니다.