1. 당신이 처한 상황...

당신은 보일러 회사에 고용된 프로그래머다. 당신이 작성할 것은 '보일러 제어' 프로그램이다. 방에 달려 있는 보일러를 끄고, 켜고, 온도를 조절하는 제어기에 들어갈 프로그램이라고 보면 되겠다. 실제로 돌아가는 보일러와 소켓 통신을 통해 이런 저런 제어 정보를 주고 받는다. 주고 받을 정보의 형식은 다음과 같다.












command 앞에 command의 length를 붙여서 서버로 날리면 된다.

당신이 처해 있는 상황은 매우 열악하다. 사용할 수 있는 command가 보일러 끄기/보일러 켜기/보일러 온도 알아오기 정도 뿐이다. 이 정도면 충분하다고? 천만에! 보일러 켜기, 보일러 끄기, 보일러 온도 표시, 보일러 온도 조절, 타이머 켜기, 타이머 끄기 기능을 구현해야 한단 말이다. 게다가 보일러 회사 사장은 매우 깐깐하여 테스트용 보일러를 구동해 달라는 요구에도 꿈쩍 하지 않을 경우가 많으며, 회사의 사정이 열악한 나머지 테스트용 보일러 조차도 잘 돌지 않을 때가 많다. 이정도면 안습 상황이다...

2. 첫 걸음...

그나마 다행인 것은, 소켓 통신만 할 수 있으면 어떤 언어든 사용할 수 있다는 것이다. 가장 익숙한 언어인 C를 선택하였다. 테스트 프레임웍은 자주 쓰던 CUnit을 사용하기로 했다.

우선 주어진 환경이 환경이니만큼, 보일러와 소켓 통신을 통해 정보를 주고 받기 위해서는 소켓 초기화 및 종료가 필요할 것 같아서 다음의 테스트를 추가하였다.
void SetupTestFunction(CuTest* test)
{
  CuAssert( test, "SetupTest", client_setup() > 0 );  
  CuAssert( test, "TeardownTest", client_teardown() > 0 );
}


테스트를 추가한 후 빌드...당연히 에러 발생...우선 client_setup() 과 client_teardown() 함수를 만든 후 무조건 1을 return 하도록 하였다. 일단 테스트 통과...

수단과 방법을 가리지 않고 테스트를 통과시킨 후, 실제로 돌아가는 코드를 작성하는 식으로 개발을 진행해 나가기로 하였다. 그에 따라서 client_setup()과 client_teardown() 함수에 소켓 접속 / 접속 해제 코드를 넣었다. 약 한시간의 삽질과 테스트 fail 후에 일단은 성공...휴...

3. 벽에 부딛혀...

"소켓 통신은 되었으니 좀 후련한 것 같군. 그런데 이제 어떤 테스트를 추가해야 하지?"
일단 온도를 얻어 오는 테스트를 하나 추가해 보았다. 온도를 얻어오는 command를 보일러에 날리고, 날아온 온도 정보가 0에서 90 사이인지를 테스트하는 것이다.

시작부터 문제가 있었다. 우선 command를 제대로 날리지 못하였고, 그에 따라 온도 정보가 제대로 날아오지 않았다. 하지만 command가 제대로 구성되었는지의 여부를 체크할 수 있는 테스트 코드가 없었기 때문에 이 상태에서 테스트 코드를 디버깅하고, 로직을 디버깅 하는 등의 추태(?)를 보였다.

위에 작성한 테스트는 QA에서 테스트하는 것과 다를 바 없다. 유닛테스트는 QA 테스트와는 다르며, 보다 작은 단위로 나누어서 생각해 볼 필요가 있다는 조언을 얻었다.

4. 이렇게 하면 어떨까?

문제가 안 풀려서 책상에서 한참 골머리를 썩이고 있다가 밖으로 나와서 잠시 바람을 쐬고 있다보면 실마리가 불현듯 생각 나는 경우가 있다. Sample로 작성된 TDD 코드를 참고삼아 보면서 곰곰히 생각해 보다가 답이 안 나와서, 집을 나와서 약속장소로 가기 위해 버스를 잡아 타고, 자리에 앉아 창밖을 물끄러미 바라 보다가 문득 이런 생각이 들었다.

"작동하는 보일러에 무언가 날려서 얻은 결과만을 테스트하지 말고, 정보를 날리기 위한 과정 과정을 자세히 나누고, 보일러 작동을 시뮬레이션하는 방식으로 TDD를 적용해 보면 어떨까?"

다시 책상에 앉은 후, 첫 번째로 한 일은 네트워크 관련 함수 구현을 멈추고 network_stub.c 를 사용하여 구현하기로 한 것이다. 다음과 같이...

#include <windows.h>
#include <stdio.h>

#include "network.h"

/* private members */
static SOCKET sock;
static const int port = 7799;
static const char* ipAddr = "127.0.0.1";

int ConnectInit(void)
{
return 1;
}

int Send(const char *buf, size_t len, int flags)
{
  return 1;
}

int Recv(void *buf, size_t len, int flags)
{
  return 1;
}

int Disconnect(void)
{
  return 1;
}


위와 같이 네트워킹 관련 함수는 모두 1을 return 하도록 하고, TDD를 이용한 개발이 완료된 후에 실제로 네트워킹 관련 내용을 채워서 완성하기로 하였다.

그리고, command를 구성할 buffer를 만들고(여기서 command는 "\x02""78" 의 형식을 띰), 그 command의 length가 제대로 설정되었는지를 확인하는 작은 test로 시작하였다.
void BufferLengthTestFunction(CuTest* test)
{
  CuAssertTrue( test, client_setup() > 0 );
  SetServerSendBuf("\x02""78");
  CuAssertIntEquals(test, 3, strlen(GetServerSendBuf())/*read_length(sockfd)*/);
  CuAssertTrue( test, client_teardown() > 0 );
}

드디어 실제로 사용되는 command를 구성하고, 구성된 buffer를 체크하는 test를 추가해 보았다. 다음은 보일러의 온도를 얻어오는 "getboilertemp" command 버퍼를 구성한 후, 보낼 command 버퍼가 제대로 구성되었는지를 체크하는 테스트이다.
void GetTempCommandTestFunction(CuTest* test)
{
  CuAssertTrue( test, client_setup() > 0 );
  CuAssert(test, 13 == BuildCommandToPacket("getboilertemp"));
  CuAssertStrEquals_Msg(test, "\x0d""getboilertemp", GetServerSendBuf());
  CuAssertTrue( test, client_teardown() > 0 );
}

이와 같이 다른 command를 test 하는 코드를 추가한 후, 실제로 메시지를 보일러 서버에 보내는 test를 수행하였다. 여기서 사용된 SendCommandToServer() 함수는 실제로 보일러에command를 보내는 것이 아니라 각 command에 따른 적당한 값(예를 들면 getboilertemp 명령어에 대해서는 21도 라는 답을 주는 식으로)을 반환한다. 즉, 서버의 동작을 흉내낸 코드이다. test 코드는 다음과 같다.

void GetTempTCPSendTestFunction(CuTest* test)
{
CuAssertTrue( test, client_setup() > 0 );
CuAssert(test, 13 == BuildCommandToPacket("getboilertemp"));
CuAssertStrEquals_Msg(test, "\x0d""getboilertemp", GetServerSendBuf());
CuAssert(test, SendCommandToServer());
CuAssertTrue( test, client_teardown() > 0 );
}

이런 식으로 다른 command에 대한 test도 추가하였고, 받은 값에 대한 test도 추가하였다.

드디어 온도 설정 기능을 구현해야 한다! 우선 온도를 설정하는 함수가 있어야 할 것 같고, 적절한 온도를 설정하였는지 여부를 검사해 보아야 할 것 같아서 다음과 같은 test를 추가하였다. 온도는 0도에서 90도까지 설정할 수 있다.

void SetTempTestFunction(CuTest *test)
{
CuAssertTrue( test, client_setup() > 0 );
CuAssert(test, setTemperature(34) == 1);
CuAssert(test, setTemperature(1) == 1);
CuAssert(test, setTemperature(90) == 1);
CuAssert(test, setTemperature(0) == 1);  // 온도 설정 OFF
CuAssert(test, setTemperature(-1) < 0);
CuAssert(test, setTemperature(91) < 0);
CuAssertTrue( test, client_teardown() > 0 );
}

추가로, 온도 설정과 관련된 여러 상황들을 test 내에서 시뮬레이션 하여 보았다. 현재 온도와 설정한 온도를 비교하여 보일러가 해야 할 일(보일러를 켤 것인지, 끌 것인지, 아니면 적당한 온도이므로 그대로 놔 둘 것인지)을 return하도록 한 getBoilerActionWithCurrSettingTemp 함수를 추가하여 test를 작성하였다. test에서는 24도로 온도를 설정해 둔 후에, 현재 온도에 따른 보일러의 행동을 체크하였다.

예를 들면, 온도를 24도로 설정하였는데 현재 온도가 34도이면 보일러를 꺼야 할 것이고, 14도이면 보일러를 켜야 할 것이다.

참고로 action 관련된 반환값은 #define을 사용하지 않고 enum 타입을 사용하였다. Effective C++ 를 보면, #define 보다는 enum을 사용하는 것이 컴파일 타임에 예외사항을 찾기에 더 수월하다고 한다.

void SetTempBoilerActionTestFunction(CuTest *test)
{
CuAssertTrue( test, client_setup() > 0 );

CuAssert(test, getBoilerActionWithCurrSettingTemp(24) == BOILERACTION_NOSETTINGTEMPERATURE);

CuAssert(test, setTemperature(34) == 1);
CuAssert(test, getBoilerActionWithCurrSettingTemp(24) == BOILERACTION_TURNON);
CuAssert(test, getBoilerActionWithCurrSettingTemp(27) == BOILERACTION_NOTHING); // 온도가 올라간다 ㅇㅋ
CuAssert(test, getBoilerActionWithCurrSettingTemp(30) == BOILERACTION_NOTHING);
CuAssert(test, getBoilerActionWithCurrSettingTemp(34) == BOILERACTION_NOTHING);
CuAssert(test, getBoilerActionWithCurrSettingTemp(37) == BOILERACTION_NOTHING);
CuAssert(test, getBoilerActionWithCurrSettingTemp(39) == BOILERACTION_NOTHING);
CuAssert(test, getBoilerActionWithCurrSettingTemp(40) == BOILERACTION_TURNOFF); // 5도보다 높이 올라가면 끈다
CuAssert(test, getBoilerActionWithCurrSettingTemp(36) == BOILERACTION_NOTHING);
CuAssert(test, getBoilerActionWithCurrSettingTemp(35) == BOILERACTION_NOTHING);
CuAssert(test, getBoilerActionWithCurrSettingTemp(32) == BOILERACTION_NOTHING);
CuAssert(test, getBoilerActionWithCurrSettingTemp(29) == BOILERACTION_NOTHING);
CuAssert(test, getBoilerActionWithCurrSettingTemp(28) == BOILERACTION_TURNON); // 5도보다 낮게 떨어지면 켠다

  .... // 중략

CuAssertTrue( test, client_teardown() > 0 );
}


마지막으로 구현해야 할 기능은 보일러 타이머 켜기/끄기 기능이다.

타이머를 설정할 setBoilerTimer 함수를 만들고, 파라미터를 체크하는 테스트를 만들었다. 첫 번째 인자는 지금으로부터 보일러가 켜지거나 꺼지도록 설정할 타이머 시간(단위는 분), 두 번째와 세 번째 인자는 현재 시간과 현재 분, 네 번째 인자는 보일러를 켤 것인지 아니면 끌 것인지를 표시하는 인자이다.

위에서와 마찬가지로, action 관련된 인자는 #define을 사용하지 않고 enum 타입을 사용하였다.

void TimerSettingTestFunction(CuTest *test)
{
CuAssert( test, client_setup() > 0 );

CuAssert(test, setBoilerTimer( 60, 0, 23, BOILERTIMER_TURNON ) == 1);
CuAssert(test, setBoilerTimer( 1, 0, 23, BOILERTIMER_TURNON ) == 1);
CuAssert(test, setBoilerTimer( 0, 0, 23, BOILERTIMER_TURNON ) < 0);  // 0보다 작거나 같은 숫자는 타이머로 둘 수 없음
CuAssert(test, setBoilerTimer( -3, 0, 23, BOILERTIMER_TURNON ) < 0);

CuAssert(test, setBoilerTimer( 60, -1, 23, BOILERTIMER_TURNON ) < 0);  // 현재 시간은 0~23 이어야 함
CuAssert(test, setBoilerTimer( 60, 8, 23, BOILERTIMER_TURNON ) == 1);
CuAssert(test, setBoilerTimer( 60, 9, 0, BOILERTIMER_TURNON ) == 1);
CuAssert(test, setBoilerTimer( 60, 23, 23, BOILERTIMER_TURNON ) == 1);
CuAssert(test, setBoilerTimer( 60, 24, 23, BOILERTIMER_TURNON ) < 0);  // 현재 시간은 0~23 이어야 함

.... // 중략

CuAssertTrue( test, client_teardown() > 0 );
}


개발할 때 테스트하기 제일 짜증나는 것이 타이머 관련된 기능이다. 5분 후에 켜지는 기능을 테스트하려면 5분을 기다려야 하고, 알람 설정 시 화면을 테스트하려고 하면 매번 알람을 맞추고 최대 1분 정도를 기다려야 할 수 있다.

보일러 클라이언트 프로젝트에서도 가장 고민했던 것이 타이머 관련 기능이었다. 결국, 시간 관련된 인자는 모두 파라미터로 넣어 주는 방식을 사용하여 고민을 해결하였다. 현재 시간을 파라미터로 넣어서 타이머를 설정하고, 타이머 체크 또한 일정 시간이 지난 시점의 시간을 파라미터로 넣어서 체크(getBoilerTimerAction 함수 참고)하였다. 내용은 다음과 같다.

void TimerSettingTestFunction(CuTest *test)
{
CuAssertTrue( test, client_setup() > 0 );

CuAssert(test, setBoilerTimer( 60, 6, 20, BOILERTIMER_TURNON ) == 1); // 6시 20분부터 60분 후에 켠다
CuAssert(test, getBoilerTimerAction( -1, 59) < 0); // 시간 세팅 잘못
CuAssert(test, getBoilerTimerAction( 24, 59) < 0); // 시간 세팅 잘못
CuAssert(test, getBoilerTimerAction( 6, 60) < 0); // 분 세팅 잘못
CuAssert(test, getBoilerTimerAction( 6, -1) < 0); // 분 세팅 잘못

CuAssert(test, getBoilerTimerAction( 6, 30) == BOILERTIMER_NOTHING);
CuAssert(test, getBoilerTimerAction( 6, 50) == BOILERTIMER_NOTHING);
CuAssert(test, getBoilerTimerAction( 6, 59) == BOILERTIMER_NOTHING);
CuAssert(test, getBoilerTimerAction( 7, 0) == BOILERTIMER_NOTHING);
CuAssert(test, getBoilerTimerAction( 7, 15) == BOILERTIMER_NOTHING);
CuAssert(test, getBoilerTimerAction( 7, 20) == BOILERTIMER_TURNON); // 시간이 되었으니 킵시다

CuAssert(test, setBoilerTimer( 30, 8, 15, BOILERTIMER_TURNON ) == 1); // 8시 15분부터 30분 후에 켠다
CuAssert(test, getBoilerTimerAction( 8, 20) == BOILERTIMER_NOTHING);
CuAssert(test, getBoilerTimerAction( 8, 25) == BOILERTIMER_NOTHING);
CuAssert(test, getBoilerTimerAction( 8, 44) == BOILERTIMER_NOTHING);
CuAssert(test, getBoilerTimerAction( 8, 46) == BOILERTIMER_TURNON); // 시간이 지나 버렸네. 재빨리 킵시다

.... // 중략

CuAssertTrue( test, client_teardown() > 0 );
}


5. 더 나은 보일러를 위하여...

이렇게 하여 보일러 제어 프로그램, 보다 정확히 말하면 보일러 제어 프로그램에 필요한 API 를 완성하였다. 나름 고심하여 완성하였고 만족스러운 부분도 없지는 않지만, 고쳐야 할 부분이 이곳 저곳에 보인다. 고쳐야 할 점을 정리해 보면 다음과 같다.

- network_stub 부분을 사용하지 않았다. 보일러 부분에 모든 로직을 모아 놓고, 서버를 흉내낸 부분(특정 command를 해석해서, 그에 해당하는 적당한 예제 답을 자동으로 주는 함수)또한 보일러 부분에 들어 있었다. 이와 같은 모의 서버를 network_stub부분에 넣었으면 어땠을까 하는 생각이 든다.

- 보일러에는 실제 프로그램에서는 사용되지 않을 로직이 많이 들어 있다. 이 부분을 따로 빼야 한다.

- Assert 문 사용과 관련된 점. CuAssert 만을 사용하고, CuAssertIntEquals 을 사용하지 않은 것이다. 예를 들면
  CuAssert(tc, 3 == 3); 대신에
  CuAssertIntEquals(tc, 3, 3); 이런 식으로 하면 보다 깔끔하다.

- 함수의 naming 문제. GetServerSendBuffer 함수는 보내기 전의 버퍼(SendBuffer)의 포인터를 가져오는 함수인데, 서버로 보낸 후의 값을 받아오는 함수로 오해되기 쉽다.


보일러 TDD를 통하여 얻은 교훈은 다음과 같다.

- 지금까지는 실제로 작동하는 로직만을 테스트 할 수 있다고 생각하였는데, 이와 같이 서버가 작동하지 않더라도 클라이언트에서 mock server를 간단하게 작성해 두고 사용하게 될 API를 테스트하는 식으로 구현할 수 있다는 것을 알게 되었다.

- 유닛 테스트의 범위를 잘게 쪼개는 방법에 대한 감을 찾게 되었다.

- 예외사항을 테스트에 몰아 넣음으로써 코드의 정확성을 높이는 법을 훈련하였다.

- 위에서도 언급하였지만, 타이머 방식으로 작동하는 어플리케이션을 TDD로 개발하는 법을 익히게 되었다.

- 작성된 테스트들의 빽을 믿고, 리팩토링을 거침 없이 할 수 있었다.

아직 TDD 내공이 부족하다는 것을 다시 한번 느끼게 되었고, 다양한 상황에 다양한 방법으로 적용하는 법을 익혀야겠다고 생각했다. 각 상황에서 얻은 교훈을 기록해 두고, TDD 사전으로 사용하면 다른 프로젝트에 적용할 때 도움이 많이 될 것 같다(타이머에 적용하는 TDD와 같이). TDD 적용에 관한 자신감도 얻게 되었다. 무엇보다도, TDD 적용은 역시 재미있다. ㅎㅎㅎ

PS) 이상의 프로젝트는, 애자일 컨설팅에서 진행한 TDD 교육의 리허설 방식으로 진행되었습니다. 조언해주신 김창준님께 감사드립니다.
혹시 삭제해야 하면 알려주세요.