Coding/시스템 디자인

토스 SLASH 22 - 애플 한 주가 고객에게 전달 되기까지 정리

Hide­ 2024. 7. 4. 00:19
반응형

해외주식 전체 아키텍처

토스증권은 크게 게이트웨이, 채널계, 해외원장, 국내원장 4가지로 이루어져 있다. 대부분의 증권사들의 원장계는 C기반의 모놀리틱 서버로 구성되어있는 반면 토스증권의 해외주식 원장계는 MSA로 구성되어있다. 

주문 체결 흐름도

고객은 토스증권을 통해 주문을 제출하게 되지만 토스증권은 한국 법인이기에 나스닥과 같은 현지 거래소와 직접 거래할 수 없다. 따라서 해외거래 중개인 브로커를 통해 주문을 제출하게 된다. 브로커를 통해 거래가 이루어지기 때문에 토스증권과 브로커의 주문 정합성은 매우 중요한 요소이다. 

앞선 장표의 흐름을 시스템 레벨에서 도식화한 모습은 위와 같다. 고객의 주문은 매매 서버 - 매매 요청 서버를 거쳐 브로커에게 전달되고 브로커가 거래소에 주문을 제출하는 구조이다. 제출된 주문에 대한 체결 결과는 주문 요청 트랜잭션과는 별개의 트랜잭션으로 발생되며 하나의 주문에 대해 다수개의 이벤트가 발생될 수 있다. 이렇게 발생된 이벤트들은 체결 수신 서버 - 카프카를 거쳐 매매 서버에서 처리가 이루어진다. 

MSA 원장에서의 동시성 보장

매매 서버는 고객의 주문을 브로커에게 전달하고 원장에 기록하는 책임을 가지고 있다. 토스증권에서는 WTS, MTS, 자동매매 등 다양한 곳에서 고객의 잔고를 갱신하는 트랜잭션들이 발생한다. 따라서 안전하게 동시성을 제어할 수 있어야 한다. 

이를 위한 가장 보편적인 방법은 락을 통한 동시성 제어 방법이다. 그러나 매매 요청은 고객의 잔고, 증거금, 주문 등 여러 테이블에 대한 삽입 및 갱신이 일어나는 트랜잭션으로 대상 테이블에 대해 하나하나 모두 락을 잡아버리면 성능저하와 데드락을 피할 수 없게 된다.

따라서 보통 이러한 경우 락을 위한 별도의 테이블을 두게 되고 타 증권사들은 계좌 락 테이블(ACCOUNT_LOCK)을 통해 트랜잭션 동시성을 제어하게 된다. 고객의 자산이동이 발생하는 트랜잭션들은 위 사진처럼 계좌 락 테이블에 대한 락을 획득하는 것으로 트랜잭션을 시작한다. 그러나 토스증권 해외주식 원장은 MSA구조로 각 작은 모듈들이 독립적인 데이터베이스를 사용하고 있다. 또한 시스템이 발전함에 따라 하나의 서버가 여러개로 분리될 수도 있다. 그렇기에 토스증권은 레디스를 통한 분산락을 사용하고 있다. 

하지만 이러한 분산락에도 주의할점이 있다. 분산락은 원장 내 모든 서버가 공통으로 사용하기 때문에 하나의 트랜잭션이 무한정 락을 소유하고 있을 경우 다른 서버들의 요청이 무한정 대기할 수 있다. (데드락) 따라서 분산락은 적절한 타임아웃 설정이 필요하다. 하지만 분산락 타임아웃은 의도대로 동작하지 않을 수가 있다. 분산락 타임아웃이 지나 락 자체는 해제되었으나 트랜잭션이 아직 끝나지 않은 상태여서 다른 트랜잭션과 경합이 일어날 수 있다. 

고객의 잔고에서 출금하는 트랜잭션 경합 상황을 예로 들어보자. 

  1. 0초에 T1, T2 트랜잭션이 동시에 발생했고 T1은 락을 획득하고 T2는 락을 대기하고 있는 상태이다.
  2. 2초 뒤에 T1은 락 타임아웃으로 인해 락이 해제되었고 T2는 락을 획득하여 잔고에서 500원을 출금한다.
  3. 2초 뒤인 4초에 지연된 T1 트랜잭션 처리가 완료되어 2000원을 출금하게 되면 고객의 잔고는 0원이 되면서 갱신 유실이 발생한다.

이러한 문제는 분산락 해제 전 DB 트랜잭션이 커밋되거나 분산락을 해제하고 나서 커밋이 되는 경우 등에 발생하며 JPA환경에서는 쿼리 쓰기 지연 등으로 인해 이러한 문제가 발생할 확률이 비교적 높다.

갱신 유실 방지는 원자적 연산 사용, 명시적 잠금, 갱신 손실 자동 감지, Compare-and-set 연산등으로 방지할 수 있다. 

  • 명시적 잠금: 앞선 예제와 같이 여러 테이블을 갱신하는 트랜잭션에서는 비용이 매우 비싸다.
  • 원자적 연산 사용/갱신 손실 자동 감지: DBMS에 의존적이기 때문에 ORM과 궁합이 좋지 않다.

이러한 문제로 인해 토스증권에서는 Compare-and-set 연산을 사용하고 있다. JPA에서는 @OptimisticLocking 어노테이션을 통해 간단하게 CAS연산 구현이 가능하다. OptimisticLocking은 version을 통해 갱신 유실을 방지한다.

  1. 0초에 잔고의 버전은 1이다.
  2. 3초에 T2가 갱신하면서 버전이 2로 증가했다.
  3. 1초 뒤인 4초에 T1 트랜잭션이 업데이트를 시도할 때 T2가 이미 버전을 변경했기 때문에 오른쪽에 있는 쿼리의 Where문으로 인해 T1 트랜잭션은 실패하게 된다. 

하지만 OptimisticLocking만으로는 모든 동시성을 제어할 수 없다. 분산락이 없다면 동시에 발생하는 트랜잭션들은 대기없이 실패하게 되거나 별도의 재시도 구현이 필요하기 때문이다. 트랜잭션 재시도 구현은 재시도 자체의 실패 등 여러 케이스들을 고려해야하며 이는 곧 코드의 복잡도 상승으로 이어지게 된다. 대부분의 경우 분산락은 정상적으로 동작하기 때문에 토스증권에서는 분산락으로 동시성을 제어하고 만약의 상황에서도 OptimisticLocking 어노테이션을 통해 데이터 정합성이 틀어지지 않도록 하는 방법을 채택해 사용하고 있다. 

또한 주요 테이블들은 하이버네이트 envers를 이용해 변경 히스토리를 저장하여 원활히 데이터 흐름을 파악할 수 있도록 하고 있다. 

해외구간 네트워크 지연으로부터 안전하게 서비스하기

브로커와 통신하는 구간은 해외망으로 네트워크 지연이 빈번하게 발생하는 구간이다. 브로커 요청 자체가 지연될 경우 매매 서버에 스레드까지 함께 블락킹되어 최악의 경우에는 모든 스레드가 행에 걸려 고객의 요청들을 받을 수 없는 상태가 될 수 있다. 

토스증권에서는 고객의 요청을 받는 스레드와 브로커에게 요청하는 스레드를 분리하는 것으로 모든 스레드가 블락킹되는 이슈를 해결하고 있다. 하지만 브로커 통신 구간은 여전히 지연이 빈번하기 때문에 하나의 API에서 동기로 처리할 경우 상황에 따라 고객은 이 주문 응답을 기다리느라 다른 서비스를 이용할 수 없게 된다. 따라서 토스증권은 이 구간을 비동기로 처리하여 고객 경험은 상승시키고 트랜잭션 시간을 최소화하고 있다. 브로커 요청이 실패할수도 있기 때문에 브로커 요청 이전에 주문을 반드시 대기 상태로 저장해야 한다. 대기 상태의 주문은 브로커 응답 결과에 따라 접수 성공/실패로 주문 상태를 갱신한다. 

TCP기반 통신을 사용하는 이상 타임아웃이 발생할 수 있다. 브로커의 응답을 받지못한 채 타임아웃이 발생하면 해당 주문 상태를 임의로 판단할 수 없게 된다. 또한 브로커측의 식별자를 전달받지 못한 상황이기 때문에 브로커 제약으로 인해 주문 상태를 조회하기도 어려운 상황이다. 

토스증권은 이러한 주문들을 재시도 대상으로 판단한다.

  1. 토스증권의 1번 주문이 브로커에 정상적으로 처리되었다. 
  2. 네트워크 구간에서 응답을 받지 못하고 이를 재시도 대상으로 분리한다.
  3. 재시도 대상 주문을 다시 요청하면 브로커는 1번 주문에 대해 두 개의 주문이 생성되어 토스증권의 주문과 브로커의 주문 정합성이 어긋나게 된다.

토스증권은 이러한 문제를 멱등한 API를 통해 해결하였다. 즉 토스 주문 ID를 멱등키로 보내 하나의 토스 주문 ID에는 하나의 브로커 주문만 생성하도록 약속한 것이다. 그럼 이제 멱등성은 확보했으니 성공할 때 까지 주문 요청을 보내기만 하면 되는걸까?

타임아웃의 특성 상 짧은 주기로 재요청을 하게 될 경우 네트워크 지연 상황을 더욱 악화시킬 수 있다. 네트워크 지연으로 인해 더 빈번한 타임아웃이 발생할 수 있는 것이다. 토스증권에서는 일정 횟수로 재시도를 제한하고 지수적으로 간격을 두어 재시도하는 전략을 사용하고 있다. 혹시라도 주문 및 체결 정합성이 틀어질 경우 대사 배치에서 잡히게 되고 이는 별도 오퍼레이션에 의해 처리할 수 있도록 장치가 준비되어 있다. 그리고 토스증권의 모든 정합성은 체결 내역에서부터 시작하기 때문에 토스증권내의 정합성이 틀어지는 일은 절대 발생하지 않는다.

브로커 의존성 격리하기

브로커는 외부기관으로 비즈니스 결정으로 인해 언제든지 변경되고 추가될 수 있다. 현재 구조에서 브로커가 변경되면 네트워크 프로토콜부터 인터페이스까지 매매 서버가 새로운 브로커에 맞추게 되며 변경이 같이 가해지게 된다. 또한 매매 서버 처리량이 브로커의 처리량과 강하게 결합되어 있는 것 또한 문제로 보인다. 

토스증권에서는 브로커 의존성을 가장 강한 격리 수준인 서버 레벨에서 격리하고 있다. 브로커가 전문 통신을 지원하든 HTTP를 지원하든 매매 서버는 내부 인터페이스만 맞추면 된다. 앞서 살펴본 타임아웃 주문처리 역시 대표적인 브로커 인터페이스 의존 요소이다. 토스증권 식별자로 상태를 조회할 수 있게 제공해주는 브로커가 추가된다면 전혀 다른 방식의 타임아웃 주문 처리를 도메인 로직과는 무관하게 확장할 수 있다. 또한 브로커 처리량에 따라서 매매 요청 서버를 스케일 아웃하여 효율적인 리소스 사용이 가능해지고 브로커 장애가 토스증권 서비스 전체로부터 격리 가능한 구조가 되었다.

토스증권에서 브로커로 향하는 Outbound 트랜잭션들은 매매 요청 서버를 통해 나가고 브로커에서 토스증권으로 들어오는 Inbound 트랜잭션들은 체결 수신 서버가 담당하도록 구성되어 있다. 브로커 의존성을 격리함으로써 앞서 언급한 장점 외에도 체결 수신처리에서도 강력한 장점들이 생긴다.

체결 수신 서버는 브로커의 이벤트를 받아 데이터베이스에 적재하고 카프카로 발송하는 책임을 가져간다. 이로써 매매 서버가 매우 바쁜 상황이더라도 브로커의 이벤트를 수신하지 못하는 상황을 방지할 수 있다. 또한 카프카 업로드전에 브로커 수신 내역을 데이터베이스에 적재하고 있기 때문에 카프카가 다운된다고 하더라도 폴링 모드로 전환하는 등 다양한 Fail over 전략이 메시지 유실없이 선택 가능해진다.

또한 중복 이벤트 역시 브로커 인터페이스와 무관하게 처리가 가능해지게 된다. 중복 처리는 이벤트를 발행할 때 유니크한 아이디를 발급하여 처리가 가능하다. 브로커 파트너가 여러개라면 각 파트너끼리 아이디가 경합될 수도 있고 하지만 이 역할을 체결 수신 서버가 가져가 여러개의 브로커 파트너에 대해서도 중복없이 전역적인 유니크 아이디 발급이 가능해진다. 

Reference

https://www.youtube.com/watch?v=UOWy6zdsD-c