개발자로 후회없는 삶 살기
JPA PART.프록시와 연관관계 관리 본문
서론
※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.
https://www.inflearn.com/roadmaps/149
본론
프록시가 뭔지 알아보고 프록시의 활용을 알아봅니다. 예를 들어서 멤버와 팀이 있는데 멤버를 조회할 때 팀도 db에서 조회해야 할까? 고민하는 상황입니다.
try {
Member member = em.find(Member.class, 1L);
printMemberAndTeam(member);
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
}
private static void printMemberAndTeam(Member member) {
String name = member.getName();
Team team = member.getTeam();
}
회원 id 1를 찾아왔는데 멤버와 팀을 같이 출력하는 경우라고 하면 회원을 찾아오고 회원에 연관된 팀 정보를 불러와서 팀을 출력할 것입니다. 이런 비즈니스 로직에서는 멤버를 가져오고 팀도 한 번에 한 방 쿼리로 가져오면 좋을 것입니다.
private static void printMember(Member member) {
String name = member.getName();
}
그런데 상황이 바뀌어서 회원만 출력하도록 하면 DB에서 연관관계가 걸렸다고 해서 사용하지 않는 팀도 가져오면 손해입니다. 이런 문제를 어떻게 해결해야 할까요? 언제는 멤버와 팀을 거의 같이 사용하고 언제는 멤버만 사용하고 하면 낭비를 고려해야 합니다. jpa는 이런 걸 프록시로 해결합니다.
=> 프록시 기초
프록시를 명확히 이해하고 갑니다. jpa는 em.find 말고도 getReference라는 메서드를 제공하는데 em.find는 DB에서 실제 엔터티 객체를 조회하는 것이고 getRe는 DB 조회를 미루는 가짜 엔터티 객체를 조회합니다. find는 DB에 쿼리가 나가니 당연히 객체가 조회되는데 refer는 쿼리가 안 나가는데 객체가 조회가 됩니다.
try {
Member member = new Member();
member.setName("hello");
em.persist(member);
em.flush();
em.clear();
Member findM = em.find(Member.class, member.getId());
System.out.println("findM.getId() = " + findM.getId());
System.out.println("findM.getName() = " + findM.getName());
tx.commit();
}
멤버에 이름을 저장하고 persist하고 플러시해서 1차 캐시에서 비워서 db에서 가져오게 만듭니다.
1) find
find를 하면 쿼리가 나가고 당연히 잘 나옵니다.
jpa가 한 번에 조인을 해서 팀을 다 가져옵니다.
2) getReference
Member findM = em.getReference(Member.class, member.getId());
// System.out.println("findM.getId() = " + findM.getId());
// System.out.println("findM.getName() = " + findM.getName());
이러면 getReference를 했는데
쿼리가 안나갑니다.
Member findM = em.getReference(Member.class, member.getId());
System.out.println("findM.getId() = " + findM.getId());
System.out.println("findM.getName() = " + findM.getName());
근데 실제 사용을 해보면
쿼리가 나갑니다. getReference를 호출하는 시점에는 쿼리를 안하고 실제 사용하는 시점에서 DB에 쿼리를 날립니다.
findM가 뭔지 정체를 찍어보면 하이버네이트가 강제로 만든 가짜 프록시 클래스입니다.
=> 매커니즘
이제부터 이 매커니즘을 자세히 알아봅니다. find를 하면 진짜 객체를 주는데 이 getRef를 하면 진짜 멤버 객체를 주는 게 아니라 하이버네이트가 내부 라이브러리를 통해 프록시라고 하는 가짜 엔터티 객체를 줍니다. 껍데기는 똑같은데 내부가 텅텅비었습니다. 그리고 target이라는 진짜 레퍼런스를 가리키고 있고 초기에는 null입니다.
=> 특징
실제 엔터티를 상속받아서 만들어져서 겉 모양이 똑같습니다. 하이버네이트가 내부적으로 프록시 객체를 만듭니다. 사용하는 입장에서는 상속 관계라서 진짜 객체인지 가짜인지 구분하지 않고 사용하면 됩니다.
실제 타겟을 가지고 있는데 그래서 프록시 객체의 getName을 호출하면 타겟의 getName을 대신 호출합니다. 근데 처음에는 쿼리를 안 날려서 조회를 안했기 때문에 타겟이 null입니다.
-> 프록시 객체의 초기화
1) 프록시 객체를 getRef로 가져오면 진짜가 아닌 가짜 객체가 오는데 이때 getName을 호출하면 진짜의 getName을 볼텐데 target에 값이 null로 없습니다. 영속성 컨텍스트에 target이 없는 것입니다.(target인 Member가 영속 관리가 안되고 있는 것)
2) 그러면 jpa가 이것을 영속성 컨텍스트에 진짜 멤버 객체를 가져오라고 요청합니다. 그러면 컨텍이 실제 db를 조회해서 member를 컨텍에 가져오고 프록시의 target 필드와 연결시켜줍니다. 프록시와 진짜 객체를 연결합니다. 그래서 getName을 하면 진짜의 getName을 해서 name이 반환되고 이렇게 매커니즘이 동작합니다.
프록시에 값이 없을 때 컨텍을 통해서 진짜 값을 달라고 초기화하는 것이 중요합니다. 프록시는 한 번 초기화되면 값을 알아서 다시 호출할 필요는 없습니다. 그래서 getRef 하면 가짜 프록시 객체가 조회된 것이고 getName할 때 내부적으로 프록시가 컨텍에 요청해서 내부적으로 실제 객체를 가지면서 실제 타겟값을 알게되면서 실제 값을 가져옵니다. 이러한 매커니즘으로 기본적으로 동작합니다.
- 프록시의 특징
1. 처음 사용할 때(getRef 한 후 getName) 한번만 초기화
두 번 세번 호출해도 한 번 초기화한 것을 계속 사용합니다. getName()을 두 번하면 초기화가 안 됩니다.
2. 프록시 객체를 초기화할 때 프록시가 실제 엔터티로 바뀌는게 아니고 접근만 가능한 것
이게 교체되는 것이 아니고 내부에 타겟에만 값이 채워지는 것입니다.
3. 프록시 객체는 원본 엔터티를 상속받아서 타입 체크 시 유의해야합니다.
== 대신 instance of를 사용해야 합니다. jpa에서 타입을 비교할 때는 ==대신 instance of를 써야합니다. 예를 들어서 멤버 타입이라고 해서 == 비교하면 안 된다는 것입니다.
예제)
Member member = new Member();
member.setName("hello1");
em.persist(member);
Member member2 = new Member();
member.setName("hello2");
em.persist(member2);
em.flush();
em.clear();
Member findM = em.find(Member.class, member.getId());
Member findM2 = em.find(Member.class, member2.getId());
System.out.println("m1 == m2 : " + (findM.getClass() == findM2.getClass()));
member1을 find로 진짜 객체를 가져오고 member 2를 find로 진짜를 가져와서 둘이 타입이 같은 지 비교를 하면
당연히 true 입니다.
Member findM = em.find(Member.class, member.getId());
Member findM2 = em.getReference(Member.class, member2.getId());
근데 타입 체크를 할 때 진짜 조심해야 합니다. m2를 getRef로 가져오면 == 비교하면
false가 나옵니다. 타입 == 비교는 진짜 정확해야해서 상속 관계가 적용이 안 됩니다.
이거 실제로 보면 알지 않나요? ✅
private static void logic(Member findM, Member findM2) {
System.out.println("m1 == m2 : " + (findM.getClass() == findM2.getClass()));
}
이렇게 find를 하는 것과 getRef를 하는 것을 보고 하면 구분이 되는데 비즈로직에서는 이렇게 안 되고 파라미터로만 넘어올 것이라서
private static void logic(Member findM, Member findM2) {
System.out.println("m1 == m2 : " + (findM instanceof Member));
}
이게 실제인지 프록시인지 구분이 안 됩니다. 그래서 타입 비교를 절대로 == 으로 하면 안 되고 instance of로 해야합니다.
4. 영속성 컨텍에 찾는 엔터티가 이미 컨텍스트에 있으면 em.getRef를 호출해도 실제 엔터티를 반환
Member findM = em.find(Member.class, member.getId());
System.out.println(findM.getClass());
Member findM2 = em.getReference(Member.class, member.getId());
System.out.println(findM2.getClass());
m1을 find로 조회하면 진짜 객체입니다. 이 상태에서는 컨텍에 이미 원본이 있습니다.
이 상태에서 getRef를 하고 타입을 보면 프록시가 아니라 Member 타입입니다.
🚨 힌트!
이전에 JPA에서 한 번 find를 하면 db에서 가져와서 1차 캐시에 올려두고 그 후 조회를 하면 가장 먼저 1차 캐시를 조회해서 조회한 객체가 항상 == 비교가 같다고 나왔습니다.
System.out.println("m1 == m2 : " + (findM == findM2));
# 결과
m1 == m2 : true
위 코드는 find 이후 getRef 코드로 실행해보면 ref = Member 프록시가 아니라 실제 멤버 타입입니다.
1) 이렇게 하는 이유 1 : 이미 원본을 컨텍에 올려놨는데 가짜를 가져오는 것은 이점이 없어서 성능 최적화를 할 게 없어서 원본을 가져옵니다.
2) 진짜 이유 : JPA에서는 1차 캐시에서 가져온 것은 항상 == 이 t여야 합니다. jpa에서는 자바 컬랙션처럼 영속성 컨텍에서 가져온 것이고 pk가 똑같으면 jpa는 == 비교를 항상 t를 반환합니다.
이게 jpa가 기본으로 제공하는 매커니즘으로 jpa는 한 트랜잭션 안에서 같은 것을 보장합니다. == 비교할 때 jpa에서 t로 만들어 주기 위해서 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔터티를 반환합니다.
최초 find 해서 db에서 가져와서 1차 캐시에 올리고 두번째 find에서도 1차 캐시에서 조회하는 것과 같은 결과라고 보면 됩니다.
-> getRef 후 find
첫 getRef하면 프록시가 나오는데 find하면 당연히 멤버가 나와야 할 것 같습니다.
Member findM = em.getReference(Member.class, member.getId());
System.out.println(findM.getClass());
# class hellojpa.domain.Member$HibernateProxy$7Gan9fMc
Member findM2 = em.find(Member.class, member.getId());
System.out.println(findM2.getClass());
# class hellojpa.domain.Member$HibernateProxy$7Gan9fMc
System.out.println("m1 == m2 : " + (findM == findM2));
# 결과
m1 == m2 : true
근데 jpa는 한 트랜젝션에서 == 이 성립함을 보장해야 한다고 했습니다. 그래서 결과를 보면 find한 후의 sout를 봐도 멤버가 아닌 프록시가 나옵니다. 프록시로 한 번 반환이 되면 em.find도 프록시로 반환해서 한 트래잭션에서 ==을 맞추도록 합니다.
핵심 ✅
여기서 핵심은 find를 해도 프록시가 나올 수 있기에 프록시든 아니든 문제가 없게 개발하는 것이 중요한 것입니다. 또한 실무에서 이렇게 복잡할 일은 거의 없습니다. 그리고 이런다고 해서 find를 할 때 쿼리를 안 날리는 것은 아닙니다. 실제 db에 조회를 하지만 프록시가 나오는 것입니다.
5. 컨텍에 도움을 받을 수 없는 준영속 상태일 때, 프록시를 초기화하면 문제가 발생
이건 실제 클래스의 영속 여부가 아니라 프록시 객체의 영속 여부로 지금까지 getReF를 하면 find처럼 컨텍이 관리합니다.
Member findM = em.getReference(Member.class, member.getId());
System.out.println(findM.getClass());
em.detach(findM);
findM.getName();
tx.commit();
프록시의 초기화 요청은 컨텍을 통해서 일어나는데 만약 detach로 컨텍을 끄고 getname을 하면 원래는 프록시 객체가 컨텍의 도움을 받아서 초기화를 해야하는데 컨텍과의 연결을 끊고 getName으로 초기화하려고 하면 프록시를 초기화할 수 없다는 예외가 발생합니다.
no session이 컨텍이 없다는 얘기입니다. 이 문제는 진짜 중요한 것으로 실무에서 진짜 많이 만납니다. 이런 걸 보면 프록시가 컨테에 관리되는 요소라는 것을 알 수 있습니다. 이런 예외가 뜨면 프록시가 컨텍에 관리를 안 받고 있음을 알아야 합니다. (JPA를 쓰면 반드시 만나는 예외입니다.)
-> 프록시 확인 유틸리티 메서드
프록시를 확인할 수 있는 메서드가 있습니다.
1. 프록시 초기화 여부 확인
Member findM = em.getReference(Member.class, member.getId());
System.out.println("init : " + emf.getPersistenceUnitUtil().isLoaded(findM));
findM.getName();
System.out.println("init : " + emf.getPersistenceUnitUtil().isLoaded(findM));
이 프록시가 초기화됐는지 확인하는 메서드로
프록시의 내부에 target이 null이면 f, 초기화 됐다면 t입니다. em 팩토리에서 가져와서 isLoaded 메서드를 하면 초기화 안 한 프록시면 false가 나옵니다.
2. 프록시 강제 초기화
Hibernate.initialize(findM);
getName은 사실 강제로 초기화한 것입니다. 근데 이것 말고 진짜 강제 초기화할 수 있습니다. 이걸 하면 getName처럼 이때 쿼리가 나갑니다.
- 즉시 로딩과 지연 로딩
getRef를 많이 쓸까요? 많이 안 씁니다. 프록시의 매커니즘을 알아야 즉시 로딩과 지연 로딩을 알 수 있습니다.
단순히 회원만 사용하는 로직이면 연관관계가 걸려있다고 해서 팀도 가져오면 손해입니다.
-> 지연 로딩
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
그래서 JPA는 지연로딩이라는 옵션을 제공합니다. Member에 ManyToOne에 fetch = Lazy라고 하면 이 team을 프록시 객체로 조회합니다. 무슨 말이냐면 member 클래스만 db에서 조회한다는 말입니다.
Member findM = em.find(Member.class, member.getId());
System.out.println("findM.getTeam().getClass() = " + findM.getTeam().getClass());
이제 member를 find하면
원래는 팀이 조인으로 같이 불러왔는데 멤버만 가져옵니다.
getTeam을 하면 프록시로 나옵니다. 멤버를 조회할 때는 딱 멤버만 가져오고 팀은 프록시로 가져옵니다.
System.out.println("===");
findM.getTeam().getName();
System.out.println("===");
"프록시를 가져왔다는 것은??" 이 다음에 프록시의 메서드를 호출하면 이 시점에 객체가 초기화되면서 쿼리가 나가서 팀을 가져옵니다.
보면 === 사이에 쿼리가 나가면서 초기화됩니다.
-> 내부 매커니즘
멤버를 로딩할 때 팀 참조는 지연로딩으로 세팅이 되어있으니 프록시를 가져온 것이고 이것을 지연로딩이라고 합니다. 팀을 사용할 때 초기화가 됩니다. 방금 시나리오대로 하면 멤버의 값만 출력하는 경우가 많다면 그때는 지연로딩이 맞습니다. 근데 반대로 비즈니스 로직에서 팀과 회원을 무조건 같이 쓰면 지연로딩을 쓰면 이렇게 멤버 따로 팀 따로 쿼리가 계속 나갈 것입니다. 이러면 성능상 손해입니다.
-> 멤버와 팀을 같이 쓰면? ✅
즉시 로딩으로 함께 조회할 수 있습니다. eager로 바꾸고 실행하면 멤버와 팀을 한번에 조인해서 가져오고 한번에 진짜 팀까지 객체를 가져온 것입니다. 프록시라는게 없어서 초기화가 필요없습니다.
어플 개발에서 대부분 멤버와 팀을 같이 쓰면 즉시 로딩을 씁니다.
- 프록시와 즉시로딩 주의사항
정말 중요한 내용입니다. 실무에서는 즉시로딩을 절대로 쓰면 안 됩니다. 왜 그럴까요?
1. jqal에서 n+1 문제 발생
eager로 세팅하고 find하면 팀과 멤버가 같이 쿼리가 한 번 나올 것입니다.
근데 find말고 jpql로 전체 멤버를 조회해보면 쿼리가 eager인데 두번 나옵니다. em.find는 pk로 찾아오는 것을 jpa 내부저그로 다 최적화되어있는데 jpql은 sql로 그대로 날라가는 것입니다. 그러면 당연히 멤버만 가져옵니다.
jpa가 가져온 멤버를 보니깐 member가 즉시로딩으로 되어있으니 즉시로딩이라는 것은 가져올 때 무조건 값이 들어있어야 하는 것이라서 멤버 쿼리 나가고 멤버의 개수가 10개면 개수만큼 다시 쿼리가 나갑니다. 최초 쿼리가 1이고 1 때문에 추가 쿼리가 n번 나간다고 해서 n+1 문제입니다.
- 영속성 전이 cascade
부모를 저장할 때 연관된 자식 엔터티도 같이 저장하고 싶을 때 사용하는 것입니다. 지연로딩이나 연관관계와는 전혀 상관없는 내용입니다.
@Entity
public class Parent {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@OneToMany(mappedBy = "parent")
private List<Child> children = new ArrayList<>();
private String name;
public void addChild(Child child) {
children.add(child);
child.setParent(this);
}
@Entity
public class Child {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
Parent 객체를 만들고 Child 객체를 만듭니다. 예를들어서 Parent가 child를 List로 가지고 child는 parent를 참조해서 다대1 양방향 관계를 가집니다. 그리고 노예 측에(1측) 연관관계 편의 메서드를 만듭니다. 노예측에서 노예와 주인 측에 데이터를 넣는 상황입니다. 이렇게 양방향 연관관계를 만들었습니다.
-> 실행
try {
Parent parent = new Parent();
Child child1 = new Child();
Child child2 = new Child();
parent.addChild(child1);
parent.addChild(child2);
em.persist(parent);
em.persist(child1);
em.persist(child2);
tx.commit();
부모와 자식 여러개를 만들고 addChild해서 두명의 다측을 넣습니다. persist 할 때 다측인 주인에만 값을 넣어도 된다고 했는데 양방향이 되면 편의 메서드로 둘 다 넣으라고 했습니다. 그런데 이때 em.persist를 3번 해야할 것입니다.
이렇게 해야 3번 insert가 될 것입니다.
em.persist(parent);
// em.persist(child1);
// em.persist(child2);
한 번만 하면 당연히 쿼리가 한 번만 나갈 것입니다.
그런데 내가 부모 중심으로 코드를 작성하고 있는데 이렇게 3번이나 persist하는 게 귀찮습니다. 따라서 부모 중심으로 코드를 짤 때는 부모가 자식을 관리해줬으면 좋겠고 자식은 자동으로 persist이 되면 좋을 것 같습니다.
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
private List<Child> children = new ArrayList<>();
이때 쓰는게 cascade입니다. 중심이 되는 1측에 cascade.All을 하면
persist를 부모만 했는데 child 둘 다 persist이 됐습니다. DB에도 부모만 persist을 했는데 자식 데이터가 다 들어갔습니다. 이게 cascade입니다. 연관관계 이런 건 전혀 관계 없고 persist를 할 때 persist 하려는 객체에 cascade를 표시한 (지금의 child) 객체도 persist을 날려주는 것이 cascade입니다.
🚨 주의
영속성 전이라고 해서 연관관계 매핑하는 것과 아무 관련이 없습니다. persist할 때 영속화를 편리하게 하는 것이지 그 이하도 이상도 아닙니다.
=> 옵션
all이나 persist, remove만 쓰게 됩니다. cascade를 표시한 것을 정말 life cycle을 다 맞춰야하면 all을 하고 저장할 때만 할 거면 persist를 쓰면 됩니다.
db 오션 중에서 cascade가 1측 테이블의 컬럼이 삭제 될 때 삭제된 행의 pk를 fk로 가지는 다측 테이블이 같이 삭제되는 것인데 이렇게 연관된 것을 같이 관리하는 것이 cascade입니다. 따라서 삭제될 때 같이 삭제되게 하려면 remove로 합니다. 실무에서 많이 사용합니다.
언제 쓰나요? ✅
1대다에 다 거는 게 아닙니다. 하나의 부모가 자식들을 관리할 때 의미가 있습니다. 게시판이랑 첨부파일 같은 경우에는 쓸 수 있습니다. 첨부파일 여러개를 하나의 게시물에서만 관리하기 때문에 이럴 때는 쓸 수 있습니다.
언제 안 쓰나요? ✅
지금 cascade를 붙이려는 엔터티(child)를 다른 엔터티에서도 관리하면 쓰면 안됩니다. parent라는 이 엔터티만 child를 관리하거나 얘만 연관관계가 있으면 상관이 없는데 다른 데랑 연관이 있으면 쓰면 안되고 소유자가 하나 일때만 써야 합니다. 단일 엔터티에 완전히 종속적이면 라이프 사이클이 완전히 똑같아서 써도 됩니다.
- 고아 객체
orphanremoval은 말 그대로 부모 엔터티와 연관관계가 끊어진 자식 엔터티를 자동으로 삭제하는 기능으로 고아가 되면 자동으로 지우는 기능입니다.
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();
Parent findParent = em.find(Parent.class, parent.getId());
findParent.getChildren().remove(0);
onetomany에 orphanremoval = true를 하고 par가 관리하고 있는 child list에서 빠지면 고아가 되어 삭제됩니다. par에서 get으로 자식 리스트를 가져오고 컬랙션에서 remove를 하면 삭제됩니다.
orphanremoval 때문에 delete 쿼리가 끊어진 자식으로 자동으로 나갑니다.
🚨 주의점
함부로 쓰면 큰일나는 기능입니다. 이것도 참조하는 기능이 하나일 때만(게시판, 첨부파일) 사용해야 합니다. 개인 소유할 때만 사용해야 하며
Parent parent1 = em.find(Parent.class, parent.getId());
em.remove(parent1);
부모를 제거하면 자식도 고아가 되어서 cascadetype.remove 처럼 동작합니다.
remove로 parent를 지우면 delete가 2개 나갑니다.
- 영속성 전이와 고아객체
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
이 둘을 같이 쓸 수 있습니다. 이걸 다 키게 되면 부모 엔터티를 통해서 자식의 생명주기를 관리할 수 있습니다. em.persist할 때도 remove 할때도 parent만 했고 자식을 지우지 않아도 자식이 제거되고 부모만 저장해도 자식이 저장됩니다. parent가 자식의 생명주기를 관리합니다.
'[백엔드] > [JPA | 학습기록]' 카테고리의 다른 글
[문법] 다양한 연관관계 매핑 (3) | 2024.08.10 |
---|---|
[문법] JPA 동작 원리 (0) | 2024.08.09 |
JPA PART.고급 매핑 (0) | 2023.08.20 |
JPA PART.다양한 연관관계 매핑 (0) | 2023.08.17 |
JPA PART.연관관계 매핑 기초 (0) | 2023.08.12 |