던플 개발기 (10): 고정 크론에서 collect-due로 — 실데이터 동적 수집

Dunplay

지난 편에서 전체 스택을 로컬에 띄웠다면, 이번엔 그 위에서 언제·누구를 수집할지를 다시 짠 이야기입니다. 스택을 도커로 묶고 나니 수집기가 실제로 돌기 시작했는데, 처음에 넣어 둔 방식은 "정해진 아이템 목록을 정해진 간격으로 순회하는" 고정 루프였습니다. 아이템이 4개일 때는 잘 돌았습니다. 그러다 수집 대상을 하나씩 늘리면서 이 방식이 조용히 깨지기 시작했습니다.

이 편의 주제는 하나입니다. 수집 대상이 늘수록 고정 스케줄은 깨진다. "지금 수집할 때가 된 것"을 코드에 박힌 목록이 아니라 DB 질의로 뽑는 collect-due 워커로 옮기고, 그 김에 synthetic seed를 걷어내 실데이터 baseline으로 전환하고, 네오플 API 키를 item·market 두 개로 갈라낸 과정을 정리합니다.

한눈에 보면

  • 처음엔 도커 collector가 ITEM_IDS 환경변수에 박힌 목록을 매 tick마다 통째로 순회했습니다. 아이템을 추가할 때마다 이 목록을 손으로 고쳐야 했고, tier와 상관없이 전부 같은 간격으로 수집됐습니다.
  • collect-due는 목록 대신 DB에서 next_collect_at <= now()인 아이템만 뽑아 수집합니다. import 대상에 아이템을 추가하면 자동으로 수집에 들어옵니다.
  • due 아이템은 FOR UPDATE SKIP LOCKED로 lease를 걸어 집고, 수집 뒤에는 성패와 무관하게 tier 간격으로 next_collect_at을 다시 밀어 둡니다. 일시 실패가 아이템을 "영원히 due" 상태로 박제하지 않게요.
  • 로컬 baseline을 synthetic seed에서 실 네오플 데이터로 바꿨습니다. dev:seed(=dev:seed:real)는 import → collect-due → relay → 재계산을 실행하고, synthetic은 dev:seed:synthetic으로 분리했습니다.
  • 네오플 키를 NEOPLE_ITEM_API_KEY(/df/items)와 NEOPLE_MARKET_API_KEY(/df/auction) 둘로 나눴습니다. 한 워크로드가 다른 워크로드의 rate-limit 예산을 몰래 태우지 못하도록 단일 키 fallback을 없앴습니다.
  • 이 모든 게 (1)편의 tier 모델 위에서 굴러갑니다. collect-due는 tier를 "간격을 계산하는 함수"로 소비할 뿐, tier가 무엇인지는 여전히 domain이 정합니다.

고정 스케줄이 안 되는 순간

도커 스택에 넣어 둔 첫 collector는 부끄러울 만큼 단순했습니다. 수집할 아이템 id를 환경변수에 나열하고, 매 tick마다 그 목록을 통째로 돌며 하나씩 수집하는 셸 루프였습니다.

# docker-compose.yml (before) — collector 서비스
environment:
  COLLECT_TICK: '60'
  ITEM_IDS: 785e56a0ed4e3efd573da1f56a45217d f9941d3fa0b8253bb0b2567a29b1299f ae1b370972f85389d77eee716ae9f6e2 ...
command:
  - |
    while true; do
      for id in $$ITEM_IDS; do node main.js collect-item --itemId="$$id" || true; done;
      node main.js relay-outbox || true;
      sleep "$${COLLECT_TICK:-60}";
    done

여기엔 문제가 두 개 겹쳐 있었습니다.

첫째, 아이템을 추가하려면 두 곳을 고쳐야 했습니다. 수집 대상은 import-targets.ts에 이름으로 선언하는데, 그 아이템을 실제로 수집하려면 네오플에서 해석된 32자 hex id를 다시 docker-compose.ymlITEM_IDS에 손으로 붙여 넣어야 했습니다. 목록 두 개가 서로를 모른 채 어긋나기 딱 좋은 구조입니다.

둘째, tier가 아무 의미가 없었습니다. (1)편에서 인기도에 따라 HOT_1M·ACTIVE_3M·WARM_5M·COLD_10M으로 나눠 수집 간격을 다르게 두기로 해 놓고, 정작 collector는 목록에 있는 모든 아이템을 COLLECT_TICK(60초)마다 똑같이 긁었습니다. tier 설계가 DB에만 있고 실행에는 반영되지 않은 셈입니다.

아이템이 4개일 때는 이 어긋남이 안 보였습니다. 그런데 PC방 토큰 교환권, 소울 결정 같은 아이템을 붙이면서 목록이 길어지자 곧바로 드러났습니다. 대상이 늘수록 고정 목록은 관리 비용이 선형으로 늘고, tier는 여전히 죽어 있었습니다.

flowchart LR
  N[import-targets.ts에 아이템 추가] -.수동 복붙.-> IDS["docker-compose ITEM_IDS"]
  T["매 tick 60초"] --> L[ITEM_IDS 전체 순회]
  IDS --> L
  L --> C1[collect-item id1]
  L --> C2[collect-item id2]
  L --> C3[collect-item id3]
  C1 --> X["tier 무시 · 전부 같은 간격"]
  C2 --> X
  C3 --> X

방향은 분명했습니다. "누구를 수집할지"의 근거를 코드에 박힌 목록에서 떼어내, 이미 tier와 next_collect_at을 들고 있는 tracked_items 테이블로 옮겨야 했습니다.

collect-due: 수집 시점을 질의로

collect-due는 목록을 순회하지 않습니다. 매 tick마다 DB에 **"지금 수집할 때가 된 아이템"**을 물어보고, 돌아온 것만 수집합니다. 판정 기준은 이미 (1)편에서 만들어 둔 next_collect_at 하나입니다.

flowchart TD
  S["collect-due tick"] --> P{"provider HEALTHY / RECOVERING?"}
  P -->|아니오| STOP["수집 중단 · 로그만 남김"]
  P -->|예| CLAIM["claimDueItems<br/>next_collect_at ≤ now()<br/>FOR UPDATE SKIP LOCKED"]
  CLAIM --> E{"due 아이템 있나?"}
  E -->|없음| NONE["nothing due"]
  E -->|있음| LOOP["각 due 아이템"]
  LOOP --> COL["네오플 경매장 수집"]
  COL --> R{"tier == DORMANT?"}
  R -->|예| DM["demoteToDormant<br/>next_collect_at = null"]
  R -->|아니오| RS["reschedule<br/>now + tier interval"]

핵심은 due 아이템을 집는 질의입니다. 이건 collector가 여러 개 돌 수 있는 상황을 전제로 하므로, (1)편에서와 같은 FOR UPDATE SKIP LOCKED lease를 씁니다. 특히 claim과 lease 쓰기를 한 문장으로 묶어, row를 집는 순간 곧바로 lease가 걸리게 했습니다.

UPDATE tracked_items AS t
SET lease_expires_at = $leaseExpiry
FROM (
  SELECT item_id
  FROM tracked_items
  WHERE next_collect_at IS NOT NULL
    AND next_collect_at <= $now
    AND (lease_expires_at IS NULL OR lease_expires_at <= $now)
  ORDER BY next_collect_at ASC
  LIMIT $limit
  FOR UPDATE SKIP LOCKED
) AS due
WHERE t.item_id = due.item_id
RETURNING t.item_id AS "itemId", t.collection_tier AS "collectionTier", ...

SKIP LOCKED 덕분에 collector(또는 dispatcher 인스턴스)를 늘려도 한 아이템을 한 워커만 집고, 나머지는 잠긴 row를 건너뜁니다. lease_expires_at이 지난 row도 다시 대상에 들어오므로, 워커가 중간에 죽어도 lease가 끝나면 다른 워커가 이어받습니다. 이 raw SQL은 data-access 계층 안에만 두고, 결과 row는 mapper로 파싱해 domain 모델로만 바깥에 내보냅니다.

워커 본체는 그 위에서 짧습니다. provider 상태를 확인하고, due 아이템을 집고, 하나씩 수집한 뒤, 수집 성패와 상관없이 tier 간격으로 다시 예약합니다.

async run(now: Date, batchSize = 100, leaseMs = 120_000): Promise<void> {
  const status = await this.statusRepo.ensureExists(NEOPLE_DNF, 'HEALTHY', now);
  if (!isDispatchAllowed(status.status)) {
    this.logger.log(`provider ${status.status} — collection paused`);
    return;
  }
 
  const due = await this.dispatcherRepo.claimDueItems(now, leaseMs, batchSize);
  if (due.length === 0) { this.logger.log('nothing due'); return; }
 
  for (const item of due) {
    try {
      await this.collect.collect(item.itemId, now);
    } catch (error) {
      this.logger.error(`collect failed ${item.itemId}: ${...}`);
    }
    // 수집이 실패해도 tier 간격으로 다시 예약한다. 일시 실패가
    // 아이템을 "영원히 due" 상태로 박제하지 않도록.
    const next = nextCollectAtForTier(item.collectionTier, now);
    if (next === null) {
      await this.dispatcherRepo.demoteToDormant(item.itemId);
    } else {
      await this.dispatcherRepo.reschedule(item.itemId, next);
    }
  }
}

주석으로 남긴 한 줄이 이 워커의 성격을 요약합니다. 일시 실패한 아이템도 반드시 다시 예약합니다. 수집이 timeout이나 일시 오류로 실패했다고 next_collect_at을 그대로 두면, 그 아이템은 매 tick마다 다시 due로 잡혀 실패를 반복하며 다른 아이템의 예산까지 잡아먹습니다. 실패든 성공이든 tier 간격만큼 뒤로 밀어 두면, 다음 순번에 자연스럽게 재시도되면서 큐가 특정 아이템에 물리지 않습니다.

맨 앞의 provider 상태 확인은 (2)편과 이어지는 대목입니다. isDispatchAllowedHEALTHYRECOVERING에서만 참이라, 네오플이 점검(503/DNF980)이거나 DEGRADED면 collect-due는 due 아이템이 있어도 조용히 멈춥니다. "수집할 때가 됐는가"와 "지금 수집해도 되는가"를 한곳에서 함께 보는 셈입니다.

전체 tick을 흐름으로 보면 이렇습니다. 도커 collector는 이제 ITEM_IDS 순회 대신 collect-duerelay-outbox만 반복하고, 무엇을 수집할지는 전적으로 DB가 정합니다.

sequenceDiagram
  participant TK as collector loop(tick)
  participant DUE as collect-due
  participant PG as PostgreSQL(tracked_items)
  participant NE as 네오플 /df/auction
  participant OX as relay-outbox → Kafka
  TK->>DUE: collect-due
  DUE->>PG: claimDueItems (FOR UPDATE SKIP LOCKED)
  PG-->>DUE: due 아이템 + lease
  loop 각 due 아이템
    DUE->>NE: 경매장 매물·판매 수집
    NE-->>DUE: 응답
    DUE->>PG: reschedule(next_collect_at = now + tier)
  end
  TK->>OX: relay-outbox (신규 snapshot을 Kafka로)

한 가지는 솔직히 적어 둡니다. 운영에서 이 tick의 자리는 EventBridge 스케줄 + SQS 워커(별도 task)가 맡을 예정이고, collect-due는 그 워커가 큐로 흘려보낼 일을 로컬에서 인라인으로 대신하는 stand-in입니다. 하지만 "due를 질의로 뽑아 tier로 재예약한다"는 핵심 로직은 로컬이든 운영이든 그대로여서, 나중에 SQS로 옮겨도 이 판정 규칙은 재사용됩니다.

synthetic을 걷어내고 실데이터로

collect-due가 실제 due 아이템을 수집하게 되자, 그동안 화면을 채우던 synthetic seed가 오히려 걸리적거리기 시작했습니다. 초기엔 네오플 키 없이도 대시보드가 비어 보이지 않게 seed-* id로 가짜 시세를 심어 뒀는데, 이게 실데이터와 섞이면 총액·랭킹이 오염됩니다.

그래서 로컬 baseline 자체를 실데이터 기준으로 뒤집었습니다. 기본 dev:seed는 실 네오플 데이터를 import·수집하는 경로가 되고, synthetic은 오프라인/테스트 전용으로 명시적으로 갈라냈습니다.

// package.json (루트)
"dev:seed:real":      "... import-items && ... collect-due && ... relay-outbox && ... recalculate-popularity",
"dev:seed:synthetic": "... seed-test-items && ... recalculate-popularity",
"dev:purge:synthetic":"... market-cron:purge-synthetic-data",
"dev:seed":           "pnpm dev:seed:real",   // 기본값이 실데이터
"dev:setup":          "pnpm dev:setup:real"    // infra → migration → seed:real

dev:seed:real은 네오플 키가 없으면 fallback 없이 실패합니다. "키가 없으면 조용히 가짜 데이터로 대체"하는 편의가 오히려 실데이터인 척하는 화면을 만들기 때문입니다. 채워지지 않으면 채워지지 않은 대로 두고, 왜 비었는지는 로그가 말하게 했습니다.

synthetic이 로컬 DB에 남아 있더라도 운영 화면 질의는 이를 걷어내야 합니다. 그래서 seed-, integration-item- 두 접두사를 read query에서 배제하는 필터를 두고, 대시보드·랭킹·무버스·차트 질의가 공통으로 씁니다.

export const SYNTHETIC_ITEM_ID_PREFIXES = ['seed-', 'integration-item-'] as const;
 
// $queryRaw WHERE 절용 술어. 접두사는 SQL 리터럴(사용자 입력 아님)이라 주입면이 없다.
export function notSyntheticItemIdSql(itemIdColumn: Prisma.Sql): Prisma.Sql {
  return Prisma.sql`${itemIdColumn} NOT LIKE 'seed-%' AND ${itemIdColumn} NOT LIKE 'integration-item-%'`;
}

이 필터는 방어선이 이중이라는 점이 포인트입니다. dev:purge:synthetic이 synthetic row를 실제로 지우지만, 그 사이 어중간하게 남은 로컬 DB가 fixture를 화면에 흘리지 않도록 read 단계에서 한 번 더 막습니다. 실 네오플 id는 32자 hex라 seed-·integration-item- 접두사와 절대 겹치지 않으므로, 실데이터를 실수로 걸러 낼 위험은 없습니다.

Neople 키를 item·market으로 나눈 이유

실데이터 수집으로 넘어오자 새 제약이 하나 생겼습니다. rate limit입니다. (1)편에서 적었듯 네오플 호출 한도는 API key 기준 초당 500·분당 30,000인데, 이 예산을 성격이 다른 두 워크로드가 나눠 쓰고 있었습니다.

  • item 워크로드 — 카탈로그 조회와 import. item-api, market-cronimport-items/df/items를 호출합니다. 사용자 검색이나 신규 아이템 등록에 반응해 산발적으로 튑니다.
  • market 워크로드 — 경매장 수집. market-cron의 collect / collect-due가 /df/auction을 due 아이템마다 반복 호출합니다. tier가 촘촘해질수록 꾸준히 예산을 씁니다.

이 둘이 한 키를 공유하면, 수집 워커가 tier를 올려 /df/auction을 몰아칠 때 그 소비가 카탈로그 검색의 예산까지 갉아먹습니다. 반대로 대량 import가 돌면 수집이 throttle에 걸립니다. 서로의 부하가 상대의 한도를 침범하는 구조입니다.

그래서 키를 두 개의 별도 발급 credential로 나누고, 각자 독립된 rate-limit 예산을 갖게 했습니다.

flowchart LR
  subgraph itemwl["item 워크로드"]
    IA["item-api catalog"]
    IMP["market-cron import-items"]
  end
  subgraph marketwl["market 워크로드"]
    COL["market-cron collect / collect-due"]
  end
  IA --> IK["NEOPLE_ITEM_API_KEY"]
  IMP --> IK
  COL --> MK["NEOPLE_MARKET_API_KEY"]
  IK --> ITEMS["/df/items · 독립 rate-limit 예산"]
  MK --> AUC["/df/auction · 독립 rate-limit 예산"]

구현에서 신경 쓴 건 "실수로 다시 합쳐지지 않게" 만드는 것이었습니다. 각 호출자가 자기 키 변수 이름을 명시적으로 넘기게 하고, 단일 변수 fallback을 아예 제거했습니다.

export const NEOPLE_ITEM_API_KEY_VAR = 'NEOPLE_ITEM_API_KEY';    // /df/items
export const NEOPLE_MARKET_API_KEY_VAR = 'NEOPLE_MARKET_API_KEY'; // /df/auction
 
export function loadNeopleApiConfig(options: LoadNeopleApiConfigOptions): NeopleApiConfig {
  const { keyVar, env = process.env } = options;   // keyVar는 필수
  const apiKey = (env[keyVar] ?? '').trim();
  if (apiKey.length === 0) {
    // 값이 아니라 '변수 이름'만 로그에 남긴다. 키 값은 절대 echo하지 않는다.
    throw new NeopleApiConfigError(`${keyVar} must be set`);
  }
  ...
}

예전엔 NEOPLE_API_KEY 하나에 암묵적 fallback이 있어서, 한 워크로드가 키를 지정하지 않으면 조용히 다른 워크로드의 키를 재사용할 수 있었습니다. 지금은 keyVar가 필수라 그런 은근한 공유가 원천적으로 불가능합니다. BFF와 market-web은 여전히 어떤 네오플 키도 받지 않고, 키 값은 로컬 .env에만 두고 코드·문서·로그·git 어디에도 남기지 않는다는 원칙은 그대로입니다.

(1)편 tier와 어떻게 맞물리나

여기까지 오면 (1)편의 tier 모델과 이번 편의 collect-due가 어떻게 역할을 나누는지가 분명해집니다. tier가 무엇이고 어떤 간격을 갖는지는 여전히 domain이 정하고, collect-due는 그걸 소비만 합니다.

간격은 domain의 순수 함수 하나에 모여 있습니다. collect-due는 tier 이름을 이 함수에 넣어 다음 수집 시각을 받을 뿐, 60초니 300초니 하는 숫자를 워커 안에 흩뿌리지 않습니다.

export const COLLECTION_TIER_INTERVAL_MS = {
  HOT_1M: 60_000,
  ACTIVE_3M: 180_000,
  WARM_5M: 300_000,
  COLD_10M: 600_000,
  DORMANT: null, // 주기 수집 없음
} as const;
 
export function nextCollectAtForTier(tier: CollectionTier, from: Date): Date | null {
  const intervalMs = COLLECTION_TIER_INTERVAL_MS[tier];
  if (intervalMs === null) return null; // DORMANT → dispatch 회전에서 빠진다
  return new Date(from.getTime() + intervalMs);
}

덕분에 각 계층이 자기 몫만 봅니다.

  • domain — tier의 정의와 간격, DORMANT는 예약하지 않는다는 규칙(null).
  • 인기 점수 스코어러(별도, 3분 주기) — 조회·거래 신호로 아이템의 tier를 다시 매긴다.
  • collect-due — "지금 due인 것"을 집어 수집하고, 그때의 tier로 next_collect_at을 다시 민다.

이 분업이 (1)편과 이번 편을 잇는 지점입니다. (1)편에서 "누구를 언제 수집할지"를 tier로 설계했지만, 도커의 첫 collector는 그 설계를 실행에 반영하지 못한 채 목록만 순회했습니다. collect-due는 그 간극을 메웁니다. tier는 여전히 domain이 정하고, 그 결정이 마침내 next_collect_at을 통해 실제 수집 주기로 이어집니다. DORMANT로 내려간 아이템은 demoteToDormantnext_collect_atnull이 되어 due 질의에서 빠지고, 상세 조회나 찜으로 다시 활성화되기 전까지 예산을 쓰지 않습니다.

정리하면

이번 편의 전제는 하나였습니다. 수집 대상이 늘수록 고정 스케줄은 깨진다.

  • 코드에 박힌 ITEM_IDS 목록을 순회하는 대신, next_collect_at <= now()인 아이템을 FOR UPDATE SKIP LOCKED로 집어 수집한다. import 대상에 아이템을 추가하면 자동으로 수집에 들어온다.
  • 수집 성패와 무관하게 tier 간격으로 다시 예약해, 일시 실패가 아이템을 "영원히 due"로 박제하지 않게 한다.
  • 로컬 baseline을 synthetic에서 실데이터로 뒤집고, synthetic은 dev:seed:synthetic·dev:purge:synthetic으로 분리하며, read query는 seed-·integration-item- 접두사를 이중으로 배제한다.
  • 네오플 키를 item·market 두 개로 나눠 rate-limit 예산을 격리하고, 단일 키 fallback을 없애 실수로 다시 합쳐지지 않게 한다.

관통하는 원칙은 (1)·(2)편과 같습니다. 결정의 근거를 코드가 아니라 데이터에 둔다. 무엇을 수집할지는 목록이 아니라 tracked_items가, 지금 수집해도 되는지는 하드코딩한 시각이 아니라 provider 상태가 답합니다.

다음 편에서는 화면 쪽으로 넘어가, 이렇게 모은 아이템 한 건의 시세와 매물을 한 화면에서 보는 터미널을 만든 이야기 — item-apimarket-api를 BFF에서 어떻게 조합해 하나의 뷰로 내려줬는지 — 를 다루겠습니다.

같이 보면 좋은 글