Matplotlib 기초: Python 데이터 시각화 그래프 그리기
이 글의 핵심
그래프에서 한글이 네모로 깨지고 마이너스 기호가 이상하게 나오는 문제는 Matplotlib을 한국어 데이터에 쓸 때 거의 반드시 만납니다. 폰트와 axes.unicode_minus 설정으로 이를 해결하는 방법, pyplot 방식과 Figure·Axes 객체 방식의 차이, 저장 해상도를 챙기는 습관을 함께 짚습니다.
들어가며
Matplotlib은 데이터 시각화를 위한 Python의 표준 라이브러리입니다.
“표준”이라는 말은 Python 설치에 포함된다는 뜻이 아니라(별도 설치가 필요합니다), 사실상 모든 시각화 도구의 바탕이라는 뜻입니다. Seaborn, pandas의 df.plot()은 내부적으로 Matplotlib을 호출하므로, 이런 도구로 그린 그래프의 세부를 고치려면 결국 Matplotlib의 Figure·Axes를 만져야 합니다. 그래서 Matplotlib의 구조를 한 번 이해해 두면 다른 도구를 쓸 때도 막히는 일이 크게 줄어듭니다.
구조는 두 층으로 되어 있습니다. Figure는 그림 전체(캔버스)이고, Axes는 그 안에 놓인 좌표축 하나(그래프 한 칸)입니다. 이름이 비슷한 Axis(x축, y축 자체)와 헷갈리기 쉬운데, 한 Figure에 여러 Axes가 들어가고 한 Axes에 x·y 두 개의 Axis가 있다고 기억하면 됩니다. 그리는 방법도 두 가지입니다. plt.plot()처럼 모듈 함수를 부르는 pyplot 방식은 “현재 Axes”를 내부적으로 기억해 두고 거기에 그리며, fig, ax = plt.subplots()로 객체를 받아 ax.plot()을 부르는 객체지향 방식은 어디에 그릴지를 명시합니다. 이 글의 앞부분은 짧은 pyplot 방식으로, 서브플롯부터는 객체지향 방식으로 씁니다.
설치와 첫 그래프
설치
pip install matplotlib
첫 그래프
plt.plot(x, y)는 가로축·세로축 좌표 쌍을 이은 선을 그립니다. 보고서에 넣을 도표의 뼈대는 plot으로 잡으며, xlabel·ylabel·title로 읽는 사람이 축 의미를 알 수 있게 적어 줍니다.
import matplotlib.pyplot as plt
# 데이터
x = [1, 2, 3, 4, 5]
y = [2, 4, 6, 8, 10]
# 그래프 그리기
plt.plot(x, y)
plt.xlabel('X축')
plt.ylabel('Y축')
plt.title('선 그래프')
plt.show()
이 코드를 한글 폰트 설정 없이 그대로 실행하면 축 이름과 제목이 네모(□)로 표시되고, 터미널에는 “UserWarning: Glyph … missing from font(s) DejaVu Sans.” 같은 경고가 여러 줄 찍힙니다. Matplotlib 기본 폰트인 DejaVu Sans에 한글 글리프가 없기 때문입니다. 한글을 쓰는 그래프라면 아래 “그래프 스타일·폰트·크기 한 번에 맞추기” 절의 폰트 설정을 그리기 전에 먼저 적용해야 합니다.
plt.show()의 동작도 환경마다 다릅니다. 로컬 PC에서는 창을 띄우고 창을 닫을 때까지 스크립트를 멈추며, Jupyter에서는 셀 아래에 이미지를 표시합니다. 모니터가 없는 서버나 Docker 컨테이너에서는 백엔드가 비대화형인 Agg로 잡혀서 “FigureCanvasAgg is non-interactive, and thus cannot be shown” 경고만 나오고 아무것도 표시되지 않습니다. 서버에서 그래프를 만드는 스크립트라면 show() 대신 savefig()로 파일을 저장하는 것이 정석입니다.
선 그래프
기본 선 그래프
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
y1 = np.sin(x)
y2 = np.cos(x)
plt.plot(x, y1, label='sin(x)', color='blue', linestyle='-')
plt.plot(x, y2, label='cos(x)', color='red', linestyle='--')
plt.xlabel('x')
plt.ylabel('y')
plt.title('삼각함수')
plt.legend()
plt.grid(True)
plt.show()
np.linspace(0, 10, 100)은 0부터 10까지를 100개의 점으로 나눕니다. plot은 점과 점을 직선으로 잇기 때문에, 점이 10개뿐이면 사인 곡선이 각진 꺾은선으로 보입니다. 곡선이 매끄러워 보이지 않는다면 점의 개수를 늘리면 됩니다. plot을 여러 번 호출하면 같은 Axes에 선이 겹쳐 그려지고, 각 호출의 label이 legend()의 범례 항목이 됩니다. label을 주지 않고 legend()를 호출하면 “No artists with labels found to put in legend” 경고와 함께 빈 범례가 나옵니다.
color='blue', linestyle='--'는 'b--'처럼 한 문자열로 줄여 쓸 수도 있지만, 보고서용 코드라면 읽기 쉬운 키워드 인자를 권합니다. 선이 여러 개일 때 색만으로 구분하면 흑백 인쇄나 색각 이상이 있는 독자에게 구분이 안 되므로, 이 예제처럼 선 스타일도 함께 다르게 주는 것이 좋은 습관입니다.
세로·가로 막대 그래프
기본 막대 그래프
categories = ['A', 'B', 'C', 'D']
values = [25, 40, 30, 55]
plt.bar(categories, values, color='skyblue')
plt.xlabel('카테고리')
plt.ylabel('값')
plt.title('막대 그래프')
plt.show()
가로 막대 그래프
plt.barh(categories, values, color='lightgreen')
plt.xlabel('값')
plt.ylabel('카테고리')
plt.title('가로 막대 그래프')
plt.show()
막대 그래프와 선 그래프의 선택 기준은 x축이 연속인가, 범주인가입니다. 월별 매출처럼 순서가 있고 추세를 보고 싶다면 선, 부서별 인원처럼 서로 독립된 범주를 비교한다면 막대가 맞습니다. 범주 이름이 길거나 개수가 많으면 세로 막대의 x축 라벨이 서로 겹치는데, 이때 라벨을 45도 돌리기보다 barh로 가로 막대를 쓰는 편이 읽기 편합니다. 이 코드에서 x축에 문자열 리스트를 그대로 넘기면 Matplotlib이 범주형 축으로 처리해 넘긴 순서대로 배치합니다. 값 크기순으로 보여 주고 싶다면 그리기 전에 데이터를 정렬해야 합니다.
막대 그래프의 y축은 0에서 시작해야 한다는 원칙도 기억해 둘 만합니다. 막대의 길이 자체가 값을 표현하기 때문에, set_ylim(20, 60)처럼 축을 잘라 내면 25와 55의 차이가 실제보다 몇 배 커 보입니다. 선 그래프는 변화량을 보는 도구라 축을 좁혀도 괜찮지만, 막대는 그렇지 않습니다.
히스토그램
# 정규 분포 데이터
data = np.random.randn(1000)
plt.hist(data, bins=30, color='purple', alpha=0.7, edgecolor='black')
plt.xlabel('값')
plt.ylabel('빈도')
plt.title('히스토그램')
plt.show()
히스토그램은 값의 범위를 bins개의 구간으로 나누고 각 구간에 들어간 데이터 개수를 막대로 그립니다. bins 값에 따라 같은 데이터가 전혀 다르게 보인다는 점이 가장 큰 함정입니다. 구간이 너무 적으면 분포의 모양이 뭉개지고, 너무 많으면 막대마다 데이터가 몇 개씩밖에 없어 들쭉날쭉한 잡음처럼 보입니다. 감으로 정하기 어렵다면 bins='auto'를 주면 NumPy가 데이터 크기와 분산을 보고 적당한 구간 수를 고릅니다. 크기가 다른 두 집단을 겹쳐 비교할 때는 density=True로 개수 대신 비율을 그려야 공정한 비교가 됩니다. alpha=0.7과 edgecolor='black'은 막대 경계를 분명히 하고 겹친 히스토그램이 서로 비치게 하는 용도입니다.
산점도
x = np.random.rand(50)
y = np.random.rand(50)
colors = np.random.rand(50)
sizes = 1000 * np.random.rand(50)
plt.scatter(x, y, c=colors, s=sizes, alpha=0.5, cmap='viridis')
plt.colorbar()
plt.xlabel('X')
plt.ylabel('Y')
plt.title('산점도')
plt.show()
산점도는 x와 y 외에 c(색)와 s(크기)로 두 개의 차원을 더 표현할 수 있습니다. c에 숫자 배열을 넘기면 cmap의 색 척도로 매핑되고, colorbar()가 그 척도를 옆에 표시합니다. s는 반지름이 아니라 면적(포인트 제곱) 단위라서, 값이 두 배면 점의 지름은 약 1.4배만 커집니다. 크기로 수량을 표현할 때 이 점을 모르면 차이를 잘못 읽게 됩니다. 점이 수천 개를 넘어 서로 겹치면 alpha를 낮추거나, 아예 hexbin이나 2D 히스토그램으로 밀도를 표현하는 편이 정보가 더 잘 전달됩니다.
여러 서브플롯 배치
fig, axes = plt.subplots(2, 2, figsize=(10, 8))
# 1번 그래프
axes[0, 0].plot([1, 2, 3], [1, 4, 9])
axes[0, 0].set_title('선 그래프')
# 2번 그래프
axes[0, 1].bar(['A', 'B', 'C'], [3, 7, 5])
axes[0, 1].set_title('막대 그래프')
# 3번 그래프
axes[1, 0].hist(np.random.randn(100), bins=20)
axes[1, 0].set_title('히스토그램')
# 4번 그래프
axes[1, 1].scatter(np.random.rand(50), np.random.rand(50))
axes[1, 1].set_title('산점도')
plt.tight_layout()
plt.show()
plt.subplots(2, 2)는 Figure 하나와 Axes 네 개가 담긴 2×2 NumPy 배열을 돌려주므로 axes[행, 열]로 접근합니다. 한 줄짜리 배치(plt.subplots(1, 3))면 1차원 배열이 되어 axes[0]처럼 인덱스 하나로 접근해야 하고, plt.subplots()처럼 인자가 없으면 배열이 아니라 Axes 객체 하나가 옵니다. 행과 열 수를 바꿨는데 “IndexError: too many indices for array”가 난다면 이 차이 때문입니다. 항상 2차원으로 받고 싶다면 squeeze=False를 주면 됩니다.
pyplot 방식에서 쓰던 함수 이름이 객체지향 방식에서는 조금씩 다르다는 점도 헷갈립니다. plt.title()은 ax.set_title(), plt.xlabel()은 ax.set_xlabel(), plt.xlim()은 ax.set_xlim()입니다. 서브플롯을 쓰면서 중간에 plt.title()을 호출하면 마지막으로 활성화된 Axes, 보통은 마지막에 그린 칸에만 제목이 붙습니다. 이것이 “자주 하는 실수”에서 말하는 두 방식을 섞을 때의 문제입니다. tight_layout()은 제목과 축 라벨이 이웃 칸과 겹치지 않도록 간격을 자동 조정하며, 최신 버전에서는 plt.subplots(..., layout='constrained')가 더 정교하게 같은 역할을 합니다.
판매 데이터 시각화 예제
import matplotlib.pyplot as plt
import pandas as pd
# 데이터
sales_data = pd.DataFrame({
'month': ['1월', '2월', '3월', '4월', '5월', '6월'],
'sales': [150, 180, 165, 220, 250, 240],
'profit': [30, 45, 35, 60, 75, 70]
})
# 그래프
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
# 매출 추이
ax1.plot(sales_data['month'], sales_data['sales'],
marker='o', linewidth=2, markersize=8)
ax1.set_title('월별 매출 추이', fontsize=14, fontweight='bold')
ax1.set_xlabel('월')
ax1.set_ylabel('매출 (만원)')
ax1.grid(True, alpha=0.3)
# 수익 막대 그래프
ax2.bar(sales_data['month'], sales_data['profit'],
color='green', alpha=0.7)
ax2.set_title('월별 수익', fontsize=14, fontweight='bold')
ax2.set_xlabel('월')
ax2.set_ylabel('수익 (만원)')
plt.tight_layout()
plt.savefig('sales_report.png', dpi=300)
plt.show()
이 예제는 매출과 수익을 다른 그래프 종류로 나란히 놓았습니다. 매출은 추세가 중요하니 선으로, 수익은 월별 크기 비교가 중요하니 막대로 그렸습니다. savefig를 show 앞에 둔 순서가 중요한데, 로컬 창 모드에서 show()는 창을 닫을 때 Figure를 정리하기 때문에 그 뒤에 savefig를 호출하면 흰 빈 이미지가 저장됩니다. dpi=300은 인쇄용 해상도로, figsize=(12, 5)와 곱해 3600×1500 픽셀 이미지가 됩니다. 웹 게시용이라면 100~150이면 충분하고 파일도 훨씬 작습니다. 저장한 이미지의 가장자리가 잘리거나 여백이 너무 넓다면 bbox_inches='tight'를 추가하세요.
제가 이런 월별 리포트 스크립트를 처음 자동화했을 때 겪은 문제는 메모리가 계속 늘어나는 것이었습니다. 반복문 안에서 plt.subplots()로 Figure를 계속 만들기만 하고 닫지 않으면 pyplot이 모든 Figure를 기억하고 있어서, 20개를 넘기면 “More than 20 figures have been opened” 경고가 뜨고 결국 메모리가 바닥납니다. 반복 생성하는 코드라면 저장 직후 plt.close(fig)를 호출해야 합니다.
그래프 스타일·폰트·크기 한 번에 맞추기
Matplotlib은 보고서에 넣을 도표를 그리는 도구입니다. style로 전체 톤을 맞추고, 한글은 OS에 맞는 폰트 이름을 rcParams에 지정해야 깨지지 않습니다. figsize와 색 팔레트를 먼저 정해 두면 여러 축을 그릴 때도 일관된 느낌을 유지하기 쉽습니다.
# 스타일 적용
plt.style.use('seaborn-v0_8')
# 한글 폰트 설정
plt.rcParams['font.family'] = 'Malgun Gothic'
plt.rcParams['axes.unicode_minus'] = False
# 그래프 크기
plt.figure(figsize=(10, 6))
# 색상
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1']
여기서 순서가 중요합니다. plt.style.use()는 스타일 파일에 정의된 rcParams 값을 덮어쓰므로, 폰트 설정을 먼저 하고 스타일을 나중에 적용하면 스타일에 폰트 관련 항목이 있을 때 한글 설정이 날아갈 수 있습니다. 위 코드처럼 스타일 → 폰트 순서로 두는 것이 안전합니다. 스타일 이름 'seaborn'은 Matplotlib 3.6에서 'seaborn-v0_8'로 바뀌었고(3.6~3.7은 경고만 출력) 3.8부터는 옛 이름이 제거되어, 예전 글의 plt.style.use('seaborn')을 그대로 쓰면 “OSError: ‘seaborn’ is not a valid package style …” 에러가 납니다. 사용 가능한 이름은 plt.style.available로 확인할 수 있습니다.
'Malgun Gothic'은 Windows 기본 폰트라 macOS에서는 'AppleGothic', Linux 서버에서는 fonts-nanum 패키지를 설치한 뒤 'NanumGothic'을 지정하는 식으로 OS마다 바꿔야 합니다. 폰트를 새로 설치했는데도 여전히 네모가 나온다면 Matplotlib의 폰트 캐시가 옛 목록을 들고 있기 때문입니다. matplotlib.get_cachedir()가 알려 주는 디렉터리의 fontlist-*.json을 지우고 다시 실행하거나, matplotlib.font_manager.fontManager.addfont('폰트 경로.ttf')로 파일을 직접 등록하면 해결됩니다. 여러 OS에서 같은 스크립트를 돌린다면 폰트 파일을 프로젝트에 포함하고 addfont로 등록하는 방식이 가장 확실합니다.
axes.unicode_minus = False는 Matplotlib이 음수 부호로 쓰는 유니코드 마이너스 기호(U+2212)를 일반 하이픈으로 바꾸는 설정입니다. 한글 폰트 상당수에 이 기호가 없어서, 설정하지 않으면 음수 눈금의 부호가 네모로 나옵니다.
회귀선 산점도와 잔차 플롯, 흔한 실수
회귀선이 있는 산점도와 잔차 플롯
아래 스크립트는 numpy로 합성 데이터를 만들고, polyfit으로 1차 회귀선을 그린 뒤 잔차 분포를 함께 봅니다. 보고서용으로 savefig까지 포함했습니다.
import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(42)
x = np.linspace(0, 10, 80)
y = 2.5 * x + 1.0 + rng.normal(0, 1.8, size=x.shape)
coef = np.polyfit(x, y, 1)
y_hat = np.poly1d(coef)(x)
residuals = y - y_hat
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4), constrained_layout=True)
ax1.scatter(x, y, alpha=0.7, label="관측")
ax1.plot(x, y_hat, color="crimson", linewidth=2, label="1차 회귀")
ax1.set_title("산점도와 회귀선")
ax1.legend()
ax1.grid(True, alpha=0.3)
ax2.hist(residuals, bins=18, color="steelblue", edgecolor="black", alpha=0.85)
ax2.set_title("잔차 분포")
ax2.grid(True, alpha=0.3)
fig.savefig("regression_residuals.png", dpi=200)
plt.show()
np.polyfit(x, y, 1)은 최소제곱법으로 1차식의 계수 [기울기, 절편]을 구하고, np.poly1d는 그 계수로 호출 가능한 다항식 객체를 만듭니다. 데이터를 y = 2.5x + 1 + 잡음으로 만들었으니 추정된 기울기는 2.5 근처가 나와야 합니다. default_rng(42)로 시드를 고정했기 때문에 몇 번을 실행해도 같은 그래프가 나오므로, 보고서에 그래프를 넣고 나중에 다시 만들어야 할 때 결과가 바뀌지 않습니다.
오른쪽 잔차 히스토그램이 이 예제의 핵심입니다. 회귀선이 데이터를 잘 설명한다면 잔차(실제값 - 예측값)는 0을 중심으로 좌우 대칭인 종 모양이어야 합니다. 한쪽으로 치우치거나 봉우리가 두 개라면 1차식으로는 부족하다는 신호입니다. 실무에서는 히스토그램과 함께 x에 대한 잔차 산점도도 자주 그리는데, 잔차가 x가 커질수록 부채꼴로 퍼진다면 분산이 일정하지 않다는 뜻이라 단순 선형 회귀의 가정이 깨진 것입니다. 앞의 예제들과 달리 constrained_layout=True를 쓴 것은 tight_layout()의 최신 대안으로, 범례나 컬러바가 있을 때 더 정확하게 여백을 계산합니다.
savefig 순서, 한글 깨짐, API 혼용
plt.show()후savefig를 호출해 빈 파일이 저장되는 경우(순서를 바꿔야 함).- 한글 레이블이 깨져 보고서가 unusable이 되는 경우(운영체제별 폰트 설정 필요).
- 객체지향 API와
pyplot상태 머신을 섞어 축이 엉뚱한 서브플롯에 그려지는 경우.
출판용 포맷, 색맹 팔레트, 스타일 공통화
- 과학 출판물은 벡터 포맷(PDF/SVG)을 선호합니다. 비트맵은
dpi를 명시하세요. - 색맹 친화 팔레트를 검토하세요. 범주형 색은
plt.style.use('tableau-colorblind10')나'seaborn-v0_8-colorblind'스타일, 연속값 색 척도는viridis나cividis가 무난하며, 빨강-초록 대비에 의존하는jet·RdYlGn계열은 피하는 편이 좋습니다. - 반복되는 스타일은 matplotlibrc 또는
plt.style.context로 팀 공통화합니다. - 대시보드가 아니라 리포트 자동화라면
plt.close(fig)로 Figure를 닫아 메모리를 반환합니다.
Matplotlib 대신 쓸 만한 도구
| 도구 | 장면 |
|---|---|
| Matplotlib | 세밀한 제어, 논문·백엔드 렌더 |
| Seaborn | 통계 플롯 빠른 프로토타입 |
| Plotly | 인터랙티브 웹 |
추가 리소스
Matplotlib 요약
- Matplotlib: Python 시각화 라이브러리
- pyplot: 간단한 그래프 API
- 그래프 종류: 선, 막대, 히스토그램, 산점도
- 서브플롯: 여러 그래프 배치
- 스타일: 색상, 폰트, 레이아웃
다음 단계
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. 그래프에서 한글이 네모로 깨지거나 마이너스 기호가 이상하게 나오면 어떻게 하나요?
A. Matplotlib의 기본 폰트에는 한글 글리프가 없어서, plt.rcParams['font.family']에 설치된 한글 폰트 이름(Windows라면 ‘Malgun Gothic’ 등)을 지정해야 합니다. 한글 폰트로 바꾸면 음수 축의 마이너스 기호가 깨질 수 있으므로 plt.rcParams['axes.unicode_minus'] = False도 함께 설정합니다. 폰트 이름은 OS마다 다르기 때문에 서버나 다른 PC에서 그릴 때는 해당 환경에 설치된 폰트를 확인해야 합니다.