seungriyou / spring-study

자바 스프링 부트를 배워봅시다 🔥
0 stars 0 forks source link

[강의 정리] 04. 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 #7

Open seungriyou opened 9 months ago

seungriyou commented 9 months ago

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

Contents


H2 URL

처음 파일을 생성할 때는 tcp://localhost/을 제외하고 파일 모드로 접속하고, 그 후에는 tcp://localhost/를 추가하여 네트워크 모드로 접속한다.

jdbc:h2:~/workspace/spring-study/spring-study/04-jpa_1_app/jpashop/jpashop


하지만, 테스트 코드만 돌릴 것이라면 메모리 DB를 사용할 수 있으므로 H2를 켜지 않아도 된다.


Note

도메인 분석 설계

코드를 다시 보면서 엔티티 설계 시 주의할 점을 체화하자!

https://github.com/seungriyou/spring-study/tree/21465dfa71b444bb478d9af72cfa27eb34114005/04-jpa_1_app/jpashop/src/main/java/jpabook/jpashop/domain

seungriyou commented 9 months ago

1. 프로젝트 환경설정

h2 데이터베이스

실행하기

h2 데이터베이스 설치 완료 가정

  1. h2.sh 가 위치한 곳으로 이동하고 실행한다.
```bash
cd workspace/spring-study/2024/h2/bin
./h2.sh
```
  1. 열린 브라우저에서 url의 IP 주소 부분을 localhost로 변경한다.

  2. JDBC URL을 db 파일 생성을 원하는 디렉토리 경로로 설정한다. (파일 모드)

    맨 마지막 부분은 db 파일의 이름이어야 함에 주의한다!

    jdbc:h2:~/workspace/spring-study/spring-study/04-jpa_1_app/jpashop/jpashop
  3. 좌측 상단에 연결 끊기 버튼을 누른후, JDBC URL에 tcp://localhost/를 삽입해준다. (네트워크 모드)

    jdbc:h2:tcp://localhost/~/workspace/spring-study/spring-study/04-jpa_1_app/jpashop/jpashop


JPA, DB 설정

application.properties 파일을 삭제하고 application.yml 파일을 생성한다.

  • spring.jpa.hibernate.ddl-auto: create: 애플리케이션 시점에 테이블을 drop 하고 다시 생성한다.
  • 모든 로그 출력은 가급적 logger를 통해 남겨야 한다.
옵션 SQL 출력 위치
show_sql 하이버네이트 실행 SQL을 System.out에 남긴다. (권장 X)
org.hibernate.SQL 하이버네이트 실행 SQL을 logger를 통해 남긴다.


테스트 코드 작성

ref

- https://twer.tistory.com/entry/JUnit5-RunWith - https://kangyb.tistory.com/37 - https://github.com/seungriyou/spring-study/issues/4#issuecomment-1877308690

JUnit5 + Spring Boot 3.X 기준으로, 강의에서 나오는 @RunWith(SpringRunner.class)를 사용할 수 없다.

찾아보니 JUnit5에서부터는 @ExtendWIth(SpringExtension.class)를 사용해야 하는데, 이 버전부터는 통합 테스트 시 사용했던 @SpringBootTest만 사용해도 해당 기능이 모두 포함되어 있기 때문에 사용하지 않아도 된다고 한다.

@SpringBootTest
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    @Transactional
    public void testMember() throws Exception { ... }
}


트랜잭션


[!Caution] 같은 영속성 컨텍스트 안에서는 id 값이 같으면 같은 엔티티로 식별된다. 1차 캐시에서 이미 영속성 컨텍스트에 해당 엔티티가 관리되고 있기 때문이다.

findMember = jpabook.jpashop.Member@193c810
member = jpabook.jpashop.Member@193c810
(findMember == member) = true


jar 빌드

  1. 다음의 명령어로 빌드한다.

    ./gradlew clean build
  2. build/libs 디렉토리로 이동한다.

    cd build/libs
  3. jar 파일을 실행한다.

    java -jar jpashop-0.0.1-SNAPSHOT.jar


쿼리 파라미터 로그 남기기

현재는 org.hibernate.SQL로 출력된 로그에 다음과 같이 쿼리 파라미터가 나타나지 않는다.

insert 
into
    member
    (username, id) 
values
    (?, ?)


우선, application.yml 파일에 다음과 같이 org.hibernate.orm.jdbc.bind: trace를 추가한다.

logging:
  level:
    org.hibernate.SQL: debug  # logger로 sql 찍기
    org.hibernate.orm.jdbc.bind: trace  # 스프링 부트 3.x, hibernate6

그렇다면 다음과 같이 binding 결과가 출력된다.

2024-01-14T19:22:46.856+09:00 TRACE 29683 --- [    Test worker] org.hibernate.orm.jdbc.bind              : binding parameter (1:VARCHAR) <- [memberA]
2024-01-14T19:22:46.857+09:00 TRACE 29683 --- [    Test worker] org.hibernate.orm.jdbc.bind              : binding parameter (2:BIGINT) <- [1]


그리고, 스프링 부트를 사용한다면 다음의 라이브러리를 추가하면 더 직관적으로 확인할 수 있다.

외부 라이브러리: https://github.com/gavlyukovskiy/spring-boot-data-source-decorator

→ 여기에서 설치 방법을 참고한다. (스프링 부트 3.0 이상에서는 1.9.0 이상을 사용해야 한다.)

implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1'

해당 라이브러리에서는 버전 정보를 명시하는 이유는, 외부 라이브러리 버전에 대해서는 스프링 부트에서 미리 셋팅을 해두지 않았기 때문이다.

2024-01-14T19:28:06.250+09:00  INFO 29849 --- [    Test worker] p6spy                                    : #1705228086250 | took 1ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/workspace/spring-study/spring-study/04-jpa_1_app/jpashop/jpashop
insert into member (username,id) values (?,?)
insert into member (username,id) values ('memberA',1);


[!Caution] 쿼리 파라미터를 로그로 남기는 외부 라이브러리는 시스템 자원을 사용하기 때문에, 개발 단계에서는 사용해도 되지만 운영 시스템에 적용하려면 꼭 성능 테스트를 하고 사용해야 한다.

seungriyou commented 9 months ago

2. 도메인 분석 설계

도메인 모델과 테이블 설계


엔티티 분석

사실 실무에서는 잘 넣지 않는 요소도 포함되어 있다.

  • 다대다 관계 (Category)
  • 양방향 관계 (Member, Order) : 실제 세계와 객체 세계는 다르다. 실무에서는 회원이 주문을 참조하지 않고, 주문이 회원을 참조하는 것으로 충분하기 때문에 양방향일 필요가 없다.


테이블 분석


[!note] 실습 코드에서는 DB에 소문자 + _ 스타일을 사용하겠다!


연관관계 매핑 분석


[!tip] 외래키가 있는 곳을 연관관계의 주인으로 정하자! 비즈니스상 우위에 있다고 주인으로 정하면 안 된다.

이렇게 해야 해당 테이블을 변경할 수 있어 더 편리하다.

테이블은 FK에서만 변경하면 되는데, 객체에서는 양쪽에서 모두 가능하므로, 어느 곳을 믿고 update를 쳐야하는지?

→ JPA에서는 연관관계의 주인 쪽을 믿고 update를 쳐야한다고 정해두었다.


엔티티 클래스 개발

[!tip] 여기에서는 쉽게 설명하기 위해 엔티티 클래스의 getter와 setter를 모두 열어두고 단순하게 설계한다. 하지만 실무에서는 가급적 getter는 열어두고 setter는 꼭 필요한 경우에만 사용해야 한다.

메모***


[!note] FK를 꼭 걸어야하는가? (JPA은 alter SQL을 통해 FK를 걸어줌)

  • 실시간 트래픽이 중요하고, 정합성보다는 유연하게 잘 서비스되는 것이 중요하다면 인덱스만 잘 잡아도 OK
  • 너무 중요하고 데이터가 항상 맞아야 한다면 FK 걸어주는 것이 GOOD


엔티티 설계 시 주의점*** 📌

  1. 엔티티에는 가급적 setter를 사용하지 말자.

  2. 모든 연관관계는 지연로딩으로 설정해야 한다.

    • 즉시로딩은 예측이 어렵고, 어떤 쿼리가 실행될지 추적하기 어렵다.
    • JPQL을 실행할 때는 SQL로 그대로 변역되기 때문에 N+1 문제가 자주 발생한다.
    • 연관된 엔티티를 함께 조회해야 한다면, fetch join 또는 엔티티 그래프 기능을 사용한다.
    • **@XToOne 관계**는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다.
  3. 컬렉션은 필드에서 초기화하자.

    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems = new ArrayList<>();
    • null 문제에서 안전하다.
    • 하이버네이트는 엔티티를 영속화(em.persist())할 때, 컬렉션을 감싸서 하이버네이트 제공 내장 컬렉션으로 변경한다. 만약 임의의 메서드에서 컬렉션을 잘못 생성하면 하이버네이트 내부 매커니즘에 문제가 발생할 수 있다.
  4. 테이블, 컬럼명 생성 전략

    • 스프링 부트에서는 하이버네이트 기본 매핑 전략(= 엔티티 필드명을 그대로 테이블의 컬럼명으로 사용)을 변경해서 실제 테이블 필드명은 달라지게 된다. 엔티티 (필드) 테이블 (컬럼)
      카멜 케이스 언더스코어
      . _
      대문자 소문자
    • 적용 2단계 (논리명 생성, 물리명 적용)

  5. CASCADE

    • ALL: persist를 전파한다.
    • OrderorderItem, delivery에 cascade를 적용한다.

      Order를 영속화할 때, orderItemdelivery가 모두 영속화된다.

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
    
    @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;
    // order만 persist 하면 된다.
    
    // em.persist(orderItemA);
    // em.persist(orderItemB);
    // em.persist(orderItemC);
    em.persist(order);
  6. 양방향 관계일 때는 원자적으로 양쪽에서 값을 저장해주는 것이 좋다. 이때, 연관관계 편의 메서드를 생성한다.

    🔉 양방향 관계일 때, 연관관계 편의 메서드핵심적으로 컨트롤 하는 쪽에 위치하는 것이 좋다.

    • Order

      // === 연관관계 편의 메서드 (양방향) ===
      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);
      }
    • Category

      // === 연관관계 편의 메서드 (양방향) ===
      public void addChildCategory(Category child) {
          this.child.add(child);
          child.setParent(this);
      }



[!caution] 이번 강의에서 했던 내용은 다음의 코드에서 확인할 수 있다!

중요한 내용 잊지 않도록 복기하자!!

https://github.com/seungriyou/spring-study/tree/21465dfa71b444bb478d9af72cfa27eb34114005/04-jpa_1_app/jpashop/src/main/java/jpabook/jpashop/domain

seungriyou commented 9 months ago

4. 회원 도메인 개발

회원 리포지토리

EntityManager

스프링이 EntityManager를 만들어서 주입해주므로, @PersistenceContext로 필드 주입 받는다.

@PersistenceContext
private EntityManager em;   // 스프링이 EntityManager를 만들어서 주입해 줌


혹은 다른 컴포넌트와 동일하게 의존관계 주입을 받아도 된다. (ex. final & @RequiredArgsConstructor) 이는 스프링 부트(스프링 데이터 JPA?)에서 지원해주는 기능이다. (✅)

@Repository // 스프링 빈 등록 (컴포넌트 스캔)
@RequiredArgsConstructor
public class MemberRepository {

    private final EntityManager em; // 다른 컴포넌트와 동일하게 생성자 주입 가능


JPQL


회원 서비스

영속화한 엔티티에서 id 값 가져오기

em.persist(member)를 하면, 영속성 컨텍스트에서 member가 관리된다.

이때, 영속성 컨텍스트는 객체를 key와 value로 관리하게 되는데, key 값은 id가 된다. (PK와 매핑한 것이 key가 되기 때문)

따라서 영속성 컨텍스트에 올라갔다면, 아직 DB에 저장된 시점이 아니더라도 **id 값이 생성되었다는 것이 보장**된다.


@Transactional


주의할 점


회원 기능 테스트

테스트에서의 @Transactional

(회원가입()) 테스트에서 @Transactional을 사용하면 insert 쿼리가 나가지 않는다.

  • 원래는 commit 시에 sql이 나가야 하는데, 테스트에서 @Transactional을 사용하면 commit 되지 않고 rollback 되기 때문이다.
  • 테스트 케이스에 @Rollback(false)로 rollback을 끌 수도 있으나, 권장하지 않는다. (실제 DB에 적용되므로)
  • insert 쿼리를 보고 싶다면, EntityManager도 주입 받아서 em.flush()를 해서 콘솔에 sql이 찍히는 것을 보면 된다.

sql이 나갔음에도 불구하고 트랜잭션이기 때문에 롤백된다.


예외 발생 테스트의 두 가지 방법

  1. try-catch 문 & fail() 사용하기

    fail() 라인에 도달하면 테스트 실패

  2. Assertions.assertThrows(Exception.class, () -> ...) 사용하기


메모리 DB 설정 방법

테스트 시에만 스프링 내에 임시 데이터베이스 띄우는 방법

  1. test/resources 디렉토리를 만들고, 그곳에 main/resources/application.yml 파일을 복사 붙여넣기 한다.

테스트 코드의 경우, test 디렉토리에 있는 resources를 우선적으로 사용한다.

  1. spring.datasource.url을 다음과 같이 바꾼다.

JVM 안에서 띄울 수 있다. 로컬에서 h2 데이터베이스를 띄울 필요가 없다!

https://www.h2database.com/html/cheatSheet.html

```yaml
spring:
  datasource:
    url: jdbc:h2:mem:test
```


혹은, application.yml에서 spring.datasource, spring.jpa 관련 설정을 모두 지워도 가능하다. 스프링에서 기본으로 메모리 모드를 지원해주기 때문이다.

또한, 스프링은 기본적으로 create drop 모드를 지원한다.


[!tip] 개발 상황과 테스트 상황에서의 application.yml 등의 설정은 따로 가져가는 것을 권장한다.

seungriyou commented 9 months ago

5. 상품 도메인 개발

상품 엔티티 (+ 비즈니스 로직)

엔티티 안에 비즈니스 로직을 넣어보자! (응집도)

객체지향적으로 생각해보면, 데이터를 가지고 있는 쪽에(→ 즉, 엔티티에!) 비즈니스 로직이 있는 것이 좋다.

안 그러면 엔티티에서 값을 꺼내와서 밖에서 계산하고 다시 설정해야 하는데, 실무에서는 setter 사용을 지양해야 한다.

따라서, 엔티티의 값을 변경(생성 X)해야 할 일이 있으면 핵심 비즈니스 로직을 통해서 해야 한다.

// === 비즈니스 로직 ===
/*
재고 증가
 */
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 stack :(");
    }
    this.stockQuantity = restStock;
}


상품 리포지토리

em.merge()에 대해서는 추후 다시 다룬다!

@Repository
@RequiredArgsConstructor
public class ItemRepository {

    private final EntityManager em;

    public void save(Item item) {
        if (item.getId() == null) {     // (1) item이 영속화 되기 전의 새롭게 생성한 객체라면
            em.persist(item);           // 신규 등록 (persist)
        } else {                        // (2) 이미 DB에 등록된 걸 가져온 것이라면
            em.merge(item);             // merge (update와 비슷한 동작)
        }
    }

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


상품 서비스

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

    private final ItemRepository itemRepository;

    @Transactional  // override (readOnly = false)
    public void saveItem(Item item) {
        itemRepository.save(item);
    }

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

    public Item findOne(Long itemId) {
        return itemRepository.findOne(itemId);
    }
}
seungriyou commented 9 months ago

6. 주문 도메인 개발**

[!note] 엔티티에 실제 비즈니스 로직이 있어서 더 많은 것을 엔티티로 위임하는 도메인 모델 패턴에 대해 알아보자!

주문, 주문 상품 엔티티 (+ 비즈니스 로직)

[!tip] this를 꼭 써야 할까?

꼭 필요한 것이 아니라면 안 써도 충분히 IDE 하이라이팅으로 구별할 수 있기 때문에 안 써도 된다.

추가할 메서드 및 로직


OrderItem

// === 생성 메서드 ===
public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
    // 할인 기능이 추가될 수 있으므로 orderPrice는 따로
    OrderItem orderItem = new OrderItem();
    orderItem.setItem(item);
    orderItem.setOrderPrice(orderPrice);
    orderItem.setCount(count);

    // Item 재고 감소
    item.removeStock(count);
    return orderItem;
}

// === 비즈니스 로직 ===
public void cancel() {
    // Item의 재고를 주문 수량만 추가해주어야 한다.
    getItem().addStock(count);
}

// === 조회 로직 ===
/*
주문 상품 가격 조회
 */
public int getTotalPrice() {
    return getOrderPrice() * getCount();
}


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.CANCEL);

    // Item의 재고를 원상복구하기 위해
    for (OrderItem orderItem : orderItems) {
        orderItem.cancel();
    }
}

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


주문 서비스

주문하기

[!note]
어디까지 cascade 범위를 설정해야 할까?

  1. 다른 것이 참조할 수 없는 프라이빗 오너인 경우

    deliveryorderItem 모두 속하는 order에서만 사용한다.

  2. persist 해야 하는 라이프 사이클이 같은 경우

딱 이 정도 범위에서만 써야 한다.

만약 다른 곳에서도 delivery를 가져다 쓰는 등, 위 조건에 해당하지 않는다면, cascade 설정하면 안 되고 별도의 리포지토리를 생성해서 따로 persist 해야 한다.


[!tip]
항상 코드를 제약하는 스타일로 짜야 좋은 설계와 유지보수로 다가갈 수 있다.


주문 취소

SQL을 직접 사용하는 경우(ex. MyBatis, JdbcTemplate 등)에는 서비스 계층에서 비즈니스 로직을 쓸 수 밖에 없다.

어떤 엔티티의 비즈니스 로직 내에서 다른 엔티티들을 수정한 경우, 해당 변경 사항들을 DB에 반영하기 위해 결국 서비스 계층에 전부 꺼내와서 직접 DB에 반영해야 하는 것이다. (update 쿼리)

하지만 JPA를 사용한다면, JPA가 알아서 dirty checking을 통해 변경된 내역들에 대해 자동으로 update 쿼리를 날린다.

JPA의 매우 큰 장점이다!


[!note] 이처럼 비즈니스 로직 대부분이 엔티티에 있어 객체지향의 특성을 적극 활용하여, 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 하는 것을 도메인 모델 패턴이라 한다.

JPA와 같은 ORM을 사용하면 이러한 방식으로 개발하게 된다!

그와 반대로 엔티티에는 비즈니스 로직이 거의 없고, 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라 한다.

어떤 패턴이 유지보수가 더 쉬운지를 판단해야 한다. 또한, 한 프로젝트 안에서도 양립하기도 한다.


테스트

[!tip] 사실 좋은 테스트란 스프링과 상관 없이, dependency 없이 해당 메서드가 동작하는 것을 확인하는 단위 테스트이다. (ex. Item 엔티티 내의 removeStock()에 대한 단위 테스트)

지금은 스프링, JPA와 엮어서 통합 테스트를 하고 있다. 여러 엔티티, 여러 기능을 섞어서 테스트 하는 경우이다.


주문 검색 기능 개발

동적 쿼리 작성 방법

  1. JPQL: 문자열 조작이 어려워서 잘 사용하지 않는다.
  2. Criteria: JPA 표준 스펙이나, 실행하려는 쿼리가 명확히 보이지 않아 유지보수가 어렵다.
  3. QueryDSL: Java 코드이므로 컴파일 시점에 오류를 잡을 수 있고 쿼리가 눈에 잘 보인다. (이것을 권장 ✅)


[!note] 생산성을 높이기 위한 프로젝트 셋팅 → 스프링 부트, 스프링 데이터 JPA, QueryDSL

seungriyou commented 8 months ago

7. 웹 계층 개발

회원 등록

컨트롤러


[!tip] 등록 / 수정 시 폼 객체를 써야하나 엔티티를 써야하나?

  • 요구 사항이 매우 단순하면 엔티티를 사용해도 된다. 하지만 엔티티를 폼으로 사용하게 된다면 화면을 처리하기 위한 기능이 점점 증가되어, 결과적으로 엔티티에 화면 종속 기능이 계속 생기게 된다. 따라서 유지 보수가 어려워지게 된다.
  • 엔티티를 최대한 순수하게, 최대한 의존성이 없도록 만들어야 한다. 즉, 엔티티는 핵심 비즈니스 로직만 가지고 있고, 화면을 위한 로직은 없어야 한다.
  • 화면에 맞는 API는 폼 객체DTO를 사용하는 것을 권장한다.


[!caution] API를 만들 때에는 절대 엔티티를 외부로 넘기면 안 된다!!

엔티티에 로직을 추가하면 API 스펙이 바뀌어 버리게 된다.


상품 등록

[!warning] 컨트롤러에서 book을 생성할 때 setter를 사용하기보다는, createBook과 같은 static 생성 메서드를 사용하는 것을 권장한다. (예제에서는 setter를 열어두었다)


상품 수정***

[!note] 수정하는 방법에는 두 가지가 있다.

  1. 변경 감지 (→ JPA의 best practice)
  2. 병합 (→ 실무에서 거의 쓸 일이 없다!)


엔티티 수정: 변경 감지와 병합(merge)***

아주아주아주 중요하다!

준영속 엔티티

영속성 컨텍스트가 더이상 관리하지 않는 엔티티이다.

다음과 같이 new임의로 만들어낸 엔티티(book)도 기존 식별자를 가지고 있으면 준영속 엔티티로 볼 수 있다.

엔티티 객체가 이미 DB에 한 번 저장되어 식별자가 존재하는 것이다!

@PostMapping("items/{itemId}/edit")
public String updateItemForm(@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";
}


하지만 준영속 엔티티는 JPA가 관리하지 않기 때문에, 준영속 엔티티를 수정하려면 다음의 두 가지 방법을 사용해야 한다.

  1. 변경 감지 기능 사용
  2. 병합(merge) 사용


[1] 변경 감지 (Dirty Checking) (권장 ✅)

영속성 컨텍스트에서 엔티티를 다시 조회한 후에 수정하는 방법이다.

트랜잭션 안에서 엔티티를 다시 조회하고 원하는 속성만 변경하면, 트랜잭션 커밋 시점에 변경 감지(dirty checking)가 동작하여 데이터베이스에 update 쿼리가 실행된다.

public class ItemService {

    private final ItemRepository itemRepository;

    ...

    @Transactional
    public void updateItem(Long itemId, Book param) {
        // findItem은 영속 상태이다!
        Item findItem = itemRepository.findOne(itemId);
        findItem.setPrice(param.getPrice());
        findItem.setName(param.getName());
        findItem.setStockQuantity(param.getStockQuantity());

        // 따라서 영속화하기 위한 작업을 따로 하지 않아도 된다.
        // 트랜잭션이 완료되면 flush 되며, 그때 변경 감지로 인해 업데이트 쿼리가 날아간다.
    }


[!caution] 또한, 위의 코드처럼 업데이트를 단발성으로 하면 안된다.

엔티티에서 의미 있는 메서드(ex. change(...))로 따로 빼야 추후 변경 지점이 엔티티로 한정되게 된다.

엔티티 레벨에서 변경되는 로직을 작성하는 것을 권장한다.


[2] 병합 (Merge)

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

병합의 동작 방식은 다음과 같다.

  1. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 영속 엔티티를 조회한다.
  2. 영속 엔티티의 모든 필드를 준영속 엔티티의 값으로 모두 교체한다. (= 병합)
  3. 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 데이터베이스에 UPDATE SQL이 실행된다.

image


[!caution] 원하는 속성만 선택할 수 있는 변경 감지 기능과는 다르게, 병합 시에는 모든 속성이 변경된다.

따라서 병합 시 값이 없으면 기존 값이 유지되는 것이 아닌, null로 업데이트 할 위험도 있다. 실무에서는 보통 변경을 원하는 데이터만 노출하므로 병합을 사용하면 번거로워지게 된다.

→ 실무에서는 변경 감지 기능을 권장한다!!


가장 좋은 방법

[!tip] 엔티티를 변경할 때는 항상 변경 감지를 사용하자!

  1. 컨트롤러에서 어설프게 엔티티를 생성하지 말자.
  2. 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 명확하게 전달하라.

    파라미터 or DTO

  3. 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하라.
  4. 트랜잭션 커밋 시점에 변경 감지가 실행된다.


예시 코드는 다음과 같다.


[!tip] setter 대신 엔티티 안에서 바로 추적할 수 있는 메서드를 만들자. (ex. findItem.change(...))

업데이트 코드를 변경해야 하는데, 해당 코드가 여기저기에 퍼져있다고 생각해보자..!


상품 주문

[!tip] 서비스 계층에 식별자를 넘기자!

핵심 비즈니스 로직이 있는 경우(ex. 단순 조회 제외) 서비스 계층에 엔티티를 직접 찾아서 넘기기보다는 식별자를 넘기자. 이렇게 하면 서비스 계층의 로직 안에서 할 수 있는 게 더 많아진다. 엔티티도 영속 상태로 흘러가기 때문이다. (→ dirty checking)

트랜잭션이 걸려있는 서비스 계층 밖에서 엔티티를 찾으면 영속 상태가 아니게 된다!

  • 컨트롤러
```java
@PostMapping("/order")
public String roder(@RequestParam("memberId") Long memberId,
                    @RequestParam("itemId") Long itemId,
                    @RequestParam("count") int count) {
    // 식별자 넘기기 (엔티티 대신)
    orderService.order(memberId, itemId, count);
    return "redirect:/orders";
}
```


주문 목록 검색, 취소

[!tip] 서비스에서 단순히 리포지토리에 동작을 위임만 하는 경우라면, 컨트롤러에서 바로 리포지토리를 불러와 사용해도 된다. 원하는대로!

public List<Order> findOrders(OrderSearch orderSearch) {
    return orderRepository.findAll(orderSearch);
}