광고 성과 데이터 StarRocks 도입기
안녕하세요. 라포랩스 Server Engineer 고준현, Data Engineer 이병우입니다.
퀸잇은 입점 셀러분들의 매출과 노출을 키우기 위해 AI 광고 상품을 운영하고 있습니다. 셀러분이 캠페인을 세팅하고 판매 중인 상품을 등록하면, 개인화 추천 알고리즘이 그 상품을 퀸잇 유저들의 광고 지면에 노출시킵니다. 노출 기록은 실시간으로 수집되어 캠페인 과금으로 이어집니다.
광고 성과 데이터는 캠페인의 각 상품이 유저들에게 얼마나 노출되었고 얼마나 구매로 이어졌는지 보여줍니다. 셀러분들은 이 데이터를 보고 광고의 효과를 판단하고 다음 전략을 세웁니다. 그래서 성과 데이터가 안정적으로, 또 정확하게 나와야 퀸잇 AI 광고 상품의 신뢰도가 유지됩니다. 그런데 기존 시스템은 여러 문제를 안고 있어 이 부분을 제대로 해내지 못하고 있었습니다. 이 글에서는 기존 광고 성과 데이터가 겪었던 문제들과, StarRocks를 도입해 이를 해결한 과정을 정리합니다.
기존 광고 성과 데이터 제공 시스템
퀸잇 광고 시스템은 유저 행동 데이터를 실시간으로 수집 및 가공하여 아래 두 MySQL 원천 테이블에 데이터를 적재합니다.
ad_interaction_raw: 유저의 광고 노출·클릭 등을 한 건씩 실시간으로 기록하는 과금 원천 데이터.
ad_purchase_aggregated_events: 셀러의 광고(노출·클릭)를 거쳐 일어난 구매를 5분마다 한 번씩 모아서 기록하는 원천 데이터.
광고 성과 데이터는 위 두 테이블을 원천으로 만들어집니다. 성과 데이터 테이블은 원천 테이블과 마찬가지로 MySQL에 존재했고 각 캠페인 단위로 노출 수, 클릭 수, 과금액, 구매 수량, 구매액을 집계했습니다. 실제로는 더 많은 단위의 성과 데이터가 존재하나 이 글에서는 문제 단순화를 위해 캠페인 단위의 성과 데이터만 설명합니다.
두 원천 테이블의 다수 이벤트 row가 캠페인(ad_id) 단위 성과 row로 누적됩니다.
기존에는 이 두 테이블의 데이터를 배치 잡을 통해 주기적으로 조회하고 집계하여 광고 성과 데이터를 업데이트했습니다. 그 구조는 아래와 같습니다.
원천 테이블에 대해 마지막으로 처리한 ID 값을
global_pointer테이블에서 불러옵니다.해당 ID 값을 기준으로 원천 테이블에서 처리되지 않은 모든 데이터 row를 조회한 후 광고 캠페인별로 노출 수, 클릭 수, 과금액, 구매 수량, 구매액의 증분 값을 집계합니다.
집계한 성과 값을 Kafka topic으로 발행합니다. 이때 message key는 캠페인 ID가 됩니다.
마지막으로 처리한 원천 데이터 ID 값을
global_pointer테이블에 저장합니다.Kafka consumer가 메시지를 수신합니다.
메시지의 캠페인 ID에 해당하는 성과 데이터 테이블의 row를 조회합니다.
해당 row에 노출 수, 클릭 수, 과금액, 구매 수량, 구매액의 증분을 반영하고 CTR, CVR, ROAS를 새로 계산한 후 저장합니다.
집계된 증분 값을 성과 데이터에 업데이트하는 부분을 비동기로 분리한 이유는 DB 업데이트가 배치 잡의 병목이 되어 성과 증분 집계를 지연시키지 않도록 하기 위함이었습니다. 이때 캠페인 ID를 partition key로 설정하기 때문에 캠페인별로 메시지가 순서대로 처리되어 동시성 문제가 발생하지 않았으며, 라포랩스 공용 라이브러리가 제공하는 transactional inbox pattern을 통해 멱등성을 보장했습니다. 또한 원천 테이블 조회는 chunk 단위로 수행하여 DB 부하와 메모리 부하를 조절했습니다.
기존 시스템의 문제
위 시스템은 퀸잇 AI 광고를 처음 만들고 운영하던 시기에 함께 개발되었고, 이 시기에는 광고 성과 데이터를 그럭저럭 잘 제공할 수 있었습니다. 그러나 이후 퀸잇의 트래픽 증가 및 광고 지면 확대로 일별 광고 노출 수가 천만 건에 가까이 달하게 되고, 입점 셀러 수가 증가하며 광고 성과 데이터에 대한 요구사항도 늘어남에 따라 여러 문제가 나타나기 시작했습니다.
확장성이 떨어지는 구조
AI 광고를 진행하는 셀러분들이 증가하면서 셀러분들로부터 상품별 성과 데이터나 일자별 성과 데이터와 같은 더 다양한 종류의 성과 데이터를 제공해달라는 요청이 들어오게 되었습니다. 그런데 기존의 시스템은 이러한 요청에 확장성 있게 대응할 수 없는 구조였습니다. 이는 근본적으로 상품별 성과 데이터나 일자별 성과 데이터가 기존의 캠페인별 성과 데이터보다 더 작은 집계 단위를 가지고 있기 때문입니다. 그림 2의 AdCampaignPerformanceAggregationBatch는 원천 데이터를 캠페인별로 집계합니다. 하지만 상품별 성과 데이터를 만들기 위해선 원천 데이터를 그보다 더 작은 단위인 상품별로 집계해야 하고, 마찬가지로 일자별 성과 데이터를 얻기 위해선 (일자별, 캠페인별)로 집계해야 합니다. 따라서 캠페인 단위 집계 배치를 재활용하지 못하고 상품별 집계 배치, 일자별 집계 배치와 같이 별도의 배치를 만들어야 합니다. 같은 이유로 Kafka consumer 나 DB 테이블도 재활용할 수 없고 별도의 consumer와 테이블을 만들어줘야 합니다. 즉 새로운 종류의 성과 데이터를 추가할 때마다 그림 2의 구조를 구현하는 코드를 한 벌 더 구현해야 했고, 이는 의미있는 구현이라기보단 기존 코드의 복사-붙여넣기 수준의 작업이라 번거로움이 컸습니다.
여기서 캠페인 성과 집계 배치를 가장 작은 단위로 하는 것도 고려해볼 수 있는데요. 예를 들어 AdCampaignPerformanceAggregationBatch에서 (일자별, 상품별)로 집계하면 Kafka consumer 쪽에서 캠페인별, 상품별, 일자별 성과 데이터를 모두 업데이트할 수 있을 것입니다. 이 경우 새로운 종류의 성과 데이터를 추가할 때 그에 대응하는 DB 테이블 추가와 Kafka consumer 쪽에 코드 몇 줄 추가하만 하면 됩니다. 그러나 이 방식은 두 가지 문제가 있습니다.
"가장 작은 단위"는 시간이 지나면서 달라질 수 있습니다. 예를 들어 현 시점에서는 (일자별, 상품별)이 가장 작은 단위이지만, "지면별 성과 데이터" 추가 요청이 들어오면 우리는 "지면"이라는 더 작은 단위의 집계가 필요합니다.
집계 단위가 작을수록 집계 작업이 만들어내는 부하가 커집니다. 배치 잡이 발행하는 메시지의 크기가 집계 단위에 반비례하여 커지기 때문입니다. 1번 문제를 방지하기 위해 "현실적으로 가장 작은 단위로 집계해서 미래의 모든 추가 요청에 대응할 수 있도록 하자"라고 결정한다면 (그 단위를 어떻게 결정할지의 문제는 뒤로 하고) 이는 언제 올지 모르는 미래에 대비하기 위해 현재에 불필요한 부하를 만들어내는 것이 됩니다.
추가로, 이렇게 번거로운 방식으로 성과 데이터를 추가 구현하여 배포했을 때 그 데이터는 기본적으로 배포 시점 이후의 성과만 보여줄 수 있습니다. 이는 기존 시스템이 증분 방식으로 성과를 업데이트하는 구조였기 때문입니다. 만약 과거의 성과까지 반영하고자 한다면 과거의 원천 데이터에 대한 별도의 집계 및 백필 작업을 수행해야 했고, 원천 데이터의 크기가 매우 크기 때문에 아주 많은 시간이 소요되어야 했습니다.
데이터베이스 부하 증가
광고 서버는 유저의 앱 상호작용을 실시간으로 수신하여 AI 광고 과금 원천을 생성하기 때문에 기본적으로 DB 쓰기 부하가 높습니다. 그런데 이렇게 실시간으로 생성되는 원천 데이터를 집계하여 성과 데이터를 업데이트할 경우 DB에 쓰기 부하가 더해집니다. 물론 집계에 대한 쓰기 작업이기 때문에 그 부하가 원천 데이터 쓰기만큼 높지는 않으나, 원천 데이터 쓰기 부하가 증가하면 성과 데이터의 쓰기 부하도 이에 비례하여 증가하게 됩니다. 또한 새로운 종류의 성과 데이터를 추가할 경우 업데이트하는 테이블이 하나 더 추가되는 것이기 때문에 쓰기 부하는 성과 데이터의 종류에 비례해 선형적으로 증가합니다. 실제로 라포랩스에서도 이로 인한 광고 서버의 전체적인 쓰기 부하가 매우 높았고 아래 그림과 같이 RDS 평균활성세션 알람이 빈번하게 발생했습니다. 높은 쓰기 부하는 AI 광고 과금 원천 데이터 생성의 지연을 유발하고, 이는 광고 매출 손실로 직결되기 때문에 비즈니스적으로 매우 크리티컬한 문제였습니다.
간헐적 집계 누락
위 구조와 같이 global pointer 기반으로 원천 데이터를 주기적으로 읽으면 모든 데이터를 누락 없이 읽을 것이라고 생각하기 쉽지만, 실제로는 구현에 따라 누락이 발생할 수 있습니다 (아래 '부록' 참고). 이러한 누락은 발생을 감지하기도 쉽지 않을 뿐더러, 만약 누락을 발견했다고 하더라도 실시간으로 업데이트되는 여러 종류의 성과 데이터에 일일이 누락된 데이터를 보정하는 것 또한 어려운 일이었습니다. 실제로 특정 캠페인의 노출 수가 해당 캠페인에 속한 상품들의 개별 노출 수 총합과 일치하지 않는 등 이러한 누락이 문제가 된 케이스가 간간이 발생하였습니다.
장애 시 복구의 어려움
종종 광고 서버에 장애가 발생하여 원천 데이터가 잘못 만들어지고, 사후 수습을 하면서 원천 데이터를 수정해야 하는 일이 발생합니다. 문제는 원천 데이터 수정이 성과 데이터에 자동으로 반영되지 않는다는 것입니다. 이는 원천 데이터를 읽는 배치 잡이 커서 기반으로 증분에 대한 집계만 하기 때문입니다. 그렇다고 해서 실시간으로 업데이트되는 성과 데이터를 DB에서 수동으로 업데이트하는 것도 어렵습니다. 따라서 유일한 해결책은 보정 데이터를 삽입하여 그 "증분"을 통해 성과 데이터의 최종적인 일관성을 맞추는 것이었고, 이 보정 데이터를 만들어내는 작업이 장애 대응에 상당한 피로감을 더했습니다.
해결 방향: OLAP DB 도입
위와 같은 문제는 기존의 시스템 구성으로는 해결이 어렵다고 판단하였습니다. 결국 기존의 MySQL에 더해 별도의 OLAP(Online Analytical Processing) DB를 새로 구성하여 성과 데이터를 제공하기로 결정했습니다. 대용량 데이터의 집계와 분석에 특화된 OLAP DB를 도입하면, 광고 서버의 쓰기 부하와 셀러의 성과 조회 부하를 완벽히 격리할 수 있습니다. 또한, 실시간으로 쏟아지는 대규모 원천 데이터를 기반으로 CTR, CVR, ROAS 같은 복잡한 성과 지표를 MySQL에 부하를 주지 않고도 실시간에 가깝게 계산해 낼 수 있습니다.
StarRocks를 선택한 이유
후보 비교: BigQuery, ClickHouse, StarRocks
성과 데이터를 셀러분들에게 실시간에 가깝게 서빙할 OLAP DB가 필요했고, 후보는 이미 사내에서 쓰던 BigQuery를 비롯해 ClickHouse, StarRocks 세 가지였습니다.
BigQuery: 가장 먼저 떠올린 후보는 이미 사내에서 분석용으로 쓰고 있던 BigQuery였습니다. 하지만 BigQuery는 근본적으로 대용량 배치 분석에 최적화된 엔진이라 셀러분들에게 성과 데이터를 실시간에 가깝게 서빙해야 하는 이번 요구사항과는 맞지 않았습니다. 비용 측면에서도 On-demand 과금으로는 자주 반복되는 조회가 비용을 빠르게 폭증시켰고 (성과 조회 한 번에 수십 GB를 스캔하는 쿼리도 있었습니다) 그렇다고 정액(capacity) 과금으로 전환하면 실시간 서빙을 위한 상시 용량을 과도하게 확보해야 하는 부담이 있었습니다.
ClickHouse: 다음 후보는 ClickHouse였는데, 결정적인 걸림돌이 glue catalog 연동에 있었습니다. ClickHouse를 glue catalog에 연결하면
timestamp타입 컬럼을 가진 Iceberg 테이블이SHOW TABLES목록에 아예 나타나지 않았습니다 (동일한 스키마에서 시간 컬럼만 제거한 테이블은 정상적으로 보였습니다). 목록에 없으니 조회 자체가 불가능했는데, 광고 데이터는 거의 모든 테이블이 시간 컬럼을 갖고 있어 (파티셔닝 기준이기도 합니다) 사실상 광고 테이블 전반을 ClickHouse로 다룰 수 없다는 의미였습니다. 또한 S3에 있는 Iceberg 파일을 직접 지정해 읽으면 조회가 되는데 glue catalog를 거쳐 SQL로 조회하면Host is empty in S3 URI에러가 발생했습니다. 당시 ClickHouse에 버그로 등록되어 있었고(ClickHouse#87609), 저희가 확인한 최신 버전에도 반영되지 않은 상태여서 production으로 쓰기엔 무리가 있다고 판단했습니다. 물론 타입을 변환하거나 뷰를 거치는 우회도 생각해볼 수 있었지만, 시간 컬럼이 필수적인 광고 데이터 전반에 그런 오버헤드를 떠안기는 부담스러웠습니다. 이를 피하려면 결국 원천을 ClickHouse 내부에 다시 적재하는 방향이 될 수밖에 없었는데, 이는 원천을 외부에 그대로 두려던 저희 전략과 맞지 않았습니다. ClickHouse가 부족하다기보다, 당시의 ClickHouse–Glue/Iceberg 연동이 저희 구성과 맞지 않았던 문제입니다.StarRocks: 반면 StarRocks는 Glue + Iceberg external catalog 연동이 안정적이어서 데이터를 내부에 다시 적재할 필요 없이 원천을 그대로 둔 채 조회할 수 있었습니다. 한편 '원격 스토리지를 조회하면 느리지 않냐'는 우려도 있었습니다. 이는 Data Cache와 Materialized View(MV) 두 가지로 해소했습니다. Data Cache는 원격 스토리지에서 한 번 읽은 데이터 블록을 BE의 메모리와 로컬 디스크에 캐싱해 같은 데이터를 다시 읽을 때는 원격까지 가지 않도록 합니다. MV는 (뒤에서 자세히 다루지만) 자주 조회하는 집계 결과를 내부에 미리 만들어 둬 원격 조회 자체를 줄여줍니다. 또한 광고 성과는 여러 지표를 조인해 보여줘야 해서 조인 성능이 중요했는데, 분산 조인이 약했던 ClickHouse와 달리 StarRocks는 비용 기반 옵티마이저(CBO)를 통해 이 요구를 잘 충족했습니다. 마지막으로 StarRocks가 MySQL wire protocol과 호환된다는 점도 결정적이었습니다. 기존 광고 서버(백엔드)가 MySQL을 기반으로 개발되어 있었기에, 애플리케이션 단의 드라이버나 의존성을 교체하는 큰 공수 없이 기존 인프라와 쿼리 라이브러리를 최대한 재활용하며 안정적으로 연동할 수 있었습니다.
이러한 이유로 저희는 광고 성과 데이터의 OLAP DB으로 StarRocks를 최종 선택했습니다.
원천을 외부에 두는 external table 전략
가장 먼저 정해야 했던 것은 '성과 데이터의 원천을 어디에 저장할 것인가'였습니다. 선택지는 크게 두 가지였습니다.
(1) OLAP DB 내부 스토리지에 데이터를 직접 적재(data loading)하는 방식
(2) 데이터는 외부 스토리지에 두고 OLAP DB는 이를 external table로 조회만 하는 방식
저희는 (2)를 택했습니다. (1)처럼 데이터를 엔진 내부에 적재하면 엔진에 문제가 생기거나 클러스터를 새로 띄울 때 데이터까지 함께 위험에 노출되는 반면, 원천을 외부에 두면 운영 안정성과 엔진 비종속성이라는 두 가지 이점을 얻을 수 있다고 봤기 때문입니다. ****구체적으로는 광고 데이터를 포함한 원천을 Apache Iceberg 테이블 포맷으로 S3에 적재하고 그 메타데이터(카탈로그)는 AWS Glue로 관리하는 레이크하우스를 먼저 구축한 뒤, OLAP DB는 이 Iceberg 테이블을 external table로 조회하도록 한 것입니다.
가장 큰 이유는 운영 안정성이었습니다. StarRocks는 저희가 처음 도입하는 엔진이었고, 운영하다 보면 버전 업그레이드나 배포 구조 변경 같은 시행착오가 적지 않을 것으로 예상됐습니다. 데이터를 엔진 내부 스토리지(internal catalog)에 둔 상태라면 그런 상황마다 데이터까지 함께 위험에 노출되어 대응이 매우 어려워지지만, 원천을 외부(Iceberg/Glue)에 둔 덕분에 OLAP DB 쪽에 무슨 일이 생기더라도 데이터는 그대로 남아 있어 클러스터만 다시 세워 빠르게 복구할 수 있습니다. (실제로 이 판단은 후술할 아키텍처 전환 과정에서 큰 도움이 되었습니다.)
또 다른 이점은 엔진 비종속성이었습니다. 특정 엔진에 종속되지 않고 여러 엔진이 같은 데이터를 동시에 읽을 수 있다는 점인데, 이를 위해 오픈 테이블 포맷인 Iceberg를 사용했습니다. 앞선 ClickHouse·StarRocks 비교를 같은 Iceberg 원천 위에서 나란히 진행할 수 있었던 것도 이 덕분입니다.
물론 (2)에도 trade-off는 있습니다. 데이터가 엔진 내부에 있는 (1)에 비해 원격 스토리지를 조회하는 (2)는 일반적으로 조회 성능 면에서 불리할 수 있습니다. 다만 저희가 서빙해야 하는 성과 데이터의 규모와 지연 요구 수준에서는 그 격차가 결정적이지 않았고, 앞서 StarRocks 항목에서 설명한 Data Cache와 Materialized View로 충분히 메울 수 있는 범위였습니다. 그래서 약간의 성능을 양보하더라도 운영 안정성과 비종속성을 얻는 편이 낫다고 판단했습니다.
StarRocks 아키텍처 선택: shared-data에서 shared-nothing으로
StarRocks를 선택한 뒤에는 클러스터를 어떻게 구성할지를 정해야 했습니다. 저희는 shared-data 구조로 시작했다가 실시간 워크로드와 맞지 않아 shared-nothing 구조로 전환했습니다. 이 절에서는 StarRocks의 기본 구성 요소를 살펴본 뒤 그 전환의 이유와 과정을 공유합니다.
StarRocks의 구성 요소와 두 종류의 데이터
저희 구성에서 StarRocks가 다루는 데이터는 두 종류입니다. 이를 먼저 구분해 두어야 뒤의 아키텍처 설명이 명확해집니다.
external catalog의 데이터: glue catalog에 연결된 Iceberg 원천 테이블입니다. 앞 절에서 말했듯 이 데이터의 source of truth는 항상 외부(S3 + Glue)에 있고, StarRocks는 이를 external table로 조회만 합니다. 광고 성과의 원천(노출·클릭·구매 등)이 여기에 해당합니다.
internal database의 데이터: StarRocks가 직접 관리하는 내부 테이블과 그 위에 만든 Materialized View입니다. 셀러분들에게 서빙하는 광고 성과 데이터는 이 internal MV에서 나옵니다.
StarRocks 클러스터는 두 종류의 노드로 구성됩니다. FE(Frontend)는 메타데이터를 관리하고 쿼리 실행 계획(plan)을 세우며, 실제 연산은 연산 노드(BE 또는 CN) 가 수행합니다. 이 연산 노드를 BE(Backend) 로 두느냐 CN(Compute Node) 으로 두느냐가 곧 두 배포 아키텍처를 가르는데, 그 차이는 결국 internal 데이터를 어디에 저장하느냐에서 비롯됩니다. external catalog의 Iceberg 데이터가 S3에 있다는 사실은 어느 쪽을 택하든 변하지 않으니, 차이는 오직 internal 데이터에서만 생깁니다.
shared-nothing(FE + BE): BE가 internal 테이블·MV를 자신의 로컬 디스크에 저장하고, 그 데이터를 직접 연산합니다. 즉 데이터와 연산이 한 노드에 함께 있습니다.
shared-data(FE + CN): internal 테이블·MV까지 S3 같은 오브젝트 스토리지에 저장하고, CN은 상태를 갖지 않은 채 연산만 담당합니다.
즉 저희가 현재 쓰는 shared-nothing 구조에서 광고 성과 MV는 BE의 로컬 디스크에 저장되고, 그 MV가 집계하는 원천 Iceberg 데이터는 여전히 S3에 있습니다.
shared-data 구조의 한계
처음에 저희는 shared-data 구조(FE+CN)로 클러스터를 구성했습니다. internal 데이터를 S3에 저장하면 스토리지와 compute가 분리되어 확장이 자유롭고, 데이터를 로컬에 둘 BE 노드가 필요 없으니 스토리지 비용도 적을 것이라 기대했기 때문입니다. 이는 앞 절에서 말한 'storage와 compute를 분리해 복구 안정성을 확보한다'는 방향과도 잘 맞는 선택이었습니다.
하지만 운영해보니 shared-data 구조는 저희의 실시간 워크로드와 잘 맞지 않는다는 결론에 도달했습니다. 가장 큰 문제는 비용이었습니다. 앞서 설명했듯 shared-data 구조에서는 internal 데이터가 S3에 저장됩니다. 그래서 성과 데이터를 실시간에 가깝게 반영하려고 MV를 몇 분 간격으로 갱신할 때마다 그 변경분이 고스란히 S3 객체 쓰기로 이어졌습니다. 이 트랜잭션이 매우 많은 S3 PutObject 호출을 발생시켰고, 예상보다 훨씬 큰 비용으로 이어졌습니다. 실제로 이 비용은 이후 shared-nothing 구조로 전환하면서 약 1/20 수준(약 95% 절감)으로 떨어졌는데, 바꿔 말하면 shared-data 구조에서는 그만큼의 비용이 대부분 잦은 S3 쓰기에서 새어 나가고 있었다는 뜻입니다. (이는 저희만 겪은 문제는 아니어서 StarRocks에도 shared-data의 실시간 시나리오 비용 최적화에 대한 이슈(starrocks#58316)가 올라와 있었습니다.)
또 다른 불편함은 모니터링이었습니다. StarRocks는 /metrics 엔드포인트를 통해 Prometheus 연동을 지원하지만, shared-data 구조에서 기본적으로 확인하기 쉬운 지표는 S3 I/O, metadata, cache, publish/version 작업처럼 스토리지 계층 중심의 지표였습니다. 반면 저희가 필요했던 것은 MV별 갱신 빈도, 소요 시간 및 실패 여부였는데 당시 확인한 /metrics에서는 이 값들을 Prometheus/Grafana 대시보드로 바로 구성할 만큼 명확하게 얻기 어려웠습니다. shared-data 구조에서는 CN이 stateless로 동작하고 데이터와 일부 메타데이터가 S3 같은 object storage에 위치하다 보니 MV 갱신 상태를 운영 관점에서 추적하기가 더 까다로웠습니다.
shared-nothing으로의 전환
비용 문제를 마주하고 가장 먼저 시도한 것은 버전 업그레이드였습니다. 마침 StarRocks 4.0에는 shared-data 클러스터의 cloud-native table에서 object storage API 호출 비용을 줄이기 위한 File Bundling 최적화가 추가되어 있었습니다. 그래서 shared-data 구조를 유지한 채 3.x에서 4.0.2로 올려보았지만, 저희 워크로드에서는 비용 문제가 기대만큼 해소되지 않았습니다. 원인을 단정하긴 어렵지만 File Bundling이 v4.0 이후 생성된 테이블에 한해 기본 적용된다는 점이 컸을 것으로 추측합니다. 저희 테이블과 MV는 3.x에서 만들어진 상태였기 때문에 버전만 올리는 in-place 업그레이드만으로는 이 최적화가 기존 테이블에 실제로 적용되지 않았던 것으로 보입니다. 적용하려면 테이블을 새로 만들거나 옵션을 명시적으로 켜야 했고, 이 판단이 구조 자체를 바꾸는 방향으로 전환하게 된 계기가 되었습니다.
결국 클러스터를 shared-nothing(FE+BE) 구조로 다시 구성했습니다. BE의 로컬 디스크에 internal 데이터를 두자 과도한 S3 호출로 인한 비용 문제가 해소되었고, shared-data 구조에서 저희 모니터링 환경과 맞지 않던 MV 메트릭 수집도 자연스럽게 가능해져 모니터링 대시보드를 구성할 수 있었습니다.
이 전환을 비교적 수월하게 진행할 수 있었던 것은 external table 전략 덕분이었습니다. 성과 데이터의 원천이 외부 Iceberg(S3+Glue)에 있었으므로 새 클러스터를 세우고 MV만 다시 만들면 됐습니다. 앞 절에서 예고한 ‘구조 변경에 유연하다’는 이점이 실제 구조 변경 상황에서 확인된 셈입니다.
파티셔닝과 버켓팅
클러스터 아키텍처를 shared-nothing으로 안정화한 후, 저희의 다음 과제는 셀러분들에게 성과 데이터를 더 빠르게 제공할 수 있도록 Materialized View(이하 MV)의 조회 및 갱신 성능을 최적화하는 것이었습니다.
StarRocks에서 MV를 효율적으로 설계하기 위해선 먼저 StarRocks 내부적으로 데이터가 어떤 형태로 저장되는지를 이해해야 합니다. 구체적으로는 파티셔닝(Partitioning)과 버켓팅(Bucketing)이 무엇인지 이해할 필요가 있습니다. 아래 내용은 StarRocks 공식 문서를 참고하여 작성했습니다.
파티셔닝
여타 데이터베이스의 파티셔닝과 마찬가지로 StarRocks의 파티셔닝도 하나의 테이블/MV의 데이터를 여러 개의 파티션으로 나누어 저장하는 것을 말합니다. StarRocks는 DDL에서 PARTITION BY 구문을 통해 특정 컬럼을 파티션 키로 지정할 수 있습니다. 예를 들어 아래 DDL에서는 click_stream 테이블의 event_time 컬럼을 파티션 키로 지정했습니다.
CREATE TABLE click_stream (
user_id BIGINT,
event_time DATETIME,
url STRING,
...
)
DUPLICATE KEY(user_id, event_time)
PARTITION BY date_trunc('day', event_time);
여기서 date_trunc는 시간적 의미를 가진 컬럼의 값을 특정 시간 단위로 자르는 함수이며, 그 첫 번째 인자인 'day'를 통해 일 단위로 자른다는 것을 표현합니다. 즉 PARTITION BY date_trunc('day', event_time)은 event_time 컬럼 값이 가리키는 날짜를 기준으로 파티셔닝한다는 의미입니다. 이 예시처럼 StarRocks 공식 문서에서는 시간적 의미를 가진 컬럼을 파티션 키로 지정하는 것을 권장하고 있습니다.
MV 파티셔닝과 Incremental Refresh
StarRocks에서는 MV를 만들 때도 파티셔닝을 할 수 있는데 그 조건은 다음 두 가지입니다.
베이스 테이블/MV가 파티셔닝되어 있다
그 위에 만드는 MV의 파티션 키가 베이스 테이블/MV의 파티션 컬럼을 기반으로 한다
이렇게 만들어진 MV의 파티션은 StarRocks 내부 메타데이터에 의해 베이스 테이블/MV의 파티션과 일대일로 매핑됩니다 (N:1 로 매핑되게 할 수도 있으나 이 글에서는 다루지 않겠습니다). 여기서 파티션 키를 잘 설정하는 것은 MV 갱신의 효율성 측면에서 매우 중요한데, 그 이유는 incremental refresh 때문입니다. StarRocks는 파티셔닝된 MV를 갱신할 때 베이스 테이블/MV의 모든 데이터를 다시 읽어 갱신하지 않습니다. 대신 지난번 갱신 이후 변경이 일어난 파티션의 데이터만 다시 읽습니다. 이를 incremental refresh라고 합니다. 테이블/MV의 전체 데이터를 읽는 것과 특정 파티션의 데이터만 읽는 것의 부하 차이는 상황에 따라 하늘과 땅 수준으로 벌어질 수 있습니다. 따라서 파티션 키를 잘 설정하여 MV 갱신 시 실제로 읽히는 파티션의 개수를 최소화하는 것이 매우 중요합니다.
위의 click_stream 테이블을 예시로 들어보겠습니다. 어떤 날짜에 이 테이블에 추가되는 데이터의 event_time 컬럼 값은 대체로 그 날짜의 시간을 가리킬 것이고 결과적으로 그 날짜에 매핑된 파티션에만 데이터가 추가될 것입니다. 예를 들어 2026년 5월 19일에 추가되는 데이터는 대부분 '20260519' 파티션에 추가될 것입니다. 간혹 몇몇 지연된 이벤트가 '20260518' 파티션에 데이터를 추가할 수도 있겠지만, 데이터 파이프라인에 심각한 지연이 있지 않은 이상 더 과거 날짜의 파티션에는 데이터가 추가될 일이 없을 것입니다. 따라서 이 날 StarRocks가 click_stream 테이블을 베이스 테이블로 하는 MV를 갱신할 때는, click_stream 테이블에 파티션이 100개든 1,000개든 상관없이 '20260518'과 '20260519'의 두 파티션만 읽게 됩니다.
반면 event_time이 아닌 user_id 컬럼이 파티션 키라면 어떤 일이 벌어질까요? 클릭을 하는 유저는 어느 시점이든 매우 다양하니 특정 파티션뿐이 아닌 여러 파티션에 데이터가 상시로 추가될 것이고, 이에 따라 MV 갱신 시 읽히는 파티션의 수도 많을 것입니다. 이는 비효율적인 incremental refresh로 이어집니다. 이것이 파티션 키를 잘 설정해야 하는 이유이며, StarRocks에서 시간 컬럼을 파티션 키로 지정하는 것을 권장하는 이유 중 하나이기도 합니다.
버켓팅
StarRocks는 한 파티션 내에서 데이터를 태블릿(Tablet)이라는 단위로 나누어 저장합니다. 버켓팅이란 이러한 태블릿에 데이터를 분산시키는 것을 의미하며 파티셔닝과 마찬가지로 MV 설계에 매우 중요한 요소입니다. StarRocks에서는 해시 버켓팅(Hash Bucketing)과 랜덤 버켓팅(Random Bucketing)을 지원합니다 (최신 버전에서는 범위 기반 버켓팅이 추가되었으나 이 글에서는 다루지 않습니다).
해시 버켓팅과 랜덤 버켓팅
해시 버켓팅은 특정 컬럼을 버켓팅 키로 지정하고 그 컬럼 값을 해싱하여 데이터가 저장될 태블릿을 지정합니다. DDL에서 DISTRIBUTED BY HASH 구문을 통해 사용할 수 있습니다.
CREATE TABLE click_stream (
user_id BIGINT,
event_time DATETIME,
url STRING,
...
)
DUPLICATE KEY(user_id, event_time)
PARTITION BY date_trunc('day', event_time)
DISTRIBUTED BY HASH(user_id) BUCKETS 8;
예를 들어 위 DDL에서는 user_id 컬럼을 버켓팅 키로 지정하였습니다. BUCKETS 8은 태블릿을 8개 만든다는 의미입니다. 즉 DISTRIBUTED BY HASH(user_id) BUCKETS 8은 user_id 컬럼 값을 해싱하여 8개 태블릿에 데이터를 분산시켜 저장한다는 의미입니다. 버켓팅 키는 아래 조건을 만족하는 컬럼을 선택하는 것을 권장합니다.
값이 균등하게 분포된 칼럼일 것: 여러 태블릿에 데이터를 균등하게 분산시킬 수 있도록 하기 위함
동등 조건으로 자주 조회되는 컬럼일 것: 조회 시 태블릿 프루닝 (tablet pruning) 을 통해 성능을 높이기 위함
조인 연산 시 조인 키로 자주 설정되는 컬럼일 것: colocate join을 통해 조인 성능을 높이기 위함
이 중 colocate join이 무엇인지에 대해서는 아래에서 설명하겠습니다.
반면 랜덤 버켓팅은 버켓팅 키를 지정하지 않고 데이터를 태블릿에 무작위로 분산시키는 방식입니다. DDL에서 DISTRIBUTED BY RANDOM으로 지정하거나 DISTRIBUTED BY 구문 자체를 생략하면 적용됩니다. 특정 키 값에 데이터가 쏠려(skew) 일부 태블릿만 비대해지는 문제를 피하기는 쉽지만, 같은 키 값을 가진 데이터가 같은 태블릿에 모이지 않으므로 조인이나 집계에서 해시 버켓팅만큼의 이점을 얻기는 어렵습니다. 어떤 컬럼을 자주 조인·집계 기준으로 쓰는지에 따라 둘 중 적절한 방식을 선택하면 됩니다.
Colocate Join
분산 데이터베이스에서는 한 테이블 내의 데이터가 여러 노드에 분산되어 저장됩니다. 두 테이블을 조인할 때, 한 테이블의 한 row가 조인될 다른 테이블의 row들은 여러 노드에 존재하게 됩니다. 따라서 두 테이블에서 동일한 조인 키를 가진 row들이 같은 노드에 모이도록 노드 간 데이터를 전송한 후 조인을 수행하게 되며, 이를 shuffle join이라 합니다. 한편 두 테이블 중 하나의 크기가 상대적으로 작을 경우 그 테이블을 모든 노드로 전송하는 전략을 취하기도 하며 이를 broadcast join이라 합니다. 두 조인 전략 모두 노드 간 데이터 전송이 필요하기 때문에 네트워크 오버헤드가 발생하고, 이는 조인 성능에 큰 영향을 미칩니다. 하지만 만약 두 테이블에서 동일한 조인 키를 가진 row끼리 같은 노드에 존재한다면? 노드 간 데이터 전송을 하지 않아도 되기 때문에 조인 성능이 훨씬 좋아질 것입니다. 이를 colocate join이라고 합니다 (chocolate join이 아닙니다).
StarRocks에서는 해시 버켓팅을 통해 colocate join을 사용할 수 있습니다. 아래 DDL과 같이 click_stream 테이블을 정의한다고 해보겠습니다.
CREATE TABLE click_stream (
user_id BIGINT,
event_time DATETIME,
url STRING,
...
)
DUPLICATE KEY(user_id, event_time)
PARTITION BY date_trunc('day', event_time)
DISTRIBUTED BY HASH(user_id) BUCKETS 8
PROPERTIES(
"colocate_with" = "user_group"
);
여기서 "colocate_with" = "user_group"은 이 테이블을 user_group 이라는 colocation group (CG) 에 속하게 한다는 의미입니다. 동일한 CG에 속한 테이블끼리만 colocate join이 가능합니다. 만약 click_stream 테이블과 조인해야 하는 user 테이블을 만들어야 한다고 하면,
CREATE TABLE user (
id BIGINT,
name STRING,
age INT,
...
)
PRIMARY KEY (id)
DISTRIBUTED BY HASH(id) BUCKETS 8
PROPERTIES(
"colocate_with" = "user_group"
);
위와 같이 user 테이블이 user_group CG 에 속하도록 만들 수 있습니다. 이때 user의 태블릿 수는 click_stream과 동일한 8개여야 하며 버켓팅 키인 user.id는 click_stream.user_id와 동일한 타입이어야 합니다. 이제 두 테이블에서 동일한 인덱스의 태블릿은 항상 동일한 노드에 존재하는 것이 보장됩니다. 따라서 한 유저의 user 테이블 데이터와 click_stream 데이터가 동일한 인덱스의 태블릿 - 동일한 노드에 존재하게 되므로 조인 연산이 노드 내부에서만 일어날 수 있게 됩니다.
지금까지 StarRocks가 데이터를 어떤 형태로 저장하는 지 이해하기 위해 파티셔닝과 버케팅, 그리고 버케팅을 활용한 colocate join을 살펴봤습니다.
• 파티셔닝(특히 시간 컬럼 기준)은 변경된 파티션만 다시 읽는 증분 갱신(incremental refresh) 을 가능하게 해 MV 갱신 부하를 줄입니다.
• 버켓팅은 데이터를 태블릿에 분산시켜, 조회 시 태블릿 프루닝으로, 적절한 키를 고르면 colocate join으로 조회·조인 성능을 높입니다.
이제부터는 이 개념들을 실제 광고 성과 데이터에 어떻게 적용했는지 - 어떤 컬럼을 파티션 키, 버케팅 키로 잡고, MV를 어떤 단위로 어떻게 쌓아 올렸는지 - 를 살펴보겠습니다.
MV 설계 전략
StarRocks를 도입하는 시점에 제공 중이던 광고 성과 데이터는 아래와 같습니다.
전체 (셀러별 / 캠페인별 / 상품별) 성과 데이터
일자별 (셀러별 / 캠페인별 / 상품별) 성과 데이터
여기서 가장 세밀한 단위인 "일자별 - 상품별" 단위로 원천 데이터를 집계하는 1차 MV를 아래와 같이 만들었습니다.
CREATE MATERIALIZED VIEW
ad_interaction_daily_aggregated_by_ad_product_id_mv
PARTITION BY created_at_kst ...(a)
DISTRIBUTED BY HASH(ad_id) BUCKETS 16 ...(b)
ORDER BY (ad_id, ad_product_id) ...(c)
REFRESH ASYNC START('2025-11-28 00:00:00') EVERY(INTERVAL 10 MINUTE) ...(d)
PROPERTIES (
'colocate_with' = 'group_ad_id',
'auto_refresh_partitions_limit' = '2' ...(e)
)
AS
SELECT
ad_product_id,
ad_id,
date_trunc('day', created_at_kst) AS created_at_kst,
SUM(charge_amount) AS charge_amount,
SUM(CASE WHEN interaction_type = 'CLICKED' THEN
interaction_count ELSE 0 END) AS clicked_count,
SUM(CASE WHEN interaction_type = 'VIEWED' THEN
interaction_count ELSE 0 END) AS viewed_count
FROM glue_catalog.ms_advertisement.ad_interaction_raw
GROUP BY ad_product_id, ad_id, date_trunc('day', created_at_kst);
CREATE MATERIALIZED VIEW
ad_purchase_daily_aggregated_by_ad_product_id_mv
PARTITION BY interaction_start_at_kst ...(a)
DISTRIBUTED BY HASH(ad_id) BUCKETS 16 ...(b)
ORDER BY (ad_id, ad_product_id) ...(c)
REFRESH ASYNC START('2025-11-28 00:00:00') EVERY(INTERVAL 10 MINUTE) ...(d)
PROPERTIES (
'colocate_with' = 'group_ad_id',
'auto_refresh_partitions_limit' = '14' ...(e)
)
AS
SELECT
ad_product_id,
ad_id,
date_trunc('day', interaction_start_at_kst) AS interaction_start_at_kst,
SUM(CASE WHEN aggregation_type = 'AMOUNT' THEN
value ELSE 0 END) AS purchased_amount,
SUM(CASE WHEN aggregation_type = 'QUANTITY' THEN
value ELSE 0 END) AS purchased_quantity
FROM glue_catalog.ms_advertisement.ad_purchase_aggregated_events
GROUP BY ad_product_id, ad_id, date_trunc('day', interaction_start_at_kst);
위 두 MV는 모두 (상품, 캠페인, 이벤트 발생 일자)로 GROUP BY 하여 노출 수, 클릭 수, 과금액, 구매 수량, 구매액을 집계합니다.
여기서 주요한 기술적 결정과 그 근거는 아래와 같습니다.
(a) 파티션 키를 created_at_kst, interaction_start_at_kst로 설정한 것: 두 컬럼이 '이벤트 발생 시각'으로써 기능하여 자연스럽게 일자별 파티셔닝이 가능하며, incremental refresh를 효율적으로 할 수 있는 컬럼이기 때문입니다.
(b) 버켓팅 키를 ad_id로 설정한 것: cardinality가 높으며, 가장 트래픽이 높은 쿼리가 ad_id에 대한 IN 조건을 포함하며, 메타데이터 테이블과 조인 시 조인 키로 사용되는 컬럼이기 때문입니다. ad_id는 앞서 설명한 버켓팅 키 권장 조건 세 가지를 그대로 만족합니다.
(c) 정렬 키를 (ad_id, ad_product_id)로 설정한 것: 정렬 키는 한 태블릿 내에서 데이터를 정렬하는 기준이 됩니다. 따라서 조회 쿼리에서 조건절로 자주 등장하는 한두 개의 컬럼을 정렬 키로 설정하는 것이 조회 성능에 유리하며, 광고 성과 데이터에서는 이 두 컬럼이 그 조건에 해당했습니다.
(d) 갱신 주기를 10분으로 설정한 것: 사내 데이터 인프라가 감당할 수 있는 가장 짧은 주기를 실험을 통해 찾아내었고 그것이 10분입니다.
(e) auto_refresh_partitions_limit 값 설정: 해당 속성은 MV 자동 갱신 시 가장 최근 N개 파티션만 갱신 대상으로 한정하는 역할을 합니다. 각 원천 테이블에서 이벤트 송신 지연이 발생할 수 있는 최대 일수를 고려하여 설정했습니다. ad_interaction_raw는 기본적으로 준실시간 송신을 가정하나 최대 24시간의 지연까지 허용하므로 auto_refresh_partitions_limit 값을 2로 설정했습니다. ad_purchase_aggregated_events는 상품 광고의 구매 기여를 광고 노출 후 최대 14일까지 인정하고 있어서 auto_refresh_partitions_limit 값을 14로 설정했습니다.
이제 이 1차 MV를 베이스로 더 큰 단위의 집계도 가능해집니다. StarRocks는 MV를 베이스로 또 다른 MV를 생성하는 것을 지원하며 이를 nested MV라고 합니다. 이를 이용하여 다음과 같이 "일자별 - 캠페인별"로 집계하는 2차 MV를 만들 수 있었습니다.
CREATE MATERIALIZED VIEW
ad_interaction_daily_aggregated_by_ad_id_mv
PARTITION BY date_trunc('day', created_at_kst) ...(a)
DISTRIBUTED BY HASH(ad_id) BUCKETS 16
ORDER BY (ad_id)
REFRESH ASYNC ...(b)
PROPERTIES (
'colocate_with' = 'group_ad_id',
'auto_refresh_partitions_limit' = '2'
)
AS
SELECT
ad_id,
created_at_kst,
SUM(charge_amount) AS charge_amount,
SUM(clicked_count) as clicked_count,
SUM(viewed_count) as viewed_count
FROM ad_interaction_daily_aggregated_by_ad_product_id_mv
GROUP BY ad_id, created_at_kst;
위 DDL에서 눈여겨 볼 부분은 다음과 같습니다.
(a) 파티션 키는 베이스 MV의 파티션 키와 동일하게 설정해야 합니다.
(b) 1차 MV와 다르게 갱신 주기를 설정하지 않았습니다. 이 경우 1차 MV에 변경이 일어나면 2차 MV가 자동으로 갱신됩니다.
또한 메타데이터 테이블 상에 있는 단위로도 집계가 가능합니다. 우선 colocate join으로 성능적 이점을 내기 위해 external catalog에 있는 메타데이터 테이블을 1차 MV로 가공하였습니다. 이는 external catalog 테이블은 버켓팅을 지원하지 않기 때문입니다.
CREATE MATERIALIZED VIEW ai_product_advertisement_mv
DISTRIBUTED BY HASH(id) BUCKETS 16
ORDER BY (id)
REFRESH ASYNC START('2025-11-28 00:00:00') EVERY(INTERVAL 1 MINUTE)
PROPERTIES(
"colocate_with" = "group_ad_id"
)
AS
SELECT
*
FROM glue_catalog.ms_advertisement.ai_product_advertisement;
다음으로 아래와 같이 조인하여 "일자별 - 셀러별" 집계 MV를 만들었습니다.
CREATE MATERIALIZED VIEW ad_interaction_daily_aggregated_by_seller_id_mv
PARTITION BY date_trunc('day', created_at_kst)
DISTRIBUTED BY HASH(seller_id) BUCKETS 16
ORDER BY (seller_id)
REFRESH ASYNC
PROPERTIES (
'auto_refresh_partitions_limit' = '2'
)
AS
SELECT
ad.seller_id,
intr.created_at_kst,
COALESCE(SUM(intr.charge_amount), 0) AS charge_amount,
COALESCE(SUM(intr.clicked_count), 0) AS clicked_count,
COALESCE(SUM(intr.viewed_count), 0) AS viewed_count
FROM ai_product_advertisement_mv ad
LEFT JOIN ad_interaction_daily_aggregated_by_ad_id_mv intr
ON ad.id = intr.ad_id
GROUP BY ad.seller_id, intr.created_at_kst;
ad_interaction_daily_aggregated_by_seller_id_mv는 2차 MV인 ad_interaction_daily_aggregated_by_ad_id_mv를 베이스로 만들었기 때문에 3차 MV입니다.
StarRocks는 nested MV의 차수를 제한하고 있지 않지만, 공식 문서에서는 최대 차수가 3을 넘지 않을 것을 권장하고 있습니다. 그 이유를 공식 문서에서는 같이 서술하고 있지 않지만 추론해보면 다음과 같습니다.
상위 MV의 변경이 하위 MV의 갱신을 트리거하는 부하가 커지고, 상위 MV에 문제가 생길 경우 모든 하위 MV로 문제가 전파됩니다.
하위 MV에 문제가 생길 경우 상위 MV 중 어디에서 문제가 발생했는지 추적하는 것이 복잡해집니다.
따라서 저희는 최대 MV 차수가 3을 넘지 않도록 전체 MV 스키마를 설계했으며, 만약 3차 MV를 베이스로 사용하여 더 큰 단위의 집계를 효율적으로 할 수 있더라도 대신 1차 혹은 2차 MV를 베이스로 사용했습니다. 이를 통해 MV 갱신 성능을 희생하는 대신 운영 안정성을 얻을 수 있었습니다.
도입 후 성과
StarRocks 도입 후 기존의 문제들이 다음과 같이 해결되었습니다.
확장성이 떨어지는 구조에서 확장 가능한 구조로
성과 데이터를 변경하거나 새로 추가하는 것이 매우 간편해졌습니다. 기존에는 새로운 종류의 성과 데이터를 추가하기 위해 배치 잡, Kafka 컨슈머와 MySQL 테이블을 새로 하나씩 만들어야 했던 것에 비해, 이제는 단순히 DDL을 작성하여 MV를 1-2개 추가하는 것만으로 성과 데이터 변경 및 추가가 가능해졌습니다. 또한 과거분에 대한 데이터 백필도 파티션 기반 병렬 프로세싱으로 백그라운드에서 자동으로 실행되어 간편하고 빠르게 백필할 수 있게 되었습니다.
데이터베이스 부하 감소
성과 데이터를 더 이상 MySQL에 쓰지 않게 됨에 따라 MySQL 쓰기 부하 문제가 근본적으로 해결되었습니다. 구체적으로는 다음과 같은 개선이 있었습니다.
평균활성세션 p50 8개, p99 16개 → p50 5개, p99 10개
vCPU 대비 p50 100%, p99 200% → p50 62%, p99 125%
커밋 레이턴시 p50 12ms, p99 25ms → p50 8ms, p99 15ms
커넥션 대기 시간 p99 0.6ms → 0.1ms
집계 누락의 근본적 차단
StarRocks의 MV는 집계 갱신 시 파티션의 전체 데이터를 스캔함으로서 누락을 근본적으로 차단합니다. 이를 통해 성과 데이터의 정확성을 보장할 수 있게 되었습니다. 또한 성과 데이터 정확성에 영향을 주는 장애 상황이 일어날 경우 StarRocks 및 데이터 파이프라인 모니터링을 통해 즉시 감지 및 대응할 수 있게 되었습니다.
장애 시 손쉬운 복구
StarRocks가 MV 갱신 시 전체 파티션을 다시 읽기 때문에 기존에 생성된 원천 데이터가 사후에 변경되어도 MV 갱신과 함께 성과 데이터에 자동으로 반영됩니다. 이에 따라 시스템 장애로 인해 잘못 만들어진 원천 데이터를 수정할 때 별도의 보정 데이터를 만들 필요가 없어져 장애 대응의 피로도가 줄었습니다. 실제로 StarRocks 전환 이후 데이터 파이프라인 장애로 원천 데이터를 수정해야 하는 일이 있었는데, 전환 전이었다면 보정 데이터 생성 및 보정 후 최종 일관성 확인까지 4시간 이상 걸렸을 작업을 원천 데이터 수정만으로 30분 이내에 완료할 수 있었습니다.
실전 이슈들
광고 성과 데이터에 StarRocks를 도입하고 운영하면서 공식 문서만으로는 알 수 없던 몇 가지 실전 이슈를 마주했습니다.
데이터의 실시간성
StarRocks에서 MV의 데이터는 실시간에 비교적 가깝게 서빙되나 진짜 "실시간"은 아닙니다. 우선 원천 데이터가 glue catalog에 sink되기까지의 지연이 존재하고, 다음으로 MV가 갱신되기까지의 지연이 존재합니다. 정상적인 상황에서 sink 지연은 2분 이내이고 1차 MV의 갱신 주기가 10분으로 설정되어 있음을 고려하면 최대 12분의 동기화 지연이 존재할 수 있습니다 (2,3차 MV는 갱신에 걸리는 시간이 1-2초 정도로 미미합니다). 클릭 수나 ROAS 같은 광고 성과 데이터는 지연이 1시간 이내이면 문제없다고 보기 때문에 최대 12분의 지연은 감당 가능한 수준입니다. 하지만 광고 성과 데이터가 아닌 특정 광고 메타데이터는 수 초 이내의 실시간성을 요구하는데, 이러한 메타데이터를 StarRocks로 함께 서빙할 경우 문제가 생깁니다.
광고 서버에서 StarRocks를 처음 도입했을 때는 캠페인별 성과 데이터를 캠페인 메타데이터와 조인한 MV를 만들어두고, 특정 캠페인에 대한 성과 데이터를 조회할 때 이 MV를 조회하는 방식으로 구현했습니다. 여기에는 두 가지 이유가 있었습니다.
퀸잇 셀러 어드민의 AI 광고 성과 페이지에서는 개별 캠페인의 성과뿐만 아니라 진행 상태, on/off 여부, 일일 예산 등의 메타데이터도 함께 볼 수 있도록 제공하고 있습니다. 따라서 어드민에서 제공하는 모든 데이터를 하나의 MV로 말아두면 그만큼 애플리케이션 코드가 단순해지고 유지보수가 쉬워집니다.
특정 메타데이터 필드에 대한 필터 및 정렬 기능을 제공할 수 있습니다.
그러나 첫 배포 후 위의 메타데이터들이 잘못 표시되는 것 같다는 셀러분들의 VOC가 들어왔고, 조사 결과 StarRocks의 동기화 지연이 원인인 것으로 드러났습니다. 진행 상태와 on/off 여부와 같은 데이터는 셀러분들이 지연에 매우 민감하게 반응하는 정보이기 때문에 실시간성이 더 중요했습니다. 따라서 지연을 허용할 수 있는 데이터는 기존대로 StarRocks에서, 실시간성이 중요한 데이터는 MySQL에서 조회하고, 각 데이터 소스에서 조회한 데이터를 애플리케이션에서 조합하여 셀러 어드민에 서빙하는 방식으로 변경했습니다. 이러한 변경의 대가로 애플리케이션 코드가 약간 복잡해졌고, MySQL에서 가져오는 필드들에 대한 필터 및 정렬 기능을 삭제해야 했습니다. 하지만 코드 복잡도는 허용 가능한 수준이었고 특정 필드의 필터 및 정렬 기능은 삭제해도 비즈니스적으로 문제가 없는 것을 확인하여 부담없이 삭제할 수 있었습니다.
MV 파티셔닝 관련 이슈
StarRocks에서 MV를 만들 때 파티셔닝을 적용하는 것은 성능 최적화에 압도적으로 중요한 부분입니다. 그런데 파티셔닝을 적용한 MV를 생성하기 위해 DDL을 작성하여 실행하는 과정에서 MV 생성이 실패하는 케이스가 자주 발생했습니다. 공식 문서에 따르면 파티셔닝을 적용하는 방법은 앞선 예시의 PARTITION BY date_trunc('day', created_at_kst) 와 같이 베이스 테이블의 파티션 키와 동일한 컬럼을 파티션 키로 지정하는 것입니다. 그러나 실제로는 해당 구문을 사용하여 MV 생성을 시도해도 아래와 같은 에러가 발생하면서 실패하는 경우가 있었습니다.
[42000][1064] Getting analyzing error. Detail message: Materialized view partition column in partition exp must be base table partition column.
위 에러 메시지는 "MV의 파티션 키는 베이스 테이블의 파티션 키와 동일해야 한다"는 원론적인 가이드만 줄 뿐, 실제 실패 원인에 대한 어떤 정보도 주지 않습니다. 따라서 문제 원인을 디버깅하는 것이 매우 까다로웠습니다. 저희가 원인을 찾아낸 실패 케이스는 다음 세 가지였습니다.
Iceberg catalog의 테이블을 베이스로 MV를 만드는 경우: DDL에서
GROUP BY절에 파티션 키 컬럼을 그대로 적으면 실패합니다.GROUP BY date_trunc('day', created_at_kst)와 같이date_trunc함수를 반드시 써야 합니다.StarRocks FE 노드에서 어떠한 이유로 메타데이터 갱신에 실패하는 경우: DDL을 올바르게 작성해도 실행이 항상 실패합니다. 이 경우 FE 노드를 재시작해야 합니다.
특정 MV를
INACTIVE상태로 변경했다가 다시ACTIVE로 되돌린 후 이 MV를 베이스로 nested MV를 만드는 경우: 마찬가지로 DDL을 올바르게 작성해도 항상 실패합니다. 이미 존재하는 nested MV의 경우 상태가 자동으로INACTIVE로 바뀌며, 이를ACTIVE로 변경하려고 하면 동일한 에러가 발생합니다.
특히 3번의 경우 StarRocks 자체의 동작 문제로 판단되어, StarRocks에 직접 이슈를 제기했습니다 (starrocks#68479). 그리고 해결 전까지 해당 MV들을 partition 기반 incremental refresh 대신 full refresh로 운영하며 우회했습니다 (초반 데이터량이 full refresh로도 충분히 빠르게 갱신되는 수준이었기에 가능한 임시 방편이었습니다).
또한 MV 생성에 성공하였더라도 생성된 MV와 베이스 테이블 간 파티션 매핑이 되지 않는 케이스도 존재했습니다. 이 현상은 생성된 MV에 대해 아래 쿼리를 실행하여 확인했습니다.
SHOW MATERIALIZED VIEWS WHERE name = <mv_name>;
위 쿼리의 실행 결과에서 last_refresh_mv_refresh_partitions 컬럼이 마지막 MV 갱신 task에서 실제로 갱신된 파티션 목록을 보여줍니다. 이 값이 incremental refresh로 갱신되도록 의도된 파티션만 가리키면 파티션 매핑이 정상적으로 된 것이며, 그렇지 않다면 파티션 매핑이 되지 않은 것입니다. 저희가 확인한 이 문제의 원인은 위의 MV 생성 실패 케이스와 마찬가지로 StarRocks FE 노드의 문제였고, 마찬가지로 FE 노드 재시작 후 MV를 다시 생성하니 해결되었습니다. Incremental refresh가 동작하지 않는다는 것은 파티셔닝으로 얻을 수 있는 성능적 이득이 없다는 의미이므로 매우 중대한 문제이나, StarRocks 내에서 파티션 매핑 상태를 직접 보여주는 명령어를 제공하지 않는다는 점이나 공식 문서에서 이 문제에 대한 명확한 트러블슈팅 가이드가 존재하지 않는다는 점은 아쉬웠습니다.
타임존: UTC 적재와 KST 파티셔닝
광고 성과 데이터는 'KST 기준 일자별'로 조회되는 경우가 많습니다. 그런데 원천 데이터의 시간 컬럼은 UTC로 저장되어 있었습니다. 만약 MV의 파티션 키를 UTC 시간 그대로 잡으면, KST 기준과 파티션 경계가 어긋나 조회 효율이 떨어집니다.
그래서 저희는 원천을 Iceberg에 UTC로 적재하되, KST로 변환한 별도의 시간 컬럼(_kst 접미사)을 만들어 이 컬럼을 파티션 키로 사용했습니다. 다만, CDC로 Iceberg v2 테이블에 UPSERT(equality delete) 방식으로 적재하다 보니 파티션 필드의 소스 컬럼이 equality field(=PK)에 포함되어야 한다는 제약이 있었습니다. 따라서 KST 파티션 컬럼을 도입할 때는 이 컬럼을 PK에도 함께 넣어주었습니다.
마무리
이 글은 한 가지 문제에서 출발했습니다. 셀러분들에게 제공하는 광고 성과 데이터가 안정적이고 정확해야 한다. 기존 MySQL 기반 시스템은 확장성, DB 부하, 집계 정확성, 장애 복구 어느 측면에서도 이 요구를 채워주지 못했고, 저희는 OLAP DB 도입이라는 방향으로 한계를 풀어보기로 했습니다. 원천을 Iceberg/Glue 위에 두고 StarRocks를 그 위의 query layer로 올리는 external table 전략, shared-data에서 shared-nothing으로의 아키텍처 전환, MV의 파티셔닝·버켓팅·colocate 설계를 거치며 광고 서버 DB의 쓰기 부하가 격리되었고, 성과 데이터 정확성에 대한 VOC가 사라졌으며, 장애 대응의 피로도가 눈에 띄게 줄었습니다.
이 과정을 거치면서 크게 느낀 점이 두 가지 있습니다.
OLAP 엔진은 "쓰는" 것이 아니라 "설계하는" 것입니다.
StarRocks를 도입한 것 자체로 문제가 해결되지는 않았습니다. 파티션 키를 어떻게 잡을지, 버켓팅 키를 무엇으로 둘지, colocation group을 어떻게 구성할지, MV의 차수를 어디까지 허용할지. 이런 결정 하나하나가 incremental refresh 성능과 조인 성능, 운영 안정성에 그대로 직결되었습니다. 엔진의 성능을 끌어내려면 워크로드와 데이터의 모양을 먼저 이해하고 그 위에서 최적화 전략을 깊이 고민해야 합니다. "널리 쓰이는 검증된 엔진"을 골랐다는 사실만으로는 어떤 성능도 자동으로 따라오지 않습니다.
널리 쓰이는 기술이라고 100% 신뢰할 수는 없습니다.
StarRocks–Glue 연동에서 만난 MV 파티셔닝 실패와 파티션 매핑 누락, ClickHouse의 timestamp 컬럼 + glue catalog 버그, shared-data 구조에서의 예상 밖 S3 비용. 모두 공식 문서만 읽어서는 알 수 없었던 이슈들이었습니다. 결국 FE 노드의 메타데이터 동작을 직접 들여다보고 GitHub 이슈 트래커를 뒤지고, 필요할 때는 저희가 직접 이슈를 제기하면서 풀어갈 수밖에 없었습니다. 기술을 도입한다는 건 결국 그 기술에 deep dive할 준비가 되어 있다는 뜻이기도 합니다.
긴 글 읽어주셔서 감사합니다.
부록: 커서 기반 집계에서 데이터가 누락되는 경우
앞에서 언급한 커서 기반 누락은, 원천 테이블의 ID 정렬 순서와 DB 커밋 순서가 항상 같다는 가정이 깨질 때 일어납니다. ad_interaction_raw는 유저의 광고 노출과 클릭이 발생할 때마다 row가 추가되는 테이블이고, 광고 서버의 여러 스레드가 동시에 여기에 씁니다. 그리고 이 테이블의 id는 DB가 순차 증가로 발급하는 값이 아니라, 애플리케이션에서 만든 순증 ULID를 UUID 형식으로 변환한 값입니다. 즉 id의 대소 관계는 대체로 ID가 생성된 시각을 반영할 뿐, 그 row가 DB에 커밋된 순서까지 보장해주지는 않습니다.
시나리오를 하나 만들어 보겠습니다. 배치의 현재 커서가 018f3a00이라고 하죠. 실제 id는 UUID지만 여기서는 정렬 관계만 보기 위해 8자리 hex로 줄여 표기하겠습니다. Thread A가 먼저 광고 노출 이벤트를 받아 018f3a10을 만들고 INSERT 트랜잭션을 시작합니다. 그 직후 Thread B가 또 다른 노출 이벤트로 018f3a40을 생성합니다. ULID는 시간순으로 증가하므로 018f3a10 < 018f3a40입니다. 그런데 Thread A의 트랜잭션이 다른 처리나 DB 대기로 늦어지는 사이, Thread B의 트랜잭션이 먼저 커밋되어 버립니다.
이 시점에 집계 배치가 돌아 WHERE id > '018f3a00' ORDER BY id LIMIT ... 로 원천을 조회하면, 아직 커밋되지 않은 Thread A의 row는 보이지 않고 Thread B의 row만 잡힙니다. 배치는 마지막으로 본 ID인 018f3a40을 새 커서로 저장합니다. 이후 Thread A가 뒤늦게 커밋되어도 그 row의 ID는 018f3a10, 이미 저장된 커서 018f3a40보다 작습니다. 다음 배치부터는 WHERE id > '018f3a40' 조건으로 조회하기 때문에, Thread A의 row는 더 이상 조회 대상에 들어오지 않습니다.
요컨대 ID가 "나중에 생성된 값일수록 크다"는 성질과 "나중에 커밋된 row일수록 크다"는 성질은 같지 않습니다. 커서 기반 증분 집계가 필요로 하는 건 후자인데, 위 구조에서 커서로 쓰던 값은 전자였습니다. 트래픽이 적고 트랜잭션이 거의 즉시 커밋되는 환경에서는 이 차이가 잘 드러나지 않습니다. 하지만 실시간으로 row가 쏟아지고 여러 스레드가 동시에 쓰는 환경이라면 언젠가 한 번은 터질 수밖에 없는 경합이었습니다.