From ba1796b18be783e57ac67af3e355e4a9001e2dda Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 00:43:05 +0900 Subject: [PATCH 1/2] =?UTF-8?q?test:=20supertest=20=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=EC=95=B1=EC=9D=84=20127.0.0.1=EC=97=90=20=EC=97=B4=EC=96=B4=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4?= =?UTF-8?q?=EC=9D=98=20=ED=8F=AC=ED=8A=B8=20=EA=B0=80=EB=A1=9C=EC=B1=84?= =?UTF-8?q?=EA=B8=B0=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - role-routes worker의 GET /rest-docs가 간헐 401(기대 404). worker 앱은 이 경로를 항상 404로 끝내고 테스트 env에는 문서 토큰이 없어, 앱 안에서는 401이 나올 수 없음 → 다른 서버의 응답 - supertest는 닫힌 서버를 와일드카드(::)로 열고 127.0.0.1로 요청. macOS는 다른 프로세스가 같은 포트를 127.0.0.1로 따로 바인드하게 두고 요청을 그쪽으로 보냄(실측). 맥미니의 VS Code 헬퍼가 127.0.0.1 임시 포트에서 /rest-docs에 401을 돌려줌 - 사고 순간의 포트 점유는 재현하지 못함, 메커니즘과 401 응답자만 확인 - listenOnLoopback(src/test/http-app.ts): 앱을 127.0.0.1에 먼저 열어 같은 주소·포트를 다른 프로세스가 잡지 못하게(EADDRINUSE), supertest는 열린 주소를 그대로 씀 - getHttpServer()를 쓰는 spec 7개의 app.init()을 교체 - http-app.spec: 와일드카드로 열면 가로채진다(macOS만, 리눅스는 바인드 자체 거부), 헬퍼로 열면 못 가로챈다, 사용처 전수 점검(role-routes를 되돌리면 그 파일을 위반으로 잡음 확인) - 가이드 §9 운영 호스트 보호에 한 줄 --- docs/guide/architecture-conventions.md | 2 +- .../auth/controllers/jwks.controller.spec.ts | 3 +- src/features/system/health.controller.spec.ts | 3 +- .../system/metrics.controller.spec.ts | 3 +- .../metrics/http-metrics.middleware.spec.ts | 3 +- src/test/admin-query-null-inputs.spec.ts | 3 +- src/test/http-app.spec.ts | 95 +++++++++++++++++++ src/test/http-app.ts | 10 ++ src/test/role-routes.spec.ts | 3 +- test/app.e2e-spec.ts | 3 +- 10 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 src/test/http-app.spec.ts create mode 100644 src/test/http-app.ts diff --git a/docs/guide/architecture-conventions.md b/docs/guide/architecture-conventions.md index 444ac56d..7824e227 100644 --- a/docs/guide/architecture-conventions.md +++ b/docs/guide/architecture-conventions.md @@ -139,7 +139,7 @@ CI `check` 잡은 정적 검사를 개별 스텝으로 돌리되 `dto:check`는 `--warning`(이관 중이라 경고만)이라, `git push --no-verify`로 pre-push를 건너뛰면 SDL↔DTO 드리프트가 CI를 통과할 수 있다 — pre-push를 우회하지 않는 것이 규칙이다. `knip`(dead code)·`nestjs-doctor`는 PR 코멘트만(advisory, 오탐 있음). -**운영 호스트 보호.** 개발 머신이 운영 맥미니를 겸하고, testcontainers(MySQL·Redis)가 운영 컨테이너와 같은 OrbStack VM(4CPU·8GB)을 나눠 쓴다. 전체 jest(330스위트, 실DB 145개)를 pre-push마다 돌리던 시절 한 번에 5.7~12.7분이 걸렸고, 그동안 운영 응답이 느려지고 부하성 간헐 실패가 났다. 그래서 pre-push는 위처럼 범위를 좁히고 전체는 CI에 맡긴다. 맥미니에서 전체 테스트(`yarn validate`·`yarn test:cov`·경로 없는 `yarn test`)를 돌려야 하면 **한 번에 하나만** 돌린다(여러 세션·에이전트가 동시에 띄우지 않는다). jest는 globalSetup에서 호스트 락(127.0.0.1:47391 포트를 여는 것, 프로세스가 죽으면 OS가 푼다)을 잡아 같은 호스트의 실행을 하나로 줄 세우고, 로컬 워커는 4개로 제한한다(`jest.config.js`, 실측 근거는 주석). CI와 watch 모드는 락을 잡지 않는다. 부하 중에 난 간헐 실패는 결함으로 단정하기 전에 단독 재실행으로 확인한다. +**운영 호스트 보호.** 개발 머신이 운영 맥미니를 겸하고, testcontainers(MySQL·Redis)가 운영 컨테이너와 같은 OrbStack VM(4CPU·8GB)을 나눠 쓴다. 전체 jest(330스위트, 실DB 145개)를 pre-push마다 돌리던 시절 한 번에 5.7~12.7분이 걸렸고, 그동안 운영 응답이 느려지고 부하성 간헐 실패가 났다. 그래서 pre-push는 위처럼 범위를 좁히고 전체는 CI에 맡긴다. 맥미니에서 전체 테스트(`yarn validate`·`yarn test:cov`·경로 없는 `yarn test`)를 돌려야 하면 **한 번에 하나만** 돌린다(여러 세션·에이전트가 동시에 띄우지 않는다). jest는 globalSetup에서 호스트 락(127.0.0.1:47391 포트를 여는 것, 프로세스가 죽으면 OS가 푼다)을 잡아 같은 호스트의 실행을 하나로 줄 세우고, 로컬 워커는 4개로 제한한다(`jest.config.js`, 실측 근거는 주석). CI와 watch 모드는 락을 잡지 않는다. 부하 중에 난 간헐 실패는 결함으로 단정하기 전에 단독 재실행으로 확인한다. supertest로 부르는 앱은 `listenOnLoopback`(`src/test/http-app.ts`)으로 127.0.0.1에 먼저 연다 — 와일드카드로 열면 macOS에서 다른 프로세스가 같은 포트를 127.0.0.1로 잡아 응답을 가로챈다(`http-app.spec`이 반증과 사용처 전수를 고정). --- diff --git a/src/features/auth/controllers/jwks.controller.spec.ts b/src/features/auth/controllers/jwks.controller.spec.ts index 3cc5b1ee..4ae3b0c0 100644 --- a/src/features/auth/controllers/jwks.controller.spec.ts +++ b/src/features/auth/controllers/jwks.controller.spec.ts @@ -11,6 +11,7 @@ import { RAW_RESPONSE_PATHS, } from '@/global/interceptors/api-response.interceptor'; import { testAuthConfig } from '@/test/auth-config'; +import { listenOnLoopback } from '@/test/http-app'; const KEYS = generateEphemeralKeyMaterial(); @@ -82,7 +83,7 @@ describe('JWKS HTTP 응답', () => { app = module.createNestApplication(); // main.ts와 같은 배선(같은 상수) app.useGlobalInterceptors(new ApiResponseInterceptor(RAW_RESPONSE_PATHS)); - await app.init(); + await listenOnLoopback(app); }); afterAll(async () => { diff --git a/src/features/system/health.controller.spec.ts b/src/features/system/health.controller.spec.ts index f0075958..e220ce0c 100644 --- a/src/features/system/health.controller.spec.ts +++ b/src/features/system/health.controller.spec.ts @@ -13,6 +13,7 @@ import { ApiResponseInterceptor, RAW_RESPONSE_PATHS, } from '@/global/interceptors/api-response.interceptor'; +import { listenOnLoopback } from '@/test/http-app'; function controllerWith(result: ReadinessResult): HealthController { const service = { ready: () => Promise.resolve(result) } as HealthService; @@ -126,7 +127,7 @@ describe('헬스 HTTP 응답 (real app)', () => { app = module.createNestApplication>(); // main.ts와 같은 배선(같은 상수) app.useGlobalInterceptors(new ApiResponseInterceptor(RAW_RESPONSE_PATHS)); - await app.init(); + await listenOnLoopback(app); }); afterAll(async () => { await app.close(); diff --git a/src/features/system/metrics.controller.spec.ts b/src/features/system/metrics.controller.spec.ts index 51ca98fa..e8340b2c 100644 --- a/src/features/system/metrics.controller.spec.ts +++ b/src/features/system/metrics.controller.spec.ts @@ -9,6 +9,7 @@ import { RAW_RESPONSE_PATHS, } from '@/global/interceptors/api-response.interceptor'; import { MetricsService } from '@/global/metrics'; +import { listenOnLoopback } from '@/test/http-app'; /** 제외 목록 밖 경로가 실제로 봉투에 싸이는지 볼 대조군 */ @Controller('envelope-probe') @@ -31,7 +32,7 @@ describe('MetricsController (real app)', () => { }).compile(); app = module.createNestApplication>(); app.useGlobalInterceptors(new ApiResponseInterceptor(RAW_RESPONSE_PATHS)); - await app.init(); + await listenOnLoopback(app); metrics = module.get(MetricsService); }); afterAll(async () => { diff --git a/src/global/metrics/http-metrics.middleware.spec.ts b/src/global/metrics/http-metrics.middleware.spec.ts index 4b1acb39..37d4df68 100644 --- a/src/global/metrics/http-metrics.middleware.spec.ts +++ b/src/global/metrics/http-metrics.middleware.spec.ts @@ -25,6 +25,7 @@ import { UNMATCHED_ROUTE, } from '@/global/metrics/http-metrics.middleware'; import { MetricsService } from '@/global/metrics/metrics.service'; +import { listenOnLoopback } from '@/test/http-app'; @Controller('items') class ItemsController { @@ -87,7 +88,7 @@ describe('HttpMetricsMiddleware (실제 Express 스택)', () => { imports: [TestAppModule], }).compile(); app = module.createNestApplication>(); - await app.init(); + await listenOnLoopback(app); metrics = app.get(MetricsService); }); afterAll(() => app.close()); diff --git a/src/test/admin-query-null-inputs.spec.ts b/src/test/admin-query-null-inputs.spec.ts index 2611de58..4a48a8e9 100644 --- a/src/test/admin-query-null-inputs.spec.ts +++ b/src/test/admin-query-null-inputs.spec.ts @@ -36,6 +36,7 @@ import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { getTestRedisUrl } from '@/test/db/redis-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount } from '@/test/factories'; +import { listenOnLoopback } from '@/test/http-app'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; // 관리자 FE는 '전체' 필터를 필드 생략이 아니라 null로 보낸다. nullable 입력의 null이 Prisma where까지 내려가면 @@ -218,7 +219,7 @@ describe('관리자 Query nullable 입력 null 전수 (real DB)', () => { new GraphQLExceptionFilter(logger, app.get(MetricsService)), ), ); - await app.init(); + await listenOnLoopback(app); }); afterAll(async () => { diff --git a/src/test/http-app.spec.ts b/src/test/http-app.spec.ts new file mode 100644 index 00000000..93e1093c --- /dev/null +++ b/src/test/http-app.spec.ts @@ -0,0 +1,95 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { createServer, Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { join } from 'node:path'; + +import { Controller, Get, type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import type { App } from 'supertest/types'; + +import { listenOnLoopback } from '@/test/http-app'; + +@Controller('who') +class WhoController { + @Get() + who(): string { + return 'app'; + } +} + +/** 다른 프로세스가 같은 포트에 서버를 여는 상황. 실패하면 오류 코드를 돌려준다 */ +function bindIntruder(port: number, host?: string): Promise { + const server = createServer((_req, res) => res.end('intruder')); + return new Promise((resolve) => { + server.once('error', (e: NodeJS.ErrnoException) => resolve(e.code ?? '')); + server.listen(port, host, () => resolve(server)); + }); +} + +const portOf = (app: INestApplication) => + ((app.getHttpServer() as Server).address() as AddressInfo).port; + +// 리눅스(CI)는 와일드카드가 잡은 포트의 127.0.0.1 바인드 자체를 거부한다 — 가로채기는 macOS(맥미니)에서만 난다 +const onMac = process.platform === 'darwin' ? it : it.skip; + +describe('listenOnLoopback', () => { + let app: INestApplication; + const intruders: Server[] = []; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [WhoController], + }).compile(); + app = module.createNestApplication>(); + }); + afterEach(async () => { + intruders.splice(0).forEach((s) => s.close()); + await app.close(); + }); + + onMac( + '반증: 와일드카드로 연 앱은 같은 포트를 127.0.0.1로 잡은 서버에 요청을 빼앗긴다', + async () => { + await app.listen(0); // supertest가 닫힌 서버를 여는 방식 + const intruder = await bindIntruder(portOf(app), '127.0.0.1'); + expect(intruder).toBeInstanceOf(Server); + intruders.push(intruder as Server); + + const res = await request(app.getHttpServer()).get('/who'); + expect(res.text).toBe('intruder'); + }, + ); + + it('127.0.0.1로 열면 같은 포트를 다른 서버가 가로채지 못한다', async () => { + await listenOnLoopback(app); + const port = portOf(app); + + expect(await bindIntruder(port, '127.0.0.1')).toBe('EADDRINUSE'); + // 와일드카드 바인드는 macOS에서 성공하지만 127.0.0.1 요청은 더 구체적인 앱 쪽으로 간다 + const wildcard = await bindIntruder(port); + if (wildcard instanceof Server) intruders.push(wildcard); + + const res = await request(app.getHttpServer()).get('/who'); + expect(res.text).toBe('app'); + }); + + it('supertest로 앱을 부르는 spec은 모두 listenOnLoopback으로 연다', () => { + const root = join(__dirname, '..', '..'); + const specs = ['src', 'test'] + .flatMap((dir) => + readdirSync(join(root, dir), { recursive: true, encoding: 'utf8' }) + .filter((f) => /\.(spec|e2e-spec)\.ts$/.test(f)) + .map((f) => join(dir, f)), + ) + .map((path) => ({ path, src: readFileSync(join(root, path), 'utf8') })) + .filter(({ src }) => src.includes('getHttpServer()')); + + expect(specs.length).toBeGreaterThan(1); + expect( + specs + .filter(({ src }) => !src.includes('listenOnLoopback(')) + .map(({ path }) => path), + ).toEqual([]); + }); +}); diff --git a/src/test/http-app.ts b/src/test/http-app.ts new file mode 100644 index 00000000..60b3de33 --- /dev/null +++ b/src/test/http-app.ts @@ -0,0 +1,10 @@ +import type { INestApplication } from '@nestjs/common'; + +/** + * supertest에 넘길 앱은 127.0.0.1에 먼저 연다. supertest는 닫힌 서버를 와일드카드(::)로 열고 127.0.0.1로 요청하는데, + * macOS는 다른 프로세스가 같은 포트를 127.0.0.1로 따로 바인드하게 두고 요청을 그쪽으로 보낸다(맥미니의 에디터 프로세스가 + * 401을 돌려준 간헐 실패). 127.0.0.1에 열어 두면 같은 주소·포트는 다른 프로세스가 잡지 못한다. + */ +export async function listenOnLoopback(app: INestApplication): Promise { + await app.listen(0, '127.0.0.1'); +} diff --git a/src/test/role-routes.spec.ts b/src/test/role-routes.spec.ts index e5dff1bb..9c7b250e 100644 --- a/src/test/role-routes.spec.ts +++ b/src/test/role-routes.spec.ts @@ -8,6 +8,7 @@ import type { App } from 'supertest/types'; import { AppModule } from '@/app.module'; import { PUB_SUB } from '@/global/pubsub'; import { PrismaService } from '@/prisma'; +import { listenOnLoopback } from '@/test/http-app'; // 역할별 리스너 게이트: 컨트롤러가 어느 모듈에 있든 worker에서는 /health·/metrics 밖이 전부 404여야 한다. // module-wiring.spec은 compile만 보므로 HTTP 노출은 실제 앱을 띄워 본다(DB·Redis는 대역, 디스패처·크론은 env로 끔). @@ -63,7 +64,7 @@ describe('역할별 HTTP 노출 (real app)', () => { .useValue(Object.assign(new PubSub(), { close: () => Promise.resolve() })) .compile(); const app = module.createNestApplication>(); - await app.init(); + await listenOnLoopback(app); return app; } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 50bf3c3a..545ed193 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -10,6 +10,7 @@ import { ACCOUNT_REPOSITORY } from './../src/features/auth/repositories/account. import { PrismaService } from './../src/prisma'; import { AccountType } from '@/generated/prisma/client'; +import { listenOnLoopback } from '@/test/http-app'; describe('AppController (e2e)', () => { let app: INestApplication; @@ -63,7 +64,7 @@ describe('AppController (e2e)', () => { .compile(); app = moduleFixture.createNestApplication(); - await app.init(); + await listenOnLoopback(app); jwt = moduleFixture.get(JwtService); }); From b55c4147681ae97ae37a023592ee66dfcde2f849 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 00:59:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=EA=B0=80=EB=A1=9C=EC=B1=84?= =?UTF-8?q?=EA=B8=B0=20spec=EC=9D=B4=20=EC=8B=A4=ED=8C=A8=ED=95=B4?= =?UTF-8?q?=EB=8F=84=20=EB=9D=84=EC=9A=B4=20=EC=84=9C=EB=B2=84=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=AB=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단언이 서버를 모으기 전에 실패하면 열린 서버가 남아 jest가 끝나지 않고 공용 락을 계속 잡음(첫 버전 실행에서 약 35분) - 서버는 열리는 즉시 모으고 afterEach가 연결까지 닫음 - 반증: 단언을 일부러 실패시켜도 13초 안에 종료(1 failed), 정상 3 passed --- src/test/http-app.spec.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/test/http-app.spec.ts b/src/test/http-app.spec.ts index 93e1093c..4a691b32 100644 --- a/src/test/http-app.spec.ts +++ b/src/test/http-app.spec.ts @@ -18,12 +18,18 @@ class WhoController { } } +/** 열린 채 남은 서버는 jest를 끝나지 못하게 하므로, 단언이 실패해도 afterEach가 닫도록 열리는 즉시 모은다 */ +const intruders: Server[] = []; + /** 다른 프로세스가 같은 포트에 서버를 여는 상황. 실패하면 오류 코드를 돌려준다 */ function bindIntruder(port: number, host?: string): Promise { const server = createServer((_req, res) => res.end('intruder')); return new Promise((resolve) => { server.once('error', (e: NodeJS.ErrnoException) => resolve(e.code ?? '')); - server.listen(port, host, () => resolve(server)); + server.listen(port, host, () => { + intruders.push(server); + resolve(server); + }); }); } @@ -35,7 +41,6 @@ const onMac = process.platform === 'darwin' ? it : it.skip; describe('listenOnLoopback', () => { let app: INestApplication; - const intruders: Server[] = []; beforeEach(async () => { const module = await Test.createTestingModule({ @@ -44,7 +49,10 @@ describe('listenOnLoopback', () => { app = module.createNestApplication>(); }); afterEach(async () => { - intruders.splice(0).forEach((s) => s.close()); + for (const server of intruders.splice(0)) { + server.closeAllConnections(); + server.close(); + } await app.close(); }); @@ -54,7 +62,6 @@ describe('listenOnLoopback', () => { await app.listen(0); // supertest가 닫힌 서버를 여는 방식 const intruder = await bindIntruder(portOf(app), '127.0.0.1'); expect(intruder).toBeInstanceOf(Server); - intruders.push(intruder as Server); const res = await request(app.getHttpServer()).get('/who'); expect(res.text).toBe('intruder'); @@ -67,8 +74,7 @@ describe('listenOnLoopback', () => { expect(await bindIntruder(port, '127.0.0.1')).toBe('EADDRINUSE'); // 와일드카드 바인드는 macOS에서 성공하지만 127.0.0.1 요청은 더 구체적인 앱 쪽으로 간다 - const wildcard = await bindIntruder(port); - if (wildcard instanceof Server) intruders.push(wildcard); + await bindIntruder(port); const res = await request(app.getHttpServer()).get('/who'); expect(res.text).toBe('app');