Spring Boot 애플리케이션 성능 최적화 실전 가이드
실무에서 적용한 Spring Boot 성능 최적화 기법과 그 결과를 공유합니다. JPA N+1 문제 해결, 캐싱 전략, 커넥션 풀 튜닝 등을 다룹니다.
들어가며
최근 프로젝트에서 트래픽이 급증하면서 성능 이슈가 발생했습니다. API 응답 시간이 평균 2초를 넘어가고, 피크 타임에는 타임아웃이 빈번하게 발생했습니다. 이 글에서는 문제를 진단하고 해결한 과정을 공유합니다.
1. JPA N+1 문제 해결
문제 상황
상품 목록 조회 API에서 심각한 성능 저하가 발생했습니다. 100개의 상품을 조회할 때 101개의 쿼리가 실행되고 있었습니다.
// 문제가 있는 코드
@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 문제를 해결했습니다.
@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 패턴 구현
@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 커넥션 관리를 개선했습니다.
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000적정 풀 사이즈 계산
connections = ((core_count * 2) + effective_spindle_count)
4코어 서버에서 SSD 사용 시: (4 * 2) + 1 = 9개 정도가 적정합니다.
주의: 무조건 커넥션 풀을 크게 설정하는 것은 오히려 성능 저하를 일으킬 수 있습니다.
4. 비동기 처리
시간이 오래 걸리는 작업은 비동기로 처리하여 응답 시간을 개선했습니다.
@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% ↓ |
| 피크 타임 TPS | 50 | 300 | 500% ↑ |
| DB 쿼리 수 | 101 | 1 | 99% ↓ |
| 캐시 히트율 | 0% | 85% | - |
마치며
성능 최적화는 측정과 분석에서 시작됩니다. 프로파일링 도구를 활용하여 병목 지점을 정확히 파악하고, 적절한 해결책을 적용하는 것이 중요합니다. 이번 경험을 통해 다음과 같은 교훈을 얻었습니다:
- 측정 없이 최적화하지 말 것 - 추측이 아닌 데이터 기반으로 접근
- 단계적으로 적용 - 한 번에 여러 변경을 하면 원인 파악이 어려움
- 모니터링 필수 - 개선 후에도 지속적인 모니터링 필요
다음 글에서는 MSA 환경에서의 성능 최적화에 대해 다루겠습니다.