Skip to content

Feature/#78 querydsl#80

Open
BDOR665 wants to merge 4 commits intodevelopfrom
feature/#78-querydsl
Open

Feature/#78 querydsl#80
BDOR665 wants to merge 4 commits intodevelopfrom
feature/#78-querydsl

Conversation

@BDOR665
Copy link
Contributor

@BDOR665 BDOR665 commented Aug 7, 2025

🚀 What’s this PR about?

  • QueryDSL 기반 조건 필터링 기능 추가

🛠️ What’s been done?

  • QueryDSL 기반 RecruitmentRepositoryImpl 구현
  • findBySearchConditions() 메서드 내 복잡한 조건 쿼리 리팩토링
  • 조건별 필터 (경력, 연봉, 지역, 키워드, 기술스택, 태그 등) 적용
  • 복잡한 LEFT JOIN + GROUP BY 구조에서 두 단계 조회 방식으로 리팩토링 (Pagination 안정성 확보)

🧪 Testing Details

👀 Checkpoints for Reviewers

  • QueryDSL 사용해서 로직 수정했는데 안돌아가거나 성능개선이 안되면 버리는걸로 하죠...

📚 References & Resources

🎯 Related Issues

Summary by CodeRabbit

  • 신규 기능

    • QueryDSL이 통합되어 복잡한 조건의 채용 공고 검색이 가능합니다.
    • 다양한 검색 조건(연봉, 경력, 학력, 위치, 기술 스택, 태그, 키워드 등)으로 채용 공고를 필터링할 수 있습니다.
  • 버그 수정

    • 날짜 및 시간 필드의 JSON 직렬화 포맷이 일관되게 적용되어 데이터 표시가 명확해졌습니다.
  • 환경 설정

    • QueryDSL 및 Jakarta Persistence 지원을 위한 의존성이 추가되었습니다.
    • QueryDSL 사용을 위한 Spring 설정이 추가되었습니다.

BDOR665 added 2 commits August 7, 2025 08:04
- QueryDSL 적용으로 RecruitmentRepository 리팩터링
- 기존 JPQL 주석 처리
- QueryDSL 관련 Config 및 DTO 수정
- build.gradle에 QueryDSL 의존성 추가
- DTO 필드에 @jsonformat 추가하여 날짜/시간 포맷 지정
- Recruitment 관련 DTO 클래스 수정: RecruitmentResponseDTO, RecruitmentDetailResponseDTO, RecruitmentRequestDTO
@BDOR665 BDOR665 self-assigned this Aug 7, 2025
@BDOR665 BDOR665 added the 성능 개선 구현된 기능의 성능을 개선합니다. label Aug 7, 2025
@BDOR665 BDOR665 linked an issue Aug 7, 2025 that may be closed by this pull request
1 task
@coderabbitai
Copy link

coderabbitai bot commented Aug 7, 2025

Walkthrough

QueryDSL과 Jakarta 지원을 위한 의존성 및 설정이 build.gradle에 추가되었습니다. Recruitment 관련 DTO에 날짜/시간 JSON 포맷 지정 어노테이션이 적용되었고, 기존 JPQL 기반 복잡 쿼리가 리포지토리 커스텀 인터페이스 및 QueryDSL 기반 구현체로 리팩토링되었습니다. QueryDSL 사용을 위한 Spring 설정 클래스도 추가되었습니다.

Changes

Cohort / File(s) Change Summary
QueryDSL 및 Jakarta 의존성 추가
build.gradle
QueryDSL(Jakarta 지원) 관련 의존성(querydsl-jpa, querydsl-core, querydsl-apt, jakarta.annotation-api, jakarta.persistence-api) 추가. 기존 의존성 및 빌드 로직에는 변화 없음.
DTO 날짜/시간 포맷 어노테이션 적용
.../RecruitmentDetailResponseDTO.java,
.../RecruitmentRequestDTO.java,
.../RecruitmentResponseDTO.java
Jackson의 @JsonFormat 어노테이션을 사용하여 날짜/시간 필드(establishmentDate, dueDate, modifiedAt)의 JSON 직렬화 포맷 지정. RecruitmentResponseDTO의 생성자에 QueryDSL의 @QueryProjection 어노테이션 추가.
리포지토리 커스텀 인터페이스 및 구현체 도입
.../RecruitmentRepository.java,
.../RecruitmentRepositoryCustom.java,
.../RecruitmentRepositoryImpl.java
기존 JPQL 기반 복잡 쿼리 메서드를 주석 처리하고, RecruitmentRepositoryCustom 인터페이스 및 QueryDSL 기반 구현체(RecruitmentRepositoryImpl)를 추가. 동적 검색 조건 처리, 페이징, 다양한 필터(경력, 연봉, 위치, 키워드, 태그, 기술스택 등) 적용. 리포지토리 인터페이스가 커스텀 인터페이스를 확장하도록 변경.
QueryDSL Spring 설정
.../global/common/config/QueryDslConfig.java
JPAQueryFactory를 Spring Bean으로 등록하는 QueryDslConfig 설정 클래스 추가. EntityManager를 주입받아 QueryDSL 사용 환경 구성.

Sequence Diagram(s)

sequenceDiagram
    participant Controller
    participant RecruitmentRepository (Spring Data)
    participant RecruitmentRepositoryImpl (QueryDSL)
    participant JPAQueryFactory
    participant DB

    Controller->>RecruitmentRepository: findBySearchConditions(cond, pageable)
    RecruitmentRepository->>RecruitmentRepositoryImpl: findBySearchConditions(cond, pageable)
    RecruitmentRepositoryImpl->>JPAQueryFactory: query().select(...).from(...).where(...)
    JPAQueryFactory->>DB: 실행된 QueryDSL 쿼리
    DB-->>JPAQueryFactory: 결과 반환
    JPAQueryFactory-->>RecruitmentRepositoryImpl: 결과 DTO 및 count 반환
    RecruitmentRepositoryImpl-->>RecruitmentRepository: Page<RecruitmentResponseDTO> 반환
    RecruitmentRepository-->>Controller: Page<RecruitmentResponseDTO> 반환
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~35 minutes

Possibly related PRs

Suggested labels

기능 개발, 리팩토링

Suggested reviewers

  • Roysung0625
  • seongjunin98
  • Cori1304-Seong
  • jam9582
  • hueyjeong

Poem

🐰
QueryDSL 바람 타고
DTO에 날짜 꽃이 피었네
복잡한 쿼리, 토끼도 척척
리포지토리 새 옷 갈아입고
봄처럼 깔끔해진 코드밭
리뷰어님, 오늘은 당근 하나 더!
🥕✨

Note

⚡️ Unit Test Generation is now available in beta!

Learn more here, or try it out under "Finishing Touches" below.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/#78-querydsl

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai generate unit tests to generate unit tests for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@github-actions
Copy link

github-actions bot commented Aug 7, 2025

Unit Test Results

359 tests   332 ✔️  13s ⏱️
  48 suites    27 💤
  48 files        0

Results for commit b5e12ff.

♻️ This comment has been updated with latest results.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

📜 Review details

Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 60a965e and 13ab493.

📒 Files selected for processing (8)
  • build.gradle (1 hunks)
  • src/main/java/org/example/hugmeexp/domain/recruitment/dto/RecruitmentDetailResponseDTO.java (2 hunks)
  • src/main/java/org/example/hugmeexp/domain/recruitment/dto/RecruitmentRequestDTO.java (2 hunks)
  • src/main/java/org/example/hugmeexp/domain/recruitment/dto/RecruitmentResponseDTO.java (2 hunks)
  • src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepository.java (1 hunks)
  • src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryCustom.java (1 hunks)
  • src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java (1 hunks)
  • src/main/java/org/example/hugmeexp/global/common/config/QueryDslConfig.java (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: Roysung0625
PR: Please-Hug/HugmeEXP_Backend#32
File: src/main/java/org/example/hugmeexp/domain/studyRoom/dto/response/ReservationDetailDto.java:5-5
Timestamp: 2025-07-23T09:00:11.298Z
Learning: ReservationDetailDto와 같은 response DTO에서는 Data 어노테이션을 사용하지 않고, 사용하지 않는 lombok import는 제거하는 것을 선호한다.
📚 Learning: reservationdetaildto와 같은 response dto에서는 @DaTa 어노테이션을 사용하지 않고, 사용하지 않는 lombok import는 제거하는 것을 선호한다....
Learnt from: Roysung0625
PR: Please-Hug/HugmeEXP_Backend#32
File: src/main/java/org/example/hugmeexp/domain/studyRoom/dto/response/ReservationDetailDto.java:5-5
Timestamp: 2025-07-23T09:00:11.298Z
Learning: ReservationDetailDto와 같은 response DTO에서는 Data 어노테이션을 사용하지 않고, 사용하지 않는 lombok import는 제거하는 것을 선호한다.

Applied to files:

  • src/main/java/org/example/hugmeexp/domain/recruitment/dto/RecruitmentResponseDTO.java
  • src/main/java/org/example/hugmeexp/domain/recruitment/dto/RecruitmentDetailResponseDTO.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (10)
build.gradle (1)

67-73: QueryDSL 의존성 설정이 올바르게 구성되었습니다.

Jakarta 지원 버전의 QueryDSL 5.0.0과 필요한 annotation processor들이 모두 포함되어 있어 Spring Boot 3.5.0과 호환됩니다. 타입 안전한 쿼리 생성을 위한 설정이 완료되었습니다.

src/main/java/org/example/hugmeexp/domain/recruitment/dto/RecruitmentRequestDTO.java (1)

67-68: 날짜 포맷 어노테이션이 적절히 추가되었습니다.

LocalDateTime 필드에 대한 JSON 직렬화 포맷이 명시되어 일관된 API 응답을 보장합니다.

src/main/java/org/example/hugmeexp/domain/recruitment/dto/RecruitmentResponseDTO.java (3)

3-4: 필요한 import 구문이 적절히 추가되었습니다.

QueryDSL projection과 JSON 포맷팅을 위한 import가 올바르게 추가되었습니다.


23-24: 날짜 필드에 일관된 JSON 포맷이 적용되었습니다.

dueDatemodifiedAt 필드에 동일한 날짜 포맷 패턴이 적용되어 API 응답의 일관성이 보장됩니다.

Also applies to: 31-32


35-51: QueryDSL projection을 위한 생성자 어노테이션이 올바르게 추가되었습니다.

@QueryProjection 어노테이션을 통해 QueryDSL에서 타입 안전한 projection을 사용할 수 있게 되었습니다. 이는 복잡한 쿼리의 성능과 타입 안전성을 개선합니다.

src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryCustom.java (1)

9-12: 커스텀 리포지토리 인터페이스가 올바른 패턴으로 구현되었습니다.

Spring Data의 커스텀 리포지토리 패턴을 따르며, 페이지네이션을 지원하는 복잡한 검색 쿼리를 위한 인터페이스가 적절히 정의되었습니다.

src/main/java/org/example/hugmeexp/global/common/config/QueryDslConfig.java (1)

8-21: QueryDSL 설정 클래스가 Spring 모범 사례에 따라 올바르게 구현되었습니다.

생성자 주입을 사용하여 EntityManager를 주입받고, JPAQueryFactory 빈을 적절히 생성하는 간결하고 명확한 설정입니다.

src/main/java/org/example/hugmeexp/domain/recruitment/dto/RecruitmentDetailResponseDTO.java (1)

3-3: JSON 날짜 포맷 적용이 적절합니다.

날짜/시간 필드에 대한 일관된 JSON 직렬화 포맷을 적용한 것이 좋습니다. LocalDateLocalDateTime 타입에 맞는 적절한 패턴을 사용하고 있습니다.

또한 이전 학습 내용에 따라 response DTO에서 @Data 어노테이션을 사용하지 않고 있는 점도 잘 지켜지고 있습니다.

Also applies to: 25-26, 28-29

src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepository.java (1)

17-17: QueryDSL 커스텀 리포지토리 통합이 적절합니다.

RecruitmentRepositoryCustom 인터페이스를 확장하여 QueryDSL 기반 구현을 통합한 것이 좋습니다. 이는 복잡한 쿼리 로직을 별도 구현체로 분리하는 Spring Data JPA의 모범 사례입니다.

src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java (1)

116-128: 지리적 경계 필터링 로직이 잘 구현되었습니다.

좌표 순서와 관계없이 min/max를 올바르게 계산하여 경계 상자 내 필터링을 수행하는 로직이 견고합니다.

Comment on lines +19 to +104
// /**
// * 채용 공고 목록을 조회합니다.
// * 조건에 따라 필터링된 채용 공고를 페이지 단위로 반환합니다.
// *
// * @param cond 검색 조건 DTO
// * @param pageable 페이징 정보
// * @return 필터링된 채용 공고 목록 (RecruitmentResponseDTO)
// *
// * 사용자가 지도를 드래그하여 선택한 사각형 범위 내에 있는 공고만 필터링
// */
// @Query(
// value = """
// SELECT new org.example.hugmeexp.domain.recruitment.dto.RecruitmentResponseDTO(
// r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
// r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
// )
// FROM Recruitment r
// JOIN r.company c
// LEFT JOIN r.techStacks ts
// LEFT JOIN r.tags t
// WHERE r.dueDate > CURRENT_TIMESTAMP AND
// (:#{#cond.salaryMin} IS NULL OR r.salaryMin >= :#{#cond.salaryMin}) AND
// (:#{#cond.salaryMax} IS NULL OR r.salaryMax <= :#{#cond.salaryMax}) AND
// (
// (:#{#cond.experienceMin} IS NULL OR :#{#cond.experienceMax} IS NULL)
// OR
// (r.experienceMax >= :#{#cond.experienceMin} AND r.experienceMin <= :#{#cond.experienceMax})
// ) AND
// (:#{#cond.education} IS NULL OR r.education = :#{#cond.education}) AND
// (:#{#cond.workLocation} IS NULL OR r.workLocation LIKE CONCAT('%', :#{#cond.workLocation}, '%')) AND
// (
// :#{#cond.topLeftLat} IS NULL OR
// :#{#cond.topLeftLng} IS NULL OR
// :#{#cond.bottomRightLat} IS NULL OR
// :#{#cond.bottomRightLng} IS NULL OR
// (
// r.latitude BETWEEN LEAST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND GREATEST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND
// r.longitude BETWEEN LEAST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng}) AND GREATEST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng})
// )
// ) AND
// (:#{#cond.techStacks} IS NULL OR ts.techItem.id IN :#{#cond.techStacks}) AND
// (:#{#cond.tags} IS NULL OR t.tagItem.id IN :#{#cond.tags}) AND
// (:#{#cond.keyword} IS NULL OR
// LOWER(r.title) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%')) OR
// LOWER(c.companyName) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%'))
// )
// GROUP BY r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
// r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
// HAVING (:#{#cond.techStacks} IS NULL OR COUNT(DISTINCT ts.id) = :#{#cond.techStackCount}) AND
// (:#{#cond.tags} IS NULL OR COUNT(DISTINCT t.id) = :#{#cond.tagCount})
// ORDER BY r.modifiedAt DESC
// """,
// countQuery = """
// SELECT COUNT(DISTINCT r.id)
// FROM Recruitment r
// JOIN r.company c
// LEFT JOIN r.techStacks ts
// LEFT JOIN r.tags t
// WHERE r.dueDate > CURRENT_TIMESTAMP AND
// (:#{#cond.salaryMin} IS NULL OR r.salaryMin >= :#{#cond.salaryMin}) AND
// (:#{#cond.salaryMax} IS NULL OR r.salaryMax <= :#{#cond.salaryMax}) AND
// (
// (:#{#cond.experienceMin} IS NULL OR :#{#cond.experienceMax} IS NULL)
// OR
// (r.experienceMax >= :#{#cond.experienceMin} AND r.experienceMin <= :#{#cond.experienceMax})
// ) AND
// (:#{#cond.education} IS NULL OR r.education = :#{#cond.education}) AND
// (:#{#cond.workLocation} IS NULL OR r.workLocation LIKE CONCAT('%', :#{#cond.workLocation}, '%')) AND
// (
// :#{#cond.topLeftLat} IS NULL OR
// :#{#cond.topLeftLng} IS NULL OR
// :#{#cond.bottomRightLat} IS NULL OR
// :#{#cond.bottomRightLng} IS NULL OR
// (
// r.latitude BETWEEN LEAST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND GREATEST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND
// r.longitude BETWEEN LEAST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng}) AND GREATEST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng})
// )
// ) AND
// (:#{#cond.techStacks} IS NULL OR ts.techItem.id IN :#{#cond.techStacks}) AND
// (:#{#cond.tags} IS NULL OR t.tagItem.id IN :#{#cond.tags}) AND
// (:#{#cond.keyword} IS NULL OR
// LOWER(r.title) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%')) OR
// LOWER(c.companyName) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%'))
// )
// """)
// Page<RecruitmentResponseDTO> findBySearchConditions(@Param("cond") RecruitmentSearchConditionDTO cond, Pageable pageable);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

주석 처리된 코드를 제거해주세요.

주석 처리된 JPQL 쿼리는 이미 QueryDSL 구현으로 대체되었으므로 제거하는 것이 좋습니다. 주석으로 남겨둔 코드는 유지보수성을 떨어뜨리고 혼란을 야기할 수 있습니다.

-//    /**
-//     * 채용 공고 목록을 조회합니다.
-//     * 조건에 따라 필터링된 채용 공고를 페이지 단위로 반환합니다.
-//     *
-//     * @param cond 검색 조건 DTO
-//     * @param pageable 페이징 정보
-//     * @return 필터링된 채용 공고 목록 (RecruitmentResponseDTO)
-//     *
-//     * 사용자가 지도를 드래그하여 선택한 사각형 범위 내에 있는 공고만 필터링
-//     */
-//    @Query(
-//        value = """
-//            SELECT new org.example.hugmeexp.domain.recruitment.dto.RecruitmentResponseDTO(
-//                r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
-//                r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
-//            )
-//            FROM Recruitment r
-//            JOIN r.company c
-//            LEFT JOIN r.techStacks ts
-//            LEFT JOIN r.tags t
-//            WHERE r.dueDate > CURRENT_TIMESTAMP AND
-//                (:#{#cond.salaryMin} IS NULL OR r.salaryMin >= :#{#cond.salaryMin}) AND
-//                (:#{#cond.salaryMax} IS NULL OR r.salaryMax <= :#{#cond.salaryMax}) AND
-//                (
-//                  (:#{#cond.experienceMin} IS NULL OR :#{#cond.experienceMax} IS NULL)
-//                  OR
-//                  (r.experienceMax >= :#{#cond.experienceMin} AND r.experienceMin <= :#{#cond.experienceMax})
-//                ) AND
-//                (:#{#cond.education} IS NULL OR r.education = :#{#cond.education}) AND
-//                (:#{#cond.workLocation} IS NULL OR r.workLocation LIKE CONCAT('%', :#{#cond.workLocation}, '%')) AND
-//                (
-//                    :#{#cond.topLeftLat} IS NULL OR
-//                    :#{#cond.topLeftLng} IS NULL OR
-//                    :#{#cond.bottomRightLat} IS NULL OR
-//                    :#{#cond.bottomRightLng} IS NULL OR
-//                    (
-//                        r.latitude BETWEEN LEAST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND GREATEST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND
-//                        r.longitude BETWEEN LEAST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng}) AND GREATEST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng})
-//                    )
-//                ) AND
-//                (:#{#cond.techStacks} IS NULL OR ts.techItem.id IN :#{#cond.techStacks}) AND
-//                (:#{#cond.tags} IS NULL OR t.tagItem.id IN :#{#cond.tags}) AND
-//                (:#{#cond.keyword} IS NULL OR
-//                    LOWER(r.title) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%')) OR
-//                    LOWER(c.companyName) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%'))
-//                 )
-//            GROUP BY r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
-//                r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
-//            HAVING (:#{#cond.techStacks} IS NULL OR COUNT(DISTINCT ts.id) = :#{#cond.techStackCount}) AND
-//                (:#{#cond.tags} IS NULL OR COUNT(DISTINCT t.id) = :#{#cond.tagCount})
-//            ORDER BY r.modifiedAt DESC
-//            """,
-//        countQuery = """
-//            SELECT COUNT(DISTINCT r.id)
-//            FROM Recruitment r
-//            JOIN r.company c
-//            LEFT JOIN r.techStacks ts
-//            LEFT JOIN r.tags t
-//            WHERE r.dueDate > CURRENT_TIMESTAMP AND
-//                (:#{#cond.salaryMin} IS NULL OR r.salaryMin >= :#{#cond.salaryMin}) AND
-//                (:#{#cond.salaryMax} IS NULL OR r.salaryMax <= :#{#cond.salaryMax}) AND
-//                (
-//                    (:#{#cond.experienceMin} IS NULL OR :#{#cond.experienceMax} IS NULL)
-//                     OR
-//                     (r.experienceMax >= :#{#cond.experienceMin} AND r.experienceMin <= :#{#cond.experienceMax})
-//                ) AND
-//                (:#{#cond.education} IS NULL OR r.education = :#{#cond.education}) AND
-//                (:#{#cond.workLocation} IS NULL OR r.workLocation LIKE CONCAT('%', :#{#cond.workLocation}, '%')) AND
-//                (
-//                    :#{#cond.topLeftLat} IS NULL OR
-//                    :#{#cond.topLeftLng} IS NULL OR
-//                    :#{#cond.bottomRightLat} IS NULL OR
-//                    :#{#cond.bottomRightLng} IS NULL OR
-//                    (
-//                        r.latitude BETWEEN LEAST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND GREATEST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND
-//                        r.longitude BETWEEN LEAST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng}) AND GREATEST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng})
-//                    )
-//                ) AND
-//                (:#{#cond.techStacks} IS NULL OR ts.techItem.id IN :#{#cond.techStacks}) AND
-//                (:#{#cond.tags} IS NULL OR t.tagItem.id IN :#{#cond.tags}) AND
-//                (:#{#cond.keyword} IS NULL OR
-//                    LOWER(r.title) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%')) OR
-//                    LOWER(c.companyName) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%'))
-//                )
-//    """)
-//    Page<RecruitmentResponseDTO> findBySearchConditions(@Param("cond") RecruitmentSearchConditionDTO cond, Pageable pageable);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// /**
// * 채용 공고 목록을 조회합니다.
// * 조건에 따라 필터링된 채용 공고를 페이지 단위로 반환합니다.
// *
// * @param cond 검색 조건 DTO
// * @param pageable 페이징 정보
// * @return 필터링된 채용 공고 목록 (RecruitmentResponseDTO)
// *
// * 사용자가 지도를 드래그하여 선택한 사각형 범위 내에 있는 공고만 필터링
// */
// @Query(
// value = """
// SELECT new org.example.hugmeexp.domain.recruitment.dto.RecruitmentResponseDTO(
// r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
// r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
// )
// FROM Recruitment r
// JOIN r.company c
// LEFT JOIN r.techStacks ts
// LEFT JOIN r.tags t
// WHERE r.dueDate > CURRENT_TIMESTAMP AND
// (:#{#cond.salaryMin} IS NULL OR r.salaryMin >= :#{#cond.salaryMin}) AND
// (:#{#cond.salaryMax} IS NULL OR r.salaryMax <= :#{#cond.salaryMax}) AND
// (
// (:#{#cond.experienceMin} IS NULL OR :#{#cond.experienceMax} IS NULL)
// OR
// (r.experienceMax >= :#{#cond.experienceMin} AND r.experienceMin <= :#{#cond.experienceMax})
// ) AND
// (:#{#cond.education} IS NULL OR r.education = :#{#cond.education}) AND
// (:#{#cond.workLocation} IS NULL OR r.workLocation LIKE CONCAT('%', :#{#cond.workLocation}, '%')) AND
// (
// :#{#cond.topLeftLat} IS NULL OR
// :#{#cond.topLeftLng} IS NULL OR
// :#{#cond.bottomRightLat} IS NULL OR
// :#{#cond.bottomRightLng} IS NULL OR
// (
// r.latitude BETWEEN LEAST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND GREATEST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND
// r.longitude BETWEEN LEAST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng}) AND GREATEST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng})
// )
// ) AND
// (:#{#cond.techStacks} IS NULL OR ts.techItem.id IN :#{#cond.techStacks}) AND
// (:#{#cond.tags} IS NULL OR t.tagItem.id IN :#{#cond.tags}) AND
// (:#{#cond.keyword} IS NULL OR
// LOWER(r.title) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%')) OR
// LOWER(c.companyName) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%'))
// )
// GROUP BY r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
// r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
// HAVING (:#{#cond.techStacks} IS NULL OR COUNT(DISTINCT ts.id) = :#{#cond.techStackCount}) AND
// (:#{#cond.tags} IS NULL OR COUNT(DISTINCT t.id) = :#{#cond.tagCount})
// ORDER BY r.modifiedAt DESC
// """,
// countQuery = """
// SELECT COUNT(DISTINCT r.id)
// FROM Recruitment r
// JOIN r.company c
// LEFT JOIN r.techStacks ts
// LEFT JOIN r.tags t
// WHERE r.dueDate > CURRENT_TIMESTAMP AND
// (:#{#cond.salaryMin} IS NULL OR r.salaryMin >= :#{#cond.salaryMin}) AND
// (:#{#cond.salaryMax} IS NULL OR r.salaryMax <= :#{#cond.salaryMax}) AND
// (
// (:#{#cond.experienceMin} IS NULL OR :#{#cond.experienceMax} IS NULL)
// OR
// (r.experienceMax >= :#{#cond.experienceMin} AND r.experienceMin <= :#{#cond.experienceMax})
// ) AND
// (:#{#cond.education} IS NULL OR r.education = :#{#cond.education}) AND
// (:#{#cond.workLocation} IS NULL OR r.workLocation LIKE CONCAT('%', :#{#cond.workLocation}, '%')) AND
// (
// :#{#cond.topLeftLat} IS NULL OR
// :#{#cond.topLeftLng} IS NULL OR
// :#{#cond.bottomRightLat} IS NULL OR
// :#{#cond.bottomRightLng} IS NULL OR
// (
// r.latitude BETWEEN LEAST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND GREATEST(:#{#cond.topLeftLat}, :#{#cond.bottomRightLat}) AND
// r.longitude BETWEEN LEAST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng}) AND GREATEST(:#{#cond.topLeftLng}, :#{#cond.bottomRightLng})
// )
// ) AND
// (:#{#cond.techStacks} IS NULL OR ts.techItem.id IN :#{#cond.techStacks}) AND
// (:#{#cond.tags} IS NULL OR t.tagItem.id IN :#{#cond.tags}) AND
// (:#{#cond.keyword} IS NULL OR
// LOWER(r.title) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%')) OR
// LOWER(c.companyName) LIKE LOWER(CONCAT('%', :#{#cond.keyword}, '%'))
// )
// """)
// Page<RecruitmentResponseDTO> findBySearchConditions(@Param("cond") RecruitmentSearchConditionDTO cond, Pageable pageable);
🤖 Prompt for AI Agents
In
src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepository.java
between lines 19 and 104, remove the entire block of commented-out JPQL query
code. This code is obsolete because the functionality has been replaced by a
QueryDSL implementation, so deleting these commented lines will improve code
maintainability and reduce confusion.

Comment on lines 28 to 90
public Page<RecruitmentResponseDTO> findBySearchConditions(RecruitmentSearchConditionDTO cond, Pageable pageable) {
QRecruitment r = QRecruitment.recruitment;
QCompany c = QCompany.company;
QTechStack ts = QTechStack.techStack;
QTag t = QTag.tag;

// Group By 후 having 절에서 사용할 count 컬럼
NumberExpression<Long> techStackCount = ts.id.count();
NumberExpression<Long> tagCount = t.id.count();

List<RecruitmentResponseDTO> content = queryFactory
.select(new QRecruitmentResponseDTO(
r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
))
.from(r)
.join(r.company, c)
.leftJoin(r.techStacks, ts)
.leftJoin(r.tags, t)
.where(
r.dueDate.gt(LocalDateTime.now()),
salaryMinGoe(cond.getSalaryMin()),
salaryMaxLoe(cond.getSalaryMax()),
experienceBetween(cond.getExperienceMin(), cond.getExperienceMax()),
educationEq(cond.getEducation()),
workLocationLike(cond.getWorkLocation()),
withinBounds(cond),
keywordLike(cond.getKeyword()),
techStacksIn(cond.getTechStacks()),
tagsIn(cond.getTags())
)
.groupBy(r.id)
.having(
techStackCountEq(techStackCount, cond.getTechStacks(), cond.getTechStackCount()),
tagCountEq(tagCount, cond.getTags(), cond.getTagCount())
)
.orderBy(r.modifiedAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();

Long total = queryFactory
.select(r.id.countDistinct())
.from(r)
.join(r.company, c)
.leftJoin(r.techStacks, ts)
.leftJoin(r.tags, t)
.where(
r.dueDate.gt(LocalDateTime.now()),
salaryMinGoe(cond.getSalaryMin()),
salaryMaxLoe(cond.getSalaryMax()),
experienceBetween(cond.getExperienceMin(), cond.getExperienceMax()),
educationEq(cond.getEducation()),
workLocationLike(cond.getWorkLocation()),
withinBounds(cond),
keywordLike(cond.getKeyword()),
techStacksIn(cond.getTechStacks()),
tagsIn(cond.getTags())
)
.fetchOne();

return new PageImpl<>(content, pageable, total);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

🛠️ Refactor suggestion

QueryDSL 구현에 대한 통합 테스트를 추가하세요.

복잡한 필터링 로직이 올바르게 동작하는지 확인하기 위해 다음 시나리오에 대한 테스트가 필요합니다:

  • techStacks/tags 필터링 시 정확한 카운트 매칭
  • 지리적 경계 필터링
  • 페이지네이션이 올바른 총 개수를 반환하는지

통합 테스트 코드를 생성해드릴까요? 또는 이를 추적하기 위한 새 이슈를 생성할까요?

🤖 Prompt for AI Agents
In
src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java
around lines 28 to 90, the complex filtering logic in findBySearchConditions
requires integration tests to verify correct behavior. Add tests covering
scenarios for exact count matching of techStacks and tags filters, geographic
boundary filtering, and pagination returning the correct total count. These
tests should use realistic data setups and assert that the repository method
returns expected results under these conditions.

Comment on lines 69 to 87
Long total = queryFactory
.select(r.id.countDistinct())
.from(r)
.join(r.company, c)
.leftJoin(r.techStacks, ts)
.leftJoin(r.tags, t)
.where(
r.dueDate.gt(LocalDateTime.now()),
salaryMinGoe(cond.getSalaryMin()),
salaryMaxLoe(cond.getSalaryMax()),
experienceBetween(cond.getExperienceMin(), cond.getExperienceMax()),
educationEq(cond.getEducation()),
workLocationLike(cond.getWorkLocation()),
withinBounds(cond),
keywordLike(cond.getKeyword()),
techStacksIn(cond.getTechStacks()),
tagsIn(cond.getTags())
)
.fetchOne();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

카운트 쿼리에 GROUP BY와 HAVING 절이 누락되었습니다.

techStacks나 tags 필터를 사용할 때 카운트 쿼리가 부정확한 결과를 반환할 수 있습니다. 컨텐츠 쿼리에는 GROUP BY와 HAVING이 있지만 카운트 쿼리에는 없어서 페이지네이션 총 개수가 잘못 계산됩니다.

두 가지 해결 방법을 제안합니다:

방법 1: 서브쿼리를 사용한 카운트

Long total = queryFactory
    .select(r.id.countDistinct())
    .from(r)
    .join(r.company, c)
    .leftJoin(r.techStacks, ts)
    .leftJoin(r.tags, t)
    .where(
        r.dueDate.gt(LocalDateTime.now()),
        // ... 기존 조건들
    )
    .groupBy(r.id)
    .having(
        techStackCountEq(ts.id.count(), cond.getTechStacks(), cond.getTechStackCount()),
        tagCountEq(t.id.count(), cond.getTags(), cond.getTagCount())
    )
    .fetch().size(); // 또는 서브쿼리로 감싸서 count

방법 2: 두 단계 쿼리 (PR 목표에 언급된 접근법)

// 먼저 필터링된 ID 목록을 조회
List<Long> filteredIds = queryFactory
    .select(r.id)
    .from(r)
    .join(r.company, c)
    .leftJoin(r.techStacks, ts)
    .leftJoin(r.tags, t)
    .where(/* 조건들 */)
    .groupBy(r.id)
    .having(/* having 조건들 */)
    .fetch();

// 그 다음 ID 목록으로 페이지네이션
🤖 Prompt for AI Agents
In
src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java
around lines 69 to 87, the count query lacks GROUP BY and HAVING clauses,
causing inaccurate total counts when filtering by techStacks or tags. To fix
this, modify the count query to include GROUP BY r.id and HAVING clauses that
match the filters on techStacks and tags, then either count the resulting
grouped records or wrap the query as a subquery to get the total count.
Alternatively, implement a two-step query: first fetch the filtered IDs with
GROUP BY and HAVING, then count or paginate based on that ID list.

- RecruitmentRepositoryImpl 클래스에서 buildWhereClause와 buildHavingClause 메서드를 분리하여 코드 간소화
- 중복된 메서드를 제거하고 코드 가독성 향상
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java (2)

55-63: 카운트 쿼리에 GROUP BY와 HAVING 절이 누락되었습니다.

techStacks나 tags 필터를 사용할 때 카운트 쿼리가 부정확한 결과를 반환할 수 있습니다. 컨텐츠 쿼리에는 GROUP BY와 HAVING이 있지만 카운트 쿼리에는 없어서 페이지네이션 총 개수가 잘못 계산됩니다.


28-65: QueryDSL 구현에 대한 통합 테스트를 추가하세요.

복잡한 필터링 로직이 올바르게 동작하는지 확인하기 위해 다음 시나리오에 대한 테스트가 필요합니다:

  • techStacks/tags 필터링 시 정확한 카운트 매칭
  • 지리적 경계 필터링
  • 페이지네이션이 올바른 총 개수를 반환하는지
📜 Review details

Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 13ab493 and 059361f.

📒 Files selected for processing (1)
  • src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: Roysung0625
PR: Please-Hug/HugmeEXP_Backend#32
File: src/main/java/org/example/hugmeexp/domain/studyRoom/dto/response/ReservationDetailDto.java:5-5
Timestamp: 2025-07-23T09:00:11.298Z
Learning: ReservationDetailDto와 같은 response DTO에서는 Data 어노테이션을 사용하지 않고, 사용하지 않는 lombok import는 제거하는 것을 선호한다.
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (1)
src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java (1)

128-134: techItem.idtagItem.id 경로 검증 완료
TechStack 엔티티가 techItem 필드를, Tag 엔티티가 tagItem 필드를 통해 각각 TechItem/TagItem 엔티티를 참조하고 있으므로,
QueryDSL에서 사용한 QTechStack.techStack.techItem.id.in(...)QTag.tag.tagItem.id.in(...) 구문은 엔티티 관계에 부합합니다.
추가 수정 사항은 없습니다.

Comment on lines +80 to +85
private BooleanExpression buildHavingClause(NumberExpression<Long> techStackCountExpr, List<Long> techStacks, Long techStackCount,
NumberExpression<Long> tagCountExpr, List<Long> tags, Long tagCount) {
BooleanExpression techExpr = techStacks != null && techStackCount != null ? techStackCountExpr.eq(techStackCount) : null;
BooleanExpression tagExpr = tags != null && tagCount != null ? tagCountExpr.eq(tagCount) : null;
return techExpr != null ? (tagExpr != null ? techExpr.and(tagExpr) : techExpr) : tagExpr;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

HAVING 절 로직을 더 읽기 쉽게 개선할 수 있습니다.

현재 구현은 정확하지만, 중첩된 삼항 연산자로 인해 가독성이 떨어집니다.

-private BooleanExpression buildHavingClause(NumberExpression<Long> techStackCountExpr, List<Long> techStacks, Long techStackCount,
-                                            NumberExpression<Long> tagCountExpr, List<Long> tags, Long tagCount) {
-    BooleanExpression techExpr = techStacks != null && techStackCount != null ? techStackCountExpr.eq(techStackCount) : null;
-    BooleanExpression tagExpr = tags != null && tagCount != null ? tagCountExpr.eq(tagCount) : null;
-    return techExpr != null ? (tagExpr != null ? techExpr.and(tagExpr) : techExpr) : tagExpr;
-}
+private BooleanExpression buildHavingClause(NumberExpression<Long> techStackCountExpr, List<Long> techStacks, Long techStackCount,
+                                            NumberExpression<Long> tagCountExpr, List<Long> tags, Long tagCount) {
+    BooleanExpression techExpr = techStacks != null && techStackCount != null ? techStackCountExpr.eq(techStackCount) : null;
+    BooleanExpression tagExpr = tags != null && tagCount != null ? tagCountExpr.eq(tagCount) : null;
+    
+    if (techExpr == null) return tagExpr;
+    if (tagExpr == null) return techExpr;
+    return techExpr.and(tagExpr);
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private BooleanExpression buildHavingClause(NumberExpression<Long> techStackCountExpr, List<Long> techStacks, Long techStackCount,
NumberExpression<Long> tagCountExpr, List<Long> tags, Long tagCount) {
BooleanExpression techExpr = techStacks != null && techStackCount != null ? techStackCountExpr.eq(techStackCount) : null;
BooleanExpression tagExpr = tags != null && tagCount != null ? tagCountExpr.eq(tagCount) : null;
return techExpr != null ? (tagExpr != null ? techExpr.and(tagExpr) : techExpr) : tagExpr;
}
private BooleanExpression buildHavingClause(NumberExpression<Long> techStackCountExpr, List<Long> techStacks, Long techStackCount,
NumberExpression<Long> tagCountExpr, List<Long> tags, Long tagCount) {
BooleanExpression techExpr = techStacks != null && techStackCount != null
? techStackCountExpr.eq(techStackCount)
: null;
BooleanExpression tagExpr = tags != null && tagCount != null
? tagCountExpr.eq(tagCount)
: null;
if (techExpr == null) return tagExpr;
if (tagExpr == null) return techExpr;
return techExpr.and(tagExpr);
}
🤖 Prompt for AI Agents
In
src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java
around lines 80 to 85, the buildHavingClause method uses nested ternary
operators that reduce readability. Refactor the method to use clear if-else
statements or separate conditional checks to build and combine techExpr and
tagExpr expressions, improving code clarity while preserving the existing logic.

- RecruitmentRepository 페이징 로직 분리 및 최적화
- ID 조회, 상세 조회, 총 개수 조회 단계별 쿼리 실행으로 성능 개선
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java (3)

68-75: 카운트 쿼리에 GROUP BY와 HAVING 절이 여전히 누락되어 있습니다.

techStacks나 tags 필터에 정확한 개수 매칭 조건이 있을 때, 카운트 쿼리가 부정확한 결과를 반환합니다. ID 조회 쿼리(35-48줄)에는 GROUP BY와 HAVING이 있지만 카운트 쿼리에는 없어서 페이지네이션 총 개수가 잘못 계산됩니다.

다음과 같이 수정하세요:

// 3. 총 개수 조회 (조건만 적용)
-Long total = queryFactory
-        .select(r.id.countDistinct())
-        .from(r)
-        .join(r.company, c)
-        .leftJoin(r.techStacks, ts)
-        .leftJoin(r.tags, t)
-        .where(buildWhereClause(r, cond))
-        .fetchOne();
+List<Long> totalIds = queryFactory
+        .select(r.id)
+        .from(r)
+        .join(r.company, c)
+        .leftJoin(r.techStacks, ts)
+        .leftJoin(r.tags, t)
+        .where(buildWhereClause(r, cond))
+        .groupBy(r.id)
+        .having(buildHavingClause(ts.id.count(), cond.getTechStacks(), cond.getTechStackCount(),
+                t.id.count(), cond.getTags(), cond.getTagCount()))
+        .fetch();
+Long total = (long) totalIds.size();

94-99: HAVING 절 로직을 더 읽기 쉽게 개선하세요.

중첩된 삼항 연산자로 인해 가독성이 떨어집니다.

private BooleanExpression buildHavingClause(NumberExpression<Long> techStackCountExpr, List<Long> techStacks, Long techStackCount,
                                            NumberExpression<Long> tagCountExpr, List<Long> tags, Long tagCount) {
    BooleanExpression techExpr = techStacks != null && techStackCount != null ? techStackCountExpr.eq(techStackCount) : null;
    BooleanExpression tagExpr = tags != null && tagCount != null ? tagCountExpr.eq(tagCount) : null;
-    return techExpr != null ? (tagExpr != null ? techExpr.and(tagExpr) : techExpr) : tagExpr;
+    
+    if (techExpr == null) return tagExpr;
+    if (tagExpr == null) return techExpr;
+    return techExpr.and(tagExpr);
}

28-78: 복잡한 필터링 로직에 대한 통합 테스트를 추가하세요.

특히 다음 시나리오에 대한 테스트가 필요합니다:

  • techStacks/tags 정확한 개수 매칭
  • 지리적 경계 필터링
  • 페이지네이션 정확성
  • 여러 조건 조합 시 동작

통합 테스트 코드를 생성해드릴까요? 또는 이를 추적하기 위한 새 이슈를 생성할까요?

📜 Review details

Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 059361f and b5e12ff.

📒 Files selected for processing (1)
  • src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: Roysung0625
PR: Please-Hug/HugmeEXP_Backend#32
File: src/main/java/org/example/hugmeexp/domain/studyRoom/dto/response/ReservationDetailDto.java:5-5
Timestamp: 2025-07-23T09:00:11.298Z
Learning: ReservationDetailDto와 같은 response DTO에서는 Data 어노테이션을 사용하지 않고, 사용하지 않는 lombok import는 제거하는 것을 선호한다.

Comment on lines +55 to +65
List<RecruitmentResponseDTO> content = queryFactory
.select(new QRecruitmentResponseDTO(
r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
))
.from(r)
.join(r.company, c)
.leftJoin(r.techStacks, ts)
.leftJoin(r.tags, t)
.where(r.id.in(ids))
.fetch();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

콘텐츠 조회 시 정렬 순서가 보장되지 않을 수 있습니다.

ID 조회 시에는 modifiedAt.desc()로 정렬했지만, IN 절로 콘텐츠를 조회할 때는 정렬이 적용되지 않아 순서가 뒤바뀔 수 있습니다.

List<RecruitmentResponseDTO> content = queryFactory
        .select(new QRecruitmentResponseDTO(
                r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
                r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
        ))
        .from(r)
        .join(r.company, c)
        .leftJoin(r.techStacks, ts)
        .leftJoin(r.tags, t)
        .where(r.id.in(ids))
+       .orderBy(r.modifiedAt.desc())
        .fetch();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
List<RecruitmentResponseDTO> content = queryFactory
.select(new QRecruitmentResponseDTO(
r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
))
.from(r)
.join(r.company, c)
.leftJoin(r.techStacks, ts)
.leftJoin(r.tags, t)
.where(r.id.in(ids))
.fetch();
List<RecruitmentResponseDTO> content = queryFactory
.select(new QRecruitmentResponseDTO(
r.id, r.recruitmentSourceId, r.title, c.companyName, c.companyImageUrl, r.dueDate,
r.experienceMin, r.experienceMax, r.workLocation, r.latitude, r.longitude, r.modifiedAt
))
.from(r)
.join(r.company, c)
.leftJoin(r.techStacks, ts)
.leftJoin(r.tags, t)
.where(r.id.in(ids))
.orderBy(r.modifiedAt.desc())
.fetch();
🤖 Prompt for AI Agents
In
src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java
around lines 55 to 65, the query fetching RecruitmentResponseDTO by IDs does not
apply any sorting, which can cause the result order to be inconsistent. To fix
this, add an orderBy clause with modifiedAt.desc() to the query to ensure the
results are sorted by modification date in descending order.

}


private BooleanExpression buildWhereClause(QRecruitment r,RecruitmentSearchConditionDTO cond) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

파라미터 사이에 공백을 추가하세요.

코드 일관성을 위해 파라미터 사이에 공백을 추가하세요.

-private BooleanExpression buildWhereClause(QRecruitment r,RecruitmentSearchConditionDTO cond) {
+private BooleanExpression buildWhereClause(QRecruitment r, RecruitmentSearchConditionDTO cond) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private BooleanExpression buildWhereClause(QRecruitment r,RecruitmentSearchConditionDTO cond) {
private BooleanExpression buildWhereClause(QRecruitment r, RecruitmentSearchConditionDTO cond) {
🤖 Prompt for AI Agents
In
src/main/java/org/example/hugmeexp/domain/recruitment/repository/RecruitmentRepositoryImpl.java
at line 81, add a space between the parameters 'QRecruitment r' and
'RecruitmentSearchConditionDTO cond' in the method signature of buildWhereClause
to maintain code consistency and readability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

성능 개선 구현된 기능의 성능을 개선합니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[기능] QueryDSL

3 participants

Comments