BindProject

[Backend] 유저 프로필 모듈 구현4편 - DSL 기반 검색 기능과 응답 모델 설계

dding-shark 2025. 6. 23. 21:32
728x90

목차

  1. 도입 - DSL을 선택한 이유
  2. 전체 아키텍처 흐름
  3. DSL 쿼리 설계
  4. 응답 모델 구조
  5. 페이징 최적화 방식
  6. 정리 및 다음 편 예고

1. 도입 - DSL을 선택한 이유

우리는 사용자 프로필을 다음 조건들로 검색해야 합니다:

  • 닉네임 (like 검색)
  • 성별
  • 지역
  • 관심사 (Instrument)
  • 선호 장르 (Genre)

이러한 조건은 모두 **선택적(optional)**이고, 동시에 다중 값도 허용됩니다. 이를 만족하기 위한 쿼리 조건 생성과 join이 필요한데,
Spring Data JPA의 기본 Repository만으로는 아래와 같은 제약이 있습니다:

방식 장점 단점
Method Query 간단한 조건 처리 복잡한 조합 불가
Specification 동적 조합 가능 연관 관계 Join Fetch 어렵고 DSL보다 Verbose
Querydsl 타입 안정성, Join, 동적 조건 모두 가능 복잡도는 다소 있음

→ 따라서 DSL을 기반으로 쿼리 작성하기로 결정했습니다.


2. 전체 아키텍처 흐름

텍스트 기반 흐름도로 정리하면 다음과 같습니다:

[요청 DTO (ProfileSearchCondition)]
            |
            v
[DSL Repository]
  ├─ 조건에 따라 BooleanBuilder 구성
  ├─ Step1: id만 추출 (offset + limit 적용)
  ├─ Step2: fetchJoin으로 관계 엔티티 로딩
            |
            v
[UserProfile + 관심사 + 장르 데이터 로드]
            |
            v
[UserProfileResponse DTO 변환]
            |
            v
[Page<UserProfileResponse> 반환]

3. DSL 쿼리 설계

DSL 주요 구성

BooleanBuilder builder = new BooleanBuilder();
if (nickname != null) builder.and(profile.nickname.containsIgnoreCase(nickname));
if (location != null) builder.and(profile.location.eq(location));
...

Step1: ID 기준 페이징 쿼리

List<Long> ids = queryFactory
    .select(profile.userId)
    .from(profile)
    .where(builder)
    .offset(pageable.getOffset())
    .limit(pageable.getPageSize())
    .fetch();

Step2: fetchJoin 조회

List<UserProfile> results = queryFactory
    .selectFrom(profile)
    .leftJoin(profile.userInterests, userInterest).fetchJoin()
    .leftJoin(profile.userGenres, userGenre).fetchJoin()
    .where(profile.userId.in(ids))
    .fetch();

이중 쿼리 방식은 N+1 문제를 근본적으로 회피하고, 페이징도 정확히 처리할 수 있는 전략입니다.


4. 응답 모델 구조

UserProfileResponse DTO

@Builder
@Getter
public class UserProfileResponse {
    private Long userId;
    private String nickname;
    private String profileImageUrl;
    private String introduction;
    private String gender;
    private String location;
    private List<String> interests;
    private List<String> genres;
}

서비스 계층에서는 UserProfile + 연관 관심사/장르를 다음처럼 변환합니다:

UserProfileResponse.builder()
    .userId(profile.getUserId())
    .nickname(profile.getNickname())
    .interests(profile.getUserInterests().stream()
        .map(i -> i.getInterest().name())
        .collect(Collectors.toList()))
    ...
    .build();

5. 페이징 최적화 방식

JPA는 fetchJoinoffset/limit을 함께 쓰는 걸 공식적으로 금지합니다.

그렇기 때문에 우리는 다음과 같은 방식으로 분리합니다:

Step 1: userId만 추출 → 페이징 적용
Step 2: in(userIds) 조건으로 fetch join
Step 3: count 쿼리는 profile만 따로 카운트

장점:

  • Join 된 테이블이 아무리 커도 페이징 정확도 보장
  • 관계 데이터도 N+1 없이 한 번에 로드

6. 정리 

정리

항목 구현 방식
필터링 조건 BooleanBuilder 동적 조합
N+1 회피 fetchJoin + 2단계 조회
페이징 처리 ID 기준 추출 + IN 쿼리
응답 DTO 관심사/장르 포함 변환
728x90