도메인이 그리도 중요한가요 ㄷㄷㄷ
ㄷㄷㄷ
DDD(Domain-Driven Design)는 소프트웨어를 데이터 구조나 기술 스택 중심이 아니라 비즈니스 문제 중심으로 설계하는 방법론이다.
이커머스를 개발한다고 치면 개발자들(특히 나)의 시각에서 처음 머릿속에서 기술 설계는 이렇게 시작한다.
▎ 테이블은 뭐가 필요하지? users, orders, products...
DDD는 이렇게 시작한다.
▎ 주문이 뭐지? 주문은 어떤 상태를 가지지? 주문 취소는 언제 어떤식으로 가능하지?
비즈니스 전문가와 개발자가 같은 언어로 대화하고 그 언어가 코드에도 그대로 반영되는 걸 목표로 한다.
들어가며
최근 DDD와 아키텍쳐 패턴들에 대해 공부를 하면서 뭔가 머리로는 알 것 같은데 뭔가 와닿지는 않는 느낌을 많이 받았다.
마침 적용해보기 너무 좋은 소스인 예전에 진행했던 팀 프로젝트(리팩토링 必)가 있어서 그걸 직접 리팩토링해보기로 했다. 당연히 적용한 패턴은 DDD + 헥사고날 아키텍처다. DDD는 원칙이고 헥사고날(Ports + Adapters)은 그걸 구현하는 방식 중 하나라고 보면 된다.
[Architecture]
레이어드 vs 헥사고날
우선 어떤 아키텍처로 진행할지 정해야 했다. 두 구조 모두 DIP를 지향하지만 방식에 차이가 있다.
레이어드는 위에서 아래로 단방향 의존을 강제한다.
▎ Controller → Service → Repository → DB
도메인이 인프라 인터페이스를 바라본다. DIP를 지키려면 Repository 인터페이스를 도메인 패키지에 둬야 하는데 이건 팀 규율에 달려 있다. 구조가 강제하지 않는다.
헥사고날은 도메인을 중심에 놓고 인프라가 도메인을 바라보게 한다.
▎ Controller → Facade → Port ← Adapter → DB
화살표 방향이 다르다. Port ← Adapter. 인프라(Adapter)가 도메인(Port)을 향해 의존한다. Port는 도메인 패키지 안에 있으니, 인프라 타입이 도메인으로 새는 걸 구조 자체가 막는다.
레이어드는 도메인이 인프라 인터페이스를 바라보고, 헥사고날은 인프라가 도메인 인터페이스를 바라본다.
| 계층 | 레이어드 | 헥사고날 |
|---|---|---|
| Presentation | Controller | Controller |
| Application | Service | Facade |
| Domain | Entity, Repository 인터페이스 | Entity, Port |
| Infrastructure | Repository 구현체, JPA | Adapter, JPA |
"그래서 언제 뭘 써야 유리해요?"
우선 Claude가 알려준 경우는 이렇다.
헥사고날이 유리한 경우
- 주문, 정산, 재고처럼 비즈니스 로직이 두텁고 복잡한 도메인
- DB나 외부 시스템 교체 가능성이 있을 때
- 나중에 MSA로 분리할 계획이 있을 때 (Port 경계가 그대로 서비스 간 계약이 된다)
- 팀이 크고 도메인 경계를 구조적으로 강제해야 할 때
레이어드가 유리한 경우
- CRUD 위주의 단순한 도메인
- MVP 검증 단계처럼 속도가 우선일 때
- 팀이 작고 Port/Adapter 개념에 익숙하지 않을 때
이걸 보니 오히려 판단이 더 어려워졌다.
아니 그럼 단순히 서비스가 복잡하면 헥사고날을 쓰고 단순하면 레이어드를 쓰라는건가?
복잡해도 레이어드가 더 유리할 수는 없는건가?
비즈니스 로직이 어느정도 규모가 돼야 복잡한건데?
꼭 그렇진 않다. 복잡해도 레이어드가 나은 경우가 있다.
- 팀이 레이어드에 숙달됨 — 좋은 구조보다 팀의 실행력이 먼저다
- 비즈니스 방향이 불확실함 — 빠른 피벗이 우선, 설계 비용이 발목을 잡는다
- DIP를 엄격하게 지킬 수 있음 — 레이어드도 원칙만 지키면 헥사고날과 차이가 작다
진짜 기준은 복잡도가 아니다. "도메인 경계를 구조가 강제해야 하는가" 가 기준이다.
- 팀 규율로 경계를 지킬 수 있다 → 레이어드
- 구조가 강제하지 않으면 무너질 것 같다 → 헥사고날
복잡도는 간접 지표일 뿐, 직접 기준은 아니다.
뭐가 더 좋으니 이거 쓰라는 정답은 없다. 현재 팀의 상태와 서비스가 필요로 하는 환경, 그리고 지금 어떤 비용을 감당할 수 있느냐(트레이드 오프)의 문제다.
난 이번에 도메인 로직을 인프라(JPA), 외부 기술(S3, k8s, Email 등)로부터 완전히 분리하고 싶었기 때문에 헥사고날을 선택했다.
[Refactoring]
도메인에 왜 인프라가 있으면 안되나?
사실 항상 그런 건 아니다. 팀이 작고 DB를 바꿀 일이 없고 테스트를 @SpringBootTest로 다 돌려도 감당되는 규모라면 도메인에 JPA가 있어도 서비스는 잘 돌아간다. 이건 옳고 그름이 아니라 트레이드오프다. 다만 팀이 커지고 도메인이 복잡해질수록 아래 두 비용이 점점 무거워진다.
리팩토링 전 domain/ 패키지 안에 이런 파일이 있었다.
// domain/user/repository/UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
domain/ 패키지인데 extends JpaRepository가 있다. 당연히 도메인이 JPA라는 기술을 직접 알고 있는 상태다.
단순히 생각해봐도 DB를 바꾸면 도메인도 뜯어고쳐야 한다는 문제가 있다. 실제로 발생하는 문제는 두 가지다.
- 도메인 코드를 단독으로 컴파일하면 Spring이 없어서 실패한다
- 비즈니스 로직 테스트 하나를 돌리려면 JPA 컨텍스트를 전부 띄워야 한다
그냥 Repository를 분리해서 infrastructure 패키지로 옮기면 안되나?
처음 이렇게 간단하게 생각을 했다. 사실 이것만 해도 domain/ 패키지는 정리된다. 근데 정작 중요한 한 단계가 빠진다.
// infrastructure로 옮겨도 UserRepository는 여전히 JpaRepository 확장
public interface UserRepository extends JpaRepository<User, Long> {}
// ServiceImpl은 여전히 이걸 직접 주입받는다
@Autowired private UserRepository userRepository; // JPA 인터페이스 직접 의존
Repository가 infra 패키지로 갔지만 ServiceImpl이 여전히 JpaRepository를 확장한 인터페이스를 직접 알고 있다. 위치만 바뀐 거지, Service와 JPA의 관계는 그대로다.
그래서 Port 인터페이스를 추가하였다.
경계선이다. 구현체에는 영향을 주지 않으며 필요한 것만 명시해놓는다.
// domain/user/port/UserPort.java — JPA 없음, 순수 Java 인터페이스
public interface UserPort {
Optional<User> findById(Long id);
User save(User user);
}
// infrastructure/persistence/user/UserAdapter.java — JPA는 여기만
public class UserAdapter implements UserPort {
private final JpaUserRepository jpa;
public Optional<User> findById(Long id) {
return jpa.findById(id);
}
}
Port는 도메인의 선언이다. JPA로 줄지 MyBatis로 줄지는 Adapter가 결정한다. 도메인이 알 필요가 없다.
기존 Service 인터페이스랑 Port가 뭐가 달라? 똑같아 보이는데
이게 처음에 제일 헷갈렸다.
기존에도 WargameService 인터페이스가 있었고 WargameServiceImpl이 구현했다. Port도 인터페이스고 Adapter가 구현한다. 자세히 보지 않으면 구조만 봤을 때 똑같아 보인다.
차이는 어느 패키지에 인터페이스가 있느냐다. 그리고 그게 의존성 방향을 뒤집는다.
기존:
domain/repository/UserRepository extends JpaRepository ← domain이 JPA를 알고있음
현재:
domain/port/UserPort (JPA 없음)
infrastructure/UserAdapter implements UserPort ← infrastructure가 domain을 알고있음
기존에는 도메인이 인프라를 알았다. 지금은 인프라가 도메인을 안다.
DIP가 거창한 말처럼 들리는데, 여기선 실제로 패키지 한 칸 차이이다.
Facade? Service에서 그냥 타도메인 직접 주입하면 안돼?
기존에 별 생각 없이 개발하면서 편하게 사용하던 방식이다. 근데 그게 이미 문제였다. (당연히 당시엔 문제가 될 수 있는지조차 몰랐다.)
리팩토링 전 Controller 코드를 보면,
// WargameController
@Autowired private WargameService wargameService;
@Autowired private BadgeService badgeService;
@Autowired private UserService userService;
@Autowired private KubernetesPodService kubernetesPodService;
Controller가 HTTP 처리를 하면서 동시에 여러 도메인을 조합하고 있다. Controller의 역할이 아닌 타 서비스까지 책임을 지고 있었다. "flag 제출" 같은 요청이 오면 Controller가 직접 다 도메인의 비즈니스 로직을 순서대로 호출해야 했다.
이 부분은 Facade를 추가하여 책임을 가져갔다.
// 현재 WargameController
@Autowired private WargameFacade wargameFacade; // 하나만
// WargameFacade.submitFlag()
public ResSubmitFlagDto submitFlag(Long userId, Long wargameId, String flag) {
// 1. flag 검증
boolean isCorrect = wargameDomainService.isCorrectFlag(wargameFlag.getFlag(), flag);
if (isCorrect) {
// 2. 풀이 기록
solvedWargamePort.save(...);
// 3. 점수 반영
userStatsPort.updateSolvedCount(...);
// 4. 배지 부여
badgeFacade.checkAndAssignBadges(user);
}
}
- Controller — 요청/응답만 처리
- Facade — 유스케이스 흐름 제어 (어떤 순서로 뭘 호출할지)
- Service/Port — 각자의 도메인 책임만
Facade도 타도메인을 주입받는다. BadgeFacade를 WargameFacade에서 주입받는다. Service랑 구조는 비슷하지만, Facade 안에는 JPA(인프라 레이어)가 없다는 게 핵심 차이다.
테스트도 훨씬 수월하게
Port가 생기면서 이제 DB 없이 테스트가 가능해진다. 기존에는 서비스에서 JpaRepository를 직접 다뤘기 때문에 테스트를 위해서는 DB가 필수였다.
// 기존 — Spring 전체 띄워야 테스트 가능
@SpringBootTest // DB 연결, JPA 설정, 빈 전체 로딩...
class UserServiceTest { }
// 현재 — Port를 가짜로 구현하면 끝
class FakeUserPort implements UserPort {
private Map<Long, User> store = new HashMap<>();
public Optional<User> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public User save(User user) {
store.put(user.getId(), user);
return user;
}
}
class UserFacadeTest {
UserFacade facade = new UserFacade(new FakeUserPort()); // DB 없음, 순수 Java
@Test
void 유저가_없으면_예외() { ... }
}
JpaRepository는 DB 연결이 있어야 존재할 수 있다.UserPort는 순수 인터페이스라서 HashMap으로 구현한 Fake를 꽂을 수 있다.
데이터 흐름
HTTP 요청
│
Controller ─ HTTP 처리만. 비즈니스 로직 없음
│
Facade ─ 어떤 순서로 뭘 호출할지
│
├── DomainService ─ 비즈니스 규칙 (flag 검증, 점수 계산)
│
└── Port ─ "이 데이터 줘"
│
Adapter ─ Port 구현. JPA 호출
│
DB
flag 제출 한 번의 흐름:
POST /wargame/{id}/submit
│
WargameController
│
WargameFacade.submitFlag()
├─ WargameDomainService.isCorrectFlag() ← flag 비교 로직
├─ SolvedWargamePort.save() → DB
├─ UserStatsPort.updateSolvedCount() → DB
├─ BadgeFacade.checkAndAssignBadges() → DB
└─ KubernetesPodPort.delete() → Kubernetes API
각 레이어가 딱 자기 관심사만 다룬다.
단순히 구조만으로도 충분하다
다른 라이브러리를 사용하거나 거창한 기술을 추가한 것이 아니다. 바뀐 건 패키지 구조뿐이다.
JPA 의존 → 원래 domain/에서 씀 → 이제 infrastructure/에서만 씀
K8s 의존 → 원래 service/에서 씀 → 이제 infrastructure/에서만 씀
S3 의존 → 원래 service/에서 씀 → 이제 infrastructure/에서만 씀
런타임 동작은 같다. 빌드 결과물도 같다. 순전히 파일을 올바른 자리에 놓은 것뿐이다.
- JPA를 바꾸고 싶으면
infrastructure/안에서만 작업하면 된다. 도메인 코드는 손대지 않는다. - 테스트에서 DB가 없어도 된다.
- 새 기능 추가할 때 영향 범위가 명확하다.
마치며
기존 코드를 예전에 짤 때는 나름 괜찮게 했다고 생각하고 진행했었는데 사실 문제가 많았다. 레이어 간 경계가 없었으며, 도메인이 인프라를 알고, Service가 타도메인을 알고, Controller가 비즈니스 로직을 알았다.
하나하나 뜯어보면서 적용할 때는 왜 이렇게까지 해야 할까 이해가 힘들었는데, 다 끝내고 전체 구조를 보고 이해하니까 매우매우매우 아름다운 구조라는 느낌이 들었다. 사실 주니어 입장에서 뭔가 좋은 코드다 라는 걸 단번에 알기는 어렵다(안다고 착각하는 경우는 많을지도...) 점점 경험이 쌓이고 좋은 코드를 많이 보고 공부할수록 정말 조금씩 보이고 시야가 넓어지는 것 같다.