(Working Effectively with Legacy Code) 012. I Need to Make Many Changes in One Area.

I Need to Make Many Changes in One Area

레거시 클래스에 대해 테스트 루틴을 작성하려할때, 의존 관계를 제거하기 까다로운 경우가 많다.

무엇보다 클래스를 테스트 하네스에 넣기 위해 가장 까다로운 것은 변경 지점이 좁은 범위에 여기저기 흩어져있는 경우이다.

만약 신규 기능을 시스템에 추가할 때, 깊은 의존 관계를 가진 여러 클래스를 모두 변경해야한다면 많은 시간이 소모될 것이다.

이러한 점을 감안하고서라도 밀접한 클래스 간의 의존 관계는 모두 제거 해야하는 걸까?

꼭 모두 제거해야할 필요는 없을 것이다.

여러 변경을 모아서 한 번에 테스트 루틴을 작성하기 위해선 좀 더 상위 수준에서 테스트할 필요가 있다.

예를 들어서 여러 private 메서드를 public 메서드 하나로 테스트를 수행하거나

혹은 다수의 객체가 개입하는 동작을 객체 하나의 인터페이스에서 테스트하는 등이다.

이처럼 보다 상위 수준에서 작업을 하게 되면 변경 작업을 위한 테스트 및 개별 위치별로 추가적인 리팩토링을 수행하기 위한 테스트 코드도 획득할 수 있다.

이 테스트 루틴으로 인해 동작을 검증하게 되면 코드의 구조를 근본적으로 변경할 수 있게 된다.

상위 수준의 테스트 루틴을 작성하려면 먼저 어느 위치에 작성할 지부터 결정해야 한다.

이번 포스팅에서는 교차 지점(interception point) 의 개념을 알아보고, 이 교차 지점을 어떻게 찾는 지 살펴본 뒤,

교차 지점으로서 가장 좋은 위치인 조임 지점(pinch point) 과 조임 지점을 찾는 방법에 대해서 알아보도록 하자.

1. 교차 지점(Interception Points)

교차 지점 이란 특정 변경에 의한 영향을 감지할 수 있는 프로그램 내의 위치를 말한다.

자연스러운 봉합 지점이 거의 없고, 객체들이 서로 밀접하게 엮여있는 상태의 애플리케이션의 경우 적절한 교차 지점을 찾아내는 데 어려움이 따른다.

교차 지점을 찾으려면 먼저 변경이 필요한 위치를 확인한 후, 이 위치들이 외부에 미치는 영향을 추적한다.

이때 영향이 탐지되는 모든 곳이 교차 지점이 되지만, 이 교차 지점들 중 최상의 교차 지점을 판단하는 것이 필요하다.

이 교차 지점을 찾는 예시로 간단한 경우와 상위 수준의 교차 지점을 찾는 경우로 나누어 알아보자.

1.1. 간단한 경우(The Simple Case)

간단한 경우의 예제부터 보도록 하자.

운용 비용의 계산을 위한 Invoice 클래스가 있고, getValue() 메서드로 운송 비용을 계산한다고 가정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Invoice {
// ...
public Money getValue() {
Money total = itemsSum();
if (billingDate.after(Date.yearEnd(openingDate))) {
if (originator.getState().equals("FL") || originator.getState().equals("NY")) {
total.add(getLocalShipping());
} else {
total.add(getDefaultShipping());
}
} else {
total.add(getSpanningShipping());
}
total.add(getTax());
return total;
}
// ...
}

이때 요구사항으로 NY, 뉴욕으로 보내는 운송 비용의 계산 방법을 변경해야 하는 상황이다.

이때 운송 비용의 계산 로직을 추출하여 신규 클래스 ShippingPricer를 작성했다고 가정하면 아래와 같이 코드가 바뀌게 된다.

1
2
3
4
5
6
7
8
public class Invoice {
public Money getValue() {
Money total = itemsSum();
total.add(shippingPricer.getPrice());
total.add(getTax());
return total;
}
}

기존에 getValue() 메서드가 수행하던 작업 대부분이 ShippingPricer 클래스로 넘어갔음을 추측할 수 있다.

단, billingDate 변수를 ShippingPricer 클래스로 넘겨주어야 하므로 Invoice 클래스의 생성자도 변경해야 한다.

이제 영향 다이어그램을 그려가면서 교차 지점을 찾아보도록 하자.

위의 변경으로 getValue() 메서드는 Invoice 클래스 내에서 사용되지 않게 되었고, BillingStatement 클래스에서 호출되게 되었다.

이때 getValue() 메서드를 호출하는 BillingStatement 클래스의 메서드를 makeStatement()라고 하면 아래와 같은 영향 다이어그램을 가지게 된다.

Figure 12.1 getValue affects BillingStatement.makeStatement.

상술했듯, Invoice 클래스의 생성자도 수정해야 하므로, 생성자의 의존하는 코드도 살펴보도록 하자.

Invoice 생성자는 ShippingPricer 클래스를 생성하며 ShippingPricer 객체를 사용하는 메서드는 getValue()가 유일하다고 할때 영향 다이어그램은 아래와 같다.

Figure 12.2 Effects on getValue.

모두 종합하면 아래와 같은 영향 다이어그램이 완성된다.

Figure 12.3 A chain of effects.

위 영향 다이어그램에서 타원으로 표현된 것들이 모두 교차 지점이다.

하나씩 살펴보도록 하자.

shippingPricer 변수를 사용해 테스트를 시도할 수는 있지만, 이는 Invoice 클래스 내의 private 변수이므로 접근할 수 없다는 단점이 있다.

BillingStatement 클래스의 makeStatement() 메서드에 테스트 루틴을 작성하여 반환 값을 검증할 수도 있다.

하지만 제일 나은 방법은 Invoice 클래스의 getValue() 메서드를 실행하고 해당 반환 값을 검증하는 방식이다.

1.2. 상위 수준의 교차 지점(Higher-Level Interception Points)

일반적으로 변경 대상인 클래스의 public 메서드 중 하나가 가장 좋은 교차 지점인 경우가 대부분이다.

이 경우 교차 지점을 찾아서 사용하기 쉽지만, public 메서드가 최선의 교차 지점이 아닌 경우가 있다.

Invoice 클래스를 좀 더 확장해보자.

운영 비용의 계산 방법을 변경하고, 운송 업체를 관리하기 위한 필드를 포함하도록 Item 클래스를 변경해야 한다고 가정한다.

여기에 BillingStatement 클래스에서 운송 업체별로 구별하여 처리할 수 있도록 조건을 추가한다.

아래 UML은 위의 요구사항을 표현한 것이다.

Figure 12.4 Expanded billing system.

가장 단순하게 생각한다면, 각 클래스마다 개별적으로 테스트 루틴을 작성하고 필요한 변경을 수행하는 방법이 제일 먼저 떠오른다.

물론 좋은 방법이긴 하지만 상위 수준의 교차 지점이 발견되는 경우 좀 더 효율적으로 작업할 수 있다.

상위 수준의 교차 지점에서 작업하는 경우 세 가지 이점이 존재한다.

  1. 의존 관계를 비교적 덜 제거해도 된다.
  2. 코드를 하나의 묶음으로 취급할 수 있다.
  3. 클래스들의 동작을 보장하는 테스트 루틴으로 좀 더 규모다 큰 리팩토링에 대해서 보장할 수 있다.

이번 예제에서는 BillingStatement 클래스의 테스트 코드를 불변 조건으로 이용하고, InvoiceItem 클래스의 구조를 바꿀 수 있다.

먼저 테스트 코드부터 작성하면 아래와 같다.

1
2
3
4
5
6
7
void testSimpleStatement() {
Invoice invoice = new Invoice();
invoice.addItem(new Item(0, new Money(10));
BillingStatement statement = new BillingStatement();
statement.addInvoice(invoice);
assertEquals("", statement.makeStatement());
}

위의 테스트 코드는 불변 조건인 BillingStatement 클래스의 객체가 하나의 Item 품목을 갖는 Invoice 객체에 대해 어떠한 값을 makeStatement() 메서드를 통해 반환하는 지 확인한 후, 해당 값을 사용하도록 테스트 루틴을 변경하기 위해 작성하였다.

그 뒤에 다수의 테스트 루틴을 추가하여 ItemInvoice의 여러 조합에서 어떠한 영향을 받는 지 검증하기 위한 용도로 쓸 수 있다.

여기서 BillingStatement 클래스가 가장 이상적인 교차 지점인 이유에 대해 생각해보자.

Figure 12.5 Billing system effect sketch.

위의 영향 다이어그램에서 알 수 있듯이 BillingStatement 클래스의 makeStatement() 메서드가 모든 변경에 의한 영향을 감지할 수 있는 유일한 곳이기 때문이다.

여기서 모든 변경에 의한 영향을 감지할 수 있따는 포인트를 조임 지점(pinch point) 라고 부른다.

조임 지점을 찾을 수 있다면 변경 작업은 매우 쉬워지지만, 조임 지점은 변경 지점에 의해서 결정된다는 것을 명심해야 한다.

여러 곳에서 호출되는 클래스라고 할 지라도, 해당 클래스의 다수 변경에 대한 조임 지점은 한 개 뿐일 수도 있기때문이다.

이번엔 예제를 좀 더 확장하여 발주 여부를 판단하는 InventoryControl 클래스를 추가해보자.

해당 클래스는 Item 클래스의 needsReorder()를 호출하여 발주 여부를 판단한다.

UML로 표현하면 아래와 같다.

Figure 12.6 Billing system with inventory.

새로 확장된 요구사항 때문에 영향 스케치를 변경해야 할까?

답은 “아니다” 이다.

ItemshippingCarrier 필드 등을 추가하더라도, needsReorder() 메서드에 아무런 영향이 없으므로 여전히 조짐 지점은 BillingStatement 클래스의 makeStatement() 메서드이다.

한 번 더 예제를 확장해보자.

Item 클래스에 공급자를 표현하는 supplier를 얻어오고나 설정하는 메서드를 추가하고, InventoryControl 클래스와 BillingStatement 클래스도 supplier를 사용한다고 가정하면 영향 다이어그램은 아래와 같이 변경된다.

Figure 12.7 Full billing system scenario.

이때 변경사항에 영향을 감지하려면 BillingStatement 클래스의 makeStatement() 메서드와 InventoryControl 클래스의 run() 메서드의 영향을 받는 변수를 통해 검출할 수는 있지만 교차 지점이 두 개로 늘긴 했지만, 모든 메서드와 변수를 변경하는 것보다는 훨씬 쉬워진 상태이며 테스트 루틴을 통해 많은 변경을 감지할 수 있게 된다.

조임 지점이 금방 발견된다면 좋겠지만, 많은 영향을 서로 주고 받는 다면 영향 다이어그램이 매우 복잡해질 것이다.

이럴 땐 변경 지점으로 다시 돌아가서 조임 지점을 찾는 것이 권장되며, 그래도 찾이 어려울 경우 가급적 변경 지점 근처에 테스트 루틴을 위치시키도록 해야한다.

또 다른 방법으로는 영향 다이어그램 내에서 공통적인 사용 방법을 찾아 조임 지점인지 확인하는 것이다.

“두 개의 클래스 중 한 곳에만 테스트 루틴을 작성해도 될까?” 보다는 “이 메서드를 분리하면 이곳에서 변경을 감지할 수 있는가?” 라고 쿼리하는 것이 좀 더 도움이 된다.

2. 조임 지점을 이용한 설계 판단(Judging Design with Pinch Points)

1번 챕터를 통해 조임 지점의 유용성에 대해서 알아보았다.

조임 지점은 단순히 테스트 루틴을 작성하는 것 뿐만 아니라 조임 지점의 위치에 따라 코드를 개선하는 방법에 대한 힌트도 얻을 수 있다.

조임 지점의 정의에 대해 좀 더 명세해보면, 조임 지점은 자연적인 캡슐화의 경계선 이라고 볼 수 있다.

즉, 조임 지점을 찾았다는 것은 클래스의 영향이 지나가는 통로를 발견한 것이다.

우리는 객체의 구성 정보보다는 통로에 집중하여 변경의 영향을 감지하려는 것이므로 캡슐화의 정의에 부합한다고 볼 수도 있다.

결론적으로 조임 지점을 고려하여 클래스 간의 책임을 어떻게 분배할 것인가에 대해 고민하고 이를 통해 더 나은 캡슐화 방법을 도출하여 설계에 이점을 가져올 수 있게된다.

3. 조임 지점의 함정(Pinch Point Traps)

단위 테스트를 작성하다보면, 점점 소규모 통합 테스트마냥 커지는 상황에 놓이게 된다.

A 클래스에 대해 단위 테스트를 작성하려고 했는데, A 클래스의 동작을 위해 B, C, D 등의 클래스가 필요하다고 해서 모든 동작을 점검하려고 할 때, 테스트가 점점 커짐을 느낄 수 있게 된다.

테스트가 커지게 되면 결국 피드백을 획득하기 위한 시간이 지연되어 단위 테스트를 하는 의미가 퇴색되기에 피할 수 있다면 피하는 것이 좋다.

따라서 새로운 코드의 단위 테스트를 작성할 때의 핵심은 가급적 독립적으로 클래스를 테스트하는 것이며, 너무 비대해진다 싶으면 클래스가 가진 책임을 분산(=클래스의 분리)을 해보는 것이 바람직하며, 위장 객체를 적극적으로 활용해야 한다.

반면 기존 코드의 단위 테스트를 작성할 때의 핵심은 클래스 하나를 대상으로 하지말고 애플리케이션의 일부를 테스트 루틴으로 커버하고, 마지막에는 조임 지점의 테스트 루틴을 제거하는 방향으로 진행해야 한다.

조임 지점의 테스트 루틴 작성을 통해, 해당 코드 영역을 완벽하게 숙지한 후 리팩토링과 테스트 루틴 작성을 병행하여야 한다.

최종적으로는 결국 조임 지점의 테스트 루틴을 삭제하여 각각의 클래스에 대한 단위 테스트를 사용해야 한다.

즉, 조임 지점은 끝이 아니라, 준비 단계 라고 볼 수 있다.