배치 작업의 불편한 진실 - Thread, Daemon 그리고 트랜잭션 롤백의 한계
왜 @Transactional 하나로는 대용량 배치를 다룰 수 없는가
여는 글
“Spring Batch 쓰면 되는데 왜 굳이 Thread를 extends해서 배치를 만드시나요?”
맞습니다. 2025년에 새로 만든다면 Spring Batch나 상용 배치 프레임워크를 쓰는 게 정답입니다. 하지만 현실은 어떨까요? 10년, 15년 된 레거시 시스템들은 여전히 Thread를 extends한 배치 클래스들로 가득합니다. 그리고 이런 배치들이 퇴근 후에 터지면, Thread와 Daemon의 차이를 모르는 개발자는 원인조차 찾기 어렵습니다.
현실의 배치 시스템 - Thread extends의 시대
왜 아직도 Thread extends 구조인가?
대기업 금융권이나 시스템을 들여다보면 놀라운 광경을 목격합니다. 수백 개의 중요 비즈니스 로직을 담은 배치 잡들이 Thread를 extends한 클래스로 구현되어 cron이나 Thread.sleep()으로 스케줄링되고 있습니다.
왜 이런 구조가 아직도 살아있을까요?
역사적 이유: 2000년대 중반까지 Spring Batch는 없었습니다(2007년 첫 출시). 있는 건 순수 Java Thread와 JDBC뿐이었죠. 그때 만들어진 Thread 기반 배치들이 “잘 돌아가니까” 그대로 유지되고 있습니다.
단순함의 미학: Thread를 extends한 배치 클래스는 누구나 이해할 수 있습니다. run() 메소드만 구현하면 되고, 디버깅도 쉽고, 로컬에서 테스트하기도 편합니다. 복잡한 프레임워크 설정 없이 바로 실행 가능합니다.
독립성: 각 배치가 독립된 JVM에서 실행되므로, 하나가 죽어도 다른 배치에 영향이 없습니다. 메모리 누수가 있어도 실행 후 JVM이 종료되니 문제없습니다.
운영팀의 선호: 운영팀은 단순한 걸 좋아합니다. “java -cp batch.jar com.company.batch.DailySettlementThread” 이거면 끝입니다. 복잡한 배치 어드민 화면? 그런 거 없어도 됩니다.
Thread와 Daemon - 배치가 끝나지 않는 이유
User Thread vs Daemon Thread
Java 애플리케이션이 종료되는 조건을 아시나요? 모든 User Thread가 종료되어야 JVM이 종료됩니다. 여기서 중요한 건 Daemon Thread는 계산에 포함되지 않는다는 점입니다.
User Thread의 특징:
- main 메소드를 실행하는 스레드가 대표적
- Thread를 extends한 클래스나 new Thread()로 생성한 스레드는 기본적으로 User Thread
- 모든 User Thread가 종료되어야 JVM 종료
- 배치에서 Thread를 extends한 클래스들이 모두 User Thread로 동작
Daemon Thread의 특징:
- GC, 모니터링 등 백그라운드 작업용
- 모든 User Thread가 종료되면 즉시 강제 종료
- setDaemon(true)로 명시적 설정 필요
배치에서 자주 겪는 Thread 문제
Case 1: 종료되지 않는 배치
Thread를 extends한 배치 클래스의 run() 메소드는 끝났는데 JVM이 종료되지 않습니다. 원인은 대부분 이렇습니다:
- ExecutorService를 shutdown() 하지 않음
- 커넥션 풀의 유지보수 스레드가 User Thread로 생성됨
- 파일 감시나 디렉토리 폴링 스레드가 계속 실행 중
Case 2: 갑자기 죽는 배치
처리 중인데 갑자기 배치가 종료됩니다. Daemon Thread로 중요한 작업을 처리했기 때문입니다:
- 비동기 로깅을 Daemon Thread로 처리
- 중요한 후처리를 Daemon Thread에 위임
- 메인 배치 Thread가 먼저 종료되어 Daemon Thread도 강제 종료
Thread 관리 베스트 프랙티스
1. ExecutorService는 반드시 정리하라
배치에서 병렬 처리를 위해 ExecutorService를 사용한다면, 반드시 적절히 종료해야 합니다. shutdown()과 awaitTermination()을 조합하여 안전하게 종료하세요. 특히 shutdownNow()는 실행 중인 작업을 중단시키므로 주의가 필요합니다.
2. Daemon Thread 사용은 신중히
로깅, 모니터링, 통계 수집 같은 부가적인 작업에만 Daemon Thread를 사용하세요. 비즈니스 로직이나 데이터 처리는 절대 Daemon Thread에서 처리하면 안 됩니다.
3. Thread 상태 모니터링
배치 시작 시와 종료 전에 활성 스레드를 확인하는 습관을 들이세요. Thread.getAllStackTraces()를 활용하면 현재 실행 중인 모든 스레드를 확인할 수 있습니다.
트랜잭션 롤백의 불편한 진실
@Transactional의 한계
Spring의 @Transactional은 편리하지만, 대용량 배치에서는 함정이 될 수 있습니다. 100만 건을 처리하는 메소드에 @Transactional을 걸면 어떻게 될까요?
메모리 문제: 트랜잭션이 커밋되기 전까지 모든 변경사항이 메모리에 유지됩니다. Undo 로그가 계속 쌓이면서 메모리 부족이 발생할 수 있습니다.
롤백 세그먼트 부족: 데이터베이스의 롤백 세그먼트는 무한하지 않습니다. 대용량 트랜잭션은 롤백 세그먼트를 고갈시킬 수 있습니다.
Lock 경합: 긴 트랜잭션은 다른 작업을 블로킹합니다. 온라인 서비스와 배치가 같은 테이블을 사용한다면 심각한 성능 저하가 발생합니다.
데이터베이스별 롤백 한계
각 데이터베이스는 롤백할 수 있는 데이터 양에 물리적 한계가 있습니다. 이는 설정으로 조정 가능하지만, 무한정 늘릴 수는 없습니다.
Oracle의 경우
Oracle은 롤백 세그먼트(Undo Segment)를 사용합니다. UNDO_RETENTION 파라미터로 보관 기간을 설정하고, 롤백 세그먼트 크기는 동적으로 확장됩니다. 하지만 테이블스페이스 크기가 한계입니다.
일반적인 권장사항:
- 롤백 세그먼트는 가장 큰 테이블의 10% 크기로 설정
- 트랜잭션당 4개 이하의 롤백 세그먼트 사용
- 배치 작업 시 별도의 큰 롤백 세그먼트 할당
실제 한계:
- 단일 트랜잭션이 사용할 수 있는 Undo 공간은 Undo 테이블스페이스 크기에 제한
- 일반적으로 수 GB에서 수십 GB 수준
- 그 이상은 “ORA-01555: snapshot too old” 에러 발생
MySQL (InnoDB)의 경우
InnoDB는 Undo Log를 사용하며, innodb_max_undo_log_size로 크기를 제한합니다. 기본값은 1GB이며, 이를 초과하면 자동으로 truncate됩니다.
트랜잭션 한계 계산:
- 각 rollback segment는 1024개의 undo slot 보유
- 기본 설정: 128개 rollback segment × 1024 slot = 약 131,072개의 동시 트랜잭션 지원
- 단일 대용량 트랜잭션의 경우 Undo Log가 수십 GB까지 증가 가능
실제 경험:
- 180GB까지 Undo Log가 증가한 사례 존재
- 이런 경우 롤백에 수 시간에서 수 일 소요
- 강제 종료해도 재시작 시 롤백 진행
PostgreSQL의 경우
PostgreSQL은 MVCC를 위해 WAL(Write-Ahead Log)을 사용합니다. Oracle이나 MySQL과 달리 별도의 롤백 세그먼트가 없고, 테이블 자체에 여러 버전의 행을 저장합니다.
WAL 크기 제한:
- max_wal_size: 체크포인트 간 WAL 최대 크기 (기본 1GB, PostgreSQL 9.5+)
- min_wal_size: 재활용을 위해 유지할 최소 WAL 크기 (기본 80MB)
- max_wal_size는 soft limit이므로 실제로는 초과 가능 (부하가 높거나 아카이빙 지연 시)
- 구버전(9.4 이하)은 checkpoint_segments 파라미터 사용
트랜잭션 크기 한계:
- 이론적으로는 디스크 공간이 허용하는 한 제한 없음
- 실제로는 VACUUM이 정리하지 못한 dead tuple이 쌓여 테이블 bloat 발생
- 장시간 트랜잭션은 다른 트랜잭션의 VACUUM을 방해
현실적인 배치 트랜잭션 전략
1. 청크 단위 처리
전체를 하나의 트랜잭션으로 처리하지 마세요. 1000건, 10000건 단위로 나누어 처리하면:
- 메모리 사용량 예측 가능
- 실패 시 부분 재처리 가능
- 다른 작업과의 Lock 경합 최소화
2. 체크포인트 방식
처리 진행상황을 별도 테이블에 기록하세요:
- 마지막 처리 ID나 타임스탬프 저장
- 실패 시 체크포인트부터 재시작
- 전체 롤백 불필요
3. 보상 트랜잭션
롤백 대신 반대 작업을 수행하는 보상 트랜잭션을 고려하세요:
- Saga 패턴의 배치 버전
- 각 단계별 보상 로직 구현
- 부분 실패에 유연하게 대응
4. 임시 테이블 활용
대용량 데이터 처리 시 임시 테이블을 활용하세요:
- 원본 테이블 Lock 최소화
- 처리 완료 후 한 번에 반영
- 실패 시 임시 테이블만 DROP
실전 사례 - 1억 건 정산 배치의 교훈
문제 상황
일 매출 1억 건을 정산하는 배치가 있었습니다. 처음엔 단순했습니다. MyBatis를 사용하여 전체 데이터를 조회하고 처리하는 방식이었죠:
@Transactional
public void settleDailySales(LocalDate date) {
List<Sale> sales = saleMapper.selectDailySales(date);
for (Sale sale : sales) {
processSettlement(sale);
}
}
한 달 후, 이 배치는 OutOfMemoryError로 죽었습니다. 1억 건의 데이터를 메모리에 로드하려 했으니 당연한 결과였죠.
첫 번째 개선 - JDBC 직접 사용과 Cursor
MyBatis의 한계를 느끼고 JDBC를 직접 사용하기로 했습니다. PreparedStatement와 ResultSet을 활용하여 Cursor 방식으로 처리했습니다. 하지만 여전히 문제가 있었습니다. 트랜잭션이 너무 길어 Undo Log가 폭발적으로 증가했고, 온라인 서비스가 “ORA-01555: snapshot too old” 에러를 뱉기 시작했습니다.
두 번째 개선 - 청크 처리
JDBC Connection을 직접 관리하면서 1만 건씩 나누어 처리하도록 변경했습니다. 각 청크는 별도 트랜잭션으로 처리하고, commit 후 새로운 Connection을 얻어 다음 청크를 처리했습니다. 하지만 5천만 건 처리 후 네트워크 장애로 배치가 중단되었고, 어디서부터 재처리해야 할지 알 수 없었습니다.
최종 해결책
결국 이렇게 설계했습니다:
- 파티션 기반 병렬 처리: 일별 파티션을 시간대별로 24개 청크로 분할
- 체크포인트 테이블: 각 청크의 처리 상태를 별도 테이블에 기록
- Thread Pool 활용: 4개 스레드로 병렬 처리, 각 스레드는 독립 트랜잭션
- 실패 복구 전략: 실패한 청크만 재처리, 3회 재시도 후 수동 처리 대상으로 분류
결과적으로:
- 처리 시간: 8시간 → 2시간
- 메모리 사용: 최대 32GB → 안정적인 4GB
- 장애 복구: 전체 재처리 → 실패 청크만 재처리 (평균 5분)
Thread와 트랜잭션 디버깅 팁
Thread 덤프 분석
Thread 배치가 멈춰있거나 종료되지 않을 때, Thread 덤프는 최고의 디버깅 도구입니다:
jstack <PID> > thread_dump.txt
kill -3 <PID> # 표준 출력으로 덤프
주목해야 할 패턴:
- BLOCKED 상태의 스레드들 - 데드락 가능성
- WAITING on monitor - 동기화 문제
- 많은 수의 TIMED_WAITING - 커넥션 풀 고갈 가능성
트랜잭션 모니터링
Oracle:
-- 현재 활성 트랜잭션과 Undo 사용량
SELECT s.sid, s.serial#, s.username, t.used_ublk, t.used_urec
FROM v$session s, v$transaction t
WHERE s.taddr = t.addr
ORDER BY t.used_ublk DESC;
-- 롤백 세그먼트 사용률
SELECT segment_name, status, tablespace_name,
bytes/1024/1024 as size_mb,
blocks, extents
FROM dba_rollback_segs;
MySQL:
-- InnoDB 트랜잭션 상태
SELECT * FROM information_schema.INNODB_TRX;
-- Undo Log 크기 확인
SELECT NAME, ALLOCATED_SIZE/1024/1024/1024 AS SIZE_GB
FROM INFORMATION_SCHEMA.INNODB_TABLESPACES
WHERE NAME LIKE '%undo%';
-- History List Length (MVCC 부하 지표)
SHOW ENGINE INNODB STATUS; -- TRANSACTIONS 섹션 확인
PostgreSQL:
-- 장시간 실행 중인 트랜잭션
SELECT pid, age(clock_timestamp(), query_start), query
FROM pg_stat_activity
WHERE state != 'idle'
AND query NOT ILIKE '%pg_stat_activity%'
ORDER BY query_start;
-- WAL 사용량
SELECT name, setting, unit
FROM pg_settings
WHERE name IN ('max_wal_size', 'min_wal_size', 'wal_keep_size');
추가 고려사항
Connection Pool 설정의 중요성
배치 작업에서 간과하기 쉬운 부분이 Connection Pool 설정입니다:
- validationQuery/testOnBorrow: 장시간 실행되는 배치에서는 필수입니다. DB 연결이 끊어진 것을 모르고 계속 처리하다가 커밋 시점에 실패할 수 있습니다.
- maxWait: 너무 짧으면 부하가 높을 때 연결을 얻지 못해 실패합니다. 배치는 온라인보다 여유있게 설정하세요.
- removeAbandoned: 배치 특성상 한 연결을 오래 사용할 수 있으므로 주의가 필요합니다.
Bulk Operation 활용
데이터베이스별로 제공하는 대량 처리 기능을 활용하면 성능이 크게 향상됩니다:
- JDBC Batch: PreparedStatement.addBatch()와 executeBatch() 활용
- MySQL LOAD DATA INFILE: CSV 파일로 대량 적재 시 INSERT 대비 20배 이상 빠름
- Oracle SQL*Loader: 외부 유틸리티지만 대용량 데이터 로드에 최적화
- PostgreSQL COPY: 가장 빠른 대량 데이터 입력 방법
Transaction Isolation Level 고려
배치 작업의 특성에 따라 적절한 격리 수준을 선택하세요:
- READ UNCOMMITTED: 정확도보다 속도가 중요한 통계성 배치
- READ COMMITTED: 대부분의 배치 작업에 적합
- REPEATABLE READ: 정합성이 중요한 정산 배치
- SERIALIZABLE: 거의 사용하지 않음, 성능 저하가 심각
맺는글
Spring Batch나 상용 배치 프레임워크가 좋은 건 압니다. 하지만 현실의 많은 시스템은 여전히 Thread를 extends한 배치 클래스들로 돌아갑니다. 이런 환경에서 안정적인 배치를 만들려면:
- Thread 생명주기를 이해하라: User Thread와 Daemon Thread의 차이는 기본 중의 기본입니다
- 트랜잭션을 작게 쪼개라: @Transactional 하나로 해결하려 하지 마세요
- 데이터베이스의 한계를 알아라: 각 DB의 롤백 메커니즘과 한계를 이해해야 합니다
- 모니터링을 생활화하라: Thread 덤프와 트랜잭션 모니터링은 필수입니다
- 실패를 전제로 설계하라: 체크포인트와 재처리 전략은 선택이 아닌 필수입니다
레거시 시스템이라고 무시하지 마세요. 그 안에는 15년간 쌓인 비즈니스 로직과 예외 처리가 담겨 있습니다. 새로운 기술도 좋지만, 기본기를 탄탄히 다지는 것이 더 중요합니다.
그리고 기억하세요. “배치는 새벽 3시에 터진다”는 법칙을. 미리미리 준비하세요.