객체는 객체 그래프로 연관된 객체들을 탐색.
그렇지만 객체가 DB에 저장되어 있으므로 연관된 객체를 마음껏 탐색하기는 어렵다.
JPA구현체들은 이것을 해결하기 위해 프록시라는 기술을 사용한다. 프록시를 사용하면 연관된 객체를 처음부터 DB에서 조회하는 것이 아니라, 실제 사용하는 시점에 DB에서 조회할 수 있다.
자주 함께 사용하는 객체들은 조인을 사용해서 함께 조회 하는것이 효과적이다.
JPA는 즉시로딩과 지연로딩이라는 방법으로 둘을 모두 지원한다.
JPA는 연관된 객체를 함께 저장하거나 함께 삭제할 수 있는 영속성 전이와 고아 객체 제거라는 편리한 기능을 제공한다.
엔티티를 조회할 때 연관된 엔티티들이 항상 사용되는 것은 아니다. 멤버 엔티티를 조회할 때 연관된 팀 엔티티는 비즈니스 로직에 따라 사용될 때도 있지만, 그렇지 않을 때도 있다.
@Entity @Getter
@NoArgsConstructor
public class Member implements Serializable {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
@ManyToOne
private Team team;
@Builder
public Member(Long id, String name, int age, Team team) {
this.id = id;
this.name = name;
this.age = age;
this.team = team;
}
}
@Entity
@Getter
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
@Builder
public Team(String name){
this.name = name;
}
}
아래는 테스트 코드이다.
@Test
@DisplayName("프록시 테스트")
void proxyTest(){
//given
tx.begin();
Team team = Team.builder()
.name("team1")
.build();
Member member = Member.builder()
.name("홍길동")
.age(27)
.team(team)
.build();
em.persist(team);
em.persist(member);
em.flush();
//when
Member resultMember = em.find(Member.class, member.getId());
Team resultTeam = resultMember.getTeam();
//then
assertThat(resultMember.getName()).isEqualTo("홍길동");
assertThat(resultMember.getAge()).isEqualTo(27);
assertThat(resultTeam.getName()).isEqualTo("team1");
}
이 코드는 멤버 아이디로 회원 엔티티를 찾으면서 연관된 팀의 이름도 출력한다.
그런데 만약 멤버 엔티티만 조회한다고 한다면 팀 엔티티까지 DB에서 조회해두는 것은 비효율적이다.
JPA는 이러한 문제를 해결하려고 엔티티가 실제 사용될 때까지 DB 조회를 지연하는 방법을 제공해준다.
이것을 지연로딩이라고 한다.
정리하자면 실제 팀 엔티티 값을 사용하는 시점에 DB에서 팀 엔티티에 필요한 데이터를 조회하는 것이다.
지연 로딩을 사용하기 위해선 실제 엔티티 객체 대신 DB조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라고 한다.
JPA에서 식별자로 엔티티 하나를 조회할 때에는 EntityManager.find()
를 사용한다.
이 메소드는 영속성 컨텍스트에 엔티티가 존재하지 않으면 DB를 조회한다.
이런식으로 엔티티를 직접 조회하게 되면 조회한 엔티티 사용유무에 관계없이 DB를 조회하게 된다.
엔티티를 실제 사용시점까지 DB조회를 미루고 싶다면 EntityManager.getReference()
를 사용하면 된다. 이 메소드를 호출할 때 JPA는 DB를 조회하지 않고 실제 엔티티 객체도 생성하지 않는다. 대신 DB접근을 위임한 프록시 객체를 반환한다.
프록시 클래스는 실제 클래스를 상속받아 만들어진 것이므로 실제 클래스와 겉 모양이 같다. 사용하는 입장에서
이게 진짜 객체인지 프록시 객체인지 구분하지 않고 사용해도 된다.
실제 객체의 참조를 보관하기 때문에 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.
프록시의 특징 7가지
프록시 객체는 실제 사용될 때 DB를 조회해서 실제 엔티티 객체를 생성하는데 이것이 프록시 객체 초기화이다.
초기화 과정은 다음과 같다.
엔티티를 프록시로 조회할 때 식별자(PK) 값을 파라미터로 전달하는데 프록시 객체는 이 식별자 값을 보관한다.
프록시 객체는 식별자 값을 가지고 있으므로 식별자 값을 조회하는 메소드를 호출해도 프록시를 초기화하지 않는다. 단, 엔티티 접근 방식을 프로퍼티(@Access(AccessType.PROPERTY)
)로 설정한 경우에만 초기화하지 않는다. 접근방식을 필드로 설정하면 JPA는 실행한 메소드가 그것만 조회하는 것인지 아니면 부가적인 기능이 있는지를 알지 못하기에 프록시 객체를 초기화한다.
JPA에서 isLoaded(Object entity)
를 사용하여 프록시 인스턴스의 초기화 여부를 확인할 수 있다.
프록시 객체는 연관된 엔티티를 지연로딩 할때 주로 사용된다.
JPA의 조회시점 두가지 방법
@ManyToOne(fetch = FetchType.EAGER)
@ManyToOne(fetch = FetchType.LAZY)
즉시 로딩(EAGER Loading)은 위에서 본것 처럼 FetchType을 EAGER로 설정해준다. 위 코드로 보면 멤버를 조회하는 순간 팀도 함께 조회한다.
두 테이블을 조회하기에 조회쿼리를 2번 실행할 것이라고 예상하지만, 대부분의 JPA 구현체는 즉시 로딩을 최적화 하기 위해 가능하면 조인 쿼리를 사용한다.
nullable 설정에 따른 조인 전략
@JoinColumn(nullable = true)
: NULL 허용(기본값), 외부 조인 사용@JoinColumn(nullbale = false)
: NULL 허용하지 않음, 내부 조인 사용지연 로딩(LAZY Loading)을 사용하려면 FetchType을 LAZY로 설정해준다. 위 코드로 보면 find를 호출할 시에 멤버만 조회하고 팀은 조회하지 않지만 조회한 회원의 team 멤버변수에 프록시 객체를 넣어둔다.
반환된 팀 객체는 프록시 객체이므로 실제 사용될 때까지 데이터 로딩을 미룬다. 이것을 지연 로딩이라 한다.
Hibernate는 엔티티를 영속 상태로 만들 때, 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 Hibernate가 제공하는 내장 컬렉션으로 변경하는데 이것을 컬렉션 래퍼라고 한다.
엔티티를 지연 로딩하면 프록시 객체를 사용해서 지연 로딩을 수행하지만 주문내역 같은 컬렉션은 컬렉션 래퍼가 지연 로딩을 처리해준다.
fetch 속성의 기본 설정값은 다음과 같다.
@ManyToOne
, @OneToOne
: 즉시 로딩(FetchType.EAGER)@OneToMany
, @ManyToMany
: 지연 로딩(FetchType.LAZY)책에 나와있는 추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것 이라고 한다. 그리고는 실제 사용하는 상황을 봐서 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화 하면 된다고 한다.
Eager 설정과 조인 전략
@ManyToOne
, @OneToOne
@OneToMany
, @ManyToMany
특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶으면 영속성 전이기능을 사용하면 된다. JPA는 CASCADE 옵션으로 영속성 전이를 제공한다. 영속성 전이를 사용하면 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장할 수 있다.
JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 한다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Parent {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
private List<Child> children = new ArrayList<>();
}
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Child {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private Parent parent;
}
@Test
void 영속성전이테스트(){
tx.begin();
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);
em.flush();
tx.commit();
}
위 코드처럼 부모를 영속화할 때, 자식도 함께 영속화하기 위해 cascade PERESIST 옵션을 설정했다.
이렇게하면 한번에 영속화 가능하다.
영속성 전이는 연관관계 매핑하는 것과는 아무 관련이 없다. 엔티티를 영속화할 때 연관된 엔티티도 같이 영속화하는 편리함을 제공할 뿐이다.
삭제도 마찬가지로 영속성 전이 사용 가능하다. CasCadeType.REMOVE
로 설정하여 부모 엔티티를 삭제하면 연관된 자식 엔티티들도 같이 삭제된다.
remove설정을 하지않고 부모를 삭제하면 부모 엔티티만 삭제가 된다. 그런데 이 부모를 삭제하는 순간 외래키 제약조건 때문에 DB에서 외래키 무결성 예외가 발생하게 된다.
cascade 옵션은 여러 속성을 같이 사용할 수 있다.
참고❗️ PERSIST, REMOVE는 persist, remove메소드를 실행할때 전이가 바로 발생하지 않고 flush를 호출할 때 전이가 발생한다.
JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이걸 고아 객체 제거라고 한다. 이 기능을 사용해서 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제하도록 할 수 있다.
orphanRemoval = true 옵션을 설정하면 컬렉션에서 엔티티를 제거할 때 DB의 데이터도 삭제된다. 마찬가지로 이 제거 기능도 영속성 컨텍스트를 flush할때 적용되므로 flush시점에 DELETE SQL이 실행된다.
모든 엔티티를 제거하려면 컬렉션을 비워주면 된다.
고아 객체 제거는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다. 그렇기에 이 기능은 한군데에서만 참조할 때 사용해야 한다. 만약 삭제한 엔티티를 다른 곳에서도 참조한다면 문제가 발생할 수 있다. 이런 이유때문에 orphanRemoval 옵션은 @OneToMany
, @OneToOne
에만 사용할 수 있다.
고아 객체 제거에는 기능이 또 한가지가 있는데, 개념적으로 볼 때 부모를 제거하면 자식은 고아가 된다. 따라서 부모를 제거하면 자식도 같이 제거된다.
여태 배운 두 옵션을 (persist + orphanRemoval = true) 같이 사용하면 부모 엔티티를 통해 자식의 생명주기를 관리할 수가 있다.
Parent parent = em.find(Parent.class, parentId);
parent.addChild(child1);
//delete
Parent parent = em.find(Parent.class, parentId);
parent.getChildren().remove(removeObject);
이번 포스팅에서는 프록시의 동작 원리에 대해 학습하고 즉시 로딩 그리고 지연 로딩에 관해 알아보았다.