Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/convenience-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,6 @@ CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포

본문 16 KiB, 원본 응답 2 MiB, 동시 원본 호출 4개, 전체 요청 32개를 제한합니다. 본문·대기·원장·원본 처리를 합쳐 15초 기한을 적용하며 취소된 대기 요청은 예산을 소비하지 않습니다. 인증·입력 검증 뒤 성공한 상품·메타정보·초기화·카탈로그는 5분, 매장·실재고는 30초 동안 최대 256개·16 MiB의 메모리 캐시에 저장합니다. 캐시 적중은 호출 예산을 소비하지 않습니다. 오류는 캐시하지 않습니다.

캐시 가능한 작업은 인증·입력 검증을 통과한 뒤 작업명과 모든 조회 조건이 같은 진행 요청을 공유합니다. 최초 동시 조회도 원본 호출·예산 소비는 한 번입니다. 각 응답 body와 request ID는 독립이며 공유를 기다린 요청은 `coalesced` 단계로 기록합니다. 한 요청의 취소는 다른 대기자에게 전파하지 않고, 마지막 대기자가 떠나면 원본 작업과 큐를 취소합니다. 최초 요청의 전체 15초 기한이 지난 공유 작업은 후속 대기자에게도 504를 반환하며, 실패·취소 결과는 캐시하지 않습니다. 동시 접수 32개·원본 4개·분당30회·일3,000회 상한은 유지합니다. 서로 다른 상품·지역·인증키·조회 조건은 임의로 합치지 않습니다.

리다이렉트·비 JSON·실패 envelope·잘못된 수량은 오류로 반환합니다. 재고 오류를 수량 0으로 바꾸지 않습니다. 세븐일레븐 수량 `-1`은 미확인이며 품절로 표시하지 않습니다. 반환된 매장의 수량이 모두 미확인이면 `stockAvailable`은 false입니다. 캐시 수량은 조회 시점 이후 바뀔 수 있으므로 실제 방문 전 점포 확인이 필요합니다.
20 changes: 20 additions & 0 deletions docs/superpowers/plans/2026-10-05-relay-coalescing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 동일 중계 조회 병합 구현 계획

> **For agentic workers:** 이번 세션은 executing-plans로 순서대로 구현·검토합니다.

**Goal:** 캐시 가능한 동일한 편의점 조회의 동시 요청을 한 원본 작업으로 병합합니다.

**Architecture:** 인증·입력 검증·각 요청의 15초 기한과 32개 접수 상한을 유지합니다. 검증된 canonicalKey별 진행 작업은 별도 AbortController와 15초 기한을 가지며, 마지막 대기자가 떠나면 취소합니다. 성공 JSON만 기존 TTL 캐시에 저장하고 작업 완료/실패/전체 취소 후 진행 맵을 정리합니다. 캐시 없는 작업은 병합하지 않습니다.

**Tech Stack:** TypeScript, Node AbortController, Vitest.

## 순서

- [x] tests/relay/http-relay-coalescing.test.ts: 동시20개 원본1회·예산1회, 응답 body 독립, 실패 후 재조회, 개별/전체 취소, 시간 초과, 다른 상품/지역 격리, 인증/입력 검증과 접수 상한 유지의 실패 테스트를 작성합니다.
- `npm test -- tests/relay/http-relay-coalescing.test.ts`에서 원본20회 등 기대 실패를 확인합니다.
- 핵심 단언: `expect(upstream).toHaveBeenCalledTimes(1); expect(takeQuota).toHaveBeenCalledTimes(1);`.
- [x] scripts/relay/http-relay.ts: `Map<string, SharedWork>`에 검증된 작업을 보관하고 `abortable(work.result, caller.signal)`로 각 요청을 분리합니다. `work.waiters--` 후 0이고 미완료이면 맵에서 제거하고 작업을 취소합니다. 원본 작업 내부에서 acquire·quota·abortable upstream·JSON 직렬화·cache를 수행하고 finally에서 타이머·활성 슬롯·맵을 해제합니다.
- [x] `npm test -- tests/relay`, `npm run check`, `npm run test:coverage`, `npm run build`로 회귀·100% 커버리지를 검증합니다. 모의100요청은 기존32접수 상한을 유지하므로 전부200을 요구하지 않고 접수된 동일 조회의 원본1회와 초과503을 검증합니다.
- [x] docs/convenience-relay.md에 병합·취소·실패·TTL·상한 동작을 설명하고 코드 리뷰 후 커밋·PR을 작성합니다. 공개 운영/GS 원본 부하 테스트나 GS 인증 우회는 하지 않습니다. 정상 API 인증 경로가 미확보면 GS 고유 대량 조회는 미해결로 명시합니다.

검증: 기존70개 baseline 통과, 신규11개 회귀와 전체2,350개 통과. check/build 성공, coverage4항목100%. 독립리뷰2회 결함없음. 마지막 단계의 코드/문서는 완료됐으며 PR 발행은 이 기록 커밋 직후 수행합니다.
118 changes: 90 additions & 28 deletions scripts/relay/http-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,18 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
const secret = Buffer.from(`Bearer ${token}`);
const fetcher = options.fetcher ?? fetch;
const cache = options.cacheTtl ? createResponseCache() : undefined;
interface SharedWork {
controller: AbortController;
result: Promise<string>;
waiters: number;
done: boolean;
stage: string;
}
const pending = new Map<string, SharedWork>();
let outstanding = 0;
let active = 0;
const queue: Array<() => void> = [];
function acquire(signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.reject(new RelayError(499));
if (active < 4) {
active++;
return Promise.resolve();
Expand All @@ -102,6 +109,69 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
queue.push(start);
});
}
function startWork(key: string, name: string, params: unknown, deadline: number): SharedWork {
const controller = new AbortController();
const work: SharedWork = {
controller,
result: Promise.resolve(''),
waiters: 0,
done: false,
stage: 'queue',
};
let expired = false;
const timer = setTimeout(
() => {
expired = true;
controller.abort();
},
Math.max(0, deadline - performance.now()),
);
work.result = (async () => {
let acquired = false;
try {
await acquire(controller.signal);
acquired = true;
if (performance.now() >= deadline) {
expired = true;
controller.abort();
}
controller.signal.throwIfAborted();
work.stage = 'quota';
const allowed = await abortable(
options.takeQuota().catch(() => {
throw new RelayError(503);
}),
controller.signal,
);
if (!allowed) throw new RelayError(429);
controller.signal.throwIfAborted();
work.stage = 'upstream';
const data = await abortable(
options.upstream(name, params, controller.signal, fetcher),
controller.signal,
);
controller.signal.throwIfAborted();
const body = JSON.stringify(data);
if (body === undefined) throw new RelayError(502);
if (cache) cache.set(key, body, options.cacheTtl!(name));
return body;
} catch (error) {
if (expired) throw new RelayError(504);
throw error;
} finally {
work.done = true;
clearTimeout(timer);
controller.abort();
if (pending.get(key) === work) pending.delete(key);
if (acquired) {
active--;
queue.shift()?.();
}
}
})();
if (cache) pending.set(key, work);
return work;
}
return async (request: Request): Promise<Response> => {
const authorization = Buffer.from(request.headers.get('authorization') ?? '');
if (authorization.length !== secret.length || !timingSafeEqual(authorization, secret))
Expand Down Expand Up @@ -129,7 +199,8 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
expired = true;
abort();
}, 15000);
let acquired = false;
let work: SharedWork | undefined;
let workKey = '';
let stage = 'body';
let resultStatus = 200;
const suppliedId = request.headers.get('x-request-id');
Expand All @@ -146,29 +217,17 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
});
}
stage = 'queue';
await acquire(controller.signal);
acquired = true;
if (performance.now() >= deadline) {
expired = true;
abort();
}
controller.signal.throwIfAborted();
stage = 'quota';
if (
!(await abortable(
options.takeQuota().catch(() => {
throw new RelayError(503);
}),
controller.signal,
))
)
throw new RelayError(429);
controller.signal.throwIfAborted();
stage = 'upstream';
const data = await options.upstream(name, p, controller.signal, fetcher);
if (cache) cache.set(key, JSON.stringify(data), options.cacheTtl!(name));
return reply(200, data);
workKey = key;
work = cache ? pending.get(key) : undefined;
const shared = work !== undefined;
work ??= startWork(key, name, p, deadline);
work.waiters++;
stage = shared ? 'coalesced' : 'upstream';
const body = await abortable(work.result, controller.signal);
return new Response(body, {
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
});
} catch (error) {
const status = expired
? 504
Expand All @@ -183,7 +242,7 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
try {
void Promise.resolve(
options.onEvent?.({
stage,
stage: work && stage !== 'coalesced' ? work.stage : stage,
operation: name,
requestId,
outcome: resultStatus === 200 ? 'ok' : 'error',
Expand All @@ -198,9 +257,12 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
request.signal.removeEventListener('abort', abort);
controller.abort();
outstanding--;
if (acquired) {
active--;
queue.shift()?.();
if (work) {
work.waiters--;
if (work.waiters === 0 && !work.done) {
if (pending.get(workKey) === work) pending.delete(workKey);
work.controller.abort();
}
}
}
};
Expand Down
176 changes: 176 additions & 0 deletions tests/relay/http-relay-coalescing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { afterEach, expect, it, vi } from 'vitest';
import { createHttpRelay, RelayError } from '../../scripts/relay/http-relay.js';
const request = (
body: unknown = { item: 'cola', lat: 37 },
signal?: AbortSignal,
token = 'token',
) =>
new Request('http://localhost/test/stock', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
signal,
});
function setup(cache = true) {
let resolve!: (value: unknown) => void;
let reject!: (error: unknown) => void;
const gate = new Promise<unknown>((ok, fail) => {
resolve = ok;
reject = fail;
});
const upstream = vi.fn(async (_op, _params, _signal) => gate);
const takeQuota = vi.fn(async () => true);
const relay = createHttpRelay('token', {
prefix: '/test/',
operations: ['stock'],
cacheTtl: cache ? () => 30000 : undefined,
validate: (v) => {
if (!v || typeof v !== 'object') throw new RelayError(400);
return v;
},
upstream,
takeQuota,
});
return { relay, upstream, takeQuota, resolve, reject };
}
afterEach(() => vi.useRealTimers());
it('동일20개 최초 조회는 원본과 예산을 각각1회 사용하고 body를 분리한다', async () => {
const s = setup();
const pending = Array.from({ length: 20 }, () => s.relay(request()));
await vi.waitFor(() => expect(s.upstream).toHaveBeenCalled());
s.resolve({ quantity: 7 });
const results = await Promise.all(pending);
expect(s.upstream).toHaveBeenCalledTimes(1);
expect(s.takeQuota).toHaveBeenCalledTimes(1);
expect(await Promise.all(results.map((r) => r.json()))).toEqual(Array(20).fill({ quantity: 7 }));
});
it.each(['leader', 'follower'])(
'개별 %s 취소는 다른 대기자 원본을 취소하지 않는다',
async (who) => {
const s = setup();
const c = new AbortController();
const first = s.relay(request(undefined, who === 'leader' ? c.signal : undefined));
await vi.waitFor(() => expect(s.upstream).toHaveBeenCalledTimes(1));
const second = s.relay(request(undefined, who === 'follower' ? c.signal : undefined));
await new Promise((r) => setTimeout(r, 10));
c.abort();
const canceled = who === 'leader' ? first : second;
expect((await canceled).status).toBe(499);
expect(s.upstream.mock.calls[0][2].aborted).toBe(false);
s.resolve({ quantity: 0 });
expect((await (who === 'leader' ? second : first)).status).toBe(200);
expect(s.upstream).toHaveBeenCalledTimes(1);
},
);
it('모든 대기자가 취소하면 작업을 중단하고 새 조회는 별도 작업으로 시작한다', async () => {
const s = setup();
const a = new AbortController();
const b = new AbortController();
const p = s.relay(request(undefined, a.signal));
const q = s.relay(request(undefined, b.signal));
await vi.waitFor(() => expect(s.upstream).toHaveBeenCalled());
a.abort();
b.abort();
await Promise.all([p, q]);
expect(s.upstream.mock.calls[0][2].aborted).toBe(true);
const fresh = s.relay(request());
await vi.waitFor(() => expect(s.upstream).toHaveBeenCalledTimes(2));
s.resolve({ quantity: 9 });
expect((await fresh).status).toBe(200);
expect(await (await s.relay(request())).json()).toEqual({ quantity: 9 });
});
it('실패를 함께 반환하지만 캐시하지 않고 다음 요청은 다시 조회한다', async () => {
const s = setup();
const a = s.relay(request());
const b = s.relay(request());
await vi.waitFor(() => expect(s.upstream).toHaveBeenCalled());
s.reject(new RelayError(403));
expect((await a).status).toBe(403);
expect((await b).status).toBe(403);
s.upstream.mockResolvedValue({ quantity: 7 });
expect((await s.relay(request())).status).toBe(200);
expect(s.upstream).toHaveBeenCalledTimes(2);
});
it('같은 키여도 캐시 미설정 작업은 공유하지 않는다', async () => {
const s = setup(false);
const a = s.relay(request());
const b = s.relay(request());
await vi.waitFor(() => expect(s.upstream).toHaveBeenCalledTimes(2));
s.resolve({});
await Promise.all([a, b]);
});
it('상품·지역이 다르면 별도로 조회하며 검증 실패 요청은 공유하지 않는다', async () => {
const s = setup();
const valid = [
s.relay(request()),
s.relay(request({ item: 'coffee', lat: 37 })),
s.relay(request({ item: 'cola', lat: 38 })),
];
expect((await s.relay(request(null))).status).toBe(400);
expect((await s.relay(request(undefined, undefined, 'wrong'))).status).toBe(401);
s.resolve({});
await Promise.all(valid);
expect(s.upstream).toHaveBeenCalledTimes(3);
});
it('100개 동시 요청에도32접수 상한을 유지하고 접수 조회는 원본1회이다', async () => {
const s = setup();
const p = Array.from({ length: 100 }, () => s.relay(request()));
await vi.waitFor(() => expect(s.upstream).toHaveBeenCalled());
s.resolve({});
const results = await Promise.all(p);
expect(results.filter((r) => r.status === 200)).toHaveLength(32);
expect(results.filter((r) => r.status === 503)).toHaveLength(68);
expect(s.upstream).toHaveBeenCalledTimes(1);
});
it('뒤늦게 합류한 요청도 공유 작업15초 기한 후504를 받고 슬롯이 회수된다', async () => {
vi.useFakeTimers();
const s = setup();
const a = s.relay(request());
await vi.advanceTimersByTimeAsync(1000);
const b = s.relay(request());
await vi.advanceTimersByTimeAsync(14000);
expect((await a).status).toBe(504);
expect((await b).status).toBe(504);
s.upstream.mockResolvedValue({ quantity: 1 });
const fresh = s.relay(request());
await vi.advanceTimersByTimeAsync(0);
expect((await fresh).status).toBe(200);
});
it('미정의 원본 응답은 오류이며 성공 캐시에 저장하지 않는다', async () => {
const s = setup();
const p = s.relay(request());
s.resolve(undefined);
expect((await p).status).toBe(502);
s.upstream.mockResolvedValue({ quantity: 0 });
expect((await s.relay(request())).status).toBe(200);
expect(s.upstream).toHaveBeenCalledTimes(2);
});
it('공유 요청도 각 request ID와 coalesced 단계를 기록한다', async () => {
let finish!: (data: unknown) => void;
const gate = new Promise((ok) => {
finish = ok;
});
const onEvent = vi.fn();
const handler = createHttpRelay('token', {
prefix: '/test/',
operations: ['stock'],
cacheTtl: () => 30000,
validate: (v) => v,
upstream: async () => gate,
takeQuota: async () => true,
onEvent,
});
const a = request();
a.headers.set('x-request-id', 'first');
const b = request();
b.headers.set('x-request-id', 'second');
const p = handler(a);
const q = handler(b);
await new Promise((r) => setTimeout(r, 10));
finish({});
await Promise.all([p, q]);
expect(onEvent.mock.calls.map(([e]) => [e.requestId, e.stage])).toEqual([
['first', 'upstream'],
['second', 'coalesced'],
]);
});
Loading