# 동시 편집 전략 설계

> **작성일**: 2026-02-08 21:12
> **작성자**: Backend Agent, Data Agent
> **상태**: ✅ 검토 완료, 수정됨

---

## 1. 문제 정의

여러 사용자가 동시에 같은 문서를 편집할 때:
- 누가 편집 중인지 알 수 없음
- 저장 시 덮어쓰기로 작업 손실 가능
- 충돌 발생 시 해결 방법 없음

---

## 2. 전략 선택

### 선택지 비교

| 전략 | 장점 | 단점 | 복잡도 |
|------|------|------|--------|
| **잠금 (Locking)** | 구현 단순, 충돌 없음 | 동시 작업 불가 | ⭐ 낮음 |
| **OT (Operational Transform)** | 실시간 협업 | 구현 매우 복잡 | ⭐⭐⭐ 높음 |
| **CRDT** | 분산 환경 최적 | 구현 복잡 | ⭐⭐⭐ 높음 |

### 결정: **잠금 (Locking) 전략** ⭐

**이유**:
1. MVP 단계에서 구현 단순함 우선
2. 사내 사용자 수 적음 (충돌 빈도 낮음)
3. 나중에 OT/CRDT로 확장 가능

---

## 3. 잠금 메커니즘

### 3.1 동작 방식

```
[사용자 A 문서 열기]
    ↓
[편집 시작 버튼 클릭]
    ↓
[Firestore에 잠금 설정]
  - editingBy: userId
  - editingAt: timestamp
    ↓
[다른 사용자 B 접근 시]
  → "A님이 편집 중입니다" 표시
  → 읽기만 가능
    ↓
[A가 저장 또는 나가기]
    ↓
[잠금 해제]
  - editingBy: null
  - editingAt: null
```

### 3.2 Firestore 필드 (기존 스키마 확장)

```typescript
// /documents/{docId}
{
  // ... 기존 필드 ...
  
  // 동시 편집용
  editingBy: string | null,      // 편집 중인 사용자 ID
  editingByName: string | null,  // 편집 중인 사용자 이름 (비정규화)
  editingAt: Timestamp | null    // 편집 시작 시간
}
```

### 3.3 잠금 만료 정책

| 시나리오 | 처리 |
|----------|------|
| 정상 저장 | 잠금 해제 |
| 취소 클릭 | 잠금 해제 |
| 브라우저 닫기 | Heartbeat 실패 → 자동 해제 |
| 네트워크 끊김 | 30분 후 자동 해제 |

---

## 4. 구현 상세

### 4.1 잠금 획득

```typescript
async function acquireLock(docId: string, userId: string, userName: string): Promise<boolean> {
  const docRef = db.collection('documents').doc(docId);
  
  return db.runTransaction(async (transaction) => {
    const doc = await transaction.get(docRef);
    const data = doc.data();
    
    // 이미 다른 사용자가 편집 중
    if (data?.editingBy && data.editingBy !== userId) {
      // 30분 지났으면 강제 해제
      const editingAt = data.editingAt?.toDate();
      const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
      
      if (editingAt && editingAt < thirtyMinutesAgo) {
        // 만료된 잠금 → 새로 획득
      } else {
        return false; // 잠금 획득 실패
      }
    }
    
    // 잠금 설정
    transaction.update(docRef, {
      editingBy: userId,
      editingByName: userName,
      editingAt: Timestamp.now()
    });
    
    return true;
  });
}
```

### 4.2 잠금 해제

```typescript
async function releaseLock(docId: string, userId: string): Promise<void> {
  const docRef = db.collection('documents').doc(docId);
  
  await db.runTransaction(async (transaction) => {
    const doc = await transaction.get(docRef);
    
    // 본인 잠금만 해제 가능
    if (doc.data()?.editingBy === userId) {
      transaction.update(docRef, {
        editingBy: null,
        editingByName: null,
        editingAt: null
      });
    }
  });
}
```

### 4.3 Heartbeat (선택)

```typescript
// 5분마다 editingAt 갱신
useEffect(() => {
  if (!isEditing) return;
  
  const interval = setInterval(() => {
    updateEditingAt(docId);
  }, 5 * 60 * 1000);
  
  return () => clearInterval(interval);
}, [isEditing]);
```

---

## 5. UI/UX

### 5.1 편집 중 표시

```
┌─────────────────────────────────────┐
│ 📝 보험 용어 정리                    │
├─────────────────────────────────────┤
│ 🔒 김철수님이 편집 중 (10분 전 시작)  │
│                                     │
│ 읽기 전용 모드로 표시됩니다.          │
│                                     │
│ [새로고침] [알림 받기]               │
└─────────────────────────────────────┘
```

### 5.2 편집 모드 진입

```
[보기 모드] → [✏️ 편집] 클릭 → [편집 모드]
                  ↓
              잠금 획득 시도
                  ↓
              실패 시 알림
```

---

## 6. 추가 보호 메커니즘

### 6.1 만료 잠금 자동 정리 (Cloud Functions)

> QA 피드백 반영: 브라우저 강제 종료 시 대비

```typescript
// Cloud Functions - 매 10분마다 실행
export const cleanupExpiredLocks = functions.pubsub
  .schedule('every 10 minutes')
  .onRun(async () => {
    const thirtyMinutesAgo = Timestamp.fromDate(
      new Date(Date.now() - 30 * 60 * 1000)
    );
    
    const expiredDocs = await db.collection('documents')
      .where('editingAt', '<', thirtyMinutesAgo)
      .where('editingBy', '!=', null)
      .get();
    
    const batch = db.batch();
    expiredDocs.forEach((doc) => {
      batch.update(doc.ref, {
        editingBy: null,
        editingByName: null,
        editingAt: null
      });
    });
    
    await batch.commit();
  });
```

### 6.2 연결 상태 표시 + 끊김 경고

> Frontend 피드백 반영: Heartbeat 실패 시 알림

```
┌─────────────────────────────────────┐
│ ⚠️ 연결 상태 불안정                   │
│ 변경 사항이 저장되지 않을 수 있습니다.  │
│ [다시 연결] [로컬에 저장]             │
└─────────────────────────────────────┘
```

---

## 7. 에이전트 검토 결과

### Backend Agent
✅ 검토 완료, 이슈 없음
- 확인 항목: Transaction으로 race condition 방지됨

### Data Agent
✅ 검토 완료, 이슈 없음
- 확인 항목: editingByName 비정규화로 추가 쿼리 불필요

### QA Agent
✅ 검토 완료, 피드백 반영됨
- 확인 항목: Cloud Functions 스케줄러로 만료 잠금 정리 추가

### UX Agent
✅ 검토 완료, 이슈 없음
- 확인 항목: 편집 중 표시, 연결 끊김 경고 UI 포함

### Frontend Agent
✅ 검토 완료, 피드백 반영됨
- 확인 항목: 연결 상태 표시 + 끊김 시 경고 추가

---

**다음 단계**: 코드 구현 (src/utils/documentLock.ts, functions/cleanupLocks.ts)
