게시글을 조회할 때는 여러가지 옵션이 있다.
1. 카테고리 (전체, 주인 찾아요, 물건 찾아요)
분실물 찾기용 커뮤니티라서 카테고리가 이렇게 설정된 점 참고
2. 검색어
3. 해결한 글 제외하기
4. 물놀이 장소 선택
총 4가지 옵션을 통해 조회를 수행할 수 있다. 이 기능을 구현할 때에는 Querydsl을 사용해서 구현했다. 다른 부분은 생략하고 Repository 부분만 중점적으로 다루려고 한다.
전체 코드
public Slice<LostFoundBoardListRespDto> getLostFoundBoardListBySlice(LostFoundBoardListReqDto lostFoundBoardListReqDto, Pageable pageable) {
JPAQuery<LostFoundBoardListRespDto> query = queryFactory
.select(
Projections.constructor(LostFoundBoardListRespDto.class,
lostFoundBoard.id,
lostFoundBoard.title,
lostFoundBoard.content,
member.nickname,
JPAExpressions.select(comment.id.count()).from(comment).where(comment.lostFoundBoardId.eq(lostFoundBoard.id)),
lostFoundBoard.createdDate,
JPAExpressions.select(lostFoundBoardImage.imageFile.storeFileUrl).from(lostFoundBoardImage)
.where(
lostFoundBoardImage.id.eq(
JPAExpressions
.select(lostFoundBoardImage.id.max())
.from(lostFoundBoardImage)
.where(lostFoundBoardImage.lostFoundBoardId.eq(lostFoundBoard.id))
)
),
lostFoundBoard.lostFoundEnum
))
.from(lostFoundBoard)
.join(lostFoundBoard.waterPlace, waterPlace)
.join(lostFoundBoard.member, member);
BooleanBuilder booleanBuilder = new BooleanBuilder();
// 카테고리(LOST / FOUND)
String category = lostFoundBoardListReqDto.getCategory();
if (category != null) {
booleanBuilder.and(lostFoundBoard.lostFoundEnum.eq(LostFoundEnum.valueOf(category)));
}
// 검색어 (제목, 내용 포함)
String searchWord = lostFoundBoardListReqDto.getSearchWord();
if (searchWord != null && StringUtils.isBlank(searchWord)) {
booleanBuilder.andAnyOf(
lostFoundBoard.title.toLowerCase().containsIgnoreCase(searchWord.toLowerCase()),
lostFoundBoard.content.toLowerCase().containsIgnoreCase(searchWord.toLowerCase())
);
}
// 계곡 아이디
List<Long> waterPlaceIds = lostFoundBoardListReqDto.getWaterPlaceId();
if (!isEmptyList(waterPlaceIds)) {
booleanBuilder.and(lostFoundBoard.waterPlace.id.in(waterPlaceIds));
}
// 해결된 게시글 제외
if (!lostFoundBoardListReqDto.isResolved()) {
booleanBuilder.and(lostFoundBoard.isResolved.eq(false));
}
List<LostFoundBoardListRespDto> results = query
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(lostFoundBoard.createdDate.desc())
.where(booleanBuilder)
.fetch();
return new SliceImpl<>(results, pageable, hasNextPage(results, pageable.getPageSize()));
}
일단 Slice 조회 방식을 사용했다. 그리고 한 방의 쿼리로 DTO 값을 채우려고 했다.
Projection 이란?
Querydsl을 이용해 엔티티 전체를 가져오는 것이 아니라 조회 대상을 지정해 원하는 값만 조회하는 것을 말한다.
즉 DTO를 사용해서 값을 조회하는 것이다.
Projections.constructor
내가 사용한 방식은 생성자 방식이다. 이외에도 프로퍼티 방식, 필드 방식이 있지만 이 내용은 기회가 된다면 다음에 다뤄보도록 하겠다.
생성자 방식을 사용하면 값을 넘길 때 생성자 값과 순서가 일치해야하며 @AllArgsConstructor가 필요하며 setter는 없어도 된다.
이 방식은 @QueryProjection까지 지원해준다. 이 어노테이션을 사용하면 DTO도 Q파일로 생성해준다. 컴파일러로 타입 체크를 할 수 있어서 안전한 방법이다. 하지만 DTO에 QueryDSL 어노테이션을 유지해야하기에 Querydsl에 종속적이라는 것과 DTO까지 Q파일을 생성해야한다는 단점이 있다. 참고
이를 사용해서 내가 원하는 값을 가져와 DTO를 채워준다. 그러나 이때 댓글 개수와 게시글 이미지(1개)는 해당 엔티티가 갖고 있는 데이터가 아니기에 서브쿼리를 사용해서 원하는 값을 각각 가져온다.
서브쿼리
1. 댓글 개수
JPAExpressions.select(comment.id.count())
.from(comment)
.where(comment.lostFoundBoardId.eq(lostFoundBoard.id))
먼저 JPAExpressions에 대해 간단히 알아보자.
Querydsl에서 JPAExpressions는 JPQL(Java Persistence Query Language) 또는 JPA Criteria 쿼리에서 하위 쿼리를 생성할 수 있는 유틸리티 클래스이다. select, where 또는 from 절에서 사용 가능하다.
comment.id.count() 이런 식으로 작성하면 실제 쿼리에서 select count(comment.id) 이렇게 나타난다. where 조건절을 만족하는 comment id의 개수가 반환될 것이다. 즉 위의 쿼리를 해석해보면 게시글에 작성한 댓글의 개수가 반환될 것이다.
2. 게시글 이미지 조회
게시글 조회 페이지에서는 저장된 이미지 중 하나만 보여주는 식으로 UI가 설계되어 있었다. 그렇기에 초반에 작성한 코드는 아래와 같았다.
JPAExpressions.select(lostFoundBoardImage.imageFile.storeFileUrl)
.from(lostFoundBoardImage)
.where(lostFoundBoardImage.lostFoundBoardId.eq(lostFoundBoard.id))
.limit(1)
limit 절을 사용해서 하나의 이미지만 조회하는 것을 예상하고 구현했으나 예외가 발생했다.
문제 발생 상황은 여러 개의 이미지를 선택해 게시글을 작성하고 게시글 조회 페이지로 이동했을 때 서버에서 에러가 발생했다.

서브쿼리에서 여러 개의 행이 반환되었다는 에러였다.
우선 실제 쿼리를 확인해보았다.
select lostFoundBoard.id, lostFoundBoard.title, lostFoundBoard.content, member1.nickname,
(select count(comment.id)
from Comment comment where comment.lostFoundBoardId = lostFoundBoard.id),
lostFoundBoard.createdDate,
(select lostFoundBoardImage.imageFile.storeFileUrl
from LostFoundBoardImage lostFoundBoardImage
where lostFoundBoardImage.lostFoundBoardId = lostFoundBoard.id),
lostFoundBoard.lostFoundEnum
from LostFoundBoard lostFoundBoard
inner join lostFoundBoard.waterPlace as waterPlace
inner join lostFoundBoard.member as member1
where lostFoundBoard.isResolved = ?1
order by lostFoundBoard.createdDate desc
두 번째 서브쿼리 절을 보면 limit 라는 문자를 찾아볼 수 없다. 누락된 것이다.
구글링 해보니 동일한 사례가 꽤 있었다. 다른 분의 블로그를 참고해서 해결했다.
JPAExpressions.select(lostFoundBoardImage.imageFile.storeFileUrl)
.from(lostFoundBoardImage)
.where(lostFoundBoardImage.id.eq(
JPAExpressions
select(lostFoundBoardImage.id.max())
.from(lostFoundBoardImage)
.where(lostFoundBoardImage.lostFoundBoardId.eq(lostFoundBoard.id))
)
수정된 코드는 이와 같다. where 절에서 서브쿼리를 하나 더 사용해서 pk가 가장 큰 게시글 이미지를 조건으로 걸어준다.
이후에는 from절, join절을 작성했다.
.from(lostFoundBoard)
.join(lostFoundBoard.waterPlace, waterPlace)
.join(lostFoundBoard.member, member);
이렇게 첫 번째 작업이 완료됐다. 초기 작업을 통해 JPAQuery<LostFoundBoardListRespDto>가 만들어졌다. 여기에 다양한 조건을 추가할 것이다.
옵션 추가
BooleanBulider 란?
BooleanBuilder booleanBuilder = new BooleanBuilder();
쿼리에서 데이터를 필터링하기 위한 부울 표현식을 동적으로 작성할 수 있는 클래스이다. 이는 가변 기준을 사용하여 복잡한 쿼리를 구성할 때 특히 유용하다. 주요 특징은 아래와 같다.
- AND, OR, NOT 등의 논리 연산자를 사용하여 여러 조건자를 결합할 수 있다. 이를 통해 복잡한 필터링 기준을 만들 수 있다.
- BooleanBuilder에 추가하는 조건자는 도메인 모델의 클래스와 속성을 기반으로 한다. 이는 런타임이 아닌 컴파일 타임에 오류를 잡는 데 도움이 된다.
1. 카테고리
String category = lostFoundBoardListReqDto.getCategory();
if (category != null) {
booleanBuilder.and(lostFoundBoard.lostFoundEnum.eq(LostFoundEnum.valueOf(category)));
}
category가 null 값이 아니라면 해당 값과 동일한 카테고리의 게시글이 조회되도록 조건이 추가된다.
2. 검색어
String searchWord = lostFoundBoardListReqDto.getSearchWord();
if (searchWord != null && StringUtils.isBlank(searchWord)) {
booleanBuilder.andAnyOf(
lostFoundBoard.title.toLowerCase().containsIgnoreCase(searchWord.toLowerCase()),
lostFoundBoard.content.toLowerCase().containsIgnoreCase(searchWord.toLowerCase())
);
}
searchWord 검색어가 존재한다면 title 제목과 content 본문의 내용에 검색어가 포함되어 있는지 검사한다.
1번 카테고리와 2번 검색어를 살펴보면 booleanBuilder의 다양한 메서드를 사용하고 있다.
and, or, not 은 이미 알고 있는 논리 연산자와 동일한 의미이다.
그렇다면 andAnyOf()는 어떤 의미일까?
논리적 AND 연산자를 사용하여 빌더에 여러 조건자를 추가하는데 이를 논리적 OR 연산자로 연결된 단일 그룹으로 처리한다. 이는 조건 중 하나 이상이 충족되어야 하는 여러 대체 조건을 쿼리에 추가하려는 경우 특히 유용하다.
즉 제목에서 검색어가 포함되거나 본문에서 검색어가 포함되어야하는 것이다. 나는 제목, 본문의 내용을 소문자고 바꾸고 검색어도 소문자로 변경해서 비교했다. 또한 containsIgnoreCase()를 사용해 대소문자를 무시하고 문자열 포함 여부를 검사했다. 사실 소문자로 변경했기 때문에 contains()를 사용해도 무관했을 것이라 생각한다.
그러나 과거의 나의 행동을 현재의 나는 이해할 수 없는 거 흔한 일 아닐까 ...?ㅎㅎ
다양하고 더 자세한 내용은 공식 문서에서 확인할 수 있다. 공식 문서
3. 해결한 글 제외하기
if (!lostFoundBoardListReqDto.isResolved()) {
booleanBuilder.and(lostFoundBoard.isResolved.eq(false));
}
사용자가 해결 완료한 게시글은 isResolved 필드가 true로 저장되어 있다. 그래서 해결 완료 여부가 false인 게시글 (해결되지 않은 게시글)을 조회할 수 있도록 구현했다.
4. 물놀이 장소 선택
게시글을 조회할 때 특정 물놀이 장소를 선택할 수 있다. 그러면 클라이언트에서 서버로 물놀이 장소 pk를 같이 보내준다.
List<Long> waterPlaceIds = lostFoundBoardListReqDto.getWaterPlaceId();
if (!isEmptyList(waterPlaceIds)) {
booleanBuilder.and(lostFoundBoard.waterPlace.id.in(waterPlaceIds));
}
이때 여러 물놀이 장소를 선택할 수 있다. 그렇기에 in절을 사용해서 이에 포함되는 물놀이 장소를 선택한 게시글을 조회할 수 있도록 한다.
이제 중간 작업이 마무리되었다. 마지막 작업만 남았다.
결과 반환
List<LostFoundBoardListRespDto> results = query
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(lostFoundBoard.createdDate.desc())
.where(booleanBuilder)
.fetch();
메서드의 파라미터로 받아온 Pageable에 저장된 offset, pageSize를 적용한다. 그리고 order by절을 통해 게시글 작성 날짜 최신순으로 정렬하고, where 절에 중간 작업에서 만들어진 BooleanBuilder 인스턴스를 추가한다.
fetch()를 호출해서 리스트로 결과를 반환하도록 한다.
fetchOne(), fetchFirst(), fetchResults(), fetchCount() 이와 같이 여러가지 메소드가 있지만 이것도 다음 기회에 다뤄보도록 하겠다!
return new SliceImpl<>(results, pageable, hasNextPage(results, pageable.getPageSize()));
SliceImple 객체를 생성해서 결과를 반환한다.
댓글