오늘은 저의 험난했던 신고된 댓글 리스트 조회 기능 구현을 정리해보겠습니다.
관리자 페이지에서 사용될 신고된 댓글 리스트 조회기능을 구현해야 했습니다. 저는 항상 기능 구현 전에 어느 정도 틀을 잡고 시작하는 편이라, 관리자 페이지를 메모장에 그려보면서 필요한 API들을 정리하였습니다.

글씨는 좀 아쉽지만,,, 정리하면 필요한 조건들은 다음과 같습니다.
신고 횟수조건페이지네이션- 닉네임
검색생성일오름차순 / 내림차순 정렬신고 처리 상태에 따른 필터링
조건이 다양하고 추후에 조건이 추가될 수 있으므로 동적 쿼리가 필요하다고 생각되었습니다.
spring에서 사용할 수 있는 동적 쿼리 방법은 다양합니다.
spring에서 이용 가능한 동적 쿼리
- JPQL
- 사용이 간편함. sql문을 직접 작성해주면 사용 가능.
- 런타임 시점에 오류가 확인이 가능한 점에서 아쉬웠습니다.
- Criteria
- JPQL을 문자열이 아닌 자바 코드로 작성하도록 도와주는
빌더 클래스 API- 러닝 커브도 확실히 존재하고, 코드 가독성이 떨어진다고 생각했습니다.
- QueryDsl
- 러닝 커브가 존재합니다.
- 하지만, 자바 문법으로 쿼리문을 작성할 수 있고, 다양한 클래스가 있어 좀 더 세부적인 조건을 주입할 수 있다고 생각했습니다.
- 컴파일 에러가 조회되어, 코드 작성이 쉬운 편입니다.
- MyBatis
- 가장 성능이 뛰어나다고 생각합니다.
- 직접 SQL을 작성하고 사용하니, 간편하게 쿼리문을 작성할 수 있습니다.
- 코드 수정이 일어난다면 관련된 모든 SQL을 수정해야 한다는 점에서 매우 불편하게 느껴졌습니다.
하지만 위에 정리한 이유들로, QueryDsl방법을 사용하게 되었습니다.
querydsl의 설정 방법은 Spring Boot 버전이 2.X 버전인지, 3.X 버전인지에 따라 다릅니다.
저희 러닝하이팀은 Spring Boot 3.2.3버전을 사용하기 때문에 3버전의 세팅 방법을 적용했습니다.
dependencies {
// querydsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}
// Querydsl 설정부
def generated = 'src/main/generated'
// querydsl QClass 파일 생성 위치를 지정
tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(generated))
}
// java source set 에 querydsl QClass 위치 추가
sourceSets {
main.java.srcDirs += [ generated ]
}
// gradle clean 시에 QClass 디렉토리 삭제
clean {
delete file(generated)
}
querydsl을 사용하기 위한 Dependencies를 추가해주었고, querydsl에서 실질적으로 사용하는 엔티티인 QClass의 생성 위치를 지정해두었습니다.
이제, 프로젝트 실행 시에 위치를 설정해둔 곳으로 QClass가 실제 엔티티 수 만큼 생성됩니다.

@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
jpaQueryFactory를 Bean 등록해주어 각 도메인 별 레포지토리에서 EntityManager을 사용할 수 있도록 해줍니다.
이렇게 간단한 설정으로 querydsl 사용이 가능합니다. 설정은 Spring Boot 버전만 잘 확인하고 설정한다면 어렵지 않게 할 수 있었던 것 같습니다!
⛔️ 주의! build.gradle에 플러그인 설정 주의!
현재 많은 래퍼런스에서 플러그인 설정까지 하는 것으로 명시되어 있습니다.
로컬 환경에서는 정상 작동하지만, 배포 환경에서는 에러가 발생하므로 제외하고 사용해주셔야 합니다!!
이제, 실제로 구현된 신고된 댓글 리스트 조회 API를 통해 다양한 조건들(신고 횟수, 페이지네이션, 닉네임 검색, 생성일 정렬, 신고 처리 상태 필터링)을 어떻게 충족시켰는지 확인해 보겠습니다.
private static final String GET_MAPPING_RESPONSE_MESSAGE = "성공적으로 조회되었습니다.";
private static final String CREATE_DATE_SORT_PROP = "createDate";
@HasAccess
@GetMapping(value = "/reported")
public ResponseEntity<ApiResult> getReportedReplyList(@ModelAttribute GetReportedReplySearchRequest searchRequest) {
Sort sort = Sort.by( searchRequest.getSortDirection(), CREATE_DATE_SORT_PROP );
Pageable pageable = PageRequest.of(searchRequest.getPage(), searchRequest.getSize(), sort);
Page<GetReplyListResponse> reportedReplyPage = replyService.getReportedReplyList(
GetReportedReplyRequest.of(pageable, searchRequest.getSearch(), searchRequest.getReportStatus())
);
return ResponseEntity.ok().body(ApiResult.success(GET_MAPPING_RESPONSE_MESSAGE, reportedReplyPage));
}
우선, RequestParam으로 받던 매개 변수들은 수가 많고 가독성이 떨어져 ModelAttribute로 DTO로 매핑시켜서 받도록 설정하였습니다.
HasAccess
커스텀 어노테이션입니다.
Http 요청에 담긴 jwt를 통해 관리자 권한을 가진 사용자가 요청을 했는 지 검사해줍니다.
Pageable
Pageable에 offset과 limit, sort를 담고 나서, querydsl에서 Page<> 객체로 값을 반환해주어 페이지네이션을 구현합니다.
신고된 댓글 리스트는 관리자 페이지에서 조회되는 내용입니다.
그렇다보니 특정 페이지 조회가 필요할 것이라 생각되어offset/limit 방법의 페이지네이션을 구현하게 되었습니다.신고 처리 상태가
IN PROGRESS인 댓글들 조회가 가장 많이 일어날 것이라 보입니다.
많은 양의IN PROGRESS인 댓글들로 인해 페이지가 늘어난다고 해도 성능 저하가 있을 정도의 양이 될 것이라 생각되지 않았습니다. 기존의offset/limit Pagination방식의 단점인 뒷 페이지로 갈수록첫 행부터 누적으로 행을 조회하기 때문에 성능 저하가 있기 때문인데, 이것을 해결할 수 있었기 때문입니다.
@Getter
public class GetReportedReplySearchRequest {
@PositiveOrZero(message = "0 또는 자연수만 입력 가능합니다.")
@Schema(description = "현재 페이지", example = "1")
int page;
@Positive
@Schema(description = "페이지 당 표시 게시글 수", example = "10")
int size;
@Pattern(regexp = "desc|asc", message = "정렬 조건이 맞지 않습니다.")
@Schema(description = "정렬 조건", example = "desc")
Sort.Direction sortDirection;
@Pattern(regexp = "NONE|INPROGRESS|ACCEPTED|REJECTED", message = "신고 상태 조건이 맞지 않습니다.")
@Schema(description = "신고 상태", example = "INPROGRESS")
ProcessingStatus reportStatus;
@Size(max = 10, message = "10자 이내로 입력해주세요.")
@Pattern(regexp = "^[ ㄱ-ㅎ가-힣a-zA-Z0-9]*$") // 특수문자 입력 방지, 빈 문자, 공백 허용
@Schema(description = "닉네임 검색 내용", example = "러너1")
String search;
public GetReportedReplySearchRequest (Integer page, Integer size, Sort.Direction sortDirection,
ProcessingStatus reportStatus, String search) {
this.page = page == null ? 0 : page;
this.size = size == null ? 10 : size;
this.sortDirection = sortDirection == null ? Sort.Direction.DESC : Sort.Direction.ASC;
this.reportStatus = reportStatus == null ? ProcessingStatus.INPROGRESS : reportStatus;
this.search = search;
}
}
클라이언트로부터 받은 요청들이 매핑되는 DTO입니다.
인자들의 default 값 설정이 필요하다고 생각되어 생성자 안에 초기 값을 설정할 수 있도록 설계하였습니다.
@validService 계층에서 비즈니스 로직이 실행됩니다. @Transactional(readOnly = true)
public Page<GetReplyListResponse> getReportedReplyList(GetReportedReplyRequest request) {
replyChecker.checkSearchValid(request.search());
return replyQueryRepository.findAllReportedByPageableAndSearch(request);
}
replyChecker에서 요청 값들의 2차 검증을 진행합니다.
검증이 마무리 된다면 실질적으로 DB에 접근하는 Infra Structure 계층에 값을 넘겨줍니다.
import static com.runninghi.runninghibackv2.reply.domain.aggregate.entity.QReply.reply;
// 클래스 내부에서 QClass 접근을 위한 Import
@Repository
@RequiredArgsConstructor
public class ReplyQueryRepositoryImpl implements ReplyQueryRepository {
private final JPAQueryFactory jpaQueryFactory; // entityManager 생성
private static final int REPORTED_COUNT = 1; // 조회 조건으로 존재하는 신고 횟수 상수
@Override
public Page<GetReplyListResponse> findAllReportedByPageableAndSearch(GetReportedReplyRequest request) {
Long count = getCount(request); // 총 댓글 수 조회
List<GetReplyListResponse> content = getReportedReplyList(request); // 댓글 리스트 페이지네이션
return new PageImpl<>(content, request.pageable(), count); // Page<> 객체로 반환
}
private Long getCount(GetReportedReplyRequest request) {
return jpaQueryFactory
.select(reply.count())
.from(reply)
.where(likeNickname(request.search()),
eqReportStatus(request.reportStatus()),
reply.reportedCount.goe(REPORTED_COUNT))
.fetchOne();
}
private List<GetReplyListResponse> getReportedReplyList (GetReportedReplyRequest request) {
return jpaQueryFactory
.select(Projections.fields(GetReplyListResponse.class,
reply.replyNo,
reply.writer.nickname.as("memberName"),
reply.reportedCount,
reply.isDeleted,
reply.createDate,
reply.updateDate
))
.from(reply)
.where(likeNickname(request.search()),
eqReportStatus(request.reportStatus()),
reply.reportedCount.goe(REPORTED_COUNT) )
.orderBy(
getOrderSpecifierList(request.pageable().getSort())
.toArray(OrderSpecifier[]::new) )
.offset(request.pageable().getOffset())
.limit(request.pageable().getPageSize())
.fetch();
}
/* 닉네임 검색 조건이 들어갈지 정해줍니다.*/
private BooleanExpression likeNickname (String search) {
if (!StringUtils.hasText(search)) return null; // space bar까지 막아줌
return reply.writer.nickname.like("%" + search + "%");
}
/* 신고 처리 상태에 따라 조건이 들어갈지 정해줍니다. */
private BooleanExpression eqReportStatus (ProcessingStatus reportStatus) {
if (!StringUtils.hasText(reportStatus.name())) return null;
return reply.reportStatus.eq(reportStatus);
}
/* Pageable 내부 Sort 객체에서 존재하는 정렬 조건들을 모두 추출해주는 메소드 */
private List<OrderSpecifier<?>> getOrderSpecifierList (Sort sort) {
List<OrderSpecifier<?>> orderList = new ArrayList<>();
sort.stream().forEach(order -> {
Order direction = order.isAscending() ? Order.ASC : Order.DESC;
String property = order.getProperty();
Path<Object> target = Expressions.path(Object.class, reply, property);
orderList.add(new OrderSpecifier(direction, target));
});
return orderList;
}
}
실질적으로 동적 쿼리가 진행되는 클래스입니다.
실제 엔티티를 이용하는 것이 아닌, 프록시 객체인 QClass를 만들어 쿼리를 작성해줍니다.
BooleanExpression
where절에 쓸 조건 부를 존재하지 않을 시 제외시키기 위한 값 확인을 위한 객체입니다.
매개 변수가 null 값이라면 where문에서 제외되고, 값이 존재한다면 QClass의 필드에 접근하여 값 조회에 사용할 수 있습니다.
OrderSpecifier
querydsl에서 정렬하기 위해 사용하는 객체입니다.
OrderBy에는 여러 정렬이 들어갈 수 있으므로 Sort 객체에서 꺼내온 정렬 리스트를 querydsl에서 사용할 수 있게 List<OrderSpecifier> 형태로 만들고 OrderBy에 배열 형태로 넣어준다면 여러 정렬이 적용되는 동적 정렬을 수행할 수 있습니다.
fetchResults는 작성된 querydsl문의 count와 fetch를 동시에 실행해줍니다.
하지만, 복잡한 쿼리문에 대해서 잔오류가 발견되었다는 사유로 현재는 deprecated된 상태입니다.
그렇다보니, count와 fetch를 분리해서 사용하게 되었습니다.
헉.. 정리 정말 멋집니다..
최근에 필터 기능 구현하면서 MyBatis를 사용해보려고 했는데 쪼꼼 구찮더라고요~ㅎㅎ
수정사항이 너무 많을것 같아서ㅜㅜ..
글 읽어보면서 저도 쿼-디 함 적용해볼겠슴돠!!!