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"] = False4 리스트와 반복문: 시계열 데이터 다루기 (4주차)
4.1 이번 장에서 배우는 것
이번 장의 주제는 두 가지, 여러 값을 담는 그릇인 리스트와 그 그릇을 자동으로 훑는 반복문입니다. 장을 마칠 때 여러분의 손에 남을 것은 다음과 같습니다.
- 여러 값을 하나의 이름으로 묶는 리스트를 만들고, 인덱싱과 슬라이싱으로 원하는 값만 집어냅니다.
for반복문을 여섯 개의 부품으로 분해해 이해하고, 합계에서 평균, 조건 카운트, 필터로 이어지는 누적 변수 패턴을 손에 익힙니다.- 반복 횟수를 미리 알 수 없는 문제 앞에서
while과break를 꺼내 드는 감각을 기릅니다. 0.1 + 0.2가0.3이 아닌 이유, 그리고 그 어색한 숫자를round와 f-string으로 다듬는 요령.- 예금 복리를 시뮬레이션해서 이 책의 첫 그래프인 성장 곡선을 그리고, 1장에서 약속했던 72의 법칙을 직접 검증합니다.
- 성장률의 누적은 덧셈이 아니라 곱셈이라는 사실, 그리고 “평균 성장률”이라는 말에 숨은 함정.
4.2 여는 질문: 90일치 매출, 손으로 더하시겠습니까
이콘 카페 사장님이 종강을 앞두고 한 학기 장사를 결산하려고 합니다. 포스기에서 하루하루의 매출 기록을 뽑아 보니 개강일부터 종강일까지 정확히 90개의 숫자가 나왔습니다. 알고 싶은 것은 소박합니다. 한 학기 총매출은 얼마였고, 하루 평균은 얼마였으며, 유난히 장사가 안된 날은 며칠이나 있었는가.
지금까지 배운 도구만으로 이 일을 하려면 어떻게 될까요. 2장의 방식대로라면 sales_day1, sales_day2, … sales_day90까지 변수 아흔 개를 만들고, 그 아흔 개를 더하는 식을 한 줄에 길게 타이핑해야 합니다. 중간에 변수 이름 하나만 잘못 쳐도 NameError가 나거나, 더 나쁘게는 에러 없이 틀린 합계가 나옵니다. 여기에 “평균보다 낮은 날이 며칠인가”라는 질문이 붙는 순간 3장의 if문을 아흔 번 복사해야 하는데, 이쯤 되면 코드를 배우는 의미가 없습니다. 차라리 계산기가 낫지요.
문제의 뿌리는 분업이 잘못된 데 있습니다. 기준을 정하고 결과를 해석하는 일은 사람이 잘하지만, 같은 동작을 아흔 번 반복하는 일은 컴퓨터가 압도적으로 잘합니다. 그런데 지금 우리는 반복까지 사람이 떠맡고 있습니다. 이번 장에서 배울 두 문법이 이 분업을 바로잡습니다. 리스트는 아흔 개의 숫자를 이름 하나로 묶고, 반복문은 “각 숫자에 대해 이 일을 하라”는 명령 한 줄로 아흔 번의 작업을 대신합니다. 데이터가 90개든 9,000개든 코드는 같은 길이입니다.
4.3 그래프 준비
미리 알려 드리면, 이번 장 후반에 이 책의 첫 그래프가 등장합니다. 그래프에 “잔액(원)” 같은 한글 라벨을 달 예정인데, matplotlib은 기본 설정에서 한글 글꼴을 모르기 때문에 준비 없이 그리면 글자가 전부 네모 상자로 깨져 나옵니다. 아래 셀을 미리 실행해 두면 운영체제에 맞는 한글 글꼴이 지정됩니다. 이 셀은 앞으로 그래프가 나오는 모든 장의 첫머리에 똑같이 등장하니, 얼굴을 익혀 두시기 바랍니다.
!pip install koreanize-matplotlib
import koreanize_matplotlib구글 Colab에서 실습하는 분은 사정이 하나 다릅니다. Colab 서버에는 한글 글꼴 자체가 설치되어 있지 않아서, 글꼴을 지정하는 것만으로는 부족합니다. 그런 경우 바로 위의 셀을 실행해 koreanize-matplotlib 패키지를 설치하면 글꼴 설치와 설정이 한 번에 해결됩니다. 자기 컴퓨터의 주피터 환경에서 실습 중이라면 위 셀은 건너뛰면 됩니다.
4.4 4.1 리스트: 아흔 개의 숫자에 이름 하나
리스트(list)는 여러 값을 순서대로 담는 파이썬의 그릇입니다. 만드는 법은 간단합니다. 대괄호 [ ] 안에 값들을 쉼표로 나란히 적으면 됩니다. 90일치는 뒤로 미루고, 우선 이콘 카페의 이번 주 아메리카노 판매 기록으로 시작하겠습니다. 일주일이면 원리를 익히기에 충분하고, 원리는 90일에도 그대로 통합니다.
sales = [52, 48, 61, 55, 88, 73, 41] # 이번 주 아메리카노 판매량(잔), 월요일부터 일요일까지
print(sales)
print(type(sales))
print(len(sales))[52, 48, 61, 55, 88, 73, 41]
<class 'list'>
7
변수 일곱 개가 필요했을 데이터가 이름 하나에 담겼습니다. type()으로 물어보면 list라는 새 자료형이라고 답하고, len()은 안에 든 값의 개수를 알려 줍니다. len은 2장에서 문자열의 글자 수를 셀 때 만났던 그 함수인데, 리스트에 쓰면 원소의 개수를 셉니다.
담는 것보다 중요한 것은 꺼내는 것입니다. 리스트의 각 값은 자리 번호를 갖는데, 이 번호를 인덱스(index)라고 부르고 대괄호로 지목합니다. 주의할 점이 하나 있습니다. 파이썬은 자리를 0부터 셉니다. 첫 원소는 “시작 지점에서 0칸 떨어진 값”이라는 논리입니다. 처음에는 어색하지만, 잠시 뒤 슬라이싱과 range를 만나면 이 방식의 일관성이 보이기 시작합니다.
print(sales[0]) # 첫 원소: 월요일
print(sales[4]) # 0부터 세어 네 칸 이동: 다섯 번째 원소인 금요일
print(sales[-1]) # 음수 인덱스는 뒤에서부터: 마지막 원소인 일요일
print(sales[-2]) # 뒤에서 두 번째: 토요일52
88
41
73
sales[4]가 네 번째가 아니라 다섯 번째 원소, 즉 금요일의 88잔이라는 점을 눈으로 확인해 두시기 바랍니다. 음수 인덱스는 끝에서부터 거슬러 세는 요령으로, “가장 최근 기록”을 꺼낼 때 sales[-1]이라고 쓰면 리스트가 얼마나 길든 항상 마지막 값을 줍니다. 그렇다면 없는 자리를 부르면 어떻게 될까요. 원소가 일곱 개인 리스트에 여덟 번째 자리를 요구해 보겠습니다.
print(sales[7])실수 노트: 자리 번호가 하나 밀렸을 때 — IndexError
위 셀을 실행하면 이런 메시지가 나옵니다.
---------------------------------------------------------------------------
IndexError Traceback (most recent call last)
Cell In[5], line 1
----> 1 print(sales[7])
IndexError: list index out of range
3장에서 익힌 요령대로 마지막 줄부터 읽습니다. IndexError: list index out of range, 리스트의 자리 번호가 범위를 벗어났다는 뜻입니다. 원소가 일곱 개면 유효한 인덱스는 0부터 6까지이므로, 마지막 자리는 항상 len(리스트) - 1입니다. “일곱 개니까 7번까지 있겠지”라는 직관이 0에서 시작하는 규칙과 부딪히며 생기는, 리스트를 배우는 주에 가장 흔한 오류입니다. 잠시 뒤 range를 배우고 나면 같은 실수가 range(len(sales) + 1)처럼 모양을 바꿔 다시 찾아오니, 이름을 기억해 두시기 바랍니다.
대괄호는 꺼낼 때만 쓰는 것이 아닙니다. 특정 자리에 새 값을 대입하면 그 자리의 값이 교체됩니다. 마침 수요일 기록에서 시럽 추가 주문 두 잔이 빠졌던 것이 발견되어, 정정할 일이 생겼습니다.
sales[2] = 63 # 수요일 기록 정정: 61잔이 아니라 63잔이었습니다
print(sales)[52, 48, 63, 55, 88, 73, 41]
리스트는 이렇게 만든 뒤에도 내용물을 고칠 수 있는 그릇입니다. 이제 한 칸이 아니라 여러 칸을 한꺼번에 잘라내는 법을 보겠습니다. 사장님의 다음 질문이 정확히 이것이기 때문입니다. “주중 장사와 주말 장사는 분위기가 다른데, 나눠서 볼 수 없나?” 리스트에서 연속한 구간을 잘라내는 문법을 슬라이싱(slicing)이라고 하고, 리스트[시작:끝] 꼴로 씁니다.
weekday = sales[0:5] # 월요일부터 금요일까지
weekend = sales[5:7] # 토요일과 일요일
print(weekday)
print(weekend)
print(sales[:3]) # 시작을 생략하면 처음부터
print(sales[4:]) # 끝을 생략하면 마지막까지[52, 48, 63, 55, 88]
[73, 41]
[52, 48, 63]
[88, 73, 41]
sales[0:5]가 다섯 개의 값을 주는데 5번 자리(토요일)는 빠져 있습니다. 슬라이싱은 시작 인덱스는 포함하고 끝 인덱스는 포함하지 않습니다. 처음에는 야박하게 느껴지는 규칙이지만 좋은 점이 두 가지 있습니다. 개수가 바로 보인다는 것(0:5는 5 - 0 = 5개), 그리고 자르는 지점이 이어진다는 것입니다. sales[0:5]와 sales[5:7]을 붙이면 정확히 원본이 되고, 겹치는 날도 빠지는 날도 없습니다. 시작이나 끝을 생략하는 축약형은 “처음부터”, “끝까지”라고 읽으면 됩니다.
이제 잘라 놓은 데이터로 사장님의 질문에 답할 차례입니다. 파이썬은 리스트를 통째로 받아 요약해 주는 내장 함수를 몇 개 갖추고 있습니다. 합계를 내는 sum, 가장 큰 값을 찾는 max, 가장 작은 값을 찾는 min입니다.
total = sum(sales)
average = total / len(sales)
print(f"이번 주 판매량 합계: {total}잔")
print(f"하루 평균: {average:.1f}잔")
print(f"가장 바빴던 날: {max(sales)}잔, 가장 한산했던 날: {min(sales)}잔")
print(f"주중 하루 평균: {sum(weekday) / len(weekday):.1f}잔, 주말 하루 평균: {sum(weekend) / len(weekend):.1f}잔")이번 주 판매량 합계: 420잔
하루 평균: 60.0잔
가장 바빴던 날: 88잔, 가장 한산했던 날: 41잔
주중 하루 평균: 61.2잔, 주말 하루 평균: 57.0잔
숫자 몇 개가 곧장 경영 정보가 됩니다. 하루 평균은 60잔인데 주중 평균(61.2잔)과 주말 평균(57.0잔)이 갈라지니, 주말에는 원두 준비량을 줄여도 된다는 힌트가 나옵니다. 다만 이 함수들에는 묘한 한계가 있습니다. max(sales)는 최고 기록이 88잔이라는 값은 알려 주지만, 그것이 무슨 요일이었는지는 말해 주지 않습니다. 이 미제 사건은 반복문을 배운 뒤 4.3절에서 해결하겠습니다.
리스트에 값을 담고 꺼내는 법까지 왔으니, 마지막으로 담은 뒤에 늘리는 법입니다. 이콘 카페가 신메뉴를 출시했다고 합시다. 이미 만들어 둔 메뉴 목록의 끝에 새 항목을 붙일 때는 append를 씁니다.
menu = ["아메리카노", "카페라떼", "바닐라라떼"]
menu.append("아인슈페너") # 신메뉴를 목록 끝에 추가
print(menu)
print(len(menu))
print("아인슈페너" in menu)
print("녹차라떼" not in menu)['아메리카노', '카페라떼', '바닐라라떼', '아인슈페너']
4
True
True
menu.append(...)의 생김새가 지금까지의 함수들과 다릅니다. sum(menu)처럼 함수에 리스트를 넘기는 것이 아니라, 리스트 이름 뒤에 점을 찍고 부릅니다. 이렇게 특정 자료형에 소속되어 점으로 호출하는 함수를 메서드(method)라고 부릅니다. append는 리스트 전용 메서드여서 “이 리스트의 끝에 붙여라”라는 뜻이 되고, 호출하고 나면 menu 자체가 길어집니다.
마지막 두 줄의 in과 not in은 3장에서 문자열 안을 뒤질 때 쓰던 그 연산자입니다. 그때 “in의 진짜 무대는 리스트”라고 예고했는데, 리스트에 쓰면 어떤 값이 원소로 들어 있는지를 True/False로 답해 줍니다. 주문받은 메뉴가 메뉴판에 있는지 확인하는 3장의 조건문과 자연스럽게 짝을 이루는 도구입니다.
# [직접 해보기]
# TODO 1) 지난주 여러분의 하루 지출을 어림해 7개짜리 리스트 my_spend를 만드세요. (월~일 순서)
# TODO 2) 인덱싱으로 월요일 지출을, 음수 인덱싱으로 일요일 지출을 출력하세요.
# TODO 3) 슬라이싱으로 주말 이틀 치만 잘라 출력하세요.
# TODO 4) sum, max, min을 써서 총지출과 최대·최소 지출을 f-string 한 줄로 출력하세요.4.5 4.2 for 반복문: 한 번 말하고 일곱 번 시키기
리스트가 데이터를 묶었으니 이제 일을 묶을 차례입니다. 요일별 판매량을 전부 화면에 출력하고 싶다면 어떻게 할까요. print(sales[0])부터 print(sales[6])까지 일곱 줄을 쓰는 방법이 있지만, 그것은 여는 질문에서 걷어차기로 한 바로 그 방식입니다. for 반복문은 “리스트의 각 원소에 대해 이 블록을 실행하라”를 한 번만 말하게 해 줍니다.
for cups in sales:
print(f"{cups}잔 팔렸습니다")52잔 팔렸습니다
48잔 팔렸습니다
63잔 팔렸습니다
55잔 팔렸습니다
88잔 팔렸습니다
73잔 팔렸습니다
41잔 팔렸습니다
한 줄씩 뜯어보기
for— 반복이 시작된다는 선언입니다.cups— 루프 변수입니다. 매 바퀴마다 리스트에서 꺼내 온 값이 이 이름에 담깁니다. 이름은 우리가 짓는 것이라x라고 해도 돌아가지만, “잔 수”를 담고 있으니cups라고 짓는 편이 코드를 읽는 미래의 자신에게 친절합니다.in sales— 값을 어디서 꺼내 올지 지정합니다. 조건문에서 참/거짓을 묻던in과 철자는 같지만,for옆에서는 “이 안의 원소들을 차례로”라는 뜻의 문법 부품으로 쓰입니다.- 줄 끝의 콜론(
:)과 들여쓰기 — 3장의if와 똑같은 약속입니다. 콜론이 블록의 시작을 알리고, 들여쓰인 줄들이 반복되는 몸통입니다.
실행 순서를 따라가 보면 이렇습니다. 첫 바퀴에서 cups에 52가 담기고 블록이 실행됩니다. 블록이 끝나면 파이썬은 다음 원소 48을 cups에 담고 블록을 다시 실행합니다. 마지막 원소 41까지 처리하고 나면 반복은 조용히 끝나고, 실행 흐름은 for 블록 다음 줄로 넘어갑니다. 원소가 일곱 개였으니 블록은 정확히 일곱 번 돌았습니다. 리스트가 90개짜리였다면 같은 두 줄이 90번 돌았을 것입니다.
for 문은 언제나 여섯 개의 부품으로 이루어집니다. 어떤 for 문을 만나든 이 여섯 자리를 찾아 읽으면 구조가 보입니다.
| 부품 | 역할 | 위 예제에서 |
|---|---|---|
for 키워드 |
반복의 시작 선언 | for |
| 루프 변수 | 매 바퀴 꺼내 온 값이 담기는 이름 | cups |
in 키워드 |
루프 변수와 순회 대상을 잇는 다리 | in |
| 순회 대상 | 값을 꺼내 올 곳 (리스트, 문자열, range 등) |
sales |
| 콜론 | “여기부터 블록”이라는 신호 | 줄 끝의 : |
| 들여쓰인 블록 | 매 바퀴 실행되는 몸통 | print(...) |
누적 변수: 합계에서 필터까지
출력은 했지만 결산은 아직입니다. 반복문으로 합계를 내려면 사람이 암산할 때와 같은 전략을 씁니다. 종이에 0을 적어 두고, 숫자를 하나 볼 때마다 종이 위의 값에 더해서 고쳐 적는 것입니다. 그 종이 역할을 하는 변수를 누적 변수라고 부릅니다. 여기서 새 연산자 하나를 같이 소개합니다. total = total + cups처럼 “자기 자신에 더해서 다시 담는” 문장은 워낙 자주 쓰여서, 파이썬은 total += cups라는 축약형을 제공합니다. 복합 대입 연산자라고 하며, -=, *=, /=도 같은 원리로 동작합니다.
total = 0
for cups in sales:
total += cups # total = total + cups 와 같은 뜻
print(f"일주일 합계: {total}잔")일주일 합계: 420잔
한 줄씩 뜯어보기
total = 0— 반복이 시작되기 전에 준비하는 빈 종이입니다. 반드시for바깥, 즉 들여쓰기 없는 위치에 있어야 합니다. 이 줄의 위치가 얼마나 중요한지는 잠시 뒤 실수 노트에서 확인합니다.total += cups— 매 바퀴 실행되는 유일한 일입니다. 바퀴가 돌 때마다total은 0, 52, 100, 163, 218, 306, 379, 420으로 자랍니다.print(...)— 들여쓰기가 없다는 점이 핵심입니다. 반복이 모두 끝난 뒤 한 번만 실행됩니다. 실수로 들여 쓰면 중간 집계가 일곱 번 출력됩니다. 에러는 아니지만 의도와 다른 결과지요.
정리하면 누적 패턴의 뼈대는 셋입니다. 반복 전에 그릇을 준비하고(바깥), 돌면서 채우고(안), 반복이 끝난 뒤 수확한다(바깥). 이 뼈대 하나로 앞으로 나올 변형을 전부 만들 수 있습니다.
그런데 눈치챈 분이 있을 것입니다. 4.1절에서 sum(sales)가 같은 답을 한 줄에 줬는데 왜 굳이 손으로 누적할까요. 이유는 두 가지입니다. 첫째, 조건이 붙는 순간 sum은 무력해집니다. “60잔 이상 판 날만 세어라” 같은 요청은 내장 함수가 대신해 주지 않습니다. 둘째, 덧셈이 아닌 누적에는 아예 내장 지름길이 없습니다. 이번 장 끝의 경제학 응용에서 성장률을 곱해서 누적할 때 이 패턴이 그대로 필요합니다. sum은 검산용으로 두고, 패턴을 확장해 보겠습니다. 먼저 평균입니다.
total = 0
count = 0
for cups in sales:
total += cups
count += 1 # 원소를 하나 처리할 때마다 개수도 함께 셉니다
average = total / count
print(f"하루 평균 {average:.1f}잔 (검산: sum과 len으로 계산해도 {sum(sales) / len(sales):.1f}잔)")하루 평균 60.0잔 (검산: sum과 len으로 계산해도 60.0잔)
누적 변수가 두 개로 늘었을 뿐 뼈대는 같습니다. 물론 count는 len(sales)와 같은 값이 되므로 여기서는 낭비처럼 보이지만, 개수를 세는 이 동작에 조건을 붙이는 순간 이야기가 달라집니다. 사장님의 세 번째 질문, “60잔 이상 나간 날이 며칠이었나”가 정확히 그 경우입니다. 3장의 if를 for 블록 안에 넣으면 됩니다. 들여쓰기가 두 겹이 되는 것에 주의하시기 바랍니다. if에 속한 줄은 for보다 한 단계 더 들어갑니다.
busy_days = 0
for cups in sales:
if cups >= 60:
busy_days += 1 # 조건을 통과한 바퀴에서만 실행됩니다
print(f"60잔 이상 판 날: {busy_days}일")60잔 이상 판 날: 3일
일곱 바퀴를 모두 돌지만 busy_days += 1은 63, 88, 73을 만난 세 바퀴에서만 실행됩니다. 여기서 반 걸음만 더 가면, 날 수를 세는 대신 그 날들의 기록 자체를 수집할 수 있습니다. 빈 리스트를 그릇으로 준비하고, 조건을 통과한 값을 append로 담으면 됩니다. 이 변형을 필터(filter)라고 부릅니다. 원본에서 조건에 맞는 것만 걸러 낸다는 뜻입니다.
busy_sales = [] # 빈 리스트: 대괄호만 열고 닫으면 됩니다
for cups in sales:
if cups >= 60:
busy_sales.append(cups)
print(busy_sales)
print(f"바쁜 날의 평균: {sum(busy_sales) / len(busy_sales):.1f}잔")[63, 88, 73]
바쁜 날의 평균: 74.7잔
합계, 평균, 조건 카운트, 필터. 네 코드를 나란히 놓고 보면 전부 한 가족임이 보입니다. 그릇을 준비하고, 돌면서 채우고, 끝난 뒤 수확한다. 달라진 것은 그릇의 종류(숫자냐 리스트냐)와 채우는 조건뿐입니다. 판다스를 배우기 전까지 여러분이 하게 될 데이터 집계의 대부분이 이 뼈대의 변주입니다.
실수 노트: 그릇을 반복문 안에서 준비하면 — 조용히 틀리는 버그
누적 패턴에서 가장 흔한 사고는 total = 0의 위치가 한 칸 밀리는 것입니다. 에러가 나는 실수라면 파이썬이 바로 알려 주겠지만, 이 실수는 멀쩡히 실행되고 답만 틀립니다. 직접 보겠습니다.
total_wrong = 0
for cups in sales:
total_wrong = 0 # 초기화가 반복문 안으로 들어와 버렸습니다
total_wrong += cups
print(total_wrong)41
420이어야 할 합계가 41, 그러니까 마지막 원소 하나의 값이 되었습니다. 매 바퀴 시작마다 종이를 백지로 만들었으니, 반복이 끝났을 때 남는 것은 마지막 바퀴의 값뿐입니다. 파이썬 입장에서는 시키는 대로 정확히 일한 것이라 에러 메시지도 없습니다. 에러가 나는 버그보다 조용히 틀리는 버그가 무서운 이유입니다.
이런 버그를 잡는 실용적인 습관은 반복문 안에 print(cups, total_wrong)을 임시로 넣어 누적 변수가 바퀴마다 자라는 모습을 눈으로 확인하는 것입니다. 자라야 할 값이 매번 리셋되고 있다면 초기화 위치부터 의심하면 됩니다. 확인이 끝나면 임시 print는 지웁니다.
# [직접 해보기]
# TODO 1) sum을 쓰지 말고, for와 누적 변수로 weekend(주말 이틀)의 판매량 합계를 구해 출력하세요.
# TODO 2) 하루 평균(60.0잔)보다 많이 판 날이 며칠인지 세어 보세요.
# TODO 3) 50잔 미만이었던 날의 판매량만 새 리스트 slow_sales에 모아 출력하세요.4.6 4.3 range: 리스트 없이 아흔 번 반복하기
지금까지의 for는 항상 꺼낼 리스트가 있었습니다. 그런데 반복하고 싶은 이유가 데이터가 아니라 횟수일 때도 있습니다. “10년치 이자를 계산해라”처럼 그냥 열 번 돌면 되는 경우입니다. 이럴 때 숫자 나열을 즉석에서 만들어 주는 것이 range입니다.
for i in range(5):
print(i)0
1
2
3
4
0부터 4까지, 다섯 개의 숫자가 나오고 5는 나오지 않습니다. 리스트의 인덱스가 0부터였던 것과 같은 원리로, range(5)는 “0에서 출발해 5 직전까지”입니다. range는 쓰는 방법이 세 가지인데, 넘기는 값의 개수로 구분합니다. 어떤 숫자들이 만들어지는지는 list()로 변환해 보면 한눈에 보입니다.
print(list(range(5))) # 끝만 지정: 0부터 5 직전까지
print(list(range(2015, 2025))) # 시작과 끝: 2015부터 2025 직전까지
print(list(range(2016, 2025, 2))) # 시작, 끝, 간격: 2016부터 두 칸씩[0, 1, 2, 3, 4]
[2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024]
[2016, 2018, 2020, 2022, 2024]
셋째 형태까지 오면 “끝은 포함하지 않는다”는 규칙이 슬라이싱의 규칙과 정확히 같다는 것이 보입니다. range(2015, 2025)가 만드는 숫자는 2025 - 2015 = 10개, 딱 10년치입니다. 개강일을 0일째로 세면 90일 학기는 range(90)이고, 이 또한 90개입니다. 끝을 빼는 약속 덕분에 개수 계산이 뺄셈 한 번으로 끝나는 것, 이것이 파이썬 전반에 걸친 일관된 설계입니다.
range의 두 번째 쓸모는 인덱스 만들기입니다. 4.1절에서 남겨 둔 미제 사건을 기억하실 겁니다. 최고 기록 88잔이 무슨 요일이었는지 max는 말해 주지 않았습니다. 문제의 핵심은 판매량 리스트와 요일 리스트가 따로 있다는 것입니다. for cups in sales:로 돌면 값만 나올 뿐 그 값이 몇 번째인지를 모르므로, 요일 리스트의 같은 자리를 찾아갈 수 없습니다. 인덱스 i로 돌면 두 리스트를 같은 자리끼리 짝지을 수 있습니다.
days = ["월", "화", "수", "목", "금", "토", "일"]
for i in range(len(sales)): # i는 0, 1, 2, ... 6
print(f"{days[i]}요일: {sales[i]}잔")월요일: 52잔
화요일: 48잔
수요일: 63잔
목요일: 55잔
금요일: 88잔
토요일: 73잔
일요일: 41잔
range(len(sales))는 “이 리스트의 유효한 인덱스 전부”를 만드는 관용구입니다. len(sales)가 7이니 range(7), 즉 0부터 6까지가 나오는데, 이것이 정확히 sales의 자리 번호들입니다. 실수 노트에서 예고했던 함정도 여기 있습니다. range(len(sales) + 1)이라고 쓰면 7번 자리까지 방문하려다 IndexError가 납니다.
같은 일을 조금 더 우아하게 하는 도구도 있습니다. enumerate는 리스트를 돌면서 자리 번호와 값을 한꺼번에 넘겨줍니다. 맛보기로만 보여 드립니다.
for i, cups in enumerate(sales):
print(f"{days[i]}요일: {cups}잔")월요일: 52잔
화요일: 48잔
수요일: 63잔
목요일: 55잔
금요일: 88잔
토요일: 73잔
일요일: 41잔
루프 변수 자리에 i, cups처럼 이름 두 개가 쉼표로 나란히 놓였습니다. 매 바퀴 enumerate가 (자리 번호, 값) 쌍을 주면 앞의 것은 i에, 뒤의 것은 cups에 담깁니다. 값 여러 개를 한 번에 받는 이 문법은 5장에서 튜플을 배울 때 정식으로 다루니, 지금은 range(len(...)) 방식만 확실히 익혀 두고 이런 지름길이 있다는 것만 기억하면 충분합니다.
이제 미제 사건을 종결하겠습니다. 최고 기록의 요일을 찾는 것은 누적 패턴의 또 다른 변주입니다. 그릇에 합계 대신 “지금까지 본 것 중 최고 기록”과 “그 요일”을 담아 두고, 더 큰 값을 만날 때마다 갈아 끼우면 됩니다.
best_day = ""
best_cups = 0
for i in range(len(sales)):
if sales[i] > best_cups: # 지금까지의 최고 기록을 넘어서면
best_cups = sales[i] # 기록을 갈아 치우고
best_day = days[i] # 요일도 같이 기억합니다
print(f"가장 바빴던 날: {best_day}요일 ({best_cups}잔)")가장 바빴던 날: 금요일 (88잔)
금요일 88잔. 조별 과제 마감이 몰리는 날이라는 사장님의 감과 일치합니다. 누적 변수에 꼭 합계만 담으라는 법은 없습니다. “지금까지의 최고”를 담으면 최댓값 추적이 되고, 최고와 최저를 같이 담으면 두 값을 한 번의 반복으로 얻습니다.
곁가지: 이 두 리스트, 사실은 표 한 장입니다
방금 쓴 days와 sales를 나란히 세워 보면 요일 열과 판매량 열이 있는 엑셀 표와 똑같은 구조입니다. 같은 자리끼리 한 행이 되는 것이지요. 실제로 9장에서 배울 판다스의 데이터프레임은 “이름 붙은 리스트 여러 개를 나란히 묶은 표”라고 할 수 있고, 지금 여러분이 다루는 리스트 하나하나가 그 표의 열(column)이 됩니다. 리스트를 다루는 손놀림이 곧 데이터 분석의 기본기인 이유입니다.
4.7 4.4 복리 시뮬레이션: 이자가 이자를 낳을 때
1장에서 72의 법칙을 소개하며 “성장률로 72를 나누면 두 배가 되는 데 걸리는 햇수”라고만 말하고 지나갔습니다. 그때는 검증할 도구가 없었기 때문입니다. 이제 반복문이 있으니 돈이 실제로 자라는 과정을 재연할 수 있습니다.
시나리오는 이콘 카페에서 가져옵니다. 사장님이 이번 학기 이익 중 100만 원을 여윳돈으로 떼어 연 5% 정기예금에 넣고 10년을 묵히기로 했습니다. 복리이므로 첫해 이자 5만 원은 이듬해부터 원금 취급을 받아 저도 이자를 법니다. “잔액에 1.05를 곱하는 일을 열 번 반복한다”는 문장이 그대로 for와 range가 됩니다.
balance = 1_000_000 # 예치 원금(원)
rate = 0.05 # 연 이율 5%
for year in range(1, 11): # 1년 차부터 10년 차까지
balance = balance * (1 + rate)
print(year, "년 후:", balance, "원")1 년 후: 1050000.0 원
2 년 후: 1102500.0 원
3 년 후: 1157625.0 원
4 년 후: 1215506.25 원
5 년 후: 1276281.5625 원
6 년 후: 1340095.640625 원
7 년 후: 1407100.42265625 원
8 년 후: 1477455.4437890626 원
9 년 후: 1551328.2159785158 원
10 년 후: 1628894.6267774417 원
10년 뒤 잔액은 약 163만 원, 늘어난 63만 원 가운데 50만 원은 원금이 벌어들인 단순 이자이고 나머지 13만 원가량은 이자가 벌어들인 이자입니다. 증가폭도 매년 커집니다. 첫해에는 5만 원이 늘었지만 마지막 해에는 7만 7천 원 남짓 늘었습니다.
그런데 출력의 꼴이 마음에 걸립니다. 처음 세 해는 .0으로 끝나는 깔끔한 값이더니, 4년 차부터 소수점 아래에 조각이 붙기 시작해 뒤로 갈수록 길어지고, 10년 차에는 1628894.6267774417처럼 통장에는 존재하지 않는 원 단위 이하의 부스러기까지 달고 나옵니다. 반올림해서 다듬으면 될 일이지만, 다듬는 법을 배우기 전에 파이썬 소수의 작은 비밀 하나를 보고 가는 편이 좋겠습니다. 다음 셀의 결과를 예상해 보고 실행해 보시기 바랍니다.
print(0.1 + 0.2)
print(0.07 * 100)0.30000000000000004
7.000000000000001
왜 이런 숫자가 나올까: 0.1 + 0.2는 0.3이 아니다
0.30000000000000004와 7.000000000000001. 고장이 아닙니다. 컴퓨터는 모든 수를 2진법으로 저장하는데, 10진법에서 깔끔한 0.1이 2진법에서는 무한히 이어지는 소수입니다. 10진법에서 1/3을 쓰려면 0.3333…을 어디선가 끊어야 하는 것과 같은 사정입니다. 컴퓨터는 정해진 자릿수에서 끊어 저장하므로, 0.1은 사실 0.1에 아주 가까운 근삿값으로 담겨 있습니다. 그 미세한 오차가 계산을 거치며 표면으로 올라온 것이 저 기다란 꼬리입니다. 파이썬만의 문제도 아니어서, 엑셀이든 다른 언어든 소수를 이 방식(부동소수점)으로 다루는 모든 곳에서 같은 일이 일어납니다.
대처법은 상황에 따라 두 가지입니다.
- 보여 줄 때만 다듬기: f-string의 서식이 답입니다.
f"{balance:,.0f}"는 계산값은 건드리지 않고 화면에만 반올림해 천 단위 쉼표와 함께 보여 줍니다. 대부분의 경우 이것으로 충분하고, 계산 정밀도를 잃지 않아 안전합니다. - 값 자체를 다듬기:
round(balance, 0)처럼round(값, 자릿수)를 쓰면 반올림된 값이 새로 만들어집니다. 반올림된 값으로 이후 계산을 이어 가야 할 때 씁니다.
하나만 덧붙이면, 이런 사정 때문에 소수끼리의 == 비교는 위험합니다. 0.1 + 0.2 == 0.3은 False입니다. 소수를 비교할 일이 있다면 두 값의 차이가 충분히 작은지를 묻는 것이 안전한 습관입니다. 이제 f-string으로 출력을 다듬고, 내친김에 그래프를 그릴 준비로 연도별 잔액을 리스트에 모아 두겠습니다.
balance = 1_000_000
balances = [balance] # 0년 차(예치 직후)의 잔액부터 기록해 둡니다
for year in range(1, 11):
balance = balance * 1.05
balances.append(balance)
print(f"{year:2d}년 후: {balance:,.0f}원")
print(f"10년 동안 원금의 {balances[-1] / balances[0]:.2f}배가 되었습니다") 1년 후: 1,050,000원
2년 후: 1,102,500원
3년 후: 1,157,625원
4년 후: 1,215,506원
5년 후: 1,276,282원
6년 후: 1,340,096원
7년 후: 1,407,100원
8년 후: 1,477,455원
9년 후: 1,551,328원
10년 후: 1,628,895원
10년 동안 원금의 1.63배가 되었습니다
누적 패턴이 또 등장했습니다. 이번 그릇은 리스트 balances이고, 매 바퀴 그해의 잔액을 append로 쌓았습니다. 그 결과 0년 차부터 10년 차까지 열한 개의 값이 시간 순서대로 담겼습니다. 이렇게 시점마다 하나씩 관측된 값의 나열을 시계열(time series)이라고 부릅니다. 연도별 GDP, 월별 물가지수, 일별 환율이 모두 시계열이고, 이콘 카페의 90일 매출 기록도 시계열입니다. 그리고 시계열은 숫자로 훑는 것보다 그림으로 보는 쪽이 압도적으로 빠릅니다.
4.8 4.5 그림으로 확인하기: 이 책의 첫 그래프
방금 만든 balances에는 숫자 열한 개가 들어 있습니다. 표로 훑으면 “늘고 있다” 정도는 알 수 있지만, 어떤 모양으로 늘고 있는지는 잘 잡히지 않습니다. 장 첫머리의 그래프 준비 셀을 실행해 두었다면, 아래 네 줄이 그 모양을 보여 줍니다.
years = list(range(0, 11)) # 가로축: 0년 차부터 10년 차까지
plt.plot(years, balances, marker="o")
plt.title("연 5% 복리: 100만 원의 10년")
plt.xlabel("경과 연수")
plt.ylabel("잔액(원)")
plt.show()
한 줄씩 뜯어보기
years = list(range(0, 11))— 가로축에 놓을 자리표입니다. 세로축에 놓을balances가 열한 개이므로 가로축 값도 열한 개여야 합니다. 두 리스트의 길이가 다르면 matplotlib이 에러를 냅니다.plt.plot(years, balances, marker="o")— 선 그래프를 그리는 핵심 명령입니다. 첫 인자가 가로축, 둘째 인자가 세로축이고, 같은 자리끼리 짝지어 점을 찍은 뒤 순서대로 선으로 잇습니다. 4.3절에서 두 리스트를 인덱스로 짝지었던 것과 같은 원리를 matplotlib이 대신해 주는 셈입니다.marker="o"는 각 짝의 위치에 동그라미를 찍어 관측점을 드러내는 장식입니다.plt.title,plt.xlabel,plt.ylabel— 그래프의 제목과 축 이름표입니다. 라벨 없는 그래프는 무엇을 그렸는지 그린 사람만 아는 그림이 되므로, 이 세 줄은 습관으로 붙이기를 권합니다. 한글이 네모 상자로 나온다면 그래프 준비 셀을 건너뛴 것이니 그 셀부터 다시 실행하면 됩니다.plt.show()— 그리기를 마쳤으니 화면에 보여 달라는 마침표입니다.
세로축 눈금 위에 적힌 1e6은 “눈금 숫자에 10의 6제곱을 곱해 읽어라”, 즉 눈금의 1.0이 100만 원이라는 뜻입니다. 큰 숫자를 다룰 때 matplotlib이 즐겨 쓰는 표기이니 당황하지 않아도 됩니다.
이제 그래프가 말하는 내용을 읽어 보겠습니다. 언뜻 직선처럼 보이지만 자를 대 보면 위로 휘어 있습니다. 단리였다면, 그러니까 매년 원금 100만 원에 대해서만 5만 원씩 이자가 붙었다면 그래프는 정확한 직선이 됩니다. 갈수록 가팔라지는 이 휨이 복리의 시각적 서명입니다. 앞으로 이 책의 모든 그래프에는 “그래프가 말하는 경제학 한 문장”을 캡션으로 달기로 하겠습니다. 이 그래프의 문장은 이렇습니다. 이자가 이자를 낳으므로, 곡선은 갈수록 가팔라진다.
색을 바꾸고 격자를 넣고 균형점에 주석을 다는 꾸미기 문법은 8장에서 본격적으로 다룹니다. 그때까지는 plot, title, xlabel, ylabel, show 다섯 개면 충분합니다.
4.9 4.6 while: 반복 횟수를 미리 모를 때
지금까지의 반복에는 공통점이 있습니다. 시작하기 전에 몇 바퀴 돌지 정해져 있었다는 것입니다. 리스트를 돌면 리스트 길이만큼, range(1, 11)이면 열 바퀴. 그런데 현실의 질문 중에는 횟수를 미리 알 수 없는 것이 많습니다.
이콘 카페에서 3년을 일한 졸업생 선배가 학교 앞에 자기 가게를 차렸다고 합시다. 통장에 창업 자금 500만 원이 있고, 매달 월세 60만 원과 기본 재료비 30만 원, 합쳐서 90만 원이 고정적으로 나갑니다. 개업 초기라 매출이 없다고 보수적으로 가정하면, 이 돈으로 몇 달을 버틸 수 있을까요. 창업자들이 활주로에 빗대어 런웨이(runway)라고 부르는, 오래된 질문입니다. 몇 바퀴 돌지는 모르지만 언제 멈출지는 압니다. 통장이 한 달 치 지출보다 얇아지는 순간입니다. 이런 문제를 위한 문법이 while입니다. while은 “조건이 참인 동안 반복하라”고 말합니다.
funds = 5_000_000 # 창업 자금(원)
monthly_cost = 900_000 # 월세 60만 원 + 재료비 30만 원
months = 0
while funds >= monthly_cost:
funds -= monthly_cost
months += 1
print(f"{months}개월 버틸 수 있고, 통장에는 {funds:,}원이 남습니다")5개월 버틸 수 있고, 통장에는 500,000원이 남습니다
한 줄씩 뜯어보기
while funds >= monthly_cost:— 매 바퀴가 시작되기 전에 검사하는 관문입니다. 조건이 참이면 블록을 한 번 더 실행하고, 거짓이 되는 순간 반복 전체가 끝납니다. 조건식 자리에 오는 것은 3장에서 배운 그 불리언 표현식입니다.funds -= monthly_cost— 한 달 치 지출입니다. 복합 대입의 빼기 버전을 처음 실전에 썼습니다.months += 1—for와 달리while은 바퀴 수를 세어 주지 않으므로, 몇 바퀴 돌았는지 알고 싶으면 이렇게 직접 셉니다.- 마지막
print— 조건이 거짓이 된 뒤에 도달합니다. 출력을 보면 다섯 달을 버티고 50만 원이 남는데, 50만 원은 90만 원보다 작으므로 여섯 번째 달 월세를 낼 수 없어 반복이 멈춘 것입니다.
for와 while의 분업은 이렇게 정리할 수 있습니다. for는 “이만큼 반복해”입니다. 돌아야 할 목록이나 횟수가 먼저 있습니다. while은 “이 상태인 동안 반복해”입니다. 멈춤의 조건이 먼저 있습니다. 질문의 꼴을 보면 어느 쪽인지 보입니다. “10년 뒤 잔액은?”은 for의 질문이고, “잔액이 바닥나는 데 몇 달?”은 while의 질문입니다.
이번에는 방향을 뒤집어, 줄어드는 통장이 아니라 쌓이는 통장을 보겠습니다. 매달 30만 원씩 저축하고 연 3% 이자가 월 복리로 붙는 적금이 있다고 단순화합시다(월 이율은 0.03을 12로 나눈 0.25%). 1,000만 원을 모으는 데 몇 달이 걸릴까요. 목표는 알지만 횟수는 모르니, 역시 while의 질문입니다.
balance = 0
monthly_saving = 300_000
monthly_rate = 0.03 / 12 # 연 3%를 월 이율로 환산
months = 0
while balance < 10_000_000:
balance = balance * (1 + monthly_rate) + monthly_saving
months += 1
print(f"{months}개월 만에 목표 달성 (최종 잔액 {balance:,.0f}원)")33개월 만에 목표 달성 (최종 잔액 10,306,425원)
잔액을 갱신하는 줄을 뜯어 읽으면, 지난달 잔액에 한 달 치 이자를 붙이고(* (1 + monthly_rate)) 이번 달 저축액을 더하는(+ monthly_saving) 두 동작이 겹쳐 있습니다. 답은 33개월입니다. 이자가 한 푼도 없다고 치면 1,000만 원을 30만 원으로 나눠 34개월이 필요하니, 연 3%라는 시시해 보이는 이율이 한 달을 벌어 준 셈입니다. 이율을 바꿔 가며 실행해 보면 이자율과 시간의 관계가 손에 잡히기 시작합니다.
여기에 현실적인 조건을 하나 얹어 보겠습니다. 목표를 2,000만 원으로 올리되, 5년 넘게는 기다리지 않겠다는 것입니다. “목표 도달”과 “시간 초과” 중 먼저 오는 쪽에서 멈춰야 하는데, while의 조건식은 하나뿐입니다. 이럴 때 반복 도중에 즉시 탈출하는 비상구, break를 씁니다.
balance = 0
months = 0
while balance < 20_000_000:
balance = balance * (1 + monthly_rate) + monthly_saving
months += 1
if months == 60:
break # 60개월이 되면 조건과 상관없이 즉시 반복을 끝냅니다
if balance >= 20_000_000:
print(f"{months}개월 만에 목표 달성")
else:
print(f"60개월을 모아도 {balance:,.0f}원 — 목표에는 미치지 못합니다")60개월을 모아도 19,394,014원 — 목표에는 미치지 못합니다
break를 만나는 순간 파이썬은 조건 검사조차 생략하고 while 블록 밖으로 나갑니다. 반복이 끝난 뒤의 if/else는 두 출구 중 어느 쪽으로 나왔는지를 판정합니다. 결과는 60개월 쪽입니다. 월 30만 원 저축으로 5년 안에 2,000만 원은 만들어지지 않으니, 목표 시점을 늦추거나 저축액을 늘려야 한다는 답을 숫자가 대신 말해 줍니다. 참고로 break는 while 전용이 아니라 for 블록 안에서도 똑같이 동작합니다.
while은 자유로운 만큼 for에는 없는 위험을 하나 품고 있습니다. for는 리스트가 끝나면 반드시 멈추지만, while은 조건이 거짓이 되지 않으면 영원히 돕니다. 아래 셀은 저축 예제에서 잔액을 키우는 줄을 깜빡한 코드입니다. 실행하지 말고 눈으로만 읽어 보시기 바랍니다.
balance = 0
months = 0
while balance < 10_000_000:
months = months + 1 # 잔액을 키우는 줄을 깜빡했습니다실수 노트: 끝나지 않는 반복 — 무한 루프
이 셀을 실행하면 에러 메시지조차 나오지 않습니다. balance는 영원히 0이고 조건은 영원히 참이므로, 셀 왼쪽의 실행 표시 [*]만 하염없이 깜빡입니다. 파이썬 입장에서는 시키는 대로 성실하게 반복하는 중이라 아무것도 잘못되지 않았습니다. 무한 루프에 빠졌다는 것을 알아채는 쪽도, 멈추는 쪽도 사람의 몫입니다.
멈추는 법부터 알아 두겠습니다. Colab에서는 실행 중인 셀 왼쪽의 정지 버튼을 누르고, 주피터에서는 툴바의 사각형 아이콘(Interrupt)을 누릅니다. 터미널에서 실행 중이라면 Ctrl+C입니다. 어느 쪽이든 KeyboardInterrupt라는 이름과 함께 실행이 끊기는데, 이것은 “사용자가 중단시켰다”는 기록일 뿐 여러분이 잘못했다는 뜻이 아닙니다.
예방 습관은 두 가지입니다. 첫째, while을 쓸 때마다 한 번 자문하는 것입니다. 조건식에 등장하는 변수(balance)를 블록 안에서 조건이 거짓이 되는 방향으로 움직이는 줄이 있는가. 둘째, 확신이 없을 때는 위의 break 예제처럼 바퀴 수 상한을 보험으로 걸어 두는 것입니다. if months > 1000: break 한 줄이면, 논리가 틀렸을 때 프로그램이 몇 초 만에 스스로 멈추고 이상한 결과를 보여 주므로 문제를 훨씬 빨리 발견하게 됩니다.
# [직접 해보기]
# TODO 1) 창업 자금 예제에서 월 지출을 75만 원으로 줄이면 몇 달을 버티는지 while로 계산해 보세요.
# TODO 2) 저축 예제에서 매달 저축액을 50만 원으로 올리면 1,000만 원까지 몇 달 걸리는지 계산해 보세요.
# (월 이율은 그대로 0.03 / 12)4.10 4.7 1장의 약속: 72의 법칙 검증
이제 1장에서 미뤄 둔 숙제를 처리할 차례입니다. 72의 법칙은 “연 성장률이 g%면 두 배가 되는 데 약 72/g년이 걸린다”는 어림 공식이었습니다. 어떤 경제가 연 3%로 꾸준히 성장한다면 법칙의 예측은 72 나누기 3, 즉 24년입니다. 정말 그런지는 이제 직접 셀 수 있습니다. “두 배가 될 때까지”는 전형적인 while의 질문이기 때문입니다.
level = 100.0 # 어느 해의 GDP 수준을 100으로 놓습니다
growth = 0.03
years = 0
while level < 200:
level = level * (1 + growth)
years += 1
print(f"연 3% 성장: {years}년 만에 두 배 (72의 법칙의 예측: 72 / 3 = {72 / 3:.0f}년)")연 3% 성장: 24년 만에 두 배 (72의 법칙의 예측: 72 / 3 = 24년)
정확히 24년, 예측과 일치합니다. 우연이 아닙니다. 두 배가 되는 조건 \((1+g)^n = 2\)를 풀면
\[n = \frac{\ln 2}{\ln(1+g)} \approx \frac{0.693}{g}\]
이므로 엄밀히는 “69.3의 법칙”이 맞습니다. 그런데도 72가 쓰이는 데는 이유가 있습니다. 72는 2, 3, 4, 6, 8, 9, 12로 모두 나누어떨어져 암산에 압도적으로 유리하고, 성장률이 어느 정도 큰 구간에서는 근사 오차를 슬쩍 보정해 주기까지 합니다. 다른 성장률로도 확인해 보겠습니다.
growth = 0.06
level = 100.0
years = 0
while level < 200:
level = level * (1 + growth)
years += 1
print(f"연 6% 성장: {years}년 (72의 법칙: {72 / 6:.0f}년)")
growth = 0.01
level = 100.0
years = 0
while level < 200:
level = level * (1 + growth)
years += 1
print(f"연 1% 성장: {years}년 (72의 법칙: {72 / 1:.0f}년)")연 6% 성장: 12년 (72의 법칙: 12년)
연 1% 성장: 70년 (72의 법칙: 72년)
6%에서는 12년으로 딱 맞고, 1%에서는 실제 70년과 예측 72년이 두 해 어긋납니다. 성장률이 작을수록 69.3 쪽이 정확해지기 때문인데, 어림 공식으로는 충분히 훌륭한 성적입니다.
숫자 세 개를 나란히 놓고 보면 성장률의 무게가 느껴집니다. 연 6%로 크는 경제는 12년마다, 그러니까 한 사람이 대학을 졸업해 은퇴할 때까지 서너 번 두 배가 됩니다. 연 1%라면 평생에 한 번 두 배를 볼까 말까입니다. 고도성장기의 한국이 앞의 세계였고, 오늘날의 많은 선진국이 뒤의 세계에 가깝습니다. 성장률 몇 %포인트의 차이는 연말 뉴스에서는 사소해 보여도, 세대의 시간에서는 완전히 다른 경제를 만듭니다.
고백할 것이 하나 있습니다. 방금 거의 같은 코드를 세 번 복사하며 숫자만 바꿨습니다. 슬슬 불편하지 않습니까. 코드 덩어리에 이름을 붙여 두고 “성장률 6%로 한 번, 1%로 한 번” 하고 불러 쓸 수 있다면 좋을 텐데, 그 도구가 바로 다음 장의 주인공인 함수입니다. 오늘의 불편함은 다음 장의 예고편으로 남겨 두겠습니다.
4.11 4.8 경제학 응용: 성장률은 더하지 말고 곱하세요
뉴스에서 이런 문장을 봤다고 합시다. “이 나라는 지난 4년간 3.0%, 2.0%, -1.0%, 4.0% 성장했다.” 그래서 4년 동안 경제는 총 몇 % 커졌을까요. 반사적으로 넷을 더해 8%라고 답하기 쉽습니다. 그럴듯하지만 틀린 답입니다.
성장률은 수준이 아니라 비율입니다. 올해 3% 성장했다는 것은 작년 규모에 1.03을 곱했다는 뜻이고, 이듬해의 2%는 이미 1.03배가 된 규모에 다시 1.02를 곱했다는 뜻입니다. 그래서 여러 해의 성장은 덧셈이 아니라 곱셈으로 쌓입니다. 기준 연도의 규모를 \(Y_0\)이라 하면
\[\frac{Y_4}{Y_0} = (1+g_1)(1+g_2)(1+g_3)(1+g_4)\]
입니다. 코드로 옮기면 누적 변수 패턴의 곱셈판입니다. 그릇을 0이 아니라 기준값 100으로 초기화하고, 더하는 대신 곱하면 됩니다. 4.2절에서 “덧셈이 아닌 누적에는 내장 지름길이 없다”고 했던 바로 그 상황이라, sum 같은 함수 대신 직접 익힌 패턴이 제값을 합니다.
growth_rates = [3.0, 2.0, -1.0, 4.0] # 가상의 나라, 4년치 성장률(%)
index = 100.0 # 기준 연도의 GDP 수준을 100으로
path = [index]
for g in growth_rates:
index = index * (1 + g / 100) # 성장률 %를 1.03 같은 배율로 바꿔 곱합니다
path.append(index)
print(path)
print(f"4년 누적 성장률: {index - 100:.2f}% (성장률의 단순 합: {sum(growth_rates):.0f}%)")[100.0, 103.0, 105.06, 104.0094, 108.169776]
4년 누적 성장률: 8.17% (성장률의 단순 합: 8%)
누적 성장률은 8.17%로 단순 합 8%보다 조금 큽니다. 곱셈을 전개하면 \(1.03 \times 1.02 = 1 + 0.03 + 0.02 + 0.03 \times 0.02\)처럼 두 성장률의 곱만큼이 덤으로 붙기 때문입니다. 성장률이 몇 % 수준으로 작을 때는 이 교차항이 미미해서 단순 합도 그럭저럭 통합니다. 뉴스에서 대충 더해 말해도 크게 틀리지 않는 이유입니다. 문제는 변동이 클 때입니다. 다음 계산을 보겠습니다.
swings = [10.0, -10.0] # 호황 +10%, 이듬해 침체 -10%
value = 100.0
for g in swings:
value = value * (1 + g / 100)
arithmetic_mean = sum(swings) / len(swings)
geometric_mean = ((value / 100) ** (1 / 2) - 1) * 100
print(f"2년 뒤 경제 규모: {value:.1f} (출발점은 100)")
print(f"산술평균 성장률: {arithmetic_mean:.1f}%")
print(f"연평균(기하평균) 성장률: {geometric_mean:.2f}%")2년 뒤 경제 규모: 99.0 (출발점은 100)
산술평균 성장률: 0.0%
연평균(기하평균) 성장률: -0.50%
+10% 뒤에 -10%면 제자리일 것 같지만 결과는 99, 원점보다 1% 작습니다. -10%가 이미 110으로 커진 몸집에 걸려 11을 깎아 갔기 때문입니다. 여기서 “평균 성장률의 함정”이 드러납니다. 두 해의 산술평균은 (10 - 10) / 2 = 0%인데, 연평균 0%로 2년을 가면 경제는 정확히 제자리여야 합니다. 실제로는 1%가 줄었으니 산술평균은 이 경제의 궤적을 잘못 요약하고 있는 것입니다. 정직한 연평균은 “매년 똑같은 성장률로 갔을 때 같은 도착점에 이르는 비율”, 즉 누적 배율 0.99에 제곱근을 씌운 기하평균이고, 계산해 보면 연 -0.50%입니다. 성장률, 투자 수익률, 물가상승률처럼 곱으로 쌓이는 수의 평균은 기하평균이 정답이며, 산술평균은 언제나 기하평균보다 크거나 같아서 실제보다 후한 인상을 줍니다. 펀드 광고의 “연평균 수익률”을 볼 때 이 구분을 아는 사람과 모르는 사람은 같은 숫자에서 다른 정보를 읽습니다. -50%와 +50%를 오간 펀드의 산술평균은 0%지만, 투자금은 4분의 1이 사라져 있습니다.
성장률 리스트만 보면 -1%의 해가 유난히 어두워 보입니다. 그런데 경제의 실제 크기가 어떻게 움직였는지는 성장률이 아니라 방금 만든 지수 경로 path가 말해 줍니다. 첫 그래프에서 배운 네 줄로 확인해 보겠습니다.
years = list(range(2020, 2025)) # 기준 연도 2020 = 100
plt.plot(years, path, marker="o")
plt.title("가상 국가의 GDP 지수 (2020 = 100)")
plt.xlabel("연도")
plt.ylabel("GDP 지수")
plt.xticks(years) # 가로축 눈금을 연도 정수에 맞춥니다
plt.show()
그래프가 말하는 경제학 한 문장. 침체의 해에 경제는 뒷걸음쳤지만, 그 수준은 여전히 기준 연도보다 높았다. 성장률(변화의 속도)과 수준(현재의 크기)은 다른 이야기이고, 뉴스의 성장률 숫자만으로 “경제가 코로나 이전보다 작아졌다”고 단정할 수 없는 이유가 여기 있습니다. 변화율과 수준을 구분하는 눈은 거시경제 기사를 읽는 기본기인데, 마침 이번 장의 미니 프로젝트가 한국의 실제 데이터로 이 눈을 훈련하는 과제입니다.
4.12 연습문제
손풀기
1. 일주일 지출 장부 — 리스트 daily_spend = [8500, 12000, 5600, 9900, 15200, 22000, 7300]은 어느 학생의 월요일부터 일요일까지 하루 지출(원)입니다. (1) 인덱싱으로 수요일 지출을, 음수 인덱싱으로 일요일 지출을 출력하세요. (2) len과 sum으로 기록된 일수와 총지출을 출력하세요. (3) 가장 많이 쓴 날과 가장 아낀 날의 차이를 max와 min으로 계산해 f-string으로 출력하세요.
2. 주중과 주말 — 위 daily_spend를 슬라이싱으로 주중(앞 다섯 개)과 주말(뒤 두 개)로 나누고, 각 구간의 하루 평균 지출을 소수 첫째 자리까지 출력하세요. 주말 하루 평균이 주중 하루 평균의 몇 배인지도 계산해 출력하세요.
3. range 세 형태 — for와 range로 세 가지를 각각 출력하세요. (1) 0부터 9까지, (2) 2015년부터 2024년까지의 연도, (3) 2016년부터 2024년 사이의 짝수 해만. 각각 range의 첫째, 둘째, 셋째 형태를 써야 합니다.
응용
4. 바쁜 날 골라내기 — 이콘 카페의 2주일 치 판매 기록입니다. sales_2w = [52, 48, 63, 55, 88, 73, 41, 58, 50, 66, 49, 91, 70, 44]. (1) for와 if로 60잔 이상 판 날이 며칠인지 세어 출력하세요. (2) 그 날들의 판매량만 리스트 busy에 모으세요. (3) busy의 평균과 전체 14일 평균을 각각 소수 첫째 자리까지 출력해 비교하세요.
5. 24개월 적금 — 매달 말 20만 원을 납입하고 월 이율 0.2%(연 2.4%)가 붙는 적금이 있습니다. for와 range(24)로 24개월 뒤 잔액을 계산하세요. 잔액 갱신은 4.6절의 저축 예제와 같은 방식입니다. 납입 원금 480만 원과의 차이, 즉 이자 총액도 :,.0f 서식으로 출력하세요.
6. 통장의 런웨이 — 통장에 300만 원이 있습니다. 매달 월세 45만 원이 나가고 아르바이트비 32만 원이 들어와, 다달이 13만 원씩 줄어듭니다. while로 잔액이 월 순유출액보다 작아지기 전까지 몇 달을 버틸 수 있는지 세고, 그때의 잔액과 함께 출력하세요.
도전
7. 연평균 성장률의 정체 — 어느 신흥국의 3년치 성장률이 rates = [12.0, -8.0, 5.0]입니다. (1) 기준값 100에서 출발하는 누적 지수를 곱셈 누적으로 계산하세요. (2) 산술평균 성장률을 계산하세요. (3) 기하평균 연평균 성장률을 (누적지수 / 100) ** (1 / 3) - 1로 계산해 %로 출력하세요. (4) 검산: 100에 (1 + 기하평균)을 세 번 곱하면 누적 지수와 같아지는지 확인하세요. 기하평균이 산술평균보다 작게 나오는 이유를 주석 두 문장으로 설명하세요.
8. 72의 법칙 검증표 — 성장률 1%부터 9%까지(range(1, 10)) 각각에 대해, 두 배가 되는 데 걸리는 실제 연수를 while로 세어 “성장률 3%: 실제 24년, 법칙의 예측 24.0년” 같은 형태로 한 줄씩 출력하세요. for 안에 while이 들어가는 중첩 구조가 필요하고, level과 연수 변수를 for의 매 바퀴 안에서 다시 초기화해야 한다는 점에 주의하세요(4.2절 실수 노트의 반대 상황입니다). 출력을 보고 법칙이 가장 잘 맞는 구간이 어디인지 주석 한 문장으로 적으세요.
풀이는 부록 C에 있습니다.
4.13 미니 프로젝트: 생각을 코드로, 코드를 글로
과제: 성장의 궤적 — 한국 경제의 최근 10년 (예상 소요 30~60분)
아래는 2015년부터 2024년까지 한국의 실질 GDP 성장률입니다. 한국은행 국민계정 통계를 바탕으로 한 교육용 근사치이므로, 공식 수치와 소수점 아래가 다를 수 있습니다.
years = list(range(2015, 2025))
growth_rates = [2.8, 2.9, 3.2, 2.9, 2.2, -0.7, 4.3, 2.6, 1.4, 2.0]
이번 장에서 배운 도구만으로 이 10년을 분석하고, 세 문단의 짧은 글로 정리하는 것이 과제입니다.
요구사항
- 10년간의 산술평균 성장률을 계산하세요. 누적 변수 패턴으로 구한 뒤
sum으로 검산하면 좋습니다. - 2014년의 GDP 수준을 100으로 놓고,
for와append로 연말 GDP 지수의 경로 리스트를 만드세요. 10년 누적 성장률이 몇 %인지 출력하세요. - 성장률이 가장 높았던 해와 가장 낮았던 해가 각각 몇 년인지 코드로 찾아 출력하세요. 4.3절의 최고 요일 찾기와 같은 방법이 통합니다.
- 선 그래프를 1개 이상 그리세요. 연도별 성장률 추이 또는 GDP 지수 경로 중 하나면 되고, 둘 다 그리면 더 좋습니다. 제목과 축 라벨은 필수입니다.
- (선택) 도전 문제 7의 방법으로 기하평균 연평균 성장률을 구해 산술평균과 비교해 보세요.
3문단 글쓰기 틀 — 그래프 아래에 마크다운 셀로 작성합니다.
- 1문단(주장): 그래프에서 발견한 사실 하나를 명확한 문장으로 요약합니다. 예를 들어 “2020년의 침체는 깊었지만 짧았고, 이듬해의 반등이 낙폭을 넘어섰다”라거나 “최근 10년 한국 경제의 기조는 3% 안팎에서 2% 안팎으로 내려앉았다” 같은 문장입니다.
- 2문단(근거): 계산한 수치를 두 개 이상 직접 인용해 주장을 뒷받침합니다. 2020년의 -0.7%와 2021년의 4.3%의 대비, 10년 누적 성장률, 코로나 이전 5년과 이후의 평균 비교 등이 좋은 재료입니다. 그래프의 어느 부분을 보라는 안내도 한 문장 넣으세요.
- 3문단(한계와 확장): 이 분석이 놓친 것을 최소 한 가지 밝힙니다. 교육용 근사치라는 점, 총량 성장률은 1인당 소득이나 분배를 말해 주지 않는다는 점 등이 후보입니다. 마지막으로 이 데이터에서 더 알고 싶어진 질문을 한 문장으로 적습니다.
자기 점검 체크리스트
# 미니 프로젝트 작업 공간
years = list(range(2015, 2025))
growth_rates = [2.8, 2.9, 3.2, 2.9, 2.2, -0.7, 4.3, 2.6, 1.4, 2.0] # 실질 GDP 성장률(%), 교육용 근사치
# TODO 1) 10년 산술평균 성장률 (누적 변수 패턴 + sum 검산)
# TODO 2) 2014년 = 100 으로 놓은 GDP 지수 경로 리스트와 10년 누적 성장률
# TODO 3) 성장률이 가장 높았던 해와 가장 낮았던 해
# TODO 4) 선 그래프 (제목, 축 라벨 포함)
# TODO 5) (선택) 기하평균 연평균 성장률과 산술평균 비교4.14 요약과 다음 장 예고
이번 장에서 코드가 다루는 데이터의 규모가 달라졌습니다. 리스트는 여러 값을 이름 하나로 묶고, 인덱싱과 슬라이싱은 그 안에서 원하는 값과 구간을 집어내며, 끝 번호를 포함하지 않는 약속이 슬라이싱과 range를 관통한다는 것을 확인했습니다. for는 여섯 개의 부품으로 이루어진 반복의 문법이었고, 그 위에서 그릇을 준비하고 돌면서 채우고 끝난 뒤 수확하는 누적 변수 패턴 하나로 합계, 평균, 조건 카운트, 필터, 최댓값 추적까지 만들어 냈습니다. 반복 횟수를 미리 모르는 질문 앞에서는 while이 조건으로 반복을 끌고 갔고, break가 비상구를, 바퀴 수 상한이 무한 루프의 보험을 맡았습니다. 그 도구로 복리 예금을 시뮬레이션해 이 책의 첫 그래프인 성장 곡선을 그렸고, 1장에서 약속만 했던 72의 법칙이 실제 계산과 맞아떨어지는 것을 확인했습니다. 경제학 쪽의 수확은 성장률을 다루는 감각입니다. 성장률의 누적은 덧셈이 아니라 곱셈이고, 곱으로 쌓이는 수의 정직한 평균은 기하평균이며, 변화율과 수준은 서로 다른 이야기라는 것. 덤으로 0.1 + 0.2가 0.3이 아닌 이유와 그 숫자를 다듬는 법도 알게 되었습니다.
남은 불편도 분명합니다. 72의 법칙을 검증하며 같은 코드를 세 번 복사했고, 요일과 판매량처럼 짝을 이루는 데이터를 리스트 두 개에 나눠 담은 채 인덱스로 아슬아슬하게 이어 붙였습니다. 다음 장의 두 주인공이 정확히 이 두 불편을 해결합니다. 함수는 코드 덩어리에 이름을 붙여 몇 번이고 다시 쓰게 해 주고, 딕셔너리는 “아메리카노는 2,500원”처럼 이름과 값을 처음부터 한 몸으로 묶어 줍니다. 그 도구 위에서 수요의 가격탄력성과 장바구니 물가지수를 계산하게 됩니다.