Blog/Spring Boot

Spring Boot 애플리케이션 성능 최적화 실전 가이드

실무에서 적용한 Spring Boot 성능 최적화 기법과 그 결과를 공유합니다. JPA N+1 문제 해결, 캐싱 전략, 커넥션 풀 튜닝 등을 다룹니다.

3분 읽기
Spring BootPerformanceJPARedisBackend

들어가며

최근 프로젝트에서 트래픽이 급증하면서 성능 이슈가 발생했습니다. API 응답 시간이 평균 2초를 넘어가고, 피크 타임에는 타임아웃이 빈번하게 발생했습니다. 이 글에서는 문제를 진단하고 해결한 과정을 공유합니다.

1. JPA N+1 문제 해결

문제 상황

상품 목록 조회 API에서 심각한 성능 저하가 발생했습니다. 100개의 상품을 조회할 때 101개의 쿼리가 실행되고 있었습니다.

java
// 문제가 있는 코드
@Entity
public class Product {
    @Id
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    private Category category;
    
    @OneToMany(mappedBy = "product")
    private List<Review> reviews;
}
 
// Repository
List<Product> products = productRepository.findAll();

해결 방법

Fetch Join과 EntityGraph를 활용하여 N+1 문제를 해결했습니다.

java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    
    @Query("SELECT DISTINCT p FROM Product p " +
           "LEFT JOIN FETCH p.category " +
           "LEFT JOIN FETCH p.reviews")
    List<Product> findAllWithCategoryAndReviews();
    
    // 또는 EntityGraph 사용
    @EntityGraph(attributePaths = {"category", "reviews"})
    List<Product> findAll();
}

결과: 쿼리 수가 101개에서 1개로 감소하고, 응답 시간이 2.3초에서 0.3초로 개선되었습니다.

2. Redis 캐싱 전략

자주 조회되지만 변경이 적은 데이터에 대해 Redis 캐싱을 적용했습니다.

Look-Aside 패턴 구현

java
@Service
@RequiredArgsConstructor
public class ProductService {
    
    private final ProductRepository productRepository;
    private final RedisTemplate<String, Product> redisTemplate;
    
    public Product getProduct(Long id) {
        String key = "product:" + id;
        
        // 1. 캐시 조회
        Product cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }
        
        // 2. DB 조회
        Product product = productRepository.findById(id)
            .orElseThrow(() -> new NotFoundException("상품을 찾을 수 없습니다"));
        
        // 3. 캐시 저장 (TTL 1시간)
        redisTemplate.opsForValue().set(key, product, 1, TimeUnit.HOURS);
        
        return product;
    }
}

캐시 무효화: 상품 정보가 업데이트될 때는 반드시 캐시를 삭제해야 합니다.

3. 커넥션 풀 튜닝

HikariCP 설정을 최적화하여 DB 커넥션 관리를 개선했습니다.

yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 10
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      leak-detection-threshold: 60000

적정 풀 사이즈 계산

text
connections = ((core_count * 2) + effective_spindle_count)

4코어 서버에서 SSD 사용 시: (4 * 2) + 1 = 9개 정도가 적정합니다.

주의: 무조건 커넥션 풀을 크게 설정하는 것은 오히려 성능 저하를 일으킬 수 있습니다.

4. 비동기 처리

시간이 오래 걸리는 작업은 비동기로 처리하여 응답 시간을 개선했습니다.

java
@Service
public class NotificationService {
    
    @Async
    public CompletableFuture<Void> sendEmail(String to, String subject, String body) {
        // 이메일 발송 로직
        emailSender.send(to, subject, body);
        return CompletableFuture.completedFuture(null);
    }
}
 
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }
}

성능 개선 결과

지표개선 전개선 후개선율
평균 응답 시간2.3초0.3초87% ↓
피크 타임 TPS50300500% ↑
DB 쿼리 수101199% ↓
캐시 히트율0%85%-

마치며

성능 최적화는 측정과 분석에서 시작됩니다. 프로파일링 도구를 활용하여 병목 지점을 정확히 파악하고, 적절한 해결책을 적용하는 것이 중요합니다. 이번 경험을 통해 다음과 같은 교훈을 얻었습니다:

  1. 측정 없이 최적화하지 말 것 - 추측이 아닌 데이터 기반으로 접근
  2. 단계적으로 적용 - 한 번에 여러 변경을 하면 원인 파악이 어려움
  3. 모니터링 필수 - 개선 후에도 지속적인 모니터링 필요

다음 글에서는 MSA 환경에서의 성능 최적화에 대해 다루겠습니다.

text