| 도메인이란?
JPA 설계에서 도메인은 비즈니스에서 다루는 핵심 개념과 그 개념들의 관계를 객체(entity)로 표현한 것이다. 도메인은 비지니스 중심 객체 설계로, DB에 끌려가는 게 아니라 비지니스에 맞게 객체를 설계하고 JPA가 그 객체를 저장할 수 있게 도와주는 역할을 한다. 도메인은 엔티티 선언을 통해 DB에 저장되는 객체들을 구현한다.
| 양방향 매핑과 cascade
양방향 관계는 단방향 관계 2개를 맺은 것이고, 이때 외래 키를 가진 객체를 주인으로 설정한다. 이때 주인만 외래 키를 관리할 수 있고, 이 외래 키를 통해 참조한 객체 DB 값을 직접 수정할 수 있다. 양방향 매핑을 하기 위해서는 mapped by 속성이 필요한데, mapped by는 쉽게 말하면 “나는 주인 아니야. 외래키 관리 안해.”라고 선언하는 역할이다. 비주인은 mappedBy 속성으로 주인을 참조하며, 외래 키 값을 직접 변경할 수는 없다(읽기만 가능).
그렇지만 보통 비주인 엔티티가 부모 객체일 때가 많은데(user가 부모 객체, user에 딸린 reviewPost가 자식 객체. 부모 객체인 user가 자식 객체인 reviewPost를 관리하도록 하는게 맞지만, 외래키는 reviewPost에 있기 때문에 주인은 자식 객체인 reviewPost가 되어버린다. 따라서 부모 객체가 자식 객체를 관리하기 어려워진다.), 이렇게 부모 객체에서 자식 객체를 따로 관리하게 해주고 싶을 때는 cascade를 쓴다. cascade는 주인/비주인 여부와 상관없이, 어떤 엔티티에 대해 작업(저장, 삭제 등)을 할 때, 그 엔티티가 가지고 있는 연관된 엔티티에도 그 작업을 전이시킬 수 있도록 해주는 기능이다. 따라서 이 cascade를 활용해 부모 객체에서 연관된 객체들의 생명주기를 관리할 수 있게 한다.
따라서 양방향 매핑은 양쪽에서 관계를 자주 접근해야 할 때, 혹은 부모 객체에서 자식 객체의 생명 주기를 관리해야 할 때(부모 객체가 삭제되면 자식 객체도 삭제) 등에 많이 쓰인다. 그러나 양방향 매핑은 성능에 도 영향을 주고, 메모리 사용량을 늘릴 수도 있으며 코드가 복잡해지면서 유지 관리성에서 효율이 떨어질 수도 있다는 단점이 있다. 따라서 적절하게 필요할 때만 균형있게 사용하는 것이 중요하다.
| N+1 문제에 관하여
정의; N+1 문제란?
N+1 문제는 1:N 연관관계가 있는 엔티티가 있을 때 발생하는 문제이다. 구체적으로 말하면, 특정 객체를 대상으로 수행한 쿼리가 해당 객체가 가지고 있는 연관관계 또한 조회하게 되면서 N번의 추가적인 쿼리가 발생하는 문제를 말한다. 예를 들어, user 엔티티가 있고 그 하위 객체인(1:N관계인) matchMissions 엔티티가 있다고 치자. 이 상위 객체들(user 목록)을 먼저 한번에 가져오고, 그 후에 그 각 객체(각 user)에 연관된 자식 객체들(미션들)을 조회하게 되면, 상위 객체 개수만큼 추가 쿼리(N번)가 실행되면서 총 1+N 쿼리가 발생하게 되는 것이다. 이게 바로 N+1 문제이다.
List<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(user.getName());
// 여기가 문제! -> 유저 1명마다 DB 쿼리가 또 나감
for (MatchMission mm : user.getMatchMissions()) {
System.out.println(" 참여 미션: " + mm.getMissionName());
}
}
원인; N+1 문제는 정확히 왜 발생하는 것일까?
원인을 알려면 먼지 JPA 실행 시 객체를 조회하는 과정을 알아야 한다. jpaRepository에 정의한 인터페이스 메서드를 실행하면 JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행하게 된다. JPQL은 엔티티 객체를 대상으로 하는 쿼리 언어로서 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 쿼리를 한다(SQL이 테이블을 조회하는 거라면, JPQL은 자바 클래스(엔티티)를 조회한다). JPQL은 findAll()이란 메소드를 수행하였을 때 해당 엔티티를 조회하는 select * from Owner 쿼리만 실행하게 된다. 즉 JPQL 입장에서는 연관관계에 있는 다른 객체들을 무시하고 해당 엔티티 기준으로만 쿼리를 조회하는 것이다. 그렇기 때문에 연관된 엔티티 데이터가 필요한 경우, FetchType으로 지정한 시점에 조회를 별도로 호출하게 된다. 이 FetchType에는 Lazy, Eager 두가지 종류가 있다. Eager는 어떤 객체를 조회할 때, 연관관계에 있는 객체도 같이 미리 조회하는 반면에, Lazy는 해당 객체만 조회해오고 연관관계에 있는 다른 객체는 조회를 미룬다. 해당 연관관계에 있는 객체에 대한 추가작업이 있어야만 불러오게 되는 것이다. 이 두가지 FetchType에서 모두 N+1 문제가 발생할 수 있다.
EAGER(즉시 로딩)인 경우
- JPQL에서 만든 SQL을 통해 데이터를 조회
- 이후 JPA에서 Fetch 전략을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회
- 2번 과정으로 N + 1 문제 발생
LAZY(지연 로딩)인 경우
- JPQL에서 만든 SQL을 통해 데이터를 조회
- JPA에서 Fetch 전략을 가지지만, 지연 로딩이기 때문에 추가 조회는 하지 않음
- 하지만, 하위 엔티티를 가지고 작업하게 되면 추가 조회가 발생하기 때문에 결국 N + 1 문제 발생
즉 JPA는 JPQL을 실행하는 과정에서, 첫 쿼리는 연관된 하위 엔티티까지 알아서 한 번에 가져오지 않는다. 이후 추가로 조회하는 과정이 생기기 때문에 이러한 문제가 발생하는 것이다.
해결 방법
- Fetch Join 연관된 엔티티를 JPQL에서 JOIN FETCH로 명시적으로 불러오는 방법. 쿼리문으로 직접 쓰는 방법이다. 이 방법을 사용한다면 한 번의 쿼리로 관련된 객체까지 함께 가져올 수 있다.
@Query("SELECT u FROM User u JOIN FETCH u.matchMissions") List<User> findAllWithMatchMissions(); - @EntityGraph
쿼리를 직접 쓰지 않고도 JPA가 내부적으로 fetch Join을 자동으로 해주게끔 설정하는 어노테이션이다. 다만 내부 동작이 JPQL보다 덜 명확할 순 있다.@EntityGraph(attributePaths = "matchMissions") List<User> findAll(); - Batch Size 설정
이렇게 size를 10으로 설정하면, 유저 10명에 대한 LAZY 연관관계를 미리 10개 묶어서 한번에 조회하게 되고, 따라서 쿼리 횟수가 대폭 감소한다. 지연 로딩이라면 지연 로딩된 엔티티 최초 사용시점에 10건을 미리 로딩해두고, 11번째 엔티티 사용 시점에 다음 SQL을 추가로 실행한다. 여전히 쿼리가 여러번 나가긴 하지만, 이렇게 묶어서 한번에 조회한다는 점에서 LAZY를 완화할 수 있는 방법이다.spring: jpa: properties: hibernate: default_batch_fetch_size: 10 - @OneToMany 등의 연관관계에서 N+1이 일어나는 걸 막기 위해, JPA가 일정량의 연관 데이터를 한 번에 가져오도록 묶어주는 설정이다. 이는 application.yml 파일에서 설정하게 된다.
'UMC스터디' 카테고리의 다른 글
| JPQL과 QueryDSL (1) | 2025.06.13 |
|---|---|
| Spring Boot의 핵심 개념 정리 (0) | 2025.04.08 |
| SQL 기본 정리 (0) | 2025.03.27 |
| 서버 관련 공부 (0) | 2025.03.21 |
| TCP/IP 4계층 모델을 활용한 홈페이지 접속 방법 설명 (0) | 2025.03.21 |