Skip to content
hello tis
Go back

[26.09.21] 챌린지 API의 횟수 원장 통합을 위한 보관과 조회 방식 비교하기

기능마다 테이블을 만들지 않기 위한 횟수 이력 통합 검토하기

사용자별로 기능을 하루에 사용할 수 있는 횟수를 제한합니다. 사용 이력을 원장 형식으로 쌓고, 이 기록으로 일일 사용 횟수를 계산합니다.

지금은 기능별로 횟수 테이블을 두는데, 기능 출시가 잦아지면서 새 테이블을 만드는 일이 부담이 됐습니다. 같은 역할의 테이블을 하나로 통합하는 방안을 검토하고 있고, 이관은 기존 테이블과 통합 테이블에 함께 기록하는 Dual Write 방식으로 계획했습니다.

통합하면 한 테이블에 쌓이는 데이터가 많아집니다. 일일 사용 횟수를 계산하는 쿼리의 실행 시간이 얼마나 늘어나는지, 계속 쌓이는 원장의 저장 공간을 어떤 기준으로 정리할지 확인해야 합니다.

과거 이력의 저장 공간을 줄이기 위한 압축 위치 비교하기

일자별 파티션을 기준으로 과거 이력을 보관하고 삭제하는 두 방안을 검토합니다. PARTITION BY RANGE COLUMNS(...)로 날짜 범위를 정의하면 데이터는 입력 시 해당 파티션에 저장되고, 이후 날짜의 파티션을 미리 추가하는 관리만 필요합니다. (MySQL 파티션 관리 문서)

MySQL 8.4 InnoDB는 특정 파티션만 압축하거나 시간이 지나면 자동으로 압축하는 기능이 없습니다. ROW_FORMAT=COMPRESSED는 테이블 전체에 적용되므로 최근 이력과 인덱스도 압축됩니다. 따라서 전체를 압축해 한 테이블로 관리할지, 과거 이력만 별도 테이블로 옮겨 압축할지를 비교합니다. (MySQL 테이블 정의, 압축 적용 범위)

운영 테이블 전체 압축과 일자별 파티션 유지

별도 보관 테이블로 이동

파티션 교환은 양쪽 테이블의 구조와 Row Format이 같아야 하므로, 비압축 파티션을 같은 형식의 테이블로 옮긴 뒤 압축합니다. 대상 날짜 선택, 교환, 압축은 배치로 실행합니다. (MySQL 파티션 교환 조건)

아래 그림은 두 번째 방안입니다. ‘보관 테이블로 이동’은 일자별 파티션을 운영 테이블 밖으로 옮기는 단계입니다.

별도 보관 테이블을 사용하는 방안입니다. 애플리케이션 서버가 DB의 횟수 검증 테이블을 조회합니다. 계산에 사용하지 않는 과거 파티션을 별도 테이블로 옮겨 압축하고, 일주일 뒤 외부 저장소의 파일로 추출합니다. 추출 결과를 검증한 뒤 해당 보관 테이블을 삭제합니다.

EXCHANGE PARTITION의 내부 동작과 요청 영향

EXCHANGE PARTITION은 행을 복사하고 삭제하는 비용은 없지만, 운영 테이블과 보관 테이블의 Metadata Lock(MDL)이 필요합니다. 과거 이력을 조회하지 않더라도 교환 중에는 오늘의 횟수 조회와 기록이 대기할 수 있고, 범위 검증 옵션은 교환 후 조회 결과에 영향을 줍니다. MySQL 8.4.11 구현을 기준으로 정리했습니다.

파티션과 보관 테이블의 교환 순서

usage_history가 PARTITION BY RANGE COLUMNS(used_date)로 일자별 파티션 p20260913을 갖고 있다는 전제입니다.

CREATE TABLE usage_archive_20260913 LIKE usage_history;
ALTER TABLE usage_archive_20260913 REMOVE PARTITIONING;

ALTER TABLE usage_history
    EXCHANGE PARTITION p20260913
    WITH TABLE usage_archive_20260913
    WITH VALIDATION;

LIKE로 구조와 파티션 정의만 복제한 뒤 파티션 구성을 제거해 같은 구조의 빈 보관 테이블을 만듭니다. 교환 후 과거 이력은 보관 테이블에 있고, p20260913은 빈 파티션으로 남습니다. 압축 전환과 파티션 삭제는 별도 작업입니다. (MySQL 파티션 교환 문서)

  1. 구조와 범위 검증: 두 테이블에 MDL_SHARED_NO_WRITE를 확보하고 구조, 인덱스, Row Format의 호환성을 확인합니다. WITH VALIDATION은 파티션으로 들어올 보관 테이블의 행만 날짜 범위와 대조하며, 밖으로 꺼낼 과거 파티션은 검사하지 않습니다.
  2. 배타 잠금 전환: 보관 테이블, 운영 테이블 순으로 MDL_EXCLUSIVE를 확보합니다. 두 테이블을 사용 중인 트랜잭션이 있으면 끝날 때까지 기다립니다.
  3. InnoDB 파일과 메타데이터 교환: 파티션 파일과 보관 테이블 파일의 이름을 서로 바꾸고, Data Dictionary의 인덱스, 테이블스페이스 연결 정보와 엔진 내부 식별자를 교환합니다. 행을 다시 삽입하거나 인덱스를 재구축하지 않고, 압축도 이 단계에서 하지 않습니다.
  4. 커밋과 잠금 해제: Data Dictionary 변경과 DDL을 Binary Log에 기록하고 커밋합니다. 문장이 끝나면서 잠금이 해제되고, 이후 요청은 교환된 구성을 사용합니다.

MDL은 서버 계층(SQL 계층 구현)이, 파일과 메타데이터 교환은 InnoDB(InnoDB 교환 구현)가 담당합니다.

배타 잠금 확보 → 오늘 파티션의 읽기와 쓰기 대기 → 과거 로우 묶음과 빈 보관 테이블 교환 → 잠금 해제와 요청 재개. 잠금 영역은 운영 테이블과 보관 테이블 전체를 감쌉니다.

단계별 읽기와 쓰기 영향

단계일반 SELECTINSERT, UPDATE, DELETE
구조와 범위 검증 잠금 확보 후허용두 테이블의 새 쓰기는 대기
배타 잠금 전환 대기기존 MDL을 보유한 트랜잭션은 진행, 새 MDL을 요청하는 읽기는 대기계속 대기
배타 잠금 확보 후 교환과 커밋새 읽기는 대기계속 대기
문장 완료 후 잠금 해제잠금 해제 후 조회 재개잠금 해제 후 쓰기 재개

표는 잠금 읽기를 제외한 일반 SELECT를 기준으로 합니다. 배타 잠금 전환 시 대기 조건은 다음과 같습니다.

1단계의 검증 잠금도 충돌하는 MDL을 가진 쓰기 트랜잭션이 있으면 기다립니다. 교환 작업 시간과 잠금 획득 대기 시간은 구분해야 합니다. (MySQL MDL 문서, 배타 잠금 전환 구현)

잠금 범위는 과거 파티션만이 아니라 운영 테이블 전체입니다. 오늘 파티션만 읽는 쿼리도 MDL을 먼저 확보해야 하므로, 파티션 프루닝으로 이 대기를 피할 수 없습니다.

검증 옵션과 교환 후 조회 결과

1단계의 범위 검증은 MDL_SHARED_NO_WRITE를 확보한 뒤 실행되므로, 검증 시간은 운영 테이블의 쓰기 대기 시간에 포함됩니다. (MySQL 파티션 교환 문서)

검증을 생략하면 무엇을 포기하는지 MySQL 8.4.11에서 재현했습니다. id 기준 p0 VALUES LESS THAN (50), p1 VALUES LESS THAN (100)으로 나눈 테이블의 p0과 id=51이 든 테이블을 교환했습니다.

프루닝은 데이터를 읽지 않고 파티션 정의로 대상을 계산하므로, 정의를 위반한 행은 조건 조회와 변경에서 보이지 않습니다.

운영 요청 지연을 줄일 기준

InnoDB 압축 설정 비교

보관 위치를 정하기 위해 압축 설정별 용량과 처리 시간을 로컬에서 합성 데이터로 실험했습니다. 일괄 적재와 파일 추출만 비교했으므로, 운영 테이블 전체 압축을 선택하려면 삽입과 횟수 조회가 함께 실행되는 조건도 확인해야 합니다.

실험 조건

비압축 ROW_FORMAT=DYNAMIC과 ROW_FORMAT=COMPRESSED의 KEY_BLOCK_SIZE 8, 4, 2, 1을 비교했습니다. KEY_BLOCK_SIZE는 압축 페이지 크기로, 압축 레벨과 다르며 8로 설정해도 용량이 절반이 되지는 않습니다. (MySQL 압축 테이블 문서)

KEY_BLOCK_SIZE별 측정 결과

적재는 Binary Log를 끄고, 추출은 binlog_format=ROW, sync_binlog=1로 켜고 따로 측정했습니다. 첫 추출은 Buffer Pool 자동 복원을 끄고 DB를 재시작한 뒤 측정했고, OS와 스토리지 캐시는 비우지 않았습니다.

아래는 중앙값입니다. DB CPU는 첫 추출 중 mysqld의 누적 CPU 시간이며, 사용률이나 피크가 아닙니다.

설정파일 크기적재 (초)재추출 (ms)첫 추출 (ms)DB CPU (ms)
비압축 DYNAMIC88MiB1.858231317100
KEY_BLOCK_SIZE=860MiB8.509233284200
KEY_BLOCK_SIZE=456MiB15.303232341250
KEY_BLOCK_SIZE=256MiB18.325234485320
KEY_BLOCK_SIZE=161MiB22.635264673610

다섯 테이블이 512MiB Buffer Pool을 공유했고, 1KiB는 미리 읽은 뒤에도 압축 해제가 다시 발생했습니다. 재추출 결과에는 메모리에 남은 페이지 형태도 영향을 줬습니다.

Append-only 데이터 관리 사례

보관한 데이터를 정리하고 다시 조회하는 방법은 두 사례에서 살펴봤습니다. Percona는 오래된 데이터를 분리하는 단위, Airtable은 외부로 옮긴 이력을 조회하는 구조입니다.

Percona: 파티션 단위 이력 분리와 삭제

Smit Arora의 Quick Data Archival in MySQL Using Partitions를 읽었습니다.

Airtable: 과거 이력의 외부 보관과 조회

Matthew Jin의 How we reduced archive storage costs by 100x and saved millions을 읽었습니다.

삭제 후에도 이력을 조회하기 위한 보관 범위와 검증 기준 정하기

처음에는 기능마다 만드는 테이블을 줄이려 했지만, 압축 실험과 두 사례를 거치며 통합 테이블에 남길 범위와 밖으로 옮긴 이력의 관리 방법까지 함께 정해야 한다는 생각이 들었습니다. 파티셔닝이나 외부 조회 엔진을 고르기 전에 정할 기준은 다음과 같습니다.


Share this post:

Previous Post
[26.09.22] 챌린지 API의 쿼리 지연 분석을 위한 실행 정보 수집 방식 비교하기
Next Post
2026년 9월 3주차 회고: 챌린지 API 출시와 로그 유실 검증