테스트 코드와 단단한 코드 구조 - boostcampwm-2022/web17-waglewagle GitHub Wiki

❗️단단한 코드 구조와 트러블 슈팅

(1) 중구난방 관심사; 혼재된 관심사

서비스 레이어에 대한 단위 테스트를 구성하려고 시도하였다. 그러나, 테스트 코드보다 먼저 컨트롤러와 서비스 레이어가 가지고 있는 코드의 구조적인 문제점을 발견할 수 있었다.

컨트롤러 레이어에서도 데이터 검증에 대한 관심사를 가지고 있었고, 서비스 레이어에서도 데이터 검증에 대한 관심사를 가지고 있었다. 거기에 컨트롤러는 서비스 레이어의 검증 과정에서 발생한 예외 상황에 대한 관심사까지 가지고 있어 테스트 코드 작성이 어려웠던 것이다.

너무 많은 관심사를 가지고 있던 컨트롤러

@ResponseBody
@PostMapping()
public ResponseEntity<KeywordResponse.CreateDTO>
createKeyword(@RequestBody final KeywordRequest.CreateDTO createDTO) {
	
	if (keywordService.isDuplicated(id)) { ...(1): 서비스 메서드 
    return ...
  }

	try {
		Keyword keyword = keywordService.createKeyword(id, ...); ...(2): 서비스 메서드 2
    return ...
	} catch {
    return ...
  }
}
  1. 서비스 메서서들에 대한 단위 테스트를 진행하는 것만으로 비즈니스 로직의 흐름을 보장할 수 있을까?
    • 컨트롤러 레이어에서도 비즈니스 로직에 대한 흐름을 제어하고 있다면, 비즈니스 로직이라는 하나의 관심사를 왜 둘이 나누어 가지게 된걸까?
  2. 검증과 비즈니스 로직은 하나의 트랜젝션 안에서 동작해야 하는 것 아닐까?

➡️ 컨트롤러 레이어에서 가져갔던 비지니스 로직의 흐름 제어라는 역할, 책임서비스 레이어이전해야 한다.

(2) 비즈니스 로직의수많은 분기들 : 컨트롤러 예외 처리

try - catch로 뚱뚱해지는 컨트롤러 레이어

public ResponseEntity<Response> controllerMethod() {

	ResponseEntity res;

	try {
		res = service.method();
		return new ResponseEntity<>(res, HttpStatus.OK); ...(3) 성공 분기 3
	} catch(NoSuchElementException e1) {
		return new ResponseEntity<>("", HttpStatus.NOT_FOUND); ...(1) 실패 분기 1
	} catch(IllegalArgumentException e2) {
		return new ResponseEntity<>("", HttpStatus.BAD_REQUEST); ...(2) 실패 분기 2
	}
	......
}
  1. 데이터를 검증하는 로직을 서비스 레이어로 이동시켜 데이터의 검증과 비스니스 로직 실행이 하나의 트랜젝션 안에서 이루어지게 되었다.
  2. 서비스 레이어에서 데이터 검증 중 정상적으로 비즈니스 로직을 실행하면 안된다는 판단이 섰을 때, Exception을 throw하고 이것을 컨트롤러에서 처리해준다면, 이것 역시 컨트롤러의 역할과 책임에 어울리지 않는다고 판단했다.
    • 이는 첫째로 컨트롤러 레이어의 관심사는 필요한 데이터를 받아서 서비스 레이어로 넘겨주고, 되돌려 받는 반환값을 다시 클라이언트에게 전달해주는 것이라고 생각했기 때문이고, 둘째로는 컨트롤러 레이어에서 서비스 레이어의 메서드가 어떤 Exception을 throw할 지 알고 있어야 하기 때문이다.

➡️ 실패 분기, 예외 처리에 대한 다른 방법이 필요하다!

🔨 해결과정

(1) 관심사 분리

➡️ 컨트롤러 레이어와 서비스 레이어의 역할과 책임 재정의하기

컨트롤러 레이어

  1. API 요청을 인식
  2. 요청 매개변수들을 서비스 레이어로 전달
  3. 서비스 레이어에서 반환한 값을 클라이언트로 전달

서비스 메서드

  1. API 요청에 대한 핵심 비지니스 로직의 실행 및 검증
  2. 레포지토리 레이어에 적절한 데이터를 요청
  3. 실패 케이스에 대해 Exception 발생 처리
  4. 성공 케이스에 대해 적절한 값을 컨트롤러 레이어로 전달

(2) 전역 Exception Handler 도입 (feat. 커스텀 Exception)

➡️ Spring에서 지원하는 전역적 예외처리 장치 RestControlllerAdvice 레이어를 도입

@RestControllerAdvice
public class GlobalExceptionHandler {

	@ExceptionHandler(NoSuchElementException.class) ...(1)
	protected ResponseEntity
	handleNoSuchElementException(final NoSuchElementException e) {
		
		return ResponseEntity.status(HttpStatus.NOT_FOUND);
	}

	@ExceptionHandler(IllegalArgumentException.class) ...(2)
	protected ResponseEntity
	handleIllegalArgumentException(final IllegalArgumentException e) {
		
		return ResponseEntity.status(HttpStatus.BAD_REQUEST);
	}
}

서비스 레이어의 실패 분기에서 발생한 각 예외의 처리를 담당하는 핸들러 메서드를 구현하였다.

public ResponseEntity<Response> controllerMethod() {

	return new ResponseEntity<>(service.method(), HttpStatus.OK);
}

컨트롤러 레이어는 서비스 레이어에서 전달받은 값을 클라이언트에 전달하는 역할만 하게 되면서, 훨씬 코드가 간결해졌다.

❓ 개발자마다 서로 다른 예외 메세지를 사용한다면, 새로운 혼란이 발생하지 않을까?

public ResponseEntity serviceMethod() {
	throw new NoSuchElementException("예외 메세지 직접 입력");
}
  1. 일관적이지 못한 메시지와 다양한 예외 상황에서 일관되지 못하거나 너무 모호한 예외가 발생하게 된다.
  2. 이것을 Exception Handler에서 처리하기 위해선 서비스 레이어에서 발생시키는 모든 Exception에 대해서 알고 있어야 한다.
  3. Exception Handler가 자신의 책임을 다하기 위해서 다른 레이어의 내부를 알아야 한다면, 해당 레이어의 책임이 과중하고 관심사가 제대로 분리된 상태가 아니라고 판단했다.

➡️ Exception 객체가 가지는 메시지통일하고, 구체화해야 한다.

@Getter
@RequiredArgsConstructor
public enum ExceptionMessage {

    NO_SUCH_KEYWORD("키워드를 찾을 수 없습니다."),
    ALREADY_JOINED_KEYWORD("이미 가입한 키워드입니다.");

    private final String message;
}
public class NoSuchKeywordException extends NoSuchElementException {

    public NoSuchKeywordException() {
        super(ExceptionMessage.NO_SUCH_KEYWORD.getMessage());
    }
}
  1. 각 도메인 별로 throw되는 Exception의 역할을 하는 각각의 Exception 클래스를 선언하여 Exception Handler가 가진 과중한 책임을 분리하고자 했다.
  2. Exception message에 대한 enum 클래스를 정의하여 메시지를 통일할 수 있도록 하였다.
    • 물론, 여전히 Java나 Spring이 제공하는 Exception에 제각각의 메시지를 담는 것을 막을 수 없지만 각 도메인의 휘하의 exception 디렉토리에 관련 클래스를 정의하여 그런 일을 최소화 하고자 했다.
  3. 위에서 정의한 enum 클래스를 기반으로 각각의 상황에 맞는 custom exception을 정의하였다.
public EntityDTO serviceMethod() {
	
	if (!isValid())... 검증 과정
		throw new NoSuchKeywordException();
}

결과

스크린샷 2022-12-12 오후 5 13 31

  1. 서비스 레이어는 exception 디렉토리를 참조하여 예외 상황에 맞춰 미리 만들어진 적절한 exception을 throw한다.
  2. Exception Handler 레이어는 exception 디렉토리를 참조하여 서비스 레이어에서 전달할 예외 상황에 대해서만 처리해주면 되는, 관심사가 적절히 분리된 상태이다.

❗️테스트 코드와 트러블 슈팅

(1) 어떤 테스트를 진행해야 할까?

컨트롤러 레이어와 서비스 레이어의 관심사를 분리하기 위해 Exception Handler 레이어와 custom Exception 클래스를 정의하였다.

그 후, 각각의 관심사에 맞게 테스트 코드를 짜려 하였지만, Controller와 Service, Repository 3개의 레이어를 계층적으로 사용하기 있는 현 상태에서 어떤 방식으로 테스트 코드를 작성해야 적절한 지에 대해서 알 수 없었다.

책임도, 실행도 너무 무거운 테스트

  1. 특히, 컨트롤러 레이어에 대한 테스트를 진행할 경우, 해당 요청은 Service 레이어와 Repository 레이어를 모두 거치기 때문에 사실상 postman 등의 툴을 통해 실제 요청을 보내는 것과 차이점이 없었다.
  2. 이 경우, 테스트가 실행 환경에 영향을 받을 수 있어 테스트만을 위한 새로운 환경을 조성해주어야 하며, 모든 테스트에서 적절한 의존성을 주입해야 하기 때문에 하나의 테스트 메서드를 실행할 때에도 모든 의존성을 주입해주어야 해서 테스트 시간이 너무 오래 소요되는 문제가 있었다.
  3. 게다가 컨트롤러 레이어를 테스트하는 과정에서 서비스 레이어와 레포지토리 레이어를 모두 거치기 때문에 테스트 케이스를 실패하더라도 과연 어디서 실패했는 지에 대해서 정확하게 알 수 없는 문제가 있었다.
➡️ 테스트 환경 내에서 **관심사** 를 **분리** 하여 각 레이어에 대해 **독립적으로** 테스트를 수행한다.

(2) 의존성을 관리하는 방법

가장 큰 문제는 각각의 계층이 하위 계층을 의존하고 있다는 것이다. 서비스 레이어는 레포지토리 레이어를 의존하며 레포지토리 레이어가 가진 메서드를 실행하고, 그 결과를 비즈니스 로직 중간중간에 사용하게 된다.

물론 실제 환경에서는 서비스 레이어는 레포지토리 레이어가 반환한 결과값에 따라 서로 다른 처리를 해주어야 하므로 레포지토리 레이어가 반환한 결과값이 서비스 레이어의 관심사이지만, 테스트 환경 내에서는 그런 사실이 문제가 된다.

연쇄적으로 이어지는 의존 관계

  1. 각 레이어에 대해서 다른 레이어, 혹은 다른 도메인의 같은 레이어를 의존하고 있는 것 자체가 문제라고 판단했다.
  2. 의존성 관계가 연쇄적으로 연결되어 있어 하나를 의존하게 된다면 의존성의 의존성까지 모두 의존하게 되어버려 결과적으로 실제 API 요청을 통한 테스트와 차이점이 없어지게 된다.

➡️ 테스트용 구현체를 의존성으로 주입하자.

  1. Spring은 DI 컨테이너를 활용하여 interface에 대해 의존하도록 하고, 구현체를 매핑하는 방식으로 구현체를 주입한다는 있다는 것을 알고 있었다.
  2. 즉, 테스트 환경에서는 테스트 환경에 걸맞는 의존성의 구현체를 주입해주는 방식으로 해당 문제를 해결할 수 있을 것으로 판단했다.

(3) 과투자

테스트를 위한 구현체를 따로 구현하여 테스트 환경에서 이용하는 것으로 의존성의 연쇄를 끊을 수 있게 되었지만, 모든 의존성에 대해서 테스트를 위한 구현체를 따로 작성하는 것은 너무 과한 투자라고 판단했다.

예를 들어서 5개의 도메인들이 각각 컨트롤러, 서비스, 레포지토리 레이어의 객체를 하나씩만 가진다고 하더라도 15개의 객체를 모두 구현해주어야 한다는 뜻으로 가상의 유사 어플리케이션을 하나 추가로 더 구현하는 정도로 시간과 노력이 많이 소요될 것으로 판단했다.

시간인력한계

  1. 우리는 결국 짧은 시간 안에 어떤 결과물을 내놓아야 하는 프로젝트를 수행하고 있는 입장이다.이 상황에서 가상의 어플리케이션 하나를 더 구현하는 것은 과투자라고 판단했다.

➡️ 그때 그때 작은 객체를 만들어주자

  1. 테스트를 위한 환경을 모든 테스트에 일괄적으로 적용하려다 보니 발생한 문제라고 판단했다. 그 때 그 때 내가 사용할 메서드를 따로 따로 구현할 수 있다면, 훨씬 높은 생산성으로 테스트 코드를 작성할 수 있을 것이다.
  2. 이를 위한 프레임워크로 Mockito를 도입하였고, Mockto의 mock 메서드를 활용해 가짜로 만들어낸 객체를 의존성 주입에 사용하고, 해당 객체의 메서드에 대해서 입력값과 출력값을 임의로 조절하는 방식으로 각각의 테스트 케이스에 맞는 작은 객체를 만들 수 있다.

결과와 한계

  1. 가짜 객체를 활용하여 의존성을 주입하기 때문에 각각의 레이어에 대해서 간단하게 의존성의 동작을 모사할 수 있었다.
  2. 그러나, 가짜 객체가 입력값에 대해서 어떤 출력값을 내보내는 지에 대해서 검증이 필요할 것으로 판단된다.
    • 각각의 레이어에서 하위 레이어로 이동하면서 사용했던 입출력 값에 대해서 검증하는 방식으로 진행할 수도 있지만, 이는 점점 더 많은 구체적인 테스트 케이스를 구현해야 하게 되는 결과로 이어지고, 상기 과투자 문제를 다시금 불러일으키게 된다.
⚠️ **GitHub.com Fallback** ⚠️