진행중인 팀 프로젝트에서 북마크 기능 관련 이슈가 생겼다.
현재 맛집 데이터 제공과 관한 프로젝트를 하고 있는데, 전체 데이터 수가 너무 많아 한번에 사용자에게 띄워주면 3~5초 이상 로드 시간이 필요했다. 따라서 무한 스크롤을 내가 적용했었다.
북마크는 원래 프론트 엔드에서 쿠키
를 써서 처리하기로 했고, 잘 됐었다. 그런데, 무한 스크롤이 적용되자 북마크를 모아보는게 불가능했다. 서버에서 페이징
을 사용해서 데이터를 보내주는데, 로드되지 않은 페이지에 북마크 데이터가 있을 경우 이를 찾지 못하는 것이다.
...따라서 백엔드에서 북마크 기능을 처리하기로 했다.
시작은 쉬울 것 같았다.
먼저 북마크 정보를 따로 저장할 엔티티를 만든다.
@Entity @Getter
public class Bookmark {
@Id @GeneratedValue
private Long id;
private String userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "menu_id")
private Menu menu;
public Bookmark() {
}
public Bookmark(String userId, Menu menu) {
this.userId = userId;
this.menu = menu;
}
}
위와 같이, 메뉴랑 N:1 매핑을 갖도록 설정했다. (하나의 메뉴는, 여러명의 사용자에 의해 북마크 될 수 있다.)
그리고 Repository
를 만들어서, 조회, 수정, 삭제를 구현하고 있었다.
조회에서, 북마크와 매핑된 메뉴 정보만 뽑아서 리스트로 반환하고 싶었기에
em.createQuery("select b.menu from Bookmark b join fetch b.menu join fetch b.menu.place where b.userId = :uniqueId", Menu.class)
.setParameter("uniqueId", userId)
.getResultList();
이렇게 쿼리를 작성해서, fetch join
도 사용해 성능 최적화도 하며 나 스스로에게 감탄을 하던 찰나..
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list [FromElement{explicit,not a collection join,fetch join,fetch non-lazy properties,classAlias=null,role=com.asmt.ssu.domain.Bookmark.menu,tableName=menu,tableAlias=menu1_,origin=bookmark bookmark0_,columns={bookmark0_.menu_id,className=com.asmt.ssu.domain.Menu}}] [select b.menu from com.asmt.ssu.domain.Bookmark b join fetch b.menu join fetch b.menu.place where b.uniqueUserId = :uniqueId]; nested exception is java.lang.IllegalArgumentException: org.hibernate.QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list [FromElement{explicit,not a collection join,fetch join,fetch non-lazy properties,classAlias=null,role=com.asmt.ssu.domain.Bookmark.menu,tableName=menu,tableAlias=menu1_,origin=bookmark bookmark0_,columns={bookmark0_.menu_id,className=com.asmt.ssu.domain.Menu}}] [select b.menu from com.asmt.ssu.domain.Bookmark b join fetch b.menu join fetch b.menu.place where b.uniqueUserId = :uniqueId]
엄청나게 긴 오류가 나왔다. 핵심 한줄만 뽑으면
query specified join fetching, but the owner of the fetched association was not present in the select list
즉, fetch join을 사용할 때 연관 관계의 주인이 select절에 포함되지 않으면 안된댄다.
나는 select b.menu from ...
꼴로 쿼리를 썼는데, b를 가지고 fetch join을 할거면 select b
로 쓰라는거다.
따라서 나는 b의 리스트를 뽑고, 새로 ArrayList
를 만들어 b.menu
를 반복적으로 넣어주기로 했다.
List<Menu> resultList = new ArrayList<>();
em.createQuery("select b from Bookmark b join fetch b.menu m join fetch m.place where b.userId = :uniqueId", Bookmark.class)
.setParameter("uniqueId", userId)
.getResultStream().forEach(bookmark -> resultList.add(bookmark.getMenu()));
쿼리로 한번에 뽑아오는 것보단 느리겠지만, 그래도 적절히 빠른 속도가 나올 것 같았다.
북마크를 넣게 되니까, 이제 뿌려주는 메뉴 정보에도 북마크가 되어있는지 아닌지 아이콘 표시를 위해 데이터가 필요했다.
데이터 자체에 북마크 표시를 넣는건 미친 짓 같았다. 그러면 데이터가 N * 유저수
만큼 있어야 하는 대 참사가 생긴다.
따라서 반환을 기존에도 DTO로 하고 있었으니, DTO에만 북마크 여부를 추가해 반환해주기로 했다.
return em.createQuery("select new com.asmt.ssu.domain.SearchDTO(p.placeName, p.placeAddress, p.placeRating, p.placeLink, p.placeDistance, p.school, m.id, m.menuName, m.menuPrice, m.menuImg, CASE WHEN b.menu.id IS NULL THEN false ELSE true END)" +
" from Menu m join m.place p left join Bookmark b on b.menu = m and b.userId = :userId where p.school = :school and m.menuPrice between :minValue and :maxValue order by "+ searchForm.makeSortResult(), SearchDTO.class)
.setParameter("school", searchForm.getSchool())
.setParameter("minValue", searchForm.getMinimumPrice())
.setParameter("maxValue", searchForm.getMaximumPrice())
.setParameter("userId", searchForm.getUserId())
.setFirstResult((searchForm.getPage() - 1) * 300)
.setMaxResults(searchForm.getPage() * 300)
.getResultList();
그 결과 join 한번, left join 한번을 섞은 쿼리가 나왔다. 원래도 쿼리가 좀 길었는데, 더 길어졌다. QueryDsl
을 쓰면 그래도 좀더 코드스럽게 쿼리를 짤 수 있다는데, 공부를 해봐야 할 것 같다.
public List<SearchDTO> findResultByPriceWithName(SearchForm searchForm) {
StringBuilder jpqlBuilder = new StringBuilder();
for (int i = 0; i < searchForm.getSearchKeywordList().size(); i++) {
if (i > 0) {
jpqlBuilder.append(" OR ");
}
jpqlBuilder.append("m.menuName LIKE :searchString").append(i);
}
String jpql = jpqlBuilder.toString();
TypedQuery<SearchDTO> resultQuery = em.createQuery("select new com.asmt.ssu.domain.SearchDTO(p.placeName, p.placeAddress, p.placeRating, p.placeLink, p.placeDistance, p.school, m.id, m.menuName, m.menuPrice, m.menuImg, CASE WHEN b.menu.id IS NULL THEN false ELSE true END)" +
" from Menu m join m.place p left join Bookmark b on b.menu = m and b.userId = :userId where p.school = :school and (" + jpql + ") and m.menuPrice between :minValue and :maxValue order by "+ searchForm.makeSortResult(), SearchDTO.class)
.setParameter("school", searchForm.getSchool())
.setParameter("minValue", searchForm.getMinimumPrice())
.setParameter("maxValue", searchForm.getMaximumPrice())
.setParameter("userId", searchForm.getUserId())
.setFirstResult((searchForm.getPage() - 1) * 300)
.setMaxResults(searchForm.getPage() * 300 );
for (int i = 0; i < searchForm.getSearchKeywordList().size(); i++) {
resultQuery.setParameter("searchString" + i, "%" + searchForm.getSearchKeywordList().get(i) + "%");
}
return resultQuery.getResultList();
}
마지막으로 이 프로젝트에서 가장 복잡한 쿼리문을 날리는 메서드를 첨부하며 마무리한다.
위 메서드는, 메뉴의 이름까지 포함 관계를 넣어 검색하는 쿼리이다. sql에서 like
에 %string%
을 넣는건 성능상 큰 손해가 있을 수 있다는데, 아직 데이터가 적어서 그런지 그렇게 느리진 않은 것 같다.