프록시
- 엔티티를 조회할 때 연관된 엔티티들이 항상 사용되는 것이 아님
- 연관된 엔티티들이 사용되지 않음에도 불구하고 데이터베이스에서 연관된 엔티티를 함께 조회해 두는 것은 효율적이지 않음
- JPA는 위와 같은 문제를 해결하기 위해 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데 이것을 지연 로딩이라고 부름
- 지연 로딩 기능을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라 부름
1. 프록시 기초
- JPA에서 식별자로 엔티티 하나를 조회할 때는 `EntityManager.find()`를 사용
- 해당 메서드는 영속성 컨텍스트에 엔티티가 없을 경우 데이터베이스를 조회
- 이렇게 엔티티를 직접 조회할 경우 조회한 엔티티를 실제 사용하든 사용하지 않든 데이터베이스를 조회하게 됨
- 엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶을 경우 `EntityManager.getReference()` 메서드를 사용하면 됨
- 해당 메서드를 호출할 때 JPA는 데이터베이스를 조회하지 않고 실제 엔티티 객체도 생성하지 않음
- 대신 데이터베이스 접근을 위임한 프록시 객체를 반환함
- 프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같기 때문에 사용하는 입장에서는 이것이 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 됨
- 프록시 객체는 실제 객체에 대한 참조 (target)을 보과하고 프록시 객체의 메서드를 호출하면 프록시 객체는 실제 객체의 메서드를 호출함
- 프록시 객체는 getName()처럼 실제 사용될 때 데이터베이스를 조회해서 실제 엔티티 객체를 생성하는데 이것을 프록시 객체의 초기화라고 부름
프록시 초기화 과정
- 프록시 객체에 member.getName()을 호출해서 실제 데이터 조회
- 프록시 객체는 실제 엔티티가 생성되어 있지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청하는데 이것을 프록시 초기화라고 부름
- 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성
- 프록시 객체는 생성된 실제 엔티티 객체의 참조를 Member target 멤버 변수에 보관
- 프록시 객체는 실제 엔티티 객체의 getName()을 호출해서 결과 반환
1.1 프록시 특징
프록시의 특징은 다음과 같습니다.
- 프록시 객체는 처음 사용할 때 한 번만 초기화
- 프록시 객체를 초기화한다고 프록시 객체가 실제 엔티티로 바뀌는 것은 아님
- 프록시 객체가 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근할 수 있음
- 프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시 주의해서 사용해야 함
- 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 데이터베이스를 조회할 필요가 없으므로 em.getReference()를 호출해도 프록시가 아닌 실제 엔티티를 반환
- 프록시 초기화는 영속성 컨텍스트의 도움을 받아야 가능하기 때문에 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태 (Detached)의 프록시를 초기화하면 LazyInitializationException 예외를 발생시킴
2. 프록시와 식별자
- 엔티티를 프록시로 조회할 때 식별자 (PK) 값을 파라미터로 전달하는데 프록시 객체는 해당 식별자 값을 보관함
- 프록시 객체는 식별자를 가지고 있으므로 식별자 값을 조회하는 getId() 메서드를 호출해도 프록시를 초기화하지 않음
- 단, 엔티티 접근 방식을 프로퍼티 (@Access(AccessType.PROPERTY))로 설정한 경우에만 초기화하지 않음
- 엔티티 접근 방식을 필드 (@Access(AccessType.FIELD))로 설정하면 JPA는 getId() 메서드가 id만 조회하는 메서드인지 다른 필드까지 활용해서 어떤 일을 하는 메서드인지 알지 못하므로 프록시 객체를 초기화함
- 연관관계를 설정할 때는 식별자 값만 사용하므로 프록시를 사용하면 데이터베이스 접근 횟수를 줄일 수 있음
- 연관관계를 설정할 때는 엔티티 접근 방식을 필드로 설정해도 프록시를 초기화하지 않음
3. 프록시 확인
- JPA가 제공하는 `PersistenceUnitUtil.isLoaded(Object entity)` 메서드를 사용하면 프록시 인스턴스의 초기화 여부를 확인할 수 있음
- 아직 초기화되지 않은 프록시 인스턴스는 false를 반환
- 이미 초기화되었거나 프록시 인스턴스가 아니면 true를 반환
- 클래스명을 직접 출력해 보면 조회한 엔티티가 진짜 엔티티인지 프록시로 조회한 것인지 확인 가능
- 프록시를 생성하는 라이브러리에 따라 출력 결과는 달라질 수 있음
즉시 로딩과 지연 로딩
- 프록시 객체는 주로 연관된 엔티티를 지연 로딩할 때 사용
- JPA는 개발자가 연관된 엔티티의 조회 시점을 선택할 수 있도록 다음 두 가지 방법을 제공
- 즉시 로딩: 엔티티를 조회할 때 연관된 엔티티도 함께 조회
- 지연 로딩: 연관된 엔티티를 실제 사용할 때 조회
1. 즉시 로딩 (EAGER Loading)
- 즉시 로딩을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.EAGER로 지정
부연 설명
- 회원과 팀을 즉시 로딩으로 설정했으므로 `em.find(Member.class, "member1")`로 회원을 조회하는 순간 팀도 함께 조회
- 회원과 팀 두 테이블을 조회해야 하므로 쿼리를 두 번 실행할 것 같지만, 대부분의 JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리 사용
2. 지연 로딩 (LAZY Loading)
- 지연 로딩을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.LAZY로 지정
부연 설명
- 회원과 팀을 지연 로딩으로 설정했으므로 `em.find(Member.class, "member1")`를 호출하면 회원만 조회하고 팀은 조회하지 않는 대신 조회한 회원의 team 멤버 변수 프록시 객체를 넣어둠
- `Team team = member.getTeam();`을 호출했을 때 반환된 팀 객체는 프록시 객체이며 실제 사용될 때까지 데이터 로딩을 미룸
- `team.getName()`처럼 실제 데이터가 필요한 순간이 되어서야 데이터베이스를 조회해서 프록시 객체 초기화를 진행
3. 즉시 로딩, 지연 로딩 정리
- 처음부터 연관된 엔티티를 모두 영속성 컨텍스트에 올려두는 것은 현실적이지 않지만
- 필요할 때마다 SQL을 실행해서 연관된 엔티티를 지연 로딩하는 것 또한 최적화 관점에서 보면 꼭 좋은 것만은 아님
- 결국 연관된 엔티티를 즉시 로딩하는 것이 좋은지 아니면 실제 사용할 때까지 지연해서 로딩하는 것이 좋은지는 상황에 따라 다름
- 지연 로딩과 즉시 로딩을 간단히 정리하면 다음과 같음
- 지연 로딩: 연관된 엔티티를 프록시로 조회하며 프록시를 실제 사용할 때 초기화하면서 데이터베이스를 조회
- 즉시 로딩: 연관된 엔티티를 즉시 조회하며 Hibernate는 가능하면 SQL 조인을 사용해서 한 번에 조회
지연 로딩 활용
다음과 같이 사내 주문 관리 시스템을 개발한다고 가정하겠습니다.
- 회원 (Member)은 팀 (Team) 하나에만 소속될 수 있음 (N:1)
- 회원 (Member)은 여러 주문내역 (Order)을 가짐 (1:N)
- 주문내역 (Order)은 상품정보 (Product)를 가짐 (N:1)
애플리케이션 로직은 다음과 같다고 가정하겠습니다.
- Member와 연관된 Team은 자주 함께 사용되기 때문에 즉시 로딩으로 설정
- Member와 연관된 Order는 가끔 사용되므로 지연 로딩으로 설정
- Order와 연관된 Product는 자주 함께 사용되었으므로 Order와 Product는 즉시 로딩으로 설정
부연 설명
- 회원과 팀의 연관관계를 FetchType.EAGER로 설정했기 때문에 회원 엔티티를 조회하면 연관된 팀 엔티티도 즉시 조회됨
- 회원을 조회한 뒤 `member.getTeam()`을 호출하면 이미 로딩된 팀 엔티티를 반환함
- 회원과 주문내역의 연관관계를 FetchType.LAZY로 설정했으므로 회원 엔티티를 조회할 경우 연관된 주문내역 엔티티는 프록시로 조회해서 실제 사용될 때까지 로딩을 지연함
- 회원 엔티티를 조회하면 아래 SQL이 호출되고 그림처럼 엔티티를 로딩함
1. 프록시와 컬렉션 래퍼
하이버네이트는 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경하는데 이것을 컬렉션 래퍼라 부릅니다.
부연 설명
- 출력 결과를 보면 컬렉션 래퍼인 org.hibernate.collection.internal.PersistentBag이 반환된 것을 확인 가능
엔티티를 지연 로딩하면 프록시 객체를 사용해서 지연 로딩을 수행하지만 주문내역 같은 컬렉션은 컬렉션 래퍼가 지연 로딩을 처리해 줍니다.
- 참고로 member.getOrders()를 호출해도 컬렉션은 초기화되지 않고 컬렉션은 member.getOrders().get(0)처럼 컬렉션에서 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화함
- 앞서 시스템 설계에서 주문내역과 상품의 로딩 방법을 FetchType.EAGER로 설정했으므로 만약 member.getOrders().get(0)을 호출할 경우 주문내역을 초기화할 때 연관된 상품도 함께 로딩됨
2. JPA 기본 Fetch 전략
- 컬렉션을 로딩하는 것은 비용이 많이 들고 잘못하면 너무 많은 데이터를 로딩할 수 있기 때문에 JPA의 기본 fetch 전략은 연관된 엔티티가 하나면 즉시 로딩을, 컬렉션이면 지연 로딩을 사용
- @ManyToOne, @OneToOne: 즉시 로딩
- @OneToMany, @ManyToMany: 지연 로딩
- 권장하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것
- 애플리케이션 개발이 어느 정도 완료단계에 왔을 때 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 됨
3. 컬렉션에 FetchType.EAGER 사용 시 주의점
- 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않음
- 컬렉션과 조인한다는 것은 데이터베이스 테이블로 보면 일대다 조인이며 일대다 조인은 결과 데이터가 다 쪽에 있는 수만큼 증가하게 됨
- 서로 다른 컬렉션을 두 개 이상 조인할 때 너무 많은 데이터가 반환될 수 있고 결과적으로 애플리케이션 성능이 저하될 수 있음
- JPA는 이렇게 조회된 결과를 메모리에 필터링해서 반환하기 때문에 두 개 이상의 컬렉션을 즉시 로딩으로 설정하는 것을 권장하지 않음
- 컬렉션 즉시 로딩은 항상 OUTER JOIN을 사용
- 다대일 관계인 회원 테이블과 팀 테이블을 조인할 때 회원 테이블의 외래 키에 NOT NULL 제약조건을 걸어둘 경우 모든 회원은 항상 팀에 소속되므로 INNER JOIN을 사용해도 되지만 반대로 팀 테이블에서 회원 테이블로 일대다 관계를 조인할 때 회원이 한 명도 없는 팀을 INNER JOIN 하면 팀까지 조회되지 않는 문제 발생
- 데이터베이스 제약조건으로 이런 상황을 방지할 수 없으므로 JPA는 일대다 관계를 즉시 로딩할 때 항상 외부 조인을 사용
영속성 전이: CASCADE
- 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶을 경우 영속성 전이 (Transitive Persistence) 기능을 사용하면 됨
- JPA는 CASCASDE 옵션을 통해 영속성 전이를 제공
- 영속성 전이를 사용하면 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장할 수 있음
이해를 위해 부모 엔티티가 여러 자식 엔티티를 가지고 있다고 가정해 보겠습니다.
만약 부모 1명에 자식 두 명을 저장한다면 다음과 같이 코드를 작성해야 합니다.
- JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 하기 때문에 부모 엔티티를 영속 상태로 만들고 자식 엔티티도 각각 영속 상태로 만듦
1. 영속성 전이: 저장
- 부모를 영속화할 때 연관된 자식들도 함께 영속화하기 위해 cascade = CascadeType.PERSIST 옵션을 설정할 수 있음
- 해당 옵션을 적용하면 간편하게 부모와 자식 엔티티를 한 번에 영속화할 수 있음
부연 설명
- 부모만 영속화하면 CascadeType.PERSIST로 설정한 자식 엔티티까지 함께 영속화해서 저장함
- 영속성 전이는 연관관계를 매핑하는 것과는 아무 관련이 없고 단지 엔티티를 영속화할 때 연관된 엔티티를 같이 영속화하는 편리함을 제공함
2. 영속성 전이: 삭제
- 영속성 전이는 엔티티를 삭제할 때도 사용 가능
- CascadeType.REMOVE로 설정했을 경우 엔티티만 삭제하면 연관된 자식 엔티티도 함께 삭제함
부연 설명
- 영속성 전이를 적용한 뒤 부모를 삭제하는 코드를 실행하면 DELETE SQL을 세 번 실행하고 부모는 물론 연관된 자식도 모두 삭제함
- 삭제 순서는 외래 키 제약조건을 고려해서 자식을 먼저 삭제하고 부모를 삭제함
- 반면, CascadeType.REMOVE를 설정하지 않고 부모만 삭제하는 코드를 실행하면 부모 엔티티만 삭제됨
- 하지만 데이터베이스의 부모 row를 삭제하는 순간 자식 테이블에 걸려 있는 외래 키 제약조건으로 인해, 데이터베이스에서 외래 키 무결성 예외가 발생함
3. CASCADE의 종류
CascadeType 코드를 보면 다양한 옵션이 있는 것을 확인 가능합니다.
다음처럼 여러 속성을 같이 사용할 수 있습니다.
고아 객체 (Orphan)
- JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이것을 고아 객체 제거라 함
- 개념적으로 볼 때 부모를 제거하면 자식은 고아가 되므로 부모를 제거했을 때 자식도 같이 제거됨
부연 설명
- 고아 객체 제거 기능을 활성화하기 위해 컬렉션에 orphanRemoval = true로 설정했으므로 컬렉션에서 제거한 엔티티는 자동으로 삭제됨
아래 코드를 실행했을 때 호출되는 SQL은 다음과 같습니다.
부연 설명
- 컬렉션에서 첫 번째 자식을 제거했으며 orphanRemoval = true 옵션으로 인해 컬렉션에서 엔티티를 제거하면 데이터베이스의 데이터도 삭제됨
- 고아 객체 제거 기능은 영속성 컨텍스트를 flush 할 때 적용되므로 flush 시점에 DELETE SQL이 실행됨
정리하면 고아 객체 제거는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능입니다.
- 따라서 해당 기능은 참조되는 곳이 하나일 때만 사용해야 됨
- 만약 삭제한 엔티티를 다른 곳에서도 참조하면 문제가 발생할 수 있으므로 orphanRemoval은 @oneToOne과 @OneToMany에만 사용 가능
영속성 전이 + 고아 객체, 생명주기
- CascadeType.ALL과 orphanRemoval = true를 동시에 사용하면 부모 엔티티를 통해서 자식 엔티티의 생명주기를 관리할 수 있음
- 자식을 저장할 때 부모에 등록만 하면 됨 (CASCADE)
- 자식을 삭제할 때 부모에서 제거하면 됨 (orphanRemoval)
참고
자바 ORM 표준 JPA 프로그래밍 - 김영한 저
'DB > 자바 ORM 표준 JPA 프로그래밍' 카테고리의 다른 글
[10장] 객체지향 쿼리 언어 (0) | 2025.03.09 |
---|---|
[9장] 값 타입 (0) | 2025.03.04 |
[7장] 고급 매핑 (0) | 2025.02.28 |
[6장] 다양한 연관관계 매핑 (0) | 2025.02.27 |
[5장] 연관관계 매핑 기초 (1) | 2025.02.24 |