본문 바로가기
가치관 쌓기

계약에 의한 설계(Contract By Design) 더 잘 활용하기(with java)

by simplify-len 2021. 4. 5.

들어가기

계약에 의한 설계(Contract By Design) 라는 용어에 대해서 조영호님의 Object 책에서 처음 접하게 되었습니다. 계약에 의해 설계가 이루어지지 않는 코드에서 발생하는 문제점을 운영중인 프로덕트 코드에서 쉽게 발견할 수 있었고, 이를 계기로 사내에 '계약에 의한 설계' 라는 이름으로 세미나까지 하게 되었습니다.

 

발표자료

 

그러나, 발표를 하면서도 '실제 동작되는 코드에서는 어떻게 '계약에 의한 설계' 를 지킬 수 있을까?' 라는 생각을 했습니다. 실제로 계약에 의한 설계(Contract By Design) 를 준수하기 위해서 구글이 만든 cofoja 라는 라이브러리도 있고, vanilla4j 라는 라이브러리도 있었습니다. 하지만 이 두 개의 라이브러리를 적극적으로 활용하지 못한 이유가 있습니다. 구글에서 만든 라이브러리의 경우 주석이 범란하는 문제가 발생해 읽는 이로 하여금 코드를 더욱 복잡하게 만드는 경향이 존재했고, vanilla4j 의 경우, 잘 만들었지만 테스팅 도구인 hamcreate, asssert4j 등의 테스팅 도구에 대한 학습이 선행되어야 하는 러닝커브가 존재했습니다. 

 

그 외 타 라이브러리들도 여전히 자바가 강조하는 '단순성' 이라는 철학에 대해서 충족시켜주지 못했습니다.

 

그렇게 시간이 지나서 우연히 오라클의 Java docs 를 살피던 중 Assertion 관련 부분에 계약에 의한 설계(Contract By Design) 에 대한 내용을 읽게 되었고, 관련 내용을 공유하고자 해당 블로그를 작성합니다.

 

 

왜 Java 라는 언어 차원에서 Eiffel 언어 처럼 계약에 의한 설계를 지원해주지 않았던 걸까?

 우리는 Eiffel 언어와 유사하게, 계약에 의한 설계를 준수해줄 수 있는 도구를 언어차원에서 고려했지만, Java 플랫폼 라이브러리에 대한 대규모 변경과 이전 라이브러리와 새 라이브러리 간의 엄청난 불일치없이 Java 프로그래밍 언어에 접목 할 수 있다는 것을 스스로 확신 할 수 없었습니다. 게다가 우리는 그러한 도구가 자바 프로그래밍 언어의 특징인 단순성을 보존 할 것이라고 확신하지 못했습니다. 우리는 단순한 boolean assertion 기능이 상당히 간단한 솔루션이며, 균형있으며, 훨씬 덜 위험하다는 결론에 도달했습니다.

 boolean assertion 기능을 언어에 추가한다고해서 향후 언젠가 본격적인 계약 별 설계 기능을 추가 할 수 있다는 점에 주목할 필요가 있습니다. 이는 simple boolean assertion 기능은 제한된 형태의 계약 별 설계 스타일 프로그래밍을 가능하게합니다.

 assert 문은 비공개 전제 조건, 사후 조건 및 클래스 불변 검사에 적합합니다. 공용 전제 조건 검사는 IllegalArgumentException 및 IllegalStateException과 같이 특히 문서화 된 예외를 발생시키는 메서드 내부 검사에 의해 수행되어야합니다.

docs.oracle.com/javase/8/docs/technotes/guides/language/assert.html#compatibility

 

 그래서 자바에서는 계약에 의한 설계를 어떻게 작성하는 걸까?

 

 먼저 계약에 의한 설계에 대해서 알아보겠습니다. 계약에 의한 설계는 크게 3가지로 나눠집니다. 사전조건(preconditions), 사후조건(postconditions), 불변식(invariants) 이렇게 있습니다.

그림 1 https://www.slideshare.net/JoenggyuLenKim/design-by-contract-226703670

 

이번에는 코드로서 위 3가지 계약을 Java의 Assert를 활용하는 방법에 대해서 알아보겠습니다. 마찬가지로 Oracle 에서 제공되는 예시로 살펴볼까한다. (참고자료)

 

사전조건(Preconditions) - what must be true when a method is invoked.

  • 메서드가 호출되기 위해 만족돼야 하는 조건.
  • 메서드의 요구사항을 명시
  • 사전 조건이 만족되지 않을 경우 메서드가 실행되서는 안 된다.
  • 사전 조건을 만족시키는 것은 메서드를 실행하는 클라이언트의 의무
/**
  * Sets the refresh rate.
  *
  * @param  rate refresh rate, in frames per second.
  * @throws IllegalArgumentException if rate <= 0 or
  * rate > MAX_REFRESH_RATE.
*/
public void setRefreshRate(int rate) {
  // Enforce specified precondition in public method
  if (rate <= 0 || rate > MAX_REFRESH_RATE)
    throw new IllegalArgumentException("Illegal rate: " + rate);
    setRefreshInterval(1000/rate);

Public Method 의 경우에는 assert 의 영향을 받지 않도록 작성해야 합니다.

 

Public method 에서 받아오는 인수는 절대 assertion을 쓰지 말라고 권고하고 있습니다. 적절하지 않는 이유로 method의 인자 값이 적절한 값이 들어왔는지 검사 될 수 있도록 항상 보장되어야 하기 때문에 assert 는 부적절하다고 합니다. 더욱이 assert 는 어떤 특정한 예외를 던질 수도 없습니다. 오직 AssertionError 만 던지기 때문에 적합하지 않습니다. 단 nonpublic 하고, 우리가 판단하기에 참이라 굳게 믿는 함수에 대해서는 assert를 사용해야 합니다. 대표적인 예시로, 이전 함수에 의해 실행되어지는 "helper method" 에 적합합니다.

/**
 * Sets the refresh interval (which must correspond to a legal frame rate).
 *
 * @param  interval refresh interval in milliseconds.
*/
 private void setRefreshInterval(int interval) {
  // Confirm adherence to precondition in nonpublic method
  assert interval > 0 && interval <= 1000/MAX_REFRESH_RATE : interval;

  ... // Set the refresh interval
 } 

// MAX_REFRESH_RATE가 1000보다 크고 클라이언트가 1000보다 큰 새로 고침 빈도를 선택하면 
// 위의 assert 가 실패합니다. 이것은 실제로 라이브러리의 버그를 나타냅니다!

 

 

사후조건(Postconditions) - what must be true after a method completes successfully.

  • 메서드가 실행된 후에 클라이언트에게 보장해야 하는 조건
  • 클라이언트가 사전조건을 만족시켰다면 메서드는 사후조건을 명시된 조건을 만족시켜야 한다.
  • 만약, 사후조건에 명시된 조건을 충족하지 못한 경우 예외를 던진다.
  • 사후 조건을 만족시키는 것은 서버의 의무다.
 /**
  * Returns a BigInteger whose value is (this-1 mod m).
  *
  * @param  m the modulus.
  * @return this-1 mod m.
  * @throws ArithmeticException  m <= 0, or this BigInteger
  *has no multiplicative inverse mod m (that is, this BigInteger
  *is not relatively prime to m).
  */
public BigInteger modInverse(BigInteger m) {
  if (m.signum <= 0)
    throw new ArithmeticException("Modulus not positive: " + m);
  ... // Do the computation
  assert this.multiply(result).mod(m).equals(ONE) : this;
  return result;
}

 사후 조건을 충족하는 메서드를 적용하는 메서드는 Public 또는 Nonpublic 메서드 모두 사용할 수 있다. 위 코드와 같이 마지막에 나온 결과값을 확인하는 용도로 사용됩니다.

 

 가끔씩, 사후 조건을 체크하기 위해 필요한 비지니스 로직이 필요할 수 있으며, 또한 일부 데이터를 저장해야 될 수 있습니다. 두 개의 Assert 문과 하나 이상의 변수 상태를 저장하는 간단한 내부 클래스로 이를 수행 할 수 있으므로 계산 후 (또는 재확인) 할 수 있습니다. 예를 들어 다음과 같은 코드가 있다고 가정합니다.

 void foo(int[] array) {
  // 어떤 비지니스 로직 수행 후...
  ...

  // 비지니스 로직 수행 후, 이 시점에서 나온 Array가 처음 인수로 받았던 값의 순서가 정확하게 일치해야 한다면?
 }

이런 사후 조건을 추가해야 한다고 할 때 아래와 같은 코드를 만들어야 할 수도 있습니다. 이는 단순한 assert 가 어떻게 기능적인 assert 로 동작되는 것을 이해할 수 있습니다.

 void foo(final int[] array) {

  // Inner class that saves state and performs final consistency check
  class DataCopy {
	private int[] arrayCopy;

	DataCopy() { arrayCopy = (int[]) array.clone(); }

	boolean isConsistent() { return Arrays.equals(array, arrayCopy); }
  }

  DataCopy copy = null;

  // Always succeeds; has side effect of saving a copy of array
  assert ((copy = new DataCopy()) != null);

  ... // Manipulate array

  // Ensure array has same ints in same order as before manipulation.
  assert copy.isConsistent();
  } 

 

위 코드를 살펴보면, 두 개의 assert 문으로 배열의 순서를 보장하고 있습니다. 위와 같은 코드 패턴을 활용하면 더 많은 데이터를 저장하고, precondition과 postcondition 과 과련된 임의의 복잡한 assertions 도 테스트할 수 있게 됩니다.

 

 첫번째 assert문의 assert ((copy = new DataCopy()) != null); 을 보고  copy = new DataCopy() 이렇게 바꾸고 싶을지도 모르지만, 이는 assert 의 동작여부와 상관없이 동작되기 때문에 절대 바꾸면 안됩니다.

 

 

클래스 불변식(Class invariants) - what must be true about each instance of a class.

  • 항상 참이라고 보장되는 서버의 조건
  • 메서드가 실행되는 도중에는 불변식을 만족시키지 못할 수도 있지만, 메서드를 실행하기 전이나 종료된 후에 불변식은 항상 참이여야 한다.

 클래스 불변식(class invariants)은 클래스 인스턴스가 일관된 상태에서 다른 상태로 전환되는 경우를 제외하고, 항상 클래스의 모든 인스턴스에 적용되는 적용되는 internal invariant 의 타입 중 하나입니다.  클래스 불변식(class invariants)은 여러 속성 간의 관계를 지정할 수 있으며, 모든 메서드가 비지니스 로직 수행 후 완료되기 전과 후가 true 여야 합니다.

Assert 메커니즘은 불변을 확인하기 위해 특정 스타일을 적용하지 않습니다.(이것이 java의 assert 문이 가진 강점이지 않을까?) 그러나 필요한 제약 조건을 확인하는 식을 Assertion으로 호출 할 수 있는 단일 내부 메서드로 결합하는 편리한 방법도 있습니다. 바로 아래와 같은 코드를 말이죠!

 // Returns true if this tree is properly balanced
 private boolean balanced() {
  ...
 }
 
 assert balanced(); 

--- 일부 내용 추가 2021년 4월 23일 ---

결론

 실질적으로 계약에 의한 조건은 assert 문을 활용해 가능합니다! 하지만, 사용하는데 있어서 장애물이 여전히 존재합니다. 일단 assert 라는 것 자체가 Java에서는 diabled 상태가 Default 입니다. 즉, 아무리 assert 를 하더라도, 실행할 때  java -ea ... 를 설명해주지 않으면 무용지물.

 

또 하나, AssertError입니다. AssertException이 아닙니다. 그러므로, 개발자가 에러를 잡을 수 있는게 아니다보니, 발생하는 문제점도 있을 것 입니다. valid4J 와 같은 라이브러리를 사용하는 것과, Assert 차이점에 대해서 조금더 심도깊은 고찰이 필요하다고 느껴집니다.

 

 글에서 말하는 것으로 느끼기에는, assert 라는 것 자체가 굉장히 pure하고 자바의 어떤 요소와도 의존성을 갖지 않는 것만으로도 굉장히 매력을 느껴집니다. 조금 더 적극적인 계약에 의한 설계를 해볼 수는 있을 것이라는 생각은 들지만, 해쳐나가야 할 장애물이 많은 듯합니다.

 

더 알아보기

www.eiffel.com/values/design-by-contract/

docs.oracle.com/javase/8/docs/technotes/guides/language/assert.html#compatibility

www.infoworld.com/article/2074956/icontract-design-by-contract-in-java.html

john.cs.olemiss.edu/~hcc/softArch/notes/iContract/OW_Berlin99_web.pdf

 

 

'가치관 쌓기' 카테고리의 다른 글

Getter와 Setter는 왜 써야 할까? 꼭 써야될까?  (0) 2021.02.27

댓글