본문 바로가기

개발공부/Java(JPA)

[JPA] JPA save() 반복 호출로 DB 커넥션 과부하 해결

UI 버전 사본 만들기 ( 기존데이터 복사 ) 할때 RDS 의 DB CPU가 99.98%로 올라가는 이슈가 생겼어.

중첩된 for문과 save()가 해당 이슈의 원인이라 파악.

이슈가 된 코드와 어떻게 변경하면 좋을지 알아보자

 

이슈 코드 ( 중첩 for문 구조 )

List<CtgryAccnt> accounts = ctgryAccntRepository.findAllByMainAccntNo( oldMainAccountNo );
if( CollectionUtils.isNotEmpty(accounts) ) {
    for (CtgryAccnt account : accounts) {
        Long oldAccountNo = account.getCtgryAccntNo();

        CtgryAccnt newAccount = new CtgryAccnt();
        newAccount.setMainAccntNo( newMainAccountNo );
        newAccount.setCtgryAccntTy( account.getCtgryAccntTy() );
        newAccount.setRgDt( LocalDateTime.now() );
        ctgryAccntRepository.save( newAccount );
        Long newAccountNo = newAccount.getCtgryAccntNo();

        copyI18nResource( oldAccountNo, newAccountNo, ResourceCode.SUB_ACCOUNT_IMG_THUMBNAIL_CATEGORY, userId );

        List<CtgryDtsAccnt> categoryDetailAccounts = ctgryDtsAccntRepository.findAllByCtgryAccntNoOrderByAccntLocaAsc( oldAccountNo );
        if( CollectionUtils.isNotEmpty(categoryDetailAccounts) ) {
            for (CtgryDtsAccnt categoryDetailAccount : categoryDetailAccounts) {
                Long oldCategoryDetailAccountNo = categoryDetailAccount.getCtgryDtsAccntNo();
                DtsAccnt detailAccount = copyDetailAccount( categoryDetailAccount.getDtsAccntNo(), userId );

                CtgryDtsAccnt newCategoryDetailAccount = new CtgryDtsAccnt();
                newCategoryDetailAccount.setCtgryAccntNo( newAccountNo );
                newCategoryDetailAccount.setAccntLoca( categoryDetailAccount.getAccntLoca() );
                newCategoryDetailAccount.setDtsAccntNo( detailAccount.getDtsAccntNo() );
                newCategoryDetailAccount.setRgDt( LocalDateTime.now() );
                newCategoryDetailAccount.setCtgryTabName( categoryDetailAccount.getCtgryTabName() );
                newCategoryDetailAccount.setCtgryTabIndex( categoryDetailAccount.getCtgryTabIndex() );
                ctgryDtsAccntRepository.save( newCategoryDetailAccount );
                Long newCategoryDetailAccountNo = newCategoryDetailAccount.getCtgryDtsAccntNo();

                copyI18nResource( oldCategoryDetailAccountNo, newCategoryDetailAccountNo, ResourceCode.SUB_ACCOUNT_IMG_DETAIL_CATEGORY, userId );
            }
        }
    }
}

 

해결 방법

 

해당 로직에서도 JPA save() 반복 호출DB 커넥션 과부하가 발생.

**parallelStream**과 **saveAll()**을 사용해 배치 처리로 커넥션 수를 줄이고 성능을 개선

 

🔍 문제점 분석

  1. CtgryAccnt → CtgryDtsAccnt → DtsAccnt 계층적 중첩 루프로 인해 DB 커넥션 수가 기하급수적으로 증가.
  2. 각 계층별로 save()를 호출하면서 DB I/O 부하 발생.
  3. 병렬 스트림 미적용으로 멀티코어 활용 부족.

🛠️ 개선 전략

  1. parallelStream()으로 병렬 처리.
  2. save() → saveAll()로 변경하여 커넥션 수 최소화.
  3. copyI18nResource()는 병렬 처리 대상 제외(외부 의존성으로 인한 동시성 이슈 방지).
  4. 가독성 개선성능 최적화.

🚀 개선 코드

List<CtgryAccnt> accounts = ctgryAccntRepository.findAllByMainAccntNo(oldMainAccountNo);

if (CollectionUtils.isNotEmpty(accounts)) {
    // CtgryAccnt 병렬 생성 및 저장
    List<CtgryAccnt> newAccounts = accounts.parallelStream()
            .map(account -> {
                CtgryAccnt newAccount = new CtgryAccnt();
                newAccount.setMainAccntNo(newMainAccountNo);
                newAccount.setCtgryAccntTy(account.getCtgryAccntTy());
                newAccount.setRgDt(LocalDateTime.now());
                return newAccount;
            })
            .collect(Collectors.toList());

    ctgryAccntRepository.saveAll(newAccounts);

    // CtgryAccnt 번호 매핑
    Map<Long, Long> accountIdMapping = new HashMap<>();
    for (int i = 0; i < accounts.size(); i++) {
        accountIdMapping.put(accounts.get(i).getCtgryAccntNo(), newAccounts.get(i).getCtgryAccntNo());
    }

    // I18N 리소스 복사 (병렬 스트림은 copyI18nResource에 동시성 이슈가 없을 경우만 적용)
    accountIdMapping.forEach((oldAccountNo, newAccountNo) ->
            copyI18nResource(oldAccountNo, newAccountNo, ResourceCode.SUB_ACCOUNT_IMG_THUMBNAIL_CATEGORY, userId)
    );

    // CtgryDtsAccnt 병렬 처리
    List<CtgryDtsAccnt> allOldCategoryDetailAccounts = accountIdMapping.keySet().parallelStream()
            .flatMap(oldAccountNo -> ctgryDtsAccntRepository.findAllByCtgryAccntNoOrderByAccntLocaAsc(oldAccountNo).stream())
            .collect(Collectors.toList());

    List<CtgryDtsAccnt> newCategoryDetailAccounts = allOldCategoryDetailAccounts.parallelStream()
            .map(oldDetailAccount -> {
                DtsAccnt newDetailAccount = copyDetailAccount(oldDetailAccount.getDtsAccntNo(), userId);

                CtgryDtsAccnt newCategoryDetailAccount = new CtgryDtsAccnt();
                newCategoryDetailAccount.setCtgryAccntNo(accountIdMapping.get(oldDetailAccount.getCtgryAccntNo()));
                newCategoryDetailAccount.setAccntLoca(oldDetailAccount.getAccntLoca());
                newCategoryDetailAccount.setDtsAccntNo(newDetailAccount.getDtsAccntNo());
                newCategoryDetailAccount.setRgDt(LocalDateTime.now());
                newCategoryDetailAccount.setCtgryTabName(oldDetailAccount.getCtgryTabName());
                newCategoryDetailAccount.setCtgryTabIndex(oldDetailAccount.getCtgryTabIndex());
                return newCategoryDetailAccount;
            })
            .collect(Collectors.toList());

    ctgryDtsAccntRepository.saveAll(newCategoryDetailAccounts);

    // CtgryDtsAccnt I18N 리소스 복사
    newCategoryDetailAccounts.parallelStream().forEach(newDetailAccount -> {
        Long oldCategoryDetailAccountNo = allOldCategoryDetailAccounts.stream()
                .filter(oldAcc -> oldAcc.getAccntLoca().equals(newDetailAccount.getAccntLoca()))
                .findFirst()
                .map(CtgryDtsAccnt::getCtgryDtsAccntNo)
                .orElse(null);

        if (oldCategoryDetailAccountNo != null) {
            copyI18nResource(oldCategoryDetailAccountNo, newDetailAccount.getCtgryDtsAccntNo(),
                             ResourceCode.SUB_ACCOUNT_IMG_DETAIL_CATEGORY, userId);
        }
    });
}

📊 성능 개선 포인트

DB 커넥션 수 감소

  • 기존: CtgryAccnt n개 + CtgryDtsAccnt m개에 대해 n + m번의 커넥션 발생.
  • 개선: saveAll() 사용으로 2~3번의 커넥션으로 단축.

CPU 사용률 개선

  • 기존: 단일 스레드로 동작.
  • 개선: **parallelStream*으로 CPU 코어 활용 극대화.

가독성 개선

  • 중첩 루프 제거로 코드 이해도 향상.

⚠️ 유의사항

  1. AWS RDS Proxy 설정으로 DB 커넥션 풀링 최적화 추천.
  2. 병렬 스트림 시 copyI18nResource()는 동시성 이슈 확인 필요.
  3. Hibernate jdbc.batch_size 설정으로 배치 성능 극대화.