raw
backend

DataLoader를 매 요청마다 5개 만드는데, REQUEST scope는 안 썼다

2026.05.01·16분

DataLoader를 NestJS에 붙이는 자료를 찾으면 거의 다 이 코드가 나와요.

ts
1@Injectable({ scope: Scope.REQUEST })
2export class FeedbackCreatorLoader { ... }

요청마다 새 인스턴스를 만들기 위해 Scope.REQUEST를 붙인다.
이렇게 작성하는 것이 올바른 방법인가 생각이 들더라구요,

저는 아래와 같이 구현해봤습니다.

ts
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 설정이다.

ts
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.

ts
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: AttachmentsByTargetLoader
9 ) {}
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(...) 를 만들어서 반환한다.

ts
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.

ts
1@ResolveField(() => UserOutput)
2async createdBy(
3 @Parent() feedback: FeedbackEntity,
4 @Loaders() loaders: AppLoaders
5): Promise<UserOutput> {
6 return loaders.feedbackCreator.load(feedback.id);
7}

@Loaders() 데코레이터는 GraphQL context에서 loaders 를 꺼내는 일만 한다.
즉 위에서 만들어진 묶음을 그대로 받아 쓴다.


무엇이 싱글톤이고 무엇이 매번 새로 생기는가

추적해보니 이 구조에는 두 종류의 객체가 섞여 있다.

ts
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가 되어 더 깔끔해 보인다.

ts
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가 된다.

text
1FeedbackCreatorLoader (REQUEST)
2 ↑ 의존
3FeedbackResolver ← 자동으로 REQUEST 됨
4 ↑ 의존
5... 위로 감염

FeedbackCreatorLoader 하나에 REQUEST를 붙이면, 그것을 주입받는 모든 service, resolver가 같이 REQUEST가 된다.
의존성 그래프를 따라 위로 위로 전염된다.

이게 왜 일어나는지 객체지향적으로 설명하면 — 어떤 객체가 다른 객체를 필드로 보유하면, 보유자의 수명은 보유물의 수명보다 길 수 없기 때문이다.

ts
1class FeedbackResolver {
2 constructor(private readonly loader: FeedbackCreatorLoader) {}
3 // ↑
4 // 이 필드는 "처음 받은 인스턴스"를 영원히 가리킨다.
5 // 다른 요청에서 다른 loader를 가리키게 만들 수 없다.
6}

매 요청마다 다른 loader 가 들어와야 한다면, Resolver 자체도 매 요청마다 새로 만들어야 한다.
그래서 NestJS가 자동으로 위로 전염시킨다.

비용 2. DI 트리 재구성

매 요청마다 NestJS는 다음을 해야 한다.

  1. reflect-metadata 로 의존성 정보 다시 읽기
  2. constructor 호출 체인 (Loader → Repository → DataSource ...)
  3. TypeORM이 REQUEST 트리 안에 있다면 EntityManager proxy 재생성
  4. 요청 끝나면 GC가 모두 회수

객체 5개의 constructor 호출 + 각 의존성 해결. 작아 보이지만 이게 모든 요청에 쌓인다.


우리 패턴은 이 비용을 어떻게 피하는가

핵심은 "객체 수명을 두 층으로 분리" 하는 것이다.

text
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) 한 번이 만드는 것:

ts
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 함수의 본문이 실행되지 않는다.

ts
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 scopeSingleton + 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. 객체 수명을 두 층으로 분리 — 무거운 의존성은 부팅에 1번, 가벼운 캐시 컨테이너만 요청에 5번
  2. Scope 전염 없음 — Resolver, Service는 모두 깔끔한 싱글톤
  3. DI 트리 재구성 없음 — 부팅 때 그린 그래프를 평생 재사용

DI scope를 고를 때 진짜 질문은 "이 객체의 정체성이 매번 달라야 하는가?" 이다.
DataLoader의 정체성이 매번 달라야 하는 이유는 캐시 격리 하나뿐이고, 그 부분만 수동 팩토리로 분리해 놓으면 나머지는 싱글톤의 이점을 그대로 누릴 수 있다.

[!tip] 한 줄 정리 DI scope를 결정하는 건 객체 수명을 결정하는 것과 같다.
정체성이 매번 달라야 하는 부분만 좁은 scope에 두고, 나머지는 부팅 때 한 번 만들어 두자.
Factory 패턴은 그 둘을 코드로 분리하는 방법이고, REQUEST scope는 DI에 자동으로 위임하는 방법이다.
둘 중 무엇이 맞는지는 격리해야 할 것이 무엇인지에 달려 있다.