📖

스토리텔링 형식으로 업데이트되었습니다! 실무 사례와 함께 더 쉽게 이해할 수 있어요.

이미지 로딩 중...

API 캐싱과 성능 최적화 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 25. · 15 Views

API 캐싱과 성능 최적화 완벽 가이드

웹 서비스의 응답 속도를 획기적으로 개선하는 캐싱 전략과 성능 최적화 기법을 다룹니다. HTTP 캐싱부터 Redis, 데이터베이스 최적화, CDN까지 실무에서 바로 적용할 수 있는 핵심 기술을 초급자 눈높이에서 설명합니다.


목차

  1. HTTP_캐싱_헤더
  2. Redis를_활용한_응답_캐싱
  3. 데이터베이스_쿼리_최적화
  4. 페이지네이션_vs_커서_기반_페이징
  5. 응답_압축
  6. CDN_활용_전략

1. HTTP_캐싱_헤더

김개발 씨는 오늘도 회사에서 운영 중인 API 서버의 모니터링 화면을 보고 있었습니다. 그런데 이상한 점이 눈에 띄었습니다.

같은 사용자가 같은 데이터를 1초에 수십 번씩 요청하고 있었던 것입니다. "이거 서버 자원 낭비 아닌가요?" 김개발 씨의 질문에 박시니어 씨가 웃으며 대답했습니다.

"HTTP 캐싱 헤더를 설정하면 해결돼요."

HTTP 캐싱 헤더는 브라우저나 중간 프록시에게 "이 응답을 얼마나 오래 저장해도 되는지" 알려주는 약속입니다. 마치 우유팩에 유통기한을 적어두는 것과 같습니다.

Cache-Control은 캐시 정책을 정의하고, ETag는 콘텐츠의 고유 지문 역할을 합니다. 이 두 가지를 잘 활용하면 불필요한 네트워크 요청을 크게 줄일 수 있습니다.

다음 코드를 살펴봅시다.

// Express.js에서 HTTP 캐싱 헤더 설정하기
const express = require('express');
const crypto = require('crypto');
const app = express();

app.get('/api/products', (req, res) => {
  const products = getProductsFromDB();

  // ETag 생성: 데이터의 고유 지문
  const etag = crypto.createHash('md5')
    .update(JSON.stringify(products)).digest('hex');

  // 클라이언트가 보낸 ETag와 비교
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end(); // 변경 없음, 캐시 사용
  }

  // Cache-Control: 60초 동안 캐시 허용
  res.set('Cache-Control', 'public, max-age=60');
  res.set('ETag', etag);
  res.json(products);
});

김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 어느 날 서버 비용 청구서를 보고 깜짝 놀랐습니다.

트래픽 비용이 예상보다 3배나 많았기 때문입니다. 로그를 분석해보니 같은 사용자가 상품 목록 API를 페이지를 열 때마다 반복해서 호출하고 있었습니다.

"이 데이터는 1분에 한 번 정도만 바뀌는데, 매번 새로 요청할 필요가 있을까요?" 김개발 씨의 질문에 박시니어 씨가 고개를 끄덕였습니다. "바로 그 지점이에요.

HTTP 캐싱 헤더를 사용하면 브라우저가 알아서 캐시된 데이터를 재사용해요." 그렇다면 HTTP 캐싱이란 정확히 무엇일까요? 쉽게 비유하자면, HTTP 캐싱은 마치 냉장고에 반찬을 보관하는 것과 같습니다.

매 끼니마다 반찬을 새로 만들 필요 없이, 냉장고에서 꺼내 먹으면 되는 것처럼요. 브라우저도 서버로부터 받은 응답을 잠시 저장해두었다가, 같은 요청이 들어오면 저장된 데이터를 재사용합니다.

Cache-Control 헤더는 이 반찬의 유통기한을 정하는 역할을 합니다. max-age=60이라고 설정하면 "60초 동안은 이 데이터를 신선하다고 믿어도 돼"라는 의미입니다.

public은 CDN 같은 중간 캐시 서버도 이 응답을 저장해도 된다는 뜻이고, private은 오직 최종 사용자의 브라우저만 캐시할 수 있다는 뜻입니다. 하지만 유통기한만으로는 부족한 경우가 있습니다.

데이터가 실제로 변경되었는지 어떻게 알 수 있을까요? 바로 여기서 ETag가 등장합니다.

ETag는 응답 콘텐츠의 지문과 같습니다. 데이터를 해시 함수에 통과시켜 고유한 문자열을 만들어내는 것입니다.

마치 책의 ISBN처럼, 내용이 바뀌면 ETag도 바뀝니다. 브라우저는 캐시된 응답의 ETag를 기억해두었다가, 다음 요청 시 If-None-Match 헤더에 담아 보냅니다.

서버는 현재 데이터의 ETag와 비교해서, 같으면 304 Not Modified를 응답합니다. 이 응답에는 본문이 없으므로 네트워크 전송량이 크게 줄어듭니다.

위의 코드를 살펴보겠습니다. 먼저 crypto 모듈로 데이터의 MD5 해시를 생성하여 ETag로 사용합니다.

클라이언트가 보낸 if-none-match 헤더와 비교해서 같으면 304를 반환하고, 다르면 새 데이터와 함께 갱신된 ETag를 보내줍니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 뉴스 사이트의 메인 페이지를 생각해보세요. 헤드라인은 자주 바뀌지 않지만, 사용자들은 새로고침을 반복합니다.

Cache-Control로 30초 캐시를 설정하고 ETag를 함께 사용하면, 대부분의 요청이 304 응답으로 처리되어 서버 부하가 크게 줄어듭니다. 주의할 점도 있습니다.

로그인한 사용자별로 다른 데이터를 보여주는 API에는 public 캐시를 사용하면 안 됩니다. 다른 사용자의 개인정보가 캐시되어 노출될 수 있기 때문입니다.

이런 경우에는 private, no-store를 사용해야 합니다. 다시 김개발 씨 이야기로 돌아가봅시다.

HTTP 캐싱 헤더를 적용한 후, 서버 요청 수가 70% 가량 줄어들었습니다. 다음 달 청구서를 보고 김개발 씨는 뿌듯한 미소를 지었습니다.

실전 팁

💡 - 정적 자원(이미지, CSS, JS)에는 max-age를 길게(1년), 동적 API에는 짧게(수십 초) 설정하세요

  • 민감한 데이터는 반드시 Cache-Control: private, no-store를 사용하세요
  • ETag 생성 시 해시 계산 비용도 고려해야 합니다. 데이터가 크면 Last-Modified 헤더가 더 효율적일 수 있습니다

2. Redis를_활용한_응답_캐싱

HTTP 캐싱만으로는 한계가 있었습니다. 김개발 씨가 담당하는 API는 복잡한 데이터베이스 쿼리를 실행해야 했고, 매 요청마다 500ms가 걸렸습니다.

"첫 번째 요청은 어쩔 수 없다 치더라도, 같은 쿼리를 반복 실행하는 건 낭비 아닐까요?" 박시니어 씨가 화이트보드에 그림을 그리며 말했습니다. "서버 측 캐시가 필요한 시점이에요.

Redis를 소개해드릴게요."

Redis는 메모리 기반의 초고속 데이터 저장소입니다. 마치 책상 위에 자주 쓰는 서류를 올려두는 것처럼, 자주 조회하는 데이터를 메모리에 보관하여 순식간에 꺼내 쓸 수 있습니다.

데이터베이스 조회 결과를 Redis에 캐싱하면 반복 요청의 응답 시간을 밀리초 단위로 줄일 수 있습니다.

다음 코드를 살펴봅시다.

const Redis = require('ioredis');
const redis = new Redis();

// 캐시 우선 조회 패턴 (Cache-Aside Pattern)
async function getProductById(productId) {
  const cacheKey = `product:${productId}`;

  // 1단계: 캐시에서 먼저 조회
  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log('캐시 히트!');
    return JSON.parse(cached);
  }

  // 2단계: 캐시 미스 시 DB에서 조회
  console.log('캐시 미스, DB 조회 중...');
  const product = await db.query('SELECT * FROM products WHERE id = ?', [productId]);

  // 3단계: 결과를 캐시에 저장 (TTL: 5분)
  await redis.setex(cacheKey, 300, JSON.stringify(product));

  return product;
}

김개발 씨는 상품 상세 API의 응답 시간 그래프를 보며 한숨을 쉬었습니다. 평균 500ms, 피크 시간대에는 2초까지 치솟았습니다.

데이터베이스가 버거워하고 있었던 것입니다. "HTTP 캐싱은 클라이언트 측에서 동작하잖아요.

서버 측에서도 캐싱을 할 수 있나요?" 김개발 씨의 질문에 박시니어 씨가 웃으며 대답했습니다. "물론이죠.

Redis라는 멋진 도구가 있어요." Redis란 무엇일까요? 비유하자면 Redis는 두뇌의 단기 기억 장치와 같습니다.

우리가 자주 사용하는 전화번호는 굳이 수첩을 뒤적이지 않아도 바로 떠오르죠. Redis도 마찬가지입니다.

자주 조회되는 데이터를 메모리에 올려두고, 요청이 오면 디스크를 거치지 않고 즉시 응답합니다. 기존 방식의 문제점을 살펴봅시다.

매 API 요청마다 데이터베이스에 쿼리를 실행하면, 데이터베이스 서버에 부하가 집중됩니다. 특히 복잡한 JOIN 연산이나 집계 쿼리는 수백 밀리초가 걸리기도 합니다.

100명이 동시에 같은 상품 페이지를 보면, 똑같은 쿼리가 100번 실행되는 셈입니다. Redis를 사용하면 이 문제가 해결됩니다.

첫 번째 요청에서 데이터베이스를 조회한 후, 그 결과를 Redis에 저장해둡니다. 이후 같은 요청이 들어오면 데이터베이스 대신 Redis에서 데이터를 가져옵니다.

Redis는 메모리에서 동작하므로 응답 시간이 1ms 미만입니다. 위 코드에서 사용한 패턴을 Cache-Aside 패턴이라고 부릅니다.

애플리케이션이 캐시를 옆에 두고(aside), 먼저 캐시를 확인한 뒤 없으면 원본 저장소에서 가져오는 방식입니다. 코드를 단계별로 살펴보겠습니다.

먼저 redis.get으로 캐시를 조회합니다. 데이터가 있으면 캐시 히트, JSON으로 파싱하여 바로 반환합니다.

없으면 캐시 미스, 데이터베이스에서 조회합니다. 마지막으로 setex 명령으로 결과를 캐시에 저장하는데, 두 번째 인자 300은 TTL(Time To Live)로 300초 후 자동 삭제됨을 의미합니다.

실무에서 주의할 점이 있습니다. 데이터가 변경되면 캐시도 갱신해야 합니다.

상품 정보를 수정했는데 캐시에 옛날 데이터가 남아있으면 사용자에게 잘못된 정보를 보여주게 됩니다. 이를 캐시 무효화라고 하며, 데이터 수정 시 해당 캐시 키를 삭제하거나 갱신해야 합니다.

또한 캐시 키 설계도 중요합니다. product:123처럼 의미 있는 네이밍 규칙을 정하고 팀 전체가 따라야 합니다.

그래야 나중에 특정 패턴의 캐시를 일괄 삭제하는 등의 운영이 가능해집니다. 김개발 씨는 Redis 캐싱을 적용한 후 API 응답 시간이 평균 20ms로 줄어든 것을 확인했습니다.

데이터베이스 CPU 사용률도 절반으로 떨어졌습니다. "이제야 숨통이 트이네요." 김개발 씨가 기쁜 표정으로 말했습니다.

실전 팁

💡 - TTL은 데이터 특성에 맞게 설정하세요. 자주 바뀌는 데이터는 짧게, 거의 안 바뀌는 데이터는 길게

  • 캐시 키에 버전 정보를 포함하면 배포 시 일괄 무효화가 쉬워집니다 (예: v1:product:123)
  • Redis 장애에 대비해 캐시 조회 실패 시 DB 직접 조회로 폴백하는 로직을 추가하세요

3. 데이터베이스_쿼리_최적화

캐싱으로 반복 요청은 빨라졌지만, 첫 번째 요청은 여전히 느렸습니다. 김개발 씨가 슬로우 쿼리 로그를 분석하다가 충격적인 사실을 발견했습니다.

단순해 보이는 쿼리 하나가 3초나 걸리고 있었던 것입니다. "쿼리 자체를 최적화해야 할 것 같아요." 박시니어 씨가 EXPLAIN 명령어를 실행하며 말했습니다.

"인덱스부터 확인해봅시다."

데이터베이스 쿼리 최적화는 같은 결과를 얻으면서도 더 빠르게 데이터를 조회하는 기술입니다. 마치 도서관에서 책을 찾을 때, 서가를 처음부터 끝까지 뒤지는 것보다 카탈로그를 활용하는 것이 빠른 것처럼요.

인덱스 설정, 쿼리 구조 개선, N+1 문제 해결 등을 통해 쿼리 성능을 극적으로 향상시킬 수 있습니다.

다음 코드를 살펴봅시다.

// Before: N+1 문제가 있는 비효율적인 코드
async function getOrdersWithProducts_bad() {
  const orders = await db.query('SELECT * FROM orders');
  // 주문 100개면 쿼리 101번 실행!
  for (const order of orders) {
    order.products = await db.query(
      'SELECT * FROM products WHERE order_id = ?', [order.id]
    );
  }
  return orders;
}

// After: JOIN으로 N+1 문제 해결
async function getOrdersWithProducts_good() {
  // 한 번의 쿼리로 모든 데이터 조회
  const results = await db.query(`
    SELECT o.*, p.id as product_id, p.name as product_name
    FROM orders o
    LEFT JOIN order_items oi ON o.id = oi.order_id
    LEFT JOIN products p ON oi.product_id = p.id
    WHERE o.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
  `);
  // 결과를 주문별로 그룹핑
  return groupByOrder(results);
}

김개발 씨는 주문 목록 API가 유독 느린 이유를 파악하지 못하고 있었습니다. 코드를 보면 단순한 SELECT 문인데, 왜 이렇게 오래 걸리는 걸까요?

박시니어 씨가 데이터베이스 모니터링 도구를 열었습니다. "여기 보세요.

이 API 한 번 호출에 쿼리가 101번 실행되고 있어요." 김개발 씨는 믿을 수 없다는 표정이었습니다. "101번이요?

어떻게 그렇게 되는 거죠?" 이것이 바로 악명 높은 N+1 문제입니다. 비유하자면 이런 상황입니다.

학급 학생 30명의 성적표를 받아오는데, 먼저 학생 목록을 가져오고(1번), 그다음 각 학생의 성적을 한 명씩 조회합니다(30번). 총 31번의 요청이 발생합니다.

만약 학생 목록과 성적을 한 번에 가져올 수 있다면 요청은 단 1번이면 됩니다. 위 코드의 Before 부분을 보세요.

orders를 먼저 조회하고, 반복문 안에서 각 주문의 상품을 개별 조회합니다. 주문이 100개면 1 + 100 = 101번의 쿼리가 실행됩니다.

데이터베이스 연결을 맺고 끊는 오버헤드만 해도 상당합니다. After 부분에서는 JOIN을 사용해 한 번의 쿼리로 모든 데이터를 가져옵니다.

쿼리 수가 101번에서 1번으로 줄어드니 성능이 극적으로 향상됩니다. 또 하나 중요한 것이 인덱스입니다.

인덱스가 없으면 데이터베이스는 전체 테이블을 처음부터 끝까지 훑어야 합니다. 이를 풀 테이블 스캔이라고 하는데, 데이터가 많을수록 치명적으로 느려집니다.

EXPLAIN 명령어로 쿼리 실행 계획을 확인할 수 있습니다. type 컬럼이 ALL이면 풀 테이블 스캔, ref나 range면 인덱스를 사용하고 있다는 뜻입니다.

WHERE 절이나 JOIN 조건에 자주 사용되는 컬럼에는 반드시 인덱스를 걸어주세요. 실무에서 흔히 저지르는 실수가 하나 더 있습니다.

SELECT *을 습관적으로 사용하는 것입니다. 필요한 컬럼만 명시하면 네트워크 전송량도 줄고, 커버링 인덱스를 활용할 가능성도 높아집니다.

김개발 씨는 N+1 문제를 해결하고 적절한 인덱스를 추가한 후, 주문 목록 API의 응답 시간이 3초에서 50ms로 줄어든 것을 확인했습니다. "쿼리 최적화의 위력이 이 정도인 줄 몰랐어요." 김개발 씨가 감탄했습니다.

실전 팁

💡 - ORM을 사용할 때는 eager loading 옵션을 활용해 N+1 문제를 예방하세요

  • 슬로우 쿼리 로그를 주기적으로 분석하고, EXPLAIN으로 실행 계획을 확인하세요
  • 인덱스는 읽기 성능을 높이지만 쓰기 성능을 낮추므로, 적절한 균형을 찾아야 합니다

4. 페이지네이션_vs_커서_기반_페이징

상품 목록 API에 페이지네이션을 구현하던 김개발 씨가 이상한 현상을 발견했습니다. 1페이지는 빠른데, 10000페이지로 가면 응답이 5초나 걸렸습니다.

"OFFSET이 커질수록 느려지는 건 당연한 거예요." 박시니어 씨가 설명을 시작했습니다. "대안이 있어요.

커서 기반 페이징이라고 들어보셨어요?"

페이지네이션은 대량의 데이터를 나눠서 보여주는 기법입니다. 전통적인 OFFSET 방식은 구현이 쉽지만 뒤쪽 페이지로 갈수록 느려집니다.

반면 커서 기반 페이징은 마지막으로 본 항목을 기준으로 다음 데이터를 조회하므로, 몇 번째 페이지든 일정한 속도를 유지합니다. 마치 책갈피를 꽂아두고 그 다음부터 읽는 것과 같습니다.

다음 코드를 살펴봅시다.

// OFFSET 방식: 페이지가 뒤로 갈수록 느려짐
async function getProductsOffset(page, limit = 20) {
  const offset = (page - 1) * limit;
  // OFFSET 10000이면 10000개를 읽고 버린 후 20개 반환
  return db.query(
    'SELECT * FROM products ORDER BY id DESC LIMIT ? OFFSET ?',
    [limit, offset]
  );
}

// 커서 기반: 항상 일정한 속도
async function getProductsCursor(cursor, limit = 20) {
  // 커서(마지막으로 본 상품 ID) 이후의 데이터만 조회
  const query = cursor
    ? 'SELECT * FROM products WHERE id < ? ORDER BY id DESC LIMIT ?'
    : 'SELECT * FROM products ORDER BY id DESC LIMIT ?';

  const params = cursor ? [cursor, limit] : [limit];
  const products = await db.query(query, params);

  // 다음 페이지를 위한 커서 반환
  const nextCursor = products.length ? products[products.length - 1].id : null;
  return { products, nextCursor };
}

김개발 씨는 쇼핑몰의 상품 목록 페이지를 개발하고 있었습니다. 상품이 100만 개가 넘다 보니, 당연히 페이지네이션이 필요했습니다.

OFFSET과 LIMIT을 사용해서 금방 구현했는데, 테스트 중 이상한 점을 발견했습니다. "1페이지는 50ms인데, 50000페이지는 왜 7초나 걸리죠?" 김개발 씨의 질문에 박시니어 씨가 설명했습니다.

"OFFSET의 동작 원리를 이해하면 당연한 결과예요." OFFSET의 동작 원리는 이렇습니다. OFFSET 100000, LIMIT 20이라고 하면, 데이터베이스는 처음부터 100020개의 행을 읽습니다.

그리고 앞의 100000개는 버리고 마지막 20개만 반환합니다. 읽기만 하고 버리는 데이터가 점점 늘어나니 당연히 느려질 수밖에 없습니다.

비유하자면 책의 500페이지를 읽으려고 1페이지부터 499페이지까지 한 장씩 넘기는 것과 같습니다. 책갈피가 있다면 바로 그 위치로 갈 수 있는데 말이죠.

커서 기반 페이징이 바로 이 책갈피 역할을 합니다. 마지막으로 본 항목의 ID를 기억해두고, "이 ID보다 작은 것 중에서 20개만 주세요"라고 요청합니다.

데이터베이스는 인덱스를 타고 정확히 그 위치로 이동해서 20개만 읽어옵니다. 1페이지든 100만 페이지든 속도가 동일합니다.

위 코드를 보면, cursor 파라미터가 있을 때는 WHERE id < ? 조건이 추가됩니다.

id 컬럼에 인덱스가 있다면 이 조회는 번개처럼 빠릅니다. 조회 결과의 마지막 항목 ID를 nextCursor로 반환해서 클라이언트가 다음 요청에 사용할 수 있게 합니다.

그렇다면 커서 기반이 항상 좋은 걸까요? 그렇지는 않습니다.

OFFSET 방식은 "37페이지로 바로 가기"가 가능하지만, 커서 방식은 순차적으로만 탐색할 수 있습니다. 게시판처럼 페이지 번호를 직접 클릭하는 UI에는 OFFSET이, 무한 스크롤이나 "더 보기" 버튼에는 커서 방식이 적합합니다.

또한 커서로 사용할 컬럼은 유일하고 정렬된 값이어야 합니다. created_at 같은 시간 컬럼은 중복될 수 있으므로, 보통 id와 조합해서 사용합니다.

김개발 씨는 무한 스크롤 UI를 사용하는 모바일 앱 API에 커서 기반 페이징을 적용했습니다. 어느 페이지든 50ms 이내에 응답하게 되었고, 사용자 경험이 크게 개선되었습니다.

실전 팁

💡 - 페이지 번호가 필요한 UI에는 OFFSET을, 무한 스크롤에는 커서 방식을 사용하세요

  • 커서는 보통 ID나 timestamp를 사용하며, 클라이언트에 노출해도 안전한 값이어야 합니다
  • OFFSET을 꼭 써야 한다면, 전체 페이지 수에 상한을 두어 너무 뒤쪽 페이지 접근을 막는 것도 방법입니다

5. 응답_압축

네트워크 모니터링을 하던 김개발 씨가 눈을 의심했습니다. API 응답 하나가 무려 2MB였습니다.

"JSON 데이터가 이렇게 클 수 있나요?" 김개발 씨의 질문에 박시니어 씨가 대답했습니다. "상품 1000개의 상세 정보니까요.

하지만 압축하면 200KB 정도로 줄일 수 있어요."

응답 압축은 서버가 보내는 데이터를 압축해서 네트워크 전송량을 줄이는 기술입니다. 마치 옷을 압축팩에 넣어 여행 가방 공간을 절약하는 것과 같습니다.

gzip은 가장 널리 사용되는 압축 방식이고, Brotli는 더 높은 압축률을 제공합니다. 대부분의 브라우저가 이 두 가지를 모두 지원합니다.

다음 코드를 살펴봅시다.

const express = require('express');
const compression = require('compression');
const app = express();

// gzip 압축 미들웨어 적용
app.use(compression({
  // 1KB 이상일 때만 압축 (작은 응답은 오버헤드가 더 큼)
  threshold: 1024,
  // 압축 레벨: 1-9 (높을수록 더 압축, 더 느림)
  level: 6,
  // 압축할 Content-Type 필터
  filter: (req, res) => {
    const contentType = res.getHeader('Content-Type') || '';
    // JSON, HTML, CSS, JS만 압축
    return /json|text|javascript|css/.test(contentType);
  }
}));

app.get('/api/products', (req, res) => {
  // Accept-Encoding 헤더 확인 (클라이언트 압축 지원 여부)
  console.log('Client accepts:', req.headers['accept-encoding']);
  // compression 미들웨어가 자동으로 gzip/brotli 적용
  res.json(largeProductList);
});

모바일 앱 출시를 앞두고 김개발 씨는 네트워크 성능 테스트를 진행했습니다. 3G 환경에서 상품 목록 로딩에 8초가 걸렸습니다.

원인을 분석해보니 JSON 응답이 너무 컸습니다. "2MB를 3G로 받으려면 당연히 오래 걸리죠." 박시니어 씨가 말했습니다.

"압축을 적용하면 획기적으로 줄일 수 있어요." 응답 압축이란 무엇일까요? 우체국에서 해외로 소포를 보낼 때를 생각해보세요.

옷을 그냥 넣으면 부피가 크지만, 압축팩에 넣으면 절반 이하로 줄어듭니다. 배송비도 아끼고 도착도 빨라집니다.

웹에서의 압축도 마찬가지입니다. 서버가 데이터를 압축해서 보내면, 네트워크로 전송되는 양이 줄어들어 응답이 빨라집니다.

gzip은 1990년대부터 사용된 검증된 압축 방식입니다. 텍스트 데이터에서 보통 70-80%의 압축률을 보여줍니다.

2MB JSON이 400KB로 줄어드는 셈입니다. Brotli는 구글이 개발한 더 최신 압축 방식으로, gzip보다 20-25% 더 높은 압축률을 제공합니다.

다만 압축하는 데 시간이 조금 더 걸립니다. 현대 브라우저는 대부분 Brotli를 지원하므로, 정적 자원에는 Brotli를, 동적 API에는 gzip을 사용하는 전략이 일반적입니다.

위 코드에서 compression 미들웨어를 사용하면 Express가 자동으로 응답을 압축합니다. threshold 옵션으로 압축할 최소 크기를 지정합니다.

너무 작은 응답은 압축 오버헤드가 더 클 수 있기 때문입니다. 클라이언트는 Accept-Encoding 헤더로 자신이 지원하는 압축 방식을 알려줍니다.

서버는 이를 확인하고 적절한 방식으로 압축한 후, Content-Encoding 헤더에 사용한 압축 방식을 명시합니다. 브라우저는 이 헤더를 보고 자동으로 압축을 해제합니다.

주의할 점이 있습니다. 이미지나 동영상 같은 바이너리 파일은 이미 압축된 포맷이므로 gzip을 적용해도 효과가 없고, 오히려 CPU만 낭비합니다.

압축은 텍스트 기반 콘텐츠(JSON, HTML, CSS, JavaScript)에만 적용하세요. 김개발 씨는 압축을 적용한 후 응답 크기가 2MB에서 180KB로 줄어든 것을 확인했습니다.

3G 환경에서도 2초 이내에 로딩이 완료되었습니다. "이렇게 간단한 설정으로 이런 효과라니요." 김개발 씨가 감탄했습니다.

실전 팁

💡 - 이미지, 동영상 등 이미 압축된 파일에는 gzip을 적용하지 마세요

  • 정적 파일은 미리 압축해두고(pre-compression), 동적 응답은 실시간 압축하세요
  • CPU 사용량이 걱정된다면 압축 레벨을 낮추거나 Nginx 같은 리버스 프록시에 압축을 위임하세요

6. CDN_활용_전략

김개발 씨네 서비스가 해외 진출을 앞두고 있었습니다. 그런데 미국에서 테스트해보니 응답 시간이 국내보다 3배나 느렸습니다.

"서버가 한국에 있으니까요. 물리적 거리는 어쩔 수 없어요." 김개발 씨가 한숨을 쉬자, 박시니어 씨가 말했습니다.

"CDN을 사용하면 전 세계 어디서든 빠른 응답이 가능해요."

CDN(Content Delivery Network)은 전 세계에 분산된 서버 네트워크입니다. 마치 편의점 체인처럼, 가까운 곳에서 물건을 받을 수 있게 해줍니다.

사용자는 지리적으로 가장 가까운 CDN 서버(엣지 서버)에서 콘텐츠를 받기 때문에, 본 서버가 어디에 있든 빠른 응답을 경험합니다. 정적 파일은 물론 API 응답까지 캐싱할 수 있습니다.

다음 코드를 살펴봅시다.

// CloudFront + API Gateway 연동 예시 (AWS CDK)
const distribution = new cloudfront.Distribution(this, 'ApiCDN', {
  defaultBehavior: {
    origin: new origins.HttpOrigin('api.example.com'),
    // API 응답도 CDN에서 캐싱
    cachePolicy: new cloudfront.CachePolicy(this, 'ApiCachePolicy', {
      // 쿼리 스트링과 헤더 기반 캐시 분리
      queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
      headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
        'Authorization', 'Accept-Language'
      ),
      // 기본 캐시 시간: 1분
      defaultTtl: Duration.minutes(1),
      maxTtl: Duration.hours(1),
    }),
    // 원본 서버로의 요청 횟수 최소화
    originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER,
  },
  // 글로벌 엣지 로케이션 활용
  priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
});

서비스가 성장하면서 김개발 씨 앞에 새로운 도전이 놓였습니다. 글로벌 서비스입니다.

한국, 미국, 유럽 사용자 모두에게 빠른 경험을 제공해야 했습니다. "서버를 각 지역에 배포하면 되지 않을까요?" 김개발 씨의 질문에 박시니어 씨가 고개를 저었습니다.

"인프라 비용과 운영 복잡도가 기하급수적으로 늘어나요. 더 좋은 방법이 있어요." CDN이란 무엇일까요?

전 세계에 퍼져 있는 편의점 체인을 생각해보세요. 본사 창고는 서울에 있지만, 각 지역 편의점에 인기 상품을 미리 배치해둡니다.

손님은 서울까지 갈 필요 없이 가까운 편의점에서 바로 물건을 살 수 있습니다. CDN도 마찬가지입니다.

전 세계 수백 개의 엣지 서버에 콘텐츠를 복제해두고, 사용자는 가장 가까운 서버에서 데이터를 받습니다. 서울에서 미국 서버까지 네트워크 왕복 시간은 약 200ms입니다.

빛의 속도 한계 때문에 물리적으로 줄일 수 없는 지연입니다. 하지만 미국 엣지 서버에 캐시가 있다면 20ms면 충분합니다.

10배 빨라지는 것입니다. CDN은 전통적으로 이미지, CSS, JavaScript 같은 정적 파일에 사용되었습니다.

하지만 최근에는 API 응답도 캐싱합니다. 상품 목록처럼 자주 조회되면서 자주 바뀌지 않는 데이터가 좋은 대상입니다.

위 코드는 AWS CloudFront 설정 예시입니다. cachePolicy에서 쿼리 스트링과 헤더를 기반으로 캐시를 분리합니다.

예를 들어 /api/products?category=shoes와 /api/products?category=shirts는 별도로 캐싱됩니다. Authorization 헤더가 다르면 역시 별도로 캐싱되어, 로그인한 사용자별로 다른 응답을 캐싱할 수 있습니다.

캐시 무효화도 중요합니다. 데이터가 변경되면 CDN 캐시도 갱신해야 합니다.

대부분의 CDN은 API로 특정 URL의 캐시를 즉시 삭제하는 기능을 제공합니다. 또는 URL에 버전 파라미터를 포함시켜 자연스럽게 새 캐시를 사용하게 할 수도 있습니다.

주의할 점도 있습니다. 사용자별로 다른 데이터를 보여주는 개인화된 API는 CDN 캐싱에 적합하지 않습니다.

잘못 설정하면 A 사용자의 데이터가 B 사용자에게 노출될 수 있습니다. 이런 API는 CDN을 통과하되 캐싱하지 않도록 설정해야 합니다.

김개발 씨는 CDN을 적용한 후 글로벌 사용자 모두에게 100ms 이내의 응답 시간을 제공할 수 있게 되었습니다. 원본 서버로의 요청도 80%나 줄어들어 서버 비용도 절감되었습니다.

"CDN이 이렇게 강력한 도구인 줄 몰랐어요." 김개발 씨가 뿌듯한 표정으로 말했습니다.

실전 팁

💡 - 정적 파일에는 긴 TTL과 파일명에 해시를 포함시키는 전략을 사용하세요 (예: app.a1b2c3.js)

  • API 캐싱 시에는 반드시 캐시 키에 필요한 모든 변수(사용자, 언어 등)를 포함시키세요
  • 캐시 적중률(Cache Hit Ratio)을 모니터링하고, 낮다면 캐시 정책을 점검하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Node.js#Caching#Redis#Performance#CDN#API,성능,캐싱

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

WebSocket과 Server-Sent Events 실시간 통신 완벽 가이드

웹 애플리케이션에서 실시간 데이터 통신을 구현하는 핵심 기술인 WebSocket과 Server-Sent Events를 다룹니다. 채팅, 알림, 실시간 업데이트 등 현대 웹 서비스의 필수 기능을 구현하는 방법을 배워봅니다.

API 테스트 전략과 자동화 완벽 가이드

API 개발에서 필수적인 테스트 전략을 단계별로 알아봅니다. 단위 테스트부터 부하 테스트까지, 실무에서 바로 적용할 수 있는 자동화 기법을 익혀보세요.

효과적인 API 문서 작성법 완벽 가이드

API 문서는 개발자와 개발자 사이의 가장 중요한 소통 수단입니다. 이 가이드에서는 좋은 API 문서가 갖춰야 할 조건부터 Getting Started, 엔드포인트 설명, 에러 코드 문서화, 인증 가이드, 변경 이력 관리까지 체계적으로 배워봅니다.

OAuth 2.0과 소셜 로그인 완벽 가이드

OAuth 2.0의 핵심 개념부터 구글, 카카오 소셜 로그인 구현까지 초급 개발자를 위해 쉽게 설명합니다. 인증과 인가의 차이점, 다양한 Flow의 특징, 그리고 보안 고려사항까지 실무에 바로 적용할 수 있는 내용을 다룹니다.

JWT 기반 인증 구현하기 완벽 가이드

웹 애플리케이션에서 가장 널리 사용되는 JWT 인증 방식을 초급 개발자도 쉽게 이해할 수 있도록 설명합니다. 토큰의 구조부터 보안 베스트 프랙티스까지 실무에 바로 적용할 수 있는 내용을 담았습니다.