Spring Boot 애플리케이션을 개발하다 보면 성능 문제에 직면할 때가 있다. 처음에는 잘 동작하던 코드가 데이터가 늘어나고 사용자가 많아지면서 느려지기 시작한다.
성능 최적화는 개발 과정에서 계속 고려해야 할 중요한 요소다. 오늘은 실제 프로젝트에서 자주 마주치는 성능 문제들과 그 해결책들을 정리해보겠다.
Spring Boot의 기본 HikariCP 설정은 개발 환경에 적합하다. 하지만 실제 운영 환경에서는 부족한 경우가 많다.
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 20 # 기본값: 10
minimum-idle: 5 # 기본값: 10
connection-timeout: 30000 # 30초
idle-timeout: 600000 # 10분
max-lifetime: 1800000 # 30분
leak-detection-threshold: 60000 # 1분
적절한 연결 풀 크기를 계산하는 공식이 있다:
최적 풀 크기 = (CPU 코어 수 × 2) + 디스크 수
예를 들어 4코어 CPU, 1개 디스크인 경우: (4 × 2) + 1 = 9개
하지만 이는 시작점일 뿐이고, 실제 트래픽에 따라 조정해야 한다.
@Component
public class HikariMonitor {
@Autowired
private DataSource dataSource;
@Scheduled(fixedRate = 30000) // 30초마다
public void monitorConnectionPool() {
if (dataSource instanceof HikariDataSource) {
HikariDataSource hikariDataSource = (HikariDataSource) dataSource;
HikariPoolMXBean poolBean = hikariDataSource.getHikariPoolMXBean();
log.info("Active connections: {}", poolBean.getActiveConnections());
log.info("Idle connections: {}", poolBean.getIdleConnections());
log.info("Total connections: {}", poolBean.getTotalConnections());
}
}
}
가장 흔한 JPA 성능 문제 중 하나다.
문제가 있는 코드:
@Entity
public class Order {
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems;
}
// 서비스에서
public List<Order> getOrders() {
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
order.getOrderItems().size(); // N+1 문제 발생
}
return orders;
}
해결 방법 1: JOIN FETCH 사용
@Query("SELECT o FROM Order o JOIN FETCH o.orderItems")
List<Order> findAllWithOrderItems();
해결 방법 2: @EntityGraph 사용
@EntityGraph(attributePaths = {"orderItems"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithOrderItems();
@Entity
public class Order {
@BatchSize(size = 10)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems;
}
불필요한 SELECT 방지:
// 나쁜 예
@Query("SELECT u FROM User u")
List<User> findAllUsers();
// 좋은 예
@Query("SELECT u.id, u.name FROM User u")
List<Object[]> findUserBasicInfo();
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return productRepository.findById(id).orElse(null);
}
@CacheEvict(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
}
spring:
cache:
type: redis
redis:
time-to-live: 600000 # 10분
redis:
host: localhost
port: 6379
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
@Service
public class EmailService {
@Async("taskExecutor")
public CompletableFuture<Void> sendEmail(String email, String content) {
// 이메일 전송 로직
return CompletableFuture.completedFuture(null);
}
}
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
}
@Service
public class OrderService {
public OrderResponse processOrder(OrderRequest request) {
CompletableFuture<User> userFuture = getUserAsync(request.getUserId());
CompletableFuture<Product> productFuture = getProductAsync(request.getProductId());
CompletableFuture<Inventory> inventoryFuture = checkInventoryAsync(request.getProductId());
return CompletableFuture.allOf(userFuture, productFuture, inventoryFuture)
.thenApply(v -> {
User user = userFuture.join();
Product product = productFuture.join();
Inventory inventory = inventoryFuture.join();
return new OrderResponse(user, product, inventory);
}).join();
}
}
# 개발 환경
java -Xms512m -Xmx1024m -XX:+UseG1GC -jar app.jar
# 운영 환경
java -Xms2g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar app.jar
@Component
public class MemoryMonitor {
@Scheduled(fixedRate = 60000) // 1분마다
public void logMemoryUsage() {
MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
log.info("Used heap: {} MB", heapUsage.getUsed() / 1024 / 1024);
log.info("Max heap: {} MB", heapUsage.getMax() / 1024 / 1024);
log.info("Usage: {}%", (heapUsage.getUsed() * 100) / heapUsage.getMax());
}
}
@Configuration
public class HttpClientConfig {
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
// Connection Pool 설정
PoolingHttpClientConnectionManager connectionManager =
new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(100);
connectionManager.setDefaultMaxPerRoute(20);
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.build();
factory.setHttpClient(httpClient);
return new RestTemplate(factory);
}
}
@Service
public class ExternalApiService {
private final WebClient webClient;
public ExternalApiService() {
this.webClient = WebClient.builder()
.baseUrl("https://api.example.com")
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofSeconds(10))
))
.build();
}
public Mono<String> getData(String id) {
return webClient.get()
.uri("/data/{id}", id)
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(5));
}
}
<!-- logback-spring.xml -->
<configuration>
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE"/>
<queueSize>1024</queueSize>
<discardingThreshold>0</discardingThreshold>
<includeCallerData>false</includeCallerData>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/application.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="ASYNC_FILE"/>
</root>
</configuration>
logging:
level:
com.example: INFO
org.springframework.web: WARN
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
metrics:
export:
prometheus:
enabled: true
@Component
public class CustomMetrics {
private final MeterRegistry meterRegistry;
private final Counter requestCounter;
private final Timer requestTimer;
public CustomMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.requestCounter = Counter.builder("custom.requests")
.description("Number of custom requests")
.register(meterRegistry);
this.requestTimer = Timer.builder("custom.request.duration")
.description("Duration of custom requests")
.register(meterRegistry);
}
public void incrementRequestCounter() {
requestCounter.increment();
}
public Timer.Sample startTimer() {
return Timer.start(meterRegistry);
}
}
문제: 10만 건의 주문 데이터를 조회할 때 30초 소요
해결 과정:
결과: 30초 → 3초로 단축
@Query(value = "SELECT o.id, o.orderDate, o.totalAmount FROM Order o WHERE o.status = :status",
countQuery = "SELECT COUNT(o) FROM Order o WHERE o.status = :status")
Page<OrderSummary> findOrderSummariesByStatus(@Param("status") OrderStatus status, Pageable pageable);
문제: 외부 API 호출로 인한 응답 지연
해결 방법:
@Service
public class OptimizedApiService {
@Cacheable(value = "externalData", key = "#id")
public CompletableFuture<ApiResponse> getDataAsync(String id) {
return webClient.get()
.uri("/data/{id}", id)
.retrieve()
.bodyToMono(ApiResponse.class)
.timeout(Duration.ofSeconds(5))
.retry(3)
.toFuture();
}
}
<!-- JMeter 테스트 계획 예시 -->
<TestPlan>
<ThreadGroup>
<elementProp name="ThreadGroup.main_controller">
<LoopController>
<stringProp name="LoopController.loops">100</stringProp>
</LoopController>
</elementProp>
<stringProp name="ThreadGroup.num_threads">50</stringProp>
<stringProp name="ThreadGroup.ramp_time">10</stringProp>
</ThreadGroup>
</TestPlan>
@Component
public class PerformanceTest {
@Test
public void testResponseTime() {
long startTime = System.currentTimeMillis();
// 테스트할 로직 실행
String result = service.processRequest(request);
long endTime = System.currentTimeMillis();
long responseTime = endTime - startTime;
assertThat(responseTime).isLessThan(1000); // 1초 이내
}
}
성능 최적화는 한 번에 모든 것을 개선하려고 하지 말고, 단계적으로 접근하는 것이 좋다.
먼저 성능 모니터링을 통해 병목 지점을 파악하고, 그 다음에 최적화 작업을 진행하는 것이 효율적이다.
최적화 작업 순서:
모든 최적화는 실제 환경에서 테스트해보고, 개선 효과를 측정한 후에 적용하는 것이 중요하다.