Today I Learned
#JpaRepository @Query 사용법 정리 & 실습 코드
이번에 진행 중인 프로젝트에서 단순 메서드 네이밍 기반 쿼리(findBy...)로는 복잡한 조건을 표현하기 어려운 상황이 있었다.
특히 다중 조건 + 정렬 + 특정 컬럼만 조회 같은 경우, 메서드 이름이 너무 길어지고 가독성이 떨어졌다.
그래서 @Query 어노테이션을 사용해 JPQL 기반으로 직접 쿼리를 작성하는 방법을 공부했다.
@Query 기본 개념
- JPQL(Java Persistence Query Language) 기반으로 작성한다.
- 엔티티(Entity) 이름과 필드명을 사용한다. (DB 테이블명/컬럼명 아님)
- 복잡한 조건, 조인, 특정 컬럼만 조회 등에 유용하다.
- 파라미터 바인딩 방식 2가지: 위치 기반: `?1`, `?2` (순서 의존) / 이름 기반: :`name`, `:status` (`@Param` 사용, 순서 무관)
사용 예시
1. 기본 조회
public interface CommentRepository extends JpaRepository<Comment, Long> {
// 위치 기반 파라미터
@Query("SELECT c FROM Comment c WHERE c.task.taskId = ?1 AND c.parentId IS NULL ORDER BY c.createdAt DESC")
List<Comment> findParentComments(Long taskId);
// 이름 기반 파라미터
@Query("SELECT c FROM Comment c WHERE c.task.taskId = :taskId AND c.parentId = :parentId ORDER BY c.createdAt ASC")
List<Comment> findChildComments(@Param("taskId") Long taskId, @Param("parentId") Long parentId);
}
- 레포지토리 내부에선 코드가 길어보이지만 서비스단에선 메서드가 훨씬 더 명확하게 읽혀진다.
2. 특정 컬럼만 조회 (DTO 매핑)
public record CommentSummary(Long commentId, String content, String username) {}
public interface CommentRepository extends JpaRepository<Comment, Long> {
@Query("SELECT new com.example.dto.CommentSummary(c.commentId, c.content, u.username) " +
"FROM Comment c JOIN c.user u WHERE c.task.taskId = :taskId")
List<CommentSummary> findCommentSummaries(@Param("taskId") Long taskId);
}
- `new 패키지명.DTO(...)` 형태로 DTO 생성자를 직접 호출 가능
- 불필요한 컬럼 조회를 줄여 성능 최적화
3. 페이징 + @Query
@Query("SELECT c FROM Comment c WHERE c.task.taskId = :taskId AND c.parentId IS NULL")
Page<Comment> findParentCommentsWithPaging(@Param("taskId") Long taskId, Pageable pageable);
- `Pageable`을 메서드 파라미터에 넣으면 자동으로 페이징 처리
- `ORDER BY`는 `Pageable`의 `Sort`로 제어 가능
4. 수정/삭제 쿼리
@Transactional
@Modifying
@Query("UPDATE Comment c SET c.deleted = true WHERE c.commentId = :commentId OR c.parentId = :commentId")
int softDeleteCommentAndReplies(@Param("commentId") Long commentId);
- `@Transactional`을 통해 이 메서드가 하나의 트랜잭션 안에서 실행되도록 설정
- UPDATE 같은 데이터 변경 쿼리는 트랜잭션이 있어야 실제 반영
- Spring Data JPA에서 `@Query`는 기본적으로 SELECT 전용, `@Modifying`를 붙여야 UPDATE, DELETE 같은 DML을 실행 가능
- `@Modifying` 메서드의 반환값이 `int`이면, 실제 영향을 받은 row(엔티티) 개수를 반환 → 즉, 별도의 count 쿼리없이 삭제된 총 댓글수를 반환
실습 코드: 부모 댓글 페이징 + 대댓글 조회
이번에 공부한 내용을 바탕으로, 부모 댓글만 페이징 + 대댓글 조회를 `@Query`로 구현해봤다.
public interface CommentRepository extends JpaRepository<Comment, Long> {
// 부모 댓글 페이징 조회
@EntityGraph(attributePaths = "user")
@Query("SELECT c FROM Comment c WHERE c.task.taskId = :taskId AND c.parentId IS NULL")
Page<Comment> findParentComments(@Param("taskId") Long taskId, Pageable pageable);
// 특정 부모 댓글의 대댓글 조회
@EntityGraph(attributePaths = "user")
@Query("SELECT c FROM Comment c WHERE c.parentId = :parentId")
List<Comment> findChildComments(@Param("parentId") Long parentId, Sort sort);
}
@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
private final CommentMapper commentMapper;
private final InternalQueryUserService userService;
@Transactional
public Page<CommentResponse> getComments(Long taskId, Pageable pageable, String sort) {
taskService.findByTaskId(taskId);
Sort.Direction orderBy = sort.equals("oldest") ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sortByCreatedAt = Sort.by(orderBy, "createdAt");
Pageable parentPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortByCreatedAt);
// 1. 부모 댓글 페이징 조회
Page<Comment> parentComments = commentRepository.findParentComments(taskId, parentPageable);
List<CommentResponse> allComments = new ArrayList<>();
for (Comment parentComment : parentComments.getContent()) {
UserResponse parentUserResponse = userService.toUserResponse(parentComment.getUser());
CommentResponse parentResponse = commentMapper.toCommentResponse(parentComment, parentUserResponse);
// 2. 대댓글 조회
List<Comment> childComments = commentRepository.findChildComments(parentComment.getCommentId(), sortByCreatedAt);
List<CommentResponse> replies = childComments.stream()
.map(childComment -> {
UserResponse childUserResponse = userService.toUserResponse(childComment.getUser());
return commentMapper.toCommentResponse(childComment, childUserResponse);
})
.toList();
allComments.add(parentResponse);
allComments.addAll(replies);
}
PageImpl<CommentResponse> comments = new PageImpl<>(allComments, pageable, parentComments.getTotalElements());
return comments;
}
}
마치며
`@Query`를 사용하면 복잡한 조건을 깔끔하게 표현할 수 있어 가독성이 좋아진다.
특히 DTO 매핑과 페이징 처리를 함께 써보니 API 응답 최적화에 큰 도움이 됐다.
다만, JPQL은 엔티티 필드명을 사용하므로, 엔티티 구조 변경 시 쿼리도 함께 수정해야 한다는 점을 주의해야 한다.
앞으로는 메서드 네이밍 기반 쿼리와 `@Query`를 상황에 맞게 혼합해서 사용할 계획이다.