대용량 트래픽 처리를 위한 Spring Boot 성능 최적화: JVM 튜닝부터 아키텍처까지
대용량 트래픽 처리를 위한 Spring Boot 성능 최적화: JVM 튜닝부터 아키텍처까지
서비스가 성장하면서 피할 수 없는 숙제가 바로 성능 최적화입니다. 처음에는 몇 백 명의 사용자도 버거웠던 시스템이 수만 명, 수십만 명의 동시 사용자를 감당해야 하는 상황이 됩니다. 이번 글에서는 실무에서 겪은 성능 병목 지점들과 이를 해결한 구체적인 방법들을 단계별로 정리해보겠습니다.
1. 성능 최적화, 왜 필요한가?
1.1 실무에서 겪는 성능 문제들
사용자 경험 악화:
- 페이지 로딩 시간 3초 → 40% 이탈률 증가
- API 응답 시간 1초 → 사용자 만족도 급감
- 시스템 다운 → 비즈니스 손실 직결
비용 증가:
성능 최적화 없는 확장 = 서버 비용 10배 증가
최적화 후 확장 = 서버 비용 2-3배 증가
개발팀 생산성 저하:
- 장애 대응으로 인한 개발 리소스 소모
- 임시방편 해결책의 기술 부채 누적
- 새로운 기능 개발 지연
1.2 성능 최적화의 우선순위
실무에서는 측정 → 병목 식별 → 개선 → 재측정 순서로 접근해야 합니다.
1. 모니터링 구축 (가장 중요!)
2. 병목 지점 식별
3. 영향도 큰 순서대로 개선
4. 성능 테스트로 검증
5. 지속적인 모니터링
2. 성능 모니터링: 문제를 찾아야 해결할 수 있다
2.1 APM 도구 도입
Pinpoint 설정 (오픈소스 APM)
# docker-compose.yml
version: '3.8'
services:
pinpoint-hbase:
image: pinpointdocker/pinpoint-hbase:2.5.1
container_name: pinpoint-hbase
pinpoint-web:
image: pinpointdocker/pinpoint-web:2.5.1
container_name: pinpoint-web
depends_on:
- pinpoint-hbase
ports:
- "8080:8080"
environment:
- CLUSTER_ENABLE=true
- HBASE_HOST=pinpoint-hbase
pinpoint-collector:
image: pinpointdocker/pinpoint-collector:2.5.1
container_name: pinpoint-collector
depends_on:
- pinpoint-hbase
ports:
- "9994:9994"
- "9995:9995"
- "9996:9996"
Spring Boot 애플리케이션에 Agent 적용:
# JVM 옵션에 Pinpoint Agent 추가
java -javaagent:/path/to/pinpoint-bootstrap.jar \
-Dpinpoint.agentId=my-app-01 \
-Dpinpoint.applicationName=my-application \
-jar my-app.jar
2.2 JVM 메트릭 모니터링
Micrometer + Prometheus 설정:
<!-- pom.xml -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# application.yml
management:
endpoints:
web:
exposure:
include: prometheus, metrics, health
endpoint:
metrics:
enabled: true
prometheus:
enabled: true
metrics:
export:
prometheus:
enabled: true
커스텀 메트릭 수집:
@Component
public class PerformanceMetrics {
private final MeterRegistry meterRegistry;
private final Counter errorCounter;
private final Timer responseTimer;
public PerformanceMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.errorCounter = Counter.builder("api.errors")
.description("API error count")
.register(meterRegistry);
this.responseTimer = Timer.builder("api.response.time")
.description("API response time")
.register(meterRegistry);
}
public void recordError(String endpoint, String errorType) {
errorCounter.increment(
Tags.of("endpoint", endpoint, "error.type", errorType)
);
}
public void recordResponseTime(String endpoint, Duration duration) {
responseTimer.record(duration, Tags.of("endpoint", endpoint));
}
}
3. JVM 튜닝: 기반부터 탄탄하게
3.1 힙 메모리 최적화
메모리 사용량 분석:
# 힙 덤프 생성
jcmd <pid> GC.run_finalization
jcmd <pid> VM.gc
jmap -dump:format=b,file=heap.hprof <pid>
# VisualVM 또는 Eclipse MAT로 분석
최적화된 JVM 옵션:
# 프로덕션 환경 JVM 설정 (16GB 서버 기준)
java -server \
-Xms8g -Xmx8g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:G1HeapRegionSize=16m \
-XX:+ParallelRefProcEnabled \
-XX:+UseStringDeduplication \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseCGroupMemoryLimitForHeap \
-Djava.awt.headless=true \
-Dfile.encoding=UTF-8 \
-jar my-app.jar
메모리 설정 가이드라인:
- Heap Size: 물리 메모리의 50-75%
- Young Generation: Heap의 25-40%
- Old Generation: 나머지
3.2 가비지 컬렉터 선택과 튜닝
G1GC 튜닝 (Java 8+):
# G1GC 상세 설정
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100 # 목표 GC 중단 시간
-XX:G1HeapRegionSize=16m # 리전 크기
-XX:G1NewSizePercent=20 # Young Generation 최소 비율
-XX:G1MaxNewSizePercent=30 # Young Generation 최대 비율
-XX:G1ReservePercent=15 # 여유 공간
-XX:ConcGCThreads=4 # 동시 GC 스레드 수
ZGC 적용 (Java 17+, 대용량 힙):
# ZGC 설정 (64GB+ 힙 메모리)
-XX:+UnlockExperimentalVMOptions
-XX:+UseZGC
-XX:SoftMaxHeapSize=30g # 소프트 제한
-Xmx32g # 하드 제한
GC 로그 분석:
# GC 로그 활성화
-Xlog:gc*:gc.log:time,level,tags
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=10M
@Component
public class GCMonitor {
private final Logger logger = LoggerFactory.getLogger(GCMonitor.class);
@EventListener
public void handleGCEvent(GCEvent event) {
if (event.getPauseTime() > Duration.ofMillis(200)) {
logger.warn("Long GC pause detected: {}ms, Type: {}, Before: {}MB, After: {}MB",
event.getPauseTime().toMillis(),
event.getGcType(),
event.getMemoryUsageBeforeGc() / 1024 / 1024,
event.getMemoryUsageAfterGc() / 1024 / 1024
);
}
}
}
3.3 스레드 풀 최적화
적정 스레드 수 계산:
CPU 집약적 작업: 스레드 수 = CPU 코어 수 + 1
I/O 집약적 작업: 스레드 수 = CPU 코어 수 × (1 + 대기시간/처리시간)
예시: 8코어, API 처리시간 50ms, DB 대기시간 200ms
스레드 수 = 8 × (1 + 200/50) = 8 × 5 = 40
스레드 풀 모니터링:
@Component
public class ThreadPoolMonitor {
private final MeterRegistry meterRegistry;
@Autowired
private ThreadPoolTaskExecutor taskExecutor;
@Scheduled(fixedRate = 5000)
public void monitorThreadPool() {
Gauge.builder("thread.pool.active")
.register(meterRegistry, taskExecutor, ThreadPoolTaskExecutor::getActiveCount);
Gauge.builder("thread.pool.queue.size")
.register(meterRegistry, taskExecutor, executor -> executor.getThreadPoolExecutor().getQueue().size());
Gauge.builder("thread.pool.max")
.register(meterRegistry, taskExecutor, ThreadPoolTaskExecutor::getMaxPoolSize);
}
}
4. Spring Boot 애플리케이션 최적화
4.1 Tomcat 설정 최적화
# application.yml
server:
tomcat:
threads:
max: 200 # 최대 스레드 수
min-spare: 20 # 최소 대기 스레드 수
max-connections: 8192 # 최대 동시 연결 수
accept-count: 200 # 큐 대기 연결 수
connection-timeout: 20000 # 연결 타임아웃 (20초)
keep-alive-timeout: 20000 # Keep-Alive 타임아웃
max-keep-alive-requests: 100 # Keep-Alive 최대 요청 수
compression:
enabled: true
mime-types:
- application/json
- application/xml
- text/html
- text/xml
- text/plain
- text/css
- text/javascript
- application/javascript
min-response-size: 1024
Undertow로 변경 (더 나은 성능):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
server:
undertow:
threads:
io: 16 # I/O 스레드 (CPU 코어 수 * 2)
worker: 200 # 워커 스레드
buffer-size: 16384 # 버퍼 크기 (16KB)
direct-buffers: true # 다이렉트 버퍼 사용
4.2 비동기 처리 최적화
비동기 컨트롤러:
@RestController
public class AsyncController {
@Autowired
private UserService userService;
@GetMapping("/users/{id}")
public CompletableFuture<ResponseEntity<User>> getUser(@PathVariable Long id) {
return userService.getUserAsync(id)
.thenApply(user -> ResponseEntity.ok(user))
.exceptionally(throwable -> {
logger.error("Error getting user", throwable);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
});
}
@GetMapping("/users/{id}/profile")
public DeferredResult<ResponseEntity<UserProfile>> getUserProfile(@PathVariable Long id) {
DeferredResult<ResponseEntity<UserProfile>> deferredResult =
new DeferredResult<>(5000L); // 5초 타임아웃
deferredResult.onTimeout(() ->
deferredResult.setErrorResult(
ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).build()
)
);
userService.getUserProfileAsync(id)
.whenComplete((profile, throwable) -> {
if (throwable != null) {
deferredResult.setErrorResult(
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()
);
} else {
deferredResult.setResult(ResponseEntity.ok(profile));
}
});
return deferredResult;
}
}
WebFlux 도입 (리액티브 스택):
@RestController
public class ReactiveController {
@Autowired
private ReactiveUserService userService;
@GetMapping("/users")
public Flux<User> getUsers() {
return userService.getAllUsers()
.doOnSubscribe(subscription -> logger.info("Starting user stream"))
.doOnComplete(() -> logger.info("User stream completed"))
.onErrorResume(throwable -> {
logger.error("Error in user stream", throwable);
return Flux.empty();
});
}
@GetMapping("/users/{id}")
public Mono<ResponseEntity<User>> getUser(@PathVariable Long id) {
return userService.getUser(id)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build())
.onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
}
}
4.3 캐시 최적화
다층 캐시 전략:
@Service
public class OptimizedUserService {
private final LoadingCache<Long, User> localCache;
private final RedisTemplate<String, User> redisTemplate;
private final UserRepository userRepository;
public OptimizedUserService(UserRepository userRepository,
RedisTemplate<String, User> redisTemplate) {
this.userRepository = userRepository;
this.redisTemplate = redisTemplate;
this.localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build(this::loadUserFromRedisOrDb);
}
public User getUser(Long id) {
try {
// 1차: 로컬 캐시
return localCache.get(id);
} catch (Exception e) {
logger.error("Cache error for user {}", id, e);
// 캐시 실패 시 직접 DB 조회
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id));
}
}
private User loadUserFromRedisOrDb(Long id) {
// 2차: Redis 캐시
String key = "user:" + id;
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 3차: 데이터베이스
user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id));
// Redis에 캐시 (TTL 1시간)
redisTemplate.opsForValue().set(key, user, Duration.ofHours(1));
return user;
}
@Scheduled(fixedRate = 60000)
public void reportCacheStats() {
CacheStats stats = localCache.stats();
logger.info("Local cache stats - Hit rate: {:.2f}%, Miss count: {}, Eviction count: {}",
stats.hitRate() * 100,
stats.missCount(),
stats.evictionCount()
);
}
}
5. 데이터베이스 연결 최적화
5.1 Connection Pool 튜닝
HikariCP 최적화:
spring:
datasource:
hikari:
maximum-pool-size: 20 # 최대 커넥션 수
minimum-idle: 5 # 최소 유휴 커넥션 수
connection-timeout: 20000 # 커넥션 타임아웃 (20초)
idle-timeout: 300000 # 유휴 타임아웃 (5분)
max-lifetime: 1200000 # 커넥션 최대 생명 시간 (20분)
validation-timeout: 5000 # 커넥션 검증 타임아웃
leak-detection-threshold: 60000 # 커넥션 누수 감지 (1분)
pool-name: HikariPool-1
register-mbeans: true # JMX 모니터링 활성화
적정 Pool Size 계산:
Pool Size = ((코어 수 × 2) + 디스크 수)
예시: 8코어, SSD 1개
Pool Size = (8 × 2) + 1 = 17 ≈ 20
단, 다음 요소들도 고려:
- 평균 쿼리 실행 시간
- 동시 접속자 수
- 애플리케이션 인스턴스 수
Connection Pool 모니터링:
@Component
public class ConnectionPoolMonitor {
@Autowired
private HikariDataSource dataSource;
@Autowired
private MeterRegistry meterRegistry;
@PostConstruct
public void setupMonitoring() {
Gauge.builder("hikari.connections.active")
.register(meterRegistry, dataSource.getHikariPoolMXBean(),
pool -> pool.getActiveConnections());
Gauge.builder("hikari.connections.idle")
.register(meterRegistry, dataSource.getHikariPoolMXBean(),
pool -> pool.getIdleConnections());
Gauge.builder("hikari.connections.total")
.register(meterRegistry, dataSource.getHikariPoolMXBean(),
pool -> pool.getTotalConnections());
}
@Scheduled(fixedRate = 30000)
public void logPoolStats() {
HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
if (pool.getActiveConnections() > pool.getTotalConnections() * 0.8) {
logger.warn("Connection pool usage high: {}/{}",
pool.getActiveConnections(), pool.getTotalConnections());
}
}
}
5.2 쿼리 최적화
슬로우 쿼리 모니터링:
spring:
jpa:
properties:
hibernate:
session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 100 # 100ms 이상 쿼리 로깅
show-sql: false # 프로덕션에서는 false
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
배치 처리 최적화:
@Repository
public class OptimizedUserRepository {
@PersistenceContext
private EntityManager entityManager;
@Transactional
public void batchInsertUsers(List<User> users) {
int batchSize = 50;
for (int i = 0; i < users.size(); i++) {
entityManager.persist(users.get(i));
if (i % batchSize == 0 && i > 0) {
entityManager.flush();
entityManager.clear();
}
}
entityManager.flush();
entityManager.clear();
}
@Transactional
public void batchUpdateUsers(List<User> users) {
String sql = """
UPDATE users
SET name = :name, email = :email, updated_at = :updatedAt
WHERE id = :id
""";
Query query = entityManager.createNativeQuery(sql);
for (User user : users) {
query.setParameter("name", user.getName())
.setParameter("email", user.getEmail())
.setParameter("updatedAt", LocalDateTime.now())
.setParameter("id", user.getId());
query.executeUpdate();
}
}
}
5.3 읽기/쓰기 분리
Master/Slave 설정:
@Configuration
public class DatabaseConfig {
@Bean
@Primary
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.slave")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource routingDataSource() {
ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("master", masterDataSource());
dataSourceMap.put("slave", slaveDataSource());
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(masterDataSource());
return routingDataSource;
}
}
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "slave" : "master";
}
}
읽기/쓰기 분리 활용:
@Service
@Transactional(readOnly = true) // 기본적으로 읽기 전용 (Slave 사용)
public class UserService {
@Transactional // 쓰기 작업 (Master 사용)
public User createUser(UserCreateRequest request) {
User user = new User(request.getName(), request.getEmail());
return userRepository.save(user);
}
// 읽기 작업 (Slave 사용)
public User getUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found"));
}
// 읽기 작업 (Slave 사용)
public Page<User> getUsers(Pageable pageable) {
return userRepository.findAll(pageable);
}
}
6. 아키텍처 레벨 최적화
6.1 로드 밸런서 설정
Nginx 설정:
upstream backend {
least_conn; # 연결 수가 적은 서버 우선
server app1:8080 max_fails=3 fail_timeout=30s;
server app2:8080 max_fails=3 fail_timeout=30s;
server app3:8080 max_fails=3 fail_timeout=30s;
keepalive 32; # Keep-alive 연결 수
}
server {
listen 80;
server_name api.example.com;
# Gzip 압축
gzip on;
gzip_types application/json application/javascript text/css text/javascript;
gzip_min_length 1000;
# 클라이언트 요청 크기 제한
client_max_body_size 10M;
# 타임아웃 설정
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Keep-alive 설정
proxy_http_version 1.1;
proxy_set_header Connection "";
# 캐시 설정 (정적 리소스)
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Health Check 엔드포인트
location /health {
access_log off;
proxy_pass http://backend/actuator/health;
}
}
6.2 CDN 및 정적 리소스 최적화
AWS CloudFront 설정 예시:
# cloudformation.yml
Resources:
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Origins:
- Id: ApiOrigin
DomainName: api.example.com
CustomOriginConfig:
HTTPPort: 80
HTTPSPort: 443
OriginProtocolPolicy: https-only
DefaultCacheBehavior:
TargetOriginId: ApiOrigin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # Managed-CachingDisabled
Compress: true
CacheBehaviors:
- PathPattern: "/static/*"
TargetOriginId: ApiOrigin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # Managed-CachingOptimized
TTL: 31536000 # 1년
정적 리소스 최적화:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(365))
.cachePublic())
.resourceChain(true)
.addResolver(new VersionResourceResolver()
.addContentVersionStrategy("/**"));
}
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.favorParameter(false)
.ignoreAcceptHeader(false)
.defaultContentType(MediaType.APPLICATION_JSON)
.mediaType("json", MediaType.APPLICATION_JSON)
.mediaType("xml", MediaType.APPLICATION_XML);
}
}
7. 성능 테스트 및 측정
7.1 JMeter 부하 테스트
기본 부하 테스트 시나리오:
<!-- jmeter-test-plan.jmx -->
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="API Performance Test">
<stringProp name="TestPlan.arguments"></stringProp>
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
</TestPlan>
<hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="User Load">
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">100</stringProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">50</stringProp>
<stringProp name="ThreadGroup.ramp_time">30</stringProp>
</ThreadGroup>
</hashTree>
</hashTree>
</jmeterTestPlan>
자동화된 성능 테스트:
#!/bin/bash
# performance-test.sh
echo "Starting performance test..."
# JMeter 실행
jmeter -n -t api-test-plan.jmx -l results.jtl -e -o report
# 결과 분석
python3 analyze-results.py results.jtl
# 임계값 체크
RESPONSE_TIME_P95=$(grep "95th percentile" report/statistics.json | cut -d: -f2)
ERROR_RATE=$(grep "Error rate" report/statistics.json | cut -d: -f2)
if [ "$RESPONSE_TIME_P95" -gt 1000 ]; then
echo "❌ 95th percentile response time too high: ${RESPONSE_TIME_P95}ms"
exit 1
fi
if [ "$ERROR_RATE" -gt 1 ]; then
echo "❌ Error rate too high: ${ERROR_RATE}%"
exit 1
fi
echo "✅ Performance test passed"
7.2 지속적 성능 모니터링
자동 알림 설정:
@Component
public class PerformanceAlert {
private final SlackWebhookClient slackClient;
private final MeterRegistry meterRegistry;
@Scheduled(fixedRate = 60000) // 1분마다 체크
public void checkPerformanceMetrics() {
// 응답 시간 체크
Timer responseTimer = meterRegistry.find("http.server.requests").timer();
if (responseTimer != null) {
Duration p95 = Duration.ofNanos((long) responseTimer.percentile(0.95));
if (p95.toMillis() > 1000) { // 1초 초과
sendAlert("High Response Time",
String.format("95th percentile response time: %dms", p95.toMillis()));
}
}
// 에러율 체크
Counter errorCounter = meterRegistry.find("http.server.requests")
.tag("status", "5xx").counter();
Counter totalCounter = meterRegistry.find("http.server.requests").counter();
if (errorCounter != null && totalCounter != null) {
double errorRate = (errorCounter.count() / totalCounter.count()) * 100;
if (errorRate > 5.0) { // 5% 초과
sendAlert("High Error Rate",
String.format("Error rate: %.2f%%", errorRate));
}
}
// 메모리 사용량 체크
MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
double heapUsagePercent = (double) heapUsage.getUsed() / heapUsage.getMax() * 100;
if (heapUsagePercent > 80) { // 80% 초과
sendAlert("High Memory Usage",
String.format("Heap usage: %.2f%%", heapUsagePercent));
}
}
private void sendAlert(String title, String message) {
slackClient.send(SlackMessage.builder()
.channel("#alerts")
.username("Performance Bot")
.text(String.format("🚨 %s: %s", title, message))
.build());
}
}
8. 실무 적용 사례와 결과
8.1 Before/After 성능 비교
최적화 전:
- 평균 응답 시간: 2.5초
- 95th percentile: 8초
- 처리량: 50 TPS
- 에러율: 15%
- 서버 리소스: CPU 80%, Memory 90%
최적화 후:
- 평균 응답 시간: 300ms (83% 개선)
- 95th percentile: 800ms (90% 개선)
- 처리량: 300 TPS (6배 향상)
- 에러율: 0.5% (30배 개선)
- 서버 리소스: CPU 40%, Memory 60%
8.2 최적화 단계별 효과
1단계: JVM 튜닝
- G1GC 적용 + 힙 메모리 최적화
- 응답 시간 30% 개선
- GC 중단 시간 80% 감소
2단계: Connection Pool 최적화
- HikariCP 설정 튜닝
- 데이터베이스 응답 시간 50% 개선
- Connection timeout 에러 90% 감소
3단계: 캐시 도입
- Redis 캐시 + 로컬 캐시
- 응답 시간 70% 개선
- 데이터베이스 부하 80% 감소
4단계: 비동기 처리
- CompletableFuture + 스레드 풀 최적화
- 처리량 300% 향상
- 자원 사용률 50% 개선
8.3 ROI 분석
비용 절감:
서버 비용: 월 $5,000 → $2,000 (60% 절감)
운영 비용: 장애 대응 시간 80% 감소
개발 비용: 성능 개선으로 신기능 개발에 집중 가능
비즈니스 영향:
사용자 만족도: 70% → 95% (25% 향상)
이탈률: 40% → 15% (25% 개선)
매출 영향: 성능 개선으로 인한 전환율 20% 향상
9. 성능 최적화 체크리스트
9.1 모니터링 설정
- APM 도구 설치 (Pinpoint, New Relic 등)
- JVM 메트릭 수집 (Micrometer + Prometheus)
- 비즈니스 메트릭 정의 및 수집
- 알림 설정 (응답 시간, 에러율, 리소스 사용률)
- 대시보드 구성 (Grafana, DataDog 등)
9.2 JVM 최적화
- 적절한 힙 메모리 크기 설정
- GC 알고리즘 선택 및 튜닝
- GC 로그 분석 환경 구축
- 스레드 덤프 분석 도구 준비
- 메모리 누수 모니터링
9.3 애플리케이션 최적화
- 웹 서버 설정 최적화 (Tomcat/Undertow)
- 스레드 풀 크기 최적화
- 비동기 처리 도입
- 캐시 전략 수립 및 구현
- 데이터베이스 쿼리 최적화
9.4 인프라 최적화
- 로드 밸런서 설정
- CDN 도입
- 데이터베이스 읽기/쓰기 분리
- 캐시 서버 구축 (Redis)
- 모니터링 서버 분리
9.5 지속적 관리
- 정기적인 성능 테스트
- 자동화된 성능 회귀 테스트
- 용량 계획 수립
- 장애 대응 플레이북 작성
- 성능 최적화 가이드라인 문서화
마무리
성능 최적화는 한 번에 끝나는 작업이 아닙니다. 측정 → 분석 → 개선 → 검증의 사이클을 지속적으로 반복해야 합니다.
가장 중요한 것은 무엇을 개선할지 아는 것입니다. 추측이 아닌 데이터를 기반으로 병목 지점을 찾고, 우선순위를 정해서 단계적으로 개선해야 합니다.
실무에서는 기술적 완벽함보다는 비즈니스 임팩트가 큰 개선사항부터 적용하는 것이 효과적입니다. 사용자 경험을 크게 개선하면서도 운영 비용을 절감할 수 있는 최적화 포인트를 찾는 것이 핵심입니다.
성능 최적화는 개발자의 성장에도 큰 도움이 됩니다. 시스템의 전체적인 동작 원리를 이해하게 되고, 비즈니스와 기술의 균형점을 찾는 안목을 기를 수 있습니다.
지속적인 모니터링과 개선을 통해 안정적이고 빠른 시스템을 만들어가시기 바랍니다!