끊임없이 검증하라

나에게 당연할지라도

Project

P2_페이지 내 하이퍼 링크 달아주는 코드_3_요구사항 분석과 TDD

fadet 2022. 6. 17. 01:08

이 포스트는 학습 과정에서 그 내용을 기록한 글이기에 부정확한 정보가 포함될 수 있습니다.

따라서 해당 글은 참고용으로만 봐주시고 틀린 부분이 있다면 알려주시면 감사하겠습니다. 

 


 

이전 포스트에선 웹으로 원래 로직을 이식하여 작동'만'하는 엉터리 코드를 작성하였습니다. 언급했듯 그렇게 코드를 이상하게 작성한 이유는 이번 포스트부터 잘못된 설계와 코드를 비교하는 과정을 작성하기 위한 빌드업이라고 이해해주시면 될 것 같습니다.

 

이전 포스트를 문제로 이번 포스트를 해설로 봐주시면 좋을 것 같습니다만 아직 저도 많이 부족한 수련생이기 때문에 자신이 더 좋은 생각이 있으시다면 알려주시면 감사할 것 같네요. 

 

 

테스트코드와 TDD

 

TDD란 Red-Green-Refactor 사이클로 이루어진 개발 방법론을 의미합니다. Red란 우선 실패하는 테스트 코드를 작성하는 것, Green은 그 실패하는 테스트를 통과만 할 수 있는 최소한의 프로덕트 코드를 작성하는 것, Refactor는 프로덕트 코드의 품질 개선 단계입니다. 실제 개발 현장에선 TDD를 DDD(도메인 주도 개발)의 반대 개념이나 테스트 커버리지를 높이는 등 개념을 혼용하곤 하는데 원칙적으론 TDD는 아닙니다. 여기서 개발을 접하신지 얼마 안되신 분이나 로직 파악을 위한 실습을 주로 진행 중이시라면 TDD는 물론 테스트 코드를 짜는 것조차 크게 중요하지 않게 느껴질 수도 있습니다.

 

하지만 초기 학습을 넘어 프로젝트를 구상하는 단계에 진입했다면 테스트 코드는 선택이 아닌 필수라고 할 수 있습니다. 초기 학습 중이신 분들은 테스트 코드라고 해봐야 비즈니스 로직을 먼저 작성하고 나중에 테스트 코드 몇 줄 추가하는 것을 상상하실 수 있는데 여러분의 상상보다 테스트 코드는 훨씬 더 중요합니다.

 

물론 TDD는 개발 방법론 중 하나일 뿐이고 당연히 이를 필수로 여길 필요는 없습니다만 테스트, 특히 유닛 테스트는 마치 컴파일 에러처럼 개발자를 위한 것이므로 테스트 코드 작성은 항상 옳습니다.

 

TDD에 대한 글을 추후 포스트할 것이라 테스트 코드에 대해 짧게 얘기하고 넘어가겠습니다. 정말 극소수의 실력 있는 개발자가 아니라면 프로젝트를 협업으로 관리하는 것이 일반적입니다. 그 프로젝트 repo에는 수많은 commit과 merge가 반복될테고 수많은 사람들의 코드가 뒤섞일겁니다. 이 과정에서 코드끼리 일으킬 side effect를 일일히 개발자들이 검증이 가능할까요? 이것만 생각해도 테스트 코드의 중요성이 와닿으실 것이라 생각합니다.

 

이번 글을 진행하며 테스트 코드에 대한 설명을 덧붙일테니 아예 모르는게 아니시라면 글을 읽는데 크게 무리가 없을 것 같네요. 테스트 코드에 대한 내용은 너무 중요해서 다른 주제를 다루며 짧게 넘어갈 수 없는 내용이라 추후 다룰 예정입니다. 그래도 짧게 짚고 넘어갈 부분은 존재합니다.

 

우선 테스트 코드에 대해 간단히 소개하겠습니다. 개발 중 테스트는 크게 단위테스트(unit)와 통합테스트(integration)이 존재합니다. 유닛테스트는 각 컴포넌트들이 잘 기능하는지 하는 테스트고 통합테스트는 서비스를 위해 이런 컴포넌트들을 통합하는 과정에서 호환성에 문제가 없는지 테스트하는 것입니다. 주로 유닛테스트는 @ExtendWith, 통합테스트는 @SpringBootTest로 진행합니다.

 

사실 원래 유닛 테스트는 말 그대로 유닛 단위로 메서드, 클래스 등을 쪼개 격리를 보장하며 진행하는 테스트이며 이를 위해 모의 객체를 사용하며 통합 테스트 역시 컨트롤러~DB까지의 컴포넌트 연결 뿐만 아닌 인수 테스트나 E2E 테스트로 분류되는 개념이 추가로 존재합니다만 이는 나중에 포스팅하겠습니다.

 

스프링은 DI때문에 모듈간 아예 분리해서 테스트하기는 힘들때가 많기 때문에 사실 종류를 위와 같이 딱딱 둘로 분리하기는 힘듭니다. 이에 대해서는 긴 설명이 필요하므로 일단 이후 설명은 모두 단위 테스트 위주로 줄이겠습니다. 단위 테스트는 FIRST라는 특성을 따르는데 이를 여기서 모두 설명하긴 힘들고 이 특성들을 따르며 단위 테스트를 작성하기 위한 유의사항만 소개하고 넘어가겠습니다.

1. 1개의 테스트는 1개의 개념만을 검증
- 두 개 이상의 메소드를 한 테스트에 검증하거나 실패, 성공을 한 테스트 안에서 검증하는 것을 지양합니다.
(단, 여러 단위테스트가 완성된 뒤 이를 묶어 상향식으로 올려가는 경우는 예외)
2. 1개의 테스트 함수에서 assert문의 최소화
- 보통 테스트의 결과를 boolean 타입으로 return하는 것을 맡는 함수가 assert문인데 이를 너무 남발해선 안됩니다.
3. 테스트 함수 작성시 테스트 명을 구체화(한글도 허용)
- 테스트 코드는 실 서비스하는 프로덕트 코드와 구분되기에 한글로 명확히 표시하는 것도 좋으며 @DisplayName을 사용하여 구체적으로 표시하는 것도 좋습니다.

 

또한 유닛 테스트는 보통 given/when/then 문법을 사용하는데 이는 차차 소개하겠습니다. 더 자세한 것은 나중에 알아보고 일단 본론으로 넘어가겠습니다.

 

요구사항 분석

 

본격적인 설계에 앞서 요구사항을 좀 더 상세히 정리하여 기본이 되는 도메인과 함께 컴포넌트들의 인터페이스들을 처음부터 다시 정의하도록 하겠습니다.

 

다시 이전 포스트의 기획 view 사진을 먼저 보자면 아래와 같습니다.

크게 우리가 진행할 코드의 흐름을 정리해보면 (A는 사용자, B는 서버)

1-A. 사용자가 입력사항[코드 전문, 인덱스 키워드, 타이틀 키워드]를 입력
1-B. 서버가 입력사항을 저장하고 코드를 분석하여 나머지 필드 [타이틀 리스트 및 개수, 인덱스 중복 여부] 초기화

2-B. 서버는 입력사항[코드 전문, 인덱스 키워드, 타이틀 키워드]과 검증사항[타이틀 개수, 인덱스 중복 여부] 렌더링
2-A. 사용자가 검증사항[타이틀 개수, 인덱스 중복 여부]를 확인하고 이상이 있으면 입력사항을 수정
2-B. 서버가 수정된 입력사항에 대해 1-B과정을 다시 반복
[검증이 통과 될때까지 반복]

3-B. 서버가 수정사항이 반영된 결과 코드를 렌더링
3-A. 사용자가 변경된 결과 코드를 페이지에서 확인

앞선 포스트에서도 이 요구사항을 반영하여 코드를 작성했지만 문제는 흐름을 모듈화하지 않아 컴포넌트들이 난잡해졌고 그렇게되면 테스트 코드를 작성하는 것도 매우 어렵습니다. 그렇기에 기술 명세에 다음과 같은 사항을 추가하겠습니다.

 

일단 가장 기본적인 입출력 사항입니다. 특이사항은 없습니다.

- 사용자 입력 사항
1. [원본 코드 전문, 인덱스 키워드, 타이틀 키워드]
2. (검증 실패 후) 수정한 [원본 코드 전문, 인덱스 키워드, 타이틀 키워드] 

- 서버 제공 사항
1. 입력된 [원본 코드 전문, 인덱스 키워드, 타이틀 키워드]
2. (검증 실패 후) 수정된 [원본 코드 전문, 인덱스 키워드, 타이틀 키워드]
3. 입력 사항이 반영된 결과 코드 전문

 

다음으로 서버가 저장할 정보입니다. 앞서 이 사항이 정리가 되지 않았기에 도메인이 난잡했습니다. 추후 수정되면 최초 작성 코드 및 이전 수정 코드 정보를 보여주는 기능이 추가될 수 있지만 현재 프로젝트에선 필요치 않은 기능이므로 과감히 모두 최신 수정 코드 정보로 덮어쓰도록 하겠습니다. 주의할 건 실무에서는 이렇게 확장 가능성이 예상된다면 이를 고려하는 것이 좋습니다.

- 서버 DB 저장 정보(DB가 없으므로 repository에 임시로 저장할 내용)
1. 최종 수정된 [원본 코드 전문, 인덱스 키워드, 타이틀 키워드]  (일단 앞서 작성한 내용은 모두 덮어씀)

 

프로그램 기술 명세에는 원래 시나리오, 세부사항, 이슈를 모두 작성해야하지만 이 프로젝트는 위에서 언급한 것으로 충분할 것 같습니다.

 

테스트 코드 예시 - domain, repository

 

앞서 TDD에 대해 간단하게 소개했었는데 이번 프로젝트는 그에 따라 실패 테스트 작성 > 테스트만 통과할 수 있는 최소한의 프로덕트 코드 작성 > 리팩토링의 절차를 따르겠습니다.

 

이제 위 기술 명세에 맞춰 테스트 코드를 작성할텐데 저는 보통 domain,repository > service > controller > view 순으로 작성하는 편입니다. 또한 이번 장에선 테스트 코드를 작성하는 과정을 좀 자세히 적겠습니다. 사이클이 한 번 돌면 그 다음부터는 조금 더 빠르게 진행하겠습니다.

 

또한 포스트가 단위 테스트들로 구성되어 있지만 이번 장에선 통합 테스트에 쓰는 @SpringBootTest 를 사용하는데 그 이유는 인터페이스를 사용하면 Mock을 사용하기 까다롭기 때문입니다. Mock이란 앞서 언급했던 모의 객체이며 이번 문단 마지막에 Mock을 사용한 예시도 보여드릴 예정입니다.

 

우선 저는 이전 포스트에 생성한 프로젝트와 설정은 똑같이 이름만 바꿔서 프로젝트를 생성하신 후 패키지 구조 역시 동일하게 유지했고 새로운 프로젝트명은 newPostLink로 했습니다. 가장 먼저 test에 CodeRepositoryTest를 생성합니다.

 

package fadet.newPostLink.repository;

@SpringBootTest
class CodeRepositoryTest {

    @Autowired
    CodeRepository codeRepository;

당연히 CodeRepositoryTest에는 CodeRepository를 주입해야하겠죠? 하지만 당연히 CodeRepository는 존재하지 않기때문에 에러가 발생합니다. 그래서 우리는 CodeRepository를 만들어 줄 것인데 자바 class가 아닌 interface로 만들 것입니다. 여기서 스프링의 기능을 한 가지 사용하는데 그것은 뒤에서 다시 언급하겠습니다.

 

작은 프로젝트에선 굳이 interface를 사용할 필요 없이 class를 만들어도 무방하며 이 프로젝트 역시 동일하지만 스프링을 사용한다면 interface를 적극 사용하는 것을 추천하며 이를 잘 모르신다면 자바 다형성과 스프링 모듈화에 대한 정보를 먼저 짧게 찾아보시면 좋을 것 같습니다. 테스트 코드 작성에 있어서 구현체를 먼저 작성하는 것이 아닌 interface를 먼저 생성해 구조를 잡아놓고 구현체를 끼워주는 것이 다형성을 적극적으로 사용하는 코드입니다.

 

package fadet.newPostLink.repository;

public interface CodeRepository {

    void save();
    void update();
    Code findOne();

}

CodeRepository에는 정말 기초적인 메서드 3개인 저장, 수정, 검색 세가지만 정의했습니다. 하지만 여기서 findOne() 메서드는 return이 반드시 필요하며 그 값은 저장된 코드가 될 것이기에 reference type을 Code로 했으며 당연히 이것도 에러가 발생합니다. 따라서 Code 클래스도 생성해주도록 합시다. 도메인까지 굳이 인터페이스로 할 필요 없을 것 같아 클래스로 생성해줬습니다.

 

다시 CodeRepositoryTest로 돌아가서 이제 본격적인 테스트 메서드를 작성합니다. 

//CodeRepositoryTest
...

@Test
void 코드입력() {
    //given
    Code newOne = new Code("allCode1", "titleHtmlKeyword1", "indexHtmlKeyword1");

    //when
    codeRepository.save();

    //then
    assertThat().isEqualTo("allCode1");

테스트 코드의 메서드는 한글로 입력해도 괜찮습니다. 원래라면 영어로 입력하려고 하는데 포스트다보니 한글로 해도 무방할 것 같네요. 테스트 코드의 메서드 작성은 많이들 아시겠지만 기본적으로 given, when, then 문법으로 하는게 일반적입니다. 

 

given의 경우 말 그대로 주어진 변수나 입력 값 등을 정의하는 준비 단계이며 여기선 사용자가 코드 정보를 입력하는 것을 가정했습니다. 여기선 당연히 Code 생성자 내의 파라미터가 에러가 발생할 겁니다. 우리는 Code의 생성자나 필드를 정의한 적이 없으니까요. when은 이런 준비 과정이 끝난 후 테스트를 실행하는 과정이며 보통 제일 짧습니다.

 

then은 테스트의 검증 과정인데 에러가 발생하기도 전에 문제를 만났습니다. 사용자가 작성한 allCode 파라미터를 검증할 방법이 떠오르지 않네요. 여기서 우리가 save() 메서드를 잘못 작성한 것을 알아차릴 수 있습니다.

 

    //when
    Code savedCode = codeRepository.save();

    //then
    assertThat(savedCode.getAllCode()).isEqualTo("allCode1");

따라서 위와 같이 고치면 테스트를 성공할 수 있을테니 CodeRepository에서 save() 메서드의 리턴 타입을 void가 아닌 Code로 바꿔주면 됩니다. 이처럼 TDD에선 실행할 필요도 없이 테스트 코드 작성 중에도 메서드들을 검증하는 효과를 느낄 수 있습니다.

 

그럼 이제 코드입력() 메서드를 마저 완성하겠습니다.

위 그림처럼 빨간 줄에 빨간 글씨에 난리도 아닌데 지극히 정상입니다. 우리는 저것들을 정의해준 적이 없으니까요. 따라서 차례로 정의해주도록 합시다. Code부터 수정하겠습니다.

 

우선 Code의 생성자를 정의해주려면 필요한 파라미터들부터 정해줘야겠죠. 따라서 필드를 아래처럼 만들어줍니다. 

public class Code {
    // 필수 입력
    private String allCode;
    private String titleHtmlKeyword;
    private String indexHtmlKeyword;
}

이제 필드가 추가되었으니 생성자도 정의할 수 있겠네요.

public class Code {
...

    public Code(String allCode, String titleHtmlKeyword, String indexHtmlKeyword) {
        this.allCode = allCode;
        this.titleHtmlKeyword = titleHtmlKeyword;
        this.indexHtmlKeyword = indexHtmlKeyword;
    }
}

이제 given절은 해결됐습니다. 이제 when절을 살펴보니 프로퍼티 접근을 해줘야 합니다. 따라서 Getter를 추가해줍니다. 그리고 getId를 통해 Code의 id값도 불러와야하네요. 따라서 Code에 id 필드까지 추가해주면 될 겁니다.

 

이제 IDE에서 모든 빨간 줄이 제거됐으니 테스트를 실행해봅시다.

결과는 당연히 예외 발생입니다. 이 역시 당연합니다. repository는 interface로 구현되지 않았으니까요. 이제 우리는 이렇게 무수한 컴파일 에러들과 만나게 될 겁니다. TDD는 테스트 코드 작성 > 코드 빨간 줄 제거 가 끝나면 테스트 실행 후 컴파일에러 발생 > 에러가 발생된 프로덕트 코드의 수정 > 다시 테스트 실행 후 에러 발생 ... 을 무한 반복하는 과정으로 이루어집니다.

 

이것이 처음 하시는 분들에게는 정말 와닿지 않으실 수도 있습니다. 내가 프로덕트 코드의 논리 구조를 알고 코딩하는데 굳이 이래야할까?라는 생각이나 너무 시간적으로 손해 아닌가?라는 등의 많은 생각들과 함께 말이죠. 하지만 개발자들은 언제나 본인의 이해 범위를 넘어서는 요구사항을 만나는 것이 필연적이며 코드 작성 중 수많은 검증 과정을 거쳐야만 합니다. 하지만 이 때마다 모든 것을 개발자가 이해하며 검증하는 것은 불가능에 가깝습니다.

 

실제로 내가 이해하기 어려운 수준의 로직도 테스트 코드의 컴파일 에러를 분석하다 보면 작성이 보다 쉬워지며 많은 자료들에서 테스트 코드를 먼저 작성하는데 드는 비용이 프로덕트 코드 작성 후 검증을 반복하는 비용보다 훨씬 절약된다고 나와있습니다. 이쯤 설명하면 어느정도 된 것 같으니 다시 본론으로 돌아가겠습니다.

 

우리는 컴파일 오류로 CodeRepository의 구현체가 없다는 걸 깨달았습니다. 따라서 구현체인 CodeRepositoryImpl을 만들어 주도록 합시다. 쉽게 만드시려면 CodeRepository로 이동하셔서 Alt+Enter 후 구현체 만들기를 하시면 됩니다. 또한 구현체는 실제 repository기에 @Repository를 붙여줍니다.

 

@Repository
public class CodeRepositoryImpl implements CodeRepository {
    @Override
    public Code save() {
        return null;
    }

    @Override
    public Code update() {
        return null;
    }

    @Override
    public Code findOne() {
        return null;
    }
}

이제 save() 메서드를 작성해줘야 하는데 DB만 사용하셨다면 DB가 없을때 당황하실 수도 있을 것 같습니다. 그냥 쉽게 repository 안에 Map으로 임시 저장소를 만들어 주면 됩니다. 따라서 Map<Long, Code>와 순번을 매겨줄 sequence를 필드로 추가합니다. 또한 save() 메서드도 마저 완성해주겠습니다.

 

@Repository
public class CodeRepositoryImpl implements CodeRepository {

    private static Map<Long, Code> store = new HashMap<>();
    private static long sequence = 0L;

    @Override
    public Code save(Code newOne) {
        newOne.setId(++sequence);
        store.put(newOne.getId(), newOne);
        return newOne;
    }

 

이렇게 완성되면 코드입력() 메서드 테스트는 성공적으로 통과합니다. 지금까지 domain과 repository를 TDD로 코딩하는 과정을 조금 상세히 보여드렸는데 숙련자분이 볼 때는 얼마나 지루하셨을지 모르겠네요. 하지만 잘 모르셨던 분이라면 조금 감을 잡으셨다고 생각하며 이후 과정은 너무 자세한 부분을 생략토록 하겠습니다.

 

추가로 당분간은 실패 테스트는 없고 성공 테스트만 존재합니다. 일반적으로 실무에선 해피 케이스만을 작성한 테스트 코드는 반쪽짜리라고 할만큼 실패 케이스 작성도 중요합니다만 이번 프로젝트는 간단한 구현만을 요구하므로 제외하였습니다. Validation에 대한 부분을 소개한 뒤에 많이 등장하겠네요.

 

아 그리고 처음에 언급했던 것처럼 Mock을 사용한 단위 테스트를 하려면 아래와 같이 어노테이션만 수정해주시면 됩니다. 모의 객체를 사용하는 코드에선 @InjectMocks와 @Mock 등을 자주 사용합니다. 자세한 사용법은 생략하겠지만 기본적으로 이 어노테이션들로 주입받는 모의 객체는 말 그대로 빈 객체이므로 when과 같은 스태틱 메서드를 통해 객체를 정의해주어야 합니다. 

@ExtendWith(MockitoExtension.class)
class CodeRepositoryTest {

    @InjectMocks
    private CodeRepositoryImpl codeRepositoryImpl;

 

그런데 이번 포스트에선 유닛테스트인데도 불구하고 @ExtendWith 대신 굳이 무거운 @SpringBootTest를 사용했습니다. 사실 @ SpringBootTest를 사용한다고 모두 통합 테스트는 아닙니다. 하지만 @SpringBootTest로 테스트를 구동하면 실제 프로덕트 context가 실행되기에 테스트 실행 속도가 느리며 사용되는 스프링빈이 주입될 여지가 있기에 유닛테스트는 격리에 초점을 맞추므로 보통 Mock을 사용하여 모의 객체를 만들어 마치 스프링 빈처럼 사용하곤 합니다. 하지만 이번 포스팅에서는 스프링의 특징을 한 가지 소개하기 위해@ SpringBootTest를 사용했습니다.

 

위 과정에서 우리는 분명 CodeRepositoryTest에 @Autowired로 인터페이스인 CodeRepository만을 주입받았습니다.

package fadet.newPostLink.repository;

@SpringBootTest
class CodeRepositoryTest {

    @Autowired
    CodeRepository codeRepository;

그런데 신기하게도 Test는 이상없이 통과되었습니다. 무언가 이상하지 않으셨나요? 사실 코드입력() 메서드는 명시된CodeRepository가 아니라 그 구현체인 CodeRepositoryImpl를 주입받았기 때문입니다. 스프링은 인터페이스로 로직을 짜도 나중에 그 구현체를 추가해서 바꿔주기만 하면 잘 작동하고 이 특징은 정말 큰 장점입니다. 따라서 이 프로젝트에선 이런 특징을 잘 살려 코딩을 하려 합니다.

 

여기서 위 예시처럼 Mock을 사용하여 단위테스트를 진행하게 된다면 CodeRepository가 인터페이스기에 NPE가 발생합니다. 따라서 추가한 예시에선 구현체 repositoryImpl을 사용하신 것을 볼 수 있습니다. 이 내용은 워낙 얘기할 주제가 많으니 나중에 스프링 시리즈에서 더 자세히 다루도록 하겠습니다.

 

 


 

다음 포스트에선 나머지 테스트 코드를 마저 작성하고 이전 포스트에서 짚어야할 문제점들을 더 다뤄보겠습니다.