2024. 10. 11. 13:32ㆍ내일배움캠프/Schedule Management
이번에는 '1 : N 관계' 에서의 '전체(목록) 조회를 구현하면서 겪은 기묘한 모험담(?) 을 기록해보려 한다. 이번 뿐만 아니라 이후 프로젝트 또는 실무에서도 충분히 겪을 수 있는 상황이라 생각해 기록하게 되었다. 이번에 작성한 코드는 여기서 확인할 수 있다.
먼저 현재 프로젝트는 'Spring Data JPA' 를 활용한 프로젝트이기에 최대한 'JPA' 를 활용하는 목적을 가지고 있다. 프로젝트 진행 중 'Lv.3 요구사항 - 페이지네이션' 을 반영하게 되며 문제가 연이어 터지게 되었다. 여기서 말하는 요구사항은 '일정 엔티티' 가 '댓글 엔티티' 와 '1 : N' 관계를 가져야하고, '일정' 을 전체(목록) 조회를 할 때 일정의 정보와 일정이 갖는 댓글 엔티티 개수를 반환해야 한다는 내용이다. 또한 '페이징' 한 결과를 반환해야 하는데 이 때 JPA 에서 제공하는 'Pageable' 인터페이스를 활용해야 한다는 내용 또한 명시되어 있다.
이미 두 엔티티 간의 연관관계는 요구사항에 따라 '1 : N' 으로 맺어진 상태이기에 '전체 일정 조회' 에 대한 메서드를 수정하기 시작했다.
0. 요청 파라미터 추가
먼저 '페이지 네이션' 을 위해 'ScheduleController.findAllSchedules()' 메서드에 전달 받을 요청 파라미터를 아래와 같이 추가했다.
@GetMapping("/search-condition")
@ResponseStatus(HttpStatus.OK)
public ResponseScheduleList findAllSchedules(@RequestParam(name = "author", defaultValue = "") String author,
@RequestParam(name = "title", defaultValue = "") String title,
@RequestParam(name = "pageNum", defaultValue = "0") int pageNum,
@RequestParam(name = "pageSize", defaultValue = "3") int pageSize) {
PageRequest pageRequest = PageRequest.of(pageNum, pageSize, Sort.by(DESC, "updateAt"));
return scheduleService.findAll(author, title, pageRequest);
}
추가된 요청 파라미터는 'int pageNum, int pageSize' 이다. 그리고 전달 받은 'pageNum, pageSize' 와 '정렬 정보' 로 Pageable 인터페이스의 구현체인 PageRequest 객체를 생성해 검색 조건과 함께 'ScheduleService.findAll()' 호출시 파라미터로 전달하였다.
1. 단순하게 일정 조회만
첫 번째로 든 생각은 "일정 정보도 필요하고 댓글 정보도 필요하니 그냥 일정 엔티티 목록을 조회하고 필요한 정보를 가져오면 되지 않을까?" 였다. 그래서 아래와 같이 Qeury(= JPQL) 를 작성하였다.
@Query(value =
"SELECT s " +
"FROM Schedule AS s " +
"WHERE s.author LIKE CONCAT('%', :author, '%') AND s.title LIKE CONCAT('%', :title, '%')"
)
Slice<Schedule> findAllByAuthorAndTitle(@Param("author") String author, @Param("title") String title, Pageable pageable);
검색 조건을 만족하는 일정 엔티티들을 메모리에 가져와 전달 받은 Pageable 객체를 통해 페이지네이션을 수행하게 된다. 여기까지는 "필요한 정보도 잘 가져왔고, Pageable 도 잘 사용했으니 됬다!" 라는 생각이 들었으나 막상 실행창에 출력된 Query(사용된)를 확인하고는 해당 방법 사용을 바로 포기했다.
분명 쿼리를 하나 날렸는데 무수히 많은 쿼리들을 마주한 것이다. 첫 번째 쿼리는 위에서 작성한 쿼리가 사용되었지만 내가 작성하지 않은 댓글 엔티티를 조회하는 쿼리들이 사용된 것이었다.
천천히 생각을 해보니 현재 두 엔티티 관계를 양방향으로 해두면서 댓글 엔티티에 아래와 같은 '지연 로딩' 을 적용해 놓은 것이 생각났다.
// Comment 클래스(Entity)에 작성된 필드
@ManyToOne(fetch = FetchType.LAZY) // 현재 '지연 로딩' 적용 중
@JoinColumn(name = "SCHEDULE_ID")
private Schedule schedule;
'지연 로딩' 은 일정 엔티티를 조회할 때 연관된 댓글 엔티티의 정보가 필요 없을 경우를 대비해 댓글 엔티티 정보에 접근하지 않으면 해당 엔티티를 조회하지 않도록 설정한 것인데, 일정 목록을 조회하고 각 일정의 댓글 개수를 확인하기 위해 댓글 엔티티에 접근하게 되면서 일정이 갖는 모든 댓글 엔티티를 조회하는 쿼리가 자동적으로 사용된 것이었다.
이런 문제(=상황)를 '1 + N 문제' 라 말하는데 '1(기능 수행을 위해 필요한 쿼리)' 외에 'N(기능 수행을 위해 자동적으로 사용된 쿼리)' 이 발생하는 문제이다. 현재 내가 DB 에 추가한 테스트 데이터가 많지 않아서 그나마 적은 것이지 한 일정에 수 많은 댓글을 가지고 있었다면 그 댓글 수 만큼 추가 쿼리가 나갈 것이고 만약 한 일정이 아니라 여러 일정이고 각 일정이 많은 댓글을 가지고 있다면 사용되는 쿼리는 기하급수적으로 늘어날 것이다. 이런 문제는 '1 : N' 관계에서 '1' 을 다수 조회할 때 접할 수 있다.
2. fetch join 사용
(1) 의 상활을 겪어 저멀리 기억의 저편(?) 에서 'fetch join' 이라는 녀석이 기억났다. 'fetch join' 은 성능 향상을 위해 JPQL 에서 제공하는 'JOIN' 의 한 종류이다. 'JOIN FETCH' 를 사용하게 되면 조회 대상 엔티티 정보와 해당 엔티티와 연관관계인 엔티티 정보를 한 번에 조회한다. 그래서 하나의 쿼리를 사용해 두 엔티티 정보를 조회할 수 있게 된다.
이 기억을 토대로 바로 Query 에 'JOIN FETCH' 를 아래와 같이 적용하였다.
@Query(value =
"SELECT s " +
"FROM Schedule AS s LEFT JOIN FETCH s.comments " +
"WHERE s.author LIKE CONCAT('%', :author, '%') AND s.title LIKE CONCAT('%', :title, '%')"
)
Slice<Schedule> findAllByAuthorAndTitle(@Param("author") String author, @Param("title") String title, Pageable pageable);
적용후 API 테스트를 해보니 쿼리도 한 번 사용되고 원하는 정보 또한 얻을 수 있었다. 신이나서 다른 페이지도 잘 동작되는지 총 일정 개수 이상의 페이지를 요청하면 제대로 빈 배열이 반환되는지 확인을 위해 추가 API 테스트를 진행했더니 아래와 같은 경고 문구를 볼 수 있었다.
2024-10-11T12:09:28.862+09:00 WARN 31448 --- [nio-8080-exec-3] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
해당 경고문구는 "실행에는 문제가 없을 수 있으나 메모리 낭비를 경고한다" 는 내용으로 해석해 볼 수 있는데, 사실 이는 'OOM(out of memory, heap 영역의 인스턴스 및 더미 테이터가 지정한 heap 영역보다 커질 때 발생)' 을 경고하는 문구로도 볼 수 있었다.
그런데 이상하지 않은가? 분명 나는 PageRequest 객체에 페이지 번호, 크기 정보를 담아 페이지네이션을 진행하기에 'fetch join' 을 하고 특정 범위의 레코드들만을 메모리로 가져와 작업을 할 터였다. 그런데 왜 몇개 되지도 않는 정보를 조회한다고 위 같은 경고문구가 뜨는 걸까? 이런 의문을 가지고 구글링을 하고 사용된 쿼리를 확인해보며 원인을 알 수 있었다.
일단, Pageable 를 활용한 페이지네이션을 할 때 'fetch join' 을 사용하면 벌어지는 상황에 대해 정리해 보면, 'fetch join ' 은 조회 대상 엔티티와 연관된 엔티티를 모두 한 번에 조회하는 기능을 수행한다. 그래서 사실 필요한 '조회 대상 엔티티 정보' 만을 메모리에 가져올 수 있게 'OFFSET, LIMIT' 를 사용해 주는게 맞다. 하지만 나의 경우 요구사항에 명시된 것 처럼 Pageable 을 활용하므로 쿼리에 'OFFSET, LIMIT' 를 사용하지 않았다. 그러니 일단 조회한 모든 엔티티의 정보를 메모리에 가져오고 Pageable 을 통한 페이징이 이루어진 것이다. 즉, 'OFFSET, LIMIT' 가 적용된 쿼리가 사용되지 않은 것이었다.
그러면 "그냥 쿼리에 OFFSET, LIMIT 를 쓰면 되지 않나?" 라는 생각이 들 수 있지만 그렇게 되면 Pageable 을 사용할 이유가 없다 페이지 번호 및 크기 정보로 자동적으로 페이징을 해주는 것이 목적이기에 직접 페이징을 하면 Pageable 을 사용할 필요가 없다.
하지만 나의 경우 요구사항에 따라 Pageable 을 사용해야 한다. 또한 '1 : N' 관계의 엔티티를 조회해야 하므로 'fetch join' 을 사용해야 '1 + N' 문제를 해결할 수 있다. 한 마디로 이도 저도 못하는 기묘한(?) 상황을 마주하게 된 것이다.
3. Batch Size 설정
문제 해결을 위해 더 찾아본 결과 한 번에 메모리에 가져오는 크기를 지정할 수 있는 방법을 찾게 되었다. 쿼리에 '@BatchSize' 어노테이션을 사용하거나 'application.properties' 에 전역적으로 지정하는 방법이 있었는데, 일단 어노테이션을 사용해도 똑같이 'OOM' 을 경고하는 문구를 볼 수 있었으며, 전역적으로 설정하는 방법 적용 전 "만약 한 번에 조회해야 하는 일정의 개수가 변경된다면 결국 말짱 도루묵 아닌가?" 라는 생각이 들어 전역설정 까지는 진행하지 않았다.
뭔가 좀 더 근본적으로 문제를 해결할 수 있는 방법을 더 고민하게 되었다.
4. 쿼리 꼭 한 번만 날려야 할까?
문득 든 생각은 "일정 목록 조회 시 반드시 하나의 쿼리만을 사용해야 하는가?" 라는 생각이었다. 이 생각은 곧 "조건에 해당하는 엔티티를 조회하고 Pageable 을 통해 페이징을 진행한 뒤에 조회한 일정의 'id' 들을 모아서 각 일정과 연관된 댓글 엔티티들을 조회하면 되지 않을까?" 라는 생각으로 이어졌다. 긴가민가 하지만 어느 정도 갈피가 잡힐 듯 하여 바로 코드를 수정했다.
// 검색 조건과 페이지 정보에 해당하는 일정 엔티티들 조회
@Query(value =
"SELECT s " +
"FROM Schedule AS s " +
"WHERE s.author LIKE CONCAT('%', :author, '%') AND s.title LIKE CONCAT('%', :title, '%')"
)
Slice<Schedule> findAllByAuthorAndTitle(@Param("author") String author, @Param("title") String title, Pageable pageable);
// 조회한 일정들과 연관된 댓글 엔티티들 조회
@Query(value = "SELECT s " +
"FROM Schedule AS s LEFT JOIN FETCH s.comments " +
"WHERE s.id IN :ids ORDER BY s.updateAt DESC"
)
List<Schedule> findAllByScheduleIdIn(@Param("ids") List<Long> ids);
'findAllByAuthorAndTitle()' 메소드에서는 (1) 에 진행한 것 처럼 단순하게 검색 조건에 해당하는 일정 엔티티들을 조회하고 Pageable 로 페이징을 수행한 결과를 반환한다. 그리고 ScheduleService 에서는 반환 받은 일정 목록에서 일정의 'id' 값을 모아 'findAllByScheduleIdIn()' 메서드를 호출해 조회한 일정 엔티티들과 연관된 댓글 엔티티들을 조회하게 된다.
추가된 'findAllByScheduleIdIn()' 메서드의 경우 이미 Pageable 을 통한 페이징이 끝났기에 자유롭게 'fetch join' 을 사용할 수 있었다. 그리고 이미 조회할 일정 엔티티들을 알고 있어 'LIMIT, OFFSET' 없이 쿼리를 작성할 수 있었고 'fetch join' 을 사용했기에 '1 + N' 문제도 해결할 수 있다.
실제 API 테스트 진행시 사용된 쿼리를 보면 항상 2개의 쿼리가 사용되는 것을 확인할 수 있었다.
Hibernate:
select
s1_0.id,
s1_0.author,
s1_0.body,
s1_0.create_at,
s1_0.title,
s1_0.update_at
from
schedule s1_0
where
s1_0.author like concat('%', ?, '%') escape ''
and s1_0.title like concat('%', ?, '%') escape ''
order by
s1_0.update_at desc
limit
?
Hibernate:
select
s1_0.id,
s1_0.author,
s1_0.body,
c1_0.schedule_id,
c1_0.id,
c1_0.author,
c1_0.body,
c1_0.create_at,
c1_0.update_at,
s1_0.create_at,
s1_0.title,
s1_0.update_at
from
schedule s1_0
left join
comment c1_0
on s1_0.id=c1_0.schedule_id
where
s1_0.id in (?, ?, ?)
order by
s1_0.update_at desc
5. 마침내
단 2개의 쿼리를 사용해 조회한 일정 엔티티들과 연관된 댓글 엔티티들의 정보를 모두 조회하였다. '1 + N' 문제도 없고 'OOM' 경고 문구도 뜨지 않는다. 심지어 요구사항에 명시된 대로 Pageable 도 잘 활용할 수 있었다. 이제는 조회한 정보를 토대로 DTO 를 생성해 반환하기만 하면 되었다.
public ResponseScheduleList findAll(String author, String title, PageRequest pageRequest) {
// 검색 조건에 해당하는 일정 목록 조회 및 페이징
Slice<Schedule> foundSchedules = scheduleRepository.findAllByAuthorAndTitle(author, title, pageRequest);
// 페이징 결과에서 각 일정들의 'id' 추출
List<Long> scheduleIds = new ArrayList<>();
foundSchedules.getContent().forEach(s -> scheduleIds.add(s.getId()));
// 페이징 결과의 일정들과 연관된 댓글 조회
List<Schedule> finish = scheduleRepository.findAllByScheduleIdIn(scheduleIds);
// 반환 할 DTO 객체 생성
List<ResponseSchedule> responseScheduleList = new ArrayList<>();
finish.stream().map(Schedule::makeResponse).forEach(responseScheduleList::add);
return ResponseScheduleList.builder()
.schedules(responseScheduleList)
.pageable(foundSchedules.getPageable())
.build();
}
"유레카!" 를 외치며 수정한 터라 변수명 같은 경우 다시 손봐야 겠지만 이렇게 '1 : N' 관계에서 전체 조회시 마주한 기묘한 모험(?)을 마무리할 수 있게 되었다.
6. 정리
한 번에 할 수 없으면 나누어서 해결해 보자.
'내일배움캠프 > Schedule Management' 카테고리의 다른 글
[일정 관리 앱] 도전 기능 요구사항 반영 (0) | 2024.10.14 |
---|---|
[일정 관리 앱] N : M(다대다) 관계 풀어내기 (0) | 2024.10.12 |
[일정 관리 앱] 댓글 CRUD API 테스트 (0) | 2024.10.11 |
[일정 관리 앱] 일정 수정, 삭제 API 테스트 (0) | 2024.10.09 |
[일정 관리 앱] 일정 생성, 조회 API 테스트 (0) | 2024.10.08 |