054. (Getting Started with Test-Driven Development) 4. 기능 / 명세 / 설계

4. 기능 / 명세 / 설계

4.1. 기능 명세

개발자는 코드를 작성하고 이를 빌드하여 사용자가 사용할 수 있게 배포한다.

그리고 사용자는 배포된 소프트웨어를 이용해서 원하는 기능을 실행한다.

결국 개발자가 코드를 작성하는 이유는 사용가가 사요할 어떤 기능을 제공하기 위함이다.

이 기능에 대한 명세를 다양한 형태로 존재하며, 어떤 형태가 되든 간에 사용자에게 제공할 기능을 구현하려면

기능을 크게 두 가지로 나누어 생각해볼 수 있다.

바로 입력과 결과다.

예를 들어 로그인 기능은 아래와 같이 나누어볼 수 있다.

  • 입력 : 아이디와 비밀번호
  • 결과 : 아이디와 비밀번호가 일치하면 성공, 일치하지않으면 실패

입력은 기능을 실행하는 데 필요한 값이며, 로그인 기능을 실행하는 데 필요한 건은 아이디와 비밀번호이므로 이 두 가지가 로그인 기능의 입력이 된다.

로그인 기능의 결과는 성공 또는 실패이다.

앞선 포스팅에서 다루었던 만료일 계산 기능의 결과는 다음과 같다.

  • 입력 : 첫 납부일, 납부일, 납부액
  • 결과 : 만료일

입력은 보통 메서드의 파라미터로 전달된다.

만료일 계산 기능도 아래와 같이 필요한 값을 파라미터로 전달받았다.

1
2
3
// payData : 만료일 계산 기능의 입력
// 반환값 : 만료일 계산 기능의 결과
val expiryDate = calculator.calculateExpiryDate(payData)

결과는 여러 형식으로 정의할 수 있다.

가장 쉽게 생각할 수 있는 결과 형식은 반환값이다.

또는 예외 자체를 결과로 사용할 수 도 있다.

예를 들어 로그인 기능을 구현할 때 인증에 실패하면 아래와 같이 예외를 발생시키는 것이다.

1
2
3
4
5
6
7
fun login(id: String, password: String) {
val user = getUser(id)
if (user.matchPassword(password.not()) {
// 예외를 결과로 사용
throw IdPwNotMatchException()
}
}

기능 실행 결과에는 변경도 포함된다.

예를 들어 회원 가입 기능은 실행의 결과로 데이터베이스에 회원 정보를 추가해야하고,

데이터베이스에 데이터를 추가하는 것은 값을 반환하는 것과는 달리 시스템의 상태를 변경하는 행위이다.

이러한 변경은 반환값으로는 알 수 없기때문에, 실행 후 다시 변경 대상에 접근해서 결과를 확인해야한다.

이때 회원 가입 기능은 상황에 따라 다른 결과를 가지게 되는데,

동일한 아이디가 존재하는 상황에서는 예외를 돌려주어야하고, 동일한 아이디가 존재하지않으면 가입 성공 처리 후 결과로 회원 번호 등을 반환하고 데이터베이스를 갱신해야 한다.

이처럼 설계는 기능의 명세로부터 시작한다.

스토리보드를 포함한 다양한 형태의 요구사항 문서를 이용해서 기능 명세를 구체화하고,

기능 명세를 구체화하는 동안 입력과 결과를 도출하고 이렇게 도출한 기능 명세를 코드에 반영한다.

기능 명세의 입력과 결과를 코드에 반영하는 과정에서 기능의 이름, 파라미터, 리턴 타입 등이 결정된다.

이는 최종적으로 기능에 대한 설계 과정과 연결된다.

4.2. 설계 과정을 지원하는 TDD

거듭 설명했듯이, TDD는 테스트를 만드는 것부터 시작한다.

테스트 코드를 먼저 만들고 테스트를 통과시키기 위해 코드를 구현하고 리팩토링하는 과정을 반복한다.

여기서 중요한 것은 테스트 코드를 가장 먼저 작성해야하다는 점이다.

테스트 코드를 가장 먼저 작성하기 위해서는 아래 두 가지가 필요하다.

  1. 테스트할 기능을 실행
  2. 실행 결과를 검증

기능을 실행할 수 없으면 테스트를 할 수 없고, 테스트는 기능을 실행한 결과를 검증하는 것이므로 테스트 코드에서 기능을 실행할 수 있어야 한다.

즉, 테스트에서 실행할 수 있는 객체나 함수가 존재해야 한다.

실행할 객체가 존재하려면 객체를 생성할 때 사용할 클래스가 필요하고 실행할 메서드도 필요하다.

따라서 테스트 대상이 되는 클래스와 메서드의 이름을 결정해야 한다.

또한 메서드를 실행할 때 사용할 인자의 타입과 개수를 결정해야 한다.

테스트를 만들기 위한 준비 과정을 그림으로 나타내면 아래와 같다.

flowchart LR
    A[테스트를 만들려면?]-->B[테스트할 기능 실행]
    A-->C[결과를 검증]
    B-->D[클래스, 메서드, 함수 이름]
    B-->E[파라미터]
    C-->F[리턴 값]

좀 더 코드 친화적으로 생각해보자.

테스트 코드를 작성하려면 아래의 네 가지 항목이 필요하다.

  1. 클래스 이름
  2. 메서드 이름
  3. 메서드 파라미터
  4. 실행 결과

이 네 가지 항목들을 결정하는 과정에서 이름을 곰니하고 파라티터 타입과 리턴 타입을 고민한다.

고민하는 행위 자체가 곧 설계하는 과정이다.

타입의 이름을 정의하고, 타입이 제공할 기능을 결정하는 것은 기본적인 설계 행위이며,

이 과정에서 타입이 제공할 기능을 실행하는 데 필요한 값과 결과가 무엇인지 고민한다.

결과적으로 TDD 자체는 설계가 아니지만, TDD를 하다보면 테스트 코드를 작성하는 과정에서 일부 설계를 진행하게 된다.

4.2.1. 필요한 만큼 설계하기

TDD는 테스트를 통과할 만큼만 코드를 작성한다.

필요할 것으로 예측해서 미리 코드를 만들지않는다.

이는 설계에도 동일하게 적용되어, 필요할 것으로 예측된다고 해서 미리 설계를 유연하게 만들지않는다.

실제 테스트 사례를 추가하고 통과시키는 과정에서 피룡한 만큼 설계를 변경하는 것이다.

TDD로 개발을 진행하면 현시점에서 테스트를 통과시키는 데 필요한 만큼의 코드만 만들게 된다.

물론 모든 코드에 대해 테스트를 먼저 작성하는 것은 힘든 일이지만 TDD로 개발하는 코드의 비율이 높아질수록

현재 시점에서 필요한 설계만 코드에 반영될 가능성이 커지게 된다.

유연한 설계는 설계에 유연함이 필요한 시점에 추가하여, 불필요하게 설계가 복잡해지는 것을 방지할 수 있다.

물론 필요할 만큼만 설계를 한다고해서 사전에 설계 활동을 생략하는 것은 아니다.

요구사항을 분석하는 과정에서 당연히 설계는 진행되는 것이다.

다만 이 시기의 설계는 초안에 불과할 뿐이며, 이 초안대로 끝까지 개발된다는 보장도 없다.

특히나, 요구사항은 시간이 지나면 변경되기 마련이므로 초기의 설계도 덩달아 변경되야한다.

이때 TDD를 활용하면 불필요한 구성 요소를 덜 만들게 된다.

4.3. 기능 명세 구체화

테스트 코드를 작성하기 위해 개발자는 기능 명세를 정리해야 한다.

통상 기획자가 전달해주는 요구사항 명세는 개발자가 기능을 구현하기에는 생략된 내용이 많다.

개발자는 먼저 요구사항에서 기능의 입력과 결과를 도출해야 하며, 다양한 테스트 사례를 추가하는 과정에서 구현하기 애매한 점을 발견하게 된다.

테스트 코드를 작성하려면 입력과 결과가 명확해야하므로 애매한 점에 대해서는 기획자나 실무 담당자와 이야기해서

상황에 따라 기능이 어떻게 동작해야 하는지 구체적으로 정리해야 한다.

이때 개발자는 예시를 통해 기능 명세를 구체화하게 된다.

만료일 계산 기능의 예시를 다시 떠올려보자.

요구 사항은 아래와 같았다.

  • 서비스를 사용하려면 매달 1만원을 선불로 납부한다. 납부일을 기준으로 한 달 뒤가 서비스 만료일이다.
  • 2개월 이상 요금을 납부할 수 있다.
  • 10만원을 납부하면 서비스를 1년 동안 제공받을 수 있다.

이때 테스트를 작성하려면 한 달 뒤라는 게 정확히 언제인지 특정 해야한다.

무작정 30일만 더한다고 한 달 뒤가 되는 것이 아니므로 담당자들과의 의논을 통해 아래와 같이 다양한 테스트 코드를 작성할 수 있게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
@DisplayName("납부일과 만료일의 일자가 같지 않음")
fun billingDateAndExpiryDateNotSameDay() {
assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 1, 31),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 2, 28)
)

assertExpiryDate(
PayData(
billingDate = LocalDate.of(2019, 5, 31),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 6, 30)
)

assertExpiryDate(
PayData(
billingDate = LocalDate.of(2020, 1, 31),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2020, 2, 29)
)
}

이처럼 모호한 상황을 만나면 이를 구체적인 예시로 바꾸어 테스트 코드에 반영해야 한다.

즉 테스트 코드는 예를 이용한 구체적인 명세가 되며, 구체적인 예를 개발자가 요구사항을 더 잘 이해할 수 있게 만든다.

또한 테스트 코드는 바로 실행할 수 있다는 장점이 있으므로,

테스트 코드를 통해 예시를 바로 실행해볼 수 있다.

이는 유지보수에 큰 도움이되며, 특정 상황에서 코드가 어떻게 동작하는 지 이해하고 싶은지도 검증할 수 있게 된다.