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: true | 0줄 | 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 캐시 아님) |
| 기본 store | DB query-result-cache 테이블 |
| 외부 store | Redis / ioredis / custom provider |
| 캐시 키 | hash(SQL + parameters) 또는 명시 id |
| 무효화 전략 | 짧은 TTL / Subscriber / 명시 remove |
| 가장 흔한 실전 | 5초 TTL + 무효화 없음 |
| 절대 캐시 안 되는 자리 | 권한·결제·인증 쿼리 |
한 줄 결론 — cache는 반복 호출 비용을 흡수하지만 무효화가 진짜 어려움이다. 다음 문서(05)는 자식의 개수만 필요한 자리에서 전체 컬렉션을 안 가져오는 loadRelationCountAndMap을 본다.