(Working Effectively with Legacy Code) 008. How Do I Add a Feature?

How Do I Add a Feature?

레거시 코드를 다룰 때 가장 고려해야하는 부분은 대부분의 코드가 테스트 코드를 가지고 있지 않다는 것이다.

심지어 테스트 코드를 추가하기에 적합한 구조나 위치를 가지고 있지 않기도 한다.

물론 앞선 포스팅에서 다뤘듯이, 기존 코드의 수정없이 테스트 루틴 없이 새로운 코드를 추가하는 방법도 있지만 많은 개선 효과를 가져오진 못할 것이다.

또한 추가된느 코드가 테스트되지않은 코드와 중복되는 경우 의도와는 다르게 결국 레거시 코드가 될 것이다.

1. Test-Driven Development

테스트 주도 개발(TDD : Test-Driven Development) 는 가장 강력한 기능 추가 기법이다.

테스트 주도 개발(이하 TDD)는 먼저 문제 해결을 위한 메서드를 생각하고, 이 메서드를 구현하기 전에 테스트 코드를 미리 작성하는 기법으로

앞으로 작성할 코드가 무엇을 구현할지 명확하게 이해하게 해준다.

TDD는 아래와 같은 알고리즘을 사용한다.

  1. 실패하는 테스트 케이스를 작성한다.
  2. 컴파일을 수행한다.
  3. 테스트를 통과한다.
  4. 중복을 제거한다.
  5. 1-4를 반복한다.

예제를 통해 알아보자.

어떤 금융 관련 프로그램이 있고, 1차 적률을 계산하는 기능이 요구된다고 하자.

우리가 지금 아는 것은 테스트 케이스의 결과값이 -0.5가 된다는 것이라고 가정한다.

1.1.1. Write a Failing Test Case

먼저 실패하는 테스트 케이스를 작성해보자.

1
2
3
4
5
6
public void testFirstMoment() {
InstrumentCalculator calculator = new InstrumentCalculator();
calculator.addElement(1.0);
calculator.addElement(2.0);
assertEquals(-0.5, calculator.firstMomentAbout(2.0), TOLERANCE);
}

1.1.2. Get It to Compile

1.1.1.의 테스트 코드는 컴파일되지않는다.

InstrumentCalculator 클래스의 firstMomentAbout() 메서드가 작성되지않았기 때문이다.

정상적인 컴파일을 위해 아래와 같이 메서드를 작성한다.

1
2
3
4
5
6
public class InstrumentCalculator {
double firstMomentAbout(double point) {
return Double.NaN;
}
// ...
}

이때 -0.5가 아닌 Double.NaN을 반환하기에 테스트는 무조건 실패한다.

1.1.3. Make It Pass

테스트 코드의 컴파일이 가능해졌으니, 이제 테스트를 통과하는 코드를 작성한다.

1
2
3
4
5
6
7
8
9
10
11
public class InstrumentCalculator {
public double firstMomentAbout(double point) {
double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += element - point;
}
return numerator / elements.size();
}
// ...
}

1.1.4. Remove Duplication

예제의 경우 중복 코드는 없지만, TDD 절차상 마지막으로 중복을 제거하는 작업을 수행해야 한다.

1.1.5. Repeat

여태까지와 같은 루틴을 반복해나간다.

1.2.1. Write a Failing Test Case

1.1.3. 에서 추가한 코드를 보자.

만약 반복문 안에서 증감이 아닌 0.0 으로 나눈다면 예외가 발생할 것이다.

혹은

elements 리스트가 비어있다면 아무런 동작도 하지 않을 것이다.

이를 대비하기 위한 테스트 코드로 수정해보자.

1
2
3
4
5
6
7
8
public void testFirstMoment() {
try {
new InstrumentCalculator().firstMomentAbout(0.0);
fail("expected InvalidBasisException")
} catch (InvalidBasisException e) {
// Exception Handling
}
}

1.2.2. Get It to Compile

이제 코드에서 InvalidBasisException를 발생시키도록 firstMomentAbout() 메서드를 수정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class InstrumentCalculator {
public double firstMomentAbout(double point) throws InvalidBasisException {
if (element.size() == 0) {
throw new InvalidBasisException("no elements");
}

double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += element - point;
}
return numerator / elements.size();
}
// ...
}

1.2.3. Make It Pass

여기까지 작업 후 테스트 루틴은 바로 통과할 수 있다.

1.2.4. Remove Duplication

이번 예제의 경우도 중복 코드는 없지만, TDD 절차상 마지막으로 중복을 제거하는 작업을 수행해야 한다.

1.2.5. Repeat

여태까지와 같은 루틴을 반복해나간다.

1.3.1. Write a Failing Test Case

이번엔 2차 적률을 계산하는 메서드를 추가한다고 가정해보자.

먼저 아직 존재하지 않는 메서드 secondMomentAbout() 에 대한 테스트 루틴을 작성한다.

1
2
3
4
5
6
7
8

public void testSecondMoment() throws Exception {
InstrumentCalculator calculator = new InstrumentCalculator();
calculator.addElement(1.0);
calculator.addElement(2.0);

assertEquals(0.5, calculator.secondMomentAbout(2.0), TOLERANCE);
}

1.3.2. Get It to Compile

이제 코드에서 secondMomentAbout()를 추가로 선언한다.

기존에 작성한 firstMomentAbout()를 복사하여 시그니쳐를 유지하면 편하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class InstrumentCalculator {

public double firstMomentAbout(double point) throws InvalidBasisException {
// ...
}

public double secondMomentAbout(double point) throws InvalidBasisException {
if (element.size() == 0) {
throw new InvalidBasisException("no elements");
}

double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += element - point; // TEST FAIL
}
return numerator / elements.size();
}
// ...
}

1.3.3. Make It Pass

2차 적률을 위한 로직 변경을 적용하여 테스트를 통과 시킨다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class InstrumentCalculator {

public double firstMomentAbout(double point) throws InvalidBasisException {
// ...
}

public double secondMomentAbout(double point) throws InvalidBasisException {
if (element.size() == 0) {
throw new InvalidBasisException("no elements");
}

double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += Math.pow(element - point, 2.0); // TEST SUCCESS
}
return numerator / elements.size();
}
// ...
}

코드를 그대로 복사하고 붙여넣기한 것이므로 복사한 부분을 삭제하면 바로 중복이 제거된다.

한 가지 명심해야할 점은 레거시 코드를 변경할 때, 기존 동작의 정상 동작 보장을 위한 측면에서

이 복사 후 붙여넣기가 굉장히 강력한 기법이라는 것이다.

1.3.4. Remove Duplication

firstMomentAbout() 메서드와 secondMomentAbout() 메서드의 로직은 매우 유사하므로 중복을 제거하는 것이 좋다.

아래와 같이 secondMomentAbout() 메서드의 본문 전체를 추출하여 nthMomentAbout() 라는 메서드를 신규 작성하여 중복을 제거할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class InstrumentCalculator {

private double nthMomentAbout(double point, double n) throws InvalidBasisException {
if (element.size() == 0) {
throw new InvalidBasisException("no elements");
}

double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += Math.pow(element - point, n);
}
return numerator / elements.size();
}

public double firstMomentAbout(double point) throws InvalidBasisException {
return nthMomentAbout(point, 1.0);
}

public double secondMomentAbout(double point) throws InvalidBasisException {
return nthMomentAbout(point, 2.0);
}

}

1.3.5. Repeat

여태까지와 같은 루틴을 반복해나간다.

1.4 Summary

시스템을 꾸준히 관리하기 위한 방법으론 TDD에 대해서 알아보았다.

TDD의 최대 장점 중 하나는 한 번에 하나의 작업에 집중할 수 있다는 것이다.

TDD 과정에서는 신규 코드와 리팩토링을 둘 중 하나에만 집중하여 작업을 수 있기때문이다.

또한 레거시 코드를 대상으로 작업하는 경우, 기존 코드에 대한 독립성을 유지하면서 신규 코드를 작성할 수 있다.

레거시 코드가 대상일 때 TDD를 작성하는 절차는 아래와 같다.

  1. 변경 대상 클래스를 테스트 루틴으로 보호한다.
  2. 실패 테스트 케이스를 작성한다.
  3. 컴파일을 수행한다.
  4. 테스트를 통과시키도록 수정한다. (기존 코드는 최대한 변경하지 않는다)
  5. 중복을 제거한다.
  6. 1-5 과정을 반복한다.

2. Programming by Difference

1. Test-Driven Development 챕터의 예제 코드를 보다보면 이 예제가 절차지향적 코드라는 것을 눈치챌 수 있다.

객체지향 프로그래밍의 경우 상속 이라는 선택지를 이용하는 방법이 하나 더 존재한다.

상속을 이용함으로써 변경 대상인 클래스를 직접 수정하지않으면서 기능을 추가하고, 추가한 기능을 어떻게 통합할지 파악해나가는 것인데, 이를 차이에 의한 프로그래밍(Programming by Difference) 라고 한다.

이번에도 예제를 통해 살펴보자.

메일링 리스트를 관리하는 자바 기반의 프로그램이 있다고 가정한다.

1
2
3
4
5
6
7
private InternetAddress getFromAddress(Message message) throws MessagingException {
Address [] from = message.getFrom ();
if (from != null && from.length > 0) {
return new InternetAddress (from[0].toString());
}
return new InternetAddress (getDefaultFrom());
}

getFromAddress() 메서드는 인자로 주어진 message 객체로부터 보낸 사람의 이메일 주소를 추출해 반환하는 동작을 수행한다.

getFromAddress() 메서드를 호출하는 곳은 아래 한 곳이라고 가정한다.

1
2
MimeMessage forward = new MimeMessage (session);
forward.setFrom (getFromAddress (message));

이 상태에서 익명 수신자 기능이라는 새로운 요구사항이 발생할 경우 어떻게 해야할까?

먼저 실패를 위한 테스트 케이스를 작성해보자.

1
2
3
4
5
public void testAnonymous() throws Exception { 
MessageForwarder forwarder = new MessageForwarder();
forwarder.forwardMessage(makeFakeMessage());
assertEquals("anon-members@" + forwarder.getDomain(), expectedMessage.getFrom()[0].toString());
}

MessageFowarder 클래스는 보낸사람의 정보가 들어있는 클래스이며, 객체인 forwarder에서 특정 이메일 주소를 보낸 사람 정보에 세팅할 수 있도록 처리되어있어야 한다.

위의 테스트 코드를 실행하면 forwarder 객체에서 포워딩하는 메시지가 expectedMessage 변수에 설정된다.

익명 수신자 기능을 추가하면서 MessageFowarder 클래스를 수정하지않기 위해 AnonymousMessageForwarder 클래스를 작성하고, 이 클래스를 테스트에 사용하면 된다.

AnonymousMessageForwarder 클래스를 테스트 코드에 적용하면 아래와 같다.

1
2
3
4
5
public void testAnonymous() throws Exception {
MessageForwarder forwarder = new AnonymousMessageForwarder();
forwarder.forwardMessage(makeFakeMessage());
assertEquals("anon-members@" + forwarder.getDomain(), expectedMessage.getFrom()[0].toString());
}

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

Figure 8.1

MessageFowarder 클래스의 getFromAddress() 메서드는 private에서 protected로 선언되었음을 확인할 수 있으며, 이를 MessageFowarder 클래스를 상속받은 AnonymousMessageForwarder 클래스에서 아래와 같이 오버라이딩한다.

1
2
3
4
protected InternetAddress getFromAddress(Message message) throws MessagingException {
String anonymousAddress = "anon-" + listAddress;
return new InternetAddress(anonymousAddress);
}

문제는 해결하였지만, 간단한 동작 수정을 위해 신규 클래스를 새로 작성해야만 했다.

단순 동작의 추가를 위해 기존에 존재하는 클래스를 전부 상속하는 것은 해결 방법이긴 하지만 장기적으로 권장되지않는다.

따라서 테스트를 일단 빨리 통과하는 데 목적을 두고 전부 상속해서 통과시킨 후, 통과 이후에 코드 설계를 변경하도록 해야하며 설계를 변경하는 과정에서 새로 추가된 동작의 정상 동작 여부를 작성한 테스트 루틴으로 보장할 수 있게된다.

1
2
3
4
5
public void testAnonymous() throws Exception {
MessageForwarder forwarder = new AnonymousMessageForwarder();
forwarder.forwardMessage(makeFakeMessage());
assertEquals("anon-members@" + forwarder.getDomain(), expectedMessage.getFrom()[0].toString());
}

위에서 작업한 이 방식이 간단하긴 하지만, 새로운 요청 사항을 대응하다보면 코드 품질 저하의 원인이 될수도 있다.

이번엔 메일링 리스트에 없는 사람에게도 숨은 참조 형태로 보내는 기능을 추가해보도록 하자.

이 수신자들은 off-list 라고 부르도록 하겠다.

익명 수신자때처럼 새로운 클래스를 작성하여 처리하면 될까?

아래 UML을 보자.

Figure 8.2

이 구조로도 동작은 하겠지만 off-list 수신자와 익명 포워딩을 둘 다 사용하는 메시지가 있는 경우 구멍이 존재한다.

이는 상속을 너무 많이 사용하는 경우 나타나는 문제로, 기능이 서로 다른 서브클래스에 나눠서 추가할 경우 한 번에 하나의 기능만 사용할 수 있기 때문이다.

이를 해결하기위해 먼저 메시지의 옵션을 설정할 수 있도록 Properties 클래스를 작성하자.

1
2
3
Properties configuration = new Properties(); 
configuration.setProperty("anonymous", "true");
MessageForwarder forwarder = new MessageForwarder(configuration);

MessageForwarder 클래스의 생성자에 Properties 클래스의 객체를 파라미터로 넘기도록 처리한다.

위와 같은 변경을 적용하더라도

1
2
3
4
5
public void testAnonymous() throws Exception {
MessageForwarder forwarder = new AnonymousMessageForwarder();
forwarder.forwardMessage(makeFakeMessage());
assertEquals("anon-members@" + forwarder.getDomain(), expectedMessage.getFrom()[0].toString());
}

위의 테스트 루틴은 여전히 통과가 가능하다.

기존 테스트 루틴이 통과됨을 확인하였으니 MessageForwarder 클래스의 getFromAddress() 메서드를 아래와 같이 변경해보자.

1
2
3
4
5
6
7
8
9
10
11
12
private InternetAddress getFromAddress(Message message) throws MessagingException {
String fromAddress = getDefaultFrom();
if (configuration.getProperty("anonymous").equals("true")) {
fromAddress = "anon-members@" + domain;
} else {
Address [] from = message.getFrom ();
if (from != null && from.length > 0) {
fromAddress = from[0].toString();
}
}
return new InternetAddress(fromAddress);
}

이제 MessageForwarder 클래스의 getFromAddress() 메서드는 일반 사용자와 익명 사용자를 모두 처리할 수 있다.

1
2
3
4
5
6
7
8
public class AnonymousMessageForwarder extends MessageForwarder {

// protected InternetAddress getFromAddress(Message message) throws MessagingException {
// String anonymousAddress = "anon-" + listAddress;
// return new InternetAddress(anonymousAddress);
// }

}

위와 같이 AnonymousMessageForwarder 클래스의 getFromAddress() 메서드를 제거해도 테스트 루틴을 통해 기존 동작도 이상없음을 확인할 수 있다.

이제 AnonymousMessageForwarder 클래스는 필요없으므로 삭제해도 무방하다.

다음으로 MessageForwarder 클래스의 getFromAddress() 메서드를 아래와 같이 리팩토링한다.

1
2
3
4
5
6
7
8
9
private InternetAddress getFromAddress(Message message) throws MessagingException {
String fromAddress = getDefaultFrom();
if (configuration.getProperty("anonymous").equals("true")) {
from = getAnonymousFrom();
} else {
from = getFrom(Message);
}
return new InternetAddress(from);
}

조금은 깔끔해졌지만, 익명 포워딩과 off-list 수신자 기능이 MessageForwader 클래스 내에 위치하므로 단일 책임 원칙에 위배됨을 인지할수도 있다.

이를 해소하기 위해 MailingConfiguration 클래스를 새로 작성하고 이 클래스에서 옵션 속성들을 관리하도록 처리하려면 아래 UML 대로 작업하면 된다.

Figure 8.3

다소 과도해보일 수 있지만 옵션 속성의 개수가 늘어나게 되면 조건문도 비례해서 늘어나므로 이를 대비하기 위한 설계라고 생각하면 합리적이다.

이후 MessageForwarder 클래스의 getFromAddress() 메서드를 MailingConfiguration 클래스로 옮기면 어떻게 될까?

MailingConfiguration 클래스는 메시지를 수신한 후 반환해야할 보낸 사람 주소를 결정하게 된다.

옵션이 익명으로 설정되어있다면 익명 사용자의 보낸 사람 주소를 반환하고, 그렇지않다면 메시지의 보낸 사람 주소를 반환할 것이다.

이 설계는 속성값을 얻거나 설정하는 메서드가 필요없어진다는 데 의의가 있다.

UML로 살펴보면 아래와 같다.

Figure 8.4

이번엔 MailingConfiguration 클래스에 off-list 수신자 관련 기능을 추가해보자.

buildRecipientList() 라는 이름으로 메서드를 추가하면 아래와 같은 UML을 가지게 된다.

Figure 8.5

여기까지 진행하고나면 MailingConfiguration 라는 클래스명의 정체성이 조금 애매해진다.

Configuration(설정) 이라는 단어는 원래 수동적인 의미를 가지고 있으나, 실제 클래스의 동작은 능동적으로 데이터를 생성하고 수정한다.

따라서 기존에 존재하는 클래스와 이름이 중복되는 일이 없다면 MailingList 라는 이름을 가지는 것이 적절하다.

이름을 바꾸게되면 MessageForwarder 클래스는 MailingList 클래스에 대해 보낸 사람 주소를 추출하거나

수신자 목록을 생성하도록 요청만 하게 된다.

이 요청에 대한 메시지는 어떻게 수정할지에 대한 책임은 MailingList 클래스에 귀속된다.

바꾼 설계의 UML은 아래와 같다.

Figure 8.6