이 포스트는 김영한님의 "자바 ORM 표준 JPA 프로그래밍"을 참조하였음을 알립니다.
📕 목차
1. 프록시
2. 즉시 로딩과 지연 로딩
3. 지연 로딩 활용
4. 영속성 전이: CASCADE
5. 고아 객체(Orphan Object)
6. 영속성 전이 + 고아 객체, 생명주기
👀 Summary
• 객체 그래프 탐색을 위해 Proxy를 사용한다.
• 객체 조회 시에는 즉시 로딩과 지연 로딩이 있다.
• 객체 저장&삭제 시, 연관 객체를 함께 저장&삭제하려면 영속성 전이를 사용한다.
• 부모 객체와 연관관계가 끊어진 자식을 자동으로 삭제하려면 고아 객체 제거 기능을 사용한다.
1. 프록시
연관된 객체를 처음부터 전부 다 가져오지 않고, 실제 사용 시점에 데이터베이스에서 조회하는 방법이다.
JPA 표준 명세는 지연 로딩 구현 방법을 JPA 구현체(여기선 하이버네이트)에 위임했다.
하이퍼네이트는 지연 로딩 지원 방법으로 프록시와 바이트 코드를 수정하는 두 가지 방법을 제공한다.
📌 BASE
Member member = em.find(Member.class, "member1"); // 영속성 컨텍스트에 없으면 DB 조회
Member member = em.getReference(Member.class, "member1"); // 실제 사용 시점까지 조회 미룸
- getReference() 메서드는 Entity를 실제 사용하는 시점까지 DB 조회를 미루는 대신 프록시 객체를 반환한다.
진짜로 Proxy 객체를 가져오나 궁금해서 직접 해봤다.
@DataJpaTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class FooTest {
@Autowired
TestEntityManager testEntityManager;
EntityManager em;
@BeforeEach
void init() {
em = testEntityManager.getEntityManager();
}
@Test
@DisplayName("프록시 테스트")
void testProxy() {
Foo foo = Foo.builder()
.username("foo")
.build();
testEntityManager.persist(foo);
em.flush();
em.clear();
Foo proxy = em.getReference(Foo.class, foo.getId());
System.out.println(proxy.getClass());
}
}
![](https://blog.kakaocdn.net/dn/bOfqCa/btsnquYZ2Nm/qS8QbjszqNrOfZ4Av0yzz1/img.png)
HibernateProxy를 들고 오는 거 확인~
🟡 Proxy 특징
- Proxy 클래스는 실제 클래스를 상속 받아서 만들어진다.
- 사용자 입장에서는 진짜 객체인지, Proxy 객체인지 구분하지 않고 사용하면 된다.
- Proxy 객체는 실제 객체에 대한 참조(target)를 보관한다.
- Proxy 객체의 메서드를 호출하면, 실제 객체의 메소드를 호출하여 역할을 위임한다.
- Proxy 객체를 초기화한다고 해서 실제 Entity로 바뀌는 것이 아니므로 타입 체크에 주의해야 한다.
- 영속성 Context에 이미 실제 Entity가 있다면 Proxy가 아닌 실제 Entity를 반환한다.
🟡 Proxy 객체 초기화
Foo foo = em.getReference(Foo.class, 1);
foo.getName();
- Proxy 객체에 foo.getName()을 호출해서 실제 데이터를 조회한다.
- Proxy 객체는 영속성 Context에 실제 Entity가 없으면 생성을 요청한다. (초기화)
- 영속성 Context는 DB를 조회하여 실제 Entity 객체를 생성한다.
- Proxy 객체는 생성된 실제 Entity 객체의 참조를 Foo target 멤버 변수에 보관한다.
- Proxy 객체는 실제 Entity 객체의 getName()을 호출하여 결과를 반환한다.
여기서 주의할 점은 초기화는 영속성 Context의 도움을 받아야 가능하다는 점이다.
영속성 Context의 도움을 받을 수 없는 준영속 상태의 Proxy를 초기화하면 org.hibernate.LazyInitializationException 예외를 발생시킨다.
📌 식별자
Member member = em.find(Member.class, 1);
Team team = em.getReference(Team.class, 1); // 식별자 보관
team.getId(); // 초기화되지 않음
member.setTeam(team);
- Proxy는 식별자(pk) 값을 파라미터로 전달받아 보관하고 있다.
- 따라서, getId()를 호출해도 Proxy를 초기화하지 않고 보관한 식별자 값을 돌려준다.
- Entity 접근 방식이 @Access(AccessType.PROPERTY)라면 연관관계 설정할 때 유용하게 사용할 수 있다.
📌 프록시 확인
Foo proxy = em.getReference(Foo.class, foo.getId());
boolean isLoad = em.getEntityManagerFactory()
.getPersistenceUnitUtil().isLoaded(proxy);
System.out.println("isLoad = " + isLoad);
System.out.println("fooProxy = " + proxy.getClass().getName());
- JPA가 제공하는 PersistenceUnitUtil.isLoaded(Object entity) 메서드로 Proxy instance 초기화 여부를 알 수 있다.
- true : 초기화 되었거나 proxy가 아닌 인스턴스
- false : 초기화되지 않은 proxy
- 조회한 Entity가 진짜 Entity인지 확인해보고 싶다면 getClass()를 호출해보면 된다.
2. 즉시 로딩과 지연 로딩
📌 즉시 로딩 (FetchType.EAGER)
@Entity
public class Foo {
...
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
}
Foo foo1 = em.find(Foo.class, foo.getId());
Team team1 = foo1.getTeam();
- @ManyToOne의 fetch 속성을 FetchType.EAGER로 지정한다.
- Foo 객체 정보 조회 시, 연관관계에 있는 Team 객체 정보까지 한 번에 조회한다.
- 즉시 로딩 최적화를 위해 가능하다면 JOIN 쿼리를 사용하여 한 번의 쿼리만 실행한다.
✒️ NULL 제약 조건과 JPA 조인 전략
조인 전략 중에서 가장 성능이 좋은 것은 내부 조인(INNER JOIN)이다.
하지만, JPA는 team에 소속되지 않은 foo가 있을 수 있으므로 외부 조인(LEFT OUTER JOIN)을 사용한다.
이 경우 2가지 방법으로 대처할 수 있다.
1. @JoinColumn(name = "TEAM_ID", nullable = false)
2. @ManyToOne(fetch = FetchType.EAGER, optional = false)
둘 중 한 가지 속성을 정의해주면, JPA는 내부 조인 쿼리로 변경해서 수행한다.
📌 지연 로딩 (FetchType.LAZY)
@Entity
public class Foo {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
}
Foo foo1 = em.find(Foo.class, foo.getId());
Team team1 = foo1.getTeam();
System.out.println("team1 = " + team1.getClass());
- @ManyToOne의 fetch 속성을 FetchType.LAZY로 지정한다.
- foo 객체 조회 시 team 필드에 proxy 객체를 할당하여, 실제 사용할 때 database를 조회한다.
📌 Summary
- 둘 중 알맞은 전략을 선택할 줄 알아야 한다.
- 대부분 애플리케이션 로직에서 회원과 팀 엔티티를 같이 사용한다면 SQL 조인이 더 효율적일 것이다.
3. 지연 로딩 활용
📌 As-is
🟡 모델 분석
- 고객(Member)
- 팀(Team)
- 주문내역(Order)
- 상품정보(Product)
(기존 프로젝트에서 코드 치다가 Member 클래스가 겹쳐서 Client로 했었는데, 귀찮아서 프로젝트를 새로 파버렸다.)
🟡 애플리케이션 로직 분석 결과 (가정)
- Member와 연관된 Team은 자주 함께 사용되어, EAGER 전략을 사용했다.
- Member와 연관된 Order는 가끔 사용되어, LAZY 전략을 사용했다.
- Order와 연관된 Product는 자주 함께 사용되어, EAGER 전략을 사용했다.
🟡 회원 Entity 코드
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
private Integer age;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "team_id")
private Team team;
@OneToMany(mappedBy = "customer", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
}
- Member를 조회할 때, 연관관계가 정의된 Team도 함께 조회한다.
- Member를 조회할 때, 연관관계가 정의된 Orders 결과를 proxy로 조회한다.
📌 Collection wrapper
Member member2 = em.find(Member.class, member1.getId());
Team team1 = member2.getTeam();
List<Order> orders = member2.getOrders();
System.out.println("team = " + team1.getClass().getName());
System.out.println("orders = " + orders.getClass().getName());
[output]
team = com.example.jpa.domain.Team
orders = org.hibernate.collection.spi.PersistentBag
- 하이버네이트가 Entity를 영속화할 때, Entity 안의 Collection을 추적하고 관리할 목적으로 만드는 것
- 원본 Collection을 하이버네이트가 제공하는 내장 컬렉션(PersistentBag)으로 변경한다.
🟡 org.hibernate.collection.spi.PersistentBag
/**
* An unordered, unkeyed collection that can contain the same element
* multiple times. The Java collections API, curiously, has no {@code Bag}.
* Most developers seem to use {@code List}s to represent bag semantics,
* so Hibernate follows this practice.
*
* @apiNote Incubating in terms of making this non-internal. These contracts
* will be getting cleaned up in following releases.
*
* @author Gavin King
*/
@Incubating
public class PersistentBag<E> extends AbstractPersistentCollection<E> implements List<E> {
protected List<E> bag;
/**
* The Collection provided to a PersistentBag constructor
*/
private Collection<E> providedCollection;
...
}
- orders 같은 Collection을 Proxy 객체가 아닌, PersistentBag이 지연 로딩을 처리한다.
- PersistentBag 또한 proxy 역할을 수행하므로 이하 Proxy로 통일.
- PersistentBag이 초기화를 하는 시점
- member.getOrders()를 호출해도 Collection은 초기화되지 않는다.
- member.getOrders().get(0)처럼 실제 데이터 조회할 때 DB를 조회하여 초기화한다.
📌 JPA default fatch strategy
💡 모든 연관관계에 지연 로딩을 사용하고, 어느정도 개발 완료 단계에서 최적화용으로 즉시 로딩을 적용하라.
- @ManyToOne, @OneToOne : 즉시 로딩(FetchType.EAGER)
- @OneToMany, @ManyToMay : 지연 로딩(FetchType.LAZY)
연관된 Entity가 하나면 즉시 로딩, Collection이면 지연 로딩을 사용한다.
📌 컬렉션에 FetchType.EAGER 주의 사항
- 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않는다.
- JPA는 조회된 결과를 메모리에서 필터링해서 반환한다.
- N,M 일대다 관계에서 너무 많은 데이터를 반환하거나 성능 저하를 유발할 수 있다.
- 컬렉션 즉시 로딩은 항상 외부 조인을 사용하라.
- 외래키에 not null 제약이 있다면 항상 내부 조인을 사용해도 되지만, 아니라면 사용하지 마라.
- @ManyToOne, @OneToOne (기본 EAGER)
- (optional = false): 내부 조인
- (optional = true): 외부 조인
- @OneToMany, @ManyToMany (기본 LAZY)
- (optional = false): 외부 조인
- (optional = true): 외부 조인
4. 영속성 전이: CASCADE
💡 JPA에서 Entity를 저장할 때 연관된 모든 Entity는 영속 상태여야 한다.
JPA는 CASCADE 옵션으로 영속성 전이(transitive persistence) 기능을 제공한다.
쉽게 말해 부모 Entity 저장 시, 자식 Entity도 함께 저장할 수 있다.
만약, 영속성 전이가 없다면 부모 1, 자식 2를 저장하기 위해선 다음과 같은 코드가 필요할 것이다.
@Test
@DisplayName("Transitive Persistence 테스트")
void testTransitivePersistence() {
/* init */
Parent parent = new Parent();
em.persist(parent);
Child child1 = new Child();
child1.setParent(parent);
em.persist(child1);
Child child2 = new Child();
child2.setParent(parent);
em.persist(child2);
}
📌 영속성 전이: 저장
@Entity
@Getter
@NoArgsConstructor
public class Parent {
...
@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
private List<Child> children = new ArrayList<>();
}
@Test
@DisplayName("Transitive Persistence 테스트")
void testTransitivePersistence() {
/* init */
Parent parent = new Parent();
Child child1 = new Child();
Child child2 = new Child();
child1.setParent(parent);
child2.setParent(parent);
em.persist(parent);
}
- CascadeType.PERSIST : 부모를 영속화할 때, 연관 자식들도 함께 영속화
- 영속성 전이는 편리함을 제공하는 기능이지, 연관관계 매핑과는 관련이 없다.
📌 영속성 전이: 삭제
@OneToMany(mappedBy = "parent", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
private List<Child> children = new ArrayList<>();
em.remove(parent);
- CascadeType.REMOVE : 부모를 삭제할 때, 연관 자식들도 함께 삭제
- 부모 Entity만 삭제하면 연관된 자식 Entity도 함께 삭제할 수 있다.
- DELETE SQL을 3번 실행한다.
- 외래 키 제약 조건을 고려하여 자식을 먼저 삭제하고 부모를 삭제한다.
- REMOVE 설정이 되어 있지 않으면 부모 Entity만 사라지지만, 외래 키 제약조건으로 인해 DBMS에서 외래키 무결성 예외가 발생한다.
📌 CASCADE 종류
/**
* Defines the set of cascadable operations that are propagated
* to the associated entity.
* The value <code>cascade=ALL</code> is equivalent to
* <code>cascade={PERSIST, MERGE, REMOVE, REFRESH, DETACH}</code>.
*
* @since 1.0
*/
public enum CascadeType {
/** Cascade all operations */
ALL,
/** Cascade persist operation */
PERSIST,
/** Cascade merge operation */
MERGE,
/** Cascade remove operation */
REMOVE,
/** Cascade refresh operation */
REFRESH,
/** Cascade detach operation */
DETACH
}
5. 고아 객체(Orphan Object)
JPA는 부모 Entity Collection에서 자식 Entity 참조만 제거하면, 자식 Entity가 자동으로 삭제되는 기능을 지원한다.
@Entity
@Getter
@NoArgsConstructor
public class Parent {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Child> children = new ArrayList<>();
}
@Test
@DisplayName("Orphan Removal 테스트")
@Transactional
void testOrphanRemoval() {
/* init */
...
/* delete */
System.out.println("===== delete =====");
Parent parent1 = em.find(Parent.class, parent.getId());
System.out.println("removeChildren : " + parent1.getChildren().remove(0));
}
@AfterEach
void tearDown() {
Parent parent = em.find(Parent.class, 1);
System.out.println("check DB : " + parent.getChildren());
em.flush();
em.clear();
}
- orphanRemoval = true : Collection에서 Entity 제거 시, DB의 데이터도 삭제
- 참조가 제거된 Entity는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제한다.
- 고아 객체 제거 기능은 영속성 컨텍스트 flush 단계에서 적용된다.
- 부모를 제거하면 자식도 같이 제거되므로 CascadeType.REMOVE와 같다.
6. 영속성 전이 + 고아 객체, 생명주기
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
- 자식을 저장하려면 부모에 등록만 하면 된다. (CASCADE)
- 자식을 삭제하려면 부모에서 제거하면 된다. (orphanRemoval)
즉, 두 옵션을 모두 제공하면 부모 Entity에서 자식의 Life-Cycle을 관리할 수 있다.