[JPA] 프록시와 연관관계 정리

Updated:

들어가며

해당 글은 자바 ORM 표준 프로그래밍을 정리한 글입니다.

JPA에 사용하는 프록시와 지연로딩과 즉시로딩에 대해서 알아보고 영속성 전이에 대한 개념과 영속성 전이를 이용해 고아객체를 제거하는 방법에 대해서 알아본다.

프록시

  • 엔티티를 조회할 때 연관된 엔티티들을 항상 사용하는 것이 아닐 수 있기에 필요한 연관 엔티티를 사용할 때 조회할 수 있다.
  • 지연로딩 기능을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라 한다.
  • EntityManager.getReference() 메소드를 사용해 실제 사용하는 시점까지 데이터베이스 조회를 미룰 수 있다.
@Getter
@Setter
@Entity
public class Game {
    @Id @GeneratedValue
    private Long id;

    private String name;
}
Game game = new Game();
game.setName("game");
em.persist(game);
        
em.flush(); em.clear();;

Game refGame = em.getEntityManager().getReference(Game.class, game.getId());
System.out.println(refGame.getClass().getName());
me.blog.jpa.model.proxy.Game$HibernateProxy$Quw3ynSC

프록시의 특징

프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같다. 사용하는 입장에서는 해당 객체가 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 된다.

  • 프록시 객체는 처음 사용할 때 한 번만 초기화 된다.
  • 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 프록시 객체가 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근할 수 있다.
  • 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시에 주의해서 사용해야 한다.
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 데이터베이스를 조회할 필요가 없으므로 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환한다.
  • 초기화는 영속성 컨텍스트의 도움을 받아야 가능하다. 따라서 준영속 상태의 프록시를 초기화하면 문제가 발생한다.

프록시 객체의 초기화

프록시 객체는 실제 사용 될 때 데이터베이스를 조회해서 실제 엔티티 객체를 생성하는데 이것을 프록시 객체의 초기화라 한다.

Game game = new Game();
game.setName("game");
em.persist(game);

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

Game refGame = em.getEntityManager().getReference(Game.class, game.getId());
System.out.println(refGame.getClass().getName());
System.out.println(refGame.getName()); // 해당 시점에 SELECT 실행
System.out.println(refGame.getClass().getName()); // proxy 그대로 사용
me.blog.jpa.model.proxy.Game$HibernateProxy$WXYVKbhV
...
    select
        game0_.id as id1_1_0_,
        game0_.name as name2_1_0_ 
    from
        game game0_ 
    where
        game0_.id=?
...        
me.blog.jpa.model.proxy.Game$HibernateProxy$WXYVKbhV        

프록시와 식별자

엔티티를 프록시로 조회할 때 식별자 값을 파라미터로 전달하는데 프록시 객체는 이 식별자 값을 보관한다. 따라서 식별자 값을 조회하는 경우 프록시를 초기화하지 않는다. 단 @Access(AccessType.FIELD)로 설정하는 경우에는 프록시 객체를 초기화 한다.

프록시 확인

PersistenceUnitUtil.isLoaded(Object Entity) 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 확인 할 수 있다.

  • 초기화되지 않은 프록시 인스턴스는 false를 반환

책에서는 EntityManager에서 가져오게 되어 있지만 버전이 높아져서 가져오는 방법이 바뀌었다. 😭

즉시 로딩과 지연 로딩

즉시로딩

  • 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
  • @ManyToOne(FetchType.EAGER)를 사용
  • 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용한다.

NULL 제약조건과 JPA 조인 전략

  • 즉시 로딩 실행시에 JPA가 내부 조인(INNER JOIN)이 아닌 외부 조인(LEFT OUTER JOIN)을 사용한다.
@Entity
public class Member {

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

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}
Hibernate: 
    select
        member0_.member_id as member_i1_4_0_,
        member0_.team_id as team_id2_4_0_,
        team1_.team_id as team_id1_8_1_,
        team1_.name as name2_8_1_ 
    from
        member member0_ 
    left outer join
        team team1_ 
            on member0_.team_id=team1_.team_id 
    where
        member0_.member_id=?
  • @JoinColumn(name = “TEAM_ID”, nullable = false)을 설정해서 해당 외래 키는 NULL 값을 허용하지 않는다고 알려주면 JPA는 외부 조인 대신 내부조인을 사용한다.
@Entity
public class Member {

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

    @ManyToOne
    @JoinColumn(name = "team_id", nullable = false)
    private Team team;
}
Hibernate: 
    select
        member0_.member_id as member_i1_4_0_,
        member0_.team_id as team_id2_4_0_,
        team1_.team_id as team_id1_8_1_,
        team1_.name as name2_8_1_ 
    from
        member member0_ 
    inner join
        team team1_ 
            on member0_.team_id=team1_.team_id 
    where
        member0_.member_id=?
  • @ManyToOne(fetch = FetchType.EAGER, optional = false)로 설정해도 내부 조인을 사용한다.
@Entity
public class Member {

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

    @ManyToOne(optional = false)
    @JoinColumn(name = "team_id", nullable = false)
    private Team team;
}
Hibernate: 
    select
        member0_.member_id as member_i1_4_0_,
        member0_.team_id as team_id2_4_0_,
        team1_.team_id as team_id1_8_1_,
        team1_.name as name2_8_1_ 
    from
        member member0_ 
    inner join
        team team1_ 
            on member0_.team_id=team1_.team_id 
    where
        member0_.member_id=?

지연 로딩

  • 실제 사용될 때까지 데이터 로딩을 미룬다.
  • @ManyToOne(FetchType.LAZY)를 사용

지연 로딩 활용

프록시와 컬렉션 래퍼

  • 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경하는데 이것을 컬렉션 래퍼라 한다.
  • team.getMembers()를 호출해도 컬렉션은 초기화되지 않고 컬렉션의 실제 데이터를 조회할 때 데이터베이스에서 조회해서 초기화한다.
@Test
void teamInsertAndSelect() {
  Team team = new Team();
  em.persist(team);

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

  Team result = em.find(Team.class, team.getId());
  System.out.println(result.getMembers().getClass().getName()); // JPA 컬렉션 래퍼
}
org.hibernate.collection.internal.PersistentBag

JPA 기본 페치 전략

  • @ManyToOne, @OneToOne : 즉시 로딩(FetchType.EAGER)
  • @OneToMany, @ManyToMany : 지연 로딩(FetchType.LAZY)
  • 추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것
    • 지연 로딩을 사용하다가 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하는게 좋음

컬렉션에 FetchType.EAGER 사용 시 주의점

  • 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않는다.
    • 일대다 조인의 결과는 다 쪽에 있는 수 만큼 증가하기 때문에 주의해야 한다.
  • 컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.
  • @ManyToOne, @OneToOne
    • optional = false : 내부 조인
    • optional = true : 외부 조인
  • @OneToMany, @ManyToMany
    • optional = false : 외부 조인
    • optional = true : 외부 조인

영속성 전이: CASCADE

  • JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 한다.
  • 영속성 전이를 사용하면 부모만 영속 상태로 만들면 연관된 자식까지 한 번에 영속 상태로 만들 수 있다.
  • ALL, PERSIST, MERGE, REMOVE, REFRESH, DETACH 옵션이 존재 한다.
@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @ManyToOne(optional = false, cascade = CascadeType.PERSIST)
    @JoinColumn(name = "team_id")
    private Team team;
}
@Test
void cascadeTest() {
  Team team = new Team();
  Member member = new Member();
  member.setTeam(team);
  em.persist(member); // team insert 후 member insert

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

  Member m = em.find(Member.class, member.getId());
}
Hibernate: 
    insert 
    into
        team
        (name, team_id) 
    values
        (?, ?)
        
Hibernate: 
    insert 
    into
        member
        (team_id, member_id) 
    values
        (?, ?)
        

고아 객체

  • 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제
  • orphanRemoval = true를 추가
  • 컬렉션에서 제거하면 데이터베이스의 데이터도 삭제된다. 고아 객체 제거 기능은 영속성 컨텍스트를 플러시할 때 적용되므로 플러시 시점에 DELETE SQL이 실행
@Entity
public class Team {
    @Id @GeneratedValue
    @Column(name = "team_id")
    private Long id;
    ...
    @OneToMany(mappedBy="team", orphanRemoval = true)
    private List<Member> members = new ArrayList<Member>();
}
@Test
    void orphanRemovalTest() {
        Team team = new Team();

        Member member = new Member();
        member.setTeam(team);
        em.persist(member);
        em.persist(team);

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

        Team result = em.find(Team.class, team.getId()); // team의 id를 알기 위해 조회
        em.remove(result);

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

        Team teamRes = em.find(Team.class, team.getId());
        Member memberRes = em.find(Member.class, member.getId());

        assertThat(teamRes).isNull();
        assertThat(memberRes).isNull();
    }
Hibernate: 
    insert 
    into
        member
        (team_id, member_id) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        team
        (name, team_id) 
    values
        (?, ?)
// member insert  team insert 했기 때문에 다시 member team update 필요      
Hibernate: 
    update
        member 
    set
        team_id=? 
    where
        member_id=?
...
// 고아 객체 제거
Hibernate: 
    delete 
    from
        member 
    where
        member_id=?
Hibernate: 
    delete 
    from
        team 
    where
        team_id=?                                

마치며

JPA에서 엔티티를 로딩할 때 프록시를 사용해서 로딩한다는 부분과 설정에 따라서 지연로딩, 즉시로딩 되는 것을 예제를 통해서 확인하였다. cascade를 통해서 연관된 엔티티도 한꺼번에 처리할 수 있으나 단순히 편하게 사용하기 위해서 cascadeALL을 셋팅하는 것은 부담이 될 수 있으니 주의 해야 한다.



Leave a comment