마지막 응답이 아니라 최신 요청이 이기게 만들기

문제는 요청 중복이 아니라 상태 소유권이었습니다

웰컴 체크인 구매미션 화면은 상품 목록과 오늘 참여 가능한 횟수를 조회합니다. 처음에는 화면 진입 시 한 번 조회하는 단순한 흐름처럼 보였습니다. 기능이 확장되면서 같은 조회가 화면 재진입, 참여 성공 후 silent refresh, 슬롯 충돌 복구에서 반복됐습니다.

여러 요청이 겹치면 자바스크립트는 먼저 시작한 요청을 자동으로 폐기하지 않습니다. 응답이 도착하는 순서대로 콜백을 실행합니다. 따라서 기본 동작은 최신 요청이 이기는 구조가 아니라 마지막 응답이 이기는 구조입니다.

sequenceDiagram
    participant Old as 이전 화면 요청 A
    participant New as 현재 화면 요청 B
    participant Store as Pinia Store

    Old->>Old: 요청 시작
    New->>New: 화면 재진입 후 요청 시작
    New-->>Store: 최신 응답 반영 (참여 가능 2회)
    Old-->>Store: 늦은 이전 응답 반영 (참여 가능 0회)
    Note over Store: 현재 화면이 stale 참여완료 상태로 변경

이 화면에서는 영향 범위가 넓었습니다. 하나의 응답이 상품 목록뿐 아니라 Pinia의 참여 가능 횟수, 조회 완료 상태, 오류 팝업, 로딩 종료, 선택 상품 재매핑까지 바꿨습니다. Pinia는 이전 컴포넌트가 unmount된 뒤에도 살아 있으므로 이전 인스턴스의 응답이 현재 인스턴스에 영향을 줄 수 있었습니다.

화면 이탈 시 취소만 믿을 수 없었습니다

공통 API 계층에는 페이지 이동 시 진행 중인 요청을 취소하는 로직이 있었습니다. 그러나 취소 조건이 spinner pending 여부에 연결돼 있었고, 상품 조회는 spinner를 표시하지 않았습니다. 따라서 화면 이탈이 모든 요청의 취소를 보장하지 않았습니다.

AbortController를 개별 API까지 전달해 취소를 강화할 수도 있었습니다. 하지만 취소 신호와 응답 완료가 경합할 수 있고, 동일 화면에서 겹치는 요청까지 해결하려면 결국 어떤 응답이 현재 상태를 갱신할 권한이 있는지 판정해야 했습니다.

첫 번째 접근: readiness boolean

처음에는 participationCountLoaded boolean을 추가했습니다. 현재 화면의 조회가 성공하기 전에는 이전 count가 0이어도 연결 서비스 버튼을 참여완료로 바꾸지 않도록 했습니다.

이 접근은 저장된 count가 즉시 노출되는 문제는 막았지만, 이전 요청이 늦게 성공하면 다시 readiness를 true로 바꿀 수 있었습니다. boolean은 조회 완료 여부만 표현할 뿐, 어느 요청이 완료됐는지 구분하지 못했습니다.

두 번째 접근: count만 request ID로 보호

요청 시작마다 증가하는 ID를 발급하고 현재 ID와 일치하는 응답만 count와 readiness를 반영하도록 변경했습니다.

beginParticipationCountRequest() {
    this.participationCountRequestId += 1;
    return this.participationCountRequestId;
}

completeParticipationCountRequest(requestId: number, count: number) {
    if (!this.isCurrentParticipationCountRequest(requestId)) {
        return false;
    }

    this.availableParticipationCount = Math.max(0, count);
    this.participationCountLoaded = true;
    return true;
}

count는 보호됐지만 충분하지 않았습니다. 상품 목록은 request ID 판정 전에 갱신됐고, 이전 실패 응답은 최신 성공 이후에도 오류 팝업을 띄울 수 있었습니다. 이전 요청의 finally가 최신 요청 진행 중에 로딩을 종료하는 문제도 남았습니다.

이 지점에서 문제를 개별 state가 아니라 요청이 가진 상태 갱신 권한으로 다시 정의했습니다.

최종 접근: 상태 전이 전체를 최신 요청에 귀속

최신 요청인지 확인한 뒤 상품 목록과 공유 상태를 함께 반영했습니다. non-200, 예외 처리, loading 종료에도 같은 판정을 적용했습니다.

const requestId = store.beginParticipationCountRequest(!silent);

try {
    const response = await apiStore.getProducts();

    if (response?.status === 200) {
        const isLatest = store.completeParticipationCountRequest(requestId, response.data?.availableParticipationCount ?? 3);

        if (isLatest) {
            products.value = response.data?.products ?? [];
        }
    } else if (!silent && store.isCurrentParticipationCountRequest(requestId)) {
        $onErrorData({ status: response?.status });
    }
} catch {
    if (!silent && store.isCurrentParticipationCountRequest(requestId)) {
        $onErrorData();
    }
} finally {
    if (!silent && store.isCurrentParticipationCountRequest(requestId)) {
        isLoading.value = false;
    }
}

silent refresh는 진행 중에도 기존 화면을 유지해야 하므로 readiness를 false로 내리지 않고 request ID만 갱신했습니다. 슬롯 충돌 재조회가 더 최신 요청에 밀린 경우에는 과거 상품 목록 기준의 선택값을 현재 목록에 재매핑하지 않고 안전하게 비웠습니다.

flowchart TD
    A[fetchProducts 시작] --> B[request ID 증가]
    B --> C[API 응답]
    C --> D{현재 request ID와 일치?}
    D -- 아니오 --> E[products·count·error·loading 변경 금지]
    D -- 예 --> F[상품 목록과 count/readiness 반영]
    F --> G{silent 요청?}
    G -- 아니오 --> H[현재 요청만 loading 종료]
    G -- 예 --> I[기존 readiness 유지]

테스트는 응답 순서를 직접 제어했습니다

실제 네트워크 속도에 의존하면 경합 테스트가 불안정해집니다. Deferred Promise 두 개를 만들어 응답 완료 순서를 명시적으로 제어했습니다.

const previousResponse = deferred<ApiResponse>();
const currentResponse = deferred<ApiResponse>();

api.getProducts
    .mockReturnValueOnce(previousResponse.promise)
    .mockReturnValueOnce(currentResponse.promise);

const previousFetch = fetchProducts();
const currentFetch = fetchProducts();

다음 시나리오를 독립적으로 검증했습니다.

  • 현재 성공 후 이전 성공이 늦게 도착해도 최신 상품과 count를 유지합니다.
  • 현재 성공 후 이전 실패가 도착해도 오류 처리를 실행하지 않습니다.
  • 이전 요청이 먼저 완료돼도 최신 요청이 pending이면 loading을 유지합니다.
  • silent refresh 중에는 기존 readiness를 유지합니다.
  • 슬롯 충돌 재조회가 최신 요청에 밀리면 stale 선택값을 비웁니다.

관련 회귀 테스트 49건이 통과했습니다.

Throttle과 해결 대상이 다릅니다

이전에 여러 API가 동시에 성인인증 응답을 내려 중복 리다이렉트가 발생한 문제는 throttle로 해결했습니다. 그 문제는 같은 부수효과가 여러 번 실행되는 것이 핵심이었습니다. 이번 문제는 서로 다른 요청 결과 중 어떤 응답이 상태를 갱신할 권한을 가지는지가 핵심이었습니다.

  • Throttle은 호출 또는 부수효과 횟수를 줄입니다.
  • Cancellation은 불필요한 요청을 중단합니다.
  • Request ID는 최신 요청만 상태를 갱신하도록 소유권을 제한합니다.

따라서 모든 API에 request ID를 일괄 추가하지 않습니다. 겹치는 요청이 동일한 장기 상태를 변경하고, 이전 요청 취소가 보장되지 않으며, 응답 순서가 사용자에게 보이는 결과를 바꿀 때 적용합니다.

배운 것

첫째, 비동기 경합을 데이터 한 필드의 문제로 보면 방어가 계속 새어 나갑니다. 상품 목록, count, readiness, 오류, loading, 선택 상태를 하나의 요청 소유권으로 묶어야 합니다.

둘째, boolean은 완료 여부는 표현하지만 완료 주체는 표현하지 못합니다. 여러 요청이 존재하면 요청 식별자가 필요합니다.

셋째, cancellation만으로 정합성을 증명하기 어렵습니다. 취소가 보장되지 않는 경로와 동일 인스턴스 중복 요청이 있다면 응답 freshness 검사가 필요합니다.

넷째, 경합 테스트는 네트워크 지연을 흉내 내는 sleep보다 Deferred Promise로 완료 순서를 통제하는 편이 빠르고 결정적입니다.

이 패턴은 검색 자동완성, 연속 필터 변경, 탭 전환, 라우트 재진입, 참여 후 재조회처럼 동일 상태를 여러 요청이 갱신하는 화면에 적용할 수 있습니다.