이 글을 쓰게 된 배경
최근에 팀원들과 테이블 설계 과정에서 의견 갈등이 생겨 긴 시간 회의를 했습니다. 테이블 설계를 위해 여러가지 의견을 나눴는데 그 중에 서로 다른 견해를 가지고 있었던 것이 있었습니다. 그것은 바로 하나의 커넥션 비용과 여러개의 복잡한 로직 중 어느것이 비용이 더 큰가 에 대한 내용이었습니다. 저는 그래도 복잡한 로직이 100개 1000개가 넘어가면 복잡한 로직의 비용이 더 비쌀 것이라는 입장이었지만 팀원의 의견은 달랐습니다. 회의에서는 유지보수성의 이유로 결정을 내렸지만 저는 개인적으로 이 것이 궁금해서 이 글을 작성하게 되었습니다.
다룰 내용
- 커넥션 처리 과정
- 복잡합 로직 계산 비용
- 결론
- 실제 테스트
커넥션 처리 과정
커넥션 비용 계산에 앞서 커넥션이 어떻게 처리되는지 알고 있어야 어느 부분에서 성능 개선을 해야하는지 알 것 같아서 과정에 대해서 먼저 정리하겠습니다.
사용자가 데이터베이스에 접근해야 하는 상황이 생기면 데이터베이스 커넥션 이라는 것을 획득해야 합니다. 위 그림과 같은 과정으로 사용자는 데이터 베이스 커넥션을 얻게 됩니다. 이 과정은 데이터베이스마다 다르지만 보통 수~수십 ms정도의 시간이 걸린다고 합니다.
커넥션 풀
이 시간을 단축하기 위해서 커넥션 풀 이라는 아이디어를 사용합니다. 커넥션 풀은 어플리케이션을 시작할 때 커넥션을 미리 생성해두고 필요할 때마다 가져다 쓰는 방식의 아이디어입니다. 이 아이디어는 많은 라이브러리와 프레임워크에서 사용할 수 있으며 물론 스프링부트에서도 2.0 버전부터 hikariCP라는 오픈소스를 사용할 수 있습니다. 커넥션 풀의 또다른 장점으로는 커넥션 수를 조절하여 DB에 무한으로 연결이 생성되는 걸 보호하는 효과도 있습니다.
복잡한 로직 계산 비용
로직이 얼마나 복잡한 지는 결국 로직을 수행하는 시간이 얼마나 걸리냐로 판단할 수 있습니다. 저는 그래서 시간복잡도를 기준으로 시간이 얼마나 걸리는지을 알아보았습니다.
- O(1) 비용: μs~ms 수준
- O(n) 비용: 데이터 크기(n)에 따라 ms~수십 ms
- O(n^2) 비용: 데이터 크기에 따라 수십 ms~수백 ms
- O(n!) 비용: 수백 ms~초 단위
결론
하나의 커넥션 비용이 수~수십 ms 정도의 시간이 소모되기 때문에 O(n)까지는 로직 계산의 비용이 더 저렴하지만 O(n^2) 부터는 커넥션을 맺는 비용이 더 저렴하다는 것을 알 수 있습니다.
실제 테스트
위와 같은 결론은 내렸지만 실제로 스프링부트 프로젝트에서 걸리는 시간을 테스트 해보기 위해 제가 작업하고 있던 스프링부트 3.0 프로젝트에서 테스트를 진행해보았습니다. 또한 실무에서는 거의 대부분 커넥션 풀을 사용하기 때문에 커넥션을 맺는 시간이 더 짧아질 것 같아 커넥션 풀을 가져오는 시간과 로직 계산 비용을 비교해보겠습니다.
2024-12-22T17:55:54.201+09:00 INFO 77646 --- [scholarship] [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2024-12-22T17:55:54.253+09:00 INFO 77646 --- [scholarship] [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn0: url=jdbc:h2:tcp://localhost/~/scholarship user=SA
2024-12-22T17:55:54.254+09:00 INFO 77646 --- [scholarship] [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
위의 로그를 통해 스프링부트 프로젝트에서 hikariCP 커넥션 풀을 사용하고 있는 것을 확인할 수 있습니다.
커넥션 한개
테스트 코드와 결과입니다. 커넥션 풀에서 하나의 커넥션을 가져올 때는 0.02 ms로 아주 작은 시간이 걸리는 것을 확인할 수 있었습니다.
@Test
void testSingleConnectionTimeNano() throws Exception {
long startTime = System.nanoTime(); // 시작 시간 (나노초)
try (Connection connection = dataSource.getConnection()) {
long endTime = System.nanoTime(); // 끝난 시간 (나노초)
long duration = endTime - startTime; // 나노초 단위 소요 시간
System.out.println("커넥션 가져오는 데 걸린 시간: " + duration + " ns");
System.out.println("커넥션 가져오는 데 걸린 시간: " + duration / 1_000_000.0 + " ms"); // 밀리초로 변환
}
}
커넥션 가져오는 데 걸린 시간: 26458 ns
커넥션 가져오는 데 걸린 시간: 0.026458 ms
커넥션 여러개
커넥션 풀에서 여러개의 커넥션을 가져와서 사용할 때의 시간입니다. 병렬로 커넥션을 맺더라도 0.3 ms의 적은 시간이 걸리는 것을 확인할 수 있습니다.
@Test
void testParallelConnectionsNano() throws Exception {
int parallelTasks = 10;
long[] connectionTimes = new long[parallelTasks];
Thread[] threads = new Thread[parallelTasks];
for (int i = 0; i < parallelTasks; i++) {
int taskIndex = i;
threads[i] = new Thread(() -> {
try {
long startTime = System.nanoTime();
try (Connection connection = dataSource.getConnection()) {
long endTime = System.nanoTime();
connectionTimes[taskIndex] = endTime - startTime;
}
} catch (Exception e) {
connectionTimes[taskIndex] = -1; // 실패 시 -1 기록
}
});
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join(); // 모든 스레드 종료 대기
}
// 결과 분석
long totalTime = 0;
int successCount = 0;
for (long time : connectionTimes) {
if (time > 0) {
totalTime += time;
successCount++;
}
}
long averageTime = successCount > 0 ? totalTime / successCount : -1;
System.out.println("병렬 커넥션 평균 시간 (ns): " + averageTime);
System.out.println("병렬 커넥션 평균 시간 (ms): " + averageTime / 1_000_000.0);
assertThat(successCount).isEqualTo(parallelTasks);
}
병렬 커넥션 평균 시간 (ns): 343575
병렬 커넥션 평균 시간 (ms): 0.343575
O(1) 작업
다음은 조건이 많은 O(1)의 작업의 시간 비용을 테스트 해본 코드와 결과입니다.
@Test
void testMultipleIfStatements() {
int input = 5; // 조건을 평가할 값
// 시간 측정 시작
long startTime = System.nanoTime();
// 다수의 if문
if (input == 1) {
// 아무 작업도 하지 않음
} else if (input == 2) {
// 아무 작업도 하지 않음
} else if (input == 3) {
// 아무 작업도 하지 않음
} else if (input == 4) {
// 아무 작업도 하지 않음
} else if (input == 5) {
// 실행되는 조건
System.out.println("Input is 5");
} else {
// 기본 조건
}
// 시간 측정 종료
long endTime = System.nanoTime();
long duration = endTime - startTime;
System.out.println("조건 평가에 걸린 시간: " + duration + " ns");
System.out.println("조건 평가에 걸린 시간: " + duration / 1_000_000.0 + " ms");
}
조건 평가에 걸린 시간: 123750 ns
조건 평가에 걸린 시간: 0.12375 ms
O(n) 작업
다음은 O(n)작업을 하는데 걸리는 시간을 측정한 것 입니다. n은 약 적당한 양의 데이터를 다룬다고 가정하고 1000개 정도로 설정했습니다.
@Test
void testONTime() {
int n = 1000; // n의 크기
List<Integer> list = new ArrayList<>();
// 데이터 준비
for (int i = 0; i < n; i++) {
list.add(i);
}
// O(n) 작업 측정 시작
long startTime = System.nanoTime(); // 시작 시간
int sum = 0;
for (int num : list) {
sum += num; // O(n) 작업: 리스트 순회하며 합계 계산
}
long endTime = System.nanoTime(); // 종료 시간
long duration = endTime - startTime; // 실행 시간 (나노초 단위)
System.out.println("O(n) 작업에 걸린 시간: " + duration + " ns");
System.out.println("O(n) 작업에 걸린 시간: " + duration / 1_000_000.0 + " ms");
// 결과 검증
int expectedSum = (n - 1) * n / 2; // 0부터 n-1까지의 합 계산
assert sum == expectedSum : "합계가 예상과 다릅니다!";
}
O(n) 작업에 걸린 시간: 95000 ns
O(n) 작업에 걸린 시간: 0.095 ms
O(n^2)
다음은 O(n^2) 작업에 걸리는 시간입니다. 이전에 비해서 생각보다 많은 시간 비용이 드는 것을 확인할 수 있었습니다.
@Test
void testON2Time() {
int n = 1000; // n의 크기
List<Integer> list = new ArrayList<>();
// 데이터 준비
for (int i = 0; i < n; i++) {
list.add(i);
}
// O(n²) 작업 측정 시작
long startTime = System.nanoTime(); // 시작 시간
int sum = 0;
for (int i = 0; i < list.size(); i++) {
for (int j = 0; j < list.size(); j++) {
sum += list.get(i) + list.get(j); // 중첩 루프에서 합 계산
}
}
long endTime = System.nanoTime(); // 종료 시간
long duration = endTime - startTime; // 실행 시간 (나노초 단위)
System.out.println("O(n²) 작업에 걸린 시간: " + duration + " ns");
System.out.println("O(n²) 작업에 걸린 시간: " + duration / 1_000_000.0 + " ms");
// 결과 검증 (단순히 합이 음수가 아닌지 체크)
assert sum > 0 : "합계가 0 이하입니다!";
}
O(n²) 작업에 걸린 시간: 20425917 ns
O(n²) 작업에 걸린 시간: 20.425917 ms
단일 커넥션 | 병렬 커넥션 | O(1) | O(n) | O(n^2) |
0.02 ms | 0.34 ms | 0.1 ms | 0.095 ms | 20 ms |
표로 정리하면 다음과 같습니다.
조건을 여러개 설정하니까 O(1)의 작업 시간이 O(n)보다 더 커진 것이 의외였습니다.
테스트 후 결론
실제로 스프링부트 프로젝트에서 테스트를 해본 결과 차라리 커넥션을 맺어서 데이터베이스의 데이터를 활용하는 것이 새로 계산하는 것보다 낫다는 것을 알 수 있었습니다. 하지만 O(1)과 O(n)의 동작 시간이 예상과 달랐던 것 처럼 실제 성능을 테스트 해보는 것이 더 중요할 것 같습니다. 그래도 앞으로는 오늘 나온 결론을 기본적으로 염두에 두면 좋은 참고 자료가 될 것 같습니다.
'Spring' 카테고리의 다른 글
스프링 부트에서 예외와 처리 방법 (2) | 2024.12.30 |
---|---|
Spring Security 필터에서 발생한 인증/인가 예외 처리하는 방법 (2) | 2024.12.30 |
트랜잭션 by JPA (2) | 2024.12.17 |
연관관계 매핑이 꼭 필요한가 (2) | 2024.12.16 |
Spring Boot에서 Querydsl 사용 (0) | 2024.12.04 |