던플 개발기 (0): 모노레포·BFF·수집 분리를 이렇게 설계한 이유

Dunplay

던플마켓(Dunplay - Market)은 던전앤파이터 경매장의 매물과 시세를 수집해 인기·급등·급락·차트로 보여 주는 서비스입니다. 혼자 만드는 사이드 프로젝트인데, 첫 커밋부터 앱이 네 개로 나뉘어 있고 내부 통신에는 gRPC까지 들어가 있습니다.

처음 구조를 잡을 때 가장 경계했던 건 "혼자 만드는데 이렇게까지 할 필요가 있나"라는 과설계였습니다. 그래서 이 글은 자랑하는 글이 아니라, 어떤 제약 때문에 이 구조가 강제됐는지를 다시 짚어 보는 기록입니다. 결론부터 말하면 모노레포·BFF·수집 분리는 취향이 아니라 던플마켓이 처음부터 안고 있던 세 가지 제약에서 나왔습니다.

한눈에 보면

  • 던플마켓은 외부 API가 불안정하고(점검·장애·약관 제약), 사용자 조회는 빨라야 하며, 미니앱을 계속 늘릴 플랫폼이라는 세 제약을 동시에 안고 시작했습니다.
  • 이 제약이 각각 수집 분리, BFF를 통한 읽기 경로, 모노레포를 끌어냈습니다.
  • 모노레포는 Nx + pnpm을 골랐습니다. .proto·OpenAPI 같은 계약이 여러 앱에 걸쳐 있어, 계약 변경을 한 번에 반영하고 import 경계를 CI에서 강제하기 위해서입니다.
  • 읽기 경로는 web → bff(REST) → api(gRPC) → DB/Redis로 흐릅니다. 브라우저는 내부 계약(gRPC·ORM·API key)을 모르고 BFF만 봅니다.
  • 수집 경로는 EventBridge → SQS → cron worker → 네오플 API → DB로, 사용자 조회 경로와 완전히 분리돼 있습니다. 네오플이 점검에 들어가도 조회는 멈추지 않습니다.
  • 처음부터 마이크로서비스로 잘게 쪼개지 않았습니다. 서비스는 4개로 시작하고, 코드 내부만 계층으로 나눴습니다.

제약에서 구조가 나온다

구조를 먼저 그리고 거기에 기능을 끼워 넣으면 십중팔구 과설계가 됩니다. 그래서 결정의 출발점을 제약에 뒀습니다.

제약 1. 데이터 공급자가 통제 밖에 있다. 던플마켓의 모든 시세 데이터는 네오플 공식 Open API에서 옵니다. 그런데 이 API는 게임 점검 시간과 동일하게 멈추고(HTTP 503, DNF980), 호출 한도가 있으며, 약관상 결과 데이터의 저장·가공·지속 접속에 제한이 있습니다. 즉 외부 의존이 정상적으로도 자주 끊기는 것을 전제로 설계해야 했습니다.

제약 2. 읽기와 쓰기의 성격이 정반대다. 사용자 조회는 빠르고 안정적이어야 합니다. 반면 수집은 느리고, 몰리고, 실패하고, 재시도해야 합니다. 이 둘을 한 프로세스에 두면 수집 backpressure가 조회 응답을 갉아먹습니다.

제약 3. 미니앱을 계속 늘릴 계획이다. 던플마켓은 첫 제품일 뿐이고, 이후 골드 시세·길드·랭킹·아바타 마켓이 붙습니다. 그래서 "던플마켓 하나"가 아니라 "미니앱을 얹는 플랫폼"으로 가정해야 했습니다.

아래 전체 그림은 이 세 제약이 코드 구조로 번역된 결과입니다. 위쪽 사용자 조회 경로와 아래쪽 외부 수집 경로가 갈라져 있는 점이 핵심입니다.

flowchart LR
  U[브라우저] --> WEB[market-web<br/>Next.js SSR]
  WEB -->|REST| BFF[market-bff<br/>NestJS REST]
  U -->|검색 등 직접 호출 REST| BFF
  BFF -->|gRPC| API[market-api<br/>NestJS gRPC]
  API --> PG[(PostgreSQL)]
  API --> REDIS[(Redis)]
 
  EBS[EventBridge Scheduler] -->|1분 tick| SQS1[SQS scheduler]
  SQS1 --> CRON[market-cron<br/>수집 워커]
  CRON -->|due 아이템| PG
  CRON -->|수집 job| SQS2[SQS collect]
  SQS2 --> CRON
  CRON --> NEO[네오플 Open API]
  CRON --> PG

왜 모노레포인가

계약이 여러 앱에 걸쳐 있다

던플마켓에는 두 종류의 계약이 있습니다. 하나는 BFF와 백엔드 사이의 gRPC 계약(.proto)이고, 다른 하나는 브라우저와 BFF 사이의 REST 계약(OpenAPI)입니다. 그리고 이 계약들은 한 앱에 갇혀 있지 않습니다.

.proto        → market-api(서버 구현) + market-bff(gRPC 클라이언트)
OpenAPI       → market-bff(제공) + market-web(Axios 타입 생성)

.proto 한 줄을 고치면 market-apimarket-bff가 동시에 영향을 받습니다. 폴리레포라면 이건 "한쪽 저장소를 고치고, 패키지를 publish하고, 다른 저장소에서 버전을 올리는" 과정이 됩니다. 혼자 만드는 프로젝트에서 이 계약 동기화 비용은 기능 개발보다 먼저 사람을 지치게 합니다.

모노레포에서는 계약과 그 소비자들이 한 커밋 안에 있어, 타입 깨짐이 PR 단계에서 바로 드러납니다. 이게 모노레포를 고른 첫 번째 이유입니다.

기준 폴리레포 모노레포(단일 저장소)
계약 변경 반영 저장소별 publish·버전 동기화 한 커밋에 원자적 반영
타입 깨짐 발견 소비자 저장소에서 뒤늦게 변경과 동시에
툴체인 저장소마다 중복 설정 단일 설정
솔로 개발 비용 조정 오버헤드 큼 낮음
단점 저장소가 커지고 경계가 흐려지기 쉬움

모노레포의 단점은 "경계가 흐려진다"는 점인데, 이건 다음 항목으로 막습니다.

Nx를 고른 이유 (vs Turborepo)

모노레포 도구로는 Nx와 Turborepo를 두고 고민했습니다. 둘 다 task 캐시와 affected 실행을 제공하지만, 던플마켓에는 Nx가 더 맞았습니다.

  • import 경계 강제: Nx의 @nx/enforce-module-boundaries로 "이 계층은 저 계층을 import할 수 없다"를 ESLint 규칙으로 CI에서 막을 수 있습니다. 경계가 흐려지는 모노레포의 단점을 정확히 메웁니다.
  • 혼합 스택 제너레이터: 프론트엔드(Next.js)와 백엔드(NestJS)가 한 저장소에 섞여 있는데, Nx는 두 플러그인을 모두 1급으로 다룹니다.
  • 프로젝트 그래프: 앱과 라이브러리의 의존 관계를 그래프로 보면서 경계를 설계할 수 있습니다.

Turborepo가 더 가볍고 설정이 단순하다는 장점은 분명합니다. 다만 "경계를 규칙으로 강제하고 싶다"는 요구가 1순위였기 때문에 Nx를 택했습니다. 이 비교는 Turborepo vs Nx 비교 글에서 더 자세히 다뤘습니다.

태그로 경계를 규칙화한다

Nx 프로젝트마다 scope, layer, runtime 태그를 붙이고, 태그 사이의 import 가능 여부를 규칙으로 선언합니다.

flowchart TD
  subgraph apps [layer:app]
    WEB[market-web<br/>runtime:web]
    BFF[market-bff<br/>runtime:bff]
    API[market-api<br/>runtime:api]
    CRON[market-cron<br/>runtime:cron]
  end
  subgraph server [runtime:server 백엔드 공유]
    DOMAIN[domain]
    APP[application]
    DATA[data-access<br/>TypeORM]
    NEO[neople-client]
  end
  CONTRACTS[contracts-grpc<br/>runtime:any]
 
  API --> APP
  CRON --> APP
  APP --> DOMAIN
  DATA --> DOMAIN
  API --> DATA
  CRON --> DATA
  BFF --> CONTRACTS
  WEB -. REST only .-> BFF

핵심 규칙은 몇 가지로 압축됩니다.

  • runtime:web(브라우저 코드)은 gRPC·TypeORM·네오플 client를 import할 수 없습니다. 브라우저 번들에 내부 계약이 새는 것을 원천 차단합니다.
  • layer:domain은 프레임워크·transport·ORM에 의존하지 않습니다. 도메인 규칙이 NestJS나 TypeORM에 묶이지 않게 합니다.
  • layer:applicationlayer:data-access 구현을 직접 import하지 않고 port로만 의존합니다. ORM 교체 가능성을 계층 경계로 남겨 둡니다.

이 규칙이 모노레포의 "한 저장소라서 아무 데서나 가져다 쓰게 되는" 함정을 막아 줍니다. 경계 설계 자체에 대한 일반론은 프론트엔드 모노레포 경계 전략에 따로 정리해 뒀습니다.

왜 BFF를 두는가 (읽기 경로)

읽기 경로는 브라우저에서 시작해 DB까지 이렇게 흐릅니다.

sequenceDiagram
  participant B as 브라우저
  participant W as market-web(SSR)
  participant F as market-bff(REST)
  participant A as market-api(gRPC)
  participant D as PostgreSQL / Redis
  B->>W: 페이지 요청
  W->>F: REST 호출 (서버 간)
  F->>A: gRPC 호출
  A->>D: 조회
  D-->>A: 데이터
  A-->>F: gRPC 응답
  F-->>W: 화면에 맞춘 REST 응답
  W-->>B: SSR HTML
  Note over B,F: 검색 등 일부는 브라우저가 BFF REST를 직접 호출

여기서 BFF(Backend For Frontend)는 "프론트엔드 화면에 맞춰 데이터를 빚어 주는 얇은 서버"입니다. 굳이 한 단계를 더 두는 이유는 세 가지입니다.

1. 브라우저가 내부 계약을 몰라야 한다. 브라우저나 market-web은 gRPC·.proto·TypeORM·네오플 API key를 전혀 모릅니다. 외부에 노출되는 표면은 BFF의 REST API 하나뿐입니다. 내부 통신 방식을 바꿔도 브라우저 계약은 그대로입니다.

2. SSR과 브라우저가 같은 계약을 쓴다. market-web은 첫 화면(차트·시세·순위)을 서버에서 그릴 때도, 검색·정렬을 브라우저에서 처리할 때도 같은 BFF REST를 호출합니다. 데이터 진입점이 하나라 화면 조립이 단순해집니다.

3. 화면에 맞춘 응답을 한 곳에서 만든다. 메인 화면은 인기·급등·급락·차트를 한 번에 필요로 합니다. 이걸 브라우저가 여러 번 호출해 조합하는 대신, BFF가 GET /api/v1/home 하나로 화면에 맞게 묶어 줍니다.

내부는 gRPC, 외부는 REST

통신 방식을 안과 밖에서 다르게 가져갔습니다.

구간 방식 이유
브라우저 ↔ BFF REST + OpenAPI 브라우저 친화적, 캐시 가능, OpenAPI에서 Axios 타입 생성
BFF ↔ market-api gRPC + Protocol Buffers 강타입 계약(.proto)이 source of truth, 서버 간 효율

REST가 외부 계약으로 좋은 이유는 REST API는 HTTP의 의미를 어떻게 살린 설계일까에서 다룬 그대로입니다. 브라우저·캐시·툴 생태계가 전부 HTTP 의미 위에 서 있으니까요. 반면 내부 서버 간 통신은 사람이 직접 호출할 일이 없고 계약의 엄밀함과 효율이 더 중요해서 gRPC가 맞았습니다.

주의할 점은 market-bffDB·Redis·네오플에 직접 접근하지 않는다는 것입니다. BFF는 오직 market-api만 gRPC로 호출합니다. 데이터 접근 권한과 비밀은 market-api 안쪽에만 둡니다.

왜 수집을 분리하는가 (수집 경로)

가장 중요한 분리입니다. 사용자 조회 경로와 외부 데이터 수집 경로를 아예 다른 앱(market-cron)으로 떼어 냈습니다.

flowchart LR
  EBS[EventBridge Scheduler<br/>rate 1분] --> SQS1[SQS scheduler tick]
  SQS1 --> DISP[market-cron<br/>dispatcher]
  DISP -->|due 아이템 claim| PG[(PostgreSQL)]
  DISP --> SQS2[SQS collect-item]
  SQS2 --> COL[market-cron<br/>collector]
  COL --> NEO[네오플 Open API]
  COL --> PG
  GATE[provider 상태<br/>+ circuit breaker] -. 점검·장애 시 중단 .-> DISP

읽기 경로와 수집 경로는 성격이 정반대라 한 프로세스에 둘 이유가 없습니다.

특성 읽기 경로(web/bff/api) 수집 경로(cron)
트리거 사용자 요청 스케줄(EventBridge tick)
지연 요구 빨라야 함 느려도 됨
데이터 출처 DB·Redis 외부 네오플 API
실패 빈도 낮음 높음(점검·rate limit)
확장 축 사용자 수 활성 아이템 수

이 표가 분리의 이유를 거의 다 설명합니다. 하나씩 보면 이렇습니다.

1. 장애 격리. 네오플이 점검(503/DNF980)에 들어가면 수집은 멈춰야 하지만, 사용자는 저장된 마지막 데이터를 계속 봐야 합니다. 수집과 조회가 분리돼 있으니 수집 쪽이 죽어도 조회 경로는 멀쩡합니다. 화면에는 마지막 갱신 시각과 점검 상태만 띄웁니다.

2. 부하 분리. 수집은 1분마다 인기 아이템 수백 개를 외부에서 끌어오는 bursty 작업입니다. 이 부하가 사용자 응답을 건드리면 안 됩니다. SQS를 사이에 둬서 수집 job을 큐로 흘려보내고, 워커는 자기 속도로 처리합니다. 호출 한도를 넘기면 무리하게 호출하지 않고 backlog로 남깁니다.

3. 약관 대응 스위치. 네오플 약관상 실제 반복 수집은 컴플라이언스 확인 전까지 켜면 안 됩니다. 수집이 별도 앱·feature flag로 떨어져 있으니, 조회 서비스는 그대로 두고 수집만 꺼 둘 수 있습니다. 이건 단순한 설계 미감이 아니라 운영상 꼭 필요한 분리였습니다.

4. 다른 확장 축. 조회 트래픽은 사용자 수에 비례하고, 수집 부하는 활성 아이템 수에 비례합니다. 두 축이 다르므로 따로 스케일하는 편이 자연스럽습니다.

수집을 단순 cron이 아니라 EventBridge → SQS → dispatcher → SQS → collector 구조로 둔 건, 아이템마다 스케줄을 만들지 않으면서도 인기도에 따라 1·3·5·10분 간격으로 수집하고(tier), 워커를 늘려도 같은 아이템을 중복 처리하지 않기(lease) 위해서입니다. tier 수집과 증분 저장, 점검 대응 같은 수집 내부 이야기는 분량상 다음 편(개발기 Backend 편)에서 따로 다루겠습니다.

계층은 나누되 서비스는 아직 나누지 않는다

마지막으로 짚을 판단은 "어디까지 나눌 것인가"입니다. 던플마켓은 처음부터 item-api, listing-api, price-api처럼 잘게 쪼개지 않았습니다. 서비스를 나누면 배포·장애 추적·계약 버전 관리 비용이 먼저 늘기 때문입니다.

대신 서비스는 4개(web/bff/api/cron)로 시작하고, 코드 내부만 계층으로 나눴습니다.

domain        도메인 모델과 규칙 (프레임워크·ORM 모름)
application   use case와 port
data-access   TypeORM entity·mapper·repository
infra         네오플 adapter 등 외부 연동

트래픽, 장애 격리, 팀 소유권 중 하나가 실제로 달라질 때만 서비스를 더 분리한다는 기준을 세워 뒀습니다. 지금은 그 신호가 없으니 계층 경계로 충분합니다. 이렇게 하면 나중에 한 계층을 떼어 독립 서비스로 승격하기도 쉽습니다.

기술 선정 요약

지금까지의 선택을 한 표로 모으면 이렇습니다.

영역 선택 핵심 이유
저장소 Nx 모노레포 + pnpm 계약을 한 커밋에 반영, 태그로 import 경계 강제
프론트엔드 Next.js App Router SSR로 첫 화면(차트·순위) 서버 렌더, RSC 기본
외부 통신 REST + OpenAPI 브라우저 친화적, 타입 생성, 캐시
내부 통신 gRPC + Protocol Buffers 강타입 계약, 서버 간 효율
백엔드 NestJS DI·모듈 구조, gRPC/REST 양쪽 1급 지원
수집 EventBridge + SQS + cron 워커 조회와 분리, 큐로 부하 흡수, lease로 중복 방지
저장소(DB) PostgreSQL + Redis 영속은 PG, 빠른 조회·인기점수는 Redis 보조
상태 관리 TanStack Query + Zustand 서버 상태와 작은 client 상태를 역할로 분리

각 선택의 공통점은 "지금 당장 화려한 것"보다 "제약을 견디고 나중에 갈아끼울 수 있는 것"을 골랐다는 점입니다.

정리하면

던플마켓의 구조는 처음부터 거창하게 설계한 게 아니라, 세 가지 제약에 하나씩 답하다 보니 모인 결과에 가깝습니다.

  • 외부 API가 자주 끊기니 수집을 조회에서 떼어 냈고,
  • 브라우저에 내부 계약을 숨기고 화면에 맞춘 응답을 주려고 BFF를 뒀으며,
  • 계약이 여러 앱에 걸치고 미니앱을 계속 늘릴 거라 모노레포로 묶고 경계를 규칙으로 강제했습니다.

반대로, 지금 필요 없는 분리(서비스 쪼개기)는 일부러 미뤘습니다. 개인 프로젝트에서 가장 비싼 건 코드가 아니라 "유지할 수 있는 범위를 넘는 구조"라고 봤기 때문입니다.

다음 편에서는 이 구조 위에서 수집 경로를 어떻게 만들었는지 — tier 기반 수집, 증분 저장, 그리고 네오플 점검을 정상 운영 조건으로 다루는 방법 — 을 다루겠습니다.

같이 보면 좋은 글