도메인 중심 설계로 인프라에 독립적인 비즈니스 로직 설계
하위 레이어에 의존적인 아키텍처
아래는 서비스를 개발하다 보면 흔히 볼 수 있는 소프트웨어 아키텍처의 구조입니다.
Layered Architecture
Service가 JPA Entity에 직접 의존 — DB 변경 시 비즈니스 로직도 함께 변경됨
많은 블로그의 포스트들이나 문서들이 아래와 같이 설명하고 있습니다.
레이어드 아키텍처란?
- 소프트웨어를 여러 개의 계층으로 분리해서 설계하는 방법
- 각각 계층이 서로 독립적으로 구성되어 있어서 한 계층의 변경이 다른 계층에 영향을 주지 않게 설계할 수 있다.
- 외부의 요구사항이나 세부적인 구현이 변화하더라도 도메인의 로직을 변경하지 않도록 보호하기 위해서 계층화를 하게 된다.
하지만 우리가 개발하는 대부분의 코드는 아래와 같습니다.
@Entity
@Getter
@Setter
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
private BigDecimal totalAmount;
private LocalDateTime createdAt;
public Order(String orderNumber, BigDecimal totalAmount) {
this.orderNumber = orderNumber;
this.totalAmount = totalAmount;
this.createdAt = LocalDateTime.now();
}
public Order() {}
}@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
}@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public OrderResponse createOrder(String orderNumber, BigDecimal totalAmount) {
Order order = new Order(orderNumber, totalAmount);
Order savedOrder = orderRepository.save(order);
return new OrderResponse(savedOrder);
}
}@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping
public OrderResponse createOrder(@RequestParam String orderNumber,
@RequestParam BigDecimal totalAmount) {
return orderService.createOrder(orderNumber, totalAmount);
}
}진짜 문제는 "의존성"
위 코드를 보시면 여러 계층으로 분리되어 있고, 각각이 독립적이며, 구현이 변해도 도메인은 안전해 보입니다.
하지만 여기에 함정이 숨어있습니다. 제가 코드를 분석하다가 발견한 문제가 세 가지 있었습니다:
- ServiceLayer가 JPA 엔티티에 의존 - Order는 이미 JPA 애노테이션과 강하게 결합됨
- ServiceLayer가 DTO에 의존 - OrderResponse는 REST API 응답 형식으로 강하게 결합됨
- ServiceLayer가 구현체에 의존 - JpaRepository를 상속한 OrderRepository에만 작동함
문제는 단순히 "계층을 분리했다"는 것만으로는 부족했습니다. **"누가 누구에게 의존하고 있는가"**가 훨씬 더 중요합니다.
문제 1: 데이터베이스가 바뀌면 비즈니스 로직도 깨진다
제가 처음 마주친 질문은 이거였습니다:
데이터베이스를 MongoDB로 바꾼다면 어떻게 해야 할까?
Order는 JPA 엔티티입니다. @Entity, @Table, @Id 같은 애노테이션들이 가득합니다. 이건 RDB에 매우 강하게 결합된 설계입니다.
MongoDB로 전환한다면? JPA 애노테이션들을 모두 제거하고 Spring Data MongoDB의 애노테이션으로 바꿔야 합니다. 엔티티 구조 자체를 재설계해야 할 수도 있습니다.
그런데 여기서 발견한 더 큰 문제가 있었습니다. Order 엔티티에는 단순한 데이터만 아니라 주문 생성 로직, 금액 계산 로직 같은 비즈니스 로직들이 섞여있었습니다.
문제 2: 인터페이스가 변하면 서비스도 함께 변한다
REST API 대신 Kafka 메시지 기반으로 변경해야 한다면?
OrderService는 OrderResponse DTO를 반환합니다. 이 DTO는 REST API 응답 형식에 맞춰 설계된 것입니다.
만약 동일한 비즈니스 로직을 Kafka 메시지 기반으로 노출해야 한다면? Service는 더 이상 OrderResponse를 반환하는 게 아니라 OrderEvent 같은 객체를 반환해야 합니다. Service가 통신 방식의 변경에 영향을 받게 됩니다.
근본 원인: "계층의 분리"가 아니라 "의존성의 방향"
이 두 가지 문제를 분석하다 보니 패턴이 보였습니다:
상위 계층(Service)이 하위 계층(Persistence, Presentation)에 의존하고 있다는 것이었습니다.
계층을 분리하는 것만으로는 부족합니다. 의존성의 방향을 뒤집어야 합니다.
해결책: 의존성을 역전시키자
문제의 핵심은 명확했습니다:
하위 계층(Infrastructure)이 Service를 지배하지 말고, Service가 자신이 필요로 하는 인터페이스를 정의하도록 해야 한다.
이를 위해 저는 JPA 엔티티와 도메인 객체를 완전히 분리하는 방식을 선택했습니다.
주요 개선 사항은:
- 데이터베이스 변경으로부터 자유로워진다 — Infrastructure 계층만 교체하면 됨
- 비즈니스 로직이 비즈니스 규칙으로 설계된다 — 역할과 책임 중심으로 설계 가능
- 도메인이 진정한 불변 객체가 될 수 있다 —
final키워드 사용, CQS 원칙 준수
1단계: 도메인과 인프라를 분리
Before: 단순한 분리 시도
@Entity
@Getter
@Setter
public class Order { // JPA와 비즈니스 로직이 섞여있음
@Id
private Long id;
private String orderNumber;
private BigDecimal totalAmount;
}
@Service
public class OrderService {
private final OrderRepository orderRepository;
public Order createOrder(String orderNumber, BigDecimal totalAmount) {
Order order = new Order(orderNumber, totalAmount);
return orderRepository.save(order);
}
}데이터베이스가 MariaDB에서 MongoDB로 바뀌는 순간, 비즈니스 로직도 함께 변해야 합니다.
After: 계층을 명확하게 분리
도메인 객체:
public class Order {
private final Long id;
private final String orderNumber;
private final BigDecimal totalAmount;
private final LocalDateTime createdAt;
public Order(String orderNumber, BigDecimal totalAmount) {
this.orderNumber = orderNumber;
this.totalAmount = totalAmount;
this.createdAt = LocalDateTime.now();
}
public String getOrderNumber() { return orderNumber; }
public BigDecimal getTotalAmount() { return totalAmount; }
public LocalDateTime getCreatedAt() { return createdAt; }
}JPA 엔티티는 순수하게 ORM 매핑만 담당합니다:
@Entity
@Getter
@Setter
public class OrderEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
private BigDecimal totalAmount;
private LocalDateTime createdAt;
}리포지토리 인터페이스를 정의하여 도메인에 의존하도록 합니다:
public interface OrderRepository {
Order save(Order order);
Order findById(Long id);
}JPA 구현체는 도메인과 JPA 엔티티 사이의 매핑을 담당합니다:
@Repository
public class OrderRepositoryImpl implements OrderRepository {
private final OrderJpaRepository jpaRepository;
@Override
public Order save(Order order) {
OrderEntity entity = new OrderEntity();
entity.setOrderNumber(order.getOrderNumber());
entity.setTotalAmount(order.getTotalAmount());
entity.setCreatedAt(order.getCreatedAt());
OrderEntity saved = jpaRepository.save(entity);
return new Order(saved.getOrderNumber(), saved.getTotalAmount());
}
@Override
public Order findById(Long id) {
return jpaRepository.findById(id)
.map(entity -> new Order(entity.getOrderNumber(), entity.getTotalAmount()))
.orElseThrow();
}
}이제 MongoDB로 변경해야 한다면?
@Repository
public class OrderMongoRepository implements OrderRepository {
private final OrderMongoDao mongoDao;
@Override
public Order save(Order order) {
OrderDocument doc = new OrderDocument();
doc.setOrderNumber(order.getOrderNumber());
doc.setTotalAmount(order.getTotalAmount());
mongoDao.save(doc);
return order;
}
@Override
public Order findById(Long id) {
return mongoDao.findById(id)
.map(doc -> new Order(doc.getOrderNumber(), doc.getTotalAmount()))
.orElseThrow();
}
}결과는 이렇습니다:
- OrderService는 건드리지 않음
- 도메인 Order도 변경하지 않음
- 구현체만 MongoDB 버전으로 교체
2단계: 역할과 책임으로 설계
데이터 중심 vs 책임 중심의 차이
JPA 엔티티는 데이터 중심일 수밖에 없습니다. RDB의 테이블 구조를 반영하니까요.
하지만 도메인 객체는 역할과 책임으로 설계할 수 있습니다. 데이터 구조에 얽매일 필요가 없거든요.
Before: 데이터 중심으로 설계하면
@Entity
@Getter
public class DiscountPolicy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long orderId;
private BigDecimal fixedDiscountAmount; // 정액할인
private BigDecimal percentageDiscount; // 정률할인
private String couponCode; // 쿠폰할인
private boolean isCouponExpired;
private BigDecimal couponDiscountAmount;
private String discountType; // "FIXED", "PERCENTAGE", "COUPON"
private LocalDateTime createdAt;
}이 방식의 문제는 명확합니다:
- 모든 방식의 필드를 한 엔티티에 때려 박아야 함
- 실제로 필요 없는 필드들도 null로 채워짐
- 새로운 할인 정책이 나올 때마다 엔티티에 새 필드를 추가해야 함
After: 책임 중심으로 설계하면
할인의 책임을 인터페이스로 정의합니다:
public interface DiscountPolicy {
boolean isValid();
BigDecimal calculateDiscount(BigDecimal originalAmount);
}각 할인 정책이 자신의 책임에만 충실합니다:
public class FixedAmountDiscount extends AbstractDiscountPolicy {
private final BigDecimal discountAmount;
@Override
public boolean isValid() {
return discountAmount != null && discountAmount.compareTo(BigDecimal.ZERO) > 0;
}
@Override
public BigDecimal calculateDiscount(BigDecimal originalAmount) {
if (!isValid()) {
throw new InvalidDiscountException("정액할인 금액이 유효하지 않습니다");
}
return discountAmount.min(originalAmount);
}
}
public class PercentageDiscount extends AbstractDiscountPolicy {
private final BigDecimal percentage;
@Override
public boolean isValid() {
return percentage != null
&& percentage.compareTo(BigDecimal.ZERO) > 0
&& percentage.compareTo(BigDecimal.valueOf(100)) < 0;
}
@Override
public BigDecimal calculateDiscount(BigDecimal originalAmount) {
if (!isValid()) {
throw new InvalidDiscountException("할인율이 유효하지 않습니다");
}
return originalAmount.multiply(percentage).divide(BigDecimal.valueOf(100));
}
}
public class CouponDiscount extends AbstractDiscountPolicy {
private final String couponCode;
private final BigDecimal discountAmount;
private final boolean isExpired;
@Override
public boolean isValid() {
return !isExpired && discountAmount.compareTo(BigDecimal.ZERO) > 0;
}
@Override
public BigDecimal calculateDiscount(BigDecimal originalAmount) {
if (!isValid()) {
throw new InvalidDiscountException("쿠폰이 유효하지 않습니다");
}
return discountAmount.min(originalAmount);
}
}비교 표로 보는 차이
| 측면 | Before (데이터 중심) | After (책임 중심) |
|---|---|---|
| 필드 | 모든 할인 방식 필드 포함 | 각 정책별 필요한 필드만 |
| Null 처리 | 미사용 필드는 null로 채움 | 항상 의미 있는 값만 |
| 확장성 | 정책 추가 시 엔티티 수정 필요 | 새 클래스만 추가 (OCP) |
| 테스트 | 많은 필드를 세팅해야 함 | 필요한 것만 간단히 세팅 |
3단계: 불변 객체로 설계해서 명확하게
JPA 엔티티의 제약사항
JPA 엔티티는 final 키워드를 사용할 수 없습니다. Hibernate가 프록시 객체를 만들기 위해 서브클래싱을 필요로 하기 때문입니다.
@Entity
public class Order {
private BigDecimal totalAmount;
// 이 메서드가 상태를 변경하는지 명확하지 않음
public void applyDiscount(BigDecimal discountPercent) {
totalAmount = totalAmount.multiply(BigDecimal.ONE.subtract(discountPercent));
}
// 조회인 줄 알았는데 상태도 변경?
public boolean discountApply(BigDecimal discountPercent) {
if (totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
return false; // 조회인가?
}
totalAmount = totalAmount.multiply(
BigDecimal.ONE.subtract(discountPercent)
); // 명령?
return true;
}
}이를 CQS 원칙 위반이라 합니다. (Command-Query Separation)
도메인 객체는 불변으로 설계
도메인 객체는 다릅니다. final 키워드를 사용해서 불변으로 만들 수 있습니다:
public class Order {
private final Long id;
private final String orderNumber;
private final BigDecimal totalAmount;
private final int quantity;
private final LocalDateTime createdAt;
// 조회(Query) - 상태 변경 없음
public boolean canApplyDiscount(BigDecimal discountPercent) {
return totalAmount.compareTo(BigDecimal.ZERO) > 0
&& discountPercent.compareTo(BigDecimal.ZERO) > 0
&& discountPercent.compareTo(BigDecimal.ONE) < 0;
}
// 명령(Command) - 새로운 Order 객체 반환
public Order applyDiscount(BigDecimal discountPercent) {
if (!canApplyDiscount(discountPercent)) {
throw new InvalidDiscountException("할인 불가");
}
BigDecimal discountedAmount = totalAmount.multiply(
BigDecimal.ONE.subtract(discountPercent)
);
return new Order(this.id, this.orderNumber, discountedAmount, this.quantity);
}
}이제 명확합니다:
- 조회(
canApplyDiscount) — boolean 반환, 상태 변경 없음 - 명령(
applyDiscount) — 새로운 Order 객체 반환, 원본은 건드리지 않음
final 키워드는 단순한 제약이 아니라, 아키텍처적 명확성을 강제하는 도구입니다.
멀티모듈: 아키텍처를 코드로 강제
지금까지는 개념과 패턴으로 의존성을 관리했습니다. 하지만 사람은 실수합니다.
더 강력한 방법이 있습니다: 멀티모듈 구조. 이 방식을 사용하면, 컴파일 타임에 아키텍처 의존성이 자동으로 강제됩니다.
모듈 구조
order-service/
├── order-domain (비즈니스 로직)
├── order-service (Use Case)
├── order-mariadb (데이터 접근)
└── order-application (API 엔드포인트)
각 모듈의 의존성은 이렇게 설정합니다:
// order-domain (맨 아래 - 외부 의존성 없음)
dependencies {
// 프레임워크 의존성 없음
}
// order-service (중간)
dependencies {
implementation project(':order-domain')
runtimeOnly project(':order-mariadb') // ← 핵심!
}
// order-mariadb (인프라)
dependencies {
implementation project(':order-domain')
}
// order-application (맨 위)
dependencies {
implementation project(':order-service')
}
runtimeOnly의 마법
runtimeOnly로 설정하면:
- 컴파일 타임: order-mariadb의 클래스를 import할 수 없음 (IDE가 빨간 줄)
- 런타임: Spring이 자동으로 구현체를 찾아 주입함
Multi-Module Dependency Structure
runtimeOnly로 컴파일 타임에 구현체 접근을 차단 — 아키텍처가 코드로 강제됨
강제되는 아키텍처
누군가 구현체에 직접 의존하려고 시도합니다:
// 컴파일 에러 발생!
@Service
public class OrderService {
private final OrderRepositoryImpl impl; // 빨간 줄!
public OrderService(OrderRepositoryImpl impl) {
this.impl = impl;
}
}IDE가 즉시 빨간 줄을 그어줍니다. OrderRepositoryImpl은 import할 수 없으니까요.
개발자는 어쩔 수 없이 인터페이스로 변경할 수밖에 없습니다:
// 컴파일 성공
@Service
public class OrderService {
private final OrderRepository repository; // 인터페이스만 가능
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}이게 바로 멀티모듈의 강력함입니다. 팀 규칙에 의존하지 않고, 아키텍처를 코드 레벨에서 강제할 수 있습니다.
변경 범위를 정리하면:
- 도메인 (Order, DiscountPolicy) — 변경 없음
- Service — 변경 없음
- Repository 구현체 — 수정 (매핑 로직만)
- DB 스키마 — 변경 (테이블 분리)