이미지 로딩 중...
AI Generated
2025. 11. 13. · 4 Views
onSend Hook을 활용한 응답 압축 및 캐싱 완벽 가이드
Node.js 애플리케이션에서 onSend Hook을 활용하여 응답 데이터를 효율적으로 압축하고 캐싱하는 방법을 알아봅니다. 성능 최적화와 네트워크 비용 절감을 위한 실전 기법을 다룹니다.
목차
- onSend Hook 기본 개념 - 응답 전송 전 데이터 가공하기
- 응답 캐싱 전략 - ETag와 Last-Modified 활용하기
- 스트리밍 압축 구현 - 대용량 데이터 효율적으로 처리하기
- 응답 캐시 저장소 구현 - Redis를 활용한 서버 사이드 캐싱
- 조건부 압축 최적화 - Content-Length 기반 압축 결정
- 응답 변환 파이프라인 - 민감 정보 필터링과 포맷 통일
- 압축률 모니터링과 적응형 압축 - 실시간 최적화 전략
- 에러 응답 처리와 로깅 - onSend에서 에러 추적하기
1. onSend Hook 기본 개념 - 응답 전송 전 데이터 가공하기
시작하며
여러분이 API 서버를 운영하면서 응답 데이터가 너무 커서 네트워크 전송 시간이 오래 걸리는 상황을 겪어본 적 있나요? 특히 대용량 JSON 데이터나 이미지를 반환할 때 사용자들이 느린 응답 속도에 불만을 제기하는 경우가 많습니다.
이런 문제는 실제 개발 현장에서 매우 빈번하게 발생합니다. 응답 데이터가 클수록 네트워크 대역폭을 많이 소비하고, 모바일 환경이나 느린 네트워크에서는 사용자 경험이 크게 저하됩니다.
또한 클라우드 서비스를 사용하는 경우 데이터 전송 비용도 증가하게 됩니다. 바로 이럴 때 필요한 것이 onSend Hook입니다.
이 Hook을 사용하면 응답이 클라이언트로 전송되기 직전에 데이터를 압축하거나 변환하여 전송 크기를 획기적으로 줄일 수 있습니다.
개요
간단히 말해서, onSend Hook은 Fastify와 같은 Node.js 프레임워크에서 응답 데이터가 실제로 클라이언트로 전송되기 바로 직전에 실행되는 라이프사이클 훅입니다. 이 Hook이 필요한 이유는 응답 데이터를 최종적으로 가공할 수 있는 마지막 기회를 제공하기 때문입니다.
예를 들어, 대용량 JSON 응답을 gzip으로 압축하거나, 민감한 정보를 필터링하거나, 응답 형식을 통일하는 등의 작업을 일관되게 처리할 수 있습니다. 전통적인 방법에서는 각 라우트 핸들러에서 개별적으로 압축이나 변환 로직을 작성해야 했다면, onSend Hook을 사용하면 모든 응답에 대해 자동으로 일관된 처리를 적용할 수 있습니다.
이 Hook의 핵심 특징은 첫째, 모든 응답에 대해 중앙집중식으로 처리 로직을 적용할 수 있다는 점, 둘째, 응답 스트림을 직접 제어할 수 있어 메모리 효율적인 처리가 가능하다는 점, 셋째, 비동기 처리를 지원하여 복잡한 변환 작업도 가능하다는 점입니다. 이러한 특징들이 대규모 프로덕션 환경에서 안정적이고 효율적인 응답 처리를 가능하게 합니다.
코드 예제
// Fastify에서 onSend Hook 기본 사용법
fastify.addHook('onSend', async (request, reply, payload) => {
// payload: 실제 전송될 응답 데이터
// 압축이 필요한 조건 체크
if (request.headers['accept-encoding']?.includes('gzip')) {
// Content-Type이 JSON인 경우에만 처리
if (reply.getHeader('content-type')?.includes('application/json')) {
// 데이터 크기 체크 (1KB 이상만 압축)
if (payload.length > 1024) {
reply.header('content-encoding', 'gzip');
// 압축된 데이터 반환
return zlib.gzipSync(payload);
}
}
}
// 조건에 맞지 않으면 원본 반환
return payload;
});
설명
이것이 하는 일: onSend Hook은 Fastify의 요청-응답 라이프사이클에서 응답 데이터가 실제로 네트워크를 통해 전송되기 바로 직전에 호출되는 함수입니다. 이 시점에서 최종 응답 데이터를 받아 원하는 형태로 가공하거나 압축한 후 다시 반환할 수 있습니다.
첫 번째로, addHook 메서드를 사용하여 'onSend' 이벤트에 핸들러 함수를 등록합니다. 이 핸들러는 request, reply, payload 세 가지 매개변수를 받는데, request는 현재 요청 객체, reply는 응답 객체, payload는 실제 전송될 데이터입니다.
이 구조 덕분에 요청의 특성(헤더, 메서드 등)에 따라 다른 처리를 적용할 수 있습니다. 두 번째로, 조건문을 통해 압축이 필요한지 판단합니다.
위 코드에서는 클라이언트가 gzip 인코딩을 지원하는지(Accept-Encoding 헤더), 응답이 JSON 형식인지(Content-Type 헤더), 데이터 크기가 충분히 큰지(1KB 이상)를 체크합니다. 이렇게 조건을 체크하는 이유는 작은 데이터를 압축하면 오히려 오버헤드가 발생할 수 있고, 이미지 같은 바이너리 데이터는 이미 압축되어 있을 수 있기 때문입니다.
세 번째 단계로, 조건이 모두 충족되면 zlib.gzipSync를 사용하여 데이터를 압축하고, content-encoding 헤더를 설정하여 클라이언트에게 압축 방식을 알려줍니다. 마지막으로 압축된 데이터를 반환하면 Fastify가 이를 클라이언트로 전송합니다.
조건에 맞지 않는 경우에는 원본 payload를 그대로 반환합니다. 여러분이 이 코드를 사용하면 별도의 미들웨어 없이도 모든 API 응답을 자동으로 압축할 수 있어 네트워크 전송 시간을 30-70% 가량 단축할 수 있습니다.
또한 중앙집중식으로 관리되기 때문에 유지보수가 쉽고, 나중에 압축 알고리즘을 변경하거나 조건을 수정할 때 한 곳만 수정하면 됩니다. 특히 대규모 JSON 데이터를 반환하는 API에서는 클라우드 데이터 전송 비용을 크게 절감할 수 있습니다.
실전 팁
💡 작은 응답은 압축하지 마세요. 1KB 미만의 데이터는 압축 오버헤드가 압축 이득보다 클 수 있습니다. 임계값을 설정하여 효율적으로 관리하세요.
💡 이미 압축된 포맷(이미지, 비디오)은 제외하세요. Content-Type을 체크하여 application/json, text/html 등 텍스트 기반 응답만 압축 대상으로 지정하세요.
💡 동기 함수(gzipSync) 대신 비동기 함수(gzip)를 사용하면 대용량 데이터 압축 시 이벤트 루프가 블로킹되지 않습니다. 프로덕션에서는 비동기 버전을 권장합니다.
💡 압축 레벨을 조정하여 속도와 압축률의 균형을 맞추세요. zlib.constants.Z_BEST_SPEED는 빠르지만 압축률이 낮고, Z_BEST_COMPRESSION은 느리지만 압축률이 높습니다.
💡 에러 처리를 반드시 추가하세요. 압축 실패 시 원본 데이터를 반환하도록 try-catch로 감싸면 서비스 중단을 방지할 수 있습니다.
2. 응답 캐싱 전략 - ETag와 Last-Modified 활용하기
시작하며
여러분이 동일한 데이터를 요청하는 클라이언트에게 매번 전체 응답을 전송하면서 서버 리소스가 낭비되고 있다고 느낀 적 있나요? 특히 상품 목록이나 뉴스 피드처럼 자주 변경되지 않는 데이터를 수백 명의 사용자가 동시에 요청하면 서버 부하가 급격히 증가합니다.
이런 문제는 트래픽이 많은 서비스에서 심각한 비용 증가와 성능 저하로 이어집니다. 동일한 데이터를 반복적으로 처리하고 전송하는 것은 CPU, 메모리, 네트워크 대역폭을 모두 낭비하는 행위입니다.
또한 클라이언트 입장에서도 불필요한 데이터를 다시 받아야 하므로 로딩 시간이 길어집니다. 바로 이럴 때 필요한 것이 HTTP 캐싱 전략입니다.
onSend Hook에서 ETag나 Last-Modified 헤더를 활용하면 변경되지 않은 데이터에 대해 304 Not Modified 응답을 보내 서버와 클라이언트 모두의 리소스를 절약할 수 있습니다.
개요
간단히 말해서, ETag(Entity Tag)는 응답 데이터의 특정 버전을 식별하는 고유한 문자열이고, Last-Modified는 리소스가 마지막으로 수정된 시간을 나타냅니다. 이 개념들이 필요한 이유는 클라이언트가 이미 가지고 있는 데이터가 여전히 유효한지 확인하여 불필요한 데이터 전송을 방지하기 때문입니다.
예를 들어, 사용자가 상품 목록 페이지를 새로고침할 때 데이터가 변경되지 않았다면 "변경 없음"이라는 짧은 응답만 보내면 되므로 네트워크 사용량을 95% 이상 줄일 수 있습니다. 기존에는 매번 전체 데이터를 조회하고 직렬화하여 전송해야 했다면, 이제는 ETag를 비교하여 데이터가 변경되지 않았으면 304 상태 코드만 반환할 수 있습니다.
이 개념의 핵심 특징은 첫째, 조건부 요청(Conditional Request)을 통해 네트워크 전송량을 대폭 감소시킨다는 점, 둘째, 서버 처리 부하도 줄어든다는 점(데이터 직렬화 불필요), 셋째, 클라이언트 측 캐시와 완벽하게 통합된다는 점입니다. 이러한 특징들이 고성능 웹 애플리케이션의 필수 요소가 됩니다.
코드 예제
// onSend Hook에서 ETag 기반 캐싱 구현
const crypto = require('crypto');
fastify.addHook('onSend', async (request, reply, payload) => {
// GET 요청이고 성공 응답인 경우만 캐싱 처리
if (request.method === 'GET' && reply.statusCode === 200) {
// payload로부터 ETag 생성 (MD5 해시)
const etag = crypto.createHash('md5').update(payload).digest('hex');
reply.header('ETag', `"${etag}"`);
// 클라이언트가 보낸 If-None-Match 헤더와 비교
const clientEtag = request.headers['if-none-match'];
if (clientEtag === `"${etag}"`) {
// 데이터가 변경되지 않음 - 304 응답
reply.code(304);
return ''; // 빈 바디 반환
}
}
// 데이터가 변경되었거나 첫 요청 - 전체 데이터 반환
return payload;
});
설명
이것이 하는 일: 이 코드는 응답 데이터의 고유한 식별자(ETag)를 생성하고, 클라이언트가 이전에 받았던 데이터와 동일한지 확인하여 불필요한 데이터 전송을 방지하는 HTTP 캐싱 메커니즘을 구현합니다. 첫 번째로, GET 요청이면서 정상 응답(200)인 경우만 캐싱 로직을 적용합니다.
POST, PUT, DELETE 같은 변경 요청은 캐싱하면 안 되고, 에러 응답도 캐싱 대상이 아니기 때문입니다. 이 조건 체크는 캐싱이 적절한 상황에만 적용되도록 보장합니다.
두 번째로, crypto 모듈의 MD5 해시 함수를 사용하여 응답 데이터(payload)의 해시값을 계산합니다. 이 해시값은 데이터의 "지문"과 같아서 데이터가 조금이라도 변경되면 완전히 다른 값이 나옵니다.
이 해시값을 ETag 헤더로 설정하면 클라이언트는 이를 저장했다가 다음 요청에서 If-None-Match 헤더로 전송합니다. 세 번째로, 클라이언트가 보낸 If-None-Match 헤더 값과 현재 생성한 ETag를 비교합니다.
두 값이 일치하면 클라이언트가 이미 최신 버전의 데이터를 가지고 있다는 의미이므로, 상태 코드를 304로 변경하고 빈 문자열을 반환합니다. 이렇게 하면 헤더만 전송되고 본문은 전송되지 않아 네트워크 사용량이 극적으로 감소합니다.
여러분이 이 코드를 사용하면 반복적인 GET 요청에 대해 서버 처리 시간을 90% 이상 단축할 수 있습니다. 특히 모바일 앱에서 주기적으로 데이터를 폴링하는 경우, 대부분의 요청이 304 응답으로 처리되어 사용자의 데이터 요금을 절약하고 배터리 소모도 줄일 수 있습니다.
또한 서버의 CPU와 메모리 사용량도 크게 감소하여 더 많은 동시 접속자를 처리할 수 있게 됩니다.
실전 팁
💡 ETag 생성 비용을 고려하세요. 대용량 응답의 경우 MD5 해싱도 오버헤드가 될 수 있으므로, 데이터베이스의 updated_at 필드나 버전 번호를 활용하는 것이 더 효율적일 수 있습니다.
💡 Cache-Control 헤더와 함께 사용하세요. "Cache-Control: private, must-revalidate"를 설정하면 브라우저가 캐시를 유지하면서도 매번 서버에 유효성을 확인합니다.
💡 Weak ETag(W/"...")를 사용하면 완벽히 동일하지 않아도 "의미적으로 동등한" 응답을 캐시로 처리할 수 있습니다. 압축 여부가 다른 응답을 같은 것으로 취급할 때 유용합니다.
💡 개인화된 응답은 Vary 헤더를 추가하세요. "Vary: Authorization"을 설정하면 사용자별로 다른 캐시를 유지하여 보안 문제를 방지할 수 있습니다.
💡 ETag는 따옴표로 감싸야 합니다. HTTP 스펙에 따라 "${etag}" 형식으로 작성해야 브라우저와 CDN이 올바르게 인식합니다.
3. 스트리밍 압축 구현 - 대용량 데이터 효율적으로 처리하기
시작하며
여러분이 수십 MB의 대용량 JSON 데이터나 로그 파일을 API로 전송하려고 할 때, 메모리 부족 에러나 타임아웃 문제를 겪어본 적 있나요? 전체 데이터를 메모리에 로드한 후 압축하면 순간적으로 메모리 사용량이 급증하여 서버가 불안정해집니다.
이런 문제는 대용량 파일 다운로드 API, 데이터 내보내기 기능, 실시간 로그 스트리밍 등에서 자주 발생합니다. 동기적으로 전체 데이터를 처리하면 Node.js의 단일 스레드 특성상 다른 요청들도 블로킹되어 전체 서비스 성능이 저하됩니다.
바로 이럴 때 필요한 것이 스트리밍 압축입니다. onSend Hook에서 Node.js 스트림을 활용하면 데이터를 청크 단위로 읽으면서 동시에 압축하여 전송할 수 있어 메모리를 효율적으로 사용할 수 있습니다.
개요
간단히 말해서, 스트리밍 압축은 전체 데이터를 한 번에 메모리에 로드하지 않고 작은 청크로 나누어 읽으면서 동시에 압축하고 전송하는 기법입니다. 이 기법이 필요한 이유는 메모리 효율성과 응답 속도를 모두 개선할 수 있기 때문입니다.
예를 들어, 100MB의 데이터를 전송할 때 전통적인 방법은 200MB 이상의 메모리(원본 + 압축본)가 필요하지만, 스트리밍 방식은 수 KB만으로도 처리할 수 있습니다. 또한 클라이언트는 첫 번째 청크를 받는 즉시 처리를 시작할 수 있어 체감 속도가 빨라집니다.
기존에는 모든 데이터를 Buffer로 변환하고 압축한 후 한 번에 전송했다면, 이제는 데이터 스트림과 압축 스트림을 파이프로 연결하여 실시간으로 처리할 수 있습니다. 이 기법의 핵심 특징은 첫째, 메모리 사용량이 데이터 크기와 무관하게 일정하다는 점, 둘째, 백프레셔(backpressure) 메커니즘으로 생산자와 소비자의 속도를 자동으로 조절한다는 점, 셋째, 파이프라인 구조로 여러 변환을 조합할 수 있다는 점입니다.
이러한 특징들이 대용량 데이터 처리에서 안정성과 성능을 보장합니다.
코드 예제
// 스트리밍 압축을 위한 onSend Hook
const zlib = require('zlib');
const { pipeline } = require('stream');
const { promisify } = require('util');
const pipelineAsync = promisify(pipeline);
fastify.addHook('onSend', async (request, reply, payload) => {
// 압축 조건 체크
const shouldCompress = request.headers['accept-encoding']?.includes('gzip')
&& reply.getHeader('content-type')?.includes('application/json');
if (shouldCompress && payload instanceof Buffer && payload.length > 10240) {
reply.header('content-encoding', 'gzip');
// 스트림으로 변환하여 압축 파이프라인 구성
const { Readable } = require('stream');
const stream = Readable.from(payload);
// gzip 압축 스트림 생성 (비동기)
return stream.pipe(zlib.createGzip({ level: zlib.constants.Z_BEST_SPEED }));
}
return payload;
});
설명
이것이 하는 일: 이 코드는 대용량 응답 데이터를 스트림으로 변환하고 압축 스트림과 파이프로 연결하여 메모리 효율적인 실시간 압축을 수행합니다. 첫 번째로, 압축이 필요한 조건을 체크합니다.
클라이언트가 gzip을 지원하고, 응답이 JSON 형식이며, 데이터 크기가 10KB 이상일 때만 스트리밍 압축을 적용합니다. 10KB 미만의 작은 데이터는 스트림 오버헤드가 더 클 수 있으므로 제외합니다.
두 번째로, Buffer로 된 payload를 Readable 스트림으로 변환합니다. Readable.from() 메서드는 Node.js 12.3.0부터 지원되며, Buffer나 배열, 이터러블을 스트림으로 쉽게 변환할 수 있습니다.
이렇게 스트림으로 변환하면 데이터를 청크 단위로 읽을 수 있는 인터페이스가 만들어집니다. 세 번째로, zlib.createGzip()으로 압축 변환 스트림을 생성하고 pipe() 메서드로 연결합니다.
이 파이프 연결은 매우 강력한데, 데이터가 자동으로 원본 스트림에서 압축 스트림으로 흘러가면서 백프레셔가 자동으로 관리됩니다. 즉, 압축 처리가 느리면 읽기 속도도 자동으로 조절되어 메모리가 넘치지 않습니다.
Z_BEST_SPEED 옵션은 압축률보다 속도를 우선하여 실시간 응답에 적합합니다. 여러분이 이 코드를 사용하면 100MB 데이터를 전송할 때 메모리 사용량을 수백 MB에서 수 KB로 줄일 수 있습니다.
또한 첫 바이트까지의 시간(TTFB)이 단축되어 클라이언트가 더 빠르게 데이터를 받기 시작할 수 있습니다. 대량의 동시 다운로드 요청이 있어도 서버 메모리가 안정적으로 유지되며, Node.js의 이벤트 루프도 블로킹되지 않아 다른 요청들도 원활하게 처리됩니다.
실전 팁
💡 파일 시스템에서 읽는 경우 fs.createReadStream을 직접 사용하세요. Buffer로 변환하지 않고 바로 파일 스트림을 압축 스트림과 연결하면 메모리를 더 절약할 수 있습니다.
💡 에러 처리를 위해 promisify된 pipeline을 사용하세요. stream.pipe()는 에러를 자동으로 전파하지 않지만 pipeline()은 모든 스트림의 에러를 catch할 수 있습니다.
💡 압축 레벨을 상황에 따라 조절하세요. 실시간 API는 Z_BEST_SPEED, 배치 내보내기는 Z_BEST_COMPRESSION이 적합합니다.
💡 highWaterMark 옵션으로 청크 크기를 조절할 수 있습니다. 기본값은 16KB인데, 네트워크 환경에 따라 64KB나 256KB로 늘리면 처리량이 향상될 수 있습니다.
💡 Transform 스트림을 활용하면 압축 외에 데이터 변환, 필터링, 암호화 등을 파이프라인에 추가할 수 있습니다. 여러 변환을 조합하여 복잡한 처리를 메모리 효율적으로 구현하세요.
4. 응답 캐시 저장소 구현 - Redis를 활용한 서버 사이드 캐싱
시작하며
여러분이 데이터베이스 쿼리 결과를 매번 다시 조회하고 직렬화하는 데 서버 리소스를 낭비하고 있다고 느낀 적 있나요? 특히 복잡한 집계 쿼리나 조인이 많은 쿼리는 실행 시간이 수 초씩 걸리는데, 동일한 결과를 요청하는 사용자들이 많으면 데이터베이스에 엄청난 부하가 발생합니다.
이런 문제는 읽기가 많은 서비스에서 데이터베이스가 병목이 되는 주요 원인입니다. 동일한 쿼리를 반복 실행하면 CPU, 메모리, 디스크 I/O가 모두 낭비되고, 데이터베이스 커넥션 풀도 고갈되어 다른 쿼리들도 대기하게 됩니다.
결과적으로 전체 서비스의 응답 시간이 느려집니다. 바로 이럴 때 필요한 것이 서버 사이드 캐싱입니다.
onSend Hook에서 응답 결과를 Redis 같은 인메모리 저장소에 저장하면, 동일한 요청에 대해 데이터베이스를 거치지 않고 즉시 응답할 수 있습니다.
개요
간단히 말해서, 서버 사이드 캐싱은 API 응답 결과를 서버의 메모리나 Redis에 저장했다가 동일한 요청이 오면 즉시 반환하는 전략입니다. 이 전략이 필요한 이유는 데이터베이스 부하를 획기적으로 줄이고 응답 속도를 밀리초 단위로 단축할 수 있기 때문입니다.
예를 들어, 인기 상품 목록이나 베스트셀러 같은 데이터는 수많은 사용자가 동시에 조회하는데, 한 번 계산한 결과를 캐시에 저장하면 수백 번의 데이터베이스 쿼리를 한 번의 Redis 조회로 대체할 수 있습니다. 기존에는 각 요청마다 데이터베이스에서 데이터를 조회하고 처리해야 했다면, 이제는 캐시에서 직렬화된 응답을 바로 가져와 전송할 수 있습니다.
이 전략의 핵심 특징은 첫째, 응답 시간을 수십 밀리초에서 1-2 밀리초로 단축한다는 점, 둘째, 데이터베이스 부하를 80-90% 이상 감소시킨다는 점, 셋째, TTL(Time To Live)을 설정하여 자동으로 만료되도록 관리할 수 있다는 점입니다. 이러한 특징들이 고성능 API 서버의 필수 아키텍처 패턴이 됩니다.
코드 예제
// Redis를 활용한 응답 캐싱 onSend Hook
const redis = require('redis');
const client = redis.createClient();
await client.connect();
fastify.addHook('onSend', async (request, reply, payload) => {
// GET 요청만 캐싱
if (request.method === 'GET' && reply.statusCode === 200) {
// 캐시 키 생성 (URL + 쿼리 파라미터)
const cacheKey = `cache:${request.url}`;
// 응답을 Redis에 저장 (TTL: 60초)
await client.setEx(cacheKey, 60, payload.toString());
// Cache-Control 헤더 설정
reply.header('Cache-Control', 'public, max-age=60');
reply.header('X-Cache', 'MISS'); // 캐시 미스 표시
}
return payload;
});
// onRequest Hook에서 캐시 확인
fastify.addHook('onRequest', async (request, reply) => {
if (request.method === 'GET') {
const cacheKey = `cache:${request.url}`;
const cached = await client.get(cacheKey);
if (cached) {
// 캐시 히트 - 즉시 응답
reply.header('Content-Type', 'application/json');
reply.header('X-Cache', 'HIT');
reply.send(cached);
}
}
});
설명
이것이 하는 일: 이 코드는 두 개의 Hook을 조합하여 완전한 캐싱 시스템을 구현합니다. onRequest에서 캐시를 확인하고, 캐시가 있으면 즉시 반환하며, 없으면 정상 처리 후 onSend에서 결과를 캐시에 저장합니다.
첫 번째로, onRequest Hook에서 요청이 들어오면 가장 먼저 Redis를 확인합니다. 캐시 키는 request.url을 사용하는데, 이는 경로와 쿼리 파라미터를 모두 포함하므로 /api/products?page=1과 /api/products?page=2가 별도로 캐싱됩니다.
Redis에서 get() 메서드로 캐시를 조회하여 데이터가 있으면 reply.send()로 즉시 응답하고 요청 처리를 종료합니다. 이 경우 라우트 핸들러나 데이터베이스가 전혀 호출되지 않습니다.
두 번째로, 캐시가 없으면(캐시 미스) 요청이 정상적으로 라우트 핸들러로 전달되어 데이터베이스를 조회하고 응답을 생성합니다. 이때 onSend Hook이 실행되어 생성된 응답을 Redis에 저장합니다.
setEx() 메서드는 값을 저장하면서 동시에 TTL(60초)을 설정하는데, 60초 후에는 자동으로 삭제되어 오래된 데이터가 캐시에 남지 않습니다. 세 번째로, X-Cache 헤더를 추가하여 디버깅과 모니터링을 쉽게 합니다.
HIT는 캐시에서 응답했다는 의미이고, MISS는 데이터베이스를 조회했다는 의미입니다. 개발 중에는 이 헤더를 보고 캐싱이 제대로 작동하는지 확인할 수 있고, 프로덕션에서는 캐시 히트율을 모니터링하여 캐시 전략을 최적화할 수 있습니다.
여러분이 이 코드를 사용하면 캐시 히트 시 응답 시간이 50ms에서 2ms로 단축되고, 데이터베이스 쿼리가 완전히 제거되어 DB 커넥션도 절약됩니다. 트래픽이 많은 엔드포인트의 경우 캐시 히트율이 90%를 넘어가면 데이터베이스 부하가 1/10로 줄어들어 더 많은 사용자를 동일한 인프라로 처리할 수 있습니다.
또한 TTL 관리가 자동으로 되어 개발자가 캐시 무효화를 일일이 신경 쓰지 않아도 됩니다.
실전 팁
💡 캐시 키에 사용자 정보를 포함하세요. 개인화된 응답은 cache:${userId}:${request.url} 형식으로 사용자별로 별도 캐시를 유지해야 합니다.
💡 TTL을 데이터 특성에 맞게 설정하세요. 자주 변경되는 데이터는 10-30초, 거의 변경 안 되는 데이터는 5-10분으로 설정하면 효율적입니다.
💡 캐시 무효화 전략을 구현하세요. 데이터가 변경될 때(POST, PUT, DELETE) 관련된 캐시 키를 client.del()로 삭제하여 항상 최신 데이터를 제공하세요.
💡 Redis 연결 실패를 대비하여 try-catch로 감싸세요. 캐시 시스템이 다운되어도 원래 로직은 정상 동작하도록 fallback을 구현하는 것이 중요합니다.
💡 압축과 함께 사용하세요. 대용량 응답은 압축한 후 캐시에 저장하면 Redis 메모리를 절약할 수 있습니다. 단, 압축 해제 비용도 고려해야 합니다.
5. 조건부 압축 최적화 - Content-Length 기반 압축 결정
시작하며
여러분이 모든 응답을 무조건 압축하다가 오히려 성능이 저하되는 경우를 경험해본 적 있나요? 특히 이미 압축된 이미지나 작은 JSON 응답을 압축하면 CPU만 낭비하고 실제 크기는 오히려 증가하는 역효과가 발생합니다.
이런 문제는 압축을 "무조건 좋은 것"으로 생각하여 모든 응답에 적용할 때 발생합니다. 압축과 압축 해제에는 CPU 비용이 들고, 작은 데이터나 이미 압축된 데이터는 압축 이득보다 오버헤드가 더 큽니다.
또한 압축 알고리즘마다 특성이 다르므로 상황에 맞게 선택해야 합니다. 바로 이럴 때 필요한 것이 조건부 압축 전략입니다.
onSend Hook에서 응답의 Content-Type, Content-Length, 클라이언트 지원 여부 등을 종합적으로 판단하여 압축 여부와 알고리즘을 동적으로 결정할 수 있습니다.
개요
간단히 말해서, 조건부 압축은 응답의 특성(크기, 타입, 클라이언트)을 분석하여 압축이 실제로 이득이 되는 경우에만 선택적으로 적용하는 전략입니다. 이 전략이 필요한 이유는 압축 비용과 이득 사이의 균형을 맞춰 전체적인 시스템 효율을 최대화하기 위해서입니다.
예를 들어, 100바이트짜리 JSON을 압축하면 헤더 오버헤드 때문에 오히려 크기가 커지고, JPEG 이미지는 이미 압축되어 있어 재압축해도 거의 줄어들지 않습니다. 반면 대용량 텍스트 데이터는 70-80% 압축되므로 반드시 압축해야 합니다.
기존에는 모든 응답에 동일한 압축 정책을 적용했다면, 이제는 각 응답의 특성을 실시간으로 분석하여 최적의 결정을 내릴 수 있습니다. 이 전략의 핵심 특징은 첫째, CPU 사용량과 네트워크 사용량의 트레이드오프를 동적으로 최적화한다는 점, 둘째, 다양한 압축 알고리즘(gzip, brotli, deflate) 중 최적을 선택할 수 있다는 점, 셋째, 클라이언트별로 다른 전략을 적용할 수 있다는 점입니다.
이러한 특징들이 실제 프로덕션 환경에서 압축의 효과를 극대화합니다.
코드 예제
// 조건부 압축을 위한 고급 onSend Hook
const zlib = require('zlib');
// 압축하면 안 되는 MIME 타입들
const NO_COMPRESS_TYPES = new Set([
'image/', 'video/', 'audio/', 'application/zip',
'application/gzip', 'application/x-rar'
]);
fastify.addHook('onSend', async (request, reply, payload) => {
const contentType = reply.getHeader('content-type') || '';
const acceptEncoding = request.headers['accept-encoding'] || '';
// 압축 불가 타입 체크
if (NO_COMPRESS_TYPES.some(type => contentType.includes(type))) {
return payload;
}
// 최소 크기 체크 (1KB)
if (!(payload instanceof Buffer) || payload.length < 1024) {
return payload;
}
// Brotli 우선 (더 높은 압축률)
if (acceptEncoding.includes('br')) {
reply.header('content-encoding', 'br');
return zlib.brotliCompressSync(payload, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 }
});
}
// Gzip 대체
if (acceptEncoding.includes('gzip')) {
reply.header('content-encoding', 'gzip');
return zlib.gzipSync(payload, { level: zlib.constants.Z_BEST_SPEED });
}
return payload;
});
설명
이것이 하는 일: 이 코드는 다단계 조건 검사를 통해 압축이 실제로 이득이 되는지 판단하고, 클라이언트가 지원하는 압축 알고리즘 중 가장 효율적인 것을 선택하여 적용합니다. 첫 번째로, 압축하면 안 되는 콘텐츠 타입을 미리 정의한 Set에서 체크합니다.
image/, video/, audio/로 시작하는 MIME 타입과 이미 압축된 포맷(zip, gzip, rar)은 재압축해도 효과가 없거나 오히려 크기가 증가할 수 있습니다. Set.some()을 사용하여 빠르게 체크하고, 해당되면 압축 없이 원본을 반환합니다.
두 번째로, 응답 크기를 체크합니다. payload가 Buffer인지 확인하고(스트림이나 문자열은 제외), 크기가 1024바이트(1KB) 미만이면 압축하지 않습니다.
작은 데이터는 압축 헤더(gzip 기준 최소 18바이트)와 CPU 비용이 압축 이득보다 크기 때문입니다. 이 임계값은 테스트를 통해 최적값을 찾아야 하는데, 보통 512바이트에서 2KB 사이가 적절합니다.
세 번째로, Accept-Encoding 헤더를 파싱하여 클라이언트가 지원하는 압축 알고리즘을 확인합니다. Brotli(br)를 우선적으로 선택하는 이유는 gzip보다 15-20% 더 높은 압축률을 제공하기 때문입니다.
다만 Brotli는 압축 속도가 느릴 수 있으므로 BROTLI_PARAM_QUALITY를 4로 설정하여(최대 11) 속도와 압축률의 균형을 맞춥니다. Brotli를 지원하지 않는 클라이언트는 gzip으로 fallback하며, 둘 다 지원하지 않으면 압축하지 않고 원본을 반환합니다.
여러분이 이 코드를 사용하면 불필요한 압축으로 인한 CPU 낭비를 방지하면서도 효과적인 응답에는 최적의 압축을 적용할 수 있습니다. 예를 들어, 텍스트 기반 API는 평균 60-70% 압축되지만, 이미지 API는 압축을 건너뛰어 CPU를 절약합니다.
또한 최신 브라우저에는 Brotli를 제공하여 더 작은 응답을 전송하고, 오래된 클라이언트에는 gzip으로 호환성을 유지합니다. 결과적으로 전체 서버의 CPU 사용률과 네트워크 대역폭이 모두 최적화됩니다.
실전 팁
💡 압축 레벨을 A/B 테스트하세요. Brotli quality 4-6, gzip level 1-6 사이에서 응답 시간과 압축률을 측정하여 서비스에 맞는 최적값을 찾으세요.
💡 Vary: Accept-Encoding 헤더를 추가하세요. CDN이나 프록시가 압축 버전과 비압축 버전을 별도로 캐시하도록 하여 잘못된 응답을 방지합니다.
💡 압축 통계를 로깅하세요. 압축 전후 크기, 압축 시간, 알고리즘 선택 등을 기록하면 압축 전략을 데이터 기반으로 개선할 수 있습니다.
💡 동적 임계값을 고려하세요. 서버 부하가 높을 때는 임계값을 높여(예: 5KB) 압축을 덜 하고, 여유 있을 때는 낮춰(예: 512바이트) 더 많이 압축하는 동적 전략도 가능합니다.
💡 Content-Length 헤더를 정확하게 설정하세요. 압축 후에는 reply.header('content-length', compressedData.length)로 업데이트해야 클라이언트가 진행률을 올바르게 표시할 수 있습니다.
6. 응답 변환 파이프라인 - 민감 정보 필터링과 포맷 통일
시작하며
여러분이 API 응답에 실수로 비밀번호 해시, 내부 ID, 이메일 같은 민감한 정보가 포함되어 유출된 적이 있거나, 각 엔드포인트마다 응답 형식이 달라 프론트엔드 개발자들이 불편을 겪는 상황을 본 적 있나요? 수십 개의 라우트 핸들러에서 각각 필터링 로직을 작성하면 누락되기 쉽고 유지보수도 어렵습니다.
이런 문제는 보안과 일관성 측면에서 심각한 위험을 초래합니다. 민감 정보 유출은 GDPR 같은 개인정보 보호 규정 위반으로 이어질 수 있고, 일관되지 않은 응답 형식은 클라이언트 코드를 복잡하게 만들어 버그를 유발합니다.
또한 각 핸들러에 중복 코드가 분산되어 있으면 정책 변경 시 모든 코드를 수정해야 합니다. 바로 이럴 때 필요한 것이 응답 변환 파이프라인입니다.
onSend Hook에서 모든 응답을 검사하고 민감 정보를 자동으로 필터링하며, 일관된 포맷으로 변환할 수 있습니다.
개요
간단히 말해서, 응답 변환 파이프라인은 onSend Hook에서 모든 응답 데이터를 파싱하고 변환하여 보안과 일관성을 보장하는 중앙집중식 처리 메커니즘입니다. 이 메커니즘이 필요한 이유는 보안 정책과 데이터 포맷을 한 곳에서 관리하여 실수를 방지하고 유지보수를 쉽게 하기 때문입니다.
예를 들어, 사용자 객체가 응답에 포함될 때마다 password, passwordHash, internalId 같은 필드를 자동으로 제거하면 개발자가 일일이 신경 쓰지 않아도 안전합니다. 또한 모든 성공 응답을 { success: true, data: ...
} 형식으로 통일하면 클라이언트 코드가 단순해집니다. 기존에는 각 라우트 핸들러에서 delete user.password, 또는 별도의 직렬화 함수를 호출해야 했다면, 이제는 응답을 반환만 하면 자동으로 처리됩니다.
이 메커니즘의 핵심 특징은 첫째, 보안 정책을 코드 전체에 자동으로 적용할 수 있다는 점, 둘째, 응답 포맷을 일관되게 유지할 수 있다는 점, 셋째, 정책 변경 시 한 곳만 수정하면 된다는 점입니다. 이러한 특징들이 안전하고 유지보수하기 쉬운 API를 만드는 기반이 됩니다.
코드 예제
// 응답 변환 파이프라인 구현
const SENSITIVE_FIELDS = ['password', 'passwordHash', 'token', 'secret', 'apiKey'];
// 재귀적으로 민감 필드 제거
function sanitizeObject(obj) {
if (Array.isArray(obj)) {
return obj.map(item => sanitizeObject(item));
}
if (obj && typeof obj === 'object') {
const cleaned = {};
for (const [key, value] of Object.entries(obj)) {
// 민감 필드는 제외
if (!SENSITIVE_FIELDS.includes(key)) {
cleaned[key] = sanitizeObject(value);
}
}
return cleaned;
}
return obj;
}
fastify.addHook('onSend', async (request, reply, payload) => {
// JSON 응답만 처리
if (reply.getHeader('content-type')?.includes('application/json')) {
try {
const data = JSON.parse(payload.toString());
// 민감 정보 필터링
const sanitized = sanitizeObject(data);
// 통일된 포맷으로 래핑
const formatted = {
success: reply.statusCode < 400,
timestamp: new Date().toISOString(),
data: sanitized
};
return JSON.stringify(formatted);
} catch (err) {
// JSON 파싱 실패 시 원본 반환
return payload;
}
}
return payload;
});
설명
이것이 하는 일: 이 코드는 JSON 응답을 파싱하여 민감한 필드를 재귀적으로 제거하고, 모든 응답을 일관된 구조로 래핑하여 보안과 사용성을 동시에 향상시킵니다. 첫 번째로, SENSITIVE_FIELDS 배열에 절대 클라이언트로 전송되면 안 되는 필드명을 정의합니다.
password, passwordHash는 보안 위협이고, token이나 apiKey는 인증 정보이므로 반드시 제거해야 합니다. 이 목록은 프로젝트의 보안 정책에 따라 확장할 수 있으며, internalId, createdBy 같은 내부 메타데이터도 추가할 수 있습니다.
두 번째로, sanitizeObject 함수는 재귀적으로 객체를 순회하며 민감 필드를 제거합니다. 배열이면 각 요소를 재귀 처리하고, 객체면 각 속성을 검사하여 민감 필드가 아닌 것만 새 객체에 복사합니다.
이 재귀 구조 덕분에 중첩된 객체(예: user.profile.credentials.password)도 안전하게 처리됩니다. 원시 값(문자열, 숫자)은 그대로 반환합니다.
세 번째로, onSend Hook에서 JSON 응답만 선택적으로 처리합니다. payload를 문자열로 변환하고 JSON.parse로 파싱한 후, sanitizeObject로 민감 정보를 제거합니다.
그 다음 모든 응답을 { success, timestamp, data } 구조로 래핑합니다. success는 상태 코드가 400 미만이면 true, data는 정화된 응답 데이터, timestamp는 서버 시간입니다.
이렇게 하면 클라이언트는 항상 동일한 구조를 예상할 수 있어 에러 처리가 단순해집니다. 마지막으로 JSON.stringify로 다시 직렬화하여 반환합니다.
여러분이 이 코드를 사용하면 개발자가 실수로 민감 정보를 반환해도 자동으로 차단되어 보안 사고를 방지할 수 있습니다. 예를 들어, 새로운 개발자가 User 엔티티를 그대로 반환해도 password 필드는 자동으로 제거됩니다.
또한 프론트엔드 개발자는 항상 response.data로 데이터에 접근하고 response.success로 성공 여부를 체크할 수 있어 코드가 일관되고 버그가 줄어듭니다. 보안 감사나 컴플라이언스 검토 시에도 중앙집중식 필터링을 증명하기 쉽습니다.
실전 팁
💡 화이트리스트 방식도 고려하세요. 민감 필드를 제거하는 대신 허용된 필드만 포함하는 방식이 더 안전할 수 있습니다. 특히 금융이나 의료 시스템에서는 화이트리스트를 권장합니다.
💡 환경별로 다른 정책을 적용하세요. 개발 환경에서는 디버깅을 위해 더 많은 정보를 노출하고, 프로덕션에서는 엄격하게 필터링하도록 process.env.NODE_ENV로 분기할 수 있습니다.
💡 성능을 위해 JSON.parse/stringify 대신 스트리밍 파서를 사용하세요. 대용량 응답은 clarinet 같은 스트리밍 JSON 파서로 처리하면 메모리를 절약할 수 있습니다.
💡 로깅을 추가하여 필터링된 필드를 추적하세요. 민감 필드가 발견될 때마다 로그를 남기면 어떤 엔드포인트에서 민감 정보가 반환되려 했는지 파악하여 근본 원인을 수정할 수 있습니다.
💡 스키마 검증과 결합하세요. JSON Schema나 Zod로 응답 스키마를 정의하고 검증하면 예상치 못한 필드가 포함되는 것을 방지할 수 있습니다.
7. 압축률 모니터링과 적응형 압축 - 실시간 최적화 전략
시작하며
여러분이 압축 설정을 한 번 정하고 그대로 사용하면서 실제로 얼마나 효과적인지, 어떤 엔드포인트에서 문제가 있는지 전혀 모르는 상황을 겪고 있지 않나요? 압축률이 낮은 응답에 불필요하게 CPU를 소비하거나, 반대로 압축률이 높은 응답에 낮은 레벨을 적용하여 네트워크를 낭비할 수 있습니다.
이런 문제는 데이터 기반 최적화가 부족하여 발생합니다. 압축 전후 크기, CPU 사용 시간, 엔드포인트별 압축률 등을 측정하지 않으면 최적 설정을 찾을 수 없고, 시스템 상황 변화에도 대응할 수 없습니다.
결과적으로 압축의 잠재력을 충분히 활용하지 못합니다. 바로 이럴 때 필요한 것이 압축률 모니터링과 적응형 압축 전략입니다.
onSend Hook에서 압축 효과를 측정하고 통계를 수집하여, 이를 기반으로 압축 레벨과 알고리즘을 동적으로 조정할 수 있습니다.
개요
간단히 말해서, 적응형 압축은 압축 성능을 실시간으로 모니터링하고 그 데이터를 기반으로 압축 설정을 자동으로 조정하여 최적의 효율을 유지하는 전략입니다. 이 전략이 필요한 이유는 고정된 설정으로는 다양한 응답 특성과 변화하는 서버 부하에 최적으로 대응할 수 없기 때문입니다.
예를 들어, 어떤 엔드포인트는 텍스트 데이터라 압축률이 80%인데 다른 엔드포인트는 이미 압축된 데이터라 5%밖에 안 될 수 있습니다. 또한 서버 부하가 높을 때는 압축 레벨을 낮춰 CPU를 절약하고, 여유 있을 때는 높여 네트워크를 절약하는 것이 효율적입니다.
기존에는 개발자가 추측과 경험으로 압축 설정을 정했다면, 이제는 실제 데이터를 수집하고 분석하여 과학적으로 최적화할 수 있습니다. 이 전략의 핵심 특징은 첫째, 압축 효과를 정량적으로 측정하여 가시화한다는 점, 둘째, 엔드포인트별로 다른 압축 전략을 적용할 수 있다는 점, 셋째, 시스템 상황에 따라 자동으로 조정되어 항상 최적 상태를 유지한다는 점입니다.
이러한 특징들이 압축을 단순한 기능에서 지능형 최적화 시스템으로 발전시킵니다.
코드 예제
// 압축률 모니터링 및 적응형 압축 구현
const zlib = require('zlib');
const os = require('os');
// 엔드포인트별 압축 통계
const compressionStats = new Map();
fastify.addHook('onSend', async (request, reply, payload) => {
if (!(payload instanceof Buffer) || payload.length < 1024) {
return payload;
}
const route = request.routeOptions?.url || request.url;
const originalSize = payload.length;
const cpuLoadAvg = os.loadavg()[0]; // 1분 평균 CPU 로드
// CPU 부하에 따라 압축 레벨 조정
let compressionLevel;
if (cpuLoadAvg > 0.8) {
compressionLevel = zlib.constants.Z_BEST_SPEED; // 부하 높음: 빠른 압축
} else if (cpuLoadAvg > 0.5) {
compressionLevel = zlib.constants.Z_DEFAULT_COMPRESSION; // 중간
} else {
compressionLevel = zlib.constants.Z_BEST_COMPRESSION; // 여유: 최대 압축
}
const startTime = process.hrtime.bigint();
const compressed = zlib.gzipSync(payload, { level: compressionLevel });
const compressionTime = Number(process.hrtime.bigint() - startTime) / 1e6; // ms
const compressedSize = compressed.length;
const ratio = ((originalSize - compressedSize) / originalSize * 100).toFixed(2);
// 통계 업데이트
const stats = compressionStats.get(route) || { count: 0, totalRatio: 0, avgTime: 0 };
stats.count++;
stats.totalRatio += parseFloat(ratio);
stats.avgTime = (stats.avgTime * (stats.count - 1) + compressionTime) / stats.count;
compressionStats.set(route, stats);
// 헤더에 압축 정보 추가 (디버깅용)
reply.header('X-Compression-Ratio', `${ratio}%`);
reply.header('X-Compression-Time', `${compressionTime.toFixed(2)}ms`);
reply.header('content-encoding', 'gzip');
return compressed;
});
// 통계 조회 엔드포인트
fastify.get('/api/compression-stats', async (request, reply) => {
const stats = {};
for (const [route, data] of compressionStats.entries()) {
stats[route] = {
requests: data.count,
avgCompressionRatio: (data.totalRatio / data.count).toFixed(2) + '%',
avgCompressionTime: data.avgTime.toFixed(2) + 'ms'
};
}
return stats;
});
설명
이것이 하는 일: 이 코드는 시스템의 CPU 부하를 실시간으로 모니터링하여 압축 레벨을 자동으로 조정하고, 각 엔드포인트의 압축 성능 데이터를 수집하여 분석 가능하게 만듭니다. 첫 번째로, os.loadavg()를 사용하여 현재 CPU 부하를 확인합니다.
loadavg()[0]은 최근 1분간의 평균 CPU 로드를 반환하는데, 이 값이 0.8 이상이면 시스템이 과부하 상태입니다. 이때는 Z_BEST_SPEED(레벨 1)를 사용하여 압축 시간을 최소화하고, 0.5-0.8 사이면 Z_DEFAULT_COMPRESSION(레벨 6), 0.5 미만이면 Z_BEST_COMPRESSION(레벨 9)을 사용하여 압축률을 최대화합니다.
이렇게 동적으로 조정하면 서버가 바쁠 때는 응답성을 유지하고, 여유 있을 때는 네트워크를 절약할 수 있습니다. 두 번째로, process.hrtime.bigint()를 사용하여 나노초 단위로 압축 시간을 정확하게 측정합니다.
압축 전후로 시간을 기록하고 차이를 계산한 후 1,000,000으로 나누어 밀리초로 변환합니다. 이 시간 데이터는 압축이 실제로 얼마나 CPU를 사용하는지 보여주며, 타임아웃 설정이나 성능 튜닝에 활용할 수 있습니다.
세 번째로, 압축 전후 크기를 비교하여 압축률을 계산하고, 엔드포인트별로 통계를 Map에 축적합니다. 각 엔드포인트마다 요청 수, 평균 압축률, 평균 압축 시간을 추적하여, 어떤 API가 압축에 적합한지, 어떤 API가 압축 효과가 낮은지 파악할 수 있습니다.
X-Compression-Ratio와 X-Compression-Time 헤더를 추가하여 개발자가 개별 요청의 압축 효과를 즉시 확인할 수 있게 합니다. 네 번째로, /api/compression-stats 엔드포인트를 제공하여 축적된 통계를 조회할 수 있게 합니다.
이 데이터를 Grafana 같은 모니터링 도구와 연동하면 압축 성능을 실시간 대시보드로 시각화할 수 있습니다. 여러분이 이 코드를 사용하면 압축이 "블랙박스"가 아닌 투명하고 제어 가능한 시스템이 됩니다.
예를 들어, /api/large-data 엔드포인트의 평균 압축률이 75%인 것을 발견하면 이 엔드포인트에는 항상 높은 압축 레벨을 적용하도록 정책을 수정할 수 있습니다. 반대로 /api/images의 압축률이 5%라면 압축을 아예 비활성화하여 CPU를 절약할 수 있습니다.
또한 트래픽 패턴에 따라 압축 전략이 자동으로 조정되어 서버가 항상 최적 상태로 운영됩니다.
실전 팁
💡 통계를 외부 저장소에 저장하세요. Map은 서버 재시작 시 사라지므로, Redis나 TimeSeries DB에 주기적으로 저장하여 장기 추세를 분석하세요.
💡 압축률 임계값을 설정하세요. 압축률이 10% 미만인 엔드포인트는 자동으로 압축을 비활성화하는 규칙을 추가하면 불필요한 CPU 사용을 방지할 수 있습니다.
💡 A/B 테스트를 자동화하세요. 동일한 엔드포인트에 대해 다른 압축 레벨을 무작위로 적용하고 성능을 비교하여 최적값을 찾을 수 있습니다.
💡 알람을 설정하세요. 평균 압축 시간이 50ms를 초과하거나 압축률이 급격히 변하면 알림을 보내 문제를 조기에 발견하세요.
💡 Content-Type별 통계도 수집하세요. JSON, HTML, CSV 등 타입별로 압축 효과가 다르므로 타입별 통계를 분석하면 더 세밀한 최적화가 가능합니다.
8. 에러 응답 처리와 로깅 - onSend에서 에러 추적하기
시작하며
여러분이 프로덕션 환경에서 에러가 발생했는데 로그에 충분한 정보가 없어서 원인을 파악하는 데 몇 시간씩 걸린 경험이 있나요? 특히 간헐적으로 발생하는 500 에러나 응답 직렬화 실패 같은 문제는 재현하기 어려워 디버깅이 매우 힘듭니다.
이런 문제는 에러 발생 시 충분한 컨텍스트 정보를 수집하지 않아서 발생합니다. 일반적인 로깅은 요청 정보만 기록하고 응답 내용은 놓치는 경우가 많아서, 어떤 데이터가 잘못되었는지, 왜 직렬화에 실패했는지 알 수 없습니다.
또한 에러가 클라이언트에 그대로 노출되면 민감한 내부 정보가 유출될 수 있습니다. 바로 이럴 때 필요한 것이 onSend Hook에서의 에러 처리와 로깅입니다.
모든 응답이 전송되기 전에 에러를 감지하고, 상세한 정보를 안전하게 로깅하며, 클라이언트에는 적절한 에러 메시지만 전송할 수 있습니다.
개요
간단히 말해서, onSend에서의 에러 처리는 응답 전송 직전에 발생할 수 있는 모든 문제를 포착하여 로깅하고, 클라이언트에는 안전하고 일관된 에러 응답을 제공하는 전략입니다. 이 전략이 필요한 이유는 응답 처리 단계에서 발생하는 에러가 놓치기 쉬운 동시에 매우 중요하기 때문입니다.
예를 들어, 순환 참조로 인한 JSON 직렬화 실패, 압축 중 메모리 부족, 캐시 저장 실패 등은 라우트 핸들러에서는 감지할 수 없고 onSend에서만 잡을 수 있습니다. 이런 에러를 제대로 처리하지 않으면 클라이언트는 알 수 없는 에러를 받거나 연결이 끊어집니다.
기존에는 에러가 발생하면 기본 에러 핸들러가 스택 트레이스를 그대로 노출했다면, 이제는 에러를 분류하고 안전한 메시지로 변환하며 상세 정보는 서버에만 기록할 수 있습니다. 이 전략의 핵심 특징은 첫째, 응답 처리 단계의 모든 에러를 중앙에서 처리한다는 점, 둘째, 클라이언트에는 보안을 유지하면서 유용한 정보를 제공한다는 점, 셋째, 디버깅에 필요한 모든 컨텍스트를 로그에 기록한다는 점입니다.
이러한 특징들이 안정적이고 디버깅 가능한 프로덕션 시스템을 만듭니다.
코드 예제
// 에러 처리 및 로깅을 위한 onSend Hook
const winston = require('winston');
// 구조화된 로거 설정
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
fastify.addHook('onSend', async (request, reply, payload) => {
try {
// 에러 응답 감지 (4xx, 5xx)
if (reply.statusCode >= 400) {
let errorData;
try {
errorData = JSON.parse(payload.toString());
} catch {
errorData = { message: payload.toString() };
}
// 상세 에러 로깅
logger.error('Error response', {
method: request.method,
url: request.url,
statusCode: reply.statusCode,
errorMessage: errorData.message,
errorStack: errorData.stack,
requestId: request.id,
userId: request.user?.id,
userAgent: request.headers['user-agent'],
ip: request.ip,
timestamp: new Date().toISOString()
});
// 클라이언트에는 안전한 메시지만 전송
if (reply.statusCode >= 500) {
return JSON.stringify({
success: false,
error: 'Internal Server Error',
message: 'An unexpected error occurred. Please try again later.',
requestId: request.id // 지원팀에 문의 시 사용
});
}
}
return payload;
} catch (err) {
// onSend 자체에서 에러 발생 시
logger.error('onSend hook failed', {
error: err.message,
stack: err.stack,
url: request.url
});
// 원본 payload 반환하여 서비스 중단 방지
return payload;
}
});
설명
이것이 하는 일: 이 코드는 모든 응답을 전송하기 전에 에러 여부를 확인하고, 에러인 경우 서버에는 상세 로그를 남기면서 클라이언트에는 안전하고 유용한 정보만 제공하는 이중 전략을 구현합니다. 첫 번째로, 응답 상태 코드를 체크하여 에러(400 이상)인지 판단합니다.
4xx 에러는 클라이언트 오류(잘못된 요청, 인증 실패 등)이고, 5xx 에러는 서버 오류입니다. 에러가 아니면 아무 처리도 하지 않고 payload를 그대로 반환하여 성공 응답에는 오버헤드가 없도록 합니다.
두 번째로, 에러 payload를 JSON으로 파싱하여 에러 정보를 추출합니다. try-catch로 감싸는 이유는 일부 에러가 JSON이 아닌 평문일 수 있기 때문입니다.
winston 로거를 사용하여 구조화된 로그를 기록하는데, 요청 메서드, URL, 상태 코드는 물론이고 에러 메시지, 스택 트레이스, 요청 ID, 사용자 ID, User-Agent, IP 주소, 타임스탬프 등 디버깅에 필요한 모든 컨텍스트를 포함합니다. 이렇게 상세한 로그가 있으면 나중에 문제를 재현하거나 패턴을 분석할 때 매우 유용합니다.
세 번째로, 5xx 서버 에러인 경우 클라이언트에 전송되는 메시지를 필터링합니다. 원본 에러에는 스택 트레이스, 데이터베이스 정보, 파일 경로 같은 민감한 내부 정보가 포함될 수 있으므로, 일반적인 "Internal Server Error" 메시지로 대체합니다.
다만 requestId는 포함하여 사용자가 지원팀에 문의할 때 특정 에러를 찾을 수 있게 합니다. 4xx 클라이언트 에러는 사용자가 수정할 수 있도록 원본 메시지를 유지합니다.
네 번째로, onSend Hook 자체에서 에러가 발생할 가능성에 대비하여 전체를 try-catch로 감쌉니다. 예를 들어, 로거가 다운되거나 JSON 직렬화에 실패하면 원본 payload를 반환하여 적어도 기본 응답은 전송되도록 합니다.
이 fallback 메커니즘이 없으면 onSend 에러로 인해 모든 요청이 실패할 수 있습니다. 여러분이 이 코드를 사용하면 프로덕션 환경에서 발생하는 모든 에러를 추적하고 분석할 수 있는 체계가 갖춰집니다.
예를 들어, 특정 사용자가 계속 500 에러를 겪는다면 userId로 필터링하여 그 사용자의 모든 요청을 조사할 수 있습니다. 또한 스택 트레이스를 Sentry나 Datadog 같은 에러 추적 도구로 전송하여 에러 발생 빈도, 영향받는 사용자 수 등을 실시간으로 모니터링할 수 있습니다.
동시에 클라이언트에는 민감한 정보가 노출되지 않아 보안을 유지할 수 있습니다.
실전 팁
💡 요청 ID를 생성하여 전체 요청 흐름을 추적하세요. onRequest에서 UUID를 생성하고 request.id에 저장하면 로그, 에러, 응답 헤더에서 동일한 ID로 연관 짓기 쉽습니다.
💡 에러를 분류하여 알림을 선택적으로 보내세요. 500 에러는 즉시 Slack 알림, 400 에러는 일정 빈도 이상일 때만 알림하는 식으로 노이즈를 줄이세요.
💡 개인정보를 로그에서 제거하세요. 비밀번호, 토큰, 이메일 등이 에러 메시지에 포함될 수 있으므로 정규식으로 마스킹하거나 제거하세요.
💡 로그 레벨을 적절히 사용하세요. 4xx는 warn, 5xx는 error, 정상 응답 중 특이사항은 info로 구분하면 중요한 로그를 빠르게 찾을 수 있습니다.
💡 샘플링을 고려하세요. 트래픽이 매우 많으면 모든 에러를 로깅하는 것이 부담될 수 있으므로, 동일한 에러는 1분에 1번만 로깅하는 식으로 샘플링하세요.