
Table of Contents
“로그 테이블
created_at에 인덱스 하나만 걸어주시면 안 될까요?”
“테이블 사이즈가 수십 TB인데 인덱스만 TB 단위입니다. 안 됩니다.”
데이터 엔지니어로서 대용량 로그성 데이터를 다루다 보면 필연적으로 마주치는 상황이다. UPDATE 없이 쌓이기만 하는(Append-only) 로그 테이블인데, 너무 거대해서 날짜 컬럼(created_at)에 인덱스조차 걸 수 없는 상황.
이 글은 그런 척박한 환경에서 Primary Key(Sequence) 하나만 믿고 고속으로 데이터를 퍼 날랐던 전략과, 그 과정에서 마주친 클러스터 시퀀스(Cluster Sequence)의 함정에 대한 기록이다.
1. 문제: “어제치 로그 가져오는데 DB가 뻗어요”
상황은 이랬다.
- 대상: 수십억 건이 쌓여있는 대용량 로그 테이블 (History, Audit Log 등).
- 특징:
INSERT만 발생.updated_at컬럼 없음. 오직created_at만 존재. - 목표: 매일 새벽, “어제 00:00 ~ 오늘 00:00” 사이의 로그를 추출하여 DW에 적재.
SELECT * FROM huge_log_table
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'
아주 단순한 쿼리지만, created_at에 인덱스가 없으니 DB는 Full Table Scan을 시작한다. I/O 부하가 폭발하고, 다른 서비스 쿼리들이 줄줄이 대기(Wait) 상태에 빠진다.
DBA에게 인덱스 생성을 요청했지만, “인덱스 생성 자체가 엄청난 부하이고, 스토리지 비용도 감당 안 된다”며 반려되었다. 맞는 말이라 반박할 수 없었다.
2. 해결: “Sequence Index를 날짜처럼 쓰자”
인덱스를 새로 만들 수 없다면, 이미 있는 인덱스를 써야 한다.
대부분의 테이블은 AUTO_INCREMENT 속성의 숫자형 PK를 가진다. 그리고 이 PK는 Clustered Index로 잡혀 있어 조회 성능이 가장 빠르다.
“만약 1월 1일 00시에 생성된 첫 번째 로그의 PK를 알고, 1월 2일 00시의 첫 PK를 안다면?”
우리는 created_at 범위 검색을 PK 범위 검색으로 치환할 수 있다.
-- Full Scan (Bad)
WHERE created_at BETWEEN '2024-01-01' AND '2024-01-02'
-- Index Range Scan (Good)
WHERE id BETWEEN 1000000 AND 2000000
이 방식을 도입하자 수시간 걸리던 추출 작업이 분 단위로 단축되었고, DB 부하도 획기적으로 줄어들었다.
3. 심화: 맨땅에 헤딩하기 (초기 적재와 메타데이터 구축)
문제는 “처음(Initial Load)”이다. 매일매일 적재할 때는 전날 데이터를 참고하면 되지만, 수년 치 데이터가 이미 쌓여 있는 상태에서 “2021년 1월 1일의 시작 PK가 뭐였지?”를 어떻게 알 수 있을까?
이때 사용한 전략이 Sequence Chunking & Staging이다. Spark로 무겁게 계산하는 대신, 일단 Redshift Staging 영역으로 퍼다 나른 뒤 SQL로 분석하는 방식을 택했다.
🏗️ 메타데이터 구축 프로세스 (Reverse Engineering)
[DB Table (Huge Logs)]
ID: 1 ......................................... 100억
|Chunk 1|Chunk 2|Chunk 3| ... |Chunk N|
(1~100만) (100만~200만) ...
⬇️ (Parallel Spark Extract by ID Range)
[Redshift Staging Area (Temp Table)]
* 일단 다 때려 박는다. (COPY)
* Spark에서 뭔가 하려고 하면 느리다. DW의 파워를 빌리자.
⬇️ (SQL Aggregation)
[Metadata Table]
| target_date | min_id | max_id | status |
|-------------|-----------|-----------|--------|
| 2021-01-01 | 1 | 1,540,231 | DONE |
| 2021-01-02 | 1,540,232 | 3,100,500 | DONE |
...
- Chunking: 전체 ID 범위를 적절한 크기(예: 100만 건)로 나눈다. 이때 청크 사이즈는 RDBMS가 감당 가능한(부하 없는) 수준으로 최적화해야 한다.
- Load: Spark가 각 청크를 읽어 Redshift Staging 테이블에 적재한다.
- Analyze: Redshift에서
GROUP BY TRUNC(created_at)쿼리를 돌려 날짜별MIN/MAX(id)를 추출하고 메타 테이블을 완성한다.
4. 함정: “클러스터 시퀀스는 시간을 배신한다”
하지만 데이터를 검증하던 중 심각한 문제를 발견했다.
소스 DB가 Oracle RAC (또는 Aurora) 같은 클러스터 환경이었는데, 여기서 Sequence Gap과 Out of Order 문제가 발생한 것이다.
💡 토막 상식: Cluster Sequence (RAC/Aurora)
클러스터 DB는 성능 최적화를 위해 각 노드(Instance)별로 시퀀스를 캐싱(Cache)한다.
- Node A: 1~100번 미리 할당
- Node B: 101~200번 미리 할당
만약 09:00:00에 Node B에서 요청이 들어오면 PK 101이 발급되고,
09:00:01에 Node A에서 요청이 들어오면 PK 1이 발급된다.즉, 더 늦게 생성된 데이터(Node A)가 더 작은 PK 값을 가질 수 있다. (PK 순서 ≠ 시간 순서)
시나리오: 이어달리기(Incremental Cursor)의 실패
이런 상황에서 만약 “어제 10000번까지 가져왔으니, 오늘은 10001번부터 가져와야지”라고 단순하게 생각하면 망한다.
중간에 비어있던 번호나, 다른 노드에서 늦게 발급된 작은 번호들이 영원히 누락될 수 있기 때문이다.
5. 보완책: 매일 새로 찾고, 넉넉하게 퍼서, 메모리에서 바른다
결국 “마지막으로 읽은 ID + 1” 방식은 클러스터 환경에서 사용할 수 없다.
조금 비효율적이어도 매일매일 그 날짜의 Min/Max ID를 독립적으로 새로 찾아야 한다.
전략: Independent Scan & In-Memory Filtering
- Boundary Scan: 매일 자정, 메타데이터 구축 로직과 유사하게
created_at기준의 Min/Max ID를 새로 조회한다. - Overlap: 어제와 오늘의 ID 스캔 범위가 상당 부분 겹치게(Overlap) 된다.
- Filter: 중복 스캔의 비효율은 있지만 Full Scan보단 낫다. 일단 가져온 뒤 Spark 메모리 상에서
created_at으로 정확하게 필터링하여 중복을 제거한다.
# 1. DB: Index Range Scan (범위가 겹치더라도 일단 인덱스 타고 가져옴)
df = spark.read.jdbc(
query="SELECT * FROM table WHERE id BETWEEN Today_Min_ID AND Today_Max_Max"
)
# 2. Spark: In-Memory Filter (정확한 날짜로 커팅)
df = df.filter(col("created_at").between("start_date", "end_date"))
6. 결론 및 한계
“Sequence는 시간 순서와 일치하지 않는다.”
특히 클러스터 DB 환경에서는 이 명제가 더욱 확실해진다.
PK를 활용해 인덱스를 타게 만드는 것은 훌륭한 전략이지만, 명확한 한계점도 존재한다.
⚠️ 한계점 (Limitations)
- 이 전략은
UPDATE가 발생하지 않는 Append-only (Log) 테이블에서만 유효하다.- 데이터의 상태가 변해서
updated_at이 갱신되는 트랜잭션 테이블인데 인덱스마저 없다? 그건 Sequence로도 해결할 수 없다. (CDC 도입이나 Full Scan 외엔 답이 없다.)
기술적 제약(인덱스 부재)을 우회하려다 만난 또 다른 제약(클러스터 시퀀스). 이를 해결해 나가는 과정이야말로 엔지니어링의 묘미가 아닐까.
댓글 남기기