본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 10. 31. · 75 Views
NestJS 성능 최적화 완벽 가이드
NestJS 애플리케이션의 성능을 극대적으로 끌어올리는 실전 최적화 기법들을 배워봅니다. 캐싱, 데이터베이스 쿼리 최적화, 비동기 처리부터 메모리 관리까지 실무에서 바로 적용할 수 있는 검증된 방법들을 다룹니다.
목차
1. 캐싱 전략
시작하며
여러분이 NestJS로 API 서버를 운영하다가 갑자기 트래픽이 급증했을 때, 데이터베이스가 과부하로 응답이 느려지는 경험을 해본 적 있나요? 같은 데이터를 조회하는 요청이 반복적으로 들어올 때마다 데이터베이스에 접근하면서 서버 리소스가 낭비되는 상황 말입니다.
이런 문제는 실제 개발 현장에서 가장 자주 발생하는 성능 병목 현상입니다. 특히 변경이 잦지 않은 데이터를 반복적으로 조회할 때, 매번 데이터베이스에 쿼리를 날리면 응답 시간이 길어지고 서버 부하가 증가합니다.
바로 이럴 때 필요한 것이 Redis를 활용한 캐싱 전략입니다. 한 번 조회한 데이터를 메모리에 저장해두고 재사용하면 응답 속도를 10배 이상 빠르게 만들 수 있습니다.
개요
간단히 말해서, 캐싱은 자주 사용되는 데이터를 빠른 저장소(메모리)에 임시 보관하여 반복적인 조회를 빠르게 처리하는 기법입니다. NestJS에서는 Cache Manager와 Redis를 조합하여 강력한 캐싱 시스템을 구축할 수 있습니다.
예를 들어, 사용자 프로필 정보나 상품 목록 같은 자주 조회되지만 자주 변경되지 않는 데이터를 캐싱하면 데이터베이스 부하를 크게 줄일 수 있습니다. 기존에는 매 요청마다 데이터베이스에 쿼리를 날려야 했다면, 이제는 첫 요청 후 결과를 Redis에 저장하고 이후 요청은 Redis에서 바로 가져올 수 있습니다.
캐싱의 핵심 특징은 첫째, TTL(Time To Live)을 설정하여 데이터 신선도를 관리할 수 있고, 둘째, 분산 환경에서도 여러 서버가 같은 캐시를 공유할 수 있으며, 셋째, 메모리 기반이라 디스크 I/O보다 수백 배 빠릅니다. 이러한 특징들이 API 응답 시간을 밀리초 단위로 줄여주는 핵심 요소입니다.
코드 예제
// user.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class UserService {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private userRepository: UserRepository,
) {}
async getUserById(id: string) {
// 캐시에서 먼저 조회
const cacheKey = `user:${id}`;
const cached = await this.cacheManager.get(cacheKey);
if (cached) {
return cached; // 캐시 히트!
}
// 캐시 미스 - DB 조회
const user = await this.userRepository.findById(id);
// 5분간 캐시에 저장
await this.cacheManager.set(cacheKey, user, 300000);
return user;
}
}
설명
이것이 하는 일: 이 코드는 사용자 정보를 조회할 때 먼저 Redis 캐시를 확인하고, 없을 때만 데이터베이스에 접근하는 캐시-어사이드(Cache-Aside) 패턴을 구현합니다. 첫 번째로, getUserById 메서드가 호출되면 cacheManager.get()을 통해 Redis에 해당 사용자 데이터가 캐시되어 있는지 확인합니다.
캐시 키는 user:${id} 형식으로 만들어 각 사용자별로 독립적인 캐시를 유지합니다. 만약 캐시에 데이터가 있다면(캐시 히트), 데이터베이스에 접근하지 않고 즉시 반환하여 응답 시간을 밀리초 단위로 단축합니다.
그 다음으로, 캐시에 데이터가 없으면(캐시 미스) userRepository.findById()를 통해 실제 데이터베이스 쿼리를 실행합니다. 이때 데이터베이스 I/O가 발생하지만, 이는 캐시가 없을 때만 일어나므로 전체적인 부하는 크게 감소합니다.
마지막으로, 데이터베이스에서 가져온 사용자 정보를 cacheManager.set()을 통해 Redis에 저장합니다. TTL을 300000ms(5분)으로 설정하여 5분 후에는 자동으로 캐시가 만료되도록 합니다.
이렇게 하면 데이터가 변경되더라도 최대 5분 안에는 새로운 데이터가 반영됩니다. 여러분이 이 코드를 사용하면 동일한 사용자 정보를 조회하는 반복적인 요청에서 평균 응답 시간을 100ms에서 5ms로 줄일 수 있습니다.
또한 데이터베이스 커넥션 사용량이 감소하여 더 많은 동시 사용자를 처리할 수 있고, 데이터베이스 서버의 CPU와 메모리 사용량도 크게 절감됩니다.
실전 팁
💡 TTL은 데이터의 변경 빈도에 따라 조정하세요. 거의 변경되지 않는 데이터는 1시간 이상, 자주 변경되는 데이터는 1-5분 정도가 적당합니다.
💡 캐시 무효화 전략을 반드시 구현하세요. 데이터가 수정되거나 삭제될 때 관련 캐시를 즉시 삭제하지 않으면 사용자에게 오래된 데이터가 제공될 수 있습니다.
💡 캐시 키 네이밍 규칙을 정하세요. entity:id 또는 entity:condition:value 형식으로 일관성 있게 작성하면 나중에 특정 패턴의 캐시를 한 번에 삭제할 수 있습니다.
💡 캐시 히트율을 모니터링하세요. 히트율이 70% 이하라면 캐싱 전략을 재검토해야 합니다. Redis에 INFO stats 명령어로 히트율을 확인할 수 있습니다.
💡 대용량 객체는 캐싱하지 마세요. 100KB 이상의 큰 데이터는 네트워크 전송 비용이 커서 오히려 성능이 저하될 수 있습니다. 필요한 필드만 선택하여 캐싱하세요.
2. 데이터베이스 쿼리 최적화
시작하며
여러분이 게시글 목록을 조회하는 API를 만들었는데, 100개의 게시글을 가져오는데 2초 이상 걸리는 상황을 겪어본 적 있나요? 각 게시글마다 작성자 정보를 별도로 조회하면서 데이터베이스에 101번의 쿼리가 날아가는 경우입니다.
이런 문제는 N+1 쿼리 문제라고 불리며, ORM을 사용하는 개발자들이 가장 자주 만나는 성능 함정입니다. 부모 엔티티를 조회한 후 각 자식 엔티티를 개별적으로 조회하면서 네트워크 왕복 시간과 데이터베이스 부하가 기하급수적으로 증가합니다.
바로 이럴 때 필요한 것이 Eager Loading과 Query Builder를 활용한 쿼리 최적화입니다. 한 번의 JOIN 쿼리로 필요한 모든 데이터를 가져와 응답 시간을 수십 배 개선할 수 있습니다.
개요
간단히 말해서, 쿼리 최적화는 데이터베이스에 보내는 쿼리의 개수와 복잡도를 줄여 전체 처리 시간을 단축하는 기법입니다. TypeORM이나 Prisma 같은 ORM을 사용할 때 관계 데이터를 로드하는 방법에 따라 성능이 크게 달라집니다.
예를 들어, 게시글과 댓글, 작성자 정보를 함께 조회할 때 Lazy Loading을 사용하면 수백 개의 쿼리가 발생하지만, Eager Loading을 사용하면 단 1-2개의 쿼리로 해결됩니다. 기존에는 엔티티를 조회한 후 관련 데이터를 순회하며 개별 조회했다면, 이제는 relations 옵션이나 leftJoinAndSelect를 사용하여 필요한 모든 데이터를 한 번에 가져올 수 있습니다.
쿼리 최적화의 핵심 특징은 첫째, JOIN을 활용하여 여러 테이블의 데이터를 한 번에 조회할 수 있고, 둘째, 인덱스를 적절히 활용하면 검색 속도가 수백 배 빨라지며, 셋째, 필요한 필드만 선택(SELECT)하여 네트워크 전송량을 줄일 수 있습니다. 이러한 최적화가 API 응답 시간을 초 단위에서 밀리초 단위로 개선합니다.
코드 예제
// post.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Post } from './entities/post.entity';
@Injectable()
export class PostService {
constructor(
@InjectRepository(Post)
private postRepository: Repository<Post>,
) {}
// ❌ 나쁜 예: N+1 문제 발생
async getPostsSlow() {
const posts = await this.postRepository.find();
// 각 게시글마다 author를 개별 조회 (N개의 추가 쿼리!)
return posts;
}
// ✅ 좋은 예: JOIN으로 한 번에 조회
async getPostsFast() {
return this.postRepository.find({
relations: ['author', 'comments', 'comments.user'],
select: {
id: true,
title: true,
content: true,
author: { id: true, name: true, avatar: true },
},
order: { createdAt: 'DESC' },
take: 20, // 페이지네이션
});
}
// 🚀 최고의 예: Query Builder 사용
async getPostsOptimized(page: number = 1) {
return this.postRepository
.createQueryBuilder('post')
.leftJoinAndSelect('post.author', 'author')
.leftJoinAndSelect('post.comments', 'comments')
.select([
'post.id',
'post.title',
'post.createdAt',
'author.id',
'author.name',
'comments.id',
])
.where('post.published = :published', { published: true })
.orderBy('post.createdAt', 'DESC')
.skip((page - 1) * 20)
.take(20)
.getMany();
}
}
설명
이것이 하는 일: 이 코드는 게시글과 관련 데이터(작성자, 댓글)를 조회할 때 발생하는 N+1 쿼리 문제를 세 가지 단계로 개선하는 방법을 보여줍니다. 첫 번째 getPostsSlow 메서드는 잘못된 패턴의 예시입니다.
find()만 사용하면 게시글만 조회되고, 이후 코드에서 post.author에 접근할 때마다 TypeORM이 자동으로 추가 쿼리를 실행합니다. 100개 게시글이라면 1(게시글 조회) + 100(각 작성자 조회) = 101개의 쿼리가 발생하여 네트워크 왕복 시간만으로도 성능이 크게 저하됩니다.
그 다음으로, getPostsFast 메서드는 relations 옵션을 사용하여 관련 엔티티를 Eager Loading으로 한 번에 가져옵니다. TypeORM이 자동으로 적절한 JOIN 쿼리를 생성하여 2-3개의 쿼리로 모든 데이터를 조회합니다.
select 옵션으로 필요한 필드만 지정하여 불필요한 대용량 컬럼(예: content 본문)의 전송을 피할 수 있고, take로 페이지네이션을 적용하여 한 번에 가져오는 데이터양을 제한합니다. 마지막으로, getPostsOptimized 메서드는 Query Builder를 사용하여 가장 세밀한 제어를 제공합니다.
leftJoinAndSelect로 명시적으로 JOIN을 지정하고, select로 정확히 필요한 필드만 나열하며, where 조건으로 게시 상태 필터링, skip과 take로 오프셋 기반 페이지네이션을 구현합니다. 이 방식은 생성되는 SQL을 완전히 제어할 수 있어 복잡한 쿼리에서 최고의 성능을 발휘합니다.
여러분이 이 최적화를 적용하면 100개 게시글 조회 시간을 2초에서 50ms 이하로 줄일 수 있습니다. 데이터베이스 부하가 40배 이상 감소하고, 네트워크 전송량도 절반 이하로 줄어들며, 서버가 동시에 처리할 수 있는 요청 수가 크게 증가합니다.
특히 모바일 환경에서 네트워크 지연이 큰 경우 사용자 체감 속도가 극적으로 개선됩니다.
실전 팁
💡 개발 환경에서 TypeORM의 logging: true 옵션을 켜서 실제로 어떤 SQL이 실행되는지 항상 확인하세요. N+1 문제를 눈으로 직접 볼 수 있습니다.
💡 WHERE 절에 사용되는 컬럼에는 반드시 인덱스를 추가하세요. @Index() 데코레이터나 마이그레이션으로 인덱스를 생성하면 검색 성능이 수백 배 향상됩니다.
💡 대용량 텍스트 필드(content, description)는 목록 조회에서 제외하세요. select로 명시적으로 필요한 필드만 가져오면 네트워크 전송량을 크게 줄일 수 있습니다.
💡 relations보다 Query Builder가 더 복잡해 보이지만, 복잡한 JOIN이나 서브쿼리가 필요한 경우 Query Builder가 훨씬 효율적입니다. 성능이 중요한 쿼리에는 Query Builder를 사용하세요.
💡 데이터베이스 쿼리 성능을 모니터링하세요. EXPLAIN 명령어로 쿼리 실행 계획을 분석하면 어느 부분이 느린지 정확히 파악할 수 있습니다.
3. 비동기 처리와 Queue
시작하며
여러분이 이메일 발송 기능을 만들었는데, 사용자가 "가입 완료" 버튼을 누른 후 이메일이 전송될 때까지 5초 동안 화면이 멈춰 있는 경험을 해본 적 있나요? 이미지 리사이징, 파일 변환, 외부 API 호출 같은 시간이 오래 걸리는 작업이 사용자 요청을 블로킹하는 상황입니다.
이런 문제는 동기적인 처리 방식에서 필연적으로 발생합니다. 모든 작업을 요청 처리 과정에서 순차적으로 실행하면 사용자는 불필요하게 긴 로딩 시간을 기다려야 하고, 서버는 그동안 다른 요청을 처리하지 못해 전체 처리량(throughput)이 감소합니다.
바로 이럴 때 필요한 것이 Bull Queue를 이용한 비동기 백그라운드 작업 처리입니다. 시간이 오래 걸리는 작업을 큐에 넣고 즉시 응답한 후, 별도의 워커 프로세스가 백그라운드에서 처리하도록 하면 사용자 경험과 서버 성능이 동시에 개선됩니다.
개요
간단히 말해서, Queue 시스템은 시간이 오래 걸리는 작업을 메시지 큐에 넣어두고 나중에 별도의 워커가 처리하도록 하는 비동기 작업 패턴입니다. NestJS에서는 Bull(Redis 기반 큐 라이브러리)과 @nestjs/bull 모듈을 조합하여 강력한 백그라운드 작업 시스템을 구축할 수 있습니다.
예를 들어, 사용자가 프로필 사진을 업로드하면 즉시 "업로드 완료" 응답을 보내고, 실제 이미지 리사이징과 썸네일 생성은 백그라운드에서 처리할 수 있습니다. 기존에는 이메일 발송이 완료될 때까지 HTTP 요청이 대기해야 했다면, 이제는 이메일 발송 작업을 큐에 추가하고 즉시 응답하여 사용자는 0.1초 만에 다음 화면으로 넘어갈 수 있습니다.
Queue의 핵심 특징은 첫째, 작업 실패 시 자동 재시도(retry)를 설정할 수 있어 안정성이 높고, 둘째, 작업 우선순위를 지정하여 중요한 작업을 먼저 처리할 수 있으며, 셋째, 워커 프로세스를 여러 개 실행하여 병렬 처리로 처리량을 극대화할 수 있습니다. 이러한 특징들이 서버의 응답성과 처리 능력을 동시에 향상시킵니다.
코드 예제
// email.processor.ts
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
import { Injectable } from '@nestjs/common';
@Processor('email')
export class EmailProcessor {
@Process('welcome')
async sendWelcomeEmail(job: Job) {
const { email, name } = job.data;
console.log(`[Worker] Sending welcome email to ${email}`);
// 실제 이메일 발송 (3-5초 소요)
await this.emailService.send({
to: email,
subject: 'Welcome!',
template: 'welcome',
data: { name },
});
console.log(`[Worker] Email sent successfully to ${email}`);
return { success: true };
}
}
// user.service.ts
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
@Injectable()
export class UserService {
constructor(@InjectQueue('email') private emailQueue: Queue) {}
async createUser(dto: CreateUserDto) {
const user = await this.userRepository.save(dto);
// 큐에 작업 추가 (즉시 반환!)
await this.emailQueue.add('welcome', {
email: user.email,
name: user.name,
}, {
attempts: 3, // 실패 시 3번까지 재시도
backoff: 5000, // 재시도 간격 5초
});
return user; // 이메일 전송을 기다리지 않고 즉시 응답
}
}
설명
이것이 하는 일: 이 코드는 사용자 가입 시 웰컴 이메일 발송을 백그라운드 큐로 비동기 처리하여 사용자 요청을 블로킹하지 않도록 합니다. 첫 번째로, EmailProcessor 클래스는 @Processor('email') 데코레이터로 'email' 큐의 워커를 정의합니다.
@Process('welcome') 메서드는 'welcome' 타입의 작업을 처리하는 핸들러로, 큐에서 작업을 가져와 실제 이메일 발송을 수행합니다. 이 워커는 메인 애플리케이션과 독립적으로 실행되므로, 이메일 발송에 5초가 걸려도 사용자 요청에는 영향을 주지 않습니다.
job.data로 작업 데이터에 접근하여 수신자 정보를 가져옵니다. 그 다음으로, UserService의 createUser 메서드는 사용자를 데이터베이스에 저장한 후 emailQueue.add()를 호출하여 이메일 발송 작업을 큐에 추가합니다.
이 과정은 Redis에 작업 정보를 저장하는 것이므로 밀리초 단위로 완료되며, 실제 이메일 발송을 기다리지 않고 즉시 return user를 실행합니다. 사용자는 0.1초 만에 응답을 받고 다음 작업을 진행할 수 있습니다.
마지막으로, 작업 옵션으로 attempts: 3을 설정하여 네트워크 오류 등으로 이메일 발송이 실패하면 자동으로 최대 3번까지 재시도하도록 합니다. backoff: 5000은 재시도 사이에 5초의 대기 시간을 두어 일시적인 장애가 복구될 시간을 줍니다.
Bull은 작업 상태를 Redis에 저장하므로 서버가 재시작되어도 작업이 손실되지 않습니다. 여러분이 이 패턴을 적용하면 사용자 가입 API의 응답 시간이 5초에서 100ms로 줄어들어 사용자 경험이 극적으로 개선됩니다.
서버는 이메일 발송을 기다리는 동안 다른 수백 개의 요청을 처리할 수 있어 전체 처리량이 10배 이상 증가합니다. 또한 이메일 서비스가 일시적으로 장애가 나도 자동 재시도로 안정성이 보장되고, Bull Dashboard를 통해 실패한 작업을 쉽게 추적하고 재처리할 수 있습니다.
실전 팁
💡 CPU 집약적 작업(이미지 처리, 동영상 인코딩)은 별도의 워커 서버에서 실행하세요. 메인 API 서버와 워커 서버를 분리하면 각각 독립적으로 스케일링할 수 있습니다.
💡 작업 타임아웃을 반드시 설정하세요. timeout: 60000 옵션으로 1분 이상 걸리는 작업은 자동으로 실패 처리하여 워커가 무한 대기하는 것을 방지합니다.
💡 작업 우선순위를 활용하세요. priority 옵션(숫자가 작을수록 높은 우선순위)으로 중요한 작업(결제 알림)을 일반 작업(뉴스레터)보다 먼저 처리할 수 있습니다.
💡 Bull Board를 설치하여 큐 상태를 실시간으로 모니터링하세요. 대기 중인 작업 수, 실패한 작업, 처리 속도 등을 웹 UI로 확인할 수 있습니다.
💡 작업 데이터는 최소화하세요. 큐에 대용량 파일을 직접 넣지 말고 파일 경로나 S3 URL만 전달하세요. Redis 메모리 사용량이 줄어들고 작업 처리 속도가 빨라집니다.
4. 압축 미들웨어
시작하며
여러분이 만든 API가 매번 500KB의 JSON 데이터를 반환하는데, 모바일 사용자들이 로딩이 너무 느리다고 불만을 제기한 경험이 있나요? 특히 3G나 4G 환경에서 큰 JSON 응답을 다운로드하는 데 수 초가 걸리면서 앱이 느리게 느껴지는 상황입니다.
이런 문제는 네트워크 대역폭이 제한된 환경에서 자주 발생합니다. 서버 처리는 빠르지만 네트워크 전송 시간이 병목이 되어 전체 응답 시간이 길어집니다.
특히 배열 데이터나 반복적인 키 이름이 많은 JSON은 압축률이 높아 큰 개선 효과를 볼 수 있습니다. 바로 이럴 때 필요한 것이 Compression 미들웨어를 이용한 HTTP 응답 압축입니다.
Gzip이나 Brotli로 응답을 압축하면 전송량을 70-90%까지 줄여 모바일 환경에서도 빠른 로딩 속도를 제공할 수 있습니다.
개요
간단히 말해서, 압축 미들웨어는 HTTP 응답 본문을 Gzip이나 Brotli 알고리즘으로 압축하여 네트워크 전송량을 줄이는 기법입니다. NestJS에서는 compression 패키지를 미들웨어로 추가하기만 하면 모든 응답이 자동으로 압축됩니다.
클라이언트가 Accept-Encoding: gzip 헤더를 보내면 서버는 자동으로 응답을 압축하고, 브라우저나 HTTP 클라이언트가 자동으로 압축을 해제하여 투명하게 동작합니다. 예를 들어, 500KB의 JSON 응답이 50-100KB로 줄어들어 모바일 환경에서 로딩 시간이 5배 이상 빨라집니다.
기존에는 압축되지 않은 큰 JSON을 그대로 전송하면서 네트워크 비용과 로딩 시간이 증가했다면, 이제는 자동 압축으로 대역폭 사용량을 크게 줄이고 사용자 경험을 개선할 수 있습니다. 압축의 핵심 특징은 첫째, 텍스트 기반 데이터(JSON, HTML, CSS)는 70-90% 압축률을 보이며, 둘째, 클라이언트와 서버 양쪽 지원이 자동으로 처리되어 코드 변경이 거의 없고, 셋째, CPU 사용량은 약간 증가하지만 네트워크 절감 효과가 훨씬 큽니다.
이러한 특징들이 특히 모바일과 저속 네트워크 환경에서 체감 성능을 크게 향상시킵니다.
코드 예제
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as compression from 'compression';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Compression 미들웨어 추가
app.use(
compression({
filter: (req, res) => {
// 이미 압축된 파일은 제외
if (req.headers['x-no-compression']) {
return false;
}
// compression 라이브러리 기본 필터 사용
return compression.filter(req, res);
},
level: 6, // 압축 레벨 (0-9, 6이 기본값, 균형잡힌 선택)
threshold: 1024, // 1KB 이상만 압축
}),
);
await app.listen(3000);
console.log('Server with compression enabled on port 3000');
}
bootstrap();
// 예시: 압축 효과가 큰 응답
@Get('users')
async getUsers() {
const users = await this.userRepository.find();
// 1000명 사용자 = 약 500KB JSON
// 압축 후 = 약 50-80KB (10배 감소!)
return users;
}
설명
이것이 하는 일: 이 코드는 NestJS 애플리케이션의 모든 HTTP 응답을 자동으로 압축하여 클라이언트로 전송되는 데이터 크기를 줄입니다. 첫 번째로, bootstrap 함수에서 app.use(compression())을 호출하여 압축 미들웨어를 전역으로 등록합니다.
이렇게 하면 모든 컨트롤러의 모든 응답이 자동으로 압축 대상이 됩니다. 미들웨어는 요청 헤더의 Accept-Encoding을 확인하여 클라이언트가 gzip이나 deflate를 지원하는지 자동으로 판단합니다.
그 다음으로, filter 옵션으로 압축 여부를 세밀하게 제어합니다. x-no-compression 헤더가 있는 요청은 압축을 건너뛰고, 기본 필터는 텍스트 기반 응답(JSON, HTML, CSS, JavaScript)만 압축하고 이미 압축된 이미지나 동영상은 제외합니다.
level: 6은 압축 품질을 설정하는데, 낮을수록(1) 빠르지만 압축률이 낮고, 높을수록(9) 느리지만 압축률이 높습니다. 6은 속도와 압축률의 균형점입니다.
마지막으로, threshold: 1024 옵션으로 1KB 이상의 응답만 압축합니다. 매우 작은 응답은 압축 오버헤드가 오히려 더 클 수 있기 때문입니다.
예를 들어, getUsers 엔드포인트가 500KB의 사용자 목록 JSON을 반환할 때, 압축 미들웨어가 자동으로 50-80KB로 줄여 네트워크 전송 시간을 1/10로 단축합니다. 클라이언트는 압축된 데이터를 받아 자동으로 원본으로 복원하므로 애플리케이션 코드는 전혀 변경할 필요가 없습니다.
여러분이 이 미들웨어를 적용하면 API 응답의 네트워크 전송 시간이 크게 줄어듭니다. 500KB JSON이 3G 환경(다운로드 속도 ~1Mbps)에서 4초 걸리던 것이 압축 후 0.4초로 단축됩니다.
서버의 네트워크 송신 대역폭 사용량이 70% 감소하여 트래픽 비용이 절감되고, 사용자는 특히 모바일이나 느린 네트워크에서 훨씬 빠른 로딩 속도를 경험합니다. CPU 사용량은 약 5-10% 증가하지만, 최신 서버에서는 무시할 수준이며 네트워크 절감 효과가 훨씬 큽니다.
실전 팁
💡 Brotli 압축을 사용하면 Gzip보다 15-20% 더 높은 압축률을 얻을 수 있습니다. compression 대신 shrink-ray-current 패키지를 사용하면 Brotli를 자동으로 지원합니다.
💡 정적 파일(CSS, JavaScript)은 빌드 시 미리 압축하세요. nginx나 CDN에서 .gz 파일을 서빙하면 서버 CPU 사용량을 절약할 수 있습니다.
💡 압축 레벨은 프로덕션 환경에 따라 조정하세요. CPU가 여유롭다면 level 9로, CPU 부하가 높다면 level 3-4로 낮춰 균형을 맞추세요.
💡 이미지나 동영상은 압축하지 마세요. 이미 압축된 포맷(JPEG, PNG, MP4)은 추가 압축 효과가 거의 없고 오히려 CPU만 낭비합니다. filter 옵션으로 제외하세요.
💡 압축 효과를 모니터링하세요. 응답 헤더의 Content-Encoding: gzip과 Content-Length로 실제 압축률을 확인할 수 있습니다. 브라우저 개발자 도구의 Network 탭에서도 원본 크기와 전송 크기를 비교할 수 있습니다.
5. 커넥션 풀링
시작하며
여러분이 데이터베이스를 사용하는 API를 만들었는데, 동시 사용자가 100명만 넘어도 "too many connections" 에러가 발생하고 서버가 멈추는 경험을 해본 적 있나요? 각 요청마다 새로운 데이터베이스 커넥션을 만들고 닫으면서 커넥션 생성 오버헤드와 최대 커넥션 수 제한에 걸리는 상황입니다.
이런 문제는 데이터베이스 커넥션 관리를 제대로 하지 않을 때 필연적으로 발생합니다. 데이터베이스 커넥션 생성은 네트워크 핸드셰이크, 인증, 세션 초기화 등으로 수십 밀리초가 걸리는 무거운 작업이며, 동시에 열 수 있는 커넥션 수도 제한되어 있습니다.
바로 이럴 때 필요한 것이 커넥션 풀링(Connection Pooling)입니다. 미리 일정 수의 커넥션을 만들어두고 재사용하면 커넥션 생성 오버헤드를 없애고, 최대 커넥션 수를 제어하여 안정적인 성능을 유지할 수 있습니다.
개요
간단히 말해서, 커넥션 풀링은 데이터베이스 커넥션을 미리 생성하여 풀(pool)에 보관하고, 필요할 때 빌려 쓰고 반환하여 재사용하는 기법입니다. TypeORM이나 Prisma 같은 ORM은 기본적으로 커넥션 풀을 제공하지만, 적절한 설정을 하지 않으면 기본값이 최적이 아닐 수 있습니다.
예를 들어, 동시 요청이 많은 서비스에서 풀 크기를 10으로 설정하면 11번째 요청부터는 대기해야 하므로 응답 시간이 길어집니다. 반대로 너무 크게 설정하면 데이터베이스 서버의 최대 커넥션 수를 초과하여 에러가 발생합니다.
기존에는 각 요청마다 new Database().connect()로 새 커넥션을 만들고 close()로 닫았다면, 이제는 풀에서 커넥션을 빌려 쓰고 자동으로 반환하여 커넥션 생성 비용을 제거할 수 있습니다. 커넥션 풀링의 핵심 특징은 첫째, 커넥션 재사용으로 생성 오버헤드(50-100ms)를 제거하고, 둘째, 최대 커넥션 수를 제한하여 데이터베이스 과부하를 방지하며, 셋째, 유휴 커넥션 타임아웃으로 불필요한 리소스를 자동으로 정리합니다.
이러한 특징들이 고부하 상황에서도 안정적인 성능을 보장합니다.
코드 예제
// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST,
port: 5432,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
// ⭐ 커넥션 풀 설정
extra: {
// 최대 동시 커넥션 수 (기본값 10)
max: 20,
// 최소 유지 커넥션 수 (기본값 0)
min: 5,
// 커넥션 획득 최대 대기 시간 (30초)
connectionTimeoutMillis: 30000,
// 유휴 커넥션 자동 종료 시간 (10분)
idleTimeoutMillis: 600000,
// 커넥션 최대 수명 (1시간)
maxLifetimeSeconds: 3600,
},
// 자동 재연결
keepConnectionAlive: true,
// 쿼리 로깅 (개발 환경만)
logging: process.env.NODE_ENV === 'development',
}),
],
})
export class AppModule {}
// ✅ 올바른 사용 (자동 관리)
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async getUser(id: string) {
// TypeORM이 자동으로 풀에서 커넥션을 가져오고 반환
return this.userRepository.findOne({ where: { id } });
}
}
설명
이것이 하는 일: 이 코드는 TypeORM의 커넥션 풀 설정을 최적화하여 데이터베이스 커넥션을 효율적으로 관리합니다. 첫 번째로, extra 객체 안에 PostgreSQL 드라이버의 커넥션 풀 옵션을 설정합니다.
max: 20은 동시에 열 수 있는 최대 커넥션 수를 20개로 제한하여 데이터베이스 서버가 과부하되지 않도록 보호합니다. 애플리케이션이 20개 이상의 동시 쿼리를 실행하려고 하면 대기 큐에 들어가며, 이는 "too many connections" 에러보다 훨씬 우아한 대응입니다.
min: 5는 항상 최소 5개의 커넥션을 풀에 유지하여 갑작스러운 트래픽 증가에도 즉시 응답할 수 있도록 합니다. 그 다음으로, 타임아웃 설정들이 커넥션 수명 주기를 관리합니다.
connectionTimeoutMillis: 30000은 풀에서 커넥션을 얻을 때 최대 30초까지 대기하며, 이 시간이 지나면 에러를 발생시켜 무한 대기를 방지합니다. idleTimeoutMillis: 600000은 10분간 사용되지 않은 유휴 커넥션을 자동으로 닫아 리소스를 절약합니다.
maxLifetimeSeconds: 3600은 커넥션의 최대 수명을 1시간으로 제한하여 오래된 커넥션으로 인한 문제(네트워크 불안정, 데이터베이스 설정 변경)를 예방합니다. 마지막으로, UserService의 예시는 개발자가 커넥션을 직접 관리할 필요 없이 TypeORM이 자동으로 풀에서 커넥션을 가져오고 반환하는 것을 보여줍니다.
userRepository.findOne()을 호출하면 내부적으로 풀에서 유휴 커넥션을 가져와 쿼리를 실행하고, 완료되면 자동으로 풀에 반환합니다. 이 과정은 완전히 투명하게 처리되어 코드가 단순해집니다.
여러분이 이 설정을 적용하면 데이터베이스 쿼리마다 커넥션을 새로 만드는 50-100ms 오버헤드가 사라져 평균 응답 시간이 크게 줄어듭니다. 동시 사용자가 100명을 넘어도 최대 커넥션 수 제어로 안정적으로 작동하며, 트래픽이 적을 때는 유휴 커넥션을 자동으로 정리하여 리소스를 절약합니다.
특히 서버리스 환경(AWS Lambda)이나 마이크로서비스에서 여러 인스턴스가 하나의 데이터베이스를 공유할 때, 각 인스턴스의 max 값을 적절히 설정하여 전체 커넥션 수를 관리하는 것이 매우 중요합니다.
실전 팁
💡 풀 크기는 데이터베이스 최대 커넥션 수와 애플리케이션 인스턴스 수를 고려하여 설정하세요. PostgreSQL 기본 max_connections는 100개이므로, 인스턴스 3개라면 각각 max: 30 정도가 적당합니다.
💡 서버리스 환경에서는 커넥션 풀 관리가 어렵습니다. AWS RDS Proxy나 PgBouncer 같은 외부 커넥션 풀러를 사용하여 여러 Lambda 함수가 커넥션을 공유하도록 하세요.
💡 풀 크기를 너무 크게 설정하지 마세요. CPU 코어 수의 2-3배 정도가 적당하며, 그 이상은 오히려 컨텍스트 스위칭으로 성능이 저하됩니다.
💡 커넥션 풀 메트릭을 모니터링하세요. 대기 시간이 길거나 대기 큐가 자주 발생하면 풀 크기를 늘리고, 유휴 커넥션이 많다면 줄이세요.
💡 읽기 전용 쿼리는 별도의 읽기 복제본(read replica)을 사용하세요. 마스터 DB와 읽기 복제본에 각각 커넥션 풀을 만들면 쓰기 작업과 읽기 작업을 분산하여 전체 처리량이 증가합니다.
6. DTO 검증 최적화
시작하며
여러분이 class-validator로 입력 데이터를 검증하는 API를 만들었는데, 복잡한 중첩 객체를 검증할 때마다 응답 시간이 100ms 이상 걸리는 경험을 해본 적 있나요? 특히 배열 안에 수십 개의 객체가 있고 각각 여러 필드를 검증할 때, 검증 과정 자체가 병목이 되는 상황입니다.
이런 문제는 과도한 검증 규칙과 비효율적인 검증 설정에서 발생합니다. class-validator는 강력하지만 기본 설정으로 사용하면 불필요한 검증을 수행하거나, 중첩된 객체를 모두 순회하면서 성능이 저하될 수 있습니다.
바로 이럴 때 필요한 것이 DTO 검증 최적화입니다. ValidationPipe의 옵션을 조정하고, 불필요한 검증을 제거하며, 필요한 경우 커스텀 검증 로직을 최적화하면 검증 시간을 절반 이하로 줄일 수 있습니다.
개요
간단히 말해서, DTO 검증 최적화는 class-validator의 설정을 조정하여 필요한 검증만 수행하고 불필요한 오버헤드를 제거하는 기법입니다. NestJS의 ValidationPipe는 기본적으로 모든 프로퍼티를 검증하고, 에러 메시지를 상세하게 생성하며, 추가 프로퍼티를 허용합니다.
이런 설정들은 개발 편의성은 좋지만 프로덕션 환경에서는 불필요한 처리가 많습니다. 예를 들어, whitelist: true를 설정하면 DTO에 정의되지 않은 필드를 자동으로 제거하여 보안도 강화하고 검증 시간도 줄일 수 있습니다.
기존에는 모든 프로퍼티를 깊이 검증하고 상세한 에러 메시지를 생성했다면, 이제는 필수 검증만 수행하고 첫 번째 에러에서 중단하여 불필요한 연산을 스킵할 수 있습니다. DTO 검증 최적화의 핵심 특징은 첫째, skipMissingProperties와 forbidUnknownValues 같은 옵션으로 불필요한 검증을 건너뛰고, 둘째, stopAtFirstError로 첫 에러 발견 시 즉시 중단하여 시간을 절약하며, 셋째, 복잡한 검증 로직은 커스텀 데코레이터로 최적화할 수 있습니다.
이러한 최적화가 대규모 요청 처리에서 누적되어 큰 성능 향상을 만들어냅니다.
코드 예제
// main.ts
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
// DTO에 없는 프로퍼티 자동 제거 (보안 + 성능)
whitelist: true,
// 알 수 없는 프로퍼티가 있으면 요청 거부
forbidNonWhitelisted: true,
// 첫 번째 에러에서 검증 중단 (성능 향상)
stopAtFirstError: true,
// 타입 자동 변환 (문자열 "123" -> 숫자 123)
transform: true,
// 중첩 객체 검증 비활성화 (필요한 경우만 활성화)
// enableDebugMessages: false,
// 에러 메시지 간소화 (프로덕션)
disableErrorMessages: process.env.NODE_ENV === 'production',
}),
);
await app.listen(3000);
}
bootstrap();
// ✅ 최적화된 DTO
import { IsString, IsEmail, IsInt, Min, Max, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
@MaxLength(100)
password: string;
@IsOptional()
@IsString()
@MaxLength(50)
name?: string;
@IsInt()
@Min(0)
@Max(150)
@Type(() => Number) // 문자열 "25" -> 숫자 25 변환
age: number;
}
// ❌ 비효율적인 DTO (피할 것)
export class BadDto {
@IsString()
@Matches(/^[a-zA-Z0-9]*$/, { message: 'Very detailed error message...' })
@IsNotEmpty({ message: 'Another detailed message...' })
field: string; // 너무 많은 검증이 중복됨
}
설명
이것이 하는 일: 이 코드는 NestJS의 ValidationPipe 설정을 조정하여 DTO 검증의 성능과 보안을 동시에 개선합니다. 첫 번째로, whitelist: true 옵션은 DTO 클래스에 정의되지 않은 프로퍼티를 자동으로 제거합니다.
클라이언트가 { email, password, name, age, hackerField: "malicious" }를 보내도 hackerField는 자동으로 걸러져서 서비스 로직에 전달되지 않습니다. 이는 보안 측면에서도 중요하며, 검증할 필드가 줄어들어 성능도 향상됩니다.
forbidNonWhitelisted: true를 함께 사용하면 알 수 없는 필드가 있을 때 400 에러를 반환하여 클라이언트에게 명확한 피드백을 제공합니다. 그 다음으로, stopAtFirstError: true는 검증 중 첫 번째 에러를 발견하면 즉시 중단합니다.
기본 동작은 모든 필드를 검증하여 모든 에러를 수집하지만, 프로덕션에서는 첫 에러만 알려줘도 충분한 경우가 많습니다. 예를 들어, 10개 필드가 모두 잘못되었어도 첫 번째 에러만 확인하고 중단하여 검증 시간이 크게 줄어듭니다.
마지막으로, CreateUserDto 예시는 효율적인 검증 데코레이터 사용법을 보여줍니다. @Type(() => Number)는 쿼리 파라미터나 폼 데이터에서 문자열로 들어온 "25"를 숫자 25로 자동 변환하여 별도의 파싱 로직이 필요 없습니다.
@IsOptional()은 해당 필드가 없어도 에러를 발생시키지 않으며, 있을 때만 검증을 수행합니다. disableErrorMessages는 프로덕션 환경에서 상세한 검증 에러 메시지를 숨겨 보안을 강화하고 에러 메시지 생성 오버헤드를 제거합니다.
여러분이 이 최적화를 적용하면 복잡한 DTO 검증 시간이 100ms에서 30-50ms로 줄어들어 API 전체 응답 시간이 개선됩니다. 특히 배열로 여러 객체를 한 번에 생성하는 엔드포인트(벌크 생성)에서 효과가 두드러집니다.
whitelist 옵션으로 보안도 강화되어 SQL Injection이나 NoSQL Injection 같은 공격 벡터를 차단할 수 있고, 불필요한 필드가 데이터베이스에 저장되는 것을 방지합니다.
실전 팁
💡 복잡한 커스텀 검증은 별도의 비즈니스 로직 레이어로 분리하세요. DTO 검증은 기본적인 타입과 형식만 확인하고, 복잡한 비즈니스 규칙(예: 이메일 중복 확인)은 서비스 레이어에서 처리하는 것이 효율적입니다.
💡 정규식 검증(@Matches)은 비용이 높습니다. 가능하면 @IsEmail, @IsUrl 같은 내장 검증을 사용하고, 꼭 필요한 경우만 정규식을 사용하세요.
💡 중첩 객체 검증이 필요한 경우 @ValidateNested()와 @Type()을 사용하되, 깊이가 3단계 이상이면 성능이 급격히 저하됩니다. DTO 구조를 평평하게 유지하세요.
💡 개발 환경과 프로덕션 환경의 ValidationPipe 설정을 분리하세요. 개발에서는 상세한 에러 메시지로 디버깅을 돕고, 프로덕션에서는 간소화된 메시지로 성능을 우선하세요.
💡 검증 에러가 자주 발생하는 API는 모니터링하세요. 클라이언트가 잘못된 요청을 반복적으로 보낸다면 API 문서를 개선하거나 클라이언트 측 검증을 강화해야 합니다.
7. 로깅 전략
시작하며
여러분이 애플리케이션에 로깅을 추가했는데, 로그가 너무 많아져서 디스크가 금방 가득 차고 로그를 쓰는 I/O 작업이 API 응답 속도를 늦추는 경험을 해본 적 있나요? 특히 모든 요청과 응답을 상세히 로깅하면 초당 수백 MB의 로그 파일이 생성되면서 서버 성능이 저하되는 상황입니다.
이런 문제는 로깅을 무분별하게 사용할 때 발생합니다. 로깅은 디버깅과 모니터링에 필수적이지만, 동기적인 파일 I/O는 CPU를 블로킹하고, 과도한 로그는 디스크를 낭비하며, 민감한 정보를 로그에 남기면 보안 문제가 발생합니다.
바로 이럴 때 필요한 것이 전략적인 로깅 시스템입니다. 로그 레벨을 적절히 설정하고, 비동기 로깅으로 성능 영향을 최소화하며, 민감한 정보를 필터링하고, 로그 로테이션으로 디스크 관리를 자동화하면 성능을 해치지 않으면서도 효과적인 모니터링이 가능합니다.
개요
간단히 말해서, 로깅 전략은 필요한 정보만 적절한 레벨로 로깅하고, 성능과 보안을 고려하여 로그를 효율적으로 관리하는 방법입니다. NestJS에서는 Winston이나 Pino 같은 고성능 로깅 라이브러리를 사용하여 비동기 로깅, 로그 레벨 제어, 자동 로테이션 등을 구현할 수 있습니다.
예를 들어, 개발 환경에서는 DEBUG 레벨로 모든 것을 로깅하지만, 프로덕션에서는 INFO 이상만 로깅하여 로그량을 10분의 1로 줄일 수 있습니다. 기존에는 console.log로 모든 것을 동기적으로 출력하면서 성능이 저하되었다면, 이제는 구조화된 JSON 로그를 비동기로 파일에 쓰고 CloudWatch나 ELK 스택으로 중앙 집중화할 수 있습니다.
로깅 전략의 핵심 특징은 첫째, 로그 레벨(DEBUG, INFO, WARN, ERROR)로 중요도를 구분하여 환경별로 다르게 설정하고, 둘째, 비동기 로깅으로 I/O 블로킹을 제거하며, 셋째, 민감한 정보(비밀번호, 토큰)를 자동으로 마스킹합니다. 이러한 전략이 성능과 보안을 유지하면서 효과적인 모니터링을 가능하게 합니다.
코드 예제
// logger.config.ts
import { WinstonModule } from 'nest-winston';
import * as winston from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';
const sensitiveFields = ['password', 'token', 'accessToken', 'refreshToken'];
// 민감한 정보 마스킹
const maskSensitiveData = winston.format((info) => {
const mask = (obj: any) => {
if (typeof obj !== 'object' || obj === null) return obj;
for (const key in obj) {
if (sensitiveFields.includes(key)) {
obj[key] = '***MASKED***';
} else if (typeof obj[key] === 'object') {
mask(obj[key]);
}
}
return obj;
};
return mask(info);
});
export const loggerConfig = WinstonModule.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
maskSensitiveData(),
winston.format.errors({ stack: true }),
winston.format.json(),
),
transports: [
// 콘솔 출력 (개발 환경)
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
),
}),
// 에러 로그 파일 (매일 로테이션)
new DailyRotateFile({
filename: 'logs/error-%DATE%.log',
datePattern: 'YYYY-MM-DD',
level: 'error',
maxFiles: '14d', // 14일간 보관
maxSize: '20m', // 파일당 최대 20MB
}),
// 전체 로그 파일
new DailyRotateFile({
filename: 'logs/combined-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxFiles: '7d',
maxSize: '20m',
}),
],
});
// 사용 예시
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
async createUser(dto: CreateUserDto) {
this.logger.log(`Creating user: ${dto.email}`); // INFO 레벨
try {
const user = await this.userRepository.save(dto);
this.logger.debug(`User created: ${JSON.stringify(user)}`); // DEBUG
return user;
} catch (error) {
this.logger.error(`Failed to create user: ${error.message}`, error.stack);
throw error;
}
}
}
설명
이것이 하는 일: 이 코드는 Winston 로거를 설정하여 성능을 해치지 않으면서도 보안과 관리성이 뛰어난 로깅 시스템을 구축합니다. 첫 번째로, maskSensitiveData 커스텀 포맷은 로그 객체를 순회하며 password, token 같은 민감한 필드를 ***MASKED***로 자동 변환합니다.
이렇게 하면 개발자가 실수로 logger.log(user)처럼 전체 객체를 로깅해도 비밀번호가 로그 파일에 평문으로 남지 않아 보안이 강화됩니다. 이 마스킹은 로그가 파일에 쓰이기 전에 메모리에서 수행되므로 추가 I/O 비용이 없습니다.
그 다음으로, transports 배열은 로그를 어디에 쓸지 정의합니다. Console transport는 개발 환경에서 컬러풀한 로그를 터미널에 출력하여 실시간 디버깅을 돕습니다.
DailyRotateFile transport는 로그를 날짜별로 자동 분리하고, 파일 크기가 20MB를 넘으면 새 파일을 생성하며, 오래된 로그는 자동으로 삭제합니다. 에러 로그는 14일간 보관하고 일반 로그는 7일간 보관하여 디스크 공간을 효율적으로 사용합니다.
마지막으로, UserService의 사용 예시는 로그 레벨을 상황에 맞게 사용하는 방법을 보여줍니다. logger.log()는 INFO 레벨로 일반적인 작업 흐름을 기록하고, logger.debug()는 DEBUG 레벨로 상세한 데이터를 기록하여 프로덕션에서는 출력되지 않습니다.
logger.error()는 에러 메시지와 스택 트레이스를 모두 기록하여 문제 추적을 돕습니다. process.env.NODE_ENV에 따라 로그 레벨이 자동 조정되어 개발에서는 모든 로그를, 프로덕션에서는 INFO 이상만 출력합니다.
여러분이 이 로깅 전략을 적용하면 프로덕션 환경의 로그량이 10분의 1로 줄어들어 디스크 비용이 절감되고, 비동기 I/O로 로깅이 API 응답 시간에 영향을 주지 않습니다. 민감한 정보 마스킹으로 GDPR이나 개인정보보호법 준수가 쉬워지고, 자동 로테이션으로 디스크가 가득 차서 서비스가 중단되는 사고를 예방할 수 있습니다.
JSON 형식의 구조화된 로그는 ELK Stack(Elasticsearch, Logstash, Kibana)이나 CloudWatch Logs Insights로 쉽게 검색하고 분석할 수 있어 운영 효율성이 크게 향상됩니다.
실전 팁
💡 프로덕션에서는 절대 DEBUG 레벨을 사용하지 마세요. 로그량이 폭발적으로 증가하여 디스크가 금방 가득 차고 로그 분석이 불가능해집니다. INFO 이상만 로깅하세요.
💡 요청/응답 전체를 로깅하지 마세요. 민감한 정보가 포함될 수 있고 로그 크기가 너무 커집니다. 요청 ID, 사용자 ID, 엔드포인트, 상태 코드 정도만 로깅하세요.
💡 로그를 중앙 집중화하세요. 여러 서버의 로그를 ELK, Datadog, CloudWatch 같은 중앙 시스템으로 보내면 분산 환경에서도 전체 흐름을 추적할 수 있습니다.
💡 상관 ID(correlation ID)를 사용하세요. 각 요청에 고유 ID를 부여하고 모든 로그에 포함시키면 마이크로서비스 환경에서 요청 흐름을 추적하기 쉽습니다.
💡 로그 알림을 설정하세요. ERROR 레벨 로그가 발생하면 Slack이나 이메일로 즉시 알림을 받도록 설정하여 장애에 빠르게 대응할 수 있습니다.
8. 메모리 누수 방지
시작하며
여러분이 만든 NestJS 애플리케이션을 며칠 동안 실행했더니 메모리 사용량이 계속 증가하여 결국 서버가 멈추는 경험을 해본 적 있나요? 이벤트 리스너, 타이머, WebSocket 연결 같은 리소스를 제대로 정리하지 않아 메모리 누수(memory leak)가 발생하는 상황입니다.
이런 문제는 비동기 작업이나 이벤트 기반 프로그래밍에서 흔히 발생합니다. setInterval로 타이머를 시작했지만 컴포넌트가 파괴될 때 clearInterval을 호출하지 않거나, 이벤트 리스너를 등록했지만 removeListener를 호출하지 않으면 메모리에 계속 남아 누적됩니다.
바로 이럴 때 필요한 것이 체계적인 리소스 정리 패턴입니다. NestJS의 라이프사이클 훅을 활용하여 컴포넌트가 파괴될 때 모든 리소스를 자동으로 정리하고, 순환 참조를 피하며, 큰 객체는 WeakMap을 사용하여 자동 가비지 컬렉션이 되도록 하면 장시간 실행되는 서버에서도 안정적인 메모리 사용이 가능합니다.
개요
간단히 말해서, 메모리 누수 방지는 사용한 리소스(타이머, 이벤트 리스너, 커넥션)를 확실히 정리하여 메모리가 계속 증가하지 않도록 관리하는 기법입니다. NestJS에서는 OnModuleDestroy 인터페이스를 구현하여 서비스가 종료될 때 정리 로직을 실행할 수 있습니다.
또한 @Cron 작업이나 @SubscribeMessage 핸들러에서 생성한 리소스를 추적하고 정리해야 합니다. 예를 들어, 사용자별 실시간 알림을 위해 setInterval로 폴링을 시작했다면, 사용자가 연결을 끊을 때 반드시 clearInterval을 호출해야 합니다.
기존에는 리소스를 생성만 하고 정리하지 않아 메모리가 계속 증가했다면, 이제는 생성과 정리를 쌍으로 관리하여 메모리 사용량이 일정하게 유지됩니다. 메모리 누수 방지의 핵심 특징은 첫째, 모든 비동기 리소스(타이머, 이벤트 리스너)를 추적하고 정리하며, 둘째, 순환 참조를 피하고 필요한 경우 WeakMap/WeakSet을 사용하고, 셋째, 라이프사이클 훅을 활용하여 자동 정리를 구현합니다.
이러한 패턴들이 장기 실행 서버의 안정성을 보장합니다.
코드 예제
// notification.service.ts
import { Injectable, OnModuleDestroy } from '@nestjs/common';
@Injectable()
export class NotificationService implements OnModuleDestroy {
// 타이머 ID를 추적하기 위한 Map
private timers = new Map<string, NodeJS.Timeout>();
// 이벤트 리스너 추적
private eventListeners = new Map<string, Function>();
// ✅ 올바른 패턴: 타이머 생성과 정리
startUserNotification(userId: string) {
// 이미 실행 중이면 먼저 정리
if (this.timers.has(userId)) {
this.stopUserNotification(userId);
}
// 타이머 시작
const timerId = setInterval(async () => {
await this.sendNotification(userId);
}, 60000); // 1분마다
// 타이머 ID 저장 (나중에 정리하기 위해)
this.timers.set(userId, timerId);
console.log(`Started notification for user ${userId}`);
}
// 타이머 정리
stopUserNotification(userId: string) {
const timerId = this.timers.get(userId);
if (timerId) {
clearInterval(timerId);
this.timers.delete(userId);
console.log(`Stopped notification for user ${userId}`);
}
}
// ✅ 이벤트 리스너 정리
subscribeToEvents(userId: string) {
const handler = (data: any) => {
console.log(`Event received for ${userId}:`, data);
};
this.eventEmitter.on(`user:${userId}`, handler);
this.eventListeners.set(userId, handler);
}
unsubscribeFromEvents(userId: string) {
const handler = this.eventListeners.get(userId);
if (handler) {
this.eventEmitter.off(`user:${userId}`, handler);
this.eventListeners.delete(userId);
}
}
// ✅ 모듈 종료 시 모든 리소스 정리
onModuleDestroy() {
console.log('Cleaning up all resources...');
// 모든 타이머 정리
for (const [userId, timerId] of this.timers) {
clearInterval(timerId);
}
this.timers.clear();
// 모든 이벤트 리스너 정리
for (const [userId, handler] of this.eventListeners) {
this.eventEmitter.off(`user:${userId}`, handler);
}
this.eventListeners.clear();
console.log('All resources cleaned up');
}
}
// ❌ 나쁜 예: 메모리 누수 발생
@Injectable()
export class BadService {
startPolling() {
// 타이머 ID를 저장하지 않아 정리 불가능!
setInterval(() => {
this.doSomething();
}, 1000);
}
// 순환 참조 발생
createCircularReference() {
const obj1: any = {};
const obj2: any = {};
obj1.ref = obj2;
obj2.ref = obj1; // obj1 ↔ obj2 순환 참조
}
}
설명
이것이 하는 일: 이 코드는 생성된 모든 비동기 리소스를 추적하고 적절한 시점에 정리하여 메모리 누수를 방지하는 패턴을 보여줍니다. 첫 번째로, timers Map은 각 사용자별로 생성된 타이머 ID를 저장합니다.
startUserNotification 메서드는 타이머를 시작하기 전에 이미 실행 중인 타이머가 있는지 확인하고, 있다면 먼저 정리합니다. 이렇게 하면 같은 사용자에 대해 중복 타이머가 생성되지 않습니다.
setInterval의 반환값을 Map에 저장하여 나중에 clearInterval로 정리할 수 있도록 합니다. 그 다음으로, eventListeners Map은 이벤트 핸들러 함수를 저장합니다.
EventEmitter의 on 메서드로 리스너를 등록할 때 핸들러 함수를 변수에 저장해두어야 나중에 off 메서드로 제거할 수 있습니다. 익명 함수를 바로 전달하면 나중에 제거할 방법이 없어 메모리 누수가 발생합니다.
unsubscribeFromEvents는 저장된 핸들러를 찾아 이벤트 리스너를 제거하고 Map에서도 삭제합니다. 마지막으로, onModuleDestroy 라이프사이클 훅은 애플리케이션이 종료되거나 모듈이 리로드될 때 자동으로 호출됩니다.
이 메서드에서 timers Map을 순회하며 모든 타이머를 clearInterval로 정리하고, eventListeners Map의 모든 리스너를 off로 제거합니다. 마지막으로 clear()를 호출하여 Map 자체도 비워 메모리를 완전히 해제합니다.
이 패턴을 사용하면 개발자가 정리를 깜빡해도 시스템이 자동으로 정리합니다. 여러분이 이 패턴을 적용하면 서버를 몇 주 동안 실행해도 메모리 사용량이 일정하게 유지됩니다.
타이머와 이벤트 리스너가 무한정 쌓이지 않아 메모리 부족으로 서버가 크래시되는 사고를 예방할 수 있습니다. 특히 WebSocket이나 Server-Sent Events처럼 연결 수명이 긴 기능에서 각 클라이언트별 리소스를 정확히 추적하고 정리하는 것이 매우 중요합니다.
또한 PM2나 Docker 같은 프로세스 매니저로 재시작할 때도 리소스가 깨끗하게 정리되어 graceful shutdown이 보장됩니다.
실전 팁
💡 Node.js의 --inspect 플래그와 Chrome DevTools의 Heap Snapshot을 사용하여 메모리 누수를 진단하세요. 메모리 사용량이 계속 증가하는지 추적할 수 있습니다.
💡 clinic.js 같은 도구로 메모리 프로파일링을 수행하세요. 어떤 객체가 메모리를 많이 차지하는지 시각적으로 분석할 수 있습니다.
💡 큰 객체를 캐시할 때는 WeakMap을 사용하세요. 일반 Map은 키가 존재하는 한 값이 메모리에 유지되지만, WeakMap은 키가 다른 곳에서 참조되지 않으면 자동으로 가비지 컬렉션됩니다.
💡 Promise를 사용할 때 반드시 에러 처리를 하세요. 처리되지 않은 Promise rejection은 메모리 누수의 흔한 원인입니다. catch나 try-finally로 항상 정리 로직을 실행하세요.
💡 프로덕션에서 메모리 사용량을 모니터링하세요. CloudWatch, Datadog, New Relic 같은 APM 도구로 메모리 추세를 추적하고, 비정상적인 증가가 감지되면 알림을 받으세요.
댓글 (0)
함께 보면 좋은 카드 뉴스
마이크로서비스 배포 완벽 가이드
Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.
Zipkin으로 추적 시각화 완벽 가이드
마이크로서비스 환경에서 분산 추적을 시각화하는 Zipkin의 핵심 개념과 활용 방법을 초급자도 쉽게 이해할 수 있도록 실무 스토리로 풀어낸 가이드입니다. Docker 실행부터 UI 분석까지 단계별로 배웁니다.
Spring AOT와 네이티브 이미지 완벽 가이드
Spring Boot 3.0부터 지원되는 AOT 컴파일과 GraalVM 네이티브 이미지를 통해 애플리케이션 시작 시간을 극적으로 단축하는 방법을 알아봅니다. 초급 개발자도 쉽게 이해할 수 있도록 실무 상황과 비유로 풀어냅니다.
Application Load Balancer 완벽 가이드
AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.
고객 상담 AI 시스템 완벽 구축 가이드
AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.