배치 처리를 설계할때의 유의사항 - 실패에 대한 사이드 이펙트를 고려하자
안녕하세요, 졸린개발자입니다.
오늘은 배치 처리를 설계할때의 유의사항에 대해 알아보도록 하죠!
배치 처리란?
배치 처리란 무엇일까요?

간단히 얘기해서,
주기적으로 대량의 데이터 처리를 한꺼번에 하는것을, 배치 처리라고 합니다.
예를 들어봅시다.
매일 오전 10시마다, 모든 예약된 주문을, 완료 처리를 해야한다는 요구사항이 있다고 가정해봅시다.
그러면 오전 10시에, 대량의 예약된 주문을 조회해서
완료 처리를 해야겠죠.
이런 식으로, 주기적으로 대량의 반복적인 데이터 작업을 하는 방식이
바로 배치 처리입니다.
예제 소개 - 예약된 주문을 완료하기
매일 오전 10시마다, 모든 예약된 주문을, 완료 처리를 해야한다는 요구사항이 있다고 가정해봅시다.
아래처럼 코드를 작성할 수 있을겁니다. (pseudo code)
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
주문_완료_처리 (order);
}
}
function 주문_완료_처리(order) {
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
여기서 만약 트랜잭션 범위가 아래처럼 되면 어떻게 될까요?
("예약된_주문_완료_처리" 가 트랜잭션 범위입니다.)
@트랜잭션
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
주문_완료_처리 (order);
}
}
function 주문_완료_처리(order) {
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
발생할 수 있는 문제는 네가지입니다.
1. 주문 하나의 실패가, 모든 주문의 롤백을 발생시킴
2. 앞주문의 실패가, 뒷주문의 처리를 하지 못하게 됨
3. 트랜잭션이 너무 길게 잡혀있을 수 있음
4. 다른 트랜잭션에서 조회한 Entity가 넘어옴 (Java/Jpa 한정 이슈)
문제1: 주문 하나의 실패가, 모든 주문의 롤백을 발생시킴
@트랜잭션
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
주문_완료_처리 (order);
}
}
function 주문_완료_처리(order) {
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
만약, 예약된 주문이 2개가 있다고 가정해봅시다
(주문1, 주문2)
주문1은 잘 처리했고
주문2를 처리하다가,
만약 버그로 인해 NPE가 발생한다면 어떻게 될까요?
트랜잭션이, "예약된_주문_완료_처리"에 있기때문에
잘 처리된 주문1까지 롤백이 되겠죠?
이런 상황을 예방하려면 어떻게 해야할까요?
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
주문_완료_처리 (order);
}
}
@트랜잭션
function 주문_완료_처리(order) {
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
간단합니다. 주문 하나의 완료처리에 트랜잭션을 걸면 되겠죠.
("주문_완료_처리" 로 트랜잭션 옮김)
문제2: 앞주문의 실패가, 뒷주문의 처리를 하지 못하게 됨
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
주문_완료_처리 (order);
}
}
@트랜잭션
function 주문_완료_처리(order) {
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
위의 코드에서, 만약 아래 상황이면 어떨까요?
(주문1, 주문2) 가 있는 상황에서,
주문1을 처리하다가 Exception이 발생하는 케이스입니다.
주문2는 처리가 될 수 있었을 것이라고 가정하면
정상적으로 처리가 되었어야했음에도 불구하고,
주문1에서 Exception으로 인해, 처리가 안되었겠죠.
이를 해결해봅시다.
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
try {
주문_완료_처리 (order);
} catch (Exception e) {
log("에러 발생", e);
}
}
}
@트랜잭션
function 주문_완료_처리(order) {
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
위처럼 주문 하나의 처리에 try/catch 문을 작성하면,
주문 하나가 실패해도 나머지 주문은 영향을 받지 않겠죠?
또 다른 문제 케이스를 찾아가봅시다.
문제3: 트랜잭션이 너무 길게 잡혀있을 수 있음
사실 이 케이스는 문제1에서도 해결이 되었습니다.
하지만, 다른 문제가 발생할 수도 있음을 얘기해드리고 싶어, 최초 코드를 다시 가져오겠습니다.
@트랜잭션
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
주문_완료_처리 (order);
}
}
function 주문_완료_처리(order) {
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
여기서 발생할 수 있는 추가적인 문제는,
트랜잭션이 너무 길게 잡혀있다는 것입니다.
모든 예약된 주문이 하나의 트랜잭션에 잡힐 수 있겠죠?
그러면 예약된 주문을 수정하고 싶을때는 어떻게 될까요?
상황에 따라 다르겠지만, 최악의 케이스에서는
이 배치가 끝날때까지 예약된 주문을 수정할 수 없을껍니다.
이 문제를 해결하는 방법은, 문제 1과 같습니다.
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
주문_완료_처리 (order);
}
}
@트랜잭션
function 주문_완료_처리(order) {
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
트랜잭션을 주문마다 분리하면,
모든 예약된 주문이 하나의 트랜잭션에 묶이지 않게 되겠죠.
대부분은 이 정도면 해결이 되었을겁니다.
하지만 Java의 Jpa를 사용한다면, 아직 하나 주의사항이 남아있습니다.
문제4: 다른 트랜잭션에서 조회한 Entity가 넘어옴 (Java/Jpa 한정 이슈)
아래 코드에서 order가 Entity라면 어떨까요?
다른 트랜잭션에서 조회한 Entity가, "주문_완료_처리" 메서드로 넘어오게 될 겁니다.
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
try {
주문_완료_처리 (order);
} catch (Exception e) {
log("에러 발생", e);
}
}
}
@트랜잭션
function 주문_완료_처리(order) {
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
그러면 "주문_완료_처리"에서 새롭게 시작한 트랜잭션의
영속성 컨텍스트는, order에 대해서 모르고 있겠죠?
이 상태에서, 만약 Lazy Loading을 하게 되면 어떻게 될까요?
에러를 발생시킬겁니다.
이 상황을 타계하려면 어떤 방법을 사용할 수 있을까요?
방법1 - 새로운 트랜잭션에 id를 넘겨서, 새롭게 조회하게 한다.
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
try {
주문_완료_처리 (order.id);
} catch (Exception e) {
log("에러 발생", e);
}
}
}
@트랜잭션
function 주문_완료_처리(orderId) {
// order 조회
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
방법2 - merge를 한다
@오전_10시마다
function 예약된_주문_완료_처리() {
var orders = 예약된_주문_조회();
for(order: orders) {
try {
주문_완료_처리 (order);
} catch (Exception e) {
log("에러 발생", e);
}
}
}
@트랜잭션
function 주문_완료_처리(order) {
repository.merge(order);
// 엄청나게 복잡한 주문 관련 정책 확인
// 주문 완료 처리
// 저장
}
두가지중에, 하나를 사용하면 될껍니다.
물론 방법2에서는, merge하는거니, 트랜잭션이 시작되기 전 변경사항이 날라갈 수 있음에 주의해야겠죠.
(따라서 실무에서는 널리 활용되지는 않는 방법입니다.)
각 상황에 맞추어 적절하게 해결을 하실 수 있을겁니다.
정리
오늘은 배치 처리를 설계함에 있어서 유의사항에 대해 알아보았습니다.
총 4가지가 있었죠.
1. 주문 하나의 실패가, 모든 주문의 롤백을 발생시킴
2. 앞주문의 실패가, 뒷주문의 처리를 하지 못하게 됨
3. 트랜잭션이 너무 길게 잡혀있을 수 있음
4. 다른 트랜잭션에서 조회한 Entity가 넘어옴 (Java/Jpa 한정 이슈)
배치에서는 수~많은 데이터를 가져오게 되는데
데이터 처리 하나가, 다른 데이터 처리에 영향을 미치지 않게 처리가 되어야 합니다.
그렇지 않다면, 100만개중에 1개만 실패해도
나머지 것들이 영향을 받게 되겠죠.
(물론 이렇게 해야하는 니즈면, 일부러 트랜잭션의 범위를 넓혀야겠죠)
따라서, 배치를 설계할때에는
항상 실패에 대한 사이드 이펙트를 어디까지 제한할 것인가를 잘 고려해서
설계하시는 것이 중요합니다.
긴 글 읽어주셔서 감사합니다.
P.S
보통 배치처리를 할때에는 청크 단위로 나눠서 하실껍니다.
하지만 이 글에서는, 예제의 단순함을 위해 청크 단위로 나누지 않았음을 밝힙니다.
썸네일 사진: Unsplash 의Thibault Mokuenko