Extremely Agile/TDD2009.05.14 22:13
최근에 Fit이라는 테스팅 프레임워크에 대한 책을 번역했습니다. 이 책의 초고는 지금 베타리더분들께 가 있는데, 조만간 출판사로 넘길 수 있지 않을까 생각합니다. 기다리시는 분들께 다시 한번 사과말씀 드립니다. 아무튼 오늘 하려는 이야기는 그게 아니고...

FitNesse라는 프레임워크가 있습니다. 이 프레임워크는 Fit을 Wiki 형태로 사용할 수 있게 해주는 좋은 도구입니다. 이 도구를 처음 접하고, 어떻게 써먹으면 좋을까 고민을 좀 했습니다. 제가 회사에서 맡은 일이 주로 네트워크 프로토콜 스택을 구현하는 일이라, 거기에 적용할 방법이 없을까 생각해봤습니다. 아니, 처음부터 그렇게 생각한 건 아닙니다. 첨엔 삽질부터 시작했죠. 그 삽질이 어떤 과정으로 진행되었는지 살펴보자면 대략 다음과 같습니다.

1.
처음에 이 프로토콜 스택을 구현하기 시작할 때, 일단 통신과 관련된 패킷 핸들링 부분은 좀 천천히 구현하고, 프로토콜의 핵심이 되는 '메시지 교환 sequence' 부터 설계하고 구현할 수 없을까 생각했습니다. 그러다 보니 결국 프로토콜 스택을 두 계층으로 나누게 되었습니다. 편의상 위에 있는 놈을 U라고 하고, 아래쪽에 있는 놈을 D라고 해 보겠습니다.

2.
먼저 저는 U를 구현했습니다. U에 대한 테스트 작성을 병행했는데, 아무래도 D를 구현하지 않았기 때문에 테스트 작성이 쉽지가 않았습니다. 제가 알고 있는 한, 이 문제를 해결하는 방법은 mocking 라이브러리를 이용하는 것이었습니다. 그래서 D를 mock 하기로 결정했습니다. 그런데 jMock을 써서 D를 mock 하려 하자 난관에 부닥쳤습니다. D를 mock 하는 건 좋은데, mock된 D가 U의 콜백 함수를 부르도록 할 방법이 마땅치 않았던 것입니다. 그래서 오픈 mocking 라이브러리들은 제쳐놓고, 제가 직접 D에 대한 mock 객체를 구현하기 시작했습니다.

3.
처음에는, D 객체는 딱 하나만 만들어 놓고, 모든 U들이 D를 통해 통신하도록 했습니다. (실제 시스템은 U+D가 장비마다 하나씩 들어가게 되어 있는데, 그것과는 다른 구성이었습니다. 나중에 문제를 일으켜 결국 리팩토링하게 됩니다. -_-;) 어찌 어찌 해서, jUnit 테스트 케이스를 완성했습니다. 테스트 케이스는 우아하게 실행되었고, 모든 테스트 케이스에 파란불이 켜졌습니다. 저는 테스트 결과와, 테스트 작성 결과로 수정되고 작성된 U의 클래스들을 다이어그램으로 정리해 보고서를 제출했습니다. 거기까지는 괜찮았고, 저는 일주일 동안 Fit의 번역 작업에만 매진할 수 있었습니다.

4.
그런데 일주일이 지나고 다시 Eclipse 앞에 앉았을 때, 저는 뭔가 문제가 있다는 사실을 깨달았습니다. U를 완성할 수 있었던 것은 좋았는데, 테스트를 위해 작성한 jUnit 프로그램이 너무 복잡했던 것입니다. 여러 개의 U 객체를 묶어 통신 시나리오를 만들고 그 통신 시나리오대로 움직이는지를 테스트하려다 보니, 결국 굉장히 길고도 이해하기 까다로운 테스트가 만들어 졌던 것이죠. 그리고 일주일이 지나자, 저는 그 테스트가 어떻게 완성된 것인지를 까먹어 버렸구요. 그 덕분에, 프로그램 수정 결과로 테스트가 깨져도 대체 테스트가 '왜' 깨진 것인지를 파악하기가 쉽지 않았습니다.

5.
결국 저는 '장문의 jUnit 테스트 케이스는 그다지 바람직하지 않다'는 교훈을 얻었습니다. 저에게는 길고 장황한 통신 시나리오를 테스트할 다른 방법이 필요했습니다.

6.
그래서 결국 FitNesse로 프로그래머 테스트를 할 방법이 없을까 생각했습니다. 일단 FitNesse를 사용하기로 결정하자, 그 방법은 어렵지 않게 찾을 수 있었습니다. DoFixture를 사용하는 것이었죠. 네트워크를 통해 발생하는 모든 행위를 DoFixture의 액션으로 구현하고, 그 액션이 올바르게 실행되는지를 보기로 결정했습니다. 그래서, 다음과 같은 테이블을 작성했습니다.

사용자 삽입 이미지

테이블 중간 중간에, 이 테스트가 의도하는 바가 무엇인지를 보여주는 텍스트를 삽입했습니다. 그리고 이 테스트를 돌리기 위해 프로그램을 수정해 나가기 시작했습니다.

7.
테이블이 테스트를 주도하기 때문에, 테스트 코드의 엔트리 포인트부터 시작되는 제어의 흐름을 따라가기가 편했습니다. 그 덕에, 테스트를 실제 테스트 대상 시스템에 돌리는 픽스쳐 코드는 비교적 시간이 흐른 뒤에 다시 보더라도 그다지 어렵지 않게 이해할 수 있었습니다.

뭔가 수정할 때 마다 테스트를 계속 돌려 봤기 때문에, 내가 어디서 실수를 저질렀는지 계속 확인할 수 있었습니다. 그리고 굉장히 심각한 리팩토링을 해야 할 때도 (나중에 D를 U 별로 하나씩 분리한 다음에 D끼리 통신하도록 만드는 수정을 했는데, 그게 이번 코딩에서는 그럭저럭 '굉장히 심각한' 리팩토링이었습니다 ㅋㅋ) 그 결과가 올바른 것이었는지 재때 확인할 수 있었습니다. 결국, 항상 다음과 같은 화면을 볼 수 있었습니다.

사용자 삽입 이미지

이 과정을 거쳐 저는 U가 가져야 하는 기능의 대부분이 정상 동작함을 확인하고, D가 어떤 식으로 동작해야 하는지에 관한 세부사항을 알 수 있었습니다. 이제 D를 개발하기 시작해야 하는데, 위의 테스트를 계속해서 활용하면 D를 구현하는 것이 그다지 어렵지는 않을 거라고 기대하고 있습니다.

FitNesse를 테스트 도구로 활용하면서, 프로그램의 테스트 가능성에 대해 좀 더 많이 생각해 보게 되었습니다. 일단 테스트 가능성에 대해 생각하기 시작하자, 프로그램의 각 모듈 (이 예의 경우에는 U와 D) 사이의 관계가 좀 더 선명하게 드러나기 시작했고, 테스트 픽스쳐의 코드도 그 관계를 좀 더 분명하게 드러내게 되었습니다. 덕분에 U와 D를 설계하면서 놓쳤던 것들이 코드에 보완되기 시작했구요.

아마 이건 UI와 테스트가 거의 같은 계층에 있기 때문일 겁니다. 테스트를 좀 더 잘 할수 있으려면 U가 외부에 제공하는 인터페이스가 명확해야 합니다. U가 테스트를 잘 지원하려면 D와의 인터페이스도 분명해야 하죠.

8.
사실 앞으로 남은 일이 더 걱정입니다만, (프로토콜 개발은 제가 직접 하는 게 아니고 용역 업체와 진행하게 되어 있습니다) 이번 개발이 잘 완료되면 FitNesse를 고객(저)과 개발자(용역업체) 사이의 소통에 활용해 생산성을 높인 사례를 확보할 수 있지 않을까... 그런 기대를 하고 있습니다. 그리고 다음번에는 어떻게 적용하면 더 좋을지도 알게 될 것 같구요.

신고
Posted by 이병준

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

  1. 지금 제가 개발과 프로그래밍(웹)에 관해서 공부한 내용을 갖고 이해하기에는 조금 어려운 포스팅인 것 같습니다.
    그래도 많은 도움이 되는 것 같아서 이해하는데 너무 집착하기보다는 이렇게 활용 할 수도 있다라는 생각을 갖고
    관심을 갖고 읽게 되었던 것 같습니다.^^

    2009.05.22 03:15 신고 [ ADDR : EDIT/ DEL : REPLY ]
    • 모든 사람이 이렇게 해야 하는 건 아닙니다. 이런 방법도 있다는걸 알아두면 언젠가는 써먹을 때가 있겠죠...

      2009.05.22 08:28 신고 [ ADDR : EDIT/ DEL ]

Extremely Agile/TDD2008.11.04 15:34
FindBugs라는 프로젝트가 있습니다. 코드의 정적 분석을 통해 코드에 내재된 버그를 찾는 솔루션을 만드는 프로젝트입니다.

많은 개발자들이 정적 분석을 통한 버그 탐색 방법을 도외시하는 경향이 있습니다만, 개발자들도 사람이니 '바보같은' 실수를 저지르게 되어 있기 마련이라는 점을 감안한다면, 짝 프로그래밍이나 코드 리뷰가 버그를 많이 줄여준다고 하더라도 그런 미련한 버그들이 코드에 뒤섞이는 것을 100% 방지할 수는 없습니다.

정적 분석 (static analysis) 방법이 그런 버그를 찾을 수 있게 도와준다면, 사용하지 않을 이유는 없어 보이는데요. 다행히 FindBugs 프로젝트는 Eclipse나 Netbeans 같은 IDE상에서도 사용할 수 있을 정도로 성숙되었고, 많은 분들이 쓰고 계십니다.



이 프로젝트 URL은 http://findbugs.sourceforge.net/ 입니다. 논문도 꽤 나온 것 같은데, 재미있어 보이네요. (아직 읽어보진 않았습니다.) Lesser GPL로 배포되고 있으니, 상업적 프로젝트에도 무리없이 적용 가능할 것 같습니다.

참고할만한 다른 링크들 :

http://benelog.egloos.com/2079841 - FindBugs + Maven 2 + Hudson
http://findbugs.blogspot.com/ - FindBugs 공식 블로그
http://findbugs.sourceforge.net/bugDescriptions.html - 버그 패턴 설명
http://www.ibm.com/developerworks/kr/library/tutorial/j-cq11207/index.html - 지속적 통합과 결함 발견
신고
Posted by 이병준

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

  1. 동영상 내용 많이 도움이 될 것 같네요. 제 블로그의 포스트에서도 이 포스트에 링크를 걸었습니다~ 잘 봤습니다.

    2009.01.24 07:09 신고 [ ADDR : EDIT/ DEL : REPLY ]

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 이병준

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

Extremely Agile/TDD2008.11.03 16:09

그러면 오늘은 jMock을 이용해 남의 코드를 테스팅하는 과정을 한번 살펴보겠습니다. 정답이라고 할 수는 없고, 저 개인적인 경험에 따른 것이니, 참고 정도 하시면 좋을 것 같습니다.

남의 코드를 테스팅할 때 중점적으로 고려한 사항은 다음과 같습니다.

1. 남의 코드의 인터페이스는 손대지 않는다.
2. 남의 코드의 주 알고리즘은 손대지 않는다.


물론 2는 어쩔 수 없이 손대야 할 경우도 생기긴 합니다만, 가급적 그러지 않는 것이 정신건강상 이롭습니다. 왜 그런지는 아마 여러분들도 잘 아실 거라 생각합니다.

그러면 이제 실제 사례를 살펴보겠습니다. ConnectionManager라는 클래스입니다. 이 클래스는 대략 다음과 같은 골격을 가지고 있습니다.

public class ConnectionManager extends Thread implements Definition {
  ...
 
  ConnectionManager(PolicyAgent pa) {
  ...
  }
  ...

  public void connect() {
    Connection conn = null;
    // main algorithm
    ...
    conn = new Connection(this.policyAgent.primaryServer, this.svrPort, ...);
    ...
  }

  ...
}

이 코드를 테스트하는 데 있어서 가장 큰 문제는, 내부에서 생성하고 관리하는 Connection 객체가 어딘가로 TCP/IP 연결을 시도한다는 점이었습니다. 코드를 단위테스트하는 와중에는 해당 서버가 없었기 때문에, 우리는 Connection 객체를 어떻게든 'mock' 해야만 했습니다. 그런데 생성하는 코드가 주 알고리즘에 뒤섞여있었기 때문에 애매한 상태였죠.

그래서, 우선은 주 알고리즘에서 Connection 객체를 생성하는 코드를 별도의 메소드로 분리하고, 알고리즘 안에 포함되어 있던 new Connection 부분을 전부 createConnection을 호출하도록 변경했습니다.

public class ConnectionManager extends Thread implements Definition {
  ...
 
  ConnectionManager(PolicyAgent pa) {
  ...
  }
  ...

  protected Connection createConnection(String svr, int port, int type) {
    return new Connection(svr, port, type);
  }

  public void connect() {
    Connection conn = null;
    // main algorithm
    ...
    conn = createConnection(this.policyAgent.primaryServer, this.svrPort, ...);
    ...
  }

  ...
}

이렇게 한 다음에, JUnit 코드 안에서 ConnectionManager 클래스 코드를 계승하여 ConnectionManagerT라는 클래스를 정의했습니다.

class ConnectionManagerT extends ConnectionManager {
 private List<Connection> extConns = null;

 public ConnectionManagerT(PolicyAgent parent) {
  super(parent);
  this.extConns = new LinkedList<Connection>();
 }

 void addExternalConnection(Connection extConn) {
  synchronized( this.extConns ) {
   this.extConns.add( extConn );
  }
 }

 private Connection getExternalConnection() {
  synchronized( this.extConns ) {
   if ( this.extConns.isEmpty() ) return null;
   return this.extConns.remove(0);
  }
 }

 protected Connection createConnection(String svr, int port, int type) {
  Connection conn = null;
  if ( (conn = getExternalConnection()) == null ) {
   return super.createConnection(svr, port, type);
  }
  return conn;
 }
 ...
}

그리고 이 클래스 안에서 createConnection메소드를 오버라이드 했습니다. 이제 테스트 코드에서는 ConnectionManager 클래스를 사용하는 대신, ConnectionManagerT 클래스를 사용해서 테스트를 진행하면 됩니다. ConnectionManager 클래스에 정의된 알고리즘이 createConnection을 호출하는 순간, 하위 클래스에 정의된 createConnection이 호출될 것이고, 이 메소드는 new Connection을 하는 대신 getExternalConnection 메소드를 호출하여 connection을 얻을 것이기 때문에, mock 객체를 통해 주 알고리즘의 테스트가 진행되도록 만들 수 있습니다. setExternalConnection 메소드를 사용해 mock 객체를 ConnectionManagenrT 객체에 세팅해두면 되는 것이죠.

그러니까 테스트 케이스 코드 안에서는 다음과 같은 짓을 순차적으로 하면 된다는 겁니다.

  PolicyAgent pa = new PolicyAgent();
  pa.initiate();

  ConnectionManagerT cm = new ConnectionManagerT(pa);

  ...

  final Connection connMock1 = context.mock( Connection.class );
  cm.addExternalConnection( connMock1 );

  //
  // test no-signature error scenario
  //

  context.checking( new Expectations() {{
   ...
  }});

  cm.run(); // 알고리즘 테스트

  ... // 수많은 assertion 코드들...

어떻습니까? 이렇게 하면 남의 코드를 몇줄 고치지 않고서도 테스트를 진행할 수 있습니다. new Connection 때리는 부분을 별도의 overridable 메소드로 분리하는 것 만으로, 테스트 가능성을 높인 코드를 만들 수 있다는 이야깁니다.

이런 가능성은 TDD 형태로 테스트 코드를 먼저 작성하건, 아니면 작성된 코드에 테스트 코드를 추가하건, 순서 불문하고 테스트 코드를 작성하느라 고민하지 않으면 좀처럼 열리지 않습니다. 의외로 테스트 가능한 코드를 만드는 것은 아주 간단한 리팩토링(Refactoring)으로부터 시작되는데도 말이죠.

신고
Posted by 이병준

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

Extremely Agile/TDD2008.10.31 14:31

jMock은 Mock 객체를 이용해 테스트를 지원하는 라이브러리입니다. www.jMock.org에서 다운받으실 수 있습니다. 요즘 회사에서 '다른 사람이 작성한 코드'를 좀 더 '테스트 가능'(testable)하게 만드는 동시에 test coverage를 높이는 일을 하고 있습니다. 작년까지 했던 일과는 조금 다른 일이라, 재미있습니다.

jMock 계열의 라이브러리로는 EasyMock 같은 것도 있습니다만, 써보니까 'Easy'하다고 꼭 좋은것만도 아니어서 다시 jMock으로 돌아왔습니다. 돌아와보니 jMock이라고 또 그리 사용법이 복잡하지는 않습니다. 사용법이 좀 까다로와 보여서 진입장벽이 '약간' 있을 뿐이죠.

Mock 라이브러리를 사용해 테스트 코드를 작성하는 목적은, '테스트 대상 알고리즘을 그 알고리즘이 대상으로 하는 자원과 분리'하는 것이라고 볼 수 있겠습니다. 가령 알고리즘 foo가 어떤 자원 bar들을 대상으로 돌아간다고 해 봅시다. bar의 구현 상태와는 독립적으로, foo의 정확성만을 테스트하고 싶다고 해 봅시다. 그 경우에는 '원하는 대로 동작하는 것이 보장된' 가짜 bar 객체들만을 만들어 두고 그 위에서 foo를 돌리게 되면 foo의 정확성을 무리없이 검증할 수 있을 것입니다.

그러니 결국 Mock 라이브러리들이 하는 일은, '분리(separation)'라고 요약할 수 있겠습니다.

실제 '남의 코드'를 테스팅하는 사례를 살펴보기전에, 우선 jMock 라이브러리 사용법의 '골격'부터 알아보도록 하겠습니다. jMock라이브러리를 사용하기 위해서는, 대략 다음과 같은 클래스들을 임포트해 둘 필요가 있습니다. (뭐 eclipse가 알아서 다 해주긴 하지만..ㅎㅎ) 전 JUnit 4를 가지고 프로그래밍을 했기 때문에, JUnit4Mockery 클래스를 임포트하도록 했습니다.

import org.jmock.Expectations;
import org.jmock.Mockery;
import org.jmock.integration.junit4.JUnit4Mockery;
import org.jmock.lib.legacy.ClassImposteriser;

그런 다음 이제 JUnit 테스트 클래스의 안을 채워나가야 하는데요. ConnectionManager라는 클래스가 있다고 치고, 그 클래스를 테스트하는 JUnit4 클래스를 구현한다고 가정하겠습니다. 그 클래스 제일 윗부분에 다음과 같은 부분을 둡니다.

public class ConnectionManagerTest {

  private Mockery context = new JUnit4Mockery() {{
    // enable mocking of class also (not only interface!)
    setImposteriser(ClassImposteriser.INSTANCE);
  }};
  ...

}

보통 Mock 라이브러리들은 Interface 클래스를 인자로 받아, 그 인터페이스가 하는 일을 흉내내는 가짜 객체를 생성합니다. 그런데 EasyMock이나 JMock은 확장을 통해 일반 클래스를 인자로 받아서 Mock 객체를 생성할 수 있도록 배려하고 있습니다. 위의 적색으로 표시된 부분은, 일반 클래스를 인자로 받아 Mock 객체를 생성할 수 있도록 지시하는 부분이라고 생각하시면 됩니다.

이렇게 하고 나면 이제 테스트 코드를 작성하면 되는데요. 대략 다음과 같은 순서를 따릅니다.

 @Test
 public void testRun() throws Exception {

  // 1. Mock 객체 생성
  final Connection mock = context.mock( Connection.class );

  // 2. Mock 객체를 테스트 대상 알고리즘에 세팅
  ...
 
  // 3. 알고리즘을 돌렸을 때 mock 객체에 무슨 일이 일어나게 되는지를 명시
  context.checking( new Expectations() {{
   allowing (mock).getInteger(); will(returnValue(34));
   oneOf (mock).writeInteger(56);
   allowing (mock).close();

  }});

  // 4. 알고리즘 구동
  ...

  // 5. 각종 assert 문들 삽입
  ...
 }

우선, Mock 객체를 만듭니다. context.mock의 인자로 객체를 찍어낼 클래스를 인자로 넘겨주어야합니다. 그런 다음, 만들어진 mock 객체를 테스트 대상 알고리즘에 세팅합니다. 알고리즘을 구동할 때 mock 객체를 인자로 넘겨주게 되어 있다면, 이 단계를 생략해도 무방하겠습니다.

그런 다음, 알고리즘을 실제로 돌리면 mock 객체에 어떤 행위가 일어나게 되리라 '기대'(expectations)하는지를 세팅합니다. context.checking 메소드에 Expectations객체를 만들어 넘겨주는 식으로 세팅하게 되는데, 그 안에 발생할 것으로 기대되는 행위 목록을 적어주면 됩니다. 일단 위의 예제의 의미를 간단히 설명드리면,

allowing... 으로 시작되는 줄은, 알고리즘 구동 과정에서 mock 객체의 getInteger() 함수가 여러번 불리게 될 수 있는데, 그 때 마다 34를 리턴하도록(will(returnValue(34))) 지시하고 있습니다.
oneOf... 로 시작되는 줄은, 알고리즘 구동 과정에서 mock 객체의 writeInteger 함수가 단 한번 불리게 되는데, 그 때 인자로 56이 전달되어 호출되어야 함을 명시하고 있습니다.
마지막의 allowing...으로 시작되는 줄은, 알고리즘 구동 과정에서 mock 객체의 close() 함수가 여러번 불리게 될 수 있다는 것을 명시하고 있습니다.

결국, mock 객체가 알고리즘의 요구에 어떻게 반응하여야 하는지를 명시하는 셈입니다. 이 부분을 잘 작성해주면 mock 객체에 전달되는 값이 예상치와 일치하는 지를 검사하여 알고리즘이 제대로 된 값을 사용해 mock 객체와 통신하는지를 검사할 수 있게 되고, 결국 알고리즘의 정확성을 검증할 수 있게 됩니다.

이렇게 expectation의 목록을 작성하고 나면, 그 다음으로는 알고리즘을 구동시키고, assert구문들을 사용하여 (assertTrue, assertFalse 등) 구동 후의 상태가 예상한 것과 일치하는지를 보면 됩니다.

expection을 작성하는 문법이나, JMock 사용법에 대해 더 자세히 알고싶으신 분은 JMock 웹사이트에 올라온 cheat sheet를 보시면 좋을 것 같습니다.

[다음 글에 이어서...]

신고
Posted by 이병준

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

  1. 이상곤

    JMock에 대한 강좌 잘 읽었습니다. 헌데 따라 하는 중 ClassImposteriser 클래스를 임포트하는 과정에서 바로 막혔습니다. ㅠㅠ package가 org.jmock.lib.legacy 아래에 ClassImposteriser 있다고 나와 있고 API 문서를 봐도 그렇게 나와 있는데 stable 버전 2.5.1 jar 파일에는 그 이하에 legacy가 안보입니다. 혹시 제가 뭔가 간과 한 부분이 있는지요?

    2010.01.20 14:26 신고 [ ADDR : EDIT/ DEL : REPLY ]

Extremely Agile/TDD2008.10.21 13:53
TDD를 배우고 실무에 적용해보고 나면, 프로그래머는 대략 다음 두 가지 중 하나의 유형으로 바뀝니다.

1. TDD를 굉장히 열정적으로 사용하는 프로그래머
2. 테스트의 필요성을 절감하고 테스트 케이스를 작성하긴 하는데, 그렇게 TDD를 열심히 하지는 않는 프로그래머

저는 이 중 어느쪽이냐 하면, 후자 쪽입니다. 아직 TDD에 확실히 익숙해지지 않아서 그럴 겁니다. 물론, 자기가 작성하는 코드에 대한 자신감이 높은 프로그래머도 두번째 유형에 속할 가능성이 높습니다. 이런 프로그래머는 TDD는 너무 오버헤드가 높은 방법이라고 느끼는 경향이 있습니다.

사용자 삽입 이미지

http://www.nilkanth.com/archives/2007/06/08/three-monkeys-of-test-driven-development/


그런데 두 번째 유형의 프로그래머들이 흔히 빠지는 함정이 하나 있습니다. 블랙박스 테스트(blackbox test)를 하게 될 가능성이죠.

보통 프로그램을 클래스 단위로 구분해서 작성하게 되면, 최소한 클래스가 하나 만들어 질 때 마다 테스트 케이스들을 만들도록 하는 것이 좋습니다. 그런데 프로그래머에 따라서는 클래스가 아니라, 모듈이 하나씩 만들어 질 때마다 테스트 케이스들을 작성하기도 합니다.

그렇게 하면 보통 모듈에 속한 모든 클래스들에 대해 테스트 케이스를 작성하기는 어려워집니다. 정말로 어려워서 어렵다는 것이 아니라 귀찮아서 생략할 가능성이 높아지기 때문에 어렵다고 하는 것이죠. 그렇게 되면 모듈의 관리자 클래스(Manager class)에 대한 테스트 케이스들만 만들어지게 됩니다. 모듈의 인터페이스에 대한 테스트 케이스만 만들어지게 된다는 것이죠.

결국 모듈이 블랙 박스가 됩니다.

블랙 박스 테스트가 의미가 없다는 뜻은 아닙니다. 하지만 추상화 단계의 위쪽으로 올라가 테스트를 하게 되면, 거기서 처리해야 할 테스트의 가짓수가 늘어나게 됩니다. 그 모든 경우를 다 따져 테스트 케이스를 작성하면 좋겠지만, 그렇게 못할 가능성이 높습니다.

결정적으로, 블랙 박스 테스트로는 "모듈의 어느 부분에서" 오류가 발생했는지를 감지하기가 어렵다는 문제도 있습니다. 그렇기 때문에 가급적 코드의 가장 작은 단위에 대해서부터 단위 테스트를 진행하는 것이 좋다는 것이죠.

TDD를 하지 않더라도 그렇게 하면 적어도 버그의 수를 줄이는 데는 도움이 되는 것을 경험했습니다. 거기다 부수적인 이득도 얻을 수 있는데, "테스트가 쉬운 코드를 만든다"는 목적은 그대로 유지할 수 있다는 겁니다. 물론 "테스트 코드를 통해 실제 코드의 디자인이 결정되는" 신기한 경험은 할 수 없겠지만 말이죠.
신고
Posted by 이병준

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

Extremely Agile/TDD2008.05.21 17:20

찾아보니까 세상에는 굉장히 많은 단위 테스트 툴들이 있더군요. jUnit은 그 중 가장 유명하다 할만 합니다. 그런데 이런 단위 테스트 툴들이 없었을때에는 도대체 어떻게 단위 테스트를 했을까요? -_-

C를 위한 단위 테스트 프레임워크로 Check라는 것이 있습니다. (세상에 존재하는 모든 단위 테스트 프레임워크의 목록을 보고 싶다면 www.xprogramming.com의 관련 페이지에 가보시는게 좋겠군요.  저도 그 중 몇 가지 단위 테스트 프레임워크는 써 봤구요.) 배워볼까 싶어 잠깐 훑어봤는데, 이런 생각이 들더군요.

단위 테스트를 하기 위해서 꼭 특정한 테스트 프레임워크의 사용법을 배워야 한다면, 좀 귀찮지 않을까요? 제가 배우고 있는 언어만도 Java, C++, C, Ruby, Erlang, Scheme 등등 많은데, 그럼 대체 몇 가지의 테스트 프레임워크를 설치해야 하나요?

요즘은 Eclipse 덕에 이런 저런 프로그래밍 언어들을 동일한 환경에서 사용하기가 편해졌다고는 합니다만, 가끔은 환경 설정하고 프로그램 설치하고 하는 것도 귀찮을 때가 있거든요. 특히 시간이 부족해서 후달릴때는 더더욱.

그래서 이번에는 그냥 assert만 사용해서 단위 테스트를 해 봤습니다. 뭐 CppUnit 써서 테스트 할 때와 엇비슷한 코드가 나오더군요 ㅎㅎ (제가 훌륭한 프로그래머가 아니라서 그럴지도 -_-)

개략적인 코드 얼개만 잠시 보시면...

....    // 열심히 짠 C 모듈 코드.

#ifdef _TEST

/*
 * test DRIVER program. Just to test this module.
 */

HashTableBlock hash_table;
HashTableEntryBlock hash_table_entries;

int main() {
    ...
    HashTableEntry* e1 = ...;
    assert( e1 != 0 );
    ...
    HashTableEntry* e3 = ...;
    assert( e3->src_addr == 0x7f000001 );
    assert( e3->src_port == 3086 );
    ...

    int i = 0;
    for( i = 0; i < 1050000; ++i ) {
        HashTableEntry* ee = alloc_hash_entry_block(&hash_table_entries);
        assert( ee != 0 );
    }
    return EXIT_SUCCESS;
}

#endif

#ifdef와 #endif를 사용했기 때문에, test driver 코드는 _TEST가 명시된 경우에만 생성됩니다.

원래는 Check를 써서 테스트를 할까 했는데, 직접 assert를 사용해서 테스트를 해 봐도 뭐 그렇게 테스트가 어려워진다거나 하지는 않는 것 같아요. 결국 취향 문제인데, 테스트 결과를 일목요연하게 정리해서 보여준다거나 하는 기능이 정말로 아쉬운 사람이라면 assert 대신 다른 테스트 프레임워크를 사용하는 것이 낫겠지만, 그런 리포트에 연연하지 않는 사람이라면 이렇게 해도 별 상관 없을 것 같아요.

어차피 단 1개의 테스트만 실패했더라도, 실패한 것은 실패한 것이니까요.

[여기까지 작성한 다음에 출장을 다녀옴 -_-]

위와 같이 assert를 사용한 단위 테스트 방안을, 커널 모듈을 테스트하는 데 응용해 봤습니다. 원래 커널 모듈을 컴파일하기 위해 제가 사용했던 Makefile은 대략 다음과 같이 생겼습니다.

obj-m := captureapp.o

clean:
    \rm -f *.ko *.o *.mod.c


좀 심하게 단순하죠 -_-; 최상위 단계가 clean인 덕에, make를 때리나 make clean을 때리나 효과는 똑같습니다. 아무튼, 이 Makefile을 다음과 같이 바꾸었습니다. (2008년 5월 26일 수정됨)

obj-m := captureapp.o

UNITTEST_SRCS = hash_table_test.c
UNITTEST_EXES = ${UNITTEST_SRCS:.c=}

unittest : ${UNITTEST_EXES}

${UNITTEST_EXES} : ${UNITTEST_SRCS}
    gcc -D_TEST $< -o $@

clean :
    \rm -f *.ko *.o *.mod.c ${UNITTEST_EXES}


따라서 make unittest를 때리면, 단위 테스트를 실행하는 실행파일들이 테스트 타겟별로 만들어지게 됩니다. 위의 경우에는 현재 테스트 타겟이 hash_table 밖엔 없어서 hash_table_test라는 실행파일만 만들어지게 되죠.

hash_table.h에는 테스트 타겟인 hash table관련 코드들이 들어가있게 되구요. hash_table_test.c에는 그 코드들에 대한 단위 테스트 루틴들이 들어가게 됩니다. 우선 hash_table.h를 보시면, 코드 윗부분에 다음과 같은 매크로 디렉티브들이 들어가 있습니다.

#ifdef _TEST
#include <stdint.h>
#include <assert.h>
#include <string.h>
#define u32 uint32_t
#define u16 uint16_t
#define u8 uint8_t
#else
#include <linux/module.h>
#define assert(x)
#endif

hash_table.h의 코드가 커널 모듈에 들어갈 코드이긴 하지만, 그 코드가 커널 루틴을 심하게 건드리는 코드가 아닌 단순히 커널 모듈 안에서 사용될 라이브러리 코드라면, 사용자 공간에서 테스트를 하는 것이 낫거든요. 그래서, 위와 같은 매크로 디렉티브들을 사용하여 커널 코드에서 사용되는 타입과 사용자 공간에서 사용되는 타입들을 일치시킵니다. assert는 커널 모듈안에서는 사용할 수 없으니까 제거해주는 부분도 넣었구요.

이제 hash_table_test.c를 보시면 되겠군요. 만일의 경우를 대비해 hash_table_test.c의 코드 대부분은 #ifdef _TEST ... #endif 사이에 정의됩니다. 앞서 보셨던 코드와 별반 다를 것은 없습니다.

#include <stdlib.h>
#include "hash_table.h"

#ifdef _TEST

/*
 * test DRIVER program. Just to test this module.
 */

HashTableBlock hash_table;
HashTableEntryBlock hash_table_entries;

int main() {
    ...
    HashTableEntry* e1 = ...;
    assert( e1 != 0 );
    ...
    HashTableEntry* e3 = ...;
    assert( e3->src_addr == 0x7f000001 );
    assert( e3->src_port == 3086 );
    ...

    int i = 0;
    for( i = 0; i < 1050000; ++i ) {
        HashTableEntry* ee = alloc_hash_entry_block(&hash_table_entries);
        assert( ee != 0 );
    }
    return EXIT_SUCCESS;
}

#endif


적고보니 단위 테스트를 '프레임워크 없이'도 좀 체계적으로 진행하려면 준비해야 할 것이 꽤나 많은데요. 아마 단위 테스트 프레임워크를 사용하는 이유 중 하나는 이런 체계를 어떻게 만들어야 할 지 감이 잘 오지 않기 때문이겠죠. 만들고 나면 사실 별거 아닐 수도 있습니다만...

신고
Posted by 이병준

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

Extremely Agile/TDD2008.02.17 11:28
프로그래밍을 하다가 만나게 되는 문제들 중 상당수가 (특히 C/C++ 프로그래머의 경우) 메모리 문제입니다. 메모리 문제는 찾아내기도 어렵고, 교정하기도 어렵습니다. 잘못된 메모리 사용이 실제 어떤 형태의 현상으로 드러나게 될지를 단언할 수가 없는 탓입니다. 특히 Java같은 언어는 메모리를 할당하는 과정은 프로그래머가 통제할 수 있지만, 메모리를 반환하는 과정은 통제할 수 없기 때문에, 메모리 문제를 발견하기도 어렵고 교정하기는 더더욱 어렵습니다. 그래서 Java 프로그래밍을 하는 와중에 험한 꼴을 당하지 않으려면 Effective Java같은 책을 잘 읽어야 합니다. X-)

C/C++ 프로그래밍 언어의 경우에는 메모리를 조작하는 데 있어 프로그래머가 갖는 자유도가 꽤 큽니다. 그래서 메모리를 엉뚱하게 조작하는 실수를 저지를 확률이 굉장히 높은 편입니다. 그래서 일찍부터 많은 사람들이 'Unix 계열 운영체제에서 C나 C++로 프로그래밍하는 사람들을 위한' 메모리 관련 문제 탐지 기법들을 내놓았습니다. 이런 기법들은 '잘못된 메모리 참조가 발생할 경우 그 사실을 보고해 주는' 형태를 띠고 있으며, 워낙 그런 기법에 대한 수요가 컸기 때문에 그 일부는 이미 운영체제에 붙박이로 제공되고 있기도 합니다.

가령 Linux 같은 경우는 bash 상에서 MALLOC_CHECK_ 환경변수의 값을 1로 만들면 heap curruption이 발생했을 경우 진단 메시지가 화면에 출력되고, 2로 만들면 그 즉시 실행중이던 프로그램이 종료됩니다. 디버깅을 하다보면 heap curruption이 발생하는 시점과 프로그램이 SIGSEGV를 받는 시점이 달라서 디버깅하기 곤란할 때가 있는데, 그런 경우에 유용합니다. 적어도 '잘못된 일이 벌어지는 시점'과 '프로그램이 죽는 시점'을 똑같이 만들 수 있거든요. (printf에 의존적인 디버깅을 하시는 분들께는 이런 기법이 특히 유용하죠.) Solaris의 경우에는 watchmalloc을 사용하여 Linux와 비슷한 효과를 누릴 수 있습니다. 구글에서  man watchmalloc 해보시면 사용법을 아실 수 있으니까 설명은 생략하겠습니다. Linux와 사용법이 크게 다른 편은 아니랍니다.

하지만 뭐니뭐니 해도 메모리 관련 문제를 잡는 가장 좋은 방법은, 좋은 진단 툴을 사용하는 것입니다. 옛날부터 정평이 나 있는 메모리 누수 탐지 툴로는 purify같은 것이 있습니다만, 고가라 선듯 사용하기가 겁나죠. 하지만 Linux에서 프로그래밍을 하고 있다면, valgrind라는 막강한 툴이 있습니다.

valgrind는 -g 옵션을 주고 컴파일된 프로그램이라면 적용될 수 있습니다. 가령 컴파일된 실행파일의 이름이 a.out이라면, 다음과 같이 실행하면 됩니다.

valgrind --tool=memcheck ./a.out

--tool 옵션을 통해 실행 파일에 어떤 문제들이 내재되어 있는지를 살펴볼 수 있습니다. default는 memcheck이며, 메모리 관련 문제들을 검사하겠다는 뜻입니다. 메모리 누수(leak) 현상이 발생하는지의 여부 등을 이 옵션을 통해 검사할 수 있게 됩니다. 그것도 아주 빨리요.

프로그램(위의 경우에는 a.out)의 실행이 끝나면 valgrind는 다음과 같은 형식으로 탐지된 오류를 보고합니다.

==25832== Invalid read of size 4
==25832==    at 0x8048724: BandMatrix::ReSize(int, int, int) (bogon.cpp:45)
==25832==    by 0x80487AF: main (bogon.cpp:66)
==25832==  Address 0xBFFFF74C is not stack'd, malloc'd or free'd
위의 메시지에는 0xBFFFF74C에 대한 잘못된 메모리 참조가 발생했는데, 그 주소가 가리키는 메모리가 정상적으로 스택에 올라간 메모리도 아니고, malloc된 적도 없으며 free된 적도 없다는 것을 알리는 정보가 포함되어 있습니다. 참조가 발생한 위치도 요약되어 있구요.

최신의 Linux 배포판에는 이제 valgrind가 거의 번들되어 배포되고 있는 것 같습니다. 설사 설치되어 있지 않더라도, 요즘은 apt-get이나 yum 등 네트워크를 통해서 자동으로 프로그램을 설치할 수 있는 툴이 잘 정비되어 있으니까, 그 툴들을 사용하면 간단하게 설치해서 돌려볼 수 있습니다.

디버깅을 할 때 거의 아무런 도구를 사용하지 않는 프로그래머를 많이 볼 수 있습니다만, 메모리 관련 오류를 탐지하는 데 있어서는 이런 도구를 사용하는 쪽이 절대적으로 빠릅니다. 특히 오랜 시간 돌려놓으면 비주기적으로 죽어버리곤 하는 프로그램을 디버깅하는 데는 이런 도구를 활용하는 편이 낫죠.

valgrind에 대한 더 자세한 정보를 원하신다면 일단 여기로.


신고
Posted by 이병준

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

  1. valgrind.. 저는 memcheck 툴 사용보다 instruction instrumentation 용으로 잠깐 써 봤는데 좋더라고요.. 그런데 지금은 돌아가는 프로세스에 attach가 되나요?? 큰 프로그램에 memcheck같이 프로그램 살펴보는 tool을 붙이려고 하는데 attach가 안되서 처음부터 같이 돌려야 한다는.. 속도 죽음이죠 -_-;; (굼벵이) attach되면 좋겠는데..

    2008.02.17 14:08 신고 [ ADDR : EDIT/ DEL : REPLY ]
    • 그러게요. 프로그램이 커질수록 그런 문제가 더 심각해지죠. 보통 메모리 검사를 시행하도록 해 놓으면 10배에서 20배 가까이는 느려진다고 하네요. Solaris에서 watchmalloc을 실행했을 때에는 정말 끔찍하게 느려지곤 했었죠. 10배 수준이 아니었던듯 ㅋ

      2008.02.17 14:47 신고 [ ADDR : EDIT/ DEL ]

Extremely Agile/TDD2008.01.14 01:17

지난 시간까지 손수 디버깅 툴을 만드는 사례를 살펴보았습니다. 구구절절 말이 많았습니다만, 사실 그 모든 매크로들은 결국 printf 함수를 사용해 뭔가를 화면에 찍어보도록 하는 것에 불과했어요.

C 프로그래머들 사이에 유명한 농담 가운데 하나로 (정말 농담?) "세상에 printf보다 강력한 디버깅 툴은 없다"는 말이 있습니다. (Java 프로그래머라면 "세상에 System.out.println보다 강력한 디버깅 툴은 없다"는 말로 바꾸어야 하겠군요.) 사실 디버깅이라는 작업의 대부분이 "어디어디에 값이 제대로 들어가 있는지 확인하는" 것이기 때문에, 이 말은 비록 농담이긴 하지만 유효합니다. 뭔가를 화면에 찍어 보는 데 printf보다 더 나은 방법은 사실 드무니까요.

그런데 printf를 사용해 만든 디버깅 매크로는 여러가지로 유용하기는 합니다만, 사실 사용하기에 좀 귀찮은 면이 있어요. 왜 그렇습니까? 프로그램이 뭔가 이상하게 동작하는 것처럼 보인다면, (1) 그 즉시 프로그램을 종료시키고 (2) 버그가 있으리라 의심되는 곳 여기저기에 디버깅 매크로를 좍 뿌린 다음 (3) 프로그램을 다시 실행시켜 보는 작업을 반복해야 하거든요. 나중에 릴리즈 모드로 컴파일할 때 그 디버깅 매크로들을 죄 없앨 수 있다고는 하지만, 결국 프로그램을 고쳐야 하는 건 마찬가지인데다, 디버깅을 해야 할 때마다 돌아가고 있는 프로그램을 세우는 삽질을 해야 하니, 지금 당장 중지시키기는 곤란한 프로그램이라면 (실제로 그런 경우가 있을 수 있습니다) 디버깅 매크로를 활용하기는 곤란해요. 디버깅 매크로를 넣을 때 마다 CVS가 해당 소스 코드를 새로운 버전으로 인식한다는 점도 사소하긴 하지만 귀찮은 점이죠.

이런 귀찮음을 극복할 수 있는 궁극의 방법은 무엇일까요? 제목에 나와 있으니 다들 아시겠습니다만, 가장 좋은 것은 바로 디버거를 사용하는 것입니다.

Unix에서 C/C++ 관련 프로그램을 디버깅 할 때 가장 많이 사용하게 되는 디버거는 바로 gdb입니다. gcc나 g++로 컴파일 할 때 -g 옵션을 주면 생성되는 디버깅 정보를 사용해서 동작하는, 가장 기본적인 디버거입니다. 이 디버거의 장점은 '거의 모든 플랫폼이 다 지원하는 디버거'라는 점이 되겠구요. 단점은 '너무 단순한 인터페이스' 정도가 되겠습니다. 하지만 Emacs의 gdb 인터페이스나 cgdb 같은 gdb 인터페이스 프로그램을 사용하면, Eclipse와 같은 IDE 프로그램을 통해 디버거를 사용하는 것과 유사한 방식으로 디버깅을 해 나갈 수 있으므로, 앞에 단점으로 든 문제점은 이제 상당 수준 해소가 되어 있다고 봐도 되겠습니다.

자. 그러면 이제 gdb를 통해 "디버깅 매크로로는 할 수 없는 일들"을 찾아서 해 봅시다. 그 첫 걸음은, "이미 돌고 있는 프로그램을 죽이지 않고도 가능한 디버깅"입니다. 디버깅 매크로로는 이런 일을 할 수 없습니다.

gdb <program> <process-ID of the program>

위와 같이 gdb를 실행시키면, 이미 실행중인 프로그램에 gdb를 붙일 수 있습니다. 이 때 "program"은 -g 옵션을 주고 컴파일된 프로그램이어야 하고, 그 process ID를 알고 있어야 합니다. (Unix 명령어 ps를 사용하면 알 수 있습니다.)

이 기법이 유용한 가장 간단한 사례를 살펴보죠. 가령 어떤 프로그램이 돌다가 멎어서 아무 반응도 보이지 않게 되어버렸다고 해 봅시다. 그런 경우 어디를 수행하다가 그렇게 멎어버렸는지 알고 싶다면? 그 프로그램의 이름이 stopped_program_name이고 Process ID가 34567 번이라고 한다면, 다음과 같이 하면 됩니다.

$ gdb stopped_program_name 34567

(gdb) bt

그러면 다음과 같은 메시지들이 주욱 뜨는 것을 볼 수 있죠. AccessRepository 클래스의 소멸자 안에서 데이터베이스에 링크 정보를 save하려다 멎어서 아무 짓도 하지 않게 되어버렸군요. 프로그램 수행 중에 데이터 베이스 연결이 사라진 것이 원인입니다. (어떻게 고칠 것인지는 논외로 하죠 -_-;)

#0  0x00742410 in __kernel_vsyscall ()
#1  0x00a1cb8b in __read_nocancel () from /lib/libpthread.so.0
#2  0x00155e38 in vio_read () from /usr/lib/mysql/libmysqlclient.so.15
#3  0x00155eae in vio_read_buff () from /usr/lib/mysql/libmysqlclient.so.15
#4  0x0015722c in net_realloc () from /usr/lib/mysql/libmysqlclient.so.15
#5  0x0015761b in my_net_read () from /usr/lib/mysql/libmysqlclient.so.15
#6  0x00150a48 in cli_safe_read () from /usr/lib/mysql/libmysqlclient.so.15
#7  0x00153bc5 in cli_advanced_command ()
   from /usr/lib/mysql/libmysqlclient.so.15
#8  0x00124ade in mysql_ping () from /usr/lib/mysql/libmysqlclient.so.15
#9  0x0804e12b in BJLEE::CMySQL::query (this=0xbfb17d38, query=@0xbfb177ec,
    suppress_exception=false)
    at /home/bjlee/work/libbjlee/include/bjlee/cmysql.h:214
#10 0x0804bab1 in LinkTable::save (this=0xbfb18144, mysql_server=@0xbfb17d38,
    aid=@0x8089c28) at linktable.cpp:227
#11 0x0805bd31 in ~AccessRepository (this=0xbfb17d34) at accessrepository.h:34
#12 0x0806a79d in start_access_bacf () at bacf.cpp:117
#13 0x0806b42f in main (argc=1, argv=0xbfb18b34) at bacf.cpp:264

간단하죠? 이것이 가장 간단한 활용 방법입니다. 이제 조금 복잡한 사례를 살펴보겠습니다. 가령 다음과 같은 프로그램을 컴파일하여 a.out을 만들었다고 해 봅시다.

#include <iostream>
#include <stdlib.h>

using namespace std;

int foo() {
    return 3;
}

int main() {
    int i = 0;
    while ( (i = foo()) != 0 ) {
        cout << "test" << endl;
        sleep(1);
    }

    return EXIT_SUCCESS;
}

그리고 이 프로그램을 실행시켰다고 해 봅시다. process ID는 8606입니다. 화면에는 1초마다 한번씩 test라는 문자열이 한 줄에 하나씩 찍히게 될 겁니다. 이 세션에 gdb를 붙이려면 gdb를 실행할 때 다음과 같이 해 주면 됩니다.

$ gdb a.out 8606

그렇게 하면 gdb가 a.out에 붙습니다. 그 순간, 프로그램의 수행은 일시적으로 정지합니다. 이제 gdb가 프로그램의 실행을 통제하기 시작합니다. 그 상태에서 br 명령을 실행합니다.

(gdb) br 15

15번째 줄(cout 이 있는 부분)에 breakpoint를 걸라는 소리입니다. 그런 다음 c를 입력하고 리턴키를 눌러 프로그램의 실행을 재개합니다.

(gdb) c

continue의 약자죠. ^^; 어차피 프로그램이 무한 루프를 돌게 만들어 뒀기 때문에 프로그램은 다시 14번째 줄까지 실행한 다음, 화면에 test를 찍는 15번째 줄을 실행하기 직전에 멈출 것입니다. i의 값을 확인하고 싶다면, 다음과 같이 print 명령을 실행하면 됩니다.

(gdb) print i

자. 이렇게 하면 실행 중인 프로그램에 gdb를 붙여서 그 실행 과정을 확인할 수 있습니다. 그럼 지금부터는 이 과정을 좀 더 '자동화 하는 방법'을 알아보도록 하죠.

그러려면 command 명령의 사용법을 알아야 합니다. command 명령은 특정한 breakpoint에 도달한 경우, 특정 집합의 gdb 명령어들이 실행될 수 있도록 해 줍니다. breakpoint를 특정 코드를 실행하기 위한 조건(condition)으로 사용하는 것이죠. 그런 점에서 보면 gdb 환경을 DTrace의 D 프로그래밍 언어처럼 사용하는 것과도 유사하다고 할 수 있겠어요. (DTrace를 써보신 경험이 없으시다면 걍 그런가보다 하고 넘어갑시다 ^^;)

command 명령은 다음과 같이 사용합니다. 아까 지정했던 breakpoint 1번에 대해서, command 명령을 실행하는 사례입니다.

(gdb) command 1

저기까지 입력하고 리턴 키를 누르면, 화면에 > 프롬프트가 떠서 breakpoint 1번에 도달했을 때 실행될 gdb 명령을 입력하도록 요구합니다. 맨 마지막 명령 다음에는 end를 입력해 주어야 합니다. 다음과 같이 입력해 봅시다.

(gdb) command 1
Type commands for when breakpoint 1 is hit, one per line.
End with a line saying just "end".
>silent
>print i
>continue
>end


silent 명령어에 대해서는 gdb 프롬프트 상에서 help command라고 입력해 보면 친절한 설명이 나오니까 설명하지 않겠습니다. 우선 이렇게 입력하고 엔터 키를 누릅시다. 그러면 화면에 다시 gdb 프롬프트 (gdb)가 뜹니다. 그 상태에서 c를 눌러 프로그램 수행을 재개합니다.

그러면 화면에 다음과 같은 출력이 계속적으로 발생하는 것을 볼 수 있습니다.

$16 = 3
$17 = 3
$18 = 3
$19 = 3
...


프로그래머가 별다른 입력을 주지 않아도, 1번 breakpoint가 hit 되면 실행될 명령의 마지막에 continue를 넣어놨기 때문에 출력은 계속적으로 발생합니다. 이 출력을 좀 더 보기좋게 만들고 싶다면 printf 대신에 printf를 다음과 같이 사용하면 되겠습니다.

>printf "i = %d\n", i

그렇게 하면 화면에는 다음과 같이 출력됩니다.

i = 3
i = 3
...


출력이 화면에 가득 차면, 계속 진행할 것인지 말 것인지 묻는 프롬프트가 뜨는데, 거기서 q를 눌러서 일단은 빠져나와 보도록 합시다. 그러면 gdb 프롬프트가 떨어지는데, 거기서 다시 q를 누르면 gdb는 종료되고, 프로그램 a.out은 다시 수행을 계속합니다. (q를 누르기 전에 detach 명령을 실행해도 gdb와 원래 프로그램 프로세스가 분리되기 때문에, a.out이 계속 수행되도록 만들 수 있습니다.)

그러면 이제 위의 명령들을 별도의 파일에 저장하고, gdb를 실행할 때 스크립트 형태로 실행될 수 있도록 하는 방법을 알아보겠습니다. (여기까지 살펴보면 이제 자동화에 관련된 부분은 거의 다 살펴본 것이 되려나요?) 대부분의 UNIX 툴들이 그렇듯, gdb도 스크립트 파일을 활용해 배치 모드로 실행하면 더 멋지게 써먹을 수 있습니다. gdb 정도의 툴이 그런 방법을 제공하지 않았다면 아마 더 이상했겠죠.

다음의 코드를 입력한 다음, a.out과 같은 디렉터리에 verify_i.gdb라는 이름으로 저장해보겠습니다.

br 15
command
  silent
  printf "i = %d\n", i
  continue
end
continue

command 명령을 실행할 때 그 뒤에 breakpoint 번호를 주지 않으면, 가장 마지막에 생성된 breakpoint 번호가 내정치로 사용된다는 점을 이용했습니다. 자. 그러면 이제 이 파일을 저장한 다음에, 다음과 같이 실행해 보죠. gdb를 batch mode로 실행하기 위해 -x 옵션을 사용했습니다.

$ gdb a.out 8606 -x verify_i.gdb

그렇게 하면 화면에는... 실행하자 마자 아까 봤던 i = 3이 계속해서 찍히기 시작합니다. 중단시키려면 Ctrl-C를 누른 다음에 detach를 입력해서 프로세스로부터 디버거를 분리시키면 됩니다. 그러면 원래 프로그램의 수행은 지속될 수 있습니다.

이런 스크립트 파일을 잘 만들어 두면 디버깅 매크로 같은 것을 만들지 않고서도 프로그램 안에 등장하는 각종 변수의 값을 편하게 모니터링 할 수 있습니다. gdb를 띄울 때 마다 매번 명령어를 입력하는 수고를 들이지 않고서도 말이죠.

- 참고할만한 자료 :
영문 gdb 사용자 가이드 http://sourceware.org/gdb/current/onlinedocs/gdb_toc.html
한글 gdb 사용자 가이드 http://korea.gnu.org/manual/release/gdb/gdb.html (오래전 버전이므로 주의할것)
gdb 사용하기 http://theeye.pe.kr/40


[5부에 이어집니다]

신고
Posted by 이병준
TAG GDB, 디버깅

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

Extremely Agile/TDD2008.01.11 17:01

사실 이번 글은 독립적인 글이라기 보다는 이전 글의 연장선상에 있는 글이라고 보는 것이 좋겠네요. C에서 assert 관련 기능은 assert.h에 정의되어 있습니다. assert는 흔히 '프로그램 수행 중에 반드시 충족되어야 하는 조건을 디버깅 단계에서 검증하기 위해 사용하는' 루틴입니다. Java라면 VM을 실행할 때 -ea를 옵션으로 주고 실행시키면 assert 기능이 활성화되도록 만들 수 있고, java -ea:com.wombat.fruitbat와 같이 하면 특정한 package에 대해서만 assert 기능이 활성화되도록 만들 수 있습니다.

예전에 assert 기능을 release 모드에서 꼭 제거할 필요가 있느냐, 라는 논쟁이 있었던 것으로 기억합니다. 제거해야한다는 입장을 취하는 사람들은 "이미 디버깅이 끝난 소프트웨어에 assert 코드를 남겨두는 것이 무슨 의미가 있는가?"라는 주장을 했구요, 제거하지 말아야 한다는 주장을 하는 사람들은 "정말로 디버깅이 100% 완료되었는지 확신할 수 있는가?  그리고 assert 문을 남겨두는 것이 성능에 그렇게 해가 된다고는 보지 않는다"는 주장을 했었죠.

사실 assert를 통해 검사하는 조건들은 "프로그램 수행 중에 응당 발생할 수 있는 예외적 조건"들이 아닙니다. 예외적 조건들에 대한 처리 루틴은 프로그램 안에 반드시 들어가 있어야 합니다만, assert를 통해 검사하는 것은 "절대로 발생하면 안되는 조건이 실제로 발생하였는지"를 검사하는 것입니다. "절대로 발생해서는 안되는 조건이 발생했다는 것"은 프로그램에 있어서는 안되는 버그가 있다는 이야기이고, 그런 버그는 반드시 제거되어야 합니다.

그러니 "release 모드로 컴파일 할 때에는 assert 관련 코드는 반드시 제거되어야 한다"는 주장은 나름대로 타당성이 있죠. 하지만 "프로그램에서 버그를 100% 완벽하게 제거한다"는 것이 달성하기 힘든 목표이고 보면, 릴리즈 되는 최종 소프트웨어에 assert 문을 남겨두는 것이 크게 나쁘다고는 볼 수 없을 것 같아요. 릴리즈 된 소프트웨어를 설치하고 운영하는 중에 예기치 못한 문제가 발생한 경우, assert가 있으면 그런 문제를 해결하는 것이 분명 편해지거든요. 그래서 저는 릴리즈 모드로 컴파일된 프로그램에도 '굳이 필요하다면' assert를 남겨둘 수 있도록 하려고 해요.

자. 그러면 지난 시간에 살펴본 코드들로 돌아가서, 실제 코드를 보도록 하죠. 지난 시간에는 _DEBUG를 사용해서, 디버그 모드에서 사용할 디버그 매크로들이 활성화되도록 했었습니다. _DEBUG가 정의되어 있지 않으면, 이런 매크로들은 실제 코드에서는 사라지도록 했었죠.

그런데 assert에는 이미 이런 기능이 내재되어 있습니다. 컴파일시에 NDEBUG라는 상수가 정의되어 있으면 assert는 코드에서 사라집니다. (완전히 사라지지는 않습니다. assert 함수의 body 부분만 사라지죠. 그래서 NDEBUG를 정의하더라도 assert를 호출하는 오버헤드를 100% 제거할 수는 없습니다. 컴파일러가 최적화를 나이스하게 해 주지 않는 한.) NDEBUG가 정의되어 있지 않으면, assert는 자신이 하도록 되어 있는 일을 합니다. 자신이 검사하는 expression의 값이 0이 되면 파일명과 라인수를 출력하고 프로그램을 강제 종료시켜버리는 거죠.

그런데 기왕에 _DEBUG를 쓰는 마당에 NDEBUG라는 엇비슷하게 생겨먹은 상수를 또 쓴다는 게 어쩐지 좀 맘에 안드는 군요. 그러니 NDEBUG대신 DISABLE_ASSERT라는 상수를 사용하도록 한번 고쳐보겠습니다. 다른 디버깅 함수들은 대문자인데 assert는 소문자라는 것도 좀 맘에 들지 않는 부분이니, 그 부분도 고쳐보겠습니다. assert가 소문자이기 때문에, 자칫 실수하면 Heisenbug라는 버그가 발생할 수도 있거든요. http://ideathinking.com/blog-v2/?p=56 에 실제 그런 문제를 겪으신 분의 포스팅이 있습니다. http://sunsite.ualberta.ca/jargon/html/H/heisenbug.html 에 가면 Heisenbug의 정의가 있으니 참고하시기 바랍니다.

그럼 실제 코드를 볼까요?

#ifndef DISABLE_ASSERT
#define ASSERT(X) assert(X)
#else
#define ASSERT(X)
#endif

이런 식으로 해 놓고 assert 대신 ASSERT를 쓰면, "assert의 인자로 '반드시 수행되어야만 하는 코드'를 넣어서 발생하는 문제"(즉, Heisenbug)를 어느 정도는 방지할 수 있겠습니다. 설사 생기더라도 교정하기는 좀 쉽겠죠. 대문자로 되어 있으니, 찾아내기도 좀 더 쉬워질 테구요.

ASSERT 매크로를 _DEBUG와는 무관하게 구현하였기 때문에, 컴파일 시에 _DEBUG를 정의하지 않고 컴파일하더라도 (즉, 릴리즈 모드로 컴파일하더라도) ASSERT를 코드 안에 그대로 남겨둘 수 있습니다. ASSERT를 전부 제거하고 싶으면 DISABLE_ASSERT까지 정의해서 (-DDISABLE_ASSERT) 컴파일을 해야만 하죠.

자. 그러면 '반드시 실행되어야 하는 코드의 실행 결과를 검사하려면 어떻게 해야 하죠? 그건 ASSERT로는 좀 곤란해요. DISABLE_ASSERT를 정의하고 컴파일 하는 순간, 그 코드는 오브젝트 파일 안에서는 사라져버릴 것이거든요. 그런 경우에는 VERIFY같은 매크로를 추가로 정의해서 사용하는 편이 더 낫습니다. VERIFY의 구현에 대해서는 http://www.developerfusion.co.uk/show/1719/7/ 를 참고하시는 것이 좋을 것 같습니다. :-)


[4부로 이어집니다]

신고
Posted by 이병준

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

Extremely Agile/TDD2007.12.28 15:31
손수 만드는 디버깅 툴

디버깅을 할 때 가장 많이 쓰게 되는 툴은 무엇일까요? 아이러니하게도, 디버거가 아닙니다. 사실 툴이라고 하기도 좀 뭐하죠. 사람들이 디버깅할 때 가장 많이 사용하는 도구는, printf입니다. (Java라면 System.out.println이나 System.err.println쯤 되겠군요. C++이라면 cout이나 cerr가 되겠습니다.) 프로그램을 디버깅할 때 '프로그램의 상태를 화면에 출력하는' 일을 가장 많이 하게 된다는 뜻이죠.

하지만 printf를 무작정 프로그램 코드 안에 삽입하다보면, 나중에 삽질을 하게 됩니다. 어떤 삽질일까요? 네, 맞습니다. 나중에 프로그램 개발을 완료하고 시스템을 패키지화 해서 릴리즈 할 때가 되면, 그 모든 'printf' 문들을 전부 코드에서 지워줘야 합니다. 그러니, 가급적이면 printf를 써서 디버깅을 하더라도 좀 지능적으로 하는 것이 좋겠죠.

사용자 삽입 이미지

마이크로소프트의 Visual C++이라는 툴을 쓰다 보면 (물론 다른 툴들에도 그런 기능이 있습니다만) 컴파일을 디버그 모드로 할 것이냐 릴리즈 모드로 할 것이냐 하는 옵션을 보게 됩니다. 이런 두 가지 옵션이 있다는 것은, 릴리즈 모드로 컴파일할 때에는 '디버그 할 때에만 실행되던 코드들은 더 이상 실행되지 않아야 한다'는 원칙이 지켜져야 함을 보여줍니다.

그렇다면 디버그 모드와 릴리즈 모드는 어떻게 구별하나요? 컴파일 옵션을 통해 구별합니다.

디버그 모드로 컴파일할 때에는, 현재 컴파일이 디버그 모드에서 진행됨을 표시하는 특별한 매크로 상수를 정의하는 것이 보통입니다. gcc라면, 다음과 같이 합니다.

gcc -D_DEBUG <이하 생략>
위의 -D 옵션은 컴파일러에게 _DEBUG라는 심볼을 정의한 다음 컴파일 할 것을 주문합니다. 따라서 소스 코드를 작성할 때 #ifdef 와 #ifndef 을 적절히 활용하면, 디버깅 모드에서 실행되어야 할 코드와 그렇지 않아야 하는 코드를 적절히 나누어 작성할 수 있습니다. 다음과 같이 하면 되겠죠.

#ifdef _DEBUG
...
#else
...
#endif
그런데 printf문 하나 넣자고 그 앞뒤로 #ifdef를 둘러치자니, 그것도 못할 짓인것 같군요. 그러니, 매크로 함수를 정의해서 그 매크로 함수가 컴파일 옵션에 따라 서로 다른 방식으로 동작하도록 구현하는게 더 좋겠어요. 우선, 사용자가 원하는 문자열을 화면에 찍을 수 있도록 해 주는 매크로 함수인 DUMP의 구현부터 살펴보죠.

#ifdef _DEBUG

#define DUMP(PRNSTR,...)  printf( \
 "dumping   [%010u,%s,%04d] " #PRNSTR "\n", \
 (unsigned int)pthread_self(), __FILE__,__LINE__,__VA_ARGS__)

#else

#define DUMP(PRNSTR,...)

#endif
위의 DUMP 함수는 두 개의 인자를 받습니다. 첫 번째 인자인 PRNSTR은 printf의 서식 문자열의 일부로 사용될 문자열이고, 두 번째 인자(...)는 그 서식 문자열(PRNSTR)에 의해 출력될 인자들이에요. (printf와 똑같은 방식으로 실행되는 매크로 함수라는 거죠.)

첫 번째 인자 PRNSTR은 #PRNSTR을 통해 큰 따옴표로 둘러쳐진 문자열로 변환됩니다. (# 기호가 어떤 역할을 하는 지에 대해서는 C 전처리기 관련 문서를 찾아보시는 것이 좋겠습니다.) 따라서 "dumping   [%010u,%s,%04d] " #PRNSTR "\n"는 하나의 문자열입니다. C/C++에서는 "a" "b" "c"가 "abc"와 같으니까요. 따라서 최종적으로 만들어질 서식 문자열(printf의 첫 번째 인자로 넘겨지는)에는 쓰레드 아이디와 파일명, 그리고 라인수를 출력할 자리가 기본적으로 포함됩니다.

두 번째 인자(...)는 첫 번째 인자로 준 서식 문자열에 의해 출력될 인자들인데요, __VA_ARGS__를 사용해서 printf의 인자로 그대로 넘겨버렸습니다.

이렇게 매크로 함수를 정의하고 나면, 프로그램 코드 안에서 다음과 같은 짓을 할 수 있습니다.

int variable = 0;
...
DUMP("%d", variable);
...
이 코드를 -D_DEBUG를 사용해서 디버그 모드로 컴파일하고 실행시키면, DUMP가 실행될 때 마다 화면에 쓰레드 아이디와 파일명, 그리고 DUMP가 놓인 라인수를 포함하는 정보가 출력됩니다. 물론 변수 variable의 값도 함께 말이죠.

-D_DEBUG를 컴파일 옵션에서 빼 버리고 다시 컴파일하면 매크로 함수 DUMP는 코드 안에서 사라집니다. (#else와 #endif 사이 부분을 참고하세요) 그러므로 화면에는 어떤 메시지도 출력되지 않습니다.

자. 이런 구현법을 활용하면 다양한 일들을 할 수 있습니다. 가령, 어떤 실행문을 돌릴 때, 디버그 모드에서 돌리면 '해당 실행문이 실행되려 한다는 사실을 알리는' 텍스트 메시지를 함께 출력하고, 릴리즈 모드에서 돌리면 그런 메시지 없이 그냥 그 실행문이 실행되도록 만들려면, 다음과 같은 매크로 함수를 정의해서 사용하면 됩니다.

#ifdef _DEBUG

#define EXECUTE(X)  \
 ( printf("executing [%010u,%s,%04d] " #X "\n", \
   (unsigned int)pthread_self(), __FILE__, __LINE__),(X) )

#else

#define EXECUTE(X) x

#endif
자. 먼저 EXECUTE의 인자 X가 #X를 통해서 큰 따옴표 문자열로 변환된 다음 서식 문자열과 결합됩니다. 그런 다음 쓰레드 아이디와 파일명, 행번호 등의 정보와 함께 화면에 출력됩니다. 그런 다음 실행문 X가 실행됩니다. 세미콜론을 사용하지 않고도 이 두 실행문을 연달아 실행시킬 수 있었던 것은, ',' 연산자 때문입니다. 이 연산자는 좌 우의 피연산자들을 순서대로 실행시키는 역할을 합니다.

int variable = 0;
...
EXECUTE(variable = 1);
따라서 위의 코드는 디버그 모드에서 다음과 같은 출력을 내놓게 됩니다.

executing [0000000012, test.cpp, 36] variable = 1
그리고 그 결과로 variable 에는 1이 대입되죠. 디버그 모드가 아닌 릴리즈 모드(즉, -D_DEBUG를 사용하지 않은 상태)로 컴파일하고 실행해 보면 화면에 아무런 메시지도 출력되지 않지만 variable에 1을 대입하는 동작은 여전히 수행됩니다.

자. 그러면 이런 매크로 함수들을 많이 만들어 두면, 굉장히 쓸만한 디버깅 도구를 스스로 만들어 사용할 수 있겠군요. (물론 그러려면 C/C++의 매크로 전처리기에 대한 지식은 가지고 있어야만 합니다.)

좀 더 나아가면, 프로그램 내 특정 코드 세그먼트의 성능을 측정하는 매크로 함수도 만들어 사용할 수 있습니다. 아래의 예제를 보시죠. #ifdef ... #endif는 생략하고, 매크로 함수의 코드만 보였습니다.

#define PROFILE_BEGIN(pfid)        \
   unsigned int __prf_l1_##pfid = __LINE__; \
   struct timeval __prf_1_##pfid;    \
   struct timeval __prf_2_##pfid;    \
   do {          \
    gettimeofday(&__prf_1_##pfid, 0);  \
   } while ( false )

#define PROFILE_END(pfid)        \
   unsigned int __prf_l2_##pfid = __LINE__; \
   do {          \
    gettimeofday(&__prf_2_##pfid, 0);  \
    long __ds = __prf_2_##pfid.tv_sec - __prf_1_##pfid.tv_sec; \
    long __dm = __prf_2_##pfid.tv_usec - __prf_1_##pfid.tv_usec; \
    if ( __dm < 0 ) { __ds--; __dm = 1000000 + __dm; } \
    printf("profiling [%010u,%s] " #pfid  \
      " (%u ~ %u) total %u.%06u seconds\n", \
      (unsigned int)pthread_self(),   \
      __FILE__,      \
      __prf_l1_##pfid,    \
      __prf_l2_##pfid,    \
      (unsigned int)(__ds),    \
      (unsigned int)(__dm));    \
   } while ( false )
이 코드는 다음과 같이 사용합니다.
PROFILE_BEGIN(test1)
    /* do some programming jobs */
PROFILE_END(test1)

그런 다음 디버그 모드에서 컴파일하고 실행해 보면, 화면에 PROFILE_BEGIN(test1)과 PROFILE_END(test1) 사이의 코드가 실행된 시간이 다음과 같이 찍히게 되죠. (test1이라는 이름은 해당 코드 안에서 유일해야 합니다.)

[test_api.cpp] test1 (90 ~ 98) total 1.34122342 seconds

test_api.cpp 파일의 코드 90번째 줄 부터 98번째 줄까지의 코드 실행 시간이 저만큼 걸렸다는 뜻입니다.

프로파일링까지 통합 환경 안에서 한방에 제공하는 IDE를 쓰신다면 문제가 다르겠습니다만 (뭐 가령 Eclipse나 NetBeans같은 것 말이죠) 그런 툴을 사용할 수 없는 환경에서 vi만 가지고 개발을 해야 한다면 이런 디버그 매크로들을 여러 개 만들어 두고 사용하는 것이 여러가지 버그를 잡는 데 도움을 줍니다.

[참고할만한 링크]


[3부에 계속...]

신고
Posted by 이병준

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

  1. 지나가다

    VC++ 6.0은 매크로에서 ...을 안 지원하는 것으로 알고 있습니다.
    컴파일이 되지 않겠네요.

    2009.10.27 10:01 신고 [ ADDR : EDIT/ DEL : REPLY ]
  2. 최모씨

    좋은글 잘 읽었어요~ 이거 퍼가고 출처 남겼는데 괜찮은지요?

    2010.01.23 23:58 신고 [ ADDR : EDIT/ DEL : REPLY ]
  3. 학생이

    덕분에 많이 배워갑니다. 고맙습니다 ~!^^

    2014.06.12 23:19 신고 [ ADDR : EDIT/ DEL : REPLY ]

Extremely Agile/TDD2007.12.26 01:48
소프트웨어와 버그

소프트웨어에서 버그를 100% 제거하는 것이 거의 불가능에 가깝다는 것은 잘 알려진 이야기입니다. 소프트웨어 시스템의 복잡도가 날이 갈수록 증가하면서, 버그의 양상도 굉장히 다양해 졌습니다. 하지만 대다수의 버그들은 다음의 세 가지 범주들 가운데 하나에 해당합니다[각주:1].

  • 논리적 버그 (logical bugs)
  • 구조적 버그 (structural bugs)
  • 성능 버그 (performance bugs)

논리적 버그는 논리 오류(logical error)와 같은 말입니다. 구조적 버그는 '어떤 소프트웨어가 성취해야 할 가치를 저해하는, 소프트웨어 구조상의 결함'을 일컫는 말입니다. 성능 버그는 '어떤 소프트웨어가 성취해야 할 가치를 저해하는, 성능상의 문제'를 일컫는 말입니다.

통상 구조적 버그와 성능 버그는 서로 관계가 있는 것이 보통이겠습니다만(가령 처리율이 중요한 시스템의 프론트 엔드에 메시지 큐를 붙이지 않았다던가 하는), 확장성이 중요한 시스템처럼, 구조적 버그가 성능상의 문제를 야기하지 않는 경우도 있습니다. 확장성이 높은 시스템을 만들었어야 하는데 확장성이 떨어지는 시스템을 만들어 버렸다고 칩시다. 그런 경우, 성능상에는 아무런 문제가 없지만 '확장성이 높은 시스템'이라는 애초의 목표에 미달하였으므로 문제입니다.

사실 이런 이야기들은 굉장히 해묵은 이야기들입니다. 제가 굳이 이야기를 더 하지 않아도, 대부분의 소프트웨어 개발자들은 이미 잘 알고 있는 이야기죠. 중요한 것은 '어떤 버그가 있느냐' 하는 것이 아니라 '버그를 어떻게 해결할 것이냐'하는 것입니다.

어떻게 해결할 것인가

논리적 버그의 경우에는 해결 방법이 비교적 잘 알려져 있는 편입니다. 이런 버그가 시스템에 들어가는 이유는 단순합니다. 알고리즘을 잘 못 만들었거나, 알고리즘을 코드로 잘 못 옮겼기 때문입니다. 버그를 '잡는다'는 행위는 아마도 '버그 탐지'와 '버그 수정'의 두 가지 층위로 나누어 생각해 볼 수 있을 것인데, '버그 수정'은 결국 '애초의 알고리즘 상의 오류를 교정'하는 작업이거나 '알고리즘을 엉뚱한 코드로 옮긴 실수를 교정'하는 작업이 될 터이니, 결국 논리적 버그를 잡는 데 있어서 가장 중요한 것은 '버그 탐지'인 셈입니다. 논리적 버그를 '탐지'하는 가장 널리 알려진 솔루션은, 바로 디버거(debugger)입니다.

한편, 구조적 버그와 성능 버그의 경우에는 문제가 좀 까다롭습니다. 이런 문제를 해결하기 위해서는, 구조적 버그와 성능 버그가 나타나는 양태를 돌이켜 볼 필요가 있습니다. 대략 다음과 같은 범주들로 나눌 수 있을 것 같습니다.

  • 구조적 버그가 발견됨
  • 구조적 버그 및 구조적 버그가 야기한 성능 버그가 발견됨
  • 성능 버그가 발견됨

구조적 문제와 하등의 관련이 없는 성능 버그가 관측된 경우에는(위의 세번째 경우), 흔히 소프트웨어 개발 대가들이 하는 말 대로, '프로파일링을 열심히 한 다음에 문제가 되는 코드를 손보면' 됩니다. 프로파일러(profiler)를 잘 쓰고, 문제가 되는 지점을 탐지한 다음, 코드를 뜯어고치면 된다는 것이죠. (문제는 '탐지'는 상대적으로 쉬운 반면, '뜯어고치는 것'은 상대적으로 어렵다는 것이죠. 물론 경험이 쌓이면 나아지긴 합니다.) 이런 종류의 문제는 소프트웨어를 아무리 잘 설계하고 구현하더라도 피하기가 힘듭니다. 그러므로 회피(avoidance) 전략이 잘 먹히질 않습니다. '발견되면 해결한다'의 접근법을 취하는 것이 좋습니다.

반면, 성능 버그를 야기하지 않는 구조적 버그가 발견된 경우에는(위의 첫번째 경우) '설계가 잘못된 것'이 그 원인이므로, 설계를 바로잡는 것이 최선입니다. (소프트웨어 개발 후반부에 이런 일이 생겨버리면 좀 난감하긴 합니다.) 이런 종류의 문제는 '발견되었을 때 해결'하기가 어려운 경우가 많으므로(구조를 변경하기에는 너무 진도를 많이 나가 버린 경우에 그렇습니다), 회피 전략을 취하는 것이 효과적일 수 있습니다. 애초에 '목적에 맞는' 디자인 패턴을 도입하거나, '스파이크 솔루션(spike solution)'을 최대한 많이 만들어 보는 것이 그런 전략들 중 한가지가 될 것입니다.

성능 버그와 관련된 구조적 버그가 발견된 경우에는(위의 두번째 경우) '성능 요구사항에 부합하지 않는 구조가 선택된 것'이 원인입니다. 이런 문제는 해결하기도 어렵고, 회피하기도 어렵습니다. 이런 종류의 문제를 회피하려면, 굉장히 많은 know-how가 필요합니다. 문제가 발생한 도메인(domain)에 흔히 사용되는 기술들에 대한 깊이있는 이해가 요구되는 것이죠. 그리고 이런 버그에 대한 해결책은, 그 규모가 앞선 두 가지 종류의 버그에 비해 굉장히 큰 경우가 많습니다. (무슨무슨 미들웨어를 써야 한다거나, 무슨무슨 데이터베이스를 써야 한다거나, 무슨무슨 부하분산 기법을 써야 한다거나 하는 종류의 솔루션들이 그 예가 되겠습니다.)

당신이 초/중급의 프로그래머라면

초/중급의 프로그래머는 '구조적 문제와 관련이 있는 성능 문제'같은 것을 겪을 기회가 사실 별로 없을 것입니다. 있다면, 주변에서 같이 일하는 프로그래머들이 전부 해당 도메인에 그다지 큰 지식이 없는 경우겠죠. 대부분의 경우에는 '그런 문제가 생길 소지가 있다'고 판단되는 순간, 경험이 있는 다른 프로그래머들이 '그런 문제는 이런 저런 식의 솔루션을 도입해서 해결한다'는 답을 내놓습니다. (물론 그 답이 항상 옳지는 않습니다.) 그러니, 이 글에서도 그런 종류의 '거창한' 버그는 논외로 하겠습니다.

자. 앞 단락에서 잠깐 언급을 했습니다만, 버그를 해결하는 방법에는 두 가지가 있습니다. 버그를 아예 만들지 않거나(회피 전략), 아니면 만들어진 버그를 굉장히 빠른 시간 안에 잘 수정하거나.

회피 전략을 극도로 밀어부치면 '아예 프로그램을 짜지 않는 것이 최선'이라는 이야기가 나올 법도 합니다만, 그러면 아예 프로그래머라는 직종이 사라지는 수가 생기므로 곤란하죠. 그렇다면 '코딩을 하되 버그는 회피하는' 좋은 수단으로는 어떤 것이 있을까요? 위에서도 언급했습니다만 '디자인 패턴'이나 '스파이크 솔루션' 같은 것이 유용한 수단이 될 수 있습니다. 사실, '디자인 패턴'과 '스파이크 솔루션' 사이에는 공통점이 있습니다. 둘 다 '선험적 지식'이라는 점에 있어서 공통적이라는 것이죠. 다지안 패턴은 GoF가 만든 이래로 많은 사람들이 갈고 닦아온 코딩 패턴이고, 스파이크 솔루션은 '실제 시스템을 만들 때 중요한 지식을 미리 시험하고 체험해 볼 수 있는 솔루션'이라는 점에서, 전부 '선험적 지식'에 기대고 있습니다.

그렇다면, '이미 검증된 라이브러리 상에서 작업하는 것'도 유용한 회피 전략 중 하나가 될 수 있겠군요. 네. 그렇습니다. 이미 검증된 라이브러리 자산 위에서 작업하면 생산성은 배 이상 올라갑니다. 사람들이 STL에 그렇게 열광하는 것도 그래서죠. 뭐가 잘 되고 뭐가 문제점인지 다 알고 있는 라이브러리 위에서 작업하는데, 당연히 생산성이 올라갈 수 밖에요.

회피 전략 이야기는 이쯤 하구요. 그렇다면 버그를 '굉장히 빠른 시간 안에 잘 수정'하는 좋은 방법은 뭐가 있을까요? 디버거가 '버그의 위치를 탐지하고 교정'하는 데 도움을 준다는 것은 분명하지만, 그렇다고 디버거가 모든 일을 다 잘 해주는 것은 아니에요. 메모리 문제라면 (적어도 Linux에서는) valgrind같은 툴을 쓰면 더 빨리 버그의 위치를 탐지해 날 수 있다는 것은 잘 알려져 있는 사실이죠. 고치는 문제라면, TDD같은 방법론에 의존하는 편이 더 낫습니다. (TDD에 대해서는 켄트 벡의 저서를 읽어보시기 바랍니다.)

자. 그러면 과연 초/중급의 프로그래머는 '디버깅을 잘' 하기 위해 무엇을 공부해야 하는 것일까요?

  • 디버깅 관련 유틸리티를 만들고 사용하는 방법
  • 디버거를 사용하는 방법
  • 버그 탐지 소프트웨어들을 사용하는 방법
  • TDD

'디버깅(debugging)'은 '있는 버그를 없애는'것이니까 회피 전략에 해당하는 부분들은 뺐습니다. (디자인 패턴이나 스파이크 솔루션 같은 것들요. 디자인 패턴에 대해서는 이미 시중에 좋은 책들이 많으니 참고하시면 될것 같고, 스파이크 솔루션에 대해서는 XP관련 사이트를 찾아보시는 게 더 나을 것 같습니다. 아니면 XPE나 XPI같은 책을 읽어보시거나요.) 그냥 순수하게, '있는 버그의 위치를 찾아내고 교정하는'것에 대한 부분만 살펴보도록 하겠습니다.

따라서, 저는 이 글의 2부, 3부, 4부에서 다음과 같은 내용들을 살펴보려고 합니다.

  • 직접 만드는 디버깅 유틸리티
  • 디버거(gdb)
  • 잘 알려진 버그 탐지 소프트웨어들과, 기타 등등의 탐지 기법

TDD(Test-Driven Development)에 대해서는 이미 좋은 책들이 있으니까 굳이 다루지는 않겠습니다만, 시간이 허락하는대로 그 실제 예제를 한 번 살펴보도록 하겠습니다. 사실 TDD는 버그의 '위치'를 탐지하는 데 도움을 주는 기법이라고 하긴 좀 뭐합니다만, 테스트를 하는 주기를 굉장히 짧게 줄여 나가다보면 '버그가 어디에서 발생했는지'를 거의 정확하게 파악할 수 있습니다. 테스트가 끝난 부분에서는 버그가 발생할 가능성이 낮으니, 새로 추가된 부분에 버그가 있을 것이다라는 가정을 하면, 버그의 위치를 빨리 찾아낼 수 있다는 것이죠. 거기다 TDD에 기반해서 개발을 하게 되면 결함 비용이 굉장히 낮아지게 될 뿐 아니라, 소프트웨어의 '구조적 결함'까지도 프로젝트 초기에 찾아낼 수 있다는 장점이 있습니다.

[2부로 이어짐]



  1. "Bug"의 정의에 대한 의견이 올라와서 각주를 답니다. Wikipedia에 따르면, Bug는 다음과 같이 정의됩니다. "error, flaw, mistake, "undocumented feature", <A title=Failure href="http://en.wikipedia.org/wiki/Failure">failure</A>, or <A title="Fault (technology)" href="http://en.wikipedia.org/wiki/Fault_%28technology%29">fault</A> in a <A title="Computer program" href="http://en.wikipedia.org/wiki/Computer_program">computer program</A> that prevents it from behaving as intended." 본 글에서는 이 정의를 다소 유연하게 해석하여, 버그의 정의가 아우르는 외연을 좀 확장하였습니다. 따라서 다른 분들이 보시기에 이 글에 등장하는 구조적 버그나 성능 버그는 요구사항requirement이나 만족 조건conditions of satisfaction같아 보일 수도 있습니다. 그런 면에서 보면 이 글에서의 버그는 <A href="http://en.wikipedia.org/wiki/ISO_9126" target=_blank>ISO 9126</A>에서 사용하는 버그의 정의와 같거나 유사하다고 볼 수도 있겠습니다. [본문으로]
신고
Posted by 이병준

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

  1. 일반적으로 버그가 오류(error)라는 뜻으로 통용된다는 상식을 받아들인다면, 구조적 버그나 성능버그는 버그로 분류될 종류의 것이 아닌것 같군요. 이 두 버그는 차라리 구조 및 성능에 대한 요구사항 미달에 가깝기 때문에 "소프트웨어 품질"에 포함시키는것이 더 나아보입니다. 당연히 품질이라는 개념에도 버그가 포함되겠지만, 버그는 프로그래머들에게나 통용되는 대단히 협소한 은어정도에 해당하는것이라 뜻이 제한적인 반면 품질이라는 단어는 훨씬 고 수준에서 고객들과 함께 사용할 수 있는 보다 일반화된 용어라는 차이가 있겠습니다. 때문에 두 종류의 버그를 구조적 요구사항 미달, 성능 요구사항 미달정도로 대치해서 생각해보는게 좋을것 같습니다.

    (구조적 요구사항이란건 개발자들에겐 중요해도 고객들에겐 크게 이슈화될 게 아니기 때문에 요구사항이란 단어를 붙이는 경우라면 "구조"를 "기능"이란 단어로 대치하는것이 좋겠습니다.)

    2008.04.26 22:50 신고 [ ADDR : EDIT/ DEL : REPLY ]
    • 낭만고양이

      좋은 의견이십니다. ^^ 다시 한번 정리해보도록 하죠.

      2008.04.27 12:51 신고 [ ADDR : EDIT/ DEL ]

Extremely Agile/TDD2007.09.26 22:11
HttpUnit에 대한 글을 쓴지도 얼마되지 않았는데, 웹 서핑을 하다보니 또다른 툴을 만나게 되는군요.

아래의 그림은 JWebUnit 웹사이트 (http://jwebunit.sourceforge.net/index.html) 에서 가져온 아키텍처 그림입니다. JWebUnit 역시 HttpUnit과 마찬가지로 JUnit에 기반하여 만들어진 단위 테스트 프레임워크임을 알 수 있습니다. 흥미로운 것은 플러그인 구조를 채택해서 확장가능토록 구성된 점인데, 현재로서는 HtmlUnit 플러그인만 제공됩니다. ^^;

자바 스크립트 지원이 어느 정도까지 가능한지는 아직 잘 모르겠습니다. 솔직히 웹 브라우저와 직접 연동해서 테스트를 해 주는 Selenium과 같은 솔루션이 아니라면 자바 스크립트 엔진을 직접 구현하는 테스트 솔루션이 되어야 하는데, 그런 정도까지는 무리지 않을까, 하는 생각도 드네요.

JWebUnit 아키텍처

JWebUnit 아키텍처



public class WebIntegrationTest extends net.sourceforge.jwebunit.WebTestCase {

    public void testIndex() {
        beginAt("/index.html");
        assertTextPresent("Hello world");
    }

    private org.mortbay.jetty.Server server;

    protected void setUp() throws Exception {
        server = new org.mortbay.jetty.Server(0);
        server.addHandler(
                new org.mortbay.jetty.webapp.WebAppContext("src/main/webapp", "/my-context"));
        server.start();

        int actualPort = server.getConnectors()[0].getLocalPort();
        getTestContext().setBaseUrl("http://localhost:" + actualPort + "/my-context");
    }
}

위의 코드를 보시면 아시겠습니다만, assertTextPresent같은 재미있는 assert 함수들을 많이 제공합니다. 이런 함수들을 통해서 웹 페이지에 대한 제약사항들을 검사할 수 있습니다. 위의 예제 코드는 http://www.brodwall.com/johannes/blog/2006/12/10/in-process-web-integration-tests-with-jetty-and-jwebunit/ 에서 가져왔습니다.


신고
Posted by 이병준

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

Extremely Agile/TDD2007.09.25 02:00
방금 웹 서핑을 하다가 HttpUnit에 대한 글을 잠깐 읽었습니다. 블로그들에는 없는 내용이 없군요 :-P

http://httpunit.sourceforge.net/

위의 URL이 httpunit의 주소입니다. jUnit과 함께 사용해야하는, Java 기반의 solution입니다.

Cookbook 문서를 읽어보면 알수 있는 것입니다만 (아래의 글은 Cookbook 문서를 보고 간단히 요약한 것입니다) HttpUnit 프레임워크는 WebConversion 클래스를 사용해서 웹 브라우저가 만드는 HTTP conversion을 흉내냅니다.

WebConversation wc = new WebConversation();
WebRequest     req = new GetMethodWebRequest( "http://www.meterware.com/testpage.html" );
WebResponse   resp = wc.getResponse( req );

WebResponse 객체를 받아온 다음에는 getText() 메소드를 불러서 텍스트 형태의 프로세싱을 할 수도 있고, getDOM() 메소드를 호출해서 DOM 기반의 조작을 할 수도 있습니다. (이 편이 좀 더 편하긴 하겠네요.)

다음 처럼 하면 링크를 따라가는 것도 가능합니다.

WebConversation wc = new WebConversation();
WebResponse resp =
            wc.getResponse( "http://www.httpunit.org/doc/cookbook.html" ); // read this page
WebLink link = resp.getLinkWith( "response" );                              // find the link
link.click();                                                                               // follow it
WebResponse jdoc = wc.getCurrentPage();                                  // retrieve the referenced page

폼 프로세싱이나 테이블 형태의 자료 처리도 가능합니다. 폼 프로세싱 예제만 살펴보면,

WebForm form = resp.getForms()[0];      // select the first form in the page
assertEquals( "La Cerentolla", form.getParameterValue( "Name" ) );
assertEquals( "Chinese",       form.getParameterValue( "Food" ) );
assertEquals( "Manayunk",      form.getParameterValue( "Location" ) );
assertEquals( "on",            form.getParameterValue( "CreditCard" ) );

위와 같이 해서 폼의 기본 파라메타 값이 어떻게 만들어져있는지 검사할 수도 있고,

form.setParameter( "Food", "Italian" );      // select one of the permitted values for food
form.removeParameter( "CreditCard" );         // clear the check box
form.submit();                                // submit the form

폼에 포함된 컨트롤들에 값을 넣어서 날릴 수도 있습니다.

웹 기반의 인터페이스 테스트를 하는 데 굉장히 유용할 것 같네요. :-) 하지만 이 프레임워크의 가장 큰 약점은...

자바스크립트 지원이 아직은 기본적인 수준이다

는 점이 되겠군요 -_-;


다들 알고 있는데 저만 모르는 사실들이 아주 많은것 같아 좀 우울합니다 ㅋㅋ



신고
Posted by 이병준

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

  1. 시월애

    잘보고갑니다. ^^
    많은도움얻고갑니다 ㅎ

    2008.03.24 13:38 신고 [ ADDR : EDIT/ DEL : REPLY ]
  2. 이것은 위대한이며 많은 사람들이이 공유를 주셔서 감사합니다 사랑 할거야 확신

    2011.11.22 04:12 신고 [ ADDR : EDIT/ DEL : REPLY ]

Extremely Agile/TDD2007.09.23 20:45

아래의 그림은 TDD를 사용한 개발을 수행하는 절차를 요약하고 있습니다.

원본은 여기에 링크 걸어둔 PDF 파일에 나옵니다. 하도 예전에 다운받아둔거라 언제 받았는지도 기억이 잘 나질 않습니다만, PDF 파일을 열어 보면 어디서 다운받은 것인지는 나오는군요. :-) 참고하시기 바랍니다.

나온지 좀 오래된 자료이기는 합니다만, TDD의 개발 절차라는 것이 TDD 개념이 등장한 이래로 그렇게 많이 바뀌거나 하지는 않았습니다. 코드 작성 전에 테스트를 먼저 한다, 는 원칙은 그대로 있고, 그 적용에 따르는 세부사항만 좀 바뀐 정도일텐데, 그 나마 큰 변화가 있는 것 같지는 않아요.


사용자 삽입 이미지



절차는 간단히 요약하면 이렇습니다.
  1. 테스트 리스트를 만듭니다. (어떤 테스트를 수행할 것인지 협의하는 과정에서 나옵니다. 사용자 스토리를 만드는 과정에서 도출되는 일이 많습니다.)
  2. 다음의 3~7까지를 계속 반복합니다.
  3. 테스트 리스트에서 테스트 하나를 고릅니다.
  4. 우선, 컴파일조차 되지 않는 테스트 코드를 작성합니다. (컴파일이 되지 않으니 테스트를 아예 할 수가 없습니다.) [위의 다이어그램에서는 위에서 두 번째 줄의 왼쪽에서 두 번째 박스]
  5. 대충 뜯어 고쳐서 컴파일은 되도록 만듭니다. 테스트는 당연히 실패할 것입니다.
  6. 그런 다음, 우선 테스트가 통과되도록 만듭니다. (테스트가 통과되도록 만드는 것이 목적이니, 코드에 hardwired 된 로직이 존재해도 아직은 상관이 없습니다. 가령, 3이라는 반환값을 요구하는 테스트라면, 함수 안에서 그냥 3을 리턴해도 이 단계에서는 상관이 없다는 말입니다.
  7. 그런 다음 리팩토링(refactoring)을 시작합니다. 리팩토링을 해나가면서 중복을 제거하고, 말이 안되는 부분을 하나씩 말이 되도록 만듭니다. 리팩토링은 '아주 작은 단위'의 코드 변경입니다. 코드가 변경될 때 마다 컴파일하고 테스트를 돌려서, 그 리펙토링이 테스트를 fail시키지 않는지 확인해야 합니다. fail시켰다면, 너무 작은 리팩토링을 시도했거나, 잘못된 리팩토링 - 즉, 논리적인 오류를 발생시킨 리팩토링을 했다는 뜻입니다. 위의 그림에서는 '리팩토링'->'테스트' 사이의 순환 관계가 명시적으로 드러나지는 않습니다만, 리팩토링과 테스트는 반복적으로 계속해서 해나갈 수 있습니다.
뭐, 그림 자체는 그런 뜻입니다. :-) 그럼 테스트는 대체 어떻게 만드느냐.

이상적으로 보자면 테스트가 '개발될 모듈이 가져야 할 이상적인 인터페이스'에 근거해서 만들어지는 게 가장 좋겠지만, 그렇게 하자면 머리속에 '개발될 모듈의 형태'가 우선 잡혀야 합니다.

TDD 개발 과정에 대한 책 ("테스트 주도 개발") 을 보신 분이면 아시겠지만 테스트는 고정적이 아니며 항상 변화합니다. 개발될 모듈의 형태가 어떠할 지 완벽하게 미리 예측하는 것이 불가능하기 때문입니다. 테스트를 하고, 중복을 제거하고 리팩토링을 하다보면 개발중인 클래스가 개발자가 예측한 것과 조금은 다른 방향으로 흘러가게 되는 일도 생길 수 있고 (물론 가급적 그렇게 되지 않는 것이 좋겠지만 말입니다) 다른 식으로 구현을 가져가는 것이 보다 효율적이겠다는 판단을 내리게 되는 일도 생길 수 있습니다.

그러니, 테스트를 '미리 잘 정돈하여 만들어두어야 겠다'는 생각은 버리는 게 좋겠습니다. '어떤 테스트를 해야 하느냐'는 고정적일 수 있지만, '테스트를 어떻게 해야 하느냐'는 것은 계속해서 달라질 수 있기 때문입니다.
신고
Posted by 이병준

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

  1. TDD를 적용하기 위한 절차를 찾다가 발견하게 되었습니다. 많은 도움 얻고 갑니다...
    즐거운 하루되세요~^^

    2008.04.22 11:38 신고 [ ADDR : EDIT/ DEL : REPLY ]
  2. TDD를 하면서 느끼는 것은 초보자 무리라는 것입니다. ^^ 제가 생각하기에는 그래요.
    일단 초보자는 상급자로부터 Requirement를 받기 때문에 상당히 제한적이고, 초반에 참여를 하더라도 경험이 적기 때문에 테스트 코드를 많이 못 만들어 내는 것 같습니다.
    또한, 초보자는 Refactoring을 해야할 때, Refactoring 자체에 대한 지식이 별로 없는 관계로 힘들고 때에 따라서 Design Pattern을 적용해야 할 때가 있는데, 그렇지 못 할 때도 있더군요. 또 Design Pattern을 몰라도 되지만, 바라던 최적화 코드는 그다지 나오지 않더라구요.

    하지만, 지속적으로 이렇게해서 개발경력을 쌓으면 무적이 될 것 같기도 하네요. 그래서 저도 TDD를 지향합니다.

    Rod Johnson의 인터뷰인가? 정확하게 기억나지는 않지만, 꼭 필요한 기술이라고 해서, 1. OFD(Oriented Framework Development), 2. DI & IOC, 3, TDD 등이 있었던 것 같네요.

    잘 읽고 갑니다.

    2008.06.11 15:05 신고 [ ADDR : EDIT/ DEL : REPLY ]
    • 반갑습니다. 초보자에게 좀 무리일 수는 있습니다만, 그 과정을 통해 배우는 것이 많다는 점에서 긍정적일수도 있을 것 같습니다. 저는 TDD를 최근에야 접했는데, 제가 통상적으로 프로그램을 짤 때 거치는 과정과 많이 닮아있어 오히려 프로그래밍을 배우기 시작하는 초기에 배웠더라면 어땠을까, 하는 생각을 자주 했습니다.

      2008.06.12 14:30 신고 [ ADDR : EDIT/ DEL ]