문제 개요
충전소 검색 기능 구현 간 ChargingStation (충전소) 엔티티를 경유하는 모든 SQL 질의문에서 의도하지 않은
select * From technical_manager 쿼리가 실행되는 문제가 발생했다.
이로 인해 N+1 문제가 발생해 검색 성능을 저하시켰다.
fetch = LAZY 로 LAZY 로딩 설정을 하였으며, DTO 상에서도 Technical_Manager (유지보수자) 엔티티 를 거칠 이유가 없었기에 문제 발생 원인과 해결 과정에 대해 아래와 같이 기술한다.
원인 분석
OneToOne, 양방향 관계로 연결된 엔티티 구조
ChargingStation.java (변경 전)
public class ChargingStation {
@Id
private String statId;
// ... 생략
@OneToOne(fetch = LAZY, mappedBy = "chargingStation")
private TechnicalManager technicalManager;
// ... 이하 생략
}
TechnicalManager.java (변경 전)
public class TechnicalManager extends BaseTime {
@OneToOne(fetch = LAZY)
@JoinColumn(name = "stat_id")
private ChargingStation chargingStation;
}
발생 쿼리 (문제 쿼리)
# 아래와 같은 쿼리는 양방향으로 연결되어 있는 charging_station을 한 건 조회할 때마다 발생함
Hibernate:
select
stat_id,
member_id,
name,
field
from
technical_manager
where
stat_id = ?
1:1 양방향으로, 연관관계의 주인을 TechnicalManager (유지보수자) 로 설정했던 이유는 비즈니스 로직적으로 유지보수자를 통해 담당 ChargingStation (충전소) 을 조회할 소요가 있다고 판단했기 때문이다.
그래서 충전소 또한 해당 유지보수자를 등록하는 서비스를 가지고자 한다면 충전소도 유지보수자를 양방향으로 참조해 이를 대비하고자 했다.
그러나, 연관관계의 주인이 아닌 곳(ChargingStation, 충전소)에서 조회할 때 조회하지 않는 주인 엔티티 (유지보수자) 가 조회되어 N+1 문제가 발생하는지에 대해 확인할 필요가 있었다.
OneToOne 양방향, 주인이 아닌 엔티티가 주인 엔티티를 조회하는 이유
DB 1:1 양방향 관계에서 테이블 구조
1) DB 상 충전소 테이블에는 유지보수자의 키가 없기 때문에 충전소 엔티티 입장에서는 충전소에 연결되어 있는 유지보수자가 null인지 아닌지를 직접 조회해보기 전까지 알 수 없다.
public class ChargingStation {
@Id
private String statId;
// null 인지 아닌지 까봐야 안다.
@OneToOne(fetch = LAZY, mappedBy = "chargingStation")
private TechnicalManager technicalManager;
}
2) 또한, LAZY 로딩으로 설정해서 충전소 아이디가 유지보수자에 해당하는 연관 엔티티에 대해 프록시 객체를 사용 할 것처럼 보이지만, 실제로는 프록시 객체를 사용하지 않는다.
- 실제로 ChargingStation이 가지고 있는 TechnicalManager 객체가 null 값인지 체크할 때 ChargingStation에서는 이를 TechnicalManager를 조회하지 않는 이상 알 방도가 없다.
- 따라서, 프록시 객체가 의미없게끔 LAZY로 설정해도 EAGER로 fetch한다는 점이다.
- NPE 방지를 위해 1:1 관계에서는 프록시 객체 사용이 어렵다는 점은 JPA ORM 공식문서에도 나와있다.
The reason for this is that owner entity MUST know whether association property should contain a proxy object or NULL and it can't determine that by looking at its base table's columns due to one-to-one normally being mapped via shared PK, so it has to be eagerly fetched anyway making proxy pointless.
[JPA ORM Programming]
→ 결론 : 1:1 양방향에서는 프록시 객체는 직접 참조하기 전까지는 (까보기 전까지는) NULL인지 아닌지 모르므로 LAZY 로딩에 프록시 객체가 의미 없고, EAGER 로딩으로 끌고 온다.
OneToOne 양방향은 안되는데 OneToMany 양방향은 되는 이유
앞서 OneToOne 양방향에서는 연관관계의 종속 엔티티가 주인 엔티티를 참조할 때 프록시 객체 사용이 어렵다는 것을 알 수 있었다.
그렇다면, OneToOne 양방향은 프록시 객체 사용이 어려운데, OneToMany 양방향은 가능할까?
Article.java
public class Article {
@Id
private Long id;
@OneToMany(mappedBy = "article")
@Builder.Default
private List<Comment> commentList = new ArrayList<>();
}
위의 예시는 일반적으로 게시판을 구성할 때 양방향으로 게시글이 해당 게시글에 해당하는 댓글 엔티티를 참조하는 경우를 정의한 연관관계와 엔티티이다.
이 경우에는 연관관계의 주인은 Comment, 즉 댓글이 되며 종속되는 게시글에서 엔티티를 조회하더라도 관련 댓글에 대한 LAZY 로딩이 적용된다. 또한 프록시 객체 또한 작동한다.
적용이 되는 이유는 아래의 JPA ORM 공식문서를 통해 답을 알 수 있다.
many-to-one associations (and one-to-many, obviously) do not suffer from this issue. Owner entity can easily check its own FK (and in case of one-to-many, empty collection proxy is created initially and populated on demand), so the association can be lazy.
OneToMany에서는 빈 컬렉션이 초기화 될 때 프록시가 생긴다. 즉, OneToOne 연관관계와 달리 OneToMany는 ArrayList로 초기화되기 때문에 프록시 문제가 해결 및 LAZY 로딩이 가능해진다.
해결 과정
Batch Size로 해결해보기 (실패)
spring:
jpa:
properties:
hibernate.default_batch_fetch_size: 100
혹시나 batch size 조정을 통해 IN 절로 한꺼번에 쿼리가 실행될 수 있을까 했으나 LAZY 로딩이 안되는 이유 및 프록시 객체가 실행이 되지 않는 문제의 원인이 명확했기에 실패했다.
Inner Join으로 해결해보기 (실패)
# 아래와 같은 쿼리는 양방향으로 연결되어 있는 charging_station을 한 건 조회할 때마다 발생함
Hibernate:
select
stat_id,
stat_nm,
lat,
lng,
...
from
charging_station as cs
inner join
technical_manager as tm
on tm.stat_id = cs.stat_id
inner join을 통해 명시적으로 N+1 문제를 Join을 통해 해결할 수는 있었으나,
이 또한 조회할 필요가 없는 TechnicalManager를 명시적으로 조인해야 한다는 한계점은 변함이 없었다.
비즈니스 로직 수정, 단방향 매핑으로 수정 (성공)
ChargingStation.java (변경 후)
public class ChargingStation {
@Id
private String statId;
// 연관관계 삭제(양방향 -> 단방향)
// @OneToOne(fetch = LAZY, mappedBy = "chargingStation")
// private TechnicalManager technicalManager;
// ... 이하 생략
}
최종적으로는 충전소가 가진 유지보수자 필드를 제거함으로써 단방향으로 수정했고 DB 테이블과 엔티티 구조를 일치시킴으로써 Hibernate가 발생시키는 엔티티와 테이블 간의 괴리감을 삭제시키는 방향으로 결론지었다.
향후 비즈니스 로직적으로 ChargingStation이 TechnicalManager를 참조할 일이 있다면 JPA 편의 메소드를 통해 명시적으로 해당 도메인의 레포지토리에서 영속성을 유지시켜 호출하는 방향으로 결정했다.
결론
- DB 테이블 관점에서 엔티티 설계를 진행하자. JPA hibernate 스키마에 너무 의존하면 위험하다.
- ERD 설계에 있어 DB 친화적인 양방향보다 단방향을 우선적으로 고려해 Side Effect를 최소화하며, 이후에 비즈니스 로직이 확장될 시 양방향으로 전환하자.