본문 바로가기
✅ 테스트

Design by Contract(계약에 의한 설계)와 테스트 코드 그리고 예제📝

by kukim 2022. 2. 7.

* 이 글은 책 소프트웨어의 품격 5장과 하단 reference를 참고했습니다. 잘못된 내용이 있다면 편하게 말씀해주세요 🙏🏻

 

Design by Contract(계약에 의한 설계, DbC)란 무엇인가?

사전 조건, 사후 조건, 페널티, 불변 조건

세 가지 계약 검사

계약 검사 예제(BoundedSet)

DbC와 Test Code


Design by Contract(계약에 의한 설계)란 무엇인가?

Design by Contract(이하 DbC) 개념은 1980년대 버트란드 마이어가 처음 제시했다. 아이디어는 일상에서의 계약 개념과 동일하다. 예를 들어 통신사-고객 사이의 통신 계약을 맺었다. 통신사는 고객에게 정상적인 시스템을 제공해야 하고 고객은 요금을 내는 의무를 가진다.

이를 소프트웨어 시스템 중 메서드에 적용할 수 있다. 메서드를 Supplier-Client 사이의 Contract(계약) 관계로 본다면 Supplier(특정 메서드 구현하여 제공하는 사람)은 Client(메서드 사용하는 사람)이 메서드 호출(사용) 시 모든 입력 조건에 맞게 결과를 보장해야 하는 것이다. 그렇다. 버트란드 마이어가 제시한 DbC는 메서드마다 계약을 부과한다. 계약은 크게 사전 조건(precondition), 사후 조건(postcondition), 사후 조건 안의 페널티(penalty), 불변 조건(Invariant condition)이 있다. 

 

사전 조건(precondition)

사전 조건은 Client, 메서드 사용자(호출자)가 지키는 조건이다. 예를 들어 제곱근을 구하는 메서드가 있을 때 그 인자 값으로 음이 아닌 수를 넣어야 한다. 전적으로 호출자의 책임이지만 구현하는 사람(Supplier)은 구현하는 메서드 안에 사전 조건을 방어하는 코드를 작성해야 한다.

 

사후 조건(postcondition)

사후 조건은 해당 메서드의 실행 결과나 리턴 값, 메서드와 관련된 객체의 상태 변화를 기술한 조건이다. 예를 들어 정수를 무조건 양수로 변환하는 메서드가 있다고 하자. Client은 -2를 인자 값으로 넘겼다. 인자 값으로 받은 -2 값을 Supplier가 구현한 로직에 맞게 결과가 나왔다. 이 결과 값이 정말 양수인지 체크해야 한다. 이를 사후 조건이라고 한다.

 

사후 조건 안의 페널티(Penalty)

Client가 잘못된 사전 조건을 입력했다. Supplier는 이에 대해 방어적으로 코드를 작성해야 한다고 했다. 이를 페널티라고 한다. 다른 말로 사용자 입력에 따른 예외처리라고 볼 수 있다.

 

예) 결제하는 pay(int amount) 메서드가 있다고 했을 때

페널티 1 : int amount는 음수일 수 없다. 음수일 때 예외를 발생시킨다.

 

불변 조건(Invariant condition)

클래스 필드에 대한 항상 참인 조건이다. 예를 들어 Person이란 클래스에 int amout라는 멤버 변수가 있다. amout는 항상 양수여야 한다. 불변 조건은 메서드 실행 전후에 항상 성립해야 한다. 따라서 어떤 함수를 실행해도 amount 멤버 변수는 양수여야 한다. 이런 조건들을 불변 조건이라고 한다. 

 

예) Person 클래스

불변 조건 1 : 멤버 변수 int amount는 항상 양수이다. 

불변 조건 2 : 멤버 변수 int age는 항상 양수이다.

 

계약 검사

DbC에서 계약을 했다면 구현할 메서드에서 이를 검사해야 한다. 그 방법으로는 세 가지로 볼 수 있다.

1. 구현할 메서드 안에 if 조건문을 두어 검사한다.

    단, if 조건문 검사는 항상 실행된다. 보통 사용자 입력에 대한 사전 조건을 검사할 때 사용된다. 

    public 메서드의 사전 조건은 항상 실행되야한다. 따라서 if 조건문 검사를 통해 매번 검사한다.

2. 구현할 메서드 안에 JDK1.4부터 지원하는 assert 키워드를 사용한다.

    assert 키워드는 디버깅 용도로 사용되기 때문에 기본으로 꺼져있다. 이를 사용하려면 컴파일에 -ea 옵션을 추가해야 한다.

    보통 개발 코드에 활성화, 배포 코드에 비활성화로 사용한다.

    이는 if 조건문과 다르게 assert 검사는 끄고 켤 수 있다. 

3. 구현할 메서드 밖에서 테스트 프레임워크(JUnit) 등을 사용할 수 있다. 

    실전에서 assert 키워드보다 테스트 프레임워크를 더 많이 사용한다. 왜냐하면 불변, 사후 조건 검사를 클래스 밖으로 옮길 수 있다. 운영 코드의 복잡도를 낮출 수 있다.

 

사진 출처 : https://github.com/Quokka-Squad/Seriously-Good-Software (Minzino)

 

BoundedSet 예제

BoundedSet 클래스를 계약에 맞게 구현하고 각 조건을 처리해보자.

BoundedSet <T>는 최대 크기가 정해진 집합으로 요소의 삽입 순서를 기억한다. 두 가지 메서드가 있다.

  • void add(T elem) : 주어진 요소를 크기가 정해진 집합에 추가한다. add()로 인해 요소의 개수가 최대 크기를 초과하면 가장 오래된 요소를 제거한다. 집합에 이미 요소가 존재한다면 해당 요소를 갱신한다(가장 최신 요소로 업데이트)
  • boolean contains(T elem) : 크기가 정해진 집합에 주어진 요소가 존재하면 true를 리턴한다.

계약(Contract)에 맞게 구현

DbC에 따라 사전, 사후 조건과 페널티를 정의해볼 수 있다.

void add(T elem)

사전 조건 elem, 인자는 null이 아니여야 한다.
사후 조건 elem을 크기가 정해진 집합에 추가한다.
추가로 인해 집합 최대 크기를 초과하면 가장 오래된 요소를 제거한다.
집합에 이미 존재하는 요소가 추가되면 업데이트 한다
페널티 null 인자가 들어오면 NullPointerException을 던진다.

boolean contains(T elem)

사전 조건 elem, 인자는 null이 아니여야 한다.
사후 조건 elem이 집합에 존재하면 true를 리턴한다.
이 메서드는 집합을 변경하지 않는다.
페널티 null 인자가 들어오면 NullPointerException을 던진다.

1. if 조건 사전검사

두 메서드의 요구사항을 보고 간단히 구현할 수 있다. capacity를 받아 BoundedSetBasic를 생성한다. add()와 contains() 기능에 맞게 동작한다.

public class BoundedSetBasic<T> {
    private final LinkedList<T> data;
    private final int capacity;

    public BoundedSetBasic(int capacity) {
        this.data = new LinkedList<>();
        this.capacity = capacity;
    }


    public void add(T elem) {
        if (elem == null) // 사전 조건 검사, if 조건문으로 항상 검사
            throw new NullPointerException();

        data.remove(elem);
        if (data.size() == capacity) {
            data.removeFirst();
        }
        data.addLast(elem);
    }

    public boolean contains(T elem) {
        return data.contains(elem);
    }

    public static void main(String[] args) {
        BoundedSetBasic<Integer> set = new BoundedSetBasic<>(3);
        set.add(1);
        System.out.println(set.contains(1)); // true
    }
}

2. assert를 사용한 사후 조건 검사

postAdd() 메서드를 추가하여 사후 조건 검사를 한다. 검사를 할 수 있다는 장점이 있지만 운영 코드 내부에 assert가 함께 들어가 복잡도가 증가할 수 있다.

public class BoundedSetContract<T> {
    private final LinkedList<T> data;
    private final int capacity;

    public BoundedSetContract(int capacity) {
        data = new LinkedList<>();
        this.capacity = capacity;
    }

    public BoundedSetContract(BoundedSetContract<T> other) {
        data = new LinkedList<>(other.data);
        capacity = other.capacity;
    }

    public void add(T elem) {
        if (elem==null) // 사전 조건, 페널티 예외 처리
            throw new NullPointerException();

        data.remove(elem);
        if (data.size() == capacity) {
            data.removeFirst();
        }
        data.addLast(elem);

        assert postAdd(copy, elem) : "add failed its post-condition!"; // 사후 조건 검사
    }

    private boolean postAdd(BoundedSetContract<T> oldSet, T newElement) {
        if (!data.getLast().equals(newElement)) // 새로운 값은 제일 앞에 있어야 함
            return false;
        List<T> copyOfCurrent = new ArrayList<>(data); // 새로운 리스트와 이전 리스트에서 newElements 제거
        copyOfCurrent.remove(newElement);
        oldSet.data.remove(newElement);
        if (oldSet.data.size()==capacity) { // 용량 가득 찬 경우 가장 오래된 요소 제거
            oldSet.data.removeFirst();
        }
        return oldSet.data.equals(copyOfCurrent); // 나머지 객체는 모두 같아야 하고 순서도 같아야 함
    }

    public boolean contains(T elem) {
        return data.contains(elem);
    }
}

3. JUnit을 활용한 사후 조건 검사

기본 구현 코드에 JUnit을 사용하여 테스트했다. assert 키워드를 사용한 것과 다르게 운영코드에 복잡한 부분 (postAdd)가 없다. 단위 테스트를 따로 가지고 있어서 코드 변경, 리팩터링에도 장점을 가진다.

public class BoundedSetTests {

    // Test fixture
    private BoundedSetBasic<Integer> set;

    @Before
    public void setUp() {
        set = new BoundedSetBasic<>(3);
    }


    @Test
    public void testSingleElement() {
        set.add(1);
        assertTrue(set.contains(1));
    }

    @Test
    public void testRepeatedElement() {
        set.add(1);
        set.add(1);
        set.add(1);
        set.add(1);
        assertTrue(set.contains(1));
    }

    @Test
    public void testOverflowKeepsSecond() {
        set.add(1);
        set.add(2);
        set.add(3);
        set.add(4);
        assertTrue(set.contains(2));
    }

    @Test
    public void testOverflowRemovesOldest() {
        set.add(1);
        set.add(2);
        set.add(3);
        set.add(4);
        assertFalse(set.contains(1));
    }

    @Test
    public void testOverflowKeepsNewest() {
        set.add(1);
        set.add(2);
        set.add(3);
        set.add(4);
        assertTrue(set.contains(4));
    }

    @Test
    public void testRenewal() {
        set.add(1);
        set.add(2);
        set.add(1);
        set.add(3);
        set.add(4);
        assertTrue(set.contains(1));
    }
}

정리

1. 사전 조건 검사, 적절할 페널티 부과하는 것은 방어적 프로그래밍의 기본이다. 위 예제에서는 if-기반 확인으로 메서드 안에서 검사했다.

2. assert 키워드를 사용한 사후 조건 검사 메서드 안에서 검사한다. 이는 클래스 자체의 결함을 찾아내는 것이 목적이다. 테스트와 그 목적은 동일하다.

3. 단위 테스트는 assert 키워드보다 더 많이 사용한다. 메서드 밖에서 검사한다. 메서드를 줄일 수 있고 운영코드, 테스트 코드 나눠서 개발이 가능하다. 또한 테스트 코드를 작성하며 예상치 못한 버그 발견이나 설계에도 도움을 줄 수 있다

사진 출처 : https://github.com/Quokka-Squad/Seriously-Good-Software (Minzino)

 

DbC와 Test Code

소프트웨어의 신뢰성은 명확한 명세에서 시작된다. DbC의 모든 메서드에 대해 완벽한 명세, 계약은 개발에 큰 도움을 준다.

하지만 완벽하게 할 수 있을까? 의문이 든다. 

테스트 코드는 DbC와 배타적인 관계가 아니다. 오히려 DbC의 자세한 명세는 테스트 코드 작성에 도움을 준다. 

 링크에서는 이렇게 말한다. 1. DbC의 계약, 명세로 시작하고 2. 테스트를 작성하고 3. 구현한다. 

 

두 조합은 개발에 도움을 주는 것은 분명하다. 

 


⛓ Reference

https://en.wikipedia.org/wiki/Design_by_contract

https://wolandscat.net/2021/03/04/design-by-contract-dbc-v-test-driven-design-tdd/

https://stackoverflow.com/questions/394591/design-by-contract-and-test-driven-development

https://kevinx64.net/198

댓글