어플리케이션 구현 준비
- % 앞선 과정들(도메인 설계)은 Data측면에서의 준비였고, 이제 이런 도메인들을 가지고 어떻게 구현할 것인지에 대한 이야기.
- 구현 요구사항
- 회원 기능 → 회원 등록, 회원 조회
- 상품 기능 → 상품 등록, 상품 수정, 상품 조회
- 주문 기능 → 상품 주문, 주문 내역 조회, 주문 취소
-
Application Architecture
- 계층형 구조: Controller, Service, Repository, DB, Domain
- controller, web: 웹 계층
- service: 비즈니스 로직, 트랜잭션 처리
- repository: JPA를 직접 사용하는 계층, 엔티티 매니저 사용
- domain: 엔티티가 모여 있는 계층, 모든 계층에서 사용 가능
- 계층형 구조: Controller, Service, Repository, DB, Domain
회원 도메인 개발
- 회원 Repository 개발 (MemberRepository)
@Repository
: component로써 Spring Bean으로 자동 등록- 영속성 컨텍스트 및 Entity를 관리할 Entity Manger injection하기 →
EntityManager em;
@PersistenceContext
: spring이 JPA의 entity manager을 생성하여 해당 em에 injection해줌
- 회원 저장 →
void save(Member member)
em.persist(member)
: 받아온 member를 저장. (transaction이 commit 되는 시점(transactional로 감싼 method가 종료될 때 commit 됨)에 DB에 반영됨.)
- 단일 조회 →
Member findOne
em.find(Member.class, id)
: 내가 조회할 Entity Class, PK
- 다건 조회 →
List<Member> findAll()
em.createQuery(qlString, resultClass)
사용 → jql query는 기존 sql query와는 다르게 Table이 아닌 Entity 객체를 대상으로 query를 작성해야됨. 즉, from의 대상이 table이 아닌 EntityqlString
:"select m from Member m"
→ 대상이 Table이 아닌 Entity!resultClass
:Member.class
→ 내가 조회할 Entity Class (DB입장에선 Table).getResultList();
: 결과를 List로 반환
- 다건 조회 (이름으로 검색) →
List<Member> findByName(String name)
qlString
:"
select m from Member m where m.name = :name"
→ where문 작성. name을 parameter로 바인딩.setParameter("name", name)
→ name을 parameter로 바인딩
resultClass
:Member.class
.getResultList();
: 결과를 List로 반환
- 회원 Service 개발 (MemberService) →
class MemberService
@Service
: component로써 Spring Bean으로 자동 등록@Transactional
: [필수] JPA의 모든 데이터 변경, 조회는 Transactional 안에서 실행 되어야 함. → 본질적으로 repository에서 데이터 저장, 변경, 조회가 이루어지지만, repository는 service 하위에 존재하기에 service에 transactional annotation을 달아줌- 여기서 만약 조회만 한다 하면
@Transactional(readOnly = true)
를 사용하여 성능을 더 올릴 수 있음 → 이를 각각 method에 annotation을 달아 override할 수도 있음!
- 여기서 만약 조회만 한다 하면
@RequiredArgsConstructor
: final이 붙어 있는 애들에 대해서 자동으로 생성자를 통해 Autowired(DI) 실시. 즉, 자동으로 constructor injection 실시 -> lombok 기능 →private **final** MemberRepository memberRepository;
(memberService에선 저장소를 가지고 기능을 구현하기에 repository를 DI 받아야함.)- field injection : 편리함. 하지만 추가적인 설정(injection 하는 대상을 변경하는 등)이 불가능
- setter injection : 추가적인 설정 가능. but 외부에서 변경할 수 있다는 단점을 가지고 있음
-
constructor injection : 추가적인 설정 + 외부에서 변경 불가능
@Autowired public MemberService(MemberRepository memberRepository) { this.memberRepository = memberRepository; }
- 이게 @RequiredArgsConstructor를 통해 자동으로 생성된다는 것!
- 이를 바탕으로 다른 class에서 이전에 Autowired 된 것들을 변경해주면 됨!
- 기능 구현
- 회원 가입 →
Long join(Member member)
- 중복 회원 검증 :
validateDuplicated(member);
- 중복된 회원이면 EXCEPTION throw! →
throw new IllegalStateException("Already Exited Member!")
- 동일한 이름을 가진 member가 단 하나라도 발견되면 안됨 →
memberRepository.findByName(member.getName()).stream().findAny();
if (findMember1.isPresent()) {...}
- 중복된 회원이면 EXCEPTION throw! →
memberRepository.save(member);
return member.getId();
: 영속성 컨텍스트에 들어가는 순간, id값을 부여받기 때문에, DB에 commit하지 않아도 id를 확인할 수 있음
- 중복 회원 검증 :
- 회원 조회
- 단일 회원 조회
@Transactional(readOnly = true)
→ 조회 성능 올리기return memberRepository.findOne(memberId);
- 전체 조회
@Transactional(readOnly = true)
→ 조회 성능 올리기return memberRepository.findAll();
- 단일 회원 조회
- 회원 가입 →
- 기능 TEST
-
tdd → inteliJ settings의 live template을 통해 기본 test case template을 만들어 두고 사용 가능!
- 회원 Service Test (MemberServiceTest) →
class MemberServiceTest
@SpringBootTest
: Spring Boot 상에서 Test하겠다는 뜻. 이 annotation이 없다면 Java상에서 Test가 돌아가게 됨.@Transactional
: Rollback을 위함. service에서 작성한 것과는 별개로 필요함 -> Rollback을 위해. 즉, Test에서의 transactional은 service에서의 transactional과 달리 기본적으로 Commit하지 않고 Rollback해버림- DI 해줘야 하는 것들 :
MemberService
,MemberRepository
,EntityManager
(em은 사실 flush를 위함, 필수는 아님)- memberservice와 memberrepository를 통한 Test가 이루어지므로
- 회원 가입 Test →
void join() throws Exception
given (주어진 것들을 통해)
,when (이런 기능을 동작했을 때)
,then (이런 결과를 확인할 것)
의 형식을 이용- given에 Member를 생성, when에 member를 memberservice를 통해 저장. then을 통해 생성된 member가 이미 존재한 회원인지를 확인 → *
assertEquals*(member, memberService.findOne(savedId));
(같은 transactional (영속성 컨텍스트) 안에서 Id(PK)값이 똑같으면 오류 발생!)- Assert는 Test에서 굉장히 유용하게 쓰이는 library
- 회원 중복 가입 Test →
void duplicatedJoin() throws Exception
- given - 동일한 이름을 가진 member를 2개 생성
- when - 먼저 첫번째 member를 저장
-
then - 동일한 이름을 가진 2번째 member를 저장. 여기서 오류 발생을 기대하는 것! (중복 감지 method에서 throw된 exception을 받아야됨.)
IllegalStateException thrown =assertThrows(IllegalStateException.class, () -> memberService.join(member2)); assertEquals("Already Exited Member!", thrown.getMessage());
- try catch 대신 assertEquals로 오류가 발생했는지 판단.
- 추가로
Assert
의fail()
을 통해 예외를 강제로 발생하여 해당 코드까지 진행되면 안되게끔 설정도 가능함.
- 서버 DB를 사용하지 않고 내장 Memory DB를 사용하여 Test 진행하는 법!
- 외부 DB를 키지 않고 내장 Membory DB를 통해 빠르게 Test가 가능함.
- 또한, 실제 Main에서 진행하는 코드들과 Test에서 진행하는 test는 엄연히 다른 것! → DB를 구분해줄 필요가 있음!!!
-
이는 Test에 resources를 새로 생성해서 Test용 application.yml을 설정해주면 됨.
-
그 후 해당 application.yml에 새로운 DB 연결 정보를 넣어주면 됨.
spring: #springboot에 연결할 DB관련 정보 datasource: url: jdbc:h2:mem:test
jdbc:h2:mem:test
→ 내장 메모리를 사용하는 DB- 하지만 사실 이 모든 설정을 하지 않고 test에 resources만 생성해 준다면 Spring Boot는 자동으로 DB를 내장메모리로 사용하게 됨.
-
-
상품 도메인 개발
- 상품 Entity 개발
- [중요!] Service에 비지니스를 새로 만드는 것이 아닌, 상품 Entity 자체에 비지니스 로직을 추가하는 것.
- 즉, Item Entity에 비지니스 로직 method를 추가해주는 것.
- 원래 같으면 Service에서 set 으로 해당 비지니스 로직을 구현하겠지만, 객체 지향적으로 생각해보면 해당 객체의 값을 변경하는 것은 그 객체 안에서 진행하는 것이 가장 BEST (응집력 UP) → 이를 통해 Setter를 생성하지 않아도 되는 것. Setter 없이 프로그램을 만드는 게 안전함!
- 재고 증가 로직 (method) →
void addStock(int quantity)
this.stockQuantity += quantity;
- 재고 감소 로직 (method) →
void removeStock(int quantity)
- 감소된 재고가 0보다 작으면 Exception을 thorw 해줘야됨. →
throw new NotEnoughStockException("Not Enough Stock now!!");
- Exception 생성 :
class NotEnoughStockException extends RuntimeException
생성 후 RuntimeException Override!
- 감소된 재고가 0보다 작으면 Exception을 thorw 해줘야됨. →
- 상품 Repository 개발
- 상품 저장 →
void save(Item item)
- 새로 저장되는 item인지, 수정하는 item인지 구분 필요.
if(item.getId() == null){ em.persist(item); }
: 영속성 컨텍스트에 올라기지 않은 상태의 새로운 Item은 Id가 부여되지 않음. → 그렇기에 persist를 통해 영속성 컨텍스트에 올리고 저장하면 됨.else
: 하지만, 이미 영속성 컨텍스트에 올라가 Id를 부여받은 상태 (즉, 저장되어 있는 item) 일 경우,em.merge(item);
를 통해 준영속 상태의 엔티티를 영속 상태로 변경 → (merge 부분은 뒤에서 더 자세히 다룰 에정)
- 새로 저장되는 item인지, 수정하는 item인지 구분 필요.
- 상품 조회
- 단건 조회 :
return em.find(Item.class, id);
- 다건 조회 :
return em.createQuery("select i from Item i", Item.class.getResultList()
;
- 단건 조회 :
- 상품 저장 →
- 상품 Service 개발
- 사실 이부분에선 상품 Repository에 기능을 위임하는 정도 밖에 없음
- 그래서 이 부분을 굳이 Service를 거쳐야 하나를 고민해볼 필요가 있음
- 기본적으로 필요한
@Service @Transactional(readOnly = true) @RequiredArgsConstructor
annotation 달아주고 - ItemRepository를 DI 시켜준 후, 저장, 조회기능을 repository와 연결해주면 끝
주문 도메인 개발
- 주문 Entity 개발
- Order Class
- Order에는 양방향 연관관계가 있으며 Order가 그 양방향 연관관계에서 우위를 점하고 있는 것들이 많음. → 생성 메서드를 Entity 자체에 만들어주면 좋음! → **
static** Order createOrder(Member member, Delivery delivery, OrderItem... orderItems)
- Order가 생성될 때 무조건 호출되어야 하는 놈
- 연관관계 매핑 및 값을 설정하는 역할
//==생성 메서드==// (응집성 향상) public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems){ // ...은 args 느낌 Order order = new Order(); order.setMember(member); // 연관관계 매핑 order.setDelivery(delivery); // 연관관계 매핑 for (OrderItem orderItem : orderItems){ order.addOrderItem(orderItem); // 각 orderItem에 대한 연관관계 매핑 } order.setStatus(OrderStatus.ORDER); // 값 설정 order.setOrderDate(LocalDateTime.now()); // 값 설정 return order; // 생성된 order 반환 }
- 이런 생성메서드만을 사용하게 하기 위해서 Order Class에
@NoArgsConstructor(access = AccessLevel.PROTECTED)
를 추가해줌@NoArgsConstructor(access = AccessLevel.PROTECTED)
: preotected 형태의 생성자를 자동으로 생성해 주는 것. 즉, 외부에서 생성자를 사용할 수 없게 하여, 생생 메서드를 필수로 사용하게끔 하는 전략.
- [중요!] Service에 비지니스를 새로 만드는 것이 아닌, 상품 Entity 자체에 비지니스 로직을 추가하는 것. (”비지니스 로직”)
- 즉, Order Entity에 비지니스 로직 method를 추가해주는 것.
- 주문 취소
- Service에서 Setter를 사용한 값 변경이 아닌 Entity 자체에서 변경 -> 더 안정적인 프로그램 가능
public void cancel(){ if(delivery.getStatus() == DelivieryStatus.COMPLETE){ throw new IllegalStateException("이미 배송 완료된 상품은 취소가 불가능합니다."); } this.setStatus(OrderStatus.CANCEL); for (OrderItem orderItem : orderItems) { // 주문에서 요구했던 item들에 대해 취소시켜줌 orderItem.cancel(); // Order와 orderItem은 일대다 이기에 그 '다'의 모든 orderItem 각각에 대해 취소해줘야됨 } }
- 전체 가격 조회
- 주문에서의 각 orderItem에는 해당 아이템이 얼만지, 아이템을 몇개를 샀는지 의 정보가 있음, 이들을 곱해서 더해줘야됨
public int getTotalPrice(){ return orderItems.stream() .mapToInt(OrderItem::getTotalPrice) .sum(); // 가격*수량 이 누적되어야 됨 }
- Order에는 양방향 연관관계가 있으며 Order가 그 양방향 연관관계에서 우위를 점하고 있는 것들이 많음. → 생성 메서드를 Entity 자체에 만들어주면 좋음! → **
- OrderItem Class
- 생성 메서드 →
static OrderItem createOrderItem
- Order 처럼 생성될 때 연관관계도 있고 해서 간단하지 않음.
- 그래서 Service에서 생성로직을 구현하는 것 보다는 Entity 자체에서 생성메서드를 구현하는 것이 응집력을 높임
- 하지만, OrderItem과 Item은 양방향이 아닌, 단방향 연관관계이기에 연관관계 매핑은 필요 없어서, 생성메서드에 연관관계 매핑 관련 method는 존재하지 않음.
- 하지만, Item에서 order에 따른 수량이 변경되기 때문에, 그 부분에 있어서의 연관관계에 따른 설정은 필요함.
//==생성 메서드==// 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); // OrderItem에서 Item과의 연관관계 메서드가 없는 이유. //-> Item에서 OrderItem은 일대 다 관계이지만 이 OrderItem을 리스트로 담아 관리하는 것이 아닌 그저 수량만을 관리하기 때문 return orderItem; }
- 이런 생성메서드만을 사용하게 하기 위해서 OrderItem Class에
@NoArgsConstructor(access = AccessLevel.PROTECTED)
를 추가해줌
- 비지니스 로직
- 주문 취소 (Order및 Item과 연계되어 동작)
- 주문 취소는 Order에 의해 연계되어 실행되고, 해당 Orderitem의 취소는 다시 Item에 연계되어 동작하게 됨. (취소하면 Item의 수량은 늘어나야 됨.)
public void cancel() { getItem().addStock(count); }
- 전체 가격 조회
- OrderItem이 Item을 통해 받은 수량과 가격의 곱을 통해 전체 가격을 조회할 수 있음
public int getTotalPrice() { return getOrderPrice() * getCount(); }
- 주문 취소 (Order및 Item과 연계되어 동작)
- 생성 메서드 →
- Order Class
- 주문 Repository 개발
- 필요한 annotation인
@Repository
@RequiredArgsConstructor
를 달아주고 EntityManager를 DI 받은 후 em을 통해 저장과 단건조회 method를 만들어주면 됨. - 주문 저장 →
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)
[동적 쿼리 필요 → QueryDSL(추후 이걸 주제로 배울 예정), 현재는 JPA Criteria로 보충]- OrderSearch class 필요.
- 동적 쿼리의 선택지가 되는 두가지 변수 (회원 이름, 주문 상태)를 저장해서 OrderService로 보내주게 되는 객체.
- 회원명이 있으면 회원에 따른 where문 , 주문상태가 있으면 주문상태에 따른 where문, 둘다면 둘다에 따른 where 문이 동작
- OrderSearch class 필요.
- 필요한 annotation인
- 주문 Service 개발
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
annotation을 통해서 Bean 등록, Transactional에서 동작, 자동으로 생성자를 통한 DI 실시 진행- Order Service에서는 Member와 Item에 접근하여 Order에 저장하는 비지니스 로직을 가지고 있기에 orderRepository 뿐만 아니라 memberRepository, itemRepository 또한 DI가 필요함.
- 주문 기능 →
Long order(Long memberId, Long itemId, int count)
- 엔티티 조회
- 멤버id를 통해 Member를 가지고 오는 것. →
memberRepository.findOne(memberId);
- Item id를 통해 Item을 가지고 오는 것. →
itemRepository.findOne(itemId);
- 멤버id를 통해 Member를 가지고 오는 것. →
- 배송정보 생성
- Delivery Entity 생성 →
Delivery delivery = new Delivery(); delivery.setAddress(member.getAddress());
- Delivery Entity 생성 →
- 주문 상품 생성
- Entity 관계도를 참고하면 Order에 바로 Item을 넣어 주는 것이 아닌 OrderItem을 거쳐서 집어넣음 -> 다대다 관계이기 때문
- OrderItem 자체는 연관관계도 있고 값들도 많기에 Entity 자체에 생성메서드를 만들어 놓았기에, 이를 활용. →
OrderItem orderItem = OrderItem.***createOrderItem***(item, item.getPrice(), count);
- 또한, Order 전에 OrderItem을 생성하여 Order 생성메서드의 인자로 넣어줘야 하기에 Order 전에 생성.
- 주문 생성
- Order도 Entity 자체가 간단하지 않기에 Entity 자체에 생성 메서드를 만들었었음
Order order = Order.*createOrder*(member, delivery, orderItem);
- 주문 저장
orderRepository.save(order);
: 앞선 과정들을 바탕으로 orderRepository를 통해 save 진행.- 원래같으면 order와 연관되어 있지만 따로 persist를 진행하지 않았던 delivery, orderItem도 따로 save 하여 presist해줘야됨. BUT! 이 둘다 Order Entity 속 연관관계에서
cascade = CascadeType.ALL
로 설정 했기에 자동으로 저장됨 - 이는 Order Entity가 em을 persist 될 경우 영속성 컨텍스트 안으로 들어가기에 가능한 부분
- 영속성 컨텍스트에 Order가 올라가게 되면 그에 연계된 Delivery, OrderItem 또한 올라가게 됨. 이때 Transactional 에서 commit을 진행하면,
cascade = CascadeType.ALL
인 연계 Entity에 대해서도 commit을 해주는 것! cascade = CascadeType.ALL
은 다른 Entity에서 참조하지 않고 해당 객체에서만 참조를 할때 사용해야됨.
- 영속성 컨텍스트에 Order가 올라가게 되면 그에 연계된 Delivery, OrderItem 또한 올라가게 됨. 이때 Transactional 에서 commit을 진행하면,
- 엔티티 조회
- 취소 기능 [중요! → JPA의 장점을 확인할 수 있는 부분] →
void cancelOrder(Long orderId)
- 주문 Entity 조회 →
orderRepository.findOne(orderId);
- 주문 취소 →
order.cancel();
- 만약 JPA를 활용하지 않았더라면 -> cancel하면서 변경된 order의 state, orderitems의 item의 수량들을 모두 변경하고 다시 DB에 업데이트 시켜줘야됨 (즉, 각각에 대한 query를 날려줘야 된다는 것.)
- 하지만 JPA는 Entity내에서 변경된 점을 자동으로 업데이트 해줌. 즉, 변경된 지점을 찾아서 그 지점에 관한 update query를 자동으로 DB로 보낸다는 것 -> dirty checking(변경 감지)
- 이게 가능한 이유는 지금 취소하려는 주문 자체가 em을 통해 DB에서 가져왔기 때문 (
orderRepository.findOne(orderId);
) → em이 DB에서 Entity를 가져오게 되면, 그 Entity는 em의 영속성 컨텍스트에 머무르게 됨. → 이때 그 Entity의 값들이 변경되게 되면 em은 이를 감지하고 (변경감지, dirty checking) 해당 method의 Transactional이 끝나 commit이 이루어지면, 이와 관련된 모든 qeury를 자동으로 날려주는 것.
- 주문 Entity 조회 →
- 주문 조회 → 동적 쿼리 사용 (뒤에서 설명)
- 주문 기능 Test
@SpringBootTest @Transactional
annotation 필수 +@Test
- 사실 Test는 SpringBoot 위에서 진행하는 것 보다는 SpringBoot 없이, 즉 DB 연결 없이 단일적으로 비지니스만을 체크하는 Test가 좋은 Test. (하지만 여기선 JPA의 동작을 확인하는 것이기에 SpringBoot 위에서 Test 진행)
- 상품 주문 Test
- 주문이 들어 갔을 때 → 주문 상태, 주문된 상품의 종류, 주문 총 금액, 주문 후 해당 상품의 남은 수량이 비지니스 로직에 맞게 잘 설정 되는지
assertEquals(expected, actual, message)
를 통해 확인 - given :
Member member, Item item, int orderCount
- when :
Long orderId = orderService.order(member.getId(), item.getId(), orderCount);
-
then
Order ordered = orderRepository.findOne(orderId); assertEquals(OrderStatus.ORDER, ordered.getStatus(), "상품 주문 시 상태는 ORDER"); assertEquals(1,ordered.getOrderItems().size(), "주문한 상품 종류 수가 정확해야 한다"); assertEquals(10000*2, ordered.getTotalPrice(), "주문 가격은 가격 * 수량"); assertEquals(20-2, item.getStockQuantity(), "주문 수량만큼 재고가 줄어야 한다.");
- 주문이 들어 갔을 때 → 주문 상태, 주문된 상품의 종류, 주문 총 금액, 주문 후 해당 상품의 남은 수량이 비지니스 로직에 맞게 잘 설정 되는지
- 상품주문 시 재고수량 초과 Test
- 주문한 수량이 재고보다 넘을 경우
assertEquals(expected, actual)
를 통해 Exception이 발생하고 그 Exception message가 expected와 동일한지 확인 - given :
Member member, Item item, int orderCount
→ 여기서 orderCount가 item에 설정된 수량보다 많게 설정 - when :
NotEnoughStockException thrown = *assertThrows*(NotEnoughStockException.class, () -> orderService.order(member.getId(), book.getId(), orderCount));
→ 초과된 수량으로 주문을 할 때의 튀어나오는 Exception을 catch해서 저장해둠 - then : *
assertEquals*("Not Enough Stock now!!", thrown.getMessage());
→ 동일한 message로 Exception이 발생했는지 확인
- 주문한 수량이 재고보다 넘을 경우
- 주문 취소 Test
- 주문 취소 시 → order의 status가 CANCEL 로 변하는지 확인, 취소 수량이 원복되는 지 확인
- given :
Member member, Item item, int orderCount
,Long orderedId = orderService.order(member.getId(), item.getId(), orderCount);
- when :
orderService.cancelOrder(orderedId);
-
then
Order ordered = orderRepository.findOne(orderedId); assertEquals(OrderStatus.CANCEL, ordered.getStatus(), "주문 취소 시 상태는 CANCEL 이다."); assertEquals(20, item.getStockQuantity(), "취소된 상품은 재고가 다시 원복되어야 함");