1. 시작하며
최근 회사에서 Quartz 스케줄링을 통해 서버 디렉토리에 저장된 파일 상태와 데이터베이스의 기존 데이터를 비교하여 주기적으로 업데이트하는 작업을 개발했습니다.
하지만 초기 구현 방식은 트랜잭션이 여러 번 발생하면서 성능 저하가 심각하게 발생했습니다. 이에 Bulk Update를 도입하여 성능을 대폭 개선한 사례를 공유하고자 합니다.
회사 해당 프로젝트 코드는 반입이 금지되어 있으니 이 글에서는 여러 게시글 데이터를 한 번에 업데이트하는 방법과 함께 성능 최적화 및 Null 처리 시 주의사항을 설명합니다.
2. 기존 방식의 문제점
기존 코드는 애플리케이션단 코드에서 개별 데이터를 업데이트하는 쿼리를 반복적으로 실행하면서 트랜잭션이 여러 번 발생하는 문제가 있었습니다.
2.1 기존 코드
1) 기존 Mybatis Mapper XML
PostMapper.xml
<update id="updatePostStatus">
UPDATE POSTS
SET STATUS = #{status}, UPDATE_TIME = #{updateTime}
WHERE POST_ID = #{postId}
</update>
2) 기존 Mapper 인터페이스
PostMapper.java
public interface PostMapper {
// 개별 게시물 상태 업데이트
int updatePostStatus(@Param("postId") Long postId, @Param("status") String status, @Param("updateTime") String updateTime);
}
3) 기존 Repository 구현 클래스
PostRepository.java
@Repository
@RequiredArgsConstructor
public class PostRepository implements PostPort {
private final PostMapper postMapper;
@Override
public int updatePostStatus(PostRequest request) {
return postMapper.updatePostStatus(request.postId(), request.status(), request.updateTime());
}
}
4) Request DTO (데이터 전송 객체)
PostUpdateRequest.java
@Builder
public record PostUpdateRequest(
long postId,
String status,
LocalDateTime updateTime
) {
}
5) 기존 서비스 단 구현 클래스 (트랜잭션 과다 발생)
FilePollingServiceImpl.java
@Slf4j
@Service
@RequiredArgsConstructor
public class FilePollingServiceImpl implements FilePollingService {
private final PostPort postPort;
@Override
public void pollFilesAndUpdateStatus() {
// 파일 상태 확인하기 로직 생략
// DB 데이터와 파일 상태를 비교 로직 생략 (여기서 postRequests 생성)
// 파일 상태 DB 업데이트
int updatedRows = 0;
for (PostUpdateRequest request : postRequests) {
updatedRows += postPort.updatePostStatus(request);
}
log.info("업데이트 된 데이터 개수: {}", updatedRows);
}
}
2.2 기존 방식의 문제점
- 트랜잭션 과다 발생
for문에서 데이터를 하나씩 업데이트하므로 트랜잭션이 데이터 수만큼 발생합니다.- 이는 트랜잭션 관리 비용과 데이터베이스 부하를 증가시킵니다.
- 성능 저하
- 네트워크 I/O와 쿼리 실행 비용이 반복적으로 발생하므로 데이터가 많아질수록 성능이 저하됩니다.
- 비효율적인 코드
for문을 사용하여 쿼리를 호출하는 방식은 코드가 비효율적이고 유지보수하기 어렵습니다.
3. 개선된 방식: Bulk Update
문제를 해결하기 위해 MyBatis의 foreach 문을 활용한 Bulk Update 방식을 도입했습니다.
여러 데이터를 한 번에 업데이트하여 트랜잭션 발생 횟수를 줄이고 성능을 개선합니다.
3.1 개선된 코드
1) 개선된 Mybatis Mapper XML
PostMapper.xml
<update id="batchUpdatePostStatuses">
UPDATE POSTS
SET
STATUS = CASE
<foreach collection="postRequests" item="request" separator=" ">
WHEN id = #{request.postId} THEN #{request.status}
</foreach>
ELSE STATUS
END,
UPDATE_TIME = CASE
<foreach collection="postRequests" item="request" separator=" ">
WHEN POST_ID = #{request.postId} THEN #{request.updateTime}
</foreach>
ELSE UPDATE_TIME
END
WHERE POST_ID IN
<foreach collection="postRequests" item="request" open="(" separator="," close=")">
#{request.postId}
</foreach>
</update>
- 단일 SQL 쿼리로 여러 데이터를 업데이트합니다.
CASE문과WHERE IN절을 사용하여 Bulk Update를 수행합니다.
2) 개선된 Mapper 인터페이스
PostMapper.java
@Mapper
public interface PostMapper {
// Batch Update
int batchUpdatePostStatuses(@Param("postRequests") List<PostUpdateRequest> postRequests);
}
3) 개선된 Repository 구현 클래스
PostMapper.java
@Repository
@RequiredArgsConstructor
public class PostRepository implements PostPort {
private final PostMapper postMapper;
@Override
public int batchUpdatePostStatuses(List<PostUpdateRequest> postRequests) {
if (postRequests == null || postRequests.isEmpty()) {
return 0; // 빈 리스트인 경우 업데이트 생략
}
return postMapper.updatePostStatus(postRequests);
}
}
4) 개선된 서비스 단 구현 로직 (하나의 트랜잭션으로 처리)
FilePollingServiceImpl.java
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class FilePollingServiceImpl implements FilePollingService {
private final PostPort postPort;
@Override
public void pollFilesAndUpdateStatus() {
// 파일 상태 확인하기 로직 생략
// DB 데이터와 파일 상태를 비교 로직 생략 (여기서 request 생성)
// 파일 상태 DB 업데이트 (Bulk Update)
int updatedRows = postPort.batchUpdatePostStatuses(postRequests);
log.info("업데이트 된 데이터 개수: {}", updatedRows);
}
}
3.2 개선된 방식의 특징과 장점
- 단일 SQL 처리
- 여러 데이터를 한 번의 SQL로 처리하므로 성능이 크게 개선됩니다.
- 트랜잭션 최소화
- 단일 SQL이 하나의 트랜잭션으로 처리되어 트랜잭션 오버헤드가 크게 감소합니다.
- 네트워크 I/O 감소
- 네트워크 통신 횟수가 줄어들어 전송 비용이 절감됩니다.
- 코드 가독성 및 유지보수성 향상
- 비효율적인
for문이 제거되어 코드가 더 간결하고 직관적입니다.
- 비효율적인
4. 참고: Null 처리 시 주의사항
MyBatis의 foreach 문에서 Null 값이나 빈 리스트를 처리하지 않으면 쿼리가 비정상적으로 실행될 수 있습니다.
오류 예시
ORA-17104: SQL 문은 비어 있거나 널일 수 없습니다.
4.1 해결 방법
Null 체크 및 빈 리스트 처리를 철저히 해줘야 합니다.
if (postRequests == null || postRequests.isEmpty()) {
// null 또는 빈 리스트 처리 로직
}
5. 개선 전 후 성능 비교
| 항목 | 기존 방식 | 개선된 방식 |
|---|---|---|
| 쿼리 실행 횟수 | 데이터 수만큼 실행 | 1회 실행 |
| 트랜잭션 횟수 | 데이터 수만큼 발생 | 1회 발생 |
| 네트워크 비용 | 과도한 네트워크 I/O | 최소화 |
| 코드 가독성 | 비효율적, 유지보수 어려움 | 간결하고 직관적 |
| 성능 | 느림 | 대폭 개선 |
6. 깨달은 점
- 대량 데이터 업데이트는 Bulk Update가 필수적이다.
- 개별 트랜잭션 방식은 데이터가 많아질수록 성능 저하와 트랜잭션 오버헤드를 초래합니다.
- Bulk Update를 통해 하나의 SQL 쿼리와 단일 트랜잭션으로 여러 데이터를 처리하면 성능 최적화와 데이터베이스 부하 감소를 동시에 달성할 수 있습니다.
- 이는 네트워크 I/O 비용을 줄여 전체 시스템의 응답 시간을 크게 개선합니다.
- MyBatis
foreach사용 시 Null 및 빈 리스트 처리를 철저히 해야 한다.foreach문에서 입력값이null이거나 비어 있을 경우, 쿼리 생성이 비정상적으로 동작하며 SQL 문법 오류나 DB 예외를 발생시킬 수 있습니다.- 이러한 문제를 방지하기 위해 Null 체크와 빈 리스트 처리를 서비스나 레포지토리 단에서 사전에 검증하는 것이 중요합니다.
- 이는 단순하지만, 시스템의 안정성을 확보하는 필수적인 조치입니다.
- 단순 반복 호출 대신 최적화된 접근 방식을 선택해야 한다.
- 데이터베이스와의 반복적인 호출은 생각보다 큰 네트워크 I/O 비용과 DB 리소스 낭비를 가져옵니다.
- Bulk Update와 같은 최적화된 접근 방식을 적용하면 시스템의 효율성이 높아지고 확장성이 향상됩니다.
- 성능 최적화는 유지보수성과도 직결된다.
for문을 통한 개별 쿼리 실행은 코드의 가독성과 유지보수성을 떨어뜨립니다.- Batch Update 도입으로 코드를 간결하게 작성하면 더 읽기 쉬운 구조를 만들 수 있으며, 유지보수 비용도 줄어듭니다.