Extremely Agile/TDD2008.11.04 14:20
앞서 남이 짠 클래스 코드를 어떻게 리팩토링하면 jMock을 통해 테스트하기가 좀 더 쉬워지는지를 살펴봤습니다. 그런데 현실 세계의 클래스들이 전부 그렇게 간단하게 만들어져 있으면 문제가 쉬운데, 실제로 테스트를 진행하다보면 그렇지 않은 경우를 종종 만나게 됩니다.

가장 골치아픈 경우 중 하나는, 생성자 안에 해당 클래스의 비즈니스 로직(?)이 들어가 있는 경우죠. 그러니까 좀 돌려서 말하면, 클래스의 생성자 코드 안에 테스트해야하는 알고리즘의 일부가 들어가 있는 경우입니다. 이 알고리즘이 대상으로 하는 객체들 중 하나를 Mock 객체로 만들어 테스트해야 한다면, 상황은 간단히 풀리지 않습니다. 다음 코드를 보시죠.

class Foo {
    public Foo() {
        ...
        bar.doSomething();
        ...
    }
    ...
};

bar에게 뭔가를 하고 있습니다. 그런데 bar에 doSomething을 호출하는 순간 네트워크 접속이 발생하기 때문에, 그 상황을 피하기 위해 bar를 mock 객체로 만들고 싶다고 해 보죠. 앞선 글에서 사용한 방법(아마 패턴을 아시는 분이라면 그 방법이 소위 '템플릿 메소드 패턴'인건 아시겠습니다만...)을 그대로 써먹자면 다음과 같이 해야 할 텐데요.

class Foo {
    protected void doSomething() {
        ..
        bar.doSomething();
        ..
    }

    public Foo() {
        ...
        doSomething();
        ...
    }
    ...
}

얼핏 보면 그럴듯해 보입니다만, 다음과 같이 하는 순간 문제가 복잡해집니다.

class FooT extends Foo {

    private Bar bar;

    protected void doSomething() {
        ...
        // do something with bar
    }

    public FooT() {
        super();
        // do something with bar
    }
}

왜 문제가 복잡해지죠? FooT의 생성자가 불리는 순간, 상위 클래스의 생성자가 불리면서 doSomething()이 호출되는데, 이 때 호출되는 doSomething은 하위 클래스에 정의된 doSomething이어야 하거든요. 그런데 아직 객체가 완전히 초기화도 되지 않은 상태이니, 하위 클래스의 doSomething이 제대로 수행될 리가 없죠. 이런 문제는 C++이건 Java건 공통적으로 발생하는 문제입니다. C++에서는 가상 함수 포인터 초기화에 관련된 오류가 발생하면서 프로그램이 죽는 현상이 발생하고, Java에서는 멤버 변수 초기화에 관한 오류 메시지가 뜨면서 프로그램이 죽습니다.

이 문제는 "Don't call subclass methods from a superclass constructor"라는 글에 잘 정리가 되어 있습니다. 요지는 When designing a class for subclassing, it’s important to avoid calling any method that the subclass could or must override from the superclass constructor. 인데, 간단히 요약하면 "하위 클래스가 오버라이드할 가능성이 있는 메소드를 생성자 안에서 호출하는 것은 피해라"라는 뜻입니다. 하위 클래스에서 오버라이드를 해버리면, 이런 문제가 생길 수 있으니까요.

그러니까 생성자 안에 테스트 해야할 알고리즘의 일부가 들어있는 경우에는, 원래 클래스의 코드를 수정하는 일이 불가피해지고 맙니다. 그런 일이 자주 생기면 좋지 않기 때문에, 가급적 생성자 안에는 "초기화에 관련된 코드들"만을 두는 것이 바람직하겠습니다.

그럼 "어쩔수 없이 원래 클래스 코드를 수정해야 하는 경우"에는 어떻게 수정하는 것이 바람직 할까요? 가령 다음과 같은 클래스가 있다고 해 봅시다. 제가 실무에서 만난 클래스입니다.

public class Connection implements Definition {
   ...
   public Connection(String ip, int port, int type) throws Exception {
      ...
   }
   ...
}

이 생성자는 내부적으로 Socket 객체를 생성하여 그 메소드를 호출합니다. 골치아픈 생성자죠. ㅋㅋ 우리가 해결해야 할 목표는, Socket 객체를mock객체로 바꿔칠 수 있는 방법을 Connection 클래스에 추가하는 것이었습니다. 그래서 코드를 다음과 같이 변경했습니다.

public class Connection implements Definition {
   ...
   public Connection(String ip, int port, int type) throws Exception {
      init(ip, port, type, null);
   }
   
  Connection(String ip, int port, int type, Socket alternativeSocket) throws Exception {
      init(ip, port, type, alternativeSocket);
   }

   private void init( ... ) {
      // if alternativeSocket is not null, do something with it.
      // or, create Socket object and deal with it
   }
   ...
}

간단히 요약하면, (1) initialization 로직은 별도의 private 함수로 refactoring하고 (하위 클래스에 의해 오버라이드 되지 않아야 하기 때문에 private입니다) 그 함수의 네번째 인자로 외부에서 정의한 소켓 객체 (mock 객체겠죠)를 받은 경우 그 객체를 가지고 필요한 작업을 하도록 변경했으며, (2) 테스트를 위한 별도의 생성자를 정의하고, 그 생성자의 네번째 인자로 mock 객체를 인자로 받아 init 함수에 넘기도록 한 다음 (3) 원래 생성자는 init 함수를 호출하되 네번째 인자로 null을 넘기도록 코드를 변경했습니다.

이렇게 함으로써 코드에 그다지 '큰' 변경을 가하지 않고서도 테스트 가능성을 확보할 수 있었습니다. 새로 추가된 생성자는 그 scope가 package 이어야 합니다. 그래야 테스트 코드 안에서만 사용이 가능할테니까요.

[다음 글에서 계속...]

신고
Posted by 이병준

소중한 의견, 감사합니다. ^^