던플 개발기 (11): 터미널 — item-api와 market-api를 BFF에서 합쳐 라이브 웹으로

Dunplay

지난 편에서 실데이터 수집을 정리했다면, 이번엔 그 데이터를 한 화면에서 보는 이야기입니다. 던플마켓은 처음부터 "아이템 검색기"가 아니라 "게임을 켜둔 채 옆에 띄워 두고 시세 흐름을 관측하는 게임 경제 시세 터미널"을 지향했습니다. 터미널이라는 화면은 그 방향을 처음으로 제품 안에 구현한 지점입니다.

문제는 그 한 화면에 필요한 데이터가 한 서비스에 있지 않다는 것이었습니다. 아이템의 이름·등급·타입 같은 카탈로그는 item-api가 소유하고, 매물·판매 이력·시세 집계·수집 상태는 market-api가 소유합니다. 여기서 흔한 유혹은 "그럼 프론트가 두 API를 각각 부르면 되지"입니다. 이 편은 그 유혹을 의식적으로 피하고, BFF가 두 API를 조합해 하나의 뷰 계약으로 내려주도록 만든 기록입니다.

한눈에 보면

  • 터미널 한 화면에 필요한 데이터는 item-api(카탈로그)와 market-api(시세)에 나뉘어 있습니다. 이걸 프론트가 아니라 BFF가 조합합니다.
  • BFF는 두 gRPC 호출을 Promise.all로 동시에 던지고, GET /api/v1/items/{itemId}/terminal 하나로 합쳐 내려줍니다.
  • item-api가 카탈로그의 존재 권위입니다. 그 아이템을 모르면 404, 알면 그 상세로 market-api의 read-copy를 덮어씁니다.
  • 두 도메인을 조합하려고 item-contracts-grpcscope:item에서 scope:shared로 재태깅했습니다. 도메인 의존이 없는 전송 계약이라 공유 규칙을 깨지 않습니다.
  • 화면은 SSR로 첫 페인트를 저장 데이터로 그리고, 이후엔 realtime WebSocket으로 살아 움직입니다. 소켓이 끊기면 30초 폴링으로 fallback합니다.
  • gRPC 전송 타입(int64 문자열, epoch ms, enum 접두어)을 브라우저가 그대로 먹지 않게, BFF가 뷰 계약으로 번역해 내려줍니다.
  • Docker에서 item-api가 부팅에 실패했는데, 원인은 코드가 아니라 Prisma custom output이 node_modules로 안 떨어진 것이었습니다.

터미널이라는 화면

/item/[id]는 원래 "아이템 상세" 정도였습니다. 이걸 시세 터미널로 다시 정의하면서, 화면이 답해야 할 질문이 늘었습니다.

  • 지금 최저가·최고가·최근 체결가는 얼마인가
  • 현재 매물과 최근 판매 이력은 어떤가
  • 분 단위 시세 차트는 어떻게 흐르는가
  • 마지막 수집은 언제였고 다음 갱신은 언제인가
  • 지금 공급자(네오플)가 점검·지연 중은 아닌가
  • 아직 첫 수집 전이라 "준비 중"인 상태는 아닌가

앞의 두 줄은 카탈로그(item-api)에서, 나머지는 시세(market-api)에서 옵니다. 그리고 (2) 점검·장애 편(3) 프론트 편에서 정한 원칙 — freshness와 점검 상태를 숨기지 않는다 — 을 이 화면에서도 그대로 지켜야 했습니다. 그러니 화면이 필요로 하는 건 "아이템 데이터"와 "시세 데이터" 두 조각이 아니라, 그 둘을 이미 합쳐 둔 하나의 스냅샷이었습니다.

item-api + market-api를 BFF에서 조합

던플마켓의 경계 규칙은 단순합니다. 브라우저와 market-web은 gRPC·.proto·Prisma·네오플 키를 모릅니다. 데이터는 오직 market-bff REST로만 받습니다. BFF는 REST를 밖으로 내고, 안으로는 item-api·market-api와 gRPC로만 이야기합니다.

그래서 조합은 BFF의 몫이 됩니다. 터미널 엔드포인트는 두 컨텍스트를 동시에 부르고, item-api를 존재 권위로 두어 하나의 DTO로 합칩니다.

sequenceDiagram
  participant W as market-web (SSR/브라우저)
  participant B as market-bff (REST)
  participant I as item-api (gRPC 50052)
  participant M as market-api (gRPC)
  W->>B: GET /api/v1/items/{itemId}/terminal
  par 두 컨텍스트를 동시에
    B->>I: ItemQueryService.GetItem
    B->>M: MarketQueryService.GetTerminalItem
  end
  I-->>B: 카탈로그 상세 (존재 권위)
  M-->>B: 시세·매물·차트·freshness·providerStatus
  B->>B: 하나의 TerminalDto로 조합 + 전송 타입 번역
  B-->>W: 뷰 계약 1개

컨트롤러는 이 흐름을 거의 그대로 담고 있습니다. 두 호출을 Promise.all로 병렬화하고, item-api가 그 아이템을 모르면 404를 던지고, 저장 데이터가 stale이면 수집을 앞당기도록 tracking을 활성화합니다.

// apps/market-bff/src/app/items/items.controller.ts
@Get(':itemId/terminal')
@ApiOkResponse({ type: TerminalDto })
async terminal(@Param('itemId') itemId: string): Promise<TerminalDto> {
  const [itemResponse, terminalResponse] = await Promise.all([
    firstValueFrom(this.itemQuery.getItem({ itemId })),        // item-api
    firstValueFrom(this.query.getTerminalItem({ itemId })),    // market-api
  ]);
 
  // item-api owns the catalog: an item it does not know is a 404.
  const catalogItem = itemResponse.item;
  const data = terminalResponse.data;
  if (catalogItem == null || data == null) {
    throw new NotFoundException(`Item ${itemId} not found`);
  }
 
  // 저장 데이터가 stale/absent면 다음 수집을 앞당긴다.
  const isStale = isStaleFreshness(data.freshness?.freshness ?? '');
  await firstValueFrom(this.command.activateTrackedItem({ itemId, isStale }));
 
  return protoTerminalToDto(
    data,
    itemId,
    realtimeGatewayUrl(),
    itemApiItemToDto(catalogItem), // item-api 상세로 market-api read-copy를 override
  );
}

여기 정직하게 짚을 부분이 하나 있습니다. market-apiGetTerminalItem 응답에도 이미 TerminalItem(카탈로그 상세)이 들어 있습니다. market-api가 공용 items 테이블의 read-copy를 compatibility layer로 서빙하기 때문입니다. proto 주석에도 그 이유가 적혀 있습니다.

// libs/market/contracts-grpc/proto/market_query.proto
// Catalog (item) and market data are owned by different bounded contexts. The
// item catalog is owned by item-api; market-api serves a read-only catalog copy
// from the shared `items` table (its CatalogQueryService compatibility layer)
// so the market-scope BFF composes a single snapshot without crossing the
// scope:market -> scope:item module boundary.
service MarketQueryService {
  rpc GetTerminalItem(GetTerminalItemRequest) returns (GetTerminalItemResponse);
}
 
message TerminalItem {
  string item_id = 1;
  string name = 2;
  optional string rarity = 3;
  optional string item_type = 4;
  // ...
}

그렇다면 왜 굳이 item-api를 또 부를까요. 소유권 때문입니다. 카탈로그의 source of truth는 item-api이고, market-api가 든 건 어디까지나 read-copy입니다. 그래서 터미널은 item-api의 상세를 authoritative로 쓰고 read-copy를 덮어씁니다(itemOverride ?? mapItem(data.item)). 무엇보다 존재 판정을 item-api에 맡깁니다. market-api에는 남아 있어도 item-api가 모르는 아이템이면 404입니다. 조합은 단순히 "합치기"가 아니라 "누가 무엇의 진실인가"를 정하는 일이었습니다.

item-contracts를 scope:shared로 재태깅

이 조합에는 걸림돌이 하나 있었습니다. market-bffscope:market이고 item-apiscope:item입니다. 던플 모노레포는 Nx @nx/enforce-module-boundaries로 import 경계를 강제하는데, 규칙상 scope:marketscope:item에 의존할 수 없습니다. 그런데 BFF가 item-api의 gRPC를 부르려면 item-contracts-grpc(= item.proto에서 생성된 전송 타입)를 import해야 합니다.

여기서 라이브러리를 복제하거나 규칙을 뚫는 대신, 계약의 태그를 바꿨습니다. scope:itemscope:shared.

// libs/item/contracts-grpc/package.json (nx.tags diff)
- "tags": ["scope:item", "layer:contracts", "runtime:any"]
+ "tags": ["scope:shared", "layer:contracts", "runtime:any"]

이게 규칙 위반이 아닌 이유를 CLAUDE.md에 근거로 남겨 뒀습니다. scope:shared의 유일한 제약은 "특정 도메인 라이브러리에 의존하지 않는다"인데, 이 계약은 순수한 전송 계약이라 다른 @dunplay 라이브러리에 의존이 전혀 없습니다.

# libs/item/contracts-grpc/CLAUDE.md
- `scope:shared`로 태깅한다. item-api(scope:item)와 market-bff(scope:market)가
  모두 소비하는 cross-context 전송 계약이라, scope:market이 scope:item에 의존하지
  못하는 경계 안에서도 BFF가 이 계약만은 import할 수 있어야 한다(task 0805). 내부
  @dunplay 의존이 없어 scope:shared 규칙(공유는 도메인에 의존 금지)을 위반하지 않는다.

경계를 그림으로 보면 이렇습니다. BFF는 도메인(item-api) 자체에는 손을 못 대지만, 공용 자리로 옮긴 전송 계약만은 import할 수 있습니다.

flowchart LR
  subgraph shared["scope:shared · layer:contracts"]
    IC["item-contracts-grpc<br/>(item.proto → 생성 타입)"]
  end
  subgraph itemctx["scope:item"]
    IA["item-api"]
  end
  subgraph marketctx["scope:market"]
    BFF["market-bff"]
    MA["market-api"]
  end
  IA -->|계약 서빙| IC
  BFF -->|"전송 계약만 import (허용)"| IC
  BFF x-- "프로젝트 직접 의존 금지" --x IA

그리고 BFF 쪽에서도 이 계약을 런타임까지 끌고 오지 않으려고 조심했습니다. 컨트롤러가 의존하는 건 gRPC 클라이언트의 "타입 모양"뿐이라, 클라이언트 뷰를 import type으로만 선언했습니다.

// apps/market-bff/src/app/items/grpc/item-api-grpc.types.ts
import type {
  GetItemRequest as ItemGetItemRequest,
  GetItemResponse__Output as ItemGetItemResponse__Output,
} from '@dunplay/item-contracts-grpc';
 
// Import-type only so the controller depends on the contract types
// without loading the contracts-grpc runtime entry point.
export interface ItemQueryGrpcClient {
  getItem(request: ItemGetItemRequest): Observable<ItemGetItemResponse__Output>;
}

라이브로 흐르게

터미널은 이름 그대로 "라이브"여야 합니다. 다만 여기서 realtime은 네오플을 실시간으로 부르는 것이 아닙니다. 실시간 게이트웨이 편에서 세운 원칙대로, realtime push는 새 provider 호출이 아니라 "저장된 새 snapshot이 생겼다"는 알림입니다. 그래서 프론트의 전략은 세 겹입니다.

  1. SSR 첫 페인트: 서버가 저장된 스냅샷으로 화면을 그립니다. 사용자는 빈 화면을 거치지 않습니다.
  2. realtime 구독: 브라우저가 게이트웨이 WebSocket에 붙어 market.price.updated를 받으면, 그걸 신호로 삼아 BFF 스냅샷을 다시 부릅니다.
  3. 폴링 fallback: 소켓이 끊긴 동안에만 30초 주기로 폴링합니다.
flowchart TD
  SSR["SSR 첫 페인트<br/>getItemTerminal (저장된 snapshot)"] --> V["TerminalView (client)"]
  V --> WS{realtime WS 연결?}
  WS -->|연결됨| PUSH["market.price.updated 수신"]
  PUSH --> INV["queryClient.invalidateQueries"]
  INV --> REF["BFF terminal 스냅샷 재조회"]
  REF --> V
  WS -->|끊김| POLL["30초 폴링 fallback"]
  POLL --> REF

핵심은 폴링을 항상 돌리지 않는다는 점입니다. refetchInterval을 realtime 연결 상태에 물려 둬서, 소켓이 살아 있으면 폴링을 끄고 push에만 반응합니다.

// apps/market-web/.../item/[id]/terminal-view.tsx
const onUpdate = useCallback(() => {
  void queryClient.invalidateQueries({ queryKey: terminalQueryKey(itemId) });
}, [queryClient, itemId]);
 
const realtime = useTerminalRealtime(itemId, initialSnapshot.realtimeSubscription, onUpdate);
 
const { data } = useQuery({
  queryKey,
  queryFn: () => fetchTerminalSnapshot(itemId),
  initialData: initialSnapshot, // SSR 스냅샷을 그대로 seed
  staleTime: 15_000,
  // realtime 소켓이 붙어 있으면 폴링을 끈다.
  refetchInterval: realtime.connected ? false : POLLING_INTERVAL_MS,
});

WebSocket 훅은 capped backoff로 재연결하고, 연결이 안 되면 connectedfalse로 유지해 폴링으로 자연스럽게 넘어갑니다. 이 역할 분담은 (3) 프론트 편에서 정한 규칙 — 서버 상태는 TanStack Query, 실시간은 별도 채널 — 의 연장입니다.

그리고 프론트가 이렇게 단순해질 수 있었던 건, BFF가 뷰 계약을 미리 번역해 줬기 때문입니다. 내부 gRPC는 int64를 문자열로 싣고(longs=String), 시각을 epoch ms로 주고, enum에 FRESHNESS_·COLLECTION_TIER_·PROVIDER_STATUS_ 접두어를 답니다. BFF의 매퍼가 이걸 브라우저가 바로 먹을 수 있는 값으로 바꿉니다 — 금액은 숫자로, 시각은 ISO 8601로, enum은 접두어를 떼고, auctionNo는 2^53을 넘길 수 있어 문자열로 유지합니다. 프론트가 받는 계약은 딱 이 한 덩어리입니다.

// apps/market-web/src/app/_lib/market-bff.ts
/** The composed /item/[id] terminal snapshot returned by the BFF. */
export interface TerminalSnapshot {
  readonly item: TerminalItemDetail;
  readonly trackedItem?: TerminalTrackedItem;
  readonly priceSummary: TerminalPriceSummary;
  readonly chart: TerminalChart;
  readonly listings: readonly TerminalListing[];
  readonly sales: readonly TerminalSale[];
  readonly freshness: TerminalFreshness; // FRESH | DELAYED | STALE | MAINTENANCE | NO_DATA
  readonly providerStatus?: TerminalProviderStatus;
  readonly realtimeSubscription: TerminalRealtimeSubscription;
}

프론트는 item이 item-api에서 왔는지 market-api에서 왔는지, 값이 원래 어떤 전송 타입이었는지 알 필요가 없습니다. 두 컨텍스트와 전송의 세부는 전부 BFF 안에서 끝나고, 화면은 TerminalSnapshot 하나만 봅니다. 이 편의 논지 — 프론트가 여러 API를 직접 엮게 두지 않고, BFF가 조합해 하나의 뷰 계약으로 내려준다 — 가 이 타입 하나로 요약됩니다.

Docker에서 item-api Prisma client 문제

기능이 다 붙고 나서 마지막으로 부딪힌 건 코드가 아니라 빌드였습니다. 이 조합이 end-to-end로 돌려면 item-api도 Docker 스택에 있어야 해서 gRPC 50052로 서비스를 추가했는데, 컨테이너가 부팅하자마자 죽었습니다. Prisma client 모듈을 못 찾는다는 에러였습니다.

원인은 미묘했습니다. 던플의 대부분 data-access(예: market-data-access)는 Prisma client를 기본 위치(top-level @prisma/client)로 생성합니다. 그런데 item-data-access만은 패키지 안 **custom output(generated/prisma)**으로 client를 뽑습니다. Dockerfile은 pnpm이 패키지를 복사한 뒤 workspace_modules의 스키마를 기준으로 prisma generate를 돌렸는데, custom output이라 client가 런타임 import가 찾는 자리(node_modules)가 아니라 엉뚱한 곳에 떨어졌습니다.

고친 건 한 줄, "어느 스키마 기준으로 generate하느냐"였습니다. prod install로 pnpm이 이미 node_modules에 materialize해 둔 스키마를 기준으로 generate하니, custom output이 import가 resolve되는 자리에 정확히 안착했습니다.

# apps/item-api/Dockerfile
# Generate against the *installed* node_modules schema (not workspace_modules):
# item-data-access emits its client to a custom in-package `generated/prisma`
# output, so the client must land there for the runtime import to resolve.
RUN PRISMA_BIN=$(find /workspace/node_modules/.pnpm -path '*/prisma/build/index.js' | head -1) && \
-    for schema in workspace_modules/@dunplay/*-data-access/prisma/schema.prisma; do \
+    for schema in node_modules/@dunplay/*-data-access/prisma/schema.prisma; do \
       if [ -f "$schema" ]; then \
         node "${PRISMA_BIN}" generate --schema "${schema}"; \

이 문제를 굳이 한 섹션으로 남기는 이유는, 이게 "라이브러리마다 output 규약이 다르면 Docker 조립에서 새는 곳이 생긴다"는 교훈이라서입니다. Docker 런타임 스모크로 조합 전체를 실데이터로 확인했습니다 — 합쳐진 GET …/terminal이 200이고 item은 item-api(name/rarity/type/image), 시세는 market-api(listings 20·sales 20·chart 52pts·tier), 미존재 아이템은 404, 웹 SSR /item/[id]도 200으로 아이템명을 렌더했고, collector tick에서 market.price.updated가 실제로 push되는 것까지 봤습니다. (3) 프론트 편의 마지막 원칙처럼, 되는 걸 확인한 것만 됐다고 적습니다.

정리하면

이 편의 논지는 하나였습니다. 프론트가 여러 API를 직접 엮게 두지 않고, BFF가 조합해 하나의 뷰 계약으로 내려준다.

  • 터미널 한 화면에 필요한 데이터는 카탈로그(item-api)와 시세(market-api)에 나뉘어 있고, 그 조합을 프론트가 아니라 BFF가 맡는다.
  • BFF는 두 gRPC를 병렬로 부르고, item-api를 존재 권위로 두어 하나의 TerminalDto로 합친다. 조합은 "합치기"가 아니라 "누가 무엇의 진실인가"를 정하는 일이었다.
  • 도메인 경계를 지키면서 조합하려고 item-contracts-grpcscope:shared로 재태깅했다 — 도메인 의존이 없는 순수 전송 계약이라 공유 규칙을 깨지 않는다.
  • 화면은 SSR로 그리고 realtime push로 살아 움직이며, 소켓이 끊기면 폴링으로 fallback한다. 프론트는 TerminalSnapshot 하나만 본다.
  • 전송의 세부(int64 문자열·epoch ms·enum 접두어)와 두 컨텍스트의 존재는 전부 BFF 안에서 끝난다.

(0)에서 "왜 BFF를 두는가"를 설계로 정했다면, 이 편은 그 BFF가 실제로 두 도메인을 조합하는 값을 하는 첫 사례였습니다.

다음 편(12)에서는 아이템 한 개가 아니라 시장 전체로 시야를 넓혀, 무엇이 오르고 내렸는지 보여주는 대시보드 — 랭킹·무버스·실시간 채널·인기 점수 이야기를 다루겠습니다.

같이 보면 좋은 글