From 9d8f85100de3b486d34dc74db378c5150b0b4d8d Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Thu, 8 Oct 2026 09:37:36 +0900 Subject: [PATCH] =?UTF-8?q?perf:=20CGV=20=EC=A4=91=EB=B3=B5=20=EC=A4=91?= =?UTF-8?q?=EA=B3=84=20=EC=9A=94=EC=B2=AD=EA=B3=BC=20=EC=98=88=EC=82=B0=20?= =?UTF-8?q?=EC=86=8C=EB=B9=84=20=EC=A0=88=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cgv-relay-recovery.md | 2 + scripts/relay/cgv.ts | 2 + src/services/dtryx/transport.ts | 2 + tests/services/cgv/relay-cache.test.ts | 80 ++++++++++++++++++++++++ tests/services/dtryx/relay.test.ts | 19 ++++++ tests/utils/routeProbes-no-relay.test.ts | 34 ++++++++++ 6 files changed, 139 insertions(+) create mode 100644 tests/services/cgv/relay-cache.test.ts create mode 100644 tests/utils/routeProbes-no-relay.test.ts diff --git a/docs/cgv-relay-recovery.md b/docs/cgv-relay-recovery.md index bfe324fc..e4a5dd34 100644 --- a/docs/cgv-relay-recovery.md +++ b/docs/cgv-relay-recovery.md @@ -4,6 +4,8 @@ CGV는 `/v1/cgv/{theaters,movies,timetable,timetable-movie}` 경로로 기존 Dtryx 4320 프로세스를 사용합니다. 별도 예산을 만들지 않고 `DTRYX_RELAY_TOKEN`, `DTRYX_RELAY_STATE_DIR/quota.json`, Dtryx 로그를 공유합니다. 기본 상한은 `scripts/relay/quota.ts`의 일 3,000회·분 30회이며 CGV와 Dtryx의 실제 중계 원본 요청을 합산합니다. CU·세븐일레븐·GS25의 별도 편의점 원장은 공유하지 않습니다. +중계는 검증된 성공 응답만 재사용합니다. 극장·영화 목록은 5분, 시간표·영화별 시간표는 30초 뒤 만료됩니다. 동일 작업과 조회 조건으로 겹친 진행 요청은 원본 조회와 예산을 한 번만 소비합니다. 원본 HTTP 오류·업무 오류는 캐시하지 않으며, 시간표 캐시가 만료되면 좌석 수량을 다시 조회합니다. 이 중계 캐시는 REST 캐시와 별도이며 MCP 요청에도 적용됩니다. 분·일 상한은 변경하지 않습니다. + 5분 주기 경로 점검은 CGV 직접 요청과 중계를 확인해 사용 가능한 경로를 선택합니다. 직접 경로가 차단되면 중계를 사용하고, 직접 경로가 회복되면 점검 결과에 따라 복귀합니다. 일반 요청 중 직접 실패도 경로 선택에 반영됩니다. `/health`의 `config.cgvRelay`는 유효 설정 여부만 나타내며 연결 성공을 뜻하지 않습니다. ## 설정 diff --git a/scripts/relay/cgv.ts b/scripts/relay/cgv.ts index e3488753..f9d7f3af 100644 --- a/scripts/relay/cgv.ts +++ b/scripts/relay/cgv.ts @@ -53,6 +53,8 @@ export function createCgvRelay( ) { return createHttpRelay(token, { ...options, + cacheTtl: (operation) => + ['timetable', 'timetable-movie'].includes(operation) ? 30000 : 300000, prefix: '/v1/cgv/', operations: Object.keys(paths), validate, diff --git a/src/services/dtryx/transport.ts b/src/services/dtryx/transport.ts index afe21109..a5312a7b 100644 --- a/src/services/dtryx/transport.ts +++ b/src/services/dtryx/transport.ts @@ -2,6 +2,7 @@ import { requestDirectRoute } from '../../utils/directRoutes.js'; import { routeOperation } from '../../utils/routeHealth.js'; import { diagnosticHeaders } from '../../utils/diagnostics.js'; /** 디트릭스 전용 중계 설정과 인증 전송입니다. */ +import { parseRelayQuota } from '../../utils/relayQuota.js'; import { ServiceError } from '../../core/errors.js'; import { createTimeoutController } from '../../utils/http.js'; @@ -117,6 +118,7 @@ async function requestDtryxRelayRaw( mapped, status === 429 || status >= 500, status, + parseRelayQuota(status, response.headers), ); } const result: unknown = await response.json(); diff --git a/tests/services/cgv/relay-cache.test.ts b/tests/services/cgv/relay-cache.test.ts new file mode 100644 index 00000000..07a3919f --- /dev/null +++ b/tests/services/cgv/relay-cache.test.ts @@ -0,0 +1,80 @@ +/** CGV 중계의 짧은 성공 캐시와 진행 조회 병합을 검증합니다. */ +import { afterEach, expect, it, vi } from 'vitest'; +import { createCgvRelay } from '../../../scripts/relay/cgv.js'; +function request(operation: string, body: unknown) { + return new Request(`http://127.0.0.1/v1/cgv/${operation}`, { + method: 'POST', + headers: { Authorization: 'Bearer test-token' }, + body: JSON.stringify(body), + }); +} +afterEach(() => vi.restoreAllMocks()); +const params = { theaterCode: '0056', playDate: '20261008' }; +it.each([ + ['theaters', {}, 300000], + ['movies', params, 300000], + ['timetable', params, 30000], + ['timetable-movie', { ...params, movieCode: '20000000' }, 30000], +])( + '%s 입력 %j 성공은 %i 밀리초까지 원본 조회와 예산을 재사용한다', + async (operation, body, ttl) => { + let now = 1000000; + vi.spyOn(Date, 'now').mockImplementation(() => now); + const takeQuota = vi.fn(async () => true); + let remainingSeats = 10; + const fetcher = vi.fn(async () => + Response.json({ statusCode: 0, data: [{ frSeatCnt: remainingSeats }] }), + ); + const relay = createCgvRelay('test-token', { takeQuota, fetcher }); + expect((await relay(request(operation as string, body))).status).toBe(200); + now += Number(ttl) - 1; + remainingSeats = 9; + const cached = await relay(request(operation as string, body)); + expect(await cached.json()).toEqual({ statusCode: 0, data: [{ frSeatCnt: 10 }] }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(takeQuota).toHaveBeenCalledTimes(1); + now += 1; + const fresh = await relay(request(operation as string, body)); + expect(await fresh.json()).toEqual({ statusCode: 0, data: [{ frSeatCnt: 9 }] }); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(takeQuota).toHaveBeenCalledTimes(2); + }, +); +it('동시에 겹친 같은 시간표 요청은 원본과 예산을 한 번만 소비한다', async () => { + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const fetcher = vi.fn(async () => { + await gate; + return Response.json({ statusCode: 0, data: [] }); + }); + const takeQuota = vi.fn(async () => true); + const relay = createCgvRelay('test-token', { takeQuota, fetcher }); + const responses = [ + relay(request('timetable', params)), + relay(request('timetable', { playDate: params.playDate, theaterCode: params.theaterCode })), + ]; + await vi.waitFor(() => expect(fetcher).toHaveBeenCalled()); + finish(); + expect((await Promise.all(responses)).map((response) => response.status)).toEqual([200, 200]); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(takeQuota).toHaveBeenCalledTimes(1); +}); +it.each([500, 200])('HTTP %i 원본 오류 또는 업무 오류는 캐시하지 않는다', async (status) => { + const fetcher = vi + .fn() + .mockImplementationOnce(async () => + status === 500 + ? new Response('failure', { status }) + : Response.json({ statusCode: 1, data: [] }), + ) + .mockImplementation(async () => Response.json({ statusCode: 0, data: [] })); + const takeQuota = vi.fn(async () => true); + const relay = createCgvRelay('test-token', { takeQuota, fetcher }); + expect((await relay(request('theaters', {}))).status).toBe(502); + expect((await relay(request('theaters', {}))).status).toBe(200); + expect((await relay(request('theaters', {}))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(takeQuota).toHaveBeenCalledTimes(2); +}); diff --git a/tests/services/dtryx/relay.test.ts b/tests/services/dtryx/relay.test.ts index 62a51b95..2868326b 100644 --- a/tests/services/dtryx/relay.test.ts +++ b/tests/services/dtryx/relay.test.ts @@ -168,3 +168,22 @@ it('실패 응답 이후 연결도 중단한다', async () => { await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ status: 502 }); expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true); }); + +it('중계의 자체 429 원인과 대기 시간을 공개 진단에 보존한다', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response('', { + status: 429, + headers: { 'x-relay-quota-reason': 'minute', 'Retry-After': '30' }, + }), + ), + ); + await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ + code: 'DTRYX_RELAY_FAILED', + quotaReason: 'minute', + retryAfter: 30, + }); +}); diff --git a/tests/utils/routeProbes-no-relay.test.ts b/tests/utils/routeProbes-no-relay.test.ts new file mode 100644 index 00000000..f1980865 --- /dev/null +++ b/tests/utils/routeProbes-no-relay.test.ts @@ -0,0 +1,34 @@ +/** 정기 경로 검사는 중계 원본 조회 예산을 사용하지 않습니다. */ +import { afterEach, expect, it, vi } from 'vitest'; +import { refreshRouteChecks } from '../../src/utils/routeProbes.js'; +afterEach(() => vi.unstubAllGlobals()); +it('실제 검사 함수는 직접 원본 요청과 중계 건강 GET만 호출한다', async () => { + const fetcher = vi + .fn() + .mockImplementation(async () => Response.json({ status: 'ok', state: 'ready' })); + vi.stubGlobal('fetch', fetcher); + const storageFetch = vi.fn(async () => Response.json({})); + const namespace = { + idFromName: () => ({}), + get: () => ({ fetch: storageFetch }), + } as unknown as DurableObjectNamespace; + await refreshRouteChecks({ + UPSTREAM_ROUTE_HEALTH: namespace, + DTRYX_RELAY_URL: 'https://relay.example', + DTRYX_RELAY_TOKEN: 'test', + CONVENIENCE_RELAY_URL: 'https://relay.example', + CONVENIENCE_RELAY_TOKEN: 'test', + OY_RELAY_URL: 'https://relay.example', + OY_RELAY_TOKEN: 'test', + }); + const calls = fetcher.mock.calls as unknown as [URL, RequestInit][]; + const relayCalls = calls.filter(([url]) => url.hostname === 'relay.example'); + expect(relayCalls).toHaveLength(4); + expect( + relayCalls.every(([url, init]) => url.pathname.endsWith('/health') && init.method === 'GET'), + ).toBe(true); + const cgvCalls = calls.filter(([url]) => url.hostname === 'api.cgv.co.kr'); + expect(cgvCalls).toHaveLength(4); + expect(cgvCalls.every(([, init]) => init.method === 'GET')).toBe(true); + expect(storageFetch).toHaveBeenCalledTimes(1); +});