Spring JPA Repository vs Specification vs QueryDSL

yeahcold·2025년 4월 14일

JPA로 프로젝트를 진행하다 보면, "쿼리를 어떻게 짜는 게 가장 좋은 방식일까?"라는 고민이 생기기 마련이다.

이번 글에서는 내가 직접 운영 중인 사용자 관리 페이지를 예로 들어, 다음 세 가지 방식의 장단점을 비교하고,실제로 어떤 방식으로 발전해왔는지 공유해보려 한다.

  • Repository 메서드 기반 정적 쿼리
  • JPA Specification 기반 동적 쿼리
  • QueryDSL 기반 타입 안전 쿼리

🧩 1. 시작은 간단한 Repository 메서드

JPA는 메서드 이름만으로 SQL을 자동 생성할 수 있다. 아래는 내가 작성했던 RolePermissionRepository의 일부다.

public interface RolePermissionRepository extends JpaRepository<RolePermission, Long> {

    List<RolePermission> findByRoleInAndEnabledTrue(List<Role> roles);

    Optional<RolePermission> findByRoleAndPermission(Role role, Permission permission);

    void deleteByRole(Role role);
}

이처럼 조건이 단순하고 조인이 필요 없을 때는 Repository 메서드로도 충분하다.
하지만 다음과 같은 요구사항이 생기면 상황은 달라진다:

  • 이름/이메일을 기반으로 다중 키워드 검색
  • 사용자 활성화 여부(enabled)로 필터링
  • 특정 역할(Role)을 가진 사용자만 조회

🔍 2. 복잡한 조건은 Specification으로 해결

위 요구사항을 만족시키기 위해 처음에는 JPA Specification을 도입했다.

UserSpecifications.java

public static Specification<User> containsNameOrEmail(List<String> keywords) {
    // ... (다중 키워드 AND/OR 처리 구현)
}

public static Specification<User> hasEnabled(Boolean enabled) {
    return (root, query, cb) -> enabled == null ? null : cb.equal(root.get("enabled"), enabled);
}

public static Specification<User> hasRoles(List<Long> roleIds) {
    return (root, query, cb) -> {
        Subquery<Long> subquery = query.subquery(Long.class);
        Root<UserRole> ur = subquery.from(UserRole.class);
        subquery.select(cb.literal(1L))
            .where(
                cb.equal(ur.get("user"), root),
                ur.get("role").get("id").in(roleIds),
                cb.isTrue(ur.get("enabled"))
            );
        return cb.exists(subquery);
    };
}

Controller 예시

@GetMapping("/search")
public String searchUsers(...) {
    List<String> queries = parseQueryList(query);
    Page<UserWithRolesDTO> pagedUsers = userRoleService.searchUsers(queries, enabled, roleIds, pageable);
    ...
}

이 구조의 장점은 동적으로 조건을 붙일 수 있다는 점이다. 하지만 다음과 같은 단점이 있었다.

  • IDE 자동완성, 네이밍 지원 부족
  • 쿼리 복잡도가 올라갈수록 가독성 저하
  • join fetch, DTO 매핑이 불편함

그래서 다음 단계로 넘어가기로 했다.


💡 3. QueryDSL로 리팩토링하기

QueryDSL은 쿼리를 자바 코드로 작성하되, 타입 안정성과 IDE 지원을 제공하는 라이브러리다.

UserQueryRepositoryImpl

@Repository
@RequiredArgsConstructor
public class UserQueryRepositoryImpl implements UserQueryRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<UserWithRolesDTO> searchUsers(List<String> keywords, Boolean enabled, List<Long> roleIds, Pageable pageable) {
        QUser user = QUser.user;
        QUserRole ur = QUserRole.userRole;
        QRole role = QRole.role;

        BooleanBuilder where = new BooleanBuilder();

        // (1) 이름 or 이메일
        if (keywords != null && !keywords.isEmpty()) {
            BooleanBuilder keywordBuilder = new BooleanBuilder();
            for (String keyword : keywords) {
                String cleaned = keyword.trim().toLowerCase();
                if (cleaned.contains("(") && cleaned.contains(")")) {
                    String namePart = cleaned.substring(0, cleaned.indexOf("(")).trim();
                    String emailPart = cleaned.substring(cleaned.indexOf("(") + 1, cleaned.indexOf(")")).trim();
                    keywordBuilder.or(user.username.lower().contains(namePart)
                        .and(user.email.lower().contains(emailPart)));
                } else {
                    keywordBuilder.or(user.username.lower().contains(cleaned)
                        .or(user.email.lower().contains(cleaned)));
                }
            }
            where.and(keywordBuilder);
        }

        if (enabled != null) {
            where.and(user.enabled.eq(enabled));
        }

        if (roleIds != null && !roleIds.isEmpty()) {
            where.and(JPAExpressions.selectOne()
                .from(ur)
                .where(
                    ur.user.eq(user),
                    ur.role.id.in(roleIds),
                    ur.enabled.isTrue()
                ).exists());
        }

        List<UserWithRolesDTO> contents = queryFactory
                .select(Projections.constructor(UserWithRolesDTO.class,
                        user.id,
                        user.username,
                        user.email,
                        user.enabled,
                        JPAExpressions.select(role.name)
                            .from(ur)
                            .join(ur.role, role)
                            .where(ur.user.eq(user), ur.enabled.isTrue())
                            .orderBy(role.name.asc())
                            .limit(1) // 첫 번째 Role 이름만 예시로 가져오기
                ))
                .from(user)
                .where(where)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        Long total = queryFactory
                .select(user.count())
                .from(user)
                .where(where)
                .fetchOne();

        return new PageImpl<>(contents, pageable, total != null ? total : 0L);
    }
}

UserWithRolesDTO 생성자

(원래 있던 DTO를 활용할 수 있다.)

public UserWithRolesDTO(Long id, String username, String email, Boolean enabled, String roleName) {
    this.id = id;
    this.username = username;
    this.email = email;
    this.enabled = enabled;
    this.roleName = roleName;
}

🗣️ 개인 회고

처음엔 무조건 Repository 메서드로만 해결하려고 했고, 점점 복잡도가 올라가면서 Specification으로 옮겼다.
하지만 결국 유지보수와 IDE 지원 때문에 QueryDSL이 가장 좋은 선택이라는 걸 느꼈다.

"타입 안정성과 가독성, 성능까지 고려한다면 QueryDSL은 충분히 도입할 가치가 있다."

profile
Software Engineer

0개의 댓글