프록시와 연관관계 관리

윤용운·2022년 5월 14일
1

JPA_스터디

목록 보기
8/9
post-thumbnail

8장. 프록시와 연관관계 정리

프록시

엔티티를 조회할 때, 연관된 엔티티들까지 항상 사용하는것은 아니기 때문에, 데이터베이스에서 한번에 조회하는것은 효율적이지 않다. JPA에서는 이런 문제를 해결하기 위해 엔티티가 실제 사용될 때, 데이터베이스 조회를 지연하는 방법을 제공하는데 이것을 지연 로딩이라고 한다.

JPA 표준 명세는 지연 로딩의 구현 방법을 JPA 구현체에 위임하였다. 하이버네이트는 지연 로딩을 지원하기 위해 프록시를 사용하는 방법과, 바이트코드를 수정하는 방법 두 가지를 제공하는데, 바이트코드를 수정하는 방법은 설정이 복잡하므로 여기서는 프록시에 대해서만 알아보겠다.

프록시 기초

JPA에서는 식별자로 엔티티를 조회할 때 EntityManager.find()를 사용한다. 해당 메소드는 영속성 컨텍스트에 엔티티가 없으면 데이터베이스를 조회한다.

Member member = em.find(Member.class, "member1");

이렇게 엔티티를 직접 조회하게 되면, 실제 사용하든 안하든 데이터베이스를 조회하게 된다. 엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶으면, EntityManeger.getReference() 메소드를 사용하면 된다.

Member member = em.getReference(Member.class, "member1");

해당 메소드를 호출할 때 JPA는 데이터베이스에서 조회를 하지 않고, 실제 엔티티 객체도 생성하지 않는 대신 데이터베이스 접근을 위임한 프록시(Proxy) 객체를 반환한다.

  • 프록시의 특징
  1. 프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같다.
  2. 프록시 객체는 실제 객체에 대한 참조(target)을 보관한다. 또한, 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.
  • 프록시 객체의 초기화
  1. 프록시 객체에 member.getName() 메소드를 호출하여 실제 데이터를 조회한다.
  2. 프록시 객체는 실제 엔티티가 생성되어 있지 않다면, 영속성 컨텍스트에 실제 엔티티 생성을 요청하는데 이것을 초기화라고 한다.
  3. 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성한다.
  4. 프록시 객체는 실제 엔티티 객체의 getName()를 호출하여 결과를 반환한다.
  • 프록시의 특징
    • 처음 사용시 한번만 초기화한다.
    • 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 프록시 객체 초기화시, 프록시 객체를 통해 실제 엔티티에 접근할 수 있다.
    • 원본 엔티티를 상속받은 객체이므로 타입 체크 시에 주의해서 사용해야 한다.
    • 영속성 컨텍스트에 찾는 엔티티가 있다면 DB를 조회할 필요가 없으므로 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환한다.
    • 초기화는 영속성 컨텍스트의 도움을 받아야 한다.

      준영속 상태의 프록시를 초기화하면 하이버네이트는 org.hibernate.LazyInitializationException 예외를 발생한다.

  • 준영속 상태의 초기화
Member member = em.getReference(Member.class, "id1");
transaction.commit();
em.close() // 영속성 컨텍스트 종료

member.getName()	// 준영속 상태의 초기화 시도
					// org.hibernate.LazyInitializationException 발생

JPA 표준 명세는 지연 로딩에 대한 내용을 JPA 구현체에 맞겼다. 따라서 준영속 상태의 엔티티를 초기화할 때 어떤 일이 발생할지 표준 명세에는 정의되어 있지 않다.

프록시와 식별자

엔티티를 프록시로 조회 시, 식별자 값(PK)를 파라미터로 전달하여 프록시 객체는 해당 식별자값을 포함하고 있다. 따라서 식별자 값을 조회하는 getId()메소드를 호출해도 프록시를 초기화하지 않는다. 단, @Access(AccessType.PROPERTY)로 엔티티 접근 방식을 설정하여야 한다.
@Access(AccessType.FIELD)로 엔티티 접근 방식을 설정하게 되면, JPA는 getId() 메소드가 id만 조회하는지, 다른 필드까지 사용하는지 알지 못하므로 프록시 객체를 초기화하게 된다.

Member member = em.find(Member.class, "member1");
Team team = em.getReference(Team.class, "team1");	// SQL이 실행되지 않음
member.setTeam(team);

연관관계 설정 시에는 식별자 값만 사용하므로 프록시를 사용하면 데이터베이스 접근 횟수를 줄일 수 있다. 참고로, 연관관계를 설정할 때는 엔티티 접근 방식을 필드로 설정해도 프록시를 초기화하지 않는다.

프록시 확인

JPA가 제공하는 PersistenceUnitUtil.isLoaded(Object entity)를 사용하면 인스턴스의 초기화 여부를 확인할 수 있다. 초기화되지 않았으면 false, 초기화되었거나 프록시 인스턴스가 아니면 true를 반환한다.

조회한 엔티티가 진짜 엔티티인지 프록시로 조회한 엔티티인지를 확인하려면 클래스명을 직접 출력해보면 된다. 클래스명에 ..javassist..라 되어있으면 프록시로 조회한 엔티티이다. 이는 프록시를 생성하는 라이브러리에 따라 달라질 수 있다.

프록시를 강제 초기화하려면 하이버네이트의 initialize() 메소드를 사용하여 강제로 초기화할 수 있다.
JPA 표준에는 프록시 강제 초기화 메소드가 없으므로, 강제로 초기화하고 싶으면 프록시의 메소드를 직접 호출하면 된다. JPA 표준은 단지 초기화 여부만 확인할 수 있다.

즉시 로딩과 지연 로딩

JPA는 개발자가 연관된 엔티티의 조회 시점을 선택할 수 있도록 다음 두가지 방법을 제공한다.

즉시 로딩(EAGER LOADING)

즉시 로딩(EAGER LOADING)을 사용하게 되면, 엔티티를 조회할 때 연관된 엔티티도 함께 조회한다.
즉시 로딩을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.EAGER로 지정하면 된다. JPA 구현체에서는 즉시 로딩을 최적화하기 위해, 가능하면 조인 쿼리를 사용한다.

즉시 로딩 시, 보통 외부 조인(LEFT OUTER JOIN)을 사용하는데, 이는 외래 키가 NULL을 허용하고 있을때이다. 하지만, 외부 조인보다는 내부 조인이 성능과 최적화에 더 유리하기 때문에, @JoinColumn 속성에 nullable = false를 설정하여 외래키에 Null을 허용하지 않는다고 알려주면 JPA는 외부 조인 대신에 내부 조인을 사용하게 된다.

지연 로딩

지연 로딩(LAZY LOADING)은 연관된 엔티티를 실제 사용할 때 조회한다.
지연 로딩을 사용하려면, @ManyToOne의 fetch 속성을 Fetch.LAZY로 설정하면 된다.

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

Member member = em.find(Member.class, "member1");
Team team = member.getTeam();	// 프록시 객체 반환
team.getName();					// 팀 실제 객체 사용

team 멤버변수를 지연로딩 하게 설정하였기 때문에, em.find(Member.class, "member1");를 호출하면 회원만 조회하고, team 멤버변수에는 프록시 객체를 넣어둔다. 이후, team.getName()을 호출하면 실제 팀 객체를 DB에서 조회한 후 사용하게 된다.

조회 대상이 이미 영속성 컨텍스트에 영속되어 있으면, 프록시 객체를 사용할 이유가 없으므로 프록시 객체가 아닌 실제 객체를 사용하게 된다.

즉시로딩, 지연로딩 정리

  • 지연로딩
    연관된 엔티티를 프록시로 조회한다. 프록시를 실제 사용할 때 초기화하면서 데이터베이스를 조회한다.
  • 즉시 로딩
    연관된 엔티티를 즉시 조회한다. 하이버네이트는 가능하면 SQL 조인을 사용해서 한번에 조회한다.

지연 로딩 활용

  • Member(회원) : Team(팀) = N : 1
  • Member(회원) : Order(주문) = 1 : N
  • Order(주문) : Product(제품) = N : 1

애플리케이션 분석을 해보면 다음과 같다고 한다.

  • Member와 연관된 Team은 자주 함께 사용되므로 즉시 로딩으로 설정한다.
  • Member와 연관된 Order은 가끔 사용되므로 지연 로딩으로 설정한다.
  • Order와 관련된 Product는 자주 함께 사용되므로 즉시 로딩으로 설정한다.

회원 엔티티의 코드를 한번 봐보자.

@Entity
public class Member {
	
    @Id
    private String id;
    private String username;
    private Integer age;
    
    @ManyToOne(fetch = FetchType.EAGER)
    private Team team;
    
    @OneToMany(mappedBy = "member1", fetch = fetchType.LAZY)
    private List<Order> orders;
    
    // Getter, Setter.....
}
  • Team
    FetchType.EAGER로 설정했으므로, 회원 엔티티를 조회하면 팀 엔티티도 즉시 조회한다.

  • Order
    FetchType.LAZY로 설정했으므로, 회원 엔티티를 조회하면 연관된 주문내역 엔티티는 프록시로 조회해서 실제 사용될 때까지 로딩을 지연한다.

프록시와 컬렉션 래퍼

  • 하이버네이트는 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬랙션으로 변경하는데, 이를 컬렉션 래퍼라고 한다.
  • 엔티티를 지연 로딩하면 프록시 객체를 사용해서 지연로딩을 수행하지만, 컬렉션은 컬렉션 래퍼가 지연로딩을 수행한다.

JPA 기본 페치 전략

fetch 속성의 기본값은 다음과 같다.

  • @ManyToOne, @OneToOne : 즉시 로딩(FetchType.EAGER)
  • @OneToMany, @ManyToMany : 지연 로딩(FetchType.LAZY)

기본적으로 연관된 엔티티가 하나면 즉시 로딩을, 컬렉션이면 지연 로딩을 사용한다. 컬렉션을 로딩하는것은 비용도 많이 들고, 잘못하면 너무 많은 데이터를 로딩할 수 있기 때문이다.

추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것이다. 추후에 개발이 어느정도 완료된 후, 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 된다.

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

  • 컬렉션을 하나보다 많이 즉시 로딩하는 것은 권장하지 않는다.
    두개 이상의 컬렉션을 즉시 로딩할 경우(A 테이블을 N, M 두개의 테이블과 조인할 경우), SQL 실행 결과가 N * M이 되면서 너무 많은 데이터를 반환할 수도 있고, 결과적으로 애플리케이션 성능이 저하될 수 있다.

  • 컬렉션 즉시 로딩은 항상 외부 조인(OUTER JOIN)을 사용한다.
    회원 테이블과 팀 테이블의 관계에서, 회원 테이블의 외래 키에 NOT NULL 제약조건을 설정하면 항상 내부 조인을 사용해도 되지만, 회원이 없는 팀 테이블에서 내부 조인을 실행하면 팀도 조회가 되지 않는 문제가 생기게 된다. 데이터베이스 제약조건으로는 이를 해결할 수 없으므로, 일대다 관계를 즉시 로딩할 때는 항상 외부 조인을 사용하는것이 바람직하다.

영속성 전이: CASCADE

특정 엔티티를 영속 상태로 만들 때, 연관된 엔티티도 함께 영속 상태로 만들고 싶으면 영속성 전이 기능을 사용하면 된다.

Parent parent = new Parent();
em.persist(parent);

Child child1 = new Child();
child1.setParent(parent);
parent.getChildren().add(child1);
em.persist(child1);

Child child2 = new Child();
child2.setParent(parent);
parent.getChildren().add(child2);
em.persist(child2);

JPA에서 엔티티를 저장할 떄 연관된 모든 엔티티는 영속상태어야 하기 때문에 부모와 자식 엔티티 모두 영속상태로 만들어야 한다. 이럴 때 영속성 전이를 사용하면 연관된 자식들까지 모두 영속상태가 된다. JPA에서는 CASCADE 옵션으로 영속성 전이를 제공한다.

영속성 전이 : 저장

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

부모를 영속화하면 CascadeType.PERSIST로 설정한 자식 엔티티까지 함께 영속화해서 저장한다. 영속성 전이는 연관관계를 매핑하는 것과는 아무 관련이 없고, 단지 영속화 할 때 연관된 엔티티도 같이 영속화하는 편리함을 제공할 뿐이므로, 양방향 연관관계를 추가한 다음 영속 상태로 만들면 된다.

영속성 전이 : 삭제

부모와 자식 엔티티를 모두 제거하려면 각각의 엔티티를 하나씩 제거해야되는데, 영속성 전이는 엔티티를 삭제할 때도 사용할 수 있다. CascadeType.REMOVE로 설정하고 다음 코드처럼 부모 엔티티만 삭제하면 연관된 자식 엔티티도 함께 삭제된다.

Parent findParent = em.find(Parent.class, 1L);
em.remove(findParent);

CascadeType.REMOVE로 설정하지 않고 이 코드를 실행하면 부모 엔티티만 삭제되고, 이로 인해 자식 테이블에 걸려 있는 외래 키 제약조건으로 인해 데이터베이스에서 외래키 무결성 예외가 발생한다.

CASCADE의 종류

public enum CascadeType {
	ALL,		// 모두 적용
    PERSIST,	// 영속
    MERGE,		// 병합
    REMOVE,		// 삭제
    REFRESH,	// REFRESH
    DETACH		// DETACH
}

다음처럼 여러 속성을 같이 사용할 수 있다.

cascade = {CascadeType.PERSIST, CascadeType.REMOVE}

CascadeType.PERSIST, CascadeType.REMOVEem.persist(), em.remove()를 실행할 때 바로 전이가 발생하지 않고 플러시를 호출할 때 전이가 발생한다.

고아 객체

JPA에서는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데, 이를 고아 객체(ORPHAN) 제거라고 한다. 해당 기능을 사용하면 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제된다.

@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Child> children = new ArrayList<Child>();

다음과 같이 설정하게 되면, parent.getChildren().remove(0); 실행시 DELETE FROM CHILD WHERE ID=?쿼리가 플러시할 때 실행되게 된다.

고아 객체 제거는 참조가 제가된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다. 따라서 해당 기능은 참조하는 곳이 하나일 때만 사용해야 한다.

부모를 삭제하면 자식은 고아가 되므로, 부모를 제거하면 자식도 같이 제거된다. 이는 CascadeType.REMOVE를 설정한 것과 같다.

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

CascadeType.ALL + orphanRemoval = true를 동시에 사용하면 어떻게 될까?

엔티티는 EntityManager.persist()를 통해 영속화되고 EntityManager.remove()를 통해 제거된다. 두 옵션을 모두 활성화하면 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다.

  1. 자식 저장시 부모에 등록하면 된다(CASCADE)
    parent.addChild(child1);
  2. 자식 삭제시 부모에서 제거하면 된다(orphanRemoval)
    parent.getChildren().remove(removeObject);

Reference

  • 자바 ORM 표준 JPA 프로그래밍 (김영한)

0개의 댓글