본문으로 건너뛰기

복잡한 검색 쿼리, JPA vs MyBatis 성능 비교와 하이브리드 전략

관리자 페이지의 복잡한 검색 기능을 어떤 방식으로 구현해야 유지보수성과 성능을 함께 확보할 수 있을까?

다양한 필터 조건을 가진 검색 기능을 구현하면서 순수 JPA → Native Query → JPA Specification → MyBatis 순서로 접근 방식을 변경했습니다. 각 방식의 한계와 성능 측정 결과를 비교하고, 최종적으로 JPA와 MyBatis의 책임을 나눈 기준을 정리합니다.


1. 프로젝트 요구사항​

관리자 페이지의 고객 검색 기능​

비즈니스 요구사항:

  • 다양한 필터 조건 지원
    • 기간 필터 (가입일, 최종 로그인)
    • 계정 상태 (활성/비활성/잠김)
    • 권한별 필터
  • 키워드 검색 범위
    • 전체 검색 (이메일, 이름, 소속, 부서, 연락처)
    • 개별 필드 검색
  • 번호 기반 페이지네이션 (1, 2, 3... 페이지)

기술적 요구사항​

성능 목표:

  • 별도의 응답시간 목표는 없었으며, 동일한 테스트 조건에서 구현 방식별 상대 성능을 비교

유지보수성:

  • 새로운 검색 조건 추가 용이
  • 쿼리 가독성 확보
  • 테스트 가능한 구조

2. 구현 과정​

1단계: 순수 JPA Repository​

public interface UserRepository extends JpaRepository {
Page findByClientCompanyNameContaining(String companyName, Pageable pageable);
Page findByLastLoginAtBetween(LocalDateTime start, LocalDateTime end, Pageable pageable);
}

문제점​

  • 필터링 조건 마다
    • Repository 메서드 증가 (조건 N개 → 2^N개)
    • Service의 분기 처리 복잡
    • 가독성과 유지 보수성의 저하

2단계: Native Query​

@Query 어노테이션 사용

문제점​

  • Native Query를 사용하여,
    • DB 종속 (MySQL 전용)

    • 동적 조건 처리 (IS NULL OR) 비효율

    • 실행 계획 최적화 어려움

      • 실행 계획 분석:
          EXPLAIN SELECT u.*
      FROM user u
      WHERE :keyword IS NULL OR u.email LIKE CONCAT('%', :keyword, '%');
      • 문제점:
        • 해당 데이터와 쿼리 조건에서는 IS NULL OR 구문이 포함될 때 인덱스를 활용하지 못함
          • EXPLAIN 결과 Full Table Scan 발생
          • 데이터가 늘어날수록 성능 저하

3단계: JPA Specification​

Repository 확장:

// Specification 사용을 위해 상속만 추가
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {}

Specification 구현:

public class UserSpecification {
public static Specification<User> filterClients(
LocalDate startDate,
LocalDate endDate,
String range,
String keyword) {

return (root, query, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
...
}
}
}

장점​

  • Fetch Join을 동적으로 제어하여 N+1 문제를 해결
  • ORM 일관성 유지

단점​

  • Java와 SQL 로직 혼재
  • 복잡한 쿼리 작성의 어려움
  • 쿼리 튜닝의 어려움
    • 실제 실행되는 SQL을 보려면 로그 확인 필요
    • 실행 계획 분석이 어려움
    • 인덱스 힌트 사용 불가

성능 테스트 결과​

테스트 환경:

  • JMeter (10명 동시 접속, 50회 요청| GET /admin/clients)

  • 데이터: User 1,050명 (Admin 50 + Client 1,000)

    시나리오평균 응답시간(ms)최대(ms)처리량(TPS)
    Search (키워드 검색)20.9977.3355.93
    Date Filter (날짜 필터)15.8644.0055.96
    Basic (기본 페이지네이션)39.9981.6755.64
    Complex Filter (복합 조건)20.9575.0055.30
    평균24.45-55.71

3. MyBatis 도입​

도입 배경​

왜 MyBatis인가?

  1. SQL과 Java 코드 완전 분리 → 가독성 향상
  2. 복잡한 동적 쿼리 작성 용이
  3. 쿼리 튜닝 및 실행 계획 분석 편리
  4. 필요한 컬럼만 선택적으로 조회 가능

초기 MyBatis 구현(문제점 포함)​

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.mapper.UserMapper">
<select id="searchClients" resultType="ClientDto">
SELECT
u.user_id,
u.email,
u.name,
u.last_login_at,
c.phone_number,
co.company_name
FROM user u
LEFT JOIN clients c ON u.client_id = c.client_id
LEFT JOIN company co ON c.company_id = co.company_id
WHERE u.enabled = true

<!-- 날짜 필터 -->
<if test="startDate != null and endDate != null">
AND u.last_login_at BETWEEN #{startDate} AND #{endDate}
</if>

<!-- 키워드 검색 - 문제점: choose 중첩 -->
<if test="keyword != null and keyword != ''">
<choose>
<when test="range == 'all'">
AND (
u.email LIKE CONCAT('%', #{keyword}, '%')
OR u.name LIKE CONCAT('%', #{keyword}, '%')
OR co.company_name LIKE CONCAT('%', #{keyword}, '%')
OR c.phone_number LIKE CONCAT('%', #{keyword}, '%')
)
</when>
<when test="range == 'email'">
AND u.email LIKE CONCAT('%', #{keyword}, '%')
</when>
<when test="range == 'name'">
AND u.name LIKE CONCAT('%', #{keyword}, '%')
</when>
<when test="range == 'company'">
AND co.company_name LIKE CONCAT('%', #{keyword}, '%')
</when>
<when test="range == 'phone'">
AND c.phone_number LIKE CONCAT('%', #{keyword}, '%')
</when>
</choose>
</if>

ORDER BY u.created_at DESC
LIMIT #{offset}, #{size}
</select>

<!-- COUNT 쿼리 - 문제점: 불필요한 JOIN -->
<select id="countFilteredClients" resultType="long">
SELECT COUNT(*)
FROM user u
LEFT JOIN clients c ON u.client_id = c.client_id
LEFT JOIN company co ON c.company_id = co.company_id
WHERE u.enabled = true

<if test="startDate != null and endDate != null">
AND u.last_login_at BETWEEN #{startDate} AND #{endDate}
</if>

<if test="keyword != null and keyword != ''">
<choose>
<when test="range == 'all'">
AND (
u.email LIKE CONCAT('%', #{keyword}, '%')
OR u.name LIKE CONCAT('%', #{keyword}, '%')
OR co.company_name LIKE CONCAT('%', #{keyword}, '%')
OR c.phone_number LIKE CONCAT('%', #{keyword}, '%')
)
</when>
<!-- ... 동일한 choose 반복 -->
</choose>
</if>
</select>
</mapper>

초기 MyBatis 성능 테스트​

시나리오평균 응답시간(ms)최대(ms)처리량(TPS)
Search24.0410859.15
Date Filter23.399259.34
Basic23.8547357.59
Complex Filter23.4611259.32
평균23.69-58.85

문제 분석​

  • choose 중첩으로 조건별 SQL 구조와 수정 지점이 늘어남

  • 최대 응답시간 편차 큼 (473ms)

  • COUNT 쿼리의 불필요한 JOIN

    SELECT COUNT(*) # 4) 3단계 조인 후 연산
    FROM user u # 1) user 테이블 스캔
    LEFT JOIN clients c ON u.client_id = c.client_id # 2) 임시 테이블 생성
    LEFT JOIN company co ON c.company_id = co.company_id # 3) 임시 테이블 생성)
    WHERE ...

4. MyBatis 최적화​

개선 1: 동적 SQL 단순화​

Before (choose 중첩) → After (OR 조건 통합):

<if test="keyword != null and keyword != ''">
AND (
(#{range} = 'all' AND (
u.email LIKE CONCAT('%', #{keyword}, '%')
OR u.name LIKE CONCAT('%', #{keyword}, '%')
OR co.company_name LIKE CONCAT('%', #{keyword}, '%')
OR c.phone_number LIKE CONCAT('%', #{keyword}, '%')
))
OR (#{range} = 'email' AND u.email LIKE CONCAT('%', #{keyword}, '%'))
OR (#{range} = 'name' AND u.name LIKE CONCAT('%', #{keyword}, '%'))
OR (#{range} = 'company' AND co.company_name LIKE CONCAT('%', #{keyword}, '%'))
OR (#{range} = 'phone' AND c.phone_number LIKE CONCAT('%', #{keyword}, '%'))
)
</if>

구조 변화:

  • Before: range 값에 따라 5개의 다른 SQL 생성
  • After: 조건 분기를 한 곳으로 모아 매퍼의 수정 지점을 줄임

개선 2: COUNT 쿼리 최적화​

Before (JOIN 기반) → After (EXISTS 서브쿼리 기반)

<select id="countFilteredClients" resultType="long">
SELECT COUNT(*)
FROM user u
WHERE u.enabled = true

<if test="startDate != null and endDate != null">
AND u.last_login_at BETWEEN #{startDate} AND #{endDate}
</if>

<if test="keyword != null and keyword != ''">
AND (
(#{range} = 'all' AND (
u.email LIKE CONCAT('%', #{keyword}, '%')
OR u.name LIKE CONCAT('%', #{keyword}, '%')
OR EXISTS (
SELECT 1 FROM clients c
JOIN company co ON c.company_id = co.company_id
WHERE c.client_id = u.client_id
AND co.company_name LIKE CONCAT('%', #{keyword}, '%')
)
OR EXISTS (
SELECT 1 FROM clients c
WHERE c.client_id = u.client_id
AND c.phone_number LIKE CONCAT('%', #{keyword}, '%')
)
))
OR (#{range} = 'email' AND u.email LIKE CONCAT('%', #{keyword}, '%'))
OR (#{range} = 'name' AND u.name LIKE CONCAT('%', #{keyword}, '%'))
OR (#{range} = 'company' AND EXISTS (
SELECT 1 FROM clients c
JOIN company co ON c.company_id = co.company_id
WHERE c.client_id = u.client_id
AND co.company_name LIKE CONCAT('%', #{keyword}, '%')
))
OR (#{range} = 'phone' AND EXISTS (
SELECT 1 FROM clients c
WHERE c.client_id = u.client_id
AND c.phone_number LIKE CONCAT('%', #{keyword}, '%')
))
)
</if>
</select>

EXISTS vs JOIN 성능 비교​

  1. JOIN 방식:

    SELECT COUNT(*)
    FROM user u
    LEFT JOIN clients c ON u.client_id = c.client_id
    LEFT JOIN company co ON c.company_id = co.company_id
    WHERE co.company_name LIKE '%ABC%'

    이 프로젝트에서 확인한 실행 흐름:

     1. user 테이블 전체 스캔
    2. clients·company 연관 테이블 조인
    3. WHERE 조건 필터링
    4. COUNT(*) 계산
  2. EXISTS 방식:

    SELECT COUNT(*)
    FROM user u
    WHERE EXISTS (
    SELECT 1 FROM clients c
    JOIN company co ON c.company_id = co.company_id
    WHERE c.client_id = u.client_id
    AND co.company_name LIKE '%ABC%'
    )

    의도한 처리 방식:

    1. user 테이블 전체 스캔
    2. 각 행마다 EXISTS 서브쿼리 실행
    2.1. 조건 만족 시 즉시 TRUE 반환 (조기 종료)
    2.2. 첫 번째 매칭 행 발견 시 더 이상 검색 안 함
    3. COUNT(*) 계산

    → 전체 연관 데이터를 결과 집합으로 만들기보다 존재 여부를 확인

측정 결과 해석:

  • 해당 COUNT 쿼리와 테스트 데이터에서는 EXISTS 구조의 평균 응답 시간이 더 낮았습니다.
  • 옵티마이저는 쿼리를 변환할 수 있으므로 작성한 SQL의 표면적인 순서가 실제 실행 순서를 그대로 의미하지는 않습니다.

실제 이점은 데이터 분포와 인덱스, 옵티마이저의 실행 계획에 따라 달라집니다. 따라서 EXISTS가 항상 JOIN보다 빠르다고 일반화하지 않고, 이 프로젝트의 쿼리를 동일한 조건에서 측정한 결과로 판단했습니다.


5. 최종 성능 비교​

MyBatis 최적화 후 테스트 결과​

시나리오평균 응답시간(ms)최대(ms)처리량(TPS)
Search17.164868.95
Date Filter15.893268.99
Basic17.235468.64
Complex Filter17.114869.02
평균16.85-68.90

전체 비교​

image

개선 효과​

초기 MyBatis 구현 대비:

  • 평균 응답시간 28.9% 감소 (23.69 → 16.85ms)
  • 평균 처리량 17.1% 증가 (58.85 → 68.90 TPS)

JPA Specification 대비:

  • 평균 응답시간 31.1% 감소 (24.45 → 16.85ms)
  • 최대 응답시간 35.5% 감소 (83.67 → 54ms)
  • Basic 요청 2.3배 빠름 (39.99 → 17.23ms)
  • 평균 처리량 23.7% 향상 (55.71 → 68.90 TPS)

최종 아키텍처 설계(혼합)​

JPA를 사용할 때:

  • 단순 CRUD 작업
  • 엔티티 간 관계 활용이 중요한 경우
  • 트랜잭션 내에서 엔티티 변경 추적이 필요한 경우
  • 2~3개 이하의 조건 조합

MyBatis를 사용할 때:

  • Window Function 및 복잡한 서브쿼리
  • 다중 테이블 조인 (3개 이상)
  • SQL 구조와 실행 계획을 직접 확인해야 하는 복합 조회
  • 쿼리 튜닝이 필요한 경우

6. 정리​

핵심 요약​

최종 전략:

  • JPA: 단순 CRUD, 엔티티 관계 활용
  • MyBatis: 복잡한 검색, 통계, 성능 최적화

성능 비교 결과:

  • JPA Specification 대비 평균 응답시간 31.1% 감소
  • JPA Specification 대비 평균 처리량 23.7% 향상

수치의 기준을 분리해 보면 MyBatis 자체가 항상 빠르다는 결론보다, 복잡한 조회에서 SQL 구조를 직접 확인하고 최적화할 수 있었다는 점이 중요합니다. 단순 CRUD는 JPA로 유지하고 복합 검색만 MyBatis로 분리해 두 기술의 장점을 역할에 맞게 사용했습니다.

참고 자료