반응형

시세 플랫폼이란?

시세 플랫폼은 실시간 시세 데이터를 안전하고 빠르게 처리해야합니다. 거래소 데이터를 받아 가공한 후 내부 서비스들에게 제공하거나 과거 데이터를 누적 또는 여러 정보를 합성하여 제공하기도 합니다.

그리고 최우선 목표로 낮은 지연시간과 빠른 장애복구가 있습니다.

시세 플랫폼 구성

시세 플랫폼은 총 3가지 파트로 구성되어 있습니다.

수신부는 거래소가 제공하는 시세 데이터를 UDP 멀티캐스트 그룹에 접속해서 읽어오는 일을 합니다. 그리고 처리부에게 데이터를 전송할 때 수신 시각을 Header에 포함하여 처리부에서 총 처리 시간을 측정하는데 사용합니다.

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

조회부는 REST API를 서비스들에게 제공합니다.

처리부 장애

이 중 처리부는 코드 변경이 빈번하게 발생하여 장애 발생 확률이 높습니다. 만약 처리부에서 장애가 발생하고 장애가 복구 되어도 API를 사용하는 서비스들은 여전히 장애를 겪게 됩니다. 왜냐하면 장애 시간동안 데이터가 유실되었거나 오염되었을 수도 있기 때문입니다.

<문제 해결>

이러한 문제를 해결하기 위해 처리부와 Redis를 두 개의 그룹으로 만들어서 처리할 수 있습니다.

평상시에는 처리부A에만 할당하다가 장애가 발생할 시 처리부B로 전환하는 방법입니다. 이 방법의 장점으로는 처리부 A와 처리부 B를 서로 번갈아 배포하면서 둘 간의 차이를 비교할 수 있고, 배포의 문제가 있다면 트래픽 전환만으로 빠르게 롤백할 수 있어 배포 부담감을 많이 줄일 수 있습니다.

시스템 장애를 대비해 각 처리부를 2개로 늘리고 ZooKeeper Group을 통해 Leader를 선출하도록 했습니다. 이 경우 중복 데이터 발생하는 것을 막기 위해 오직 각 그룹에 Leader만 데이터를 처리하도록 하였습니다.

수신부 성능 저하

이런 처리도 결국 수신부가 각 처리부의 갯수만큼 데이터를 보내야 하기 때문에 수신부의 성능 저하를 야기하게 됩니다.

이 문제를 해결하기 위해 수신부와 처리부 사이에 메세지 브로커를 사용하여 해결할 수 있었습니다. 메세지 브로커를 사용하므로서 수신부와 처리부를 Decoupling 할 수 있고 수신부는 데이터를 한 번만 전송해도 되게 됩니다.

하지만 시세 플랫폼에서 가장 중요한 것이 ‘낮은 지연시간‘인데 메세지 브로커로 인해 지연시간이 늘어나게 됩니다. 그렇기에 메세지 브로커로 어떤걸 사용할지가 중요합니다.

메세지 브로커의 후보는 아래와 같습니다.

  1. UDP(User Datagram Protocol) 멀티캐스트
    • UDP 멀티캐스터는 속도는 빠르지만 라우터 설정과 Kubernetes 배포 설정이 필요하다는 단점이 있습니다.
  2. Kafka
    • 높은 처리량과 뛰어난 안정성을 제공하지만 Redis Pub/Sub 보다 속도가 느리다는 단점이 있습니다.
  3. Redis Pub/Sub
    • Kafka보다 5배 빠른 속도를 보여줬습니다. 즉, 낮은 지연시간과 사용하기 쉽고 편리한 커맨드 지원을 해줍니다.

처리부 성능 저하

수신부에 속도가 개선되어도 처리부에서 속도가 느리다면 지연시간이 늘어날 수 있습니다.

처리부에서는 TCP Socket으로부터 데이터를 읽는 일과 비즈니스를 처리하는 일을 합니다. 이 때 처리부가 데이터를 읽는 속도가 지연시간에 큰 영향을 줍니다. 그렇기 때문에 데이터를 읽는 Thread와 비즈니스 처리 Thread를 따로 두어 처리해야 합니다.

이를 위해 Spring Data Redis에서 제공하는 ReactiveRedisTemplate을 사용했습니다. Spring Data Redis에서 기본 제공하는 Lattuce를 사용하여 네트워크 라이브러리인 Natty를 사용하고 Natty의 Channel은 Socket을 추상화한 레이어로서 커넥션이 맺어진 이후 EventLoop에 등록됩니다. EventLoop는 무한 루프를 돌면서 수신 버퍼의 데이터를 읽는 역활을 합니다.

EventLoop

EventLoop은 운영체제에 따라 Nio, Epoll, KQueue등 여러 방식을 지원합니다.

NioEventLoop는 실행되면(run) 버퍼에 데이터가 있는지 확인하고(select), 데이터를 읽고(read) 변환하여(decode) 결과를 통보(notify) 합니다. 그렇기에 NioEventLoop가 비즈니스 로직을 처리하지 않는 것이 중요합니다.

비즈니스 처리에는 Blocking I/O가 포함되어 있기 때문에 처리 성능을 높이기 위해 다음과 같이 멀티 스레딩을 사용해야 합니다. 하지만 이 경우 비즈니스 처리의 순서가 역전될 수 있다는 문제가 있습니다.

 

<문제 해결>

이 문제를 방지하기 위해 멀티 스레딩 대신 EventLoopGroup을 사용하여 해결할 수 있습니다.

EventLoop는 Queue를 이용하여 순서를 보장하고 하나의 Thread만 사용하기 때문에 동기화가 필요 없다는 장점이 있습니다. EventLoop를 사용해도 어떤 EventLoop에서 처리해야 할지 알아야 순서를 보장할 수 있기 때문에 미리 종목 코드를 알아야하고 이를 위해서 JSON을 객체로 변환해야 했습니다.

문제는 이 변환 작업이 트랙픽 양이 증가함에 따라서 NioEventLoop의 CPU 자원을 많이 사용하여 지연의 원인이 된다는 것입니다.

 

<문제 해결>

이 문제를 해결하기 위해 Redis Pub/Sub에서 제공하는 Channel을 사용했습니다.

수신부가 데이터를 보낼 때 처리부의 EventLoop 개수만큼 Channel을 나누어 보내고, 처리부는 이 Channel 명을 보고 해당하는 EventLoop를 찾는 것입니다. 이를 통해 수신부에서 발송한 데이터의 순서를 처리부에서 그래도 유지할 수 있고, NioEventLoop에서 더 이상 객체 변환을 하지 않아도 되기 때문에 성능이 향상된것을 확인 할 수 있습니다.

EventLoop 구현

  • Spring에서 제공하는 ThreadPoolTaskExecutor의 corePoolSize와 maxPoolSize를 1로 설정함으로써 쉽게 만들 수 있습니다.
  • Queue가 꽉 찰 경우를 위한 정책은 DiscardOldestPolicy를 사용했습니다. Circular Queue와 유사합니다.
  • Queue의 크기를 의미하는 queueCapacity는 얼마가 적당한지 정답이 없습니다. 만약 크기가 작으면 많은 피크 시간에 데이터가 유실될 수 있고, 너무 크면 오래된 데이터가 Queue에 적재되어 실시간성을 떨어트립니다.
  • EventLoopGroup은 ThreadPoolTaskExecutor를 여러 개 생성하여 리스트 형태로 만들었습니다. List<ThreadPoolExecutor>.
    • 리스트의 개수는 EventLoop의 개수를 의미합니다.
    • 리스트의 개수를 줄일려고 했는데, 이는 EventLoop의 개수가 늘어날수록 Context Switching으로 인해 NioEventLoop의 성능에도 악영향을 주기 때문입니다. 하지만 무조건 줄이게 되면 EventLoop에 Backpressure가 발생해서 지연시간이 훨씬 늘어나게 됩니다.

EvnetLoop 개수를 줄이는 방법

  • EventLoop가 더 효율적으로 동작하게 만듭니다.
    • Non-Blocking I/O 사용
      • 비즈니스 처리를 하는 EventLoop는 한 번에 한 작업만 처리합니다. 작업에 마지막에는 Redis에 데이터를 저장하는 일이 반복되었는데 굳이 데이터가 저장되기까지 기달릴 필요가 없었습니다. 그래서 ReactiveRedisTemplate의 비동기 함수를 이용하여 EventLoop가 더이상 Block 되지 않고 다음 작업을 처리할 수 있도록 하였습니다.
    • 데이터 조회 시 Local Cache 사용
      • 비즈니스 로직 중, Redis에서 과거 데이터를 조회하는 경우가 많습니다.조회는 앞의 예처럼 비동기로 처리할 수가 없습니다. 만약 비동기로 처리하게 되면 순서가 역전될 수 있기 때문입니다. 그래서 매번 Redis에서 데이터를 읽기보다는 LocalCache에서 먼저 데이터를 읽도록 함으로써 Blocking I/O 횟수를 줄였습니다.
    • 무거운 작업을 위한 별도 EventLoopGroup 분리
      • 배치 작업이나 MySQL 저장과 같은 상대적으로 무거운 작업은 기존의 EventLoop가 아닌 별도의 그룹에서 처리하도록 하였습니다. 이 방법은 기존의 EventLoop가 Blocking 당하는 것을 방지함으로써 효율적인 처리를 가능하게 합니다.

ETC

처리량(Throughput)이 더 이상 올라가지 않는다면?

  • Socket 수신 버퍼 확인
    • Netstat -na | grep <포트번호> 혹은 ss -t dst : <포트번호> 을 사용하여 Socket의 수신 버퍼와 송신 버퍼를 눈으로 직업 확인합니다.
    • Recv-Q와 Send-Q는 해당 Sockek이 얼마나 처리 대기 상태로 남아있는지 의미합니다.
    • 만약 Recv-Q가 지속적으로 0보다 크다면 아래와 같은 방법을 시도해 볼 수 있습니다.
      • NioEventLoop 확인
        • 데이터 변환, 객체 생성 혹은 로깅 등 제거
      • CPU 사용량 확인
        • CPU Profiling을 통해 프로세스 내에 불필요한 Thread를 찾아내어 제거하거나 개수를 줄이면 Context Switching이 줄어들게 됩니다.
      • NioEventLoop를 여러 개 사용
        • 병렬로 처리함으로써 Socket 버퍼 읽기 속도를 올려줄 수 있습니다.
        • NioEventLoop의 개수는 ReactiveRedisTemplate을 여러 개 생성함으로써 늘릴 수 있습니다.

Reference

반응형

+ Recent posts