상황

리테일 앱의 웰컴 체크인 구매미션 기능을 구현했다. 사용자에게 상품 구좌 10개가 노출되는데, 이 구좌는 서버가 요청 시점마다 랜덤 추첨 + 개인화 재정렬로 구성한다. 같은 사용자가 같은 API를 두 번 호출해도 다른 상품 목록이 내려올 수 있다. 여기에 선착순 마감이 걸려 있어, 사용자가 상품을 선택해 참여를 시도하는 순간 이미 소진되어 실패할 수 있다.

문제는 참여 실패 이후였다. 프론트엔드의 관성적인 정답은 "실패했으니 서버 상태가 바뀌었을 것이다 → 목록을 전체 재조회해서 정합성을 회복한다"이다. 대부분의 CRUD 화면에서 이 관성은 옳다. 이 화면에서는 틀렸다.

문제

전체 재조회가 정합성을 회복한다는 전제에는 숨은 조건이 있다. 서버 리소스가 결정적일 것 — 같은 상태에서 다시 조회하면 같은 결과가 나올 것.

이 화면의 리소스는 비결정적이다. 재조회하면 서버가 새로 추첨한 목록이 내려오고, 사용자가 방금까지 보고 있던 유효한 상품들이 화면에서 사라진다. 실패한 것은 선택한 상품 일부인데, 재조회는 멀쩡히 선택 가능하던 나머지까지 통째로 교체해 버린다. 서버 관점의 "최신 상태"가 사용자 관점에서는 보던 상태의 파괴다.

즉 이 조건에서 재조회는 정합성을 회복하는 것이 아니라, 오히려 사용자가 인지하고 있던 정합한 상태를 깨뜨린다.

flowchart TD
    A[참여/갱신 실패 발생] --> B{서버 리소스가 결정적인가?}
    B -- "결정적<br/>(재조회해도 같은 결과)" --> C[전체 재조회<br/>서버 상태로 동기화]
    B -- "비결정적<br/>(랜덤 추첨·개인화 재정렬)" --> D[실패 응답 페이로드 기반<br/>국소 패치]
    D --> E[실패 항목만 상태 갱신<br/>나머지 목록 유지 → 즉시 재시도]
    D --> F{"종료 조건 도달?<br/>(availableCount === 0)"}
    F -- 예 --> G[프론트가 종료 상태 파생<br/>미참여 상품 전부 마감 처리]

접근과 판단

판단 기준을 조건절로 정리했다.

  • 서버 리소스가 결정적이면 — 재조회가 정합이다. 서버 상태를 다시 받아오는 것이 곧 사용자가 봐야 할 상태다.
  • 서버 리소스가 비결정적이면(랜덤 추첨, 개인화 재정렬) — 재조회는 사용자가 보던 상태를 파괴한다. 실패 응답의 페이로드를 근거로 실패한 항목만 국소 패치하는 것이 정합이다.

이 원칙을 같은 기능의 두 지점에 적용했다.

사례 1 — 부분 실패: 실패한 상품만 패치한다

사용자가 상품 여러 개를 선택해 참여를 시도했는데 일부가 선착순 마감으로 실패한 경우. 재조회하면 실패하지 않은 유효 상품까지 새 추첨 목록으로 교체된다.

대신 실패 응답이 실패한 상품 ID 목록(missionProductIds) 을 반환하도록 하고, 프론트는 해당 상품만 isLimitReached=true로 국소 패치했다. 나머지 목록은 그대로 유지되므로 사용자는 화면 전환이나 목록 리셋 없이 남은 상품으로 즉시 재시도할 수 있다. 이 실패 응답 구조는 API 인터페이스 명세에 계약으로 명문화했다.

사례 2 — 종료 상태 파생: 재조회 대신 프론트가 마감을 선언한다

참여 가능 횟수가 소진된 경우(availableCount === 0). 여기서도 재조회는 오답이다. 새로 추첨된 상품들이 내려오면, 참여가 불가능한 상태인데도 상품이 선택 가능해 보이는 기만적인 화면이 된다.

대신 프론트가 종료 상태를 파생시켰다. availableCount === 0이면 재조회 없이 미참여 상품 전부에 선착순 마감 뱃지를 강제 부여한다. 서버에 물어보는 대신, 이미 알고 있는 종료 조건으로부터 화면 상태를 결정한 것이다.

엣지케이스 — 못 본 것과 계산하고 수용한 것은 다르다

국소 패치 + 재시도 루프에는 이론적 최악 시나리오가 있다. 사용자가 재시도할 때마다 다른 사용자들이 아슬아슬하게 먼저 마감시키는 적대적 타이밍이 반복되는 경우다. 구좌 10개, 회당 최대 3개 선택 조건에서 상한을 계산하면 최대 10회 재시도가 가능하다.

이 상한을 정량화해 기획·백엔드와 공유했고, 발생 확률이 극히 희박하다는 근거로 별도 방어 로직(재시도 횟수 제한 등) 없이 수용하기로 합의했다. 엣지케이스를 인지하지 못한 채 지나가는 것과, 최악 상한을 계산해서 합의하에 수용하는 것은 완전히 다른 의사결정이다.

구현

핵심은 코드보다 계약이다. 국소 패치가 가능하려면 실패 응답이 "무엇이 실패했는지"를 알려줘야 한다.

POST /mission/participate  (부분 실패 시)

{
  "success": false,
  "failedMissionProductIds": ["P003", "P007"],  // 마감된 상품 ID
  "availableCount": 2                            // 남은 참여 가능 횟수
}

프론트 처리 로직은 두 갈래다.

function onParticipateFailed(res: ParticipateFailResponse) {
  // 사례 1: 실패 상품만 국소 패치 — 나머지 목록 유지, 즉시 재시도 가능
  patchProducts(res.failedMissionProductIds, { isLimitReached: true });

  // 사례 2: 종료 조건이면 재조회 대신 프론트가 종료 상태를 파생
  if (res.availableCount === 0) {
    patchAllUnparticipated({ isLimitReached: true }); // 전체 마감 뱃지
  }
}

주목할 점은 설계의 방향이다. 통상 API 계약이 먼저 정해지고 프론트가 갱신 전략을 맞추지만, 여기서는 반대였다. FE가 "재조회는 이 리소스에서 UX를 파괴한다"는 근거로 국소 패치 전략을 먼저 제안했고, 백엔드의 실패 응답 계약(실패 ID 목록 반환)이 그 판단을 전제로 설계됐다. 데이터 갱신 전략이 API 계약을 역으로 규정한 것이다.

결과

  • 참여 실패 시 사용자가 보던 상품 목록이 유지되어, 목록 리셋 없이 즉시 재시도하는 흐름이 성립했다.
  • 참여 소진 시점에 "선택 가능해 보이지만 참여할 수 없는" 기만적 화면이 원천적으로 발생하지 않는다.
  • 실패 응답에 실패 ID 목록을 담는 계약이 API 명세에 명문화되어, 갱신 전략이 팀의 합의된 설계로 남았다.
  • 최악 재시도 상한(10회)을 정량화한 합의로, 방어 로직이라는 추가 복잡도를 근거 있게 생략했다.

배운 것

"실패하면 재조회"는 정석이 아니라 조건부 전략이다. 그 조건은 리소스의 결정성이다.

  1. 리소스 결정성으로 갱신 전략을 판단하라. 결정적 리소스면 재조회가 정합이고, 비결정적 리소스(랜덤 추첨, 개인화 재정렬, 재고 경쟁)면 실패 페이로드 기반 국소 패치가 정합이다. 재조회가 항상 안전하다는 관성은 리소스가 비결정적인 순간 사용자가 보던 상태를 파괴하는 흉기가 된다.
  2. 종료 상태는 프론트가 파생할 수 있다. 종료 조건(잔여 횟수 0)을 이미 알고 있다면 서버에 다시 묻지 말고 화면 상태를 직접 결정하라. 재조회는 종료된 세계에 새 선택지를 그려 넣는 기만이 될 수 있다.
  3. 갱신 전략이 API 계약을 규정할 수 있다. 국소 패치가 가능하려면 실패 응답이 실패 항목을 식별해줘야 한다. FE의 UX 판단이 응답 스키마 설계의 전제가 되는 방향의 설계도 성립한다.
  4. 엣지케이스는 상한을 계산해서 수용하라. 최악 시나리오를 못 본 것과, 상한(최대 10회)과 확률을 정량화해 이해관계자와 합의하에 방어 로직 없이 수용한 것은 다른 수준의 의사결정이다.

이 원칙은 추첨형 프로모션만이 아니라 개인화 피드 재정렬, 실시간 재고 경쟁, 좌석 선점 같은 모든 비결정적 리소스 화면에 이식된다.