해당 카테고리는 김영한님의 인프런 강의 '실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발'을 듣고 내용을 정리하기 위한 것으로 자세한 설명은 해당 강의를 통해 확인할 수 있습니다.
📝 회원 도메인 개발
구현 기능
- 회원 등록
- 회원 목록 조회
📜 회원 리포지토리 개발
package jpabook.jpashop.repository;
@Repository
@RequiredArgsConstructor
public class MemberRepository {
private final 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) 주입
- 기능 설명
- save(): 데이터베이스에 회원 정보 저장
- findOne(): 데이터베이스에서 회원 조회
- findAll(): 데이터베이스에서 모든 회원 조회
- findByName(): 데이터베이스에서 이름으로 회원 조회
📜 회원 서비스 개발
package jpabook.jpashop.service;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional
public Long join(Member member) {
validateDuplicateMember(member); //중복 회원 검증
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) {
List<Member> findMembers = memberRepository.findByName(member.getName());
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을 많이 사용, 생성자가 하나면 생략 가능
- 기능 설명
- join(): 회원가입
- findMembers(): 모든 회원 조회
- findOne(): 회원 조회
참고: 실무에서는 검증 로직이 있어도 멀티 쓰레드 상황을 고려해서 회원 테이블의 회원명 컬럼에 유니크 제약 조건을 추가하는 것이 안전하다.
참고: 스프링 필드 주입 대신에 생성자 주입을 사용하자.
변경 불가능한 안전한 객체 생성 가능, 생성자가 하나면 @Autowired를 생략할 수 있음, final 키워드를 추가하면 컴파일 시점에 memberRepository를 설정하지 않는 오류를 체크할 수 있다. (보통 기본 생성자를 추가할 때 발견)
스프링 데이터 JPA를 사용하면 EntityManager도 주입 가능
📜 회원 기능 테스트
테스트 요구사항
- 회원가입을 성공해야 한다.
- 회원가입 할 때 같은 이름이 있으면 예외가 발생해야 한다.
package jpabook.jpashop.service;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Transactional
class MemberServiceTest {
@Autowired MemberService memberService;
@Autowired MemberRepository memberRepository;
@Test
@Rollback(value = false)
public void 회원가입() {
//given
Member member = new Member();
member.setName("kim");
//when
Long savedId = memberService.join(member);
//then
assertEquals(member, memberRepository.findOne(savedId));
}
@Test
public void 중복_회원_예외() {
//given
Member member1 = new Member();
member1.setName("kim");
Member member2 = new Member();
member2.setName("kim");
//when
memberService.join(member1);
try {
memberService.join(member2);
} catch (IllegalStateException e) {
return;
}
//then
fail("예외가 발생해야 합니다.");
}
}
- 기술 설명
- @SpringBootTest: 스프링 부트를 띄우고 테스트한다.(이게 없으면 @Autowired 다 실패)
- @Transactional: 반복 가능한 테스트 지원, 각각의 테스트를 실행할 때마다 트랜잭션을 시작하고 테스트가 끝나면 트랜잭션을 강제로 롤백(이 어노테이션이 테스트 케이스에서 사용될 때만 롤백)
- 기능 설명
- 회원가입 테스트
- 중복 회원 예외처리 테스트
테스트 케이스를 위한 설정
테스트는 케이스 격리된 환경에서 실행하고, 끝나면 데이터를 초기화하는 것이 좋다. 그런 면에서 메모리 DB를 사용하는 것이 가장 이상적이다.
추가로 테스트 케이스를 위한 스프링 환경과, 일반적으로 애플리케이션을 실행하는 환경은 보통 다르므로 설정 파일을 다르게 사용하자.
다음과 같이 간단하게 테스트용 설정 파일을 추가하면 된다.
[test/resources/application.yml]
spring:
datasource:
url: jdbc:h2:mem:test;
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-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 관련된 별도의 추가 설정을 하지 않아도 된다.
📝 상품 도메인 개발
구현 기능
- 상품 등록
- 상품 목록 조회
- 상품 수정
📜 상품 엔티티 개발(비즈니스 로직 추가)
package jpabook.jpashop.domain.item;
@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<>();
//==비즈니스 로직==/
/**
* 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;
}
}
예외 추가
package jpabook.jpashop.exception;
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() 메서드는 파라미터로 넘어온 수만큼 재고를 줄인다. 만약 재고가 부족하면 예외가 발생한다. 주로 상품을 주문할 때 사용한다.
📜 상품 리포지토리 개발
package jpabook.jpashop.repository;
@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()를 실행, 자세한 내용은 뒤에 웹에서 설명(그냥 지금은 저장한다 정도로 생각하자)
- save()
📜 상품 서비스 개발
package jpabook.jpashop.service;
@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);
}
}
상품 서비스는 상품 리포지토리에 단순히 위임만 하는 클래스
상품 테스트는 회원 테스트와 비슷하므로 생략
📝 주문 도메인 개발
구현 기능
- 상품 주문
- 주문 내역 조회
- 주문 취소
📜 주문, 주문상품 엔티티 개발
주문 엔티티 개발
package jpabook.jpashop.domain;
@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
//...
//==생성 메서드==//
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()): 주문 취소시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.
- 전체 주문 가격 조회: 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다. (실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 한다.)
주문상품 엔티티 개발
package jpabook.jpashop.domain;
@Entity
@Getter @Setter
public class OrderItem {
//...
//==생성 메서드==//
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)를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킨다.
- 주문 가격 조회(getTotalPrice()): 주문 가격에 수량을 곱한 값을 반환한다.
📜 주문 리포지토리 개발
package jpabook.jpashop.repository;
@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) 메서드는 조금 뒤에 있는 주문 검색 기능에서 자세히 알아보자.
📜 주문 서비스 개발
package jpabook.jpashop.service;
@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(memberId);
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();
}
/*
public List<Order> findOrders(OrderSearch orderSearch) {
return orderRepository.findAll(orderSearch);
}
*/
}
주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검색 기능을 제공한다.
참고: 예제를 단순화하려고 한 번에 하나의 상품만 주문할 수 있다.
- 주문(order()): 주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장한다.
- 주문 취소(cancelOrder()): 주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소를 요청한다.
- 주문 검색(findOrders()): OrderSearch라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다. 자세한 내용은 다음에 나오는 주문 검색 기능에서 알아보자
참고: 주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다. 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴(http://martinfowler.com/eaaCatalog/ domainModel.html)이라 한다. 반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴(http://martinfowler.com/eaaCatalog/ transactionScript.html)이라 한다.
📜 주문 기능 테스트
테스트 요구사항
- 상품 주문이 성공해야 한다.
- 상품을 주문할 때 재고 수량을 초과하면 안 된다.
- 주문 취소가 성공해야 한다.
package jpabook.jpashop.service;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Transactional
class OrderServiceTest {
@Autowired EntityManager em;
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
public void 상품주문() {
//given
Member member = createMember();
Book 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 * orderCount, getOrder.getTotalPrice());
assertEquals(8, book.getStockQuantity());
}
@Test
public void 상품주문_재고수량초과() {
//given
Member member = createMember();
Item item = createBook("시골 JPA", 10000, 10);
int orderCount = 11;
//when
try {
orderService.order(member.getId(), item.getId(), orderCount);
} catch (NotEnoughStockException e) {
return;
}
//then
fail("재고 수량 부족 예외가 발생해야 합니다.");
}
@Test
public void 주문취소() {
//given
Member member = createMember();
Item item = createBook("시골 JPA", 10000, 10);
int orderCount = 2;
Long orderId = orderService.order(member.getId(), item.getId(), orderCount);
//when
orderService.cancelOrder(orderId);
//then
Order getOrder = orderRepository.findOne(orderId);
assertEquals(OrderStatus.CANCLE, getOrder.getStatus());
assertEquals(10, item.getStockQuantity());
}
private Book createBook(String name, int orderPrice, int stockQuantity) {
Book book = new Book();
book.setName(name);
book.setPrice(orderPrice);
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;
}
}
- 상품주문(): 상품주문이 정상 동작하는지 확인하는 테스트다.
- 상품주문_재고수량초과(): 재고 수량을 초과해서 상품을 주문하면 NotEnoughStockException 예외가 발생해야 한다.
- 주문취소(): 주문을 취소하면 그만큼 재고가 증가해야 한다.
📜 주문 검색 기능 개발
package jpabook.jpashop.repository;
@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 소개장에서 간단히 언급하겠다. 지금은 이대로 진행하자.
'🍃 Spring, Spring Boot > 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발' 카테고리의 다른 글
[실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발] 3. 웹 계층 개발 (0) | 2022.06.12 |
---|---|
[실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발] 1. 도메인 분석 설계 (0) | 2022.06.05 |