손쉬운 확장을 위한 분산 DB 전환
네이버페이의 경우 2009년 오픈을 하여 현재까지 많은 서비스의 성장을 거치면서 많은 문제점이 찾아왔다. 어플리케이션의 경우 Stateless이기 때문에 언제든지 확장이 가능하지만 DB같은 경우 이러한 확장이 쉽지 않기 때문에 한계를 맞이한 것이다. 따라서 단일 DB로는 갈 수 없고 분산 DB로 가야만 하는 상황이었다.
서비스가 Read heavy인 경우 레플리카의 증설을 통해 부하 분산이 가능하다. 하지만 Write heavy의 경우 레플리카 증설을 통한 대응이 불가능하다. 네이버페이의 경우 Write heavy였기 때문에 샤딩을 선택할 수 밖에 없었다.
Shard key
샤딩 기반으로 전환하는데 있어 가장 큰 고민은 샤드키였다. 주문 데이터는 상품, 금액등의 정보도 있겠지만 주문한 상품에 대한 판매자 정보가 들어있다. 따라서 주문 정보는 구매자인 나의 정보이기도 하지만 판매자의 정보이기도 하다. 따라서 유저 키로 샤딩을 한다면 판매자는 샤드를 타겟팅하여 요청할 수가 없다. 따라서 구매자 샤드와 판매자 샤드를 각가 만들어서 해결하는 결정을 내렸다.
만약 모든 샤드에 대해 브로드 캐스팅으로 요청하는 쿼리가 필요한 경우 샤드 DB를 통해 얻을 수 있는 이점이 의미가 없어진다. 따라서 위 사진처럼 구매자 DB와 판매자 DB를 각각 유지하기로 했다.
전환
분산 DB의 전환은 단순 DB 전환뿐만 아니라 어플리케이션의 전체 전환을 야기한다. 일반적으로 DB가 바뀔 때 어플리케이션의 일부분만 바꿔서 해결할 수도 있겠지만 분산 DB로 간다는 것은 단순히 어떤 데이터를 접근하고 쓰고 이러한 패턴만 바뀌는 것이 아니라 비즈니스의 변화를 겪을수밖에 없다.
멈춤없이 서비스가 되어야하는 케이스에서 그나마 가장 안정적으로 전환하는 방법은 위 그림처럼 마틴 파울러의 Strangler Pig Pattern을 사용하는 것이다. (좌측에 있는 레거시 시스템에서 모던 어플리케이션으로 점진적으로 전환하여 넘기는 방식) Strangler Facade는 프록시 서버 또는 게이트 웨이로써 유저의 요청을 레거시에서 새로운 어플리케이션으로 전환해주고, 경우에 따라 1% / 10% / 100% 점진적인 전환을 하게 도와준다.
단일 DB에서 샤딩 DB로 데이터 레이어에서는 서로 복제를 하며 동기화가 되어있다고 볼 수 있다. 이런 상태로 모던 어플리케이션 레이어로 전환을 한다. 전환한 상태에서도 일단은 단일 DB쪽으로 먼저 요청을 보낸다. 그리고 단일 DB측에 문제가 없다면 샤딩 DB로 전환한다.
단일 DB에서 구매자 DB로 넘어갈 때 핵심은 CDC이다. 네이버페이의 경우 단일 DB에 있어 CDC를 적용할 수 없는 상황이었다. 따라서 DB레이어에서 트리거를 이용하여 변화를 감지하도록 만들었다. 이는 MySQL의 binlog, MongoDB의 oplog와 같은 방식보다는 부하가 심하겠지만 어쩔 수 없는 상황이었다. 대상이 되는 테이블에 UPDATE/DELETE/INSERT 등의 쓰기 오퍼레이션이 발생하면 해당 내용을 별도 테이블에 쓰도록 트리거를 생성했다. 따라서 타겟이 되는 데이터가 바뀌었을 때 해당 데이터의 PK를 저장하도록 했고 CDC에서는 이 PK를 주기적으로 읽어서 해당하는 테이블에 PK를 통해 조회하고 반대편 샤드 DB에 복제하는 방식이다.
주문같은 경우 Order 테이블, OrderLineItem 등의 테이블로 구분해볼 수 있다. 어떤 2개의 상품을 묶어서 결제하는 경우 OrderItem은 2개, Order는 1개 구조이다. 이 때 각각 특정한 샤드키를 포함해서 구매자 DB의 특정 샤드로 복제해주는 방식이다.
아까 구매자 DB, 판매자 DB를 별도로 구성했다고 했다. 이런 경우 이 둘 사이의 데이터 정합성은 어떻게 하겠는가라는 질문이 생길수밖에 없다. 이또한 단일 DB에서 옮겼듯이 CDC를 통해 해결했다. 이 때 사용한 분산 DB의 경우 MySQL이기 때문에 binlog 방식의 트랜잭션 로그를 기반으로 판매자 DB에 데이터를 복제했다.
추가적인 고려사항은 단일 DB와 구매자 DB 사이 양방향 복제가 필요하다는 점이다. 새로운 어플리케이션으로 전환을 했다 하더라도 언제든지 롤백이 되어야하기 때문에 새로운 어플리케이션에서 Write된 데이터는 다시 단일 DB로 복제가 되어야한다는 말이다. 따라서 단일 DB와 구매자 DB는 서로 양방향의 복제를 수행하고 있다.
이 때 발생할 수 있는 문제점이, 현재 이벤트가 어플리케이션에서 발생된 것인지 CDC에서 발생된 것인지에 따라 무한 싸이클이 돌 수 있다는 것이다. 이 문제를 해결하기 위해 복제되는 모든 대상의 테이블들에 싱크라는 컬럼을 추가했다. 그리고 어플리케이션에서는 이 컬럼을 바라보지 못하도록 하였다. 오직 CDC만 해당 컬럼을 바라보고 CDC에 복제가 된다면 싱크 컬럼에 복제 여부를 표시하여 더이상 복제되지 않도록 방어했다.
DB 레이어 차원에서 데이터를 복제하다보면 여전히 지연에 대한 문제가 있을 수 밖에 없다. 배송 상태와 같은 문제는 비즈니스적으로 풀었고 그 외에 여전히 해결할 수 없는 지연 이슈는 Dual write 드라이버를 별도로 개발하여 어플리케이션 차원에서 단일 DB와 샤딩 DB에 동시에 쓸 수 있도록 처리했다. 이는 어플리케이션에 부담을 주기 때문에 좋은 방법은 아니다.
위 그림을 보면 단일 DB와 샤딩 DB에 각각 커넥션을 가지고 있다. 데이터의 변경이 발생하면 Dual write 드라이버로 업데이트 요청이 들어온다. 그러면 드라이버는 단일 DB를 마스터로 보고 있기 때문에 단일 DB부터 먼저 업데이트를 한다. 그리고 업데이트된 문구를 구매자 DB에 던질수도 있지만 그러지않고 트리거에 의해 이력이 쌓인 데이터를 기반으로 업데이트된 것을 다시 한번 SELECT한다. 그리고 이 데이터를 구매자 DB에 접근하여 업데이트한다.
근데 만약 이 때 INSERT와 UPDATE가 아주 짧은 시간에 발생한다면 구매자 DB에 해당 데이터가 없을수도 있다. (아직 복제가 안됐기 때문에) 이런 경우 SELECT한 최종 데이터를 INSERT하고 처리를 마무리한다. 만약 단일 DB 커밋은 성공하고 구매자 DB 커밋은 실패하는 경우가 발생할수도 있다. 이런 경우 CDC가 처리한다.
이러한 과정을 거친후에도 단일 DB는 여전히 필요하다. 샤딩 환경은 비즈니스 요구 사항에 상당한 제약을 가할 수 밖에 없다. 예를 들어 구매자가 가족 결제를 진행하여 아버지와 자녀 정보를 묶어야 된다거나 혹은 판매자라면 호스팅사가 "하위 가맹점 A, B 주문 리스트를 주세요" 라고 요청한다면 샤딩은 했지만 여전히 모든 샤드에 쿼리를 날릴 수 밖에 없는 상황이 발생한다. 샤드가 3-4개 정도라면 몰라도 20개가 넘어간다면 이는 어마어마한 부하를 줄 수 밖에 없다. 따라서 이러한 문제를 해결하기 위해 단일 DB가 필요하다는 결론을 내렸고 샤딩 환경으로 전환한 이후에도 단일 DB를 유지함으로써 이러한 특수 목적의 비즈니스를 대응했다.
물론 이때에도 여전히 지연이라는 이슈가 있다. 샤딩 DB에서의 변경사항이 단일 DB로 복제될 때 발생하는 지연이다. 이러한 경우 위에서 언급한 Dual write 드라이버가 또 필요한 상황이 생길 수 있다.
Event Driven Architecture 적용
EDA를 결제 시스템에 적용하다보니 데이터의 일관성/정합성에 대한 걱정이 많았다. 그래서 별도의 Order verifier를 만들어서 주문이 최초 생성 및 결제, 발주, 발송, 구매 확정 완료등의 상태로 흘러갈 때 최초 메시지가 만들어진 상태를 수신하고 이 메시지가 5분 정도 지난 후 최종 상태까지 잘 흘러갔는가에 대해 검증하는 별도의 장치를 두었다. 이를 통해 문제가 생기는 경우 알림을 설정하고 경우에 따라 자동처리까지 수행하게 했다. 예를 들어 결제가 되었는데 3분동안 해당 결제건에 대해 최종 주문상태가 만들어지지 않는다면 카드취소하는 등의 자동 처리이다.
EDA를 적용해도 다양한 문제가 발생할 수 있고 이 때 DLQ를 활용하여 서비스가 멈추지 않고 계속 갈 수 있도록 해야한다. 컨슈머가 메시지를 컨슘하고 처리할 때 만약 API 호출을 한다고 가정하면, 이 API 호출이 항상 성공하지는 않는다. 예를 들어 네트워크 순단이 발생하거나 상대방이 배포중이거나 등의 일시적인 문제는 재시도를 통해 해결할 수 있다. 하지만 재시도 이후에도 실패한다면 빠르게 해당 메시지를 DLQ로 이동시켜 다음 메시지를 처리할 수 있도록 해야한다.
DLQ의 메시지는 분석해서 처리하는 방법도 있고 이 메시지들 또한 일시적인 문제로써 다시 소비하면 해결되는 문제들도 있다. 따라서 DLQ에 메시지를 넣을 때 접근할 수 있는 방법은 두 가지가 있다.
하나는 순서 보장이 되어야 하는가, 그리고 또 하나는 순서 보장이 필요없는가이다. 예를 들어 주문이 발생하고 a, b, c 상태로 가는데 b에 대해 문제가 발생하여 DLQ로 넣었다고 가정하자. 이 때 컨슈머가 c 상태에 대해서는 정상처리를 한다면 b는 늦어진 메시지가 된다. 이런 문제를 막기 위해 컨슈머는 해당 주문에 대해 b에 문제가 있을 때 주문번호를 레디스와 같은 임시 저장소에 저장하고 이후 똑같은 주문의 c 상태에 대해 바로 DLQ로 보내버린다. 정상인지 아닌지 판단전에 DLQ로 보내서 동일 주문건에 대해서는 순서를 보장하는 것이다.
그리고 DLQ를 소비하더라도 경우에 따라 해당 메시지가 여전히 해결되지않아 DLQ로 가는 상황이 있다. 이 때는 다시 DLQ로 보내지 않고 멈출 수 있게 해야 순서가 보장이 될 수 있다. 만약 순서 보장이 필요 없는 경우 다시 DLQ로 보내면 된다.
일반적으로 이벤트 처리에 지연이 발생하면 컨슈머를 증설하여 해결한다. 물론 뒷단 연계 시스템에서 받아낼 수 있는 한계가 있다면 이런 방식은 여전히 문제를 해결할 수 없다. 이런 경우 논리적으로 보면 토픽을 분리하는 것도 하나의 방법이다. 물론 이벤트의 순서가 보장되어야 한다면 토픽은 분리할 수 없겠지만 그룹을 지어서 순서를 별도로 갈 수 있는 상황이라면 토픽을 분리하는것도 좋을 것 같다.
또한 처리량에 따라 분리하는 방법도 있다. 어떤 이벤트는 빠르게 처리할 수 있고 어떤 이벤트는 긴 작업시간을 요구한다면 이를 하나의 토픽에서 처리하기보다 토픽을 분리하는 것이다.
위 예제는 배송에 대한 예제이다. 배송 상태를 살펴보면 모든 상태가 비즈니스적으로 필요한 것은 아니다. 그렇기에 경우에 따라 성능이 나오지 않는다면 집화와 배송완료에만 집중할 수 있다. 따라서 성능 지연 이슈가 발생하면 그 외의 상태 이벤트에 대해서는 모두 DLQ로 보낸 후 나중에 처리되거나 하는 방법을 사용할 수도 있다.
또는 우선순위 큐를 별도로 만들어서 중요 메시지들이 담기는 큐와 덜 중요한 메시지가 담기는 큐를 분리하는 방법도 있다. 물론 이를 상시 운영할수도 있고 비상시에만 컨슈머/프로듀서가 이에 맞게끔 대응할수도 있다.
배송 상태는 상태에 대한 순서가 명확하다. 이는 메시지를 순서없이 어플리케이션에서 받아낸다 하더라도 로직상 발송전에 집화가 될 수 없다는 뜻이다. 따라서 이런 로지컬한 상태를 통해 현재 버려야할 상태를 파악할수 있다. 이런 방법으로 멱등성을 유지한다.
온라인 서비스에서 발생하는 결제는 EDA로 처리한다면 몇 초 안에 이루어져야하는 즉시성이 필요하다. 몇 분씩 흘러버린 메시지는 사실 꺼내서 처리해봐야 의미없는 경우들이다. 이런 경우 시간이 지나버린 메시지들은 비워줘야한다. 이를 위해 별도의 장치를 두고 특정한 상황이라는 것을 설정하게 되면 컨슈머는 해당 메시지를 꺼내서 이벤트의 발생 시점을 파악하고 짧은 타임아웃으로 모두 버려버리게 된다.
무중단 결제를 위한 다양한 시도
많은 트래픽이 몰리면 대기열을 사용한다. 주문서에 한번 진입하면 그 뒤는 특정하게 트래픽을 발생시키는 요인이 없기 때문에 주문서 입구까지만 대기열을 적용했다. 여기에도 몇가지 문제가 있었는데, 주문서 대기열을 거쳐 들어왔다 하더라도 해당 페이지를 떠나지 않고 계속 품절된 상품에 주문을 시도하는 유저들이 있다. 이런 경우 품절 상품이라면 페이지를 이동시키도록 했다.
그리고 결제가 느려지면 결제 완료까지 해당 결제창에서 계속 대기하게 된다. 이 때 EDA 환경이기 때문에 별도의 웹소켓을 통해 상태를 계속 확인하게 된다. 이러한 과정도 일정 시간이 지나게 되면 폴링 작업으로 전환한다. 폴링 간격도 최초 2초, 이후에는 4, 8, 16초 간격으로 벌어지게끔 하여 트래픽을 줄였다.
은행 점검중에는 계좌 결제를 사용할 수 없다. 사실 배송이 필요한 상품은 즉시 결제가 일어날 필요는 없다. 따라서 이런 케이스들은 결제를 미리 받아두고 은행이 정상화되었을 때 비동기로 처리할 수 있도록 적용했다.
원천 시스템의 서킷 브레이크를 통해 실제 결제 처리 시 받는 응답을 빠르게 분석하고 1분 내에 바로 서킷을 걸 수 있도록 자동화된 시스템을 사용하고 있다. 그리고 각 어플리케이션에서는 이런 장애 점검 등을 주기적으로 모니터링하고 모두가 동기화하여 막을 수 있도록 준비가 되어있다.
Reference
본 포스팅은 https://tv.naver.com/v/67445495 를 보고 필요한 부분만 정리한 글입니다.
'Coding > 시스템 디자인' 카테고리의 다른 글
티켓 예매 서버 시스템 디자인 (4) | 2024.10.13 |
---|---|
토스 SLASH 23 - 실시간 시세 데이터 안전하고 빠르게 처리하기 정리 (0) | 2024.08.13 |
토스 SLASH 22 - 토스뱅크의 완전히 새로운 대출 시스템 정리 (0) | 2024.08.08 |
Rate Limiter 시스템 디자인 (0) | 2024.08.07 |
토스 SLASH 22 - 애플 한 주가 고객에게 전달 되기까지 정리 (0) | 2024.07.04 |