Back-end

[JAVA] 대량 데이터 배치 INSERT 리팩토링

somuxsomu 2026. 4. 8. 15:51
반응형

대량 데이터 배치 INSERT 리팩토링 — subList 활용


회사에서 알림 발송 기능 코드를 보는데, 

수신자 목록을 500건씩 쪼개서 배치 INSERT하는 부분이 있었다. 

수신자가 수천~수만 명, 혹은 그 이상 까지 될 수 있어서 한 번에 넣으면 DB 부하가 커지기 때문인데,

쪼개는 로직 자체가 불필요하게 복잡해서 리팩토링해보았다.

 

왜 쪼개서 INSERT 하는가?!

그 전에 먼저 왜 한 번에 INSERT하면 안 되는지 짚고 넘어가자!

 

MySQL에는 max_allowed_packet이라는 설정이 있다.

한 번에 DB로 보낼 수 있는 패킷(쿼리)의 최대 크기를 제한하는 값인데, 보통 4MB~64MB로 설정되어 있다.

MyBatis의 <foreach>로 멀티 row INSERT를 하면 데이터가 많을수록 쿼리 문자열 자체가 길어지기 때문에,

수만 건을 한 방에 넣으려고 하면 이 제한에 걸릴 수 있다.

 

그 외에도 한 번에 너무 많이 넣으면 DB가 해당 INSERT를 처리하는 동안 테이블 락이 길어질 수 있고,

트랜잭션 로그(undo/redo)가 한꺼번에 쌓이면서 메모리 부담도 생긴다.

실패했을 때 전부 재시도해야 하는 것도 문제다.

 

반대로 너무 적게 쪼개면 네트워크 왕복(round trip)이 너무 많아진다.

1건씩 보내면 1만 건 기준으로 DB를 1만 번 호출하는 셈이다.

매번 쿼리 파싱, 실행계획 수립 등의 오버헤드가 발생해서 전체 처리 시간이 느려진다.

 

업계에서 흔히 쓰는 배치 사이즈가 100~1,000 사이인데,

500은 그 중간값이라 대부분의 상황에서 무난하게 동작한다고 생각하면 된다 


기존 코드 구조 

수신자 목록을 조회한 뒤, 배치 분할에 필요한 값을 계산하는 부분이다.

// 수신자 목록 조회
List<Map<String, Object>> targetList = targetService.getTargetList(param);

if (targetList == null || targetList.isEmpty()) {
    resultMap.put("success", false);
    resultMap.put("message", "발송 대상이 존재하지 않습니다.");
    return resultMap;
}

// 배치 분할 계산
int batchSize = 500;
int batchCount = targetList.size() / batchSize;
int remainder = targetList.size() % batchSize;

 

batchCount는 500건 단위로 몇 번 돌릴지(몫), remainder는 마지막에 남는 건수(나머지)다.

 

예를 들어, 데이터가 1,230건이면 batchCount = 2, remainder = 230이 된다.

여기까지는 문제가 없다.

 

문제는 이 다음, 실제로 쪼개서 INSERT하는 부분이다. 

 

이 부분의 기존 코드가 꽤 복잡해 보였기 때문에, 리팩토링을 하게 되었다.


기존 코드와 문제점

int cnt = 0;
List<Map<String, Object>> tempList = new ArrayList<>();

// 1) 500건 단위 배치 처리
if (batchCount > 0) {
    for (Map<String, Object> target : targetList) {
        if (cnt < batchSize) {
            Map<String, Object> temp = new HashMap<>();
            temp.put("send_seq", param.get("send_seq"));
            temp.put("user_seq", target.get("user_seq"));
            temp.put("is_test", "N");
            temp.put("reg_seq", loginSeq);
            tempList.add(temp);
            cnt++;
        }
        if (cnt == batchSize) {
            // 500건 채워지면 INSERT
            subParam.put("targetList", tempList);
            result = targetDao.insertTargetInfo(subParam);
            cnt = 0;
            tempList = new ArrayList<>();
        }
    }
}

// 2) 나머지 처리 (500건 이하)
if (remainder > 0) {
    tempList = new ArrayList<>();
    int idx = -1;
    for (Map<String, Object> target : targetList) {
        idx++;
        // 이미 처리한 앞부분은 skip
        if (batchCount * batchSize > idx) {
            continue;
        } else {
            Map<String, Object> temp = new HashMap<>();
            temp.put("send_seq", param.get("send_seq"));
            temp.put("user_seq", target.get("user_seq"));
            temp.put("is_test", "N");
            temp.put("reg_seq", loginSeq);
            tempList.add(temp);
        }
    }
    subParam.put("targetList", tempList);
    result = targetDao.insertTargetInfo(subParam);
}

 

 

코드를 처음 봤을 때 한눈에 의도가 들어오지 않았다. 자세히 뜯어보니 몇 가지 문제가 보였다.

 

1) 같은 로직이 두 번 반복된다.

500건 단위 처리(1번 블록)와 나머지 처리(2번 블록)에서 Map을 만드는 코드가 거의 동일하다.

send_seq, user_seq, is_test, reg_seq를 넣는 부분이 복붙 수준으로 반복된다.

나중에 컬럼이 하나 추가되면 두 곳을 모두 수정해야 하고, 하나만 고치면 버그가 생긴다.

 

2) 카운터 변수로 직접 분할을 제어한다.

cnt, idx 같은 변수를 수동으로 관리하면서 지금 몇 번째인지를 추적하고 있다.

이런 방식은 흐름을 따라가기 어렵고, 조건이 조금만 복잡해져도 실수하기 쉽다.

실제로 idx = -1로 시작해서 idx++을 먼저 하는 부분은 처음 보면 왜 -1부터 시작하는지 바로 이해가 안 된다.

 

3) 나머지 처리에서 전체 리스트를 다시 순회한다.

2번 블록에서 나머지를 처리할 때, 이미 처리한 앞부분을 continue로 건너뛰면서 전체 리스트를 처음부터 다시 돌고 있다.

1,000건 중 나머지 230건을 처리하기 위해 앞의 770건을 의미 없이 순회하는 셈이다.

동작에는 문제가 없지만, 불필요한 반복이다.

 

결과적으로 "리스트를 N건씩 쪼개서 INSERT한다"는 단순한 의도에 비해 코드가 너무 복잡하다!


리팩토링 — subList 활용

 

Java의 List.subList(fromIndex, toIndex)를 사용하면, 리스트를 원하는 구간만큼 잘라서 가져올 수 있다.

이걸 활용하면 위의 코드를 for문 하나로 줄일 수 있다!

 

int batchSize = 500;
int totalSize = targetList.size();
int batchCount = totalSize / batchSize;

// 데이터가 10,030건이라는 가정 하에
// i=0  → 0 ~ 499
// i=1  → 500 ~ 999
// ...
// i=20 → 10000 ~ 10029  (마지막 나머지까지 한 번에 처리)

for (int i = 0; i <= batchCount; i++) {
    int fromIndex = i * batchSize;
    int toIndex = Math.min((i + 1) * batchSize, totalSize);

    // fromIndex == toIndex면 남은 데이터가 없으므로 skip
    if (fromIndex >= toIndex) continue;

    List<Map<String, Object>> subList = targetList.subList(fromIndex, toIndex);

    RequestParameter subParam = new RequestParameter();
    subParam.put("targetList", subList);
    result = targetDao.insertTargetInfo(subParam);
}

 

기존에 500건 단위 처리와 나머지 처리를 따로 분리했던 것을,

Math.min()을 활용해서 하나의 for문으로 통합했다.

 

마지막 반복에서 (i + 1) * batchSize가 totalSize보다 크면 totalSize로 잘려서,

나머지 데이터도 자연스럽게 처리된다.

별도의 나머지 처리 블록이 필요 없다.

 

remainder 변수도 더 이상 필요 없다.

기존 코드에서는 batchCount와 remainder를 각각 계산해서 분기했지만,

리팩토링된 코드에서는 i <= batchCount로 한 번 더 돌리면서

Math.min()이 알아서 나머지를 처리해주기 때문이다! 

 

구분  기존  리팩토링
코드 구조 500건 처리 + 나머지 처리 분리 for문 하나로 통합
분할 방식 카운터 변수(cnt, idx)로 수동 제어 subList(from, to)로 구간 지정
나머지 처리 전체 리스트를 다시 돌면서 skip Math.min()으로 자동 처리
가독성 흐름 따라가기 어려움 의도가 바로 보임

subList 에 대해

subList는 리스트의 특정 구간을 잘라서 반환하는 메서드다.

List<String> list = Arrays.asList("A", "B", "C", "D", "E");

list.subList(0, 2);  // → ["A", "B"]       (index 0 이상 2 미만)
list.subList(2, 5);  // → ["C", "D", "E"]  (index 2 이상 5 미만)

 

fromIndex는 이상(포함), toIndex는 미만(미포함)이다.

 

배열의 인덱스처럼 0부터 시작하고, toIndex에 해당하는 요소는 포함되지 않는다.

중요한 점은 subList가 새로운 리스트를 복사하는 게 아니라 원본 리스트의 뷰(view)를 반환한다는 것이다.

원본의 특정 구간을 참조하는 것이기 때문에 메모리를 추가로 사용하지 않아서 효율적이다.

하지만 이 특성 때문에 주의할 점도 있다!

 

subList 사용 시 주의할 점

원본 리스트의 뷰를 반환하기 때문에, 메모리 효율은 좋지만 원본 리스트를 수정하면 문제가 생긴다.

List<String> original = new ArrayList<>(Arrays.asList("A", "B", "C", "D", "E"));
List<String> sub = original.subList(0, 3);  // ["A", "B", "C"]

original.add("F");  // 원본 리스트 수정

System.out.println(sub.get(0));  // ❌ ConcurrentModificationException 발생!

 

subList로 꺼낸 뒤에 원본 리스트에 add나 remove를 하면 ConcurrentModificationException이 터진다!

 

subList가 바라보고 있는 원본의 구조가 바뀌어버렸기 때문이다. 

subList는 원본의 특정 구간을 "이 범위를 참조하고 있어"라고 기억하고 있는데, 

원본에 요소가 추가되거나 삭제되면 그 범위 자체가 의미 없어지기 때문에 예외를 발생시키는 것이다.


만약 원본 리스트를 수정할 가능성이 있다면, subList를 새 리스트로 복사해서 사용하면 된다.

 
// 원본과 완전히 분리된 새 리스트로 복사
List<String> safeCopy = new ArrayList<>(original.subList(0, 3));

original.add("F");            // 원본 수정해도
System.out.println(safeCopy); // ✅ ["A", "B", "C"] — 영향 없음

 

new ArrayList<>()로 감싸면 해당 시점의 데이터를 복사한 완전히 독립적인 리스트가 만들어진다. 

이후에 원본을 수정해도 복사본에는 영향이 없다.


이번 리팩토링에서는 subList를 꺼낸 뒤 바로 dao에 넘겨서 INSERT하고,

루프 안에서 원본 리스트를 수정하지 않기 때문에 별도 복사 없이 사용해도 괜찮다....!

 

하지만 다른 상황에서 subList를 쓸 때는 이 점을 꼭 기억해두자!

 


 

리팩토링이라고 해서 거창한 것만 있는 게 아니다. 

이번 케이스는 Java에서 기본으로 제공하는 subList 하나로 코드를 절반 이상 줄일 수 있었다!!!!

 

별거 아니지만.. 괜히 뿌듯... 🤓

반응형