하위 레이어에 의존적인 아키텍처


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

레이어드 아키텍쳐
많은 블로그의 포스트들이나 문서들이 아래와 같이 설명하고 있습니다.

레이어드 아키텍처란?

  • 소프트웨어를 여러 개의 계층으로 분리해서 설계하는 방법
  • 각각 계층이 서로 독립적으로 구성되어 있어서 한 계층의 변경이 다른 계층에 영향을 주지 않게 설계할 수 있다.
  • 외부의 요구사항이나 세부적인 구현이 변화하더라도 도메인의 로직을 변경하지 않도록 보호하기 위해서 계층화를 하게 된다.

하지만 우리가 개발하는 대부분의 코드는 아래와 같습니다.

  @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);
      }
  }

진짜 문제는 “의존성”


위 코드를 보시면 여러 계층으로 분리되어 있고, 각각이 독립적이며, 구현이 변해도 도메인은 안전해 보입니다.

하지만 여기에 함정이 숨어있습니다. 제가 코드를 분석하다가 발견한 문제가 세 가지 있었습니다:

  1. ServiceLayer가 JPA 엔티티에 의존 - Order는 이미 JPA 애노테이션과 강하게 결합됨
  2. ServiceLayer가 DTO에 의존 - OrderResponse는 REST API 응답 형식으로 강하게 결합됨
  3. ServiceLayer가 구현체에 의존 - JpaRepository를 상속한 OrderRepository에만 작동함

문제는 단순히 “계층을 분리했다”는 것만으로는 부족했습니다. “누가 누구에게 의존하고 있는가”가 훨씬 더 중요합니다.

문제 1: 데이터베이스가 바뀌면 비즈니스 로직도 깨진다


제가 처음 마주친 질문은 이거였습니다:

Info
데이터베이스를 MongoDB로 바꾼다면 어떻게 해야 할까?

Order는 JPA 엔티티입니다. @Entity, @Table, @Id 같은 애노테이션들이 가득합니다. 이건 RDB에 매우 강하게 결합된 설계입니다.

MongoDB로 전환한다면?

JPA 애노테이션들을 모두 제거하고 Spring Data MongoDB의 애노테이션으로 바꿔야 합니다. 엔티티 구조 자체를 재설계해야 할 수도 있습니다.

그런데 여기서 발견한 더 큰 문제가 있었습니다. Order 엔티티에는 단순한 데이터만 아니라 주문 생성 로직, 금액 계산 로직 같은 비즈니스 로직들이 섞여있었습니다.

엔티티를 수정하다 보니 실수로 비즈니스 로직도 함께 변경될 수밖에 없었습니다. 이게 문제였습니다.

문제 2: 인터페이스가 변하면 서비스도 함께 변한다


또 다른 상황을 생각해봤습니다:

Info
REST API 대신 Kafka 메시지 기반으로 변경해야 한다면?

OrderService는 OrderResponse DTO를 반환합니다. 이 DTO는 REST API 응답 형식에 맞춰 설계된 것입니다. OrderController를 통해 JSON으로 직렬화되는 것을 전제로 만들어진 것이죠.

만약 동일한 비즈니스 로직을 Kafka 메시지 기반으로 노출해야 한다면?

Service는 더 이상 OrderResponse를 반환하는 게 아니라 OrderEvent 같은 객체를 반환해야 합니다. Service가 통신 방식의 변경에 영향을 받게 됩니다.

근본 원인: “계층의 분리”가 아니라 “의존성의 방향”

이 두 가지 문제를 분석하다 보니 패턴이 보였습니다:

상위 계층(Service)이 하위 계층(Persistence, Presentation)에 의존하고 있다는 것이었습니다.

깨달은 게 있었습니다. 계층을 분리하는 것만으로는 부족합니다. 의존성의 방향을 뒤집어야 합니다.

해결책: 의존성을 역전시키자


문제의 핵심은 명확했습니다:

Info
하위 계층(Infrastructure)이 Service를 지배하지 말고, Service가 자신이 필요로 하는 인터페이스를 정의하도록 해야 한다.

이를 위해 저는 JPA 엔티티와 도메인 객체를 완전히 분리하는 방식을 선택했습니다.

주요 개선 사항은:

  1. 데이터베이스 변경으로부터 자유로워진다
    • MongoDB로 변경되어도 도메인과 Service는 건드릴 필요가 없음
    • Infrastructure 계층만 교체하면 됨
  2. 비즈니스 로직이 비즈니스 규칙으로 설계된다
    • 데이터 구조에 맞출 필요가 없음
    • 역할과 책임 중심으로 설계 가능
    • 객체지향 원칙(다형성, 추상화)을 자연스럽게 적용할 수 있음
  3. 도메인이 진정한 불변 객체가 될 수 있다
    • 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);
    }
}

여기서 중요한 점은 OrderRepositoryDiscountPolicyRepository 인터페이스를 주입받지만, 실제 구현체가 뭔지 알 필요가 없다는 것입니다. 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);
    }
}

의존성 구조 시각화


멀티모듈 아키텍쳐