<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>hyeondooori.log</title>
        <link>https://velog.io/</link>
        <description>노는 게 제일 좋은 뽀로로</description>
        <lastBuildDate>Mon, 17 Aug 2026 11:28:03 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>hyeondooori.log</title>
            <url>https://velog.velcdn.com/images/hyeondooori_/profile/aea9d2c1-b5f9-49b5-93c1-a4f47fd1253c/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. hyeondooori.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/hyeondooori_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[FastAPI 뉴스 모니터링 서버에 Kubernetes 적용하기: 3중화, HPA, 스케줄러 분리까지]]></title>
            <link>https://velog.io/@hyeondooori_/FastAPI-%EB%89%B4%EC%8A%A4-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%84%9C%EB%B2%84%EC%97%90-Kubernetes-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-3%EC%A4%91%ED%99%94-HPA-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-%EB%B6%84%EB%A6%AC%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@hyeondooori_/FastAPI-%EB%89%B4%EC%8A%A4-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%84%9C%EB%B2%84%EC%97%90-Kubernetes-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-3%EC%A4%91%ED%99%94-HPA-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-%EB%B6%84%EB%A6%AC%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Mon, 17 Aug 2026 11:28:03 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>현재 개발 중인 뉴스 모니터링 서비스는 다음과 같은 작업을 수행한다.</p>
<ul>
<li>뉴스 기사 수집 및 저장</li>
<li>AI 서버를 통한 기사 요약과 우선순위 분석</li>
<li>홍보처용 메일 생성 및 발송</li>
<li>매일 정해진 시간에 실행되는 스케줄러 작업</li>
<li>기사 조회와 편집을 위한 FastAPI API 제공</li>
</ul>
<p>기존에는 하나의 FastAPI 서버에서 API 요청과 스케줄러를 함께 처리하고 있었다.</p>
<p>기능 자체는 정상적으로 동작했지만, 사용량이 증가했을 때 다음과 같은 문제가 생길 수 있었다.</p>
<ol>
<li>API 서버 하나가 중단되면 전체 서비스가 중단된다.</li>
<li>트래픽이 늘어나도 서버가 자동으로 확장되지 않는다.</li>
<li>API 서버를 여러 개 실행하면 스케줄러도 중복 실행될 수 있다.</li>
<li>배포 도중 서버가 잠시 중단될 가능성이 있다.</li>
<li>서버의 CPU와 메모리 사용량을 기준으로 확장하기 어렵다.</li>
</ol>
<p>이 문제를 해결하기 위해 Docker Desktop의 로컬 Kubernetes 환경에 FastAPI 서버를 배포했다.</p>
<p>이번 글에서는 Kubernetes를 적용한 이유부터 구성 과정, 시행착오, 부하 테스트 결과까지 정리한다.</p>
<hr>
<h2 id="1-kubernetes를-적용한-이유">1. Kubernetes를 적용한 이유</h2>
<h3 id="11-api-서버-고가용성-확보">1.1 API 서버 고가용성 확보</h3>
<p>FastAPI 서버를 하나만 실행하면 해당 프로세스나 컨테이너가 종료되는 순간 서비스 전체가 중단된다.</p>
<p>Kubernetes에서는 같은 애플리케이션을 여러 Pod로 실행할 수 있다.</p>
<p>이번 구성에서는 FastAPI API Pod를 기본 3개 실행하도록 설정했다.</p>
<pre><code class="language-yaml">spec:
  replicas: 3</code></pre>
<p>하나의 Pod에 문제가 생겨도 나머지 Pod가 계속 요청을 처리할 수 있다. Pod가 비정상 종료되면 Kubernetes가 자동으로 새로운 Pod를 생성한다.</p>
<hr>
<h3 id="12-트래픽에-따른-자동-확장">1.2 트래픽에 따른 자동 확장</h3>
<p>기사 목록 조회나 통계 처리 요청이 몰릴 경우 고정된 서버 수만으로는 응답 시간이 느려질 수 있다.</p>
<p>이를 위해 HPA, 즉 Horizontal Pod Autoscaler를 적용했다.</p>
<p>설정 기준은 다음과 같다.</p>
<ul>
<li>기본 Pod 수: 3개</li>
<li>최대 Pod 수: 10개</li>
<li>CPU 평균 사용률 목표: 50%</li>
</ul>
<pre><code class="language-yaml">apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: news-api
  namespace: news-monitor
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: news-api
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50</code></pre>
<p>CPU 사용률이 목표치를 초과하면 Kubernetes가 API Pod를 자동으로 늘린다. 부하가 줄어들면 다시 최소 Pod 수인 3개로 축소한다.</p>
<hr>
<h3 id="13-스케줄러-중복-실행-방지">1.3 스케줄러 중복 실행 방지</h3>
<p>가장 주의해야 했던 부분은 스케줄러였다.</p>
<p>기존 FastAPI 애플리케이션은 서버가 시작될 때 내부 스케줄러도 함께 실행한다. 이 상태에서 API Pod를 3개 실행하면 같은 기사 수집 작업이나 메일 발송 작업이 3번씩 실행될 수 있다.</p>
<p>따라서 API와 스케줄러를 논리적으로 분리했다.</p>
<pre><code class="language-mermaid">flowchart LR
    U[&quot;사용자 요청&quot;] --&gt; S[&quot;Kubernetes Service&quot;]
    S --&gt; A1[&quot;FastAPI Pod 1&lt;br/&gt;Scheduler OFF&quot;]
    S --&gt; A2[&quot;FastAPI Pod 2&lt;br/&gt;Scheduler OFF&quot;]
    S --&gt; A3[&quot;FastAPI Pod 3&lt;br/&gt;Scheduler OFF&quot;]

    SC[&quot;Scheduler Pod 1개&lt;br/&gt;Scheduler ON&quot;] --&gt; DB[&quot;PostgreSQL&quot;]
    A1 --&gt; DB
    A2 --&gt; DB
    A3 --&gt; DB

    SC --&gt; AI[&quot;Dify / AI 서버&quot;]
    SC --&gt; CR[&quot;기사 수집 서버&quot;]</code></pre>
<p>API Pod에는 다음 환경변수를 설정했다.</p>
<pre><code class="language-yaml">env:
  - name: ENABLE_SCHEDULER
    value: &quot;false&quot;</code></pre>
<p>별도의 Scheduler Pod에서만 스케줄러를 활성화했다.</p>
<pre><code class="language-yaml">env:
  - name: ENABLE_SCHEDULER
    value: &quot;true&quot;</code></pre>
<p>이렇게 하면 API Pod가 10개까지 확장되더라도 기사 수집과 자동 메일 발송은 Scheduler Pod 한 곳에서만 실행된다.</p>
<hr>
<h2 id="2-전체-kubernetes-구성">2. 전체 Kubernetes 구성</h2>
<p>Kubernetes 구성 파일은 다음과 같이 나눴다.</p>
<pre><code class="language-text">k8s/
├── namespace.yaml
├── api-deployment.yaml
├── api-service.yaml
├── api-hpa.yaml
├── scheduler-deployment.yaml
├── metrics-server-patch.yaml
└── README.md</code></pre>
<p>각 파일의 역할은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>파일</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>namespace.yaml</code></td>
<td>뉴스 서비스 전용 Namespace 생성</td>
</tr>
<tr>
<td><code>api-deployment.yaml</code></td>
<td>FastAPI API Pod 3개 구성</td>
</tr>
<tr>
<td><code>api-service.yaml</code></td>
<td>API Pod로 요청 분산</td>
</tr>
<tr>
<td><code>api-hpa.yaml</code></td>
<td>CPU 기준 자동 확장</td>
</tr>
<tr>
<td><code>scheduler-deployment.yaml</code></td>
<td>스케줄러 전용 Pod 1개 실행</td>
</tr>
<tr>
<td><code>metrics-server-patch.yaml</code></td>
<td>로컬 Docker Desktop Metrics Server 설정</td>
</tr>
<tr>
<td><code>README.md</code></td>
<td>배포 및 검증 방법 정리</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-namespace-분리">3. Namespace 분리</h2>
<p>관련 리소스를 한 곳에서 관리할 수 있도록 <code>news-monitor</code> Namespace를 만들었다.</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Namespace
metadata:
  name: news-monitor</code></pre>
<p>이후 모든 Deployment, Service, HPA를 같은 Namespace에 배포했다.</p>
<pre><code class="language-powershell">kubectl apply -f k8s/namespace.yaml</code></pre>
<p>리소스를 조회할 때도 Namespace를 지정한다.</p>
<pre><code class="language-powershell">kubectl get pods -n news-monitor</code></pre>
<hr>
<h2 id="4-fastapi-deployment-구성">4. FastAPI Deployment 구성</h2>
<p>API 서버는 기본 3개의 Pod로 실행한다.</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: news-api
  namespace: news-monitor
spec:
  replicas: 3</code></pre>
<p>컨테이너는 Uvicorn으로 실행한다.</p>
<pre><code class="language-yaml">command:
  - python
  - -m
  - uvicorn
  - app.main:app
  - --host
  - 0.0.0.0
  - --port
  - &quot;8001&quot;
  - --workers
  - &quot;1&quot;</code></pre>
<p>Pod 하나에 Uvicorn Worker를 하나만 실행한 이유는 프로세스 수보다 Kubernetes Pod 수를 기준으로 확장하기 위해서다.</p>
<hr>
<h2 id="5-무중단-rollingupdate">5. 무중단 RollingUpdate</h2>
<p>배포 중 기존 Pod가 모두 종료되지 않도록 RollingUpdate 전략을 적용했다.</p>
<pre><code class="language-yaml">strategy:
  type: RollingUpdate
  rollingUpdate:
    maxUnavailable: 0
    maxSurge: 1</code></pre>
<p>의미는 다음과 같다.</p>
<ul>
<li><code>maxUnavailable: 0</code>: 교체 중 사용할 수 없는 Pod를 허용하지 않는다.</li>
<li><code>maxSurge: 1</code>: 새 Pod를 최대 1개 먼저 추가한 후 기존 Pod를 종료한다.</li>
</ul>
<p>예를 들어 기존 Pod가 3개라면 새 버전 Pod를 먼저 하나 실행한 후 정상 상태가 확인되면 기존 Pod를 차례로 교체한다.</p>
<hr>
<h2 id="6-애플리케이션-상태-검사">6. 애플리케이션 상태 검사</h2>
<p>Kubernetes가 단순히 프로세스가 실행 중인지만 확인하면 부족하다. 프로세스는 살아 있지만 실제 HTTP 요청을 처리하지 못할 수도 있기 때문이다.</p>
<p>이를 위해 세 가지 Probe를 구성했다.</p>
<h3 id="startupprobe">startupProbe</h3>
<p>애플리케이션 초기 시작이 완료될 때까지 기다린다.</p>
<pre><code class="language-yaml">startupProbe:
  httpGet:
    path: /
    port: http
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 36</code></pre>
<p>최대 약 180초 동안 애플리케이션 시작을 기다린다.</p>
<p>Dify, PostgreSQL 등 외부 서비스 연결 때문에 초기 실행이 늦어져도 곧바로 Pod가 재시작되는 문제를 막을 수 있다.</p>
<h3 id="readinessprobe">readinessProbe</h3>
<p>현재 Pod가 사용자 요청을 받을 준비가 됐는지 확인한다.</p>
<pre><code class="language-yaml">readinessProbe:
  httpGet:
    path: /
    port: http
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 6</code></pre>
<p>검사에 실패한 Pod는 Service의 요청 분산 대상에서 제외된다.</p>
<h3 id="livenessprobe">livenessProbe</h3>
<p>애플리케이션이 실행 중에 멈췄는지 확인한다.</p>
<pre><code class="language-yaml">livenessProbe:
  httpGet:
    path: /
    port: http
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 3</code></pre>
<p>연속으로 실패하면 Kubernetes가 컨테이너를 재시작한다.</p>
<hr>
<h2 id="7-cpu와-메모리-제한">7. CPU와 메모리 제한</h2>
<p>HPA가 CPU 사용률을 계산하려면 각 Pod에 CPU 요청량이 설정되어 있어야 한다.</p>
<p>API Pod에는 다음과 같이 설정했다.</p>
<pre><code class="language-yaml">resources:
  requests:
    cpu: 100m
    memory: 256Mi
  limits:
    cpu: 500m
    memory: 512Mi</code></pre>
<ul>
<li>CPU 요청량: <code>100m</code>, 즉 CPU 코어의 10%</li>
<li>CPU 제한: <code>500m</code>, 즉 CPU 코어의 50%</li>
<li>메모리 요청량: 256MiB</li>
<li>메모리 제한: 512MiB</li>
</ul>
<p>Scheduler Pod는 API보다 작은 CPU 요청량을 사용한다.</p>
<pre><code class="language-yaml">resources:
  requests:
    cpu: 50m
    memory: 256Mi
  limits:
    cpu: 300m
    memory: 512Mi</code></pre>
<p><code>requests</code>는 Kubernetes가 Pod를 배치하고 HPA 사용률을 계산하는 기준이며, <code>limits</code>는 컨테이너가 사용할 수 있는 최대 자원이다.</p>
<hr>
<h2 id="8-데이터베이스-커넥션-풀-제한">8. 데이터베이스 커넥션 풀 제한</h2>
<p>Pod 수가 늘어나면 데이터베이스 연결 수도 함께 증가한다.</p>
<p>예를 들어 API Pod 하나가 20개의 DB 연결을 만들고 HPA가 10개까지 확장되면, API 서버만 200개의 연결을 사용할 수 있다.</p>
<p>이를 방지하기 위해 Pod별 연결 수를 제한했다.</p>
<p>API Pod:</p>
<pre><code class="language-yaml">env:
  - name: DATABASE_POOL_SIZE
    value: &quot;4&quot;
  - name: DATABASE_MAX_OVERFLOW
    value: &quot;2&quot;</code></pre>
<p>Scheduler Pod:</p>
<pre><code class="language-yaml">env:
  - name: DATABASE_POOL_SIZE
    value: &quot;3&quot;
  - name: DATABASE_MAX_OVERFLOW
    value: &quot;2&quot;</code></pre>
<p>API Pod 하나가 사용할 수 있는 최대 연결 수는 <code>4 + 2 = 6개</code>다.</p>
<p>HPA가 최대 10개까지 확장되면 API 서버는 최대 약 60개, Scheduler는 약 5개의 연결을 사용한다.</p>
<p>Kubernetes에서 Pod만 확장하고 데이터베이스 연결 수를 고려하지 않으면 오히려 DB가 병목이 될 수 있으므로 반드시 함께 계산해야 한다.</p>
<hr>
<h2 id="9-scheduler-pod의-중복-실행-방지">9. Scheduler Pod의 중복 실행 방지</h2>
<p>Scheduler Deployment는 Replica를 1개로 고정했다.</p>
<pre><code class="language-yaml">spec:
  replicas: 1</code></pre>
<p>배포 중 기존 Scheduler와 새로운 Scheduler가 잠시 동시에 실행되는 것도 막아야 했다.</p>
<p>따라서 API의 RollingUpdate와 달리 <code>Recreate</code> 전략을 사용했다.</p>
<pre><code class="language-yaml">strategy:
  type: Recreate</code></pre>
<p>기존 Scheduler Pod가 종료된 후 새 Scheduler Pod가 실행되므로 스케줄러가 겹칠 가능성이 줄어든다.</p>
<p>다만 실제 운영 환경에서는 Kubernetes Lease나 PostgreSQL Advisory Lock을 추가해 분산 락을 구현하면 더 안전하다. Pod가 비정상적으로 오래 살아 있거나 네트워크가 분리되는 예외 상황까지 방지할 수 있기 때문이다.</p>
<hr>
<h2 id="10-kubernetes-secret으로-환경변수-관리">10. Kubernetes Secret으로 환경변수 관리</h2>
<p>서비스에는 다음과 같은 민감한 값이 필요하다.</p>
<ul>
<li>PostgreSQL 연결 주소</li>
<li>JWT Secret Key</li>
<li>Dify API Key</li>
<li>기사 수집 서버 주소</li>
<li>Dataset ID</li>
</ul>
<p>이 값들을 Kubernetes YAML에 직접 작성하면 Git에 노출될 수 있다.</p>
<p>따라서 <code>.env</code>를 Kubernetes Secret으로 변환했다.</p>
<pre><code class="language-powershell">kubectl create secret generic news-api-env `
  -n news-monitor `
  --from-env-file=.env `
  --dry-run=client `
  -o yaml |
kubectl apply -f -</code></pre>
<p>Deployment에서는 Secret 전체를 환경변수로 가져온다.</p>
<pre><code class="language-yaml">envFrom:
  - secretRef:
      name: news-api-env</code></pre>
<p>중요한 점은 실제 <code>.env</code>와 렌더링된 Secret YAML을 Git에 커밋하면 안 된다는 것이다.</p>
<hr>
<h2 id="11-pod에서-호스트-서비스-연결하기">11. Pod에서 호스트 서비스 연결하기</h2>
<p>로컬 Docker Desktop Kubernetes의 Pod에서 <code>localhost</code>는 Windows 호스트가 아니다.</p>
<p>Pod 내부에서 <code>localhost</code>를 사용하면 해당 Pod 자신을 가리킨다.</p>
<p>PostgreSQL, Dify, 기사 수집 서버가 Windows 호스트에서 실행되고 있다면 다음 주소를 사용해야 한다.</p>
<pre><code class="language-text">host.docker.internal</code></pre>
<p>예를 들면 다음과 같다.</p>
<pre><code class="language-env">DATABASE_URL=postgresql+asyncpg://...@host.docker.internal:5432/...
DIFY_BASE_URL=http://host.docker.internal/...
TRANSNEWS_BASE_URL=http://host.docker.internal:8000</code></pre>
<p>이 설정이 없으면 Pod는 정상적으로 생성돼도 외부 서비스 연결에서 실패할 수 있다.</p>
<hr>
<h2 id="12-service로-api-요청-분산">12. Service로 API 요청 분산</h2>
<p>FastAPI Pod 3개 앞에 ClusterIP Service를 배치했다.</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: news-api
  namespace: news-monitor
spec:
  selector:
    app: news-api
  ports:
    - port: 8001
      targetPort: 8001</code></pre>
<p>Kubernetes 내부에서는 다음 주소로 API를 호출할 수 있다.</p>
<pre><code class="language-text">http://news-api:8001</code></pre>
<p>Service가 <code>app=news-api</code> 라벨을 가진 Pod로 요청을 전달한다.</p>
<p>각 API 응답에는 요청을 처리한 Pod의 이름도 포함하도록 수정했다.</p>
<pre><code class="language-json">{
  &quot;status&quot;: &quot;ok&quot;,
  &quot;pod&quot;: &quot;news-api-76f9fbd7c5-7bjjz&quot;
}</code></pre>
<p>이를 통해 실제로 요청이 여러 Pod에 분산되는지 확인할 수 있었다.</p>
<hr>
<h2 id="13-metrics-server-설치">13. Metrics Server 설치</h2>
<p>HPA가 CPU 사용량을 기준으로 확장하려면 Metrics Server가 필요하다.</p>
<p>Docker Desktop Kubernetes에는 Metrics Server가 기본으로 설치되지 않을 수 있다.</p>
<p>Kubernetes 1.30과 호환되는 Metrics Server 0.7.2를 설치했다.</p>
<pre><code class="language-powershell">kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.7.2/components.yaml</code></pre>
<p>설치 후 다음 명령으로 확인했다.</p>
<pre><code class="language-powershell">kubectl top nodes
kubectl top pods -n news-monitor</code></pre>
<p>처음에는 다음과 같은 인증서 오류가 발생했다.</p>
<pre><code class="language-text">x509: certificate is valid for hostname, not node IP</code></pre>
<p>Docker Desktop 로컬 kubelet 인증서에 노드 IP가 포함되지 않은 것이 원인이었다.</p>
<p>로컬 환경에서만 다음 옵션을 적용했다.</p>
<pre><code class="language-yaml">- --kubelet-insecure-tls</code></pre>
<pre><code class="language-powershell">kubectl patch deployment metrics-server `
  -n kube-system `
  --type=strategic `
  --patch-file k8s/metrics-server-patch.yaml</code></pre>
<p>이 옵션은 인증서 검증을 완화하므로 실제 운영 클러스터에서는 사용하면 안 된다. 운영 환경에서는 정상적인 kubelet 인증서를 구성해야 한다.</p>
<hr>
<h2 id="14-docker-desktop-kubernetes-실행-오류-해결">14. Docker Desktop Kubernetes 실행 오류 해결</h2>
<p>처음 Kubernetes를 활성화했을 때 kubelet이 시작되지 않았다.</p>
<p>주요 오류는 다음과 같았다.</p>
<pre><code class="language-text">invalid configuration:
cgroup [&quot;kubepods&quot;] has some missing paths:
/sys/fs/cgroup/cpu/kubepods</code></pre>
<p>확인 결과 환경은 다음 상태였다.</p>
<ul>
<li>오래된 WSL 런타임</li>
<li>Linux Kernel 5.15</li>
<li>Docker cgroup v1</li>
</ul>
<p>Kubernetes 1.30 환경과 현재 Docker Desktop 구성에서 cgroup v1이 정상적으로 연결되지 않은 것이 원인이었다.</p>
<p>WSL을 업데이트했다.</p>
<pre><code class="language-powershell">wsl --update</code></pre>
<p>업데이트 후 환경은 다음과 같이 변경됐다.</p>
<ul>
<li>WSL 2.7.11</li>
<li>Linux Kernel 6.18</li>
<li>Docker cgroup v2</li>
</ul>
<p>확인 명령은 다음과 같다.</p>
<pre><code class="language-powershell">wsl --version
docker info --format &#39;{{.CgroupVersion}}&#39;</code></pre>
<p>결과가 <code>2</code>로 표시된 뒤 Docker Desktop을 다시 시작하자 Kubernetes Node가 정상적으로 <code>Ready</code> 상태가 됐다.</p>
<pre><code class="language-powershell">kubectl get nodes</code></pre>
<pre><code class="language-text">NAME             STATUS   ROLES           VERSION
docker-desktop   Ready    control-plane   v1.30.5</code></pre>
<hr>
<h2 id="15-이미지-빌드-및-배포">15. 이미지 빌드 및 배포</h2>
<p>로컬 Docker Desktop Kubernetes가 사용할 이미지를 빌드했다.</p>
<pre><code class="language-powershell">docker build -t news-monitor-api:local .</code></pre>
<p>Deployment에서는 로컬 이미지를 사용하도록 설정했다.</p>
<pre><code class="language-yaml">image: news-monitor-api:local
imagePullPolicy: IfNotPresent</code></pre>
<p>이 방식은 Docker Desktop 로컬 클러스터에서는 사용할 수 있지만 외부 Kubernetes 클러스터에서는 사용할 수 없다.</p>
<p>운영 클러스터에 배포할 때는 이미지를 Docker Hub, GHCR, ECR 등의 Registry에 Push한 뒤 이미지 주소를 변경해야 한다.</p>
<p>전체 배포 명령은 다음과 같다.</p>
<pre><code class="language-powershell">kubectl apply -f k8s/namespace.yaml

kubectl create secret generic news-api-env `
  -n news-monitor `
  --from-env-file=.env `
  --dry-run=client `
  -o yaml |
kubectl apply -f -

kubectl apply -f k8s/api-deployment.yaml
kubectl apply -f k8s/api-service.yaml
kubectl apply -f k8s/api-hpa.yaml
kubectl apply -f k8s/scheduler-deployment.yaml</code></pre>
<p>Rollout 상태도 확인했다.</p>
<pre><code class="language-powershell">kubectl rollout status deployment/news-api `
  -n news-monitor `
  --timeout=180s

kubectl rollout status deployment/news-scheduler `
  -n news-monitor `
  --timeout=180s</code></pre>
<hr>
<h2 id="16-실제-배포-결과">16. 실제 배포 결과</h2>
<p>최종적으로 다음과 같이 실행됐다.</p>
<pre><code class="language-text">news-api         3/3 Ready
news-scheduler   1/1 Ready</code></pre>
<p>모든 Pod의 재시작 횟수는 0이었다.</p>
<p>HPA 상태는 다음과 같았다.</p>
<pre><code class="language-text">TARGETS       MINPODS   MAXPODS   REPLICAS
cpu: 3%/50%   3         10        3</code></pre>
<p>Metrics Server가 수집한 유휴 상태의 사용량은 대략 다음과 같았다.</p>
<table>
<thead>
<tr>
<th>Pod</th>
<th align="right">CPU</th>
<th align="right">메모리</th>
</tr>
</thead>
<tbody><tr>
<td>API Pod 1</td>
<td align="right">4m</td>
<td align="right">147Mi</td>
</tr>
<tr>
<td>API Pod 2</td>
<td align="right">3m</td>
<td align="right">102Mi</td>
</tr>
<tr>
<td>API Pod 3</td>
<td align="right">3m</td>
<td align="right">100Mi</td>
</tr>
<tr>
<td>Scheduler</td>
<td align="right">4m</td>
<td align="right">116Mi</td>
</tr>
</tbody></table>
<hr>
<h2 id="17-service-트래픽-분산-테스트">17. Service 트래픽 분산 테스트</h2>
<p>Service를 통해 90번의 요청을 보냈다.</p>
<p>결과는 다음과 같았다.</p>
<pre><code class="language-json">{
  &quot;news-api-76f9fbd7c5-7bjjz&quot;: 29,
  &quot;news-api-76f9fbd7c5-l8c9c&quot;: 34,
  &quot;news-api-76f9fbd7c5-znhxn&quot;: 27
}</code></pre>
<p>3개의 API Pod가 각각 29건, 34건, 27건을 처리했다.</p>
<p>정확하게 동일한 수는 아니지만 세 Pod에 요청이 고르게 분산되고 있음을 확인할 수 있었다.</p>
<h3 id="주의할-점">주의할 점</h3>
<p>처음에는 다음 명령으로 Service를 로컬에 연결했다.</p>
<pre><code class="language-powershell">kubectl port-forward service/news-api -n news-monitor 8001:8001</code></pre>
<p>그런데 이 방식으로 테스트했을 때 한 Pod만 계속 응답했다.</p>
<p><code>kubectl port-forward service</code>는 Service 전체의 로드밸런싱을 그대로 재현하는 것이 아니라 선택된 하나의 Endpoint에 포트포워딩할 수 있다.</p>
<p>따라서 Service 분산 테스트는 클러스터 내부 Pod에서 ClusterIP Service를 호출하는 방식으로 진행해야 한다.</p>
<hr>
<h2 id="18-hpa-부하-테스트">18. HPA 부하 테스트</h2>
<p>API에 지속적인 요청을 발생시켜 CPU 사용량을 높였다.</p>
<p>총 9,120번의 요청을 처리하는 동안 HPA 상태를 관찰했다.</p>
<p>부하 발생 전:</p>
<pre><code class="language-text">Pod 수: 3
CPU: 8% / 50%</code></pre>
<p>부하 발생 후:</p>
<pre><code class="language-text">Pod 수: 6
CPU: 140% / 50%</code></pre>
<p>CPU 사용률이 목표치인 50%를 초과하자 API Pod가 3개에서 6개로 증가했다.</p>
<p>부하 종료 후:</p>
<pre><code class="language-text">Pod 수: 3
CPU: 3% / 50%</code></pre>
<p>일정 시간이 지나자 다시 최소 Replica인 3개로 축소됐다.</p>
<p>최종 결과는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>처리 요청</td>
<td>9,120건</td>
</tr>
<tr>
<td>기본 API Pod</td>
<td>3개</td>
</tr>
<tr>
<td>부하 시 API Pod</td>
<td>6개</td>
</tr>
<tr>
<td>부하 시 CPU</td>
<td>140%</td>
</tr>
<tr>
<td>CPU 목표</td>
<td>50%</td>
</tr>
<tr>
<td>부하 종료 후</td>
<td>3개로 자동 축소</td>
</tr>
<tr>
<td>Pod 재시작</td>
<td>0회</td>
</tr>
</tbody></table>
<p>이를 통해 HPA의 확장과 축소가 모두 정상적으로 동작하는 것을 확인했다.</p>
<hr>
<h2 id="19-kubernetes-적용-전후-비교">19. Kubernetes 적용 전후 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>적용 전</th>
<th>적용 후</th>
</tr>
</thead>
<tbody><tr>
<td>API 서버 수</td>
<td>1개</td>
<td>기본 3개</td>
</tr>
<tr>
<td>장애 대응</td>
<td>서버 중단 시 서비스 중단</td>
<td>Pod 자동 복구</td>
</tr>
<tr>
<td>트래픽 분산</td>
<td>없음</td>
<td>Service를 통한 분산</td>
</tr>
<tr>
<td>자동 확장</td>
<td>없음</td>
<td>CPU 50% 기준 3~10개</td>
</tr>
<tr>
<td>스케줄러</td>
<td>API와 함께 실행</td>
<td>별도 Pod 1개</td>
</tr>
<tr>
<td>중복 스케줄 실행</td>
<td>다중 서버에서 발생 가능</td>
<td>API에서는 비활성화</td>
</tr>
<tr>
<td>배포 방식</td>
<td>컨테이너 직접 재시작</td>
<td>RollingUpdate</td>
</tr>
<tr>
<td>상태 검사</td>
<td>제한적</td>
<td>startup/readiness/liveness</td>
</tr>
<tr>
<td>자원 제한</td>
<td>없음 또는 고정</td>
<td>CPU·메모리 제한</td>
</tr>
<tr>
<td>환경변수</td>
<td><code>.env</code> 직접 사용</td>
<td>Kubernetes Secret</td>
</tr>
<tr>
<td>모니터링</td>
<td>컨테이너 단위 확인</td>
<td>Metrics Server와 HPA 연동</td>
</tr>
</tbody></table>
<hr>
<h2 id="20-이번-작업에서-얻은-교훈">20. 이번 작업에서 얻은 교훈</h2>
<h3 id="pod를-늘리기-전에-스케줄러부터-분리해야-한다">Pod를 늘리기 전에 스케줄러부터 분리해야 한다</h3>
<p>서버를 여러 개 실행한다고 무조건 안정성이 높아지는 것은 아니다. 스케줄러가 애플리케이션에 포함되어 있다면 기사 수집, AI 분석, 메일 발송이 Pod 수만큼 반복될 수 있다.</p>
<p>API와 백그라운드 작업의 실행 책임을 먼저 분리해야 한다.</p>
<h3 id="hpa에는-metrics-server와-cpu-requests가-필요하다">HPA에는 Metrics Server와 CPU requests가 필요하다</h3>
<p>HPA YAML만 작성한다고 자동 확장이 동작하지 않는다.</p>
<p>다음 조건이 모두 필요하다.</p>
<ul>
<li>Metrics Server 설치</li>
<li>Pod의 CPU <code>requests</code> 설정</li>
<li>정상적인 Metrics API</li>
<li>일정 시간 이상 지속되는 CPU 부하</li>
</ul>
<h3 id="db-연결-수는-replica-수와-함께-계산해야-한다">DB 연결 수는 Replica 수와 함께 계산해야 한다</h3>
<p>Pod 수가 늘어날수록 DB 연결 수도 증가한다. API는 확장됐지만 PostgreSQL의 최대 연결 수를 초과하면 오히려 전체 장애로 이어질 수 있다.</p>
<h3 id="포트포워딩은-service-분산-테스트가-아니다">포트포워딩은 Service 분산 테스트가 아니다</h3>
<p><code>kubectl port-forward service/...</code> 결과만 보고 로드밸런싱이 동작하지 않는다고 판단하면 안 된다. 클러스터 내부에서 Service DNS나 ClusterIP를 호출해 확인해야 한다.</p>
<h3 id="로컬-설정과-운영-설정을-구분해야-한다">로컬 설정과 운영 설정을 구분해야 한다</h3>
<p><code>--kubelet-insecure-tls</code> 같은 옵션은 Docker Desktop 로컬 테스트에는 유용하지만 운영 환경에 그대로 적용하면 안 된다.</p>
<hr>
<h2 id="21-앞으로-개선할-부분">21. 앞으로 개선할 부분</h2>
<p>현재 구성은 로컬 Kubernetes 검증 단계까지 완료된 상태다. 실제 운영을 위해서는 다음 작업이 추가로 필요하다.</p>
<ol>
<li>Docker 이미지를 외부 Registry에 Push</li>
<li>Ingress와 도메인 연결</li>
<li>HTTPS 인증서 적용</li>
<li>Kubernetes Secret 외부 관리</li>
<li>PostgreSQL 최대 연결 수 점검</li>
<li>Scheduler 분산 락 적용</li>
<li>Prometheus와 Grafana 연동</li>
<li>로그 수집 시스템 구성</li>
<li>PodDisruptionBudget 적용</li>
<li>장애 알림 및 배포 자동화</li>
</ol>
<p>특히 스케줄러는 현재 <code>Replica 1 + Recreate</code>로 중복 가능성을 낮췄지만, 운영 환경에서는 DB 기반 분산 락이나 Kubernetes Lease를 추가하는 것이 안전하다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 작업을 통해 FastAPI 뉴스 모니터링 서버를 다음과 같은 구조로 변경했다.</p>
<ul>
<li>API Pod 기본 3개</li>
<li>CPU 사용률에 따라 최대 10개까지 확장</li>
<li>Service를 통한 트래픽 분산</li>
<li>Scheduler Pod 1개 독립 실행</li>
<li>시작·준비·생존 상태 검사</li>
<li>CPU와 메모리 제한</li>
<li>DB 커넥션 풀 제한</li>
<li>Secret 기반 환경변수 관리</li>
<li>Metrics Server 기반 자원 수집</li>
<li>실제 부하 테스트를 통한 HPA 검증</li>
</ul>
<p>단순히 Kubernetes YAML을 작성하는 것에서 끝내지 않고 실제 부하를 발생시켜 <code>3개 → 6개 → 3개</code>로 자동 확장과 축소가 이루어지는 것까지 확인했다.</p>
<p>이번 구성으로 뉴스 수집과 메일 발송 같은 정기 작업은 한 번만 실행하면서, 사용자 API는 트래픽에 따라 독립적으로 확장할 수 있는 기반을 마련했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[결제 안정성 테스트를 자동화하기: GitHub Actions와 Testcontainers로 CI 구축하기]]></title>
            <link>https://velog.io/@hyeondooori_/%EA%B2%B0%EC%A0%9C-%EC%95%88%EC%A0%95%EC%84%B1-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%98%EA%B8%B0-GitHub-Actions%EC%99%80-Testcontainers%EB%A1%9C-CI-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyeondooori_/%EA%B2%B0%EC%A0%9C-%EC%95%88%EC%A0%95%EC%84%B1-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%98%EA%B8%B0-GitHub-Actions%EC%99%80-Testcontainers%EB%A1%9C-CI-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 02 Aug 2026 09:48:29 GMT</pubDate>
            <description><![CDATA[<p>결제 시스템의 안정성을 높이기 위해 지금까지 다음 기능을 구현했다.</p>
<ul>
<li>멱등성 키 기반 중복 결제 방지</li>
<li>비관적 락 기반 동시성 제어</li>
<li>결제 상태 머신</li>
<li>블록체인 거래 대사</li>
<li>대사 모니터링 API</li>
<li>대사 실패 알림과 자동 재시도</li>
<li>Transactional Outbox</li>
<li>실제 MySQL 기반 통합 테스트</li>
</ul>
<p>하지만 테스트가 아무리 잘 작성되어 있어도 개발자가 매번 직접 실행해야 한다면 실수로 검증을 생략할 수 있다.</p>
<p>새로운 코드가 기존 결제 로직을 깨뜨렸는데도 테스트하지 않은 채 병합될 가능성도 있다.</p>
<p>이번 작업에서는 GitHub Actions를 이용해 다음 과정을 자동화했다.</p>
<pre><code class="language-text">Pull Request 생성
→ 단위 테스트 실행
→ 실제 MySQL 통합 테스트 실행
→ 테스트 성공 시 실행 JAR 빌드
→ 테스트 리포트와 JAR 보관</code></pre>
<p>또한 CI 환경에서 전체 통합 테스트를 실행하며 발견한 Testcontainers 데이터베이스 공유 문제도 함께 개선했다.</p>
<hr>
<h2 id="1-ci란-무엇인가">1. CI란 무엇인가?</h2>
<p>CI는 Continuous Integration의 약자로, 우리말로는 지속적 통합이라고 한다.</p>
<p>개발자가 코드를 원격 저장소에 올릴 때마다 빌드와 테스트를 자동으로 실행해 코드가 정상적으로 통합될 수 있는지 확인하는 방식이다.</p>
<p>CI가 없다면 개발자가 다음 명령어를 직접 실행해야 한다.</p>
<pre><code class="language-powershell">.\gradlew.bat test
.\gradlew.bat integrationTest
.\gradlew.bat bootJar</code></pre>
<p>문제는 사람이 매번 이 과정을 빠짐없이 수행하기 어렵다는 것이다.</p>
<p>CI를 적용하면 다음 상황에서 자동으로 검증할 수 있다.</p>
<ul>
<li><code>develop</code> 브랜치에 코드가 Push된 경우</li>
<li><code>develop</code> 브랜치를 대상으로 Pull Request가 생성된 경우</li>
<li>GitHub 화면에서 수동 실행한 경우</li>
</ul>
<pre><code class="language-yaml">on:
  pull_request:
    branches: [develop]

  push:
    branches: [develop]

  workflow_dispatch:</code></pre>
<p><code>workflow_dispatch</code>는 GitHub Actions 화면에서 워크플로를 직접 실행할 수 있게 해주는 설정이다.</p>
<hr>
<h1 id="2-ci-전체-구조">2. CI 전체 구조</h1>
<p>이번에 구성한 CI는 세 개의 Job으로 나누었다.</p>
<p>Job은 GitHub Actions에서 독립적으로 실행되는 작업 단위다.</p>
<pre><code class="language-text">unit-test
integration-test
build</code></pre>
<p>전체 관계는 다음과 같다.</p>
<pre><code class="language-text">             ┌─ 단위 테스트 ────────┐
코드 변경 ───┤                       ├─ 실행 JAR 빌드
             └─ MySQL 통합 테스트 ──┘</code></pre>
<p>단위 테스트와 통합 테스트는 서로 독립적이므로 병렬로 실행된다.</p>
<p>두 테스트가 모두 성공한 경우에만 빌드가 실행된다.</p>
<pre><code class="language-yaml">build:
  needs: [unit-test, integration-test]</code></pre>
<p><code>needs</code>는 현재 Job이 실행되기 전에 성공해야 하는 다른 Job을 지정하는 설정이다.</p>
<p>따라서 단위 테스트 또는 통합 테스트 중 하나라도 실패하면 실행 JAR가 만들어지지 않는다.</p>
<hr>
<h1 id="3-최소-권한-설정">3. 최소 권한 설정</h1>
<p>GitHub Actions는 저장소의 코드와 다양한 리소스에 접근할 수 있다.</p>
<p>필요 이상의 권한을 부여하면 사용하는 외부 Action이나 스크립트에 보안 문제가 생겼을 때 피해 범위가 커질 수 있다.</p>
<p>이번 CI는 코드를 읽고 테스트하는 작업만 수행하므로 저장소 읽기 권한만 부여했다.</p>
<pre><code class="language-yaml">permissions:
  contents: read</code></pre>
<p>이를 최소 권한 원칙이라고 한다.</p>
<p>최소 권한 원칙은 작업에 꼭 필요한 권한만 부여해 사고 발생 시 피해 범위를 줄이는 보안 원칙이다.</p>
<p>이번 워크플로는 다음 작업을 하지 않는다.</p>
<ul>
<li>코드를 수정해서 Push</li>
<li>Pull Request 수정</li>
<li>패키지 배포</li>
<li>Release 생성</li>
</ul>
<p>따라서 쓰기 권한은 필요하지 않았다.</p>
<hr>
<h1 id="4-중복-ci-실행-취소">4. 중복 CI 실행 취소</h1>
<p>같은 Pull Request에 코드를 여러 번 Push하면 이전 코드와 최신 코드에 대한 CI가 동시에 실행될 수 있다.</p>
<p>예를 들어 다음처럼 연속으로 코드를 올렸다고 가정해보자.</p>
<pre><code class="language-text">첫 번째 Push → CI 실행 중
두 번째 Push → 새로운 CI 실행</code></pre>
<p>첫 번째 CI는 이미 이전 버전의 코드를 테스트하고 있으므로 완료 결과의 가치가 낮다.</p>
<p>이를 방지하기 위해 동시 실행 제어를 추가했다.</p>
<pre><code class="language-yaml">concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true</code></pre>
<p><code>concurrency</code>는 동일한 그룹의 워크플로 실행을 제한하는 기능이다.</p>
<p><code>cancel-in-progress: true</code>를 설정하면 같은 브랜치에서 새 CI가 시작됐을 때 기존 CI는 자동으로 취소된다.</p>
<p>이를 통해 불필요한 실행 시간과 GitHub Actions 사용량을 줄일 수 있다.</p>
<hr>
<h1 id="5-jdk와-gradle-환경-구성">5. JDK와 Gradle 환경 구성</h1>
<p>TinyPay 백엔드는 Java 17을 사용하므로 CI에도 동일한 Java 버전을 설정했다.</p>
<pre><code class="language-yaml">- name: Set up JDK 17
  uses: actions/setup-java@v5
  with:
    distribution: temurin
    java-version: &#39;17&#39;</code></pre>
<p>Temurin은 Eclipse Adoptium에서 제공하는 OpenJDK 배포판이다.</p>
<p>로컬과 CI의 Java 버전이 다르면 다음과 같은 문제가 발생할 수 있다.</p>
<ul>
<li>사용하는 Java 문법이 지원되지 않음</li>
<li>컴파일 결과가 달라짐</li>
<li>라이브러리 호환성 문제</li>
<li>테스트는 통과하지만 배포 환경에서 실패</li>
</ul>
<p>따라서 프로젝트와 CI의 Java 버전을 맞췄다.</p>
<hr>
<h2 id="gradle-wrapper-검증">Gradle Wrapper 검증</h2>
<p>프로젝트는 Gradle Wrapper를 사용한다.</p>
<p>Gradle Wrapper는 개발자가 Gradle을 별도로 설치하지 않아도 프로젝트에 지정된 Gradle 버전을 내려받아 실행하는 방식이다.</p>
<pre><code class="language-text">gradlew
gradlew.bat
gradle/wrapper/gradle-wrapper.jar
gradle/wrapper/gradle-wrapper.properties</code></pre>
<p>이 프로젝트에서는 Gradle 9.4.1을 사용한다.</p>
<pre><code class="language-properties">distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip</code></pre>
<p>CI에서는 Wrapper 파일이 알려진 정상 파일인지 검증한다.</p>
<pre><code class="language-yaml">- name: Validate Gradle wrapper
  uses: gradle/actions/wrapper-validation@v6</code></pre>
<p>Gradle Wrapper JAR가 악의적으로 교체되면 CI가 공격자의 코드를 실행할 수도 있다.</p>
<p>Wrapper 검증은 체크섬을 비교해 변조되거나 알려지지 않은 Wrapper JAR를 탐지한다.</p>
<p>체크섬은 파일의 내용을 바탕으로 계산한 고유한 값으로, 파일이 변경됐는지 확인할 때 사용한다.</p>
<hr>
<h2 id="gradle-캐시-적용">Gradle 캐시 적용</h2>
<p>CI는 실행할 때마다 의존성을 처음부터 내려받으면 시간이 오래 걸린다.</p>
<p>이를 줄이기 위해 Gradle 캐시를 적용했다.</p>
<pre><code class="language-yaml">- name: Set up Gradle cache
  uses: gradle/actions/setup-gradle@v6</code></pre>
<p>캐시는 이전 실행에서 내려받은 라이브러리나 빌드 관련 데이터를 보관했다가 다음 실행에서 재사용하는 방식이다.</p>
<p>이를 통해 다음 항목을 반복해서 내려받는 시간을 줄일 수 있다.</p>
<ul>
<li>Spring Boot 라이브러리</li>
<li>MySQL Connector</li>
<li>Testcontainers</li>
<li>Web3j</li>
<li>JWT 라이브러리</li>
<li>테스트 라이브러리</li>
</ul>
<p>공식 Gradle Action은 Gradle Wrapper 검증과 Gradle User Home 캐싱을 지원한다.</p>
<hr>
<h1 id="6-단위-테스트-job">6. 단위 테스트 Job</h1>
<p>단위 테스트는 외부 시스템 없이 개별 서비스와 도메인 로직을 검증한다.</p>
<pre><code class="language-yaml">unit-test:
  name: Unit tests
  runs-on: ubuntu-latest
  timeout-minutes: 15</code></pre>
<p><code>runs-on: ubuntu-latest</code>는 GitHub에서 제공하는 최신 Ubuntu 가상 머신에서 작업을 실행한다는 의미다.</p>
<p>테스트 실행 명령은 다음과 같다.</p>
<pre><code class="language-yaml">- name: Run unit tests
  run: ./gradlew test --stacktrace</code></pre>
<p><code>--stacktrace</code>는 테스트나 빌드가 실패했을 때 예외가 발생한 호출 경로를 자세히 출력하는 옵션이다.</p>
<p>이를 통해 CI 실패 원인을 로그에서 더 쉽게 찾을 수 있다.</p>
<hr>
<h2 id="linux-실행-권한-처리">Linux 실행 권한 처리</h2>
<p>Windows에서는 <code>gradlew.bat</code>을 사용하지만 GitHub Actions의 Ubuntu 환경에서는 <code>gradlew</code>을 실행한다.</p>
<p>Linux에서는 파일에 실행 권한이 필요하다.</p>
<pre><code class="language-yaml">- name: Grant execute permission
  run: chmod +x gradlew</code></pre>
<p>이 과정이 없으면 다음과 같은 오류가 발생할 수 있다.</p>
<pre><code class="language-text">Permission denied</code></pre>
<hr>
<h1 id="7-mysql-통합-테스트-job">7. MySQL 통합 테스트 Job</h1>
<p>결제 동시성과 트랜잭션은 Mock 객체만으로 충분히 검증하기 어렵다.</p>
<p>따라서 통합 테스트 Job에서는 Testcontainers로 실제 MySQL을 실행한다.</p>
<pre><code class="language-yaml">integration-test:
  name: MySQL integration tests
  runs-on: ubuntu-latest
  timeout-minutes: 25</code></pre>
<p>Testcontainers는 테스트가 실행될 때 Docker 컨테이너로 MySQL과 같은 외부 시스템을 생성하는 라이브러리다.</p>
<p>테스트가 끝나면 생성한 컨테이너도 정리한다.</p>
<hr>
<h2 id="docker-환경-확인">Docker 환경 확인</h2>
<p>GitHub에서 제공하는 Ubuntu Runner에는 Docker가 설치되어 있다.</p>
<p>Testcontainers를 실행하기 전에 Docker가 정상적으로 동작하는지 확인한다.</p>
<pre><code class="language-yaml">- name: Verify Docker environment
  run: docker info</code></pre>
<p>이 단계가 실패한다면 MySQL 컨테이너를 실행할 수 없으므로 통합 테스트도 진행하지 않는다.</p>
<p>통합 테스트는 다음 명령어로 실행한다.</p>
<pre><code class="language-yaml">- name: Run MySQL Testcontainers tests
  run: ./gradlew integrationTest --stacktrace</code></pre>
<p>이 과정에서 다음 내용이 검증된다.</p>
<ul>
<li>멱등성 키 고유 제약조건</li>
<li>동시 결제 시 잔액 차감 락</li>
<li>대사 대상의 중복 선점 방지</li>
<li>알림 재시도 대상의 중복 선점 방지</li>
<li>Transactional Outbox 중복 선점 방지</li>
<li>트랜잭션 커밋 이후 처리</li>
<li>롤백 시 Outbox 저장 취소</li>
<li>전체 Spring Context 로딩</li>
</ul>
<hr>
<h1 id="8-ci에서-발견한-통합-테스트-간-충돌">8. CI에서 발견한 통합 테스트 간 충돌</h1>
<p>CI와 동일한 방식으로 모든 통합 테스트를 한 번에 실행하자 기존 테스트 하나가 실패했다.</p>
<pre><code class="language-text">WalletConcurrencyIntegrationTest FAILED
DataIntegrityViolationException
SQLIntegrityConstraintViolationException</code></pre>
<p>각 테스트를 단독으로 실행하면 성공했지만 전체 통합 테스트에서는 실패했다.</p>
<p>이 상황은 CI를 구축하지 않았다면 발견하기 어려운 문제였다.</p>
<hr>
<h2 id="원인-동일한-testcontainers-db-이름">원인: 동일한 Testcontainers DB 이름</h2>
<p>기존 통합 테스트들은 모두 동일한 JDBC URL을 사용하고 있었다.</p>
<pre><code class="language-java">&quot;spring.datasource.url=jdbc:tc:mysql:8.0.36:///tinypay&quot;</code></pre>
<p>테스트 클래스마다 Spring Context는 달랐지만 Testcontainers JDBC URL은 같았다.</p>
<p>그 결과 여러 테스트가 같은 MySQL 환경과 스키마를 공유하며 다음 문제가 발생할 수 있었다.</p>
<ul>
<li>다른 테스트가 저장한 데이터와 고유 키 충돌</li>
<li>한 테스트의 <code>create-drop</code>이 다른 테스트의 스키마에 영향</li>
<li>테스트 실행 순서에 따라 성공과 실패가 달라짐</li>
</ul>
<p>테스트가 서로 영향을 주지 않아야 한다는 테스트 격리 원칙이 깨진 것이다.</p>
<p>테스트 격리는 하나의 테스트가 생성하거나 변경한 데이터가 다른 테스트 결과에 영향을 주지 않도록 분리하는 원칙이다.</p>
<hr>
<h2 id="해결-테스트-클래스별-db-이름-분리">해결: 테스트 클래스별 DB 이름 분리</h2>
<p>각 통합 테스트 클래스에 독립적인 DB 이름을 부여했다.</p>
<pre><code class="language-text">tinypay_wallet_concurrency
tinypay_idempotency
tinypay_reliability
tinypay_chat
tinypay_context</code></pre>
<p>예를 들어 지갑 동시성 테스트는 다음 DB를 사용한다.</p>
<pre><code class="language-java">&quot;spring.datasource.url=&quot;
        + &quot;jdbc:tc:mysql:8.0.36:///tinypay_wallet_concurrency&quot;</code></pre>
<p>결제 멱등성 테스트는 별도의 DB를 사용한다.</p>
<pre><code class="language-java">&quot;spring.datasource.url=&quot;
        + &quot;jdbc:tc:mysql:8.0.36:///tinypay_idempotency&quot;</code></pre>
<p>결제 신뢰성 테스트도 분리했다.</p>
<pre><code class="language-java">&quot;spring.datasource.url=&quot;
        + &quot;jdbc:tc:mysql:8.0.36:///tinypay_reliability&quot;</code></pre>
<p>DB 환경을 분리한 뒤 전체 통합 테스트를 다시 실행했다.</p>
<pre><code class="language-text">13 tests completed
BUILD SUCCESSFUL</code></pre>
<p>이제 특정 테스트의 데이터나 스키마 변경이 다른 테스트에 영향을 주지 않는다.</p>
<p>다만 클래스마다 독립적인 MySQL 컨테이너를 준비하므로 테스트 시간은 길어졌다.</p>
<p>이 프로젝트에서는 실행 속도보다 결제 테스트의 독립성과 신뢰성이 더 중요하다고 판단했다.</p>
<hr>
<h1 id="9-테스트-실패-리포트-보관">9. 테스트 실패 리포트 보관</h1>
<p>CI가 실패하면 로그만으로 원인을 찾기 어려울 수 있다.</p>
<p>Gradle은 테스트 결과를 HTML과 XML로 생성한다.</p>
<p>단위 테스트 결과:</p>
<pre><code class="language-text">build/reports/tests/test/
build/test-results/test/</code></pre>
<p>통합 테스트 결과:</p>
<pre><code class="language-text">build/reports/tests/integrationTest/
build/test-results/integrationTest/</code></pre>
<p>GitHub Actions에서는 이를 Artifact로 업로드한다.</p>
<p>Artifact는 워크플로 실행 중 생성된 파일을 GitHub에 보관하고 내려받을 수 있게 하는 기능이다.</p>
<pre><code class="language-yaml">- name: Upload integration-test reports
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: integration-test-reports
    path: |
      build/reports/tests/integrationTest/
      build/test-results/integrationTest/
    if-no-files-found: ignore
    retention-days: 7</code></pre>
<p><code>if: always()</code>를 적용했기 때문에 테스트가 실패해도 리포트 업로드 단계는 실행된다.</p>
<p>리포트는 7일 동안 보관하도록 설정했다.</p>
<p>이를 통해 CI가 실패했을 때 GitHub Actions 화면에서 리포트를 내려받아 다음 내용을 확인할 수 있다.</p>
<ul>
<li>실패한 테스트 이름</li>
<li>예외 메시지</li>
<li>Stack Trace</li>
<li>테스트 실행 시간</li>
<li>전체 성공·실패 개수</li>
</ul>
<hr>
<h1 id="10-테스트-성공-후-실행-jar-빌드">10. 테스트 성공 후 실행 JAR 빌드</h1>
<p>단위 테스트와 통합 테스트가 모두 성공하면 Spring Boot 실행 JAR를 만든다.</p>
<pre><code class="language-yaml">build:
  name: Build executable JAR
  needs: [unit-test, integration-test]</code></pre>
<p>빌드 명령은 다음과 같다.</p>
<pre><code class="language-yaml">- name: Build executable JAR
  run: ./gradlew bootJar -x test</code></pre>
<p>테스트는 앞선 Job에서 이미 검증했으므로 JAR 생성 단계에서는 다시 실행하지 않는다.</p>
<p>생성 위치는 다음과 같다.</p>
<pre><code class="language-text">build/libs/tinypay-backend-0.0.1-SNAPSHOT.jar</code></pre>
<p>로컬 검증에서 생성된 JAR 크기는 약 120MB였다.</p>
<p>생성된 JAR도 Artifact로 업로드한다.</p>
<pre><code class="language-yaml">- name: Upload executable JAR
  uses: actions/upload-artifact@v4
  with:
    name: tinypay-backend-jar
    path: build/libs/*.jar
    if-no-files-found: error
    retention-days: 7</code></pre>
<p><code>if-no-files-found: error</code>를 적용했기 때문에 JAR가 만들어지지 않으면 CI도 실패한다.</p>
<p>즉 테스트만 성공하고 실제 실행 파일을 생성하지 못하는 상황도 탐지할 수 있다.</p>
<hr>
<h1 id="11-readme-상태-배지-추가">11. README 상태 배지 추가</h1>
<p>CI 실행 상태를 저장소 첫 화면에서 확인할 수 있도록 README에 상태 배지를 추가했다.</p>
<pre><code class="language-markdown">[![Payment Reliability CI](
https://github.com/hyeonseo1202/tinypay-backend/actions/workflows/ci.yml/badge.svg
)](
https://github.com/hyeonseo1202/tinypay-backend/actions/workflows/ci.yml
)</code></pre>
<p>배지는 최근 워크플로 상태에 따라 다음과 같이 표시된다.</p>
<pre><code class="language-text">passing
failing</code></pre>
<p>배지를 클릭하면 해당 GitHub Actions 워크플로 화면으로 이동한다.</p>
<p>포트폴리오를 확인하는 사람도 저장소 첫 화면에서 테스트 자동화 여부와 최근 실행 상태를 확인할 수 있다.</p>
<hr>
<h1 id="12-최종-github-actions-설정">12. 최종 GitHub Actions 설정</h1>
<p>최종 CI의 핵심 구조는 다음과 같다.</p>
<pre><code class="language-yaml">name: Payment Reliability CI

on:
  pull_request:
    branches: [develop]

  push:
    branches: [develop]

  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  unit-test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6
      - uses: gradle/actions/wrapper-validation@v6
      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: &#39;17&#39;
      - uses: gradle/actions/setup-gradle@v6
      - run: chmod +x gradlew
      - run: ./gradlew test --stacktrace

  integration-test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6
      - uses: gradle/actions/wrapper-validation@v6
      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: &#39;17&#39;
      - uses: gradle/actions/setup-gradle@v6
      - run: chmod +x gradlew
      - run: docker info
      - run: ./gradlew integrationTest --stacktrace

  build:
    needs: [unit-test, integration-test]
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: &#39;17&#39;
      - uses: gradle/actions/setup-gradle@v6
      - run: chmod +x gradlew
      - run: ./gradlew bootJar -x test</code></pre>
<p>실제 워크플로에는 테스트 리포트와 JAR 업로드 단계도 포함되어 있다.</p>
<hr>
<h1 id="13-검증-결과">13. 검증 결과</h1>
<p>워크플로 YAML 문법을 확인했다.</p>
<pre><code class="language-text">YAML OK</code></pre>
<p>단위 테스트를 실행했다.</p>
<pre><code class="language-text">BUILD SUCCESSFUL</code></pre>
<p>전체 MySQL 통합 테스트를 실행했다.</p>
<pre><code class="language-text">13 tests completed
BUILD SUCCESSFUL</code></pre>
<p>결제 신뢰성 통합 테스트도 독립 DB에서 다시 실행했다.</p>
<pre><code class="language-text">8 tests completed
BUILD SUCCESSFUL</code></pre>
<p>Spring Boot 실행 JAR 생성도 확인했다.</p>
<pre><code class="language-text">tinypay-backend-0.0.1-SNAPSHOT.jar
약 120MB</code></pre>
<hr>
<h1 id="14-이번-작업에서-배운-점">14. 이번 작업에서 배운 점</h1>
<p>CI는 단순히 기존 테스트를 자동으로 실행하는 도구가 아니었다.</p>
<p>전체 테스트를 동일한 환경에서 반복 실행하면서 로컬에서 개별 테스트만 실행할 때 발견하지 못했던 문제를 드러내기도 했다.</p>
<p>이번 작업에서는 CI 구축 과정에서 다음 문제를 발견했다.</p>
<pre><code class="language-text">각 테스트는 단독 실행 시 성공
→ 전체 통합 테스트 실행 시 고유 제약조건 충돌
→ Testcontainers DB 공유가 원인
→ 테스트 클래스별 독립 DB로 분리</code></pre>
<p>결제 테스트에서 중요한 것은 테스트 개수만이 아니었다.</p>
<p>다음 조건도 함께 충족해야 했다.</p>
<ul>
<li>실행 순서와 관계없이 성공</li>
<li>다른 테스트가 저장한 데이터에 영향받지 않음</li>
<li>로컬과 CI에서 같은 결과가 나옴</li>
<li>실제 MySQL의 락과 제약조건을 검증</li>
<li>실패 시 원인을 추적할 수 있는 리포트 제공</li>
</ul>
<p>최종적으로 코드 변경부터 실행 파일 생성까지 다음 과정이 자동화됐다.</p>
<pre><code class="language-text">코드 Push 또는 Pull Request
→ 단위 테스트
→ 실제 MySQL 통합 테스트
→ 실행 JAR 빌드
→ 결과와 리포트 보관</code></pre>
<p>이제 결제 안정성 기능은 구현과 테스트에 그치지 않고, 이후 코드 변경에서도 계속 검증되는 구조를 갖추게 됐다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[결제 로직을 어떻게 믿을 수 있을까? Testcontainers로 동시성과 트랜잭션 검증하기]]></title>
            <link>https://velog.io/@hyeondooori_/%EA%B2%B0%EC%A0%9C-%EB%A1%9C%EC%A7%81%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%AF%BF%EC%9D%84-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C-Testcontainers%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1%EA%B3%BC-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyeondooori_/%EA%B2%B0%EC%A0%9C-%EB%A1%9C%EC%A7%81%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%AF%BF%EC%9D%84-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C-Testcontainers%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1%EA%B3%BC-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 02 Aug 2026 08:51:03 GMT</pubDate>
            <description><![CDATA[<p>결제 시스템에 멱등성, DB 락, 상태 머신, 대사 배치를 구현했다고 해서 곧바로 안전하다고 말할 수 있을까?</p>
<p>코드만 보면 올바르게 동작하는 것처럼 보여도 실제 데이터베이스에서는 예상과 다르게 동작할 수 있다.</p>
<p>특히 다음 기능은 단위 테스트만으로 충분히 검증하기 어렵다.</p>
<ul>
<li>비관적 락을 이용한 동시성 제어</li>
<li>데이터베이스의 고유 제약조건</li>
<li>트랜잭션 커밋과 롤백</li>
<li>커밋 이후 이벤트 발행</li>
</ul>
<p>이번 작업에서는 Testcontainers로 실제 MySQL을 실행하고, 결제 대사와 알림 기능이 장애 및 동시 요청 상황에서도 안전하게 동작하는지 검증했다.</p>
<hr>
<h2 id="1-왜-단위-테스트만으로-부족했을까">1. 왜 단위 테스트만으로 부족했을까?</h2>
<p>단위 테스트는 특정 클래스의 로직을 빠르게 검증하기에 적합하다.</p>
<p>예를 들어 Mockito를 사용하면 Repository가 특정 값을 반환한다고 가정한 뒤 서비스의 상태 변경을 검증할 수 있다.</p>
<pre><code class="language-java">when(paymentLogRepository.findById(1L))
        .thenReturn(Optional.of(payment));

service.reconcileOne(1L);

assertThat(payment.getReconciliationStatus())
        .isEqualTo(ReconciliationStatus.MATCHED);</code></pre>
<p>하지만 이런 테스트는 실제 데이터베이스의 동작까지 검증하지 않는다.</p>
<p>다음과 같은 부분은 Mock 객체로 재현하기 어렵다.</p>
<ul>
<li><code>SELECT ... FOR UPDATE</code>가 실제로 다른 트랜잭션을 대기시키는가?</li>
<li>먼저 처리한 트랜잭션이 커밋된 후 두 번째 트랜잭션은 변경된 데이터를 조회하는가?</li>
<li>고유 제약조건이 중복 데이터를 실제로 차단하는가?</li>
<li>트랜잭션이 롤백되면 이벤트도 취소되는가?</li>
</ul>
<p>따라서 실제 MySQL을 사용하는 통합 테스트가 필요했다.</p>
<hr>
<h2 id="2-testcontainers란">2. Testcontainers란?</h2>
<p>Testcontainers는 테스트를 실행할 때 Docker 컨테이너로 데이터베이스나 외부 시스템을 실행하는 라이브러리다.</p>
<p>이번 테스트에서는 MySQL 8.0.36 컨테이너를 사용했다.</p>
<pre><code class="language-java">@DataJpaTest(properties = {
    &quot;spring.datasource.url=jdbc:tc:mysql:8.0.36:///tinypay&quot;,
    &quot;spring.datasource.driver-class-name=&quot;
            + &quot;org.testcontainers.jdbc.ContainerDatabaseDriver&quot;,
    &quot;spring.jpa.hibernate.ddl-auto=create-drop&quot;,
    &quot;spring.jpa.show-sql=false&quot;
})</code></pre>
<p>테스트가 시작되면 다음 과정이 자동으로 실행된다.</p>
<pre><code class="language-text">MySQL Docker 컨테이너 실행
→ 테스트용 테이블 생성
→ 통합 테스트 수행
→ 테이블 제거
→ 컨테이너 종료</code></pre>
<p>개발자의 로컬 MySQL 설정에 의존하지 않고 테스트마다 독립적인 데이터베이스 환경을 만들 수 있다는 장점이 있다.</p>
<hr>
<h2 id="3-테스트-환경-구성">3. 테스트 환경 구성</h2>
<p>통합 테스트는 일반 단위 테스트와 분리했다.</p>
<pre><code class="language-java">@Tag(&quot;integration&quot;)</code></pre>
<p>Gradle의 일반 <code>test</code> 작업에서는 <code>integration</code> 태그를 제외한다.</p>
<pre><code class="language-groovy">tasks.named(&#39;test&#39;) {
    useJUnitPlatform {
        excludeTags &#39;integration&#39;
    }
}</code></pre>
<p>별도의 <code>integrationTest</code> 작업에서는 통합 테스트만 실행한다.</p>
<pre><code class="language-groovy">tasks.register(&#39;integrationTest&#39;, Test) {
    description = &#39;Runs integration tests against a real MySQL Testcontainer.&#39;
    group = &#39;verification&#39;

    testClassesDirs = sourceSets.test.output.classesDirs
    classpath = sourceSets.test.runtimeClasspath

    useJUnitPlatform {
        includeTags &#39;integration&#39;
    }

    shouldRunAfter test
}</code></pre>
<p>실행 명령어는 다음과 같다.</p>
<pre><code class="language-powershell">.\gradlew.bat integrationTest</code></pre>
<p>이렇게 분리하면 빠른 단위 테스트는 자주 실행하고, Docker가 필요한 통합 테스트는 필요한 시점에 별도로 실행할 수 있다.</p>
<hr>
<h1 id="테스트-1-동시에-접근해도-같은-결제는-한-번만-선점되는가">테스트 1. 동시에 접근해도 같은 결제는 한 번만 선점되는가?</h1>
<p>결제 대사 배치는 처리할 결제를 조회할 때 비관적 락을 사용한다.</p>
<p>비관적 락은 다른 트랜잭션이 같은 데이터를 동시에 수정할 가능성이 높다고 가정하고, 데이터를 먼저 잠그는 방식이다.</p>
<p>Repository에는 다음과 같이 <code>PESSIMISTIC_WRITE</code> 락이 적용되어 있다.</p>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;&quot;&quot;
    SELECT p
    FROM PaymentLog p
    WHERE p.txHash IS NOT NULL
      AND p.paymentStatus IN :paymentStatuses
      AND (
            p.reconciliationStatus IS NULL
            OR p.reconciliationStatus IN :reconciliationStatuses
          )
      AND (
            p.nextReconciliationAt IS NULL
            OR p.nextReconciliationAt &lt;= :now
          )
    ORDER BY p.id
    &quot;&quot;&quot;)
List&lt;PaymentLog&gt; findReconciliationCandidatesForUpdate(
        Collection&lt;PaymentStatus&gt; paymentStatuses,
        Collection&lt;ReconciliationStatus&gt; reconciliationStatuses,
        LocalDateTime now,
        Pageable pageable
);</code></pre>
<p>하지만 코드에 <code>@Lock</code>이 붙어 있다는 사실만으로 동시성 문제가 해결됐다고 단정할 수는 없다.</p>
<p>실제 MySQL에서 두 트랜잭션을 동시에 실행해 확인해야 한다.</p>
<hr>
<h2 id="두-개의-스레드로-동시-요청-재현하기">두 개의 스레드로 동시 요청 재현하기</h2>
<p>두 작업자가 동시에 같은 대사 결제를 조회하는 상황을 만들었다.</p>
<pre><code class="language-java">ExecutorService executor = Executors.newFixedThreadPool(2);</code></pre>
<p><code>ExecutorService</code>는 여러 작업을 별도 스레드에서 실행할 수 있도록 관리하는 도구다.</p>
<p>첫 번째 트랜잭션은 결제를 조회해 락을 획득한 뒤, 테스트 코드가 허용할 때까지 대기한다.</p>
<pre><code class="language-java">Future&lt;?&gt; first = executor.submit(() -&gt;
        transaction.executeWithoutResult(status -&gt; {
            List&lt;PaymentLog&gt; claimed = findCandidates();

            claimed.forEach(PaymentLog::startReconciliation);
            claimedCount.addAndGet(claimed.size());

            firstClaimed.countDown();
            await(releaseFirst);
        })
);</code></pre>
<p>두 번째 트랜잭션도 같은 결제를 조회한다.</p>
<pre><code class="language-java">Future&lt;?&gt; second = executor.submit(() -&gt;
        transaction.executeWithoutResult(status -&gt; {
            List&lt;PaymentLog&gt; claimed = findCandidates();

            claimed.forEach(PaymentLog::startReconciliation);
            claimedCount.addAndGet(claimed.size());
        })
);</code></pre>
<p>두 스레드의 실행 순서는 <code>CountDownLatch</code>로 제어했다.</p>
<p><code>CountDownLatch</code>는 특정 작업이 완료될 때까지 다른 스레드를 대기시키는 동시성 제어 도구다.</p>
<pre><code class="language-text">첫 번째 트랜잭션이 결제 조회 및 락 획득
→ 첫 번째 트랜잭션 대기
→ 두 번째 트랜잭션 조회 시작
→ 첫 번째 트랜잭션 상태 변경 및 커밋
→ 두 번째 트랜잭션 조회 재개</code></pre>
<p>첫 번째 트랜잭션은 결제 상태를 <code>PROCESSING</code>으로 변경한다.</p>
<pre><code class="language-java">claimed.forEach(PaymentLog::startReconciliation);</code></pre>
<p>첫 번째 트랜잭션이 커밋된 뒤 두 번째 트랜잭션은 최신 상태를 확인한다.</p>
<p>해당 결제는 더 이상 <code>PENDING</code>이 아니므로 대사 후보 조회 결과에서 제외되어야 한다.</p>
<p>최종적으로 선점 횟수와 DB 상태를 검증했다.</p>
<pre><code class="language-java">assertThat(claimedCount).hasValue(1);

assertThat(finalStatus)
        .isEqualTo(ReconciliationStatus.PROCESSING);</code></pre>
<p>실제 MySQL에서 두 작업자가 동시에 접근하더라도 같은 결제가 한 번만 선점되는 것을 확인했다.</p>
<hr>
<h1 id="테스트-2-같은-알림이-중복-저장되지-않는가">테스트 2. 같은 알림이 중복 저장되지 않는가?</h1>
<p>대사 알림은 다음 값으로 이벤트 고유 키를 만든다.</p>
<pre><code class="language-text">paymentId:reconciliationStatus:reconciliationAttempt</code></pre>
<p>예를 들면 다음과 같다.</p>
<pre><code class="language-text">1:MISMATCHED:1</code></pre>
<p>동일 결제의 동일 대사 시도에서 같은 실패 상태가 여러 번 전달되더라도 알림은 한 번만 발송되어야 한다.</p>
<p>이를 위해 알림 테이블의 <code>event_key</code>에 고유 제약조건을 설정했다.</p>
<pre><code class="language-java">@Table(
    name = &quot;reconciliation_alert&quot;,
    uniqueConstraints = @UniqueConstraint(
        name = &quot;uk_reconciliation_alert_event_key&quot;,
        columnNames = &quot;event_key&quot;
    )
)</code></pre>
<p>고유 제약조건은 특정 컬럼에 동일한 값이 두 번 저장되지 않도록 데이터베이스가 보장하는 규칙이다.</p>
<hr>
<h2 id="실제-mysql에-중복-데이터-저장하기">실제 MySQL에 중복 데이터 저장하기</h2>
<p>먼저 알림을 저장한다.</p>
<pre><code class="language-java">alertRepository.saveAndFlush(
        ReconciliationAlert.pending(
                &quot;1:MISMATCHED:1&quot;,
                1L,
                ReconciliationStatus.MISMATCHED,
                1
        )
);</code></pre>
<p>그다음 동일한 이벤트 키를 다시 저장한다.</p>
<pre><code class="language-java">assertThatThrownBy(() -&gt;
        alertRepository.saveAndFlush(
                ReconciliationAlert.pending(
                        &quot;1:MISMATCHED:1&quot;,
                        1L,
                        ReconciliationStatus.MISMATCHED,
                        1
                )
        )
).isInstanceOf(DataIntegrityViolationException.class);</code></pre>
<p><code>DataIntegrityViolationException</code>은 고유 제약조건이나 외래 키처럼 데이터베이스 무결성 규칙을 위반했을 때 Spring이 발생시키는 예외다.</p>
<p>서비스에서 중복 여부를 먼저 조회하더라도 동시 요청 사이에는 경쟁 조건이 발생할 수 있다.</p>
<p>경쟁 조건은 여러 작업의 실행 순서에 따라 결과가 달라지는 문제다.</p>
<p>예를 들어 다음 순서가 가능하다.</p>
<pre><code class="language-text">작업 A: 기존 알림 없음 확인
작업 B: 기존 알림 없음 확인
작업 A: 알림 저장
작업 B: 같은 알림 저장</code></pre>
<p>따라서 애플리케이션의 중복 확인뿐 아니라 DB 고유 제약조건을 마지막 방어선으로 사용해야 한다.</p>
<hr>
<h1 id="테스트-3-알림-이벤트가-커밋-이후에만-전달되는가">테스트 3. 알림 이벤트가 커밋 이후에만 전달되는가?</h1>
<p>결제 대사 결과와 알림 발송 시점도 중요하다.</p>
<p>알림을 너무 일찍 발송하면 다음 문제가 발생할 수 있다.</p>
<pre><code class="language-text">대사 결과를 MISMATCHED로 변경
→ 알림 발송
→ DB 트랜잭션 실패 및 롤백</code></pre>
<p>이 경우 DB에는 실패 결과가 저장되지 않았지만 운영자는 실패 알림을 받게 된다.</p>
<p>이를 방지하기 위해 알림 리스너에 다음 설정을 적용했다.</p>
<pre><code class="language-java">@TransactionalEventListener(
        phase = TransactionPhase.AFTER_COMMIT
)</code></pre>
<p><code>TransactionalEventListener</code>는 트랜잭션의 특정 시점에 맞춰 이벤트를 처리하는 Spring 기능이다.</p>
<p><code>AFTER_COMMIT</code>은 트랜잭션이 정상적으로 커밋된 이후에만 리스너를 실행한다는 의미다.</p>
<hr>
<h2 id="커밋-전에는-이벤트가-전달되지-않는지-확인">커밋 전에는 이벤트가 전달되지 않는지 확인</h2>
<p>테스트에서 실제 트랜잭션을 시작하고 이벤트를 발행했다.</p>
<pre><code class="language-java">transaction.executeWithoutResult(status -&gt; {
    eventPublisher.publishEvent(event);

    assertThat(recordingAlertListener.events())
            .isEmpty();
});</code></pre>
<p>트랜잭션 내부에서는 이벤트가 리스너에 전달되지 않아야 한다.</p>
<p>트랜잭션이 종료되어 커밋된 후에는 이벤트가 기록되어야 한다.</p>
<pre><code class="language-java">assertThat(recordingAlertListener.events())
        .containsExactly(event);</code></pre>
<p>이를 통해 알림 이벤트가 결제 데이터보다 먼저 외부로 전달되지 않는다는 것을 확인했다.</p>
<hr>
<h1 id="테스트-4-트랜잭션이-롤백되면-알림도-취소되는가">테스트 4. 트랜잭션이 롤백되면 알림도 취소되는가?</h1>
<p>다음으로 대사 트랜잭션이 롤백되는 상황을 만들었다.</p>
<p>롤백은 트랜잭션 안에서 수행한 데이터 변경을 취소하고 이전 상태로 되돌리는 동작이다.</p>
<pre><code class="language-java">transaction.executeWithoutResult(status -&gt; {
    eventPublisher.publishEvent(alertEvent());
    status.setRollbackOnly();
});</code></pre>
<p><code>setRollbackOnly()</code>를 호출하면 해당 트랜잭션은 커밋하지 않고 롤백된다.</p>
<p>트랜잭션이 종료된 후 이벤트가 전달되지 않았는지 검증했다.</p>
<pre><code class="language-java">assertThat(recordingAlertListener.events())
        .isEmpty();</code></pre>
<p>결과적으로 결제 대사 결과가 DB에 반영되지 않은 상황에서는 운영 알림도 전송되지 않는다는 것을 확인했다.</p>
<hr>
<h2 id="4가지-테스트가-검증하는-범위">4가지 테스트가 검증하는 범위</h2>
<p>이번 통합 테스트가 검증하는 범위를 정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>테스트</th>
<th>검증 내용</th>
</tr>
</thead>
<tbody><tr>
<td>동시 대사 선점</td>
<td>같은 결제가 여러 작업자에게 중복 처리되지 않음</td>
</tr>
<tr>
<td>알림 고유 키</td>
<td>같은 실패 알림이 DB에 중복 저장되지 않음</td>
</tr>
<tr>
<td>커밋 이후 이벤트</td>
<td>결제 상태가 확정된 후에만 알림 처리</td>
</tr>
<tr>
<td>롤백 이벤트 폐기</td>
<td>저장되지 않은 결제 결과에 대한 잘못된 알림 방지</td>
</tr>
</tbody></table>
<p>단순히 메서드의 반환값을 확인하는 것이 아니라 실제 MySQL의 락, 고유 제약조건, 트랜잭션 동작을 검증했다.</p>
<hr>
<h2 id="테스트-결과">테스트 결과</h2>
<p>단위 테스트와 MySQL 통합 테스트를 각각 실행했다.</p>
<pre><code class="language-powershell">.\gradlew.bat test</code></pre>
<pre><code class="language-text">BUILD SUCCESSFUL</code></pre>
<p>통합 테스트는 다음 명령어로 실행했다.</p>
<pre><code class="language-powershell">.\gradlew.bat integrationTest `
  --tests &quot;com.tinypay.finance.repository.PaymentReconciliationReliabilityIntegrationTest&quot;</code></pre>
<p>결과는 다음과 같다.</p>
<pre><code class="language-text">MySQL 통합 테스트 4개 통과
BUILD SUCCESSFUL</code></pre>
<hr>
<h2 id="테스트-실행-중-만난-문제">테스트 실행 중 만난 문제</h2>
<p>첫 번째 통합 테스트 실행에서는 다음과 같은 오류가 발생했다.</p>
<pre><code class="language-text">Could not find a valid Docker environment</code></pre>
<p>Testcontainers 코드의 문제가 아니라 Docker Desktop이 실행되지 않아 MySQL 컨테이너를 생성할 수 없었던 것이 원인이었다.</p>
<p>Docker Desktop을 실행한 뒤 다시 테스트하자 MySQL 컨테이너가 정상적으로 생성됐다.</p>
<p>첫 실행은 다음 이미지를 내려받아야 하므로 시간이 오래 걸릴 수 있다.</p>
<pre><code class="language-text">mysql:8.0.36
testcontainers/ryuk</code></pre>
<p><code>Ryuk</code>은 Testcontainers가 테스트 종료 후 컨테이너와 네트워크 같은 자원을 정리하기 위해 사용하는 보조 컨테이너다.</p>
<p>이미지를 한 번 내려받은 이후에는 테스트 준비 시간이 줄어든다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 작업을 통해 “비관적 락을 적용했다”에서 끝나지 않고 실제 MySQL에서 락이 의도대로 작동한다는 것을 검증했다.</p>
<p>또한 애플리케이션 로직에만 의존하지 않고 DB 고유 제약조건을 통해 중복 알림을 한 번 더 차단했다.</p>
<p>결제 상태와 알림 사이의 일관성도 실제 트랜잭션을 이용해 검증했다.</p>
<pre><code class="language-text">결제 대사 트랜잭션 성공
→ DB 커밋
→ 알림 이벤트 전달</code></pre>
<pre><code class="language-text">결제 대사 트랜잭션 실패
→ DB 롤백
→ 알림 이벤트 폐기</code></pre>
<p>결제 시스템에서는 기능을 구현하는 것만큼 실패 상황과 실행 순서를 테스트하는 것이 중요하다.</p>
<p>특히 락과 트랜잭션은 코드만 보고 안전성을 판단하기 어렵기 때문에 실제 운영 DB와 동일한 종류의 데이터베이스에서 검증해야 한다는 점을 배울 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[결제 대사 결과를 어떻게 운영할까? 모니터링 API와 수동 재처리 기능 구현]]></title>
            <link>https://velog.io/@hyeondooori_/%EA%B2%B0%EC%A0%9C-%EB%8C%80%EC%82%AC-%EA%B2%B0%EA%B3%BC%EB%A5%BC-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%9A%B4%EC%98%81%ED%95%A0%EA%B9%8C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-API%EC%99%80-%EC%88%98%EB%8F%99-%EC%9E%AC%EC%B2%98%EB%A6%AC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@hyeondooori_/%EA%B2%B0%EC%A0%9C-%EB%8C%80%EC%82%AC-%EA%B2%B0%EA%B3%BC%EB%A5%BC-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%9A%B4%EC%98%81%ED%95%A0%EA%B9%8C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-API%EC%99%80-%EC%88%98%EB%8F%99-%EC%9E%AC%EC%B2%98%EB%A6%AC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Sun, 02 Aug 2026 08:24:04 GMT</pubDate>
            <description><![CDATA[<p>이전 작업에서는 서버의 결제 기록과 블록체인 거래 내역을 비교하는 결제 대사 기능을 구현했다.</p>
<p>하지만 대사 기능이 자동으로 실행된다는 것만으로는 운영 환경에서 충분하지 않았다.</p>
<p>예를 들어 다음과 같은 질문에 답할 방법이 필요했다.</p>
<ul>
<li>현재 대기 중인 대사는 몇 건인가?</li>
<li>결제 기록과 블록체인 거래가 일치하지 않는 건은 무엇인가?</li>
<li>RPC 장애로 재시도 횟수를 모두 소진한 결제가 있는가?</li>
<li>특정 결제가 어떤 검증 과정을 거쳤는가?</li>
<li>자동 재시도에 실패한 결제를 운영자가 다시 처리할 수 있는가?</li>
</ul>
<p>이번 작업에서는 이 문제를 해결하기 위해 결제 대사 모니터링 API, 수동 재처리 기능, Prometheus 메트릭을 구현했다.</p>
<hr>
<h2 id="1-대사-기능만으로는-부족했던-이유">1. 대사 기능만으로는 부족했던 이유</h2>
<p>결제 대사란 서버 DB에 저장된 결제 기록과 실제 결제 시스템의 거래 내역을 비교해 일치 여부를 확인하는 작업이다.</p>
<p>TinyPay에서는 서버의 <code>PaymentLog</code>와 블록체인의 트랜잭션 정보를 비교한다.</p>
<p>기존 대사 배치는 결제를 다음과 같은 상태로 관리한다.</p>
<pre><code class="language-text">PENDING
→ PROCESSING
→ MATCHED
→ MISMATCHED
→ RETRY_REQUIRED
→ RETRY_EXHAUSTED</code></pre>
<p>각 상태의 의미는 다음과 같다.</p>
<ul>
<li><code>PENDING</code>: 대사 처리 대기</li>
<li><code>PROCESSING</code>: 대사 진행 중</li>
<li><code>MATCHED</code>: 서버 기록과 블록체인 거래가 일치</li>
<li><code>MISMATCHED</code>: 금액이나 수신자 등의 정보가 불일치</li>
<li><code>RETRY_REQUIRED</code>: 외부 시스템 오류로 재시도 필요</li>
<li><code>RETRY_EXHAUSTED</code>: 최대 재시도 횟수 초과</li>
</ul>
<p>이 상태들은 DB에 저장되고 있었지만 운영자가 전체 현황을 한눈에 확인하거나 실패 건을 다시 처리할 방법은 없었다.</p>
<p>그래서 대사 결과를 조회하고 조치할 수 있는 별도의 운영 기능을 추가했다.</p>
<hr>
<h2 id="2-구현-목표">2. 구현 목표</h2>
<p>이번 작업의 목표는 크게 네 가지였다.</p>
<h3 id="대사-상태-요약">대사 상태 요약</h3>
<p>현재 상태별 결제 건수를 조회한다.</p>
<pre><code class="language-json">{
  &quot;pending&quot;: 3,
  &quot;processing&quot;: 1,
  &quot;matched&quot;: 120,
  &quot;mismatched&quot;: 2,
  &quot;retryRequired&quot;: 4,
  &quot;retryExhausted&quot;: 1
}</code></pre>
<h3 id="이상-결제-목록-조회">이상 결제 목록 조회</h3>
<p><code>MISMATCHED</code>, <code>RETRY_EXHAUSTED</code>처럼 운영자의 확인이 필요한 결제만 조회한다.</p>
<h3 id="검증-이력-추적">검증 이력 추적</h3>
<p>특정 결제가 언제 어떤 이유로 검증에 실패했는지 조회한다.</p>
<h3 id="수동-재처리">수동 재처리</h3>
<p>자동 재시도를 모두 소진했거나 거래 정보가 불일치한 결제를 운영자가 다시 대기 상태로 전환한다.</p>
<hr>
<h2 id="3-상태별-건수-조회-기능">3. 상태별 건수 조회 기능</h2>
<p>먼저 <code>PaymentLogRepository</code>에 상태별 건수를 조회하는 메서드를 추가했다.</p>
<pre><code class="language-java">long countByReconciliationStatus(ReconciliationStatus status);

long countByReconciliationStatusIsNull();</code></pre>
<p>기존 데이터 중에는 대사 상태 컬럼이 추가되기 전에 저장되어 <code>null</code>인 데이터가 존재할 수 있다.</p>
<p>이 데이터는 아직 대사하지 않은 결제이므로 <code>PENDING</code>으로 취급했다.</p>
<pre><code class="language-java">public ReconciliationSummaryResponse getSummary() {
    return new ReconciliationSummaryResponse(
            paymentLogRepository.countByReconciliationStatus(
                    ReconciliationStatus.PENDING
            ) + paymentLogRepository.countByReconciliationStatusIsNull(),

            paymentLogRepository.countByReconciliationStatus(
                    ReconciliationStatus.PROCESSING
            ),

            paymentLogRepository.countByReconciliationStatus(
                    ReconciliationStatus.MATCHED
            ),

            paymentLogRepository.countByReconciliationStatus(
                    ReconciliationStatus.MISMATCHED
            ),

            paymentLogRepository.countByReconciliationStatus(
                    ReconciliationStatus.RETRY_REQUIRED
            ),

            paymentLogRepository.countByReconciliationStatus(
                    ReconciliationStatus.RETRY_EXHAUSTED
            )
    );
}</code></pre>
<p>이를 통해 운영 화면이나 모니터링 도구에서 현재 대사 상태를 빠르게 파악할 수 있게 됐다.</p>
<hr>
<h2 id="4-이상-결제-목록-조회">4. 이상 결제 목록 조회</h2>
<p>운영자가 가장 먼저 확인해야 하는 대상은 다음 두 상태다.</p>
<pre><code class="language-text">MISMATCHED
RETRY_EXHAUSTED</code></pre>
<ul>
<li><code>MISMATCHED</code>는 서버 기록과 실제 블록체인 거래가 다르다는 의미다.</li>
<li><code>RETRY_EXHAUSTED</code>는 외부 장애 등으로 자동 재시도 횟수를 모두 소진했다는 의미다.</li>
</ul>
<p>두 상태를 기본 알림 대상으로 설정했다.</p>
<pre><code class="language-java">private static final List&lt;ReconciliationStatus&gt; DEFAULT_ALERT_STATUSES =
        List.of(
                ReconciliationStatus.MISMATCHED,
                ReconciliationStatus.RETRY_EXHAUSTED
        );</code></pre>
<p>조회 결과가 많아질 수 있으므로 페이지네이션도 적용했다.</p>
<p>페이지네이션은 데이터를 한 번에 모두 가져오지 않고 일정한 크기로 나누어 조회하는 방식이다.</p>
<pre><code class="language-java">Page&lt;PaymentLog&gt; findByReconciliationStatusInOrderByIdDesc(
        Collection&lt;ReconciliationStatus&gt; statuses,
        Pageable pageable
);</code></pre>
<p>사용자가 지나치게 큰 <code>size</code> 값을 보내는 것도 방지했다.</p>
<pre><code class="language-java">private static final int MAX_PAGE_SIZE = 100;</code></pre>
<pre><code class="language-java">PageRequest.of(
        Math.max(page, 0),
        Math.min(Math.max(size, 1), MAX_PAGE_SIZE)
);</code></pre>
<p>페이지 번호가 음수이면 <code>0</code>으로 보정하고, 페이지 크기는 최소 1건에서 최대 100건으로 제한했다.</p>
<hr>
<h2 id="5-결제별-검증-이력-조회">5. 결제별 검증 이력 조회</h2>
<p>하나의 결제는 외부 RPC 장애나 거래 반영 지연 등으로 여러 번 검증될 수 있다.</p>
<p>RPC는 서버가 블록체인 노드에 거래 정보를 요청할 때 사용하는 통신 방식이다.</p>
<p>각 검증 결과는 <code>TxVerificationLog</code>에 저장되어 있으므로 결제 ID를 기준으로 최신순 조회 메서드를 추가했다.</p>
<pre><code class="language-java">List&lt;TxVerificationLog&gt; findByPayment_IdOrderByIdDesc(Long paymentId);</code></pre>
<p>응답에는 다음 정보가 포함된다.</p>
<ul>
<li>검증 결과</li>
<li>실패 단계</li>
<li>예상 결제 금액</li>
<li>실제 결제 금액</li>
<li>예상 수신자</li>
<li>실제 수신자</li>
<li>상세 실패 원인</li>
<li>검증 시각</li>
</ul>
<pre><code class="language-java">new ReconciliationHistoryResponse.VerificationItem(
        log.getId(),
        log.getVerificationStatus().name(),
        log.getFailedAtStep(),
        log.getExpectedAmount(),
        log.getActualAmount(),
        log.getExpectedReceiver(),
        log.getActualReceiver(),
        log.getDetail(),
        log.getCreatedAt()
);</code></pre>
<p>이를 통해 운영자는 단순히 “실패했다”는 결과만 보는 것이 아니라 어느 검증 단계에서 어떤 값이 달랐는지 추적할 수 있다.</p>
<hr>
<h2 id="6-수동-재처리-기능">6. 수동 재처리 기능</h2>
<p>자동 재시도가 끝났다고 해서 결제를 계속 실패 상태로 방치할 수는 없다.</p>
<p>일시적인 블록체인 노드 장애였다면 운영자가 다시 대사를 요청할 수 있어야 한다.</p>
<p>이를 위해 <code>PaymentLog</code>에 수동 재처리 상태 전환 메서드를 추가했다.</p>
<pre><code class="language-java">public void requestManualReconciliation() {
    if (reconciliationStatus != ReconciliationStatus.MISMATCHED
            &amp;&amp; reconciliationStatus != ReconciliationStatus.RETRY_EXHAUSTED
            &amp;&amp; reconciliationStatus != ReconciliationStatus.RETRY_REQUIRED) {
        throw new IllegalStateException(
                &quot;수동 재시도를 요청할 수 없는 대사 상태입니다: &quot;
                        + reconciliationStatus
        );
    }

    this.reconciliationStatus = ReconciliationStatus.PENDING;
    this.reconciliationAttempts = 0;
    this.reconciliationStartedAt = null;
    this.nextReconciliationAt = null;
    this.reconciliationError = null;
}</code></pre>
<p>모든 상태에서 수동 재시도를 허용하지는 않았다.</p>
<p>다음 상태만 재처리할 수 있다.</p>
<pre><code class="language-text">MISMATCHED
RETRY_REQUIRED
RETRY_EXHAUSTED</code></pre>
<p>이미 정상적으로 일치한 <code>MATCHED</code> 결제나 현재 처리 중인 <code>PROCESSING</code> 결제를 다시 대기 상태로 변경하면 중복 처리 또는 상태 충돌이 발생할 수 있기 때문이다.</p>
<p>서비스에서는 잘못된 상태 전이를 API 예외로 변환했다.</p>
<pre><code class="language-java">@Transactional
public ReconciliationRetryResponse requestRetry(Long paymentId) {
    PaymentLog payment = paymentLogRepository.findById(paymentId)
            .orElseThrow(() -&gt;
                    new CustomException(ErrorType.PAYMENT_NOT_FOUND)
            );

    try {
        payment.requestManualReconciliation();
    } catch (IllegalStateException e) {
        throw new CustomException(
                ErrorType.INVALID_RECONCILIATION_STATUS,
                e.getMessage()
        );
    }

    metrics.recordManualRetry();

    return new ReconciliationRetryResponse(
            paymentId,
            payment.getReconciliationStatus().name()
    );
}</code></pre>
<p>트랜잭션 안에서 조회한 엔티티의 상태를 변경했기 때문에 JPA의 변경 감지로 수정 내용이 DB에 반영된다.</p>
<p>변경 감지는 트랜잭션 안에서 조회한 엔티티의 필드가 바뀌었는지 JPA가 확인하고, 별도의 <code>save()</code> 호출 없이 업데이트 쿼리를 실행하는 기능이다.</p>
<hr>
<h2 id="7-운영-api-보호">7. 운영 API 보호</h2>
<p>대사 결과에는 결제 금액, 트랜잭션 해시, 지갑 주소 등 민감한 정보가 포함될 수 있다.</p>
<p>또한 수동 재시도 API는 실제 시스템의 상태를 변경한다.</p>
<p>따라서 일반 사용자 API와 동일하게 공개해서는 안 된다.</p>
<p>이번에는 별도의 운영 키를 요청 헤더로 받도록 구현했다.</p>
<pre><code class="language-http">X-Ops-Key: 운영용 비밀 키</code></pre>
<p>서버 환경변수에는 다음과 같이 운영 키를 설정한다.</p>
<pre><code class="language-env">OPS_API_KEY=충분히-긴-임의의-비밀키</code></pre>
<p>설정 파일에서는 환경변수를 읽는다.</p>
<pre><code class="language-yaml">ops:
  api-key: ${OPS_API_KEY:}</code></pre>
<p>운영 키가 설정되지 않은 경우에도 접근을 허용하지 않도록 기본 동작을 차단 방식으로 설계했다.</p>
<pre><code class="language-java">public void authorize(String providedOpsKey) {
    if (!StringUtils.hasText(expectedOpsKey)
            || !StringUtils.hasText(providedOpsKey)
            || !MessageDigest.isEqual(
                    expectedOpsKey.getBytes(StandardCharsets.UTF_8),
                    providedOpsKey.getBytes(StandardCharsets.UTF_8)
            )) {
        throw new CustomException(ErrorType.OPS_ACCESS_FORBIDDEN);
    }
}</code></pre>
<p>문자열 비교에는 <code>MessageDigest.isEqual()</code>을 사용했다.</p>
<p>이는 비교 도중 값이 다른 위치를 기준으로 즉시 종료하는 일반 문자열 비교보다 타이밍 공격에 대한 노출을 줄이기 위한 선택이다.</p>
<p>타이밍 공격은 값 비교에 걸리는 시간 차이를 여러 번 측정해 비밀값을 추측하는 공격 방식이다.</p>
<p>운영 환경에서는 운영 키뿐만 아니라 기존 JWT 인증도 함께 적용되므로 두 인증 조건을 모두 통과해야 한다.</p>
<hr>
<h2 id="8-운영-api-구성">8. 운영 API 구성</h2>
<p>최종적으로 다음 네 개의 API를 추가했다.</p>
<h3 id="대사-상태-요약-1">대사 상태 요약</h3>
<pre><code class="language-http">GET /api/admin/reconciliation/summary</code></pre>
<h3 id="이상-결제-목록">이상 결제 목록</h3>
<pre><code class="language-http">GET /api/admin/reconciliation/alerts</code></pre>
<p>특정 상태만 조회할 수도 있다.</p>
<pre><code class="language-http">GET /api/admin/reconciliation/alerts?statuses=MISMATCHED&amp;statuses=RETRY_EXHAUSTED&amp;page=0&amp;size=20</code></pre>
<h3 id="결제-검증-이력">결제 검증 이력</h3>
<pre><code class="language-http">GET /api/admin/reconciliation/{paymentId}/history</code></pre>
<h3 id="수동-재시도">수동 재시도</h3>
<pre><code class="language-http">POST /api/admin/reconciliation/{paymentId}/retry</code></pre>
<p>컨트롤러에서는 모든 요청을 처리하기 전에 운영 키를 검사한다.</p>
<pre><code class="language-java">@PostMapping(&quot;/{paymentId}/retry&quot;)
public ApiResponse&lt;ReconciliationRetryResponse&gt; retry(
        @RequestHeader(
                value = &quot;X-Ops-Key&quot;,
                required = false
        ) String opsKey,
        @PathVariable Long paymentId
) {
    opsAuthorizer.authorize(opsKey);

    return ApiResponse.success(
            SuccessType.PROCESS_SUCCESS,
            monitoringService.requestRetry(paymentId)
    );
}</code></pre>
<p>헤더를 필수로 선언하지 않은 이유는 헤더가 없을 때 Spring의 기본 <code>400 Bad Request</code> 대신 우리가 정의한 운영 권한 오류를 반환하기 위해서다.</p>
<hr>
<h2 id="9-prometheus-메트릭-추가">9. Prometheus 메트릭 추가</h2>
<p>API를 직접 호출하지 않아도 Grafana에서 대사 상태를 확인할 수 있도록 Prometheus 메트릭도 추가했다.</p>
<p>Prometheus는 서버의 상태와 처리 횟수 같은 숫자 데이터를 수집하는 모니터링 도구다.</p>
<p>Grafana는 Prometheus가 수집한 데이터를 그래프와 대시보드로 시각화하는 도구다.</p>
<h3 id="상태별-현재-건수">상태별 현재 건수</h3>
<pre><code class="language-text">tinypay_payment_reconciliation_status</code></pre>
<p>상태는 태그로 구분한다.</p>
<pre><code class="language-text">tinypay_payment_reconciliation_status{status=&quot;MATCHED&quot;}
tinypay_payment_reconciliation_status{status=&quot;MISMATCHED&quot;}
tinypay_payment_reconciliation_status{status=&quot;RETRY_EXHAUSTED&quot;}</code></pre>
<p>이 값에는 게이지를 사용했다.</p>
<p>게이지는 현재 시점의 값을 표현하는 메트릭이다. 값이 증가하거나 감소할 수 있으므로 현재 대기 건수나 실패 건수를 나타낼 때 적합하다.</p>
<h3 id="처리-결과-누적-횟수">처리 결과 누적 횟수</h3>
<pre><code class="language-text">tinypay_payment_reconciliation_results_total</code></pre>
<pre><code class="language-text">tinypay_payment_reconciliation_results_total{result=&quot;MATCHED&quot;}
tinypay_payment_reconciliation_results_total{result=&quot;MISMATCHED&quot;}</code></pre>
<p>처리 결과에는 카운터를 사용했다.</p>
<p>카운터는 이벤트 발생 횟수를 누적하는 메트릭으로, 일반적으로 값이 감소하지 않는다.</p>
<h3 id="수동-재처리-요청-횟수">수동 재처리 요청 횟수</h3>
<pre><code class="language-text">tinypay_payment_reconciliation_manual_retry_total</code></pre>
<p>운영자가 수동 재시도를 요청할 때마다 값이 증가한다.</p>
<hr>
<h2 id="10-배치와-메트릭-연결">10. 배치와 메트릭 연결</h2>
<p>대사 배치가 종료된 후 DB의 상태를 다시 집계해 게이지를 갱신하도록 했다.</p>
<pre><code class="language-java">public void reconcilePayments() {
    try {
        List&lt;Long&gt; paymentIds =
                paymentReconciliationService.claimBatch();

        if (paymentIds.isEmpty()) {
            return;
        }

        for (Long paymentId : paymentIds) {
            paymentReconciliationService.reconcileOne(paymentId);
        }
    } finally {
        metrics.refreshStatusGauges();
    }
}</code></pre>
<p><code>finally</code>는 정상 처리 여부와 관계없이 실행되는 영역이다.</p>
<p>배치 중 예외가 발생해도 메트릭 갱신을 시도하도록 <code>finally</code>에서 처리했다.</p>
<p>각 결제의 대사 결과도 별도로 기록한다.</p>
<pre><code class="language-java">if (result.isValid()) {
    payment.markReconciliationMatched();
    metrics.record(ReconciliationStatus.MATCHED);
} else {
    payment.markReconciliationMismatched(result.getDetail());
    metrics.record(ReconciliationStatus.MISMATCHED);
}</code></pre>
<p>이를 통해 다음 두 가지 관점으로 대사 상태를 확인할 수 있다.</p>
<ul>
<li>현재 실패 상태로 남아 있는 결제가 몇 건인지</li>
<li>일정 시간 동안 실패가 몇 번 발생했는지</li>
</ul>
<p>현재 상태와 누적 발생 횟수는 서로 다른 의미이므로 두 메트릭을 분리했다.</p>
<hr>
<h2 id="11-테스트">11. 테스트</h2>
<p>이번 작업에서는 다음 내용을 테스트했다.</p>
<h3 id="운영-키-검증">운영 키 검증</h3>
<ul>
<li>올바른 키는 접근 허용</li>
<li>잘못된 키는 접근 거부</li>
<li>서버에 운영 키가 설정되지 않아도 접근 거부</li>
</ul>
<h3 id="상태-요약">상태 요약</h3>
<ul>
<li>각 대사 상태의 건수가 응답에 올바르게 포함되는지 확인</li>
<li>대사 상태가 <code>null</code>인 기존 데이터가 <code>PENDING</code>에 포함되는지 확인</li>
</ul>
<h3 id="수동-재시도-1">수동 재시도</h3>
<ul>
<li><code>MISMATCHED</code> 결제가 <code>PENDING</code>으로 변경되는지 확인</li>
<li>기존 재시도 횟수가 초기화되는지 확인</li>
<li>정상 상태 결제의 잘못된 재시도 요청을 거부하는지 확인</li>
<li>수동 재시도 메트릭이 기록되는지 확인</li>
</ul>
<p>기존 대사 서비스 테스트에도 메트릭 검증을 추가했다.</p>
<pre><code class="language-java">verify(metrics).record(ReconciliationStatus.MATCHED);
verify(metrics).record(ReconciliationStatus.MISMATCHED);
verify(metrics).record(ReconciliationStatus.RETRY_REQUIRED);
verify(metrics).record(ReconciliationStatus.RETRY_EXHAUSTED);</code></pre>
<p>전체 테스트 결과는 다음과 같다.</p>
<pre><code class="language-text">BUILD SUCCESSFUL</code></pre>
<hr>
<h2 id="12-이번-작업을-통해-개선된-점">12. 이번 작업을 통해 개선된 점</h2>
<p>기존에는 대사 배치가 자동으로 결제를 검증하는 것까지만 가능했다.</p>
<p>이번 작업 이후에는 다음 흐름까지 지원할 수 있게 됐다.</p>
<pre><code class="language-text">자동 대사 실행
→ 상태 및 결과 메트릭 기록
→ 불일치 결제 탐지
→ 검증 이력 확인
→ 운영자의 수동 재처리
→ 다음 배치에서 재검증</code></pre>
<p>특히 실패 상태를 단순히 DB에 남기는 데서 끝내지 않고 조회, 관찰, 원인 추적, 재처리까지 연결했다는 점이 중요했다.</p>
<p>결제 시스템에서는 정상 처리뿐만 아니라 실패를 발견하고 복구하는 운영 구조도 필요하다는 것을 배울 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[PaymentLog와 블록체인 거래를 비교하는 결제 대사 배치 구현하기]]></title>
            <link>https://velog.io/@hyeondooori_/PaymentLog%EC%99%80-%EB%B8%94%EB%A1%9D%EC%B2%B4%EC%9D%B8-%EA%B1%B0%EB%9E%98%EB%A5%BC-%EB%B9%84%EA%B5%90%ED%95%98%EB%8A%94-%EA%B2%B0%EC%A0%9C-%EB%8C%80%EC%82%AC-%EB%B0%B0%EC%B9%98-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyeondooori_/PaymentLog%EC%99%80-%EB%B8%94%EB%A1%9D%EC%B2%B4%EC%9D%B8-%EA%B1%B0%EB%9E%98%EB%A5%BC-%EB%B9%84%EA%B5%90%ED%95%98%EB%8A%94-%EA%B2%B0%EC%A0%9C-%EB%8C%80%EC%82%AC-%EB%B0%B0%EC%B9%98-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 02 Aug 2026 07:59:47 GMT</pubDate>
            <description><![CDATA[<p>앞선 작업에서는 결제 시스템에 다음 기능을 적용했다.</p>
<ul>
<li>멱등성 키를 이용한 중복 결제 방지</li>
<li>비관적 락을 이용한 잔액 동시성 제어</li>
<li>결제 상태 머신과 실패 단계 기록</li>
</ul>
<p>하지만 이런 기능을 적용해도 내부 결제 기록과 블록체인의 실제 거래 결과가 항상 일치한다고 단정할 수는 없다.</p>
<p>예를 들어 다음과 같은 장애가 발생할 수 있다.</p>
<pre><code class="language-text">블록체인 결제 성공
→ 서버 응답 전에 프로세스 종료
→ 내부 DB 상태 갱신 실패</code></pre>
<p>반대 상황도 생각할 수 있다.</p>
<pre><code class="language-text">PaymentLog에는 결제 성공으로 기록
→ 실제 블록체인 거래는 실패</code></pre>
<p>따라서 결제가 끝난 뒤 내부 DB와 외부 거래 내역을 다시 비교하는 대사 작업이 필요하다.</p>
<p>이번 글에서는 TinyPay의 <code>PaymentLog</code>와 블록체인 영수증을 주기적으로 비교하는 대사 배치를 구현한 과정을 정리한다.</p>
<h2 id="대사란">대사란?</h2>
<p>대사(Reconciliation)란 내부 시스템에 저장된 거래 기록과 외부 결제 시스템의 실제 거래 내역을 비교해 불일치를 찾는 작업이다.</p>
<p>일반적인 결제 시스템에서는 다음 데이터를 비교할 수 있다.</p>
<pre><code class="language-text">내부 결제 DB
↕
카드사·은행·PG사 거래 내역</code></pre>
<p>TinyPay에서는 블록체인 결제를 사용하므로 다음 두 데이터를 비교한다.</p>
<pre><code class="language-text">PaymentLog
↕
블록체인 트랜잭션 영수증</code></pre>
<p>대사 과정에서 확인하는 항목은 다음과 같다.</p>
<ul>
<li>트랜잭션이 실제로 존재하는가?</li>
<li>블록체인에서 성공한 거래인가?</li>
<li>공식 토큰 컨트랙트에서 발생한 거래인가?</li>
<li>예상한 판매자 지갑으로 전송됐는가?</li>
<li>실제 전송 금액이 결제 금액과 일치하는가?</li>
</ul>
<h2 id="대사가-필요한-이유">대사가 필요한 이유</h2>
<p>결제 처리에는 DB와 블록체인이라는 서로 다른 시스템이 참여한다.</p>
<p>두 시스템을 하나의 일반적인 DB 트랜잭션으로 묶을 수는 없다.</p>
<p>트랜잭션(Transaction)이란 여러 DB 작업을 하나의 작업 단위로 묶어 모두 성공하거나 모두 취소되도록 만드는 기능이다.</p>
<p>MySQL의 트랜잭션이 성공했다고 해서 블록체인 작업까지 함께 커밋되는 것은 아니다. 반대로 블록체인 거래가 성공했다고 MySQL 데이터가 자동으로 저장되는 것도 아니다.</p>
<p>따라서 다음과 같은 불일치가 생길 수 있다.</p>
<h3 id="블록체인은-성공하고-db는-실패한-경우">블록체인은 성공하고 DB는 실패한 경우</h3>
<pre><code class="language-text">블록체인 전송 성공
→ 서버 강제 종료
→ PaymentLog 갱신 실패</code></pre>
<h3 id="db에는-성공으로-기록했지만-외부-거래가-잘못된-경우">DB에는 성공으로 기록했지만 외부 거래가 잘못된 경우</h3>
<pre><code class="language-text">PaymentLog = COMPLETED
→ 실제 거래의 수신자 또는 금액 불일치</code></pre>
<h3 id="외부-시스템이-일시적으로-응답하지-않는-경우">외부 시스템이 일시적으로 응답하지 않는 경우</h3>
<pre><code class="language-text">블록체인 RPC 요청
→ 네트워크 타임아웃
→ 성공 여부를 즉시 판단할 수 없음</code></pre>
<p>RPC(Remote Procedure Call)란 애플리케이션이 네트워크를 통해 외부 시스템의 기능을 호출하는 방식이다. 이번 프로젝트에서는 블록체인 노드에 거래 영수증을 요청하는 과정이 이에 해당한다.</p>
<p>이러한 상황을 발견하고 운영자가 확인할 수 있게 만드는 것이 대사 배치의 역할이다.</p>
<h2 id="전체-대사-흐름">전체 대사 흐름</h2>
<p>구현한 대사 배치는 다음 순서로 동작한다.</p>
<pre><code class="language-text">대사 대상 결제 조회
→ DB 락으로 대상 선점
→ PROCESSING 상태로 변경
→ 블록체인 영수증 조회
→ 거래 상태·컨트랙트·수신자·금액 검증
→ 일치하면 MATCHED
→ 불일치하면 MISMATCHED
→ RPC 오류면 RETRY_REQUIRED
→ 최대 재시도 초과 시 RETRY_EXHAUSTED</code></pre>
<h2 id="대사-상태-설계">대사 상태 설계</h2>
<p>결제 처리 상태와 대사 처리 상태는 서로 목적이 다르다.</p>
<p>결제 상태는 결제 업무의 진행 단계를 나타낸다.</p>
<pre><code class="language-text">REQUESTED
→ APPROVED
→ PAID
→ VERIFIED
→ COMPLETED</code></pre>
<p>대사 상태는 해당 결제를 블록체인과 비교했는지를 나타낸다.</p>
<pre><code class="language-java">public enum ReconciliationStatus {
    PENDING,
    PROCESSING,
    MATCHED,
    MISMATCHED,
    RETRY_REQUIRED,
    RETRY_EXHAUSTED
}</code></pre>
<p>각 상태의 의미는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>PENDING</code></td>
<td>아직 대사를 실행하지 않음</td>
</tr>
<tr>
<td><code>PROCESSING</code></td>
<td>특정 배치 인스턴스가 처리 중</td>
</tr>
<tr>
<td><code>MATCHED</code></td>
<td>DB와 블록체인 거래가 일치함</td>
</tr>
<tr>
<td><code>MISMATCHED</code></td>
<td>거래 상태·수신자·금액 등이 불일치함</td>
</tr>
<tr>
<td><code>RETRY_REQUIRED</code></td>
<td>외부 오류로 나중에 재시도해야 함</td>
</tr>
<tr>
<td><code>RETRY_EXHAUSTED</code></td>
<td>최대 재시도 횟수를 초과해 수동 확인 필요</td>
</tr>
</tbody></table>
<p>불일치와 외부 오류를 구분한 것이 중요하다.</p>
<pre><code class="language-text">MISMATCHED
→ 블록체인 응답을 확인했고 실제 데이터가 다름

RETRY_REQUIRED
→ RPC 오류 등으로 아직 일치 여부를 판단하지 못함</code></pre>
<p>외부 시스템이 잠시 응답하지 않았다는 이유만으로 결제를 불일치로 판단하면 안 된다.</p>
<h2 id="paymentlog에-대사-정보-추가">PaymentLog에 대사 정보 추가</h2>
<p>대사 상태와 실행 정보를 <code>PaymentLog</code>에 추가했다.</p>
<pre><code class="language-java">@Enumerated(EnumType.STRING)
@Column(name = &quot;reconciliation_status&quot;)
private ReconciliationStatus reconciliationStatus;

@Column(
    name = &quot;reconciliation_attempts&quot;,
    nullable = false
)
private int reconciliationAttempts;

@Column(name = &quot;reconciliation_started_at&quot;)
private LocalDateTime reconciliationStartedAt;

@Column(name = &quot;last_reconciled_at&quot;)
private LocalDateTime lastReconciledAt;

@Column(name = &quot;next_reconciliation_at&quot;)
private LocalDateTime nextReconciliationAt;

@Column(
    name = &quot;reconciliation_error&quot;,
    length = 1000
)
private String reconciliationError;</code></pre>
<p>각 필드는 다음 목적으로 사용한다.</p>
<table>
<thead>
<tr>
<th>필드</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>reconciliationStatus</code></td>
<td>현재 대사 상태</td>
</tr>
<tr>
<td><code>reconciliationAttempts</code></td>
<td>대사 시도 횟수</td>
</tr>
<tr>
<td><code>reconciliationStartedAt</code></td>
<td>현재 대사를 시작한 시각</td>
</tr>
<tr>
<td><code>lastReconciledAt</code></td>
<td>마지막 대사 완료 시각</td>
</tr>
<tr>
<td><code>nextReconciliationAt</code></td>
<td>다음 재시도 가능 시각</td>
</tr>
<tr>
<td><code>reconciliationError</code></td>
<td>마지막 불일치 또는 오류 내용</td>
</tr>
</tbody></table>
<p>기존 데이터에는 대사 상태가 없기 때문에 <code>reconciliationStatus</code>가 <code>null</code>인 결제도 대기 상태로 취급한다.</p>
<h2 id="대사-시작-처리">대사 시작 처리</h2>
<p>대상 결제를 선점하면 <code>PROCESSING</code>으로 변경한다.</p>
<pre><code class="language-java">public void startReconciliation() {
    if (!StringUtils.hasText(txHash)) {
        throw new IllegalStateException(
                &quot;트랜잭션 해시가 없는 결제는 &quot;
                        + &quot;대사할 수 없습니다.&quot;
        );
    }

    this.reconciliationStatus =
            ReconciliationStatus.PROCESSING;

    this.reconciliationAttempts++;
    this.reconciliationStartedAt =
            LocalDateTime.now();

    this.reconciliationError = null;
}</code></pre>
<p>트랜잭션 해시가 없는 결제는 블록체인에서 조회할 수 없기 때문에 대사 대상으로 선점하지 않는다.</p>
<h2 id="일치-결과-기록">일치 결과 기록</h2>
<p>블록체인 거래가 내부 결제 기록과 일치하면 <code>MATCHED</code>로 변경한다.</p>
<pre><code class="language-java">public void markReconciliationMatched() {
    requireReconciliationProcessing();

    this.reconciliationStatus =
            ReconciliationStatus.MATCHED;

    this.lastReconciledAt =
            LocalDateTime.now();

    this.nextReconciliationAt = null;
    this.reconciliationError = null;
}</code></pre>
<p><code>requireReconciliationProcessing()</code>은 현재 상태가 <code>PROCESSING</code>인지 확인한다.</p>
<pre><code class="language-java">private void requireReconciliationProcessing() {
    if (reconciliationStatus
            != ReconciliationStatus.PROCESSING) {
        throw new IllegalStateException(
                &quot;처리 중인 대사만 결과를 &quot;
                        + &quot;기록할 수 있습니다.&quot;
        );
    }
}</code></pre>
<p>선점하지 않은 결제에 결과를 기록하는 실수를 방지하기 위한 검증이다.</p>
<h2 id="불일치-결과-기록">불일치 결과 기록</h2>
<p>거래 상태, 컨트랙트, 수신자 또는 금액이 다르면 <code>MISMATCHED</code>로 변경한다.</p>
<pre><code class="language-java">public void markReconciliationMismatched(
        String detail
) {
    requireReconciliationProcessing();

    this.reconciliationStatus =
            ReconciliationStatus.MISMATCHED;

    this.lastReconciledAt =
            LocalDateTime.now();

    this.nextReconciliationAt = null;
    this.reconciliationError = detail;
}</code></pre>
<p>예를 들어 다음과 같은 내용이 저장될 수 있다.</p>
<pre><code class="language-text">expected=10.5, actual=5</code></pre>
<p>운영자는 이 정보를 이용해 어떤 항목이 불일치했는지 확인할 수 있다.</p>
<h2 id="재시도-상태-기록">재시도 상태 기록</h2>
<p>RPC 타임아웃처럼 결과를 판단할 수 없는 오류는 <code>RETRY_REQUIRED</code>로 처리한다.</p>
<pre><code class="language-java">public void markReconciliationRetry(
        String detail,
        LocalDateTime nextAttemptAt
) {
    requireReconciliationProcessing();

    this.reconciliationStatus =
            ReconciliationStatus.RETRY_REQUIRED;

    this.lastReconciledAt =
            LocalDateTime.now();

    this.nextReconciliationAt =
            nextAttemptAt;

    this.reconciliationError = detail;
}</code></pre>
<p>바로 다시 요청하면 장애가 발생한 외부 시스템에 부하를 더 줄 수 있기 때문에 다음 시도 가능 시각을 저장한다.</p>
<h2 id="최대-재시도-초과-처리">최대 재시도 초과 처리</h2>
<p>계속 실패하는 요청을 무한히 재시도하면 시스템 자원이 낭비될 수 있다.</p>
<p>최대 재시도 횟수를 초과하면 <code>RETRY_EXHAUSTED</code>로 변경한다.</p>
<pre><code class="language-java">public void markReconciliationRetryExhausted(
        String detail
) {
    requireReconciliationProcessing();

    this.reconciliationStatus =
            ReconciliationStatus.RETRY_EXHAUSTED;

    this.lastReconciledAt =
            LocalDateTime.now();

    this.nextReconciliationAt = null;
    this.reconciliationError = detail;
}</code></pre>
<p><code>RETRY_EXHAUSTED</code>는 자동 처리 대상에서 제외하고 운영자가 확인해야 하는 상태다.</p>
<h2 id="최초-검증과-대사용-검증-분리">최초 검증과 대사용 검증 분리</h2>
<p>기존 <code>ReceiptVerifier</code>는 결제를 처음 처리할 때 다음 검증을 수행했다.</p>
<pre><code class="language-text">1. Redis를 통한 영수증 재사용 확인
2. 블록체인 거래 성공 여부
3. 공식 토큰 컨트랙트 확인
4. 수신자 확인
5. 금액 확인</code></pre>
<p>모든 검증을 통과하면 트랜잭션 해시를 Redis에 “사용됨”으로 등록했다.</p>
<pre><code class="language-java">markAsUsed(txHash);</code></pre>
<p>이 구조를 대사 배치에서 그대로 사용하면 문제가 발생한다.</p>
<p>대사는 이미 사용된 정상 영수증을 다시 확인하는 작업이다. 따라서 Redis 재사용 검사에서 정상 거래도 재사용 공격으로 판단된다.</p>
<pre><code class="language-text">최초 결제 검증
→ Redis에 txHash 사용 등록

대사 배치
→ 같은 txHash 재검증
→ 이미 사용된 영수증으로 실패</code></pre>
<p>이를 해결하기 위해 대사용 메서드를 별도로 추가했다.</p>
<pre><code class="language-java">VerificationResult verifyForReconciliation(
        String txHash,
        String expectedReceiver,
        BigInteger expectedAmount
);</code></pre>
<p>대사용 검증에서는 다음 항목만 확인한다.</p>
<pre><code class="language-text">블록체인 거래 성공 여부
→ 공식 토큰 컨트랙트
→ 수신자
→ 결제 금액</code></pre>
<p>Redis 재사용 검사와 사용 등록은 수행하지 않는다.</p>
<pre><code class="language-java">@Override
public VerificationResult
        verifyForReconciliation(
                String txHash,
                String expectedReceiver,
                BigInteger expectedAmount
        ) {
    return verifyOnChain(
            txHash,
            expectedReceiver,
            expectedAmount
    );
}</code></pre>
<p>최초 결제 검증과 사후 대사는 목적이 다르기 때문에 같은 검증 규칙을 무조건 재사용하면 안 된다는 점을 확인할 수 있었다.</p>
<h2 id="대사-대상-조회">대사 대상 조회</h2>
<p>대사 대상은 다음 조건을 만족하는 결제다.</p>
<ul>
<li>트랜잭션 해시가 존재함</li>
<li>대사 가능한 결제 상태임</li>
<li>아직 대사하지 않았거나 재시도 대상임</li>
<li>다음 재시도 시각이 지났음</li>
</ul>
<p>Repository 쿼리는 다음과 같이 구성했다.</p>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;&quot;&quot;
    SELECT p
    FROM PaymentLog p
    WHERE p.txHash IS NOT NULL
      AND p.paymentStatus
            IN :paymentStatuses
      AND (
            p.reconciliationStatus IS NULL
            OR p.reconciliationStatus
                IN :reconciliationStatuses
          )
      AND (
            p.nextReconciliationAt IS NULL
            OR p.nextReconciliationAt &lt;= :now
          )
    ORDER BY p.id
&quot;&quot;&quot;)
List&lt;PaymentLog&gt;
        findReconciliationCandidatesForUpdate(
                Collection&lt;PaymentStatus&gt;
                        paymentStatuses,
                Collection&lt;ReconciliationStatus&gt;
                        reconciliationStatuses,
                LocalDateTime now,
                Pageable pageable
        );</code></pre>
<p><code>Pageable</code>을 사용해 한 번에 처리할 최대 건수를 제한했다.</p>
<p>배치 크기의 기본값은 50건이다.</p>
<h2 id="여러-서버의-중복-배치-방지">여러 서버의 중복 배치 방지</h2>
<p>애플리케이션 서버가 여러 대라면 각 서버에서 같은 스케줄러가 동시에 실행될 수 있다.</p>
<pre><code class="language-text">서버 A: 결제 1 조회
서버 B: 결제 1 조회
서버 A: 결제 1 대사
서버 B: 결제 1 대사</code></pre>
<p>이를 막기 위해 대사 대상 조회에 <code>PESSIMISTIC_WRITE</code>를 적용했다.</p>
<p>비관적 락(Pessimistic Lock)은 다른 작업이 같은 데이터를 수정할 가능성이 높다고 보고 조회 시점부터 DB 행을 잠그는 방식이다.</p>
<p>첫 번째 서버가 결제를 선점하면 같은 트랜잭션 안에서 상태를 <code>PROCESSING</code>으로 변경한다.</p>
<pre><code class="language-java">List&lt;PaymentLog&gt; candidates =
        paymentLogRepository
                .findReconciliationCandidatesForUpdate(
                        paymentStatuses,
                        reconciliationStatuses,
                        now,
                        PageRequest.of(0, batchSize)
                );

candidates.forEach(
        PaymentLog::startReconciliation
);</code></pre>
<p>두 번째 서버는 첫 번째 서버의 트랜잭션이 끝난 뒤 해당 행을 읽는다. 이때 상태가 이미 <code>PROCESSING</code>이므로 대사 대상에서 제외된다.</p>
<pre><code class="language-text">서버 A: DB 락 획득
→ PROCESSING 변경
→ 커밋

서버 B: 락 획득 후 재확인
→ PROCESSING이므로 대상 제외</code></pre>
<p>Redis 기반 분산 락을 별도로 추가하지 않고 DB 행과 상태를 이용해 작업 단위의 중복 처리를 방지했다.</p>
<h2 id="중단된-processing-복구">중단된 PROCESSING 복구</h2>
<p>서버가 결제를 선점한 뒤 종료되면 해당 결제가 계속 <code>PROCESSING</code>으로 남을 수 있다.</p>
<pre><code class="language-text">PENDING
→ PROCESSING
→ 서버 종료</code></pre>
<p>이 상태를 그대로 두면 다시 대사되지 않는다.</p>
<p>이를 해결하기 위해 일정 시간 이상 <code>PROCESSING</code> 상태인 결제를 재시도 대상으로 복구한다.</p>
<pre><code class="language-java">@Modifying
@Query(&quot;&quot;&quot;
    UPDATE PaymentLog p
       SET p.reconciliationStatus
                = :retryStatus,
           p.nextReconciliationAt
                = :now,
           p.reconciliationError
                = :error
     WHERE p.reconciliationStatus
                = :processingStatus
       AND p.reconciliationStartedAt
                &lt; :staleBefore
&quot;&quot;&quot;)
int resetStaleReconciliations(...);</code></pre>
<p>기본 기준은 10분이다.</p>
<pre><code class="language-text">PROCESSING 상태로 10분 이상 유지
→ RETRY_REQUIRED로 복구
→ 다음 배치에서 다시 선점</code></pre>
<h2 id="실제-대사-처리">실제 대사 처리</h2>
<p>선점된 결제 ID를 하나씩 조회해 블록체인 영수증과 비교한다.</p>
<pre><code class="language-java">VerificationResult result =
        receiptVerifier
                .verifyForReconciliation(
                        payment.getTxHash(),
                        payment.getReceiverWalletAddress(),
                        rawAmount
                );</code></pre>
<p>결과가 유효하면 <code>MATCHED</code>로 처리한다.</p>
<pre><code class="language-java">if (result.isValid()) {
    payment.markReconciliationMatched();
}</code></pre>
<p>검증 결과가 명확한 실패라면 <code>MISMATCHED</code>로 처리한다.</p>
<pre><code class="language-java">else {
    payment.markReconciliationMismatched(
            result.getDetail()
    );
}</code></pre>
<h2 id="검증-실패-유형-매핑">검증 실패 유형 매핑</h2>
<p>블록체인 검증 결과는 실패 원인에 따라 다음 상태로 구분한다.</p>
<pre><code class="language-java">private TxVerificationStatus
        toVerificationStatus(
                FailReason reason
        ) {
    return switch (reason) {
        case SUCCESS -&gt;
                TxVerificationStatus.PASSED;

        case REPLAY_ATTACK -&gt;
                TxVerificationStatus.FAILED_REPLAY;

        case TRANSACTION_FAILED -&gt;
                TxVerificationStatus.FAILED_TX;

        case INVALID_CONTRACT -&gt;
                TxVerificationStatus.FAILED_CONTRACT;

        case INVALID_RECIPIENT -&gt;
                TxVerificationStatus.FAILED_RECEIVER;

        case INSUFFICIENT_PAYMENT -&gt;
                TxVerificationStatus.FAILED_AMOUNT;
    };
}</code></pre>
<p>대사에서는 replay 검사를 수행하지 않지만 기존 검증 결과 타입과의 호환성을 위해 해당 매핑은 유지했다.</p>
<h2 id="대사-이력-저장">대사 이력 저장</h2>
<p>현재 대사 상태는 <code>PaymentLog</code>에 저장하고, 개별 검증 결과는 <code>TxVerificationLog</code>에 추가한다.</p>
<pre><code class="language-java">txVerificationLogRepository.save(
    TxVerificationLog.builder()
        .user(payment.getUser())
        .payment(payment)
        .txHash(payment.getTxHash())
        .verificationStatus(
                toVerificationStatus(
                        result.getReason()
                )
        )
        .failedAtStep(
                result.isValid()
                        ? null
                        : result.getFailedStep()
        )
        .expectedAmount(
                payment.getAmount()
        )
        .expectedReceiver(
                payment.getReceiverWalletAddress()
        )
        .tokenAddress(tokenAddress)
        .detail(result.getDetail())
        .blockchainNetwork(
                payment.getBlockchainNetwork()
        )
        .build()
);</code></pre>
<p>두 데이터를 분리한 이유는 역할이 다르기 때문이다.</p>
<pre><code class="language-text">PaymentLog
→ 현재 대사 상태와 재시도 정보

TxVerificationLog
→ 각 대사 실행 결과에 대한 이력</code></pre>
<p>같은 결제가 여러 번 재검증되더라도 각 시도의 확정 결과를 이력으로 남길 수 있다.</p>
<h2 id="rpc-오류-재시도">RPC 오류 재시도</h2>
<p>블록체인 노드가 응답하지 않거나 네트워크 오류가 발생하면 거래 불일치로 판단하지 않는다.</p>
<pre><code class="language-java">catch (Exception e) {
    String detail =
            &quot;대사 중 외부 시스템 오류: &quot;
                    + e.getMessage();

    // 재시도 또는 최대 횟수 초과 처리
}</code></pre>
<p>최대 시도 횟수에 도달하지 않았다면 재시도를 예약한다.</p>
<pre><code class="language-java">Duration retryDelay =
        Duration.ofMinutes(
                retryDelayMinutes
        ).multipliedBy(
                payment.getReconciliationAttempts()
        );

payment.markReconciliationRetry(
        detail,
        LocalDateTime.now().plus(retryDelay)
);</code></pre>
<p>시도 횟수에 따라 대기 시간이 늘어난다.</p>
<pre><code class="language-text">1번째 실패 → 5분 후 재시도
2번째 실패 → 10분 후 재시도
3번째 실패 → 15분 후 재시도</code></pre>
<p>이처럼 재시도 간격을 점차 늘리는 방식을 백오프(Backoff)라고 한다.</p>
<p>이번 구현은 시도 횟수에 비례해 지연 시간을 늘리는 선형 백오프를 사용했다.</p>
<h2 id="최대-재시도-초과">최대 재시도 초과</h2>
<p>기본 최대 시도 횟수는 5회다.</p>
<pre><code class="language-java">if (payment.getReconciliationAttempts()
        &gt;= maxAttempts) {
    payment.markReconciliationRetryExhausted(
            detail
    );
}</code></pre>
<p>5회 이상 외부 오류가 지속되면 자동 재시도를 중단하고 운영자 확인 대상으로 남긴다.</p>
<p>무한 재시도로 인한 불필요한 블록체인 RPC 호출과 DB 부하를 방지할 수 있다.</p>
<h2 id="스케줄러-구현">스케줄러 구현</h2>
<p>Spring의 <code>@Scheduled</code>를 사용해 일정 주기로 대사 배치를 실행한다.</p>
<pre><code class="language-java">@Scheduled(
    fixedDelayString =
        &quot;${payment.reconciliation.fixed-delay-ms:60000}&quot;,
    initialDelayString =
        &quot;${payment.reconciliation.initial-delay-ms:30000}&quot;
)
public void reconcilePayments() {
    List&lt;Long&gt; paymentIds =
            paymentReconciliationService
                    .claimBatch();

    for (Long paymentId : paymentIds) {
        paymentReconciliationService
                .reconcileOne(paymentId);
    }
}</code></pre>
<p><code>fixedDelay</code>는 이전 작업이 끝난 시점부터 다음 실행까지 기다리는 시간이다.</p>
<p>기본 실행 간격은 60초이며 애플리케이션 시작 후 30초 뒤 처음 실행된다.</p>
<h2 id="운영-환경에서만-명시적으로-활성화">운영 환경에서만 명시적으로 활성화</h2>
<p>배치가 개발이나 테스트 환경에서 의도치 않게 실행되지 않도록 조건부로 등록했다.</p>
<pre><code class="language-java">@ConditionalOnProperty(
    name = &quot;payment.reconciliation.enabled&quot;,
    havingValue = &quot;true&quot;
)
public class PaymentReconciliationScheduler {
}</code></pre>
<p>기본값은 비활성화다.</p>
<pre><code class="language-yaml">payment:
  reconciliation:
    enabled: ${PAYMENT_RECONCILIATION_ENABLED:false}</code></pre>
<p>운영 환경에서는 다음 환경변수로 활성화할 수 있다.</p>
<pre><code class="language-env">PAYMENT_RECONCILIATION_ENABLED=true</code></pre>
<p>추가 설정값도 환경변수로 변경할 수 있다.</p>
<pre><code class="language-env">PAYMENT_RECONCILIATION_FIXED_DELAY_MS=60000
PAYMENT_RECONCILIATION_INITIAL_DELAY_MS=30000
PAYMENT_RECONCILIATION_BATCH_SIZE=50
PAYMENT_RECONCILIATION_RETRY_DELAY_MINUTES=5
PAYMENT_RECONCILIATION_STALE_AFTER_MINUTES=10
PAYMENT_RECONCILIATION_MAX_ATTEMPTS=5</code></pre>
<h2 id="단위-테스트">단위 테스트</h2>
<p>대사 서비스에서 다음 상황을 검증했다.</p>
<h3 id="대상-선점">대상 선점</h3>
<pre><code class="language-text">PENDING
→ claimBatch()
→ PROCESSING
→ attempts 증가</code></pre>
<pre><code class="language-java">assertThat(
    payment.getReconciliationStatus()
).isEqualTo(
    ReconciliationStatus.PROCESSING
);

assertThat(
    payment.getReconciliationAttempts()
).isEqualTo(1);</code></pre>
<h3 id="온체인-거래-일치">온체인 거래 일치</h3>
<pre><code class="language-java">when(receiptVerifier
        .verifyForReconciliation(...))
        .thenReturn(
                VerificationResult.success()
        );</code></pre>
<p>결과를 검증했다.</p>
<pre><code class="language-java">assertThat(
    payment.getReconciliationStatus()
).isEqualTo(
    ReconciliationStatus.MATCHED
);</code></pre>
<h3 id="결제-금액-불일치">결제 금액 불일치</h3>
<pre><code class="language-java">VerificationResult.fail(
    FailReason.INSUFFICIENT_PAYMENT,
    &quot;expected=10.5, actual=5&quot;
);</code></pre>
<p>결과는 <code>MISMATCHED</code>로 저장된다.</p>
<pre><code class="language-java">assertThat(
    payment.getReconciliationStatus()
).isEqualTo(
    ReconciliationStatus.MISMATCHED
);</code></pre>
<h3 id="rpc-오류">RPC 오류</h3>
<pre><code class="language-java">thenThrow(
    new RuntimeException(&quot;RPC timeout&quot;)
);</code></pre>
<p>결과는 <code>RETRY_REQUIRED</code>가 되고 다음 실행 시각이 저장된다.</p>
<h3 id="최대-재시도-초과-1">최대 재시도 초과</h3>
<p>최대 횟수에 도달한 상태에서 RPC 오류가 발생하면 <code>RETRY_EXHAUSTED</code>가 되는지 확인했다.</p>
<h2 id="테스트-결과">테스트 결과</h2>
<p>전체 단위 테스트와 실제 MySQL 통합 테스트를 실행했다.</p>
<pre><code class="language-text">전체 단위 테스트: BUILD SUCCESSFUL
MySQL 통합 테스트: BUILD SUCCESSFUL</code></pre>
<p>통합 테스트에서는 변경된 엔티티 스키마와 Repository 쿼리가 실제 MySQL에서 정상적으로 생성되는 것도 확인했다.</p>
<h2 id="자동-보정을-하지-않은-이유">자동 보정을 하지 않은 이유</h2>
<p>대사 결과가 <code>MISMATCHED</code>라고 해서 지갑 잔액이나 결제 상태를 즉시 자동 변경하지 않았다.</p>
<p>금전 데이터의 자동 보정은 잘못 수행될 경우 새로운 장애를 만들 수 있기 때문이다.</p>
<p>예를 들어 블록체인 조회가 잘못된 네트워크나 잘못된 노드를 바라보고 있다면 실제 정상 결제를 취소하는 문제가 발생할 수 있다.</p>
<p>현재 구현은 다음 범위에 집중한다.</p>
<pre><code class="language-text">불일치 탐지
→ 실패 원인 기록
→ 재시도
→ 운영자 확인 대상 분리</code></pre>
<p>불일치가 확인된 결제는 운영자가 온체인 거래와 내부 로그를 확인한 뒤 별도의 복구 작업을 실행하는 것이 안전하다.</p>
<p>자동 복구를 도입하려면 다음 조건이 추가로 필요하다.</p>
<ul>
<li>보정 작업 자체의 멱등성</li>
<li>보정 전 이중 검증</li>
<li>관리자 승인</li>
<li>원장 형태의 잔액 변경 이력</li>
<li>보정 작업 감사 로그</li>
<li>잘못된 보정을 취소하는 역거래</li>
</ul>
<h2 id="현재-구현의-한계">현재 구현의 한계</h2>
<p>현재 <code>VerificationResult</code>는 실패 단계와 상세 문자열을 제공하지만 실제 온체인 수신자와 금액을 구조화된 필드로 반환하지 않는다.</p>
<p>따라서 <code>TxVerificationLog</code>의 다음 필드는 아직 완전히 채우지 못한다.</p>
<pre><code class="language-text">actualReceiver
actualAmount</code></pre>
<p>현재는 다음 정보를 저장한다.</p>
<ul>
<li>예상 수신자</li>
<li>예상 금액</li>
<li>실패 단계</li>
<li>실패 원인</li>
<li>상세 메시지</li>
<li>토큰 주소</li>
<li>블록체인 네트워크</li>
</ul>
<p>향후 <code>VerificationResult</code>에 실제 온체인 값을 추가하면 예상값과 실제값을 데이터 컬럼으로 직접 비교할 수 있다.</p>
<h2 id="마무리">마무리</h2>
<p>이번 작업에서는 내부 결제 기록과 블록체인 거래 내역을 주기적으로 비교하는 대사 배치를 구현했다.</p>
<p>주요 개선 내용은 다음과 같다.</p>
<ul>
<li><code>PaymentLog</code>와 블록체인 영수증 비교</li>
<li>거래 상태·공식 컨트랙트·수신자·금액 검증</li>
<li>최초 결제 검증과 대사용 재검증 분리</li>
<li><code>PENDING → PROCESSING → MATCHED/MISMATCHED</code> 상태 관리</li>
<li>RPC 오류 재시도와 선형 백오프</li>
<li>최대 재시도 초과 시 수동 확인 대상으로 분리</li>
<li>DB 비관적 락 기반 중복 배치 방지</li>
<li>중단된 <code>PROCESSING</code> 작업 자동 복구</li>
<li><code>TxVerificationLog</code>를 통한 대사 이력 저장</li>
<li>환경변수를 이용한 배치 활성화 및 설정 관리</li>
<li>단위 테스트와 MySQL 통합 테스트</li>
</ul>
<p>결제 시스템에서는 요청 시점의 정상 처리만큼 사후 검증도 중요하다.</p>
<p>멱등성과 동시성 제어는 결제 과정에서 발생할 수 있는 문제를 줄이고, 상태 머신은 처리 단계를 표현한다. 대사 배치는 그 이후에도 내부 기록과 실제 외부 거래가 일치하는지 지속적으로 확인하는 안전망 역할을 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[결제 흐름을 상태 머신으로 개선하고 실패 지점 추적하기]]></title>
            <link>https://velog.io/@hyeondooori_/%EA%B2%B0%EC%A0%9C-%ED%9D%90%EB%A6%84%EC%9D%84-%EC%83%81%ED%83%9C-%EB%A8%B8%EC%8B%A0%EC%9C%BC%EB%A1%9C-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B3%A0-%EC%8B%A4%ED%8C%A8-%EC%A7%80%EC%A0%90-%EC%B6%94%EC%A0%81%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyeondooori_/%EA%B2%B0%EC%A0%9C-%ED%9D%90%EB%A6%84%EC%9D%84-%EC%83%81%ED%83%9C-%EB%A8%B8%EC%8B%A0%EC%9C%BC%EB%A1%9C-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B3%A0-%EC%8B%A4%ED%8C%A8-%EC%A7%80%EC%A0%90-%EC%B6%94%EC%A0%81%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 30 Jul 2026 11:20:59 GMT</pubDate>
            <description><![CDATA[<p>이전 결제 로직은 결제 결과를 다음과 같이 단순하게 관리하고 있었다.</p>
<pre><code class="language-java">public enum PaymentStatus {
    PENDING,
    SUCCESS,
    FAILED,
    CANCELLED
}</code></pre>
<p>이 구조는 결제가 성공했는지 실패했는지는 알려주지만, 결제가 어떤 단계를 거쳤고 어느 단계에서 실패했는지는 표현하기 어렵다.</p>
<p>블록체인 결제를 예로 들면 실제 처리 과정에는 여러 단계가 존재한다.</p>
<pre><code class="language-text">결제 요청 생성
→ 사용자 승인
→ 블록체인 전송
→ 블록체인 영수증 검증
→ 잔액 반영
→ 결제 완료</code></pre>
<p>그런데 모든 성공을 <code>SUCCESS</code>, 모든 실패를 <code>FAILED</code>로만 저장하면 다음 질문에 답하기 어렵다.</p>
<ul>
<li>사용자의 결제 승인은 완료됐는가?</li>
<li>블록체인 전송 자체가 실패했는가?</li>
<li>전송은 성공했지만 영수증 검증에서 실패했는가?</li>
<li>트랜잭션 해시는 발급됐는가?</li>
<li>어느 단계까지 재처리해야 하는가?</li>
</ul>
<p>이 문제를 해결하기 위해 결제 흐름을 상태 머신으로 개선했다.</p>
<h2 id="상태-머신이란">상태 머신이란?</h2>
<p>상태 머신(State Machine)이란 시스템이 가질 수 있는 상태와 상태가 변경될 수 있는 규칙을 명확하게 정의하는 방식이다.</p>
<p>예를 들어 결제가 다음 순서로만 진행되어야 한다고 가정한다.</p>
<pre><code class="language-text">REQUESTED
→ APPROVED
→ PAID
→ VERIFIED
→ COMPLETED</code></pre>
<p>현재 상태가 <code>REQUESTED</code>라면 다음 상태는 반드시 <code>APPROVED</code>여야 한다.</p>
<p><code>REQUESTED</code>에서 바로 <code>PAID</code>나 <code>COMPLETED</code>로 변경되는 것은 허용하지 않는다.</p>
<pre><code class="language-text">REQUESTED → APPROVED   허용
REQUESTED → PAID       거부
REQUESTED → COMPLETED  거부</code></pre>
<p>상태 머신을 사용하면 결제 처리 순서를 코드로 강제할 수 있고, 잘못된 상태 변경을 조기에 발견할 수 있다.</p>
<h2 id="기존-구조의-문제">기존 구조의 문제</h2>
<p>기존 결제 로직은 블록체인 전송과 영수증 검증이 모두 끝난 뒤 <code>PaymentLog</code>를 생성했다.</p>
<pre><code class="language-java">PaymentLog paymentLog = PaymentLog.builder()
        .txHash(txHash)
        .paymentStatus(PaymentStatus.SUCCESS)
        .verificationStatus(VerificationStatus.PENDING)
        .build();</code></pre>
<p>이 방식에서는 성공한 결제의 최종 결과만 남는다.</p>
<p>실패하면 별도의 실패 로그를 저장했지만, 상태가 모두 <code>FAILED</code>로 동일했다.</p>
<pre><code class="language-java">.paymentStatus(PaymentStatus.FAILED)
.verificationStatus(VerificationStatus.FAILED)</code></pre>
<p>따라서 블록체인 전송 전에 실패한 것인지, 전송 후 검증 단계에서 실패한 것인지 구분하기 어려웠다.</p>
<h2 id="결제-상태-세분화">결제 상태 세분화</h2>
<p>결제 상태를 다음과 같이 변경했다.</p>
<pre><code class="language-java">public enum PaymentStatus {
    REQUESTED,
    APPROVED,
    PAID,
    VERIFIED,
    COMPLETED,
    FAILED
}</code></pre>
<p>각 상태는 다음 의미를 가진다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>REQUESTED</code></td>
<td>결제 기록이 생성됨</td>
</tr>
<tr>
<td><code>APPROVED</code></td>
<td>사용자·비밀번호·예산·잔액 검증을 통과함</td>
</tr>
<tr>
<td><code>PAID</code></td>
<td>블록체인 전송이 완료되고 트랜잭션 해시를 확보함</td>
</tr>
<tr>
<td><code>VERIFIED</code></td>
<td>블록체인 영수증 검증을 통과함</td>
</tr>
<tr>
<td><code>COMPLETED</code></td>
<td>잔액 차감과 결제 기록 처리가 완료됨</td>
</tr>
<tr>
<td><code>FAILED</code></td>
<td>처리 과정에서 오류가 발생함</td>
</tr>
</tbody></table>
<p>정상적인 결제는 다음 순서로 진행된다.</p>
<pre><code class="language-text">REQUESTED
→ APPROVED
→ PAID
→ VERIFIED
→ COMPLETED</code></pre>
<p>처리 중 오류가 발생하면 각 단계에서 <code>FAILED</code>로 전환될 수 있다.</p>
<pre><code class="language-text">REQUESTED → FAILED
APPROVED  → FAILED
PAID      → FAILED
VERIFIED  → FAILED</code></pre>
<h2 id="상태-변경을-엔티티-내부로-제한">상태 변경을 엔티티 내부로 제한</h2>
<p>서비스 코드가 다음처럼 상태값을 직접 변경하도록 두면 잘못된 상태 전이가 발생하기 쉽다.</p>
<pre><code class="language-java">paymentLog.setPaymentStatus(
        PaymentStatus.COMPLETED
);</code></pre>
<p>그래서 상태 필드의 setter를 제공하지 않고, <code>PaymentLog</code>의 도메인 메서드로만 상태를 변경하도록 했다.</p>
<p>도메인 메서드란 단순히 값을 저장하는 setter가 아니라 객체가 수행하는 업무 행위를 표현하는 메서드다.</p>
<pre><code class="language-java">paymentLog.approve();
paymentLog.markPaid(txHash);
paymentLog.markVerified();
paymentLog.complete();</code></pre>
<p>호출 코드만 보더라도 결제가 어떤 단계로 진행되는지 알 수 있다.</p>
<h2 id="공통-상태-전이-검증">공통 상태 전이 검증</h2>
<p>상태 변경 전 현재 상태가 올바른지 검사하는 공통 메서드를 추가했다.</p>
<pre><code class="language-java">private void transition(
        PaymentStatus expected,
        PaymentStatus next
) {
    if (paymentStatus != expected) {
        throw new IllegalStateException(
                &quot;허용되지 않은 결제 상태 전이입니다: &quot;
                        + paymentStatus
                        + &quot; -&gt; &quot;
                        + next
        );
    }

    this.paymentStatus = next;
}</code></pre>
<p><code>expected</code>는 현재 상태로 기대하는 값이고, <code>next</code>는 변경하려는 다음 상태다.</p>
<p>예를 들어 결제를 승인하는 메서드는 <code>REQUESTED</code> 상태에서만 호출할 수 있다.</p>
<pre><code class="language-java">public void approve() {
    transition(
            PaymentStatus.REQUESTED,
            PaymentStatus.APPROVED
    );

    this.approvedAt = LocalDateTime.now();
}</code></pre>
<p>이미 승인된 결제에서 <code>approve()</code>를 다시 호출하면 다음 전이가 발생한다.</p>
<pre><code class="language-text">APPROVED → APPROVED</code></pre>
<p>이는 허용된 상태 전이가 아니므로 예외가 발생한다.</p>
<h2 id="requested에서-approved로-전환">REQUESTED에서 APPROVED로 전환</h2>
<p>결제 요청이 생성되면 기본 상태는 <code>REQUESTED</code>다.</p>
<pre><code class="language-java">this.paymentStatus =
        paymentStatus == null
                ? PaymentStatus.REQUESTED
                : paymentStatus;</code></pre>
<p>사용자 확인, 비밀번호, 예산 및 잔액 검증을 통과하면 <code>APPROVED</code>로 변경한다.</p>
<pre><code class="language-java">paymentLog.approve();</code></pre>
<p>이때 승인 시각도 함께 기록한다.</p>
<pre><code class="language-java">private LocalDateTime approvedAt;</code></pre>
<h2 id="approved에서-paid로-전환">APPROVED에서 PAID로 전환</h2>
<p>블록체인 전송이 성공해 트랜잭션 해시를 확보하면 <code>PAID</code>로 변경한다.</p>
<p>트랜잭션 해시(Transaction Hash)는 블록체인에 등록된 거래를 식별하는 고유한 값이다.</p>
<pre><code class="language-java">public void markPaid(String txHash) {
    if (!StringUtils.hasText(txHash)) {
        throw new IllegalArgumentException(
                &quot;결제 트랜잭션 해시가 필요합니다.&quot;
        );
    }

    transition(
            PaymentStatus.APPROVED,
            PaymentStatus.PAID
    );

    this.txHash = txHash;
    this.paidAt = LocalDateTime.now();
}</code></pre>
<p>트랜잭션 해시가 비어 있으면 <code>PAID</code> 상태로 전환할 수 없다.</p>
<p>즉, <code>PAID</code> 상태는 단순히 “결제를 시도했다”는 뜻이 아니라 블록체인 전송 결과를 식별할 수 있는 상태라는 의미를 갖는다.</p>
<h2 id="paid에서-verified로-전환">PAID에서 VERIFIED로 전환</h2>
<p>블록체인 전송이 성공했다고 해서 바로 최종 결제 성공으로 처리하면 안 된다.</p>
<p>다음 내용도 검증해야 한다.</p>
<ul>
<li>정상적인 컨트랙트를 통해 실행됐는가?</li>
<li>실제 수신자가 기대한 판매자 지갑인가?</li>
<li>전송 금액이 예상 금액과 일치하는가?</li>
<li>같은 거래가 재사용되지 않았는가?</li>
</ul>
<p>영수증 검증을 통과하면 <code>VERIFIED</code>로 변경한다.</p>
<pre><code class="language-java">public void markVerified() {
    transition(
            PaymentStatus.PAID,
            PaymentStatus.VERIFIED
    );

    this.verificationStatus =
            VerificationStatus.SUCCESS;

    this.verifiedAt = LocalDateTime.now();
}</code></pre>
<p>검증 시각과 별도의 검증 상태도 함께 기록한다.</p>
<h2 id="영수증-검증-결과-누락-문제-발견">영수증 검증 결과 누락 문제 발견</h2>
<p>상태 머신을 적용하며 기존 코드를 검토하던 중 추가 문제를 발견했다.</p>
<p><code>verifyReceipt()</code>의 반환형은 <code>boolean</code>이었지만 기존 코드에서는 결과값을 사용하지 않고 있었다.</p>
<pre><code class="language-java">blockchainService.verifyReceipt(
        txHash,
        receiverWalletAddress,
        rawAmount
);</code></pre>
<p>검증 메서드가 <code>false</code>를 반환해도 예외가 발생하지 않으면 결제가 성공으로 처리될 가능성이 있었다.</p>
<p>이를 다음과 같이 수정했다.</p>
<pre><code class="language-java">boolean receiptVerified =
        blockchainService.verifyReceipt(
                txHash,
                receiverWalletAddress,
                rawAmount
        );

if (!receiptVerified) {
    throw new IllegalStateException(
            &quot;블록체인 영수증 검증에 실패했습니다.&quot;
    );
}</code></pre>
<p>검증 결과가 <code>false</code>이면 결제를 <code>VERIFIED</code>로 변경하지 않는다.</p>
<p>이 시점에서 결제는 이미 블록체인 전송을 완료했으므로 현재 상태는 <code>PAID</code>다. 이후 실패 처리에서는 다음과 같이 기록된다.</p>
<pre><code class="language-text">PAID → FAILED
failedFromStatus = PAID</code></pre>
<h2 id="verified에서-completed로-전환">VERIFIED에서 COMPLETED로 전환</h2>
<p>영수증 검증까지 통과하면 지갑 잔액을 차감한다.</p>
<pre><code class="language-java">wallet.withdraw(estimatedCost);</code></pre>
<p>잔액 반영까지 성공하면 결제 상태를 <code>COMPLETED</code>로 변경한다.</p>
<pre><code class="language-java">public void complete() {
    transition(
            PaymentStatus.VERIFIED,
            PaymentStatus.COMPLETED
    );

    this.completedAt = LocalDateTime.now();
}</code></pre>
<p><code>COMPLETED</code>는 모든 결제 처리가 정상적으로 끝났다는 최종 상태다.</p>
<h2 id="실패-단계와-원인-기록">실패 단계와 원인 기록</h2>
<p>단순히 <code>FAILED</code>만 저장하면 어느 단계에서 실패했는지 알 수 없다.</p>
<p>그래서 다음 정보를 추가했다.</p>
<pre><code class="language-java">private PaymentStatus failedFromStatus;
private String failureReason;
private LocalDateTime failedAt;</code></pre>
<p>실패 처리 메서드는 현재 상태를 <code>failedFromStatus</code>에 저장한 뒤 최종 상태를 <code>FAILED</code>로 변경한다.</p>
<pre><code class="language-java">public void fail(String reason) {
    if (paymentStatus.isSuccessful()
            || paymentStatus == PaymentStatus.FAILED) {
        throw new IllegalStateException(
                &quot;완료되거나 실패한 결제는 &quot;
                        + &quot;실패 상태로 변경할 수 없습니다.&quot;
        );
    }

    this.failedFromStatus = this.paymentStatus;
    this.paymentStatus = PaymentStatus.FAILED;

    this.failureReason =
            StringUtils.hasText(reason)
                    ? reason
                    : &quot;알 수 없는 결제 실패&quot;;

    this.failedAt = LocalDateTime.now();
    this.verificationStatus =
            VerificationStatus.FAILED;
}</code></pre>
<p>예를 들어 블록체인 전송 자체가 실패하면 다음과 같이 저장된다.</p>
<pre><code class="language-text">paymentStatus = FAILED
failedFromStatus = APPROVED
failureReason = 블록체인 결제 실패</code></pre>
<p>블록체인 전송은 성공했지만 영수증 검증에서 실패하면 다음과 같다.</p>
<pre><code class="language-text">paymentStatus = FAILED
failedFromStatus = PAID
failureReason = 블록체인 영수증 검증 실패</code></pre>
<p>같은 <code>FAILED</code> 상태라도 실패 위치를 구분할 수 있다.</p>
<h2 id="종결-상태-보호">종결 상태 보호</h2>
<p><code>COMPLETED</code>와 <code>FAILED</code>는 더 이상 변경할 수 없는 종결 상태로 처리했다.</p>
<p>종결 상태(Terminal State)는 업무 흐름이 끝난 상태로, 일반적인 상태 전이를 더 이상 허용하지 않는 상태다.</p>
<p>완료된 결제를 뒤늦게 실패로 변경하거나 실패한 결제를 바로 완료로 바꾸면 결제 데이터의 신뢰성이 깨질 수 있다.</p>
<pre><code class="language-java">if (paymentStatus.isSuccessful()
        || paymentStatus == PaymentStatus.FAILED) {
    throw new IllegalStateException(...);
}</code></pre>
<p>재처리가 필요하다면 기존 상태를 임의로 바꾸는 대신 별도의 재처리 흐름을 만들어야 한다.</p>
<h2 id="결제-서비스-흐름-변경">결제 서비스 흐름 변경</h2>
<p>결제 승인 서비스에서는 블록체인 호출이 끝난 뒤 성공 로그를 한 번에 만드는 대신 먼저 <code>REQUESTED</code> 상태의 결제 기록을 생성한다.</p>
<pre><code class="language-java">PaymentLog paymentLog =
        PaymentLog.builder()
                .user(aiRequest.getUser())
                .request(aiRequest)
                .wallet(wallet)
                .orderId(orderId)
                .payerWalletAddress(
                        wallet.getWalletAddress()
                )
                .receiverWalletAddress(
                        receiverWalletAddress
                )
                .amount(estimatedCost)
                .executedAt(executedAt)
                .blockchainNetwork(
                        wallet.getBlockchainNetwork()
                )
                .build();

paymentLogRepository.save(paymentLog);</code></pre>
<p>이후 처리 단계에 맞춰 상태를 변경한다.</p>
<pre><code class="language-java">paymentLog.approve();

String txHash =
        blockchainService.transferUsdc(...);

paymentLog.markPaid(txHash);

boolean receiptVerified =
        blockchainService.verifyReceipt(...);

if (!receiptVerified) {
    throw new IllegalStateException(
            &quot;블록체인 영수증 검증에 실패했습니다.&quot;
    );
}

paymentLog.markVerified();

wallet.withdraw(estimatedCost);

paymentLog.complete();</code></pre>
<p>코드 흐름과 상태 전이가 일치하기 때문에 현재 결제가 어떤 단계인지 이해하기 쉬워졌다.</p>
<h2 id="실패-상태를-롤백하지-않도록-처리">실패 상태를 롤백하지 않도록 처리</h2>
<p>트랜잭션 안에서 예외가 발생하면 일반적으로 DB 변경사항이 롤백된다.</p>
<p>롤백(Rollback)이란 트랜잭션 도중 적용한 DB 변경을 모두 취소하고 시작 전 상태로 되돌리는 것이다.</p>
<p>하지만 결제 실패 기록까지 롤백되면 어떤 단계에서 실패했는지 남지 않는다.</p>
<p>블록체인 처리 중 발생한 예외를 <code>FAILED</code>로 기록한 뒤 <code>CustomException</code>을 반환할 수 있도록 결제 트랜잭션에 다음 정책을 적용했다.</p>
<pre><code class="language-java">@Transactional(
    noRollbackFor = CustomException.class
)
public PaymentApproveResponse paymentApprove(...) {
    // 결제 처리
}</code></pre>
<p>블록체인 처리에서 오류가 발생하면 다음과 같이 실패 상태를 기록한다.</p>
<pre><code class="language-java">String failureReason =
        &quot;블록체인 결제 실패: &quot;
                + e.getMessage();

paymentLog.fail(failureReason);
aiRequest.fail(failureReason);

throw new CustomException(
        ErrorType.INTERNAL_SERVER_ERROR
);</code></pre>
<p>클라이언트에는 오류 응답을 보내지만 <code>PaymentLog</code>의 실패 단계와 원인은 DB에 남는다.</p>
<p>단, <code>noRollbackFor</code>를 넓게 사용하면 의도하지 않은 변경까지 커밋될 수 있으므로 주의해야 한다. 이번 로직에서는 결제 데이터 변경 전 발생하는 검증 예외와 블록체인 처리 후 기록해야 하는 실패 예외의 범위를 확인하고 적용했다.</p>
<p>장기적으로는 실패 기록 전용 트랜잭션이나 결제 오케스트레이션 분리를 통해 경계를 더 명확히 만드는 것이 좋다.</p>
<h2 id="기존-success-데이터-호환">기존 SUCCESS 데이터 호환</h2>
<p>상태 머신 도입 전 결제 데이터는 성공 상태를 <code>SUCCESS</code>로 저장하고 있었다.</p>
<p>이를 enum에서 바로 제거하면 기존 DB의 <code>SUCCESS</code> 값을 Java enum으로 변환하지 못해 조회 오류가 발생할 수 있다.</p>
<p>그래서 <code>SUCCESS</code>를 레거시 호환 상태로 남겼다.</p>
<p>레거시(Legacy)란 현재는 새로운 방식으로 대체됐지만 기존 데이터나 기능과의 호환을 위해 유지하는 이전 방식을 의미한다.</p>
<pre><code class="language-java">@Deprecated
SUCCESS;</code></pre>
<p><code>@Deprecated</code>는 신규 코드에서 사용하지 않도록 표시하는 Java 애너테이션이다.</p>
<p>신규 결제에서는 <code>COMPLETED</code>만 사용하지만 기존 <code>SUCCESS</code>도 성공한 결제로 인식한다.</p>
<pre><code class="language-java">public boolean isSuccessful() {
    return this == COMPLETED
            || this == SUCCESS;
}</code></pre>
<p>성공 상태 목록도 제공한다.</p>
<pre><code class="language-java">public static Set&lt;PaymentStatus&gt;
        successfulStatuses() {
    return Set.of(
            COMPLETED,
            SUCCESS
    );
}</code></pre>
<p>결제 내역과 월 사용액 집계에서는 두 상태를 모두 포함한다.</p>
<pre><code class="language-java">paymentLogRepository
        .sumSuccessfulAmountThisMonth(
                userId,
                PaymentStatus.successfulStatuses()
        );</code></pre>
<p>이렇게 하면 기존 데이터를 즉시 마이그레이션하지 않아도 조회 기능이 깨지지 않는다.</p>
<p>추후 데이터 마이그레이션을 수행해 모든 <code>SUCCESS</code>를 <code>COMPLETED</code>로 변경한 뒤 레거시 enum을 제거할 수 있다.</p>
<h2 id="repository-쿼리-변경">Repository 쿼리 변경</h2>
<p>기존 통계 쿼리는 하나의 성공 상태만 전달받았다.</p>
<pre><code class="language-java">AND p.paymentStatus = :status</code></pre>
<p>이제 레거시 <code>SUCCESS</code>와 신규 <code>COMPLETED</code>를 함께 조회해야 하므로 <code>IN</code> 조건으로 변경했다.</p>
<pre><code class="language-java">AND p.paymentStatus IN :statuses</code></pre>
<p>메서드도 여러 상태를 받을 수 있도록 변경했다.</p>
<pre><code class="language-java">BigDecimal sumSuccessfulAmountThisMonth(
        Long userId,
        Collection&lt;PaymentStatus&gt; statuses
);</code></pre>
<p>최근 결제 내역, 월 결제 횟수 및 평균 결제 금액 조회에도 같은 기준을 적용했다.</p>
<h2 id="상태-머신-단위-테스트">상태 머신 단위 테스트</h2>
<p>상태 머신은 정상적인 흐름뿐 아니라 잘못된 상태 전이를 차단하는지도 중요하다.</p>
<h3 id="정상-상태-전이">정상 상태 전이</h3>
<pre><code class="language-java">payment.approve();
payment.markPaid(&quot;0x-transaction&quot;);
payment.markVerified();
payment.complete();

assertThat(payment.getPaymentStatus())
        .isEqualTo(
                PaymentStatus.COMPLETED
        );</code></pre>
<p>다음 정보도 함께 검증했다.</p>
<ul>
<li>트랜잭션 해시 저장</li>
<li>검증 상태 <code>SUCCESS</code></li>
<li>승인 시각</li>
<li>결제 시각</li>
<li>검증 시각</li>
<li>완료 시각</li>
</ul>
<h3 id="잘못된-상태-전이-차단">잘못된 상태 전이 차단</h3>
<p><code>REQUESTED</code> 상태에서 바로 <code>PAID</code>로 변경하는 상황을 검증했다.</p>
<pre><code class="language-java">assertThatThrownBy(() -&gt;
        payment.markPaid(&quot;0x-transaction&quot;)
).isInstanceOf(
        IllegalStateException.class
);</code></pre>
<h3 id="실패-단계-기록">실패 단계 기록</h3>
<p>블록체인 결제까지 완료된 뒤 영수증 검증에서 실패한 상황을 구성했다.</p>
<pre><code class="language-java">payment.approve();
payment.markPaid(&quot;0x-transaction&quot;);

payment.fail(&quot;영수증 검증 실패&quot;);</code></pre>
<p>검증 내용은 다음과 같다.</p>
<pre><code class="language-java">assertThat(payment.getPaymentStatus())
        .isEqualTo(PaymentStatus.FAILED);

assertThat(payment.getFailedFromStatus())
        .isEqualTo(PaymentStatus.PAID);

assertThat(payment.getFailureReason())
        .isEqualTo(&quot;영수증 검증 실패&quot;);</code></pre>
<h3 id="종결-상태-변경-차단">종결 상태 변경 차단</h3>
<p>완료된 결제를 다시 완료하거나 실패로 변경할 수 없는지 검증했다.</p>
<pre><code class="language-java">assertThatThrownBy(() -&gt;
        payment.fail(&quot;뒤늦은 실패&quot;)
).isInstanceOf(
        IllegalStateException.class
);

assertThatThrownBy(payment::complete)
        .isInstanceOf(
                IllegalStateException.class
        );</code></pre>
<h2 id="테스트-결과">테스트 결과</h2>
<p>상태 머신 단위 테스트와 실제 MySQL 기반 통합 테스트를 실행했다.</p>
<pre><code class="language-text">전체 단위 테스트: BUILD SUCCESSFUL
MySQL 통합 테스트: BUILD SUCCESSFUL</code></pre>
<p>MySQL 통합 테스트를 통해 변경된 엔티티 필드와 Repository 쿼리도 정상적으로 생성되는 것을 확인했다.</p>
<h2 id="현재-구조의-한계">현재 구조의 한계</h2>
<p>현재 상태 전이는 하나의 결제 트랜잭션 안에서 실행된다.</p>
<p>정상 완료되거나 코드에서 처리한 실패는 단계와 원인이 기록된다. 하지만 프로세스가 갑자기 종료되는 상황에는 한계가 있다.</p>
<p>예를 들어 다음 시점에 서버가 종료될 수 있다.</p>
<pre><code class="language-text">블록체인 전송 성공
→ 서버 강제 종료
→ PAID 상태 DB 커밋 전</code></pre>
<p>블록체인에서는 결제가 성공했지만 DB 트랜잭션은 롤백될 수 있다.</p>
<p>이 경우 멱등성 기록은 <code>PROCESSING</code>으로 남고 <code>PaymentLog</code>에는 <code>PAID</code> 상태가 남지 않을 수 있다.</p>
<p>이를 해결하려면 다음 단계로 대사 작업이 필요하다.</p>
<p>대사(Reconciliation)란 내부 DB 기록과 블록체인 또는 외부 결제 시스템의 실제 거래 내역을 비교해 불일치를 탐지하고 상태를 복구하는 작업이다.</p>
<pre><code class="language-text">오래된 PROCESSING 요청 조회
→ 블록체인 거래 내역 조회
→ 실제 결제 성공 여부 확인
→ PaymentLog 상태 복구
→ COMPLETED 또는 FAILED 판정</code></pre>
<p>즉, 상태 머신은 처리 단계와 전이 규칙을 제공하고, 대사 작업은 장애로 인해 중단된 상태를 실제 외부 거래와 비교해 복구하는 역할을 한다.</p>
<h2 id="마무리">마무리</h2>
<p>이번 작업에서는 단순했던 결제 상태를 단계별 상태 머신으로 개선했다.</p>
<p>주요 개선 내용은 다음과 같다.</p>
<ul>
<li><code>REQUESTED → APPROVED → PAID → VERIFIED → COMPLETED</code> 흐름 정의</li>
<li>각 처리 단계에서 <code>FAILED</code> 전환 지원</li>
<li>엔티티 메서드로만 상태 변경</li>
<li>허용되지 않은 상태 전이 차단</li>
<li>단계별 처리 시각 기록</li>
<li>실패 직전 상태와 실패 원인 기록</li>
<li>블록체인 영수증 boolean 결과 검증</li>
<li>완료·실패 상태를 종결 상태로 보호</li>
<li>기존 <code>SUCCESS</code> 데이터와 호환</li>
<li>단위 테스트로 정상·비정상 상태 전이 검증</li>
</ul>
<p>기존에는 결제 결과가 <code>SUCCESS</code>인지 <code>FAILED</code>인지만 알 수 있었다.</p>
<p>상태 머신을 적용한 뒤에는 결제가 어느 단계까지 진행됐고, 어디에서 실패했으며, 어떤 정보가 확보됐는지를 코드와 데이터로 표현할 수 있게 됐다.</p>
<p>다음 작업에서는 이 상태 정보를 바탕으로 내부 결제 기록과 블록체인 거래 내역을 비교하는 대사 배치를 구현할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[동시에 결제해도 잔액이 음수가 되지 않게 만들기: 비관적 락으로 결제 동시성 제어]]></title>
            <link>https://velog.io/@hyeondooori_/%EB%8F%99%EC%8B%9C%EC%97%90-%EA%B2%B0%EC%A0%9C%ED%95%B4%EB%8F%84-%EC%9E%94%EC%95%A1%EC%9D%B4-%EC%9D%8C%EC%88%98%EA%B0%80-%EB%90%98%EC%A7%80-%EC%95%8A%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EC%9C%BC%EB%A1%9C-%EA%B2%B0%EC%A0%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4</link>
            <guid>https://velog.io/@hyeondooori_/%EB%8F%99%EC%8B%9C%EC%97%90-%EA%B2%B0%EC%A0%9C%ED%95%B4%EB%8F%84-%EC%9E%94%EC%95%A1%EC%9D%B4-%EC%9D%8C%EC%88%98%EA%B0%80-%EB%90%98%EC%A7%80-%EC%95%8A%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EC%9C%BC%EB%A1%9C-%EA%B2%B0%EC%A0%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4</guid>
            <pubDate>Thu, 30 Jul 2026 08:24:37 GMT</pubDate>
            <description><![CDATA[<h1 id="동시에-결제해도-잔액이-음수가-되지-않게-만들기">동시에 결제해도 잔액이 음수가 되지 않게 만들기</h1>
<p>앞선 작업에서는 멱등성 키를 적용해 동일한 결제 요청이 반복 실행되는 문제를 방지했다.</p>
<p>하지만 멱등성만으로 모든 동시성 문제가 해결되는 것은 아니다.</p>
<p>예를 들어 한 사용자의 지갑 잔액이 100일 때 서로 다른 80원짜리 결제 두 건이 동시에 들어올 수 있다.</p>
<pre><code class="language-text">현재 잔액: 100

결제 A: 80
결제 B: 80</code></pre>
<p>결제 A와 B는 서로 다른 요청이고 멱등성 키도 다르다. 따라서 멱등성 검사를 모두 정상적으로 통과한다.</p>
<p>두 요청이 동시에 잔액을 조회하면 다음 문제가 발생할 수 있다.</p>
<pre><code class="language-text">결제 A: 잔액 조회 → 100
결제 B: 잔액 조회 → 100

결제 A: 100 &gt;= 80 → 결제 가능
결제 B: 100 &gt;= 80 → 결제 가능</code></pre>
<p>실제로 사용할 수 있는 잔액은 100인데 총 160의 결제가 승인될 수 있는 것이다.</p>
<p>이를 해결하기 위해 결제와 지갑 충전 과정에 비관적 락을 적용했다.</p>
<h2 id="동시성-문제란">동시성 문제란?</h2>
<p>동시성 문제란 여러 요청이 같은 데이터를 동시에 읽거나 수정하면서 의도하지 않은 결과가 발생하는 문제다.</p>
<p>이번 결제 시스템에서는 다음 데이터가 대상이었다.</p>
<ul>
<li>지갑 잔액</li>
<li>월 결제 사용액</li>
<li>AI 요청 상태</li>
<li>충전 후 잔액</li>
</ul>
<p>대표적인 문제는 잔액 초과 사용과 lost update다.</p>
<p>Lost update란 두 작업이 같은 값을 읽고 각각 수정한 뒤, 마지막 저장이 앞선 변경을 덮어쓰는 현상이다.</p>
<p>예를 들어 잔액이 100인 상황에서 결제와 충전이 동시에 실행될 수 있다.</p>
<pre><code class="language-text">결제 요청: 100을 읽고 30을 차감해 70 저장
충전 요청: 100을 읽고 20을 더해 120 저장</code></pre>
<p>정상적인 최종 잔액은 90이어야 한다.</p>
<p>하지만 충전 요청이 마지막에 120을 저장하면 결제의 30 차감이 사라진다. 반대로 결제가 마지막에 저장되면 충전 내역이 사라진다.</p>
<h2 id="기존-코드의-문제">기존 코드의 문제</h2>
<p>기존 결제 로직은 지갑을 일반 조회한 뒤 잔액을 확인했다.</p>
<pre><code class="language-java">Wallet wallet = walletRepository.findByUser_Id(userId)
        .orElseThrow(() -&gt;
                new CustomException(
                        ErrorType.WALLET_NOT_FOUND
                )
        );

if (wallet.getBalance().compareTo(estimatedCost) &lt; 0) {
    throw new CustomException(
            ErrorType.INSUFFICIENT_BALANCE
    );
}</code></pre>
<p>그다음 기존 잔액에서 결제 금액을 차감했다.</p>
<pre><code class="language-java">wallet.updateBalance(
        wallet.getBalance().subtract(estimatedCost)
);</code></pre>
<p>하나의 요청만 실행될 때는 정상적으로 동작한다.</p>
<p>하지만 두 요청이 동시에 같은 지갑을 조회하면 둘 다 변경 전 잔액을 읽을 수 있다.</p>
<pre><code class="language-text">요청 A: 잔액 100 조회
요청 B: 잔액 100 조회
요청 A: 80 차감
요청 B: 80 차감</code></pre>
<p>단순히 <code>@Transactional</code>을 사용한다고 이 문제가 자동으로 해결되지는 않는다.</p>
<p>트랜잭션은 여러 DB 작업을 하나의 단위로 묶어 성공하거나 실패하도록 만드는 기능이다. 하지만 서로 다른 트랜잭션이 같은 데이터를 동시에 읽는 것까지 항상 막아주지는 않는다.</p>
<h2 id="비관적-락을-선택한-이유">비관적 락을 선택한 이유</h2>
<p>비관적 락(Pessimistic Lock)은 여러 요청이 같은 데이터를 수정할 가능성이 높다고 가정하고, 데이터를 조회하는 시점부터 다른 요청의 수정을 막는 방식이다.</p>
<p>첫 번째 요청이 지갑 행에 락을 획득하면 두 번째 요청은 첫 번째 요청의 트랜잭션이 끝날 때까지 기다린다.</p>
<pre><code class="language-text">결제 A: 지갑 락 획득
결제 B: 같은 지갑 락 대기

결제 A: 잔액 확인 및 차감
결제 A: 트랜잭션 커밋
결제 A: 락 해제

결제 B: 지갑 락 획득
결제 B: 변경된 잔액 확인</code></pre>
<p>앞선 예시에서는 결제 A가 80을 차감한 뒤 결제 B는 잔액 20을 읽게 된다.</p>
<pre><code class="language-text">결제 A: 잔액 100 → 80 결제 → 잔액 20
결제 B: 잔액 20 → 80 결제 불가능</code></pre>
<p>결제처럼 잔액 정합성이 중요하고 충돌 시 재실행 비용이 큰 작업에서는 비관적 락이 비교적 명확한 선택이 될 수 있다.</p>
<h2 id="낙관적-락을-사용하지-않은-이유">낙관적 락을 사용하지 않은 이유</h2>
<p>낙관적 락(Optimistic Lock)은 충돌이 자주 발생하지 않는다고 가정하고, 데이터를 먼저 처리한 뒤 저장 시점에 버전을 비교하는 방식이다.</p>
<p>JPA에서는 일반적으로 엔티티에 <code>@Version</code>을 추가해 사용한다.</p>
<pre><code class="language-java">@Version
private Long version;</code></pre>
<p>두 요청이 같은 버전을 읽었다면 먼저 저장한 요청만 성공하고 나중에 저장한 요청은 충돌 예외를 받는다.</p>
<p>일반적인 데이터 수정이라면 실패한 요청을 다시 실행할 수 있다. 하지만 현재 결제 로직에는 DB 저장 전에 블록체인 전송이라는 외부 작업이 포함되어 있다.</p>
<pre><code class="language-text">요청 A: 블록체인 결제 성공
요청 B: 블록체인 결제 성공
요청 A: DB 저장 성공
요청 B: 낙관적 락 충돌</code></pre>
<p>DB에서는 한 요청만 성공했지만 블록체인 결제는 이미 두 번 실행됐을 수 있다.</p>
<p>외부 결제 실행 후 DB 충돌을 감지하는 방식은 중복 결제를 방지하기에 충분하지 않았다. 그래서 결제 전에 다른 요청을 대기시키는 비관적 락을 선택했다.</p>
<h2 id="지갑-조회에-비관적-락-적용">지갑 조회에 비관적 락 적용</h2>
<p><code>WalletRepository</code>에 락 조회 메서드를 추가했다.</p>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;&quot;&quot;
    SELECT w
    FROM Wallet w
    WHERE w.id = :walletId
&quot;&quot;&quot;)
Optional&lt;Wallet&gt; findByIdWithLock(
        @Param(&quot;walletId&quot;) Long walletId
);</code></pre>
<p>사용자 ID로 지갑을 조회하는 메서드에도 같은 락을 적용했다.</p>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;&quot;&quot;
    SELECT w
    FROM Wallet w
    WHERE w.user.id = :userId
&quot;&quot;&quot;)
Optional&lt;Wallet&gt; findByUserIdWithLock(
        @Param(&quot;userId&quot;) Long userId
);</code></pre>
<p><code>PESSIMISTIC_WRITE</code>는 해당 데이터를 수정할 목적으로 조회하며, 현재 트랜잭션이 끝날 때까지 다른 트랜잭션의 쓰기 목적 접근을 대기시키는 락 모드다.</p>
<p>MySQL에서는 일반적으로 <code>SELECT ... FOR UPDATE</code> 형태의 행 단위 잠금으로 동작한다.</p>
<p>행 단위 잠금이란 테이블 전체가 아니라 조회한 특정 행만 잠그는 방식이다. 사용자 A의 지갑을 잠가도 사용자 B의 지갑 결제는 계속 처리할 수 있다.</p>
<h2 id="결제-승인에서-지갑-락-사용">결제 승인에서 지갑 락 사용</h2>
<p>기존의 일반 지갑 조회를 락 조회로 변경했다.</p>
<pre><code class="language-java">Wallet wallet =
        walletRepository.findByUserIdWithLock(userId)
                .orElseThrow(() -&gt;
                        new CustomException(
                                ErrorType.WALLET_NOT_FOUND
                        )
                );</code></pre>
<p>이제 같은 사용자의 결제 요청은 지갑 행을 기준으로 순서대로 실행된다.</p>
<pre><code class="language-text">사용자 A 결제 1 → 사용자 A 지갑 락 획득
사용자 A 결제 2 → 사용자 A 지갑 락 대기
사용자 B 결제 1 → 사용자 B 지갑이므로 실행 가능</code></pre>
<p>모든 사용자의 결제가 하나씩 실행되는 것이 아니라 같은 지갑을 사용하는 요청끼리만 순서가 보장된다.</p>
<h2 id="월-예산-초과도-함께-방지">월 예산 초과도 함께 방지</h2>
<p>기존 결제 로직은 이번 달 성공 결제 금액을 합산한 뒤 새로운 결제 금액을 더해 월 한도를 검사한다.</p>
<pre><code class="language-java">BigDecimal monthlySpent =
        paymentLogRepository
                .sumSuccessfulAmountThisMonth(
                        userId,
                        PaymentStatus.SUCCESS
                );

if (monthlySpent.add(estimatedCost)
        .compareTo(policy.getMonthlyLimit()) &gt; 0) {
    throw new CustomException(
            ErrorType.MONTHLY_LIMIT_EXCEEDED
    );
}</code></pre>
<p>락이 없다면 두 요청이 같은 월 사용액을 조회할 수 있다.</p>
<pre><code class="language-text">월 한도: 100
현재 사용액: 40

결제 A: 40 조회 + 50 → 90, 승인
결제 B: 40 조회 + 50 → 90, 승인
실제 사용액: 140</code></pre>
<p>지갑 락을 획득한 뒤 월 사용액을 조회하도록 하면 동일 사용자의 결제 요청이 순서대로 검사를 수행한다.</p>
<pre><code class="language-text">결제 A: 월 사용액 40 → 50 결제 → 사용액 90
결제 B: 월 사용액 90 → 50 추가 시 140 → 거절</code></pre>
<p>지갑은 사용자별 결제의 공통 자원이므로 지갑 락을 동일 사용자의 결제 직렬화 기준으로 사용했다.</p>
<p>직렬화란 동시에 들어온 작업을 실제로는 하나씩 순서대로 실행되도록 만드는 것을 의미한다.</p>
<h2 id="동일-ai-요청에도-락-적용">동일 AI 요청에도 락 적용</h2>
<p>멱등성 키가 있다고 해서 클라이언트가 항상 올바른 키를 사용한다는 보장은 없다.</p>
<p>같은 AI 요청에 서로 다른 멱등성 키를 사용하면 멱등성 레코드만으로는 서로 다른 결제로 인식될 수 있다.</p>
<p>이를 막기 위해 <code>AiRequest</code> 조회에도 비관적 락을 적용했다.</p>
<p>기존 Repository에는 다음 메서드가 이미 존재했다.</p>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;&quot;&quot;
    SELECT r
    FROM AiRequest r
    WHERE r.id = :id
&quot;&quot;&quot;)
Optional&lt;AiRequest&gt; findByIdWithLock(
        @Param(&quot;id&quot;) Long id
);</code></pre>
<p>결제 서비스가 일반 <code>findById()</code> 대신 해당 메서드를 사용하도록 변경했다.</p>
<pre><code class="language-java">AiRequest aiRequest =
        aiRequestRepository.findByIdWithLock(requestId)
                .orElseThrow(() -&gt;
                        new CustomException(
                                ErrorType.AI_REQUEST_NOT_FOUND
                        )
                );</code></pre>
<p>첫 번째 요청이 <code>WAITING_APPROVAL</code> 상태를 <code>APPROVED</code>로 변경하면 두 번째 요청은 락이 풀린 뒤 변경된 상태를 확인한다.</p>
<p>따라서 서로 다른 멱등성 키를 사용하더라도 같은 AI 요청이 중복 승인되는 것을 막을 수 있다.</p>
<h2 id="잠금-순서-통일">잠금 순서 통일</h2>
<p>여러 개의 락을 사용할 때는 잠금 순서도 중요하다.</p>
<p>두 트랜잭션이 서로 다른 순서로 락을 획득하면 데드락(Deadlock)이 발생할 수 있다.</p>
<p>데드락이란 두 작업이 서로 상대방의 락이 풀리기를 기다리면서 둘 다 진행하지 못하는 상태다.</p>
<p>결제 승인에서는 잠금 순서를 다음과 같이 통일했다.</p>
<pre><code class="language-text">1. AiRequest 락
2. Wallet 락
3. 월 사용액과 잔액 확인
4. 블록체인 결제 실행
5. 결제 기록 저장
6. 잔액 차감
7. 트랜잭션 커밋 및 락 해제</code></pre>
<p>동일한 로직에서는 항상 같은 순서로 락을 획득하도록 구성했다.</p>
<h2 id="충전에도-같은-지갑-락-적용">충전에도 같은 지갑 락 적용</h2>
<p>결제만 지갑 락을 사용하면 결제와 충전이 동시에 실행될 때 lost update가 발생할 수 있다.</p>
<p>그래서 충전 로직도 일반 조회에서 락 조회로 변경했다.</p>
<pre><code class="language-java">Wallet wallet =
        walletRepository.findByIdWithLock(walletId)
                .orElseThrow(() -&gt;
                        new CustomException(
                                ErrorType.WALLET_NOT_FOUND
                        )
                );</code></pre>
<p>이제 결제와 충전은 같은 지갑 행을 기준으로 순서대로 실행된다.</p>
<pre><code class="language-text">결제 실행 중 → 충전 대기
충전 실행 중 → 결제 대기</code></pre>
<p>먼저 실행된 작업이 커밋한 잔액을 다음 작업이 읽기 때문에 한쪽 변경이 사라지지 않는다.</p>
<h2 id="잔액-변경을-도메인-메서드로-제한">잔액 변경을 도메인 메서드로 제한</h2>
<p>기존 <code>Wallet</code>은 외부에서 계산한 잔액을 그대로 저장할 수 있었다.</p>
<pre><code class="language-java">public void updateBalance(BigDecimal balance) {
    this.balance = balance;
}</code></pre>
<p>이 방식은 실수로 음수 잔액이나 잘못 계산한 값을 전달해도 엔티티가 막을 수 없다.</p>
<p>이를 <code>withdraw()</code>와 <code>deposit()</code>으로 분리했다.</p>
<h3 id="출금-가능-여부-확인">출금 가능 여부 확인</h3>
<pre><code class="language-java">public boolean canWithdraw(BigDecimal amount) {
    return amount != null
            &amp;&amp; amount.compareTo(BigDecimal.ZERO) &gt; 0
            &amp;&amp; balance.compareTo(amount) &gt;= 0;
}</code></pre>
<p>다음 조건을 모두 만족해야 한다.</p>
<ul>
<li>금액이 <code>null</code>이 아님</li>
<li>금액이 0보다 큼</li>
<li>현재 잔액이 출금 금액 이상임</li>
</ul>
<h3 id="출금">출금</h3>
<pre><code class="language-java">public void withdraw(BigDecimal amount) {
    if (!canWithdraw(amount)) {
        throw new IllegalStateException(
                &quot;지갑 잔액보다 큰 금액을 차감할 수 없습니다.&quot;
        );
    }

    this.balance = this.balance.subtract(amount);
}</code></pre>
<p>서비스에서 잔액을 확인하더라도 엔티티 내부에서 한 번 더 검증한다.</p>
<p>도메인 불변식이란 객체가 어떤 상황에서도 반드시 지켜야 하는 규칙을 뜻한다. 이번 지갑의 핵심 불변식은 “잔액보다 많은 금액을 차감할 수 없다”는 것이다.</p>
<h3 id="충전">충전</h3>
<pre><code class="language-java">public void deposit(BigDecimal amount) {
    if (amount == null
            || amount.compareTo(BigDecimal.ZERO) &lt;= 0) {
        throw new IllegalArgumentException(
                &quot;충전 금액은 0보다 커야 합니다.&quot;
        );
    }

    this.balance = this.balance.add(amount);
}</code></pre>
<p>잔액을 임의의 값으로 바꾸는 메서드를 제거하고 출금과 충전이라는 구체적인 행위만 허용했다.</p>
<h2 id="실제-mysql-동시성-테스트">실제 MySQL 동시성 테스트</h2>
<p>동시성 코드는 단순한 단위 테스트만으로 충분히 검증하기 어렵다.</p>
<p>비관적 락은 실제 DB 트랜잭션과 여러 스레드가 함께 동작해야 하기 때문이다.</p>
<p>Testcontainers를 이용해 실제 MySQL 컨테이너를 실행하고 두 스레드에서 같은 지갑을 동시에 조회하도록 테스트했다.</p>
<p>Testcontainers는 테스트 실행 시 Docker 컨테이너로 MySQL 같은 외부 시스템을 자동 실행해주는 도구다.</p>
<p>테스트 조건은 다음과 같다.</p>
<pre><code class="language-text">초기 잔액: 100
첫 번째 결제: 80
두 번째 결제: 80</code></pre>
<p>두 결제의 합은 160이므로 하나만 성공해야 한다.</p>
<h3 id="첫-번째-스레드">첫 번째 스레드</h3>
<p>첫 번째 스레드는 지갑 락을 획득하고 잠시 대기한다.</p>
<pre><code class="language-java">Wallet wallet =
        walletRepository.findByUserIdWithLock(userId)
                .orElseThrow();

firstLockAcquired.countDown();
await(releaseFirstTransaction);

if (wallet.canWithdraw(paymentAmount)) {
    wallet.withdraw(paymentAmount);
    successfulPayments.incrementAndGet();
}</code></pre>
<h3 id="두-번째-스레드">두 번째 스레드</h3>
<p>첫 번째 스레드가 락을 보유한 상태에서 두 번째 스레드도 같은 지갑을 조회한다.</p>
<pre><code class="language-java">Wallet wallet =
        walletRepository.findByUserIdWithLock(userId)
                .orElseThrow();

if (wallet.canWithdraw(paymentAmount)) {
    wallet.withdraw(paymentAmount);
    successfulPayments.incrementAndGet();
}</code></pre>
<p>두 번째 스레드는 첫 번째 트랜잭션이 끝날 때까지 기다린다.</p>
<p>첫 번째 결제가 커밋된 뒤 잔액 20을 읽기 때문에 80을 차감할 수 없다.</p>
<h3 id="검증-결과">검증 결과</h3>
<pre><code class="language-java">assertThat(successfulPayments).hasValue(1);

assertThat(finalBalance)
        .isEqualByComparingTo(&quot;20.000000&quot;);</code></pre>
<p>실제 테스트 결과는 다음과 같았다.</p>
<pre><code class="language-text">성공한 결제: 1건
최종 잔액: 20
MySQL 통합 테스트: BUILD SUCCESSFUL</code></pre>
<p>두 요청이 동시에 실행됐지만 한 요청만 잔액을 차감했다.</p>
<h2 id="비관적-락의-트레이드오프">비관적 락의 트레이드오프</h2>
<p>비관적 락은 정합성을 명확하게 보장하지만 단점도 있다.</p>
<p>현재 구조에서는 블록체인 RPC 호출 중에도 지갑 락이 유지된다.</p>
<p>RPC(Remote Procedure Call)는 애플리케이션이 네트워크를 통해 외부 시스템의 기능을 호출하는 방식이다. 이번 프로젝트에서는 블록체인 노드에 거래를 요청하고 결과를 확인하는 과정이 해당한다.</p>
<p>외부 호출이 느리면 같은 지갑의 다음 요청은 그 시간 동안 대기해야 한다.</p>
<pre><code class="language-text">지갑 락 획득
→ 블록체인 요청
→ 영수증 검증
→ DB 저장
→ 트랜잭션 커밋
→ 지갑 락 해제</code></pre>
<p>따라서 다음과 같은 특성이 있다.</p>
<h3 id="장점">장점</h3>
<ul>
<li>구현과 동작을 이해하기 쉬움</li>
<li>동일 지갑의 결제를 확실하게 순차 처리</li>
<li>잔액과 월 한도 정합성을 강하게 보장</li>
<li>외부 결제가 실행된 뒤 DB 충돌이 발생하는 문제 방지</li>
</ul>
<h3 id="단점">단점</h3>
<ul>
<li>블록체인 응답이 늦으면 락 유지 시간이 길어짐</li>
<li>같은 지갑의 다음 요청 응답 시간이 증가</li>
<li>트래픽이 많으면 DB 커넥션 대기가 늘어날 수 있음</li>
</ul>
<p>현재 단계에서는 결제 정확성을 우선해 비관적 락을 선택했다.</p>
<p>향후 거래 상태 머신을 도입하면 잔액 예약과 외부 결제를 분리해 락 유지 시간을 줄일 수 있다.</p>
<pre><code class="language-text">짧은 DB 트랜잭션에서 잔액 예약
→ 락 해제
→ 블록체인 결제 실행
→ 결제 결과에 따라 예약 확정 또는 해제</code></pre>
<h2 id="마무리">마무리</h2>
<p>이번 작업에서는 동일 사용자의 여러 결제가 동시에 실행될 때 잔액과 예산이 잘못 계산되는 문제를 해결했다.</p>
<p>주요 개선 내용은 다음과 같다.</p>
<ul>
<li>동일 AI 요청에 비관적 락 적용</li>
<li>동일 사용자의 지갑에 비관적 락 적용</li>
<li>지갑 락 이후 월 사용액과 잔액 확인</li>
<li>결제와 충전이 동일한 지갑 락을 사용하도록 통일</li>
<li>잔액 변경을 <code>withdraw()</code>와 <code>deposit()</code>으로 제한</li>
<li>실제 MySQL과 두 개의 스레드로 동시 차감 테스트</li>
<li>하나의 결제만 성공하고 최종 잔액이 음수가 되지 않는 것을 검증</li>
</ul>
<p>멱등성은 동일한 요청의 반복 실행을 막고, 지갑 락은 서로 다른 요청이 같은 잔액을 동시에 사용하는 것을 막는다.</p>
<p>두 기능은 해결하는 문제가 다르다.</p>
<pre><code class="language-text">멱등성 키
→ 같은 결제 요청의 중복 실행 방지

비관적 지갑 락
→ 서로 다른 결제 요청 간 잔액 및 예산 충돌 방지</code></pre>
<p>결제 시스템의 정합성을 지키려면 멱등성과 동시성 제어를 함께 고려해야 한다는 점을 확인할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[결제 API에 멱등성 키를 적용해 중복 결제 방지하기]]></title>
            <link>https://velog.io/@hyeondooori_/%EA%B2%B0%EC%A0%9C-API%EC%97%90-%EB%A9%B1%EB%93%B1%EC%84%B1-%ED%82%A4%EB%A5%BC-%EC%A0%81%EC%9A%A9%ED%95%B4-%EC%A4%91%EB%B3%B5-%EA%B2%B0%EC%A0%9C-%EB%B0%A9%EC%A7%80%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyeondooori_/%EA%B2%B0%EC%A0%9C-API%EC%97%90-%EB%A9%B1%EB%93%B1%EC%84%B1-%ED%82%A4%EB%A5%BC-%EC%A0%81%EC%9A%A9%ED%95%B4-%EC%A4%91%EB%B3%B5-%EA%B2%B0%EC%A0%9C-%EB%B0%A9%EC%A7%80%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 30 Jul 2026 08:23:34 GMT</pubDate>
            <description><![CDATA[<p>결제 API를 개발하다 보면 “결제 요청 한 번에 결제도 정확히 한 번만 실행된다”고 생각하기 쉽다.</p>
<p>하지만 실제 서비스에서는 동일한 결제 요청이 여러 번 전달될 수 있다.</p>
<ul>
<li>사용자가 결제 버튼을 연속으로 클릭한 경우</li>
<li>서버 응답이 늦어 프론트엔드가 요청을 다시 보낸 경우</li>
<li>모바일 네트워크가 끊겼다가 복구되면서 요청이 재전송된 경우</li>
<li>HTTP 클라이언트가 타임아웃된 요청을 자동으로 재시도한 경우</li>
<li>동일한 요청이 서로 다른 서버 인스턴스로 전달된 경우</li>
</ul>
<p>이런 상황에서 서버가 모든 요청을 새로운 결제로 인식하면 실제 결제가 두 번 실행될 수 있다.</p>
<p>이번 글에서는 TinyPay 결제 API에 멱등성 키를 적용해 중복 결제를 방지한 과정을 정리한다.</p>
<h2 id="멱등성이란">멱등성이란?</h2>
<p>멱등성(Idempotency)이란 동일한 요청을 여러 번 실행하더라도 최종 결과가 한 번 실행한 것과 같도록 만드는 성질이다.</p>
<p>결제 API에서 멱등성을 보장한다는 것은 다음을 의미한다.</p>
<pre><code class="language-text">동일 결제 요청 1회 → 결제 1회 실행
동일 결제 요청 5회 → 결제 1회 실행</code></pre>
<p>클라이언트는 결제 요청마다 고유한 <code>idempotencyKey</code>를 생성한다. 같은 결제를 재시도할 때는 새로운 키를 만들지 않고 기존 키를 다시 사용한다.</p>
<pre><code class="language-text">새로운 결제 → 새로운 idempotencyKey
같은 결제 재시도 → 기존 idempotencyKey 재사용</code></pre>
<h2 id="기존-구현의-문제">기존 구현의 문제</h2>
<p>기존 코드도 성공한 결제 기록을 조회해 기본적인 중복 요청을 방어하고 있었다.</p>
<pre><code class="language-java">Optional&lt;PaymentLog&gt; existingLog =
        paymentLogRepository.findByRequestAndPaymentStatus(
                aiRequest,
                PaymentStatus.SUCCESS
        );

if (existingLog.isPresent()) {
    return 기존_결제_응답;
}</code></pre>
<p>결제가 이미 완료된 뒤 동일한 요청이 다시 들어오면 기존 결과를 반환할 수 있다.</p>
<p>문제는 두 요청이 거의 동시에 들어오는 경우다.</p>
<pre><code class="language-text">요청 A: 성공한 PaymentLog 조회 → 없음
요청 B: 성공한 PaymentLog 조회 → 없음
요청 A: 블록체인 결제 실행
요청 B: 블록체인 결제 실행</code></pre>
<p>두 요청 모두 결제 기록이 저장되기 전에 조회하면 둘 다 “아직 처리되지 않은 결제”라고 판단할 수 있다.</p>
<p>이처럼 여러 작업이 같은 데이터를 동시에 확인하고 수정하면서 실행 순서에 따라 결과가 달라지는 문제를 경쟁 상태(Race Condition)라고 한다.</p>
<p>기존 구조는 결제 완료 후 중복 여부를 확인했기 때문에 경쟁 상태를 충분히 막을 수 없었다. 이를 해결하기 위해 실제 결제를 실행하기 전에 한 요청만 처리 권한을 확보하도록 구조를 변경했다.</p>
<h2 id="전체-처리-흐름">전체 처리 흐름</h2>
<p>개선한 결제 처리 흐름은 다음과 같다.</p>
<pre><code class="language-text">결제 요청 수신
→ 사용자·금액·비밀번호·예산·잔액 검증
→ idempotencyKey 선점 시도
→ 선점 성공 시 블록체인 결제 실행
→ 결제 성공 시 PaymentLog 저장
→ 멱등성 상태를 COMPLETED로 변경</code></pre>
<p>이미 같은 키가 존재한다면 상태에 따라 다르게 처리한다.</p>
<pre><code class="language-text">COMPLETED  → 기존 결제 결과 반환
PROCESSING → 처리 중이라는 409 응답
FAILED     → 이전 처리 실패라는 409 응답
요청 불일치 → 키 재사용 오류</code></pre>
<p>여기서 선점이란 여러 요청 중 하나가 먼저 처리 권한을 확보하는 것을 뜻한다.</p>
<h2 id="결제-요청에-idempotencykey-추가">결제 요청에 idempotencyKey 추가</h2>
<p>먼저 결제 요청 DTO에 <code>idempotencyKey</code>를 추가했다.</p>
<pre><code class="language-java">@NotBlank(message = &quot;멱등성 키가 존재하지 않습니다.&quot;)
@Size(max = 100, message = &quot;멱등성 키는 100자 이하여야 합니다.&quot;)
private String idempotencyKey;</code></pre>
<p><code>@NotBlank</code>와 <code>@Size</code>는 Bean Validation 기능이다. Bean Validation은 요청값이 서비스 로직에 들어가기 전에 필수값, 길이, 형식 등을 검사하는 Java 표준 검증 방식이다.</p>
<p>클라이언트는 다음과 같이 키를 포함해 결제를 요청한다.</p>
<pre><code class="language-json">{
  &quot;idempotencyKey&quot;: &quot;4dddbed4-3537-4ec4-82f8-643b59243e87&quot;,
  &quot;estimatedCost&quot;: 10.500000,
  &quot;walletPassword&quot;: &quot;123456&quot;
}</code></pre>
<p>키는 UUID처럼 충돌 가능성이 매우 낮은 값을 사용하는 것이 적합하다.</p>
<p>키가 없거나 100자를 초과하면 결제 로직을 실행하지 않고 <code>400 Bad Request</code>를 반환한다.</p>
<h2 id="멱등성-정보를-저장할-엔티티-설계">멱등성 정보를 저장할 엔티티 설계</h2>
<p>멱등성 처리 상태를 저장하기 위해 <code>PaymentIdempotency</code> 엔티티를 추가했다.</p>
<pre><code class="language-java">private Long userId;
private Long requestId;
private String idempotencyKey;
private BigDecimal requestAmount;
private PaymentIdempotencyStatus status;
private PaymentLog payment;</code></pre>
<p>각 필드의 역할은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>필드</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>userId</code></td>
<td>키를 사용한 사용자</td>
</tr>
<tr>
<td><code>requestId</code></td>
<td>결제 대상 AI 요청</td>
</tr>
<tr>
<td><code>idempotencyKey</code></td>
<td>클라이언트가 생성한 중복 방지 키</td>
</tr>
<tr>
<td><code>requestAmount</code></td>
<td>동일한 키로 금액이 변경되는 것을 방지</td>
</tr>
<tr>
<td><code>status</code></td>
<td>현재 멱등성 처리 상태</td>
</tr>
<tr>
<td><code>payment</code></td>
<td>완료된 실제 결제 내역</td>
</tr>
</tbody></table>
<p>별도의 엔티티를 사용한 이유는 결제가 완료되기 전부터 요청의 처리 상태를 저장해야 하기 때문이다.</p>
<p><code>PaymentLog</code>만 사용하면 실제 결제 내역이 생성되기 전에는 중복 요청을 식별하기 어렵다.</p>
<h2 id="db-unique-constraint-적용">DB Unique Constraint 적용</h2>
<p>동일한 사용자가 같은 키를 두 번 선점하지 못하도록 복합 유일 제약을 추가했다.</p>
<p>유일 제약(Unique Constraint)이란 특정 컬럼 값 또는 컬럼 조합이 테이블 안에서 중복되지 않도록 데이터베이스가 보장하는 규칙이다.</p>
<pre><code class="language-java">@Table(
    name = &quot;payment_idempotency&quot;,
    uniqueConstraints = @UniqueConstraint(
        name = &quot;uk_payment_idempotency_user_key&quot;,
        columnNames = {&quot;user_id&quot;, &quot;idempotency_key&quot;}
    )
)</code></pre>
<p>동작은 다음과 같다.</p>
<pre><code class="language-text">사용자 1 + key-123 → 저장 성공
사용자 1 + key-123 → 중복이므로 저장 실패
사용자 2 + key-123 → 저장 성공</code></pre>
<p>키만 유일하게 설정하지 않고 <code>userId</code>와 묶은 이유는 사용자별로 키 공간을 분리하기 위해서다. 서로 다른 사용자가 우연히 같은 UUID를 생성해도 영향을 받지 않는다.</p>
<h2 id="애플리케이션-코드만으로-막지-않은-이유">애플리케이션 코드만으로 막지 않은 이유</h2>
<p>Java 코드에서 먼저 기존 키를 조회한 뒤 저장하는 방법도 생각할 수 있다.</p>
<pre><code class="language-java">if (!repository.existsByIdempotencyKey(key)) {
    repository.save(entity);
}</code></pre>
<p>하지만 동시에 요청이 들어오면 다음과 같은 문제가 발생할 수 있다.</p>
<pre><code class="language-text">요청 A: 키 조회 → 없음
요청 B: 키 조회 → 없음
요청 A: 저장
요청 B: 저장</code></pre>
<p>조회와 저장 사이에 빈틈이 있기 때문이다.</p>
<p>또한 서버가 여러 대라면 <code>synchronized</code>처럼 한 서버의 메모리에서만 동작하는 잠금으로는 다른 서버에 전달된 요청을 막을 수 없다.</p>
<p>모든 서버가 공유하는 DB에 유일 제약을 적용하면 여러 서버에서 동시에 요청하더라도 하나의 INSERT만 성공한다.</p>
<h2 id="멱등성-상태-관리">멱등성 상태 관리</h2>
<p>멱등성 처리 상태는 세 가지로 구분했다.</p>
<pre><code class="language-java">public enum PaymentIdempotencyStatus {
    PROCESSING,
    COMPLETED,
    FAILED
}</code></pre>
<p>각 상태의 의미는 다음과 같다.</p>
<ul>
<li><code>PROCESSING</code>: 처리 권한을 확보하고 결제를 진행 중</li>
<li><code>COMPLETED</code>: 결제가 성공해 기존 결과를 재사용할 수 있음</li>
<li><code>FAILED</code>: 이전 결제 시도가 실패함</li>
</ul>
<p>새로운 레코드는 <code>PROCESSING</code> 상태로 생성된다.</p>
<pre><code class="language-java">public static PaymentIdempotency processing(
        Long userId,
        Long requestId,
        String idempotencyKey,
        BigDecimal requestAmount
) {
    return new PaymentIdempotency(
            userId,
            requestId,
            idempotencyKey,
            requestAmount
    );
}</code></pre>
<p>결제가 성공하면 실제 결제 기록을 연결하고 상태를 변경한다.</p>
<pre><code class="language-java">public void complete(PaymentLog payment) {
    this.payment = payment;
    this.status = PaymentIdempotencyStatus.COMPLETED;
}</code></pre>
<p>실패하면 <code>FAILED</code> 상태로 변경한다.</p>
<pre><code class="language-java">public void fail() {
    this.status = PaymentIdempotencyStatus.FAILED;
}</code></pre>
<h2 id="같은-키로-요청-내용을-바꾸는-문제">같은 키로 요청 내용을 바꾸는 문제</h2>
<p>동일한 키인지 확인하는 것만으로는 부족하다.</p>
<p>예를 들어 다음 두 요청은 키는 같지만 결제 금액이 다르다.</p>
<pre><code class="language-text">첫 번째 요청: key-123, requestId=10, amount=10
두 번째 요청: key-123, requestId=10, amount=100</code></pre>
<p>두 번째 요청에 첫 번째 결과를 반환하거나 새로운 결제를 실행하면 안 된다.</p>
<p>이를 막기 위해 기존 요청의 <code>requestId</code>와 <code>requestAmount</code>도 함께 비교했다.</p>
<pre><code class="language-java">public boolean matches(
        Long requestId,
        BigDecimal requestAmount
) {
    return this.requestId.equals(requestId)
            &amp;&amp; this.requestAmount.compareTo(requestAmount) == 0;
}</code></pre>
<p>금액 비교에는 <code>BigDecimal.equals()</code> 대신 <code>compareTo()</code>를 사용했다.</p>
<pre><code class="language-text">10.0
10.00
10.000000</code></pre>
<p>위 값들은 금액으로는 모두 같지만 <code>equals()</code>는 소수점 자릿수까지 비교하기 때문에 서로 다른 값으로 판단할 수 있다.</p>
<p><code>compareTo()</code>는 실제 숫자의 크기를 비교하므로 결제 금액 비교에 더 적합하다.</p>
<h2 id="repository-구현">Repository 구현</h2>
<p>기존 멱등성 기록을 사용자 ID와 키로 조회할 수 있도록 Repository를 추가했다.</p>
<pre><code class="language-java">public interface PaymentIdempotencyRepository
        extends JpaRepository&lt;PaymentIdempotency, Long&gt; {

    @EntityGraph(attributePaths = {
        &quot;payment&quot;,
        &quot;payment.wallet&quot;,
        &quot;payment.request&quot;
    })
    Optional&lt;PaymentIdempotency&gt;
            findByUserIdAndIdempotencyKey(
                    Long userId,
                    String idempotencyKey
            );
}</code></pre>
<p><code>@EntityGraph</code>는 연관된 데이터를 어떤 범위까지 함께 조회할지 지정하는 JPA 기능이다.</p>
<p>완료된 요청이 다시 들어오면 기존 <code>PaymentLog</code>, 지갑 및 요청 정보가 필요하다. 이를 한 번에 조회해 트랜잭션 종료 후 연관 데이터 접근 시 발생할 수 있는 지연 로딩 문제를 방지했다.</p>
<p>지연 로딩(Lazy Loading)이란 연관 데이터를 처음부터 모두 조회하지 않고, 실제로 사용할 때 추가로 조회하는 방식이다.</p>
<h2 id="멱등성-선점-서비스-구현">멱등성 선점 서비스 구현</h2>
<p>멱등성 선점과 상태 변경은 <code>PaymentIdempotencyService</code>로 분리했다.</p>
<pre><code class="language-java">@Transactional(propagation = Propagation.REQUIRES_NEW)
public PaymentIdempotency createClaim(
        Long userId,
        Long requestId,
        String idempotencyKey,
        BigDecimal requestAmount
) {
    return paymentIdempotencyRepository.saveAndFlush(
            PaymentIdempotency.processing(
                    userId,
                    requestId,
                    idempotencyKey,
                    requestAmount
            )
    );
}</code></pre>
<p>여기서 핵심은 <code>REQUIRES_NEW</code>와 <code>saveAndFlush()</code>다.</p>
<h3 id="requires_new란">REQUIRES_NEW란?</h3>
<p>트랜잭션(Transaction)이란 여러 DB 작업을 하나의 작업 단위로 묶는 기능이다. 중간에 오류가 발생하면 해당 단위의 변경을 모두 취소할 수 있다.</p>
<p><code>REQUIRES_NEW</code>는 현재 진행 중인 트랜잭션과 별개로 새로운 트랜잭션을 시작한다.</p>
<p>멱등성 선점 기록을 결제 처리보다 먼저 확정하기 위해 사용했다.</p>
<pre><code class="language-text">멱등성 PROCESSING 저장 및 커밋
→ 블록체인 결제 실행
→ PaymentLog 저장
→ 멱등성 상태 COMPLETED</code></pre>
<p>블록체인 결제 이후 서버가 갑자기 종료되더라도 선점 기록은 <code>PROCESSING</code>으로 남는다. 재요청이 들어와도 새로운 결제를 바로 실행하지 않으므로 중복 결제보다 안전한 방향으로 실패할 수 있다.</p>
<h3 id="saveandflush를-사용한-이유">saveAndFlush()를 사용한 이유</h3>
<p>JPA의 <code>save()</code>는 SQL 실행을 트랜잭션 종료 시점까지 미룰 수 있다.</p>
<p>플러시(Flush)란 JPA가 메모리에 모아둔 변경사항을 실제 DB SQL로 반영하는 과정이다.</p>
<p><code>saveAndFlush()</code>를 사용하면 INSERT를 즉시 실행하므로 DB 유일 제약 위반 여부를 블록체인 결제 전에 확인할 수 있다.</p>
<h2 id="결제-승인-로직-변경">결제 승인 로직 변경</h2>
<p>기존 사용자, 비밀번호, 예산 및 잔액 검증을 통과한 뒤 블록체인 결제 직전에 키를 선점하도록 변경했다.</p>
<pre><code class="language-java">PaymentIdempotency idempotency;

try {
    idempotency =
            paymentIdempotencyService.createClaim(
                    userId,
                    requestId,
                    request.getIdempotencyKey(),
                    estimatedCost
            );
} catch (DataIntegrityViolationException e) {
    // 이미 동일한 키가 존재하는 경우 처리
}</code></pre>
<p>동시에 같은 키로 INSERT를 시도하면 하나만 성공한다.</p>
<pre><code class="language-text">요청 A → 선점 성공 → 블록체인 결제 진행
요청 B → 유일 제약 충돌 → 기존 상태 조회</code></pre>
<h3 id="이미-완료된-요청">이미 완료된 요청</h3>
<p>기존 상태가 <code>COMPLETED</code>라면 블록체인을 다시 호출하지 않고 기존 결과를 반환한다.</p>
<pre><code class="language-java">if (existing.getStatus()
        == PaymentIdempotencyStatus.COMPLETED
        &amp;&amp; existing.getPayment() != null) {
    return toResponse(
            aiRequest,
            existing.getPayment()
    );
}</code></pre>
<p>첫 번째 결제는 성공했지만 네트워크 문제로 클라이언트가 응답을 받지 못한 경우에도 같은 키로 재요청하면 기존 결과를 받을 수 있다.</p>
<h3 id="처리-중인-요청">처리 중인 요청</h3>
<p>기존 상태가 <code>PROCESSING</code>이면 두 번째 요청에 <code>409 Conflict</code>를 반환한다.</p>
<p><code>409 Conflict</code>는 요청 형식은 올바르지만 현재 서버에 저장된 상태와 충돌한다는 의미의 HTTP 상태 코드다.</p>
<pre><code class="language-java">throw new CustomException(
        ErrorType.IDEMPOTENCY_REQUEST_IN_PROGRESS
);</code></pre>
<h3 id="이전에-실패한-요청">이전에 실패한 요청</h3>
<p>기존 상태가 <code>FAILED</code>라면 해당 키로 결제를 자동 재실행하지 않는다.</p>
<pre><code class="language-java">if (existing.getStatus()
        == PaymentIdempotencyStatus.FAILED) {
    throw new CustomException(
            ErrorType.IDEMPOTENCY_REQUEST_FAILED
    );
}</code></pre>
<p>실패한 요청을 다시 실행하려면 새로운 키를 사용하도록 했다. 이는 실패 원인이 명확하지 않은 상태에서 동일 결제가 자동으로 다시 실행되는 것을 막기 위한 정책이다.</p>
<h3 id="결제-성공과-실패-처리">결제 성공과 실패 처리</h3>
<p>결제가 성공하면 <code>PaymentLog</code>를 저장하고 멱등성 상태를 <code>COMPLETED</code>로 변경한다.</p>
<pre><code class="language-java">paymentLogRepository.save(paymentLog);

paymentIdempotencyService.complete(
        idempotency,
        paymentLog
);</code></pre>
<p>실패하면 실패 결제 기록과 함께 멱등성 상태를 <code>FAILED</code>로 변경한다.</p>
<pre><code class="language-java">paymentLogService.saveFailedPaymentLog(...);
paymentIdempotencyService.fail(
        idempotency.getId()
);</code></pre>
<h2 id="오류-상태-구분">오류 상태 구분</h2>
<p>멱등성 처리 과정에서 발생할 수 있는 충돌을 세 가지로 구분했다.</p>
<pre><code class="language-java">IDEMPOTENCY_KEY_REUSED(
    HttpStatus.CONFLICT,
    &quot;동일한 멱등성 키가 다른 결제 요청에 사용되었습니다.&quot;
),

IDEMPOTENCY_REQUEST_IN_PROGRESS(
    HttpStatus.CONFLICT,
    &quot;동일한 결제 요청이 처리 중입니다.&quot;
),

IDEMPOTENCY_REQUEST_FAILED(
    HttpStatus.CONFLICT,
    &quot;동일한 결제 요청이 이전 처리에서 실패했습니다.&quot;
)</code></pre>
<p>모든 중복 요청을 하나의 오류로 처리하지 않고 원인을 구분하면 클라이언트도 상황에 맞게 대응할 수 있다.</p>
<h2 id="테스트">테스트</h2>
<h3 id="엔티티-단위-테스트">엔티티 단위 테스트</h3>
<p>단위 테스트(Unit Test)는 클래스나 메서드처럼 작은 코드 단위를 독립적으로 검증하는 테스트다.</p>
<p>다음 내용을 검증했다.</p>
<ul>
<li>요청 ID와 금액이 같으면 동일 요청으로 판단</li>
<li>요청 ID가 다르면 불일치</li>
<li>금액이 다르면 불일치</li>
<li>실패 처리 후 상태가 <code>FAILED</code>로 변경</li>
<li>금액의 소수점 자릿수가 달라도 같은 값으로 판단</li>
</ul>
<pre><code class="language-java">assertThat(
    idempotency.matches(
        10L,
        new BigDecimal(&quot;12.34&quot;)
    )
).isTrue();</code></pre>
<p>저장된 금액이 <code>12.340000</code>이어도 동일한 금액으로 판단한다.</p>
<h3 id="mysql-통합-테스트">MySQL 통합 테스트</h3>
<p>통합 테스트(Integration Test)는 여러 구성요소가 함께 동작할 때 결과가 올바른지 검증하는 테스트다.</p>
<p>Testcontainers를 이용해 실제 MySQL 환경에서 유일 제약이 동작하는지 확인했다.</p>
<p>Testcontainers는 테스트를 실행할 때 Docker 컨테이너로 MySQL 같은 외부 시스템을 자동 실행해주는 테스트 도구다.</p>
<pre><code class="language-java">paymentIdempotencyRepository.saveAndFlush(first);

assertThatThrownBy(() -&gt;
        paymentIdempotencyRepository
                .saveAndFlush(second)
).isInstanceOf(
        DataIntegrityViolationException.class
);</code></pre>
<p>동일한 사용자의 같은 키를 두 번 저장했을 때 두 번째 저장이 DB에서 거부되는 것을 검증했다.</p>
<pre><code class="language-text">단위 테스트: BUILD SUCCESSFUL
MySQL 통합 테스트: 1 test, 0 failures</code></pre>
<h2 id="이번-구현으로-방어할-수-있는-상황">이번 구현으로 방어할 수 있는 상황</h2>
<p>이번 개선으로 다음 상황에서 동일한 결제가 다시 실행되는 것을 방지할 수 있다.</p>
<ul>
<li>결제 버튼 중복 클릭</li>
<li>동일 요청 재전송</li>
<li>클라이언트의 자동 재시도</li>
<li>여러 서버 인스턴스로 들어온 동일 요청</li>
<li>결제 처리 중 발생한 중복 요청</li>
<li>같은 키로 결제 금액을 변경한 요청</li>
<li>결제 도중 서버가 중단된 후 들어오는 재요청</li>
</ul>
<h2 id="남아-있는-과제">남아 있는 과제</h2>
<p>이번 구현은 중복 결제를 막는 데 초점을 맞췄다.</p>
<p>다만 블록체인 결제가 성공한 직후, 멱등성 상태를 <code>COMPLETED</code>로 변경하기 전에 서버가 종료되면 해당 키가 계속 <code>PROCESSING</code>으로 남을 수 있다.</p>
<p>현재 구현은 이런 상황에서 결제를 자동으로 다시 실행하지 않는다. 중복 결제를 발생시키는 것보다 운영자가 확인할 수 있도록 멈추는 방향을 선택한 것이다.</p>
<p>향후에는 대사(Reconciliation) 작업이 필요하다.</p>
<p>대사란 내부 DB의 결제 내역과 외부 결제 시스템 또는 블록체인의 실제 거래 내역을 비교해 불일치를 찾고 상태를 바로잡는 작업이다.</p>
<pre><code class="language-text">오래된 PROCESSING 요청 조회
→ 블록체인 거래 내역 확인
→ 실제 성공이면 COMPLETED
→ 실제 실패면 FAILED
→ 판단할 수 없으면 운영 확인 대상으로 등록</code></pre>
<p>이 대사 작업까지 추가하면 장애 발생 후 자동 복구가 가능한 결제 구조로 확장할 수 있다.</p>
<h2 id="마무리">마무리</h2>
<p>처음에는 성공한 <code>PaymentLog</code>가 있는지 조회하는 것만으로 중복 결제를 막으려고 했다.</p>
<p>하지만 동시에 요청이 들어오는 상황에서는 두 요청이 모두 결제 기록이 없다고 판단할 수 있었다. 이를 해결하기 위해 다음과 같이 개선했다.</p>
<ul>
<li>클라이언트가 결제별 <code>idempotencyKey</code> 전달</li>
<li>별도의 멱등성 엔티티와 처리 상태 관리</li>
<li>사용자와 키에 대한 DB 복합 유일 제약 적용</li>
<li>결제 전에 멱등성 처리 권한 선점</li>
<li>완료 요청에는 기존 결과 반환</li>
<li>처리 중·실패·키 재사용 상황을 구분</li>
<li>실제 MySQL 통합 테스트로 유일 제약 검증</li>
</ul>
<p>이번 작업을 통해 멱등성은 단순히 “기존 결제가 있는지 조회하는 기능”이 아니라, 요청의 처리 권한과 상태를 결제 실행 전에 안전하게 기록하는 구조라는 점을 배울 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[FastAPI 성능 개선하기: 대용량 뉴스 서비스를 위한 검색, 페이지네이션, Celery 성능 개선]]></title>
            <link>https://velog.io/@hyeondooori_/FastAPI-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-%EB%8C%80%EC%9A%A9%EB%9F%89-%EB%89%B4%EC%8A%A4-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A5%BC-%EC%9C%84%ED%95%9C-%EA%B2%80%EC%83%89-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-Celery-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0</link>
            <guid>https://velog.io/@hyeondooori_/FastAPI-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-%EB%8C%80%EC%9A%A9%EB%9F%89-%EB%89%B4%EC%8A%A4-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A5%BC-%EC%9C%84%ED%95%9C-%EA%B2%80%EC%83%89-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-Celery-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0</guid>
            <pubDate>Fri, 12 Jun 2026 10:41:08 GMT</pubDate>
            <description><![CDATA[<h2>들어가며</h2>

<p>
기사 데이터가 적을 때는 대부분의 쿼리가 빠르게 작동한다.
하지만 기사 수가 수십만 건 이상으로 증가하면,
검색과 목록 조회 같은 기본 기능도 점점 느려질 수 있다.
</p>

<p>
이번 글에서는 데이터 증가와 운영 환경을 고려하여 다음 항목을 개선했다.
</p>

<ul>
  <li>기사 검색을 위한 trigram 인덱스 추가</li>
  <li>최신 중요도 조회 쿼리 단순화</li>
  <li>COUNT 결과 캐싱</li>
  <li>커서 기반 페이지네이션 도입</li>
  <li>Dify 업로드 동시 처리</li>
  <li>Celery 작업 큐와 스케줄러 분리</li>
  <li>운영 환경에서 reload 제거 및 worker 확장</li>
</ul>

<h2>ILIKE 검색과 trigram 인덱스</h2>

<p>
기사 제목이나 본문에서 특정 단어를 찾기 위해
다음과 같은 검색 조건을 사용할 수 있다.
</p>

<pre><code>WHERE title ILIKE '%검색어%'
</code></pre>

<p>
앞뒤에 <code>%</code>가 붙은 검색은 일반적인 B-Tree 인덱스를
효율적으로 사용하기 어렵다.
데이터가 많아지면 데이터베이스가 많은 행을 직접 확인해야 한다.
</p>

<p>
PostgreSQL의 <code>pg_trgm</code> 확장은 문자열을 작은 단위로 나누어
유사 검색과 부분 문자열 검색을 빠르게 처리할 수 있도록 도와준다.
</p>

<pre><code>CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX article_title_trgm_idx
ON articles
USING gin (title gin_trgm_ops);
</code></pre>

<p>
기사 제목, 본문, 요약 검색에 trigram 인덱스를 추가하면
기존 검색 동작을 크게 변경하지 않으면서 검색 성능을 개선할 수 있다.
</p>

<h2>최신 중요도 조회에서 row_number 제거</h2>

<p>
기존 쿼리는 기사마다 가장 최근의 중요도 결과를 찾기 위해
<code>row_number()</code> 윈도우 함수를 사용했다.
</p>

<pre><code>기사별 중요도 데이터 정렬
→ 각 행에 순번 부여
→ 순번이 1인 데이터 선택
</code></pre>

<p>
하지만 데이터베이스에 이미 현재 중요도인지 나타내는
<code>is_current</code> 컬럼이 존재한다면,
매번 전체 데이터를 정렬하여 순번을 계산할 필요가 없다.
</p>

<pre><code>WHERE is_current = true
</code></pre>

<p>
현재 데이터가 무엇인지 저장 단계에서 관리하고,
조회 단계에서는 현재 데이터만 가져오도록 변경했다.
</p>

<p>
기사 요약처럼 최신 데이터를 직접 표시하는 컬럼이 없는 경우에는
PostgreSQL의 <code>DISTINCT ON</code>을 사용하여
기사별 최신 데이터를 조회하도록 개선했다.
</p>

<h2>정확한 전체 개수 COUNT 캐싱</h2>

<p>
페이지 번호를 표시하려면 전체 검색 결과 개수가 필요하다.
이를 위해 목록 API는 일반적으로 <code>COUNT</code> 쿼리를 실행한다.
</p>

<pre><code>SELECT COUNT(*)
FROM articles
WHERE ...;
</code></pre>

<p>
복잡한 검색 조건을 사용하는 경우 목록 조회보다
전체 개수를 계산하는 작업이 더 느릴 수도 있다.
</p>

<p>
동일한 검색 조건의 COUNT 결과를 Redis에 잠시 저장하면,
사용자가 다음 페이지로 이동할 때 같은 COUNT 쿼리를 반복하지 않아도 된다.
</p>

<pre><code>검색 조건을 정렬하여 문자열 생성
→ 해시값 생성
→ Redis 캐시 키로 사용
</code></pre>

<p>
캐시 키에는 검색어, 날짜, 중요도 등
COUNT 결과에 영향을 주는 모든 조건이 포함되어야 한다.
</p>

<h2>OFFSET 페이지네이션의 문제</h2>

<p>
페이지 번호 방식은 보통 <code>OFFSET</code>과 <code>LIMIT</code>을 사용한다.
</p>

<pre><code>SELECT *
FROM articles
ORDER BY id DESC
OFFSET 100000
LIMIT 20;
</code></pre>

<p>
OFFSET이 커지면 데이터베이스는 앞의 많은 데이터를 확인한 후 버려야 한다.
따라서 사용자가 뒤쪽 페이지로 이동할수록 조회가 느려질 수 있다.
</p>

<h2>커서 기반 페이지네이션</h2>

<p>
커서 기반 페이지네이션은 마지막으로 조회한 데이터의 위치를 기준으로
다음 데이터를 가져온다.
</p>

<pre><code>첫 번째 요청:
ORDER BY id DESC
LIMIT 21

다음 요청:
WHERE id &lt; 마지막으로 조회한 ID
ORDER BY id DESC
LIMIT 21
</code></pre>

<p>
현재 페이지 크기보다 한 개 더 조회하면
다음 페이지가 존재하는지 확인할 수 있다.
추가로 조회한 한 개의 데이터가 있다면 다음 커서를 반환한다.
</p>

<p>
이 방식은 페이지 번호를 직접 선택하기 어렵다는 단점이 있지만,
데이터가 많아져도 일정한 속도로 다음 목록을 조회할 수 있다.
</p>

<p>
기존 페이지 번호 방식은 유지하고,
빠른 탐색이 필요한 경우 커서 방식을 선택할 수 있도록 구성했다.
</p>

<h2>Dify 업로드를 동시에 처리하기</h2>

<p>
기존에는 여러 기사를 Dify에 업로드할 때 기사마다 순서대로 처리했다.
</p>

<pre><code>기사 1 업로드 완료
→ 기사 2 업로드 완료
→ 기사 3 업로드 완료
</code></pre>

<p>
기사 한 건의 업로드에 1초가 걸린다면,
기사 100건은 약 100초가 필요하다.
</p>

<p>
이를 개선하기 위해 여러 업로드를 동시에 실행하도록 변경했다.
다만 요청을 무제한으로 동시에 보내면 Dify 서버에 부담을 줄 수 있다.
</p>

<p>
따라서 <code>asyncio.Semaphore</code>를 사용하여
동시에 처리할 수 있는 업로드 수를 제한했다.
</p>

<pre><code>동시 업로드 제한: 3개

기사 1, 2, 3 업로드
→ 완료된 자리에 기사 4 업로드
→ 완료된 자리에 기사 5 업로드
</code></pre>

<p>
이러한 방식을 bounded concurrency라고 한다.
순차 처리보다 빠르면서도 외부 API에 과도한 요청을 보내지 않는다.
</p>

<h2>Celery 작업 큐 도입</h2>

<p>
기사 분석이나 정기 크롤링은 실행 시간이 길 수 있다.
이 작업을 FastAPI 서버 내부에서 직접 실행하면,
서버 재시작 시 작업이 사라지거나 여러 worker가 같은 작업을 실행할 수 있다.
</p>

<p>
Celery를 도입하면 API 서버와 백그라운드 작업 실행 프로세스를 분리할 수 있다.
</p>

<pre><code>사용자 요청
→ FastAPI가 Celery 작업 등록
→ Redis가 작업 보관
→ Celery Worker가 작업 실행
→ Redis에 작업 상태 저장
</code></pre>

<p>
FastAPI는 작업을 직접 끝낼 때까지 기다리지 않고,
작업 ID를 사용자에게 빠르게 반환할 수 있다.
</p>

<h2>Celery Beat로 정기 작업 분리</h2>

<p>
기존에는 FastAPI 애플리케이션 내부에서 스케줄러를 실행했다.
하지만 FastAPI worker를 여러 개 실행하면
각 worker에서 스케줄러가 시작될 수 있다.
</p>

<pre><code>FastAPI Worker 1 → 정기 크롤링 실행
FastAPI Worker 2 → 동일한 정기 크롤링 실행
FastAPI Worker 3 → 동일한 정기 크롤링 실행
</code></pre>

<p>
결과적으로 같은 작업이 여러 번 실행될 위험이 있다.
</p>

<p>
정기 작업 등록은 Celery Beat가 담당하고,
실제 작업 실행은 Celery Worker가 담당하도록 분리했다.
</p>

<pre><code>Celery Beat
→ 정해진 시간에 작업 등록

Celery Worker
→ 등록된 작업 실행
</code></pre>

<h2>운영 환경에서 reload 제거</h2>

<p>
개발 환경에서 사용하는 <code>--reload</code> 옵션은
파일 변경을 감지하여 서버를 자동으로 재시작한다.
개발 중에는 편리하지만 운영 환경에는 적합하지 않다.
</p>

<p>
운영 환경에서는 reload를 제거하고 여러 FastAPI worker를 실행하도록 변경했다.
</p>

<pre><code>개발 환경:
uvicorn app.main:app --reload

운영 환경:
uvicorn app.main:app --workers 2
</code></pre>

<p>
API 서버, 데이터베이스 마이그레이션, Redis,
Celery Worker, Celery Beat를 각각 별도 서비스로 구성했다.
</p>

<h2>전체 구조</h2>

<pre><code>사용자
  ↓
FastAPI Worker
  ├─ PostgreSQL 조회
  ├─ Redis 캐시 조회
  └─ Celery 작업 등록
        ↓
      Redis Broker
        ↓
      Celery Worker
        ↓
      기사 크롤링 및 분석

Celery Beat
  ↓
정기 작업 등록
</code></pre>

<h2>정리</h2>

<ul>
  <li>trigram 인덱스로 부분 문자열 검색 성능을 개선했다.</li>
  <li>현재 중요도 컬럼을 활용하여 불필요한 윈도우 함수를 제거했다.</li>
  <li>COUNT 결과를 Redis에 캐싱했다.</li>
  <li>대용량 목록 조회를 위해 커서 페이지네이션을 추가했다.</li>
  <li>Dify 업로드를 동시에 최대 3개씩 처리하도록 변경했다.</li>
  <li>기사 분석과 정기 작업을 Celery로 분리했다.</li>
  <li>운영 환경에서 reload를 제거하고 여러 API worker를 실행하도록 구성했다.</li>
</ul>

<p>
서비스 초기에는 단순한 구조가 관리하기 쉽다.
하지만 데이터와 사용자 요청이 증가하면,
API 요청 처리와 백그라운드 작업을 분리하고
반복되는 데이터베이스 작업을 줄이는 구조가 필요해진다.
</p>

<p>
이번 개선을 통해 현재 처리 속도를 높이는 것뿐만 아니라,
향후 기사 데이터와 요청량이 증가해도 확장하기 쉬운 구조를 만들 수 있었다.
</p>]]></description>
        </item>
        <item>
            <title><![CDATA[FastAPI 성능 개선하기: Redis 캐시로 반복되는 통계 쿼리 줄이기]]></title>
            <link>https://velog.io/@hyeondooori_/FastAPI-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-Redis-%EC%BA%90%EC%8B%9C%EB%A1%9C-%EB%B0%98%EB%B3%B5%EB%90%98%EB%8A%94-%ED%86%B5%EA%B3%84-%EC%BF%BC%EB%A6%AC-%EC%A4%84%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@hyeondooori_/FastAPI-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-Redis-%EC%BA%90%EC%8B%9C%EB%A1%9C-%EB%B0%98%EB%B3%B5%EB%90%98%EB%8A%94-%ED%86%B5%EA%B3%84-%EC%BF%BC%EB%A6%AC-%EC%A4%84%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Fri, 12 Jun 2026 10:40:21 GMT</pubDate>
            <description><![CDATA[<h2>Redis를 도입한 이유</h2>

<p>
뉴스 모니터링 서비스의 통계 API는 기사 수, 언론사별 기사 수,
중요도별 기사 수와 같은 데이터를 제공한다.
</p>

<p>
이러한 통계 값은 요청할 때마다 크게 달라지지 않지만,
기존 구조에서는 사용자가 통계 페이지를 열 때마다
데이터베이스에서 집계 쿼리를 다시 실행했다.
</p>

<pre><code>사용자 A가 통계 페이지 조회 → DB 집계 쿼리 실행
사용자 B가 통계 페이지 조회 → DB 집계 쿼리 실행
사용자 C가 통계 페이지 조회 → DB 집계 쿼리 실행
</code></pre>

<p>
기사 데이터가 많아질수록 <code>COUNT</code>와 <code>GROUP BY</code> 같은
집계 쿼리의 처리 비용도 증가한다.
</p>

<p>
Redis 캐시를 사용하면 한 번 계산한 결과를 잠시 저장하고,
동일한 요청이 들어왔을 때 저장된 값을 바로 반환할 수 있다.
</p>

<h2>Redis란 무엇인가?</h2>

<p>
Redis는 데이터를 메모리에 저장하는 데이터 저장소다.
일반적인 데이터베이스보다 데이터를 매우 빠르게 읽을 수 있어
캐시, 로그인 세션, 작업 상태 관리 등에 자주 사용된다.
</p>

<p>
Redis에 저장되는 데이터는 기본적으로 키와 값으로 구성된다.
</p>

<pre><code>키: stats:articles:daily:2026-06-12
값: {"total": 1200, "important": 350}
</code></pre>

<h2>Redis에 들어가는 값은 어떻게 정할까?</h2>

<p>
캐시 키에는 해당 결과를 구분할 수 있는 조건이 포함되어야 한다.
예를 들어 날짜와 사용자에 따라 통계 결과가 달라진다면,
날짜와 사용자 ID를 캐시 키에 포함해야 한다.
</p>

<pre><code>stats:user:10:date:2026-06-12
stats:user:20:date:2026-06-12
</code></pre>

<p>
서로 다른 조건에서 같은 캐시 키를 사용하면
사용자에게 잘못된 결과를 반환할 수 있다.
</p>

<p>
캐시에 저장하는 값은 데이터베이스 쿼리 결과를
JSON 형태로 변환한 값이다.
</p>

<pre><code>{
  "total_articles": 1200,
  "high_importance": 350,
  "medium_importance": 500,
  "low_importance": 350
}
</code></pre>

<h2>TTL이란 무엇인가?</h2>

<p>
캐시 데이터는 데이터베이스의 최신 상태와 달라질 수 있다.
이를 방지하기 위해 Redis 데이터에 TTL을 설정한다.
</p>

<p>
TTL은 데이터가 Redis에 유지되는 시간을 의미한다.
예를 들어 TTL을 60초로 설정하면,
캐시 데이터는 저장된 지 60초 후 자동으로 삭제된다.
</p>

<pre><code>첫 번째 요청
→ DB에서 통계 계산
→ Redis에 결과 저장
→ TTL 60초 설정

60초 이내의 요청
→ Redis 결과 반환

60초 이후 요청
→ DB에서 다시 계산
→ Redis 결과 갱신
</code></pre>

<p>
TTL이 짧으면 데이터는 더 최신 상태에 가깝지만,
데이터베이스 쿼리가 더 자주 실행된다.
</p>

<p>
TTL이 길면 데이터베이스 부하는 줄어들지만,
사용자가 잠시 오래된 데이터를 볼 수 있다.
서비스 특성에 따라 적절한 값을 선택해야 한다.
</p>

<h2>Redis 객체는 하나만 있어도 될까?</h2>

<p>
일반적으로 애플리케이션 내부에서는 Redis 연결 객체를 공용으로 사용한다.
요청마다 새로운 Redis 연결을 만들 필요는 없다.
</p>

<p>
Redis 클라이언트는 내부적으로 연결 풀을 관리한다.
여러 요청이 동시에 Redis를 사용하더라도
연결 풀에서 사용 가능한 연결을 선택하여 처리한다.
</p>

<pre><code>FastAPI 애플리케이션
→ 공용 Redis 클라이언트
→ Redis 연결 풀
→ Redis 서버
</code></pre>

<p>
다만 Redis 안에서 사용하는 데이터는 목적에 따라 구분해야 한다.
하나의 Redis 서버를 사용하더라도 키 이름이나 Redis DB 번호를 분리할 수 있다.
</p>

<pre><code>DB 0: API 통계 캐시
DB 1: Celery 작업 큐
DB 2: Celery 작업 결과
</code></pre>

<h2>캐시 조회 흐름</h2>

<pre><code>1. 요청 조건을 이용하여 캐시 키를 생성한다.
2. Redis에서 캐시 키를 조회한다.
3. 값이 존재하면 Redis 값을 반환한다.
4. 값이 없으면 데이터베이스 쿼리를 실행한다.
5. 쿼리 결과를 Redis에 저장한다.
6. 사용자에게 결과를 반환한다.
</code></pre>

<h2>Redis 장애는 어떻게 처리할까?</h2>

<p>
Redis는 성능 개선을 위한 보조 저장소다.
Redis가 잠시 사용할 수 없더라도 핵심 API는 가능하면 정상 작동해야 한다.
</p>

<p>
따라서 Redis 조회 중 오류가 발생하면
데이터베이스에서 직접 데이터를 조회하도록 처리했다.
</p>

<pre><code>Redis 정상
→ 캐시 결과 사용

Redis 장애
→ 데이터베이스 직접 조회
</code></pre>

<p>
이러한 방식을 사용하면 Redis 장애가 전체 서비스 장애로 이어지는 것을 줄일 수 있다.
</p>

<h2>정리</h2>

<ul>
  <li>반복되는 통계 쿼리 결과를 Redis에 저장했다.</li>
  <li>요청 조건을 기반으로 캐시 키를 생성했다.</li>
  <li>TTL을 설정하여 오래된 데이터가 자동으로 삭제되도록 했다.</li>
  <li>공용 Redis 클라이언트와 연결 풀을 사용했다.</li>
  <li>Redis 장애 시 데이터베이스를 직접 조회하도록 처리했다.</li>
</ul>

<p>
Redis 캐시는 데이터베이스를 완전히 대체하는 기술이 아니다.
자주 조회되지만 자주 변경되지 않는 결과를 잠시 저장하여,
데이터베이스의 반복 작업을 줄이는 역할을 한다.
</p>]]></description>
        </item>
        <item>
            <title><![CDATA[FastAPI 성능 개선하기: 중복 요청, N+1 쿼리, 이벤트 루프 문제 해결]]></title>
            <link>https://velog.io/@hyeondooori_/FastAPI-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-%EC%A4%91%EB%B3%B5-%EC%9A%94%EC%B2%AD-N1-%EC%BF%BC%EB%A6%AC-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@hyeondooori_/FastAPI-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0-%EC%A4%91%EB%B3%B5-%EC%9A%94%EC%B2%AD-N1-%EC%BF%BC%EB%A6%AC-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Fri, 12 Jun 2026 10:39:23 GMT</pubDate>
            <description><![CDATA[<h2>들어가며</h2>

<p>
FastAPI로 뉴스 모니터링 서비스를 개발하면서, 기능은 정상적으로 작동하지만
기사 수가 많아질수록 처리 속도가 느려질 수 있는 부분들을 발견했다.
</p>

<p>
이번 글에서는 다음 성능 문제를 개선한 과정을 정리한다.
</p>

<ul>
  <li>기사 본문을 불필요하게 다시 크롤링하는 문제</li>
  <li>외부 API 요청마다 새로운 HTTP 연결을 만드는 문제</li>
  <li>기사 분석 결과 저장 과정에서 발생하는 N+1 쿼리 문제</li>
  <li>HTML 파싱과 이메일 발송이 다른 API 요청을 막는 문제</li>
</ul>

<h2>기사 본문이 있는데도 다시 크롤링하는 문제</h2>

<p>
서비스는 TransNews API로부터 기사 정보를 전달받는다.
이 정보에는 기사 제목, URL, 본문 등이 포함될 수 있다.
</p>

<p>
기존 코드에서는 TransNews API가 기사 본문을 이미 전달했더라도,
기사 URL에 다시 접속하여 본문을 크롤링했다.
</p>

<pre><code>TransNews API 호출
→ 기사 URL과 본문 전달
→ 전달받은 URL에 다시 접속
→ 기사 본문 재크롤링
</code></pre>

<p>
이 방식은 같은 데이터를 얻기 위해 외부 사이트에 불필요한 요청을 한 번 더 보내게 된다.
기사 수가 많아질수록 네트워크 요청 횟수와 전체 처리 시간이 함께 증가한다.
</p>

<p>
개선 후에는 TransNews가 전달한 본문이 존재하는지 먼저 확인한다.
본문이 없는 기사만 URL에 접속하여 다시 크롤링한다.
</p>

<pre><code>if article.content:
    전달받은 본문 사용
else:
    URL을 이용하여 본문 크롤링
</code></pre>

<p>
이렇게 하면 이미 본문을 가지고 있는 기사에 대해서는 추가 네트워크 요청이 발생하지 않는다.
</p>

<h2>HTTP 연결을 요청마다 새로 만드는 문제</h2>

<p>
외부 API를 호출할 때는 서버와 HTTP 연결을 생성해야 한다.
HTTPS를 사용하는 경우에는 TLS 연결 설정 과정도 필요하다.
</p>

<p>
기존 코드에서는 외부 API를 호출할 때마다 새로운
<code>httpx.AsyncClient</code> 객체를 생성했다.
</p>

<pre><code>요청 1 → 새로운 연결 생성 → 요청 처리 → 연결 종료
요청 2 → 새로운 연결 생성 → 요청 처리 → 연결 종료
요청 3 → 새로운 연결 생성 → 요청 처리 → 연결 종료
</code></pre>

<p>
이 방식은 매번 새로운 소켓과 TLS 연결을 생성하기 때문에,
여러 기사를 연속으로 처리할 때 불필요한 시간이 반복해서 발생한다.
</p>

<p>
개선 후에는 애플리케이션 전체에서 공유하는
<code>AsyncClient</code>를 사용하도록 변경했다.
</p>

<pre><code>애플리케이션 시작
→ 공용 AsyncClient 생성

외부 API 요청
→ 기존 연결을 재사용

애플리케이션 종료
→ AsyncClient 종료
</code></pre>

<p>
공용 클라이언트는 연결 풀과 Keep-Alive를 활용한다.
이미 생성된 연결을 재사용할 수 있어 외부 API를 반복 호출할 때 처리 속도가 개선된다.
</p>

<h2>N+1 쿼리 문제 해결</h2>

<p>
N+1 쿼리는 여러 데이터를 처리할 때,
각 데이터마다 추가 데이터베이스 쿼리를 실행하는 문제를 의미한다.
</p>

<p>
예를 들어 기사 분석 결과 1,000개를 저장한다고 가정해 보자.
기존 코드에서는 각 기사마다 기존 분석 결과가 있는지 개별적으로 조회했다.
</p>

<pre><code>기사 목록 조회: 1회

기사 1의 기존 분석 조회: 1회
기사 2의 기존 분석 조회: 1회
기사 3의 기존 분석 조회: 1회
...
기사 1,000의 기존 분석 조회: 1회
</code></pre>

<p>
기사 1,000개를 처리하기 위해 약 1,001회의 쿼리가 실행되는 구조다.
기사 수가 증가하면 데이터베이스 요청 수도 비례하여 증가한다.
</p>

<p>
개선 후에는 처리할 모든 <code>article_id</code>를 모아서
기존 분석 결과를 한 번에 조회한다.
</p>

<pre><code>SELECT *
FROM article_analysis
WHERE article_id IN (1, 2, 3, ...);
</code></pre>

<p>
조회 결과는 기사 ID를 키로 사용하는 딕셔너리 형태로 정리한다.
이후 각 기사의 기존 분석 결과를 메모리에서 빠르게 찾을 수 있다.
</p>

<pre><code>기존 방식:
기사 수만큼 데이터베이스 조회

개선 방식:
배치 전체를 한 번에 데이터베이스 조회
</code></pre>

<h2>동기 작업이 이벤트 루프를 막는 문제</h2>

<p>
FastAPI의 <code>async def</code> 함수는 이벤트 루프 위에서 실행된다.
이벤트 루프는 여러 요청을 번갈아 처리하면서 높은 동시성을 제공한다.
</p>

<p>
하지만 HTML 파싱, Excel 파일 생성, SMTP 이메일 발송처럼
오래 걸리는 동기 작업을 이벤트 루프에서 직접 실행하면 문제가 발생한다.
</p>

<pre><code>요청 A: Excel 파일 생성 중
요청 B: 대기
요청 C: 대기
</code></pre>

<p>
Excel 파일을 만드는 동안 이벤트 루프가 다른 요청을 처리하지 못할 수 있다.
사용자 입장에서는 관련 없는 API 요청까지 느려진 것처럼 보인다.
</p>

<p>
개선 후에는 이러한 동기 작업을
<code>asyncio.to_thread()</code>를 이용하여 별도의 스레드에서 실행한다.
</p>

<pre><code>await asyncio.to_thread(create_excel_report)
await asyncio.to_thread(send_email)
</code></pre>

<p>
동기 작업이 별도 스레드에서 실행되는 동안,
이벤트 루프는 다른 API 요청을 계속 처리할 수 있다.
</p>

<h2>Celery가 반드시 필요한 것은 아닐까?</h2>

<p>
짧게 끝나는 동기 작업이라면 <code>asyncio.to_thread()</code>만으로도 충분할 수 있다.
하지만 작업 시간이 길거나, 서버가 종료되어도 작업이 유지되어야 한다면 Celery가 더 적합하다.
</p>

<table>
  <thead>
    <tr>
      <th>상황</th>
      <th>적합한 방법</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>몇 초 이내에 끝나는 동기 작업</td>
      <td>asyncio.to_thread()</td>
    </tr>
    <tr>
      <td>오래 걸리는 기사 분석</td>
      <td>Celery</td>
    </tr>
    <tr>
      <td>실패 시 재시도가 필요한 작업</td>
      <td>Celery</td>
    </tr>
    <tr>
      <td>정해진 시간마다 실행하는 작업</td>
      <td>Celery Beat</td>
    </tr>
  </tbody>
</table>

<h2>정리</h2>

<ul>
  <li>본문이 없는 기사만 다시 크롤링하도록 변경했다.</li>
  <li>공용 HTTP 클라이언트를 사용하여 연결을 재사용했다.</li>
  <li>기사별 개별 조회를 배치 조회로 변경하여 N+1 쿼리를 제거했다.</li>
  <li>동기 작업을 별도 스레드로 보내 이벤트 루프가 멈추지 않도록 했다.</li>
</ul>

<p>
성능 개선은 무조건 복잡한 기술을 도입하는 것이 아니다.
불필요하게 반복되는 네트워크 요청과 데이터베이스 쿼리를 줄이는 것만으로도
서비스의 처리 속도와 안정성을 크게 개선할 수 있다.
</p>]]></description>
        </item>
        <item>
            <title><![CDATA[ONOS Project Retrospective ]]></title>
            <link>https://velog.io/@hyeondooori_/ONOS-Project-Retrospective</link>
            <guid>https://velog.io/@hyeondooori_/ONOS-Project-Retrospective</guid>
            <pubDate>Sun, 27 Apr 2025 14:54:09 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/345e4cc6-bfb0-4dcf-bae5-1297dd6df769/image.png" alt=""></p>
<h2 id="1-introduction">1. Introduction</h2>
<p>In June 2024, I joined the Lee Jae-hoon professor&#39;s Network Research Lab at Dongguk University, focusing on AI-enabled networking technologies.<br>As part of the lab activities, I worked extensively with ONOS (Open Network Operating System) to understand and apply Software-Defined Networking (SDN) concepts through hands-on labs.</p>
<p>This blog post summarizes my journey.
detailed version also exists in korean.</p>
<p><a href="https://velog.io/@hyeondooori_/ONOS-2.7.0-Ubuntu-22.04%EC%9D%B4%EC%9A%A9%ED%95%B4-mininet-%EA%B0%80%EC%83%81-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%97%B0%EA%B2%B0">ONOS Practice - Connecting virtual networks using Mininet with ONOS 2.7.0 on Ubuntu 22.04</a></p>
<p><a href="https://velog.io/@hyeondooori_/%EB%9D%BC%EC%A6%88%EB%B2%A0%EB%A6%AC%ED%8C%8C%EC%9D%B43%EC%97%90-OVS-%EC%84%A4%EC%B9%98-%ED%9B%84-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B5%AC%EC%84%B1%ED%95%B4%EB%B3%B4%EA%B8%B0-ONOS%EB%A1%9C-flow-rule-%EC%84%A4%EC%A0%95%ED%95%98%EC%97%AC-VLAN-%ED%8C%A8%ED%82%B7%EC%A0%9C%EC%96%B4">ONOS Practice - Setting flow rules in ONOS to control VLAN-tagged packets</a></p>
<p><a href="https://velog.io/@hyeondooori_/ONOS-%EC%8B%A4%EC%8A%B5-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-sw-%ED%99%9C%EC%9A%A9%ED%95%B4-ONOS%EB%A1%9C-%EC%8A%A4%EC%9C%84%EC%B9%98-%ED%8F%AC%ED%8A%B8-%EB%B3%84-%EC%86%A1%EC%88%98%EC%8B%A0-%ED%8A%B8%EB%9E%98%ED%94%BD%EC%B2%98%EB%A6%AC%EB%9F%89-%EC%A0%95%EB%B3%B4-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EA%B7%B8%EB%9E%98%ED%94%84%EB%A1%9C-%EC%B6%9C%EB%A0%A5%ED%95%98%EA%B8%B0">ONOS Practice - Visualizing real-time switch port traffic statistics from ONOS using open-source tools</a></p>
<hr>
<h2 id="2-setting-up-onos-and-mininet">2. Setting up ONOS and Mininet</h2>
<p>To start, I set up the ONOS 2.7.0 controller on Ubuntu 22.04 and connected it with Mininet, a popular network emulator for SDN.<br>I practiced creating virtual networks consisting of multiple switches and hosts, and then linked them to ONOS to control the flow of network traffic.</p>
<p><strong>Key tools:</strong></p>
<ul>
<li>Ubuntu 22.04</li>
<li>Mininet</li>
<li>ONOS 2.7.0</li>
</ul>
<hr>
<h2 id="3-connecting-virtual-networks">3. Connecting Virtual Networks</h2>
<p>I created virtual topologies in Mininet (such as linear and tree structures) and verified that ONOS correctly discovered all devices and links.<br>I also explored how ONOS automatically installs default flow rules to manage packet forwarding in the network.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/b02b242e-a1a6-4c70-9058-eaf3a3794d4f/image.png" alt=""></p>
<p>I built flow rules by two ways.
one in reactive mode, where the SDN controller makes decision, and the other in proactive mode, where the user defines the rules manually.</p>
<hr>
<h2 id="4-installing-open-vswitch-on-raspberry-pi">4. Installing Open vSwitch on Raspberry Pi</h2>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/1e1fac1e-0c41-47bb-9e18-337ca362cd14/image.png" alt=""></p>
<p>Taking a step further, I installed Open vSwitch (OVS) on Raspberry Pi 3 devices to build a real-world SDN testbed.<br>After configuring the OVS bridges and ports, I connected the Raspberry Pi switches to ONOS as external network elements.</p>
<hr>
<h2 id="5-controlling-vlan-packets-with-onos">5. Controlling VLAN Packets with ONOS</h2>
<p>Using ONOS’s Flow Rule functionality, I wrote flow rules to control VLAN-tagged packets.<br>This included filtering specific VLAN IDs and forwarding traffic based on VLAN properties, allowing me to manage network segmentation dynamically.</p>
<hr>
<h2 id="6-visualizing-switch-traffic-in-real-time">6. Visualizing Switch Traffic in Real Time</h2>
<p>To monitor network performance, I visualized real-time switch port traffic data using ONOS metrics combined with open-source visualization tools.<br>This helped me gain insights into traffic patterns, detect bottlenecks, and understand SDN monitoring strategies.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/6d18eab6-66ea-447b-a5b8-2e5a3c90934d/image.png" alt=""></p>
<hr>
<h2 id="7-key-takeaways-and-reflections">7. Key Takeaways and Reflections</h2>
<p>Through these hands-on experiences, I learned:</p>
<ul>
<li>How SDN controllers like ONOS manage and orchestrate network flows.</li>
<li>Practical skills in setting up and controlling virtual and physical network devices.</li>
<li>The importance of real-time network visualization for operational insights.</li>
</ul>
<p>This project sparked my interest in developing custom ONOS applications and exploring deeper into SDN-enabled smart networking.</p>
<p>I look forward to continuing this journey by building my first ONOS application and contributing to open-source networking initiatives!</p>
<hr>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://onosproject.org/">ONOS Project Official Website</a></li>
<li><a href="https://wiki.onosproject.org/display/ONOS/Creating+Your+First+ONOS+Application">ONOS Wiki: Setting up Your First App</a></li>
</ul>
<hr>
<p>Thank you for reading! 🚀</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[백준 1929번 cpp로 풀기]]></title>
            <link>https://velog.io/@hyeondooori_/%EB%B0%B1%EC%A4%80-1929%EB%B2%88-cpp%EB%A1%9C-%ED%92%80%EA%B8%B0</link>
            <guid>https://velog.io/@hyeondooori_/%EB%B0%B1%EC%A4%80-1929%EB%B2%88-cpp%EB%A1%9C-%ED%92%80%EA%B8%B0</guid>
            <pubDate>Fri, 31 Jan 2025 07:40:23 GMT</pubDate>
            <description><![CDATA[<p>1929번 문제는 M이상 N이하의 소수를 빠르게 찾고 출력하는 문제이다.</p>
<p>이 문제는 에라토스테네스의 체 알고리즘을 이용하여 풀 수 있다.
먼저, <code>vector&lt;bool&gt; isPrime(N+1,true)</code>를 생성하여 모든 수를 소수로 가정한다.
그리고 2부터 N까지 배수를 제거하여 소수만 남기는 구조이다.
코드는 아래에 적어두었다.</p>
<blockquote>
<p><code>ios::sync_with_stdio(false);</code>와 <code>cin.tie(nullptr);</code>는 c++에서 입출력 성능을 최적화 하기 위한 코드이다.
1️⃣<code>ios::sync_with_stdio(false);</code></p>
</blockquote>
<ul>
<li>c++의 표준 입출력인 cin,cout과 c의 표준 입출력인 scanf,printf의 동기화를 해제하는 역할을 한다.</li>
<li>기존적으로 c++의 cin,cout은 c의 scanf,printf와 동기화되어 같이 사용될 때 정상적으로 동작하도록 보장된다.</li>
<li>하지만 동기화과정이 불필요한 경우 성능을 향상시킬 수 있다.</li>
<li>즉, sacnf,printf를 사용해야한다면 이 옵션은 쓰면 안된다.
2️⃣<code>cin.tie(nullptr);</code></li>
<li>기본적으로 cin과 cout은 연결(tie)되어있다.</li>
<li>즉, cin이 사용될 때마다 자동으로 cout이 flush(출력 버퍼 비움)이 되어 즉시 출력된다.
이를 해제(nullptr로 설정)하려면 불필요한 flush를 방지하여 입출력 속도를 높일 수 있다.</li>
</ul>
<pre><code>#include&lt;iostream&gt;
#include&lt;vector&gt;

using namespace std;

void sieve(int M, int N) {
    vector&lt;bool&gt; isPrime(N+1, true);
    isPrime[0] = isPrime[1] = false;

    //에라토스테네스의 체 알고리즘
    for(int i = 2; i*i &lt;=N; i++){
        if(isPrime[i]){
            for(int j = i * i; j&lt;= N; j+=i){
                isPrime[j] = false;
            }
        }
    }

    //결과 출력
    for(int i = M; i&lt;=N; i++){
        if(isPrime[i]){
            cout&lt;&lt;i&lt;&lt;&#39;\n&#39;;
        }
    }
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int M, N;
    cin &gt;&gt; M &gt;&gt; N;
    sieve(M, N);

    return 0;
}
</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[ONOS 실습] 오픈소스 sw 활용해 ONOS로 스위치 포트 별 송수신 트래픽처리량 정보 실시간 그래프로 출력하기]]></title>
            <link>https://velog.io/@hyeondooori_/ONOS-%EC%8B%A4%EC%8A%B5-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-sw-%ED%99%9C%EC%9A%A9%ED%95%B4-ONOS%EB%A1%9C-%EC%8A%A4%EC%9C%84%EC%B9%98-%ED%8F%AC%ED%8A%B8-%EB%B3%84-%EC%86%A1%EC%88%98%EC%8B%A0-%ED%8A%B8%EB%9E%98%ED%94%BD%EC%B2%98%EB%A6%AC%EB%9F%89-%EC%A0%95%EB%B3%B4-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EA%B7%B8%EB%9E%98%ED%94%84%EB%A1%9C-%EC%B6%9C%EB%A0%A5%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@hyeondooori_/ONOS-%EC%8B%A4%EC%8A%B5-%EC%98%A4%ED%94%88%EC%86%8C%EC%8A%A4-sw-%ED%99%9C%EC%9A%A9%ED%95%B4-ONOS%EB%A1%9C-%EC%8A%A4%EC%9C%84%EC%B9%98-%ED%8F%AC%ED%8A%B8-%EB%B3%84-%EC%86%A1%EC%88%98%EC%8B%A0-%ED%8A%B8%EB%9E%98%ED%94%BD%EC%B2%98%EB%A6%AC%EB%9F%89-%EC%A0%95%EB%B3%B4-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EA%B7%B8%EB%9E%98%ED%94%84%EB%A1%9C-%EC%B6%9C%EB%A0%A5%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 13 Jan 2025 01:15:12 GMT</pubDate>
            <description><![CDATA[<p>이 포스트는 링크된 블로그를 참고하여 작성되었습니다. -&gt; <a href="https://m.blog.naver.com/PostView.naver?blogId=love_tolty&amp;logNo=222646472327&amp;navType=by">CLICK!!</a>
<img src="https://velog.velcdn.com/images/hyeondooori_/post/6a186981-a4eb-4b9b-bb55-a6e3f56c9ff6/image.png" alt="">
사진 출처: 네이버 블로그-톨티의 공작소</p>
<p>이번에는 위 사진의 순서대로 실습을 진행해볼 것이다. 
실습의 편의성을 위해 모든 오픈소스 소프트웨어는 모두 하나의 PC에 설치된다.</p>
<h2 id="1️⃣-onos--mininet-구성">1️⃣ ONOS + Mininet 구성</h2>
<p>Mininet으로 가상 네트워크를 생성하여 SDN 제어기인 ONOS에 연결을 구성하는 방식이다.</p>
<p><a href="https://velog.io/@hyeondooori_/ONOS-2.7.0-Ubuntu-22.04%EC%9D%B4%EC%9A%A9%ED%95%B4-mininet-%EA%B0%80%EC%83%81-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%97%B0%EA%B2%B0">ONOS 2.7.0, Ubuntu 22.04이용해 mininet 가상 네트워크 연결
</a>
링크된 포스트와 동일하게 진행하면 된다.</p>
<p>위 포스팅대로 진행을 완료하면, 현재 ONOS와 연결된 Mininet기반 가상 네트워크 구조를 조회하면 다음과 같은 결과가 출력된다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/a20eaec1-943f-47ec-a5ec-eff2488907c1/image.png" alt="">
dpid가 &#39;0000000000000001&#39;인 OVS스위치(s1) 1대에 1번 포트(s1-eth1)과 2번 포트(s1-eth2)에 각각 호스트가 하나씩 연결된 것을 알 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/45c6845e-d395-4b1c-988c-8b9ddf179605/image.png" alt="">
구성정보를 표현한 그림이다.</p>
<h2 id="2️⃣-celery-구성">2️⃣ Celery 구성</h2>
<p>Celery로 OVS 포트 별 실시간 송/수신 트래픽 처리량 계산</p>
<blockquote>
<p>💡 Celery란 무엇인가?
Celery는 분산 작업 큐(distributed task queue)이다. python으로 작성되었으며, 비동기 작업 및 스케줄링에 널리 사용된다. 즉, 시간이 오래걸리는 작업을 백그라운드에서 실행할 수 있도록 도와주는 도구이다. 
이는 웹 애플리케이션의 응답 속도를 높이기 위해 많이 사용된다.</p>
</blockquote>
<p>Celery를 설치하는 가장 간단한 방법은 python라이브러리 설치패키지 매니저인 pip을 통해 설치하는 방법이다.
이를 위해 python3와 python3를 지원하는 pip을 pc에 설치해준다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/787e5e75-ed4d-4d37-8e3b-6d061484bcf5/image.png" alt="">
pip 설치가 완료되면 pip3를 이용하여 Celery를 설치해준다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/21581c54-3d5b-4d7b-abfc-07b90820b5e8/image.png" alt="">
pip3를 통해 설치된 python모듈을 조회하면 나는 5.4.0 버전이 조회된다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/3bb019dc-5171-47c6-9f43-773427037749/image.png" alt=""></p>
<p>Celery설치를 완료했다면, 이제 Celery 기본 Broker인 RabbitMQ를 아래와같이 설치해준다. </p>
<blockquote>
<p>비유를 들어 설명하자면, </p>
</blockquote>
<ul>
<li><code>Celery</code>는 &quot;심부름 명령을 내리는 사람&quot;이다. 심부름이 많을 때 효율적으로 각 심부름꾼(worker)에게 지시한다.</li>
<li><code>RabbitMQ</code>는 &quot;심부름 메모를 전달하는 게시판&quot;이다. 모든 심부름꾼은 게시판에서 심부름 내용을 확인하고 수행한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/49d4a52f-b072-4dfd-b5e0-d7ac5e9e386e/image.png" alt="">
설치가 다 되었다면 실행해준다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/25cccd94-bd2f-4c7b-8944-0614dfa9ce17/image.png" alt="">
이제는 <code>Broker(RabbitMQ)</code>를 통해 <code>Worker</code>에 분배 될 <code>Celery Task</code>를 생성하면 된다. 하지만, 그 전에 먼저 Celery Task에 반영해주기 위한, <strong>OVS 스위치 포트 별 트래픽 정보</strong>를 가지고 <strong>트래픽처리량</strong>을 계산하는 과정을 알아보아야 한다.</p>
<p>그래서 일단 REST API를 통해서 ONOS와 연결된 스위치 별 포트정보를 조회해서, 어떤 정보가 수집되는지 확인해볼 필요가 있다. 
이를 위해 우선 <code>cURL</code>, <code>jq</code>를 설치해주자.</p>
<ul>
<li><code>cURL</code>: HTTP 요청을 전송하는 도구</li>
<li><code>jq</code>: JSON 데이터를 읽기 쉽게 포맷하여 출력하는 도구
<img src="https://velog.velcdn.com/images/hyeondooori_/post/eaf40edd-c6bb-4417-b045-613f2fd9902d/image.png" alt="">
그런 다음 다음과같이 ONOS의 REST API를 이용해 Mininet으로 구성한 OVS 스위치 s1의 포트 별 트래픽 정보를 조회하는 스크립트 파일을 만들어준다. ONOS_IP는 본인의 IP로 바꾸어주어야 한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/44ab0073-e11d-4588-bfda-765dec97bc8c/image.png" alt=""></p>
<p>이 스크립트를 실행하면, ONOS와 연결된 스위치 별 포트 정보를 알 수 있다.
여기서는 OVS 스위치 s1의 1번포트와 2번포트에 대한 정보를 확인할 수 있다.
각 포트 별 bytesReceived, bytesSent, durationSec 값을 이용하면 스위치 포트 별 수신(RX), 송신(TX)패킷에 대한 트래픽처리량 계산이 가능하다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/b12990db-0728-4fd3-a418-2258007e8a4f/image.png" alt=""></p>
<blockquote>
<ul>
<li>bytesReceived() : 포트를 통해 수신받은 패킷의 누적 사이즈(단위: bytes)</li>
</ul>
</blockquote>
<ul>
<li>byteSent() : 포트를 통해 송신된 패킷의 누적 사이즈(단위:bytes)</li>
<li>durationSec() : 포트가 활성화된 시간(sec)
<img src="https://velog.velcdn.com/images/hyeondooori_/post/1b095e66-59fc-4272-8d97-35028ff3a9c1/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyeondooori_/post/d4ea3717-e6b4-43e3-b83d-9ab6ac67007c/image.png" alt=""></li>
</ul>
<p>여기까지 완료되었다면, 이제는 위의 송/수신 트래픽처리량(TX/RX Throughput)에 대한 공식을 가지고, 매 단위시간마다 트래픽 처리량 정보를 계산하는 Celery Task를 생성하겠다. Celery Task를 통하여 ONOS의 REST API를 호출하기 위하여, Python requests 모듈을 설치한다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/9fc10c87-47e0-42e9-9f74-3f6179e623c3/image.png" alt=""></p>
<p>처음 계산되는 송/수신 트래픽 처리량 값을 임시로 저장하기 위한 data디렉토리를 생성하고, 읽고 쓰기 권한을 아래와같이 변경해준다. 
(권한 변경 시 sudo 붙여주는 것을 잊지말자 ㅎㅎ)
<img src="https://velog.velcdn.com/images/hyeondooori_/post/6d59916b-72a6-41a9-823d-b4c493231522/image.png" alt=""></p>
<p>다음으로, 아래와같이 <code>task.py</code>라는 파일을 생성하여 ONOS_CELERY_TASK라는 celery task함수를 하나 작성한다. 
해당 task는 ONOS의 REST API를 통해 ONOS와 연결된 모든 스위치의 활서화된 포트 별 bytesSent,bytesReceived,durationSec값을 조회하고, 앞에서 도출한 공식 두 가지를 통해서 송수신 트래픽에 대한 처리량(TX/RX Throughput)을 계산한다. </p>
<pre><code>from celery import Celery
import json
import requests
import os

# Broker(RabbitMQ) 설정
app = Celery(&#39;ONOS_TASK&#39;, broker=&#39;pyamqp://guest@localhost//&#39;)

# celeryconfig.py에 정의한 Celery Task 환경설정 정보 가져오기
app.config_from_object(&#39;celeryconfig&#39;)

# 파일 저장 및 읽기 함수 정의
def FILE_WRITE(file_dir, data):
    with open(file_dir, &#39;w&#39;) as f:
        f.write(data)

def FILE_READ(file_dir):
    with open(file_dir, &#39;r&#39;) as f:
        return f.readline()

# Celery Task 함수 정의
@app.task
def ONOS_CELERY_TASK(CTRL_IP, CTRL_PORT, CTRL_ID, CTRL_PW):
    try:
        # ONOS REST API를 통해 스위치 정보 조회
        ONOS_URI = f&quot;http://{CTRL_IP}:{CTRL_PORT}/onos/v1/devices&quot;
        result = requests.get(ONOS_URI, auth=(CTRL_ID, CTRL_PW))
        DEVICE_INFO = result.json().get(&quot;devices&quot;, [])

        for device in DEVICE_INFO:
            SW_DPID = device[&quot;id&quot;]
            print(f&quot;Processing SW_DPID: {SW_DPID}&quot;)
            ONOS_URI_PORTS = f&quot;http://{CTRL_IP}:{CTRL_PORT}/onos/v1/statistics/ports&quot;
            result_ports = requests.get(ONOS_URI_PORTS, auth=(CTRL_ID, CTRL_PW))
            SW_STATE_INFO = result_ports.json().get(&quot;statistics&quot;, [])

            for state_info in SW_STATE_INFO:
                if state_info[&quot;device&quot;] == SW_DPID:
                    for port in state_info[&quot;ports&quot;]:
                        MATCH_PORT = port[&quot;port&quot;]
                        RX_BYTE_COUNT = port[&quot;bytesReceived&quot;]
                        TX_BYTE_COUNT = port[&quot;bytesSent&quot;]
                        DURATION_TIME = port[&quot;durationSec&quot;]

                        FILE_NAME = f&quot;dpid_{SW_DPID.replace(&#39;:&#39;, &#39;&#39;)}_port_{MATCH_PORT}&quot;
                        FILE_DIR = f&quot;./data/{FILE_NAME}.log&quot;

                        # 파일이 없으면 새로 생성
                        if not os.path.exists(FILE_DIR):
                            data = f&quot;{TX_BYTE_COUNT}_{RX_BYTE_COUNT}_{DURATION_TIME}&quot;
                            FILE_WRITE(FILE_DIR, data)
                        else:
                            # 파일이 있으면 데이터 읽기
                            DATA = FILE_READ(FILE_DIR).split(&quot;_&quot;)
                            PREVIOUS_TX_BYTE_COUNT = float(DATA[0])
                            PREVIOUS_RX_BYTE_COUNT = float(DATA[1])
                            PREVIOUS_DURATION_TIME = float(DATA[2])

                            CURRENT_TX_BYTE_COUNT = float(TX_BYTE_COUNT)
                            CURRENT_RX_BYTE_COUNT = float(RX_BYTE_COUNT)
                            CURRENT_DURATION_TIME = float(DURATION_TIME)

                            data = f&quot;{TX_BYTE_COUNT}_{RX_BYTE_COUNT}_{DURATION_TIME}&quot;
                            FILE_WRITE(FILE_DIR, data)

                            # Duration Time이 0일 경우 로그 출력
                            if CURRENT_DURATION_TIME - PREVIOUS_DURATION_TIME == 0:
                                print(f&quot;Duration time is 0 for Port No. {MATCH_PORT}. The SDN controller has not updated.&quot;)
                            else:
                                # Throughput 계산
                                TX_THROUGHPUT = (CURRENT_TX_BYTE_COUNT - PREVIOUS_TX_BYTE_COUNT) * 8 / (CURRENT_DURATION_TIME - PREVIOUS_DURATION_TIME)
                                RX_THROUGHPUT = (CURRENT_RX_BYTE_COUNT - PREVIOUS_RX_BYTE_COUNT) * 8 / (CURRENT_DURATION_TIME - PREVIOUS_DURATION_TIME)

                                TX_THROUGHPUT = round(TX_THROUGHPUT, 4)
                                RX_THROUGHPUT = round(RX_THROUGHPUT, 4)

                                print(f&quot;Port No. : {MATCH_PORT}&quot;)
                                print(f&quot;TX_THROUGHPUT = {TX_THROUGHPUT} bps&quot;)
                                print(f&quot;RX_THROUGHPUT = {RX_THROUGHPUT} bps&quot;)

    except Exception as error:
        print(f&quot;Error: {error}&quot;)
</code></pre><blockquote>
<p><code>CTRL</code>: Controller의 준말
<code>DPID</code>: (Device Port Identifier)
OpenFlow 프로토콜에서 각 스위치를 식별하기 위해 사용되는 64비트 길이의 식별자</p>
</blockquote>
<p>이어서, 앞에 작성한 task.py의 Celery Task함수 &quot;ONOS_CELERY_TASK&quot;가 매 3초마다 호출되도록 &quot;celeryconfig.py&quot;파일을 생성해, 스케줄링 내용을 아래와 같이 작성한다. 참고로, celery는 celery Beat이라는 스케줄러를 통해 Task들의 실행 순서를 결정할 수 있는데, 여기서 작성하는 &quot;celeryconfig.py&quot;파일 내용을 가지고 Celery Beat을 통해 Task가 매 3초간 수행되게 된다.</p>
<pre><code>from datetime import timedelta

# ONOS의 IP/Port/ID/PW 정의
CTRL_IP = &quot;192.168.224.128&quot;
CTRL_PORT = &quot;8101&quot;
CTRL_ID = &quot;karaf&quot;
CTRL_PW = &quot;karaf&quot;

# task.ONOS_CELERY_TASK(task.py 파일에 정의된 ONOS_CELERY_TASK)의 스케줄링 정보 정의
beat_schedule = {
    &#39;add-every-3-seconds&#39;: {
        &#39;task&#39;: &#39;task.ONOS_CELERY_TASK&#39;,
        &#39;schedule&#39;: timedelta(seconds=3),
        &#39;args&#39;: (CTRL_IP, CTRL_PORT, CTRL_ID, CTRL_PW)
    }
}

# 타임존 설정
timezone = &#39;UTC&#39;

broker_connection_retry_on_startup = True
</code></pre><hr>
<h3 id="💣트러블-슈팅1---broker_connection_retry_on_startup--true">💣트러블 슈팅(1) - broker_connection_retry_on_startup = True</h3>
<ul>
<li>이 옵션이 필요한 이유는 Celery워커 또는 비트 프로세스가 브로커(RabbitMQ, Redis 등)에 연결할 수 없을 때 자동으로 재시도하도록 설정하기 위함이다. </li>
<li>브로커 서비스가 아직 시작하지 않았거나, 일시적으로 응답하지 않을 수 있다. 위 옵션을 설정하면, 연결 실패 시 바로 종료되지 않고 일정 시간 동안 재시도를 시도한다.</li>
<li>🔍초기값이 False인 이유?
  초기 재시도 시 무한 루프처럼 실행되는 것을 방지하기 위해서이다.</li>
</ul>
<hr>
<p>해당 Task를 CeleryBeat를 통해 3초 단위로 실행할 것이므로 -B 옵션을 포함하여 아래와같은 명령어를 실행한다.</p>
<blockquote>
<p>Celery Beat: 주기적으로 작업을 스케줄링하여 실행할 수 있도록 해주는 Celery의 확장 패키지</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/73ee7109-584b-4e57-a144-434993187f31/image.png" alt="">
성공적으로 Celery가 실행되면 아래와같이 Celery서버의 로그화면이 출력되면서 &quot;task.py&quot;에서 작성했던 Celery Task함수 &quot;ONOS_CELERY_TASK&quot;가 호출되는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/e432c9b3-5e10-4d65-bc4e-db460e30b4fb/image.png" alt="">
그리고 매 3초마다 DPID가 of:0000000000000001인 OVS 스위치 s1의 현재 활성화된 포트 1번, 2번의 송/수신 트래픽 처리량(TX/RX Troughput)이 계산되어 출력되는 것을 알 수 있다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/f6a7e1ef-95f4-4e2f-995f-0040e0462a08/image.png" alt=""></p>
<hr>
<h3 id="💣트러블슈팅2">💣트러블슈팅(2)</h3>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/3399bb48-1a28-406b-864f-8d17942d9747/image.png" alt=""></p>
<blockquote>
<ol>
<li><strong>celerybeat-schedule 파일 문제</strong>
<code>celerybeat-schedule</code>파일은 Celery Beat가 작업 일정을 관리하기 위해 사용하는 데이터베이스 파일이다. 이 파일은 일정이 저장되어 있으므로, 비정상적인 종료나 파일 손상이 발생하면 Beat 프로세스가 해당 파일을 읽을 수 없어 오류를 발생시킨다.</li>
</ol>
<p>-&gt; 따라서, 파일이 손상된 경우에는 rm 명령어로 해당 파일을 지우고, 다시 Beat 프로세스를 실행해야한다.
2. <strong>-n 옵션을 통해 워커 이름을 실행 때마다 변경해주어야 한다.</strong>
Celery 워커는 이름을 기준으로 프로세스를 구분한다. 기본적으로 여러 개의 동일한 이름을 가진 워커가 동시에 실행되면, 충돌이 발생한다. 즉, PID가 겹치고 메시지 큐 관리에서 문제가 생기는 것이다.
같은 이름의 워커가 이미 실행 중이면 Address already in use 또는 Another worker with the same name exists 등의 오류가 발생할 수 있다.
-&gt; 즉, <code>-n</code>옵션을 사용해 각 워커가 고유한 이름을 가지도록 설정해야한다.</p>
</blockquote>
<hr>
<h2 id="3️⃣-influxdb-구성">3️⃣ InfluxDB 구성</h2>
<p>InfluxDB는 시계열 데이터를 저장하고 관리하는 오픈소스 데이터베이스이다. 주로 온도, 시간, 속도 등 시간별 수치로 측정될 수 있는 시계열 데이터에 특화되어있다. 그래서 이번엔 해당 InfluxDB에다가 앞서 Celery를 통해 매 3초마다 계산되는 송/수신 트래픽처리량(TX/RX Throughput) 정보를 주기적으로 한 번 저장해볼 것이다.</p>
<p>influxdb와 influxdb-client 패키지를 아래와 같이 설치한다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/2140fb68-53c8-4276-b8c9-904ac8f29b4d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/bab3b146-127d-428d-baa2-5efef61cc3a0/image.png" alt=""></p>
<ul>
<li>sudo service influxdb start: InfluxDB 실행</li>
<li>influx: influxDB CLI환경으로 접속됨. 버전 확인도 가능</li>
<li>트래픽처리량을 저장하기위한 sdn이라는 DB 생성, 확인, influxDB CLI종료</li>
</ul>
<p>그리고, 생성한 데이터베이스 sdn에 매 3초간 Celery Task로 계산되는 송/수신 트래픽처리량을 저장하기 위해 먼저 InfluxDB의 Python패키지를 아래와같이 설치한다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/2d150ef9-450a-4e3f-b4f7-4841c4cd84ef/image.png" alt=""></p>
<p>그리고, task.py파일을 열어서, 최상단에는 InfluxDBClient라는 파이썬 모듈을 가져와 InfluxDB에 데이터를 저장해주기 위한 함수 <code>InfluxDB_InsertData()</code>를 아래와같이 저장해주고, 송/수신 트래픽처리량을 계산하는 소스코드 아래에 계산된 트래픽처리량을 InfluxDB에 저장할 수 있도록 위쪽과 들여쓰기를 맞추어서 InfluxDB_InsertData()를 호출하는 코드를 추가해준다.</p>
<pre><code>from celery import Celery
import json
import requests
import os
from influxdb import InfluxDBClient  # InfluxDB 라이브러리 import

# Broker(RabbitMQ) 설정
app = Celery(&#39;ONOS_TASK&#39;, broker=&#39;pyamqp://guest@localhost//&#39;)

# celeryconfig.py에 정의한 Celery Task 환경설정 정보 가져오기
app.config_from_object(&#39;celeryconfig&#39;)

# InfluxDB에 데이터 저장 함수 정의
def InfluxDB_InsertData(dpid, port_no, tx_throughput, rx_throughput):
    DB_HOST = &quot;192.168.224.128&quot;  # InfluxDB 호스트 IP
    DB_PORT = &quot;8086&quot;         # InfluxDB 포트
    DB_USER = &quot;root&quot;         # InfluxDB 사용자 ID
    DB_PSWD = &quot;root&quot;         # InfluxDB 비밀번호
    DB_NAME = &quot;sdn&quot;          # InfluxDB 데이터베이스 이름

    json_body = [{
        &quot;measurement&quot;: dpid,
        &quot;tags&quot;: {
            &quot;port&quot;: port_no
        },
        &quot;fields&quot;: {
            &quot;tx_throughput&quot;: tx_throughput,
            &quot;rx_throughput&quot;: rx_throughput
        }
    }]

    client = InfluxDBClient(DB_HOST, DB_PORT, DB_USER, DB_PSWD, DB_NAME)
    print(f&quot;Write points: {json_body}&quot;)
    client.write_points(json_body)

# ONOS REST API를 통해 조회된 정보를 파일로 저장하기 위한 함수 정의
def FILE_WRITE(file_dir, data):
    with open(file_dir, &#39;w&#39;) as f:
        f.write(data)

# 파일로 저장된, ONOS REST API를 통해 조회된 정보를 읽어오기 위한 함수 정의
def FILE_READ(file_dir):
    with open(file_dir, &#39;r&#39;) as f:
        data = f.readline()
    return data

# Celery를 통해 실행시켜줄 ONOS TASK &quot;ONOS_CELERY_TASK&quot; 함수 정의
@app.task
def ONOS_CELERY_TASK(CTRL_IP, CTRL_PORT, CTRL_ID, CTRL_PW):
    try:
        ONOS_URI = f&quot;http://{CTRL_IP}:{CTRL_PORT}/onos/v1/devices&quot;
        result = requests.get(ONOS_URI, auth=(CTRL_ID, CTRL_PW))
        DEVICE_INFO = result.json()[&quot;devices&quot;]

        # ONOS REST API를 통해 스위치별 Port 상태 조회
        for device in DEVICE_INFO:
            SW_DPID = device[&quot;id&quot;]
            print(f&quot;SW_DPID: {SW_DPID}&quot;)
            ONOS_URI = f&quot;http://{CTRL_IP}:{CTRL_PORT}/onos/v1/statistics/ports&quot;
            result = requests.get(ONOS_URI, auth=(CTRL_ID, CTRL_PW))
            SW_STATE_INFO = result.json()[&quot;statistics&quot;]

            for state in SW_STATE_INFO:
                if state[&quot;device&quot;] == SW_DPID:
                    for port in state[&quot;ports&quot;]:
                        MATCH_PORT = port[&quot;port&quot;]
                        RX_BYTE_COUNT = port[&quot;bytesReceived&quot;]
                        TX_BYTE_COUNT = port[&quot;bytesSent&quot;]
                        DURATION_TIME = port[&quot;durationSec&quot;]
                        FILE_NAME = f&quot;dpid_{SW_DPID.replace(&#39;:&#39;, &#39;&#39;)}_port_{MATCH_PORT}&quot;
                        FILE_DIR = f&quot;./data/{FILE_NAME}.log&quot;

                        if not os.path.exists(FILE_DIR):
                            data = f&quot;{TX_BYTE_COUNT}_{RX_BYTE_COUNT}_{DURATION_TIME}&quot;
                            FILE_WRITE(FILE_DIR, data)
                        else:
                            DATA = FILE_READ(FILE_DIR).split(&quot;_&quot;)
                            PREVIOUS_TX_BYTE_COUNT = float(DATA[0])
                            PREVIOUS_RX_BYTE_COUNT = float(DATA[1])
                            PREVIOUS_DURATION_TIME = float(DATA[2])

                            CURRENT_TX_BYTE_COUNT = float(TX_BYTE_COUNT)
                            CURRENT_RX_BYTE_COUNT = float(RX_BYTE_COUNT)
                            CURRENT_DURATION_TIME = float(DURATION_TIME)

                            data = f&quot;{TX_BYTE_COUNT}_{RX_BYTE_COUNT}_{DURATION_TIME}&quot;
                            FILE_WRITE(FILE_DIR, data)

                            if CURRENT_DURATION_TIME - PREVIOUS_DURATION_TIME == 0:
                                print(f&quot;The SDN controller has not updated The Port No. {MATCH_PORT} State!!&quot;)
                            else:
                                TX_THROUGHPUT = (CURRENT_TX_BYTE_COUNT - PREVIOUS_TX_BYTE_COUNT) * 8 / (
                                    CURRENT_DURATION_TIME - PREVIOUS_DURATION_TIME)
                                RX_THROUGHPUT = (CURRENT_RX_BYTE_COUNT - PREVIOUS_RX_BYTE_COUNT) * 8 / (
                                    CURRENT_DURATION_TIME - PREVIOUS_DURATION_TIME)

                                TX_THROUGHPUT = round(TX_THROUGHPUT, 4)
                                RX_THROUGHPUT = round(RX_THROUGHPUT, 4)

                                print(f&quot;Port No. : {MATCH_PORT}&quot;)
                                print(f&quot;TX_THROUGHPUT = {TX_THROUGHPUT} bps&quot;)
                                print(f&quot;RX_THROUGHPUT = {RX_THROUGHPUT} bps&quot;)

                                # InfluxDB에 트래픽 처리량 저장
                                InfluxDB_InsertData(SW_DPID, MATCH_PORT, TX_THROUGHPUT, RX_THROUGHPUT)

    except Exception as error:
        print(&quot;Error:&quot;, error)
</code></pre><p>이렇게 수정한 task.py파일이 정상적으로 동작하는지 Celery서버를 실행한다.
아래 사진과같이 정상적으로 실행되었다면, 로그 화면에서 influxDB에 저장되는 데이터 정보를 확인할 수 있다. 
로그 내용을 살펴보면 DPID가 of:0000000000000001인 OVS스위치의 현재 활성화된 1번,2번 포트의 송수신 트래픽 처리량 값이 계산되어 JSON형식으로 influxDB에 전달되는 것을 알 수 있다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/bcab68e8-d131-44b0-ab72-ae8a79ae5ec9/image.png" alt=""></p>
<p>이제는 InfluxDB에 실제로 데이터가 저장되었는지 확인해보자. InfluxDB CLI에 접속하여 앞서 생성했던 &quot;sdn&quot; DB를 선택한다.
그리고 select문을 통해 저장된 데이터를 조회해보면, 시간대별로 Celery Task가 실행되면서 저장된 스위치 포트별 송/수신 트래픽 처리량 데이터 정보를 확인할 수 있다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/216a1c51-9b0c-43b7-bbc3-4d575afb6e4d/image.png" alt=""></p>
<hr>
<h2 id="4️⃣-grafana-구성">4️⃣ Grafana 구성</h2>
<p>지금까지 Celery Task를 통해 송수신 트래픽처리량도 계산했고, 이를 InfluxDB라는 시계열 데이터베이스에 저장도 했다. </p>
<p>이번에는 Grafana라는 데이터 시각화 도구를 통해 InfluxDB에 저장된 트래픽처리량 데이터를 실시간 그래프로 출력하는 과정이다.</p>
<p>먼저, grafana를 다운받아야한다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/7cc486e8-30bb-4c47-a9d7-dac4e15f5fc0/image.png" alt="">
위 사진의 url로 접근하여, Ubuntu and Debien에서 grafana 다운로드 부분을 복사한다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/f7b53a0a-605e-485b-b717-ea50ac70e05b/image.png" alt="">
복사해온 명령어를 실행시켜주면 된다. 위의 wget은 설치파일을 받아오는 것이다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/6b527eff-73a1-4511-8a15-6fe0ee4e755c/image.png" alt="">
이제 받은 파일을 가지고 Grafana를 PC에 설치해준다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/9b39e15d-9ffd-4d19-a285-b71b64fc1faf/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/3e11c15a-bb76-4930-a677-7fc3b7220523/image.png" alt="">
grafana를 실행한 뒤 Grafana UI(192.168.224.128:3000)에 접속하자. 위 사진처럼 로그인 화면이 뜨면 성공이다.</p>
<p>다음으로는 Grafana와 InfluxDB를 연동해야한다. 
로그인화면에서는 초기아이디,패스워드인 admin/admin을 입력하고 로그인한다.
influxDB로 data source를 만들어주면 된다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/5522d453-5eb7-4216-9054-d0d48f7460bc/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/f813ffc8-4184-4770-a339-af6cdecdf769/image.png" alt="">
save&amp;test 버튼을 누른다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/7619d021-dab8-4d7d-80c8-5caa40a9e72d/image.png" alt=""></p>
<p>Mininet CLI로 접속해서 h1에서 h2로 ping메시지를 보낸다.</p>
<hr>
<h3 id="💣-트러블슈팅3">💣 트러블슈팅(3)</h3>
<ol>
<li>Mininet은 ~(root)에서 접속해야만한다.
예를들어, onos디렉토리 안에서는 접근하려하면 오류가 발생한다.</li>
<li>실행했던 Mininet에서 exit하면, 꼭 <code>sudo mn --clear</code> 명령어를 통해 지워주어야한다.
지워주지 않으면 다시 Mininet CLI로 접속이 되지 않는다. </li>
</ol>
<hr>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/cd24ac5a-6ad7-4be1-99aa-32d5eaf44587/image.png" alt="">
그리고, 다른 터미널에서 ping 정보를 DB에 저장해주어야 grafana에 그래프로 반영이 된다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/02204098-da3a-4309-8330-b8a76d1b3142/image.png" alt="">
기존에 만들어두었던 것을 실행해주면 된다.
그래프 설정을 아래 사진을 쭉 따라하면 된다.
대시보드와 그래프의 이름은 원하는 것으로 바꾸어도 좋다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/bdda0e83-7154-48e7-a405-5ef485e19a55/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyeondooori_/post/faecf20d-aaac-442a-a22b-a542fe26ddd0/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyeondooori_/post/df2d9e89-59e7-43a3-835b-018a5c84aea1/image.png" alt="">
Grafana의 Dashboard를 확인해보면, 현재 가상 호스트 h1에서 h2로 트래픽이 흐르면서 가상 호스트와 연결된 OVS 스위치 1번 포트의 수신 트래픽처리량(RX Throughput)은 약 1.3kbps, 송신 트래픽처리량(TX Throughput)은 약 0.7kbps인 것을 확인할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[ONOS 실습] 라즈베리파이3에 OVS 설치 후 네트워크 구성해보기/ ONOS로 flow rule 설정하여 VLAN 패킷제어]]></title>
            <link>https://velog.io/@hyeondooori_/%EB%9D%BC%EC%A6%88%EB%B2%A0%EB%A6%AC%ED%8C%8C%EC%9D%B43%EC%97%90-OVS-%EC%84%A4%EC%B9%98-%ED%9B%84-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B5%AC%EC%84%B1%ED%95%B4%EB%B3%B4%EA%B8%B0-ONOS%EB%A1%9C-flow-rule-%EC%84%A4%EC%A0%95%ED%95%98%EC%97%AC-VLAN-%ED%8C%A8%ED%82%B7%EC%A0%9C%EC%96%B4</link>
            <guid>https://velog.io/@hyeondooori_/%EB%9D%BC%EC%A6%88%EB%B2%A0%EB%A6%AC%ED%8C%8C%EC%9D%B43%EC%97%90-OVS-%EC%84%A4%EC%B9%98-%ED%9B%84-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B5%AC%EC%84%B1%ED%95%B4%EB%B3%B4%EA%B8%B0-ONOS%EB%A1%9C-flow-rule-%EC%84%A4%EC%A0%95%ED%95%98%EC%97%AC-VLAN-%ED%8C%A8%ED%82%B7%EC%A0%9C%EC%96%B4</guid>
            <pubDate>Mon, 13 Jan 2025 01:09:41 GMT</pubDate>
            <description><![CDATA[<p>이 포스트는 아래 링크를 참고하여 작성했다.
<a href="https://blog.naver.com/love_tolty/222609033937">라즈베리파이3에 Open vSwitch(OVS) 설치/OVS 네트워크 구성/ONOS로 직접 Flow Rule 설정하여 VLAN 패킷 제어하기</a>
첨부된 링크와 현재 포스트에는 변경된 사항이 있어, 실습을 해보시려면 위의 링크된 블로그와 이 포스트를 비교해가며 쭉 따라해보면 좋을 것 같다.</p>
<blockquote>
<p>이 실습은 <code>ununtu 22.04버전</code>, <code>ONOS 2.7.0 버전</code>이 필요하다.
vmware와 Ubuntu설치는 검색해보면 자세히 정리된 블로그들이 많다.
onos 설치과정은 아래 블로그에 적혀있으니 그대로 따라하면 된다.
<a href="https://velog.io/@hyeondooori_/ONOS-2.7.0-Ubuntu-22.04%EC%9D%B4%EC%9A%A9%ED%95%B4-mininet-%EA%B0%80%EC%83%81-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%97%B0%EA%B2%B0">ONOS 2.7.0, Ubuntu 22.04이용해 mininet 가상 네트워크 연결</a></p>
</blockquote>
<p>위의 포스팅에서는 Mininet기반의 가상 네트워크 환경을 구축하여 Host간 통신이 되도록 flow rule을 reactive, proactive 방식으로 각각 설정해보았다. 
이번에는 가상으로만 패킷제어를 해보는 방식이 아닌, 물리적 장치인 라즈베리파이3를 추가하여 진행한다. 라즈베리파이3에 OVS를 설치하여 하나의 SDN스위치를 만들고 여기에 연결된 단말 장치 간 VLAN패킷을 주고받을 수 있도록  ONOS로 flow rule을 설정해보는 것이다. </p>
<p>자 이제 실습을 시작해보자!!</p>
<blockquote>
<p>준비물
<img src="https://velog.velcdn.com/images/hyeondooori_/post/09f4e616-8857-428a-9692-86e10dc0ccf4/image.png" alt=""></p>
</blockquote>
<p>참고로, 나는 라즈베리파이의 usb 단자와 노트북을 연결하기 위해 usb 이더넷 어댑터를 산 것이다. 사진에는 두 개 라고 나와있지만, 이번 실습에서는 <mark>하나는 물리적</mark>으로, <mark>하나는 가상</mark>으로 구성하여 실습을 진행할 것이다.
최근 노트북은 대부분 c타입으로 연결하기 때문에 <code>usb to c 선 1개</code> 가 있으면 될 것 같다!</p>
<h2 id="1️⃣-onos-환경-구축">1️⃣ ONOS 환경 구축</h2>
<p>먼저, 실습을 진행할 컴퓨터를 준비한다.
아래 조건을 만족해야한다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/62007576-33eb-4ebe-ae20-cd8e22b7f818/image.png" alt=""></p>
<p>나의 경우 컴퓨터와 유무선공유기 사이에 인터넷 선을 연결하여 인터넷에 연결했다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/2ce53998-4aba-460c-8344-175461bda3bc/image.png" alt="">
pc의 IP주소는 아래 사진처럼 확인할 수 있다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/c20ed1cf-e0e4-4c0c-b7b4-ae7c010a082c/image.png" alt=""></p>
<p>선행 post부터 쭉 따라했다면, ONOS가 잘 설치되어있을 것이다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/57c30626-1676-4c3b-a146-4f320e05ebbd/image.png" alt="">
확인한 pc주소를 넣어 명령어를 실행하면 ONOS CLI 접속이 잘 될 것이다. 
초기 비밀번호는 karaf이다. 
다시 기본 터미널로 나오고싶다면, ctrl+D버튼을 누르면 된다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/41bf724c-412e-4621-b844-b34a3561f316/image.png" alt="">
SDN의 openflow프로토콜을 지원하는 ONOS의 애플리케이션인 org.onosproject.openflow를 활성화시킨다.</p>
<hr>
<h3 id="💣-트러블슈팅">💣 트러블슈팅</h3>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/cf950b85-f2ef-4b68-a252-01702052c260/image.png" alt="">
명령어를 올바르게 작성했는데 이런 오류가 나온다면, SSH 호스트 키가 변경되어 발생하는 문제이다. 이를 해결하기 위해 known_hosts파일에 저장된 이전 키를 삭제해야한다.</p>
<pre><code>ssh-keygen -f &quot;/home/hyeonseo/.ssh/known_hosts&quot; -R &quot;[192.168.224.128]:8101&quot;</code></pre><p>이 명령어를 통해 키를 제거한 후 다시 SSH 연결을 시도하면 잘 된다.</p>
<hr>
<h2 id="2️⃣-라즈베리파이-연결">2️⃣ 라즈베리파이 연결</h2>
<p>이제 라즈베리파이에 ubuntu를 설치해주어야 하는데, 이 과정에서는 <code>micro sd카드</code>와 <code>micro sd카드 리더기</code>가 필요하다.</p>
<p>raspberry pi imager를 다운로드 한다.
raspberry pi imager를 설치한 후 실행해 다음과같이 선택해준다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/cd6e1a4d-32b1-46c5-acd2-400e8f2751c9/image.png" alt=""></p>
<p>저장소는 micro sd카드를 micro sd card 리더기에 연결하여 pc 본체에 연결해주면 저장소 선택이 된다.
운영체제는 기존 컴퓨터에 설치된 버전과 맞춰주면 된다. 나는 22.04버전이 기존 컴퓨터에 설치되어있어서 동일하게 22.04버전으로 선택했다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/e93622ac-9270-4192-8d1e-7f3220dfe7af/image.png" alt="">
세부 설정을 이렇게 바꾸어주어야한다.</p>
<p>그런 후, 완료되었다고 나오면, sd카드를 raspberrypi에 연결해준다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/286a8b64-8ee9-4744-8266-e8318d0fa6cf/image.png" alt="">
연결 후 전원 케이블을 뺏다 꽂아주면 초록 불이 깜빡거리며 바로 설치가 된다. </p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/cba6a050-2bec-4c5a-8917-54b186ae6f9e/image.png" alt="">
내부 네트워크설정으로 들어가 스크롤을 내려보면,
<img src="https://velog.velcdn.com/images/hyeondooori_/post/2ceac169-004c-4fc8-a939-282ffc248472/image.png" alt="">
이렇게 자동할당 되어있는 raspberrypi의 ip주소를 확인할 수 있다. </p>
<p>그리고 putty를 설치해야한다.
동일하게 &quot;putty&quot;를 검색하여 최상단에 나오는 것을 다운로드 받은 후 실행하면 된다. 참고로, sd카드를 라즈베리파이에 삽입한 후 putty에 접속되기까지는 1-2분의 시간이 소요된다!
<img src="https://velog.velcdn.com/images/hyeondooori_/post/bd97931f-2670-447f-aa65-9b5b06fd80c6/image.png" alt="">
hostname 부분에 아까 확인한 raspberrypi의 ip주소를 넣어준 후, open한다.
accept를 하고, imager의 추가 설정에서 설정했던대로 로그인 해주면 된다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/c9b35fe1-9ac6-4673-a8eb-f92d3228c768/image.png" alt="">
ubuntu로 로그인이 잘 되었다.</p>
<hr>
<h2 id="3️⃣-물리-연결-host에-vlan-설정하기">3️⃣ 물리 연결 Host에 VLAN 설정하기</h2>
<p>이번에는 아래와 같이 라즈베리파이에 연결된 usb이더넷 어댑터에 연결되어있는 Host의 vlan을 설정해줄 것이다.</p>
<p>이를 위해서는 호스트에 vmware를 설치하고 ubuntu 22.04LTS 버전을 실행시켜주어야 한다.
ubuntu 설치가 완료되었다면, Host1(노트북)의 ubuntu에 접근하여 라즈베리파이와 연결된 이더넷 장치를 확인한 후 vlan 설정을 해줄 것이다.
먼저 vlan을 설치하고, 802.1q모듈을 커널에 올린다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/99ac776b-18ae-4ff1-bb22-1204304061b0/image.png" alt="">
먼저, 연결된 이더넷의 이름을 확인하기 위해 ip a 명령어를 이용한다.
여기에선 3번에 이더넷이 보인다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/ff3fc054-be11-4ebb-8208-23ae79070ca7/image.png" alt="">
그리곤, vlan을 생성해준다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/95e6f138-8716-40f1-a152-0bb294e15789/image.png" alt="">
다시 ip a를 해보면 vlan이 확인된다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/2160fcec-74d3-478a-a5d1-488c9123e873/image.png" alt=""></p>
<p>물리 연결 Host의 인터페이스 vlan10을 조회해보면 아래와같이 ip주소가 20.0.0.1/24로 할당된 것을 확인할 수 있다.
라우팅 테이블을 확인해보았을 때도 20.0.0.0/24대역의 패킷을 보낼 때 vlan10 인터페이스를 이용하는 것을 확인할 수 있다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/4c898d79-127e-455a-b2b4-36cd070e37b0/image.png" alt=""></p>
<hr>
<h2 id="4️⃣-라즈베리파이-ovs-네트워크-구성하기">4️⃣ 라즈베리파이 OVS 네트워크 구성하기</h2>
<p>일단 라즈베리파이에 Open vSwitch(이하 OVS)를 설치해준다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/48786810-1818-4827-98a9-4d593f585089/image.png" alt="">
설치가 성공적으로 완료되었다면 아래와같이 OVS 설치버전이 조회된다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/9d62ecd4-5444-410f-b837-1502463237e6/image.png" alt=""></p>
<p>br-int라는 브릿지를 하나 생성하자.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/6d515add-aa1b-4409-ad1b-12b240573746/image.png" alt=""></p>
<p>내가 사용하는 ONOS 2.7.0버전은 OpenFlow 1.0 및 1.3을 지원한다고 한다. 따라서, 해당 버전을 사용하도록 설정해준다. 이 설정을 하지 않을 경우 ONOS가 지원하는 OpenFlow버전과 일치하지 않아, OVS 브릿지 &#39;br-int&#39;가 ONOS와 연결 수립이 되지 않을 수 있다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/f8f999d6-4272-484c-9e74-31077bd66b2a/image.png" alt=""></p>
<p>이렇게 생성됭 OVS 브릿지 br-int의 상태를 조회해보면 아래와 같이 br-int란 브릿지에 br-int라는 포트가 현재 생성된 상태임을 알 수 있다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/403659fc-13a0-4bb0-8c32-f73706991219/image.png" alt=""></p>
<p>다음으로 생성한 br-int 브릿지에 포트를 생성해 라즈베리파이의 이더넷 포트(<code>enxc84d4420046d</code>)를 연결해준다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/0a569377-108c-4f77-9366-660885378a1a/image.png" alt=""></p>
<p>이더넷 포트의 이름은 <code>ip a</code>명령어를 통해 확인했다.(2번에 이더넷 포트가 쓰여있다.)
<img src="https://velog.velcdn.com/images/hyeondooori_/post/0a4648cb-7798-41c3-a2e2-c8c39dec8579/image.png" alt=""></p>
<hr>
<h2 id="5️⃣-ovs-브릿지에-onos-연결하기">5️⃣ OVS 브릿지에 ONOS 연결하기</h2>
<p>ONOS가 OVS 브릿지 br-int에 연결되기 위해서는 ONOS와 OVS 브릿지 br-int가 동일한 네트워크 대역으로 묶여 서로 통신을 할 수 있어야 한다.
이를 위해 라즈베리파이에 접속하여 <code>/etc/netplan</code> 내부의 파일을 확인한다.
나는 50-cloud-init.yaml파일이 존재했다. 이는 다를 수 있으니 직접 확인해보아야 한다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/15d1ff44-1dd2-47fe-846b-1dd10c67fa41/image.png" alt="">
그리고, 이 파일을 다음과같이 수정해주어야 한다. 이 또한 라즈베리파이의 ip주소에 따라 다르니, 본인의 상황에 맞는 ip주소,대역 설정을 해주어야 한다.</p>
<pre><code>network:
  version: 2
  renderer: networkd
  wifis:
    wlan0:
      dhcp4: no
      addresses:
        - 192.168.0.137/24  # 실제 무선 네트워크 IP
      gateway4: 192.168.0.1  # 공유기/라우터의 게이트웨이 IP
      nameservers:
        addresses:
          - 8.8.8.8  # Google DNS
      access-points:
        &#39;Wi-Fi SSID&#39;:  
          password: &quot;네트워크 비밀번호&quot;

  bridges:
    br-int:
      interfaces:
        - wlan0  # 무선 네트워크 인터페이스를 브릿지에 연결
      dhcp4: no
      addresses:
        - 192.168.0.200/24  # 브릿지용 고정 IP (무선 네트워크와 같은 대역)
      gateway4: 192.168.0.1  # 공유기/라우터의 게이트웨이
      nameservers:
        addresses:
          - 8.8.8.8
</code></pre><ul>
<li>브릿지용 고정 IP는 처음에 확인했듯이 사용중인 IP주소 정보를 보고, 사용하지 않는 IP로 해주어야 한다. 나의 경우 200은 사용중이지 않다.
  <img src="https://velog.velcdn.com/images/hyeondooori_/post/6ae09315-fde6-40ed-a063-d2d30470a294/image.png" alt=""></li>
<li>공유기/라우터의 게이트 웨이 IP를 확인하는 방법은 다음과 같다. default에 쓰여있는 것이 게이트웨이 ip이다.
  <img src="https://velog.velcdn.com/images/hyeondooori_/post/c7809c4b-cd64-44f6-a24a-1977036ef4b8/image.png" alt=""></li>
</ul>
<hr>
<p>이렇게 netplan내부의 파일을 수정한 후 적용해준다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/697d002b-d239-4cdb-b918-7e77751e1419/image.png" alt=""></p>
<hr>
<p>지난번에 설치했던 onos를 실행해줄 것이다.
먼저, bazel을 이용해 onos를 실행해주어야 한다. 시간이 조금 걸리니, 마음을 편히 먹고 기다리자.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/c093528a-dc00-4eb3-aab2-f7964863d1cd/image.png" alt="">
이렇게하면, 8101포트가 활성화된다. 
다른 터미널을 열어서 다음과같이 확인할 수 있다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/1fe010cf-6cca-48ee-98ab-f1769dd48751/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyeondooori_/post/c117017e-c0f4-4095-83c6-08b7a5dfefe7/image.png" alt="">
접속이 되었다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/6db19828-c3a7-46b3-bc1e-3f8bd6299366/image.png" alt=""></p>
<p>이후, ONOS GUI에 접근한다.
접근 주소는 다음과 같다.</p>
<blockquote>
<p><code>http://{ONOS 설치 IP주소}:8181/onos/ui/index.html</code></p>
</blockquote>
<h2 id="6️⃣-가상-host-설정하기">6️⃣ 가상 HOST 설정하기</h2>
<p>미니넷을 설치하여 접속한다.
<code>sudo mn --controller=remote,ip=192.168.224.128,port=6653 --switch=ovs,protocols=OpenFlow13</code>
이 명령어를 통해 Mininet CLI로 접근할 수 있다.
참고로, 이 CLI에서 나오려면 <code>exit</code>을 치면 된다.
이번 실습에서는 host 하나만 필요하지만, 미니넷에서는 호스트 하나만 만드는게 불가하다. 컨트롤러와 스위치도 함께 만들어진다.</p>
<p>이제는 물리연결 HOST와의 ping을 위해 미니넷 호스트를 설정해야하는데, 
sudo ovs-vsctl set port h1-eth0 tag=10으로 한다.</p>
<h2 id="7️⃣-flow-rule-설정하기">7️⃣ Flow Rule 설정하기</h2>
<p>현재 상태를 보면 라즈베리파이에서 br-int 브릿지가 있고, Mininet에 가상 스위치 s1과 가상 호스트 h1(10.0.0.1) 및 h2(10.0.0.2)가 생성된 상태이다. 또한 br-int에 실제 물리 네트워크 인터페이스가 연결되어 있다.</p>
<p>이제 호스트 간 ping 테스트를 위한 플로우 엔트리 설정을 위해 ONOS에 JSON 파일을 작성하여 플로우 규칙을 추가해야 한다.
<code>flow_h1_to_host2.json</code></p>
<pre><code>{
  &quot;flows&quot;: [
    {
      &quot;priority&quot;: 40000,
      &quot;timeout&quot;: 0,
      &quot;isPermanent&quot;: true,
      &quot;deviceId&quot;: &quot;of:0000000000000001&quot;,
      &quot;treatment&quot;: {
        &quot;instructions&quot;: [
          {
            &quot;type&quot;: &quot;L2MODIFICATION&quot;,
            &quot;subtype&quot;: &quot;VLAN_PUSH&quot;,
            &quot;vlanId&quot;: 10
          },
          {
            &quot;type&quot;: &quot;OUTPUT&quot;,
            &quot;port&quot;: &quot;enxc84d4420046d&quot;
          }
        ]
      },
      &quot;selector&quot;: {
        &quot;criteria&quot;: [
          {
            &quot;type&quot;: &quot;ETH_TYPE&quot;,
            &quot;ethType&quot;: &quot;0x0800&quot;
          },
          {
            &quot;type&quot;: &quot;VLAN_VID&quot;,
            &quot;vlanId&quot;: 10
          },
          {
            &quot;type&quot;: &quot;IPV4_SRC&quot;,
            &quot;ip&quot;: &quot;10.0.0.1/32&quot;
          },
          {
            &quot;type&quot;: &quot;IPV4_DST&quot;,
            &quot;ip&quot;: &quot;20.0.0.1/32&quot;
          }
        ]
      }
    },
    {
      &quot;priority&quot;: 40000,
      &quot;timeout&quot;: 0,
      &quot;isPermanent&quot;: true,
      &quot;deviceId&quot;: &quot;of:0000000000000001&quot;,
      &quot;treatment&quot;: {
        &quot;instructions&quot;: [
          {
            &quot;type&quot;: &quot;OUTPUT&quot;,
            &quot;port&quot;: &quot;s1-eth1&quot;
          }
        ]
      },
      &quot;selector&quot;: {
        &quot;criteria&quot;: [
          {
            &quot;type&quot;: &quot;ETH_TYPE&quot;,
            &quot;ethType&quot;: &quot;0x0800&quot;
          },
          {
            &quot;type&quot;: &quot;VLAN_VID&quot;,
            &quot;vlanId&quot;: 10
          },
          {
            &quot;type&quot;: &quot;IPV4_SRC&quot;,
            &quot;ip&quot;: &quot;20.0.0.1/32&quot;
          },
          {
            &quot;type&quot;: &quot;IPV4_DST&quot;,
            &quot;ip&quot;: &quot;10.0.0.1/32&quot;
          }
        ]
      }
    }
  ]
}</code></pre><blockquote>
<p>deviceId: br-int 브릿지의 OpenFlow ID (onos&gt; devices 명령어로 확인 가능).
port:
h1 -&gt; br-int → Host2: 물리 포트(enxc8...)로 출력.
Host2 -&gt; br-int → h1: 가상 포트(s1-eth1 등)로 출력.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/6ad1a1d0-4194-4e95-9ae6-6a07d75bf31c/image.png" alt="">
그리고 위에 작성한 json파일을 적용하는 명령어이다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/5ca29007-99a4-424a-ba98-59c91cbf54cf/image.png" alt="">
onos에서 flows를 쳤을 때, br-int OpenFlowId부분을 확인해보면, priorty, deviceId, selector 등의 정보가 등록된 것을 확인할 수 있다.</p>
<hr>
<h3 id="❓-vlan을-사용하는-이유">❓ VLAN을 사용하는 이유?</h3>
<blockquote>
<p>VLAN(Virtual Local Area Network)
하나의 물리적 네트워크를 여러개의 가상 네트워크로 나누는 기술이다.
장비에 VLAN태그를 추가하여 특정 네트워크 트래픽을 격리하거나 구분함.
VLAN태그를 통해 같은 네트워크 스위치를 이용하지만, 서로 다른 VLAN에 속한 장치들은 기본적으로 서로 통신할 수 없다.</p>
</blockquote>
<p>현재 구성에서 VLAN을 사용한 이유는 가상 HOST에서 br-int(OVS)를 거쳐 물리 연결 HOST로 패킷이 이동할 때, 네트워크 환경을 VLAN 10으로 구분한다. 즉, VLAN 10 태그가 없는 트래핏은 해당 포트를 통과하지 못하는 것이다.</p>
<p>만일 호스트 간 패킷이 VLAN 태그 없이 전송되면, 다른 VLAN트래픽과 충돌하거나 의도치 않게 외부로 노출될 수 있다.
또한, OVS나 ONOS 플로우 설정에서 VLAN 태그를 필터링하지 않으면, 모든 패킷이 해당 경로로 전송될 수 있어 보안 및 네트워크 정책이 무너질 수 있다.</p>
<hr>
<h2 id="8️⃣-실습-실패의-원인">8️⃣ 실습 실패의 원인</h2>
<p>flow rule까지 등록을 다 했는데, 결론은 ping테스트가 성공하지 못했다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/96805da4-0222-4582-9790-70ca4e9e9c81/image.png" alt=""></p>
<blockquote>
<p>mininet으로 만든 가상 스위치인 s1과 라즈베리파이에서 만든 ovs인 br-int의 컨트롤러가 각각 존재했기 때문에 ONOS를 통한 패킷제어가 애초에 불가능했다.</p>
</blockquote>
<p>기존의 블로그에서는 HOST 두 개와 라즈베리파이, ONOS를 설치한 컴퓨터 전부를 유선으로 연결하여 같은 대역에서 패킷제어를 시도했기 때문에 가능했던 것이었다.</p>
<p>나의 경우 라즈베리파이의 유선연결이 되지 않아, 무선연결로 수행했고, 준비했던 두 개의 Host 중 하나의 Host가 오래된 노트북이었어서 이더넷 선 연결을 인식하지 못하여 Host를 하나밖에 연결하지 못했다.</p>
<p>그럼에도 불구하고, 이번 실습을 통해 ONOS의 패킷 라우팅 구조와 원리에 대해 깊이있게 이해할 수 있게 되었다는 점에 있어서 큰 의의가 있었던 실습이라고 생각한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[ONOS 실습] ONOS 2.7.0, Ubuntu 22.04이용해 mininet 가상 네트워크 연결]]></title>
            <link>https://velog.io/@hyeondooori_/ONOS-2.7.0-Ubuntu-22.04%EC%9D%B4%EC%9A%A9%ED%95%B4-mininet-%EA%B0%80%EC%83%81-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%97%B0%EA%B2%B0</link>
            <guid>https://velog.io/@hyeondooori_/ONOS-2.7.0-Ubuntu-22.04%EC%9D%B4%EC%9A%A9%ED%95%B4-mininet-%EA%B0%80%EC%83%81-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%97%B0%EA%B2%B0</guid>
            <pubDate>Sun, 15 Dec 2024 16:24:32 GMT</pubDate>
            <description><![CDATA[<p>이 포스트는 이 블로그를 참고했습니다! -&gt; <a href="https://m.blog.naver.com/love_tolty/222608954525?recommendTrackingCode=2">click!</a>
저 블로그는 ONOS 2.7.0 버전이 아닌 다른 버전을 사용하고 있어 명령어 사용이 달라지는 부분들이 있으니, 최근의 정보를 확인하고 싶다면 제 글을 쭉 따라해보시면 됩니다!</p>
<p>window 사용자 기준
Ubuntu를 실행하기 위한 가상머신이 필요합니다!
저는 vmware을 설치하여 사용했습니다.</p>
<p>그리고, Ununtu 22.04버전 이미지를 다운로드받아서, vmware에서 실행시켜주면 됩니다. </p>
<p><a href="https://m.blog.naver.com/PostView.naver?blogId=love_tolty&amp;logNo=223289610110&amp;navType=by">ONOS 설치 블로그</a>
위 블로그를 참고하여 ONOS를 설치했습니다. 
그대로 쭉 따라하시면 잘 설치 될겁니다!
<img src="https://velog.velcdn.com/images/hyeondooori_/post/92040804-1cd6-4ec9-a0dc-32cfbb1f841b/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyeondooori_/post/4635727b-c39c-4332-b492-3fcd9eec2109/image.png" alt="">
저는 이렇게 설치가 잘 되었습니다.</p>
<p>ONOS CLI를 실행하는데, 먼저 컴퓨터의 IP주소를 알아야 해요!</p>
<pre><code>ip route | grep default </code></pre><p>위 명령어를 통해 공유기의 IP주소를 확인했습니다.
저는 192.168.224.2 인 것으로 확인했습니다.</p>
<p>그 다음으로는 pc의 주소인데,</p>
<pre><code>ip addr show </code></pre><p>위 명령어를 통해 192.168.224.128인 것을 확인했습니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/4a72d067-4405-460e-a017-59a7a7bed7f4/image.png" alt="">
그러면 이렇게 본인의 IP주소를 넣어 ONOS에 접속할 수 있습니다.
초기 비밀번호는 karaf입니다.
-p 옵션은 포트번호를 지정합니다. 로컬인 클라이언트가 ONOS 컨트롤러로 SSH 연결을 시도할 때 서버의 8101포트를 통해서 SSH 요청을 보내도록 지정하는 것입니다. 즉, 나가는 포트는 랜덤한 동적 포트로 설정되지만, ONOS컨트롤러는 고정된 포트(여기서는 8101) 포트로 SSH 연결을 수신합니다.</p>
<p>이제는 가상 네트워크를 구성해서 OpenFlow 프로토콜을 기반으로 ONOS와 연결해볼 것입니다.</p>
<blockquote>
<p>여기에서 OpenFlow는 SDN에서 사용하는 프로토콜입니다!</p>
</blockquote>
<p>OpenFlow를 사용하기 위해서는 ONOS CLI로 접속하여 OpenFlow프로토콜을 지원하는 ONOS 애플리케이션인 &#39;org.onosproject.openflow&#39;를 활성화 해주어야 합니다. 
다음과 같이 진행합니다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/05219390-6e48-488e-a6c7-75074ed3b05a/image.png" alt="">
두 번째로 친 명령어는 현재 실행중인 앱을 조회하는 명령어입니다.
openflow 관련 애플리케이션들이 실행되는 것을 확인할 수 있습니다.</p>
<p>그리곤, 다른 터미널을 열어 apt-get을 이용해 mininet을 설치해주어야 합니다.</p>
<pre><code>sudo apt-get install mininet</code></pre><p>오픈소스 SW인 OpenFlow 기반의 가상 스위치를 제공해주는 Open vswitch(이하 OVS)도 패키지 관리도구인 apt-get으로 설치해줍니다.</p>
<pre><code>sudo apt-get install openvswitch-switch</code></pre><p>설치가 완료되면, mininet으로 1개의 OVS 스위치(s1)에 두 개의 가상 Host(h1,h2)와 ONOS 제어기(c0)가 연결되도록 가상네트워크를 구성하기 위해서
<img src="https://velog.velcdn.com/images/hyeondooori_/post/92155e61-2295-4369-93ae-3a43fce12e11/image.png" alt=""></p>
<p>아까 확인했던 pc의 ip주소를 넣어 명령어를 실행시켜 주면 됩니다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/155c26f3-ffd0-4a7c-880a-e5ad02e3bb83/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyeondooori_/post/7c7d68b4-002d-49cb-b8b5-c7f26a83b425/image.png" alt="">
명령어의 옵션은 위와 같습니다.</p>
<p>명령어 실행 후, ONOS CLI에서 현재 ONOS와 연결된 스위치 정보를 조회해보면 OVS 스위치 1대가 ONOS와 연결이 되었음을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/ec92f02e-c581-4a5e-a1e3-dfb2db666988/image.png" alt=""></p>
<p>mininet cli에서 ping테스트를 해보면, 아직 flow rule을 설정하지 않아 서로 메시지를 주고받을 순 없다고 나옵니다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/f9f3aff2-9de9-4e7a-b274-93b036036bcf/image.png" alt=""></p>
<h2 id="mininet으로-가상-네트워크-구조-파악하기">Mininet으로 가상 네트워크 구조 파악하기</h2>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/776238c2-b4f4-4fcb-a9e0-efc846e0750b/image.png" alt="">
nodes 명령어를 통해 확인해보면, c0,h1,h2,s1 이렇게 4개의 Node가 생긴 것을 확인할 수 있습니다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/a9ede84e-515b-465a-b049-ea2357543c91/image.png" alt=""></p>
<p>이러한 상황입니다.
여기에서 각 Node들의 interface정보들을 확인해보면, </p>
<ul>
<li>h1은 h1-eth0인터페이스에 IP주소 10.0.0.1 할당</li>
<li>h2는 h2-eth0인터페이스에 IP주소 10.0.0.2 할당</li>
<li>OVS 스위치 s1에는 eth1,eth2인터페이스 생성</li>
<li>onos에 해당하는 node인 c0는 ip주소/포트 192.168.224.128/6653
<img src="https://velog.velcdn.com/images/hyeondooori_/post/49699844-cc8d-41a0-be30-41781637f1ec/image.png" alt="">
dump와 net 명령어를 통해 더 자세히 확인할 수 있습니다. 
dump는 인터페이스 ip주소를 보여주고, 
net은 각 node들의 네트워크 인터페이스 별 연결정보를 알려줍니다.
h1의 h1-eth0 인터페이스는 OVS 스위치 s1의 s1-eth1 인터페이스와 연결되고, 호스트 h2의 h2-eth0 인터페이스는 OVS 스위치 s1의 s1-eth2인터페이스와 연결되어있는 것을 확인할 수 있습니다.
ONOS인 c0는 당연하게 OVS 스위치 s1과 local로 연결되므로, Mininet으로 살펴본 가상 네트워크 구성은 이러합니다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/41fb3925-0aa5-4651-8219-6bbe030b093a/image.png" alt=""></li>
</ul>
<h2 id="onos가-바라본-가상-네트워크-구조-파악하기">ONOS가 바라본 가상 네트워크 구조 파악하기</h2>
<p>ONOS가 바라보는 가상 네트워크 구조는 앞서 Mininet으로 파악한 가상 네트워크 구조와 동일하지만, ONOS는 직접 Flow Rule을 설정해주어야 하기 때문에 각 장치별 요소를 구분하기 위한 값이 Mininet을 통해 파악한 것과는 조금 다릅니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/d89df39c-4eb3-4600-ae07-68c9ee92b798/image.png" alt="">
먼저 ONOS CLI로 접속하여 현재 연결된 OVS 스위치 정보를 조회해보면, Mininet에서는 s1이라는 노드로 표현되었지만, 여기서는 <code>of:0000000000000001</code>라는 고유의 장치 ID 값을 가집니다. </p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/8c8be2ba-0c36-4593-a6ee-1fb6ee1cf7d0/image.png" alt="">
이번에는 hosts명령어를 통해 확인한 것입니다.
<code>locations=[of:0000000000000001/2]</code>는 호스트 <code>of:0000000000000001</code>가 스위치의 2번포트에 연결되어있음을 의미합니다. </p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/0a9b7740-c4c9-4a70-a3cc-4ac90ff237a6/image.png" alt="">
이 사진이 최종 연결구조입니다.</p>
<p>h1이 연결된 OVS 스위치(s1)의 s1-eth1 인터페이스는 1번 포트이고, h2가 연결된 s1-eth2는 2번 포트로 인식됨을 확인할 수 있습니다. </p>
<p>이제 이렇게 최종 확인된 가상 네트워크 구조를 가지고, ONOS의 Flow Rule 설정을 통해 두 Host 간 통신이 되도록 해볼 것입니다.</p>
<h2 id="flow-rule-설정하기---reactive-mode">Flow Rule 설정하기 - reactive mode</h2>
<p>Openflow 프로토콜에서는 flow rule 설정 방식이 두 가지 있습니다. 하나는 flow rule이 정의되지 않은 미지의 패킷이 SDN 스위치로 유입되었을 때, SDN 제어기에 의해 flow rule이 결정되는 reactive 모드이고, 다른 하나는 네트워크 관리자에 의하여 SDN 스위치에 미리 flow rule을 등록하는 proactive 모드입니다.</p>
<p>reactive모드를 실행하기 위해서는 ONOS CLI에서 <code>org.onosproject.fwd</code>애플리케이션을 실행시켜야 합니다. 이 애플리케이션은 packet-in메시지를 SDN 제어기인 ONOS로 보내도록 flow rule을 설정하여 ONOS가 reactive모드로 동작하도록 합니다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/cbc87ae0-4d28-4eb7-9242-7587df8bac87/image.png" alt="">
명령어가 잘 실행되었습니다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/1c99b684-b9b8-4e75-9578-e342f82a7aa8/image.png" alt="">
mininet cli에서 다시 ping을 보내보면, 기존에는 안되던 h1과 h2의 ping이 성공적으로 잘 수행된 것을 확인할 수 있습니다. 
ONOS를 통해 직접 flow rule이 OVS 스위치 s1에 설정된 것입니다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/7d9da640-164e-4067-9021-598c770f3158/image.png" alt="">
ONOS CLI에서 flows 명령어를 통해 flow rule을 조회해보면 ADDED상태로 추가된 rule을 확인할 수 있습니다. </p>
<h2 id="flow-rule-설정하기---proactive-모드">Flow Rule 설정하기 - Proactive 모드</h2>
<p>이제부터는 ONOS에서 제공하는 REST API를 활용해서 Proactive모드로써 직접 Flow Rule을 설정해볼 것입니다.
일단 ONOS CLI에 접속하여 Reactive 모드를 테스트하기 위해 앞서 실행했던 <code>org.onosproject.fwd</code>를 비활성화해줍니다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/81522f9f-39f5-4035-943e-3508951c4d89/image.png" alt=""></p>
<p>그리고 flow_rule_post.sh라는 스크립트 파일을 만들 것입니다. 이 스크립트 파일은 ONOS REST API를 이용해서 JSON파일로 정의된 Flow Rule을 해당 OVS 스위치에 설정하는 역할을 수행합니다. 
flow_rule_post.sh에는 아래 내용을 넣어주면 됩니다. 역시 IP주소는 바꾸어 넣어야 합니다.</p>
<pre><code>ONOS_IP=192.168.224.128
ONOS_PORT=8181

curl -X POST --header &quot;Content-Type: application/json&quot; --header &quot;Accept: application/json&quot; -d @$1 &quot;http://$ONOS_IP:$ONOS_PORT/onos/v1/flows/&quot; --user karaf:karaf</code></pre><p><img src="https://velog.velcdn.com/images/hyeondooori_/post/c8722f7b-da76-422a-9ece-33d8962939df/image.png" alt=""></p>
<p>이제 flow rule을 정의해볼 것입니다. 
h1에서 h2로 데이터를 보낼 수 있도록 OVS스위치 s1의 1번포트로 패킷이 들어오면 2번포트로 내보내라는 flow rule을 정의해볼 것입니다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/eb808909-c53f-4753-a673-83f2162bfcbb/image.png" alt="">
이를 위해 OVS 스위치 s1의 입력포트(IN_PORT)는 1번, 출력포트(OUTPUT)는 2번으로 지정된 flow rule을 5000이라는 우선순위를 적용하여 <code>flow_h1_to_h2.json</code>라는 JSON포맷 파일로 생성합니다.</p>
<pre><code>{
  &quot;flows&quot;: [
    {
      &quot;priority&quot;: &quot;50000&quot;,
      &quot;timeout&quot;: 0,
      &quot;isPermanent&quot;: true,
      &quot;deviceId&quot;: &quot;of:0000000000000001&quot;,
      &quot;treatment&quot;: {
        &quot;instructions&quot;: [
          {
            &quot;type&quot;: &quot;OUTPUT&quot;,
            &quot;port&quot;: &quot;2&quot;
          }
        ]
      },
      &quot;selector&quot;: {
        &quot;criteria&quot;: [
          {
            &quot;type&quot;: &quot;IN_PORT&quot;,
            &quot;port&quot;: &quot;1&quot;
          }
        ]
      }
    }
  ]
}
</code></pre><p>이번에는 반대로 h2에서 h1으로 데이터를 보낼 수 있도록 OVS스위치 s1의 2번 포트로 패킷이 들어오면 1번 포트로 내보내라는 Flow Rule을 정의해볼 것입니다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/a671a47b-d551-4d12-b3d7-d91b2a9e8616/image.png" alt="">
이를 위해 OVS 스위치 s1 (of:0000000000000001) 의 입력포트 (IN_PORT) 는 2번, 출력포트 (OUTPUT) 는 1번으로 지정된 Flow Rule을 5000이라는 우선순위 (Priority) 를 적용해 &#39;flow_h2_to_h1.json&#39; 라는 JSON 포맷 파일로 생성합니다.</p>
<pre><code>{
  &quot;flows&quot;: [
    {
      &quot;priority&quot;: &quot;50000&quot;,
      &quot;timeout&quot;: 0,
      &quot;isPermanent&quot;: true,
      &quot;deviceId&quot;: &quot;of:0000000000000001&quot;,
      &quot;treatment&quot;: {
        &quot;instructions&quot;: [
          {
            &quot;type&quot;: &quot;OUTPUT&quot;,
            &quot;port&quot;: &quot;1&quot;
          }
        ]
      },
      &quot;selector&quot;: {
        &quot;criteria&quot;: [
          {
            &quot;type&quot;: &quot;IN_PORT&quot;,
            &quot;port&quot;: &quot;2&quot;
          }
        ]
      }
    }
  ]
}</code></pre><p><img src="https://velog.velcdn.com/images/hyeondooori_/post/9eeb42fa-a66f-452d-a281-e19403d78ec3/image.png" alt="">
편의를 위해 생성한 파일의 권한까지 변경해준 모습입니다.</p>
<p>JSON 파일로 정의한 Flow Rule 파일 (flow_h2_to_h1.json, flow_h2_to_h1.json) 들을 먼저 앞에서 작성한 flow_rule_post.sh 스크립트 파일을 이용하여, ONOS를 통해 OVS 스위치 s1 (of:0000000000000001) 로 Flow Rule 정보를 등록해줍니다. Flow Rule이 정상적으로 내려갔다면 스위치 s1의 Device ID와 등록한 Flow Rule의 ID 값이 출력됩니다.</p>
<pre><code>./flow_rule_post.sh flow_h1_to_h2.json flow_h2_to_h1.json

./flow_rule_post.sh flow_h2_to_h1.json</code></pre><p><img src="https://velog.velcdn.com/images/hyeondooori_/post/3f99b655-6e22-4086-ab91-7dbb4ffaa87c/image.png" alt="">
ONOS CLI로 접근하여 실제 등록된 FlowRule정보를 확인해보면, 성공적으로 등록된 것을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/0edfa7de-957c-44ab-8872-47e6f8f808b1/image.png" alt="">
이제 네트워크 구조를 도식화해보면 다음과 같습니다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/4962fe15-a25b-4f00-b35f-e246441a1d92/image.png" alt="">
이 사진 역시 블로그에서 가져온 것이라 세부적 flow Id, c0 address, MAC address는 다릅니다.
flow rule이 등록된 상태에서 mininet CLI를 통해 h1과 h2의 ping테스트를 해보면 성공적으로 수행되는 것을 확인할 수 있습니다. 
<img src="https://velog.velcdn.com/images/hyeondooori_/post/0033cd40-cb59-46b1-9e14-f7f8de6db61b/image.png" alt=""></p>
<p>먼저 h1에서 h2로 Ping 메시지를 보낼 경우, h1은 h2의 MAC 주소를 알아내기 위해 ARP 요청 메시지를 보내고, 해당 메시지는 OVS 스위치 s1의 1번포트로 전달됩니다. 이때 OVS 스위치 s1은 메시지의 처리를 위해 자신의 Flow Table에 등록된 Flow Rule을 찾아봅니다. Flow Table에서 입력포트 (IN_PORT) 가 1번인 Match Field가 일치하여, 해당 ARP 요청 메시지는 출력포트 (OUTPUT) 인 OVS 스위치 s1의 2번포트를 통해서 h2로 전달됩니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/d40f899a-4ccc-4c8d-ab9d-5542f93bf405/image.png" alt=""></p>
<p>h1로부터 ARP 요청 메시지를 받은 h2는 자신의 MAC 주소를 포함한 ARP 응답 메시지를 보내고, 해당 메시지는 OVS 브릿지 s1의 2번포트로 전달됩니다. 이때 OVS 스위치 s1은 메시지의 처리를 위해 자신의 Flow Table에서 Flow Rule을 찾아봅니다. Flow Table에서 입력포트 (IN_PORT) 가 2번인 Match Field가 일치하여, 해당 ARP 응답 메시지는 출력포트 (OUTPUT) 인 OVS 스위치 s1의 1번포트를 통해서 h1로 전달됩니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/20a92239-96d3-4a88-bec4-104989bf1abd/image.png" alt=""></p>
<p>h2로부터 ARP 응답 메시지를 전달받은 h1은 해당 메시지에 포함된 h2의 MAC 주소를 파악하고 해당 MAC 주소를 이용하여 ICMP 요청 메시지를 h2에 전달합니다. 이때 해당 메시지는 OVS 스위치 s1의 1번 포트로 제일 먼저 전달되며, OVS 스위치 s1은 메시지의 처리를 위해 자신의 Flow Table에 등록된 Flow Rule을 찾아봅니다. Flow Table에서 입력포트 (IN_PORT) 가 1번인 Match Field가 일치하여, 해당 ICMP 요청 메시지는 출력포트 (OUTPUT) 인 OVS 스위치 s1의 2번포트를 통해서 h2로 전달됩니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/b11b48fb-359f-4891-8b2b-3ab2ffde7bc4/image.png" alt=""></p>
<p>h1로부터 ICMP 요청 메시지(echo request)를 받은 h2는 이에 대한 응답으로 ICMP 응답 메시지(echo reply)를 생성해 h1으로 전달합니다. 이때 해당 메시지는 OVS 브릿지 s1의 2번포트로 먼저 전달됩니다. 그리고 OVS 스위치 s1은 메시지의 처리를 위해 자신의 Flow Table에서 Flow Rule을 찾아봅니다. Flow Table에서 입력포트 (IN_PORT) 가 2번인 Match Field가 일치하여, 해당 ARP 응답 메시지는 출력포트 (OUTPUT) 인 OVS 스위치 s1의 1번포트를 통해서 h1으로 전달됩니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/b8ae6156-7822-482b-99cb-83fbfef5f0cf/image.png" alt=""></p>
<p>여기까지 h1에서 h2로 Ping 메시지를 보내는 동안 OVS 스위치 s1에 등록된 Flow Rule이 어떻게 적용되어 데이터를 전달하는지 살펴보았습니다. h2에서 h1으로 Ping 메시지를 보내는 경우에도 마찬가지로 동일한 방식으로 Flow Rule이 적용되어 통신이 이루어집니다.</p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/66ab2c8c-33e6-4f7d-b410-f011df7f32dd/image.png" alt="">
<img src="https://velog.velcdn.com/images/hyeondooori_/post/d47ab9c4-6673-4e72-9554-25780cf58b39/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Mininet 사용해보기]]></title>
            <link>https://velog.io/@hyeondooori_/Mininet-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@hyeondooori_/Mininet-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sun, 15 Dec 2024 11:40:29 GMT</pubDate>
            <description><![CDATA[<p>SDN관련 실습이 잘 안되어서, 다른 할 것을 찾아보던 중에 mininet을 이용해서 네트워크 환경을 시뮬레이션 하는 것에 대해 알게되었어요!
제가 본 블로그는 아래 링크 첨부합니다.
<a href="https://jelong.tistory.com/entry/Ubuntu%EC%97%90%EC%84%9C-Mininet%EB%AF%B8%EB%8B%88%EB%84%B7-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0">CLick!!</a></p>
<h3 id="mininet이란">mininet이란</h3>
<p>가상 네트워크를 통해 SDN이나 Openflow와 같은 네트워크 환경을 시뮬레이션 할 수 있는 오픈 소스 프로그램입니다.
Mininet은 가상 스위치, 호스트를 사용해 네트워크를 시뮬레이션하는 소프트웨어로 SDN 및 Openflow와 같은 네트워크 프로토콜을 시험하고 개발하는 데 사용된다고 합니다.
Mininet은 python기반으로 작성되었으며, 가상화 기술을 사용해 다중 사용자를 지원하고, 빠르고 쉽게 네트워크를 생성, 시험할 수 있습니다.</p>
<p>Mininet은 학술연구, 프로토타입 및 시스템 테스트, 교육 및 교육용 목적으로 사용됩니다. Mininet은 또한 Openflow 교육 및 SDN 프로토타입 개발 등의 목적으로도 사용됩니다. </p>
<h3 id="mininet-사용해-네트워크-토폴로지-생성해보기">mininet 사용해 네트워크 토폴로지 생성해보기.</h3>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/07ebb2f7-2752-42a6-baf6-cb40dbf49bb4/image.png" alt="">
mininet 설치
<img src="https://velog.velcdn.com/images/hyeondooori_/post/ba81b4f0-637e-48a3-994a-c6f85e8fc401/image.png" alt=""></p>
<pre><code class="language-python">from mininet.net import Mininet
from mininet.cli import CLI
from mininet.node import Host
from mininet.node import OVSKernelSwitch
from mininet.log import setLogLevel, info</code></pre>
<p>Mininet: 네트워크를 시뮬레이션하는 객체.
CLI: Mininet의 명령어 인터페이스로 사용자가 명령을 입력할 수 있게 함.
Host: 가상 호스트를 생성.
OVSKernelSwitch: Open vSwitch 기반 스위치를 생성.
setLogLevel: 로그 출력 레벨을 설정.</p>
<pre><code class="language-python">def myTopo():
    net = Mininet( topo=None, autoSetMacs=True, build=False, ipBase=&#39;10.0.1.0/24&#39; )</code></pre>
<p>net: Mininet 객체를 생성합니다.
topo=None: 사용자 정의 토폴로지를 사용할 것을 의미합니다.
autoSetMacs=True: 자동으로 MAC 주소를 설정합니다.
build=False: 네트워크는 명시적으로 build() 메서드를 호출할 때까지 빌드되지 않습니다.
ipBase=&#39;10.0.1.0/24&#39;: 호스트들이 사용할 기본 IP 주소 대역입니다.</p>
<pre><code class="language-python">h1 = net.addHost( &#39;h1&#39;, cls=Host, defaultRoute=None )
h2 = net.addHost( &#39;h2&#39;, cls=Host, defaultRoute=None )
h3 = net.addHost( &#39;h3&#39;, cls=Host, defaultRoute=None )
s1 = net.addSwitch( &#39;s1&#39;, cls=OVSKernelSwitch, failMode=&#39;standalone&#39; )</code></pre>
<p>net.addHost: 네트워크에 호스트를 추가합니다.
h1, h2, h3라는 이름의 호스트를 추가.
net.addSwitch: 네트워크에 스위치를 추가합니다.
s1이라는 이름의 스위치를 생성.
failMode=&#39;standalone&#39;: 스위치가 컨트롤러 없이 독립적으로 동작합니다.</p>
<pre><code class="language-python">net.addLink(h1, s1)
net.addLink(h2, s1)
net.addLink(h3, s1)</code></pre>
<p>호스트 h1, h2, h3를 스위치 s1과 연결합니다.</p>
<pre><code class="language-python">h1.setIP(intf=&quot;h1-eth0&quot;, ip=&#39;10.0.1.2/24&#39;)
h2.setIP(intf=&quot;h2-eth0&quot;, ip=&#39;10.0.1.3/24&#39;)
h3.setIP(intf=&quot;h3-eth0&quot;, ip=&#39;10.0.1.4/24&#39;)</code></pre>
<p>각 호스트의 네트워크 인터페이스에 IP 주소를 설정합니다.
인터페이스 이름: h1-eth0, h2-eth0, h3-eth0.
IP 주소:</p>
<ul>
<li>h1: 10.0.1.2/24</li>
<li>h2: 10.0.1.3/24</li>
<li>h3: 10.0.1.4/24<pre><code class="language-python">net.build()
net.start()</code></pre>
build(): 네트워크를 빌드합니다.
start(): 네트워크를 시작합니다.<pre><code class="language-python">CLI(net)
net.stop()</code></pre>
CLI(net): Mininet 명령어 인터페이스를 시작합니다. 사용자가 명령을 입력해 네트워크 상태를 확인하거나 테스트할 수 있습니다.
net.stop(): 네트워크를 종료합니다.<pre><code class="language-python">if __name__ == &#39;__main__&#39;:
  setLogLevel(&quot;info&quot;)
  myTopo()</code></pre>
setLogLevel(&quot;info&quot;): 로그 출력 레벨을 &quot;info&quot;로 설정.
myTopo(): 위에서 정의한 네트워크 토폴로지 함수 실행.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/b46b0d8c-7c37-460d-bdcf-ef5df047437c/image.png" alt="">
pingall명령어를 통해 잘 연결되는 것을 확인할 수 있다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[github actions와 docker를 이용한 배포과정]]></title>
            <link>https://velog.io/@hyeondooori_/%EB%B0%B0%ED%8F%AC%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@hyeondooori_/%EB%B0%B0%ED%8F%AC%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Tue, 26 Nov 2024 02:31:35 GMT</pubDate>
            <description><![CDATA[<p>목차</p>
<ol>
<li>EC2 생성</li>
<li>RDS 생성</li>
<li>Dockerfile 작성</li>
<li>github secrets에 환경변수 선언</li>
<li>github-actions.yml 코드 작성</li>
</ol>
<p>EC2 생성과 RDS 생성은 자료들이 많을 것이라고 생성한다.
주의할 점은 같은 VPC 안에서 생성되어야하기 때문에 RDS 생성 시 기존에 생성해두었던 EC2를 지정해주어야 한다.
같은 VPC안에 있어야 서버에서 RDS를 찾아 생성할 수 있다.</p>
<h1 id="1-ec2-생성">1. EC2 생성</h1>
<h1 id="2-rds-생성">2. RDS 생성</h1>
<h1 id="3-dockerfile-작성">3. DockerFile 작성</h1>
<p>프로젝트의 루트 디렉토리 내에 생성하면 된다.
<img src="https://velog.velcdn.com/images/hyeondooori_/post/6542da10-3b65-4d6d-9712-7ef385ae926b/image.png" alt=""></p>
<pre><code># 베이스 이미지: Java 17 (필요에 따라 Java 버전 변경 가능)
FROM openjdk:17-jdk-slim

# 컨테이너 내부의 작업 디렉토리 설정
WORKDIR /app

# 빌드된 JAR 파일을 컨테이너로 복사
COPY ./build/libs/*.jar app.jar

# 컨테이너에서 열릴 포트
EXPOSE 8080

# 애플리케이션 실행 명령어
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]</code></pre><h1 id="4-github-secrets-환경변수-선언">4. github secrets 환경변수 선언</h1>
<p>github actions를 이용할건데, github에 개인정보는 올리면 안되니까 환경변수를 이용한다.
아래의 github-actions.yml 코드 내부에 이용되는 것들을 환경변수로 미리 선언하는 것이다.</p>
<h2 id="yml">YML</h2>
<pre><code>YML
spring:
  application:
    name: forest
  profiles:
    group:
      dev: dev, local
      deploy: deploy
    active: dev
</code></pre><h2 id="yml_dev">YML_DEV</h2>
<pre><code>spring:
  datasource:
    url: jdbc:mysql://&lt;엔드포인트&gt;:&lt;포트번호&gt;/&lt;DB인스턴스식별자이름&gt;
    username: &lt;rds 생성 시 지정한 마스터사용자 이름&gt;
    password: &lt;rds 생성 시 지정한 마스터암호&gt;
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: update
logging:
  level:
    org.hibernate.SQL: debug</code></pre><p>hibernate:
      ddl-auto: update
데이터베이스 내부에 자동으로 테이블이 만들어지도록 하는 명령어이다. 
이 코드를 써주지 않으면 actions파일을 실행시켰을 때 오류가 나므로 꼭 포함시켜주어야한다.</p>
<h2 id="username">USERNAME</h2>
<p>ubuntu</p>
<h2 id="docker_username">DOCKER_USERNAME</h2>
<p>docker 가입할 때 지정한 사용자 이름</p>
<h2 id="docker_password">DOCKER_PASSWORD</h2>
<p>docker 가입할 때 지정한 비밀번호</p>
<h2 id="host_dev">HOST_DEV</h2>
<p>EC2로 할당받은 public IPv4 주소</p>
<h2 id="private-key">PRIVATE KEY</h2>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/242e5bea-08ea-4812-ab9d-31070c323615/image.png" alt="">
ppk파일을 다운로드 했다면, 이를 cat명령어를 이용해 확인한 후, 키 부분을 떼어 위와같이 작성한 후 <code>PRIVATE KEY</code> 환경변수로 등록해준다.</p>
<h1 id="5-github-actionsyml-코드-작성">5. github-actions.yml 코드 작성</h1>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/b08efa51-2d8e-4c7c-b603-4aa580a483fa/image.png" alt="">
먼저 루트 디렉토리 내부의 .github 패키지 내부에 workflows 패키지를 만들고, 그 안에 github-actions.yml 파일을 만든다.
나의 경우에는 dev를 실행하도록 코드를 작성했다.</p>
<pre><code class="language-yml"># github repository actions 페이지에 나타날 이름
name: CI/CD using github actions &amp; docker

# event trigger
# main브랜치에 push가 되었을 때 실행
on:
  push:
    branches: [ &quot;main&quot; ]

permissions:
  contents: read

jobs:
  CI-CD:
    runs-on: ubuntu-latest
    steps:

      # JDK setting - github actions에서 사용할 JDK 설정 (프로젝트나 AWS의 java 버전과 달라도 무방)
      - uses: actions/checkout@v3
      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: &#39;17&#39;
          distribution: &#39;temurin&#39;

      # gradle caching - 빌드 시간 향상
      - name: Gradle Caching
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles(&#39;**/*.gradle*&#39;, &#39;**/gradle-wrapper.properties&#39;) }}
          restore-keys: |
            ${{ runner.os }}-gradle-
      # 권한 부여
      - name: Add execution permissions to Gradle Wrapper
        run: chmod +x ./gradlew

      # 환경별 yml 파일 생성(1) - application.yml
      - name: make application.yml
        if: |
          contains(github.ref, &#39;main&#39;)
        run: |
          mkdir ./src/main/resources # resources 폴더 생성
          cd ./src/main/resources # resources 폴더로 이동
          touch ./application.yml # application.yml 생성
          echo &quot;${{ secrets.YML }}&quot; &gt; ./application.yml # github actions에서 설정한 값을 application.yml 파일에 쓰기
        shell: bash

      # 환경별 yml 파일 생성(2) - dev
      - name: make application-dev.yml
        run: |
          cd ./src/main/resources
          touch ./application-dev.yml
          echo &quot;${{ secrets.YML_DEV }}&quot; &gt; ./application-dev.yml
        shell: bash




      # gradle build
      - name: Build with Gradle
        run: ./gradlew build -x test


      # docker build &amp; push to develop
      - name: Docker build &amp; push to dev
        run: |
          docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
          docker build -f Dockerfile-dev -t ${{ secrets.DOCKER_USERNAME }}/docker-test-dev .
          docker push ${{ secrets.DOCKER_USERNAME }}/docker-test-dev



      ## deploy to develop
      - name: Deploy to dev
        uses: appleboy/ssh-action@master
        id: deploy-dev
        with:
          host: ${{ secrets.HOST_DEV }} # EC2 퍼블릭 IPv4 DNS
          username: ${{ secrets.USERNAME }} # ubuntu
          password: ${{ secrets.PASSWORD }}
          port: 22
          key: ${{ secrets.PRIVATE_KEY }}
          script: |
            port=8080
            echo &quot;Checking for containers using port $port...&quot;

            # Find the container ID using the specified port
            container_id=$(docker ps --filter &quot;publish=$port&quot; --format &quot;{{.ID}}&quot;)

            if [ -n &quot;$container_id&quot; ]; then
              echo &quot;Found container with ID: $container_id. Stopping it...&quot;
              docker stop &quot;$container_id&quot;
              docker rm &quot;$container_id&quot;
              echo &quot;Container stopped successfully.&quot;
            else
              echo &quot;No container is using port $port.&quot;
            fi
            sudo docker ps
            sudo docker pull ${{ secrets.DOCKER_USERNAME }}/docker-test-dev
            sudo docker run --name team-18 -d -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/docker-test-dev</code></pre>
<hr>
<hr>
<hr>
<hr>
<h3 id="applicationyml코드-작성">application.yml코드 작성</h3>
<pre><code class="language-yml">spring:
  application:
    name: &lt;애플리케아션 이름&gt;
  profiles:
    group:
      dev: dev, local
      deploy: deploy
    active: dev</code></pre>
<p>💻 <strong>코드 설명</strong></p>
<ul>
<li>spring.application<ul>
<li>name
Spring Boot 애플리케이션의 이름을 지정하는데 사용된다. 지정된 이름은 주로 로그 출력, Spring Cloud 환경에서 서비스 식별, 그리고 애플리케이션 상태 모니터링 등 여러 곳에서 활용된다. 
로깅에 표시되는 예시는 다음과같다. 예를들어 애플리케이션 이름을 &quot;hyeondooori&quot;로 했다면
<code>2024-11-22 12:00:00.000  INFO 12345 --- [           main] hyeondooori.Application : Started Application in 5.123 seconds (JVM running for 5.678)</code></li>
</ul>
</li>
<li>spring.profiles<ul>
<li>group
프로파일 그룹을 정의한다. dev그룹은 dev와 local프로파일이 함께 활성화되고,
deploy그룹은 deploy 프로파일만 활성화된다.
이를통해 여러 프로파일일을 하나의 그룹으로 묶어서 사용할 수 있다.</li>
<li>active
현재 활성화된 프로파일을 지정한다. 여기서는 dev프로파일이 활성화된 상태이다.</li>
</ul>
</li>
</ul>
<p>💻<strong>동작 방식</strong></p>
<ol>
<li>spring.profiles.active에 따라 프로파일 로드</li>
</ol>
<ul>
<li>active: dev 설정에 따라 application-dev.yml 또는 dev 관련 설정이 로드된다.</li>
</ul>
<ol start="2">
<li>그룹 활성화</li>
</ol>
<ul>
<li>dev 프로파일이 활성화되면, 그룹 설정에 따라 dev와 local 두 프로파일의 설정이 모두 적용된다.</li>
</ul>
<ol start="3">
<li>우선순위</li>
</ol>
<ul>
<li>기본설정(application.yml) -&gt; 활성화된 프로파일(application-dev.yml 또는 application-deploy.yml) 순서로 설정이 병합된다.</li>
<li>동일한 키가 중복되면, 활성화된 프로파일의 설정이 우선된다.<blockquote>
<p>개발 환경에 따라 application.yml 내부의 <code>spring.profiles.active</code>를 변경하여 활성되는 프로파일을 변경할 수 있다.
로컬환경에서 작업할 때는 active를 local로 하고, 개발환경에서는 dev로 하는 방식!</p>
</blockquote>
</li>
</ul>
<h3 id="❓aws-서버에서의-docker-desktop">❓AWS 서버에서의 Docker Desktop</h3>
<p>로컬에서 Docker컨테이너를 실행하려면 Docker Desktop이 실행중이어야만 했다.
이는 Windows나  Mac과 같은 운영체제에서 Docker engine을 실행하는데 필요하다.</p>
<p>하지만, AWS에서 실행되는 Docker는 해당 서버(AWS 인스턴스)에 설치된 Docker engine 위에서 동작한다. 이 Docker engine은 AWS 서버 자체에서 관리되며, 로컬 Docker Desktop과는 전혀 관계가 없다.</p>
<p>AWS에서 퍼블릭IP를 할당받은 인스턴스(AWS EC2 등)에서 Docker컨테이너를 실행했다면:</p>
<ul>
<li>docker 컨테이너는 AWS 인스턴스 내에서 자체적으로 동작한다.</li>
<li>퍼블릭 IP를 통해 외부에서 접근할 수 있다.</li>
</ul>
<p>컨테이너 실행 유지 조건</p>
<ol>
<li>AWS EC2 인스턴스가 계속 실행 중이어야 한다.
EC2가 중지되면, Docker 컨테이너도 중단된다.</li>
<li>Docker 컨테이너가 백그라운드에서 실행중이어야한다.
컨테이너를 실행할 때 -d(detached mode)옵션을 사용하여 컨테이너를 백그라운드에 유지한다.
<code>docker run -d -p 3306:3306 --name mysql-container -e MYSQL_ROOT_PASSWORD=&lt;password&gt; mysql</code></li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring]Intellij IDEA에서 run버튼을 누르면 일어나는 일들]]></title>
            <link>https://velog.io/@hyeondooori_/SpringIntellij-IDEA%EC%97%90%EC%84%9C-run%EB%B2%84%ED%8A%BC%EC%9D%84-%EB%88%84%EB%A5%B4%EB%A9%B4-%EC%9D%BC%EC%96%B4%EB%82%98%EB%8A%94-%EC%9D%BC%EB%93%A4</link>
            <guid>https://velog.io/@hyeondooori_/SpringIntellij-IDEA%EC%97%90%EC%84%9C-run%EB%B2%84%ED%8A%BC%EC%9D%84-%EB%88%84%EB%A5%B4%EB%A9%B4-%EC%9D%BC%EC%96%B4%EB%82%98%EB%8A%94-%EC%9D%BC%EB%93%A4</guid>
            <pubDate>Fri, 22 Nov 2024 06:33:22 GMT</pubDate>
            <description><![CDATA[<p>IntelliJ IDEA에서 <strong><code>Run</code> 버튼</strong>을 누르면, Spring Boot 애플리케이션(또는 다른 프로젝트)이 실행되기까지 여러 단계가 순차적으로 실행됩니다. 아래는 IntelliJ IDEA에서 <strong>Run 버튼의 동작 과정</strong>을 설명합니다.</p>
<hr>
<h3 id="1-실행-설정-run-configuration-확인"><strong>1. 실행 설정 (Run Configuration 확인)</strong></h3>
<ul>
<li>IntelliJ IDEA의 <strong>Run Configuration</strong>에 정의된 설정을 기반으로 애플리케이션을 실행합니다.</li>
<li>기본적으로 Spring Boot 프로젝트에서는 다음을 설정할 수 있습니다:<ul>
<li>실행할 <strong>클래스</strong>: 예를 들어 <code>@SpringBootApplication</code>이 포함된 메인 클래스.</li>
<li>활성화할 <strong>프로파일</strong>: <code>spring.profiles.active</code> 값을 설정 가능.</li>
<li>JVM 옵션 및 환경 변수 설정 가능.</li>
</ul>
</li>
</ul>
<h4 id="run-configuration-설정-확인-방법"><strong>Run Configuration 설정 확인 방법</strong></h4>
<ul>
<li>IntelliJ 메뉴에서 <code>Run &gt; Edit Configurations</code>를 클릭.</li>
<li>실행하려는 Run Configuration을 선택하여 실행 환경을 확인하고 수정할 수 있습니다.</li>
</ul>
<hr>
<h3 id="2-mavengradle-빌드-과정"><strong>2. Maven/Gradle 빌드 과정</strong></h3>
<p>Spring Boot 프로젝트에서는 실행 전에 <strong>Maven</strong> 또는 <strong>Gradle</strong> 빌드 작업이 수행됩니다.</p>
<ol>
<li><p><strong>프로젝트 컴파일</strong>:</p>
<ul>
<li>소스 코드(Java/Kotlin)를 컴파일하여 <code>.class</code> 파일을 생성.</li>
<li>컴파일된 파일은 기본적으로 <code>/target/classes</code>(Maven) 또는 <code>/build/classes/java/main</code>(Gradle) 디렉토리에 저장.</li>
</ul>
</li>
<li><p><strong>의존성 확인 및 다운로드</strong>:</p>
<ul>
<li>Maven 또는 Gradle이 <code>pom.xml</code> 또는 <code>build.gradle</code> 파일을 읽어 프로젝트에 필요한 라이브러리 의존성을 확인.</li>
<li>누락된 라이브러리가 있다면, 원격 저장소에서 자동으로 다운로드.</li>
</ul>
</li>
<li><p><strong>Spring Boot 애플리케이션 실행 파일 생성</strong>:</p>
<ul>
<li>Gradle/Maven에 의해 빌드된 <code>.jar</code> 또는 <code>.war</code> 파일이 생성됩니다(필요한 경우).</li>
</ul>
</li>
</ol>
<hr>
<h3 id="3-jvm-실행"><strong>3. JVM 실행</strong></h3>
<p>빌드가 완료된 후, IntelliJ는 Java Virtual Machine(JVM)을 실행하여 애플리케이션을 시작합니다.</p>
<ol>
<li><p><strong>메인 클래스 실행</strong>:</p>
<ul>
<li>Spring Boot 애플리케이션에서는 기본적으로 <code>@SpringBootApplication</code>이 정의된 클래스(보통 <code>Application.java</code>)가 실행됩니다.</li>
<li>이 클래스는 내부적으로 <code>SpringApplication.run()</code> 메서드를 호출하여 애플리케이션을 부트스트랩(초기화)합니다.</li>
</ul>
<p>예:</p>
<pre><code class="language-java">@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}</code></pre>
</li>
<li><p><strong>JVM 옵션 적용</strong>:</p>
<ul>
<li>Run Configuration에서 설정된 JVM 옵션이 적용됩니다. 예: <code>-Xmx1024m</code> 같은 메모리 관련 설정.</li>
</ul>
</li>
<li><p><strong>환경 변수 적용</strong>:</p>
<ul>
<li>Run Configuration에서 정의한 환경 변수도 JVM에 전달됩니다.</li>
<li>예: <code>spring.profiles.active=dev</code></li>
</ul>
</li>
</ol>
<hr>
<h3 id="4-spring-boot-애플리케이션-초기화"><strong>4. Spring Boot 애플리케이션 초기화</strong></h3>
<p>Spring Boot 애플리케이션이 JVM 위에서 실행되며 초기화 과정을 거칩니다:</p>
<ol>
<li><p><strong>Spring 컨텍스트 초기화</strong>:</p>
<ul>
<li><code>ApplicationContext</code>가 생성되고, 모든 <strong>빈(Bean)</strong>이 초기화됩니다.</li>
<li><code>@Component</code>, <code>@Service</code>, <code>@Repository</code> 등의 애너테이션으로 정의된 클래스가 빈으로 등록됩니다.</li>
</ul>
</li>
<li><p><strong>외부 설정 로드</strong>:</p>
<ul>
<li><code>application.yml</code>, <code>application.properties</code>, 또는 환경 변수에서 설정 값을 읽어옵니다.</li>
<li>활성화된 프로파일(<code>spring.profiles.active</code>)에 따라 추가 설정을 병합합니다.</li>
</ul>
</li>
<li><p><strong>내장 웹 서버 실행</strong>:</p>
<ul>
<li>Spring Boot 프로젝트는 기본적으로 내장형 웹 서버(Tomcat, Jetty 등)를 사용합니다.</li>
<li>내장 웹 서버가 지정된 포트(기본값: 8080)에서 시작됩니다.</li>
</ul>
</li>
<li><p><strong>애플리케이션 실행 준비 완료</strong>:</p>
<ul>
<li>초기화가 완료되면 로그에 <code>Started Application in [time] seconds</code> 메시지가 표시됩니다.</li>
<li>이 시점에서 애플리케이션이 요청을 받을 준비가 됩니다.</li>
</ul>
</li>
</ol>
<hr>
<h3 id="5-intellij에서-로그-출력"><strong>5. IntelliJ에서 로그 출력</strong></h3>
<p>IntelliJ IDEA의 <strong>Run 창</strong>에 애플리케이션 로그가 출력됩니다:</p>
<ul>
<li><strong>Spring Boot 로고와 버전 정보</strong>.</li>
<li>로드된 설정 파일(<code>application.yml</code> 또는 <code>application.properties</code>).</li>
<li>초기화된 빈(Bean) 목록.</li>
<li>내장 웹 서버가 시작된 포트 정보.</li>
</ul>
<p>예:</p>
<pre><code class="language-plaintext">2024-11-22 10:00:00.000  INFO 12345 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http)
2024-11-22 10:00:00.000  INFO 12345 --- [           main] com.example.Application                  : Started Application in 5.123 seconds (JVM running for 5.678)</code></pre>
<hr>
<h3 id="6-애플리케이션-실행-중-동작"><strong>6. 애플리케이션 실행 중 동작</strong></h3>
<ul>
<li>애플리케이션이 실행되면서 <strong>HTTP 요청을 수신</strong>하고, Controller, Service, Repository 계층이 동작.</li>
<li>로컬에서 실행 중인 애플리케이션에 접근하려면 브라우저나 Postman 등을 사용하여 다음 URL에 접근:<pre><code>http://localhost:8080</code></pre></li>
</ul>
<hr>
<h3 id="7-정리"><strong>7. 정리</strong></h3>
<p>IntelliJ IDEA에서 <strong>Run 버튼</strong>을 누르면 아래 과정이 실행됩니다:</p>
<ol>
<li><strong>Run Configuration</strong>을 기반으로 애플리케이션 실행 설정 확인.</li>
<li><strong>Maven/Gradle 빌드</strong>로 소스 코드 컴파일 및 의존성 다운로드.</li>
<li><strong>JVM 실행</strong> 및 <code>main()</code> 메서드 실행.</li>
<li><strong>Spring Boot 초기화</strong>:<ul>
<li>빈 등록, 설정 파일 로드, 내장 웹 서버 시작.</li>
</ul>
</li>
<li><strong>애플리케이션 시작 및 로그 출력</strong>.</li>
</ol>
<hr>
<p>이 과정을 통해 로컬에서 Spring Boot 애플리케이션이 실행되고, 브라우저를 통해 접근할 수 있는 상태가 됩니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링의 예외 처리 방법 이해하기(ExceptionHandler, ControllerAdvice 등)]]></title>
            <link>https://velog.io/@hyeondooori_/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%98-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EB%B2%95-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0ExceptionHandler-ControllerAdvice-%EB%93%B1</link>
            <guid>https://velog.io/@hyeondooori_/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%98-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EB%B2%95-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0ExceptionHandler-ControllerAdvice-%EB%93%B1</guid>
            <pubDate>Tue, 19 Nov 2024 10:27:04 GMT</pubDate>
            <description><![CDATA[<p>스프링의 예외 처리 방식을 이해하기 위래 먼저 스프링의 전체적 흐름을 이해해야한다.</p>
<hr>
<h1 id="스프링의-요청-처리-과정">스프링의 요청 처리 과정</h1>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/10ae3e0a-ced2-4f93-b00c-424f9ec212f1/image.png" alt="">
Spring이 요청에 대한 처리를 어떤 흐름으로 진행하는지에 대한 그림이다. </p>
<ol>
<li>클라이언트의 요청을 디스패처 서블릿이 받음. </li>
<li>요청 정보를 통해 요청을 위임할 컨트롤러 찾음</li>
<li>요청을 컨트롤러로 위임할 핸들러 어댑터를 찾아 전달함.</li>
<li>핸들러어댑터가 컨트롤러로 요청을 위임함.</li>
<li>비즈니스 로직을 처리함.</li>
<li>컨트롤러가 반환값을 처리함.</li>
<li>핸들러어댑터가 반환값을 처리함.</li>
<li>서버의 응답을 클라이언트로 반환함.</li>
</ol>
<ul>
<li>Dispatcher-Servlet
디스패처 서블릿의 dispatch는 &quot;보내다&quot;라는 뜻을 가지고 있다. 그리고 이러한 단어를 포함하는 디스패처 서블릿은 <mark>HTTP 프로토콜로 들어오는 모든 요청을 가장 먼저 받아 적합한 컨트롤러에 위임해주는 프론트 컨트롤러(front Controller)</mark>라고 정의할 수 있다.
클라이언트로부터 어떤 요청이 오면 톰캣(Tomcat)과 같은 서블릿 컨테이너가 요청을 받게된다. 그리고 이 모든 요청을 프론트 컨트롤러인 디스패처 서블릿이 가장 먼저 받게된다. 그러면 디스패처 서블릿은 공통작업을 먼저 처리한 후 해당 요청을 처리해야하는 컨트롤러를 찾아 작업을 위임한다.
여기서 프런트 컨트롤러(front controller)라는 용어가 사용되는데, Front Controller는 주로 서블릿 컨테이너의 제일 앞에서 서버로 들어오는 클라이언트의 모든 요청을 받아 처리해주는 컨트롤러로서, MVC 구조에서 함께 사용되는 디자인 패턴이다.
과거에는 모든 서블릿을 URL 매핑을 위해 web.xml에 모두 등록해주어야 했지만, dispatcher-servlet이 해당 어플리케이션으로 들어오는 모든 요청을 핸들링해주고 공통 작업을 처리면서 상당히 편리하게 이용할 수 있게 되었습니다. 우리는 컨트롤러를 구현해두기만 하면 디스패처 서블릿가 알아서 적합한 컨트롤러로 위임을 해주는 구조가 되었습니다.</li>
</ul>
<hr>
<p>❓ 만약 Controller에서 로직을 처리하던 중 예상치못한 에러가 발생한다면 어떻게될까?</p>
<h1 id="스프링의-기본적인-예외-처리-방법">스프링의 기본적인 예외 처리 방법</h1>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/6e8f9a2e-1f64-4108-b90d-861b1ca8a29e/image.png" alt="">
이와같은 컨트롤러의 getProduct에서 NoSuchElementFoundException 예외가 발생했다면 우리는 접속한 환경에 따라 다른 에러처리를 받게 된다. 만약 웹페이지로 접속했다면 Whitelabel Error Page를 반환받게된다.</p>
<p>Spring은 만들어질 때부터 에러처리를 위한 BasicErrorController를 구현해두었고, 스프링 부트는 예외가 발생하면 기본적으로 /error로 에러 요청을 다시 전달하도록 WAS 설정을 해두었다. </p>
<blockquote>
<p>WAS는 웹 서버와 웹 컨테이너가 합쳐진 형태로서 웹 서버 단독으로는 처리할 수 없는 데이터베이스의 조회나 다양한 로직 처리가 필요한 동적 컨텐츠를 제공한다. 
대표적인 WAS 종류: Tomcat
WAS에 대한 더욱 자세한 설명은! -&gt;<a href="https://velog.io/@hyeondooori_/JAR-WAR">CLICK!!</a></p>
</blockquote>
<p>그래서 별도의 설정이 없다면 예외 발생 시에 BasicErrorController로 에러 처리 요청이 전달된다. 참고로 이는 스프링 부트의 WebMvcAutoConfiguration을 통해 자동 설정이 되는 WAS의 설정이다. 
여기서 요청이 /error로 다시 전달된다는 부분에 주목해야한다. 일반적인 요청 흐름은 다음과 같이 진행된다.
<code>WAS(톰캣) -&gt; 필터 -&gt; 서블릿(디스패처 서블릿) -&gt; 인터셉터 -&gt; 컨트롤러</code>
그리고 컨트롤러 하위에서 예외가 발생했을 때, 별도의 예외 처리를 하지 않으면 WAS까지 에러가 전달된다. 그러면 WAS는 애플리케이션에서 처리를 못하는 예외라 exception이 올라왔다고 판단을 하고 대응 작업을 진행한다. 
<code>컨트롤러(예외발생) -&gt; 인터셉터 -&gt; 서블릿(디스패처 서블릿) -&gt; 필터 -&gt; WAS(톰캣)</code></p>
<p>WAS는 스프링 부트가 등록한 에러 설정(/error)에 맞게 요청을 전달하는데, 이러한 흐름을 총 정리하면 다음과 같다.
<code>WAS(톰캣) -&gt; 필터 -&gt; 서블릿(디스패처 서블릿) -&gt; 인터셉터 -&gt; 컨트롤러</code>
<code>-&gt; 컨트롤러(예외발생) -&gt; 인터셉터 -&gt; 서블릿(디스패처 서블릿) -&gt; 필터 -&gt; WAS(톰캣)</code>
<code>-&gt; WAS(톰캣) -&gt; 필터 -&gt; 서블릿(디스패처 서블릿) -&gt; 인터셉터 -&gt; 컨트롤러(BasicErrorController)</code></p>
<p>기본적인 에러 처리 방식은 <mark>결국 에러 컨트롤러를 한 번 더 호출</mark>하는 것이다. 그러므로 필터나 인터셉터가 다시 호출될 수 있는데, 이를 제어하기 위해서는 별도의 설정이 필요하다. </p>
<p>서블릿은 <code>dispatcherType</code>으로 요청의 종류를 구분하는데, 일반적인 요청은 <code>REQUEST</code>이며 에러 처리 요청은 <code>ERROR</code>이다. 
필터는 서블릿 기술이므로 <code>필터등록(FilterRegistrationBean)</code> 시에 호출될 <code>dispatcherType</code> 타입을 설정할 수 있고, 별도의 설정이 없다면 <code>REQUEST</code>일 경우에만 필터가 호출된다. 
하지만, 인터셉터는 스프링 기술이므로 dispatcherType을 설정할 수 없어 URI 패턴으로 처리가 필요하다. 스프링 부트에서는 WAS까지 직접 제어하게 되면서 이러한 WAS의 에러 설정까지 가능해졌다. 또한 이는 요청이 2번 생기는 것은 아니고, 1번의 요청이 2번 전달되는 것이다. 그러므로 클라이언트는 이러한 에러 처리 작업이 진행되었는지 알 수 없다. </p>
<h3 id="basicerrorcontroller">BasicErrorController</h3>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/63855eb9-34ac-45ea-aef2-b3575729de00/image.png" alt="">
BasicErrorController는 accept헤더에 따라 에러 페이지를 반환하거나 에러 메시지를 반환한다. 에러 경로는 기본적으로 /error로 정의되어있으며 properties에서 server.error.path로 변경할 수 있다.</p>
<p>errorHtml()과 errer()는 모두 getAttributeOptions를 호출해 반환할 에러 속성을 얻는데, 기본적으로 DefaultErrorAttributes로부터 반환할 정보를 가져온다. DefaultErrorAttributes는 전체 항목들에서 설정에 맞게 불필요한 속성들을 제거한다.</p>
<ul>
<li>timestamp: 에러가 발생한 시간</li>
<li>status: 에러의 http상태</li>
<li>error: 에러코드</li>
<li>path: 에러가 발생한 uri</li>
<li>exception: 최상위 예외 클래스의 이름(설정 필요)</li>
<li>message: 에러에 대한 내용(설정 필요)</li>
<li>errors: BindingException에 의해 생긴 에러 목록(설정 필요)</li>
<li>trace: 에러 스택 트레이스(설정 필요)
<img src="https://velog.velcdn.com/images/hyeondooori_/post/8ec61c93-7977-4454-ade0-b9bdeb880b1c/image.png" alt=""></li>
</ul>
<p>위는 기본 설정으로 받는 에러 응답이다. 나름 잘 갖추어져 있지만 클라이언트 입장에서 유용하지 못하다. 클라이언트는 &quot;Item with id 5000 not found&quot;라는 메시지와 함께 404 status로 에러 응답을 받으면 훨씬 유용할 것이다.
다음과 같이 properties를 통해 에러 응답을 조정할 수 있다. 물론 운영 환경에서 구현이 노출되는 trace는 제공하지 않는 것이 좋다.</p>
<blockquote>
<p>참고로 SpringBoot 2.3 이전에는 message를 기본적으로 제공했었지만, SpringBoot 2.3부터는 클라이언트에게 너무 많은 정보가 노출되는 것을 방지하기 위해 기본적으로 제공하지 않게 되었다. </p>
</blockquote>
<h1 id="스프링이-제공하는-다양한-예외-처리-방법">스프링이 제공하는 다양한 예외 처리 방법</h1>
<p>JAVA에서는 예외 처리를 위해 try-catch를 사용해야 하지만 try-catch를 모든 코드에 붙이는 것은 비효율적이다. Spring은 에러 처리라는 공통 관심사(cross-cutting concerns)를 메인 로직으로부터 분리하는 다양한 예외 처리 방식을 고안했고, 예외 처리 전략을 추상화한 <code>HandlerExceptionResolver</code> 인터페이스를 만들었다. (전략 패턴이 사용된 것이다.)</p>
<p>대부분의 HandlerExceptionResolver는 발생한 Exception을 catch하고 HTTP상태나 응답메시지 등을 설정한다. 그래서 WAS입장에서는 해당 요청이 정상적인 응답인 것으로 인식되며, 위에서 설명한 복잡한 WAS의 에러 전달이 진행되지 않는다. </p>
<p><img src="https://velog.velcdn.com/images/hyeondooori_/post/c83c8937-6df7-4e6c-a615-f943d10a5426/image.png" alt="">
위의 Object 타입인 handler는 예외가 발생한 컨트롤러 객체이다. 예외가 던져지면 디스패처 서블릿까지 전달되는데, 적합한 예외처리를 위해 HandlerExceptionResolver구현체들을 빈으로 등록해서 관리한다. 그리고 적용 가능한 구현체를 찾아 예외를 처리하는데, 우선순위대로 아래의** 4가지 구현체**들이 빈으로 등록되어있다. </p>
<ul>
<li><code>DefaultErrorAttributes</code>: 에러 속성을 저장하며 직접 예외를 처리하지 않는다.</li>
<li><code>ExceptionHandlerExceptionResolver</code>: 에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리한다. </li>
<li><code>ResponseStatusExceptionResolver</code>: Http상태코드를 지정하는 @ResponseStatus 또는 ResponseStatusException을 처리한다.</li>
<li><code>DefaultHandlerExceptionResolver</code>: 스프링 내부의 기본 예외들을 처리한다.</li>
</ul>
<hr>
<p>Spring에서 <code>ExceptionResolver</code>를 동작시켜 에러를 처리할 때 사용하는 도구들을 살펴보자.
<code>ExceptionHandler</code>, <code>RestControllerAdvice</code>의 두 개가 있다.
<code>ResponseStatusException</code>의 한계점들을 보완해준 방식들이라 이것들을 사용한다.</p>
<blockquote>
<p>ResponseStatusException의 한계점</p>
</blockquote>
<ul>
<li>직접 예외처리를 프로그래밍하므로 일관된 예외처리가 어려움</li>
<li>예외처리 코드가 중복될 수 있음</li>
<li>Spring 내부의 예외를 처리하는 것이 어려움</li>
<li>예외가 WAS까지 전달되고, WAS의 에러 요청 전달이 진행됨</li>
</ul>
<h3 id="exceptionhandler">@ExceptionHandler</h3>
<ul>
<li>매우 유연한 에러처리 제공</li>
<li>에러 응답(payload)자유롭게 다룰 수 있음.</li>
<li>컨트롤러의 메소드에 해당 어노테이션을 적용할 수 있는데, 이는 전역적으로 사용할 수 없다.
전역적인 사용을 위해서는 @RestControllerAdvice 또는 @ControllerAdvice 애노테이션을 사용해야 한다.</li>
</ul>
<h3 id="restcontrolleradvice">@RestControllerAdvice</h3>
<ul>
<li>Spring 4.3부터 제공하는 애노테이션</li>
<li>@ExceptionHandler를 전역적으로 사용할 수 있도록 도와줌</li>
<li>@ControllerAdvice와의 차이점은 에러응답을 JSON으로 내려준다는 것이다.</li>
<li>사용방법: 애노테이션을 적용해 전역적으로 에러를 핸들링하는 class를 만들어 사용한다.(에러처리 위임)</li>
</ul>
<hr>
<p>출처: <a href="https://mangkyu.tistory.com/18">https://mangkyu.tistory.com/18</a> [MangKyu&#39;s Diary:티스토리]
출처: <a href="https://mangkyu.tistory.com/204">https://mangkyu.tistory.com/204</a> [MangKyu&#39;s Diary:티스토리]</p>
]]></description>
        </item>
    </channel>
</rss>