❗주문 도메인 개발❗, 웹 계층 개발

2022. 5. 23. 13:29Java/JPA

💡 [중요] 주문 도메인 개발

 

구현 기능

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

개발 순서

  • 주문 엔티티, 주문 상품 엔티티 개발(핵심 비즈니스 로직)
  • 주문 리포지토리 개발
  • 주문 서비스 개발
  • 주문 검색 기능 개발
  • 주문 기능 테스트 

 

 

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

package jpabook.jpashop.domain;

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {

    ...

    /**
     *  핵심 비즈니스 로직
     *  복잡한 생성 메서드는 별로의 생성 메서드로 빼서 구현
     */
    // 파라미터 점점점 문법
    // 가변인자
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) { //OrderItem여러개 넣을 수 있음

    }

}

 

가변 인자
OrderItem의 인자가 0부터 여러 개까지 올 수 있음
   - 매개변수 선언할 때 항상 마지막에 써야 함
   - 전달받은 가변 인자는 반복문 써서 사용 가능  

가변인자는 컴파일 시 배열로 처리되기 때문에 사용할 때 주의해야 한다. (0개나 1개도 마찬가지)
    따라서 배열의 값을 보려면 Arrays.toString()을 사용한다.

 

 

Order > 조회 로직

package jpabook.jpashop.domain;

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {

  	...

    /**
     * 조회 로직 - 전체 주문 가격 조회
     */
    public int getTotalPrice() { //OrderItem 다 더하기
        int totalPrice = 0;
        for(OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }
}

 

이 부분을 스트림을 이용해서 코드 줄이기 가능

public int getTotalPrice() { //OrderItem 다 더하기
        return orderItems.stream()
                .mapToInt(OrderItem::getTotalPrice)
                .sum();
    }

 

Order

package jpabook.jpashop.domain;

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {

    ...

    /**
     *  복잡한 비즈니스로직을 별로의 '생성 메서드로 빼서 구현' - set
     *  장점 : 생성 관련된 것은 이 부분만 수정하면 됨 - 유지보수 쉬움
     */
    //static메서드는 클래스 메서드 - 클래스이용해서 호출, 유틸리티성 메서드 작성시 유리
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) { //OrderItem 여러개 넣을 수 있음(가변인자)
        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; // 리턴타입 Order -> return order;
    }

    /**
     * 비즈니스 로직 - 주문 취소
     */
    public void cancel() {
        if(delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }

        this.setStatus(OrderStatus.CANCEL); //this는 필드 의미함
        for(OrderItem orderItem : orderItems) {
            orderItem.cancel(); //OrderItem에도 비즈니스 로직 추가
        }
    }

    /**
     * 조회 로직 - 전체 주문 가격 조회
     */
    public int getTotalPrice() { //OrderItem 다 더하기
        int totalPrice = 0;
        for(OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }
}

 

OrderItem

package jpabook.jpashop.domain;

@Entity
@Getter
@Setter
public class OrderItem {

   ...

    /**
     *  생성 메서드
     */
    public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
        // 할인도 가능할 수 있기 때문에 원래 필드가 아닌 객체 생성해서 set해주는 것.
        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();
    }
}

 

Item

package jpabook.jpashop.domain.item;

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) // 상속관계전략 - SINGLE_TABLE 한 테이블에 다 넣음
@DiscriminatorColumn(name = "dtype") // 싱글테이블전략이므로 구분자 지정
@Getter
@Setter
public abstract class Item { // 추상 클래스

    ...

    /**
     * 사용할 데이터(stockQuantity)를 가지고 있는 [엔티티 안에 비즈니스 로직 추가]하는 것이 응집도가 있다.
     *   Service에서 비즈니스 로직을 짜는 것 보다 객체 지향적이다. 관리용이.
     *   Setter를 이용해서 stockQuantity를 변경하는 것이 아닌 비즈니스 로직을 만들어서 수행
     */
    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;
    }

}

 

 

주문 리포지토리 개발

주문 검색 기능은 추후에 붙일 예정

package jpabook.jpashop.repository;

import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;

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

 

주문 서비스 개발

package jpabook.jpashop.service;

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

    private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;

    /**
     * 주문
     *   memberId를 가져오기 위해선 MemberRepository있어야 함
     *   어떠한 도메인이든 Controller, Service, Repository에서 접근 가능
     */
    @Transactional
    public Long order(Long memberId, Long itemId, int count) {
        //엔티티 조회
        Member member = memberRepository.findOne(memberId);
        Item item = itemRepository.findOne(itemId);

        //배송정보 생성(회원정보 Address)
        Delivery delivery = new Delivery();
        delivery.setAddress(member.getAddress());

        //주문상품 생성
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

        //주문 생성
        Order order = Order.createOrder(member, delivery, orderItem);

        //주문 저장 - order 식별자값 반환
        orderRepository.save(order);
        return order.getId();

    }


}

 

💡 의문

원래 delivery, orderitem은 repository.save 해서 각각 persist 해야 한다. 하지만 여기서 

orderRepository.save(order);

이것만 했다. 이는 Order도메인에서 해결책을 찾아볼 수 있다. 

 

package jpabook.jpashop.domain;

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {

   ...

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) // OrderItem테이블에 있는 order필드
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) // 일대일관계는 FK는 어디다 둬도 된다, 그럼 access잦은 쪽에 둔다, 연관관계주인 Order가 됨
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

   ...
}

cascade = CascadeType.ALL을 사용했기 때문이다.

의미는 Order를 persist 하면 들어와 있는 Orderitem, Delivery 엔티티도 강제로 persist 날린다는 것이다.

 

cascade는

  (private owner 일 경우 사용)

   persist 하는 라이프사이클 동일할 때 사용한다.

즉 다른 곳에서 가져다 쓰거나 중요한 경우에는 쓰면 안 된다. 

잘 모르겠으면 쓰지 않는 것을 추천하고 - 코드 다 짜고 리팩토링때 고려

 

 

 

롬복 annotiation

@NoArgsConstructor(access = AccessLevel.PROTECTED)

💡 코드는 항상 제약적으로 짜는 게 좋은 설계이자 유지 보수하기에 좋습니다. 

 

OrderService

본인이 혼자 코드를 짤 때는 [생성 메서드]를 이용하겠지만,

package jpabook.jpashop.service;


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

    private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;

    /**
     * 주문
     *   memberId를 가져오기 위해선 MemberRepository있어야 함
     *   어떠한 도메인이든 Controller, Service, Repository에서 접근 가능
     */
    @Transactional
    public Long order(Long memberId, Long itemId, int count) {
        //엔티티 조회
        Member member = memberRepository.findOne(memberId);
        Item item = itemRepository.findOne(itemId);

        //배송정보 생성(회원정보 Address)
        Delivery delivery = new Delivery();
        delivery.setAddress(member.getAddress());

        //주문상품 생성 (생성메서드 이용)
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

        //주문 생성 (생성메서드 이용)
        Order order = Order.createOrder(member, delivery, orderItem);

        //주문 저장 - order 식별자값 반환
        orderRepository.save(order);
        return order.getId();
    }

    

}

 

다른 개발자가 생성 메서드가 이미 있는지 모르고, 

package jpabook.jpashop.service;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
	...
    
    @Transactional
    public Long order(Long memberId, Long itemId, int count) {
       	...
        
        //주문상품 생성 (생성메서드 이용)
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
        
        // [다른 개발자 코딩 중]
        OrderItem orderItem1 = new OrderItem();
        orderItem1.setCount();
       ...
    }


}

로직의 일관성이 없으면 나중에 유지보수하기 힘들어지므로 

이걸 막아야 합니다. 

 

Order, OrderItem에

@NoArgsConstructor(access = AccessLevel.PROTECTED)

하면 이제 저 부분(orderItem1.setCount();)에 컴파일 오류가 납니다.

 

 

 

OrderService > 주문 취소

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 void cancelOrder(Long orderId) { //주문취소할 때 아이디값만 넘어감
        //주문 엔티티 조회
        Order order = orderRepository.findOne(orderId);
        //주문 취소(이미 Order에 주문취소 비즈니스 로직 만들었음)
        order.cancel();
    }


}

 

💡 JPA의 큰 강점

원래 테이터를 변경하면, 밖에서 update쿼리를 직접 짜서 날려야 합니다.

그런데 JPA를 사용 시 데이터를 바꾸면, 

변경 포인트를 더티 체킹 해서 DB에 update쿼리가 날아갑니다. 

 

Order

 public void cancel() {
        if(delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }

        this.setStatus(OrderStatus.CANCEL); //this는 필드 의미함
        for(OrderItem orderItem : orderItems) {
            orderItem.cancel(); //OrderItem에도 비즈니스 로직 추가
        }
    }

OrderItem

public void cancel() { //핵심은 주문 수량만큼 재고 원상복구
        getItem().addStock(count);

    }

 

 

개념
변경 감지
플러시

변경 감지(Dirty Checking)

  1. commit 하는 시점에 내부적으로 flush가 호출
  2. 엔티티와 스냅샷을 비교 (스냅샷: 조회해서 1차 캐시로 들어온 최초 시점을 스냅샷으로 찍어둔 것)
  3. 데이터 변경했을 시 커밋할 때 JPA가 비교해서 → sql 생성 후 쓰기 지연 sql 저장소에 둠
  4. update 쿼리를 데이터베이스에 반영 후 커밋하게 된다.

 

플러시

  1. 영속성 컨텍스트의 변경내용을 DB와 동기화 반영
    → Insert, Update, Delete를 날려서 영속성 컨텍스트와 DB를 일치화시킴
  2. DB 트랜잭션이 커밋되면 자동으로 플러시 발생
  3. 영속성 컨텍스트를 비우지 않음
  4. 1차 캐시를 지우는 것은 아님
  5. 트랜잭션 커밋 직전에만 동기화하면 되기 때문에 트랜잭션이라는 작업 단위가 중요

플러시가 발생하면 생기는 일

  1. 변경 감지(더티 체킹)
  2. 수정된 엔티티를 쓰기 지연 SQL 저장소에 등록
  3. 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송(등록, 수정, 삭제 쿼리)

영속성 컨텍스트를 플러시 하는 방법

  1. em.flush() → 직접 호출
  2. 트랜잭션 커밋 → 플러시 자동 호출
  3. JPQL 쿼리 실행 → 플러시 자동 호출

참고 : 자바 ORM 표준 JPA 프로그래밍 - 기본편

 

 

 

[[ 생성 메서드 ]]
주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다.
서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다. 이처럼 엔티티가 비즈니스 로직을 가지고
객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴(http://martinfowler.com/eaaCatalog/ domainModel.html)이라 한다.
반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분 의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴(http://martinfowler.com/eaaCatalog/ transactionScript.html)이라 한다.

어떤 게 유지 보수하기 쉬울지 판단 후 선택,
한 프로젝트 안에서도 두 패턴 혼용 가능

 

 

주문 기능 테스트

테스트 요구사항

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

 

OrderServiceTest

매번 //given에서 Member와 Book을 초기화하기 귀찮으니까, Extract-> Method 이용합니다.

Ctrl Alt M

 

이전 버전

package jpabook.jpashop.service;

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {

    @Autowired EntityManager em;
    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    @Test
    public void 상품주문() throws Exception {
        //given
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울","강가", "132465"));
        em.persist(member);

        Book book = new Book();
        book.setName("기본JPA");
        book.setPrice(10000);
        book.setStockQuantity(10);
        em.persist(book);

        int orderCount = 2;

        //when
        Long orderId = orderService.order(member.getId(), book.getId(), orderCount);

        //then
        Order getOrder = orderRepository.findOne(orderId); //DB에서 가져옴

        assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus()); // Expected, Actual
        assertEquals("주문한 상품 종류 수가 정확해야 한다",1,getOrder.getOrderItems().size());
        assertEquals("주문 가격은 가격 * 수량이다",10000*orderCount,getOrder.getTotalPrice());
        assertEquals("주문 수량만큼 재고가 줄어야한다",8,book.getStockQuantity());
    }

    
}

 

메서드 추출

package jpabook.jpashop.service;

import javax.persistence.EntityManager;

import static org.junit.Assert.*;

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {

    @Autowired EntityManager em;
    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    @Test
    public void 상품주문() throws Exception {
        //given
        Member member = createMember(); //[메서드 추출함]
        Book book = createBook(); //[메서드 추출함]
        
        int orderCount = 2;

        //when
        Long orderId = orderService.order(member.getId(), book.getId(), orderCount);

        //then
        Order getOrder = orderRepository.findOne(orderId); //DB에서 가져옴

        assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus()); // Expected, Actual
        assertEquals("주문한 상품 종류 수가 정확해야 한다",1,getOrder.getOrderItems().size());
        assertEquals("주문 가격은 가격 * 수량이다",10000*orderCount,getOrder.getTotalPrice());
        assertEquals("주문 수량만큼 재고가 줄어야한다",8,book.getStockQuantity());
    }

    

    private Book createBook() {
        Book book = new Book();
        book.setName("기본JPA");
        book.setPrice(10000);
        book.setStockQuantity(10);
        em.persist(book);
        return book;
    }

    private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울","강가", "132465"));
        em.persist(member);
        return member;
    }


}

 

변수 뽑기 : Ctrl + Alt + V

orderRepository.findOne(orderId);
에서 Ctrl + Alt + V 하면

Order getOrder = orderRepository.findOne(orderId); 자동생성

 

파라미터로 꺼내기 : Ctrl + Alt + P

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

 

유사한 클래스 왔다 갔다 가능 : Ctrl + Shift + T

 

 

 

📢 JPA와 Hibernate 차이점
JPA와 Hibernate는 마치 자바의 interface와 해당 interface를 구현한 class와 같은 관계이다.
JPA는 인터페이스이다, 특정 기능을 하는 라이브러리가 아니다. 어떻게 관계형 데이터베이스를 사용해야 하는지를 정의한 단순한 명세이다.
Hibernate는 JPA의 구현체의 한 종류 이다.

JPA는 관계형 데이터베이스와 자바 객체를 매핑하기 위한 인터페이스(API)를 제공하고 JPA 구현체(Hibernate)는 인터페이스를 구현한 것이다.

 

 

주문 검색 기능 개발

💡 핵심 : JPA에서 JPQL 동적 쿼리를 어떻게 해결해야 하는가?

해결 : Querydsl

 

repository > OrderSearch

package jpabook.jpashop.repository;

@Getter
@Setter
public class OrderSearch {
    
    private String memberName;
    private OrderStatus orderStatus; //주문 상태[ORDER, CANCEL]
}

 

OrderRepository

package jpabook.jpashop.repository;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    ...
    
    // 주문 검색 기능 - JPQL
    public List<Order> findAll(OrderSearch orderSearch){
        return em.createQuery("select o from Order o join o.member m" +
                    "where o.status = :status" +
                    "and m.name like :name", Order.class)
                .setParameter("status", orderSearch.getOrderStatus())
                .setParameter("name", orderSearch.getMemberName())
                .setFirstResult(100) //페이징 스타트 포지션 - 100부터 가져옴
                .setMaxResults(1000) //결과 제한 - 최대 1000건 
                .getResultList();

    }


}

 Oder 조회하고 Member와 join, 타입

 

파라미터 바인딩

.setParameter

 

 

Querydsl

1. 동적 쿼리 해결

2. 복잡한 JPQL 해결

하므로 추후 Querydsl 공부!

 

package jpabook.jpashop.repository;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

   ...
   
    /**
     * 주문 검색 기능
     *  JPA Criteria로 처리했지만 Querydsl로 리팩토링 권장
     */
    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();
    }


}

 

 

웹 계층 개발(타임리프)

  • 홈 화면
  • 회원 기능
    • 회원 등록
    • 회원 조회
  • 상품 기능
    • 상품 등록
    • 상품 수정
      • [Study] 변경 감지와 병합(merge)
    • 상품 조회
  • 주문 기능
    • 상품 주문
    • 주문 내역 조회
    • 주문 취소

 

 

홈 화면과 레이아웃

HelloCotroller > 로그 찍기

롬복 annotation 사용 

package jpabook.jpashop.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@Slf4j
public class HelloController {

    @RequestMapping("/")
    public String home() {
        return "home";
    }
}

 

 

이전 HelloController와 충돌됨 - 삭제

package jpabook.jpashop;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HelloController {

    @GetMapping("hello") // hello url로 오면 HelloController 호출
    public String hello(Model model) { // model에 data를 실어서 controller를 통해 view로 넘김
        model.addAttribute("data", "hello!!"); // 키 값
        return "hello"; // return은 화면 이름, resources > templates > hello.html로 이동 - ${data}
    }
}

 

참고:
지금 우리는 무식한 방법인 include-style layouts을 사용한다. 왜? 매번 사용시마다 include 해야 함
Hierarchical-style layouts
예제에서는 뷰 템플릿을 최대한 간단하게 설명하려고, header , footer 같은 템플릿 파일을 반복해서 포함한다.
다음 링크의 Hierarchical-style layouts을 참고하면 이런 부분도 중복을 제거할 수 있다.  https://www.thymeleaf.org/doc/articles/layouts.html

예제에서는 단순하게 이해, 홈 화면이 중요한 게 아니므로 include-style layouts을 사용했다. 

 

 

view 리소스 등록

https://getbootstrap.com/

버전 5

 

다운로드

 

 

css, js를 인텔리j에 복사하기

 

 

 

css, js파일 전체 복사해서 static에 ctrl v

 

 

그 후 구동시켰는데 변화가 없다면,

 

1. 

 

2. Synchronize 'main/resources'

또는 Reload from Disk 후 재시작

 

3. 인프런 관련 질문에 대한 답변 내용 첨부

위에 많은분들이 말씀하셨듯이 현재 부트스트랩 최신버전은 5이기 때문에 점보트론이 적용되지 않는 문제가 있었는데

저처럼 정 신경쓰이시는 분들은 이걸로 쓰시면 될 것 같습니다

<!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">

<!-- jQuery library -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>

<!-- Popper JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script>

<!-- Latest compiled JavaScript -->
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>

 

header.html의 bootstrap CSS 주석 부분에 붙이시면 됩니다

뒷부분을 아직 안들어서 그런데 CSS만 필요하신거면 4가지중 맨 위에 부분만 이용하셔도 되지 않을까 싶습니다

적용

header.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="header">
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrinkto-fit=no">
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="/css/bootstrap.min.css" integrity="sha384-
ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
          crossorigin="anonymous">
    <!-- Custom styles for this template -->
    <link href="/css/jumbotron-narrow.css" rel="stylesheet">
    <title>Hello, world!</title>
    
    <!--부트스트랩 버전 문제로 점보트론이 적용되지 않는 문제 해결 (현 ver.5)-->
    <!-- Latest compiled and minified CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">

    <!-- jQuery library -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>

    <!-- Popper JS -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script>

    <!-- Latest compiled JavaScript -->
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
</head>

 

회원 등록/가입

클릭 시 ) http://localhost:8080/members/new로 가야 함

 

💡타임리프와 스프링은 integration통합이 잘 되어있음 

Controller > MemberForm

폼 객체를 사용해서 화면 계층과 서비스 계층을 명확하게 분리

package jpabook.jpashop.controller;

import lombok.Getter;
import lombok.Setter;

import javax.validation.constraints.NotEmpty;

@Getter
@Setter
public class MemberForm {//회원 등록 폼 객체 (폼 객체를 사용해서 화면 계층과 서비스 계층을 명확하게 분리)

    @NotEmpty(message = "회원 이름은 필수 입력사항입니다.")
    private String name;

    private String city;
    private String street;
    private String zipcode;

}

 

Controller > MemberController

package jpabook.jpashop.controller;

import jpabook.jpashop.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
@RequiredArgsConstructor
public class MemberController { //회원 등록 컨트롤러

    private final MemberService memberService; //Controller는 주로 Service 주입받음

    @GetMapping("/members/new")
    public String createForm(Model model) { //Controller에서 view로 갈때 model에 데이터를 실어서 넘김
        model.addAttribute("memberForm",new MemberForm()); //MemberForm() 빈 껍데기 객체 가지고 view로 넘어감
        return "members/createMemberForm";

    }
}

 

createMemberForm.html 관련 부분 정리

1.
<form role="form" action="/members/new" th:object="${memberForm}" method="post">

th:object="${memberForm} 의미
: form 안에서는 계속해서 memberForm객체를 쓰겠다.

submit시 /members/new에 post로 넘어감


2.
<input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력하세요"

th:field="*{name}"
	*의미 : th:object
: getter setter를 통한 property접근법을 통해 memberForm객체 접근


3.
<div class="form-group">
        <label th:for="city">도시</label>
        <input type="text" th:field="*{city}" class="form-control"
               placeholder="도시를 입력하세요">
</div>

th:field="*{city} <- memberForm객체 필드접근
	th:field를 쓰면 랜더링 될 때 -> <input type="text" id="city" name="city" class="form-control" placeholder="도시를 입력하세요">

 

MemberCotroller추가

Member가 아닌 MemberForm 쓴 이유는 작성 폼과 Member가 완벽히 fit하지 않으므로 새로 Form 만들어서 사용

@Valid

 @PostMapping("/members/new")
    public String create(@Valid MemberForm form) { //@Valid : 유효성 검증
        Address address = new Address(form.getCity(), form.getStreet(), form.getZipcode());

        Member member = new Member();
        member.setName(form.getName());
        member.setAddress(address);

        memberService.join(member);
        return "redirect:/"; //첫 페이지로 리다이렉트

    }

 

쿼리 나간 것

insert into member (city, street, zipcode, name, member_id) values (?, ?, ?, ?, ?)
insert into member (city, street, zipcode, name, member_id) values ('서울', '강가', '131321', '영한', 1);

 

DB

 

실패 케이스 통해 Validation 잘 작동했는지 확인(이름 빈칸으로 submit)

 

 

BindingResult

 public String create(@Valid MemberForm form, BindingResult result) {
 	...
 }
 
  MemberForm에서 오류가 있을 때 원래는 튕기는 데(밑 코드 실행 안됨)
  BindingResult 사용하면 여기에 오류가 담기고 밑에 나머지 코드가 실행 됨

 

적용

@PostMapping("/members/new")
    public String create(@Valid MemberForm form, BindingResult result) {

       //BindingResult사용 시 오류가 담겨서 나머지 코드가 실행 됨, createMemberForm에 오류메세지 넘어감
        if(result.hasErrors()) {
            return "members/createMemberForm";
        }

        Address address = new Address(form.getCity(), form.getStreet(), form.getZipcode());

        Member member = new Member();
        member.setName(form.getName());
        member.setAddress(address);

        memberService.join(member);
        return "redirect:/"; //첫 페이지로 리다이렉트

    }

 

화면

오류 메시지 넘어감

createMember.html 해당 부분

 <form role="form" action="/members/new" th:object ="${memberForm}" method="post">
        <div class="form-group">
            <label th:for="name">이름</label>

            <input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력하세요"
            		<!-- 이 부분 -->
                    <!-- 에러가 있으면 css 빨간색 적용 -->
                   th:class="${#fields.hasErrors('name')}? 'form-control fieldError' : 'form-control'">
            <!-- 에러 메세지 출력 : 에러가 있으면, name에 해당하는 오류내용 보내기 -->
            <p th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Incorrect date</p>

        </div>

 

+ 타임리프 공부

https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html

 

 

회원 목록 조회

MemberList.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader" />
    <div>
        <table class="table table-striped">
            <thead>
            <tr>
                <th>#</th>
                <th>이름</th>
                <th>도시</th>
                <th>주소</th>
                <th>우편번호</th>
            </tr>
            </thead>
            <tbody>
            <tr th:each="member : ${members}">
                <td th:text="${member.id}"></td>
                <td th:text="${member.name}"></td>
                <td th:text="${member.address?.city}"></td>
                <td th:text="${member.address?.street}"></td>
                <td th:text="${member.address?.zipcode}"></td>
            </tr>
            </tbody>
        </table>
    </div>
    <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>

참고: 타임리프에서 ?를 사용하면 null을 무시한다.

 

 

 

 

참고: 폼 객체 vs 엔티티 직접 사용 둘 중 어떤 것을 사용해야 할까?
요구사항이 정말 단순할 때는 폼 객체( MemberForm) 없이 엔티티( Member)를 직접 등록과 수정 화면에서 사용해도 된다. 하지만 화면 요구사항이 복잡해지기 시작하면, 엔티티에 화면을 처리하기 위한 기능이 점점 증가한다.
결과적으로 엔티티는 점점 화면에 종속적으로 변하고, 이렇게 화면 기능 때문에 지저분해진 엔티티는 결국 유지 보수하기 어려워진다.
실무에서 엔티티는 핵심 비즈니스 로직만 가지고 있고, 화면을 위한 로직은 없어야 한다.
화면이나 API에 맞는 폼 객체나 DTO를 사용하자. 그래서 화면이나 API 요구사항을 이것들로 처리하고,
엔티티는 최대한 순수하게 유지하자.

그리고 API를 만들 때 절대 엔티티를 외부로 반환하면 안 된다.

 

 

MemberController

package jpabook.jpashop.controller;

@Controller
@RequiredArgsConstructor
public class MemberController { //회원 등록 컨트롤러

    private final MemberService memberService; //Controller는 주로 Service 주입받음

    @GetMapping("/members/new")
    public String createForm(Model model) { //Controller에서 view로 갈때 model에 데이터를 실어서 넘김
        model.addAttribute("memberForm",new MemberForm()); //MemberForm() 빈 껍데기 객체 가지고 view로 넘어감 -> 화면에서 MemberForm()객체 접근 가능해짐
        return "members/createMemberForm";
    }

    @PostMapping("/members/new")
    public String create(@Valid MemberForm form, BindingResult result) { 

        //BindingResult사용 시 오류가 담겨서 나머지 코드가 실행 됨, createMemberForm에 오류메세지 넘어감
        if(result.hasErrors()) {
            return "members/createMemberForm";
        }

        Address address = new Address(form.getCity(), form.getStreet(), form.getZipcode());

        Member member = new Member();
        member.setName(form.getName());
        member.setAddress(address);

        memberService.join(member);
        return "redirect:/"; //첫 페이지로 리다이렉트
    }

    @GetMapping("/members")
    public String list(Model model) {
        //화면에 뿌리는 게 Member엔티티의 필드와 완전 일치했기에 반환했지만, 실무에서는 엔티티가 아닌 DTO 반환 권장
        //단, API만들 때 절대 엔티티를 외부로 반환하면 안된다.
        List<Member> members = memberService.findMembers();
        model.addAttribute("members", members);
        return "members/memberList";
    }
}

 

 

상품 등록

BookForm

package jpabook.jpashop.controller;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class BookForm { //엔티티는 핵심 비즈니스 로직만 가지고 있고, 화면을 위한 로직은 없어야 한다. 화면에 맞는 Form생성
    // Item 공통 속성
    private Long id; //수정을 위해 id필요

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

    // Book 속성
    private String author;
    private String isbn;
}

 

쿼리 로그

Hibernate: insert into item (name, price, stock_quantity, author, isbn, dtype, item_id) values (?, ?, ?, ?, ?, 'B', ?)
2022-05-19 22:06:53.444  INFO 12720 --- [nio-8080-exec-1] p6spy                                    : #1652965613444 | took 5ms | statement | connection 3| url jdbc:mysql://localhost:3306/jpabook?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
insert into item (name, price, stock_quantity, author, isbn, dtype, item_id) values (?, ?, ?, ?, ?, 'B', ?)
insert into item (name, price, stock_quantity, author, isbn, dtype, item_id) values ('abc123', 10000, 10, '영한킴', '1321345', 'B', 1);

 

DB - 싱글 테이블 전략이므로 나머지는 null로 들어갑니다. 

 

 

상품 목록 조회

💡 [중요] 상품 수정

JPA에서 '수정'은 변경 감지 또는 병합(merge) 두 방법 중 변경 감지를 쓰는 게 Best Practice입니다.

 

itemList.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <div>
        <table class="table table-striped">
            <thead>
            <tr>
                <th>#</th>
                <th>상품명</th>
                <th>가격</th>
                <th>재고수량</th>
                <th></th>
            </tr>
            </thead>
            <tbody>
            <tr th:each="item : ${items}">
                <td th:text="${item.id}"></td>
                <td th:text="${item.name}"></td>
                <td th:text="${item.price}"></td>
                <td th:text="${item.stockQuantity}"></td>
                <td>
                    <a href="#" th:href="@{/items/{id}/edit (id=${item.id})}"
                       class="btn btn-primary" role="button">수정</a>
                </td>
            </tr>
            </tbody>
        </table>
    </div>
    <div th:replace="fragments/footer :: footer"/>
</div> <!-- /container -->
</body>
</html>

 

 

개념 정리
Spring에서 @RequestParam과 @PathVariable차이

Controller 단에서 위 두 어노테이션은 uri를 통해 전달된 값을 파라미터로 받아오는 역할을 합니다.
쓰임과 차이점에 중점을 두면서 정리해보겠습니다.

 
http://localhost:8000/board?page=1&listSize=20 ← @RequestParam방식
http://localhost:8000/board/1 ← @PathVariable방식

@RequestParam

예시 코드

@GetMapping({"board", "board?page={page}&listSize={listSize}"})
	public String getBoardList(Model model
			, @RequestParam(value = "page", required = false, defaultValue = "1") int page
			, @RequestParam(value = "listSize", defaultValue = "10") int listSize
			) throws Exception {
		.
		.
		.
		return "board/boardList";
	}

 

게시글 리스트를 받아오는 api입니다. get 요청을 받으면 쿼리 스트링을 통해 전달된 page 값과 listSize 값을 받아와서 @RequestParam이 파라미터인 int page와 int listSize에 각각 대입해줍니다.

 

괄호 안의 속성 값은 각각 이렇습니다.

  • value = uri에서 바인딩하게 될 값
  • required = true 일 시 , 필수적으로 값이 전달되어야 하며 없으면 에러
  • defaultValue = 값이 없을 때 기본값으로 사용할 값

 

@PathVariable

예시 코드

@ResponseBody
	@PostMapping("/board/{id}")
	public ResponseDto<Integer> updateBoard(@PathVariable("id") int id, ) throws Exception {
    	Board board = new Board();
		board.setId(id);
		
	}

@PathVariable은 어떤 요청이든 간에 딱 하나만 쓸 수 있습니다. 주로 post 요청에 많이 씁니다.

 

 

정리

@RequestParam과 @PathVariable은 둘 다 데이터를 받아오는 데에 사용하고

@PathVariable은 값을 하나만 받아올 수 있으므로, 쿼리 스트링 등을 이용한 여러 개 데이터를 받아올 때는 @RequestParam을 사용합니다.

 

 

출처 : https://velog.io/@dongscholes/JavaSpringBoot-RequestParam-vs-PathVariable-%EC%93%B0%EC%9E%84%EC%83%88-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%B0%A8%EC%9D%B4%EC%A0%90

 

 

다시 돌아와서, 

updateItemForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <form th:object="${form}" method="post">
        <!-- id -->
        <input type="hidden" th:field="*{id}" />
        <div class="form-group">
            <label th:for="name">상품명</label>
            <input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력하세요" />
        </div>
        <div class="form-group">
            <label th:for="price">가격</label>
            <input type="number" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요" />
        </div>
        <div class="form-group">
            <label th:for="stockQuantity">수량</label>
            <input type="number" th:field="*{stockQuantity}" class="form-control" placeholder="수량을 입력하세요" />
        </div>
        <div class="form-group">
            <label th:for="author">저자</label>
            <input type="text" th:field="*{author}" class="form-control" placeholder="저자를 입력하세요" />
        </div>
        <div class="form-group">
            <label th:for="isbn">ISBN</label>
            <input type="text" th:field="*{isbn}" class="form-control" placeholder="ISBN을 입력하세요" />
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
    <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>

 

이전과 다른 부분

<form th:object="${form}" method="post">
  action이 없음
action을 생략하면 현재 URL을 그대로 사용합니다. 대신에! 전송방식이 GET에서 POST로 변경되었습니다!
이게 중요합니다. 같은 URL인데, 수정 화면을 노출할 때는 GET을 사용하고, 수정 화면의 데이터를 실제 변경할 때는 POST를 사용했다는 것입니다. 이런 방식이 좋은 URL 설계 방식입니다.

 

ItemController

package jpabook.jpashop.controller;


@Controller
@RequiredArgsConstructor
public class ItemController {

   ...
   
    /**
     * 상품 수정
     *   @GetMapping("item/{itemId}/edit") 과 @PathVariable("itemId")의 itemId 매핑(같음)
     *   Controller의 updateItemForm() -> updateItemForm.html 수정 후  Sumbit 클릭 -> updateItem()
     */

    // 상품 수정
    @GetMapping("/items/{itemId}/edit")
    public String updateItemForm(@PathVariable("itemId") Long itemId, Model model) {
        Book item = (Book) itemService.findOne(itemId); //Book만 받을 것이므로 Item이 아닌 Book타입 사용, 캐스팅

        BookForm form = new BookForm();
        form.setId(item.getId());
        form.setName(item.getName());
        form.setPrice(item.getPrice());
        form.setStockQuantity(item.getStockQuantity());
        form.setAuthor(item.getAuthor());
        form.setIsbn(item.getIsbn());

        model.addAttribute("form", form);
        return "items/updateItemForm";
    }

    /**
     * [문제점]
     * Id 조작해서 넘길 수 있음
     *  실무에서는 유저가 item에 권한이 있는지 확인 하는 로직 추가
     */
    // 상품 수정
    @PostMapping("/items/{itemId}/edit")
    public String updateItem(@ModelAttribute("form") BookForm form) {
        Book book = new Book();

        book.setId(form.getId());
        book.setName(form.getName());
        book.setPrice(form.getPrice());
        book.setStockQuantity(form.getStockQuantity());
        book.setAuthor(form.getAuthor());
        book.setIsbn(form.getIsbn());

        itemService.saveItem(book);
        return "redirect:/items";
    }

}

 

상품 수정 폼 이동

1. 수정 버튼을 선택하면 /items/{itemId}/edit URL을 GET 방식으로 요청

2. 그 결과로 updateItemForm() 메서드를 실행하는데 이 메서드는 itemService.findOne(itemId)를 호출해서 수정할 상품을 조회

3. 조회 결과를 모델 객체에 담아서 뷰( items/updateItemForm)에 전달

 

상품 수정 실행

상품 수정 폼 HTML에는 상품의 id(hidden), 상품명, 가격, 수량 정보 있음

1. 상품 수정 폼에서 정보를 수정하고 Submit 버튼을 선택

2. /items/{itemId}/edit URL을 POST 방식으로 요청하고 updateItem() 메서드를 실행

3. 이때 컨트롤러에 파라미터로 넘어온 item 엔티티 인스턴스는 현재 준영속 상태다. 따라서 영속성 컨텍스트의 지원을 받을 수 없고 데이터를 수정해도 변경 감지 기능은 동작 X

 

 

 

여기서 주목할 흐름!

ItemController

 @PostMapping("/items/{itemId}/edit")
    public String updateItem(@ModelAttribute("form") BookForm form) {
        Book book = new Book();

        book.setId(form.getId());
        book.setName(form.getName());
        book.setPrice(form.getPrice());
        book.setStockQuantity(form.getStockQuantity());
        book.setAuthor(form.getAuthor());
        book.setIsbn(form.getIsbn());

        itemService.saveItem(book);
        return "redirect:/items";
    }

itemService.saveItem(book);

 

ItemService

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

itemRepository.save(item);

 

ItemRepository

지금 아이템 아이디 있으므로, em.merge 호출됨

 public void save(Item item) {
        if(item.getId() == null) { //새로 생성한 객체
            em.persist(item); //신규 등록
        }else {
            em.merge(item); //이미 JPA통해 DB들어간것, 업데이트와 비슷
        }
    }

 

 

 

merge가 뭘까..?

근데 실무에서는 쓸 일이 거의 없다. 

 

 

💡 [중요] 변경 감지와 병합(merge)

 

준영속 엔티티?

영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말한다. (여기서는 itemService.saveItem(book)에서 수정을 시도하는 Book 객체다. Book 객체는 이미 DB에 한번 저장되어서 식별자가 존재한다. 이렇게 임의로 만들어낸 엔티티도 기존 식별자를 가지고 있으면 준 영속 엔티티로 볼 수 있다.)

 

 

근데 왜 Book객체가 준영속 엔티티일까?

준영속 엔티티는 영속 상태로 관리되다가 이후 관리되지 않는 상태를 말합니다.
예를 들어서 엔티티 객체는 살아있는데, 영속성 컨텍스트가 종료되면서, 영속성 컨텍스트가 더는 이 엔티티 객체를 관리하지 못하는 상태인 것입니다.

이 경우 보통 엔티티 객체는 데이터베이스에 이미 저장이 되어 있습니다. 다만 객체가 영속성 컨텍스트의 관리를 전혀 받지 못하는 상태입니다.

영속 상태가 되려면 식별자가 꼭 필요하기 때문에 모든 준영속 상태의 객체는 식별자를 가지고 있습니다.
여기서 Book을 수정하는 프로세스의 경우 Book은 이미 한번 영속성 컨텍스트에 저장이 되었고, 해당 데이터를 수정하기 위해 Form에 전달하고 다시 받았습니다. Book은 이미 영속 상태로 관리되고, 데이터베이스에 저장되었던 객체를 개발자가 form에 보내고, 그 값을 다시 받아서 직접 new로 Book 객체를 만들어내는데요. 그렇다고 할지라도, 이미 영속성 컨텍스트에 관리되던 객체를 그대로 만들어낸 것이기 때문에 준영속이라 할 수 있습니다.

결국 준영속의 기준은 과거에 해당 객체가 영속 상태로 데이터베이스에 저장되고 관리된 적이 있는가를 기준으로 삼으시면 됩니다.
개발자가 new Book()으로 직접 임의의 식별자를 지정하게 되는 것은, 단순히 new 상태(새로 생성한 객체)로 보면 됩니다.

핵심은 식별자를 기준으로 영속 상태가 되어서 DB에 저장된 적이 있는가로 보시면 됩니다.
그래서 식별자를 기준으로 이미 한번 영속상태가 되어버린 엔티티가 있는데, 더 이상 영속성 컨텍스트가 관리하지 않으면 모두 준영속 상태입니다.

그게 em.detach()를 해서 직접적으로 준영속 상태가 될 수도 있고,
지금처럼 수정을 위해 html form에 데이터를 노출한 이후에 다시 new로 재조립된 엔티티일 수 도 있습니다.

 

코드로 이해해보자면

@PostMapping("/items/{itemId}/edit")
    public String updateItem(@ModelAttribute("form") BookForm form) {
        Book book = new Book();

        book.setId(form.getId()); // 아이디 세팅되어있다. == DB에 갔다와서 식별자 있는 경우== 준영속 상태의 객체
        book.setName(form.getName());
        book.setPrice(form.getPrice());
        book.setStockQuantity(form.getStockQuantity());
        book.setAuthor(form.getAuthor());
        book.setIsbn(form.getIsbn());

        itemService.saveItem(book);
        return "redirect:/items";
    }

 

 

준영속 엔티티의 문제는 JPA가 관리를 하지 않습니다. 

  JPA가 관리 시 변경 감지가 일어납니다.(즉 영속 상태의 경우 )

 

그러므로 값을 바꾸어도 DB에 업데이트가 일어나지 않습니다. 

 

그럼 준영속 상태의 엔티티의 경우 수정하는 방법 2가지

준영속 엔티티를 수정하는 2가지 방법
 변경 감지 기능 사용
 병합( merge ) 사용

 

1. 변경 감지 기능 사용

ItemService

    @Transactional
    public void updateItem(Long itemId, Book param) {
        Item findItem = itemRepository.findOne(itemId); //실질 DB에서 영속상태의 엔티티 찾아옴
        findItem.setPrice(param.getPrice());
        findItem.setName(param.getName());
        findItem.setStockQuantity(param.getStockQuantity());
        // itemRepository.save(findItem); 호출할 필요가 없다, 트랜잭션 커밋되면 변경감지 일어남

    }

 

2. 병합(merge) 사용

병합은 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능이다.

  처음에 짠 코드입니다. 

 

 

병합 동작 방식

1. merge()를 실행한다.

2. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다.

   2-1. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다.

3. 조회한 영속 엔티티(mergeMember)에 member 엔티티의 값을 채워 넣는다. (member 엔티티의 모든 값을 mergeMember에 밀어 넣는다. 이때 mergeMember의 “회원 1”이라는 이름이 “회원명 변경”으로 바뀐다.)

4. 영속 상태인 mergeMember를 반환한다.

 

병합 시 동작 방식을 간단히 정리

1. 준영속 엔티티의 식별자 값으로 영속 엔티티를 조회한다.

2. 영속 엔티티의 값을 준영속 엔티티의 값으로 모두 교체한다.(병합한다.)

3. 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 데이터베이스에 UPDATE SQL이 실행

 

 

주의:
변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만,
병합을 사용하면 모든 속성이 변경된다. 병합시 값이 없으면 null로 업데이트할 위험도 있다.
(병합은 모든 필드를 교체한다.) - 그래서 실무에서 쓰기 위험하다.

 

 

그래서

엔티티를 변경할 때는 항상 변경 감지를 사용해야 합니다.

  • 컨트롤러에서 어설프게 엔티티를 생성하지 마세요.*
  • 트랜잭션이 있는 서비스 계층에 식별자( id )와 변경할 데이터를 명확하게 전달하세요.(파라미터 or dto)
  • 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하세요.
  • 트랜잭션 커밋 시점에 변경 감지가 실행됩니다. → update쿼리 디비에 날아감

 

ItemService

//준영속 엔티티 수정하는 방법 : 변경 감지 기능 사용
    @Transactional
    public void updateItem(Long itemId, String name, int price, int stockQuantity) {
        Item findItem = itemRepository.findOne(itemId); //실질 DB에서 영속상태의 엔티티 찾아옴
        findItem.setName(name);
        findItem.setPrice(price);
        findItem.setStockQuantity(stockQuantity);
        // itemRepository.save(findItem); 호출할 필요가 없다, 트랜잭션 커밋되면 변경감지 일어남

    }

 

ItemController

 @PostMapping("/items/{itemId}/edit")
    public String updateItem(@PathVariable("itemId") Long itemId, @ModelAttribute("form") BookForm form) {
        Book book = new Book();

        //Book을 어설프게 만들어서 넘긴 코드
//        book.setId(form.getId());
//        book.setName(form.getName());
//        book.setPrice(form.getPrice());
//        book.setStockQuantity(form.getStockQuantity());
//        book.setAuthor(form.getAuthor());
//        book.setIsbn(form.getIsbn());

        itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity());
        return "redirect:/items";
    }

만약 수정할 파라미터가 많으면, DTO를 생성해서 파라미터로 넘기는 방법도 있습니다.

 

설계 시 항상 유지보수성 생각하기

 

 

상품 주문

OrderController

package jpabook.jpashop.controller;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.service.ItemService;
import jpabook.jpashop.service.MemberService;
import jpabook.jpashop.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@Controller
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;
    private final MemberService memberService;
    private final ItemService itemService;

    /**
     *상품 주문 폼
     */
    @GetMapping("/order")
    public String createForm(Model model) {
        List<Member> members = memberService.findMembers();
        List<Item> items = itemService.findItems();

        model.addAttribute("members", members);
        model.addAttribute("items", items);

        return "order/orderForm";
    }

    /**
     * 상품 주문
     *   form submit 방식으로 @RequestParam(..)에 넘어와서 변수와 바인딩
     */
    @PostMapping("/order")
    public String order(@RequestParam("memberId") Long memberId,
                        @RequestParam("itemId") Long itemId,
                        @RequestParam("count") int count) {
        orderService.order(memberId, itemId, count);
        return "redirect:/orders";
    }


}

 

OrderService

@Transactional
    public Long order(Long memberId, Long itemId, int count) {
        //엔티티 조회
        Member member = memberRepository.findOne(memberId);
        Item item = itemRepository.findOne(itemId);

        //배송정보 생성(회원정보 Address)
        Delivery delivery = new Delivery();
        delivery.setAddress(member.getAddress());

        //주문상품 생성 (생성메서드 이용) - static으로 생성했으므로 객체 생성없이 클래스.메서드명으로 가져옴(위 코드와 차이점)
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

        //주문 생성 (생성메서드 이용)
        Order order = Order.createOrder(member, delivery, orderItem);

        //주문 저장 - order 식별자값 반환
        orderRepository.save(order);
        return order.getId();
    }

 

💡 물론 Controller단에서 엔티티를 찾아서 Service단에 넘기는 것도 가능하지만,

Controller에서는 식별자만 넘겨주고

// Controller
orderService.order(memberId, itemId, count);

핵심 비즈니스 로직을 서비스 안에서 할 때 영속 상태에서 (조회 등을) 할 수 있어서  == JPA가 관리

  그 안에서 값을 바꾸어도(여기서는 멤버나 아이템) 더티 체킹 가능

// Service
//엔티티 조회
Member member = memberRepository.findOne(memberId);
Item item = itemRepository.findOne(itemId);

 

 

주문 목록 검색, 취소

 

주문 목록 검색

OrderService 선 작업

/**
 * 검색
*/
 public List<Order> findOrders(OrderSearch orderSearch) {
       return orderRepository.findAllByString(orderSearch);
  }

 

@ModelAttribute
multipart/form-data 형태의 HTTP Body 내용과 HTTP 파라미터들을 생성자나 Setter를 통해1대 1로 객체에 바인딩시킨다.
만약 값을 주입해주는 생성자나 Setter함수가 없다면 매핑을 시키지 못하고, null을 갖게 된다.

 

주문 취소

OrderList.html

 <!--주문상태가 ORDER면 CANCEL버튼 노출-->
        <td>
            <a th:if="${item.status.name() == 'ORDER'}" href="#"
               th:href="'javascript:cancel('+${item.id}+')'"
               class="btn btn-danger">CANCEL</a>
        </td>
        
        
 <script>
     function cancel(id) {
     // 폼 생성
     var form = document.createElement("form");
     form.setAttribute("method", "post");
     // 호출
     form.setAttribute("action", "/orders/" + id + "/cancel");
     document.body.appendChild(form);
     form.submit();
     }
</script>

 

OrderController

/**
 * 주문 취소
 */
@PostMapping("/orders/{orderId}/cancel")
public String cancelOrder(@PathVariable("orderId") Long orderId) {
    orderService.cancelOrder(orderId);
    return "redirect:/orders";
}

 

 

빨간 박스 누르면

여기까지 주문 도메인과 웹 계층 개발을 하였고

타임리프로 화면 랜더링까지 학습하였습니다.

 

 

요즘  API통신을 주로 하므로 다음 포스팅은 API 개발을 정리해보겠습니다.

  CRUD

  성능 최적화