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 u1post의 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는 *오래된 이름*을 유지 — 모든 후속 요청이 stale

per-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

차원batchcache
무엇을 dedupe같은 tick의 동일 key같은 요청 안의 반복 key
시간 스케일1 tick1 request
scopeper-loader-instanceper-loader-instance
끄기 가능(불가){cache: false} 옵션
무효화(자동).clear() · .prime()

한 줄 결론 — batch와 cache는 같은 메커니즘의 두 시간 스케일이고, 요청 경계 안에서만 안전하다. 그 경계를 넘는 진짜 cache 레이어는 다른 챕터에서 다룬다. 다음 문서(04)는 batch가 일어나는 정확한 시점 — event loop tick의 이야기.