⌨️ 테스트 코드 그리고 TDD
내가 여기서 말하고 싶은 건 TDD에 대한 찬양도 무조건적인 맹목도 아닌 테스트 코드 그리고, 충분한 테스팅에 대해 중요성에 대해 말하고 싶다.
1. TDD
테스트 주도 개발 (Test-Driven Development, TDD)은 소프트웨 개발 방법론 중의 하나로, 선 개발 후 테스트 방식이 아닌 선 테스트 후
개발 방식의 프로그래밍 방법을 말한다. 다시 말해 먼저 자동화된 테스트 코드를 작성한 후 테스트를 통과하기 위한 코드를 개발하는 방식의
개발 방식을 말한다.
1.1. TDD를 이용한 개발방법
1. 테스트 케이스 작성
2. 테스트 케이스를 통과하는 코드 작성
3. 작성한 코드 리팩토링
형식으로 이루어진다.
여기서 우리 같은 신입들은 많이 힘들어하는데, 나는 그 이유 중 하나가 역시 명확히 알고 구현을 하는 게 아니라
이리저리 헤매다가 어쩌다 얻어걸린 식의 구현이 많기 때문에 우선 기능을 구현하고 나중에 그 기능을 통과하는 테스트 코드를 짜는
역전된 방식의 테스트 코드 작성이 문제라고 생각한다.
즉 이미 기능적으로 구현을 해놓고 테스트 코드를 역으로 통과하게 끼워 맞추듯이 짜니
이 방식에 필요한 과정인가? 의문을 가질만하고 괜히 일만 두 번 하게 만드는 기분이 많이 들 것이다.
그럼에도 불구하고 TDD가 아니라 충분한 테스트의 필요성 그리고 테스트 코드가 있으면 좋은 점에 대해 알아보자
1.2. TDD의 장점
관습적으로 TDD의 장점은
- 객체 지향적인 코드 개발
- 설계 수정시간의 단축
- 유지보수(리팩토링)의 용이성
- 테스트 문서의 대체 가능
정도로 볼 수 있다.
당연히 신입 혹은 취준 입장에서는 와닿지 않는다.. 나 또한 마찬가지이다.
이번에 새로운 간단한 기능을 추가하며 테스트 코드를 짰는데 그로 인해 좋은 리팩토링을 할 수 있었는데,
그걸 예시로 한번 테스트 코드의 필요성에 대해 알아보자.
2. 새로운 기능 추가를 하며 테스트 코드 작성
2.1. 게시물 다중 삭제 구현
우선 여기서부터 다양하게 의견이 갈리는데
이미 구현이 되어있는 단건 삭제를 loop를 도는 것과
하나의 쿼리를 `IN` 절을 통해 삭제하는 것
서로의 장-단점이 있으나,
우리의 서비스에서 다중 삭제가 한번에 몇 백건씩 삭제가 되지는 않으므로,
이미 구현되어있는 코드를 재사용하여 구현을 해봤다,
즉 확장성과 유지보수성을 고려하여 코드를 짜는 것은 어떤 느낌일까 경험해 보려고 그렇게 선택했다.
2.2. 이전 코드에서의 문제 발견
@Transactional
@Override
public String deleteArticle(Long[] articleIdList, Long usersId) {
for(Long i : articleIdList){
deleteArticle(i, usersId);
}
return "삭제 완료";
}
//게시물 삭제
@Transactional
@Override
public String deleteArticle(Long articleId, Long usersId) {
// Users users = usersRepository.findById(usersId)
// .orElseThrow(() -> new BusinessLogicException(ExceptionCode.USER_NOT_FOUND));
Users users = userService.getUserById(usersId);
Article article = articleRepository.findById(articleId)
.orElseThrow(() -> new BusinessLogicException(ExceptionCode.ARTICLE_ALREADY_DELETE));
if(!Objects.equals(article.getUsers().getId(), users.getId())){
throw new BusinessLogicException(ExceptionCode.UNAUTHORIZED);
}
articleImageRepository.findAllByArticleId(articleId)
.forEach(img -> s3UploadService.deleteImage(img.getImgUrl()));
articleLikesService.deleteLikes(articleId);
articleRepository.deleteArticleByIdAndUsersId(articleId,usersId);
return "삭제 완료";
}
위는 나의 이전 게시물 삭제 로직이다. 단건 로직을 for문을 통해 돌면서 삭제하는 것으로 다중 삭제를 구현하였다.
그렇게 해서 발생한 문제는 우선
보이는 것과 같이 getUserById가 1번 호출될 것을 기대했으나 여러 번 호출된다.
당연한 결과다 나는 단순히 for문으로 deleteArticle()을 돌았으니 그 안에서 여러 번 호출이 되는 것이다.
그런데 생각해보면 그것 말고도 많은 문제가 있다.
return "삭제 완료"의 중복이라든지 말이다.
나는 이것을 테스트 코드를 통해 확인했고
@Transactional
@Override
public String deleteArticle(Long[] articleIdList, Long usersId) {
Users users = userService.getUserById(usersId);
for(Long i : articleIdList){
deleteArticleImpl(i, users.getId());
}
return "삭제 완료";
}
//게시물 삭제
@Transactional
@Override
public String deleteArticle(Long articleId, Long usersId) {
Users users = userService.getUserById(usersId);
deleteArticleImpl(articleId,users.getId());
return "삭제 완료";
}
private void deleteArticleImpl(Long articleId, Long usersId){
Article article = articleRepository.findById(articleId)
.orElseThrow(() -> new BusinessLogicException(ExceptionCode.ARTICLE_ALREADY_DELETE));
if(!Objects.equals(article.getUsers().getId(), usersId)){
throw new BusinessLogicException(ExceptionCode.UNAUTHORIZED);
}
articleImageRepository.findAllByArticleId(articleId)
.forEach(img -> s3UploadService.deleteImage(img.getImgUrl()));
articleLikesService.deleteLikes(articleId);
articleRepository.deleteArticleByIdAndUsersId(articleId,usersId);
}
이런 식으로 수정했다.
즉 deleteArticle() 에서 의도만 남기고
구현은 deleteAticleImpl() 보냈다.
이로써 추후 게시물을 삭제하는 로직에 대해 변화가 생긱다면 deleteArticleImpl() 메서드만 수정을 하는 것으로 간단해지고
deleteArticle() 메서드는 의도만 쉽게 읽히는 간단한 메서드가 되었다.
3. 테스트 코드를 통해 배운 점
테스트 코드 없이도 물론 충분히 문제점을 파악할 수 있었지만,
아무래도 기능이 동작한다면 품질에 대해서 놓칠 수 있다.
이런 부분들을 테스트 코드에서 가볍고 그리고 독립적으로 확인할 수 있는 것은 테스트 코드의 장점이라고 볼 수 있다.
즉 한번 뼈대를 잡아 놓으면, 내가 생각치 못한 부분에 대해 테스트를 할 때에도 프로덕트 코드를 건들지 않고 테스트 코드에 추가하여
간단히 확인할 수 있다.
또한 프로덕트 코드의 리팩토링을 진행하여도 테스트 코드에 그대로 통과한다면 기능적으로는 문제가 없는 것도 확인이 가능하다.
이처럼 TDD와 같이 테스트 무조건 짜야해 무조건 테스트를 짜고 프로덕트를 짜야해 이런 건 아니지만,
그럼에도 불구하고 테스트 코드를 짜서 기능을 추가하고,
서비스에 대한 충분한 테스트를 돌려보는 것은 중요하다고 생각한다.