Guider/Backend/BackendDevGuide0008
Backend#08

BackendDevGuide0008

성능 최적화와 캐싱 전략 완전 정복

 
 
BackendDevGuide Series · Episode 8
BackendDevGuide0008
성능 최적화와 캐싱 전략 완전 정복 A-Z
⚡ 느린 API를 10배 빠르게 만드는 모든 기술
⏱ 예상 학습: 3~4주 🔴 Redis 완전정복 ⚡ 캐싱 전략 마스터 📊 DB 쿼리 최적화
🚀
10x
응답속도 향상 가능
💾
90%
DB 부하 감소 가능
📉
70%
서버 비용 절감
🏢
100%
현업 필수 기술

📚 학습 목차 — 이 가이드에서 배울 내용

1 성능 최적화의 기초 — 왜 느린가?
2 캐싱 개념 완전 이해 — 편의점 냉장고 비유
3 Redis 완전 정복 — 설치부터 실전까지
4 NestJS + Redis 캐싱 실전 패턴
5 데이터베이스 쿼리 최적화 — 인덱스 완전 정복
6 N+1 문제 완전 해결 — ORM 최적화
7 HTTP 캐시 — Cache-Control 완전 정복
8 CDN + 정적 파일 최적화
9 Node.js 성능 튜닝 — 이벤트 루프 최적화
10 메시지 큐 — Bull/BullMQ로 비동기 처리
11 부하 테스트 — k6 + Artillery 성능 측정
12 면접 Q&A + 실전 체크리스트 + 로드맵

⚡ 1장. 성능 최적화의 기초 — 왜 API가 느린가?

병목을 찾아내는 사고방식 + 프로파일링 방법

🍕 편의점 냉장고 비유로 이해하는 캐싱

손님이 편의점에 올 때마다 직원이 창고에서 물건을 꺼내온다면? 시간도 오래 걸리고 직원도 지칩니다.
냉장고(캐시)에 자주 팔리는 음료를 미리 진열해두면 → 손님은 즉시 가져갈 수 있고 → 창고(데이터베이스)를 자주 열 필요가 없습니다.
이것이 바로 캐싱의 핵심 원리입니다!

🔍 API가 느린 5가지 원인

🗄️
느린 DB 쿼리
인덱스 없음, N+1 문제
🔄
반복 연산
같은 계산 반복, 메모이제이션 없음
🌐
외부 API 호출
매번 외부 서비스 호출
📦
대용량 응답
불필요한 데이터 전송
🧵
블로킹 코드
동기 처리, 이벤트루프 블록

🏗️ 성능 병목 찾기 — 프로파일링 도구

// 방법 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가지 데이터 타입 완전 정복

① String — 가장 기본
# 기본 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
② Hash — 객체 저장
# 사용자 정보 저장 (객체)
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
③ List — 큐/스택
# 최근 본 상품 (최대 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 + ZSet — 집합/랭킹
# 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가지 — 언제 어떤 걸 쓸까?

① Cache-Aside
읽기 위주 데이터
캐시 미스 시 DB 조회 후 저장
→ 캐시 확인
→ 없으면 DB 조회
→ 캐시에 저장
→ 응답 반환
✅ 가장 일반적, 캐시 오류에 강함
② Write-Through
쓰기와 동시에 캐시 갱신
항상 최신 데이터 유지
→ DB에 저장
→ 동시에 캐시 갱신
→ 응답 반환
✅ 데이터 일관성 높음
③ Write-Behind
캐시에만 먼저 쓰고
나중에 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 전략 정리

🖼️ 정적 파일 (이미지, CSS, JS)
Cache-Control: public, max-age=31536000, immutable
📰 뉴스/블로그 목록 API
Cache-Control: public, max-age=60, stale-while-revalidate=300
👤 로그인 사용자 데이터
Cache-Control: private, max-age=300
💳 결제/민감 정보
Cache-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 + 실전 체크리스트 + 로드맵

현업 개발자가 묻는 진짜 질문들과 완벽한 대답

💬 자주 나오는 면접 질문 & 모범 답변

Q1. Redis와 DB의 차이점은? 언제 캐시를 써야 하나요?

핵심 차이: DB는 디스크 기반(영구 저장, 느림), Redis는 메모리 기반(임시 저장, 빠름). DB 응답 5~50ms, Redis 응답 0.1ms 이하.

캐시 사용 적합한 경우:
① 자주 읽히고 잘 변하지 않는 데이터 (상품 목록, 카테고리)
② 계산 비용이 높은 결과 (통계, 집계 데이터)
③ 외부 API 응답 (날씨, 환율 등)

캐시 사용 부적합한 경우:
① 실시간성이 중요한 데이터 (재고 수량, 잔액)
② 민감한 개인정보
③ 쓰기가 읽기보다 많은 데이터

Q2. N+1 문제가 뭔가요? 어떻게 해결하나요?

N+1 문제: 목록 조회 1번 + 각 항목의 연관 데이터 조회 N번 = N+1번 DB 쿼리 발생. 100개 게시글 조회 시 작성자 정보를 각각 조회하면 총 101번 쿼리.

해결책:
Eager Loading — 처음부터 JOIN으로 한 번에 (TypeORM relations, Prisma include)
DataLoader — GraphQL에서 배치 처리로 쿼리 합치기
QueryBuilder — select, leftJoinAndSelect로 정밀 제어

확인 방법: TypeORM logging: true 옵션으로 실제 쿼리 개수 확인

Q3. 인덱스를 무조건 많이 만들면 좋은가요?

No! 인덱스는 읽기를 빠르게 하지만 쓰기(INSERT/UPDATE/DELETE)를 느리게 합니다.

인덱스의 단점:
① 쓰기 시 인덱스 자료구조도 함께 갱신 → 성능 저하
② 추가 디스크 공간 필요
③ 잘못된 인덱스는 쿼리 플래너를 혼란시킬 수 있음

인덱스를 만들 컬럼: WHERE 조건에 자주 등장, JOIN ON 컬럼, ORDER BY 컬럼, 카디널리티(고유값 비율)가 높은 컬럼

Q4. Cache-Control: no-cache와 no-store의 차이는?

no-cache: 캐시 저장은 하지만, 사용 전에 서버에 재검증 요청. 변경됐으면 새 데이터, 안 변했으면 304 Not Modified로 캐시 사용.
no-store: 캐시 자체를 금지. 매번 서버에서 새로 받아옴. 결제, 민감정보에 사용.

요약: no-cache는 "저장하되 확인하고 써라", no-store는 "저장 자체를 하지 마라"

Q5. 메시지 큐를 왜 써야 하나요? 그냥 async/await 쓰면 안 되나요?

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주)

📅 1주차
Redis 설치 및 기본 명령어
Cache-Aside 패턴
NestJS 캐시 모듈 연동
HTTP Cache-Control 설정
📅 2주차
DB 인덱스 설계
EXPLAIN 분석
N+1 문제 해결
쿼리 최적화 실습
📅 3주차
BullMQ 큐 구현
Worker Thread 활용
Stream 처리
k6 부하 테스트
📅 4주차
Redis 세션/블랙리스트
분산 락 구현
Pub/Sub 실시간 알림
종합 성능 측정
🚀
BackendDevGuide0008 완료!
Redis부터 DB 최적화까지 — 이제 느린 API를 10배 빠르게 만들 수 있습니다!
다음: BackendDevGuide0009 - 실시간 통신과 WebSocket 완전 정복 A-Z
🔴 Redis 완전정복 📊 DB 인덱스 최적화 📬 BullMQ 큐 ⚡ 부하 테스트
반응형