상황

리테일 앱의 매장 체크인 서비스에 구매미션 기능을 구축했다. 사용자가 미션 상품을 선택해 응모하면 서버가 자격을 판정하고 보상을 적립하는 구조다. 돈(적립 보상)이 걸린 도메인이라 클라이언트 상태와 서버 상태가 어긋나는 순간이 곧 정합성 사고로 이어진다.

이런 도메인에서 기능 구현보다 어려운 것은 경계 질문이다. 어떤 판단을 서버가 하고, 어떤 판단을 프론트엔드가 해도 되는가. 이 경계를 암묵적으로 두면 구현자마다 다른 답을 내리고, 장애가 났을 때 "누구의 책임인가"를 사후에 다투게 된다. 그래서 이번 작업에서는 경계가 걸린 판단 3건을 구현 전에 명문화하고, 그 판단을 전제로 API 계약까지 함께 설계했다.

문제

세 지점에서 FE/BE 경계 판단이 필요했다.

  1. 부분 실패 복구: 여러 미션 상품을 한 번에 응모했는데 일부만 실패하면, 프론트는 전체 목록을 재조회해야 하는가, 실패분만 국소적으로 패치해도 되는가?
  2. 재시도 루프 상한: 실패분 재시도를 허용하면 이론상 루프가 생긴다. 방어 로직을 넣을 것인가, 리스크를 수용할 것인가?
  3. 종료 상태 표시: "마감" 뱃지 같은 종료 상태를 서버가 내려줘야 하는가, 프론트가 파생해도 되는가?

세 질문 모두 "클라이언트를 어디까지 신뢰할 것인가"의 변주다. 정답이 하나로 정해져 있지 않고, 도메인의 돈 민감도와 서버 동작의 실제 계약에 따라 답이 달라진다.

접근과 판단

경계를 가른 원칙은 하나다. 판정은 서버, 해석과 표시는 FE. 자격·정합성·집계처럼 틀리면 돈이 새는 판단은 서버가 단일 소스로 갖고, 프론트는 서버의 판정 결과를 해석해 화면 상태로 파생하는 것까지만 책임진다. 이 원칙을 세 지점에 각각 적용했다.

flowchart LR
    subgraph server["서버 — 판정"]
        A[응모 자격 판정]
        B[부분 실패 시<br/>전체 미처리 + 실패 ID 반환]
        C[재고 집계<br/>availableCount]
    end
    subgraph fe["FE — 해석·표시"]
        D[실패 ID 기준<br/>국소 패치]
        E[재시도 UX<br/>상한 리스크 수용]
        F[availableCount === 0<br/>→ 마감 뱃지 파생]
    end
    B --> D
    B --> E
    C --> F

판단 1: 전체 재조회 대신 실패분 국소 패치

부분 실패 시 가장 안전해 보이는 선택은 전체 재조회다. 서버 상태를 통째로 다시 받으면 정합성은 보장된다. 하지만 응모 직후 목록 전체가 리로드되면 사용자는 방금 성공한 항목까지 깜빡이는 화면을 보게 되고, 스크롤 위치와 선택 맥락을 잃는다.

FE가 UX 근거로 국소 패치를 제안했고, 백엔드·기획과 합의해 계약을 뒤집었다: 서버가 실패한 missionProductIds 목록을 응답에 포함하고, 프론트는 그 ID들만 실패 상태로 패치한다. 핵심은 이 판단이 프론트 내부 최적화로 끝나지 않았다는 점이다. 서버 응답 계약 자체가 "프론트가 국소 패치한다"는 판단을 전제로 설계됐다. 경계 판단이 API 스펙에 각인된 것이다.

이 판단이 성립하는 전제 조건도 함께 명문화했다. 서버의 부분 실패 동작은 유효한 응모 건까지 전부 미처리하는, all-or-nothing에 가까운 방식이다. 즉 "일부만 반영된 어중간한 서버 상태"가 존재하지 않기 때문에, 프론트가 실패 ID만 패치해도 서버 상태와 어긋날 수 없다. 서버가 진짜 부분 커밋을 한다면 같은 UX 근거라도 전체 재조회가 맞다. 판단의 결론만이 아니라 전제를 같이 기록해야, 전제가 바뀌었을 때 판단을 재검토할 수 있다.

판단 2: 재시도 상한을 정량화하고 방어 로직 없이 수용

실패분 재시도를 허용하면 이론상 재시도 루프가 생긴다. 방어 로직(클라이언트 측 재시도 카운터, 서킷 브레이커)을 넣을 수도 있었지만, 먼저 최악 케이스를 계산했다. 미션 상품 수와 실패 조합을 기준으로 루프의 최악 상한은 10회였다. 매 라운드 최소 1건은 확정(성공 또는 영구 실패)되므로 루프는 유한하고, 10회는 사용자가 의도적으로 반복해야 도달하는 수치다.

이 숫자를 이해관계자에게 공유하고, 방어 로직 없이 리스크를 수용하기로 합의했다. 여기서 남긴 것은 코드가 아니라 기록이다. "괜찮을 것 같아서 안 넣었다"와 "최악 상한 10회를 계산해 공유했고 수용하기로 합의했다"는 장애 상황에서 완전히 다른 문장이 된다. 전자는 누락이고 후자는 결정이다. 리스크는 제거하거나, 숫자로 만들어 합의하거나 둘 중 하나여야 한다.

판단 3: 마감 뱃지는 서버 집계에서 프론트가 파생

미션 상품의 "마감" 표시를 서버가 별도 플래그로 내려줄 수도 있었다. 대신 서버는 재고 집계값 availableCount만 내려주고, 프론트가 availableCount === 0이면 마감 뱃지를 파생 부여하기로 했다.

이것은 판단 1·2와 반대 방향의 결정처럼 보이지만 같은 원칙의 적용이다. 집계(판정)는 서버가 한다 — 프론트는 재고를 세지 않는다. 하지만 그 집계값을 "마감"이라는 표시 상태로 번역하는 것은 해석이고, 해석은 프론트 책임이다. 서버에 표시용 플래그를 추가하면 당장은 편하지만, 표시 정책이 바뀔 때마다(예: "품절 임박" 단계 추가) 서버 배포가 필요해진다. 판정과 표시를 분리해두면 표시 정책은 프론트 배포만으로 바뀐다.

구현

세 판단이 코드에 남긴 형태를 요약하면:

// 판단 1: 서버가 반환한 실패 ID만 국소 패치
// 전제: 서버는 부분 실패 시 전체 미처리(all-or-nothing) + 실패 ID 목록 반환
const { failedMissionProductIds } = await applyMissions(selectedIds);
patchMissionStatus(failedMissionProductIds, 'FAILED');
// 전체 재조회 없음 — 성공 항목의 화면 상태는 그대로 유지

// 판단 3: 판정(집계)은 서버, 표시 상태는 FE 파생
const isClosed = computed(() => product.availableCount === 0);

판단 2는 코드가 없는 것이 구현이다. 재시도 방어 로직을 의도적으로 넣지 않았고, 그 근거(최악 상한 10회, 이해관계자 수용 합의)를 설계 문서에 남겼다.

결과

  • 부분 실패 복구, 재시도 상한, 종료 상태 표시 3건의 경계 판단이 구현 전에 문서로 확정됐고, 그중 1건은 서버 응답 계약(실패 ID 목록 반환)으로 각인됐다.
  • "판정은 서버, 해석·표시는 FE"라는 경계 원칙이 개별 기능 판단이 아니라 재사용 가능한 팀 기준이 됐다. 이후 유사한 경계 질문(어떤 상태를 서버가 내려줘야 하는가)에 대해 같은 축으로 답할 수 있다.
  • 방어 로직 부재가 누락이 아니라 정량 근거를 가진 합의된 결정으로 기록됐다.

배운 것

돈이 걸린 도메인에서 FE/BE 경계 판단은 매번 새로 하는 것이 아니라 하나의 축으로 환원된다.

  1. 판정은 서버, 해석·표시는 FE. 틀리면 돈이 새는 판단(자격, 정합성, 집계)은 서버가 단일 소스로 갖는다. 프론트는 서버 판정을 화면 상태로 번역하는 것까지만 책임진다. 이 분리를 지키면 표시 정책 변경이 서버 배포와 분리된다.
  2. 프론트의 복구 전략은 서버의 실패 계약 위에서만 성립한다. 국소 패치가 안전했던 이유는 UX 판단이 좋아서가 아니라, 서버가 부분 실패 시 all-or-nothing으로 동작한다는 전제가 있어서다. 복구 전략을 정할 때는 결론과 함께 전제를 명문화하라. 전제가 바뀌면 판단도 무효다.
  3. 수용한 리스크는 숫자로 남겨라. 방어 로직을 안 넣는 것도 설계 결정이다. 최악 케이스를 정량화해 공유하고 합의한 기록이 있으면, "안 넣은 것"이 누락이 아니라 결정이 된다.
  4. 좋은 경계 판단은 API 계약에 각인된다. 프론트 내부에서만 소비되는 판단은 다음 구현자가 뒤집을 수 있다. 서버 응답 스펙이 그 판단을 전제로 설계되면, 경계 판단이 시스템의 구조가 된다.