📚 학습 목차 — 이 가이드에서 배울 내용
⚡ 1장. 성능 최적화의 기초 — 왜 API가 느린가?
병목을 찾아내는 사고방식 + 프로파일링 방법
🍕 편의점 냉장고 비유로 이해하는 캐싱
손님이 편의점에 올 때마다 직원이 창고에서 물건을 꺼내온다면? 시간도 오래 걸리고 직원도 지칩니다.
냉장고(캐시)에 자주 팔리는 음료를 미리 진열해두면 → 손님은 즉시 가져갈 수 있고 → 창고(데이터베이스)를 자주 열 필요가 없습니다.
이것이 바로 캐싱의 핵심 원리입니다!
🔍 API가 느린 5가지 원인
🏗️ 성능 병목 찾기 — 프로파일링 도구
// 방법 1: console.time으로 간단 측정
console.time('getUserList');
const users = await getUserList();
console.timeEnd('getUserList'); // getUserList: 342ms
// 방법 2: Date.now()로 정밀 측정
const start = Date.now();
const result = await heavyOperation();
const elapsed = Date.now() - start;
console.log(`실행 시간: ${elapsed}ms`);
// 방법 3: NestJS Interceptor로 전체 API 측정
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const start = Date.now();
const { method, url } = context.switchToHttp().getRequest();
return next.handle().pipe(
tap(() => {
const elapsed = Date.now() - start;
console.log(`[${method}] ${url} — ${elapsed}ms`);
// 1초 이상이면 경고
if (elapsed > 1000) {
console.warn(`⚠️ SLOW API: ${url} took ${elapsed}ms!`);
}
}),
);
}
}
📊 성능 측정 황금 법칙 — RAIL 모델
| 지표 | 목표 | 나쁨 | 의미 |
|---|---|---|---|
| API 응답시간 | < 200ms | > 1000ms | 사용자가 즉각 반응 느낌 |
| DB 쿼리 시간 | < 50ms | > 500ms | 인덱스 최적화 필요 기준 |
| 캐시 히트율 | > 80% | < 50% | 캐싱 전략 재검토 필요 |
| CPU 사용률 | < 70% | > 90% | 수평 확장 또는 코드 최적화 |
🔴 2장. Redis 완전 정복 — 설치부터 실전까지
세계에서 가장 빠른 인메모리 데이터 저장소
🏎️ Redis가 뭔가요? — 초고속 메모 장관 비유
회사에서 어떤 직원에게 같은 질문을 100번 한다고 상상해보세요.
매번 도서관(데이터베이스)에 가서 책을 찾아오면 시간이 오래 걸립니다.
하지만 그 직원이 메모장(Redis)에 자주 묻는 답변을 적어두면 → 즉시 대답할 수 있습니다!
Redis = 초고속 메모리 기반 저장소. RAM에 데이터를 저장하기 때문에 0.1ms 이하의 응답 속도!
📦 Redis 설치 및 기본 설정
# ---- Docker로 Redis 실행 (가장 빠른 방법) ----
docker run -d --name redis \
-p 6379:6379 \
-v redis_data:/data \
redis:7-alpine redis-server --appendonly yes --requirepass yourpassword
# ---- 또는 로컬 직접 설치 ----
# macOS
brew install redis
brew services start redis
# Ubuntu/Linux
sudo apt-get install redis-server
sudo systemctl enable redis-server
sudo systemctl start redis-server
# Redis CLI 접속 테스트
redis-cli ping # PONG 응답 오면 성공!
redis-cli -h localhost -p 6379 -a yourpassword
# NestJS Redis 패키지 설치
npm install @nestjs/cache-manager cache-manager cache-manager-redis-yet
npm install ioredis @types/ioredis
🛠️ Redis 5가지 데이터 타입 완전 정복
# 기본 SET/GET
SET user:1:name "홍길동"
GET user:1:name # "홍길동"
# TTL(만료시간) 설정
SETEX session:abc123 3600 "user_data"
TTL session:abc123 # 3599 (초)
# 숫자 증감 (조회수, 좋아요 카운트)
SET post:42:views 0
INCR post:42:views # 1
INCRBY post:42:views 100 # 101
# 사용자 정보 저장 (객체)
HSET user:1 name "홍길동" email "hong@test.com" age 30
HGET user:1 name # "홍길동"
HGETALL user:1 # 모든 필드
HMGET user:1 name email # 여러 필드
# 세션 데이터 저장에 적합
HSET session:token userId 42 role "admin"
EXPIRE session:token 86400
# 최근 본 상품 (최대 10개 유지)
LPUSH user:1:history "product:50"
LTRIM user:1:history 0 9 # 최근 10개만 유지
LRANGE user:1:history 0 -1 # 전체 조회
# 메시지 큐 (생산자)
RPUSH queue:email "send_welcome_123"
# 메시지 큐 (소비자)
BLPOP queue:email 0 # 블로킹 팝
# Set: 중복 없는 집합 (좋아요 한 사용자)
SADD post:42:likes 1 2 3 5
SISMEMBER post:42:likes 3 # 1 (있음)
SCARD post:42:likes # 4 (개수)
# ZSet: 점수 기반 정렬 (랭킹보드)
ZADD leaderboard 1500 "user:1"
ZADD leaderboard 2200 "user:2"
ZADD leaderboard 980 "user:3"
ZREVRANGE leaderboard 0 2 WITHSCORES # 상위 3명
📊 Redis vs 다른 캐시 솔루션 비교
| 구분 | Redis | Memcached | 인메모리 Map | MongoDB |
|---|---|---|---|---|
| 속도 | ⚡ 0.1ms | ⚡ 0.1ms | ⚡ 0.01ms | 🐢 5~50ms |
| 데이터 타입 | ✅ 5가지 | ⚠️ String만 | ✅ JS 객체 | ✅ Document |
| 영구 저장 | ✅ RDB/AOF | ❌ 메모리만 | ❌ 재시작 손실 | ✅ |
| 클러스터링 | ✅ Redis Cluster | ✅ | ❌ 단일 서버만 | ✅ |
| Pub/Sub | ✅ | ❌ | ❌ | ✅ Change Streams |
| 추천 용도 | 캐시/세션/큐 | 단순 캐시 | 단일 서버 캐시 | 영구 저장 |
🏗️ 3장. NestJS + Redis 캐싱 실전 패턴
Cache-Aside, Write-Through, Write-Behind 전략 완전 구현
⚙️ NestJS Redis 모듈 설정
// app.module.ts — Redis 캐시 모듈 설정
import { CacheModule } from '@nestjs/cache-manager';
import * as redisStore from 'cache-manager-redis-yet';
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
store: redisStore,
socket: {
host: process.env.REDIS_HOST || 'localhost',
port: +process.env.REDIS_PORT || 6379,
},
password: process.env.REDIS_PASSWORD,
ttl: 60 * 60, // 기본 TTL: 1시간
}),
}),
],
})
export class AppModule {}
// .env
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=yourpassword
🎯 캐싱 전략 3가지 — 언제 어떤 걸 쓸까?
캐시 미스 시 DB 조회 후 저장
→ 없으면 DB 조회
→ 캐시에 저장
→ 응답 반환
항상 최신 데이터 유지
→ 동시에 캐시 갱신
→ 응답 반환
나중에 DB 반영
→ 응답 즉시 반환
→ 비동기로 DB 저장
💻 Cache-Aside 패턴 완전 구현
// users.service.ts — Cache-Aside 패턴 구현
import { Injectable, Inject } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@Injectable()
export class UsersService {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
@InjectRepository(User) private userRepo: Repository<User>,
) {}
// ✅ Cache-Aside 패턴으로 사용자 조회
async findOne(id: number): Promise<User> {
const cacheKey = `user:${id}`;
// 1️⃣ 캐시 확인
const cached = await this.cacheManager.get<User>(cacheKey);
if (cached) {
console.log(`✅ 캐시 히트: ${cacheKey}`);
return cached;
}
// 2️⃣ 캐시 미스 → DB 조회
console.log(`❌ 캐시 미스: ${cacheKey} → DB 조회`);
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new NotFoundException(`User ${id} not found`);
// 3️⃣ 캐시 저장 (TTL: 5분)
await this.cacheManager.set(cacheKey, user, 300);
return user;
}
// ✅ 업데이트 시 캐시 무효화 (Cache Invalidation)
async update(id: number, dto: UpdateUserDto): Promise<User> {
const user = await this.userRepo.save({ id, ...dto });
// ⚠️ 수정 시 기존 캐시 삭제 (Stale 데이터 방지)
await this.cacheManager.del(`user:${id}`);
// 목록 캐시도 삭제
await this.cacheManager.del('users:list');
return user;
}
// ✅ 목록 조회 캐싱 (페이지네이션 포함)
async findAll(page = 1, limit = 10) {
const cacheKey = `users:list:page${page}:limit${limit}`;
const cached = await this.cacheManager.get(cacheKey);
if (cached) return cached;
const [data, total] = await this.userRepo.findAndCount({
skip: (page - 1) * limit,
take: limit,
order: { createdAt: 'DESC' },
});
const result = { data, total, page, totalPages: Math.ceil(total / limit) };
await this.cacheManager.set(cacheKey, result, 60); // 1분 캐시
return result;
}
}
// ✅ @CacheKey 데코레이터 방식 (단순한 경우)
import { CacheKey, CacheTTL } from '@nestjs/cache-manager';
@Controller('products')
export class ProductsController {
@Get()
@CacheKey('products_all') // 캐시 키
@CacheTTL(30 * 60) // TTL: 30분
findAll() {
return this.productsService.findAll();
}
}
⚠️ 캐시 무효화 전략 — 가장 어려운 부분!
컴퓨터 과학에서 가장 어려운 두 가지: "캐시 무효화"와 "이름 짓기".
• TTL 기반: 일정 시간 후 자동 만료 (단순, 데이터 일시적 불일치 가능)
• 이벤트 기반: 데이터 변경 시 즉시 캐시 삭제 (정확하지만 코드 복잡)
• 패턴 삭제: Redis KEYS 패턴으로 관련 캐시 일괄 삭제 (대규모 서비스에서 주의)
🗄️ 4장. 데이터베이스 쿼리 최적화 — 인덱스 완전 정복
느린 쿼리를 100배 빠르게 만드는 실전 기술
📚 인덱스란? — 도서관 색인 비유
100만 권의 책이 있는 도서관에서 "홍길동이 쓴 책"을 찾으려면?
색인(인덱스) 없이: 책 100만 권을 하나씩 확인 → 엄청난 시간 소요 (Full Table Scan)
색인(인덱스) 있으면: "저자 색인" → H섹션 → 홍길동 → 해당 위치 즉시 이동! (B-Tree 탐색)
인덱스 = 데이터 위치를 미리 정리해둔 정렬된 자료구조
🔍 EXPLAIN으로 쿼리 분석하기
-- 쿼리 실행 계획 확인 (MySQL/PostgreSQL)
EXPLAIN SELECT * FROM users WHERE email = 'hong@test.com';
-- 결과 (인덱스 없을 때 - 느림)
+------+-------------+-------+------+------+------+-------+
| type | table | rows | key | Extra |
+------+-------------+-------+------+------+------+-------+
| ALL | users | 100000| NULL | Using where | <-- Full Scan!
+------+-------------+-------+------+------+------+-------+
-- 인덱스 생성
CREATE INDEX idx_users_email ON users(email);
-- 결과 (인덱스 있을 때 - 빠름)
+------+------+-------+------------------+
| type | key | rows | Extra |
+------+------+-------+------------------+
| ref | idx_users_email | 1 | Using index | <-- 인덱스 사용!
+------+------+-------+------------------+
-- EXPLAIN ANALYZE (PostgreSQL) — 실제 실행 시간 포함
EXPLAIN ANALYZE SELECT * FROM posts
WHERE author_id = 42 AND published = true
ORDER BY created_at DESC LIMIT 10;
⚡ TypeORM + Prisma 인덱스 설정
// TypeORM - 엔티티에 인덱스 추가
import { Entity, Index, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
@Index(['authorId', 'published']) // 복합 인덱스
@Index(['createdAt']) // 단순 인덱스
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Index() // 단일 컬럼 인덱스
@Column({ unique: true })
slug: string;
@Column()
authorId: number;
@Column({ default: false })
published: boolean;
@Index()
@Column()
createdAt: Date;
}
// Prisma - schema.prisma에 인덱스 추가
model Post {
id Int @id @default(autoincrement())
slug String @unique // 유니크 인덱스
authorId Int
published Boolean @default(false)
createdAt DateTime @default(now())
@@index([authorId, published]) // 복합 인덱스
@@index([createdAt]) // 단순 인덱스
}
// ✅ 효율적인 쿼리 작성 — TypeORM QueryBuilder
async findPublishedByAuthor(authorId: number, page = 1, limit = 10) {
return this.postRepo
.createQueryBuilder('post')
.select(['post.id', 'post.title', 'post.createdAt']) // 필요한 컬럼만!
.where('post.authorId = :authorId', { authorId })
.andWhere('post.published = :published', { published: true })
.orderBy('post.createdAt', 'DESC')
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
}
⚠️ 인덱스 사용 시 주의사항
| 상황 | 인덱스 사용됨? | 이유 |
|---|---|---|
| WHERE email = 'a@b.com' | ✅ 사용 | 정확한 값 매칭 |
| WHERE email LIKE '%gmail%' | ❌ 미사용 | 앞에 % 있으면 Full Scan |
| WHERE UPPER(email) = 'A@B' | ❌ 미사용 | 함수 적용 시 인덱스 무력화 |
| WHERE created_at > '2026-01-01' | ✅ 사용 | 범위 검색 (Range Scan) |
| WHERE (authorId, published) = (1, true) | ✅ 사용 | 복합 인덱스 최좌측 원칙 |
| WHERE published = true (복합인덱스) | ❌ 미사용 | 복합 인덱스 첫 번째 컬럼 아님 |
🔗 5장. N+1 문제 완전 해결 — ORM 최적화
가장 흔한 성능 킬러를 완전히 제거하는 방법
🍕 N+1 문제란? — 배달 주문 비유
100명의 고객 목록을 가져온 뒤, 각 고객의 주문 내역을 하나씩 조회한다면?
→ 고객 목록 조회 1번 + 각 고객 주문 조회 100번 = 총 101번의 DB 쿼리!
→ N명의 고객이면 N+1번 쿼리 = "N+1 문제"
해결책: 처음부터 JOIN으로 한 번에 가져오는 "Eager Loading"!
// ❌ 나쁜 예: N+1 문제 발생
async getBadPosts() {
const posts = await this.postRepo.find(); // 쿼리 1번
for (const post of posts) {
post.author = await this.userRepo.findOne(post.authorId); // 쿼리 N번!
}
return posts;
// 총 쿼리: 1 + N번 = N+1 ← 최악!
}
// ✅ 좋은 예 1: TypeORM relations 사용
async getGoodPosts() {
return this.postRepo.find({
relations: { author: true }, // LEFT JOIN 자동 생성
select: {
id: true,
title: true,
author: { id: true, name: true }, // 필요한 필드만
},
});
// 총 쿼리: 1번 (JOIN으로 한 번에!) ← 최고!
}
// ✅ 좋은 예 2: QueryBuilder로 정밀 제어
async getPostsWithDetails() {
return this.postRepo
.createQueryBuilder('post')
.leftJoinAndSelect('post.author', 'author')
.leftJoinAndSelect('post.tags', 'tag')
.select([
'post.id', 'post.title', 'post.createdAt',
'author.id', 'author.name', // 이름만, 비밀번호 제외!
'tag.id', 'tag.name',
])
.where('post.published = :published', { published: true })
.orderBy('post.createdAt', 'DESC')
.getMany();
}
// ✅ Prisma - include로 관계 데이터 한 번에
async findAllPrisma() {
return this.prisma.post.findMany({
where: { published: true },
include: {
author: { select: { id: true, name: true } }, // 필요한 필드만
_count: { select: { comments: true } }, // 댓글 수 카운트
},
orderBy: { createdAt: 'desc' },
take: 10,
});
}
// ✅ DataLoader 패턴 — GraphQL에서 N+1 해결
import DataLoader from 'dataloader';
const userLoader = new DataLoader<number, User>(async (ids) => {
const users = await userRepo.findByIds([...ids]); // 한 번에 조회!
return ids.map(id => users.find(u => u.id === id));
});
📊 Prisma Pagination 패턴 — 커서 vs 오프셋
// ❌ 오프셋 페이징 — 대용량에서 느림
async offsetPagination(page: number, limit: number) {
return this.prisma.post.findMany({
skip: (page - 1) * limit, // 100만 번째 페이지: 999,990개를 건너뜀 → 느림!
take: limit,
});
}
// ✅ 커서 기반 페이징 — 대용량에 강함 (무한 스크롤)
async cursorPagination(cursor?: number, limit = 10) {
const posts = await this.prisma.post.findMany({
take: limit + 1, // 1개 더 가져와서 다음 페이지 여부 확인
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { id: 'desc' },
});
const hasNextPage = posts.length > limit;
const data = hasNextPage ? posts.slice(0, limit) : posts;
const nextCursor = hasNextPage ? data[data.length - 1].id : null;
return { data, nextCursor, hasNextPage };
}
🌐 6장. HTTP 캐시 완전 정복 — Cache-Control 마스터
브라우저·CDN·프록시의 캐시 동작 원리와 설정
🚦 HTTP 캐시 — 우편물 택배 비유
서울에서 부산까지 매번 택배를 보내는 대신, 부산 창고(CDN/브라우저 캐시)에 미리 물건을 보내두면?
→ 부산 사람은 서울까지 가지 않고 가까운 창고에서 즉시 받을 수 있습니다!
HTTP 캐시 = 클라이언트나 중간 서버(CDN)에 응답을 저장해두는 메커니즘
📋 Cache-Control 헤더 완전 분석
| 지시어 | 의미 | 사용 예시 |
|---|---|---|
| max-age=3600 | 3600초(1시간) 캐시 유효 | 정적 파일, API 응답 |
| no-cache | 매번 서버에 재검증 요청 | 자주 변하는 HTML |
| no-store | 캐시 완전 금지 | 민감한 개인정보, 결제 |
| public | CDN도 캐시 가능 | 공개 정적 파일 |
| private | 브라우저만 캐시 가능 | 로그인 사용자별 응답 |
| immutable | 절대 변경 안 됨 보장 | 해시 포함 파일명 |
| stale-while-revalidate=60 | 만료 후 60초간 오래된 데이터 제공하며 백그라운드 갱신 | 뉴스, 블로그 목록 |
💻 NestJS에서 HTTP 캐시 헤더 설정
// products.controller.ts — 상황별 캐시 전략
@Controller('products')
export class ProductsController {
// ✅ 공개 API — CDN 캐시 (1시간)
@Get()
@Header('Cache-Control', 'public, max-age=3600, stale-while-revalidate=600')
findAll() {
return this.productsService.findAll();
}
// ✅ 개인화 데이터 — 브라우저만 캐시 (5분)
@Get('my-wishlist')
@UseGuards(JwtAuthGuard)
@Header('Cache-Control', 'private, max-age=300')
getMyWishlist(@CurrentUser() user: User) {
return this.productsService.getWishlist(user.id);
}
// ✅ 결제/민감 정보 — 캐시 완전 금지
@Get('payment-history')
@Header('Cache-Control', 'no-store')
getPaymentHistory() {
return this.paymentsService.getHistory();
}
}
// ✅ ETag로 조건부 요청 처리 — 변경된 경우만 응답
import { createHash } from 'crypto';
@Get(':id')
async findOne(@Param('id') id: string, @Req() req, @Res() res) {
const product = await this.productsService.findOne(+id);
// 데이터 해시로 ETag 생성
const etag = `"${createHash('md5').update(JSON.stringify(product)).digest('hex')}"`;
// 클라이언트 ETag와 비교
if (req.headers['if-none-match'] === etag) {
return res.status(304).send(); // 변경 없음 → 데이터 전송 없이 캐시 사용!
}
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'public, max-age=60');
return res.json(product);
}
// ✅ Nginx에서 정적 파일 캐시 설정
// nginx.conf
location /static/ {
alias /app/public/;
expires 1y; # 1년 캐시
add_header Cache-Control "public, immutable"; # 변경 없음 보장
add_header Vary Accept-Encoding; # 압축 방식별 캐시
gzip_static on; # 미리 압축된 .gz 파일 서빙
}
🎯 상황별 Cache-Control 전략 정리
Cache-Control: public, max-age=31536000, immutableCache-Control: public, max-age=60, stale-while-revalidate=300Cache-Control: private, max-age=300Cache-Control: no-store⚙️ 7장. Node.js 성능 튜닝 — 이벤트 루프 최적화
싱글 스레드의 약점을 극복하는 실전 기법
🏪 이벤트 루프란? — 카페 직원 비유
카페 직원 1명(싱글 스레드)이 여러 손님을 응대합니다.
• 손님 A에게 커피 주문받음 → 커피 기계에 올려놓음 (비동기 I/O 시작)
• 기계가 커피 내리는 동안 → 손님 B, C 주문 받기 (다른 요청 처리)
• 커피 완성 신호 옴 → 손님 A에게 제공 (콜백 실행)
핵심: 기다리는 시간에 다른 일을 처리! 하지만 계산이 복잡한 일(CPU 집약)을 하면 다른 손님 모두 기다려야 함!
// ❌ 이벤트 루프 블로킹 — 절대 금지!
@Get('heavy')
badExample() {
// 동기 파일 읽기 — 이벤트 루프 블로킹!
const data = fs.readFileSync('./big-file.csv'); // ❌ 블로킹!
// CPU 집약 작업 — 이벤트 루프 블로킹!
let result = 0;
for (let i = 0; i < 1000000000; i++) result += i; // ❌ 수십초 블로킹!
return result;
}
// ✅ Worker Threads — CPU 집약 작업 분리
import { Worker, isMainThread, parentPort } from 'worker_threads';
// worker.js — 별도 스레드에서 실행
if (!isMainThread) {
const { data } = workerData;
let result = 0;
for (let i = 0; i < data; i++) result += i;
parentPort.postMessage(result);
}
// main.ts — 메인 스레드에서 워커 실행
function runHeavyTask(data: number): Promise<number> {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: { data } });
worker.on('message', resolve);
worker.on('error', reject);
});
}
@Get('compute')
async heavyCompute() {
const result = await runHeavyTask(1000000); // 이벤트루프 블로킹 없음!
return { result };
}
// ✅ Stream으로 대용량 파일 처리
@Get('download-report')
async downloadReport(@Res() res) {
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename=report.csv');
// 전체를 메모리에 올리지 않고 스트림으로 전송
const stream = fs.createReadStream('./reports/big-report.csv');
stream.pipe(res); // 메모리 절약!
}
// ✅ 병렬 처리 — Promise.all 활용
async getDashboardData(userId: number) {
// ❌ 순차 처리: 100ms + 150ms + 80ms = 330ms
// const user = await getUser(userId);
// const orders = await getOrders(userId);
// const notifications = await getNotifications(userId);
// ✅ 병렬 처리: max(100ms, 150ms, 80ms) = 150ms
const [user, orders, notifications] = await Promise.all([
getUser(userId), // 동시 실행!
getOrders(userId),
getNotifications(userId),
]);
return { user, orders, notifications };
}
🎯 Node.js 성능 최적화 체크리스트
- 모든 I/O는 비동기(async/await)
- Promise.all로 병렬 처리
- 대용량 처리는 Stream 사용
- CPU 집약 작업은 Worker Thread
- 응답 데이터 Gzip 압축
- readFileSync 등 동기 메서드
- 루프에서 await (순차 처리)
- 메인 스레드에서 무한 루프
- 대용량 데이터를 메모리에 전부 로드
- 동기 JSON.parse (엄청 큰 데이터)
📬 8장. 메시지 큐 — Bull/BullMQ로 비동기 처리
이메일 발송, 이미지 리사이즈, 결제 처리 등 무거운 작업을 백그라운드로
🏭 메시지 큐란? — 맥도날드 주문 시스템 비유
맥도날드에서 주문을 받고 즉시 음식이 나오지 않습니다.
→ 주문(Job)을 큐(Queue)에 쌓아두고 → 주방(Worker)이 순서대로 처리 → 완성되면 알림
메시지 큐 = API가 즉시 응답하되, 무거운 작업(이메일, 영상처리, 결제)은 백그라운드에서 처리
⚙️ BullMQ 설치 및 기본 설정
# 설치
npm install @nestjs/bull bull
npm install @types/bull -D
# 또는 최신 BullMQ 사용
npm install @nestjs/bullmq bullmq
// app.module.ts — Bull 모듈 등록
import { BullModule } from '@nestjs/bull';
@Module({
imports: [
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: +process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD,
},
}),
BullModule.registerQueue(
{ name: 'email' }, // 이메일 큐
{ name: 'image' }, // 이미지 처리 큐
{ name: 'notification' }, // 알림 큐
),
],
})
export class AppModule {}
// email.processor.ts — Job 처리기 (Worker)
import { Process, Processor, OnQueueFailed, OnQueueCompleted } from '@nestjs/bull';
import { Job } from 'bull';
@Processor('email')
export class EmailProcessor {
@Process('send-welcome')
async handleWelcomeEmail(job: Job) {
const { to, name } = job.data;
// 이메일 발송 로직
await this.mailerService.sendWelcome(to, name);
console.log(`✅ 환영 이메일 발송 완료: ${to}`);
}
@Process('send-password-reset')
async handlePasswordReset(job: Job) {
const { email, resetToken } = job.data;
await this.mailerService.sendPasswordReset(email, resetToken);
}
// 실패 시 자동 재시도 (3번)
@OnQueueFailed()
onFailed(job: Job, error: Error) {
console.error(`❌ Job ${job.id} 실패 (시도: ${job.attemptsMade}/3): ${error.message}`);
}
@OnQueueCompleted()
onCompleted(job: Job) {
console.log(`✅ Job ${job.id} 완료`);
}
}
// auth.service.ts — Job 추가 (Producer)
@Injectable()
export class AuthService {
constructor(
@InjectQueue('email') private emailQueue: Queue,
) {}
async register(dto: RegisterDto) {
const user = await this.usersService.create(dto);
// 이메일 발송을 큐에 추가 (즉시 응답 가능!)
await this.emailQueue.add(
'send-welcome',
{ to: user.email, name: user.name },
{
attempts: 3, // 실패 시 3번 재시도
backoff: { type: 'exponential', delay: 1000 }, // 지수 백오프
removeOnComplete: 100, // 완료된 job 100개만 유지
removeOnFail: 50, // 실패한 job 50개만 유지
}
);
return { message: '회원가입 완료! 이메일을 확인하세요.' }; // 즉시 응답!
}
}
// ✅ 정기 스케줄링 (Cron Job)
@Processor('email')
export class EmailProcessor {
@Cron('0 9 * * *') // 매일 오전 9시
async sendDailyDigest() {
const users = await this.usersService.getSubscribers();
for (const user of users) {
await this.emailQueue.add('daily-digest', { userId: user.id });
}
}
}
📊 메시지 큐 사용 케이스
| 작업 유형 | 큐 처리 이유 | 예시 |
|---|---|---|
| 이메일/SMS 발송 | 외부 서비스 호출 지연 | 환영 메일, 인증 코드 |
| 이미지/동영상 처리 | CPU 집약 작업 | 썸네일 생성, 인코딩 |
| 대량 데이터 처리 | 시간 소요 작업 | CSV 가져오기, 보고서 생성 |
| 결제 처리 | 재시도 필요, 멱등성 | PG사 결제 승인 |
| 푸시 알림 | 대량 동시 발송 | FCM, APNS 알림 |
📊 9장. 부하 테스트 — k6 + Artillery 성능 측정
실제 배포 전에 서버 한계를 파악하는 방법
🏋️ 부하 테스트란? — 다리 내하중 검사 비유
다리를 만들면 실제 개통 전에 얼마나 많은 차가 지나가도 버티는지 테스트합니다.
부하 테스트 = 가상 사용자 수천 명이 동시에 접속할 때 서버가 버티는지 확인
→ API 응답 시간, 에러율, 최대 처리량(TPS)을 측정하여 배포 전에 문제를 발견!
🔬 k6 부하 테스트 완전 구현
# k6 설치
brew install k6 # macOS
# 또는 Docker 사용
docker run --rm -i grafana/k6 run - < test.js
// load-test.js — 종합 부하 테스트 스크립트
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const errorRate = new Rate('errors');
const loginDuration = new Trend('login_duration');
export const options = {
stages: [
{ duration: '30s', target: 10 }, // 30초 동안 10명까지 증가
{ duration: '1m', target: 100 }, // 1분 동안 100명 유지 (일반 부하)
{ duration: '30s', target: 500 }, // 30초 동안 500명 (스파이크)
{ duration: '1m', target: 100 }, // 1분 동안 100명 (회복)
{ duration: '30s', target: 0 }, // 30초 동안 0명 (종료)
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95%의 요청이 500ms 이하
errors: ['rate<0.01'], // 에러율 1% 미만
http_req_failed: ['rate<0.05'], // 실패율 5% 미만
},
};
export default function () {
const baseUrl = 'http://localhost:3000';
// 1. 제품 목록 조회
const listRes = http.get(`${baseUrl}/api/products`);
check(listRes, {
'status 200': (r) => r.status === 200,
'응답 500ms 이하': (r) => r.timings.duration < 500,
});
errorRate.add(listRes.status !== 200);
sleep(1); // 1초 대기 (실제 사용자 패턴)
// 2. 로그인 API 테스트
const loginStart = Date.now();
const loginRes = http.post(
`${baseUrl}/api/auth/login`,
JSON.stringify({ email: 'test@test.com', password: 'password123' }),
{ headers: { 'Content-Type': 'application/json' } }
);
loginDuration.add(Date.now() - loginStart);
const token = JSON.parse(loginRes.body).accessToken;
// 3. 인증 필요한 API 테스트
http.get(`${baseUrl}/api/users/me`, {
headers: { 'Authorization': `Bearer ${token}` }
});
sleep(2);
}
# 실행 및 결과 확인
k6 run load-test.js
# 결과 예시:
# ✓ http_req_duration............: avg=87ms min=12ms p(90)=210ms p(95)=320ms
# ✓ http_req_failed...............: 0.00%
# ✓ errors........................: 0.00%
# ✓ iterations....................: 3,240/s
📈 성능 결과 해석 방법
| 지표 | 좋음 ✅ | 나쁨 ❌ | 해결책 |
|---|---|---|---|
| p(95) 응답시간 | < 500ms | > 2000ms | 캐싱, DB 인덱스, 쿼리 최적화 |
| 에러율 | < 1% | > 5% | Connection Pool, 타임아웃 설정 |
| TPS (초당 처리) | > 1000/s | < 100/s | 수평 확장, 캐싱, 비동기 처리 |
| CPU 사용률 | < 70% | > 90% | Worker Thread, 스케일 아웃 |
🔧 10장. Redis 고급 패턴 — 세션, 분산 락, Pub/Sub
Redis를 단순 캐시 이상으로 활용하는 실전 패턴
🔐 Redis 세션 관리 + JWT 블랙리스트
// auth.service.ts — JWT + Redis 세션 패턴
@Injectable()
export class AuthService {
constructor(
@Inject(CACHE_MANAGER) private cache: Cache,
private jwtService: JwtService,
) {}
async login(user: User) {
const payload = { sub: user.id, email: user.email, role: user.role };
const accessToken = this.jwtService.sign(payload, { expiresIn: '1h' });
const refreshToken = this.jwtService.sign({ sub: user.id }, { expiresIn: '7d' });
// Refresh Token을 Redis에 저장 (7일 만료)
await this.cache.set(
`refresh:${user.id}`,
refreshToken,
7 * 24 * 60 * 60 // 7일
);
return { accessToken, refreshToken };
}
// 로그아웃 — Access Token 블랙리스트에 추가
async logout(userId: number, accessToken: string) {
// Access Token 남은 유효 시간 계산
const decoded = this.jwtService.decode(accessToken) as any;
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
// 남은 시간 동안 블랙리스트에 등록
await this.cache.set(`blacklist:${accessToken}`, 1, ttl);
}
// Refresh Token 삭제
await this.cache.del(`refresh:${userId}`);
return { message: '로그아웃 완료' };
}
// JWT Guard에서 블랙리스트 확인
async isTokenBlacklisted(token: string): Promise<boolean> {
const blacklisted = await this.cache.get(`blacklist:${token}`);
return !!blacklisted;
}
}
// ✅ 분산 락 (Distributed Lock) — 동시 주문 처리 문제 해결
import Redlock from 'redlock';
@Injectable()
export class OrderService {
private redlock: Redlock;
constructor(private redis: Redis) {
this.redlock = new Redlock([redis], { retryCount: 3, retryDelay: 200 });
}
async processOrder(productId: number, quantity: number) {
const lockKey = `lock:product:${productId}`;
// 분산 락 획득 (5초 TTL)
const lock = await this.redlock.acquire([lockKey], 5000);
try {
// 재고 확인 및 차감 (동시에 여러 요청이 와도 안전!)
const product = await this.productRepo.findOne(productId);
if (product.stock < quantity) throw new Error('재고 부족');
await this.productRepo.update(productId, {
stock: product.stock - quantity
});
return await this.orderRepo.create({ productId, quantity });
} finally {
await lock.release(); // 반드시 락 해제!
}
}
}
// ✅ Redis Pub/Sub — 실시간 알림
@Injectable()
export class NotificationService {
constructor(private redisPublisher: Redis, private redisSubscriber: Redis) {
// 채널 구독
this.redisSubscriber.subscribe('order-updates');
this.redisSubscriber.on('message', (channel, message) => {
console.log(`📨 ${channel}: ${message}`);
this.broadcastToWebSocket(message);
});
}
async publishOrderUpdate(orderId: number, status: string) {
await this.redisPublisher.publish(
'order-updates',
JSON.stringify({ orderId, status, timestamp: Date.now() })
);
}
}
💡 Redis 실전 패턴 요약
| 패턴 | Redis 타입 | 사용 사례 |
|---|---|---|
| API 응답 캐시 | String (JSON) | 상품 목록, 뉴스 피드 |
| 세션/토큰 관리 | Hash + TTL | 로그인 세션, Refresh Token |
| Rate Limiting | String INCR + TTL | API 호출 제한 (초당 10회) |
| 실시간 랭킹 | Sorted Set | 게임 리더보드, 인기 검색어 |
| 분산 락 | String + SETNX | 재고 차감, 중복 결제 방지 |
| 메시지 브로커 | Pub/Sub / Stream | 실시간 채팅, 알림 |
🎯 11장. 면접 Q&A + 실전 체크리스트 + 로드맵
현업 개발자가 묻는 진짜 질문들과 완벽한 대답
💬 자주 나오는 면접 질문 & 모범 답변
핵심 차이: DB는 디스크 기반(영구 저장, 느림), Redis는 메모리 기반(임시 저장, 빠름). DB 응답 5~50ms, Redis 응답 0.1ms 이하.
캐시 사용 적합한 경우:
① 자주 읽히고 잘 변하지 않는 데이터 (상품 목록, 카테고리)
② 계산 비용이 높은 결과 (통계, 집계 데이터)
③ 외부 API 응답 (날씨, 환율 등)
캐시 사용 부적합한 경우:
① 실시간성이 중요한 데이터 (재고 수량, 잔액)
② 민감한 개인정보
③ 쓰기가 읽기보다 많은 데이터
N+1 문제: 목록 조회 1번 + 각 항목의 연관 데이터 조회 N번 = N+1번 DB 쿼리 발생. 100개 게시글 조회 시 작성자 정보를 각각 조회하면 총 101번 쿼리.
해결책:
① Eager Loading — 처음부터 JOIN으로 한 번에 (TypeORM relations, Prisma include)
② DataLoader — GraphQL에서 배치 처리로 쿼리 합치기
③ QueryBuilder — select, leftJoinAndSelect로 정밀 제어
확인 방법: TypeORM logging: true 옵션으로 실제 쿼리 개수 확인
No! 인덱스는 읽기를 빠르게 하지만 쓰기(INSERT/UPDATE/DELETE)를 느리게 합니다.
인덱스의 단점:
① 쓰기 시 인덱스 자료구조도 함께 갱신 → 성능 저하
② 추가 디스크 공간 필요
③ 잘못된 인덱스는 쿼리 플래너를 혼란시킬 수 있음
인덱스를 만들 컬럼: WHERE 조건에 자주 등장, JOIN ON 컬럼, ORDER BY 컬럼, 카디널리티(고유값 비율)가 높은 컬럼
no-cache: 캐시 저장은 하지만, 사용 전에 서버에 재검증 요청. 변경됐으면 새 데이터, 안 변했으면 304 Not Modified로 캐시 사용.
no-store: 캐시 자체를 금지. 매번 서버에서 새로 받아옴. 결제, 민감정보에 사용.
요약: no-cache는 "저장하되 확인하고 써라", no-store는 "저장 자체를 하지 마라"
async/await의 한계:
① 서버 재시작 시 처리 중인 작업 사라짐 (영속성 없음)
② 실패 시 자동 재시도 없음
③ 많은 작업이 쌓이면 메모리 부족
④ 다른 서버(마이크로서비스)에 작업 위임 불가
메시지 큐의 장점: 영속성, 재시도, 우선순위, 처리량 조절, 분산 처리, 모니터링 대시보드
✅ 레벨별 성능 최적화 체크리스트
- 캐시와 DB 차이 설명 가능
- Redis 설치 및 기본 명령어
- EXPLAIN으로 쿼리 분석
- 단순 인덱스 생성
- Promise.all로 병렬 처리
- Cache-Control 헤더 이해
- Cache-Aside 패턴 구현
- N+1 문제 탐지 및 해결
- 복합 인덱스 설계
- BullMQ Job Queue 구현
- k6 부하 테스트 실행
- Redis TTL 전략 설계
- 분산 락(Redlock) 구현
- Redis Cluster 설정
- Write-Behind 패턴
- CDN + Edge 캐싱 설계
- Worker Thread 활용
- DB Connection Pool 튜닝
🗺️ 성능 최적화 학습 로드맵 (4주)
Cache-Aside 패턴
NestJS 캐시 모듈 연동
HTTP Cache-Control 설정
EXPLAIN 분석
N+1 문제 해결
쿼리 최적화 실습
Worker Thread 활용
Stream 처리
k6 부하 테스트
분산 락 구현
Pub/Sub 실시간 알림
종합 성능 측정