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"] = False14 부록 C. 연습문제 풀이
15 부록 C. 연습문제 풀이
이 부록은 본문 각 장의 연습문제와 부록 B의 버그 찾기 문제를 한자리에 모아 푼 풀이집입니다. 장별로 손풀기, 응용, 도전 순서를 그대로 따랐고, 문제마다 요지를 한 줄로 되짚은 뒤 실행되는 풀이 코드와 짧은 해설을 붙였습니다.
풀이를 펴기 전에 스스로 5분쯤 씨름해 보기를 권합니다. 답을 먼저 보면 눈으로는 이해되지만 손은 배우지 못합니다. 막혔을 때는 풀이 전체를 베끼기보다, 코드 한 줄과 그 아래 해설 한 문장만 확인하고 노트북으로 돌아가는 편이 남습니다.
몇 가지 약속입니다. 첫째, 이 부록은 단독으로 처음부터 끝까지 실행됩니다. 각 장에서 쓰던 함수와 데이터가 필요하면 풀이 셀 안에서 다시 정의했습니다. 둘째, input()으로 값을 받는 문제는 노트북에서 그대로 실행할 수 없으므로, 입력 대신 예시 값을 코드에 직접 넣어 두었습니다. 실제로 쓸 때는 그 자리에 input()을 넣으면 됩니다. 셋째, 미니 프로젝트와 11장 캡스톤은 정답이 하나로 정해지지 않는 열린 과제라 이 부록에서 풀지 않습니다. 대신 각 장의 명세와 자기 점검 체크리스트를 길잡이로 삼으세요.
15.1 그래프 준비
# Colab에서 이 부록을 열었다면, 위 셀 대신 아래 두 줄을 먼저 실행하세요.
!pip install koreanize-matplotlib
import koreanize_matplotlib그래프가 있는 풀이(6장, 8장, 10장)는 이 폰트 설정에 기대어 한글 제목과 축 라벨을 그립니다. 로컬 주피터라면 위의 실행 셀 한 번으로 충분하고, Colab이라면 바로 위 회색 셀의 koreanize-matplotlib를 대신 씁니다.
15.2 1장 풀이 — 파이썬, 경제학의 새 연필
손풀기 1. 이콘 카페 메뉴판. print() 세 번으로 메뉴 이름·가격·"원"을 쉼표로 잇습니다.
print("아메리카노", 3000, "원")
print("카페라떼", 3800, "원")
print("크루아상", 4200, "원")아메리카노 3000 원
카페라떼 3800 원
크루아상 4200 원
print에 쉼표로 여러 값을 넘기면 값 사이에 공백을 하나씩 넣어 한 줄로 이어 줍니다. 문자열과 숫자를 섞어 넘겨도 알아서 글자로 바꿔 출력하지요. 흔한 오답은 "아메리카노" + 3000처럼 더하기로 잇는 것인데, 문자열과 정수는 더할 수 없어 TypeError가 납니다. 쉼표는 더하기와 다릅니다.
손풀기 2. 아르바이트 일당. 최저시급 10,320원으로 하루 6시간 일당을 구합니다.
# 최저시급 x 근무시간 = 하루 일당
daily = 10320 * 6
print("일당:", daily, "원")일당: 61920 원
계산 위에 주석 한 줄을 달아 두면 몇 주 뒤의 내가 코드를 다시 읽을 때 무슨 계산이었는지 바로 압니다. 결과는 61,920원. 곱셈만 있으면 정수로 나오므로 소수점이 붙지 않습니다.
손풀기 3. 김밥과 잔돈. 만 원으로 1,500원짜리 김밥을 //와 %로 나눕니다.
print("살 수 있는 김밥:", 10000 // 1500, "줄")
print("남는 돈:", 10000 % 1500, "원")살 수 있는 김밥: 6 줄
남는 돈: 1000 원
//는 나눗셈의 몫, %는 나머지입니다. 6줄을 사면 9,000원을 쓰고 1,000원이 남지요. 6과 1,000이라는 두 답이 6 * 1500 + 1000 = 10000으로 맞물립니다. 일반 나눗셈 /를 쓰면 6.666…이 나와 “몇 줄”이라는 질문에 답이 되지 않습니다.
응용 4. 할인 계산. 45,000원 운동화의 20% 할인가를 구합니다.
price = 45000
discount = price * 0.2
final = price - discount
print("할인 금액:", discount, "원")
print("최종 가격:", final, "원")할인 금액: 9000.0 원
최종 가격: 36000.0 원
할인 금액은 원래 가격에 0.2를 곱해 9,000원, 최종 가격은 그만큼을 빼서 36,000원입니다. 0.2를 곱하는 순간 결과가 실수가 되어 9000.0으로 나오는데, 자릿수가 필요하면 나중에 배울 서식으로 다듬으면 됩니다. 20%를 0.2가 아닌 20으로 곱하는 것이 가장 흔한 실수입니다.
응용 5. 환율 환산. 1달러 1,350원에서 곱하기와 나누기를 구분합니다.
rate = 1350
print("25달러 =", 25 * rate, "원")
print("50000원 =", 50000 / rate, "달러")25달러 = 33750 원
50000원 = 37.03703703703704 달러
달러를 원으로 바꿀 때는 환율을 곱하고, 원을 달러로 바꿀 때는 나눕니다. 25달러는 33,750원, 50,000원은 약 37.04달러입니다. 방향을 헷갈려 곱하고 나눗셈을 바꾸면 자릿수가 터무니없어지므로, “달러에 원 가격을 곱한다”는 말을 소리 내어 확인하는 습관이 도움이 됩니다.
응용 6. 어느 나라의 성장률. 1,800조에서 1,845조로 커진 GDP의 성장률과 두 배 도달 햇수를 구합니다.
growth = (1845 - 1800) / 1800 * 100
print("성장률:", growth, "%")
print("두 배까지:", 72 / growth, "년")성장률: 2.5 %
두 배까지: 28.8 년
성장률 공식은 (올해 - 작년) / 작년 x 100으로 2.5%가 나옵니다. 72의 법칙은 72를 성장률로 나눠 두 배 도달 시점을 어림하는 규칙이라 28.8년이 나오고요. 이 어림이 어디서 오는지는 4장에서 실제 복리로 검증합니다.
도전 7. 이콘 카페의 손익분기점. 잔당 2,200원 마진으로 고정비 250,000원을 메우는 잔 수를 찾습니다.
count = 250000 // 2200
short = 250000 % 2200
print("고정비를 나눈 몫:", count, "잔")
print("그때까지 모자란 돈:", short, "원")
# 113잔이면 아직 1,400원이 모자라므로, 정확히 114잔째부터 이익이 난다.고정비를 나눈 몫: 113 잔
그때까지 모자란 돈: 1400 원
몫 113은 고정비를 아직 다 못 메운 잔 수입니다. 113잔이면 113 x 2,200 = 248,600원이라 250,000원에 1,400원(나머지)이 모자라지요. 그래서 114잔째에 2,200원이 더 들어와야 비로소 흑자로 돌아섭니다. 250000 / 2200은 113.6이라 “올림하면 114”라고 답할 수도 있는데, //와 %로 몫과 부족액을 함께 보면 왜 114인지가 눈에 보입니다.
도전 8. 복리와 단리. 100만 원을 연 4%로 18년 맡길 때 두 방식을 비교합니다.
compound = 1000000 * 1.04 ** 18
simple = 1000000 * (1 + 0.04 * 18)
print("복리:", compound, "원")
print("단리:", simple, "원")
print("차이:", compound - simple, "원")
# 72 / 4 = 18년이면 두 배. 복리 결과가 약 2.03배이니 법칙과 잘 맞는다.복리: 2025816.5153785325 원
단리: 1720000.0 원
차이: 305816.51537853247 원
복리는 이자에 다시 이자가 붙어 1.04를 열여덟 번 곱하며 지수적으로 불어나고, 단리는 원금에만 붙어 선형으로 늘어납니다. 18년이면 그 차이가 30만 원을 넘습니다. 72의 법칙은 72 / 4 = 18년을 두 배 시점으로 예측하는데, 실제 복리 배수가 약 2.03배로 2에 가깝게 나와 어림이 잘 맞음을 확인할 수 있습니다. 거듭제곱 **가 복리라는 현상을 붙잡는 연산입니다.
15.3 2장 풀이 — 변수와 자료형
손풀기 1. 알바 주급을 변수로. 시급·근무시간·근무일을 변수에 담아 주급을 계산합니다.
hourly_wage = 10030
hours_per_day = 5
days_per_week = 3
weekly_pay = hourly_wage * hours_per_day * days_per_week
print(f"{weekly_pay:,}원")150,450원
값마다 이름을 붙여 두면 계산이 무엇을 뜻하는지 읽힙니다. 주급은 150,450원. :, 서식은 천 단위마다 쉼표를 찍어 큰 수를 읽기 쉽게 만들어 줍니다.
손풀기 2. 자료형 알아맞히기. 네 값의 자료형을 type()으로 확인합니다.
print(type(2026))
print(type(1350.5))
print(type("1350.5"))
print(type(4000 > 3500))<class 'int'>
<class 'float'>
<class 'str'>
<class 'bool'>
따옴표 없는 정수는 int, 소수점이 있으면 float, 따옴표로 감싸면 글자열 str, 비교의 결과는 참/거짓의 bool입니다. 특히 "1350.5"는 겉보기엔 숫자지만 글자열이라 그대로는 계산에 쓸 수 없다는 점이 다음 문제로 이어집니다.
손풀기 3. 문자열을 숫자로. 문자열 "3200000"을 정수로 바꿔 월 부담액을 구합니다.
annual = int("3200000")
monthly = annual / 12
print(f"{monthly:,.0f}")266,667
int()로 글자열을 정수로 바꾼 뒤 12로 나눠 266,667원을 얻습니다. :,.0f는 소수점 이하를 반올림해 없애고 천 단위 쉼표를 붙이는 서식입니다. 변환을 빼고 "3200000" / 12를 하면 글자열은 나눌 수 없다며 TypeError가 납니다.
응용 4. 학식과 도시락 가격 차이 보고. 두 가격의 차이를 금액과 비율로 함께 알립니다.
haksik = 4000
dosirak = 4800
diff = dosirak - haksik
ratio = diff / haksik
print(f"도시락이 학식보다 {diff:,}원, {ratio:.1%} 더 비싸다")도시락이 학식보다 800원, 20.0% 더 비싸다
차이는 800원이고, 학식 가격을 기준으로 한 비율은 20.0%입니다. 금액에는 :,, 비율에는 :.1%를 써서 0.2라는 소수를 20.0%로 보여 줍니다. 비율의 분모를 무엇으로 두느냐(여기서는 학식)에 따라 값이 달라지므로 기준을 분명히 해야 합니다.
응용 5. 최저시급의 명목과 실질. 9,860원에서 10,030원, 물가 2.3% 가정으로 두 인상률을 구합니다.
wage_last = 9860
wage_this = 10030
inflation = 0.023
nominal = wage_this / wage_last - 1
real = (1 + nominal) / (1 + inflation) - 1
print(f"명목 인상률: {nominal:.2%}")
print(f"실질 인상률: {real:.2%}")
# 실질 인상률이 음수이므로, 물가를 감안하면 알바생의 구매력은 오히려 줄었다.명목 인상률: 1.72%
실질 인상률: -0.56%
명목 인상률은 약 1.72%지만, 물가 2.3%를 걷어낸 실질 인상률은 약 -0.56%로 부호가 음수입니다. 본문 등록금 예제와 같은 방식으로, 실질값은 명목을 (1 + 물가)로 나눠 구합니다. 시급의 숫자는 올랐어도 실제로 살 수 있는 양은 줄었다는 뜻이지요. 명목의 착시를 실질이 바로잡습니다.
응용 6. 커피값 계산기. 잔당 가격과 잔 수를 받아 한 달 커피 지출을 계산합니다.
# input() 대신 예시 값을 넣었습니다. 실제로는 아래 두 줄을 input()으로 받습니다.
price = 3000 # price = int(input("커피 한 잔 가격: "))
cups = 18 # cups = int(input("이번 달 마신 잔 수: "))
spend = price * cups
print(f"한 달 커피 지출: {spend:,}원")한 달 커피 지출: 54,000원
두 입력값은 모두 계산에 쓰이므로 정수로 변환해야 합니다. input()은 언제나 글자열을 돌려주기 때문에, int()로 감싸지 않으면 price * cups가 곱셈이 아니라 글자열 반복이 되어 엉뚱한 결과가 나옵니다(부록 B-3이 바로 그 함정).
도전 7. 예산 제약 리포트 생성기. 잔고와 물건 세 개 가격으로 네 줄 보고서를 만듭니다.
# input() 대신 예시 값을 넣었습니다.
balance = 50000
item1, item2, item3 = 28000, 4500, 12000
cart_total = item1 + item2 + item3
is_affordable = balance >= cart_total
print(f"장바구니 합계: {cart_total:,}원")
print(f"통장 잔고: {balance:,}원")
print(f"구매 가능 여부: {is_affordable}")
print(f"결제 후 잔액: {balance - cart_total:,}원")장바구니 합계: 44,500원
통장 잔고: 50,000원
구매 가능 여부: True
결제 후 잔액: 5,500원
조건문 없이도 이 장의 도구만으로 완성됩니다. 핵심은 balance >= cart_total이 참/거짓의 불리언을 그대로 돌려준다는 점입니다. 마지막 줄의 잔액은 음수가 될 수 있는데, 그 음수 자체가 “얼마나 모자라는지”를 말해 줍니다. 예시 값에서는 합계 44,500원이라 5,500원이 남습니다.
15.4 3장 풀이 — 조건문
손풀기 1. 도시락을 살 수 있을까. 예산과 가격을 비교해 남는 돈 또는 모자란 돈을 알립니다.
lunch_money = 6000
dosirak = 5800
if lunch_money >= dosirak:
print(f"살 수 있다. 남는 돈: {lunch_money - dosirak}원")
else:
print(f"부족하다. 모자란 금액: {dosirak - lunch_money}원")살 수 있다. 남는 돈: 200원
>=는 경계(같을 때)를 포함하므로 예산이 가격과 같아도 살 수 있습니다. 여기서는 200원이 남지요. if와 else에서 뺄셈의 방향을 뒤집어, 각 갈림길에 맞는 양수를 출력하는 것이 요령입니다.
손풀기 2. 성장 국면 판정. 성장률의 부호로 성장·제자리·후퇴를 가릅니다.
growth = -0.4
if growth > 0:
print("성장")
elif growth == 0:
print("제자리")
else:
print("후퇴")후퇴
-0.4는 음수라 마지막 else로 떨어져 “후퇴”가 나옵니다. ==는 “같은가”를 묻는 비교이고 값을 넣는 =와 다릅니다. growth를 양수·0·음수로 바꿔 세 갈래가 모두 도는지 확인하는 습관이 조건문 검증의 기본입니다.
손풀기 3. 물가안정목표 구간. 연쇄 비교로 안정 구간을 판정합니다.
inflation = 2.4
if 1.0 <= inflation < 3.0:
print("안정 구간")
elif inflation < 1.0:
print("저물가")
else:
print("고물가")안정 구간
1.0 <= inflation < 3.0은 수학의 구간 표기를 그대로 옮긴 연쇄 비교로, 2.4는 여기에 걸려 “안정 구간”이 됩니다. 안정 구간을 먼저 판정한 뒤 남은 두 경우를 갈라야 논리가 깔끔합니다. 순서를 바꾸면 엉뚱한 가지가 값을 가로챌 수 있습니다.
응용 4. 스태그플레이션 경보. 고물가와 저성장이 겹치는지 and로 판정합니다.
inflation = 5.1
growth = 0.4
if inflation > 4 and growth < 1:
print(f"스태그플레이션 우려 (물가 {inflation}%, 성장 {growth}%)")
else:
print("우려 단계 아님")스태그플레이션 우려 (물가 5.1%, 성장 0.4%)
and는 두 조건이 모두 참일 때만 참입니다. 물가 5.1%는 4%를 넘고 성장 0.4%는 1% 미만이라 둘 다 참이 되어 경보가 울립니다. 물가만 높거나 성장만 낮아서는 스태그플레이션이라 부르지 않는다는 정의가 and에 담겨 있습니다.
응용 5. 원두 재주문 판정. 재고 부족 또는 행사 예정을 or로 판정합니다.
stock_kg = 4.2
has_event = True
if stock_kg < 5 or has_event:
print("재주문 필요")
else:
print("재고 충분")재주문 필요
or는 둘 중 하나만 참이어도 참입니다. 재고 4.2kg은 5kg 미만이라 첫 조건이 이미 참이고, 행사도 예정이라 어느 쪽으로 보아도 재주문이 필요합니다. “둘 다 아니어야” 재고 충분이 되는 구조가 or의 성격을 보여 줍니다.
응용 6. 가격상한 삼중 판정. 상한과 균형가격의 대소로 세 경우를 가릅니다.
price_ceiling = 1800
equilibrium_price = 2000
if price_ceiling < equilibrium_price:
print("구속력 있음: 초과수요 발생")
elif price_ceiling > equilibrium_price:
print("구속력 없음")
else:
print("경계 사례: 시장가격과 일치")구속력 있음: 초과수요 발생
상한이 균형가격보다 낮아야(<) 시장을 실제로 묶어 초과수요를 부릅니다. 여기서는 1,800 < 2,000이라 “구속력 있음”이 나오지요. 상한을 2,000, 2,200으로 바꿔 세 번 실행하면 나머지 두 가지가 나오는 것을 확인할 수 있습니다.
도전 7. 평균세율 계산기. 과세표준 66,000,000원의 산출세액과 평균세율을 구합니다.
income = 66_000_000
if income <= 14_000_000:
tax = income * 0.06
elif income <= 50_000_000:
tax = income * 0.15 - 1_260_000
elif income <= 88_000_000:
tax = income * 0.24 - 5_760_000
elif income <= 150_000_000:
tax = income * 0.35 - 15_440_000
else:
tax = income * 0.38 - 19_940_000
average = tax / income * 100
print(f"산출세액: {tax:,.0f}원")
print(f"평균세율: {average:.2f}%")
# 한계세율은 24%지만, 낮은 구간에 6%와 15%가 적용되므로 평균은 그보다 낮다.산출세액: 10,080,000원
평균세율: 15.27%
누진세는 소득 전체에 최고세율을 매기지 않고, 낮은 구간부터 차곡차곡 세율을 쌓습니다. 66,000,000원의 마지막 원에는 24%가 붙지만(한계세율), 앞의 1,400만 원은 6%, 그다음 3,600만 원은 15%로 계산되므로 전체를 나눈 평균세율은 약 15.27%로 한계세율보다 낮습니다. 코드가 쓴 누진공제 방식(\(income \times 세율 - 공제액\))은 이 구간별 합산을 한 줄로 압축한 것입니다. 도전 8과 부록 B-1이 이 구간 구조를 더 파고듭니다.
도전 8. 구간 경계 실험. 88,000,000원과 89,000,000원의 세후 소득이 역전되지 않음을 확인합니다.
income = 88_000_000 # 24% 구간의 끝
tax = income * 0.24 - 5_760_000
print(f"과표 {income:,}원: 세액 {tax:,.0f}원, 세후 {income - tax:,.0f}원")
income = 89_000_000 # 35% 구간의 시작
tax = income * 0.35 - 15_440_000
print(f"과표 {income:,}원: 세액 {tax:,.0f}원, 세후 {income - tax:,.0f}원")과표 88,000,000원: 세액 15,360,000원, 세후 72,640,000원
과표 89,000,000원: 세액 15,710,000원, 세후 73,290,000원
구간이 24%에서 35%로 바뀌었는데도 세후 소득은 72,640,000원 < 73,290,000원으로 89,000,000원 쪽이 더 많습니다. 누진세는 구간을 넘어선 초과분(여기서는 1,000,000원)에만 높은 세율을 매기기 때문입니다. 만약 소득 전체에 35%를 한꺼번에 물렸다면 세후 소득이 역전됐겠지만, 누진공제 방식이 그런 절벽을 없앱니다. “구간이 바뀌면 손해”라는 흔한 오해가 여기서 풀립니다.
15.5 4장 풀이 — 리스트와 반복문
손풀기 1. 일주일 지출 장부. 인덱싱과 요약 함수로 지출을 살핍니다.
daily_spend = [8500, 12000, 5600, 9900, 15200, 22000, 7300]
print("수요일:", daily_spend[2], "원 / 일요일:", daily_spend[-1], "원")
print("기록 일수:", len(daily_spend), "일 / 총지출:", sum(daily_spend), "원")
print(f"가장 많이 쓴 날과 아낀 날의 차이: {max(daily_spend) - min(daily_spend):,}원")수요일: 5600 원 / 일요일: 7300 원
기록 일수: 7 일 / 총지출: 80500 원
가장 많이 쓴 날과 아낀 날의 차이: 16,400원
인덱스는 0부터 세므로 월요일이 [0], 수요일은 [2]입니다. 음수 인덱스 [-1]은 뒤에서 첫 번째, 곧 일요일이지요. len, sum, max, min은 리스트를 한눈에 요약하는 네 가지 도구로, 여기서는 7일·80,500원·16,400원을 알려 줍니다.
손풀기 2. 주중과 주말. 슬라이싱으로 나눠 하루 평균을 비교합니다.
daily_spend = [8500, 12000, 5600, 9900, 15200, 22000, 7300]
weekday = daily_spend[:5]
weekend = daily_spend[5:]
wd_avg = sum(weekday) / len(weekday)
we_avg = sum(weekend) / len(weekend)
print(f"주중 평균: {wd_avg:.1f}원 / 주말 평균: {we_avg:.1f}원")
print(f"주말이 주중의 {we_avg / wd_avg:.1f}배")주중 평균: 10240.0원 / 주말 평균: 14650.0원
주말이 주중의 1.4배
[:5]는 앞 다섯 개, [5:]는 여섯 번째부터 끝까지를 잘라 냅니다. 콜론 앞뒤 숫자가 시작과 끝을 정하는데, 끝 번호는 포함되지 않는다는 규칙만 기억하면 됩니다. 주말 하루 평균이 주중의 약 1.4배로, 놀 때 지갑이 더 열립니다.
손풀기 3. range 세 형태. range의 세 가지 쓰임을 각각 출력합니다.
for i in range(10):
print(i, end=" ")
print()
for year in range(2015, 2025):
print(year, end=" ")
print()
for year in range(2016, 2025, 2):
print(year, end=" ")
print()0 1 2 3 4 5 6 7 8 9
2015 2016 2017 2018 2019 2020 2021 2022 2023 2024
2016 2018 2020 2022 2024
range(10)은 0부터 9까지, range(2015, 2025)는 시작과 끝을 정한 형태, range(2016, 2025, 2)는 2씩 건너뛰는 형태입니다. 세 경우 모두 끝 번호는 포함되지 않으므로, 2024년까지 나오게 하려면 끝을 2025로 써야 합니다. print(..., end=" ")는 줄바꿈 대신 공백으로 이어 붙여 한 줄로 보여 줍니다.
응용 4. 바쁜 날 골라내기. for와 if로 60잔 이상 판 날을 셉니다.
sales_2w = [52, 48, 63, 55, 88, 73, 41, 58, 50, 66, 49, 91, 70, 44]
busy = []
for s in sales_2w:
if s >= 60:
busy.append(s)
print("60잔 이상 판 날:", len(busy), "일")
print(f"바쁜 날 평균: {sum(busy) / len(busy):.1f}잔 / 전체 평균: {sum(sales_2w) / len(sales_2w):.1f}잔")60잔 이상 판 날: 6 일
바쁜 날 평균: 75.2잔 / 전체 평균: 60.6잔
반복문 안에서 조건에 맞는 값만 append로 새 리스트에 모으는 것이 필터의 기본 패턴입니다. 60잔 이상은 6일이고, 그 날들의 평균(약 75.2잔)은 전체 평균(약 60.6잔)보다 높습니다. 바쁜 날만 따로 보면 얼마나 바빴는지가 드러납니다.
응용 5. 24개월 적금. 매달 20만 원 납입에 월 이율 0.2%가 붙는 적금의 잔액을 구합니다.
balance = 0
for month in range(24):
balance = balance * (1 + 0.002) + 200_000
principal = 200_000 * 24
print(f"24개월 뒤 잔액: {balance:,.0f}원")
print(f"납입 원금: {principal:,}원 / 이자 총액: {balance - principal:,.0f}원")24개월 뒤 잔액: 4,912,036원
납입 원금: 4,800,000원 / 이자 총액: 112,036원
매달 기존 잔액에 이자가 붙은 뒤 새 납입금이 더해집니다. 이 한 줄 balance = balance * (1 + 이율) + 납입이 적금의 핵심이지요. 24개월 뒤 약 491만 원이 모이고, 납입 원금 480만 원과의 차이인 약 11만 원이 이자입니다. 납입금은 저마다 붙어 있는 기간이 달라 이자가 원금의 단순 비례로는 나오지 않습니다.
응용 6. 통장의 런웨이. while로 매달 순유출 아래에서 몇 달을 버티는지 셉니다.
balance = 3_000_000
net_out = 450_000 - 320_000 # 월세가 나가고 알바비가 들어와 매달 13만 원 순유출
months = 0
while balance >= net_out:
balance -= net_out
months += 1
print(f"{months}개월 버티고, 잔액은 {balance:,}원")23개월 버티고, 잔액은 10,000원
몇 달이 걸릴지 미리 모르므로 while이 제격입니다. 잔액이 한 달 순유출액보다 작아지는 순간 조건이 거짓이 되어 반복이 멈춥니다. 23개월을 버티고 10,000원이 남는데, 이 남은 돈은 한 달 치를 마저 내기엔 모자란 액수입니다.
도전 7. 연평균 성장률의 정체. 세 해 성장률의 산술평균과 기하평균을 비교합니다.
rates = [12.0, -8.0, 5.0]
index = 100.0
for r in rates:
index = index * (1 + r / 100)
arith = sum(rates) / len(rates)
geo = (index / 100) ** (1 / 3) - 1
print(f"누적 지수: {index:.3f}")
print(f"산술평균: {arith:.2f}% / 기하평균(연평균): {geo * 100:.2f}%")
print(f"검산: {100 * (1 + geo) ** 3:.3f}")
# 성장률은 더해지지 않고 곱해져 누적되므로, 실제 연평균은 곱셈 기반의 기하평균이다.누적 지수: 108.192
산술평균: 3.00% / 기하평균(연평균): 2.66%
검산: 108.192
성장률은 해마다 곱해지며 쌓입니다. 그래서 진짜 연평균은 누적 지수의 세제곱근으로 구하는 기하평균이어야 하고, 여기서는 약 2.66%가 나옵니다. 산술평균 3.0%로 3년을 키우면 100 x 1.03의 세제곱 = 109.27이 되어 실제 누적 108.192와 어긋납니다. 변동이 클수록 기하평균이 산술평균보다 작아지는데, -8%의 손실을 메우려면 그만큼 이상을 벌어야 하는 비대칭 때문입니다. 검산이 누적 지수와 정확히 맞아떨어지는 것이 기하평균이 옳은 답임을 보여 줍니다.
도전 8. 72의 법칙 검증표. for 안의 while로 실제 두 배 도달 연수를 법칙과 견줍니다.
for rate in range(1, 10):
level = 1.0
years = 0
while level < 2:
level = level * (1 + rate / 100)
years += 1
print(f"성장률 {rate}%: 실제 {years}년, 법칙의 예측 {72 / rate:.1f}년")
# 72의 법칙은 성장률 7~9% 구간에서 가장 잘 맞는다.성장률 1%: 실제 70년, 법칙의 예측 72.0년
성장률 2%: 실제 36년, 법칙의 예측 36.0년
성장률 3%: 실제 24년, 법칙의 예측 24.0년
성장률 4%: 실제 18년, 법칙의 예측 18.0년
성장률 5%: 실제 15년, 법칙의 예측 14.4년
성장률 6%: 실제 12년, 법칙의 예측 12.0년
성장률 7%: 실제 11년, 법칙의 예측 10.3년
성장률 8%: 실제 10년, 법칙의 예측 9.0년
성장률 9%: 실제 9년, 법칙의 예측 8.0년
바깥 for가 성장률을 1%부터 9%까지 훑고, 안쪽 while이 그 성장률로 두 배가 될 때까지 햇수를 셉니다. 핵심은 for가 한 바퀴 돌 때마다 level과 years를 다시 1.0과 0으로 초기화하는 것입니다. 이걸 빠뜨리면 앞 성장률의 결과가 이어져 값이 엉킵니다(4.2절 실수 노트가 경고한 상황의 반대편). 출력을 보면 72의 법칙은 8% 근처에서 가장 정확하고, 저성장 구간에서는 어림의 오차가 조금 커집니다.
15.6 5장 풀이 — 함수와 딕셔너리
손풀기 1. 한 달 학식 지출 함수. 가격과 횟수를 받아 총지출을 돌려주는 함수를 만듭니다.
def meal_budget(price, times):
return price * times
print(f"5,500원 x 22회: {meal_budget(5500, 22):,}원")
print(f"6,000원 x 22회: {meal_budget(6000, 22):,}원")5,500원 x 22회: 121,000원
6,000원 x 22회: 132,000원
같은 계산을 값만 바꿔 되풀이할 때 함수가 빛납니다. return이 계산 결과를 호출한 자리로 돌려주므로, 가격이 오른 상황도 인자만 바꿔 한 줄로 다시 계산할 수 있습니다. 121,000원에서 132,000원으로 오릅니다.
손풀기 2. 복리 원리금 함수. 기본 이율 3%를 가진 원리금 함수를 만듭니다.
def future_value(principal, rate=0.03, years=1):
return principal * (1 + rate) ** years
print(f"기본 3%, 5년: {future_value(1_000_000, years=5):,.0f}원")
print(f"5%, 5년: {future_value(1_000_000, rate=0.05, years=5):,.0f}원")기본 3%, 5년: 1,159,274원
5%, 5년: 1,276,282원
원리금 공식 \(P(1+r)^n\)을 그대로 함수로 옮겼습니다. 여기서 주의할 문법이 하나 있습니다. 기본값이 있는 매개변수는 반드시 없는 것들 뒤에 와야 하므로, rate에만 기본값을 주려면 그 뒤의 years에도 기본값이 필요합니다. def future_value(principal, rate=0.03, years)처럼 쓰면 SyntaxError가 납니다. 호출할 때 years=5처럼 이름을 붙이면 순서와 무관하게 원하는 값만 지정할 수 있습니다.
손풀기 3. 편의점 도시락 딕셔너리. 도시락 3종을 딕셔너리로 다룹니다.
lunchbox = {"제육 도시락": 4500, "치킨마요": 4200, "불고기 도시락": 4800}
print("치킨마요 가격:", lunchbox["치킨마요"], "원")
lunchbox["새우 도시락"] = 5000
print("제육 도시락이 메뉴에 있나?", "제육 도시락" in lunchbox)치킨마요 가격: 4200 원
제육 도시락이 메뉴에 있나? True
딕셔너리는 이름표(키)로 값을 꺼냅니다. 대괄호에 키를 넣어 값을 읽고, 없던 키에 값을 대입하면 새 항목이 추가됩니다. in은 키의 존재 여부를 참/거짓으로 알려 주어, 없는 키를 꺼내다 KeyError가 나는 사고를 미리 막는 데 씁니다.
응용 1. 공급 함수의 파라미터 실험. 기술 향상으로 기울기가 바뀐 공급을 비교합니다.
def quantity_supplied(price, c=10, d=3):
return c + d * price
for p in range(0, 51, 10):
print(f"P={p}: d=3 -> {quantity_supplied(p)}, d=5 -> {quantity_supplied(p, d=5)}")
# 공급량 차이는 (5-3)*price 이므로 가격이 가장 높은 50에서 가장 크다.P=0: d=3 -> 10, d=5 -> 10
P=10: d=3 -> 40, d=5 -> 60
P=20: d=3 -> 70, d=5 -> 110
P=30: d=3 -> 100, d=5 -> 160
P=40: d=3 -> 130, d=5 -> 210
P=50: d=3 -> 160, d=5 -> 260
기본값 d=3을 호출할 때 d=5로 덮어써서 두 기술 수준을 나란히 계산했습니다. 두 공급량의 차이는 (5 - 3) x price라, 가격이 0일 때는 차이가 없다가 가격이 높아질수록 벌어집니다. 그래서 가장 큰 차이는 가격 50에서 나타납니다. 기울기가 커진다는 것은 높은 가격에서 더 크게 반응한다는 뜻입니다.
응용 2. 외화 지갑 평가 함수. 보유 외화와 환율 두 딕셔너리로 원화 총액을 구합니다.
wallet = {"USD": 120, "JPY": 3000, "EUR": 45}
rates = {"USD": 1350.0, "JPY": 9.2, "EUR": 1480.0}
def wallet_value(wallet, rates):
total = 0
for cur, amount in wallet.items():
total += amount * rates[cur]
return total
print(f"{wallet_value(wallet, rates):,.0f}원")256,200원
items()로 통화 이름과 보유량을 함께 꺼내 순회하고, 같은 이름을 환율표의 키로 써서 원화로 바꾼 값을 누적합니다. 두 딕셔너리를 키로 잇는 이 패턴은 표 두 개를 맞대어 계산하는 판다스로 곧장 이어집니다. 결과는 256,200원입니다.
응용 3. 탄력성 리포트 함수. 탄력성 값과 판정을 튜플로 돌려주는 함수를 만듭니다.
def midpoint_elasticity(p0, p1, q0, q1):
pct_q = (q1 - q0) / ((q0 + q1) / 2)
pct_p = (p1 - p0) / ((p0 + p1) / 2)
return pct_q / pct_p
def interpret_elasticity(e):
size = abs(e)
if size > 1:
return "탄력적"
elif size == 1:
return "단위탄력적"
else:
return "비탄력적"
def elasticity_report(p0, p1, q0, q1):
e = midpoint_elasticity(p0, p1, q0, q1)
return e, interpret_elasticity(e)
for q1 in (180, 280):
e, verdict = elasticity_report(2000, 2500, 300, q1)
print(f"탄력성 {e:.2f}, 판정: {verdict}")탄력성 -2.25, 판정: 탄력적
탄력성 -0.31, 판정: 비탄력적
elasticity_report는 이미 만든 두 함수를 안에서 불러 결과를 튜플로 묶어 돌려줍니다. 함수가 함수를 부르는 이 조립이 5장의 핵심입니다. 돌려받은 튜플은 e, verdict = ...로 언패킹해 두 이름에 나눠 담습니다. 민감한 손님은 탄력적 (-2.25), 둔감한 손님은 비탄력적(-0.62)으로 갈립니다.
도전 1. 빠진 가격에도 무너지지 않는 물가지수. get으로 KeyError를 피하는 라스파이레스 지수를 만듭니다.
def laspeyres_index(base_prices, new_prices, basket):
cost_base = 0
cost_new = 0
for item, quantity in basket.items():
cost_base += base_prices[item] * quantity
cost_new += new_prices.get(item, base_prices[item]) * quantity
return cost_new / cost_base * 100
base_prices = {"아메리카노": 2000, "카페라떼": 2800, "샌드위치": 3500, "쿠키": 1500}
this_year_prices = {"아메리카노": 2500, "카페라떼": 3200, "샌드위치": 4200, "쿠키": 1500}
basket = {"아메리카노": 20, "카페라떼": 5, "샌드위치": 8, "쿠키": 4}
missing = dict(this_year_prices)
del missing["쿠키"]
print(f"쿠키 포함: {laspeyres_index(base_prices, this_year_prices, basket):.1f}")
print(f"쿠키 빠짐: {laspeyres_index(base_prices, missing, basket):.1f}")쿠키 포함: 120.0
쿠키 빠짐: 120.0
new_prices.get(item, base_prices[item])는 올해 가격표에 품목이 없으면 기준연도 가격을 대신 씁니다. 대괄호 접근과 달리 get은 없는 키에도 KeyError를 내지 않고 예비값을 돌려주기 때문입니다. 쿠키의 가격이 두 해 모두 1,500원이라 이 문제에서는 두 결과가 120.0으로 같게 나오는데, 이는 “값이 유지된 것으로 간주”한 설계가 제대로 작동했다는 증거입니다. 대괄호를 쓴 원래 버전은 missing 가격표에서 쿠키를 찾다 멈춥니다.
도전 2. 알바생을 몇 명까지 뽑아야 할까. 한계생산이 3잔 아래로 떨어지는 인원을 찾습니다.
def cobb_douglas(labor, capital=4, tfp=10, alpha=0.3):
return tfp * (labor ** alpha) * (capital ** (1 - alpha))
previous = 0
labor = 1
while True:
output = cobb_douglas(labor)
marginal = output - previous
print(f"{labor}명째 한계생산: {marginal:.2f}잔")
if marginal < 3:
print(f"알바생 {labor}명째부터는 한 명이 3잔도 못 보탭니다")
break
previous = output
labor += 11명째 한계생산: 26.39잔
2명째 한계생산: 6.10잔
3명째 한계생산: 4.20잔
4명째 한계생산: 3.31잔
5명째 한계생산: 2.77잔
알바생 5명째부터는 한 명이 3잔도 못 보탭니다
콥-더글라스 함수는 노동을 늘릴수록 추가 산출이 줄어드는 한계생산 체감을 보여 줍니다(지수 alpha가 1보다 작기 때문). while True로 무한히 돌되, 한계생산이 3잔 아래로 처음 떨어지는 순간 break로 빠져나옵니다. 이때 previous를 갱신하는 줄이 break 검사보다 뒤에 있어야 직전 인원과 정확히 비교됩니다. 5명째의 기여가 약 2.77잔으로 처음 3잔을 밑돌아, 그 앞에서 채용을 멈추는 것이 합리적일 수 있다는 신호입니다. for와 조건문으로도 같은 논리를 짤 수 있습니다.
15.7 6장 풀이 — 수요와 공급
이 장의 여러 풀이가 find_equilibrium, surplus, cobweb 세 함수를 함께 씁니다. 부록이 단독으로 실행되도록, 세 함수를 먼저 한 번 정의해 두고 이어지는 풀이에서 재사용합니다.
def find_equilibrium(a=100, b=2, c=10, d=3):
if b + d == 0:
return None
p_star = (a - c) / (b + d)
if p_star < 0:
return None
return p_star, a - b * p_star
def surplus(a=100, b=2, c=10, d=3):
eq = find_equilibrium(a, b, c, d)
if eq is None:
return None
p_star, q_star = eq
demand_choke = a / b
supply_choke = -c / d
cs = 0.5 * q_star * (demand_choke - p_star)
ps = 0.5 * q_star * (p_star - supply_choke)
return cs, ps
def cobweb(a, b, c, d, p0, periods=6):
prices = [p0]
for t in range(periods):
p_prev = prices[-1]
qs = c + d * p_prev
prices.append((a - qs) / b)
return prices손풀기 1. 새 시장의 균형. \(Q_d = 200 - 4P\), \(Q_s = 50 + P\)의 균형을 구합니다.
p, q = find_equilibrium(a=200, b=4, c=50, d=1)
print(f"균형 가격 {p:.1f}, 균형 거래량 {q:.1f}")균형 가격 30.0, 균형 거래량 80.0
공식 \(P^* = (a - c) / (b + d) = (200 - 50) / (4 + 1) = 30\)이고, 이를 수요식에 넣으면 거래량 80이 나옵니다. 코드의 결과와 종이 계산이 정확히 맞아떨어지지요. find_equilibrium은 이 공식을 그대로 담고 있어, 파라미터만 갈아 끼우면 어떤 선형 시장이든 균형을 돌려줍니다.
손풀기 2. 시장 상태 표. 가격을 20부터 40까지 훑으며 초과수요·초과공급을 판정합니다.
for P_ in range(20, 41, 5):
qd = 200 - 4 * P_
qs = 50 + P_
if qd > qs:
state = "초과수요"
elif qd < qs:
state = "초과공급"
else:
state = "균형"
print(f"P={P_}: Qd={qd}, Qs={qs} -> {state}")P=20: Qd=120, Qs=70 -> 초과수요
P=25: Qd=100, Qs=75 -> 초과수요
P=30: Qd=80, Qs=80 -> 균형
P=35: Qd=60, Qs=85 -> 초과공급
P=40: Qd=40, Qs=90 -> 초과공급
균형가격 30에서 Qd와 Qs가 80으로 같아 “균형”이 찍힙니다. 그보다 낮은 가격에서는 사려는 양이 팔려는 양보다 많아 초과수요, 높은 가격에서는 반대로 초과공급이지요. 가격이 균형에서 벗어났을 때 어느 힘이 세지는지가 이 표에 그대로 드러납니다.
손풀기 3. 잉여 계산. 기본 시장의 소비자·생산자·총잉여를 구하고 크기를 견줍니다.
cs, ps = surplus()
print(f"소비자 잉여 CS = {cs:,.1f}")
print(f"생산자 잉여 PS = {ps:,.1f}")
print(f"총잉여 TS = {cs + ps:,.1f}")
if cs > ps:
print("소비자 잉여가 더 크다")
elif cs < ps:
print("생산자 잉여가 더 크다")
else:
print("둘이 같다")소비자 잉여 CS = 1,024.0
생산자 잉여 PS = 682.7
총잉여 TS = 1,706.7
소비자 잉여가 더 크다
잉여는 균형점 위아래로 생기는 삼각형의 넓이입니다. 기본 시장(균형 18, 64)에서 소비자 잉여 1,024가 생산자 잉여 약 682.7보다 큽니다. 수요 곡선이 상대적으로 완만해 소비자 쪽 삼각형의 높이가 더 크기 때문입니다.
응용 1. 격자 탐색으로 균형 찾기. np.linspace 격자에서 초과수요가 0에 가장 가까운 가격을 찾습니다.
import numpy as np
best_p = None
best_gap = float("inf")
for P_ in np.linspace(0, 50, 501):
gap = abs((200 - 4 * P_) - (50 + P_))
if gap < best_gap:
best_gap = gap
best_p = P_
print(f"격자 탐색 균형가격 ~ {best_p:.2f} (공식값 30)")
# 0.1 간격에 30이 정확히 들어 있어 오차가 0이다. 더 촘촘히 해도 30 근처로 수렴한다.격자 탐색 균형가격 ~ 30.00 (공식값 30)
공식을 모른다 치고, 가격을 잘게 쪼갠 격자 위를 훑으며 수요량과 공급량의 차이가 가장 작은 지점을 찾는 방법입니다. linspace(0, 50, 501)은 0.1 간격이라 30이 격자에 정확히 들어 있어 오차 없이 30을 집어냅니다. 격자를 0.01 간격으로 더 촘촘히 하면 균형이 격자점과 어긋나는 시장에서도 정확도가 올라갑니다. 공식이 없는 복잡한 모형에서 이 수치 탐색이 특히 쓸모 있습니다.
응용 2. 공급 감소 비교정학. 공급이 \(Q_s = 20 + P\)로 줄었을 때의 새 균형을 그립니다.
import numpy as np
import matplotlib.pyplot as plt
p_old, q_old = find_equilibrium(200, 4, 50, 1)
p_new, q_new = find_equilibrium(200, 4, 20, 1)
print(f"가격 {p_old:.0f} -> {p_new:.0f}, 거래량 {q_old:.0f} -> {q_new:.0f}")
q = np.linspace(0, 200, 300)
p_demand = (200 - q) / 4
p_supply_old = q - 50
p_supply_new = q - 20
fig, ax = plt.subplots(figsize=(6.5, 5))
ax.plot(q, p_demand, color="tab:blue", label="수요")
ax.plot(q, p_supply_old, "--", color="0.6", label="공급(원래)")
ax.plot(q, p_supply_new, color="tab:red", label="공급(감소 후)")
ax.plot(q_old, p_old, "o", color="0.4")
ax.plot(q_new, p_new, "o", color="black")
ax.set_xlim(0, 200)
ax.set_ylim(0, 60)
ax.set_xlabel("수량 Q(잔)")
ax.set_ylabel("가격 P")
ax.set_title("공급 감소: 가격은 오르고 거래량은 준다")
ax.legend()
plt.tight_layout()
plt.show()가격 30 -> 36, 거래량 80 -> 56

공급 절편 c가 50에서 20으로 줄면 공급선이 왼쪽 위로 밀려, 균형가격은 30에서 36으로 오르고 거래량은 80에서 56으로 줄어듭니다. 그래프에서 파선(원래)과 빨간 실선(감소 후)의 간격이 이 이동을 보여 줍니다. 생산비가 올라 공급이 줄면 값은 비싸지고 거래는 위축된다는 원론의 결론이 그림 한 장에 담깁니다.
응용 3. 가격상한의 효과. 새 시장에 상한 25를 씌워 부족분과 실제 거래량을 구합니다.
import numpy as np
import matplotlib.pyplot as plt
ceiling = 25
p_eq, q_eq = find_equilibrium(200, 4, 50, 1)
if ceiling < p_eq:
qd = 200 - 4 * ceiling
qs = 50 + ceiling
print(f"구속력 있음(상한 {ceiling} < 균형 {p_eq:.0f})")
print(f"수요량 {qd}, 공급량 {qs}, 부족분 {qd - qs}, 실제 거래량 {min(qd, qs)}")
q = np.linspace(0, 200, 300)
fig, ax = plt.subplots(figsize=(6.5, 5))
ax.plot(q, (200 - q) / 4, color="tab:blue", label="수요")
ax.plot(q, q - 50, color="tab:red", label="공급")
ax.axhline(ceiling, color="tab:purple", linestyle="--", label="가격상한 25")
ax.plot(qs, ceiling, "o", color="tab:red")
ax.plot(qd, ceiling, "o", color="tab:blue")
ax.annotate("", xy=(qd, ceiling), xytext=(qs, ceiling),
arrowprops=dict(arrowstyle="<->", color="black"))
ax.text((qs + qd) / 2, ceiling + 3, f"부족분 {qd - qs}", ha="center")
ax.set_xlim(0, 200)
ax.set_ylim(0, 60)
ax.set_xlabel("수량 Q(잔)")
ax.set_ylabel("가격 P")
ax.set_title("가격상한 25: 25잔의 초과수요")
ax.legend()
plt.tight_layout()
plt.show()구속력 있음(상한 25 < 균형 30)
수요량 100, 공급량 75, 부족분 25, 실제 거래량 75

상한 25가 균형가격 30보다 낮아 구속력을 가집니다. 낮게 묶인 가격에서 수요량은 100으로 늘고 공급량은 75로 줄어, 그 차이 25가 부족분입니다. 실제로 팔리는 양은 둘 중 작은 쪽인 75잔으로 정해지지요. 못 산 25잔어치의 손님은 줄서기나 품절로 밀려납니다. 화살표의 폭이 그 부족분을 시각적으로 말해 줍니다.
도전 1. 수렴과 발산의 경계. 공급 기울기 \(d\)를 키우며 거미집의 수렴과 발산이 갈리는 지점을 찾습니다.
d = 1.0
while d <= 2.6:
path = cobweb(a=100, b=2, c=10, d=d, p0=16, periods=6)
if d > 2:
tag = "발산"
elif d == 2:
tag = "경계"
else:
tag = "수렴"
print(f"d={d:.1f} (d/b={d / 2:.2f}, {tag}): {[round(p, 1) for p in path]}")
d = round(d + 0.2, 1)d=1.0 (d/b=0.50, 수렴): [16, 37.0, 26.5, 31.8, 29.1, 30.4, 29.8]
d=1.2 (d/b=0.60, 수렴): [16, 35.4, 23.8, 30.7, 26.6, 29.1, 27.6]
d=1.4 (d/b=0.70, 수렴): [16, 33.8, 21.3, 30.1, 24.0, 28.2, 25.2]
d=1.6 (d/b=0.80, 수렴): [16, 32.2, 19.2, 29.6, 21.3, 27.9, 22.6]
d=1.8 (d/b=0.90, 수렴): [16, 30.6, 17.5, 29.3, 18.6, 28.2, 19.6]
d=2.0 (d/b=1.00, 경계): [16, 29.0, 16.0, 29.0, 16.0, 29.0, 16.0]
d=2.2 (d/b=1.10, 발산): [16, 27.4, 14.9, 28.7, 13.5, 30.2, 11.8]
d=2.4 (d/b=1.20, 발산): [16, 25.8, 14.0, 28.2, 11.2, 31.5, 7.2]
d=2.6 (d/b=1.30, 발산): [16, 24.2, 13.5, 27.4, 9.4, 32.8, 2.4]
거미집 모형은 공급이 지난 기 가격을 보고 한 박자 늦게 반응하는 시장입니다. 수렴 여부는 두 곡선 기울기의 비 \(d/b\)가 가릅니다. \(d/b < 1\)이면 진동이 잦아들어 균형으로 수렴하고, \(d/b > 1\)이면 진폭이 커지며 발산하지요. 수요 기울기 \(b=2\)를 고정하고 \(d\)를 키우면, \(d=2\)에서 \(d/b=1\)이 되어 진동이 일정한 경계를 이루고, 그 너머에서 발산으로 돌아섭니다. 이론이 말하는 경계 \(d = b\)와 정확히 맞습니다. 농산물 가격이 한 해 걸러 오르내리는 현상이 이 모형의 현실판입니다.
도전 2. 수요와 공급이 함께 움직일 때. 두 곡선이 같이 이동하면 가격의 방향이 정해지지 않음을 확인합니다.
base = find_equilibrium(100, 2, 10, 3)
both_big = find_equilibrium(130, 2, 40, 3) # 수요와 공급이 모두 크게 증가
supply_small = find_equilibrium(130, 2, 25, 3) # 공급 증가 폭만 작게
print("기준 균형: ", tuple(round(x, 1) for x in base))
print("둘 다 크게 증가: ", tuple(round(x, 1) for x in both_big))
print("공급 증가 폭 작게:", tuple(round(x, 1) for x in supply_small))기준 균형: (18.0, 64.0)
둘 다 크게 증가: (18.0, 94.0)
공급 증가 폭 작게: (21.0, 88.0)
수요 증가는 가격을 올리고 거래량을 늘리며, 공급 증가는 가격을 내리고 거래량을 늘립니다. 두 변화가 겹치면 거래량은 양쪽 모두 늘리는 방향이라 반드시 증가합니다(64에서 94, 88로). 그러나 가격은 서로 반대 방향이라 상쇄되어, 어느 쪽 힘이 센지에 따라 결과가 달라집니다. 공급 증가가 클 때는 가격이 제자리(18)에 머물고, 공급 증가가 작을 때는 수요 효과가 이겨 가격이 오릅니다(21). 그래서 “수요와 공급이 함께 늘면 거래량의 방향은 정해지지만 가격의 방향은 상대적 크기에 달렸다”는 명제가 성립합니다.
15.8 7장 풀이 — 모듈·패키지·터미널
이 장의 문제는 대부분 .py 파일을 만들거나 터미널에서 명령을 실행하는 것이라, 노트북 셀만으로 완전히 재현하기 어려운 부분이 있습니다. 그래서 파일을 새로 만드는 대목은 파일 내용을 회색(실행 제외) 셀로 보여 주고, 그 파일이 이미 저장소에 있는 경우(market_models.py, econo_analyzer 패키지)에는 실제로 import 해 결과를 확인합니다. 예상 출력은 코드 아래 글로 적어 두었습니다.
손풀기 1. 세 가지 import 다시 쓰기. 같은 함수를 세 방식으로 불러 결과가 같은지 확인합니다.
import market_models
from market_models import find_equilibrium
import market_models as mm
print(market_models.find_equilibrium(a=120, b=2, c=20, d=3)) # 모듈이름.함수
print(find_equilibrium(a=120, b=2, c=20, d=3)) # 이름만
print(mm.find_equilibrium(a=120, b=2, c=20, d=3)) # 별명.함수(20.0, 80.0)
(20.0, 80.0)
(20.0, 80.0)
세 방식 모두 같은 균형 (20.0, 80.0)을 돌려줍니다. 달라지는 것은 함수를 부를 때 앞에 무엇이 붙느냐뿐입니다. import market_models는 모듈이름.함수로, from ... import는 이름만으로, as mm는 짧은 별명으로 부릅니다. 이름 충돌이 걱정될 때는 첫째나 셋째 방식이 안전합니다.
손풀기 2. 이름표 예측하기. print(__name__)만 담긴 파일이 실행 방식에 따라 무엇을 출력하는지 예측합니다.
# 이 노트북 자체는 최상위로 실행되므로 __name__ 이 "__main__" 이다.
print(__name__)__main__
# 실제 파일 실험(로컬/Colab에서 직접 해 보는 부분):
%%writefile hello_name.py
print(__name__)%run hello_name.py # 최상위로 실행 -> "__main__"
import hello_name # 모듈로 불러옴 -> "hello_name"예측과 실제가 갈리는 지점이 이 문제의 핵심입니다. %run hello_name.py는 파일을 프로그램으로 직접 돌리므로 __name__이 "__main__"으로 찍히고, import hello_name은 파일을 부품(모듈)으로 불러오므로 파일 이름인 "hello_name"이 찍힙니다. 위 실행 셀에서 이 노트북의 __name__이 "__main__"으로 나오는 것도 같은 이유입니다. 이 차이가 if __name__ == "__main__":이라는 관용구의 존재 이유입니다.
손풀기 3. 터미널 세션 읽기. 마지막 명령이 왜 두 줄을 출력하는지 __name__으로 설명합니다.
import market_models as mm
# python market_models.py 가 출력하는 두 줄을 그대로 재현
p, q = mm.find_equilibrium(a=100, b=2, c=10, d=3)
print("market_models 자체 테스트를 시작합니다.")
print(f"기본 시장 균형: P* = {p:.2f}, Q* = {q:.2f}")market_models 자체 테스트를 시작합니다.
기본 시장 균형: P* = 18.00, Q* = 64.00
네 명령은 차례로 폴더 이동(cd), 파일 목록 보기(dir), 파이썬 버전 확인 (python --version), 그리고 스크립트 실행(python market_models.py)입니다. 마지막 명령이 두 줄을 뱉는 것은 파일 맨 아래 if __name__ == "__main__": 블록에 print가 두 개 들어 있기 때문입니다. 파일을 직접 실행하면 __name__이 "__main__"이 되어 이 블록이 돌지만, 다른 파일에서 import할 때는 조용합니다.
응용 4. my_utils 모듈 완성하기. 원화 서식과 부가세 함수, 그리고 테스트 블록을 갖춘 모듈을 만듭니다.
# 저장할 파일 my_utils.py 의 내용 (직접 만들어 볼 부분)
%%writefile my_utils.py
def format_won(amount):
return f"{amount:,}원"
def with_vat(price, rate=0.1):
return price * (1 + rate)
if __name__ == "__main__":
print(format_won(1234567))
print(with_vat(1800))# 파일 없이도 결과를 확인할 수 있도록 같은 함수를 여기서 정의해 시험한다.
def format_won(amount):
return f"{amount:,}원"
def with_vat(price, rate=0.1):
return price * (1 + rate)
print(format_won(1234567))
print(format_won(round(with_vat(1800))))1,234,567원
1,980원
format_won은 정수를 천 단위 쉼표와 “원”이 붙은 글자열로 바꾸고, with_vat는 가격에 부가세율을 더합니다. 이콘 카페 아메리카노 1,800원의 부가세 포함 가격은 1,980원이고, 이를 format_won으로 감싸면 “1,980원”이 됩니다. if __name__ == "__main__": 블록은 파일을 직접 실행할 때만 도는 자체 시험대라, import 해서 쓸 때는 이 테스트가 조용히 넘어갑니다.
응용 5. 탄력성 모듈을 패키지에 입주시키기. 두 균형점 사이의 탄력성이 무엇의 탄력성인지 밝힙니다.
# econo_analyzer/analysis/elasticity.py 에 넣을 함수 (패키지 입주분)
%%writefile econo_analyzer/analysis/elasticity.py
def midpoint_elasticity(p1, q1, p2, q2):
pct_q = (q2 - q1) / ((q1 + q2) / 2)
pct_p = (p2 - p1) / ((p1 + p2) / 2)
return pct_q / pct_pdef midpoint_elasticity(p1, q1, p2, q2):
pct_q = (q2 - q1) / ((q1 + q2) / 2)
pct_p = (p2 - p1) / ((p1 + p2) / 2)
return pct_q / pct_p
# 방학 시나리오의 두 균형점: (P=18, Q=64) 와 (P=12, Q=46)
e = midpoint_elasticity(18, 64, 12, 46)
print(f"두 점 사이의 탄력성: {e:.3f}")두 점 사이의 탄력성: 0.818
계산값은 약 0.82로 양수입니다. 여기서 중요한 것은 이 두 점이 이동하지 않은 공급 곡선 위의 점들이라는 사실입니다. 수요가 왼쪽으로 밀리는 동안 공급 곡선은 가만히 있었으므로, 두 균형은 같은 공급 곡선을 따라 미끄러진 것입니다. 따라서 지금 잰 값은 수요가 아니라 공급의 가격탄력성입니다. 가격과 수량이 같은 방향으로 (둘 다 하락) 움직여 부호가 양수로 나온 것이 그 증거이고, 1보다 작으니 이 공급은 비탄력적입니다.
응용 6. –verbose 옵션 설계하기. 켜면 계산 과정을 보여 주는 스위치 옵션을 답니다.
# sd_analyzer2.py 의 핵심 부분 (직접 만들어 %run 으로 시험할 부분)
%%writefile sd_analyzer2.py
import argparse
import market_models
parser = argparse.ArgumentParser()
parser.add_argument("--a", type=float, required=True)
parser.add_argument("--b", type=float, required=True)
parser.add_argument("--c", type=float, required=True)
parser.add_argument("--d", type=float, required=True)
parser.add_argument("--verbose", action="store_true", help="계산 과정을 자세히 출력")
args = parser.parse_args()
p, q = market_models.find_equilibrium(args.a, args.b, args.c, args.d)
if args.verbose:
print(f"P* = (a - c) / (b + d) = ({args.a} - {args.c}) / ({args.b} + {args.d})")
print(f" = {args.a - args.c} / {args.b + args.d}")
print(f"P* = {p:.2f}, Q* = {q:.2f}")action="store_true"는 값을 따로 받지 않는 스위치를 만듭니다. --verbose를 붙이면 args.verbose가 참, 안 붙이면 거짓이 되지요. 참일 때만 균형 공식 \(P^* = (a - c)/(b + d)\)에 숫자가 대입되는 과정을 단계별로 찍습니다.
예상 출력을 견주어 보면 이렇습니다.
%run sd_analyzer2.py --a 100 --b 2 --c 10 --d 3
P* = 18.00, Q* = 64.00
%run sd_analyzer2.py --a 100 --b 2 --c 10 --d 3 --verbose
P* = (a - c) / (b + d) = (100.0 - 10.0) / (2.0 + 3.0)
= 90.0 / 5.0
P* = 18.00, Q* = 64.00
도전 7. 수요 이동 CLI. compare_equilibria를 재사용해 이동 전후의 균형을 비교합니다.
# sd_shift.py 의 핵심 부분 (직접 만들 파일)
%%writefile sd_shift.py
import argparse
from econo_analyzer.analysis import shifts
parser = argparse.ArgumentParser()
parser.add_argument("--a", type=float, required=True)
parser.add_argument("--b", type=float, required=True)
parser.add_argument("--c", type=float, required=True)
parser.add_argument("--d", type=float, required=True)
parser.add_argument("--new-a", type=float, required=True)
args = parser.parse_args()
old = {"a": args.a, "b": args.b, "c": args.c, "d": args.d}
new = {"a": args.new_a, "b": args.b, "c": args.c, "d": args.d} # --new-a 는 args.new_a
result = shifts.compare_equilibria(old, new)
print("이동 전 균형:", result["old_equilibrium"])
print("이동 후 균형:", result["new_equilibrium"])
print("가격:", result["price_change"], "/ 거래량:", result["quantity_change"])# 패키지가 저장소에 있으므로, CLI가 부를 함수를 직접 불러 결과를 재현한다.
from econo_analyzer.analysis import shifts
old = {"a": 100, "b": 2, "c": 10, "d": 3}
new = {"a": 70, "b": 2, "c": 10, "d": 3} # --new-a 70
result = shifts.compare_equilibria(old, new)
print("이동 전 균형:", result["old_equilibrium"])
print("이동 후 균형:", result["new_equilibrium"])
print("가격:", result["price_change"], "/ 거래량:", result["quantity_change"])이동 전 균형: (18.0, 64.0)
이동 후 균형: (12.0, 46.0)
가격: 하락 / 거래량: 감소
--new-a 70은 수요 절편만 100에서 70으로 낮춰 수요를 왼쪽으로 밀어 냅니다. 방학이 되어 손님이 줄어든 상황이지요. 비교 논리를 새로 짜지 않고 compare_equilibria를 import 해 재사용한 것이 이 문제의 요지입니다. 한 번 검증된 함수를 패키지에 두면 여러 프로그램이 같은 논리를 공유할 수 있습니다. 함정은 옵션 이름의 붙임표입니다. --new-a는 파이썬 변수명이 될 수 없어 argparse가 밑줄로 바꿔 주므로 값은 args.new_a로 꺼냅니다. 출력은 균형이 (18, 64)에서 (12, 46)으로, 가격은 하락, 거래량은 감소로 나와 본문의 방학 분석과 일치합니다.
15.9 8장 풀이 — matplotlib 심화
이 장의 문제는 모두 그래프를 그립니다. 공통 요구대로, 각 풀이의 제목에 결론을 담고 축 라벨에 단위를 붙였으며, 해설의 마지막 문장이 곧 “이 그래프의 한 문장”입니다.
손풀기 1. 수출입 추이. 수출은 강조색, 수입은 회색 선으로 그리고 선 끝에 이름을 붙입니다.
import matplotlib.pyplot as plt
years = [2020, 2021, 2022, 2023, 2024]
exports = [510, 620, 680, 650, 700]
imports = [480, 580, 650, 630, 680]
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(years, exports, color="tab:blue", marker="o", linewidth=2)
ax.plot(years, imports, color="0.6", marker="o", linewidth=2)
ax.text(2024.05, 700, "수출", color="tab:blue", va="center")
ax.text(2024.05, 680, "수입", color="0.5", va="center")
ax.set_title("수출이 수입을 앞선 무역흑자가 5년간 이어졌다")
ax.set_xlabel("연도")
ax.set_ylabel("금액(억 달러)")
ax.set_xticks(years)
ax.set_xlim(2020, 2024.8)
plt.tight_layout()
plt.show()
강조색 하나와 회색의 대비로 어느 선이 주인공인지 단번에 읽힙니다. 범례 상자를 따로 두지 않고 선 끝에 text로 이름을 붙이면, 보는 사람이 색과 이름을 눈으로 맞추느라 시선을 왕복할 필요가 없습니다. 5년 내내 수출이 수입 위에 있어 무역흑자가 유지되었습니다.
손풀기 2. 분기별 영업이익. 막대에 연평균 기준선을 긋고 평균을 넘긴 분기만 강조합니다.
import matplotlib.pyplot as plt
quarterly_profit = {"1분기": 35.2, "2분기": 41.5, "3분기": 28.8, "4분기": 45.0}
names = list(quarterly_profit.keys())
values = list(quarterly_profit.values())
avg = sum(values) / len(values)
colors = ["tab:blue" if v > avg else "0.75" for v in values]
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.bar(names, values, color=colors)
ax.axhline(avg, color="tab:red", linestyle="--", label=f"연평균 {avg:.1f}억")
ax.set_title("2분기와 4분기가 연평균을 끌어올렸다")
ax.set_ylabel("영업이익(억 원)")
ax.legend()
plt.tight_layout()
plt.show()
딕셔너리에서 키와 값을 각각 리스트로 꺼내 막대의 이름과 높이로 씁니다. axhline이 연평균(37.6억)에 수평 기준선을 긋고, 리스트 컴프리헨션으로 평균을 넘긴 분기(2분기·4분기)에만 강조색을 줍니다. 색이 곧 “평균 이상”이라는 정보를 나르는 것이 요령입니다.
손풀기 3. 광고비와 매출. 산점도를 그리고 상관과 인과를 구분해 캡션을 답니다.
import matplotlib.pyplot as plt
ad_cost = [10, 15, 12, 20, 18, 25, 22]
revenue = [100, 140, 110, 180, 160, 210, 190]
fig, ax = plt.subplots(figsize=(6, 4.5))
ax.scatter(ad_cost, revenue, color="tab:blue", s=60)
ax.set_title("광고비가 많은 달에 매출도 높았다")
ax.set_xlabel("광고비(백만 원)")
ax.set_ylabel("매출(백만 원)")
plt.tight_layout()
plt.show()
산점도는 두 변수가 함께 움직이는지를 봅니다. 점들이 오른쪽 위로 늘어서 있어 광고비와 매출이 같이 커지는 상관이 보이지요. 다만 이 그림만으로 “광고가 매출을 늘렸다”는 인과를 단정할 수는 없습니다. 계절이나 신제품 출시 같은 제3의 요인이 둘을 동시에 밀어 올렸을 수도 있으니까요. 캡션은 “함께 움직였다”까지만 말해야 정직합니다.
응용 4. 가격상한제의 풍경. 아메리카노 시장에 상한 2,600원을 씌워 초과수요를 표시합니다.
import numpy as np
import matplotlib.pyplot as plt
Q = np.linspace(0, 400, 300)
p_demand = 5000 - 10 * Q
p_supply = 1000 + 10 * Q
ceiling = 2600
qd = (5000 - ceiling) / 10
qs = (ceiling - 1000) / 10
fig, ax = plt.subplots(figsize=(7, 5))
ax.plot(Q, p_demand, color="tab:blue", label="수요")
ax.plot(Q, p_supply, color="tab:red", label="공급")
ax.axhline(ceiling, color="tab:purple", linestyle="--", label="가격상한 2,600원")
ax.plot(qs, ceiling, "o", color="tab:red")
ax.plot(qd, ceiling, "o", color="tab:blue")
ax.annotate("", xy=(qd, ceiling), xytext=(qs, ceiling),
arrowprops=dict(arrowstyle="<->", color="black"))
ax.text((qs + qd) / 2, ceiling + 200, f"부족분 {qd - qs:.0f}잔", ha="center")
ax.set_xlim(0, 400)
ax.set_ylim(0, 5200)
ax.set_xlabel("수량 Q(잔)")
ax.set_ylabel("가격 P(원)")
ax.set_title("가격상한 2,600원: 80잔의 초과수요가 생긴다")
ax.legend()
plt.tight_layout()
plt.show()
상한 2,600원은 균형가격 3,000원보다 낮아 구속력을 가집니다. 그 가격에서 수요량은 240잔, 공급량은 160잔이라 80잔이 부족하지요. 두 점을 잇는 양방향 화살표와 그 위의 수치가 초과수요를 눈으로 보여 줍니다. 학생회가 값을 낮추는 데는 성공해도, 정작 살 수 있는 양은 200잔에서 160잔으로 줄어드는 역설이 그림에 담겨 있습니다.
응용 5. 두 분포 나란히. 정규분포와 로그정규분포의 평균과 중앙값 간격을 비교합니다.
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(0)
a = np.random.normal(300, 40, 500)
b = np.random.lognormal(np.log(260), 0.5, 500)
lo = min(a.min(), b.min())
hi = max(a.max(), b.max())
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
for ax, data, name in zip(axes, [a, b], ["정규분포", "로그정규분포"]):
ax.hist(data, bins=30, range=(lo, hi), color="tab:blue", alpha=0.7)
ax.axvline(data.mean(), color="tab:red", linestyle="--", label=f"평균 {data.mean():.0f}")
ax.axvline(np.median(data), color="tab:blue", linestyle="-", label=f"중앙값 {np.median(data):.0f}")
ax.set_title(name)
ax.set_xlim(lo, hi)
ax.set_xlabel("소득")
ax.legend()
plt.tight_layout()
plt.show()
np.random.seed(0)으로 시드를 고정해 누가 실행해도 같은 그림이 나오게 했습니다. x축 범위를 두 패널에 똑같이 맞춰야 분포의 모양을 공정하게 견줄 수 있습니다. 정규분포는 좌우 대칭이라 평균(빨강)과 중앙값(파랑)이 거의 겹치지만, 로그정규분포는 오른쪽 꼬리가 길어 소수의 큰 값이 평균을 위로 끌어당깁니다. 그래서 평균이 중앙값보다 눈에 띄게 오른쪽에 놓입니다. 소득을 이야기할 때 평균보다 중앙값을 즐겨 쓰는 이유가 오른쪽 그림에 있습니다.
응용 6. 나쁜 그래프 비평. 축을 조작한 막대 그래프의 문제를 짚고 개선판을 제시합니다.
import matplotlib.pyplot as plt
years = [2019, 2020, 2021, 2022, 2023]
debt = [723, 847, 971, 1067, 1127]
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.bar(years, debt, color="tab:blue")
ax.set_ylim(0, 1200) # 0에서 시작해 증가폭을 정직하게
ax.set_title("국가채무, 5년간 약 1.56배로 늘었다")
ax.set_xlabel("연도")
ax.set_ylabel("국가채무(조 원)")
ax.set_xticks(years)
plt.tight_layout()
plt.show()
원본 그래프의 문제는 셋 이상입니다. 첫째, y축을 700부터 시작해(ylim(700, 1150)) 막대 길이의 차이를 실제보다 크게 부풀립니다. 0에서 시작하면 증가가 1.56배임이 정직하게 보입니다. 둘째, 막대마다 다른 무지개색을 써 색이 아무 정보도 나르지 않으면서 시선만 흩뜨립니다. 한 가지 색이면 충분하지요. 셋째, figsize=(3.5, 6)의 좁고 긴 비율이 상승 기울기를 가파르게 왜곡하고, 제목 “국가채무”에는 결론이 없습니다. 개선판은 0 기준, 단색, 결론이 담긴 제목으로 같은 데이터를 담담하게 보여 줍니다.
도전 7. 로렌츠 보고서 함수. 분위 데이터를 받아 곡선을 그리고 지니계수를 반환하는 함수를 만듭니다.
import matplotlib.pyplot as plt
def gini(incomes):
total = sum(incomes)
n = len(incomes)
cum = [0.0]
running = 0
for x in incomes:
running += x
cum.append(running / total)
area_b = 0.0
for i in range(n):
area_b += (cum[i] + cum[i + 1]) / 2 * (1 / n)
return 1 - 2 * area_b
def lorenz_report(incomes, label):
n = len(incomes)
total = sum(incomes)
cum = [0.0]
running = 0
for x in incomes:
running += x
cum.append(running / total)
pop = [i / n for i in range(n + 1)]
g = gini(incomes)
fig, ax = plt.subplots(figsize=(6, 6))
ax.plot([0, 1], [0, 1], "--", color="0.6", label="완전평등선")
ax.plot(pop, cum, color="tab:blue", marker="o", label=f"{label} (지니 {g:.3f})")
ax.fill_between(pop, cum, pop, color="tab:blue", alpha=0.15)
ax.set_aspect("equal")
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_xlabel("인구 누적 비중(하위부터)")
ax.set_ylabel("소득 누적 비중")
ax.set_title(f"{label}의 로렌츠 곡선")
ax.legend(loc="upper left", frameon=False)
plt.tight_layout()
plt.show()
return g
deciles = [95, 160, 215, 268, 322, 381, 450, 542, 685, 1120]
quintiles = [180, 320, 520, 900, 2080]
g10 = lorenz_report(deciles, "10분위 데이터")
g5 = lorenz_report(quintiles, "5분위 데이터")
print(f"10분위 지니 {g10:.3f}, 5분위 지니 {g5:.3f}")

10분위 지니 0.357, 5분위 지니 0.438
함수 하나가 곡선 그리기, 지니계수 계산, 값 반환을 모두 맡습니다. 핵심은 분위 수를 len으로 받아 인구 격자 pop을 n + 1개로 만드는 것입니다. 그러면 10분위든 5분위든 같은 함수가 그대로 작동합니다. fill_between으로 완전평등선과 곡선 사이(면적 A)를 옅게 칠하고, 지니계수를 범례에 넣어 그림과 수치를 한자리에 모았습니다. 이렇게 재사용 가능한 분석 함수로 묶어 두면, 다음 데이터가 와도 한 줄 호출로 같은 보고서를 찍어 낼 수 있습니다.
도전 8. 비교정학 대시보드. 네 시나리오를 2행 2열에 담아 균형 이동을 한눈에 비교합니다.
import numpy as np
import matplotlib.pyplot as plt
Q = np.linspace(0, 400, 200)
base_d = 5000 - 10 * Q
base_s = 1000 + 10 * Q
scenarios = [
("수요 증가 (P 상승, Q 증가)", 5400 - 10 * Q, base_s, 220, 3200),
("수요 감소 (P 하락, Q 감소)", 4600 - 10 * Q, base_s, 180, 2800),
("공급 증가 (P 하락, Q 증가)", base_d, 600 + 10 * Q, 220, 2800),
("공급 감소 (P 상승, Q 감소)", base_d, 1400 + 10 * Q, 180, 3200),
]
fig, axes = plt.subplots(2, 2, figsize=(11, 9), sharex=True, sharey=True)
for ax, (title, new_d, new_s, q_new, p_new) in zip(axes.flat, scenarios):
ax.plot(Q, base_d, color="0.75")
ax.plot(Q, base_s, color="0.75")
ax.plot(Q, new_d, color="tab:blue")
ax.plot(Q, new_s, color="tab:red")
ax.plot(200, 3000, "o", color="0.5")
ax.plot(q_new, p_new, "o", color="black")
ax.annotate("", xy=(q_new, p_new), xytext=(200, 3000),
arrowprops=dict(arrowstyle="->", color="black"))
ax.set_title(title)
ax.set_xlim(0, 400)
ax.set_ylim(0, 5200)
for ax in axes[-1]:
ax.set_xlabel("수량 Q(잔)")
for ax in axes[:, 0]:
ax.set_ylabel("가격 P(원)")
fig.suptitle("비교정학 네 장면: 아메리카노 시장, 이동 폭 400원", fontsize=13)
plt.tight_layout()
plt.show()
원론 교과서의 비교정학 네 그림을 한 장에 담았습니다. sharex와 sharey로 네 패널의 축을 통일해야 이동 폭을 공정하게 비교할 수 있습니다. 옛 곡선은 회색, 새 곡선은 강조색으로 두고 균형점 사이에 화살표를 그어 어디로 움직였는지 표시했습니다. 수요가 움직이면 가격과 거래량이 같은 방향으로, 공급이 움직이면 반대 방향으로 갑니다. 각 패널 제목에 그 방향을 적고 suptitle로 전체를 묶으면, 네 경우의 규칙이 한눈에 정리됩니다.
15.10 9장 풀이 — 판다스 입문
이 장의 풀이는 본문과 같은 두 데이터를 씁니다. 부록이 단독으로 돌도록, 먼저 두 표를 읽어 들이고 파생 열까지 만들어 둔 뒤 이어지는 풀이에서 재사용합니다.
import pandas as pd
macro = pd.read_csv("data/korea_macro.csv")
cafe = pd.read_csv("data/cafe_sales.csv")
cafe["revenue"] = cafe["price"] * cafe["quantity"]
print(macro.shape, cafe.shape)(25, 4) (450, 7)
손풀기 1. 등록금 인상률 Series. 연도를 인덱스로 하는 Series를 만들고 평균을 구합니다.
import pandas as pd
rates = pd.Series([0.0, 1.2, 3.5, 4.2], index=[2022, 2023, 2024, 2025])
print(rates)
print(f"평균 인상률: {rates.mean():.2f}%")2022 0.0
2023 1.2
2024 3.5
2025 4.2
dtype: float64
평균 인상률: 2.23%
Series는 값의 나열에 이름표(인덱스)를 붙인 구조입니다. 연도를 인덱스로 주면 각 값이 어느 해의 것인지 그대로 읽힙니다. mean() 하나로 평균 2.23%가 나오니, sum을 len으로 나누는 수고가 사라집니다.
손풀기 2. 고용 지표만 추리기. macro에서 두 열만 골라 DataFrame으로 봅니다.
print(macro[["year", "unemployment"]].head()) year unemployment
0 2000 4.4
1 2001 4.0
2 2002 3.3
3 2003 3.6
4 2004 3.7
대괄호를 두 겹으로 쓰는 것이 요령입니다. 안쪽 대괄호는 고를 열 이름의 리스트, 바깥 대괄호는 그 리스트로 표를 자른다는 뜻입니다. 한 겹으로 macro["year"]를 쓰면 열 하나짜리 Series가 나오지만, 두 겹은 여러 열을 담은 DataFrame을 돌려줍니다.
손풀기 3. 장부 검증. value_counts로 다섯 메뉴가 90행씩 있는지 확인합니다.
print(cafe["menu"].value_counts())menu
아메리카노 90
카페라떼 90
딸기스무디 90
크로플 90
샌드위치 90
Name: count, dtype: int64
value_counts는 범주별로 몇 번 나오는지를 세어 줍니다. 다섯 메뉴가 모두 90으로 나오면, 90일 동안 하루도 빠짐없이 다섯 메뉴가 기록됐다는 뜻입니다. 분석에 앞서 데이터에 구멍이 없는지 확인하는 습관의 첫걸음입니다.
응용 4. 실업률이 높았던 해. 실업률 4.0% 이상인 해를 걸러 냅니다.
high = macro[macro["unemployment"] >= 4.0]
print(high[["year", "unemployment"]]) year unemployment
0 2000 4.4
1 2001 4.0
20 2020 4.0
macro["unemployment"] >= 4.0은 각 행이 조건에 맞는지를 참/거짓으로 늘어놓은 불리언 Series를 만들고, 이를 대괄호에 넣으면 참인 행만 남습니다. 걸러진 해들은 2000~2001년과 2020년으로, 외환위기의 여파가 남은 시기와 코로나 첫해에 몰려 있습니다.
응용 5. 최고 매출의 날. 매출 상위 5개 행을 뽑아 공통점을 살핍니다.
top5 = cafe.sort_values("revenue", ascending=False).head()
print(top5[["date", "weekday", "menu", "revenue"]]) date weekday menu revenue
96 2025-03-28 금 카페라떼 244000
95 2025-03-28 금 아메리카노 222000
125 2025-04-07 월 아메리카노 222000
40 2025-03-13 목 아메리카노 216000
181 2025-04-22 화 카페라떼 216000
sort_values로 매출을 내림차순 정렬한 뒤 head로 앞 다섯을 자르는, 두 명령을 이어 붙인 체이닝입니다. 상위에는 잔 수가 많은 아메리카노나 단가가 높은 메뉴가 오르는데, 특정 요일에 쏠려 있다면 그 요일에 무슨 일이 있었는지 물어볼 단서가 됩니다.
응용 6. 요일별 매출 비중. 요일별 매출 합계에서 금요일의 몫을 구합니다.
by_weekday = cafe.groupby("weekday")["revenue"].sum()
fri_share = by_weekday["금"] / by_weekday.sum() * 100
print(by_weekday)
print(f"금요일 매출 비중: {fri_share:.1f}%")weekday
금 13918000
목 12161000
수 11886000
월 11919500
화 11820000
Name: revenue, dtype: int64
금요일 매출 비중: 22.6%
groupby("weekday")로 요일마다 매출을 모아 합치면 요일이 인덱스인 Series가 나옵니다. 거기서 by_weekday["금"]으로 금요일 값을 꺼내 전체 합으로 나누면 비중이 나오지요. 이콘 카페는 평일(월~금) 닷새만 여니, 고르게 팔린다면 한 요일이 20%일 텐데 금요일은 약 22.6%로 그보다 높습니다. 주말을 앞둔 “불금” 효과를 의심해 볼 만합니다.
도전 7. 요일과 날씨의 조합. 두 열로 묶어 평균 판매량이 가장 높은 조합을 찾습니다.
combo = cafe.groupby(["weekday", "weather"])["quantity"].mean().round(1)
print(combo.head(9))
print("가장 높은 조합:", combo.idxmax(), "->", combo.max(), "잔")weekday weather
금 맑음 40.0
비 40.6
흐림 40.3
목 맑음 35.5
비 38.9
흐림 34.3
수 맑음 34.7
비 33.6
흐림 35.1
Name: quantity, dtype: float64
가장 높은 조합: ('금', '비') -> 40.6 잔
groupby에 열 이름의 리스트를 넘기면 요일과 날씨의 조합마다 그룹이 생깁니다. idxmax는 가장 큰 값이 있는 조합의 이름을 튜플로 돌려주지요. 특정 조합이 유난히 높게 나오면 그 맥락(이를테면 비 오는 날 따뜻한 음료 수요)을 추측해 볼 수 있습니다. 다만 조합마다 표본이 적어 우연이 섞일 수 있으니, 큰 결론은 아껴야 합니다.
도전 8. 좋았던 해 찾기. 고성장·저물가 조건을 동시에 만족하는 해를 셉니다.
good = macro[(macro["gdp_growth"] >= 3.0) & (macro["cpi_inflation"] <= 3.0)]
print(good[["year", "gdp_growth", "cpi_inflation"]])
print("좋았던 해:", len(good), "번 / 2015년 이후:", len(good[good["year"] >= 2015]), "번") year gdp_growth cpi_inflation
0 2000 9.1 2.3
2 2002 7.7 2.8
5 2005 4.3 2.8
6 2006 5.3 2.2
7 2007 5.8 2.5
10 2010 6.8 2.9
13 2013 3.2 1.3
14 2014 3.2 1.3
17 2017 3.2 1.9
21 2021 4.3 2.5
좋았던 해: 10 번 / 2015년 이후: 2 번
두 조건을 &로 묶을 때는 각 조건을 반드시 괄호로 감싸야 합니다. 파이썬에서 &가 비교 연산자보다 먼저 계산되기 때문에, 괄호를 빼면 엉뚱한 순서로 묶여 오류가 납니다. 성장 3% 이상이면서 물가 3% 이하인 해는 모두 열 번인데, 2015년 이후로는 2017년과 2021년 단 두 번뿐입니다. 고성장과 저물가를 함께 누리던 시절이 점점 드물어졌다는 이야기를 이 필터가 들려줍니다.
15.11 10장 풀이 — 종합 분석과 세금
세금 관련 풀이가 tax_incidence 함수를 되풀이해 씁니다. 부록이 단독으로 돌도록 먼저 정의해 두고 이어지는 풀이에서 재사용합니다.
def tax_incidence(a, b, c, d, 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),
}손풀기 1. 실질 성장률. 명목 7%, 물가 4.5%에서 실질 성장률을 근사식으로 구합니다.
nominal = 7.0
inflation = 4.5
real = nominal - inflation
print(f"실질 성장률(근사): {real}%")
# 실질이 양수이므로 그해 국민은 실제로 더 살 수 있게 되었다.실질 성장률(근사): 2.5%
실질 성장률은 명목 성장률에서 물가 상승률을 빼는 근사식으로 2.5%가 나옵니다. 숫자로 찍힌 소득이 7% 늘어도 물가가 4.5% 올랐으니, 실제 구매력 기준의 성장은 그 차이인 2.5%뿐이지요. 값이 양수라 국민은 그해 실제로 조금 더 살 수 있게 되었습니다.
손풀기 2. 부담 배분. \(b=1\), \(d=4\) 시장에서 소비자 부담 비율을 구합니다.
b = 1
d = 4
consumer_share = d / (b + d)
print(f"소비자 부담 비율: {consumer_share:.0%}")
# 수요 기울기 b가 공급 기울기 d보다 작아 수요가 상대적으로 비탄력적이다.소비자 부담 비율: 80%
소비자 부담 비율 \(d/(b+d) = 4/5 = 80\%\)입니다. 수요 기울기 \(b=1\)이 공급 기울기 \(d=4\)보다 작다는 것은, 가격이 변해도 수요량이 덜 반응한다는 뜻이라 수요가 상대적으로 비탄력적입니다. 세금은 덜 도망갈 수 있는 쪽으로 더 얹히므로, 비탄력적인 소비자가 80%를 집니다. 탄력성이 부담의 방향을 정한다는 원리입니다.
손풀기 3. 1인당 GDP. 명목 GDP 88(십억 달러), 인구 21.2(백만 명)의 1인당 GDP를 구합니다.
gdp = 88 # 십억 달러
pop = 21.2 # 백만 명
per_capita = gdp * 1000 / pop
print(f"1인당 GDP: {per_capita:,.0f}달러")1인당 GDP: 4,151달러
십억은 \(10^9\), 백만은 \(10^6\)이라 둘을 나누면 \(10^3\)이 남습니다. 그래서 gdp / pop에 1000을 곱하면 단위가 달러로 맞춰지지요. 단위를 숫자와 함께 끌고 가며 계산하면, 왜 1000을 곱하는지가 억지 암기가 아니라 필연이 됩니다. 결과는 약 4,151달러입니다.
응용 4. 국가 D 추가. 딕셔너리에 국가 D를 더해 평균 성장률을 구합니다.
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
nation_d = {"gdp": [300, 315, 331, 350], "pop": [30, 30, 31, 31],
"inf": [2.0, 2.1, 1.8, 1.9]}
nom = growth_series(nation_d["gdp"])
real = [nom[i] - nation_d["inf"][i + 1] for i in range(len(nom))]
print(f"평균 명목 성장률: {sum(nom) / len(nom):.2f}%")
print(f"평균 실질 성장률: {sum(real) / len(real):.2f}%")
# 실질 성장 3%대로, 고성장·저물가의 국가 B에 가장 가깝다.평균 명목 성장률: 5.27%
평균 실질 성장률: 3.34%
본문에서 만든 growth_series를 그대로 쓰고, 국가 D의 값만 딕셔너리에 담아 넘겼습니다. 딕셔너리로 데이터를 정리해 둔 덕분에 계산 코드는 한 글자도 바꿀 필요가 없다는 것이 리팩터링의 이점입니다. 국가 D는 평균 명목 약 5.27%, 실질 약 3.34%로, 성장이 견실하고 물가가 안정된 국가 B에 가장 가깝습니다.
응용 5. 다른 시장의 세금. \(a=120, b=3, c=20, d=2\) 시장에 세금 6을 매깁니다.
r = tax_incidence(a=120, b=3, c=20, d=2, t=6)
print(f"소비자 부담 {r['consumer_burden']:.1f}, 생산자 부담 {r['producer_burden']:.1f}")
print(f"세수 {r['revenue']:.1f}, 자중손실 {r['dwl']:.1f}")소비자 부담 2.4, 생산자 부담 3.6
세수 316.8, 자중손실 21.6
소비자 부담 2.4가 생산자 부담 3.6보다 작습니다. 수요 기울기 b=3이 공급 기울기 d=2보다 커서 수요가 상대적으로 탄력적, 곧 가격이 오르면 더 쉽게 발을 빼기 때문입니다. 그래서 세금의 더 큰 몫이 덜 탄력적인 생산자에게 남습니다. 세수는 316.8, 사라진 거래로 인한 자중손실은 21.6입니다.
응용 6. CLI로 비교. 세율만 5와 10으로 바꿔 세수와 자중손실의 변화를 봅니다.
for t in (5, 10):
r = tax_incidence(100, 2, 10, 3, t)
print(f"t={t}: 세수 {r['revenue']:.1f}, 자중손실 {r['dwl']:.1f}")
# 실제로는 %run econo_cli.py tax --t 5 처럼 CLI로 같은 계산을 부른다.t=5: 세수 290.0, 자중손실 15.0
t=10: 세수 520.0, 자중손실 60.0
세율을 5에서 10으로 두 배 올렸지만 세수는 290에서 520으로 약 1.79배, 곧 두 배에 못 미치게 늡니다. 세율이 오르면 거래량 q1 자체가 58에서 52로 줄어 세원이 깎이기 때문입니다. 반면 자중손실은 15에서 60으로 네 배나 뛰어, 높은 세율의 대가가 효율 손실에 집중됨을 보여 줍니다. 본문의 econo_cli.py는 이 계산을 터미널 명령 하나로 부르는 껍데기일 뿐, 속은 같은 tax_incidence입니다.
도전 7. 세수가 가장 큰 세율(래퍼 곡선). 세율을 0부터 50까지 올리며 세수의 정점을 찾습니다.
import matplotlib.pyplot as plt
ts = list(range(0, 51))
revenues = [tax_incidence(100, 2, 10, 3, t)["revenue"] for t in ts]
best_t = ts[revenues.index(max(revenues))]
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(ts, revenues, color="tab:blue")
ax.plot(best_t, max(revenues), "o", color="tab:red")
ax.annotate(f"세수 최대 t={best_t}", xy=(best_t, max(revenues)),
xytext=(best_t + 4, max(revenues) - 60))
ax.set_xlabel("세율 t (잔당)")
ax.set_ylabel("세수")
ax.set_title("래퍼 곡선: 세율을 올려도 세수는 끝없이 늘지 않는다")
plt.tight_layout()
plt.show()
print("세수가 가장 큰 세율:", best_t)
세수가 가장 큰 세율: 27
세수는 세율 \(t\)와 세후 거래량 \(q_1\)의 곱인데, 이 시장에서 \(q_1 = 64 - 1.2t\)로 세율이 오를수록 줄어듭니다. 그래서 세수 \(= t(64 - 1.2t)\)는 위로 볼록한 포물선을 그리며, \(t \approx 27\)에서 정점을 찍고 다시 내려옵니다. 이 곡선이 래퍼 곡선입니다. 세율을 무작정 올리면 잔당 걷는 돈은 커져도 팔리는 잔 수가 줄어, 어느 지점을 넘어서면 세수가 오히려 감소하지요. \(q_1\)이 0이 되는 세율에서는 세원 자체가 말라 세수도 0이 됩니다. 다만 현실의 래퍼 곡선에서 정점이 어디인지는 경제학에서 여전히 논쟁적인 문제입니다.
도전 8. 부과 대상은 정말 무관한가. 소비자에게 세금을 매겨도 부담 배분이 같음을 확인합니다.
def tax_on_consumer(a, b, c, d, t):
# 소비자가 세금을 내면 수요는 P+t 에 반응: a - b(P+t) = c + dP
p_sell = (a - c - b * t) / (b + d) # 생산자가 받는 가격
p_buy = p_sell + t # 소비자가 실제로 내는 가격
p0 = (a - c) / (b + d)
return {"consumer_burden": p_buy - p0, "producer_burden": p0 - p_sell}
prod = tax_incidence(100, 2, 10, 3, 5)
cons = tax_on_consumer(100, 2, 10, 3, 5)
print(f"생산자 부과: 소비자 {prod['consumer_burden']:.1f}, 생산자 {prod['producer_burden']:.1f}")
print(f"소비자 부과: 소비자 {cons['consumer_burden']:.1f}, 생산자 {cons['producer_burden']:.1f}")생산자 부과: 소비자 3.0, 생산자 2.0
소비자 부과: 소비자 3.0, 생산자 2.0
두 경우 모두 소비자 부담 3, 생산자 부담 2로 정확히 같습니다. 세금을 소비자에게 매기면 수요가 반응하는 가격이 \(P+t\)가 되어 균형식이 \(a - b(P+t) = c + dP\)로 바뀌는데, 이를 풀어 생산자가 받는 가격을 구하면 생산자에게 매긴 경우와 부담 배분이 일치합니다. 세금은 사는 값과 받는 값 사이에 박히는 쐐기일 뿐, 누가 정부에 돈을 건네느냐(법적 귀착)는 실제로 누가 얼마를 부담하느냐(경제적 귀착)를 바꾸지 못합니다. 부담의 배분을 정하는 것은 오직 수요와 공급의 상대적 기울기입니다. 법으로 “판매자가 낸다”고 못 박아도, 가격이 조정되며 소비자에게 넘어가는 몫은 그대로라는 뜻입니다.
15.12 부록 B 풀이 — 버그 찾기
B-1. 경계에 선 납세자. 과세표준 5,000만 원의 한계세율이 24%로 잘못 나오는 버그를 고칩니다.
def bracket_of(income):
if income <= 14_000_000:
return "1구간, 한계세율 6%"
elif income <= 50_000_000: # 원래 코드의 < 를 <= 로 고침
return "2구간, 한계세율 15%"
elif income <= 88_000_000:
return "3구간, 한계세율 24%"
else:
return "4구간 이상"
for x in (14_000_000, 50_000_000, 88_000_000):
print(f"{x:,}원 -> {bracket_of(x)}")14,000,000원 -> 1구간, 한계세율 6%
50,000,000원 -> 2구간, 한계세율 15%
88,000,000원 -> 3구간, 한계세율 24%
버그는 두 번째 조건이 income < 50_000_000이었다는 점입니다. 2구간은 “5,000만 원 이하”인데 미만(<)으로 써서, 정확히 5,000만 원인 사람이 이 가지를 건너뛰고 24% 구간으로 샜습니다. 경계를 포함하려면 <=여야 하지요. 에러가 나지 않는 조용한 논리 오류라, 세 경계값을 넣어 검산해야 비로소 드러납니다. 고친 뒤 5,000만 원은 15% 구간으로 바르게 분류됩니다.
B-2. 사라진 1월. 12개월 합산에서 IndexError가 나는 코드를 두 방법으로 고칩니다.
monthly_sales = [310, 285, 342, 360, 355, 298, 275, 330, 361, 372, 340, 388]
# 고침 1: range 를 0부터 11까지로
total1 = 0
for month in range(12):
total1 += monthly_sales[month]
# 고침 2: len 으로 길이에 맞추기
total2 = 0
for month in range(len(monthly_sales)):
total2 += monthly_sales[month]
print("고침1:", total1, "/ 고침2:", total2)고침1: 4016 / 고침2: 4016
트레이스백 마지막 줄은 IndexError: list index out of range입니다. 리스트의 칸 번호는 0부터 11까지인데, 원래 코드의 range(1, 13)은 12까지 세어 monthly_sales[12]라는 없는 칸을 건드립니다. range(12)나 range(len(monthly_sales))로 고치면 0부터 11까지를 돌아 합계 4,016이 나옵니다. 함정은 range(1, 12)로 줄이는 수리법입니다. 에러는 사라지지만 인덱스 0인 1월이 빠져 합계가 틀리지요. ’몇 월’과 ’몇 번 칸’을 헷갈리면 이런 조용한 버그가 생깁니다.
B-3. 15가 10,320번 이어 붙은 주급. 문자열을 곱해 생긴 괴물 출력을 고칩니다.
hours_text = "15" # input("이번 주 일한 시간: ") 이 돌려준 문자열
wage = 10_320
pay = int(hours_text) * wage # 곱하기 전에 정수로 변환
print(f"이번 주 주급: {pay:,}원")이번 주 주급: 154,800원
"15" * 10320이 TypeError조차 내지 않은 것은, 파이썬에서 문자열 곱하기 정수가 “그 문자열을 그만큼 반복”하라는 합법적인 연산이기 때문입니다. 그래서 15가 10,320번 이어 붙은 긴 글자열이 나온 것이지요. 원인은 input()이 돌려준 글자열을 숫자로 바꾸지 않은 데 있습니다. int()로 감싸면 15 x 10,320 = 154,800원이 제대로 계산됩니다. 숫자로 쓸 입력값은 반드시 형 변환부터 한다는 규칙을 기억하세요.
B-4. 두 장부의 어긋남. 딕셔너리 키의 공백 차이로 나는 KeyError를 고칩니다.
quantities = {"학식": 22, "아메리카노": 30, "교통카드 충전": 4}
prices = {"학식": 6000, "아메리카노": 2300, "교통카드 충전": 12500} # 공백 통일
total = 0
for item in quantities:
total += quantities[item] * prices[item]
print(f"한 달 생활비(일부): {total:,}원")한 달 생활비(일부): 251,000원
트레이스백 마지막 줄은 KeyError: '교통카드 충전'입니다. quantities에는 “교통카드 충전”(붙여 씀)이, 원래 prices에는 “교통 카드 충전”(띄어 씀)이 있어 같은 항목인데 키가 달라 매칭에 실패했습니다. 두 딕셔너리의 키는 공백과 철자까지 완전히 같아야 합니다. 눈에 잘 안 띄는 공백 하나가 KeyError의 흔한 원인이지요. 공백을 맞춰 고치면 합계 251,000원이 나옵니다.
B-5. 배달비 할인이 적용되지 않는다. 가려진 elif 가지를 두 방법으로 되살립니다.
order = 24_000
# 고침 1: 큰 기준을 먼저 검사
if order >= 20_000:
fee = 1_000
elif order >= 10_000:
fee = 3_000
else:
fee = 3_500
print(f"고침1 배달비: {fee:,}원")
# 고침 2: 첫 조건에 상한을 붙여 구간을 명확히
if 10_000 <= order < 20_000:
fee = 3_000
elif order >= 20_000:
fee = 1_000
else:
fee = 3_500
print(f"고침2 배달비: {fee:,}원")고침1 배달비: 1,000원
고침2 배달비: 1,000원
elif는 위에서부터 처음 참인 가지에서 멈춥니다. 원래 코드는 넓은 조건 order >= 10_000을 맨 위에 두어, 2만 원을 넘는 24,000원조차 여기서 붙잡혀 3,000원으로 끝났습니다. 그 아래 order >= 20_000 가지는 영영 실행될 기회를 얻지 못하지요. 큰 기준을 먼저 검사하거나(고침 1), 첫 조건에 상한을 붙여 구간을 겹치지 않게 하면(고침 2) 둘 다 1,000원이 바르게 나옵니다. 넓은 조건을 위에 두면 좁은 조건이 가려진다는 것이 elif 순서의 핵심 교훈입니다.