10  종합 분석: 세 나라 경제 진단과 나만의 CLI (8주차)

10.1 이번 장에서 배우는 것

여덟 주 동안 하나씩 익힌 문법이 이 장에서 한자리에 모입니다. 새 문법을 배우기보다, 지금까지의 연장을 한 상 위에 올려 놓고 제대로 된 분석 하나를 처음부터 끝까지 만들어 봅니다. 재료는 세 가상 국가의 4년치 거시 지표이고, 결과물은 두 가지입니다. 하나는 사람이 읽는 진단 보고서, 다른 하나는 터미널에서 부르는 나만의 명령어입니다.

  • 같은 코드를 국가마다 세 번 복사한 1판의 분석을, 딕셔너리 하나로 접어 넣습니다. 5장에서 배운 딕셔너리가 ’복붙 냄새’를 어떻게 지우는지 직접 봅니다.
  • 명목 성장률에서 물가를 걷어내 실질 성장을 읽습니다. 성장은 멎었는데 물가만 오르는 상태에 스태그플레이션(stagflation)이라는 이름을 붙입니다.
  • 6장의 수요·공급 모형에 종량세를 얹어 새 균형을 손으로 유도하고 코드로 확인합니다. 세금을 누구에게 매기든 실제 부담은 탄력성이 가른다는 사실을, 두 시나리오를 나란히 놓고 확인합니다.
  • 세금 때문에 사라진 거래, 곧 자중손실(deadweight loss)을 fill_between으로 삼각형으로 칠합니다.
  • 7장에서 예고한 econo 명령을 argparse의 서브커맨드로 완성합니다. equilibrium·shift·tax 세 분석을 터미널 한 줄로 부릅니다.

10.2 여는 질문

세 나라가 있습니다. 지난 4년 동안 세 나라 모두 GDP 숫자가 커졌습니다. 표만 보면 다들 성장한 것 같습니다. 그런데 정말 세 나라 국민이 모두 예전보다 잘 살게 되었을까요.

한 나라는 GDP가 6% 늘었지만 그해 물가가 5.5% 올랐습니다. 손에 쥔 돈은 6% 늘었는데 물건값이 5.5% 올랐다면, 실제로 더 살 수 있게 된 것은 0.5%뿐입니다. GDP 숫자가 커졌다는 말과 국민이 더 부유해졌다는 말은 같지 않습니다. 이 간극을 숫자로 벌려 보이는 것이 이 장 전반부의 일입니다.

후반부에는 질문을 하나 더 얹습니다. 정부가 이콘 카페의 아메리카노 한 잔에 세금 500원을 매긴다고 합시다. 이 500원은 누가 낼까요. 카페가 삼킬까요, 손님에게 넘어갈까요, 아니면 반씩 나눌까요. “카페에 부과했으니 카페가 낸다”는 답은 절반만 맞습니다. 세금을 누구 앞으로 고지서를 보내느냐와, 그 부담이 실제로 누구 지갑에서 나가느냐는 다른 문제입니다. 6장에서 세운 시장 모형이 이 질문에 정확한 숫자로 답합니다.

10.3 세 나라의 4년치 성적표

먼저 데이터입니다. 실제 분석이라면 CSV 파일을 읽어 오겠지만(9장에서 판다스로 그 방법을 익혔습니다), 이 장은 지난 문법을 손으로 다시 밟아 보는 자리라 값을 리스트에 직접 적어 둡니다. 세 국가 A·B·C의 2021년부터 2024년까지, 명목 GDP(십억 달러), 인구(백만 명), 물가 상승률(%)입니다.

years = [2021, 2022, 2023, 2024]

# 국가 A: 꾸준한 성장, 물가는 목표를 살짝 웃돎
gdp_A = [500, 520, 545, 570]      # 명목 GDP (십억 달러)
pop_A = [50, 51, 52, 53]          # 인구 (백만 명)
inf_A = [2.5, 3.1, 2.8, 2.2]      # 물가 상승률 (%)

# 국가 B: 규모가 크고 물가가 안정적
gdp_B = [1200, 1250, 1310, 1380]
pop_B = [80, 81, 82, 83]
inf_B = [1.5, 2.2, 1.9, 1.7]

# 국가 C: 성장이 정체되고 물가는 높음
gdp_C = [80, 85, 92, 88]
pop_C = [20, 20.5, 21, 21.2]
inf_C = [4.5, 5.5, 4.8, 6.0]

print("연도:", years)
print("국가 A 명목 GDP:", gdp_A)
print("국가 C 물가 상승률:", inf_C)
연도: [2021, 2022, 2023, 2024]
국가 A 명목 GDP: [500, 520, 545, 570]
국가 C 물가 상승률: [4.5, 5.5, 4.8, 6.0]

10.4 복붙의 냄새를 맡는 법

1판에서는 1인당 GDP를 이렇게 계산했습니다. 국가 A에 대해 반복문을 한 번 돌리고, 똑같은 반복문을 국가 B에 복사해 알파벳만 B로 바꾸고, 다시 C로 바꿔 붙였습니다. 세 덩어리를 나란히 놓고 읽어 보세요.

# 국가 A
gdp_pc_A = []
for i in range(len(years)):
    gdp_pc_A.append(gdp_A[i] * 1000 / pop_A[i])

# 국가 B  (위 세 줄을 복사해 A를 B로 바꿈)
gdp_pc_B = []
for i in range(len(years)):
    gdp_pc_B.append(gdp_B[i] * 1000 / pop_B[i])

# 국가 C  (또 한 번 복사해 B를 C로 바꿈)
gdp_pc_C = []
for i in range(len(years)):
    gdp_pc_C.append(gdp_C[i] * 1000 / pop_C[i])

print(f"2024년 1인당 GDP: A {gdp_pc_A[-1]:.0f}, B {gdp_pc_B[-1]:.0f}, C {gdp_pc_C[-1]:.0f}")
2024년 1인당 GDP: A 10755, B 16627, C 4151

세 덩어리가 알파벳 하나만 빼고 똑같습니다. 프로그래머들은 이렇게 ‘거의 같은 코드가 반복되는’ 낌새를 코드 냄새(code smell)라고 부릅니다. 냄새 자체가 오류는 아닙니다. 위 코드는 잘 돕니다. 다만 국가가 넷째로 늘어나면 또 복사해야 하고, 계산식에 실수가 있으면 세 군데를 똑같이 고쳐야 하며, 한 군데만 고치고 두 군데를 잊으면 그때부터 세 나라의 결과가 서로 다른 규칙으로 계산됩니다.

5장에서 배운 딕셔너리가 이 냄새를 지웁니다. 나라 이름을 열쇠로, 그 나라의 지표 묶음을 값으로 두면 세 나라가 하나의 자료구조 안으로 들어옵니다. 그러면 반복문 하나가 세 나라를 모두 훑습니다.

nations = {
    "국가 A": {"gdp": [500, 520, 545, 570], "pop": [50, 51, 52, 53],
             "inf": [2.5, 3.1, 2.8, 2.2]},
    "국가 B": {"gdp": [1200, 1250, 1310, 1380], "pop": [80, 81, 82, 83],
             "inf": [1.5, 2.2, 1.9, 1.7]},
    "국가 C": {"gdp": [80, 85, 92, 88], "pop": [20, 20.5, 21, 21.2],
             "inf": [4.5, 5.5, 4.8, 6.0]},
}

print("담긴 나라:", list(nations.keys()))
print("국가 B의 GDP 열:", nations["국가 B"]["gdp"])
담긴 나라: ['국가 A', '국가 B', '국가 C']
국가 B의 GDP 열: [1200, 1250, 1310, 1380]

한 줄씩 뜯어보기

nations는 딕셔너리 안에 딕셔너리가 들어간 구조입니다. 바깥 딕셔너리의 열쇠는 "국가 A" 같은 나라 이름이고, 각 열쇠에 딸린 값이 다시 {"gdp": ..., "pop": ..., "inf": ...}라는 작은 딕셔너리입니다. 그래서 nations["국가 B"]는 국가 B의 지표 묶음 전체를 가리키고, 거기에 ["gdp"]를 한 번 더 붙인 nations["국가 B"]["gdp"]가 국가 B의 GDP 리스트에 닿습니다. 열쇠를 두 번 따라 들어가는 이 구조가 곧 표의 ’행과 열’입니다. 9장에서 판다스로 다룬 데이터프레임이 바로 이 모양을 정돈해 놓은 것입니다.

# 반복문 하나가 세 나라를 모두 훑는다. 결과는 각 나라의 딕셔너리에 새 열로 저장.
for name, d in nations.items():
    pc = []
    for i in range(len(years)):
        pc.append(d["gdp"][i] * 1000 / d["pop"][i])
    d["gdp_pc"] = pc

for name, d in nations.items():
    print(f"{name} 1인당 GDP:", [f"{x:.0f}" for x in d["gdp_pc"]])
국가 A 1인당 GDP: ['10000', '10196', '10481', '10755']
국가 B 1인당 GDP: ['15000', '15432', '15976', '16627']
국가 C 1인당 GDP: ['4000', '4146', '4381', '4151']

복사해 붙였던 세 덩어리가 반복문 하나로 접혔습니다. 계산식 d["gdp"][i] * 1000 / d["pop"][i]는 이제 코드에 딱 한 번만 등장합니다. 실수를 고쳐도 한 곳만 고치면 되고, 나라가 넷으로 늘어도 nations에 항목 하나를 더하는 것으로 끝납니다. 계산 결과 gdp_pc를 각 나라의 딕셔너리에 도로 넣어 둔 점도 눈여겨보세요. 원자료와 계산값이 같은 상자 안에 함께 살면, 뒤에서 그래프를 그릴 때 나라별로 흩어진 변수를 찾아다닐 일이 없습니다.

# [직접 해보기]
# nations 딕셔너리를 반복문으로 훑어, 각 나라의 '평균 물가 상승률'을 계산해 출력하세요.
# 힌트: d["inf"] 리스트의 sum을 len으로 나눕니다.

# TODO: 여기에 코드를 작성하세요

10.5 명목에서 실질로: 물가를 걷어내기

GDP가 커졌다는 사실만으로는 부족합니다. 그 증가분에 물가 상승이 얼마나 섞여 있는지를 떼어 내야 국민이 실제로 더 살 수 있게 되었는지가 보입니다. 먼저 명목 GDP의 연간 성장률부터 구합니다. 이전 해 대비 몇 퍼센트 늘었는지입니다.

def growth_series(values):
    """연속한 값들의 전년 대비 성장률(%) 리스트를 돌려줍니다."""
    rates = []
    for i in range(1, len(values)):
        rates.append((values[i] - values[i - 1]) / values[i - 1] * 100)
    return rates

for name, d in nations.items():
    d["nom_growth"] = growth_series(d["gdp"])
    d["avg_nom"] = sum(d["nom_growth"]) / len(d["nom_growth"])

for name, d in nations.items():
    print(f"{name} 명목 성장률:", [f"{g:.2f}" for g in d["nom_growth"]],
          f"| 평균 {d['avg_nom']:.2f}%")
국가 A 명목 성장률: ['4.00', '4.81', '4.59'] | 평균 4.46%
국가 B 명목 성장률: ['4.17', '4.80', '5.34'] | 평균 4.77%
국가 C 명목 성장률: ['6.25', '8.24', '-4.35'] | 평균 3.38%

세 나라 모두 명목 성장률은 대체로 플러스입니다. 국가 C조차 앞의 2년은 6%, 8%로 빠르게 컸습니다. 그런데 같은 기간 국가 C의 물가는 매년 5% 안팎으로 올랐습니다. 늘어난 GDP의 상당 부분이 ’물건이 더 팔린 것’이 아니라 ’같은 물건의 값표가 올라간 것’이었다는 뜻입니다.

실질 성장률은 명목 성장률에서 물가 상승률을 걷어낸 값입니다. 정확한 정의는 \((1+\text{명목})/(1+\text{물가})-1\)이지만, 두 값이 작을 때는 그냥 빼는 근사로 충분합니다.

\[\text{실질 성장률} \approx \text{명목 성장률} - \text{물가 상승률}\]

경제학원론에서 처음 만나는 이 뺄셈 한 줄이, 위의 세 나라를 전혀 다르게 줄 세웁니다.

def real_growth(nominal, inflation):
    """명목 성장률에서 물가 상승률을 빼 실질 성장률(근사)을 돌려줍니다."""
    return nominal - inflation

for name, d in nations.items():
    real = []
    for i in range(len(d["nom_growth"])):
        # nom_growth[i]는 (i+1)번째 해의 성장, 그해 물가는 inf[i+1]
        real.append(real_growth(d["nom_growth"][i], d["inf"][i + 1]))
    d["real_growth"] = real
    d["avg_real"] = sum(real) / len(real)

for name, d in nations.items():
    print(f"{name} 실질 성장률:", [f"{g:.2f}" for g in d["real_growth"]],
          f"| 평균 {d['avg_real']:.2f}%")
국가 A 실질 성장률: ['0.90', '2.01', '2.39'] | 평균 1.76%
국가 B 실질 성장률: ['1.97', '2.90', '3.64'] | 평균 2.84%
국가 C 실질 성장률: ['0.75', '3.44', '-10.35'] | 평균 -2.05%

한 줄씩 뜯어보기

d["nom_growth"][i]는 (i+1)번째 해의 명목 성장률입니다. 성장률은 두 해 사이의 변화라 첫 해에는 값이 없고, 그래서 리스트가 원자료보다 하나 짧습니다. 이 어긋남 때문에 물가를 짝지을 때 인덱스를 한 칸 밀어 d["inf"][i + 1]을 씁니다. 성장률의 0번째 원소는 2021년에서 2022년으로 넘어간 성장이므로, 짝이 되는 물가는 2022년 물가, 곧 inf 리스트의 1번 자리이기 때문입니다. 인덱스가 리스트마다 한 칸씩 어긋나는 이런 상황은 시계열을 다룰 때 자주 나옵니다. 종이에 두 리스트를 위아래로 적어 몇 번 자리를 세어 보면 헷갈리지 않습니다.

print("평균 명목성장 vs 평균 실질성장")
for name, d in nations.items():
    print(f"  {name}: 명목 {d['avg_nom']:.2f}%  ->  실질 {d['avg_real']:.2f}%")
평균 명목성장 vs 평균 실질성장
  국가 A: 명목 4.46%  ->  실질 1.76%
  국가 B: 명목 4.77%  ->  실질 2.84%
  국가 C: 명목 3.38%  ->  실질 -2.05%

줄 세우기가 뒤집혔습니다. 명목 기준으로는 국가 B(4.77%)와 국가 A(4.46%)가 국가 C(3.38%)보다 조금 앞선 정도였습니다. 물가를 걷어내자 국가 C의 평균 실질 성장률은 마이너스로 내려앉습니다. 명목으로 3% 넘게 성장한 나라가, 실제로는 국민이 살 수 있는 양이 해마다 줄고 있었다는 뜻입니다.

국가 C의 상태에는 이름이 있습니다. 스태그플레이션(stagflation)입니다. 침체를 뜻하는 stagnation과 물가 상승을 뜻하는 inflation을 붙여 만든 말로, 성장은 멎었는데 물가는 오르는 상황을 가리킵니다. 1970년대 오일쇼크 때 유가가 치솟자 여러 나라가 동시에 겪었습니다. 이 상태가 특히 골치 아픈 이유는 처방이 서로 부딪히기 때문입니다. 물가를 잡으려 금리를 올리면 가뜩이나 약한 경기가 더 식고, 경기를 살리려 돈을 풀면 물가가 더 오릅니다. 성장과 물가 중 하나를 포기해야 하는 딜레마입니다.

물가 이야기가 나온 김에, 왜 하필 목표가 2%인지도 짚고 갑시다. 많은 나라의 중앙은행이 물가 상승률 2% 안팎을 목표로 삼습니다. 0%가 아닌 이유는, 물가가 아예 오르지 않거나 떨어지기 시작하면(디플레이션) 사람들이 ’더 싸질 테니 나중에 사자’며 소비를 미뤄 경기가 얼어붙기 때문입니다. 약간의 인플레이션은 그 반대 방향의 완충 장치입니다. 그렇다고 너무 높으면 돈의 값어치가 빠르게 녹아 저축과 계획이 어려워집니다. 2%는 이 둘 사이에서 오래 다듬어진 절충점입니다. 위 계산에서 국가 C가 이 목표선을 얼마나 멀리 벗어나는지는 잠시 뒤 그림으로 확인합니다.

실수 노트 — 명목과 실질을 섞어 부르기

뉴스에서 “지난해 소득이 5% 늘었다”는 문장을 보면 부유해진 것 같지만, 그해 물가가 5% 올랐다면 실질 소득은 제자리입니다. 학생들이 가장 흔히 저지르는 실수는 명목 값끼리 비교해 놓고 실질적인 결론을 내리는 것입니다. GDP, 소득, 임금, 이자율 앞에 ‘명목’과 ’실질’ 중 어느 쪽인지 붙는 순간, 두 값을 비교할 때는 반드시 같은 종류끼리 비교해야 합니다. 코드에서도 변수 이름을 avg_nom, avg_real처럼 갈라 두면 나중에 둘을 헷갈려 엉뚱하게 빼는 일을 막을 수 있습니다.

10.6 그래프 준비

숫자로 확인한 이야기를 그림으로 옮깁니다. 그 전에 한글이 깨지지 않도록 폰트를 맞춥니다. 운영체제에 따라 쓰는 글꼴이 다르므로 자동으로 갈라 줍니다.

import platform
import matplotlib.pyplot as plt

if platform.system() == "Windows":
    plt.rc("font", family="Malgun Gothic")
elif platform.system() == "Darwin":
    plt.rc("font", family="AppleGothic")
else:
    plt.rc("font", family="NanumBarunGothic")
plt.rcParams["axes.unicode_minus"] = False
# Colab에서 실행할 때만 (한 번 설치하면 세션 동안 유지됩니다)
!pip install koreanize-matplotlib
import koreanize_matplotlib

구글 코랩(Colab)에서 작업한다면 위 셀을 대신 실행합니다. koreanize-matplotlib를 한 번 설치해 두면 별도 설정 없이 한글 라벨이 나옵니다. 로컬 컴퓨터에서는 그 위의 폰트 셀만으로 충분하니 이 셀은 건너뜁니다.

10.7 그림으로 확인하기

세 그림을 그립니다. 첫째는 1인당 GDP가 어떻게 벌어졌는지, 둘째는 명목과 실질이 어떻게 다른 이야기를 하는지, 셋째는 물가가 목표선을 얼마나 벗어났는지입니다.

markers = {"국가 A": "o", "국가 B": "s", "국가 C": "^"}

plt.figure(figsize=(9, 5.5))
for name, d in nations.items():
    plt.plot(years, d["gdp_pc"], marker=markers[name], label=name)
plt.title("세 나라의 1인당 GDP 추이")
plt.xlabel("연도")
plt.ylabel("1인당 GDP (달러)")
plt.xticks(years)
plt.legend()
plt.grid(True, linestyle=":", alpha=0.6)
plt.show()

import numpy as np

labels = list(nations.keys())
nom = [nations[n]["avg_nom"] for n in labels]
real = [nations[n]["avg_real"] for n in labels]
x = np.arange(len(labels))
w = 0.35

plt.figure(figsize=(8, 5.5))
plt.bar(x - w / 2, nom, w, label="평균 명목성장", color="tab:blue", alpha=0.85)
plt.bar(x + w / 2, real, w, label="평균 실질성장", color="tab:green", alpha=0.85)
plt.axhline(0, color="grey", linewidth=0.8)
plt.xticks(x, labels)
plt.ylabel("연평균 성장률 (%)")
plt.title("명목과 실질은 다른 이야기를 한다")
plt.legend()
plt.grid(True, axis="y", linestyle=":", alpha=0.5)
plt.show()

plt.figure(figsize=(9, 5.5))
for name, d in nations.items():
    plt.plot(years, d["inf"], marker=markers[name], label=name)
plt.axhline(2.0, color="red", linestyle="--", linewidth=1, label="물가안정 목표 2%")
plt.title("세 나라의 물가 상승률")
plt.xlabel("연도")
plt.ylabel("물가 상승률 (%)")
plt.xticks(years)
plt.legend()
plt.grid(True, linestyle=":", alpha=0.6)
plt.show()

세 그림이 각각 한 문장으로 요약됩니다. 첫 그림: 국가 B가 가장 높은 자리에서 꾸준히 오르고, 국가 C는 바닥에서 거의 평평합니다. 둘째 그림: 파란 막대(명목)만 보면 세 나라가 엇비슷하지만, 초록 막대(실질)로 눈을 옮기면 국가 C만 0선 아래로 내려갑니다. 셋째 그림: 국가 C의 물가선은 목표 2%에서 세 배 넘게 떨어져 있습니다. 성장은 멎고 물가는 치솟는 스태그플레이션이 그림 두 장에 동시에 찍혀 있는 셈입니다. 그래프마다 이렇게 ’한 줄 문장’을 붙여 두는 습관은 나중에 보고서를 쓸 때 그대로 본문 문장이 됩니다.

계산과 그림을 리스트·딕셔너리·반복문만으로 끝냈습니다. 9장에서 익힌 판다스로는 같은 요약을 표 한 장으로 접을 수 있습니다. 아래 한 셀이 위의 여러 계산 결과를 데이터프레임으로 정리합니다. 도구를 바꿔도 데이터의 뼈대는 같은 딕셔너리라는 점을 확인해 두세요.

import pandas as pd

rows = []
for name, d in nations.items():
    rows.append({
        "국가": name,
        "2024 1인당GDP": round(d["gdp_pc"][-1]),
        "평균 명목성장(%)": round(d["avg_nom"], 2),
        "평균 실질성장(%)": round(d["avg_real"], 2),
    })
summary = pd.DataFrame(rows).set_index("국가")
summary
2024 1인당GDP 평균 명목성장(%) 평균 실질성장(%)
국가
국가 A 10755 4.46 1.76
국가 B 16627 4.77 2.84
국가 C 4151 3.38 -2.05

10.8 정책 실험: 세금은 누가 내는가

여기서부터 이콘 카페로 돌아갑니다. 6장에서 세운 아메리카노 시장 모형이 있습니다. 수요는 \(Q_d = 100 - 2P\), 공급은 \(Q_s = 10 + 3P\)이고, 가격은 100원 단위로 잽니다. 이 시장의 균형은 가격 18(1,800원), 거래량 64잔이었습니다.

이제 정부가 아메리카노 한 잔마다 세금 \(t\)를 매긴다고 합시다. 판매량 한 단위마다 일정액을 걷는 이런 세금을 종량세(specific tax)라고 부릅니다. 담뱃세나 유류세가 이런 방식입니다. 고지서는 카페(생산자) 앞으로 갑니다. 그러면 손님이 내는 가격은 그대로일까요.

세금이 생산자에게 부과되면, 생산자가 실제로 손에 쥐는 값은 손님이 낸 가격에서 세금을 뺀 \(P - t\)입니다. 공급은 ’받는 값’에 반응하므로 공급 함수의 가격 자리에 \(P-t\)가 들어갑니다.

\[Q_s = c + d(P - t)\]

새 균형은 수요와 이 새 공급을 같게 놓아 구합니다.

\[a - bP = c + d(P - t)\]

\(P\)에 대해 풀면 손님이 내는 새 가격이 나옵니다.

\[P_{\text{구매}} = \frac{a - c + d\,t}{b + d}\]

세금이 없을 때의 균형가격 \(P_0 = (a-c)/(b+d)\)와 견주면, 손님 가격은 정확히 \(\dfrac{d}{b+d}\,t\)만큼 올라갑니다. 이 값이 소비자가 지는 몫입니다. 나머지 \(\dfrac{b}{b+d}\,t\)는 생산자가 받는 값이 줄어드는 몫, 곧 생산자 부담입니다. 둘을 더하면 \(t\)로 딱 떨어집니다. 세금 전액은 결국 누군가 나눠 냅니다.

import market_models   # 7장에서 만든 모듈을 그대로 재사용

p0, q0 = market_models.find_equilibrium(a=100, b=2, c=10, d=3)
print(f"세금 전 균형: P* = {p0:.0f} (1,800원), Q* = {q0:.0f}잔")
세금 전 균형: P* = 18 (1,800원), Q* = 64잔
def tax_incidence(a, b, c, d, t):
    """생산자에게 부과한 종량세 t의 효과를 딕셔너리로 돌려줍니다."""
    p0 = (a - c) / (b + d)          # 세금 전 균형가격
    q0 = a - b * p0                 # 세금 전 균형거래량
    p_buy = (a - c + d * t) / (b + d)   # 손님이 내는 가격
    p_sell = p_buy - t                  # 생산자가 받는 가격
    q1 = a - b * p_buy                  # 세금 후 거래량
    return {
        "p0": p0, "q0": q0,
        "p_buy": p_buy, "p_sell": p_sell, "q1": q1,
        "consumer_burden": p_buy - p0,   # 소비자 부담(단위당)
        "producer_burden": p0 - p_sell,  # 생산자 부담(단위당)
        "revenue": t * q1,               # 정부 세수
        "dwl": 0.5 * t * (q0 - q1),      # 자중손실
    }

r = tax_incidence(a=100, b=2, c=10, d=3, t=5)   # t=5 -> 잔당 500원
print(r)
{'p0': 18.0, 'q0': 64.0, 'p_buy': 21.0, 'p_sell': 16.0, 'q1': 58.0, 'consumer_burden': 3.0, 'producer_burden': 2.0, 'revenue': 290.0, 'dwl': 15.0}

한 줄씩 뜯어보기

핵심은 p_buy = (a - c + d * t) / (b + d) 한 줄입니다. 세금이 없을 때의 균형가격 공식 (a - c) / (b + d)에 분자로 d * t가 더해진 모양입니다. 이 더해진 조각이 손님 가격을 밀어 올립니다. 바로 아래 p_sell = p_buy - t는 생산자가 세금을 떼고 손에 쥐는 값입니다. 손님은 p_buy를 내지만 생산자는 그보다 t만큼 적게 받습니다. 그 사이에 벌어진 간격이 세금이고, 이 간격을 세금이 만든 ’가격의 쐐기(wedge)’라고 부릅니다. 마지막으로 q1 = a - b * p_buy는 손님이 새 가격 앞에서 사려는 양이라 세금 전보다 줄어듭니다. 값이 오르면 덜 사는 수요의 법칙이 그대로 작동합니다.

print(f"세금 t = 5 (잔당 500원)")
print(f"  손님 가격 : {r['p0']:.0f} -> {r['p_buy']:.0f}   (소비자 부담 {r['consumer_burden']:.0f} = 300원)")
print(f"  생산자 수취: {r['p0']:.0f} -> {r['p_sell']:.0f}   (생산자 부담 {r['producer_burden']:.0f} = 200원)")
print(f"  거래량    : {r['q0']:.0f}잔 -> {r['q1']:.0f}잔")
print(f"  정부 세수 : {r['revenue']:.0f} (잔당 5 x {r['q1']:.0f}잔)")
print(f"  자중손실  : {r['dwl']:.0f}")

share_c = r["consumer_burden"] / (r["consumer_burden"] + r["producer_burden"]) * 100
print(f"  -> 세금 500원 중 손님이 {share_c:.0f}%, 카페가 {100 - share_c:.0f}%를 부담")
세금 t = 5 (잔당 500원)
  손님 가격 : 18 -> 21   (소비자 부담 3 = 300원)
  생산자 수취: 18 -> 16   (생산자 부담 2 = 200원)
  거래량    : 64잔 -> 58잔
  정부 세수 : 290 (잔당 5 x 58잔)
  자중손실  : 15
  -> 세금 500원 중 손님이 60%, 카페가 40%를 부담

고지서는 카페 앞으로 갔지만, 500원 중 300원은 손님이 냅니다. 카페는 잔당 가격을 1,800원에서 2,100원으로 올려 세금의 일부를 손님에게 넘겼고, 나머지 200원만 스스로 삼켰습니다. 세금을 누구에게 부과했는가와 누가 실제로 부담하는가가 어긋나는 이 현상을 조세 귀착(tax incidence)이라고 합니다.

실수 노트 — 법적 귀착과 경제적 귀착을 혼동하기

“정부가 카페에 세금을 매겼으니 카페가 부담한다”는 말은 절반만 맞습니다. 세금 고지서가 누구에게 가느냐는 법적 귀착이고, 그 부담이 결국 누구 지갑에서 나가느냐는 경제적 귀착입니다. 둘은 자주 어긋납니다. 실제로 잠시 뒤 확인하겠지만, 세금을 손님에게 부과하든 카페에 부과하든 두 사람이 나눠 지는 몫은 똑같습니다. 부담을 가르는 것은 고지서의 수신인이 아니라 시장의 탄력성입니다. 시험에서 “누가 세금을 내는가”라는 문제를 만나면, 고지서가 아니라 수요·공급 곡선의 기울기를 보라는 뜻입니다.

탄력성이 부담을 가른다

부담을 가르는 것이 탄력성이라면, 곡선의 기울기를 바꿔 가며 확인해 볼 수 있습니다. 두 시장을 준비합니다. 하나는 수요가 비탄력적인 시장입니다. 값이 올라도 사려는 양이 잘 줄지 않는, 담배처럼 끊기 어려운 재화를 떠올리면 됩니다(수요 곡선이 가파릅니다). 다른 하나는 수요가 탄력적인 시장으로, 값이 조금만 올라도 손님이 확 줄어드는 사치품에 가깝습니다(수요 곡선이 완만합니다). 두 시장에 똑같은 세금 \(t=5\)를 매깁니다.

# 비탄력 수요: b가 작아 수요선이 가파름 (값이 올라도 덜 줄어듦)
inelastic = tax_incidence(a=90, b=1, c=10, d=4, t=5)
# 탄력 수요: b가 커서 수요선이 완만함 (값이 조금 올라도 확 줄어듦)
elastic = tax_incidence(a=90, b=4, c=10, d=1, t=5)

for label, x in [("수요 비탄력(담배형)", inelastic), ("수요 탄력(사치품형)", elastic)]:
    total = x["consumer_burden"] + x["producer_burden"]
    cs = x["consumer_burden"] / total * 100
    print(f"{label}: 소비자 부담 {x['consumer_burden']:.0f}, "
          f"생산자 부담 {x['producer_burden']:.0f}  ->  손님이 {cs:.0f}% 부담")
수요 비탄력(담배형): 소비자 부담 4, 생산자 부담 1  ->  손님이 80% 부담
수요 탄력(사치품형): 소비자 부담 1, 생산자 부담 4  ->  손님이 20% 부담

같은 세금인데 결과가 정반대입니다. 수요가 비탄력적인 시장(담배형)에서는 손님이 80%를 뒤집어씁니다. 값이 올라도 어차피 사야 하니 카페가 마음 놓고 가격에 세금을 얹기 때문입니다. 수요가 탄력적인 시장(사치품형)에서는 반대로 손님이 20%만 부담합니다. 값을 올리면 손님이 떠나 버리니 카페가 세금을 대부분 스스로 삼킬 수밖에 없습니다.

정부가 담배에 높은 세금을 매기는 데는 이 성질이 한몫합니다. 수요가 비탄력적이라 세금을 매겨도 소비가 크게 줄지 않아 세수는 안정적으로 걷히고, 부담은 소비자에게 실립니다. 부담이 어디로 가느냐를 정하는 것은 정부의 의도가 아니라 시장의 탄력성이라는 사실이, 두 줄의 출력으로 또렷해집니다.

사라진 거래: 자중손실

세금에는 눈에 덜 띄는 비용이 하나 더 있습니다. 세금 전에는 64잔이 거래됐는데 세금 후에는 58잔으로 줄었습니다. 사라진 6잔은 어디로 갔을까요. 이 6잔은 원래라면 손님이 낼 용의가 있는 값이 카페가 받아야 할 값보다 높아 거래가 성사됐을 잔들입니다. 세금이 둘 사이에 쐐기를 박는 바람에, 서로 이득이 될 뻔한 거래가 아예 일어나지 못한 것입니다. 이렇게 세금 때문에 증발한 잉여를 자중손실(deadweight loss)이라고 부릅니다. 누구의 주머니로도 가지 않고 그냥 사라진 손실입니다. 이 삼각형을 그림으로 칠해 봅시다.

q = np.linspace(0, 100, 400)
p_dem = (100 - q) / 2        # 역수요: P = (a - Q) / b
p_sup = (q - 10) / 3         # 역공급: P = (Q - c) / d

q0, p0 = r["q0"], r["p0"]
q1, p_buy, p_sell = r["q1"], r["p_buy"], r["p_sell"]

plt.figure(figsize=(8, 6))
plt.plot(q, p_dem, color="tab:blue", label="수요 D")
plt.plot(q, p_sup, color="tab:red", label="공급 S")

# 자중손실: 사라진 거래 구간(q1~q0)을 수요선과 공급선 사이로 칠한다
mask = (q >= q1) & (q <= q0)
plt.fill_between(q[mask], (q[mask] - 10) / 3, (100 - q[mask]) / 2,
                 color="grey", alpha=0.45, label="자중손실")

# 세금이 벌린 가격의 쐐기: 손님이 내는 값과 생산자가 받는 값
plt.plot([q1, q1], [p_sell, p_buy], color="black", linewidth=2)
plt.scatter([q0, q1, q1], [p0, p_buy, p_sell], color="black", zorder=5)
plt.axhline(p0, color="grey", linestyle=":", linewidth=0.8)

plt.annotate(f"손님이 내는 값 {p_buy:.0f}", (q1, p_buy),
             textcoords="offset points", xytext=(8, 6))
plt.annotate(f"생산자가 받는 값 {p_sell:.0f}", (q1, p_sell),
             textcoords="offset points", xytext=(8, -14))
plt.annotate(f"세금 전 균형 ({q0:.0f}, {p0:.0f})", (q0, p0),
             textcoords="offset points", xytext=(6, 8))

plt.title("종량세가 만든 가격의 쐐기와 자중손실")
plt.xlabel("거래량 Q (잔)")
plt.ylabel("가격 P (100원 단위)")
plt.xlim(0, 100)
plt.ylim(0, 55)
plt.legend()
plt.grid(True, linestyle=":", alpha=0.5)
plt.show()

한 줄씩 뜯어보기

회색 삼각형은 fill_between이 그렸습니다. mask = (q >= q1) & (q <= q0)는 거래량 축에서 세금 후 거래량 58과 세금 전 거래량 64 사이 구간만 골라내는 조건입니다. &는 두 조건을 동시에 만족하는 자리에만 True를 남깁니다. 그 구간에서 fill_between의 아래 경계로 공급선 (q - 10) / 3, 위 경계로 수요선 (100 - q) / 2를 주면, 두 선 사이의 좁은 삼각형이 채워집니다. 이 삼각형의 왼쪽 변은 세금이 박은 쐐기(높이가 곧 세금 \(t\))이고, 오른쪽 꼭짓점은 세금 전 균형점입니다. 그림을 한 줄 문장으로 옮기면, 회색 삼각형이 클수록 세금이 시장에서 더 많은 거래를 지워 없앤다는 뜻입니다.

# [직접 해보기]
# tax_incidence로 세율을 t=5에서 t=8로 올렸을 때(같은 시장 a=100,b=2,c=10,d=3)
# 소비자 부담, 생산자 부담, 자중손실이 각각 어떻게 달라지는지 계산해 비교하세요.

# TODO: 여기에 코드를 작성하세요

10.9 가격에 다시 뚜껑을 씌우면

세금이 곡선을 움직여 새 균형을 만드는 정책이라면, 6장에서 본 가격상한제는 균형을 그대로 두고 가격에 뚜껑만 씌우는 정책입니다. 두 정책을 나란히 두면 대조가 선명합니다. 이콘 카페의 같은 시장에 가격상한 14(1,400원)를 씌워 봅니다. 균형가격 18보다 낮은 뚜껑입니다.

ceiling = 14
qd_c = market_models.quantity_demanded(ceiling, a=100, b=2)   # 상한에서 사려는 양
qs_c = market_models.quantity_supplied(ceiling, c=10, d=3)    # 상한에서 팔려는 양
shortage = qd_c - qs_c
traded = min(qd_c, qs_c)

print(f"균형가격 18에 상한 {ceiling}(1,400원)를 씌우면")
print(f"  사려는 양 Qd = {qd_c:.0f}잔")
print(f"  팔려는 양 Qs = {qs_c:.0f}잔")
print(f"  부족분     = {shortage:.0f}잔  (사고 싶어도 못 사는 손님)")
print(f"  실제 거래량 = {traded:.0f}잔  (팔 수 있는 만큼만)")
균형가격 18에 상한 14(1,400원)를 씌우면
  사려는 양 Qd = 72잔
  팔려는 양 Qs = 52잔
  부족분     = 20잔  (사고 싶어도 못 사는 손님)
  실제 거래량 = 52잔  (팔 수 있는 만큼만)
plt.figure(figsize=(8, 6))
plt.plot(q, (100 - q) / 2, color="tab:blue", label="수요 D")
plt.plot(q, (q - 10) / 3, color="tab:red", label="공급 S")
plt.axhline(18, color="grey", linestyle=":", linewidth=0.8, label="균형가격 18")
plt.axhline(ceiling, color="purple", linestyle="--", label=f"가격상한 {ceiling}")

plt.scatter([qs_c, qd_c], [ceiling, ceiling], color="black", zorder=5)
plt.plot([qs_c, qd_c], [ceiling, ceiling], color="purple", linewidth=3, alpha=0.5)
plt.annotate(f"팔려는 양 {qs_c:.0f}", (qs_c, ceiling),
             textcoords="offset points", xytext=(-12, 8))
plt.annotate(f"사려는 양 {qd_c:.0f}", (qd_c, ceiling),
             textcoords="offset points", xytext=(2, 8))

plt.title("가격상한이 만든 부족: 팔려는 양과 사려는 양의 간격")
plt.xlabel("거래량 Q (잔)")
plt.ylabel("가격 P (100원 단위)")
plt.xlim(0, 100)
plt.ylim(0, 55)
plt.legend()
plt.grid(True, linestyle=":", alpha=0.5)
plt.show()

상한 아래에서 손님은 72잔을 원하지만 카페는 52잔만 내놓습니다. 20잔이 부족합니다. 가격이 낮아 좋아 보이지만, 그 낮은 가격에 실제로 살 수 있는 손님은 52명뿐이고 나머지는 줄을 서거나 발길을 돌립니다. 세금이 거래를 줄이고 그 자리에 세수를 남겼다면, 가격상한은 거래를 줄이고 그 자리에 ’부족’과 줄서기를 남깁니다. 두 정책 모두 균형을 흔들면 어딘가에 대가가 남는다는 같은 교훈을 다른 얼굴로 보여 줍니다.

10.10 나만의 명령어: econo_cli.py 완성

지금까지의 분석은 모두 노트북 안에서 돌았습니다. 그런데 시장 파라미터만 바꿔 균형을 빠르게 확인하고 싶을 때마다 노트북을 열기는 번거롭습니다. 7장에서 sd_analyzer.py로 명령줄 도구의 첫걸음을 뗐고, 마지막에 이렇게 예고했습니다. 하나의 프로그램 아래 여러 하위 명령을 두는 서브커맨드(subcommand) 구조를 10장에서 완성한다고요. 그 약속을 지킬 차례입니다.

목표는 econo라는 이름 아래 세 가지 분석을 모으는 것입니다. equilibrium은 균형을 구하고, shift는 곡선이 이동했을 때 균형이 어떻게 달라지는지 견주고, tax는 방금 만든 조세 귀착을 계산합니다. 터미널에서 이렇게 부르게 됩니다.

python econo_cli.py equilibrium --a 100 --b 2 --c 10 --d 3
python econo_cli.py shift --a 100 --b 2 --c 10 --d 3 --da 20
python econo_cli.py tax --a 100 --b 2 --c 10 --d 3 --t 5

파일 이름 바로 뒤에 오는 equilibrium, shift, tax가 서브커맨드입니다. git을 쓸 때 git add, git commit처럼 명령 하나에 여러 갈래를 두는 그 구조와 같습니다.

%%writefile econo_cli.py
"""econo: a small command-line toolkit for a linear market.

Examples:
    python econo_cli.py equilibrium --a 100 --b 2 --c 10 --d 3
    python econo_cli.py shift --a 100 --b 2 --c 10 --d 3 --da 20
    python econo_cli.py tax --a 100 --b 2 --c 10 --d 3 --t 5
"""
import argparse

from market_models import find_equilibrium   # reuse the module from chapter 7


def add_market_args(parser):
    """Attach the four shared market parameters to a subcommand parser."""
    parser.add_argument("--a", type=float, default=100.0, help="demand intercept a")
    parser.add_argument("--b", type=float, default=2.0, help="demand slope b (>0)")
    parser.add_argument("--c", type=float, default=10.0, help="supply intercept c")
    parser.add_argument("--d", type=float, default=3.0, help="supply slope d (>0)")


def cmd_equilibrium(args):
    p, q = find_equilibrium(args.a, args.b, args.c, args.d)
    print(f"Qd = {args.a} - {args.b}P,  Qs = {args.c} + {args.d}P")
    if p is None:
        print("No positive equilibrium price for these parameters.")
    else:
        print(f"P* = {p:.2f}")
        print(f"Q* = {q:.2f}")


def cmd_shift(args):
    p0, q0 = find_equilibrium(args.a, args.b, args.c, args.d)
    p1, q1 = find_equilibrium(args.a + args.da, args.b, args.c + args.dc, args.d)
    print(f"before: P* = {p0:.2f}, Q* = {q0:.2f}")
    print(f"after : P* = {p1:.2f}, Q* = {q1:.2f}")
    price = "up" if p1 > p0 else "down" if p1 < p0 else "flat"
    qty = "up" if q1 > q0 else "down" if q1 < q0 else "flat"
    print(f"price {price}, quantity {qty}")


def cmd_tax(args):
    a, b, c, d, t = args.a, args.b, args.c, args.d, args.t
    p0 = (a - c) / (b + d)
    q0 = a - b * p0
    p_buy = (a - c + d * t) / (b + d)
    p_sell = p_buy - t
    q1 = a - b * p_buy
    print(f"per-unit tax t = {t}")
    print(f"buyer price  {p0:.2f} -> {p_buy:.2f}  (consumer burden {p_buy - p0:.2f})")
    print(f"seller price {p0:.2f} -> {p_sell:.2f}  (producer burden {p0 - p_sell:.2f})")
    print(f"quantity {q0:.2f} -> {q1:.2f}")
    print(f"tax revenue     = {t * q1:.2f}")
    print(f"deadweight loss = {0.5 * t * (q0 - q1):.2f}")


def build_parser():
    parser = argparse.ArgumentParser(prog="econo",
                                     description="Linear market analysis toolkit.")
    sub = parser.add_subparsers(dest="command", required=True)

    p_eq = sub.add_parser("equilibrium", help="find the market equilibrium")
    add_market_args(p_eq)
    p_eq.set_defaults(func=cmd_equilibrium)

    p_sh = sub.add_parser("shift", help="compare equilibrium before and after a shift")
    add_market_args(p_sh)
    p_sh.add_argument("--da", type=float, default=0.0, help="shift in demand intercept a")
    p_sh.add_argument("--dc", type=float, default=0.0, help="shift in supply intercept c")
    p_sh.set_defaults(func=cmd_shift)

    p_tax = sub.add_parser("tax", help="per-unit tax incidence")
    add_market_args(p_tax)
    p_tax.add_argument("--t", type=float, required=True, help="per-unit tax amount")
    p_tax.set_defaults(func=cmd_tax)

    return parser


if __name__ == "__main__":
    args = build_parser().parse_args()
    args.func(args)
Overwriting econo_cli.py

한 줄씩 뜯어보기

새 문법은 세 조각입니다. 첫째, sub = parser.add_subparsers(dest="command", required=True)가 서브커맨드를 받을 자리를 엽니다. dest="command"는 사용자가 고른 갈래 이름을 args.command에 담아 두라는 뜻이고, required=True는 갈래를 반드시 하나 골라야 한다는 조건입니다. 둘째, sub.add_parser("tax", ...)가 갈래 하나를 등록합니다. 갈래마다 자기만의 인자를 가질 수 있어서, tax에만 --t를 붙이고 shift에만 --da, --dc를 붙였습니다. 공통으로 필요한 --a부터 --d까지는 add_market_args 함수로 묶어, 세 갈래에 같은 네 줄을 복사하는 대신 함수 한 번 호출로 해결했습니다. 이 장 앞부분에서 배운 ’복붙 지우기’가 여기서도 그대로 쓰입니다. 셋째, p_tax.set_defaults(func=cmd_tax)가 각 갈래에 실행할 함수를 미리 붙여 둡니다. 그래서 맨 아래 args.func(args) 한 줄이, 사용자가 고른 갈래에 맞는 함수를 알아서 불러 줍니다.

%%writefileecono_cli.py 파일을 만들었습니다. 이제 터미널에서 python econo_cli.py ...로 부르면 되는데, 7장에서 그랬듯 노트북 안에서는 %run으로 그 실행을 미리 시험해 볼 수 있습니다. 세 서브커맨드를 차례로 불러 봅니다.

%run econo_cli.py equilibrium --a 100 --b 2 --c 10 --d 3
Qd = 100.0 - 2.0P,  Qs = 10.0 + 3.0P
P* = 18.00
Q* = 64.00
%run econo_cli.py tax --a 100 --b 2 --c 10 --d 3 --t 5
per-unit tax t = 5.0
buyer price  18.00 -> 21.00  (consumer burden 3.00)
seller price 18.00 -> 16.00  (producer burden 2.00)
quantity 64.00 -> 58.00
tax revenue     = 290.00
deadweight loss = 15.00
%run econo_cli.py shift --a 100 --b 2 --c 10 --d 3 --da 20
before: P* = 18.00, Q* = 64.00
after : P* = 22.00, Q* = 76.00
price up, quantity up

tax 명령이 노트북에서 계산한 조세 귀착(소비자 부담 3, 생산자 부담 2, 세수 290, 자중손실 15)과 한 숫자도 다르지 않습니다. 같은 tax_incidence의 셈을 터미널로 옮겨 놓았을 뿐이니 당연합니다. shift는 수요 절편을 20 올린(수요가 늘어난) 시장의 균형이 가격과 거래량 모두 오르는 것을 보여 줍니다. 그런데 인자 이름을 잊어버리면 어떻게 할까요. 사용 설명서는 우리가 따로 쓸 필요가 없습니다. argparse가 만들어 둔 --help를 부르면 됩니다.

%run econo_cli.py --help
usage: econo [-h] {equilibrium,shift,tax} ...

Linear market analysis toolkit.

positional arguments:
  {equilibrium,shift,tax}
    equilibrium         find the market equilibrium
    shift               compare equilibrium before and after a shift
    tax                 per-unit tax incidence

options:
  -h, --help            show this help message and exit

--help가 세 서브커맨드의 목록을 보여 줍니다. 특정 갈래의 사용법이 궁금하면 %run econo_cli.py tax --help처럼 갈래 이름 뒤에 --help를 붙이면, 그 갈래의 인자만 따로 안내합니다. 여기서 출력이 모두 영문과 숫자인 점을 짚고 갑니다. 7장의 사이드바에서 다뤘듯, 터미널의 옛 인코딩(cp949)과 파일의 utf-8이 어긋나면 한글이 깨질 수 있습니다. 그래서 남에게 건네줄 명령줄 도구의 출력은 P* = 21.00처럼 담백하게 두는 편이 뒤탈이 적습니다. 화면을 꾸미는 한글 설명은 노트북과 보고서에 맡기고, 터미널로 나가는 출력은 기호와 숫자로 짭니다.

실수 노트 — 서브커맨드를 빠뜨리기

서브커맨드를 required=True로 걸어 두었으므로, 갈래 이름 없이 python econo_cli.py --a 100처럼 부르면 프로그램이 실행되지 않고 오류를 냅니다. 이때 뜨는 메시지는 대략 이렇습니다.

usage: econo [-h] {equilibrium,shift,tax} ...
econo: error: the following arguments are required: command

the following arguments are required: command은 “갈래(command)를 하나 골라야 한다”는 뜻입니다. 맨 윗줄의 {equilibrium,shift,tax}가 고를 수 있는 보기를 알려 주니, 그중 하나를 파일 이름 바로 뒤에 붙여 다시 부르면 됩니다. argparse의 오류 메시지는 대부분 이렇게 무엇을 어떻게 고쳐야 하는지까지 함께 알려 줍니다.

10.11 세 나라 경제 진단 보고서

계산과 그림이 갖춰졌으니 이제 사람이 읽는 글로 옮깁니다. 아래는 이 장의 분석을 바탕으로 쓴 진단 보고서의 예시입니다. 11장의 캡스톤에서 여러분이 직접 쓸 보고서가 이런 모양입니다. 서론에서 질문과 주장을 세우고, 본문에서 숫자와 그림으로 뒷받침하고, 결론에서 한계를 밝히는 세 부분으로 짜여 있습니다. 문장이 어떻게 그림과 숫자를 끌어다 쓰는지 눈여겨보며 읽어 보세요.

세 나라의 경제, 무엇이 달랐나 — A·B·C 4년 진단

서론. 2021년부터 2024년까지 세 나라 A·B·C는 모두 명목 GDP가 늘었다. 표면만 보면 세 나라 모두 성장한 것처럼 보인다. 그러나 이 글은 다음을 주장한다. 명목 성장의 겉모습을 걷어내고 물가를 보정하면, 세 나라는 서로 전혀 다른 상태에 있으며 특히 국가 C는 성장과 물가가 함께 어긋난 스태그플레이션 국면에 있다.

분석. 근거는 세 가지다. 첫째, 1인당 GDP의 격차다(그림 1). 국가 B는 2024년 16,627달러로 가장 높은 자리에서 꾸준히 올랐고, 국가 C는 4,151달러로 4년 내내 거의 평평했다. 둘째, 명목과 실질의 어긋남이다(그림 2). 평균 명목 성장률만 보면 세 나라는 3~5%로 엇비슷했지만, 물가를 걷어낸 평균 실질 성장률은 국가 B 2.84%, 국가 A 1.76%인 반면 국가 C는 -2.05%로 홀로 0선 아래였다. 명목으로 3% 넘게 성장한 나라의 국민이 실제로는 해마다 덜 살 수 있게 되었다는 뜻이다. 셋째, 물가의 이탈이다(그림 3). 국가 C의 물가 상승률은 4년 평균 5%를 웃돌아 안정 목표 2%에서 세 배 넘게 벗어났다. 성장은 멎고 물가만 오르는 이 조합이 스태그플레이션의 정의에 그대로 들어맞는다.

결론. 세 나라를 한 줄로 요약하면, 국가 B는 안정적 성장, 국가 A는 무난하나 물가 관리에 여지가 있는 상태, 국가 C는 스태그플레이션이다. 다만 이 진단에는 한계가 있다. 자료는 4년치에 불과해 추세를 단정하기 이르고, 실질 성장률은 근사식(명목−물가)으로 구해 물가가 높은 국가 C에서는 오차가 상대적으로 크다. 또한 GDP와 물가만 보았을 뿐 고용, 소득분배, 대외 여건은 다루지 않았다. 더 확실한 진단을 위해서는 기간을 늘리고 지표를 넓혀야 한다.

보고서가 어떻게 짜였는지 뜯어봅시다. 서론의 마지막 문장이 이 글의 주장입니다. “명목을 걷어내면 세 나라가 다르고, 특히 C는 스태그플레이션”이라는 한 문장으로, 뒤에 올 모든 내용을 미리 예고합니다. 본문의 세 근거는 저마다 그림 하나와 숫자를 짝지어 인용합니다. “국가 B는 2.84%인 반면 국가 C는 -2.05%”처럼 구체적인 값을 대는 것이, “국가 C가 더 낮다”는 막연한 서술보다 훨씬 힘이 셉니다. 결론은 요약에 그치지 않고 자료의 한계를 스스로 밝힙니다. 자료가 4년뿐이라는 점, 실질 성장률이 근사값이라는 점을 인정하는 문장이 오히려 보고서의 신뢰를 높입니다. 좋은 분석은 자기가 무엇을 모르는지도 함께 적습니다.

10.12 연습문제

풀이는 부록 C에 있습니다.

손풀기

1. 실질 성장률. 어느 해 명목 GDP 성장률이 7%였고 그해 물가 상승률이 4.5%였다. 실질 성장률을 근사식으로 구하라. 이 나라의 국민이 그해 실제로 더 살 수 있게 되었는지 한 문장으로 판단하라.

2. 부담 배분. 종량세를 생산자에게 부과할 때, 소비자가 지는 몫의 비율은 \(d/(b+d)\)이다. 수요 기울기 \(b=1\), 공급 기울기 \(d=4\)인 시장에서 소비자 부담 비율을 계산하라. 이 시장의 수요는 탄력적인가 비탄력적인가, 그리고 그것이 부담 배분과 어떻게 이어지는지 설명하라.

3. 1인당 GDP. 명목 GDP가 88(십억 달러), 인구가 21.2(백만 명)인 국가 C의 2024년 1인당 GDP(달러)를 손으로 계산하라. gdp * 1000 / pop이 왜 달러 단위가 되는지 단위를 따라가며 설명하라.

응용

4. 국가 D 추가. nations 딕셔너리에 국가 D를 새 항목으로 추가하라. GDP [300, 315, 331, 350], 인구 [30, 30, 31, 31], 물가 [2.0, 2.1, 1.8, 1.9]이다. 이 장에서 만든 반복문을 그대로 써서 국가 D의 평균 명목 성장률과 평균 실질 성장률을 구하고, 앞의 세 나라와 견주어 국가 D가 어느 쪽에 가까운지 한 문장으로 판단하라. (딕셔너리 리팩터링의 효과를, 코드가 거의 바뀌지 않는 것으로 확인하라.)

5. 다른 시장의 세금. tax_incidence 함수로 시장 \(a=120,\ b=3,\ c=20,\ d=2\)에 세금 \(t=6\)을 매겼을 때의 소비자 부담, 생산자 부담, 세수, 자중손실을 구하라. 이 시장에서는 소비자와 생산자 중 누가 더 많이 부담하는지, 그 이유를 두 곡선의 기울기로 설명하라.

6. CLI로 비교. %run econo_cli.py tax 명령을 두 번 불러, 세율만 --t 5--t 10으로 바꿔 실행하라. 세수와 자중손실이 각각 어떻게 변하는지 두 출력을 나란히 놓고 비교해, 세금을 두 배로 올렸을 때 세수도 두 배가 되는지 확인하라.

도전

7. 세수가 가장 큰 세율(래퍼 곡선). 시장 \(a=100,\ b=2,\ c=10,\ d=3\)에서 세율 \(t\)를 0부터 50까지 1씩 올려 가며 각 세율의 세수 revenue를 계산해 리스트에 담아라. 세수를 세로축, 세율을 가로축으로 하는 선 그래프를 그리고, 세수가 가장 큰 세율을 찾아 표시하라. 세율을 높일수록 세수가 계속 늘지 않고 어느 지점에서 꺾여 도로 줄어드는 이 곡선을 래퍼 곡선(Laffer curve)이라 부른다. 왜 세율을 무작정 올리면 세수가 줄어드는지, 거래량 q1의 변화와 엮어 설명하라.

8. 부과 대상은 정말 무관한가. 본문에서는 세금을 생산자에게 부과했다. 이번에는 소비자에게 부과해 보라. 소비자가 세금을 낸다면 수요가 반응하는 가격은 \(P+t\)가 되어 수요 균형식이 \(a-b(P+t)=c+dP\)로 바뀐다. 이 식을 풀어 생산자가 받는 가격을 구하고, 소비자 부담과 생산자 부담이 본문(생산자 부과)의 결과와 정확히 같은지 코드로 확인하라. 결과가 같다면, “세금을 누구에게 부과하는가”는 부담 배분에 왜 영향을 주지 못하는지 한 문단으로 정리하라.

10.13 미니 프로젝트: 생각을 코드로, 코드를 글로

이 장의 진단 보고서를 본떠, 여러분이 직접 국가 D를 진단합니다. 30분에서 60분 사이의 과제입니다.

주어진 데이터. 아래 셀의 국가 D는 4년치 명목 GDP, 인구, 물가 상승률입니다. 이 나라가 어떤 상태인지는 계산하기 전에는 알 수 없습니다.

country_D = {
    "gdp": [300, 315, 331, 350],   # 명목 GDP (십억 달러)
    "pop": [30, 30, 31, 31],       # 인구 (백만 명)
    "inf": [2.0, 2.1, 1.8, 1.9],   # 물가 상승률 (%)
}
print("국가 D 원자료:", country_D)
국가 D 원자료: {'gdp': [300, 315, 331, 350], 'pop': [30, 30, 31, 31], 'inf': [2.0, 2.1, 1.8, 1.9]}

할 일.

  1. 국가 D의 연도별 1인당 GDP, 연간 명목 성장률, 연간 실질 성장률(명목−물가 근사)을 계산한다. 이 장에서 만든 함수와 반복문을 그대로 쓴다.
  2. 국가 D의 1인당 GDP 추이 또는 명목·실질 성장률 비교를, 그래프나 표 가운데 최소 하나로 만든다. 그림에는 제목과 축 이름을 붙이고, ’이 그림이 말하는 경제학 문장’을 캡션으로 단다.
  3. 그 결과를 아래 세 문단 틀에 맞춰 글로 옮긴다.

세 문단 글쓰기 틀.

  • 1문단(주장). 국가 D가 어떤 상태인지 한 문장으로 규정한다. “국가 D는 물가가 안정된 가운데 완만한 실질 성장을 이어 가는 나라다”처럼, 뒤에 올 근거를 예고하는 주장으로 연다.
  • 2문단(근거). 계산한 수치와 만든 그림을 인용해 주장을 뒷받침한다. “국가 D의 평균 실질 성장률은 ○○%로, 스태그플레이션에 빠진 국가 C(-2.05%)와 대비된다”처럼 구체적인 값을 대고, 그림 번호를 언급한다.
  • 3문단(한계와 확장). 이 진단이 무엇을 말하지 못하는지 밝힌다. 자료가 4년뿐이라는 점, 실질 성장률이 근사값이라는 점, GDP와 물가만 보았다는 점 가운데 최소 하나를 짚고, 진단을 더 확실히 하려면 무엇이 더 필요한지 적는다.

자기 점검 체크리스트.

10.14 요약과 다음 장 예고

이 장은 여덟 주치 문법을 한 분석 안에 모았습니다. 국가마다 복사했던 반복문을 딕셔너리 하나로 접으며, 거의 같은 코드가 반복되는 ’냄새’를 맡고 지우는 감각을 익혔습니다. 명목 GDP에서 물가를 걷어내자 세 나라의 순위가 뒤집혔고, 성장이 멎은 채 물가만 오르는 국가 C의 상태에 스태그플레이션이라는 이름을 붙였습니다. 후반부에서는 6장의 수요·공급 모형에 종량세를 얹어, 세금을 누구에게 부과하든 실제 부담은 탄력성이 가른다는 조세 귀착의 원리를 두 시나리오로 확인하고, 세금이 지워 없앤 거래를 자중손실 삼각형으로 칠했습니다. 가격상한제를 나란히 놓아, 균형을 흔드는 정책에는 저마다 다른 대가가 따른다는 것도 대조해 보았습니다. 마지막으로 7장에서 예고한 econo 명령을 argparse 서브커맨드로 완성해, 노트북 안의 분석을 터미널 한 줄로 부를 수 있게 만들었습니다. 그리고 이 모든 계산을 사람이 읽는 진단 보고서로 옮기며, 주장과 근거와 한계를 갖춘 글의 뼈대를 미리 보았습니다.

다음 장은 캡스톤입니다. 지금까지는 주어진 데이터와 정해진 질문을 다뤘다면, 이제는 여러분이 스스로 질문을 세우고 그에 맞는 모델이나 데이터를 골라 답을 만들어 갑니다. 최저임금과 알바 시장, 배달비와 수요 탄력성, 학교 앞 카페의 매출처럼 손에 잡히는 주제를 하나 골라, 질문에서 그림으로, 그림에서 보고서로 이어지는 간이 연구 한 편을 완성합니다. 이 장에서 읽은 진단 보고서가 그 글의 견본이 됩니다.