간단한 대시보드 만들기
발표 전날 밤, 지윤의 팀은 강의실 한 켠에 모였습니다. 노트북 화면에는 Dash 앱이 실행 중이었습니다. 드롭다운을 클릭하면 두 개의 그래프가 동시에 바뀌고, 슬라이더를 조절하면 데이터 범위가 필터링됩니다.
"이거... 진짜 되네."
현우가 마우스를 드래그하며 중얼거렸습니다. 지윤은 빙그레 웃었습니다. 이제 완성입니다.
이 절에서는 캡스톤 프로젝트 최종 대시보드를 처음부터 끝까지 만듭니다. 상단 제목, 좌측 필터 패널, 우측 그래프 두 개. 드롭다운과 슬라이더가 두 그래프를 동시에 업데이트합니다.
레이아웃 설계
먼저 화면 구조를 잡습니다. CSS Flexbox를 활용해 좌우로 나눕니다.
+----------------------------------+
| 캡스톤 분석 대시보드 |
+----------+-------+-------+--------+
| | | | |
| 필터 | 산점도 | | 막대 |
| 패널 | | | 그래프 |
| | | | |
+----------+-------+-------+--------+
완성된 대시보드 코드
아래 코드 하나를 실행하면 대시보드 전체가 동작합니다.
# 새 파일: chapter04_07_capstone_dashboard.pyfrom dash import Dash, html, dcc, callback, Output, Inputimport plotly.express as pximport pandas as pd# Bootstrap CSS (인터넷 연결 필요)# 오프라인 환경에서는 pip install dash-bootstrap-components 후# import dash_bootstrap_components as dbc# external_stylesheets=[dbc.themes.BOOTSTRAP] 으로 대체할 수 있습니다.external_stylesheets = [ "https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"]app = Dash(__name__, external_stylesheets=external_stylesheets)# 데이터 준비df = px.data.tips()days = ["Thur", "Fri", "Sat", "Sun"]sizes = sorted(df["size"].unique())# ── 레이아웃 ─────────────────────────────────────────────app.layout = html.Div([ # 상단 제목 바 html.Div([ html.H1("캡스톤 분석 대시보드", className="text-white mb-0"), html.P("Tips Dataset — 요일·인원수별 식사 패턴 분석", className="text-white-50 mb-0") ], className="bg-dark p-4 mb-4"), # 본문: 좌측 필터 + 우측 그래프 html.Div([ # ── 좌측: 필터 패널 ────────────────────────── html.Div([ html.Div([ html.H5("필터", className="card-title"), html.Label("요일 선택", className="form-label mt-3"), dcc.Dropdown( id="day-filter", options=[{"label": d, "value": d} for d in days], value=days, # 전체 선택 multi=True, # 복수 선택 허용 clearable=False, placeholder="요일을 선택하세요" ), html.Label("최소 인원수", className="form-label mt-4"), dcc.Slider( id="size-filter", min=min(sizes), max=max(sizes), step=1, value=min(sizes), marks={s: str(s) for s in sizes}, ), html.Label("성별", className="form-label mt-4"), dcc.Checklist( id="sex-filter", options=[ {"label": " 남성 (Male)", "value": "Male"}, {"label": " 여성 (Female)", "value": "Female"}, ], value=["Male", "Female"], inputStyle={"marginRight": "6px"}, labelStyle={"display": "block", "marginBottom": "4px"} ), html.Hr(), html.Div(id="summary-text", className="text-muted small") ], className="card-body") ], className="card shadow-sm", style={"width": "280px", "minWidth": "280px", "marginRight": "20px", "alignSelf": "flex-start"}), # ── 우측: 그래프 영역 ────────────────────────── html.Div([ dcc.Graph(id="scatter-graph"), dcc.Graph(id="bar-graph") ], style={"flex": "1"}) ], style={"display": "flex", "padding": "0 20px 20px 20px"})], style={"fontFamily": "Arial, sans-serif", "backgroundColor": "#f8f9fa", "minHeight": "100vh"})# ── 콜백 ─────────────────────────────────────────────────@callback( Output("scatter-graph", "figure"), Output("bar-graph", "figure"), Output("summary-text", "children"), Input("day-filter", "value"), Input("size-filter", "value"), Input("sex-filter", "value"),)def update_dashboard(selected_days, min_size, selected_sex): # 필터 적용 if not selected_days: selected_days = days if not selected_sex: selected_sex = ["Male", "Female"] filtered = df[ (df["day"].isin(selected_days)) & (df["size"] >= min_size) & (df["sex"].isin(selected_sex)) ] # 산점도: 식사 금액 vs 팁 fig_scatter = px.scatter( filtered, x="total_bill", y="tip", color="day", symbol="sex", size="size", hover_data=["time", "smoker"], category_orders={"day": days}, title=f"식사 금액 vs 팁 (n={len(filtered)})", labels={"total_bill": "식사 금액 ($)", "tip": "팁 ($)"}, template="plotly_white" ) fig_scatter.update_layout( height=320, margin=dict(t=50, b=30, l=40, r=20) ) # 막대 그래프: 요일별 평균 팁 if filtered.empty: avg_tip = pd.DataFrame({"day": selected_days, "tip": [0] * len(selected_days)}) else: avg_tip = ( filtered.groupby("day")["tip"] .agg(["mean", "count"]) .reset_index() ) avg_tip.columns = ["day", "평균팁", "건수"] avg_tip = ( pd.DataFrame({"day": days}) .merge(avg_tip, on="day", how="left") .fillna(0) ) fig_bar = px.bar( avg_tip, x="day", y="평균팁", color="day", text="평균팁", category_orders={"day": days}, title="요일별 평균 팁", labels={"평균팁": "평균 팁 ($)"}, template="plotly_white" ) fig_bar.update_traces( texttemplate="%{text:.2f}", textposition="outside" ) fig_bar.update_layout( height=320, showlegend=False, margin=dict(t=50, b=30, l=40, r=20) ) # 요약 텍스트 if filtered.empty: summary = "조건에 맞는 데이터가 없습니다." else: summary = [ html.Strong("필터 결과"), html.Br(), f"총 {len(filtered)}건", html.Br(), f"평균 팁: ${filtered['tip'].mean():.2f}", html.Br(), f"평균 식사 금액: ${filtered['total_bill'].mean():.2f}", ] return fig_scatter, fig_bar, summaryif __name__ == "__main__": app.run(debug=True)
파일을 저장하고 실행합니다.
python chapter04_07_capstone_dashboard.py
브라우저에서 http://127.0.0.1:8050으로 접속하면 대시보드가 나타납니다.
핵심 포인트 정리
다중 Output: Output을 여러 개 나열하면 함수가 반환하는 값의 순서대로 각 컴포넌트가 업데이트됩니다.
@callback( Output("scatter-graph", "figure"), # 반환값 1번 Output("bar-graph", "figure"), # 반환값 2번 Output("summary-text", "children"), # 반환값 3번 Input(...), Input(...), Input(...))def update_dashboard(...): return fig_scatter, fig_bar, summary # 순서 일치
Dropdown multi=True: multi=True를 주면 여러 항목을 선택할 수 있습니다. value는 리스트로 반환됩니다.
external_stylesheets: Bootstrap CDN 주소를 전달하면 Bootstrap 클래스를 사용할 수 있습니다. className="card shadow-sm"처럼 Bootstrap 클래스로 스타일을 입힙니다.
발표 현장
다음 날 발표 자리에서 현우가 노트북을 열었습니다. 화면에 대시보드가 떴습니다. 교수님이 앞으로 나오셔서 직접 드롭다운을 클릭했습니다. 요일을 바꾸자 산점도와 막대 그래프가 동시에 바뀌었습니다. 슬라이더를 움직이니 인원수 기준으로 데이터가 필터링됐습니다.
교수님이 천천히 고개를 끄덕이며 말씀하셨습니다.
"오, 이거 실시간으로 되네?"
지윤은 미소를 참으며 대답했습니다. "네, Dash로 만들었습니다."
PART 01에서 처음 px.scatter() 한 줄로 차트를 그렸던 지윤이, 이제 웹 브라우저에서 동작하는 인터랙티브 대시보드를 발표하는 자리에 섰습니다. Pandas로 데이터를 정제하고, Plotly로 차트를 만들고, Dash로 웹 앱을 완성하는 흐름. 그것이 데이터 시각화의 전 과정입니다.