테스트 용이성(Testability) 향상을 위한 델리게이션(Delegation)

이전 포스팅 ‘테스트 용이성(Testability) 향상을 위한 DI(Dependency Injection)’에서 이어지는 내용이다.

종속성 문제

테스트 코드 없이 개발할 때는 잘 인지하지 못하다가 테스트 코드를 넣으려고 할 때 만나는 문제 중 하나로 종속성 문제가 있다. 테스트 환경에서 특정 객체 하나를 생성하기 위해서 너무 많은 객체가 필요해지는 상황과 특정 객체가 내부적으로 다른 객체를 직접 생성하는 상황이 그것이다. 이런 상황은 몇 가지 방법으로 개선을 할 수 있다.

파라미터 수정

객체의 일부 값을 사용하려고 해당 객체를 파라미터로 받아서 생긴 종속성은 poor()가 info에서 얼마나 많은 정보에 접근하는지에 따라서 정리가 가능하다. 예를 들면 이런 식이다.

1
2
3
4
// 수정 전
void poor(const UserInfo& info);
// 수정 후
void poor(const std::string& id, const std::string& ip, const std::string& version);

그 값들만 파라미터로 받는 것으로 리팩토링해서 UserInfo에 대한 종속성을 끊을 수 있다.

Dependency Injection

아래처럼 클래스 A가 다른 클래스 B의 객체를 내부적으로 생성하는 코드가 있는데, 테스트 환경에서 B를 생성할 수 없는 상황이라면 DI 방식으로 정리가 가능하다.

1
2
3
4
5
class A {
…
private:
  B b;
}

위 코드를 아래와 같이 수정하면 테스트 환경에서 클래스 B를 Mocking 해서 클래스 A를 생성할 때 넘겨줄 수 있다.

1
2
3
4
5
6
7
8
9
10
11
class MockB : public B {
  // …
}
class A {
public:
  A(B& b): b(b) {};
private:
  B& b;
};
MockB mockB;
A a(mockB);

이제 클래스 A는 클래스 B에 대한 구현 종속보다 가벼운 인터페이스 종속만을 가진 상태가 되었고 테스트 환경에서 자유롭게 테스트도 할 수 있다.

Dependency Injection을 사용하기 어려운 상황

값들만 파라미터 받기에는 부담스러울 만큼 많이 사용하고 있거나, 클래스 A를 DI 방식으로 수정하려면 일단 파라미터로 받는 클래스의 인터페이스에 만족하는 객체를 생성해야 하는데 그 클래스의 생성자가 파라미터로 또 다른 클래스들을 받는다면 그것을 생성하기가 쉽지 않다. 그렇다고 연관된 모든 클래스를 리팩토링하기 부담스러운 상황이라면 DI 대신 델리게이션(Delegation)을 사용해서 종속성을 풀 수 있다.

참고로 예제 코드는 최대한 단순하게 구성할 것이고, delegate 구현은 C++ 11에서 추가된 Lambda Expressions을 사용할 것이다. 람다에 대한 설명은 이 글에서 하지 않는다. 꼭 람다를 사용할 필요는 없다. 함수 객체 혹은 클로저를 사용해도 되고 구현 방법은 다양하다.

델리게이션(Delegation)

코드 레벨에서 언급되는 델리게이션은 보통 어떤 작업을 대신 해주는 함수 또는 객체이다. 이 글에서는 콜백 함수 형식의 델리게이션을 사용한다.

델리게이션(Delegation)을 사용한 리팩토링(Refactoring)

아래의 클래스 다이어그램을 가지고 이야기를 할 것이다. 전체 코드는 여기에서 받을 수 있다.

위의 클래스 다이어그램에서 CapturedDobby를 테스트하고 싶은데 Dobby의 doSomething() 메서드가 UltraBigAB를 파라미터로 받고 있다. 하여, UltraBigAB를 생성해야 하는데 이를 생성하려면 BigA, BigB(이하 Big)가 필요하고 이들 각각은 MediumA, MediumB(이하 Medium)가 필요한 상황이다.

1
2
3
4
5
MediumA mediumA(1, 2, 3);
MediumB mediumB(10, 20, 30);
BigA bigA(mediumA, 100, 200);
BigB bigB(mediumB, 1000, 1000);
UltraBigAB ultraBigAB(bigA, bigB, 10000, 20000);

이제 UltraBigAB를 생성했으니 CapturedDobby에게 일을 맡겨보자.

1
2
CapturedDobby capturedDobby;
int result = capturedDobby.doSomething(50, ultraBigAB);

Medium은 파라미터로 받은 값을 모두 더하고, Big도 파라미터로 받은 Medium과 다른 값들을 모두 더하고, UltraBigAB도 파라미터로 받은 Big의 결괏값과 다른 값들을 모두 더한다. capturedDobby도  파라미터로 받은 Big과 다른 값들을 모두 더한다. 잘 동작하고 결과로 62416을 받을 수 있다. 하지만, capturedDobby의 doSomething()는 UltraBigAB에 종속성이 걸려있어서 호출하려면 UtraBigAB의 객체가 필요한데 이 객체를 생성하기가 쉽지 않다.

테스트 환경에서 Medium, Big를 생성하는 것이 어려운 상황이라고 하자. 지금은 가정이지만 단위 테스트를 염두에 두지 않고 설계한다면 테스트 환경에서 특정 객체를 단독으로 생성하는 것이 어려운 상황을 꽤 자주 만나게 될 것이다. 테스트 환경에서 Medium, Big을 생성할 수 없다면, UltraBigAB도 생성할 수 없으니 CapturedDobby를 테스트할 수가 없다.

위 상황에 Delegation을 적용해서 정리하면 다음과 같이 CapturedDobby를 풀어줄 수 있다.

1
2
3
4
5
6
7
8
9
10
class ReleasedDobby {
public:
	int getSomeValue() { return 42; }
	int doSomethingGood(int x, const std::function<int(int)> &delegate) {
		// do something
		int result = delegate(x);
		// do something
		return result;
	}
};

새로운 ReleasedDobby에게 일을 맡기려면 이제 UltraBigAB 대신 Delegate가 필요하다. 코드를 보자.

1
2
3
4
5
6
7
8
9
10
MediumA mediumA(1, 2, 3);
MediumB mediumB(10, 20, 30);
BigA bigA(mediumA, 100, 200);
BigB bigB(mediumB, 1000, 1000);
UltraBigAB ultraBigAB(bigA, bigB, 10000, 20000);
ReleasedDobby releasedDobby;
auto delegate = [&ultraBigAB](int x) {
	return x + ultraBigAB.doSomethingA() + ultraBigAB.doSomethingB();
};
int result = releasedDobby.doSomethingGood(50, delegate);

코드를 보니 여전히 Medium, Big, UltraBigAB를 모두 생성하고 있다. UltraBigAB를 사용하는 부분이 Delegate쪽으로 옮겨졌을 뿐 크게 달라지지 않은 것 같다. 하지만, 이제 ReleasedDobby는 UltraBigAB와의 직접적인 종속성이 사라졌고 테스트하기 매우 쉬워졌다. 다음과 같이 사용할 수 있다.

1
2
3
4
auto delegateCaseA = [](int x) {
	return x + 30306   + 32060;
};
int resultCaseA = releasedDobby.doSomethingGood(50, delegate);

또는, UltraBigAB를 Mocking 할 필요 없이 다음과 같이 Fake를 만들어서 테스트할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class FakeUltraBigAB {
public:
	FakeUltraBigAB(int valueA, int valueB)
		: valueA(valueA), valueB(valueB), fakeValueForBigA(0), fakeValueForBigB(0) {}
	int setFakeValueForBigA(int value) { fakeValueForBigA = value; }
	int setFakeValueForBigB(int value) { fakeValueForBigB = value; }
	int doSomethingA() { return fakeValueForBigA + valueA + valueB; }
	int doSomethingB() { return fakeValueForBigB + valueA + valueB; }
private:
	int valueA;
	int valueB;
	int fakeValueForBigA;
	int fakeValueForBigB;
};
FakeUltraBigAB fakeUltraBigAB(10000, 20000);
fakeUltraBigAB.setFakeValueForBigA(306);
fakeUltraBigAB.setFakeValueForBigB(2060);
auto testDelegateUsingFake = [&fakeUltraBigAB](int x) {
	return x + fakeUltraBigAB.doSomethingA() + fakeUltraBigAB.doSomethingB();
};
int resultCaseB = releasedDobby.doSomethingGood(50, testDelegateUsingFake);

GoogleTest와 같은 단위 테스트 환경에서 테스트 케이스를 만들기가 매우 쉬워졌다. 그렇지만, Dobby의 인터페이스가 변경되어 기존에 Dobby를 사용하고 있던 부분을 모두 수정해야 하는 상황을 만들었기 때문에 수정 범위를 최소화하면서 Dobby를 테스트하려던 목표에서 벗어났다. 절충안으로 외부에 노출되는 Dobby의 인터페이스를 유지하면서 Dobby의 내부에서 Delegation 방식을 사용해보자. 다음과 같이 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class HalfReleasedDobby {
public:
	int getSomeValue() { return 42; }
	int doSomething(int x, UltraBigAB &ultraBigAB) {
		// do something
		auto delegate = [&ultraBigAB](int x) {
			return x + ultraBigAB.doSomethingA() + ultraBigAB.doSomethingB();
		};
		int result = doSomethingGood(x, delegate);
		// do something
		return result;
	}
protected:
	int doSomethingGood(int x, const std::function<int(int)>& delegate) {
		// do something
		int result = delegate(x);
		// do something
		return result;
	}
};

내부 메서드 doSomethingGood()에게 delegate를 전달하는 것으로 내부 비즈니스 로직과 UltraBigAB와의 종속성을 제거했다. 외부에 노출되는 doSomething()의 시그니처를 유지하고 있어 기존 코드에는 사이드 이펙트를 주지 않으면서, 비즈니스 로직을 테스트 할 수 있는 길을 하나 열어 놓은 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class TestHalfReleasedDobby : public HalfReleasedDobby, public testing::Test {};
TEST_F(TestHalfReleasedDobby, TestDobbySimple) {
	auto testDelegate = [](int x) { return x + 10 + 20; };
	int result = doSomethingGood(50, testDelegate);
	ASSERT_EQ(result, 80);
}
TEST_F(TestHalfReleasedDobby, TestDobbyUsingFake) {
	FakeUltraBigAB fakeUltraBigAB(10000, 20000);
	fakeUltraBigAB.setFakeValueForBigA(306);
	fakeUltraBigAB.setFakeValueForBigB(2060);
	auto testDelegateUsingFake = [&fakeUltraBigAB](int x) {
		return x + fakeUltraBigAB.doSomethingA() + fakeUltraBigAB.doSomethingB();
	};
	int result = doSomethingGood(50, testDelegateUsingFake);
	ASSERT_EQ(result, 62416);
}

위와 같이 테스트할 수 있다. 기존 코드 호환성은 유지한 상태로 테스트 대상만 델리게이션 방식으로 리팩토링하여 단위 테스트를 구성할 수 있게 했다. 윗글에서 사용한 C++ 11의 람다는 글 상단의 링크를 참조하기 바란다. 기본적인 사항만 확인하면 매우 편리하게 사용할 수 있다.

마치며

추가한 클래스들을 포함한 클래스 다이어그램을 보면 아무런 종속성이 없는 ReleasedDobby와 달리 HalfReleasedDobby는 외부 인터페이스를 유지하고 있어서 CapturedDobby와 동일한 종속성을 가지고 있다. 하지만, HalfReleasedDobby는 단위 테스트를 쉽게 할 수 있도록 개선되어 있다. 간단하게 델리게이션을 사용하여 종속을 푸는 방법을 이야기했다. DI와 함께 사용하면 다양한 상황에서 코드 구조를 개선할 수 있을 것이다.

제가 잘못 이해하고 있는 내용은 언제든지 알려주시면 감사하겠습니다.

(원글: https://prostars.net/303)


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.