DDD 했더니 비대해지는 엔티티, 좋은 대책은 ?

web | 24 March 2020

Tags | cleanarchitecture ddd architecture boundedcontext

혹시 DDD (Domain Driven Development) 나 클린 아키텍처를 들어본 적 있으십니까? 만약 당신이 개발자라면 서점의 컴퓨터 관련 서적이나 페이스북 커뮤니티, 혹은 기술 블로그 등에서 한두 번쯤 들어보셨을 수 있습니다. 그럼 더 나아가서, DDD 나 클린 아키텍처를 프로젝트에 적용해보신 적이 있나요?

DDD/Untitled%201.png

DDD (우리말로는 “도메인 주도 개발”)과 클린 아키텍처에서 비즈니스 애플리케이션은 크게 세 계층으로 이루어집니다. 바로 Pesenter (표현 계층) , UseCase (유즈 케이스 계층) , 그리고 Entity (엔티티 계층) 인데요. Presenter는 비즈니스 애플리케이션의 귀와 입으로, 비즈니스 애플리케이션의 사용 명세라고 할 수 있습니다. 만약 MVC 구조를 따르는 웹 애플리케이션이라면 Controller 계층과 View 계층이 해당 계층에 속한다고 할 수 있겠네요.

UseCase 계층은 말 그대로 “유즈 케이스”를 정의하고 구현합니다. 유즈 케이스는 “사용자 행위 명세” , “유저 시나리오”와 같은 뜻으로 풀이될 수 있습니다. 만약 당신의 애플리케이션이 쇼핑몰 서버라면, “사용자가 주문하기” 나 “판매자가 주문의 배송 상태를 변경하기” 등의 행위 등이 유즈 케이스가 될 것입니다. Service 레이어라는 이름으로 흔히 부릅니다.

Entity 계층은 애플리케이션의 비즈니스 로직을 담는 비즈니스 객체로 이루어져 있습니다. 비즈니스 객체는 엔티티, 도메인 객체 등의 이름으로도 불리고요. 다시 쇼핑몰 서버 애플리케이션을 예로 들면, “주문”이라는 엔티티 안에는 당신의 쇼핑몰 서비스에서 주문 관련한 로직들과 상태들을 거의 모두 가지고 있어야 합니다.

DDD와 클린 아키텍처에서는 UseCase와 Entity 레이어를 작성할 때, Entity 레이어의 “비즈니스 객체”에 최대한 많은 비즈니스 룰을 담아, 응집성을 높이고 중복을 줄이는 코드를 작성하라고 말합니다. 만약 그렇게 작성하지 않으면 어떻게 될까요? UseCase 레이어의 코드는 방대해지고, 각기 다른 유즈 케이스에서 사용되는 동일한 로직들의 중복과 파편화가 증가하고, 코드 응집성은 줄어들어 유지보수가 어려울 것입니다.

아래의 사진은 유명한 Use Case 명세표입니다. 그리고 굵은 글씨로 쓰여있는 것은 클린 아키텍처에서 “엔티티 레이어에 작성하라” 고 권하는 로직입니다. 핵심 비즈니스 룰이기 때문에, 여러 유즈 케이스에서 공통적으로 쓰일 것이기 때문이죠.

만약 굵은 글씨의 비즈니스 룰들이 엔티티가 아닌 서비스 레이어에 드러났다면 어땠을까요? 아래 예시는 ATM 인출 유즈 케이스인데요. 만약 이를 작성한 한참 이후에 다른 개발자가 ATM 입금 유즈 케이스를 개발하려고 한다면, 해당 ATM 인출 로직에 사용되는 “사용자 Valid 검증”, “사용자 PIN 번호 체크” 등을 모두 재 구현해야 할 것입니다. (혹은 재 구현하기 싫어서 리펙토링으로 응집성이 높아지는 구조로 개선하겠죠.) 유지보수 시에나 신기능 개발 시에나 적지 않은 개발 비용으로 돌아올 것입니다. 그 개발 비용은 누적되어서 기업 전체의 성과를 좌지우지할 것입니다!

DDD.jpg

Entity 계층에 도메인 룰을 최대한 모으면 좋은 점은 이뿐만이 아닙니다. 대부분의 언어에서 클린 아키텍처 패턴을 구현하면 Entity 객체는 Plain 객체로 작성합니다. (java에서는 POJO라고 부릅니다.) Plain 객체란 순수히 해당 언어의 클래스 문법으로만 작성되고, 그 어떤 프레임워크나 외부 종속 없이도 테스 터블 한 객체입니다. 이런 Plain 객체는 테스트하기 매우 용이해서, 더 많은 테스트 코드를 작성하게 하는 순기능이 있습니다.

그래서 우선…

  1. 클린 아키텍처나 DDD를 적용해보지 않으셨다면, 관련된 글을 읽어보시고 주장하는 패턴들을 이해하신 후 조금씩 적용해보시길 추천드립니다.
  2. 클린 아키텍처와 DDD를 하고 있는데, 엔티티 계층은 날씬하고 서비스 계층이 비대하시다면, 서비스 계층의 로직들을 최대한 엔티티 계층으로 끌어내려보시는 걸 추천드립니다. 대부분의 서비스 계층은 결국 엔티티 객체들을 불러와서 메서드들을 호출하는 역할만으로도 충분하다는 것을 발견하실 거예요. 그리고 로직의 응집성이 엄청 상승하는 것을 느끼실 겁니다.

제가 일하는 개발팀에서도 1번과 2번을 잘 실천하고 있습니다. (쇼핑몰 시스템을 만들고 있습니다.) 물론 처음부터 잘하던 건 아니었어요. 처음에는 클린 아키텍처나 DDD 패턴에 대한 학습이 부족해, 서비스 계층에 절차적으로 로직을 풀어쓰기 다반사였습니다. 그러나 차츰차츰 응집성 있게 엔티티를 살찌워나갔고 (?), 그 결과 서비스 계층은 날씬해지고 코드 응집성은 크게 높아졌어요.

그런데 커머스 시스템이 담당해야 하는 기능이 많다 보니, 계속 새로운 기능을 개발할 때마다 엔티티 객체 파일이 점점 1000줄, 심하면 2000-3000줄까지 늘어나기 시작했습니다. 여러 상황에 쓰이는 도메인 객체일수록 더 심했어요. 예를 들어 “주문” 도메인은 “사용자의 주문 신청”에 필요한 로직, “판매자의 정산 관리”에 필요한 로직, “주문의 반품 환불 처리”에 필요한 로직 등 너무 다양한 상황에 필요한 로직을 모두 담다 보니, 로직의 응집성은 좋아지고 중복은 줄었지만 불편한 점이 이만저만이 아니었습니다. 주문 엔티티가 너무 커서 원하는 메서드를 탐색하기가 점점 어려워졌고, 여러 명이 하나의 거대 파일을 함께 수정하다 보니 버전 관리 도구 (Git)에서 충돌이 잦아졌습니다.

/**
* 3000줄 짜리 주문 엔티티 객체. 하나의 엔티티 객체에 반품, 환불, 정산, 출고, 주문내역 조회 
* 등 모든 책임이 집중되어있다. "주문" 이라는 관심사로 모으기 시작했지만, 너무 방대해져서 하위 
* 관심사들로 나눌 수 있게 됨. 
*/
@Entity
class Order {

  // ... 여러 관심사에 필요한 속성들이 모두 정의됨
  
  /**
  * 판매자가 주문을 출고한다
  */
  public Export doExport() {

  }

  /**
  * 유저가 주문을 반품한다
  */
  public OrderReturn requestReturn() {
    
  }

  /**
  * 월말에 주문을 정산한다. 수정되면 안되는 중요한 코드.
  */
  public Price calculateAccountPrice() {

  }

  // ... 여러 관심사에 필요한 메서드들이 모두 정의됨 
}

한동안 마땅한 해결책 없이, 계속 엔티티 객체는 커져만 갔습니다. 그러던 어느 날, 상품의 세일 이벤트 시스템 프로젝트 개발을 시작해야 하는 날이 왔습니다. 상품 운영팀 분들이 상품의 세일 기간과 세일 가격을 예약하고, 해당 시점에 상품의 가격이 할인되는 기능인데요. (이 외에도 세부적인 기능들이 많지만 대표 기능은 그렇습니다) 해당 기능의 비즈니스 로직을 작성하기 위해 여느 때와 같이 “상품” 엔티티 파일을 열었는데, 그 순간 멈칫했습니다. ‘이미 3000줄을 넘어가는 이 클래스 파일에, 앞으로 세일 관련 로직을 1000줄 더 추가하면 감당이 안될 것이다!’

그때 다짐했습니다. “이렇게 비대한 엔티티는 DDD와 클린 아키텍처를 주창하신 선배님들이 원하던 바가 아닐 것이다. 다시 DDD와 클린 아키텍처에서 해답을 찾아오리라!” 그리고는 ‘엔티티’, ‘분리’, ‘응집성’, ‘관심사’ 등의 키워드로 열심히 서칭을 했습니다.

문제는 “관심사의 분리” 였다.

프로그래밍을 하다 보면, 과거 학부 시절 혹은 이론 공부 시절에 ‘에이 코드 짜는 데에 이런 게 중요하겠어?’ 하던 것들이 부메랑처럼 돌아와 “아! 중요한 것이었구나” 하는 것들이 있습니다. 이번에도 저는 또 기초 이론의 중요성을 통감했습니다. 결국 거대해진 엔티티의 원인은 “관심사의 분리 가 덜 되었기 때문”이었습니다. 관심사의 분리는 객체지향 설계 5원칙인 SOLID의 첫 번째에서도 강조되고, 소프트웨어 공학의 진리 정언처럼 여겨지는 ‘응집력은 높이고, 결합도는 낮춰라’라는 말과 관련이 깊죠. ‘관심사의 분리’는 하나의 객체는 여러 관심사를 책임지면 안 된다는 아주 간단하고도 중요한 원칙입니다.

DDD에서는 이미 제게 하나의 도메인 안에서도 관심사를 분리하여 객체를 잘게 쪼개는 패턴을 소개해 주었습니다. 바로 “Bounded Context” 개념입니다. 다만 제가 처음 그 방법론을 읽었을 때, 받아들일 준비가 안되어있을 뿐이었죠. 필요성을 느끼고 다시 찾아가 읽으니 이해가 확 되더군요.

Bounded Context는 “같은 도메인이어도, 사용되는 맥락이 다르면 엔티티를 별도로 매핑하라”라는 원칙입니다. 제가 아까 상품 엔티티에 세일 관련 로직 1000줄을 추가하려고 했다고 했는데, 이때 “세일 콘텍스트”라는 패키지를 만들고, 그 안에 세일 관심사에 필요한 로직을 구현한 상품 엔티티를 새로 작성하면 관심사의 분리도 더 잘 되고 응집도도 높아집니다. 알고 나니 너무 당연하고 속이 시원한 방법이었습니다.

DDD/Untitled%202.png

위에서 작성한 주문 엔티티를 Bounded Context 별로 따로 만들면, 각 객체의 책임이 더 명확해집니다.

/**
* 출고 맥락에서의 주문
*/
@Entity("OrderForExport")
class Order {
  
  private List<Export> exports;


  /**
  * 판매자가 주문을 출고한다
  */
  public Export doExport() {

  }
}

/**
* 정산 맥락에서의 주문
*/
@Entity("OrderForAccount")
class Order {
  
  private Account account;

  /**
  * 월말에 주문을 정산한다. 수정되면 안되는 중요한 코드.
  */
  public Price calculateAccountPrice() {

  }

}

/**
* 반품 맥락에서의 주문
*/
@Entity("OrderForReturn")
class Order {
  
  private List<OrderReturn> returns;

  /**
  * 유저가 주문을 반품한다
  */
  public OrderReturn requestReturn() {
    
  }
}

걱정. 코드 중복 오히려 생기지 않을까?

예전에 어렴풋이 Bounded Context 개념을 처음 봤을 때의 감정은 아마 이런 것들이었던 것 같습니다.

“똑같은 도메인을 담당하는 객체를 하나가 아니라 여러 개를 만든다고? 그럼 코드의 중복이 늘어나는 것 아니야? 관리 비용이 너무 늘 것 같은데?”

간단한 예로 상품의 할인율 계산 로직을 생각해보자면, 할인율 = 판매가/정상가 * 100인데, 이 로직은 어느 맥락에서나 상품이라면 거의 모두 사용되기 때문에, 상품 객체를 N개 만들면 각각의 객체에 N 번 작성해야 하죠.

네, 일부 로직들의 중복이 증가하는 것은 사실입니다. 그뿐만 아니라 동일한 엔티티 속성을 N번 정의하게 되므로 전체 프로젝트의 코드의 양이 늘어나게 되죠. 그럼에도 Bounded Context 개념은 적용해볼 만한 가치가 있습니다. 프로젝트가 커지고 하나의 도메인 영역에서 여러 맥락의 일들이 일어나고 있으면, 일부 로직의 중복 증가에서 오는 비용보다, 관심사의 분리와 응집성 분리에서 오는 효용이 훨씬 큽니다. 이는 분리를 해보고 나니까 확실히 알겠더라고요.

아래는 프로젝트에 직접 적용해보면서 느꼈던 Bounded Context의 장점들입니다.

  1. “정산” 등 수정에 민감한 중요한 로직도 안전하게 분리하고 관리할 수 있다.
  2. 맥락 안에서의 도메인 로직들이 더 응집성 있게 모여서, 해당 맥락 한판을 이해하는데 더 도움이 된다.
  3. 같은 도메인이어도 맥락마다 참조를 필요로 하는 연관 도메인들이 다른데, 맥락에 필요한 관계만 최소한으로 매핑할 수 있어 N+1 문제 등 성능 문제가 다소(?) 해결된다.
  4. 같은 도메인이어도 맥락마다 사용되는 메서드가 매우 다르고, 모두 필요한 Global 한 도메인 로직은 생각보다 많지 않다. Context 분리로 얻는 효용이 일부 로직의 중복보다 효용이 크다.

아래는 그럼에도 Bounded Context의 고려해야 할 점입니다.

  1. Bounded Context는 불가피하게 Global 한 도메인 로직의 중복이 발생한다. (ex. 할인율 계산) 팀 규모가 어느 정도 커서 Global 로직의 중복 비용을 감당할 수 있을 때 나누는 것이 좋다.
  2. 팀 규모와 맞는 적절한 Bounded Context 민감도를 정하는 것이 중요해 보인다. 팀이 감당할 수 없을 만큼 잘게 Bounded Context를 나누면, (극단적으로 5명의 개발팀이 상품 도메인 객체를 관심사별로 10개 나눔) 효용보다 비용이 커진다.

CQRS 도 크게 보면 맥락의 분리

CQRS는 Command and Query Responsibility Segregation, 즉 “명령과 조회의 책임 분리”를 통해 관심사 분리를 달성하는 소프트웨어 패턴입니다. 이 또한 DDD의 Bounded Context 개념과 함께 버무려 사용할 수 있습니다.

상품 도메인에서, “판매자의 상품 관리” 맥락용 상품 엔티티를 분리해 관리하고 있다고 가정해봅니다. 처음에는 판매자가 상품 리스트나 상품 상세를 조회하는 역할과, 판매자의 상품 수정 및 관리 기능들을 모두 담당했습니다. 그러나 점점 ‘판매자 상품 조회’와 ‘판매자 상품 수정 관리’의 책임과 범위가 커지면, 이를 분리하는 것이 좋을 것입니다. 이때 CQRS 패턴에 따라 Bounded Context를 ‘조회’와 ‘명령(수정 및 관리)’의 엔티티를 분리하면 더 높은 응집성을 가진 두 객체로 나뉠 것입니다.

혹은 ‘조회’에 ORM 보다 성능이 나은 쿼리 기반의 로직이 필요하다면, CQRS를 통해 둘을 분리할 수도 있겠죠.

그래서, 지금 무엇을 해야할까

그래서, 지금 우리 프로젝트에 무엇을 하면 좋을까요? 각자의 상황에 따라 다를 것입니다. 위에서 말씀드린 내용을 일부 반복하겠습니다.

  1. 클린 아키텍처나 DDD를 적용해보지 않으셨다면, 관련된 글을 읽어보시고 주장하는 패턴들을 이해하신 후 조금씩 적용해보시길 추천드립니다.
  2. 클린 아키텍처와 DDD를 하고 있는데, 엔티티 계층은 날씬하고 서비스 계층이 비대하시다면, 서비스 계층의 로직들을 최대한 엔티티 계층으로 끌어내려보시는 걸 추천드립니다. 대부분의 서비스 계층은 결국 엔티티 객체들을 불러와서 메서드들을 호출하는 역할만으로도 충분하다는 것을 발견하실 거예요. 그리고 로직의 응집성이 엄청 상승하는 것을 느끼실 겁니다.
  3. 엔티티에 비즈니스 로직 몰빵은 잘하고 있는데, 엔티티가 너무 커지고 관심사별로 분리가 잘 안된다고 느끼시나요? Bounded Context 개념을 적용해보세요. 같은 도메인이어도 관심사가 다르면 더 하위의 도메인으로 나뉘어야 합니다.

📢 ( 광고 ) 스타일쉐어 커머스 시스템을 함께 만드실 백엔드 개발자 동료분을 찾고 있어요

제가 몸담고 있는 스타일쉐어의 커머스 개발팀은 일하는 동료들 모두가 “어떻게하면 함께 문제를 더 잘 해결할까?” 를 함께 고민하는 팀이라고 자부합니다. 개발자 개개인에게 많은 의사 결정 권한이 주어지고, 또 그만큼 개발적 의사 결정에 대한 설득과 그 결과에 대한 책임을 고민할 수 있는 성장하기 좋은 일터입니다.

함께 커머스 도메인에 존재하는 수많은 문제의 해결을 위해 고민할 팀원을 찾고 있습니다. 많은 지원 부탁드려요 🙂

(편하게 여쭤보고 싶은 점이 있으시다면 제 개인 메일인 shinjayne@gmail.com 로 가볍게 연락 주셔도 괜찮습니다!)

스타일쉐어는 대한민국 1525 인구의 절반 이상이 사용하는 No.1 패션 서비스 StyleShare를 운영하는 회사입니다.

스타일쉐어는 이전의 e커머스 회사들과는 차별화된 서비스를 제공하며, 변화하는 시대에 맞춰 M·Z 세대의 최적화된 패션·뷰티 쇼핑 채널로 거듭나고 있습니다.

높은 충성도와 19%에 달하는 구매 전환율을 기반으로 2018년 12월 기준 연 거래액 1,200억(29CM 연결 기준)을 달성했습니다. 2018년 3월에는 온라인 셀렉트샵 ‘29CM’을 인수하여 10대부터 30대까지를 아우르는 패션 커머스 기업으로 거듭났습니다.

글을 쓰기 전 참고한, 함께 읽어보면 좋은 글.