[Datamates] Superset 기반 데이터 분석 기능 통합

분석 기능 통합 개요

dbt 기반 데이터 플랫폼 콘솔에 데이터 분석 기능을 넣는다. 시각화 엔진은 Apache Superset 을 쓰되, 사용자에게는 두 번째 도구로 보이지 않게 한다. 메뉴가 하나 늘고, 데이터 모델 화면에서 분석으로 이어지고, 접속 주소는 여전히 하나다.

BI 도구를 얹는 흔한 방식은 Superset 을 별도 포트로 띄우고 링크를 거는 것이다. 그러면 사용자는 로그인을 두 번 하고, 카탈로그를 두 벌 관리하게 되고, 「이 모델을 고치면 어떤 대시보드가 깨지는가」에 아무도 답하지 못하게 된다. 이 설계서는 그 세 가지를 각각 프록시 · 자동 동기화 · 매핑 테이블로 막는다.

1.1 사용자 및 권한 전제

플랫폼은 현재 사용자별 신원·권한을 다루지 않는다. 이 전제가 설계를 크게 단순하게 만든다. Superset 은 서비스 계정 하나로만 동작하고, 게스트 토큰에 RLS 규칙을 넣지 않으며, Explore 프록시는 그 계정의 세션을 그대로 재사용한다. 사용자별 세션 위임이 없으므로 프록시 방식이 성립한다 — 권한 요구가 생기는 순간 이 설계는 다시 열어야 한다.

1.2 Superset 연동 제약

Superset 은 SQLAlchemy 드라이버로만 데이터베이스에 붙는다. 이것이 유일한 하드 제약이고, 나머지 선택지는 전부 이 제약을 어떻게 만족시키느냐의 문제다. 그래서 쿼리는 단일안으로 정하지 않고 후보 네 개를 부품이 적은 순서로 세워 P0 에서 실제로 붙여 검증했다. 결론부터 쓰면 — 새 쿼리 엔진을 하나도 추가하지 않는 1번 후보가 다섯 항목 전부를 통과했다.

본문 내 예시 명칭

모델명(stg_event · mart_daily_agg), 컬럼명(event_cd · user_nm), 대시보드 이름(처리율 현황 · 월별 사용자 추이)은 구조를 보여주기 위한 예시로 바꾼 것이다. 컨테이너 · 설정 키 · 측정값 · 파일 경로는 실제 값이다.

주요 설계 결정

Superset 을 붙일 때 실제로 갈리는 지점은 일곱 개다. 나머지는 전부 여기서 따라 나온다. 각 결정의 근거는 3장 이후에서 다시 편다.

결정 지점 택한 것 이유
쿼리 새 엔진을 넣지 않는다 — Superset 안의 DuckDB 가 Iceberg REST 카탈로그를 직접 ATTACH 드라이버가 duckdb-engine 이면 SQLAlchemy 제약을 만족한다. DuckDB 는 ATTACH … TYPE ICEBERG 를 공식 지원하므로 dbt 가 쓰는 그 카탈로그를 그대로 본다.
기존 DuckDB 경로 그대로 미리보기·실행 통계는 계속 플랫폼 프로세스 안의 DuckDB 로 읽는다(0.04초). 분석 화면은 Superset 프로세스 안의 별개 인스턴스를 쓴다 — 같은 라이브러리, 같은 카탈로그, 다른 인스턴스.
접속 초기화 superset_config.py 에서 접속마다 ATTACH 실행 DuckDB 의 ATTACH 는 세션을 다시 열면 사라지고, Superset 공식 문서에는 초기화 SQL 을 넣는 방법이 없다. 이 설계에서 직접 만들어야 하는 유일한 배관이다.
노출 경계 FastAPI 가 /superset/* 를 리버스 프록시 Superset 컨테이너는 호스트 포트를 열지 않는다. 접속 주소는 localhost:8000 하나뿐이고, 같은 오리진이 되므로 iframe · 쿠키 · X-Frame-Options 문제가 동시에 사라진다.
대시보드 열람 Embedded SDK + 게스트 토큰 공식 지원 경로. 플랫폼 서버가 서비스 계정으로 /api/v1/security/guest_token/ 를 호출해 토큰을 발급하고, SDK 가 그 토큰으로 대시보드만 그린다.
차트 제작 (Explore) 프록시 + standalone iframe SDK 는 대시보드만 임베드한다 — Explore 는 공식 경로가 없다. 같은 오리진에 두고 네비게이션을 없앤다. 이 부분만 비공식이라 리스크 5로 관리한다.
데이터셋 정의 플랫폼이 dbt manifest 에서 자동 생성 사용자는 Superset 데이터셋을 직접 만들지 않는다. 이 한 줄이 「관계를 따로 저장하는 곳이 없다」는 플랫폼 원칙을 지킨다.

전체 아키텍처

추가되는 쿼리 엔진이 없다. 양쪽 모두 DuckDB 가 같은 Iceberg REST 카탈로그를 ATTACH 한다 — 다른 점은 어느 프로세스 안에서 도느냐뿐이다. 그래서 이 그림에서 새로 만들어야 하는 것은 초록 상자 안의 한 줄, 접속마다 ATTACH 를 실행하는 초기화 훅뿐이다. 사용자 브라우저에서 Superset 으로 가는 화살표가 없다는 점도 그대로다 — 모든 요청이 FastAPI 를 지나므로 Superset 은 주소도 포트도 갖지 않는다.

도식 설명: 브라우저는 localhost:8000 한 곳만 접속하고, FastAPI 가 UI·기존 API·분석 API·Superset 프록시 네 경로를 처리한다. 플랫폼과 Superset 이 각각 자기 프로세스 안의 DuckDB 인스턴스로 같은 Iceberg REST 카탈로그를 ATTACH 해서 읽는다. 새 쿼리 엔진은 없고, 새로 만들어야 하는 것은 접속마다 ATTACH 를 실행하는 초기화 훅뿐이다.

3.1 컨테이너 구성

서비스 역할 호스트 포트 구분
MinIO 오브젝트 스토리지 9000 · 9001 기존
Iceberg REST 테이블 카탈로그 8181 기존
Airflow 파이프라인 실행 8080 계열 기존
Superset 시각화 · 차트 정의 · 쿼리 (내장 DuckDB) 없음 (내부) 신규
Postgres Superset 메타데이터 전용 없음 (내부) 신규
Redis Superset 캐시 · 큐 없음 (내부) 신규

신규 셋 모두 호스트 포트를 바인딩하지 않는다. 「별도 서비스처럼 노출하지 않는다」를 문서상의 약속이 아니라 compose 파일의 사실로 만드는 것이 핵심이다. Superset 관리 화면이 필요할 때만 docker compose port 로 임시 포워딩한다. Postgres 는 메타데이터만 담는다 — 분석 쿼리를 같은 인스턴스에 태우면 무거운 집계가 메타DB 응답을 잡아먹는다.

3.2 쿼리 비교

SQLAlchemy 제약을 만족하는 방법이 네 가지 있고, 부품이 적은 순서가 곧 검증 순서였다. 앞의 것이 되면 뒤의 것은 필요 없다.

순위 경로 추가 부품 미검증 지점
1 채택 Superset → duckdb-engine → Iceberg REST
DuckDB 가 카탈로그를 직접 ATTACH
없음 접속마다 ATTACH 주입 · 워커별 인스턴스의 동시성 · 스냅샷 신선도
2 Superset → Quack → DuckDB 서버 → Iceberg REST
DuckDB 인스턴스를 하나로 공유
DuckDB 서버 1 Quack 이 beta (v1.5.3) · 프로토콜·함수명 변경 예고 · duckdb-engine 경유 ATTACH
3 Superset → pg_duckdb → Iceberg
Postgres 프로토콜로 붙고 DuckDB 가 실행
Postgres 1 (메타DB와 별개) REST 카탈로그를 Postgres 스키마·테이블로 노출하는 방법 — 문서에 경로 기반 iceberg_scan() 예시만 있다
4 Superset → Trino → Iceberg REST
가장 정석적
Trino 1 없음 — 대신 컨테이너와 운영 부담이 가장 크다

1번이 1순위인 이유는 단순함만이 아니다. 플랫폼과 분석이 같은 DuckDB 라이브러리를 쓰므로 엔진 간 값 불일치(리스크 2)가 가장 작아진다. 다만 없어지지는 않는다 — 버전 스큐, TimeZone 같은 세션 설정, extension 버전이 남는다.

4번은 폐기가 아니라 후순위다. 동시 사용자와 데이터 규모가 커지면 Trino 가 정답이 되는 지점이 온다. 지금 넣지 않는 것은 그 지점이 아직 오지 않았기 때문이고, 그 판단 시점은 7장의 측정값으로 정한다.

DuckDB 접속 초기화와 ATTACH

DuckDB 의 ATTACH 는 세션을 다시 열면 유지되지 않는다. 그리고 Superset 공식 DuckDB 문서에는 URI 형식만 있고 초기화 SQL · connect args · extension preload 를 넣는 방법이 없다. 이 공백을 메우는 것이 1번 후보의 유일한 구현 과제였다.

superset_config.py 가 임의 파이썬이라는 점을 이용해 SQLAlchemy 의 connect 이벤트를 전역 등록한다. 메타DB(Postgres) 접속까지 걸리지 않도록 드라이버를 판별하는 가드가 필수다.

# superset_config.py — 접속마다 도는 초기화 훅 (골격)
from sqlalchemy import event
from sqlalchemy.engine import Engine

@event.listens_for(Engine, "connect")
def _attach_iceberg(conn, rec):
    # 가드 — 클래스 이름이 아니라 모듈로 판별한다 (아래 카드 참고)
    if type(conn).__module__.split(".")[0] != "duckdb_engine":
        return

    cur = conn.cursor()
    for stmt in (
        "INSTALL iceberg", "LOAD iceberg",
        # 파라미터 바인딩을 쓰지 않는다 — 값은 전부 환경변수, 사용자 입력이 아니다
        f"CREATE OR REPLACE SECRET s3 (TYPE s3, KEY_ID '{KEY}', SECRET '{SEC}', ENDPOINT '{EP}')",
        f"ATTACH IF NOT EXISTS '{CATALOG}' AS ice (TYPE ICEBERG, ENDPOINT '{REST}')",
        f"SET TimeZone = '{TZ}'",            # 양쪽을 Asia/Seoul 로 명시 고정
    ):
        cur.execute(stmt)

    # 컬럼을 해석시키는 한 번의 터치 — 이게 없으면 데이터셋이 껍데기가 된다
    for t in resolve_tables():
        cur.execute(f"DESCRIBE ice.analytics.{t}")

    cur.execute("USE ice.analytics")   # 빼면 스키마 목록에 카탈로그가 안 나온다

문장은 전부 멱등이어야 한다 — 커넥션마다 다시 돌기 때문이다. 파라미터 바인딩을 쓰지 않는 것은 ConnectionWrapper 를 거치면 ? 처리 경로가 하나 더 끼어 실패 지점이 늘어나서다. 안정화 이후에는 duckdb-engine dialect 를 얇게 상속해 on_connect() 를 오버라이드하면 URI 가 duckdb+datamates:///:memory: 로 깔끔해진다 — SQLAlchemy 가 per-connection 초기화용으로 만든 정식 훅이다. 지금은 전역 이벤트로 충분히 동작한다.

P0 실측 주요 확인 사항

① 가드 판별값. 직관적인 추측이 틀린다. duckdb-engine 이 넘기는 DBAPI 객체는 duckdb.DuckDBPyConnection 이 아니라 duckdb_engine.ConnectionWrapper 다. 클래스 이름으로 거르면 훅이 그냥 건너뛰고 — 예외도 로그도 없이 — 카탈로그 테이블이 0개가 된다. 증상만 보면 ATTACH 문법 문제로 보여 엉뚱한 데를 파게 된다. 확정값은 type(conn).__module__.split(".")[0] == "duckdb_engine".

② DESCRIBE 가 필요하다. ATTACH 직후 Iceberg 테이블의 컬럼은 아직 해석되지 않은 상태여서 information_schema 에 __ / UNKNOWN 컬럼 하나만 들어 있다. Superset 은 SQLAlchemy reflection 으로 데이터셋 컬럼을 잡으므로, 이 상태로 만들면 컬럼이 하나뿐인 껍데기가 된다. 시간 컬럼이 없으니 시계열 차트도 못 만든다 — 쿼리는 되는데 차트를 못 만드는 상태다.

테이블 10개 기준 비용은 사실상 같았다 — SELECT … LIMIT 0 109ms · DESCRIBE 105ms · memory 카탈로그에 뷰 만들기 113ms. DESCRIBE 를 택한 이유는 속도가 아니라 카탈로그가 하나로 유지된다는 점이다. 뷰 방식은 memory.analytics 가 ice.analytics 를 가리는 두 번째 네임스페이스를 만들어 모델 변경 시 갱신 책임이 생기고, Superset 의 selectStar 도 실제 테이블을 가리키지 않는다.

부수적으로 확인된 것 하나 — Superset 이 쓰는 스키마 문자열은 ice.analytics 다 (analytics 가 아니다). Superset 의 DuckDB 스펙이 스키마를 「카탈로그.스키마」로 다룬다. 데이터셋 생성과 5장의 동기화 코드 모두 이 문자열을 써야 한다.

데이터셋 및 카탈로그 연동

차트가 수십 개로 늘어난 뒤에도 「이 모델을 고치면 무엇이 깨지는가」에 조회 한 번으로 답할 수 있는가?

BI 도구를 붙일 때 가장 조용히 망가지는 것이 이것이다. Superset 은 자기 데이터셋 정의를 갖는다. 사람이 그걸 손으로 만들기 시작하면, 플랫폼이 내세운 「관계를 따로 저장하는 곳이 없다」가 그 순간 거짓이 된다.

초록은 한 방향으로만 흐른다 — 데이터셋은 만들어지는 것이지 작성되는 것이 아니다. 매핑을 메타스토어에 남기기 때문에 인디고 화살표가 성립한다: 차트에서 모델까지 거슬러 올라갈 수 있고, 그래서 이 모델을 고치면 어떤 대시보드가 깨지는가 에 조회 한 번으로 답한다. 매핑을 남기지 않으면 Superset 은 그냥 블랙박스가 된다.

도식 설명: dbt manifest 를 원본으로 플랫폼이 Superset 데이터셋을 자동 생성하고, 그 매핑을 메타스토어에 저장한다. 사람이 Superset 에서 직접 데이터셋을 만드는 경로는 막는다. 저장된 매핑 덕분에 차트에서 모델로 거슬러 올라갈 수 있다.

5.1 데이터셋 동기화 규칙

플랫폼에서 일어난 일 Superset 에 하는 일
모델 추가 데이터셋 생성 — 물리 위치를 스키마.테이블 로 매핑
모델 SQL 저장 (컬럼 변동) 데이터셋 컬럼 refresh
컬럼 한글 라벨 존재 컬럼 verbose_name 에 주입 — 차트 축·범례가 한글로 나온다
모델 설명 존재 데이터셋 description 에 주입
모델 삭제 데이터셋 삭제 — 딸린 차트가 있으면 삭제 대신 경고 후 보류
레이어 (원천 / 정제 / 분석용) 데이터셋 태그 — Explore 목록에서 필터로 쓴다

verbose_name 주입이 체감상 가장 크다. 카탈로그에 이미 user_nm → 사용자명 같은 라벨이 있으니, 그것만 넘겨주면 Superset 차트에 영문 컬럼명이 노출되지 않는다. 사용자가 다른 도구를 쓴다고 느끼게 만드는 원인의 절반은 이런 표기 불일치다.

5.2 메타스토어 확장

store.py 의 SCHEMA 문자열에 두 개를 더한다. CREATE TABLE IF NOT EXISTS 가산 방식이 이미 관례로 잡혀 있어 마이그레이션 절차가 따로 없다.

테이블 컬럼 쓰임
superset_dataset model_id · dataset_id · uuid · synced_at · state 모델 ↔ 데이터셋 1:1 매핑. 동기화 멱등성의 기준.
superset_usage chart_id · dataset_id · dashboard_id · cols · seen_at 어느 차트가 어느 컬럼을 쓰는지. 「분석 사용처」 탭과 영향 범위의 근거.

superset_usage 는 Superset 메타데이터를 주기적으로 읽어 채우는 캐시다 — 원본이 아니다. 비어 있어도 기능이 죽지 않고 표시만 빠지게 설계한다.

Superset 임베딩 및 화면 구성

공식 SDK 는 대시보드만 임베드한다. 차트를 만드는 Explore 화면은 지원 대상이 아니다. 이 비대칭을 인정하고 화면별로 다른 경로를 쓰는 것이, 하나로 통일하려다 둘 다 어색해지는 것보다 낫다.

차이는 무엇이 경계를 넘느냐다. 왼쪽은 토큰이 넘어가고 사용자 세션은 만들어지지 않는다 — 그래서 공식적으로 안전하다. 오른쪽은 서비스 계정 세션이 그대로 프록시를 통과한다. 권한이 없다는 전제 덕에 이게 성립하지만, 안정성 보장이 없으므로 화면 두 개에만 국한해 쓴다.

도식 설명: 대시보드 열람은 플랫폼 서버가 게스트 토큰을 발급하고 Embedded SDK 가 그 토큰으로 iframe 을 띄우는 공식 경로다. 차트 제작은 게스트 토큰을 쓸 수 없어 서비스 계정 세션을 프록시가 그대로 전달하고 standalone 파라미터로 Superset 화면 요소를 숨기는 비공식 경로다.

6.1 Superset 임베딩 설정

설정 값 이유
FEATURE_FLAGS.EMBEDDED_SUPERSET True SDK 와 게스트 토큰 엔드포인트를 켠다. 5.x 기본값은 False.
GUEST_TOKEN_JWT_SECRET env 토큰 서명 키. 플랫폼 서버와 공유.
GUEST_TOKEN_JWT_EXP_SECONDS 300 기본값 유지. SDK 가 자동 갱신한다.
DISABLE_EMBEDDED_SUPERSET_LOGOUT True 임베드 화면에 로그아웃 버튼이 나오면 정체가 드러난다.
ENABLE_PROXY_FIX True 프록시 뒤에서 리다이렉트 URL 을 바로 생성하게 한다.
SUPERSET_WEBSERVER_BASEURL /superset 정적 자산과 링크가 프록시 경로를 기준으로 나오게 한다.
HTTP_HEADERS {} 같은 오리진이면 불필요하지만, 프록시 경로가 달라질 경우의 대비.

프록시가 실제로 해야 하는 일은 넷이다 — 경로 재작성(/superset/* ↔ 내부 superset:8088/*, 정적 자산 경로가 가장 많이 깨진다), 서비스 계정 세션 유지(첫 요청에서 로그인하고 쿠키를 서버 쪽에 보관 — 브라우저에 Superset 쿠키를 내보내지 않는다), HTML 응답에만 <style> 주입(로고·네비·푸터 숨김, JSON·자산 응답은 손대지 않는다), 그리고 비동기 쿼리를 쓸 경우의 WebSocket 통과.

6.2 분석 화면 구성

메뉴는 하나만 늘린다. 전역 사이드바는 없다 — v4.6 에서 헤더의 「현재 메뉴 ▾」 드롭다운(GNB)으로 접었고, .shell 첫 칸에는 빈 자리표시자만 남아 있다. 그래서 메뉴 추가는 화면 골격을 건드리지 않는다.

  • 데이터 분석 (목록) — 플랫폼이 전부 그린다. Superset API 는 목록과 썸네일만 준다.
  • 대시보드 보기 — 헤더·툴바는 플랫폼, 본문만 Embedded SDK 로 그린다.
  • 차트 만들기 — 왼쪽 레일은 화면 안의 모델 목록, 본문은 Explore iframe.
  • 모델 상세의 새 탭 — 기존 탭 줄에 「분석 사용처」 하나를 더한다. 매핑 테이블의 회수 지점.

화면 A — 데이터 분석 (목록)

플랫폼이 전부 그린다 · Superset API 는 목록과 썸네일만

드롭다운 항목은 MENUS.filter(x => R().menus.includes(x.id)) 로 그려지므로 네비게이션 코드는 한 줄도 손대지 않는다 — MENUS 와 CAPS.menus 에 항목을 넣으면 아이콘·체크·배지까지 기존 규칙대로 따라온다. 배지가 필요하면 navBadge() 에 분기를 더한다(품질·파이프라인이 쓰는 그 함수다). 분석은 초기에 배지를 붙이지 않는다 — 셀 만한 이상 상태가 없다.

카드의 모델 3개 가 이 화면의 핵심이다. Superset 목록에는 없는 정보이고, 5장의 매핑 테이블이 만들어낸 값이다. 누르면 데이터 모델 화면으로 넘어간다 — 분석과 카탈로그가 한 제품이라는 증거가 목록 단계부터 보인다.

화면 B — 대시보드 보기

헤더·툴바는 플랫폼 · 본문만 Embedded SDK

제목줄 아래 한 줄이 대시보드와 데이터 모델을 잇는다. 마지막 적재 시각을 여기 붙이는 것이 중요하다 — Superset 단독으로는 알 수 없고, 「이 숫자가 언제 것인가」는 대시보드를 보는 사람의 첫 질문이다.

화면 C — 차트 만들기

왼쪽 레일은 이 화면 안의 모델 목록(전역 메뉴 아님) · 본문은 Explore

왼쪽 레일이 Superset 의 데이터셋 선택기를 대체한다. 모델을 누르면 iframe 의 src 가 그 데이터셋의 Explore 주소로 바뀐다. 사용자는 데이터셋이라는 단어를 한 번도 보지 않고 평소 쓰는 모델 이름과 한글 컬럼명만 본다 — 5장의 verbose_name 주입이 여기서 회수된다.

화면 D — 데이터 모델 상세의 새 탭

기존 탭 줄에 하나 추가 · 매핑 테이블의 회수 지점

「이 데이터로 분석」 버튼이 분석 기능의 주 진입점이다. 플랫폼에는 이미 「모델 화면이 파이프라인 생성의 유일한 진입점」이라는 관례가 있다. 분석도 같은 관례를 따르면 사용자는 새 도구를 배우는 게 아니라 아는 화면에 버튼 하나가 늘었다고 느낀다. 경고 줄은 「이 모델을 고치면 뭐가 깨지는지 모른다」를 분석 영역까지 확장한 것이다.

6.3 구현 파일 및 변경 범위

경로 내용 구분
docker-compose.yml superset · superset-db · redis 추가 (호스트 포트 없음) 수정
docker/superset/superset_config.py 플래그 · 게스트 토큰 · DuckDB 접속 init 훅 (ATTACH) · 서비스 계정 신규
docker/superset/Dockerfile 베이스 이미지 + duckdb · duckdb-engine — 플랫폼과 같은 버전 핀 신규
app/analytics/client.py Superset REST 래퍼 — 로그인 · 게스트 토큰 · 데이터셋 · 차트 신규
app/analytics/sync.py manifest → 데이터셋 upsert · verbose_name 주입 신규
app/analytics/proxy.py /superset/* 리버스 프록시 · 세션 유지 · CSS 주입 신규
app/routers/analytics.py 목록 · 토큰 발급 · 사용처 조회 · 동기화 트리거 신규
app/store.py SCHEMA 에 테이블 2개 추가 + 접근 함수 수정
app/main.py 라우터 · 프록시 마운트 수정
ui/vendor/superset-sdk.js Embedded SDK UMD 번들 — 설치형이라 CDN 불가 신규
ui/js/b53.js 화면 A · B · C + MENUS · CAPS.menus 에 analytics splice 신규
ui/js/b54.js 화면 D — 모델 상세 「분석 사용처」 탭 · 진입 버튼 신규
ui/js/b01.js pageView() switch 에 case 한 줄 수정
ui/index.html <script> 세 줄 (벤더 번들 + b53 + b54) 수정
ui/js/b00.js · topbar · GNB 손대지 않는다 — 메뉴 등록만으로 드롭다운에 나온다 무변경

메뉴 등록은 b00.js 의 MENUS 리터럴을 고치지 않고 b53.js 에서 splice 한다 — api.js 가 「데이터 수집」을 넣을 때 쓴 방식과 같다. 결과적으로 기존 파일 중 손대는 것은 b01.js 의 한 줄과 index.html 의 세 줄뿐이다. 헤더 · GNB · topbar() · 레이아웃 골격은 건드리지 않는다.

P0 검증 결과 및 리스크

검증은 두 스크립트가 반복 실행 가능한 형태로 했다 — scripts/p0_duckdb_check.py(Superset 없이 쿼리만) 와 scripts/p0_superset_check.py(Superset REST API). 완료 조건은 화면이 아니라 확인 가능한 사실이다.

7.1 P0 검증 결과

항목 결과 값 · 비고
① 접속마다 ATTACH 통과 새 커넥션 5회 전부 카탈로그 인식. 가드가 sqlite 접속에는 걸리지 않음
② 테이블 · 컬럼 인식 통과 dbt 모델 10개 전부 · reflection 9컬럼 정확 · 시간 컬럼 event_ts · event_date 인식 — DESCRIBE 를 넣은 뒤에야
② Superset 데이터셋 통과 스키마 ice.analytics · 데이터셋 생성 · SQL Lab 결과가 플랫폼과 일치
③ 동시 10 쿼리 통과 오류 0건
③ 응답 시간 주의 warm 1.1ms / cold 단독 약 160ms / cold 동시 10 약 940ms
④ 스냅샷 신선도 통과 실제 적재(dbt build) 후 확인. 풀 반납·재사용 패턴은 최신 스냅샷을 본다
⑤ 값 정합 통과 리터럴 7종(timestamp · timestamptz · decimal · null · list · struct · map) + fct_events 5행 × 9컬럼 일치
⑤ 세션 설정 통과 양쪽 Asia/Seoul 고정. 플랫폼은 SET GLOBAL 이어야 한다

결론 — 후보 1로 간다. 추가 쿼리 엔진 없이 분석 기능이 선다. 되돌릴 사안은 없다. 다만 ③ 의 수치가 값지다. cold 와 warm 의 360배 차이는 커넥션 풀 재사용이 최적화가 아니라 전제라는 뜻이고, 동시 10 접속에서 940ms 까지 오르는 것은 Iceberg REST 카탈로그가 CATALOG_JDBC_POOL__MAX__SIZE=1 로 직렬화돼 있어서다 — 비용이 커넥션 × 테이블 로 늘고 그 요청이 한 줄로 선다. 모델이 늘면 여기가 먼저 아프다.

7.2 스냅샷 신선도 검증

설계 단계에서는 「DuckDB 가 스냅샷 메타데이터를 커넥션 수명 동안 캐시해서 대시보드가 낡은 값을 무한정 서브한다」고 봤다. P0 에서 실제 적재를 돌려 확인한 결과 그 전제가 틀렸다. 같은 적재 한 번으로 세 패턴을 비교했다.

A) 열린 트랜잭션 유지        → 낡음
B) commit 후 같은 커넥션     → 최신   (커넥션은 그대로, 트랜잭션만 끊음)
C) 풀 반납 후 재사용         → 최신   ← Superset 의 실제 패턴

Superset 은 쿼리마다 커넥션을 풀에 반납하고 SQLAlchemy 는 반납 시 rollback 하므로, 다시 꺼낸 커넥션은 최신 스냅샷을 본다. 별도 대응이 필요하지 않다. 지켜야 할 전제는 둘 — ① pool_reset_on_return 을 기본값(rollback)에서 바꾸지 않는다(바꾸면 A 가 된다), ② 트랜잭션을 열어둔 채 오래 붙잡는 코드를 만들지 않는다. 남는 낡음은 Superset 결과 캐시 (DATA_CACHE_CONFIG, 현재 60초)만큼이고 무한정이 아니라 경계가 있는 값이다.

부수 효과가 크다. 신선도 때문에 커넥션 수명을 짧게 할 이유가 없어졌으므로, 접속 초기화 비용과 신선도가 더 이상 상충하지 않는다 — 커넥션을 오래 유지하는 것이 성능·신선도 양쪽에서 옳다.

TimeZone 설정 기준

플랫폼의 모든 조회는 warehouse.py 의 cursor() 를 거치고, 그건 별도 커넥션이다. 세션 SET 은 전파되지 않는다(실측):

SET TimeZone='UTC'         → 루트 UTC       / cursor 는 그대로 Asia/Seoul
SET GLOBAL TimeZone='UTC'  → 루트 · cursor 모두 UTC  (기존 cursor 까지)

평범한 SET 을 넣으면 코드는 들어가지만 조회에는 반영되지 않는다 — 조용히 아무 일도 일어나지 않는 종류의 버그다. 양쪽 모두 Asia/Seoul 로 고정하고 환경변수 하나(DATAMATES_DUCKDB_TIMEZONE)로 묶었다. 검증 스크립트에 「호스트 로컬이 아니라 명시값으로 고정됨」 검사를 넣어 두었으므로, 한쪽만 바뀌거나 SET GLOBAL 이 SET 으로 되돌아가면 FAIL 로 잡힌다.

7.3 잔여 리스크

# 리스크 상태 대응
1 낡은 스냅샷 해소 원인이 캐시가 아니라 열린 트랜잭션이었고 Superset 의 패턴은 영향받지 않는다. 전제 두 가지만 지킨다 (7.2).
2 같은 테이블, 다른 숫자 잔존 양쪽 duckdb==1.5.5 로 핀 고정하고 세션 설정을 동일하게 맞춘다. P0 에서 실제 사례가 하나 나왔다 — 값은 전부 일치했지만 세션 TimeZone 이 갈렸다(맥 Asia/Seoul vs 컨테이너 UTC). 값이 같고 표기만 다르므로 테스트로는 안 잡히고 차트 축에서만 드러난다. 모델별 집계 회귀 테스트를 P3 완료 조건에 넣는다.
3 워커별 DuckDB 인스턴스 잔존 메타데이터·데이터 캐시가 공유되지 않는다. 실패 징후는 쿼리 수에 비례한 MinIO 트래픽 선형 증가 또는 워커 메모리 누적. 보이면 후보 2(Quack)로 인스턴스를 하나로 모은다.
4 Iceberg 카탈로그가 SQLite 관측됨 CATALOG_JDBC_POOL__MAX__SIZE=1 이라 카탈로그 요청이 한 줄로 선다. 동시 10 접속 × 테이블 10개 = 100건이 직렬화돼 초기화가 160ms → 940ms 로 늘었다. 이 값은 모델 수에 비례한다. 풀 재사용을 전제로 두고 초기화 대상 스키마를 DATAMATES_RESOLVE_SCHEMAS 로 좁히며, 부족하면 카탈로그를 Postgres 로 옮긴다.
5 Explore 임베드는 비공식 미검증 standalone 은 공식이지만 CSS 로 화면 요소를 숨기는 부분은 버전 업그레이드에서 깨진다. 주입 CSS 를 파일 하나로 격리하고 업그레이드 절차에 육안 확인 항목을 넣는다. 반복해 깨지면 화면 C 를 자체 차트 편집기로 대체하고 Superset 은 헤드리스 엔진으로만 쓴다.
6 이중 관리로의 회귀 잔존 누군가 Superset 관리 화면에서 직접 데이터셋이나 DB 연결을 만들면 5장의 설계가 무너진다. 서비스 계정 외 계정을 만들지 않고, 동기화 시 플랫폼이 만들지 않은 데이터셋을 미등록으로 표시해 화면 A 에 드러낸다. 조용히 생기는 것만 막으면 된다.

구현 단계 및 최종 구성

각 단계의 완료 조건은 화면이 아니라 확인 가능한 사실이다. 앞 단계가 사실로 확인되지 않으면 다음으로 넘어가지 않는다.

단계 내용 완료 조건
P0 완료 Superset → DuckDB → Iceberg REST 만 검증. UI 를 만들지 않는다. 임시 포트로 관리 화면을 열어 ①~⑤ 만 확인. 다섯 항목 전부 통과 = 쿼리 확정
P1 생략 실패 항목만 후보로 분기. ③ 실패는 후보 2(Quack), ①·② 실패는 후보 3·4, ④ 실패는 커넥션 수명·캐시 무효화로 먼저 대응. 추가 부품 0개로 통과했는지를 문서에 남긴다
P2 프록시와 메뉴 — 포트를 닫고 /superset/* 로만 접근. 메뉴 추가, 화면 A · B 배선. localhost:8000 만으로 대시보드 열람 · 8088 접근 불가
P3 데이터셋 자동 동기화 — manifest → 데이터셋 upsert, 매핑 테이블, 한글 라벨 주입. 손으로 만든 데이터셋 0개 · 차트 축이 한글 · 엔진 간 값 비교 테스트 통과
P4 차트 제작 화면 — 화면 C, Explore 임베드, 모델 레일, 「이 데이터로 분석」 진입점. 모델 화면에서 차트 저장까지 Superset 표기 노출 0회
P5 계보 회수 — 화면 D 의 「분석 사용처」 탭과 컬럼 영향 경고. 컬럼 이름을 바꿨을 때 깨질 차트가 저장 전에 표시된다
  • 새 쿼리 엔진을 넣지 않는다. Superset 안의 DuckDB 가 duckdb-engine 으로 붙어 dbt 가 쓰는 그 Iceberg REST 카탈로그를 직접 ATTACH 한다. Trino · pg_duckdb · Quack 은 폐기가 아니라 후순위다.
  • 직접 만드는 배관은 하나뿐이다 — 접속마다 도는 초기화 훅. 가드는 클래스가 아니라 모듈( duckdb_engine )로 판별하고, DESCRIBE 로 컬럼을 해석시키고, USE ice.analytics 로 끝낸다. 셋 중 하나라도 빠지면 조용히 실패한다.
  • Superset 은 주소도 포트도 갖지 않는다. FastAPI 가 /superset/* 를 프록시하고 컨테이너는 호스트 포트를 바인딩하지 않는다 — 문서상의 약속이 아니라 compose 파일의 사실로 만든다.
  • 대시보드는 공식 SDK, Explore 는 프록시다. 비대칭을 인정하고 화면별로 다른 경로를 쓴다. 비공식인 쪽은 화면 두 개에만 국한하고 리스크 5로 관리한다.
  • 데이터셋은 만들어지는 것이지 작성되는 것이 아니다. dbt manifest 가 유일한 원본이고, superset_dataset 매핑을 남기기 때문에 차트 → 데이터셋 → 모델 역추적이 성립한다.
  • 메뉴 하나, 화면 넷, 기존 파일은 네 줄. 손대는 것은 b01.js 한 줄과 index.html 세 줄뿐이고, 헤더 · GNB · 레이아웃 골격은 건드리지 않는다.
  • 커넥션 풀 재사용은 최적화가 아니라 전제다. warm 1.1ms 대 cold 940ms 의 360배 차이가 그 근거이고, 리스크 1이 해소되면서 이 전제를 신선도와 맞바꿀 필요도 없어졌다.
  • 남은 미검증 — Superset 플래그 이름·기본값은 버전마다 다르다(현재 5.0.0 확인). Explore 임베드는 P4 에서 처음 검증한다. Quack 은 beta 라 프로토콜 변경이 예고돼 있다.

Categories:

Updated:

Leave a comment