[스프링 부트와 JPA 활용] 3. 상품 도메인 개발

건우·2026년 2월 15일

Back-end / Java, Spring

목록 보기
12/14
post-thumbnail

상품 도메인 개발 – 엔티티에 비즈니스 로직을 넣는 이유

도메인 모델 중심 설계 관점에서 상품을 어떻게 다뤄야 하는지에 초점을 둔다.


1. 상품 엔티티 개발 – 단순 데이터 객체가 아니다

상품(Item)은 단순히 name, price, stockQuantity를 가지는 데이터 객체가 아니다.
재고 관리라는 비즈니스 로직을 직접 가지고 있는 도메인 객체다.

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter
@Setter
public abstract class Item {

    @Id @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();

    // == 비즈니스 로직 == //

    public void addStock(int quantity) {
        this.stockQuantity += quantity;
    }

    public void removeStock(int quantity) {
        int restStock = this.stockQuantity - quantity;
        if (restStock < 0) {
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restStock;
    }
}

왜 재고 로직을 엔티티에 넣었을까?

많이 하는 실수가 있다.

재고 차감 로직을 서비스에서 처리하는 것

그렇게 되면:

  • 재고 정책이 여기저기 흩어지고
  • 동일한 로직이 중복될 수 있고
  • 도메인이 단순 DTO처럼 변해버린다.

하지만 재고 감소는 상품의 핵심 책임이다.

  • 재고는 음수가 되면 안 된다.
  • 재고 증가/감소 규칙은 상품이 알고 있어야 한다.

즉, 도메인 모델에 비즈니스 규칙을 캡슐화한 것이다.


2. 예외 설계 – 도메인 예외는 도메인에서 던진다

public class NotEnoughStockException extends RuntimeException {
    public NotEnoughStockException(String message) {
        super(message);
    }
}

재고가 부족하면 removeStock() 내부에서 바로 예외를 던진다.

이 설계의 의미는 명확하다.

재고 부족은 “시스템 오류”가 아니라 “비즈니스 규칙 위반”이다.

따라서:

  • 서비스가 if(stock < 0) 검사하지 않는다.
  • 도메인이 스스로 유효성을 보장한다.
  • 서비스는 흐름 제어에만 집중한다.

3. 상품 리포지토리 – JPA 기본 패턴

@Repository
@RequiredArgsConstructor
public class ItemRepository {

    private final EntityManager em;

    public void save(Item item) {
        if (item.getId() == null) {
            em.persist(item);
        } else {
            em.merge(item);
        }
    }

    public Item findOne(Long id) {
        return em.find(Item.class, id);
    }

    public List<Item> findAll() {
        return em.createQuery("select i from Item i", Item.class)
                .getResultList();
    }
}

persist vs merge

여기서 가장 중요한 부분은 merge()다.

  • persist() → 신규 엔티티 저장
  • merge() → 준영속 엔티티를 다시 병합

하지만 실무에서는
변경 감지(dirty checking)를 사용하는 것이 훨씬 안전하다.

merge()는 예측하기 어렵고, 잘못 쓰면 의도하지 않은 업데이트가 발생할 수 있다.

이 강의에서는 구조 이해용으로 쓰지만,
실무에서는 가급적 merge()를 직접 쓰지 않는다.


4. 상품 서비스 – 왜 단순 위임만 할까?

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {

    private final ItemRepository itemRepository;

    @Transactional
    public void saveItem(Item item) {
        itemRepository.save(item);
    }

    public List<Item> findItems() {
        return itemRepository.findAll();
    }

    public Item findOne(Long itemId) {
        return itemRepository.findOne(itemId);
    }
}

서비스는 거의 리포지토리에 위임만 한다.

이게 이상해 보일 수도 있다.

“서비스에 로직이 없는데 왜 만들지?”

이 구조의 의미는 명확하다.

  • 서비스는 트랜잭션 경계
  • 도메인은 비즈니스 규칙 담당
  • 리포지토리는 영속성 처리

즉, 계층 책임 분리가 명확하다.


5. 핵심 설계 포인트

도메인 모델은 데이터 덩어리가 아니다

  • 재고 감소 로직은 상품 엔티티가 책임진다.
  • 비즈니스 규칙은 도메인 내부에 둔다.

예외도 도메인 책임이다

  • 재고 부족은 도메인 예외로 처리한다.
  • 서비스는 흐름만 제어한다.

트랜잭션은 서비스 계층에서 관리한다

  • @Transactional(readOnly = true)
  • 쓰기 작업은 별도로 @Transactional

JPA merge는 조심해서 사용해야 한다

  • 변경 감지 기반 설계를 선호한다.
  • 실무에서는 DTO → 엔티티 직접 병합 패턴을 주의한다.

6. 정리

상품 도메인 구현은

  • 도메인에 비즈니스 로직을 두는 이유
  • 계층 책임 분리
  • 트랜잭션 경계 설정
  • JPA 저장 전략 이해

이 네 가지를 이해하는 단계다.


출처
스프링 부트와 JPA 활용 (김영한, 인프런, 2019)

0개의 댓글