[order] 엔티티는 왜 데이터만 들고 있으면 안 될까?
나는 코드를 잘 짜고 있는 줄 알았다
과거 프로젝트를 진행할 때, 나의 1순위 목표는 가독성이었다. 그래서 서비스 로직을 메인 메서드와 여러 개의 private 서브 메서드로 쪼갰다. 그리고 메인 메서드에서 private 메서드를 호출하여 로직을 처리하도록 만들었다. 이렇게 했더니 자잘한 기능별로 분리되어 코드가 한눈에 들어왔고, 관련된 로직이 하나의 서비스 클래스 안에 모여있어 유지보수가 편해졌다고 자부했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 예시로 가져온 집현전 메인-서브 메서드 구조
// 메인 메서드 - 주소 분리 (시군구, 동, 번, 지, 동, 호)
public Map<String, String> splitAddressDetails(String address) {
String[] parts = address.split(" ");
String district = splitDistrict(parts);
...
return addrDetails;
}
// 서브 메서드 - 시, 군, 구 분리
private String splitDistrict(String[] parts) {
...
return parts[0];
}
객체지향이 아니라 절차지향을 쓰고 있었다.
하지만 부동산 관련 프로젝트(집현전)를 진행하면서 문제가 있었다. 문자열로 된 전체 주소를 받아서 시군구를 분리하고 그 시군구로 법정동코드를 매칭하는 서비스가 있었다. 처음에는 위험 조회 서비스에서만 필요했는데 이후 다른 API에서도 법정동코드를 요구하는 곳들이 늘어나면서 다른 서비스에서도 똑같은 주소 변환 로직이 필요해진 것이다.
하지만 기존의 주소 변환 메서드는 private으로 강하게 묶여있어 재사용이 불가능했다. 더 큰 문제는 주소와 관련된 규칙이 특정 서비스 내부에만 존재하다 보니, 어떤 로직이 어디에 있는지 파악하기 어려워졌다는 점이었다. 결국 코드를 그대로 복붙하거나, 다른 서비스 클래스를 의존성 주입 받아 억지로 끌어다 쓰는 결합도 높은 상황이 발생했다.
당시에는 단순히 재사용성의 문제라고 생각했다. 하지만 원인을 되짚어보니 더 근본적인 문제는 서비스 클래스가 데이터 처리와 비즈니스 규칙을 모두 책임지고 있었다는 점이었다. 엔티티는 데이터를 담는 역할만 수행했고, 실제 행동은 모두 서비스에 집중되어 있었다. 이는 객체는 상태와 행위를 함께 가져야 한다는 객체지향 원칙과는 거리가 먼 구조였다. (집현전 프로젝트는 결국 이 구조로 마무리 되었다.)
도메인 모델 패턴 (DDD)
이러한 문제를 겪으며 서비스 계층에 비즈니스 로직이 집중되는 구조에 한계를 느꼈다. 이를 해결할 방법을 찾던 중 도메인 모델 패턴(DDD)을 접하게 되었고, 새롭게 개발한 주문 흐름 프로젝트(Order-system)에서는 처음부터 해당 방식을 적용해 보기로 했다.
도메인 모델 패턴(DDD)을 학습하고 적용했다. 도메인 모델 패턴을 학습하며, 엔티티를 단순한 데이터 저장소가 아니라 비즈니스 규칙을 스스로 보장하는 객체로 설계하기 시작했다. 데이터의 상태를 변경하는 로직을 엔티티 내부로 옮기니, 데이터와 로직이 한 곳에 뭉쳐 응집도가 높아졌고 외부에서 안전하게 객체를 사용할 수 있게 되었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 주문 시스템의 Order 엔티티
public void paid() {
if (!this.status.equals(OrderStatus.CREATED)) {
throw new IllegalStateException("주문 생성 상태에서만 주문을 완료할 수 있습니다.");
}
this.status = OrderStatus.PAID;
}
public void preparing() {
if (!this.status.equals(OrderStatus.PAID)) {
throw new IllegalStateException("결제 완료 상태에서만 배송 준비할 수 있습니다.");
}
this.status = OrderStatus.PREPARING;
}
과거 방식처럼 서비스 클래스에서 모든 로직을 처리했다면, 주문 상태 변경 규칙이 서비스에 계속 쌓이면서 서비스 계층의 책임이 커지고, 관련 로직을 추적하기 어려워졌을 것이다. 하지만 처음부터 도메인 모델 패턴을 적용하여 비즈니스 책임을 각 엔티티로 분산시키니 파일별 책임을 명확히 나누고 코드의 복잡도를 낮출 수 있었다.
Q&A
막상 로직을 엔티티로 옮기고 나니 새로운 의문이 들었다. ”그럼 컨트롤러는 서비스로 넘기고, 서비스는 다시 엔티티로 메서드만 호출해주는데 이거 불필요한 과정만 늘어나고 비효율적인 거 아닐까?”
고민 끝에 내가 놓치고 있던 것은 역할과 책임의 명확한 분리라는 것을 깨달았다.
- 컨트롤러(Controller): 서빙 직원이다. 클라이언트의 주문(요청)을 받아 주방(서비스)에 전달하고, 완성된 음식(응답)을 내어주는 역할만 해야 한다.
- 서비스(Service): 주방의 매니저다. 직접 비즈니스 규칙을 구현하기보다는, 어떤 객체가 어떤 순서로 동작해야 하는지 조율한다.
- 엔티티(Entity): 요리사다. 외부의 간섭 없이 자신의 데이터(재료)를 가지고 안전하게 핵심 비즈니스 로직(요리)을 수행한다.
책임 분리에도 많은 고민이 필요하다.
새로운 프로젝트에 DDD를 적용해보니, 무작정 코드를 잘게 쪼개는 것이 좋은 설계의 전부가 아님을 깨달았다. 진정한 설계는 이 데이터의 상태를 변경하는 진짜 책임은 누구에게 있는가?를 치열하게 묻는 것에서 시작한다. 물론 모든 로직을 엔티티에 넣는 것은 아니다. 외부 API 호출이나 여러 엔티티를 조합하는 작업은 서비스 계층의 책임이다. 다만 엔티티 자신의 상태를 변경하는 규칙만큼은 엔티티 스스로 관리하도록 설계했다.
처음 도메인 모델 패턴으로 코드를 작성할 때는 낯설고 설계가 어렵게 느껴졌지만, 책임을 명확히 분리해 둔 덕분에 요구사항이 추가되거나 변경되어도 두려움 없이 안전하게 확장이 가능한 유연한 구조를 얻게 되었다.