던플 개발기 (10): 고정 크론에서 collect-due로 — 실데이터 동적 수집
지난 편에서 전체 스택을 로컬에 띄웠다면, 이번엔 그 위에서 언제·누구를 수집할지를 다시 짠 이야기입니다. 스택을 도커로 묶고 나니 수집기가 실제로 돌기 시작했는데, 처음에 넣어 둔 방식은 "정해진 아이템 목록을 정해진 간격으로 순회하는" 고정 루프였습니다. 아이템이 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.yml의 ITEM_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)편과 이어지는 대목입니다. isDispatchAllowed는 HEALTHY와 RECOVERING에서만 참이라, 네오플이 점검(503/DNF980)이거나 DEGRADED면 collect-due는 due 아이템이 있어도 조용히 멈춥니다. "수집할 때가 됐는가"와 "지금 수집해도 되는가"를 한곳에서 함께 보는 셈입니다.
전체 tick을 흐름으로 보면 이렇습니다. 도커 collector는 이제 ITEM_IDS 순회 대신 collect-due → relay-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:realdev: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-cron의import-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로 내려간 아이템은 demoteToDormant로 next_collect_at이 null이 되어 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-api와 market-api를 BFF에서 어떻게 조합해 하나의 뷰로 내려줬는지 — 를 다루겠습니다.
