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()**을 사용해 배치 처리로 커넥션 수를 줄이고 성능을 개선
🔍 문제점 분석
- CtgryAccnt → CtgryDtsAccnt → DtsAccnt 계층적 중첩 루프로 인해 DB 커넥션 수가 기하급수적으로 증가.
- 각 계층별로 save()를 호출하면서 DB I/O 부하 발생.
- 병렬 스트림 미적용으로 멀티코어 활용 부족.
🛠️ 개선 전략
- parallelStream()으로 병렬 처리.
- save() → saveAll()로 변경하여 커넥션 수 최소화.
- copyI18nResource()는 병렬 처리 대상 제외(외부 의존성으로 인한 동시성 이슈 방지).
- 가독성 개선 및 성능 최적화.
🚀 개선 코드
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 코어 활용 극대화.
✅ 가독성 개선
- 중첩 루프 제거로 코드 이해도 향상.
⚠️ 유의사항
- AWS RDS Proxy 설정으로 DB 커넥션 풀링 최적화 추천.
- 병렬 스트림 시 copyI18nResource()는 동시성 이슈 확인 필요.
- Hibernate jdbc.batch_size 설정으로 배치 성능 극대화.
'개발공부 > Java(JPA)' 카테고리의 다른 글
Cache#1 스프링의 캐시 추상화(@Cacheable) (0) | 2025.02.24 |
---|---|
[Java] Stream() 과 parallelStream() 차이점 (0) | 2025.02.18 |
[JPA] for문으로 save() 할 경우 생기는 이슈 및 처리 (0) | 2025.02.18 |
[Java] Map 과 HashMap의 차이점! (0) | 2025.02.17 |
[JPA] JPA에서 동기(Synchronous) vs 비동기(Asynchronous) (0) | 2025.02.17 |