Next.js를 활용해 실시간 좌석 동기화가 가능한 대규모 티켓 예매 서비스를 구현했습니다. (본 레포지터리는 프론트엔드 영역만 다룹니다.
1. 실시간 좌석 상태 반영
- WebSocket을 통해 여러 사용자가 동시에 접속해도 좌석 상태(HOLD, SOLD, AVAILABLE)가 즉시 반영됩니다.
- 상태 관리는 Zustand 전역 스토어를 활용하여 UI와 데이터를 일관되게 유지했습니다.
2. UX & 렌더링 최적화
- `useMemo`와 Zustand selector를 활용하여 불필요한 리렌더링을 줄이고, 상태 변경된 좌석만 다시 렌더링하도록 최적화했습니다.
- `SeatGridSkeleton`으로 초기 진입 시 Skeleton UI를 제공해 로딩 경험을 개선했습니다.
3. Presence 관리
- 사용자가 페이지를 이탈하거나 창을 닫을 때 `navigator.sendBeacon`을 사용해 서버로 알림을 전송합니다.
- 서버(FastAPI)는 Redis를 통해 사용자의 활성 상태 및 좌석 HOLD 정보를 정리하여 안정성을 확보했습니다.
-
WebSocket 기반 업데이트
- 좌석 상태(HOLD, SOLD, AVAILABLE)를 WebSocket으로 받아와 즉시 UI 반영
- 서버에서 다건 상태 변경을 push하면, 클라이언트는
patchStatus로 상태를 일괄 갱신
-
Zustand 전역 스토어 활용
- 전역 상태를 관리해 페이지 전환 없이도 좌석 상태가 일관되게 유지
patchStatus는 seat_id 기준으로 매핑 후 필요한 좌석만 업데이트
useEffect(() => { const ws = new WebSocket(`${DEV_DOMAIN}/ws/events/777/seats`); // ...생략 ws.onmessage = (evt) => { const msg = JSON.parse(evt.data); if (msg.type !== 'seat_update') return; const updates = (Array.isArray(msg.payload) ? msg.payload : [msg.payload]) .map((p: any) => p.seat_id ? { seat_id: p.seat_id, status: p.seat_status } : { prev_seat_id: p.prev_seat_id, status: 'AVAILABLE' }, ); patchStatus(updates); // ✅ 상태 일괄 갱신 }; return () => ws.close(); }, [patchStatus]);
-
부분 렌더링만 수행
- Zustand selector를 이용해 필요한 상태만 구독 → 좌석 상태가 바뀐 해당 좌석만 다시 렌더링
Seat컴포넌트를React.memo로 감싸 불필요한 재조합 방지
-
계산 비용 메모이제이션
- 수천 개 좌석 배열을 400석 단위 섹션으로 나누는 연산을
useMemo로 감싸, 실제seats상태가 변경될 때만 재계산 → 렌더링 성능 안정화
- 수천 개 좌석 배열을 400석 단위 섹션으로 나누는 연산을
-
로딩 경험 최적화 (Skeleton UI)
- 초기 진입 시
SeatGridSkeleton을 먼저 보여주어 지각된 대기 시간을 줄이고, 데이터가 도착하면 실제 좌석 그리드로 자연스럽게 전환
// SeatBase.tsx — 부분 렌더링 function SeatBase({ seatId, seatLabel, status }: Props) { const { myPaidSeat, setMySeat } = useSeatStore(); if (myPaidSeat) return null; return ( <button disabled={status === 'SOLD'} onClick={/* ...좌석 HOLD API 호출... */} /> ); } export default React.memo(SeatBase); // SeatGridClient.tsx — 섹션 분할 + 스켈레톤 const sections = []; for (let i = 0; i < seats.length; i += 400) sections.push(seats.slice(i, i + 400)); return sections.length === 0 ? ( <SeatGridSkeleton /> ) : ( sections.map((sectionSeats) => ( <div>{sectionSeats.map((s) => <Seat key={s.seat_id} {...s} />)}</div> )) );
- 초기 진입 시
- 의존성 설치
npm install- 개발서버 실행 ( localhost:3000 )
npm run dev - 프로덕션 빌드 및 실행
npm run build
npm run start