사내 기기 재고 관리를 하는 프로젝트를 하게 되었다. 보통 재고가 들어오면 몇 천 단위로 들어오게 되는데 그 때 어떻게 신속하게 메모리를 사용하고 db 호출 횟수를 어떻게 처리할 것인지에 대한 고민이 생겼다.
고민 1. for문을 돌 때 마다 N+1 문제가 발생한다. for 문을 돌 때마다 성능이 선형적으로 저하될 것이다.
해결 1. QueryDSL 의 벌크 연산 기능을 활용해서 한 번의 쿼리로 대량 데이터를 처리 가능하다.
JPAUpdateClause updateClause = phrJPAQueryFactory.update(bandManagement)
.where(whereBandsLabelIn(deviceUpdateRequestDto.getBands()));
고민 2. update, delete 연산은 JPA 의 EntityManager 를 직접 사용하지 않고, JPQL 을 생성해 db 에 직접 쿼리를 실행한다. 영속성 컨텍스트를 우회하므로 1차 캐시와 동기화 문제가 발생 할 수도 있다.
해결 2. 벌크 연산 후 flush() 와 clear() 로 영속성 컨텍스트를 초기화해야 한다. 메모리 누수를 방지할 수 있다.
더보기
영속성 컨텍스트을 물통이라 생각해보자.
1. 큰 통에 물을 담고 있다.(영속성 컨텍스트)
2. 벌크 연산으로 db에 직접 물을 부었다. (db 직접 업데이트)
3. 하지만 통안의 물은 그대로이다. (영속성 컨텍스트)
4. 이 상태에서 또 작업을 하면.. 💥
updateClause.execute(); // DB에 직접 변경!
phrJPAEntityManager.flush(); // 남은 변경사항 강제 반영
phrJPAEntityManager.clear(); // 청소!
고민 3. db 왕복 횟수를 최소화하여 네트쿼크 오버헤드를 감소시킬 필요가 있다.
해결 3. 배치 전략으로 최적화를 한다.
if(logs.size() % 100 == 0) { // 100개씩 batch
bandManagementLogRepository.saveAll(logs);
phrJPAEntityManager.flush();
phrJPAEntityManager.clear();
}
전체 코드는 아래에 같다.
@Transactional(value = "phrDataBaseTransactionManager")
public void bulkUpdate(Region region, DeviceUpdateRequestDto deviceUpdateRequestDto, Integer adminId) {
JPAUpdateClause updateClause = phrJPAQueryFactory.update(bandManagement)
.where(whereBandsLabelIn(deviceUpdateRequestDto.getBands()));
if (region != null) {
updateClause.set(bandManagement.region, region.getRegionCode());
}
if (deviceUpdateRequestDto.getBandStatus() != null) {
updateClause.set(bandManagement.bandStatus, deviceUpdateRequestDto.getBandStatus());
}
if (deviceUpdateRequestDto.getStockInDate() != null) {
updateClause.set(bandManagement.stockInDate, deviceUpdateRequestDto.getStockInDate());
}
if (deviceUpdateRequestDto.getReceivedDate() != null) {
updateClause.set(bandManagement.receivedDate, deviceUpdateRequestDto.getReceivedDate());
}
if (deviceUpdateRequestDto.getReason() != null && !deviceUpdateRequestDto.getReason().isEmpty()) {
updateClause.set(bandManagement.reason, deviceUpdateRequestDto.getReason());
}
updateClause.set(bandManagement.adminId, adminId);
updateClause.execute();
phrJPAEntityManager.flush();
phrJPAEntityManager.clear();
bulkInsertLog(region, deviceUpdateRequestDto, adminId);
}
@Transactional(value = "phrDataBaseTransactionManager")
public void bulkInsertLog(Region region, DeviceUpdateRequestDto deviceUpdateRequestDto, Integer adminId) {
List<BandManagementLog> logs = new ArrayList<>();
List<BandManagement> updatedList = phrJPAQueryFactory.selectFrom(bandManagement)
.where(whereBandsLabelIn(deviceUpdateRequestDto.getBands()))
.fetch();
for (BandManagement management : updatedList) {
try {
BandManagementLog log = BandManagementLog.builder()
.bandManagement(management)
.adminId(adminId)
.regDt(LocalDateTime.now())
.bandStatus(deviceUpdateRequestDto.getBandStatus())
.reason(deviceUpdateRequestDto.getReason())
.stockInDate(deviceUpdateRequestDto.getStockInDate())
.receivedDate(deviceUpdateRequestDto.getReceivedDate())
.region(String.valueOf(region))
.build();
logs.add(log);
if(logs.size() % 100 == 0) { // 100개씩 batch
bandManagementLogRepository.saveAll(logs);
phrJPAEntityManager.flush();
phrJPAEntityManager.clear();
}
} catch (Exception e) {
log.error("밴드 ID {} 로그 생성 실패: {}", management.getManagementSeqNo(), e.getMessage());
}
}
// 남은 로그 저장
if (!logs.isEmpty()) {
bandManagementLogRepository.saveAll(logs);
phrJPAEntityManager.flush();
phrJPAEntityManager.clear();
}
}
개선점
지금은 하나의 큰 트랜잭션이지만 적절한 크기로 분할하고 실패 시 재시도 메카니즘 또한 추후 필요할 것 같다.
'JPA' 카테고리의 다른 글
[QueryDsl] 동적 접근이란 무엇일까 (0) | 2024.10.25 |
---|---|
Open Session in View (0) | 2024.04.25 |
[Relation] mappedBy (1) | 2023.07.27 |