PH pullh
현장 노트 / 결제 코어 일부를 Rust로 옮긴 팀의 마이그레이션 저널
마이그레이션 저널 7분 Rust

결제 코어 일부를 Rust로 옮긴 팀의 마이그레이션 저널

한 번의 대형 재작성은 없었다. 대신 장애 비용이 큰 경로부터 천천히 Rust로 옮기며 신뢰를 쌓았다.

Rust 현장 이야기 커버 이미지

저희 팀은 처음부터 모든 서비스를 Rust로 바꾸려 하지 않았습니다. 대신 지난 1년간 사후 분석 문서를 가장 많이 만들어 낸 경로 하나만 골랐습니다. 승인과 캡처, 취소가 엇갈리면서 같은 결제가 두 번 잡히는 그 경로였습니다.

이 글에서 하려는 이야기는 "Rust가 빨라서 좋았다"가 아닙니다. 이관 과정에서 저희가 배운 것은 대부분 속도와 무관했고, 오히려 "무엇을 값으로 표현하지 않고 있었는가"에 관한 것이었습니다. 단계별로 무엇을 내보냈고 무엇에 놀랐는지를 순서대로 적어 둡니다.

0단계 · 어디가 아픈지부터 숫자로 적었습니다

시작은 코드가 아니라 CS 티켓이었습니다. 월요일 아침 운영팀이 "같은 주문에 두 번 청구됐다"는 문의를 캡처해 보내 왔고, 그런 티켓이 그 달에만 서른 건 넘게 쌓여 있었습니다. 금액은 크지 않았지만 환불과 사과, 대사 작업까지 합치면 건당 처리 비용이 결제 금액의 수십 배였습니다.

저희는 이관을 결정하기 전에 먼저 이 문제의 모양을 재기로 했습니다. 결제 원장에서 같은 주문 번호에 승인 레코드가 둘 이상 붙은 경우를 세어 보니, 전체의 0.02% 수준이었습니다. 작아 보이지만 그중 90% 이상이 네트워크 타임아웃 뒤의 재시도 구간에서 발생했습니다. 즉, 무작위로 흩어진 버그가 아니라 특정 경로에 몰려 있는 결함이었습니다. 몰려 있다는 사실이 이관 범위를 정해 줬습니다. 전체를 다시 쓸 필요가 없었습니다.

중복 승인 발생률 0.02%
그중 재시도 경로 비중 91%
1차 이관 대상 코드 모듈 3개

1단계 · 그림자 트래픽으로 먼저 읽기만 했습니다

첫 배포에서 새 Rust 서비스는 아무것도 결정하지 않았습니다. 게이트웨이가 요청을 기존 서비스로 보내면서 같은 페이로드의 사본을 Rust 쪽으로도 흘려보냈고, Rust 서비스는 판단 결과를 응답 대신 로그로만 남겼습니다. 그리고 두 결과를 매 시간 대조하는 diff 리포트를 만들었습니다.

이 단계에서 저희가 내보낸 것은 기능이 아니라 관측 장치였습니다. 그리고 곧 그 장치가 이관 전체에서 가장 값진 산출물이 됩니다.

그림자 트래픽 diff 리포트 (예시, 24시간 집계)

비교 요청 수            : 1,842,904
결정 일치              : 1,842,731  (99.9906%)
결정 불일치            :       173

불일치 분류:
  idempotency_key_mismatch : 168   ## 두 서비스가 다른 키를 계산
  amount_rounding          :   4
  status_naming            :   1

idempotency_key_mismatch 샘플:
  legacy : sha256("ORD-77412|1|13500|KRW|card")
  rust   : sha256("ORD-77412|1|13500.00|KRW|CARD")
  -> 동일 요청, 서로 다른 키

불일치 173건 중 168건이 한 가지 원인이었습니다. 같은 논리적 요청에 대해 두 서비스가 서로 다른 멱등키를 만들고 있었습니다. 금액을 문자열로 붙일 때 기존 서비스는 정수로, 새 서비스는 소수점 두 자리로 직렬화했고, 결제 수단 코드도 한쪽은 소문자, 한쪽은 대문자였습니다. 사람 눈에는 같은 요청이지만 키 저장소 입장에서는 완전히 다른 두 개의 요청이었습니다.

틀린 가설: 재시도 폭주가 커넥션을 말린다고 믿었습니다

여기서 고백할 것이 있습니다. 저희는 그림자 리포트를 보기 전까지, 중복 승인의 원인을 이미 안다고 생각하고 있었습니다. 팀 안에서 몇 달째 공유되던 설명은 이랬습니다. 결제사 응답이 느려지면 클라이언트가 재시도를 던지고, 재시도가 몰리면 DB 커넥션 풀이 마르고, 커넥션을 못 얻은 멱등키 조회가 실패하면서 코드가 "처음 보는 요청"으로 취급해 승인을 한 번 더 낸다는 그림이었습니다.

그럴듯했고, 정황도 맞았습니다. 중복이 몰리는 시간대는 응답 지연이 튀는 시간대와 겹쳤습니다. 그래서 저희가 세운 대책도 그 가설에 맞춰져 있었습니다. 커넥션 풀을 키우고, 재시도에 지터를 넣고, 서킷 브레이커를 붙이는 계획이었습니다.

이 가설을 검증하려고 두 가지를 했습니다. 첫째, 중복 승인이 발생한 시각의 커넥션 풀 지표를 전부 끌어와 붙였습니다. 둘째, 스테이징에서 재시도를 평소의 20배로 쏟아부어 풀을 의도적으로 고갈시킨 뒤 중복이 재현되는지 봤습니다.

가설 검증 결과 (예시)

[운영 데이터] 중복 승인 발생 시각의 커넥션 풀 상태 (n=412)
  pool_size            : 64
  in_use p99           : 38
  wait_queue_depth p99 : 0
  acquire_timeout      : 0건
  -> 풀이 마른 시점과 중복 발생 시점의 교집합: 0건

[스테이징] 재시도 20배 부하로 풀 고갈 유도
  acquire_timeout      : 5,140건
  요청 실패            : 5,140건 (503 반환)
  중복 승인            : 0건
  -> 풀이 마르면 중복이 아니라 그냥 실패한다

두 결과 모두 가설을 죽였습니다. 운영에서 중복이 난 순간에 커넥션 풀은 한 번도 포화되지 않았고, 스테이징에서 풀을 실제로 말려 보니 시스템은 중복을 만드는 대신 정직하게 503을 냈습니다. 커넥션 고갈은 가용성 문제였지 정합성 문제가 아니었습니다.

진짜 원인은 그림자 diff가 알려 줬습니다. 재시도는 원인이 아니라 조건이었습니다. 재시도가 발생해야 두 번째 요청이 생기고, 그 두 번째 요청이 첫 번째와 다른 키를 갖고 있으면 멱등 검사는 완벽하게 동작하면서도 중복을 통과시킵니다. 저희가 몇 달 동안 "부하 문제"라고 부르던 것은 사실 직렬화 규칙이 한 곳에 정의되어 있지 않다는 설계 문제였습니다. 부하는 그 결함을 자주 실행시켰을 뿐입니다.

이 경험 이후 저희는 원인 후보를 나눌 때 "이 가설이 참이라면 반드시 함께 보여야 할 지표가 무엇인가"를 먼저 적습니다. 커넥션 가설의 경우 그것은 acquire_timeout이었고, 그 값이 0이라는 사실만으로 논쟁은 끝났어야 했습니다.

"이 PR에서 제일 좋은 부분은 Rust 코드가 아니라, 키를 만드는 방법이 이제 코드베이스에 딱 한 군데만 있다는 점입니다. 예전 버전도 이 함수를 부르게 만들어 주세요."

1차 이관 PR 리뷰 코멘트

2단계 · 금액과 키를 타입으로 못 박았습니다

원인을 알고 나니 고칠 것은 분명했습니다. 금액을 부동소수점이나 문자열로 떠다니게 두지 않고 최소 단위 정수 위의 뉴타입으로 고정하고, 멱등키 생성을 그 타입만 받는 함수 하나로 좁히는 일이었습니다. 재미있는 것은 이 작업이 Rust 이관의 부산물이 아니라 이관을 정당화한 이유가 되었다는 점입니다. 컴파일러가 "여기서 금액을 문자열로 바꾸고 있다"를 잡아 주기 때문에 규칙이 시간이 지나도 풀리지 않습니다.

money.rs / idempotency.rs — 실제로 바꾼 핵심

/// 최소 단위 정수로만 표현되는 금액. 통화가 다르면 섞이지 않는다.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Money {
    minor: i64,
    currency: Currency,
}

impl Money {
    pub fn from_minor(minor: i64, currency: Currency) -> Self {
        Self { minor, currency }
    }

    /// 정규 표기는 오직 여기서만 만든다. Display 구현은 일부러 두지 않았다.
    pub fn canonical(&self) -> String {
        format!("{}:{}", self.minor, self.currency.as_code()) // "13500:KRW"
    }
}

impl std::ops::Add for Money {
    type Output = Result<Money, MoneyError>;
    fn add(self, rhs: Money) -> Self::Output {
        if self.currency != rhs.currency {
            return Err(MoneyError::CurrencyMismatch);
        }
        self.minor
            .checked_add(rhs.minor)
            .map(|m| Money::from_minor(m, self.currency))
            .ok_or(MoneyError::Overflow)
    }
}

/// 멱등키를 만드는 유일한 경로. 필드 순서와 표기를 여기서 고정한다.
pub fn idempotency_key(req: &AuthorizeRequest) -> IdempotencyKey {
    let parts: Vec<String> = vec![
        req.order_id.as_str().to_owned(),
        req.attempt_group.to_string(),
        req.amount.canonical(),                  // Money만 받는다
        req.method.as_code().to_ascii_uppercase(),
    ];
    IdempotencyKey::from_digest(sha256(parts.join("|").as_bytes()))
}

여기서 가장 중요한 결정은 Money에 Display를 구현하지 않은 것입니다. 편의를 위해 구현해 두면 누군가는 반드시 로그 포맷 문자열에 금액을 그대로 끼워 넣고, 그 문자열이 언젠가 키 계산에 흘러듭니다. 정규 표기를 만드는 방법이 하나뿐이면 그런 경로가 아예 생기지 않습니다. 통화가 다른 금액의 덧셈이 Result를 반환하도록 한 것도 같은 이유입니다. 실패 가능성을 반환 타입에 적어 두는 방식은 Rust 오류 처리 가이드에서 다루는 패턴 그대로입니다.

놀란 지점도 있었습니다. 이 타입을 도입하자 기존 코드에서 통화가 섞여 더해지던 자리가 두 군데 드러났습니다. 정산 리포트의 합계였고, 다행히 두 곳 모두 단일 통화 데이터만 흘러 실제 오류는 나지 않고 있었습니다. 버그가 아니라 언젠가 버그가 될 자리를 컴파일러가 먼저 가리킨 셈입니다.

3단계 · 옛 서비스가 새 규칙을 따르게 했습니다

보통이라면 여기서 트래픽을 새 서비스로 넘겼을 겁니다. 저희는 순서를 바꿨습니다. 먼저 기존 서비스가 키 계산을 스스로 하지 않고 새 서비스의 키 생성 엔드포인트를 호출하도록 바꿨습니다. 트래픽은 여전히 옛 경로로 흘렀지만, 키의 정의는 한 곳으로 모였습니다.

이 배포만으로 중복 승인 지표가 눈에 띄게 내려갔습니다. 아직 Rust가 결제를 처리하지 않는 상태에서 문제의 대부분이 사라진 것입니다. 팀에게는 이 순간이 이관 전체에서 가장 교훈적이었습니다. 저희가 얻은 이득은 언어에서 온 것이 아니라, 언어를 도입하는 과정에서 강제로 명확해진 정의에서 왔습니다.

그다음 그림자 diff를 다시 돌려 불일치가 0에 수렴하는 것을 이틀간 확인한 뒤에야 실제 트래픽을 1%, 10%, 50%, 100% 순으로 옮겼습니다. 각 단계마다 롤백 조건을 미리 문서에 적어 두었습니다. 불일치율이 0.001%를 넘거나 p99 응답이 기준선의 1.5배를 넘으면 자동으로 되돌리는 규칙이었습니다. 실제로 10% 구간에서 한 번 롤백했는데, 원인은 새 서비스가 아니라 배포 설정에서 커넥션 상한을 잘못 적은 것이었습니다.

4단계 · 이관 이후 남은 일

전량 이관 뒤에도 저희는 그림자 비교 장치를 끄지 않았습니다. 대신 방향을 뒤집어, 옛 서비스를 축소 배포로 남겨 두고 새 서비스의 결정과 계속 대조했습니다. 3개월 동안 새 불일치가 없다는 것을 확인하고서 옛 경로를 지웠습니다. 이 3개월이 길게 느껴졌지만, 결제 도메인에서 "지워도 된다"는 확신은 대체로 시간과 데이터로만 살 수 있었습니다.

테스트 쪽 변화도 적어 둡니다. 저희는 멱등키 생성 함수에 속성 기반 테스트를 붙였습니다. 같은 논리적 요청을 서로 다른 순서와 표기로 만들어도 항상 같은 키가 나오는지, 금액이 1원이라도 다르면 반드시 다른 키가 나오는지를 무작위 입력으로 검사합니다. 이 테스트는 사고를 규칙으로 굳혀 두는 장치였고, 접근 방식은 Rust 테스트 가이드에 정리된 내용과 같습니다.

사례에 관한 안내 위 기록은 결제 시스템 이관에서 흔히 반복되는 상황들을 하나의 가상 팀 이야기로 엮어 각색한 것입니다. 지표와 코드, 리뷰 코멘트는 설명을 위해 구성한 예시이며 실제 기업이나 서비스의 데이터가 아닙니다.

도입 뒤에 남은 기준

  • 이관은 트래픽을 옮기기 전에 두 구현을 비교하는 장치부터 만듭니다. 그 리포트가 원인 분석의 절반을 해결했습니다.
  • 돈은 부동소수점이나 문자열이 아니라 통화가 붙은 최소 단위 정수 뉴타입으로 다룹니다.
  • 키나 서명처럼 정규 표기가 필요한 값은 그것을 만드는 함수를 하나만 남깁니다.
  • 가설을 세울 때 "참이라면 함께 보여야 할 지표"를 먼저 적고, 그 지표가 조용하면 가설을 버립니다.
  • 각 단계의 롤백 조건을 배포 전에 숫자로 확정합니다.

남은 원칙

이 이관에서 얻은 가장 옮길 만한 원칙은 언어 선택과 무관합니다. 같음의 정의를 소유한 곳이 두 군데면, 그 시스템에는 아직 멱등성이 없습니다. 저희 시스템은 멱등키 검사도 있었고 유니크 인덱스도 있었고 재시도 정책 문서도 있었습니다. 없었던 것은 "무엇이 같은 요청인가"에 대한 단일한 답이었습니다. 그 답이 두 서비스에 각각 흩어져 있는 한, 검사 로직을 아무리 촘촘히 짜도 통과할 것은 통과합니다.

Rust가 여기서 한 일은 그 정의를 타입 안에 가둘 수 있게 해 준 것입니다. 같은 규율을 다른 언어에서도 지킬 수 있지만, 이 도메인에서는 규율을 사람의 주의력이 아니라 컴파일러에 맡길 수 있다는 점이 학습 비용을 갚고도 남았습니다. 다른 언어의 현장 기록은 심층 가이드 목록에서 이어 보실 수 있습니다.

Next Read

Rust 실무 가이드로 이어서 보기

러닝 커브는 있지만, 한 번 팀에 안착하면 “불안해서 손 못 대는 코드”를 줄이는 힘이 크다.