한 개발자가 주식 분석 저장소를 두 번 만들었다. 1세대는 OLTP 엔진인 InnoDB 위에서 분석 작업을 돌렸고, 2세대는 똑같은 작업을 OLAP 스택 위로 옮겼다. 흥미로운 점은 그가 단 하나의 최적화를 얻기 위해 레이크하우스 전체 구조를 처음부터 이해해야 했다는 사실이다. 이 전환 과정은 트랜잭션 처리용 데이터베이스와 분석용 데이터 플랫폼이 왜 근본적으로 다른 물건인지를 실무 감각으로 보여준다.
InnoDB가 최적화하려 했던 것
InnoDB는 행(row) 단위로 모든 컬럼을 한곳에 모아 저장한다. 특정 심볼의 한 봉(bar)을 가져올 때 디스크의 한 지점만 건드리면 되니 데이터 적재(ingest)에는 더없이 좋다. 문제는 두 개 컬럼만 필요한 지표 계산에서도 버퍼 풀을 거쳐 모든 컬럼을 읽어야 한다는 점이다. 인덱스도 같은 트레이드오프를 안고 있다. 모든 인덱스는 별도의 B-트리이고, 쓰기가 발생할 때마다 테이블의 모든 B-트리를 갱신해야 한다. 읽기는 극도로 빠르지만 그 대가는 느린 쓰기다. 저자가 원시(Raw) 테이블에는 기본 키만 두고, 그 결과를 받는 테이블에만 인덱스와 파티셔닝을 집중한 이유가 여기에 있다.
읽기와 쓰기가 서로를 막지 않는 비결은 언두 로그(undo log)와 MVCC다. 행이 수정되거나 삭제되면 옛 값이 언두 로그에 남아, 쓰기 도중 다른 세션이 읽으면 그 옛 값을 돌려준다. 삽입도 작은 언두 로그를 남기지만 커밋 시 곧바로 비워지고, 수정·삭제의 언두 로그는 히스토리 리스트에 합류해 과거 버전 조회를 가능하게 한다. 수정·삭제가 잦으면 이 리스트가 빠르게 불어나지만, InnoDB는 커밋 전후에 시작된 모든 세션이 끝나면 해당 로그를 정리하도록 표시해 둔다. 요컨대 MVCC 덕분에 삽입과 수정이 읽기를 막지 않고 진행된다.
100GB를 넘긴 뒤에야 보인 설계 실수
저자의 뼈아픈 교훈은 설계에서 나왔다. 초기에 financialdata 테이블만 날짜(datetime)와 시가·고가·저가·종가를 들고 있었고, 다른 모든 지표 테이블은 이 테이블과 조인해야 날짜를 알 수 있었다. 모든 쿼리에 이 컬럼들이 필요하리라 가정했기 때문인데, 정작 분석에서 가장 중요한 값이 종가라는 사실조차 당시엔 몰랐다. 이 구조가 틀렸음을 깨달은 건 데이터가 100GB를 넘긴 뒤였다. 지표 테이블마다 StockDate를 추가하는 작업은 저장 프로시저와 여러 스케줄 이벤트로 테이블별로 나눠 돌려 잠금은 피했지만, 대기 시간은 피할 수 없는 고역이었다.
역설적이게도 "기준 테이블에서 필요한 컬럼만 골라 쓴다"는 그의 발상 자체는 OLAP의 핵심 원리, 즉 필요한 컬럼만 읽고 나머지는 무시하는 컬럼 지향 방식과 거의 같았다. 5년이 지나 저자는 자신의 분석 작업이 테이블당 두세 개 컬럼만 쓰면서도 매번 전 컬럼을 읽고 있었음을 확인했고, 분석 성능이 곧 시장에서의 우위로 이어질 수 있다는 판단 아래 엔진 선택 자체가 잘못됐다는 결론에 이른다.
레이크하우스를 움직이는 두 갈래의 길
2세대의 분석 테이블은 MinIO 위에 올린 Iceberg로, Polaris 카탈로그 뒤의 Parquet 파일들을 Spark가 읽는 구조다. Polaris는 카탈로그 역할을 하는데, 실체는 Iceberg 테이블마다 metadata.json 파일을 가리키는 포인터를 저장하는 Postgres 데이터베이스와 API다. 모든 쿼리는 이 포인터를 묻는 것으로 시작하고, 모든 커밋은 이 포인터를 갱신하며 끝난다. Polaris가 한 번에 하나의 갱신만 받아들이기 때문에 테이블 변경이 원자적으로 유지된다. 즉 커밋이란 결국 포인터를 원자적으로 맞바꾸는 동작이다. MinIO는 홈랩 환경에서 S3를 대체하는 물리 저장소일 뿐이고, 실제 두뇌는 Iceberg가 담당한다.
읽기 측 최적화는 세 겹의 가지치기(pruning)로 나뉜다. 저자는 이를 러시아 인형에 비유한다. 가장 작은 인형은 Parquet 데이터 파일로, 로우 그룹마다 컬럼별 최소·최대값과 널 개수를 요약해 둔다. 한 단계 큰 인형인 매니페스트는 데이터 파일들을 묶어 같은 통계에 더해 파티션 값, 파일 크기, 위치, 그리고 삭제·수정된 행을 추적하는 삭제 파일(Delete File)까지 연결한다. 삭제 파일이 있으면 읽을 때마다 데이터 파일과 병합하는 '머지 온 리드'가 일어나는데, 이는 스트리밍에서 특히 유용하다. 바꾸는 것보다 무엇이 바뀌었는지 적어두고 나중에 계산하는 편이 훨씬 빠르기 때문이다. 가장 큰 인형인 매니페스트 리스트는 매니페스트별 파티션 값 범위와 파티션 버전을 담아, 서로 다른 버전으로 쪼개진 파티션을 독립적으로 가지치기한 뒤 합치는 '스플릿 플래닝'을 가능하게 한다.
파티셔닝 운영의 무게도 완전히 달라졌다. 1세대에서는 테이블 하나를 파티셔닝하는 데 며칠에서 일주일이 걸렸고, 파티션을 바꾸려면 테이블 전체를 다시 나눠야 했다. 저자가 약 1만 3천 개의 StockID를 130개씩 묶어 100개 해시라는 큰 값을 택한 것도 재파티셔닝을 수년 뒤로 미루기 위한 고육책이었다. 반면 Iceberg는 파티션을 메타데이터에 계산해 저장하므로, 파티션에 컬럼을 추가하더라도 그 비용을 읽기 시점에 치르면 된다.
한 번 배우면 대부분에 통한다
쓰기 경로를 보면 세 겹 구조가 어떻게 맞물리는지 분명해진다. MERGE는 고쳐 쓸 행과 그 행이 든 파일을 먼저 찾아야 하므로 쓰기가 읽기로 시작된다. 실행기는 가장 큰 인형부터 거슬러 올라가 Polaris에 현재 메타데이터 포인터를 묻고, 그 메타데이터가 현재 스냅샷을, 스냅샷이 매니페스트 리스트를 가리킨다. 플래너는 심볼의 버킷을 계산해 해당 버킷만 진입하고, 파티션 범위에 심볼이 포함된 매니페스트만 확인하며, 살아남은 데이터 파일 안에서 다시 로우 그룹 통계로 불필요한 그룹을 쳐낸 뒤 남은 것만 디코딩한다. 쓰기 시점에 통계를 찍어두고, 데이터를 읽기 전에 가지치기하고, 컬럼 단위로 디코딩한다는 이 큰 그림은 Snowflake, BigQuery, Delta Lake가 공유하는 공통 모델이다. 벤더마다 이름만 다를 뿐이라, 한 번 제대로 익혀두면 대부분을 이해한 셈이 된다. 다만 저자 스스로 밝히듯 매니페스트와 데이터 파일의 차이, 즉 데이터 묶음이 여러 계층에서 일어난다는 개념을 체득하는 데만 한 달이 걸렸다는 점은, 이 전환이 결코 공짜가 아님을 보여준다.