Backend

대용량 데이터 처리 배치 만들기

mopil 2024. 5. 6. 16:54
반응형

필자가 회사에서 다루는 데이터는 주로 고객 데이터로, 1000만 건 정도 된다.

 

무거운 로직을 전체고객에 대해서 수행하거나, 주기적으로 수행되는 배치의 성능을 개선하기 위해서 시도했던 것들과 적용하는 노하우 몇 가지를 정리한다.

 

 

# 배치 프로그램에서 챙겨야 할 것들

일반적으로 필자가 배치 프로그램을 만들 때 중요하게 고려하는 건 다음과 같다.

 

1. 모수의 사이즈

2. 수행 시간이 얼마나 걸리는지

3. 멱등성과 실패 시 재처리 방법

 

 

모수의 사이즈

모수의 사이즈는 배치 수행시간과 직접적으로 연결되므로 매우 중요하게 고려해야 한다. 또한 트랜잭션을 너무 오래 물고 있지 않도록 고려해야 하므로 모수 측정은 반드시 선행되어야 한다.

 

 

수행 시간이 얼마나 걸리는지

필자 기준 1시간이 넘어가면 오래 걸린다고 판단하여 성능 개선을 시도한다.

 

멱등성과 실패 시 재처리 방

멱등성이란 배치를 여러 번 실행했을 때도 동일한 결과가 나오는지에 대한 기준이다. 멱등성이 보장되는 배치를 작성하도록 노력하는 것이 좋다.

 

실패 시 재처리 방법도 고려해야 한다. 모수가 큰 경우, 처음부터 다시 돌리는 건 비효율 적이므로 청크 기반 프로세싱이나 트랜잭션을 나눠서 구성하는 방법을 고려해야한다.

 


앞서 소개한 3가지 주제와는 별개로 기술 관련 이야기를 먼저 하고자 한다.

 

필자는 업무에서 스프링 프레임워크를 주로 사용하지만, 스프링 배치 라이브러리는 사용하지 않는다. 대신 사내 구축되어 있는 k8s 배치를 많이 활용한다. (스프링 빈을 띄워서 별도 팟을 수행하는 형식)

 

스프링 배치 라이브러리가 제공하는 편의 기능들 (청크 프로세싱 등)은 레포에 이미 구현되어 있어서 따로 도입하지는 않았다. 이 점을 참고해서 보면 좋을 것이다.

 

주 기술 스택

- 스프링 부트, Kotlin

- JPA, Mybatis, JDBC template

 

# 모수 사이즈 기반 하여 기술 선택하기

배치 프로그램에서 첫 번째 단계인 read 단계를 효율적으로 하기 위해서 몇 가지 기술을 선택적으로 채택한다.

 

 

JPA

일반적으로 대용량 배치 프로그램을 만들 때 JPA는 사용하지 않는다. JPA는 배치 프로그램 특화로 설계되었지 않기 때문에 불필요한 query가 많이 발생하여 성능 저하의 주요 원인이 되기 때문이다.

 

필자는 모수가 그리 크지 않아서 10분 이내로 끝날 수 있는 배치 한해서는 쉽게 구현할 수 있기에 JPA를 사용하여 read를 구현한다.

 

 

Mybatis / JDBC

Mybatis 환경 세팅이 되어있다면 Mybatis를 사용해도 좋다. 보통 JDBC Template이나 NamedJdbcTemplate 객체를 사용한다.

 

두 기술 모두 Native query를 사용하여 배치 성능을 향상시킨다. 이는 read 연산뿐 아니라 write 연산 파트에서도 좋은 성능 향상을 가져올 수 있다.

 

단점으로는 type-safe 한 코드를 작성할 수 없다는 점이다. (쿼리에 오류가 있어도 컴파일 시점엔 알 수 없다.)

 

성능 향상을 위한 trade-off라고 생각한다.

 

 


 

# 데이터 읽기 (Read)

기술을 선택했다면, 배치를 수행할 모수를 읽을 read 연산을 구현한다.

 

보통 select 쿼리로 바로 읽어서 사용하는데 여기서 몇 가지 성능 향상과 배치 프로그램 안전성 향상을 위한 기법들이 있으니 소개한다.

 

페이징 활용하기

리스트에 데이터를 담을 땐 1000건 단위로 페이징 처리해서 담는 게 좋다. 이는 너무 대용량이면 배치 프로그램 자체가 OOM이 나서 죽어버릴 수 있기 때문이다.

 

페이징은 페이지 기반 페이징과(일반 페이징) 커서 기반 페이징 두 가지를 선택할 수 있는데, 커서 기반 페이징이 성능이 훨씬 좋다.

 

 

모수 쪼개기

페이징을 쓰더라도 배치의 모수가 큰 경우는 한 배치 사이클마다 모수를 쪼개는 방법도 있다.

 

예를 들어 1000만 건의 고객 데이터를 모두 배치를 돌려야 할 때, 100만 건씩 배치를 10번 수행하는 것이다.

 

이때는 데이터의 PK나 식별값으로 나누는데, 나누기 연산을 이용하면 나름 고르게 분산시킬 수 있다.

 

ex) 1씩 증가하는 고객번호가 키라면, 5등분 하기 위해서 나머지 연산을 통해 고객번호 % 5 == 0 -> 첫 번째 수행 그룹, 고객번호 % 5 == 1 -> 두 번째 수행 그룹... 이런 식으로 나눌 수 있다.

 

 

모수를 이런식으로 쪼개면 멱등성은 잃어버리지만, 배치 수행시간을 단축시켜 안정성을 높일 수 있다.

 

나눠서 수행하기 위해서는 현재까지 어떤 상태까지 배치를 수행했는지 (ex. 몇 번째 고객번호까지 수행했는지)를 알고 있어야 하므로 주로 캐싱을 사용한다.

 

 

코루틴 활용

코루틴을 활용해서 데이터를 읽을 때 병렬로 읽는 것도 좋은 방법이다.

 

커넥션 풀 사이즈를 고려하여 적절한 코루틴 개수를 채택한다.

 

필자는 커넥션 풀 20개 기준 5~10개 정도로 사용한다. 

 

코루틴을 사용하면 성능이 대폭 향상되지만, 그만큼 프로그램의 복잡도가 증가하게 된다.

 


# 데이터 쓰기 (Write)

읽은 데이터를 메인 로직을 수행하는 부분이다. 

 

 

트랜잭션 쪼개기

많은 양의 데이터일수록 트랜잭션을 오래 물고 있는 것은 좋지 않다. 1000건, 100건마다 커밋을 해서 query를 반영해 주는 것이 안정적이다.

 

비즈니스 로직이 복잡하고 무겁더라면, 단건 트랜잭션으로 처리해도 좋다.

 

트랜잭션을 쪼개면 멱등성이 없어지므로 이 점도 고려한다.

(ex. 1000건 중 200건을 수행하고 201건에서 예외가 발생하면 200건은 수행 완료된 상태로 롤백되지 않는다.)

 

 

UPSERT/MERGE 구문 활용

이를 지원하는 디비에서 해당 쿼리를 사용하면 데이터 처리 속도를 대폭 향상시킬 수 있다.

 

다만, 실시간성 데이터를 받는 테이블인 경우는 사용을 절대 하면 안 된다.

 

배치가 수행되는 시간에는 락이 걸릴 수 있기 때문에 자칫 잘못하면 서비스 장애로 이어질 수 있기 때문이다.

 

 

코루틴 활용

읽기와 비슷하게 쓰기도 코루틴을 적용하여 성능을 대폭 향상시킬 수 있다.

 

대신 읽기 때 보다 동시성 이슈를 고려해야 한다. 서로 다른 키를 가지는 그룹을 코루틴(스레드)에 할당하여 처리하도록 하는 것이 안정적이다.

 

만약 서로 다른 코루틴이 같은 데이터를 처리하려 하면 데드락이 발생할 수 있으므로 이 점을 특히 주의한다.

 

Note That

  • JDBC는 블로킹 I/O이므로 코루틴을 열어도 내부적으로 스레드가 할당된다. (즉, 멀티스레딩으로 처리하는 것과 동일)
  • 병렬처리 시 외부 API를 호출하면 유관 부서에, DBA에게 부하 측면으로 괜찮은지 공유 및 논의를 구하는 것이 좋다.

 


# 멱등성과 재처리

최대한 배치 프로그램도 멱등하게 설계하는것이 좋지만, 앞서 소개한 성능 개선을 위해 적절히 trade-off 하는 것도 중요한 것 같다.

 

멱등하게 구현하고 싶다면, 트랜잭션을 길게 잡아서 예외 발생 시 모두 롤백되게 하거나 새로운 데이터를 생성하는 경우는 배치 수행 때마다 일괄 삭제 기능을 추가하면 된다.

 

 

재처리는 실패 시 요란하게 노티를 줘서 개발자가 빠르게 대응을 할 수 있도록 구현하는 게 좋다. (ex. 슬랙 알림, 이메일 알림)

 

청크 기반 프로세싱으로 구현한 경우 어느 청크나 어느 데이터까지 수행했는지 기억(캐싱) 하고 있는 것도 좋다.

반응형