변경에 유연한 설계, 우리는 2가지 종류의 DTO를 쓴다!

변경에 유연한 설계, 우리는 2가지 종류의 DTO를 쓴다!

2021, Sep 30    

변경에 유연한 설계, 우리는 2가지 종류의 DTO를 쓴다!

안녕하세요 깃-들다의 손너잘 입니다.

이번 글에서는 깃들다의 설계중에 DTO와 관련된 이야기를 해보고자 합니다.

여러분은 DTO를 어떤식으로 많이 사용하시나요? 아마 많은 프로젝트에서 View ↔ Controller ↔ Service, 혹은 DAO ↔ Service 와 데이터를 주고받을 때 사용하실겁니다.

저희도 역시 마찬가지로 View ↔ Controller ↔ Service 간의 데이터 이동시 DTO를 이용하는데요, 깃들다의 경우View ↔ Controller, Controller ↔ Service 에서 각각 서로 다른 DTO를 사용합니다. *DAO ↔ Service (persistance layer ↔ Application layer)는 JPA를 사용하기 때문에 DTO를 사용하지 안습니다.*

이번 글에서는 깃들다가 왜 DTO를 각 레이어간에서 사용하는지에 대한 이야기를 해보고자 합니다.

Layerd Arch.

일반적인 웹 어플리케이션은 보통 4계의 계층으로 이루어져 있습니다.

Untitled

바로 Presentation Application Domain Infrastructure 입니다. 이러한 용어가 어색할 수 있어 스프링 구조로 변경시켜 말하자면

Presentation : Controller
Application : Service
Domain : Domain
Infrastructre : DAO, RepositoryImpl

라고 표현할 수 있습니다. 그리고 추상화 수준은 위에서부터 아래로 내려가면서 옅어집니다.

또한 각 레이어간의 의존성은 한 방향으로 흐르며 보통 위에서 아래로 흐르게 됩니다. 왜 그래야 하는지에 대하여 간략하게 설명해보겠습니다.

Infrastructure 레이어에는 보통 외부 모듈등이 위치하게 됩니다. 엄밀히 말하면 우리가 사용하는 자바 라이브러리 또한 Infrastructure에 위치하게 됩니다.

그렇다면 java.lang.String을 예시로 들어보겠습니다. 우리는 Domain을 작성하면서 String이라는 클래스를 사용하게 됩니다. 이때 String이 A 어플리케이션의 어떤 클래스를 참조하고 있다고 가정해봅니다. 그렇다면 B 어플리케이션이 String을 가져다 사용할 때 무슨 문제가 생길까요? B 어플리케이션은 자신에게 필요하지 않은 A 어플리케이션의 클래스를 강제로 Import 받게 됩니다.

이뿐만이 아닙니다. 하위계층은 추상화되어 상위계층에서 이용됩니다. 즉, 여러개의 상위 계층은 하나의 하위계층에 의존할 수 있습니다. 하지만 만일 누군가가 위 원칙을 어기고 Domain 구현체를 의존하는 Infrastructure를 만들었다고 가정해봅시다. 그렇게 된다면 Domain 구현체의 변경이 Infrastructure에 속하는 객체에 영향을 끼칠 수 있습니다. 결국은 해당 Infrastructure계층에 속한 객체를 의존하는 다른 Domain객체들은, 다른 Domain 구현체의 변경으로 인해 전체적으로 변화가 일어날 가능성이 있습니다.

만일 누군가가 자신의 프로젝트를 수정했더니 java.lang.String에 변화가 일어나 String 객체를 사용하는 모든 프로젝트에 영향을 끼친다고 생각해 보세요..

이러한 문제점으로 인해 레이어 아키텍쳐의 의존성 방향은 프로젝트의 유연한 변경에 큰 영향을 끼치게 됩니다.

DTO는 왜 사용하는가?

여러분은 DTO를 왜 사용한다고 생각하시나요? DTO의 이름의 뜻은 Data Transfer Object입니다. 즉, 데이터를 전송하기 위한 객체를 의미합니다. 따라서 보통 DTO에는 Entity, Request로 들어온 값을 사상시켜 그 데이터를 전송하는 목적으로 사용됩니다. 따라서 DTO는 객체로 취급하지 않고 보통 Data Structure로 취급하는게 일반적입니다.

Spring에서 DTO는 어떻게 사용되는가?

Spring으로 웹 어플리케이션을 만들면서 아래와 같은 코드를 많이들 작성하셨을 것 입니다.

@RestController
public class PostController {

    private final PostService postService;

    public PostController(PostService postService) {
        this.postService = postService;
    }

    @PostMapping("/posts")
    public ResponseEntity<PostResponse> create(@RequestBody PostRequest postRequest) {
        PostResponse postResponse = postService.create(postRequest);

        return ResponseEntity.ok(postResponse);
    }
   ...
}
@Service
@Transactional
public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    public PostResponse create(PostRequest postRequest) {
        String title = postRequest.getTitle();
        String content = postRequest.getContent();

        Post post = postRepository.save(new Post(null, title, content));

        return PostResponse.from(post);
    }
    ...
}

아주 정석적인 Controller, Service 구조입니다. @RequestBody 를 통해 사용자로부터 요청한 데이터를 PostRequest라는 DTO에 Mapping시키고, 그 데이터를 Service로 보내는 행위를 하고 있습니다.

하지만 위 코드에서는 치명적인 문제점이 있습니다. 혹시 눈치 채셨나요?

바로 PostRequest를 service로 바로 넘기는 부분에 있습니다. 이 부분이 왜 문제인지 한번 확인해 보도록 하겠습니다.

Untitled 1

위 프로젝트의 패키지 구조입니다. dto는 presentation 레이어에 위치하고 있습니다. 이때 각 클래스간의 의존성을 확인해 보도록 하겠습니다.

Untitled 2

오른쪽 그림은 각 객체간의 의존성 방향을 나타내고 있습니다. 이렇게 보니까 무엇이 문제이지 잘 이해가 안될 수 있습니다. 그렇다면 객체들을 지우고, 패키지(레이어)의 의존성을 살펴보겠습니다. 이제 보이시나요? Presentation 레이어가 Application 레이어에 의존하고, Application 레이어가 Presentation 레이어에 의존하고 있습니다. 즉, 양방향 참조를 하고 있습니다.

이게 무슨 문제가 있는지는 위에서 설명했습니다. 조금 더 구현에 가까운 Application 계층이 Presentation 계층의 변화에 영향을 받고 OCP를 지키지 못하게 됩니다.

그러면 어떻게 해결할 수 있을까?

이를 해결하기 위해서 처음 말했듯, 깃들다 팀은 View ↔ Controller, Controller ↔ Service 에서 각각 서로 다른 DTO를 사용합니다.

Untitled 3

패키지 구조를 보면 Application 레이어에 dto가 생긴것을 볼 수 있습니다.

코드로 한번 확인해 봅시다.

@RestController
public class PostController {

    private final PostService postService;

    public PostController(PostService postService) {
        this.postService = postService;
    }

    @PostMapping("/posts")
    public ResponseEntity<PostResponse> create(@RequestBody PostRequest postRequest) {

        PostRequestDto postRequestDto = new PostRequestDto(
            postRequest.getTitle(),
            postRequest.getContent()
        );

        PostResponseDto postResponseDto = postService.create(postRequestDto);

        PostResponse postResponse = new PostResponse(
            postResponseDto.getId(),
            postRequest.getTitle(),
            postRequest.getContent()
        );

        return ResponseEntity.ok(postResponse);
    }
   ...
}

Controller 소스입니다. Controller에 들어온 PostRequest를 Application 레이어의 PostRequestDto로 변경하여 서비스로 넘기는 것을 볼 수 있습니다. 이렇게 변경함으로서 의존성에 어떤 변화가 일어났는지 그림으로 확인해 보겠습니다.

Untitled 4

의존성이 드디어 한쪽 방향으로 흐르게 되었습니다! 이젠 Presentation에서 아무리 변화가 생기더라고 Application에는 아무 영향을 주지 않습니다. 즉, 변화에 닫혀있는(유연한) 설계를 할 수 있게 되었습니다!!!!

이런 실수를 조심하세요

이렇게 코드를 작성하고나니 Controller가 너무 더러워졌습니다. 마찬가지로 Service로 더러워지겠죠.. 이를 해결하기 위해서 메서드 분리를 진행했지만 그래도 필드가 많은 Entity를 Dto로 만들거나 하면 코드가 더러워 보이는 것은 사실입니다.

많은분들이 여기서 실수를 합니다. 바로 정적 팩토리 메서드 인데요. 나름 코드를 이쁘게 만들겠다고 아래와 같은 소스를 작성하시는 분들이 있습니다.

@PostMapping("/posts")
    public ResponseEntity<PostResponse> create(@RequestBody PostRequest postRequest) {
        PostRequestDto postRequestDto = PostRequestDto.from(postRequest);
        PostResponseDto postResponseDto = postService.create(postRequestDto);
        PostResponse postResponse = PostResponse.from(postResponseDto);

        return ResponseEntity.ok(postResponse);
    }

확실히 소스는 깔끔해졌네요… 하지만 이 소스의 문제점은 무엇일까요? 다시 한번 의존성 방향 그림을 그려보겠습니다.

Untitled 5

DTO간의 의존성이 생기면서 또 다시 양방향 참조가 생겼습니다. 물론 PostRequest.from(postRequestDto) 와 같은 코드는 괜찬습니다. 어짜피 Presentation에 있는 DTO가 Application의 DTO를 참조하는것이니까요. 하지만 그 반대는 문제가 있습니다.

마찬가지로 아래와 같은 실수도 많이 합니다.

public PostResponseDto create(PostRequestDto postRequestDto) {
        String title = postRequestDto.getTitle();
        String content = postRequestDto.getContent();

        Post post = new Post(null, title, content);

        Post savedPost = postRepository.save(post);

        return new PostResponseDto(
            savedPost.getId(),
            savedPost.getTitle(),
            savedPost.getContent()
        );
    }

위에서 설명한것 같이 2개의 Dto를 사용하게 되면 Service로직또한 위와같이 만들어지는데요, 코드를 깔끔하게 하기 위해서 아래와 같은식으로 리팩토링을 진행합니다.

public PostResponseDto create(PostRequestDto postRequestDto) {
        Post post = postRequestDto.toEntity();

        Post savedPost = postRepository.save(post);

        return PostResponseDto.from(post);
    }

코드는 확실히 깔끔해 졌군요. 하지만 이것도 위와 같은 문제가 있습니다. 의존성 방향 그림을 그려보면, Domain과 Application 레이어가 양방향 참조를 하는것을 확인할 수 있습니다. 그렇다면 코드를 이렇게 더럽게 유지할 수 밖에 없을까요?

아니요 그렇지 않습니다. DTO라는 개념을 처음 제시한 마틴파울러의 PoEAA(Pattern of Enterprise Application Architecture)를 보면 DtoAssembler라는것을 제시합니다. 간단히 말하면 도메인을 Dto로 만들고, Dto를 도메인으로 만드는 로직을 몰아넣은 유틸성 객체를 만들라는 의미입니다.

Untitled 6

위 구조는 실제 Pickgit의 패키지 구조인데요, Presentation ,Application 레이어에 Assembler가 있는것을 볼 수 있습니다.

Untitled 7

내부를 보면 외부로 빼놓기에 너무 비대한 로직 변환 로직을 다 몰아넣을것을 볼 수 있습니다.

Untitled 8

그러면 이런식으로 코드를 깔끔하게 유지할 수 있습니다.

결론

이로서 깃들다팀이 왜 DTO를 2개를 사용하는지, 변경에 유연한 설계 관점에서 서술해봤습니다. 많은 도움이 되셨나요? 개인적으로 프로젝트를 하면서 느끼는점은, 이러한 이론적인것도 좋지만 프로젝트의 크기와 일정을 고려하여 어느정도 타협하는것도 나쁘지 않다는 생각입니다 🙂 읽어주셔서 감사합니다.

Reference

엔터프라이즈 애플리케이션 아키텍처 패턴 (마틴파울러 저)