QueryDSL을 이용한 Batch Update 성능 개선
JPA를 사용하다 보면 대량의 데이터를 수정해야 하는 상황에서 Dirty Checking 방식의 성능 한계에 부딪히게 됩니다. 이번 포스팅에서는 JPA Dirty Checking의 성능 이슈를 살펴보고, QueryDSL의 SQLQueryFactory를 활용한 Batch Update로 성능을 획기적으로 개선하는 방법을 소개합니다.
개요#
대량의 데이터를 수정해야 할 때, 일반적으로 JPA의 Dirty Checking 방식을 사용합니다. 엔티티를 조회하여 영속성 컨텍스트에 올린 뒤 필드를 변경하면 트랜잭션 커밋 시점에 변경 감지가 일어나 UPDATE 쿼리가 실행되는 방식입니다. 하지만 데이터의 양이 늘어날수록 이 방식의 처리 속도는 급격히 느려질 수 있습니다. 엔티티 수만큼 개별적인 UPDATE 쿼리가 발생하기 때문입니다.
이전 포스팅(QueryDSL을 이용한 Batch Insert 성능 개선)에서 Insert 성능 개선 방법을 소개한 적이 있습니다. Update도 동일한 접근으로 해결할 수 있습니다.
이미 프로젝트에서 JPA와 QueryDSL을 사용하고 있다면, 추가적인 라이브러리 도입 없이 QueryDSL-SQL 모듈을 활용하여 Type-Safe하게 Batch Update를 구현할 수 있습니다. 이번 포스팅에서는 그 방법을 소개합니다.
JPA Dirty Checking의 성능 이슈#
JPA의 일반적인 Update 패턴은 엔티티를 조회하여 영속성 컨텍스트에 올린 뒤, 필드를 변경하고 트랜잭션이 커밋될 때 변경 감지(Dirty Checking)를 통해 UPDATE 쿼리를 실행하는 방식입니다.
1 |
|
위 코드는 사용하기 편리하지만, 1,000개의 데이터를 수정하면 1,000번의 UPDATE 쿼리가 데이터베이스로 전송됩니다. 여기에 findAllById로 인한 SELECT 쿼리까지 더하면, 대량 수정 작업에서는 심각한 성능 저하의 원인이 됩니다.
QueryDSL Batch Update 구현#
QueryDSL-SQL 모듈을 사용하면 JPA 엔티티가 아닌 JDBC 레벨에서 직접 SQL을 구성하여 실행할 수 있습니다. 이를 통해 addBatch 기능을 활용한 Bulk Update를 구현할 수 있습니다.
구현 코드 예시#
SQLQueryFactory를 사용하여 Batch Update를 구현하는 방법은 다음과 같습니다.
1 |
|
코드 설명
- RelationalPathBase: SQL 쿼리 작성을 위해 대상 테이블의 메타데이터를 정의합니다.
SQLQueryFactory는RelationalPath타입을 요구하기 때문에, JPA 엔티티 기반의QWriter(EntityPathBase)를 테이블 참조로 직접 사용할 수 없어 별도로 정의합니다. - QWriter.writer.*: 컬럼 참조에는 기존에 생성된 Q클래스의 path를 그대로 활용합니다.
- addBatch(): 루프를 돌며 데이터를 즉시 UPDATE 하지 않고, JDBC의 Batch 기능을 활용하기 위해 메모리에 쿼리 파라미터들을 쌓아둡니다.
- execute(): 쌓여있는 Batch 쿼리를 데이터베이스로 한 번에 전송하여 실행합니다.
Insert와의 구조적 차이#
Batch Insert와 Batch Update는 동일한 addBatch + execute() 패턴을 사용하지만, 중요한 구조적 차이가 있습니다.
| 항목 | Batch Insert | Batch Update |
|---|---|---|
where 조건 |
불필요 | 필수 |
| 테이블 참조 | RelationalPathBase |
RelationalPathBase (동일) |
| 컬럼 참조 | QWriter.writer.* |
QWriter.writer.* (동일) |
| id 처리 | 없음 | requireNotNull 필요 |
가장 중요한 차이는 where 조건입니다. where 없이 addBatch()를 호출하면 해당 UPDATE는 테이블 전체를 대상으로 실행되어 의도치 않은 전체 UPDATE가 발생할 수 있습니다. 반드시 where(QWriter.writer.id.eq(id))와 같이 대상 row를 특정해야 합니다.
또한 EntityAuditing을 통해 상속받는 id 필드는 Long? (nullable) 타입이므로, requireNotNull로 null을 방어한 뒤 사용해야 컴파일 타입 안전성이 보장됩니다.
Batch 동작 검증 방법#
addBatch를 사용하면 SQL 내용 자체는 dirty checking과 동일하게 UPDATE writer SET ... WHERE id = ? 형태입니다. 쿼리 내용만으로는 실제로 batch가 동작하는지 구분하기 어렵습니다.
실제 전송 방식의 차이는 JDBC URL에 profileSQL=true를 추가하면 로그로 확인할 수 있습니다.
1 | jdbc:mysql://localhost:3306/mydb?rewriteBatchedStatements=true&logger=Slf4JLogger&profileSQL=true |
케이스 1 — dirty checking (N번 통신)
1 | [QUERY] update writer set active=1,email='email-2',name='updated'... where id=2 |
executeUpdate가 건마다 호출되어 [QUERY] + [FETCH] 쌍이 건수만큼 반복됩니다. 10건이면 DB 서버로 10번 왕복합니다.
케이스 2 — addBatch (1번 통신)
1 | [QUERY] update writer |
[QUERY] 로그가 1개만 출력됩니다. 세미콜론으로 구분된 모든 쿼리가 하나의 패킷으로 전송되며, 28건이든 10,000건이든 DB 서버로 1번만 왕복합니다.
구분 포인트 요약
| 구분 기준 | dirty checking | addBatch |
|---|---|---|
| 메서드명 | executeUpdate |
executeBatch ([QUERY] 수로 판단) |
[QUERY] 로그 수 |
N개 | 1개 |
[FETCH] 존재 |
건마다 존재 | 없음 |
| 쿼리 형태 | 쿼리 1개씩 | ;로 이어진 멀티 쿼리 |
profileSQL=true만 붙이면 로그 줄 수와 메서드명만으로 의도한 대로 batch가 동작하고 있는지 즉시 확인할 수 있습니다.
성능 비교#
성능 측정 코드#
정확한 성능 측정을 위해 JPA Dirty Checking 방식과 QueryDSL addBatch의 실행 시간을 각각 측정했습니다.
1 |
|
측정 방식 설명
- 반복 측정: 각 데이터 구간(100건 ~ 10,000건)마다 총 5회 반복하여 측정했습니다.
- Warm-up 고려: 테스트 실행 시 첫 번째 회차는 결과에서 제외했습니다. 커넥션 풀 초기화 등 초기 비용이 포함되어 결과가 왜곡되는 것을 방지하기 위함입니다.
- 평균값 산출: 첫 회차를 제외한 나머지 4회의 실행 시간을 합산하여 평균값을 산출했습니다.
성능 측정 결과#
JPA Dirty Checking과 QueryDSL addBatch를 사용했을 때의 성능 차이를 비교한 결과입니다.
| rows | dirty checking (ms) | add batch (ms) | 성능 개선율 |
|---|---|---|---|
| 100 | 265.5 | 28.5 | 89.3% |
| 200 | 368.5 | 45.5 | 87.7% |
| 500 | 808.75 | 103.5 | 87.2% |
| 1,000 | 1,647.25 | 191.5 | 88.4% |
| 2,000 | 3,315.0 | 392.0 | 88.2% |
| 5,000 | 8,593.5 | 928.25 | 89.2% |
| 10,000 | 16,530.75 | 2,063.0 | 87.5% |
- bar (dirty checking): JPA
save(writer)를 루프에서 건별 호출 — UPDATE N번 개별 전송 - line (add batch): QueryDSL SQLQueryFactory의 addBatch — N개의 UPDATE를 1번의 네트워크 왕복으로 전송
참고: 이 측정은 애플리케이션 서버와 데이터베이스가 **동일한 로컬 환경(loopback)**에서 수행된 결과입니다. Loopback 통신은 실제 네트워크 대비 레이턴시가 거의 없는 이상적인 조건임에도 불구하고 이 정도의 성능 차이가 발생합니다. 실제 운영 환경처럼 애플리케이션 서버와 DB 서버가 별도의 네트워크에 위치한다면, 건별로 전송하는 dirty checking 방식은 네트워크 왕복 비용이 쿼리 수만큼 누적되어 성능 차이가 훨씬 더 크게 벌어질 수 있습니다.
결론#
대량의 데이터를 수정해야 하는 배치성 작업에서는 JPA의 Dirty Checking 방식보다 JDBC Batch Update를 사용하는 것이 성능 측면에서 훨씬 유리합니다.
Insert와 마찬가지로, 오직 Batch Update 성능 개선만을 위해 별도의 라이브러리를 도입하는 것은 프로젝트의 복잡도를 높일 수 있습니다. 이미 JPA와 QueryDSL을 사용 중인 환경이라면, QueryDSL-SQL을 활용하는 것이 추가적인 학습 곡선이나 설정의 번거로움 없이 Type-Safe하게 성능을 극대화할 수 있는 가장 효율적인 대안입니다.
Insert와 Update 모두 동일한 addBatch + execute() 패턴을 사용하지만, Update에서는 반드시 where 조건으로 대상 row를 특정해야 한다는 점을 기억하세요.