본문 바로가기
💻 하나씩 차곡차곡/Back-end

"나도 드디어...성능 최적화를...?" - 대용량 INSERT 성능 개선: SQL Session 배치 처리로 최적화하기

by 뚜루리 2025. 2. 7.
728x90
320x100

[서론]

2025년 1월, 새해가 되니 내가 담당자?!

작년에 클라이언트가 개선사항을 요청했고 현재 '운영중'이니 내년에 개선하겠다고 약속을 했으며, 그 약속을 했던 전임자는 떠나갔다. 그럼 개선은...? 올해 담당자인 내가 해야 한다. 그래서 써보는 개선 일지.

 


 

[요청사항]

  • "1,000건 이상 엑셀 업로드를 할 때 자꾸 에러가 떠요! 1000건 이상 업로드 되게끔 해주세요!"

 


 

[문제상황 및 원인]

  • [상황] 대량 데이터 DB INSERT 시 성능 저하 및 오류 발생 (ORA-01653 등)
    • [원인] 기존에는 개별 INSERT 문을 실행했으며, 1건씩 처리했기 때문에 성능 저하와 에러가 발생함.
  • [상황] API 통신을 위한 데이터 변환시 속도 저하 발생 (1,500건 2분 가량 소요)
    • [원인] 그리드 데이터를 가공하여 API로 전송할 때 for문을 사용하여 변환 데이터가 많아질수록 API 요청 전 가공 시간이 급격히 증가 

 


 

[해결하기]

1.대량 데이터 DB INSERT 시 성능 저하 및 오류 발생

→ SQL SESSION을 활용하여 배치 인서트(BATCH INSERT) 방식으로 변경하여 INSERT 실행

 

좀 더 쉽게 풀어보자면!

[기존 방식]

INSERT → COMMIT → INSERT → COMMIT → INSERT → COMMIT

👉 1000건이면 1000번 반복 → 성능 저하, 네트워크 부하 증가

[배치 인서트(BATCH INSERT) 방식]

INSERT, INSERT, INSERT .. (1000건 모아둔 후 한꺼번에 실행) COMMIT (1번만!)

👉 즉, 1000번을 따로 실행하는 대신, 1000개를 모아 한꺼번에 처리하는 방식!

 

[MyBatis sqlSession을 활용한 배치 INSERT 예제]

import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;

public void insertBatch(List<MyData> dataList) {
    SqlSessionFactory sqlSessionFactory = MyBatisUtil.getSqlSessionFactory();
    try (SqlSession sqlSession = sqlSessionFactory.openSession(false)) { // autoCommit=false
        for (int i = 0; i < dataList.size(); i++) {
            sqlSession.insert("myMapper.insertData", dataList.get(i));

            // 1000개마다 플러시 및 커밋
            if (i % 1000 == 0) {
                sqlSession.flushStatements();
                sqlSession.commit();
            }
        }
        sqlSession.commit(); // 마지막 커밋
    } catch (Exception e) {
        sqlSession.rollback();
        throw e;
    }
}
  • 1000건 단위로 flush + commit 하여 성능 최적화.
  • rollback 포함하여 예외 발생 시 처리 가능.

 


 

[왜 배치 인서트 방식을 사용했는가?]

1. SQL 파싱 비용 절감

  • 개별적으로 INSERT를 실행하면 매번 SQL을 파싱, 실행 계획 수립, 바인딩 등의 과정이 필요. 배치 배치 처리 시 한 번만 SQL을 파싱하고 여러 데이터를 한 번에 처리할 수 있어 성능이 향상됨.

2. 네트워크 오버헤드 감소

  • 개별 INSERT는 각각의 SQL이 데이터베이스와 통신해야 하지만, 배치 방식은 한 번에 여러 개를 전송하여 네트워크 부하를 줄임.

3. 트랜잭션 처리 효율성

  • 개별 INSERT 실행 시마다 COMMIT 하면 Redo Log와 Undo Log가 반복적으로 기록됨.
  • 배치 방식으로 COMMIT을 줄이면 디스크 I/O를 줄여 성능이 좋아짐.

4. Redo Log와 Undo Log 사용량 감소

  • Oracle은 INSERT 연산 시 **Redo Log(복구 로그)와 Undo Log(롤백 정보)**를 저장해야 함.
  • 배치 INSERT는 여러 건을 한꺼번에 처리하여 로그 기록을 최소화할 수 있음.

 


 

[배치 인서트 방식으로 왜 SQL SESSION을 사용했는가?]

🔥 배치 INSERT 방식 비교

방식 설명 장점 단점
JDBC Batch
(executeBatch())
PreparedStatement.addBatch()로 여러 개의 SQL을 모아서 한 번에 실행 ✅ 빠름 (SQL 실행 횟수 감소)
✅ DBMS 독립적 (MyBatis, Hibernate 등과 같이 사용 가능)
❌ 직접 커넥션, 트랜잭션 관리 필요
❌ 예외 발생 시 개별 처리 어려움
MyBatis Batch
(sqlSession)
MyBatis의 flushStatements()를 사용하여 여러 개의 INSERT를 한 번에 실행 ✅ JDBC보다 편리한 트랜잭션 관리
 flushStatements()로 적절한 시점에 커밋 가능
❌ 배치 크기 튜닝 필요 (flushStatements() 주기 조절해야 함)
❌ 일부 DBMS에서 성능 최적화가 다를 수 있음
PL/SQL Bulk Insert
(FORALL, 
BULK COLLECT)
Oracle에서 PL/SQL을 이용하여 대량 데이터를 빠르게 삽입하는 방식  가장 빠름 (PL/SQL 엔진에서 직접 처리)
✅ 네트워크 부하 최소화
✅ 커밋 제어 가능
 Oracle 전용 (다른 DB에서는 사용 불가)
❌ 트랜잭션 롤백 시 개별 행 처리 어려움

 

💡 MyBatis 배치 INSERT를 선택한 이유

1️⃣ JDBC executeBatch()보다 MyBatis가 편리

  • JDBC는 직접 커넥션을 관리해야 하지만, MyBatis는 `sqlSession.flushStatements()`로 쉽게 처리 가능.  

2️⃣ 트랜잭션 관리가 용이

  • `commit()`을 적절한 타이밍에 실행할 수 있어, 대량 데이터 삽입 시 안정성이 높음.  

3️⃣ SQL과 로직 분리

  • XML 기반으로 SQL을 관리할 수 있어 유지보수가 쉬움.

 


[개선 결과]

비교 항목 기존 방식 (개별 INSERT) 개선 (SQL Session 배치)
실행 시간 (1,500 ) 에러 발생 20초
메모리 사용량 높음 (OutOfMemory 가능) 낮음 (적절한 flush 관리)

 

 


2. API 통신을 위한 데이터 변환시 속도 저하 발생(1,500건 2분 가량 소요)

→ 성능 최적화 코드 개선 (for → map)

[기존 Javascript 코드 예시]

let transformedData = [];
for (let i = 0; i < gridData.length; i++) {
    transformedData.push({
        id: gridData[i].id,
        name: gridData[i].name,
        value: gridData[i].value
    });
}
  • 기존에는 API에서 for문을 사용하여 데이터를 변환 후 전송 → 데이터 개수가 많아질수록 성능이 급격히 저하됨
  • 1,500건 기준 약 1분 소요

 

[개선 Javascript 코드 예시]

let transformedData = gridData.map(item => ({
    id: item.id,
    name: item.name,
    value: item.value
}));
  • map 사용 후, 1,500건 기준 1~2초 소요.

 


 

[왜 map이 더 빠를까?]

1. map()최적화가 잘되어 있음.

  • V8 엔진(Chrome, Node.js)에서 map()은 내부적으로 최적화 되어 있어 엔진이 map()을 실행할 때 배열의 크기를 미리 할당하고, 반복 중에 추가적인 연산을 최소화하도록 최적화함.
  • for 문은 매번 배열 길이를 확인해야 할 수도 있지만, map()은 최적화된 방식으로 전체 배열을 처리함.

2. map()은 배열의 크기를 미리 할당함

  • map()은 새로운 배열을 반환할 때, 기존 배열과 동일한 크기의 메모리를 미리 예약하고 실행됨.
  • 반면, for 문에서는 push()를 사용할 때 배열 크기를 동적으로 변경하므로, 메모리 할당과 GC(Garbage Collection) 오버헤드가 추가됨.

3. map()은 순수 함수(Pure Function) 방식으로 실행됨

 

  • map()은 원본 배열을 변경하지 않고 새로운 배열을 반환함.
  • for 문은 반복마다 원본 배열을 변경할 수도 있어서, 불필요한 연산이 많아질 가능성이 있음.

4. map()은 병렬 처리(병렬 연산) 최적화 가능

  • JavaScript 엔진(V8, SpiderMonkey 등)에서 map()은 내부적으로 최적화된 병렬 연산을 수행할 수 있음.
  • 반면, for 문은 명령형 방식이므로 한 번에 하나씩 처리하게 되어 실행 속도가 더 느려질 가능성이 있음.

 

❌ 하지만, 무조건 map()이 빠른 건 아님!

  • map()은 새로운 배열을 생성하므로, 메모리 사용량이 증가할 수 있음.
  • 단순 반복(console.log(), 카운트 증가)이라면 for 문이 더 빠를 수도 있음.

 


[개선 결과]

비교 항목 기존 방식 (for문) 개선  (map)
실행 시간 (1,500 ) 2분 1~2초

 

 

 

 

 

728x90
320x100