BI 도구를 데이터 웨어하우스에 연결하기
BI 도구를 데이터 웨어하우스에 연결하기
Boundary의 PostgreSQL 데이터베이스에는 내장 데이터 웨어하우스(wh_* 테이블)가 있어요. 이 웨어하우스는 세션, 연결, 인증 토큰, 사용자, 호스트, 크레덴셜의 전체 수명주기를 포착해요. 이 차원 모델(dimensional model)은 Boundary 이벤트가 발생할 때 데이터베이스 트리거를 통해 자동으로 채워져서, 외부 ETL 파이프라인 없이도 실시간 분석이 가능해요.
본문
이 가이드에서 다룰 내용은 다음과 같아요:
- 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)
- 데이터 웨어하우스의 아키텍처와 테이블에 대해 더 알고 싶으면 Boundary 데이터 웨어하우스 문서를 참고해요.
- 데이터 웨어하우스에 실행할 수 있는 쿼리 예시는 데이터 웨어하우스 감사(Audit the data warehouse) 문서를 참고해요.