Skip to content

Latest commit

 

History

History
397 lines (300 loc) · 14.1 KB

JPA 실험실.md

File metadata and controls

397 lines (300 loc) · 14.1 KB

JPA 실험실

대충 어설프게 알고 쓰고 있는 JPA. 실험해보며 알아보자.

단방향 @OneToMany은 특별한 경우가 아니라면 쓰지 않는 것이 좋다.

아래 코드를 보면 알겠지만 객체 관계 관점에서만 바라보면 아주 직관적이고 깔끔 단순한 모델이다.

OrderOrderItem의 존재를 알지만 OrderItemOrder의 존재를 모른다.

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

    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;
    
    ...
    
    @OneToMany
    private List<OrderItem> orderItems;
    
    ...
}

@Entity
@Table(name = "ORDER_ITEM")
@Getter
public class OrderItem {

    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    ...
    
    // Order에 대한 정보 없음
    
    ...
}    
  • 단방향이므로 mappedBy 애트리뷰트를 붙일 대상이 없다.

    • mappedBy를 붙일 대상이 없는데도 굳이 붙이면 다음과 같은 에러가 난다.

      Caused by: org.hibernate.AnnotationException: mappedBy reference an unknown target entity property: io.homo.efficio.cryptomall.entity.order.OrderItem.order in io.homo.efficio.cryptomall.entity.order.Order.orderItems

  • 위와 같이 구성 후 테스트 해보면 ORDERS, ORDER_ITEMS 테이블 외에 다음과 같이 ORDERS_ORDER_ITEMS라는 테이블이 생성된다.

    create table orders_order_items (
       order_order_id bigint not null,
        order_items_order_item_id bigint not null
    ) engine=MyISAM
    ...
    alter table orders_order_items 
       add constraint UK_70a4sa284yptqe6d1xxson8kn unique (order_items_order_item_id)
    ...
    alter table orders_order_items 
       add constraint FKrp82oqw4ek9fpmcf803wxvcta 
       foreign key (order_items_order_item_id) 
       references order_item (order_item_id)
    ...    
    alter table orders_order_items 
       add constraint FK4a5vis32u4bexdg4xyjjc7o4j 
       foreign key (order_order_id) 
       references orders (order_id)    
    
  • 원했던 것은 단순한 ORDERS:ORDER_ITEM = 1:N 이었지만, 실제로는 ORDERS:ORDERS_ORDER_ITEMS:ORDER_ITEMS = 1:N:1 관계가 형성된다.

  • 이렇게 되면 CUD를 할 때 ORDERS_ORDER_ITEMS에 대해서도 CUD를 해야하므로 불필요한 오버헤드가 생긴다.

정리

결국 단방향 @OneToMany을 통해 얻고자 했던 단순함도 얻지 못하고 불필요한 오버헤드만 발생하므로 단방향 @OneToMany은 별로 좋은 점이 없다.

테스트 메서드에서는 XXXRepository.save()만으로는 flush가 유발되지 않는다.

@Entity
@Table(name = "PRODUCT")
@Getter
public class Product extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "product_id")
    private Long id;

    private String name;

    private double price;
    
    public Product(@NonNull String name,
                   double price) {
        this.name = name;
        this.price = price;
    }
} 


@RunWith(SpringRunner.class)
@DataJpaTest
public class ProductRepositoryTest {

    @Autowired
    private ProductRepository repository;

    @Test
    public void whenSave__thenReturnProduct() {
        final Product product = repository.save(
                new Product(
                        "어디다쓰 헬스 장갑", 15.00d
                )
        );
    }
}

테스트 메서드 실행 후 로그를 보면 아래와 같이 insert into product가 실행되지 않음을 알 수 있다.

Hibernate: 
    select
        next_val as id_val 
    from
        hibernate_sequence for update
            
Hibernate: 
    update
        hibernate_sequence 
    set
        next_val= ? 
    where
        next_val=?

하지만, repository.save() 후에 repository.flush()를 명시적으로 호출하면 아래와 같이 insert into product가 실행되고 로그에 표시된다.

    @Test
    public void whenSave__thenReturnProduct() {
        final Product product = repository.save(
                new Product(
                        "어디다쓰 헬스 장갑", 15.00d
                )
        );
        repository.flush();
    }


Hibernate: 
    select
        next_val as id_val 
    from
        hibernate_sequence for update
            
Hibernate: 
    update
        hibernate_sequence 
    set
        next_val= ? 
    where
        next_val=?
Hibernate: 
    insert 
    into
        product
        (created_at, last_modified_at, category_id, name, price, product_id) 
    values
        (?, ?, ?, ?, ?, ?)
o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [TIMESTAMP] - [Sun Aug 05 01:12:24 KST 2018]
o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [TIMESTAMP] - [Sun Aug 05 01:12:24 KST 2018]
o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [null]
o.h.type.descriptor.sql.BasicBinder      : binding parameter [4] as [VARCHAR] - [어디다쓰 헬스 장갑]
o.h.type.descriptor.sql.BasicBinder      : binding parameter [5] as [DOUBLE] - [15.0]
o.h.type.descriptor.sql.BasicBinder      : binding parameter [6] as [BIGINT] - [1]

참고로 일반 메서드에서는 XXXRepository.save()만으로도 flush를 유발한다. 아래 일반 메서드에 사용되는 @Transactionalflush를 유발한다. 예제에서 함께 확인할 수 있다.

하지만 save() 이후에 변경된 사항은 명시적으로 flush()를 호출해주지 않으면 DB에 반영되지 않으므로 주의해야 한다.

정리

테스트 메서드에서는 XXXRepository.save()만으로는 flush가 유발되지 않으므로, 영속 객체의 데이터에 대한 변경을 모두 마친 후에 명시적으로 XXXRepository.flush()를 호출해줘야 한다.

일반 메서드에 사용되는 @Transactionalflush를 유발한다.

아래와 같은 CommandLineRunner 구현체로 테스트를 해보면 insert 문은 실행되지만, update 문은 실행되지 않는다.

즉, save() 시점에 한 번 flush되지만 그 이후에는 영속 상태(MANAGED 상태)인 객체의 데이터에 변경 사항이 생겨도, 명시적으로 flush()를 호출하지 않으면 flush 되지 않는다.

@Component
public class InitRunner implements CommandLineRunner {

    @Autowired
    private ProductRepository repository;

    @Override
    public void run(String... args) throws Exception {
        final Product product = repository.save(
                new Product(
                        "어디다쓰 헬스 장갑", 15.00d
                )
        );

        product.setName("나이스 헬스 장갑");
    }
}


insert 
into
    product
    (created_at, last_modified_at, category_id, name, price, product_id) 
values
    (?, ?, ?, ?, ?, ?)
binding parameter [1] as [TIMESTAMP] - [Sun Aug 05 01:42:17 KST 2018]
binding parameter [2] as [TIMESTAMP] - [Sun Aug 05 01:42:17 KST 2018]
binding parameter [3] as [BIGINT] - [null]
binding parameter [4] as [VARCHAR] - [어디다쓰 헬스 장갑]
binding parameter [5] as [DOUBLE] - [15.0]
binding parameter [6] as [BIGINT] - [1]

하지만 run()@Transactional을 붙이면 save()setName()을 하나의 트랜잭션으로 묶으면서, 별도의 명시적인 flush() 호출 없이도 마지막에 flush()를 암묵적으로 호출하게 되므로 update 문도 실행된다.

    @Override
    @Transactional  // <== 추가!!
    public void run(String... args) throws Exception {
        final Product product = repository.save(
                new Product(
                        "어디다쓰 헬스 장갑", 15.00d
                )
        );

        product.setName("나이스 헬스 장갑");
    }
    

insert 
into
    product
    (created_at, last_modified_at, category_id, name, price, product_id) 
values
    (?, ?, ?, ?, ?, ?)
binding parameter [1] as [TIMESTAMP] - [Sun Aug 05 01:42:17 KST 2018]
binding parameter [2] as [TIMESTAMP] - [Sun Aug 05 01:42:17 KST 2018]
binding parameter [3] as [BIGINT] - [null]
binding parameter [4] as [VARCHAR] - [어디다쓰 헬스 장갑]
binding parameter [5] as [DOUBLE] - [15.0]
binding parameter [6] as [BIGINT] - [1]

update
    product 
set
    last_modified_at=?,
    category_id=?,
    name=?,
    price=? 
where
    product_id=?
binding parameter [1] as [TIMESTAMP] - [Sun Aug 05 01:42:17 KST 2018]
binding parameter [2] as [BIGINT] - [null]
binding parameter [3] as [VARCHAR] - [나이스 헬스 장갑]
binding parameter [4] as [DOUBLE] - [15.0]
binding parameter [5] as [BIGINT] - [1]

정리

테스트가 아닌 일반 메서드에 사용되는 @Transactional은 트랜잭션을 하나로 묶기 위해 일반 메서드의 종료 시점에 commit 하면서 암묵적으로 flush를 유발한다.

테스트 메서드에 사용되는 @Transactionalflush를 유발하지 않는다.

하지만 일반 메서드에서와는 달리 테스트 메서드에 사용되는 @Transactionalflush를 유발하지 않는다. 게다가 테스트 메서드에는 @Transactional을 붙이지 않아도 기본값으로 롤백을 시켜주므로 트랜잭션 관리를 별도로 지정할 특별한 사유가 없다면 @Transactional을 붙여도 차이가 없으므로 붙일 필요가 없다.

하지만 참고로 @SpringBootTest를 사용하는 통합 테스트에서 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)와 같이 RANDOM_PORTDEFINED_PORT를 지정해주면 실제로 서블릿 컨테이너가 구동되며 클라이언트의 HTTP 호출도 실제와 마찬가지로 서버와는 비동기로 동작한다. 따라서 이 경우에는 HTTP 요청과 백엔드 처리가 서로 다른 스레드에서 동작하고 따라서 트랜잭션이 유지되지 않는다. 즉, 다른 테스트 환경과는 다르게 RollBack이 되지 않는다. 여기 참고

   ...
   
    @Test
    @Transactional
    public void whenSave__thenReturnProduct() {
        final Product product = repository.save(
                new Product(
                        "어디다쓰 헬스 장갑", 15.00d
                )
        );
        product.setName("나이스 헬스 장갑");
        
        assertThat(product.getName()).isEqualTo("나이스 헬스 장갑");
    }

   ...


// 아래와 같이 sequence 관련 쿼리만 나온다.
    select
        next_val as id_val 
    from
        hibernate_sequence for update
            
Hibernate: 
    select
        next_val as id_val 
    from
        hibernate_sequence for update
            
2018-08-05 01:47:44.107 DEBUG 24000 --- [           main] org.hibernate.SQL                        : 
    update
        hibernate_sequence 
    set
        next_val= ? 
    where
        next_val=?
Hibernate: 
    update
        hibernate_sequence 
    set
        next_val= ? 
    where
        next_val=?

따라서, 테스트 메서드에서는 반드시 명시적으로 flush()를 호출해줘야 한다. 또는 flush를 유발하는 commit, JPQL 실행 등을 해줘야 한다. 그렇지 않으면 위와 같이 사실은 DB에 저장되지 않았음에도 메모리에 저장된 값만으로 비교하므로 테스트는 통과하게 된다. 이는 로컬 환경에서는 테스트가 통과하지만 실제 운영 환경에서는 실패하는 상황으로 이어질 수 있다.

이에 대해서는 스프링 문서의 Demonstration of all transaction-related annotations 단원의 아래 쪽 Avoid false positives when testing ORM code에도 특별히 강조되어 있다.

정리

테스트 메서드에서는 반드시 명시적으로 flush()를 호출해주거나, commit, JPQL 쿼리 실행으로 flush를 유발해야 한다.

하나의 repository에서만 flush()를 호출하면 다른 repository에서의 변경 사항까지 모두 함께 flush 된다.

TODO

LazyInitializationException

@*ToMany@ElementCollection으로 연관된 객체의 기본 fetch 전략은 Lazy다.

@Entity
@Getter
class Team {

    @OneToMany(mappedBy = "team")
    List<Member> members = new ArrayList<>();    
}

이렇게 팀이 여러 멤버를 가질 수 있는 관계로 설정된 상태에서 아래와 같이 팀에서 멤버를 가져오고 멤버 하나에 접근하려면,

class TeamService {

    public List<Member> getMembers(Long teamId) {
        Team team = this.teamRepository.findById(teamId).orElseThrow(() -> new TeamNotFoundException());
        List<Member> members = team.getMembers();
        Member member0 = members.get(0);  // 여기서 예외 발생
    }
}

다음과 같이 LazyInitializationException이 발생한다.

org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: 어쩌구.저쩌구.Team.members, could not initialize proxy - no Session

.. 이하 생략 ..

원인은 예외 메시지에 있는 그대로 세션이 없어서 members에 대한 프록시가 실제 members를 가져올 수 없기 때문이다.

그럼 세션을 살려주면 된다. 세션을 살리는 데는 여러 방법이 있는데 스프링 데이터 JPA에서 가장 간단한 방법은 @Transactional(readOnly = true)를 메서드에 추가하는 것이다. 그럼 해당 메서드 종료시까지 트랜잭션이 유지되고 그동안 세션이 살아있으므로 프록시가 실제 members를 가져올 수 있다.

정리

@*ToMany, @ElementCollection의 기본 Fetch 전략은 Lazy 다.

Team을 조회한 후에 세션이 종료되면 Lazy하게 가져올 수 없어 LazyInitializationException이 발생한다.

이를 해결하려면 Team을 조회한 후에도 세션이 살아있게 해야하며, 스프링 데이터 JPA에서는 @Transactional(readOnly = true)를 이용해서 쉽게 해결할 수 있다.