BI 도구를 데이터 웨어하우스에 연결하기

BI 도구를 데이터 웨어하우스에 연결하기

Boundary의 PostgreSQL 데이터베이스에는 내장 데이터 웨어하우스(wh_* 테이블)가 있어요. 이 웨어하우스는 세션, 연결, 인증 토큰, 사용자, 호스트, 크레덴셜의 전체 수명주기를 포착해요. 이 차원 모델(dimensional model)은 Boundary 이벤트가 발생할 때 데이터베이스 트리거를 통해 자동으로 채워져서, 외부 ETL 파이프라인 없이도 실시간 분석이 가능해요.

출처: HashiCorp Boundary docs

본문

이 가이드에서 다룰 내용은 다음과 같아요:

  • BI 도구용 안전한 읽기 전용 데이터베이스 사용자 만들기
  • 무료 BI 도구(Metabase를 주 예시로) 연결하기
  • 접근 검토, 세션 분석, 감사 보고에 검증된 쿼리 실행하기

이 가이드는 Boundary v0.21.0에서 검증됐어요.

1단계: 읽기 전용 데이터베이스 사용자 만들기

Boundary 데이터베이스에 연결된 PostgreSQL 클라이언트에서 다음 명령을 한 번 실행하세요.

-- Create the role
CREATE ROLE boundary_bi_readonly WITH LOGIN PASSWORD 'set-your-own-password-here';
GRANT CONNECT ON DATABASE boundary TO boundary_bi_readonly;
GRANT USAGE ON SCHEMA public TO boundary_bi_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO boundary_bi_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO boundary_bi_readonly;

-- Safety: prevent any writes
ALTER ROLE boundary_bi_readonly SET default_transaction_read_only = ON;

-- Optional: cap query runtime to avoid production impact
ALTER ROLE boundary_bi_readonly SET statement_timeout = '60s';

⚠️ 경고: BI 도구에 쓰기 권한을 절대 주지 마세요. Boundary 데이터베이스는 프로덕션 접근 프록시를 운영하는 곳이에요.

2단계: Metabase 연결하기(무료, 오픈소스)

Metabase는 노트북이나 서버에서 실행되는 무료 BI 도구예요. 여기에서 다운로드할 수 있어요.

2.1: 데이터베이스 추가

데이터베이스를 추가하려면 다음 단계를 따르세요.

  • Metabase를 시작해요(java -jar metabase.jar 또는 Docker: docker run -p 3000:3000 metabase/metabase).
  • Settings(기어 아이콘)로 이동해서 Admin settings를 클릭해요.
  • Databases를 클릭하고 Add database를 클릭해요.
  • 다음 필드를 작성해요:
    • Database type: PostgreSQL
    • Display name: Boundary Analytics
    • Host: Boundary 데이터베이스 호스트 (예: localhost 또는 Docker 컨테이너 이름)
    • Port: 데이터베이스 포트 (기본값 5432. boundary dev의 경우 출력의 Dev Database Url을 확인하세요)
    • Database name: boundary
    • Username: boundary_bi_readonly
    • Password: 1단계에서 설정한 비밀번호
  • Save를 클릭해요.

2.2: 연결 확인

Metabase에서 Browse Data로 이동해서 Boundary Analytics를 선택해요. wh_session_accumulating_fact, wh_user_dimension, wh_host_dimension 같은 테이블이 보일 거예요.

wh_date_dimension과 wh_time_of_day_dimension만 보인다면, 웨어하우스에 아직 세션이 기록되지 않은 상태예요. Boundary에서 활동을 만들어보고 다시 확인하세요.

3단계: 핵심 테이블

데이터 웨어하우스의 핵심 테이블 설명은 다음 표를 참고하세요.

테이블 설명
wh_session_accumulating_fact 모든 세션: 누가, 어떤 대상에, 언제, 얼마나 오래, 얼마나 많은 데이터
wh_session_connection_accumulating_fact 세션 안의 모든 연결: 클라이언트 IP, 업로드/다운로드 바이트
wh_auth_token_accumulating_fact 사용자 로그인 토큰: 발급 시간, 마지막 사용, 삭제 여부
wh_user_dimension 인증 방법, 이메일, 조직을 포함한 사용자
wh_host_dimension 전체 org→project→target 계층을 가진 대상
wh_credential_dimension 크레덴셜 라이브러리, 스토어, Vault 경로
wh_date_dimension 달력 날짜(미리 채워짐, 그룹화에 사용)
wh_time_of_day_dimension 하루의 초(미리 채워짐)

팁: 각 레코드의 최신 버전을 얻으려면 항상 current_row_indicator = 'Current'로 필터링하세요. 이 테이블들은 이력 추적(SCD Type 2)을 사용하므로, 오래된 버전이 현재 버전과 함께 존재해요.

4단계: 검증된 쿼리

Metabase의 SQL Editor(비주얼 빌더가 아닌 네이티브 쿼리 에디터)에 다음 쿼리들을 붙여 넣으세요.

4.1: 세션 활동 — 최근 30일

SELECT
    dd.date_actual AS session_date,
    wh.target_name,
    wh.project_name,
    COUNT(*) AS session_count
FROM wh_session_accumulating_fact wsf
JOIN wh_host_dimension wh ON wh.key = wsf.host_key
    AND wh.current_row_indicator = 'Current'
JOIN wh_date_dimension dd ON dd.key = wsf.session_pending_date_key
WHERE dd.date_actual >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY dd.date_actual, wh.target_name, wh.project_name
ORDER BY session_date DESC, session_count DESC;

Metabase에서: 꺾은선 차트로 시각화해요. X = session_date, Y = session_count, series = target_name.

4.2: 사용자 접근 명단(분기별 접근 검토)

SELECT
    u.user_name,
    u.auth_account_email,
    u.auth_method_type,
    u.user_organization_name,
    COUNT(DISTINCT s.session_id) AS sessions_last_90d,
    COUNT(DISTINCT h.target_id) AS unique_targets,
    MAX(s.session_pending_time) AS last_activity
FROM wh_user_dimension u
LEFT JOIN wh_session_accumulating_fact s ON u.key = s.user_key
LEFT JOIN wh_host_dimension h ON s.host_key = h.key AND h.current_row_indicator = 'Current'
WHERE u.current_row_indicator = 'Current'
  AND (s.session_pending_time >= CURRENT_DATE - INTERVAL '90 days' OR s.session_pending_time IS NULL)
GROUP BY u.user_name, u.auth_account_email, u.auth_method_type, u.user_organization_name
ORDER BY last_activity DESC NULLS LAST;

4.3: 대상별 세션 지속 시간

SELECT
    wh.target_name,
    COUNT(*) AS completed_sessions,
    ROUND(AVG(EXTRACT(EPOCH FROM (wsf.session_terminated_time - wsf.session_active_time)))::numeric, 1) AS avg_seconds,
    ROUND(MAX(EXTRACT(EPOCH FROM (wsf.session_terminated_time - wsf.session_active_time)))::numeric, 1) AS max_seconds
FROM wh_session_accumulating_fact wsf
JOIN wh_host_dimension wh ON wh.key = wsf.host_key AND wh.current_row_indicator = 'Current'
WHERE wsf.session_terminated_time <> 'infinity'
  AND wsf.session_active_time <> 'infinity'
  AND wsf.session_pending_time >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY wh.target_name
ORDER BY avg_seconds DESC;

4.4: 연결 감사 추적(소스 IP, 바이트)

SELECT
    u.user_name,
    h.target_name,
    c.connection_authorized_time,
    c.client_tcp_address,
    c.endpoint_tcp_address || ':' || c.endpoint_tcp_port_number AS endpoint,
    c.bytes_up,
    c.bytes_down
FROM wh_session_connection_accumulating_fact c
JOIN wh_user_dimension u ON c.user_key = u.key AND u.current_row_indicator = 'Current'
JOIN wh_host_dimension h ON c.host_key = h.key AND h.current_row_indicator = 'Current'
WHERE c.connection_authorized_time >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY c.connection_authorized_time DESC;

4.5: 대상 인벤토리 — 사용량이 있는 모든 대상

SELECT
    h.target_name,
    h.target_type,
    h.target_default_port_number,
    h.project_name,
    h.organization_name,
    COUNT(DISTINCT s.session_id) AS sessions_last_30d,
    MAX(s.session_pending_time) AS last_session
FROM wh_host_dimension h
LEFT JOIN wh_session_accumulating_fact s ON s.host_key = h.key
    AND s.session_pending_time >= CURRENT_DATE - INTERVAL '30 days'
WHERE h.current_row_indicator = 'Current'
  AND h.host_id <> 'None'  -- exclude the default placeholder record
GROUP BY h.target_name, h.target_type, h.target_default_port_number,
         h.project_name, h.organization_name
ORDER BY sessions_last_30d DESC;

4.6: 연결이 없는 세션(잠재적 인증 실패)

SELECT
    s.session_id,
    u.user_name,
    h.target_name,
    s.session_pending_time,
    s.session_terminated_time
FROM wh_session_accumulating_fact s
JOIN wh_user_dimension u ON s.user_key = u.key AND u.current_row_indicator = 'Current'
JOIN wh_host_dimension h ON s.host_key = h.key AND h.current_row_indicator = 'Current'
LEFT JOIN wh_session_connection_accumulating_fact c ON s.session_id = c.session_id
WHERE s.session_pending_time >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY s.session_id, u.user_name, h.target_name, s.session_pending_time, s.session_terminated_time
HAVING COUNT(c.connection_id) = 0
ORDER BY s.session_pending_time DESC;

4.7: 인증 토큰 사용 — 다중 IP 감지

SELECT
    u.user_name,
    a.auth_token_id,
    a.auth_token_issued_time,
    a.auth_token_approximate_last_access_time,
    array_agg(DISTINCT c.client_tcp_address::text) AS client_ips
FROM wh_auth_token_accumulating_fact a
JOIN wh_user_dimension u ON a.user_key = u.key AND u.current_row_indicator = 'Current'
JOIN wh_session_accumulating_fact s ON a.auth_token_id = s.auth_token_id
JOIN wh_session_connection_accumulating_fact c ON s.session_id = c.session_id
WHERE a.auth_token_issued_time >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY u.user_name, a.auth_token_id, a.auth_token_issued_time, a.auth_token_approximate_last_access_time
HAVING COUNT(DISTINCT c.client_tcp_address) > 1
ORDER BY array_length(array_agg(DISTINCT c.client_tcp_address::text), 1) DESC;

4.8: 컴플라이언스 — 완전한 감사 추적(SOC 2, SOX, PCI, HIPAA)

이 쿼리는 어떤 컴플라이언스 감사의 핵심 자원이에요. 누가, 무엇을, 언제, 어디서, 얼마나 많은 데이터가 이동했는지 — 감사자가 요구하는 모든 필드가 있는 모든 접근 이벤트를 반환하죠.

SELECT
    s.session_id,
    s.session_pending_time AS access_time,
    s.session_terminated_time AS end_time,
    CASE
        WHEN s.session_terminated_time = 'infinity' THEN NULL
        ELSE ROUND(EXTRACT(EPOCH FROM (s.session_terminated_time - s.session_pending_time))::numeric, 1)
    END AS duration_seconds,
    u.user_name,
    u.auth_account_email,
    u.auth_method_type,
    h.target_name,
    h.target_type,
    h.project_name,
    h.organization_name,
    c.client_tcp_address,
    c.endpoint_tcp_address,
    ROUND(COALESCE(c.bytes_up, 0) / 1024.0, 1) AS kb_uploaded,
    ROUND(COALESCE(c.bytes_down, 0) / 1024.0, 1) AS kb_downloaded,
    s.total_connection_count
FROM wh_session_accumulating_fact s
JOIN wh_user_dimension u ON s.user_key = u.key AND u.current_row_indicator = 'Current'
JOIN wh_host_dimension h ON s.host_key = h.key AND h.current_row_indicator = 'Current'
LEFT JOIN wh_session_connection_accumulating_fact c ON s.session_id = c.session_id
WHERE s.session_pending_time BETWEEN '2026-01-01' AND '2026-06-30'
ORDER BY s.session_pending_time DESC;

Metabase에서: 이걸 "Access Audit Trail"이라는 이름의 Question으로 저장하고 날짜 범위 필터를 추가해요. 감사자에게는 CSV로 내보내면 좋아요.

4.9: 컴플라이언스 — 비활성 및 고아 계정 감지

이 쿼리는 한 번도 사용되지 않았거나 90일 이상 사용되지 않은 계정을 찾아요. 분기별 접근 인증 검토의 핵심 요구사항이죠.

SELECT
    u.user_name,
    u.auth_account_email,
    u.auth_method_type,
    COUNT(DISTINCT s.session_id) AS total_sessions,
    COUNT(DISTINCT h.target_id) AS unique_targets,
    MAX(s.session_pending_time) AS last_activity,
    CASE
        WHEN MAX(s.session_pending_time) IS NULL THEN 'NEVER USED — deprovision'
        WHEN MAX(s.session_pending_time) < CURRENT_DATE - INTERVAL '90 days' THEN 'INACTIVE >90d — review'
        ELSE 'ACTIVE'
    END AS status
FROM wh_user_dimension u
LEFT JOIN wh_session_accumulating_fact s ON u.key = s.user_key
LEFT JOIN wh_host_dimension h ON s.host_key = h.key AND h.current_row_indicator = 'Current'
WHERE u.current_row_indicator = 'Current'
GROUP BY u.user_name, u.auth_account_email, u.auth_method_type
ORDER BY last_activity DESC NULLS LAST;

검토 주기: 이 쿼리를 분기별로 실행하세요. "NEVER USED" 계정은 프로비저닝을 해제하고, "INACTIVE >90d" 계정은 매니저 확인을 위해 플래그를 붙이세요.

4.10: 컴플라이언스 — 직무 분리(Separation of duties) 확인

이 쿼리는 프로덕션과 비프로덕션 환경에 모두 접근하는 사용자를 감지해요. 기본적인 직무 분리 위반이죠. ILIKE 패턴을 자신의 네이밍 규칙에 맞게 조정하세요.

WITH user_env AS (
    SELECT
        u.user_name,
        CASE
            WHEN h.target_name ILIKE '%prod%' THEN 'PROD'
            WHEN h.target_name ILIKE '%dev%' OR h.target_name ILIKE '%test%' THEN 'NON-PROD'
            ELSE 'OTHER'
        END AS env,
        COUNT(*) AS accesses
    FROM wh_session_accumulating_fact s
    JOIN wh_user_dimension u ON s.user_key = u.key AND u.current_row_indicator = 'Current'
    JOIN wh_host_dimension h ON s.host_key = h.key AND h.current_row_indicator = 'Current'
    WHERE s.session_pending_time >= CURRENT_DATE - INTERVAL '90 days'
    GROUP BY u.user_name, env
)
SELECT user_name, array_agg(DISTINCT env) AS environments, SUM(accesses) AS total
FROM user_env
GROUP BY user_name
HAVING 'PROD' = ANY(array_agg(env)) AND 'NON-PROD' = ANY(array_agg(env));

행이 반환되면 잠재적 위반이에요. 해당 사용자에 대해 프로덕션과 비프로덕션 접근이 승인된 것인지 검토하세요.

4.11: SecOps — 측면 이동(Lateral movement) 감지

이 쿼리는 짧은 시간 창 안에 여러 다른 대상에 접근하는 사용자를 찾아요. 침해 중 측면 이동의 강력한 지표이죠.

WITH rapid AS (
    SELECT
        u.user_name,
        COUNT(DISTINCT h.target_id) AS unique_targets,
        MIN(s.session_pending_time) AS first_access,
        MAX(s.session_pending_time) AS last_access,
        ROUND(EXTRACT(EPOCH FROM (MAX(s.session_pending_time) - MIN(s.session_pending_time))) / 60)::int AS minutes,
        array_agg(DISTINCT h.target_name ORDER BY h.target_name) AS targets
    FROM wh_session_accumulating_fact s
    JOIN wh_user_dimension u ON s.user_key = u.key AND u.current_row_indicator = 'Current'
    JOIN wh_host_dimension h ON s.host_key = h.key AND h.current_row_indicator = 'Current'
    WHERE s.session_pending_time >= CURRENT_DATE - INTERVAL '24 hours'
    GROUP BY u.user_name
)
SELECT * FROM rapid
WHERE unique_targets >= 3
ORDER BY unique_targets DESC;

트라이지: 24시간 안에 3개 이상 대상 → 조사. 1시간 안에 5개 이상 대상 → 사고 대응으로 에스컬레이션.

4.12: SecOps — 데이터 유출(Data exfiltration) 감지

이 쿼리는 각 사용자의 최근 연결 볼륨을 자신의 30일 기준선과 비교해요. 정상보다 3 표준편차 이상인 연결이 플래그 됩니다.

WITH baseline AS (
    SELECT user_key, AVG(bytes_down) AS avg_dn, STDDEV(bytes_down) AS std_dn
    FROM wh_session_connection_accumulating_fact
    WHERE connection_authorized_time >= CURRENT_DATE - INTERVAL '30 days'
    GROUP BY user_key
),
recent AS (
    SELECT c.connection_id, c.user_key, u.user_name, h.target_name,
           c.bytes_down, c.connection_authorized_time
    FROM wh_session_connection_accumulating_fact c
    JOIN wh_user_dimension u ON c.user_key = u.key AND u.current_row_indicator = 'Current'
    JOIN wh_host_dimension h ON c.host_key = h.key AND h.current_row_indicator = 'Current'
    WHERE c.connection_authorized_time >= CURRENT_DATE - INTERVAL '24 hours'
)
SELECT r.user_name, r.target_name,
       ROUND(r.bytes_down / 1024.0, 1) AS kb_downloaded,
       ROUND(b.avg_dn / 1024.0, 1) AS avg_kb,
       ROUND((r.bytes_down - b.avg_dn) / NULLIF(b.std_dn, 0), 1) AS std_devs
FROM recent r
JOIN baseline b ON r.user_key = b.user_key
WHERE r.bytes_down > (b.avg_dn + 3 * b.std_dn)
ORDER BY std_devs DESC;

알림 기준: 3 표준편차 이상 → 비정상, 조사. 5 표준편차 이상 → 심각, 즉시 에스컬레이션.

4.13: SecOps — 사고 타임라인 재구성

보안 사고를 조사할 때, 이 쿼리는 특정 사용자의 완전한 시간순 타임라인을 재구성해요. 'user'를 사용자 이름으로 바꾸고 날짜 범위를 조정하세요.

SELECT s.session_pending_time AS event_time, 'AUTHORIZED' AS event,
       u.user_name, h.target_name, NULL::text AS client_ip,
       NULL::double precision AS kb
FROM wh_session_accumulating_fact s
JOIN wh_user_dimension u ON s.user_key = u.key AND u.current_row_indicator = 'Current'
JOIN wh_host_dimension h ON s.host_key = h.key AND h.current_row_indicator = 'Current'
WHERE u.user_name = 'user'
  AND s.session_pending_time >= CURRENT_DATE - INTERVAL '7 days'

UNION ALL

SELECT c.connection_authorized_time, 'CONNECTED',
       u.user_name, h.target_name, c.client_tcp_address::text,
       (COALESCE(c.bytes_up, 0) + COALESCE(c.bytes_down, 0)) / 1024.0
FROM wh_session_connection_accumulating_fact c
JOIN wh_user_dimension u ON c.user_key = u.key AND u.current_row_indicator = 'Current'
JOIN wh_host_dimension h ON c.host_key = h.key AND h.current_row_indicator = 'Current'
WHERE u.user_name = 'user'
  AND c.connection_authorized_time >= CURRENT_DATE - INTERVAL '7 days'

UNION ALL

SELECT s.session_terminated_time, 'TERMINATED',
       u.user_name, h.target_name, NULL,
       COALESCE(s.total_bytes_down, 0) / 1024.0
FROM wh_session_accumulating_fact s
JOIN wh_user_dimension u ON s.user_key = u.key AND u.current_row_indicator = 'Current'
JOIN wh_host_dimension h ON s.host_key = h.key AND h.current_row_indicator = 'Current'
WHERE u.user_name = 'user'
  AND s.session_terminated_time <> 'infinity'
  AND s.session_terminated_time >= CURRENT_DATE - INTERVAL '7 days'

ORDER BY event_time;

출력: 이 쿼리는 사용자에 대한 모든 AUTHORIZED → CONNECTED → TERMINATED 이벤트의 시간순 표를 출력해요. 사고 보고서에 바로 붙여 넣을 수 있죠.

5단계: 대체 BI 도구

Metabase의 대안이 되는 BI 도구들에 대한 섹션을 참고하세요.

Grafana(무료, 오픈소스)

데이터 웨어하우스에 Grafana를 구성하려면:

  • Boundary 데이터베이스를 가리키는 PostgreSQL 데이터 소스를 추가해요.
  • 위의 같은 쿼리를 Grafana Dashboard → Add Panel → SQL에서 사용해요.
  • 쿼리에 따라 형식을 Table이나 Time series로 설정해요.
  • Grafana는 시계열 패널에 시간 열이 필요해요. session_pending_time이나 date-dimension 조인을 사용하세요.

Apache Superset(무료, 오픈소스)

데이터 웨어하우스에 Apache Superset을 구성하려면:

  • PostgreSQL 데이터베이스 연결을 추가해요.
  • SQL Lab 쿼리를 만들거나 Explore로 차트를 빌드해요.
  • 웨어하우스 차원 테이블에서는 WHERE 절에 current_row_indicator = 'Current'를 사용하세요.

DBeaver / pgAdmin(무료 SQL 클라이언트)

DBeaver와 pgAdmin은 임시 탐색(ad-hoc exploration)에 완벽해요. 직접 연결하고 위의 쿼리 중 아무거나 실행하면 돼요.

6단계: 문제 해결(Troubleshooting)

문제가 생겼을 때 취할 수 있는 문제 해결 단계를 참고하세요.

웨어하우스 테이블이 비어 있어요

새 Boundary 배포에서는 정상이에요. 사용자가 인증하고 세션을 만들면서 웨어하우스가 채워져요. 확인하려면:

SELECT COUNT(*) FROM wh_session_accumulating_fact;
SELECT COUNT(*) FROM wh_auth_token_accumulating_fact;

둘 다 0이면, Boundary로 세션을 승인하고 대상에 연결해 보세요. 그리고 다시 시도해요.

Metabase에서 "permission denied"가 나와요

이 오류가 나면 다음을 확인하세요:

  • boundary_bi_readonly 역할이 GRANT CONNECT와 GRANT SELECT로 생성됐는지
  • 올바른 비밀번호를 사용하는지
  • 데이터베이스 호스트/포트가 올바른지

쿼리가 느려요

쿼리가 느리면:

  • 날짜 필터를 사용하세요(WHERE ... >= CURRENT_DATE - INTERVAL 'X days')
  • statement 타임아웃을 설정하세요: ALTER ROLE boundary_bi_readonly SET statement_timeout = '30s';
  • 가능하면 BI 쿼리에 읽기 복제본(read replica)을 사용하세요

컬럼 이름이 내 버전과 달라요

Boundary의 내부 스키마는 진화해요. 자신의 버전을 확인하려면:

-- List all warehouse tables and their columns
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name LIKE 'wh_%'
ORDER BY table_name, ordinal_position;

요약(Summary)

이제 Boundary 내장 데이터 웨어하우스에 동작하는 읽기 전용 BI 연결이 생겼어요. 핵심 워크플로우는 다음과 같아요:

  • Metabase(또는 PostgreSQL 호환 BI 도구)를 Boundary 데이터베이스에 연결
  • 위의 검증된 쿼리로 wh_* 테이블을 조회
  • 접근 검토, 세션 모니터링, 감사 보고용 대시보드 구축
  • 새로고침을 예약 — 데이터는 Boundary 내부 트리거를 통해 거의 실시간으로 갱신돼요

ETL도, 미들웨어도, 데이터 파이프라인도 필요 없어요. Boundary가 유지 관리해 주는 웨어하우스에 PostgreSQL 쿼리를 하는 것뿐이에요.

더 알아보기 (Learn more)