[8] 프록시와 연관관계 관리

ttt-1-2·2026년 5월 3일

교재: 자바 ORM 표준 JPA 프로그래밍 

8장에서 다룰 내용:

  • 프록시와 즉시로딩, 지연로딩
  • 영속성 전이와 고아 객체

1. 프록시

엔티티를 조회할 때 연관된 엔티티가 항상 필요한 것은 아니다.
ex: 회원을 조회할 때 팀 정보는 상황에 따라 사용할 수도 있고 사용하지 않을 수도 있다. 하지만 기본적으로 em.find()를 사용하면 연관된 엔티티까지 함께 조회될 수 있어 비효율이 발생한다.

→ 이 문제를 해결하기 위해 JPA는 지연 로딩을 제공한다. 실제 사용하는 시점까지 데이터베이스 조회를 미루는 방식이다. 이때 실제 엔티티 대신 사용하는 객체를 프록시라 한다.

프록시 기초

JPA에서 엔티티 조회 방법:

  • em.find(): 즉시 조회, DB 바로 접근
  • em.getReference(): 프록시 반환, DB 조회하지 않음

프록시 구조: 프록시는 실제 엔티티를 상속한 객체다. 겉보기에는 동일하게 사용 가능하다. 또한 프록시는 내부에 실제 엔티티 참조(target)를 가진다.

프록시 초기화

// MemberProxy 반환
Member member = em.getReference(Member.class, "id1");
member.getName(); // 1. getName()
// 프록시 클래스 예상 코드
class MemberEntity extends Member {
	Member target = null;
	
	public String getName() {
		
		if(target == null) {
			// 2. 초기화 요청
			// 3. DB 조회
			// 4. 실제 엔티티 생성 및 참조 보관
		}
		
		// 5. target.getName()
		return target.getName();
	}
}

프로시와 식별자

엔티티를 프록시 조회할 때 식별자(PK) 값을 파라미터로 전달하는데 프록시 객체는 이 식별자 값을 보관한다.

Team team = em.getReference(Team.class, "team1"); //식별자 보관
team.getId(); //초기화되지 않음

프록시 확인

프록시 초기화 여부 확인 방법: PersistenceUnitUtil.isLoaded()

boolean isLoad = em.getEntityManagerFactory()
								.getPersistenceUnitUtil()
								.isLoaded(entity);

→ 초기화 안됨: false
→ 초기화됨: true

2. 즉시 로딩과 지연 로딩

연관 엔티티를 언제 조회할지 결정하는 방식이다. 핵심은 “지금 가져올지, 나중에 필요할 때 가져올지”이다.

2.1 즉시 로딩 (EAGER)

엔티티를 조회할 때 연관 엔티티도 함께 조회한다. 한 번 조회 시 JOIN을 사용해서 같이 가져오는 경우가 많다. 조회 시점에 이미 데이터가 준비되어 있어서 바로 사용할 수 있다. 하지만 필요하지 않은 데이터까지 가져올 수 있다.

예시 코드

@Entity
public class Member {

	@ManyToOne(fetch = FetchType.EAGER)
	@JoinColumn(name = "TEAM_ID")
	private Team team;

}
-- 실행 SQL (예시)

SELECT
	M.MEMBER_ID,
	M.TEAM_ID,
	M.USERNAME,
	T.TEAM_ID,
	T.NAME
FROM MEMBER M LEFT OUTER JOIN TEAM T
	ON M.TEAM_ID = T.TEAM_ID
WHERE M.MEMBER_ID = 'member1';

→ member를 조회하는 순간 team도 같이 조회된다. 이후 getTeam() 호출 시 추가 쿼리는 발생하지 않는다.

2.2 지연 로딩 (LAZY)

연관 엔티티를 실제 사용할 때 조회한다. 처음에는 프록시 객체만 들어가 있고, 값이 필요한 순간 DB를 조회한다. 불필요한 데이터 조회를 줄일 수 있다.

예시 코드

@Entity
public class Member {
	
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "TEAM_ID")
	private Team team;

}
-- 실행 SQL 흐름

-- 1. member 조회
SELECT *
FROM MEMBER
WHERE MEMBER_ID = 'member1';

-- 2. team 실제 사용 시 조회
SELECT *
FROM TEAM
WHERE TEAM_ID = 'team1';

→ 처음에는 team이 프록시로 들어오고, getName()을 호출하는 순간 실제 team 데이터를 가져온다.

즉시 로딩은 조회 시점에 모두 가져와서 편하지만 비효율이 생길 수 있다. 지연 로딩은 필요한 시점에만 조회해서 성능에 유리하지만, 사용 시점과 영속성 컨텍스트를 신경 써야 한다.

실무에서는 대부분 LAZY를 기본으로 사용하고, 필요한 경우에만 JOIN이나 fetch 전략으로 최적화한다.

3. 지연 로딩 활용

3.1 프록시와 컬렉션 래퍼

지연 로딩에서 컬렉션은 일반 List가 아니라 하이버네이트가 제공하는 컬렉션 래퍼로 관리된다. 이 객체는 내부적으로 프록시처럼 동작하며 실제 데이터가 필요할 때 DB를 조회한다.

Member member = em.find(Member.class, "member1");
List<Order> orders = member.getOrders();
System.out.println(orders.getClass().getName());

orders를 조회해도 초기화되지 않는다. 실제 데이터 접근 시점에 초기화된다. member.getOrders().get(0);

이 시점에 DB를 조회하여 주문 데이터를 가져온다. 그리고 Order는 Product를 즉시 로딩으로 설정했으므로 함께 로딩된다.

3.2 JPA 기본 페치 전략

JPA의 기본 전략은 단건 연관은 즉시 로딩, 컬렉션은 지연 로딩이다.

@ManyToOne, @OneToOne → EAGER
@OneToMany, @ManyToMany → LAZY

컬렉션은 데이터 양이 많을 수 있어서 기본적으로 지연 로딩을 사용한다. 실무에서는 대부분 LAZY를 기본으로 두고 필요한 경우만 fetch join으로 최적화한다.

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

컬렉션을 즉시 로딩으로 설정하면 조인으로 인해 데이터가 급격히 증가할 수 있다. 특히 여러 컬렉션을 동시에 조인하면 N × M 형태로 결과가 커진다. 성능 문제가 발생할 수 있다.

또한 컬렉션 즉시 로딩은 항상 OUTER JOIN을 사용한다. 데이터가 없어도 결과를 유지하기 위함이다.

결론적으로 컬렉션에는 EAGER 사용을 지양하고, 필요 시 명시적으로 조회 전략을 조정하는 것이 좋다.

4. 영속성 전이: CASCADE

영속성 전이는 부모 엔티티를 저장하거나 삭제할 때 연관된 자식 엔티티도 함께 처리하도록 해주는 기능이다. 즉, 부모만 persist/remove 해도 자식까지 자동으로 처리된다.

4.1 저장 (CascadeType.PERSIST)

cascade 옵션을 사용하면 부모만 저장해도 자식까지 함께 저장된다.

@Entity
public class Parent {

	@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
	private List<Child> children = new ArrayList<>();
	
}
private static void saveWithCascade(EntityManager em) {

	Child child1 = new Child();
	Child child2 = new Child();
	
	Parent parent = new Parent();
	child1.setParent(parent);
	child2.setParent(parent);
	
	parent.getChildren().add(child1);
	parent.getChildren().add(child2);
	
	em.persist(parent);

}

→ parent만 persist 해도 child1, child2까지 함께 저장된다.

4.2 삭제 (CascadeType.REMOVE)

삭제도 동일하게 부모만 제거하면 자식도 함께 삭제된다.

Parent parent = em.find(Parent.class, 1L);
em.remove(parent);
  • CascadeType.REMOVE 설정 시 자식까지 자동 삭제된다.
  • 설정이 없으면 FK 제약 때문에 예외가 발생한다.

4.3 CASCADE 종류

CascadeType은 여러 옵션을 제공한다.

public enum CascadeType {
	ALL,
	PERSIST,
	MERGE,
	REMOVE,
	REFRESH,
	DETACH
}

여러 옵션을 함께 사용할 수도 있다.

cascade = {CascadeType.PERSIST, CascadeType.REMOVE}

5. 고아 객체

고아 객체 제거는 부모와의 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능이다. 즉, 컬렉션에서 참조만 제거하면 DB에서도 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, id);
parent.getChildren().remove(0);

→ flush 시점에 DELETE FROM CHILD 실행된다.

모든 자식을 제거하려면 컬렉션을 비우면 된다: parent.getChildren().clear();

고아 객체 제거는 “더 이상 어떤 곳에서도 참조되지 않는 엔티티를 삭제”하는 개념이다. 따라서 한 부모에 완전히 종속된 경우에만 사용해야 하며, 여러 곳에서 참조되는 엔티티에는 사용하면 안 된다. (보통 @OneToOne, @OneToMany에서 사용)

또한 부모를 삭제하면 자식도 함께 삭제되는데, 이는 CascadeType.REMOVE와 유사하게 동작한다.

6. 영속성 전이 + 고아 객체, 생명주기

Cascade + orphanRemoval을 함께 사용하면 부모가 자식의 전체 생명주기를 관리할 수 있다.

  • 자식 추가는 cascade로 처리된다.
Parent parent = em.find(Parent.class, parentId);
parent.addChild(child1);

→ parent만 관리하면 child 자동 persist

  • 자식 삭제는 orphanRemoval로 처리된다.
Parent parent = em.find(Parent.class, parentId);
parent.getChildren().remove(child1);

→ 컬렉션에서 제거하면 child 자동 delete

0개의 댓글