티스토리 뷰

24시간마다 6개월 이상 지난 데이터를 삭제하는 spring batch job을 개발하여 실행중에 있었다. 이 배치가 며칠전부터 오류가 나기 시작했다. 상황을 파악해보니 60초로 설정해둔 DB 타임아웃에 걸려 spring 쪽에서는 아래와 같은 에러와 함께 job이 취소된 것이었다.

Cause: cubrid.jdbc.driver.CUBRIDException: Has been interrupted.

삭제 대상이 대략 25만 row 정도 되고 전체 데이터가 3600만건 정도 되는 데이터였는데, 예상했던 것 보다 많은 데이터였고 예상하지 못한 시간이 걸리는 작업이었다. 처음 개발할 때만해도 데이터가 얼마 쌓이지 않고 삭제도 1~2초 안에 작업이 완료되는 것을 보고 별 문제가 없을 것이라고 생각한 것이 문제였다.

더불어 원인 파악을 위해 해당 job을 두 세 번 다시 실행시키면서 다른 팀에서 DB에 접근이 안되어 그 쪽에서도 오류를 발생시키고 말았다. 그 팀에서는 우리 DB에 데이터를 insert해주는 작업을 처리하는 배치를 관리 중이었는데 에러가 몇 번 발생하자 메일을 보냈던 것이다.

org.springframework.jdbc.UncategorizedSQLException

우선 첫 번째 문제는 delete 작업 시 실제 물리 데이터를 삭제하고 인덱스를 다시 생성하는데 걸리는 시간 때문이었다고 결론을 내렸다. 이미 삭제된 데이터야 확인이 불가능하니 이후 데이터를 확인해보았고, 점차적으로 증가 추가였던 것을 확인할 수 있었다. 이에 25만건 정도의 데이터가 처리시간이 60초가 넘게 걸리는 임계치였던 것으로 추정할 수 있었다. 이는 DB의 성능 문제를 떠나 적절하게 부하를 분산할 수 있도록 코딩을 하지 않았던 안일함이 오류를 불러일으켰다고 생각한다.

사실 신기한 점은 두 번째 문제였다. 왜 며칠 동안 발생하지 않던 문제가 내가 직접 job을 실행시켰을 때 발생한 것일까? 이 부분은 CUBRID의 메뉴얼을 보고서야 문제점을 짐작할 수 있었다. CUBRID에서는 Granularity Locking 이라는 기법 (한글매뉴얼)을 사용하는데, 간단히 설명하면 다음과 같다. UPDATE나 DELETE와 같은 작업을 수행할 때 WHERE절을 사용한다면 검색된 row에 대해서만 lock을 잡고 작업을 수행한다. 그러나 검색 결과의 수가 특정 임계치를 초과하는 row 수가 나오면 테이블단위의 lock을 잡고 작업을 수행하는 방식이다. 만약 특정 임계치 이상의 테이블에 대해 lock이 필요하다면 database 자체에 lock을 걸 수도 있다. 상황에 따라 적절히 상위 lock으로 갈아타는 방식인데, 이는 lock 관리 비용 때문에 적용되었다고 한다. row 단위로 lock을 설정&해제하게 되면 lock overhead가 높아지지만 대신 트랜잭션 동시성이 향상된다. 그러나 어떤 트랜잭션이 너무 많은 row를 대상으로 작업하는 경우 row 단위 lock에 대한 overhead가 너무 높아지기 때문에 차라리 테이블에 대한 lock을 설정하는 것이다. CUBRID의 동시성 잠금 파라미터 매뉴얼에 따르면, lock_escalation이라는 값이 행에 대한 lock을 테이블 lock으로 확대할지에 대해 결정하는 파라미터이며, 기본값은 100,000 이라고 한다. DB인스턴스가 올라가있는 서버는 접근권한이 없어 자세히 파악할 수는 없었지만, 예상컨데 3일 동안 쌓인 row 수가 table lock을 발생시켰다고 볼 수 있을 것 같다.

해결책은 where절에 limit을 설정해서 여러 번 트랜잭션을 나누어서 작업하는 방법으로 결정했다. 데이터 입력 날짜가 지정되어 있고 과거의 데이터가 변하지는 않을테니 삭제 대상 count를 알아와서 대략 트랜잭션 1회 당 10,000~100,000건 정도가 되도록 하고 loop를 돌며 삭제 작업을 처리해주면 될 것 같다. 중간중간 sleep도 걸어주고. 임시 테이블을 만들어 보존할 데이터를 옮기고 기존 테이블을 drop시킨 뒤 임시 테이블을 rename해주는 방법 등도 있겠으나, 공수도 적게 들고 확실하게 각 트랜잭션 타임을 줄일 수 있는 방법으로 해결하고자 전자를 택하기로 했다.

스프링을 쓰고 있다면 문제는 간단히 해결된다.

@Transactional(value = "myTransactionManager", propagation = Propagation.NEVER)

우선 위와 같이 @Transactional 어노테이션을 지정해주며 명시적으로 트랜잭션을 사용하지 않겠다고 선언해둔다. 'propagation = Propagation.NEVER'라고 지정해두면 트랜잭션을 사용하지 않음으로써 DB작업이 1회 일어날 때마다 자동으로 커밋이 완료된다. 프로젝트에서 트랜잭션 매니저를 두 개 이상 사용하고 있다면 트랜잭션 매니저의 이름도 명시해 준다.

int loopCount = (int)Math.ceil((double)targetCount / (double)limitCountPerOneTransaction);

우선 삭제 대상 갯수를 가져오고, 트랜잭션 1회 당 삭제할 갯수로 나누면 loop를 몇 회 할지 결정할 수 있다. int끼리 잘못 나누면 loopCount가 0이 나오는 경우가 있으니 유의.

for(int i=0; i < loopCount; i++) {
    mydao.delete(basisDate, limitCountPerOneTransaction);
    Thread.sleep(1000);
}

loop 안에서 삭제 처리 후 다른 트랜잭션을 배려하는 차원에서 1초 정도씩은 쉬어주도록 했다. 우선 테스트 DB에 100만건의 삭제 대상 데이터를 입력하고 테스트 해보았을 때는 문제 없이 삭제가 가능했다. 이후 실DB에서 트랜잭션 1회 당 10,000개의 row를 삭제하도록 설정하고 실행해봤을 때 3600만 건의 데이터 중 100만건의 데이터 삭제 작업도 문제없이 완료되는 것을 확인할 수 있었다.

저작자 표시 비영리 동일 조건 변경 허락
신고
댓글
댓글쓰기 폼