한눈에 보기

  • 문제: bronze를 Trino Hive 커넥터로 노출하자 단일 종목 point lookup에 104.2초, count(*)에 255.8초가 걸렸다. 같은 데이터의 Iceberg 파생 테이블은 0.13초였다.
  • 원인: 파일 8만 개 × 파티션 2.9만+5.2만 개 구조에서, Hive 커넥터는 쿼리마다 파티션 메타데이터 조회와 splits 생성에 시간을 다 썼다. 게다가 쓰던 metastore 구성은 문서화되지 않은 데모용이었다.
  • 선택: 실 Hive Metastore(Thrift) 도입 + bronze를 종목당 파일 하나로 재편(year 파티션 제거).
  • 결과: point lookup 104.2초→1.07초(약 97배), splits 생성 5.2분→56ms.
  • 핵심 교훈: 병목의 위치를 계측으로 특정하기 전에 고치기 시작하면, 엉뚱한 곳을 세 번 고치게 된다.

출발점: 숫자부터 고정했다

레이크하우스 전환(사라지는 산출물 - dbt와 Trino Iceberg 레이크하우스) 전에 baseline을 실측해 뒀다. bronze 파일 수 80,367개(KRX 28,690 + NASDAQ 51,677). DuckDB glob 방식으로 심볼 1개의 COUNT/MIN/MAX가 약 140초, dbt staging view 생성만 397~403초. 이 숫자들이 이후 모든 개선의 비교 기준이 됐다.

Trino Hive 커넥터로 bronze를 zero-copy 노출한 뒤에도 병목은 형태만 바꿔 남았다. 2026-07-22 실측 — hive.bronze.ohlcv_krxcount(*) 255.8초, 단일 종목 point lookup 104.2초. 반면 Iceberg 쪽 실버 테이블은 count(*) 0.13초. Iceberg는 매니페스트 메타데이터로 파일 스캔 없이 프루닝하는데, Hive 커넥터는 파티션 필터가 걸려도 일단 방대한 파티션 목록 자체를 다뤄야 했다.

진단: 범인은 파일 리스팅이 아니었다

처음 가설은 “S3 파일 리스팅이 느리다”였고, 그에 맞는 처방(file status cache)을 먼저 넣었다. 효과 검증을 위해 JMX 메트릭을 열어보고 나서야 가설이 틀렸음을 알았다. 그 캐시의 요청 카운트는 세션 전체에서 0 — 단 한 번도 호출되지 않는 코드 경로였다. 진짜 화력이 몰리는 곳은 파티션 메타데이터 캐시였다. 요청 276,278건에 히트율 21%. 캐시 TTL이 켜져 있는데도 계속 miss가 났다.

병목은 “파일 목록 나열”이 아니라 **“파티션 메타데이터 조회 자체”**였다. 그리고 이 조회를 처리하던 hive.metastore=file 구성은, 파고들어 보니 Trino 문서에 없는 데모/PoC용 구성이었다. 임시 구성 위에 캐시를 얹는 게 아니라 실제 메타스토어가 필요했다.

1차전: 실 Hive Metastore 도입 — 그리고 배포 3연속 실패

standalone Hive Metastore(Thrift)를 도입하기로 했다. 로컬 검증까지는 순조로웠는데, 원격 머신 배포가 서로 다른 원인으로 세 번 연속 실패했다.

  1. 미커밋 산출물: 배포는 git clone 기반인데 작업 산출물이 커밋돼 있지 않았다. 배포 시스템에게 “미커밋 파일은 존재하지 않는 것과 같다”.
  2. 네트워크 차단: 빌드 중 특정 배포 호스트만 연결 불가. 같은 빌드 로그에서 연결이 검증된 경로(공식 Docker 이미지에서 바이너리 COPY)로 우회했다.
  3. ClassNotFoundException: S3AFileSystem: 실 HMS는 CREATE TABLE 시 s3:// 위치를 직접 검증하는데, 공식 이미지의 classpath에 S3A jar가 없었다. 스코핑 단계에서 “새 컴포넌트가 외부 시스템에 직접 접근하는가”를 점검하지 않은 대가였다.

세 실패의 공통점은 코드가 아니라 전달 경로(커밋·네트워크·클래스패스)의 문제였다는 것. “구현 완료”와 “배포 가능”은 커밋 여부 하나로 갈린다는 걸 몸으로 배웠다.

배포가 끝나자 성능 게이트는 명확하게 통과했다.

항목BeforeAfter
단일 종목 point lookup104.2s1.07s (재실행 0.27s)
SHOW SCHEMAS13.3s0.44s
count(*) 645만 행255.8s98.3s

2차전: 그래도 98초 — 이제는 파일 수 자체다

point lookup은 97배 빨라졌지만 전체 스캔은 2.6배에 그쳤다. 메타데이터 병목이 사라지자 다음 병목이 드러난 것이다 — 8만 개 소파일을 하나하나 여는 I/O. 실측하니 Trino가 쿼리 계획에서 splits를 생성하는 데만 5.2분(309,836ms)이 걸렸다.

파일 수를 줄이려면 bronze 레이아웃 자체를 바꿔야 했다. 당시 구조는 symbol={SYM}/year={YYYY}/ohlcv.parquet — 종목×연도 조합이 파티션 2.9만 개(KRX)와 5.2만 개(NASDAQ)를 만들고 있었다.

여기서 원인 분석이 흥미로워졌다. “year 파티션은 읽기 성능을 위한 것”이라는 가설부터 확인했는데, 아니었다. year 분할의 실제 기원은 과거 데이터 유실 사고(overwrite 한 번에 사라진 수년치 데이터) 이후의 blast-radius 방어 — 쓰기가 잘못돼도 한 해치만 날아가게 하려는 것이었다. 그런데 이 방어는 부분 쓰기 손상을 가정한다. MinIO의 PUT은 원자적이다. 성공하면 전체 교체, 실패하면 이전 객체 유지. year 분할이 막으려던 손상을 스토리지가 이미 막고 있었다. 방어 근거가 착시였다면, 파티션을 유지할 이유가 없다.

대안은 세 개였다. 종목당 단일 파일(A1), landing/serving 2-zone 분리(저장 2벌 + 복잡도), bronze의 Iceberg 전환(zero-copy가 버그로 막혀 있어 이중 적재 강제 + 이전에 의식적으로 되돌린 결정의 재번복). A1을 택했다.

전환 결과 파티션은 2.9만+5.2만에서 심볼 수 규모(KRX 2,702 + NASDAQ 3,998)로 줄었고, splits 생성은 5.2분에서 56ms가 됐다.

전환이 순탄했다는 뜻은 아니다

마이그레이션은 write-new → 배포 → 검증 후 delete-old의 3단계 비파괴로 설계했고, 안전장치로 쓰기 시점 가드(기존 대비 90% 이상 급감하면 쓰기 거부)와 MinIO 버킷 versioning을 깔았다. 그래도 사고가 났다.

live-mount 인시던트: 컨테이너가 호스트의 코드 디렉토리를 바인드마운트하고 있어서, 새벽에 코드를 편집한 뒤 아침 NASDAQ 일배치가 마이그레이션 전에 새 코드로 돌아버렸다. 3,604개 심볼의 단일 파일에 당일 1행씩만 쓰인 상태. “마이그레이션 먼저, 배포 나중”이라는 순서가 바인드마운트 때문에 뒤집힌 것이다. versioning과 재수집으로 복구했지만(검증 사례: 1행→6,854행), 배포 경계가 이미지 빌드가 아니라 파일 편집인 환경에서는 “코드를 고치는 순간이 곧 배포”라는 걸 다시 확인했다.

코드리뷰가 잡아준 결함도 있었다. delete 단계 로직에 배포 후 append분을 삭제하고 검증이 이를 잘못 승인하는 유실 경로가 숨어 있었다. 그리고 파일 내부 row-group을 잘게 쪼개 date 프루닝을 살리는 실험은 실측(입력 행수는 줄었지만 실행 시간 13.8s→16.4s로 악화)으로 기각하고 되돌렸다.

트레이드오프와 한계

종목당 단일 파일은 공짜가 아니다. 일일 수집이 종목 파일 전체를 다시 쓰는 write amplification이 생겼고(아직 미측정), “특정 연도를 전 종목 가로질러” 읽는 조회는 프루닝을 잃었다. 쓰기 가드는 90% 이상의 붕괴만 잡는다 — 10~90% 구간의 부분 유실은 blind spot이다. 연도 단면 조회가 지배적 패턴이 되면 재검토한다는 조건을 남겨뒀다.

정리

140초(glob) → 104초(Hive file metastore) → 1.07초(실 HMS) → splits 56ms(단일 파일). 넉 달에 걸친 이 연대기에서 각 단계는 이전 단계의 병목이 사라진 뒤에야 다음 병목을 드러냈다. 계측 없이 고쳤던 것(file status cache)은 전부 헛발이었고, JMX·EXPLAIN ANALYZE·baseline 실측이 있던 결정만 살아남았다.