다운로드 대기열 시스템 구축하기
들어가며
안녕하세요. Tokenomics Engineering 팀의 서명현입니다. 🤚
저희 Xangle ERP 에서는 가상자산에 대한 회계 처리를 돕기 위한 서비스로, 블록체인 상에서 읽어나는 거래 내역들을 다운받을 수 있습니다. 블록체인 상에서 일어나는 거래 내역들을 가져와야 하는 특성 상 시간이 걸리더라도 실패하지 않고 다운로드하는 것을 목표로 두고 개발했는데요.
트래픽이 낮을 때는 다운로드 요청이 들어오는대로 처리하면 되지만, 트래픽이 늘어나며 동시에 여러 작업을 처리하는 경우 서버 부하가 급격하게 증가하는 문제도 있었습니다. 많은 트래픽과 장시간의 작업에도 안정적으로 처리해야 하는 상황이라면 이번 글이 도움이 될 것 같습니다.
목차 소개
이번 글은 다음과 같은 순서로 진행됩니다.
문제 상황
이자, 요구사항인데요.
📢 어느 정도의 트래픽이 발생할지 예상이 전혀 안된다. 몇 번을 요청하든 얼마나 많은 데이터의 양을 요청하든 실패하지 않고 끝까지 처리하는게 중요하다.
당시 저희가 내놓는 Xangle ERP 신규 서비스에 포함되는 기능이었기도 하고, 국내에 비교할만한 경쟁 서비스가 없었기 때문에 성능이나 확장성과 같은 비기능적 요구사항을 특정할 수 없었습니다.
하지만 사용자에게 잘 노출되는 곳에 기능이 위치하고, 대부분의 페이지에 포함되기 때문에 서비스 전반적으로 영향을 미칠 것을 고려하였습니다. 따라서 안정성을 강화한 아키텍처 설계가 필요하다고 판단했습니다.
이로써 안정성을 위협할만한, 작업이 중단될 수 있는 몇가지 문제 상황을 떠올릴 수 있었는데요.
- 요청이 몰리는 경우 서버 부하 없이 다운로드 처리하기
- 다운로드 실패하지 않고 안전하게 완료하기
이 두 가지를 중심으로 다운로드 기능을 어떻게 설계했는지 설명드리겠습니다.
요청이 몰리는 경우 서버 부하 없이 다운로드 처리하기
다운로드 요청이 갑자기 늘어난다면 어떻게 처리할까요?
이런 질문을 받는다면
- 캐싱, JPA 쿼리 최적화
- 비동기 처리
- 스레드 풀 조정
- 요청을 여러 대의 서버로 분산
이렇게 Application 개발과 리소스 관점에서 여러 키워드가 떠오르는데요. 저도 처음에는 캐싱/쿼리 최적화에 초점을 두었다가, 실시간으로 반영되어야하는 거래 내역과 많은 필터링 조건을 반영해야 하는 다운로드 특성상 단순히 다운로드 소요시간을 줄이는데는 한계가 있음을 깨닫고, 다른 시각으로 접근하고자 했습니다.
여기서 잠깐! 요청이 들어오는대로 다운로드 처리해도 되지 않아요?
가능합니다. 스프링 프레임워크는 다중 요청을 처리하기 위해 자바의 멀티 스레딩을 사용하고 있습니다. 프로그램 실행에 필요한 스레드를 미리 생성해놓고 요청이 들어오면 하나씩 스레드를 할당하기 때문에 여러 작업을 동시에 처리할 수 있는데요.
하지만 스레드 최대 사이즈를 넘는 요청과 함께 작업큐도 꽉 차게 된다면 어떻게 될까요? 너무 많은 스레드를 생성해두는 것도 어플리케이션 성능에 큰 영향을 줄 수 있으니 지양해야 합니다. 따라서 멀티스레드에 의존하지 않고 여러 요청이 들어올 경우 어떤 기준에 따라 나눠서 처리해야 할 필요가 있습니다.
다운로드 요청이 몰리는 경우를 식당의 점심시간과 비슷하다고 생각했는데요
점심시간 회사 주변의 식당에는 늘 손님으로 가득 차있어서 식당 앞에 줄을 서서 먹고 오는 경험을 많이 하셨을겁니다. 최대로 수용할 수 있는 인원이 정해져있기 때문에 손님이 꽉 차면 안의 손님이 나가고 대기하는 사람들이 차례대로 들어갈 수 있습니다.
이와 비슷한 아이디어로, Spring Framework에도 여러 태스크를 동시에 처리하기 위한 멀티 스레드와 최대 스레드 풀이 정해져 있고, 마찬가지로 이를 초과해서 들어온 다운로드 작업을 순서대로 처리해야하지 않을까? 말이죠.
그래서 “선착순 다운로드”라는 단어가 떠올랐습니다. 다운로드 서버라는 식당에 여러 태스크의 손님이 기다리고 있고, 완료하신 손님이 나가고 순서대로 입장하는 과정을 말이죠.
다시 정리하면,
다운로드 요청이 들어온 순서를 저장 (대기열 생성) → 처리 가능한 작업 수만큼 처리 → 작업이 완료되면 대기열의 작업 처리
* 반복
이 과정을 반복할 수 있는 아키텍처를 설계하고자 했습니다.
1) 대기열 구현 방법
제가 생각한 대기열 구현 방법은 2가지였습니다.
- 메세지 큐 처리
- Redis Queue 대기열 구현
위 두가지 방법 모두 구현해 보았는데요. 결론적으로 몇 가지 시행착오를 통해 Redis Queue 대기열을 직접 구현하는 것이 더 적합했습니다.
메세지 큐 방식도 좋아보이는데요?
저희 팀에 할당된 Kafka 리소스가 있었기 때문에 Kafka Consumer를 사용하여 구현해보았는데요.
다운로드 topic에 여러 consumer를 할당하여 병렬 처리가 가능하고, 무엇보다 작업이 성공적으로 처리되지 않은 경우 다시 처리하기 때문에 안정성이 높다는 장점이 있었습니다. 그러나 실시간 데이터 처리에 적합한 Kafka 특성 상 일정 주기로 리밸런싱이 발생하여 컨슈머 그룹을 조정하게 되고, 만약 리밸런싱 주기보다 길어지는 다운로드 요청이 들어오면? 무한으로 다운로드를 재시작하게 되고, 이런 메세지가 누적된다면 모든 다운로드 작업이 중단될 것입니다. 😯
2) Redis Queue 를 이용한 다운로드 대기열
따라서 저희는 단순 대기열이 필요하므로 Redis Queue로 직접 대기열을 구현하게 되었습니다.
왜 Redis Queue인가?
- 요청이 들어온 순서대로 처리할 수 있습니다.
- RDB/NoSQL을 사용해서 정렬하기보다는 Redis를 사용하는게 성능적, 자원 효율성 측면에서 유리합니다.
- 대기열 순서와 필터링 조건은 영구적인 저장이 필요하지 않습니다.
- 인메모리 데이터베이스를 사용하는게 자원 효율성 측면에서 유리합니다.
요청된 다운로드 작업을 관리하는 대기열(Waiting Queue) 을 구성하고, 처리는 먼저 들어온 순으로 진행합니다.
다운로드 순서와 작업 정보를 저장했으니 이제 클라이언트는 처리가 완료될 때까지 기다리지 않아도 됩니다. 요청이 들어오면 대기열에 저장하고 바로 리턴 후, 백그라운드에서 처리할 수 있으니까 말이죠.
마치 식당으로 치면 대기자 명단이 추가되었다고 볼 수 있을 것 같습니다. 식당에 들어가기 전까지 다른 행동을 해도 되니까 말이죠.
이처럼 다운로드 대기열에 저장 후 요청을 바로 반환하고, 백그라운드 작업을 위해 5초에 한번씩 스케줄링 작업으로 대기열을 확인합니다. 대기 중인 작업이 존재하면 다운로드 작업을 시작합니다. 다운로드 작업이 빈번하게 일어난다면 좀 더 짧은 주기로 조절하면 됩니다.
여기서 잠깐! 스케줄링 작업은 단일 스레드에서 일어나는걸 아시나요?
Spring Framework에서 기본적으로 제공하는 스케줄러는 단일 스레드에서 순차적으로 처리합니다. 이전 스케줄링 작업이 완료되어야만 다음 작업이 시작할 수 있습니다. 따라서 동시에 여러 다운로드를 수행하기 위해선 별도의 스레드에서 처리할 수 있도록 비동기 처리가 필요합니다.
@Async
@Scheduled(fixedRate = 5000)
public void processDownloadTask() {
// 다운로드 대기열 확인
// 다운로드 작업 처리
}
여기까지 시퀀스 다이어그램은 다음과 같습니다.
다운로드 실패하지 않고 안전하게 완료하기
3) 실패한 다운로드 작업 다운로드 처리열로 재시작하기
다운로드 서버에서는 이따금 실패하는 작업들이 발생했는데요. 이는 배포로 인해 진행 중인 다운로드 작업이 실패한 경우에 발생함을 인지할 수 있었습니다. 처리 중인 작업을 안전하게 종료할 수 있는 graceful shutdown이 설정되어 있지만, 최대 대기 시간 안에 끝나지 않으면 강제 종료하기 때문에 실패하는 케이스가 있었습니다.
따라서 진행 중이던 작업들을 다시 처리할 수 있어야 한다고 생각했습니다.
대기열에 있는 작업은 다운로드를 시작할 때 작업을 꺼내 대기열에서 삭제합니다. 다음 작업을 꺼낼 수 있도록 하기 위해서 말이죠. 따라서 진행 중인 작업들을 별도의 공간에 저장하고, 애플리케이션이 종료되기 전에 그 작업들을 대기열 맨 앞으로 옮긴다면, 애플리케이션이 재시작되더라도 작업을 다시 시작할 수 있을 것입니다.
이러한 다운로드 처리열을 Redis Set을 사용하여 구현했습니다.
왜 Redis Set인가?
- 다운로드 시작한 Task를 저장하고, 다운로드를 완료하면 삭제해아하기 때문에 빈번하게 특정 데이터의 삽입/수정이 필요합니다.
- 자료구조 중 Set은 요소의 추가/삭제에 대한 시간복잡도 O(1)을 가지고 있으며, 진행 중인 다운로드 작업은 순서가 없고 중복이 없기 때문에 적합합니다.
- 실패한 다운로드를 처리하기 위한 작업이므로 영구적인 저장이 필요하지 않습니다.
- 더하여, 대기열을 Redis로 처리하고 있으므로 같은 리소스 내에서 관리하는 것이 유지보수 측면에서 나을 것이라 판단했습니다.
이와 같은 이유로 다운로드가 시작하면 처리셋에 작업을 저장하고, 만약 서버가 종료되면 처리셋에 있던 작업들을 대기열 맨 앞으로 옮깁니다.
task1 ~ task5 까지 5개의 요청이 들어온다고 가정하고, 3개의 스레드에서 동시에 다운로드를 처리한다고 가정해보겠습니다. (편의상 대기열에 한번에 저장)
# 대기열 task1~task5 생성
127.0.0.1:6379> RPUSH waiting_queue task1 task2 task3 task4 task5
(integer) 5
127.0.0.1:6379> LRANGE waiting_queue 0 -1
1) "task1"
2) "task2"
3) "task3"
4) "task4"
5) "task5"
# task1~task3 다운로드 시작
127.0.0.1:6379> LPOP waiting_queue
"task1"
127.0.0.1:6379> SADD processing_set task1
(integer) 1
127.0.0.1:6379> LPOP waiting_queue
"task2"
127.0.0.1:6379> SADD processing_set task2
(integer) 1
127.0.0.1:6379> LPOP waiting_queue
"task3"
127.0.0.1:6379> SADD processing_set task3
(integer) 1
127.0.0.1:6379> LRANGE waiting_queue 0 -1
1) "task4"
2) "task5"
127.0.0.1:6379> SMEMBERS processing_set
1) "task1"
2) "task2"
3) "task3"
# task1 다운로드 완료
127.0.0.1:6379> SREM processing_set task1
(integer) 1
127.0.0.1:6379> SMEMBERS processing_set
1) "task2"
2) "task3"
# 서버 종료 (모두 조회해서 대기열 맨 앞에 요소 추가, 처리셋 초기화)
127.0.0.1:6379> SMEMBERS processing_set
1) "task2"
2) "task3"
127.0.0.1:6379> LPUSH waiting_queue task3 task2
(integer) 4
127.0.0.1:6379> SREM processing_set task2 task3
(integer) 2
127.0.0.1:6379> LRANGE waiting_queue 0 -1
1) "task2"
2) "task3"
3) "task4"
4) "task5"
127.0.0.1:6379> SMEMBERS processing_set
(empty array)
애플리케이션 종료 시에는 처리셋(processing_set)에 있는 모든 작업을 꺼내서 대기열(waiting_queue) 맨 앞에 추가합니다. 그럼 애플리케이션이 다시 시작하더라도 중단된 다운로드 작업을 다시 시작할 수 있을 것입니다.
이렇게 요청된 다운로드 작업과 순서를 관리하는 대기열(waiting_queue) 과 처리중인 다운로드 작업을 안전하게 재시작할 수 있도록 하는 처리열(Processing Set) 을 구성해 보았습니다.
Sequence Diagram
느낀 점
Tokenomics Engineering 팀에 합류해 처음으로 맡은 기능이었기에 시간을 더 투자할 수 있었는데요. 다운로드 대기열을 설계하고 스케줄링 작업을 통한 비동기 처리하기까지 단순한 것 같으면서도 여러 시행착오를 통해 대기열 시스템을 구축할 수 있었습니다.
이렇게 요청이 몰리는 경우 다운로드를 안전하게 끝까지 완료할 수 있는 대기열 시스템 마무리하겠습니다. Xangle ERP는 가상자산 내역의 안전한 다운로드를 위해 힘쓰고 있다는 사실을 기억해주세요!