9주차 · 함수 리팩토링과 테스트
이 장을 마치면 다음을 할 수 있습니다.
- W5에서 배운 함수를 “더 잘 쓰는” 방법을 단계별로 설명하기
- 긴 스크립트를 입력/계산/판정/출력 역할 함수로 분해하기
- 함수 이름을 읽는 즉시 역할이 보이게 짓기
- 지역 변수와 전역 변수의 범위 차이를 코드로 설명하기
- 부작용이 디버깅을 어렵게 하는 이유를 이해하기
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는 나중에 점수 목록이 바뀌어도, 등급 기준이 바뀌어도 고칠 곳이 딱 한 군데입니다. 이것이 리팩토링의 목표입니다.
먼저 기억할 리팩토링 루프
Section titled “먼저 기억할 리팩토링 루프”이번 주의 핵심은 새 문법을 많이 외우는 것이 아니라, 이미 배운 함수를 안전한 순서로 쓰는 것입니다.
- 현재 동작을 관찰합니다. 지금 코드가 어떤 값을 출력하거나 반환하는지 먼저 확인합니다.
- 중요한 동작을
assert로 잠급니다. “이 함수는 반드시 이 값을 돌려줘야 한다”는 조건을 코드로 적습니다. - 작은 함수 하나만 추출합니다. 계산, 판정, 출력 중 한 역할만 먼저 분리합니다.
- 다시 실행합니다.
assert가 통과하면 다음 역할을 분리하고, 실패하면 방금 바꾼 부분부터 확인합니다.
assert는 무엇인가요?
Section titled “assert는 무엇인가요?”assert 조건은 “이 조건은 반드시 참이어야 한다”는 뜻입니다. 조건이 참이면 아무 메시지도 내지 않고 다음 줄로 넘어갑니다. 조건이 거짓이면 AssertionError가 발생하고 프로그램이 멈춥니다.
def add(a, b): return a + b
assert add(2, 3) == 5print("첫 번째 점검 완료")
assert add(2, 3) == 6, "2 + 3은 5여야 합니다"print("이 줄은 실행되지 않습니다")위 코드에서 첫 번째 assert는 통과합니다. 그래서 "첫 번째 점검 완료"가 출력됩니다. 두 번째 assert는 실패합니다. 그래서 AssertionError가 나오고 마지막 print()는 실행되지 않습니다.
리팩토링 quickstart: 어디서부터 자를까요?
Section titled “리팩토링 quickstart: 어디서부터 자를까요?”가장 쉬운 출발점은 입력 / 계산 / 판정 / 출력 4칸으로 나누는 것입니다.
| 역할 | 스스로에게 물어볼 질문 | 예 |
|---|---|---|
| 입력 | 데이터는 어디에서 오는가? | 리스트, 문자열, 사용자 입력 |
| 계산/변환 | 숫자나 값을 어떻게 바꾸는가? | 평균 계산, 문자열을 숫자로 변환 |
| 판정 | 조건에 따라 어떤 라벨을 붙이는가? | A/B/C/D 등급 판정 |
| 출력 | 사람에게 무엇을 보여주는가? | print(), 리포트 문자열 |
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 범위
Section titled “2.3 범위”- 지역 변수(local variable): 함수 안에서만 존재
- 전역 변수(global variable): 파일 전체에서 접근 가능
학습 초기에는 global 키워드 사용을 최소화하면 버그가 크게 줄어듭니다.
2.4 부작용
Section titled “2.4 부작용”함수 밖 상태를 바꾸면 부작용이 생깁니다.
- 전역 변수 변경
- 파일 쓰기
- 외부 시스템 호출
부작용이 있는 함수는 테스트가 어렵기 때문에 경계를 명확히 두는 것이 좋습니다.
2.5 assert로 조건 점검하기
Section titled “2.5 assert로 조건 점검하기”assert calculate_average([10, 20, 30]) == 20리팩토링 중에는 assert가 안전벨트입니다. 함수 동작이 바뀌지 않았음을 자동으로 확인해 줍니다.
def calculate_average(scores): return sum(scores) / len(scores)
assert calculate_average([10, 20, 30]) == 20assert calculate_average([70, 80, 90]) == 80print("평균 함수 점검 완료")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에서는 짧은 보조 함수를 부담 없이 만들고, 반환값으로 연결하는 습관을 들이는 것입니다.
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 코드의 현재 출력이 무엇인지 확인합니다.
- 그 출력이 유지되어야 한다는 조건을
assert로 적습니다. - 평균 계산 부분만 함수로 뽑고 다시 실행합니다.
- 등급 판정 부분만 함수로 뽑고 다시 실행합니다.
- 모든
assert가 통과하면 출력 코드를 정리합니다.
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)리팩토링 후에도 같은 동작인지 확인하기
Section titled “리팩토링 후에도 같은 동작인지 확인하기”assert round(avg, 2) == 80.2assert grade == "B"assert convert_to_grade(70) == "C"print("리팩토링 후 점검 완료")첫 번째 assert는 평균 계산을, 두 번째 assert는 전체 결과를, 세 번째 assert는 등급 경계값을 보호합니다. 이렇게 해 두면 함수 이름이나 내부 구조를 바꾸더라도 동작이 달라졌는지 바로 알 수 있습니다.
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 (전역) | safe_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 safe_counter == 2로 결과가 같은지 확인합니다.
실습 문제 · 직접 코딩
Section titled “실습 문제 · 직접 코딩”문제: 중복된 평균 계산 코드를 함수 1개로 추출하세요.
목표: 두 반의 평균을 같은 방식으로 계산하므로, 평균 계산을 calculate_average(scores) 함수 하나로 분리합니다.
- 입력:
section_a = [70, 80, 90],section_b = [60, 75, 95] - 작성할 함수:
calculate_average(scores)— 숫자 리스트를 받아 평균(float)을 반환합니다. - 완료 조건: 함수 안에서는
print()를 쓰지 않고, 출력은 함수 밖에서 합니다. - 필수 점검:
assert calculate_average(section_a) == 80.0 - 예상 출력:
average A: 80.0,average B: 76.7
문제: 아래 코드에서 버그를 찾고 수정하세요. 이 함수는 왜 항상 None을 반환할까요?
목표: print()와 return의 차이를 다시 확인합니다. 계산 결과를 다른 곳에서 쓰려면 함수가 값을 return해야 합니다.
- 관찰: 수정 전에는
result에None이 들어갑니다. - 수정 방향: 평균을 화면에 보여주는 일과 평균을 반환하는 일을 분리합니다.
- 필수 점검:
assert get_average([60, 70, 80]) == 70.0 - 완료 조건:
result를 이용해 다른 계산을 할 수 있어야 합니다.
문제: 아래 코드를 리팩토링하세요. 파싱/계산/판정을 3개 함수로 나누고, 출력은 마지막 실행부에서만 하세요.
목표: 한 덩어리 코드를 parse_scores, calculate_average, convert_to_grade로 분리합니다.
parse_scores(raw_list): 숫자로 바꿀 수 있는 문자열만int리스트로 반환합니다.calculate_average(scores): 숫자 리스트의 평균을 반환합니다.convert_to_grade(avg): 평균으로 A/B/C/D 등급을 반환합니다.- 예상 출력:
avg: 85.8 grade: B - 필수 점검:
assert parse_scores(raw) == [85, 92, 78, 88],assert round(calculate_average([85, 92, 78, 88]), 1) == 85.8,assert convert_to_grade(85.8) == “B”
문제: 전역 변수를 사용하는 아래 코드를 순수 함수 스타일로 바꾸세요.
목표: 함수가 전역 리스트를 직접 바꾸지 않고, 입력으로 받은 리스트를 바탕으로 새 리스트를 반환하게 만듭니다.
- 작성할 함수:
record_score_pure(current_log, name, score) - 반환값: 기존 기록에
“이름:점수”한 줄이 추가된 새 리스트 - 완료 조건: 원래 리스트를 직접 바꾸지 않고, 반환값을 변수에 다시 저장합니다.
- 필수 점검:
assert new_log == [“kim:85”, “lee:92”]
문제: 간단한 점수 문자열을 함수로 나누어 처리하고 assert로 검증하세요.
목표: “name=kim,score=85” 형식의 문자열에서 이름과 점수를 꺼냅니다. 숫자로 바꿀 수 있는 점수만 평균에 포함합니다.
parse_line(raw): 성공하면 이름과 점수를 담은 딕셔너리를 반환합니다. 실패하면None을 반환합니다.calculate_average(records):None이 아닌 레코드들의score평균을 반환합니다.classify_class(avg): 평균으로 A/B/C/D 등급을 반환합니다.- 필수 점검: 성공 파싱 1개, 실패 파싱 1개, 평균 계산 1개를
assert로 확인합니다. 등급 판정 점검은 여유가 있으면 추가합니다. - 권장 순서: 먼저
parse_line만 완성하고, 그다음 성공한 결과만 리스트에 모으세요.park의 점수는bad이므로 평균 계산에서 제외합니다.
제출 전 자기점검
Section titled “제출 전 자기점검”실습을 제출하거나 다음 문제로 넘어가기 전에 아래 항목을 확인하세요.
- 계산 함수 안에
print()가 없는가? - 함수가 필요한 값을
return하는가? - 전역 변수를 직접 바꾸지 않는가?
- 대표값과 경계값을 포함해 최소 2개 이상의
assert가 있는가? - 리팩토링 전후 출력이 같은가?
- 실패한
assert가 있다면, 마지막으로 바꾼 함수 하나만 먼저 확인했는가?