자바 ORM 표준 JPA 프로그래밍 - 기본편 : JPA Proxy, 지연로딩, Cascade

jkky98·2024년 10월 1일
0

Spring

목록 보기
51/77

em.find() vs em.getReference()

이미 잘 쓰고있는 em.find(entity) 내부 동작 메커니즘은 다음과 같다.

  1. 영속성 컨텍스트에 entity 서치 -> 존재한다면 반환 (쿼리 X)
  2. 영속성 컨텍스트에 없을 경우 DB 서치 -> 존재한다면 반환 (쿼리 O) + 영속성 컨텍스트에 조회대상 엔티티 안착시킴

em.getReference(entity)는 em.find()처럼 엔티티를 찾는 역할을 하지만 실제 엔티티 클래스 객체를 반환하는 것이 아니라 껍데기(Proxy)를 반환한다.

프록시란

실제 클래스를 상속 받아 만들어지며 겉 모양이 같다. 이때 겉 모양이 같다는 것은 실제 엔티티가 id, name 필드를 가지고 getter 메서드들이 존재한다면 Proxy는 동일한 이름의 getter 메서드를 가지는 것이다.

다만 Proxy는 필드에 어느것도 가지지 않고(null로 되어있음) 부모 엔티티의 참조를 가지게 되는데, Proxy의 getter를 사용하면 부모 엔티티의 기능들을 사용하도록 부모의 동일 이름 메서드들을 호출한다.

Member memberRef = em.getReference(entity) 
// Member memberRef는 Member타입이 아닌 자식 타입인 class org.domain.Member$HibernateProxy$SDxgDOz7와
// 처럼 다른 타입(Member의 자식)을 나타낸다. 
member.getName();
// getName()을 호출하는 시점에 프록시 객체의 초기화가 시작된다.

프록시 초기화

getName()과 같은 기능 메서드 호출 시 프록시 초기화가 진행된다고 하였다. 이때 초기화라는 것은 Proxy의 참조에 실제 엔티티 주소를 구성하는 작업이다.

getReference()를 통해 영속성 컨텍스트에 프록시가 올라간다. 그리고 getName()시에 프록시 초기화가 진행되는데 이때 참조 주소를 구성하기 위해

1차적으로 영속성 컨텍스트를 조회한다. 영속성 컨텍스트에 실제 엔티티가 존재한다면 이 엔티티의 주소를 프록시에 받아 프록시 타입의 엔티티를 반환한다. (쿼리가 나가지 않는다.)

2차적으로(영속성 컨텍스트에 엔티티X) DB를 조회하여 실제 엔티티를 영속성 컨텍스트에 올리고 실제 엔티티의 주소를 프록시에 구성하여 프록시 타입의 엔티티를 반환한다.

의문점

아직 프록시에 대한 효용을 언급하지는 않았다. 실제로 getReference()를 사용할 일도 없다고 한다. 하지만 굳이 이 프록시 과정을 이해하는 이유는 지연로딩과 관련되어 꽤 중요한 이해이기 때문에 우선 프록시가 작동하는 원리를 이해하는 것이 중요하다.

@Entity
public class Member extends BaseEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;

    @OneToOne(mappedBy = "member")
    private Locker locker;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

간단하게 설명하자면 즉시로딩은 위의 Member 엔티티를 find() 했을 때 연관된 엔티티를 모두 다룰 수 있도록 큰 패키지(member + locker + orders) 자체를 바로 제공한다. 이때 즉시 로딩은 연관된 엔티티들도 가져오도록 쿼리를 여러번 날리거나 크게 날리게 된다.

  • 근데 로직상 member만 필요하고 locker나 orders등을 쓰지 않는다면?...
  • 굳이 항상 locker와 orders를 member를 가져올 때마다 붙여서 가져와야 하나? 일단은 Member만 가져온다면 참 좋을탠데..

지연로딩은 위와 같은 바램을 해결해주는 방식이며 이 지연로딩을 구현해주는 것이 Proxy이므로 일단은 Proxy의 작동방식을 숙지하고 Proxy가 지연로딩을 구현하는 방법을 알아보도록 할 것이다.

프록시 관련 자주 만나는 실무상황

보통 영속성 컨텍스트는 트랜잭션의 끝과 함께 clear된다. 트랜잭션 안에서는 엔티티를 잘 사용하고 있다가 트랜잭션이 끝나고 엔티티를 다루려고 하다보면 LazyInitializationException와 같은 예외를 만나는 경우가 매우 많을 것이다.

반면 위의 트랜잭션 엔딩 상황을 그대로 겪은 비영속 엔티티 객체(프록시가 아닌)의 경우 getter 사용은 무리가 없다. getter는 단순히 필드에 세팅된 값을 반환해주기 때문이다. 다만 setter와 같은 수정 기능을 사용하고 이 사항이 반영되려면 조회후 변경감지 혹은 merge()를 사용해야한다.

그런데 프록시 엔티티 객체의 경우는 어떠한 기능도 사용할 수 없다. 프록시 엔티티 객체의 모든 기능은 영속성 컨텍스트의 도움을 받아 참조주소로 하여금 실제 객체 기능을 실행시킬 수 있기 때문이다.

지연로딩(FetchType.LAZY)

  • 근데 로직상 member만 필요하고 locker나 orders등을 쓰지 않는다면?...
  • 굳이 항상 locker와 orders를 member를 가져올 때마다 붙여서 가져와야 하나? 일단은 Member만 가져온다면 참 좋을탠데..

지연로딩은 간단히 말해서 한 상 가득차려진 진수성찬이 아닌 처음엔 조금 씩 담고 더 먹고 싶을 때마다 음식을 꺼내오는 방식이다.

배가 조금 고파도, 많이 고파도, 적당히 고파도 풀 패키지 밥상을 가져온다면 빈번하게 음식이 남는 상황이 발생할 것이다.

@Entity
@Table(name = "ORDERS")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ORDER_ID")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private Member member;

    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private Status status;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "DELIVERY_ID")
    private Delivery delivery;
}

위와 같이 외래키가 설정된 필드들의 어노테이션에 fetch = FetchType.LAZY를 걸어준다.

  • XToOne의 기본 fetchType은 EAGER이고 OneToX의 기본 fetchType은 LAZY이므로 mappedBy가 걸린 쪽(외래키의 대상)에는 LAZY 설정이 필요하지 않다.

위는 member 필드에 LAZY를 빼고 em.find()를 수행했을 때 쿼리 로그를 나타낸 것으로 member 테이블에도 select 쿼리가 나간 것을 볼 수 있다.

LAZY를 넣고 수행했을 때는 order만 select하는 것을 볼 수 있다.

이렇게 지연로딩 설정을 통해 일단은 가장 필요한 부분만 가져오는 것이다. 그리고 우리는 Order 타입의 order 엔티티 객체를 사용할 수 있다.(프록시 아님)

order.getMember()를 수행하고 쿼리 로그와 리턴 타입을 확인해보자.

Member memberOrder = findOrder.getMember();
System.out.println("memberOrder.getClass() = " + memberOrder.getClass());

order에 대한 select 쿼리가 위와 동일하게 실행됐을 뿐 getMember에도 member에 대한 select 쿼리가 나타나진 않았다. 이는 당연하다. 위에서 우린 이미 프록시 객체의 실제 메서드를 사용할 때 쿼리가 나타난다고 배웠다.(프록시 초기화)

일단 확실히 알아두어야 할 것은 LAZY로 설정한 외래키 필드에 대해서 이에 대해 getter로 외래키로 연결된 엔티티를 가져올 때 이는 프록시 타입이라는 것이다.

Member memberOrder = findOrder.getMember();
System.out.println("memberOrder.getClass() = " + memberOrder.getClass());
System.out.println("memberOrder.getName() = " + memberOrder.getName());

위와 같이 member의 getter를 사용하면

위와 같이 member에 대한 select 쿼리가 나가게 된다.(locker도 같이 딸려온 이유는 member 엔티티에 OneToOne으로 연결된 Locker에 대해 LAZY 설정을 안했기 때문이다.)

EAGER의 위험성

Locker를 LAZY 설정 하지 않아서 나타나는 쿼리를 보며 우리가 LAZY 설정을 모든 연관관계에 해놓지 않아 하나의 엔티티에 대한 조회 기능에 대해 모든 엔티티가 그물망처럼 따라올 것이라는 상상을 했고 그것이 굉장히 비효율적으로 느꼈다면 지연로딩에 대한 이해는 끝났다고 생각한다

EAGER를 기본으로 해서 하나의 조회에 대해 엄청난 양의 엔티티들이 딸려오는 문제를 N+1 문제라고 한다.

실무에서

XToOne이 기본적으로 EAGER로 설정되어있다. 우리는 이를 기본적으로는 LAZY로 바꿔서 사용해야한다.

만약 해당 엔티티에 연관된 연관 필드가 항상 같이 필요한 경우라면 EAGER를 그때 선택할 수 있다. 일단 기본적으로는 모두 딸려오는 현상을 잘라줘야 한다는 것이다. LAZY로 하여금 모두 발라내고 시작하는 것이다.

CASCADE

XToX에 속성으로 cascade = CascadeType.ALL와 같이 적용해서 사용할 수 있다. CascadeType.ALL와 달리 CascadeType.PERSIST와 같은 속성도 존재하는데 ALL은 저장, 삭제, 병합시 연관관계의 엔티티도 모두 저장, 삭제, 병합되는 것이다. 반면 PERSIST의 경우 저장만, REMOVE(삭제만), MERGE(병합만)에 해당한다.

가장 보통적으로 사용하는 ALL, PERSIST, REMOVE, MERGE 정도만 파악해두도록 하자.

고아객체(orphanRemoval = true)

cascade와 같이 XToX 속성으로 orphanRemoval = true를 주면 적용된다. 고아객체의 경우 이 속성을 적용한 연관관계 엔티티(자식 엔티티)에 대해서 관계가 끊어지면(부모 엔티티의 자식 엔티티 연관 컬렉션에서 자식 엔티티를 제외) 자식 엔티티가 삭제되는 방향이다.

orphanRemoval = true VS CascadeType.REMOVE

둘은 매우 비슷한 로직을 가진다. 둘 다 em.remove(부모엔티티)시에 자식 엔티티가 삭제되는 것은 동일하다. 하지만 orphanRemoval은 em.remove가 아닌 단순히 해당 엔티티의 연관관계 필드(컬렉션)에서만 제거되어도 삭제가 된다는 것이다. 그렇기에 (orphanRemoval=true + CascadeType.ALL)조합은 딱히 문제가 없어보인다.

Bug, 헷갈림, 의문점

cascade와 연관관계는 완전히 독립적이다. 그렇기에 mappedBy에 의해 readOnly가 된 필드에 cascade 설정을 해서 변화를 줄 수 있다는 것에서는 그 과정이 끄덕여지는데 그렇다면 orphanRemoval은?? 이라는 생각이 들었다.

일단 확실한 점은 다음과 같다.

  • 연관관계의 주인과 부모 엔티티는 보통 반대이다. 1:N 관계에서 N쪽에 외래키가 설정되고 연관관계의 주인이 할당된다. 연관관계의 주인은 자식 엔티티가 된다. 그리고 cascade, orphanRemoval은 주인 엔티티(연관관계의 비주인)에 설정된다.

  • cascade와 orphanRemoval은 거의 같이 쓰인다.

검색을 해보니 orphanRemoval의 버그가 있다고 하는데 이는 확실치 않았다.

  • cascade, orphanRemoval 설정은 영속성 컨텍스트가 아닌 부모 엔티티가 라이프 사이클을 관리하게 된다는 것이다.
profile
자바집사의 거북이 수련법

0개의 댓글