본문 바로가기
Coding/시스템 디자인

토스 SLASH 23 - 실시간 시세 데이터 안전하고 빠르게 처리하기 정리

by Hide­ 2024. 8. 13.
반응형

아키텍처

국내 시세의 경우 한국거래소에서 실시간으로 제공해주는 전문 형식의 데이터를 통해 체결가, 호가뿐 아니라 거래정지, 공매도, 외국인 투자 현황을 표시한다. 

시세 플랫폼은 거래소 데이터를 제일 먼저 수신하고 가공한 뒤 내부 서비스들에게 제공한다. 내부 서비스들은 가공된 데이터 중 각자 필요한 정보를 실시간 또는 API를 통해 얻게 된다. 시세 플랫폼은 단순히 전문을 디코딩 및 전달만 하는게 아니라 주식 차트처럼 가공 데이터를 누적하거나 여러정보를 합성하여 제공하기도 한다. 그리고 이러한 시세 플랫폼은 낮은 지연시간과 빠른 장애복구를 최우선 목표로 삼고 있다.

시세 플랫폼은 총 3가지 파트로 구성되어있다. 먼저 수신부는 거래소가 제공하는 시세 데이터를 UDP 멀티캐스트 그룹에 접속하여 읽어오는 일을 한다. 그리고 처리부에게 데이터를 전송할 때 수신 시각을 헤더에 포함하여 처리부에서 총 처리시간을 측정하는데 사용한다.

처리부는 비즈니스 로직이 모여있는 곳으로 처리결과를 레디스에 저장하거나 실시간 정보를 서비스들에게 바로 전달한다. 비즈니스 로직중에는 I/O 블락킹이 있기 때문에 처리시간에 가장 많은 영향을 주는 곳이다.

마지막으로 조회부는 REST API를 서비스들에게 제공한다. 이 중 처리부는 코드 변경이 가장 빈번하게 발생하기 때문에 장애 발생확률이 높다.

장애 대응

처리부에 장애가 발생하면 시세 데이터를 사용하는 모든 서비스에 영향을 주게 된다. 중요한점은 처리부의 장애를 복구하더라도 API를 사용하는 서비스들은 여전히 장애를 겪게 된다는 점이다. 장애 시간동안 데이터가 유실되거나 오염되었을 수 있기 때문이다. 건수가 적다면 수작업 보정이 가능하겠지만 초당 수천건의 데이터를 다루기에 찰나의 장애에도 많은 데이터 오류가 발생한다.

이를 해결하기 위해 처리부와 레디스를 총 2개의 그룹으로 만들었다. 평소에는 A그룹만 서비스에 할당하다가 장애가 발생하는 경우 B그룹으로 전환한다. 오염된 데이터를 장중에 복구하기보다는 문제가 없는 데이터베이스를 빠르게 사용하는 것이다. 이러한 구조를 통해 처리부 A와 B를 번갈아 배포하며 둘 간의 차이를 비교할수도 있고 배포에 문제가 있다면 트래픽 전환만으로 빠르게 롤백할 수 있는 장점을 얻을 수 있었다.

또한 갑작스러운 시스템 장애를 대비하여 각 그룹(A, B)에 처리부를 여러개로 늘리고 주키퍼를 통해 리더를 선출하도록 했다. 이 경우 중복 데이터가 발생하는것을 막기 위해 오직 각 그룹의 리더만 데이터를 처리하도록 설계했다. 

메시지 브로커

수신부는 처리부의 개수만큼 반복해서 데이터를 보내야하고 앞으로도 처리부는 늘어날 가능성이 존재하였기에 앞단에 메시지 브로커를 두었다. 이를 통해 수신부-처리부 간의 의존성을 없앴고 수신부는 데이터를 한번만 전송해도 되는 이점을 얻었다. 하지만 시세 플랫폼에서 가장 중요한것은 "낮은 시간지연"인데 메시지 브로커로 인해 지연시간이 늘어나게 된다.

UDP는 TCP보다 빠르기 때문에 비디오 스트리밍이나 게임에서 많이 사용하는 프로토콜이다. 하지만 UDP 멀티캐스트를 위해 라우트 설정과 쿠버네티스 배포 설정이 필요했기 때문에 빠른 개발을 위해 선택하지 않았다.

카프카는 높은 처리량과 기존에 사내에서 활발하게 사용중이었기에 개발 초기에는 카프카를 사용했었다. 하지만 자체 테스트 결과 다음 후보인 레디스 Pub/Sub보다 지연시간이 길어 마찬가지로 선택하지 않았다.

보통 대부분의 메시지 브로커들은 메시지를 받으면 내부 큐에 저장하는데 비해 레디스 펍섭은 메시지를 받는 즉시 채널에 등록되어있는 구독자들에게 보내버린다. 만약 구독자가 없다면 그냥 데이터를 버리기때문에 데이터가 유실될 가능성은 있지만 지연시간을 줄이는 면에서 보면 유리하다. 

레디스 펍섭안에는 pubsub_channel이라는 딕셔너리 타입의 변수가 있는데 이곳에 모든 채널과 구독자 정보를 보관한다. 구독자가 특정 채널을 구독하면 그 채널에 해당하는 연결 리스트에 구독자 정보를 추가하고 발행자가 메시지를 보내면 딕셔너리에서 채널을 찾고 그 채널에 해당하는 연결 리스트를 모두 순회하면서 차례대로 구독자들에게 메시지를 전송하게 된다.

처리부 시스템

처리부는 TCP소켓으로부터 데이터를 읽고 비즈니스를 처리하는 일을 수행한다. 처리부는 레디스와 TCP연결을 맺은 후 소켓의 수신 버퍼로부터 데이터를 읽어간다. 이 때 중요한 점은 처리부가 데이터를 읽는 속도가 지연시간에 큰 영향을 준다는 점이다. 

TCP 흐름제어는 송신 속도가 수신 속도보다 빠를 경우 데이터 유실을 막기 위한 메커니즘으로 수신자는 소켓 버퍼를 기준으로 한 번에 받을 수 있는 양인 윈도우 사이즈를 송신자에게 전달해주고 송신자는 다음 차례에 윈도우 사이즈 만큼의 데이터만 보냄으로써 데이터 유실을 방지한다. 쉽게 말해 처리부의 소켓 수신 버퍼가 가득 차게 되면 더이상 레디스는 데이터를 보내지 않는다는 뜻이고, 데이터를 보내지않으니 지연시간이 늘어날 수 밖에 없다.

수신 버퍼를 읽는 스레드가 하는 일이 많아지면 당연히 읽는 속도도 떨어지게 된다. 따라서 비즈니스 처리는 반드시 별도 스레드에게 위임하는것이 좋고 비즈니스 로직중에 I/O 블락킹이 존재하는 경우 반드시 그렇게 해야한다.

발표자는 Spring Data Redis가 제공하는 ReactiveRedisTemplate을 사용했는데 Spring Data Redis는 기본적으로 Lettuce라는 클라이언트 라이브러리를 사용한다. Lettuce는 네트워크 라이브러리인 Netty를 사용하고 Netty의 Channel은 소켓을 추상화한 레이어로써 커넥션이 맺어진 이후 이벤트 루프에 등록된다. 여기서 이벤트 루프가 무한루프를 돌면서 수신 버퍼에 데이터를 읽는 역할을 한다. 

이벤트 루프는 운영체제에 따라 NIO, Epoll, KQueue등 여러 방식을 지원하는데 위 사진은 그 중 NIO 방식의 실행 코드이다. NIOEventLoop가 실행되면(run) 버퍼에 데이터가 있는지 확인하고(select) 데이터를 읽고(read) 변환하여(decode) 마지막으로 결과를 통보하는(notify)것을 확인할 수 있다. 그러므로 NIOEventLoop가 비즈니스 로직을 처리하지 않는 것이 중요하다.

비즈니스 처리에는 I/O 블락킹이 포함되어있기 때문에 처리 성능을 높이기 위해 위 사진과 같이 멀티스레딩을 사용해야한다. 그런데 이 경우 문제는 비즈니스 처리의 순서가 역전될 수 있다는 점이다. 예를 들어 삼성전자 체결가 전문이 매우 짧은 시간동안 여러개가 들어왔는데 처리순서가 바뀌게 되면 앱에는 엉뚱한 가격이 보여지게 된다. 이를 방지하기 위해 멀티스레딩 대신 EventLoopGroup을 사용했다.

이벤트 루프는 큐를 이용하여 순서를 보장하고 하나의 스레드만 사용하기 때문에 동기화가 필요없다는 장점이 있다. 이벤트 루프를 사용하더라도 어떤 이벤트 루프에서 처리해야 할지 알아야 순서를 보장할 수 있기 때문에 반드시 종목 코드를 미리 알아야 하고 이를 위해서는 JSON을 객체로 변환해야했다. 문제는 이 변환 작업이 트래픽 양이 증가함에 따라 NIOEventLoop의 CPU자원을 많이 사용하여 지연의 원인이 된다는 것이다.

이 문제는 레디스 펍섭에서 제공하는 채널을 통해 해결했다. 예를 들어 수신부가 데이터를 보낼 때 처리부의 이벤트 루프 개수만큼 채널을 나누어 보내고 처리부는 이 채널명을 보고 해당하는 이벤트 루프를 찾는 것이다.

이를 통해 수신부에서 발송한 데이터의 순서를 처리부에서 그대로 유지할 수 있고 무엇보다 NIOEventLoop에서 더이상 객체 변환을 하지 않아도 되기 때문에 성능도 올라가는것을 확인했다.

이벤트 루프

이벤트 루프는 스프링에서 제공하는 ThreadPoolTaskExecutor의 corePoolSize와 maxPoolSize를 1로 설정함으로써 쉽게 만들 수 있다. 큐가 꽉찰 경우를 위한 정책은 DiscardOldestPolicy를 사용했다. 가장 오래된 작업을 지운 후 새 작업을 넣는 방식으로 Circular Queue와 유사하다고 할 수 있다. 비록 데이터를 유실할 가능성은 있지만 실시간성이 중요하기에 가장 적합하다고 판단했다. 

큐의 크기를 의미하는 queueCapacity는 얼마가 적당한지 정답이 없다. 너무 작으면 유실될 수 있고 반대로 너무 크면 오래된 데이터가 큐에 적재되어 실시간성을 떨어트린다. 

EventLoopGroup은 ThreadPoolTaskExecutor를 여러개 생성하여 리스트 형태로 만들었다. 리스트 개수는 이벤트 루프의 개수를 의미하며 이 개수를 줄이기 위해 노력했다. 이벤트 루프의 개수가 늘어날수록 컨텍스트 스위칭으로 인해 NIOEventLoop의 성능에도 악영향을 주기 때문이다. 하지만 무조건 개수를 줄이면 이벤트 루프에 Backpressure가 발생해서 지연시간이 훨씬 늘어나게 된다. 따라서 이는 성능테스트를 통해 적정 개수를 찾아야 한다.

이벤트 루프 개수를 줄이기 위해

첫 번째로 레디스 저장 시 Non Blocking I/O 방식을 사용했다. 비즈니스 처리를 하는 이벤트 루프는 한 번에 한 작업만 처리한다. 이 작업의 마지막에는 레디스에 데이터를 저장하는 일이 반복되었는데 생각해보니 굳이 데이터가 저장되기까지 기다릴 필요가 없었다. 따라서 ReactiveRedisTemplate의 비동기 함수를 이용하여 이벤트 루프가 더이상 블락킹되지않고 다음 작업을 처리할 수 있도록 하였다. 

두 번째로 데이터 조회 시 로컬캐시를 사용했다. 비즈니스 로직 중 레디스에서 과거 데이터를 조회하는 경우가 많이 있었다. 조회는 비동기로 처리할 경우 순서가 역전될 수 있기 때문에 앞의 예제처럼 비동기를 적용할 수 없었다. 따라서 매번 레디스에서 데이터를 읽기보다는 로컬캐시에 먼저 데이터를 읽도록 함으로써 블락킹 I/O 횟수를 상당히 줄일 수 있었다.

마지막으로 무거운 작업을 위한 별도의 이벤트 루프 그룹을 만들었다. 배치 작업이나 MySQL 저장과 같은 상대적으로 무거운 작업은 기존의 이벤트 루프가 아닌 별도의 그룹에서 처리하도록 하였다. 이 방법은 이벤트 루프가 블락킹 당하는것을 방지함으로써 효율적인 처리를 가능하게 한다. 

그럼에도 높은 트래픽으로 처리량이 올라가지 않는 상황이 발생했고 netstat 명령어를 실행하여 실제 소켓의 수신 버퍼와 송신 버퍼를 눈으로 직접 확인했다. Recv-Q와 Send-Q는 해당 소켓이 얼마나 처리대기 상태로 남아있는지를 의미하는데 특히 Recv-Q가 0보다 지속적으로 크다면 다음과 같은 방법들을 시도해볼 수 있다.

첫 번째로 NIOEventLooop가 하는 일을 다시 한번 살펴봐야 한다. 데이터 변환, 객체 생성 혹은 로깅등과 같은 사소한 처리도 높은 트래픽 상황에서는 성능 저하의 원인이 된다. 앞에서 레디스 채널을 통해 데이터 디코딩을 분리한것과 같이 읽기 성능을 높이기 위해 작은 부분이라도 다른 스레드에게 위임해야한다. 

두 번째로 CPU 사용량을 확인해야 한다. CPU Profiling을 통해 프로세스 내에 불필요한 스레드를 찾아내어 제거하거나 개수를 줄이면 컨텍스트 스위칭이 줄어들게 된다. 예를 들어 기본적으로 설정되어있는 스프링의 Tomcat Thread 개수를 1로 줄일 수 있다.

세 번째로 NIOEventLoop를 여러개 사용한다. 이 방법은 NIOEventLoop 성능이 원하는 만큼 나오지 않을 때 개수를 늘려 병렬로 처리함으로써 소켓 버퍼읽기 속도를 올려줄 수 있다. NIOEventLoop의 개수는 ReactiveRedisTemplate을 여러 개 생성함으로써 늘려줄 수 있다. 

Reference

https://www.youtube.com/watch?v=SF7eqlL0mjw