노트 정리/자바 ORM 표준 JPA 프로그래밍

[JPA이론] 8. 프록시와 연관관계 관리

DuL2 2022. 10. 19. 20:58

프록시

프록시는 가짜 엔티티 객체를 말한다. JPA를 사용하다 보면 자주 만날 수 있는 용어인데 왜 프록시 객체가 필요한 것일까?

 

먼저, 프록시가 무엇인지 알아보기 위해 프록시 객체를 만들어 보자.

em.find() vs em.getReference()

find()는 JPA에서 실제 DB에 접근하여 ORM에 맞게 실제 엔티티 객체를 가져오는 메소드이다. 이와 달리 getReference() 메서드는 가짜 DB에 직접 접근하지 않고 엔티티 객체인 프록시 객체를 조회 해준다.

 

try {
            //sample 저장
            Member member = new Member();
            member.setUsername("1st member");

            em.persist(member);

            em.flush();
            em.clear();						---[1]

            Member findMember = em.find(Member.class, member.getId());			---[2]
            System.out.println("findMember.getId() = " + findMember.getId());
            System.out.println("findMember.getUsername() = " + findMember.getUsername());
            
            Member findMember = em.getReference(Member.class, member.getId());	---[3]
            System.out.println("findMember.getId() = " + findMember.getId());
            System.out.println("findMember.getUsername() = " + findMember.getUsername());

            // 트랜잭션 커밋.
            tx.commit();

 

위 코드에서 [1]번으로 인해 영속성 컨텍스트가 비워지고 다시 member를 찾는 과정을 한번 확인해보자.

 

[2]번의 경우와 [3]의 경우를 비교해봐야 하는데 [2]번에서는 정상적으로 DB에 접근하여 실제 데이터를 가져오기 때문에 select 쿼리가 나가고 DB의 정보를 엔티티 객체에 담아 리턴해준다.

 

즉, 다음과 같은 순서로 콘솔에 찍힌다

 

select 쿼리 ...                 --> DB 접근
findMember.getId() = 1
findMember.getUsername() = 1st member

 

하지만 [3]의 getReference() 메서드를 사용하면 실제 데이터가 필요하기 전까지 DB에 접근하지 않는데 그런 결과를 콘솔창을 통해 확인할 수 있다.

findMember.getId() = 1
select 쿼리 ...                 --> DB 접근
findMember.getUsername() = 1st member

 

첫번째 줄에서 id의 경우에는 데이터를 찾기 위해 사용되었으므로 객체 세상에서 이미 존재했던 데이터기 때문에 아무런 조회없이 System.out.print에 찍힐 수 있었지만, Member.Id 값은 가짜 엔티티 객체 즉, 프록시 객체이기에 데이터가 없고 이를 호출할 때 select 쿼리를 사용하므로써 데이터를 얻어 리턴해주게 된다.

 

받아온 member 객체의 class를 찍어보면 다음과 같다.

 

HiberanteProxy -> 하이버네이트가 만들어준 프록시 클래스이다.

 

프록시의 특징

 프록시 객체는 실제 클래스를 상속 받아서 만들어지기 때문에 겉모양이 똑같다. 그렇기 때문에 사용하는 입장에서는 이론상 진짜 객체인지 프록시 객체인지 구분하지 않고 사용할 수 있다.

 

 프록시에는 target이라는 필드가 있고 실제 데이터가 필요할때 영속성 컨텍스트에 데이터를 초기화 요청하여 target의 데이터를 받고 target을 통해 최종적으로 실제 데이터를 반환해준다.

 

 프록시는 최초 한 번만 초기화하며 프록시 객체가 실제 객체로 바뀌는 것은 아니다. 그렇기 때문에 만약 타입 체크(비교)를 할 일이 있다면 상속 받았다는 특징을 통해 `instance of`를 사용하여 타입 확인을 할 수 있다.

 단, 이미 영속성 컨텍스트에 올라온 객체를 getReference()하여 받아올 때는 프록시 객체가 반환되는 것이 아니라 영속성 컨텍스트에 존재하는 객체 즉, 실제 엔티티를 받게 된다. 이와 반대로 만약 영속성 컨텍스트에 이미 프록시 객체가 있을 때 같은 트랜잭션 안에서 find()를 통해 다시 받아오게 되면 이 때는 프록시 객체를 받아 보게 된다.

 즉, 영속성 컨텍스트는 같은 트랜잭션 안에서 프록시와 실제 클래스의 차이는 상관없이 최초 초기화된 객체를 반환해준다. 그 이유는 같은 트랜잭션 안에서의 객체는 같아야 하기 때문이다.

 

 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때는 프록시를 초기화(DB 접근시)시 문제(예외 - LazyInitializationException)가 발생하게 된다. 물론, 영속성 컨텍스트가 닫히거나 비워져도 같은 예외가 발생한다.

 

try {
            Member refMember = em.getReference(Member.class, member.getId());
            System.out.println("refMember.getId() = " + refMember.getId());

            em.detach(refMember);
            //em.close();
            //em.clear();

            System.out.println("refMember.getUsername() = " + refMember.getUsername());
} catch (...

 

프록시 확인

프록시를 확인할 수 있는 여러가지 방법이 있다.

 

먼저, 프록시 인스턴스의 초기화 여부 상태를 알고 싶다면 다음과 같이 할 수 있다. EntityManagerFactory에서 PersistenceUnitUtil을 받아 초기화 된 객체인지를 확인하면 된다.

// 프록시 초기화 여부 확인
System.out.println("isLoaded = " + emf.getPersistenceUnitUtil().isLoaded(refMember));

프록시 클래스를 확인하고 싶을 때는 프록시의 클래스를 받아 이름을 출력하면 된다.

System.out.println("refMember.getClass() = " + refMember.getClass().getName());

하이버네이트는 프록시를 강제 초기화 하는 방법도 있다.(JPA 표준은 강제 초기화가 없으므로 강제 호출(member.getName()과 같이)을 통해 초기화 해주어야 한다.)

//하이버네이트의 강제 초기화
Hibernate.initialize(refMember);

 

즉시 로딩과 지연 로딩

 즉시 로딩은 엔티티 객체를 불러올 때 하위의 모든 객체를 즉시 불러오도록 만든다. 완벽하게 값이 존재하는 엔티티 객체를 생성하기 위해서 여러 쿼리가 한번에 나가게 된다.

 

 지연 로딩은 즉시 로딩과 달리 당장 필요하지 않은 객체는 프록시 객체를 만들어 끼워넣고 추후에 데이터를 요청할 때 위에서 설명했던 프록시의 target에 영속성 컨텍스트를 통해 연결시켜주게 된다.

 

아래 예시는 Team에 걸려있던 다대일 매핑을 즉시 로딩(디폴트)에서 지연로딩으로 바꾸어 주었다.

Class Member {
...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
...
}

 이렇게 설정한 상태에서 Member 객체를 find 하더라도 내부의 team은 당장 호출되지 않고 프록시로 설정되게 된다.

 

        Team teamA = new Team();
        teamA.setName("teamA");

        Member member1 = em.find(Member.class, member.getId());
        member1.setTeam(teamA);

        em.persist(member1);
        em.persist(teamA);

        em.flush();
        em.clear();

        Member memberForLazy = em.find(Member.class, member.getId());
        System.out.println("memberForLazy.getTeam().getClass() = " + memberForLazy.getTeam().getClass());

 콘솔창을 보게되면 team은 프록시 객체임을 알 수 있다.

 

만약 Member와 Team이 빈번하게 같이 사용되어 즉시 로딩을 사용하고 싶다면 EAGER 타입으로 설정해주며 된다.

Class Member {
...
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
...
}

다대일, 일대일은 기본 설정이 EAGER이므로 설정할 필요는 없다.

 

EAGER 설정일 때 Member와 Team을 한번씩 쿼리를 날려 찾아올 수도 있겠지만 JPA 구현체는 가능하면 조인을 해서 SQL 한번에 조회하게 된다.

 

즉시로딩 사용시 주의사항

 실무에서 이를 사용할 때 가급적이면 지연 로딩만 사용하는 것이 좋다. 그 이유는 즉시 로딩을 적용하면 의도치 않은 SQL들이 발생할 수 있는데 실무에서는 테이블이 더욱 복잡하고 Member에 수 많은 엔티티가 포함되어 수 있으므로 즉시 로딩 사용시 모든 쿼리를 예측하기가 쉽지 않다.

 

 또한, 즉시 로딩은 JPQL에서 N+1 문제를 일으키는 데 N+1 문제란 1가지의 쿼리로 인해서 N가지의 쿼리가 추가로 나가는 문제이다. 만약 member 테이블 전체를 가져오는 쿼리 `Select * from Member`를 사용하여 리스트를 받아오게 되면 한 명의 회원당 회원 엔티티에 묶여있는 하위 엔티티 모두를 조회하면서 부하가 발생할 수 있다.

 이를 해결할 수 있는 방법으로는 세 가지가 존재하는 데 첫 번째는 JPQL의 fetch join을 통해 상황에 맞게 동적으로 사용하여 해결할 수 있다. 두 번째는 EntityGraph라는 어노테이션을 사용하여 해결할 수 있고,  마지막으로는 배치 사이즈를 통해 해결할 수 있다.

 

 `@ManyToOne`, `@OneToOne`의 기본 설정은 EAGER이므로 LAZY로 설정하여 사용한다.

 

영속성 전이 : CASCADE

 영속성 전이란 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들 수 있는 것을 말한다. 즉, 부모 엔티티가 영속화 되면 영속성 전이 옵션에 따라서 자식 엔티티도 함께 저장이 되게 된다.(persist가 전이 된다는 뜻)

 

예제를 보자.

    Child child1 = new Child();
    Child child2 = new Child();

    Parent parent = new Parent();
    parent.addChild(child1);
    parent.addChild(child2);

    em.persist(parent);
    em.persist(child1);
    em.persist(child2);

 위와 같이 JPA는 데이터가 변경되면 해당 객체를 하나씩 persist 해주어야한다. 하지만 영속성 전이라는 것을 활용하게 되면 이런 수고로움이 줄어들게 된다.

 

영속성 전이 설정을 해보자. 다음과 같이 cascade 옵션을 넣어주면 된다.

@Entity
public class Parent {
...

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
    private List<Child> children = new ArrayList<>();

...
}

이렇게 설정해주면 다음 persist 코드 한줄로 관련된 객체를 모두 영속화 할 수 있다.

    em.persist(parent);

 

 

영속성 전이 주의사항

 Parent - Child 처럼 단일로 연관관계가 있는 상황에서는 영속성 전이를 사용하는데 문제가 없다. 하지만 만약 다른 곳에서 child를 사용하게 되면 Parent의 삭제로 인해 Child 도 영향을 받게 되고 Child를 사용하는 다른 엔티티에 영향이 생기므로 사용해서는 안된다.

 

 예를 들어 게시판에 들어있는 사진 링크를 관리한다고 치면 사용할 수도 있다 하지만 이 사진을 다른 엔티티가 또 관리한다면 문제가 생길 수 있으므로 사용하지 않는 것이 좋다.

 

 라이프 사이클이 유사하고 단일 소유자이며 종속적인 경우에만 사용하도록 하자.

 

영속성 전이 옵션

옵션으로 크게 ALL, PERSIST, REMOVE 이 정도를 사용하게 되는데 ALL은 모든 행동에 대해서 PERSIST는 영속화 하는 것에 대해서 REMOVE는 삭제에 대한 영속성 전이를 위해 사용된다.

 

주로 삭제에 민감한 데이터가 존재하여 위험을 감수하기 어렵다면 ALL이 아닌 PERSIST로 설정하여 삭제를 영속성 전이 대상에서 뺄 수 있다.

 

고아 객체

 고아 객체는 부모 객체와의 연관 관계가 끊어진 자식 엔티티를 말한다. 예를 들어 부모가 삭제되었을 때 남아있는 자식 객체를 고아 객체라 볼 수 있다.

 

 JPA에서는 이 고아 객체를 제거하는 옵션을 제공하는데 `orphanRemoval = true`를 사용하여 부모 객체와 끊어진 자식객체를 자동으로 인식하고 제거해줄 수 있다.

 

예제를 보자.

@Entity
public class Parent {
...
    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Child> children = new ArrayList<>();
...
}
--> transaction.
        Parent findParent = em.find(Parent.class, parent.getId());
        findParent.getChildren().remove(0);

 

고아 객체 - 주의

 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 인식하여 삭제하게 된다. 이 때 반드시 참조하는 곳이 하나일 때만 사용해야 한다.

 

 또한, 고아 객체 설정을 할 수 있는 쪽은 1인 쪽이다. 즉 `@OneToOne`, `@OneToMany`만 사용 가능하다.

 

 부모가 삭제되어 자식이 고아가 된 경우에는 고아 객체 상태이자 영속성 전이의 대상이므로 영속성 전이의 REMOVE처럼 동작하게 된다.

 

 고아 객체 제거는 특정 엔티티가 개인 소유할 때만 사용하는 것이 좋다.

 

영속성 전이 + 고아 객체, 생명주기

cascade = CascadeType.ALL, orphanRemoval = true

 

 두 옵션을 모두 사용하므로써 부모 엔티티를 통해 자식 엔티티까지 생명 주기를 관리할 수 있게 된다.

 

 이는 도메인 주도 설계(DDD)의 Aggragate Root 개념을 구현할 때 유용하다.(대략 개념 - 부모만 repo을 만들어서 Aggragate Root인 부모를 통해 통째로 생명 주기를 관리하는 방식을 추구하는 것)