개요
앞선 글들에서 Tasklet 방식과 Processor 로직 최적화를 통해 애플리케이션 레벨에서의 성능 개선을 이루어냈다.
하지만 여전히 DB Insert라는 거대한 벽이 남아있었다.
이번 글에서는 DB Insert 성능을 극한으로 끌어올리기 위한 처절했던? 여정을 공유하고자 한다.
JdbcTemplate.batchUpdate()의 숨겨진 비밀부터 PreparedStatement의 깊은 이해, 병렬 처리 도전,
그리고 마침내 나를 거대한 깨달음으로 이끈 '클러스터드 인덱스'까지.
이 모든 삽질과 배움의 과정이 비슷한 문제로 고민하는 개발자들에게 작은 도움이 되길 바란다.
Bulk Insert? JDBC 필수 옵션! rewriteBatchedStatements
Bulk Insert를 위해 나는 이미 JdbcTemplate의 batchUpdate()를 사용 중이었다.
많은 개발자들이 이 방식을 사용하고 있을 거라 생각한다.
그런데 문득 MySQL에서 실제로 사용되는 쿼리를 확인했을 때,
내가 생각한 Bulk Insert 형태가 아닌, 단순한 여러 개의 개별 Insert 문으로 실행되고 있음을 발견했다.
이것이 JDBC 드라이버의 한계인지 확인해보다가 MySQL JDBC 문서에서 중요한 옵션을 발견했다.
"JDBC URL에 rewriteBatchedStatements=true 옵션을 추가하면,
MySQL이 내부적으로 여러 개의 Insert 문을 하나의 Multi-row Insert로 재작성하여 성능을 크게 향상시킬 수 있다."
spring:
datasource:
url: jdbc:mysql://localhost:3306/hotel?rewriteBatchedStatements=true # 옵션 적용
username: test
password: test
실제로 위와 같이 옵션을 적용한 후 실행되는 DB 쿼리를 재확인했을 때,
비로소 내가 알고 있던 형태의 Bulk Insert 쿼리가 실행되는 걸 보았다.
즉, 지금까지는 Bulk Insert를 쓰고 있다고 착각하고 있었던 셈이다.
이후 직접 성능을 측정한 결과는 내 예상을 뛰어넘었다.
(물리적인 환경은 Local PC로 다음과 같다)
하드웨어 개요:
칩: Apple M1 Pro
총 코어 개수: 10(8 성능 및 2 효율)
메모리: 32 GB
디스크: APPLE SSD AP1024R
디스크 용량: 1TB(1,000,555,581,440바이트)
시나리오 | 옵션 미적용 Insert 쿼리 수행 시간 | 옵션 적용 Insert 쿼리 수행 시간 |
1회 Bulk Insert rows: 90,000 | 평균 18.1 초 | 평균 3.5 초 |
1회 Bulk Insert rows: 180,000 | 평균 36.5 초 | 평균 7.2 초 |
1회 Bulk Insert rows: 270,000 | 평균 55.2 초 | 평균 11.1 초 |
성능이 크게 개선될 것이라 예상은 했지만 결과는 상상 이상이었다.
최종적으로 360만rows를 생성하는 Batch 작업이 11분 20초에서 -> 2분 27초로 5배 가까이 단축됐다.
앞으로 Bulk Insert를 사용할 때는 반드시 기억해야 할 필수 옵션이었다.
PreparedStatement의 숨겨진 성능 조건 파헤치기
나는 이미 JdbcTemplate의 batchUpdate()를 통해 PreparedStatement를 사용하고 있었다.
PreparedStatement는 Statement에 비해 보안적으로 강점이 있고, 성능적으로도 뛰어나다고 알려져 있었다.
하지만 나는 솔직히 그 '성능적 우수함'의 구체적 이유를 명확히 이해하지 못하고 있었다.
그래서 이번에는 그 이점을 정말로 확인해보고 싶었다.
왜 PreparedStatement가 빠른가?
조금 더 조사해보니 PreparedStatement의 가장 큰 강점은 DB의 실행 계획 캐시 재사용에 있었다.
Statement는 쿼리마다 매번 파싱하고 실행 계획을 새로 만들지만,
PreparedStatement는 같은 쿼리 구조라면 이 실행 계획을 재사용할 수 있다. 이게 바로 성능상의 이점이었다.
단, 조건이 하나 있었다.
"실행되는 쿼리의 구조가 매번 완벽히 동일해야 한다."
게다가 Spring Boot 환경에서는 단순히 batchUpdate()만으로 끝나는 게 아니었다.
커넥션 풀(hikari)의 추가 설정이 필요했는데, 나는 이걸 처음에 놓치고 있었다.
예상과 다른 첫 번째 실험 결과
처음에는 다음과 같은 시나리오로 성능 테스트를 진행했다.
- 동일한 Bulk Insert 쿼리 구조 반복
- 매번 Bulk 개수가 달라지는 쿼리 구조
또한 Writer()의 Bulk Insert 양을 9만, 18만, 27만으로 조절해가며 테스트했다.
시나리오 | 개수가 바뀌는 Bulk Insert | 개수가 동일한 Bulk Insert |
Insert 360만 rows / 1회 평균 9만 rows | 2분 30초 (1회 평균 3.5초) | 2분 27초 (1회 평균 3.5초) |
Insert 360만 rows / 1회 평균 18만 rows | 2분 29초 (1회 평균 7.6초) | 2분 29초 (1회 평균 7.5초) |
Insert 360만 rows / 1회 평균 27만 rows | 2분 35초 (1회 평균 11.3초) | 2분 34초 (1회 평균 11.3초) |
실행 계획 캐시 재사용으로 인해 성능 차이가 날 것이라는 기대가 완전히 무너졌다.
놓치고 있던 핵심: 커넥션 풀 설정
알고보니 성능 차이가 나지 않았던 이유는 커넥션 풀의 PreparedStatement 캐싱 설정이 빠져 있었기 때문이었다.
hikari에서 아래와 같은 설정이 필요했다.
spring:
datasource:
hikari:
data-source-properties:
useServerPrepStmts: true # 서버 측 Prepared Statement 사용하도록 설정
cachePrepStmts: true # Prepared Statement 캐싱을 활성화
prepStmtCacheSize: 250 # 캐싱할 Prepared Statement 최대 개수
prepStmtCacheSqlLimit: 2048 # 캐싱할 SQL 쿼리의 최대 길이를 설정
이 설정을 추가하고 다시 테스트한 결과, 드디어 쿼리 실행 계획 캐시 재사용의 진가가 드러났다.
시나리오 | 개수가 바뀌는 Bulk Insert | 개수가 동일한 Bulk Insert |
Insert 360만 rows / 1회 평균 9만 rows | 총 2분 34초 / 1회 평균 3.7초 | 총 1분 50초 / 1회 평균 2.6초 |
Insert 360만 rows / 1회 평균 18만 rows | 총 2분 35초 / 1회 평균 7.5초 | 총 1분 52초 / 1회 평균 5.3초 |
Insert 360만 rows / 1회 평균 27만 rows | 총 2분 39초 / 1회 평균 11.4초 | 총 1분 54초 / 1회 평균 7.9초 |
기존 성능 차이가 없던 두 시나리오 사이에 명확한 성능 차이가 생겼다.
쿼리 구조가 고정되었을 때 DB 실행 계획 캐시의 재사용으로 쿼리 수행 시간이 30% 이상 개선된 것이다.
결국 PreparedStatement의 성능 이점을 위해 갖춰야할 것: 동일한 쿼리 구조
이번 실험으로 명확한 결론을 내릴 수 있었다.
PreparedStatement의 실행 계획 캐시를 제대로 활용하려면 쿼리 구조가 매번 완벽히 동일해야 한다.
단순히 batchUpdate()로 PreparedStatement를 사용한다고 해서 끝나는 게 아니었다.
실제로 실행되는 쿼리의 VALUES 개수가 다르면,
MySQL은 이를 다른 쿼리로 인식해 실행 계획 캐시를 재사용하지 못한다.
따라서 동일한 실행 계획 캐시를 유지하려면 쿼리의 구조—특히 VALUES의 개수—가 매번 완벽히 동일해야 했다.
문제는 여기서 시작됐다.
현재 Tasklet에서 read - processor 반복을 통해 데이터를 쌓고 있었고,
Output 임계치(예: 10만 건)가 쌓이면 Writer가 이를 DB에 Insert하는 구조였다.
하지만 이건 어디까지나 임계치를 설정하는 것일 뿐, Processor가 매번 정확히 10만 건을 반환하도록 보장할 수는 없었다.
예를 들어 한 번의 Processor 실행에서 10만 3천 건이 만들어질 수도 있고, 다른 번에는 9만 7천 건이 만들어질 수도 있다.
이런 상황에서는 Writer가 받을 때마다 Output 개수가 미묘하게 달라지고,
결국 반복된 Insert 쿼리 구조가 깨질 위험이 있었다.
"PreparedStatement의 성능을 최대로 끌어내고 싶다면, 이 문제를 반드시 해결해야 했다."
동일한 쿼리 구조를 유지하는 방법: Tasklet의 진짜 강점
처음에는 Writer 쪽에서 해결해보려고 했다.
남은 데이터를 채우거나 패딩 데이터를 추가하는 식으로 쿼리 구조를 맞춰 볼 수 있었으나
이건 너무 억지스러웠고, 유지보수성도 떨어졌다.
이때 아래와 같은 생각이 들었다.
"내가 Tasklet을 왜 선택했더라? 바로 이런 유연함을 위해서였다."
Tasklet은 read - processor - write 흐름을 원하는 대로 완전히 제어할 수 있는 구조다.
그래서 전략을 바꿨다.
- Processor는 그냥 있는 그대로 Output 데이터를 생성한다.
- Tasklet 안에 remainOuput 버퍼를 만들어, Processor에서 반환된 Output 데이터를 모두 이 버퍼에 누적한다.
- 누적된 데이터를 정확히 10만 건을 기준으로 나눠 Writer에 반복해서 넘긴다.
- 남은 데이터가 10만 건을 채우지 못하면? 다음 루프에서 Processor가 또 Output을 만들어오고, 버퍼에 계속 쌓아간다.
- 마지막 최종 루프에서는 남아 있는 데이터도 마무리로 한 번 더 Write한다.
아래는 Tasklet 내부의 핵심 로직이다. (예시 코드)
private void writeAvailabilities(
List<RoomAvailability> currentOutput,
boolean hasNext
) {
// 버퍼에 담기
remainOuput.addAll(currentOutput);
// BASE_LINE_WRITE_COUNT : 10만
int tryCount = remainOuput.size() / BASE_LINE_WRITE_COUNT;
for (int i = 0; i < tryCount; i++) {
int startIndex = i * BASE_LINE_WRITE_COUNT;
int endIndex = startIndex + BASE_LINE_WRITE_COUNT;
// 10만 단위로 writeList 구성
List<RoomAvailability> writeList = remainOuput.subList(startIndex, endIndex);
// 실제 DB 쓰기
writer.write(writeList);
}
remainOuput = remainOuput.subList(tryCount * BASE_LINE_WRITE_COUNT, remainOuput.size());
// 최종 루프였다면 남은 Ouput 모두 쓰기
if (!hasNext) {
writer.write(remainOuput);
}
}
이 구조의 강점은 명확하다.
- Processor가 만들어내는 Output 개수가 매번 다르더라도 Writer는 항상 10만 건 단위로 끊어서 Insert한다
- 덕분에 PreparedStatement의 실행 계획 캐시도 항상 동일한 쿼리 구조로 유지된다.
- 최종 루프에서 마지막 잔여 데이터만 예외적으로 처리하면 되기 때문에 구조가 단순하고 깔끔하다.
이건 Chunk 방식으로는 절대 구현할 수 없는 Tasklet만의 유연함이었다.
Tasklet의 진정한 강점을 제대로 활용한 셈이다.
병렬 처리, 다시 도전해보다
PreparedStatement 최적화는 여기까지 마침표를 찍었다.
실행 계획 캐시를 최대한 활용하고, Tasklet의 유연함으로 Writer로 넘기는 데이터까지 세밀하게 제어했다.
쿼리 구조도 항상 동일하게 유지되도록 최적화했으니, 이제 할 수 있는 건 다 했다고 생각했다.
하지만 문득 이런 생각이 스쳤다.
“혹시 병렬 처리로 더 성능을 끌어올릴 수 있지 않을까? 지난 Processor() 성능 개선기처럼..”
사실 나는 과거 실무에서 대량 Insert 쿼리를 병렬로 돌리다 MySQL Dead Lock을 마주한 경험이 있었다.
그때는 트랜잭션 충돌로 DB가 느려지고 테이블이 잠기는 현상까지 겪으면서,
몸으로 병렬 Insert의 위험성을 깨달았고 그 뒤로는 Insert 쿼리에 병렬 처리를 시도하지 않았다.
하지만 이번엔 상황이 달랐다.
- PreparedStatement 최적화가 끝났고,
- Tasklet의 유연함으로 쿼리 구조까지 완벽히 고정해뒀다.
“이 정도면 이제 병렬로 돌려도 문제가 없지 않을까?”
과거의 두려움을 조금 내려놓고, 다시 한번 병렬화에 도전해보기로 했다.
병렬화 실험 결과: 기대 vs 현실
PreparedStatement 최적화 과정에서 실행 계획 캐시를 재활용 하기 위해 단순한 for문으로 Writer()를 반복 호출했다.
(예시 코드)
// BASE_LINE_WRITE_COUNT : 10만
int tryCount = remainOuput.size() / BASE_LINE_WRITE_COUNT;
for (int i = 0; i < tryCount; i++) {
int startIndex = i * BASE_LINE_WRITE_COUNT;
int endIndex = startIndex + BASE_LINE_WRITE_COUNT;
// 10만 단위로 writeList 구성
List<RoomAvailability> writeList = remainOuput.subList(startIndex, endIndex);
// 실제 DB 쓰기
writer.write(writeList);
}
이번에는 이 로직을 Stream.parallel()로 변경해 병렬 처리를 시도했다.
(예시 코드)
// BASE_LINE_WRITE_COUNT : 10만
int tryCount = remainOutput.size() / BASE_LINE_WRITE_COUNT;
IntStream.range(0, tryCount)
.parallel()
.forEach(i -> {
int startIndex = i * BASE_LINE_WRITE_COUNT;
int endIndex = startIndex + BASE_LINE_WRITE_COUNT;
// 10만 단위로 writeList 구성
List<RoomAvailability> writeList = remainOuput.subList(startIndex, endIndex);
// 실제 DB 쓰기
writer.write(writeList);
});
이렇게 간단히 병렬 처리로 전환했을 뿐인데 결과는 기대 이상이었다.
시나리오 | 총 수행 시간 |
Insert 360만 rows / 10만 Bulk 순차 실행 | 총 1분 50초 |
Insert 360만 rows / 10만 Bulk 2개 병렬 | 총 1분 21초 |
Insert 360만 rows / 10만 Bulk 3개 병렬 | 총 54초 |
Insert 360만 rows / 10만 Bulk 4개 병렬 | 총 47초 |
Insert 360만 rows / 10만 Bulk 5개 병렬 | 총 43초 |
Insert 360만 rows / 10만 Bulk 6개 병렬 | 총 45초 |
Insert 360만 rows / 10만 Bulk 7개 병렬 | 총 46초 |
Insert 360만 rows / 10만 Bulk 8개 병렬 | 총 47초 |
Insert 360만 rows / 10만 Bulk 9개 병렬 | 총 50초 |
Insert 360만 rows / 10만 Bulk 10개 병렬 | 총 51초 |
Insert 360만 rows / 10만 Bulk 15개 병렬 | 총 53초 |
처음엔 병렬 개수를 늘릴수록 분명한 속도 향상이 느껴졌다.
하지만 병렬 개수가 5개를 넘어가면서부터 속도 향상 폭이 줄어들었고, 오히려 다시 느려지는 구간도 나타났다.
이 현상의 원인을 명확히 파악할 필요가 있었다.
병렬 개수가 늘어날수록 첫 번째 Insert 쿼리가 느려지는 현상
병렬 처리가 전체 성능을 끌어올린 건 분명했다.
하지만 이번에는 병렬 처리된 각 Bulk Insert 쿼리의 개별 소요 시간을 세부적으로 측정해봤다.
시나리오 | 반복 구간 첫 Bulk Insert 쿼리 수행 시간 |
Insert 360만 rows / 10만 Bulk 순차 실행 | 평균 2.5초 |
Insert 360만 rows / 10만 Bulk 2개 병렬 | 평균 3.5초 |
Insert 360만 rows / 10만 Bulk 3개 병렬 | 평균 3.9 초 |
Insert 360만 rows / 10만 Bulk 4개 병렬 | 평균 4.3 초 |
Insert 360만 rows / 10만 Bulk 5개 병렬 | 평균 4.6초 |
Insert 360만 rows / 10만 Bulk 6개 병렬 | 평균 6.4 초 |
Insert 360만 rows / 10만 Bulk 7개 병렬 | 평균 7.2 초 |
Insert 360만 rows / 10만 Bulk 8개 병렬 | 평균 8.2 초 |
Insert 360만 rows / 10만 Bulk 9개 병렬 | 평균 9.6 초 |
Insert 360만 rows / 10만 Bulk 10개 병렬 | 평균 9.9 초 |
Insert 360만 rows / 10만 Bulk 15개 병렬 | 평균 11 초 |
놀랍게도 병렬로 실행할 때마다 첫 번째 쿼리가 유독 느려졌고, 나머지 쿼리들은 거의 즉시 끝났다.
예를 들어:
- 병렬 2개 실행: 첫 번째 쿼리 완료 시간 3.5초, 두 번째 쿼리 0.01초
- 병렬 9개 실행: 첫 번째 쿼리 완료 시간 10초, 나머지 0.01초 컷
병렬 개수가 늘어날수록 첫 번째 쿼리가 점점 더 느려지는 경향이 두드러졌다.
처음에는 단순히 병렬 처리의 한계인가 싶었지만, 곧 깨달았다.
이건 전형적인 AUTO_INCREMENT 세션 락의 징후였다.
거대한 착각의 서막: AUTO_INCREMENT Lock, 그리고 인덱스 경합에 대한 얕은 이해
병렬 처리를 통해 얻은 성능 개선은 생각보다 제한적이었다.
처음에는 단순히 병렬 처리의 한계라고만 생각했지만, 테스트 결과 계속해서 아래와 같은 패턴을 보여줬다.
병렬 개수가 늘어날수록 첫 번째 쿼리만 느려지고, 나머지는 거의 즉시 끝나는 현상.
점점 이러한 확신이 들었다
이건 단순한 물리적인 리소스 부족으로 인한 한계가 아니라, 분명 MySQL 내부 구조에서 오는 병목이다.
곧바로 MySQL의 동작 방식을 다시 깊이 파고들었고, 중요한 사실을 재확인하게 됐다.
- AUTO_INCREMENT는 세션 단위로 락을 건다.
여러 스레드가 동시에 Insert 쿼리를 실행하면, 각 세션은 AUTO_INCREMENT 값을 가져가기 전까지 대기 상태에 빠진다. - 인덱스 락 충돌 문제도 존재한다.
AUTO_INCREMENT로 PK가 순차 증가할 때 같은 PK 인덱스 범위에 데이터가 몰리게 된다.
만약 다른 트랜잭션(예: 고객 예약 같은)이 같은 인덱스 범위를 동시에 건드리면, 인덱스 락 충돌로 인해 서로 대기 상태에 들어간다.
과거에 대량 Insert 쿼리 시 겪었던 "테이블이 통째로 잠기는 느낌"의 정체가 바로 이거였다.
실제로는 테이블 전체 락이 아니었고, PK 인덱스 락 충돌로 인한 대기 현상이었다는 사실을 이제야 정확히 이해할 수 있었다.
트랜잭션 락과 AUTO_INCREMENT: 다시 보는 문제의 본질
이번 학습을 통해 정말 중요한 걸 다시 깨달았다.
MySQL(InnoDB)의 트랜잭션은 기본적으로 레코드 락을 사용한다.
긴 트랜잭션이라고 해서 무조건 테이블 전체가 락이 걸리는 건 아니다.
테이블 전체가 잠기는 현상은 아주 특수한 경우에만 발생한다는 것도 잘 알려진 사실이다.
그런데 나는 왜 이렇게 자주 "전체 락이 걸린 느낌"을 받았을까?
그 해답은 이제 분명했다.
바로 AUTO_INCREMENT의 세션 락 + PK 인덱스 락 충돌의 조합이었다.
내가 진짜 해결해야 할 병목은 단순히 트랜잭션 격리 레벨의 문제가 아니었다.
결국 본질은 PK 설계와 AUTO_INCREMENT의 구조적 함정에 있었다.
AUTO_INCREMENT 우회: 수동 PK 설정 실험
문제의 본질은 이제 분명했다.
핵심 병목은 AUTO_INCREMENT의 세션 락과 PK 인덱스 락 충돌이었다.
가장 먼저 떠오른 해결책은 단순했다.
바로 AUTO_INCREMENT를 아예 쓰지 않고, PK 값을 애플리케이션에서 직접 세팅해서 넣는 방식이다.
따라서 기존 Batch Insert 쿼리를 다음과 같이 변경했다.
(기존 쿼리 예시)
INSERT INTO room_availability (room_type_id, date, available_count, price) VALUES (?, ?, ?, ?)
(PK 값 지정 쿼리 예시)
INSERT INTO room_availability (id, room_type_id, date, available_count, price) VALUES (?, ?, ?, ?, ?)
그리고 PK(ID) 값은 애플리케이션 단에서 고유하게 생성해서 전달했다.
다시 성능 테스트를 진행했다.
시나리오 | Auto PK: 첫 Bulk Insert 쿼리 수행 시간 | 수동 PK: 첫 Bulk Insert 쿼리 수행 시간 |
Insert 360만 rows / 10만 Bulk 2개 병렬 | 평균 3.5초 | 평균 3.8초 |
Insert 360만 rows / 10만 Bulk 3개 병렬 | 평균 3.9 초 | 평균 3.9 초 |
Insert 360만 rows / 10만 Bulk 4개 병렬 | 평균 4.3 초 | 평균 4.3 초 |
Insert 360만 rows / 10만 Bulk 5개 병렬 | 평균 4.6초 | 평균 4.6초 |
Insert 360만 rows / 10만 Bulk 6개 병렬 | 평균 6.4 초 | 평균 6.6 초 |
Insert 360만 rows / 10만 Bulk 7개 병렬 | 평균 7.2 초 | 평균 7.4 초 |
Insert 360만 rows / 10만 Bulk 8개 병렬 | 평균 8.2 초 | 평균 8.4 초 |
Insert 360만 rows / 10만 Bulk 9개 병렬 | 평균 9.6 초 | 평균 9.6 초 |
Insert 360만 rows / 10만 Bulk 10개 병렬 | 평균 9.9 초 | 평균 9.9 초 |
결과는..? 수동으로 PK를 지정했음에도 병렬 쿼리의 성능 개선은 이뤄지지 않았다.
수동 PK 실험 후 찾은 다른 해결책: UUID
솔직히 결과는 예상 밖이었다, 단 10%라도 조금의 차이는 발생할 것이라 기대했다.
하지만 AUTO_INCREMENT를 제거하고 PK를 직접 세팅했는데도, 성능 차이는 거의 없었다.
왜 그랬을까?
처음에는 "세션 락 병목이 없어지면 성능이 조금이라도 개선되겠지"라는 단순한 기대를 했지만,
이번 실험은 중요한 사실을 다시 일깨워줬다.
PK를 수동으로 세팅한다고 해서 인덱스 락 병목까지 해결되진 않는다.
MySQL(InnoDB)의 구조상, PK가 순차 증가하는 한 같은 인덱스 범위에 데이터가 몰릴 수밖에 없다.
이때 다른 트랜잭션(예: 고객 예약 같은)이 같은 인덱스 범위를 업데이트하면?
→ 결국 PK 인덱스 충돌이 발생해 서로 대기하게 된다.
즉, 이번 실험으로 세션 락 문제는 해결됐을지 몰라도, 인덱스 병목이라는 더 근본적인 문제가 남아 있었다.
이때 아래와 같은 생각이 들었다.
"결국 인덱스 병목까지 피하려면 PK의 '순차성' 자체를 바꿔야 하는 게 아닐까?"
이때 눈에 들어온 게 바로 UUID 방식이었다.
UUID, 클러스터드 인덱스의 역린을 건드리다
AUTO_INCREMENT의 세션 락, 그리고 순차적 PK 때문에 마지막 인덱스 범위에 걸려 인덱스 충돌이 걸리는 현상.
이 핫스팟 현상을 해결할 방법으로 랜덤 UUID가 떠올랐다.
- PK 값을 테이블 전체에 무작위로 분산시킨다면,
- 더 이상 특정 인덱스 페이지에 쓰기 요청이 몰리는 일은 없을 테고,
- 병렬 Insert의 효율도 극대화될 것이라는 기대였다.
마치 꽉 막힌 도로를 피해 여러 갈래의 시원한 길로 동시에 진입하는 그림을 그렸다.
그래서 바로 기존 BIGINT AUTO_INCREMENT PK을 VARCHAR(26)으로 변경하고,
애플리케이션에서 UUID.randomUUID()로 생성된 값을 PK로 사용하는 방식으로 코드를 수정했다.
그 후 이전과 동일한 조건에서 다양한 병렬 처리 시나리오로 성능 테스트를 진행했다.
이때까지만 해도 상당한 성능 향상을 기대했던 게 사실이다.
예상은 빗나가고, 성능은 추락하다
테스트 결과는 '예상 밖'이라는 말로는 부족했다. 오히려 '참담했다'고 표현하는 게 맞을 것 같다.
아래는 AUTO_INCREMENT PK를 사용했을 때와 UUID PK를 적용했을 때의 Insert 성능 비교 결과다.
시나리오 | Auto PK: 총 수행 시간 | UUID PK: 총 수행 시간 |
Insert 360만 rows / 10만 Bulk 2개 병렬 | 총 81초 | 총 96초 |
Insert 360만 rows / 10만 Bulk 3개 병렬 | 총 54초 | 총 89초 |
Insert 360만 rows / 10만 Bulk 4개 병렬 | 총 47초 | 총 85초 |
Insert 360만 rows / 10만 Bulk 5개 병렬 | 총 43초 | 총 83초 |
Insert 360만 rows / 10만 Bulk 6개 병렬 | 총 45초 | 총 77초 |
Insert 360만 rows / 10만 Bulk 7개 병렬 | 총 46초 | 총 74초 |
Insert 360만 rows / 10만 Bulk 8개 병렬 | 총 47초 | 총 77초 |
Insert 360만 rows / 10만 Bulk 9개 병렬 | 총 50초 | 총 78초 |
Insert 360만 rows / 10만 Bulk 10개 병렬 | 총 51초 | 총 86초 |
보다시피, UUID PK를 적용하자 전체 Insert 수행 시간이 모든 구간에서 증가했고,
병렬 처리 개수를 늘려도 성능 향상은 미미하거나 오히려 더 저하되는 현상까지 나타났다.
믿기 어려운 결과였다.
테스트 환경이나 다른 변수를 의심하며 몇 번이고 코드를 점검하고 테스트를 반복했지만, 결과는 요지부동이었다.
예상과 완전히 다른 결과를 마주하며, 지금까지의 가정과 접근 방식에 대한 재검토가 필요하다고 느꼈다.
단순히 결과를 받아들이는 데 그치지 않고, 실제 병목이 무엇인지 근본부터 다시 확인하고 싶었다.
밑바닥부터 다시 시작된 탐구
단순히 'UUID는 현 상황에 안 맞나 보다' 하고 넘어갈 수도 있었겠지만, 그럴 수는 없었다.
이토록 처참한 결과를 그냥 덮어둘 수는 없었다.
이 실패의 근본적인 원인을 이해하고 싶다는 갈증이 강하게 밀려왔다.
그래서 모든 가정을 내려놓고, 가장 원초적인 질문으로 돌아가기로 했다.
MySQL, 특히 InnoDB는 데이터를 디스크에 대체 어떤 방식으로 저장하고 쓰는 걸까?
당시 이 질문에 대해 충분히 깊이 있게 고민하지 않았던 것을 계기로,
나는 실무에서 당연하게 지나쳤던 DB 내부 구조와 인덱스 전략의 본질을 다시 공부하게 되었다.
이번 실패는 나에게 가장 기본적인 것을 놓치고 있었다는 사실을 아주 명확하게, 그리고 꽤나 아프게 각인시켜 주었다.
클러스터드 인덱스, 모든 문제의 시작과 끝
여러 자료를 뒤지고 여러 테스트를 반복한 끝에,
이 모든 의문의 중심에 '클러스터드 인덱스(Clustered Index)' 라는,
그 이름은 익숙하지만 그 중요성과 동작 방식은 제대로 알지 못했던 핵심적인 존재가 있었음을 깨닫게 되었다.
클러스터드 인덱스: 데이터의 물리적 주소록
InnoDB 테이블에서 데이터 레코드는 PK 값을 기준으로 물리적으로 정렬되어 저장된다.
이건 마치 잘 정리된 백과사전과 같다.
위와 같이 각 항목(데이터 레코드)은 그 고유 번호(PK) 순서대로 페이지에 배열되어 있고,
리프 노드에는 단순한 색인(포인터)이 아니라 실제 내용(데이터 레코드 전체)이 담겨있다는 점이 핵심이다.
이러니 특정 범위를 조회하는데 있어 정렬된 데이터를 그대로 뽑아오면 되니 얼마나 효율적인가?
만약 1번째 PK부터 100번째 PK까지 값을 뽑아온다면,
클러스터드 인덱스는 1번째 값만 찾으면 나머지 100번째 값까진 굳이 찾지 않아도 된다.
100번째까지 정렬된 데이터를 그대로 뽑아오면 되니까.
클러스터드 인덱스가 아니였다면, 1번째 PK부터 100번째 PK 값을 뽑아오는데 100번의 서치가 발생될지도 모르는 일이다.
그러나 반대로 생각해보자.
조회할 때는 이 정렬된 묶음이 너무나 효율적이나,
새로운 값을 넣을 때는 이 정렬을 유지하기 위해 마지막 인덱스 페이지에 쓰기가 집중될 수 밖에 없다.
즉, PK 정렬을 기반으로 실제 데이터 위치도 정렬 시켜, 디스크 I/O를 최적화하여 조회 성능을 극대화하고,
반대로 PK 정렬을 기반으로 실제 데이터 위치도 정렬되게끔 저장해야하니 쓰기 병목이 발생되는 것이다.
이 부분에 대한 깊은 이해 없이 판단했던 것이, 성능 저하 원인을 초기에 놓친 결정적 이유였다.
순차적 PK 와 클러스터드 인덱스: 잘 맞는 조합, 하지만 완벽하진 않아
클러스터드 인덱스의 개념을 깨닫고 나니,
그동안 당연하게 사용했던 AUTO_INCREMENT 방식의 순차적 PK가
왜 그렇게 '보편적으로' Insert 성능에 유리하다고 평가받는지 명확해졌다.
마치 잘 짜인 시스템처럼, 순차적 PK는 클러스터드 인덱스와 아름다운 조화를 이루는 듯 보였다.
- 빛: 'Append-Optimized'의 마법 - 질서정연한 데이터 적재
- 가장 큰 장점은 역시 'Append-Optimized' 특성이다.
새로운 데이터가 대부분 테이블의 물리적인 마지막 페이지에 순차적으로 추가되니,
InnoDB 입장에서는 얼마나 효율적인가.
기존 데이터 페이지를 뒤섞거나 중간에 새로운 페이지를 끼워 넣을 필요(페이지 분할)가 거의 없다.
이건 마치 새 학년 교과서를 책꽂이의 빈칸에 차곡차곡 순서대로 꽂는 것과 같다.
디스크 I/O 패턴은 예측 가능해지고, 페이지 분할은 최소화되어 B-Tree의 깊이 증가도 억제된다.
당연히 Insert 성능에 매우 유리할 수밖에.
- 가장 큰 장점은 역시 'Append-Optimized' 특성이다.
- 그림자: '인기 있는 마지막 페이지'의 비애 - 동시성의 덫, 핫스팟
- 하지만 이 완벽해 보이는 조합에도 간과했던 그림자가 있었다.
바로 병렬 처리에서 발생 했던 '핫스팟(Hotspot)' 문제다.
동시에 여러 Insert 요청이 테이블의 '마지막 페이지'라는 단일 지점으로 몰리게 되면 어떻게 될까?
특히 높은 동시성 환경에서 여러 트랜잭션이 동시에 이 마지막 페이지에 접근하려 할 때,
InnoDB는 데이터 정합성을 위해 해당 페이지나 관련 인덱스 키 범위에 대한 잠금(Lock)을 걸 수밖에 없다. - AUTO_INCREMENT의 경우, 다음 ID 값을 할당받기 위한 내부적인 락(AUTO-INC Lock)과 더불어,
이렇게 데이터가 특정 영역에 집중되면서 발생하는 인덱스 페이지 경합은 병렬 처리의 효율을 떨어뜨리는 주범이 되었다.
병렬 Insert 테스트에서 병렬 개수를 늘려도 첫 번째 쿼리만 유독 느려지던 현상,
그리고 전체 성능 향상에 한계가 있던 이유가 바로 여기에 있었다. - 결국, 순차적 PK는 대부분의 경우 클러스터드 인덱스와 환상의 궁합을 자랑하지만,
극단적인 동시 쓰기 환경에서는 '인기 있는 마지막 페이지'라는 병목 지점을 만들 수 있다는 양면성을 가지고 있었던 것이다.
처음 UUID를 고려했던 출발점도 바로 이 핫스팟 문제 때문이었다.
다만, 그때는 이 문제의 원인을 '순차성'으로만 단순하게 치부하고,
그 '순차성'이 클러스터드 인덱스에서 얼마나 중요한 이점인지 미처 깨닫지 못했던 것이다.
- 하지만 이 완벽해 보이는 조합에도 간과했던 그림자가 있었다.
랜덤 PK 와 클러스터드 인덱스: 최악의 궁합, 성능 재앙의 시작
자, 그럼 여기서 UUID를 쓰면 어떻게 될까?
이건 마치 백과사전의 아무 페이지나 펼쳐서, 그 페이지의 아무 곳에나 새 항목을 마구잡이로 끼워 넣으려는 시도와 같다.
순서도 없고, 예측도 불가능하다.
이게 클러스터드 인덱스 환경에서 랜덤 PK가 가져오는 재앙의 서막으로 주요한 문제점들은 다음과 같다
- 페이지 분할 파티 - 멀쩡한 페이지 계속 쪼개기:
이미 내용이 꽉 찬 중간 페이지에 새 항목을 억지로 끼워 넣으려면, 해당 페이지를 두 개로 나눠야 한다(페이지 분할).
이 과정은 엄청난 디스크 I/O와 CPU 자원을 소모하고, 테이블 전체의 성능을 급격히 떨어뜨린다.
Insert 성능이 나락으로 간 주범 중 하나가 바로 이것이었다. - 텅 빈 공간의 향연, 그리고 데이터 파편화:
잦은 페이지 분할은 각 데이터 페이지가 충분히 채워지지 않은 상태로 남게 만든다.
페이지는 많은데 정작 내용은 별로 없는, 극도의 비효율 상태.
디스크 공간은 공간대로 낭비하고, 데이터는 여기저기 흩어져(데이터 단편화) 결국 조회 성능까지 망가뜨린다. - 조회에서도 최악의 성능 저하 - 캐시 효율 급감과 랜덤 I/O 폭증:
데이터가 디스크 여러 곳에 흩어져 있으니,
필요한 데이터를 찾기 위해 엔진은 디스크 여기저기를 헤집고 다녀야 한다(랜덤 I/O 증가).
어렵게 찾아온 데이터를 메모리(버퍼 풀)에 올려놔도,
또 다른 랜덤한 위치의 데이터를 찾으러 가야 하니 캐시 효율은 바닥을 칠 수밖에 없다.
AUTO_INCREMENT의 병목을 해결하려다,
오히려 클러스터드 인덱스의 구조적 특성에 부합하지 않는 설계를 적용한 것이 문제였다.
반성의 시간: 기본으로 돌아가야 했던 이유
이번 경험을 통해 단순한 성능 이슈가 아닌,
DB 저장 방식과 인덱스 구조에 기반한 설계 판단의 중요성을 절감하게 되었다.
이 과정은 결코 쉽지 않았지만,
결과적으로 MySQL의 클러스터드 인덱스 구조에 대한 깊은 이해를 얻게 되는 결정적인 계기가 되었다.
이번 실험은 '겉핥기식 지식'과 '구조에 대한 본질적 이해'의 차이를 분명하게 인식하게 해주었고,
시스템 레벨에서의 최적화는 결국 구조적 이해 없이는 이루어질 수 없다는 교훈을 남겼다.
이번 경험을 통해 개발자로서 가장 중요한 자세 중 하나인,
'기본 원리에 대한 겸손함'과 '현상을 넘어 구조를 이해하려는 노력'의 중요성을 실감했다.
실패는 성장의 밑거름, 끝나지 않은 최적화 여정
이번 4편에서는 DB Insert 성능 개선이라는 목표를 향해 나아가면서 겪었던 여러 시행착오와 깨달음을 공유했다.
rewriteBatchedStatements라는 기본적인 JDBC 옵션부터 시작해,
PreparedStatement의 진정한 성능을 끌어내기 위한 조건들,
그리고 Tasklet의 유연성을 활용한 쿼리 구조 최적화까지, 나름의 '빛'을 찾아가는 과정이었다.
하지만 그 과정에서 AUTO_INCREMENT PK와 병렬 처리의 함정에 빠지기도 했고,
성급한 판단으로 도입했던 UUID PK 전략이 클러스터드 인덱스의 역린을 건드려 처참한 실패를 맛보기도 했다.
이 '그림자'와 같은 경험들은 나에게 깊은 좌절감을 안겨주었지만,
동시에 MySQL InnoDB의 가장 핵심적인 원리인 클러스터드 인덱스의 중요성을 뼈저리게 깨닫는 결정적인 계기가 되었다.
결국, 이번 4편의 가장 큰 수확은 '성공적인 최적화' 그 자체가 아니라,
'기본으로 돌아갈 용기'와 '현상을 넘어 본질을 보려는 노력'의 가치를 배운 것이다.
그리고 이 실패를 통해 얻은 깊이 있는 이해는 앞으로의 최적화 여정에 가장 든든한 발판이 되어줄 것이라 믿는다.
아직 도전은 끝나지 않았다.
클러스터드 인덱스를 제대로 이해한 지금, 다시 출발선에 섰다.
- 핫스팟 문제를 최소화하면서도 클러스터드 인덱스의 이점을 살릴 수 있는 최적의 PK 전략은 무엇일까?
- 대량 데이터 Insert의 '끝판왕'으로 불리는 LOAD DATA INFILE은 과연 얼마나 강력한 성능을 보여줄까?
- 그리고 이 모든 것을 안정적인 서비스 운영과 어떻게 조화시킬 수 있을까?
이 질문들에 대한 답을 찾는 여정은 쉽지 않겠지만, 이 문제들을 반드시 해결해내고 싶다.
"능력이 부족한 건 부족한 거고, 결국 해내는 건 해내는 거니까."
다음 글 예고: [Batch 시리즈 #5] 차세대 PK 전략과 LOAD DATA INFILE 정복기
다음 편에서는 이 치열한 고민과 질문들에 대한 답을 찾아가는 과정을 공유하려 한다.
클러스터드 인덱스 환경에 최적화된 새로운 PK 전략들(Time-Ordered UUID/ULID 등)을 심도 있게 실험하고,
LOAD DATA INFILE을 Spring Batch에 효과적으로 통합하여 그 성능을 직접 검증해 볼 예정이다.
또한, 이전 시리즈에서 미처 다루지 못했던 DB 제약 조건 및 다양한 옵션 튜닝이 실제 성능에 미치는 영향까지 파헤쳐 보겠다.
Tasklet 참고 코드
- PreparedStatement 성능 이점을 위해 항상 동일한 Insert 쿼리 구조 적용
- Writer 병렬 처리 적용
@Component
@StepScope
@RequiredArgsConstructor
public class OriginOpenAvailabilityTasklet implements Tasklet {
private static final int BASE_LINE_WRITE_COUNT = 90000;
private static final int MAX_WRITE_COUNT = 150000;
private final RoomAutoPolicyTaskletReader autoPolicyReader;
private final OriginOpenAvailabilityTaskletProcessor openAvailabilityProcessor;
private final RoomAvailabilityTaskletWriter availabilityWriter;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
// 성능 측정을 위한 시간 로깅
Perf perf = new Perf();
// execute 과정에서 기록된 다음 reader 시작점
Long lastSeenId = getLastSeenId(chunkContext.getAttribute("lastSeenId"));
// Output 임계치까지 [read - process] 반복 실행
ReadProcessCombineResult combineResult = combineReaderProcessor(lastSeenId, contribution, perf);
// Output 결과 저장
writeAvailabilities(combineResult.outputAvailabilities, contribution, perf);
return handleExecuteResult(combineResult.hasNext, combineResult.lastSeenId, chunkContext);
}
private Long getLastSeenId(Object lastSeenId) {
if (lastSeenId == null) {
return null;
}
if (lastSeenId instanceof Long) {
return (Long)lastSeenId;
}
throw ErrorCode.CONFLICT.exception("Invalid lastSeenId type: " + lastSeenId.getClass().getName());
}
private record ReadProcessCombineResult(
boolean hasNext,
Long lastSeenId,
List<RoomAvailability> outputAvailabilities
) {
}
// Output 임계치까지 [read - processor] 반복 수행
private ReadProcessCombineResult combineReaderProcessor(
Long lastSeenId,
StepContribution contribution,
Perf perf
) {
boolean outputThreshold = false;
boolean hasNext = true;
List<RoomAvailability> outputResult = new ArrayList<>(MAX_WRITE_COUNT);
while (!outputThreshold) {
CursorPage<RoomAutoAvailabilityPolicy, Long> autoPolicyCursorPage = autoPolicyReader.read(lastSeenId);
contribution.incrementReadCount();
List<RoomAutoAvailabilityPolicy> inputAutoPolicies = autoPolicyCursorPage.content();
perf.log("Reader rows", inputAutoPolicies.size());
lastSeenId = autoPolicyCursorPage.nextCursor();
List<RoomAvailability> outputAvailabilities = openAvailabilityProcessor.process(inputAutoPolicies);
perf.log("Output rows", outputAvailabilities.size());
outputResult.addAll(outputAvailabilities);
if (!autoPolicyCursorPage.hasNext()) {
hasNext = false;
break;
}
outputThreshold = outputResult.size() >= BASE_LINE_WRITE_COUNT;
}
return new ReadProcessCombineResult(hasNext, lastSeenId, outputResult);
}
private void writeAvailabilities(
List<RoomAvailability> writeAvailabilities,
StepContribution contribution,
Perf perf
) {
availabilityWriter.write(writeAvailabilities);
contribution.incrementWriteCount(writeAvailabilities.size());
perf.log("Write rows", writeAvailabilities.size());
}
private RepeatStatus handleExecuteResult(
boolean hasNext,
Long lastSeenId,
ChunkContext chunkContext
) {
if (hasNext) {
chunkContext.setAttribute("lastSeenId", lastSeenId);
return RepeatStatus.CONTINUABLE;
}
return RepeatStatus.FINISHED;
}
}
'Batch' 카테고리의 다른 글
[Batch 시리즈 #6] MySQL 옵션 튜닝의 실패, 구조를 향한 시작 (0) | 2025.05.22 |
---|---|
[Batch 시리즈 #5] 클러스터드 인덱스를 넘어서: PK 실험과 LOAD DATA INFILE의 만남 (0) | 2025.05.15 |
[Batch 시리즈 #3] 성능 측정과 병목점 찾기: Tasklet의 한계, 그리고 Processor 최적화 (0) | 2025.05.07 |
[Batch 시리즈 #2] Chunk vs Tasklet, 상황에 맞는 최적의 도구는? (0) | 2025.05.03 |
[Batch 시리즈 #1] 매일 수백만 건의 예약 가능 객실, 어떻게 만들까? - 객실 가용성 배치 도입기 (0) | 2025.05.03 |