본문으로 건너뛰기
AIDevOps
  • Learn
  • Learning Paths
  • Practice
  • Open Source
  • Books
  • Engineering

    AI DevOpsAI 서비스 개발·운영 전체 지도LLMOpsLLM 배포·평가·관측실전 프로젝트AI Agent 프로젝트 실습

    Knowledge

    Docs기술 문서 모음Blog엔지니어링 아티클Plogger개발 기록 피드

    Validate

    Certification3단계 역량 인증 · 준비 중
AI Models
LlamaMistralGemmaDeepSeekQwen
🧠 AI Core
AI 입문 & 로드맵ML FundamentalsLLM Fundamentals|Python AIC++|PyTorchTensorFlowJAX
🤖 AI 실전 개발
AI 실전 입문 & 로드맵Hugging FaceLangChainLlamaIndexLLMOps|LangGraphMCPMulti-AgentAgent Evaluation
🧠 AI Agent 개발
금융 AI AgentLLM API 서버주식 투자 AgentAIOps AI Agent교육 AI Agent코딩 AI Agent
🌱 Spring Cloud
Spring 입문 & 로드맵Spring Cloud GatewaySpring BootJava|Spring AISpring SecuritySpring BatchSpring JPA
🐳 DevOps
DevOps 입문 & 로드맵LinuxDockerCI/CD|Kubernetes 기본K8s 심화/실무PrometheusGrafana
🧱 인프라
인프라 입문 & 로드맵NginxRedis
☁️ 클라우드
클라우드 입문 & 로드맵AWSGCPAzureNCPCloudflare
🎨 Frontend
Frontend 입문 & 로드맵JavaScriptTypeScript|ReactNext.js|VueNuxt
📱 Mobile
Mobile 입문 & 로드맵KotlinAndroidFlutter
⚙️ Backend
Backend 입문 & 로드맵Python 기본FastAPIDjangoFlask|CGoGinNode.js
💾 Database
DB 입문 & 로드맵공통 SQLOracleMySQLPostgreSQL|MongoDB벡터 DB
🧪 검증
k6JMeternGrinder
AIDevOps

Engineering AI. From Code to Production.
AI와 AI Agent를 개발하고 운영하기 위한 엔지니어링 학습 플랫폼

Learn

  • 전체 가이드
  • Learning Paths
  • Practice
  • Books

Resources

  • AI DevOps
  • LLMOps
  • 실전 프로젝트
  • Docs
  • Blog
  • Plogger
  • Open Source
  • Certification (준비 중)

Start Here

  • AI Core 로드맵
  • AI 실전 개발 로드맵
  • Spring Cloud 로드맵
  • DevOps 로드맵
  • 인프라 로드맵

 

  • 클라우드 로드맵
  • Frontend 로드맵
  • Mobile 로드맵
  • Backend 로드맵
  • Database 로드맵
© 2026 AI DevOps Korea. All rights reserved.
이용약관개인정보처리방침Sitemaptestforge.kr
AI DevOps Korea · 시스템 성능 가이드

⚙️ 시스템 성능 개선 실전 가이드

Visitors

DB 쿼리 최적화, Redis 캐시 전략, 커넥션 풀 튜닝, API 응답시간 개선, k6 부하 테스트까지. 프로덕션에서 실제로 작동하는 성능 최적화 기술을 단계별로 다룹니다.

← 블로그 목록
⚙️

목차

0 / 12
  1. 성능 지표와 목표 설정
  2. 병목 진단 방법
  3. DB 쿼리 최적화
  4. 인덱스 전략
  5. N+1 문제 해결
  6. EXPLAIN으로 분석
  7. 캐시 전략
  8. Redis 캐시 설계
  9. HTTP 캐시 & CDN
  10. 커넥션 풀 & 스레드 튜닝
  11. 비동기 처리 & 페이지네이션
  12. k6 성능 검증
목차 12개 섹션
  1. 성능 지표와 목표 설정
  2. 병목 진단 방법
  3. DB 쿼리 최적화
  4. 인덱스 전략
  5. N+1 문제 해결
  6. EXPLAIN으로 분석
  7. 캐시 전략
  8. Redis 캐시 설계
  9. HTTP 캐시 & CDN
  10. 커넥션 풀 & 스레드 튜닝
  11. 비동기 처리 & 페이지네이션
  12. k6 성능 검증

성능 지표와 목표 설정

최적화를 시작하기 전에 측정 지표와 목표치를 먼저 정의해야 합니다. 숫자 없이 시작하는 최적화는 방향을 잃기 쉽습니다.

지표설명프로덕션 목표 (일반적)
p50 응답시간전체 요청의 50%가 이 시간 이내< 100ms
p95 응답시간전체 요청의 95%가 이 시간 이내< 500ms
p99 응답시간전체 요청의 99%가 이 시간 이내< 1,000ms
에러율5xx 응답 비율< 0.1%
처리량 (TPS)초당 처리 요청 수서비스 규모에 따라 상이
💡

p99가 아닌 p95를 먼저 공략하세요. p99는 네트워크 지터, GC pause 등 제어하기 어려운 요소에 영향받습니다. p95를 안정적으로 낮춘 뒤 p99를 개선하는 순서가 효율적입니다.

병목 진단 방법

느린 요청의 원인을 찾는 가장 빠른 방법은 각 구간의 시간을 직접 측정하는 것입니다. 추측하지 말고 측정하세요.

PYTHON
import time
import logging
from contextlib import contextmanager

logger = logging.getLogger(__name__)

@contextmanager
def timer(label: str):
    """요청 처리 각 구간 시간을 측정하는 컨텍스트 매니저"""
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed_ms = (time.perf_counter() - start) * 1000
        logger.info(f"[PERF] {label}: {elapsed_ms:.1f}ms")

# 사용 예시 — FastAPI 핸들러
async def get_dashboard(user_id: int):
    with timer("db.fetch_user"):
        user = await db.get_user(user_id)

    with timer("db.fetch_stats"):
        stats = await db.get_stats(user_id)

    with timer("cache.set"):
        await redis.set(f"dashboard:{user_id}", stats, ex=300)

    return {"user": user, "stats": stats}

# 로그 출력 예시:
# [PERF] db.fetch_user: 3.2ms
# [PERF] db.fetch_stats: 847.5ms  ← 병목!
# [PERF] cache.set: 1.1ms
ℹ️

Slow Query Log 활성화: PostgreSQL은 log_min_duration_statement = 100으로 100ms 이상 쿼리를 자동 기록합니다. MySQL은 slow_query_log = ON, long_query_time = 0.1로 설정하세요.

DB 쿼리 최적화

대부분의 API 성능 문제는 DB에서 시작됩니다. 인덱스 누락, N+1 쿼리, 불필요한 풀스캔이 3대 원인입니다.

인덱스 전략

인덱스는 읽기 속도를 높이는 대신 쓰기 속도와 저장 공간을 소모합니다. 자주 조회되는 컬럼, 특히 WHERE / ORDER BY / JOIN에 사용되는 컬럼을 우선 인덱싱합니다.

SQL
-- 문제: user_id + created_at 복합 조회가 Full Scan
SELECT * FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;

-- 해결: 복합 인덱스 생성 (선택도 높은 컬럼을 앞에)
CREATE INDEX CONCURRENTLY idx_orders_user_created
  ON orders (user_id, created_at DESC);

-- 커버링 인덱스: SELECT 컬럼까지 인덱스에 포함 → 테이블 접근 0회
CREATE INDEX CONCURRENTLY idx_orders_cover
  ON orders (user_id, created_at DESC)
  INCLUDE (status, total_amount);
인덱스 유형적합한 상황주의사항
단일 컬럼단순 equal 조회선택도 낮으면 효과 없음
복합 컬럼WHERE + ORDER BY 조합선두 컬럼 조건 필수
커버링SELECT 컬럼 고정된 쿼리인덱스 크기 증가
부분 인덱스특정 상태값만 조회WHERE status = 'active'

N+1 문제 해결

N+1은 목록 N개를 조회한 뒤 각 항목마다 추가 쿼리를 날리는 패턴입니다. 목록 100개면 쿼리 101회가 실행됩니다.

PYTHON
# 나쁜 예: N+1 쿼리 (게시글 10개 → 쿼리 11회)
posts = await db.query("SELECT * FROM posts LIMIT 10")
for post in posts:
    # 게시글마다 별도 쿼리 실행
    author = await db.query("SELECT * FROM users WHERE id = ?", post.user_id)
    post.author = author

# 좋은 예 1: JOIN으로 단일 쿼리
posts = await db.query("""
    SELECT p.*, u.name AS author_name, u.avatar AS author_avatar
    FROM posts p
    JOIN users u ON u.id = p.user_id
    LIMIT 10
""")

# 좋은 예 2: IN 절로 배치 조회 (ORM 사용 시)
posts = await Post.query.limit(10).all()
user_ids = {p.user_id for p in posts}
users = {u.id: u for u in await User.query.filter(User.id.in_(user_ids)).all()}
for post in posts:
    post.author = users[post.user_id]
⚠️

ORM의 Lazy Loading을 주의하세요. Django ORM, SQLAlchemy, JPA 모두 기본적으로 연관 객체를 Lazy Load합니다. select_related /prefetch_related / JOIN FETCH로 명시적으로 로드하세요.

EXPLAIN으로 실행 계획 분석

SQL
-- PostgreSQL: 실제 실행 계획 + 소요 시간 확인
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.*, u.name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'pending'
ORDER BY o.created_at DESC
LIMIT 50;

-- 핵심 키워드 해석
-- Seq Scan     → 풀스캔 (인덱스 없음 또는 미사용)
-- Index Scan   → 인덱스 사용 (양호)
-- Index Only   → 커버링 인덱스 (최적)
-- Hash Join    → 대용량 JOIN (정상)
-- Nested Loop  → 소용량 JOIN (정상), 대용량이면 문제
-- rows=10000 / actual rows=1  → 통계 오래됨 → ANALYZE 실행
SQL
-- 통계 갱신 (실행 계획 추정치가 실제와 크게 다를 때)
ANALYZE orders;

-- 인덱스 사용 현황 확인 (사용 안 된 인덱스 제거 대상)
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;

캐시 전략

캐시는 비싼 연산 결과를 재사용하는 구조입니다. 무엇을 캐시할지, 언제 무효화할지 전략이 없으면 오히려 장애 원인이 됩니다.

Redis 캐시 설계

PYTHON
import json
import hashlib
from functools import wraps
import redis.asyncio as redis

r = redis.from_url("redis://localhost:6379", decode_responses=True)

def cache(ttl: int = 300, key_prefix: str = ""):
    """결과를 Redis에 캐시하는 데코레이터"""
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            # 캐시 키: 함수명 + 인자 해시
            raw_key = f"{key_prefix or func.__name__}:{args}:{sorted(kwargs.items())}"
            cache_key = hashlib.md5(raw_key.encode()).hexdigest()

            cached = await r.get(cache_key)
            if cached:
                return json.loads(cached)  # 캐시 히트

            result = await func(*args, **kwargs)
            await r.set(cache_key, json.dumps(result), ex=ttl)
            return result
        return wrapper
    return decorator

# 사용 예시
@cache(ttl=60, key_prefix="user_stats")
async def get_user_stats(user_id: int) -> dict:
    """DB 집계 쿼리 — 60초 캐시"""
    return await db.query(
        "SELECT COUNT(*) orders, SUM(amount) total FROM orders WHERE user_id = ?",
        user_id,
    )
패턴설명적합한 데이터
Cache-Aside앱이 직접 캐시 조회 → 미스 시 DB 조회 후 캐시 저장조회 빈도 높은 마스터 데이터
Write-Through쓰기 시 DB + 캐시 동시 갱신일관성이 중요한 데이터
Write-Behind캐시에 먼저 쓰고 비동기로 DB 반영쓰기 많고 유실 허용 가능한 데이터
TTL 기반 무효화만료 시간 후 자동 삭제통계, 집계, 외부 API 결과
⚠️

Cache Stampede(캐시 스탬피드) 주의: TTL 만료 직후 다수의 요청이 동시에 DB를 조회하는 현상입니다. Redis SET NX로 락을 구현하거나, 만료 전 백그라운드에서 갱신하는 방식으로 방어하세요.

HTTP 캐시 & CDN

TYPESCRIPT
// Next.js App Router — 응답별 캐시 헤더 설정
import { NextResponse } from 'next/server';

// 공개 데이터: CDN이 1시간 캐시, 브라우저도 5분 캐시
export async function GET() {
  const data = await fetchPublicData();
  return NextResponse.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
  });
}

// 사용자별 데이터: CDN 캐시 금지, 브라우저 1분 캐시
export async function GET(req: Request) {
  const data = await fetchUserData();
  return NextResponse.json(data, {
    headers: {
      'Cache-Control': 'private, max-age=60',
    },
  });
}
YAML
# Cloudflare Cache Rules 예시 (wrangler.jsonc)
# 정적 에셋: 1년 캐시
# /api/public/*: Edge 30분 캐시
# /api/user/*: 캐시 없음 (bypass)

# Cache-Control 헤더 가이드
# public, s-maxage=N      → CDN N초 캐시
# stale-while-revalidate  → 만료 후에도 구버전 제공하며 백그라운드 갱신
# no-store                → 캐시 완전 금지 (민감 데이터)
# must-revalidate         → 만료 즉시 서버 재검증 필수

커넥션 풀 & 스레드 튜닝

커넥션 풀이 부족하면 요청이 풀에서 대기하며 응답 시간이 급증합니다. 반대로 과도하게 키우면 DB 서버가 과부하됩니다.

PYTHON
# SQLAlchemy 비동기 커넥션 풀 설정
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost/db",
    pool_size=20,          # 상시 유지 커넥션 수 (CPU 코어 × 2~4 기준)
    max_overflow=10,       # 피크 시 추가 허용 커넥션 (pool_size + max_overflow = 최대)
    pool_timeout=30,       # 풀에서 커넥션 대기 최대 시간(초)
    pool_recycle=1800,     # 30분 후 커넥션 재생성 (DB 타임아웃 방지)
    pool_pre_ping=True,    # 사용 전 커넥션 유효성 검사
)
YAML
# PostgreSQL max_connections 설정 기준
# max_connections = (CPU 코어 수 × 4) + 여유분
# 예: 8코어 서버 → max_connections = 200

# PgBouncer (커넥션 풀러) — 수백 개 앱 커넥션을 DB 10개로 압축
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction    # 트랜잭션 단위 풀링 (가장 효율적)
max_client_conn = 1000    # 앱에서 받는 최대 커넥션
default_pool_size = 20    # DB로 내보내는 실제 커넥션
💡

커넥션 풀 사이즈 공식: pool_size = (DB CPU 코어 수 × 2) + 효율적인 디스크 병렬도. HikariCP 제작자 Brett Wooldridge의 권고치는 CPU 코어당 2개입니다. 크게 설정한다고 빠르지 않습니다 — DB도 컨텍스트 스위칭 비용이 있습니다.

비동기 처리 & 페이지네이션

독립적인 I/O 작업은 병렬로 실행하면 전체 응답 시간을 크게 줄일 수 있습니다. 무거운 작업은 백그라운드로 분리하고, 대용량 목록은 반드시 페이지네이션합니다.

PYTHON
import asyncio

# 나쁜 예: 순차 실행 (합계 시간 = 각 작업 시간의 합)
async def get_dashboard_slow(user_id: int):
    profile = await fetch_profile(user_id)    # 50ms
    orders  = await fetch_orders(user_id)     # 80ms
    stats   = await fetch_stats(user_id)      # 60ms
    # 총 190ms

# 좋은 예: 병렬 실행 (합계 시간 ≈ 가장 느린 작업 시간)
async def get_dashboard_fast(user_id: int):
    profile, orders, stats = await asyncio.gather(
        fetch_profile(user_id),   # ┐
        fetch_orders(user_id),    # ├─ 동시 실행
        fetch_stats(user_id),     # ┘
    )
    # 총 ~80ms (가장 느린 orders 기준)
PYTHON
# 커서 기반 페이지네이션 — OFFSET 방식보다 대용량에서 일관된 성능
async def get_orders_cursor(user_id: int, cursor: int | None, limit: int = 20):
    """
    OFFSET 방식은 10만 번째 페이지에서 풀스캔에 가까워짐.
    커서(마지막 id) 방식은 항상 인덱스만 사용.
    """
    query = """
        SELECT id, status, total_amount, created_at
        FROM orders
        WHERE user_id = :user_id
          AND (:cursor IS NULL OR id < :cursor)
        ORDER BY id DESC
        LIMIT :limit
    """
    rows = await db.fetch_all(query, {"user_id": user_id, "cursor": cursor, "limit": limit})
    next_cursor = rows[-1]["id"] if len(rows) == limit else None
    return {"items": rows, "next_cursor": next_cursor}

k6 성능 검증

최적화 작업 전후를 k6로 측정하면 개선 효과를 수치로 확인할 수 있습니다. 목표 TPS에서 p95 응답시간이 SLA를 만족하는지 반드시 검증합니다.

system-perf-test.jsJAVASCRIPT
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';

const dbQueryTime = new Trend('db_query_time', true);

export const options = {
  stages: [
    { duration: '1m', target: 50  }, // 워밍업
    { duration: '3m', target: 200 }, // 정상 부하
    { duration: '2m', target: 500 }, // 피크 부하
    { duration: '1m', target: 0   }, // 쿨다운
  ],
  thresholds: {
    'http_req_duration':          ['p(95)<500', 'p(99)<1000'],
    'http_req_failed':            ['rate<0.001'],
    'db_query_time':              ['p(95)<100'],
  },
};

export default function () {
  // 1. 목록 조회 (DB + 캐시 검증)
  const listRes = http.get('http://localhost:3000/api/orders?limit=20', {
    tags: { name: 'list' },
  });
  check(listRes, {
    'list status 200': (r) => r.status === 200,
    'list has items':  (r) => r.json()?.items?.length > 0,
  });

  // 서버가 응답 헤더에 X-DB-Time을 추가하면 측정 가능
  const dbTime = listRes.headers['X-DB-Time'];
  if (dbTime) dbQueryTime.add(Number(dbTime));

  sleep(0.5);

  // 2. 단건 조회 (캐시 히트율 확인)
  const id = Math.floor(Math.random() * 1000) + 1;
  const detailRes = http.get(`http://localhost:3000/api/orders/${id}`, {
    tags: { name: 'detail' },
  });
  check(detailRes, {
    'detail status 200 or 404': (r) => [200, 404].includes(r.status),
  });

  sleep(Math.random() + 0.5);
}
BASH
# 결과 요약 예시 — 최적화 전후 비교
# ── 최적화 전 ───────────────────────────────
# http_req_duration  p(95)=1,240ms  p(99)=3,820ms  ✗ SLA 초과
# http_req_failed    rate=0.82%                     ✗ 에러율 초과

# ── 최적화 후 (인덱스 추가 + Redis 캐시) ───
# http_req_duration  p(95)=187ms    p(99)=412ms    ✓
# http_req_failed    rate=0.00%                    ✓
# db_query_time      p(95)=23ms                    ✓

k6 run system-perf-test.js --out json=result.json
💡

응답 헤더로 서버 사이드 타이밍을 노출하세요. X-DB-Time, X-Cache-Status 헤더를 추가하면 k6에서 DB 쿼리 시간과 캐시 히트율을 직접 추적할 수 있습니다. 프로덕션에서는 내부 IP에서만 노출하도록 제한하세요.