Spring Framework/JPA

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

hnjee 2025. 6. 18. 16:42

1. 프록시
1) 조회시 비효율적인 상황 생각해보기 
2) em.find()와 em.getReference() 차이 
3) 처음 반환되는 프록시 객체 (껍데기) 
4) 프록시 초기화 
5) 프록시 관련 유틸

2. 즉시 로딩과 지연 로딩
1) 기본 개념
2) LAZY 예시
3) EAGER 예시
4) 실무 권장 전략

3. 영속성 전이 (Cascade)
1) 기본 개념
2) 설정 예시
3) 주의사항

4. 고아 객체 제거 (orphanRemoval)
1) 개념
2) 설정 예시
3) 주의사항 
4) 영속성 전이 + 고아 객체의 생명주기 관리 조합 


1. 프록시

1) 조회시 비효율적인 상황 생각해보기 

public void printUser(String memberId) {
    Member member = em.find(Member.class, memberId); 
    System.out.println("회원 이름: " + member.getUsername()); //멤버 정보만 사용함 
}
  • Member와 Team이 연관관계를 맺고 있을 때
    단순히 Member 정보만 필요한데도 연관된 Team까지 항상 같이 조회된다면
    불필요한 쿼리 실행과 리소스 낭비가 발생한다. 
  • 이러한 비효율을 줄이기 위해 사용하는 것이
    바로 프록시(Proxy)를 이용한 지연 로딩(LAZY Loading)이다.

2) em.find()와 em.getReference() 차이 

메서드 동작 특징
em.find() 즉시 DB 조회 실제 엔티티 반환
em.getReference() DB 조회 지연 프록시 객체 반환 (초기화 전까지 DB 접근 없음)
Member member = em.getReference(Member.class, 1L); // 프록시 객체 반환됨, DB 접근 없음 
System.out.println(member.getName()); 
// 처음으로 프록시 객체 내부 데이터에 접근하는 시점에 딱 한번 프록시 초기화가 발생한다. 
// 이때 DB 접근해서 초기화돼서 진짜 객체처럼 동작

 

3) 처음 반환되는 프록시 객체 (껍데기) 

  • 프록시는 JPA가 실제 엔티티를 상속받아서 만든 하위 클래스다.
  • 처음에는 반환되는 것은 실제로 Member의 데이터를 담고 있지 않은 껍데기 객체
    • 따라서 toString() 같은 걸 잘못 쓰면 예상 한것과 다르게 쿼리가 날아감
  • 영속성 컨텍스트에 엔티티가 이미 존재한다면
    • getReference()를 호출해도 프록시가 아닌 실제 객체 반환.
    • 동일한 영속성 컨텍스트 안에서는 동일성 보장을 위해 프록시 생성을 하지 않음

4) 프록시 초기화 

class MemberProxy extends Member implements HibernateProxy {

    private boolean initialized = false;
    private EntityManager em;

    @Override
    public String getName() {
        if (!initialized) {
            // 여기서 DB에서 조회하고, 프록시 자기 자신(this)의 name 필드 등에 값 설정
            loadFromDBIntoThis(); 
            initialized = true;
        }
        return super.getName(); // 자신의 필드에서 값 꺼냄!
    }
}
  • 프록시 초기화란 처음으로 프록시의 내부 데이터에 접근하는 상황에 (예: member.getName())
    프록시가 실제 DB에서 조회하여 내부 데이터(자기 자신 this)를 채우는 것을 뜻한다. 
    • Hibernate는 프록시가 초기화될 때 DB에서 조회하여 실제 엔티티를 생성 후 그걸 참조한다는 설명도 있어서 헷갈리지만..
      뭐가 맞는 걸까..?
  • 하지만 주의할 점은 프록시 객체는 초기화돼도 실제 객체로 바뀌는 것이 아니라, 여전히 프록시 객체라는 점이다.
    • 클래스 타입은 여전히 Member$$Proxy 같은 이름을 갖는다.
    • 따라서 실체 객체는 아니므로 == 비교 시 실패할 수 있음.
    • 대신 instanceof 연산자를 사용해서 타입 비교해야 함.
  • 준영속 상태에서 초기화하면 오류 발생
    • 프록시는 DB 조회가 필요한데, 영속성 컨텍스트에서 관리 안 되면 DB 접근 자체가 불가능해서
    • LazyInitializationException 발생

5) 프록시 관련 유틸

  • PersistenceUnitUtil.isLoaded(entity): 프록시 초기화 여부 확인
  • entity.getClass().getName(): 프록시 클래스인지 확인 가능
  • Hibernate.initialize(entity): 프록시 강제 초기화 (JPA 표준은 강제 초기화 기능이 없음) 

 

2. 즉시 로딩과 지연 로딩

1) 기본 개념

  • 연관 엔티티를 로딩할 때 즉시 로딩(EAGER) 또는 지연 로딩(LAZY) 전략 중 하나를 선택 가능.
전략  설정값 엔티티 조회시
즉시 로딩 EAGER 연관된 엔티티도 즉시 조인 쿼리로 함께 조회
지연 로딩 LAZY 연관된 엔티티는 프록시로 반환하고 실제 접근 시점에 조회
  • 예제 도메인 
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String username;

    @ManyToOne(fetch = FetchType.XXXX) //LAZY 또는 EAGER
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

2) LAZY 예시

Member member = em.find(Member.class, 1L); // Member만 조회 (쿼리1) + Team은 프록시 반환
System.out.println(member.getUsername()); // 쿼리 안 나감
System.out.println(member.getTeam().getName()); // Team 조회 쿼리 발생 (쿼리2), 프록시 초기화
-- 쿼리1: member만 조회
select m.id, m.username, m.team_id from member m where m.id = 1;

-- 쿼리2: member.getTeam().getName() (team 프록시 초기화)
select t.id, t.name from team t where t.id = ?;

3) EAGER 예시

Member member = em.find(Member.class, 1L); //member+team 한번에 조인해서 조회
System.out.println(member.getUsername());
System.out.println(member.getTeam().getName()); // 이미 로딩돼 있음
-- member와 team을 한 번에 조인해서 조회
select m.id, m.username, m.team_id,
       t.id, t.name
from member m
left outer join team t on m.team_id = t.id
where m.id = 1;

4) 실무 권장 전략

  • 모든 연관관계는 기본적으로 LAZY 사용 권장 + JPQL의 으로 제어
    • EAGER는 무조건 조인이라 의도하지 않은, 예측 불가능한 쿼리 가능성 (N+1 문제)
    • LAZY를 사용하고, 조인이 필요한 경우에는 JPQL에 명시적으로 fetch join 쿼리를 작성한다. 

fetch join 아래 포스팅 참고 ↓

 

10. 객체지향 쿼리 언어 JPQL (2)

5. 경로 표현식 1) 용어 정리 2) 실무 조언6. 페치 조인(fetch join) 6-1. 개념 6-2. 사용 예시   1) em.find()로 Member 조회  2) JPQL로 Member 리스트 조회  3) JPQL의 페치 조인으로 Member 리스트 조회

hnjee.tistory.com

 

3. 영속성 전이 (Cascade)

1) 기본 개념

  • 부모 엔티티를 영속화(persist)할 때,
    연관된 자식 엔티티들도 함께 영속화되게 하고 싶은 경우 사용하는 설정
  • 기본적으로 부모 영속화로 연관된 자식 엔티티까지 영속화 되지 않음
  • cascade 옵션 종류 
    • PERSIST: 저장 전이
    • REMOVE: 삭제 전이
    • MERGE, REFRESH, DETACH: 각각 병합, 갱신, 분리
    • ALL: 위에 5개 모든 상태 전이 적용

2) 설정 예시

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

    private String name;

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

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

    private String name;

    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Parent parent;
}
Parent parent = new Parent();
Child child1 = new Child();
Child child2 = new Child();

child1.setParent(parent);
child2.setParent(parent);
parent.getChildren().add(child1);
parent.getChildren().add(child2);

em.persist(parent); // ✅ child1, child2도 함께 저장됨!
// CascadeType.PERSIST가 설정되어 있기 때문에
// em.persist(parent) 만으로도 자식들이 함께 저장됨!

3) 주의사항

  • Cascade는 연관관계 매핑과는 별개
  • 단지 편의를 위한 기능일 뿐, 반드시 모든 연관관계에 적용할 필요는 없음
  • 특히 양방향 관계에서 한쪽만 cascade 적용하면 예상치 못한 동작 발생할 수 있음
  • 대부분은 CascadeType.ALL 또는 PERSIST + REMOVE 조합만 실무에서 주로 사용함

 

4. 고아 객체 제거 (orphanRemoval)

1) 개념

  • 부모 엔티티에서 자식 엔티티를 컬렉션에서 제거하거나, 연관 관계를 끊었을 때
    자동으로 자식 엔티티를 삭제해주는 기능 (delete 쿼리 자동 발생)
  • 예: 리스트에서 자식을 제거하면 DB에서도 자동으로 삭제되도록 함

2) 설정 예시

@Entity
public class Parent {
    @OneToMany(mappedBy = "parent") // ❗ 비주인 → 변경사항 DB에 반영 안됨 
    private List<Child> children;
}

@Entity
public class Child {
    @ManyToOne
    @JoinColumn(name = "PARENT_ID") // ✅ 주인
    private Parent parent;
}

Parent parent = em.find(Parent.class, 1L);
Child child = parent.getChildren().get(0);

parent.getChildren().remove(child); // delete 쿼리 안 나감
//왜? Parent 객체 입장에서는 컬렉션만 바뀐 것이지 
//실제 외래키(PARENT_ID)를 Child 쪽에서 조작하지 않았기 때문에
//JPA는 이 삭제된 사항을 DB에 반영하지 않음 

em.remove(child); //수동으로 삭제처리 해줘야 DB에도 반영됨
@Entity
public class Parent {
    @Id @GeneratedValue
    private Long id;

    private String name;

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

Parent parent = em.find(Parent.class, 1L);
Child child = parent.getChildren().get(0);
parent.getChildren().remove(child); 
// em.remove(child) 안 해도 JPA가 알아서 flush 시점에 DELETE 쿼리 발생시켜줌 
// 단, 반드시 영속 상태(Managed)에서 작동함

3) 주의사항

  • @OneToOne, @OneToMany에서만 적용 가능
  • 고아 객체 제거는 다른 곳에서 참조되지 않는 자식일 때만 사용
  • 개인 소유 개념이 강한 경우에만 사용해야 함
  • CascadeType.REMOVE와 비슷하게 동작할 수 있으나 방식은 다르다. 

4) 영속성 전이 + 고아 객체의 생명주기 관리 조합 

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();
  • 이 조합을 쓰면 부모 엔티티가 자식의 생명주기를 완전히 책임지는 구조가 됨
  • 도메인 주도 설계(DDD)에서 Aggregate Root 개념을 구현할 때 유용
  • 예: 주문(Order)이 주문상품(OrderItem)의 생명주기를 완전히 책임질 때 사용