카테고리 없음

Troubleshooting: 좋아요 동시성 이슈 해결

ghnn 2025. 9. 16. 18:16

문제 정의

  • 구조: 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 직접 수정 금지
  • 운영 보정 기능: 단건 재계산(관리자 호출형) 도입 검토