Chapter 10. 프록시와 연관관계 관리

Chapter 10. 프록시와 연관관계 관리

JPA의 프록시, 즉시/지연 로딩, 영속성 전이(CASCADE), 고아 객체 제거를 다룬다.


1. 프록시

1.1 프록시 기초

프록시는 실제 엔티티 대신 데이터베이스 조회를 지연할 수 있는 가짜 객체이다.

┌─────────────────────────────────────────────────────────────────────────────┐
│                    em.find() vs em.getReference()                           │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   em.find(Member.class, id)          em.getReference(Member.class, id)      │
│           │                                     │                           │
│           ▼                                     ▼                           │
│   ┌───────────────────┐              ┌───────────────────┐                  │
│   │  데이터베이스 조회 │              │  프록시 객체 반환  │                  │
│   │   (즉시 조회)      │              │  (조회 지연)       │                  │
│   └─────────┬─────────┘              └─────────┬─────────┘                  │
│             ▼                                  │                            │
│   ┌───────────────────┐                        │ 실제 사용 시               │
│   │   실제 Member     │                        ▼                            │
│   │   엔티티 반환     │              ┌───────────────────┐                  │
│   └───────────────────┘              │  초기화 후        │                  │
│                                      │  실제 엔티티 접근 │                  │
│                                      └───────────────────┘                  │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
// 즉시 조회 - DB 조회 발생
Member member = em.find(Member.class, "member1");

// 지연 조회 - 프록시 반환, DB 조회 X
Member member = em.getReference(Member.class, "member1");

1.2 프록시 구조

┌─────────────────────────────────────────────────────────────────────────────┐
│                          프록시 클래스 구조                                  │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   ┌─────────────────────┐                                                   │
│   │       Member        │  ← 실제 엔티티 클래스                             │
│   │─────────────────────│                                                   │
│   │ id                  │                                                   │
│   │ name                │                                                   │
│   │─────────────────────│                                                   │
│   │ getId()             │                                                   │
│   │ getName()           │                                                   │
│   └──────────▲──────────┘                                                   │
│              │ extends                                                      │
│   ┌──────────┴──────────┐                                                   │
│   │    MemberProxy      │  ← 프록시 클래스 (상속)                           │
│   │─────────────────────│                                                   │
│   │ Member target       │  ← 실제 엔티티 참조                               │
│   │─────────────────────│                                                   │
│   │ getId()             │  → target.getId()                                 │
│   │ getName()           │  → 초기화 후 target.getName()                     │
│   └─────────────────────┘                                                   │
│                                                                             │
│   ※ 프록시는 실제 클래스를 상속받아 겉모양이 같음                           │
│   ※ 사용자는 프록시인지 실제 객체인지 구분 없이 사용                        │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

1.3 프록시 초기화 과정

┌─────────────────────────────────────────────────────────────────────────────┐
│                         프록시 초기화 과정                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   클라이언트              MemberProxy           영속성 컨텍스트      DB     │
│       │                       │                       │            │       │
│       │  1. getName()         │                       │            │       │
│       │─────────────────────► │                       │            │       │
│       │                       │                       │            │       │
│       │                       │  2. 초기화 요청        │            │       │
│       │                       │ (target == null)      │            │       │
│       │                       │─────────────────────► │            │       │
│       │                       │                       │  3. DB조회 │       │
│       │                       │                       │──────────► │       │
│       │                       │                       │ ◄──────────│       │
│       │                       │  4. 실제 엔티티 생성   │            │       │
│       │                       │ ◄─────────────────────│            │       │
│       │                       │                       │            │       │
│       │                       │ target = realMember   │            │       │
│       │                       │                       │            │       │
│       │  5. target.getName()  │                       │            │       │
│       │ ◄─────────────────────│                       │            │       │
│       │                       │                       │            │       │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
class MemberProxy extends Member {
    Member target = null;  // 실제 엔티티 참조

    public String getName() {
        if (target == null) {
            // 초기화 요청 → 영속성 컨텍스트 → DB 조회 → 실제 엔티티 생성
            this.target = /* 실제 Member 엔티티 */;
        }
        return target.getName();  // 실제 엔티티의 메서드 호출
    }
}

1.4 프록시 특징

┌─────────────────────────────────────────────────────────────────────────────┐
│                           프록시 특징 정리                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   1. 한 번만 초기화                                                         │
│      └─ 처음 사용할 때 한 번만 초기화됨                                     │
│                                                                             │
│   2. 프록시 ≠ 실제 엔티티                                                   │
│      └─ 초기화해도 프록시 객체가 실제 엔티티로 바뀌지 않음                  │
│      └─ 프록시를 통해 실제 엔티티에 접근하는 것                             │
│                                                                             │
│   3. 타입 체크 주의                                                         │
│      └─ == 비교 대신 instanceof 사용                                        │
│      └─ member.getClass() == Member.class  (X)                              │
│      └─ member instanceof Member           (O)                              │
│                                                                             │
│   4. 영속성 컨텍스트에 엔티티 존재 시                                        │
│      └─ getReference()도 실제 엔티티 반환                                   │
│                                                                             │
│   5. 준영속 상태에서 초기화 불가                                            │
│      └─ LazyInitializationException 발생                                    │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
// 준영속 상태에서 초기화 시도 → 예외 발생
Member member = em.getReference(Member.class, "id1");
em.close();  // 영속성 컨텍스트 종료

member.getName();  // LazyInitializationException 발생!

1.5 프록시와 식별자

// 프록시는 식별자(PK)를 보관
Team team = em.getReference(Team.class, "team1");
team.getId();  // 초기화 안됨 (이미 식별자 보유)

// 연관관계 설정 시 프록시 활용 (DB 접근 감소)
Member member = em.find(Member.class, "member1");
Team team = em.getReference(Team.class, "team1");  // SQL 실행 X
member.setTeam(team);  // 식별자만 사용하므로 초기화 X

1.6 프록시 확인 유틸리티

// 초기화 여부 확인
boolean isLoaded = emf.getPersistenceUnitUtil().isLoaded(entity);

// 프록시 클래스 확인
System.out.println(member.getClass().getName());
// 출력: jpabook.Member$HibernateProxy$...

// 프록시 강제 초기화 (Hibernate)
Hibernate.initialize(member);

// 또는 메서드 호출로 초기화
member.getName();

2. 즉시 로딩과 지연 로딩

2.1 개념 비교

┌─────────────────────────────────────────────────────────────────────────────┐
│                     즉시 로딩 vs 지연 로딩                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   [즉시 로딩 - FetchType.EAGER]                                             │
│                                                                             │
│   Member member = em.find(Member.class, id);                                │
│                                                                             │
│   실행 SQL:                                                                 │
│   SELECT M.*, T.*                                                           │
│   FROM MEMBER M                                                             │
│   LEFT JOIN TEAM T ON M.TEAM_ID = T.ID                                      │
│   WHERE M.ID = ?                                                            │
│                                                                             │
│   결과: Member + Team 함께 조회                                             │
│                                                                             │
│   ─────────────────────────────────────────────────────────────────────     │
│                                                                             │
│   [지연 로딩 - FetchType.LAZY]                                              │
│                                                                             │
│   Member member = em.find(Member.class, id);                                │
│                                                                             │
│   실행 SQL (1차):                                                           │
│   SELECT * FROM MEMBER WHERE ID = ?                                         │
│                                                                             │
│   member.getTeam().getName();  // 팀 실제 사용 시                           │
│                                                                             │
│   실행 SQL (2차):                                                           │
│   SELECT * FROM TEAM WHERE ID = ?                                           │
│                                                                             │
│   결과: Member 먼저, Team은 프록시 → 실제 사용 시 조회                      │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

2.2 설정 방법

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String username;

    // 즉시 로딩 - 회원 조회 시 팀도 함께 조회
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    // 지연 로딩 - 주문 조회 시점까지 로딩 지연
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
}

2.3 JPA 기본 페치 전략

연관관계기본 페치 전략이유
@ManyToOneEAGER연관 엔티티 하나
@OneToOneEAGER연관 엔티티 하나
@OneToManyLAZY컬렉션 (데이터 많음)
@ManyToManyLAZY컬렉션 (데이터 많음)

권장: 모든 연관관계에 지연 로딩(LAZY) 사용 후 필요한 곳만 즉시 로딩 적용

2.4 즉시 로딩과 조인 전략

┌─────────────────────────────────────────────────────────────────────────────┐
│                    즉시 로딩 시 조인 전략                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   nullable 설정에 따른 조인 전략:                                           │
│                                                                             │
│   @JoinColumn(nullable = true)   →  LEFT OUTER JOIN (기본값)                │
│   @JoinColumn(nullable = false)  →  INNER JOIN                              │
│                                                                             │
│   또는                                                                      │
│                                                                             │
│   @ManyToOne(optional = true)    →  LEFT OUTER JOIN (기본값)                │
│   @ManyToOne(optional = false)   →  INNER JOIN                              │
│                                                                             │
│   ─────────────────────────────────────────────────────────────────────     │
│                                                                             │
│   이유:                                                                     │
│   • nullable = true: 외래키 NULL 가능 → 외부 조인 필요                      │
│   • nullable = false: 외래키 NOT NULL 보장 → 내부 조인 가능                 │
│                                                                             │
│   ※ 내부 조인이 성능상 유리                                                 │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
@Entity
public class Member {
    // 내부 조인 사용 (TEAM_ID가 항상 존재)
    @ManyToOne(fetch = FetchType.EAGER, optional = false)
    @JoinColumn(name = "TEAM_ID", nullable = false)
    private Team team;
}

2.5 컬렉션에 EAGER 사용 시 주의점

┌─────────────────────────────────────────────────────────────────────────────┐
│                 컬렉션 즉시 로딩 주의사항                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   1. 컬렉션 2개 이상 즉시 로딩 권장하지 않음                                │
│                                                                             │
│      A 테이블 ─┬─ 1:N ─ B 테이블 (N개)                                      │
│                └─ 1:N ─ C 테이블 (M개)                                      │
│                                                                             │
│      결과 행 수: N × M (카테시안 곱)                                        │
│      → 애플리케이션 성능 저하                                               │
│                                                                             │
│   2. 컬렉션 즉시 로딩은 항상 OUTER JOIN                                     │
│                                                                             │
│      • 1:N 관계에서 자식이 없는 부모 조회 가능해야 함                       │
│      • DB 제약조건으로 막을 수 없음                                         │
│                                                                             │
│   ─────────────────────────────────────────────────────────────────────     │
│                                                                             │
│   FetchType.EAGER 조인 전략 정리:                                           │
│                                                                             │
│   @ManyToOne, @OneToOne                                                     │
│     optional = false  →  INNER JOIN                                         │
│     optional = true   →  OUTER JOIN                                         │
│                                                                             │
│   @OneToMany, @ManyToMany                                                   │
│     항상 OUTER JOIN (optional 무관)                                         │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

2.6 컬렉션 래퍼

하이버네이트는 컬렉션을 추적/관리하기 위해 내장 컬렉션으로 변경한다.

Member member = em.find(Member.class, id);

// 하이버네이트 내장 컬렉션으로 래핑됨
System.out.println(member.getOrders().getClass().getName());
// 출력: org.hibernate.collection.internal.PersistentBag

// 컬렉션 래퍼가 지연 로딩 처리
member.getOrders().get(0);  //  시점에 SQL 실행

3. 영속성 전이 (CASCADE)

부모 엔티티 저장/삭제 시 연관된 자식 엔티티도 함께 저장/삭제한다.

3.1 CASCADE 옵션 종류

옵션설명
ALL모든 옵션 적용
PERSIST영속 (저장)
REMOVE삭제
MERGE병합
REFRESH새로고침
DETACH준영속

3.2 영속성 전이: 저장 (PERSIST)

┌─────────────────────────────────────────────────────────────────────────────┐
│                     CASCADE.PERSIST 동작                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   [CASCADE 없음]                      [CASCADE.PERSIST]                     │
│                                                                             │
│   Parent parent = new Parent();       Parent parent = new Parent();         │
│   Child child1 = new Child();         Child child1 = new Child();           │
│   Child child2 = new Child();         Child child2 = new Child();           │
│                                                                             │
│   parent.addChild(child1);            parent.addChild(child1);              │
│   parent.addChild(child2);            parent.addChild(child2);              │
│                                                                             │
│   em.persist(parent);                 em.persist(parent);                   │
│   em.persist(child1);  // 필요        // child1, child2 자동 영속           │
│   em.persist(child2);  // 필요                                              │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
@Entity
public class Parent {
    @Id @GeneratedValue
    private Long id;

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

    public void addChild(Child child) {
        children.add(child);
        child.setParent(this);
    }
}

@Entity
public class Child {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Parent parent;
}

// 사용
Parent parent = new Parent();
Child child1 = new Child();
Child child2 = new Child();

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

em.persist(parent);  // child1, child2도 함께 영속화

3.3 영속성 전이: 삭제 (REMOVE)

@Entity
public class Parent {
    @OneToMany(mappedBy = "parent", cascade = CascadeType.REMOVE)
    private List<Child> children = new ArrayList<>();
}

// 사용
Parent parent = em.find(Parent.class, parentId);
em.remove(parent);  // 자식 엔티티도 함께 삭제

// 실행 SQL (삭제 순서: 자식 → 부모)
// DELETE FROM CHILD WHERE ID = ?
// DELETE FROM CHILD WHERE ID = ?
// DELETE FROM PARENT WHERE ID = ?

주의: CASCADE.REMOVE가 없으면 외래키 무결성 예외 발생


4. 고아 객체 (orphanRemoval)

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

┌─────────────────────────────────────────────────────────────────────────────┐
│                        고아 객체 제거 동작                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   Parent                                                                    │
│   ┌─────────────────────┐                                                   │
│   │ children:           │                                                   │
│   │   [Child1, Child2]  │                                                   │
│   └─────────────────────┘                                                   │
│                                                                             │
│   parent.getChildren().remove(0);  // Child1 제거                           │
│                                                                             │
│   Parent                           DB                                       │
│   ┌─────────────────────┐          DELETE FROM CHILD                        │
│   │ children:           │    →     WHERE ID = ? (Child1)                    │
│   │   [Child2]          │                                                   │
│   └─────────────────────┘                                                   │
│                                                                             │
│   컬렉션에서 제거 → 고아 객체로 인식 → 자동 DELETE                          │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
@Entity
public class Parent {
    @Id @GeneratedValue
    private Long id;

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

// 사용
Parent parent = em.find(Parent.class, parentId);
parent.getChildren().remove(0);  // 첫 번째 자식 제거 → DELETE SQL 실행

// 모든 자식 제거
parent.getChildren().clear();  // 모든 자식 DELETE

4.1 고아 객체 주의사항

┌─────────────────────────────────────────────────────────────────────────────┐
│                       고아 객체 주의사항                                     │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   1. 참조하는 곳이 하나일 때만 사용                                         │
│      └─ 특정 엔티티가 개인 소유하는 경우에만 사용                           │
│      └─ 다른 곳에서도 참조하면 문제 발생                                    │
│                                                                             │
│   2. @OneToOne, @OneToMany에서만 사용 가능                                  │
│                                                                             │
│   3. 부모 제거 시 자식도 함께 제거됨                                        │
│      └─ CascadeType.REMOVE와 동일한 효과                                    │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

5. 영속성 전이 + 고아 객체 = 생명주기 관리

CascadeType.ALL + orphanRemoval = true를 함께 사용하면 부모 엔티티를 통해 자식의 생명주기를 관리할 수 있다.

@Entity
public class Parent {
    @Id @GeneratedValue
    private Long id;

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

    public void addChild(Child child) {
        children.add(child);
        child.setParent(this);
    }
}

// 자식 저장 - 부모에 추가만 하면 됨
Parent parent = em.find(Parent.class, parentId);
parent.addChild(new Child());  // INSERT 발생

// 자식 삭제 - 부모에서 제거만 하면 됨
parent.getChildren().remove(child);  // DELETE 발생

// 부모 삭제 - 자식도 함께 삭제
em.remove(parent);  // 부모 + 모든 자식 DELETE
┌─────────────────────────────────────────────────────────────────────────────┐
│                        생명주기 관리 비교                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   [일반적인 엔티티]                                                         │
│   • em.persist()로 영속화                                                   │
│   • em.remove()로 제거                                                      │
│   • 엔티티 스스로 생명주기 관리                                             │
│                                                                             │
│   [CascadeType.ALL + orphanRemoval = true]                                  │
│   • 부모.addChild()로 영속화                                                │
│   • 부모.getChildren().remove()로 제거                                      │
│   • 부모 엔티티가 자식의 생명주기 관리                                      │
│                                                                             │
│   → DDD의 Aggregate Root 개념 구현에 유용                                   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

6. 핵심 요약

┌─────────────────────────────────────────────────────────────────────────────┐
│                    프록시와 연관관계 관리 요약                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   프록시                                                                    │
│   ├── em.getReference(): 프록시 반환 (DB 조회 지연)                         │
│   ├── 초기화: 실제 사용 시 영속성 컨텍스트 통해 DB 조회                     │
│   ├── 준영속 상태에서 초기화 → LazyInitializationException                  │
│   └── 확인: PersistenceUnitUtil.isLoaded(), Hibernate.initialize()          │
│                                                                             │
│   즉시/지연 로딩                                                            │
│   ├── EAGER: 엔티티 조회 시 연관 엔티티 함께 조회 (JOIN)                    │
│   ├── LAZY: 연관 엔티티 실제 사용 시 조회 (프록시)                          │
│   ├── 기본값: @xToOne → EAGER, @xToMany → LAZY                              │
│   └── 권장: 모든 연관관계에 LAZY 사용                                       │
│                                                                             │
│   영속성 전이 (CASCADE)                                                     │
│   ├── PERSIST: 부모 저장 시 자식도 저장                                     │
│   ├── REMOVE: 부모 삭제 시 자식도 삭제                                      │
│   └── ALL: 모든 옵션 적용                                                   │
│                                                                             │
│   고아 객체 (orphanRemoval)                                                 │
│   ├── 연관관계 끊어진 자식 자동 삭제                                        │
│   ├── @OneToOne, @OneToMany만 사용 가능                                     │
│   └── 참조하는 곳이 하나일 때만 사용                                        │
│                                                                             │
│   생명주기 관리                                                             │
│   └── CascadeType.ALL + orphanRemoval = true                                │
│       → 부모를 통해 자식 생명주기 완전 관리                                 │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
기능설정용도
지연 로딩fetch = FetchType.LAZY연관 엔티티 조회 지연
즉시 로딩fetch = FetchType.EAGER연관 엔티티 함께 조회
저장 전이cascade = CascadeType.PERSIST부모와 함께 저장
삭제 전이cascade = CascadeType.REMOVE부모와 함께 삭제
고아 객체orphanRemoval = true연관 끊기면 삭제