토스 - 동시성, 네크워크 지연, 외부 서비스 의존성 관리
본 글은 토스ㅣSLASH 22 - 애플 한 주가 고객에게 전달 되기까지를 정리한 글입니다.
높은 정합성과 신뢰성을 요구하는 주식원장 시스템을 어떻게 MSA로 지원하는지 소개합니다.
목차
1. MSA기반 해외 원장 시스템 구조
- Spring, Kotlin, JPA 기술스택을 사용
- 필요에 따라 Redis, Kafka 사용
- 각 도메인 별 모듈들이 독립적으로 구성
- 한 모듈의 장애가 전체에 전파가 안되도록 설계
주문 체결 흐름도
- 고객 ↔︎ 토스증권 ↔︎ 브로커(해외거래 중개인) ↔︎ 현지 거래소
- 토스와 같은 법인은 현지 거래소와 바로 거래할수 없음
- 토스의 주문 요청(2)과 체결 결과(4)는 다른 트랜잭션
- 하나의 주문에 대해 다수개의 이벤트가 발생가능
동시다발적으로 발생하는 트랜잭션에서 고객의 자산을 안전하게 처리하기
MSA기반 원장에서 동시성은 어떻게 다루어지는가?
매매서버
- 매매서버의 역할
- 고객의 주문을 브로커에 전달
- 원장에 기록
- WTS, MTS, 자동매매 등 여러곳에서 고객의 잔고를 갱신하는 트랜잭션이 발생
- 이러한 환경에서 안전하게 동시성을 제어할 수 있어야함
- 가장 보편적인 방법은 매매 서버에서 락을 통한 동시성 제어
- 매매 요청은 고객의 잔고, 증거금, 주문 등 여러 테이블에 대한 삽입 및 갱신이 발생
- 각 테이블에 대해 모두 락이 존재하면 성능 저하, 대드락을 피할 수 없음
- 토스는 MSA 구성, 모듈별로 독립된 DB로 구성, 시스템의 발전으로 분리가능성이 존재
- 위와 같은 방식(락)이 적용되면 서비스간 높은 결합도 유지, 비효율적인 자원사용이 야기됨
- 토스는 이러한 상황을 Redis기반 분산락을 사용하여 해결중
분산락의 문제점 해결
- 분산락 타임아웃
- 하나의 트랜잭션이 무한정 락을 소유할 경우 다른 서버들에서 무한정 대기(데드락)
- 이를 타임아웃으로 해결
- 분산락 타임아웃의 문제
- 타임아웃으로 락은 해제되었으나 트랜잭션이 끝나지 않아 다른 트랜잭션과 경합이 발생가능
- 예, 아래그림) T1원 2000원 출금, T2는 500원을 출금하는 예제
- 위와 같은 문제는 분산락 해제전 또는 해제후 디비 트랜잭션 커밋이 되어 발생하는 문제
- JPA에서는 쓰기 지연등으로 이러한 문제가 발생할 가능성이 높음
- 갱신 유실은 다음과 같은 방법으로 방지가능
- 원자적 연산 사용 - DBMS에 의존적, ORM과 궁합이 좋지 않음
- 갱신 손실 자동 감지 - DBMS에 의존적, ORM과 궁합이 좋지 않음
- 명시적 잠금 - 여러 테이블을 갱신하는 트랜잭션에서 비용이 비쌈
- Compare-and-set 연산 - 토스에서는 이 방식을 사용
- JPA에서는 @OptimisticLocking을 사용하여 간단하게 CAS 연산 구현 가능
- OptimisticLocking은 버전을 통해 갱신 유실을 방지
- 예) 오른쪽의 쿼리와 4초에서 업데이트를 시도할때 버전이 안맞아서 T1 트랜잭션이 실패
- 분산락 없이 OptimisticLocking 만으로 동시성 제거는 불가능한가? 결론은 불가능
- 대기없이 실패, 이로인해 별도의 재시도 구현이 필요
- 트랜잭션 재시도 구현은 여러케이스를 고려해야해서 코드의 복잡성 올라감
- 결론
- 토스는 분산락으로 동시성 제어, 만약의 상황을 위해 OptimisticLocking사용
- 주요 테이블들은 하이버네이트 envers를 통해 변경 히스토리를 저장
2. 해외구간 네트워크 지연으로부터 안전하게 서비스하기
- 브로커와 통신하는 구간은 해외망으로 네트워크 지연이 빈번하게 발생
- 브로커 요청이 지연될경우 매매서버의 스레드가 블락킹, 최악의 경우 모든 스레드가 블락킹
- 더 이상 고객 요청 처리 불가능
- 토스에서는 고객의 요청에 대한 스레드와 브로커에 요청하는 스레드를 분리하여 모든 스레드가 블락킹되는 이슈를 해결중
- 브로커 통신 구간은 여전히 빈번한 지연이 발생
- 하나의 API로 동기로 처리시 고객은 주문응답을 기다려야함
- 토스는 이 구간을 비동기로 처리하여 고객경험을 상승시키고 트랜잭션 시간을 최소화로 유지중
- 여전히 실패가능한 브로거 요청을 처리하기 위해 주문을 먼저 대기상태로 저장하고 성공/실패 상태로 갱신
- TCP기반 통신처리시 타임아웃 발생가능
- 주문 결과를 알 수 없음 → 상태 갱신 불가능
- 브로커의 식별자를 알수 없어 주문상태를 조회할수 없음
- TCP기반 통신처리시 타임아웃 발생가능
- 토스는 이러한 문제를 재시도 + 멱등성API 로 해결
- 1번 주문(식별자 포함)에서 네트워크 지연이 발생해서 타임아웃 발생시 재시도 대상에 포함됨
- 1번 주문(식별자 포함)이 다시 재시도
- 식별자가 같으므로 하나의 주문만 생성됨
- 타임아웃 발생시 응답이 올때까지 재시도?
- 이 경우 네트워크 상태를 더욱 악화할 수 있음
- 토스는 일정횟수로 재시도를 제한하고 지수적으로 간격을 둠 (1,2,4,8분)
- 주문 및 체결 정합성이 틀어진경우를 대비해 별도의 잡이 수행중
3. 브로커 의존성 격리하기
- 브로커(외부기관)의 비즈니스에 의해 언제든지 변경되거나 추가될 수 있음
- 새로운 브로커가 추가되면 매매서버(네트워크 프로토콜, 인터페이스 등)를 변경 필요
- 매매서버와 브로커의 처리량 또한 강하게 결합되어있음
- 토스에서는 브로커 의존성을 가장 강한 격리 수준인 서버 레벨에서 격리중
- 브로커의 지원통신방식과 관계없이 매매서버는 내부 인터페이스만 고려
- 매매서버는 브로커와 관계없이 도메인 로직을 유지가능
- 브로커의 처리량에 따라 매매 요청 서버를 스케일아웃하여 효율적인 리소스 사용이 가능
- 매매 서버가 브로커 장애로 부터 격리
- 토스증권 -> 브로커의 Outbound 트랜잭션은 매매 요청 서버에서 처리
- 브로커 -> 토스증권의 Inbound 트랜잭션은 체결 수신서버가 담당
- 브로커 의존성 격리시 매매 요청 뿐만 아니라 체결 수신에서도 장점이 있음
- 매매서버가 바쁜 상황이더라도 브로커의 응답을 수신가능
- 수신 내역을 DB에 저장하고 있기 때문에 카프카가 다운되더라도 다양한 Fail Over전략 선택가능
- 브로커 인터페이스와 무관하게 유니크 아이디를 사용해 중복 이벤트 처리가능
- 여러개의 브로커사용시 아이디가 경합될 수 있지만 체결수신 서버가 관리하기 때문에 전역적인 유니크 아이디 발급가능
정리
- 동시성 제어를 위해 분산락과 낙관적락 사용
- 분산락을 기본으로 사용시하고 데드락을 방지하기 위해 타임아웃 설정
- 정합성을 위한 2차 안전장치로 낙관적락으로 사용
- 외부 서비스 처리 서비스를 분리
- 제어할 수 없는 외부 서비스의 요청을 고객 요청과 분리해서 사용성 개선
- Inbound/Outbound 트랜잭션을 분리된 서비스로 처리해서 한 동작에서의 장애가 다른 동작에 영향을 미치지 않도록 설계