JPA InheritanceType.JOINED 전략으로 리팩토링 진행

2024. 7. 3. 13:31

 OneToOne 관계에서 FetchType.LAZY는 만능이 아니다 ?!

 

 여태까지 ToOne 관계에서 무분별한 조회를 막기 위해 default인 EAGER가 아닌 LAZY로 설정하면 프록시로 호출되기 때문에, 실제 조회 쿼리가 실행되지 않는다고 알고 있었다.

하지만, @OneToOne 양방향 관계에서, 연관관계의 주인이 아닌 테이블 조회 시, LAZY는 동작하지 않는다.

그 이유는 생각해보면 단순하다. JPA에서 양방향 연관관계 설정 시에 FK는 연관관계의 주인인 테이블이 보유한다. 즉, Member와 Patient가 있고 Patient가 연관관계의 주인이라면 Patient 테이블에는 member_id가 존재하지만 Member 테이블에는 patient_id가 존재하지 않는다. 

그러면 여기서 LAZY 전략은 기본적으로 프록시를 이용해 동작한다. LAZY가 설정되어 있다면 실제 객체를 가져오는 것이 아니라 프록시를 호출한다. 여기서 만약 연관관계 주인인 Patient를 호출한다면 Member는 프록시가 호출되기 때문에 LAZY가 잘 동작한다.

하지만 문제는 연관관계의 주인이 아닌 테이블을 조회했을 경우(여기서는 Member)이다.

Member 테이블은 patient_id를 가지고 있지 않다. 즉, Member 필드의 Patient는 null이고 프록시는 기본적으로 null을 감싸지 못한다. 만약 null이 아니라고 해도 Member는 그 값을 알지 못하기에 프록시를 사용하지 못하고, 먼저 그 값이 있는지 확인하기 위해 조회 쿼리를 실행한다. 결국 LAZY로 설정을 해도 EAGER와 같은 즉시 로딩을 실행하는 것이다. 

 

나는 이를 몰랐고, LAZY가 잘 동작할거라 판단하였기에 Member와 Patient(환자), Caregiver(간병인)를 단순하게 1:1 관계로 설정하여 개발을 진행하였다.

 

그 결과 나는 조회 쿼리 폭탄을 맞았는데..

Member를 조회할 때마다 Patient와 Caregiver 조회 쿼리가 각각 호출되어 한 번의 쿼리(Member)를 예상했지만 실제로는 3번의 쿼리가 호출된 것이다. 

 

그렇다면 이를 해결하기 위해 단순하게 연관관계의 주인을 Patient와 Caregiver쪽에 두면 되지 않느냐라고 생각할 수 있는데,

프로젝트 특성상 역할에 따라 Patient와 Caregiver를 조회하는 경우도 많았기에 결국 쿼리가 3번씩 호출되는 건 필연적인 문제였다.

 

또 하나의 문제는 Member, Patient, Caregiver를 너무 각각의 엔티티로 판단하고 설계한 점이다.처음 설계 시에는 단순하게 Member는 단순하게 가입 시 받는 정보만 갖고 있고,(username, password, role 등)Patient, Caregiver 엔티티에서 세부 정보를 각각 보관하자 (name, contact, significant 등) 라고 생각하여 진행하였다. 하지만 Patient와 Caregiver 엔티티에서 중복된 필드들이 많아졌고 

 

위와 같은 문제를 해결하기 위해 두 가지 방법이 있었는데,

  1. 모든 조회에 fetch join을 사용하여 한 번의 쿼리로 조회하기 or global_batch_fetch_size 사용
  2. 프로젝트 구조 변경(@OneToOne 양방향 연관관계 사용 지양)

모든 조회 쿼리에 fetch join을 사용하는 것은 모든 조회 쿼리에 fetch join을 작성하는 비용이 들 뿐만 아니라 spring data jpa의 추상화된 crud 기능 이용을 제대로 못한다는 생각이 들었다. global fetch size 또한 근본적인 해결책이 아니라 판단하였다.

 

나는 결론적으로 2번을 택하였고, 단순히 OneToOne 연관관계를 수정하는 것이 아닌, JPA가 제공하는 상속 기능을 통하여 앞서 나온 문제를 해결하였다.

 

상속을 통한 해결을 선택한 이유는

  1. Member와 Patient/Caregiver 엔티티가 긴밀하게 엮여있고, Patient와 Caregiver에서 중복된 필드들이 존재하였다.(이름, 나이, 성별 등) 이와 같은 중복 필드들은 Member에서 공통적으로 관리가 가능한 것이고 Patient와 Caregiver에서는 공통된 필드가 아닌 자신만의 필드들만 관리하면 되었기 때문이다.
  2. 또한 상속 기능을 적용하면 앞서 나온 조회 쿼리가 세 번씩 호출되는 것을 막을 수 있기 때문이다. JPA에서 상속 전략은 사용할 수 있는 전략이 두 가지 정도가 있었다.(TABLE PER CLASS는 UNION 사용으로 인한 성능 저하로 제외)  
    • SINGLE TABLE : 하나의 테이블에서 모든 필드 관리. 간단하고 조회 시 단일 테이블 조회 쿼리만 나가서 성능이 대체로 빠르다. 하지만 NULL 값 많아지는 것과  컬럼수가 많아질 시 오히려 성능 저하가 일어난다는 단점이 있다.
    • JOIN TABLE : 상속 관계로 묶인 테이블을 JOIN으로 조회한다. 가장 일반적이고 정규화가 되어 테이블별로 정보를 한눈에 파악하기 쉽지만 JOIN에 의한 성능저하가 있을 수 있고 삽입이나 수정 시 쿼리가 두 번 호출된다는 단점이 있다.

이 중 나는 JOIN TABLE 전략을 택하였는데, 

  1. SINGLE TABLE로 진행 시, 조회와 삽입 삭제가 빠르지만, 자식 클래스가 매핑한 모든 필드에 NULL을 허용해야 한다는 것과 일정 컬럼 초과 시 성능이 느려진다는 이유에서 이다. 현재 Member와 Patient, Caregiver의 모든 필드의 개수는 21개 인데, 이후 더 추가될 확률이 높다. 20개가 넘는 컬럼들을 SIGNLE TABLE로 관리한다는 것이, 성능이 느려질 수 있다는 것은 차치하더라도 가독성 측면에서 좋지 않을 것이라 판단하였다.
  2. JOIN 전략에서, JOIN으로 인한 성능 저하는 영향이 크지 않고, 정규화가 되어 있어 객체와 - DB 간의 관리가 용이하고 저장공간이 효율화되기 때문에 장점이 크다고 판단하였다. 

그 결과,

  • Member 조회 시
    • 기존 코드 : Member(1) + Patient(1) + Caregiver(1) : 총 3번(1+1+1)의 쿼리
    • 리팩토링 후 코드 : Member JOIN Patient JOIN Caregiver(1) : 총 1번의 쿼리
  • Patient or Caregiver 조회 시
    • 기존 코드 : Member 조회(쿼리 3번) + Member에 해당하는 Patient or Caregiver 추가 조회(1번)  : 총 4번(3+1)의 쿼리
    • 리팩토링 후 코드 : Patient JOIN Member JOIN Caregiver(1) : 총 1번의 쿼리

 

이처럼 상속을 도입함으로써 프로젝트 구조 개선 + 조회 속도 향상을 이루어낼 수 있었다.

 

'PatientPal' 카테고리의 다른 글

7/10 ~ 7/14  (0) 2024.07.10
Querydsl + No-Offset 페이지네이션 구현  (0) 2024.07.09
7/1 ~ 7/7  (0) 2024.07.01
AWS, Presigned URL 이용한 프로필 이미지 업로드 구현기  (0) 2024.06.29
6/24 ~ 6/30  (0) 2024.06.24

BELATED ARTICLES

more