5. 튜닝
5.1. application.properties 튜닝
max-threads, maximum-pool-size 등을 늘려서 요청 병렬 처리 성능을 높일 수 있다.
[1] application.properties 수정
spring.application.name=step03_app_test
server.address=0.0.0.0
# Server Configuration
server.port=8080
# Redis Configuration
spring.data.redis.host=127.0.0.1
spring.data.redis.client-name=default
spring.data.redis.password=redis1234
spring.data.redis.port=6379
spring.data.redis.timeout=2000
spring.data.redis.lettuce.pool.max-active=8
spring.data.redis.lettuce.pool.max-idle=8
spring.data.redis.lettuce.pool.min-idle=0
spring.data.redis.lettuce.pool.max-wait=-1
# Datasource
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/testdb?useSSL=false&serverTimezone=Asia/Seoul
spring.datasource.username=testuser
spring.datasource.password=testpass
#spring.datasource.hikari.maximum-pool-size=20
#spring.datasource.hikari.minimum-idle=10
#spring.datasource.hikari.connection-timeout=30000
#spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000
# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=DEBUG
###########################################
# 튜 닝 설 정 #
###########################################
## Tomcat max-threads
server.tomcat.max-threads=100
server.tomcat.accept-count=200
server.connection-timeout=20000
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.idle-timeout=30000
spring.datasource.hikari.connection-timeout=30000
###########################################
[2] 배포 및 테스트 수행
1. .jar 파일 재빌드
2. Ubuntu로 SFTP 전송
3. 기존 프로세스 종료 후 재실행
ps -ef | grep java
sudo kill -9 <PID>
nohup java -jar step03_app_test-0.0.1-SNAPSHOT.jar &
k6 run --vus 100 --duration 30s get-user-ubuntu-script.js
5.2. JVM 튜닝
이번엔 JVM 자체의 힙 메모리 설정을 조정하고 GC 로그를 분석해 보자.
[1] JVM 설정
nohup java -Xms512m -Xmx1024m \
-Xlog:gc*:file=gc.log:time,uptime,level,tags \
-jar step03_app_test-0.0.1-SNAPSHOT.jar &
[1] Xms, Xmx: 힙 초기/최대 크기를 지정
[2] -Xlog:gc*: GC 로그를 gc.log 파일로 저장
[2] JVM 튜닝 후 다시 성능 테스트
k6 run --vus 100 --duration 30s get-user-ubuntu-script.js
✅ vCPU + RAM 별 힙 크기 추천 (Xmx 기준)
| vCPU | RAM | 권장 Heap 크기 | 비고 |
| 1 | 1GB | 512MB ~ 768MB | 작은 힙, 작은 GC pause |
| 1 | 2GB | 1GB | Java 11 이상에서 G1GC/ZGC 권장 |
| 2 | 2~4GB | 1GB ~ 2GB | GC 튜닝에 따라 조절 가능 |
| 4 | 4~8GB | 2GB ~ 4GB | GC pause 감소 목적 |
| 8 | 8~16GB | 4GB ~ 8GB | G1GC, ZGC, Shenandoah 등 권장 |
| 16+ | 32GB+ | 8GB ~ 16GB+ | GC 튜닝 필수, 세부 옵션 조정 필요 |
💡 일반적으로 RAM의 25~50% 범위에서 설정한다. vCPU가 적을수록 너무 큰 힙은 피하는 것이 좋다 (GC 오버헤드 발생 가능성).
✅ JVM 힙 및 GC 설정 예시
1. 일반 환경
# 2 vCPU + 4GB RAM
java -Xms1g -Xmx1g -XX:+UseG1GC -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar
2. 중간 규모 서버
# 4 vCPU + 8GB RAM
java -Xms2g -Xmx2g -XX:+UseG1GC -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar
✅ Kubernetes(HPA) 환경에서의 JVM 힙 설계
HPA(Horizontal Pod Autoscaler)가 적용된 환경이라면, JVM 힙 설계 방식도 다르다.
| HPA 여부 | 힙 설계 전략 |
| ❌ 없음 | 가능한 큰 힙 설정 → GC 최소화 |
| ✅ 있음 | 작은 힙 + 빠른 GC → Pod 수 증가로 부하 분산 |
HPA + JVM 설계 시 권장
- Pod 1개 RAM 1~2GB → Heap 512MB ~ 1GB
- Pod 1개 RAM 4GB → Heap 1~2GB
- Pod 1개 RAM 8GB → Heap 2~3GB
5.3. MySQL Slow Query Log 튜닝
실제 서비스에서 데이터베이스 성능 문제를 추적할 때 가장 먼저 보는 로그 중 하나가 슬로우 쿼리 로그이다. 이 로그를 통해 응답 시간이 오래 걸리는 쿼리, 인덱스를 사용하지 않는 쿼리 등을 찾아낼 수 있다.
MySQL은 지정된 시간 이상 소요된 쿼리를 로그 파일에 기록하는 기능을 제공한다. 이걸 슬로우 쿼리 로그(Slow Query Log)라고 부른다.
| 항목 | 설명 |
| 대상 쿼리 | long_query_time 이상 걸린 쿼리 (기본 10초) |
| 기록 내용 | 실행 SQL, 실행 시간, 락 시간, 검사한 row 수 등 |
| 로그 위치 | 보통 /var/lib/mysql/slow.log (RDS는 CloudWatch) |
| 사용 조건 | slow_query_log = 1 + long_query_time 설정 필요 |
⚙️ 주요 설정 값 정리
| 설정 항목 | 예시 값 | 설명 |
| slow_query_log | 1 | 슬로우 쿼리 로그 활성화 |
| slow_query_log_file | /var/lib/mysql/slow.log | 로그 저장 경로 |
| long_query_time | 0.5 | 0.5초 이상 쿼리만 기록 (실무에선 보통 0.1~0.5) |
| log_queries_not_using_indexes | 1 | 인덱스를 사용하지 않은 쿼리도 기록 |
🛠️ 설정 방법
① my.cnf에 직접 설정 (로컬/VM 환경)
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/lib/mysql/slow.log
long_query_time = 0.5
log_queries_not_using_indexes = 1
② Docker 환경에서 설정
# docker-compose.yaml 예시
services:
mysql:
image: mysql:8.0
container_name: mysql-server
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: testdb
ports:
- "3306:3306"
volumes:
- ./my.cnf:/etc/mysql/conf.d/my.cnf
- mysql-data:/var/lib/mysql
command:
--slow_query_log=1
--slow_query_log_file=/var/lib/mysql/slow.log
--long_query_time=0.5
--log_queries_not_using_indexes=1
volumes:
mysql-data:
🔍 슬로우 쿼리 로그 확인
Docker/MySQL이 실행 중이라면 컨테이너 내부로 들어가 로그를 확인할 수 있다.
docker exec -it mysql-server bash
cat /var/lib/mysql/slow.log
| 항목 | 설명 |
| Query_time | SQL 실행 시간 |
| Lock_time | 테이블/행 락 대기 시간 |
| Rows_examined | 실제 조회한 row 수 |
| Rows_sent | 클라이언트로 보낸 row 수 |
☁️ AWS RDS에서 슬로우 쿼리 로그 설정
AWS RDS는 my.cnf를 직접 수정할 수 없기 때문에 Parameter Group을 통해 설정한다.
✅ 설정할 파라미터
| 파라미터 | 값 | 설명 |
| slow_query_log | 1 | 슬로우 쿼리 로그 켜기 |
| long_query_time | 0.5 | 0.5초 이상 쿼리만 기록 |
| log_queries_not_using_indexes | 1 | 인덱스 없는 쿼리도 기록 |
| log_output | FILE | 로그 파일로 저장 (권장) |
✅ 적용 순서
- RDS 콘솔 → Parameter Groups → 새 그룹 생성 또는 기존 그룹 수정
- 위 파라미터들 설정
- 해당 파라미터 그룹을 RDS 인스턴스에 연결
- 설정 반영을 위해 RDS 재시작
📌 CloudWatch Logs로 연동하면 로그를 외부에서도 쉽게 확인할 수 있다.
6. Redis 캐시 적용 (Read 성능 극대화 전략)
위의 테스트와 같이 애플리케이션에서 반복적으로 DB를 조회하는 API가 있다면, 캐시(Cache)를 활용해 응답 속도를 대폭 향상시킬 수 있다.
6.1. 환경 구성
[1] RedisConfig 수정
Spring에서 Redis 연결을 설정하고, 캐시 매니저(CacheManager)를 등록
@Configuration
@EnableCaching
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.password}")
String password;
// Redis 커넥션 객체 생성
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
config.setPassword(password);
return new LettuceConnectionFactory(config);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
@Bean
public CacheManager cacheManager(LettuceConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
[2] UserService 수정
@Cacheable: 조회 시 먼저 캐시에 있는지 확인하고, 없으면 DB 조회 후 캐시에 저장
@CacheEvict: 데이터 생성/수정 시 해당 캐시 무효화 (항상 최신 데이터 보장)
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
@CacheEvict(value = "userCache", allEntries = true)
public void initUsers() {
if (userRepository.count() == 0) {
IntStream.rangeClosed(1, 1000).forEach(i -> {
userRepository.save(User.builder()
.name("User" + i)
.email("user" + i + "@test.com")
.age(20 + (i % 30))
.build());
});
}
}
@Transactional(readOnly = true)
@Cacheable(value = "userCache", key = "#id")
public User findById(Long id) {
simulateDbDelay(); // DB hit 테스트 체감용 (선택)
return userRepository.findById(id).orElse(null);
}
@Transactional
@CacheEvict(value = "userCache", allEntries = true)
public List<User> createBulk(List<User> users) {
return userRepository.saveAll(users);
}
private void simulateDbDelay() {
try {
Thread.sleep(50); // DB hit 차이를 체감하기 위한 인위적 지연
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
[3] pom.xml
Redis 사용을 위한 필수 라이브러리: spring-boot-starter-data-redis, spring-boot-starter-cache 등
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>step03_app_test</groupId>
<artifactId>step03_app_test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>step03_app_test</name>
<description>step03_app_test</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.34</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.34</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
6.2. 캐시 동작 개념 정리
| 어노테이션 | 동작 방식 |
| @Cacheable(value = "userCache", key = "#id") | id로 조회 시, 캐시에 있으면 그 값을 리턴하고 DB는 호출하지 않음 |
| @CacheEvict(value = "userCache", allEntries = true) | 데이터 생성 또는 초기화 시, 모든 캐시 삭제 (신선한 상태 유지) |
6.3. 테스트
6.3.1. 1차 실행 (캐시가 없는 상태 - Miss)
[1] 테스트 실행
k6 run get-user-ubuntu-script.js
[2] 결과 확인
첫 요청이므로 Redis에 캐시된 데이터가 없고, 모든 요청이 DB까지 조회하게 되어 응답이 느리다.
ubuntu@ubuntu:~/k6$ k6 run --vus 10 --duration 30s get-user-ubuntu-script.js
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: get-user-ubuntu-script.js
output: -
scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration (incl. graceful stop):
* default: 10 looping VUs for 30s (gracefulStop: 30s)
INFO[0000] 테스트 시작 source=console
INFO[0031] 테스트 종료 source=console
█ TOTAL RESULTS
checks_total.......................: 484 15.618815/s
checks_succeeded...................: 93.59% 453 out of 484
checks_failed......................: 6.40% 31 out of 484
✓ status is 200
✗ response time < 500ms
↳ 87% — ✓ 211 / ✗ 31
HTTP
http_req_duration.......................................................: avg=227.34ms min=10.29ms med=61.83ms max=2.05s p(90)=602.85ms p(95)=1.18s
{ expected_response:true }............................................: avg=227.34ms min=10.29ms med=61.83ms max=2.05s p(90)=602.85ms p(95)=1.18s
http_req_failed.........................................................: 0.00% 0 out of 242
http_reqs...............................................................: 242 7.809408/s
EXECUTION
iteration_duration......................................................: avg=1.26s min=1.01s med=1.07s max=3.07s p(90)=1.66s p(95)=2.18s
iterations..............................................................: 242 7.809408/s
vus.....................................................................: 4 min=4 max=10
vus_max.................................................................: 10 min=10 max=10
NETWORK
data_received...........................................................: 44 kB 1.4 kB/s
data_sent...............................................................: 20 kB 633 B/s
running (0m31.0s), 00/10 VUs, 242 complete and 0 interrupted iterations
6.3.2. 2차 실행 (캐시가 있는 상태 - Hit)
[1] 테스트 실행
k6 run get-user-ubuntu-script.js
[2] 결과 확인
이미 Redis에 user1 정보가 캐시된 상태이므로, 모든 요청이 캐시에서 즉시 응답되어 성능이 대폭 개선된다.
ubuntu@ubuntu:~/k6$ k6 run --vus 10 --duration 30s get-user-ubuntu-script.js
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: get-user-ubuntu-script.js
output: -
scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration (incl. graceful stop):
* default: 10 looping VUs for 30s (gracefulStop: 30s)
INFO[0000] 테스트 시작 source=console
INFO[0031] 테스트 종료 source=console
█ TOTAL RESULTS
checks_total.......................: 568 18.32562/s
checks_succeeded...................: 100.00% 568 out of 568
checks_failed......................: 0.00% 0 out of 568
✓ status is 200
✓ response time < 500ms
HTTP
http_req_duration.......................................................: avg=66.16ms min=10.81ms med=47.63ms max=302.52ms p(90)=153.86ms p(95)=192.22ms
{ expected_response:true }............................................: avg=66.16ms min=10.81ms med=47.63ms max=302.52ms p(90)=153.86ms p(95)=192.22ms
http_req_failed.........................................................: 0.00% 0 out of 284
http_reqs...............................................................: 284 9.16281/s
EXECUTION
iteration_duration......................................................: avg=1.08s min=1.01s med=1.05s max=1.5s p(90)=1.17s p(95)=1.22s
iterations..............................................................: 284 9.16281/s
vus.....................................................................: 10 min=10 max=10
vus_max.................................................................: 10 min=10 max=10
NETWORK
data_received...........................................................: 52 kB 1.7 kB/s
data_sent...............................................................: 23 kB 742 B/s
running (0m31.0s), 00/10 VUs, 284 complete and 0 interrupted iterations
default ✓ [======================================] 10 VUs 30s
✅ 결과 요약
| 구분 | 캐시 미적용 (Miss) | 캐시 적용 (Hit) |
| 평균 응답 시간 | 약 227ms | 약 66ms |
| 성공률 | 93.5% | 100% |
| 느린 응답 수 | 31건 | 0건 |
'Test > k6' 카테고리의 다른 글
| [4] k6: 테스트 시각화(k6, InfluxDB, Grafana 대시보드 구축) (0) | 2025.12.13 |
|---|---|
| [2] k6: 실전 API 부하 테스트(Spring Boot 환경 구축과 트러블 슈팅) (0) | 2025.12.13 |
| [1] k6: 성능 테스트 기초(테스트 이론과 k6 입문) (0) | 2025.07.04 |
