DataLoader를 NestJS에 붙이는 자료를 찾으면 거의 다 이 코드가 나와요.
1@Injectable({ scope: Scope.REQUEST })2export class FeedbackCreatorLoader { ... }요청마다 새 인스턴스를 만들기 위해 Scope.REQUEST를 붙인다.
이렇게 작성하는 것이 올바른 방법인가 생각이 들더라구요,
저는 아래와 같이 구현해봤습니다.
1@Injectable() // ← 싱글톤2export class FeedbackCreatorLoader {3 constructor(4 @InjectRepository(FeedbackEntity)5 private readonly feedbackRepository: Repository<FeedbackEntity>6 ) {}7
8 create(): DataLoader<number, UserEntity> {9 return new DataLoader<number, UserEntity>(async (ids) => { ... });10 }11}Scope.REQUEST 가 없다.
그런데 캐시 누수는 안 생긴다.
호기심에 create() 메서드 안에 console.log를 찍어봤어요.
모든 요청마다 5개의 Loader 전부에서 로그가 찍힌다. 어떤 요청은 그중 1개만 쓰는데도.
이게 좀 마음에 걸렸다. 안 쓰는 4개를 매번 왜 만들지?
코드를 위에서 아래로 따라가 봤어요.
코드 흐름을 따라가 보자
시작점은 app.module.ts 안의 GraphQL 설정이다.
1const graphQLModule = GraphQLModule.forRootAsync<ApolloDriverConfig>({2 imports: [DataLoaderModule],3 inject: [AppLoadersFactory],4 useFactory: (appLoadersFactory: AppLoadersFactory) => ({5 context: ({ req, res }) => ({6 req,7 res,8 loaders: appLoadersFactory.create(), // ← 여기가 시작9 }),10 }),11});useFactory 자체는 부팅 시 1번만 실행된다.
그 안에 정의된 context 콜백 함수만 매 요청마다 호출된다.
이 콜백이 appLoadersFactory.create() 를 부른다.
다음 정거장은 AppLoadersFactory.
1@Injectable()2export class AppLoadersFactory {3 constructor(4 private readonly feedbackThreadsLoader: FeedbackThreadsLoader,5 private readonly feedbackDrawingLoader: FeedbackDrawingLoader,6 private readonly feedbackCreatorLoader: FeedbackCreatorLoader,7 private readonly threadCreatorLoader: ThreadCreatorLoader,8 private readonly attachmentsByTargetLoader: AttachmentsByTargetLoader9 ) {}10
11 create(): AppLoaders {12 return {13 feedbackThreads: this.feedbackThreadsLoader.create(),14 feedbackDrawing: this.feedbackDrawingLoader.create(),15 feedbackCreator: this.feedbackCreatorLoader.create(),16 threadCreator: this.threadCreatorLoader.create(),17 attachmentsByTarget: this.attachmentsByTargetLoader.create(),18 };19 }20}여기 create() 가 매번 호출되면서 5개의 Loader 각각의 create() 도 호출된다.
그리고 각 Loader의 create() 가 new DataLoader(...) 를 만들어서 반환한다.
1@Injectable()2export class FeedbackCreatorLoader {3 constructor(4 @InjectRepository(FeedbackEntity)5 private readonly feedbackRepository: Repository<FeedbackEntity>6 ) {}7
8 create(): DataLoader<number, UserEntity> {9 return new DataLoader<number, UserEntity>(async (feedbackIds) => {10 const rows = await this.feedbackRepository.find({11 where: { id: In(feedbackIds) },12 relations: { createdBy: true },13 });14 // ... 매핑 로직15 });16 }17}여기서 핵심을 발견했어요.
create() 라는 이름이 두 군데에 있다. 같은 이름이지만 다른 클래스의 다른 메서드다.
AppLoadersFactory.create()— 5개를 묶어서 만드는 공장장FeedbackCreatorLoader.create()— DataLoader 1개를 만드는 공장 직원
직원이 5명, 공장장이 1명. 공장장이 직원 5명 모두에게 "한 개씩 찍어와" 라고 시키는 구조다.
마지막 정거장은 Resolver.
1@ResolveField(() => UserOutput)2async createdBy(3 @Parent() feedback: FeedbackEntity,4 @Loaders() loaders: AppLoaders5): Promise<UserOutput> {6 return loaders.feedbackCreator.load(feedback.id);7}@Loaders() 데코레이터는 GraphQL context에서 loaders 를 꺼내는 일만 한다.
즉 위에서 만들어진 묶음을 그대로 받아 쓴다.
무엇이 싱글톤이고 무엇이 매번 새로 생기는가
추적해보니 이 구조에는 두 종류의 객체가 섞여 있다.
1// ⓐ 클래스 인스턴스 — 싱글톤2@Injectable()3export class FeedbackCreatorLoader {4 // 이 객체는 부팅 시 1번 만들어지고 평생 살아있음5 // Repository를 constructor로 받아서 보유6}7
8// ⓑ DataLoader 인스턴스 — 요청별9new DataLoader(batchFn)10// 이 객체는 매 요청마다 create() 안에서 새로 만들어지고11// 요청 끝나면 GC가 회수같은 단어 "DataLoader" 가 두 가지를 가리켜서 헷갈렸어요.
FeedbackCreatorLoader 는 우리가 만든 NestJS provider 클래스고,
new DataLoader(...) 는 외부 라이브러리(graphql/dataloader)의 진짜 DataLoader 인스턴스다.
이걸 카페로 비유하면:
| 객체 | 카페 비유 | 역할 | 언제 만드는가 |
|---|---|---|---|
| ⓐ Loader 클래스 | 원두 그라인더 | Repository(원두 저장고) 연결, 갈아내는 방법 보유 | 부팅 시 1번 |
| ⓑ DataLoader 인스턴스 | 종이 드립 필터 | 한 손님 분의 캐시(빈 컵)만 보유 | 요청 시마다 |
손님이 올 때마다 그라인더를 새로 사진 않는다.
종이 필터만 매번 새로 깔아주면 된다.
그럼 왜 REQUEST scope를 안 썼을까
만약 FeedbackCreatorLoader 자체를 Scope.REQUEST 로 만들면, 클래스가 곧 DataLoader가 되어 더 깔끔해 보인다.
1@Injectable({ scope: Scope.REQUEST })2export class FeedbackCreatorLoader extends DataLoader<number, UserEntity> {3 constructor(@InjectRepository(FeedbackEntity) repo) {4 super(async (ids) => { ... });5 }6}이렇게 하면 NestJS DI가 매 요청마다 새 인스턴스를 알아서 만들어준다.
직접 factory.create() 같은 걸 부를 필요 없다.
그런데 이 방식엔 두 가지 비용이 따라온다.
비용 1. Scope 전염
NestJS 규칙: REQUEST scope provider에 의존하는 모든 provider는 자기도 REQUEST scope가 된다.
1FeedbackCreatorLoader (REQUEST)2 ↑ 의존3FeedbackResolver ← 자동으로 REQUEST 됨4 ↑ 의존5... 위로 감염FeedbackCreatorLoader 하나에 REQUEST를 붙이면, 그것을 주입받는 모든 service, resolver가 같이 REQUEST가 된다.
의존성 그래프를 따라 위로 위로 전염된다.
이게 왜 일어나는지 객체지향적으로 설명하면 — 어떤 객체가 다른 객체를 필드로 보유하면, 보유자의 수명은 보유물의 수명보다 길 수 없기 때문이다.
1class FeedbackResolver {2 constructor(private readonly loader: FeedbackCreatorLoader) {}3 // ↑4 // 이 필드는 "처음 받은 인스턴스"를 영원히 가리킨다.5 // 다른 요청에서 다른 loader를 가리키게 만들 수 없다.6}매 요청마다 다른 loader 가 들어와야 한다면, Resolver 자체도 매 요청마다 새로 만들어야 한다.
그래서 NestJS가 자동으로 위로 전염시킨다.
비용 2. DI 트리 재구성
매 요청마다 NestJS는 다음을 해야 한다.
reflect-metadata로 의존성 정보 다시 읽기- constructor 호출 체인 (Loader → Repository → DataSource ...)
- TypeORM이 REQUEST 트리 안에 있다면 EntityManager proxy 재생성
- 요청 끝나면 GC가 모두 회수
객체 5개의 constructor 호출 + 각 의존성 해결. 작아 보이지만 이게 모든 요청에 쌓인다.
우리 패턴은 이 비용을 어떻게 피하는가
핵심은 "객체 수명을 두 층으로 분리" 하는 것이다.
1[부팅 시 1회 생성, 평생 살아있음] ──────────── 수명 ①2 FeedbackCreatorLoader 인스턴스3 ├── this.feedbackRepository ──── 부팅 때 받은 후 안 바뀜4 └── create() 메서드 정의5
6 ▼ 요청이 들어옴, create() 호출7
8[요청 동안만 살아있음] ─────────────────────── 수명 ②9 new DataLoader(batchFn)10 └── 빈 cache Map (요청 안에서만 채워짐)11
12 ▼ 요청 종료, context 버려짐 → GC 회수무거운 것 — Repository 의존성, Logger, 메타데이터 — 은 수명 ① 에 있다.
가벼운 것 — 빈 Map 1개를 든 wrapper — 만 수명 ② 에 있다.
DI 컨테이너가 매번 트리를 다시 그릴 필요 없다.
부팅 때 그려놓은 그래프를 평생 재사용하고, 요청이 들어오면 단지 new DataLoader(fn) 5번 호출할 뿐이다.
[!info] 클로저가 두 층을 연결한다
create()안의 batch 함수는 클로저로this를 캡처한다.
그래서 새로 만든 DataLoader는 가벼워도, 필요할 땐 싱글톤이 가진this.feedbackRepository를 그대로 사용할 수 있다.
그래서 5번 호출이 정말 비싼가
처음 이 코드를 봤을 때 마음에 걸렸던 이유는, "안 쓰는 Loader도 매번 만든다" 가 비효율로 느껴졌기 때문이다.
프론트의 불필요한 리렌더링 같은 느낌이었어요.
그런데 비용을 계산해보면 다르다.
new DataLoader(batchFn) 한 번이 만드는 것:
1// 대략적인 내부 구조2{3 _batchFn: batchFn, // 함수 참조 (8 byte)4 _cache: new Map(), // 빈 Map (작은 헤더, 0 entries)5 _queue: [], // 빈 배열6 _batchScheduled: false,7}V8 기준 200~300 byte, 수십 ns. 5개를 만들어도 1µs 미만이다.
DB 쿼리 한 번이 1~10ms 인 걸 생각하면, 이 비용은 반올림하면 0이다.
함수 호출과 함수 본문 실행은 다른 일
더 중요한 건 이거다 — create() 호출 시점에는 batch 함수의 본문이 실행되지 않는다.
1create(): DataLoader<number, UserEntity> {2 return new DataLoader(3 async (feedbackIds) => { // ← 이 함수는 정의만 됨4 const rows = await this.feedbackRepository.find({...}); // ← 실행 안 됨5 const byFeedback = new Map(); // ← 실행 안 됨6 for (const feedback of rows) { ... } // ← 실행 안 됨7 }8 );9}new DataLoader(...) 는 batch 함수를 참조로만 저장한다.
실제 실행은 누군가 loader.load(123) 을 호출하고, 같은 tick에 다른 load() 들이 모여서 batch가 트리거될 때다.
쓰지 않는 Loader라면? batch 함수는 영원히 실행되지 않는다.
DB 쿼리도, for문도, Map 만들기도 일어나지 않는다.
[!tip] 직관 점검 매 요청마다 5번 호출 = 비용 거의 0인 wrapper 객체 5개 생성
진짜 비용(DB I/O, 데이터 변환)은 실제로 사용된 Loader에서만 발생한다.
프론트의 useMemo와 같은 원칙 — 비싸면 캐시, 안 비싸면 다시 계산이 더 단순.
두 패턴 비교
| REQUEST scope | Singleton + Factory (우리 패턴) | |
|---|---|---|
| Loader 클래스 인스턴스 | 요청마다 새로 | 부팅 시 1회만 |
| DataLoader 인스턴스 | 요청마다 새로 | 요청마다 새로 |
| DI 트리 reflection | 요청마다 발생 | 부팅 시 1회만 |
| Scope 전염 | 의존 트리 위로 | 없음 |
| 요청별 캐시 격리 | ✅ | ✅ |
| 코드 단순성 | ✅ | Factory 1개 추가 필요 |
REQUEST scope가 코드는 더 짧다.
대신 의존 트리 전체에 영향을 미친다.
Singleton + Factory는 Factory 클래스 1개를 추가로 만들어야 하는 대신, DI 영향이 한 곳에서 끝난다.
어느 쪽이 정답인가
정답은 없다. 트레이드오프다.
REQUEST scope가 자연스러운 경우:
- 요청 단위 transaction을 service에 침투시키고 싶을 때 (TypeORM EntityManager per-request)
- 멀티테넌시 — 요청 사용자에 따라 DB connection이 다를 때
- 요청 컨텍스트가 깊은 트리에 박혀야 할 때
이때는 scope 전염이 오히려 원하는 동작이다. 모든 트리가 요청 컨텍스트를 알 아야 하니까.
Singleton + Factory가 더 나은 경우:
- 격리해야 하는 게 캐시 같은 작은 상태 뿐일 때
- 다른 service들은 모두 stateless 싱글톤일 때
- DataLoader처럼 "요청 단위 격리가 필요한 가벼운 객체"가 명확히 분리되는 경우
DataLoader는 정확히 후자에 해당한다. 격리해야 할 것은 캐시 Map 하나뿐이고, Repository나 Service는 모두 stateless다.
그래서 GraphQL 생태계 전반(Apollo, Yoga, Mercurius)이 "context 콜백에서 DataLoader를 새로 만든다" 를 정석 패턴으로 삼고 있는 거다.
정리
처음의 의문 — "안 쓰는 4개도 매 요청마다 만들어진다" — 은 사실이지만 비싸지 않다.
new DataLoader(fn) 은 빈 Map을 든 wrapper 1개를 만들 뿐이고, 안의 batch 함수 본문은 실제로 사용될 때만 실행된다.
대신 이 패턴이 얻는 것은 분명하다.
- 객체 수명을 두 층으로 분리 — 무거운 의존성은 부팅에 1번, 가벼운 캐시 컨테이너만 요청에 5번
- Scope 전염 없음 — Resolver, Service는 모두 깔끔한 싱글톤
- DI 트리 재구성 없음 — 부팅 때 그린 그래프를 평생 재사용
DI scope를 고를 때 진짜 질문은 "이 객체의 정체성이 매번 달라야 하는가?" 이다.
DataLoader의 정체성이 매번 달라야 하는 이유는 캐시 격리 하나뿐이고, 그 부분만 수동 팩토리로 분리해 놓으면 나머지는 싱글톤의 이점을 그대로 누릴 수 있다.
[!tip] 한 줄 정리 DI scope를 결정하는 건 객체 수명을 결정하는 것과 같다.
정체성이 매번 달라야 하는 부분만 좁은 scope에 두고, 나머지는 부팅 때 한 번 만들어 두자.
Factory 패턴은 그 둘을 코드로 분리하는 방법이고, REQUEST scope는 DI에 자동으로 위임하는 방법이다.
둘 중 무엇이 맞는지는 격리해야 할 것이 무엇인지에 달려 있다.