Spring 애플리케이션 성능 최적화 방법 총정리 — DB·캐싱·JVM·비동기·모니터링 실전 가이드


처음에는 잘 돌아가던 Spring Boot 서비스가 트래픽이 늘면서 응답 시간이 늘어지고, 피크 타임마다 타임아웃 에러가 쏟아지기 시작합니다. 서버 사양을 올려도 근본적으로 나아지지 않는 경우가 많습니다. Spring 성능 최적화는 단순히 코드 몇 줄을 바꾸는 것이 아니라, 어디서 병목이 생기는지를 먼저 파악하고 그 원인에 맞는 방법을 정확히 적용하는 체계적 프로세스입니다. 이 글에서는 실무에서 가장 자주 발생하는 성능 병목과 그 해결 방법을 DB 최적화·커넥션 풀·캐싱·JVM 튜닝·비동기 처리·모니터링 순서로 코드 예시와 함께 단계별로 정리합니다.


목차

  1. 성능 최적화의 첫 번째 원칙 — 측정 없이 최적화 없다
  2. DB 쿼리 최적화 — N+1 문제·Fetch Join·인덱스 전략
  3. 커넥션 풀 최적화 — HikariCP 설정과 @Transactional 남용 방지
  4. 캐싱 전략 — Redis·@Cacheable로 DB 부하 획기적으로 줄이기
  5. JVM·스레드 튜닝 — GC·가상 스레드·비동기 처리
  6. 모니터링 체계 구축 — Actuator·Prometheus·Grafana 실전 설정

1. 성능 최적화의 첫 번째 원칙 — 측정 없이 최적화 없다

“느리다”는 말만으로는 아무것도 못 한다

실무에서는 측정 → 병목 식별 → 개선 → 재측정 순서로 접근해야 합니다. 직감이나 추측으로 최적화 작업을 시작하면 정작 중요한 병목은 그대로 두고 영향이 없는 곳에 에너지를 쏟는 실수를 하게 됩니다. Bondweb

성능 최적화는 반드시 이 순서를 따라야 합니다.

1. 측정   — 응답 시간, TPS, 에러율, DB 슬로우 쿼리 확인
2. 병목   — 어느 레이어(DB? 앱? 외부 API?)에서 시간이 쌓이는지 특정
3. 개선   — 원인에 맞는 최적화 적용
4. 검증   — 개선 전후 수치 비교로 효과 확인
5. 반복   — 다음 병목으로 이동

빠르게 병목을 찾는 3가지 도구

① Spring Boot Actuator + 슬로우 쿼리 로그

yaml

# application.yml — 슬로우 쿼리 감지
spring:
  jpa:
    properties:
      hibernate:
        session:
          events:
            log:
              LOG_QUERIES_SLOWER_THAN_MS: 100  # 100ms 이상 쿼리 로깅
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE  # 파라미터 바인딩 로깅

② p6spy — SQL 실행 계획 + 실행 시간 추적

xml

<!-- build.gradle 또는 pom.xml -->
<dependency>
    <groupId>p6spy</groupId>
    <artifactId>p6spy</artifactId>
    <version>3.9.1</version>
</dependency>

③ APM 도구 — Pinpoint (오픈소스) 또는 DataDog

APM(Application Performance Management) 도구 도입으로 Spring Boot 애플리케이션에 Agent를 적용해 각 레이어별 소요 시간과 호출 흐름을 시각적으로 파악할 수 있습니다. 어떤 메서드에서 얼마나 시간이 걸리는지, 외부 API 호출이 병목인지, DB가 문제인지를 한눈에 볼 수 있어 병목 추적 시간을 크게 단축합니다. Bondweb


2. DB 쿼리 최적화 — N+1 문제·Fetch Join·인덱스 전략

Spring 성능 문제의 절반 이상은 DB 쿼리에서 발생합니다. 그중 가장 흔한 것이 N+1 문제입니다.

N+1 문제 — 1번 조회 후 N번 추가 쿼리가 발생하는 패턴

java

// ❌ N+1 문제 발생 코드
// Order 목록 1개 조회 + 각 Order의 Member를 N번 추가 조회
List<Order> orders = orderRepository.findAll();  // 쿼리 1번
for (Order order : orders) {
    System.out.println(order.getMember().getName());  // 쿼리 N번 추가
}
// Order가 100개면 총 101번 쿼리 발생!

해결 방법 ① JPQL Fetch Join

java

// ✅ Fetch Join으로 한 번에 조회
@Query("SELECT o FROM Order o JOIN FETCH o.member")
List<Order> findAllWithMember();
// → 쿼리 1번으로 Order + Member 모두 로드

해결 방법 ② @EntityGraph

java

// ✅ @EntityGraph 사용 — 어노테이션으로 간결하게
@EntityGraph(attributePaths = {"member", "orderItems"})
List<Order> findAll();
// → member와 orderItems를 동시에 Fetch Join

해결 방법 ③ Batch Size로 IN 쿼리 변환

yaml

# application.yml — 컬렉션 Lazy 로딩 시 IN 쿼리로 묶어서 처리
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100  # 최대 100개씩 IN 쿼리

java

// application.properties 대신 @BatchSize 어노테이션으로도 적용 가능
@Entity
public class Order {
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> orderItems = new ArrayList<>();
}

DTO 직접 조회 — 필요한 컬럼만 가져오기

엔티티 전체를 조회하면 불필요한 컬럼까지 모두 DB에서 가져와 네트워크·메모리 낭비가 생깁니다.

java

// ✅ DTO로 필요한 컬럼만 조회
@Query("SELECT new com.example.dto.OrderSummary(o.id, m.name, o.totalPrice) " +
       "FROM Order o JOIN o.member m WHERE o.status = :status")
List<OrderSummary> findOrderSummariesByStatus(@Param("status") OrderStatus status);

인덱스 전략 — 자주 검색하는 컬럼에 인덱스 추가

java

// @Table + @Index로 DDL 생성 시 인덱스 자동 생성
@Entity
@Table(name = "orders",
       indexes = {
           @Index(name = "idx_order_status", columnList = "status"),
           @Index(name = "idx_order_member_created",
                  columnList = "member_id, created_at DESC")
       })
public class Order { ... }

EXPLAIN 명령어로 실행 계획을 확인하고, 풀 스캔(Full Scan)이 발생하는 컬럼에 인덱스를 추가하는 것이 쿼리 최적화의 기본입니다.


3. 커넥션 풀 최적화 — HikariCP 설정과 @Transactional 남용 방지

커넥션 풀 크기 최적 설정

커넥션 풀 크기 공식은 (코어 수 × 2) + 디스크 수입니다. 예를 들어 8코어, SSD 1개 환경에서는 풀 크기를 (8 × 2) + 1 = 17 ≈ 20으로 설정합니다. 단, 평균 쿼리 실행 시간·동시 접속자 수·애플리케이션 인스턴스 수도 함께 고려해야 합니다. Bondweb

yaml

# application.yml — HikariCP 최적 설정
spring:
  datasource:
    hikari:
      pool-name: MainPool
      maximum-pool-size: 20        # (코어8 × 2) + SSD1 + 여유분
      minimum-idle: 20             # 항상 최대 유지 (고트래픽 환경)
      connection-timeout: 5000     # 5초 초과 시 예외 발생
      idle-timeout: 300000         # 5분
      max-lifetime: 1800000        # 30분 (MySQL wait_timeout보다 짧게)
      keepalive-time: 120000       # 2분마다 생존 확인
      data-source-properties:
        cachePrepStmts: true
        prepStmtCacheSize: 250
        useServerPrepStmts: true

@Transactional 범위 최소화 — 커넥션 점유 시간 단축

java

// ❌ 외부 API 호출을 트랜잭션 안에 포함 — 커넥션 장시간 점유
@Transactional
public OrderResult processOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.confirm();                          // DB 작업

    String result = externalPayApi.pay(order); // 외부 API (2~5초 소요!)
    // 이 2~5초 동안 DB 커넥션이 점유된 채로 대기

    order.complete(result);
    return new OrderResult(order);
}

// ✅ 개선 — 외부 API 호출을 트랜잭션 밖으로 분리
public OrderResult processOrder(Long orderId) {
    confirmOrder(orderId);                          // 트랜잭션 1 (빠름)
    String result = externalPayApi.pay(orderId);   // 커넥션 없이 API 호출
    completeOrder(orderId, result);                 // 트랜잭션 2 (빠름)
    return buildResult(orderId);
}

@Transactional
private void confirmOrder(Long orderId) { ... }

@Transactional
private void completeOrder(Long orderId, String result) { ... }

읽기 전용 트랜잭션 최적화

java

// ✅ 조회 전용 메서드에 readOnly = true 적용
// → 더티체킹(변경 감지) 비활성화로 성능 향상 + 레플리카 DB 분리 가능
@Transactional(readOnly = true)
public List<OrderResponse> findOrders(OrderSearchDto search) {
    return orderRepository.search(search)
        .stream()
        .map(OrderResponse::from)
        .toList();
}

4. 캐싱 전략 — Redis·@Cacheable로 DB 부하 획기적으로 줄이기

캐싱이 필요한 상황 판단 기준

캐싱은 모든 곳에 적용하는 것이 아닙니다. 다음 조건을 만족하는 데이터에만 캐싱을 적용해야 효과적입니다.

  • 읽기 빈도가 쓰기보다 훨씬 높은 데이터
  • 변경이 드물고 동일한 결과를 여러 번 조회하는 데이터
  • 조회 비용이 높은 데이터 (복잡한 JOIN 쿼리, 외부 API 결과)

Spring Cache 추상화 + Redis 설정

yaml

# application.yml
spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms
  cache:
    type: redis
    redis:
      time-to-live: 3600000  # 기본 TTL 1시간
      cache-null-values: false

java

// @EnableCaching 활성화
@SpringBootApplication
@EnableCaching
public class Application { ... }

@Cacheable·@CacheEvict·@CachePut 실전 활용

java

@Service
public class ProductService {

    // ✅ 캐시 저장 — 같은 id 재요청 시 DB 조회 없이 캐시에서 반환
    @Cacheable(value = "products", key = "#id",
               condition = "#id > 0")
    @Transactional(readOnly = true)
    public ProductResponse getProduct(Long id) {
        return productRepository.findById(id)
            .map(ProductResponse::from)
            .orElseThrow(() -> new ProductNotFoundException(id));
    }

    // ✅ 캐시 삭제 — 상품 수정 시 관련 캐시 제거
    @CacheEvict(value = "products", key = "#id")
    @Transactional
    public void updateProduct(Long id, ProductUpdateRequest request) {
        Product product = productRepository.findById(id).orElseThrow();
        product.update(request.getName(), request.getPrice());
    }

    // ✅ 캐시 갱신 — 수정 후 새 값으로 캐시 업데이트
    @CachePut(value = "products", key = "#result.id")
    @Transactional
    public ProductResponse updateAndRefresh(Long id, ProductUpdateRequest req) {
        Product product = productRepository.findById(id).orElseThrow();
        product.update(req.getName(), req.getPrice());
        return ProductResponse.from(product);
    }
}

Redis TTL 전략 — 캐시 스탬피드 방지

여러 요청이 동시에 캐시 미스를 일으켜 DB에 동시 쿼리가 몰리는 캐시 스탬피드(Cache Stampede) 현상을 방지하려면 TTL에 랜덤 지터(Jitter)를 추가합니다.

java

@Component
public class CacheConfig {
    @Bean
    public RedisCacheConfiguration cacheConfiguration() {
        // 기본 TTL 1시간 ± 10분 랜덤 지터 적용
        long baseTtl = 3600;
        long jitter = (long)(Math.random() * 600);
        return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofSeconds(baseTtl + jitter))
            .disableCachingNullValues()
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer())
            );
    }
}

5. JVM·스레드 튜닝 — GC·가상 스레드·비동기 처리

GC 튜닝 — G1GC와 ZGC 선택 기준

JVM GC 정책을 잘 선택하는 것만으로 응답 시간이 크게 개선될 수 있습니다.

bash

# 프로덕션 권장 JVM 옵션 (Java 17+ G1GC)
java -jar app.jar \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=100 \      # GC Pause 목표 100ms 이하
  -XX:G1HeapRegionSize=16m \
  -Xms2g \                        # 초기 힙 = 최대 힙 (GC 빈도 감소)
  -Xmx2g \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/heap-dump.hprof \
  -Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=20m

응답 지연(Latency)이 가장 중요한 서비스(결제, 실시간 게임)라면 ZGC 또는 Shenandoah GC를 검토하세요. 이들은 STW(Stop-The-World) 시간을 1ms 미만으로 유지하는 것을 목표로 합니다.

가상 스레드(Virtual Thread) — Java 21의 게임 체인저

Java 21의 Virtual Threads(가상 스레드)를 사용하면 최신 성능 최적화를 극대화할 수 있습니다. Namu Wiki

가상 스레드는 JVM이 관리하는 경량 스레드입니다. 기존 플랫폼 스레드(OS 스레드)는 하나를 생성하는 데 수 MB의 스택 메모리가 필요하지만, 가상 스레드는 수 KB로 수십만 개를 만들 수 있습니다. I/O 블로킹 구간에서 OS 스레드를 점유하지 않기 때문에 대기 I/O가 많은 웹 API 서비스에서 처리량이 크게 향상됩니다.

yaml

# Spring Boot 3.2+ — 가상 스레드 활성화 (한 줄 설정!)
spring:
  threads:
    virtual:
      enabled: true

java

// 코드 변경 없이 설정 한 줄로 Tomcat·Jetty·Undertow가
// 요청마다 가상 스레드를 사용하도록 자동 전환
// Spring MVC 기반 애플리케이션에서 즉시 효과 발휘

최근 들어 Java 21에서 Virtual Threads를 출시하였고 Spring에서 사용 가능함에 따라 굳이 어려운 WebFlux를 사용하지 않고 MVC를 다시 채택하는 경우가 늘었습니다. Namu Wiki

@Async 비동기 처리 — 응답 시간과 무관한 작업 분리

이메일 발송, 푸시 알림, 로그 기록처럼 응답에 포함하지 않아도 되는 작업은 비동기로 분리하면 API 응답 시간을 크게 단축할 수 있습니다.

java

// 1. 비동기 설정
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

// 2. @Async 적용 — 이 메서드는 별도 스레드에서 실행
@Service
public class NotificationService {
    @Async
    public CompletableFuture<Void> sendWelcomeEmail(String email) {
        // 이메일 발송 로직 (2~3초 소요)
        emailClient.send(email);
        return CompletableFuture.completedFuture(null);
    }
}

// 3. 호출 측 — 이메일 발송을 기다리지 않고 즉시 응답 반환
@Service
public class UserService {
    public UserResponse registerUser(UserCreateRequest request) {
        User user = userRepository.save(User.from(request));
        notificationService.sendWelcomeEmail(user.getEmail()); // 비동기 호출
        return UserResponse.from(user);                         // 즉시 반환
    }
}

6. 모니터링 체계 구축 — Actuator·Prometheus·Grafana 실전 설정

Spring Boot Actuator 설정

yaml

# application.yml — Actuator 핵심 엔드포인트 활성화
management:
  endpoints:
    web:
      exposure:
        include: health, metrics, prometheus, info, threaddump, heapdump
  endpoint:
    health:
      show-details: when-authorized
    metrics:
      enabled: true
  metrics:
    tags:
      application: ${spring.application.name}
      environment: ${spring.profiles.active:local}

Micrometer + Prometheus 연동

xml

<!-- pom.xml -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

java

// 커스텀 메트릭 — 비즈니스 지표 직접 수집
@Component
public class OrderMetrics {
    private final Counter orderCreatedCounter;
    private final Timer orderProcessingTimer;

    public OrderMetrics(MeterRegistry registry) {
        this.orderCreatedCounter = Counter.builder("order.created.total")
            .description("총 주문 생성 수")
            .tag("service", "order")
            .register(registry);

        this.orderProcessingTimer = Timer.builder("order.processing.duration")
            .description("주문 처리 소요 시간")
            .publishPercentiles(0.5, 0.95, 0.99)  // p50, p95, p99
            .register(registry);
    }

    public void recordOrderCreated() {
        orderCreatedCounter.increment();
    }

    public <T> T recordOrderProcessing(Supplier<T> supplier) {
        return orderProcessingTimer.record(supplier);
    }
}

Grafana 대시보드에서 모니터링해야 할 핵심 지표

JVM 메트릭과 Micrometer + Prometheus 설정으로 애플리케이션 상태를 실시간으로 추적하고 커스텀 메트릭을 수집할 수 있습니다. Bondweb

지표메트릭 이름임계값 기준
API 응답 시간 p99http_server_requests_seconds1초 초과 시 경고
JVM 힙 사용률jvm_memory_used_bytes80% 초과 시 경고
GC 발생 횟수jvm_gc_pause_seconds_count급증 시 GC 튜닝 필요
커넥션 풀 대기hikaricp_pending_connections0 초과 시 풀 크기 검토
커넥션 풀 활성hikaricp_active_connections최대치 근접 시 증설 검토
스레드 수jvm_threads_live_threads급증 시 스레드 누수 의심
에러율http_server_requests_seconds + status=5xx1% 초과 시 알람

성능 최적화 체크리스트 — 적용 전 검토 순서

실무에서 Spring 성능 최적화를 시작할 때 권장하는 우선순위입니다.

1순위 (가장 큰 효과): DB 쿼리 최적화
   ✓ 슬로우 쿼리 로그로 병목 쿼리 찾기
   ✓ N+1 문제 Fetch Join / @EntityGraph로 해결
   ✓ 자주 조회하는 컬럼 인덱스 추가
   ✓ SELECT * → 필요한 컬럼만 DTO 조회로 전환

2순위 (빠른 효과): 커넥션 풀 + @Transactional 최적화
   ✓ HikariCP maximum-pool-size 공식으로 계산
   ✓ @Transactional 범위 최소화 (외부 API 분리)
   ✓ 조회 전용에 readOnly = true 적용

3순위 (높은 ROI): 캐싱 전략 도입
   ✓ 변경이 적고 조회 빈도 높은 데이터 식별
   ✓ @Cacheable + Redis TTL 설정
   ✓ 캐시 무효화 전략 (@CacheEvict) 설계

4순위 (중장기): JVM + 스레드 튜닝
   ✓ Java 21 가상 스레드 활성화 (Spring Boot 3.2+)
   ✓ GC 로그 분석 후 G1GC / ZGC 선택
   ✓ 비동기 처리(@Async) 도입으로 응답 분리

5순위 (필수 인프라): 모니터링 체계 구축
   ✓ Actuator + Prometheus + Grafana 대시보드
   ✓ p95, p99 응답 시간 기반 알람 설정
   ✓ 커넥션 풀·GC·에러율 실시간 추적

결론

Spring 성능 최적화의 핵심 원칙은 세 가지입니다. 첫째, 측정 먼저, 최적화 나중입니다. 슬로우 쿼리 로그와 APM으로 병목을 정확히 찾아야 헛수고를 피할 수 있습니다. 둘째, 80%의 성능 문제는 DB 쿼리에서 옵니다. N+1 해결·인덱스 추가·DTO 직접 조회만으로 응답 시간이 극적으로 개선되는 경우가 많습니다. 셋째, Java 21 가상 스레드와 Redis 캐싱은 코드 변경이 최소화되면서 효과가 큰 최신 최적화 기법입니다. 모든 최적화는 Actuator + Prometheus + Grafana 모니터링 체계를 통해 효과를 수치로 확인하고 다음 병목으로 이동하는 사이클을 반복해야 합니다.


ℹ️ 참고 안내 본 글의 코드 예시는 Spring Boot 3.2+ / Java 21 / HikariCP 5.x / Spring Data Redis 3.x 기준으로 작성되었습니다. 버전에 따라 설정 방법이 다를 수 있으며, 최적화 설정 적용 전후 반드시 스테이징 환경에서 부하 테스트(JMeter, k6, nGrinder)로 효과를 검증하시기 바랍니다.

Comments

답글 남기기