9주차 · 함수 리팩토링과 테스트
9장. 함수 리팩토링과 테스트
Section titled “9장. 함수 리팩토링과 테스트”이 장을 마치면 다음을 할 수 있습니다.
- W5에서 배운 함수를 “더 잘 쓰는” 방법을 단계별로 설명하기
- 긴 스크립트를 입력/계산/출력 역할 함수로 분해하기
- 함수 이름을 읽는 즉시 역할이 보이게 짓기
- 지역 변수와 전역 변수의 범위 차이를 코드로 설명하기
- 부작용(side effect)이 디버깅을 어렵게 하는 이유를 이해하기
assert로 리팩토링 전후 동작을 보호하기- Bad/Good 예시로 책임 혼합 코드와 분리된 코드의 차이를 비교하기
이번 주 이야기: “코드가 동작하는데, 왜 다시 쓰나요?”
Section titled “이번 주 이야기: “코드가 동작하는데, 왜 다시 쓰나요?””W5에서 함수를 처음 배웠고, W6(자료구조)·W7(문자열 파싱)에서 함수를 활용했습니다. 이제 질문이 생깁니다.
“코드가 잘 돌아가는데, 뭘 더 고쳐야 하나요?”
두 가지 상황을 비교해 보겠습니다.
# 상황 A: 동작하지만 읽기 어려운 코드scores = [70, 80, 90]total = 0for x in scores: total += xavg = total / len(scores)if avg >= 80: print("B")else: print("C")# 상황 B: 동작하고 읽기도 쉬운 코드def calculate_average(scores): return sum(scores) / len(scores)
def convert_to_grade(avg): return "B" if avg >= 80 else "C"
scores = [70, 80, 90]print(convert_to_grade(calculate_average(scores)))둘 다 같은 결과를 냅니다. 하지만 상황 B는 나중에 점수 목록이 바뀌어도, 등급 기준이 바뀌어도 고칠 곳이 딱 한 군데입니다. 이것이 리팩토링의 목표입니다.
리팩토링 quickstart: 어디서부터 자를까요?
Section titled “리팩토링 quickstart: 어디서부터 자를까요?”가장 쉬운 출발점은 입력 / 계산 / 판정 / 출력 4칸으로 나누는 것입니다.
def read_input(): pass # TODO: your code
def compute_value(data): pass # TODO: your code
def classify_result(value): pass # TODO: your code
def main(): data = read_input() value = compute_value(data) label = classify_result(value) print(label)처음 리팩토링할 때 체크할 4가지
Section titled “처음 리팩토링할 때 체크할 4가지”- 한 함수는 한 가지 책임만 맡긴다.
- 함수 이름을 보면 “무엇을 하는지” 바로 보여야 한다.
- 함수 밖 상태를 몰래 바꾸지 말고, 가능하면
return으로 돌려준다. - 리팩토링 전후 결과가 같은지
assert나 테스트로 확인한다.
1) 왜 함수가 필요한가?
Section titled “1) 왜 함수가 필요한가?”초보자 코드는 보통 “한 파일 + 긴 순차 코드”로 시작합니다. 처음에는 빠르지만, 기능이 늘어나면 문제가 생깁니다.
- 같은 계산을 여러 번 복사하게 됨
- 한 줄 수정이 다른 곳에서 버그를 만듦
- 테스트 지점이 없어 “맞는지” 확인이 어려움
함수는 “한 가지 책임”을 이름 붙여 묶는 도구입니다.
구조 변화: 큰 덩어리 코드 → 작은 단위 함수
Section titled “구조 변화: 큰 덩어리 코드 → 작은 단위 함수”flowchart TB A[giant_script.py<br/>입력+계산+판정+출력 혼합] --> B[책임별 분해] B --> C[read_input 함수<br/>입력 담당] B --> D[calc_* 함수<br/>계산 담당] B --> E[classify_* 함수<br/>판정 담당] B --> F[main 함수<br/>조합 담당]
2) 핵심 개념 5개
Section titled “2) 핵심 개념 5개”2.1 분해(Decomposition)
Section titled “2.1 분해(Decomposition)”문제를 4단계로 나누세요.
- 입력 받기
- 계산/변환
- 검증
- 출력
각 단계를 함수 1개 이상으로 만듭니다.
2.2 이름 짓기(Naming)
Section titled “2.2 이름 짓기(Naming)”- 좋은 이름:
calculate_average,parse_score_line,build_report - 나쁜 이름:
doit,x,temp2
규칙: 동사 + 대상(verb + object)
2.3 범위(Scope)
Section titled “2.3 범위(Scope)”- 지역 변수(local variable): 함수 안에서만 존재
- 전역 변수(global variable): 파일 전체에서 접근 가능
초보 단계에서는 global 사용을 최소화하면 버그가 크게 줄어듭니다.
2.4 부작용(Side Effects)
Section titled “2.4 부작용(Side Effects)”함수 밖 상태를 바꾸면 부작용(side effect)이 생깁니다.
- 전역 변수 변경
- 파일 쓰기
- 외부 시스템 호출
부작용이 있는 함수는 테스트가 어렵기 때문에 경계를 명확히 두는 것이 좋습니다.
2.5 assert로 조건 점검하기
Section titled “2.5 assert로 조건 점검하기”assert calculate_average([10, 20, 30]) == 20리팩토링 중에는 assert가 안전벨트입니다. 함수 동작이 바뀌지 않았음을 자동으로 확인해 줍니다.
C와 비교: 긴 절차 코드에서 함수 분리하는 감각
Section titled “C와 비교: 긴 절차 코드에서 함수 분리하는 감각”| 항목 | C | Python | 기억할 점 |
|---|---|---|---|
| 함수 선언 | 타입 + 이름 필요 | def로 시작 | Python은 문법이 짧아 분리가 더 쉽다 |
| 블록 구분 | { ... } | 들여쓰기 | 함수 경계가 공백으로 보인다 |
| 전역 상태 의존 | 흔함 | 가능하지만 권장하지 않음 | 초보 단계에서는 입력/반환 중심이 안전 |
| 리팩토링 확인 | 수동 출력 비교 | assert + 작은 함수 테스트 | Python이 빠른 검증에 유리 |
/* C 스타일 긴 절차 코드 */for (int i = 0; i < n; i++) { total += scores[i];}avg = total / n;def calculate_average(scores): return sum(scores) / len(scores)핵심은 Python에서는 짧은 helper 함수를 부담 없이 만들고, 반환값으로 연결하는 습관을 들이는 것입니다.
3) 출력 예측 연습: 이 코드는 무엇을 출력할까요?
Section titled “3) 출력 예측 연습: 이 코드는 무엇을 출력할까요?”리팩토링 실력을 키우려면 코드를 머릿속에서 실행하는 훈련이 필요합니다.
퀴즈 A: 지역 변수와 전역 변수
Section titled “퀴즈 A: 지역 변수와 전역 변수”x = 10
def add_five(x): x = x + 5 return x
result = add_five(x)print(x) # (1) 무엇이 출력될까요?print(result) # (2) 무엇이 출력될까요?퀴즈 B: 전역 변수 부작용
Section titled “퀴즈 B: 전역 변수 부작용”counter = 0
def increment(): global counter counter += 1
increment()increment()print(counter) # 무엇이 출력될까요?4) 버그 찾기 연습
Section titled “4) 버그 찾기 연습”아래 코드에서 버그를 찾아보세요.
버그 A: 함수가 None을 돌려준다
Section titled “버그 A: 함수가 None을 돌려준다”def calculate_average(scores): total = sum(scores) avg = total / len(scores) print(avg) # ← 여기가 문제
result = calculate_average([70, 80, 90])print(result * 2) # TypeError!문제: print(avg)만 하고 return이 없습니다. 함수가 None을 돌려줘서 곱셈에서 오류가 납니다.
수정: print(avg) → return avg
버그 B: 전역 변수 의존으로 결과가 달라진다
Section titled “버그 B: 전역 변수 의존으로 결과가 달라진다”total = 100
def add_score(score): global total total += score
add_score(20)add_score(20)print(total) # 140 — 기대값이 달라질 수 있음문제: 함수를 몇 번 호출했는지에 따라 결과가 달라집니다. total의 초기값이 무엇이었는지 추적해야 합니다.
수정: 전역 변수를 없애고 인자로 전달하세요.
def add_score(current_total, score): return current_total + score5) Bad vs Good 리팩토링 예시
Section titled “5) Bad vs Good 리팩토링 예시”Bad (책임 혼합)
Section titled “Bad (책임 혼합)”scores = [71, 83, 95, 64, 88]total = 0for x in scores: total += xavg = total / len(scores)print("avg", avg)if avg >= 90: print("A")elif avg >= 80: print("B")elif avg >= 70: print("C")else: print("D")Good (계산/판정/출력 분리)
Section titled “Good (계산/판정/출력 분리)”def calculate_average(scores): return sum(scores) / len(scores)
def convert_to_grade(score): if score >= 90: return "A" if score >= 80: return "B" if score >= 70: return "C" return "D"
scores = [71, 83, 95, 64, 88]avg = calculate_average(scores)grade = convert_to_grade(avg)print("avg", round(avg, 2)) # round(값, 소수점자리수): 소수점 2자리로 반올림print("grade", grade)6) 초보자 실수/안티패턴 표
Section titled “6) 초보자 실수/안티패턴 표”| 실수 | 왜 생기나 | 위험 | 대안 |
|---|---|---|---|
| 100줄 함수 1개 | 빨리 완성하고 싶어서 | 수정 시 연쇄 버그 | 책임별 10~20줄 함수로 분리 |
| 함수가 print만 함 | 눈으로 확인 습관 | 재사용/테스트 어려움 | 계산 함수는 return 우선 |
| 전역 변수 의존 | 편의성 착각 | 실행 순서 따라 결과 변경 | 인자 전달 방식 사용 |
이름이 tmp, data2 | 네이밍 투자 부족 | 협업 시 오해 | 동사+대상 규칙 |
| assert 없음 | ”돌아가면 됨” 사고 | 리팩토링 회귀 버그 | 핵심 규칙마다 assert 1개 이상 |
| 기능추가+리팩토링 동시 진행 | 단계 분리 미흡 | 원인 추적 실패 | 먼저 보호(assert), 그다음 구조 개선 |
예제 1 줄별 해설
Section titled “예제 1 줄별 해설”calculate_average는 입력 리스트를 평균으로 바꾸는 단일 책임 함수입니다.convert_to_grade는 점수 규칙만 다룹니다(계산/출력과 분리).summarize_scores는 작은 함수들을 조합하는 오케스트레이터 역할입니다.- 실행 파트는 함수 정의와 분리하여 “입력만 바꿔 재사용”이 가능하게 만듭니다.
round(값, 2)로 소수점 2자리까지만 출력해 가독성을 높입니다.assert3개는 평균, 등급, 경계값(70점)을 동시에 보호합니다.- 함수명을 일부러
x처럼 바꿔본 뒤 다시 복구해 가독성 차이를 체감해 보세요.
예제 2 실행 흐름 추적
Section titled “예제 2 실행 흐름 추적”아래 표는 코드를 순서대로 실행할 때 각 변수가 어떻게 바뀌는지 보여줍니다.
| 실행 순서 | 호출 | counter (전역) | pure_counter | 출력 |
|---|---|---|---|---|
| 시작 | — | 0 | — | start counter: 0 |
increase_local(counter) | 지역 복사본만 변경 | 0 (그대로) | — | local call: 1 |
| after local 출력 | — | 0 (그대로) | — | after local: 0 |
increase_global() 1번 | 전역 counter 변경 | 1 | — | global call 1: 1 |
increase_global() 2번 | 전역 counter 변경 | 2 | — | global call 2: 2 |
| final counter 출력 | — | 2 | — | final counter: 2 |
increase_pure(0) | 새 값 반환 | — | 1 | — |
increase_pure(1) | 새 값 반환 | — | 2 | — |
핵심 관찰: increase_local은 전역 counter를 바꾸지 않습니다. increase_global은 호출할 때마다 전역을 누적 변경합니다. increase_pure는 전역을 전혀 건드리지 않아 가장 안전합니다.
예제 2 줄별 해설
Section titled “예제 2 줄별 해설”- 처음 출력에서
after local이 0인지 확인해 local 변경 범위를 이해합니다. increase_global호출 뒤 값이 누적되는 이유를 “외부 상태 변경”으로 설명해봅니다.- 전역 상태 패턴은 작은 예제에서는 동작해도, 규모가 커지면 추적 비용이 커집니다.
increase_pure처럼 입력→출력만 있는 함수로 바꾸면 테스트가 쉬워집니다.- 리팩토링 후에도
assert pure_counter == 2로 동일 동작을 증명합니다.
실습 문제 · 직접 코딩
Section titled “실습 문제 · 직접 코딩”문제: 중복된 평균 계산 코드를 함수 1개로 추출하세요.
문제: 아래 코드에서 버그를 찾고 수정하세요. 이 함수는 왜 항상 None을 반환할까요?
문제: 아래 코드를 리팩토링하세요. 입력/계산/출력을 3개 함수로 나누고, assert로 검증하세요.
문제: 전역 변수를 사용하는 아래 코드를 순수 함수 스타일로 바꾸세요.
문제: W7 파싱 파이프라인을 함수로 분해하고 assert로 검증하세요.