소프트웨어 공학 · 심화
클린 코드 노하우 - 읽히는 코드로 고치는 순서
함수 나누기 - 한 가지 일과 추상화 수준
한 함수 한 가지 일, 같은 추상화 수준 유지, 매개변수 줄이기, 부수 효과 드러내기, 명령과 조회 분리, 완성 코드는 긴 정산 메서드를 작은 메서드로 나누고 결과가 같은지 비교한다
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 변수와 메서드의 이름에 의도, 단위, 범위를 담았다. 이름을 고쳐도 읽기 어려운 코드가 남는다. 정산 메서드 하나에 할인 계산, 명세 작성, 합계 계산, 정산 기록이 섞여 있다면 메서드 이름만으로 내부의 흐름을 설명하기 어렵다. 이번에는 이름을 붙일 수 있는 작업 단위를 찾아 긴 메서드를 나눈다.
목표는 줄 수를 줄이는 데 있지 않다. 정산의 순서를 확인할 때는 정산의 순서만, 할인 계산을 확인할 때는 할인 계산만 읽을 수 있게 만드는 데 있다. 다만 나누는 동안 계산 결과나 기록 내용이 달라지면 안 된다. 고치기 전 코드를 남겨 두고 같은 입력으로 실행하여 명세, 합계, 기록을 비교한다.
- 함수가 맡은 한 가지 일을 호출하는 쪽의 관점에서 설명한다.
- 업무의 흐름과 세부 계산을 서로 다른 추상화 수준으로 나눈다.
- 매개변수를 줄이되 숨은 입력과 숨은 상태를 늘리지 않는다.
- 계산과 상태 변경을 구별하고 부수 효과를 이름과 호출 위치에 드러낸다.
- 고치기 전후의 반환값과 변경된 상태를 실행으로 비교한다.
문제 상황
학원 수강 관리 프로그램에는 월말 수강료 정산 기능이 있다. 담당자가 수강 내역을 넘기면 프로그램은 수강생별 납부액을 계산하고, 화면에 보여 줄 명세를 만들고, 정산 합계를 기록한다. 여기서 기록은 예제를 단순하게 유지하기 위해 메모리의 문자열 목록에 추가한다.
오래된 정산 메서드는 이 일을 반복문 하나와 그 뒤의 몇 줄로 처리한다. 처음 작성할 때는 자연스러웠다. 수강 내역을 순회하므로 그 안에서 할인액도 계산하고 명세도 만들었다. 합계를 얻었으니 바로 기록도 남겼다. 하지만 명세 형식만 확인하려는 사람도 계산식과 기록 코드를 함께 읽어야 한다.
최근에는 정산 전에 예상 납부액을 확인하는 화면이 필요해졌다. 기존 정산 메서드를 호출하면 합계를 조회하는 것만으로 정산 기록이 생긴다. 화면이 새로 고침될 때마다 기록이 늘어나는 구조다. 계산 결과를 돌려주는 메서드가 기록까지 바꾸기 때문에 호출하는 쪽이 내부 구현을 알아야 한다.
이번 예제의 규칙은 작게 고정한다. 할인은 수강료에만 적용하고 교재비에는 적용하지 않는다. 할인액은 원 단위 정수 나눗셈으로 계산하므로 소수 부분을 버린다. 수강 내역이 비어 있어도 정산을 실행하면 합계 0원이라는 기록을 한 건 남긴다. 이 규칙을 새로 설계하지 않고 그대로 옮기는 것이 이번 작업의 범위다.
입력은 수강료와 교재비가 음수가 아니고 할인율이 0부터 100 사이인 자료라고 가정한다. 합계도 int 범위 안에 있다. 입력 검증과 숫자 표현 방식을 바꾸는 일은 메서드 추출과 구분한다. 여러 변화를 한꺼번에 넣으면 결과가 달라졌을 때 원인을 찾기 어려워진다.
한 가지 일은 호출하는 쪽에서 정한다
“한 함수는 한 가지 일을 한다”에서 한 가지는 문장의 개수나 연산자의 개수가 아니다. 납부액을 계산하려면 할인액을 구하고 교재비를 더해야 한다. 연산이 둘 이상이어도 호출하는 쪽에서는 납부액 계산이라는 한 작업이다. 반대로 계산한 금액을 목록에 기록하는 일은 결과를 구하는 일과 별도로 설명할 수 있다.
함수의 책임을 확인할 때는 먼저 짧은 문장으로 역할을 써 본다. “수강 내역의 납부액을 계산한다”는 하나의 결과를 설명한다. “납부액을 계산하고 명세를 만들고 기록을 남긴다”는 여러 작업을 나열한다. 이때 접속사가 있다는 사실만으로 나누지는 않는다. 각 작업을 따로 읽거나 호출해야 할 이유가 있는지 살핀다.
예제에서는 예상 금액 조회라는 실제 요구가 계산과 기록을 나누는 근거다. 명세 작성도 금액 계산과 수정 이유가 다르다. 할인 규칙이 바뀌면 계산식을 살피고, 표시 형식이 바뀌면 명세를 살핀다. 이 경계를 메서드로 표현하면 변경할 위치가 좁아진다.
다만 정산 메서드가 하위 작업을 호출한다는 이유로 여러 책임을 가졌다고 단정할 수는 없다. 상위 메서드는 정산 절차를 조율하는 한 가지 일을 맡을 수 있다. 세부 작업의 내용을 직접 펼쳐 놓지 않고 순서와 연결만 보여 주면 된다.
| 작업 | 읽는 목적 | 추출할 메서드 |
|---|---|---|
| 할인과 교재비를 반영한다 | 납부액 규칙 확인 | calculatePayableWon |
| 수강 내역을 한 줄로 표현한다 | 명세 형식 확인 | formatStatement |
| 전체 명세와 합계를 구한다 | 집계 흐름 확인 | calculateSettlement |
| 계산 결과를 기록한다 | 상태 변경 확인 | appendSettlementRecord |
메서드를 추출한 뒤에도 이름으로 본문을 설명하기 어렵다면 경계가 맞지 않을 수 있다. processData처럼 넓은 이름을 붙여 어려움을 감추기보다, 실제로 어떤 결과를 만드는지 다시 확인한다. 반대로 이름이 본문을 거의 그대로 반복하고 별도 규칙도 없다면 추출의 이득이 작은지 검토한다.
같은 추상화 수준에서 읽히게 한다
추상화 수준(abstraction level)은 세부 내용을 얼마나 접어서 표현했는지를 뜻한다. “정산 결과를 계산한다”는 업무 단위의 표현이다. “수강료에 할인율을 곱한 뒤 100으로 나눈다”는 계산 절차의 표현이다. 둘 다 필요하지만 같은 메서드에 섞이면 독자는 업무 흐름과 산술 규칙 사이를 계속 오간다.
상위 메서드에서는 계산, 기록, 반환이라는 흐름을 보여 준다. 집계 메서드에서는 각 수강 내역의 납부액을 구하고 명세를 모으고 합계를 더한다. 납부액 메서드에서는 실제 산술식을 보여 준다. 위에서 아래로 내려갈수록 질문이 구체적으로 바뀌는 구조다.
추상화 수준을 맞춘다는 말은 모든 줄을 메서드 호출로 바꾸라는 뜻이 아니다. 집계 메서드의 반복문, 명세 목록 추가, 합계 누적은 집계 과정을 직접 표현한다. 여기에 addAmount라는 메서드를 만들어 단순한 덧셈을 숨겨도 읽는 이득은 작다. 분리하려는 규칙이 있는지, 이름이 독자에게 유용한 정보를 주는지가 기준이다.
작은 메서드가 늘어나면 파일 안에서 이동하는 횟수도 늘어난다. 상위 메서드 이름만으로 흐름을 이해할 수 없다면 분리했어도 읽기 어려울 수 있다. 예제에서는 공개된 흐름을 앞에 두고, 그 흐름이 사용하는 집계와 계산 메서드를 뒤에 둔다. 독자는 정산을 실행하는 입구에서 시작하여 필요한 세부로 내려갈 수 있다.
다음 장에서 다룰 조건문 구조는 여기서 바꾸지 않는다. 분기와 반복의 모양을 그대로 둔 채 작업에 이름을 붙여도 읽기가 나아질 수 있다. 이번에는 추출 전의 계산 순서와 문자열 조립 순서를 유지하여 변화의 범위를 좁힌다.
매개변수와 부수 효과를 함께 살핀다
함수를 작게 나누면 매개변수가 늘어나기 쉽다. 수강생 이름, 강좌 이름, 수강료, 할인율, 교재비를 각각 넘기면 같은 수강 내역의 조각들이 호출부에 길게 나열된다. 이 값들이 이미 Enrollment 하나에 모여 있다면 그 수강 내역을 전달하는 편이 자연스럽다.
그렇다고 매개변수 개수만 줄이려고 프로그램 전체를 담은 객체를 넘기지는 않는다. 납부액 계산에 전체 수강생 목록이나 정산 기록 목록이 필요하지는 않다. 매개변수는 적으면서도 필요한 입력을 드러내야 한다. 이번 코드에서 납부액 계산은 Enrollment 하나를 받고, 명세 작성은 Enrollment와 계산된 납부액을 받는다.
명세 작성에 납부액을 따로 주는 이유도 있다. 집계 과정에서 이미 계산한 값을 그대로 사용하기 위해서다. 명세 메서드 안에서 다시 계산하면 합계에 쓰인 값과 명세에 쓰인 값의 연결이 눈에 덜 드러난다. 다만 이 메서드는 내부에서만 호출하며, 호출하는 곳에서 바로 계산한 값을 전달한다.
반환값 없이 여러 결과를 얻으려고 빈 목록을 매개변수로 넘기는 방법도 피한다. calculateSettlement가 호출자가 준 명세 목록을 채우면 계산 결과와 상태 변경이 섞인다. 명세와 합계를 Settlement로 묶어 반환하면 호출부에서 결과를 한 번에 받을 수 있다. 여기서 자료를 묶는 목적은 함수의 입출력을 읽기 쉽게 표현하는 데 있다.
부수 효과(side effect)는 함수 밖에서 관찰할 수 있는 상태 변경이다. 예제의 기록 목록에 문자열을 추가하는 일이 여기에 해당한다. calculateSettlement 내부에서 새 목록을 만들고 채우는 일은 호출자가 기존에 갖고 있던 상태를 바꾸지 않는다. 이 차이를 구별해야 모든 목록 조작을 같은 문제로 취급하지 않게 된다.
명령과 조회 분리(command-query separation)는 결과를 묻는 작업과 상태를 바꾸는 작업을 구별하는 기준이다. 계산 결과만 필요한 화면은 calculateSettlement를 호출한다. 기록을 남기려면 appendSettlementRecord를 호출한다. 이름과 인자를 읽으면 어느 호출이 기존 상태를 바꾸는지 드러난다.
완성 코드의 settleAndRecord는 두 작업을 조율하며 결과도 반환한다. 기존 정산 기능의 반환값과 기록 동작을 보존하기 위한 진입점이다. 엄격하게 명령과 조회를 나눈 인터페이스는 아니지만, 이름에 기록 동작을 드러내고 내부의 계산을 독립적으로 호출할 수 있게 한다. 기존 기능을 유지하면서 경계를 만드는 선택이다.
기록 목록을 전역 변수로 옮기면 매개변수 하나가 사라진다. 대신 호출부에 보이지 않는 입력과 변경 대상이 생긴다. 이번 코드에서는 목록을 명시적으로 전달한다. 매개변수를 줄이는 목표가 상태 변경을 숨기는 결과로 이어지지 않게 한다.
완성 코드
Main.java 한 파일로 실행한다. LegacySettlement는 비교 기준이 되는 긴 메서드다. SettlementService는 같은 작업을 작은 메서드로 나눈 구현이다. 두 구현은 같은 수강 내역을 받고 서로 다른 기록 목록을 사용한다. 코드의 구간 표시는 뒤의 해설과 연결되며 Java의 실제 줄 번호는 아니다.
Main.java
import java.util.ArrayList;
import java.util.List;
public class Main {
// [01] 입력과 결과
record Enrollment(
String studentName,
String courseName,
int tuitionWon,
int discountPercent,
int materialFeeWon) {
}
record Settlement(List<String> statements, int totalWon) {
}
static class LegacySettlement {
// [02] 고치기 전: 계산, 명세 작성, 기록이 한곳에 있다.
Settlement settleAndRecord(
List<Enrollment> enrollments,
List<String> records) {
List<String> statements = new ArrayList<>();
int totalWon = 0;
for (Enrollment enrollment : enrollments) {
int discountWon = enrollment.tuitionWon()
* enrollment.discountPercent() / 100;
int payableWon = enrollment.tuitionWon()
- discountWon + enrollment.materialFeeWon();
String statement = enrollment.studentName() + " / "
+ enrollment.courseName() + " / "
+ payableWon + "원";
statements.add(statement);
totalWon += payableWon;
}
records.add("정산 합계: " + totalWon + "원");
return new Settlement(statements, totalWon);
}
}
static class SettlementService {
// [03] 정산 실행의 순서를 보여 준다.
Settlement settleAndRecord(
List<Enrollment> enrollments,
List<String> records) {
Settlement settlement = calculateSettlement(enrollments);
appendSettlementRecord(settlement, records);
return settlement;
}
// [04] 기존 목록을 바꾸지 않고 결과를 계산한다.
Settlement calculateSettlement(List<Enrollment> enrollments) {
List<String> statements = new ArrayList<>();
int totalWon = 0;
for (Enrollment enrollment : enrollments) {
int payableWon = calculatePayableWon(enrollment);
statements.add(formatStatement(enrollment, payableWon));
totalWon += payableWon;
}
return new Settlement(statements, totalWon);
}
// [05] 한 수강 내역의 납부액 규칙이다.
private int calculatePayableWon(Enrollment enrollment) {
int discountWon = enrollment.tuitionWon()
* enrollment.discountPercent() / 100;
return enrollment.tuitionWon()
- discountWon + enrollment.materialFeeWon();
}
// [06] 계산된 금액을 명세 한 줄로 표현한다.
private String formatStatement(
Enrollment enrollment, int payableWon) {
return enrollment.studentName() + " / "
+ enrollment.courseName() + " / "
+ payableWon + "원";
}
// [07] 전달받은 기록 목록을 변경한다.
void appendSettlementRecord(
Settlement settlement, List<String> records) {
records.add("정산 합계: " + settlement.totalWon() + "원");
}
}
public static void main(String[] args) {
// [08] 시간이나 외부 상태에 의존하지 않는 고정 입력이다.
List<Enrollment> enrollments = List.of(
new Enrollment("가람", "자바 기초", 100000, 10, 5000),
new Enrollment("나래", "웹 기초", 80000, 0, 3000),
new Enrollment("다온", "자바 실습", 99990, 15, 0));
List<Enrollment> original = new ArrayList<>(enrollments);
List<String> legacyRecords = new ArrayList<>(
List.of("이전 정산 기록"));
List<String> revisedRecords = new ArrayList<>(
List.of("이전 정산 기록"));
LegacySettlement legacy = new LegacySettlement();
SettlementService service = new SettlementService();
// [09] 같은 입력으로 두 구현을 실행한다.
Settlement before = legacy.settleAndRecord(
enrollments, legacyRecords);
Settlement after = service.settleAndRecord(
enrollments, revisedRecords);
for (String statement : after.statements()) {
System.out.println(statement);
}
System.out.println("합계: " + after.totalWon() + "원");
check(before.statements().equals(after.statements()),
"전후 명세 일치");
check(before.totalWon() == after.totalWon(),
"전후 합계 일치");
check(legacyRecords.equals(revisedRecords),
"전후 기록 일치");
// [10] 두 구현이 같은 실수를 해도 잡도록 기대값을 확인한다.
List<String> expectedStatements = List.of(
"가람 / 자바 기초 / 95000원",
"나래 / 웹 기초 / 83000원",
"다온 / 자바 실습 / 84992원");
check(after.statements().equals(expectedStatements)
&& after.totalWon() == 262992,
"고정 기대값 일치");
check(revisedRecords.equals(List.of(
"이전 정산 기록", "정산 합계: 262992원")),
"기존 기록 보존과 한 건 추가");
check(enrollments.equals(original), "입력 보존");
// [11] 계산만 호출하면 기록이 늘지 않는다.
List<String> recordsBeforeQuery =
new ArrayList<>(revisedRecords);
Settlement preview = service.calculateSettlement(enrollments);
check(preview.equals(after)
&& revisedRecords.equals(recordsBeforeQuery),
"조회 결과 일치와 기록 유지");
// [12] 빈 입력의 기존 동작도 보존한다.
List<String> emptyLegacyRecords = new ArrayList<>();
List<String> emptyRevisedRecords = new ArrayList<>();
Settlement emptyBefore = legacy.settleAndRecord(
List.of(), emptyLegacyRecords);
Settlement emptyAfter = service.settleAndRecord(
List.of(), emptyRevisedRecords);
check(emptyBefore.equals(emptyAfter)
&& emptyLegacyRecords.equals(emptyRevisedRecords),
"빈 입력 전후 일치");
check(emptyAfter.statements().isEmpty()
&& emptyAfter.totalWon() == 0
&& emptyRevisedRecords.equals(
List.of("정산 합계: 0원")),
"빈 입력 기대값 일치");
}
// [13] 실패하면 표시한 뒤 실행을 실패로 끝낸다.
private static void check(boolean condition, String name) {
if (condition) {
System.out.println("PASS: " + name);
} else {
System.out.println("FAIL: " + name);
throw new AssertionError(name);
}
}
}
줄별 해설
[01] Enrollment는 수강 내역 하나에 속한 입력을 모은다. Settlement는 명세 목록과 합계를 함께 반환한다. JDK 17의 record를 사용하므로 각 구성 요소를 읽는 접근자와 값 비교를 위한 equals가 만들어진다. 다만 record 안의 목록까지 변경할 수 없게 되는 것은 아니다. 이 프로그램은 반환받은 명세 목록을 수정하지 않는다.
[02] 고치기 전 메서드는 반복문 안에서 산술 계산과 문자열 조립을 직접 수행한다. 반복문 뒤에서는 전달받은 기록 목록을 바꾼다. 이 구현을 비교 기준으로 남겨 두면 메서드 추출 과정에서 할인 적용 대상이나 명세 순서가 달라졌는지 확인할 수 있다.
[03] 고친 진입점은 계산하고, 기록하고, 결과를 반환한다. 정산을 실행할 때 어떤 순서로 상태가 바뀌는지 세 문장에 드러난다. 금액을 계산하는 산술식은 이 수준에 나타나지 않는다. 이름에도 AndRecord를 남겨 호출자가 기록 동작을 예상하게 한다.
[04] 집계 메서드는 기존 입력 목록을 읽고 자신이 만든 명세 목록을 채운다. 납부액을 한 번 계산하여 명세와 합계 양쪽에 사용한다. 합계는 반복문이 진행될 때마다 누적되므로 입력 순서와 명세 순서가 그대로 이어진다. 기록 목록은 매개변수로 받지 않는다.
[05] 할인액 계산식을 고치기 전과 같은 순서로 옮겼다. 다온의 할인액은 99990 곱하기 15를 100으로 나눈 14998원이다. 소수 부분이 버려지므로 납부액은 84992원이다. 수식을 짧게 만들겠다고 연산 순서를 바꾸면 정수 나눗셈의 위치가 달라질 수 있다.
[06] 명세 메서드는 이미 계산한 금액을 문자열로 표현한다. 금액을 다시 계산하거나 기록 목록을 수정하지 않는다. 이 메서드를 private으로 둔 것은 클래스 바깥에서 임의의 금액을 넣어 명세를 만들 필요가 없기 때문이다. 매개변수의 관계를 확인할 호출 위치도 좁아진다.
[07] append라는 이름과 records 매개변수가 변경 대상을 보여 준다. 이 메서드는 반환값 없이 기록을 추가한다. 기록을 만드는 문자열이 아직 단순하므로 별도 메서드를 더 추출하지 않았다. 세부 형식에 독립적인 규칙이 생기면 그때 경계를 다시 살핀다.
[08]과 [09] 두 구현은 같은 수강 내역을 사용하지만 기록 목록은 각각 준비한다. 같은 기록 목록을 공유하면 먼저 실행한 구현의 부수 효과가 다음 실행의 시작 상태가 된다. 비교 대상은 입력뿐 아니라 실행 전 상태도 같아야 한다. “이전 정산 기록”을 넣어 기존 항목이 유지되는지도 살핀다.
[10] 전후 비교만으로 모든 정확성을 보장할 수는 없다. 두 구현에 같은 잘못이 있으면 비교가 통과한다. 그래서 직접 계산한 명세와 합계를 기대값으로 확인한다. 기록도 합계 문자열 한 건이 뒤에 붙었는지 확인한다. 이번 입력에 대한 구체적인 동작을 서로 다른 관점에서 검사하는 셈이다.
[11] 조회 전 기록 목록을 복사하고 계산만 호출한 뒤 비교한다. 결과는 정산 실행 때와 같고 기록은 그대로여야 한다. 여기서는 계산 메서드가 기록 목록을 받지 않는 구조도 의도를 뒷받침한다. 실행 검사는 관찰된 결과를 확인하고, 함수 경계는 변경 가능성을 줄인다.
[12]와 [13] 빈 입력에서는 명세가 비고 합계가 0이며 기록은 한 건 생긴다. 자주 빠뜨리는 경계 동작도 전후 비교와 기대값 검사를 함께 둔다. check는 성공과 실패를 출력하고 실패 시 AssertionError로 끝낸다. Java의 assert 활성화 옵션 없이도 검사가 실행된다.
실행 결과
Main.java를 저장한 디렉터리에서 다음 명령을 실행한다. 컴파일 경고를 확인하기 위해 -Xlint:all을 추가했다. 외부 라이브러리와 패키지 선언은 필요하지 않다.
javac -Xlint:all -d out *.java
java -cp out Main
컴파일 단계에는 경고나 출력이 없고, 프로그램의 예상 출력은 다음과 같다. 명세를 한 번 표시한 뒤 전후 비교 결과를 출력한다. 시각, 난수, 네트워크를 사용하지 않으므로 입력을 바꾸지 않으면 출력도 같다.
가람 / 자바 기초 / 95000원
나래 / 웹 기초 / 83000원
다온 / 자바 실습 / 84992원
합계: 262992원
PASS: 전후 명세 일치
PASS: 전후 합계 일치
PASS: 전후 기록 일치
PASS: 고정 기대값 일치
PASS: 기존 기록 보존과 한 건 추가
PASS: 입력 보존
PASS: 조회 결과 일치와 기록 유지
PASS: 빈 입력 전후 일치
PASS: 빈 입력 기대값 일치
이 검사가 모든 가능한 입력의 동등성을 증명하지는 않는다. 여기서는 할인 없음, 정수 나눗셈의 소수 부분 버림, 기존 기록 유지, 빈 입력을 확인한다. 실제 코드에 다른 규칙이 있다면 그 규칙을 대표하는 입력도 추가해야 한다. 특히 반환값이 같아도 기록 횟수나 순서가 바뀔 수 있으므로 상태 변경을 비교에서 빼지 않는다.
실무에서 자주 틀리는 것
매개변수를 줄이려고 변경 대상을 숨긴다
다음 코드는 매개변수가 하나지만 records라는 필드에 의존한다. 호출부만 보고는 기록이 추가된다는 사실을 알기 어렵다. 아래 조각은 각각의 설계에서 메서드 내부를 비교한 것이다.
// 틀린 코드: records는 객체의 필드다.
Settlement calculateSettlement(List<Enrollment> enrollments) {
Settlement result = calculateRows(enrollments);
records.add("정산 합계: " + result.totalWon() + "원");
return result;
}
// 고친 코드: 기록 명령과 변경 대상을 드러낸다.
void appendSettlementRecord(
Settlement result, List<String> records) {
records.add("정산 합계: " + result.totalWon() + "원");
}
매개변수 개수는 읽기 비용을 살피는 신호다. 적다는 사실 자체가 좋은 설계를 보장하지 않는다. 변경 대상이 어디서 왔는지와 어떤 호출이 그 대상을 바꾸는지를 함께 확인한다.
값을 채워 넣는 매개변수를 계산 결과처럼 쓴다
호출자가 빈 목록을 만들어 전달해야 한다면 호출 순서와 초기 상태에 대한 약속이 늘어난다. 재사용한 목록에 이전 명세가 남아 있을 수도 있다. 결과를 직접 반환하면 이런 준비 작업을 줄일 수 있다.
// 틀린 코드
void calculateSettlement(
List<Enrollment> enrollments,
List<String> statements,
int[] totalWon) {
for (Enrollment enrollment : enrollments) {
int amount = calculatePayableWon(enrollment);
statements.add(formatStatement(enrollment, amount));
totalWon[0] += amount;
}
}
// 고친 코드
Settlement calculateSettlement(List<Enrollment> enrollments) {
List<String> statements = new ArrayList<>();
int totalWon = 0;
for (Enrollment enrollment : enrollments) {
int amount = calculatePayableWon(enrollment);
statements.add(formatStatement(enrollment, amount));
totalWon += amount;
}
return new Settlement(statements, totalWon);
}
고친 코드는 결과 두 개를 Settlement로 묶는다. 호출자가 결과를 담을 배열과 목록을 미리 만들 필요가 없고, 호출 전에 합계를 0으로 초기화해야 한다는 약속도 사라진다.
상위 흐름에 세부 계산을 남겨 둔다
메서드 몇 개를 추출했어도 상위 흐름에 산술식이 남아 있으면 읽는 수준이 계속 바뀐다. 다음은 집계 반복문 안의 일부다.
// 틀린 코드
int payableWon = enrollment.tuitionWon()
- enrollment.tuitionWon()
* enrollment.discountPercent() / 100
+ enrollment.materialFeeWon();
statements.add(formatStatement(enrollment, payableWon));
// 고친 코드
int payableWon = calculatePayableWon(enrollment);
statements.add(formatStatement(enrollment, payableWon));
고친 호출부는 납부액을 구해 명세를 만든다는 흐름을 보여 준다. 할인 계산을 확인할 때만 calculatePayableWon으로 내려간다. 추출한 메서드 이름에는 반환값의 단위도 남겨 둔다.
추출하면서 계산식까지 바꾼다
수학적으로 비슷해 보이는 식이라도 정수 연산에서는 결과가 달라질 수 있다. 다온의 수강료를 예로 들면 아래 첫 식은 84991원을 만들고 기존 식은 84992원을 만든다.
// 틀린 코드: 메서드를 나누면서 버림 위치도 바꿨다.
return enrollment.tuitionWon()
* (100 - enrollment.discountPercent()) / 100
+ enrollment.materialFeeWon();
// 고친 코드: 기존 계산 순서를 보존한다.
int discountWon = enrollment.tuitionWon()
* enrollment.discountPercent() / 100;
return enrollment.tuitionWon()
- discountWon + enrollment.materialFeeWon();
메서드 추출의 목적은 같은 동작을 더 읽기 쉽게 배치하는 것이다. 계산 규칙을 바꿔야 한다면 별도의 변경으로 다루고 기대값도 그 규칙에 맞춰 검토한다. 한 번의 변경에서 보존할 것과 바꿀 것을 구별해야 비교 결과를 해석하기 쉽다.
한눈에 보기
| 기준 | 확인할 질문 | 예제의 선택 |
|---|---|---|
| 한 가지 일 | 호출하는 쪽에서 한 작업으로 설명되는가 | 납부액 계산과 명세 표현을 분리한다 |
| 추상화 수준 | 업무 순서 사이에 산술 세부가 끼어 있는가 | 정산, 집계, 개별 계산을 나눈다 |
| 매개변수 | 필요한 입력과 변경 대상이 드러나는가 | 수강 내역을 전달하고 결과를 묶어 반환한다 |
| 부수 효과 | 어느 호출이 기존 상태를 바꾸는가 | 기록 추가를 별도 명령으로 둔다 |
| 동작 보존 | 반환값과 상태 변경이 모두 같은가 | 명세, 합계, 기록, 빈 입력을 비교한다 |
메서드를 나눈 뒤에는 호출부를 먼저 읽는다. 그 이름들만으로 흐름이 이어지는지 확인하고, 세부 규칙이 궁금할 때 해당 메서드로 내려간다. 이름을 이해하려고 매번 본문 전체를 열어야 한다면 이름이나 경계를 다시 살필 필요가 있다.
추출할 수 있는 모든 표현식을 추출하지는 않는다. 집계에 필요한 반복과 누적처럼 한곳에 있는 편이 이해하기 쉬운 부분도 있다. 한 번 읽을 때 유지해야 할 정보가 줄었는지, 바뀌는 규칙을 좁은 범위에서 확인할 수 있는지로 판단한다.
연습 문제
- 완성 코드의 결과 명세를 슬래시 구분 형식에서 “가람: 자바 기초, 95000원” 형식으로 바꾼다고 하자. 고친 구현에서 수정할 메서드와 검사를 찾는다. 전후 비교를 계속 유지하려면 비교 기준을 어떻게 다뤄야 하는지도 설명한다.
- “예상 정산을 두 번 조회해도 기록이 늘지 않는다”를 검사하는 코드를 작성한다. 조회 결과가 서로 같은지도 함께 확인한다. 기록은 빈 목록 대신 기존 기록이 있는 목록을 사용한다.
- calculatePayableWon의 매개변수를 없애기 위해 현재 처리 중인 Enrollment를 필드에 저장하자는 제안이 나왔다. 호출 순서와 입력의 가시성 관점에서 문제를 설명한다.
- 수강료 10001원, 할인율 33, 교재비 700원인 수강 내역 한 건을 추가한다. 기대 납부액을 계산하고 고치기 전후의 명세, 합계, 기록을 비교하는 검사를 작성한다.
정답과 해설
고친 구현에서는 formatStatement를 수정한다. 금액 규칙이 같으므로 calculatePayableWon은 바꾸지 않는다. 고정 기대 명세도 새 형식으로 바꾼다. 이 작업은 출력 형식을 바꾸므로 기존 구현과 문자열이 같다는 검사는 그대로 통과할 수 없다. 먼저 원래 형식에서 추출 전후가 같은지 확인한 뒤, 형식 변경 단계에서는 비교 기준의 문자열 형식도 의도적으로 바꾸거나 새 기대값을 중심으로 검사한다. 비교가 실패했다는 이유만으로 검사를 지우지 말고, 어떤 동작을 바꾸기로 했는지 명시한다.
완성 코드의 main 안에서 다음과 같이 검사할 수 있다. 복사본은 조회 전 상태를 보존하고 두 결과 비교는 같은 입력의 반복 조회 결과를 확인한다.
List<String> records = new ArrayList<>(List.of("기존 기록")); List<String> snapshot = new ArrayList<>(records); Settlement first = service.calculateSettlement(enrollments); Settlement second = service.calculateSettlement(enrollments); check(first.equals(second) && records.equals(snapshot), "반복 조회 결과 일치와 기록 유지");계산 메서드가 이 지역 목록을 받지 않으므로 이 검사만으로 모든 숨은 상태 변경을 찾아낼 수는 없다. 구현에 기록 필드가 없는지도 함께 확인해야 한다. 완성 코드에서는 기록을 전달받는 명령과 계산 경계가 분리되어 있다.
필드를 사용하면 먼저 현재 수강 내역을 설정하고 나중에 계산해야 한다. 설정을 빠뜨리면 이전 수강 내역으로 계산할 수 있다. 호출부에서 계산에 쓰이는 입력도 보이지 않는다. Enrollment를 매개변수로 전달하면 한 호출에 필요한 입력이 그 자리에 나타난다. 매개변수 하나를 없애는 이득보다 호출 순서에 대한 약속이 늘어나는 비용이 크다.
할인액은 10001 곱하기 33을 100으로 나눈 3300원이다. 납부액은 10001에서 3300을 빼고 700을 더한 7401원이다. main에 다음 코드를 추가할 수 있다.
List<Enrollment> roundingInput = List.of( new Enrollment("라온", "자료 실습", 10001, 33, 700)); List<String> oldRecords = new ArrayList<>(); List<String> newRecords = new ArrayList<>(); Settlement oldResult = legacy.settleAndRecord( roundingInput, oldRecords); Settlement newResult = service.settleAndRecord( roundingInput, newRecords); check(oldResult.equals(newResult) && oldRecords.equals(newRecords), "추가 입력 전후 일치"); check(newResult.totalWon() == 7401 && newResult.statements().equals( List.of("라온 / 자료 실습 / 7401원")) && newRecords.equals( List.of("정산 합계: 7401원")), "추가 입력 기대값 일치");이 입력은 할인액의 소수 부분을 버리는 위치와 교재비가 할인 대상에서 빠진다는 규칙을 함께 확인한다. 전후 비교에 기대값 검사를 더하면 코드 배치가 바뀌어도 유지해야 할 계산 규칙이 분명해진다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.