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: 1 addition & 1 deletion docs/guide/architecture-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`이 반증과 사용처 전수를 고정).

---

Expand Down
3 changes: 2 additions & 1 deletion src/features/auth/controllers/jwks.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 () => {
Expand Down
3 changes: 2 additions & 1 deletion src/features/system/health.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -126,7 +127,7 @@ describe('헬스 HTTP 응답 (real app)', () => {
app = module.createNestApplication<INestApplication<App>>();
// main.ts와 같은 배선(같은 상수)
app.useGlobalInterceptors(new ApiResponseInterceptor(RAW_RESPONSE_PATHS));
await app.init();
await listenOnLoopback(app);
});
afterAll(async () => {
await app.close();
Expand Down
3 changes: 2 additions & 1 deletion src/features/system/metrics.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -31,7 +32,7 @@ describe('MetricsController (real app)', () => {
}).compile();
app = module.createNestApplication<INestApplication<App>>();
app.useGlobalInterceptors(new ApiResponseInterceptor(RAW_RESPONSE_PATHS));
await app.init();
await listenOnLoopback(app);
metrics = module.get(MetricsService);
});
afterAll(async () => {
Expand Down
3 changes: 2 additions & 1 deletion src/global/metrics/http-metrics.middleware.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -87,7 +88,7 @@ describe('HttpMetricsMiddleware (실제 Express 스택)', () => {
imports: [TestAppModule],
}).compile();
app = module.createNestApplication<INestApplication<App>>();
await app.init();
await listenOnLoopback(app);
metrics = app.get(MetricsService);
});
afterAll(() => app.close());
Expand Down
3 changes: 2 additions & 1 deletion src/test/admin-query-null-inputs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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까지 내려가면
Expand Down Expand Up @@ -218,7 +219,7 @@ describe('관리자 Query nullable 입력 null 전수 (real DB)', () => {
new GraphQLExceptionFilter(logger, app.get(MetricsService)),
),
);
await app.init();
await listenOnLoopback(app);
});

afterAll(async () => {
Expand Down
101 changes: 101 additions & 0 deletions src/test/http-app.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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';
}
}

/** 열린 채 남은 서버는 jest를 끝나지 못하게 하므로, 단언이 실패해도 afterEach가 닫도록 열리는 즉시 모은다 */
const intruders: Server[] = [];

/** 다른 프로세스가 같은 포트에 서버를 여는 상황. 실패하면 오류 코드를 돌려준다 */
function bindIntruder(port: number, host?: string): Promise<Server | string> {
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, () => {
intruders.push(server);
resolve(server);
});
});
}

const portOf = (app: INestApplication<App>) =>
((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<App>;

beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [WhoController],
}).compile();
app = module.createNestApplication<INestApplication<App>>();
});
afterEach(async () => {
for (const server of intruders.splice(0)) {
server.closeAllConnections();
server.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);

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 요청은 더 구체적인 앱 쪽으로 간다
await bindIntruder(port);

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([]);
});
});
10 changes: 10 additions & 0 deletions src/test/http-app.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await app.listen(0, '127.0.0.1');
}
3 changes: 2 additions & 1 deletion src/test/role-routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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로 끔).
Expand Down Expand Up @@ -63,7 +64,7 @@ describe('역할별 HTTP 노출 (real app)', () => {
.useValue(Object.assign(new PubSub(), { close: () => Promise.resolve() }))
.compile();
const app = module.createNestApplication<INestApplication<App>>();
await app.init();
await listenOnLoopback(app);
return app;
}

Expand Down
3 changes: 2 additions & 1 deletion test/app.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<App>;
Expand Down Expand Up @@ -63,7 +64,7 @@ describe('AppController (e2e)', () => {
.compile();

app = moduleFixture.createNestApplication();
await app.init();
await listenOnLoopback(app);
jwt = moduleFixture.get(JwtService);
});

Expand Down
Loading