DTO의 사용성과 매핑 전략에 대해

바이너리·2022년 3월 1일
7
post-thumbnail

💡 해당 글 내용과 관련된 애플리케이션 코드는 아래의 내용을 변형해서 구현했습니다.
https://spring.io/guides/tutorials/spring-boot-kotlin/
https://github.com/spring-guides/tut-spring-boot-kotlin


DTO를 왜 사용할까

웹 애플리케이션을 개발하다 보면 상당히 자주 "경계 간 객체들 주고받는 방법"에 대해 고민하곤 합니다.

레이어드 아키텍처를 사용할 때는 주로 아래와 같은 고민을 한 것 같아요.

View로 비즈니스 객체가 노출된다

    @GetMapping("/{slug}")
    fun findOne(@PathVariable slug: String): ResponseEntity<Article> {
        try {
            return ResponseEntity.ok(service.findOneBySlug(slug))
        } catch (e: IllegalArgumentException) {
            throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message)
        }
    }

블로그 애플리케이션에서 slug(글 제목이 kebab-case로 변형된 것)으로 글을 조회하는 API가 있다고 가정하겠습니다.

이 경우 Service 👉 Controller 👉 Client로 Article 객체가 그대로 전달됩니다.

클라이언트에서 localhost:8080/api/article/hello-binary이라는 URL로 요청을 보내면 아래와 같은 응답을 받을 수 있습니다.

{
  "title": "Hello binary",
  "headline": "^0^",
  "content": "I am binary",
  "author": {
    "login": "springbinary",
    "firstname": "binary",
    "lastname": "yun",
    "description": null,
    "id": 1
  },
  "slug": "hello-binary",
  "addedAt": "2022-03-01T17:16:56.475525",
  "id": 3
}

그러면 클라이언트는 자연스럽게 Article에는 어떤 프로퍼티가 있고, author라는 프로퍼티 이름을 가진 user또한 어떤 프로퍼티와 데이터를 담고 있는지 파악할 수 있습니다.

간단하게 구현한 코드라서 크게 문제가 될 부분은 없어 보입니다. 하지만 user 모델이 비밀번호 같은 프로퍼티를 가지게 된다면?

또 사진처럼 클라이언트 UI에서는 author와 관련된 정보는 사용자 ID만 필요한데, 불필요한 정보까지 전부 반환하고 있습니다.

DTO로 래핑하기

RenderedUser, RenderedArticle라는 이름의 DTO 객체를 만들어서 반환해주도록 변경해보겠습니다.

    @GetMapping("/{slug}")
    fun findOne(@PathVariable slug: String): ResponseEntity<RenderedArticle> {
        val article = try {
            service.findOneBySlug(slug)
        } catch (e: IllegalArgumentException) {
            throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message)
        }

        val renderedArticle = article.render()
        return ResponseEntity.ok().body(renderedArticle)
    }
fun Article.render() = RenderedArticle(
    title, headline, content, RenderedUser(author.login), addedAt.format()
)

data class RenderedArticle(
    val title: String,
    val headline: String,
    val content: String,
    val author: RenderedUser,
    val addedAt: String
)

data class RenderedUser(
    val login: String
)

이렇게 할 경우, 위에서와 똑같은 요청을 보내면 아래와 같이 달라진 응답을 받을 수 있습니다.

{
  "slug": "hello-binary",
  "title": "Hello binary",
  "headline": "^0^",
  "content": "I am binary",
  "author": {
    "login": "springbinary"
  },
  "addedAt": "2022-03-01 1st 2022"
}

클라이언트는 화면에 표시하는 데 필요한 정보만 받을 수 있고, 서버 입장에서도 객체의 프로퍼티를 완전히 노출하는 것이 아니라 한 단계 래핑하기 때문에 캡슐화 측면에서 좋습니다.


Data Transfer Object란?

위키피디아에서는 DTO(Data Transfer Object)를 프로세스 간 데이터 전달을 위해 사용하는 객체라고 정의합니다.
주요한 특징으로, 비즈니스 객체(Entity)나 데이터 접근 객체(DAO - Data Access Object)와는 다르게 어떠한 비즈니스 로직도 포함하지 않고 단순 저장/조회만 수행하는 객체라고 합니다.


Hexagonal Architecture에서 DTO를 사용하기

위에서는 레이어드 아키텍처에서 DTO가 필요한 상황에 대해 알아봤습니다.

레이어드 아키텍처는 주로 MVC패턴을 기반으로 구성되기 때문에 Service 레이어에서 사용하는 비즈니스 객체와, View에서 사용하는 값 객체 간 변화를 위해 DTO를 사용합니다.

(레이어드 아키텍처의 더 자세한 사용 범위 대해 알고싶다면 이 링크 참조)

하지만 헥사고날 아키텍처에서는 조금은 다른 관점에서 DTO의 사용을 고려하게 됩니다.


이전 글을 통해 헥사고날 아키텍처는 모든 의존성은 도메인을 향해야 하고, 추상화와 구현을 이용해서 계층 간 결합을 약하게 해서 변경에 유연하게 대응할 수 있는 아키텍처라는 걸 알아봤습니다.

DTO 없이 모든 계층이 같은 모델을 공유하게 된다면, 계층들이 강하게 결합될 것 입니다. 하지만 그렇다고 무작정 DTO를 만들어 사용하면 보일러플레이트(반복적으로 비슷하게 찍어내는) 코드를 너무 많이 만들게 됩니다.

위의 코드에서도 render라는 확장 함수를 정의해서 비즈니스 모델을 DTO로 변환해주는 작업을 거쳤습니다.

그래서 DDD 에서는 여러 가지 매핑 전략이 존재합니다. 매핑 전략이라고 말하고, DTO 사용 범위 전략이라고 말할 수 있습니다.


No Mapping

첫 번째 전략으로는 하나의 도메인 모델을 모든 계층에서 공유하는 것 입니다. 모든 계층이 같은 모델을 사용하면 계층 간 매핑이 필요 없습니다.

장점

  • 유스케이스가 간단한 CRUD로만 이루어져 있을 경우에는 굳이 여러 개의 모델을 둘 필요가 없습니다.
  • 모든 계층이 정확히 같은 구조를 가지고 같은 정보만을 필요로 하면 효과적입니다.

단점

  • 웹/영속성 계층은 모델에 대한 특별한 요구사항이 있을 수 있습니다.
    • ex. 웹 계층은 JSON 직렬화를 위한 어노테이션, 영속성 계층은 ORM 프레임워크와 관련된 어노테이션이 필요할 수 있습니다.
  • 한 곳에서의 변화가 다른 계층까지 전파되기 때문에 단일 책임 원칙을 위배합니다.

매핑하지 않기는 초기 구축 비용 방면에서는 매우 저렴하기 때문에, 초기 시스템에서는 매핑하지 않기 전략을 사용해서 간단하게 구축해도 큰 문제가 없습니다.
시스템이 비대해지면서 행동/유효성 검증 로직이 추가될 경우 다른 매핑 전략으로 변경하는 방식을 선택할 수 있습니다.


Two-Way Mapping

양방향 매핑이란 각 계층이 전용 모델을 가지는 것을 의미합니다. 각 어댑터가 전용 모델을 가지고, 해당 모델을 도메인 모델로 변환하거나 도메인 모델을 변환할 책임을 가지도록 구현합니다.

장점

  • 각 계층이 전용 모델을 변경하더라도 다른 계층에 영향이 없습니다.
  • 웹이나 영속성에 관한 코드로 오염되지 않은 순수한 도메인 모델을 만들 수 있습니다.
  • 매핑의 책임이 명확합니다.
    • 바깥쪽 계층/어댑터는 안쪽 계층의 모델로 매핑하고 다시 반대 방향으로 매핑합니다.
    • 안쪽 계층은 해당 계층의 모델만 알면 되고 매핑 대신 도메인 로직에 집중할 수 있습니다.

단점

  • 과도하게 많은 보일러플레이트 코드가 생깁니다.
    • ModelMapper와 같은 매핑 프레임워크를 사용하더라도 구현의 번거로움을 완전히 해소해주지는 않습니다.
    • 내부 동작이 제네릭과 리플렉션을 사용할 경우 로직을 디버깅하기 어렵습니다.
  • 도메인 모델이 계층 경계를 벗어나 변경에 취약해질 수 있습니다.

Full Mapping

이 매핑 전략에서는 각 연산마다 별도의 입출력 모델을 사용합니다.
즉, 웹 어댑터와 애플리케이션 계층에 있는 모든 클래스/유스케이스가 자신의 전용 모델을 가지고 각 연산을 실행하는 데 필요한 모델로 매핑하는 전략입니다.

장점

  • 여러 유스케이스에 걸쳐있지 않아서 구현 및 유지보수가 쉽습니다.

단점

  • 양방향 매핑보다 훨씬 많은 매핑 코드를 필요로 합니다.

이 매핑 전략은 전역적으로 서비스에 적용하기에 추천되지 않습니다. 인커밍 어댑터(웹 계층)와 애플리케이션 계층 사이에서 상태를 변경할 때 가장 유용하며, 애플리케이션 계층과 영속성 계층 사이에서는 오버헤드가 크게 발생해서 사용하지 않는 것이 좋습니다.


One-Way Mapping

이 전략은 상태 인터페이스를 이용해서 기존의 도메인 모델을 추상화하고, 각 계층이 해당 추상화된 모델을 공유한 뒤 다른 계층으로부터 전달받을 때 단방향으로 매핑하는 전략입니다.
모든 계층은 다른 계층으로부터 객체를 받을 때 자신이 이용할 수 있도록 매핑하면 됩니다. 그래서 매핑의 대상이 한 인터페이스로 고정되어 있어서 단방향 매핑 전략이라고 부릅니다.

장점

  • 계층 간 모델이 비슷할 때 매핑을 생략할 수 있어 효과적입니다.

단점

  • 매핑이 계층을 넘나들며 퍼져있기 때문에 다른 전략에 비해 개념적으로 이해하기 어렵습니다.

전역적으로 한 매핑 전략을 고수하려 하지 말고, 계층별로 필요하다고 판단되는 전략을 적용해야 할 것입니다. 전략을 이용하는 개발자가 유스케이스에 걸맞는 적절한 전략을 선택할 수 있어야 합니다.

팀 내에서 합의할 수 있는 가이드라인을 정해 선택하고, 왜 해당 전략이 최우선적으로 적용되었는지 설명할 수 있어야 시간이 지나도 유효하게 평가받을 수 있습니다.

상황별로 매핑 가이드라인을 정하는 것은 어렵고 많은 커뮤니케이션을 요구하지만, 코드가 어떤 일을 정확히 수행해야 하는 지 따르기만 하면 되기 때문에 유지보수 하기 쉬운 코드로 보상받을 수 있습니다.


소개한 매핑 전략들은 모두 장단점이 명확합니다. 책에서는 "은총알은 없다 - 어떤 엔지니어링 상황에서도 완벽하게 잘 들어맞는 해결책은 없다"라고 표현합니다.

저는 이 말이 모든 개발자가 쉽게 빠질 수 있는 맹목적 믿음을 방지하기 위해 항상 숙지해야 하는 말이라고 생각합니다.

매핑 전략에 정해진 컨벤션이나 철칙은 없으며, 어떤 전략도 그렇게 여겨져서는 안 됩니다.


참고 자료

profile
01101001011010100110100101101110

1개의 댓글

comment-user-thumbnail
2022년 11월 21일

잘 읽고 갑니다.

답글 달기