개요
지난 4편, 클러스터드 인덱스라는 기본기를 간과한 대가로 PK 전략에서 쓰라린 실패를 맛보았다.
하지만 그 실패는 문제의 본질을 이해하는 소중한 계기가 되었다.
이제 클러스터드 인덱스를 제대로 이해한 상태에서, 대량 Insert 성능을 위한 진짜 고민을 시작한다.
과연 클러스터드 인덱스 환경에서 최적의 PK 전략은 무엇일까?
이 질문에 대한 답을 찾기 위해,
가장 먼저 기존 AUTO_INCREMENT의 한계를 보완할 새로운 PK 후보, Time-Ordered UUID를 탐색해 보려한다.
이번 글은 이 탐색을 출발점 삼아 실제로 성능 병목이 키를 넘어 구조로 옮겨가는 과정을 함께 따라가 본다.
핫스팟과의 줄다리기, 첫 번째 대안 : Time-Ordered UUID
4편에서 겪었던 AUTO_INCREMENT의 핫스팟 문제, 그리고 랜덤 UUID 도입으로 인한 쓰라린 성능 저하.
이 두 가지 경험은 명확한 숙제를 남겼다:
- 클러스터드 인덱스의 이점을 살리면서도,
- 병렬 Insert 시 발생하는 쓰기 경합을 줄일 수 있는 PK 전략을 찾아야 한다는 것.
왜 Time-Ordered UUID인가? - 순차성과 분산성, 두 마리 토끼를 잡으려는 시도
단순 AUTO_INCREMENT는
- 클러스터드 인덱스에 데이터를 순차적으로 쌓아나가,
- 페이지 분할을 최소화하는 이상적인 파트너처럼 보이지만,
- 동시성이 높은 환경에서는 마지막 페이지에 대한 극심한 경합, 즉 핫스팟을 유발한다.
반대로, 랜덤 UUID는
- 쓰기를 분산시켜 핫스팟은 피할 수 있을지 몰라도,
- 클러스터드 인덱스를 무참히 조각내며 페이지 분할과 단편화의 악몽을 선사한다.
지난 4편의 테스트 결과가 이를 증명했다.
그렇다면 이 둘의 장점만을 취할 수는 없을까?
시간 순서를 어느 정도 가지면서도, 동일 시간대 내에서는 값을 적절히 분산시켜주는 PK.
이것이 가능하다면,
- AUTO_INCREMENT처럼 과도한 페이지 분할 없이 데이터를 쌓아나가면서도,
- 쓰기 요청이 특정 페이지에만 집중되는 것을 막아 핫스팟을 완화할 수 있을 것이다.
마치 잘 닦인 여러 차선 도로에 차량을 효율적으로 분산시켜 전체적인 흐름을 원활하게 만드는 그림이다.
이런 기대를 품고 가장 먼저 살펴본 것이 바로 Time-Ordered UUID이다
Time-Ordered UUID는 일반적인 UUID(v4 등)와 달리, 생성 시점의 타임스탬프 정보를 UUID의 앞부분에 포함시킨다.
이로 인해 ID 값들이 시간 순서대로 정렬될 가능성이 매우 높아진다.
동시에, 타임스탬프 외의 나머지 비트들은 랜덤하게 생성되어 동일 시간 내에서의 고유성을 보장하고,
미세한 값의 차이를 만들어낸다.
이 구조 덕분에, 클러스터드 인덱스 입장에서는 완전한 랜덤 값보다는 훨씬 "예측 가능하게" 순차적인 데이터로 인식될 수 있다.
따라서 AUTO_INCREMENT처럼 극단적으로 마지막 페이지만 공략하지 않으면서도,
전체 페이지에 무작위로 흩뿌려지는 대신 "시간적으로 가까운 페이지들"에 데이터를 분산시켜 기록할 수 있을 것이라 추측했다.
즉, 페이지 분할은 최소화하면서 핫스팟은 완화하는, 두 마리 토끼를 잡는 전략이 될 수 있지 않을까?
이 가설이 맞다면, 병렬 Insert 성능 개선의 실마리를 찾을 수 있을 것이다.
Time-Ordered UUID 구현 방식 선택 및 테스트 설계
Time-Ordered UUID를 PK로 사용하기 위한 구현 방식은 크게 두 가지를 고려할 수 있다.
- 애플리케이션 레벨에서 생성 (예: ULID 라이브러리 활용)
애플리케이션 코드 내에서 ULID 생성 라이브러리를 사용하여 ID를 만들고,
이를 BINARY(16) 또는 CHAR(26) 형태로 DB에 저장하는 방식이다.
이 방식은 DB 종류나 버전에 구애받지 않고 일관된 ID 생성이 가능하다는 장점이 있다. - 데이터베이스 레벨에서 생성 (예: MySQL 8.0+ UUID_TO_BIN(UUID(), 1) 함수 활용)
MySQL 8.0 버전부터는 UUID() 함수와 UUID_TO_BIN() 함수의 시간 정렬 옵션을 함께 사용하여,
시간 순서로 정렬 가능한 BINARY(16) 타입의 ID를 생성하고 저장할 수 있다.
PK 컬럼의 기본값으로 설정할 수도 있다.
이 방식은 DB 네이티브 기능을 활용하므로 애플리케이션 의존성을 줄이고,
BINARY(16) 타입 사용으로 저장 공간 효율을 높일 수 있다는 장점이 있다.
이번 테스트에서는 데이터베이스 레벨에서 생성 방식을 사용하여 Time-Ordered UUID PK를 구현하고,
그 성능을 직접 확인해 보기로 했다.
(DDL 적용 예시)
CREATE TABLE room_availability (
id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID(), 1)),
...
);
(Entity ID 컬럼 수정)
@Entity
public class RoomAvailability {
@Id
@Column(columnDefinition = "BINARY(16)")
private byte[] id;
...
}
Time-Ordered UUID, 실제로 얼마나 효과가 있을까?
이론적인 기대와 설계는 끝났다. 중요한 것은 실제 데이터로 증명하는 것.
그래서 다음과 같은 시나리오로 Time-Ordered UUID의 성능을 직접 확인해 보기로 했다.
과연 이 새로운 PK 후보가 나의 "클러스터드 인덱스 친화적 고성능 PK"라는 기대를 충족시켜 줄 수 있을까?
(물리적인 환경은 Local PC로 다음과 같다)
하드웨어 개요:
칩: Apple M1 Pro
총 코어 개수: 10(8 성능 및 2 효율)
메모리: 32 GB
디스크: APPLE SSD AP1024R
디스크 용량: 1TB(1,000,555,581,440바이트)
4편과 마찬가지로 10만 단위의 Bulk Insert 병렬 처리를 시도하며,
이번에는 단순히 총 수행 시간뿐만 아니라 DB 내부 지표까지 함께 살펴보기로 했다.
총 수행 시간 비교: 기대와는 달랐던 첫 만남
가장 먼저, 각 PK 전략별 병렬 Insert 시 총 수행 시간을 비교했다.
시나리오 | Auto PK: 총 수행 시간 | 랜덤 UUID PK: 총 수행 시간 | DB UUID PK: 총 수행 시간 |
Insert 360만 rows / 10만 Bulk 2개 병렬 | 총 81초 | 총 96초 | 총 62초 |
Insert 360만 rows / 10만 Bulk 3개 병렬 | 총 54초 | 총 89초 | 총 54초 |
Insert 360만 rows / 10만 Bulk 4개 병렬 | 총 47초 | 총 85초 | 총 49초 |
Insert 360만 rows / 10만 Bulk 5개 병렬 | 총 43초 | 총 83초 | 총 47초 |
Insert 360만 rows / 10만 Bulk 6개 병렬 | 총 45초 | 총 77초 | 총 52초 |
Insert 360만 rows / 10만 Bulk 7개 병렬 | 총 46초 | 총 74초 | 총 47초 |
Insert 360만 rows / 10만 Bulk 8개 병렬 | 총 47초 | 총 77초 | 총 58초 |
Insert 360만 rows / 10만 Bulk 9개 병렬 | 총 50초 | 총 78초 | 총 52초 |
Insert 360만 rows / 10만 Bulk 10개 병렬 | 총 51초 | 총 86초 | 총 55초 |
결과를 보면 Time-Ordered UUID PK는 랜덤 UUID PK보다는 확실히 나은 성능을 보였지만,
안타깝게도 기대를 걸었던 AUTO_INCREMENT PK의 핫스팟 문제 해결사 역할을 해주지는 못했다.
병렬 처리 개수가 적을 때(2개)는 AUTO_INCREMENT보다 빠른 모습을 보이기도 했지만,
병렬 처리 개수가 늘어남에 따라 AUTO_INCREMENT의 최고 성능 지점(병렬 5개, 43초)에는 미치지 못했고,
오히려 특정 구간에서는 더 느린 결과(예: 병렬 6개 Auto PK 45초 vs Time-Ordered UUID 52초)를 보이기도 했다.
전반적으로 AUTO_INCREMENT와 유사하거나 약간 뒤처지는 성능 패턴을 나타냈다.
DB 내부 지표 분석: 실패의 원인을 찾아서
총 수행 시간만으로는 Time-Ordered UUID가 왜 기대만큼의 성능을 내지 못했는지,
그리고 각 PK 전략이 클러스터드 인덱스에 정확히 어떤 영향을 미쳤는지 명확히 알 수 없었다.
그래서 DB 내부 지표, 특히 테이블의 공간 활용 효율성과 관련된 지표들을 중점적으로 살펴보았다.
대량 Insert 작업 후 각 테이블의
- TABLE_ROWS (전체 행 수),
- AVG_ROW_LENGTH (행 평균 길이),
- DATA_LENGTH (순수 데이터가 차지하는 공간),
- 그리고 Data_free (할당되었지만 사용되지 않는 빈 공간) 값을
INFORMATION_SCHEMA.TABLES (또는 "SHOW TABLE STATUS;")에서 확인한 결과는 다음과 같았다.
PK 전략 | AVG_ROW_LENGTH | DATA_LENGTH | ROWS | Data free |
AUTO_INCREMENT | 61 | 222052352 | 3587121 | 4194304 |
랜덤 UUID | 156 | 549453824 | 3521773 | 6291456 |
Time-Ordered UUID | 73 | 256819200 | 3498643 | 5242880 |
측정 결과, Data_free 값은
- 랜덤 UUID가 약 6.3MB로 가장 높았고,
- Time-Ordered UUID가 약 5.2MB,
- AUTO_INCREMENT가 약 4.2MB로 가장 낮았다.
일반적으로 Data_free 값이 클수록 테이블 내부에 사용되지 않는 공간, 즉 단편화가 많이 발생했다고 해석할 수 있다.
이 지표는 랜덤 UUID가 가장 많은 내부 단편화를 유발했으며,
Time-Ordered UUID도 AUTO_INCREMENT에 비해 다소 높은 단편화 수준을 보였음을 시사한다.
이는 두 UUID 방식 모두 페이지 분할이 AUTO_INCREMENT보다 더 발생했을 가능성을 나타낸다.
DATA_LENGTH를 보면,
- 랜덤 UUID는 약 549MB로 가장 많은 데이터 공간을 사용했고,
- Time-Ordered UUID는 약 257MB,
- AUTO_INCREMENT는 약 222MB를 사용했다.
AVG_ROW_LENGTH 역시,
- 랜덤 UUID(156) > Time-Ordered UUID(73) > AUTO_INCREMENT(61) 순으로,
- PK 타입의 크기(BINARY(16) vs BIGINT)와 그로 인한 내부 오버헤드가 반영된 결과로 보인다.
특히 랜덤 UUID의 경우 극심한 페이지 분할로 인해,
각 페이지의 실제 데이터 밀도가 낮아져 전체 데이터 크기가 커졌을 가능성이 크다.
Time-Ordered UUID 역시 AUTO_INCREMENT에 비해 더 많은 공간을 사용했는데,
이는 PK 크기와 함께 약간의 페이지 비효율성을 동반했을 수 있다.
기대에는 못 미쳤지만, 중요한 단서를 얻다
총 수행 시간과 DB 내부 지표를 종합적으로 분석한 결과,
Time-Ordered UUID는 랜덤 UUID보다 확실히 클러스터드 인덱스에 친화적인 모습을 보였지만,
AUTO_INCREMENT의 전반적인 효율성과 최고 성능에는 미치지 못했다.
Time-Ordered UUID는 Data_free 값과 DATA_LENGTH를 통해 볼 때,
랜덤 UUID만큼 극심한 페이지 분할이나 단편화를 유발하지는 않았다.
그러나 AUTO_INCREMENT에 비해서는 DATA_LENGTH와 Data_free가 다소 높아,
완벽한 순차적 Insert의 이점을 완전히 살리지는 못한 것으로 보인다.
이는 Time-Ordered UUID도 시간 순서를 따르지만,
동일 시간 내에서의 미세한 값 분산이 약간의 페이지 재구성이나 공간 비효율을 야기했을 수 있음을 시사한다.
또한, BINARY(16) PK 크기로 인한 오버헤드도 무시할 수 없는 요인이다.
결론적으로, Time-Ordered UUID는 AUTO_INCREMENT의 핫스팟 문제를 일부 완화할 가능성은 보여주었으나,
우리 환경에서는 그 효과가 기대만큼 크지 않았고, 오히려 AUTO_INCREMENT의 단순성과 효율성을 넘어서지는 못했다.
이는 단일 테이블, 단일 인덱스 구조에서 PK 전략 변경만으로는,
대량 Insert 성능 개선에 근본적인 한계가 있음을 다시 한번 확인시켜 주었다.
그렇다면, B-Tree 자체를 물리적으로 분할하여 쓰기 경합의 범위를 더욱 좁히고,
각 영역에서 독립적인 Insert가 가능하도록 만들 수는 없을까?
이 질문이 나를 다음 목적지, 테이블 파티셔닝으로 이끌었다.
테이블 파티셔닝: 쓰기 부하 분산, 클러스터드 인덱스의 한계를 넘어서다
Time-Ordered UUID 실험은 기대만큼의 극적인 성능 향상을 가져다주지 못했다.
클러스터드 인덱스를 고려한 PK 전략 수정만으로는,
단일 테이블, 단일 B-Tree 구조에서 발생하는 대량 Insert의 근본적인 병목을 완전히 해소하기 어렵다는 결론에 이르렀다.
결국, 모든 데이터는 하나의 거대한 책장(테이블)에, 하나의 정렬 기준(클러스터드 인덱스)으로 관리되고 있었기 때문이다.
그렇다면, 이 거대한 책장 자체를 여러 개의 작은 책장으로 나누어 관리하면 어떨까?
각 책장(파티션)은 독립적으로 데이터를 저장하고, 쓰기 작업 또한 각자의 책장에서 이루어진다면,
병목 현상을 훨씬 효과적으로 분산시킬 수 있지 않을까?
이 아이디어가 바로 테이블 파티셔닝(Table Partitioning) 도입을 검토하게 된 계기다.
왜 파티셔닝인가? 클러스터드 인덱스 환경에서의 부하 분산 기대 효과
테이블 파티셔닝은 논리적으로는 하나의 테이블이지만,
물리적으로는 여러 개의 작은 단위(파티션)로 데이터를 분할하여 저장하는 기술이다.
각 파티션은 자체적인 저장 공간과 인덱스를 가질 수 있으며, 특정 파티셔닝 키를 기준으로 데이터가 분배된다.
우리의 "객실 가용성 데이터"는 주로 날짜를 기준으로 생성되고 조회되는 특성이 있다.
만약 available_date 컬럼의 년(YYYY), 월(MM)을 기준으로 파티션을 나눈다면 다음과 같은 이점을 기대할 수 있다.
1. Insert 부하 분산
새로운 객실 가용성 데이터는 기본적으로 미래의 특정 기간에 집중적으로 생성된다.
예를 들어, 6개월 치 데이터를 생성한다면, 각 월별 파티션으로 Insert 요청이 분산될 수 있다.
만약 AUTO_INCREMENT PK를 계속 사용하더라도,
각 파티션은 내부적으로 독립적인 B-Tree 구조(또는 그 일부)를 가지므로,
마지막 페이지에 대한 핫스팟 경합이 단일 테이블일 때보다 훨씬 줄어들 것이다.
각 파티션의 "마지막 페이지"는 서로 다르기 때문이다.
병렬 Insert 시, 각 스레드가 서로 다른 파티션(예: 서로 다른 월)에 데이터를 동시에 Insert 한다면,
Lock 경합 없이 진정한 병렬 처리가 가능해져 전체 처리량을 크게 향상시킬 수 있다.
2. 조회 성능 향상 (Partition Pruning)
사용자가 특정 날짜 범위(예: "다음 달 예약 가능한 방")를 조회할 때,
옵티마이저는 해당 날짜가 포함된 파티션만 스캔하고 나머지 파티션은 아예 접근조차 하지 않는다 (파티션 프루닝).
이는 전체 데이터 크기가 아무리 커도, 실제 조회 대상 데이터는 줄어들어 응답 속도를 크게 개선할 수 있다.
3. 데이터 관리 용이성
오래된 데이터(예: 지난 년도의 가용성 데이터)를 삭제하거나 아카이빙할 때,
해당 파티션 전체를 DROP하거나 TRUNCATE하는 방식으로 매우 빠르게 처리할 수 있다.
DELETE 문을 사용하는 것보다 훨씬 효율적이고 시스템 부하도 적다.
ㅡ물론 만능은 아니다
물론, 파티셔닝 도입이 항상 만능은 아니다.
파티션 키 설계가 잘못되면 오히려 성능이 저하될 수 있고, 파티션 개수가 너무 많아지면 관리 오버헤드가 증가할 수 있다.
또한, 특정 조회 쿼리가 파티션 프루닝을 제대로 활용하지 못하면 성능 이점을 누리기 어렵고,
경우에 따라서는 쿼리가 더 복잡해질 수도 있다.
주기적인 파티션 생성, 삭제, 병합 등의 관리 작업이 필요하기에, 이에 대한 자동화 방안을 마련하는 것도 숙제다.
하지만 현재 우리가 겪고 있는 대량 Insert 병목과 클러스터드 인덱스의 핫스팟 문제를 고려했을 때,
파티셔닝은 충분히 시도해 볼 만한 가치가 있는 전략이라고 판단했다.
"객실 가용성 데이터"를 위한 파티셔닝 전략 설계: 년-월 기준 Range 파티셔닝
예약 가용성 room_availability 테이블은 date (예약 가능 날짜) 컬럼을 가지고 있다.
이 컬럼을 기준으로 년(YYYY)과 월(MM)을 조합하여 Range 파티셔닝을 적용하기로 했다.
예를 들어, "2025년 5월 데이터", "2025년 6월 데이터" 와 같이 각 월별로 파티션을 생성하는 것이다.
(MySQL에서는 RANGE COLUMNS를 사용하여 DATE 타입 컬럼을 직접 파티션 키로 사용할 수 있다.)
파티션 정의 (DDL):
# 기존 DDL
CREATE TABLE `origin_room_availability` (
`id` bigint NOT NULL AUTO_INCREMENT,
`created_at` datetime(6) NOT NULL,
`updated_at` datetime(6) NOT NULL,
`available_count` int NOT NULL,
`date` date NOT NULL,
`price` int NOT NULL,
`room_type_id` bigint NOT NULL,
PRIMARY KEY (`id`),
KEY `room_type_id` (`room_type_id`),
CONSTRAINT `room_availability__fk` FOREIGN KEY (`room_type_id`) REFERENCES `room_type` (`id`)
);
# 파티셔닝 도입 및 기본키 수정 DDL
CREATE TABLE hotel.`partition_room_availability` (
`room_type_id` bigint NOT NULL,
`date` date NOT NULL,
`available_count` int NOT NULL,
`price` int NOT NULL,
`created_at` datetime(6) NOT NULL,
`updated_at` datetime(6) NOT NULL,
PRIMARY KEY `room_type_pk` (`room_type_id`, `date`)
) PARTITION BY RANGE COLUMNS(date) (
PARTITION p202505 VALUES LESS THAN ('2025-06-01'),
PARTITION p202506 VALUES LESS THAN ('2025-07-01'),
PARTITION p202507 VALUES LESS THAN ('2025-08-01'),
PARTITION p202508 VALUES LESS THAN ('2025-09-01'),
PARTITION p202509 VALUES LESS THAN ('2025-10-01'),
PARTITION p202510 VALUES LESS THAN ('2025-11-01'),
PARTITION p202511 VALUES LESS THAN ('2025-12-01'),
PARTITION pMAX VALUES LESS THAN (MAXVALUE)
);
MySQL에서 Range 파티셔닝을 사용할 때,
파티션 키로 사용되는 모든 컬럼은 Primary Key 또는 Unique Key의 일부여야 한다.
이번 파티셔닝 과정에서는 [room_type_id & date]가 고유하기에 이를 PK로 두어 설정했다.
만약 기존 id로 PK를 구성한다면, available_date를 PK에 포함시켜 복합 PK로 구성해야 한다.
이는 클러스터드 인덱스의 구조에도 영향을 미치므로 신중한 설계가 필요하다.
파티셔닝 환경에서의 병렬 Insert: 진정한 부하 분산을 향하여
이제 테이블이 년-월 기준으로 파티셔닝되었으니, 병렬 Insert 전략도 이에 맞춰 수정해야 한다.
핵심은 각 스레드가 서로 다른 파티션에 데이터를 Insert 하도록 유도하는 것이다
애플리케이션 로직 변경
- 배치 작업 시 생성해야 할 객실 가용성 데이터의 날짜 범위를 확인한다 (예: 오늘부터 180일 후까지).
- 이 날짜 범위를 기준으로, 데이터가 속할 파티션(년-월)을 미리 계산한다.
- 병렬 처리 시, 각 스레드에게 서로 다른 파티션(또는 파티션 그룹)에 해당하는 데이터의 생성 및 Insert 작업을 할당한다.
현재는 Tasklet 과정 중 Processor로 넘어온 output를 월별로 쪼갠 뒤 병렬 처리를 시도했다.
private void writeAvailabilities(
List<Availability> output,
StepContribution contribution,
Perf perf
) {
Map<YearMonth, List<Availability>> groupedByMonth = output.stream()
.collect(Collectors.groupingBy(avail -> {
LocalDate date = avail.getId().getDate();
return YearMonth.from(date);
}));
List<YearMonth> yearMonths = new ArrayList<>(groupedByMonth.keySet());
IntStream.range(0, groupedByMonth.size())
.parallel()
.forEach(i -> {
List<PartitionRoomAvailability> writeChunk = groupedByMonth.get(yearMonths.get(i));
availabilityWriter.write(writeChunk);
contribution.incrementWriteCount(writeChunk.size());
});
perf.log("Write rows", writeAvailabilities.size());
}
기대 효과
- 각 스레드는 자신이 담당하는 파티션의 클러스터드 인덱스에만 접근하게 된다.
- 서로 다른 파티션에 대한 쓰기는 물리적으로 분리된 공간에서 이루어지므로,
- 이전의 단일 테이블에서 발생했던 마지막 페이지 핫스팟이나 인덱스 페이지 잠금 경합이 현저히 줄어들 것이다.
이론적으로 파티셔닝은 각 파티션에 대한 쓰기 작업을 분산시켜 병렬 Insert 성능을 크게 향상시킬 것으로 기대했다.
특히, 각 스레드가 서로 다른 월(파티션)의 데이터를 동시에 처리함으로써,
단일 테이블에서 발생했던 핫스팟 문제를 회피할 수 있을 것이라 예상했다.
과연 실제 결과는 어땠을까?
이전과 동일하게 총 360만 건의 데이터를 Insert 하는 시나리오에서,
이번에는 년-월 기준 Range 파티셔닝이 적용된 테이블에 대해
각 월(파티션)별로 데이터를 분배하여 7개의 스레드로 동시에 Insert 하는 테스트를 진행했다.
(이전 테스트에서 병렬 5~7개 구간에서 가장 좋은 성능을 보였으므로, 이번에는 7개 병렬로 고정하여 테스트했다.)
비교를 위해, 파티셔닝을 적용하지 않은 단일 테이블에 동일한 데이터 분산 로직 후 7개 병렬 Insert를 수행한 결과와,
이전 4편에서 진행했던 단일 테이블 대상의 단순 병렬 Insert 결과("기존 Bulk 테스트")를 함께 제시한다.
시나리오 (총 360만 rows) | 기존 Bulk 테스트 | 단일 테이블 | 파티션 분할 |
5만 Rows -> 분산 후 7개 병렬 | - | 총 34초 | 총 26초 |
10만 rows -> 분산 후 7개 병렬 | 총 110초 (10만 단일) | 총 34초 | 총 26초 |
20만 rows -> 분산 후 7개 병렬 | 총 81초 (병렬: 2) | 총 34초 | 총 28초 |
30만 rows -> 분산 후 7개 병렬 | 총 54초 (병렬: 3) | 총 36초 | 총 32초 |
40만 rows -> 분산 후 7개 병렬 | 총 47초 (병렬: 4) | 총 38초 | 총 38초 |
50만 rows -> 분산 후 7개 병렬 | 총 43초 (병렬: 5) | 총 43초 | 총 45초 |
60만 rows -> 분산 후 7개 병렬 | 총 45초 (병렬: 6) | 총 44초 | 총 48초 |
70만 rows -> 분산 후 7개 병렬 | 총 46초 (병렬: 7) | 총 47초 | 총 47초 |
80만 rows -> 분산 후 7개 병렬 | 총 47초 (병렬: 8) | 총 51초 | 총 43초 |
90만 rows -> 분산 후 7개 병렬 | 총 50초 (병렬: 9) | 총 55초 | 총 57초 |
100만 rows -> 분산 후 7개 병렬 | 총 51초 (병렬: 10) | 총 57초 | 총 58초 |
Insert 쿼리 평균 처리 시간 시나리오 | 단일 Bulk 쿼리 | 단일 테이블 7개 병렬 쿼리 | 파티션 분할 7개 병렬 쿼리 |
5만 rows | - | 평균 0.9초 | 평균 0.6초 |
10만 rows | 평균 3.6초 | 평균 0.9초 | 평균 0.6초 |
20만 rows | 평균 5.4초 | 평균 1.9초 | 평균 1.3초 |
30만 rows | 평균 7.8초 | 평균 2.8초 | 평균 2.4초 |
40만 rows | 평균 11.1초 | 평균 3.8초 | 평균 3.8초 |
50만 rows | 평균 14.6초 | 평균 5초 | 평균 5.3초 |
60만 rows | 평균 18.8초 | 평균 6.3초 | 평균 6.7.초 |
70만 rows | 평균 22.8초 | 평균 8.8초 | 평균 8.8초 |
80만 rows | 평균 27초 | 평균 10.6초 | 평균 8.3초 |
90만 rows | 평균 31.2초 | 평균 13.5초 | 평균 13.7초 |
100만 rows | 평균 36.1초 | 평균 14.3초 | 평균 14.6초 |
테스트 결과는 예상과 다소 다른, 흥미로운 양상을 보여주었다.
위 표와 그래프에서 보듯이,
스레드당 처리하는 데이터 양(Bulk 크기)이 적을 때(예: 10만~30만 rows)는
파티션 분할 테이블이 단일 테이블보다 확실히 빠른 성능을 보여주었다.
예를 들어, 스레드당 10만 건씩 처리하는 경우,
파티셔닝된 테이블은 26초 만에 작업을 완료하여 단일 테이블(34초)보다 약 23% 빠른 속도를 기록했다.
이는 파티셔닝으로 인해 각 스레드가 서로 다른 물리적 영역에 쓰기 작업을 수행하면서
초기 핫스팟 경합이 효과적으로 줄어들었기 때문으로 분석된다.
하지만, 스레드당 처리량이 특정 수준(예: 40만 rows)을 넘어가면서부터는
단일 테이블과 파티션 분할 테이블의 성능 차이가 거의 없어지거나,
오히려 파티셔닝된 테이블이 약간 더 느려지는 '엎치락뒤치락'하는 현상이 관찰되었다.
예를 들어, 스레드당 50만 건 처리 시 단일 테이블은 43초, 파티션 분할 테이블은 45초가 소요되었고,
90만 건 처리 시에는 각각 55초와 57초로 비슷한 양상을 보였다.
이러한 결과가 나타난 이유는 몇 가지로 추측해볼 수 있다:
- 파티션 자체의 오버헤드:
파티션 수가 많아지면(현재 월별 파티션), 각 파티션을 관리하고 접근하는 데 드는 미세한 오버헤드가 누적될 수 있다.
스레드당 처리량이 매우 커지면, 각 스레드가 여러 파티션에 걸쳐 데이터를 쓰게 되거나,
단일 파티션 내에서도 데이터 양이 많아져 내부적인 병목이 발생했을 수 있다. - 단일 테이블의 최적화 한계 도달:
스레드당 처리량이 매우 커지면, 단일 테이블이라 할지라도 이미 병렬 처리의 한계점에 도달하여
더 이상 파티셔닝으로 인한 분산 효과가 크게 나타나지 않을 수 있다.
즉, 다른 시스템 자원(CPU, I/O 대역폭 등)이 병목이 되는 것이다. - 테스트 데이터의 분포 및 특성:
테스트 데이터가 특정 파티션에 집중되도록 생성되었다면,
해당 파티션에 대한 경합이 발생하여 파티셔닝의 이점을 충분히 살리지 못했을 수도 있다.
(이번 테스트 과정에서는 고의로 데이터 분포를 나눠서 처리했기 때문에 해당 사항이 아니나,
실 서비스에서는 충분히 발생할 수 있다.)
결론적으로, 파티셔닝은 스레드당 처리량이 적절한 수준일 때 병렬 Insert 성능 개선에 확실히 기여했지만,
처리량이 매우 커지는 특정 구간부터는 그 효과가 줄어들거나 단일 테이블과 유사한 성능을 보였다.
파티셔닝, 만능일까? - 장점과 함께 고려해야 할 현실적인 문제들
이번 파티셔닝 실험은 몇 가지 중요한 시사점을 남겼다.
스레드당 처리하는 데이터 양이 적절한 수준일 때, 파티셔닝은 분명 병렬 Insert 성능 개선에 기여했다.
각 스레드가 서로 다른 물리적 파티션에 접근함으로써 초기 핫스팟 경합을 효과적으로 줄일 수 있었기 때문이다.
하지만 처리량이 특정 수준을 넘어가면서부터는 그 효과가 줄어들거나 단일 테이블과 유사한 성능을 보였다.
이는 파티셔닝 자체의 오버헤드, 혹은 시스템 전체의 다른 자원(CPU, I/O 대역폭 등)이 병목으로 작용했을 가능성을 의미한다.
즉, 파티셔닝이 모든 상황에서 만능 해결책이 될 수는 없으며,
데이터의 양, 병렬 처리 수준, 그리고 시스템 전체의 병목 지점을 종합적으로 고려해야 한다는 교훈을 얻었다.
그럼에도 불구하고 이번 테스트 결과는,
대량 데이터 Insert 성능 개선에 있어 파티셔닝이 강력한 도구가 될 수 있음을 명확히 보여주었다.
클러스터드 인덱스의 특성을 이해하고,
데이터의 물리적 저장 구조를 변경하는 것이 얼마나 큰 차이를 만들어낼 수 있는지 다시 한번 깨닫는 계기가 되었다.
이제 애플리케이션 로직(PK 전략, 병렬 처리 방식)과 DB 구조(파티셔닝) 변경을 통해 Insert 성능을 한 단계 끌어올렸다.
그렇다면, 이보다 더 빠르고 효율적인 방법은 없을까?
지금까지 우리는 JDBC를 통한 batchUpdate를 기반으로 최적화를 진행해왔다.
아무리 rewriteBatchedStatements=true 옵션을 사용하고,
PreparedStatement의 실행 계획 캐시를 활용하며 파티셔닝으로 부하를 분산시킨다 한들,
결국 SQL 인터페이스를 통해 데이터를 한 건 한 건(또는 묶음 단위) DB에 전달하고 DB는 이를 파싱하고 실행하는 과정을 거친다.
만약, 이 SQL 계층의 오버헤드 자체를 최소화하고 데이터를 파일에서 직접 데이터베이스 테이블로 '쏟아붓는' 방식이 있다면?
MySQL에는 바로 이러한 작업을 위해 특별히 설계된, 강력한 기능이 존재한다. 바로 LOAD DATA INFILE 명령어다.
다음 장에서는 이 MySQL의 '최종 병기'라 불리는 LOAD DATA INFILE을 우리 배치 프로세스에 통합하여,
Insert 성능의 극한을 경험해 보기 위한 도전을 시작한다.
또 다른 도전: LOAD DATA INFILE에 대해
MySQL에는 대량 데이터 적재(Bulk Data Loading) 작업을 위해 특별히 설계된
매우 강력하고 효율적인 기능이 존재하는데, 이것이 바로 LOAD DATA INFILE 명령어다.
어떠한 이유 때문에 LOAD DATA INFILE 명령이 가장 강력하다는 것일까?
LOAD DATA INFILE, 왜 압도적으로 빠른가? - 내부 동작 원리 파헤치기
LOAD DATA INFILE이 일반적인 INSERT 문보다 훨씬 빠르다고 알려져 있는데, 그 이유는 무엇일까?
단순히 파일에서 데이터를 읽어오기 때문만은 아니다.
핵심은 SQL 계층의 오버헤드를 최소화하고, 스토리지 엔진 레벨에 가깝게 데이터를 직접 처리하는 데 있다.
- 최소화된 SQL 파싱 및 처리:
- 일반 INSERT 문은 각 행(또는 배치)마다 SQL 파싱, 문법 검사, 실행 계획 수립 등의 과정을 거친다.
수백만 건의 데이터라면 이 오버헤드는 엄청나다. - LOAD DATA INFILE은 명령어 자체는 한 번만 파싱되고,
이후 데이터 파일에서 직접 행들을 읽어와 테이블에 삽입하는 작업은 내부적으로 최적화된 경로를 사용한다.
각 행에 대한 반복적인 SQL 처리 오버헤드가 거의 없다.
- 일반 INSERT 문은 각 행(또는 배치)마다 SQL 파싱, 문법 검사, 실행 계획 수립 등의 과정을 거친다.
- 네트워크 오버헤드 감소:
- 클라이언트에서 서버로 대량의 INSERT 문을 전송하는 대신,
데이터 파일 전체(또는 매우 큰 청크)를 서버로 효율적으로 전송하거나,
파일이 서버에 이미 있다면(non-LOCAL LOAD DATA) 데이터 전송 자체의 네트워크 오버헤드는 거의 없다.
- 클라이언트에서 서버로 대량의 INSERT 문을 전송하는 대신,
- 스토리지 엔진 최적화:
- InnoDB와 같은 스토리지 엔진은 LOAD DATA INFILE을 통해 대량의 데이터가 한꺼번에 들어온다는 것을 인지하고,
데이터 페이지 채우기, 인덱스 빌드 등의 작업을 벌크 연산에 맞게 최적화한다. - 예를 들어, 클러스터드 인덱스의 경우, 데이터를 정렬하여 한 번에 페이지에 기록하거나,
인덱스 빌드를 지연시켜 모든 데이터가 로드된 후 한 번에 처리하는 등의 내부 최적화가 가능하다.
- InnoDB와 같은 스토리지 엔진은 LOAD DATA INFILE을 통해 대량의 데이터가 한꺼번에 들어온다는 것을 인지하고,
- 로그 기록 최소화 (특정 조건 하):
- LOAD DATA INFILE은 일반적인 DML보다,
리두 로그(Redo Log)나 언두 로그(Undo Log) 기록을 최소화하는 방식으로 동작할 수 있다.
(복구 가능성을 위해 완전히 로깅을 생략하지는 않음)
(autocommit 설정 및 트랜잭션 관리 방식에 따라 달라질 수 있다)
- LOAD DATA INFILE은 일반적인 DML보다,
이러한 이유들로 인해 LOAD DATA INFILE은
대량 데이터 적재 시 INSERT 문을 반복하는 것보다 훨씬 뛰어나다는데 동의할 수 밖에 없었다
LOAD DATA INFILE이 지금까지의 모든 노력을 뛰어넘는 압도적인 성능을 보여줄 수 있겠다는 기대감으로 도전을 시작했다.
LOAD DATA INFILE: 험난했던 과정, 그러나 명확했던 목표
이론적 확신은 있었지만, LOAD DATA INFILE을 성공적으로 적용하기까지의 과정은 예상대로 순탄치만은 않았다.
실제 구현 과정에서 마주했던 주요 문제점들과 이를 해결해 나간 과정을 단계별로 공유하고자 한다.
1단계 : 스텝 분리 (처음으로 "2단계 스텝"을 도입한 이유)
첫 번째 관문은 Step 분리였다. 지금까지의 배치 작업은 하나의 Tasklet으로 모든 흐름을 처리했다.
DB에서 데이터를 읽고, 이를 기반으로 저장할 데이터를 가공하여
그대로 DB에 저장하는 로직까지 하나의 Step에서 처리하는 구조였다.
하지만 이번엔 처음으로 Step을 두 개로 분리했다.
- Step 1: GenerateCsvOpenAvailabilityTasklet → 데이터를 읽고, 가공하고, CSV 파일로 만든다.
- Step 2: LoadDataInfileOpenAvailabilityTasklet → 그 파일들을 데이터베이스에 적재한다.
이 단순한 분리는 결과적으로 복잡한 최적화 설계를 가능하게 만든 열쇠였다.
파일 생성 성능을 독립적으로 측정할 수 있었고,
DB 적재 로직의 병렬성이나 오류 제어도 분리된 맥락에서 자유롭게 다룰 수 있었다.
“한 번에 다 한다”는 편리함 대신,
"문제를 쪼개고 각 단계의 책임을 분리한다"는 구조적 접근의 힘을 느낄 수 있었다.
2단계: ExecutionContext로 스텝 간 CSV 경로 전달
스텝이 나뉘며 생긴 현실적인 문제는 CSV 파일 경로를 다음 스텝으로 어떻게 넘길 것이냐는 것이었다.
이를 해결하기 위해 Spring Batch의 ExecutionContext를 활용했다.
파일 생성 스텝에서 모든 CSV 파일의 경로를 Set<String> 형태로 수집하고,
이를 JobExecutionContext에 저장했다. 그다음 적재 스텝에서 다시 꺼내 사용한다.
// Step 1 - 파일 경로 저장
contribution.getStepExecution()
.getJobExecution()
.getExecutionContext()
.put("csvFilePaths", filePaths);
// Step 2 - 파일 경로 읽기
Object obj = jobExecution.getExecutionContext().get("csvFilePaths");
Set<String> csvFilePaths = (Set<String>) obj;
덕분에 두 스텝은 느슨하게 연결되면서도, 파일 경로라는 명시적인 인터페이스를 통해 협력할 수 있게 되었다.
그리고 이 구조는 이후 설명할 LOAD DATA LOCAL INFILE 병렬 처리 구조에서도 매우 중요한 역할을 하게 된다.
3단계: CSV 파일 생성 최적화 (360만 rows: 21초 → 0.6초)
그 다음 관문은 LOAD DATA INFILE의 데이터를 CSV 파일 형태로 준비하는 것이었다.
OpenCSV 라이브러리 활용
초기 구현에서는 아래와 같이 OpenCSV 라이브러리를 사용하여 RoomAvailability 객체 리스트를 파일로 변환했다.
// csv 변환 객체
public class CsvDto {
@CsvBindByName(column = "room_type_id")
private long roomTypeId;
@CsvBindByName(column = "open_data")
private String openData;
...
// 생성자, getter, setter 생략
}
public void exportToCsv(List<CsvDto> dtos, String filePath) throws Exception {
try (Writer writer = Files.newBufferedWriter(Paths.get(filePath))) {
StatefulBeanToCsv<CsvDto> beanToCsv = new StatefulBeanToCsvBuilder<User>(writer)
.withApplyQuotesToAll(false)
.build();
beanToCsv.write(dtos);
}
}
그런데 CSV 파일 생성이 이렇게 느릴 줄은 몰랐다.
360만 건의 대용량 데이터를 6개 파일로 분할하여 병렬 처리했음에도, 이 파일 생성 단계에서만 21초가 소요되었다.
이유는 명확했다. OpenCSV는 각 객체를 리플렉션 기반으로 매핑하며, 한 줄 한 줄을 Writer에 넘긴다.
쓰기 자체보다 Bean 매핑, quote 처리, 내부 상태 유지에 많은 비용이 들고 있었던 것이다.
이는 DB 적재 성능을 논하기 전에 이미 전체 배치 시간에 큰 부담으로 작용하는 수치였다.
univocity-parsers 라이브러리 변경 및 문자열 변환 최적화
성능 개선을 위해 먼저 univocity-parsers 라이브러리로 교체를 검토했다.
더 나아가, 근본적인 효율성 증대를 위해 객체 매핑을 포기하고, String[] 형태로 직접 데이터를 만들기로 했다.
대신 무식할 정도로 빠르게 CSV를 쓸 수 있었다.
// Writer
public void write(List<String[]> outputs, Path csvPath) {
if (outputAvailabilities.isEmpty()) {
return;
}
CsvWriterSettings settings = new CsvWriterSettings();
settings.getFormat().setDelimiter(',');
try (Writer writer = Files.newBufferedWriter(csvPath)) {
CsvWriter csvWriter = new CsvWriter(writer, settings);
for (String[] row : outputs) {
csvWriter.writeRow(row);
}
} catch (Exception e) {
log.info("Failed to generate CSV availabilities", e);
}
}
이러한 최적화(라이브러리 변경, 객체 변환 최소화 및 String[] 직접 사용)를 적용한 결과,
6개 파일에 대한 병렬 쓰기 시간은 놀랍게도 21초에서 단 0.6초로 단축되었다.
LOAD DATA INFILE의 본격적인 성능 개선를 위한 최적화의 첫걸음이었다.
4단계: MySQL 보안 및 설정 문제 & JDBC 옵션 문제 해결
신속하게 준비된 CSV 파일들을 DB에 적재하기 위해 LOAD DATA INFILE 쿼리를 실행했지만,
예상치 못한 오류들이 발생했다. 첫 번째는 MySQL의 secure_file_priv 시스템 변수 관련 문제였다.
위와 같이 해당 변수에 지정된 특정 디렉터리에 파일이 위치해야만 LOAD DATA INFILE 명령어가 정상 작동하는데,
운영 환경에서 이 경로에 배치 작업 파일을 위치시키는 것은 보안상 바람직하지 않다고 판단했다.
이에 대한 대안으로 LOAD DATA LOCAL INFILE을 사용하기로 결정했다.
LOCAL 옵션은 클라이언트 측의 파일을 서버로 전송하여 로드하므로 secure_file_priv 경로 제약에서 비교적 자유로울 수 있다.
하지만 역시 Spring Boot에서 LOAD DATA LOCAL INFILE을 사용하려면 단순히 쿼리만 날린다고 끝나지 않았다.
JDBC 드라이버가 이 기능을 허용해야 하는데, 이를 위해 JDBC URL에 아래 옵션을 반드시 추가해야 한다:
spring:
datasource:
url: jdbc:mysql://localhost:3306/hotel?allowLoadLocalInfile=true
username: test
password: test
이 옵션이 없으면 다음과 같은 에러를 만나게 된다:
The MySQL server is running with the --secure-file-priv option...
내부적으로는 "LOCAL INFILE을 허용하지 않음"이라는 뜻이며 서버가 아닌 클라이언트(JDBC) 측 설정 문제다.
정리하자면, 이 한 줄 설정 없이는 어떤 LOAD DATA LOCAL INFILE도 작동하지 않는다.
하지만 이렇게 JDBC 옵션까지 추가했음에도 불구하고 또 BadSqlGrammarException이 발생했다.
이번에는 MySQL 서버 설정에서 local_infile 옵션 자체가 OFF로 비활성화되어 있어 발생한 오류였다.
해당 옵션을 SET GLOBAL local_infile = 1; 명령을 통해 활성화하는 추가적인 서버 설정 변경이 필요했다.
이러한 과정을 통해 LOAD DATA LOCAL INFILE을 실행할 수 있는 환경을 구축했다.
5단계: LOAD DATA LOCAL INFILE 병렬 처리 (18초 → 10초)
LOAD DATA LOCAL INFILE 명령 자체는 명확히 빨랐다.
약 60만 건의 데이터를 담은 CSV 파일 하나를 로드하는 데 약 2.7~2.8초가 소요되었다.
(단일 Bulk Insert는 60만 rows를 수행하는데 약 18초~19초가 소요된다)
하지만 처리해야 할 파일이 총 6개였으므로, 아래와 같이 순차적으로 실행한다면 약 17~18초가 소요되었다.
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
Set<String> csvFilePaths = getCsvFilePaths(contribution);
for (String filePath : csvFilePaths) {
String sql = "LOAD DATA LOCAL INFILE '" + filePath + "' "...;
jdbcTemplate.execute(sql);
}
return RepeatStatus.FINISHED;
}
파일 생성 시간을 극적으로 단축시킨 만큼, 이 부분에서도 추가적인 최적화 여지를 찾고 싶었다.
해결책은 역시 병렬 처리였다.
각기 다른 CSV 파일을 대상으로 하는 LOAD DATA LOCAL INFILE 명령들은 서로 독립적으로 실행될 수 있다고 판단했다.
Java Stream의 parallel() 기능을 활용하여 6개의 파일 로드 작업을 동시에 실행하도록 구현했다.
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
Set<String> csvFilePaths = getCsvFilePaths(contribution);
String[] csvFilePathArray = csvFilePaths.toArray(new String[0]);
IntStream.range(0, csvFilePathArray.length)
.parallel()
.forEach(i -> {
String sql = "LOAD DATA LOCAL INFILE '" + csvFilePathArray[i] + "' "..;
jdbcTemplate.execute(sql);
});
return RepeatStatus.FINISHED;
}
그 결과, 순차 실행 시 18~19초가 소요되던 로드 시간은 10~11초 수준으로 성공적으로 단축되었다.
6단계: 데이터 무결성을 위한 IGNORE 옵션 활용
대량 데이터 적재 시 발생할 수 있는 또 다른 문제는 데이터의 정합성, 특히 PK 중복이다.
파일 생성 과정에서 로직 오류나 기타 예기치 않은 이유로,
중복된 키를 가진 데이터가 포함될 가능성을 완전히 배제하기는 어렵다.
이러한 상황에서 배치 작업 전체가 중단되는 것을 방지하기 위해,
LOAD DATA LOCAL INFILE 명령어에 IGNORE 옵션을 추가했다.
이 옵션은 중복 키 오류가 발생하면 해당 행의 삽입만 건너뛰고 다음 행 처리를 계속 진행하도록 하여,
작업의 안정성과 데이터 적재 성공률을 높이는 데 기여한다.
LOAD DATA LOCAL INFILE '/tmp/file.csv'
IGNORE -- 중복 키 발생 시 해당 행 무시
INTO TABLE room_availability
FIELDS TERMINATED BY ','
LINES TERMINATED BY '\n'
(column1, column2, ...);
최종 성능 테스트: 기대치를 뛰어넘는 결과
단계 | 수행 시간 |
read-processor 반복 수행 시간 | 약 2.6초 |
총 360만 rows 분할 파일 생성 시간 | 약 0.6초 |
CSV DB 업로드 수행 시간 | 약 10.2초 |
총 계 | 총 13.6 초 |
앞서 설명한 CSV 파일 생성 최적화와 병렬화된 LOAD DATA LOCAL INFILE 전략을 통합한 결과,
총 360만 건의 데이터를 처리하는 데 약 13~14초라는 괄목할 만한 성과를 달성했다.
이전에 JDBC batchUpdate를 사용하며 최적화를 거듭했음에도 불구하고,
360만 rows를 저장하는데 30초 이상 소요되던 작업과 비교하면 이제는 13초대로 단축하면서 거의 실시간에 가까운 처리 능력이다.
이러한 결과는 LOAD DATA INFILE이 대용량 데이터 적재 상황에서
SQL 인터페이스의 오버헤드를 최소화하고 얼마나 효율적으로 동작할 수 있는지를 명확히 보여준다.
이후 고려해야 할 현실적인 문제들
1. 파일 I/O 병목
- 데이터를 파일로 쓰고, 다시 DB가 그 파일을 읽는 과정에서 디스크 I/O가 새로운 병목이 될 수 있다.
특히 매우 큰 데이터의 경우.
2. 보안 및 권한
- FILE 권한, secure_file_priv 설정, LOCAL 옵션 사용 시 보안 취약점 등을 신중히 검토해야 한다.
- 특히, 운영 환경에서는 이 파일 적재 경로와 권한을 어떻게 안전하게 유지할 수 있을지 고민이 필요하다
3. 트랜잭션 원자성
- LOAD DATA INFILE은 그 자체로 큰 단위 작업이다.
Spring Batch의 청크 단위 트랜잭션과 어떻게 조화시킬지, 실패 시 복구 전략은 어떻게 가져갈지 명확히 해야 한다.
- LOAD DATA INFILE 전체를 하나의 Tasklet 트랜잭션으로 묶거나,
- 파일 자체를 작은 단위로 분할하여 여러 번 실행하는 방안
데이터 정합성
- 파일 생성 과정에서의 데이터 변환 오류나,
- LOAD DATA INFILE 실행 중 데이터 유실 가능성 등을 고려한 검증 로직이 필요할 수 있다.
이후 이러한 도전 과제와 고려사항들을 충분히 검토하고 해결 방안을 마련한다면,
LOAD DATA INFILE은 분명 대량 Insert 성능을 한 차원 끌어올릴 수 있는 강력한 무기가 될 것이다.
클러스터드 인덱스와의 동행, 그 끝에서 얻은 교훈
이번에는 클러스터드 인덱스에 대한 깊이 있는 이해의 중요성에서부터 시작하여,
Time-Ordered UUID, 테이블 파티셔닝 등 다양한 PK 및 구조 최적화 전략을 탐구했다.
그 과정에서 겪었던 수많은 시행착오와 문제 해결 경험들은 값진 자산이 되었다.
PK 전략은 중요한 출발점이었지만, 결국 구조를 바꾸는 선택을 해야 했다.
최종적으로 LOAD DATA INFILE이라는 강력한 도구를 통해 성능 목표를 달성했지만,
중요한 것은 단순히 특정 기술을 적용했다는 사실보다 문제의 본질을 파악하고,
데이터의 특성과 시스템 환경을 고려하여 최적의 해결책을 찾아나가는 과정 그 자체였다.
파일 생성 방식의 개선, DB 설정 문제 해결, 병렬 처리 적용 등
각 단계에서의 세심한 접근이 모여 성과를 이루어낼 수 있었다.
병목의 무게중심, IO를 넘어 시스템으로
여기까지의 실험을 통해 우리는 다음을 분명히 확인할 수 있었다.
- INSERT 병목은 파티셔닝과 LOAD DATA INFILE 전략으로 극복 가능하다.
- CSV 파일 생성 속도조차 새로운 병목이 될 수 있다.
- 병렬 적재는 처리량을 높이되, 설정 조건과 자원 제약을 반드시 고려해야 한다.
특히 이번 구조에서 중요했던 점은 단순히 기술을 적용했다는 것이 아니라,
배치의 단계별 책임을 나누고, 각 단계의 병목을 독립적으로 분석·개선할 수 있게 된 점이다.
Step 분리를 통해 성능 측정, 예외 제어, 리소스 사용까지 모두 명확해졌다.
ExecutionContext를 이용한 스텝 간 데이터 전달은,
단일 흐름에 얽매이지 않는 유연한 배치 구조의 첫걸음이기도 했다.
운영 환경에서 이 구조를 도입하기 위해선 몇 가지 추가 고려가 필요하다.
예를 들어 LOAD DATA LOCAL INFILE을 사용할 경우,
파일 시스템 접근 권한(secure_file_priv), MySQL 서버 설정(local_infile=1),
클라이언트 JDBC URL(allowLoadLocalInfile=true)의 세 가지 조건이 모두 만족되어야 한다.
운영 환경에서는 파일 접근 범위를 최소화하거나, 외부 파일 시스템 사용을 제한하는 방안도 고려되어야 한다.
다음 단계: DB는 어디까지 버틸 수 있을까?
성능 병목이 더 이상 SQL 한 줄이나 코드 최적화의 문제가 아니라면,
그다음 질문은 자연스럽게 "이 구조를 DB가 얼마나 잘 받아줄 수 있느냐"로 옮겨간다.
InnoDB 버퍼 사이즈, flush 정책, redo log, 병렬 쓰기 정책...
다음 글에서는 MySQL 내부 옵션을 직접 조정하며, 성능이 어떻게 바뀌는지 실험해볼 예정이다.
목표는 단순하다.
지금의 구조에서 추가적인 20~30% 성능 향상이 가능한가?
가능하다면 어떤 옵션 조합이 그 실마리를 쥐고 있는가?
이제 성능 튜닝의 마지막 단계를 향해 넘어가 본다.
참고: 주요 코드
Step 분리 Job Cofing 설정
@Configuration
@RequiredArgsConstructor
public class InfileOpenRoomJobConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
@Bean
public Job infileOpenAvailabilityTaskletJob(
Step generateCsvOpenAvailabilityStep,
Step loadCsvOpenAvailabilityStep
) {
String jobName = "infileOpenAvailabilityTaskletJob";
return new JobBuilder(jobName, jobRepository)
.start(generateCsvOpenAvailabilityStep)
.next(loadCsvOpenAvailabilityStep)
.incrementer(new RunIdIncrementer())
.build();
}
@Bean
public Step generateCsvOpenAvailabilityStep(GenerateCsvOpenAvailabilityTasklet generateCsvOpenAvailabilityTasklet) {
String stepName = "generateCsvOpenAvailabilityStep";
return new StepBuilder(stepName, jobRepository)
.tasklet(generateCsvOpenAvailabilityTasklet, transactionManager)
.build();
}
@Bean
public Step loadCsvOpenAvailabilityStep(
LoadDataInfileOpenAvailabilityTasklet loadDataInfileOpenAvailabilityTasklet) {
String stepName = "loadCsvOpenAvailabilityStep";
return new StepBuilder(stepName, jobRepository)
.tasklet(loadDataInfileOpenAvailabilityTasklet, transactionManager)
.build();
}
}
CSV 파일 생성 및 데이터 저장 Tasklet
@Component
@StepScope
@RequiredArgsConstructor
@Slf4j
public class GenerateCsvOpenAvailabilityTasklet implements Tasklet {
private static final int BASE_LINE_WRITE_COUNT = 700000;
private static final int MAX_WRITE_COUNT = 800000;
private static final int FILE_SIZE = 150000;
private static final String PREFIX_FILE_NAME = "availability";
private static final String FILE_EXTENSION = ".csv";
public static final String STEP_CSV_ATTRIBUTE_NAME = "csvFilePaths";
private final RoomAutoPolicyTaskletReader autoPolicyReader;
private final CsvOpenAvailabilityTaskletProcessor openAvailabilityProcessor;
private final GenerateCsvOpenAvailabilityWriter generateCsvWriter;
private final Path basePath = createDirectories();
private final Set<String> csvFilePaths = new HashSet<>();
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
// 성능 측정을 위한 시간 로깅
Perf perf = new Perf();
// execute 과정에서 기록된 다음 reader 시작점
Long lastSeenId = getLastSeenId(chunkContext.getAttribute("lastSeenId"));
// Output 임계치까지 [read - process] 반복 실행
ReadProcessCombineResult combineResult = combineReaderProcessor(lastSeenId, contribution, perf);
// Output 결과 CSV 파일로 저장
generateCsvAvailabilities(combineResult.outputAvailabilities, perf);
return handleExecuteResult(combineResult.hasNext, combineResult.lastSeenId, contribution, 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<String[]> outputAvailabilities
) {
}
// Output 임계치까지 [read - processor] 반복 수행
private ReadProcessCombineResult combineReaderProcessor(
Long lastSeenId,
StepContribution contribution,
Perf perf
) {
boolean outputThreshold = false;
boolean hasNext = true;
List<String[]> 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<String[]> 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 generateCsvAvailabilities(
List<String[]> writeAvailabilities,
Perf perf
) {
if (writeAvailabilities.isEmpty()) {
return;
}
int createFileCount = writeAvailabilities.size() / FILE_SIZE;
IntStream.range(0, createFileCount)
.parallel()
.forEach(i -> {
int startIndex = i * FILE_SIZE;
int endIndex = i != createFileCount - 1 ? startIndex + FILE_SIZE : writeAvailabilities.size();
List<String[]> writeChunk = writeAvailabilities.subList(startIndex, endIndex);
Strin fileName = PREFIX_FILE_NAME + (i + 1) + FILE_EXTENSION;
Path filePath = Path.of(basePath.toString(), fileName);
generateCsvWriter.write(writeChunk, filePath);
perf.log(fileName + " Write rows", writeChunk.size());
csvFilePaths.add(filePath.toAbsolutePath().toString());
});
}
// csv 파일을 저장할 디렉토리 생성
private Path createDirectories() {
LocalDateTime now = LocalDateTime.now();
// 디렉토리 형식: 2025-05-14_20:21
String timestamp = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH:mm"));
// 최종 디렉토리 경로: tmp/batch/openroom/2025-05-14_20:21
Path dirPath = Path.of("tmp", "batch", "openroom", timestamp);
try {
return Files.createDirectories(dirPath);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private RepeatStatus handleExecuteResult(
boolean hasNext,
Long lastSeenId,
StepContribution contribution,
ChunkContext chunkContext
) {
if (hasNext) {
chunkContext.setAttribute("lastSeenId", lastSeenId);
return RepeatStatus.CONTINUABLE;
}
contribution.getStepExecution()
.getJobExecution()
.getExecutionContext()
.put(STEP_CSV_ATTRIBUTE_NAME, csvFilePaths);
return RepeatStatus.FINISHED;
}
}
CSV 파일 쓰기 Writer
@Component
@RequiredArgsConstructor
@Slf4j
public class GenerateCsvOpenAvailabilityWriter {
public void write(List<String[]> outputAvailabilities, Path csvPath) {
if (outputAvailabilities.isEmpty()) {
return;
}
CsvWriterSettings settings = new CsvWriterSettings();
settings.getFormat().setDelimiter(',');
try (Writer writer = Files.newBufferedWriter(csvPath, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
CsvWriter csvWriter = new CsvWriter(writer, settings);
for (String[] row : outputAvailabilities) {
csvWriter.writeRow(row);
}
} catch (Exception e) {
log.info("Failed to generate CSV availabilities", e);
}
}
}
CSV 파일 LOAD Tasklet
@Component
@StepScope
@RequiredArgsConstructor
public class LoadDataInfileOpenAvailabilityTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
Set<String> csvFilePaths = getCsvFilePaths(contribution);
String[] csvFilePathArray = csvFilePaths.toArray(new String[0]);
Perf perf = new Perf();
IntStream.range(0, csvFilePathArray.length)
.parallel()
.forEach(i -> {
String sql = "LOAD DATA LOCAL INFILE '" + csvFilePathArray[i] + "' " +
"IGNORE " +
"INTO TABLE csv_room_availability " +
"FIELDS TERMINATED BY ',' " +
"LINES TERMINATED BY '\\n' " +
"(room_type_id, open_date, available_count, price) " +
"SET created_at = NOW(), updated_at = NOW() ";
jdbcTemplate.execute(sql);
perf.log("CSV file loaded: " + csvFilePathArray[i], 0);
});
return RepeatStatus.FINISHED;
}
private Set<String> getCsvFilePaths(StepContribution contribution) {
StepExecution stepExecution = contribution.getStepExecution();
JobExecution jobExecution = stepExecution.getJobExecution();
ExecutionContext executionContext = jobExecution.getExecutionContext();
Object csvFilePathsObj = executionContext.get(STEP_CSV_ATTRIBUTE_NAME);
if (!(csvFilePathsObj instanceof Set)) {
throw ErrorCode.CONFLICT.exception("csvFilePaths is not a List");
}
return (Set<String>)csvFilePathsObj;
}
}
Load Data Infile 관련 MySQL 공식문서: https://dev.mysql.com/doc/refman/8.4/en/load-data.html
'Batch' 카테고리의 다른 글
[Batch 시리즈 #7] MySQL 튜닝 재도전: 구조적 이해로 옵션을 바라보기 (0) | 2025.06.22 |
---|---|
[Batch 시리즈 #6] MySQL 옵션 튜닝의 실패, 구조를 향한 시작 (0) | 2025.05.22 |
[Batch 시리즈 #4] Insert 성능 최적화: PreparedStatement, 그리고 클러스터드 인덱스의 함정 (0) | 2025.05.12 |
[Batch 시리즈 #3] 성능 측정과 병목점 찾기: Tasklet의 한계, 그리고 Processor 최적화 (0) | 2025.05.07 |
[Batch 시리즈 #2] Chunk vs Tasklet, 상황에 맞는 최적의 도구는? (0) | 2025.05.03 |