본문 바로가기
N8N 워크플로우 N8N 워크플로우

Exactly-Once를 믿고 설계했다가 주문이 두 번 처리된 이유

읽는 시간 약 8분
exactly onceeba5bc ebafbfeab3a0 ec84a4eab384ed9688eb8ba4eab080 eca3bcebacb8ec9db4 eb9190 ebb288 ecb298eba6aceb909c ec9db4ec9ca0 1790602702

주문이 두 번 처리되는 기술적 악몽

온라인 쇼핑몰에서 결제 버튼을 눌렀는데 주문 내역이 두 번 찍히거나, 은행 앱에서 이체를 했는데 돈이 두 번 빠져나가는 경험을 해본 적이 있나요? 사용자 입장에서는 당황스러운 이 현상은 개발자들에게는 ‘Exactly Once(정확히 한 번 처리)’라는 거대한 숙제와 맞닿아 있습니다. 시스템을 설계할 때 우리는 모든 것이 완벽하게 한 번만 수행되기를 바라지만, 현실의 네트워크와 서버는 결코 그렇게 호락호락하지 않습니다.

이 글에서는 왜 ‘Exactly Once’라는 이상적인 목표가 현실에서는 달성하기 어려운지, 그리고 그럼에도 불구하고 어떻게 주문 중복 처리를 방지하고 안전한 시스템을 구축할 수 있는지 실용적인 관점에서 살펴보겠습니다.

Exactly Once가 실현 불가능한 이유

컴퓨터 과학에는 ‘두 장군 문제(Two Generals Problem)’라는 유명한 사고 실험이 있습니다. 두 장군이 통신이 불안정한 상황에서 공격 시간을 합의해야 하는데, 메시지가 전달되었는지 확신할 수 없는 상황에서는 결코 100% 합의에 도달할 수 없다는 이론입니다. 분산 시스템에서의 ‘Exactly Once’도 이와 비슷합니다.

주문 시스템을 예로 들어보겠습니다. 사용자가 결제 요청을 보내면 서버는 결제를 처리하고 결과를 DB에 저장합니다. 그런데 서버가 결제를 완료한 직후, 네트워크 장애로 인해 사용자에게 ‘성공’ 응답을 보내기 전에 연결이 끊길 수 있습니다. 사용자는 응답을 받지 못했으니 다시 ‘결제하기’ 버튼을 누릅니다. 서버는 이전 결제가 성공했는지 알 길이 없으므로 다시 결제를 시도합니다. 이것이 바로 중복 주문의 시작입니다.

결국 네트워크는 언제나 실패할 수 있고, 서버는 언제나 멈출 수 있습니다. 따라서 ‘정확히 한 번’을 시스템 수준에서 보장하는 것은 물리적으로 불가능에 가깝습니다. 우리가 할 수 있는 것은 ‘최소 한 번(At Least Once)’ 처리되도록 설계한 뒤, 중복을 걸러내는 ‘멱등성(Idempotency)’을 확보하는 것입니다.

멱등성이란 무엇인가

멱등성은 ‘여러 번 수행해도 결과가 달라지지 않는 성질’을 의미합니다. 수학적으로는 f(f(x)) = f(x)인 상태를 말하죠. 주문 시스템에 적용하면, 같은 주문 요청을 10번 보내든 100번 보내든, 최종적으로는 오직 한 번만 결제되고 주문이 생성되어야 합니다.

멱등성을 구현하는 핵심 전략

  • 고유 식별자 사용: 클라이언트가 요청을 보낼 때마다 고유한 ‘주문 키(Idempotency Key)’를 생성해서 함께 보냅니다. 서버는 이 키를 데이터베이스에 기록하고, 동일한 키로 들어오는 요청은 무시하거나 이미 처리된 결과를 반환합니다.
  • 상태 기반 검증: 주문의 상태를 ‘결제 대기’, ‘결제 완료’, ‘취소’ 등으로 관리합니다. 요청이 들어오면 현재 상태를 확인하여, 이미 ‘결제 완료’ 상태라면 추가 처리를 하지 않고 이전 결과를 보여줍니다.
  • 데이터베이스 트랜잭션 활용: 데이터베이스의 고유 제약 조건(Unique Constraint)을 활용하여 주문 키가 중복되는 경우 아예 데이터 저장을 거부하도록 설계합니다.

실생활에서 적용하는 주문 방지 체크리스트

개발자나 시스템 설계자가 아니더라도, 서비스를 운영하거나 기획하는 입장에서는 다음의 원칙을 기억해야 합니다.

    • 클라이언트의 버튼 제어: 사용자가 결제 버튼을 누르는 즉시 버튼을 비활성화하거나 ‘처리 중’ 상태로 바꾸어 중복 클릭을 물리적으로 막아야 합니다.
    • 타임아웃 설정: 네트워크 응답이 없을 때 무작정 대기하기보다 적절한 타임아웃을 설정하고, 재시도 로직을 구현하되 반드시 멱등성 키를 포함해야 합니다.
    • 로그 및 추적: 어떤 요청이 중복으로 들어왔는지, 왜 중복 처리가 시도되었는지 파악할 수 있도록 요청 ID를 로그에 상세히 남겨야 합니다.

흔한 오해와 사실 관계

많은 이들이 ‘트랜잭션(Transaction)을 사용하면 중복이 발생하지 않는다’라고 오해합니다. 트랜잭션은 데이터의 일관성을 유지하는 데는 탁월하지만, 네트워크 단절로 인해 요청이 두 번 들어오는 상황 자체를 막아주지는 못합니다. 트랜잭션은 ‘작업 단위’를 묶어줄 뿐, ‘외부 요청의 중복’을 식별하는 기능은 없기 때문입니다.

또한 ‘메시지 큐(Kafka, RabbitMQ 등)를 쓰면 해결된다’는 생각도 주의해야 합니다. 메시지 큐는 메시지 전달을 보장하지만, 시스템 장애 시 메시지가 중복으로 배달될 가능성을 완전히 배제하지는 않습니다. 결국 메시지 큐를 사용하더라도 수신 측에서 멱등성을 구현하는 것이 필수적입니다.

전문가가 제안하는 비용 효율적인 방안

중복 처리를 방지하기 위해 너무 복잡한 분산 락(Distributed Lock) 시스템을 도입하면 시스템 성능이 크게 떨어질 수 있습니다. 가장 효율적인 방법은 다음과 같은 단계적 접근입니다.

1단계: 간단한 식별자 활용

클라이언트에서 생성한 UUID를 결제 요청 헤더에 담아 보냅니다. 서버는 Redis와 같은 빠른 저장소에 이 ID가 존재하는지 확인합니다. 존재한다면 즉시 이전 처리 결과를 반환합니다.

2단계: 데이터베이스 제약 조건 활용

주문 테이블의 주문 번호 컬럼에 ‘Unique’ 제약을 겁니다. 이렇게 하면 앱 레벨에서 실수가 있더라도 데이터베이스 차원에서 중복 저장을 강제로 막을 수 있습니다.

3단계: 비동기 처리와 상태 확인

사용자에게 즉각적인 응답을 주어야 한다면, 일단 요청을 큐에 넣고 ‘처리 중’ 상태를 사용자에게 보여줍니다. 이후 백그라운드 작업자가 처리 결과를 업데이트하면 사용자가 이를 조회하게 합니다.

자주 묻는 질문

질문: 사용자가 결제 버튼을 광클하면 어떻게 막나요?

답변: 프론트엔드에서 버튼을 즉시 비활성화하는 것이 가장 우선입니다. 백엔드에서는 요청에 포함된 고유 요청 ID를 통해 서버 측에서 중복 요청을 걸러내는 2중 방어 체계를 갖추어야 합니다.

질문: 멱등성 키는 누가 만들어야 하나요?

답변: 클라이언트(앱이나 웹)에서 생성하는 것이 가장 일반적입니다. 하지만 클라이언트 환경을 신뢰할 수 없다면, 서버에서 최초 요청 시 생성하여 클라이언트에게 전달하고 이후 재시도 시 사용하도록 유도할 수도 있습니다.

질문: 이미 결제가 두 번 되었을 때는 어떻게 처리하나요?

답변: 기술적인 예방이 실패했다면 결국 환불 프로세스가 필요합니다. 중복 결제 여부를 감지하는 배치 프로그램을 주기적으로 실행하여, 동일한 주문 번호로 두 번 승인된 건을 찾아내고 자동으로 취소하거나 관리자에게 알림을 보내는 대응 절차(SOP)를 마련해야 합니다.

시스템 설계의 철학

결국 ‘Exactly Once’는 도달하고자 하는 목적지이지, 우리가 지금 당장 발을 딛고 있는 땅이 아닙니다. 시스템 설계는 ‘실패를 인정하고, 실패했을 때 어떻게 복구할 것인가’에 집중해야 합니다. 중복 요청은 반드시 들어온다는 가정하에, 어떻게 하면 시스템이 이를 우아하게 무시하거나 올바르게 처리할지 고민하는 과정이 바로 견고한 소프트웨어를 만드는 길입니다.

중복 주문이라는 사소해 보이는 문제가 사실은 분산 시스템의 가장 깊은 기술적 난제와 맞닿아 있다는 점을 이해한다면, 앞으로 시스템을 설계할 때 훨씬 더 방어적이고 안전한 아키텍처를 고민할 수 있을 것입니다. 완벽한 시스템은 없지만, 완벽한 대비를 갖춘 시스템은 충분히 만들 수 있습니다.

sounghoo
함께 보면 좋은 글

댓글 0

첫 댓글을 남겨보세요.

광고 차단 알림

광고 클릭 제한을 초과하여 광고가 차단되었습니다.

단시간에 반복적인 광고 클릭은 시스템에 의해 감지되며, IP가 수집되어 사이트 관리자가 확인 가능합니다.