03 · Batch & Cache
이 문서가 답하는 질문: DataLoader 안에 batch와 cache가 둘 다 들어있다 — 둘은 어떻게 다르고, 왜 cache는 반드시 per-request여야 하는가? 한 줄 답: “batch는 공간 차원의 dedupe(같은 tick의 같은 key들을 한 번에)이고, cache는 시간 차원의 dedupe(같은 요청 안에서 같은 key의 결과 재사용)다. 둘 다 요청 경계 안에서만 안전하다.”
Why — 왜 둘을 함께 봐야 하나
DataLoader 안에는 사실상 두 개의 dedupe 메커니즘이 들어 있다. 사람들은 보통 batch만 떠올리지만, cache가 조용히 더 큰 일을 한다. 그리고 cache의 scope를 잘못 잡으면 — batch보다 더 큰 사고가 난다.
| 메커니즘 | 무엇을 dedupe하나 | 어디서 효과를 보나 |
|---|---|---|
| batch | 같은 tick에 모인 동일 key 요청들 | 형제 필드들이 한 번에 fetch됨 |
| cache | 같은 요청 안에서 같은 key의 반복 호출 | 트리의 깊은 곳에서 같은 user를 또 만남 |
같이 봐야 진짜 그림이 나온다.
How — Batch와 Cache는 언제 각각 효과를 내나
Batch가 효과를 내는 자리
query {
users(limit: 100) { # 1번 SELECT
posts { # → User.posts × 100 → batch 1번
title
}
}
}- 부모 컬렉션의 모든 형제가 같은 tick에
.load(user.id)를 호출. - DataLoader가 queue를 쌓다가 다음 tick에 한 번에 dispatch.
Cache가 효과를 내는 자리
query {
post(id: "p1") {
author { name } # → userLoader.load("u1") → DB hit
comments {
author { name } # → userLoader.load("u1") → cache hit!
}
}
}- 같은 user
u1이 post의 author로도 등장하고, comment의 author로도 등장한다. - 두 번째 호출은 이미 resolve된 Promise를 그대로 반환한다 → DB 호출 안 일어남.
함께 효과를 내는 자리
query {
users(limit: 100) { # 100명의 user
posts {
comments {
author { name } # 댓글 작성자 — 일부는 원래 users 안에 이미 있음
}
}
}
}- 부모-자식 batch가 일어나면서,
- 작성자가 원래 users 안에 있던 사람이면 cache hit으로 추가 DB 호출 안 일어남.
What — DataLoader의 cache 동작 정의
1) cache key는 cacheKeyFn이 결정
new DataLoader(batchFn, {
cacheKeyFn: (key) => JSON.stringify(key),
});- 기본값은 strict equality (
===) — 즉 primitive는 잘 동작, 객체는 reference 비교라서 안 됨. - 객체를 key로 쓸 때는 반드시 cacheKeyFn 지정.
2) cache는 Promise를 저장한다, 값이 아니라
// 같은 tick에서 두 번 호출:
const p1 = userLoader.load("u1"); // queue에 추가, Promise 반환
const p2 = userLoader.load("u1"); // queue에 *추가 안 됨*, 같은 Promise 반환
// p1 === p2 → true이게 dedupe다 — batch queue에도 한 번만 들어간다.
3) 한 번 fetch된 key는 같은 요청 동안 계속 hit
const u1 = await userLoader.load("u1"); // DB hit
const u1Again = await userLoader.load("u1"); // cache hit, 같은 값
// u1 === u1Again → true (참조 동일)4) cache는 수동으로 무효화 가능
// 변경(mutation) 후 캐시 비우기
userLoader.clear("u1");
// 전체 비우기
userLoader.clearAll();
// 외부에서 값 prime — 다른 곳에서 이미 가져온 데이터를 캐시에 미리 심기
userLoader.prime("u1", existingUser);이게 mutation 직후 stale cache 방지의 표준 패턴이다.
const resolvers = {
Mutation: {
updateUser: async (_, { id, input }, { db, loaders }) => {
const updated = await db.users.update({ where: { id }, data: input });
loaders.userById.clear(id).prime(id, updated);
// ↑ 같은 요청 안의 후속 resolver들이 cache에서 신선한 값을 본다
return updated;
},
},
};What — 왜 cache는 반드시 per-request인가
이유 1 — 보안: 캐시 누출
// ❌ 절대 안 됨 — 서버 전역 DataLoader
const userLoader = new DataLoader(batchFn);
// 시나리오:
// 1. user A가 자기 프로필 fetch → userLoader.load('a') → 캐시에 A
// 2. user B가 *해킹 시도로 같은 id*를 fetch → cache hit으로 A의 데이터를 받음
// 3. authorization check가 *resolver보다 아래*에 있으면 누출per-request scope면 — 다른 user의 요청에는 다른 DataLoader 인스턴스가 붙으므로 교차 누출이 구조적으로 불가능하다.
이유 2 — 신선도
// ❌ 전역 DataLoader
const userLoader = new DataLoader(batchFn);
// 시나리오:
// 1. user A의 프로필이 cache에 들어감
// 2. user A가 *다른 요청에서* 자기 이름을 변경
// 3. 첫 요청의 cache는 *오래된 이름*을 유지 — 모든 후속 요청이 staleper-request면 — 요청이 끝나면 DataLoader 인스턴스가 GC되고, 다음 요청은 완전히 새 cache에서 시작한다. Stale 문제가 발생할 시간 자체가 없다.
이유 3 — 메모리
전역 cache는 언제 비울지 모른다. 한 요청에 100만 row를 fetch한 cache가 서버 메모리에 영구히 남는다. per-request는 요청 끝나면 자동으로 비워진다.
정리
What — 언제 cache를 꺼야 하나
DataLoader의 cache는 옵션으로 꺼진다.
new DataLoader(batchFn, { cache: false });batch만 쓰고 cache는 안 쓰는 경우 — 의외로 흔하다.
사례 1 — write-heavy 작업
// 카운터 증가, log 적재 등 — 매번 새로 호출되어야 함
const counterLoader = new DataLoader(batchIncrement, { cache: false });사례 2 — 값이 시간에 따라 바뀌는 자원
// 실시간 가격, 동시 접속자 수 — cache되면 stale
const priceLoader = new DataLoader(batchFetchPrices, { cache: false });사례 3 — batch만 필요한 외부 API
// AWS SDK batch APIs (DynamoDB BatchGetItem 등) — 같은 요청에 동일 key가
// 두 번 들어올 일이 거의 없는 자리What-if — 잘못 잡힌 scope 사례
사례 1 — 모듈 top-level에 선언
// loaders.ts
export const userLoader = new DataLoader(batchFn);
// ↑ 모듈 로딩 시 한 번만 평가됨 = 전역이걸 import해서 쓰면 모든 요청이 같은 인스턴스를 공유한다 → 사고.
사례 2 — singleton DI container
// ❌ NestJS, InversifyJS 등 — 컨테이너에 singleton scope로 등록
@Injectable({ scope: Scope.DEFAULT }) // = singleton
class UserLoader extends DataLoader<string, User> { ... }// ✅ request scope로 명시
@Injectable({ scope: Scope.REQUEST })
class UserLoader extends DataLoader<string, User> { ... }사례 3 — 공유는 좋다고 생각해서 cache를 외부로
// ❌ Redis로 빼서 "여러 요청이 공유하게" 하자
const userLoader = new DataLoader(batchFn, {
cacheMap: new RedisBackedMap(redis),
});이렇게 하면 — 위의 보안/신선도 문제가 그대로 돌아온다. DataLoader의 cache는 per-request가 본질이고, cross-request cache는 완전히 다른 layer(HTTP cache, normalized client cache, Redis response cache)에서 해결할 일이다. 이건 다음 챕터(05-cache-performance)에서 본다.
Insight — batch가 본체고 cache가 부산물인 이유
DataLoader README에 명시되어 있다 —
“In addition to batching, DataLoader also caches loads from the perspective of a load context. After
.load()is called once with a given key, the resulting value is cached to eliminate redundant loads.”
핵심은 “from the perspective of a load context”. cache의 본질은 *“같은 batch를 두 번 만들지 않게 하는 부산물”*이다. queue에 같은 key를 두 번 넣으면 — 어차피 batch 한 번에 같은 결과가 나온다. 그렇다면 두 번째 호출은 queue에 넣을 필요 없이 첫 호출의 Promise를 그대로 반환하면 된다. 이게 cache다.
즉, cache는 batch dedupe의 시간 차원 확장이다 —
- 같은 tick에 같은 key 두 번 → batch dedupe (queue에 한 번만)
- 같은 요청 안에서 다른 tick에 같은 key 두 번 → cache dedupe (Promise 재사용)
둘은 같은 메커니즘의 두 시간 스케일이다. 그래서 cache scope가 요청을 넘어가면 — 원래 의도된 dedupe 단위를 벗어나 전혀 다른 문제(stale, 누출)로 바뀐다.
요약 + Mermaid
| 차원 | batch | cache |
|---|---|---|
| 무엇을 dedupe | 같은 tick의 동일 key | 같은 요청 안의 반복 key |
| 시간 스케일 | 1 tick | 1 request |
| scope | per-loader-instance | per-loader-instance |
| 끄기 가능 | (불가) | {cache: false} 옵션 |
| 무효화 | (자동) | .clear() · .prime() |
한 줄 결론 — batch와 cache는 같은 메커니즘의 두 시간 스케일이고, 요청 경계 안에서만 안전하다. 그 경계를 넘는 진짜 cache 레이어는 다른 챕터에서 다룬다. 다음 문서(04)는 batch가 일어나는 정확한 시점 — event loop tick의 이야기.