1. 개요

DoEatFit의 핵심 기능 중 하나인 **‘잼(Jam)‘**은 친구들과 함께 실시간으로 같은 운동 루틴을 편집하고 세트 완료 상태를 공유하는 기능입니다.

하지만 사용자가 “오늘 내 일지 기록 불러오기” 버튼을 눌러 기존 운동 10여 개를 한 방에 잼 세션으로 쏟아부을 때 치명적인 문제가 발생했습니다. 비동기 HTTP 요청 특성 상, 서버에 도달하는 순서가 네트워크 상태나 TCP 큐에 따라 랜덤하게 섞이는 Race Condition이 발생한 것이죠. 게다가 방장과 다른 참가자가 동시에 운동을 추가하려고 할 때, 화면에는 루틴 순서가 완전히 뒤죽박죽이 되어 나타나는 심각한 데이터 꼬임 버그를 마주하게 되었습니다.

연관 포스트 이전 포스트에서 다루었던 브라우저 히스토리 스택 방어벽 구축기는 다음 링크를 참고해 주세요. 👉 ISS-UX) History API 오용으로 인한 가상 히스토리 루프 버그 해결


2. 핵심 내용

2-1. 🚨 문제 현상 및 근본 원인

  • 현상:
    • 방장 A가 내 일지에서 “스쿼트, 벤치프레스, 데드리프트” 순서로 불러오기를 실행합니다.
    • 하지만 잼 화면에서는 “벤치프레스, 데드리프트, 스쿼트” 식으로 무작위로 섞여서 렌더링됩니다.
    • 만약 A가 불러오기를 하는 찰나에 B가 다른 운동을 수동 추가하면, A의 운동들 사이사이에 B의 운동이 지그재그(Interleaving)로 파고듭니다.
  • 원인:
    • 기존에는 자바스크립트 객체(Record<string, string>)의 키 삽입 순서에 의존하여 정렬을 기대했습니다.
    • 클라이언트가 Promise.all 혹은 병렬 비동기 통신으로 10개의 아이템을 한꺼번에 ROUTINE_UPSERT 던져버리다 보니, 어떤 패킷이 서버에 먼저 도착할지 며느리도 모르는 상황이 벌어졌습니다.
    • 그렇다고 프론트엔드에서 1개씩 await 하며 릴레이 전송을 하자니, 10개를 렌더링하는데 2초 이상이 걸리는 끔찍한 병목(Bottleneck) 현상이 수반되었습니다.

2-2. 💡 우아한 해결책: 타임스탬프 기반 CRDT 정렬

중앙 서버가 순서를 교정해주는 대신, 클라이언트가 보내는 데이터 자체에 태생적인 절대 순서값(sortOrder)을 각인시켜 버리는 분산 환경(CRDT) 아이디어를 차용했습니다.

1) 타임스탬프 덩어리(Chunk) 방식 단순 인덱스(0, 1, 2…)가 아니라, 버튼을 누른 찰나의 **현재 기기 시간(Date.now())**을 기준값으로 잡습니다.

  • A 유저 클릭 시점 (T=1710000000)
    • 스쿼트: sortOrder = 1710000000 + 0
    • 벤치: sortOrder = 1710000000 + 1
  • B 유저가 0.1초 뒤 클릭 (T=1710000100)
    • 풀업: sortOrder = 1710000100 + 0

이렇게 하면 A의 운동 뭉치 뒤에 B의 운동 뭉치가 자연스럽게 이어 붙으며 지그재그로 섞이는 현상이 완벽히 방어됩니다.

2) 멱등성(Idempotency) 보장 실수로 “불러오기” 버튼을 연타하는 유저가 잼 방의 운동 순서를 맨 밑으로 밀어버리지 않게 하기 위해, 현재 잼 방에 이미 존재하는 아이디라면 기존의 sortOrder를 고스란히 상속받아 덮어쓰도록(멱등성) 방어 로직을 추가했습니다.

3) 병렬 전송 유지 (병목 타파) 데이터 안에 절대적인 순서 번호가 내장되어 있으므로, 서버에 도착하는 순서가 뒤죽박죽이 되어도 상관없습니다. 프론트엔드는 SSE로 받아온 데이터를 파싱할 때 그저 sortOrder를 기준으로 한 번 .sort() 해주면 그만입니다. 덕분에 답답하게 1개씩 await하며 전송할 필요 없이, 한 방에 병렬로 쾌적하게 꽂아 넣을 수 있게 되었습니다.

2-3. 코드 명세

// types/jam.ts
export interface JamSharedRoutine {
    id: string;
    sortOrder?: number; // 타임스탬프 기반 절대 순서
    // ...
}
 
export function parseJamItems(routineItems: Record<string, string>): JamSharedRoutine[] {
    return Object.entries(routineItems ?? {})
        .map(([id, json]) => parseJamItem(id, json))
        .filter((item): item is JamSharedRoutine => item !== null)
        // 파싱된 항목들을 무조건 sortOrder 기준으로 오름차순 정렬!
        .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
// jam-session-view.tsx (일지 불러오기 로직 일부)
const currentItems = parseJamItems(state.routineItems);
// 기존 아이템들의 최대값보다 큰 값을 보장 (타임머신 역전 방지)
const maxSort = currentItems.reduce((max, item) => Math.max(max, item.sortOrder ?? 0), 0);
const baseTime = Math.max(Date.now(), maxSort + 1);
 
for (let i = 0; i < states.length; i++) {
    const shared = toSharedRoutine(states[i]);
    
    // 멱등성: 이미 존재하는 운동이면 기존 순서 상속, 아니면 새 덩어리 순서 부여
    const existing = currentItems.find((item) => item.id === shared.id);
    shared.sortOrder = existing?.sortOrder ?? (baseTime + i);
 
    // 딜레이 없이 병렬로 쏜다! 서버 도착 순서가 꼬여도 상관 없음.
    send({type: "ROUTINE_UPSERT", itemId: shared.id, itemJson: serializeJamItem(shared)});
}

2-4. 동작 원리 시각화

sequenceDiagram
    participant UserA as User A (방장)
    participant UserB as User B (참가자)
    participant Server as Jam Server (Map)
    
    Note over UserA, UserB: 둘이 거의 동시에 "운동 추가" 버튼 클릭
    UserA->>UserA: 기준시간 T (ex. 1000)
    UserB->>UserB: 기준시간 T+50 (ex. 1050)
    
    par Network Race Condition
        UserB->>Server: UPSERT "B운동" (sortOrder: 1050)
        UserA->>Server: UPSERT "A운동2" (sortOrder: 1001)
        UserA->>Server: UPSERT "A운동1" (sortOrder: 1000)
    end
    
    Note over Server: 도착은 B -> A2 -> A1 순서로 꼬임
    
    Server-->>UserA: SSE Push (B, A2, A1)
    Server-->>UserB: SSE Push (B, A2, A1)
    
    Note over UserA, UserB: Front-end: .sort((a,b) => a.sortOrder - b.sortOrder)
    
    Note over UserA, UserB: 렌더링 결과: [ A운동1 (1000) ] -> [ A운동2 (1001) ] -> [ B운동 (1050) ]
    Note over UserA, UserB: 완벽하게 원래 순서와 묶음(Chunk) 복원 성공!

3. 회고

분산 환경에서 여러 사용자가 같은 객체(잼 방)를 동시 편집할 때 겪게 되는 “데이터 레이스”와 “이벤트 순서 역전”의 고전적인 문제를, 백엔드의 대공사 없이 프론트엔드의 영리한 식별자(sortOrder + Date.now()) 부여만으로 깔끔하게 회피해 낸 경험이었습니다.

무작정 await을 걸어 억지로 순서를 맞추려 했다면 유저 경험(UX) 측면에서 끔찍한 병목을 감수해야 했을 것입니다. 언제나 네트워크는 믿을 수 없다는 대전제 하에, **“데이터 자체가 자신의 순서를 기억하게 만든다”**는 철학의 위력을 몸소 체감할 수 있었습니다.