시작하며
안녕하세요. 개발자 stark입니다.
오늘은 제가 도메인 주도 설계(DDD)를 하며 Aggregate를 구성할 때 느낀 점을 정리해보려고 합니다. 저는 실무에서도 사이드 프로젝트에서도 DDD로 개발을 진행하고 있습니다. 그런데 도메인에 대한 Aggregate를 구성할 때 매번 하고 있는 고민이 있습니다. 예를 들어 사이드 프로젝트에서 Aggregate Root가 될 PostEntity와 일반 종속 Entity(첨부파일, 해시태그)들을 포함하는 게시글 Aggregate를 하나 구성했습니다.
저는 Spring과 DB접근 라이브러리로 ORM인 JPA를 사용하고 있었기에 엔티티의 관계성을 설정해 줄 필요성이 있었습니다. 근데 여기서 Aggregate Root인 PostEntity만으로 Repository에 접근해서 (첨부파일, 해시태그)의 저장, 수정, 삭제가 가능하게 하려다 보니 @OneToMany를 사용하지 않으면 안 되겠다 싶어서 양방향 관계를 정의해야 할지 매번 고민해 왔습니다.
왜냐하면 저는 @ManyToOne 단방향 관계를 선호하기에 항상 @OneToMany를 사용하지 않는 방식으로 엔티티를 구성하곤 했기 때문입니다. DDD에서는 @OneToMany를 사용해서 엔티티에 양방향 관계를 적용하는 것이 옳을까요? 아니면 원래 하던 대로 단방향 @ManyToOne을 적용하고 Root의 Repository만 사용하는 것이 아니라 각 엔티티별 Repository를 선언해서 사용해도 될까요? 저는 어떻게 설계해야 할지 개발하면서도 계속 고민해 왔는데요. 솔직히 이 글을 정리하기 전까지도 명확한 기준점을 정하지 못했습니다. 그러나 이번 포스팅을 작성하면서 저만의 기준점을 정하게 되었습니다. 그 과정을 소개드리고자 합니다. 재미있게 봐주셨으면 좋겠습니다.
애그리게잇이 무엇이냐? 아래의 포스팅을 확인해 주세요!
[DDD] 애그리게잇(Aggregate) 구성하기
시작하며안녕하세요, 개발자 stark입니다! 오랜만에 글을 적게 되었는데요. 오늘은 제가 실무에서 가장 많이 고민했던 주제 중 하나인 DDD(Domain-Driven Design)의 애그리게잇 설계에 대해 다뤄보려고
curiousjinan.tistory.com
개인적인 생각을 정리한 글이니 이 글의 내용이 옳다고 볼 수 없습니다. 언제든지 잘못된 부분을 알려주시면 감사하겠습니다.
게시글 테이블 구조 및 Aggregate 설명
일단 가상의 게시글 테이블 구조를 설계해 봤습니다.
- RDB를 사용하고 있다고 가정했으며 게시글을 작성할 때는 첨부파일과 해시태그를 같이 저장할 수 있도록 구성했습니다.
이 테이블 구조를 토대로 Aggregate를 구성한다면 이렇게 표현될 것입니다.
- Aggregate 내부에는 Post, Attachment, HashTag 3개의 엔티티가 포함되어 있습니다.
Spring Jpa 엔티티 구성하기
저는 헥사고날 아키텍처에서 @OneToMany와 양방향 관계는 잘 사용하지 않습니다. 왜냐하면 생각보다 다양한 문제들이 존재하기 때문입니다(변환이나, 로딩 문제 등). 대신 @ManyToOne 단방향 관계를 구성하는 것을 선호합니다.
@NoArgsConstructor
@Getter
@Entity
@Table(name = "posts")
public class PostEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Column(columnDefinition = "TEXT")
private String content;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String author;
private int viewCount;
}
@NoArgsConstructor
@Getter
@Entity
@Table(name = "attachments")
public class AttachmentEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ManyToOne 단방향 - Attachment에서 Post로의 참조
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private PostEntity post;
@Column(nullable = false)
private String fileName;
@Column(nullable = false)
private String filePath;
private long fileSize;
@Column(nullable = false)
private String contentType;
@Column(nullable = false, updatable = false)
private LocalDateTime uploadedAt;
}
@NoArgsConstructor
@Getter
@Entity
@Table(name = "hash_tags")
public class HashTagEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ManyToOne 단방향 - HashTag에서 Post로의 참조
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private PostEntity post;
@Column(nullable = false)
private String name;
}
이렇게 엔티티를 구성한 다음 도메인 비즈니스를 가지게 될 도메인 POJO객체를 만들었습니다.
도메인 모델과 퍼시스턴스 모델의 분리 (Anti-Corruption Layer)
도메인 주도 설계를 JPA와 함께 적용할 때, 도메인 객체와 영속성 객체를 분리하는 전략은 매우 효과적입니다. 이 접근법은 도메인 모델을 JPA의 기술적 제약으로부터 보호하고, 각 계층이 자신의 관심사에만 집중할 수 있게 해 줍니다.
순수 도메인 모델은 JPA 어노테이션이나 관계 매핑이 전혀 없는 POJO로 구성됩니다. 이 객체들은 오직 비즈니스 로직과 규칙만을 표현하며, 단방향 컬렉션으로 관계를 표현합니다. 반면 퍼시스턴스 모델은 데이터베이스 매핑에 필요한 모든 JPA 어노테이션을 포함하고, @OneToMany, @ManyToOne 등의 관계 매핑과 cascade 설정을 담당합니다.
두 모델 사이의 변환은 매퍼 계층이 담당하며, MapStruct 같은 도구를 활용해 매핑 코드를 자동화할 수 있습니다. 이렇게 분리함으로써 도메인 모델에서는 순환참조나 지연로딩 문제가 발생하지 않으며, 영속성 계층에서는 필요한 최적화를 자유롭게 적용할 수 있습니다. 다만 이렇게 도메인과 Entity를 각각 만들어놓고 변환하는 방식이 정말 옳은지에 대해서는 아직 많은 고민을 하고 있습니다. 생각보다 변환작업이 매우 복잡한 경우가 많고 오히려 mapper를 관리하는 게 일이 되어버리는 상황도 있기 때문입니다. 너무 많이 고생하다 보니 이젠 그냥 엔티티만으로 구성해도 되는 거 아닐까? 이런 생각까지 들게 되었습니다.
POJO 도메인 모델은 어떻게 구성할까?
마이크로소프트에서 제공하는 .Net기반 DDD 설계 가이드북이 있습니다. 이 가이드에서는 이렇게 설명하고 있습니다. '예를 들어, 도메인 모델 계층은 다른 계층에 대한 종속성을 가져서는 안 됩니다(도메인 모델 클래스는 일반 기존 클래스 객체 또는 POCO 클래스여야 함). Domain 계층 라이브러리는 .NET 라이브러리 또는 NuGet 패키지에 대한 종속성만 가지며 데이터 라이브러리 또는 지속성 라이브러리와 같은 다른 사용자 지정 라이브러리에는 종속성이 없습니다. (아래 마이크로소프트 가이드 글의 내용을 인용하였습니다.)
Designing a DDD-oriented microservice - .NET
.NET Microservices Architecture for Containerized .NET Applications | Understand the design of the DDD-oriented ordering microservice and its application layers.
learn.microsoft.com
여기서 '지속성(Persistence)'은 무슨 의미일까요? 저에게 한국어로 지속성은 생소했지만 영단어를 봤을 때는 이해하기 쉬웠습니다. 헥사고날 아키텍처를 구성하다 보면 Persistence Adapter라는 구현체를 만들고 여기서 JPA 또는 다른 라이브러리를 의존해서 db에 접근하게 됩니다. 즉, Persistence = 프로그램 실행을 넘어 데이터를 '보존'하는 것을 의미합니다.
그리고 DDD에서는 도메인 모델이 Persistence(지속성)의 세부를 알 필요가 없도록 (“Persistence Ignorance: 지속성을 무시”)하도록 설계해야 합니다. 도메인 계층은 순수 비즈니스 로직만, 인프라스트럭처 계층(Repository 구현체 등)이 실제 저장·조회 책임을 져야 한다는 의미입니다.
이것을 자세히 설명하면 다음과 같습니다. “Persistence Ignorance(PI)”란, 도메인 모델이 자신의 영속화(persistence) 방식에 대해 전혀 알 필요가 없도록, 즉 “어떻게·어디에 저장되는지” 혹은 “어떤 프레임워크를 쓰는지”조차 모르게 설계하는 원칙을 말합니다. 이것을 위해서 도메인 객체(Entity, VO, Aggregate 등)는 순수한 POJO/POCO로 남아 있어야 하고, JPA 어노테이션·ORM API 호출·DB 트랜잭션 코드 등을 포함해서는 안 됩니다. 대신 Repository, ORM 매핑 레이어 등 인프라스트럭처 계층에서만 영속화 세부 구현을 책임집니다.
참고로 여기서 말하는 POJO란 'Plain Old Java Object'의 약자로, 직역하면 "평범한(Plain), 오래된(Old) 자바 객체(Java Object)"입니다. 특별한 제약 없이 자바의 기본 기능만 사용하는 순수한 객체를 의미합니다. POJO는 다음과 같은 특징을 가집니다.
- 특정 프레임워크(EJB, Spring, Hibernate 등)의 인터페이스를 구현하거나 클래스를 상속받지 않음
- 어노테이션이나 마커 인터페이스에 종속되지 않음
- 보통 필드와 이에 접근하는 getter/setter 메서드, 기본 생성자를 포함
- 비즈니스 로직을 단순하고 직관적으로 표현
도메인 모델: DTO를 매개변수로 사용해도 될까?
제가 처음 DDD를 접하고 도메인 객체를 구성하면서 제일 많이 고민한 것이 있습니다.
Presentation 레이어(컨트롤러)에서 완성한 requestDto 객체를 application 레이어(서비스)에 매개변수로 보낼 것입니다. 그러면 서비스 메서드는 매개변수로 받은 dto 값을 사용해서 비즈니스를 수행하게 됩니다. 이때 DDD는 POJO로 구성된 도메인 내부의 메서드를 통해 비즈니스를 처리할 것이기 때문에 dto의 값은 주로 도메인 객체가 사용할 것입니다.
바로 여기서 이런 궁금증이 생겼습니다. 'DTO 객체를 도메인 메서드의 매개변수로 사용해도 괜찮을까?' 왜냐하면 생각보다 변경할 값이 많은 비즈니스 메서드가 있는 경우가 있는데 이런 경우 매번 서비스 메서드에서 도메인 비즈니스 필요로 하는 값들을 dto에서 하나하나 다 꺼내서 매개변수로 넣어주니 코드가 길어지기도 하고 이런 식으로 메서드를 호출하는 게 더 효율적인지에 대한 의구심이 들었기 때문입니다.
코드 예시를 통해 어떤 상황을 설명하는 것인지 알아봅시다.
- 이 예시는 dto에 수정할 게시글 정보와 첨부파일, 해시태그 리스트를 각각 가지고 있는 상황입니다.
// UpdatePostDto (Presentation/Application Layer)
public class UpdatePostDto {
private String title;
private String content;
private String author;
private List<AttachmentDto> attachments;
private List<String> hashTags;
}
// Application Service
@RequiredArgsConstructor
@UseCase
public class PostAppService implements PostUseCase {
private final PostPort postPort;
private final AttachmentMapper attMapper;
private final HashTagMapper tagMapper;
@Transactional
public void update(UpdatePostDto dto) {
Post post = postPort.findById(new PostId(dto.getId()));
// DTO → 도메인 값으로 개별 매핑
List<Attachment> attachments = dto.getAttachments().stream()
.map(attMapper::toDomain)
.toList();
List<HashTag> hashTags = dto.getHashTags().stream()
.map(HashTag::new)
.toList();
// 도메인 메서드에 primitive/VO/컬렉션 형태로 넘김
post.update(
dto.getTitle(),
dto.getContent(),
dto.getAuthor(),
attachments,
hashTags
);
postPort.save(post);
}
}
// Domain Entity (Pojo)
public class Post {
// ...
public void update(
String title,
String content,
String author,
List<Attachment> attachments,
List<HashTag> hashTags
) {
this.title = title;
this.content = content;
this.author = author;
replaceAttachments(attachments);
replaceHashTags(hashTags);
this.updatedAt = LocalDateTime.now();
}
}
만약 DTO를 도메인 객체에서 받으면 어떻게 구성될까요?
- 서비스 로직은 매우 간소화되지만 도메인 로직이 복잡해지고 DTO에 대한 의존성이 생깁니다.
// ✗ 잘못된 예시: 도메인이 DTO에 직접 의존한다
// UpdatePostDto (Presentation/Application Layer)
public class UpdatePostDto {
private String title;
private String content;
private String author;
private List<AttachmentDto> attachments;
private List<String> hashTags;
// getters …
}
// Application Service
@RequiredArgsConstructor
@UseCase
public class PostAppService implements PostUseCase {
private final PostPort postPort;
@Transactional
@Override
public void update(UpdatePostDto dto) {
// 1) 도메인 조회
Post post = postPort.findById(new PostId(dto.getId()));
// 2) 잘못된 호출: DTO를 그대로 넘겨버린다
post.update(dto);
// 3) 저장
postPort.save(post);
}
}
// Domain Entity (Pojo)
public class Post {
// ... 필드, 생성자 등 생략 ...
// 잘못된 시그니처: DTO 타입을 그대로 받으면 계층 분리 위반!
public void update(UpdatePostDto dto) {
this.title = dto.getTitle();
this.content = dto.getContent();
this.author = dto.getAuthor();
// attachments/hashtags도 DTO 의존
this.attachments = dto.getAttachments().stream()
.map(attDto -> new Attachment(
attDto.getFileName(),
attDto.getFilePath(),
attDto.getFileSize(),
LocalDateTime.now()
))
.toList();
this.hashTags = dto.getHashTags().stream()
.map(HashTag::new)
.toList();
this.updatedAt = LocalDateTime.now();
}
}
저는 이 고민을 몇 달 넘게 했는데 다음과 같은 이유 때문에 DTO는 도메인 객체에서 의존하지 않기로 했습니다.
첫 번째 이유는 계층의 의존성 역전을 막기 위해 상위 계층 (Presentation, application)에서 사용하는 DTO 객체는 하위 계층인 (Domain)이 몰라야 한다고 생각했습니다. 그래서 아무리 서비스 레이어가 복잡해진다 하더라도 도메인에서는 DTO객체를 의존하지 않는 방향으로 설계하기로 했습니다.
두 번째 이유는 Post.update(UpdatePostDto dto) 이렇게 도메인이 DTO를 직접 받으면(의존하면), 도메인이 'API·UI 스펙'에 종속되어 버립니다. 이렇게 되면 API가 바뀔 때마다 도메인 메서드 시그니처도 함께 바꿔야 하고, 도메인 단위 테스트를 할 때에도 DTO를 만들어야 하므로 불필요한 결합이 생깁니다.
Post를 Aggregate Root로 도메인 모델 구성하기
제가 생각한 Aggregate 구성은 Post엔티티를 Aggregate Root로 사용하는 것입니다. 먼저 엔티티(Post, AttachMent, HashTag) 클래스와 1:1로 매핑되는 POJO 형태의 도메인 객체를 각각 만들어줬습니다. 이후 Aggregate의 Root 역할을 하게 될 Post 도메인 객체 내부에서 AttachMent, HashTag를 필드로 선언하여 참조하도록 구성했습니다.
@Getter
public class Post {
private PostId id;
private String title;
private String content;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String author;
private int viewCount;
private List<Attachment> attachments = new ArrayList<>();
private List<HashTag> hashTags = new ArrayList<>();
// 생성자
private Post(PostId id, String title, String content, String author) {
this.id = id;
this.title = title;
this.content = content;
this.author = author;
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
this.viewCount = 0;
}
// 팩토리 메서드
public static Post create(String title, String content, String author) {
return new Post(null, title, content, author);
}
}
이렇게 Post 도메인을 Aggregate Root로 구성했으니 앞으로 게시글에 관련된 모든 비즈니스는 Post 도메인을 통해서만 가능하도록 메서드를 구성할 것입니다. 즉, 첨부파일 또는 해시태그와 관련된 비즈니스를 처리하고 싶다면 Root인 Post 도메인 내부에 관련 메서드를 선언해서 비즈니스를 진행해야 합니다.
Aggregate Root로 데이터(테이블) 접근
우리는 테이블에 접근하기 위해 일반적으로 엔티티당 1개의 JpaRepository를 구성합니다.
- 헥사고날 아키텍처를 기준으로 서비스 레이어에서 out port 인터페이스를 호출하면 포트의 구현체인 어댑터가 호출될 것이고 어댑터는 외부 db에 접근해야 하므로 게시글과 관련된 Post, Attach, HashTag 리포지토리를 참조하고 있을 것입니다.
이제 게시글 내부에 있는 첨부파일을 수정해 봅시다.
- 아래 다이어그램과 같이 Adapter 레이어에서는 AttachmentRepository를 통해서 DB의 첨부파일 테이블에 접근할 것입니다. 근데 DDD에서 이런 식의 테이블 접근이 옳은 방법일까요? DDD의 규칙을 다시 살펴볼 필요성이 있습니다.
'DDD에서는 Aggregate 단위로 일관성 경계(Consistency Boundary)를 정의하며, 이 경계 안의 모든 변경은 Aggregate Root를 통해서만 이루어져야 합니다.' 이 말은 Root가 되는 Post 엔티티를 통해서만 Aggregate에 포함된(종속된) 다른 엔티티들의 변경이 이루어져야 한다는 것으로 해석할 수 있습니다. 따라서 리포지토리(Repository)는 'Aggregate Root마다 하나씩'만 정의해야 합니다. 그리고 Aggregate 내부의 종속(Entity)들에 대해서는 별도의 리포지토리를 만들지 않고, Aggregate Root 리포지토리를 통해 저장·수정·삭제를 수행해야 한다는 것입니다.
아래 마이크로소프트에서 정리해 준 가이드 내용 중 '그림 7-17'을 확인해 보시면 조금 더 이해하는데 도움이 되실 겁니다.
Designing the infrastructure persistence layer - .NET
.NET Microservices Architecture for Containerized .NET Applications | Explore the repository pattern in the design of the infrastructure persistence layer.
learn.microsoft.com
즉, Post Aggregate에는 PostRepository 인터페이스만 사용해야 하고 AttachmentRepository나 HashTagRepository 같은 리포지토리를 선언해서 외부에 드러내면 안 된다는 것입니다. 이것을 위해서는 PostEntity 내부에 자식 컬렉션을 모두 매핑해 두고 postRepository.save(entity)로 첨부파일·해시태그까지 자동 반영되도록 구성해야 합니다.
아래 다이어그램과 같이 게시글 Aggregate에서 PostRepository만을 사용하도록 변경해야 합니다.
이렇게 변경하기 위해서는 아래와 같이 Post 엔티티를 재구성해야 합니다.
- PostEntity 내부에 AttachmentEntity, HashTagEntity를 리스트로 선언하고 @OneToMany(cascade = ALL, orphanRemoval = true)를 걸어줍니다. 그리고 AttachmentEntity, HashTagEntity 내부에는 이전과 동일하게 @ManyToOne으로 PostEntity를 선언해 둡니다. 그리고 PostEntity 내부의 @OneToMany들에 mappedBy조건을 걸어서 서로 매핑시켜 주면 양방향으로 구성되어 엔티티 재구성은 끝나게 됩니다.
@Entity
@Table(name = "posts")
public class PostEntity {
// ... 기존 필드 …
@OneToMany(mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<AttachmentEntity> attachments = new ArrayList<>();
@OneToMany(mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<HashTagEntity> hashTags = new ArrayList<>();
// 편의 메서드: 양쪽 연관관계 세팅
public void addAttachment(AttachmentEntity att) {
attachments.add(att);
att.setPost(this);
}
public void addHashTag(HashTagEntity tag) {
hashTags.add(tag);
tag.setPost(this);
}
// 삭제 메서드도 마찬가지로 구현
}
자 이제는 첨부파일 수정을 하면 이렇게 작업이 진행됩니다.
- Post 서비스 내부의 비즈니스 로직이 실행되면 Post 도메인에 새로운 첨부파일을 추가합니다. 그리고 JpaRepositor의 save() 메서드를 호출하기 전에 도메인 객체를 엔티티로 변환해 줍니다. 이후 save() 메서드를 호출하기만 하면 끝입니다. 참 쉽죠..? 이런 식으로 구성하면 Aggregate의 Root인 Post엔티티의 Repository를 통해서만 첨부파일(AttachmentEntity)의 데이터가 저장되는 것이므로 제대로 구성된 Aggregate라고 할 수 있을 것입니다.
하나의 Repository를 사용하는 게 과연 옳을까?
저는 예전에 공부할 때 JPA에서는 @OneToMany를 사용하지 않고 단방향으로 @ManyToOne만을 사용하는 게 더 낫다고 배운 적이 있습니다. 그리고 실제로도 도메인 객체를 변환할 때 @OneToMany를 사용하면 순환참조가 발생하거나 Lazy로딩 설정 때문에 발생하는 문제들이 있었습니다. 그렇다 보니 이렇게 DDD 원칙을 지키기 위해서 엔티티에 @OneToMany를 적용시키고 단 1개의 Repository만을 사용하는 게 맞는지에 대한 고민이 들었습니다.
일단 OneToMany 사용에 대한 고려사항을 살펴봅시다.
- N+1 문제: 연관된 엔티티를 로드할 때 추가적인 쿼리가 발생
- 순환참조: JSON 직렬화 과정에서 무한 루프 발생 가능
- 영속성 전이(Cascade) 관리의 복잡성: 부모-자식 관계에서 변경 사항 전파 시 예상치 못한 동작
- Lazy Loading 이슈: 세션이 닫힌 후 관련 엔티티에 접근 시 LazyInitializationException 발생
이렇게 고민을 하다 보니 양방향 @OneToMany로 변경했던 엔티티를 또다시 기존 설계방식대로 단방향 @ManyToOne으로 바꾸고 싶다는 생각이 들었습니다. 과연 @ManyToOne 단방향 매핑으로 엔티티를 구성한다고 해서 DDD의 “Aggregate Root당 하나의 Repository”만 사용한다는 원칙을 깬 것일까요? DDD와 헥사고날 아키텍처의 개념을 잘 적용해 보면 '도메인(애플리케이션) 계층에서 하나의 리포지토리 접근 인터페이스(port)'를 사용하도록 구성한다면 결국 도메인에 대한 테이블 변경 지점은 1개의 port만을 통해 작업이 진행되니 이것은 DDD 원칙을 지키는 것과 동일한 게 아닐까요?
어느 정도는 DDD 원칙의 본질을 유지하면서 기술 또는 아키텍처를 통해 제약을 해결해 나가는 것도 하나의 방법이 아닐까 생각합니다. 그렇기에 아래와 같은 방식은 결국 DDD의 원칙과 일치한다고 생각합니다.
어플리케이션 계층 (비즈니스 로직)
↓
PostPort (단일 인터페이스)
↓
PostPersistenceAdapter (구현체)
↓
여러 JPA Repository (기술적 세부사항)
- 도메인 관점에서는 여전히 Post Aggregate를 통해서만 연관 엔티티(Attachment, HashTag)에 접근합니다.
- 단일 진입점이 유지됩니다 - 도메인 서비스(application 비즈니스)는 오직 PostPort만 알고 있습니다.
- 캡슐화가 유지됩니다 - 여러 리포지토리를 사용하는 것은 어댑터 내부의 구현 세부사항입니다.
다음으로 기술적 타협점의 정당성을 살펴봅시다.
- JPA에서 @OneToMany 관계가 가진 기술적 제약(N+1 문제, 순환참조 등)은 실제 문제입니다.
- DDD 원칙을 고수하기 위해 이러한 기술적 문제를 감수하는 것은 실용적이지 않습니다.
- 도메인 계층의 순수성을 유지하면서 인프라 레이어에서는 기술적 최적화를 하는 것은 정당한 타협입니다.
이론적 근거 또한 살펴봅시다.
DDD의 창시자인 Eric Evans는 DDD 전반에 걸쳐 “모델의 순수성만 고집하지 말고, 실제 구현 환경에서 마주치는 제약(성능·도구 한계 등)도 함께 고려하라”는 실용적(Pragmatic) 설계의 중요성을 여러 차례 강조합니다. 결국 "모델과 구현 사이의 균형을 찾아야 한다."는 것이 핵심 개념인 것입니다.
이런 고민의 과정을 거치며 제가 생각한 타협점은 @ManyToOne 단방향으로 구성하는 것입니다.
- 다시 원점의 다이어그램으로 돌아와 버렸습니다. 서비스에서 단 1개의 port를 통해 데이터베이스에 접근한다. 이런 개념적인 접근으로 바라보면 아래와 같이 adapter에서 여러 repository를 참조하는 것이 문제가 없다고 생각하게 되었습니다.
자 그럼 코드로 구현해 봅시다.
Post(게시글)와 관련된 테이블에 접근하게 해 줄 port를 단 1개만 만들어두고 이 port를 통해서만 db접근을 하도록 한 다음 구현체인 adpater에서는 Post, Attachment, HashTag 리포지토리를 모두 주입받아서 사용하도록 하는 것입니다. 바로 아래와 같이 구현하면 개념적으로 단 1개의 접근 방법(port)을 제공하게 되어 DDD 원칙을 지키면서도 기술적으로도 타협을 했다고 생각하였습니다.
public interface PostPost {
Post findById(PostId id);
void save(Post post);
}
@RequiredArgsConstructor
@Adapter
public class PostPersistenceAdapter implements PostPort {
private final PostJpaRepository postRepository;
private final AttachmentJpaRepository attachmentRepository;
private final HashTagJpaRepository hashTagRepository;
private final PostMapper mapper;
@Transactional
public void save(Post post) {
// 1) 도메인 → PostEntity
PostEntity entity = mapper.toEntity(post);
// 2) Post만 저장
postRepository.save(entity);
// 3) ManyToOne 단방향만 사용해도
// attachment, hashtag 동기화 로직으로 처리
syncAttachments(post, entity);
syncHashTags(post, entity);
}
// …
}
이 방식은 도메인 비즈니스를 조율하는 서비스(Service → PostPort) 입장에선 postPort.save(post) 한 번만 호출하는 것이고 인프라스트럭처(adapter)에선 postRepository, attachmentRepository, hashTagRepository를 마음대로 쓰되, PostPersistenceAdapter 내부에서만 사용하기 때문에 'DDD 원칙에는 훼손이 없다'라고 생각했습니다.
POJO 도메인 내부의 비즈니스(메서드) 구성
마지막으로 POJO 형태의 도메인 객체에 어떻게 비즈니스 메서드가 구성될지 작성해 봤습니다.(코드를 스크롤하며 어떤 식으로 도메인 메서드가 내부에 선언되는지 확인해 주시면 됩니다!)
@Getter
public class Post {
private PostId id;
private String title;
private String content;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String author;
private int viewCount;
private List<Attachment> attachments = new ArrayList<>();
private List<HashTag> hashTags = new ArrayList<>();
// 생성자
private Post(PostId id, String title, String content, String author) {
this.id = id;
this.title = title;
this.content = content;
this.author = author;
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
this.viewCount = 0;
}
// 팩토리 메서드
public static Post create(String title, String content, String author) {
return new Post(null, title, content, author);
}
// 게시글 내용 수정
public void update(String title, String content) {
this.title = title;
this.content = content;
this.updatedAt = LocalDateTime.now();
}
// 조회수 증가
public void increaseViewCount() {
this.viewCount++;
}
// 첨부파일 추가
public void addAttachment(Attachment attachment) {
this.attachments.add(attachment);
}
// 첨부파일 제거
public void removeAttachment(AttachmentId attachmentId) {
this.attachments.removeIf(attachment -> attachment.getId().equals(attachmentId));
}
// 모든 첨부파일 제거
public void clearAttachments() {
this.attachments.clear();
}
// 첨부파일 일괄 갱신
public void updateAttachments(List<Attachment> newAttachments) {
this.attachments.clear();
this.attachments.addAll(newAttachments);
}
// 해시태그 추가
public void addHashTag(HashTag hashTag) {
if (!containsHashTag(hashTag.getTag())) {
this.hashTags.add(hashTag);
}
}
// 해시태그 제거
public void removeHashTag(String tag) {
this.hashTags.removeIf(hashTag -> hashTag.getTag().equals(tag));
}
// 해시태그 포함 여부 확인
public boolean containsHashTag(String tag) {
return this.hashTags.stream()
.anyMatch(hashTag -> hashTag.getTag().equals(tag));
}
// 해시태그 목록 일괄 설정
public void changeHashTags(List<String> tags) {
this.hashTags.clear();
tags.forEach(tag -> this.hashTags.add(new HashTag(tag)));
}
// 게시글 내용에서 해시태그 추출 및 설정
public void extractAndSetHashTags() {
List<String> extractedTags = extractHashTagsFromContent();
setHashTags(extractedTags);
}
// 게시글 내용에서 해시태그 추출 (# 으로 시작하는 단어)
private List<String> extractHashTagsFromContent() {
Pattern pattern = Pattern.compile("#(\\w+)");
Matcher matcher = pattern.matcher(this.content);
List<String> tags = new ArrayList<>();
while (matcher.find()) {
tags.add(matcher.group(1));
}
return tags;
}
// 게시글이 특정 태그를 포함하는지 확인
public boolean hasAnyTagOf(List<String> searchTags) {
return this.hashTags.stream()
.anyMatch(hashTag -> searchTags.contains(hashTag.getTag()));
}
// 이미지 첨부파일 개수 확인
public long countImageAttachments() {
return this.attachments.stream()
.filter(Attachment::isImage)
.count();
}
// 유효한 상태인지 확인 (제목과 내용이 비어있지 않은지)
public boolean isValid() {
return title != null && !title.trim().isEmpty()
&& content != null && !content.trim().isEmpty();
}
}
이렇게 Aggregate Root 내부에서 연관된 비즈니스를 모두 처리하도록 하면 응집도 높은 도메인이 됩니다.
마무리하며
마지막으로 이 문구를 소개시켜주신 저희 회사 인사담당자님의 LinkedIn 링크를 하단에 적어두었습니다. 저희 회사에서 저와 함께 개발문화를 만들어가고 싶은 열정있는 개발자분들은 언제나 커피챗 환영합니다. (많은 관심 부탁드립니다!)
'DDD' 카테고리의 다른 글
[DDD] 애그리게잇(Aggregate) 구성하기 (0) | 2025.03.12 |
---|---|
[DDD] 유비쿼터스 언어(Ubiquitous Language)의 중요성 (0) | 2024.12.28 |
화살표 if문을 DDD로 우아하게 리팩토링하기 (1) | 2024.10.29 |
스프링에서 도메인 객체를 사용하는 건에 대해 (6) | 2024.08.31 |