본문 바로가기
오픈소스를 위한 기초 상식

데이터 대시보드 제작 가이드

by 지나가는 프로도 2025. 3. 28.

데이터 대시보드는 여러 데이터 소스에서 수집된 정보를 시각적으로 표현하여 중요한 지표를 한눈에 파악할 수 있게 해주는 도구입니다. 이 가이드에서는 Python과 웹 기술을 활용하여 효과적인 대시보드를 구축하는 방법을 알아보겠습니다.

1. 대시보드 기획 및 설계

효과적인 대시보드를 만들기 위해서는, 먼저 목적과 사용자 요구사항을 명확히 정의해야 합니다.

1.1 대시보드 목적 정의

대시보드는 다음과 같은 목적으로 설계될 수 있습니다:

  • 분석용 대시보드: 데이터 분석가가 심층적인 분석을 위해 사용
  • 전략용 대시보드: 경영진이 의사결정을 위해 참고하는 KPI(핵심성과지표) 중심
  • 운영용 대시보드: 일상적인 업무 모니터링 및 이상 징후 감지

1.2 핵심 지표(KPI) 선정

# 대시보드에 표시할 KPI 정의 예시
kpi_metrics = {
    'sales': {
        'daily_revenue': '일일 매출',
        'monthly_growth': '월간 성장률',
        'conversion_rate': '전환율',
        'average_order_value': '평균 주문 금액'
    },
    'user': {
        'active_users': '활성 사용자 수',
        'new_users': '신규 사용자 수',
        'retention_rate': '사용자 유지율',
        'churn_rate': '이탈률'
    },
    'product': {
        'top_selling': '최다 판매 제품',
        'inventory_level': '재고 수준',
        'return_rate': '반품률'
    }
}

1.3 대시보드 레이아웃 설계

대시보드 레이아웃은 사용자의 정보 흐름과 시선 이동을 고려하여 설계해야 합니다:

  1. Z-패턴: 왼쪽 상단에서 오른쪽 상단, 왼쪽 하단, 오른쪽 하단으로 시선이 이동하는 패턴
  2. F-패턴: 웹사이트 방문자의 시선이 왼쪽 상단에서 시작하여 F자 형태로 움직이는 패턴
  3. 중요도 기반: 가장 중요한 정보를 왼쪽 상단에 배치하고 덜 중요한 정보를 오른쪽 하단으로 배치
# 간단한 대시보드 레이아웃 계획 예시
dashboard_layout = {
    'row1': [
        {'width': 6, 'height': 4, 'title': '일일 매출 추이', 'chart_type': 'line'},
        {'width': 6, 'height': 4, 'title': '활성 사용자 현황', 'chart_type': 'bar'}
    ],
    'row2': [
        {'width': 4, 'height': 3, 'title': '전환율', 'chart_type': 'gauge'},
        {'width': 4, 'height': 3, 'title': '신규 사용자', 'chart_type': 'counter'},
        {'width': 4, 'height': 3, 'title': '평균 세션 시간', 'chart_type': 'counter'}
    ],
    'row3': [
        {'width': 12, 'height': 5, 'title': '제품별 매출 분포', 'chart_type': 'pie'}
    ]
}

2. 대시보드 개발 도구 선택

2.1 Python 기반 대시보드 라이브러리

Dash by Plotly

대표적인 Python 웹 대시보드 프레임워크입니다.

pip install dash dash-bootstrap-components pandas plotly

Streamlit

빠르게 데이터 애플리케이션을 구축할 수 있는 도구입니다.

pip install streamlit pandas matplotlib plotly

Panel

대화형 시각화 애플리케이션을 만들기 위한 고급 툴킷입니다.

pip install panel holoviews hvplot

2.2 도구 비교 및 선택 기준

도구 장점 단점 적합한 사용 사례

Dash - 세밀한 커스터마이징<br>- 반응형 레이아웃<br>- 풍부한 컴포넌트 - 상대적으로 높은 학습 곡선<br>- 복잡한 코드 구조 기업용 대시보드<br>복잡한 인터랙션
Streamlit - 매우 간단한 API<br>- 빠른 개발 속도<br>- 간편한 배포 - 커스터마이징 제한<br>- 복잡한 앱에 부적합 빠른 프로토타입<br>데이터 분석 리포트
Panel - 다양한 시각화 라이브러리와 호환<br>- 유연한 레이아웃<br>- Jupyter 통합 - 문서화 부족<br>- 소규모 커뮤니티 데이터 과학자용<br>탐색적 시각화

3. Dash를 활용한 대시보드 구현

3.1 기본 구조 설정

# app.py
import dash
from dash import dcc, html
import dash_bootstrap_components as dbc
import plotly.express as px
import pandas as pd
from datetime import datetime, timedelta
import sqlite3

# 애플리케이션 초기화
app = dash.Dash(__name__, 
                external_stylesheets=[dbc.themes.BOOTSTRAP],
                meta_tags=[{'name': 'viewport', 'content': 'width=device-width, initial-scale=1'}])

# 앱 타이틀 설정
app.title = '비즈니스 성과 대시보드'

# 레이아웃 정의
app.layout = dbc.Container([
    dbc.Row([
        dbc.Col(html.H1("비즈니스 성과 대시보드", className='text-center mb-4'), width=12)
    ]),
    
    # 필터 및 컨트롤 섹션
    dbc.Row([
        dbc.Col([
            html.Label("날짜 범위 선택:"),
            dcc.DatePickerRange(
                id='date-range',
                start_date=(datetime.now() - timedelta(days=30)).date(),
                end_date=datetime.now().date(),
                display_format='YYYY-MM-DD'
            )
        ], width=6),
        dbc.Col([
            html.Label("제품 카테고리:"),
            dcc.Dropdown(
                id='category-filter',
                options=[
                    {'label': '전체', 'value': 'all'},
                    {'label': '전자제품', 'value': 'electronics'},
                    {'label': '의류', 'value': 'clothing'},
                    {'label': '식품', 'value': 'food'}
                ],
                value='all'
            )
        ], width=6)
    ], className='mb-4'),
    
    # KPI 카드 섹션
    dbc.Row([
        dbc.Col(dbc.Card([
            dbc.CardBody([
                html.H5("총 매출", className="card-title"),
                html.H3(id="total-sales", children="₩0", className="card-text text-success")
            ])
        ]), width=3),
        dbc.Col(dbc.Card([
            dbc.CardBody([
                html.H5("주문 수", className="card-title"),
                html.H3(id="order-count", children="0", className="card-text")
            ])
        ]), width=3),
        dbc.Col(dbc.Card([
            dbc.CardBody([
                html.H5("평균 주문금액", className="card-title"),
                html.H3(id="avg-order", children="₩0", className="card-text")
            ])
        ]), width=3),
        dbc.Col(dbc.Card([
            dbc.CardBody([
                html.H5("전환율", className="card-title"),
                html.H3(id="conversion-rate", children="0%", className="card-text")
            ])
        ]), width=3)
    ], className='mb-4'),
    
    # 차트 섹션
    dbc.Row([
        dbc.Col([
            dbc.Card([
                dbc.CardHeader("일일 매출 추이"),
                dbc.CardBody(dcc.Graph(id="sales-trend"))
            ])
        ], width=6),
        dbc.Col([
            dbc.Card([
                dbc.CardHeader("카테고리별 매출 분포"),
                dbc.CardBody(dcc.Graph(id="category-distribution"))
            ])
        ], width=6)
    ], className='mb-4'),
    
    dbc.Row([
        dbc.Col([
            dbc.Card([
                dbc.CardHeader("상위 판매 제품"),
                dbc.CardBody(dcc.Graph(id="top-products"))
            ])
        ], width=12)
    ])
    
], fluid=True)

# 서버 정의
server = app.server

# 메인 실행 부분
if __name__ == '__main__':
    app.run_server(debug=True)

3.2 데이터 연동

# SQLite 데이터베이스에서 데이터 가져오기
def get_sales_data(start_date, end_date, category='all'):
    conn = sqlite3.connect('sales_data.db')
    
    # 쿼리 조건 설정
    query = """
    SELECT 
        s.order_id, 
        s.order_date, 
        s.product_id, 
        p.product_name,
        p.category,
        s.quantity,
        s.price,
        s.quantity * s.price as total_amount
    FROM 
        sales s
    JOIN 
        products p ON s.product_id = p.product_id
    WHERE 
        s.order_date BETWEEN ? AND ?
    """
    
    params = [start_date, end_date]
    
    if category != 'all':
        query += " AND p.category = ?"
        params.append(category)
    
    # 데이터 읽기
    df = pd.read_sql_query(query, conn, params=params)
    conn.close()
    
    # 날짜 형식 변환
    df['order_date'] = pd.to_datetime(df['order_date'])
    
    return df

3.3 콜백 함수 정의

# 콜백 함수: 필터 변경 시 차트 업데이트
@app.callback(
    [
        dash.dependencies.Output('total-sales', 'children'),
        dash.dependencies.Output('order-count', 'children'),
        dash.dependencies.Output('avg-order', 'children'),
        dash.dependencies.Output('conversion-rate', 'children'),
        dash.dependencies.Output('sales-trend', 'figure'),
        dash.dependencies.Output('category-distribution', 'figure'),
        dash.dependencies.Output('top-products', 'figure')
    ],
    [
        dash.dependencies.Input('date-range', 'start_date'),
        dash.dependencies.Input('date-range', 'end_date'),
        dash.dependencies.Input('category-filter', 'value')
    ]
)
def update_dashboard(start_date, end_date, category):
    # 데이터 가져오기
    df = get_sales_data(start_date, end_date, category)
    
    if df.empty:
        return "₩0", "0", "₩0", "0%", {}, {}, {}
    
    # KPI 계산
    total_sales = df['total_amount'].sum()
    order_count = df['order_id'].nunique()
    avg_order = total_sales / order_count if order_count > 0 else 0
    
    # 전환율 계산 (예시: 방문자 수 데이터가 있다고 가정)
    visitors = 12000  # 실제로는 데이터베이스에서 가져와야 함
    conversion_rate = (order_count / visitors) * 100 if visitors > 0 else 0
    
    # 일일 매출 추이 차트
    daily_sales = df.groupby(df['order_date'].dt.date)['total_amount'].sum().reset_index()
    sales_trend_fig = px.line(
        daily_sales, 
        x='order_date', 
        y='total_amount',
        title='일일 매출 추이',
        labels={'order_date': '날짜', 'total_amount': '매출액'},
        template='plotly_white'
    )
    
    # 카테고리별 매출 분포 차트
    category_sales = df.groupby('category')['total_amount'].sum().reset_index()
    category_fig = px.pie(
        category_sales,
        values='total_amount',
        names='category',
        title='카테고리별 매출 분포',
        template='plotly_white'
    )
    
    # 상위 판매 제품 차트
    product_sales = df.groupby('product_name')['total_amount'].sum().reset_index()
    top_products = product_sales.sort_values('total_amount', ascending=False).head(10)
    top_products_fig = px.bar(
        top_products,
        x='product_name',
        y='total_amount',
        title='상위 10개 판매 제품',
        labels={'product_name': '제품명', 'total_amount': '매출액'},
        template='plotly_white'
    )
    
    return (
        f"₩{total_sales:,.0f}",
        f"{order_count:,}",
        f"₩{avg_order:,.0f}",
        f"{conversion_rate:.1f}%",
        sales_trend_fig,
        category_fig,
        top_products_fig
    )

4. Streamlit을 활용한 대시보드 구현

4.1 기본 구조 설정

# app.py
import streamlit as st
import pandas as pd
import matplotlib.pyplot as plt
import plotly.express as px
import plotly.graph_objects as go
import sqlite3
from datetime import datetime, timedelta

# 페이지 설정
st.set_page_config(
    page_title="비즈니스 성과 대시보드",
    page_icon="📊",
    layout="wide",
    initial_sidebar_state="expanded"
)

# 페이지 제목
st.title("비즈니스 성과 대시보드")

# 사이드바 필터
st.sidebar.header("필터")

# 날짜 범위 선택
start_date = st.sidebar.date_input(
    "시작일",
    datetime.now() - timedelta(days=30)
)

end_date = st.sidebar.date_input(
    "종료일",
    datetime.now()
)

# 카테고리 필터
categories = ["전체", "전자제품", "의류", "식품"]
selected_category = st.sidebar.selectbox("제품 카테고리", categories)

# 데이터 로드 함수
@st.cache_data
def load_data(start_date, end_date, category):
    conn = sqlite3.connect('sales_data.db')
    
    # 쿼리 조건 설정
    query = """
    SELECT 
        s.order_id, 
        s.order_date, 
        s.product_id, 
        p.product_name,
        p.category,
        s.quantity,
        s.price,
        s.quantity * s.price as total_amount
    FROM 
        sales s
    JOIN 
        products p ON s.product_id = p.product_id
    WHERE 
        s.order_date BETWEEN ? AND ?
    """
    
    params = [start_date, end_date]
    
    if category != "전체":
        query += " AND p.category = ?"
        params.append(category)
    
    # 데이터 읽기
    df = pd.read_sql_query(query, conn, params=params)
    conn.close()
    
    # 날짜 형식 변환
    df['order_date'] = pd.to_datetime(df['order_date'])
    
    return df

# 데이터 로드
df = load_data(start_date, end_date, selected_category)

# KPI 지표 행
col1, col2, col3, col4 = st.columns(4)

total_sales = df['total_amount'].sum()
order_count = df['order_id'].nunique()
avg_order = total_sales / order_count if order_count > 0 else 0
visitors = 12000  # 실제로는 DB에서 가져와야 함
conversion_rate = (order_count / visitors) * 100 if visitors > 0 else 0

with col1:
    st.metric("총 매출", f"₩{total_sales:,.0f}")

with col2:
    st.metric("주문 수", f"{order_count:,}")

with col3:
    st.metric("평균 주문금액", f"₩{avg_order:,.0f}")

with col4:
    st.metric("전환율", f"{conversion_rate:.1f}%")

# 차트 행 1
st.subheader("매출 분석")
col1, col2 = st.columns(2)

with col1:
    # 일일 매출 추이 차트
    daily_sales = df.groupby(df['order_date'].dt.date)['total_amount'].sum().reset_index()
    
    fig = px.line(
        daily_sales, 
        x='order_date', 
        y='total_amount',
        title='일일 매출 추이',
        labels={'order_date': '날짜', 'total_amount': '매출액'}
    )
    st.plotly_chart(fig, use_container_width=True)

with col2:
    # 카테고리별 매출 분포 차트
    category_sales = df.groupby('category')['total_amount'].sum().reset_index()
    
    fig = px.pie(
        category_sales,
        values='total_amount',
        names='category',
        title='카테고리별 매출 분포'
    )
    st.plotly_chart(fig, use_container_width=True)

# 차트 행 2
st.subheader("제품 분석")

# 상위 판매 제품 차트
product_sales = df.groupby('product_name')['total_amount'].sum().reset_index()
top_products = product_sales.sort_values('total_amount', ascending=False).head(10)

fig = px.bar(
    top_products,
    x='product_name',
    y='total_amount',
    title='상위 10개 판매 제품',
    labels={'product_name': '제품명', 'total_amount': '매출액'}
)
st.plotly_chart(fig, use_container_width=True)

# 원시 데이터 표시 (접을 수 있는 섹션)
with st.expander("원시 데이터 보기"):
    st.dataframe(df)

5. 대시보드 기능 확장

5.1 인터랙티브 요소 추가

# Dash에 드릴다운 기능 추가
@app.callback(
    dash.dependencies.Output('product-detail-modal', 'is_open'),
    [dash.dependencies.Input('top-products', 'clickData')],
    [dash.dependencies.State('product-detail-modal', 'is_open')]
)
def toggle_modal(clickData, is_open):
    if clickData:
        return not is_open
    return is_open

@app.callback(
    [dash.dependencies.Output('product-detail-title', 'children'),
     dash.dependencies.Output('product-detail-content', 'children')],
    [dash.dependencies.Input('top-products', 'clickData')],
)
def update_modal_content(clickData):
    if not clickData:
        return "제품 상세", "제품을 선택하세요"
    
    product_name = clickData['points'][0]['x']
    
    # 제품 상세 정보 조회
    conn = sqlite3.connect('sales_data.db')
    product_query = """
    SELECT * FROM products WHERE product_name = ?
    """
    product_df = pd.read_sql_query(product_query, conn, params=[product_name])
    
    sales_query = """
    SELECT 
        s.order_date, 
        s.quantity,
        s.price,
        s.quantity * s.price as total_amount
    FROM 
        sales s
    JOIN 
        products p ON s.product_id = p.product_id
    WHERE 
        p.product_name = ?
    ORDER BY 
        s.order_date DESC
    LIMIT 10
    """
    sales_df = pd.read_sql_query(sales_query, conn, params=[product_name])
    conn.close()
    
    # 제품 상세 정보 표시
    if product_df.empty:
        return f"{product_name} 상세", "제품 정보를 찾을 수 없습니다."
    
    product_info = product_df.iloc[0]
    
    content = html.Div([
        html.H5("제품 정보"),
        html.P(f"카테고리: {product_info['category']}"),
        html.P(f"가격: ₩{product_info['price']:,.0f}"),
        html.P(f"재고: {product_info['stock']} 개"),
        
        html.H5("최근 판매 내역"),
        dash_table.DataTable(
            data=sales_df.to_dict('records'),
            columns=[{'name': i, 'id': i} for i in sales_df.columns],
            style_table={'overflowX': 'auto'},
            style_cell={'textAlign': 'left'},
            style_header={
                'backgroundColor': 'rgb(230, 230, 230)',
                'fontWeight': 'bold'
            }
        )
    ])
    
    return f"{product_name} 상세", content

5.2 대시보드 성능 최적화

# 대용량 데이터 처리를 위한 최적화 기법

# 1. 데이터 캐싱
@st.cache_data(ttl=3600)  # 1시간 동안 캐시 유지
def load_large_data(start_date, end_date, category):
    # 데이터 로드 코드...
    return df

# 2. 데이터 다운샘플링
def downsample_time_series(df, freq='D'):
    """시계열 데이터를 지정된 주기로 다운샘플링"""
    return df.resample(freq, on='timestamp').mean()

# 3. 집계 쿼리 사용
def optimized_query(start_date, end_date, category):
    query = """
    SELECT 
        date(s.order_date) as order_day,
        sum(s.quantity * s.price) as daily_sales
    FROM 
        sales s
    JOIN 
        products p ON s.product_id = p.product_id
    WHERE 
        s.order_date BETWEEN ? AND ?
    """
    
    if category != "전체":
        query += " AND p.category = ?"
        params = [start_date, end_date, category]
    else:
        params = [start_date, end_date]
    
    query += " GROUP BY date(s.order_date)"
    
    # 실행...
    return result_df

# 4. 페이징 처리
@app.callback(
    dash.dependencies.Output('paginated-table', 'children'),
    [dash.dependencies.Input('page', 'value')]
)
def update_table(page):
    page_size = 20
    offset = (page - 1) * page_size
    
    query = f"""
    SELECT * FROM large_table
    LIMIT {page_size} OFFSET {offset}
    """
    
    # 쿼리 실행...
    return table

5.3 대시보드 배포 방법

Dash 애플리케이션 배포

# requirements.txt 생성
"""
dash==2.10.2
dash-bootstrap-components==1.4.1
pandas==2.0.3
plotly==5.15.0
gunicorn==21.2.0
"""

# Procfile (Heroku 배포용)
"""
web: gunicorn app:server
"""

# Docker 배포 (Dockerfile)
"""
FROM python:3.9-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8050

CMD ["gunicorn", "--bind", "0.0.0.0:8050", "app:server"]
"""

Streamlit 애플리케이션 배포

# requirements.txt 생성
"""
streamlit==1.24.0
pandas==2.0.3
plotly==5.15.0
matplotlib==3.7.2
"""

# Streamlit Cloud에 배포
# 1. GitHub 저장소에 코드 푸시
# 2. https://streamlit.io/cloud 접속
# 3. 저장소 연결 및 앱 배포

 

import pandas as pd
import numpy as np
from prophet import Prophet
from datetime import datetime, timedelta

def generate_sales_forecast(historical_df, forecast_days=30):
    """Prophet 모델을 사용하여 매출 예측"""
    
    # 데이터 준비
    forecast_df = historical_df[['order_date', 'total_amount']].copy()
    forecast_df.columns = ['ds', 'y']
    
    # Prophet 모델 학습
    model = Prophet(
        daily_seasonality=False,
        weekly_seasonality=True,
        yearly_seasonality=True,
        seasonality_mode='multiplicative'
    )
    
    model.fit(forecast_df)
    
    # 예측 기간 설정
    future = model.make_future_dataframe(periods=forecast_days)
    
    # 예측 수행
    forecast = model.predict(future)
    
    return forecast

def create_anomaly_detection_system(df, threshold=2.0):
    """Z-점수 기반 이상치 감지 시스템"""
    
    # 이동 평균 및 표준편차 계산
    window = 7  # 7일 기준
    df['rolling_mean'] = df['total_amount'].rolling(window=window).mean()
    df['rolling_std'] = df['total_amount'].rolling(window=window).std()
    
    # Z-점수 계산
    df['z_score'] = np.abs((df['total_amount'] - df['rolling_mean']) / df['rolling_std'])
    
    # 이상치 플래그 설정
    df['is_anomaly'] = df['z_score'] > threshold
    
    return df

# 알림 이메일 전송 함수
def send_alert_email(anomaly_date, metric_name, actual_value, expected_value):
    import smtplib
    from email.mime.text import MIMEText
    from email.mime.multipart import MIMEMultipart
    
    # 이메일 설정
    sender_email = "alerts@company.com"
    receiver_email = "admin@company.com"
    password = "your_password"
    
    # 이메일 내용 생성
    subject = f"대시보드 알림: {metric_name} 이상치 감지"
    
    body = f"""
    안녕하세요,
    
    {anomaly_date}에 {metric_name}에서 이상치가 감지되었습니다.
    
    실제값: {actual_value:.2f}
    예상값: {expected_value:.2f}
    편차: {(actual_value - expected_value) / expected_value * 100:.2f}%
    
    대