개발 공부/Spring

DTO의 사용범위

gmelon 2023. 5. 17. 11:25

배경

넥스트스텝의 학습테스트로 배우는 Spring 과정을 수강하고 있는데, 과정 진행 중 controller 패키지의 DTO를 어떤 계층까지 사용하면 좋을지에 대한 고민이 들었다. 처음엔 간단하게 생각했지만 생각보다 고민할 내용이 많았어서 고민한 내용과 결론을 정리해보려고 한다.

DTO란

먼저, DTO는 Data Transfer Object의 약자로 말그대로 계층 간에 데이터를 주고 받기 위해 사용하는 객체이다. 보통 비지니스 로직은 두지 않고 필드와 그에 대한 getter와 생성자 등만 구현해두고 사용하는 경우가 많다.

예를 들어 아래와 같은 객체가 DTO이다.

@NoArgsConstructor
@Getter
public class PlayRequestDto {

    private String names;
    private int count;

    @Builder
    public PlayRequestDto(String names, int count) {
        this.names = names;
        this.count = count;
    }
}

DTO의 사용 범위

Controller에서만 사용

개발 전 설계를 하면서 dto를 어디까지 사용하도록 해야할지 고민했다. 그리고 먼저 아래와 같은 이유때문에 controller까지만 RequestDto를 사용하자고 결론을 내렸다.

  1. controller dto를 서비스 계층까지 가지고 가게 되면 서비스 로직의 변화가 api 스펙의 변화를 야기할 수 있다
  2. 해당 도메인의 서비스 도메인을 다른 도메인에서 사용하게 될 수도 있는데 특정 api 스펙에 특화된 controller dto를 service까지 가져가면 이러한 서비스 객체의 모듈화가 어렵다.

그래서 구현된 controller 코드는 아래와 같다.

@RequiredArgsConstructor
@Controller
public class PlayController {
    private final PlayService playService;

    @PostMapping("/plays")
    public PlayResponseDto plays(@RequestBody PlayRequetDto requestDto) {
        List<PlayResult> playResults = playService.play(splitNames(playRequestDto.getNames()), playRequestDto.getCount());
        // 중략
        return new PlayResponseDto(winners, racingCars);
    }
}

코드를 보면 client에서 요청 body에 넣어 보낸 PlayRequestDto의 값을 getter로 꺼내 playService에 전달하고 있다.

PlayService의 코드도 보자.

@Service
public class PlayService {
    public List<PlayResult> play(String carNames, int playCount) {
        RacingCarGame racingCarGame = new RacingCarGame(carNames, playCount);
        List<PlayResult> playResults = Collections.emptyList();

        while (!racingCarGame.isEnd()) {
            racingCarGame.play(movingStrategy);
            playResults = racingCarGame.getPlayResults();
        }

        return playResults;
    }
}

Dto를 Controller까지만 사용하도록 했기 때문에 Service에서는 값을 각각 받아오는 것을 볼 수 있다.

그런데 위 경우 dto에 필드가 많지 않아 직접 하나하나 인자로 넘겨주었지만 만약 필드가 많아지는 일반적인 경우 controller <-> service 간의 dto를 새롭게 만들게 될 것이다. 즉, 아래와 같은 dto가 만들어진다.

@Getter
@NoArgsConstructor
public class PlayParamDto {

    private String names;
    private int count;

    @Builder
    public PlayParamDto(String names, int count) {
        this.names = names;
        this.count = count;
    }
}

그런데 볼 수 있듯 PlayParamDto는 PlayRequestDto와 코드가 거의 동일해진다. 물론 api 스펙과 서비스에서 요구하는 인자가 다른 경우도 있겠지만 여러 리뷰어분들의 의견과 구글링을 통해 얻은 정보로는 서비스 로직과 컨트롤러 로직에서 사용하는 필드는 거의 동일하다고 한다. 심지어 서비스 객체는 실무에서 많은 경우에 해당 도메인의 컨트롤러에서만 사용되는 것을 경험하셨다고 한다. 그래서 오히려 서비스의 모듈화를 지나치게 일찍 고민해서 괜히 불필요한 dto(+중복 코드)를 마구 생성하고 있는게 아닌가 생각했다.

심지어 이러한 중복 코드는 요청뿐만 아니라 응답 dto에서도 발생한다. 위 코드에서는 PlayResult가 일종의 controller <-> service 간의 dto 역할을 하고 있다.

위와 같이 컨트롤러와 서비스의 필드가 동일하고 동일한 도메인에서만 사용되는 상황이 대부분이라면, 전체적으로는 dto를 service 계층에서까지 사용하도록 하고 정말로 서비스 계층의 모듈화가 필요한 상황이 되거나 서비스 로직과 컨트롤러 로직에서 필요로 하는 인자가 달라지는 경우 해당 도메인에서만 service용 dto를 분리하는 방향으로 리팩토링 하는게 오히려 비용이 적을 수 있겠다고 생각했다.

Service까지 사용

그래서 그 다음엔 dto가 service에서 까지 참조되도록 구현했다. controller, service 코드는 각각 아래와 같이 변경될 것이다.

@RequiredArgsConstructor
@Controller
public class PlayController {
    private final PlayService playService;

    @PostMapping("/plays")
    public PlayResponseDto plays(@RequestBody PlayRequetDto requestDto) {
        return playService.play(requestDto);
    }
}
@Service
public class PlayService {
    public PlayResponseDto play(PlayRequestDto requestDto) {
        RacingCarGame racingCarGame = new RacingCarGame(requestDto.getNames(), requestDto.getCount());
        List<PlayResult> playResults = Collections.emptyList();

        while (!racingCarGame.isEnd()) {
            racingCarGame.play(movingStrategy);
            playResults = racingCarGame.getPlayResults();
        }

        // PlayResponseDto 생성 로직
        PlayResponseDto responseDto = ...;

        return responseDto;
    }
}

DTO를 어떤 패키지에 둘지

결론적으로 dto를 service 계층까지 사용하도록 구현이 되었다. 그런데 이렇게 되니 이제 그렇다면 dto는 controller 패키지에 있어야 되는지 service 패키지에 있어야 되는지가 고민이 되었다.

기존에 controller에서만 사용할 때는 web/dto 패키지에 위치하면 딱 좋았는데 service에서 까지 사용하는 상태에서는 이렇게되면 service에서 상위 계층인 controller 계층에 의존하게 되므로 controller -> service -> domain 이라는 계층 간 의존 방향이 지켜지지 않게 된다.

이럴 경우 dto를 service(비지니스) 패키지에 두면 controller에서 웹 요청을 처리하거나 응답할 때 하위 계층의 dto를 사용하게 되고 service 계층에서는 동일한 계층의 dto 객체를 사용하기 때문에 의존 방향이 반대가 되는 문제가 해결된다.

처음에는 요청/응답을 처리하는 dto를 service에 두는게 어색했는데 관련하여 드린 질문에 대한 브라운(성현)님께서 주신 피드백을 인용하자면 조금 바꾸어 생각해보면 웹 요청을 처리하는 dto라기 보다는 서비스의 비즈니스 기능을 호출할 때 쓰는 dto를 클라이언트에서부터 쓰고 있는거로도 볼 수 있는 것 이기 때문에 위에서 언급한 모든 내용들을 감안할 때 이러한 방향이 더 적합하다는 결론을 내렸다.

계층 간 의존 방향을 왜 지켜야 할까

라는 근본적인 질문이 들었는데 지금으로썬 높은 결합도와 낮은 응집도를 유지하기 위해서 인 것 같다. 계층 간 의존 방향이 섞여 있으면 각 계층이 서로 강하게 결합되고 이는 유지보수와 계층 별 단위 테스트를 어렵게 만든다. 또한 코드의 유연성과 확장성도 크게 떨어지게 된다.

따라서 각 계층의 의존 방향을 한 방향으로 유지하는게 유지 보수성과 테스트 용이성을 고려한 좋은 설계라고 생각되고, 그런 의미에서 다시 한번 dto를 service 계층에 두는 것이 적합하다고 생각된다.

결론

코드를 구현하면서 막연한 부분이 많았는데 생각해보면 개발에 좋은 방향은 있어도 단 하나의 정답은 없는 것이니까 그게 당연하다는 생각이 들었다. 계속해서 고민해나가면서 여러 가지 상황에 가장 적합한 방법들을 찾아나갈 필요가 있겠다고 생각했다. 지금 내린 결론도 더 많은 경험과 지식이 쌓이면 언제든지 바뀔 수 있을 것 같다.

그래서 현재까지 내린 dto 사용 범위에 대한 결론은 아래와 같다.

  1. 기본적으로는 dto를 service 계층에 두고 client, controller, service에서 모두 사용하도록 하자.
  2. 그러다가 controller와 service의 인자가 달라지거나 서비스를 모듈화해야 할 상황이 생기면 그때 service 용 dto를 생성해 리팩토링 하는 방식으로 개발 비용을 아끼고 중복 코드를 줄이자.