336x280(권장), 300x250(권장), 250x250, 200x200 크기의 광고 코드만 넣을 수 있습니다.
1. 개요

회사에서 테스트 지원을 하다보면 많은 개발자가 이미 만들어 놓은 테스트를 유지보수 하는데 꽤 많은 시간을 사용하고 있음을 볼 수 있다. 그 중에서도 실패한 테스트를 고치는 일이 잦다. 만약 어떤 결함이 존재할 때, 테스트가 그 결함으로 인해 실패하는 것은 매우 고무적인 일이다. 왜냐하면 테스트가 제 할 몫을 한 것이기 때문이다. 하지만 리팩토링 등으로 테스트 대상(이하 SUT, System Under Test)의 내부구현이 바뀜으로 결함유무와 관계없이 테스트가 실패하는 것은 안타까운 일이다. 이런 현상이 자주 발생하면 개발자는 테스트를 짐처럼 여기게 될 것이고, 나중에는 테스트가 정말로 가치있는지에 대한 고민을 시작할 것이다.

그렇다면 위와 같은 일이 왜 생길까? 이유는 의존성 때문이다. 테스트는 자연스레 SUT에 의존하는 부분이 생기고 이 의존성이 위 문제를 발생시킨다. 아래 테스트 코드를 보자.

@Test
public void nameShouldBeModified() throws Exception {
    final Integer id = 1;
    Name name = new Name(id, "Min");

    Name result = O.process(name);

    assertThat(result, is(new Name(id, "Min-modified")));
}
#1. Spring Batch  관련 상태기반 테스트

Spring Batch 기반의 배치 코드 중 일부분을 테스트 하는 예제이다. 위 코드 중 process의 파라메터가 바뀌기라도 하면 이 테스트는 수정되어야 한다. 따라서 process 하는 부분이 SUT와의 의존성이다. 그런데  한편 위 코드는 의존성 문제 관점에서 보면 이상적인 테스트이기도 하다. 왜냐하면 현재 SUT 중 테스트 하려는 메서드에만 의존성을 가지고 있는데 이 이상 의존성을 줄일수는 없기 때문이다. 사실 이 글에서 얘기하려는 것 중 많은 부분은 Mock 라이브러리의 사용으로 생기는 현상들에 대해서이다.

아래는 서비스를 ROS 상태로 중지시키는 시나리오를 테스트 하는 코드이다. 코드 중 'readOnlyStop'과 'readOnlyNottifer'는 'target.stop' 메서드 내부에서 사용하는 객체이다.

@Test
public void serviceShouldBeStopped_WhenNeedingReadOnlyStop() {
    when(readOnlyStop.stopNow()).thenReturn(success);
    when(readOnlyNotifier.relatedServices()).thenReturn(createRelatedServices());
    when(readOnlyNotifier.notifyToRelatedServices(any(StopMessage.class))).
        thenReturn(success);

    target.stop(readOnlyStopType, "Read Only Stop");

    verify(readOnlyStop).stopNow();
}
#2. 의존성이 많은 행위기반 테스트

위 테스트는 많은 개발자가 즐겨쓰는 Mock 라이브러리인 Mockito를 이용하여 행위검증을 하는 예이다. 위 테스트 코드를 보면 테스트가 메서드 내부에 있는 동작에 지나치게 의존하고 있는 것을 볼 수 있다. 좀더 정확하게 표현하자면 위 5줄 모두가 의존성이다. 따라서 SUT의 메서드가 리팩토링이 된다면 위 테스트는 십중팔구 컴파일 오류가 나거나 실패할 것이고, 개발자는 결국 테스트 코드를 수정해야 할 것이다.

켄트벡은 "Where, Oh Where to Test?" 라는 글에서 이 문제를 세련스럽게 Stability 라는 단어로 표현했다. Stability 문제는 두 가지 경우를 포함한다. 첫째는 테스트가 성공했는데 결함이 있는 경우이고, 둘째는 테스트가 실패했는데 결함이 없는 경우이다. 둘의 공통점은 테스트가 잘못된 피드백을 주었고 결과로 불필요한 낭비가 생겼다는 것이다. 앞서 리팩토링 후 테스트 코드의 컴파일이 실패하거나 테스트가 실패하는 경우를 언급했다. 나는 이 경우가 테스트가 실패했음에도 실제로는 결함이 아닌 경우라 생각한다. 왜냐하면 결함 여부와 관계없이 단지 구현이 바뀜으로 인해 테스트가 실패했기 때문이다. 

지금까지 얘기한 문제는 단순해보일지도 모른다. 하지만 유지보수에 많은 부담을 주는 부분이다. 그럼 어떻게 하면 Stability 문제를 해결할 수 있을까? 본인은 두 가지 방향이 있다고 생각한다.

2. 구현에 의존적인 테스트를 만들지 말자

위에서 소개한 두 예제의 차이점은 첫째 예제가 구현에 의존적인 부분이 없는 반면, 둘째 예제는 구현에 의존적인 부분이 많다는 점이다. 첫째 예제는 상태검증이라 부르고, 둘째 예제는 행위검증이라 부른다. 그렇다면 항상 첫째 예제처럼 테스트를 작성하면 되는 것일까? 안타깝게도 불가능하다. 많은 경우 테스트를 하려면 부득불 둘째 예제처럼 작성해야만 한다. 아래 코드를 보자.

public void stop(StopType stopType, String reason) {
    validate(stopType, reason);

    if (stopType == StopType.COMPLETE_STOP) {
        stopNowQuietly(completeStop, whenFailedMessageIs(""));
    } else if (stopType == StopType.READONLY_STOP) {
        stopNowQuietly(readOnlyStop, whenFailedMessageIs("ROS"));
        readOnlyNotifier.notifyIfThereAreRelatedSerivcesUsing(createMessageBy(reason)); 
    }
}
#3. 어떤 서비스의 점검을 시작하는 메서드

위 코드는 가상의 시나리오를 구현하는 코드로써 점검에 관련한 일을 한다. 하는 일은 간단한데 파라메터에 따라 일반점검 혹은 읽기가 가능한 점검 중 하나를 수행한다. 읽기가 가능한 점검 시에는 관련 된 서비스에 알림을 한다. 위 코드를 테스트해야 한다고 가정해보자. 특히 관련된 서비스에게 알림을 잘 했는지를 보장하는 것이 테스트의 가장 중요한 목적이라면 어떻게 테스트 해야 할까? 많은 경우 아래처럼 행위검증을 사용할 수 밖에 없다.

...
verify(readOnlyNotifier).notifyIfThereAreRelatedSerivcesUsing(..);
...
#4. 3번 코드를 대상으로 알림을 했는지 여부를 검증하는 코드

위 테스트는 해당 메서드가 정확히 실행되었는지를 보장해준다. 하지만 최종적으로 달성하려는 바는 같음에도 리팩토링 등을 통해 readOnlyNotifier가 아닌 다른 객체를 사용한다면 테스트의 실패와 개발자의 수정으로 이어질 것이다.

이럴 때 사용할 수 있는 대안은 통합 테스트이다. 통합 테스트를 이용하면 메서드를 실제로 실행시킬 수 있으며 어떻게(How)가 아닌 무엇(What)에 집중할 수 있는 기회를 얻는다. 다시 말해 특정 목적을 달성하려고 구현한 방법이 아닌 달성 결과를 기반으로 검증할 수 있게 된다. 그렇다면 위 예는 통합 테스트에서 어떻게 검증할 수 있을까? 메서드 호출 후 남는 전송결과로그를 살펴보면 어떨까? 전송로그결과 파일 안에 방금 전송한 로그가 존재하는 것을 확인하는 것이다. 이렇게 하면 테스트는 예전처럼 행위검증이 아닌 상태검증을 하게 된다. 그 결과로 내부구현에 대한 의존성이 사라진다. 상태검증으로 변경 된 코드는 아래와 같다.

...
verify(readOnlyNotifier).notifyIfThereAreRelatedSerivcesUsing(..);
assertThat(logInFile, is(existing()));
...
#5. 4번의 검증 코드를 상태 기반 검증으로 변환

하지만 통합 테스트는 단점이 있다. 첫째는 실행시간의 증가이다. 위 예의 경우 실제로 로깅을 하고 관련 서비스에 메세지를 보내려면 아무래도 단위 테스트보다는 실행시간이 오래 걸리게 된다. 테스트의 실행시간이 오래 걸리는 것은 개발자가 개발 시 받는 피드백 속도를 둔화시키고 결국 생산성 저하로 이어질 수 있다. 둘째는 오류가 났을 때 어디에 문제가 생겼는지 알기 어렵다는 점이다. 통합 테스트의 특성상 많은 영역이 실행될 것이고 그 중 한 군데라도 결함이 생기면 테스트는 실패할 것이다. 하지만 이 때 결함이 어떤 부분에 생겼는지 명확하지 않다는 것이 문제다. 단위 테스트가 잘 고립되어 있을 때 테스트가 실패하면 문제점은 테스트 실행 대상 내에 있다고 단정할 수 있는 것과는 대조적이다. 마지막 문제는 SUT가 아닌 다른 곳이 의존성이 생길 수 있다는 점이다. 위 예에서는 로그파일에 대한 추가 의존성이 생겼다.

사실 위와 같은 단점 때문에 통합 테스트와 행위 기반의 단위 테스트를 비교하다보면 의존성 문제를 감안하더라도 행위검증을 선택하는 경우도 많다. 이 경우에는 아래 방향을 따르면 좋다.

3. 구현에 의존적인 테스트를 만들어야 한다면 최대한 의존하는 부분을 줄이며 테스트를 만들자

A) Mockito 사용

Mockito는 최근 들어 많은 사랑을 받고 있는 듯 하다. 사내에 EasyMock,  JMock을 사용하는 개발팀도 있지만 최근에는 많은 개발팀이 Mockito를 선택하고 있다. Mockito에는 다른 경쟁 라이브러리와 비교되는 특징이 몇 가지 있다. 그 중 의존성 관점에서 주목할 만한 점은 Mockito 홈페이지에 있는 "Verify what you want"라는 인상적인 문구와 함께 소개 된 특징이다. 이런 Mockito의 특징을 보려면 다른 라이브러리와 비교하는 것이 좋을 것 같아 JMock과 비교하는 예제를 준비했다.

public void moveArticle(Integer articleId) {
     Article article = articleDAO.findBy(articleId);

     if (article.movable()) {
         articleDAO.updateArticle(article);
         articleDAO.updateAttachedFiles(article);
         articleDAO.updateAttachedPics(article);
     }
}
#6. 아티클을 이동하는 메서드

위 코드는 실제 프로젝트에서 가져와 약간의 변형을 가한 코드이다. 하는 일을 간단히 설명하자면 아티클을 찾은 후 아티클의 이동이 가능하다면 이동을 하는 예이다. 테스트에 경험이 있는 사람은 금방 알 수 있지만 행위검증이 필요한 경우이다. 이 코드를 보면 크게 두 가지 흐름이 있음을 알 수 있다. 아티클이 이동 가능한 경우와 이동이 불가능한 경우이다. 이동이 가능할 때는 아티클 정보를 업데이트 하지만, 불가능하면 아무것도 하지 않는다. JMock을 이용하여 이 두 가지 흐름 중 이동 가능한 경우를 테스트 해보았다.

@Test
public void articleShouldBeMoved() {
    context.checking(new Expectations() {
        {
            oneOf(articleDAO).findBy(articleId);
            will(returnValue(article));
            oneOf(article).movable();
            will(returnValue(Boolean.TRUE));

            oneOf(articleDAO).updateArticle(article);
            oneOf(articleDAO).updateAttachedFiles(article);
            oneOf(articleDAO).updateAttachedPics(article);
        }
    });

    sut.moveArticle(articleId);

    context.assertIsSatisfied();
}
#7. JMock을 이용하여 아티클을 이동하는 메서드를 테스트

JMock은 Expectations를 이용하여 Stubbing과 Verifying을 한다. 위에서 보면 위가 Stubbing 하는 부분이고 아래가 Verifying하는 부분이다. 이 코드를 읽어보면 'moveArticle'을 호출한 후 관련된 모든 메서드가 잘 실행되었는지 검증을 한다는 것을 확인할 수 있다. 이후 만약 개발자가 코드를 수정하다 실수로 'updateAttachedFiles'를 수행 안 하기라도 하면 바로 오류가 날 것이다. JMock을 이용하여 엄격한 검증을 하고 있기 때문이다. Expectations안에 있는 코드는 내부의 수행되어야 하는 모든 행위를 그대로 표현하고 있다. 하지만 이게 좋은 것일까? 

JMock에 대한 관심에서 잠시 벗어나 이 얘기를 처음 시작했던 의존성 문제를 생각해보자. 위 코드를 보면 바로 알수 있듯이 의존성 투성이다. articleDAO가 내부에서 하는 모든 행위가 기록되어 있다. 의존성 관점에서는 별로 탐탁치 않은 부분이다. 그렇다면 테스트를 하지 말자는 것인가? 그것은 아니다. 다만 본인은 위 테스트를 조금 느슨하게 할 수 있다고 생각한다. 예를 들어 테스트의 목적을 잘 생각해보면 중요한 부분은 메서드 내부의 movable에 따른 분기로 볼 수 있다. 이동이 가능할 때는 'updateArticle', 'updateAttachedFiles', 'updateAttachedPics'가 있는 코드블락으로 진입해야 한다. 그렇다면 코드블락으로 진입하는지 여부만 테스트를 하면 어떨까? 좀더 구체적으로 'updateArticle'이 호출된다면 그 아래도 당연히 호출 될 테니 아래와 같이 코드를 고치면 좋을 것 같다.

@Test
public void articleShouldBeMoved() {
    context.checking(new Expectations() {
        {
            oneOf(articleDAO).findBy(articleId);
            will(returnValue(article));
            oneOf(article).movable();
            will(returnValue(Boolean.TRUE));

            oneOf(articleDAO).updateArticle(article);
            oneOf(articleDAO).updateAttachedFiles(article);
            oneOf(articleDAO).updateAttachedPics(article);
        }
    });

    sut.moveArticle(articleId);

    context.assertIsSatisfied();
}
#8. JMock의 엄격한 검증으로 인해 실패하는 테스트

하지만 테스트를 돌려보면 오류가 나는 것을 확인할 수 있다. 오류 내용은 'updateAttachedFiles', 'updateAttachedPics'가 실제로 호출 되었는데 테스트에는 이런 부분에 대한 언급이 없었다는 점을 지적한다.  JMock은 기본적으로 이렇게 엄격한 검증을 수행한다. 이어 Mockito의 코드를 보자.

@Test
public void articleShouldBeMoved() {
    when(articleDAO.findBy(articleId)).thenReturn(article);
    when(article.movable()).thenReturn(Boolean.TRUE);

    sut.moveArticle(articleId);

    verify(articleDAO).updateArticle(article);
}
#9. Mockito를 이용하여 아티클을 이동하는 메서드를 테스트

위 코드를 보면 방금 전 얘기했던 느슨한 검증에 관한 아이디어가 그대로 적용되어 있음을 볼 수 있다. 'moveArticle'을 한 후 'updateArticle'이 호출되었는지만 검사하고 있다. 결과로 테스트가 SUT에 의존하던 코드가 줄었다.  

B) Mockcito의 느슨한 검증 활용

Mockito의 느슨한 검증을 활용할 수 있는 예가 하나 더 있다. 아래 코드를 보자.

@Test
public void serviceShouldBeStopped_WhenNeedingReadOnlyStop() {
        when(readOnlyStop.stopNow()).thenReturn(success);

        target.stop(readOnlyStopType, "Read Only Stop");

        verify(readOnlyStop).stopNow();
}
#10. 내부구현에 의존하는 코드가 2회 반복해서 등장

위 테스트의 진한 부분을 보면 'readOnlyStop.stopNow'이 첫줄과 마지막 줄에 반복해서 등장하는 것을 확인할 수 있다. 혹시 그 중 하나는 제거할 수 있지 않을까? 이 경우 SUT의 코드가 어떻게 되었느야에 따라 의존성을 좀더 제거할 수도 있다. 제거가 가능한지 확인하기 위해 아래 SUT의 코드를 보자.

if (readOnlyStop.stopNow()) {
    ...
} else {
    throw new FailedStopException("Cannot stop the service.");
}
#11. 방금 전 테스트가 테스트 하는 대상

위 코드는 앞에서 소개 된 점검 예제의 한 부분이다. 살펴보면 'readOnlyStop.stopNow'가 True를 반환하지 않으면 예외가 발생되게 되어있다. 이 경우 verify를 이용하여 readOnlyStop의 stopNow가 호출되었는지를 검사하지 않아도 해당 메서드가 제대로 호출되지 않으면 예외가 발생할 것이다. 다시 말해 앞서 소개한 'when(readOnlyStop.stopNow()).thenReturn(success);'가 제대로 수행되지 않으면 False가 반환 될 것이고 예외가 발생하여 테스트는 실패할 것이다. 따라서 verify부분을 제거해도 된다는 결론에 이르게 된다. 이는 대부분의 Mock 라이브러리와 마찬가지로 Mockito는 Mock 객체에 Stubbing이 되어있지 않으면 False나 Null 등을 반환하게 되어있기 때문에 가능한 일이다. 결국 테스트 코드는 아래와 같이 변경될 수 있다.

@Test
public void serviceShouldBeStopped_WhenNeedingReadOnlyStop() {
        when(readOnlyStop.stopNow()).thenReturn(success);

        target.stop(readOnlyStopType, "Read Only Stop");
        verify(readOnlyStop).stopNow();
}
#12. verify 부분이 제거되어 보다 의존성이 줄어든 테스트

이렇게 Mockito를 잘 활용하면 의존성을 완화할 수 있다. 다만 주의할 점은 Mockito를 이용하여 느슨한 검증을 할 때 테스트 목적이 훼손되지 않도록 조심해야 한다는 것이다. 예를 들어 느슨한 검증을 하다 제대로 된 검증을 하지 못하는 테스트를 만들수도 있다. 만약 테스트를 만들었는데 조금 의아스럽다면 뮤테이션 테스트를 이용하여 테스트를 검증해보는 것이 좋다.

C) Tell, Don`t Ask 원칙

이 원칙은 Law of Demeter 원칙으로 많이 알려졌다. 또 최소지식의 원칙이라 부르기도 한다. 이 원칙을 내 표현으로 설명하면 다른 객체와 의사소통 할 때 지나치게 자세히 얘기하지 말고 가능한 간결하게 얘기하자는 것이다. 아래 코드를 보자.

public void stop(Integer type, String reason) {
    ...

    if (readOnlyNotifier.relatedServices().size() > 0) {        
        if (readOnlyNotifier.notifyToRelatedServices(stopMessage) == false) {
            throw new FailedReadOnlyNotificationException();
        }
    }
}
#13.  readOnlyNotifier에게 과다하게 의존적인 코드

'...'으로 생략 된 부분을 제외한 나머지 코드 부분의 테스트를 만든다고 가정해보자. 어떻게 하면 될까? 우선 실행흐름을 보자. 잘 보면 위 코드만 가지고도 테스트 해야 할 흐름이 세 가지나 있다. 그중 관련 서비스가 한 개 이상 있어 알림을 해야하는 흐름을 테스트 한다고 생각해보자. Mockito를 이용하여 아래와 같이 테스트 코드를 작성했다. 앞서 Mockito를 소개하며 언급했던 느슨한 검증이 가능한 특징으로 인해 verify 부분이 없는 것에 유의한다.

@Test
public void stopEventShouldBeNotifiedToRelatedServices() {
    when(readOnlyNotifier.relatedServices()).thenReturn(createRelatedServices());
    when(readOnlyNotifier.notifyToRelatedServices(any(StopMessage.class))).
        thenReturn(success);

    target.stop(readOnlyStopType, "Stop For Testing");
}
#14. 위 코드에 대한 테스트 코드

이 테스트의 의존성을 더 줄일 수 있을까? Tell, Don`t Ask 원칙을 도입하면 가능하다. 위 SUT의 문제점은 'stop' 메서드에서 'readOnlyNotifier에게 관련된 서비스가 있는지 묻고' 난 후 그 결과에 따라 메세지를 보낸다는 점이다. 묻고 보내기 때문에 두 클래스 간의 접점이 두 군데가 된다. 그런데 여기에 Tell, Don`t Ask 원칙을 적용하면 물어보지 않고 단순히 요청을 말하게 된다. 접점을 한 군데로 줄일 수 있다. 이를 위해 SUT의 코드를 아래와 같이 수정하고, 기존 코드는 readOnlyNotifier로 이동시킨다.

public void stop(Integer type, String reason) {
    ...
    readOnlyNotifier.notifyIfThereAreRelatedServices(stopMessage);
}

public class ReadOnlyNotifier {
    ...
    public void notifyIfThereAreRelatedServices(StopMessage stopMessage) {
        if (this.relatedServices().size() > 0) {        
            if (this.notifyToRelatedServices(stopMessage) == false) {
                throw new FailedReadOnlyNotificationException();
            }
        }
    }
    ...
}
#15. Tell, Don`t Ask 원칙의 적용

예전과 같이 묻지 않고 그냥 'readOnlyNotifier'에게 일을 맡기고 있다. 즉, 해당 코드가 하던 일이 'readOnlyNotifier'로 이동 된 것이다. 결과로 코드가 매우 간결해졌고 커플링도 줄어들었다. 이렇게 커플링이 줄어들면 이후 리팩토링 할 때도 서로간의 간섭이 별로 없어 좋다. 최종적으로 테스트 코드는 아래처럼 의존성이 더 줄어든다. 

@Test
public void stopEventShouldBeNotifiedToRelatedServices() {
    when(readOnlyNotifier.relatedServices()).thenReturn(createRelatedServices());
    when(readOnlyNotifier.notifyToRelatedServices(any(StopMessage.class))).
        thenReturn(success);
    when(readOnlyNotifier.notifyIfThereAreRelatedServices(any(StopMessage.class))).
        thenReturn(success);

    target.stop(readOnlyStopType, "Stop For Testing");
}
#16. Tell, Don`t Ask 원칙의 적용으로 인해 처음과 비교해 의존성이 줄어든 테스트 


4. 요약

결함유무와 관계없이 실패하는 테스트는 유지보수에 부담을 더한다. 이 부담은 테스트가 증가 할수록 심화될 수 있기 때문에 가볍게 볼 수 없는 부분이다. 사실 테스트가 SUT에 의존성을 갖는 것은 매우 자연스러운 일이다. 하지만 이 문제를 해결하지 않으면 유지보수 부담을 줄일 수 없다. 따라서 테스트를 작성할 때 가능한 SUT에 의존성하지 않도록 노력해야 한다. 이를 위해 앞서 소개한 통합 테스트, Mockito, Tell, Don`t Ask 원칙을 활용할 수 있다.

WRITTEN BY
차민창
르세상스 엔지니어가 사회를 이끌어나가는 상상을 하며!

,
336x280(권장), 300x250(권장), 250x250, 200x200 크기의 광고 코드만 넣을 수 있습니다.
앞선 글에서 영향력 검토, 검증방법, 검증시점이 리팩토링 후 검증에 있어 필수적으로 고려해야 하는 부분이며 검증비용에도 영향을 미친다고 얘기했습니다. 이에 따라 단위 테스트를 분류 해보자면 검증방법으로써 분류될 수 있습니다. 하지만 단위 테스트의 탁월한 점은 단위 테스트가 검증방법으로써 가장 탁월한 선택 중 하나가 되면서 동시에 영향력 검토와 검증시점에도 긍정적 영향을 미친다는 점입니다. 위 세 가지 관점에서 단위 테스트를 평가해보았습니다.

첫째 단위 테스트는 영향력을 줄이는 데 도움이 됩니다. 단위 테스트를 하게 되면 자연스레 의존성에 대해 생각하게 됩니다. 어떤 대상에 대해 테스트를 하려면 해당 대상을 테스트 가능한 상태로 준비시켜야 하는데 이때 의존성을 없애고 단위 테스트로서의 초점을 좀 더 명확하게 하기 위해 의존성 중지(Dependency Breaking)를 하게 됩니다. 즉, 데이터베이스에 질의하는 부분을 담당하는 DAO(Data Access Object)를 사용하고 있다면 테스트 시 관심초점이 아닌 데이터베이스에 질의하는 부분을 실제로 수행하기보다는 해당 DAO를 데이터베이스에 성공적으로 질의한 것처럼 행동하는 가짜(Stub, Fake Object, Mock 등)로 바꾸는 것입니다. 이런 과정을 통해 개발자는 여러 의존성을 느끼게 됩니다. 너무 많은 의존성이 있다고 느끼는 것은 그만큼 연결된 곳이 많다는 증거이고, 이는 영향 범위를 다시 생각해보라는 신호가 될 수 있습니다. 만약 이 신호에 귀를 기울인다면 영향범위를 좀더 줄여볼 수 있습니다. 이것은 OOP(Object Oriented Programming)에서 강조하는 의존성 최소화(Loosed Coupling)와 같은 맥락입니다.

둘째 단위 테스트는 검증비용을 줄여줍니다. 검증비용을 줄이는 데에는 크게 두 가지 부분이 작용합니다. 단위 테스트는 매우 빠르게 수행된다는 점과 한번 작성해놓은 단위 테스트는 큰 노력 없이 다시 실행할 수 있다는 점입니다. 이 부분은 사람이 테스트를 수행하는 것과 비교할 때 좀 더 명확해집니다. 사람이 어떤 기능 10개를 테스트 할 때 하나에 1분씩 본다고 해도 10분이 걸립니다. 또한 나중에 같은 테스트를 해보려면 또 10분을 투자해야 합니다. 하지만 단위 테스트를 그렇지 않습니다.

마지막으로 단위 테스트는 검증시점을 앞당겨 줍니다. 예전에 여러 코드에 존재하는 잘못된 코드 관례(Code Convention)를 수정해야 했습니다. 이 작업을 수동으로 하기엔 작업범위가 넓었기 때문에 정규표현식을 이용하여 한꺼번에 수천 개의 파일을 수정했습니다. 수정 후 특별히 컴파일 오류가 발생하지 않았고 저는 잘 되었다고 생각하고 커밋(Commit)을 했습니다. 그런데 몇 분 후 CI서버에서 메일이 한 통 왔습니다. 내용을 보니 특정 단위 테스트가 실패했다는 메일이었습니다. 자세히 살펴보니 방금 수정하면서 오류가 새롭게 생겨 발생한 문제였습니다. 저는 바로 코드를 롤백(Rollback)했고, 정규표현식을 고쳐 다시 파일을 수정한 후 일을 마무리 지을 수 있었습니다. 만약 단위 테스트가 없었더라면 어떤 결과가 일어났을까요? 코드 관례에 관계 되어 다소 부담이 적은 수정이었고 컴파일 오류 또한 발생하지 않았기 때문에 십중팔구 그냥 모르고 지나갔을 것입니다. 게다가 너무 광범위한 수정이라 QA를 받기 어려워 QA를 따로 받지 않았을 것입니다. 결국 서비스 중에 문제가 발생했을 테고 고객문의가 들어왔을 것입니다. 하지만 단위 테스트가 있었고 CI가 커밋이 되는 순간 테스트를 수행하고 실패한 경우 메일을 보내주었기 때문에 문제를 조기에 인식할 수 있었습니다.

이렇게 단위 테스트는 모든 방면에 있어 탁월함을 자랑하며 이로 인해 리팩토링에 대한 투자비용을 상당부분 줄여줍다. 다만 단위 테스트를 작성하는 데는 다소의 초기비용이 필요합니다. 하지만 리팩토링 뿐 아니라 어떤 형태로든 코드는 계속 변한다는 점을 생각해볼 때 단위 테스트를 작성해놓는 것은 계속적으로 도움을 줄 것이고 시간이 가면 갈수록 현명한 투자라고 느끼게 될 것입니다.

이어 단위 테스트는 인터페이스 변경 없이 내부코드만 변경이 있을 경우 그 영향력이 아무리 넓다 하더라도 영향력을 깨끗이 제거해줄 수 있습니다. 변경범위의 측면에서 내부코드의 변경은 인터페이스 변경과는 확연하게 구분되는 중요한 특징을 가집니다. 그것은 인터페이스 변경은 영향을 받는 곳 즉 해당 인터페이스를 사용하는 곳의 변경도 필요한 반면, 내부코드 변경의 경우는 영향 받는 곳의 코드변경은 없다는 점입니다. 이 점을 통해 알 수 있는 사실은 만약 우리가 변경되는 내부코드의 변경 전/후의 동일성을 확실히 보장한다면 내부코드 수정으로 말미암아 영향 받는 부분에 대해서는 더는 신경 쓰지 않아도 된다는 것입니다. 왜냐하면 변경이 발생한 곳은 내부코드뿐이고 내부코드의 동작은 전과 동일하기 때문입니다. 그리고 이것을 가능하게 해주는 것이 단위 테스트입니다. 그렇다면 단위 테스트를 이용하여 변경 전/후의 동일성을 확실히 보장하기 위해서는 어떻게 해야 할까요? 이 부분은 무척 어려운 주제라 생각하지만 본인의 개인적인 경험을 이야기하면 약간의 참고가 될 수 있으리라 봅니다.

저는 지난 리팩토링 프로젝트에서 리팩토링을 시작하기 전, 즉 변경 전 기준으로 단위 테스트를 신중하게 작성했습니다. 가장 먼저 주요하게 자주 실행되는 부분(주 흐름, Main Flow)에 대해서 테스트를 작성했습니다. 만약 테스트가 통과하면 예외적 흐름에 대해서도 테스트를 작성했습니다. 제가 인지하고 있는 모든 흐름을 테스트 한 후에는 커버리지(Coverage) 측정 도구를 수행해보았습니다. 커버리지 측정 도구는 아래 그림처럼 실행 된 코드 경로는 녹색으로 보여주었고 단위 테스트가 실행하지 않은 부분은 빨간색으로 보여주었습니다. 그리고 만약 빨간색 부분이 보이면 그 부분이 실행되도록 단위 테스트를 더 보강하였습니다.


그림#1. 실행 된 부분과 실행되지 않는 부분을 구분할 수 있다. 위 화면은 Undercover를 이용하여 생성했다.

이렇게 했던 이유는 단위 테스트에 의해 실행되지 않는 부분이 테스트 되지 않아 동일성 보장에 허점이 생기는 것을 방지하기 위함이었습니다. 이렇게 변경 전 기준으로 테스트 작성을 완료하고 나서야 리팩토링을 시작했습니다. 조금 고치고 테스트 수행을 해서 통과하면 다시 조금 고치는 것을 반복했습니다. 그리고 모든 리팩토링이 완료 되면 QA 부서에 리팩토링 완료를 알렸습니다. 하지만 때때로 영향력 범위 등의 문제로 QA가 검증하는 것이 합리적이지 않다고 판단하면 QA부서를 통하지 않고 리팩토링을 바로 완료시키기도 했습니다. 이것은 제가 무모해서가 아니라 단위 테스트에 의해 동일성이 보장되었다는 자신감이 있었기 때문에 가능했던 일입니다.

동일성을 보장했음에도 QA단계를 거쳤던 것은 계속적으로 강조했던 것처럼 이중검증을 해서 단위 테스트에서 동일성 보장을 하려 했음에도 실수 등으로 인해 생길 수 있는 문제를 발견하기 위해서였습니다. 이 판단은 틀리지 않았는데 아무리 신경 써서 단위 테스트를 만들어도 이후에 때로 잘못된 부분이 발생되곤 했기 때문입니다. 이로 인해 단위 테스트만으로는 부족함이 있다는 것을 깨달았으며 인수(Acceptance Testing) 테스트의 필요성을 느꼈습니다.

추가로 이렇게 많은 도움을 주는 단위 테스트를 만드는 것도 중요하지만 제가 코드 관례를 수정할 때 경험했던 것처럼 단위 테스트를 실제로 실행하여 잘못된 부분에 대한 피드백(Feedback)을 빠르게 받는 것도 매우 중요합니다. 하지만 큰 규모의 환경에서 순수한 단위 테스트와 데이터베이스 혹은 외부 네트워크와 통신하는 통합 테스트가 섞여 있을 때 로컬 개발 환경에서 무엇인가를 수정 할 때마다 모든 테스트를 수행해보는 것은 실행속도 때문에 개발자에게 부담스러운 일이 됩니다. 따라서 빠른 개발을 위해 본인이 현재 고치고 있는 부분에 대해서만 테스트를 수행하게 되는 경향이 있습니다. 하지만 내가 부분적으로 고치고 있다고 생각하는 부분이 다른 곳에도 영향을 주는 경우는 많습니다. 이런 경우 CI 서버의 커밋빌드를 활용하면 좋습니다.

유명한 CI 서버 대다수가 커밋빌드 기능을 제공합니다. 커밋빌드란 소스가 저장소에 커밋 되는 순간 CI 서버가 알아차리고 빌드와 테스트를 수행하는 기능입니다. 만약 컴파일이나 테스트가 실패하면 CI 서버는 커밋한 개발자에게 메일이나 SMS를 발송하여 실패를 알립니다. 개발자는 이런 알림을 이용하여 빠르게 문제발생을 인지할 수 있고 결국 검증시점이 앞당겨지는 것과 동일한 효과를 취하게 됩니다.

수정 이력
2010/05/07 : 이 주제가 여러 편에 걸쳐 게시되었기 때문에 내용이 중복되지 않고 읽는이가 부드러운 흐름을 타게하기 위해 내용을 다소 수정합니다.


WRITTEN BY
차민창
르세상스 엔지니어가 사회를 이끌어나가는 상상을 하며!

,
336x280(권장), 300x250(권장), 250x250, 200x200 크기의 광고 코드만 넣을 수 있습니다.
인터페이스를 수정하는 경우 제게 있어 가장 인상 깊었던 부분은 컴파일러가 영향력 검토에 있어 매우 중요한 역할을 한다는 것이었습니다. 예를 들어 특정 메서드의 인자를 변경해야 한다고 가정해봅시다. 해당 메서드의 인자를 변경하고 컴파일을 수행합니다. 이때 만약 컴파일 오류가 발생한다면 해당 메서드를 사용하고 있는 곳이 있다는 뜻이며 변경에 따라 영향을 받는 곳이 있다는 증거입니다. 실제로 컴파일은 이클립스와 같은 IDE를 통해 실시간으로 이뤄지기 때문에 개발자는 매우 빠르고 정확하게 영향력 검토를 할 수 있습니다.

하지만 이 같은 컴파일러의 지원에도 영향력 검토가 쉽지 않은 경우가 있습니다. 대표적인 경우 중 하나는 클래스 경로 정보를 갖고 있는 설정 파일입니다. 최근 많이 사용되는 여러 프레임웍에서는 설정 파일에 클래스 경로를 적게 하고 실행 시에 그 정보를 이용하여 클래스를 동적으로 생성합니다. 이런 경우 해당 클래스의 이름이나 위치가 변경되었을 때 컴파일러를 통해서는 영향력을 확인할 수 없게 됩니다. 또 한 가지 대표적인 경우는 JSP의 표현식(Expression Language)에서 특정 클래스의 메서드를 참조하는 경우입니다. 이 경우에도 역시 컴파일러를 통해서는 영향력을 확인할 수 없습니다. 따라서 이런 부분에 대해 자동으로 영향력 검토를 해주는 도구가 없는 경우, 설정 파일이나 JSP의 표현식과 같은 영역에 대해서는 개발자가 직접 검색을 하는 등의 방법을 통해 영향력 검토를 수행해야 합니다. 이런 경우 컴파일러만을 이용하여 영향력 검토를 할 경우보다 훨씬 더 큰 비용이 필요해지며 사람이 개입함으로 인해 실수가 생길 확률도 높아지게 됩니다. 따라서 컴파일러를 이용한 영향력 검토는 한계가 있다고 볼 수 있습니다.

한편 컴파일러를 검증방법으로써도 활용할 수 있습니다. 마틴 파울러의 리팩토링을 보면 메서드의 이름을 이해하기 좋게 바꾸거나 세부적인 코드의 변경 없이 특정 메서드를 다른 클래스로 옮기거나 하는 것도 리팩토링으로 분류하고 있습니다. 저 역시 지난 프로젝트에서 이러한 유형의 리팩토링을 많이 했습니다. 특히 첫 번째 리팩토링 프로젝트에서 코드를 수정하기 보다는 클래스나 메서드를 옮기고 이름을 바꾸는 등의 일을 많이 했습니다. 당시 저는 리팩토링 후 코드가 전과 같이 문제 없는 상태로 있다는 것을 어떻게 확신 할 수 있는지에 대해 부단히 고민했습니다. 그리고 위와 같이 클래스를 옮기는 등에 리팩토링을 가만히 살펴보았더니 이들에 대한 검증은 컴파일러가 맡고 있었습니다. 예전에는 컴파일러는 자바코드를 JVM이 실행 가능한 바이트 코드(Byte Code)로 변경해주는 도구라고만 생각했었습니다. 그러나 위 경험을 통해 컴파일러의 검증기능에 주목하기 시작했고 이어 컴파일러의 검증범위에 대해 생각해보게 되었습니다. 당시 작업을 하며 가장 어려울 때는 전에 얘기한 설정파일이나 JSP 표현식이였습니다. 이 부분은 컴파일러에서 검증하지 못했습니다. 결국 컴파일러의 검증범위 밖에 있는 것들이 문제였고 이런 부분들도 컴파일러에서 검증할 수 있으면 얼마나 좋을까라는 생각을 했습니다. 즉 컴파일러의 검증범위가 좀더 넓어졌으면 좋겠다고 생각한 것입니다.

이 때문인지 몰라도 Effective Java 2판의 저자인 Joshua Bloch는 그의 책을 통해 무엇인가 잘못 되었을 때 컴파일러가 알아차릴 수 있게 코드를 작성하는 것을 강조합니다. 저는 컴파일러가 검증방법으로써 이점이 있지만 한계 또한 있다는 점을 이미 언급했습니다. 하지만 컴파일러 검증을 이용하면 테스트 보다 더 이른 시점에 검증이 가능하다는 점과 단위 테스트와 검증부분이 중복 된다 하더라도 이중검증을 할 수 있다라는 점 때문에 Effective Java 2판의 내용과 같이 컴파일러의 검증범위를 넓히려는 노력은 매우 합리적이라 봅니다.

아래는 Effective Java 2판에서 저자가 강조했던 내용 중 실무에서 자주 발생하는 사례를 정리한 것으로써 컴파일러의 검증범위를 넓히기 위해 활용될 수 있습니다.

1. Generic 활용

Generic을 사용하지 않는 코드
List myIntList = new LinkedList();
myIntList.add(new Integer(0));
String x = (String) myIntList.iterator().next();

위 예제는 Generic을 사용하지 않고 List를 사용하는 예제입니다. 위 코드는 정상적으로 컴파일이 되지만 실행시점에 ClassCastingException이 발생합니다. 왜냐하면 두 번째 줄에서 Integer객체를 생성해 넣었는데, 세 번째 줄에서는 해당 객체를 String 사용하려고 했기 때문입니다. 여기서 주목해야 할 점은 오류를 컴파일 시에는 발견할 수 없었다는 점입니다. 하지만 자바 1.5에서 도입 된 Generic로 말미암아 컴파일 시점에 위 오류를 발견할 수 있게 되었습니다.

Generic을 활용한 코드
List<Integer> myIntList = new LinkedList()<Integer>;
myIntList.add(new Integer(0));
String x = myIntList.iterator().next();

위 예제는 Generic을 사용하여 바뀐 코드입니다. 위 예제는 첫 번째 예제와는 달리 세 번째 줄에서 컴파일 오류가 발생합니다. 왜냐하면 해당 List는 Integer로 사용하기로 결정되었기 때문에 String으로 사용할 수 없기 때문입니다. 위와 같이 Generic을 이용하면 컴파일러에서 더 많은 검증을 할 수 있습니다.

2. Enum 활용

Enum을 사용하지 않는 코드
public boolean isModifable(String osType) {
... // 유효한 osType은 Linux, Window이다.
}

위 코드에서 볼 수 있듯이 예전에는 누군가가 실수하여 type에 "Computer"라는 문자열이 잘못 들어와도 컴파일러가 검증할 방법이 없었습니다. 예를 들어 허용되지 않는 문자열인 Computer가 데이터베이스에 저장되어도 그것을 특별히 검사하는 코드가 없다면 문제를 발견하기 어려웠습니다. 하지만 Enum에 도입으로 컴파일 시점에 오류를 발견할 수 있게 되었습니다.

Enum을 활용한 코드
public boolean isModifable(OSType osType) {
...
}

public enum OSType {
LINUX, WINDOW
}
        
위와 같이 Enum에 사용으로 OSType에 미리 정의되지 않은 값은 넘길 수 없게 되었습니다. 만약 LINUX나 WINDOW가 아닌 다른 타입을 넘기려고 하면 컴파일 오류가 발생할 것입니다.  

3. Switch 문을 Constant-specific methods 로 변경

Constant-specific methods를 사용하지 않는 코드
public enum Operation {
  PLUS, MINUS, TIMES, DIVIDE, POWER;

  double eval(double x, double y) {
    switch(this) {
      case PLUS:   return x + y;
      case MINUS:  return x - y;
      case TIMES:  return x * y;
      case DIVIDE: return x / y;
    }
    throw new AssertionError("Unknown op: " + this);
  }
}

위 예는 SUN의 공식 문서에서 가져온 예입니다. 위 예는 컴파일도 잘 되고 동작도 잘 됩니다. 하지만 자세히 보면 POWER 타입이 추가 되었는데 실수로 switch 문에 새로운 타입에 대한 코드를 넣지 않았음을 볼 수 있습니다. 이 때 컴파일은 잘 되지만 실행 중 오류가 발생합니다.

Constant-specific methods를 사용하지 않는 코드에 Type 추가 시
public enum Operation {
  PLUS   { double eval(double x, double y) { return x + y; } },
  MINUS  { double eval(double x, double y) { return x - y; } },
  TIMES  { double eval(double x, double y) { return x * y; } },
  DIVIDE { double eval(double x, double y) { return x / y; } };

  abstract double eval(double x, double y);
}

그래서 코드를 위와 같이 작성하면 eval 메서드를 사용하는 측에서는 코드 변경이 전혀 없으면서도, 타입이 추가 되었을 때 eval 메서드를 구현하지 않으면 컴파일 오류가 발생하게 됩니다.

저는 컴파일러를 이용한 영향력 검토와 검증방법으로써 사용이 가능하지만 한계가 있다는 점을 설명했습니다. 또한 Effective 2판의 조언에 따라 컴파일러 검증범위의 확장이 가능한 점 또한 설명했습니다. 하지만 여전히 컴파일러의 검증범위는 미비하며 컴파일러를 아무리 잘 활용해도 검증하지 못하는 범위가 많음을 알 수 있습니다. 따라서 컴파일러를 적극적으로 활용해야 할 필요는 있지만 한편으로는 컴파일러가 하지 못하는 부분에 대한 검증을 시도해야 할 필요가 있습니다. 이 점이 바로 단위 테스트, 인수 테스트, End-To-End 테스트와 같은 테스트가 필요한 이유입니다.

수정 이력
2010/05/07 : 이 주제가 여러 편에 걸쳐 게시되었기 때문에 내용이 중복되지 않고 읽는이가 부드러운 흐름을 타게하기 위해 내용을 다소 수정합니다.

WRITTEN BY
차민창
르세상스 엔지니어가 사회를 이끌어나가는 상상을 하며!

,
336x280(권장), 300x250(권장), 250x250, 200x200 크기의 광고 코드만 넣을 수 있습니다.
마틴 파울러는 그의 저서인 ‘리팩토링’에서 리팩토링을 다음과 같이 정의했습니다.

“리팩토링은 외부의 동작을 바꾸지 않고 내부의 구조를 개선하는 것”

검증의 시각에서 이 정의를 볼 때 우리가 리팩토링을 하며 개선 외에 추구해야 할 또 다른 목표를 알 수 있습니다. 바로 리팩토링을 하더라도 외부의 동작을 바꾸지는 않는 것입니다. 다시 말해 리팩토링을 어떻게 하든 예전의 동작은 그대로 유지되어야 합니다. 지난 글에서 이 부분을 ‘검증’이라 불렀습니다. 검증을 하려 하다 보면 항상 반복적으로 고려하는 요소가 있음을 알게 됩니다. 바로 영향력 검토, 검증방법 결정, 검증시점입니다.

영향력 검토는 내가 수행하는 리팩토링이 어디에 영향을 미치는지 확인하는 단계입니다. 예를 들어 사용자가 남자이면 True를 반환하는 메서드가 있고 이 메서드는 프로젝트 전반에 걸쳐 광범위하게 사용되고 있다고 가정해봅시다. 이때 리팩토링 중 실수로 남자임에도 False가 반환되게 되었습니다. 이때 프로젝트에는 어떤 영향이 미칠까요? 아마 이 메서드를 사용하는 모든 곳의 기능이 정상적으로 동작하지 않을 것입니다. 이것이 바로 해당 메서드가 가지고 있는 영향력이며, 우리는 리팩토링 전에 이 영향력을 반드시 알아내야 합니다. 때로 리팩토링 시 내가 변경하는 곳으로 인해 생기는 영향력에 대해 정확히 파악하기가 어려운 경우가 있습니다. 이런 경우는 정확한 검증을 할 수 없다는 뜻과 동일하기 때문에 영향력을 파악하기 전에는 리팩토링 진행을 멈추는 것이 좋습니다,

그 다음으로는 검증방법을 결정해야 합니다. 특정 부분을 리팩토링 한 후에 이 부분의 동작이 예전과 정확히 동일한지를 실제로 검사하는 단계입니다. 예를 들어봅시다. F를 리팩토링 할 예정입니다. 이 부분은 기능 A와 B에 영향을 미칩니다. 따라서 리팩토링 후 A와 B에 대한 기능을 테스트 인력이 검토하기로 했습니다. 이 예는 영향력 검토를 하고 검증방법을 결정하는 예를 보여줍니다. “A와 B에 대한 기능을 테스트 인력이 검토하기로 했다” 부분이 검증방법을 결정한 것이라 볼 수 있습니다. 예에서처럼 테스트 인력이 검증할 수도 있지만 자동화 된 단위 테스트 등을 이용할 수 있습니다.

마지막으로 검증시점에 대한 고려가 필요합니다. 검증시점은 말 그대로 검증을 수행하는 시점을 뜻합니다. 앞선 내용에서 이미 언급했지만 보다 빠르게 검증하는 것은 비용 면에서 많은 유익을 줍니다. 지난 글에서는 표를 이용하여 개발 중, QA 중, 서비스 중으로 나눠서 설명을 했었는데 이 것은 서비스 흐름 관점의 구분이라 볼 수 있습니다. 좀 더 기술적으로는 컴파일 시점과 실행 시점으로 분류해볼 수 있습니다. 이 경우 컴파일 시점(Compile Time)이 실행 시점 보다 이르며 따라서 문제는 컴파일 시점(Runtime)에 발견되는 것이 좋습니다. 이 점은 컴파일러 검증의 필요성을 나타냅니다. 또한 실행 시점 중에서도 고객에게 서비스를 하고 있는 실행 시점보다는 테스트를 수행하고 있는 실행 시점에 문제가 발견되는 것이 더 좋습니다. 이 점은 단위 테스트와 CI서버 도입의 필요성을 나타냅니다.

위 세 가지 점에 대해 고려한 후에는 리팩토링을 하기 위해 필요한 비용과 리팩토링 후 검증을 하기 위해 필요한 비용, 즉 리팩토링에 대한 투자비용을 생각해볼 수 있습니다. 투자비용을 생각해보는 이유는 리팩토링 또한 일종의 투자이기 때문입니다. 리팩토링은 현재 어떤 노력을 기울여 이후의 유지보수 등의 노력을 적게 들이려는 노력의 일환입니다. 그렇기 때문에 리팩토링에 대한 투자비용을 따져보고 우리가 투자를 할 때 의례 하듯이 이 투자로 인해 얻어질 거라 생각하는 이익보다 투자비용이 크다고 판단한다면 리팩토링을 진행할지에 대해 다시 한번 생각해보는 것이 좋습니다. 또한 투자의 관점에서 리팩토링을 보면 적게 투자해서 많이 얻는 것도 중요합니다. 주식투자에서 이것을 가능하게 가능 것이 저 평가 된 우량주를 발견하는 것이라면, 리팩토링에서는 아래 그림과 같이 영향력과 검증방법에 따른 비용을 줄이고 검증시점을 앞당기는 것이 적게 투자해서 많이 얻는 열쇠라 할 수 있습니다.


그림#1. 영향력, 검증비용을 감소시키고 검증시점을 앞당기는 것

수정 이력
2010/05/07 : 이 주제가 여러 편에 걸쳐 게시되었기 때문에 내용이 중복되지 않고 읽는이가 부드러운 흐름을 타게하기 위해 내용을 다소 수정합니다.

WRITTEN BY
차민창
르세상스 엔지니어가 사회를 이끌어나가는 상상을 하며!

,