
Calculator 클래스를 만들고 App에서 인스턴스를 생성했는데, 막상 main()을 보니 연산 로직이 Calculator가 아닌 App에 그대로 남아 있었다.
(연산의 책임은 Calculator의 객체가 지고 io의 책임은 App이 지고 있는 상황)
// App.java — Calculator를 만들었지만 연산은 여전히 App이 직접 하고 있음
Calculator calculator = new Calculator(); // 인스턴스는 만들었지만...
switch (operator) { // ❌ 연산은 App이 직접 수행
case '+': result = first + second; break;
case '-': result = first - second; break;
// ...
}
calculator.getResults(); // 결과 조회만 Calculator에서 가져옴
Calculator 인스턴스를 생성하긴 했는데, App이 switch로 연산을 직접 수행하고 calculator는 결과를 꺼낼 때만 쓰이고 있었다. "객체를 만들었다"와 "객체에게 책임을 위임했다"가 다르다는 걸 코드를 실제로 보기 전까지 인식하지 못했다.
객체를 생성하는 것과 객체에게 일을 시키는 것은 다른 행동이다. 절차적으로 작성된 main()의 switch 블록을 그대로 둔 채 Calculator만 옆에 선언한 셈이었다. App이 연산 방법을 알고 있는 한, Calculator는 단순한 결과 저장소에 불과하다.
핵심은 "App이 연산 방법을 알아야 하는가?" 라는 질문이다. App이 +일 때 더하고 -일 때 빼는 방법을 직접 알고 있다면, Calculator 클래스가 생겨도 책임은 분리되지 않은 것이다.
switch 블록 전체를 Calculator.calculate() 안으로 이동시켰다. App은 두 수와 연산자를 calculate()에 넘기고, 결과만 돌려받는다. (메시지 기반으로만 객체들이 상호작용할 수 있는 context를 만들자)
이동 전 — App이 연산 방법을 알고 있음
// App.java
int result = 0;
switch (operator) {
case '+': result = first + second; break;
case '-': result = first - second; break;
case '*': result = first * second; break;
case '/':
if (second == 0) { ... }
result = first / second; break;
}
calculator.results.add(result); // 결과를 직접 저장소에 밀어넣음
이동 후 — App은 위임만 한다
// App.java
int result = calculator.calculate(first, second, operator); // ✅ 방법은 모른다, 결과만 받는다
// Calculator.java — 연산 방법은 여기서만 안다
public int calculate(int num1, int num2, OperatorType operator) {
int result = (int) operator.apply(num1, num2);
results.add(result); // 저장도 내부에서 스스로
return result;
}
추가로 results 필드에 private을 붙이면서, App이 calculator.results.add()로 직접 저장소를 건드리던 길도 막았다. 외부에서 직접 접근하는 경로를 차단하자, 자연스럽게 calculate() 안에서 저장까지 책임지는 구조가 완성됐다.
- 객체를 생성하는 것과 객체에게 책임을 위임하는 것은 다르다.
new Calculator()는 시작일 뿐이고,App이 연산 방법을 모르게 되는 순간이 진짜 분리가 완성된 시점이다.private캡슐화는 "외부 접근을 막는 규칙"이 아니라, 외부가 내부 방법을 알아야 할 이유를 없애는 장치다.- 결국 객체는 역할과 행동이다.
제네릭을 적용하면서 int → T로 타입을 바꾸는 작업을 시작했는데, 어디서 시작해서 어디서 끝내야 하는지 감이 없었다.
ArithmeticCalculator를 수정했더니 OperatorType도 바꿔야 했고, OperatorType을 바꿨더니 App도 바꿔야 했다.
결국 세 파일을 동시에 열어놓고 이곳저곳을 동시에 수정하다가, 어느 시점에 무엇이 깨진 건지 파악이 안 되는 상황이 됐다.
ArithmeticCalculator<T> 수정
→ OperatorType.apply() 시그니처 수정
→ App의 입력 처리 수정
→ 다시 ArithmeticCalculator로 돌아와서 추가 수정...
변경이 변경을 낳고, 수정이 수정을 낳는 연쇄가 끝나지 않았다.
문제의 본질은 변경의 경계(Boundary)를 생각하지 않고 코드를 수정했기 때문이다.
클래스를 나눠놨다고 해서 자동으로 변경이 격리되는 게 아니다.
클래스 사이에 "이 변경은 여기까지만 영향을 준다"는 경계가 설계되어 있어야 한다.
이번 경우 경계가 무너진 지점은 OperatorType.apply()의 시그니처였다.
apply(int, int)였을 때 ArithmeticCalculator가 int로 넘기고 있었기 때문에,
ArithmeticCalculator가 T를 쓰게 되면 apply()도 같이 바꿔야 했다.
즉, 두 클래스가 타입(int)을 공유하고 있었고, 그 타입이 변경의 파급 경로가 됐다.
해결의 실마리는 "누가 타입 변환 책임을 져야 하는가?" 라는 질문이었다.
OperatorType은 연산 행위만 담당하면 된다. 피연산자가 Integer인지 Double인지 알 필요가 없다.
반면 ArithmeticCalculator는 T를 받아서 처리하는 쪽이니, T → double 변환은 여기서 책임지는 게 맞다.
// ArithmeticCalculator — T를 double로 변환하는 책임을 여기서 진다
public double calculate(T num1, T num2, OperatorType operator) {
double result = operator.apply(num1.doubleValue(), num2.doubleValue());
results.add((T) /* 처리 */);
return result;
}
// OperatorType — double만 알면 된다. T를 전혀 모른다
public abstract double apply(double num1, double num2);
OperatorType이 double만 받도록 고정하자, ArithmeticCalculator에서 아무리 T를 바꿔도 OperatorType은 건드릴 필요가 없어졌다.
변경이 경계 안에서 멈췄다.
이 과정에서 바운더리 컨텍스트(Boundary Context) 라는 개념이 자연스럽게 체감됐다.
유지보수성은 "클래스를 나누는 것"이 아니라 "변경이 어디서 멈추는가"로 결정된다.
변경이 여러 파일로 번진다면, 클래스 사이 어딘가에 타입이나 로직이 공유되고 있다는 신호다.
"이 수정이 몇 개의 파일에 영향을 주는가"를 먼저 생각하면, 경계를 어디에 그어야 하는지 보이기 시작한다.