
JPA로 프로젝트를 진행하다 보면, "쿼리를 어떻게 짜는 게 가장 좋은 방식일까?"라는 고민이 생기기 마련이다.
이번 글에서는 내가 직접 운영 중인 사용자 관리 페이지를 예로 들어, 다음 세 가지 방식의 장단점을 비교하고,실제로 어떤 방식으로 발전해왔는지 공유해보려 한다.
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)로 필터링위 요구사항을 만족시키기 위해 처음에는 JPA Specification을 도입했다.
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);
};
}
@GetMapping("/search")
public String searchUsers(...) {
List<String> queries = parseQueryList(query);
Page<UserWithRolesDTO> pagedUsers = userRoleService.searchUsers(queries, enabled, roleIds, pageable);
...
}
이 구조의 장점은 동적으로 조건을 붙일 수 있다는 점이다. 하지만 다음과 같은 단점이 있었다.
그래서 다음 단계로 넘어가기로 했다.
QueryDSL은 쿼리를 자바 코드로 작성하되, 타입 안정성과 IDE 지원을 제공하는 라이브러리다.
@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);
}
}
(원래 있던 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은 충분히 도입할 가치가 있다."