하위 레이어에 의존적인 아키텍처
아래 이미지는 서비스를 개발하다 보면 흔히들 볼 수 있는 소프트웨어 아키텍처의 구조입니다.

많은 블로그의 포스트들이나 문서들이 아래와 같이 설명하고 있습니다.
레이어드 아키텍처란?
- 소프트웨어를 여러 개의 계층으로 분리해서 설계하는 방법
- 각각 계층이 서로 독립적으로 구성되어 있어서 한 계층의 변경이 다른 계층에 영향을 주지 않게 설계할 수 있다.
- 외부의 요구사항이나 세부적인 구현이 변화하더라도 도메인의 로직을 변경하지 않도록 보호하기 위해서 계층화를 하게 된다.
하지만 우리가 개발하는 대부분의 코드는 아래와 같습니다.
@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: 데이터베이스가 바뀌면 비즈니스 로직도 깨진다
제가 처음 마주친 질문은 이거였습니다:
Order는 JPA 엔티티입니다. @Entity, @Table, @Id 같은 애노테이션들이 가득합니다. 이건 RDB에 매우 강하게 결합된 설계입니다.
MongoDB로 전환한다면?
JPA 애노테이션들을 모두 제거하고 Spring Data MongoDB의 애노테이션으로 바꿔야 합니다. 엔티티 구조 자체를 재설계해야 할 수도 있습니다.
그런데 여기서 발견한 더 큰 문제가 있었습니다. Order 엔티티에는 단순한 데이터만 아니라 주문 생성 로직, 금액 계산 로직 같은 비즈니스 로직들이 섞여있었습니다.
엔티티를 수정하다 보니 실수로 비즈니스 로직도 함께 변경될 수밖에 없었습니다. 이게 문제였습니다.
문제 2: 인터페이스가 변하면 서비스도 함께 변한다
또 다른 상황을 생각해봤습니다:
OrderService는 OrderResponse DTO를 반환합니다. 이 DTO는 REST API 응답 형식에 맞춰 설계된 것입니다. OrderController를 통해 JSON으로 직렬화되는 것을 전제로 만들어진 것이죠.
만약 동일한 비즈니스 로직을 Kafka 메시지 기반으로 노출해야 한다면?
Service는 더 이상 OrderResponse를 반환하는 게 아니라 OrderEvent 같은 객체를 반환해야 합니다. Service가 통신 방식의 변경에 영향을 받게 됩니다.
근본 원인: “계층의 분리”가 아니라 “의존성의 방향”
이 두 가지 문제를 분석하다 보니 패턴이 보였습니다:
상위 계층(Service)이 하위 계층(Persistence, Presentation)에 의존하고 있다는 것이었습니다.
깨달은 게 있었습니다. 계층을 분리하는 것만으로는 부족합니다. 의존성의 방향을 뒤집어야 합니다.
해결책: 의존성을 역전시키자
문제의 핵심은 명확했습니다:
이를 위해 저는 JPA 엔티티와 도메인 객체를 완전히 분리하는 방식을 선택했습니다.
주요 개선 사항은:
- 데이터베이스 변경으로부터 자유로워진다
- MongoDB로 변경되어도 도메인과 Service는 건드릴 필요가 없음
- Infrastructure 계층만 교체하면 됨
- 비즈니스 로직이 비즈니스 규칙으로 설계된다
- 데이터 구조에 맞출 필요가 없음
- 역할과 책임 중심으로 설계 가능
- 객체지향 원칙(다형성, 추상화)을 자연스럽게 적용할 수 있음
- 도메인이 진정한 불변 객체가 될 수 있다
final키워드 사용 가능- CQS 원칙을 명확하게 따를 수 있음
- 부수효과(side effect) 제거
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: 계층을 명확하게 분리
다른 방식을 시도했습니다. 도메인은 비즈니스 로직만, JPA는 데이터 매핑만 담당하도록 말입니다.
먼저 도메인 객체입니다:
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();
}
}
@Repository
public interface OrderJpaRepository extends JpaRepository<OrderEntity, Long> {
}
서비스 레이어는 도메인 인터페이스에만 의존합니다:
@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);
}
}
이제 정말로 강력한 부분이 보입니다. 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 버전으로 교체 ✓
이것이 바로 의존성을 역전시킨 효과입니다. Service가 구현체에 의존하지 않고, 자신이 필요한 인터페이스만 알고 있을 뿐입니다.
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);
}
모든 구현체가 OrderId를 동일하게 관리하므로, 공통 로직을 추상클래스로 분리합니다:
// OrderId를 공통으로 관리하는 추상클래스
public abstract class AbstractDiscountPolicy implements DiscountPolicy {
protected final Long orderId;
public AbstractDiscountPolicy(Long orderId) {
this.orderId = orderId;
}
@Override
public final Long getOrderId() {
return orderId;
}
}
public class FixedAmountDiscount extends AbstractDiscountPolicy {
private final BigDecimal discountAmount;
public FixedAmountDiscount(Long orderId, BigDecimal discountAmount) {
super(orderId);
this.discountAmount = 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;
public PercentageDiscount(Long orderId, BigDecimal percentage) {
super(orderId);
this.percentage = 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;
public CouponDiscount(Long orderId, String couponCode, BigDecimal discountAmount, boolean isExpired) {
super(orderId);
this.couponCode = couponCode;
this.discountAmount = discountAmount;
this.isExpired = 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);
}
}
서비스 계층에서는 DiscountPolicy 목록을 통해 총 할인액을 계산합니다:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final DiscountPolicyRepository discountPolicyRepository;
public Order createOrder(String orderNumber, BigDecimal amount) {
return orderRepository.save(new Order(UUID.randomUUID(), orderNumber, amount));
}
public BigDecimal getOrderTotal(Long orderId) {
Order order = orderRepository.findById(orderId);
List<DiscountPolicy> discountPolicies = discountPolicyRepository.findByOrderId(orderId);
BigDecimal totalDiscount = BigDecimal.ZERO;
for (DiscountPolicy policy : discountPolicies) {
if (policy.isValid()) {
totalDiscount = totalDiscount.add(policy.calculateDiscount(order.getOriginalAmount()));
}
}
return order.calculateFinalAmount(totalDiscount);
}
}
이렇게 설계하면:
- 정액할인: 금액 필드만 필요
- 정률할인: 비율 필드만 필요
- 쿠폰할인: 쿠폰코드, 만료 여부만 필요
각각 필요한 것만 가지고, 그 역할에만 충실합니다. 새로운 할인 정책도 쉽게 추가할 수 있습니다.
비교 표로 보는 차이
| 측면 | Before (데이터 중심) | After (책임 중심) |
|---|---|---|
| 필드 | 모든 할인 방식 필드 포함 | 각 정책별 필요한 필드만 |
| Null 처리 | 미사용 필드는 null로 채움 | 항상 의미 있는 값만 |
| 확장성 | 정책 추가 시 엔티티 수정 필요 | 새 클래스만 추가 (개방-폐쇄 원칙) |
| 테스트 | 많은 필드를 세팅해야 함 | 필요한 것만 간단히 세팅 |
여기서 재미있는 점은 실제 DB에는 모든 데이터가 한 테이블에 있어도 괜찮다는 것입니다. 매핑 계층이 이를 처리하니까요.
영속성 계층에서의 매핑 처리
여기서 핵심은 실제 DB 구조와 도메인 구조는 다를 수 있다는 것입니다.
DB에는 모든 할인 정보가 한 테이블에:
@Entity
@Table(name = "discount_policies")
public class DiscountPolicyEntity {
@Id
private Long id;
private String policyType; // FIXED, PERCENTAGE, COUPON
private BigDecimal amount;
private String couponCode;
private boolean isExpired;
}
@Entity
@Table(name = "discount_policies")
public class DiscountPolicyEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String policyType; // FIXED, PERCENTAGE, COUPON
private BigDecimal amount;
private String couponCode;
private boolean isExpired;
}
Repository 인터페이스는 도메인 관점에서 정의합니다:
public interface DiscountPolicyRepository {
List<DiscountPolicy> findByOrderId(Long orderId);
}
그리고 Repository 구현체가 매핑의 책임을 집니다:
@Repository
public class DiscountPolicyRepositoryImpl implements DiscountPolicyRepository {
private final DiscountPolicyJpaRepository jpaRepository;
@Override
public List<DiscountPolicy> findByOrderId(Long orderId) {
List<DiscountPolicyEntity> entities = jpaRepository.findByOrderId(orderId);
// Entity를 도메인 구현체로 변환
return entities.stream()
.map(this::buildDiscountPolicy)
.collect(Collectors.toList());
}
private DiscountPolicy buildDiscountPolicy(DiscountPolicyEntity entity) {
return switch(entity.getPolicyType()) {
case "FIXED" -> new FixedAmountDiscount(entity.getOrderId(), entity.getAmount());
case "PERCENTAGE" -> new PercentageDiscount(entity.getOrderId(), entity.getPercentage());
case "COUPON" -> new CouponDiscount(entity.getOrderId(), entity.getCouponCode(),
entity.getAmount(), entity.isExpired());
default -> throw new IllegalArgumentException("Unknown policy type");
};
}
}
데이터베이스가 변경되면 어떻게 될까?
나중에 성능 최적화를 위해 할인 정책을 타입별로 분리된 테이블로 변경한다고 해봅시다:
@Entity @Table(name = "fixed_discount_policies")
public class FixedDiscountPolicyEntity { /* ... */ }
@Entity @Table(name = "percentage_discount_policies")
public class PercentageDiscountPolicyEntity { /* ... */ }
@Entity @Table(name = "coupon_discount_policies")
public class CouponDiscountPolicyEntity { /* ... */ }
그럼 Service는 어떻게 될까요?
아무것도 안 합니다. 😀
Service가 알아야 할 것은 DiscountPolicyRepository.findByOrderId() 라는 인터페이스뿐입니다. 구현이 어떻게 변했는지 모릅니다.
Repository 구현체의 findByOrderId() 메서드만 수정하면 끝:
@Repository
public class DiscountPolicyRepositoryImpl implements DiscountPolicyRepository {
private final FixedDiscountPolicyJpaRepository fixedRepo;
private final PercentageDiscountPolicyJpaRepository percentageRepo;
private final CouponDiscountPolicyJpaRepository couponRepo;
@Override
public List<DiscountPolicy> findByOrderId(Long orderId) {
List<DiscountPolicy> policies = new ArrayList<>();
// 각 테이블에서 조회
fixedRepo.findByOrderId(orderId).stream()
.map(entity -> new FixedAmountDiscount(orderId, entity.getAmount()))
.forEach(policies::add);
percentageRepo.findByOrderId(orderId).stream()
.map(entity -> new PercentageDiscount(orderId, entity.getPercentage()))
.forEach(policies::add);
couponRepo.findByOrderId(orderId).stream()
.map(entity -> new CouponDiscount(orderId, entity.getCouponCode(),
entity.getAmount(), entity.isExpired()))
.forEach(policies::add);
return policies;
}
}
변경 범위를 정리하면:
- ✅ 도메인 (Order, DiscountPolicy) - 변경 없음
- ✅ Service - 변경 없음
- ⚠️ Repository 구현체 - 수정 (매핑 로직만)
- 🔧 DB 스키마 - 변경 (테이블 분리)
데이터 중심 설계의 문제점
JPA 엔티티는 RDB에 강하게 결합되므로, 데이터 중심 설계가 됩니다. 이렇게 되면:
// JPA 엔티티 - 모든 로직이 한곳에
public BigDecimal calculateFinalAmount() {
if ("FIXED".equals(discountType)) {
return totalAmount.subtract(fixedDiscountAmount);
} else if ("PERCENTAGE".equals(discountType)) {
return totalAmount.multiply(
BigDecimal.ONE.subtract(percentageDiscount.divide(BigDecimal.valueOf(100)))
);
} else if ("COUPON".equals(discountType)) {
if (isCouponExpired(couponCode)) return totalAmount;
return totalAmount.subtract(fixedDiscountAmount);
}
return totalAmount;
}
문제점:
- 새로운 정책이 추가될 때마다 이 메서드를 수정해야 함
- OCP (개방-폐쇄 원칙) 위반
- DB 구조가 바뀌면 비즈니스 로직도 함께 수정되는 위험
// JPA 엔티티 - 데이터 중심
public BigDecimal calculateFinalAmount() {
if ("FIXED".equals(discountType)) {
return totalAmount.subtract(fixedDiscountAmount);
} else if ("PERCENTAGE".equals(discountType)) {
return totalAmount.multiply(BigDecimal.ONE.subtract(percentageDiscount.divide(BigDecimal.valueOf(100))));
} else if ("COUPON".equals(discountType)) {
// 쿠폰 유효성 확인 로직
if (isCouponExpired(couponCode)) {
return totalAmount;
}
return totalAmount.subtract(fixedDiscountAmount);
}
return totalAmount;
}
이는 객체지향 원칙을 위반하며, 새로운 할인 정책이 추가될 때마다 이 메서드를 수정해야 합니다. 이를 Open-Closed Principle(확장에는 열려있고 수정에는 닫혀있어야 한다) 위반이라고 합니다.
또한, DiscountPolicy의 DB 테이블 구조가 변경 된다라고 하면 모든 비즈니스 로직을 수정해야합니다.
3단계: 불변 객체로 설계해서 명확하게
JPA 엔티티의 제약사항
여기서 흥미로운 제약이 하나 있습니다.
JPA 엔티티는 final 키워드를 사용할 수 없습니다.
Hibernate가 프록시 객체를 만들기 위해 서브클래싱을 필요로 하기 때문입니다. 그래서 JPA 엔티티는 언제든 상태가 변할 수 있습니다.
이게 뭘 의미할까요? 코드를 봅시다:
@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;
}
}
메서드를 호출하는 개발자는 혼란스럽습니다:
applyDiscount()는 메서드 이름만으로는 상태를 변경하는지 알 수 없음discountApply()를 보면 조회처럼 보이지만 실제로는 상태를 변경함
이를 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;
public Order(Long id, String orderNumber, BigDecimal totalAmount, int quantity) {
this.id = id;
this.orderNumber = orderNumber;
this.totalAmount = totalAmount;
this.quantity = quantity;
this.createdAt = LocalDateTime.now();
}
public Order applyDiscount(BigDecimal discountPercent) {
if (totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("할인할 수 없는 주문입니다");
}
BigDecimal discountedAmount = totalAmount.multiply(
BigDecimal.ONE.subtract(discountPercent)
);
return new Order(this.id, this.orderNumber, discountedAmount, this.quantity);
}
public boolean canApplyDiscount(BigDecimal discountPercent) {
return totalAmount.compareTo(BigDecimal.ZERO) > 0
&& discountPercent.compareTo(BigDecimal.ZERO) > 0
&& discountPercent.compareTo(BigDecimal.ONE) < 0;
}
public BigDecimal getTotalAmount() { return totalAmount; }
public String getOrderNumber() { return orderNumber; }
}
final 키워드를 사용하면 불변 객체가 되고, CQS 원칙을 명확하게 지킬 수 있습니다:
- 조회(Query):
canApplyDiscount()- 할인 가능 여부만 확인하고 상태는 변경하지 않음 - 명령(Command):
applyDiscount()- 새로운 Order 객체를 반환
서비스 계층에서는 이를 명확하게 구분해서 사용합니다:
@Service
public class OrderService {
private final OrderRepository orderRepository;
public Order applyDiscount(Long orderId, BigDecimal discountPercent) {
Order order = orderRepository.findById(orderId);
// 1단계: 조회 - 할인을 적용할 수 있는지 확인
if (!order.canApplyDiscount(discountPercent)) {
throw new InvalidDiscountException("할인을 적용할 수 없습니다");
}
// 2단계: 명령 - 새로운 Order 객체 생성 (원본은 변경되지 않음)
Order discountedOrder = order.applyDiscount(discountPercent);
// 3단계: 저장
return orderRepository.save(discountedOrder);
}
}
final 키워드가 만드는 명확성:
// 조회만 함 - 상태 변경 없음
public boolean canApplyDiscount(BigDecimal discountPercent) {
return totalAmount.compareTo(BigDecimal.ZERO) > 0
&& discountPercent.compareTo(BigDecimal.ZERO) > 0;
}
// 명령 - 새로운 객체 반환 (원본은 변경 안 됨)
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);
}
이제 명확합니다:
- 조회(
canApplyDiscount) → boolean 반환, 상태 변경 없음 - 명령(
applyDiscount) → 새로운 Order 객체 반환, 원본은 건드리지 않음
개발자가 코드를 읽을 때 메서드 이름과 반환 타입만으로 의도가 명확해집니다. 이것이 CQS 원칙의 힘입니다.
final 키워드는 단순한 제약이 아니라, 아키텍처적 명확성을 강제하는 도구입니다.
멀티모듈: 아키텍처를 코드로 강제
지금까지는 개념과 패턴으로 의존성을 관리했습니다. 하지만 사람은 실수합니다. 누군가 규칙을 무시할 수도 있습니다.
더 강력한 방법이 있습니다: 멀티모듈 구조
이 방식을 사용하면, 컴파일 타임에 아키텍처 의존성이 자동으로 강제됩니다. IDE가 빨간 줄을 그으니까요.
모듈 구조
프로젝트를 다음과 같이 분리합니다:
order-service/
├── order-domain (비즈니스 로직)
├── order-service (Use Case)
├── order-mariadb (데이터 접근)
└── order-application (API 엔드포인트)
각 모듈의 의존성은 이렇게 설정합니다:
// order-application (맨 위)
dependencies {
implementation project(':order-service')
}
// order-service (중간)
dependencies {
implementation project(':order-domain')
runtimeOnly project(':order-mariadb') // ← 여기가 핵심!
}
// order-mariadb (아래)
dependencies {
implementation project(':order-domain')
}
// order-domain (맨 아래 - 외부 의존성 없음)
dependencies {
// 프레임워크 의존성 없음
}
runtimeOnly의 마법
runtimeOnly는 생각보다 강력합니다.
일반적인 implementation:
implementation project(':order-mariadb')
이렇게 설정하면 order-mariadb의 모든 클래스에 접근 가능합니다.
하지만 runtimeOnly:
runtimeOnly project(':order-mariadb')
이렇게 설정하면:
- 컴파일 타임: order-mariadb의 클래스를 import할 수 없음 (IDE가 빨간 줄)
- 런타임: Spring이 자동으로 구현체를 찾아 주입함
강제되는 아키텍처
이게 어떤 효과를 만들까요? 예시를 봅시다.
누군가 구현체에 직접 의존하려고 시도합니다:
// ❌ 컴파일 에러 발생
@Service
public class OrderService {
private final OrderRepositoryImpl impl; // 빨간 줄!
public OrderService(OrderRepositoryImpl impl) {
this.impl = impl;
}
}
IDE가 즉시 빨간 줄을 그어줍니다. OrderRepositoryImpl은 import할 수 없으니까요. (runtimeOnly 의존성)
개발자는 어쩔 수 없이 인터페이스로 변경할 수밖에 없습니다:
// ✅ 컴파일 성공
@Service
public class OrderService {
private final OrderRepository repository; // 인터페이스만 가능
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
이게 바로 멀티모듈의 강력함입니다. 팀 규칙에 의존하지 않고, 아키텍처를 코드 레벨에서 강제할 수 있습니다.
실제 구현 예시
1. order-domain 모듈 (도메인)
// 도메인 객체 (프레임워크에 무관)
public class Order {
private final Long id;
private final String orderNumber;
private final BigDecimal originalAmount;
private final DiscountPolicy discountPolicy;
private final LocalDateTime createdAt;
public Order(Long id, String orderNumber, BigDecimal originalAmount, DiscountPolicy discountPolicy) {
this.id = id;
this.orderNumber = orderNumber;
this.originalAmount = originalAmount;
this.discountPolicy = discountPolicy;
this.createdAt = LocalDateTime.now();
}
public BigDecimal getFinalAmount() {
if (discountPolicy.isValid()) {
BigDecimal discount = discountPolicy.calculateDiscountAmount(originalAmount);
return originalAmount.subtract(discount);
}
return originalAmount;
}
public String getOrderNumber() { return orderNumber; }
public BigDecimal getOriginalAmount() { return originalAmount; }
}
public interface OrderRepository {
Order save(Order order);
Order findById(Long id);
}
2. order-service 모듈 (서비스)
// 서비스는 도메인과 인터페이스에만 의존
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final DiscountPolicyRepository discountPolicyRepository;
public OrderService(OrderRepository orderRepository, DiscountPolicyRepository discountPolicyRepository) {
this.orderRepository = orderRepository;
this.discountPolicyRepository = discountPolicyRepository;
}
public Order createOrder(String orderNumber, BigDecimal amount) {
Order order = new Order(UUID.randomUUID(), orderNumber, amount);
return orderRepository.save(order);
}
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId);
}
// 특정 주문의 할인 정책들을 조회
public List<DiscountPolicy> getDiscountPolicies(Long orderId) {
return discountPolicyRepository.findByOrderId(orderId);
}
// 총 할인액 계산
public BigDecimal calculateTotalDiscount(Long orderId, BigDecimal originalAmount) {
List<DiscountPolicy> policies = discountPolicyRepository.findByOrderId(orderId);
return policies.stream()
.filter(DiscountPolicy::isValid)
.map(policy -> policy.calculateDiscountAmount(originalAmount))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
여기서 중요한 점은 OrderRepository와 DiscountPolicyRepository 인터페이스를 주입받지만, 실제 구현체가 뭔지 알 필요가 없다는 것입니다. order-persistence 모듈의 존재 자체를 모르고, 인터페이스를 통해서만 데이터에 접근합니다.
3. order-mariadb 모듈 (영속성 레이어)
// JPA 엔티티 (영속성 레이어에만 존재)
@Entity
@Table(name = "orders")
@Getter
@Setter
public class OrderEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
private BigDecimal originalAmount;
private LocalDateTime createdAt;
}
// 도메인 리포지토리의 구현체 (spring-data-jpa)
@Repository
public class OrderRepositoryImpl implements OrderRepository {
private final OrderJpaRepository jpaRepository;
public OrderRepositoryImpl(OrderJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Order save(Order order) {
OrderEntity entity = new OrderEntity();
entity.setOrderNumber(order.getOrderNumber());
entity.setOriginalAmount(order.getOriginalAmount());
entity.setCreatedAt(LocalDateTime.now());
OrderEntity saved = jpaRepository.save(entity);
return new Order(saved.getId(), saved.getOrderNumber(), saved.getOriginalAmount());
}
@Override
public Order findById(Long id) {
return jpaRepository.findById(id)
.map(entity -> new Order(entity.getId(), entity.getOrderNumber(), entity.getOriginalAmount()))
.orElseThrow();
}
}
@Repository
public interface OrderJpaRepository extends JpaRepository<OrderEntity, Long> {
}
4. order-application 모듈 (프레젠테이션)
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public OrderResponse createOrder(@RequestBody CreateOrderRequest request) {
Order order = orderService.createOrder(request.getOrderNumber(), request.getAmount());
return OrderResponse.from(order);
}
@GetMapping("/{orderId}")
public OrderResponse getOrder(@PathVariable Long orderId) {
Order order = orderService.getOrder(orderId);
return OrderResponse.from(order);
}
}
의존성 구조 시각화

