본문 바로가기

개발공부/Java(JPA)

[JPA] for문으로 save() 할 경우 생기는 이슈 및 처리

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에 포함되므로 유지보수가 어려울 수 있음.

🔍 권장 접근 순서:

  1. saveAll() → 단순하고 효율적.
  2. 배치 인서트(EntityManager) → 데이터 많으면.
  3. 네이티브 SQL → 성능이 최우선일 때.

🔧 성능 모니터링 팁:

  • AWS RDS Performance InsightsActive SessionsCPU 사용량 추적.
  • Spring Boot ActuatorHikariCP 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 커넥션이 과도하게 발생하고 있어.

  1. LicnsAccnt 저장 → mainAccntRepository.findAllByLicnsAccntNo() 호출 반복.
  2. MainAccnt 저장 → 각 타입별 copyXAccount() 메서드 호출 반복.
  3. 모든 save 메서드가 개별적으로 호출되며, 네트워크 오버헤드DB 부하 증가 발생.

🚀 개선 방향

  1. JPA saveAll()을 사용해 배치 처리
  2. 하위 계층의 copyXAccount() 메서드를 stream과 parallelStream()으로 최적화
  3. 가능한 경우 SQL INSERT INTO SELECT 사용
  4. 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*로 전환.