# 0128 — 깃발·줄 타이밍 동기화 (차트 가상 시간 집계 지연 보정)

---

## 핵심 문제

줌인이 깃발 도착보다 ~0.7–0.8초 빠르게 발생하여 20,000명의 사용자 모두에서 일관되지 않게 보였음.

**사용자 요구사항:**
- 1차 깃발 도착 시점에 줄이 (깃발이 먼저, 줄이 그 시점)
- 2차 깃발 도착 시점에 줌아웃
- 모든 컴퓨터에서 동일하게

---

## 근본 원인 분석

### 두 개의 시간 참조계 존재

| 요소 | 시간 참조 | 어떻게 계산 |
|------|-----------|-------------|
| **깃발 위치** | 차트 내부 **가상 시간 (virtual time)** | `lastDataTimestamp + (Date.now() - capturedAt)` |
| **줌 트리거** | 실제 시간 **(Date.now())** | `Date.now() - roundStartTime >= threshold` |

### 가상 시간 지연의 원인: 차트 라이브러리 집계

차트 라이브러리 내부 설정:
```js
realtime: {
  interval: {
    aggregation: 500,    // 500ms 캔들 단위로 데이터 묶기
    extraction: 1000     // 1초마다 캔들 처리
  }
}
```

데이터 흐름:
1. 가격 데이터 수신 (T = Date.now())
2. **aggregation**: `floor(T / 500) * 500`으로 반올림 → 최대 499ms lag
3. **extraction**: 1초 주기로 캔들 처리 → 추가 최대 1000ms lag
4. 가상 시간 보간: `lastAggregatedTimestamp + (now - extractionTime)`

**평균 총 lag = ~750ms** (aggregation 250ms avg + extraction 500ms avg)

### 깃발 타이밍 계산

차트 라이브러리 내부:
```js
// Flag 1: roundStart + (bettingTime - calculatingTime) * 1000
// bettingTime=30, calculatingTime=8
flag1Timestamp = roundStartTimestamp + 22000;  // 22초

// Flag 2: roundStart + (bettingTime - distributingTime) * 1000
// distributingTime=3
flag2Timestamp = roundStartTimestamp + 27000;  // 27초
```

깃발은 **가상 시간**이 flag timestamp에 도달할 때 "도착":
- 깃발1 도착 (가상): roundStart + 22000
- 깃발1 도착 (실제): roundStart + 22000 + 750 = roundStart + **22750**
- 깃발2 도착 (실제): roundStart + 27000 + 750 = roundStart + **27750**

### 줌 트리거 타이밍

GameStateSyncService에서 elapsed (실제 시간) 기준:
```typescript
if (elapsed >= threshold) {
  predictedStatus = GameStatus.CALCULATING; // → 줌 트리거
}
```

**이전 threshold 22300ms → 깃발 도착(22750) 보다 450ms 빠름 → 줌이 깃발보다 먼저**

---

## 해결 방법

### threshold를 깃발 도착 실제 시각과 맞춤

| 이벤트 | 가상 시간 | 실제 시간 |
|--------|-----------|-----------|
| 깃발1 도착 | roundStart + 22000 | roundStart + **22750** |
| **줌 인** | — | roundStart + **22800** (깃발 후 50ms) |
| 깃발2 도착 | roundStart + 27000 | roundStart + **27750** |
| **줌 아웃** | — | roundStart + **27800** (깃발 후 50ms) |

```typescript
if (elapsed >= 27800) {
  predictedStatus = GameStatus.DISTRIBUTING;  // 줌아웃
} else if (elapsed >= 22800) {
  predictedStatus = GameStatus.CALCULATING;   // 줌인
}
```

### 일관성 보장

`aggregation: 500` + `extraction: 1000`은 차트 라이브러리 **내부 고정 상수**.
모든 클라이언트에서 동일한 집계 간격 → 동일한 ~750ms lag → 동일한 깃발 도착 실제 시각.

threshold가 이 고정 lag을 보정하므로 **모든 컴퓨터에서 동일하게 동작**.

---

## 시도하고 안 된 것들 (및 이유)

### 1. 서버 메시지 이벤트 제거 (클라이언트 예측만)
- **목적**: 네트워크 지연으로 인한 타이밍 불일치 제거
- **결과**: 클라이언트 예측은 실제 시간 기준인데 깃발은 가상 시간 기준이라 여전히 불일치
- **교훈**: 두 시간 참조계의 차이가 근본 원인

### 2. 서버 메시지에서 이벤트 복원
- **목적**: 서버 타이밍과 깃발/줌 동기화
- **결과**: 한국 서버 ~5초 네트워크 지연으로 완전히 임의의 타이밍
- **교훈**: 서버 메시지 타이밍은 Korean server에서 사용 불가

### 3. 가격 타임스탬스를 Date.now()로 보정 (binance-interceptor.ts)
- **목적**: 가상 시간 = 실제 시간이 되도록
- **결과**: 집계(aggregation)가 Date.now()를 500ms 경계로 반올림하여 여전히 ~500ms lag
- **교훈**: Date.now()를 보내더라도 집계 단계에서 반올림 lag 발생

### 4. game:statusChanged 이벤트 1초 지연
- **목적**: 깃발(store 기반)이 줌(이벤트 기반)보다 먼저 보이게 함
- **결과**: 사용자가 "딜레이가 어중간하게 안맞아" 라고 피드백
- **교훈**: 사용자는 깃발·줌 분리가 아니라 깃발 도착 시점에 줄이 되는 것을 원함

### 5. 빌드 JS에서 직접 useEffect zoom 트리거 비활성화 (void 0 패치)
- **목적**: 이벤트 지연으로 줌만 분리
- **결과**: 직접 useEffect와 이벤트 두 경로 모두 있었어서 유효성 불확실
- **교훈**: 차트 라이브러리 내부 패치는 유지 불가능한 접근

### 6. calculatingTime 조정 (8→9, flag 1초 빠르게)
- **목적**: 깃발 타임스탬스를 1초 앞으로
- **결과**: 가상 시간 lag가 보정되지 않으면 깃발 위치가 바뀌지만 도착 시각은 여전히 lagged
- **교훈**: flag timestamp 조정은 가상 시간 lag와 독립적

---

## 최종 적용된 변경사항

### 1. binance-interceptor.ts — WebSocket 타임스탬스 보정
```typescript
// 중계 서버에서 원본 바이낸스 데이터를 전달 (타임스탬ts 보정)
if (data.type === 'binance_trade') {
  const tradeData = data.data;
  if (tradeData) {
    const now = Date.now();
    if (tradeData.T !== undefined) tradeData.T = now;  // exchange 타임스탬스 → 클라이언트 시각
    if (tradeData.E !== undefined) tradeData.E = now;
  }
  // ... forward to chart
}
```
**이유**: 원본 Binance T/E 타임스탬스는 exchange → relay → client 경로로 ~1초 뒤쳐짐.
Date.now()로 설정하면 집계 lag만 남음 (500ms aggregation).

### 2. GameStateSyncService.ts — 줌 threshold 보정
```typescript
// 차트 집계 지연 보정: aggregation 500ms + extraction 1000ms → avg lag ~750ms
// 깃발 도착(가상 시간) = roundStart + 22000 → 실제 시간 = roundStart + 22750
// 줌 트리거를 깃발 도착 실제 시각과 맞추기
if (elapsed >= 27800) {
  predictedStatus = GameStatus.DISTRIBUTING;  // 줌아웃 (깃발2 도착 직후)
} else if (elapsed >= 22800) {
  predictedStatus = GameStatus.CALCULATING;   // 줌인 (깃발1 도착 직후)
}
```

### 3. GameStateSyncService.ts — 서버 메시지에서 이벤트 복원
```typescript
// 서버 상태 변경 시 즉시 이벤트 발생 (깃발+줌 동시 트리거)
if (prevStatus !== this.serverStatus) {
  const eventData = { ... };
  emitEvent('game:statusChanged', eventData);
  useGameStore.setState({ gameStatus: this.serverStatus });
}
```

### 4. 빌드 후 패치 — 깃발 overlay delay 제거
```bash
# 차트 라이브러리 내부 깃발 오버레이 delay:500 → delay:0
sed -i 's/showOnStatus:Y\.CALCULATING,hideAt:0,delay:500/showOnStatus:Y.CALCULATING,hideAt:0,delay:0/g' index-CdxAmIWR.js
```

---

## 차트 라이브러리 내부 구조 (분석 참고)

### 깃발 렌더링 (rAF loop)
```js
// gameStatus !== CALCULATING이면 깃발 숨김
if (!(gameStatus === CALCULATING && gameStatus !== DISTRIBUTING && gameStatus !== PUBLISHING) || !flagPositions) {
  flag1.style.opacity = "0";
  flag2.style.opacity = "0";
  return requestAnimationFrame(loop);  // 다음 프레임에 다시 체크
}

// 깃발 위치 계산
const W = flagPositions.first - virtualTime;   // ms offset
const U = chartLeft + W * pxPerMs + 4;         // pixel X
flag1.style.opacity = (U >= -50 && U <= chartWidth + 200) ? "1" : "0";
```

### 줌 트리거 (두 경로)
```js
// 경로 1: 직접 store 구독 (즉시)
useEffect(() => {
  if (currentTarget) updateTimeWindow(gameStatus === CALCULATING ? 30 : 60);
}, [gameStatus, ...]);

// 경로 2: game:statusChanged 이벤트 구독
useEffect(() => {
  subscribe('game:statusChanged', (e) => {
    const target = e.to === CALCULATING ? 30 : DISTRIBUTING ? 60 : null;
    if (target && target !== currentTarget) updateTimeWindow(target);
  });
}, [...]);
```

### 가상 시간 계산
```js
let lastTimestamp = visiblePoints[last].timestamp;  // 집계된 캔들 타임스탬스
let capturedAt = Date.now();

// requestAnimationFrame 루프:
if (newDataArrived) {
  lastTimestamp = newData.timestamp;  // 새 캔들의 타임스탬스 (집계됨)
  capturedAt = Date.now();
}
const virtualTime = lastTimestamp + (Date.now() - capturedAt);  // 보간
```

### 줌 애니메이션
```js
// timeWindow 변경 시 1초(1000ms) 동안 easeInOutCubic으로 보간
animationState = {
  startPxPerMs: currentPxPerMs,
  targetPxPerMs: chartWidth / (newTimeWindow * 1000),
  startTime: performance.now(),
  duration: 1000  // 1초 animations
};
```

---

## 빌드 정보

- **파일**: `index-CdxAmIWR.js`
- **날짜**: 2026-01-28 ~18:00
- **크기**: ~681 KB
- **경로**: `C:\laragon\www\bc-game-updown\public\assets\`
- **빌드 후 패치**: delay:500 → delay:0 (깃발 오버레이)

---

## 테스트 확인 사항

- 깃발1이 깃발 도착 위치에 나타나고 **그 직후(~50ms)** 줄이 되는가?
- 깃발2 도착 후 줌아웃이 되는가?
- 한국 서버에서도 동일한 타이밍인가? (집계 간격은 클라이언트 고정 상수)
- Console log: `Client Status: BETTING → CALCULATING | elapsed: 22.8s` 확인

---

## 관련 파일

| 파일 | 변경 내용 |
|------|-----------|
| `frontend/apps/up-down/src/utils/binance-interceptor.ts` | WebSocket 타임스탬스 T/E → Date.now() |
| `frontend/apps/up-down/src/services/GameStateSyncService.ts` | threshold 22800/27800, 서버 이벤트 복원 |
| `public/assets/index-CdxAmIWR.js` | 빌드 후 delay:500→0 패치 |
| `public/index.html` | 최신 빌드 참조 |

---

**작성일**: 2026-01-28
**최종 빌드**: index-CdxAmIWR.js
**threshold**: 22800ms (줌인) / 27800ms (줌아웃)
**집계 lag 보정**: 750ms (aggregation 500ms + extraction 1000ms 평균)
