상황

리테일 앱의 온보딩성 이벤트 화면을 만들었다. 사용자가 지정된 상품 중 하나를 구매하면 보상(e머니)을 지급하는 구매 미션인데, 두 가지 제약이 겹쳐 있었다.

  • 선착순 마감: 참여 가능 수량이 한정되어 있고, 소진되면 전 상품이 마감된다.
  • 돈이 걸린 보상: 참여가 인정되면 실제 금전성 포인트가 지급된다.

즉 화면 하나에 레이스 컨디션과 금전 정합성이 동시에 걸려 있는 구조였다. 이런 화면에서 가장 먼저 정해야 하는 것은 기능 명세가 아니라 책임 경계 — 어떤 판단을 누가 소유하는가였다.

문제

FE는 조회 API 응답으로 isLimitReached 같은 마감 여부 필드를 받는다. 이 값으로 참여 버튼을 비활성화하면 겉보기에는 마감 처리가 끝난 것처럼 보인다. 하지만 이 값은 조회 시점의 스냅샷일 뿐이다.

사용자가 화면을 열어둔 채 머무는 동안 다른 사용자들이 잔여 수량을 소진할 수 있다. 조회 시점에는 참여 가능이었지만 참여 요청 시점에는 마감인 상태 — 조회와 참여 사이의 레이스는 클라이언트에서 어떤 코드를 짜도 막을 수 없다. FE가 아무리 최신 값을 폴링해도 "요청이 서버에 도착하는 순간"의 상태는 서버만 안다.

여기서 경계를 잘못 그으면 두 가지 사고가 난다. FE 판정을 믿고 참여를 확정 표시하면 마감 후 참여가 인정된 것처럼 보이는 오표시가 나고, 보상 지급까지 FE 상태로 추론하면 지급 안 된 보상을 지급된 것으로 보여주는 금전성 오류가 된다.

접근과 판단

원칙을 하나로 정했다. 데이터의 진위 판정은 서버에 귀속시키고, FE는 판정의 해석과 표시만 소유한다.

flowchart LR
    subgraph server["서버 소유 — 진위 판정"]
        A["선착순 마감 판정<br/>참여 요청 도착 시점 기준"]
        B["참여 인정<br/>PARTICIPATED"]
        C["보상 지급<br/>PAID"]
    end
    subgraph fe["FE 소유 — 해석·표시"]
        D["버튼 비활성화<br/>UX 방어"]
        E["마감 뱃지 파생<br/>availableCount === 0"]
        F["구좌 표시 우선순위<br/>서버 필드 조합 해석"]
    end
    A --> D
    A --> E
    B --> F
    C --> F

구체적인 배분은 이렇다.

선착순 마감의 최종 판정자는 서버다. FE의 isLimitReached 기반 버튼 비활성화는 유지하되, 이것의 지위를 명확히 했다 — 정합성 장치가 아니라 UX 방어다. 불필요한 실패 요청을 줄이고 사용자에게 즉각적인 피드백을 주는 역할일 뿐, 마감 여부의 진실은 참여 요청에 대한 서버 응답만이 결정한다. FE는 참여 요청이 마감 응답으로 돌아오는 케이스를 정상 플로우로 취급하고 그에 맞는 안내를 표시한다.

보상은 참여와 지급을 분리해서 다룬다. 서버는 참여 인정(PARTICIPATED)과 보상 지급(PAID)을 별도 상태로 관리한다. FE는 이 두 상태를 합성하거나 추론하지 않고, 각 상태를 받은 그대로 표시만 한다. "참여했으니 지급됐겠지"라는 클라이언트 측 추론이 금전성 화면에서 가장 위험한 코드다.

표시 로직은 FE가 온전히 소유한다. 상품 구좌 하나의 표시 상태는 6가지로 분기된다 — product == null 여부, isDefault 여부, 참여/마감 상태의 조합에 따라 우선순위가 갈린다. 이것은 서버 필드들의 조합 해석이지 새로운 판정이 아니므로 FE 책임이다. 이 우선순위 로직을 서버에 요구하는 것이야말로 경계 위반이다.

구현

경계를 지키는 파생과 경계를 넘는 덮어쓰기의 차이를 보여주는 사례가 하나 있다.

전체 참여 가능 수량이 소진되면(availableCount === 0) FE는 개별 상품의 마감 필드와 무관하게 전 상품에 마감 뱃지를 부여한다. 언뜻 FE가 서버 판정을 덮어쓴 것처럼 보이지만, 구조는 반대다.

  • 서버의 판정: "전체 수량이 소진되었다" (집계 수준의 진위 판정)
  • FE의 파생: "그렇다면 개별 상품 카드들은 마감 상태로 표시되어야 한다" (판정의 표시 수준 해석)

FE는 새로운 사실을 만들어내지 않았다. 서버가 내린 상위 수준 판정에서 하위 수준의 표시 상태를 파생시켰을 뿐이다. 판정의 귀속은 서버에 그대로 있고, 그 판정을 화면 요소들에 어떻게 전파할지는 FE가 소유한다. 이 구분이 되면 "FE가 데이터를 가공해도 되나?"라는 모호한 질문이 "이 가공이 새 판정인가, 기존 판정의 해석인가?"라는 답 가능한 질문으로 바뀐다.

서버 판정 (진위)                    FE 파생 (표시)
availableCount === 0        →      전 상품 카드에 마감 뱃지
                            →      참여 버튼 비활성화
                            →      마감 안내 문구 노출

반대로 FE가 해서는 안 되는 것: 조회 시점의 isLimitReached === false를 근거로 참여 성공을 낙관적으로 확정 표시하는 것. 이것은 해석이 아니라 서버가 아직 내리지 않은 판정을 FE가 선점하는 행위다.

결과

이 경계 위에서 화면의 각 동작이 명확한 소유자를 갖게 됐다.

  • 조회~참여 사이 레이스는 서버 응답 처리로 흡수 — FE는 마감 응답을 예외가 아닌 정상 분기로 처리
  • 보상 표시는 서버의 PARTICIPATED/PAID 분리 상태를 그대로 반영 — 클라이언트 추론에 의한 오지급 표시 가능성 원천 차단
  • 6분기 표시 우선순위 같은 복잡한 UX 로직은 FE에 집중 — 서버 API를 표시 요구사항 변경으로부터 격리

수치화된 before/after는 없다. 처음부터 이 경계로 설계했기 때문에 "레이스 사고가 줄었다"가 아니라 "레이스가 사고로 이어질 경로가 설계 단계에서 닫혔다"에 가깝다.

배운 것

이 사례에서 재사용 가능한 경계 판단 기준이 나온다.

데이터의 진위 판정 — 레이스, 정합성, 돈이 걸린 판단 — 은 서버에 귀속시킨다. 판정 결과의 해석, 조합, 표시 우선순위는 FE가 소유한다.

적용할 때의 리트머스 테스트:

  • 이 로직이 틀리면 돈이 잘못 나가거나 데이터가 어긋나는가? → 서버 판정. FE 코드는 UX 방어 이상의 지위를 갖지 않는다.
  • 이 로직이 틀리면 화면이 어색해질 뿐인가? → FE 소유. 서버에 표시 로직을 요구하지 않는다.
  • FE가 데이터를 가공하고 있다면 — 새 판정을 만드는가, 기존 판정을 해석하는가? 전자는 경계 위반, 후자는 FE의 정당한 소유물이다.

"클라이언트를 신뢰하지 않는다"는 격언은 도메인별로 구체화되어야 실무 기준이 된다. 선착순 도메인에서는 "조회 시점 스냅샷으로 참여 가능성을 확정하지 않는다"이고, 보상 도메인에서는 "참여와 지급을 클라이언트에서 합성 추론하지 않는다"이다. 격언 수준에 머무는 원칙은 코드 리뷰에서 힘을 쓰지 못하고, 도메인 수준으로 내려온 원칙만이 경계 논쟁을 끝낸다.