본문 바로가기
JPA

[QueryDsl] 대량으로 수정하기

by 슈슈슉민 2025. 4. 8.

 

사내 기기 재고 관리를 하는 프로젝트를 하게 되었다. 보통 재고가 들어오면 몇 천 단위로 들어오게 되는데 그 때 어떻게 신속하게 메모리를 사용하고 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