프록시와 연관관계 관리
1. 프록시
엔티티를 조회할 때 연관된 엔티티들이 항상 사용되는 것은 아닐 것이다. 지금 내가 리딩하고 있는 토이프로젝트만 봐도 유저 엔티티에 피드 엔티티, 댓글 엔티티 등 많은 엔티티와 연관관계를 갖는데 항상 사용되지는 않는다. 유저의 정보를 조회할 때 이 사용되지 않는 엔티티까지 조회하는 것은 굉장한 부담이 되고 비효율적일 것이다.
JPA는 이런 문제를 해결하고자 엔티티가 실제 사용될 때까지 데이터베이스 조회를 지연하는 방법을 제공하는데 이것을 지연 로딩이라고 한다. 이 지연 로딩을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데 이 것을 프록시 객체라 부른다.
1.1. 프록시 기초
프록시는 JPA가 엔티티의 연관관계를 처리하는 핵심적인 기법 중 하나로, 실제 엔티티 객체를 대신해 데이터베이스 접근을 지연시킬 수 있는 가상의 객체다.
[그림 1]과 같이 프록시 객체(MemberProxy)의 메소드(getName())가 실행될 때 데이터베이스를 조회해서 실제 엔티티객체를 생성하는데 이것을 프록시 객체의 초기화라 부른다. 프록시 객체는 생성된 실제 엔티티 객체의 참조를 엔티티 타겟(Member Target) 멤버 변수에 보관한다.
프록시의 특징은 아래와 같다.
- 프록시 객체는 처음 사용할 때 한 번만 초기화 된다.
- 프록시 객체를 초기화할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능하다. (참조)
- 프록시 객체는 원본 엔티티를 상속받기 때문에 타입 체크 시 주의해야 한다.
- 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환한다.
- 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제 발생한다.
1.2. 프록시와 식별자
엔티티를 프록시로 조회할 때 식별자(Primary Key) 값을 파라미터로 전달하는 데 프록시 객체는 이 식별자 값을 보관한다. 그러므로 getId()를 호출해도 프록시를 초기화하지 않는다. 이와 관련된 어노테이션은 @Access를 살펴보자.
- @Access(AccessType.PROPERTY): 초기화 되지 않는다.
- @Access(AccessType.FIELD): 초기화한다.
Member member = em.getReference(Member.class, memberId); // 프록시 객체 반환
System.out.println("Member ID: " + member.getId()); // 프록시 객체로도 ID 접근 가능
System.out.println("Member Name: " + member.getName()); // 이 시점에서 초기화
1.3. 프록시 확인
프록시 객체는 실제 엔티티와 구분하기 어렵다. 따라서 JPA에서는 프록시 객체인지 실제 객체인지 확인할 수 있는 여러 방법을 제공한다.
Member member = em.getReference(Member.class, memberId);
System.out.println("Proxy? " + (member instanceof HibernateProxy));
System.out.println("Initialized? " + Hibernate.isInitialized(member));
2. 즉시 로딩과 지연 로딩
2.1. 즉시 로딩
즉시 로딩(Eager Loading)은 엔티티가 조회될 때 관련된 연관 엔티티를 즉시 함께 조회하는 방식이다. 즉시 로딩은 성능상 오버헤드가 있을 수 있으며, 연관된 엔티티가 많을수록 더 많은 쿼리가 발생하거나 복잡한 조인이 필요할 수 있다. 이로 인해 과도한 쿼리 발생이나 N+1 문제를 초래할 수 있으므로 주의가 필요하다. @ManyToOne(fetch = FetchType.EAGER)와 같이 설정할 수 있다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
@JoinColumn(name = "team_id")
private Team team;
}
// 사용 코드
Member member = em.find(Member.class, memberId);
Team team = member.getTeam(); // 이 시점에서 team이 이미 로딩됨
2.2. NULL 제약조건과 JPA 조인 전략
JPA는 선택적 관계면 외부 조인을 사용하고 필수 관계면 내부 조인을 사용한다. 엔티티 간의 연관관계를 매핑할 때, 제약조건을 잘 보고 내부 조인을 사용할지 외부 조인을 사용할지 판단하여 사용해야한다. 아래 예제는 필수 관계로 설정하여 내부 조인을 사용할 수있도록하는 코드다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "team_id", nullable = false) // 외래 키 컬럼에 NULL 값 허용하지 않음
private Team team;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
private Team team;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
}
2.3. 지연 로딩
지연 로딩(Lazy Loading)은 엔티티가 실제로 필요할 때까지 연관된 데이터를 로딩하지 않는 방식이다. 지연 로딩은 프록시를 활용하여 필요한 시점에 데이터베이스를 조회함으로써 성능을 최적화할 수 있다. (@OneToMany(fetch = FetchType.LAZY)와 같이 설정할 수 있다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
@JoinColumn(name = "team_id")
private Team team;
}
// 사용 코드
Member member = em.find(Member.class, memberId);
Team team = member.getTeam(); // 이 시점에서 team은 로딩되지 않음
String teamName = team.getName(); // 이 시점에서 team이 로딩됨
3. 영속성 전이 - CASCADE
3.1. 영속성 전이: 저장
영속성 전이(CASCADE)는 엔티티가 저장될 때 연관된 엔티티도 함께 저장되도록 하는 기능이다. JPA는 CascadeType.PERSIST를 통해 부모 엔티티를 저장할 때 자식 엔티티도 자동으로 저장되도록 지원한다.
@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();
parent.setName("Parent 1");
Child child1 = new Child();
child1.setName("Child 1");
child1.setParent(parent);
Child child2 = new Child();
child2.setName("Child 2");
child2.setParent(parent);
parent.getChildren().add(child1);
parent.getChildren().add(child2);
em.persist(parent); // parent와 함께 child1, child2도 저장됨
3.2. 영속성 전이: 삭제
영속성 전이는 저장뿐만 아니라 삭제에도 적용될 수 있다. CascadeType.REMOVE를 설정하면 부모 엔티티가 삭제될 때 연관된 자식 엔티티도 자동으로 삭제된다. 이는 연관된 데이터의 일관성을 유지하는 데 매우 유용하다. 그러나 잘못된 사용은 데이터 손실로 이어질 수 있으므로 주의가 필요하다.
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "parent", cascade = CascadeType.REMOVE) // 삭제 시 영속성 전이
private List<Child> children = new ArrayList<>();
}
// 사용 코드
Parent parent = em.find(Parent.class, parentId);
em.remove(parent); // parent와 함께 children도 삭제됨
3.3. CASCADE의 종류
JPA에서는 다양한 CASCADE 유형을 제공한다.
- CascadeType.PERSIST: 저장
- CascadeType.MERGE: 병합
- CascadeType.REMOVE: 삭제
- CascadeType.REFRESH
- CascadeType.DETACH
- CascadeType.ALL: 모두 적용
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "parent", cascade = CascadeType.MERGE) // 부모 엔티티 병합 시 자식도 병합됨
private List<Child> children = new ArrayList<>();
}
// 사용 코드
Parent parent = em.find(Parent.class, parentId);
parent.setName("Updated Parent Name");
for (Child child : parent.getChildren()) {
child.setName("Updated Child Name");
}
em.merge(parent); // parent와 함께 자식 엔티티도 병합됨
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "parent", cascade = CascadeType.REFRESH) // 부모 엔티티 새로고침 시 자식도 새로고침됨
private List<Child> children = new ArrayList<>();
}
// 사용 코드
Parent parent = em.find(Parent.class, parentId);
parent.setName("Changed Name");
em.refresh(parent); // 변경된 엔티티가 데이터베이스의 최신 상태로 업데이트됨
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "parent", cascade = CascadeType.DETACH) // 부모 엔티티 분리 시 자식도 분리됨
private List<Child> children = new ArrayList<>();
}
// 사용 코드
Parent parent = em.find(Parent.class, parentId);
em.detach(parent); // parent와 함께 자식 엔티티도 분리됨
@Entity
public class Parent {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL) // 모든 CASCADE 작업이 자식 엔티티에 전파됨
private List<Child> children = new ArrayList<>();
}
// 사용 코드
Parent parent = new Parent();
parent.setName("Parent 1");
Child child1 = new Child();
child1.setName("Child 1");
child1.setParent(parent);
Child child2 = new Child();
child2.setName("Child 2");
child2.setParent(parent);
parent.getChildren().add(child1);
parent.getChildren().add(child2);
em.persist(parent); // 저장
em.merge(parent); // 병합
em.remove(parent); // 삭제
em.refresh(parent); // 새로고침
em.detach(parent); // 분리
5. 고아 객체
고아 객체(Orphan Removal)는 부모 엔티티와의 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능이다. @OneToMany(orphanRemoval = true)로 설정할 수 있다.
@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, parentId);
parent.getChildren().remove(0); // 연관관계가 끊어진 자식 엔티티가 자동으로 삭제됨