(Working Effectively with Legacy Code) 006. I Don’t Have Much Time and I Have to Change It

I Don’t Have Much Time and I Have to Change It

코드 변경을 위해 의존 관계를 제거하고 테스트 루틴을 작성하는 일은 개발자의 리소스를 많이 잡아먹는다.

하지만 결과적으로 개발 시간과 시행착오를 줄여주는 데 그 의의가 있다.

테스트 루틴의 작성을 통해 오류를 빠르게 포착하고 오류 탐색에 드는 비용도 줄일 수 있기 때문이다.

결론적으로, 테스트 루틴의 사용은 개발 속도를 높이는 데 도움을 준다.

테스트 루틴의 작성을 의무화하고 고려해야할 점은 테스트 루틴을 작성하기 위한 상황을 분류하고 작성에 필요한 비용을 산정하는 것이며, 여기에 가장 문제가 되는 것이 기능 구현에 걸리는 시간을 알 수 없다는 것이다.

특히 레거시 코드의 경우 정밀한 시간 비용 산정이 더더욱 어려워진다.

당장 변경해야하는 클래스가 있다면 테스트 하네스 내에서 해당 클래스의 객체를 생성하고 의존 관계를 끊어내는 연습을 시도하도록 하자.

1. Sprout Method

시스템에 새로운 기능을 추가해야하는 데, 이 기능을 완전히 새로운 코드로 표현할 수 있다면

신규 메서드로 구현한 후, 해당 메서드를 필요한 위치에서 호출하는 방법이 있다.

호출을 수행하는 코드를 테스트 루틴으로 보호하기는 어렵더라도, 최소한 새로운 코드에 대한 테스트 루틴을 작성할 수 있다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
public class TransactionGate {
// ...
public void postEntries(List entries) {
for (Iterator it = entries.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
entry.postDate();
}
transactionBundle.getListManager().add(entries);
}
// ...
}

postEntries() 메서드는 각 entry마다 날짜를 설정한 뒤 이를 transactionBundle 객체에 저장한다.

이때 기존에 transactionBundle 객체에 이미 존재하는 지 검증하는 코드를 아래와 같이 추가할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TransactionGate {
// ...
public void postEntries(List entries) {
List entriesToAdd = new LinkedList();
for (Iterator it = entries.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
if (!transactionBundle.getListManager().hasEntry(entry) {
entry.postDate();
entriesToAdd.add(entry);
}
}
transactionBundle.getListManager().add(entriesToAdd);
}
// ...
}

단순히 검증 후 저장하는 로직을 추가했을 뿐이지만 두 가지 문제점이 발생한다.

첫 번째 문제점은 날짜의 설정과 중복 여부 검증이라는 두 개의 기능이 섞여버렸다는 점.

두 번째 문제점은 임시 변수가 생겼다는 점이다.

좀 더 다른 변경 작업으로 처리하려면 새로 추가할 기능인 중복 여부 검증을 포함하는 새로운 메서드를 작성하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TransactionGate {
// ...
List uniqueEntries(List entries) {
List result = new ArrayList();
for (Iterator it = entries.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
if (!transactionBundle.getListManager().hasEntry(entry) {
result.add(entry);
}
}
return result;
}
// ...
}

uniqueEntries()라는 새로운 메서드를 작성하여 중복 여부가 검증된 List를 반환하는 새로운 메서드를 작성하였다.

이 메서드를 기존의 postEntries()에 추가해보자.

1
2
3
4
5
6
7
8
9
10
11
12
public class TransactionGate {
// ...
public void postEntries(List entries) {
List entriesToAdd = uniqueEntries(entries); // here
for (Iterator it = entriesToAdd.iterator(); it.hasNext(); ) {
Entry entry = (Entry)it.next();
entry.postDate();
}
transactionBundle.getListManager().add(entriesToAdd);
}
// ...
}

임시 변수 entriesToAdd가 추가되긴 했지만, postEntries()의 기존 동작을 보장할 수 있게 되었다.

지금까지 설명한 것이 발아 메서드(Sprout method) 의 예시이다.

발아 메서드의 작성 순서는 아래와 같다.

  1. 어느 부분에 코드 변경이 필요한지 식별한다.
  2. 메서드 내의 특정 위치에서 일련의 명령문으로 구현할 수 있다면, 신규 메소드를 호출하는 코드를 작성한 후 주석 처리한다.
  3. 호출되는 메서드가 필요로 하는 지역 변수를 확인하고, 이 변수들을 신규 메서드의 인자로 전달한다.
  4. 호출하는 메서드가 값을 반환해야하는 지 결정하고, 반환시 변수를 추가하도록 코드를 변경한다.
  5. 새롭게 추가되는 메서드를 테스트 주도 개발 방법을 사용하여 작성한다.
  6. 2번에서 주석처리했던 신규 메소드 호출 코드의 주석을 제거한다.

독립된 한 개의 기능으로서 코드를 추가하거나 테스트 루틴이 아직 준비되지않은 경우 발아 메서드의 사용이 권장된다.

만약 클래스의 의존 관계가 너무 복잡하여 많은 인자없이 인스턴스를 생성할 수 없는 경우 Null 값을 전달하거나 public static 과 같은 형태로 선언하는 것을 고려하면 된다.

1.1. Advantages and Disadvantages

발아 메서드는 변경 대상인 메서드와 클래스를 잠시 내려놓고, 새로운 메서드로 새로운 기능을 추가하는 행위이다.

이로 인해 코드의 의도를 이해하기 힘들어지거나, 기존 메소드가 완전하지않은(=만들다 만 것 같은) 상태로 보일 수 있다.

결국 원래의 메서드와 클래스에 대해 추가적인 작업을 하여 테스트 루틴으로 보호해야함을 말한다.

그럼에도 불구하고 발아 메서드는 기존 코드와 새로운 코드를 확실하게 구분할 수 있으며, 최소한 변경 부분에 대해 개별적으로 이해할 수 있다.

또한 기존 코드와 신규 코드 사이의 인터페이스도 분명해지며, 영향을 받는 변수들을 모두 파악할 수 있어 코드의 정확성에 대한 판단이 용이해진다.

2. Sprout Class

의존 관계가 복잡한 경우 인스턴스의 생성이 어렵거나 하는 이유로 발아 메서드로 풀어낼 수 없는 경우가 존재한다.

이를 해결하기 위해 의존 관계를 끊어내는 리팩토링은 많은 시간과 비용을 요구한다.

이런 경우 변경에 필요한 기능을 별도의 클래스로 추출하여 테스트 루틴을 작성하는 방법이 있다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
std::string QuarterlyReportGenerator::generate() {
std::vector<Result> results = database.queryResults( beginDate, endDate);
std::string pageText;

pageText += "<html><head><title>"
"Quarterly Report"
"</title></head><body><table>";

if (results.size() != 0) {
for (std::vector<Result>::iterator it = results.begin(); it != results.end(); ++it) {
pageText += "<tr>";
pageText += "<td>" + it->department + "</td>";
pageText += "<td>" + it->manager + "</td>";

char buffer[128];
sprintf(buffer, "<td>$%d</td>", it->netProfit / 100);
pageText += std::string(buffer);
sprintf(buffer, "<td>$%d</td>", it->operatingExpense / 100);
pageText += std::string(buffer);
pageText += "</tr>";
}
} else {
pageText += "No results for this period";
}
pageText += "</table>";
pageText += "</body>";
pageText += "</html>";
return pageText;
}

이 코드에서 생성하는 HTML 테이블에 아래와 같은 헤더를 추가하는 경우를 생각해보자.

1
"<tr><td>Department</td><td>Manager</td><td>Profit</td><td>Expenses</td></tr>"

이 변경은 QuarterlyReportTableHeaderProducer 라는 클래스로 아래와 같이 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
using namespace std;

class QuarterlyReportTableHeaderProducer {
public:
string makeHeader();
};

string QuarterlyReportTableProducer::makeHeader() {
return "<tr><td>Department</td><td>Manager</td><td>Profit</td><td>Expenses</td></tr>";
}

이후 QuarterlyReportGenerator::generate() 메서드에서 QuarterlyReportTableProducer 클래스의 객체를 생성하여 직접 호출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
std::string QuarterlyReportGenerator::generate() {
std::vector<Result> results = database.queryResults( beginDate, endDate);
std::string pageText;

pageText += "<html><head><title>"
"Quarterly Report"
"</title></head><body><table>";

QuarterlyReportTableProducer producer;
pageText += producer.makeHeader();

if (results.size() != 0) {
for (std::vector<Result>::iterator it = results.begin(); it != results.end(); ++it) {
pageText += "<tr>";
pageText += "<td>" + it->department + "</td>";
pageText += "<td>" + it->manager + "</td>";

char buffer[128];
sprintf(buffer, "<td>$%d</td>", it->netProfit / 100);
pageText += std::string(buffer);
sprintf(buffer, "<td>$%d</td>", it->operatingExpense / 100);
pageText += std::string(buffer);
pageText += "</tr>";
}
} else {
pageText += "No results for this period";
}
pageText += "</table>";
pageText += "</body>";
pageText += "</html>";
return pageText;
}

이 변경을 위해 새로운 클래스를 만드는 것이 불합리하게 느껴질 수도 있다.

의존 관계를 좀 더 느슨하게 하기 위해 아래와 같이 인터페이스를 갖도록 해보자.

1
2
3
4
class QuarterlyReportTableHeaderGenerator {
public:
string generate();
};

위의 인터페이스를 구현하도록 코드를 변경하여 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class HTMLGenerator {
public:
virtual ~HTMLGenerator() = 0;
virtual string generate() = 0;
};

class QuarterlyReportTableHeaderGenerator : public HTMLGenerator {
public:
// ...
virtual string generate();
// ...
};

class QuarterlyReportGenerator : public HTMLGenerator {
public:
// ...
virtual string generate();
// ...
};

이제 QuarterlyReportGenerator 클래스를 테스트 루틴을 통해 보호하고 대부분의 동작은 HTML을 생성하는 클래스들이 처리할 수 있도록 설계가 변경되었다.

이처럼 발아 클래스(Sprout Class) 를 작성하게 되는 경우는 크게 두 가지이다.

첫 번째는 어떤 클래스에 완전히 새로운 역할을 추가하고 싶은 경우,

두 번째는 기존 클래스에 약간의 기능을 추가하고 싶은데, 기존 클래스가 테스트 하네스 내에서 테스트할 수 없는 경우이다.

발아 클래스는 아래와 같은 순서로 작성한다.

  1. 어느 부분에 코드 변경이 필요한지 식별한다.
  2. 메서드 내의 특정 위치에서 일련의 명령문으로 구현할 수 있다면, 변경을 구현할 클래스를 작성하고 해당 위치에 객체를 생성한다.
  3. 신규 클래스 내의 메소드를 호출하는 코드를 작성한 후 주석 처리한다.
  4. 호출되는 메서드가 필요로 하는 지역 변수를 확인하고, 이 변수들을 클래스의 생성자가 호출될 때 인자로 전달한다.
  5. 발아 클래스가 호출 메서드에 결과값을 반환해야하면, 결과값을 반환할 메서드를 추가한 뒤, 해당 메서드를 호출하는 코드를 추가 작성한다.
  6. 새로운 클래스를 테스트 주도 개발로 작성한다.
  7. 3번에서 주석처리했던 객체 생성과 메서드 호출 코드의 주석을 제거한다.

2.1. Advantages and Disadvantages

발아 클래스는 보다 확신을 가지고 코드의 변경을 수행할 수 있다는 장점이 있다.

다만, 복잡한 매커니즘을 가지고 있어 개발자의 이해를 어느 정도 필요로 한다.

예를 들어 개발자가 새로운 프로젝트에 투입되어 코드리딩을 수행할 때는, 주요 클래슫르이 어떻게 의존 관계를 가지는 지에 중점을 두면서 익혀나가는 것에 반해

발아 클래스는 클래스의 선언부가 추상적이거나, 다른 클래스의 처리 부분으로 이루어지기때문에 상대적으로 이해하기가 어렵다.

3. Wrap Method

기존 메서드에 동작을 추가하는 것은 간단하다.

문제가 되는 건 이 “동작의 추가”가 과연 옳은 접근법인가? 에 대한 고민이 필요하다는 것이다.

메서드를 최초로 작성할 때에는 분명히 “하나의 메서드”가 “하나의 동작”을 보장하기 위한 설계를 가지고 있었을 것이다.

허나, 동시에 어떤 동작을 수행하기 위해 코드의 추가가 빈번하게 일어날 수 있는데 이를 일시적 결합(temporal coupling) 이라고 불렀다.

일시적 결합이 과도하게 사용되면 전반적인 코드의 품질이 낮아질 수 밖에 없다.

두 개의 동작이 같은 시점에 실행되어야할 뿐이지, 두 동작이 어떤 연관성을 가지고 있지않는 경우가 대다수이기 때문이다.

추후 두 개의 동작을 하나의 메서드에서 분리하기위해선 봉합 기법을 수행해야 한다.

참고 The Seam Model 포스팅

예상되는 문제점을 파악했으니, 이번엔 동작을 추가할 때 사용되는 기법들에 대해서 알아보자.

먼저 포장 메서드(wrap method) 라고 불리는 방법이다.

아래 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Employee {
// ...
public void pay() {
Money amount = new Money();
for (Iterator it = timecards.iterator(); it.hasNext(); ) {
Timecard card = (Timecard)it.next();
if (payPeriod.contains(date)) {
amount.add(card.getHours() * payRate);
}
}
payDispatcher.pay(this, date, amount);
}
}

pay() 메서드는 급여 정보를 payDispatcher 객체로 전달하는 동작을 수행한다.

여기에 새로운 요구사항으로 직원 이름으로 파일을 갱신하여 보내기 위해 pay() 메서드를 수정해야한다고 가정해보자.

아래와 같이 파일 처리한 경우를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Employee {
private void dispatchPayment() {
Money amount = new Money();
for (Iterator it = timecards.iterator(); it.hasNext(); ) {
Timecard card = (Timecard)it.next();
if (payPeriod.contains(date)) {
amount.add(card.getHours() * payRate);
}
}
payDispatcher.pay(this, date, amount);
}

public void pay() {
logPayment();
dispatchPayment();
}

private void logPayment() {
// ...
}
}

pay() 메서드의 로직을 private 접근 제어자를 가진 dispatchPayment() 메서드로 이전한 후,

pay() 메서드는 logPayment() 메서드를 통해 지불할 금액을 기록하고 지불 정보를 payDispatcher 객체로 전달한다.

기존에 pay()를 호출하는 코드가 있었다면, 이러한 변경사항을 알지 못하여도 기존의 동작이 보장될 것이다.

위와 같은 형태가 포장 메서드의 한 가지 형태로 기존 메서드와 이름이 같은 메서드를 생성하고, 기존 코드에 처리를 위엄한다.

또 다른 포장 메서드의 형태로 기존에 호출된 적이 없는 새로운 메서드를 추가하는 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Employee {
public void makeLoggedPayment() {
logPayment();
pay();
}

public void pay() {
// ...
}

private void logPayment() {
// ...
}
}

위의 예제에서 추가한 기록 작업을 좀 더 명시적으로 표현하기 위해 makeLoggedPayment() 메서드를 추가하였다.

포장 메서드는 새로운 기능을 추가하면서 봉합 지점을 특정할 수 있는 좋은 방법이다.

그러나 포장 메서드는 새롭게 추가된 기능은 기존 기능의 이전 혹은 이후에만 수행되어야 한다는 단점이 있다.

또한 메서드가 추가되는 만큼 새로운 이름을 고안해야한다는 점이다.

포장 메서드를 작성하는 단계는 다음과 같다.

  1. 변경해야할 메서드를 식별한다.
  2. 변경이 메서드 내의 특정 위치에서 일련의 명령문으로 구현할 수 있다면 메서드의 이름을 적절하게 바꾼다.
  3. 변경 전의 기존 메서드와 동일한 이름과 시그니처를 가진 메서드를 새로 작성한다.
  4. 새로운 메서드에서 기존 메서드를 호출하도록 처리한다.
  5. 새로운 기능을 위한 메서드를 테스트 주도 개발을 통해 작성한다.
  6. 변경한 메서드를 3에서 작성한 신규 메서드에서 호출한다.

좀 더 명시적인 메서드를 추가하는 경우엔 아래와 같은 절차를 수행한다.

  1. 변경해야할 메서드를 식별한다.
  2. 변경이 메서드 내의 특정 위치에서 일련의 명령문으로 구현할 수 있다면 변경을 구현할 메서드를 테스트 주도 개발에 의해 새로 작성한다.
  3. 새로운 메서드와 기존 메서드를 호출하는 별도의 메서드를 작성한다.

3.1. Advantages and Disadvantages

호출하는 코드에 대한 테스트 루틴을 작성하기가 어려울 경우, 포장 메서드는 테스트가 끝난 신규 기능을 애플리케이션에 추가할 수 있는 좋은 방법이다.

코드의 길이가 불가피하게 증가하는 발아 메서드나 발아 클래스와 달리 포장 메서드 기법은 기존 메서드의 길이를 보장할 수 있다.

또한 신규 기능과 기존 기능이 명시적으로 분리되어, 각 코드가 해당 목적을 독립적으로 유지할 수 있다.

반면 새로운 이름을 가진 메서드를 추가하는 만큼 오히려 부적절한 이름을 가진 메서드가 추가될 우려가 있다.

4. Wrap Class

이번엔 포장 클래스에 대해서 알아보자.

포장 메서드를 클래스 수준으로 확장한 것이 포장 클래스로 거의 유사한 컨셉트를 가지고 있다.

시스템에 동작을 추가하는 경우 그 동작을 기존 메서드에 추가할 수도 있지만, 그 메서드를 사용하는 다른 클래스에 추가할 수도 있다.

아래의 에제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Employee {
public void pay() {
Money amount = new Money();
for (Iterator it = timecards.iterator(); it.hasNext(); ) {
Timecard card = (Timecard)it.next();
if (payPeriod.contains(date)) {
amount.add(card.getHours() * payRate);
}
}
payDispatcher.pay(this, date, amount);
}
// ...
}

이번엔 특정 지원에게 급여를 지급한 사실을 기록하는 기능을 추가한다고 가정하자.

가장 간단한 방법은 구현체 추출 혹은 인터페이스 추출 기법을 사용해 포장 클래스가 인터페이스를 구현하도록 하는 것이다.

아래 코드는 구현체 추출 기법을 사용한 예시이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class LoggingEmployee extends Employee {
public LoggingEmployee(Employee e) {
employee = e;
}

public void pay() {
logPayment();
employee.pay();
}

private void logPayment() {
// ...
}
//...
}

새롭게 만든 LoggingEmployee 클래스는 생성자로 전달받은 Employee 객체를 이용해 pay() 메서드를 호출하고,

별도의 기록 메서드 logPayment() 를 호출하여 두 동작을 동시에 수행한다.

이러한 기법을 데코레이터 패턴(decorator pattern) 이라고 부른다.

pay() 메서드처럼 코드 내의 많은 위치에서 호출되는 메서드의 경우, 데코레이터 패턴은 신규 기능을 추가하기 좋은 방법이다.

이번엔 다른 포장 기법을 알아보자.

위의 pay() 메서드에 대한 호출시, 여러 곳이 아닌 단 한 곳에서만 기록 동작을 수행하는 경우를 생각해보자.

이때는 데코레이터 패턴보다는 별도의 클래스를 이용해 기록하는 방법을 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class LoggingPayDispatcher {
private Employee e;

public LoggingPayDispatcher(Employee e) {
this.e = e;
}

public void pay() {
employee.pay();
logPayment();
}

private void logPayment() {
// ...
}
// ...
}

위와 같이 별도의 클래스로 빼두면 기록이 필요한 위치에서 LoggingPayDispatcher 객체를 생성하면 된다.

포장 클래스의 핵심은 신규 동작을 기존 클래스에 추가하지 않으면서 전체 시스템에 추가할 수 있다는 점이다.

만약 메서드를 호출하는 곳이 많을 경우 데코레이터 패턴이 효과적이며,

적을 경우엔 포장 클래스가 더욱 효과적이다.

중요한 것은 포장 클래스를 시스템의 상위 수준 개념으로 만들 수 있는지 검증하는 것이다.

포장 클래스를 적용하는 순서는 아래와 같다.

  1. 변경해야할 코드 위치를 식별한다.
  2. 변경이 코드 내의 특정 위치에서 일련의 명령문으로 구현할 수 있다면 해당 클래스를 인자로 받는 클래스를 작성한다.
  3. 클래스를 추출하기 어려울 경우, 구현체 추출 혹은 인터페이스 추출 기법을 사용한다.
  4. 테스트 주도 개발 방법을 사용해서 포장 클래스에 새로운 처리를 수행하는 메서드를 작성한다.
  5. 메서드를 하나 더 추가로 작성한 후, 이 메서드에서 신규 메서드 및 기존 클래스 내의 기존 메서드를 호출한다.
  6. 새로운 동작이 수행될 위치에서 포장 클래스의 객체를 생성한다.

포장 클래스는 아래 두 가지 경우에 사용을 고려하면 된다.

  1. 추가하려는 동작이 완전히 독립적이며, 구현에 의존적인 동작이나 관련없는 동작으로 기존 클래스를 오염시키고 싶지않을 경우
  2. 클래스가 비대해져서 더 이상 키우고 싶지 않는 경우

5. Summary