소프트웨어 공학 · 심화
클린 코드 노하우 - 읽히는 코드로 고치는 순서
읽히는 코드란 무엇인가 - 측정할 수 있는 기준
읽는 시간이 쓰는 시간보다 길다는 관점, 이름·크기·중첩·중복이라는 관찰 가능한 기준, 규칙보다 의도, 완성 코드는 소스 문자열에서 메서드 길이·중첩 깊이·긴 매개변수 목록을 세어 표로 출력한다
개발자KR · 원고 갱신
이 장에서 배우는 것
처음 코드를 작성할 때는 실행 결과가 나오도록 만드는 데 집중한다. 그러나 프로그램을 맡아 관리하기 시작하면 이미 있는 코드를 읽는 일이 자주 생긴다. 수강료가 예상과 다르게 계산된 이유를 찾고, 조건 하나를 추가할 위치를 고르고, 동료가 고친 부분을 검토한다. 이때 필요한 것은 문법을 알아보는 능력만이 아니다. 코드가 어떤 판단을 하고 어떤 결과를 만드는지 짧은 경로로 이해할 수 있어야 한다.
이 책에서는 유지보수에서 읽는 시간이 쓰는 시간보다 길다는 관점을 출발점으로 삼는다. 이는 모든 작업의 시간을 재어 얻은 비율을 말하는 것이 아니다. 코드를 한 번 작성한 뒤 여러 차례 다시 읽는 상황을 설계의 기준으로 삼겠다는 뜻이다. 작성할 때 몇 글자를 덜 입력하는 이익보다 다음 사람이 조건과 결과를 확인하기 쉬운 이익을 먼저 살펴본다.
- 코드를 읽는 비용을 실제 작업 상황과 연결해 설명한다.
- 이름·크기·중첩·중복을 관찰 가능한 질문으로 바꾼다.
- 측정값과 작성자의 의도를 함께 보고 수정 이유를 설명한다.
- Java 소스 문자열에서 메서드 길이, 블록 중첩 깊이, 긴 매개변수 목록을 측정한다.
- 고치기 전과 후의 수강료 계산 결과가 같은지 실행으로 확인한다.
문제 상황
학원 수강 관리 프로그램에서 미납 수강료를 계산하는 코드를 맡았다고 가정한다. 이 예제의 규칙은 단순하다. 수강 중인 수강생이 아직 납부하지 않았다면 회당 수강료에 수강 횟수를 곱한다. 수강 중이 아니거나 이미 납부했다면 미납액은 0이다. 금액은 예제를 위한 정수이며 실제 학원의 요금이 아니다.
화면에는 미납액 하나만 표시된다. 그러나 담당자는 그 숫자를 믿기 전에 여러 가지를 확인해야 한다. 수강료가 회당 금액인지 전체 금액인지, 횟수가 어떤 기간의 횟수인지, 납부 상태가 계산에 어떻게 반영되는지 알아야 한다. 계산식이 짧아도 주변 정보를 찾아다녀야 한다면 읽는 비용은 작지 않다.
static int fee(int unitFee, int lessonCount, boolean active, boolean paid) {
int result = 0;
if (active) {
if (!paid) {
result = unitFee * lessonCount;
}
}
return result;
}
이 코드는 Java 문법을 아는 독자라면 따라갈 수 있다. 그렇다고 읽기 쉬운 정도를 설명하기까지 쉬운 것은 아니다. “조금 복잡하다”는 말만으로는 무엇을 바꿀지 결정하기 어렵다. 안쪽 계산에 도달하기 전에 조건 블록 두 겹을 지나간다는 식으로 말하면 관찰 대상이 분명해진다.
이번에는 두 조건을 모두 만족해야 같은 계산을 한다는 의도를 한 조건식에 드러낸다. 조건식을 정리하는 여러 방법은 뒤에서 다룬다. 여기서는 작은 수정 하나를 관찰하고, 실제 결과와 측정값을 함께 확인하는 데 집중한다.
static int fee(int unitFee, int lessonCount, boolean active, boolean paid) {
int result = 0;
if (active && !paid) {
result = unitFee * lessonCount;
}
return result;
}
수정 뒤에는 조건 블록이 한 겹이다. 반면 매개변수는 여전히 네 개다. 한 번 고쳤다고 모든 관찰값이 좋아져야 하는 것은 아니다. 이번 변경이 무엇을 개선했고 무엇은 그대로 두었는지 구분할 수 있으면 된다.
읽는 비용을 관찰 가능한 질문으로 바꾼다
읽히는 코드는 독자가 필요한 판단을 적은 추측으로 할 수 있는 코드다. 여기서 판단이란 “이 값의 단위는 무엇인가”, “이 계산은 언제 실행되는가”, “이 부분을 바꾸면 어디까지 영향을 받는가” 같은 질문에 답하는 일이다. 줄 수가 적다는 사실만으로 이 질문들이 해결되지는 않는다.
첫 관찰 대상은 이름이다. 변수 이름을 읽고 값의 역할과 단위를 짐작할 수 있는지 살펴본다. 이번 예제의 unitFee와 lessonCount는 금액과 횟수를 구별하는 단서다. 다만 이름의 길이나 특정 단어의 유무만 세어 의미가 충분하다고 판정할 수는 없다. 이름은 호출하는 곳과 업무 설명을 함께 읽어 확인해야 한다.
두 번째는 크기다. 메서드를 읽으며 앞부분을 계속 되돌아보는지, 계산 결과를 찾기 위해 긴 구간을 훑는지 관찰한다. 메서드 길이는 이런 구간을 찾는 간단한 신호다. 긴 메서드는 살펴볼 후보가 되지만, 길다는 이유만으로 잘못되었다고 결론 내리지는 않는다. 서로 관련된 단순한 처리들이 나란히 있는 경우도 있기 때문이다.
세 번째는 중첩이다. 안쪽 코드에 도달하기 위해 기억해야 하는 바깥 조건이 몇 겹인지 살펴본다. 수강 중이라는 조건을 기억한 채 납부 여부를 판단하고, 다시 다른 조건을 따라 들어가면 읽는 사람이 유지해야 할 문맥이 늘어난다. 중첩 깊이는 그 부담을 살피는 출발점이다.
네 번째는 중복이다. 같은 계산이나 같은 업무 판단이 여러 곳에 반복되는지 찾는다. 글자가 같다는 사실과 업무 규칙이 같다는 사실은 구분해야 한다. 모양이 비슷해도 변경 이유가 다른 계산이 있고, 이름이 달라도 같은 규칙을 표현하는 계산이 있다. 이번 프로그램은 중복을 자동 판정하지 않는다. 중복은 주변 업무 맥락과 함께 사람이 살펴볼 대상으로 남긴다.
| 관찰 대상 | 확인할 질문 | 사용할 단서 | 주의할 점 |
|---|---|---|---|
| 이름 | 역할과 단위를 짐작할 수 있는가 | 선언과 사용 위치 | 글자 수만으로 뜻을 평가하지 않는다 |
| 크기 | 앞부분을 자주 다시 읽는가 | 메서드가 차지하는 줄 수 | 긴 코드가 모두 어려운 것은 아니다 |
| 중첩 | 바깥 조건을 몇 겹 기억하는가 | 안쪽 블록의 깊이 | 복잡한 조건식은 별도로 읽는다 |
| 중복 | 같은 업무 판단을 여러 곳에서 고치는가 | 반복되는 계산과 조건 | 문자열 일치와 의미 일치는 다르다 |
관찰 결과를 말할 때는 위치와 이유를 붙인다. “읽기 어렵다”보다 “미납액을 계산하는 줄까지 조건 블록 두 겹을 따라가야 한다”가 검토하기 쉽다. 다른 사람이 같은 코드를 보고 그 설명이 맞는지 확인할 수 있기 때문이다. 이렇게 설명할 수 있는 기준이 있어야 코드 검토가 개인 취향의 대결로 흐르는 일을 줄일 수 있다.
숫자는 판결이 아니라 확인할 위치를 알려 준다
측정값은 코드를 대신 읽어 주지 않는다. 메서드가 짧아도 한 줄에 여러 판단을 몰아넣을 수 있고, 블록이 얕아도 조건식이 길어 이해하기 어려울 수 있다. 반대로 매개변수가 여러 개여도 각각의 역할이 분명하고 호출 위치가 제한되어 있을 수 있다. 숫자를 얻은 뒤에는 그 숫자가 가리키는 코드를 다시 읽어야 한다.
이번 프로그램은 세 가지 값을 출력한다. 메서드 길이는 선언이 시작되는 줄부터 닫는 중괄호가 있는 줄까지다. 그 사이의 빈 줄과 주석 줄도 포함한다. 블록 중첩은 메서드 본문을 0으로 두고, 그 안의 중괄호 블록에 들어갈 때마다 1을 더한 최댓값이다. 긴 매개변수 목록은 매개변수가 네 개 이상인 메서드 한 개를 1건으로 센다.
네 개라는 경계는 이 장의 비교 실습을 위한 선택이다. Java의 제한이나 팀 전체에 적용할 품질 기준을 뜻하지 않는다. 경계를 바꾸면 경고 대상도 달라진다. 그래서 프로그램의 출력에는 경계값을 함께 적는다. 경계의 출처를 숨긴 채 숫자만 비교하면 서로 다른 기준으로 얻은 결과를 같은 값처럼 다루게 된다.
| 항목 | 계산 정의 | 이번 전후 값 |
|---|---|---|
| 메서드 길이 | 선언 시작 줄부터 끝 줄까지, 양끝 포함 | 9줄 → 7줄 |
| 블록 중첩 깊이 | 메서드 본문 0, 내부 블록마다 1 증가 | 2 → 1 |
| 긴 매개변수 목록 | 매개변수 4개 이상이면 1건 | 1건 → 1건 |
이 정의에서 중괄호 없는 조건문은 블록 깊이를 늘리지 않는다. switch의 가지나 조건 연산자도 각각 별도의 깊이로 세지 않는다. 따라서 출력 이름을 단순히 “복잡도”라고 부르면 측정 범위를 오해하기 쉽다. 이 장은 실제로 센 대상에 맞춰 “블록 중첩 깊이”라고 부른다. 예제의 측정 대상에는 지역 클래스와 람다식도 넣지 않는다. 다른 형태의 소스를 분석하려면 그 구조를 어떻게 셀지 먼저 정해야 한다.
목표는 숫자 세 개를 모두 낮추는 것이 아니다. 이번 수정에서는 두 상태가 함께 계산을 허용한다는 의도를 읽기 쉽게 드러내는 것이 목표다. 길이와 중첩의 감소는 그 변화를 설명하는 보조 자료다. 매개변수 목록을 줄이기 위해 의미 없는 포장 구조를 추가한다면 숫자는 좋아져도 독자가 따라가야 할 대상은 늘어날 수 있다.
규칙보다 의도를 먼저 확인한다는 말은 규칙을 무시한다는 뜻이 아니다. 규칙을 적용하는 이유를 설명할 수 있어야 한다는 뜻이다. “두 줄 줄였다”에서 멈추지 않고 “함께 만족해야 하는 조건을 같은 위치에서 읽게 했다”까지 설명해야 변경을 평가할 근거가 생긴다.
완성 코드
프로그램은 수강료 계산을 네 가지 상태 조합으로 확인하고, 별도의 Java 소스 문자열 두 개를 분석한다. 분석에는 JDK에 포함된 컴파일러의 구문 트리(abstract syntax tree)를 사용한다. 문자열에서 중괄호 문자를 직접 세지 않으므로 문자열 리터럴이나 주석에 있는 중괄호를 블록으로 오인하지 않는다.
분석 대상 소스는 실행하지 않는다. 실행 검증에 쓰는 beforeFee와 afterFee에는 분석 대상 메서드와 같은 매개변수 및 본문을 적었다. 따라서 이 예제에는 본문이 두 번 나타난다. 실행 검증은 아래 입력에서 두 구현의 반환값을 확인하고, 분석은 위 문자열의 모양을 측정한다. 둘의 역할이 다르며, 실행 검증이 소스 문자열과 실행 메서드의 일치까지 자동 보장하는 것은 아니다.
다음 내용을 Main.java로 저장한다. JRE만 있는 환경이 아니라 JDK 17이 필요하다. 소스를 구문 분석만 하므로 분석 대상의 클래스 파일을 만들지 않으며 프로그램 자체는 파일을 쓰지 않는다.
Main.java
import com.sun.source.tree.BlockTree;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.MethodTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.SourcePositions;
import com.sun.source.util.TreeScanner;
import com.sun.source.util.Trees;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
public class Main {
private static final int LONG_PARAMETER_LIMIT = 4;
private static final String BEFORE_SOURCE = """
class Before {
static int fee(int unitFee, int lessonCount, boolean active, boolean paid) {
int result = 0;
if (active) {
if (!paid) {
result = unitFee * lessonCount;
}
}
return result;
}
}
""";
private static final String AFTER_SOURCE = """
class After {
static int fee(int unitFee, int lessonCount, boolean active, boolean paid) {
int result = 0;
if (active && !paid) {
result = unitFee * lessonCount;
}
return result;
}
}
""";
public static void main(String[] args) throws IOException {
verifyFees();
List<MethodMetric> before = measure("Before", BEFORE_SOURCE);
List<MethodMetric> after = measure("After", AFTER_SOURCE);
check(before.size() == 1 && after.size() == 1,
"one method per source");
MethodMetric beforeMetric = before.get(0);
MethodMetric afterMetric = after.get(0);
check(beforeMetric.lines() == 9 && afterMetric.lines() == 7,
"method lines");
check(beforeMetric.blockDepth() == 2 && afterMetric.blockDepth() == 1,
"block depth");
check(beforeMetric.parameterCount() == 4
&& afterMetric.parameterCount() == 4,
"parameter count");
System.out.println();
System.out.println("long parameter limit: " + LONG_PARAMETER_LIMIT);
System.out.println("version | method | lines | blockDepth | longLists");
printMetric("before", beforeMetric);
printMetric("after", afterMetric);
}
private static void verifyFees() {
boolean[] states = {false, true};
for (boolean active : states) {
for (boolean paid : states) {
int expected = active && !paid ? 2000 : 0;
int before = beforeFee(1000, 2, active, paid);
int after = afterFee(1000, 2, active, paid);
check(before == expected && after == expected,
"fee active=" + active + ", paid=" + paid);
}
}
}
private static int beforeFee(
int unitFee, int lessonCount, boolean active, boolean paid) {
int result = 0;
if (active) {
if (!paid) {
result = unitFee * lessonCount;
}
}
return result;
}
private static int afterFee(
int unitFee, int lessonCount, boolean active, boolean paid) {
int result = 0;
if (active && !paid) {
result = unitFee * lessonCount;
}
return result;
}
private static List<MethodMetric> measure(
String className, String source) throws IOException {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
if (compiler == null) {
throw new IllegalStateException("JDK 17 is required");
}
DiagnosticCollector<JavaFileObject> diagnostics =
new DiagnosticCollector<>();
List<MethodMetric> metrics = new ArrayList<>();
try (StandardJavaFileManager files =
compiler.getStandardFileManager(diagnostics, null, null)) {
JavaFileObject input = new SourceText(className, source);
JavacTask task = (JavacTask) compiler.getTask(
null, files, diagnostics, List.of("-proc:none"),
null, List.of(input));
Iterable<? extends CompilationUnitTree> units = task.parse();
boolean hasError = diagnostics.getDiagnostics().stream()
.anyMatch(item -> item.getKind() == Diagnostic.Kind.ERROR);
if (hasError) {
throw new IllegalArgumentException("Invalid source: " + className);
}
SourcePositions positions = Trees.instance(task).getSourcePositions();
for (CompilationUnitTree unit : units) {
collectMetrics(unit, positions, metrics);
}
}
return List.copyOf(metrics);
}
private static void collectMetrics(
CompilationUnitTree unit,
SourcePositions positions,
List<MethodMetric> metrics) {
new TreeScanner<Void, Void>() {
@Override
public Void visitMethod(MethodTree method, Void unused) {
if (method.getBody() == null || method.getReturnType() == null) {
return null;
}
long start = positions.getStartPosition(unit, method);
long end = positions.getEndPosition(unit, method);
int lines = (int) (unit.getLineMap().getLineNumber(end - 1)
- unit.getLineMap().getLineNumber(start) + 1);
metrics.add(new MethodMetric(
method.getName().toString(),
lines,
blockDepth(method.getBody()),
method.getParameters().size()));
return null;
}
}.scan(unit, null);
}
private static int blockDepth(BlockTree body) {
int[] maximum = {0};
new TreeScanner<Void, Integer>() {
@Override
public Void visitBlock(BlockTree block, Integer parentDepth) {
int depth = parentDepth + 1;
maximum[0] = Math.max(maximum[0], depth);
return super.visitBlock(block, depth);
}
}.scan(body, -1);
return maximum[0];
}
private static void printMetric(String version, MethodMetric metric) {
int longLists = metric.parameterCount() >= LONG_PARAMETER_LIMIT ? 1 : 0;
System.out.println(version + " | " + metric.name()
+ " | " + metric.lines()
+ " | " + metric.blockDepth()
+ " | " + longLists);
}
private static void check(boolean condition, String name) {
if (!condition) {
System.out.println("FAIL " + name);
throw new IllegalStateException(name);
}
System.out.println("PASS " + name);
}
private record MethodMetric(
String name, int lines, int blockDepth, int parameterCount) {
}
private static final class SourceText extends SimpleJavaFileObject {
private final String source;
private SourceText(String className, String source) {
super(URI.create("string:///" + className + Kind.SOURCE.extension),
Kind.SOURCE);
this.source = source;
}
@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
return source;
}
}
}
줄별 해설
import 선언 부분은 소스 구조를 읽는 도구와 문자열을 컴파일러에 전달하는 도구를 가져온다. com.sun.source로 시작하는 이름을 직접 구현한 내부 기능으로 오해할 수 있지만, 여기서 쓰는 형식은 JDK의 jdk.compiler 모듈이 제공하는 공개 API다. 구현 내부 패키지에 접근하는 별도 옵션은 필요하지 않다.
LONG_PARAMETER_LIMIT 선언은 긴 목록을 판단할 경계를 한곳에 둔다. 이 값은 경고 대상의 정의이므로 화면에도 출력한다. BEFORE_SOURCE와 AFTER_SOURCE는 Java의 텍스트 블록으로 작성한 분석용 문자열이다. 들여쓰기가 정리되어도 줄의 상대적인 위치는 유지된다. 클래스 선언 줄을 제외하면 첫 문자열의 메서드는 9줄, 두 번째는 7줄이다.
main의 첫 줄은 계산 결과부터 확인한다. 그다음 두 소스를 측정하고 각각 메서드 하나를 얻었는지 검사한다. 분석 대상이 바뀌어 메서드가 추가되면 이 검사가 실패한다. 결과 목록의 첫 항목을 아무 확인 없이 대표값으로 사용하는 일을 막기 위한 것이다.
verifyFees의 두 반복문은 active와 paid의 모든 참·거짓 조합을 만든다. 금액과 횟수는 1000과 2로 고정한다. 두 구현이 서로 같은지만 보지 않고 업무 규칙에서 정한 expected와 각각 비교한다. 두 구현에 같은 실수가 들어가도 서로 같다는 이유만으로 검사를 통과하는 상황을 줄이기 위해서다.
measure의 컴파일러 확인 부분은 현재 실행 환경에 소스 분석 도구가 있는지 확인한다. SourceText는 파일 대신 문자열을 제공한다. URI는 이 소스를 식별하는 이름이며 외부 주소에 접속하지 않는다. task.parse()는 소스를 구문 분석한다. 클래스 파일을 생성하는 컴파일 작업은 호출하지 않는다.
진단 목록을 검사하는 부분은 문법 오류가 있는 소스에 대해 그럴듯한 숫자를 출력하지 않게 한다. 다만 구문 분석만 하므로 이름 해석과 형식 검사가 필요한 모든 오류를 검출하는 것은 아니다. 이 예제의 분석은 준비된 소스 두 개를 대상으로 하며, 일반적인 Java 프로그램의 유효성을 보증하는 검사기로 사용하지 않는다.
collectMetrics의 visitMethod는 본문이 있는 일반 메서드를 모은다. 본문이 없는 선언은 제외하고, 반환 형식이 없는 생성자도 제외한다. 시작 위치는 메서드 선언의 시작을 가리키고 끝 위치는 마지막 문자 바로 뒤를 가리킨다. 따라서 끝 줄을 구할 때 end에서 1을 뺀다. 줄 수는 끝 줄에서 시작 줄을 빼고 양끝을 포함하도록 1을 더한다.
blockDepth의 scan(body, -1)은 메서드 본문을 깊이 0으로 만들기 위한 시작값이다. 본문 블록에 들어오면 1을 더해 0이 된다. 그 안의 if 블록은 1, 다시 그 안의 if 블록은 2가 된다. super.visitBlock에 현재 깊이를 넘기므로 형제 블록 두 개를 차례로 방문해도 깊이가 누적되지 않는다. 배열 maximum은 방문 중 얻은 최댓값 하나를 보관한다.
printMetric은 매개변수 수가 경계 이상인지 판단해 0 또는 1을 출력한다. 표의 한 행은 메서드 하나를 뜻한다. 메서드가 여러 개라면 각 행의 longLists를 더해 긴 목록을 가진 메서드 수를 구할 수 있다. 지금은 전후에 한 메서드씩 있으므로 두 행을 직접 비교한다.
check는 조건이 맞으면 PASS를, 틀리면 FAIL을 출력한다. 실패했을 때는 예외를 던져 실행을 중단한다. 실패 메시지를 출력하고도 뒤의 비교표를 정상 결과처럼 이어서 보여 주지 않도록 한 것이다. MethodMetric은 측정 결과를 함께 운반하는 레코드이며, SourceText는 컴파일러가 읽을 문자열을 보관한다.
실행 결과
터미널에서 Main.java가 있는 디렉터리로 이동한 뒤 실행한다. 아래는 제시한 코드의 예상 출력이다. 프로그램은 시각이나 난수를 사용하지 않으며 입력 상태의 순서도 고정되어 있다.
javac -d out *.java
java -cp out Main
PASS fee active=false, paid=false
PASS fee active=false, paid=true
PASS fee active=true, paid=false
PASS fee active=true, paid=true
PASS one method per source
PASS method lines
PASS block depth
PASS parameter count
long parameter limit: 4
version | method | lines | blockDepth | longLists
before | fee | 9 | 2 | 1
after | fee | 7 | 1 | 1
앞의 네 줄은 같은 상태 입력에 대해 두 구현이 예상 미납액을 반환했음을 나타낸다. 뒤의 네 줄은 측정 대상 수와 측정값이 이 예제의 정의에 맞는지 확인한다. 계산 결과의 검증과 소스 모양의 검증이 따로 있다는 점을 구분해서 읽어야 한다.
이 실행은 모든 정수 입력에서 두 구현이 같다는 증명은 아니다. 여기서는 상태 조합 전체와 대표 금액을 확인했다. 금액 0, 횟수 0, 여러 양수 조합을 더하면 확인 범위를 넓힐 수 있다. 음수 허용 여부와 정수 범위를 넘는 계산의 처리 정책은 이 장에서 바꾸지 않는다. 읽기 쉽게 고치는 작업에 새로운 업무 규칙을 섞지 않기 위해서다.
비교표를 보면 길이는 2줄 줄고 블록 중첩은 한 겹 줄었다. 긴 매개변수 목록은 그대로다. 이 결과를 “전체 품질이 몇 점 올랐다”로 환산하지 않는다. 서로 다른 관찰값을 임의로 더하면 무엇이 좋아졌는지 설명하기 어려워진다. 대신 이번 변경의 범위가 조건을 읽는 경로를 줄이는 일이었다고 설명한다.
실무에서 자주 틀리는 것
줄을 합쳐서 길이만 줄인다
다음 코드는 여러 문장을 한 줄로 합쳐 줄 수를 낮춘다. 그러나 독자는 결과 초기화, 조건 판단, 반환을 한꺼번에 분리해 읽어야 한다. 메서드 길이를 읽는 비용의 단서로 삼았는데, 줄 수만 줄여 그 단서의 의미를 약하게 만든 셈이다.
// 틀린 예: 줄 수 감소만 목표로 삼았다.
int result = 0; if (active && !paid) { result = unitFee * lessonCount; } return result;
// 고친 예: 판단과 계산과 반환의 경계를 보이게 한다.
int result = 0;
if (active && !paid) {
result = unitFee * lessonCount;
}
return result;
바람직한 설명은 “짧아졌다”보다 구체적이어야 한다. 한눈에 구분할 작업의 경계를 유지했는지 함께 살펴본다. 줄 수 비교는 같은 표현 방식과 같은 측정 정의를 유지할 때 더 유용하다.
중괄호 문자를 모두 블록으로 센다
문자열에도 중괄호가 들어갈 수 있다. 다음 방식은 문자 하나가 나타날 때마다 깊이를 늘리므로 실제 실행 구조와 다른 값을 만든다. 닫는 중괄호 처리까지 추가하더라도 문자열과 주석을 구별하지 못한다는 문제는 남는다.
// 틀린 예: 문자열 내용도 블록 시작으로 센다.
String source = "class Sample { String mark = \"{\"; }";
long openingCount = source.chars().filter(ch -> ch == '{').count();
// 고친 예: 완성 코드의 구문 분석을 사용한다.
List<MethodMetric> metrics = measure("Before", BEFORE_SOURCE);
int depth = metrics.get(0).blockDepth();
문자를 직접 세는 간단한 실험도 쓸모가 있다. 다만 그 결과를 Java 블록 깊이라고 부르려면 문법에 따른 구분이 필요하다. 이번 프로그램은 구문 트리의 BlockTree를 방문해 측정 대상과 이름을 맞춘다.
고친 결과끼리만 같으면 충분하다고 본다
두 구현이 같은 값에 도착해도 그 값이 업무 규칙에 맞는지는 별개의 문제다. 특히 원래 구현의 동작을 잘못 이해한 채 두 코드를 함께 작성하면 같은 실수가 반복될 수 있다.
// 부족한 예: 둘 다 잘못 계산해도 통과할 수 있다.
check(beforeFee(1000, 2, true, false)
== afterFee(1000, 2, true, false), "same result");
// 고친 예: 규칙에서 정한 예상값을 각각 확인한다.
int expected = 2000;
check(beforeFee(1000, 2, true, false) == expected, "before expected");
check(afterFee(1000, 2, true, false) == expected, "after expected");
같은 입력에 같은 출력이 나오는지 확인하는 작업은 계속 필요하다. 여기에 예상값을 더하면 어느 동작을 보존하려는지도 드러난다. 완성 코드는 두 비교를 하나의 check 조건에 모아 네 가지 상태를 확인한다.
하나의 경계값으로 읽기 쉬움을 판정한다
매개변수가 네 개 미만이라는 이유로 전체 메서드를 읽기 쉽다고 판정하면 이름, 중첩, 중복을 살펴볼 기회를 놓친다. 경고가 없다는 사실은 해당 경계에 걸리지 않았다는 뜻이다.
// 틀린 예: 한 관찰값을 전체 품질 판정으로 바꾼다.
boolean readable = metric.parameterCount() < LONG_PARAMETER_LIMIT;
// 고친 예: 확인할 대상이라는 의미로 제한한다.
boolean needsParameterReview =
metric.parameterCount() >= LONG_PARAMETER_LIMIT;
두 번째 코드는 네 개 이상이면 검토할 후보라는 뜻만 담는다. 실제 수정 여부는 호출 위치와 매개변수의 역할을 읽은 뒤 결정한다. 관찰값의 의미를 좁고 정확하게 유지해야 숫자가 의도를 대신하는 일을 줄일 수 있다.
한눈에 보기
| 단계 | 해야 할 일 | 이번 예제의 근거 |
|---|---|---|
| 동작 확인 | 보존할 결과를 입력과 함께 적는다 | 수강·납부 상태 네 조합의 예상 미납액 |
| 관찰 | 막연한 인상을 위치와 질문으로 바꾼다 | 계산까지 조건 블록 두 겹을 따라간다 |
| 측정 | 계산 정의와 경계를 공개한다 | 선언부터 끝까지의 줄 수, 본문 깊이 0 |
| 수정 설명 | 숫자가 바뀐 이유를 의도와 연결한다 | 함께 만족할 조건을 같은 위치에 놓았다 |
| 범위 확인 | 측정하지 않은 영역을 구분한다 | 이름의 적절성과 의미상 중복은 직접 읽는다 |
읽히는 코드를 판단하는 첫 기준은 설명 가능성이다. 어디에서 읽는 부담이 생겼는지, 어떤 의도로 바꿨는지, 같은 동작을 어떻게 확인했는지 말할 수 있어야 한다. 다음 장에서는 이 관찰 대상 가운데 이름을 살펴본다. 값의 의도·단위·범위를 이름으로 얼마나 전달할 수 있는지 수강 관리 코드에서 확인한다.
연습 문제
- AFTER_SOURCE의 result 선언 다음에 빈 줄 하나를 추가한다. 메서드 길이, 블록 중첩 깊이, 긴 매개변수 목록은 각각 어떻게 바뀌는가. 실행 전에 예상값을 적고 관련 check의 기대값을 수정해 확인한다.
- LONG_PARAMETER_LIMIT를 5로 바꾼다. 출력표의 어느 값이 바뀌는가. 이 변경으로 코드가 더 읽기 쉬워졌다고 말할 수 있는지도 설명한다.
- verifyFees에 회당 수강료 0, 횟수 2, 수강 중, 미납 상태를 확인하는 검사를 추가한다. 두 구현의 결과를 예상값과 각각 비교한다. 원래의 네 상태 검사도 유지한다.
- 매개변수가 다섯 개이고 블록 중첩 깊이가 0인 메서드와, 매개변수가 두 개이고 블록 중첩 깊이가 3인 메서드가 있다. 어느 쪽이 더 읽기 쉬운지 숫자만으로 결정할 수 있는가. 추가로 읽어야 할 내용을 두 가지 이상 적는다.
정답과 해설
-
수정 후 메서드 길이는 8줄, 블록 중첩 깊이는 1, 긴 매개변수 목록은 1건이다. 길이 검사에서 afterMetric.lines()의 기대값을 8로 바꾼다. 이번 길이 정의는 본문 중간의 빈 줄도 포함한다. 계산 결과는 변하지 않는다. 이 실험은 길이가 표현 방식의 영향을 받는다는 점을 보여 준다. 따라서 한 줄 차이만으로 읽기 쉬움의 우열을 단정하지 않는다.
-
표의 longLists가 전후 모두 0으로 바뀌며 경계 안내는 5가 된다. 매개변수 개수 자체는 여전히 4이므로 parameter count 검사는 통과한다. 메서드 길이와 블록 중첩 깊이도 변하지 않는다. 분석 기준만 바뀌었으므로 코드의 읽기 쉬움이 개선되었다고 말할 근거는 없다.
-
verifyFees의 반복문 다음에 아래 검사를 추가할 수 있다. 예상 미납액은 0이다. 상태 조건이 계산을 허용하더라도 회당 수강료가 0이면 곱한 결과는 0이 된다.
int expected = 0; int before = beforeFee(0, 2, true, false); int after = afterFee(0, 2, true, false); check(before == expected && after == expected, "zero unit fee");기존 네 상태 검사 다음에 PASS zero unit fee가 추가된다. 나머지 측정값과 비교표는 그대로다. 이 검사는 상태 조합 외에 금액 입력의 확인 범위를 넓힌다.
-
숫자만으로 결정할 수 없다. 다섯 매개변수가 무엇을 나타내며 호출 위치에서 구별되는지 읽어야 한다. 중첩 세 겹이 어떤 업무 조건을 나타내는지도 확인해야 한다. 각 메서드의 이름, 계산의 길이, 반복되는 업무 판단, 주변 코드까지 추가로 살펴볼 수 있다. 숫자는 먼저 읽을 위치를 고르는 데 사용하고, 판단 이유는 실제 코드에서 찾는다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.