클래스와 책임 - 응집도와 결합도
이 장에서 배우는 것
앞 장에서 오류를 호출자에게 전달하는 방법을 정리했다. 이번에는 정상적인 처리를 맡은 클래스의 경계를 살펴본다. 예외를 잘 설계했더라도 수강생 정보, 수강료 계산, 안내문 작성, 출력 처리가 한 클래스에 모여 있으면 변경할 때마다 넓은 범위를 읽어야 한다. 클래스의 책임을 나누는 목적은 파일 수를 늘리는 데 있지 않다. 어떤 요구가 바뀌었을 때 어디를 고쳐야 하는지 설명할 수 있게 만드는 데 있다.
이번 예제에서는 오래된 관리 클래스가 만들던 수강 안내문을 그대로 유지하면서 계산과 안내문 작성, 결과 전달을 분리한다. 기존 코드와 새 코드에 같은 입력을 주고 반환값과 안내문을 모두 대조한다. 코드가 짧아졌다는 인상보다 관찰할 수 있는 결과가 유지됐다는 근거를 먼저 확보한다.
- 한 클래스가 변경되는 이유를 요구 사항의 말로 설명한다.
- 함께 바뀌는 데이터와 동작을 모아 응집도를 높인다.
- 게터로 내부 상태를 꺼내 처리하는 코드와 객체에 일을 요청하는 코드를 구별한다.
- 생성자 주입으로 클래스가 사용하는 협력 대상을 드러낸다.
- 역할을 나눈 전후 코드의 반환값과 출력 내용을 실행으로 비교한다.
문제 상황
학원 수강 관리 프로그램에 EnrollmentManager라는 클래스가 있다. 처음에는 수강 안내문 하나를 만드는 용도였다. 이후 수강생별 감면액과 강좌별 수강료가 추가됐다. 담당자는 기존 메서드에 매개변수와 계산식을 덧붙였다. 지금은 관리 클래스가 금액을 계산하고 안내문을 조립한 뒤 전달 목록에 넣는다.
이 구조에서는 서로 다른 요청이 같은 메서드로 들어온다. “감면액이 수강료보다 크면 청구액을 0원으로 만든다”는 요청은 계산에 관한 것이다. “안내문에서 강좌명을 먼저 보여 준다”는 요청은 표현에 관한 것이다. “만든 안내문을 다른 전달 대상으로 보낸다”는 요청은 외부 협력에 관한 것이다. 요청의 출처와 검토 기준이 다른데도 한 메서드를 함께 수정한다.
다음은 그 메서드의 핵심이다. 완성 코드에는 비교 기준으로 사용할 기존 클래스를 별도로 남긴다. 여기서 사용하는 금액과 이름은 동작 확인을 위해 정한 가상의 예제 값이다.
int enroll(String studentName, int reduction,
String courseTitle, int tuition) {
int fee = Math.max(0, tuition - reduction);
String receipt = studentName + " | " + courseTitle
+ " | 청구액 " + fee + "원";
receipts.add(receipt);
return fee;
}
이 메서드가 길어서 문제가 생긴 것은 아니다. 짧더라도 서로 다른 변경 이유가 겹칠 수 있다. 반대로 여러 메서드를 가진 클래스도 하나의 책임에 집중할 수 있다. 줄 수와 메서드 수만으로 클래스의 적절한 크기를 판정하면 실제 변경의 경계를 놓친다.
예제의 업무 범위도 먼저 고정한다. 수강 신청 한 번은 청구액 하나와 안내문 하나를 만든다. 신청 중복 검사, 결제 승인, 수강 취소는 다루지 않는다. 여기서 “결과가 같다”는 말은 이 범위 안에서 반환한 청구액과 전달 목록에 쌓인 안내문의 내용 및 순서가 같다는 뜻이다.
변경 이유로 클래스의 경계를 찾는다
단일 책임 원칙(Single Responsibility Principle)을 “클래스에는 메서드를 하나만 둔다”로 읽으면 곤란하다. 이 예제에서 사용할 기준은 한 클래스가 하나의 일관된 이유로 변경되도록 만드는 것이다. 그 이유는 “수강 관련”처럼 넓은 명사가 아니라 “안내문의 표시 형식이 바뀐다”처럼 구체적인 요구로 적어야 한다.
응집도(cohesion)는 클래스 안의 데이터와 동작이 얼마나 밀접하게 관련되는지 나타낸다. 수강생의 감면액과 그 감면액을 적용하는 동작은 함께 읽는 편이 자연스럽다. 안내문 구분자를 바꾸는 동작은 감면액을 보관하는 동작과 함께 바뀔 이유가 적다. 둘을 떼어 놓으면 각 클래스를 읽을 때 고려할 규칙이 줄어든다.
결합도(coupling)는 다른 요소에 얼마나 의존하는지에 관한 성질이다. 의존 자체를 없애야 한다는 뜻은 아니다. 수강 신청을 처리하려면 수강생과 강좌를 알아야 한다. 다만 수강 신청을 조율하는 클래스가 감면액의 필드 위치나 출력 목록의 구체적인 자료구조까지 알아야 할 필요는 없다.
| 변경 요구 | 담당 클래스 | 보유할 정보 또는 협력 |
|---|---|---|
| 수강생 감면액 적용 | Student | 이름, 감면액 |
| 강좌 수강료 적용 | Course | 강좌명, 수강료 |
| 안내문 표시 형식 변경 | ReceiptFormatter | 안내문 구성 규칙 |
| 안내문 전달 대상 변경 | ReceiptSink 구현 | 전달 대상 |
| 신청 처리 순서 변경 | EnrollmentService | 계산, 작성, 전달의 협력 |
수강 신청 서비스는 여러 객체를 호출하지만, 그렇다고 곧바로 여러 책임을 가진 것은 아니다. 이 클래스의 책임은 신청 처리의 순서를 조율하는 것이다. 감면액을 직접 빼거나 안내문 문자열을 직접 조립하지 않으므로, 계산식이나 표시 형식의 세부 변경이 서비스 안으로 번지지 않는다.
이 표는 모든 프로그램에 적용할 고정 설계가 아니다. 여기서는 감면액이 수강생에게 등록된 속성이고, 강좌에는 기본 수강료가 있다는 업무 가정을 사용했다. 나중에 감면 기준이 신청 시점이나 여러 강좌의 조합에 따라 달라진다면 그 규칙을 별도 역할로 옮길 근거가 생긴다. 아직 없는 요구를 예상해 미리 많은 클래스를 만드는 일은 이번 변경의 목표가 아니다.
클래스를 나눈 뒤에는 각 클래스를 한 문장으로 소개해 본다. “이것도 하고 저것도 한다”는 문장이 계속 나오면 경계를 다시 살핀다. 다만 문장만 억지로 짧게 줄여서는 도움이 되지 않는다. 실제 변경 사례를 대입해 어느 파일이 바뀌는지 확인해야 한다.
데이터를 꺼내기보다 동작을 요청한다
필드를 private으로 만들고 모든 필드에 게터(getter)를 붙였다고 해서 데이터와 동작이 함께 모인 것은 아니다. 외부 코드가 감면액과 수강료를 꺼낸 뒤 계산한다면, 계산 규칙은 여전히 외부에 있다. 필드에 직접 접근하던 문법만 메서드 호출로 바뀐 셈이다.
// 계산 규칙이 호출자에게 남아 있다.
int fee = Math.max(0,
course.getTuition() - student.getReduction());
// 강좌에 청구액 계산을 요청한다.
int fee = course.feeFor(student);
묻지 말고 시키기(Tell, Don't Ask)는 객체가 가진 정보를 바탕으로 할 수 있는 일을 그 객체에 요청하라는 지침이다. 완성 코드에서 Course는 자기 수강료를 알고, Student는 자기 감면액을 안다. Course.feeFor는 수강료를 Student에 전달하고, Student는 감면 적용과 0원 하한 처리를 맡는다. 서비스는 계산식을 알지 않아도 청구액을 얻는다.
모든 질문을 금지하는 지침은 아니다. 안내문을 만드는 객체는 이름과 강좌명을 알아야 한다. 이때 이름을 반환하는 메서드는 표시할 사실을 제공한다. 반면 감면액을 반환받아 여러 호출자가 같은 계산식을 반복한다면, 동작이 흩어졌다는 신호로 볼 수 있다. 메서드 이름에 get이 있는지보다 반환받은 값으로 무엇을 하는지가 중요하다.
동작을 옮길 때는 데이터와 규칙의 주인을 함께 살핀다. 안내문에 이름이 들어간다는 이유만으로 Student에 안내문 전체를 만들게 하면 강좌 표시와 문구 변경까지 수강생 클래스에 모인다. “데이터를 가지고 있다”는 사실만으로 모든 동작의 소유권이 결정되지는 않는다. 어떤 규칙과 함께 바뀌는지까지 보아야 한다.
서비스가 계산 과정을 덜 알게 되는 대신 Course와 Student 사이에는 협력이 생긴다. 이것은 결합이 사라진 것이 아니라 필요한 위치로 옮겨진 것이다. 강좌가 수강생에게 감면 적용을 요청한다는 관계는 이번 업무 가정에서 설명할 수 있다. 설명하기 어려운 객체 사이에 호출을 계속 추가한다면 경계가 적절한지 다시 검토한다.
생성자에 협력 대상을 적는다
책임을 나누고도 서비스 안에서 전달 대상을 직접 만들면 호출자가 그 대상을 선택하기 어렵다. 생성자 주입(constructor injection)은 필요한 협력 대상을 객체 생성 시 전달하는 방식이다. 별도 도구가 없어도 일반 Java 생성자로 사용할 수 있다.
완성 코드의 EnrollmentService는 ReceiptFormatter와 ReceiptSink를 생성자로 받는다. 서비스를 읽는 사람은 객체를 만들 때 무엇을 준비해야 하는지 바로 알 수 있다. ReceiptSink는 안내문 하나를 받는 동작만 약속하는 인터페이스다. 이 예제에서는 목록의 add 메서드를 연결하므로 네트워크나 파일 없이 전달 결과를 관찰할 수 있다.
List<String> receipts = new ArrayList<>();
EnrollmentService service = new EnrollmentService(
new ReceiptFormatter(), receipts::add);
여기서 receipts::add는 안내문을 받아 목록에 추가하는 메서드 참조다. ReceiptSink의 메서드는 반환값이 없으므로 목록 추가의 성공 여부 반환값은 사용하지 않는다. 서비스가 아는 것은 안내문을 넘길 수 있다는 계약뿐이다. 목록 자체를 서비스에 넘기지 않으므로 목록 조회나 삭제 같은 불필요한 동작도 서비스의 협력 범위에 들어오지 않는다.
모든 클래스 앞에 인터페이스를 만들 필요는 없다. 이 예제의 ReceiptFormatter는 구체 클래스로 주입한다. 표시 형식이 여러 종류라는 요구가 없고, 현재 구현을 직접 사용해도 책임이 드러나기 때문이다. ReceiptSink는 실제 전달 수단과 서비스 사이의 경계라서 작은 인터페이스를 둔다.
의존 대상을 생성자로 받는 것과 생성자의 인수를 무작정 늘리는 것도 다르다. 서비스가 많은 협력 대상을 요구한다면 조율하는 흐름이 너무 넓은지 살펴야 한다. 인수를 하나의 설정 꾸러미로 감싸 겉으로만 줄이면 의존 관계는 그대로 남는다.
완성 코드
아래 내용을 Main.java 하나에 저장한다. 공개 클래스는 Main뿐이며 나머지 클래스와 인터페이스는 같은 파일에 둔다. 파일을 나누는 작업과 책임을 나누는 작업이 같지 않다는 점을 확인하기 위한 구성이다. 실제 프로젝트에서는 필요에 따라 각 클래스를 같은 이름의 파일로 옮길 수 있다.
LegacyEnrollmentManager는 기존 동작을 비교하기 위한 기준이다. 새 서비스는 같은 세 사례를 처리한다. 일반 청구, 일부 감면, 수강료를 넘는 감면을 각각 실행한다. 입력 객체는 기존 메서드가 받던 이름, 감면액, 강좌명, 수강료와 같은 값으로 만든다.
Main.java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
public class Main {
public static void main(String[] args) {
List<String> before = new ArrayList<>();
LegacyEnrollmentManager legacy =
new LegacyEnrollmentManager(before);
int[] beforeFees = {
legacy.enroll("가람", 0, "자바 기초", 12000),
legacy.enroll("나래", 2000, "자바 기초", 12000),
legacy.enroll("다온", 15000, "자바 기초", 12000)
};
List<String> after = new ArrayList<>();
EnrollmentService service = new EnrollmentService(
new ReceiptFormatter(), after::add);
Course course = new Course("자바 기초", 12000);
int[] afterFees = {
service.enroll(new Student("가람", 0), course),
service.enroll(new Student("나래", 2000), course),
service.enroll(new Student("다온", 15000), course)
};
List<String> expectedReceipts = List.of(
"가람 | 자바 기초 | 청구액 12000원",
"나래 | 자바 기초 | 청구액 10000원",
"다온 | 자바 기초 | 청구액 0원");
check(Arrays.equals(beforeFees, afterFees),
"전후 청구액 일치");
check(before.equals(after), "전후 안내문과 순서 일치");
check(Arrays.equals(afterFees, new int[] {12000, 10000, 0}),
"청구액 기대값 일치");
check(after.equals(expectedReceipts), "안내문 기대값 일치");
check(after.size() == 3, "신청마다 안내문 한 건 전달");
System.out.println("수강 안내문");
after.forEach(System.out::println);
}
private static void check(boolean condition, String name) {
System.out.println((condition ? "PASS " : "FAIL ") + name);
if (!condition) {
throw new AssertionError(name);
}
}
}
final class LegacyEnrollmentManager {
private final List<String> receipts;
LegacyEnrollmentManager(List<String> receipts) {
this.receipts = Objects.requireNonNull(receipts);
}
int enroll(String studentName, int reduction,
String courseTitle, int tuition) {
int fee = Math.max(0, tuition - reduction);
String receipt = studentName + " | " + courseTitle
+ " | 청구액 " + fee + "원";
receipts.add(receipt);
return fee;
}
}
final class Student {
private final String name;
private final int reduction;
Student(String name, int reduction) {
this.name = Objects.requireNonNull(name);
if (reduction < 0) {
throw new IllegalArgumentException("감면액은 음수일 수 없다");
}
this.reduction = reduction;
}
String name() {
return name;
}
int applyReductionTo(int tuition) {
return Math.max(0, tuition - reduction);
}
}
final class Course {
private final String title;
private final int tuition;
Course(String title, int tuition) {
this.title = Objects.requireNonNull(title);
if (tuition < 0) {
throw new IllegalArgumentException("수강료는 음수일 수 없다");
}
this.tuition = tuition;
}
String title() {
return title;
}
int feeFor(Student student) {
return student.applyReductionTo(tuition);
}
}
final class ReceiptFormatter {
String format(Student student, Course course, int fee) {
return student.name() + " | " + course.title()
+ " | 청구액 " + fee + "원";
}
}
@FunctionalInterface
interface ReceiptSink {
void send(String receipt);
}
final class EnrollmentService {
private final ReceiptFormatter formatter;
private final ReceiptSink sink;
EnrollmentService(ReceiptFormatter formatter, ReceiptSink sink) {
this.formatter = Objects.requireNonNull(formatter);
this.sink = Objects.requireNonNull(sink);
}
int enroll(Student student, Course course) {
int fee = course.feeFor(student);
String receipt = formatter.format(student, course, fee);
sink.send(receipt);
return fee;
}
}
줄별 해설
Main의 before 목록은 기존 코드가 만든 안내문을 받는다. beforeFees의 세 원소는 기존 코드가 반환한 청구액이다. 반환값과 외부로 전달한 내용을 따로 모으는 이유는 한쪽만 같아도 다른 쪽이 달라질 수 있기 때문이다. 반환값이 맞아도 안내문에 감면 전 금액이 들어간다면 이번 변경은 성공하지 못한 것이다.
after 목록과 afterFees는 새 코드에서 같은 역할을 한다. Course 객체 하나를 세 신청에 사용하지만 Student는 사례별로 만든다. 세 사례에서 강좌 수강료는 같고 감면액만 다르므로, 비교하려는 차이가 입력에서 드러난다. 새 코드의 입력이 기존 입력과 달라지면 비교 결과를 해석하기 어렵다.
expectedReceipts는 기존 결과와 별개로 적은 기대값이다. 전후 결과만 비교하면 두 구현이 같은 실수를 해도 검사를 통과할 수 있다. 청구액과 안내문의 기대값도 함께 검사해, 기존 코드와의 일치 및 예제 요구와의 일치를 구분한다. 안내문 목록의 equals는 내용뿐 아니라 원소의 순서도 비교한다.
check는 조건을 출력한 뒤 실패하면 AssertionError를 던진다. 따라서 실패한 실행이 마지막 안내문 출력까지 정상적으로 이어지지 않는다. Java의 assert 문은 실행 옵션에 따라 꺼질 수 있으므로 여기서는 일반 메서드로 검사한다. 각 검사의 이름은 무엇이 유지돼야 하는지 읽을 수 있게 붙인다.
LegacyEnrollmentManager는 계산, 문자열 조립, 목록 추가를 한곳에서 수행한다. 이 클래스는 새 서비스가 호출하는 부품이 아니다. main에서 나란히 실행하는 비교 대상이다. 새 서비스가 내부에서 기존 관리 클래스를 계속 호출하도록 만들면 책임을 분리한 결과를 확인하기 어렵다.
Student의 reduction은 외부로 반환하지 않는다. applyReductionTo가 자신의 감면액을 사용해 청구액을 계산한다. Math.max는 감면액이 수강료보다 클 때도 청구액을 0원으로 제한한다. 호출자마다 이 조건을 다시 쓰지 않으므로 같은 규칙이 여러 위치로 흩어지지 않는다.
Course의 feeFor는 자기 수강료를 기준으로 감면 적용을 요청한다. 현재 한 줄짜리 메서드지만 서비스에 필요한 업무 표현을 제공한다. 서비스가 원하는 것은 수강료 필드의 값이 아니라 해당 수강생의 청구액이다. 이 차이가 서비스 코드의 읽는 단위를 데이터 조작에서 업무 요청으로 바꾼다.
Student와 Course의 생성자는 이름의 null 여부와 금액의 음수 여부를 확인한다. 이번 전후 비교 범위는 이름이 null이 아니고 금액이 0 이상인 유효 입력이다. 기존 메서드가 받아들이던 잘못된 입력까지 동일하게 처리한다는 주장은 하지 않는다. 기존 호출자가 실제로 어떤 값을 보내는지 조사하는 작업은 이런 경계 검사를 도입할 때도 필요하다.
ReceiptFormatter는 계산된 fee를 받아 안내문을 만든다. 여기서 다시 course.feeFor를 호출하지 않는 이유는 계산 책임을 되풀이하지 않기 위해서다. 서비스는 한 번 계산한 청구액을 안내문 작성과 반환에 함께 사용한다. 표시 형식의 변경은 format 안에 모인다.
ReceiptSink는 전달 동작 하나만 선언한다. FunctionalInterface 표시는 추상 메서드가 하나인 인터페이스라는 의도를 컴파일러가 확인하게 한다. Main의 after::add가 이 계약을 구현한다. 서비스 생성자의 requireNonNull은 필수 협력 대상이 빠진 객체가 만들어지는 것을 막는다.
마지막 enroll의 네 줄은 계산, 작성, 전달, 반환 순서다. 이 순서는 감춰야 할 세부 사항이 아니라 서비스가 맡은 내용이다. 이를 다시 여러 겹의 중계 메서드로 감싸면 흐름을 읽으려 여러 위치를 따라가야 할 수 있다. 각 줄이 맡은 역할을 같은 수준에서 읽을 수 있는 현재 구성을 유지한다.
실행 결과
JDK 17이 준비된 macOS 또는 Linux에서 Main.java를 저장한 디렉터리로 이동해 실행한다. 프로그램은 메모리의 목록만 사용하며 파일, 현재 시각, 난수에 의존하지 않는다. 아래는 코드에 따른 예상 출력이다.
javac -d out *.java
java -cp out Main
PASS 전후 청구액 일치
PASS 전후 안내문과 순서 일치
PASS 청구액 기대값 일치
PASS 안내문 기대값 일치
PASS 신청마다 안내문 한 건 전달
수강 안내문
가람 | 자바 기초 | 청구액 12000원
나래 | 자바 기초 | 청구액 10000원
다온 | 자바 기초 | 청구액 0원
컴파일 경고까지 확인하려면 다음 명령을 사용할 수 있다. 이 코드에는 원시 타입이나 검사되지 않은 형 변환이 없으며, 컴파일이 성공하면 컴파일러의 별도 출력 없이 종료된다.
javac -Xlint:all -Werror -d out *.java
다섯 개의 PASS는 세 가지 입력 사례에서 지정한 성질이 유지된다는 근거다. 가능한 모든 수강생과 모든 금액에 대해 동작을 증명하는 것은 아니다. 감면액과 수강료가 같은 경우처럼 경계에 해당하는 입력은 추가로 확인할 수 있다. 또한 전달 중 오류가 발생하는 상황은 이 예제의 성공 경로 비교에 포함하지 않았다.
실제 코드에서 옮기는 순서도 비교에 영향을 준다. 먼저 기존 결과를 모을 경계를 정하고, 계산을 옮긴 뒤 비교한다. 이어 안내문 작성과 전달 경계를 나누고 다시 비교한다. 여기서는 지면에서 전후를 한 번에 보여 주지만, 작업 중에는 역할 하나를 옮길 때마다 같은 입력을 실행하는 편이 차이의 원인을 찾기 쉽다.
실무에서 자주 틀리는 것
필드를 숨긴 뒤 계산식은 밖에 남긴다
다음의 틀린 코드는 필드 접근을 게터 호출로 바꿨을 뿐이다. 감면 적용과 0원 하한 규칙을 호출자가 계속 알고 있다. 이런 호출자가 늘어나면 규칙이 바뀔 때 여러 위치를 수정해야 한다.
// 틀린 코드: 계산 규칙이 서비스에 있다.
int fee = Math.max(0,
course.getTuition() - student.getReduction());
// 고친 코드: 계산을 객체에 요청한다.
int fee = course.feeFor(student);
게터를 모두 삭제하는 것이 목적은 아니다. 계산을 위해 내부 상태를 꺼내는지, 안내문에 필요한 사실을 읽는지 구별한다. 완성 코드의 name과 title은 후자에 해당한다. 같은 반환 메서드라도 호출자의 사용 방식에 따라 설계에 미치는 영향이 다르다.
생성자 주입을 해 놓고 받은 대상을 쓰지 않는다
인수로 받은 협력 대상을 무시하고 새 객체를 만들면 호출자가 전달 방식을 선택할 수 없다. 틀린 코드는 Main이 안내문을 수집하려 해도 콘솔 출력으로 바꿔 버린다. 아래 두 코드는 완성 코드의 서비스 생성자를 바꾼다고 가정한 비교다.
// 틀린 코드
EnrollmentService(ReceiptFormatter formatter, ReceiptSink sink) {
this.formatter = new ReceiptFormatter();
this.sink = System.out::println;
}
// 고친 코드
EnrollmentService(ReceiptFormatter formatter, ReceiptSink sink) {
this.formatter = Objects.requireNonNull(formatter);
this.sink = Objects.requireNonNull(sink);
}
주입은 생성자 모양만의 문제가 아니다. 외부에서 선택한 협력 대상을 실제 처리에 사용해야 한다. 반대로 모든 new를 지워야 하는 것도 아니다. Main은 객체들을 조립하는 위치이므로 구체적인 객체와 전달 대상을 선택한다.
전달 구현에 안내문 작성까지 맡긴다
전달 대상에 따라 같은 안내문의 문구가 달라지면 표시 형식 변경이 여러 전달 구현에 퍼진다. ReceiptSink가 완성된 문자열을 받도록 정한 이유는 전달 대상이 계산이나 문구 구성까지 떠안지 않게 하기 위해서다.
// 틀린 코드: 전달 구현 안에서 문구까지 정한다.
ReceiptSink sink = receipt -> after.add("[접수] " + receipt);
// 고친 코드: 정해진 안내문을 그대로 전달한다.
ReceiptSink sink = after::add;
접수 표시가 필요하다는 요구가 생기면 ReceiptFormatter에서 표현을 정한다. 전송 형식에 필요한 인코딩처럼 전달 수단 자체의 규칙과 사용자에게 보이는 안내문 문구는 구별해야 한다. 이 예제에서는 문자열을 메모리 목록에 넣으므로 별도 변환이 필요하지 않다.
다른 이름의 관리 클래스로 일을 몰아준다
기존 메서드 전체를 Helper나 Processor로 옮기고 서비스가 그 메서드를 부르게 하면 파일은 늘지만 책임 경계는 그대로다. 아래 틀린 코드는 기존 관리 클래스의 메서드에 모든 일을 계속 맡긴다.
// 틀린 코드: 기존 책임 묶음을 그대로 호출한다.
return legacy.enroll(studentName, reduction, courseTitle, tuition);
// 고친 코드: 서비스는 각 역할의 협력을 조율한다.
int fee = course.feeFor(student);
String receipt = formatter.format(student, course, fee);
sink.send(receipt);
return fee;
분리 결과를 평가할 때는 클래스 이름보다 변경 위치를 확인한다. 안내문 구분자를 바꿀 때 계산 클래스를 수정해야 하는지, 전달 목록을 바꿀 때 서비스 흐름을 고쳐야 하는지 대입해 본다. 책임의 경계는 이 질문에 답할 수 있어야 한다.
한눈에 보기
| 관점 | 확인 질문 | 이번 예제의 선택 |
|---|---|---|
| 변경 이유 | 어떤 요구 때문에 이 클래스를 고치는가 | 계산, 표시, 전달, 조율을 구분한다 |
| 응집도 | 데이터와 그 데이터를 쓰는 규칙이 모였는가 | 감면액과 적용 동작을 Student에 둔다 |
| 결합도 | 상대의 내부 정보까지 알아야 하는가 | 서비스는 청구액 계산을 요청한다 |
| 생성자 주입 | 필요한 협력 대상을 생성 시 알 수 있는가 | 작성자와 전달 대상을 인수로 받는다 |
| 동작 보존 | 관찰할 결과가 무엇인가 | 청구액, 안내문 내용, 순서를 비교한다 |
역할이 나뉘면 전체 흐름을 이해하려 여러 클래스를 읽게 되는 비용도 생긴다. 따라서 모든 한 줄 계산을 별도 클래스로 추출하는 방식은 피한다. 이번 구조는 서비스에서 흐름을 보고, 필요한 경우에만 해당 역할의 세부 내용을 읽도록 만든 것이다. 한 파일에 함께 두어도 각 역할의 경계와 협력은 확인할 수 있다.
이번에는 누가 어떤 일을 맡는지에 집중했다. 필드가 바뀔 수 있는지, 같은 의미의 데이터를 어떻게 묶을지는 별도의 설계 질문이다. 다음 장에서는 불변성과 값 객체를 통해 데이터 표현의 경계를 살핀다.
연습 문제
- 안내문의 구분자를 “ | ”에서 “ / ”로 바꾸라는 요청이 들어왔다. 새 구조에서 수정할 클래스와 유지해야 할 클래스를 적어라. 전후 일치 검사와 기대값 검사를 어떻게 해석해야 하는지도 설명하라.
- 감면액이 수강료와 같은 사례를 기존 코드와 새 코드에 추가하라. 수강생은 “라온”, 감면액과 수강료는 모두 12000으로 한다. 추가할 입력, 기대 청구액, 기대 안내문을 작성하라.
- 새 서비스로 안내문을 보내되 목록에 저장하지 않고 전송 횟수만 세어라. ReceiptSink를 구현하는 클래스를 작성하고 신청 한 번 뒤 횟수가 1인지 check로 확인하라. 서비스의 계산과 안내문 작성 코드는 바꾸지 않는다.
- 서비스에서 Student의 감면액을 조회해 청구액을 계산하도록 되돌리면 어떤 책임이 이동하는가. name 조회와 reduction 조회를 이번 예제에서 다르게 판단한 이유를 설명하라.
정답과 해설
첫 번째 문제에서는 ReceiptFormatter의 문자열 구분자를 수정한다. Student와 Course의 계산, EnrollmentService의 처리 순서, ReceiptSink의 계약은 유지한다. 이 요청은 의도적인 출력 변경이므로 예전 구분자를 사용하는 기존 코드와 안내문이 같다는 검사는 실패해야 한다. 청구액 일치 검사는 계속 통과해야 한다. 변경 요구에 맞는 새 안내문 기대값으로 표시 결과를 확인하고, 기존과 다른 부분이 구분자뿐인지 검토한다. 동작을 보존하는 변경과 요구에 따라 동작을 바꾸는 변경을 같은 것으로 취급해서는 안 된다.
return student.name() + " / " + course.title()
+ " / 청구액 " + fee + "원";
두 번째 문제에서는 beforeFees와 afterFees의 마지막에 다음 호출을 각각 넣는다. afterFees의 기대 배열에도 마지막 값 0을 추가하고, expectedReceipts에 아래 문자열을 추가한다. 신청 건수 검사의 기대값은 4로 바꾼다. before와 after 목록을 비교하는 코드는 그대로 사용할 수 있다.
legacy.enroll("라온", 12000, "자바 기초", 12000)
service.enroll(new Student("라온", 12000), course)
// 추가할 기대 안내문
"라온 | 자바 기초 | 청구액 0원"
감면액이 수강료보다 큰 경우와 같은 경우는 모두 청구액이 0원이지만 입력의 경계가 다르다. 이런 사례를 더하면 0원 하한 규칙을 해석한 방식이 맞는지 확인할 수 있다. 배열 원소를 추가할 때는 앞 원소 뒤의 쉼표도 함께 맞춰야 한다.
세 번째 문제의 한 가지 답은 다음과 같다. CountingSink를 같은 파일의 별도 클래스로 추가하고, 사용 코드는 Main.main 안에 넣는다. check는 Main의 메서드이므로 그 위치에서 호출한다.
final class CountingSink implements ReceiptSink {
private int count;
@Override
public void send(String receipt) {
count++;
}
int count() {
return count;
}
}
CountingSink countingSink = new CountingSink();
EnrollmentService countingService = new EnrollmentService(
new ReceiptFormatter(), countingSink);
countingService.enroll(new Student("가람", 0),
new Course("자바 기초", 12000));
check(countingSink.count() == 1, "전달 횟수 일치");
이 구현은 안내문 내용은 보관하지 않고 전달 요청 횟수만 기록한다. 서비스는 동일한 ReceiptSink 계약을 사용하므로 변경하지 않는다. count 조회는 관찰 결과를 확인하는 용도다. 숫자를 반환한다는 이유만으로 게터 남용이라고 판단할 수 없다는 사례이기도 하다.
네 번째 문제에서는 감면 규칙의 책임이 Student에서 서비스로 이동한다. 서비스가 감면액을 빼고 0원 하한을 처리한다면, 같은 계산이 필요한 다른 호출자도 규칙을 알아야 할 가능성이 커진다. name 조회는 안내문에 사실을 표시하기 위한 것이며, 수강생의 감면 규칙을 외부에서 재구현하지 않는다. 판단 기준은 조회 메서드의 존재 자체가 아니라 조회 뒤에 어떤 규칙을 수행하는지에 있다.