본문으로 건너뛰기

복합 검색 성능 36% 개선 (JPA → MyBatis)

동적 SQL 최적화 + EXISTS 서브쿼리로 평균 응답시간 24.45ms → 15.51ms


0. 개요

문제 상황
  • 요구사항: 다양한 필터링 조건 (기간, 키워드 6종, 정렬)
  • JPA Specification 사용 시: Java와 SQL 로직 혼재, 가독성 저하, 튜닝 어려움
  • MyBatis 초기 버전: 동적 SQL 구조 문제로 성능 저하 (최대 473ms)
해결 방향
  • MyBatis 동적 SQL로 가독성 확보
  • <choose> 중첩 제거 → OR 조건 기반 통합
  • COUNT 쿼리를 EXISTS 서브쿼리로 변경
  • SQL 캐시 효율 향상

1. 문제 분석

요구사항: 관리자 페이지 회원 검색

필터 조건옵션
기간 필터최근 로그인 날짜 범위
키워드 검색전체 / 이메일 / 이름 / 소속 / 부서 / 연락처
정렬최근 로그인 순 / 이름 순
페이지네이션번호 기반 (10개씩)

테스트 조건

  • JMeter: 동시 사용자 10명, 각 50회 요청
  • 데이터: Company 10개, User 1050개 (Clients 1000명 + Admin 50명)
  • 4가지 시나리오: Basic(필터 없음), Date Filter, Search, Complex Filter

2. 구현 과정

1단계) 순수 JPA

public interface UserRepository extends JpaRepository {
Page findByClientCompanyNameContaining(String companyName, Pageable pageable);
Page findByLastLoginAtBetween(LocalDateTime start, LocalDateTime end, Pageable pageable);
// 필터링 조건마다 메서드 증가 → 가독성 저하
}

문제점

  • Repository 메서드 증가
  • Service 분기 처리 복잡
  • 조건 조합에 따른 메서드 폭발

2단계) Native Query

@Query(value = """
SELECT u.*, c.*, co.*
FROM user u
LEFT JOIN client c ON u.client_id = c.client_id
LEFT JOIN company co ON c.company_id = co.company_id
WHERE (:keyword IS NULL OR co.name LIKE CONCAT('%', :keyword, '%'))
AND (:startDate IS NULL OR u.last_login_at >= :startDate)
ORDER BY u.last_login_at DESC
""", nativeQuery = true)
Page searchClients(...);

문제점

  • DB 종속 (MySQL 전용)
  • 동적 조건 처리 (IS NULL OR) 비효율
  • 실행 계획 최적화 어려움

3단계) JPA Specification

public static Specification filterClients(
LocalDate startDate,
LocalDate endDate,
String range,
String keyword
) {
return (root, query, criteriaBuilder) -> {
List predicates = new ArrayList<>();

// Client가 있는 User만
predicates.add(criteriaBuilder.isNotNull(root.get("client")));

// 날짜 필터
if (startDate != null && endDate != null) {
predicates.add(criteriaBuilder.between(
root.get("lastLoginAt"),
startDateTime,
endDateTime
));
}

// 키워드 검색
if ("all".equals(range)) {
Join clientJoin = root.join("client");
Join companyJoin = clientJoin.join("company");

predicates.add(criteriaBuilder.or(
criteriaBuilder.like(root.get("email"), "%" + keyword + "%"),
criteriaBuilder.like(root.get("name"), "%" + keyword + "%"),
criteriaBuilder.like(companyJoin.get("name"), "%" + keyword + "%")
));
}

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

장점

  • ✅ ORM 일관성 유지
  • ✅ N+1 해결 (root.join() Fetch Join 효과)

단점

  • ❌ Java와 SQL 로직 혼재 → 가독성 저하
  • ❌ 쿼리 튜닝 어려움

테스트 결과

구분평균 응답시간(ms)최대(ms)처리량(TPS)
Basic39.9981.6755.64
Date Filter15.8644.0055.96
Search20.9977.3355.93
Complex Filter20.9575.0055.30
Total24.4583.67221.77

4단계) MyBatis (초기 버전)

 중첩 사용 -->

SELECT u.*, c.*, co.*
FROM user u
LEFT JOIN client c ON u.client_id = c.client_id
LEFT JOIN company co ON c.company_id = co.company_id
WHERE u.client_id IS NOT NULL




AND (
u.email LIKE CONCAT('%', #{keyword}, '%')
OR u.name LIKE CONCAT('%', #{keyword}, '%')
OR co.name LIKE CONCAT('%', #{keyword}, '%')
)


AND u.email LIKE CONCAT('%', #{keyword}, '%')





테스트 결과

구분평균 응답시간(ms)최대(ms)처리량(TPS)
Basic23.8547357.59
Date Filter23.399259.34
Search24.0410859.15
Complex Filter23.4611259.32
Total24.5647366.70

문제점

  • <choose> 중첩으로 매번 SQL 구문 다르게 생성
  • ❌ SQL 템플릿이 자주 변해 DB 캐시 비효율
  • ❌ 최대 응답시간 편차 큼 (473ms)

3. 최종 해결: MyBatis 최적화

핵심 개선 1: 동적 SQL 단순화

 ## 중첩 (SQL 캐시 비효율) -->
AND (u.email LIKE ... OR u.name LIKE ...)
...
AND u.email LIKE ...

AND (
(#{range} = 'all' AND (
u.email LIKE CONCAT('%', #{keyword}, '%')
OR u.name LIKE CONCAT('%', #{keyword}, '%')
OR u.department LIKE CONCAT('%', #{keyword}, '%')
))
OR (#{range} = 'email' AND u.email LIKE CONCAT('%', #{keyword}, '%'))
OR (#{range} = 'name' AND u.name LIKE CONCAT('%', #{keyword}, '%'))
OR (#{range} = 'department' AND u.department LIKE CONCAT('%', #{keyword}, '%'))
)

효과

  • ✅ 쿼리 템플릿 고정화 → SQL 캐시 효율 향상
  • ✅ 조건에 따라 SQL 구문이 바뀌지 않음

핵심 개선 2: COUNT 쿼리 최적화 (JOIN → EXISTS)

  SELECT COUNT(*)
FROM user u
LEFT JOIN client c ON u.client_id = c.client_id
LEFT JOIN company co ON c.company_id = co.company_id
WHERE ...


SELECT COUNT(*)
FROM user u
WHERE u.client_id IS NOT NULL


AND (
u.email LIKE CONCAT('%', #{keyword}, '%')
OR EXISTS (
SELECT 1 FROM client c
WHERE c.client_id = u.client_id
AND c.phone_number LIKE CONCAT('%', #{keyword}, '%')
)
OR EXISTS (
SELECT 1 FROM client c
JOIN company co ON c.company_id = co.company_id
WHERE c.client_id = u.client_id
AND co.name LIKE CONCAT('%', #{keyword}, '%')
)
)


JOIN vs EXISTS 비교

항목JOIN 후 COUNT(*)EXISTS (채택)
동작 방식모든 조인 결과 계산 → 임시 테이블 생성 → COUNT조건 만족 시 첫 행에서 검색 중지
불필요한 조인✅ 발생❌ 제거
조기 종료❌ 불가✅ 가능
성능느림빠름

4. 성능 측정 결과

최종 비교

구분JPA SpecMyBatis (초기)MyBatis (최종)개선율 (JPA 대비)
평균 응답시간24.45ms24.56ms15.51ms36.8% ↓
최대 응답시간83.67ms473ms54ms35% ↓
처리량 (TPS)221.7766.70273.3423% ↑

MyBatis 초기 대비 개선

항목BeforeAfter개선율
평균 응답시간24.56ms15.51ms36.8% ↓
최대 응답시간473ms54ms88.5% ↓
처리량 (TPS)66.7273.34309% ↑

5. 핵심 개선 포인트

1️⃣ 쿼리 구조 단순화

  • <choose> 중첩 제거 → OR 조건 기반 통합
  • SQL 템플릿 고정화 → DB 캐시 효율 향상

2️⃣ 불필요한 JOIN 제거

  • COUNT 쿼리를 EXISTS 서브쿼리로 변경
  • 조건 만족 시 첫 행에서 검색 중지

3️⃣ DB 부하 감소

  • 임시 테이블 생성 없이 COUNT 계산
  • 인덱스 활용 최적화

6. 하이브리드 ORM 전략

구분기술사용 사례
기본 CRUDJPA회원 등록/수정/삭제, 단순 조회
복잡한 검색MyBatis다중 조건 필터링, 통계 쿼리
장점-개발 생산성 ↑, 쿼리 성능 ↑

7. 결론

성과
  • JPA Specification 대비 평균 응답시간 36% 개선 (24.45ms → 15.51ms)
  • MyBatis 초기 대비 최대 응답시간 88.5% 감소 (473ms → 54ms)
  • 쿼리 구조 단순화 + EXISTS 패턴으로 DB 부하 감소
배운 점
  • ORM 한계 상황 인지: 복잡한 조회는 SQL 중심 접근이 효율적
  • 동적 SQL 최적화: 쿼리 템플릿 고정화의 중요성
  • EXISTS 서브쿼리 패턴: JOIN 없이 조건 확인 가능

8. 추가 고려사항

인덱스 설계

-- user 테이블
CREATE INDEX idx_user_client_id ON user(client_id);
CREATE INDEX idx_user_last_login_at ON user(last_login_at);
CREATE INDEX idx_user_email ON user(email);
CREATE INDEX idx_user_name ON user(name);

-- client 테이블
CREATE INDEX idx_client_company_id ON client(company_id);
CREATE INDEX idx_client_phone_number ON client(phone_number);

-- company 테이블
CREATE INDEX idx_company_name ON company(name);

실행 계획 분석

EXPLAIN SELECT COUNT(*)
FROM user u
WHERE u.client_id IS NOT NULL
AND (
u.email LIKE '%keyword%'
OR EXISTS (
SELECT 1 FROM client c
WHERE c.client_id = u.client_id
AND c.phone_number LIKE '%keyword%'
)
);

9. 참고 자료