문제 정의
- 구조: model.like: [String] 배열 전체를 클라이언트가 수정 후 문서 업데이트
struct CommunityModel: Codable {
// ...
let like: [String]
// ...
}
- 증상:
- 좋아요 35개인 글에서 A 해제 → [A, B, C...] → [B, C...] 저장 → count = 34
- B는 여전히 35 상태에서 해제 → [A, C...] 저장 → A 다시 포함 → count = 34
- 결과: 실제 상태와 서버 기록 불일치 발생. 최신 변경 유실.
- 원인: 로컬 stale 스냅샷 기반 덮어쓰기 + 원자성 부재
원인 분석
- 로컬 상태 기반 재계산 → 최신 변경 유실
- 전체 배열 덮어쓰기 → 동시성 붕괴
- 원자성 부재 → count와 like 불일치
- 확장성 리스크 → 배열 커질수록 문서 크기 증가, Firestore 1MB 제한
대안 비교
A. 배열 유지 + 서버 제공 연산
- 방법: FieldValue.arrayUnion / arrayRemove + FieldValue.increment
- 장점: 스키마 변경 없음, 빠른 적용
- 단점: 문서 1MB 제한 지속, 대규모 시 비효율.
B. 서브컬렉션 정규화 (최종 선택 ✅)
- Schema:
/posts/{postId}
- likeCount: number
- ...other fields
/posts/{postId}/like/{userId}
- 방법: like 문서는 개별 생성/삭제. likeCount는 FieldValue.increment(Int64)로 원자적 변경
- 장점: 동시성 보장, 문서 크기 제한 해소, 네트워크 효율, 목록 페이징 용이
최종 결정
- 서브컬렉션 도입: /posts/{postId}/like/{userId}
- 집계 필드 유지: posts/{postId}.likeCount는 트랜잭션 + increment(Int64)로만 변경
- UI 갱신: 실시간 구독 기반. 로컬 패치 제거
- 중복 방지: throttle + flatMapFirst 적용
구현
토글 로직
- Int64(±1) 명시적 사용
func toggleLikeWithCount(collection: FirestoreCollection, postCode: String) -> Completable {
let db = Firestore.firestore()
guard let uid = Auth.auth().currentUser?.uid else {
return .error(FirebaseAuthError.noUser)
}
let postRef = db.collection(collection.rawValue).document(postCode)
let likeRef = postRef.collection("like").document(uid)
return Completable.create { completable in
db.runTransaction({ txn, _ -> Any? in
let likeSnap = try? txn.getDocument(likeRef)
if likeSnap?.exists == true {
txn.deleteDocument(likeRef)
txn.updateData(["likeCount": FieldValue.increment(Int64(-1))], forDocument: postRef)
} else {
txn.setData([:], forDocument: likeRef)
txn.updateData(["likeCount": FieldValue.increment(Int64(1))], forDocument: postRef)
}
return nil
}) { _, err in
err == nil ? completable(.completed) : completable(.error(err!))
}
return Disposables.create()
}
}
실시간 구독
func observeIsLiked(collection: FirestoreCollection, postCode: String, userId: String) -> Observable<Bool> {
let doc = db.collection(collection.rawValue).document(postCode).collection("like").document(userId)
return Observable.create { observer in
let listener = doc.addSnapshotListener { snapshot, _ in
observer.onNext(snapshot?.exists == true)
}
return Disposables.create { listener.remove() }
}
}
func observeLikeCount(collection: FirestoreCollection, postCode: String) -> Observable<Int> {
let doc = db.collection(collection.rawValue).document(postCode)
return Observable.create { observer in
let listener = doc.addSnapshotListener { snapshot, _ in
let value = (snapshot?.data()?["likeCount"] as? NSNumber)?.intValue ?? 0
observer.onNext(value)
}
return Disposables.create { listener.remove() }
}
}
마이그레이션(사전 배포 단계)
- 상황: 커뮤니티 기능 배포 전 버그 발견. 실제 사용자 데이터 부재.
- 결정: 데이터 이행 생략. 스키마 전환 + 초기화 전략 적용.
- 도입 방식:
- 스키마 교체 적용. /posts/{postId}/like/{userId} + likeCount 도입.
- 초기값 설정. 기존 like 배열 폐기. likeCount = 0 기본값.
- 테스트 데이터 리셋 또는 재생성.
- 검증 범위(수동):
- 다중 계정 동시 토글 재현. 경합 시 정합성 확인.
- 네트워크 오류 후 재시도 동작 확인.
- throttle + flatMapFirst 중복 요청 차단 확인.
효과
- 경합 상황 정합성 보장(트랜잭션 재시도 기반)
- 문서 크기 감소, 1MB 제한 해소
- 네트워크 효율 및 확장성 향상
추후 고려(계획 수준)
- 보안 규칙 강화: 본인 uid 일치 시 like 문서 생성/삭제 허용, likeCount 직접 수정 금지
- 운영 보정 기능: 단건 재계산(관리자 호출형) 도입 검토