스프링에서 도메인 객체를 사용하는 이유가 무엇일까?
📌 서론
이번 포스팅의 내용은 제가 "전통적인 3계층 아키텍처" 방식과 "DDD를 적용시킨 헥사고날 아키텍처"를 사용해서 개발해 보면서 느낀 점을 정리해 봤습니다. (특히 도메인 객체를 사용하는 이유와 객체 지향적인 개발에 중점을 두었습니다.)
제가 개발해 보며 느낀 점을 정리해 둔 것이기 때문에 잘못된 내용이나 잘 모르지만 아는 것처럼 적어둔 부분도 분명히 존재할 것이라고 생각합니다. 그러니 "단순한 개발 회고록" 정도로만 생각하고 재미있게 봐주시면 좋겠습니다 :)
1. 전통적인 3계층 아키텍처 방식의 개발 (도메인 객체가 없음)
일반적으로 3계층 아키텍처에서는 도메인 객체를 사용하지 않는다.
- 3계층 아키텍처에서는 대부분 Entity, Dto를 사용하는 방식으로 개발한다. (도메인 객체를 사용할 수는 있다.)
- 다만 우리가 지금부터 살펴볼 코드는 도메인을 사용하지 않는 3계층 아키텍처이다.
Order 엔티티 선언
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 기본 생성자
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 엔티티 생성자는 private으로 설정하여 팩토리 메서드를 사용하게 함
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> orderItems = new ArrayList<>();
// 단순한 엔티티 생성 메서드 (팩토리 메서드)
public static Order createOrder() {
return new Order(null, LocalDateTime.now(), OrderStatus.CREATED, new ArrayList<>());
}
// 상태 변경 메서드
public void changeStatus(OrderStatus status) {
this.status = status;
}
// 연관 관계 메서드
public void addOrderItem(OrderItem orderItem) {
this.orderItems.add(orderItem);
orderItem.changeOrder(this);
}
}
OrderItem 엔티티 선언
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 기본 생성자
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 엔티티 생성자는 private으로 설정하여 팩토리 메서드를 사용하게 함
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
private int quantity;
private double price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
// 단순한 엔티티 생성 메서드 (팩토리 메서드)
public static OrderItem createOrderItem(String productName, int quantity, double price) {
return new OrderItem(null, productName, quantity, price, null);
}
// 연관 관계 메서드 (protected로 설정)
protected void changeOrder(Order order) {
this.order = order;
}
}
Controller 선언
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity<Order> createOrder(@RequestBody List<OrderItem> orderItems) {
Order order = orderService.createOrder(orderItems);
return new ResponseEntity<>(order, HttpStatus.CREATED);
}
@PostMapping("/{orderId}/items")
public ResponseEntity<Void> addOrderItem(@PathVariable Long orderId, @RequestBody OrderItem orderItem) {
orderService.addOrderItem(orderId, orderItem);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
@PostMapping("/{orderId}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long orderId) {
orderService.cancelOrder(orderId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
@PostMapping("/{orderId}/complete")
public ResponseEntity<Void> completeOrder(@PathVariable Long orderId) {
orderService.completeOrder(orderId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
@GetMapping
public ResponseEntity<List<Order>> getAllOrders() {
List<Order> orders = orderService.getAllOrders();
return ResponseEntity.ok(orders);
}
}
주문 서비스(Service) 인터페이스(Interface) 선언
public interface OrderService {
Order createOrder(List<OrderItem> orderItems);
void addOrderItem(Long orderId, OrderItem orderItem);
void cancelOrder(Long orderId);
void completeOrder(Long orderId);
List<Order> getAllOrders();
}
주문 서비스(Service) 구현체(Impl) 선언
@RequiredArgsConstructor
@Service
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
@Transactional
@Override
public Order createOrder(List<OrderItem> orderItems) {
Order order = Order.createOrder(); // 엔티티의 단순 팩토리 메서드를 사용하여 생성
orderItems.forEach(order::addOrderItem); // 서비스에서 비즈니스 로직에 따라 연관 관계 설정
return orderRepository.save(order);
}
@Transactional
@Override
public void addOrderItem(Long orderId, OrderItem orderItem) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found with ID: " + orderId));
order.addOrderItem(orderItem);
orderRepository.save(order);
}
@Transactional
@Override
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found with ID: " + orderId));
if (order.getStatus() == OrderStatus.SHIPPED) {
throw new IllegalStateException("Cannot cancel a shipped order");
}
order.changeStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
}
@Transactional
@Override
public void completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found with ID: " + orderId));
order.changeStatus(OrderStatus.COMPLETED);
orderRepository.save(order);
}
@Transactional(readOnly = true)
@Override
public List<Order> getAllOrders() {
return orderRepository.findAll();
}
}
Repository 선언 (JPA 사용)
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
}
2. 전통적인 3계층 아키텍처의 역할과 책임
엔티티의 역할과 책임 (데이터의 상태를 보유하고 이를 영속성 컨텍스트에 저장)
- 일반적으로 엔티티를 설계할 때 getter만을 사용하고 setter는 사용하지 않는다. 만약 엔티티의 상태값 변경이 필요한 경우에는 엔티티 내부에 add, change 메서드를 선언해서 상태를 변경시킨다.
- 이것은 외부에서 무분별하게 setter를 사용해서 원치 않는 데이터 변경이 발생하는 것을 금지하기 위해 change, add같이 확실한 책임을 가진 이름을 정해서 메서드를 생성하고 이 메서드를 사용하도록 하는 것이다.
- 또한 이 아키텍처에서는 엔티티 내부에 비즈니스 로직을 가지지 않도록 설계한다. 이것은 엔티티가 DB 통신의 책임만 가지도록 하기 위함이다. (단일 책임 원칙을 지키도록 설계)
서비스의 역할과 책임 (비즈니스 로직 처리, 데이터 검증, 트랜잭션 관리, 데이터베이스 통신 등 여러 가지)
- 이렇게 엔티티 내부에 비즈니스를 담지 않도록 주의해서 설계하면 엔티티가 가질 수도 있었던 "db통신, 비즈니스" 2개의 책임이 "db통신" 1개의 책임으로 줄어든다. 덕분에 엔티티는 1개의 책임을 가지게 되고 비즈니스를 처리하는 책임은 서비스 계층이 담당하게 된다.
- 서비스에서는 서비스 클래스가 비즈니스 로직 처리, 데이터 검증, 트랜잭션 관리, 데이터베이스 통신(리포지토리 호출) 등 여러 책임을 가지게 된다. 사실상 이는 단일 책임 원칙에서 벗어나는 것이라고 보면 된다. (당연히 모두 SRP를 지키도록 설계하는 것은 사실상 불가능하다.)
- 전통적인 3계층 아키텍처에서 서비스 계층이 직접 JPA 리포지토리(또는 다른 데이터 접근 레이어)를 호출하는 경우, 서비스 계층은 강한 책임을 가진다고 볼 수 있다. 이는 서비스 계층과 데이터 접근 계층 간의 결합도가 높고 책임이 덜 분리되어 있는 상태라고 볼 수 있다.
리포지토리의 역할과 책임 (데이터베이스와의 CRUD 작업을 수행, 데이터 접근 로직을 캡슐화)
- JPA 리포지토리를 상속받는 Repository 클래스의 역할은 데이터베이스와의 CRUD 작업을 추상화하고 데이터 접근 로직을 캡슐화하는 것이다.
- 이를 통해 리포지토리는 데이터베이스와의 직접적인 상호작용을 서비스 계층으로부터 분리하여, 서비스가 비즈니스 로직에 집중할 수 있게 한다.
- 또한, 데이터베이스 접근 방법을 추상화하여 애플리케이션의 데이터베이스 독립성과 코드의 유지보수성을 높이는 역할을 한다.
전체적인 (역할과 책임) 정리
- 엔티티와 리포지토리는 단일 책임 원칙을 준수한다고 볼 수 있다. 각각의 역할이 명확히 분리되어 있으며, 하나의 책임에 집중하고 있기 때문이다. 다만 서비스 계층은 비즈니스 로직과 데이터 접근 로직을 모두 포함하기 때문에 단일 책임 원칙을 지키지 못한다.
- 따라서, 서비스 계층은 여러 책임을 가지기 때문에 강한 책임을 가진다고 할 수 있다. 이것을 해결하기 위해서는 서비스 계층을 더 세분화하거나, 헥사고날 아키텍처와 같은 다른 아키텍처 패턴을 사용하여 책임을 분리하고 결합도를 줄이는 방법이 있다.
3. 헥사고날 아키텍처 (도메인 사용)
헥사고날 아키텍처에 대해서는 아래의 포스팅에 남겨두었다.
- 여기에 쓰기에는 내용이 너무 방대해서 이미 적어둔 글을 연결시켜 두었습니다.
- 혹시나 헥사고날 아키텍처가 어떤 것인지 간단하게라도 알아보고자 하신다면 아래의 글을 확인해 주세요!
4. 왜 도메인 계층을 적용시키는 걸까? (개인적인 생각)
내 생각은 다음과 같다.
- 3계층 아키텍처를 보면 서비스 계층이 "비즈니스 로직 처리, 데이터 검증, 트랜잭션 관리, 데이터베이스 통신" 등의 여러 가지 책임을 가지기 때문에 이것을 분리하기 위해서 "도메인"이라는 계층을 추가하는 설계 방식이 나온 것이라고 생각했다.
- 왜냐하면 도메인을 사용하면, 서비스 계층이 담당하던 '비즈니스 로직 처리'와 '데이터 검증'의 책임을 도메인 객체로 이전할 수 있기 때문이다. 이를 통해 서비스 계층의 책임을 명확하게 줄일 수 있다.
- 즉, 기존 3계층 아키텍처에서 계층을 더 세분화시켜서 각 계층의 책임을 확실하게 분리하여 "단일 책임 원칙"을 지킬 수 있도록 하기 위함이 아닌가 생각된다. (결국 이건 객체지향의 SOLID 원칙을 지키려고 노력하는 게 아닐까? 생각했다.)
내가 실제로 도메인 객체를 사용하면서 느낀 점
- 처음에는 도메인을 사용하는 것이 어색했지만, 개발 경험이 쌓이면서 도메인을 사용하는 것이 객체지향적인 설계를 촉진한다는 점을 깨달았다. 이건 내게 매우 긍정적인 경험이었다.
- 도메인이라는 계층이 추가되면서 서비스가 가졌던 여러 가지 책임이 분리되어서 "트랜잭션 관리, 데이터 조립" 정도만 하게 되니 각 계층의 책임이 훨씬 명확해졌다. (도메인을 모델로 생각할 수도 있지만 나는 도메인이 확실한 책임을 가지니 "책임"이라고 적어두었다.)
- 또한 개발하면서 도메인 내부에 모든 비즈니스 로직을 작성하다 보니 객체 자체의 응집도가 굉장히 높아지게 되었다. 내가 따로 신경 쓰지 않아도 이런 식으로 자연스럽게 객체지향적인 코드 작성이 진행되는 것을 느낄 수 있었다.
- 응집도가 높다 보니 이 프로젝트를 처음 보는 사람이 "도메인"만 잘 살펴봐도 관련된 비즈니스를 바로 알 수 있다는 장점도 있었다. (서비스 메서드를 하나하나 읽으며 따라가며 디버깅해보지 않아도 일단 무슨 비즈니스가 있는지는 빠르게 파악이 가능하다.)
- 마지막으로 정말 중요하게 생각하는 것인데 비즈니스에 대한 "단위 테스트 작성"이 너무 편하다고 느껴졌다. 왜냐하면 기존 3계층 아키텍처에서는 서비스가 비즈니스를 처리하다 보니 테스트를 작성할 때 비즈니스뿐만 아니라 "검증", "db연결" 같은 책임들도 함께 고려하며 테스트를 작성해야 하는 번거로움이 있었다. 그러나 도메인을 사용하니 도메인 내부에 비즈니스가 응집되어 있어서 "비즈니스"에 대한 단위테스트를 정말 깔끔하고 쉽게 작성해서 검증작업을 진행할 수 있었다.
그러나 도메인을 사용한다고 장점만 있는 것은 아닌 것 같다.
- 분명 이렇게 도메인 내부에 비즈니스 로직을 모아두니 응집도가 높아지고 각 계층이 가지는 책임이 명확해졌다. 그러다 보니 개발하면서 객체끼리의 협력 "다른 객체에 메시지(요청: 메서드 호출)를 보내면 메시지를 받은 객체는 내부에서 "스스로 판단하고 행동한다."라는 객체지향의 개념에 대해서도 큰 깨달음을 얻을 수 있었다. (비즈니스를 객체가 내부적으로 알아서 처리하는 것)
- 하지만 도메인을 사용하면서 개발하다 보니, 뭔가 중요한 것을 놓치고 있는 것은 아닐까 하는 생각이 들었다. 최근에는 객체지향 원칙을 준수하려고 많이 노력하면서, 모든 상황에서 SOLID 원칙을 적용하는 것이 현실적으로 가능한지에 대해 깊이 고민하게 되었다. 그러다 보니 너무 개발론만을 지키면서 개발하려는 성향이 커지는 것 같았다. (FM만 지키는 사람같이 말이다.)
- 특히 도메인을 사용하면서 SOLID와 객체지향적인 설계방식을 완벽히 지키면서 개발하려고 하다 보니 특정 상황에는 오히려 코드가 복잡하고 어려워지는 모습을 볼 수 있었다. (예시: 한 도메인 만으로는 비즈니스 처리가 불가능하고 도메인 내부에서 다른 도메인의 비즈니스의 협력이 필요한 경우에는 의존성을 가지게 하는 것에 대한 고민을 하게 된다.)
- 우리가 프로젝트를 할 때는 분명 정해진 기획이 존재하기 때문에 이 기획의 "정책" 자체를 변경해서 단일 책임을 지키도록 하고 SOLID를 지키도록 개발할 수는 있지만 그것이 생각보다 쉽지는 않다. (기획자 분들을 어떻게 설득할지 고민해야 하고 그 정책을 바꿨을 때 발생하는 사이드 이팩트도 고려해야 한다.)
- 이로 인해 비즈니스 로직 작성이 딱딱해지고 팀 내 소통에도 문제가 발생할 수 있다는 것을 느꼈다. (왜냐하면 모든 개발자가 SOLID 원칙이나 객체지향적인 개발론을 지향하는 것은 아니기 때문이다.) 어떤 경우에는 여러 책임을 한 곳에 모아두는 것이 더 간단할 수도 있지만, 단일 책임 원칙을 지키기 위해 과도하게 분리하면 비즈니스 로직이 지나치게 복잡해질 수 있다.
- 물론 한 메서드가 여러 책임을 가지면 추후 하나를 수정했을 때 발생하는 "사이드 이팩트"가 존재한다는 것은 알고 있다. 그러나 이것은 어느 정도 감수가 필요한 상황도 있다고 느꼈고 어떻게든 "trade off"를 해야 할 부분이라고 생각한다. 이 문제를 해결하기 위해 EDA(Event-Driven Architecture)를 도입하는 방법도 고려해 봤다. 그러나 이벤트를 사용할 때도 주요 관심사와 비관심사를 명확히 구분하는 것이 중요하며, 내가 설정한 비관심사가 실제로 맞는지에 대한 검토가 필요했다.
- 또한 도메인 객체를 사용하면서 '도메인 to 엔티티', '엔티티 to DTO', '도메인 to DTO'와 같은 변환 작업이 많아져 로직이 복잡해지는 문제가 발생했다. 이것을 개선하기 위해 객체 간 변환 작업을 자동화해 주는 mapstruct 라이브러리를 사용한다거나 factory method를 만들어두어도 허점이 있어서 오류가 자주 발생했다. (컴파일에서 문제를 찾기도 힘들다.)
- 즉, 개발자가 변환과정에서 실수로 코드를 잘못 작성하면 계속해서 잘못된 데이터를 사용해서 변환이 될 수도 있다는 것이다. 어떤 데이터가 잘못 들어가고 있는지도 모르고 사용하게 될 수도 있다. (도메인을 사용하게 되면서 변환과정을 거쳐야 하고 이것의 정확성을 고려해야 하는 복잡성을 얻게 되었다.)
도메인뿐만 아니라 아키텍처도 중요하다.
- 사실 전통적인 3계층 아키텍처에도 도메인을 사용할 수 있다고 생각한다. 다만 서비스가 가지는 (비즈니스 로직 처리, 데이터 검증, 트랜잭션 관리, 데이터베이스 통신) 이것들 중 (비즈니스 로직 처리, 데이터 검증) 이렇게 2개만 해결할 수 있다. "데이터 검증"에 대해서는 해결할 수 없다. (그래서 아키텍처 또한 중요하다는 것이다.)
- 그럼 이 문제를 어떻게 해결해야 할까? 나는 이것을 해결하기 위해 헥사고날 아키텍처가 만들어진 것이라고 생각한다. 헥사고날 아키텍처에서는 "port"라는 인터페이스와 adapter라는 port 구현체 클래스를 사용하도록 하는 방식을 사용한다. 이렇게 함으로써 db와의 통신은 adapter 클래스가 처리하도록 책임을 위임한다.
- 그러나 서비스 계층에서는 직접 repository 클래스를 호출해서 db 통신을 하지 않을 뿐이지 db 통신 책임을 위임받은 port는 서비스에서 직접 호출해야 한다. 그래서 나는 이것을 "느슨한 책임"을 가지는 것이라고 생각했다. 그러나 서비스는 어떤 상황이든 db 통신 작업을 요청하는 것은 해야 하기 때문에 이렇게 port를 사용하는 것이 느슨하게라도 책임을 분리하기 위한 최선의 방안이었다고 생각한다. (어쨋든 호출은 해야 한다.)
5. 도메인 객체 + 헥사고날 아키텍처를 사용함으로써 "책임"을 분리한다.
헥사고날 아키텍처의 장점
- 헥사고날 아키텍처(Hexagonal Architecture, 또는 Ports and Adapters Architecture)를 사용하면, 책임이 보다 명확하게 분리되고, 시스템이 더 유연해지며 변경에 강한 구조가 된다.
- 또한 헥사고날 아키텍처에서는 서비스가 포트를 호출하고, 구현체(어댑터)가 실제 데이터베이스 통신을 처리하기 때문에 기존 3계층 아키텍처보다는 단일 책임 원칙(Single Responsibility Principle, SRP)을 더 잘 지키는 것으로 볼 수 있다.
헥사고날 아키텍처에서의 책임 분리 (도메인 객체를 사용할 때)
핵심 비즈니스 로직과 인프라스트럭처의 분리
- 헥사고날 아키텍처에서 서비스는 도메인을 사용해서 비즈니스를 처리하고, 이것을 조율을 해줄 뿐 외부와의 통신이나 데이터베이스 접근 등의 인프라스트럭처 작업을 직접 수행하지 않는다. (port를 호출해서 adapter에게 작업을 위임한다.)
- 조금 자세히 설명하자면 서비스 계층은 포트(Port) 인터페이스를 통해 외부에 의존하며, 이 포트 인터페이스의 구현체는 어댑터(Adapter)로서 외부 시스템(예: 데이터베이스, 외부 API)과의 실제 통신을 담당한다.
헥사고날 아키텍처 평가
느슨한 결합과 높은 응집도
- 헥사고날 아키텍처는 포트 인터페이스를 통해 애플리케이션 로직과 외부 시스템 간의 결합을 느슨하게 만든다. 이는 변경에 유연하게 대처할 수 있게 해 주며, 테스트도 용이하게 만들어 준다.
- 포트를 통해 어댑터를 호출하는 구조이기 때문에, 서비스와 데이터베이스 간의 결합이 느슨해지고 책임이 분리되며, 각 부분이 고유의 역할을 명확히 하게 된다. (최대 장점 중 하나인 것 같다.)
책임의 느슨한 분리
- 서비스는 여전히 포트를 호출하여 어댑터의 기능을 사용하기 때문에, "db에 대한 어떤 책임도 가지지 않는다"라고 말할 수는 없다. 그러나 실제로 데이터베이스에 접근하거나 인프라스트럭처 관련 로직을 포함하지 않기 때문에, "책임은 있지만 매우 느슨하게 결합"된 상태로 볼 수 있다. 그래서 "느슨한 책임"을 가진다고 볼 수 있다.
글을 읽어주신 모든 분들께 감사합니다. 앞으로는 경험을 통해 얻은 정보들을 이런 식으로 조금씩 정리해보려고 합니다.
감사합니다 :)
'Spring + Java' 카테고리의 다른 글
[Java] Enum NPE 문제 빠르게 해결하기 (feat. equals, switch, AttributeConverter) (0) | 2024.11.03 |
---|---|
[Java] 메서드 추출(Extract Method)로 복잡한 비즈니스 로직 개선하기 (0) | 2024.11.02 |
[Spring] synchronized를 사용한 동시성 문제 해결방법 (1) | 2024.06.07 |
[Spring] StackTrace 상세분석 (예외처리) (0) | 2024.03.30 |
[Spring] 자바 리플렉션과 생성자 주입의 관계 (1) | 2023.11.19 |