복잡한 검색 쿼리, 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.99 77.33 55.93 Date Filter (날짜 필터) 15.86 44.00 55.96 Basic (기본 페이지네이션) 39.99 81.67 55.64 Complex Filter (복합 조건) 20.95 75.00 55.30 평균 24.45 - 55.71
3. MyBatis 도입
도입 배경
왜 MyBatis인가?
- SQL과 Java 코드 완전 분리 → 가독성 향상
- 복잡한 동적 쿼리 작성 용이
- 쿼리 튜닝 및 실행 계획 분석 편리
- 필요한 컬럼만 선택적으로 조회 가능
초기 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) |
|---|---|---|---|
| Search | 24.04 | 108 | 59.15 |
| Date Filter | 23.39 | 92 | 59.34 |
| Basic | 23.85 | 473 | 57.59 |
| Complex Filter | 23.46 | 112 | 59.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 성능 비교
-
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(*) 계산 -
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) |
|---|---|---|---|
| Search | 17.16 | 48 | 68.95 |
| Date Filter | 15.89 | 32 | 68.99 |
| Basic | 17.23 | 54 | 68.64 |
| Complex Filter | 17.11 | 48 | 69.02 |
| 평균 | 16.85 | - | 68.90 |
전체 비교

개선 효과
초기 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로 분리해 두 기술의 장점을 역할에 맞게 사용했습니다.
참고 자료