Post

[JPA] 프록시, 지연로딩, 영속성 전이

[JPA] 프록시, 지연로딩, 영속성 전이

Proxy

프록시란?

실제 엔티티 대신 사용되는 가짜 객체

1
2
3
4
5
6
7
8
// DB를 통해 실제 엔티티 객체 조회
Member member = em.find(Member.class, 1L);
// DB조회를 미루는 가짜 프록시 엔티티 객체 조회
Member member = em.getReference(Member.class, 1L);  
System.out.println("member = " + member.getClass());
// member = class Member$HibernateProxy$...

member.getName();  // 이 순간 DB 조회
  • 프록시 객체에 getName() 호출
  • 프록시 객체가 실제 엔티티가 생성되지 않았으면 영속성 컨텍스트에 초기화 요청
  • 영속성 컨텍스트가 DB 조회
  • 실제 Entity 생성
  • 프록시 객체가 실제 Entity를 참조하도록 연결
  • 실제 Entity의 getName() 반환

프록시 주의사항

프록시는 한 번만 초기화 된다

1
2
3
Member refMember = em.getReference(Member.class, 1L);
refMember.getName();  // 초기화
refMember.getAge();   // 이미 초기화됨, DB 조회 X

프록시 객체가 실제 엔티티로 바뀌는 것이 아니다

1
2
3
4
5
6
7
8
Member refMember = em.getReference(Member.class, 1L);
System.out.println("before: " + refMember.getClass());
// before: Member$HibernateProxy$...

refMember.getName();  // 초기화

System.out.println("after: " + refMember.getClass());
// after: Member$HibernateProxy$...  (여전히 프록시!)
  • 프록시 객체를 통해 실제 엔티티에 접근하는 것
  • 초기화 후에도 프록시 객체는 프록시 객체

타입 비교는 == 대신 instanceof 사용

1
2
3
4
5
6
Member member1 = em.find(Member.class, 1L);      // 실제 엔티티
Member member2 = em.getReference(Member.class, 2L);  // 프록시

System.out.println(member1.getClass() == member2.getClass());  // false
System.out.println(member1 instanceof Member);  // true
System.out.println(member2 instanceof Member);  // true (권장)

영속성 컨텍스트에 찾는 엔티티가 이미 있으면 실제 엔티티 반환

1
2
3
4
Member member1 = em.find(Member.class, 1L);  // 실제 엔티티
Member member2 = em.getReference(Member.class, 1L);  // 실제 엔티티 반환!

System.out.println(member1 == member2);  // true
  • JPA는 같은 트랜잭션 안에서 == 비교 시 true를 보장
  • 이미 영속성 컨텍스트에 있으면 getReference() 도 실제 엔티티 반환

준영속 상태일 때 프록시를 초기화하면 문제 발생

1
2
3
4
5
6
Member refMember = em.getReference(Member.class, 1L);
em.detach(refMember);  // 준영속 상태
// em.clear();
// em.close();

refMember.getName();  // LazyInitializationException 발생!
  • 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태에서 프록시 초기화 시 예외 발생

지연 로딩과 즉시 로딩

지연로딩 (LAZY Loading)

연관된 엔티티를 실제 사용할 때까지 조회를 미루는 전략

1
2
3
4
5
6
7
8
9
10
@Entity
public class Member {
    @Id
    private Long id;
    private String username;
    
    @ManyToOne(fetch = FetchType.LAZY)  // 지연 로딩
    @JoinColumn(name = "team_id")
    private Team team;
}
1
2
3
4
5
6
7
8
Member member = em.find(Member.class, 1L);
// SELECT * FROM MEMBER WHERE ID = 1  (Member만 조회)

Team team = member.getTeam();  // 프록시 객체
System.out.println(team.getClass());  // Team$HibernateProxy$...

team.getName();  // 이 순간 Team 조회
// SELECT * FROM TEAM WHERE ID = ?
  • Member 조회 시 Team은 프록시 객체로 가져옴
  • team.getName() 호출 시점에 실제 Team을 DB에서 조회

즉시로딩(EAGER Loading)

연관된 엔티티를 함께 조회하는 전략

1
2
3
4
5
6
7
8
9
10
@Entity
public class Member {
    @Id
    private Long id;
    private String username;
    
    @ManyToOne(fetch = FetchType.EAGER)  // 즉시 로딩
    @JoinColumn(name = "team_id")
    private Team team;
}
1
2
3
4
5
6
7
Member member = em.find(Member.class, 1L);
// SELECT M.*, T.* FROM MEMBER M
// JOIN TEAM T ON M.TEAM_ID = T.ID
// WHERE M.ID = 1

Team team = member.getTeam();  // 이미 조회됨, 프록시 아님
System.out.println(team.getClass());  // Team (실제 엔티티)
  • Member 조회 시 Team도 함께 조회 (join 사용)
  • Team은 프록시가 아닌 실제 엔티티

LAZY, EAGER 선택 기준

1
2
3
4
5
6
7
8
9
10
// Member와 Team이 있을 때

// 경우 1: Member만 자주 사용
Member member = memberRepository.findById(1L);
// → LAZY 사용 (Team은 필요할 때만 조회)

// 경우 2: Member와 Team을 항상 함께 사용
Member member = memberRepository.findById(1L);
Team team = member.getTeam();
// → EAGER 사용? NO! 여전히 LAZY 권장

→ 가급적 LAZY만 사용하자

EAGER의 문제점

예상하지 못한 SQL 발생

1
2
3
// Member → Team, Order, Address 등 여러 관계가 EAGER면?
Member member = em.find(Member.class, 1L);
// 엄청나게 복잡한 JOIN 쿼리 발생!

JPQL에서 N+1 문제 발생

1
2
3
4
5
6
7
8
List<Member> members = em.createQuery("select m from Member m", Member.class)
    .getResultList();
// 1. SELECT * FROM MEMBER (members 조회)
// 2. SELECT * FROM TEAM WHERE ID = ? (member1의 team 조회)
// 3. SELECT * FROM TEAM WHERE ID = ? (member2의 team 조회)
// 4. SELECT * FROM TEAM WHERE ID = ? (member3의 team 조회)
// ...
// N개의 Member 조회 시 N번의 Team 조회 = N+1 문제

N+1 문제는 Fetch Join 으로 해결 가능.

1
2
3
4
5
6
7
// JPQL에서 fetch join 사용
List<Member> members = em.createQuery(
    "select m from Member m join fetch m.team", Member.class)
    .getResultList();
// SELECT M.*, T.* FROM MEMBER M
// JOIN TEAM T ON M.TEAM_ID = T.ID
// 한 방 쿼리로 해결

→ Fetch Join도 만능은 아니다. 페이징 처리나 컬렉션 페치 조인 시 주의가 필요하다.

@ManyToOne , @OneToOne 은 즉시로딩 디폴트

1
2
3
4
5
@ManyToOne  // 기본값: fetch = FetchType.EAGER
@OneToOne   // 기본값: fetch = FetchType.EAGER

@OneToMany  // 기본값: fetch = FetchType.LAZY
@ManyToMany // 기본값: fetch = FetchType.LAZY

→ 반드시 LAZY로 명시하자

1
2
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)

영속성 전이 (CASCADE)

CASCADE 란?

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶을 때 사용한다.

1
2
3
4
5
6
7
8
@Entity
public class Parent {
    @Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
    private List<Child> children = new ArrayList<>();
}

CASCADE가 없는 경우

1
2
3
4
5
6
7
8
9
10
Parent parent = new Parent();
Child child1 = new Child();
Child child2 = new Child();

parent.addChild(child1);
parent.addChild(child2);

em.persist(parent);
em.persist(child1);  // 각각 persist 해야 함
em.persist(child2);  // 각각 persist 해야 함

CASCADE가 있는 경우

1
2
3
4
5
6
7
8
Parent parent = new Parent();
Child child1 = new Child();
Child child2 = new Child();

parent.addChild(child1);
parent.addChild(child2);

em.persist(parent);  // parent만 persist하면 children도 자동 persist

CASCADE 옵션 종류

1
2
3
4
5
6
7
8
9
10
11
12
13
public enum CascadeType {
    ALL,       // 모두 적용
    PERSIST,   // 영속
    REMOVE,    // 삭제
    MERGE,     // 병합
    REFRESH,   // 새로고침
    DETACH     // 준영속
}

//주로 사용하는 옵션
//CascadeType.ALL: 모든 작업 전이
//CascadeType.PERSIST: 저장 시에만 전이
//CascadeType.REMOVE: 삭제 시에만 전이

CASCADE 사용 시점

주의 → CASCADE는 연관관계 매핑과 아무 관련 없다.

단지 엔티티를 영속화 할때 연관된 엔티티도 함께 영속화 하는 편리함을 제공

사용조건 →

  • 단일 엔티티에 완전 종속일 때 (라이프사이클이 있는 경우)
  • 소유자가 하나일 때
1
2
3
4
5
6
// 좋은 예: Parent-Child (Parent만 Child 관리)
Parent  Child (O)

// 나쁜 예: 여러 곳에서 Child 참조
Parent  Child
Member  Child  //(Child를 여러 곳에서 관리하면 안됨!)

고아 객체 (Orphan Removal)

고아 객체 제거란?

부모엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제

1
2
3
4
5
@Entity
public class Parent {
    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Child> children = new ArrayList<>();
}
1
2
3
4
Parent parent = em.find(Parent.class, 1L);
parent.getChildren().remove(0);  // 컬렉션에서 제거

// DELETE FROM CHILD WHERE ID = ?  (자동으로 삭제 쿼리 실행)

orphanRemoval vs CASCADE.REMOVE

1
2
3
4
5
6
7
8
9
10
// CascadeType.REMOVE
@OneToMany(mappedBy = "parent", cascade = CascadeType.REMOVE)
// 부모가 삭제되면 자식도 삭제

// orphanRemoval
@OneToMany(mappedBy = "parent", orphanRemoval = true)
// 부모와의 관계가 끊어지면 자식 삭제

// CASCADE.REMOVE: 부모 엔티티가 삭제될 때만 자식도 삭제
// orphanRemoval: 부모와의 연관관계가 끊어질 때도 자식 삭제

사용 조건

  • 참조하는 곳이 하나일 때만 사용해야 한다.
  • 특정 엔티티의 개인 소유일 때
1
2
3
4
5
6
// 좋은 예: 게시판 - 첨부파일
Board  File (Board만 File 관리)

// 나쁜 예: 여러 곳에서 참조
Board  File
Member  File  //(File을 여러 곳에서 참조하면 안됨!)

영속성 전이 + 고아 객체

생명주기 관리

CascadeType.ALL + orphanRemoval = true

1
2
3
4
5
6
7
@Entity
public class Parent {
    @OneToMany(mappedBy = "parent", 
               cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<Child> children = new ArrayList<>();
}
  • 부모 엔티티를 통해 자식의 생명주기를 관리할 수 있다.
  • 자식을 저장하려면 부모만 등록하면 됨 → CASCADE
  • 자식을 삭제하려면 부모에서 제거만 하면 됨 → OrphanRemoval
1
2
3
4
5
6
7
8
// 저장
Parent parent = new Parent();
Child child = new Child();
parent.addChild(child);
em.persist(parent);  // child도 자동 저장

// 삭제
parent.getChildren().remove(0);  // child 자동 삭제

→ DDD의 Aggregate Root 개념 구현 시 유용

엔티티와 값 객체가 뭉쳐있는 덩어리를 Aggregate라고 하고, 이 덩어리의 대표를 Aggregate Root라고 한다. Aggregate Root를 통해서만 하위 엔티티를 관리하는 패턴으로, CASCADE와 orphanRemoval을 함께 사용하면 이를 구현할 수 있다.
This post is licensed under CC BY 4.0 by the author.