대용량 DB Insert 튜닝 가이드: UUID로 인해 느려지는 현상 해결법
DB
마지막 업데이트

대용량 DB Insert 튜닝 가이드: UUID로 인해 느려지는 현상 해결법


초당 수백, 수천 건의 데이터를 수집하는 환경에서 시간이 지날수록 Insert 속도가 눈에 띄게 느려지시나요?

실제로 IoT 데이터 수집 파이프라인을 운영하면서 이 문제를 직접 겪었습니다. 초기에는 초당 3,000건을 거뜬히 처리하던 시스템이, 데이터가 45GB를 넘어가면서 초당 200건까지 떨어졌습니다. 원인을 추적해 보니 VARCHAR(100) utf8mb4로 된 UUID 인덱스가 버퍼 풀을 완전히 넘어선 상태였습니다.

이 현상은 MySQL InnoDB 엔진에서 무작위 값을 가진 UUID를 기본키(PK)나 주요 인덱스로 사용할 때 발생하는 전형적인 버퍼 풀 스래싱(Buffer Pool Thrashing) 문제입니다.

이 가이드에서는 다음의 내용을 다룹니다:

  • 왜 무작위 UUID를 쓰면 데이터가 쌓일수록 Insert 속도가 기하급수적으로 느려지는지 확인합니다.
  • 데이터 타입 변경(utf8mb4 -> ascii)만으로 인덱스 크기를 10분의 1로 줄이는 방법을 학습합니다.
  • 기존 기기(Device)의 레거시 UUID를 변경할 수 없는 상황에서 어플리케이션 레벨의 정렬 벌크 인서트 기법을 적용하는 법을 알아봅니다.

하드웨어(RAM)를 무작정 늘리기 전에, B-Tree 인덱스의 특성에 맞게 데이터 길이를 줄이고 삽입 순서를 조금만 비틀어주면 지옥 같던 디스크 I/O를 극적으로 줄일 수 있습니다.

무작위 UUID 인덱스의 치명적인 문제

InnoDB는 B-Tree 구조를 사용하여 데이터를 정렬해 저장합니다. AUTO_INCREMENT나 연속된 타임스탬프 기반 키를 사용하면 새로운 데이터가 트리 구조의 ‘가장 오른쪽 끝방(Right-edge)‘에 예쁘게 쌓입니다.

하지만 UUID v4와 같은 완전한 무작위 문자열을 인덱스로 삼으면 어떻게 될까요? 데이터가 추가될 때마다 거대한 B-Tree의 무작위 위치를 비집고 들어가야 합니다. 처음에는 전체 트리가 메모리(Buffer Pool)에 올라가 있어서 빠르지만, 인덱스 크기가 버퍼 풀 크기(예: 5.2GB)를 초과하는 임계점을 넘는 순간, 무작위 빈 공간을 찾기 위해 하드디스크의 수많은 페이지를 열고 닫는 무자비한 디스크 I/O가 발생하며 속도가 처참하게 무너집니다.

언제 튜닝이 필요한가

다음과 같은 현상이 발생한다면 인덱스 튜닝이 시급합니다:

  • 파티셔닝된 테이블이라도 해당 파티션 공간 안에서 뒤로 갈수록 속도가 저하된다.
  • innodb_buffer_pool_size보다 하루 생산되는 인덱스 용량이 더 커서 Memory Cache Hit 확률이 떨어진다.
  • 45GB에 달하는 거대한 마스터 테이블에 INSERT를 할 때 과거보다 몇 배 이상 느린 응답속도(Latency)가 나온다.

단계별 튜닝 가이드

1. 인덱스 메모리 다이어트 (ASCII 타겟팅)

전 세계 IoT 기기나 클라이언트가 쏘는 기존 랜덤 식별자를 바꿀 수 없다면, 데이터베이스 내 인덱스 부피를 가장 얇게 다이어트시켜서 한정적인 메모리 안에 다 구겨 넣어야 합니다.

대부분 UUID는 숫자와 영문 소문자, 하이픈(-)만 포함되지만, 아무 생각 없이 VARCHAR(100) utf8mb4로 스키마를 생성하는 경우가 잦습니다. utf8mb4는 한 글자당 최대 4바이트를 차지하므로 메모리 공간을 극심하게 낭비합니다.

다음 명령어 하나로 인덱스 크기를 물리적으로 10분의 1까지 축소할 수 있습니다:

-- 기존 utf8mb4 컬럼을 가벼운 ascii 전용으로 수정 (기기 식별자 최적화)
ALTER TABLE T_DEVICE 
MODIFY COLUMN DEVICE_ID VARCHAR(40) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL COMMENT '기기 식별자';

이 짧은 수정만으로도 하루치 인덱스 트리 용량이 기가바이트(GB) 단위에서 수백 메가바이트(MB) 단위로 줄어들어, 버퍼 풀 메모리 내에서 100% 처리 가능한 기적이 일어납니다.

2. 정렬 벌크 인서트 적용 (Sorted Bulk Insert)

기기에서 보내는 무작위 값을 막을 수 없다면, 어플리케이션(Ex. Spring Batch)에서 수만 건 단위로 데이터를 모아 DEVICE_ID 순으로 정렬한 뒤 DB에 통째로 밀어 넣으세요.

// Spring Batch ItemWriter 단에서의 정렬 처리 예시
@Bean
public ItemWriter<DeviceLogDto> sortedBatchWriter(JdbcTemplate jdbcTemplate) {
    return items -> {
        List<DeviceLogDto> sortedList = new ArrayList<>(items.getItems());
        
        // B-Tree 물리 구조에 맞게 순차적으로 빗자루질(Sweep) 할 수 있도록 정렬
        sortedList.sort(Comparator.comparing(DeviceLogDto::getDeviceId));

        jdbcTemplate.batchUpdate("INSERT INTO table (device_id, log_data) VALUES (?, ?)", 
            new BatchPreparedStatementSetter() {
                // 파라미터 세팅 로직
            });
    };
}

정렬되지 않았을 때는 1건 인서트 할 때마다 디스크에서 무작위 방을 꺼내야 하지만, 크게 모아서 정렬한 뒤 던지면 DB가 특정 인덱스 페이지(방) 하나를 연 김에 수십 건을 연달아 부어버리고 문을 닫으므로(Sequential Sweep) 디스크 I/O가 획기적으로 줄어듭니다.

3. 신규 시스템이라면 시간순 UUID 전격 도입

현재 시스템 구축 단계거나 식별자 생성 규칙을 바꿀 권한이 있다면, 시간순으로 정렬이 보장되는 UUID v7 (앞부분 48비트가 밀리초 단위 Timestamp인 UUID)을 도입하시길 권장합니다. 이를 통해 문자열 익명 식별자의 장점을 챙기면서, 동작은 완벽하게 Auto Increment 처럼 작동하게 만들 수 있습니다.

흔히 하는 실수

JPA saveAll() 로 대용량 벌크 인서트 시도하기

가장 잦고 뼈아픈 실수입니다. 특히 UUID 처럼 DB에서 자동 생성(Auto Increment)되지 않는 수동 할당 식별자 환경에서 repository.saveAll()을 호출하면 절망적인 결과가 나옵니다. JPA는 해당 UUID가 기존에 존재하는지 모를 경우, INSERT 전에 무조건 SELECT 쿼리로 해당 ID 조회(N번)를 먼저 실행합니다. 트랜잭션 연속성(Batch Binding) 효과가 파괴되며, 기껏 정렬해 놓은 데이터들 중간중간 통신 오버헤드가 발생합니다. 반드시 대용량 데이터는 **JdbcTemplate.batchUpdate**나 MyBatis를 사용해 쿼리 망을 하나로 합쳐서 전송하세요.

그룹(Chunk) 사이즈를 너무 작게 잡기

정렬 벌크 인서트를 할 때 끊어서 모아가는 Chunk 사이즈가 1천 건, 1만 건 정도로 너무 작으면 흩뿌려진 밀도가 너무 적어 극적인 디스크 블록 적중률(Hit Ratio) 향상을 기대하기 어렵습니다. 어플리케이션 RAM이 허용하는 한 10만 건~30만 건 이상 거대한 덩치로 메모리에서 잡아놓고 정렬을 구사해야 이득이 극대화됩니다.

튜닝 후 반드시 확인할 지표

변경을 적용한 뒤 “빨라진 것 같다”는 느낌만으로 끝내면 안 됩니다. 운영 환경에서는 아래 지표를 함께 추적해야 합니다.

Buffer Pool Hit Rate

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

Innodb_buffer_pool_reads(디스크에서 읽은 횟수)와 Innodb_buffer_pool_read_requests(메모리에서 읽은 요청)의 비율을 보면 됩니다. 99% 이상이 건강한 상태입니다. ASCII 전환 후 이 수치가 85%에서 99.7%로 올라가는 것을 직접 확인했습니다.

Insert Latency 추이

시간대별 Insert 응답 시간을 꺾은선 그래프로 모니터링합니다. 정상이라면 하루 종일 일정해야 합니다. 오후로 갈수록 느려진다면 여전히 버퍼 풀이 넘치고 있다는 신호입니다.

인덱스 크기 대 버퍼 풀 비율

SELECT 
  table_name,
  ROUND(index_length / 1024 / 1024) AS index_mb
FROM information_schema.tables 
WHERE table_schema = 'your_db' 
ORDER BY index_length DESC 
LIMIT 10;

인덱스 총합이 innodb_buffer_pool_size의 70% 이내에 있어야 안정적입니다. 이를 넘으면 Eviction이 잦아지며 성능이 급격히 떨어집니다.

빠른 체크리스트

Insert 성능이 떨어졌을 때 아래 순서로 점검하면 가장 빠릅니다.

  1. UUID 컬럼의 charset이 ascii인지 확인한다
  2. 인덱스 총 크기가 버퍼 풀의 70% 이내인지 확인한다
  3. 어플리케이션에서 정렬 후 벌크 인서트하고 있는지 확인한다
  4. Chunk 사이즈가 10만 건 이상인지 확인한다
  5. JPA saveAll() 대신 JdbcTemplate.batchUpdate()를 쓰고 있는지 확인한다
  6. 신규 시스템이라면 UUID v7 도입을 검토한다

자주 묻는 질문 (FAQ)

Q. to_days() % 7 로 7개 파티션을 나눴는데도 왜 메모리가 터지나요?

나머지(%) 파티션을 사용하면 과거 데이터가 비워지지 않고 지난주 월요일, 다다음주 월요일 데이터가 영원토록 섞여서 파티션 자체가 뚱뚱해집니다. 이를 방지하려면 오래된 파티션을 TRUNCATE 해주는 스케줄러가 돌고 있는지 확인해야 하며, 근본적으로는 RANGE 파티션을 사용하는 것이 낫습니다.

Q. 어플리케이션 정렬, OS 정렬, DB 정렬의 차이가 뭔가요?

자바 같은 어플리케이션 정렬(TimSort)은 빠른 인메모리 CPU 연산을 바탕으로 하지만 무거운 객체 헤더 크기를 견뎌야 합니다. 따라서 시스템 RAM 여유 한도 내에서 가장 강력합니다. 가장 피해야 할 것은 DB에 통째로 밀어 넣고 인덱스 없이 Filesort를 시키는 것입니다. DB 단위의 정렬은 복잡한 트랜잭션 및 고립 수준(Lock, MVCC) 메타데이터를 끌어안고 연산해야 하므로 성능 손실이 극심합니다.

Q. utf8mb4를 ascii로 바꾸면 한글이 깨지지 않나요?

UUID는 숫자, 영문 소문자, 하이픈만 포함하므로 ascii로 바꿔도 전혀 문제없습니다. 다만 사용자 이름이나 한글 데이터가 들어가는 컬럼에는 절대 적용하면 안 됩니다. 반드시 UUID 전용 컬럼에만 적용하세요.

먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.

광고