UI 버전 사본 만들기 ( 기존데이터 복사 ) 할때 RDS 의 DB CPU가 99.98%로 올라가는 이슈가 생겼어.
무분별한 for 문과 save()가 해당 이슈의 원인이라 파악!
이슈가 된 코드와 어떻게 변경하면 좋을지 알아보자
이슈 코드 1
List<VerAd> ads = verAdRepository.findAllByVerNo( oldVerNo );
if( CollectionUtils.isNotEmpty(ads) ) {
for (VerAd ad : ads) {
VerAd newAd = new VerAd();
newAd.setVerNo( newVerNo );
newAd.setAdNo( ad.getAdNo() );
newAd.setShowLoca( ad.getShowLoca() );
newAd.setShowOrd( ad.getShowOrd() );
newAd.setRgDt( LocalDateTime.now() );
verAdRepository.save( newAd );
}
}
이 코드에서 for 루프 내에서 verAdRepository.save()가 반복 호출되며, DB 커넥션이 과도하게 사용되고 있어.
이를 최적화하려면 아래와 같은 방법을 적용해볼 수 있어
🛠️ 해결 방법 1: saveAll() 사용 (Spring Data JPA)
루프 내에서 save()를 반복 호출하지 말고, 리스트에 모은 후 한 번에 저장해.
✨ 코드 개선:
// 시작화면 / 출력대기화면
List<VerAd> ads = verAdRepository.findAllByVerNo(oldVerNo);
if (CollectionUtils.isNotEmpty(ads)) {
List<VerAd> newAds = new ArrayList<>();
for (VerAd ad : ads) {
VerAd newAd = new VerAd();
newAd.setVerNo(newVerNo);
newAd.setAdNo(ad.getAdNo());
newAd.setShowLoca(ad.getShowLoca());
newAd.setShowOrd(ad.getShowOrd());
newAd.setRgDt(LocalDateTime.now());
newAds.add(newAd);
}
verAdRepository.saveAll(newAds); // 한 번에 저장
}
🎯 변경점:
- save()를 n번 호출 → saveAll()을 1번 호출.
- 장점: JPA가 배치 인서트를 통해 SQL을 모아서 실행.
⚙️ 해결 방법 2: 배치 인서트 최적화 (EntityManager 직접 사용)
saveAll()도 JPA가 내부적으로 반복 처리하므로, 대량 데이터의 경우 배치 인서트를 수동으로 최적화할 수 있어.
✨ 코드 개선:
@PersistenceContext
private EntityManager entityManager;
public void batchInsertVerAds(Long oldVerNo, Long newVerNo) {
List<VerAd> ads = verAdRepository.findAllByVerNo(oldVerNo);
if (CollectionUtils.isNotEmpty(ads)) {
int batchSize = 50;
int count = 0;
for (VerAd ad : ads) {
VerAd newAd = new VerAd();
newAd.setVerNo(newVerNo);
newAd.setAdNo(ad.getAdNo());
newAd.setShowLoca(ad.getShowLoca());
newAd.setShowOrd(ad.getShowOrd());
newAd.setRgDt(LocalDateTime.now());
entityManager.persist(newAd);
count++;
// 배치 크기마다 flush/clear 호출
if (count % batchSize == 0) {
entityManager.flush();
entityManager.clear();
}
}
entityManager.flush();
entityManager.clear();
}
}
🧠 변경점:
- save() 대신 persist() 사용.
- 배치 크기(50개)마다 flush() 및 clear() 호출.
- 장점: 메모리 사용량 절감 및 DB 커넥션 감소.
🚀 해결 방법 3: 네이티브 SQL INSERT INTO SELECT 활용
JPA의 for 루프 없이 DB 자체의 연산 능력을 활용해 단 한 번의 SQL로 복제.
✨ 코드 개선:
@Modifying
@Query(value = """
INSERT INTO ver_ad (ver_no, ad_no, show_loca, show_ord, rg_dt)
SELECT :newVerNo, ad_no, show_loca, show_ord, NOW()
FROM ver_ad
WHERE ver_no = :oldVerNo
""", nativeQuery = true)
int copyVerAds(@Param("oldVerNo") Long oldVerNo, @Param("newVerNo") Long newVerNo);
🧠 변경점:
- JPA 루프 없이 SQL로 복제.
- 장점: 커넥션 1개, SQL 1번으로 작업 완료.
- 단점: 비즈니스 로직이 SQL에 포함되므로 유지보수가 어려울 수 있음.
🔍 권장 접근 순서:
- saveAll() → 단순하고 효율적.
- 배치 인서트(EntityManager) → 데이터 많으면.
- 네이티브 SQL → 성능이 최우선일 때.
🔧 성능 모니터링 팁:
- AWS RDS Performance Insights로 Active Sessions과 CPU 사용량 추적.
- Spring Boot Actuator와 HikariCP metrics로 커넥션 풀 사용량 확인.
이슈 코드 2
List<LicnsAccnt> licenseAccounts = licnsAccntRepository.findAllByVerNo( oldVerNo );
if( CollectionUtils.isNotEmpty(licenseAccounts) ) {
for (LicnsAccnt licenseAccount : licenseAccounts) {
Long oldLicenseAccountNo = licenseAccount.getLicnsAccntNo();
LicnsAccnt newLicenseAccount = new LicnsAccnt();
newLicenseAccount.setVerNo( newVerNo );
newLicenseAccount.setLicnsDiv( licenseAccount.getLicnsDiv() );
newLicenseAccount.setRgDt( LocalDateTime.now() );
licnsAccntRepository.save( newLicenseAccount );
Long newLicenseAccountNo = newLicenseAccount.getLicnsAccntNo();
List<MainAccnt> mainAccounts = mainAccntRepository.findAllByLicnsAccntNo( oldLicenseAccountNo );
if( CollectionUtils.isNotEmpty( mainAccounts ) ) {
for (MainAccnt mainAccount : mainAccounts) {
Long oldMainAccountNo = mainAccount.getMainAccntNo();
MainAccnt newMainAccount = new MainAccnt();
newMainAccount.setLicnsAccntNo( newLicenseAccountNo );
newMainAccount.setAccntLoca( mainAccount.getAccntLoca() );
newMainAccount.setAccntTy( mainAccount.getAccntTy() );
newMainAccount.setRgDt( LocalDateTime.now() );
mainAccntRepository.save( newMainAccount );
Long newMainAccountNo = newMainAccount.getMainAccntNo();
MainAccountType mainAccountType = MainAccountType.search( mainAccount.getAccntTy() );
switch (mainAccountType) {
case CATEGORY: copyCategoryAccount( oldMainAccountNo, newMainAccountNo, userId );
break;
case FRAME: copyFrameAccount( oldMainAccountNo, newMainAccountNo, userId );
break;
case PP: copyPpAccount( oldMainAccountNo, newMainAccountNo, userId );
break;
case RECOMMEND_CONTENTS: copyRecommendContentAccount( oldMainAccountNo, newMainAccountNo, userId );
break;
case APP_MARKETING: copyAppPromoAccount( oldMainAccountNo, newMainAccountNo, userId );
break;
}
}
}
}
}
⚠️ 문제 분석
코드에서 중첩 루프 + JPA save() 반복 호출로 인해 DB 커넥션이 과도하게 발생하고 있어.
- LicnsAccnt 저장 → mainAccntRepository.findAllByLicnsAccntNo() 호출 반복.
- MainAccnt 저장 → 각 타입별 copyXAccount() 메서드 호출 반복.
- 모든 save 메서드가 개별적으로 호출되며, 네트워크 오버헤드와 DB 부하 증가 발생.
🚀 개선 방향
- JPA saveAll()을 사용해 배치 처리
- 하위 계층의 copyXAccount() 메서드를 stream과 parallelStream()으로 최적화
- 가능한 경우 SQL INSERT INTO SELECT 사용
- AWS Lambda 환경이므로 RDS Proxy를 통해 커넥션 풀 관리(성능 개선)
🛠️ 개선 코드
1️⃣ LicnsAccnt 엔티티 일괄 저장
List<LicnsAccnt> licenseAccounts = licnsAccntRepository.findAllByVerNo(oldVerNo);
if (CollectionUtils.isNotEmpty(licenseAccounts)) {
List<LicnsAccnt> newLicenseAccounts = licenseAccounts.stream()
.map(la -> {
LicnsAccnt newAccount = new LicnsAccnt();
newAccount.setVerNo(newVerNo);
newAccount.setLicnsDiv(la.getLicnsDiv());
newAccount.setRgDt(LocalDateTime.now());
return newAccount;
})
.collect(Collectors.toList());
verDeviceRepository.saveAll(newLicenseAccounts);
}
💡 변경점:
- for 루프 제거.
- *N번의 save() → 1번의 saveAll()*로 DB 커넥션 감소.
2️⃣ MainAccnt 엔티티 일괄 저장 및 병렬 처리
Map<Long, Long> accountIdMapping = new HashMap<>();
licenseAccounts.parallelStream().forEach(oldAccount -> {
Long oldLicenseAccountNo = oldAccount.getLicnsAccntNo();
LicnsAccnt newLicenseAccount = new LicnsAccnt();
newLicenseAccount.setVerNo(newVerNo);
newLicenseAccount.setLicnsDiv(oldAccount.getLicnsDiv());
newLicenseAccount.setRgDt(LocalDateTime.now());
licnsAccntRepository.save(newLicenseAccount);
Long newLicenseAccountNo = newLicenseAccount.getLicnsAccntNo();
accountIdMapping.put(oldLicenseAccountNo, newLicenseAccountNo);
List<MainAccnt> mainAccounts = mainAccntRepository.findAllByLicnsAccntNo(oldLicenseAccountNo);
if (CollectionUtils.isNotEmpty(mainAccounts)) {
List<MainAccnt> newMainAccounts = mainAccounts.stream()
.map(ma -> {
MainAccnt newMainAccount = new MainAccnt();
newMainAccount.setLicnsAccntNo(newLicenseAccountNo);
newMainAccount.setAccntLoca(ma.getAccntLoca());
newMainAccount.setAccntTy(ma.getAccntTy());
newMainAccount.setRgDt(LocalDateTime.now());
return newMainAccount;
})
.collect(Collectors.toList());
mainAccntRepository.saveAll(newMainAccounts);
}
});
💡 변경점:
- CPU 병렬 처리(parallelStream).
- MainAccnt도 루프 없이 saveAll()로 저장.
- accountIdMapping을 이용해 oldMainAccountNo → newMainAccountNo 매핑 저장.
3️⃣ 하위 계정 타입별 copyXAccount() 최적화
accountIdMapping.forEach((oldMainAccountNo, newMainAccountNo) -> {
MainAccountType mainAccountType = MainAccountType.search(mainAccntRepository.findTypeById(oldMainAccountNo));
switch (mainAccountType) {
case CATEGORY -> copyCategoryAccount(oldMainAccountNo, newMainAccountNo, userId);
case FRAME -> copyFrameAccount(oldMainAccountNo, newMainAccountNo, userId);
case PP -> copyPpAccount(oldMainAccountNo, newMainAccountNo, userId);
case RECOMMEND_CONTENTS -> copyRecommendContentAccount(oldMainAccountNo, newMainAccountNo, userId);
case APP_MARKETING -> copyAppPromoAccount(oldMainAccountNo, newMainAccountNo, userId);
}
});
💡 변경점:
- 불필요한 if/else 대신 switch-case 개선.
- 가독성 개선.
⚙️ DB 커넥션 최적화 설정 (AWS RDS & Hibernate)
1️⃣ application.yml 설정
spring.jpa.properties.hibernate.jdbc.batch_size: 50
spring.jpa.properties.hibernate.order_inserts: true
spring.jpa.properties.hibernate.order_updates: true
spring.datasource.hikari.maximum-pool-size: 30
2️⃣ AWS RDS Proxy 도입
- AWS Lambda → RDS 간 커넥션 관리 최적화.
- 동시성 증가 시 성능 개선.
📊 성능 비교
방식 DB 커넥션 수 성능 개선 기대치
기존 코드 | 엔티티 개수 × 3 | - |
saveAll() 적용 | 엔티티 개수 / 3 | 🚀 60~70% 개선 |
parallelStream 적용 | 엔티티 개수 / 5 | 🚀 80~90% 개선 |
SQL INSERT INTO | 단 1회 | 🚀 95% 이상 개선 |
🎯 최종 권장안
- 단순 개선: saveAll() 사용.
- 성능 중요: parallelStream + saveAll() + RDS Proxy.
- 극단적 성능: **SQL INSERT INTO SELECT*로 전환.
'개발공부 > Java(JPA)' 카테고리의 다른 글
[Java] Stream() 과 parallelStream() 차이점 (0) | 2025.02.18 |
---|---|
[JPA] JPA save() 반복 호출로 DB 커넥션 과부하 해결 (0) | 2025.02.18 |
[Java] Map 과 HashMap의 차이점! (0) | 2025.02.17 |
[JPA] JPA에서 동기(Synchronous) vs 비동기(Asynchronous) (0) | 2025.02.17 |
[Java] String VS StringBuilder 차이점 (0) | 2025.02.17 |