학습 공간 개념 · 판단 · 실험

Frontend Performance Lab

동작 실습 · JAVASCRIPT 비동기

일요일을 골랐는데 왜 토요일 좌석이 보일까요?

응답 지연 시간을 바꿔 세 가지 구현 방식을 비교해 보세요. 뒤늦게 도착한 이전 응답이 최신 화면을 덮어쓰는 문제와 해결 방법을 직접 확인할 수 있습니다.

미리 알면 좋아요 · Promise와 비동기 네트워크 요청의 기본 흐름

브라우저 타이머로 비동기 응답 지연을 재현하는 실습입니다. 실제 서버 통신이나 좌석 예약은 발생하지 않으며, 새로고침하면 초기화됩니다.

01 · 먼저 이해하기

선택한 순서와 응답이 도착하는 순서는 다를 수 있습니다

토요일 좌석을 조회한 직후 일요일을 선택했다고 가정해 보세요. 일요일 응답이 먼저 도착해 화면에 표시되었더라도, 뒤늦게 도착한 토요일 응답을 그대로 반영하면 화면이 다시 토요일 좌석으로 뒤집힙니다.

아래에서 날짜를 선택하면 세 가지 구현에 동시에 같은 요청이 전달됩니다. 잔여 좌석은 토요일 12석, 일요일 34석으로 설정되어 있습니다.

02 · 조건과 예상

어느 응답이 먼저 도착하게 할까요?

요청 경쟁을 재현하기 위한 응답 지연 설정이며, 설정을 바꾸면 진행 중인 요청이 초기화됩니다.

03 · 직접 실행

날짜를 빠르게 바꿔 보세요

현재 선택: 선택 전

빠른 선택 버튼은 토요일과 일요일 조회를 연속으로 요청합니다. 개별 버튼을 눌러 직접 선택하거나 같은 날짜를 다시 요청해 볼 수도 있습니다.

도착하는 순서대로 반영

응답이 도착할 때마다 현재 선택한 날짜인지 확인하지 않고 화면에 바로 저장합니다. 늦게 도착한 이전 응답이 최신 화면을 덮어쓸 수 있습니다.

아직 받은 응답이 없습니다

날짜를 선택해 시작하세요

대기 중인 요청 0개

요청 타임라인

    요청 시작부터 응답 반영·무시·취소까지의 흐름을 표시합니다.

    마지막 요청만 반영

    요청 번호와 현재 선택한 날짜가 일치할 때만 저장합니다. 이전 요청도 끝까지 실행되지만 결과는 무시합니다.

    아직 받은 응답이 없습니다

    날짜를 선택해 시작하세요

    대기 중인 요청 0개

    요청 타임라인

      요청 시작부터 응답 반영·무시·취소까지의 흐름을 표시합니다.

      이전 요청 취소 + 마지막 요청 확인

      새 요청을 보낼 때 AbortController로 진행 중인 이전 요청을 취소합니다. 응답을 반영하기 전에 마지막 요청인지도 함께 확인합니다.

      아직 받은 응답이 없습니다

      날짜를 선택해 시작하세요

      대기 중인 요청 0개

      요청 타임라인

        요청 시작부터 응답 반영·무시·취소까지의 흐름을 표시합니다.

        04 · 해석과 재도전

        왜 날짜뿐만 아니라 요청 번호까지 확인해야 할까요?

        요청 번호는 사용자가 클릭한 순서를 뜻합니다. 지연 시간을 반대로 바꾸거나 같은 날짜를 연속으로 두 번 요청해 보세요. 날짜만 비교하면 같은 날짜의 이전 요청과 새 요청을 구분할 수 없으므로 요청 번호(식별자)까지 함께 확인해야 안전합니다.

        이전 요청을 취소했으니 서버에서 처리하던 좌석 예약도 자동으로 취소되었다고 볼 수 있을까요?
        실제로 실행하는 코드 보기

        위 실습에서 동작하는 실제 코드입니다. 타이머로 응답 지연을 재현하는 mockSeats 함수와 요청 상태·취소를 관리하는 RequestRace 클래스를 확인할 수 있습니다.

        request-race/model.ts
        export type ShowDate = "saturday" | "sunday";
        export type Strategy = "naive" | "latest" | "cancel";
        export type Delays = Record<ShowDate, number>;
        export const strategies: Strategy[] = ["naive", "latest", "cancel"];
        export const defaultDelays: Delays = { saturday: 1500, sunday: 300 };
        export const dateNames: Record<ShowDate, string> = { saturday: "토요일", sunday: "일요일" };
        export interface SeatResult { date: ShowDate; seats: number; requestId: number }
        export interface RequestEntry { id: number; date: ShowDate; delay: number; status: "pending" | "applied" | "ignored" | "cancelled" }
        export interface PanelState { result: SeatResult | null; requests: RequestEntry[] }
        export interface RaceState { selected: ShowDate | null; panels: Record<Strategy, PanelState> }
        export function initialRaceState(): RaceState {
          return { selected: null, panels: { naive: { result: null, requests: [] }, latest: { result: null, requests: [] }, cancel: { result: null, requests: [] } } };
        }
        
        // A deliberately local network model, with fixed data and a controllable timer.
        // No fetch, server reservation, performance measurement or real seat inventory.
        export function mockSeats(date: ShowDate, requestId: number, delay: number, signal: AbortSignal): Promise<SeatResult> {
          return new Promise((resolve, reject) => {
            if (signal.aborted) { reject(signal.reason); return; }
            const abort = () => { clearTimeout(timer); reject(signal.reason); };
            const timer = setTimeout(() => {
              signal.removeEventListener("abort", abort);
              resolve({ date, requestId, seats: date === "saturday" ? 12 : 34 });
            }, delay);
            signal.addEventListener("abort", abort, { once: true });
          });
        }
        
        export class RequestRace {
          private state = initialRaceState();
          private listeners = new Set<(state: RaceState) => void>();
          private pending = new Map<AbortController, Strategy>();
          private requestId = 0;
          private epoch = 0;
          private disposed = false;
          getSnapshot = () => this.state;
          subscribe(listener: (state: RaceState) => void) {
            this.listeners.add(listener);
            return () => { this.listeners.delete(listener); };
          }
          private publish() { for (const listener of this.listeners) listener(this.state); }
          select(date: ShowDate, delays: Delays) {
            if (this.disposed) return;
            const id = ++this.requestId;
            const epoch = this.epoch;
            this.state = { ...this.state, selected: date };
            for (const strategy of strategies) {
              if (strategy === "cancel") for (const [controller, owner] of this.pending) if (owner === "cancel") controller.abort();
              const controller = new AbortController();
              this.pending.set(controller, strategy);
              const panel = this.state.panels[strategy];
              this.state = { ...this.state, panels: { ...this.state.panels, [strategy]: { ...panel, requests: [...panel.requests, { id, date, delay: delays[date], status: "pending" }] } } };
              mockSeats(date, id, delays[date], controller.signal).then((result) => {
                if (this.disposed || epoch !== this.epoch) return;
                // The guard is kept alongside cancellation: abort alone is not a freshness contract.
                const apply = strategy === "naive" || (id === this.requestId && date === this.state.selected);
                this.finish(strategy, id, apply ? "applied" : "ignored", apply ? result : undefined);
              }, () => {
                if (!this.disposed && epoch === this.epoch) this.finish(strategy, id, "cancelled");
              }).finally(() => { this.pending.delete(controller); });
            }
            this.publish();
          }
          private finish(strategy: Strategy, id: number, status: RequestEntry["status"], result?: SeatResult) {
            const panel = this.state.panels[strategy];
            this.state = { ...this.state, panels: { ...this.state.panels, [strategy]: {
              result: result ?? panel.result,
              requests: panel.requests.map((entry) => entry.id === id ? { ...entry, status } : entry),
            } } };
            this.publish();
          }
          reset() {
            this.epoch++;
            for (const controller of this.pending.keys()) controller.abort();
            this.pending.clear();
            this.requestId = 0;
            this.state = initialRaceState();
            this.publish();
          }
          dispose() { this.disposed = true; this.reset(); this.listeners.clear(); }
        }
        
        TypeScript · 읽기 전용81줄