04 · Query Cache — 반복 호출의 흡수기

이 문서가 답하는 질문: TypeORM의 내장 쿼리 캐시는 어떻게 동작하는가? Redis와 어떻게 통합하는가? 무효화는 어떻게 하는가? 한 줄 답: cache: true같은 쿼리 + 같은 파라미터의 결과를 기본 1초간 보관한다 — 캐시 자체는 쉽지만 무효화모든 캐시의 진짜 문제다.”


Why — 왜 쿼리 캐시가 필요한가

relations로 N+1을 끄고, leftJoinAndSelect로 카르테시안을 통제했어도 — 같은 무거운 쿼리가 분당 1000번 발사되면 DB는 여전히 죽는다.

  • N+1한 요청 안에서의 round-trip 폭발이다.
  • 반복 호출여러 요청같은 결과반복적으로 요청하는 비용이다.

쿼리 캐시는 후자를 흡수한다 — 결과를 들고 있다가 같은 쿼리가 오면 DB를 안 친다.

핵심 주장:

  • TypeORM의 cache: true기본적으로 DB 내부 캐시 테이블(query-result-cache)을 사용한다.
  • Redis 등 외부 캐시 provider를 끼울 수 있다.
  • 캐시 자체는 단순하지만 무효화 전략모든 캐시 문제의 핵심이다.

How — 캐시의 정확한 동작

1. DataSource에서 캐시 활성화

const dataSource = new DataSource({
  type: 'postgres',
  // ...
  cache: true,  // ← 기본 cache provider 활성화
});

이것만 켜면 TypeORM이 내부적으로 query-result-cache 테이블을 만든다.

CREATE TABLE "query-result-cache" (
  id SERIAL PRIMARY KEY,
  identifier VARCHAR,
  time BIGINT,
  duration INTEGER,
  query VARCHAR,
  result TEXT
);

2. 쿼리별로 캐시 켜기

const users = await userRepo.find({
  where: { active: true },
  cache: true,             // ← 기본 1000ms
});
// 또는 명시적 millis
const users = await userRepo.find({
  where: { active: true },
  cache: 30000,            // ← 30초
});
// 또는 식별자 + millis (← 무효화에 필수)
const users = await userRepo.find({
  where: { active: true },
  cache: { id: 'active-users', milliseconds: 60000 },
});

3. QueryBuilder에서

const posts = await postRepo
  .createQueryBuilder('post')
  .leftJoinAndSelect('post.user', 'user')
  .where('post.published = :p', { p: true })
  .cache('published-posts', 60000)   // ← (id, ms)
  .getMany();

Mermaid — 캐시 hit/miss의 동선


What — Redis 캐시 통합

DataSource 설정

const dataSource = new DataSource({
  // ...
  cache: {
    type: 'redis',
    options: {
      host: 'localhost',
      port: 6379,
    },
    duration: 30000,           // ← 모든 쿼리의 기본 ms
    alwaysEnabled: false,      // ← true면 모든 find가 자동 캐시
    ignoreErrors: true,        // ← Redis 죽어도 DB로 fallback
  },
});

ioredis로 직접

import Redis from 'ioredis';
 
const dataSource = new DataSource({
  // ...
  cache: {
    type: 'ioredis',
    options: new Redis({ host: 'localhost', port: 6379 }),
    duration: 30000,
  },
});

alwaysEnabled: true의 함정

cache: {
  type: 'redis',
  alwaysEnabled: true,        // ← 모든 쿼리가 자동 캐시됨
  duration: 30000,
}

이건 편하지만 위험하다user별 권한 검사 쿼리까지 캐시되면 권한이 사라진 사람이 30초간 계속 들어온다. 캐시는 명시적으로 켜는 게 안전하다.

캐시 키의 정체

TypeORM이 자동으로 만드는 키는:

hash(normalized_sql + JSON.stringify(parameters))

같은 find({ where: { id: 1 } })이라도 id 값이 다르면 다른 키다. 명시적 id (cache: { id: 'x', milliseconds: 1000 })를 주면 그 id가 그대로 키가 된다.


무효화 전략 — 캐시의 진짜 문제

명시 id로 잡고 직접 제거

// 1. 쓸 때
await userRepo.find({
  cache: { id: 'active-users', milliseconds: 60000 },
});
 
// 2. update 후 명시적 제거
await userRepo.update(id, { active: false });
await dataSource.queryResultCache?.remove(['active-users']);

이게 가장 명확하다. 하지만 id를 사람이 관리해야 하므로 코드 곳곳에 흩어진다.

Subscriber로 자동화

@EventSubscriber()
export class UserCacheInvalidator implements EntitySubscriberInterface<User> {
  listenTo() { return User; }
 
  async afterInsert(event: InsertEvent<User>) {
    await event.connection.queryResultCache?.remove(['active-users', 'user-list']);
  }
  async afterUpdate(event: UpdateEvent<User>) {
    await event.connection.queryResultCache?.remove(['active-users', 'user-list']);
  }
  async afterRemove(event: RemoveEvent<User>) {
    await event.connection.queryResultCache?.remove(['active-users', 'user-list']);
  }
}

Subscriber엔티티 변경을 후크하여 관련 캐시 키를 자동으로 비운다.

TTL만 짧게 두기 (가장 흔한 실전)

cache: { id: 'feed', milliseconds: 5000 }   // 5초만 보관

복잡한 무효화 대신 5초 stale을 허용한다. *읽기 트래픽 폭증의 90%*를 흡수하면서 무효화 코드는 0줄이다.

비교 매트릭스 — 무효화 전략

전략코드 양stale 가능성복잡도권장 자리
짧은 TTL (5초)0줄5초까지낮음피드·랭킹·count
명시 id + 수동 remove많음0초 (정확)중간사용자 프로필
Subscriber 자동 invalidate중간0초 (정확)중간CRUD 단순 엔티티
alwaysEnabled: true0줄TTL까지낮음권장 안 함 (보안 위험)
캐시 안 씀0줄0초0권한·결제·인증

What-if — 잘못된 해석들

오해 1 — “캐시 켜면 빨라진다”

hit ratio충분히 높을 때만 빨라진다. 키가 user별로 다르면 hit ratio가 1% 미만이고 오히려 더 느려진다 (캐시 lookup 비용만 추가).

오해 2 — “무효화는 쓸 때마다 하면 된다”

write가 여러 곳에서 일어나는 엔티티는 모든 write 경로에 invalidate를 박아야 한다 — 하나라도 빠지면 영원히 stale. Subscriber한 자리에 모아야 한다.

오해 3 — “Redis면 무한 캐시 가능”

Redis 메모리는 유한하다. maxmemory-policy: allkeys-lru 같은 방출 정책을 안 정하면 OOM으로 Redis가 죽고 DB가 동시에 죽는다.

오해 4 — “TypeORM 캐시는 entity 캐시다”

아니다. 쿼리 결과 캐시다. 같은 user를 두 다른 쿼리가 가져오면 두 번 캐시된다. Hibernate L2 같은 entity-level cache가 아니다.


Insight — 한 단락 이야기

“캐시는 문제를 푸는 도구가 아니라 문제를 다른 자리로 옮기는 도구다”

Phil Karlton의 유명한 농담 — “컴퓨터 과학의 어려운 두 가지: 캐시 무효화와 이름 짓기”. TypeORM의 query cache는 캐시 자체는 쉽게 만들었다 — cache: true 한 줄이면 끝. 하지만 언제 어떻게 무효화할지어떤 ORM도 자동화할 수 없다 — 그건 비즈니스 의미의 문제이기 때문이다. 5초 TTL이라는 단순한 답이 Twitter 타임라인Reddit 피드실제 운영 전략인 이유 — 정확성보다 운영의 단순함대규모 서비스의 운명을 가른다. Subscriber로 자동 invalidate를 짜기 전에 *“5초 stale이 비즈니스적으로 허용되는가”*를 먼저 묻는 것이 옳다. cache: true시간을 사는 도구다 — 그 시간 동안 진실이 변하지 않는다비즈니스적 약속이 가능한 자리에서만 켜라.


요약 + Mermaid

핵심 키
cache: true의 정체쿼리 결과 캐시 (entity 캐시 아님)
기본 storeDB query-result-cache 테이블
외부 storeRedis / ioredis / custom provider
캐시 키hash(SQL + parameters) 또는 명시 id
무효화 전략짧은 TTL / Subscriber / 명시 remove
가장 흔한 실전5초 TTL + 무효화 없음
절대 캐시 안 되는 자리권한·결제·인증 쿼리

한 줄 결론cache반복 호출 비용을 흡수하지만 무효화진짜 어려움이다. 다음 문서(05)는 자식의 개수만 필요한 자리에서 전체 컬렉션을 안 가져오는 loadRelationCountAndMap을 본다.