<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko-KR, en-US"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.nilgil.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.nilgil.com/" rel="alternate" type="text/html" hreflang="ko-KR, en-US" /><updated>2026-09-09T20:02:16+00:00</updated><id>https://blog.nilgil.com/feed.xml</id><title type="html">nilgil</title><subtitle>A software engineer who is passionate about programming and dedicated to sharing technical expertise with fellow developers.
</subtitle><author><name>nilgil</name><email>nilgil183@gmail.com</email></author><entry><title type="html">Hibernate OffsetDateTime/ZonedDateTime이 UTC로 매핑되는 문제 - 타임존 처리 전략 분석</title><link href="https://blog.nilgil.com/posts/hibernate-default-timezone-strategy/" rel="alternate" type="text/html" title="Hibernate OffsetDateTime/ZonedDateTime이 UTC로 매핑되는 문제 - 타임존 처리 전략 분석" /><published>2025-04-09T00:00:00+00:00</published><updated>2026-09-09T20:01:31+00:00</updated><id>https://blog.nilgil.com/posts/hibernate-default-timezone-strategy</id><content type="html" xml:base="https://blog.nilgil.com/posts/hibernate-default-timezone-strategy/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#요약" id="markdown-toc-요약">요약</a></li>
  <li><a href="#문제-상황" id="markdown-toc-문제-상황">문제 상황</a></li>
  <li><a href="#attributeconverter로-해결" id="markdown-toc-attributeconverter로-해결">AttributeConverter로 해결?!</a></li>
  <li><a href="#hibernate의-타입-변환-흐름-분석" id="markdown-toc-hibernate의-타입-변환-흐름-분석">Hibernate의 타입 변환 흐름 분석</a></li>
  <li><a href="#hibernate의-metabuildingprocess" id="markdown-toc-hibernate의-metabuildingprocess">Hibernate의 MetaBuildingProcess</a></li>
  <li><a href="#timezonestoragestrategy" id="markdown-toc-timezonestoragestrategy">TimeZoneStorageStrategy</a></li>
  <li><a href="#timezonesupport" id="markdown-toc-timezonesupport">TimeZoneSupport</a></li>
  <li><a href="#timezonestoragetype" id="markdown-toc-timezonestoragetype">TimeZoneStorageType</a></li>
  <li><a href="#정리" id="markdown-toc-정리">정리</a></li>
  <li><a href="#해결-1--timezonestoragetype-설정-변경" id="markdown-toc-해결-1--timezonestoragetype-설정-변경">해결 1 : TimeZoneStorageType 설정 변경</a>    <ul>
      <li><a href="#글로벌-설정" id="markdown-toc-글로벌-설정">글로벌 설정</a></li>
      <li><a href="#개별-필드-설정" id="markdown-toc-개별-필드-설정">개별 필드 설정</a></li>
    </ul>
  </li>
  <li><a href="#해결-2--hibernate의-제안-받아들이기" id="markdown-toc-해결-2--hibernate의-제안-받아들이기">해결 2 : Hibernate의 제안 받아들이기</a></li>
  <li><a href="#느낀-점" id="markdown-toc-느낀-점">느낀 점</a></li>
</ul>

<h2 id="요약">요약</h2>

<p>Hibernate 6.2부터 타임존 데이터를 처리하는 기본 전략이 변경되었습니다.
기존에는 MySQL, MariaDB, PostgreSQL 같은 벤더를 사용할 때 시스템 타임존을 기준으로 날짜/시간 데이터를 정규화하여 저장 및 조회했지만,
Hibernate 6.2부터는 <code class="language-plaintext highlighter-rouge">UTC</code> 기준으로 정규화하도록 기본 설정이 변경되었습니다.</p>

<p>이로 인해 <code class="language-plaintext highlighter-rouge">TIMESTAMP WITH TIME ZONE</code> 타입을 제공하지 않는 MySQL, MariaDB와 같은 데이터베이스를 사용하는 경우
새로 적재되는 데이터가 <code class="language-plaintext highlighter-rouge">UTC</code> 기준으로 정규화되어 적재될 것이기에 기존 데이터와의 정합성에 유의해야 합니다.</p>

<p>PostgreSQL은 <code class="language-plaintext highlighter-rouge">TIMESTAMP WITH TIME ZONE</code> 타입을 제공하지만 저장 시 내부적으로 <code class="language-plaintext highlighter-rouge">UTC</code>로 정규화하고 타임존 정보를 저장하지 않습니다.
즉, 어떤 타임존으로 저장해도 내부적으로 정규화되어 저장되기에 기존 데이터와의 정합성 문제는 없습니다.</p>

<p>그러나 조회 시에는 <code class="language-plaintext highlighter-rouge">TIMESTAMP WITH TIME ZONE</code> 타입 제공 여부를 떠나 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code>/<code class="language-plaintext highlighter-rouge">ZonedDateTime</code> 타입으로 데이터를 가져오는 경우
<code class="language-plaintext highlighter-rouge">ZoneOffset</code>/<code class="language-plaintext highlighter-rouge">ZoneId</code>가 <code class="language-plaintext highlighter-rouge">UTC</code> 기준으로 매핑되어 이와 관련한 이슈가 없는지 확인이 필요합니다.</p>

<p>이러한 변경은 다음 설정을 통해 Hibernate 6.2 이전과 같이 동작하도록 할 수 있습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spring</span><span class="pi">:</span>
  <span class="na">jpa</span><span class="pi">:</span>
    <span class="na">properties</span><span class="pi">:</span>
      <span class="na">hibernate</span><span class="pi">:</span>
        <span class="na">timezone.default_storage</span><span class="pi">:</span> <span class="s">NORMALIZE</span>
</code></pre></div></div>

<hr />

<h2 id="문제-상황">문제 상황</h2>

<p>저희 회사는 PostgreSQL을 사용하며 <code class="language-plaintext highlighter-rouge">TIMESTAMP WITH TIME ZONE</code> 타입으로 날짜/시간 데이터를 저장합니다.
그리고 이는 애플리케이션에서 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code> 타입에 매핑되도록 구성되어 있습니다.
기존에는 조회된 데이터가 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code>에 매핑될 때 시스템 기본 타임존인 <code class="language-plaintext highlighter-rouge">Asia/Seoul</code>에 맞게 <code class="language-plaintext highlighter-rouge">ZoneOffset</code>이 <code class="language-plaintext highlighter-rouge">+09:00</code>로 설정되었습니다.
그런데 Spring Boot 버전을 올린 이후 <code class="language-plaintext highlighter-rouge">ZoneOffset</code>이 <code class="language-plaintext highlighter-rouge">UTC</code> 기준으로 설정되었습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 기존 동작 (Hibernate 6.2 미만)</span>
<span class="nc">OffsetDateTime</span> <span class="n">createdAt</span> <span class="o">=</span> <span class="n">entity</span><span class="o">.</span><span class="na">getCreatedAt</span><span class="o">();</span> <span class="c1">// +09:00 Offset</span>

<span class="c1">// 변경 후 동작 (Hibernate 6.2 이상)</span>
<span class="nc">OffsetDateTime</span> <span class="n">createdAt</span> <span class="o">=</span> <span class="n">entity</span><span class="o">.</span><span class="na">getCreatedAt</span><span class="o">();</span> <span class="c1">// +00:00 Offset (UTC)</span>
</code></pre></div></div>

<p>System, Hibernate, JDBC Connection, Jackson에 타임존 설정을 명시적으로 시도해보았지만 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code>은 여전히 <code class="language-plaintext highlighter-rouge">UTC</code> 기준으로 매핑되었습니다.
이로 인해 Hibernate를 통해 가져온 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code>을 <code class="language-plaintext highlighter-rouge">equals</code>로 비교하거나 문자열로 변환하는 부분에서 기존과 다른 결과값을 얻게 되어 이슈가 발생했습니다.</p>

<hr />

<h2 id="attributeconverter로-해결">AttributeConverter로 해결?!</h2>

<p>일반적으로 많이 사용하는 <code class="language-plaintext highlighter-rouge">AttributeConverter</code>를 등록하여 데이터베이스에서 가져온 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code>에 <code class="language-plaintext highlighter-rouge">ZoneOffset</code>을 명시적으로 설정하려고 했습니다.
그러나 <code class="language-plaintext highlighter-rouge">AttributeConverter</code>는 Java 객체와 데이터베이스 스키마 간의 불일치를 해결하는 것이 주 목적이라는 생각이 들었고,
기존에 시스템 타임존을 사용하던 방식이 <code class="language-plaintext highlighter-rouge">UTC</code>로 고정되도록 바뀌었다는 것이 개인적으로 납득이 잘 되지 않았기 때문에 Hibernate의 소스 코드를 직접 확인해보기로 했습니다.</p>

<hr />

<h2 id="hibernate의-타입-변환-흐름-분석">Hibernate의 타입 변환 흐름 분석</h2>

<blockquote>
  <p>다음부터 디버깅하며 소스 코드를 확인하는 내용입니다. <a href="#timezonestoragestrategy">건너뛰기</a></p>
</blockquote>

<p>먼저 <code class="language-plaintext highlighter-rouge">ValueExtractor</code> 인터페이스를 살펴보았습니다. 이 인터페이스는 데이터베이스 조회 결과를 Java 타입으로 변환하는 역할을 합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">package</span> <span class="nn">org.hibernate.type.descriptor</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">interface</span> <span class="nc">ValueExtractor</span><span class="o">&lt;</span><span class="no">X</span><span class="o">&gt;</span> <span class="o">{</span>
    <span class="no">X</span> <span class="nf">extract</span><span class="o">(</span><span class="nc">ResultSet</span> <span class="n">rs</span><span class="o">,</span> <span class="kt">int</span> <span class="n">paramIndex</span><span class="o">,</span> <span class="nc">WrapperOptions</span> <span class="n">options</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">SQLException</span><span class="o">;</span>

    <span class="no">X</span> <span class="nf">extract</span><span class="o">(</span><span class="nc">CallableStatement</span> <span class="n">statement</span><span class="o">,</span> <span class="kt">int</span> <span class="n">paramIndex</span><span class="o">,</span> <span class="nc">WrapperOptions</span> <span class="n">options</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">SQLException</span><span class="o">;</span>

    <span class="no">X</span> <span class="nf">extract</span><span class="o">(</span><span class="nc">CallableStatement</span> <span class="n">statement</span><span class="o">,</span> <span class="nc">String</span> <span class="n">paramName</span><span class="o">,</span> <span class="nc">WrapperOptions</span> <span class="n">options</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">SQLException</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>디버깅을 통해 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code> 변환 시 <code class="language-plaintext highlighter-rouge">TimestampUtcAsJdbcTimestampJdbcType</code> 클래스 내 익명클래스로 구현된 구현체가 사용됨을 확인할 수 있었습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">package</span> <span class="nn">org.hibernate.type.descriptor.jdbc</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">TimestampUtcAsJdbcTimestampJdbcType</span> <span class="kd">implements</span> <span class="nc">JdbcType</span> <span class="o">{</span>
    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="o">&lt;</span><span class="no">X</span><span class="o">&gt;</span> <span class="nc">ValueExtractor</span><span class="o">&lt;</span><span class="no">X</span><span class="o">&gt;</span> <span class="nf">getExtractor</span><span class="o">(</span><span class="kd">final</span> <span class="nc">JavaType</span><span class="o">&lt;</span><span class="no">X</span><span class="o">&gt;</span> <span class="n">javaType</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nc">BasicExtractor</span><span class="o">&lt;&gt;(</span><span class="n">javaType</span><span class="o">,</span> <span class="k">this</span><span class="o">)</span> <span class="o">{</span>
            <span class="nd">@Override</span>
            <span class="kd">protected</span> <span class="no">X</span> <span class="nf">doExtract</span><span class="o">(</span><span class="nc">ResultSet</span> <span class="n">rs</span><span class="o">,</span> <span class="kt">int</span> <span class="n">paramIndex</span><span class="o">,</span> <span class="nc">WrapperOptions</span> <span class="n">options</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">SQLException</span> <span class="o">{</span>
                <span class="kd">final</span> <span class="nc">Timestamp</span> <span class="n">timestamp</span> <span class="o">=</span> <span class="n">rs</span><span class="o">.</span><span class="na">getTimestamp</span><span class="o">(</span><span class="n">paramIndex</span><span class="o">,</span> <span class="no">UTC_CALENDAR</span><span class="o">);</span>
                <span class="k">return</span> <span class="n">javaType</span><span class="o">.</span><span class="na">wrap</span><span class="o">(</span><span class="n">timestamp</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">?</span> <span class="kc">null</span> <span class="o">:</span> <span class="n">timestamp</span><span class="o">.</span><span class="na">toInstant</span><span class="o">(),</span> <span class="n">options</span><span class="o">);</span>
            <span class="o">}</span>
            <span class="c1">// ... 생략 ...</span>
        <span class="o">};</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이 구현체는 데이터베이스 조회 결과인 <code class="language-plaintext highlighter-rouge">ResultSet</code>에서 <code class="language-plaintext highlighter-rouge">Timestamp</code>를 가져온 후, 이를 <code class="language-plaintext highlighter-rouge">Instant</code>로 변환하여 <code class="language-plaintext highlighter-rouge">javaType.wrap()</code> 메소드에 전달합니다.</p>

<p>이어서 <code class="language-plaintext highlighter-rouge">OffsetDateTimeJavaType</code> 클래스의 <code class="language-plaintext highlighter-rouge">wrap()</code> 메소드를 보겠습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">package</span> <span class="nn">org.hibernate.type.descriptor.java</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">OffsetDateTimeJavaType</span> <span class="kd">extends</span> <span class="nc">AbstractTemporalJavaType</span><span class="o">&lt;</span><span class="nc">OffsetDateTime</span><span class="o">&gt;</span>
        <span class="kd">implements</span> <span class="nc">VersionJavaType</span><span class="o">&lt;</span><span class="nc">OffsetDateTime</span><span class="o">&gt;</span> <span class="o">{</span>
    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="o">&lt;</span><span class="no">X</span><span class="o">&gt;</span> <span class="nc">OffsetDateTime</span> <span class="nf">wrap</span><span class="o">(</span><span class="no">X</span> <span class="n">value</span><span class="o">,</span> <span class="nc">WrapperOptions</span> <span class="n">options</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// ... 생략 ...</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">value</span> <span class="k">instanceof</span> <span class="nc">Instant</span><span class="o">)</span> <span class="o">{</span>
            <span class="nc">Instant</span> <span class="n">instant</span> <span class="o">=</span> <span class="o">(</span><span class="nc">Instant</span><span class="o">)</span> <span class="n">value</span><span class="o">;</span>
            <span class="k">return</span> <span class="n">instant</span><span class="o">.</span><span class="na">atOffset</span><span class="o">(</span><span class="nc">ZoneOffset</span><span class="o">.</span><span class="na">UTC</span><span class="o">);</span>
        <span class="o">}</span>
        <span class="c1">// ... 생략 ...</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">value</code>가 <code class="language-plaintext highlighter-rouge">Instant</code> 타입인 경우 <code class="language-plaintext highlighter-rouge">ZoneOffset.UTC</code>를 사용하여 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code>을 생성합니다.
이와 같은 이유로 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code>의 <code class="language-plaintext highlighter-rouge">Offset</code>이 <code class="language-plaintext highlighter-rouge">UTC</code>로 고정된 것입니다.</p>

<p>그런데 <code class="language-plaintext highlighter-rouge">TimestampWithTimeZoneJdbcType</code>이라는 구현체도 있는데,
왜 이름부터 UTC스러운 <code class="language-plaintext highlighter-rouge">TimestampUtcAsJdbcTimestampJdbcType</code>이 사용되었는지 알아볼 필요가 있었습니다.</p>

<hr />

<h2 id="hibernate의-metabuildingprocess">Hibernate의 MetaBuildingProcess</h2>

<p>Hibernate는 애플리케이션 실행 시점에 엔티티, 컨버터 등을 읽어들여 메타데이터를 구성합니다.
타입 매핑과 같은 설정들이 메타데이터 구성 과정에서 이루어지기에 <code class="language-plaintext highlighter-rouge">MetadataBuildingProcess</code>라는 클래스를 살펴보게 되었습니다.</p>

<p>그런데 <code class="language-plaintext highlighter-rouge">handleTypes()</code>라는 메서드에서 중요한 설정을 발견했습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">static</span> <span class="kt">void</span> <span class="nf">handleTypes</span><span class="o">(...)</span> <span class="o">{</span>
    <span class="kd">final</span> <span class="nc">JdbcType</span> <span class="n">timestampWithTimeZoneOverride</span> <span class="o">=</span> <span class="n">getTimestampWithTimeZoneOverride</span><span class="o">(</span><span class="n">options</span><span class="o">,</span> <span class="n">jdbcTypeRegistry</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">timestampWithTimeZoneOverride</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">adaptTimestampTypesToDefaultTimeZoneStorage</span><span class="o">(</span><span class="n">typeConfiguration</span><span class="o">,</span> <span class="n">timestampWithTimeZoneOverride</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>위 코드는 <code class="language-plaintext highlighter-rouge">getTimestampWithTimeZoneOverride()</code>로 가져온 <code class="language-plaintext highlighter-rouge">JdbcType</code> 구현체를 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code> 변환에 사용하도록 Registry에 등록하는 역할을 합니다.</p>

<p><code class="language-plaintext highlighter-rouge">getTimestampWithTimeZoneOverride()</code> 메서드는 다음과 같습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">static</span> <span class="nc">JdbcType</span> <span class="nf">getTimestampWithTimeZoneOverride</span><span class="o">(</span><span class="nc">MetadataBuildingOptions</span> <span class="n">options</span><span class="o">,</span> <span class="nc">JdbcTypeRegistry</span> <span class="n">jdbcTypeRegistry</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">switch</span> <span class="o">(</span><span class="n">options</span><span class="o">.</span><span class="na">getDefaultTimeZoneStorage</span><span class="o">())</span> <span class="o">{</span>
        <span class="k">case</span> <span class="nl">NORMALIZE:</span>
            <span class="k">return</span> <span class="n">jdbcTypeRegistry</span><span class="o">.</span><span class="na">getDescriptor</span><span class="o">(</span><span class="nc">Types</span><span class="o">.</span><span class="na">TIMESTAMP</span><span class="o">);</span>
        <span class="k">case</span> <span class="nl">NORMALIZE_UTC:</span>
            <span class="k">return</span> <span class="n">jdbcTypeRegistry</span><span class="o">.</span><span class="na">getDescriptor</span><span class="o">(</span><span class="nc">SqlTypes</span><span class="o">.</span><span class="na">TIMESTAMP_UTC</span><span class="o">);</span>
        <span class="k">default</span><span class="o">:</span>
            <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>만약 <code class="language-plaintext highlighter-rouge">options.getDefaultTimeZoneStorage()</code>로 가져온 <code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy</code> 값이
<code class="language-plaintext highlighter-rouge">NORMALIZE</code> 또는 <code class="language-plaintext highlighter-rouge">NORMALIZE_UTC</code>라면 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code> 변환에 사용할 <code class="language-plaintext highlighter-rouge">JdbcType</code> 구현체를 반환하고,
이를 Registry에 등록하여 기존 매핑 설정을 Override 하도록 하는 것입니다.</p>

<p>디버깅을 통해 확인해보니 <code class="language-plaintext highlighter-rouge">options.getDefaultTimeZoneStorage()</code>의 반환 값은 <code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy.NORMALIZE_UTC</code>였고,
이로 인해 호출되는 <code class="language-plaintext highlighter-rouge">jdbcTypeRegistry.getDescriptor(SqlTypes.TIMESTAMP_UTC)</code>의 반환값이 <code class="language-plaintext highlighter-rouge">TimestampUtcAsJdbcTimestampJdbcType</code>였습니다.</p>

<p>즉 <code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy.NORMALIZE_UTC</code>로 인해 <code class="language-plaintext highlighter-rouge">OffsetDateTime</code> 변환에 <code class="language-plaintext highlighter-rouge">TimestampUtcAsJdbcTimestampJdbcType</code> 구현체가 사용되도록
Registry에 등록된 것입니다.</p>

<hr />

<h2 id="timezonestoragestrategy">TimeZoneStorageStrategy</h2>

<p>Hibernate 6에서 <code class="language-plaintext highlighter-rouge">TimeZoneStorage</code>라는 개념이 새로 도입되었습니다.
이에 대한 <code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy</code> 전략 설정에 따라 Hibernate가 날짜/시간 데이터를 데이터베이스에 저장할 때 타임존 정보를 어떻게 처리할지 결정됩니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">package</span> <span class="nn">org.hibernate</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">enum</span> <span class="nc">TimeZoneStorageStrategy</span> <span class="o">{</span>
    <span class="no">NATIVE</span><span class="o">,</span>
    <span class="no">COLUMN</span><span class="o">,</span>
    <span class="no">NORMALIZE</span><span class="o">,</span>
    <span class="no">NORMALIZE_UTC</span>
<span class="o">}</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">NATIVE</code>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">WITH TIME ZONE</code> 타입에 시간대 정보를 온전히 저장합니다.</li>
      <li>데이터베이스 <code class="language-plaintext highlighter-rouge">Dialect</code>의 <code class="language-plaintext highlighter-rouge">TimeZoneSupport</code> 값이 <code class="language-plaintext highlighter-rouge">NATIVE</code>가 아닌 경우 실행 시점에 구성 오류를 반환합니다.</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">COLUMN</code> : 시간대 정보를 별도의 열에 저장합니다.</li>
  <li><code class="language-plaintext highlighter-rouge">NORMALIZE</code> : <code class="language-plaintext highlighter-rouge">JDBC_TIME_ZONE</code> 설정 또는 시스템 타임존으로 정규화하여 타임스탬프를 저장합니다.</li>
  <li><code class="language-plaintext highlighter-rouge">NORMALIZE_UTC</code> : UTC로 정규화하여 타임스탬프를 저장합니다.</li>
</ul>

<p>이러한 핵심이 되는 <code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy</code>를 선택하기 위해 <code class="language-plaintext highlighter-rouge">TimeZoneSupport</code>, <code class="language-plaintext highlighter-rouge">TimeZoneStorageType</code>와 같은 열거형 타입들이 사용됩니다.</p>

<hr />

<h2 id="timezonesupport">TimeZoneSupport</h2>

<p>이 값은 데이터베이스가 <code class="language-plaintext highlighter-rouge">WITH TIME ZONE</code> 타입을 지원하는 정도를 나타내며, 총 3가지 값이 존재합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">package</span> <span class="nn">org.hibernate.dialect</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">enum</span> <span class="nc">TimeZoneSupport</span> <span class="o">{</span>
    <span class="no">NATIVE</span><span class="o">,</span>
    <span class="no">NORMALIZE</span><span class="o">,</span>
    <span class="no">NONE</span>
<span class="o">}</span>
</code></pre></div></div>

<p>다음 순서대로 지원도가 높음을 의미합니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">NATIVE</code> : <code class="language-plaintext highlighter-rouge">WITH TIME ZONE</code> 타입에 타임존 정보를 함께 저장 (Oracle, H2, …)</li>
  <li><code class="language-plaintext highlighter-rouge">NORMALIZE</code> : <code class="language-plaintext highlighter-rouge">WITH TIME ZONE</code> 타입에 특정 시간대로 정규화하여 저장 (PostgreSQL, …)</li>
  <li><code class="language-plaintext highlighter-rouge">NONE</code> : <code class="language-plaintext highlighter-rouge">WITH TIME ZONE</code> 타입을 지원하지 않음 (MySQL, MariaDB, …)</li>
</ul>

<p>각 데이터베이스 Dialect 구현체마다 이 값을 반환하는 메서드가 있습니다. <code class="language-plaintext highlighter-rouge">PostgreSQLDialect</code>의 경우 다음과 같이 <code class="language-plaintext highlighter-rouge">NORMALIZE</code> 값을 반환합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">package</span> <span class="nn">org.hibernate.dialect</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">PostgreSQLDialect</span> <span class="kd">extends</span> <span class="nc">Dialect</span> <span class="o">{</span>
    <span class="c1">// ...</span>
    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">TimeZoneSupport</span> <span class="nf">getTimeZoneSupport</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">TimeZoneSupport</span><span class="o">.</span><span class="na">NORMALIZE</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<hr />

<h2 id="timezonestoragetype">TimeZoneStorageType</h2>

<p><code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy</code>를 설정하기 위한 값입니다. 이 값과 <code class="language-plaintext highlighter-rouge">TimeZoneSupport</code> 값의 조합으로 <code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy</code>가 설정됩니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">package</span> <span class="nn">org.hibernate.annotations</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">enum</span> <span class="nc">TimeZoneStorageType</span> <span class="o">{</span>
    <span class="no">NATIVE</span><span class="o">,</span>
    <span class="no">NORMALIZE</span><span class="o">,</span>
    <span class="no">NORMALIZE_UTC</span><span class="o">,</span>
    <span class="no">COLUMN</span><span class="o">,</span>
    <span class="no">AUTO</span><span class="o">,</span>
    <span class="no">DEFAULT</span>
<span class="o">}</span>
</code></pre></div></div>

<p>기존 6.2 버전 이전에는 따로 <code class="language-plaintext highlighter-rouge">TimeZoneStorageType</code>을 설정하지 않았다면 기본 전략으로 <code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy.NORMALIZE</code>가 사용되었습니다.
그런데 6.2 버전부터는 <code class="language-plaintext highlighter-rouge">TimeZoneStorageType.DEFAULT</code>가 기본 설정으로 사용되도록 변경되었습니다.
이 설정은 데이터베이스의 <code class="language-plaintext highlighter-rouge">TimeZoneSupport</code> 값이 <code class="language-plaintext highlighter-rouge">NATIVE</code>가 아닌 경우 <code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy.NORMALIZE_UTC</code>를 전략으로 사용하도록 합니다.</p>

<p>자세한 내용은 다음 문서에서 확인할 수 있습니다.
<a href="https://docs.jboss.org/hibernate/orm/6.2/migration-guide/migration-guide.html#ddl-timezones:~:text=datatype%20by%20default-,Timezone%20and%20offset%20storage,behavior%2C%20set%20the%20configuration%20property%20hibernate.timezone.default_storage%20to%20NORMALIZE.,-Byte%5B%5D/Character%5B%5D%20mapping">Hibernate 6.2 Migration Guide</a></p>

<p>실제로 전략을 선택하는 코드를 보면 다음과 같습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">package</span> <span class="nn">org.hibernate.boot.internal</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">MetadataBuilderImpl</span> <span class="kd">implements</span> <span class="nc">MetadataBuilderImplementor</span><span class="o">,</span> <span class="nc">TypeContributions</span> <span class="o">{</span>
    <span class="c1">// ...</span>
    <span class="kd">private</span> <span class="nc">TimeZoneStorageStrategy</span> <span class="nf">toTimeZoneStorageStrategy</span><span class="o">(</span><span class="nc">TimeZoneSupport</span> <span class="n">timeZoneSupport</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">switch</span> <span class="o">(</span><span class="n">defaultTimezoneStorage</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">case</span> <span class="nl">NATIVE:</span>
                <span class="k">if</span> <span class="o">(</span><span class="n">timeZoneSupport</span> <span class="o">!=</span> <span class="nc">TimeZoneSupport</span><span class="o">.</span><span class="na">NATIVE</span><span class="o">)</span> <span class="o">{</span>
                    <span class="k">throw</span> <span class="k">new</span> <span class="nf">HibernateException</span><span class="o">(</span><span class="s">"The configured time zone storage type NATIVE is not supported with the configured dialect"</span><span class="o">);</span>
                <span class="o">}</span>
                <span class="k">return</span> <span class="nc">TimeZoneStorageStrategy</span><span class="o">.</span><span class="na">NATIVE</span><span class="o">;</span>
            <span class="k">case</span> <span class="nl">COLUMN:</span>
                <span class="k">return</span> <span class="nc">TimeZoneStorageStrategy</span><span class="o">.</span><span class="na">COLUMN</span><span class="o">;</span>
            <span class="k">case</span> <span class="nl">NORMALIZE:</span>
                <span class="k">return</span> <span class="nc">TimeZoneStorageStrategy</span><span class="o">.</span><span class="na">NORMALIZE</span><span class="o">;</span>
            <span class="k">case</span> <span class="nl">NORMALIZE_UTC:</span>
                <span class="k">return</span> <span class="nc">TimeZoneStorageStrategy</span><span class="o">.</span><span class="na">NORMALIZE_UTC</span><span class="o">;</span>
            <span class="k">case</span> <span class="nl">AUTO:</span>
                <span class="k">switch</span> <span class="o">(</span><span class="n">timeZoneSupport</span><span class="o">)</span> <span class="o">{</span>
                    <span class="k">case</span> <span class="nl">NATIVE:</span>
                        <span class="k">return</span> <span class="nc">TimeZoneStorageStrategy</span><span class="o">.</span><span class="na">NATIVE</span><span class="o">;</span>
                    <span class="k">case</span> <span class="nl">NORMALIZE:</span>
                    <span class="k">case</span> <span class="nl">NONE:</span>
                        <span class="k">return</span> <span class="nc">TimeZoneStorageStrategy</span><span class="o">.</span><span class="na">COLUMN</span><span class="o">;</span>
                    <span class="k">default</span><span class="o">:</span>
                        <span class="k">throw</span> <span class="k">new</span> <span class="nf">HibernateException</span><span class="o">(</span><span class="s">"Unsupported time zone support: "</span> <span class="o">+</span> <span class="n">timeZoneSupport</span><span class="o">);</span>
                <span class="o">}</span>
            <span class="k">case</span> <span class="nl">DEFAULT:</span>
                <span class="k">switch</span> <span class="o">(</span><span class="n">timeZoneSupport</span><span class="o">)</span> <span class="o">{</span>
                    <span class="k">case</span> <span class="nl">NATIVE:</span>
                        <span class="k">return</span> <span class="nc">TimeZoneStorageStrategy</span><span class="o">.</span><span class="na">NATIVE</span><span class="o">;</span>
                    <span class="k">case</span> <span class="nl">NORMALIZE:</span>
                    <span class="k">case</span> <span class="nl">NONE:</span>
                        <span class="k">return</span> <span class="nc">TimeZoneStorageStrategy</span><span class="o">.</span><span class="na">NORMALIZE_UTC</span><span class="o">;</span>
                    <span class="k">default</span><span class="o">:</span>
                        <span class="k">throw</span> <span class="k">new</span> <span class="nf">HibernateException</span><span class="o">(</span><span class="s">"Unsupported time zone support: "</span> <span class="o">+</span> <span class="n">timeZoneSupport</span><span class="o">);</span>
                <span class="o">}</span>
            <span class="k">default</span><span class="o">:</span>
                <span class="k">throw</span> <span class="k">new</span> <span class="nf">HibernateException</span><span class="o">(</span><span class="s">"Unsupported time zone storage type: "</span> <span class="o">+</span> <span class="n">defaultTimezoneStorage</span><span class="o">);</span>
        <span class="o">}</span>
        <span class="c1">// ...</span>
    <span class="o">}</span>
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">defaultTimezoneStorage</code>는 <code class="language-plaintext highlighter-rouge">TimeZoneStorageType</code>의 기본값인 <code class="language-plaintext highlighter-rouge">DEFAULT</code>이고, <code class="language-plaintext highlighter-rouge">timeZoneSupport</code>는 PostgreSQL을 사용하기 때문에
<code class="language-plaintext highlighter-rouge">NORMALIZE</code>입니다.
이러한 이유로 <code class="language-plaintext highlighter-rouge">TimeZoneStorage</code> 전략으로 <code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy.NORMALIZE_UTC</code>가 사용되게 된 것입니다.</p>

<hr />

<h2 id="정리">정리</h2>

<p>Hibernate 6.2 이상을 사용하며, <code class="language-plaintext highlighter-rouge">TimeZoneSupport</code>가 <code class="language-plaintext highlighter-rouge">NATIVE</code>가 아닌 데이터베이스(MySQL, MariaDB, PostgreSQL 등…)를 사용하는 경우
<code class="language-plaintext highlighter-rouge">OffsetDateTime</code>/<code class="language-plaintext highlighter-rouge">ZonedDateTime</code>으로 매핑된 데이터 조회 시 <code class="language-plaintext highlighter-rouge">Offset</code>/<code class="language-plaintext highlighter-rouge">TimeZone</code>이 <code class="language-plaintext highlighter-rouge">UTC</code>로 고정됩니다.</p>

<blockquote>
  <p>현 포스트에서는 조회에 관련된 디버깅 과정만 담았지만, 데이터를 저장하는 경우에도 UTC 기준으로 정규화되어 데이터베이스로 넘어간다고 보시면 됩니다.</p>
</blockquote>

<hr />

<h2 id="해결-1--timezonestoragetype-설정-변경">해결 1 : TimeZoneStorageType 설정 변경</h2>

<p><code class="language-plaintext highlighter-rouge">TimeZoneStorageType</code>을 <code class="language-plaintext highlighter-rouge">NORMALIZE</code>로 설정하여 <code class="language-plaintext highlighter-rouge">TimeZoneStorageStrategy</code>를 <code class="language-plaintext highlighter-rouge">NORMALIZE</code>로 변경할 수 있습니다.
이를 통해 기존처럼 시스템 타임존 기준으로 정규화되도록 할 수 있습니다.</p>

<h3 id="글로벌-설정">글로벌 설정</h3>

<p>다음과 같이 프로퍼티 설정으로 <code class="language-plaintext highlighter-rouge">TimeZoneStorageType</code>을 글로벌 설정할 수 있습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spring</span><span class="pi">:</span>
  <span class="na">jpa</span><span class="pi">:</span>
    <span class="na">properties</span><span class="pi">:</span>
      <span class="na">hibernate</span><span class="pi">:</span>
        <span class="na">timezone.default_storage</span><span class="pi">:</span> <span class="s">NORMALIZE</span>
</code></pre></div></div>

<p>자세한 내용은 다음 문서에서 확인할 수 있습니다.
<a href="https://docs.jboss.org/hibernate/orm/6.2/userguide/html_single/Hibernate_User_Guide.html#_time_zone_storage:~:text=implements%20CurrentTenantIdentifierResolver.-,hibernate.timezone.default_storage,-See%3A%20AvailableSettings">Hibernate 6.2 User Guide</a></p>

<h3 id="개별-필드-설정">개별 필드 설정</h3>

<p><code class="language-plaintext highlighter-rouge">@TimeZoneStorage</code>를 사용하여 개별 필드에 설정하는 방식입니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="nd">@TimeZoneStorage</span><span class="o">(</span><span class="nc">TimeZoneStorageType</span><span class="o">.</span><span class="na">NORMALIZE</span><span class="o">)</span>
<span class="kd">private</span> <span class="nc">OffsetDateTime</span> <span class="n">normalized</span><span class="o">;</span>

<span class="nd">@TimeZoneStorage</span><span class="o">(</span><span class="nc">TimeZoneStorageType</span><span class="o">.</span><span class="na">COLUMN</span><span class="o">)</span> <span class="c1">// 별도의 컬럼에 타임존 저장</span>
<span class="nd">@TimeZoneColumn</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"column_offset"</span><span class="o">)</span>
<span class="kd">private</span> <span class="nc">OffsetDateTime</span> <span class="n">offsetDateTime</span><span class="o">;</span>
</code></pre></div></div>

<p>자세한 내용은 다음 문서에서 확인할 수 있습니다.
<a href="https://docs.jboss.org/hibernate/orm/6.2/userguide/html_single/Hibernate_User_Guide.html#settings-hibernate.timezone.default_storage">Hibernate 6.2 User Guide</a></p>

<hr />

<h2 id="해결-2--hibernate의-제안-받아들이기">해결 2 : Hibernate의 제안 받아들이기</h2>

<p>Hibernate가 제시하는 접근 방식의 핵심은 ‘시스템 타임존에 대한 의존성 제거’ 그리고 ‘일관성’이라고 이해됩니다.</p>

<p>기존 방식은 시스템 타임존에 의존하기 때문에 데이터의 일관성을 유지하기 어려울 수 있습니다.
예를 들어, 같은 애플리케이션이라도 서버의 타임존 설정에 따라 데이터베이스에 저장되는 값이 달라질 수 있습니다.
또한 다중 서버 환경에서 서로 다른 타임존 설정을 가진 서버들이 동일한 데이터베이스에 접근하는 경우 데이터 정합성에 문제가 발생할 수 있습니다.</p>

<p>이러한 관점에서 바라보면 <strong><u>Hibernate 6.2의 변경사항은</u></strong> 단순히 불편함을 주는 변경이 아닌, <strong><u>보다 견고한 시스템 설계를 위한 제안</u></strong>으로 볼 수 있습니다.
특히 글로벌 서비스나 마이크로서비스 환경에서 데이터 정합성을 보장하고 시스템 복잡도를 낮추는 데 큰 도움이 될 것이라 생각됩니다.</p>

<hr />

<h2 id="느낀-점">느낀 점</h2>
<p>‘기존과 달라졌으니 원래대로 돌려놓자’라는 단순한 접근보다는 ‘이러한 변경의 의도는 무엇일까?’라는 질문을 통해 더 나은 시스템을 위한 고민으로 발전시킬 수 있었던 유익한 경험이었습니다.</p>

<p><a href="https://blog.nilgil.com/posts/spring-http-transfer-method-changes/">Spring RestClient/RestTemplate 요청이 실패하는 이유</a>를 경험했던 것처럼, 
외부 라이브러리의 변경사항은 예상치 못한 문제를 일으킬 수 있음을 다시 한 번 실감했습니다. 그리고 이를 통해 테스트 코드가 얼마나 중요한지도 다시 한 번 깨닫게 되었습니다.</p>

<p>이번 이슈와 관련하여 이미 여러 글들이 포스팅되어 있어 간단히 해결할 수도 있었습니다. 
그러나 직접 디버깅하고 소스 코드를 분석해 본 것이 Hibernate에 대한 이해도를 높이는 좋은 기회가 되었기에 앞으로도 이러한 학습 자세를 유지해야겠다는 생각을 하게 되었습니다.</p>]]></content><author><name>nilgil</name><email>nilgil183@gmail.com</email></author><category term="Tech" /><category term="Hibernate" /><summary type="html"><![CDATA[Hibernate 6.2부터 타임존 데이터를 처리하는 기본 전략이 변경되었습니다. 이로 인해 발생할 수 있는 이슈와 디버깅 과정, 그리고 해결 방법을 알아봅니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nilgil.com/assets/img/blog/common/hibernate.png" /><media:content medium="image" url="https://blog.nilgil.com/assets/img/blog/common/hibernate.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Spring RestClient/RestTemplate 요청이 실패하는 이유 - 데이터 타입에 따른 전송 방식 차이</title><link href="https://blog.nilgil.com/posts/spring-http-transfer-method-changes/" rel="alternate" type="text/html" title="Spring RestClient/RestTemplate 요청이 실패하는 이유 - 데이터 타입에 따른 전송 방식 차이" /><published>2025-01-18T00:00:00+00:00</published><updated>2026-09-09T20:01:31+00:00</updated><id>https://blog.nilgil.com/posts/spring-http-transfer-method-changes</id><content type="html" xml:base="https://blog.nilgil.com/posts/spring-http-transfer-method-changes/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#요약" id="markdown-toc-요약">요약</a></li>
  <li><a href="#문제-상황" id="markdown-toc-문제-상황">문제 상황</a></li>
  <li><a href="#패킷-확인" id="markdown-toc-패킷-확인">패킷 확인</a></li>
  <li><a href="#chunked-transfer-encoding-란" id="markdown-toc-chunked-transfer-encoding-란">Chunked Transfer Encoding 란?</a></li>
  <li><a href="#restclient가-청크-전송을-사용하는-경우는" id="markdown-toc-restclient가-청크-전송을-사용하는-경우는">RestClient가 청크 전송을 사용하는 경우는?</a></li>
  <li><a href="#messageconverter" id="markdown-toc-messageconverter">MessageConverter</a></li>
  <li><a href="#spring-framework-61-release-notes" id="markdown-toc-spring-framework-61-release-notes">Spring Framework 6.1 Release Notes</a></li>
  <li><a href="#해결-방법" id="markdown-toc-해결-방법">해결 방법</a>    <ul>
      <li><a href="#직접-직렬화" id="markdown-toc-직접-직렬화">직접 직렬화</a></li>
      <li><a href="#mappingjackson2httpmessageconverter-커스텀" id="markdown-toc-mappingjackson2httpmessageconverter-커스텀">MappingJackson2HttpMessageConverter 커스텀</a></li>
      <li><a href="#bufferingclienthttprequestfactory-사용" id="markdown-toc-bufferingclienthttprequestfactory-사용">BufferingClientHttpRequestFactory 사용</a></li>
    </ul>
  </li>
  <li><a href="#결론" id="markdown-toc-결론">결론</a></li>
  <li><a href="#참고-자료" id="markdown-toc-참고-자료">참고 자료</a></li>
</ul>

<h2 id="요약">요약</h2>

<p>Spring 6.1부터 RestTemplate(이하 생략)과 RestClient의 요청 본문 처리 방식이 데이터 타입에 따라 다르게 동작하도록 변경되었습니다.
<code class="language-plaintext highlighter-rouge">byte[]</code>나 <code class="language-plaintext highlighter-rouge">String</code> 타입은 기존처럼 Content-Length 헤더와 함께 전송되지만,
<code class="language-plaintext highlighter-rouge">Object</code> 타입의 경우 Chunked Transfer Encoding을 사용하여 Streaming 방식으로 전송됩니다.
이러한 변경으로 인해 Chunked Transfer Encoding을 지원하지 않는 일부 API에서 문제가 발생할 수 있어 주의가 필요합니다.</p>

<h2 id="문제-상황">문제 상황</h2>

<p>Spring 6의 RestClient를 사용하여 외부 API를 호출하는 과정에서 문제가 발생했습니다.
코드 상으로는 문제가 없어 보였으나 계속해서 500 응답을 받았고, 에러 메시지에 상세한 설명이 없어 원인 파악이 어려웠습니다.</p>

<p>문제가 된 코드는 다음과 같습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 500 에러가 발생하는 코드</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">callApi</span><span class="o">(</span><span class="nc">TestObj</span> <span class="n">testObj</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">restClient</span><span class="o">.</span><span class="na">post</span><span class="o">()</span>
        <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/api/..."</span><span class="o">)</span>
        <span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="n">testObj</span><span class="o">)</span>
        <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
        <span class="o">.</span><span class="na">toBodilessEntity</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Postman으로 요청을 보냈을 때 정상적으로 응답을 받았기에 코드 레벨에서 문제 해결을 위한 여러 시도를 해보았습니다.
그러다 Object를 직접 JSON String으로 직렬화하여 전송했을 때 정상적으로 동작하는 것을 확인했습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 정상 동작하는 코드</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">callApi</span><span class="o">(</span><span class="nc">TestObj</span> <span class="n">testObj</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">String</span> <span class="n">jsonBody</span> <span class="o">=</span> <span class="n">objectMapper</span><span class="o">.</span><span class="na">writeValueAsString</span><span class="o">(</span><span class="n">testObj</span><span class="o">);</span>
    <span class="n">restClient</span><span class="o">.</span><span class="na">post</span><span class="o">()</span>
        <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/api/..."</span><span class="o">)</span>
        <span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="n">jsonBody</span><span class="o">)</span> <span class="c1">// String으로 변환하여 전달</span>
        <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
        <span class="o">.</span><span class="na">toBodilessEntity</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="패킷-확인">패킷 확인</h2>

<p>저는 위와 같은 이유로 ‘RestClient 내부적으로 Object 직렬화가 잘못되고 있는 건가?’ 라는 의심을 하게 되었습니다.
그래서 Object가 어떻게 직렬화되어 외부로 보내지고 있는지 두 눈으로 직접 확인해 보고자 Wireshark로 패킷을 캡쳐해보았습니다.</p>

<ul>
  <li>실패 케이스의 HTTP Message</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hypertext Transfer Protocol, has 2 chunks (including last chunk)
    POST / HTTP/1.1\r\n
    Connection: Upgrade, HTTP2-Settings\r\n
    Host: localhost:8080\r\n
    HTTP2-Settings: AAEAAEAAAAIAAAAAAAMAAAAAAAQBAAAAAAUAAEAAAAYABgAA\r\n
    Transfer-encoding: chunked\r\n
    Upgrade: h2c\r\n
    User-Agent: Java-http-client/21.0.5\r\n
    Content-Type: application/json\r\n
    \r\n
    [Full request URI: http://localhost:8080/]
    HTTP chunked response
        Data chunk (43 octets)
            Chunk size: 43 octets
            Chunk data: 7b226e616d65223a226e696c67696c222c22616765223a39392c22686f626279223a2274656e6e6973227d
            Chunk boundary: 0d0a
        End of chunked encoding
            Chunk size: 0 octets
        \r\n
    File Data: 43 bytes
</code></pre></div></div>

<ul>
  <li>성공 케이스의 HTTP Message</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hypertext Transfer Protocol
    POST / HTTP/1.1\r\n
    Connection: Upgrade, HTTP2-Settings\r\n
    Content-Length: 43\r\n
    Host: localhost:8080\r\n
    HTTP2-Settings: AAEAAEAAAAIAAAAAAAMAAAAAAAQBAAAAAAUAAEAAAAYABgAA\r\n
    Upgrade: h2c\r\n
    User-Agent: Java-http-client/21.0.5\r\n
    Content-Type: application/json\r\n
    \r\n
    [Response in frame: 205178]
    [Full request URI: http://localhost:8080/]
    File Data: 43 bytes
</code></pre></div></div>

<p>위 두 결과를 비교해 보니 예상하지 못한 부분에서 차이점이 명확히 보였습니다.</p>

<p>바로 Chunked Transfer Encoding 입니다.
실패했던 케이스는 요청 본문이 Streaming 방식으로 쪼개어 전송된 경우였고,
이를 통해 ‘API 측에서 Chunked Transfer Encoding을 지원하지 않아 발생한 문제’로 원인을 특정할 수 있었습니다.</p>

<h2 id="chunked-transfer-encoding-란">Chunked Transfer Encoding 란?</h2>

<p>Chunked Transfer Encoding은 HTTP/1.1에서 도입된 전송 방식으로, 데이터를 청크(chunk) 단위로 나누어 순차적으로 전송하는 방식입니다.</p>

<p>자세한 내용은 링크로 대체합니다.
<a href="https://datatracker.ietf.org/doc/html/rfc7230#section-4.1">rfc7230#section-4.1</a></p>

<h2 id="restclient가-청크-전송을-사용하는-경우는">RestClient가 청크 전송을 사용하는 경우는?</h2>

<p>RestClient는 기본적으로 JdkClientHttpRequestFactory를 사용하며, 이때 사용되는 Http1Request의 <code class="language-plaintext highlighter-rouge">headers()</code> 구현을 살펴보면,
Content-Length가 0보다 작을 경우(-1은 크기를 알 수 없다는 의미) 헤더에 <code class="language-plaintext highlighter-rouge">Transfer-encoding: chunked</code>를 추가하여 청크 전송을 수행합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// jdk.internal.net.http.Http1Request</span>
<span class="nc">List</span><span class="o">&lt;</span><span class="nc">ByteBuffer</span><span class="o">&gt;</span> <span class="nf">headers</span><span class="o">()</span> <span class="o">{</span>
    <span class="c1">// ...</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">requestPublisher</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">contentLength</span> <span class="o">=</span> <span class="n">requestPublisher</span><span class="o">.</span><span class="na">contentLength</span><span class="o">();</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">contentLength</span> <span class="o">==</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">systemHeadersBuilder</span><span class="o">.</span><span class="na">setHeader</span><span class="o">(</span><span class="s">"Content-Length"</span><span class="o">,</span> <span class="s">"0"</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">else</span> <span class="k">if</span> <span class="o">(</span><span class="n">contentLength</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">systemHeadersBuilder</span><span class="o">.</span><span class="na">setHeader</span><span class="o">(</span><span class="s">"Content-Length"</span><span class="o">,</span> <span class="nc">Long</span><span class="o">.</span><span class="na">toString</span><span class="o">(</span><span class="n">contentLength</span><span class="o">));</span>
            <span class="n">streaming</span> <span class="o">=</span> <span class="kc">false</span><span class="o">;</span>
        <span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
            <span class="n">streaming</span> <span class="o">=</span> <span class="kc">true</span><span class="o">;</span>
            <span class="n">systemHeadersBuilder</span><span class="o">.</span><span class="na">setHeader</span><span class="o">(</span><span class="s">"Transfer-encoding"</span><span class="o">,</span> <span class="s">"chunked"</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span>
    <span class="c1">// ...</span>
<span class="o">}</span>
</code></pre></div></div>

<p>SimpleClientHttpRequestFactory에서도 마찬가지로, SimpleClientHttpRequest의 executeInternal 메소드를 살펴보면
Content-Length가 0보다 작을 경우 청크 전송을 수행하도록 구현되어 있습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// org.springframework.http.client.SimpleClientHttpRequest</span>
<span class="kd">protected</span> <span class="nc">ClientHttpResponse</span> <span class="nf">executeInternal</span><span class="o">(</span><span class="nc">HttpHeaders</span> <span class="n">headers</span><span class="o">,</span> <span class="nd">@Nullable</span> <span class="nc">Body</span> <span class="n">body</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">(</span><span class="k">this</span><span class="o">.</span><span class="na">connection</span><span class="o">.</span><span class="na">getDoOutput</span><span class="o">())</span> <span class="o">{</span>
        <span class="kt">long</span> <span class="n">contentLength</span> <span class="o">=</span> <span class="n">headers</span><span class="o">.</span><span class="na">getContentLength</span><span class="o">();</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">contentLength</span> <span class="o">&gt;=</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">this</span><span class="o">.</span><span class="na">connection</span><span class="o">.</span><span class="na">setFixedLengthStreamingMode</span><span class="o">(</span><span class="n">contentLength</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
            <span class="k">this</span><span class="o">.</span><span class="na">connection</span><span class="o">.</span><span class="na">setChunkedStreamingMode</span><span class="o">(</span><span class="k">this</span><span class="o">.</span><span class="na">chunkSize</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span>
    <span class="c1">// ...</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이처럼 ClientHttpRequestFactory의 여러 구현체들이 Content-Length의 유무에 따라 청크 전송을 수행하는 것을 확인할 수 있었습니다.</p>

<h2 id="messageconverter">MessageConverter</h2>

<p>그렇다면 RestClient에서 <code class="language-plaintext highlighter-rouge">byte[]</code>나 <code class="language-plaintext highlighter-rouge">String</code>을 body에 담을 때는 청크 전송이 되지 않고,
<code class="language-plaintext highlighter-rouge">Object</code>를 body에 담을 때는 청크 전송이 발생한 이유가 무엇일까요?
이를 이해하기 위해서는 RestClient에 등록되어 사용되는 MessageConverter에 대해 살펴볼 필요가 있습니다.</p>

<p>MessageConverter는 HTTP 요청/응답의 본문(body)을 자바 객체로 변환하거나, 자바 객체를 HTTP 응답 본문으로 변환하는 역할을 수행합니다.
RestClient에는 기본적으로 5개의 MessageConverter가 등록되어 있습니다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">ByteArrayHttpMessageConverter</code></li>
  <li><code class="language-plaintext highlighter-rouge">StringHttpMessageConverter</code></li>
  <li><code class="language-plaintext highlighter-rouge">ResourceHttpMessageConverter</code></li>
  <li><code class="language-plaintext highlighter-rouge">AllEncompassingFormHttpMessageConverter</code></li>
  <li><code class="language-plaintext highlighter-rouge">MappingJackson2HttpMessageConverter</code></li>
</ul>

<p>MessageConverter들은 각각 지원하는 Media Type과 Class Type이 정의되어 있어서,
header의 Content-Type 값과 body에 담긴 데이터의 타입에 따라 적절한 컨버터가 선택됩니다.</p>

<p><code class="language-plaintext highlighter-rouge">byte[]</code>를 body에 담을 경우에는 ByteArrayHttpMessageConverter가,
<code class="language-plaintext highlighter-rouge">String</code>을 body에 담을 경우에는 StringHttpMessageConverter가 사용됩니다.
이들의 내부 구현을 살펴보면, <code class="language-plaintext highlighter-rouge">getContentLength()</code> 메소드를 Override하여 컨텐츠 길이를 제공하고 있음을 확인할 수 있습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// org.springframework.http.converter.ByteArrayHttpMessageConverter</span>
<span class="nd">@Override</span>
<span class="kd">protected</span> <span class="nc">Long</span> <span class="nf">getContentLength</span><span class="o">(</span><span class="kt">byte</span><span class="o">[]</span> <span class="n">bytes</span><span class="o">,</span> <span class="nd">@Nullable</span> <span class="nc">MediaType</span> <span class="n">contentType</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="o">(</span><span class="kt">long</span><span class="o">)</span><span class="n">bytes</span><span class="o">.</span><span class="na">length</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// org.springframework.http.converter.StringHttpMessageConverter</span>
<span class="nd">@Override</span>
<span class="kd">protected</span> <span class="nc">Long</span> <span class="nf">getContentLength</span><span class="o">(</span><span class="nc">String</span> <span class="n">str</span><span class="o">,</span> <span class="nd">@Nullable</span> <span class="nc">MediaType</span> <span class="n">contentType</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">Charset</span> <span class="n">charset</span> <span class="o">=</span> <span class="n">getContentTypeCharset</span><span class="o">(</span><span class="n">contentType</span><span class="o">);</span>
    <span class="k">return</span> <span class="o">(</span><span class="kt">long</span><span class="o">)</span><span class="n">str</span><span class="o">.</span><span class="na">getBytes</span><span class="o">(</span><span class="n">charset</span><span class="o">).</span><span class="na">length</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Object</code>를 body에 담는 경우에는 MappingJackson2HttpMessageConverter가 사용되는데,
이 컨버터는 <code class="language-plaintext highlighter-rouge">getContentLength()</code> 메소드를 별도로 Override하지 않고 있습니다.
따라서 Abstract 클래스에서 구현된 <code class="language-plaintext highlighter-rouge">null</code>을 반환하는 메소드를 그대로 사용하게 됩니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// org.springframework.http.converter.AbstractHttpMessageConverter</span>
<span class="nd">@Nullable</span>
<span class="kd">protected</span> <span class="nc">Long</span> <span class="nf">getContentLength</span><span class="o">(</span><span class="no">T</span> <span class="n">t</span><span class="o">,</span> <span class="nd">@Nullable</span> <span class="nc">MediaType</span> <span class="n">contentType</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span> <span class="o">{</span>
    <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>앞서 설명한 것처럼 여러 ClientHttpRequestFactory들은 Content-Length가 없는 경우 청크 전송을 수행하게 됩니다.
이러한 이유들로 인해 RestClient를 사용할 때 body에 어떤 타입이 담겼는지에 따라 다른 전송 방식이 사용되는 것입니다.</p>

<h2 id="spring-framework-61-release-notes">Spring Framework 6.1 Release Notes</h2>

<p>사실 이렇게 동작하게 된건 얼마 되지 않았습니다. 이전에는 요청/응답 본문을 버퍼에 모아서 한 번에 전송하는 것이 기본 동작이었습니다.
그러나 Spring Framework 6.1 릴리즈에서 메모리 최적화를 위해 ClientHttpRequestFactory 구현체들의 기본 동작 방식을 변경한 것입니다.</p>

<p><a href="https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-6.1-Release-Notes#web-applications">Spring Framework 6.1 Release Notes</a></p>

<p>릴리즈 노트 중 해당 내용은 다음과 같습니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>To reduce memory usage in RestClient and RestTemplate, most ClientHttpRequestFactory implementations no longer buffer
request bodies before sending them to the server. As a result, for certain content types such as JSON, the contents size
is no longer known, and a Content-Length header is no longer set. If you would like to buffer request bodies like
before, simply wrap the ClientHttpRequestFactory you are using in a BufferingClientHttpRequestFactory.
</code></pre></div></div>

<p>Object 타입을 전송할 때는 객체의 최종 크기를 미리 예측하기 어렵고, 직렬화 과정에서 예상보다 큰 JSON 데이터가 생성될 수 있습니다.
이전처럼 버퍼에 모든 데이터를 담아 전송하는 Buffering 방식을 사용할 경우, 상황에 따라 메모리 사용량이 기하급수적으로 증가할 위험이 있습니다.</p>

<p>그러나 Streaming 방식에서는 직렬화와 전송이 동시에 진행되므로, 대용량 데이터 처리 시에도 메모리를 효율적으로 사용할 수 있습니다.
스프링 측에서는 이러한 장점을 활용하기 위해 <strong><em><u>기본 동작 방식을 Buffering 방식에서 Streaming 방식으로 변경</u></em></strong>한 것입니다.</p>

<h2 id="해결-방법">해결 방법</h2>

<p>그렇다면 현 상황을 해결하기 위해 어떤 방법을 시도할 수 있을까요?</p>

<p>API 측에 청크 전송 지원을 요청하는 것이 가장 근본적인 해결책이 될 수 있지만, 현실적으로 어려울 것 같습니다.
따라서 클라이언트 측에서 청크 전송을 사용하지 않는 방향으로 해결 방법을 찾아야 합니다.</p>

<h3 id="직접-직렬화">직접 직렬화</h3>

<p>가장 간단한 방법으로는 body에 담을 때 직접 <code class="language-plaintext highlighter-rouge">byte[]</code>나 <code class="language-plaintext highlighter-rouge">String</code>으로 직렬화하여 담는 방법입니다.</p>

<h3 id="mappingjackson2httpmessageconverter-커스텀">MappingJackson2HttpMessageConverter 커스텀</h3>

<p>MappingJackson2HttpMessageConverter를 상속받아 getContentLength()를 Override하는 커스텀 컨버터를 만들고,
이를 RestClient에 등록된 MappingJackson2HttpMessageConverter와 교체하는 방법입니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 커스텀 컨버터</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">FixedLengthJsonMessageConverter</span> <span class="kd">extends</span> <span class="nc">MappingJackson2HttpMessageConverter</span> <span class="o">{</span>

    <span class="nd">@Override</span>
    <span class="kd">protected</span> <span class="nc">Long</span> <span class="nf">getContentLength</span><span class="o">(</span><span class="nc">Object</span> <span class="n">object</span><span class="o">,</span> <span class="nc">MediaType</span> <span class="n">contentType</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nf">calculateSize</span><span class="o">(</span><span class="n">object</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">long</span> <span class="nf">calculateSize</span><span class="o">(</span><span class="nc">Object</span> <span class="n">value</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span> <span class="o">{</span>
        <span class="nc">CountingOutputStream</span> <span class="n">countingStream</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">CountingOutputStream</span><span class="o">(</span><span class="nc">NullOutputStream</span><span class="o">.</span><span class="na">INSTANCE</span><span class="o">);</span>
        <span class="k">try</span> <span class="o">(</span><span class="nc">JsonGenerator</span> <span class="n">generator</span> <span class="o">=</span> <span class="n">getObjectMapper</span><span class="o">().</span><span class="na">getFactory</span><span class="o">().</span><span class="na">createGenerator</span><span class="o">(</span><span class="n">countingStream</span><span class="o">))</span> <span class="o">{</span>
            <span class="n">getObjectMapper</span><span class="o">().</span><span class="na">writeValue</span><span class="o">(</span><span class="n">generator</span><span class="o">,</span> <span class="n">value</span><span class="o">);</span>
        <span class="o">}</span>
        <span class="k">return</span> <span class="n">countingStream</span><span class="o">.</span><span class="na">getCount</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>

<span class="c1">// RestClient에 등록</span>
<span class="kd">public</span> <span class="nc">RestClient</span> <span class="nf">noChunkedClient</span><span class="o">()</span> <span class="o">{</span>
    <span class="k">return</span> <span class="nc">RestClient</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
        <span class="o">.</span><span class="na">messageConverters</span><span class="o">(</span><span class="n">converters</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="n">converters</span><span class="o">.</span><span class="na">removeIf</span><span class="o">(</span><span class="n">converter</span> <span class="o">-&gt;</span> <span class="n">converter</span> <span class="k">instanceof</span> <span class="nc">MappingJackson2HttpMessageConverter</span><span class="o">);</span>
            <span class="n">converters</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="k">new</span> <span class="nc">FixedLengthJsonMessageConverter</span><span class="o">());</span>
        <span class="o">}).</span><span class="na">build</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="bufferingclienthttprequestfactory-사용">BufferingClientHttpRequestFactory 사용</h3>

<p>Release Note에서는 버퍼링이 필요한 경우 이 팩토리를 사용하도록 제안했습니다.
하지만 이 방식은 모든 요청/응답 스트림을 메모리에서 버퍼링하기 때문에, 상황에 따라 메모리 사용량이 크게 증가할 수 있습니다.</p>

<p>따라서 이 방식을 적용하기 전에는 충분한 검토가 필요합니다.
단순히 청크 전송을 피하기 위해 요청/응답 버퍼를 사용하는 것은 메모리 관리 측면에서 우려되는 부분이 있습니다.</p>

<h2 id="결론">결론</h2>

<p>외부 시스템과의 통신을 디버깅하는 과정에서 너무 코드 레벨에만 집중하여 많은 시간을 소모했던 것 같습니다.
이를 통해 문제 해결 시 코드에만 국한되지 않고 더 넓은 관점에서 접근해야 해야겠다는 생각을 하게 되었습니다.
또한 API를 호출하는 과정에서 제가 직접 제어하는 영역은 정말 좁다는 것을 느꼈고,
내부 동작 원리에 대한 깊은 이해가 신속한 문제 해결의 핵심이 된다는 것을 알게 되었습니다.</p>

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li><a href="https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-6.1-Release-Notes#web-applications">Spring Framework 6.1 Release Notes</a></li>
  <li><a href="https://tools.ietf.org/html/rfc7230#section-4.1">HTTP/1.1 Specification-Chunked Transfer Encoding</a></li>
  <li><a href="https://docs.spring.io/spring-framework/reference/integration/rest-clients.html">Spring RestClient Documentation</a></li>
</ul>]]></content><author><name>nilgil</name><email>nilgil183@gmail.com</email></author><category term="Tech" /><category term="Spring" /><summary type="html"><![CDATA[Spring Framework 6.1에서 RestClient/RestTemplate의 요청 본문 전송 방식이 변경되었습니다. 데이터 타입에 따른 전송 방식 차이와 Chunked Transfer Encoding 관련 문제 해결 방법을 알아봅니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nilgil.com/assets/img/blog/common/spring.png" /><media:content medium="image" url="https://blog.nilgil.com/assets/img/blog/common/spring.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">2024년도 개발자로서의 회고</title><link href="https://blog.nilgil.com/posts/review-of-2024-as-a-developer/" rel="alternate" type="text/html" title="2024년도 개발자로서의 회고" /><published>2025-01-07T00:00:00+00:00</published><updated>2026-09-09T20:01:31+00:00</updated><id>https://blog.nilgil.com/posts/review-of-2024-as-a-developer</id><content type="html" xml:base="https://blog.nilgil.com/posts/review-of-2024-as-a-developer/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#회사에서-2회-연속-개인-수상--" id="markdown-toc-회사에서-2회-연속-개인-수상--">회사에서 2회 연속 개인 수상  🎉🎉</a></li>
  <li><a href="#주요-고객사-프로젝트" id="markdown-toc-주요-고객사-프로젝트">주요 고객사 프로젝트</a></li>
  <li><a href="#우물-안-개구리의-현실-직시" id="markdown-toc-우물-안-개구리의-현실-직시">우물 안 개구리의 현실 직시</a></li>
  <li><a href="#내가-변화하고-있는가" id="markdown-toc-내가-변화하고-있는가">내가 변화하고 있는가?</a>    <ul>
      <li><a href="#진정하자" id="markdown-toc-진정하자">진정하자</a></li>
      <li><a href="#루틴-루틴-루틴" id="markdown-toc-루틴-루틴-루틴">루틴, 루틴, 루틴!</a></li>
      <li><a href="#발표-스터디-개설" id="markdown-toc-발표-스터디-개설">발표 스터디 개설</a></li>
    </ul>
  </li>
  <li><a href="#2025년-목표" id="markdown-toc-2025년-목표">2025년 목표</a>    <ul>
      <li><a href="#전반적인-밸류-업" id="markdown-toc-전반적인-밸류-업">전반적인 밸류 업</a></li>
      <li><a href="#건강" id="markdown-toc-건강">건강</a></li>
    </ul>
  </li>
</ul>

<p>올해는 개인적으로 많은 변화가 있던 해였다. 나의 부족함을 깨달았고, 그것들을 개선하기 위해 다양한 시도들을 했다. 
그 중 회고를 습관화하려는 시도가 현재 진행 중이기에, 다들 하는 연간 회고 나도 한 번 해보자.</p>

<hr />
<h2 id="회사에서-2회-연속-개인-수상--">회사에서 2회 연속 개인 수상  🎉🎉</h2>
<p>일단 칭찬부터 하자. 우리 회사에서는 반기마다 실적을 리뷰하고, 앞으로의 방향성을 공표하기 위해 킥오프 행사를 진행한다. 
전사 킥오프가 진행되고 이후 부문 단위 킥오프를 진행하는데, 이 부문 킥오프 때 우수 팀 또는 개인에게 표창하는 행사도 함께 진행한다. 
개인 수상은 조직 중 한 명에게만 주어진다. 참고로 상반기 개인 수상은 주니어에게 주어지는 <code class="language-plaintext highlighter-rouge">루키</code>, 하반기 개인 수상은 <code class="language-plaintext highlighter-rouge">우수사원</code> 표창이었다.</p>

<p class="lead">2024년도 개발 조직의 개인 수상은 상,하반기 모두 내가 받게 되었다.</p>

<p><img src="/assets/img/blog/20250107/award.png" alt="" style="width:51.5%" />
<img src="/assets/img/blog/20250107/certificate.png" alt="" style="width:44.5%" /></p>

<p>개발 조직은 총 20명도 되지 않는 회사 내 작은 조직이다. 그래도 그중에서 단 한 명만 선정하는 개인 수상을 2회 연속 받게 된 것은 의미가 적지 않다고 생각한다.</p>

<p>그런데 왜 내가 받았을까? 앞으로의 방향성을 위해 고민해 볼 필요가 있는 것 같다.</p>

<p>먼저 상반기 <code class="language-plaintext highlighter-rouge">루키</code> 선정은 수행했던 일의 종류도 많았고, 그 중 개발 조직의 개발 생산성과 인프라 안정성에 도움 되는 일들이 많았기 때문이라 생각한다. 
이건 내게 주어진 일들이 다른 주니어들에 비해 <code class="language-plaintext highlighter-rouge">성과</code>가 눈에 띄는 업무들이었기에 가능했던 것 같다. 운이 좋았다.</p>

<p>그러나 하반기 <code class="language-plaintext highlighter-rouge">우수사원</code> 선정은 상반기와 이유가 다르다고 생각한다. 나름 작지 않은 프로젝트에 백엔드 개발자로 후임 1명과 함께 투입되어 
기능 개발, 오픈소스 커스텀, 인프라 구성 등 다양한 부분에서 리딩하며 기한 내에 문제없이 잘 마무리하였고, 
담당자와의 약속을 어떻게든 지키기 위해 <code class="language-plaintext highlighter-rouge">책임감</code>있게 밤늦게까지 일을 했던 기록들이 이유가 되었던 것 같다.</p>

<p>앞으로도 <code class="language-plaintext highlighter-rouge">성과</code>와 <code class="language-plaintext highlighter-rouge">책임감</code> 두 단어를 베이스로 삼아, 어디서든 꼭 <code class="language-plaintext highlighter-rouge">필요로 하는 개발자</code>가 되는 것을 목표로 해야겠다.</p>

<hr />
<h2 id="주요-고객사-프로젝트">주요 고객사 프로젝트</h2>
<p>백엔드 개발자는 나와 후임 1명이 투입되었기에 대부분을 내가 리딩하여 모르는 것들은 싹 다 뒤져가면서 하나씩 해치워갔다. 
결과적으로는 잘 마무리되었지만 프로젝트 진행 중 정말 많은 기술적 문제에 부딪혔다. 
개인적인 아쉬움으로 각각 기술에 대해 미리 깊게 공부를 하고 시작을 했으면 시간이 훨씬 절약되었을 것 같다는 생각이 들었다. 
일단 개발하고 부딪히며 찾아보는 스타일인데, 정말 많이 헤매고 오래 걸렸다. 
이런 경험을 겪으며 업무 전 미리 파악하고, 습득하고, 설계를 하는 것이 중요하다고 느꼈다. 올해에는 이런 것들을 채워나가는 한 해로 만들어야겠다.</p>

<p>이 프로젝트는 정말 책임감으로 해치운 것 같다. 부족함이 많았지만 시간을 갈아서라도 제시간에 마무리하려는 책임감으로 프로젝트를 잘 끝냈다. 
당장 능력이 부족해도 책임감으로 시간을 갈아 넣는다면 그 능력이 채워지는 것도 같다. 
앞으로도 내 일에 대해 책임감만 놓지 않는다면 어떤 일이든 다 해낼 수 있을 거라는 자신감도 생겼다. 
그러나 더 좋은 개발자가 되려면 더 효율적으로 시간을 써야 한다. 미리 설계를 잘하고 업무를 시작하는 것을 연습하자.</p>

<hr />
<h2 id="우물-안-개구리의-현실-직시">우물 안 개구리의 현실 직시</h2>
<p><img src="/assets/img/blog/20250107/frog.svg" alt="" />
대부분의 날들을 퇴근 후 개발 관련 강의를 들으며 공부했다. 밤 새가며 사이드 프로젝트를 개발한 날도 적지 않다. <br />
나는 아주 많이는 아니지만 적당히 열심히 살아왔다고 생각한다.</p>

<p>회사에서도 어느 정도 인정을 받은 것 같다. 상도 두어번 받았고, 이제 새로운 프로젝트를 맡아 어느 정도 리딩도 하고 있다.<br />
또 부트캠프에서나 사이드 프로젝트에서도 개발 실력으로 꿀린 적은 없었다. 나는 내 실력에 적지 않은 자신감이 있었다.</p>

<blockquote>
  <p>그렇게 나는 내가 실력 있는 개발자라고 생각해 왔다.</p>
</blockquote>

<p>이러한 믿음으로 나는 더 넓은 세상으로의 도전을 하고자 멈춰있던 발을 약간 떼보았다.<br />
그렇게 나는 벽에 부딪혔고, 지난 3년간의 나에 대해 돌아보기 시작했다.</p>

<p>나에 대한 객관화를 잘 못했던 것 같다.</p>

<blockquote>
  <p>세상은 넓고, 내가 못 본척하던 내 단점들은 외부 세계에서 정말 취약하다.</p>
</blockquote>

<p>지금까지의 방식으로 시간을 더 들여 열심히 한다고 해서 내가 실력 있는 개발자가 될 수 있을까?  <br />
아니다. 나 자체가 변화하지 않으면 내가 이루고자 하는 목표에 도달할 수 없다는 결론을 내렸다.</p>

<p>그렇기에 나는 내 단점들을 보완하고, 객관적으로 실력 있는 사람이 되기 위한 시작을 해야 한다.</p>

<hr />
<h2 id="내가-변화하고-있는가">내가 변화하고 있는가?</h2>
<p><img src="/assets/img/blog/20250107/in-the-jungle.webp" alt="" />
분명 이전과는 다르다. 이전에는 저수지에 떠있는 오래된 배처럼 느껴졌다면, 지금은 미로 같은 정글에서 길을 헤매는 뗏목 같은 느낌이다. 
아직 정확한 방향을 잡지 못하고 이리저리 떠내려가는 기분이지만, 어딘가로 가고 있다는 생각은 든다. 
그런데 물이 흘러봤자 결국엔 같은 곳에서 모이지 않겠나?</p>

<h3 id="진정하자">진정하자</h3>
<p>초반에는 마음이 급해 스트레스가 참 많았다. 그러나 다시 한번 생각해 보니 이게 금방 될 거면 누구나 성공하겠지 싶더라. 
내가 레퍼런스 삼고자 하는 실력 있는 개발자들이 들였을 수많은 시간들을 미처 생각 못 하고 당장의 성과가 없어 마음을 졸였던 것 같다. 
방향을 잘 잡고 꾸준히 정량의 시간을 들이는 것에 목표를 두면 성과가 자연스럽게 스며올 것이라는 생각으로 고친 뒤 마음의 안정을 찾은 요즘이다.</p>

<h3 id="루틴-루틴-루틴">루틴, 루틴, 루틴!</h3>
<p>안 하던 것들을 하려다 보니 참 어려웠다. 분명히 오래는 앉아있었는데 남는 것이 없는? 안타까운 나날들이 이어졌다. 퇴근 후 시간은 한정적이라 마음이 찢어진다. 정말로. 
그래서 이를 해결하고자 루틴을 짜기 위해 다방면으로 알아보았고, 많은 사례들을 통해 나만의 루틴을 잡는데 거의 한 달을 다 보낸 것 같다. 
지금은 이것저것 주변에 보이는 것 다 엮어서 만든 뗏목 느낌이지만 어떻게든 물에 떠서 가긴 간다.</p>

<h3 id="발표-스터디-개설">발표 스터디 개설</h3>
<p>나는 이전부터 무언가 직책을 맡는 것을 정말 싫어했다. 음향을 공부하던 대학생 때 학회장 제의가 들어왔지만 결사코 못하겠다 답했다. 
그러나 개발 일을 하면 할수록 내 소프트 스킬에 아쉬움을 느끼는 경우가 적지 않았고, 언젠가는 개선해 보겠다는 마음으로 지내왔었다. 
이에 더불어 이전 내 단점 중 하나인 ‘깊게 알지 못한다’라는 단점도 함께 깨부수고자 
<a href="https://nilgil.notion.site/BPS-12cebff1f83f80a5adcfef6d65d1ba89">발표 스터디</a>를 직접 개설하였고, 현재 3주 주기로 지속 중이다.</p>

<hr />
<h2 id="2025년-목표">2025년 목표</h2>
<p><img src="/assets/img/blog/20250107/2025.svg" alt="" />
1년, 앞으로의 1년은 작년 상반기처럼 가만히만 보내고 싶지는 않다. 동적이고 싶다. 살아있음을 느끼는 한 해를 만들고 싶다.</p>

<h3 id="전반적인-밸류-업">전반적인 밸류 업</h3>
<p>나의 전반적인 가치를 끌어올리는 것이 올해의 가장 큰 목표다. 연차가 쌓여가며 개발 외적인 능력에 부족함을 느끼는 경우들이 점점 많아지고 있다. 
예를 들어 글쓰기, 발표, 일정관리와 같은 것들이 그러하다.</p>

<p>개발을 잘하는 것은 물론, 다방면에서 일을 잘하는 사람이 되는 것이 목표이다.</p>

<h3 id="건강">건강</h3>
<p>2024년 평일에는 대부분 수면시간이 4시간 밑이었다. 지금 생각해 보면 몸이 버틴게 용하긴 하다. 
당장 뭔가 몸에 이상이 온건 아니지만 점점 예전같지는 않다는 느낌이 들기 시작한다. 일론 머스크도 하루에 6시간은 잔다고 하더라. 
시간을 효율적으로 써서 해결하려하지 않고 잠을 줄이는 방식을 우선으로 해왔던게 지금와서 생각해보면 참 바보 같기도 하다.</p>

<p>잠은 6시간 자면서, 회사에서 많은 것들을 배우고, 퇴근 후 시간을 효율적으로 관리하여<br />
4시간 잘 때보다 더 많은 시간을 성장에 투자하는 것이 올해 가장 큰 목표이다.</p>]]></content><author><name>nilgil</name><email>nilgil183@gmail.com</email></author><category term="Review" /><category term="IT" /><summary type="html"><![CDATA[2024년 개발자로서의 성과와 한계를 돌아보고, 실력 있는 개발자가 되기 위해 필요한 근본적인 변화와 2025년 목표를 담은 회고록]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nilgil.com/assets/img/blog/20250107/award.png" /><media:content medium="image" url="https://blog.nilgil.com/assets/img/blog/20250107/award.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">톰캣은 어떻게 트래픽을 인지하고 처리하는 걸까?</title><link href="https://blog.nilgil.com/posts/how-does-tomcat-recognize-and-handle-traffic/" rel="alternate" type="text/html" title="톰캣은 어떻게 트래픽을 인지하고 처리하는 걸까?" /><published>2024-12-01T00:00:00+00:00</published><updated>2026-09-09T20:01:31+00:00</updated><id>https://blog.nilgil.com/posts/how-does-tomcat-recognize-and-handle-traffic</id><content type="html" xml:base="https://blog.nilgil.com/posts/how-does-tomcat-recognize-and-handle-traffic/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#connector" id="markdown-toc-connector">Connector</a></li>
  <li><a href="#endpoint" id="markdown-toc-endpoint">Endpoint</a></li>
  <li><a href="#serversocket-생성" id="markdown-toc-serversocket-생성">ServerSocket 생성</a></li>
  <li><a href="#listening-socket" id="markdown-toc-listening-socket">Listening Socket</a></li>
  <li><a href="#backlog-queue" id="markdown-toc-backlog-queue">Backlog Queue</a></li>
  <li><a href="#acceptor" id="markdown-toc-acceptor">Acceptor</a></li>
  <li><a href="#poller" id="markdown-toc-poller">Poller</a></li>
  <li><a href="#executor" id="markdown-toc-executor">Executor</a></li>
  <li><a href="#느낀-점" id="markdown-toc-느낀-점">느낀 점</a></li>
</ul>

<p>스프링 부트를 사용할 때 별다른 설정을 하지 않으면 자동으로 톰캣을 WAS로 사용합니다.
그리고 톰캣은 특정 포트로 들어오는 요청을 받아서 서블릿 컨테이너에 넘겨 우리가 만든 비즈니스 로직이 수행되도록 합니다.
그동안 이러한 흐름을 당연하게 여겨왔고, 개발하는 데 문제는 없었습니다.
그러나 서블릿 컨테이너에 넘어오기 전 앞단에서 서버의 특정 포트로 들어오는 요청을 톰캣이 어떻게 인지하고 처리하는지에 대해 궁금증이 생겼고,
이를 알아보기 위해 톰캣의 소스 코드를 분석하기로 했습니다. <em>(Tomcat 10.1.31 버전 기준)</em></p>

<h2 id="connector">Connector</h2>

<p>톰캣에서는 앞단에서 요청을 수신하고, 파싱해서 서블릿 컨테이너에 전달하는 역할을 Connector라는 컴포넌트가 담당합니다.
톰캣에는 NIO Connector, NIO2 Connector, AJP Connector까지 총 3개의 Connector가 존재하지만,
오늘은 톰캣에서 기본값으로 사용되는 NIO Connector를 기준으로 알아보겠습니다.</p>

<p>NIO2 Connector는 대량의 비동기 처리가 필요한 경우, AJP Connector는 앞단에 Apache 서버를 사용하는 경우
최적화를 위해 검토해 볼 가치가 있으니 궁금한 경우 자세히 알아보시길 바랍니다.
참고로 NIO Connector의 처리량으로 충분한 경우가 대부분이라고 합니다.</p>

<p><img src="/assets/img/blog/20241201/img-1.png" alt="" class="lead" /></p>

<p class="figcaption">기본값이 NIO</p>

<p><img src="/assets/img/blog/20241201/img-2.png" alt="" class="lead" /></p>

<p class="figcaption">해당 프로토콜로 Connector 생성</p>

<p>만약 NIO2 Connector나 AJP Connector를 사용하고 싶다면 다음과 같이 프로토콜 값을 수정하여 적용할 수 있습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="nd">@Configuration</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">TomcatConfig</span> <span class="o">{</span>
    <span class="nd">@Bean</span>
    <span class="kd">public</span> <span class="nc">WebServerFactoryCustomizer</span><span class="o">&lt;</span><span class="nc">TomcatServletWebServerFactory</span><span class="o">&gt;</span> <span class="nf">tomcatCustomizer</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// [AJP Protocol]</span>
        <span class="c1">// return (tomcat) -&gt; tomcat.setProtocol("AJP/1.3");</span>
        <span class="c1">// return (tomcat) -&gt; tomcat.setProtocol("org.apache.coyote.ajp.AjpNioProtocol");</span>

        <span class="c1">// [NIO2 Connector]</span>
        <span class="k">return</span> <span class="o">(</span><span class="n">tomcat</span><span class="o">)</span> <span class="o">-&gt;</span> <span class="n">tomcat</span><span class="o">.</span><span class="na">setProtocol</span><span class="o">(</span><span class="s">"org.apache.coyote.http11.Http11Nio2Protocol"</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="endpoint">Endpoint</h2>

<p>Connector를 생성하는 과정을 쭉 따라가 보면 해당 프로토콜 값으로 ProtocolHandler를 생성합니다.
Connector 구현체는 하나지만 각 Connector 종류에 따라 다른 ProtocolHandler를 가지게 됩니다.
NIO Connector의 경우 위 기본값으로 사용된 <code class="language-plaintext highlighter-rouge">org.apache.coyote.http11.Http11NioProtocol</code> 인스턴스를 만들어 ProtocolHandler로 사용합니다.</p>

<p><img src="/assets/img/blog/20241201/img-3.png" alt="" class="lead" /></p>

<p class="figcaption">ProtocolHandler 인스턴스 생성</p>

<p>그런데 우리는 여기서 Http11NioProtocol의 생성자에서 생성하는 NioEndpoint 인스턴스에 주목해야 합니다.</p>

<p><img src="/assets/img/blog/20241201/img-4.png" alt="" class="lead" /></p>

<p class="figcaption">NioEndpoint 인스턴스 생성</p>

<p>이 NioEndpoint가 실제로 네트워크 수준의 소켓 통신(연결 수락, 데이터 읽기/쓰기)을 관리하기 때문입니다.</p>

<p>그리고 이어서 Connector 생성 이후의 흐름을 쭉 따라가 보면 Connector가 포함된 TomcatWebServer를 시작하는 과정에서 Connector와 NioEndpoint의 초기화 작업이 이뤄집니다.</p>

<h2 id="serversocket-생성">ServerSocket 생성</h2>

<p>NioEndpoint의 초기화 작업 중 initServerSocket()이라는 함수를 호출합니다.
이 함수는 내부적으로 JNI(Java Native Interface)를 사용하여 시스템 콜을 호출해
커널에 Socket을 생성하고, 해당 소켓을 서버 포트에 바인딩(Bind)시키고, Listen 상태로 만듭니다.</p>

<p><img src="/assets/img/blog/20241201/img-5.png" alt="" class="lead" /></p>

<p class="figcaption">JNI 호출하여 시스템 콜 socket 호출</p>

<p><img src="/assets/img/blog/20241201/img-6.png" alt="" class="lead" /></p>

<p><img src="/assets/img/blog/20241201/img-7.png" alt="" class="lead" /></p>

<p class="figcaption">JNI 호출하여 시스템 콜 bind, listen 호출</p>

<h2 id="listening-socket">Listening Socket</h2>

<p>이렇게 생성된 소켓은 Listening Socket이라고 불립니다.
데이터 I/O를 위한 일반적인 소켓과는 다르게 톰캣이 종료될 때까지 존재하며, 커널에서 새로운 연결을 수립하는 용도로 사용되는 소켓입니다.
즉, NioEndpoint는 초기화 시점에 Listening Socket을 커널에 생성하고 이를 ServerSocket 타입의 필드로 매핑합니다.</p>

<p><img src="/assets/img/blog/20241201/img-8.png" alt="" class="lead" /></p>

<h2 id="backlog-queue">Backlog Queue</h2>

<p>Listening Socket에는 클라이언트의 연결 요청을 관리하는 Backlog Queue가 존재합니다.
내부적으로 SYN Queue와 Accept Queue로 이루어져 있으며, 각각 커널의 3way-handshake 처리 단계에서 사용됩니다.</p>

<p><img src="/assets/img/blog/20241201/img-9.png" alt="" class="lead" /></p>

<p>결과적으로 커널이 3way-handshake를 처리하여 수립된 연결은 Accept Queue에 쌓이게 됩니다.</p>

<h2 id="acceptor">Acceptor</h2>

<p>톰캣에는 Acceptor라고 하는 데몬 스레드가 있습니다.
이 Acceptor는 톰캣 실행 시점에 생성되어 종료될 때까지 백그라운드에서
Accept Queue에 쌓인 3way-handshake가 완료된 연결을 수락합니다.</p>

<p><img src="/assets/img/blog/20241201/img-10.png" alt="" class="lead" /></p>

<p class="figcaption">serverSock.accept()를 타고 들어가보면 JNI인 accept를 호출</p>

<p><img src="/assets/img/blog/20241201/img-11.png" alt="" class="lead" /></p>

<p class="figcaption">JNI를 호출하여 시스템 콜 accept 호출</p>

<p>accept 시스템 콜을 호출하면 커널은 해당 연결이 앞으로 데이터를 주고받는 데 사용할 소켓을 생성합니다.
이후 해당 연결은 타임아웃으로 만료되기 전까지는 3way-handshake를 다시 수행하지 않고 해당 소켓을 통해 바로 데이터를 주고받게 됩니다.</p>

<p>accept를 호출했을 때 Accept Queue에 쌓인 연결 요청이 없는 경우에는 Blocking 상태가 됩니다.</p>

<p><img src="/assets/img/blog/20241201/img-12.png" alt="" class="lead" /></p>

<p class="figcaption">endpoint.serverSocketAccept()를 타고 들어가면 accept 시스템 콜을 호출</p>

<p>accept 콜 이후 Acceptor는 생성된 소켓의 등록 이벤트를 톰캣의 또 다른 데몬 스레드인 Poller에 추가한 뒤 한 번의 루프를 종료합니다.</p>

<p><img src="/assets/img/blog/20241201/img-13.png" alt="" class="lead" /></p>

<p class="figcaption">Acceptor 루프 흐름</p>

<h2 id="poller">Poller</h2>

<p>Poller도 Acceptor처럼 톰캣 실행 시점에 생성되어 종료될 때까지 백그라운드에서 동작하는 데몬 스레드입니다.
Poller는 루프를 돌며 Acceptor가 추가한 소켓 등록 이벤트를 읽어 새로 생성된 소켓을 Selector에 등록합니다.
여기서 Selector는 여러 소켓의 상태 변화를 관리하는 역할을 합니다.</p>

<p><img src="/assets/img/blog/20241201/img-14.png" alt="" class="lead" /></p>

<p class="figcaption">Acceptor가 추가한 소켓 등록 이벤트에서 SocketChannel 추출하여 Selector에 등록</p>

<p>또한 루프 내에서 Selector의 select()를 호출하여 상태 변화를 감지하는 시스템 콜을 호출합니다.
운영체제마다 Selector의 구현체가 다르며 리눅스는 epoll, 맥은 kqueue와 같은 시스템 콜을 사용합니다.
구현은 다르지만 결과적으로 모두 소켓들의 상태 변화 이벤트를 감지하고 수집하는 기능을 수행합니다.</p>

<p><img src="/assets/img/blog/20241201/img-15.png" alt="" class="lead" /></p>

<p>Poller는 이렇게 수집된 이벤트를 Executor에게 넘기며 이후 처리를 위임합니다.
만약 소켓에 읽을 데이터가 존재한다는 이벤트가 발생했다면,
소켓으로부터 데이터를 읽는 작업과 파싱하는 작업들은 Executor가 수행하게 됩니다.</p>

<p><img src="/assets/img/blog/20241201/img-16.png" alt="" class="lead" /></p>

<h2 id="executor">Executor</h2>

<p>Executor는 우리가 자주 보던 <code class="language-plaintext highlighter-rouge">http-nio-8080-exec-1</code>과 같은 이름의 워커 스레드들입니다.
스레드 풀로 관리되며, 기본값으로 10개의 Executor가 작업을 수행하기 위해 대기하고 있습니다.</p>

<p>Executor는 소켓에서 데이터를 읽고, HTTP 요청을 파싱하는 등의 작업을 통해 HttpServletRequest를 생성하고,
이를 서블릿 컨테이너에 넘겨 비즈니스 로직을 수행합니다.
그리고 응답을 다시 소켓에 입력하는 것까지의 역할을 수행한 뒤 스레드 풀에 반환되어 다음 작업을 대기합니다.</p>

<p><img src="/assets/img/blog/20241201/img-17.png" alt="" class="lead" /></p>

<p class="figcaption">소켓 데이터 읽기</p>

<p><img src="/assets/img/blog/20241201/img-18.png" alt="" class="lead" /></p>

<p class="figcaption">헤더 파싱</p>

<p><img src="/assets/img/blog/20241201/img-19.png" alt="" class="lead" /></p>

<p class="figcaption">서블릿 컨테이너에 전달</p>

<h2 id="느낀-점">느낀 점</h2>

<p>처음 생각했던 것보다 더 로우 레벨로 파고들다 보니 몰랐던 것들을 굉장히 많이 접하게 된 공부였습니다.
현 글에서는 주제에서 벗어나지 않기 위해 많은 것들을 생략했지만,
‘커널의 3way-handshake 처리 흐름’, ‘톰캣의 최적화 포인트’, ‘톰캣의 확장성을 위한 아키텍처 구조’ 등
이후 많은 도움이 될 지식들이 덩굴째 따라왔습니다.
이것들도 따로 포스팅을 통해 완전히 내 것으로 만드는 작업을 잊지 말아야겠습니다.</p>]]></content><author><name>nilgil</name><email>nilgil183@gmail.com</email></author><category term="Tech" /><category term="Server" /><summary type="html"><![CDATA[톰캣의 NIO Connector가 어떻게 트래픽을 처리하는지 소스 코드 레벨에서 상세히 분석합니다. Acceptor, Poller, Executor 등 핵심 컴포넌트들의 동작 원리와 소켓 통신 과정을 심층적으로 살펴봅니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nilgil.com/assets/img/blog/common/tomcat.png" /><media:content medium="image" url="https://blog.nilgil.com/assets/img/blog/common/tomcat.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">잔나비 소곡집 I 작업기</title><link href="https://blog.nilgil.com/posts/work-for-jannabi-little-record-1/" rel="alternate" type="text/html" title="잔나비 소곡집 I 작업기" /><published>2024-07-30T00:00:00+00:00</published><updated>2026-09-09T20:01:31+00:00</updated><id>https://blog.nilgil.com/posts/work-for-jannabi-little-record-1</id><content type="html" xml:base="https://blog.nilgil.com/posts/work-for-jannabi-little-record-1/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#주어진-기회" id="markdown-toc-주어진-기회">주어진 기회</a></li>
  <li><a href="#녹음-당일" id="markdown-toc-녹음-당일">녹음 당일</a></li>
  <li><a href="#나에게-주어지는-합격-목걸이" id="markdown-toc-나에게-주어지는-합격-목걸이">나에게 주어지는 합격 목걸이</a></li>
  <li><a href="#가을밤에-든-생각" id="markdown-toc-가을밤에-든-생각">가을밤에 든 생각</a></li>
  <li><a href="#진짜-아티스트" id="markdown-toc-진짜-아티스트">진짜 아티스트</a></li>
  <li><a href="#끝으로" id="markdown-toc-끝으로">끝으로</a></li>
</ul>

<p>제가 일하던 스튜디오에는 많은 단골 뮤지션들이 있었습니다. 오늘은 그 단골 뮤지션들 중 밴드 잔나비의 ‘소곡집 I’ 앨범 작업기를 풀어보려 합니다.</p>

<h2 id="주어진-기회">주어진 기회</h2>

<p>2020년 10월 새로운 스튜디오에서 일을 시작한 지 두 달 남짓 되었을 때 밴드 잔나비가 새로운 앨범을 준비한다며 찾아왔습니다.
그동안 정규 앨범들에서는 컨셉에 맞지 않아 내지 못했던 좋은 곡들을 소곡집으로 모아 발매하려는 것이었습니다.
이전에 일하던 스튜디오는 연륜이 있는 뮤지션들이 주 고객이라 제가 평소에 자주 듣는 음악들을 작업하는 경우가 많지는 않았습니다.
그렇기에 잔나비의 작업 소식은 굉장히 설레게 다가왔습니다.</p>

<p>그러나 이 시기는 이직한지 얼마 되지않아 스튜디오의 정책과 매뉴얼들을 익혀가는 시기였기에 아직 녹음을 많이 할 때는 아니었습니다.
그래서 고객들 중에서도 VIP였던 잔나비의 녹음은 조금 더 연차가 있는 엔지니어가 녹음을 받게 될 것이라 생각했었습니다.
그런데 저를 강하게 키우기 위함이었을까 갑자기 저에게 기회가 주어졌고, 보컬을 녹음 할 수 있는 기회가 오게 되었습니다.
사실 이전에 일하던 스튜디오와 바뀐 시스템에서 아직 능력을 발휘하지 못했던 때라 걱정도 많았지만, 기회가 주어졌기에 잘 해보겠다 답했습니다.</p>

<h2 id="녹음-당일">녹음 당일</h2>

<p><img src="/assets/img/blog/20240730/booth1.jpg" alt="" style="width:49%" />
<img src="/assets/img/blog/20240730/booth2.jpg" alt="" style="width:49%" /></p>

<p class="figcaption">보컬 녹음 부스</p>

<p>TV에서만 보던 잔나비의 보컬 최정훈님이 편한 츄리닝 복장으로 오셨고, 기타리스트 김도형님, 그리고 매니저님이 함께 오셨습니다.
날씨가 조금 추워지기 시작해 전기난로를 틀었었고, 특이하게 앉아서 신발을 벗고 노래하셔서 바닥에 담요를 깔았습니다.
그런데 개인적으로 최정훈님이 노래를 그렇게 잘하지는 않을 거라고 생각했었는데, 녹음을 해보면서 노래를 너무 잘해 깜짝 놀랐었습니다.
무슨 발성 이런걸 떠나서 음악에 착 달라붙는 디테일과 느낌이 너무 좋았습니다.</p>

<p>이 날 녹음한 노래는 3번 트랙 ‘그 밤 그 밤’ 이라는 노래입니다.
그런데 저에게는 이 녹음이 제 커리어 중 가장 아쉬운 날이었습니다.
김도형님이 제 옆에서 보컬 디렉팅(보컬이 곡을 더 잘 녹음을 할 수 있게 실시간으로 조언)을 하셨는데 평소와 다르게 중간중간 말을 제대로 알아듣지 못해 실수를 여러 번 했었습니다.
이날 잠을 거의 못자고 와서 많이 피곤한 상태였는데 그래서 그랬던 건지 너무 아쉬웠었습니다.</p>

<p>저는 그 당시에 잔나비의 음악을 자주 듣긴 했지만 팬이라고 할 정도는 아니었습니다.
그런데 최근 뮤직 페스티벌을 몇 번 다녀오고 잔나비에 아주 제대로 빠져들어 요즘은 잔나비 음악만 듣고있습니다. (아 이 날 친분좀 쌓을걸..)</p>

<p><img src="/assets/img/blog/20240730/jazz-festival.png" alt="" /></p>

<p class="figcaption">2024 서울 재즈 페스티벌</p>

<p>그래도 이 날 잔나비와 도시락 같이 먹은 사이라는 타이틀을 얻은 저는 성덕이라 할 수 있습니다.</p>

<h2 id="나에게-주어지는-합격-목걸이">나에게 주어지는 합격 목걸이</h2>

<p>그렇게 녹음을 마무리했고, 총 5곡의 보컬, 악기 녹음을 여러 엔지니어들이 함께 마무리하여 이제 후 작업만 남았습니다.
후 작업 중 가장 중요하다 할 수 있는 보컬 튠 작업, 즉 보컬 녹음본에서 음정, 박자, 끝음처리, 숨소리 등 노래를 최대한 완벽하게 수정하는 작업입니다.
이 작업도 사실 더 연차가 많은 엔지니어가 하게 될 거라 생각했는데, 일단 한번 해봐라 식으로 또 기회가 주어졌고, 컨펌을 받기 위해 밤을 새가며 튠 작업을 했었습니다.</p>

<p>이때 작업한 곡은 2번 트랙 ‘한걸음’ 이라는 노래입니다. 정말 저를 갈아서 열정적으로 작업을 했던 결과일까, 잔나비 측에서 아주 마음에 들어 했습니다. 수정사항도 단 한 개 밖에 없었습니다.</p>

<h2 id="가을밤에-든-생각">가을밤에 든 생각</h2>

<p>그렇게 잔나비 앨범의 첫 번째 튠 작업을 성공적으로 마무리하여 어느정도 실력을 인정 받은 저에게 타이틀 곡 ‘가을밤에 든 생각’의 튠 작업 기회가 주어졌습니다.
자신감이 붙은 저는 또 밤을 새 가며 잔나비의 음악에 저를 갈아 넣었고, 그 결과 또한 성공적이었습니다.
이번엔 수정사항이 여러 개 있었지만, 잘 수정하여 컨펌을 받아냈고, 끝내 다른 곡들의 튠 작업도 모두 맡게 되었습니다.</p>

<h2 id="진짜-아티스트">진짜 아티스트</h2>

<p>이어서 5번 트랙 ‘작전명 청-춘!’의 튠 작업 또한 잘 마무리하여 컨펌을 받아냈고, 4번 트랙 ‘늙은 개’ 라는 곡을 작업하게 되었습니다.
튠 작업을 마무리하고 컨펌을 받기위해 작업본을 전달했는데, 잔나비 측에서 원본의 날 것과 같은 느낌을 살리고 싶다 하여 제가 작업한 것을 아예 쓰지 않게 되었습니다.
그러나 저는 아쉽기보다 잔나비가 아티스트로서 음악을 어떻게 바라보는지에 대해 존경심을 느꼈습니다.</p>

<h2 id="끝으로">끝으로</h2>

<p><img src="/assets/img/blog/20240730/result1.png" alt="" style="width:49%" />
<img src="/assets/img/blog/20240730/result2.png" alt="" style="width:49%" /></p>

<p class="figcaption">발매 당시 임느님 위에…</p>

<p>이로서 잔나비 ‘소곡집 1’ 앨범에서 4번 트랙 ‘늙은 개’를 제외한 모든 곡에 관여를 하였고, 2020년 11월 6일 앨범이 발매되었습니다.
지금와서 생각해보면 음향 엔지니어로 지낸 모든 시간 중 밤새가며 ‘가을밤에 든 생각’ 곡을 작업했던 시간들이 가장 기억에 남습니다.
많이 힘들기도 했고 보람차기도 했기 때문인 것 같습니다. 그 당시에는 하루하루 정말 힘든 시간들이었지만, 지금은 추억이 된 잔나비의 소곡집 앨범..
가끔 그 곡들이 들릴 때, 열정에 밤을 새웠던 그때가 떠오릅니다.</p>]]></content><author><name>nilgil</name><email>nilgil183@gmail.com</email></author><category term="Review" /><category term="Audio-Production" /><summary type="html"><![CDATA[음향 엔지니어 시절, 잔나비의 '소곡집 I' 앨범 작업에 참여했던 특별한 경험담. 보컬 녹음부터 튜닝까지, 밤새워가며 완성했던 앨범 제작 과정의 이야기를 담았습니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nilgil.com/assets/img/blog/20240730/record.png" /><media:content medium="image" url="https://blog.nilgil.com/assets/img/blog/20240730/record.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">음향 to IT</title><link href="https://blog.nilgil.com/posts/audio-production-to-it/" rel="alternate" type="text/html" title="음향 to IT" /><published>2024-07-20T00:00:00+00:00</published><updated>2026-09-09T20:01:31+00:00</updated><id>https://blog.nilgil.com/posts/audio-production-to-it</id><content type="html" xml:base="https://blog.nilgil.com/posts/audio-production-to-it/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#현직-백엔드-개발자" id="markdown-toc-현직-백엔드-개발자">현직 백엔드 개발자</a></li>
  <li><a href="#전직-레코딩-엔지니어" id="markdown-toc-전직-레코딩-엔지니어">전직 레코딩 엔지니어</a></li>
  <li><a href="#음향-to-it" id="markdown-toc-음향-to-it">음향 to IT</a></li>
</ul>

<p>블로그에 어떤 글을 써볼까 고민하던 중 제가 IT 업계에 발 들이기 전 어떤 사람이었는지 보여주고 싶다는 생각이 들었습니다. 
어디서 어떤 일을 했고, 어떤 계기로 개발자의 길을 선택하게 되었는지, 그리고 3년 차 개발자가 된 지금, 이 선택에 대해 어떤 생각을 하고 있는지 짧게나마 공유드립니다.</p>

<h2 id="현직-백엔드-개발자">현직 백엔드 개발자</h2>

<p>저는 스물여덟 늦은 나이에 IT 업계에 뛰어들어 허겁지겁 입문 지식들을 구겨 담아 모 중견기업에 취업하였고, 현재 3년차 백엔드 개발자로 근무 중입니다.</p>

<p><img src="/assets/img/blog/20240720/side-project.jpg" alt="" class="lead" /></p>

<p class="figcaption">사이드 프로젝트 개발자 정모 현장</p>

<p>그런데, 그렇다고 이때까지 놀기만 했던 건 아닙니다.</p>

<h2 id="전직-레코딩-엔지니어">전직 레코딩 엔지니어</h2>

<p>저는 음향 업계에 몸담고 있었습니다. 그중에서도 레코딩 엔지니어!<br />
레코딩 엔지니어는 스튜디오에서 뮤지션들의 음악을 녹음하여 음반을 만드는 일을 합니다.</p>

<p><img src="/assets/img/blog/20240720/vibe1.jpg" alt="" style="width:48%" />
<img src="/assets/img/blog/20240720/vibe2.jpg" alt="" style="width:48%" /></p>

<p class="figcaption">바이브 스튜디오</p>

<p>레코딩 엔지니어는 일을 할 수 있는 스튜디오가 많지 않고, 스튜디오마다 한, 두 명씩만 직원을 고용하기에 음향 업계 내에서도 취업하기 힘든 직업으로 손꼽힙니다.
정말 이름만 대도 어디의 누군지 알 정도로 업계가 좁습니다.</p>

<p><img src="/assets/img/blog/20240720/tone4.jpeg" alt="" style="width:41.2%" />
<img src="/assets/img/blog/20240720/tone1.jpg" alt="" style="width:54.8%" /></p>

<p class="figcaption">톤 스튜디오 서울/제주</p>

<p>저는 그중에서도 많은 사람들이 일하고 싶어하는 업계 탑급 스튜디오에서 메이저 가수들과 작업을 해왔습니다.</p>

<p><img src="/assets/img/blog/20240720/record1.jpg" alt="" style="width:55%" />
<img src="/assets/img/blog/20240720/record2.jpg" alt="" style="width:41%" /></p>

<p class="figcaption">이선희, 이은미, 잔나비, 권진아, 노을, 조장혁, 바비킴, 여자친구, 박원, 콜드, 매드크라운, 루나, 스텔라장, 임한별, 적재, 최유리, 김고은 등…</p>

<p>저는 저의 길에서 나쁘지 않은 성과들을 얻어왔었다고 생각합니다. 앞으로 꾸준히 했으면 잘 됐을지 혹시 모릅니다.</p>

<h2 id="음향-to-it">음향 to IT</h2>

<p>그러나 업계가 너무 불안정하고, 대부분 정규직이 아닌 프리랜서로 고용하는 현실에 저는 이 업계를 떠나기로 결정했습니다. 제 모든 선택에 후회는 없습니다. 
음향에 몸 담았던 모든 시간들이 좋은 추억이자 경험들이라 생각하고, 새로운 직업으로 개발자를 택한 것도 정말 잘 한 선택인 것 같습니다. 
아예 관계가 없던 업계라 공부해야 할 게 아직 산더미지만, 3년차인 지금도 개발이 정말 재밌게 느껴져 앞으로도 희망적입니다.</p>

<p>비록 비전공자로서 시작했지만, 항상 ‘나는 다르다’ 라는 마인드로 임하고 있습니다.
앞으로 어떤 미래가 펼쳐질지는 모르겠지만, 전 항상 자신 있습니다.</p>

<p><img src="/assets/img/blog/20240720/win.jpg" alt="" class="centered" /></p>

<p class="figcaption">끝까지 가면 내가 다 이겨</p>

<p>너무 제 포부를 밝히는 글이 된 것 같은데, 결론은 블로그에 음향 업계에 있을 당시 해왔던 작업들을 회고하는 글들을 아주 간간히 남겨보려고 합니다.</p>

<p>블로그에 그냥 IT 관련 글만 있으면 조금 아쉬우니까요!</p>]]></content><author><name>nilgil</name><email>nilgil183@gmail.com</email></author><category term="Review" /><category term="Audio-Production" /><summary type="html"><![CDATA[메이저 가수들과 작업하던 레코딩 엔지니어가 IT 업계로 전향한 이야기. 음향 업계의 현실과 새로운 도전을 결심하게 된 계기, 그리고 개발자로서의 포부를 담았습니다.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.nilgil.com/assets/img/blog/20240720/studio.jpg" /><media:content medium="image" url="https://blog.nilgil.com/assets/img/blog/20240720/studio.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>