Spring Data JPA의 Query Method와 @Query는 훌륭했지만, 실무의 거친 요구사항을 만나면 한계가 명확하게 드러납니다.
쇼핑몰의 상품 검색창을 생각해 봅시다.
사용자는 '상품명'만 검색할 수도 있고, '가격 범위'만 필터링할 수도 있으며, '카테고리'와 '브랜드'를 동시에 선택할 수도 있습니다.
즉, 조건이 있을 수도 있고 없을 수도 있는 '동적 쿼리(Dynamic Query)'가 필요합니다.
과거 MyBatis 시절에는 XML 파일에 <if test="name != null"> 같은 태그를 수십 개 도배하여 쿼리 문자열(String)을 조립했습니다. JPA @Query에서 문자열로 동적 쿼리를 짜다 보면 띄어쓰기 하나 잘못해서 서버가 켜진 뒤 고객이 검색 버튼을 누르는 순간 에러가 터집니다(런타임 에러).
자바 코드로 쿼리를 짜서, 컴파일 시점에 SQL 에러를 잡아내는 기술이 바로 QueryDSL입니다.
1. JPQL의 한계와 QueryDSL의 탄생
우선 @Query(JPQL)로 동적 쿼리를 짤 때의 지옥을 감상해 봅시다.
// 과거의 동적 쿼리 (MyBatis나 순수 JPQL 문자열 조립)
String jpql = "SELECT u FROM User u WHERE 1=1";
if (name != null) {
jpql += " AND u.name = '" + name + "'"; // 띄어쓰기 실수하면 런타임 에러 폭발!
}
if (age != null) {
jpql += " AND u.age >= " + age;
}
문자열(String)은 컴퓨터 입장에서 그냥 글자일 뿐입니다. u.namee라고 오타를 내도 자바 컴파일러는 알지 못합니다. 개발자가 가장 사랑하는 에러는 '컴파일 에러(빨간 줄)'이고, 가장 저주하는 에러는 '런타임 에러'입니다. QueryDSL은 쿼리를 문자열이 아닌 '자바 코드(메서드)'로 짜게 만들어 주어, 오타가 나면 즉시 빨간 줄을 그어줍니다.
2. Q-클래스(Q-Class)의 정체와 생성 원리
QueryDSL을 쓰려면 가장 먼저 Q-Class라는 것을 만들어야 합니다. 우리가 만든 User.java 엔티티를 QueryDSL 플러그인(APT)이 싹 긁어다가 QUser.java라는 붕어빵을 구워냅니다. 이 Q-클래스 안에는 테이블의 컬럼들이 자바의 필드로 예쁘게 매핑되어 있습니다.
Gradle 빌드 설정에서 annotationProcessor "com.querydsl:querydsl-apt"를 추가하고 빌드를 돌리면 build/generated 폴더에 QUser가 생성됩니다.
🪄 3. 자바 코드로 쿼리를 짜는 경이로운 경험
세팅이 끝났다면 이제 JPAQueryFactory를 사용하여 실제 쿼리를 작성해 봅니다. 아까 보았던 끔찍한 문자열 조립 코드가 얼마나 아름답게 변하는지 확인해 보세요.
import static com.example.domain.QUser.user; // Q클래스를 static import 하면 코드가 극도로 짧아집니다.
@Repository
@RequiredArgsConstructor
public class UserRepositoryCustomImpl implements UserRepositoryCustom {
private final JPAQueryFactory queryFactory; // QueryDSL의 핵심 엔진
public List<User> searchDynamicUsers(String nameCond, Integer ageCond) {
return queryFactory
.selectFrom(user) // SELECT * FROM user
.where(
nameEq(nameCond), // WHERE 조건들을 쉼표(,)로 넘기면 자동으로 AND 결합이 됩니다.
ageGoe(ageCond)
)
.orderBy(user.createdAt.desc()) // 정렬도 완벽한 자동완성(IntelliSense) 지원!
.fetch();
}
// 쉼표(,) AND 결합의 마법: 메서드가 null을 반환하면 QueryDSL이 알아서 조건에서 제외해 줍니다! (동적 쿼리의 핵심)
private BooleanExpression nameEq(String nameCond) {
return nameCond != null ? user.name.eq(nameCond) : null;
}
private BooleanExpression ageGoe(Integer ageCond) {
return ageCond != null ? user.age.goe(ageCond) : null; // goe = Greater or Equal (>=)
}
}
4. 실무 도입의 절대적 장점 요약
- 타입 안정성(Type-safe):
user.namme라고 오타를 내는 순간 컴파일러가 빨간 줄을 쫙 긋고 빌드를 막아버립니다. 운영 서버에서 쿼리 에러가 터질 확률이 0%에 수렴합니다. - 자동 완성(Auto-complete):
user.만 쳐도 IDE가 테이블의 컬럼 목록을 쫘르륵 보여줍니다. DB 스키마를 외울 필요가 없습니다. - 동적 쿼리 처리의 예술성:
BooleanExpression을 메서드로 뽑아내어 조합하면(isServiceable() = isAdult().and(isNotBanned())), 조건문 자체가 하나의 완벽한 비즈니스 문장으로 읽힙니다.
5. 마무리 및 주요 발전 방향
지금까지 오타와 띄어쓰기의 지옥이었던 JPQL 문자열 조립을 버리고, 완벽한 자바 컴파일러의 보호 아래에서 예술적이고 우아하게 동적 쿼리를 찍어내는 QueryDSL의 웅장한 아키텍처에 대해 다루어 보았습니다. QueryDSL을 자유자재로 다루는 순간, 여러분은 대한민국 백엔드 상위 10%의 쿼리 작성 능력을 갖추게 됩니다.
이로써 애플리케이션의 코딩(Controller, Service, Repository, QueryDSL) 단계는 실무 레벨로 완성되었습니다. 그런데 코드를 아무리 잘 짜도, 데이터베이스(DB)로 향하는 '고속도로'가 막혀있다면 시스템은 마비됩니다. 수천 명의 유저가 동시에 접속했을 때 DB 연결 통로가 모자라 요청이 튕겨 나가는 사태를 막기 위해 우리는 '커넥션 풀(Connection Pool)'이라는 방파제를 세워야 합니다. 본 설명에서는 "전 세계 스프링 부트가 기본으로 채택한 극강의 성능 튜닝 포인트!" 데이터베이스 커넥션 풀(HikariCP)의 원리 파악과 장애를 예방하는 완벽한 실무 튜닝 공식에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!
여러분이 인기 아이돌 콘서트 티켓팅 사이트의 백엔드 개발자라고 상상해 봅시다. 밤 8시 정각, 1만 명의 유저가 동시에 '결제하기' 버튼을 누릅니다. 스프링 부트(Spring Boot) 서버는 무사히 요청을 받았지만, 데이터베이스(DB)에 접속하려다 보니 심각한 문제가 발생합니다. 애플리케이션이 DB와 TCP/IP 통신을 맺고 로그인하는 과정은 엄청나게 무겁고 긴 시간이 소요됩니다. 1만 명이 접속할 때마다 매번 이 연결(Connection)을 새로 맺는다면 DB 서버는 10초도 안 돼서 뻗어버릴 것입니다. 이 끔찍한 병목(Bottleneck) 현상을 해결하기 위해 "미리 DB와의 연결(통로) 100개를 맺어놓고, 유저가 올 때마다 빌려주고 다시 반납받는 대여소"를 만들었는데, 이것이 바로 커넥션 풀(Connection Pool)입니다. 그리고 스프링 부트가 전 세계 수많은 풀 중에서 최고로 인정하여 기본(Default)으로 탑재한 전설적인 라이브러리가 바로 HikariCP(히카리CP)입니다.
.
1. HikariCP는 왜 세상에서 가장 빠를까?
과거에는 Tomcat-JDBC, c3p0, Apache DBCP 등 다양한 커넥션 풀이 있었습니다. 하지만 HikariCP가 등장한 이후 모든 벤치마크 테스트에서 다른 풀들을 말 그대로 '압살'해버리며 천하통일을 이루었습니다. (스프링 부트 2.0부터 기본 탑재)
- 극단적인 코드 경량화: 히카리CP의 핵심 코드는 몇 천 줄에 불과할 정도로 가볍습니다. 쓸데없는 오버헤드가 없습니다.
- ConcurrentBag 매커니즘: 멀티 쓰레드 환경에서 여러 쓰레드가 락(Lock)을 걸고 커넥션을 차지하려 싸우는 과정(Context Switching)을 최소화하는 독자적인 알고리즘을 사용합니다.
- 바이트코드 수준의 최적화: 자바 코드가 컴파일된 바이트코드를 극한으로 깎아서 최적화하여 0.001초의 속도라도 더 끌어올렸습니다.
2. 실무 장애를 막는 3대 핵심 튜닝 옵션
히카리CP는 기본값만 써도 훌륭하지만, 대용량 트래픽을 받는 실무 서버라면 application.yml에서 튜닝을 해야 합니다. 가장 중요한 3가지 옵션을 살펴봅시다.
1. maximum-pool-size (최대 풀 크기)
가장 중요한 옵션입니다. 풀에 유지할 커넥션의 최대 개수입니다. 기본값은 10입니다.
spring:
datasource:
hikari:
maximum-pool-size: 50 # 동시에 50개의 트랜잭션 쿼리를 처리할 수 있음
"그럼 크게 1000개 주면 무조건 빠른 거 아니야?" 절대 아닙니다. DB 서버의 CPU 코어 수와 메모리 한계가 있기 때문에, 커넥션이 너무 많으면 서로 CPU를 차지하려 싸우느라 오히려 성능이 수직으로 추락합니다(Thrashing). 실무의 황금 공식은 [DB 서버의 CPU 코어 수 * 2 + 디스크 여유분(보통 1~2)] 언저리에서 성능 테스트(JMeter)를 통해 최적점을 찾는 것입니다.
2. connection-timeout (커넥션 획득 대기 시간)
풀에 있는 50개의 커넥션을 유저들이 전부 빌려가서 텅 비어있을 때, 유저가 들어오면 기다려야 합니다. 이때 "얼마나 기다리다 포기(Exception)할 것인가?"를 정합니다. 기본값은 30000ms(30초)입니다.
spring:
datasource:
hikari:
connection-timeout: 3000 # 3초 안에 커넥션 못 빌리면 쿨하게 에러 던져라!
실무에서는 30초 대기는 너무 깁니다. 유저가 하얀 화면을 30초나 보고 있게 만들 바엔, 3초 만에 "현재 접속자가 많아 지연되고 있습니다"라는 에러 창을 띄워주는 것이 WAS(스프링) 서버의 쓰레드(Thread) 고갈을 막는 현명한 방법입니다.
3. max-lifetime (커넥션 최대 수명)
커넥션이 풀에 존재할 수 있는 최대 수명입니다. 기본값은 30분(1800000ms)입니다. DB 서버나 방화벽(Firewall)이 오랫동안 통신이 없는 커넥션을 강제로 끊어버리는 경우가 잦습니다. 이를 막기 위해 인프라의 네트워크 타임아웃 시간보다 2~3분 정도 짧게 설정하는 것이 원칙입니다.
3. 데드락(Deadlock)의 공포: Pool Size 튜닝의 함정
만약 한 번의 API 요청(쓰레드 하나) 안에서 커넥션을 2개 이상 동시에 요구하는 로직이 있다면 치명적인 커넥션 데드락(Deadlock) 장애가 발생할 수 있습니다.
예를 들어 풀 사이즈가 10인데, 쓰레드 10개가 동시에 들어와서 각각 1개씩 커넥션을 물고 안 놔준 채 "로직 처리를 위해 1개 더 줘!"라고 외치면, 풀은 이미 비어있으므로 모든 쓰레드가 영원히 대기 상태에 빠져 서버가 멈춰버립니다. 따라서 풀 사이즈를 정할 때는 단일 로직에서 동시에 요구하는 커넥션 수까지 고려하여 넉넉하게 산정해야 합니다.
4. 마무리 및 주요 발전 방향
지금까지 느려 터진 데이터베이스 물리적 연결의 병목을 뚫어버리고, 대용량 트래픽 앞에서도 서버가 뻗지 않고 든든하게 버텨주게 하는 스프링 부트 인프라의 척추, HikariCP 커넥션 풀의 동작 원리와 실무 튜닝 공식에 대해 다루어 보았습니다. 이제 여러분의 서버는 효율적인 통로 관리를 통해 수많은 유저를 맞이할 준비가 되었습니다.
하지만, 아무리 커넥션 풀을 빵빵하게 늘려놓아도 근본적인 문제가 남습니다. "쇼핑몰 메인 페이지의 상품 목록"처럼 1초에 1,000명씩 조회하지만 데이터는 하루에 한 번만 바뀌는 데이터를 매번 느린 DB(디스크)까지 가서 조회해 와야 할까요? 이건 엄청난 리소스 낭비입니다. 디스크까지 안 가고 메모리 선에서 요청을 끊어버리는 압도적인 스피드의 기술이 필요합니다.
다음 포스트에서는 "DB의 부하를 0으로 만들고 응답 속도를 10배 끌어올린다!" 실무 대용량 트래픽 처리의 만병통치약, Spring Boot와 Redis를 활용한 글로벌 캐싱(Caching) 아키텍처 전략에 대해 다루겠습니다.
'Framework > Spring Boot' 카테고리의 다른 글
| [Spring Boot] Spring Security - JSON Web Token 구현 (0) | 2026.10.12 |
|---|---|
| [Spring Boot] Redis 캐싱(Caching)을 이용하여 응답 속도 단축하기 (0) | 2026.10.08 |
| [Spring Boot] Spring Data JPA 쿼리메서드(QueryMethod)로 검색과 페이징을 구현하기 (0) | 2026.10.07 |
| [Spring Boot] JPAHibernate 기초 객체와 관계형 DB의 패러다임 불일치를 부수다 (0) | 2026.10.07 |
| [Spring Boot] 의존성 주입DI과 IoC 컨테이너 객체의 생성 소멸 자동화 (0) | 2026.10.06 |