최근에 스터디로 도메인 주도 개발 시작하기라는 책을 읽기 시작했다.
책에서는 DDD의 아키텍처를 다음과 같은 4가지 영역으로 나눈다.
| 영역 | 설명 |
|---|---|
| 사용자 인터페이스(UI) 또는 표현(Presentation) | 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 여기서 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수도 있다. |
| 응용 (Application) | 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 게층을 조합해서 기능을 실행한다. |
| 도메인 (Domain) | 시스템이 제공할 도메인 규칙을 구현한다. |
| 인프라스트럭쳐(infrastructure) | 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다. |
여기 표를 보면 인프라스트럭쳐 계층에서 데이터베이스와의 연동을 처리한다고 되어있다.
지금까지의 나는 도메인 계층에서 JPA를 사용하고 있었기에 위와 같은 계층 구조를 적용해 보기로 했다.
.
└── src/
├── presentation/
│ └── controller
├── application/
│ └── application-service
└── domain/
├── domain-service
├── repository
└── entity
기존에 사용하던 방식은 인프라스트럭쳐 계층이 없고, 도메인 계층에서 JPA를 사용해 엔티티와 JpaRepository를 만들어서 사용하는 방식으로 데이터베이스 접근 기술이 도메인 서비스에 함께 존재하는 방식이다.
DDD 아키텍처를 적용하여 다음과 같이 구현해보았다.
.
└── src/
├── presentation
│ └── controller
├── application/
│ └── application-service
├── domain/
│ ├── domain-service
│ ├── domain-repository interface
│ └── model
└── infrastructure/
├── entity
├── repository (jpa)
├── mapper
└── repository.impl (domain-repository implementation)
JPA를 인프라스트럭처 계층에서 사용하게 되면서, 도메인 계층에서는 도메인 리포지토리 인터페이스와 도메인 서비스와 도메인 클래스가(위에서는 이를 model로 표현했다) 위치하게 되었다.
인프라스트럭처 계층에서는 엔티티와 JpaRepository를 구현한 인터페이스, 그리고 도메인 계층의 리포지토리 인터페이스의 구현체들과 도메인 클래스와 Entity를 매핑해주는 Mapper 클래스가 위치하게 되었다.
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Advertisement {
private Long id;
private String title;
private String videoUrl;
private String advertiser;
private String agency;
private String manufacturer;
private Boolean isArchived;
private LocalDateTime archivedAt;
private LocalDateTime createdAt;
public static Advertisement of(Long id, String title, String videoUrl, String advertiser, String agency, String manufacturer, Boolean isArchived, LocalDateTime archivedAt
, LocalDateTime createdAt) {
return new Advertisement(id, title, videoUrl, advertiser, agency, manufacturer, isArchived, archivedAt, createdAt);
}
public void archive() {
if(this.isArchived) {
this.isArchived = false;
}
else {
this.isArchived = true;
archivedAt = LocalDateTime.now();
}
}
}
도메인 클래스 내부에는 롬복을 제외하면 순수 자바 코드로만 이뤄져 있다. 또한 다양한 도메인 로직들이 이 도메인 클래스 안에 들어가게 된다.
public interface AdvertisementRepository {
Optional<Advertisement> findById(Long id);
void save(Advertisement advertisement);
List<Advertisement> findAllByKeyword(String kwdVal, LocalDateTime start, LocalDateTime end);
}
@Entity
@Table(name = "advertisement")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AdvertisementEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "advertisement_id")
private Long id;
private String title;
private String videoUrl;
private String advertiser;
private String agency;
private String manufacturer;
private Boolean isArchived = Boolean.FALSE;
private LocalDateTime archivedAt;
private AdvertisementEntity(Long id, String title, String videoUrl, String advertiser, String agency, String manufacturer, Boolean isArchived, LocalDateTime archivedAt) {
this.id = id;
this.title = title;
this.videoUrl = videoUrl;
this.advertiser = advertiser;
this.agency = agency;
this.manufacturer = manufacturer;
this.isArchived = isArchived;
this.archivedAt = archivedAt;
}
public static AdvertisementEntity of(Long id, String title, String videoUrl, String advertiser, String agency, String manufacturer, Boolean isArchived, LocalDateTime archivedAt) {
return new AdvertisementEntity(id, title, videoUrl, advertiser, agency, manufacturer, isArchived, archivedAt);
}
}
엔티티는 실제 DB에 저장되는 테이블의 정보를 담고 있다. 엔티티는 도메인과 다르게 도메인과 관련된 로직이 들어가있지 않고 getter와 생성자만 존재하게 된다.
public interface AdvertisementEntityRepository extends JpaRepository<AdvertisementEntity, Long> {
Optional<AdvertisementEntity> findAdvertisementEntityById(Long id);
@Query("select a from AdvertisementEntity a where (:kwdVal is null or a.title like %:kwdVal%) and a.createdAt >= :start and a.createdAt <= :end order by a.createdAt desc")
List<AdvertisementEntity> findAllBySort(@Param("kwdVal") String kwdVal, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
}
JpaRepository를 구현한 인터페이스이다.
@Repository
@RequiredArgsConstructor
public class AdvertisementRepositoryImpl implements AdvertisementRepository {
private final AdvertisementEntityRepository advertisementEntityRepository;
private final AdvertisementMapper advertisementMapper;
private final MoodEntityRepository moodEntityRepository;
@Override
public Optional<Advertisement> findById(final Long id) {
return advertisementEntityRepository.findById(id)
.map(advertisementMapper::toDomain);
}
@Override
public void save(final Advertisement advertisement) {
final AdvertisementEntity advertisementEntity = advertisementMapper.toEntity(advertisement);
advertisementEntityRepository.save(advertisementEntity);
}
@Override
public List<Advertisement> findAllBySort(String kwdVal, LocalDateTime start, LocalDateTime end) {
return advertisementEntityRepository.findAllBySort(kwdVal, start, end)
.stream()
.map(advertisementMapper::toDomain)
.toList();
}
}
도메인 계층에서 정의한 리포지토리 인터페이스를 구현한 클래스이다. 이때, 의존성으로 JpaRepository와 Mapper 클래스를 가지게 된다. 만약 QueryDSL과 같이 추가적인 기술이 들어간다면 이곳에서 구현할 수 있다.
@Component
public class AdvertisementMapper {
public Advertisement toDomain(final AdvertisementEntity advertisementEntity) {
return Advertisement.of(
advertisementEntity.getId(),
advertisementEntity.getTitle(),
advertisementEntity.getVideoUrl(),
advertisementEntity.getAdvertiser(),
advertisementEntity.getAgency(),
advertisementEntity.getManufacturer(),
advertisementEntity.getIsArchived(),
advertisementEntity.getArchivedAt(),
advertisementEntity.getCreatedAt()
);
}
public AdvertisementEntity toEntity(final Advertisement advertisement) {
return AdvertisementEntity.of(
advertisement.getId(),
advertisement.getTitle(),
advertisement.getVideoUrl(),
advertisement.getAdvertiser(),
advertisement.getAgency(),
advertisement.getManufacturer(),
advertisement.getIsArchived(),
advertisement.getArchivedAt()
);
}
}
Mapper 클래스는 도메인 클래스와 엔티티를 매핑해주는 역할을 한다. 명시적인 의존성 관리를 위해 @Component 어노테이션을 통해 해당 클래스를 빈으로 등록하고 메소드를 호출하는 방식을 사용했다.
계층이 하나 더 늘어나다 보니 작성해야 하는 코드의 양이 늘어나고, 개발 시간이 길어질 수밖에 없었다. 그러나 이는 트레이드오프라고 생각한다.
코드의 복잡성은 올라갈 수 있으나, 상위 계층들(Presentation, application, domain)이 기술에 의존적이지 않게 됨으로써 언어의 변경, DB의 변경(RDBMS or NoSQL), 데이터 접근 기술의 변경(JPA, MyBatis등등)에 자유롭게 된다.
또한 상위 계층들이 기술에 의존적이지 않게 되므로 이 아키텍처를 사용하면 멀티모듈에서 큰 이점을 얻을 수 있을 것이라 생각한다. 다른 기술로의 이전에서 모듈 의존성만 변경하면 되기 때문이다.
다만 기술에 의존적이지 않게 되다 보니, 적용하고 있는 기술에서 편하게 사용할 수 있는 기능들을 사용하지 못하게 되는 경우도 있다. 예를 들어 해당 아키텍처를 사용하면 도메인 로직들이 도메인 클래스에 존재하기 때문에 특정 데이터를 수정하는 과정에서 JPA에서 제공해 주는 Dirty Checking을 사용할 수 없게 된다.
다시 한번 말하지만, 단점 없는 아키텍처는 없다고 생각한다. 결국엔 트레이드오프가 존재할 수밖에 없다고 생각한다.
진짜 너무 대단하고멋있어요, DDD저도 알려주세요 제발요진짜제발