Skip to content

Commit a238b36

Browse files
authored
[#609] 서비스 레이어의 Firebase 작업을 트랜잭션화한다 (#642)
* refactor: 사용자 upsert에 Firestore transaction 적용 * refactor: 알림 읽음 토글에 Firestore transaction 적용 * refactor: Apple refresh token 삭제에 Firestore transaction 적용 * chore: 커밋 메시지 확인 지침을 보강 * refactor: Firestore transaction 비동기 처리 정리 * refactor: 알림 트랜잭션 에러 처리 흐름 정리
1 parent 477eb2e commit a238b36

5 files changed

Lines changed: 156 additions & 118 deletions

File tree

.hermes/skills/devlog-architecture-harness/references/devlog-workflow-rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ This reference holds DevLog-specific working rules that should live with the pro
4545

4646
- If the user says they will commit or asks only for a commit message, provide commit-message guidance instead of committing.
4747
- Before proposing a commit message, inspect the actual diff and recent `git log`.
48+
- When recent history contains GitHub merge commits, do not infer commit-message style from merge subjects such as `[#123] ... (#456)`. Open the merge commit with `git show --no-patch --format=full <merge-commit>` and use the individual commit messages in the body, or inspect nearby non-merge commits.
4849
- Match the repository's current Korean style and prefix pattern.
4950
- If the user explicitly specifies a prefix or noun-phrase ending, follow it exactly.
5051
- For broad architecture refactors, split commits by layer when the user asks for staged commits.

Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,12 +274,7 @@ final class PushNotificationServiceImpl: PushNotificationService {
274274
throw FirestoreError.dataNotFound("notification")
275275
}
276276

277-
guard let currentValue = document.data()["isRead"] as? Bool else {
278-
logger.error("isRead not found for notification: \(document.documentID)")
279-
throw FirestoreError.dataNotFound("isRead")
280-
}
281-
282-
try await document.reference.updateData(["isRead": !currentValue])
277+
try await toggleReadValue(for: document.reference)
283278
logger.info("Successfully toggled notification read")
284279
} catch {
285280
logger.error("Failed to toggle notification read", error: error)
@@ -302,6 +297,24 @@ private extension PushNotificationServiceImpl {
302297
Self.record(error, code: code)
303298
}
304299

300+
func toggleReadValue(for notificationRef: DocumentReference) async throws {
301+
_ = try await store.runTransaction { transaction, errorPointer in
302+
do {
303+
let snapshot = try transaction.getDocument(notificationRef)
304+
guard let currentValue = snapshot.data()?["isRead"] as? Bool else {
305+
throw FirestoreError.dataNotFound("isRead")
306+
}
307+
308+
transaction.updateData(["isRead": !currentValue], forDocument: notificationRef)
309+
} catch let error as NSError {
310+
errorPointer?.pointee = error
311+
return nil
312+
}
313+
314+
return nil
315+
}
316+
}
317+
305318
func makeQuery(
306319
uid: String,
307320
query: PushNotificationQuery

Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -197,15 +197,8 @@ final class AppleAuthenticationServiceImpl: AuthenticationService {
197197

198198
let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens))
199199

200-
logger.info("Starting Apple token document fetch for unlink. uid: \(uid)")
201-
let doc = try await tokensRef.getDocument()
202-
203-
if doc.exists {
204-
logger.info("Starting Apple refresh token deletion from Firestore for unlink. uid: \(uid)")
205-
try await tokensRef.updateData([
206-
"appleRefreshToken": FieldValue.delete()
207-
])
208-
}
200+
logger.info("Starting Apple refresh token deletion from Firestore for unlink. uid: \(uid)")
201+
try await deleteAppleRefreshToken(from: tokensRef)
209202

210203
logger.info("Starting Firebase Apple provider unlink. uid: \(uid)")
211204
_ = try await user?.unlink(fromProvider: providerID.rawValue)
@@ -337,6 +330,28 @@ final class AppleAuthenticationServiceImpl: AuthenticationService {
337330
}
338331

339332
private extension AppleAuthenticationServiceImpl {
333+
func deleteAppleRefreshToken(from tokensRef: DocumentReference) async throws {
334+
_ = try await store.runTransaction { transaction, errorPointer in
335+
let snapshot: DocumentSnapshot
336+
337+
do {
338+
snapshot = try transaction.getDocument(tokensRef)
339+
} catch let error as NSError {
340+
errorPointer?.pointee = error
341+
return nil
342+
}
343+
344+
if snapshot.exists {
345+
transaction.updateData(
346+
["appleRefreshToken": FieldValue.delete()],
347+
forDocument: tokensRef
348+
)
349+
}
350+
351+
return nil
352+
}
353+
}
354+
340355
private static func record(_ error: Error, code: CrashlyticsError.Code) {
341356
FirebaseCrashlyticsHelper.record(
342357
error,

Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift

Lines changed: 39 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -348,66 +348,57 @@ private extension TodoServiceImpl {
348348
for todoRef: DocumentReference,
349349
counterRef: DocumentReference
350350
) async throws {
351-
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
352-
store.runTransaction({ transaction, errorPointer in
353-
let todoSnapshot: DocumentSnapshot
351+
_ = try await store.runTransaction { transaction, errorPointer in
352+
let todoSnapshot: DocumentSnapshot
353+
354+
do {
355+
todoSnapshot = try transaction.getDocument(todoRef)
356+
} catch let error as NSError {
357+
errorPointer?.pointee = error
358+
return nil
359+
}
360+
361+
var todoData = data
362+
363+
if !todoSnapshot.exists {
364+
let counterSnapshot: DocumentSnapshot
354365

355366
do {
356-
todoSnapshot = try transaction.getDocument(todoRef)
367+
counterSnapshot = try transaction.getDocument(counterRef)
357368
} catch let error as NSError {
358369
errorPointer?.pointee = error
359370
return nil
360371
}
361372

362-
var todoData = data
363-
364-
if !todoSnapshot.exists {
365-
let counterSnapshot: DocumentSnapshot
366-
367-
do {
368-
counterSnapshot = try transaction.getDocument(counterRef)
369-
} catch let error as NSError {
370-
errorPointer?.pointee = error
371-
return nil
372-
}
373-
374-
let nextNumberValue = counterSnapshot.data()?[CounterFieldKey.nextNumber.rawValue]
375-
let nextNumber: Int
376-
377-
if let storedNextNumber = nextNumberValue as? Int {
378-
nextNumber = storedNextNumber
379-
} else if counterSnapshot.exists {
380-
errorPointer?.pointee = NSError(
381-
domain: "TodoServiceImpl",
382-
code: 1,
383-
userInfo: [NSLocalizedDescriptionKey: "Todo counter is invalid."]
384-
)
385-
return nil
386-
} else {
387-
nextNumber = 1
388-
}
373+
let nextNumberValue = counterSnapshot.data()?[CounterFieldKey.nextNumber.rawValue]
374+
let nextNumber: Int
389375

390-
todoData[TodoFieldKey.number.rawValue] = nextNumber
391-
transaction.setData(
392-
[
393-
CounterFieldKey.nextNumber.rawValue: nextNumber + 1,
394-
CounterFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp()
395-
],
396-
forDocument: counterRef,
397-
merge: true
376+
if let storedNextNumber = nextNumberValue as? Int {
377+
nextNumber = storedNextNumber
378+
} else if counterSnapshot.exists {
379+
errorPointer?.pointee = NSError(
380+
domain: "TodoServiceImpl",
381+
code: 1,
382+
userInfo: [NSLocalizedDescriptionKey: "Todo counter is invalid."]
398383
)
384+
return nil
385+
} else {
386+
nextNumber = 1
399387
}
400388

401-
transaction.setData(todoData, forDocument: todoRef, merge: true)
402-
return nil
403-
}) { _, error in
404-
if let error {
405-
continuation.resume(throwing: error)
406-
return
407-
}
408-
409-
continuation.resume(returning: ())
389+
todoData[TodoFieldKey.number.rawValue] = nextNumber
390+
transaction.setData(
391+
[
392+
CounterFieldKey.nextNumber.rawValue: nextNumber + 1,
393+
CounterFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp()
394+
],
395+
forDocument: counterRef,
396+
merge: true
397+
)
410398
}
399+
400+
transaction.setData(todoData, forDocument: todoRef, merge: true)
401+
return nil
411402
}
412403
}
413404

Application/DevLogInfra/Sources/Service/UserServiceImpl.swift

Lines changed: 73 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,6 @@ final class UserServiceImpl: UserService {
3636
}
3737

3838
do {
39-
let userRef = store.document(FirestorePath.user(user.uid))
40-
let infoRef = store.document(FirestorePath.userData(user.uid, document: .info))
41-
let tokensRef = store.document(FirestorePath.userData(user.uid, document: .tokens))
42-
let settingsRef = store.document(FirestorePath.userData(user.uid, document: .settings))
43-
let todoCounterRef = store.document(FirestorePath.counter(user.uid, document: .todo))
44-
4539
// 사용자 기본 정보
4640
var userField: [String: Any] = [
4741
"currentProvider": response.providerID
@@ -62,64 +56,22 @@ final class UserServiceImpl: UserService {
6256
userField["appleName"] = user.displayName
6357
}
6458

65-
let userDocument = try await userRef.getDocument()
66-
if !userDocument.exists {
67-
userField["statusMsg"] = ""
68-
userField["createdAt"] = FieldValue.serverTimestamp()
69-
}
70-
71-
var settingField: [String: Any] = [:]
59+
var tokenField: [String: Any] = [:]
7260

7361
if let fcmToken = response.fcmToken {
74-
settingField["fcmToken"] = fcmToken
62+
tokenField["fcmToken"] = fcmToken
7563
}
7664

7765
// 깃헙 로그인 시 추가 정보 저장
7866
if response.providerID == "github.com", let accessToken = response.accessToken {
79-
settingField["githubAccessToken"] = accessToken
67+
tokenField["githubAccessToken"] = accessToken
8068
}
8169

82-
// Reference to capture ~ in concurrently-executing code; Swift 6 lang mode의 경고 해결
83-
let userFieldSnapshot = userField
84-
let settingFieldSnapshot = settingField
85-
// -----------------------------------------------------
86-
87-
async let userUpdate: Void = userRef.setData(
88-
["updatedAt": FieldValue.serverTimestamp()],
89-
merge: true
70+
try await upsertUserDocuments(
71+
uid: user.uid,
72+
userField: userField,
73+
tokenField: tokenField
9074
)
91-
async let infoUpdate: Void = infoRef.setData(userFieldSnapshot, merge: true)
92-
async let tokensUpdate: Void = {
93-
guard !settingFieldSnapshot.isEmpty else { return }
94-
try await tokensRef.setData(settingFieldSnapshot, merge: true)
95-
}()
96-
97-
let settingsDocument = try await settingsRef.getDocument()
98-
var settingsField: [String: Any] = [
99-
"timeZone": TimeZone.autoupdatingCurrent.identifier
100-
]
101-
if !settingsDocument.exists {
102-
settingsField["allowPushNotification"] = true
103-
settingsField["pushNotificationHour"] = 9
104-
settingsField["pushNotificationMinute"] = 0
105-
}
106-
107-
let settingsFieldSnapshot = settingsField
108-
async let settingsUpdate: Void = settingsRef.setData(settingsFieldSnapshot, merge: true)
109-
async let todoCounterUpdate: Void? = { // 옵셔널이 포함된 이유: 신규 사용자일 때만 할 작업
110-
guard !userDocument.exists else { return nil }
111-
112-
try await todoCounterRef.setData(
113-
[
114-
"nextNumber": 1,
115-
"updatedAt": FieldValue.serverTimestamp()
116-
],
117-
merge: true
118-
)
119-
return nil
120-
}()
121-
122-
_ = try await (userUpdate, infoUpdate, tokensUpdate, settingsUpdate, todoCounterUpdate)
12375

12476
logger.info("Successfully upserted user: \(user.uid)")
12577
} catch {
@@ -241,4 +193,70 @@ private extension UserServiceImpl {
241193
private func record(_ error: Error, code: CrashlyticsError.Code) {
242194
Self.record(error, code: code)
243195
}
196+
197+
func upsertUserDocuments(
198+
uid: String,
199+
userField: [String: Any],
200+
tokenField: [String: Any]
201+
) async throws {
202+
let userRef = store.document(FirestorePath.user(uid))
203+
let infoRef = store.document(FirestorePath.userData(uid, document: .info))
204+
let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens))
205+
let settingsRef = store.document(FirestorePath.userData(uid, document: .settings))
206+
let todoCounterRef = store.document(FirestorePath.counter(uid, document: .todo))
207+
208+
_ = try await store.runTransaction { transaction, errorPointer in
209+
let userDocument: DocumentSnapshot
210+
let settingsDocument: DocumentSnapshot
211+
212+
do {
213+
userDocument = try transaction.getDocument(userRef)
214+
settingsDocument = try transaction.getDocument(settingsRef)
215+
} catch let error as NSError {
216+
errorPointer?.pointee = error
217+
return nil
218+
}
219+
220+
var infoField = userField
221+
if !userDocument.exists {
222+
infoField["statusMsg"] = ""
223+
infoField["createdAt"] = FieldValue.serverTimestamp()
224+
}
225+
226+
var settingsField: [String: Any] = [
227+
"timeZone": TimeZone.autoupdatingCurrent.identifier
228+
]
229+
if !settingsDocument.exists {
230+
settingsField["allowPushNotification"] = true
231+
settingsField["pushNotificationHour"] = 9
232+
settingsField["pushNotificationMinute"] = 0
233+
}
234+
235+
transaction.setData(
236+
["updatedAt": FieldValue.serverTimestamp()],
237+
forDocument: userRef,
238+
merge: true
239+
)
240+
transaction.setData(infoField, forDocument: infoRef, merge: true)
241+
242+
if !tokenField.isEmpty {
243+
transaction.setData(tokenField, forDocument: tokensRef, merge: true)
244+
}
245+
246+
transaction.setData(settingsField, forDocument: settingsRef, merge: true)
247+
248+
if !userDocument.exists {
249+
transaction.setData(
250+
[
251+
"nextNumber": 1,
252+
"updatedAt": FieldValue.serverTimestamp()
253+
],
254+
forDocument: todoCounterRef,
255+
merge: true
256+
)
257+
}
258+
259+
return nil
260+
}
261+
}
244262
}

0 commit comments

Comments
 (0)