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 크기의 광고 코드만 넣을 수 있습니다.
웹개발을 하면서 한글은 매우 어려운 문제입니다. 제 경우에는 한글 문제만큼은 단기 학습을 통해서 해결이 되지 않았습니다. 온갖 문서를 읽어보고 실험해보고 했지만 정확히 이해하는 것은 매우 힘들었습니다. 또한 조금 이해한 것 같으면 계속해서 새로운 문제가 등장하였습니다. 지금도 여전히 깊은 이해는 없다고 생각합니다. 하지만 경험을 정리하면 누군가에게는 도움이 될수도 있으리란 기대에 내용을 정리해봅니다.

1. 서버 측면에서 POST와 GET의 처리의 차이점

저는 POST로 보내든 GET으로 보내든 request.setCharacterEncoding(CHARSET)만 호출해주면 자바 내에서의 한글처리는 완료된다고 알고 있었습니다. 하지만 실제로는 그렇지 않았습니다. request.setCharacterEncoding(CHARSET)는 POST방식에만 유효합니다. GET방식의 캐릭터셋 정의는 톰캣의 경우 server.xml의 Connector 설정쪽에 URIEncoding라는 옵션을 통해서 가능합니다. 이 옵션에 캐릭터셋을 정의하게 되면 GET방식으로 오는 파라메터에 대해서 해당 캐릭터셋을 이용하여 디코딩을 합니다.(만약 지정되어 있지 않다면 ISO-8859-1을 기본적으로 사용하게 됩니다.)

결론적으로 POST와 GET을 모두 정확하게 처리하려면 setCharacterEncoding뿐만 아니라 URIEncoding이 정의가 되어 있어야 합니다. 또한 GET방식은 브라우저에 따라서 자동으로 URL인코딩을 해주지 않는 경우가 있기 때문에 GET파라메터에 대해서는 항상 URL인코딩을 해주는 것이 좋을 것으로 보입니다.

2. 항상 setCharacterEncoding을 해주어야 하는 이유?

어느 순간 왜 항상 setCharacterEncoding을 해줘야 하는지에 대한 의문이 들었습니다. 송신측에서 내가 보내는 데이터가 어떤 타입인지 명시해주고 수신측에서 그 값에 따라 동적으로 처리해주면 좋을텐데라는 생각이 들었습니다. 처음에는 HTTP 해더를 살펴보면 뭔가 답이 있을 것이라 생각했습니다. 파이어폭스의 플러그인인 파이어버그를 이용하여 서버로 날아가는 HTTP 패킷을 살펴보았습니다. 비슷해 보이는 해더를 찾아볼 수가 없었습니다. HTTP RFC를 열심히 뒤져보았습니다. 그러나 요청에서 사용할 수 있는 해더 후보 목록 중에서는도무지 해당 내용을 찾아볼 수가 없었습니다. HTTP 1.1이 다국어에 대한 고려를 미처 하지 못해 지원이 안 되는구나라고 생각했습니다.

3. Connector의 useBodyEncodingForURI 옵션을 발견

그러던 중 톰캣의 useBodyEncodingForURI 라는 옵션의 명세를 보게됐습니다. 내용은 아래와 같았습니다.


This specifies if the encoding specified in contentType should be used for URI query parameters, instead of using the URIEncoding. This setting is present for compatibility with Tomcat 4.1.x, where the encoding specified in the contentType, or explicitely set using Request.setCharacterEncoding method was also used for the parameters from the URL. The default value is false.

그 중 위 밑줄 처친 부분이 심상치 않게 느껴졌습니다. 분명히 "contentType안에 명시된 인코딩"이라고 하고 있습니다. contentType이라는 해더가 분명히 있을 것이라 생각하게 되었습니다. 

4. Content-type=application/x-form-urlencoded;charset=UTF-8 발견

문제에 대해 여러 지인들에게 질문을 던지던 중 제 사수이시기도 했던 선배분이 톰캣 소스를 보고 HTTP 해더에 "Content-type=application/x-form-urlencoded;charset=UTF-8"과 같이 보내주면 setCharacterEncoding을 하지 않아도 UTF-8로 파라메터가 읽혀진다는 것을 알려주었습니다. 정말 테스트해보니 아무것도 정의하지 않고 getCharacterEncoding을 하니 UTF-8이 반환되었습니다. 

5. 그렇다면 어떤 근거로?

처음 가졌던 의문에 대한 답은 된 셈이였습니다. 하지만 찝찝합니다. 어떤 근거로 톰캣이 이렇게 구현을 했는지가 궁금했던 것이였습니다. 이것 때문에 (나름대로는)많은 시간을 들여서 찾아본 결과 해당 내용에 대해 다룬 내용을 찾을 수 있었습니다.

HTTP/1.1 uses many of the constructs defined for Internet Mail (RFC 822 [9]) and the Multipurpose Internet Mail Extensions (MIME [7]) to allow entities to be transmitted in an open variety of representations and with extensible mechanisms.
http://tools.ietf.org/html/rfc2045#section-5

The purpose of the Content-Type field is to describe the data contained in the body fully enough that the receiving user agent can pick an appropriate agent or mechanism to present the data to the user, or otherwise deal with the data in an appropriate manner. The value in this field is called a media type. 
즉 HTTP에서 데이터(Entities) 전송을 위해 MIME 사용이 가능합니다. 그리고 그 중 Content-Type은 수신측이 우리가 보내는 데이터를 정확히 해석하는 것을 도와주기 위해 사용됩니다. 즉 Content-Type은 위의 "Content-type=application/x-form-urlencoded;charset=UTF-8" 와 같이 여러 가지 정보를 기술하기 위해 사용 될 수 있는 것입니다.

6. 남은 부분

하지만 아직까지 남아있는 궁금증이 있습니다. 그것은 바로 "브라우저에서 해더에 Content-Type을 함께 보내게 하려면 어떻게 해야하나"라는 문제입니다. 이 방법을 알게되면 서버에서 획일적으로 하는 setCharacterEncoding을 완전히 제거할 수 있습니다. 대신 톰캣은 해더에서 정보를 읽어 상황에 따라 적절하게 요청을 읽을 수 있을 것입니다. 이것은 블로그의 트랙백과 같은 기능에서 유용하게 이용될 수 있을 것 같습니다. 트랙백은 여러 서비스들에서 보내며 그들의 인코딩은 다양할 수 있기 때문입니다. 

이 부분을 찾아내기 위해여 여러 테스트를 해보았으나 아직 찾지 못했습니다. 그래서 우선은 알아낸 부분까지 기록하고 나중에 이 문제와 관련한 또다른 지식이나 이해가 생기게 되면 계속해서 이 포스트에 정리해나가도록 하겠습니다. 

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

,
336x280(권장), 300x250(권장), 250x250, 200x200 크기의 광고 코드만 넣을 수 있습니다.
Webwork에는 재밌는 기능이 하나 있습니다. 그것은 요청 파라메터를 모델 객체로 바로 매핑 시켜주는 기능입니다. 예제 코드를 만들어 보았습니다.

public class Param extends ActionSupport {

    private final static Log log = LogFactory.getLog(Param.class);
    private Person person;
   
    public String execute() {
        log.info(person.getName());
        log.info(person.getAge());
        return SUCCESS;
    }
    public Person getPerson() {
        return person;
    }

    public void setPerson(Person person) {
        this.person = person;
    }

    private class Person {
        private String name;
        private Integer age;

        public Integer getAge() {
            return age;
        }
        public void setAge(Integer age) {
            this.age = age;
        }
        public String getName() {
            return name;
        }
        public void setName(String name) {
            this.name = name;
        }
    }
}

위와 같은 액션을 만든 후 아래와 같이 접근해보면,
http://localhost:8080/Param.action?person.name=Min&person.age=90

콘솔에 이름과, 나이가 찍히게 됩니다. GET파라메터가 객체로 바로 매핑 된 것입니다. 내부적으론 Person객체의 setter들이 호출 될 것입니다.

처음 보았을 때는 상당히 유용한 기능이라고 생각했습니다. 반복되는 코드의 작업을 줄여줄 것이라 생각했기 때문입니다. 하지만 좀 더 사용하다보니 절대 사용하지 말아야 하는 기능 중에 하나라고 생각하게 되었습니다.

이유는 "모델 객체가 요청 파라메터의 영역으로까지 영향을 미치게 되었기 때문"입니다. 즉 모델과 요청 파라메터 사이에는 강한 결합(Tight coupling)이 발생한 것입니다. 만약 위의 person.name, person.age와 같은 파라메터가 여기 저기(JSP or JS 등)에서 사용되고 있는 상황에서, 어떤 이유로 Person 객체의 name 필드가 nickname으로 수정되고 setter 또한 setNickname으로 수정되어야 한다고 가정 해봅시다. 이 때 여기 저기 노출되어 있는 파라메터명이 모두 수정(person.name->person.nickname)되어야 합니다. 아마 단순히 서블릿만을 사용해 본 신입 개발자도 의아한 표정으로 물어볼 것입니다.

"모델 클래스를 변경했을 뿐인데 왜 파라메터명들이 모두 바뀌어야하죠? 제 서블릿 코드는 자바 코드만 고치면 되는데요."

그렇습니다. 지금 상황에선 차라리 예전 서블릿에서 많이 쓰던 아래와 같은 코드가 낫습니다.

String name = request.getParameter("name");
String age = request.getParameter("age");
Person person = new Person();
person.setName(name);
person.setAge(age);

(참고. 서블릿과 구 프로그래밍 방식이 무조건 좋다는 말은 아닙니다. 요청과 모델 사이에 매핑 코드가 존재하는 구조가 좋다는 말입니다.)

위와 같은 코드는 모델이 바뀐다고 요청 파라메터까지 바뀌지 않아도 됩니다. 우리는 이것을 흔히 "느슨한 결합( Loose coupling)"이라고 얘기합니다. 사실 원래 신경쓰지 않아도 우리가 늘 해온대로만 코딩해도 느슨하게 이루어지던 부분입니다. 그런데 위의 경우에는 소위 새로운 무엇이라고 불리는 것이 오히려 기존의 장점을 없애고 해악을 끼쳤다고 볼 수 있겠습니다.

그렇기 때문에 우리는 새로운 기술을 적용할 때 좀더 신중해야합니다. 새로운 것이 항상 긍정적인 효과만을 가져다주진 않기 때문입니다. 그리고 위 서블릿의 예에서 볼 수 있듯이 오래된 방식이라고 해서 항상 퇴출해야 하는 것만은 아닙니다. 오래된 것은 오래된 것 나름대로의 숙성된 향이 있기 때문입니다. :)

예상 반론. 누군가는 변화가 발생했을 때 기존의 setter 인터페이스를 그대로 유지하면서 새로운 인터페이스로 위임하면 되지 않겠느냐고 말할 수 있겠습니다. 물론 이것은 분명히 하나의 해결책이 될 수 있습니다. 하지만 좋은 해결책은 아닙니다. setter가 많아짐으로 인해 클래스의 복잡도(setter가 많은 것도 복잡도가 높아지는 요인이라고 생각합니다)가 증가하기 때문입니다. 또한 구 setter들 위에 다른 개발자를 위해 주석을 달아놔야 할 것입니다. "기존 웹 파라메터와의 호환성을 위해 유지함"이라고 말입니다.

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

,
336x280(권장), 300x250(권장), 250x250, 200x200 크기의 광고 코드만 넣을 수 있습니다.
예전에 JVM(Java Virtual Machine) 옵션 튜닝에 빠져 관련문서를 열심히 보던 때가 있었습니다. JVM 옵션을 튜닝하면 뭔가 그럴싸한 성과를 낼 수 있을 것이라 생각했었습니다. JVM 옵션 튜닝에 대해 뭔가 큰 환상이 있었습니다. 그러던 중, 꽤 큰 시스템을 대상으로 JVM 옵션을 튜닝해볼 수 있는 기회가 주어졌습니다. 기쁜 마음으로 다양한 실험을 해보았습니다. 하지만 결과는 만족스럽지 않았습니다. 가비지 콜렉션 시간을 약간 줄이고, 자바 프로세스가 시스템의 물리 메모리를 좀 더 천천히 가져가게 하였습니다. 안타깝게도 이게 전부였습니다. 가시적으로 눈에 확 띄는 효과가 없었습니다. JVM 옵션 튜닝에 대해 가졌던 환상이 깨졌고 많이 아쉬웠습니다. 하지만 남는 것이 있었습니다. 여러 옵션들의 사용법에 대해서 배울 수 있었던 것입니다.

당시 배웠던 옵션 중에는 JVM이 구동될 때 클라이언트 혹은 서버 모드를 선택할 수 있게 해주는 옵션이 있습니다. 실행 시 -client 옵션을 주면 클라이언트 모드로 구동되고, -server 옵션을 주면 서버 모드로 구동이 됩니다. 각 모드는 각각의 고유한 특징이 있습니다.
클라이언트 모드의 경우에는 빠른 시작과 적은 메모리 사용을 위해 최적화 되어 있습니다. 클라이언트 모드에서는 바이트 코드를 기계어로 컴파일 할 때 복잡한 최적화 기법을 이용하지 않습니다. 덕분에 코드를 분석하고 컴파일하는 시간이 서버 모드에 비해 훨씬 줄어들게 됩니다. 그래서 보다 빨리 시작되며, 컴파일 할 때 메모리도 적게 쓸 수 있습니다.
서버 모드의 경우에는 오랜 시간동안 실행되는 서버 애플리케이션에 최적화 되어 있습니다. 그렇기 때문에 C++ 컴파일러에서 쓰던 최적화 기법뿐만 아니라 더 진보된 많은 컴파일 최적화 기법들을 이용하여 컴파일을 합니다. 그래서 초기에는 컴파일 하는데 클라이언트 모드에 비교하여 좀 더 시간이 걸립니다. 대신 컴파일이 완전히 종료되면, 컴파일 된 코드에 실행에 대해서는 더 나은 속도를 보장하게 됩니다.

저는 이 옵션에 대해서 알게 된 이후로 제가 맡고 있는 모든 웹 애플리케이션 시스템을 서버 모드로 돌렸습니다. 일반적인 웹 애플리케이션은 구동된 후 같은 기능이 오랜 시간 동안 여러번 실행되기 때문에 주요 코드들은 반복적으로 자주 이용됩니다. 따라서 서버 모드가 적합하였습니다. 그 후로도 JVM이 구동되는 곳이라면 어디든지 서버 모드를 사용했습니다. 마치 버릇처럼 되어버렸습니다. GUI쪽의 애플리케이션을 만들지 않는 이상 클라이언트 모드를 사용할 만한 곳은 없다고 스스로 생각했습니다.

그 런데 어느날이였습니다. 로컬 개발 환경을 구성하다보니 새로운 생각이 떠올랐습니다. 그것은 로컬 개발 환경에서는 서버 모드 대신 클라이언트 모드를 사용하는 것이 더 좋을 것 같다라는 생각이였습니다. 클라이언트 모드가 더 좋을거라 생각한 근거는 톰캣이 일반적으로 짦은 시간 동안만 사용되며 자주 재시작이 되기 때문입니다. 저의 로컬 개발 환경을 예로 들어보겠습니다. 저는 톰캣을 씁니다. 기능에 대한 코드를 작성하고 톰캣을 띄웁니다. 그리고 알맞은 URL에 들어가 테스트를 해봅니다. 버그가 발견 됩니다. 버그를 고칩니다. 버그 수정본을 반영하기 위해 서버를 재시작(리로딩)합니다. 이런 과정을 하루에도 수십번 반복합니다. 이런 경우라면 주요 코드라 할지라도 몇번 호출되지 않습니다. 새로운 코드 반영을 위해 금방 톰캣이 꺼지기 때문이죠. 그래서 빠른 시작과 빠른 컴파일이 더 유용하다고 생각했습니다.

이 후 로컬 환경에는 클라이언트 모드를 사용하고 있습니다만, 사실 속도차를 체감하진 못합니다. 그래도 서버 모드보다는 나을 것입니다. 필요없이 복잡한 최적화를 하지 않을테니까요. :)


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

,
336x280(권장), 300x250(권장), 250x250, 200x200 크기의 광고 코드만 넣을 수 있습니다.
일반적인 자바 웹 개발 환경에서는 개발중 작은 클래스 파일 하나를 고치더라도 톰캣을 재시작하거나 리로딩 해야 합니다. PHP나 ASP와 같은 개발 환경에 비교해선 매우 비효율적이지요. 특히 제가 일하는 환경에서는 몇가지 이유로 톰캣을 재시작하거나 리로딩 하는 시간이 40초 이상 걸릴 때가 많습니다. 가끔 재시작하는 것을 바라보고 있노라면 지루함으로 인해 머리가 멍해지곤 합니다. 뭔가 해결책이 필요해 보였습니다.

이렇게 고통스런 나날을 보내던 중 JUnit을 이용한 테스트 코드가 보였습니다. 그것은 제게 새로운 가능성을 열어주었습니다. 그 가능성이란 톰캣 없이도 제가 만든 코드의 동작을 확인할 수 있게 해준다는 것입니다. 게다가 톰캣과는 명확하게 구별되는 장점들이 있습니다. 그러한 장점들에 대해 제가 느낀 위주로 소개를 드리고 싶습니다.

여러 장점 중에 첫번째는 속도입니다. 위 사례처럼 톰캣이 시작할 때 꽤 많은 시간을 소요하는 것에 비해, 테스트 코드를 수행할 때는 매우 빠르게 실행이 됩니다. DB연결이 필요한 DAO와 같은 통합 테스트가 아니라면 그야말로 번개같이 실행됩니다. 그 까닭은 톰캣이 시작할 때 전체 웹애플리케이션을 위한 모든 기능에 대해 준비하는 반면에, 테스트 코드는 테스트에 필요한 부분만을 로딩할 수 있기 때문입니다.

두번째는 편의성입니다. 예전에는 브라우저를 띄어놓고 테스트 할 때 여기 저기 눌러보아야 했습니다. 어떤 경우에는 로그인도 해야하고 여러 가지 설정을 힘들게 해야했죠. 하지만 테스트 코드로 잘 만들어 놓는다면 시작버튼만 눌러주면 됩니다. 그럼 테스트 코드가 쭉 실행이 됩니다.

세번째는 기록성입니다. 브라우저에서의 테스트는 기록이 남질 않습니다.(제가 알고 있는 기록하는 툴이 있긴합니다. 하지만 다양한 상황을 잘 다루진 못하는 것 같습니다.) 그에 반해 테스트 코드는 고스란히 남습니다. 이렇게 남은 코드는 이후 언제라도 다시 돌려볼 수 있습니다. 뭔가 수정을 해보고 나면 쭉 돌려봅니다. 녹색불이 켜지면 OK입니다. 제 경험을 말씀드리자면 프로젝트 후반에 가슴이 떨리는 몇몇 변동사항이 있었지만, 테스트 코드와 함께 수월하게 헤쳐나갈 수 있었습니다.

마지막으로 교육성입니다. 때로 테스트 되었던 API의 사용법이 가물 가물 할 때, 테스트 코드를 보며 사용법을 확인할 수 있습니다. 마치 예제코드처럼 활용되는 것이죠. 함께 일하는 분 중 한 분은 이렇게 말씀하시기도 했습니다. "새로 공통 API를 만들면 다른 사람이 사용법을 쉽게 알아볼 수 있게 테스트 코드를 만들어 주세요!"

이렇게 많은 장점이 존재하지만 분명 단점도 있습니다. 그것은 테스트 코드를 작성하는 추가 비용이 든다는 것입니다. 하지만 얻는 이득에 비하면 추가 비용은 매우 작은 부분이 아닐까 생각합니다. 고급 뷔페에 가서 최선의 능력을 발휘하기 위해 화장실 정도는 다녀올 수 있지 않나요? 분명히 화장실 가는 시간 정도야 투자할 수 있을꺼예요. :)

저는 테스트 철학이니 TDD니 이런 것들은 잘 모릅니다. 그렇지만 테스트가 좋습니다. 왜냐하면 테스트는 제가 해야 할 반복적인 일을 줄여주고 제가 좀더 재미있어 하는 문제에 집중할 수 있게 도와주기 때문입니다. 그러므로 제게 있어 테스트는 무척 "실용적"입니다. 저는 테스트를 자신있게 추천합니다.

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

,