예제로 살펴 보는 Design By Contract

Posted 2006. 10. 13. 15:48
1980년대 중반에 Bertland MeyerDesign By Contract의 개념을 소개한지 벌써 20년의 세월이 흘렀고, 그 효용성은 이미 어느 정도 검증이 되었음에도 불구하고 관련 도구와 프로그래밍 언어의 지원 부족으로 아직도 널리 보급되지는 못하고 있다.  이 글에서는 개념 중심의 설명을 탈피하여, 예제를 중심으로, Design By Contract가 무엇인지 살펴보자.

지금부터는 우리는 자바를 사용해 은행 업무를 지원하는 소프트웨어를 만들 예정이다. 프로젝트에 착수하여 초기 요구 사항 분석과 디자인을 끝내고, 구현 단계에서 다음과 같이 은행 계좌를 나타내는 인터페이스를 만들었다. (실제 구현은 훨씬 복잡하겠지만 편의를 위해 간단하게 구현하였다.)


interface BankAccount {
    double getBalance();

    double deposit(double amount);

    double withdraw(double amount);
}

간단한 인터페이스인 만큼, 언뜻 보기에는 명료해 보이지만 실제로 BackAccount를 사용하는 클라이언트 입장에서는 여러 가지 의문점이 생긴다. 클라이언트 작성자의 가정이 항상 인터페이스 설계자의 의도와 맞아 떨어지지 않기 때문이다. 예를 들어 현재 잔고보다 많은 돈을 인출하면 어떻게 될까? 인터페이스 사용자는 마이너스 통장을 생각하고 대출로 처리될 것이라고 가정했는데, 인터페이스 설계자는 이런 인출이 불가능하다고 생각했다면 큰 문제가 생길 것이다.

또 다른 예로 withdraw() 메쏘드에 마이너스 값의 amount가 들어오면 어떻게 처리해야 할까? 인터페이스 설계자는 그런 요청은 일어나지 않는다고 가정하고 코드를 만들었는데, 클라이언트는 이 경우 에러 코드를 리턴해 줄 것이라고 가정하고 amount에 대한 확인 없이 메쏘드를 호출한다면 예상치 못한 문제가 생길 것이다. 이런 경우 클라이언트가 인자(argument) 검사를 안 하고 넘긴 것인지 잘못인지, 인터페이스 설계자가 에러 처리를 안 한 것이 문제인지도 분명히 알 수 없다. 즉 버그 발생 시 누구의 책임(caller 혹은 callee)인지가 불분명해진다.

사실 이 모든 문제에 대답은 이미 요구사항을 분석하고 세부 디자인을 하는 과정에서 논의가 되었을 것이다. 그렇지 않다면 디자인 단계에서 이미 실수가 있는 것이다. 하지만 자바 같은 일반적인 프로그래밍 언어에서 코드를 만드는 과정에는 인터페이스에 이러한 구체적인 제약 조건을 기술할 방법이 없다. 예를 들어 withdraw()와 deposit() 메쏘드에는 반드시 양수의 amount를 넘겨야 한다고 설계하였더라도, 자바에는 unsigned 타입이 없기 때문에 인터페이스만 보고서는 그 사실을 파악할 수 없다. 잔고가 0 이하로 내려갈 수 있느냐는 문제 또한 인터페이스 차원에서는 표현하기가 어렵다.

이 문제를 해결하는 전통적인 방법은 무엇일까? 인터페이스에 관련 사항들을 주석으로 달아놓는 것이다. 주석을 달면 다음과 같이 된다.


/**
* This class represents a customer's bank account.
* The balance cannot fall below zero.
* Any withdraw which exceeds the balance must be rejected.
*/

interface BankAccount {
    /**
     * Returns the account balance. The return value is always positive.
     */

    double getBalance();

    /**
     * Deposit the amount of money into the account. Amount cannot be negative.
     */

    double deposit(double amount);

    /**
     *  Withdraw the amount of money from the account. Amount cannot be negative.
     */

    double withdraw(double amount);
}
예제는 [1]에서 따왔습니다.

위 코드에서는 명세서(specification)에 나와 있는 제약 조건을 주석의 형식으로 코드에 삽입해서 인터페이스 이용자가 관련된 내용을 쉽게 알 수 있게 하였다. BankAccount의 deposit() 메쏘드를 사용하는 클라이언트는 deposit() 메쏘드의 주석을 읽고, amount 인자에 음수를 넘기면 안 된다는 사실을 알 수 있다. 또한 getBalance()의 주석을 보면 이 메쏘드는 항상 양수를 리턴함을 알 수 있다.

하지만 이렇게 코드에 부가 설명을 달아놓는 것만으로는 모든 문제가 해결되지 않는다. 일부 게으른 개발자들은 문서를 읽지 않을 수도 있고, 문서를 읽었더라도 코딩 과정 중에 실수로 deposit()에 음수의 amount를 넘길 수도 있을 것이다. 이렇게 인터페이스를 사용하는 통합 과정에서 발생하는 문제는 단위 테스트(unit testing) 만으로는 쉽게 발견하기가 어렵다.

이와 같은 요구 사항을 코드에 좀 더 명시적으로 표현하려면 어떻게 해야 할까? 다음 예제를 보자.


@Contract
@Invar("$this.balance > = 0.0")
interface BankAccount {

  @Post("$return >= 0.0")
  double getBalance();

  @Pre("amount >= 0.0")
  @Post("$this.balance == $old($this.balance)+amount
         && $return == $this.balance"
)
  double deposit(double amount);

  @Pre("amount >= 0.0 &&
        $this.balance -- amount >= 0.0"
)
  @Post("$this.balance == $old($this.balance)-amount
         && $return == $this.balance"
)
  double withdraw(double amount);
  ...
}

위 코드는 Contract4J라는 도구를 이용해 BankAccount 인터페이스의 요구 사항을 자바 어노테이션을 통해 표시해준 것이다. 여기서 @Pre는 메쏘드 수행 전에 클라이언트가 만족시켜주어야 하는 조건을 의미하고, @Post는 @Pre 조건이 만족되었을 때 해당 메쏘드가 보장해주는 조건을 말한다.

예를 들어 withdraw() 메쏘드의 경우 @Pre에서 amount >= 0.0 && $this.balance - amount >= 0.0 이라고 정의해 주었으므로, 클라이언트는 반드시 0보다 크거나 같으면서 현재 잔고보다 작은 amount를 인자로 넘겨야 한다. 대신 클라이언트가 이런 조건만 만족시켜주면 withdraw() 메쏘드는 @Post에 기술한 내용처럼 잔고를 (현재 잔고 - 인출한 액수)로 조정하고, 남은 잔고를 리턴해 주는 것을 보장한다.

이러한 어노테이션이 기존의 주석과 다른 점은 자연어가 아닌 프로그래밍 언어(혹은 논리 언어)에 가까운 표현식을 사용하면서 해당 요구사항(제약 사항)을 정형화하였다는 점이다. 이렇게 Pre-condition, Post-condition를 정형화하면 해당 조건들을 프로그램 수행시에 자동으로 검사하도록 만들 수 있다.

다시 withdraw()의 예를 들면, Contract4J는 @Pre와 @Post 조건을 읽어 들여 런타임에 해당 조건이 확실히 만족됨을 확인할 수 있도록 withdraw() 실행 앞 뒤로 Pre-condition과 Post-condition이 만족되는 확인하는 코드를 생성해 준다(AspectJ 사용). 만약 조건이 만족하지 않으면 해당 위치에서 즉각적으로 프로그램의 수행을 멈춘다. 따라서 일부 클라이언트가 실수로 잘못된 인자를 넘겼거나, withdraw()의 구현이 잘못되었을 경우 별도의 테스트 없이 코드를 실행해 보는 것만으로 이를 바로 알 수 있다.

같은 방식으로 @Invar로 표현되는 Invariant가 있는데, 이는 클래스가 항상 만족시켜야 하는 조건을 의미한다. @Invar ( $this.balance >= 0.0 )은 어떤 메쏘드를 실행하더라도 잔고가 절대로 0 밑으로 떨어지지 않음을 보장해 주는 역할을 한다. withdraw()나 deposit() 메쏘드를 실행한 전후로 잔고가 0 보다 낮아지면 프로그램 수행을 멈추게 된다. (하지만 메쏘드 수행 중에는 일시적으로 잔고가 0 이하가 될 수는 있다.)

정리하면 DbC의 효과는 프로그램을 보다 명료하게 만들어주는 데 있다. 왜냐하면 요구사항과 디자인 과정에서 나온 여러 가지 제약 사항(constraint)를 코드에 녹여 넣고, 자동으로 검사할 수 있기 때문이다. 또한 개발 과정에서 이러한 자동 검사를 이용한 후에, 안정성이 확보되었다고 생각하면 이후에는 이런 검사 코드를 생략할 수도 있다. 대신에 @Pre, @Post, @Invar 등의 정보를 파싱하여 사용자에게 유용한 문서를 자동으로 생성하는 데 사용될 수도 있다.

참고 문서
[1] AOP@Work: Component design with Contract4J, Improve your software with Design by Contract and AspectJ
http://www-128.ibm.com/developerworks/java/library/j-aopwork17.html