우테코 5기

[레벨 2 미션] 지하철 미션 학습 기록(2)

teo_99 2023. 6. 8. 02:53

컨트롤러 - 서비스 DTO 분리

이번 미션에는 컨트롤러와 서비스 간의 통신에 있어 DTO를 사용했습니다. '컨트롤러와 서비스 간에 무엇을 주고받을것인지'는 오랜 관심사이기도 했는데요. 1. 도메인 객체를 주고받는 방법, 2. 표현 계층의 DTO를 주고받는 방법 모두 시도해봤기에 이번엔 조금 다른 방법을 시도해봤습니다. 바로 서비스 전용 DTO를 생성하는 것입니다. 물론 실무에서 적용할 수 있을지에 대해서는 확신이 들지는 않지만 연습 과정에 있어서는 큰 도움이 될 것 같았기 때문입니다. 구조는 다음과 같습니다.

 

    public void enrollStation(@Valid EnrollStationCommand command) {
        Line line = lineRepository.findById(command.getLineId());
        Section section = generateSection(command);
        line.addSection(section);
        lineRepository.insert(line);
    }

서비스 메소드에서는 '전용 입력 모델'을 받습니다. EnrollStationCommand라고 하는 객체는 Controller - Service 사이의 통신에서만 사용됩니다. 그리고 서비스에서는 이 DTO가 올바른 데이터를 가지고 있다는 보장을 어느정도 할 수 있도록 @Valid로 빈 검증까지 수행했습니다.

 

마찬가지로 서비스가 어떤 값을 반환할때도 마찬가지로 DTO를 사용했는데요,

    public List<StationResult> findRouteMap(@Valid IdCommand command) {
        Line line = lineRepository.findById(command.getId());
        return makeStationResultsOf(line);
    }

StationResult라고 하는 객체도 Controller - Service 사이의 통신에서만 사용됩니다. 

 

이처럼 컨트롤러 - 서비스 간에 DTO를 만들어보았고, 역시나 리뷰어님도 이 부분에 대해 질문해주셨습니다.

무엇을 주고받을 것인지에 대한 완벽한 정답은 없다고 생각됩니다. 따라서 장단점을 명확하게 알고 있는 것이 중요합니다. 리뷰어님의 경우에는 처음에는 request DTO를 사용하도록 작성하고, 표현 계층과 응용 계층이 서로 복잡해져서 분리할 필요성이 생기면 그때 선택적으로 분리한다고 하십니다!

 

@Validated로 DTO 검증하기

앞선 내용과 어느정도 연결되는 내용입니다. 컨트롤러 - 서비스 DTO를 만들고 나니, 해당 DTO에 검증을 붙이고 싶어졌습니다. 그렇게 된다면 표현 계층에서의 검증에는 관계가 없이 Service는 올바른 데이터를 받을 수 있다는 가정을 어느정도 할 수 있을테니까요. 클린 아키텍처에서 제시하는 바이기도 합니다.

 

그래서 DTO에 검증을 붙이는 방법을 찾아보았습니다. 처음에는 request DTO와 마찬가지로 BeanValidation이 제공하는 @NotNull, @NotEmpty, @Positive 등의 어노테이션을 붙이고 service를 동작시키니 반영이 되지를 않더라구요.

 

public class EnrollStationCommand {

    @NotNull
    private final Long lineId;
    @NotNull
    private final Long upBound;
    @NotNull
    private final Long downBound;
    @Positive
    private final Integer distance;
    
    ...
}
@Service
@Transactional
public class LineService {
	public void enrollStation(@Valid EnrollStationCommand command) {
		// 위 @Valid 어노테이션은 동작하지 않는다 
    	Line line = lineRepository.findById(command.getLineId());
        ...
}

왜일까요? Controller에서 붙이는 @Valid는 바로 작동이 되는데 말이죠. 조금 찾아보니 다음과 같은 내용을 학습할 수 있었습니다. 

@Valid는 Spring MVC의 구성요소인 ArgumentResolver에 의해 작동한다. 즉, 컨트롤러가 아닌 Service 같은 빈에서 @Valid 어노테이션을 사용하려면 @Validated 어노테이션을 빈에 붙어주어야 한다. 

따라서 Service를 다음과 같이 수정했고, 정상 작동을 확인할 수 있었습니다.

@Service
@Validated
@Transactional
public class LineService {

    public void enrollStation(@Valid EnrollStationCommand command) {
        Line line = lineRepository.findById(command.getLineId());
        Section section = generateSection(command);
        line.addSection(section);
        lineRepository.insert(line);
    }
    ...
}

 

@Validated에 대한 내용은 다음의 블로그에서 잘 설명하고 있습니다!

https://mangkyu.tistory.com/174

 

[Spring] @Valid와 @Validated를 이용한 유효성 검증의 동작 원리 및 사용법 예시 - (1/2)

Spring으로 개발을 하다 보면 DTO 또는 객체를 검증해야 하는 경우가 있습니다. 이를 별도의 검증 클래스로 만들어 사용할 수 있지만 간단한 검증의 경우에는 JSR 표준을 이용해 간결하게 처리할 수

mangkyu.tistory.com

 

 

 

서비스 통합 테스트 vs. 서비스 모킹 테스트

서비스에서는 Repository 혹은 DAO를 참조하는 경우가 많은데요. 이에 따라서 서비스를 어떻게 테스트해야 할지 고민이 많이 되었습니다. 

 

모킹을 사용할 것이냐(mockist), 사용하지 않을 것이냐(classist)를 생각해보면 저는 Mocking을 안하는 쪽(classist)에 가깝긴 합니다. 왜냐하면 테스트가 구현 자체에 대해 영향을 받으니까요. A라는 메소드를 사용하지 않고 B라는 메소드를 사용하도록 프로덕션 코드가 변경되었다면 모킹을 사용하는 경우 테스트 자체가 깨져버릴 수 있습니다.

 

테스트를 작성하는 이유는 우리가 기대하는 기능이 잘 작동할 것임을 검증하는 이유도 있지만, 프로덕션 코드의 변경을 용이하게 하기 위함도 있다고 생각합니다. 테스트가 있기 때문에 구현부를 쉽게 바꿀 수 있다고 생각하는데요, 이런 관점에서 모킹은 그렇게 효율적이지 않다고 느껴졌습니다.

 

그래서 이번 미션에서는 서비스를 @SpringBootTest로 진행했습니다. 어떻게 보면 DB까지 포함해 통합 테스트를 진행한 것이라 볼 수 있는데 이것도 깔끔한 방법은 아니라고 느껴졌습니다. 일단 data.sql로 더미 데이터가 존재해야 하기 때문입니다.

 

이에 대해서 리뷰어님도 다음과 같은 말씀을 전해주셨습니다.

모킹을 사용하는 방법, 사용하지 않는 방법은 각각의 장점이 존재하는데요, 일정 부분을 내어주고 모킹을 하거나, 일정 부분을 내어주고 통합 테스트를 해야 할 때가 존재하는 것 같습니다. 모든 상황에 최적인 방법은 존재하지 않는 듯 하네요. Mock을 할 것인지, 그냥 통합 테스트를 할 것인지는 아직까지도 고민인 문제라 방학때 한번 깊게 정리해봐야겠습니다.

 

 

데이터베이스 관련 예외의 상태코드는?

데이터베이스 관련 예외가 발생한다면(DataAccessException 등) 어떻게 상태코드를 내어주어야 할까요? 보통 데이터베이스 예외의 경우 복구 불가능한 예외여서 checked Exception이고, 이를 스프링부트가 unchecked Exception인 DataAccessException으로 자동 전환해 주는 것으로 알고 있는데요.

 

문제는 데이터베이스 예외가 발생하는 원인이 클라이언트에 있을 수도 있고, 코드에도 있을 수 있고, 데이터베이스 자체에 있을 수 있다는 것입니다. 코드나 데이터베이스에 문제가 있는 경우는 500번대 예외를 전달하면 되지만... 클라이언트가 입력을 잘못해서 데이터베이스에 그대로 전달되는 경우를 어떻게 검출해야 하는지가 고민이 되었습니다.

 

그래서 크루들에게도 질문해보았는데요,

그에 대한 많은 크루분들이 답변해주신 내용은 다음과 같았습니다. 

  •  400번대 상태코드를 내려주려면 서비스에서 예외가 발생하는게 맞는 것 같다! 비즈니스 로직에 대한 예외를 영속성에서 던지는게 어색하게 느껴진다.
  • DAO는 계층 간 경계가 무너질 수 있기 때문에 온전히 데이터베이스에 관련한 예외만 처리해야 하고, 그렇다면 500번대로 반환하는 것이 맞다.
  •  개발자가 미처 생각하지 못해 발생하는 부분에 대해서는 500번대 상태코드를 반환하되, 점차 검증을 통해 보완하는 방식이 좋다고 생각한다.

이렇게 많은 의견들을 주셨고, 이에 따른 제 결론은 다음과 같습니다.

비즈니스 로직에서 가능한 모든 시나리오를 검출해 예외를 던져야 한다. 그럼에도 데이터베이스까지 사용자가 잘못 입력한 정보가 도달하는 경우가 있을 수 있다. 하지만 이런 경우를 근본적으로 막기는 어려우므로 로깅등의 메커니즘을 활용해 적절히 비즈니스 로직을 보완해나가는게 중요하다.