도메인 주도 설계로 소프트웨어 만들기

최범균님의 Domain Driven Design(DDD) 입문 강의 후기

Joonghyeon Kim
How we build MyRealTrip
14 min readJun 9, 2020

--

시작하며

우리 개발자들은 늘 소프트웨어를 만들고 있습니다. 이렇게 소프트웨어를 만드는 과정에서 종종 간과하는 부분 중 하나가 바로 “엔지니어 관점에서 소프트웨어를 바라보니 아무래도 기술적인 영역에 좀 더 집중하게 되고, 다른 영역에 대한 것은 놓쳐버리기도 한다”는 점입니다.

물론 소프트웨어에서 기술은 빠질 수 없는 부분이지만, 기술만으로 충분할까요?

이러한 고민에서 지난 2020년 4월 말, 마이리얼트립에서는 도메인 주도 설계(Domain Driven Design, DDD)에 대한 책인 “DDD Start!”의 저자이신 최범균님을 모셔 “DDD 입문” 강의를 진행했습니다.

최범균님의 DDD 입문 강의

ps. 최범균님의 DDD 입문 강의는 정부의 사회적 거리두기 캠페인을 준수하고자 참가자들은 마스크를 쓰고 참여하였고, 온라인으로도 진행되었습니다.

도메인 주도 설계(Domain Driven Design)?

도메인 주도 설계란 무엇인가?

이미 “도메인 주도 설계(Domain Driven Design, DDD)”에 대해서 잘 알고 계신 분들도 있겠지만 저처럼 낯선 분들도 있을 겁니다.

도메인 주도 설계를 풀어서 얘기하자면 “도메인 중심으로 소프트웨어를 설계하는 방법”이라고 할 수 있습니다. 그러면 여기서 “도메인(Domain)”이란 무엇을 의미하는 걸까요?

도메인 주도 설계에서 도메인이란 우리가 소프트웨어로 “해결하고자 하는 문제 영역”을 의미합니다.

온라인 쇼핑 도메인과 하위 도메인들

도메인은 다시 하위의 여러 도메인으로 나누어지곤 합니다. 예를 들면 위 이미지처럼 “온라인 쇼핑” 도메인은 “상품”, “주문”, “회원”과 같은 하위 도메인으로 나뉘고, 이 중 “주문” 도메인은 또다시 “주문자”, “주문 상품”, “배송”과 같은 하위 도메인으로 나눠질 수 있습니다.

이렇게 도메인을 나누는 기준에 어떤 절대적인 기준이 있는 건 아니라고 생각합니다. 상위 수준에서는 좀 더 추상화된 도메인으로, 하위 수준에서는 좀 더 세분화된 도메인으로 나눌 수 있을 겁니다.

도메인 모델(Domain Model)

위에서 얘기한 도메인을 개념적으로 표현한 모델이 바로 “도메인 모델(Domain Model)”입니다. 도메인 모델은 다양한 방식으로 표현할 수 있습니다. 일반적으로 UML(Unified Modeling Language)에서 자주 사용하는 “클래스 다이어그램(Class Diagram)”부터, 필요에 따라 “상태 다이어그램(State Diagram)”이나 “시퀀스 다이어그램(Sequence Diagram)”을 사용할 수도 있고, 아니면 UML이 아닌 다른 방식으로 표현해도 무방합니다.

다만 도메인 모델을 표현할 때 최대한 표현력을 가질 수 있게 단순히 속성만 나열하는 것이 아니라 행위를 통해 도메인 기능을 나타내도록, 그리고 실제로 사용하는 도메인 용어를 사용하도록 해야 합니다.

또한 도메인이란 고정적이지 않기 때문에 도메인도 계속 변하고 도메인 용어도 따라서 변화하기 마련인데요, 도메인 모델 역시 이를 반영하여야 합니다.

이번에 마이리얼트립에서 우리 팀이 새롭게 진행한 프로젝트에서는 도메인 주도 설계를 참고하여 도메인 모델을 표현하기 위해 클래스 다이어그램과 상태 다이어그램을 작성했는데, 이 과정이 처음에 시간이 좀 걸렸지만 결국에는 도메인을 이해하기 위한 도구이자 커뮤니케이션을 효율적으로 하기 위한 도구로써 큰 도움이 되었다고 자부합니다.

한 가지 아쉬운 점이라고 하면, 도메인 전문가분들과의 커뮤니케이션을 통해 실제로 사용하는 도메인 용어를 도메인 모델에 반영해야 했는데 이 과정이 부족했기에 다음에는 도메인 전문가분들과의 커뮤니케이션에 더 노력을 기울여야겠다는 생각이 듭니다.

엔티티(Entity)와 밸류(Value)

도메인 모델은 도메인 객체들로 표현할 수 있는데, 이러한 도메인 객체는 기본적으로 “엔티티(Entity)”와 “밸류(Value)”로 구분할 수 있습니다.

엔티티는 식별성과 연속성을 가지는 객체인데, 좀 더 풀어서 얘기하자면 고유한 식별자로 식별할 수 있으며 자신의 상태와 라이프사이클(Life cycle)을 가지는 도메인 객체입니다.

밸류는 개념적으로 묶을 수 있는 데이터 집합을 표현합니다. 도메인 주도 설계를 몰랐더라도 자주 들었던 “값 객체(Value Object)”라고 부르는 것이 바로 이것입니다. 밸류를 사용하면 각각의 데이터를 단일로 취급할 때보다 표현력을 향상시킬 수 있습니다.

예를 들자면 “주문” 객체에 배송 관련 정보인 “배송지 주소”, “배송지 우편번호”, “수령자 이름”과 같이 개별 속성으로 취급하는 것보다는, “배송 정보”라는 밸류로 모아서 관리하면 표현력이 좀 더 향상되는 것을 느낄 수 있습니다.

더불어 엔티티와 밸류의 메서드(행위)로 기능과 제약을 표현하고, 습관적으로 사용하는 setter/getter 메서드는 지양하라고 최범균님이 언급하셨는데, 아래의 Java 예제 코드를 간단하게 살펴보면 왜 그런 언급을 하셨는지 알 수 있습니다.

주문의 배송을 완료시키는 기능의 간단한 예제 코드(case 1, case 2)

DeliveryCompleteService의 completeDelivery 메서드 내부를 보시면 case 1과 case 2가 있는데요, 어떤 것이 더욱 와닿는 표현이라고 생각하시나요? 저는 case 2이 case 1보다 무엇을 하고자 하는지 그 의도가 비교적 분명하게 느껴지고, case 2처럼 만든 객체가 도메인 기능을 잘 표현하고 있는 도메인 객체라고 생각합니다.

애그리거트(Aggregate)

도메인 모델은 점차 복잡해지기 마련입니다. 서비스가 자랄수록 도메인 역시 함께 자라기 때문입니다. 이렇게 도메인 모델의 복잡도는 점차 증가하기 마련인데, 이러한 복잡도를 관리하기 위해 도메인 객체들의 묶음이자 집합체인 “애그리거트(Aggregate)”가 필요합니다. 애그리거트를 사용하면 우리가 다루는 도메인 객체를 좀 더 상위 수준으로 추상화할 수 있습니다.

도메인 객체들을 애그리거트로 묶기

아래 내용을 참고하여 도메인 객체들을 애그리거트로 구성할 수 있습니다.

  • 하나의 애그리거트에 포함된 객체들은 도메인 규칙과 요구사항에 따라 함께 취급됩니다.
  • 하나의 애그리거트에 포함된 객체들은 동일하거나 유사한 라이프사이클(함께 생성되거나, 함께 제거되거나, 함께 변경되는 등…)을 가져야 합니다.
  • 하나의 애그리거트에 포함된 객체는 다른 애그리거트에 포함되지 않아야 합니다.
  • 애그리거트는 자기 자신을 관리할 수 있지만 다른 애그리거트를 직접 관리하지는 않습니다.

이러한 애그리거트에는 포함된 객체들의 대표가 되는 “애그리거트 루트(Aggregate root)”가 필요합니다. 애그리거트에는 다수의 객체들이 포함되어 있고 이들은 함께 움직이면서 일관성을 유지해야 하는데, 만약 바깥에서 애그리거트 내부의 객체들에게 직접 접근해서 상태나 속성을 변경해버리면 일관성이 깨져버립니다.

따라서 애그리거트 바깥에서 애그리거트에 직접 접근할 수 있는 곳은 오직 애그리거트 루트 뿐이어야 합니다. 애그리거트 루트가 이러한 창구 역할을 하면서 애그리거트에 포함된 객체들의 일관성을 유지할 수 있습니다.

애그리거트 루트

예를 들어 위의 이미지에서, 주문 애그리거트의 애그리거트 루트인 주문 객체를 통해서 “주문의 배송을 완료한다”라는 기능을 제공하고, 이에 따라 주문 애그리거트 내부에서는 여러 객체들의 상태나 속성이 알맞게 변경될 수 있습니다.

그런데 만약 애그리거트 루트를 접근하지 않고 애그리거트 내부에 있는 객체에 접근하게 한다면 어떻게 될까요? 배송 객체에만 접근해서 상태를 배송 완료 상태로 바꿔버린다면? 다른 객체들은 배송 완료에 대한 어떤 기능을 수행하지 않았지만 배송 객체 혼자 배송 완료 상태가 되어 애그리거트 내의 일관성이 깨지게 될 겁니다. 이런 상황을 피하기 위해 애그리거트 바깥에서는 반드시 애그리거트 루트를 통해서만 접근해야 합니다.

계층형 아키텍처(Layered Architecture)

우리가 아키텍처를 구성할 때 자주 사용하는 전형적인 “계층형 아키텍처(Layered Architecture)”에서는 표현(사용자 인터페이스), 응용, 도메인, 인프라스트럭처 4개 계층으로 구분합니다.

계층형 아키텍처

각 계층에 대해서 간단하게 설명하자면 다음과 같습니다.

  • 표현(사용자 인터페이스) 계층 : 사용자의 요청을 받아서 해석하고, 다른 계층에 처리를 위임하고 처리 결과를 사용자에게 알맞게 변환하여 보내주는 기능을 담당합니다.
  • 응용 계층 : 사용자의 요청을 처리하는 기능이 위치하는 곳입니다. 서비스 중 응용 서비스가 여기에 포함되며, 일반적으로 처리 흐름을 제어하고 실제 도메인 기능은 도메인 객체에게 위임합니다.
  • 도메인 계층 : 도메인 기능을 가진 도메인 객체들이 위치하는 곳입니다. 엔티티나 밸류, 리포지터리, 서비스 중 도메인 서비스가 여기에 포함됩니다.
  • 인프라스트럭처 계층 : 구현 기술이 위치하는 계층입니다. RDBMS 연동이나 메시징 플랫폼, HTTP 통신과 같은 저수준의 기능들을 담당합니다.

계층형 아키텍처에서는 일반적으로 상위 계층이 하위 계층에 의존합니다. 표현 계층은 응용 계층에 의존하고, 응용 계층은 도메인 계층에 의존하는 방식입니다.

DIP(Dependency Inversion Principle)

우리가 다루는 모듈은 “고수준 모듈”과 “저수준 모듈”으로 나눌 수 있습니다. 여기서 고수준 모듈은 의미 있는 단일 기능을 제공하는 모듈, 저수준 모듈은 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현인 모듈입니다.

예를 들어 “배송 알림” 기능은 고수준 모듈의 기능이고, “RDBMS에서 주문의 배송 정보를 조회하고, 주문자에게 메일로 배송 알림 메일을 전송한다”는 저수준 모듈의 기능이라고 할 수 있죠.

계층형 아키텍처를 고수준 모듈과 저수준 모듈에 맞춰서 생각해보면, 고수준 모듈은 응용 계층이나 도메인 계층, 저수준 모듈은 인프라스트럭처 계층이라고 볼 수 있습니다.

흔히 고수준 모듈이 저수준 모듈에 의존하도록 구현하는데, 이 경우 저수준 모듈의 변경이 곧 고수준 모듈의 변경으로 이뤄지곤 합니다.

고수준에서의 “배송 알림” 자체에 변경이 없어도, 저수준인 “메일로 배송 알림 메일을 전송한다”라는 저수준의 기능이 “SMS로 배송 알림 메시지를 전송한다”로 바뀐다면 고수준 모듈에서도 변경이 발생하는 것이죠.

이러한 단점을 극복하기 위해 의존 관계를 역전시켜서 저수준 모듈이 고수준 모듈에 의존하도록 구현하는 것 DIP(Dependency Inversion Principle)라고 합니다.

저수준 모듈이 고수준 모듈을 의존

“배송 알림” 기능을 정의한 “배송 알리미” 인터페이스를 만들고, 저수준 모듈에서 “배송 알리미” 인터페이스를 구현한 저수준 모듈인 “메일 배송 알리미”나 “SMS 배송 알리미”를 만드는 것이죠.

이렇게 저수준 모듈이 고수준 모듈에 의존하도록 바꾸면 저수준 기능인 “메일로 배송 알림 메일을 전송”하던 것이 “SMS로 배송 알림 메시지를 전송”하는 것으로 바뀌더라도 고수준 모듈에서의 변경은 최소화할 수 있습니다.

이때 한 가지 주의사항이 있는데, DIP를 적용하는 목적은 고수준 모듈이 저수준 모듈에 의존하지 않고 반대로 저수준 모듈이 고수준 모듈에 의존하게 하려는 것이기에, 인터페이스를 도출할 때 저수준 모듈의 관점에서 도출하면 안 된다는 것입니다.

저수준 관점에서 인터페이스를 도출하면 안 된다

응용 서비스(Application service)

“응용 서비스(Application service)”는 도메인 객체를 이용하여 사용자의 요청에 알맞는 기능을 처리하고 결과를 반환하는 역할을 합니다. 표현 계층과 도메인 계층을 연결해주는 일종의 창구 역할이라고 볼 수 있습니다.

응용 서비스는 응용 계층에 속하기 때문에 도메인과 관련된 로직이 직접적으로 포함되지 않아야 합니다. 대신 도메인 계층에 포함된 도메인 객체들을 사용하여 도메인 기능을 처리하면서 흐름을 제어합니다. 이렇게 처리 흐름을 제어하는 역할을 하다보니 응용 서비스의 기능은 종종 트랜잭션의 단위가 되기도 합니다.

또한 응용 서비스는 표현 계층에 의존하지 않아야 합니다. 예를 들면 표현 계층의 기술인 HTTP 프로토콜에 대한 것(HttpSession, MultipartFile 등)은 응용 서비스에서 사용되지 않도록 해야 합니다.

응용 서비스의 전형적인 구현을 보자면,

  1. 리포지터리로 사용할 도메인의 애그리거트 루트를 구하고,
  2. 애그리거트 루트의 도메인 기능을 실행하고 처리 흐름을 제어하면서,
  3. 처리 결과를 반환합니다.

응용 서비스에 대해서 최범균님이 언급하신 내용 중 하나는 “응용 서비스의 메서드 파라미터로 필요한 값들을 넘기는 대신 도메인 객체 자체를 넘기는 것은 최대한 지양하자”, 였습니다. 응용 서비스의 메서드 파라미터로 도메인 객체를 사용하다보면 도메인 객체에 원래는 필요하지 않던 속성들을 추가하기 마련이고, 이러한 속성들을 영속화에서 제외하는 경우 이를 위해 별도의 설정을 하는 등의 문제를 야기할 수 있기 때문입니다. 따라서 메서드 파라미터로 도메인 객체가 딱 들어맞는 경우에만 사용할 것을 권장하셨습니다.

또 하나 고민해볼 수 있는 내용으로는 “응용 서비스의 결과로 도메인 객체를 반환하는 것과 조회 전용 객체를 반환하는 것 중 어떤 것이 좋을까”, 입니다. 물론 각자 팀의 표준이나 구현 편의성, 성능 등 여러가지 상황을 고려하면 절대적인 답은 없겠지만 별도의 조회 전용 객체를 만들어 반환하는 편을 추천해주셨습니다.

리포지터리(Repository)

“리포지터리(Repository)”는 애그리거트의 영속성을 처리하기 위해 사용합니다.

리포지터리는 애그리거트 루트 단위로 존재해야 합니다. 애그리거트는 그 자체로 하나의 완전한 집합체이기 때문입니다. 따라서 영속화할 때 애그리거트 루트인 객체뿐만 아니라 애그리거트에 포함된 모든 객체를 함께 영속화해야 합니다. 물론 애그리거트를 저장소에서 조회하는 경우에도 애그리거트 루트와 애그리거트에 포함된 객체들을 전부 가져와야 하며, 삭제하는 경우도 마찬가지입니다.

따라서 “주문 애그리거트”가 있고 애그리거트 루트가 주문 객체라면, 주문 객체에 대한 리포지터리를 만들면 됩니다. 주문 애그리거트에 포함된 다른 객체인 “배송”이나 “주문 상품”, “주문자” 각각에 대해서 리포지터리를 만들 필요는 없습니다. 주문 리포지터리가 주문 애그리거트 전체의 영속성을 관리해주니까요.

최범균님은 JPA의 리포지터리를 사용하여 엔티티 객체를 로딩할 때 연관된 객체들을 기본적으로 EAGER 로딩하고, 필요한 경우에만 LAZY 로딩을 사용하기를 언급하셨습니다.

소감

최범균님의 DDD 입문 강의를 듣고 도메인 주도 설계에 대한 주요 개념들, 그리고 실제로 적용한다면 어떻게 적용할 수 있을지 알아볼 수 있었습니다.

열정적인 강의 현장

강의 내용 중 최범균님의 실제 사례를 예시로 든 내용도 많아서 도메인 주도 설계를 실제로 적용할 때 참고가 될만한 이야기들도 많았고, 코드로 구현해봤을 때 어떤 모습인지도 준비해주신 슬라이드를 통해 간접적으로 경험할 수 있었습니다.

또한 위에서는 다 적지 못했지만, 도메인 서비스, 도메인 이벤트, CQRS, Bounded Context, Context Map 등 더 많은 내용에 대해서도 언급하셨는데 본문의 내용에 전부 담지 못해 아쉬움을 느낍니다.

다만, 도메인 주도 설계가 은탄환(Silver bullet)은 아니라고 생각합니다. 도메인 주도 설계를 적용한다고 해서 반드시 훌륭한 소프트웨어가 주어지는 것도 아닐 겁니다. 그러나 도메인 주도 설계는 도메인 영역(도메인 전문가)과 기술 영역(엔지니어)의 간극을 줄이고 어느 쪽으로도 치우치지 않은 균형을 가진 소프트웨어를 만들기 위해서 필요한 도구 아닐까 하는 생각이 듭니다.

최범균님의 DDD 입문 강의를 듣고 도메인 주도 설계에 대해서 제 나름대로의 느낀 바를 적어보았습니다. 제가 속해있는 Experience 개발팀에서도 도메인 주도 설계에 대해서 많은 관심을 가지고 있는데요, 앞으로 이를 프로덕트에 적용할 수 있는 방법을 함께 고민하고 있습니다.

팀과 회사에 대해 궁금하신 분들은 아래의 페이지를 방문해주세요.

https://career.myrealtrip.com/

--

--