Introduction
여러분, 결제 시스템이 단 1초만 멈춰도 어떤 일이 벌어질지 상상해보셨나요? 고객의 불만부터 시작해 막대한 매출 손실, 브랜드 이미지 실추까지... 그야말로 도미노 효과의 시작입니다.
Shopify의 스태프 개발자 Bart는 이런 악몽 같은 상황을 매일 밤낮으로 고민하는 사람 중 하나입니다. 그가 5년간 Shopify의 결제 인프라를 담당하며 깨달은 핵심 노하우 10가지를 공개했습니다.
"신뢰는 걸어서 오지만, 말을 타고 떠난다"는 네덜란드 속담처럼 결제 시스템의 신뢰도는 쌓기는 어렵지만 무너지기는 한순간이라는 것이죠.
타임아웃 설정부터 서킷 브레이커, 멱등성 키 사용까지. 이 글에서 소개하는 10가지 팁은 단순한 기술적 조언을 넘어 결제 시스템의 철학을 담고 있습니다.
백만 번에 한 번 일어날 법한 오류가 매일 발생하는 Shopify의 규모에서, 이들은 어떻게 안정성을 유지하고 있을까요? 결제 시스템의 비밀을 파헤쳐 봅시다.
안정적인 결제 시스템 구축을 위한 10가지 팁:
Shopify의 결제 인프라를 담당하는 스태프 엔지니어가 전하는 안정적인 결제 시스템 구축을 위한 10가지 핵심 팁과 요령
지난 5년간 저는 Shopify의 결제 인프라 팀에서 일하면서 다양한 방식으로 수십 명의 개발자들을 온보딩했습니다. 어떤 이들은 다른 프로그래밍 언어를 사용했고, 또 다른 이들은 Ruby를 써봤지만 결제 분야는 처음이었죠. 새로 합류한 팀원들에게서 대체로 일관되게 나타난 점은 Shopify 정도 규모의 시스템을 구축한 경험이 거의 없다는 것이었는데요. 사실 저도 입사했을 때는 그랬죠.
무엇을 모르는지조차 모를 때는 배우기가 어렵습니다. 제가 여러 해에 걸쳐 배운 것들을 나중에는 다른 이들에게 전수하게 되었습니다. 이러한 주제들을 정리해서 팀에 발표했고, 그것을 다시 이 블로그 포스트로 압축했습니다. 자, 그럼 안정적인 결제 시스템 구축을 위한 제가 꼽은 10가지 팁과 요령을 소개해 드리겠습니다.
1. 타임아웃 시간 줄이기
Ruby의 내장 Net::HTTP 클라이언트는 서버에 연결을 열기 위해 60초, 데이터 쓰기에 60초, 그리고 응답 읽기에 60초의 기본 타임아웃을 가집니다. 사람이 기다리고 있는 온라인 애플리케이션의 경우 이는 너무 긴 시간인데요. 그래도 다행히 기본 타임아웃이 설정되어 있긴 합니다. 반면에 Go의 http.Client나 Node.JS의 http.request 같은 다른 프로그래밍 언어의 HTTP 클라이언트들은 기본 타임아웃이 전혀 없죠. 이는 즉, 응답하지 않는 서버가 무기한으로 리소스를 점유하여 불필요하게 인프라 비용을 증가시킬 수 있다는 뜻입니다.
데이터 저장소에도 타임아웃을 설정할 수 있습니다. 예를 들어, MySQL은 MAX_EXECUTION_TIME 최적화 힌트를 통해 SELECT 쿼리별로 밀리초 단위의 타임아웃을 설정할 수 있습니다. pt-kill 같은 다른 도구들과 함께 사용하면 잘못된 쿼리가 데이터베이스에 과부하를 주는 것을 방지할 수 있습니다.
이 글에서 단 한 가지만 기억하라고 한다면 가능한 모든 곳에서 짧은 타임아웃을 조사하고 설정하라는 것입니다. 하지만 "적절한 타임아웃은 얼마일까요?"라고 궁금해하실 수 있을텐데요. 궁극적으로 이는 여러분 애플리케이션의 고유한 상황에 달려 있으며, 모니터링을 통해 추론할 수 있습니다. 하지만 제 경험상 1초의 연결 타임아웃과 5초의 쓰기 및 읽기 또는 쿼리 타임아웃이 적당한 시작점입니다. 최종 사용자의 관점에서 이 대기 시간을 생각해보세요: 페이지가 성공적으로 로드되거나 오류를 표시하는 데 5초 이상 기다리고 싶으신가요?(전혀요)
2. 서킷 브레이커 설치하기
타임아웃은 포기하기 전에 얼마나 오래 기다릴지에 대한 상한선을 정합니다. 하지만 다운된 서비스는 보통 한동안 그 상태로 유지되는 경향이 있어서, 짧은 시간 내에 여러 번의 타임아웃이 발생하면 아예 시도하지 않는 것으로 이를 개선할 수 있습니다. 여러분의 집이나 아파트에서 볼 수 있는 회로 차단기와 마찬가지로, 일단 회로가 열리거나 차단되면 아무것도 통과하지 않습니다.
Shopify는 Ruby에서 Net::HTTP, MySQL, Redis, 그리고 gRPC 서비스를 서킷 브레이커로 보호하기 위해 Semian을 개발했습니다. 서비스가 다운된 것을 감지하면 즉시 예외를 발생시켜, 예상되는 또 다른 타임아웃을 기다리지 않고 리소스를 절약합니다. 경우에 따라 이러한 예외를 처리하여 대체 방안을 제공할 수 있습니다.
Semian은 HTTP 엔드포인트의 호스트와 포트를 연결하여 보호할 리소스의 식별자를 만들 것을 권장합니다. 전 세계적인 결제 처리는 보통 단일 엔드포인트를 사용하지만, 종종 결제 게이트웨이는 승인률을 최적화하고 비용을 낮추기 위해 뒤에서 로컬 매입사를 사용합니다. Shopify Payments 신용카드 거래의 경우, 우리는 엔드포인트 호스트와 포트에 판매자의 국가 코드를 추가하여 더 세분화된 Semian 식별자를 만듭니다. 이렇게 하면 한 국가의 로컬 장애로 인한 열린 회로가 다른 국가의 판매자들의 거래에 영향을 미치지 않습니다.
당연히 Semian과 다른 서킷 브레이커 구현들은 애플리케이션에 추가하는 것만으로 모든 안정성 문제를 해결해주는 은총알이 아닙니다. 애플리케이션이 실패할 수 있는 방식과 대체 방안이 어떤 모습일지 이해해야 합니다. 규모가 커지면 서킷 브레이커도 여전히 많은 리소스(그리고 돈)를 낭비할 수 있습니다.
3. 용량 이해하기
대기열(Queue) 이론을 조금만 이해하면 시스템이 부하 상태에서 어떻게 동작할지 추론하는 데 큰 도움이 됩니다. 간단히 요약하면, 리틀의 법칙은 "시스템 내 평균 고객 수(일정 기간 동안)는 평균 도착률에 시스템 내 평균 체류 시간을 곱한 것과 같다"고 말합니다. 도착률은 시스템에 들어오고 나가는 고객의 양입니다.
처음에는 깨닫지 못할 수 있지만, 대기열은 어디에나 있습니다: 식료품점, 교통, 공장, 그리고 최근에 다시 발견한 바와 같이 축제장의 화장실 앞에서도요. 농담은 접어두고, 온라인 애플리케이션에서도 대기열을 찾을 수 있습니다. 백그라운드 작업, Kafka 이벤트, 웹 요청 모두 대기열에서 처리되는 작업 단위의 예입니다. 수식으로 표현하면, 리틀의 법칙은 용량 = 처리량 * 지연 시간입니다. 이는 처리량 = 용량 / 지연 시간을 의미합니다. 더 실용적인 용어로 말하자면: 대기열에 50개의 요청이 도착하고 요청 처리에 평균 100밀리초가 걸린다면, 우리의 처리량은 초당 500개의 요청입니다.
대기열 크기, 처리량, 지연 시간 간의 관계가 명확해졌으니, 변수 중 하나를 변경하면 어떤 영향이 있는지 추론할 수 있습니다. N+1 쿼리는 요청의 지연 시간을 증가시키고 처리량을 낮춥니다. 들어오는 요청의 양이 우리의 용량을 초과하면 요청 대기열이 늘어나고 어느 시점에서 클라이언트는 요청이 처리되기를 기다리다가 타임아웃됩니다. 어느 시점에서는 들어오는 작업량에 제한을 둬야 합니다 - 애플리케이션이 세상의 속도를 따라갈 수는 없으니까요. 속도 제한(rate limiting)과 부하 감소(load shedding)는 이를 위한 두 가지 기술이죠.
4. 모니터링 및 알림 추가하기
대기열에 대한 새로운 이해를 바탕으로, 이제 우리는 시스템이 과부하로 인해 다운될 위험이 있는지 알기 위해 어떤 종류의 지표를 모니터링해야 하는지 더 잘 알게 되었습니다. Google의 사이트 신뢰성 엔지니어링(SRE) 책은 사용자 대면 시스템을 모니터링해야 할 네 가지 황금 신호를 나열해 설명해주죠.
- 지연 시간: 작업 단위를 처리하는 데 걸리는 시간으로, 성공과 실패로 나누어 측정합니다. 서킷 브레이커를 사용하면 실패가 매우 빠르게 발생할 수 있어 오해의 소지가 있는 그래프로 이어질 수 있습니다.
- 트래픽: 새로운 작업이 시스템에 들어오는 속도로, 일반적으로 분당 요청 수로 표현됩니다.
- 오류: 예상치 못한 일이 발생하는 비율입니다. 결제에서는 우리는 결제 실패와 오류를 구분합니다. 예를 들어, 잔액 부족으로 인한 결제 거절은 전혀 예상치 못한 일이 아닙니다. 반면에 우리의 금융 파트너로부터 받는 HTTP 500 응답 코드는 오류입니다. 그러나 실패율의 갑작스러운 증가는 추가 조사가 필요할 수 있습니다.
- 포화도: 시스템이 총 용량 대비 얼마나 부하를 받고 있는지를 나타냅니다. 이는 사용 가능한 메모리 대비 사용된 메모리의 양이나 시스템의 모든 계층에서 스레드 풀의 총 스레드 수 대비 활성 스레드 수일 수 있습니다.
5. 구조화된 로깅 구현하기
지표가 우리 시스템의 동작에 대한 고수준 개요를 제공한다면, 로깅은 단일 웹 요청이나 백그라운드 작업 내부에서 무슨 일이 일어났는지 이해할 수 있게 해줍니다. Ruby on Rails의 기본 로그는 사람이 읽기에는 친숙하지만 기계가 파싱하기는 어렵습니다. 애플리케이션 서버가 하나뿐이라면 괜찮을 수 있지만, 그 이상으로 확장되면 로그를 중앙 집중식으로 저장하고 쉽게 검색할 수 있게 만들고 싶어질 것입니다. 이는 key-value 쌍이나 JSON과 같은 기계가 읽을 수 있는 형식으로 구조화된 로깅을 사용함으로써 가능해집니다. 이렇게 하면 로그 집계 시스템이 데이터를 파싱하고 인덱싱할 수 있습니다.
분산 시스템에서는 일종의 연관 식별자를 전달하는 것이 유용합니다. 가상의 예를 들어보겠습니다. 구매자가 체크아웃에서 결제를 시작할 때, Rails 컨트롤러에서 correlation_id가 생성됩니다. 이 식별자는 민감한 신용카드 데이터를 처리하는 결제 서비스에 API 호출을 하는 백그라운드 작업에 전달됩니다. 이 식별자는 API 매개변수와 SQL 쿼리 주석에 포함됩니다. 체크아웃 프로세스의 이러한 구성 요소들이 모두 correlation_id를 로깅하기 때문에, 이 결제 시도를 디버그해야 할 때 관련된 모든 로그를 쉽게 찾을 수 있습니다.
6. 멱등성 키 사용하기
분산 시스템은 대부분의 시간에는 안정적으로 보이더라도 신뢰할 수 없는 네트워크를 사용합니다. Shopify의 규모에서는 백만 번에 한 번 일어날 법한 신뢰할 수 없는 일이 결제 처리 중에 발생한다는 것은 하루에 여러 번 일어난다는 뜻입니다. 이것이 타임아웃된 결제 API 호출이라면, 우리는 요청을 재시도하고 싶지만 안전하게 해야 합니다. 고객의 카드를 두 번 청구하는 것은 카드 소지자에게 짜증나는 일일 뿐만 아니라, 판매자가 이중 청구를 알아채고 환불하지 않으면 잠재적인 지불 거절에 노출될 수 있습니다. 이중 환불 역시 판매자의 비즈니스에 좋지 않죠.
간단히 말해, 우리는 가끔 발생할 수 있는 문제로 인해 API 요청을 한 번 이상 보내게 되더라도 결제나 환불이 정확히 한 번만 일어나기를 원합니다. 우리의 중앙 집중식 결제 서비스는 각각에 대해 고유한 멱등성 키를 보내서 시도를 추적할 수 있습니다. 이 시도는 최소한 한 번 이상의 (재시도된) 동일한 API 요청으로 구성됩니다. 멱등성 키는 시도가 완료한 단계들(예: 거래의 로컬 데이터베이스 레코드 생성)을 조회하고 우리의 금융 파트너들에게 단 한 번의 요청만 보내도록 합니다. 이러한 단계 중 어느 하나라도 실패하고 동일한 멱등성 키를 가진 재시도된 요청이 수신되면, 계속하기 전에 동일한 상태를 재생성하기 위해 복구 단계가 실행됩니다.
멱등성 키는 우리가 요청을 재시도 가능하게 유지하고 싶은 시간 동안 고유해야 합니다. 일반적으로 24시간 이하입니다. 우리는 이러한 멱등성 키에 무작위 버전 4UUID 대신 보편적으로 고유하고 사전식으로 정렬 가능한 식별자(ULID)를 사용하는 것을 선호합니다. ULID는 48비트 타임스탬프와 80비트의 무작위 데이터로 구성됩니다. 타임스탬프 덕분에 ULID를 정렬할 수 있어, 데이터베이스가 인덱싱에 사용하는 B-트리 데이터 구조와 훨씬 더 잘 작동합니다. Shopify의 한 고처리량 시스템에서는 멱등성 키를 UUIDv4에서 ULID로 전환함으로써 INSERT 문 실행 시간이 50% 감소하는 것을 확인했습니다.
7. 정산(Reconciliation)을 일관되게 하기
정산을 통해 우리의 기록이 금융 파트너들의 기록과 일치하는지 확인합니다. 우리는 청구나 환불과 같은 개별 기록과 아직 판매자에게 지급되지 않은 현재 잔액과 같은 집계를 조정합니다. 정확한 기록을 유지하는 것은 단순히 표시 목적만이 아닙니다. 일부 관할 구역에서는 판매자를 위해 생성해야 하는 세금 양식의 입력으로도 사용됩니다.
불일치가 발생하면 우리는 이를 데이터베이스에 이상(anomaly)으로 기록합니다. 예를 들어, MismatchCaptureStatusAnomaly는 로컬에서 캡처된 청구 상태가 우리의 금융 파트너들이 반환한 상태와 동일하지 않음을 표현합니다. 종종 우리는 자동으로 이 불일치를 해결하려 시도하고 이상을 해결된 것으로 표시할 수 있습니다. 이것이 불가능한 경우, 개발자 팀이 이상을 조사하고 필요에 따라 수정 사항을 배포합니다.
가능한 경우 자동 수정을 시도하지만, 우리는 시스템이 무엇을 했고 얼마나 자주 일어났는지 알기 위해 불일치를 추적하고 싶어 합니다. 우리는 이상을 해결하는 데 의존하는 것을 최후의 수단으로 삼아야 하며, 처음부터 이상이 생기지 않도록 하는 해결책을 선호해야 합니다.
8. 부하 테스트 통합하기
리틀의 법칙은 유용한 정리지만, 실제로는 더 복잡합니다: 작업 처리 시간이 균일하게 분포되어 있지 않아 100% 포화도를 달성하는 것은 불가능합니다. 실제로는 70-80% 정도에서 대기열 크기가 증가하기 시작하며, 대기열에서 기다리는 시간이 클라이언트 타임아웃을 초과하면 클라이언트 입장에서는 우리 서비스가 다운된 것과 같습니다. 들어오는 작업량이 충분히 크다면 서버의 메모리가 대기열에 있는 작업을 저장하기에 부족해져 충돌할 수도 있습니다.
우리는 다양한 방법으로 대기열 크기를 제어할 수 있습니다. 예를 들어, 우리는 스크립트로 짤 수 있는 로드 밸런서를 사용하여 주어진 시간에 발생하는 체크아웃의 양을 조절합니다. 구매자에게 좋은 사용자 경험을 제공하기 위해, 체크아웃하려는 구매자의 수가 우리의 용량을 초과하면 이 구매자들을 대기열(다시 말하지만, 대기열은 어디에나 있습니다!)에 배치한 후 주문 결제를 허용합니다.
우리는 특별히 설정된 벤치마크 스토어에서 대규모 플래시 세일을 시뮬레이션하여 우리 시스템의 한계와 보호 메커니즘을 정기적으로 테스트합니다. 특히 결제를 종단간으로 부하 테스트할 때 우리는 약간의 문제에 직면하는데요. 우리의 금융 파트너들의 테스트 및 스테이징 환경은 프로덕션과 동일한 용량이나 지연 시간 분포를 가지고 있지 않습니다. 이를 해결하기 위해 우리의 벤치마크 스토어는 이러한 특성을 모방하는 응답을 제공하는 특별한 벤치마크 게이트웨이로 구성되어 있습니다.
9. 사고 관리 체계 확립하기
이 글의 시작 부분에서 언급했듯이, 우리는 실패를 완전히 피할 수 없으며 이에 대비해야 한다는 것을 알고 있습니다. 사고는 보통 온콜 서비스 담당자들이 호출을 받으면서 시작됩니다. 이는 모니터링을 기반으로 한 자동 알림이거나 누군가가 문제를 발견하고 수동으로 알리는 경우일 수 있습니다. 문제가 확인되면, 우리의 Slack 봇 spy에 명령을 보내 사고 프로세스를 시작합니다.
대화는 할당된 사고 채널로 옮겨가며, 여기에는 세 가지 역할이 관여합니다:
- 사고 관리자 온콜(IMOC): 사고 조정 책임
- 지원 대응 관리자(SRM): 공개 커뮤니케이션 책임
- 서비스 담당자(들): 안정성 복구 작업 수행
문제가 완화되면 사고가 종료되고, Slack 봇은 우리의 서비스 데이터베이스 애플리케이션에 서비스 중단 기록을 생성합니다. 이 중단 기록에는 초기 이벤트 타임라인, 중요하다고 표시된 Slack 메시지, 그리고 관련된 사람들의 목록이 포함됩니다.
10. 사후 회고
우리는 사고 발생 후 1주일 이내에 사후 회고 미팅을 갖는 것을 목표로 합니다. 이 미팅에서는:
- 정확히 무슨 일이 일어났는지 깊이 파고듭니다
- 우리 시스템에 대해 어떤 잘못된 가정을 했는지 살펴봅니다
- 같은 일이 다시 발생하는 것을 방지하기 위해 무엇을 할 수 있는지 논의합니다
이러한 것들이 이해되면, 일반적으로 같은 일이 다시 발생하는 것을 방지하기 위한 안전장치를 구현하는 몇 가지 실행 항목이 할당됩니다.
회고는 문제를 예방하려는 시도에만 좋은 것이 아니라, 팀의 새로운 구성원들에게 귀중한 학습 도구이기도 합니다. Shopify에서는 모든 사고의 세부 사항이 모든 직원들이 배울 수 있도록 내부적으로 공개됩니다. 잘 문서화된 사고는 온콜 로테이션에 합류하는 새로운 구성원들을 위한 훈련 도구로도 사용될 수 있습니다. 참조할 수 있는 보관된 문서로 사용하거나 재난 역할 연기 시나리오를 만드는 데 사용될 수 있습니다.
저는 2016년에 Shopify가 Digital by Design 회사가 되기 전에 네덜란드에서 캐나다로 이 직업을 위해 이사왔습니다. 일하는 동안 저는 종종 이 네덜란드 속담을 떠올립니다.
판매자들이 온라인이나 대면 결제를 위해 Shopify Payments를 선택한다면 그들의 생계가 우리에게 달려 있으며, 우리는 그 책임을 진지하게 받아들입니다. 실패를 완전히 피할 수는 없지만, 우리는 다운타임을 최소화하고, 영향 범위를 제한하며, 실패에 탄력적인 애플리케이션을 구축하기 위해 많은 개념과 기술을 적용합니다.
여러분이 어디에 있든, 여러분의 다음 여정은 여기에서 시작됩니다! 실제 문제를 해결하기 위해 시스템을 처음부터 구축하는 것에 관심이 있다면, 우리 엔지니어링 블로그에는 우리가 마주친 다른 도전 과제들에 대한 이야기가 있습니다.
Top 1% 개발자로 거듭나는 확실한 처방전, 데브필입니다.
댓글
의견을 남겨주세요