도메인 주도 개발 시작하기
가장 쉽게 배우는 도메인 주도 설계 입문서. 도메인 주도 설계(DDD)를 처음 배우는 개발자를 위한 책이다. 실제 업무에 DDD를 적용할 수 있도록 기본적인 DDD의 핵심 개념을 익히고 구현을 통해 학
www.aladin.co.kr
(책에 있는 문장이 아닐 수 있음... 편집 있을 수 있음...)
Chapter 1. 도메인
- 도메인 = 해결하고자 하는 문제 영역
- 전문가나 관련자가 요구한 내용이 항상 올바른 것은 아니며 때론 본인들이 실제로 원하는 것을 정확하게 표현하지 못할 때도 있다. 대화를 통해 진짜로 원하는 것을 찾아야 한다.
- 도메인에 따라 용어 의미가 결정되므로 도메인마다 각각의 다이어그램에 모델링해야 한다.
- 처음부터 완벽한 개념 모델을 만들기보다 전체 윤곽을 이해하는 데 집중하고, 구체화 시켜 간다.
- 문서화 해라. 전반적인 기능 목록, 모듈 구조, 빌드 구조는 코드보다 문서를 참조하는 것이 빠르다.
- 코드도 문서임을 기억해라. 근데 코드는 상세 내용이라 이해하는 데 시간이 많이 걸린다.
- 도메인 용어에 알맞는 단어를 찾는 시간을 아까워하지 말자.
Chapter 2. 아키텍처
- 고수준과 저수준을 구분하고, 고수준이 저수준에 의존하지 않도록 해라(DIP). 구현 교체과 테스트를 생각하라. 그러면 인터페이스를 쓰게 된다.
- DIP를 단순히 인터페이스와 구현 클래스를 분리하는 정도로 받아들일 수도 있다. 분리가 핵심이 아니고 의존성을 바꾸는 게 핵심이다. 혹시 저수준 모듈에서 인터페이스를 추출하고 있지 않은가? 관점을 바꿔라. 고수준 모듈 관점에서 추상화하고 도출한다.
- 응용서비스, 인프라스트럭처 영역이 도메인 영역에 의존한다.
- 그렇다고 DIP를 항상 적용할 필요는 없다. 완벽한 DIP보다 구현 기술에 의존적인 코드(e.g. JPA)를 도메인에 일부 포함하는 게 효과적일 때도 있다. 구현의 편리함은 변경의 유연함만큼 중요하다.
- 도메인 영역의 주요 구성요소: Entity, Value, Aggregate, Repository, Domain service
- DB 테이블의 엔티티와 도메인 모델의 엔티티는 동일하지 않다. 도메인 모델의 엔티티는 데이터와 함께 도메인 기능을 제공한다. 단순히 데이터를 담는 데이터 구조가 아니라 기능을 제공하는 객체이다.
- 애그리거트는 관련 객체를 하나로 묶은 그룹이다. 루트 엔티티를 갖는다. 루트 엔티티는 애그리거트의 기능을 제공한다. 사용자는 루트 엔티티를 통해서 간접적으로 애그리거트 내의 다른 엔티티에 접근한다.
- 리포지터리는 애그리거트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의한다.
- 응용서비스는 도메인 모델을 이용해서 기능을 구현한다. 트랜잭션을 관리한다.
- 인프라스트럭처는 표현영역, 응용영역, 도메인영역을 지원한다. 다른 영역에서 필요로 하는 프레임워크, 구현 기술, 보조 기능을 지원한다. 도메인영역, 응용영역이 정의한 인터페이스를 인프라스트럭처 영역에서 구현한다.
- 패키지 구성: 도메인이 크면 하위 도메인으로 나누고, 도메인 별로 모듈을 구성해 그 안에 ui → application → domain ← infrastructure 패키지를 구성한다. 도메인 모듈은 다시 도메인에 속한 애그리거트를 기준으로 다시 패키지를 구성한다. 정해진 규칙은 없다. 코드를 찾을 때 불편한 정도만 아니면 된다. (저자는 한 패키지에 10~15개 미만으로 유지하려고 노력한다고 한단다.)
Chapter 3. 애그리거트
- 세부적인 모델만 이해한 상태로는 코드를 수정하는 것이 꺼려지기 때문에 코드 변경을 최대한 회피하는 쪽으로 요구사항을 협의하게 된다.
- 복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만드려면 상위 수준에서 모델을 조망할 수 있어야 한다.
- 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 갖는다.
- 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다.
- 'A가 B를 갖는다'로 해석할 수 있는 요구사항이 있다고 하더라도 이것이 반드시 A와 B가 한 애그리거트에 속한다는 것을 의미하는 것은 아니다. (e.g. 상품과 리뷰)
- 큰 애그리거트로 보이는 것들이 많지만, 도메인에 대한 경험이 생기고 도메인 규칙을 제대로 이해할수록 애그리거트의 실제 크기는 줄어든다. (저자의 경험에서는 다수의 애그리거트가 한 개의 엔티티 객체만 갖는 경우가 많았으며 두 개 이상의 엔티티로 구성되는 애그리거트는 드물었다.)
- 애그리거트 루트는 애그리거트의 전체를 관리하는 주체이다. 애그리거트 루트는 애그리거트의 일관성이 깨지지 않도록 한다.
- 애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하면 안 된다. set 메서드는 공개하지 않으며, 밸류 타입은 불변으로 구현한다. 불변으로 구현할 수 없다면 접근제한자를 조정해서 외부 실행을 제한한다.
- 애그리거트 루트는 구성요성의 상태를 참조하기도 하고, 기능 실행을 위임하기도 한다.
- 트랜잭션 범위는 작을수록 좋다.
- 한 트랙잭션에서는 한 개의 애그리거트만 수정해야 한다. 애그리거트는 최대한 서로 독립적이어야 한다.
- 만약 부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면 애그리거트에서 다른 애그리거트를 직접 수정하지 말고 응용서비스에서 두 애그리거트를 수정하도록 구현한다.
- 도메인 이벤트를 사용하면 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하는 코드를 작성할 수 있다.
- 애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다. Order와 OrderLine을 각각의 테이블에 저장한다고 해서 각각의 리포지터리를 만들지 않는다.
- 리포지터리는 애그리거트 전체를 저장소에 영속화해야 한다. 동일하게 애그리거트를 구하는 리포지터리 메서드는 완전한 애그리거트를 제공해야 한다.
- 애그리거트에서 다른 애그리거트를 참조한다는 것은 다른 애그리거트의 루트를 참조한다는 것과 같다.
- 필드를 이용한 애그리거트 참조는 탐색 오용, 성능에 대한 문제, 확장의 어려움을 야기한다.
- ID를 이용해서 다른 애그리거트를 참조한다. 응용서비스에서 필요한 애그리거트를 로딩한다.
- ID 참조 방식을 사용하면서 N+1 문제가 발생하지 않도록 하려면 조회를 위한 별도 DAO를 만들어서 조인을 이용해 한 번의 쿼리로 필요한 데이터를 로딩한다.
- 애그리거트마다 서로 다른 저장소를 사용하면 한 번의 쿼리로 관련 애그리거트를 조회할 수 없다. 이 때 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다.
- 개념적으로 1:N 연관이 있더라도 성능 문제 때문에 실제 구현에 반영하지 않는다. N:1로 연관 지어 구현한다.
- 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면 애그리거트에 팩토리 메서드를 구현하는 것을 고려한다. (e.g. Store가 Product를 생성한다.)
Chapter 4. 리포지터리
- 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포지터리를 구현한 클래스는 인프라스트럭처 영역에 속한다.
- 스프링 데이터 JPA는 지정한 규칙에 맞게 리포지터리 인터페이스를 정의하면 리포지터리를 구현한 객체를 알아서 만들어 스프링 빈으로 등록해 준다.
- 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다. 루트 엔티티 외에 또 다른 엔티티가 있다면 진짜 엔티티인지 의심해 봐야 한다. 단지 별도 테이블에 데이터를 저장한다고 해서 엔티티인 것은 아니다.
- 엔티티인지 밸류인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것이다. 테이블의 식별자는 애그리거트의 식별자와 동일한 것이 아니다.
- @OneToMany의 clear()는 먼저 select 쿼리로 대상 엔티티를 로딩하고 각 엔티티에 대해 delete 쿼리를 실행한다. @Embeddable의 clear()는 컬렉션의 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제를 수행한다.
- 애그리거트 영속성 전파 속성(cascade)을 사용해 애그리거트를 저장하고 삭제할 때 하나로 처리되도록 한다.
- DIP는 저수준의 변경에 고수준이 영향을 받지 않도록 하기 위함이다. 하지만 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다. 변경이 거의 없는 상황에서 변경을 미리 대비하는 것은 과하다.
Chapter 5. 스프링 데이터 JPA를 이용한 조회 기능
- 다양한 검색 조건 조합 https://docs.spring.io/spring-data/jpa/reference/jpa/specifications.html
- 페이징 처리 https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Pageable.html
Chapter 6. 표현영역과 응용영역
- 표현 영역은 사용자의 요청을 받아 응용 서비스가 요구하는 형식으로 사용자 요청을 변환하고, 결과를 알맞는 형식으로 제공한다.
- 응용 서비스는 사용자가 원하는 기능을 제공한다. 리포지터리로부터 도메인 객체를 가져와 사용한다. 도메인 객체 간의 흐름을 제어한다. 트랜잭션 처리를 담당한다.
- 응용 서비스의 크기는 얼마나 되어야 하는가? 보통 두 가지 중 하나다. 한 응용 서비스에 도메인의 모든 기능 구현하기 vs 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기. (저자는 후자를 선호한다.)
- 응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 있지만 도메인의 로직 실행을 응용 서비스와 표현 영역에 분산시켜 코드의 응집도를 낮추는 원인이 된다. 응용 서비스가 표현 영역에서 필요한 데이터만 리턴하는 것이 좋다.
- 응용 서비스의 파라미터 타입은 표현 영역과 관련된 타입이면 안 된다. (e.g. HttpServletRequest)
- 표현 영역은 쿠키나 세션을 이용해서 사용자의 연결 상태를 관리한다.
- 표현 영역은 필수 값, 값의 형식, 범위 등을 검증한다. 응요 서비스는 데이터의 존재 유무와 같은 논리적 오류를 검증한다.
- 표현 영역에서 인증된 사용자인지 검사한다. (by 서블릿 필터, 스프링 시큐리티)
- 조회 화면을 위한 조회 전용 모델과 DAO를 만들면 서비스 코드가 단순히 조회 전용 기능을 호출하는 형태로 끝날 수 있다. 조회 전용에는 트랜잭션이 필요하지도 않다. 이 경우 굳이 서비스를 만들지 않고 표현 영역에서 바로 조회 전용 기능을 사용해도 괜찮다.
Chapter 7. 도메인 서비스
- 한 애그리거트로 기능을 구현할 수 없는 경우 별도 서비스로 구현한다. (e.g. 결제 금액 계산 로직)
- 도메인 서비스는 상태 없이 로직만 구현한다.
- 도메인 서비스 사용 주체는 애그리거트가 될 수도 있고 응용 서비스가 될 수도 있다.
(책의 예시에서는 도메인 메서드에 도메인 서비스를 인자로 주었다. 반대로 도메인 서비스 메서드에 도메인 모델을 인자로 주기도 했다. 후자가 났다고 생각한다. 도메인 모델이 도메인 서비스에 의존하는 것은 잘못된 의존성이라고 생각.) - 도메인 서비스 객체를 애그리거트에 주입하지 않는다.
- 도메인 서비스는 도메인 로직을 수행하지 응용 로직을 수행하진 않는다. 트랜잭션 처리와 같은 로직은 응용 로직이므로 응용 서비스에서 처리해야 한다.
- 특정 기능이 응용 서비스인지 도메인 서비스인지 헷갈리 때는 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트의 상태 값을 계산하는지 검사해본다. = 도메인 로직. 도메인 로직이면서 한 애그리거트에 넣기에 적합하지 않으면 도메인 서비스로 구현한다.
- 외부 시스템이나 타 도메인과의 연동 기능도 도메인 서비스가 될 수 있다. 시스템 간 연동은 HTTP API 호출로 이뤄질 수 있지만 A 서비스 입장에서는 도메인 로직으로 볼 수 있다(e.g. 사용자 권한 확인). 이 때 도메인 로직 관점에서 인터페이스를 작성한다. (requestUserRole()처럼 시스템 연동 관점이 아니라)
- 도메인 서비스는 도메인 로직을 표현하므로 다른 도메인 구성요소와 같은 패키지에 위치한다.
- 도메인 서비스의 개수가 많거나 엔티티나 밸류와 같은 다른 구성요소와 명시적으로 구분하고 싶다면 domain 밑에 domain.model, domain.service, domain.repository와 같이 하위 패키지를 구분하여 위치시켜도 된다.
- 도메인 서비스 로직이 고정되어 있지 않은 경우 도메인 서비스 자체를 인터페이스로 만들고 이를 구현한 클래스를 둘 수도 있다. 도메인 서비스의 구현이 특정 구현 기술에 의존하거나 외부 시스템의 API를 실행한다면 도메인 영역의 도메인 서비스는 인터페이스로 추상화해야 한다.
Chapter 8. 애그리거트 트랜잭션
- 한 애그리거트에 여러 개의 트랜잭션이 접근하여 수정할 경우 일관성이 깨지는 문제가 나타난다. 두 가지 중 하나를 해야한다.
- 한 트랜잭션이 변경하는 동안, 다른 트랜잭션이 변경하지 못하게 막는다. (Pessimistic Lock)
- 한 트랜잭션이 변경하면, 다른 트랜잭션이 다시 조회한 뒤 변경하도록 한다. (Optimistic Lock)
- Pessimistic Lock
- 스프링 데이터 JPA with Hibernate: @Lock(LockModeType.PESSIMISTIC_WRITE) 를 사용하면 'for update' 쿼리를 이용해 비관적 락을 구현한다.
- 비관적 락을 사용할 때는 잠금 순서에 따른 deadlock이 발생할 수 있다. 이를 방지하기 위해 Lock을 구할 때 최대 대기 시간을 지정해야 한다.
- Optimistic Lock
- 비관적 락이 모든 트랜잭션 충돌 문제를 해결하는 것은 아니다. 예를 들어 운영자가 배송정보 창을 열어두고 배송상태를 변경하는 사이에 고객이 배송정보를 변경하면 운영자는 변경 전 배송정보로 배송을 진행하게 된다.
- 낙관적 락은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인한다. 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트 버전과 동일한 경우에만 데이터를 수정한다.
- JPA: 버전으로 사용할 필드에 @Version 애너테이션을 붙이고 매핑되는 테이블에 버전 저장할 칼럼을 추가한다. 업데이트 쿼리가 'update ..., version = version + 1 ... where ... and version = 10'와 같이 실행된다.
- 트랜잭션이 충돌하면 Exception이 발생한다.
- JPA는 find() 조회시 LockModeType.OPTIMISTIC_FORCE_INCREMENT를 사용해 해당 엔티티의 상태가 변경되었는지에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다. 이를 사용하면 루트 엔티티가 아닌 다른 엔티티나 밸류가 변경되더라도 버전 값을 증가시킬 수 있어 낙관적 락을 안전하게 적용할 수 있다.
- 오프라인 선점 잠금
- 구글 docs는 누군가 먼저 편집을 하는 중이라면 사용자에게 안내를 한다. 충돌 여부를 알려주지만 동시에 수정하는 것을 막지는 않는다. 만약 더 엄격하게 데이터 충돌을 막고 싶다면? 오프라인 선점 잠금.
- 오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다. A 사용자가 첫 번째 트랜잭션을 시작할 때 잠금을 선점하면 다른 사용자는 A 사용자가 잠금을 해제하기 전까지 잠금을 구할 수 없다. (e.g. 블로그)
- 오프라인 선점 잠금은 크게 잠금 선점 시도, 잠금 확인, 잠금 해제, 잠금 유효시간 연장, 4가지 기능이 필요하다.
- lockId가 없으면 잠금을 해제할 수 없다. 이 lockId를 어딘가에 저장해야 한다. 디비라든가.
Chapter 9. 바운디드 컨텍스트
- 카탈로그의 상품, 재고 관리의 상품, 주문의 상품, 배송의 상품은 이름만 같지 실제로 의미하는 것이 다르다.
- 모델은 특정한 컨텍스트 안에서 완전한 의미를 갖는다. 구분되는 경계를 갖는 컨텍스트를 바운디드 컨텍스트라고 부른다.
- 바운디드 컨텍스트는 용어로 기준으로 구분한다.
- 이상적으로 하위 도메인과 바운디드 컨텍스트가 일대일 관계를 가지면 좋겠지만 현실은 그렇지 않을 때가 많다. 바운디드 컨텍스트는 조직 구조에 따라 결정되기도 한다.
- 모든 바운디드 컨텍스트를 반드시 도메인 주도로 개발할 필요는 없다. 서로 다른 구현 기술을 사용할 수도 있다.
- 두 바운디드 컨텍스트 간 통합이 발생한다. 예를 들어, 상품 상세 페이지 하단에 추천 상품을 보여준다. 이 때 카탈로그 컨텍스트와 추천 컨텍스트의 도메인 모델은 서로 다르다. 카탈로그 시스템은 추천 시스템으로부터 추천 데이터를 받아와 카탈로그 도메인 모델을 사용해서 추천 상품을 표현해야 한다.
- 메시지 큐를 사용해 두 바운디드 컨텍스트를 간접적으로 통합할 수 있다.
- 두 바운디드 컨텍스트를 개발하는 팀은 메시징 큐에 담을 데이터의 구조를 협의하게 되는데 그 큐를 누가 제공하느냐에 따라 데이터 구조가 결정된다.
- 개별 바운디드 컨텍스트에 매몰되면 전체를 보지 못할 때가 있다. 컨텍스트 맵을 통해 전체 비즈니스를 조망할 수 있다. 컨텍스트 맵은 컨텍스트 간의 관계를 표시한다.
Chapter 10. 이벤트
- 이벤트 (특히 비동기로) 사용하여 두 시스템간의 결합을 낮출 수 있다.
- 이벤크 클래스의 이름은 과거 시제를 사용한다.
- 이벤트 클래스는 이벤트를 처리하는 데 필요한 최소한의 데이터를 포함한다.
- 고려할 점
- 이벤트 발생 주체에 대한 정보를 포함할 것인가 - e.g. Order가 발생시킨 이벤트만 조회하기
- 이벤트 전송 실패를 얼마나 허용할 것인가 - 재전송 횟수 제한 두기
- 이벤트 손실 - 아웃박스 패턴은 이벤트 보관을 보장할 수 있으나 비동기 로컬 핸들러는 이벤트를 유실할 수 있다.
- 이벤트 순서 - 순서가 중요하면 이벤트 저장소를 사용하는 것이 좋다. 메시징 시스템은 기술에 따라 순서가 보장되지 않을 수 있다.
- 이벤트 재처리 - 동일한 이벤트가 다시 들어오면 어떻게 할 것인지 정한다. 마지막으로 처리한 이벤트의 순번은 저장해두고 그 이전 이벤트는 처리하지 않고 무시한다든가, 멱등으로 처리한다든가.
- 이벤트를 처리할 때는 동기든 비동기든 DB 트랜잭션을 고려해야 한다. 트랜잭션 실패와 이벤트 처리 실패를 모두 고려하면 복잡해지므로 경우의 수를 줄인다. 경우의 수를 줄이는 방법은 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 것이다. (e.g. @TransactionalEventListener)
Chapter 11. CQRS
- 조회 화면 특성상 조회 속도가 빠를수록 좋다.
- ORM 기법은 도메인 상태 변경 기능을 구현하는 데에는 적합하지만 여러 애그리거트에서 데이터를 가져와 출력하는 기능을 구현하기에는 고려할 게 많아서 구현을 복잡하게 만든다.
- 상태 변경을 위한 모델과 조회를 위한 모델을 분리한다. Command Query Responsibility Segregation
- CQRS는 복잡한 도메인이 적합하다. 각 모델에 맞는 구현 기술을 선택할 수 있다.
- 조회 모델에는 응용 서비스가 존재하지 않아도 된다. 단순히 데이터를 조회하는 기능은 응용 로직이 복잡하지 않기에 컨트롤러에서 바로 DAO를 실행해도 무방하다.
- 명령 모델과 조회 모델이 서로 다른 데이터 저장소를 사용할 수도 있다. 두 데이터 저장소 간 데이터 동기화는 이벤트를 활용해서 처리한다. 명령 모델에서 상태를 변경하면 이에 해당하는 이벤트가 발생하고, 그 이벤트를 조회 모델에 전달해서 변경 내역을 반영하면 된다.
- 일반적인 웹 서비스는 상태 변경 요청보다 상태 조회 요청이 많다. 조회 성능을 높이기 위해 쿼리를 최적화하기도 하고, 조회 데이터를 캐싱하기도 하고, 조회 전용 저장소를 따로 사용하기도 한다. 결과적으로 CQRS를 적용하는 것과 같은 효과를 만든다.
- 장점: 명령 모델을 구현할 때 도메인 자체에 집중할 수 있다. 조회 성능을 향상시키는 데 유리하다.
- 단점: 구현해야 할 코드가 더 많다. 더 많은 구현 기술이 필요하다.
'Principal' 카테고리의 다른 글
TDD 원칙 (0) | 2024.02.18 |
---|---|
책 <웹 API 디자인> (2) | 2024.02.14 |
애자일 Agile 개발 방법론 (0) | 2022.10.17 |
책 <객체지향의 사실과 오해> (1) | 2022.10.05 |
DDD와 어플리케이션 이벤트 스토밍 (0) | 2022.08.24 |