JPA를 활용한 다중 테이블 복합 쿼리 구현

서론: JPA의 복잡한 조회 기능

최근 프로젝트에서 데이터베이스 접근 계층에 JPA와 Hibernate 조합을 사용해야 하는 요구사항이 있었다. 단순 CRUD 작업은 CrudRepository 인터페이스만으로 충분하지만, 동적 조건과 여러 테이블을 포함하는 복잡한 검색 로직에서는 한계가 있다. 특히 UI에서 입력된 다양한 필터 조건을 조합해 유연하게 쿼리를 생성해야 할 때는 JpaSpecificationExecutor와 JPA Criteria API를 활용하는 것이 효과적이다.

기본 개념: Specification과 동적 쿼리

Specification은 JPA에서 동적 쿼리를 작성하기 위한 인터페이스로, toPredicate() 메서드를 통해 조건을 구성할 수 있다. 이를 통해 빌더 패턴처럼 원하는 조건을 조합하여 최종 쿼리를 생성할 수 있다.

예제 1: 단일 엔티티 기반 동적 필터링

사용자 정보(User)를 이름, ID, 전화번호로 필터링하는 시나리오를 가정하자. 각 조건은 선택적으로 적용되어야 하며, 입력된 값이 있을 경우에만 해당 조건이 쿼리에 포함되어야 한다.

먼저 요청 파라미터를 담을 DTO 클래스를 정의한다:

public class UserSearchCriteria {
    private Integer page = 0;
    private Integer size = 10;
    private String name;
    private String userId;
    private String mobile;

    // getter 및 setter 생략
}

다음으로 Specification을 사용해 조건을 조합하는 로직을 작성한다:

public Specification<User> buildSpec(UserSearchCriteria criteria) {
    return (root, query, cb) -> {
        List<Predicate> predicates = new ArrayList<>();

        if (criteria.getName() != null && !criteria.getName().trim().isEmpty()) {
            predicates.add(cb.like(root.get("name"), "%" + criteria.getName() + "%"));
        }

        if (criteria.getUserId() != null && !criteria.getUserId().trim().isEmpty()) {
            predicates.add(cb.equal(root.get("id"), Long.valueOf(criteria.getUserId())));
        }

        if (criteria.getMobile() != null && !criteria.getMobile().trim().isEmpty()) {
            predicates.add(cb.like(root.get("mobile"), "%" + criteria.getMobile() + "%"));
        }

        return cb.and(predicates.toArray(new Predicate[0]));
    };
}

레포지토리는 다음과 같이 선언되어야 한다:

public interface UserRepository extends JpaRepository<User, Long>, 
                                      JpaSpecificationExecutor<User> {
}

서비스 계층에서는 다음과 같이 사용할 수 있다:

Pageable pageable = PageRequest.of(criteria.getPage(), criteria.getSize());
Page<User> result = userRepository.findAll(buildSpec(criteria), pageable);

예제 2: 다중 엔티티 조인 기반 복합 조회

다음은 더 복잡한 시나리오로, 여러 연관 엔티티를 조인하여 조건을 걸어야 하는 경우다. 아래와 같은 도메인 모델이 있다고 가정하자:

  • Resident: 거주자 정보 (기준 테이블)
  • Person: 개인 기본 정보 (Resident → Person 일대일 관계)
  • Profile: 상세 프로필 (Person → Profile 일대일 관계)
  • Area: 지역 정보 (Resident → Area 다대일 관계)

엔티티 정의 예시:

@Entity
public class Resident {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "area_id")
    private Area area;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "person_id")
    private Person person;
}

@Entity
public class Person {
    @Id
    private Long id;

    @OneToOne(fetch = FetchType.LAZY, mappedBy = "person")
    private Profile profile;

    @Enumerated(EnumType.STRING)
    private Gender gender;
}

@Entity
public class Profile {
    @Id
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "person_id")
    private Person person;

    private String realName;
}

@Entity
public class Area {
    @Id
    private Long id;
    private String cityName;
}

이제 다음 조건을 기반으로 Resident를 조회하고자 한다:

  • Profile.realName에 특정 문자열 포함
  • Person.gender 조건 일치
  • Area.id와 일치

검색 조건을 담을 클래스:

public class ResidentSearchCriteria {
    private String realName;
    private Gender gender;
    private Long areaId;
    private int page = 0;
    private int size = 10;
}

Specification 기반 동적 쿼리 생성:

public Specification<Resident> buildResidentSpec(ResidentSearchCriteria criteria) {
    return (root, query, cb) -> {
        List<Predicate> conditions = new ArrayList<>();

        if (criteria.getRealName() != null && !criteria.getRealName().trim().isEmpty()) {
            Join<Person, Resident> personJoin = root.join("person", JoinType.LEFT);
            Join<Profile, Person> profileJoin = personJoin.join("profile", JoinType.LEFT);
            conditions.add(cb.like(profileJoin.get("realName"), "%" + criteria.getRealName() + "%"));
        }

        if (criteria.getGender() != null) {
            Join<Person, Resident> personJoin = root.join("person", JoinType.LEFT);
            conditions.add(cb.equal(personJoin.get("gender"), criteria.getGender()));
        }

        if (criteria.getAreaId() != null) {
            Join<Area, Resident> areaJoin = root.join("area", JoinType.LEFT);
            conditions.add(cb.equal(areaJoin.get("id"), criteria.getAreaId()));
        }

        return conditions.isEmpty() ? 
               cb.conjunction() : 
               cb.and(conditions.toArray(new Predicate[0]));
    };
}

레포지토리 인터페이스:

public interface ResidentRepository extends JpaRepository<Resident, Long>,
                                          JpaSpecificationExecutor<Resident> {
}

조인 순서에 주의해야 한다. 예를 들어 Profile.realName에 접근하려면 먼저 resident.root에서 person을 조인한 후, 그 결과에서 다시 profile을 조인해야 한다.

성능 고려사항

  • N+1 문제 방지: 필요 시 @EntityGraph 또는 fetch 조인을 사용해 연관 데이터를 한 번에 로딩한다.
  • 조건 재사용: 자주 사용되는 조건은 별도의 메서드로 분리하여 재사용성을 높인다.
  • NULL 처리: 조건이 없을 경우 무조건 LEFT JOIN이 발생할 수 있으므로, 조건 존재 여부를 확인하고 조인을 제어하는 것이 중요하다.

결론

처음에는 MyBatis와 같은 SQL 중심 프레임워크보다 JPA의 복잡한 쿼리 작성이 불편하게 느껴질 수 있다. 그러나 Specification 패턴과 Criteria API를 익히게 되면, 타입 안전성과 유지보수성 측면에서 큰 이점을 얻을 수 있다. 특히 동적 쿼리가 많은 관리자 화면이나 검색 기능에서 강점을 발휘하며, SQL 지식이 부족한 개발자라도 안정적인 쿼리를 작성할 수 있도록 도와준다.

태그: jpa hibernate Specification Criteria API Spring Data JPA

7월 7일 23:59에 게시됨