본문 바로가기
Spring/Spring Boot + JPA

[Spring Boot + JPA] 웹 어플리케이션 개발 - 3. 도메인 개발

by yoon_seon 2023. 4. 6.

회원 도메인 개발

구현 기능

  • 회원 등록
  • 회원 목록 조회

 

회원 리포지토리 개발

MemberRepository.java

@Repository
public class MemberRepository {
	
	@PersistenceContext
	private EntityManager em; // 스프링이 엔티티매니저를 만들어서 주입해준다.
	
	// 회원 정보 저장
	public void save(Member member) {
		em.persist(member);
	}
	
	// 회원 조회
	public Member findOne(Long id) {
		return em.find(Member.class, id);
	}
	
	// 회원 목록 조회
	public List<Member> findAll() {
		return em.createQuery("select m from Member m", Member.class)
				.getResultList();
	}
	
	// 이름으로 회원 조회
	public List<Member> findByName(String name) {
		return em.createQuery("select m from Member m where m.name = :name", Member.class)
				.setParameter("name", name)
				.getResultList();
	}
	
}

기술 설명

  • @Repository : 스프링 빈으로 등록, JPA 예외를 스프링 기반 예외로 변환
  • @PersistenceContext : 엔티티 매니저(EntityManager) 주입
  • @PersistenceUnit : 엔티티 매니저 펙토리(EntityManagerFactory) 주입

 

EntityManager는 @Autowired가 아닌 @PersistenceContext를 사용해야 Injection이 된다.

스프링 데이터 JPA를 사용하면 @PersistenceContext가 아닌 @Autowired를 사용해도 Injection이 가능하도록 지원한다.

그렇기 때문에 lombok이 지원하는 @RequiredArgsConstructor를 사용할 수 있다.

@Repository
@RequiredArgsConstructor
public class MemberRepository {
	
	private EntityManager em;
   
   ...

 

회원 서비스 개발

MemberService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
	
	private final MemberRepository memberRepository;
	
	// 회원 가입
	@Transactional
	public Long join(Member member) {
		validateDuplidateMember(member);
		memberRepository.save(member);
		return member.getId();
	}
	
	// 중복 회원 검증
	private void validateDuplidateMember(Member member) {
		List<Member> findMembers = memberRepository.findByName(member.getName()); // Alt + Shift + L
		if (!findMembers.isEmpty()) {
			throw new IllegalStateException("중복 회원 존재");
		}
	}
	
	// 회원 전체 조회
	public List<Member> findMembers() {
		return memberRepository.findAll();
	}
	
	// 단일 회원 조회
	public Member findOne(Long memberId) {
		return memberRepository.findOne(memberId);
	}

}

기술 설명

  • @Service
  • @Transactional : 트랜잭션, 영속성 컨택스트
    • readOnly = true : 데이터의 변경이 없는 읽기 전용 메서드에 사용, 영속성 컨택스트를 플러시 하지 않으므로 약간의 성능 향상(읽기 전용에는 다 적용)
    • 데이터베이스 드라이버가 지원하면 DB에서 성능 향상
  • @Autowired
    • 생성자 Injection 많이 사용, 생성자가 하나면 생략 가능
참고 : 
실무에서는 검증 로직이 있어도 멀티 스레드 상황을 고려해서 회원 테이블의 회원명 컬럼에 유니크 제약 조건을 추가하는 것이 안전하다.

 

스프링 필드 주입 대신에 생성자 주입을 사용하자.

  • 생성자 주입 방식을 권장
  • 변경 불가능한 안전한 객체 생성 가능
  • 생성자가 하나면 @Autowired 생략 가능
  • final 키워드를 추가하면 컴파일 시점에 memberRepository를 설정하지 않는 오류를 체크할 수 있다.
    (보통 기본 생성자를 추가할 때 발견)

 

회원기능 테스트

테스트 요구사항

  • 회원가입을 성공해야 한다.
  • 회원가입 시 같은 이름이 있으면 예외가 발생해야 한다.

MemberServiceTest.java

@SpringBootTest
@Transactional
@ActiveProfiles("test")
class MemberServiceTest {
	
	@Autowired private MemberService memberService;
	@Autowired private MemberRepository memberRepository;
	@Autowired private EntityManager em;
	
	@Test
	void join() {
		// given
		Member member = new Member();
		member.setName("kim");
		
		// when
		Long saveId = memberService.join(member);
		
		// then
		em.flush(); // DB에 영속성 컨택스트에 있는 Query가 나가기 때문에 Query 확인 가능
		assertEquals(member, memberRepository.findOne(saveId));
		
	}
	
	@Test
	void testJoinDuplication() {
		// given
		Member member1 = new Member();
		member1.setName("kim1");
		Member member2 = new Member();
		member2.setName("kim1");
		
		// when
		memberService.join(member1);
		
		// then		
		IllegalStateException exception = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
		assertEquals("중복 회원 존재", exception.getMessage());		
		
	}


}

 

기술 설명

  • @SpringBootTest : 스프링 부트 띄우고 테스트(이게 없으면 @Autowired 다 실패)
  • @Transaction :  반복 가능한 테스트 지원, 각각의 테스트를 실행할 때마다 트랜잭션을 시작하고
                                     테스트가 끝나면 트랜잭션을 강제로 롤백(이 어노테이션은 테스트 케이스에서 사용될 때만 롤백)

 

테스트 케이스를 위한 설정

테스트는 케이스 격리된 환경에서 실행하고, 끝나면 데이터를 초기화하는 것이 좋다. 그런 면에서 메모리 DB를 사용하는 것이 가장 이상적이다. 추가로 테스트 케이스를 위한 스프링 환경과, 일반적으로 애플리케이션을 실행하는 환경은 보통 다르므로 설정 파일을 다르게 사용하자. 다음과 같이 간단하게 테스트용 설정 파일을 추가하면 된다

 

test/resources/application.yml

spring:
  config.activate.on-profile: test
#  
#  datasource:
#    url: jdbc:h2:mem:test
#    username: sa
#    password: 
#    driver-class-name: org.h2.Driver
#    
#  jpa:
#    hibernate:
#      ddl-auto: create-drop   # create : 애플리케이션 실행 시점에 엔티티 정보를 drop후 create하고 애플리케이션을 실행.
#                              # create-drop : 애플리케이션 종료 시점에 엔티티 정보를 drop.
#    properties:
#      hibernate:
  #        show-sql: true
#        format_sql: true
       
logging.level:
  org.hibernate.SQL: debug
#  org.hibernate.type: trace

이제 테스트에서 스프링을 실행하면 이 위치에 있는 설정 파일을 읽는다.
(만약 이 위치에 없으면 src/resources/application.yml 을 읽는다.)

 

스프링 부트는 datasource 설정이 없으면, 기본적을 메모리 DB를 사용하고, driver-class도 현재 등록된 라이브러리를 보고 찾아준다. 추가로 ddl-auto 도 create-drop 모드로 동작한다. 따라서 데이터소스나, JPA 관련된 별도의 추가 설정을 하지 않아도 된다.


 

상품 도메인 개발

구현기능

  • 상품 등록
  • 상품 목록 조회
  • 상품 수정

 

상품 엔티티 개발(비즈니스 로직 추가)

Item.java

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter
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<Category>();
	
	
	// 비즈니스 로직
	/**
	 * stock 증가
	 */
	public void addStock(int quantity) {
		this.stockQuantity += quantity;
	}
	
	/**
	 * stock 감소
	 */
	public void removeStock(int quantity) {
		int restStock = this.stockQuantity - quantity;
		if (restStock < 0) {
			throw new NotEnoughStockException("need more stock");
		}
		this.stockQuantity = restStock;
	}
	
}

 

예외 추가

NotEnoughStockException.java

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

 

비즈니스 로직 분석

  • addStock() 메서드는 파라미터로 넘어온 수만큼 재고를 늘린다.
    이 메서드는 증가하거나 상품 주문을 취소해서 재고를 다시 늘려할 때 사용한다.
  • removeStock() 메서드는 파라미터로 넘어온 수만큼 재고를 줄인다.
    만약 재고가 부족하다면 예외가 발생한다. 주로 상품을 주문할 때 사용한다.

 

상품 리포지토리 개발

ItemRepository.java

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

기능 설명

  • save()
    • id가 없으면 신규로 보고 persist() 실행
    • id가 있으면 이미 데이터베이스에 저장된 엔티티를 수정한다고 보고 merge를 실행.
      자세한 내용은 뒤에 웹에서 설명(지금은 저장한다 정도로 생각하자.)

 

상품 서비스 개발

ItemService.java

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

상품 서비스는 상품 리포지토리에 단순히 위임만 하는 클래스

 

상품 기능 테스트는 회원 테스트와 비슷하므로 생략


 

주문 도메인 개발

구현 기능

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

 

주문, 주문상품 엔티티 개발

 

주문 엔티티 개발

Order.java

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
	
	@Id @GeneratedValue
	@Column(name = "order_id")
	private Long id;
	
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "member_id")
	private Member member;
	
	@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
	private List<OrderItem> orderItems = new ArrayList<>();
	
	@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
	@JoinColumn(name = "delivery_id")
	private Delivery delivery;
	
	private LocalDateTime orderDate;
	
	@Enumerated(EnumType.STRING)
	private OrderStatus status; // 주문상태 [ORDER, CANCEL]
	
	
	/*** 연관관계 편의 메서드 ***/
	
	public void setMember(Member member) {
		this.member = member;
		member.getOrders().add(this);
	}

	public void addOrderItem(OrderItem orderItem) {
		orderItems.add(orderItem);
		orderItem.setOrder(this);
	}

	public void setDelivery(Delivery delivery) {
		this.delivery = delivery;
		delivery.setOrder(this);
	}
	
	/*** 생성 메서드 ***/
	public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
		Order order = new Order();
		order.setMember(member);
		order.setDelivery(delivery);
		
		for(OrderItem orderItem : orderItems) {
			order.addOrderItem(orderItem);
		}
		
		order.setStatus(OrderStatus.ORDER);
		order.setOrderDate(LocalDateTime.now());
		
		return order;
	}
	
	/*** 비즈니스 로직 ***/
	// 주문 취소
	public void cancel() {
		if (delivery.getStatus() == DeliveryStatus.COMP) {
			throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
		}
		
		this.setStatus(OrderStatus.CANCLE);
		
		for (OrderItem orderItem : orderItems) {
			orderItem.cancel();
		}
	}
	
	/*** 조회 로직 ***/
	// 전체 주문 가격 조회
	public int getTotalPrice() {
		int totalPrice = 0;
		for (OrderItem orderItem : orderItems) {
			totalPrice += orderItem.getTotalPrice();
		}
		return totalPrice; 
	}
	
}

기능 설명

  • 생성 메서드(createOrder()) : 주문 엔티티를 생성할 때 사용한다. 주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔티티를 생성한다.
  • 주문 취소(cancel()) : 주문 취소 시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다
  • 전체 주문 가격 조회 : 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다.(실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화한다.)

 

@NoArgsConstructor(access = AccessLevel.PROTECTED)

외부에서 new를 통하여 객체를 생성하지 않고 생성 메서드만으로 객체를 생성할 수 있도록 어노테이션을 추가하였다.

해당 어노테이션은 protected로 생성자를 막은것과 동일한 기능을 한다.

 

주문상품 엔티티 개발

OrderItem.java

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
	
	@Id @GeneratedValue
	@Column(name = "order_item_id")
	private Long Id;
	
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "item_id")
	private Item item;
	
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name="order_id")
	private Order order;
	
	private int orderPrice; // 주문 가격
	private int count; // 주문 수량
	
	/*** 생성 메서드 ***/
	public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
		OrderItem orderItem = new OrderItem();
		orderItem.setItem(item);
		orderItem.setOrderPrice(orderPrice);
		orderItem.setCount(count);
		
		item.removeStock(count);
		return orderItem;
	}
	
	/*** 비즈니스 로직 ***/
	public void cancel() {
		getItem().addStock(count);
	}
	
	/*** 조회 로직 ***/
	// 주문상품 전체 가격 조회
	public int getTotalPrice() {
		return getOrderPrice() * getCount();
	}
}

기능 설명

  • 생성 메서드(createOrderItem()) : 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다. 그리고 item.removeStock(count)를 호출해서 주문한 수량만큼 상품의 재고를 줄인다.
  • 주문 취소(cancel()) : getItem().addStock(count)를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킨다.
  • 주문 가격 조회 : 주문 가격에 수량을 곱한 값을 반환한다.

 

주문 리포지토리 개발

OrderRepository.java

@Repository
@RequiredArgsConstructor
public class OrderRepository {
	
	private final EntityManager em;
	
	// 주문 정보 저장
	public void save(Order order) {
		em.persist(order);
	}
	
	// 주문 정보 조회
	public Order findOne(Long id) {
		return em.find(Order.class, id);
	}
	
//	public List<Order> findAll(OrderSearch orderSearch) {}
	
}

주문 리포지토리에는 주문 엔티티를 저장하고 검색하는 기능이 있다. 마지막의 findAll(OrderSearch orderSearch) 메서드는 조금 뒤에 있는 주문 검색 기능에서 자세히 알아보자.

 

 

주문 서비스 개발

OrderService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
	
	private final OrderRepository orderRepository;
	private final MemberRepository memberRepository;
	private final ItemRepository itemRepository;
	
	/**
	 * 주문
	 */
	@Transactional
	public Long order(Long memberId, Long itemId, int count) {
		
		// 엔티티 조회
		Member member = memberRepository.findOne(itemId);
		Item item = itemRepository.findOne(itemId);
		
		// 배송정보 생성
		Delivery delivery = new Delivery();
		delivery.setAddress(member.getAddress());
		
		// 주문상품 생성
		OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
		
		// 주문 생성
		Order order = Order.createOrder(member, delivery, orderItem);
		
		// 주문 저장
		orderRepository.save(order);
		
		return order.getId();
	}
	
	
	// 주문 취소
	@Transactional
	public void cancelOrder(Long orderId) {
		//주문 엔티티 조회
		Order order = orderRepository.findOne(orderId);
		
		// 주문 취소 처리
		order.cancel();
	}
	
	// 검색
/*	public List<Order> findOrders(OrderSearch orderSearch) {
		return orderRepository.findAll(orderSearch);
	}
*/
}

주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검색 기능을 제공한다. 

참고 : 예제를 단순화 하려고 한 번에 하나의 상품만 주문할 수 있다.

 

  • 주문(order()) : 주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장한다.
  • 주문 취소(cencel()) : 주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소를 요청한다.
  • 주문 검색(findOrders)) : OrderSearch라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다. 자세한 내용은 다음에 나오는 주문 검색 기능에서 알아보자.
참고 : 
주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직이 대부분 엔티티에 있다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할만 한다. 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것은 도메인 모델 패턴이라 한다. 반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라 한다.

 

 

주문 기능 테스트

테스트 요구사항

  • 상품 주문이 성공해야 한다.
  • 상품을 주문할 때 재고 수량을 초과하면 안 된다.
  • 주문 취소가 성공해야 한다.

OrderServiceTest.java

@SpringBootTest
@Transactional
@ActiveProfiles("test")
class OrderServiceTest {
	
	@Autowired private EntityManager em;
	@Autowired private OrderService orderService;
	@Autowired private OrderRepository orderRepository;
	

	@Test
	@DisplayName("상품주문")
	void order() {
		// given
		Member member = createMember();
		Item book = createBook("시골 JPA", 10000, 10);
		
		int orderCount = 2;
		
		// when
		Long orderId = orderService.order(member.getId(), book.getId(), orderCount);
		
		// then
		Order getOrder = orderRepository.findOne(orderId);
		
		assertEquals(OrderStatus.ORDER, getOrder.getStatus());
		assertEquals(1, getOrder.getOrderItems().size());
		assertEquals(10000 * 2, getOrder.getTotalPrice());
		assertEquals(10-2, book.getStockQuantity());
	}
	
	@Test
	@DisplayName("상품주문 재고수량 초과")
	void stockCountOver() {
		// given
		Member member = createMember();
		Item item = createBook("시골 JPA", 10000, 10);
		
		int orderCount = 20;
		
		// when
		assertThrows(NotEnoughStockException.class, () -> 
			orderService.order(member.getId(), item.getId(), orderCount)
			);
		
		// then
		fail("재고 수량 부족 예외가 발생해야 한다.");
		
	}
	
	@Test
	@DisplayName("주문 취소")
	void cencel() {
		// given
		Member member = createMember();
		Item book = createBook("시골 JPA", 10000, 10);
		
		int orderCount = 2;
		
		Long orderId = orderService.order(member.getId(), book.getId(), orderCount);
		
		// when
		orderService.cancelOrder(orderId);
		
		// then
		Order getOrder = orderRepository.findOne(orderId);
		
		assertEquals(OrderStatus.CANCLE, getOrder.getStatus());
		assertEquals(10, book.getStockQuantity());

	}
	
	private Book createBook(String name, int price, int stockQuantity) {
		Book book = new Book();
		book.setName(name);
		book.setPrice(price);
		book.setStockQuantity(stockQuantity);
		em.persist(book);
		return book;
	}

	private Member createMember() {
		Member member = new Member();
		member.setName("회원1");
		member.setAddress(new Address("서울", "거리", "123-123"));
		em.persist(member);
		return member;
	}
}
  • 상품주문(order()) : 상품주문이 정상 동작하는지 확인하는 테스트다.
  • 상품주문 재고수량 초과(stockCountOver()) : 재고 수량을 초과해서 상품을 주문하면 NotEnoughStockException 예외가 발생해야 한다
  • 주문취소(cencel()) : 주문을 취소하면 그만큼 재고가 증가해야 한다.

 

 

주문 검색 기능 개발

JPA에서 동적 쿼리를 어떻게 해결해야 하는가?

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    //...

    public List<Order> findAllByCriteria(OrderSearch orderSearch) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Order> cq = cb.createQuery(Order.class);
        Root<Order> o = cq.from(Order.class);
        Join<Order, Member> m = o.join("member", JoinType.INNER); //회원과 조인
        List<Predicate> criteria = new ArrayList<>();

        //주문 상태 검색
        if (orderSearch.getOrderStatus() != null) {
            Predicate status = cb.equal(o.get("status"),
                    orderSearch.getOrderStatus());
            criteria.add(status);
        }

        //회원 이름 검색
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            Predicate name =
                    cb.like(m.<String>get("name"), "%" +
                            orderSearch.getMemberName() + "%");
            criteria.add(name);
        }

        cq.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
        TypedQuery<Order> query = em.createQuery(cq).setMaxResults(1000); //최대 1000건
        return query.getResultList();
    }
}

 

JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡하다. 결국 다른 대안이 필요하다. 많은 개발자가 비슷한 고민을 했지만, 가장 멋진 해결책은 Querydsl이 제시했다. Querydsl 소개장에서 간단히 언급하겠다. 지금은 이대로 진행하자.

 

 


해당 글은 인프런의 [실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발] 강의를 정리한 내용입니다.

 

실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 인프런 | 강의

실무에 가까운 예제로, 스프링 부트와 JPA를 활용해서 웹 애플리케이션을 설계하고 개발합니다. 이 과정을 통해 스프링 부트와 JPA를 실무에서 어떻게 활용해야 하는지 이해할 수 있습니다., - 강

www.inflearn.com

댓글