<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://seungjjun.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://seungjjun.github.io/" rel="alternate" type="text/html" /><updated>2026-05-01T05:02:33+00:00</updated><id>https://seungjjun.github.io/feed.xml</id><title type="html">개발이야기</title><subtitle>An amazing website.</subtitle><author><name>seungjjun</name><email>stw550@gmail.com</email></author><entry><title type="html">thin이냐 fat이냐, 그 전에</title><link href="https://seungjjun.github.io/architecture/thin-or-fat-event/" rel="alternate" type="text/html" title="thin이냐 fat이냐, 그 전에" /><published>2026-05-01T05:00:00+00:00</published><updated>2026-05-01T05:00:00+00:00</updated><id>https://seungjjun.github.io/architecture/thin-or-fat-event</id><content type="html" xml:base="https://seungjjun.github.io/architecture/thin-or-fat-event/"><![CDATA[<h2 id="개요">개요</h2>

<p>결제 관련 업무 중 환불 관련 기능을 만들고 있었다. 어드민에서 환불 요청이 들어오면, 별도의 환불 프로세서 모듈이 그 요청을 받아 실제 환불을 처리하도록 흐름을 분리하는 일이었다. 두 모듈 사이에 토픽을 하나 두고, 어드민은 환불 요청을 메시지로 발행하고 프로세서는 그 메시지를 소비해 환불을 처리한다. 구조 자체는 단순했다.</p>

<p>문제는 <em>“그 메시지에 무엇을 담을 것인가”</em> 였다.</p>

<p>처음에는 별 고민이 없었다. 메시지만 보고도 환불 프로세서가 곧장 일을 시작할 수 있도록, 결제 id, 환불 금액, 환불 사유를 비롯해 처리에 필요할 만한 정보를 전부 페이로드에 담아주려고 했다. 컨슈머가 일을 잘 하도록 도와주는 게 메시지를 설계하는 사람의 역할이라고 생각했기 때문이다.</p>

<p>그런데 막상 필드를 하나씩 작성하다 보니, “이게 맞나?” 라는 생각이 들기 시작했다.</p>

<h2 id="처음엔-비용-문제로-봤다">처음엔 비용 문제로 봤다</h2>

<p>처음에 fat한 메시지로 설계하는게 낫다고 생각한 이유는 다음과 같다.</p>

<blockquote>
  <p>“환불 결제 대상 id 하나만 보내면, 컨슈머가 결국 그 id로 DB 조회를 한 번 더 해야 하는 거 아닌가? 그러면 비효율적이지 않나?”</p>
</blockquote>

<p>그래서 fat한 메시지가 더 낫다고 생각했다. 어차피 메시지에 다 들어 있으면 조회를 안 해도 되니까. 한 번의 DB I/O를 아끼는 셈이라고 봤다.</p>

<p>그런데 환불 프로세서의 일을 좀 더 자세히 들여다보니, 이 사고가 의미 없다고 생각을 다시 하게 되었다.</p>

<p>환불 처리는 결국 결제 상태를 변경하고, 환불 내역을 저장하고, 정합성을 맞추기 위한 여러 DB 작업을 동반한다. 즉 메시지에 모든 정보가 다 들어 있다고 해도 DB에 가지 않는 시나리오는 처음부터 존재하지 않았다. “조회 한 번을 아낀다” 는 명분은, 어차피 N번 들어가야 하며 돈이 걸려 속도보다는 정합성이 중요한 작업 앞에서 의미가 없었다.</p>

<p><strong>즉 thin이냐 fat이냐는 DB 비용으로 가를 수 있는 문제가 아니다.</strong></p>

<h2 id="독립성을-기준으로-삼아봤다">독립성을 기준으로 삼아봤다</h2>

<p>비용 기준이 사라지자 다른 기준이 필요했다. 고민하다 두 가지 정도로 정리했다.</p>

<ol>
  <li>컨슈머가 그 메시지를 처리할 때 메시지 외부에 있는 다른 시스템을 추가로 호출해야 하는가</li>
  <li>그 시점의 사실을 나중에 replay 해서 재현할 가능성이 있는가</li>
</ol>

<p>둘 중 하나라도 해당된다면 fat이 맞고, 둘 다 아니라면 thin이어도 충분하다는 식의 기준이었다.</p>

<p>이 기준은 기존의 DB 비용(속도) 기준보다는 메시지의 본질에 가까운 질문이라고 생각했다. 그런데 이 기준을 들고 환불 메시지를 다시 봤을 때, 또 어딘가 어긋난다는 느낌이 들었다.</p>

<p>환불 컨슈머가 결제 id로 들여다보는 곳은 결제 DB다. 그런데 이 DB가 과연 어드민(프로듀서)의 자원이라고 부를 수 있는가? 어드민은 환불을 요청하는 쪽일 뿐, 결제 데이터의 소유자는 아니다. 결제 DB는 결제 도메인 안에서 어드민과 환불 프로세서가 함께 바라보는 공용 저장소에 더 가깝다. 컨슈머가 그 DB를 다시 본다고 해서, 그게 프로듀서에게 무언가를 부탁하는 의존인지는 애매했다.</p>

<p>여기서 “독립적인 처리” 라는 기준이 한 번 더 갈라진다는 걸 알게 됐다. 컨슈머가 진짜 다른 시스템 (외부 PG, 외부 회원 서비스, 다른 도메인의 API 같은 것) 에 손을 뻗어야 하는 경우와, 같은 도메인 안에서 데이터를 함께 보고 있을 뿐인 경우는 외형이 비슷해 보여도 무게가 다르다. 전자는 시스템 경계를 넘는 진짜 결합이지만, 후자는 그저 같은 데이터를 다른 모듈이 들여다보는 일에 가깝다.</p>

<p>환불 케이스는 후자에 가까웠다. 그러니 독립성 기준만 가지고는 thin이어야 할지 fat이어야 할지를 깔끔하게 판단할 수 없었다. 비용 기준이 비껴갔던 것처럼, 독립성 기준도 이 케이스에선 결정타가 되지 못했다.</p>

<p>그러면서 또 다른 생각이 떠올랐다.</p>

<blockquote>
  <p>“그런데 메시지에 무엇을 담을지 결정하려면, 결국 메시지가 무엇인지 먼저 정해야 하지 않나?”</p>
</blockquote>

<h2 id="메시지가-뭔지부터-다시-물었다">메시지가 뭔지부터 다시 물었다</h2>

<p>돌아보니 나는 줄곧 같은 사고를 하고 있었다.</p>

<blockquote>
  <p>“이 메시지를 받아서 컨슈머는 무엇을 해야 하지? 그러려면 무엇이 필요하지?”</p>
</blockquote>

<p>이 사고는 자연스럽지만, 동시에 이상한 사고이기도 했다. 메시지를 만드는 쪽인 어드민이 메시지를 받는 쪽인 환불 프로세서가 어떤 일을 어떻게 해야 하는지를 미리 상상하고 있다는 뜻이기 때문이다.</p>

<p>이벤트 기반으로 가는 이유가 뭐였는지를 다시 떠올려봤다. 결국은 결합도를 낮추기 위함이다. 그런데 메시지를 만들 때마다 컨슈머의 행동을 머릿속에 그리고 있다면, 그 결합은 단지 코드가 아니라 사고 안에 그대로 살아 있는 셈이다. 컨슈머가 바뀌면 메시지도 바뀌어야 하고, 새 컨슈머가 붙으면 메시지에 또 무언가를 보태야 한다.</p>

<p>이 자리에서 메시지가 무엇을 담아야 하는지보다, <strong>이벤트란 무엇인가</strong> 부터 다시 정의해야겠다는 생각이 들었다.</p>

<h2 id="이벤트는-사실이다">이벤트는 사실이다</h2>

<p>여러 생각을 하다 내가 내린 이벤트의 정의는 이거였다.</p>

<blockquote>
  <p>이벤트는 그 시점에 발생한 사실이다.</p>
</blockquote>

<p>이렇게 정의를 내린 이유는 단순하다. 이벤트 기반 아키텍처가 결합도를 낮추기 위한 도구라면, 이벤트의 정의도 그 목적에 부합해야 한다. 그러려면 이벤트는 받는 쪽을 의식하지 않은 무언가여야 한다. 받는 쪽이 무엇을 할지 모른 채로도 충분히 의미가 있는, 그 자체로 닫힌 기록.</p>

<p>“발생한 사실” 이라는 표현은 이 조건을 자연스럽게 만족시킨다. 사실은 누가 어떻게 사용하든 사실이고, 컨슈머가 메일을 보내든 통계를 집계하든 그것은 사실의 변형이 아니라 사실에 대한 반응이다.</p>

<p>이 관점에서 명령(Command)과 이벤트(Event)는 분명하게 갈린다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">ProcessRefund</code> 는 명령이다. 미래형이고, 누군가에게 무엇을 하라고 지시한다.</li>
  <li><code class="language-plaintext highlighter-rouge">RefundRequested</code> 는 이벤트다. 과거형이고, 이미 일어난 사실을 기록한다.</li>
</ul>

<p>이름 짓는 컨벤션을 이야기하는 것이 아니다. 메시지를 만드는 사람이 <em>“받는 쪽이 어떻게 행동해야 하는가”</em> 를 머릿속에 두고 있느냐, 그렇지 않느냐의 차이다. 이벤트는 그 행동을 알지 않아야 한다.</p>

<h2 id="그래서-thin인가-fat인가">그래서 thin인가 fat인가</h2>

<p>이벤트에 대한 정의를 새로 잡았다고 해서, thin/fat 문제까지 사라지는 건 아니었다.</p>

<p>같은 환불 사실도 <em>“환불 #123이 요청되었다”</em> 라고 기록할 수 있고, <em>“환불 #123이 결제 #456에 대해 5,000원, 단순변심 사유로, 사용자 A에 의해 요청되었다”</em> 라고 기록할 수도 있다. 둘 다 사실을 기록한 이벤트라는 정의에서는 어긋나지 않는다.</p>

<p>다만 이전과 달라진 건 질문의 모양이다.</p>

<p>이전엔 <em>“컨슈머가 무엇을 필요로 하는가”</em> 를 물었다면, 이제는 <em>“이 사실을 얼마나 풍부하게 기록할 것인가”</em> 를 묻게 된다. 후자의 질문은 컨슈머의 행동이 아니라 사실의 입자 크기에 대한 질문이다. 이 시점의 상태를 그 자체로 보존해야 하는가, 아니면 식별자만 남겨두고 필요할 때 다시 조회하게 둘 것인가.</p>

<p>결국 이번 환불 메시지는 결제 id 하나만 담는 thin으로 결론을 냈다. 다음 두 가지 조건을 확인하고 나서야 안전하다고 판단할 수 있었다.</p>

<p>첫째, 환불 처리에서 컨슈머가 의존하는 결제 정보 (결제 금액, 결제자, 결제 시각 …)는 한 번 기록되면 변하지 않는 필드들이다. 즉 발행 시점이든 처리 시점이든 이 값들은 같다. 둘째, 환불 내역은 결제 row를 수정하는 방식이 아니라 별도 row로 append되는 구조라, 환불 history는 시점 추적이 가능하다. DB 전체가 append-only는 아니지만, <em>컨슈머의 의존 대상이 immutable한 부분으로 제한되어 있다는 점</em> 이 thin 선택을 정당화하는 근거였다.</p>

<p>뒤집어 말하면, 이 조건이 깨지는 도메인에선 thin으로 가기 어렵다. 예를 들어 요청 시점의 환불 사유 자체가 사후에 수정 가능한 시스템이라면, 컨슈머가 처리 시점에 DB를 봤을 때 _발행 시점의 사실_이 아닌 _수정된 사실_을 보게 된다. 부분 환불이 짧은 간격으로 동시 발행되는 도메인이라면, 첫 번째 메시지가 처리되기도 전에 두 번째 요청이 들어와 “남은 환불 가능 금액”이 발행 시점과 어긋날 수 있다. 외부 PG의 비동기 상태 변경, 컨슈머 장애로 인한 수 시간 뒤 재처리 같은 시나리오에서도 같은 문제가 생긴다. 이런 케이스에선 fat이거나, 발행 시점 상태를 복원할 수 있는 별도의 audit/event-sourcing 인프라가 필요해진다.</p>

<p>이렇게 정리하고 나니, thin/fat 결정은 <em>“이 도메인에서 컨슈머가 의존하는 상태 집합이 발행 시점과 처리 시점 사이에 변할 수 있는가”</em> 라는 한 줄로 좁혀졌다. 변할 수 있다면 fat, 변하지 않거나 변해도 최신 상태가 처리에 더 적합하다면 thin. 이번 환불 케이스는 후자였고, 그래서 thin이 컨벤션을 따르는 동시에 도메인적으로도 정합적인 선택이 됐다.</p>

<h2 id="결합도-다시-보기">결합도 다시 보기</h2>

<p>정의를 잡고 나서 또 하나 분명해진 게 있다. 결합도라는 단어를 더 세분해서 보게 됐다는 점이다.</p>

<p><strong>풀린 결합</strong> 은 행동의 결합이다. 컨슈머가 메시지를 받아 메일을 보내든, 통계를 쌓든, 슬랙 알림을 띄우든 프로듀서는 알 필요도 없고 바꿀 일도 없다. 새 컨슈머가 하나 붙어도 프로듀서 코드는 한 줄도 건드리지 않는다.</p>

<p><strong>남은 결합</strong> 도 분명히 있다. 같은 DB 테이블을 바라본다는 것. 컨슈머가 결제 id로 DB를 조회한다면, 결국 그 테이블의 스키마와 의미에 의존하고 있는 셈이다. 이벤트 기반으로 간다고 해서 이 결합이 사라지지는 않는다.</p>

<p>그리고 <strong>결합이라고 부르기 어려운 것</strong> 이 있다. 메시지의 발행 순서다. 환불이 요청되어야 환불 처리가 시작된다는 것은 시스템 설계의 문제가 아니라 그냥 사실의 순서다. 후행 사건은 선행 사건이 일어난 다음에 발생한다는 것은 어떤 아키텍처를 쓰든 변하지 않는다. 이걸 결합이라고 부르면 세상의 모든 인과관계가 결합이 된다.</p>

<p>이렇게 나누어 놓고 보니, 이벤트 기반이 결합을 없애는 것이 아니라 <strong>결합의 위치를 옮기는 일</strong> 에 가깝다는 게 분명해진다. 행동의 결합을 떼어 내고, 데이터 형태에 대한 결합은 남겨 두고, 인과의 순서는 처음부터 결합이 아닌 것으로 인정하는 일.</p>

<h2 id="마치며">마치며</h2>

<p>처음에는 환불 메시지에 어떤 필드를 넣을지를 두고 시작한 고민이었다. 그런데 이 작은 결정 하나가 비용 기준 → 독립성 기준 → 정의 자체 → 결합도의 분류까지 사고를 한참 끌고 갔다.</p>

<p>도착한 자리는 결제 id만 담는 thin 메시지라는, 어쩌면 처음부터 답이었을지도 모르는 결론이었다. 그런데 그 자리에 도달하기까지 무엇을 거쳤는지가 결국 이번에 더 남는 게 됐다.</p>

<p>메시지를 만들 때 던지는 질문이 바뀌었다. 이전엔 <em>“컨슈머가 받아서 무엇을 하려면 어떤 정보가 필요한가”</em> 라고 물었다면, 이제는 <em>“이 시점에 무엇이 사실이 되었는가”</em> 라고 묻는다. 후자의 질문은 받는 쪽을 신경 쓰지 않는 질문이고, 그래서 결합도를 낮추는 사고와 결이 맞는다.</p>]]></content><author><name>seungjjun</name><email>stw550@gmail.com</email></author><category term="architecture" /><category term="event-driven" /><category term="event" /><category term="kafka" /><category term="message-design" /><summary type="html"><![CDATA[개요]]></summary></entry><entry><title type="html">응답이 사라진 요청 — HTTP 재시도는 왜 까다로운가</title><link href="https://seungjjun.github.io/network/http-retry-and-lost-response/" rel="alternate" type="text/html" title="응답이 사라진 요청 — HTTP 재시도는 왜 까다로운가" /><published>2026-04-24T14:30:00+00:00</published><updated>2026-04-24T14:30:00+00:00</updated><id>https://seungjjun.github.io/network/http-retry-and-lost-response</id><content type="html" xml:base="https://seungjjun.github.io/network/http-retry-and-lost-response/"><![CDATA[<h2 id="개요">개요</h2>

<p><a href="https://seungjjun.github.io/system%20design/idempotency-key/">이전 글</a>에서 멱등성 키 하나로 중복 결제를 막는 원리에 대해 이야기했다. 그 글은 서버 쪽 방어에 가까웠다. 같은 요청이 여러 번 들어와도 서버가 한 번만 처리하도록 만드는 이야기였다.</p>

<p>그런데 글을 쓰고 나서 한 가지 질문이 남았다. 애초에 같은 요청이 왜 여러 번 들어오는가?</p>

<p>“같은 요청이 여러 번 들어올 수 있다”는 전제를 너무 당연하게 깔고 시작했었다. 정작 그 두 번째 요청이 누가, 어떻게 만들어내는지는 제대로 짚지 않았다.</p>

<p>브라우저가 알아서 보내는 건지, 사용자가 다시 누르는 건지, HTTP 라이브러리가 처리하는 건지. 막상 대답하려니 확신이 없었다.<br />
결제 관련 업무를 하다 보면 응답 유실 같은 상황은 늘 “겪을 수도 있는 일”로 머릿속에 남아 있다. 아직 직접 그런 장애를 마주한 적은 없지만, 겪지 않았다고 안심할 수 있는 주제도 아니라고 생각했다. 그래서 이번 기회에 네트워크 경계에서 실제로 어떤 일이 벌어지는지, HTTP 스펙과 클라이언트는 이런 상황을 어떻게 다루도록 설계되어 있는지 정리해보기로 했다</p>

<p>브라우저가 알아서 실패한 요청을 다시 보내주는 걸까? 아니면 사용자가 버튼을 다시 누르는 것만이 원인일까? HTTP 스펙은 이 상황을 어떻게 정의하고 있을까?</p>

<p>이번 글은 멱등성 키의 바로 한 단계 앞, 클라이언트 쪽에서 벌어지는 일을 정리해보려 한다.</p>

<h2 id="시나리오-응답이-돌아오지-않는-결제-요청">시나리오: 응답이 돌아오지 않는 결제 요청</h2>

<p>구체적인 상황부터 그려보자.</p>

<ol>
  <li>클라이언트가 결제 서버로 <code class="language-plaintext highlighter-rouge">POST /payments</code> 요청을 보낸다.</li>
  <li>TCP 커넥션이 정상적으로 맺어지고, 요청 패킷도 서버에 도착한다.</li>
  <li>서버는 요청을 받아 PG사에 결제를 위임하고, 5만 원 결제가 정상적으로 처리된다.</li>
  <li>서버는 “결제 성공” 응답을 클라이언트로 돌려보낸다.</li>
  <li>그런데 그 순간 네트워크 장애가 생겨, 응답 패킷이 클라이언트에 도달하지 못한다.</li>
</ol>

<pre><code class="language-mermaid">sequenceDiagram
    participant C as Client
    participant S as Server
    participant P as PG

    C-&gt;&gt;S: POST /payments (5만원)
    S-&gt;&gt;P: 결제 요청
    P--&gt;&gt;S: 결제 승인
    Note over S: 서버는 정상 처리 완료
    S--xC: 응답 전송 중 네트워크 장애
    Note over C: 요청은 보냈는데&lt;br/&gt;응답이 오지 않는다
</code></pre>

<p>클라이언트 입장에서는 요청은 분명히 보냈는데 응답이 돌아오지 않는다. 시간이 지나면 타임아웃 에러가 뜬다.</p>

<p>이때 클라이언트는 어떻게 행동해야 할까? 브라우저가 알아서 재시도해주는가, 아니면 에러만 띄우고 끝나는가?</p>

<h2 id="브라우저는-post를-재시도하지-않는다">브라우저는 POST를 재시도하지 않는다</h2>

<p>결론부터 말하면, 대부분의 HTTP 클라이언트는 POST 요청을 자동으로 재시도하지 않는다. <strong>POST는 기본적으로 멱등하지 않기 때문이다.</strong></p>

<p>근거는 HTTP 스펙(RFC 9110)이 정의하는 메서드별 멱등성에 있다.</p>

<ul>
  <li>GET, HEAD: 서버 상태를 변경하지 않는 조회. 여러 번 호출해도 안전하다.</li>
  <li>PUT, DELETE: 최종 상태만 중요한 연산. 반복해도 결과가 같다.</li>
  <li>POST: 호출할 때마다 새로운 리소스를 만들거나 상태를 바꾼다.</li>
</ul>

<p>POST는 멱등하지 않으므로 클라이언트가 임의로 재시도하면 곤란하다. 5만 원이 두 번 결제돼 10만 원이 빠져나갈 수도, 주문이 두 건으로 갈라질 수도 있다.</p>

<p>그래서 표준 HTTP 클라이언트들은 POST를 보수적으로 다룬다.</p>

<p>브라우저도 마찬가지다. 폼을 POST로 제출했다가 응답이 오지 않으면 에러 페이지를 보여줄 뿐, 같은 요청을 알아서 다시 보내지는 않는다. 새로고침 시 “양식을 다시 제출하시겠습니까?” 같은 경고가 뜨는 이유도 여기에 있다. 재시도로 인한 side effect를 사용자에게 명시적으로 확인받는 장치다.</p>

<p>결국 결제 같은 POST 요청의 재시도 여부는 브라우저도, HTTP 라이브러리도 아닌 애플리케이션 코드가 결정해야 한다.</p>

<h2 id="클라이언트가-마주한-네-가지-가능성">클라이언트가 마주한 네 가지 가능성</h2>

<p>그런데 애플리케이션이 재시도를 결정하려 해도 한 가지 근본적인 문제가 있다. 타임아웃을 받은 순간 클라이언트가 마주하는 것은 단순한 “실패”가 아니라 정보가 부족한 상태다.</p>

<p>“요청은 보냈는데 응답이 없다”는 이 한 문장 뒤에는 사실 여러 가능성이 숨어 있다.</p>

<ol>
  <li>요청 패킷이 서버에 도달하지 못했다. (결제 시도 없음)</li>
  <li>서버가 요청은 받았지만, 처리 전에 죽었다. (결제되지 않음)</li>
  <li>서버가 정상적으로 처리를 끝냈지만, 응답 패킷이 유실됐다. (결제됨)</li>
  <li>서버가 처리 중 실패했고, 그 실패 응답마저 유실됐다. (결제되지 않음)</li>
</ol>

<p>클라이언트는 이 넷 중 어느 것인지 구분할 방법이 없다. 손에 쥔 건 타임아웃 에러 하나뿐이다.</p>

<p>재시도를 하면 3번 상황이었을 때 결제가 두 번 일어난다. 중복 결제다.<br />
재시도를 하지 않으면 3번이었을 때 돈은 빠져나갔는데 주문은 잡히지 않는다. 결제 누락이다.</p>

<p>결국 클라이언트 입장에서는 답이 없다. 다시 보내도 틀릴 수 있고, 안 보내도 틀릴 수 있다.</p>

<h2 id="두-장군-문제">두 장군 문제</h2>

<p>분산 시스템에서 이런 상황을 가리키는 유명한 이름이 있다. <strong>두 장군 문제(Two Generals Problem)</strong> 다.</p>

<p>두 부대가 적을 포위하고 있고, 동시에 공격해야만 승리할 수 있다는 설정이다. 공격 시점을 맞추려면 서로 메시지를 주고받아야 하는데, 전령은 적진을 통과하다가 잡힐 수 있다. 메시지를 보낸 쪽은 그것이 도착했는지 알 수 없고, 확인 메시지가 와도 그 확인이 안전하게 도착했는지는 또 보장되지 않는다. 확인의 확인, 확인의 확인의 확인을 끝없이 반복해도 완벽한 합의에 도달할 수 없다는 것이 이 문제의 결론이다.</p>

<p>우리의 결제 시나리오도 이 문제와 닮아 있다. 클라이언트가 장군 A, 서버가 장군 B, 네트워크가 적진을 지나는 전령인 셈이다. 어느 한쪽도 상대방이 요청이나 응답을 받았는지 완벽하게 확인할 방법이 없다. ACK로 추정할 뿐인데, 그 ACK 자체가 또 네트워크를 타고 이동해야 하기 때문이다.</p>

<p>TCP도, HTTP도, 그 어떤 네트워크 프로토콜도 이 한계를 없애지 못한다. 그래서 네트워크 프로그래밍은 이 불확실성을 없애려 하기보다, 불확실성을 전제로 두고 그 위에서 안전하게 동작하도록 설계하는 쪽으로 발전해왔다.</p>

<h2 id="tcp-재전송과-http-재시도는-다른-이야기다">TCP 재전송과 HTTP 재시도는 다른 이야기다</h2>

<p>“TCP는 신뢰성 있는 프로토콜이잖아? 그럼 알아서 재전송해주지 않나?”</p>

<p>자주 혼동되는 지점인데, 레이어가 다르다.</p>

<p>TCP는 패킷 단위의 재전송을 보장한다. 패킷이 유실되면 sequence number와 ACK를 기반으로 같은 패킷을 다시 보낸다. 이 과정은 HTTP보다 한 층 아래에서 일어나고, HTTP 애플리케이션은 이를 의식하지 않아도 된다.</p>

<p>문제는 TCP의 재전송이 같은 커넥션이 유지되는 동안에만 유효하다는 점이다. 커넥션 자체가 끊어지면(타임아웃, RST 등) TCP는 더 이상 할 수 있는 일이 없다. 그 뒤부터는 상위 레이어인 HTTP의 몫이다.</p>

<p>그리고 앞서 말했듯, HTTP 레이어에서는 POST를 자동으로 재시도하지 않는다. “서버에 요청은 닿았고 처리도 끝났는데 응답 전달 중 커넥션이 끊긴” 시나리오에서 TCP는 도움이 되지 않는다. 패킷 레벨 재전송은 이미 불가능하고, HTTP는 재시도를 거부한다.</p>

<pre><code class="language-mermaid">flowchart LR
    subgraph TCP["TCP 계층"]
        T1[패킷 단위 재전송&lt;br/&gt;커넥션 유지 시에만]
    end
    subgraph HTTP["HTTP 계층"]
        H1[POST 자동 재시도 X]
    end
    subgraph APP["애플리케이션 계층"]
        A1[재시도 전략 + 멱등성 키]
    end
    TCP --&gt;|커넥션 끊어지면 한계| HTTP
    HTTP --&gt;|스펙상 위임| APP
    
    classDef tcp fill:#e3f2fd,stroke:#1976d2;
    classDef http fill:#f3e5f5,stroke:#7b1fa2;
    classDef app fill:#fff3e0,stroke:#f57c00;
    class TCP tcp;
    class HTTP http;
    class APP app;
</code></pre>

<p>이 애매한 공백을 메우는 것이 애플리케이션 레벨의 재시도 전략이고, 그 재시도를 안전하게 만드는 도구가 이전 글에서 다룬 멱등성 키다.</p>

<h2 id="안전한-재시도를-설계한다는-것">안전한 재시도를 설계한다는 것</h2>

<h3 id="먼저-요청을-멱등하게-만든다">먼저 요청을 멱등하게 만든다</h3>

<p>재시도를 하기 전에 먼저 물어야 할 질문은 “이 요청은 멱등한가?”다. 멱등하다면 반복해도 결과가 같으니 재시도해도 안전하다. 멱등하지 않다면 재시도가 side effect를 중복시킨다. 재시도 전에 요청 자체를 멱등하게 만들어야 한다.</p>

<p>결제 같은 POST 요청을 멱등하게 만드는 방법은 이전 글에서 정리했다. 클라이언트가 요청마다 고유한 <code class="language-plaintext highlighter-rouge">Idempotency-Key</code>를 생성해 헤더에 담아 보내고, 서버는 같은 키의 요청을 한 번만 처리한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /payments HTTP/1.1
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{ "amount": 50000, "orderId": "ORDER-123" }
</code></pre></div></div>

<p>여기서 중요한 원칙 하나. <strong>재시도할 때는 같은 키를 재사용해야 한다.</strong> 매번 새 키를 생성하면 서버 입장에서는 서로 다른 요청이고, 멱등성 키의 의미가 사라진다. 실무에서는 보통 “결제 의도” 단위로 키를 한 번 만들어둔다. 사용자가 결제 버튼을 누르는 순간 UUID를 생성해두고, 그 뒤 재시도가 몇 번 일어나든 같은 키를 그대로 쓴다.</p>

<h3 id="재시도해도-되는-에러-아닌-에러">재시도해도 되는 에러, 아닌 에러</h3>

<p>판단 기준은 하나다. 다시 보내면 성공할 가능성이 있는가.</p>

<p>4xx (잘못된 카드, 잔액 부족 등): 요청 자체의 문제. 재시도해도 같은 결과가 나온다.
5xx (서버 내부 오류, 게이트웨이 타임아웃): 서버 쪽의 일시적 문제일 수 있다. 재시도 후보.
네트워크 에러 (타임아웃, 커넥션 끊김): 응답을 못 받은 상황이라 재시도가 필요하지만, 반드시 멱등성 키가 있어야 한다.</p>

<p><code class="language-plaintext highlighter-rouge">429 Too Many Requests</code>나 <code class="language-plaintext highlighter-rouge">503 Service Unavailable</code>처럼 서버가 명시적으로 재시도 힌트를 주는 응답에는 Retry-After 헤더가 함께 오기도 한다. 이런 신호도 판단에 활용한다.</p>

<h3 id="타임아웃은-한-덩어리가-아니다">타임아웃은 한 덩어리가 아니다</h3>

<p>“타임아웃”을 하나로 묶어서 보면 안 된다. 종류가 다르고 위험도도 다르다.</p>

<ul>
  <li>Connection timeout: 커넥션 수립에 걸리는 시간. 이 단계에서 실패했다면 요청이 서버에 닿지도 못한 것이므로 비교적 안전하게 재시도할 수 있다.</li>
  <li>Read timeout: 요청을 보낸 뒤 응답을 기다리는 시간. 이 시점의 타임아웃은 서버가 이미 요청을 받아 처리 중일 가능성을 포함한다. 재시도 시 중복 처리 위험이 있는 건 이쪽이다.</li>
</ul>

<p>Read timeout을 지나치게 짧게 잡으면 서버는 정상적으로 처리하고 있는데 클라이언트가 기다리지 못하고 끊어버리는 상황이 잦아진다. 여기에 재시도까지 엮이면 앞서 본 “응답이 사라진 요청” 시나리오가 반복된다. PG사나 백엔드의 평균 응답 시간과 p99 레이턴시를 보고 정해야 한다. 너무 짧으면 중복을 유도하고, 너무 길면 사용자 경험을 해친다.</p>

<h2 id="마치며">마치며</h2>

<p>네트워크 위에서 당연하게 여기는 “요청과 응답”은 사실 보장된 것이 아니다. 요청은 닿았는데 응답은 사라질 수 있고, 그 사이의 진실은 누구도 완벽히 알 수 없다.</p>

<p>그래서 “요청이 실패했는데 다시 보낼까?”라는 질문은 생각보다 간단하지 않다. 그 안에는 멱등성, 재시도 정책, 타임아웃 설정, 백오프 전략, 두 장군 문제까지 얽혀 있다.</p>

<p>이전 글의 멱등성 키와 이번 글의 재시도 전략은 결국 같은 불확실성을 양쪽에서 덮는 작업이다. 요청을 받는 쪽이 같은 요청을 한 번만 처리하도록 막고, 요청을 보내는 쪽이 언제·어떻게 재시도할지를 규율한다.</p>

<p>결제 서버 개발자 입장에서는 어느 한쪽만 챙겨도 안 된다. 프론트에서 결제 서버로 들어오는 요청도, 결제 서버에서 PG사로 나가는 요청도 같은 문제에 노출되기 때문이다. 두 장치가 맞물려야 “같은 요청이 여러 번 들어올 수 있다”는 현실 위에서 결제 시스템을 안전하게 운영할 수 있다.</p>

<h2 id="참고자료">참고자료</h2>
<ul>
  <li><a href="https://www.rfc-editor.org/rfc/rfc9110.html">RFC 9110: HTTP Semantics</a></li>
  <li><a href="https://en.wikipedia.org/wiki/Two_Generals%27_Problem">Two Generals’ Problem - Wikipedia</a></li>
</ul>]]></content><author><name>seungjjun</name><email>stw550@gmail.com</email></author><category term="Network" /><category term="HTTP" /><category term="Idempotency" /><category term="결제" /><category term="Retry" /><summary type="html"><![CDATA[개요]]></summary></entry><entry><title type="html">Kafka는 왜 중복을 허용할까 — At-least-once와 멱등성의 조합</title><link href="https://seungjjun.github.io/kafka/at-least-once-and-idempotency/" rel="alternate" type="text/html" title="Kafka는 왜 중복을 허용할까 — At-least-once와 멱등성의 조합" /><published>2026-04-18T07:00:00+00:00</published><updated>2026-04-18T07:00:00+00:00</updated><id>https://seungjjun.github.io/kafka/at-least-once-and-idempotency</id><content type="html" xml:base="https://seungjjun.github.io/kafka/at-least-once-and-idempotency/"><![CDATA[<h2 id="개요">개요</h2>
<p>이전 글에서 멱등성 키 하나로 중복 결제를 막는 원리에 대해 이야기했다.<br />
네트워크는 언제든 끊길 수 있고, 클라이언트는 언제든 같은 요청을 다시 보낼 수 있으니, 서버는 같은 요청이 여러 번 들어와도 결과가 달라지지 않도록 설계되어야 한다는 내용이었다.</p>

<p>최근 간단하게 Kafka를 직접 구현하며 학습하다가 흥미로운 점을 마주했다.<br />
Kafka가 기본값으로 제시하는 메시지 전달 보장이 “한 번만 정확히(exactly-once)”가 아니라 <strong>“적어도 한 번(at-least-once)”</strong> 이라는 것이다. 즉, Kafka는 메시지가 중복될 수 있음을 전제로 동작한다.</p>

<p>중복 결제 한 건이 얼마나 치명적인지 알고 있던 입장에서 이 기본값은 조금 의아했다. 메시지 브로커가 “중복은 어쩔 수 없다”고 말하는 것처럼 들렸기 때문이다.</p>

<p>하지만 구현을 따라가며 Consumer의 동작을 들여다보니, 이 선택은 타협이라기보다 의도된 설계에 가까웠다. 그리고 그 설계를 완성하는 마지막 조각이 이전 글에서 다룬 멱등성이었다.</p>

<p>이번 글에서는 Kafka Consumer의 오프셋 커밋 시점이 왜 중요한지, 그리고 왜 결제 시스템이 결국 At-least-once와 멱등성의 조합을 선택하게 되는지 정리해보려 한다.</p>

<h2 id="consumer가-메시지를-소비한다는-것">Consumer가 메시지를 “소비한다”는 것</h2>
<p>Kafka에서 Consumer는 메시지를 push 받지 않는다. 브로커에게 “나 어디까지 읽었으니까, 그 다음부터 몇 개만 줘” 하고 직접 가져간다(pull). 이때 어디까지 읽었는지를 기록하는 값이 <strong>offset</strong> 이다.</p>

<p>여기서 중요한 점은, Consumer가 메시지를 받는다고 해서 그 메시지가 소비된 것으로 처리되지 않는다는 것이다. Consumer가 오프셋을 명시적으로 커밋해야 비로소 브로커는 “이 Consumer가 여기까지 처리를 끝냈구나” 하고 인식한다.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant C as Consumer
    participant B as Kafka Broker

    C-&gt;&gt;B: FETCH offset=10, max=100
    B--&gt;&gt;C: 레코드 [10, 11, ..., 109] 반환
    loop 각 레코드 처리
        C-&gt;&gt;C: 메시지 처리
    end
    C-&gt;&gt;B: COMMIT offset=110
    B-&gt;&gt;B: committed offset = 110 갱신
    B--&gt;&gt;C: COMMIT_ACK
</code></pre>

<p>얼핏 단순해 보이지만, 이 흐름에는 한 가지 까다로운 질문이 숨어 있다.<br />
처리와 커밋 중 무엇을 먼저 할 것인가?</p>

<p>이 선택이 Kafka의 두 전달 보장, <strong>At-most-once</strong>와 <strong>At-least-once</strong>를 가른다.</p>

<h2 id="커밋-시점이-만드는-두-가지-세계">커밋 시점이 만드는 두 가지 세계</h2>
<h3 id="at-most-once-처리하기-전에-커밋한다">At-most-once: 처리하기 전에 커밋한다</h3>
<p>먼저 처리하기 전에 커밋부터 하는 전략을 생각해보자.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant C as Consumer
    participant B as Kafka Broker

    C-&gt;&gt;B: FETCH offset=0
    B--&gt;&gt;C: 레코드 [0, 1, 2]
    C-&gt;&gt;B: COMMIT offset=3
    Note over C,B: 처리 전에 먼저 커밋
    C-&gt;&gt;C: 레코드 0 처리 완료
    C-&gt;&gt;C: 레코드 1 처리 중...
    Note over C: Error 발생
    Note over C,B: --- 재시작 ---
    C-&gt;&gt;B: FETCH (committed=3 이후)
    B--&gt;&gt;C: 레코드 [3, 4, ...]
    Note over C: 레코드 1, 2는 영원히 유실
</code></pre>

<p>이 방식은 error가 일어나도 같은 메시지를 다시 처리할 일이 없다. 커밋이 이미 되어 있으니, 재시작한 Consumer는 이미 처리된 것으로 간주된 메시지 이후부터 가져간다.</p>

<p>하지만 단점은 명확하다. 위 시나리오처럼 처리 도중 error가 나면 그 메시지는 유실된다. 브로커는 Consumer가 이미 처리했다고 믿지만 실제로는 처리되지 않은 채로 남는다.</p>

<p>이것이 At-most-once, 즉 “메시지는 최대 한 번만 전달된다(유실될 수는 있지만 중복되지는 않는다)”는 보장이다.</p>

<h3 id="at-least-once-처리한-후에-커밋한다">At-least-once: 처리한 후에 커밋한다</h3>
<p>반대로 처리가 끝난 다음에 커밋하는 전략을 보자.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant C as Consumer
    participant B as Kafka Broker

    C-&gt;&gt;B: FETCH offset=0
    B--&gt;&gt;C: 레코드 [0, 1, 2]
    C-&gt;&gt;C: 레코드 0 처리 완료
    C-&gt;&gt;C: 레코드 1 처리 완료
    C-&gt;&gt;C: 레코드 2 처리 완료
    Note over C: 커밋 직전 Error
    Note over C,B: --- 재시작 ---
    C-&gt;&gt;B: FETCH (committed=0 이후)
    B--&gt;&gt;C: 레코드 [0, 1, 2] 다시 전달
    Note over C: 중복 처리 발생
</code></pre>

<p>이 방식은 error가 나도 메시지가 유실되지 않는다. 커밋되지 않은 상태에서 Consumer가 죽으면, 재시작했을 때 같은 offset부터 다시 읽어오기 때문이다.</p>

<p>대신 처리는 끝났는데 커밋 직전에 error가 나면, 재시작한 Consumer는 동일한 메시지를 한 번 더 처리하게 된다. 즉 중복이 발생할 수 있다.</p>

<p>이것이 At-least-once, “메시지는 적어도 한 번은 전달된다(유실되지는 않지만 중복될 수는 있다)”는 보장이다.</p>

<h2 id="유실과-중복-무엇이-더-나쁜가">유실과 중복, 무엇이 더 나쁜가</h2>
<p>여기서 개발자는 선택의 기로에 선다. 유실을 허용할 것인가, 중복을 허용할 것인가.</p>

<p>얼핏 대등해 보이지만, 결제 시스템의 관점에서는 그렇지 않다.</p>

<p>유실이 발생한 상황을 상상해보자.<br />
사용자는 결제를 완료했고, PG사에서는 돈이 빠져나갔다. 그런데 그 “결제 완료” 이벤트가 Consumer에서 유실되어 내부 시스템에 반영되지 않았다면 어떤 일이 벌어질까?</p>

<ul>
  <li>주문은 여전히 ‘결제 대기’ 상태로 남는다.</li>
  <li>배송, 적립, 쿠폰 사용 같은 후속 처리도 이루어지지 않는다.</li>
  <li>사용자는 “돈은 빠져나갔는데 주문은 왜 안 잡혔지?”라며 고객센터에 문의한다.</li>
  <li>운영팀은 어디서 이벤트가 사라졌는지 역추적해야 한다.</li>
</ul>

<p>유실의 진짜 문제는 이 일이 일어났다는 사실 자체를 감지하기 어렵다는 점이다. 무언가 빠졌다는 것을 알아채려면 별도의 정합성 체크 로직이 필요하고, 발견이 늦어질수록 복구 비용도 빠르게 커진다.</p>

<p>반면 중복이 발생하면 어떨까?<br />
같은 결제 완료 이벤트가 Consumer에 두 번 전달되면 시스템은 같은 주문에 대해 두 번 후처리를 시도한다. 그런데 이 중복은 이미 예상 가능한 사건이다.</p>

<p>이전 글에서 다룬 멱등성 키를 떠올려보자. 같은 주문 ID, 같은 이벤트 ID로 요청이 두 번 들어오면 두 번째 요청은 무시되거나 이전 결과를 그대로 돌려준다. 즉 중복은 이미 방어할 준비가 되어 있는 문제다.</p>

<p>유실과 달리 중복은 감지하기 쉽고, 방어 수단도 이미 존재한다. 결제 시스템이 At-least-once를 선택하는 이유는 여기에 있다.</p>

<h2 id="kafka가-중복-허용을-기본값으로-둔-이유">Kafka가 “중복 허용”을 기본값으로 둔 이유</h2>
<p>이 관점에서 보면 Kafka의 설계 의도가 조금 더 선명해진다.<br />
Kafka는 “메시지를 절대 유실하지 않는 것”을 최우선 가치로 두는 시스템이다.</p>

<p>메시지 브로커의 본질적인 역할은 결국 데이터가 A에서 B로 반드시 전달되도록 보장하는 것이다. 그런데 “반드시 한 번만”까지 함께 보장하려면 Producer-Broker-Consumer 사이의 모든 네트워크 구간에서 중복을 완전히 차단해야 한다. 현실의 네트워크에서 이 비용은 대단히 크다.</p>

<p>Kafka는 이 원칙을 구현 레벨에서도 그대로 유지한다.</p>

<ul>
  <li>Producer는 메시지가 브로커에 저장됐다는 ack를 받을 때까지 재시도한다. ack가 네트워크 문제로 유실되면 Producer는 브로커가 이미 저장했는지 모른 채 같은 메시지를 다시 보낸다.</li>
  <li>Consumer는 처리 후 커밋하는 방식을 기본으로 한다. 커밋이 실패하거나 Consumer가 죽으면, 재시작 후 같은 메시지를 다시 읽는다.</li>
</ul>

<p>두 지점 모두 중복의 가능성을 포함한다. Kafka는 이를 완전히 막으려 애쓰지 않고, 대신 Consumer 쪽에서 멱등하게 처리할 것을 요구한다.<br />
메시지 전달의 신뢰성은 브로커가, 중복 처리의 방지는 애플리케이션이 맡는 일종의 역할 분담이다.</p>

<pre><code class="language-mermaid">flowchart LR
    P[Producer] --&gt;|ack 받을 때까지 재시도&lt;br/&gt;유실 방지| B[(Kafka Broker)]
    B --&gt;|At-least-once&lt;br/&gt;중복 가능| C[Consumer]
    C --&gt;|eventId 체크&lt;br/&gt;유니크 제약| A[애플리케이션 상태]

    classDef broker fill:#e3f2fd,stroke:#1976d2;
    classDef app fill:#fff3e0,stroke:#f57c00;
    class P,B broker;
    class C,A app;
</code></pre>

<p>왼쪽(파란색)은 유실 방지를 책임지는 Kafka의 영역, 오른쪽(주황색)은 중복 흡수를 책임지는 애플리케이션의 영역이다.</p>

<h2 id="그렇다면-exactly-once는-없는가">그렇다면 Exactly-once는 없는가</h2>
<p>“유실도 중복도 싫다. 정확히 한 번만 전달되는 방법은 없나?”</p>

<p>Kafka는 실제로 Exactly-once Semantics(EOS)를 제공한다. Transactional Producer와 Consumer의 <code class="language-plaintext highlighter-rouge">isolation.level=read_committed</code> 설정을 조합하면, Kafka 생태계 안에서는 “정확히 한 번”의 전달을 기술적으로 구현할 수 있다.</p>

<p>다만 두 가지 제약이 있다.</p>

<p>하나는 적용 범위다. Kafka의 트랜잭션은 Kafka 안에서만 유효하다. Consumer가 메시지를 읽어 외부 시스템(PG사, 은행 API, 외부 DB 등)에 반영하는 순간, Kafka의 트랜잭션 경계는 거기서 끝난다. 결제 시스템의 상당수 연산은 바로 이 경계를 넘나드는 작업이라 EOS만으로는 부족한 경우가 많다.</p>

<p>다른 하나는 비용이다. 트랜잭션 관리, 커밋 조율, 격리 수준 제어 모두 추가 오버헤드를 만든다. 대부분의 실무 결제 시스템이 EOS에 의존하기보다 애플리케이션 레벨에서 멱등하게 만드는 방향을 택하는 이유다.</p>

<p>그래서 실무의 현실적인 선택지는 결국 At-least-once와 애플리케이션 레벨 멱등성의 조합으로 좁혀진다. Kafka 공식 문서와 실무 가이드들도 대체로 이 조합을 권한다.</p>

<h2 id="결제-이벤트에서-멱등성은-어떻게-구현되는가">결제 이벤트에서 멱등성은 어떻게 구현되는가</h2>
<p>그렇다면 Consumer는 구체적으로 어떻게 멱등하게 동작해야 할까?<br />
이전 글에서 다룬 멱등성 키의 원리가 거의 그대로 적용된다. 다만 키의 출처가 “클라이언트 HTTP 헤더”에서 “메시지 자체의 필드”로 바뀔 뿐이다.</p>

<p>실무에서는 보통 다음과 같은 방식이 쓰인다.</p>

<ul>
  <li>이벤트 ID 기반 중복 제거: 메시지에 <code class="language-plaintext highlighter-rouge">eventId</code>나 <code class="language-plaintext highlighter-rouge">orderId</code> 같은 고유 식별자를 담아 보낸다. Consumer는 메시지를 처리하기 전에 이 ID가 이미 처리된 적이 있는지 확인하고, 있다면 건너뛴다.</li>
  <li>DB 유니크 제약 활용: 후처리 결과를 저장할 때 <code class="language-plaintext highlighter-rouge">eventId</code>에 유니크 제약을 걸어둔다. 중복된 이벤트가 들어오면 제약 위반으로 실패하므로 자연스럽게 차단된다.</li>
  <li>상태 기반 처리: “이미 결제 완료된 주문은 다시 결제 완료로 바꾸지 않는다”처럼, 최종 상태를 기준으로 다시 처리되어도 결과가 달라지지 않도록 로직을 짠다.</li>
</ul>

<p>어떤 방식이든 핵심은 같다. 같은 메시지가 두 번 들어와도 시스템의 최종 상태가 달라지지 않아야 한다는 것이다. 멱등성 키 글에서 이야기한 $f(f(x)) = f(x)$ 의 원리가 Consumer 레벨에서도 그대로 적용된다.</p>

<h2 id="마치며">마치며</h2>
<p>Kafka를 처음 접했을 때는 기본 전달 보장이 at-least-once라는 점이 조금 허술해 보였다. 하지만 Consumer의 커밋 흐름을 직접 구현하며 따라가보니, 이 기본값은 “무엇을 브로커에 맡기고 무엇을 애플리케이션에 맡길 것인가”에 대한 분명한 설계 결정이라는 것을 알 수 있었다.</p>

<p>결제 시스템의 관점에서도 결론은 비슷하다. 중복은 멱등성 키로 막을 수 있지만, 유실은 사후 복구가 훨씬 어렵다. 그러니 브로커에게는 유실 방지를 요구하고, 중복은 애플리케이션이 흡수한다.</p>

<p>이전 글에서 다룬 멱등성 키와 이번 글의 At-least-once는 결국 같은 문제를 서로 다른 위치에서 다루고 있는 셈이다. 신뢰할 수 없는 경계를 넘어 데이터를 주고받을 때 피할 수 없이 생기는 중복, 그리고 그것을 시스템이 스스로 흡수하도록 만드는 일. 결제 시스템을 만든다는 것은 이런 경계들을 하나씩 정리해 나가는 과정이 아닐까 싶다.</p>]]></content><author><name>seungjjun</name><email>stw550@gmail.com</email></author><category term="Kafka" /><category term="Kafka" /><category term="At-least-once" /><category term="Idempotency" /><category term="결제" /><category term="메시지브로커" /><summary type="html"><![CDATA[개요 이전 글에서 멱등성 키 하나로 중복 결제를 막는 원리에 대해 이야기했다. 네트워크는 언제든 끊길 수 있고, 클라이언트는 언제든 같은 요청을 다시 보낼 수 있으니, 서버는 같은 요청이 여러 번 들어와도 결과가 달라지지 않도록 설계되어야 한다는 내용이었다.]]></summary></entry><entry><title type="html">데이터베이스 관점에서의 일관성(Consistency)이란?</title><link href="https://seungjjun.github.io/database/consistency/" rel="alternate" type="text/html" title="데이터베이스 관점에서의 일관성(Consistency)이란?" /><published>2026-03-28T13:50:00+00:00</published><updated>2026-03-28T13:50:00+00:00</updated><id>https://seungjjun.github.io/database/consistency</id><content type="html" xml:base="https://seungjjun.github.io/database/consistency/"><![CDATA[<h2 id="일관성">일관성</h2>

<h3 id="일관성의-의미">일관성의 의미</h3>
<p>일관성의 사전적 의미는 ‘하나의 방법이나 태도 따위로 처음부터 끝까지 한결같이 나아가는 성질’ 이다.<br />
쉽게 말해, 이랬다저랬다 상황이나 기분에 따라 바뀌지 않고, 처음 정한 원칙이나 행동, 태도를 끝까지 똑같이 유지하는 모습을 뜻한다.</p>

<h3 id="일관성과-예측-가능성">일관성과 예측 가능성</h3>
<p>일관성이 있다는 것은 곧 <strong>예측 가능하다</strong>는 의미로도 해석할 수 있다.<br />
나는 예측 가능한 시스템이 잘 만들어진 시스템이라고 생각한다. 그 이유는 어떤 행동을 했을 때 어떤 결과가 나올지 어느 정도 예상할 수 있다면, 그에 맞는 대응도 가능하기 때문이다.</p>

<p>반대로 예측 불가능한 시스템이라면 개발자는 늘 불안할 수밖에 없다. 어떤 상황에서 오류가 발생할지, 어떤 입력에서 예외가 터질지, 왜 어제는 잘 되던 기능이 오늘은 실패하는지 감을 잡기 어렵기 때문이다.<br />
결국 예측 불가능성은 디버깅 비용을 높이고, 시스템에 대한 신뢰도도 떨어뜨린다.</p>

<h3 id="현실-세계에서의-일관성">현실 세계에서의 일관성</h3>
<p>사람을 대할 때도 비슷하다. 감정 기복이 심하고 상황에 따라 태도가 크게 달라지는 사람보다, 한결같고 반응이 안정적인 사람을 상대하는 것이 훨씬 편하다.<br />
왜냐하면 그 사람의 반응을 어느 정도 예상할 수 있고, 관계 속에서 불필요한 긴장이나 피로가 줄어들기 때문이다.<br />
이처럼 일관성은 현실 세계의 인간관계에서도 중요하고, 소프트웨어 세계에서도 매우 중요한 가치다.</p>

<h3 id="소프트웨어에서-일관성이-중요한-이유">소프트웨어에서 일관성이 중요한 이유</h3>
<p>소프트웨어에서 일관성은 단순히 “항상 똑같다”는 감각적인 표현을 넘어, <strong>시스템의 신뢰성과 안정성을 결정하는 핵심 요소</strong>가 된다.<br />
특히 분산 시스템에서는 하나의 데이터를 여러 서버나 여러 데이터베이스가 나누어 저장하고 처리하기 때문에, 일관성이 더 중요해진다.<br />
데이터가 여러 곳에 복제되어 있을 때 각 사본이 서로 다른 값을 가지고 있다면 사용자는 같은 요청을 해도 보는 위치나 시점에 따라 다른 결과를 경험하게 된다. 이런 차이는 혼란을 만들고, 심한 경우 서비스에 대한 신뢰를 무너뜨릴 수 있다.</p>

<h3 id="데이터-관점에서의-일관성">데이터 관점에서의 일관성</h3>
<p>데이터 관점에서 일관성은 하나의 데이터를 갖고 있는 모든 사본 또는 인스턴스가 모든 시스템 및 데이터베이스에서 동일한 데이터 상태를 나타내는 것을 의미한다.<br />
즉, 일관성이 보장된 데이터는 사용자가 어느 곳에 접근하여 데이터를 읽든 항상 동기화된 동일한 상태의 데이터 값을 보장해야 한다는 것이다.</p>

<p>하지만 현실의 시스템에서는 성능, 가용성, 네트워크 지연 같은 여러 제약이 존재하기 때문에 모든 상황에서 완벽한 일관성을 즉시 보장하기가 쉽지 않다.<br />
그래서 시스템은 서비스의 특성과 목적에 따라 어느 수준까지 일관성을 보장할 것인지 선택하게 된다.<br />
이러한 관점에서 대표적으로 이야기되는 일관성에는 <strong>최종 일관성(Eventual Consistency)</strong> 과 <strong>강한 일관성(Strong Consistency)</strong> 이 있다.</p>

<h2 id="최종-일관성-eventual-consistency">최종 일관성 (Eventual Consistency)</h2>
<h3 id="최종-일관성의-개념">최종 일관성의 개념</h3>
<p>최종 일관성은 데이터를 어떻게 저장하든, 최종적으로는 해당 데이터를 갖고 있는 모든 데이터베이스가 동일한 상태로 수렴하면 된다는 개념이다.<br />
즉, 데이터가 변경된 직후에는 잠시 동안 각 사본의 상태가 서로 다를 수 있지만, 시간이 지나면 결국 같은 값으로 맞춰진다는 것을 의미한다.</p>

<h3 id="최종-일관성의-예시">최종 일관성의 예시</h3>
<p>예를 들어 내가 유튜브 동영상을 시청해 조회수가 1 증가했다고 하자.<br />
이때 어떤 사용자는 이미 증가된 조회수를 볼 수 있지만, 다른 사용자는 잠시 동안 이전 조회수를 볼 수도 있다.<br />
하지만 몇 초 혹은 몇 분 뒤에는 모든 사용자에게 같은 조회수가 보이게 된다.<br />
이처럼 <strong>지금 당장은 다를 수 있지만, 결국은 같아지는 것</strong>이 최종 일관성의 핵심이다.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant A as 사용자 A
    participant S1 as 서버 1
    participant S2 as 서버 2
    participant B as 사용자 B

    A-&gt;&gt;S1: 조회수 증가 요청
    S1--&gt;&gt;A: 증가 완료
    S1-&gt;&gt;S2: 나중에 동기화

    B-&gt;&gt;S2: 조회수 조회
    S2--&gt;&gt;B: 이전 조회수 반환

    S2-&gt;&gt;S2: 동기화 완료
</code></pre>

<h3 id="최종-일관성이-필요한-이유">최종 일관성이 필요한 이유</h3>
<p>최종 일관성은 시스템의 처리 속도와 가용성을 높이기 위해 자주 사용된다.<br />
모든 데이터 변경을 모든 서버에 즉시 반영하려고 하면 응답 시간이 느려질 수 있고, 일부 서버에 문제가 생겼을 때 전체 서비스가 영향을 받을 수도 있다.<br />
반면 최종 일관성은 일시적인 불일치를 허용하는 대신, 더 빠르게 응답하고 더 유연하게 시스템을 운영할 수 있게 해준다.</p>

<p>즉, 최종 일관성은 데이터가 변경된 직후 짧은 시간 동안 사본 간 데이터 불일치 상태를 허용하지만, 결과적으로는 모든 사본이 동일한 상태로 수렴하는 것을 보장하는 개념이다.<br />
이는 “지금 이 순간 완전히 같아야 한다”보다 “조금 늦더라도 결국 맞춰진다”에 더 초점을 둔 방식이라고 볼 수 있다.</p>

<h3 id="최종-일관성이-유용한-상황">최종 일관성이 유용한 상황</h3>
<p>최종 일관성은 유튜브 조회수, 인스타그램 좋아요 수, 게시글 조회 수처럼 <strong>잠시 값이 어긋나더라도 사용자에게 치명적인 문제가 되지 않는 서비스</strong>에서 유용하다.<br />
이런 데이터는 몇 초 정도 늦게 반영되더라도 사용자가 큰 불편을 느끼지 않는 경우가 많다.<br />
오히려 즉각적인 완전 일치를 위해 시스템 성능을 희생하는 것이 더 비효율적일 수 있다.</p>

<p>또한 SNS 피드, 댓글 수, 추천 수처럼 대규모 트래픽이 몰리는 서비스에서도 최종 일관성은 실용적이다.<br />
수많은 사용자가 동시에 데이터를 읽고 쓰는 상황에서 모든 요청에 대해 즉시 완전한 동기화를 보장하려고 하면 시스템 비용이 급격히 커질 수 있기 때문이다.<br />
이럴 때 최종 일관성은 현실적인 타협점이 된다.</p>

<h3 id="최종-일관성의-한계">최종 일관성의 한계</h3>
<p>하지만 모든 상황에 적합한 것은 아니다.<br />
처리 속도가 빠르다는 장점이 있지만, 데이터의 순간적인 불일치가 사용자의 경험을 떨어뜨릴 수 있는 경우도 있다.</p>

<p>예를 들어 재고가 1개 남은 상품을 A 사용자가 보고 결제를 진행했다고 하자.<br />
그런데 재고 차감이 아직 다른 서버에 반영되기 전에 B 사용자가 동일한 상품을 보면, 여전히 재고가 1개 남아 있다고 보일 수 있다.<br />
B 사용자는 상품을 주문할 수 있다고 생각하고 주소 입력과 결제까지 진행했지만, 마지막 순간에 품절 메시지를 보게 될 수도 있다.<br />
사용자는 “분명 구매 가능하다고 했는데 왜 안 되지?”라는 실망감을 느낄 수 있다.</p>

<p>즉, 최종 일관성은 <strong>사용자 경험에 큰 문제가 없는 범위 내에서만</strong> 효과적인 전략이다.<br />
데이터의 약간의 지연이 허용되는 영역에서는 강력하지만, 그 지연이 곧바로 손해나 서비스 신뢰도 저하로 이어지는 영역에서는 신중하게 사용해야 한다.</p>

<h2 id="강한-일관성-strong-consistency">강한 일관성 (Strong Consistency)</h2>
<h3 id="강한-일관성의-개념">강한 일관성의 개념</h3>
<p>강한 일관성은 데이터가 변경되는 즉시 모든 사본에 동일하게 반영되어, 어떤 시점에 어떤 사용자가 데이터를 읽더라도 항상 동일한 값을 보장하는 방식이다.<br />
즉, 데이터가 한 번 갱신되면 그 직후부터는 어디에서 읽든 같은 결과가 나와야 한다.</p>

<p>쉽게 말하면, 어떤 사용자가 값을 변경했다면 다른 사용자는 절대로 “이전 값”을 보아서는 안 된다.<br />
강한 일관성에서는 최신 데이터가 모든 시스템에 반영되기 전까지 읽기나 쓰기 과정이 제어도록 만든다.</p>

<h3 id="강한-일관성의-예시">강한 일관성의 예시</h3>
<p>예를 들어 은행 계좌에서 10만 원이 출금되었다면, ATM, 모바일 앱, 인터넷 뱅킹, 내부 정산 시스템 어디에서 계좌를 조회하든 모두 동일한 잔액이 보여야 한다.<br />
어떤 채널에서는 출금 전 잔액이 보이고, 다른 채널에서는 출금 후 잔액이 보인다면 심각한 문제를 초래할 수 있다.<br />
이처럼 돈, 결제, 주문, 예약, 재고처럼 <strong>정확성이 속도보다 더 중요한 영역</strong>에서는 강한 일관성이 필수적이다.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant User as 사용자
    participant App as 앱
    participant DB1 as DB 1
    participant DB2 as DB 2

    User-&gt;&gt;App: 잔액 출금 요청
    App-&gt;&gt;DB1: 잔액 변경
    App-&gt;&gt;DB2: 즉시 동기화
    DB2--&gt;&gt;App: 반영 완료
    App--&gt;&gt;User: 출금 완료

    User-&gt;&gt;App: 잔액 조회
    App--&gt;&gt;User: 항상 동일한 최신 잔액 반환
</code></pre>

<h3 id="강한-일관성의-장점">강한 일관성의 장점</h3>
<p>강한 일관성의 가장 큰 장점은 신뢰성이다.<br />
사용자는 언제 어디서 데이터를 읽더라도 같은 결과를 기대할 수 있고, 개발자 역시 시스템의 상태를 더 명확하게 이해할 수 있다.<br />
그만큼 비즈니스 로직도 단순해지고, “어느 서버에서는 아직 반영되지 않았을 수 있다” 같은 예외 상황을 덜 고려해도 된다.</p>

<h3 id="강한-일관성의-비용">강한 일관성의 비용</h3>
<p>다만 강한 일관성은 그만큼 비용이 크다.<br />
모든 사본이 같은 상태가 될 때까지 기다려야 하므로 응답 속도가 느려질 수 있고, 네트워크 지연이나 일부 노드 장애가 전체 처리에 영향을 줄 수 있다.<br />
즉, 강한 일관성은 정확성과 신뢰성을 높여 주지만, 성능과 가용성 측면에서는 더 많은 희생을 요구하는 방식이기도 하다.</p>

<h3 id="어떤-상황에-강한-일관성이-적합한가">어떤 상황에 강한 일관성이 적합한가</h3>
<p>결국 시스템 설계에서 중요한 것은 “무조건 강한 일관성이 좋다” 혹은 “최종 일관성이 더 효율적이다”처럼 하나의 답을 고르는 것이 아니다.<br />
핵심은 <strong>어떤 데이터가 얼마나 즉시 정확해야 하는지</strong>, 그리고 <strong>사용자가 어느 정도의 지연이나 불일치를 받아들일 수 있는지</strong>를 기준으로 적절한 방식을 선택하는 것이다.</p>

<p>조회 수나 좋아요 수처럼 조금 늦게 반영되어도 괜찮은 데이터에는 최종 일관성이 적합할 수 있다.<br />
반면 결제 금액, 계좌 잔액, 좌석 예약, 재고 수량처럼 순간적인 오차조차 큰 문제를 만들 수 있는 데이터에는 강한 일관성이 더 적합하다.</p>

<h2 id="마무리">마무리</h2>
<p>일관성은 단순히 데이터를 똑같이 맞추는 기술적 개념이 아니라, 시스템에 대한 사용자의 신뢰를 만드는 중요한 속성이라고 생각한다.<br />
사용자는 내부 구조를 알지 못하더라도 서비스가 “예상한 대로 동작하는가”를 통해 그 시스템을 평가한다.<br />
그리고 그 예측 가능성을 만들어 주는 핵심 요소 중 하나가 바로 일관성이다.</p>

<p>결국 좋은 시스템이란 무조건 빠르기만 한 시스템도, 무조건 엄격하기만 한 시스템도 아니다.<br />
상황에 따라 어느 정도의 일관성을 선택할지 분명한 기준을 가지고, 사용자 경험과 비즈니스 요구사항 사이에서 균형을 잘 맞춘 시스템이 좋은 시스템이라고 생각한다.</p>]]></content><author><name>seungjjun</name><email>stw550@gmail.com</email></author><category term="Database" /><category term="DB" /><category term="데이터베이스" /><category term="Consistency" /><category term="일관성" /><category term="분산시스템" /><summary type="html"><![CDATA[일관성]]></summary></entry><entry><title type="html">멱등성 키 하나로 중복 결제를 막는 원리</title><link href="https://seungjjun.github.io/system%20design/idempotency-key/" rel="alternate" type="text/html" title="멱등성 키 하나로 중복 결제를 막는 원리" /><published>2026-03-23T14:00:00+00:00</published><updated>2026-03-23T14:00:00+00:00</updated><id>https://seungjjun.github.io/system%20design/idempotency-key</id><content type="html" xml:base="https://seungjjun.github.io/system%20design/idempotency-key/"><![CDATA[<h2 id="개요">개요</h2>
<p>온라인 쇼핑몰에서 결제 버튼을 눌렀는데 화면이 멈춰버린 경험, 누구나 한 번쯤 있을 것이다.</p>

<p>답답한 마음에 새로고침을 하거나 결제 버튼을 연타했는데, 잠시 후 ‘결제가 완료되었습니다’라는 문자와 함께 내 계좌에서 돈이 두 번 빠져나갔다면 어떨까?</p>

<p>사용자 입장에서는 고객센터에 전화해 환불을 요구하면 그만일지 모르지만, 결제 시스템을 만드는 개발자 입장에서는 식은땀이 나는 장애 시나리오다.
단순히 시스템이 다운되는 것보다, 데이터의 정합성(특히 ‘돈’과 관련된)이 깨지는 것이 훨씬 더 치명적인 문제이기 때문이다.</p>

<p>이러한 ‘중복 결제’ 문제는 왜 발생하는 것일까?<br />
사용자의 성급함 때문이라고 탓할 수도 있겠지만, 근본적인 원인은 네트워크의 불안정성에 있다.
그리고 이 불안정한 네트워크 환경에서 결제 시스템을 안전하게 지켜내기 위한 핵심 개념이 바로 이번 글의 주제인 <strong>멱등성</strong>(Idempotency)이다.</p>

<h2 id="결제-시스템에서의-네트워크-타임아웃">결제 시스템에서의 네트워크 타임아웃</h2>
<p>멱등성을 이야기하기 전에, 먼저 중복 결제가 발생하는 구체적인 상황을 짚고 넘어갈 필요가 있다.</p>

<p>클라이언트(웹 브라우저 혹은 앱)가 결제 서버로 결제 요청을 보낼 때, 이 요청이 성공하는지 실패하는지는 꽤 명확해 보이지만 사실 그 사이에 <strong>타임아웃</strong> 이라는 상태가 존재한다.</p>

<p>만약 서버가 죽어서 연결 자체가 안 되거나, 잔액 부족으로 결제가 거절되었다면 클라이언트는 명확한 실패 응답을 받는다.<br />
하지만 다음의 흐름을 생각해보자.</p>

<ol>
  <li>클라이언트가 결제 서버에 5만 원 결제 요청을 보낸다.</li>
  <li>서버는 요청을 받아 PG사에 5만원 결제 요청을 보내고 결제가 이루어진다.</li>
  <li>서버가 클라이언트에게 “결제 성공!” 이라는 응답을 돌려보내려는데, 이때 네트워크가 끊기거나 지연이 발생한다.</li>
  <li>클라이언트 입장에서는 일정 시간 동안 응답이 오지 않으니 ‘시간 초과’ 에러를 띄운다.</li>
</ol>

<p>여기서 문제가 발생한다.</p>

<p>클라이언트는 결제가 실패했다고 생각해서 (혹은 사용자가 버튼을 다시 눌러서) 동일한 결제 요청을 서버로 다시 보낸다.</p>

<p>하지만 서버 입장에서는 첫 번째 요청 처리를 이미 완료했음에도 불구하고, 새로 들어온 요청을 새로운 결제 건으로 인식해 또다시 5만 원을 차감해 버린다.
이것이 바로 우리가 막아야 할 중복 결제 시나리오다.</p>

<h2 id="멱등성이란">멱등성이란?</h2>
<p>수학이나 컴퓨터 과학에서 멱등성이란, 연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질을 의미한다.</p>

<p>수학 기호로 표현하자면 $f(f(x)) = f(x)$ 가 성립하는 함수 $f$를 멱등하다고 한다.</p>

<p>가장 쉬운 예로 엘리베이터의 버튼을 생각해보자.<br />
엘리베이터의 ‘닫힘’ 버튼을 한 번 누르나, 성격이 급해서 열 번을 연속으로 연타하나 엘리베이터 문이 닫힌다는 결과 자체는 변하지 않는다. 이것이 멱등성이 보장된 상태다.</p>

<p>이를 우리가 개발하는 REST API 관점으로 가져와보자.</p>
<ul>
  <li>GET: 서버의 데이터를 조회만 하므로 여러 번 호출해도 상태가 변하지 않는다. (멱등함)</li>
  <li>PUT / DELETE: 특정 리소스를 완전히 교체하거나 삭제하므로, 여러 번 지우거나 덮어씌워도 결국 최종 상태는 같다. (멱등함)</li>
  <li>POST: 호출할 때마다 새로운 리소스를 생성하거나 상태를 변경한다. (멱등하지 않음)</li>
</ul>

<p>일반적으로 결제 요청은 <code class="language-plaintext highlighter-rouge">POST /payments</code> 와 같은 형태로 이루어진다.<br />
호출할 때마다 새로운 결제 트랜잭션이 생성되기 때문에 멱등하지 않다. 그렇다면 이 POST 요청을 어떻게 멱등하게 만들 수 있을까?</p>

<h2 id="멱등성-키의-도입">멱등성 키의 도입</h2>
<p>이 문제를 해결하기 위해 Stripe, Toss 등 수많은 글로벌 결제 회사들이 사용하는 표준적인 방법이 바로 <strong>멱등성 키</strong>를 도입하는 것이다.</p>

<p>원리는 간단하다.<br />
클라이언트가 결제 요청을 할 때, 이 요청이 유일하다는 것을 증명하는 고유한 ID를 생성해서 HTTP 헤더에 담아 함께 보내는 것이다.</p>

<ol>
  <li>클라이언트가 헤더에 <code class="language-plaintext highlighter-rouge">Idempotency-Key: a1b2c3d4...</code> 를 담아 결제를 요청한다.</li>
  <li>서버는 이 멱등성 키를 받아 데이터베이스(혹은 Redis와 같은 캐시 저장소)에 이미 처리된 적이 있는 키인지 조회한다.</li>
  <li>만약 처음 보는 키라면? 정상적으로 결제 로직을 수행하고, 결제가 완료되면 이 멱등성 키와 결제 결과(응답 값)를 쌍으로 묶어 저장해둔다.</li>
  <li>만약 이미 저장된 키라면? (즉, 타임아웃 등으로 인해 클라이언트가 재시도한 경우라면) 결제 로직을 다시 타지 않는다. 대신, 이전에 저장해두었던 결제 결과(응답 값)를 그대로 꺼내서 클라이언트에게 돌려준다.</li>
  <li>이렇게 하면 클라이언트가 네트워크 문제로 같은 결제 요청을 10번, 100번 재시도하더라도 서버는 최초 1회만 결제를 처리하고 나머지는 캐싱된 성공 응답만 내려주게 된다. 비로소 결제 API가 멱등성을 갖추게 된 것이다.</li>
</ol>

<p>이렇게 하면 클라이언트가 네트워크 문제로 같은 결제 요청을 10번, 100번 재시도하더라도 서버는 최초 1회만 결제를 처리하고 나머지는 캐싱된 성공 응답만 내려주게 된다.
비로소 결제 API가 멱등성을 갖추게 된 것이다.</p>

<h2 id="멱등성-키-설계-시-고려할-것들">멱등성 키 설계 시 고려할 것들</h2>
<p>멱등성 키를 도입하는 것 자체는 어렵지 않지만, 실제 운영 환경에서는 키를 어떻게 생성하고, 얼마나 오래 보관하며, 실패한 요청은 어떻게 다룰지까지 설계해야 한다.</p>

<h3 id="키의-생성-누가-어떤-형식으로">키의 생성: 누가, 어떤 형식으로?</h3>
<p>멱등성 키는 클라이언트가 생성하여 서버에 전달하는 것이 원칙이다.<br />
서버가 키를 발급하는 구조로 만들면, 키를 발급받는 요청 자체가 실패했을 때 다시 원점으로 돌아오기 때문이다.
키의 형식으로는 UUID v4가 가장 널리 쓰인다. 충돌 확률이 사실상 무시할 수 있는 수준이고, 클라이언트 단독으로 생성할 수 있기 때문이다.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
</code></pre></div></div>

<p>다만 주의할 점이 있다. 멱등성 키는 하나의 의도에 대응해야 한다.<br />
같은 사용자가 같은 상품을 두 번 구매하는 것은 두 건의 서로 다른 결제이므로, 각각 다른 멱등성 키를 가져야 한다.</p>

<p>반대로 네트워크 타임아웃으로 인한 재시도는 같은 의도의 반복이므로 동일한 키를 재사용해야 한다.<br />
이 구분이 제대로 이루어지지 않으면 멱등성 키가 정상적인 결제까지 차단하거나, 반대로 중복 결제를 허용하게 된다.</p>

<h3 id="첫-번째-요청이-실패했다면">첫 번째 요청이 실패했다면?</h3>
<p>최초 요청이 성공하지 않고 실패 응답을 멱등키와 저장하게 되면 문제가 될 수 있다. 
클라이언트가 같은 키로 재시도할 때마다 실패 응답만 돌려받게 되어 결제를 완료할 수 없는 상황에 빠진다.<br />
이를 해결하기 위한 기본 원칙은 다음과 같다.</p>

<ul>
  <li>성공 응답(2xx): 멱등성 키와 함께 저장한다. 이후 같은 키로 요청이 들어오면 저장된 성공 응답을 그대로 반환한다.</li>
  <li>클라이언트 에러(4xx): 잔액 부족, 잘못된 카드 번호 등 클라이언트 측 원인이므로, 재시도해도 결과가 달라지지 않는다. 이 역시 멱등성 키와 함께 저장하여 동일한 에러 응답을 반환한다.</li>
  <li>서버 에러(5xx): 서버 측 일시적 장애이므로, 재시도하면 성공할 가능성이 있다. 따라서 응답을 저장하지 않고 같은 키로의 재시도를 허용해야 한다.</li>
</ul>

<p>이 세 가지 분기를 구분하지 않으면 멱등성 키가 오히려 시스템의 복구를 가로막는 장애물이 될 수 있다.</p>

<h2 id="조금-더-깊게-들어가보면-동시성-제어">조금 더 깊게 들어가보면 (동시성 제어)</h2>
<p>이론은 완벽해 보이지만, 실제 시스템에 이를 구현할 때는 한 가지 더 까다로운 문제를 만나게 된다. 바로 동시성 문제다.</p>

<p>만약 사용자가 정말 빠르게 결제 버튼을 두 번 눌러서, 완전히 동일한 멱등성 키를 가진 두 개의 요청이 0.001초 차이로 서버에 동시에 도달했다면 어떻게 될까?<br />
서버의 스레드 A와 스레드 B가 동시에 DB를 조회하고, 둘 다 “어? 아직 처리 안 된 키네?” 라고 판단하여 결제 로직을 두 번 실행해버릴 위험이 있다. 바로 <strong>Race Condition</strong>이다.</p>

<p>이를 방지하기 위해 서버는 멱등성 키를 저장할 때 분산 락을 사용하거나, 데이터베이스의 유니크 제약 조건을 활용하여 “단 하나의 스레드만” 결제 처리를 시작할 수 있도록 통제해야 한다.</p>

<p>결제 도메인에서 멱등성을 보장한다는 것은 단순히 캐시를 확인하는 것을 넘어, 이런 동시성 제어까지 이루어졌을 때 안전한 결제 시스템이 완성된다고 볼 수 있다.</p>

<h2 id="마치며">마치며</h2>
<p>눈에 보이지 않는 네트워크 영역의 문제는 시스템 전반에 치명적인 영향을 미친다. 결제 시스템에서의 네트워크 타임아웃 또한 피할 수 없는 자연재해와 같다.</p>

<p>우리는 네트워크가 항상 안정적일 것이라고 믿어선 안 된다. 클라이언트는 언제든 같은 요청을 다시 보낼 수 있다. 멱등성은 이러한 예측 불가능한 환경 속에서 시스템의 데이터 정합성을 지키고 시스템을 예측가능하게 만들어준다.</p>]]></content><author><name>seungjjun</name><email>stw550@gmail.com</email></author><category term="System Design" /><category term="Idempotency" /><category term="Payment" /><category term="중복결제" /><summary type="html"><![CDATA[개요 온라인 쇼핑몰에서 결제 버튼을 눌렀는데 화면이 멈춰버린 경험, 누구나 한 번쯤 있을 것이다.]]></summary></entry><entry><title type="html">AI 시대의 시스템 설계와 추상화</title><link href="https://seungjjun.github.io/ai/ai-and-abstraction/" rel="alternate" type="text/html" title="AI 시대의 시스템 설계와 추상화" /><published>2026-03-11T12:00:00+00:00</published><updated>2026-03-11T12:00:00+00:00</updated><id>https://seungjjun.github.io/ai/ai-and-abstraction</id><content type="html" xml:base="https://seungjjun.github.io/ai/ai-and-abstraction/"><![CDATA[<h2 id="무엇에-집중할-것인가">무엇에 집중할 것인가</h2>

<p>AI 덕분에 개발 생산성은 비약적으로 높아졌다.
단순한 API를 만들고, 반복적인 보일러플레이트 코드를 작성하고, 익숙한 패턴의 구현을 완성하는 데 드는 시간과 수고는 눈에 띄게 줄어들고 있다. 이제 많은 구현은 더 빠르게 만들 수 있고, 어느 정도는 더 쉽게 만들 수도 있게 되었다.</p>

<p>하지만 바로 그 지점에서 새로운 질문이 생긴다.
기계가 코드를 빠르게 만들어주는 시대에, 개발자는 무엇에 더 집중해야 할까?</p>

<p>이제 단순히 구현 속도만으로 차별화되기는 점점 어려워지고 있다.
앞으로 더 중요해지는 역량은 무엇이 진짜 문제인지 정의하고, 그것을 시스템으로 풀 수 있는 형태로 구조화하는 능력이라고 생각한다.
코드를 생산하는 능력보다, 복잡한 현실을 이해하고 문제를 올바르게 설정하는 능력. AI 시대일수록 이 역량의 가치가 더 커진다.</p>

<h2 id="설계-이전에-필요한-것-문제를-올바르게-정의하는-능력">설계 이전에 필요한 것, 문제를 올바르게 정의하는 능력</h2>

<p>최근 결제 시스템을 개발하며 흥미로운 경험을 했다.
시스템 설계 방향을 AI와 함께 고민해 보니, 학습된 패턴을 바탕으로 일반적으로 좋은 구조와 베스트 프랙티스에 가까운 답을 꽤 빠르게 제시해 주었다. 구현 초안을 만드는 속도도 인상적이었다.</p>

<p>하지만 그것을 실무에 그대로 적용할 수는 없었다.
우리 회사가 가진 레거시 시스템의 한계, 기존 데이터베이스와의 결합성, 운영 과정에서 이미 굳어진 정책, 그리고 문서만으로는 드러나지 않는 예외적인 비즈니스 제약까지 AI가 온전히 이해하고 반영하기는 어렵기 때문이다.</p>

<p>실무에서 더 어려운 일은 “어떻게 구현할까”보다 그보다 앞선 질문이다.
이 시스템이 실제로 해결해야 하는 문제는 무엇인가?
어디까지를 이 시스템의 책임으로 둘 것인가?
어떤 복잡성은 받아들이고, 어떤 복잡성은 제거해야 하는가?</p>

<p>결국 중요한 것은 정답처럼 보이는 구조를 가져오는 일이 아니라, 우리 조직의 맥락 속에서 문제를 다시 정의하는 일이었다.
같은 현상도 어떤 경우에는 데이터 모델의 문제이고, 어떤 경우에는 도메인 경계의 문제이며, 또 어떤 경우에는 운영 정책의 문제일 수 있다. 이 차이를 구분하지 못하면 설계도 흔들리고 구현도 쉽게 누더기가 된다.</p>

<p>AI는 답안을 빠르게 써줄 수 있다.
하지만 어떤 질문을 던져야 하는지, 무엇을 풀어야 하는지를 결정하는 일은 여전히 개발자의 몫이다.</p>

<h2 id="설계는-구조를-세우는-일이고-문제-정의는-그-출발점이다">설계는 구조를 세우는 일이고, 문제 정의는 그 출발점이다</h2>

<p>좋은 설계는 단지 기술적으로 보기 좋은 구조를 만드는 일이 아니다.
문제를 어떤 단위로 나누고, 어떤 책임을 어디에 둘지 정하며, 시스템이 감당해야 할 복잡도의 경계를 결정하는 일에 가깝다.</p>

<p>이 과정에서 가장 중요한 출발점이 바로 문제 정의다.
문제를 잘못 정의하면, 그 위에 아무리 좋은 패턴과 깔끔한 코드를 쌓아도 결국 엉뚱한 방향으로 단단한 시스템을 만들게 된다. 반대로 문제를 정확히 정의하면, 설계와 구현은 훨씬 선명해진다.</p>

<p>개발자가 회사의 맥락에 맞게 문제를 정의하고, 도메인을 적절한 단위로 추상화하며, 탄탄한 인터페이스를 설계해 두면 그 내부를 채우는 구현은 AI가 훨씬 빠르고 효율적으로 도울 수 있다.
그래서 AI 시대에 설계 역량이 중요하다는 말은, 결국 그 앞단에 있는 문제 정의 능력이 더 중요해진다는 말과도 같다.</p>

<h2 id="파편화된-코드에서-견고한-시스템으로">파편화된 코드에서 견고한 시스템으로</h2>

<p>AI가 아무리 훌륭한 코드를 빠르게 만들어낸다고 해도, 그것들을 단순히 이어 붙인다고 해서 거대한 비즈니스를 버텨내는 견고한 시스템이 되지는 않는다.
소프트웨어는 본질적으로 복잡하며, 시간이 지날수록, 요구사항이 늘어날수록, 예외 케이스가 쌓일수록 그 복잡도는 빠르게 증가한다.</p>

<p>이 복잡도를 다루는 핵심 무기 중 하나가 바로 추상화다.
추상화는 단순히 구현을 감추는 기술이 아니다. 무엇이 중요한지 구분하고, 무엇을 시스템의 책임으로 삼을지 결정하며, 흩어진 규칙을 하나의 일관된 구조로 묶어내는 방식이다.</p>

<p>그리고 좋은 추상화는 좋은 문제 정의에서 출발한다.
무엇을 일반 규칙으로 다룰 것인지, 무엇을 예외로 남겨둘 것인지, 어떤 책임을 하나의 모듈 안에 응집시킬 것인지를 먼저 결정해야 하기 때문이다.</p>

<p>AI가 쏟아내는 수많은 코드 속에서 개발자는 비즈니스 도메인에 대한 이해를 바탕으로 시스템의 역할을 정의하고 모듈 간의 경계를 명확히 그어야 한다.
파편화된 로직들을 하나의 일관된 서비스로 엮어내고, 시간이 지나도 쉽게 무너지지 않는 구조를 만드는 일은 결국 인간의 추상화 역량에 달려 있다.</p>

<h2 id="추상의-결과물-1-당연한-것을-당연하게-지켜내는-신뢰">추상의 결과물 1: 당연한 것을 당연하게 지켜내는 신뢰</h2>

<p>이러한 설계와 추상화가 빛을 발하는 첫 번째 영역은, 사용자가 너무나 당연하게 기대하는 신뢰의 영역이다.</p>

<p>일반 사용자나 비개발자 직군과 이야기해 보면, 거대한 이벤트로 수백만 명의 트래픽이 몰리는 상황에서도 내가 누른 결제 버튼이 오류 없이 정상적으로 처리되는 것을 당연하게 여긴다.
하지만 실제로는 수많은 요청이 동시에 들어오는 상황에서 데이터 정합성을 유지하고, 중복 처리나 누락 없이 결제를 끝까지 안전하게 처리하는 일은 결코 단순하지 않다.</p>

<p>여기서 중요한 것은 단순히 예외 처리를 많이 넣는 것이 아니다.
어떤 실패를 시스템 차원에서 막아야 하는지, 어떤 상태를 기준으로 정합성을 판단할지, 어떤 책임을 어느 계층이 가져가야 하는지를 먼저 정의해야 한다.
다시 말해, 신뢰할 수 있는 시스템은 결국 신뢰를 구현하기 전에 무엇을 지켜야 하는지부터 명확히 정의한 시스템이다.</p>

<p>단 한 번의 실패로도 서비스의 신뢰는 크게 흔들릴 수 있다.
복잡한 비즈니스 로직을 안전한 구조로 추상화하고, 그 당연함을 흔들리지 않게 지켜내는 것. 이것이 철저한 설계가 만들어내는 가장 묵직한 가치다.</p>

<h2 id="추상의-결과물-2-필터링이-필요-없는-진짜-검색">추상의 결과물 2: 필터링이 필요 없는 ‘진짜’ 검색</h2>

<p>방대한 데이터가 쏟아지는 시대에, 훌륭한 시스템 설계가 돋보이는 또 다른 영역은 정확한 탐색이다.</p>

<p>우리는 종종 검색 결과를 보고도 그것이 내가 진짜 원하던 정보가 맞는지 다시 판단해야 한다. 스크롤을 내리고, 페이지를 넘기고, 머릿속에서 한 번 더 필터링한다.
이런 검색은 결과를 보여주기는 하지만, 사용자의 판단 비용을 줄여주지는 못한다.</p>

<p>반면 잘 설계된 검색 시스템은 다르다.
단순히 많은 결과를 반환하는 것이 아니라, 사용자가 무엇을 찾고 싶어 하는지를 더 정확하게 문제로 정의한다. 사용자가 입력한 키워드 자체보다 그 뒤에 있는 의도를 어떻게 해석할 것인지, 어떤 맥락을 중요하게 보고 어떤 노이즈를 제거할 것인지가 더 중요하다.</p>

<p>좋은 검색은 결국 문제 정의의 품질에서 차이가 난다.
단순한 키워드 매칭 문제로 볼 것인지, 사용자의 탐색 의도를 이해하는 문제로 볼 것인지에 따라 시스템의 구조도 완전히 달라진다.
그리고 이 차이가 검색 결과를 보고 사용자가 한 번 더 필터링해야 하는 과정 자체를 줄여준다.</p>

<p>단순한 코드의 합이 아니라, 데이터와 사용자 의도 사이의 복잡성을 어떻게 정의하고 구조화했는지가 시스템의 수준을 결정한다.</p>

<h2 id="ai-시대에-더-중요해지는-개발자의-역량">AI 시대에 더 중요해지는 개발자의 역량</h2>

<p>물론 구현 역량 자체가 중요하지 않다는 뜻은 아니다.
여전히 구현은 필요하고, 정확하고 안정적인 코드를 만드는 능력은 기본이다. 다만 AI가 구현 속도를 크게 끌어올리는 시대에는, 단순히 빨리 짜는 능력만으로는 차별화되기 어려워진다.</p>

<p>앞으로 개발자에게 더 중요해지는 역량은 다음과 같은 것들일 것이다.
주어진 요청을 그대로 코드로 옮기는 능력보다, 그 요청 뒤에 있는 실제 문제를 파악하는 능력.
표면적인 증상을 보고 바로 구현으로 뛰어드는 것이 아니라, 그것이 정책의 문제인지, 모델링의 문제인지, 시스템 경계의 문제인지 구분하는 능력.
그리고 그렇게 정의한 문제를 여러 제약 속에서도 풀 수 있도록 구조화하는 능력.</p>

<p>결국 좋은 개발자는 구현을 잘하는 사람에 머무르지 않는다.
문제를 더 정확하게 보고, 더 작은 단위로 나누고, 더 적절한 책임 경계를 세워서 시스템이 다룰 수 있는 형태로 바꾸는 사람이다.
AI 시대일수록 이 역량은 더 본질적인 경쟁력이 된다.</p>

<h2 id="마치며-구현을-넘어서-문제를-정의하는-개발자">마치며: 구현을 넘어서 문제를 정의하는 개발자</h2>

<p>과거에는 코드가 사람과 기계가 함께 이해할 수 있으면 충분했다.
하지만 이제는 AI라는 새로운 협업 주체가 등장했고, 구현의 많은 부분은 점점 더 빠르게 자동화되고 있다.</p>

<p>이 변화 속에서 개발자의 가치가 사라지는 것은 아니다. 오히려 어디에서 가치가 드러나는지가 더 분명해지고 있다.
기술의 구현 난이도는 낮아지고 있지만, 문제를 발견하고, 그 문제를 정확히 정의하고, 복잡한 현실을 시스템으로 풀 수 있는 구조로 번역하는 일의 중요성은 더 커지고 있다.</p>

<p>AI가 코드를 작성하는 시대일수록, 개발자는 더 깊이 생각해야 한다.
무엇이 진짜 문제인지, 무엇을 단순화해야 하는지, 무엇을 시스템이 책임져야 하는지를 끊임없이 정의해야 한다.
앞으로 개발자가 가져야 할 핵심 역량은 단순한 구현 속도가 아니라, 문제를 올바르게 정의하는 능력이라고 생각한다.</p>]]></content><author><name>seungjjun</name><email>stw550@gmail.com</email></author><category term="AI" /><category term="AI" /><category term="Architecture" /><category term="Abstraction" /><summary type="html"><![CDATA[무엇에 집중할 것인가]]></summary></entry><entry><title type="html">티스토리에서 깃허브로 : 새로운 공간으로의 이동</title><link href="https://seungjjun.github.io/etc/moving-to-github-pages/" rel="alternate" type="text/html" title="티스토리에서 깃허브로 : 새로운 공간으로의 이동" /><published>2026-03-07T06:00:00+00:00</published><updated>2026-03-07T06:00:00+00:00</updated><id>https://seungjjun.github.io/etc/moving-to-github-pages</id><content type="html" xml:base="https://seungjjun.github.io/etc/moving-to-github-pages/"><![CDATA[<h2 id="새로운-시작">새로운 시작</h2>

<p>2026년의 시작과 함께 블로그 이전 작업을 마쳤다.</p>

<p>백엔드 개발자로 커리어를 쌓아온 지 어느덧 4년 차에 접어들었고, 최근에는 서울의 새로운 자취방으로 이사를 하는 등 신변에도 꽤 많은 변화가 있었다.<br />
새로운 공간에서 짐을 풀고 인테리어를 고민하다 보니, 내가 작성하는 글들이 쌓이는 ‘디지털 공간’ 역시 새롭게 단장하고 싶다는 생각이 자연스럽게 들었다.</p>

<h2 id="이전하는-이유">이전하는 이유</h2>

<p>기존 블로그(티스토리): <a href="https://seungjjun.tistory.com">https://seungjjun.tistory.com</a></p>

<p>기존 블로그인 티스토리는 개발 공부를 처음 시작할 때 큰 고민 없이 선택한 플랫폼이었다. 하지만 3년 넘게 운영하며 다음과 같은 갈증과 변화가 생겼다.</p>

<ol>
  <li>
    <p><strong>반년의 공백과 회고</strong>: 사실 2025년 하반기부터는 블로그 활동이 거의 멈춰 있었다. <br />
마지막 글이 8월 3일이었으니 반년이 훌쩍 넘은 셈이다. 회사에서 맡은 업무가 많아지면서 개인 공부와 기록에 소홀해졌고, 방치된 블로그를 볼 때마다 마음 한편이 무거웠다. <br />
이번 이전은 단순히 플랫폼을 바꾸는 것을 넘어, 다시금 기록하는 습관을 되찾기 위한 재정비의 의미가 크다.</p>
  </li>
  <li>
    <p><strong>커스터마이징의 한계</strong>: 백엔드 개발자로서 시스템을 통제하고 싶은 욕구는 블로그에도 예외가 아니었다. 정해진 스킨 안에서 머무르기보다, 내가 원하는 대로 레이아웃을 수정하고 기능을 추가하고 싶었다.</p>
  </li>
  <li>
    <p><strong>글쓰기 경험의 일관성</strong>: 업무와 개인 공부에 마크다운을 주로 사용하는데, 티스토리 에디터와 마크다운 사이의 미묘한 부조화가 늘 아쉬웠다. 이제는 평소 쓰던 도구로 글을 쓰고 Git으로 관리하는 워크플로우를 갖고 싶었다.</p>
  </li>
  <li>
    <p><strong>지속 가능한 아카이빙</strong>: 내 글이 플랫폼에 종속되지 않고, Git 저장소에 온전히 보관된다는 안정감을 얻고 싶었다.</p>
  </li>
</ol>

<h2 id="나의-두-번째-자취방-나의-블로그">나의 두 번째 자취방, 나의 블로그</h2>

<p>블로그는 결국 생각을 정리하는 도구다.</p>

<p>새로 이사한 방을 내 생활 패턴에 맞춰 정리하듯, 블로그 역시 온전히 내 컨트롤 하에 두고 싶었다.<br />
테마를 고르고 폰트와 레이아웃을 수정하는 과정은 단순히 꾸미는 행위를 넘어, 내가 가장 글쓰기에 집중할 수 있는 최적의 환경을 설계하는 과정이었다.</p>

<p>나에게 최적화된 이 공간에서라면 앞으로 더 꾸준히 기록을 남길 수 있을 것 같다.</p>

<h2 id="앞으로의-운영-방식">앞으로의 운영 방식</h2>

<p>새로운 블로그에서는 다음과 같은 변화를 주려고 한다.</p>

<ol>
  <li>
    <p><strong>기술적인 깊이와 사이드 프로젝트</strong>: 실무에서 진행 중인 <strong>구독 결제 시스템</strong> 구축 과정에서의 삽질이나, 요즘 관심 있는 <strong>검색 엔진</strong> 공부 기록들을 정리할 예정이다.</p>
  </li>
  <li>
    <p><strong>AI 시대의 개발자 ‘뉴노멀’</strong>: 최근 AI가 코딩을 대체한다는 이야기가 많지만, 결국 중요한 것은 ‘<strong>문제를 정의하고 품질을 보장하는 능력</strong>‘이라는 점에 깊이 공감한다.<br />
이제는 코드를 한 줄 더 짜는 것보다 ‘요구사항을 구체화하는 법’과 ‘AI라는 새로운 추상화 레이어를 활용하는 법’에 대한 고민을 기록해 보려 한다.</p>
  </li>
  <li>
    <p><strong>언러닝(Unlearning)과 스페셜리스트</strong>: 익숙해진 관성을 의심하고 새로운 습관을 익히는 ‘언러닝’의 과정, 그리고 AI가 대체하기 어려운 판단력을 가진 ‘스페셜리스트’로 성장하기 위한 학습 기록들을 꾸준히 남길 생각이다.</p>
  </li>
  <li>
    <p><strong>부드러운 문체</strong>: 이전에는 다소 딱딱한 경어체를 유지하려 노력했지만, 이제는 주제에 따라 평어체를 섞어 쓰며 내 생각을 더 자연스럽게 전달해보려 한다.</p>
  </li>
</ol>

<p>너무 완벽한 글을 쓰려다 발행을 미루기보다, 조금 부족하더라도 꾸준히 기록하는 것을 목표로 삼으려 한다. 이 새로운 공간이 앞으로의 성장을 오롯이 담아내는 좋은 그릇이 되길 기대해본다.</p>]]></content><author><name>seungjjun</name><email>stw550@gmail.com</email></author><category term="Etc" /><category term="Blog" /><category term="Github Pages" /><category term="Retrospective" /><summary type="html"><![CDATA[새로운 시작]]></summary></entry></feed>