정확히 한 번은 없습니다, 멱등하게 만들 뿐입니다
결제나 주문처럼 되돌리기 어려운 요청을 다루는 백엔드 엔지니어라면 이 질문에 이미 여러 번 걸려 넘어졌을 것입니다. 요청을 보냈는데 응답이 오지 않습니다. 다시 보내야 할까요. 이 글을 다 읽고 나면 그 질문에 즉답할 수 있게 됩니다. 답은 재시도 로직을 더 정교하게 짜는 데 있지 않습니다. 애초에 잘못된 목표를 쫓고 있었다는 사실을 인정하는 데 있습니다.
많은 팀이 암묵적으로 정확히 한 번이라는 목표를 세웁니다. 요청은 정확히 한 번 도착하고 정확히 한 번 처리되고 정확히 한 번 응답해야 한다는 것입니다. 이 목표는 우아하지만 네트워크로 연결된 두 컴퓨터 사이에서는 성립하지 않습니다. 성립하지 않는다는 사실을 받아들이면 오히려 설계가 단순해집니다. 정확히 한 번을 포기하고 여러 번 보내도 결과가 한 번만 일어나게 만드는 쪽으로 목표를 바꾸는 것입니다. 이것이 멱등성입니다. 아래에서는 왜 정확히 한 번이 원리적으로 불가능한지부터, 멱등성을 실제 코드로 구현할 때 무너지는 지점과 그것을 막는 구체적인 방법까지 순서대로 다룹니다.
글의 핵심 개념을 형상화했습니다.
타임아웃이 알려주지 않는 것
요청을 보내고 응답을 받지 못했을 때 서버 쪽에서 실제로 일어났을 수 있는 일은 최소 네 가지입니다. 요청이 아예 도착하지 못했을 수도 있고, 도착했지만 처리 도중 서버가 죽었을 수도 있고, 처리는 끝났지만 응답이 돌아오는 길에 사라졌을 수도 있고, 지금도 처리가 진행 중일 수도 있습니다. 클라이언트가 관찰하는 화면은 이 네 경우 모두 똑같습니다. 아무것도 오지 않았다는 사실 하나뿐입니다.
여기서 중요한 것은 정보가 조금 부족하다는 정도가 아니라 정보량이 정확히 0이라는 사실입니다. 타임아웃을 실패로 간주하고 예외 처리 경로로 흘려보내는 코드는 사실 저 네 가지 가능성 중 하나를 임의로 골라 찍은 셈입니다. 그리고 세 번째 경우, 즉 처리는 완전히 끝났는데 응답만 사라진 경우에는 그 찍기가 확실히 틀립니다. 이미 끝난 결제를 다시 요청하면 이중 결제가 됩니다.
더 불편한 사실은 이 문제를 상태 조회로 해결하려 해도 같은 함정에 다시 빠진다는 점입니다. 확인 요청 자체도 타임아웃 날 수 있기 때문입니다. 무한히 확인만 반복할 수는 없는 노릇입니다. 그래서 결론은 타임아웃이라는 사건 자체를 없애려는 시도를 그만두는 것입니다. 타임아웃은 네트워크의 근본 성질이지 버그가 아닙니다. 대신 타임아웃이 나더라도 다시 보내는 행위 자체를 안전하게 만드는 쪽으로 방향을 바꿔야 합니다.
정확히 한 번이 실패하는 이유
정확히 한 번이라는 표현은 마케팅 문구로는 매력적이지만 엄밀하게 따지면 성립할 수 없는 약속입니다. 메시지 전달을 두 컴퓨터 사이에서 완전히 신뢰성 있게 딱 한 번만 이루어지게 하려면 송신 측과 수신 측이 서로의 상태를 완벽하게 동기화해야 합니다. 동기화 자체가 또 다른 메시지 전달이고, 그 메시지 전달 역시 유실될 수 있습니다. 이 순환은 끝나지 않습니다. 통신 이론에서 오래전부터 알려진 이 한계는 결제 시스템이라고 예외가 아닙니다.
실무에서 흔히 마주치는 옵션은 사실 두 가지뿐입니다. 최대 한 번, 그리고 최소 한 번입니다. 최대 한 번은 확실치 않으면 보내지 않는 쪽을 택합니다. 응답을 못 받으면 포기합니다. 최소 한 번은 확실치 않으면 다시 보내는 쪽을 택합니다. 결과가 두 번 이상 일어날 위험을 감수합니다. 대부분의 시스템은 데이터 유실보다 중복을 감당하는 편이 쌉니다. 결제가 안 되는 것보다 이중 결제가 감지되어 환불하는 편이 그나마 다루기 쉽습니다. 그래서 실무는 대체로 최소 한 번을 택하고 그 위에서 중복이 일어나도 결과가 한 번만 반영되게 만드는 장치를 얹습니다. 그 장치가 멱등성입니다.
정확히 한 번을 정확히 한 번인 것처럼 겉으로만 흉내 낼 수는 있습니다. 최소 한 번으로 전달하되 수신 측에서 중복을 완전히 걸러내면, 전체 시스템은 정확히 한 번처럼 동작합니다. 다만 이 동작은 전달 계층의 성질이 아니라 애플리케이션 계층에서 만들어 낸 성질입니다. 이 구분이 이 글 전체의 핵심입니다. 전달을 완벽하게 만들려 하지 말고 중복 전달을 견디는 처리 계층을 만들어야 합니다.
멱등 키, 누가 만들어야 하는가
중복을 걸러내려면 먼저 두 요청이 같은 요청이라는 사실을 판정할 기준이 있어야 합니다. 그 기준이 멱등 키입니다. 여기서 실무가 자주 무너지는 첫 지점은 키를 누가 만드는가입니다. 답은 명확합니다. 요청을 보내는 클라이언트가 만들어야 합니다.
이유는 앞서 다룬 타임아웃의 성질에서 바로 나옵니다. 서버가 요청을 받은 뒤에 키를 새로 생성하면, 재시도로 들어온 두 번째 요청은 새로운 키를 받게 됩니다. 서버 입장에서 그 둘은 서로 다른 요청이 되어 버리고 중복 제거는 시작도 하기 전에 무의미해집니다. 두 요청이 논리적으로 같다는 사실을 알고 있는 유일한 주체는 그 요청을 만든 쪽뿐입니다. 그래서 계약은 이렇게 정해집니다. 클라이언트는 논리적으로 하나인 작업에 키를 한 번만 만들어 붙이고, 재시도할 때마다 반드시 같은 키를 다시 보냅니다. 서버는 그 키를 기준으로 이미 처리한 요청인지 판정합니다.
이 원칙이 명확한데도 실무에서 계속 어긋나는 데는 이유가 있습니다. 재시도 루프 안에서 요청을 만드는 함수를 다시 호출하면, 그 함수 안에 무작위 값을 채워 키를 만드는 코드가 있을 경우 매번 새 키가 생깁니다. 키 생성은 재시도 루프 바깥에서 딱 한 번 일어나야 합니다. 이 구분이 코드 구조에 눈에 띄게 드러나 있지 않으면 나중에 누군가 무심코 루프 안으로 옮겨 놓습니다.
요청 본문을 해시해서 키로 쓰는 방식도 흔한 실수입니다. 편리해 보이지만 의도적으로 같은 내용을 두 번 보내는 정상적인 요청까지 막아 버립니다. 같은 사람에게 같은 금액을 두 번 송금하는 일은 얼마든지 정상입니다. 내용이 같다는 것과 같은 작업이라는 것은 전혀 다른 개념입니다. 시각을 키에 섞는 방식도 마찬가지로 깨집니다. 재시도할 때의 시각은 원본 요청의 시각과 다르기 때문에 매번 새로운 키가 만들어지고, 중복 제거는 또다시 무력화됩니다.
상태를 되돌리지 않는다는 약속
키 저장소로 중복을 막는 방법은 강력하지만 인프라를 하나 더 얹는 일입니다. 저장소가 흔들리면 방어선도 함께 흔들립니다. 더 값싸고 더 튼튼한 두 번째 방어선이 있습니다. 별도 저장소 없이 데이터 모델 자체를 재시도에 무감각하게 만드는 방법입니다.
주문 상태가 생성, 결제완료, 배송중, 배송완료 순서로 흐른다고 해 보겠습니다. 여기에 규칙을 하나만 추가합니다. 상태는 정해진 순서로만 전진하고 절대 후퇴하지 않습니다. 이 규칙이 있으면 상태 갱신 코드는 이렇게 쓰입니다. 현재 상태가 결제완료보다 앞선 단계일 때만 결제완료로 바꿉니다. 같은 요청이 두 번 들어오면 첫 번째는 조건을 만족해 상태를 바꾸고, 두 번째는 이미 결제완료 상태이므로 조건을 만족하지 못해 아무 행도 바꾸지 못합니다. 별도의 키 저장소 없이도 재시도가 안전해집니다.
단조성은 상태 문자열에만 적용되는 개념이 아닙니다. 몇 가지 형태로 나타납니다. 순서가 있는 상태 값에서는 뒤로 못 가게 막는 방식이 있고, 한 번 참이 되면 다시 거짓이 되지 않는 표시를 쓰는 방식도 있습니다. 발송됨이나 승인됨 같은 값이 여기 해당합니다. 시각이나 버전 번호가 더 큰 갱신만 받아들이는 방식은 늦게 도착한 옛 메시지를 자동으로 걸러 줍니다. 집합에 원소를 추가만 하고 빼지 않는 방식도 있습니다. 같은 원소를 여러 번 넣어도 집합은 똑같은 결과를 유지하기 때문입니다.
조회하고 판단하고 갱신하는 세 단계의 함정
단조 상태 전이를 구현할 때 가장 흔히 저지르는 실수는 조건을 어디에 두느냐입니다. 상태를 먼저 조회하고 애플리케이션 코드에서 그 값을 보고 판단한 다음, 별도의 갱신문을 실행하는 세 단계로 나누면 앞서 없앤 그 틈이 그대로 되살아납니다. 조회와 갱신 사이에 다른 요청이 끼어들 여지가 생기기 때문입니다. 두 개의 동시 요청이 똑같이 결제완료 이전 상태를 조회하고, 둘 다 조건을 만족한다고 판단해 버리면, 결국 갱신은 두 번 일어납니다.
해법은 조건을 조회로 확인하지 않고 갱신문 자체에 넣는 것입니다. 데이터베이스에게 조건 판정과 갱신을 하나의 원자적 연산으로 처리하게 맡깁니다. 현재 상태가 결제완료 이전일 때만 결제완료로 바꾸라는 조건을 갱신 쿼리 안에 직접 써넣으면, 두 개의 동시 요청 중 먼저 도착한 하나만 조건을 통과하고 나머지는 자동으로 걸러집니다. 애플리케이션 코드가 판단을 떠맡지 않고 데이터베이스가 그 자리에서 판단과 실행을 동시에 처리하기 때문입니다.
여기서 반드시 확인해야 할 것이 하나 더 있습니다. 갱신문이 실제로 몇 개의 행을 바꿨는지입니다. 그 값이 0이면 이미 지나간 전이였다는 뜻입니다. 이것은 오류가 아니라 정상적인 중복 처리의 결과입니다. 그런데 이 값을 확인하지 않고 갱신문이 실행됐다는 사실만으로 성공을 선언하는 코드가 의외로 많습니다. 갱신된 행 수를 무시하면 중복 여부를 알 방법이 사라지고, 뒤이은 로직이 이미 끝난 일을 또 한 번 실행하게 될 위험이 남습니다.
물론 이 방식이 모든 경우를 덮지는 못합니다. 하나의 데이터베이스 트랜잭션으로 감쌀 수 없는 부분 실패, 이를테면 결제사에 요청은 보냈는데 우리 데이터베이스에 결과를 기록하기 직전에 프로세스가 죽는 경우는 단조 상태 전이만으로 해결되지 않습니다. 이런 경계는 멱등 키와 함께 써야 메워집니다. 두 방어선은 서로 대체재가 아니라 서로 다른 실패 지점을 막는 보완재입니다.
재시도도 계산이 필요합니다
수신자를 안전하게 만들었다고 해서 마음껏 재시도해도 되는 것은 아닙니다. 잘못 설계된 재시도는 그 자체로 장애를 만듭니다. 정확히 말하면 작은 장애를 큰 장애로 키우는 가장 흔한 원인이 재시도입니다. 서버가 이미 과부하 상태인데 클라이언트들이 일제히 재시도를 시작하면, 그 재시도 트래픽이 서버가 회복할 기회 자체를 없애 버립니다.
첫 번째 규칙은 모든 실패를 재시도하지 않는 것입니다. 인증이 틀렸는데 다시 보내면 또 틀립니다. 요청 형식이 잘못됐는데 다시 보내도 결과는 똑같습니다. 이런 실패를 재시도하는 것은 서버 자원을 태우면서 결과만 늦추는 일입니다. 반대로 일시적인 과부하나 순간적인 네트워크 끊김은 재시도로 대부분 해결됩니다. 이 판정 기준은 사람의 감이 아니라 코드 안에 표로 명시되어 있어야 합니다. 연결 자체가 실패했다면 재시도하고, 서버가 일시적으로 과부하 상태라면 재시도하고, 인증 오류나 요청 형식 오류라면 재시도하지 않습니다.
타임아웃은 이 표에서 유일하게 조건부입니다. 요청이 상대편에 도달했는지 자체를 알 수 없기 때문입니다. 수신자가 멱등하다면 재시도해도 되고, 멱등하지 않다면 재시도 대신 상태 조회로 넘어가야 합니다. 이 칸의 답이 상대방의 성질에 따라 달라진다는 점이 중요합니다. 그래서 외부 결제 API를 연동할 때 가장 먼저 확인해야 할 것은 그쪽이 멱등 키를 지원하는가입니다. 지원한다면 타임아웃을 안전하게 재시도할 수 있고, 지원하지 않는다면 타임아웃이 날 때마다 사람이 개입해야 하는 구조를 벗어날 수 없습니다.
재시도 간격도 무작위 요소 없이 고정 값으로 두면 위험합니다. 동시에 타임아웃을 겪은 클라이언트 여러 대가 똑같은 간격으로 다시 몰려오면 서버는 회복할 틈도 없이 다시 밀려드는 트래픽을 맞이합니다. 간격을 점점 늘려 가면서 약간의 무작위성을 섞어야 재시도들이 서로 흩어져서 도착합니다. 서버가 응답에 재시도 가능 여부와 권장 대기 시간을 실어 주면 클라이언트 구현은 훨씬 단순해집니다. 판단을 클라이언트 쪽 추측에 맡기지 않고 서버가 직접 알려 주는 셈이기 때문입니다.
그래서 무엇을 먼저 해야 하나요
지금까지 다룬 내용을 하나의 결정으로 압축하면 이렇게 됩니다. 정확히 한 번을 달성하려는 시도를 멈추고 최소 한 번 전달을 전제로 한 번만 일어나게 만드는 장치를 얹으십시오. 그 장치는 두 층으로 구성됩니다. 클라이언트가 만들어 서버에 넘기는 멱등 키, 그리고 데이터 모델 자체에 박아 넣은 단조 상태 전이입니다. 어느 하나만으로는 부족합니다. 키 저장소는 부분 실패를 덮지만 인프라 의존성을 늘리고, 단조 상태 전이는 인프라 없이도 튼튼하지만 여러 시스템에 걸친 실패까지 막지는 못합니다.
가장 먼저 점검할 지점은 코드베이스 안에서 조회한 뒤 판단하고 갱신하는 세 단계로 흩어진 로직입니다. 이 패턴이 있다면 그 자리가 바로 다음 장애가 시작될 지점입니다. 그다음으로 점검할 지점은 재시도 루프 안에서 매번 새로 만들어지는 값이 있는지입니다. 무작위 값이든 타임스탬프든, 재시도할 때마다 달라지는 값이 키의 일부라면 그 멱등성은 이름만 멱등성일 뿐입니다. 마지막으로 외부와 연동하는 모든 API를 두고 하나씩 물어야 합니다. 이 API는 멱등 키를 지원하는가. 답이 아니오라면, 그 연동은 지금 이 순간에도 타임아웃마다 사람의 눈을 요구하고 있는 셈입니다.