프록시와 연관관계 관리

Thomas·2023년 8월 1일
0

가장 먼저 JPA 프로그래밍 책 기반 베이스로 작성할 계획이다.
여기서 다룰것은 프록시, 즉시로딩, 지연로딩,영속성 전이, 고아객체 정도이다.
이전에 강의 들으면서 대충은 알겠는데.. 대충 아는 건 싫어서 정리를 해줄려고 한다.

프록시와 연관관계 관리

프록시가 나오게 된 이유를 먼저 설명을 하자면
책에는 "객체는 객체 그래프로 연관된 객체들을 탐색한다. 그런데 객체가 데이터베이스에 저장되어 있으므로 연관된 객체를 마음껏 탐색하기는 어렵다. JPA 구현체들은 이 문제를 해결하려고 프록시라는 기술을 사용한다" 이렇게 나와있지만 쉽게 이해하기란 어렵다.

그래서 조금이나마 쉽게 풀려고 노력해 볼 생각이다.

가장 먼저 연관관가 있는 엔티티를 조회하는 방법은 크게 2가지인데
1, 객체 그래프 탐색(객체 연관관계를 사용한 조회)
2. 객체지향 쿼리 사용(JPQL)

물론 첫번쨰를 통해 설명할 예정이다.

일단 Member라는 Entity에 Team 객체를 가지고 있고
회원1, 회원2가 팀1에 소속해 있다고 가정하자.

그러면 member.getTeam()을 사용해서 member와 연관된 team 엔티티를 조회할 수 있다.

EntityManager em = getEntityManager();

Member member = em.find(Member.class, "member1")
Team team = member.getTeam(); // 객체 그래프 탐색

이런식으로 하나하나 연관된 객체를 탐색하는 건 쉽지가 않다.

객체가 데이터베이스에 저장되어 있으므로 연관된 객체를 마음껏 탐색하기는 어렵다
-> 즉 프록시가 탄생한다.

그래서 프록시를 사용하면 연관된 객체를 처음부터 데이터베이스에서 조회하는 것이 아니라, (1)실제 사용하는 시점에 데이터베이스에서 조회할 수 있다. 하지만 자주 (2)함꼐 사용하는 객체들은 조인을 사용해서 함꼐 조회하는 것이 효과적이다.(연관된 엔티티들을 함께 조회하는 방법 중 가장 흔한 방법은 조인(Join)을 사용하는 것이다)

JPA는 즉시 로딩과 지연로딩이라는 방법으로 둘을 모두 지원한다.

프록시

엔티티를 조회할 떄 연관된 엔티티를 항상 사용되는 것은 아니다.

예를 들면
회원과 팀 정보를 출력하는 비즈니스 로직이 있고

Member member = em.find(Member.class, memberId);
System.out.println(member.getUsername);
System.out.println(team.getName);

회원 정보만 출력하는 비즈니스 로직이 잇다면

Member member = em.find(Member.class, memberId);
System.out.println(member.getUsername);

-> 여기서 em.find()로 회원 엔티티를 조회 할 떄 회원과 연관된 팀 엔티티까지 데이터베이스에서 함꼐 조회해 두는 것은 효율적이지 않다.

JPA는 이런 문제를 해결하려고 엔티티가 실제 사용될 떄까지 데이터베이스 조회를 지연하는 방법을 제공하는데 이것을 지연 로딩이다.

쉽게 이야기해서 team.getName()처럼 팀 엔티티의 값을 실제 사용하는 시점에 데이터베이스에서 팀 엔티티에 필요한 데이터를 조회하는 것이다. 이 방법을 사용하면 회원 데이터만 데이터베이스에서 조회해도 된다.

그런데 지연 로딩 기능을 사영하려면 실제 엔티티 객체에 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라고 한다.

프록시의 특징

프록시 클래스는 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같다.

프록시 객체는 실제 객체에 대한 참조를 보관한다. 그리고 프록시 객체의 메소드를 호출하면 프록시 객체는 실제 객체의 메소드를 호출한다.

프록시 객체의 초기화

프록시 객체는 member.getName()처럼 실제 사용될 떄 데이터베이스를 조회해서 실제 엔티티 객체를 생성하는데 이것을 프록시 객체의 초기화라고 한다.

초기화 과정

  1. 프록시 객체에 member.getName()을 호출해서 실제 데이터를 조회한다.
  2. 프록시 객체는 실제 엔티티가 생성되어 있지 않으면 영속성 컨텍스트에 실제 엔티티 생성을 요청하는데 이것을 초기화이다.
  3. 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성한다.
  4. 프록시 객체는 생성된 실제 엔티티 객체의 참조를 Member target 맴버변수에 보관한다.
  5. 프록시 객체는 실제 엔티티 객체의 getName()을 호출해서 결과를 반환한다.

즉시 로딩과 지연 로딩

즉시로딩

즉시 로딩을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.EAGER로 지정한다.

Member member = em.find(Member.class, "member1")
Team team = member.getTeam();

회원을 조회하는 순간 팀도 함께 조회한다. 이때 회원과 팀 두테이블을 조회해야 하므로 쿼리를 2번 실행할 것 같지만, 대부분 JPA 구현체는 즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용한다.

즉시 로딩 실행 SQL에서 JPA가 내부 조인이 아닌 외부 조인을 사용한다. 예제에서 사용하는 TeamID 외래 키는 NULL값을 허용하고 있는데 따라서 팀에 소속되지 않은 회원이 있을 가능성이 있다. 팀에 소속하지 않은 회원과 팀을 내부 조인 하면 팀은 물론이고 회원 데이터도 조회 할 수 없다.

외부 조인보다 내부 조인이 성능과 최적화에서 더 유리하다

그럼 내부 조인을 사용하려면 어떻게 해야할까?
-> @JoinColumn에 nullable = false응ㄹ 설정해서 이 외래 키는 NULL 값을 허용하지 않는다고 알려주면 JPA는 외부 조인 대신에 내부 조인을 사용한다.

지연 로딩

프록시 객체는 주로 연관된 엔티티를 지연 로딩할 떄 사용한다.

지연 로딩을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.Lazy로 지정한다.

Member member = em.find(Member.class, "member1")
Team team = member.getTeam();

회원만 조회하고 팀은 조회하지 않는다. 대신에 조회한 회원의 team 맴버변수에 프록시 객체를 넣어둔다.

Team team = member.getTeam(); // 프록시 객체

반환된 팀 객체는 프록시 객체다. 이 프록시 객체는 실제 사용될 떄까지 데이터 로딩을 미룬다.

Team team = member.getTeam(); // 팀 객체 실제 사용

이처럼 실제 데이터가 필요한 순간이 되어서야 데이터베이스를 조회해서 프록시 객체를 초기화한다.

조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체를 사용할 이유가 없다. 따라서 프록시가 아닌 실제 객체를 사용한다.

JPA 기본 Fetch 전략

JPA의 기본 Fetch 전략은 연관된 엔티티가 하나면 즉시 로딩을, 컬렉션이면 지연로딩을 사용한다. 컬렉션을 로딩하는 것은 비용이 많이 들고 잘못하면 너무 많은 데이터를 로딩할 수 있기 때문이다.

예를 들면 특정 회원이 연관된 컬렉션에 데이터를 수만 건 등록했는데, 설정한 페치 전략이 즉시 로딩이먀ㅕㄴ 해당 회원을 로딩하는 순간 수만 건의 데이터도 함께 로딩된다. 반면에 연관된 엔티티가 하나면 즉시 로딩해도 큰 문제가 발생하지는 않는다.

현역 개발자가 추천하는 방법은 모든 연관관계에 지연 로딩을 사용한다!
즉시로딩은 N + 1 Query문제 발생 할 수도 있기 떄문이다.

그리고 개발이 어느 정도 완료단계에 왔을 떄 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 된다.

컬렉션에 즉시로딩 사용시 주의 점

  • 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않는다.
  • 컬렉션 즉시 로딩은 항상 외부 조인을 사용한다

@ManyToOne, @OneToOne

  • (optional = false): 내부조인
  • (optional = true): 외부조인

@OneToMany, @ManyToMany

  • (optional = false): 외부조인
  • (optional = true): 외부조인

영속성 전이: CASCADE

특정 엔티티를 영속 상태로 만들 떄 연관된 엔티티도 함꼐 영속 상태로 만들고 싶으면 영속성 전이(transitive persistence)기능을 사용하면 된다. 즉, 영속성 전이를 사용하면 부모 엔티티를 저장할 떄 자식 엔티티도 함께 저장할 수 있다.

부모 엔티티

@Entity
public class Parent {
	@Id @GeneratedValue
    private Long id;
    
    @OnetToMany(mappedBy = "parent")
    private List<Child> children = new ArrayList<Child>();
}

자식 엔티티

@Entity
public class Child {
	@Id @GeneratedValue
    private Long id;
    
	@ManyToOne
    private Parent parent;
}
// 부모 저장
Parent parent = new Parent();
em.persist(parent);

// 1번 자식 저장
Child child1 = new Child();
child1.setParent(parent); // 자식 -> 부모 연관관계 설정
parent.getChildren().add(child1); 
em.persist(child1);

// 2번 자식 저장
Child child2 = new Child();
child2.setParent(parent); // 자식 -> 부모 연관관계 설정
parent.getChildren().add(child2); 
em.persist(child2);

JPA에서 엔티티를 저장할 떄 연관된 모든 엔티티는 영속 상태여야 한다.
영속성 전이를 사용하지 않으면 부모 엔티티를 영속 상태로 만들고 자식 엔티티도 각각 영속 상태로 만들어 줘야한다.

영속성 전이: 저장

@Entity
public class Parent {
	@Id @GeneratedValue
    private Long id;
    
    @OnetToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
    private List<Child> children = new ArrayList<Child>();
}

cascade 옵션을 적용하면 간현하게 부모와 자식 엔티티를 한 번에 영속화할 수 있다.

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);

영속성 전이: 삭제

영속성 전이는 엔티티를 삭제할 떄도 사용할 수 있다. CascadeType.REMOVE로 설정하면 된다.

CASCADE의 종류

ALL -> 모두 적용
PERSIST -> 영속
MERGE -> 병합
REMOVE -> 삭제
REFRESH -> 리프레쉬
DETACH -> DETACH

고아 객체

JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는게 이것을 고아 객체 제거라 한다. 이 기능을 사용해서 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제된다.

@Entity
public class Parent {
	@Id @GeneratedValue
    private Long id;
    
    @OnetToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Child> children = new ArrayList<Child>();
}

사용코드

Parent parent1 = em.find(Oarent.class, id);
parent1.getChildrent().remove(0); // 자식 엔티티를 컬렉션에서 제거

고아 객체 제거는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아객체로 보고 삭제하는 기능이다. 따라서 이 기능은 참조하는 곳이 하나일 때만 사용해야 한다. 쉽게 이야기해서 특정 엔티티가 개인 소유하는 엔티티에만 이 기능을 적용해야 한다. 만약에 삭제한 엔티티를 다른 곳에서도 참조한다면 문제가 발생할 수 있다. 이런 이유로 orphanRemoval은 @OneToOne, @OneToMany에만 사용할 수 있다.

-> 방법은 없는건 아니다
https://github.com/uiseongsang/nbc-spring-assignment-blog/issues/2
제가 이전에 이 문제로 튜터님한테 질문을 드렸는데 궁금하면 한 번 보는걸 추천드린다.

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

CascadeType.ALL + orphanRemoval = true를 동시에 사용하면 어떻게 될까?

일반적으로 엔티티는 EntityManager.persist()를 통해 영속화되고 EntitiyManager.remove()를 통해 제거된다. 이것은 엔티티 스스로 생명주기를 관리한다는 뜻이다.

그런데 두 옵션을 모두 활성화하면 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다.

자식을 저장하려면 부모에 등록만 하면 된다 (CASCAE)

Parent parent = em.find(Parent.class, parentId);
parent.addChild(child1);

자식을 삭제하려면 부모에서 제거하면 된다(orphanRemoval)

Parent parent = em.find(Parent.class, parentId);
parent.getChildren().remove(removeObject);

영속성 전이는 DDD의 Aggregate Root 개념을 구현할 떄 사용하면 편리하다


번외로

영속성 전이 최강 조합: orphanRemoval = true + Cascade.ALL
위 2개를 함꼐 설정하면 자식 엔티티의 라이프 사이클이 부모 엔티티와 동일해지며, 직접 자식 엔티티의 생명주기를 관리할 수 있게 되므로 자식 엔티티의 Repository 조차 없어도 된다. (따라서, 매핑 테이블에서 많이 쓰임)

profile
Backend Programmer

0개의 댓글