나의 기술이야기

API 응답 시간을 70% 단축시킨 최적화 여정: 3초에서 0.9초로

도깨빈 2025. 6. 17. 09:21

🚨 문제 상황: 느려터진 API가 사용자를 떠나게 만들고 있었다

우리 서비스의 핵심 API 중 하나인 /api/products/search의 평균 응답 시간이 3초를 넘나들고 있었습니다. 모니터링 도구를 확인해보니 최악의 경우 5초까지 걸리는 상황이었죠.

사용자들은 검색 결과를 기다리다 지쳐 페이지를 떠나고 있었고, 비즈니스 팀에서는 매일 컨버전율 하락에 대한 문의가 들어왔습니다. 뭔가 조치를 취해야 할 때였습니다.

📊 현재 상황 분석: 병목 지점을 찾아라

성능 측정 도구 도입

가장 먼저 한 일은 정확한 측정이었습니다. New Relic과 사내 APM 도구를 활용해 다음을 측정했습니다:

// 각 구간별 실행 시간 측정을 위한 래퍼 함수
const measureTime = (label, fn) => {
  const start = Date.now();
  const result = fn();
  console.log(`${label}: ${Date.now() - start}ms`);
  return result;
};

발견한 병목 지점들

  1. 데이터베이스 쿼리: 전체 시간의 65% (약 2초)
  2. 외부 API 호출: 전체 시간의 20% (약 0.6초)
  3. 데이터 가공 로직: 전체 시간의 15% (약 0.45초)

🎯 최적화 전략 수립

1단계: 데이터베이스 쿼리 최적화 (2초 → 0.5초)

문제점 발견 기존 쿼리를 분석해보니 심각한 문제들이 있었습니다:

-- 기존의 비효율적인 쿼리
SELECT p.*, c.name as category_name, b.name as brand_name
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN brands b ON p.brand_id = b.id
WHERE p.name LIKE '%검색어%' 
   OR p.description LIKE '%검색어%'
   OR c.name LIKE '%검색어%'
ORDER BY p.created_at DESC
LIMIT 20;

해결 방법

인덱스 추가

-- 복합 인덱스 생성
CREATE INDEX idx_products_search ON products(category_id, brand_id, created_at);
CREATE INDEX idx_products_name_desc ON products(name, description);

쿼리 개선

-- 최적화된 쿼리
SELECT p.id, p.name, p.price, p.image_url,
       c.name as category_name, b.name as brand_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
INNER JOIN brands b ON p.brand_id = b.id
WHERE (p.name LIKE '검색어%' OR p.description LIKE '검색어%')
  AND p.status = 'active'
ORDER BY p.created_at DESC
LIMIT 20;

결과: 2초 → 0.5초 (75% 개선)

2단계: 캐싱 전략 도입 (0.6초 → 0.1초)

외부 API 호출을 줄이기 위해 Redis를 활용한 캐싱을 도입했습니다.

const Redis = require('redis');
const client = Redis.createClient();

const getCachedData = async (key, fetchFn, ttl = 300) => {
  try {
    // 캐시에서 먼저 확인
    const cached = await client.get(key);
    if (cached) {
      return JSON.parse(cached);
    }
    
    // 캐시에 없으면 API 호출
    const data = await fetchFn();
    
    // 결과를 캐시에 저장 (5분 TTL)
    await client.setex(key, ttl, JSON.stringify(data));
    
    return data;
  } catch (error) {
    console.error('Cache error:', error);
    // 캐시 실패시 직접 API 호출
    return await fetchFn();
  }
};

// 사용 예시
const productRecommendations = await getCachedData(
  `recommendations:${userId}`,
  () => externalAPI.getRecommendations(userId),
  600 // 10분 캐시
);

결과: 0.6초 → 0.1초 (83% 개선)

3단계: 데이터 가공 로직 최적화 (0.45초 → 0.2초)

기존에는 모든 상품 데이터를 가져온 후 JavaScript에서 필터링하고 있었습니다.

// 기존 비효율적인 방식
const processProducts = (products) => {
  return products
    .filter(product => product.status === 'active')
    .map(product => ({
      ...product,
      discountRate: calculateDiscount(product.price, product.originalPrice),
      isNew: isNewProduct(product.createdAt),
      rating: calculateAverageRating(product.reviews)
    }))
    .sort((a, b) => b.rating - a.rating);
};

개선된 방식: 필요한 데이터만 조회하고 DB에서 계산

// DB에서 계산된 데이터를 가져오는 방식
const getOptimizedProducts = async (searchTerm) => {
  const query = `
    SELECT 
      p.id, p.name, p.price, p.original_price,
      p.image_url, p.created_at,
      c.name as category_name,
      b.name as brand_name,
      ROUND((p.original_price - p.price) / p.original_price * 100) as discount_rate,
      CASE WHEN p.created_at > DATE_SUB(NOW(), INTERVAL 7 DAY) 
           THEN true ELSE false END as is_new,
      COALESCE(AVG(r.rating), 0) as avg_rating
    FROM products p
    INNER JOIN categories c ON p.category_id = c.id
    INNER JOIN brands b ON p.brand_id = b.id
    LEFT JOIN reviews r ON p.id = r.product_id
    WHERE p.status = 'active' 
      AND (p.name LIKE ? OR p.description LIKE ?)
    GROUP BY p.id, p.name, p.price, p.original_price, p.image_url, 
             p.created_at, c.name, b.name
    ORDER BY avg_rating DESC, p.created_at DESC
    LIMIT 20
  `;
  
  return await db.query(query, [`%${searchTerm}%`, `%${searchTerm}%`]);
};

결과: 0.45초 → 0.2초 (56% 개선)

🚀 추가 최적화: 더 나아가기

Connection Pool 최적화

const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 20,        // 기존 10 → 20
  queueLimit: 0,
  acquireTimeout: 60000,      // 60초
  timeout: 60000,
  reconnect: true
});

응답 압축

const compression = require('compression');
app.use(compression({
  level: 6,
  threshold: 1024,  // 1KB 이상일 때만 압축
}));

페이지네이션 개선

// Offset 기반 → Cursor 기반 페이지네이션으로 변경
const getProductsCursor = async (cursor = null, limit = 20) => {
  const whereClause = cursor ? `WHERE p.id < ${cursor}` : '';
  
  const query = `
    SELECT p.* FROM products p
    ${whereClause}
    ORDER BY p.id DESC
    LIMIT ${limit + 1}
  `;
  
  const results = await db.query(query);
  const hasMore = results.length > limit;
  const products = hasMore ? results.slice(0, -1) : results;
  
  return {
    products,
    nextCursor: hasMore ? products[products.length - 1].id : null,
    hasMore
  };
};

📈 최종 결과

구분최적화 전최적화 후개선율

평균 응답시간 3.0초 0.9초 70% 개선
95 퍼센타일 5.2초 1.4초 73% 개선
DB 쿼리 시간 2.0초 0.5초 75% 개선
외부 API 호출 0.6초 0.1초 83% 개선
데이터 가공 0.45초 0.2초 56% 개선

🎯 비즈니스 임팩트

성능 개선 후 눈에 띄는 변화들:

  • 이탈률 25% 감소: 검색 페이지 이탈률이 45% → 34%로 감소
  • 전환율 18% 증가: 검색 → 상품 상세 페이지 전환율 향상
  • 사용자 만족도 향상: 고객 지원팀으로 들어오는 "사이트가 느리다"는 문의 90% 감소

💡 배운 점과 팁

1. 측정 없이는 최적화도 없다

성능 최적화의 첫 번째 단계는 정확한 측정입니다. 추측으로 최적화하지 말고 데이터에 기반해서 병목 지점을 찾아야 합니다.

2. 80/20 법칙을 활용하라

전체 성능 문제의 80%는 20%의 코드에서 발생합니다. 가장 큰 병목부터 해결하는 것이 효율적입니다.

3. 캐싱은 만능이 아니다

캐싱은 강력한 도구지만 적절한 TTL 설정과 캐시 무효화 전략이 중요합니다. 잘못 설계하면 오히려 문제가 될 수 있어요.

4. 데이터베이스를 친구로 만들어라

복잡한 로직을 애플리케이션에서 처리하기보다는 데이터베이스의 성능을 활용하는 것이 종종 더 효율적입니다.

🔍 앞으로의 계획

현재도 지속적인 모니터링을 통해 성능을 관리하고 있으며, 다음과 같은 추가 개선사항들을 계획하고 있습니다:

  • CDN 도입: 이미지와 정적 파일 로딩 속도 개선
  • ElasticSearch 도입: 더 빠르고 정확한 검색 기능
  • 마이크로서비스 분리: 검색 서비스를 별도로 분리해 확장성 개선

성능 최적화는 한 번에 끝나는 작업이 아닙니다. 지속적인 모니터링과 개선을 통해 사용자 경험을 계속해서 향상시켜 나가는 것이 중요하다고 생각합니다.


이 글이 API 성능 최적화를 고민하고 계신 분들에게 도움이 되었으면 좋겠습니다. 궁금한 점이나 경험을 공유하고 싶으시다면 댓글로 남겨주세요!

반응형