멀티테넌트 NestJS 서비스에서 "테넌트마다 DB 연결을 따로 잡고 싶은데 요청 스코프는 느리다고 하니 durable 을 써 볼까" 하는 순간이 와요. 결론부터 말하면 durable 프로바이더는 테넌트가 수십 곳 안쪽으로 정해져 있고, 테넌트마다 진짜로 다른 객체(전용 연결·전용 클라이언트)가 필요할 때만 맞아요. 테넌트 ID 를 읽기만 한다면 싱글턴 + AsyncLocalStorage 가 더 싸요.
이 글은 설정을 고르고 운영할 때 필요한 숫자와 확인 순서를 다뤄요. @nestjs/core 12.3.2·Express 5.3 어댑터로 작은 서버를 띄워 Apple M1·Node.js 25.3 에서 직접 쟀고, 규칙은 NestJS 공식 문서 Injection scopes의 Durable providers 절과 12.3.2 패키지 소스로 다시 확인했어요. 스코프 전반과 요청 스코프 처리량은 NestJS 프로바이더 스코프 고르기 글에 있어요.
durable 프로바이더 쓸까 말까 — 판단표
공식 문서는 durable 을 "고객사 10곳이 각자 전용 데이터 소스를 쓰는" 경우로 설명하고, 테넌트가 많으면 맞지 않는다고 경고해요.
| 상황 | 고를 것 | 이유 |
|---|---|---|
| 테넌트 ID·사용자·로케일을 서비스에서 읽기만 함 | 싱글턴 + AsyncLocalStorage | 트리를 만들 일이 없어요. 문서도 이 경우 이 방식을 먼저 권해요 |
| 테넌트 10~50곳 고정, 테넌트마다 전용 DB·스키마 연결 | REQUEST + durable: true | 테넌트당 트리 한 벌, 같은 테넌트 요청은 생성자 호출 없음 |
| 테넌트 수백 곳 이상이거나 가입으로 계속 늘어남 | 싱글턴 연결 관리자 + AsyncLocalStorage | 연결 수·메모리를 관리자 한 곳에서 한도로 묶을 수 있어요 |
| 요청 UUID 기반 캐시처럼 요청마다 진짜로 다른 상태 | REQUEST(durable 아님) | 묶을 수 있는 공통 속성이 없어요 |
| 테넌트별 설정값(요금제·기능 플래그) 몇 개만 다름 | 싱글턴 + 설정 조회 | 객체를 테넌트마다 만들 이유가 없어요 |
핵심 질문은 "테넌트마다 다른 인스턴스가 꼭 있어야 하나"예요. 값만 다르면 값을 넘기면 돼요. AsyncLocalStorage 쓰는 법은 NestJS Async local storage 레시피와 Node.js async_context 문서에 있어요.
durable 이 안 먹는 세 가지 경우 — 생성자 횟수로 확인
durable 은 위로 번져요. 문서 설명대로 A 가 durable 인 B 에 의존하면 A 도 durable 이 되는데, A 의 다른 요청 스코프 의존성이 모두 durable 일 때만이에요. 하나라도 아니면 A 는 평범한 요청 스코프로 남고, 그 위의 컨트롤러도 요청마다 새로 만들어져요.
Controller → OrderService → TenantConn(durable) 체인에 테넌트 a·b·a·a·b·c 순서로 여섯 번 요청하고, OrderService 에 의존성 하나를 더 붙인 두 변형도 같은 순서로 돌렸어요.
- ① durable 만 — 테넌트마다 한 번씩, 의도대로 3번이에요.
- ②
durable을 안 적은 요청 스코프 클래스가 섞임 — 요청 로거 하나가OrderService와 컨트롤러를 요청마다 만들게 했어요. - ③
@Injectable()만 붙은 클래스가@Inject(REQUEST)를 받음 — 놓치기 쉬워요. 12.3.2 의request-providers.js에서REQUEST프로바이더는scope: Scope.REQUEST만 있고durable이 없어서, 그걸 받는 평범한 클래스는 durable 이 아닌 요청 스코프로 판정돼요.
TenantConn 도 REQUEST 를 받는데 멀쩡한 이유는 durable: true 를 직접 적었기 때문이에요. 명시한 클래스는 자기 플래그를 쓰고 의존성을 따지지 않아요. 요청 정보를 읽는 클래스에는 { scope: Scope.REQUEST, durable: true } 를 적으세요.
판정 결과를 로그로 보는 법
환경 변수 NEST_DEBUG=1 을 주고 로거를 켠 채(logger: false 면 안 보여요) 앱을 띄우면 부팅 때 클래스마다 introspected as request-scoped, durable 이면 이어서 introspected as durable 이 찍혀요. ①에서는 TenantConn·OrderService·컨트롤러 셋 다 durable 줄이 나왔고, ②에서는 TenantConn 만 나왔어요. 컨트롤러 이름 옆에 durable 줄이 없으면 체인 어딘가가 막고 있는 거예요. durable 줄이 없는 가장 아래 클래스가 원인이에요.
테넌트 수별 메모리 — 직접 잰 트리 크기
durable 트리는 전략의 맵이 테넌트 ContextId 를 쥐는 동안 살아 있어서 메모리는 (테넌트 수 × 트리 크기)로 늘어요. 생성자 말고는 아무것도 없는 빈 서비스를 3·10·30개 이은 체인을 만들고 서로 다른 테넌트 1만 곳으로 한 번씩 요청한 뒤 GC 후 힙 증가량을 나눴어요. 인스턴스 수는 서비스에 TenantConn 과 컨트롤러를 더한 값이에요.
인스턴스 하나가 늘 때마다 약 0.6KB 씩 커졌어요. 테넌트 수를 곱하면 이래요.
| 테넌트 수 | 인스턴스 5개 체인 | 인스턴스 12개 체인 | 인스턴스 32개 체인 |
|---|---|---|---|
| 50곳 | 0.17MB | 0.38MB | 0.98MB |
| 1,000곳 | 3.4MB | 7.6MB | 19.5MB |
| 10만 곳 | 344MB | 760MB | 1,949MB |
50곳이면 걱정할 크기가 아니에요. 문제는 키가 클라이언트 입력으로 무한히 늘 수 있을 때예요. 헤더 값을 그대로 키로 쓰면 임의 문자열 10만 개만으로 마지막 줄이 생겨요.
시간 비용도 같이 쟀어요. 새 테넌트의 첫 요청은 같은 테넌트 반복 요청보다 인스턴스 5개 체인에서 약 0.17ms, 12개에서 0.31ms, 32개에서 0.80ms 더 걸렸어요(로컬 HTTP 왕복 포함, 1만 회 평균). 실제로 무거운 건 생성자 안에서 여는 DB 연결이에요.
테넌트별 커넥션 풀 계산 — DB 연결 한도부터
durable 을 쓰는 가장 흔한 이유가 테넌트별 DB 연결이에요. 이때 먼저 터지는 건 Node 메모리가 아니라 DB 쪽 연결 수예요. 계산식은 간단해요.
DB 서버 하나가 받는 연결 수 = (그 서버에 있는 테넌트 수) × (테넌트당 풀 크기) × (앱 파드 수)
PostgreSQL 은 공식 문서 max_connections 항목에 기본값이 보통 100 이라고 적혀 있어요. 예를 들어 볼게요.
| 구성 | 계산 | DB 서버당 연결 |
|---|---|---|
| 테넌트마다 DB 서버 따로, 풀 10, 파드 4 | 1 × 10 × 4 | 40 |
| DB 서버 하나에 스키마로 나눈 테넌트 10곳, 풀 10, 파드 4 | 10 × 10 × 4 | 400 |
| 같은 구성, 풀 2 로 줄임 | 10 × 2 × 4 | 80 |
| 같은 구성, 파드 10 으로 오토스케일 | 10 × 2 × 10 | 200 |
두 번째 줄이 흔한 함정이에요. 스키마로 나눈 테넌트마다 풀을 만들면 기본 한도 100 의 네 배예요. 풀 크기는 (DB 한도 − 여유분) ÷ (테넌트 수 × 최대 파드 수)로 거꾸로 정하는 게 안전해요. 위 예에서 여유분 20 을 남기면 (100 − 20) ÷ (10 × 10) = 0.8 이라 테넌트별 풀은 성립하지 않고, 공유 풀 하나에서 요청마다 SET search_path 로 스키마를 바꾸는 방식이 맞아요. 풀 크기를 스레드·작업 수로 거꾸로 계산하는 방법은 스프링 배치 파티셔닝의 커넥션 풀 계산과 같은 방식이에요.
테넌트 키 설계 — 허용 목록과 퇴출
ContextIdStrategy.attach() 는 요청마다 불려 어느 트리를 쓸지 정해요. 문서 예제는 헤더 값을 맵 키로 바로 쓰는데, 운영에서는 두 가지를 더해야 해요.
모르는 테넌트는 트리를 만들지 않기
허용 목록에 없는 값이면 원래 요청 ContextId 를 돌려줘요. 그 요청은 평범한 요청 스코프로 돌고 끝나면 GC 되며, 거절은 가드에서 해요. 임의 테넌트 1만 개로 다시 재 보니 인스턴스 12개 체인에서 테넌트당 7.6KB 남던 것이 요청당 약 0.35KB 로 줄었어요(나머지는 측정 오차 수준). 키는 헤더보다 서명된 토큰의 테넌트 클레임에서 가져오는 게 안전해요. 토큰에 테넌트 클레임을 넣는 구조라면 JWT 디코더로 실제 토큰의 payload 에 그 값이 있는지 먼저 확인해 보세요.
attach(contextId: ContextId, request: Request) {
const tenant = request.headers['x-tenant-id'] as string | undefined;
if (!tenant || !this.allowed.has(tenant)) {
return { resolve: () => contextId, payload: { tenantId: null } };
}
const tree = this.lru.getOrCreate(tenant, () => ContextIdFactory.create());
return {
resolve: (info: HostComponentInfo) => (info.isTreeDurable ? tree : contextId),
payload: { tenantId: tenant },
};
}
오래 안 쓴 테넌트 내보내기
맵에서 테넌트 항목을 지우면 그 트리는 다른 데서 참조하지 않는 한 GC 대상이 돼요. 직접 재 보니 1만 곳을 지운 뒤 늘었던 힙의 84~94% 가 돌아왔어요. 그래서 맵을 LRU 로 만들어 상한을 두면 돼요. 함정은 라이프사이클 이벤트 문서대로 요청 스코프 클래스에서는 onModuleInit·onModuleDestroy 같은 훅이 불리지 않고, 실제로 app.close() 때 durable 인스턴스의 onModuleDestroy 는 안 불렸어요. 맵에서 지우기 전에 연결을 직접 닫아야 해요.
닫을 인스턴스는 moduleRef.resolve(TenantConn, 테넌트ContextId, { strict: false }) 로 꺼내면 돼요. 테넌트 ContextId 를 넘기면 이미 만들어진 인스턴스가 나와요. close() 후 맵에서 지우면 다음 요청에서 새 트리가 생겨요. 처리 중이던 요청이 옛 인스턴스를 쥐고 있을 수 있으니 진행 중 쿼리를 기다리는 정상 종료를 쓰고, 앱 종료 때 맵 전체를 닫는 코드는 싱글턴의 onApplicationShutdown 에 두세요.
HTTP 밖에서 쓰기 — 큐 작업·크론에서 테넌트 트리 꺼내기
durable 트리는 HTTP 라우터가 요청을 받을 때 고르는데, 큐 작업이나 크론에는 요청이 없어요. 이때 Module reference 문서의 resolve() 를 쓰는데, ContextId 를 안 넘기면 부를 때마다 새 트리가 만들어져요. 실제로 넘기지 않았더니 TenantConn 생성자가 한 번 더 불렸고 REQUEST 자리는 undefined 였어요. 테넌트 트리를 재사용하려면 전략을 통과한 ContextId 를 만들어 넘겨야 해요.
const contextId = ContextIdFactory.getByRequest({ headers: { 'x-tenant-id': job.tenantId } } as any);
this.moduleRef.registerRequestByContextId(contextId.payload, contextId);
const svc = await this.moduleRef.resolve(OrderService, contextId, { strict: false });
getByRequest() 는 전략이 등록돼 있으면 attach() 를 불러 주니, 가짜 요청 객체에 테넌트 값만 넣으면 HTTP 요청이 만든 테넌트 트리의 인스턴스가 그대로 나오고 생성자는 다시 불리지 않았어요. 이 코드가 작업마다 번거롭다면 AsyncLocalStorage 가 더 맞다는 신호예요.
운영에서 자주 나는 문제와 확인 순서
| 증상 | 먼저 볼 것 | 흔한 원인 |
|---|---|---|
| durable 로 바꿨는데 처리량이 그대로 | NEST_DEBUG=1 로 컨트롤러가 durable 로 판정됐는지 | 체인에 durable 아닌 요청 스코프, @Inject(REQUEST) 받는 평범한 클래스 |
| 힙이 시간이 갈수록 계속 늘어남 | 전략 맵 크기를 메트릭으로 | 헤더 값을 검증 없이 키로 사용, 퇴출 없음 |
| DB "too many connections" | (테넌트 × 풀 × 파드) 계산 | 테넌트별 풀 + 오토스케일 |
REQUEST 로 받은 값에 헤더가 없음 | attach() 의 payload | durable 트리에는 요청 객체가 아니라 payload 만 들어가요 |
| 테넌트 A 요청에서 B 의 값이 보임 | durable 클래스가 요청마다 바뀌는 값을 필드에 저장하는지 | 사용자 ID·요청 ID 를 durable 인스턴스 필드에 넣음 |
| 큐 작업마다 연결이 새로 열림 | resolve() 에 ContextId 를 넘겼는지 | 인자 없이 resolve() 호출 |
| 배포·종료 후 DB 에 연결이 남음 | 종료 훅에서 테넌트 연결을 닫는지 | durable 인스턴스엔 onModuleDestroy 가 안 불림 |
다섯 번째 줄은 보안 문제예요. durable 인스턴스는 같은 테넌트의 모든 요청이 공유해서, 필드에 로그인 사용자를 저장하면 같은 테넌트의 다른 사용자 요청이 그 값을 읽을 수 있어요. durable 클래스에는 테넌트 단위로 같은 값(연결·설정)만 두세요.
도입 전 체크리스트
- 객체가 달라야 하나: 테넌트마다 다른 "값"이면 durable 이 아니에요.
- 테넌트 수 상한: 1년 뒤 수백 곳을 넘을 것 같으면 연결 관리자 방식으로 시작해요.
- 체인 판정:
NEST_DEBUG=1로그에서 컨트롤러가introspected as durable인지,@Inject(REQUEST)를 받는 클래스가 모두durable: true를 적었는지. - 키 출처: 서명된 값에서 오는지, 허용 목록 밖이면 트리를 안 만드는지.
- 연결 수: (테넌트 × 풀 × 최대 파드)가 DB 한도의 80% 안인지.
- 퇴출과 종료: LRU 상한, 퇴출·종료 때
close()코드가 있는지. - 비HTTP 경로: 큐·크론이
getByRequest()로 같은 트리를 받는지, 아니면 ALS 로 처리하는지.
자주 묻는 질문
durable 프로바이더는 싱글턴과 성능이 같나요?
같지 않아요. 생성자는 다시 안 불리지만 요청마다 전략을 거쳐 트리를 고르는 단계가 붙어요. 새 테넌트 첫 요청은 인스턴스 12개 기준 약 0.3ms 더 걸렸어요.
테넌트가 몇 곳까지면 durable 을 써도 되나요?
보통 메모리보다 연결 수가 먼저 한계예요. 트리는 1,000곳·인스턴스 12개여도 빈 클래스 기준 7.6MB 지만, 테넌트별 DB 풀은 (테넌트 × 풀 × 파드)가 DB 한도 안이어야 해요.
durable 클래스에서 요청 헤더를 읽을 수 있나요?
직접은 못 읽어요. REQUEST 자리에는 attach() 가 돌려준 payload 가 들어가요. 사용자·요청마다 다른 값은 메서드 인자나 AsyncLocalStorage 로 넘기세요.
테넌트 트리를 지우면 정말 메모리가 돌아오나요?
맵에서 ContextId 를 지우면 GC 대상이 되고, 1만 곳 기준 늘어난 힙의 84~94% 가 돌아왔어요. 연 DB 연결은 GC 로 닫히지 않으니 지우기 전에 직접 닫아야 해요.
큐 작업에서도 같은 테넌트 인스턴스를 쓸 수 있나요?
ContextIdFactory.getByRequest() 에 테넌트 값을 담은 객체를 넘겨 얻은 ID 로 moduleRef.resolve() 를 부르면 같은 인스턴스가 나와요. ID 없이 부르면 매번 새 트리가 생겨요.
정리
- durable 은 테넌트마다 다른 객체가 필요하고 테넌트가 적고 고정일 때만 써요. 값만 다르면 싱글턴 + AsyncLocalStorage 예요.
- durable 아닌 요청 스코프나
@Inject(REQUEST)를 받는 평범한 클래스 하나가 체인 위쪽을 다시 요청마다 만들어요.NEST_DEBUG=1로 먼저 확인하세요. - 테넌트 트리는 빈 클래스로도 3.4~19.5KB 씩 남아요. 허용 목록·LRU 상한·직접
close()를 같이 두세요. - 테넌트별 DB 풀은 (테넌트 × 풀 × 파드) ≤ DB 한도로 거꾸로 계산해요.
- 관련 글: NestJS 프로바이더 스코프 고르기, 스프링 배치 커넥션 풀 계산
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 NestJS 주입 스코프 원리 — REQUEST·durable 버블링에서 도식과 코드로 단계별로 풀었어요.