<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발하는 콩</title>
    <link>https://green-bin.tistory.com/</link>
    <description>한 가지를 대하는 태도를 보면, 만 가지를 대하는 태도를 알 수 있다.</description>
    <language>ko</language>
    <pubDate>Mon, 29 Jun 2026 15:36:09 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Cold Bean</managingEditor>
    <image>
      <title>개발하는 콩</title>
      <url>https://tistory1.daumcdn.net/tistory/5509633/attach/e976ed0e272c478abe68d3834746ea65</url>
      <link>https://green-bin.tistory.com</link>
    </image>
    <item>
      <title>Java - Grafana + K6 + Jaeger로 부하테스트 해보기</title>
      <link>https://green-bin.tistory.com/298</link>
      <description>&lt;h2 id=&quot;개요&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;158&quot; data-ke-size=&quot;size26&quot;&gt;개요&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;162&quot; data-ke-size=&quot;size16&quot;&gt;부하 테스트의 목적은 목표 부하에서 SLO(서비스 수준 목표)를 지키며 얼마나 안정적&amp;middot;효율적으로 운영할 수 있는지를 수치로 확인하고, 그 근거로 &lt;b&gt;용량&amp;middot;설정&amp;middot;코드&lt;/b&gt;를 결정하는 데 있다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;267&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;사용-툴&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;269&quot; data-ke-size=&quot;size26&quot;&gt;사용 툴&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-indent-level=&quot;1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;K6&lt;/b&gt;: 부하 테스트 진행 (v1.2.3)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;InfluxDB&lt;/b&gt;: 부하 테스트 결과 데이터 저장할 시계열 DB (v1.11)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Grafana&lt;/b&gt;: 부하테스트 결과를 대시보드 형태로 시각화 (v11.6.1)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Jaeger&lt;/b&gt;: 트레이스 시각화를 통해 병목 구간 판단&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-expanded=&quot;false&quot; data-title=&quot;K6 vs JMeter 비교&quot; data-node-type=&quot;expand&quot; data-testid=&quot;expand-container-expand-expand-title-17&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-renderer-start-pos=&quot;428&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 id=&quot;아키텍처-개요&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;433&quot; data-ke-size=&quot;size26&quot;&gt;아키텍처 개요&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal; background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-indent-level=&quot;1&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;개발자가 작성한 시나리오로 K6가 HTTP 트래픽을 생성하여 부하 발생&lt;/li&gt;
&lt;li&gt;각 요청에 대한 메트릭 데이터를 InfluxDB에 저장&lt;/li&gt;
&lt;li&gt;Grafana가 InfluxDB에 쿼리를 날려 실시간 대시보드 표시(p95, 에러율, RPS 등 주요 지표 시각화)&lt;/li&gt;
&lt;li&gt;Jaeger는 Spirng Boot에서 발생한 스팬을 수집하여 Jaeger UI에 시각화&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;645&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;부하-테스트-준비&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;647&quot; data-ke-size=&quot;size26&quot;&gt;부하 테스트 준비&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;h4 id=&quot;1.-툴-설치-및-연결&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;658&quot; data-ke-size=&quot;size20&quot;&gt;1. 툴 설치 및 연결&lt;/h4&gt;
&lt;h4 id=&quot;2.-테스트-시나리오-설정&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;1263&quot; data-ke-size=&quot;size20&quot;&gt;2. 테스트 시나리오 설정&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;1279&quot; data-ke-size=&quot;size16&quot;&gt;부하 테스트는 정의된 시나리오가 있어야 결과가 재현&amp;middot;해석&amp;middot;의사결정이 가능하다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;1279&quot; data-ke-size=&quot;size16&quot;&gt;간단하게 테스트 시나리오 종류와 예시 스크립트를 확인해보자&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-expanded=&quot;false&quot; data-title=&quot;테스트 시나리오 종류 및 예시 코드&quot; data-node-type=&quot;expand&quot; data-testid=&quot;expand-container-expand-expand-title-18&quot;&gt;
&lt;div data-testid=&quot;tooltip--container&quot;&gt;
&lt;div style=&quot;color: #292a2e;&quot;&gt;
&lt;p id=&quot;기본-부하-테스트&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;1326&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;기본 부하 테스트&lt;/b&gt;&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-indent-level=&quot;1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;목적&lt;/b&gt;: 정상 응답 및 안정된 응답 시간 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot;&gt;
&lt;div style=&quot;background-color: #ffffff;&quot;&gt;
&lt;pre id=&quot;code_1757469968986&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const options = {
  scenarios: {
    /* =========================
     * 기본 부하 테스트 (Steady-state)
     * - 일정 TPS로 5분 유지, 안정 구간 성능(SLI) 확인
     * - preAllocatedVUs &amp;asymp; rate &amp;times; p95(sec) &amp;times; 3 (대략치)
     * ========================= */
    basic_load: {
      executor: 'constant-arrival-rate',
      rate: 60,                  // 초당 60TPS
      timeUnit: '1s',
      duration: '5m',
      preAllocatedVUs: 40, 
      maxVUs: 120,
    },
  },
};​&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;span style=&quot;background-color: #000000; color: #292a2e;&quot; data-testid=&quot;renderer-code-block&quot; data-ds--code--code-block=&quot;&quot; data-code-lang=&quot;javascript&quot;&gt;&lt;span data-ds--code--row=&quot;&quot; data-testid=&quot;renderer-code-block-line-1&quot;&gt;&lt;span&gt;&lt;span style=&quot;list-style-type: unset; background-color: #000000; color: #1558bc; text-align: unset;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;p id=&quot;점진적-부하-증가-테스트&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;1814&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;점진적 부하 증가 테스트&lt;/b&gt;&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-indent-level=&quot;1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;목적&lt;/b&gt;: 사용자 증가 시 시스템 대응 능력 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757469963918&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export let options = {
  scenarios: {
    // 점진적 부하 증가 테스트
    ramping_load: {
      executor: 'ramping-arrival-rate',
      timeUnit: '1s',   // 요청이 만들어지는 기본 단위
      startRate: '0',   // 시작 TPS
      preAllocatedVUs: 20,
      maxVUs: 200,
      stages: [
        // 워밍업 (20TPS): 낮은 TPS에서 시작해서 천천히 올림 - 예열 단계
        {duration: '30s', target: 20},
        // 램프업 (100TPS): 목표 TPS까지 점진적으로 증가시키면서 시스템 확장성 확인
        {duration: '2m', target: 100},
        // 소크 (100TPS): 일정 TPS를 유지하면서 시스템 안정성 확인
        {duration: '1m30s',target: 100},
        // 램프다운 (0TPS): 부하를 점진적으로 감소시켜 리소스 정리 과정 확인
        {duration: '1m', target: 0},
      ]
    },
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot;&gt;
&lt;div style=&quot;background-color: #ffffff;&quot;&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p id=&quot;스트레스-테스트&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;2511&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;스트레스 테스트&lt;/b&gt;&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-indent-level=&quot;1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;목적&lt;/b&gt;: 시스템 최대 처리 한계 및 장애 점검&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757469998158&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const options = {
  scenarios: {
    /* =========================
     * 스트레스 테스트 (Stress)
     * - 목표를 서서히 초과하며 한계점/붕괴지점 탐색 &amp;rarr; 회복 구간 확인
     * - 총 6분
     * ========================= */
    stress_test: {
      executor: 'ramping-arrival-rate',
      timeUnit: '1s',
      startRate: 0,
      preAllocatedVUs: 80,
      maxVUs: 400,
      stages: [
        { duration: '1m', target: 50 },   // 워밍업
        { duration: '1m', target: 150 },  // 정상 상한 근처
        { duration: '1m', target: 250 },  // 스트레스 시작
        { duration: '1m', target: 350 },  // 한계 넘김(붕괴 관찰)
        { duration: '1m', target: 200 },  // 완화
        { duration: '1m', target: 100 },  // 회복 관찰
      ],
    },
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p id=&quot;스파이크-테스트&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;3246&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;스파이크 테스트&lt;/b&gt;&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-indent-level=&quot;1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;목적&lt;/b&gt;: 갑작스러운 트래픽 증가 시 시스템 안정성 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757470009198&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const options = {
  scenarios: {
    /* =========================
     * 스파이크 테스트 (Spike)
     * - 짧은 시간 급증/급락에 대한 탄력성 확인
     * - keep-alive/TLS/풀/큐 즉시 대응 확인
     * - 총 ~2.5분
     * ========================= */
    spike_test: {
      executor: 'ramping-arrival-rate',
      timeUnit: '1s',
      startRate: 10,             // 베이스라인
      preAllocatedVUs: 60,
      maxVUs: 400,
      stages: [
        { duration: '30s', target: 10 },   // 안정 구간
        { duration: '10s', target: 250 },  // 급상승(스파이크)
        { duration: '20s', target: 10 },   // 급락
        { duration: '10s', target: 250 },  // 2차 스파이크
        { duration: '50s', target: 50 },   // 상향 안정화
        { duration: '30s', target: 0 },    // 램프다운
      ],
    },
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;span style=&quot;text-align: left;&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;4035&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;4035&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot;&gt;본 테스트에서는 점진적 부하 시나리오로 진행했고,&amp;nbsp;진행 순서는 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-indent-level=&quot;1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;워밍업 단계 (30초, 0 &amp;rarr; 20TPS)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-indent-level=&quot;2&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;테스트 시작 시 요청 속도를 0에서 출발해 30초 동안 20TPS까지 올림.&lt;/li&gt;
&lt;li&gt;목적: 갑작스러운 과부하를 주지 않고, 시스템/캐시/DB 커넥션 등을 예열(warm-up)하기 위함.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;램프업 단계 (2분, 20TPS &amp;rarr; 100TPS)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-indent-level=&quot;2&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;2분 동안 요청 속도를 점진적으로 100TPS까지 끌어올림.&lt;/li&gt;
&lt;li&gt;목적: 서비스가 점차 늘어나는 트래픽에 맞춰 확장성(Scalability)을 잘 보장하는지 확인.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;소크(Soak) 단계 (1분 30초, 100TPS 유지)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-indent-level=&quot;2&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;100TPS를 일정하게 유지하면서 시스템이 안정적으로 처리할 수 있는지 검증.&lt;/li&gt;
&lt;li&gt;목적: 지속 부하 상황에서 &lt;b&gt;지속 성능(throughput, latency, error율)&lt;/b&gt; 확인.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;램프다운 단계 (1분, 100TPS &amp;rarr; 0TPS)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-indent-level=&quot;2&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;1분 동안 점진적으로 요청 속도를 줄여 0TPS까지 내림.&lt;/li&gt;
&lt;li&gt;목적: 트래픽 감소 시 리소스(스레드, 커넥션, 메모리)가 정상적으로 해제되는지, &lt;b&gt;리소스 정리(tear-down)&lt;/b&gt; 과정을 확인.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;4069&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;3.-SLO-설정&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;4071&quot; data-ke-size=&quot;size23&quot;&gt;3. SLO 설정&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;4082&quot; data-ke-size=&quot;size16&quot;&gt;SLO란 서비스의 성능과 안정성을 위한 내부적인 지표로 테스트 결과를 감으로 보지 않고, 정량 기준으로 성공/실패 여부를 판단하기 위해 필요하다. READ/WRITE API를 분리해서 설정하는 것이 좋다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;4082&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;4201&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;권장 SLO&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-indent-level=&quot;1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;READ&lt;/b&gt;: p95 &amp;le; &lt;b&gt;300ms&lt;/b&gt; , p99 &amp;le; &lt;b&gt;600ms, &lt;/b&gt;1s 초과 비율 &amp;lt; &lt;b&gt;0.1%, &lt;/b&gt;실패율 &amp;lt; &lt;b&gt;0.0.1%&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;WRITE&lt;/b&gt;: p95 &amp;le; &lt;b&gt;500ms&lt;/b&gt;,&lt;b&gt; &lt;/b&gt;p99 &amp;le; &lt;b&gt;900ms&lt;/b&gt;,&lt;b&gt; &lt;/b&gt;1.5s 초과 비율 &amp;lt; &lt;b&gt;0.2%&lt;/b&gt;,&lt;b&gt; &lt;/b&gt;실패율 &amp;lt; &lt;b&gt;0.0.1%&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757469886492&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  thresholds: {
    // 레이턴시
    'http_req_duration': [
      'p(95)&amp;lt;500',     // p95 &amp;le; 500ms
      'p(99)&amp;lt;900',     // p99 &amp;le; 900ms
      'rate&amp;lt;=0.002@1.5s', // 1.5s 초과 비율 &amp;lt; 0.2%
    ],
    // 실패율 (0.1% 미만)
    'http_req_failed': ['rate&amp;lt;0.001'],
  },&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-indent-level=&quot;1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;평가 기준&lt;/b&gt;: k6 지표(p95/p99, 에러율), InfluxDB/Grafana 대시보드, Jaeger 트레이스(병목 구간) 종합 판단&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;4924&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;4.-대용량-데이터-적재&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;4926&quot; data-ke-size=&quot;size23&quot;&gt;4. 대용량 데이터 적재&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-indent-level=&quot;1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인덱스, 캐시, 버퍼 등 성능 변화 및 병목 구간 확인을 위해 10만~100만 건 데이터 적재&lt;/li&gt;
&lt;li&gt;주요 테이블: transaction, member, card 등&lt;/li&gt;
&lt;li&gt;데이터 생성: 프로시저 또는 &lt;a style=&quot;color: #1868db;&quot; href=&quot;https://www.mockaroo.com/&quot; data-testid=&quot;link-with-safety&quot; data-is-router-link=&quot;false&quot; data-renderer-mark=&quot;true&quot;&gt;Mockaroo &lt;/a&gt;활용&lt;/li&gt;
&lt;li&gt;예시: 사용자 10만 명, 카드 20만 개, 거래내역 50만 건&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;5108&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;5.-K6-스크립트-작성&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;5110&quot; data-ke-size=&quot;size23&quot;&gt;5. K6 스크립트 작성&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;5125&quot; data-ke-size=&quot;size16&quot;&gt;자세한 내용은 &lt;a style=&quot;color: #1868db;&quot; href=&quot;https://grafana.com/docs/k6/latest/&quot; data-testid=&quot;link-with-safety&quot; data-is-router-link=&quot;false&quot; data-renderer-mark=&quot;true&quot;&gt;K6 공식 문서&lt;/a&gt; 가 잘 작성되어 있기 때문에 읽어보자.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;5125&quot; data-ke-size=&quot;size16&quot;&gt;본 테스트에서는 점진적 부하 증가 시나리오로 테스트 진행했다.&lt;/p&gt;
&lt;pre id=&quot;code_1757469833605&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import http from 'k6/http';
import exec from 'k6/execution';
import {group, check} from 'k6';
import {randomString} from '../js/k6-utils.js';

export let options = {
  scenarios: {
    // 점진적 부하 증가 테스트
    ramping_load: {
      executor: 'ramping-arrival-rate',
      timeUnit: '1s',   // 요청이 만들어지는 기본 단위
      startRate: '0',   // 시작 TPS
      preAllocatedVUs: 20,
      maxVUs: 200,
      stages: [
        // 워밍업 (20TPS): 낮은 TPS에서 시작해서 천천히 올림 - 예열 단계
        {duration: '30s', target: 20},
        // 램프업 (100TPS): 목표 TPS까지 점진적으로 증가시키면서 시스템 확장성 확인
        {duration: '2m', target: 100},
        // 소크 (100TPS): 일정 TPS를 유지하면서 시스템 안정성 확인
        {duration: '1m30s',target: 100},
        // 램프다운 (0TPS): 부하를 점진적으로 감소시켜 리소스 정리 과정 확인
        {duration: '1m', target: 0},
      ]
    },
  },
  thresholds: {
    // 읽기 요청 SLO
    'http_req_duration{endopoint:read}': [
      'p(95)&amp;lt;300',     // 95% 요청 처리 시간 &amp;le; 300ms
      'p(99)&amp;lt;600',     // 99% 요청 처리 시간 &amp;le; 600ms
      'rate&amp;lt;=0.002@1s', // 1s 초과 비율 &amp;lt; 0.2%
    ],
    // 쓰기 요청 SLO
    'http_req_duration{endpoint:write}': [
      'p(95)&amp;lt;500',     // 95% 요청 처리 시간 &amp;le; 500ms
      'p(99)&amp;lt;900',     // 99% 요청 처리 시간 &amp;le; 900ms
      'rate&amp;lt;=0.002@1.5s', // 1.5s 초과 비율 &amp;lt; 0.2%
    ],
    http_req_failed: ['rate&amp;lt;0.001']  // 실패율 &amp;lt; 0.1%
  },
  tags: {
    testId: '${serverName}-${testId}',
    endpoint: 'write'  // 읽기 API 테스트: 'read', 쓰기 API 테스트: 'write'
  }
}

/**
 * 부하 테스트가 실행되기 전에 한 번 실행되는 function.
 * 테스트에 필요한 데이터를 생성하거나 외부에서 불러와 전달 가능
 */
const BASE_URL = 'http://localhost:8080';
export function setup() {
  // 부하테스트에 사용할 JWT 토큰 발급
  const url = BASE_URL + '/oauth/token';
  const basicAuth = 'Basic {yourAuthValue}';
  
  const params = {
    headers: {
      'Authorization': basicAuth,
    }
  }

  const response = http.post(url, null, params);
  const token = response.json('data.accessToken');

  return token;  // setup function의 리턴 값은 default 함수에서 인자로 받음
}

/**
 * 테스트 시나리오에 맞춰 반복적으로 실행되는 function
 */
export default function (token) {
  // 카드 발급 API 부하테스트
  const url = BASE_URL + '/your/api';
  const payload = JSON.stringify({
    cardNo: randomString(16, '0123456789'),
    trafficCardNo: randomString(16, '0123456789'),
    validDt: '3512',
  });

  const response = http.post(url, payload, params);
  
  check(response, {'status was 200': (r) =&amp;gt; r.status === 200});  // 응답 status가 200인지 체크
}

/**
 * 부하 테스트가 실행된 후에 한 번 실행되는 function.
 */
export function teardown(data) {}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;7608&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;부하-테스트-진행&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;7610&quot; data-ke-size=&quot;size26&quot;&gt;부하 테스트 진행&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;h4 id=&quot;1.-K6-스크립트-실행&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;7621&quot; data-ke-size=&quot;size20&quot;&gt;1. K6 스크립트 실행&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1757469855569&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;$ ./k6 run --out influxdb=http://localhost:15086/k6 scripts/script.js&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;2.-실시간-Dashboard-확인&quot; style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-renderer-start-pos=&quot;7711&quot; data-ke-size=&quot;size20&quot;&gt;2. 실시간 Dashboard 확인&lt;span&gt;&lt;span style=&quot;color: #505258;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;div style=&quot;background-color: #ffffff; color: #292a2e; text-align: start;&quot; data-expanded=&quot;false&quot; data-title=&quot;주요 지표 설명&quot; data-node-type=&quot;expand&quot; data-testid=&quot;expand-container-expand-expand-title-19&quot;&gt;
&lt;div data-testid=&quot;tooltip--container&quot;&gt;
&lt;div style=&quot;color: #292a2e;&quot;&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;1278&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cq7APC/btsQsQFROJa/j9ytfofPVnLxe8iroqcbmK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cq7APC/btsQsQFROJa/j9ytfofPVnLxe8iroqcbmK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cq7APC/btsQsQFROJa/j9ytfofPVnLxe8iroqcbmK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcq7APC%2FbtsQsQFROJa%2Fj9ytfofPVnLxe8iroqcbmK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1904&quot; height=&quot;1278&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;1278&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;주요 지표에 대한 설명은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;http_req_duration: 전체 HTTP 요청 소요 시간 (ms)&lt;/li&gt;
&lt;li&gt;http_req_blocked: 네트워크 스택 대기 시간(CPU, OS 포함)&lt;/li&gt;
&lt;li&gt;http_req_connecting: TCP 연결 설정 시간&lt;/li&gt;
&lt;li&gt;http_req_tls_handshaking: TLS hand shake 시간&lt;/li&gt;
&lt;li&gt;http_req_sending: 요청 데이터 전송 시간&lt;/li&gt;
&lt;li&gt;http_req_wating: 서버 응답 대기 시간 (서버 처릭 시간)&lt;/li&gt;
&lt;li&gt;http_req_receiving: 서버 응답 데잉터 수신 시간&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;span style=&quot;text-align: left;&quot;&gt;&lt;/span&gt;&lt;/div&gt;
&lt;h4 data-pm-slice=&quot;1 1 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-size=&quot;size20&quot;&gt;3. Jaeger를 통해 문제되는 요청에 대한 트레이스 확인&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1905&quot; data-origin-height=&quot;864&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vHQdr/btsQsQMCUGz/keCHylx9TB05hqIfBRwL0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vHQdr/btsQsQMCUGz/keCHylx9TB05hqIfBRwL0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vHQdr/btsQsQMCUGz/keCHylx9TB05hqIfBRwL0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvHQdr%2FbtsQsQMCUGz%2FkeCHylx9TB05hqIfBRwL0k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1905&quot; height=&quot;864&quot; data-origin-width=&quot;1905&quot; data-origin-height=&quot;864&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-pm-slice=&quot;1 3 []&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-size=&quot;size26&quot;&gt;개선하기&lt;/h2&gt;
&lt;h4 data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-size=&quot;size20&quot;&gt;문제 상황&lt;/h4&gt;
&lt;div data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;mediaSingle&quot; data-prosemirror-content-type=&quot;node&quot; data-media-vc-wrapper=&quot;true&quot;&gt;
&lt;div data-media-vc-wrapper=&quot;true&quot; data-width-type=&quot;pixel&quot; data-width=&quot;760&quot; data-layout=&quot;center&quot; data-node-type=&quot;mediaSingle&quot;&gt;
&lt;div style=&quot;color: #000000;&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;1278&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEz9FK/btsQpWU8Qo5/9iKSruaPL7lgOHIKknRk80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEz9FK/btsQpWU8Qo5/9iKSruaPL7lgOHIKknRk80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEz9FK/btsQpWU8Qo5/9iKSruaPL7lgOHIKknRk80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEz9FK%2FbtsQpWU8Qo5%2F9iKSruaPL7lgOHIKknRk80%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1904&quot; height=&quot;1278&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;1278&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;점진적 부하 테스트를 수행하던 중 특정 구간에서 요청 처리 시간이 급격히 증가하는 것을 확인할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-size=&quot;size20&quot;&gt;원인 분석&lt;/h4&gt;
&lt;div data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;mediaSingle&quot; data-prosemirror-content-type=&quot;node&quot; data-media-vc-wrapper=&quot;true&quot;&gt;
&lt;div data-media-vc-wrapper=&quot;true&quot; data-width-type=&quot;pixel&quot; data-width=&quot;760&quot; data-layout=&quot;center&quot; data-node-type=&quot;mediaSingle&quot;&gt;
&lt;div style=&quot;color: #000000;&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1905&quot; data-origin-height=&quot;864&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAPdsz/btsQrmMeRQj/cMBhluZSueYga5idwGHlE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAPdsz/btsQrmMeRQj/cMBhluZSueYga5idwGHlE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAPdsz/btsQrmMeRQj/cMBhluZSueYga5idwGHlE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAPdsz%2FbtsQrmMeRQj%2FcMBhluZSueYga5idwGHlE0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1905&quot; height=&quot;864&quot; data-origin-width=&quot;1905&quot; data-origin-height=&quot;864&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;Jaeger를 통해 문제되는 구간의 요청 Trace를 분석했다.&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;여러 요청에서 &lt;b&gt;스팬과 스팬 사이의 공백 구간이 길게 관찰&lt;/b&gt;되는 것을 볼 수 있다.&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;이는 애플리케이션 내부 로직 지연보다는 리소스 확보 대기 시간이 길어지고 있음을 의미한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-size=&quot;size23&quot;&gt;가설 설정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;트래픽이 증가하는 과정에서 커넥션 풀 사이즈가 부하 테스트 트래픽을 감당하지 못해 대기 시간이 증가했다고 판단했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-size=&quot;size23&quot;&gt;해결 시도&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;&lt;a href=&quot;https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing#pool-lockin&quot; data-prosemirror-mark-name=&quot;link&quot; data-prosemirror-content-type=&quot;mark&quot;&gt;HikariCP 공식 Wiki&lt;/a&gt;를 참고하여 &lt;span data-prosemirror-mark-name=&quot;code&quot; data-prosemirror-content-type=&quot;mark&quot;&gt;maximumPoolSize&lt;/span&gt; 값을 조정&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757478856013&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;pool size = Tn x (Cm - 1) + 1&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;Tn: 전체 Thread 갯수&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;Cm: 하나의 Task에서 동시에 필요한 Connection 수&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757478864172&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;pool size = 200 x (2 - 1) + 1 = 201&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757478905304&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring: 
   datasource: 
      hikari: 
         maximum-pool-size: 201 # 10 -&amp;gt; 201 변경&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;heading&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;div data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;mediaSingle&quot; data-prosemirror-content-type=&quot;node&quot; data-media-vc-wrapper=&quot;true&quot;&gt;
&lt;div data-media-vc-wrapper=&quot;true&quot; data-width-type=&quot;pixel&quot; data-width=&quot;760&quot; data-layout=&quot;center&quot; data-node-type=&quot;mediaSingle&quot;&gt;
&lt;div style=&quot;color: #000000;&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;1274&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4pP4r/btsQsRrgu8P/ry3Vk8SpteKyJsoU9JEvP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4pP4r/btsQsRrgu8P/ry3Vk8SpteKyJsoU9JEvP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4pP4r/btsQsRrgu8P/ry3Vk8SpteKyJsoU9JEvP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4pP4r%2FbtsQsRrgu8P%2Fry3Vk8SpteKyJsoU9JEvP1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1904&quot; height=&quot;1274&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;1274&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1906&quot; data-origin-height=&quot;865&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/myV1t/btsQs3SudNF/KEGRP8jrqhJGfYKqZVMXcK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/myV1t/btsQs3SudNF/KEGRP8jrqhJGfYKqZVMXcK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/myV1t/btsQs3SudNF/KEGRP8jrqhJGfYKqZVMXcK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmyV1t%2FbtsQs3SudNF%2FKEGRP8jrqhJGfYKqZVMXcK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1906&quot; height=&quot;865&quot; data-origin-width=&quot;1906&quot; data-origin-height=&quot;865&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;bulletList&quot; data-prosemirror-content-type=&quot;node&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;동일한 조건에서 다시 부하 테스트 진행했다.&lt;/li&gt;
&lt;li data-prosemirror-node-block=&quot;true&quot; data-prosemirror-node-name=&quot;listItem&quot; data-prosemirror-content-type=&quot;node&quot;&gt;지표 상 요청 처리 시간이 안정화되고, Trace 상 스팬 간 공백 구간이 크게 줄어든 것을 확인할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://grafana.com/docs/grafana/latest/getting-started/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://grafana.com/docs/grafana/latest/getting-started/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1759141875834&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Get started with Grafana Open Source | Grafana documentation&quot; data-og-description=&quot;Getting started with managing your metrics, logs, and traces using Grafana In this webinar, we&amp;rsquo;ll demo how to get started using the LGTM Stack: Loki for logs, Grafana for visualization, Tempo for traces, and Mimir for metrics.&quot; data-og-host=&quot;grafana.com&quot; data-og-source-url=&quot;https://grafana.com/docs/grafana/latest/getting-started/&quot; data-og-url=&quot;https://grafana.com/docs/grafana/latest/getting-started/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/DPvEK/hyZJ0sLO4G/FRnnw5p7XeLCuuHQHOlYyK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/be2Cn8/hyZJBAfj7u/BYI4PXTydAKr9h2ToAkPA1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://grafana.com/docs/grafana/latest/getting-started/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://grafana.com/docs/grafana/latest/getting-started/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/DPvEK/hyZJ0sLO4G/FRnnw5p7XeLCuuHQHOlYyK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/be2Cn8/hyZJBAfj7u/BYI4PXTydAKr9h2ToAkPA1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Get started with Grafana Open Source | Grafana documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Getting started with managing your metrics, logs, and traces using Grafana In this webinar, we&amp;rsquo;ll demo how to get started using the LGTM Stack: Loki for logs, Grafana for visualization, Tempo for traces, and Mimir for metrics.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;grafana.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.influxdata.com/influxdb/v2/get-started/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.influxdata.com/influxdb/v2/get-started/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1759141890982&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Get started with InfluxDB | InfluxDB OSS v2 Documentation&quot; data-og-description=&quot;Thank you for your feedback! Let us know what we can do better:&quot; data-og-host=&quot;docs.influxdata.com&quot; data-og-source-url=&quot;https://docs.influxdata.com/influxdb/v2/get-started/&quot; data-og-url=&quot;https://docs.influxdata.com/influxdb/v2/get-started/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.influxdata.com/influxdb/v2/get-started/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.influxdata.com/influxdb/v2/get-started/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Get started with InfluxDB | InfluxDB OSS v2 Documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Thank you for your feedback! Let us know what we can do better:&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.influxdata.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>etc</category>
      <category>Grafana k6 Jaeger 부하테스트</category>
      <category>InfluxDB K6 Grafana 부하테스트</category>
      <author>Cold Bean</author>
      <guid isPermaLink="true">https://green-bin.tistory.com/298</guid>
      <comments>https://green-bin.tistory.com/298#entry298comment</comments>
      <pubDate>Thu, 4 Sep 2025 15:04:08 +0900</pubDate>
    </item>
    <item>
      <title>Querydsl - Cannot invoke &amp;quot;com.querydsl.core.types.Expressions.accept(com.querydsl.core.types.Visitor, Object)&amp;quot; because &amp;quot;arg&amp;quot; is null&amp;quot; 원인과 해결 방법</title>
      <link>https://green-bin.tistory.com/289</link>
      <description>&lt;pre id=&quot;code_1755699030436&quot; class=&quot;java&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;Cannot invoke &quot;com.querydsl.core.types.Expressions.accept(com.querydsl.core.types.Visitor, Object)&quot; because &quot;arg&quot; is null&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Querydsl에서 @QueryProjection을 사용해 DTO를 조회하는 과정에서 발생한 예외이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Querydsl에서&amp;nbsp;DTO를&amp;nbsp;@QueryProjection으로&amp;nbsp;조회할&amp;nbsp;때,&amp;nbsp;select&amp;nbsp;절에&amp;nbsp;일부&amp;nbsp;필드를&amp;nbsp;null로&amp;nbsp;반환하고자&amp;nbsp;직접&amp;nbsp;null을&amp;nbsp;명시하면&amp;nbsp;아래와&amp;nbsp;같은&amp;nbsp;예외가&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예외가 발생한 코드는 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1755698889415&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.Projections;

QMember member = QMember.member;

List&amp;lt;MemberDto&amp;gt; results = queryFactory.select(Projections.constructor(MemberDto.class, 
        member.id,
        null, // 에러 원인
        member.email))
    .from(member)
    .fetch();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;null 대신 Querydsl에서 제공하는 Expressions.nullExpression(필드타입.class)을 사용해 null 값을 표현해야 한다.&amp;nbsp;타입을&amp;nbsp;명확히&amp;nbsp;지정해서&amp;nbsp;빈&amp;nbsp;값을&amp;nbsp;만들어주는&amp;nbsp;셈이다.&lt;/p&gt;
&lt;pre id=&quot;code_1755698989346&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.Projections;

QMember member = QMember.member;

List&amp;lt;MemberDto&amp;gt; results = queryFactory.select(Projections.constructor(MemberDto.class, 
        member.id,
        Expressions.nullExpression(String.class),  // null 대체
        member.email))
    .from(member)
    .fetch();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>나의 에러 일지</category>
      <category>Cannot invoke &amp;quot;com.querydsl.core.types.Expressions.accept(com.querydsl.core.types.Visitor 해결 방법</category>
      <category>Object)&amp;quot; because &amp;quot;arg&amp;quot; is null&amp;quot;</category>
      <author>Cold Bean</author>
      <guid isPermaLink="true">https://green-bin.tistory.com/289</guid>
      <comments>https://green-bin.tistory.com/289#entry289comment</comments>
      <pubDate>Mon, 28 Jul 2025 15:56:32 +0900</pubDate>
    </item>
    <item>
      <title>Spring - ContentCachingRequestWrapper에서 getContentAsByteArray가 빈 값을 반환하는 이유와 해결 방법</title>
      <link>https://green-bin.tistory.com/287</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 프로젝트에서 Request Body로부터 traceId를 전달받아 이를 저장하고, 이후 로깅이나 트레이싱 등에 활용해야 하는 요구사항이 있었다. 이를 위해 Spring에서 제공하는 ContentCachingRequestWrapper를 사용하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ContentCachingRequestWrapper는 원래 InputStream, Reader, 혹은 @RequestBody로 Request Body를 한번 읽고 나면 다시 읽을 수 없다는 한계를 깔끔하게 해결해 주는 유용한 클래스이다. 덕분에 traceId 같은 중요한 데이터를 여러 계층에서 반복적으로 로깅하거나 재사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 실제로 적용하는 과정에서, ContentCachingRequestWrapper로 감쌌음에도 불구하고 Request Body에서 null이나 빈 값이 반환되는 현상을 경험하게 됐다. 이번 글에서는 이 트러블슈팅 경험과 원인, 그리고 실질적인 해결 방법을 정리해 공유한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 아래 링크에서 확인할 수 있다.&lt;/p&gt;
&lt;figure id=&quot;og_1752761011298&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;practice-java-spring/spring-data-envers at c9d7609aa09d546408c531ede48434ca5dfb27f9 &amp;middot; chanbinme/practice-java-spring&quot; data-og-description=&quot;Contribute to chanbinme/practice-java-spring development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/chanbinme/practice-java-spring/tree/c9d7609aa09d546408c531ede48434ca5dfb27f9/spring-data-envers&quot; data-og-url=&quot;https://github.com/chanbinme/practice-java-spring/tree/c9d7609aa09d546408c531ede48434ca5dfb27f9/spring-data-envers&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bfEcuW/hyZnbAdh6j/wBN5HcmtcZCYcd8XRjjkfk/img.png?width=1200&amp;amp;height=600&amp;amp;face=963_151_1045_241,https://scrap.kakaocdn.net/dn/bzjuy0/hyZjdM8Szw/oCiAmI2noaSdDME3tvRus1/img.png?width=1200&amp;amp;height=600&amp;amp;face=963_151_1045_241&quot;&gt;&lt;a href=&quot;https://github.com/chanbinme/practice-java-spring/tree/c9d7609aa09d546408c531ede48434ca5dfb27f9/spring-data-envers&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/chanbinme/practice-java-spring/tree/c9d7609aa09d546408c531ede48434ca5dfb27f9/spring-data-envers&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bfEcuW/hyZnbAdh6j/wBN5HcmtcZCYcd8XRjjkfk/img.png?width=1200&amp;amp;height=600&amp;amp;face=963_151_1045_241,https://scrap.kakaocdn.net/dn/bzjuy0/hyZjdM8Szw/oCiAmI2noaSdDME3tvRus1/img.png?width=1200&amp;amp;height=600&amp;amp;face=963_151_1045_241');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;practice-java-spring/spring-data-envers at c9d7609aa09d546408c531ede48434ca5dfb27f9 &amp;middot; chanbinme/practice-java-spring&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to chanbinme/practice-java-spring development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;%EA%B0%9C%EB%B0%9C%20%ED%99%98%EA%B2%BD-1&quot; style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;개발 환경&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Java 17&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Spring Boot 3.4.x&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Gradle&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;IntelliJ&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;RequestCachingFilter&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번만 읽을 수 있는 Request를 ContentCachingRequestWrapper로 감싸 여러 번 읽을 수 있도록 하는 Filter이다. 반드시 filterChain.doFilter()에 Wrapper로 감싼 request를 전달해야 이후 계층(Filter, Controller, ExceptionHandler 등)에서 Body에 접근할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1752760013248&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * 요청을 ContentCachingRequestWrapper로 래핑하여 요청 본문을 캐싱하는 필터.
 * 이 필터는 요청 본문을 읽을 수 있도록 하여, 이후의 필터나 컨트롤러에서 요청 본문에 접근할 수 있게 한다.
 */
@Component
@Order(1)
public class RequescCachingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
        filterChain.doFilter(wrappedRequest, response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;TraceIdFilter&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;request를 ContentCachingRequestWrapper로 cast해서 body에 있는 traceId를 조회하는 Filter이다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1752760026896&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * 요청 본문에서 traceId를 추출하는 필터.
 * 이 필터는 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽고, traceId를 추출하여 로그에 출력한다.
 */
@Component
@Order(2)
public class TraceIdFilter extends OncePerRequestFilter {

    private static final String TRACE_ID_FIELD = &quot;traceId&quot;;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (request instanceof ContentCachingRequestWrapper cachingRequest) {
            // 요청의 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽을 수 있다.
            String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());

            // 요청 본문에서 traceId를 추출한다.
            String traceId = extractTraceId(requestBody);

            // traceId를 로그에 출력하거나 다른 용도로 사용할 수 있습니다.
            System.out.println(&quot;############### Trace ID Filter Start ###############&quot;);
            System.out.println(&quot;Trace ID: &quot; + traceId);
            System.out.println(&quot;############### Trace ID Filter End ###############&quot;);
        }

        filterChain.doFilter(request, response);
    }

    private String extractTraceId(String requestBody) throws JsonProcessingException {
        JsonNode node = objectMapper.readTree(requestBody);
        if (node.has(TRACE_ID_FIELD)) {
            return node.get(TRACE_ID_FIELD).asText();
        }
        return null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 구현한 후 실제로 traceId를 로그에 찍어보았지만, null 값이 출력됐다.&lt;/p&gt;
&lt;pre id=&quot;code_1752761634653&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;############### Trace ID Filter Start ###############
Trace ID: null
############### Trace ID Filter End ###############&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Request Body 자체가 아예 오지 않는 건 아닌지 의심했지만, CommonsRequestLoggingFilter의 로그를 보면 정상적으로 Body 값이 들어가는 것을 확인할 수 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1752761693318&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;2025-07-17T23:13:34.261+09:00 DEBUG 23020 --- [nio-8080-exec-1] o.s.w.f.CommonsRequestLoggingFilter      : Request Data: POST /posts, payload={
    &quot;traceId&quot;: &quot;00001&quot;,
    &quot;title&quot;: &quot;배고픈데&quot;,
    &quot;content&quot;: &quot;뭐먹지&quot;
}]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 조회되지 않는지 원인과 해결 방법을 알아보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 문제의 원인은 ContentCachingRequestWrapper의 동작 원리를 완전히 이해하지 못한 것에 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 주석을 보면 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;694&quot; data-origin-height=&quot;133&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TI8Eu/btsPnehj804/pGji88C8Hjsf7b2bYshrtK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TI8Eu/btsPnehj804/pGji88C8Hjsf7b2bYshrtK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TI8Eu/btsPnehj804/pGji88C8Hjsf7b2bYshrtK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTI8Eu%2FbtsPnehj804%2FpGji88C8Hjsf7b2bYshrtK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;694&quot; height=&quot;133&quot; data-origin-width=&quot;694&quot; data-origin-height=&quot;133&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;입력 스트림과 리더(reader)로부터 읽은 모든 내용을 캐시하고, 이 내용을 바이트 배열을 통해 가져올 수 있게 해주는 HttpServletRequest 래퍼입니다. 이 클래스는 실제로 내용이 읽힐 때만 그 내용을 캐시 하는 인터셉터 역할을 하며, 그렇지 않은 경우에는 내용을 강제로 읽지 않습니다. 즉, 요청의 내용이 소비되지 않으면 캐시도 되지 않으므로, getContentAsByteArray()를 통해 내용을 가져올 수 없습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, ContentCachingRequestWrapper로 감싸더라도, 요청 Body가 실제로 한 번이라도 InputStream (또는 Reader, @RequestBody 등)으로 읽혀야 캐시가 생긴다는 것이.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하자면, 개발자가 getInputStream()이나 getReader()로 Request Body를 수동으로 읽지 않는 이상, 그리고 컨트롤러의 @RequestBody가 호출되지 않은 상태라면, ContentCachingRequestWrapper 내부 캐시는 비어 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Body를 제대로 얻으려면 @RequestBody 등에서 본문을 읽은 시점 이후의 계층(예: 인터셉터의 postHandle, afterCompletion같은)에서 getContentAsByteArray()를 사용해야 한다. 보통 Filter는 DispatcherServlet보다 먼저 실행되므로, Filter에서는 Body가 아직 읽히지 않은 상태이기 때문에 getContentAsByteArray()가 빈 배열을 반환하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각만 하면 의미가 없기 때문에 실제로 그런지 테스트해보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;TraceIdInterceptor&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 interceptor에서 각 메서드별로 wrapper 감싸진 request에서 traceId를 조회하도록 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1752759989014&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * 요청에서 traceId를 추출하는 인터셉터.
 * ContentCachingRequestWrapper로 감싼다고 해서 body를 읽을 수 있는 것은 아니다.
 * body가 한 번도 읽히지 않았다면, getContentAsByteArray()는 빈 배열을 반환한다.
 * 일반적으로 body가 읽히는 시점은 @RequestBody가 있는 컨트롤러 메소드가 호출될 때이다.
 */
@Component
@RequiredArgsConstructor
public class TraceIdInterceptor implements HandlerInterceptor {

    private static final String TRACE_ID_FIELD = &quot;traceId&quot;;

    private final ObjectMapper objectMapper;

    /**
     * 요청이 컨트롤러에 도달하기 전에 호출되는 메소드.
     * 아직 @RequestBoyd를 호출하지 않았기 때문에 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽을 수 없다.
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (request instanceof ContentCachingRequestWrapper cachingRequest) {
            // 요청의 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽을 수 있다.
            String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());

            // 요청 본문에서 traceId를 추출한다.
            String traceId = extractTraceId(requestBody);

            // traceId를 로그에 출력하거나 다른 용도로 사용할 수 있습니다.
            System.out.println(&quot;############### Trace ID Interceptor.preHandle Start ###############&quot;);
            System.out.println(&quot;Trace ID: &quot; + traceId);
            System.out.println(&quot;############### Trace ID Interceptor.preHandle End ###############&quot;);
        }

        return true;
    }

    /**
     * 컨트롤러 메소드가 호출된 후에 호출되는 메소드.
     * 이 시점에서는 @RequestBody가 호출되어 ContentCachingRequestWrapper가 요청 본문을 캐싱했기 때문에, 본문을 읽을 수 있다.
     * 하지만 예외가 발생하면 이 메소드는 호출되지 않는다.
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (request instanceof ContentCachingRequestWrapper cachingRequest) {
            // 요청의 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽을 수 있다.
            String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());

            // 요청 본문에서 traceId를 추출한다.
            String traceId = extractTraceId(requestBody);

            // traceId를 로그에 출력하거나 다른 용도로 사용할 수 있습니다.
            System.out.println(&quot;############### Trace ID Interceptor.postHandle Start ###############&quot;);
            System.out.println(&quot;Trace ID: &quot; + traceId);
            System.out.println(&quot;############### Trace ID Interceptor.postHandle End ###############&quot;);
        }
    }

    /**
     * 컨트롤러 메소드가 호출된 후에 호출되는 메소드.
     * 이 시점에서는 @RequestBody가 호출되어 ContentCachingRequestWrapper가 요청 본문을 캐싱했기 때문에, 본문을 읽을 수 있다.
     * afterCompletion 메소드는 요청 처리 후에 항상 호출되며, 예외가 발생하더라도 호출된다.
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        if (request instanceof ContentCachingRequestWrapper cachingRequest) {
            // 요청의 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽을 수 있다.
            String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());

            // 요청 본문에서 traceId를 추출한다.
            String traceId = extractTraceId(requestBody);

            // traceId를 로그에 출력하거나 다른 용도로 사용할 수 있습니다.
            System.out.println(&quot;############### Trace ID Interceptor.afterCompletion Start ###############&quot;);
            System.out.println(&quot;Trace ID: &quot; + traceId);
            System.out.println(&quot;############### Trace ID Interceptor.afterCompletion End ###############&quot;);
        }
    }

    private String extractTraceId(String requestBody) throws JsonProcessingException {
        JsonNode node = objectMapper.readTree(requestBody);
        if (node.has(TRACE_ID_FIELD)) {
            return node.get(TRACE_ID_FIELD).asText();
        }
        return null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752762917953&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@EnableWebMvc
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final ObjectMapper objectMapper;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new TraceIdInterceptor(objectMapper))
                .addPathPatterns(&quot;/**&quot;) // 모든 경로에 대해 인터셉터 적용
                .excludePathPatterns(&quot;/error&quot;); // 에러 페이지는 제외
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Controller&lt;/h4&gt;
&lt;pre id=&quot;code_1752762939916&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/posts&quot;)
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @PostMapping
    public void createPost(@RequestBody PostCreateRequestDto postCreateRequestDto) {
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752763015421&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;############### Trace ID Interceptor.preHandle Start ###############
Trace ID: null
############### Trace ID Interceptor.preHandle End ###############

############### Trace ID Interceptor.postHandle Start ###############
Trace ID: 00001
############### Trace ID Interceptor.postHandle End ###############

############### Trace ID Interceptor.afterCompletion Start ###############
Trace ID: 00001
############### Trace ID Interceptor.afterCompletion End ###############&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상한 대로 postHandler, afterCompletion에서 조회되는 걸 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예외가 발생했을 때는 어떻게 될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1752763076082&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/posts&quot;)
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @PostMapping
    public void createPost(@RequestBody PostCreateRequestDto postCreateRequestDto) {
        throw new Exception(&quot;test&quot;);    // 예외 발생
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752763143944&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;############### Trace ID Filter Start ###############
Trace ID: null
############### Trace ID Filter End ###############

############### Trace ID Interceptor.preHandle Start ###############
Trace ID: null
############### Trace ID Interceptor.preHandle End ###############

############### Trace ID Interceptor.afterCompletion Start ###############
Trace ID: 00001
############### Trace ID Interceptor.afterCompletion End ###############&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;afterCompletion()에서만 호출되는 걸 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 외에도 Filter에서 filterChain.doFilter()를 호출한 이후에 Request를 조회하면 요청이 완료된 이후 DispatcherServlet - WAS 후처리를 담당하기 때문에 조회할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1752763433455&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class TraceIdFilter extends OncePerRequestFilter {

    private static final String TRACE_ID_FIELD = &quot;traceId&quot;;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (request instanceof ContentCachingRequestWrapper cachingRequest) {
            String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());

            String traceId = extractTraceId(requestBody);

            System.out.println(&quot;############### Trace ID Filter.doFilter before Start ###############&quot;);
            System.out.println(&quot;Trace ID: &quot; + traceId);
            System.out.println(&quot;############### Trace ID Filter.doFilter before End ###############&quot;);
        }

        try {
            filterChain.doFilter(request, response);
        } finally {
            if (request instanceof ContentCachingRequestWrapper cachingRequest) {
                String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());

                String traceId = extractTraceId(requestBody);

                System.out.println(&quot;############### Trace ID Filter.doFilter after Start ###############&quot;);
                System.out.println(&quot;Trace ID: &quot; + traceId);
                System.out.println(&quot;############### Trace ID Filter.doFilter after End ###############&quot;);
            }
        }
    }

    private String extractTraceId(String requestBody) throws JsonProcessingException {
        JsonNode node = objectMapper.readTree(requestBody);
        if (node.has(TRACE_ID_FIELD)) {
            return node.get(TRACE_ID_FIELD).asText();
        }
        return null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752763455047&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;############### Trace ID Filter.doFilter before Start ###############
Trace ID: null
############### Trace ID Filter.doFilter before End ###############

############### Trace ID Filter.doFilter after Start ###############
Trace ID: 00001
############### Trace ID Filter.doFilter after End ###############&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 다음과 같다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 231px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 21px;&quot;&gt;&lt;b&gt;위치&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.938%; height: 21px;&quot;&gt;&lt;b&gt;Body 조회 가능 여부&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 49.8449%; height: 21px;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 42px;&quot;&gt;Filter&amp;nbsp;내부(doFilter)&lt;/td&gt;
&lt;td style=&quot;width: 21.938%; height: 42px;&quot;&gt;X&amp;nbsp;(body&amp;nbsp;미소비)&lt;/td&gt;
&lt;td style=&quot;width: 49.8449%; height: 42px;&quot;&gt;Controller에서&amp;nbsp;@RequestBody가&amp;nbsp;호출되기&amp;nbsp;전&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 42px;&quot;&gt;Controller(@RequestBody)&lt;/td&gt;
&lt;td style=&quot;width: 21.938%; height: 42px;&quot;&gt;O&amp;nbsp;(body&amp;nbsp;읽음)&lt;/td&gt;
&lt;td style=&quot;width: 49.8449%; height: 42px;&quot;&gt;실제로&amp;nbsp;Body가&amp;nbsp;소비됨,&amp;nbsp;Wrapper에&amp;nbsp;캐시가&amp;nbsp;생김&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 21px;&quot;&gt;Interceptor&amp;nbsp;preHandle&lt;/td&gt;
&lt;td style=&quot;width: 21.938%; height: 21px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;width: 49.8449%; height: 21px;&quot;&gt;@RequestBody&amp;nbsp;호출&amp;nbsp;전이므로&amp;nbsp;캐시&amp;nbsp;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 42px;&quot;&gt;Interceptor&amp;nbsp;postHandle&lt;/td&gt;
&lt;td style=&quot;width: 21.938%; height: 42px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 49.8449%; height: 42px;&quot;&gt;@RequestBody&amp;nbsp;이후이므로&amp;nbsp;캐시로&amp;nbsp;본문&amp;nbsp;획득&amp;nbsp;가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 21px;&quot;&gt;Interceptor&amp;nbsp;afterCompletion&lt;/td&gt;
&lt;td style=&quot;width: 21.938%; height: 21px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 49.8449%; height: 21px;&quot;&gt;항상&amp;nbsp;호출됨,&amp;nbsp;예외&amp;nbsp;상황&amp;nbsp;포함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 42px;&quot;&gt;Filter(doFilter&amp;nbsp;이후)&lt;/td&gt;
&lt;td style=&quot;width: 21.938%; height: 42px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 49.8449%; height: 42px;&quot;&gt;요청&amp;nbsp;후에는&amp;nbsp;DispatcherServlet을&amp;nbsp;지나&amp;nbsp;Body&amp;nbsp;캐싱&amp;nbsp;완료&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ContentCachingRequestWrapper 사용 시 주의할 점은 Request Body가 실제로 한 번이라도 읽혀야 캐싱이 이루어진다는 점이다. Filter 등 DispatcherServlet 이전에는 Body를 바로 읽을 수 없으니, 반드시 적절한 위치(컨트롤러 @RequestBody 이후, Interceptor의 postHandle/afterCompletion 등)에서 본문을 조회해야 원하는 데이터를 얻을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 점만 명확히 알고 있으면, 실무에서 불필요하게 디버깅에 시간을 낭비하지 않을 수 있으니, 꼭 기억해 두자! (필자는 오늘 이거 때문에 꽤 많은 시간을 소비했다.. ㅎㅎ)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/docs/4.2.3.RELEASE_to_4.2.4.RELEASE/Spring%20Framework%204.2.4.RELEASE/org/springframework/web/util/ContentCachingRequestWrapper.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.spring.io/spring-framework/docs/4.2.3.RELEASE_to_4.2.4.RELEASE/Spring%20Framework%204.2.4.RELEASE/org/springframework/web/util/ContentCachingRequestWrapper.html&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1752763538194&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;ContentCachingRequestWrapper&quot; data-og-description=&quot;&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-framework/docs/4.2.3.RELEASE_to_4.2.4.RELEASE/Spring%20Framework%204.2.4.RELEASE/org/springframework/web/util/ContentCachingRequestWrapper.html&quot; data-og-url=&quot;https://docs.spring.io/spring-framework/docs/4.2.3.RELEASE_to_4.2.4.RELEASE/Spring%20Framework%204.2.4.RELEASE/org/springframework/web/util/ContentCachingRequestWrapper.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/docs/4.2.3.RELEASE_to_4.2.4.RELEASE/Spring%20Framework%204.2.4.RELEASE/org/springframework/web/util/ContentCachingRequestWrapper.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-framework/docs/4.2.3.RELEASE_to_4.2.4.RELEASE/Spring%20Framework%204.2.4.RELEASE/org/springframework/web/util/ContentCachingRequestWrapper.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;ContentCachingRequestWrapper&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.spring.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/79441441/spring-boot-3-4-2-contentcachingrequestwrapper-returns-an-exhausted-inputstream&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://stackoverflow.com/questions/79441441/spring-boot-3-4-2-contentcachingrequestwrapper-returns-an-exhausted-inputstream&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>나의 에러 일지</category>
      <category>contentcachingrequestwrapper getcontentasbytearray is empty</category>
      <category>contentcachingrequestwrapper null 원인</category>
      <category>contentcachingrequestwrapper 조회 안됨</category>
      <author>Cold Bean</author>
      <guid isPermaLink="true">https://green-bin.tistory.com/287</guid>
      <comments>https://green-bin.tistory.com/287#entry287comment</comments>
      <pubDate>Thu, 17 Jul 2025 17:03:17 +0900</pubDate>
    </item>
    <item>
      <title>Spring - 딸깍으로 쉽게 Request 로그 남기기(CommonsRequestLoggingFilter)</title>
      <link>https://green-bin.tistory.com/285</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션을 운영하다 보면, 클라이언트의 요청이 어떻게 들어오는지, 어떤 데이터가 전달되는지 로그를 남겨둬야 할 때가 있다. 특히, 복잡한 API를 개발하거나, 에러 대응 및 디버깅이 필요할 때 요청 정보를 간단하게 로깅할 수 있다면 작업 효율이 크게 올라갈 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 진행중인 프로젝트에서 Request에 대한 로깅이 필요했고, Spring에서 제공하는 CommonsRequestLoggingFilter라는 도구를 알게되었다. 복잡한 설정 없이 손쉽게 Request 정보를 로깅할 수 있다는 장점이 있다. 이 글에서는 CommonsRequestLoggingFilter를 이용해 딸깍 한 번으로 쉽게 Request 요청을 로그로 남길 수 있는지 공유하려고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 아래 링크에서 확인할 수 있다.&lt;/p&gt;
&lt;figure id=&quot;og_1752710561464&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;practice-java-spring/spring-data-envers at master &amp;middot; chanbinme/practice-java-spring&quot; data-og-description=&quot;Contribute to chanbinme/practice-java-spring development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/chanbinme/practice-java-spring/tree/master/spring-data-envers&quot; data-og-url=&quot;https://github.com/chanbinme/practice-java-spring/tree/master/spring-data-envers&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bTfstO/hyZnwEbbq4/HViUGKVn6Jf4pj7mWOtIfK/img.png?width=1200&amp;amp;height=600&amp;amp;face=963_151_1045_241,https://scrap.kakaocdn.net/dn/fIx36/hyZjpGNIfa/dzMeB7jO77e18ZyfsLovBK/img.png?width=1200&amp;amp;height=600&amp;amp;face=963_151_1045_241&quot;&gt;&lt;a href=&quot;https://github.com/chanbinme/practice-java-spring/tree/master/spring-data-envers&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/chanbinme/practice-java-spring/tree/master/spring-data-envers&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bTfstO/hyZnwEbbq4/HViUGKVn6Jf4pj7mWOtIfK/img.png?width=1200&amp;amp;height=600&amp;amp;face=963_151_1045_241,https://scrap.kakaocdn.net/dn/fIx36/hyZjpGNIfa/dzMeB7jO77e18ZyfsLovBK/img.png?width=1200&amp;amp;height=600&amp;amp;face=963_151_1045_241');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;practice-java-spring/spring-data-envers at master &amp;middot; chanbinme/practice-java-spring&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to chanbinme/practice-java-spring development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;%EA%B0%9C%EB%B0%9C%20%ED%99%98%EA%B2%BD-1&quot; style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;개발 환경&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Java 17&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Spring Boot 3.4.x&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Gradle&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;IntelliJ&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CommonsRequestLoggingFilter이란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CommonsRequestLoggingFilter는 Spring에서 제공하는 서블릿 필터로, HTTP 요청 정보를 자동으로 로그로 남겨주는 기능을 제공한다. 별도의 코드 작성 없이 Bean 등록과&amp;nbsp; 간단한 설정만으로 로그 출력을 활성화 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RequestLoggingConfig&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 코드를 작성할 필요 없이 아래와 같이 CommonsRequestLoggingFilter의 빈 설정만 해주면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1752675550050&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class RequestLoggingConfig {

    @Bean
    public CommonsRequestLoggingFilter requestLoggingFilter() {
        CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
        filter.setIncludeClientInfo(false);  // IP 주소 및 사용자 에이전트 정보 포함
        filter.setIncludeQueryString(false); // 쿼리 문자열 포함
        filter.setIncludePayload(true); // 요청 페이로드 포함
        filter.setMaxPayloadLength(10000); // 최대 페이로드 길이 설정
        filter.setIncludeHeaders(false); // 헤더 정보 포함
        filter.setAfterMessagePrefix(&quot;Request Data: &quot;); // 로그 메시지 접두사 설정

        return filter;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 설정 옵션은 아래와 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;setIncludeClientInfo(boolean)&lt;/b&gt;:&amp;nbsp;클라이언트&amp;nbsp;IP,&amp;nbsp;세션&amp;nbsp;ID&amp;nbsp;등의&amp;nbsp;정보&amp;nbsp;포함&amp;nbsp;여부&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setIncludeHeaders(boolean)&lt;/b&gt;:&amp;nbsp;HTTP&amp;nbsp;헤더&amp;nbsp;로그&amp;nbsp;포함&amp;nbsp;여부&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setIncludePayload(boolean)&lt;/b&gt;:&amp;nbsp;Request&amp;nbsp;body(본문)&amp;nbsp;로그&amp;nbsp;포함&amp;nbsp;여부&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setIncludeQueryString(boolean)&lt;/b&gt;:&amp;nbsp;쿼리&amp;nbsp;파라미터&amp;nbsp;포함&amp;nbsp;여부&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setMaxPayloadLength(int)&lt;/b&gt;:&amp;nbsp;로그로&amp;nbsp;남기는&amp;nbsp;본문의&amp;nbsp;최대&amp;nbsp;길이&amp;nbsp;설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;application.yml&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그 출력 레벨은 기본적으로 DEBUG이므로, CommonsRequestLoggingFilter의 로깅 레벨을 DEBUG로 맞추면 로그가 남기 시작한다.&lt;/p&gt;
&lt;pre id=&quot;code_1752675578021&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;logging:
  level:
    org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PostController&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1752675803492&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/posts&quot;)
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;
    
    @PostMapping
    public void createPost(@RequestBody PostCreateRequestDto postCreateRequestDto) {
        postService.createPost(postCreateRequestDto);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;905&quot; data-origin-height=&quot;701&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lNz8j/btsPmw2oMoP/axaimUlSnQBGB1xcoGNF80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lNz8j/btsPmw2oMoP/axaimUlSnQBGB1xcoGNF80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lNz8j/btsPmw2oMoP/axaimUlSnQBGB1xcoGNF80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlNz8j%2FbtsPmw2oMoP%2FaxaimUlSnQBGB1xcoGNF80%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;905&quot; height=&quot;701&quot; data-origin-width=&quot;905&quot; data-origin-height=&quot;701&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;821&quot; data-origin-height=&quot;130&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rh6p8/btsPm09Wul3/bvZdhk6DcvzgevLD1F2C40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rh6p8/btsPm09Wul3/bvZdhk6DcvzgevLD1F2C40/img.png&quot; data-alt=&quot;setIncludePayload 옵션만 true로 설정했을 때&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rh6p8/btsPm09Wul3/bvZdhk6DcvzgevLD1F2C40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Frh6p8%2FbtsPm09Wul3%2FbvZdhk6DcvzgevLD1F2C40%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;821&quot; height=&quot;130&quot; data-origin-width=&quot;821&quot; data-origin-height=&quot;130&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;setIncludePayload 옵션만 true로 설정했을 때&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;812&quot; data-origin-height=&quot;209&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0gXlo/btsPlstfB1A/FJftU3OXmeZT2yKY9b1XOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0gXlo/btsPlstfB1A/FJftU3OXmeZT2yKY9b1XOK/img.png&quot; data-alt=&quot;모든 옵션을 true로 설정했을 때&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0gXlo/btsPlstfB1A/FJftU3OXmeZT2yKY9b1XOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0gXlo%2FbtsPlstfB1A%2FFJftU3OXmeZT2yKY9b1XOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;812&quot; height=&quot;209&quot; data-origin-width=&quot;812&quot; data-origin-height=&quot;209&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;모든 옵션을 true로 설정했을 때&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.baeldung.com/spring-http-logging&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.baeldung.com/spring-http-logging&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <category>commonsrequestloggingfilter</category>
      <category>filter 로그 남기기</category>
      <category>request 로그 필터</category>
      <category>spring request logging filter</category>
      <category>spring request 로그</category>
      <category>spring 요청 로그</category>
      <author>Cold Bean</author>
      <guid isPermaLink="true">https://green-bin.tistory.com/285</guid>
      <comments>https://green-bin.tistory.com/285#entry285comment</comments>
      <pubDate>Wed, 16 Jul 2025 17:07:24 +0900</pubDate>
    </item>
    <item>
      <title>JPA - SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select List 원인과 해결 방법</title>
      <link>https://green-bin.tistory.com/283</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Querydsl을 작성하여 테스트하던 중 아래와 같은 에러를 만났다.&lt;/p&gt;
&lt;pre id=&quot;code_1759140025420&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public Optional&amp;lt;Partner&amp;gt; searchPartnerByMerNo(String merNo) {
    return Optional.ofNullable(
        query.select(partner)
             .from(merchant)
             .innerJoin(merchant.partner, partner).fetchJoin()
             .where(merchant.merchantNo.eq(merNo))
             .fetchOne()
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1759139960317&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select List&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해석해 보면 &amp;ldquo;조인된 연관관계의 주인이 select 절에 포함되지 않았다&amp;rdquo; 라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;%EA%B0%9C%EB%B0%9C%20%ED%99%98%EA%B2%BD-1&quot; style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;개발 환경&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Java 17&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Spring Boot 3.4.x&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Spring Data JPA&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Querydsl&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Gradle&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;IntelliJ&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 fetchJoin에 있다. fetchJoin은 단순히 join해서 데이터를 가져오는 것뿐만이 아니라, 연관된 엔티티들을 한 번에 영속성 컨텍스트에 채워 넣겠다는 의미를 갖는다. (그러한 이유때문에 N+1을 해결하기 위한 방법으로 많이 사용된다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 위 코드처럼 partner만 select하고 merchant는 select에서 빠져있으면, Hibernate는 어떤 엔티티를 기준으로 그래프를 로딩해야할지 알 수 없다. 여기서 owner 엔티티는 merchant이기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fetchJoin은 반드시 owner 엔티티를 select해야 한다. 이렇게 하면 merchant가 select 되고, partner도 fetchJoin을 통해 함께 로딩된다. 즉 한 번의 쿼리로 두 엔티티가 영속성 컨텍스트에 채워지게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1759140483144&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;query.select(merchant)
     .from(merchant)
     .join(merchant.partner, partner).fetchJoin()
     .where(...);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 단순히 partner만 뽑아오고 싶다면, fetch Join은 사용하지 말아야한다.&lt;/p&gt;
&lt;pre id=&quot;code_1759140553540&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;query.select(partner)
     .from(merchant)
     .join(merchant.partner, partner)
     .where(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>나의 에러 일지</category>
      <author>Cold Bean</author>
      <guid isPermaLink="true">https://green-bin.tistory.com/283</guid>
      <comments>https://green-bin.tistory.com/283#entry283comment</comments>
      <pubDate>Wed, 9 Jul 2025 16:47:23 +0900</pubDate>
    </item>
    <item>
      <title>MySQL - Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value.</title>
      <link>https://green-bin.tistory.com/280</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트에 사용할 더미 데이터 생성을 위해 MySQL에서 재귀 CTE를 사용하다가 아래와 같은 에러를 만났었다.&lt;/p&gt;
&lt;pre id=&quot;code_1759141207729&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;WITH RECURSIVE seq AS (
    SELECT 1 AS seq
    UNION ALL
    SELECT seq + 1 FROM seq WHERE seq &amp;lt; 1000000
)
SELECT
    DATE_SUB(CURRENT_DATE, INTERVAL 1 DAY) AS transaction_dt,
    FLOOR(50000 + (RAND() * 990)) * 100 AS request_amount,
    FLOOR(RAND() + (FLOOR(1000 + (RAND() * 500)) + 1)) * 100 AS processing_amount,
    DATE_SUB(CURRENT_DATE, INTERVAL 1 DAY) AS created_dt,
    LPAD(seq, 6, '0') AS approval_no
FROM seq
LIMIT 1000000;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1759141083196&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트&amp;nbsp;데이터를&amp;nbsp;대량으로&amp;nbsp;만들기&amp;nbsp;위해&amp;nbsp;WITH&amp;nbsp;RECURSIVE&amp;nbsp;구문으로&amp;nbsp;시퀀스를&amp;nbsp;생성했는데,&amp;nbsp;1001번째에서&amp;nbsp;쿼리가&amp;nbsp;중단되는&amp;nbsp;문제가&amp;nbsp;발생했었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알고&amp;nbsp;보니&amp;nbsp;MySQL은&amp;nbsp;무한&amp;nbsp;루프를&amp;nbsp;방지하기&amp;nbsp;위해&amp;nbsp;재귀&amp;nbsp;CTE의&amp;nbsp;최대&amp;nbsp;반복&amp;nbsp;횟수를&amp;nbsp;1000으로&amp;nbsp;제한하고&amp;nbsp;있었다.&amp;nbsp;따라서&amp;nbsp;1000개까지만&amp;nbsp;순차적으로&amp;nbsp;생성이&amp;nbsp;가능했고,&amp;nbsp;그&amp;nbsp;이상으로&amp;nbsp;반복하려&amp;nbsp;하면&amp;nbsp;위&amp;nbsp;에러가&amp;nbsp;발생했던&amp;nbsp;것이었다.&amp;nbsp;이&amp;nbsp;제한&amp;nbsp;값은&amp;nbsp;시스템&amp;nbsp;변수&amp;nbsp;cte_max_recursion_depth로&amp;nbsp;관리되고&amp;nbsp;있었고,&amp;nbsp;기본값은&amp;nbsp;1000이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 세션 단위에서 cte_max_recursion_depth 값을 늘려주는 방식으로 해결했다.&lt;/p&gt;
&lt;pre id=&quot;code_1759141156961&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SET SESSION cte_max_recursion_depth = 100000;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래처럼 글로벌 설정도 가능하지만, 글로벌로 설정하면 MySQL 서버 전체에 영향을 주기 때문에 주의해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1759141289598&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SET GLOBAL cte_max_recursion_depth = 100000;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dba.stackexchange.com/questions/338291/setting-cte-max-recursion-depth-value-has-no-effect&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dba.stackexchange.com/questions/338291/setting-cte-max-recursion-depth-value-has-no-effect&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1759141439045&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Setting cte_max_recursion_depth value has no effect&quot; data-og-description=&quot;I am using MySQL 8.0.36, I have a recursive query to insert 5 million rows to a table. I get the error ERROR 3636 (HY000): Recursive query aborted after 1001 iterations. Try increasing @@&quot; data-og-host=&quot;dba.stackexchange.com&quot; data-og-source-url=&quot;https://dba.stackexchange.com/questions/338291/setting-cte-max-recursion-depth-value-has-no-effect&quot; data-og-url=&quot;https://dba.stackexchange.com/questions/338291/setting-cte-max-recursion-depth-value-has-no-effect&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/mpeUJ/hyZKhmBntt/GvuLxb8BdwwUUWejIKmsTk/img.png?width=316&amp;amp;height=316&amp;amp;face=0_0_316_316&quot;&gt;&lt;a href=&quot;https://dba.stackexchange.com/questions/338291/setting-cte-max-recursion-depth-value-has-no-effect&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dba.stackexchange.com/questions/338291/setting-cte-max-recursion-depth-value-has-no-effect&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/mpeUJ/hyZKhmBntt/GvuLxb8BdwwUUWejIKmsTk/img.png?width=316&amp;amp;height=316&amp;amp;face=0_0_316_316');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Setting cte_max_recursion_depth value has no effect&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;I am using MySQL 8.0.36, I have a recursive query to insert 5 million rows to a table. I get the error ERROR 3636 (HY000): Recursive query aborted after 1001 iterations. Try increasing @@&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dba.stackexchange.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_cte_max_recursion_depth&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_cte_max_recursion_depth&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1759141461639&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;MySQL :: MySQL 8.0 Reference Manual :: 7.1.8 Server System Variables&quot; data-og-description=&quot;&quot; data-og-host=&quot;dev.mysql.com&quot; data-og-source-url=&quot;https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_cte_max_recursion_depth&quot; data-og-url=&quot;https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_cte_max_recursion_depth&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_cte_max_recursion_depth&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_cte_max_recursion_depth&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;MySQL :: MySQL 8.0 Reference Manual :: 7.1.8 Server System Variables&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev.mysql.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>나의 에러 일지</category>
      <category>MySQL Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value. 에러</category>
      <category>Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value.</category>
      <category>Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value. 해결</category>
      <author>Cold Bean</author>
      <guid isPermaLink="true">https://green-bin.tistory.com/280</guid>
      <comments>https://green-bin.tistory.com/280#entry280comment</comments>
      <pubDate>Mon, 7 Jul 2025 09:56:12 +0900</pubDate>
    </item>
    <item>
      <title>소프트웨어 버전 관리</title>
      <link>https://green-bin.tistory.com/277</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1284&quot; data-origin-height=&quot;950&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dqaQks/btsOW7CFpW9/iyDEAFqwL8FqnMIhwbt3R1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dqaQks/btsOW7CFpW9/iyDEAFqwL8FqnMIhwbt3R1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dqaQks/btsOW7CFpW9/iyDEAFqwL8FqnMIhwbt3R1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdqaQks%2FbtsOW7CFpW9%2FiyDEAFqwL8FqnMIhwbt3R1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;370&quot; data-origin-width=&quot;1284&quot; data-origin-height=&quot;950&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;{Major}.{Minor}.{PATCH}&amp;nbsp;구조&amp;nbsp;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Major&amp;nbsp;버전:&amp;nbsp;&amp;nbsp;이전&amp;nbsp;버전과&amp;nbsp;호환되지&amp;nbsp;않는&amp;nbsp;API&amp;nbsp;변경&amp;nbsp;시&amp;nbsp;증가&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Minor&amp;nbsp;버전:&amp;nbsp;하위&amp;nbsp;호환&amp;nbsp;가능한&amp;nbsp;새로운&amp;nbsp;기능&amp;nbsp;추가&amp;nbsp;시&amp;nbsp;증가&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Patch&amp;nbsp;버전:&amp;nbsp;하위&amp;nbsp;호환&amp;nbsp;가능한&amp;nbsp;버그&amp;nbsp;수정&amp;nbsp;시&amp;nbsp;증가&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;버전&amp;nbsp;증가&amp;nbsp;규칙&amp;nbsp;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각&amp;nbsp;버전&amp;nbsp;번호는&amp;nbsp;음수가&amp;nbsp;아닌&amp;nbsp;정수여야&amp;nbsp;함&amp;nbsp;&lt;/li&gt;
&lt;li&gt;상위&amp;nbsp;버전이&amp;nbsp;증가하면&amp;nbsp;하위&amp;nbsp;버전은&amp;nbsp;0으로&amp;nbsp;리셋&amp;nbsp;(예:&amp;nbsp;1.9.0&amp;nbsp;&amp;rarr;&amp;nbsp;1.10.0&amp;nbsp;&amp;rarr;&amp;nbsp;2.0.0)&amp;nbsp;&lt;/li&gt;
&lt;li&gt;버전&amp;nbsp;배포&amp;nbsp;후&amp;nbsp;해당&amp;nbsp;버전의&amp;nbsp;내용은&amp;nbsp;절대&amp;nbsp;변경&amp;nbsp;금지.&amp;nbsp;변경사항이&amp;nbsp;있다면&amp;nbsp;반드시&amp;nbsp;새로운&amp;nbsp;버전으로&amp;nbsp;배포&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>etc</category>
      <category>개발 버전 관리</category>
      <category>버전 관리</category>
      <category>소프트웨어 버전 관리</category>
      <author>Cold Bean</author>
      <guid isPermaLink="true">https://green-bin.tistory.com/277</guid>
      <comments>https://green-bin.tistory.com/277#entry277comment</comments>
      <pubDate>Sun, 29 Jun 2025 17:39:06 +0900</pubDate>
    </item>
    <item>
      <title>Spring - Spring Data Envers로 엔티티 변경 이력을 쉽게 관리해보기</title>
      <link>https://green-bin.tistory.com/276</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사이드 프로젝트나 실제 서비스에서 &lt;b&gt;데이터 변경 이력 관리&lt;/b&gt;가 필요할 때가 많다. &amp;ldquo;누가, 언제, 무엇을, 어떻게 바꿨는지&amp;rdquo; 추적이 필요하다면, Spring Data Envers가 딱이다. 이번 글에서는 &lt;b&gt;Spring Data Envers&lt;/b&gt;를 실제로 적용하는 방법을, 내가 직접 해보면서 겪은 시행착오와 함께 정리해본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;1-envers&quot; data-ke-size=&quot;size26&quot;&gt;Envers란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hibernate&amp;nbsp;Envers는&amp;nbsp;JPA&amp;nbsp;엔티티의&amp;nbsp;변경&amp;nbsp;이력을&amp;nbsp;자동으로&amp;nbsp;관리해준다.&amp;nbsp;Spring&amp;nbsp;Data&amp;nbsp;Envers는&amp;nbsp;이를&amp;nbsp;Spring&amp;nbsp;Data&amp;nbsp;JPA와&amp;nbsp;자연스럽게&amp;nbsp;통합해주기&amp;nbsp;때문에,&amp;nbsp;기존&amp;nbsp;Repository&amp;nbsp;패턴을&amp;nbsp;그대로&amp;nbsp;쓰면서도&amp;nbsp;이력&amp;nbsp;관리가&amp;nbsp;가능하다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;2--envers&quot; data-ke-size=&quot;size26&quot;&gt;프로젝트에 Envers 적용하기&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;의존성 추가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle에 아래 의존성을 추가한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1752381922685&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.data:spring-data-envers'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;엔터티 설정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 이력을 관리하고 싶은 엔터티에 @Audited 어노테이션만 붙이면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스에 @Audited를 사용하면 엔티티 전체 이력을 관리하게 되고, 특정 필드에 사용하면 해당 필드의 변경 이력만 관리하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 필드에 @NotAudited를 붙이면 해당 필드는 이력 관리에서 제외된다. 보통 민감 정보, 빈번히 변경되는 값 등을 이력 관리에서 제외할 때 사용한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;어노테이션&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;적용 대상&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;효과&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;@Audited&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;엔티티/필드&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;변경 이력 관리 대상에 포함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;@NotAudited&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;필드&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;해당 필드만 이력 관리에서 제외&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1752382009201&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@Entity
@Audited   // 엔티티 전체 이력 관리
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String content;

    @OneToMany(mappedBy = &quot;post&quot;)
    private List&amp;lt;Comment&amp;gt; comments;

    public void addComment(Comment comment) {
        if (comments == null) {
            comments = new ArrayList&amp;lt;&amp;gt;();
        }

        if (!comments.contains(comment)) {
            comments.add(comment);
        }
    }

    public void removeComment(Comment comment) {
        if (comments != null) {
            comments.remove(comment);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1752386692446&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Builder
@Entity
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Audited   // 특정 필드의 이력 관리
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;post_id&quot;, nullable = false)
    private Post post;

    public void setPost(Post post) {
        this.post = post;
        post.addComment(this);
    }

    public void removePost() {
        if (this.post != null) {
            this.post.removeComment(this);
            this.post = null;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;❓연관관계 필드에서 @Audited를 붙이면 어떻게 될까?&lt;br /&gt;&lt;/b&gt;&lt;br /&gt;연관관계(@ManyToOne, @OneToMany) 필드에 @Audited를 붙이면 해당 연관관계 엔티티의 변경 이력까지 기록하게 된다. 다만, 해당 엔티티에도 @Audited가 붙어 있어야 한다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;만약 연관관계 엔티티의 변경 이력까지는 기록하지 않고, 연관 관계의 현재 FK(id) 값만 이력 테이블에 저장하고 싶다면 @Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)으로 옵션을 붙여서 사용하면 된다. FK까지도 기록하고싶지 않으면 @NotAudited를 붙이면 된다.&lt;/blockquote&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;데이터베이스에 테이블 자동 생성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 애플리케이션을 ddl-auto를 사용해 테이블이 생성해보면 COMMENT, POST 테이블 외에 COMMNET_HISTORY, POST_HISTORY, REVINFO 테이블이 생긴 것을 볼 수 있다.&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;472&quot; data-origin-height=&quot;230&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tp4D5/btsPfgtCHDJ/6PikkP48xzCyCDepGzvaLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tp4D5/btsPfgtCHDJ/6PikkP48xzCyCDepGzvaLk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tp4D5/btsPfgtCHDJ/6PikkP48xzCyCDepGzvaLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Ftp4D5%2FbtsPfgtCHDJ%2F6PikkP48xzCyCDepGzvaLk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;200&quot; height=&quot;97&quot; data-origin-width=&quot;472&quot; data-origin-height=&quot;230&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;blockquote data-ke-style=&quot;style2&quot;&gt;기본적으로 Hibernate Envers는 엔티티 이력 테이블에 _AUD 접미사를 사용한다. 예를 들어, Post 엔티티라면 post_AUD 테이블이 생성된다. 이 접미사를 바꾸고 싶다면, application.yml 또는 application.properties에서 설정할 수 있다. 나는 _history로 변경했다.&lt;/blockquote&gt;
&lt;pre id=&quot;code_1752387359252&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  jpa:
    properties:
      org:
        hibernate:
          envers:
            audit_table_suffix: _history   # 이력 테이블 접미사 변경&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;엔티티 변경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 엔티티가 변경되었을 때 어떻게 기록되는지 확인해보자. 게시판 생성, 수정, 삭제를 순차적으로 진행했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PostController&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1752499564981&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/posts&quot;)
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @PostMapping
    public void createPost() {
        postService.createPost();
    }

    @PatchMapping
    public void updatePost() {
        postService.updatePost();
    }

    @DeleteMapping
    public void deletePost() {
        postService.deletePost();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PostService&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1752499579656&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@Transactional
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    public void createPost() {
        Post post = Post.builder().title(&quot;아 덥다&quot;).content(&quot;더워 죽겠다. 너무너무 덥다...&quot;).build();
        postRepository.save(post);
    }

    public void updatePost() {
        postRepository.findById(1L)
            .ifPresent(post -&amp;gt; post.updateContent(&quot;에어컨 틀었더니 조금 시원하다. 에어컨 최고!&quot;));
    }

    public void deletePost() {
        postRepository.deleteById(1L);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PostRepository&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1752499600136&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;848&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WDoIw/btsPh6KwpnQ/xkQBxgDteWSsdGT8bVYoo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WDoIw/btsPh6KwpnQ/xkQBxgDteWSsdGT8bVYoo0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WDoIw/btsPh6KwpnQ/xkQBxgDteWSsdGT8bVYoo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWDoIw%2FbtsPh6KwpnQ%2FxkQBxgDteWSsdGT8bVYoo0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;483&quot; data-origin-width=&quot;848&quot; data-origin-height=&quot;512&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p style=&quot;position: absolute;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 이미지를 보면 이력이 자동 생성된 걸 확인할 수 있다. 이제 이 이력에 대한 정보가 무엇을 나타내는걸까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이력 테이블(_AUD지만 내가 별도 설정해두어서 현재는 _HISTORY가 붙은 테이블)과 REVINFO 테이블에 대해 알아보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;이력 테이블(_AUD 테이블)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Envers를 적용한 엔티티마다 생성되는 이력 테이블은 아래와 같은 구조를 갖는다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 101px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 27.6744%; height: 21px;&quot;&gt;&lt;b&gt;컬럼명&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 72.3256%; height: 21px;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 27.6744%; height: 21px;&quot;&gt;엔티티 PK (ID)&lt;/td&gt;
&lt;td style=&quot;width: 72.3256%; height: 21px;&quot;&gt;엔티티의 기본키(PK). 복합키라면 여러 컬럼이 될 수 있다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 27.6744%; height: 21px;&quot;&gt;엔티티 필드(CONTENT, TITLE)&lt;/td&gt;
&lt;td style=&quot;width: 72.3256%; height: 21px;&quot;&gt;엔티티의 이력 관리 대상 필드 값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 27.6744%; height: 21px;&quot;&gt;REV&lt;/td&gt;
&lt;td style=&quot;width: 72.3256%; height: 21px;&quot;&gt;해당 변경 이력이 속한 리비전 번호. REVINFO 테이블의 REV와 FK로 연결된다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 27.6744%; height: 17px;&quot;&gt;REVTYPE&lt;/td&gt;
&lt;td style=&quot;width: 72.3256%; height: 17px;&quot;&gt;변경 유형&lt;br /&gt;&lt;br /&gt;- 0 : 추가(INSERT)&lt;br /&gt;- 1 : 수정(UPDATE)&lt;br /&gt;- 2 : 삭제(DELETE)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;REVINFO 테이블&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REVINFO 테이블은 리비전(변경 이력의 묶음)을 관리한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;b&gt;컬럼명&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;REV&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;리비전 번호(순차 증가 PK)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;REVSTMP&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;리비전 생성 시각(Unix timestamp, ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;REVINFO는 @RevisionEntity를 사용해 커스텀할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정보를 바탕으로 이미지를 보면 각 변경 타입(REVTYPE)에 맞게 잘 저장된걸 확인할 수 있다. 이렇게 설정만 해두면 이렇게 편하게 자동으로 이력을 관리해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;REVINFO 커스텀&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금도 충분히 편할 수 있지만 뭔가 아쉽다. 예를 들어, 변경 요청을 한 IP를 저장하고 싶을 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이럴 때는@RevisionEntity와 RevisionListener를 사용해서 원하는대로 Revinfo(리비전 엔티티)를 커스텀할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 이력에 변경 요청을 한 IP를 자동으로 저장할 수 있도록 해보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CustomRevisionEntity&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DefaultRevisionEntity를 상속 받아 IP 컬럼을 추가할 커스텀 엔티티를 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1752501502775&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * Envers에서 사용하는 커스텀 리비전 엔티티.
 * 기본 리비전 엔티티에 IP 주소 필드를 추가한다.
 */
@Getter
@Setter
@Entity
@Table(name = &quot;revinfo&quot;)    // Envers에서 사용하는 기본 테이블 이름
@RevisionEntity(CustomRevisionListener.class) // 리스너 연결
public class CustomRevisionEntity extends DefaultRevisionEntity {

    private String ipAddress;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CustomRevisionListener&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Revision이 생성될 때마다 현재 요청의 IP 주소를 커스텀 리비전 엔티티에 세팅한다.&lt;/p&gt;
&lt;pre id=&quot;code_1752501674462&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class CustomRevisionListener implements RevisionListener {

    /**
     * Envers에서 새로운 리비전이 생성될 때 호출되는 메소드.
     * 현재 요청의 IP 주소를 CustomRevisionEntity에 설정한다.
     *
     * @param revisionEntity 새로 생성된 리비전 엔티티
     */
    @Override
    public void newRevision(Object revisionEntity) {
        CustomRevisionEntity rev = (CustomRevisionEntity) revisionEntity;

        String ip = &quot;unknown&quot;;

        // 현재 요청의 HttpServletRequest에서 IP 추출
        ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attrs != null) {
            HttpServletRequest request = attrs.getRequest();
            ip = request.getHeader(&quot;X-Forwarded-For&quot;);
            if (ip == null || ip.isEmpty()) {
                ip = request.getRemoteAddr();
            }
        }
        rev.setIpAddress(ip);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;807&quot; data-origin-height=&quot;542&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSGSh1/btsPiJgKgIO/ClRldxEdgpkM4mVQerWIv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSGSh1/btsPiJgKgIO/ClRldxEdgpkM4mVQerWIv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSGSh1/btsPiJgKgIO/ClRldxEdgpkM4mVQerWIv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSGSh1%2FbtsPiJgKgIO%2FClRldxEdgpkM4mVQerWIv0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;537&quot; data-origin-width=&quot;807&quot; data-origin-height=&quot;542&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p data-ke-size=&quot;size16&quot;&gt;REVINFO에 IP_ADDRESS가 추가된 것을 확인할 수 있다. 이제 우리는 매번 직접 IP 주소를 저장할 필요가 없어졌다. envers가 알아서 다 해주니까!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;5&quot; data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Envers는 이번에 우리 팀에서 도입하게 되면서 처음 알게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Envers는 설정이 간단하지만, 실제로 써보면 &amp;ldquo;이렇게까지 자동화가 되나?&amp;rdquo; 싶을 정도로 강력하다.&lt;br /&gt;사이드 프로젝트든, 실무든 데이터 이력 관리가 필요하다면 한 번쯤 꼭 도입해보길 추천한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, 이력 테이블도 결국 DB 리소스를 잡아먹기 때문에 꼭 필요한 엔티티/필드에만 @Audited를 적용하자. 그리고 DB 마이그레이션 시 REVINFO 및 _AUD 테이블 구조도 함께 변경해줘야 할 수 있다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.baeldung.com/java-hibernate-envers-extending-revision-custom-fields&quot;&gt;https://www.baeldung.com/java-hibernate-envers-extending-revision-custom-fields&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-data/envers/docs/current/reference/html/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.spring.io/spring-data/envers/docs/current/reference/html/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1752387000531&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Spring Data Envers - Reference Documentation&quot; data-og-description=&quot;Example 10. Repository definitions using domain classes with annotations interface PersonRepository extends Repository { &amp;hellip; } @Entity class Person { &amp;hellip; } interface UserRepository extends Repository { &amp;hellip; } @Document class User { &amp;hellip; } PersonRepository re&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-data/envers/docs/current/reference/html/&quot; data-og-url=&quot;https://docs.spring.io/spring-data/envers/docs/current/reference/html/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-data/envers/docs/current/reference/html/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-data/envers/docs/current/reference/html/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Spring Data Envers - Reference Documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Example 10. Repository definitions using domain classes with annotations interface PersonRepository extends Repository { &amp;hellip; } @Entity class Person { &amp;hellip; } interface UserRepository extends Repository { &amp;hellip; } @Document class User { &amp;hellip; } PersonRepository re&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.spring.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <category>spring data envers</category>
      <category>spring envers</category>
      <category>데이터 변경 이력</category>
      <category>엔티티 변경 이력 관리</category>
      <author>Cold Bean</author>
      <guid isPermaLink="true">https://green-bin.tistory.com/276</guid>
      <comments>https://green-bin.tistory.com/276#entry276comment</comments>
      <pubDate>Fri, 27 Jun 2025 14:04:54 +0900</pubDate>
    </item>
    <item>
      <title>Docker - standard_init_linux.go:xxx: exec user process caused &amp;quot;exec format error&amp;ldquo; 원인과 해결 방법</title>
      <link>https://green-bin.tistory.com/275</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 Dockerfile에서 Java 애플리케이션을 실행할 때 ENTRYPOINT에 모든 JVM 옵션과 실행 명령어를 길게 나열해서 작성했었다. 하지만 JVM 옵션이 많아지고 환경별로 다른 설정이 필요해지면서 관리가 어려워졌다. 그래서 별도의 쉘 스크립트 파일에서 이런 복잡한 실행 로직을 관리할 수 있도록 entrypoint.sh를 만들게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;entrypoint.sh를 추가하여 Docker 컨테이너를 실행하자 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;entrypoint.sh에서 예상치 못한 format 에러가 발생했다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;standard_init_linux.go:xxx: exec user process caused &quot;exec format error&amp;ldquo;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개발 환경&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Docker&lt;/li&gt;
&lt;li&gt;Portainer&lt;/li&gt;
&lt;li&gt;Linux&lt;/li&gt;
&lt;li&gt;Java 17&lt;/li&gt;
&lt;li&gt;Spring Boot 3.4.x&lt;/li&gt;
&lt;li&gt;Bash/Shell Script&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기존 Dockerfile&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Dockerfile에서 다음과 같이 ENTRYPOINT를 직접 작성했었다. 하지만 JVM 옵션이 길어질수록 가독성이 저하되는 문제가 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1751179585938&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;FROM openjdk:17-jre-slim

COPY app.jar /app.jar

ENTRYPOINT [&quot;java&quot;, &quot;-Xms512m&quot;, &quot;-Xmx1024m&quot;, &quot;-XX:+UseG1GC&quot;, &quot;-XX:+UseStringDeduplication&quot;, &quot;-Dspring.profiles.active=prod&quot;, &quot;-jar&quot;, &quot;/app.jar&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;개선된 Dockerfile (문제 발생)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도의 쉘 스크립트로 분리해서 더 유연하게 관리할 수 있도록 개선했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 추가된 entrypoint.sh 파일에서&amp;nbsp; format 에러가 발생했다.&lt;/p&gt;
&lt;pre id=&quot;code_1751179764991&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;FROM openjdk:17-jre-slim

COPY app.jar /app.jar
COPY entrypoint.sh /entrypoint.sh

RUN chmod +x /entrypoint.sh

ENTRYPOINT [&quot;/entrypoint.sh&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1751179948470&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# entrypoint.sh

# JVM 기본 옵션 설정
JAVA_OPTS=&quot;-Xms512m -Xmx2g&quot;
JAVA_OPTS=&quot;$JAVA_OPTS -XX:+UseG1GC&quot;
JAVA_OPTS=&quot;$JAVA_OPTS -XX:+UseStringDeduplication&quot;
JAVA_OPTS=&quot;$JAVA_OPTS -Dspring.profiles.active=prod&quot;

# 애플리케이션 실행
exec java $JAVA_OPTS -jar /app.jar &quot;$@&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;exec format error는 주로 다음과 같은 상황에서 발생한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실행 파일의 아키텍처가 맞지 않는 경우 (예: ARM 바이너를 x86에서 실행)&lt;/li&gt;
&lt;li&gt;실행 파일이 손상된 경우&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스크립트 파일에 shebang 라인이 없는 경우&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 경우에는 세 번째 케이스였다. Shebang 라인은 스크립트의 실행 환경을 결정하기 때문에 반드시 있어야 한다. Shebang 라인이 없으면 Docker는 entrypoint.sh 파일을 실행할 때 어떤 인터프리터로 실행해야 하는지 알 수 없어서 바이너리 파일로 인식하려고 실행하려다가 실패한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Shebang이란?&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;#!/bin/sh&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Shebang 라인은 스크립트 파일의 맨 첫 번째 줄에 위치하는 #!(주석 아님) 로 시작하는 라인이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Shebang 라인은 스크립트 파일이 어떤 인터프리터의 명령어 집합인지를 시스템에 알려주는 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;#! 뒤에는 명령어들을 해석할 프로그램의 위치를 나타낸다.&lt;/p&gt;
&lt;pre id=&quot;code_1751182162721&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 예시 

#!/bin/sh          # POSIX 호환 shell
#!/bin/bash        # Bash shell  
#!/usr/bin/python  # Python 2
#!/usr/bin/env python # 환경에서 python 찾기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;entrypoint.sh 파일 맨 첫 줄에 shebang 라인을 추가해주면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1751180717404&quot; class=&quot;bash&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# entrypoint.sh

# shebang 라인 추가
#!/bin/sh

# JVM 기본 옵션 설정
JAVA_OPTS=&quot;-Xms512m -Xmx2g&quot;
JAVA_OPTS=&quot;$JAVA_OPTS -XX:+UseG1GC&quot;
JAVA_OPTS=&quot;$JAVA_OPTS -XX:+UseStringDeduplication&quot;
JAVA_OPTS=&quot;$JAVA_OPTS -Dspring.profiles.active=prod&quot;

# 애플리케이션 실행
exec java $JAVA_OPTS -jar /app.jar &quot;$@&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://iksciting.com/what-is-shebang/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://iksciting.com/what-is-shebang/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1751182340348&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Linux: What is Shebang? - IKSciting&quot; data-og-description=&quot;Linux에서 script를 열었을 때 첫 줄이&amp;nbsp;#!로 시작하는 경우를 종종 보게 된다. 이를&amp;nbsp;shebang이라고 부르는데 어원에 대해서는 sharp + bang, hash + bang, shell + bang 등 여러가지 설이 있지만 이름이나 어원은&quot; data-og-host=&quot;iksciting.com&quot; data-og-source-url=&quot;https://iksciting.com/what-is-shebang/&quot; data-og-url=&quot;https://iksciting.com/what-is-shebang/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/c3BehM/hyZcbV5y3N/6BFDs5h2bKjz6sjV129gPK/img.png?width=480&amp;amp;height=367&amp;amp;face=0_0_480_367,https://scrap.kakaocdn.net/dn/cF5f5A/hyZcf5hkz3/qUSLuot2dxXonkxcGq7Fa0/img.png?width=480&amp;amp;height=367&amp;amp;face=0_0_480_367&quot;&gt;&lt;a href=&quot;https://iksciting.com/what-is-shebang/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://iksciting.com/what-is-shebang/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/c3BehM/hyZcbV5y3N/6BFDs5h2bKjz6sjV129gPK/img.png?width=480&amp;amp;height=367&amp;amp;face=0_0_480_367,https://scrap.kakaocdn.net/dn/cF5f5A/hyZcf5hkz3/qUSLuot2dxXonkxcGq7Fa0/img.png?width=480&amp;amp;height=367&amp;amp;face=0_0_480_367');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Linux: What is Shebang? - IKSciting&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Linux에서 script를 열었을 때 첫 줄이&amp;nbsp;#!로 시작하는 경우를 종종 보게 된다. 이를&amp;nbsp;shebang이라고 부르는데 어원에 대해서는 sharp + bang, hash + bang, shell + bang 등 여러가지 설이 있지만 이름이나 어원은&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;iksciting.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/opencontainers/runc/issues/1773&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/opencontainers/runc/issues/1773&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1751180278643&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;&amp;quot;exec format error&amp;quot; for ENTRYPOINT pointing to shell script lacking shebang line &amp;middot; Issue #1773 &amp;middot; opencontainers/runc&quot; data-og-description=&quot;I'm posting this here because error message is raised by a file in this project: standard_init_linux.go:195: exec user process caused &amp;quot;exec format error&amp;quot; As an end user I'd like it if somehow it wa...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/opencontainers/runc/issues/1773&quot; data-og-url=&quot;https://github.com/opencontainers/runc/issues/1773&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/VN12n/hyZcjs36P8/Ewz4OeMkpVSyykZVBSmMCK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/BWywN/hyZbrkpQVS/qPdjix77OVk8Def4wvSAu0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/opencontainers/runc/issues/1773&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/opencontainers/runc/issues/1773&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/VN12n/hyZcjs36P8/Ewz4OeMkpVSyykZVBSmMCK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/BWywN/hyZbrkpQVS/qPdjix77OVk8Def4wvSAu0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;&quot;exec format error&quot; for ENTRYPOINT pointing to shell script lacking shebang line &amp;middot; Issue #1773 &amp;middot; opencontainers/runc&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;I'm posting this here because error message is raised by a file in this project: standard_init_linux.go:195: exec user process caused &quot;exec format error&quot; As an end user I'd like it if somehow it wa...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>나의 에러 일지</category>
      <category>exec format error</category>
      <category>exec user process caused &amp;quot;exec format error&amp;ldquo; 해결</category>
      <category>shebnag exec format error</category>
      <category>standard_init_linux.go:xxx: exec user process caused &amp;quot;exec format error&amp;ldquo;</category>
      <author>Cold Bean</author>
      <guid isPermaLink="true">https://green-bin.tistory.com/275</guid>
      <comments>https://green-bin.tistory.com/275#entry275comment</comments>
      <pubDate>Thu, 26 Jun 2025 14:17:10 +0900</pubDate>
    </item>
    <item>
      <title>HTTP - 프록시 환경에서 실제 클라이언트 IP 찾기 (X-Forwarded-For, X-Real-IP)</title>
      <link>https://green-bin.tistory.com/272</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 프로젝트에서 화이트리스트 IP 검증 로직(IP 기반 접근 제한)을 구현하게 되었다. 처음에는 단순하게 request.getRemoteAddr()를 사용하면 될 줄 알았는데, 실제 운영 환경에서는 중간에 프록시를 거치다보니 실제 클라이언트 IP가 아닌 프록시 IP가 조회되는 문제가 있었다. 이 문제를 해결하기 위해 알게된 X-Forwarded-For와 X-Real-IP에 대해 알아보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 애플리케이션을 개발하다 보면 클라이언트의 실제 IP 주소가 필요한 경우가 많다. 특히 보안 정책, 접근 제어, 로깅 및 분석 등의 목적으로 사용된다. 하지만 실제 운영을 위한 웹 아키텍처에서는 클라이언트와 서버 사이에 다양한 중간 계층들이 존재한다. 로드 밸런서, 리버스 프록시, CDN 등을 거치면서 서버가 실제로 받는 IP는 클라이언트의 실제 IP 주소가 아닌 바로 직전의 프록시 IP 주소가 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 내가 담당한 프로젝트의 아키텍처는 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Client -&amp;gt; Load Balancer -&amp;gt; Reverse Proxy -&amp;gt; WAS&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 구조에서 request.getRemoteAddr()를 사용하면 실제 클라이언트 IP가 아닌 바로 직전 프록시의 IP를 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화이트리스트 검증에서는 실제 클라이언트의 IP가 필요하기 때문에 이 문제를 해결해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;X-Forwarded-For (XFF) 헤더&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;X-Forwarded-For 헤더는 HTTP 프록시나 로드 밸런서를 통해 접속하는 클라이언트의 원 IP 주소를 식별하는 사실상의 표준 헤더이다. 클라이언트와 서버 중간에서 중간 계층을 거치면서 IP가 변경되는데, 클라이언트의 원 IP 주소를 보기 위해서 X-Forwarded-For 요청 헤더가 사용된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동작 방식&lt;/h3&gt;
&lt;pre id=&quot;code_1751173138820&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;X-Forwarded-For: &amp;lt;client&amp;gt;, &amp;lt;proxy1&amp;gt;, &amp;lt;proxy2&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 프록시를 거칠 때마다 우측 끝에 IP를 추가한다.&lt;/p&gt;
&lt;pre id=&quot;code_1751173162653&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;User(1.1.1.1)
└ Proxy(2.2.2.2) - X-Forwarded-For: 1.1.1.1
  └ Nginx(3.3.3.3) - X-Forwarded-For: 1.1.1.1, 2.2.2.2&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 왼쪽 IP가 최초 클라이언트의 IP이고, 가장 오른쪽 IP가 마지막 프록시의 IP이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;X-Real-IP 헤더&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;X-Real-IP는 바로 직전 클라이언트의 IP를 나타낸다. XFF와 달리 단일 IP 값만을 전달한다.&lt;/p&gt;
&lt;pre id=&quot;code_1751173487495&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;User - Nginx - Tomcat	# X-Real-IP는 User IP
User - Proxy - Nginx - Tomcat	# X-Real-IP는 Proxy IP&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;보안 고려사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; background-color: #fcfcfc;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;XFF와 X-Real-IP 헤더의 문제점은 클라이언트가 조작할 수 있다는 것이다. 악의적인 사용자가 가짜 IP를 헤더에 추가해서 보안 정책을 우회할 수 있는 문제가 있다.&lt;/b&gt;&lt;/span&gt; 그래서 신뢰할 수 있는 리버스 프록시를 통해서만 접근을 허용하고, 다른 경로로 백엔드 서버로의 직접 접근을 차단해야 한다.&amp;nbsp;&lt;/span&gt; &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참조&lt;/h2&gt;
&lt;figure id=&quot;og_1751178064291&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;What is X-Forwarded-For and when can you trust it?&quot; data-og-description=&quot;The X-Forwarded-For (XFF) HTTP header provides crucial insight into the origin of web requests. The header works as a mechanism for conveying the original...&quot; data-og-host=&quot;httptoolkit.com&quot; data-og-source-url=&quot;https://httptoolkit.com/blog/what-is-x-forwarded-for/&quot; data-og-url=&quot;https://httptoolkit.com/blog/what-is-x-forwarded-for/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/c2hjaV/hyZfriKxz7/jTktnz9i0z9gvd5Yponwck/img.jpg?width=2000&amp;amp;height=468&amp;amp;face=0_0_2000_468,https://scrap.kakaocdn.net/dn/bp7EDn/hyZf4gICNH/EEdbsVzHQ1bzWoyvglkVrK/img.jpg?width=2000&amp;amp;height=468&amp;amp;face=0_0_2000_468,https://scrap.kakaocdn.net/dn/YyDa8/hyZccOeSe2/p6Dh8o6mbra3XTO4QLH191/img.jpg?width=960&amp;amp;height=319&amp;amp;face=0_0_960_319&quot;&gt;&lt;a href=&quot;https://httptoolkit.com/blog/what-is-x-forwarded-for/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://httptoolkit.com/blog/what-is-x-forwarded-for/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/c2hjaV/hyZfriKxz7/jTktnz9i0z9gvd5Yponwck/img.jpg?width=2000&amp;amp;height=468&amp;amp;face=0_0_2000_468,https://scrap.kakaocdn.net/dn/bp7EDn/hyZf4gICNH/EEdbsVzHQ1bzWoyvglkVrK/img.jpg?width=2000&amp;amp;height=468&amp;amp;face=0_0_2000_468,https://scrap.kakaocdn.net/dn/YyDa8/hyZccOeSe2/p6Dh8o6mbra3XTO4QLH191/img.jpg?width=960&amp;amp;height=319&amp;amp;face=0_0_960_319');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;What is X-Forwarded-For and when can you trust it?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The X-Forwarded-For (XFF) HTTP header provides crucial insight into the origin of web requests. The header works as a mechanism for conveying the original...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;httptoolkit.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1751171831419&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;X-Forwarded-For - HTTP | MDN&quot; data-og-description=&quot;X-Forwarded-For (XFF) 헤더는 HTTP 프록시나 로드 밸런서를 통해 웹 서버에 접속하는 클라이언트의 원 IP 주소를 식별하는 사실상의 표준 헤더다. 클라이언트와 서버 중간에서 트래픽이 프록시나 로드 &quot; data-og-host=&quot;developer.mozilla.org&quot; data-og-source-url=&quot;https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Headers/X-Forwarded-For&quot; data-og-url=&quot;https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Headers/X-Forwarded-For&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bRQGoL/hyZcl5sD9N/l1SVLOc6cPIAH5xOshwKU0/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Headers/X-Forwarded-For&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Headers/X-Forwarded-For&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bRQGoL/hyZcl5sD9N/l1SVLOc6cPIAH5xOshwKU0/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;X-Forwarded-For - HTTP | MDN&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;X-Forwarded-For (XFF) 헤더는 HTTP 프록시나 로드 밸런서를 통해 웹 서버에 접속하는 클라이언트의 원 IP 주소를 식별하는 사실상의 표준 헤더다. 클라이언트와 서버 중간에서 트래픽이 프록시나 로드&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.mozilla.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>Network</category>
      <category>X-Forwarded-For</category>
      <category>실제 클라이언트 ip 조회</category>
      <author>Cold Bean</author>
      <guid isPermaLink="true">https://green-bin.tistory.com/272</guid>
      <comments>https://green-bin.tistory.com/272#entry272comment</comments>
      <pubDate>Mon, 16 Jun 2025 15:20:26 +0900</pubDate>
    </item>
  </channel>
</rss>