(Working Effectively with Legacy Code) 004. The Seam Model

The Seam Model

테스트 주도 개발을 아니라면 대부분의 개발자들은 테스트 코드를 작성하면서, 기존 코드가 테스트 코드를 작성하기 어렵게 작성되었는지에 대해 알게된다.

현실적으로 일정에 쫓겨 해피케이스의 동작만 일단 잘 돌아가도록 개발했다면 더더욱 그럴 것이다.

테스트하기 편리한 코드를 작성하는 방법은, 개발 과정에서 테스트 코드 작성을 병행하거나

테스트를 용이하게 지원가능한 설계에 많은 시간을 투자하는 것이다.

이번 포스팅에서는 테스트 코드를 작성하기 용이한 관점으로 코드를 바라보고,

기존에 작성된 코드에 대해 편집없이 동작을 변경할 수 있는 봉합 모델(Seam Model) 에 대해서 알아보도록 하자.

A Huge Sheet of Text

우리는 일반적으로 재사용이 가능하도록 프로그램을 작은 조각들로 쪼개서 개발하는 것이 좋다고 들어왔다.

하지만 작게 쪼갠 모듈이나 코드 조각들은 생각과 달리 재사용하는 것이 어려운 경우가 많다.

이는 재사용이라는 것 자체가 어렵기도 하고, 소프트웨어의 내부의 의존 관계를 끊어내는 것이 쉽지않기때문이다.

Seams

단위 테스트를 위해 클래스를 각각 추출하기 위해선 수많은 의존 관계를 끊어내야한다.

아무리 좋은 설계를 적용하더라도 이를 해결하기란 쉽지않은데, 예제를 통해 한 번 이해해보자.

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
bool CAsyncSslRec::Init() {
if (m_bSslInitialized) {
return true;
}

m_smutex.Unlock();
m_nSslrefcount++;
m_bSslInitialized = true;
FreeLibrary(m_hSslDll1);
m_hSslDll1 = 0;
FreeLibrary(m_hSslDll2);

m_hSslDll2 = 0;

if (!m_bFailureSent) {
m_bFailureSent = TRUE;
PostReceiveError(SOCKETCALLBACK, SSL_FAILURE);
}

CreateLibrary(m_hSslDll1, "syncesel1.dll");
CreateLibrary(m_hSslDll2, "syncesel2.dll");

m_hSslDll1 -> Init();
m_hSslDll2 -> Init();

return true;
}

위의 코드에서

1
PostReceiveError(SOCKETCALLBACK, SSL_FAILURE);

위의 코드만 제거하고 실행하고 싶다면, 해당 코드만 제거하면 된다.

이때 조건을 좀 더 추가해보자.

위의 코드 블럭을 제거하고 싶은 이유는 PostReceiveError() 메서드가 다른 서브 시스템과 통신하는 전역 함수이기 때문에,

해당 서브 시스템을 테스트하기가 매우 힘들기 때문이라고 가정하자.

정리하자면, 실제 운영시에는 PostReceiveError()메서드를 호출해야하고 테스트할 때만 호출을 제외하고 CAsyncSslRec::Init() 메서드를 호출할 수 있어야 한다.

다양한 해결책 중 봉합(Seam) 을 사용해보자.

먼저 봉합(Seam) 에 대해 좀 더 명확히 정의해보면 아래와 같다.

**봉합(Seam)**은 코드를 직접 편집하지 않고도 프로그램의 동작을 변경할 수 있는 포인트를 말한다.

자 이제 PostReceiveError() 메서드를 호출할 수 있는 곳을 봉합해보자.

첫 번째 방법

PostReceiveError() 메서드는 전역 함수로서 CAsyncSslRec 클래스의 멤버가 아니다.

이때 동일한 시그니처를 갖는 메서드를 선언해보자.

1
2
3
4
5
class CAsyncSslRec {
// ...
virtual void PostReceiveError(UINT type, UINT errorCode);
// ...
}

구현부는 아래와 같이 처리한다.

1
2
3
void CAsyncSslRec::PostReceiveError(UINT type, UINT errorCode) {
::PostReceiveError(type, errorCode);
}

이렇게 코드를 변경하면 기존에 동작은 보장이 된다.

두 번째 방법

이번엔 서브클래스를 작성하여 오버라이딩하는 방법을 사용해보자.

먼저 서브클래스 TestingAsyncSslRec를 작성해보자.

1
2
3
4
5
class TestingAsyncSslRec : public CAsyncSslRec {
virtual void PostReceiveError(UINT type, UINT errorCode) {
// ...
}
}

이후 CAsyncSslRec 객체 생성시 TestingAsyncSslRec를 대신 생성하여, 호출을 실질적으로 무효화할 수 있다.

위와 같은 방법을 객체 봉합(Object seams) 이라고 부르며, 호출하는 코드를 변경하지않고 호출되는 메서드만 변경할 수 있다.

객체 봉합 기법은 객체 지향 언어에서 사용할 수 있으며, 이외에도 다양한 봉합 방법이 존재한다.

레거시 코드를 테스트할 때, 가장 큰 문제점 중 하나는 의존 관계를 제거하는 것으로 봉합의 관점에서 바라보면 코드에 포함되어 있는 의존 관계를 끊기 위한 단서를 얻을 수 있다.

Seam Types

이번엔 봉합의 종류에 대해서 알아보자.

봉합은 절차지향 언어냐 객체지향 언어냐에 따라 종류가 다르며,

봉합의 종류를 완벽하게 이해하려면 소스 코드가 시스템에서 실행되는 코드로 변환되는 단계를 추종하는 것이다.

각 단계를 따라가다보면 단계마다 새로운 종류의 봉합을 발견할 수 있을 것이다.

1. Preprocessing Seams (전처리 봉합)

대부분의 프로그래밍 환경에서 프로그램 소스 코드는 컴파일러 입력된다.

이후 컴파일러가 오브젝트 코드 또는 바이크 코드 명령어를 생성한다.

컴파일 이전의 별도의 단계를 수행하는 대표적인 언어로 C와 C++이 있다.

C와 C++에서는 컴파일 이전에 매크로 전처리기가 실행된다.

먼저 코드를 살펴보자.

1
2
3
4
TEST(getBalance, Account) {
Account account;
LONGS_EQUAL(0, account.getBalance());
}

위의 코드는 전처리 후 컴파일러에게 아래와 같이 표현된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class AccountgetBalanceTest : public Test {
public: AccountgetBalanceTest () : Test ("getBalance" "Test") {}
void run (TestResult& result_);
}

AccountgetBalanceInstance;
void AccountgetBalanceTest::run (TestResult& result_) {
Account account;
{
result_.countCheck();
long actualTemp = (account.getBalance());
long expectedTemp = (0);
if ((expectedTemp) != (actualTemp)) {
result_.addFailure (Failure (name_, "c:\\seamexample.cpp", 24, StringFrom(expectedTemp),
StringFrom(actualTemp)));
return;
}
}
}

추가로 다음과 같이 조건부 컴파일 코드를 포함시켜 디버깅이나 다중 플랫폼에 활용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ...
m_pRtg -> Adj(2.0);

#ifdef DEBUG
#ifndef WINDOWS
{
FILE *fp = fopen(TGLOGNAME,"w");
if (fp) {
fprintf(fp,"%s", m_pRtg -> pszState);
fclose(fp);
}
}
#endif

m_pTSRTable -> p_nFlush |= GF_FLOT;
#endif
// ...

위와 같이 지나치게 많은 전처리기를 사용하면 코드의 명료성이 떨어진다.

#ifdef, #ifndef, #if와 같은 조건부 컴파일 지시어를 사용하면 여러 개의 다른 프로그램을 한 개의 소스 코드에서 유지보수 할 수 있다.

#define 으로 매크로를 정의할 수도 있지만, 이는 단순히 텍스트 치환에 지나지않으며 매크로로 인해 버그가 은폐될 가능성이 존재한다.

이러한 단점에도 불구하고 C, C++의 전처리기는 봉합 포인트를 제공한다는 점에서 활용도가 높다.

아래 프로그램의 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <DFHLItem.h>
#include <DHLSRecord.h>

extern int db_update(int, struct DFHLItem *);

void account_update(int account_no, struct DHLSRecord *record, int activated) {
if (activated) {
if (record -> dateStamped && record -> quantity > MAX_ITEMS) {
db_update(account_no, record -> item);
} else {
db_update(account_no, record -> backup_item);
}
}
db_update(MASTER_ACCOUNT, record -> item);
}

위의 C 프로그램은 db_update 라는 라이브러리 루틴에 몇 개의 의존 관계를 가지고 있다.

db_update 함수는 데이터베이스를 직접적으로 호출하며, 해당 함수를 대체하지 않으면 함수의 영향을 감지할 수 없다.

전처리 봉합을 사용하면 db_update 함수를 대체할 수 있다.

이를 대체하기 위해 localdefs.h 헤더 파일을 도입하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <DFHLItem.h>
#include <DHLSRecord.h>

extern int db_update(int, struct DFHLItem *);

#include "localdefs.h"

void account_update(int account_no, struct DHLSRecord *record, int activated) {
if (activated) {
if (record -> dateStamped && record -> quantity > MAX_ITEMS) {
db_update(account_no, record -> item);
} else {
db_update(account_no, record -> backup_item);
}
}
db_update(MASTER_ACCOUNT, record -> item);
}

localdefs.h 헤더 파일에 db_update 함수의 몇 개의 변수를 정의한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifdef TESTING

// ...

struct DFHLItem *last_item = NULL;
int last_account_no = -1;

#define db_update(account_no,item) {
last_item = (item);
last_account_no = (account_no);
}

//...

#endif

위와 같에 db_update를 대체하면 올바른 매개변수로 db_update가 호출되었는지 검증하기 위한 테스트 루틴을 작성할 수 있다.

위와 같은 봉합이 가능한 이유는 C 언어 전치리기의 #include 지시어가 컴파일 전에 텍스트를 대체할 수 있기때문이다.

이처럼 모든 봉합엔 활성화 지점(enabling post) 이 존재한다.

활성화 지점(enabling post) 은 어느 동작을 사용할 지 선택할 수 있다.

컴파일러는 코드의 중간 표현을 생성하며, 이 중간 표현을 다른 파일에 들어있는 코드를 호출한다.

이때 링커(linker) 는 이 중간 표현들을 조합하여, 호출 주소를 변환해주어 완전한 실행 프로그램을 만들어주는 역할을 한다.

C와 C++ 에서는 별도의 링커가 존재하지만, Java는 컴파일러가 내부적으로 수행한다.

Java는 소스 파일에 import 문이 포함되어있을 경우, 컴파일러가 import된 클래스를 이미 컴파일 여부를 확인한다.

아직 컴파일되지 않았다면, 해당 클래스를 컴파일한 후 프로그램을 실행시 모든 호출 주소를 제대로 변환할 수 있는 지 확인한다.

이처럼 언어 별로 주소 변환을 수행하는 방법은 다르지만, 일반적으로 링크 봉합 기법을 사용해 프로그램의 일부를 대체할 수 있다.

아래 예제를 보자.

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
33
34
35
36
package fitnesse;

import fit.Parse;
import fit.Fixture;

import java.io.*;
import java.util.Date;
import java.util.*;

public class FitFilter {
public String input;
public Parse tables;
public Fixture fixture = new Fixture();
public PrintWriter output;

public static void main(String argv[]) {
new FitFilter().run(argv);
}

public void run(String argv[]) {
args(argv);
process();
exit();
}

public void process() {
try {
tables = new Parse(input);
fixture.doTables(tables);
} catch (Exception e) {
exception(e);
}
tables.print(output);
}
// ...
}

import된 클래스 중 fit.Parse 클래스와 fit.Fixture 클래스를 확인할 수 있다.

컴파일러와 JVM은 CLASSPATH 환경 변수를 사용해 클래스를 찾을 위치를 지정받아 위의 클래스들을 찾는다.

이를 이용해 동일한 이름의 클래스를 서도 다른 디렉토리에 저장한 후 CLASSPATH 환경 변수의 값을 바꾸어 링크할 수 있다.

다소 번거롭지만, 의존 관계를 제거하는 매우 유용한 방법이다.

이 상황에서 봉합지점은

1
tables = new Parse(input);

이며, 활성화 지점은 CLASSPATH 환경 변수이다.

이러한 동적 링크는 구식 프로그래밍 언어가 아닌 대부분의 많은 프로그래밍 언어에서 사용된다.

C와 C++의 빌드 시스템 상당수는 실행 파일을 생성할 때 정적 링크를 수행한다.

일반적으로 링크 봉합을 이용하는 가장 쉬운 방법은 대체 대상인 클래스나 함수에 대해 별도의 라이브러리를 작성한 후

테스트용으로 빌드 스크립트를 변경해서 원래의 라이브러리에 링크시키는 것이다.

이번엔 다수의 그래픽 라이브러리 호출을 포함하는 CAD 애플리케이션 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void CrossPlaneFigure::rerender() {
// draw the label
drawText(m_nX, m_nY, m_pchLabel, getClipLen());
drawLine(m_nX, m_nY, m_nX + getClipLen(), m_nY);
drawLine(m_nX, m_nY, m_nX, m_nY + getDropLen());

if (!m_bShadowBox) {
drawLine(m_nX + getClipLen(), m_nY, m_nX + getClipLen(), m_nY + getDropLen());
drawLine(m_nX, m_nY + getDropLen(), m_nX + getClipLen(), m_nY + getDropLen());
}

// draw the figure
for (int n = 0; n < edges.size(); n++) {
// ...
}
// ...
}

위의 코드는 그래픽 라이브러리를 여러 곳에서 직접 호출하고 있다.

이러한 코드를 검증하는 유일한 방법은 의도대로 렌더링이 진행되는 지 화면을 통해 확인하는 방법뿐이다.

이를 링크 봉합을 통해 처리해볼 수 있다.

그래픽 함수들이 모두 한 개의 라이브러리에 포함되어 있다면, 이 함수들을 스텁(Stub) 형태로 만들어서 애플리케이션에 링크시킬 수 있다.

단순히 의존 관계를 제거하는 것이 목적이라면 아래와 같이 구현부를 제거한 공백 함수로 만들면 된다.

1
2
void drawText(int x, int y, char *text, int textLength) {}
void drawLine(int firstX, int firstY, int secondX, int secondY) {}

만약 함수들을 값을 반환해야한다면, 일반적으로 성공을 나타내는 값이나 기본값을 반환하는 것으로 충분하다.

1
2
3
int getStatus() {
return FLAG_OKAY;
}

그래픽 라이브러리의 경우 링크 봉합을 사용하기 적합한데, 이는 다른 라이브러리들과는 달리 그래픽 라이브러리는 대부분이 순수한 명령 인터페이스이기 때문이다.

함수를 호출해서 어떤 동작을 하도록 명령을 내릴 뿐, 많은 정보를 반환하도록 요구하는 경우가 거의 없다.

반면에 반환값을 요구하는 경우엔 기본값을 반환하는 것이 적합하지 않을 수 있어 조금은 어려운 일이 된다.

링크 봉합은 의존 관계를 분리하기 위해 주로 사용되지만, 약간의 추가 작업으로 감지를 위해 사용할 수도 있다.

위의 그래픽 라이브러리 예제의 경우 아래와 같이 함수 호출을 저장하는 별도의 자료구조를 사용할 수 있다.

1
2
3
4
5
std::queue<GraphicsAction> actions;

void drawLine(int firstX, int firstY, int secondX, int secondY) {
actions.push_back(GraphicsAction(LINE_DRAW, firstX, firstY, secondX, secondY);
}

이 자료구조를 사용해 테스트 대상 함수의 영향을 아래와 같이 감지할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TEST(simpleRender,Figure) {
std::string text = "simple";
Figure figure(text, 0, 0);

figure.rerender();
LONGS_EQUAL(5, actions.size());

GraphicsAction action;
action = actions.pop_front();
LONGS_EQUAL(LABEL_DRAW, action.type);

action = actions.pop_front();
LONGS_EQUAL(0, action.firstX);
LONGS_EQUAL(0, action.firstY);
LONGS_EQUAL(text.size(), action.secondX);
}

감지를 위해 사용되는 구조가 복잡해지는 경우가 많으므로, 단순한 구조로 최소한의 감지를 먼저 수행한 뒤 점진적으로 발전시키는 게 좋다.

단 주의할 점으로, 링크 봉합의 활성화 지점은 언제나 소스 코드의 외부에 위치하기때문에 링크 봉합을 사용하고 있음을 알아차리기 어려울 수 있다.

3. Object Seams (객체 봉합)

객체 봉합은 객체 지향 언어에서 사용할 수 있는 봉합 기법 중에서 가장 유용한 기법이다.

객체 지향 프로그램의 경우, 호출 위치의 코드를 들여다봐도 실제로 어떤 메소드가 실행될지 정의되어있지 않다는 점을 인지해야 한다.

예를 들어 아래의 코드를 보자.

1
cell.Recalculate();

코드만 보면 Recalculate() 라는 메서드가 존재하고 이 메서드가 호출되어 실행될 것임을 알 수 있다.

문제는 같은 이름의 메서드가 두 개 이상 존재할 수 있다는 점이다.

위의 UML처럼 구성된 클래스에서 Recalculate() 메서드는 두 개가 존재하게 된다.

이때 cell 변수가 어떤 객체를 가리키는 지 알 수 없다면, 당연히 어떤 메서드가 호출될지도 알 수가 없다.

만약 다른 코드의 변경없이 호출되는 Recalculate() 메서드를 변경할 수 있다면 이 부분이 봉합 포인트가 될 것이다.

주의해야할 점은 객체 지향 언어의 메서드 호출이 언제나 봉합 포인트가 될 수는 없다는 것이다.

아래 코드는 봉합 포인트가 아닌 호출의 예제이다.

1
2
3
4
5
6
7
8
9
10
11

public class CustomSpreadsheet extends Spreadsheet {
public Spreadsheet buildMartSheet() {
// ...
Cell cell = new FormulaCell(this, "A1", "=A2+A3");
// ...
cell.Recalculate();
// ...
}
// ...
}

위 예제에서는 cell 변수를 FormulaCell 클래스로 생성 후 변경없이 Recalculate() 메서드를 호출하고 있기에 활성화 지점을 찾을 수 없고,

따라서 봉합 포인트가 존재하지 않는다.

아래의 코드가 객체 봉합이 가능한 예제이다.

1
2
3
4
5
6
7
8
public class CustomSpreadsheet extends Spreadsheet {
public Spreadsheet buildMartSheet(Cell cell) {
// ...
cell.Recalculate();
// ...
}
// ...
}

위 예제에서 cell 변수를 인자로 주어지기때문에, 정확히 어떤 클래스인지 특정할수가 없기에 봉합 포인트가 존재한다고 볼 수 있다.

대부분의 객체 봉합은 매우 단순하지만, 좀 더 까다로운 경우의 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
public class CustomSpreadsheet extends Spreadsheet {
public Spreadsheet buildMartSheet(Cell cell) {
// ...
Recalculate(cell);
// ...
}
private static void Recalculate(Cell cell) {
// ...
}
// ...
}

여기서 Recalculate() 메서드는 정적 메서드로, buildMartSheet() 메서드의 변경 없이

Recalculate() 메서드 내부에서 동작이 변경 가능하므로 봉합이라고 볼 수 있다.

이때 static 키워드를 제거하고, privateprotected로 변경하면 서브클래스를 작성 후 오버라이드하여 테스트 루틴 작성시 활용할 수 있다.

아래 코드는 그 예시이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class CustomSpreadsheet extends Spreadsheet {
public Spreadsheet buildMartSheet(Cell cell) {
// ...
Recalculate(cell);
// ...
}

protected void Recalculate(Cell cell) {
// ...
}
// ...
}

public class TestingCustomSpreadsheet extends CustomSpreadsheet {
protected void Recalculate(Cell cell) {
// ...
}
}

마무리하며

이번 포스트에서는 주요 봉합 기법들에 대해서 알아보았다.

첫 번째 에제로 돌아가서 어떤 봉합 포인트가 존재하는 지에 대해 다시 알아보자.

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
bool CAsyncSslRec::Init() {
if (m_bSslInitialized) {
return true;
}

m_smutex.Unlock();
m_nSslrefcount++;
m_bSslInitialized = true;
FreeLibrary(m_hSslDll1);
m_hSslDll1 = 0;
FreeLibrary(m_hSslDll2);

m_hSslDll2 = 0;

if (!m_bFailureSent) {
m_bFailureSent = TRUE;
PostReceiveError(SOCKETCALLBACK, SSL_FAILURE);
}

CreateLibrary(m_hSslDll1, "syncesel1.dll");
CreateLibrary(m_hSslDll2, "syncesel2.dll");

m_hSslDll1 -> Init();
m_hSslDll2 -> Init();

return true;
}

PostReceiveError() 함수를 호출할 때 어떤 봉합을 사용할 수 있을까?

1. 링크 봉합
PostReceiveError()는 전역 함수이므로 링크 봉합을 사용할 수 있다. Stub 함수를 갖는 라이브러리를 작성하고 이를 링크함으로서 동작을 제거할 수 있다.

활성화 지점은 당연히 소스 코드의 외부인 makefile이나 ide의 설정 부분이 된다.

테스트 버전을 빌드할 때는 테스트용 라이브러리, 릴리즈 버전을 빌드할 때는 실제 라이브러리와 링크되도록 설정하자.

2. 전처리 봉합
#include 를 코드에 추가하여 전처리기를 사용해 PostReceiveError() 매크로를 정의할 수 있다.

활성화 지점은 PostReceiveError() 매크로 정의를 활성화하거나 비활성하하는 별도의 전처리 정의부 이다.

3. 객체 봉합
PostReceiveError() 함수에 대한 가상 함수를 선언할 수 있다.

활성화 지점은 객체를 생성하기로 결정한 코드의 위치로, 해당 위치에서 CAsyncSslRec 객체나 PostReceiveError() 함수를 오버라이딩한 테스트용 서브클래스 객체를 생성할 수 있다.

위와 같이 메소드를 고치지않고도 다양한 방법으로 함수 호출 시의 동작을 변경할 수 있는 것은 꽤 놀라운 일이다.

일반적으로 객체 지향 프로그래밍에서는 객체 봉합이 가장 적합한 방법이며, 전처리 봉합과 링크 봉합은 대안이 없는 경우에 사용하는 최후에 카드로 사용하는 것이 바람직하다.