<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[RSS Feed of yunhobb 블로그]]></title><description><![CDATA[녹차 주도 개발]]></description><link>https://yunhobb.github.io/tech-blog</link><generator>GatsbyJS</generator><lastBuildDate>Mon, 01 Jun 2026 14:52:02 GMT</lastBuildDate><item><title><![CDATA[Transactional Outbox 오픈소스 기여 기록 — CGLIB 프록시가 만든 핸들러 ID 불안정성]]></title><description><![CDATA[…]]></description><link>https://yunhobb.github.io/tech-blog/transactional-outbox-opensource-contribution/</link><guid isPermaLink="false">https://yunhobb.github.io/tech-blog/transactional-outbox-opensource-contribution/</guid><pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;숙박과 장기 임대 도메인에서 개발을 하다 보면 거주자와 숙박 이용자에게 알림을 보낼 일이 자주 있습니다. 그중에서도 &lt;strong&gt;계약 관련 알림&lt;/strong&gt;은 일부 누락되어도 되는 정도가 아니라 &lt;strong&gt;반드시 고객에게 도달해야 하는&lt;/strong&gt; 알림입니다. 계약 갱신·만료 같은 통지가 누락되면 분쟁의 소지가 됩니다.&lt;/p&gt;
&lt;p&gt;그런데 &quot;DB에 계약 상태를 저장하는 것&quot;과 &quot;고객에게 알림을 발행하는 것&quot;은 서로 다른 시스템입니다. 이 둘을 어떻게 원자적으로 묶어 알림을 &apos;반드시&apos; 내보낼 것인가 — 이 문제의 해법으로 자주 언급되는 것이 &lt;strong&gt;Transactional Outbox&lt;/strong&gt; 패턴입니다.&lt;/p&gt;
&lt;p&gt;저는 학습 대상으로 &lt;a href=&quot;https://github.com/namastack/namastack-outbox&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;namastack-outbox&lt;/code&gt;&lt;/a&gt;를 골랐습니다. &lt;strong&gt;Spring Modulith가 2.1부터 outbox 기반 이벤트 외부화(externalization) 백엔드로 공식 채택한&lt;/strong&gt; 라이브러리입니다. (&lt;a href=&quot;https://spring.io/blog/2026/02/19/spring-modulith-2-1-m2-2-0-3-and-1-4-8-released/&quot;&gt;Spring Modulith 2.1 M2 릴리스 노트&lt;/a&gt;에 &lt;code class=&quot;language-text&quot;&gt;spring-modulith-starter-namastack&lt;/code&gt;로 소개됩니다.) 모듈러 모놀리스에서 outbox 전용 코드 없이 트랜잭션 이벤트 보장을 얻을 수 있다는 점 때문에 내부 구현을 직접 확인하고 싶었습니다. (컬리 기술블로그에서도 언급이 있었습니다.)&lt;/p&gt;
&lt;p&gt;코드를 읽다가 버그 하나를 발견하고 두 개의 PR을 머지하게 된 기록입니다.&lt;/p&gt;
&lt;h2 id=&quot;1-배경-Transactional-Outbox는-왜-필요할까&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#1-%EB%B0%B0%EA%B2%BD-Transactional-Outbox%EB%8A%94-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%A0%EA%B9%8C&quot; aria-label=&quot;1 배경 Transactional Outbox는 왜 필요할까 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;1. 배경: Transactional Outbox는 왜 필요할까&lt;/h2&gt;
&lt;p&gt;먼저 패턴을 정리합니다. 흔한 시나리오는 다음과 같습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;1. 계약 상태를 DB에 저장한다 (트랜잭션 커밋)
2. &quot;계약 갱신 안내&quot; 알림을 고객에게 발행한다 (메시지 브로커 등)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;문제는 이 둘이 &lt;strong&gt;서로 다른 시스템&lt;/strong&gt;이라는 점입니다. 하나의 비즈니스 동작 안에서 DB와 메시지 브로커, 두 곳에 각각 써야 하는데 &lt;a href=&quot;https://microservices.io/patterns/data/transactional-outbox.html&quot;&gt;microservices.io&lt;/a&gt;는 이를 &lt;strong&gt;이중 쓰기 문제(dual write problem)&lt;/strong&gt; 라고 부릅니다. 1번 커밋 직후 애플리케이션이 죽으면 2번이 실행되지 않아 DB엔 계약 변경이 남았는데 고객은 안내를 못 받고, 반대로 2번을 먼저 보내고 1번이 롤백되면 실제로는 바뀌지도 않은 계약에 대한 유령 알림이 나갑니다.&lt;/p&gt;
&lt;p&gt;둘을 하나의 분산 트랜잭션으로 묶는 방법은 현실적이지 않습니다. &lt;strong&gt;DB와 메시지 브로커를 아우르는 2PC(2단계 커밋)는 적절한 선택지가 아닙니다.&lt;/strong&gt; Kafka 같은 브로커가 XA 트랜잭션을 제대로 지원하지 않을뿐더러, 2PC 자체가 가용성을 떨어뜨립니다. 필요한 보장은 다음과 같습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;DB 트랜잭션이 커밋되면 메시지는 반드시 발행되고, 롤백되면 절대 발행되지 않는다.&lt;/strong&gt; (microservices.io의 표현으로는 &quot;메시지는 DB 트랜잭션이 커밋된 경우에 한해(if and only if) 발행된다.&quot;)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Transactional Outbox는 이 요구를, &lt;strong&gt;메시지 브로커를 트랜잭션 바깥으로 빼내&lt;/strong&gt; 충족합니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;비즈니스 데이터와 &lt;strong&gt;발행할 메시지(outbox 레코드)를 같은 로컬 트랜잭션 안에서&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;OUTBOX&lt;/code&gt; 테이블에 함께 저장한다. 둘 다 같은 DB이므로, 하나의 ACID 트랜잭션으로 원자적으로 커밋된다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;메시지 릴레이(Message Relay)&lt;/strong&gt; 라 불리는 별도 프로세스가 &lt;code class=&quot;language-text&quot;&gt;OUTBOX&lt;/code&gt; 테이블을 읽어, 저장된 레코드를 실제 브로커로 발행(혹은 핸들러 실행)한다.&lt;/li&gt;
&lt;li&gt;성공하면 레코드를 완료 처리하고, 실패하면 재시도 정책에 따라 다시 시도한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;핵심은 &lt;strong&gt;&quot;무엇을 할지&quot;를 일단 DB에 안전하게 적어두고, &quot;실행&quot;은 메시지 릴레이가 나중에 별도로 보장한다&lt;/strong&gt;는 것입니다. 메시지 릴레이를 구현하는 방식은 크게 두 가지입니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;동작&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Polling Publisher&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;릴레이가 &lt;code class=&quot;language-text&quot;&gt;OUTBOX&lt;/code&gt; 테이블을 주기적으로 폴링해 미처리 레코드를 발행&lt;/td&gt;
&lt;td&gt;구현이 단순. 폴링 주기만큼의 지연과 DB 부하&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Transaction Log Tailing (CDC)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;DB 트랜잭션 로그(binlog 등)를 추적해 outbox 삽입을 감지·발행&lt;/td&gt;
&lt;td&gt;지연이 적음. Debezium 같은 CDC 인프라 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;이번에 뜯어본 &lt;code class=&quot;language-text&quot;&gt;namastack-outbox&lt;/code&gt;는 이 중 &lt;strong&gt;Polling Publisher&lt;/strong&gt; 방식입니다.&lt;/p&gt;
&lt;p&gt;한 가지 주의할 점이 있습니다. 이 패턴이 보장하는 것은 &lt;strong&gt;at-least-once(최소 한 번)&lt;/strong&gt; 입니다. 릴레이가 메시지를 발행한 뒤 &quot;완료&quot; 처리 직전에 죽으면, 재시작 후 같은 레코드를 한 번 더 발행할 수 있습니다. 그래서 microservices.io도 &quot;메시지 릴레이가 메시지를 두 번 이상 발행할 수 있다&quot;고 명시하며, &lt;strong&gt;컨슈머를 멱등(idempotent)하게&lt;/strong&gt; 설계할 것을 전제로 둡니다.&lt;/p&gt;
&lt;p&gt;이번 글의 버그는 이 ID와 관련됩니다. outbox 레코드는 &quot;이 레코드를 처리할 핸들러가 누구인지&quot;를 &lt;strong&gt;문자열 ID&lt;/strong&gt;로 들고 있습니다. 폴링으로 꺼낸 레코드를 올바른 핸들러로 연결하려면 이 ID가 &lt;strong&gt;언제나 같은 값으로 안정적&lt;/strong&gt;이어야 합니다. 버그는 이 지점에 있었습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-문제-핸들러-ID가-프록시-이름을-쓰고-있다&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#2-%EB%AC%B8%EC%A0%9C-%ED%95%B8%EB%93%A4%EB%9F%AC-ID%EA%B0%80-%ED%94%84%EB%A1%9D%EC%8B%9C-%EC%9D%B4%EB%A6%84%EC%9D%84-%EC%93%B0%EA%B3%A0-%EC%9E%88%EB%8B%A4&quot; aria-label=&quot;2 문제 핸들러 ID가 프록시 이름을 쓰고 있다 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;2. 문제: 핸들러 ID가 프록시 이름을 쓰고 있다&lt;/h2&gt;
&lt;p&gt;&lt;code class=&quot;language-text&quot;&gt;namastack-outbox&lt;/code&gt;에서 핸들러 ID를 만드는 코드는 &lt;code class=&quot;language-text&quot;&gt;BaseHandlerMethod&lt;/code&gt;에 있었습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;kotlin&quot;&gt;&lt;pre class=&quot;language-kotlin&quot;&gt;&lt;code class=&quot;language-kotlin&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// io.namastack.outbox.handler.method.BaseHandlerMethod&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;protected&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;buildId&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; String &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; className &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; bean&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;java&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name   &lt;span class=&quot;token comment&quot;&gt;// ← 클래스명을 런타임 클래스에서 가져온다&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; methodName &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; method&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; paramTypes &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; method&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;parameterTypes&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;joinToString&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string-literal singleline&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;,&quot;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; it&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token string-literal singleline&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token expression&quot;&gt;className&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token expression&quot;&gt;methodName&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token expression&quot;&gt;paramTypes&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;)&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;ID는 &lt;code class=&quot;language-text&quot;&gt;클래스명#메서드명(파라미터타입)&lt;/code&gt; 형태입니다. 클래스명을 &lt;code class=&quot;language-text&quot;&gt;bean::class.java.name&lt;/code&gt;으로 가져오고 있었는데, 핸들러 빈에 &lt;code class=&quot;language-text&quot;&gt;@Transactional&lt;/code&gt;이 붙어 있으면 이 &lt;code class=&quot;language-text&quot;&gt;bean&lt;/code&gt;은 원본 객체가 아니라 CGLIB 프록시일 수 있다는 점이 문제였습니다.&lt;/p&gt;
&lt;p&gt;Spring은 &lt;code class=&quot;language-text&quot;&gt;@Transactional&lt;/code&gt; 같은 AOP가 적용된 빈을 &lt;strong&gt;프록시(Proxy)&lt;/strong&gt; 로 감쌉니다. 이 프록시를 만드는 방식은 두 가지입니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;언제 쓰이나&lt;/th&gt;
&lt;th&gt;&lt;code class=&quot;language-text&quot;&gt;bean::class.java.name&lt;/code&gt;이 돌려주는 값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JDK 동적 프록시(JDK Dynamic Proxy)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;빈이 인터페이스를 구현하고 &lt;code class=&quot;language-text&quot;&gt;proxyTargetClass=false&lt;/code&gt;일 때&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;jdk.proxy2.$Proxy123&lt;/code&gt; — 원본과 전혀 무관한 이름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CGLIB 프록시&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;인터페이스가 없거나 &lt;code class=&quot;language-text&quot;&gt;proxyTargetClass=true&lt;/code&gt;일 때&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;com.example.MyHandler$$SpringCGLIB$$0&lt;/code&gt; — 원본을 상속한 서브클래스&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;JDK 동적 프록시는 같은 인터페이스를 구현한 &lt;strong&gt;별도 객체&lt;/strong&gt;를, CGLIB는 원본 클래스를 &lt;strong&gt;상속한 서브클래스&lt;/strong&gt;를 런타임에 만들어 냅니다. Spring Boot는 2.0부터 &lt;code class=&quot;language-text&quot;&gt;proxyTargetClass=true&lt;/code&gt;가 기본값이라 대개 CGLIB이 쓰이지만, &lt;strong&gt;중요한 건 어느 쪽이든 &lt;code class=&quot;language-text&quot;&gt;bean::class.java.name&lt;/code&gt;이 원본 클래스명을 돌려주지 않는다는 점입니다.&lt;/strong&gt; CGLIB이면 원본 이름 뒤에 접미사가 붙고, JDK 프록시면 아예 &lt;code class=&quot;language-text&quot;&gt;com.sun.proxy.$Proxy123&lt;/code&gt;처럼 핸들러와 무관한 이름이 나옵니다.&lt;/p&gt;
&lt;p&gt;이 글의 핸들러 빈은 CGLIB로 감싸진 경우라, &lt;code class=&quot;language-text&quot;&gt;bean::class.java.name&lt;/code&gt;은 이런 값을 돌려주고 있었습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;com.example.MyHandler$$SpringCGLIB$$0&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;이게 왜 문제냐면, 이 ID는 &lt;strong&gt;DB에 그대로 저장&lt;/strong&gt;되어 outbox 레코드가 핸들러를 찾는 키로 쓰이기 때문입니다. 핸들러 ID가 안정적이지 않으면 다음과 같은 일이 벌어집니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code class=&quot;language-text&quot;&gt;@Transactional&lt;/code&gt;을 새로 붙이거나 떼면 → 프록시 적용 여부가 바뀌고 → 클래스명이 바뀌고 → &lt;strong&gt;ID가 바뀝니다.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;그러면 이미 DB에 쌓여 있던 outbox 레코드의 ID(&lt;code class=&quot;language-text&quot;&gt;...$$SpringCGLIB$$0#...&lt;/code&gt;)와 새로 등록된 핸들러의 ID가 어긋나, &lt;strong&gt;재시도 정책 조회(&lt;code class=&quot;language-text&quot;&gt;retryPolicyRegistry.getByHandlerId()&lt;/code&gt;)가 깨집니다.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code class=&quot;language-text&quot;&gt;$$SpringCGLIB$$0&lt;/code&gt;의 숫자 접미사는 프록시 생성 순서 등에 따라 달라질 수 있는 &lt;strong&gt;불안정한 식별자&lt;/strong&gt;입니다. 영속화되는 ID의 재료로 쓰기에는 적합하지 않습니다. 환경에 따라 가끔 발생하는 버그가 아니라, &lt;strong&gt;AOP 설정을 변경하는 순간 조용히 깨지는&lt;/strong&gt; 문제였습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-첫-번째-PR-237-진짜-클래스-이름을-사용하자&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#3-%EC%B2%AB-%EB%B2%88%EC%A7%B8-PR-237-%EC%A7%84%EC%A7%9C-%ED%81%B4%EB%9E%98%EC%8A%A4-%EC%9D%B4%EB%A6%84%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%9E%90&quot; aria-label=&quot;3 첫 번째 PR 237 진짜 클래스 이름을 사용하자 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;3. 첫 번째 PR (#237): 진짜 클래스 이름을 사용하자&lt;/h2&gt;
&lt;p&gt;이 라이브러리 안에는 이미 프록시를 벗겨내는 유틸이 있었습니다. Spring이 제공하는 &lt;code class=&quot;language-text&quot;&gt;AopProxyUtils.ultimateTargetClass()&lt;/code&gt;를 감싼 &lt;code class=&quot;language-text&quot;&gt;ReflectionUtils.getTargetClass()&lt;/code&gt;입니다. ID를 만들 때 이것을 쓰도록 한 줄만 바꾸면 됩니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;kotlin&quot;&gt;&lt;pre class=&quot;language-kotlin&quot;&gt;&lt;code class=&quot;language-kotlin&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// AS-IS&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; className &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; bean&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;java&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name

&lt;span class=&quot;token comment&quot;&gt;// TO-BE&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; className &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; ReflectionUtils&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getTargetClass&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;bean&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name  &lt;span class=&quot;token comment&quot;&gt;// ← 프록시 뒤의 실제 타겟 클래스&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;이렇게 하면 &lt;code class=&quot;language-text&quot;&gt;@Transactional&lt;/code&gt;이 붙든 안 붙든 핸들러 ID는 언제나 원본 클래스명(&lt;code class=&quot;language-text&quot;&gt;com.example.MyHandler#handle(java.lang.String)&lt;/code&gt;)으로 안정적으로 고정됩니다.&lt;/p&gt;
&lt;p&gt;테스트는 &quot;CGLIB 프록시로 감싼 빈과 원본 빈이 같은 ID를 만드는가&quot;를 검증하도록 작성했습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;kotlin&quot;&gt;&lt;pre class=&quot;language-kotlin&quot;&gt;&lt;code class=&quot;language-kotlin&quot;&gt;&lt;span class=&quot;token annotation builtin&quot;&gt;@Test&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;`handler ID uses target class name not CGLIB proxy class name`&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; proxiedBean &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;createCglibProxy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;targetBean&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; handlerFromTarget &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;TypedHandlerMethod&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;targetBean&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; method&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; handlerFromProxy &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;TypedHandlerMethod&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;proxiedBean&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; method&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;token function&quot;&gt;assertThat&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;handlerFromProxy&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;id&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;isEqualTo&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;handlerFromTarget&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;id&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token annotation builtin&quot;&gt;@Test&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;`handler ID contains original class name not proxy class name`&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; proxiedBean &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;createCglibProxy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;targetBean&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; handlerFromProxy &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;TypedHandlerMethod&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;proxiedBean&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; method&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;token function&quot;&gt;assertThat&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;handlerFromProxy&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;id&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;contains&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string-literal singleline&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;TestHandler&quot;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token function&quot;&gt;assertThat&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;handlerFromProxy&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;id&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;doesNotContain&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string-literal singleline&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;CGLIB&quot;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token function&quot;&gt;assertThat&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;handlerFromProxy&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;id&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;doesNotContain&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string-literal singleline&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;$$&quot;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;이슈(GH-238)를 먼저 등록하고, 본문 한 줄 + 테스트로 PR을 올렸습니다. 변경량은 적었지만 리뷰 과정에서 추가로 확인할 점이 있었습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-리뷰에서-날아온-지적-작업이-하위-호환을-깨는-변경이다&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#4-%EB%A6%AC%EB%B7%B0%EC%97%90%EC%84%9C-%EB%82%A0%EC%95%84%EC%98%A8-%EC%A7%80%EC%A0%81-%EC%9E%91%EC%97%85%EC%9D%B4-%ED%95%98%EC%9C%84-%ED%98%B8%ED%99%98%EC%9D%84-%EA%B9%A8%EB%8A%94-%EB%B3%80%EA%B2%BD%EC%9D%B4%EB%8B%A4&quot; aria-label=&quot;4 리뷰에서 날아온 지적 작업이 하위 호환을 깨는 변경이다 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;4. 리뷰에서 날아온 지적: 작업이 하위 호환을 깨는 변경이다&lt;/h2&gt;
&lt;p&gt;첫 PR(#237)이 머지된 직후, 메인테이너(&lt;code class=&quot;language-text&quot;&gt;@rolandbeisel&lt;/code&gt;)가 제가 놓치고 있던 문제를 짚었습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&quot;이건 사실 breaking change입니다. 이미 옛 ID(CGLIB 프록시 이름)를 참조하는 outbox 레코드가 DB에 남아 있는 상태에서 업데이트 후 애플리케이션을 재시작하면, 그 옛 ID로는 핸들러를 못 찾습니다.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;저는 &quot;앞으로 만들어질 ID&quot;만 고려했고, &lt;strong&gt;이미 DB에 쌓여 있는, 아직 처리되지 않은 레코드&lt;/strong&gt;를 놓치고 있었습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;업그레이드 전 저장된 레코드의 ID: &lt;code class=&quot;language-text&quot;&gt;com.example.MyHandler$$SpringCGLIB$$0#handle(...)&lt;/code&gt; (옛 프록시 이름)&lt;/li&gt;
&lt;li&gt;업그레이드 후 등록되는 핸들러의 ID: &lt;code class=&quot;language-text&quot;&gt;com.example.MyHandler#handle(...)&lt;/code&gt; (안정적인 새 이름)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;둘이 매칭되지 않으니, 업그레이드 시점에 미처리 상태로 남아 있던 레코드들은 핸들러를 찾지 못합니다. ID 생성 규칙을 바꾼 것이 기존 데이터와의 단절을 만든 것입니다.&lt;/p&gt;
&lt;p&gt;저는 두 가지 해법을 제안하고 후속 PR을 열겠다고 했습니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;등록 시 &lt;strong&gt;두 ID(프록시 이름 + 실제 이름)를 모두&lt;/strong&gt; 저장해 옛 레코드도 매칭되게 한다.&lt;/li&gt;
&lt;li&gt;1차 조회 실패 시 &lt;strong&gt;CGLIB 접미사를 떼고&lt;/strong&gt; 재조회하는 fallback을 둔다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;메인테이너는 1번 방향을 권했습니다. &lt;em&gt;&quot;안정적인 ID와 레거시 ID 둘 다로 핸들러를 등록하고, 조회는 안정 ID를 먼저 본 뒤 없으면 레거시 ID로 떨어지게 하자.&quot;&lt;/em&gt; 협력자(&lt;code class=&quot;language-text&quot;&gt;@Alek96&lt;/code&gt;)는 한 가지를 더 짚어줬습니다. *&quot;핸들러 레지스트리뿐 아니라 &lt;strong&gt;fallback 레지스트리도 같은 ID를 쓰니&lt;/strong&gt; 둘 다 처리해야 한다.&quot;*&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-두-번째-PR-240-레거시-ID를-별칭으로-함께-등록&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#5-%EB%91%90-%EB%B2%88%EC%A7%B8-PR-240-%EB%A0%88%EA%B1%B0%EC%8B%9C-ID%EB%A5%BC-%EB%B3%84%EC%B9%AD%EC%9C%BC%EB%A1%9C-%ED%95%A8%EA%BB%98-%EB%93%B1%EB%A1%9D&quot; aria-label=&quot;5 두 번째 PR 240 레거시 ID를 별칭으로 함께 등록 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;5. 두 번째 PR (#240): 레거시 ID를 별칭으로 함께 등록&lt;/h2&gt;
&lt;p&gt;방향이 정해진 뒤 구현은 명확했습니다. 먼저 &lt;code class=&quot;language-text&quot;&gt;BaseHandlerMethod&lt;/code&gt;에 &lt;strong&gt;레거시 ID&lt;/strong&gt;(런타임 클래스명 = 프록시 이름 기반)를 별도로 계산해 들고 있게 했습니다. 안정 ID는 그대로 두었습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;kotlin&quot;&gt;&lt;pre class=&quot;language-kotlin&quot;&gt;&lt;code class=&quot;language-kotlin&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;/** 런타임 클래스명 기반 ID. CGLIB 접미사($$SpringCGLIB$$0 등)가 포함될 수 있다. */&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; legacyId&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; String &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;buildLegacyId&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;protected&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;buildLegacyId&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; String &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; className &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; bean&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;java&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name   &lt;span class=&quot;token comment&quot;&gt;// 프록시면 프록시 이름 그대로&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; methodName &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; method&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; paramTypes &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; method&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;parameterTypes&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;joinToString&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string-literal singleline&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;,&quot;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; it&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token string-literal singleline&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token expression&quot;&gt;className&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token expression&quot;&gt;methodName&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token expression&quot;&gt;paramTypes&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;)&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;그리고 각 레지스트리에 &lt;code class=&quot;language-text&quot;&gt;registerAlias()&lt;/code&gt;를 추가했습니다. 일반 &lt;code class=&quot;language-text&quot;&gt;register()&lt;/code&gt;와 달리, &lt;strong&gt;ID 기반 조회 맵에만&lt;/strong&gt; 별칭을 추가합니다. (타입별 핸들러 목록 등에 중복으로 넣지 않기 위해서입니다.)&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;kotlin&quot;&gt;&lt;pre class=&quot;language-kotlin&quot;&gt;&lt;code class=&quot;language-kotlin&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// OutboxHandlerRegistry&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;internal&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;registerAlias&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;aliasId&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; String&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; handlerMethod&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; OutboxHandlerMethod&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token function&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;handlersById&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;putIfAbsent&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;aliasId&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; handlerMethod&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;token string-literal singleline&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Duplicate alias ID detected: &lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token expression&quot;&gt;aliasId&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;마지막으로 빈을 스캔해 등록하는 &lt;code class=&quot;language-text&quot;&gt;OutboxHandlerBeanPostProcessor&lt;/code&gt;에서, &lt;strong&gt;빈이 프록시일 때만&lt;/strong&gt;(&lt;code class=&quot;language-text&quot;&gt;handler.id != handler.legacyId&lt;/code&gt;) 레거시 별칭을 모든 레지스트리에 함께 등록하도록 했습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;kotlin&quot;&gt;&lt;pre class=&quot;language-kotlin&quot;&gt;&lt;code class=&quot;language-kotlin&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// d. 빈이 AOP 프록시일 때만 레거시 별칭 등록 (하위 호환)&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;handler&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;id &lt;span class=&quot;token operator&quot;&gt;!=&lt;/span&gt; handler&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;legacyId&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token function&quot;&gt;registerLegacyAliases&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;handler&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;legacyId&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; result&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;kotlin&quot;&gt;&lt;pre class=&quot;language-kotlin&quot;&gt;&lt;code class=&quot;language-kotlin&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;registerLegacyAliases&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;legacyId&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; String&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; result&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; HandlerScanResult&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; handler &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; result&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;handler

    handlerRegistry&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;registerAlias&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;legacyId&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; handler&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;

    result&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;fallback&lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; fallback &lt;span class=&quot;token operator&quot;&gt;-&gt;&lt;/span&gt;
        fallbackHandlerRegistry&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;registerAlias&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;legacyId&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; fallback&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;// ← Alek96이 짚어준 부분&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

    retryPolicyScanners
        &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;mapNotNull&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; it&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;scan&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;handler&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;forEach&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; policy &lt;span class=&quot;token operator&quot;&gt;-&gt;&lt;/span&gt; retryPolicyRegistry&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;registerAlias&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;legacyId&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; policy&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;여기서 한 가지를 &lt;strong&gt;추가로&lt;/strong&gt; 처리했습니다. 메인테이너 토론에서는 핸들러 레지스트리와 fallback 레지스트리만 언급됐지만, 레거시 ID를 가진 레코드는 &lt;strong&gt;재시도 정책 조회&lt;/strong&gt;도 동일하게 작동해야 합니다. 그래서 &lt;code class=&quot;language-text&quot;&gt;OutboxRetryPolicyRegistry&lt;/code&gt;에도 &lt;code class=&quot;language-text&quot;&gt;registerAlias()&lt;/code&gt;를 더했습니다. PR 본문에 &lt;em&gt;&quot;불필요하면 빼겠다&quot;&lt;/em&gt;고 적어 두었는데, 협력자가 &lt;em&gt;&quot;Good catch&quot;&lt;/em&gt; 로 승인해 주었습니다.&lt;/p&gt;
&lt;p&gt;이렇게 하면 업그레이드 후에도 흐름이 끊기지 않습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;업그레이드 후, 옛 레코드(ID = ...$$SpringCGLIB$$0#handle)가 처리될 때
1. 안정 ID로 핸들러 조회 → 없음
2. 레거시 ID(별칭)로 조회  ← 별칭으로 등록해 둔 덕분
3. 핸들러 + fallback + 재시도 정책 모두 정상 해석&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;테스트로는 ▲프록시 빈일 때 두 ID가 같은 핸들러로 해석되는지 ▲프록시가 아닌 빈엔 별칭이 안 생기는지 ▲fallback·재시도 정책 별칭이 제대로 도는지를 검증했고, 커버리지 100%로 &lt;code class=&quot;language-text&quot;&gt;1.2.0&lt;/code&gt; 마일스톤에 머지됐습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;6-마무리&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#6-%EB%A7%88%EB%AC%B4%EB%A6%AC&quot; aria-label=&quot;6 마무리 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;6. 마무리&lt;/h2&gt;
&lt;p&gt;이번 기여에서 정리한 점은 다음과 같습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;변경 전에 영향도를 파악한다.&lt;/strong&gt; 이번 수정은 &lt;code class=&quot;language-text&quot;&gt;bean::class.java.name&lt;/code&gt; 한 줄을 바꾸는 것이 전부였습니다. 그러나 이 한 줄이 만든 ID는 outbox 레코드로 &lt;strong&gt;DB에 저장&lt;/strong&gt;되고, &lt;strong&gt;fallback 레지스트리&lt;/strong&gt;와 &lt;strong&gt;재시도 정책 조회(&lt;code class=&quot;language-text&quot;&gt;retryPolicyRegistry.getByHandlerId()&lt;/code&gt;)&lt;/strong&gt; 의 키로도 쓰이고 있었습니다. ID 생성 규칙을 바꾸면 이미 저장된 레코드의 옛 ID와 새로 등록되는 핸들러의 ID가 어긋나, 그 레코드들은 핸들러를 찾지 못합니다. 그래서 고치기 전에 &lt;em&gt;&quot;이 값이 DB에 저장되는가? 어떤 레지스트리가 키로 쓰는가? 이미 저장된 값은 그대로 매칭되는가?&quot;&lt;/em&gt; 를 먼저 확인해야 했습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;리뷰가 설계를 보강했다.&lt;/strong&gt; 첫 PR의 breaking change, fallback 레지스트리 별칭, 재시도 정책 별칭 — 이 셋은 제가 아니라 리뷰어(&lt;code class=&quot;language-text&quot;&gt;@rolandbeisel&lt;/code&gt;, &lt;code class=&quot;language-text&quot;&gt;@Alek96&lt;/code&gt;)가 짚어준 지점입니다. 리뷰가 코드 검사를 넘어 설계 보강 역할을 했습니다.&lt;/li&gt;
&lt;li&gt;PR #237: &lt;a href=&quot;https://github.com/namastack/namastack-outbox/pull/237&quot;&gt;Fix handler ID instability with CGLIB proxies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PR #240: &lt;a href=&quot;https://github.com/namastack/namastack-outbox/pull/240&quot;&gt;Add legacy alias support for backward compatibility with CGLIB proxy handler IDs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title><![CDATA[EcsRunTaskOperator에 skip_on_exit_code 옵션 추가 — Apache Airflow 기여 기록]]></title><description><![CDATA[관리비 및 공과금 데이터를 크롤링하는 파이프라인을 구축했습니다. 로컬 개발 환경에서는 크롤러를 Docker 컨테이너로 구동하여 정상 동작을 확인했고, 스케줄링은 Apache Airflow를 활용했습니다. 그러나 해당 크롤러를 dev 환경(AWS ECS…]]></description><link>https://yunhobb.github.io/tech-blog/airflow-ecs-skip-on-exit-code/</link><guid isPermaLink="false">https://yunhobb.github.io/tech-blog/airflow-ecs-skip-on-exit-code/</guid><pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;관리비 및 공과금 데이터를 크롤링하는 파이프라인을 구축했습니다. 로컬 개발 환경에서는 크롤러를 Docker 컨테이너로 구동하여 정상 동작을 확인했고, 스케줄링은 Apache Airflow를 활용했습니다.&lt;/p&gt;
&lt;p&gt;그러나 해당 크롤러를 dev 환경(AWS ECS)에 배포하여 실행하자, 로컬에서 정상 처리되던 태스크가 Airflow에서 실패(Failed) 상태로 종료되었습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1-현상-로컬-Docker스킵와-dev-ECS실패의-동작-차이&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#1-%ED%98%84%EC%83%81-%EB%A1%9C%EC%BB%AC-Docker%EC%8A%A4%ED%82%B5%EC%99%80-dev-ECS%EC%8B%A4%ED%8C%A8%EC%9D%98-%EB%8F%99%EC%9E%91-%EC%B0%A8%EC%9D%B4&quot; aria-label=&quot;1 현상 로컬 Docker스킵와 dev ECS실패의 동작 차이 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;1. 현상: 로컬 Docker(스킵)와 dev ECS(실패)의 동작 차이&lt;/h2&gt;
&lt;p&gt;수도, 전기, 가스 관리비 도메인의 특성상, 고지서 발행일 특정일 기준 n영업일 이내라고 명시만 되어있고, 매번 동일한 일자에 고지가 되지 않습니다. 따라서 특정 월의 고지서가 아직 업로드되지 않는 케이스가 존재합니다. 이는 데이터를 수집할 수 없는 상태이므로, 태스크를 실패가 아닌 스킵(Skip)으로 처리해야 합니다.&lt;/p&gt;
&lt;p&gt;이를 위해 크롤러가 데이터 미존재 시 0이 아닌 특정 exit code를 반환하며 종료되도록 설계했습니다. 그러나 같은 크롤러가 실행 환경에 따라 다르게 동작했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;로컬 환경 (&lt;code class=&quot;language-text&quot;&gt;DockerOperator&lt;/code&gt;):&lt;/strong&gt; 설정한 exit code가 Airflow에서 스킵(Skip) 상태로 정상 인식되었습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dev 환경 (&lt;code class=&quot;language-text&quot;&gt;EcsRunTaskOperator&lt;/code&gt;):&lt;/strong&gt; 동일한 크롤러가 동일한 exit code를 반환했음에도 태스크가 실패(Failed)로 처리되었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code class=&quot;language-text&quot;&gt;EcsRunTaskOperator&lt;/code&gt;는 ECS 태스크의 exit code가 0이 아닐 경우, 예외 조건 없이 &lt;code class=&quot;language-text&quot;&gt;AirflowException&lt;/code&gt;을 발생시키며 태스크를 실패로 기록하고 있었습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-exit-code의-정의와-활용&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#2-exit-code%EC%9D%98-%EC%A0%95%EC%9D%98%EC%99%80-%ED%99%9C%EC%9A%A9&quot; aria-label=&quot;2 exit code의 정의와 활용 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;2. exit code의 정의와 활용&lt;/h2&gt;
&lt;p&gt;exit code(종료 코드)는 프로세스가 종료될 때 부모 프로세스에 전달하는 0~255 사이의 정수 값입니다. 셸에서 실행한 직전 명령의 종료 코드는 &lt;code class=&quot;language-text&quot;&gt;$?&lt;/code&gt;로 확인할 수 있습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;bash&quot;&gt;&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;$ &lt;span class=&quot;token function&quot;&gt;ls&lt;/span&gt; /not-exist
ls: /not-exist: No such &lt;span class=&quot;token function&quot;&gt;file&lt;/span&gt; or directory
$ &lt;span class=&quot;token builtin class-name&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$?&lt;/span&gt;
&lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;# ← 명령 수행 실패를 의미하는 코드 1 반환&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;POSIX 규격에서 통용되는 주요 exit code의 의미는 다음과 같습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;exit code&lt;/th&gt;
&lt;th&gt;정의된 의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;성공 (Success)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;일반 오류 (General error)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;셸 오용 (Misuse of shell builtins)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;td&gt;명령 실행 불가 (Permission denied 등)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;127&lt;/td&gt;
&lt;td&gt;명령을 찾을 수 없음 (Command not found)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;128 + n&lt;/td&gt;
&lt;td&gt;시그널 n에 의한 종료 (예: 130 = SIGINT, Ctrl+C)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;255&lt;/td&gt;
&lt;td&gt;범위를 벗어난 종료 코드&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;0은 성공을 의미하지만, 0이 아닌 나머지 코드의 구체적인 의미는 애플리케이션 단위에서 정의하여 사용할 수 있습니다. 즉, &quot;exit code 2는 오류가 아니라 데이터 미존재(스킵)를 의미한다&quot;와 같은 규칙 설정이 가능합니다. 앞서 설계한 크롤러가 사용한 방식이 바로 이것입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-오퍼레이터별-기능-비교&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#3-%EC%98%A4%ED%8D%BC%EB%A0%88%EC%9D%B4%ED%84%B0%EB%B3%84-%EA%B8%B0%EB%8A%A5-%EB%B9%84%EA%B5%90&quot; aria-label=&quot;3 오퍼레이터별 기능 비교 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;3. 오퍼레이터별 기능 비교&lt;/h2&gt;
&lt;p&gt;두 환경의 결과 차이는 Airflow 오퍼레이터의 기능 공백에서 발생했습니다. Airflow 소스 코드를 확인한 결과, 컨테이너 실행 오퍼레이터 간 옵션 지원 여부가 달랐습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;오퍼레이터&lt;/th&gt;
&lt;th&gt;&lt;code class=&quot;language-text&quot;&gt;skip_on_exit_code&lt;/code&gt; 지원&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;DockerOperator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;지원함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;KubernetesPodOperator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;지원함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;EcsRunTaskOperator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;지원 안 함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code class=&quot;language-text&quot;&gt;skip_on_exit_code&lt;/code&gt;는 지정한 exit code가 반환되었을 때 &lt;code class=&quot;language-text&quot;&gt;AirflowSkipException&lt;/code&gt;을 발생시켜 태스크를 스킵 상태로 처리하는 파라미터입니다. 타 오퍼레이터와의 기능 일관성을 맞추기 위해, &lt;code class=&quot;language-text&quot;&gt;EcsRunTaskOperator&lt;/code&gt;에도 해당 기능을 직접 추가하기로 결정했습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-기존-PR-보완-및-코드-구현&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#4-%EA%B8%B0%EC%A1%B4-PR-%EB%B3%B4%EC%99%84-%EB%B0%8F-%EC%BD%94%EB%93%9C-%EA%B5%AC%ED%98%84&quot; aria-label=&quot;4 기존 PR 보완 및 코드 구현 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;4. 기존 PR 보완 및 코드 구현&lt;/h2&gt;
&lt;p&gt;동일한 기능 구현을 시도했던 기존 작업(PR #47256)이 존재했으나, 머지되지 못하고 중단된 상태임을 확인했습니다. 그래서 이 PR의 구현 방향을 참고하여, 누락된 부분까지 보완해 직접 구현한 뒤 새 PR(&lt;a href=&quot;https://github.com/apache/airflow/pull/63274&quot;&gt;apache/airflow#63274&lt;/a&gt;)로 제출했습니다.&lt;/p&gt;
&lt;p&gt;수정 작업은 &lt;code class=&quot;language-text&quot;&gt;providers/amazon/.../operators/ecs.py&lt;/code&gt; 파일에서 진행되었습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;python&quot;&gt;&lt;pre class=&quot;language-python&quot;&gt;&lt;code class=&quot;language-python&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# 1) 파라미터 정의 — 단일 정수(int) 또는 컨테이너 객체 허용&lt;/span&gt;
skip_on_exit_code&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; Container&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token builtin&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# 2) 데이터 정규화 — 단일 값 리스트화 및 None 예외 처리&lt;/span&gt;
self&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;skip_on_exit_code &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
    skip_on_exit_code
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;isinstance&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;skip_on_exit_code&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; Container&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;skip_on_exit_code&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; skip_on_exit_code &lt;span class=&quot;token keyword&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;None&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# 3) 조건 판정 — 지정된 exit code 일치 여부에 따라 발생 예외를 분기&lt;/span&gt;
exit_code &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; container&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;get&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;exitCode&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; exit_code &lt;span class=&quot;token keyword&quot;&gt;in&lt;/span&gt; self&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;skip_on_exit_code&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    exception_cls&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;AirflowException&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; AirflowSkipException
&lt;span class=&quot;token keyword&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    exception_cls &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; AirflowException&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;하드코딩되어 있던 &lt;code class=&quot;language-text&quot;&gt;AirflowException&lt;/code&gt; 발생 로직을, &lt;code class=&quot;language-text&quot;&gt;skip_on_exit_code&lt;/code&gt; 포함 여부에 따라 &lt;code class=&quot;language-text&quot;&gt;AirflowSkipException&lt;/code&gt;으로 분기되도록 수정한 것이 핵심입니다.&lt;/p&gt;
&lt;p&gt;기능 검증을 위해 아래 항목에 대한 유닛 테스트(Unit Test)도 추가했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;정의된 exit code 반환 시 &lt;code class=&quot;language-text&quot;&gt;AirflowSkipException&lt;/code&gt; 발생 여부&lt;/li&gt;
&lt;li&gt;로그 출력과 스킵 처리의 정상 연동 여부&lt;/li&gt;
&lt;li&gt;정의되지 않은 exit code 반환 시 기존대로 &lt;code class=&quot;language-text&quot;&gt;AirflowException&lt;/code&gt; 발생 여부&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-마무리&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#5-%EB%A7%88%EB%AC%B4%EB%A6%AC&quot; aria-label=&quot;5 마무리 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;5. 마무리&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code class=&quot;language-text&quot;&gt;DockerOperator&lt;/code&gt;와 &lt;code class=&quot;language-text&quot;&gt;KubernetesPodOperator&lt;/code&gt;에서 제공하던 &lt;code class=&quot;language-text&quot;&gt;skip_on_exit_code&lt;/code&gt; 옵션을 &lt;code class=&quot;language-text&quot;&gt;EcsRunTaskOperator&lt;/code&gt;에도 동일하게 구현했습니다.&lt;/li&gt;
&lt;li&gt;이를 통해 AWS ECS 환경에서도 특정 exit code를 기준으로 태스크를 실패가 아닌 스킵(Skip) 상태로 제어할 수 있게 되었습니다.&lt;/li&gt;
&lt;li&gt;오픈소스 기여 시, 중단된 기존 PR을 분석하고 보완하는 방식으로 개발 리소스를 단축할 수 있습니다. 새로운 문제일수록 누군가 이미 시도했을 가능성이 높으므로, 구현에 앞서 관련 이슈와 PR을 먼저 검색하는 것이 효율적입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;전체 구현과 리뷰 과정은 머지된 PR에서 확인할 수 있습니다 → &lt;a href=&quot;https://github.com/apache/airflow/pull/63274&quot;&gt;apache/airflow#63274 — Add skip&lt;em&gt;on&lt;/em&gt;exit_code support to EcsRunTaskOperator&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title><![CDATA[ArgoCD는 Healthy인데 왜 503은 반환했을까?]]></title><description><![CDATA[현재 저희 서비스의 DEV 환경은 다음과 같이 구성되어 있습니다. 오케스트레이션: AWS EKS 위에 Spring Boot 애플리케이션을 파드로 배포 배포: ArgoCD(GitOps…]]></description><link>https://yunhobb.github.io/tech-blog/eks-alb-503-rolling-deploy/</link><guid isPermaLink="false">https://yunhobb.github.io/tech-blog/eks-alb-503-rolling-deploy/</guid><pubDate>Sun, 23 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;현재 저희 서비스의 DEV 환경은 다음과 같이 구성되어 있습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;오케스트레이션:&lt;/strong&gt; AWS EKS 위에 Spring Boot 애플리케이션을 파드로 배포&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;배포:&lt;/strong&gt; ArgoCD(GitOps)로 매니페스트를 동기화, 갱신은 기본 전략인 롤링 업데이트(rolling update)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;트래픽 인입:&lt;/strong&gt; ALB Ingress + AWS Load Balancer Controller, 타겟 모드는 &lt;code class=&quot;language-text&quot;&gt;target-type: ip&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;헬스 체크:&lt;/strong&gt; 쿠버네티스 &lt;code class=&quot;language-text&quot;&gt;readinessProbe&lt;/code&gt;와 ALB 자체 health check가 각각 동작&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉 트래픽은 &lt;code class=&quot;language-text&quot;&gt;사용자 → ALB → (파드 IP) → Spring Boot 파드&lt;/code&gt; 경로로 흐르고, 배포는 ArgoCD가 새 파드를 띄우고 구 파드를 내리는 식으로 진행됩니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ArgoCD에서는 모든 파드가 &lt;code class=&quot;language-text&quot;&gt;Healthy&lt;/code&gt;로 표시되는데, 배포 직후 API를 호출하면 간헐적으로 503 응답을 마주했습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1-ArgoCD-배포-상태와-ALB-헬스-체크의-불일치-현상&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#1-ArgoCD-%EB%B0%B0%ED%8F%AC-%EC%83%81%ED%83%9C%EC%99%80-ALB-%ED%97%AC%EC%8A%A4-%EC%B2%B4%ED%81%AC%EC%9D%98-%EB%B6%88%EC%9D%BC%EC%B9%98-%ED%98%84%EC%83%81&quot; aria-label=&quot;1 ArgoCD 배포 상태와 ALB 헬스 체크의 불일치 현상 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;1. ArgoCD 배포 상태와 ALB 헬스 체크의 불일치 현상&lt;/h2&gt;
&lt;p&gt;저희는 ALB Ingress를 &lt;code class=&quot;language-text&quot;&gt;target-type: ip&lt;/code&gt;로 쓰고 있었습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;yaml&quot;&gt;&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;alb.ingress.kubernetes.io/target-type&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; ip&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;이 설정에서 ALB는 노드(NodePort)를 거치지 않고 &lt;strong&gt;파드 IP에 직접&lt;/strong&gt; 트래픽을 전달합니다. 이때 &lt;strong&gt;ALB의 판정 단위는 파드 하나하나&lt;/strong&gt;가 됩니다. 새로 뜬 파드는 kubelet &lt;code class=&quot;language-text&quot;&gt;readinessProbe&lt;/code&gt;와 ALB 자체 health check를 &lt;strong&gt;각각&lt;/strong&gt; 통과해야 트래픽을 받는데, &lt;strong&gt;둘은 서로의 판정 결과를 주고받지 않습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;쿠버네티스&lt;/th&gt;
&lt;th&gt;AWS ALB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;healthy 판단 주체&lt;/td&gt;
&lt;td&gt;kubelet&lt;/td&gt;
&lt;td&gt;ALB (보통 다른 AZ에서)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;통과 조건&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;readinessProbe&lt;/code&gt; &lt;strong&gt;1회&lt;/strong&gt; 200&lt;/td&gt;
&lt;td&gt;health check &lt;code class=&quot;language-text&quot;&gt;healthy_threshold&lt;/code&gt; &lt;strong&gt;연속&lt;/strong&gt; 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArgoCD 가시성&lt;/td&gt;
&lt;td&gt;보임&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;안 보임&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;kubelet은 자기 노드의 파드를 보고 &lt;code class=&quot;language-text&quot;&gt;readinessProbe&lt;/code&gt;가 한 번 200을 주면 곧장 &lt;code class=&quot;language-text&quot;&gt;Ready&lt;/code&gt;로 표시합니다. 그러면 K8s Endpoints에 파드 IP가 올라가고, ArgoCD는 이를 보고 &lt;code class=&quot;language-text&quot;&gt;Healthy&lt;/code&gt;를 표시합니다.&lt;/p&gt;
&lt;p&gt;실제로 트래픽을 전달하는 주체는 ALB입니다. ALB는 ArgoCD가 보는 K8s 상태를 알지 못합니다. 자기만의 health check를 연속으로 통과시켜 &lt;code class=&quot;language-text&quot;&gt;healthy&lt;/code&gt;라고 판단하기 전까지는 그 파드에 요청을 보내지 않습니다.&lt;/p&gt;
&lt;p&gt;즉 &lt;strong&gt;ArgoCD가 보는 healthy와 ALB가 보는 healthy는 같은 단어지만 서로 다른 상태를 가리킵니다.&lt;/strong&gt; 이 둘이 어긋나는 순간이 배포 시점이었습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;참고:&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;target-type: instance&lt;/code&gt;였다면 ALB 타겟은 노드(NodePort)이므로, 새 파드가 떠도 ALB에 따로 등록할 필요 없이 라우팅은 kube-proxy가 맡습니다. 아래에서 볼 파드 단위 등록 지연이 애초에 생기지 않습니다. 대신 노드 한 단계를 더 거치는 트레이드오프가 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-503은-코드-버그가-아니다-ALB가-직접-뱉는-신호&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#2-503%EC%9D%80-%EC%BD%94%EB%93%9C-%EB%B2%84%EA%B7%B8%EA%B0%80-%EC%95%84%EB%8B%88%EB%8B%A4-ALB%EA%B0%80-%EC%A7%81%EC%A0%91-%EB%B1%89%EB%8A%94-%EC%8B%A0%ED%98%B8&quot; aria-label=&quot;2 503은 코드 버그가 아니다 ALB가 직접 뱉는 신호 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;2. 503은 코드 버그가 아니다: ALB가 직접 뱉는 신호&lt;/h2&gt;
&lt;p&gt;원인을 추적하기 전에 503의 의미부터 짚어야 했습니다. ALB는 백엔드(타겟)까지 도달하지 못하면 &lt;strong&gt;자기가 직접&lt;/strong&gt; 상태 코드를 생성하기 때문입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;503(Service Unavailable)은 타겟 그룹에 healthy 타겟이 0일 때&lt;/strong&gt;, ALB가 어디에도 요청을 보내지 못하고 자기 선에서 생성해 돌려주는 코드입니다. 즉 503은 코드가 실패했다는 뜻이 아니라 &lt;strong&gt;요청을 보낼 곳이 없다는 신호&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;p&gt;따라서 ArgoCD가 Healthy인데도 503이 떴다는 것은, &lt;strong&gt;ALB 관점에서는 healthy 타겟이 0인 순간이 있었다&lt;/strong&gt;는 의미입니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;참고:&lt;/strong&gt; 같은 5xx라도 502(Bad Gateway, upstream이 RST를 보낸 계열)·504(Gateway Timeout, upstream 응답 지연)는 요청을 보냈으나 실패한 쪽이라 원인이 다릅니다. 이번 건은 타겟이 0이라 아예 보내지 못한 503이었습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-진짜-원인-Registration-Lag&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#3-%EC%A7%84%EC%A7%9C-%EC%9B%90%EC%9D%B8-Registration-Lag&quot; aria-label=&quot;3 진짜 원인 Registration Lag permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;3. 진짜 원인: Registration Lag&lt;/h2&gt;
&lt;p&gt;원인은 &lt;strong&gt;새 파드가 ALB에 등록되어 healthy가 되기까지의 지연(registration lag)&lt;/strong&gt; 이었습니다. 배포 시점을 초 단위로 풀어보면 간극이 그대로 드러납니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;t=0s    : 새 Pod 생성
t=120s  : kubelet readinessProbe 통과 → K8s Endpoints에 IP 추가
          → ArgoCD는 여기서 &apos;Healthy&apos; 표기 ✓
t=121s  : AWS LB Controller가 ALB.RegisterTargets 호출
          → target state: &apos;initial&apos;
t=121~270s : ALB 자체 health check 진행
             (30초 간격 × healthy_threshold=5)
             ▶ 이 구간에 요청이 오면 503 (healthy target = 0)
t=270s  : ALB target state: &apos;healthy&apos;  ← 이제야 트래픽 받을 준비 완료&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code class=&quot;language-text&quot;&gt;readinessProbe&lt;/code&gt;가 통과한 &lt;strong&gt;t=120s&lt;/strong&gt;와 ALB가 &lt;code class=&quot;language-text&quot;&gt;healthy&lt;/code&gt;로 인정한 &lt;strong&gt;t=270s&lt;/strong&gt; 사이에 &lt;strong&gt;약 150초의 빈 구간&lt;/strong&gt;이 있습니다. 이 150초 동안 ArgoCD는 &lt;code class=&quot;language-text&quot;&gt;Healthy&lt;/code&gt;를 표시하지만, 롤링 업데이트가 같은 타이밍에 &lt;strong&gt;기존 파드를 종료&lt;/strong&gt;시키면, ALB 입장에서는 트래픽을 받을 healthy 타겟이 0이 됩니다.&lt;/p&gt;
&lt;p&gt;쿠버네티스의 &lt;code class=&quot;language-text&quot;&gt;readinessProbe&lt;/code&gt;는 &lt;strong&gt;앱 프로세스가 떴다는 것까지만 보장&lt;/strong&gt;합니다. 그것을 곧바로 트래픽 받을 준비가 끝났다는 의미로 받아들여 구 파드를 내린 것이, ArgoCD는 Healthy인데 사용자는 503을 보는 상황의 원인이었습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-해결-두-Control-Plane을-K8s-readiness로-묶는다&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#4-%ED%95%B4%EA%B2%B0-%EB%91%90-Control-Plane%EC%9D%84-K8s-readiness%EB%A1%9C-%EB%AC%B6%EB%8A%94%EB%8B%A4&quot; aria-label=&quot;4 해결 두 Control Plane을 K8s readiness로 묶는다 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;4. 해결: 두 Control Plane을 K8s readiness로 묶는다&lt;/h2&gt;
&lt;p&gt;원인은 한 줄로 정리됩니다. &lt;strong&gt;kubelet이 Ready라고 해서 ALB도 healthy로 인식하는 것은 아니다.&lt;/strong&gt; 따라서 해법도 명확합니다. &lt;strong&gt;ALB가 healthy라고 인정하기 전까지는 쿠버네티스도 파드를 Ready로 표시하지 않게&lt;/strong&gt; 만들어, 두 health 판정을 한 지점에서 일치시키면 됩니다.&lt;/p&gt;
&lt;p&gt;이걸 그대로 구현한 게 AWS Load Balancer Controller의 &lt;strong&gt;Pod Readiness Gate&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;yaml&quot;&gt;&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;readinessGates&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;conditionType&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; target&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;health.elbv2.k8s.aws/&amp;lt;ingress&lt;span class=&quot;token punctuation&quot;&gt;&gt;&lt;/span&gt;_&amp;lt;service&lt;span class=&quot;token punctuation&quot;&gt;&gt;&lt;/span&gt;_&amp;lt;port&lt;span class=&quot;token punctuation&quot;&gt;&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;이 게이트가 붙으면 파드는 &lt;code class=&quot;language-text&quot;&gt;readinessProbe&lt;/code&gt;를 통과해도 &lt;strong&gt;ALB target이 &lt;code class=&quot;language-text&quot;&gt;healthy&lt;/code&gt;가 될 때까지&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;Ready&lt;/code&gt;로 마킹되지 않습니다. 즉 3번에서 본 150초의 간극 동안 롤아웃이 전진하지 못하므로, 기존 파드도 그대로 유지됩니다. healthy 타겟이 0이 되는 구간 자체가 사라집니다. (네임스페이스에 &lt;code class=&quot;language-text&quot;&gt;elbv2.k8s.aws/pod-readiness-gate-inject=enabled&lt;/code&gt; 라벨을 붙이면 컨트롤러 웹훅이 이 게이트를 자동 주입합니다.)&lt;/p&gt;
&lt;p&gt;처방을 한 장으로 정리하면 이렇습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;문제&lt;/th&gt;
&lt;th&gt;처방&lt;/th&gt;
&lt;th&gt;원리&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Registration lag (503)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Pod Readiness Gate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;두 health 판정을 K8s readiness에서 일치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;단일 replica&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;replicas ≥ 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;가용성 하한 확보&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probe 모호&lt;/td&gt;
&lt;td&gt;readiness/liveness 분리&lt;/td&gt;
&lt;td&gt;health 정의를 layer별로 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;실제로 적용한 매니페스트는 다음과 같습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;yaml&quot;&gt;&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;replicas&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;template&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;containers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; api&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;admin
          &lt;span class=&quot;token key atrule&quot;&gt;startupProbe&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;httpGet&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; /actuator/health/liveness&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;port&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;8080&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;failureThreshold&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;30&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;periodSeconds&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;5&lt;/span&gt;
          &lt;span class=&quot;token key atrule&quot;&gt;readinessProbe&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;httpGet&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; /actuator/health/readiness&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;port&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;8080&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;periodSeconds&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;5&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;failureThreshold&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;
          &lt;span class=&quot;token key atrule&quot;&gt;livenessProbe&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;httpGet&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; /actuator/health/liveness&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;port&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;8080&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;periodSeconds&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;30&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# Ingress annotations&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;alb.ingress.kubernetes.io/healthcheck-path&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; /actuator/health/readiness
&lt;span class=&quot;token key atrule&quot;&gt;alb.ingress.kubernetes.io/healthcheck-interval-seconds&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&apos;10&apos;&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;alb.ingress.kubernetes.io/healthy-threshold-count&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&apos;2&apos;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;health check 간격을 &lt;code class=&quot;language-text&quot;&gt;10초 × threshold 2&lt;/code&gt;로 줄여 등록 지연 자체를 짧게 만들었습니다(150초 → 약 20초). probe를 &lt;code class=&quot;language-text&quot;&gt;/actuator/health/readiness&lt;/code&gt;와 &lt;code class=&quot;language-text&quot;&gt;/liveness&lt;/code&gt;로 분리한 것도 핵심입니다. &lt;strong&gt;살아 있다(liveness)와 트래픽을 받을 수 있다(readiness)는 서로 다른 판정&lt;/strong&gt;이기 때문입니다. 거기에 &lt;code class=&quot;language-text&quot;&gt;replicas: 2&lt;/code&gt;로 두어, 롤링 중 한 파드가 교체되는 순간에도 healthy 타겟이 0으로 떨어지지 않도록 가용성 하한을 확보했습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-마무리&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#5-%EB%A7%88%EB%AC%B4%EB%A6%AC&quot; aria-label=&quot;5 마무리 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;5. 마무리&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;쿠버네티스(kubelet)와 ALB라는 두 개의 Control Plane이 서로 다른 시점에 &apos;healthy&apos;를 판단하고, 그 propagation delay가 사용자에게 503으로 드러난 것&lt;/strong&gt;이었습니다. ArgoCD가 Healthy를 표시하는 시점과 ALB가 트래픽을 전달해도 된다고 판단하는 시점은 처음부터 일치하지 않았습니다.&lt;/p&gt;
&lt;p&gt;어떤 컴포넌트가 &apos;healthy&apos;라고 할 때 그것이 누구의 관점인지 확인하지 않으면, production에서 503을 만날 수 있습니다.&lt;/p&gt;
&lt;p&gt;플랫폼이 무중단 배포를 보장해 줄 것 같지만, 서로 다른 두 시스템이 맞물리는 경계에는 이런 시차가 존재합니다. 그 시차를 명시적으로 견디도록 &lt;strong&gt;Pod Readiness Gate로 두 plane을 한 지점에 묶는 것&lt;/strong&gt;이 이번 503을 메운 처방이었습니다.&lt;/p&gt;</content:encoded></item><item><title><![CDATA[INSERT 한 문장이 일으킨 데드락 — 서브쿼리 S락과 트리거 X락의 락 순서 역전 분석]]></title><description><![CDATA[이 글은 "S락·X락·갭 락·락 승격 — InnoDB 락 노트"에 이어지는 2편입니다. 1편에서 정리한 공유락(S)·배타락(X)·락 승격 개념을 그대로 가져다 씁니다. 운영 DB 모니터링에서 데드락(deadlock…]]></description><link>https://yunhobb.github.io/tech-blog/deadlock-insert-subquery-trigger/</link><guid isPermaLink="false">https://yunhobb.github.io/tech-blog/deadlock-insert-subquery-trigger/</guid><pubDate>Wed, 30 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;이 글은 &lt;a href=&quot;../mysql-innodb-lock-basics/&quot;&gt;&lt;strong&gt;&quot;S락·X락·갭 락·락 승격 — InnoDB 락 노트&quot;&lt;/strong&gt;&lt;/a&gt;에 이어지는 2편입니다. 1편에서 정리한 공유락(S)·배타락(X)·락 승격 개념을 그대로 가져다 씁니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;운영 DB 모니터링에서 데드락(deadlock) 기록을 발견했습니다. 데드락 로그가 지목한 문장은 &lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt;에 행 하나를 넣는 upsert 쿼리였습니다. 단순해 보이는 INSERT 한 번이 교착에 빠진 원인을 추적했습니다.&lt;/p&gt;
&lt;p&gt;원인은 &lt;strong&gt;한 INSERT 문이 같은 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt; 행을 두 번(S락 → X락) 잠그고 있었다&lt;/strong&gt;는 데 있습니다. 그 사이에 다른 행의 X락이 끼어들면서 교착이 발생했습니다. 로그를 한 줄씩 따라가며 살펴봅니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1-현상-upsert-한-번에-데드락-로그가-찍혔다&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#1-%ED%98%84%EC%83%81-upsert-%ED%95%9C-%EB%B2%88%EC%97%90-%EB%8D%B0%EB%93%9C%EB%9D%BD-%EB%A1%9C%EA%B7%B8%EA%B0%80-%EC%B0%8D%ED%98%94%EB%8B%A4&quot; aria-label=&quot;1 현상 upsert 한 번에 데드락 로그가 찍혔다 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;1. 현상: upsert 한 번에 데드락 로그가 찍혔다&lt;/h2&gt;
&lt;p&gt;문제의 쿼리는 이렇게 생겼습니다. &lt;code class=&quot;language-text&quot;&gt;gcid&lt;/code&gt;로 가장 최신 계약(&lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;)을 골라 그 &lt;code class=&quot;language-text&quot;&gt;id&lt;/code&gt;를 &lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt;에 꽂는, &lt;code class=&quot;language-text&quot;&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt; 기반 upsert입니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;java&quot;&gt;&lt;pre class=&quot;language-java&quot;&gt;&lt;code class=&quot;language-java&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Mono&lt;/span&gt;&lt;span class=&quot;token generics&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token class-name&quot;&gt;Long&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;upsertContractSlot&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name&quot;&gt;UpsertContractSlot&lt;/span&gt; upsertContractSlot&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token class-name&quot;&gt;String&lt;/span&gt; query &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;token triple-quoted-string string&quot;&gt;&quot;&quot;&quot;
        insert into contract_slots (contract_id, slot_id, user_id, ...)
        values (
            (select c.id                              -- ⚠️ 문제 1: contracts에 S락
             from contracts as c
             join (select max(c.ezrems_cid) as max_cid
                   from contracts as c
                   where c.ezrems_gcid = :ezrems_gcid
                   group by c.ezrems_gcid) as subquery
             on subquery.max_cid = c.ezrems_cid),
            :slot_id,
            :user_id,
            ...
        )
        on duplicate key update ...                   -- ⚠️ 문제 2: 트리거 발동 → contracts에 X락
        &quot;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; databaseClient&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;sql&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;query&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;bind&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;ezrems_gcid&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; upsertContractSlot&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getGcid&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;rowsUpdated&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;그리고 &lt;code class=&quot;language-text&quot;&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt;가 남긴 데드락 기록입니다. (테이블은 MariaDB / InnoDB, 일부 컬럼은 생략했습니다.)&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-10-28 08:42:27

*** (1) TRANSACTION:
TRANSACTION 1110068327, ACTIVE 0 sec starting index read
UPDATE mims_authorizations AS ma
    JOIN contracts AS c ON NEW.contract_id = c.id
    ...
WHERE ma.source_id = NEW.id

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS on table `mgrv_dev`.`contracts`
trx id 1110068327 lock_mode X locks rec but not gap waiting
Record: contracts.id = 197084

*** (1) CONFLICTING WITH:
RECORD LOCKS on table `mgrv_dev`.`contracts`
trx id 1110068328 lock mode S locks rec but not gap
Record: contracts.id = 197084

*** (2) TRANSACTION:
TRANSACTION 1110068328, ACTIVE 0 sec inserting
insert into contract_slots (contract_id, ...)
values ((select c.id from contracts where c.ezrems_gcid = 141431 ...), ...)
on duplicate key update ...

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS on table `mgrv_dev`.`contract_slots`
index contract_slots_ezrems_gcid_ezrems_guest_code_uindex
trx id 1110068328 lock_mode X waiting
Record: ezrems_gcid=141431, ezrems_guest_code=0, id=2088501

*** (2) CONFLICTING WITH:
RECORD LOCKS on table `mgrv_dev`.`contract_slots`
trx id 1110068327 lock_mode X
Record: ezrems_gcid=141431, ezrems_guest_code=0, id=2088501

*** WE ROLL BACK TRANSACTION (1)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;로그에는 INSERT 하나에 트랜잭션 둘이 얽혀 있고, 그중 한쪽은 &lt;code class=&quot;language-text&quot;&gt;mims_authorizations&lt;/code&gt;를 UPDATE 하고 있습니다. 그런데 upsert 쿼리에는 &lt;code class=&quot;language-text&quot;&gt;mims_authorizations&lt;/code&gt;라는 테이블이 등장하지 않습니다. 단서는 &lt;code class=&quot;language-text&quot;&gt;(1)&lt;/code&gt; 트랜잭션의 SQL에 박힌 &lt;code class=&quot;language-text&quot;&gt;NEW.contract_id&lt;/code&gt;, &lt;code class=&quot;language-text&quot;&gt;NEW.id&lt;/code&gt;였습니다. &lt;code class=&quot;language-text&quot;&gt;NEW&lt;/code&gt;는 &lt;strong&gt;트리거(Trigger) 안에서만&lt;/strong&gt; 쓸 수 있는 키워드입니다. 즉 이 UPDATE는 직접 호출한 쿼리가 아니라, &lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt;에 행이 들어갈 때 따라 도는 트리거의 본문입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-로그-해부-누가-무엇을-쥐고-무엇을-기다렸나&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#2-%EB%A1%9C%EA%B7%B8-%ED%95%B4%EB%B6%80-%EB%88%84%EA%B0%80-%EB%AC%B4%EC%97%87%EC%9D%84-%EC%A5%90%EA%B3%A0-%EB%AC%B4%EC%97%87%EC%9D%84-%EA%B8%B0%EB%8B%A4%EB%A0%B8%EB%82%98&quot; aria-label=&quot;2 로그 해부 누가 무엇을 쥐고 무엇을 기다렸나 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;2. 로그 해부: 누가 무엇을 쥐고 무엇을 기다렸나&lt;/h2&gt;
&lt;p&gt;InnoDB의 데드락 로그는 각 트랜잭션이 &lt;strong&gt;무엇을 기다리는지(WAITING FOR)&lt;/strong&gt;, 그리고 그게 &lt;strong&gt;누구와 충돌하는지(CONFLICTING WITH)&lt;/strong&gt;를 기록합니다. 두 트랜잭션이 같은 &lt;code class=&quot;language-text&quot;&gt;gcid=141431&lt;/code&gt;을 동시에 처리하면서, 같은 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt; 행(&lt;code class=&quot;language-text&quot;&gt;197084&lt;/code&gt;)과 같은 &lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt; 행(&lt;code class=&quot;language-text&quot;&gt;2088501&lt;/code&gt;)을 두고 엇갈렸습니다. 정리하면 이렇습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;트랜잭션&lt;/th&gt;
&lt;th&gt;이미 쥔 락 (보유)&lt;/th&gt;
&lt;th&gt;기다리는 락 (대기)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;trx …327&lt;/strong&gt; (트리거 UPDATE 실행 중)&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt;(2088501) &lt;strong&gt;X락&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;(197084) &lt;strong&gt;X락&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;trx …328&lt;/strong&gt; (INSERT 실행 중)&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;(197084) &lt;strong&gt;S락&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt;(2088501) &lt;strong&gt;X락&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;화살표로 그려 보면 서로의 꼬리를 문 모양이 드러납니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;trx …327 ── 기다림 ──▶ contracts(197084)   ◀── 쥐고 있음 ── trx …328
trx …328 ── 기다림 ──▶ contract_slots(2088501) ◀── 쥐고 있음 ── trx …327&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code class=&quot;language-text&quot;&gt;327&lt;/code&gt;은 &lt;code class=&quot;language-text&quot;&gt;328&lt;/code&gt;이 쥔 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;를, &lt;code class=&quot;language-text&quot;&gt;328&lt;/code&gt;은 &lt;code class=&quot;language-text&quot;&gt;327&lt;/code&gt;이 쥔 &lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt;를 기다립니다. 전형적인 &lt;strong&gt;순환 대기(circular wait)&lt;/strong&gt;, 곧 교착입니다. InnoDB는 둘 중 하나를 롤백시켜 교착을 풀며, 로그 마지막 줄 &lt;code class=&quot;language-text&quot;&gt;WE ROLL BACK TRANSACTION (1)&lt;/code&gt;이 그 결과입니다. 트리거를 돌리던 &lt;code class=&quot;language-text&quot;&gt;327&lt;/code&gt;이 롤백됐습니다.&lt;/p&gt;
&lt;p&gt;여기서 한 가지 의문이 남습니다. 같은 쿼리를 호출한 두 세션인데, &lt;strong&gt;왜 한쪽은 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;에 S락(공유락)을, 다른 쪽은 X락(배타락)을 쥐고 있는가&lt;/strong&gt; 하는 점입니다. 다음 절에서 그 이유를 살펴봅니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-원인-한-INSERT-문-안의-두-단계-락&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#3-%EC%9B%90%EC%9D%B8-%ED%95%9C-INSERT-%EB%AC%B8-%EC%95%88%EC%9D%98-%EB%91%90-%EB%8B%A8%EA%B3%84-%EB%9D%BD&quot; aria-label=&quot;3 원인 한 INSERT 문 안의 두 단계 락 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;3. 원인: 한 INSERT 문 안의 두 단계 락&lt;/h2&gt;
&lt;p&gt;이유는 &lt;strong&gt;하나의 upsert가 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;를 두 번, 서로 다른 모드로 잠근다&lt;/strong&gt;는 데 있습니다. 쿼리에 달아둔 &lt;code class=&quot;language-text&quot;&gt;⚠️ 문제 1&lt;/code&gt;, &lt;code class=&quot;language-text&quot;&gt;⚠️ 문제 2&lt;/code&gt;가 바로 그 두 지점입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문제 1 — 서브쿼리가 거는 S락.&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;values (...)&lt;/code&gt; 안에는 &lt;code class=&quot;language-text&quot;&gt;contract_id&lt;/code&gt;를 구하려고 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;를 읽는 서브쿼리가 들어 있습니다. InnoDB는 기본 격리 수준(REPEATABLE READ)에서, 쓰기 문장이 다른 행을 &lt;strong&gt;읽어서 그 값을 쓰면&lt;/strong&gt; 읽은 행의 일관성을 지키기 위해 &lt;strong&gt;공유락(S락)&lt;/strong&gt;을 건다. 그래서 INSERT가 진행되는 동안 선택된 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt; 행(&lt;code class=&quot;language-text&quot;&gt;197084&lt;/code&gt;)에는 S락이 잡힙니다. S락은 공유락이므로 &lt;strong&gt;두 세션이 동시에 같은 행에 S락을 쥐어도 충돌하지 않는다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;문제 2 — 트리거가 거는 X락.&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt;는 이름과 달리 실제로 행을 쓰는 동작이라, &lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt;에 걸린 &lt;strong&gt;AFTER 트리거&lt;/strong&gt;를 깨운다. 그 트리거가 &lt;code class=&quot;language-text&quot;&gt;UPDATE mims_authorizations ... JOIN contracts&lt;/code&gt;를 실행하는데, 로그를 보면 이 문장이 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;(&lt;code class=&quot;language-text&quot;&gt;197084&lt;/code&gt;)에 &lt;strong&gt;배타락(X락)&lt;/strong&gt;을 요청합니다(&lt;code class=&quot;language-text&quot;&gt;lock_mode X locks rec but not gap&lt;/code&gt;). 같은 트랜잭션이 서브쿼리에서 S락으로 잡았던 행을, 트리거 시점에는 &lt;strong&gt;X락으로 올려야&lt;/strong&gt; 한다. 락 승격(lock upgrade)이다.&lt;/p&gt;
&lt;p&gt;하나의 upsert가 락을 잡는 순서를 시간순으로 늘어놓으면 이렇습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;upsertContractSlot 한 번이 잡는 락 (순서대로)
1. 서브쿼리 SELECT contracts            → S락  contracts(197084)
2. INSERT … ON DUPLICATE KEY UPDATE     → X락  contract_slots(2088501)
3. ODKU가 깨운 트리거의 UPDATE … JOIN    → X락  contracts(197084)   ← 1번의 S락을 X로 승격&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;핵심은 &lt;strong&gt;같은 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt; 행을 1번(S)과 3번(X)에서 두 번 잠그고, 그 사이 2번에서 다른 행(&lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt;)의 X락이 끼어든다&lt;/strong&gt;는 점입니다. 단독으로 실행될 때는 문제가 없다. 하지만 같은 &lt;code class=&quot;language-text&quot;&gt;gcid&lt;/code&gt;로 두 세션이 동시에 들어오면, 이 순서가 교착의 조건이 됩니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-락-순서-역전-왜-풀리지-않는-교착인가&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#4-%EB%9D%BD-%EC%88%9C%EC%84%9C-%EC%97%AD%EC%A0%84-%EC%99%9C-%ED%92%80%EB%A6%AC%EC%A7%80-%EC%95%8A%EB%8A%94-%EA%B5%90%EC%B0%A9%EC%9D%B8%EA%B0%80&quot; aria-label=&quot;4 락 순서 역전 왜 풀리지 않는 교착인가 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;4. 락 순서 역전: 왜 풀리지 않는 교착인가&lt;/h2&gt;
&lt;p&gt;두 세션 A(&lt;code class=&quot;language-text&quot;&gt;327&lt;/code&gt;)와 B(&lt;code class=&quot;language-text&quot;&gt;328&lt;/code&gt;)가 같은 &lt;code class=&quot;language-text&quot;&gt;gcid=141431&lt;/code&gt;로 동시에 upsert를 호출한 경우를 따라가 봅니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;세션 A (trx …327)                       세션 B (trx …328)
─────────────────────────────────       ─────────────────────────────────
1. contracts(197084) S락 획득            1. contracts(197084) S락 획득   (S끼리는 공존 OK)
2. contract_slots(2088501) X락 획득      2. contract_slots(2088501) X락 … 대기 (A가 쥠)
3. 트리거: contracts X락 … 대기
   (B가 같은 행에 S락을 쥐고 있어
    S→X 승격 불가)
        └──────────── 서로 물림 → 교착 ────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;핵심은 &lt;strong&gt;S락은 공존하지만 X락 승격은 다른 세션의 S락이 남아 있으면 진행되지 못한다&lt;/strong&gt;는 점입니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A와 B 둘 다 서브쿼리로 같은 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt; 행을 읽으므로, &lt;strong&gt;S락을 함께&lt;/strong&gt; 쥔다.&lt;/li&gt;
&lt;li&gt;&lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt; 행의 X락은 한 세션만 가질 수 있으므로, 먼저 도착한 A가 가져간다. B는 여기서 대기한다.&lt;/li&gt;
&lt;li&gt;A는 트리거를 실행하려고 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;를 &lt;strong&gt;S에서 X로 올려야&lt;/strong&gt; 한다. X로 올리려면 다른 세션이 그 행에 락을 쥐고 있지 않아야 하는데, &lt;strong&gt;B가 아직 S락을 쥔 채 2번에서 멈춰 있다.&lt;/strong&gt; A는 B가 S를 놓기를 기다린다.&lt;/li&gt;
&lt;li&gt;그러나 B는 A가 쥔 &lt;code class=&quot;language-text&quot;&gt;contract_slots&lt;/code&gt; X락을 기다리는 중이라 전진하지 못하고, S락도 놓지 못한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A는 B를 기다리고, B는 A를 기다립니다. 어느 쪽도 진행할 수 없으므로 InnoDB가 데드락으로 판정하고 A(&lt;code class=&quot;language-text&quot;&gt;327&lt;/code&gt;)를 롤백시킵니다. 환경에 따라 간헐적으로 발생하는 것이 아니라, &lt;strong&gt;같은 &lt;code class=&quot;language-text&quot;&gt;gcid&lt;/code&gt;로 요청이 겹치는 순간 구조적으로 발생하는&lt;/strong&gt; 교착입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-해결-방향-락을-한-방향으로-정렬한다&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#5-%ED%95%B4%EA%B2%B0-%EB%B0%A9%ED%96%A5-%EB%9D%BD%EC%9D%84-%ED%95%9C-%EB%B0%A9%ED%96%A5%EC%9C%BC%EB%A1%9C-%EC%A0%95%EB%A0%AC%ED%95%9C%EB%8B%A4&quot; aria-label=&quot;5 해결 방향 락을 한 방향으로 정렬한다 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;5. 해결 방향: 락을 한 방향으로 정렬한다&lt;/h2&gt;
&lt;p&gt;데드락의 일반적인 해법은 &lt;strong&gt;모든 트랜잭션이 자원을 같은 순서로 잠그게 하는 것&lt;/strong&gt;입니다. 이 사례에서는 &quot;한 INSERT 문이 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;를 S로 잡았다가 X로 다시 잡는&quot; 두 단계 구조를 없애는 것이 핵심입니다. 후보는 세 가지입니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방향&lt;/th&gt;
&lt;th&gt;무엇을 바꾸나&lt;/th&gt;
&lt;th&gt;트레이드오프&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;① 서브쿼리를 INSERT 밖으로&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;contract_id&lt;/code&gt;를 먼저 조회(또는 호출부에서 주입)하고, INSERT엔 확정된 값만 넣는다&lt;/td&gt;
&lt;td&gt;쓰기 문장이 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;에 S락을 들고 있지 않게 되어, 락은 트리거의 X락 한 번뿐. 왕복(round-trip) 한 번 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;② 처음부터 X로 잠그기&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;서브쿼리 조회를 &lt;code class=&quot;language-text&quot;&gt;SELECT … FOR UPDATE&lt;/code&gt;로 바꿔, 양쪽이 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt;를 X락으로 먼저 잡고 줄 서게 한다&lt;/td&gt;
&lt;td&gt;S→X 승격이 사라져 교착 해소. 대신 &lt;code class=&quot;language-text&quot;&gt;contracts&lt;/code&gt; 접근이 직렬화되어 동시성 저하&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;③ 트리거를 걷어내기&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;mims_authorizations&lt;/code&gt; 갱신을 DB 트리거가 아니라 애플리케이션 트랜잭션 코드로 옮긴다&lt;/td&gt;
&lt;td&gt;숨은 락(트리거의 X락)이 사라져 흐름이 명시적이 됨. 트리거에 기대던 다른 경로까지 함께 점검해야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;①과 ②는 &lt;strong&gt;락을 한 번만, 한 방향으로&lt;/strong&gt; 잡게 만들어 순환 자체를 끊습니다. ③은 여기에 더해 &quot;쿼리만 봐서는 드러나지 않던 트리거의 락&quot;을 제거합니다. 세 방향 모두 &quot;같은 행을 한 트랜잭션 안에서 S→X로 두 번 잠그지 않는다&quot;는 원칙으로 수렴합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;6-마무리&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#6-%EB%A7%88%EB%AC%B4%EB%A6%AC&quot; aria-label=&quot;6 마무리 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;6. 마무리&lt;/h2&gt;
&lt;p&gt;이번 분석에서 얻은 교훈을 정리합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;데드락 로그는 추측이 아니라 증거다.&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;LATEST DETECTED DEADLOCK&lt;/code&gt;의 &lt;code class=&quot;language-text&quot;&gt;WAITING FOR&lt;/code&gt; / &lt;code class=&quot;language-text&quot;&gt;CONFLICTING WITH&lt;/code&gt;를 읽으면 누가 무엇을 쥐고 무엇을 기다리는지가 드러납니다. 락 모드(S/X)와 레코드 키를 표로 정리하자 순환 대기가 명확해졌습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;쿼리에 드러나지 않는 락을 의심하자.&lt;/strong&gt; 데드락을 만든 &lt;code class=&quot;language-text&quot;&gt;mims_authorizations&lt;/code&gt; UPDATE는 직접 작성한 문장이 아니었습니다. &lt;code class=&quot;language-text&quot;&gt;NEW.&lt;/code&gt; 키워드가 트리거를 가리켰고, 그 트리거의 X락이 원인의 절반이었습니다. 트리거·외래키·서브쿼리처럼 &lt;strong&gt;자동으로 따라붙는 락&lt;/strong&gt;은 SQL 텍스트만 봐서는 드러나지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;한 문장이 같은 행을 두 번 잠그면 의심하자.&lt;/strong&gt; S락(읽기)과 X락(쓰기)을 한 트랜잭션 안에서 같은 행에 순차로 거는 구조는, 동시 요청이 겹치는 순간 락 승격이 막히며 교착으로 이어집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;참고:&lt;/strong&gt; 이 데드락은 환경에 따른 간헐적 버그가 아니라 같은 &lt;code class=&quot;language-text&quot;&gt;gcid&lt;/code&gt;가 겹치면 재현되는 구조적 문제였습니다. 재현 조건이 명확하면 수정 후 검증도 명확하게 할 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded></item><item><title><![CDATA[S락·X락·갭 락·락 승격 — InnoDB 락 정리 노트]]></title><description><![CDATA[운영 DB에서 데드락(deadlock…]]></description><link>https://yunhobb.github.io/tech-blog/mysql-innodb-lock-basics/</link><guid isPermaLink="false">https://yunhobb.github.io/tech-blog/mysql-innodb-lock-basics/</guid><pubDate>Mon, 21 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;운영 DB에서 데드락(deadlock) 로그를 읽다가, &lt;code class=&quot;language-text&quot;&gt;lock_mode X locks rec but not gap&lt;/code&gt;, &lt;code class=&quot;language-text&quot;&gt;lock mode S&lt;/code&gt; 같은 표현이 어떤 락을 가리키는지는 알아도 &lt;strong&gt;어떤 문장이 왜 그 락을 잡았는지&lt;/strong&gt;는 바로 설명하기 어려웠습니다. 그래서 데드락 추적에 앞서 락부터 정리했습니다.&lt;/p&gt;
&lt;p&gt;이 글은 그 정리 노트입니다. InnoDB가 행을 어떻게 잠그는지 따라가며, 다음 편에서 다룰 데드락의 배경을 먼저 정리합니다. (개념 설명은 &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html&quot;&gt;MySQL 공식 문서의 InnoDB Locking&lt;/a&gt;을 기준으로 했고, &lt;a href=&quot;https://px201226.github.io/mysql-transaction-lock/&quot;&gt;px201226님의 &quot;MySQL 트랜잭션 락&quot; 글&lt;/a&gt;을 참고해 구성을 잡았습니다. 갭 락과 삽입 의도 락 부분은 당근 기술블로그의 &lt;a href=&quot;https://medium.com/daangn/mysql-gap-lock-%EB%8B%A4%EC%8B%9C%EB%B3%B4%EA%B8%B0-7f47ea3f68bc&quot;&gt;&quot;MySQL Gap Lock 다시보기&quot;&lt;/a&gt;·&lt;a href=&quot;https://medium.com/daangn/mysql-gap-lock-%EB%91%90%EB%B2%88%EC%A7%B8-%EC%9D%B4%EC%95%BC%EA%B8%B0-49727c005084&quot;&gt;&quot;두 번째 이야기&quot;&lt;/a&gt;에서 실사례를 빌렸습니다. MariaDB의 InnoDB도 같은 락 모델을 따릅니다.)&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1-두-개의-기본-락-공유락S과-배타락X&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#1-%EB%91%90-%EA%B0%9C%EC%9D%98-%EA%B8%B0%EB%B3%B8-%EB%9D%BD-%EA%B3%B5%EC%9C%A0%EB%9D%BDS%EA%B3%BC-%EB%B0%B0%ED%83%80%EB%9D%BDX&quot; aria-label=&quot;1 두 개의 기본 락 공유락S과 배타락X permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;1. 두 개의 기본 락: 공유락(S)과 배타락(X)&lt;/h2&gt;
&lt;p&gt;InnoDB의 행 락은 두 종류에서 출발합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;공유락(Shared Lock, S):&lt;/strong&gt; 읽기를 위한 락. 여러 트랜잭션이 &lt;strong&gt;같은 행에 동시에 S락을 쥘 수 있습니다.&lt;/strong&gt; 단, 그 행을 바꾸려는 배타락은 막습니다. &lt;code class=&quot;language-text&quot;&gt;SELECT ... FOR SHARE&lt;/code&gt;(과거 &lt;code class=&quot;language-text&quot;&gt;LOCK IN SHARE MODE&lt;/code&gt;)로 명시적으로 걸 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;배타락(Exclusive Lock, X):&lt;/strong&gt; 쓰기를 위한 락. 한 트랜잭션만 쥘 수 있고, &lt;strong&gt;다른 어떤 락(S든 X든)과도 공존하지 못합니다.&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;SELECT ... FOR UPDATE&lt;/code&gt;나 &lt;code class=&quot;language-text&quot;&gt;UPDATE&lt;/code&gt;/&lt;code class=&quot;language-text&quot;&gt;DELETE&lt;/code&gt;가 이 락을 겁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;정리하면 &lt;strong&gt;S락끼리는 공존하고, X락은 단독으로만 잡힙니다.&lt;/strong&gt; 이 규칙이 뒤에 나올 데드락의 절반을 설명합니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;보유 ↓ \ 요청 →&lt;/th&gt;
&lt;th&gt;S&lt;/th&gt;
&lt;th&gt;X&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;S&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✓ 공존&lt;/td&gt;
&lt;td&gt;✗ 대기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;X&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✗ 대기&lt;/td&gt;
&lt;td&gt;✗ 대기&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;여기서 한 가지를 미리 짚어 둡니다. &lt;strong&gt;이미 S락을 쥔 행을 같은 트랜잭션이 X락으로 올리는 것&lt;/strong&gt;(락 승격, lock upgrade)은, 그 사이 다른 트랜잭션도 같은 행에 S락을 쥐고 있으면 성공하지 못합니다. S락은 공존하지만, X로의 승격은 다른 락이 모두 풀려 있어야 하기 때문입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-행을-잠그기-전에-손드는-락-의도락Intention-Lock&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#2-%ED%96%89%EC%9D%84-%EC%9E%A0%EA%B7%B8%EA%B8%B0-%EC%A0%84%EC%97%90-%EC%86%90%EB%93%9C%EB%8A%94-%EB%9D%BD-%EC%9D%98%EB%8F%84%EB%9D%BDIntention-Lock&quot; aria-label=&quot;2 행을 잠그기 전에 손드는 락 의도락Intention Lock permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;2. 행을 잠그기 전에 손드는 락: 의도락(Intention Lock)&lt;/h2&gt;
&lt;p&gt;InnoDB는 테이블 락과 행 락을 함께 씁니다(다중 세분성 잠금, multiple granularity locking). 그런데 누군가 테이블 전체에 락을 걸려 할 때, &quot;이 테이블 안 어딘가의 행에 락이 걸려 있나?&quot;를 행을 일일이 훑어 확인하면 느립니다. 그래서 행 락을 걸기 &lt;strong&gt;전에&lt;/strong&gt;, 테이블 레벨에 &quot;나 이 테이블의 어떤 행을 잠글 거야&quot;라는 깃발을 먼저 꽂습니다. 이게 의도락(Intention Lock)입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;IS(Intention Shared):&lt;/strong&gt; 행에 S락을 걸 의도. (&lt;code class=&quot;language-text&quot;&gt;SELECT ... FOR SHARE&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IX(Intention Exclusive):&lt;/strong&gt; 행에 X락을 걸 의도. (&lt;code class=&quot;language-text&quot;&gt;SELECT ... FOR UPDATE&lt;/code&gt;, &lt;code class=&quot;language-text&quot;&gt;UPDATE&lt;/code&gt;, &lt;code class=&quot;language-text&quot;&gt;INSERT&lt;/code&gt; 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;의도락끼리는 대부분 호환됩니다. 둘 다 &quot;행 단위로 락을 걸 예정&quot;이라는 표시일 뿐이고, 실제 충돌은 행 레벨에서 가려지기 때문입니다. 공식 문서의 호환성 매트릭스는 다음과 같습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;X&lt;/th&gt;
&lt;th&gt;IX&lt;/th&gt;
&lt;th&gt;S&lt;/th&gt;
&lt;th&gt;IS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;X&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;S&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;표에서 보이듯, &lt;strong&gt;테이블 레벨 X락만 모든 것을 막습니다.&lt;/strong&gt; 평소 우리가 만나는 데드락은 의도락이 아니라 &lt;strong&gt;행 레벨의 S/X&lt;/strong&gt;에서 벌어지니, 의도락은 &quot;이런 계층이 있다&quot; 정도로만 알아두면 충분합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-InnoDB가-행을-잠그는-세-가지-방식-레코드--갭--넥스트키-락&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#3-InnoDB%EA%B0%80-%ED%96%89%EC%9D%84-%EC%9E%A0%EA%B7%B8%EB%8A%94-%EC%84%B8-%EA%B0%80%EC%A7%80-%EB%B0%A9%EC%8B%9D-%EB%A0%88%EC%BD%94%EB%93%9C--%EA%B0%AD--%EB%84%A5%EC%8A%A4%ED%8A%B8%ED%82%A4-%EB%9D%BD&quot; aria-label=&quot;3 InnoDB가 행을 잠그는 세 가지 방식 레코드  갭  넥스트키 락 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;3. InnoDB가 행을 잠그는 세 가지 방식: 레코드 / 갭 / 넥스트키 락&lt;/h2&gt;
&lt;p&gt;InnoDB의 행 락은 사실 &quot;행&quot;이 아니라 &lt;strong&gt;인덱스 레코드&lt;/strong&gt;를 잠급니다. 잠그는 범위에 따라 셋으로 나뉩니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;락&lt;/th&gt;
&lt;th&gt;잠그는 대상&lt;/th&gt;
&lt;th&gt;&lt;code class=&quot;language-text&quot;&gt;performance_schema&lt;/code&gt; 표기&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;레코드 락(Record Lock)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;인덱스 레코드 &lt;strong&gt;하나&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;X,REC_NOT_GAP&lt;/code&gt; / &lt;code class=&quot;language-text&quot;&gt;S,REC_NOT_GAP&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;갭 락(Gap Lock)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;레코드와 레코드 &lt;strong&gt;사이의 빈 구간&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;X,GAP&lt;/code&gt; / &lt;code class=&quot;language-text&quot;&gt;S,GAP&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;넥스트키 락(Next-Key Lock)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;레코드 + &lt;strong&gt;그 앞 갭&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(기본 표기)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;갭 락은 값을 잠그는 게 아니라 &lt;strong&gt;그 구간에 새 행이 끼어드는 것(INSERT)을 막는&lt;/strong&gt; 락입니다. 같은 조건으로 두 번 읽었을 때 없던 행이 생기는 &lt;strong&gt;팬텀 리드(phantom read)&lt;/strong&gt;를 막기 위한 장치입니다. 넥스트키 락은 레코드 락과 갭 락을 합친 것으로, REPEATABLE READ에서 범위 조건을 다룰 때 기본으로 쓰입니다.&lt;/p&gt;
&lt;p&gt;갭 락이 언제 붙는지는 인덱스 종류를 탑니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;기본키·유니크 인덱스 + 동등 조건(=)으로 정확히 1건:&lt;/strong&gt; 레코드 락만. 갭은 안 잠급니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;존재하지 않는 값 조회, 범위 조건, 또는 보조 인덱스(secondary index):&lt;/strong&gt; &lt;strong&gt;레코드 락 + 갭 락&lt;/strong&gt;이 함께 붙습니다. 특히 보조 인덱스 동등 조회는 거의 항상 갭까지 잠그기 때문에, 한 건만 건드린 것처럼 보여도 인접 구간의 INSERT가 막힐 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;여기에 갭과 짝을 이루는 락이 하나 더 있습니다. &lt;strong&gt;삽입 의도 락(INSERT Intention Lock)&lt;/strong&gt;입니다. &lt;code class=&quot;language-text&quot;&gt;INSERT&lt;/code&gt;가 어떤 갭에 행을 넣기 직전 그 갭에 거는 특수한 갭 락이며, 다음 성질이 중요합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;갭 락끼리는 서로 호환됩니다.&lt;/strong&gt; 여러 트랜잭션이 같은 갭에 갭 락을 함께 쥘 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;하지만 갭 락과 삽입 의도 락은 충돌합니다.&lt;/strong&gt; 누군가 갭을 잠가두면, 그 갭에 INSERT 하려는 트랜잭션은 대기합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 &quot;갭 락은 공존하지만 삽입 의도 락과는 비호환&quot;이라는 비대칭이 6절의 또 다른 데드락 패턴을 만듭니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;참고:&lt;/strong&gt; 2편의 데드락 로그에는 &lt;code class=&quot;language-text&quot;&gt;locks rec but not gap&lt;/code&gt;이 등장합니다. 갭은 잠그지 않고 &lt;strong&gt;레코드 하나만&lt;/strong&gt; 잠근, 위 표의 &quot;레코드 락&quot;입니다. 즉 2편의 교착은 갭 락이 아니라 &lt;strong&gt;레코드 락 + S→X 승격&lt;/strong&gt;이 원인이며, 지금 설명한 갭 락은 그것과 별개의 데드락 갈래입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-격리-수준이-락을-바꾼다--기본은-REPEATABLE-READ&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#4-%EA%B2%A9%EB%A6%AC-%EC%88%98%EC%A4%80%EC%9D%B4-%EB%9D%BD%EC%9D%84-%EB%B0%94%EA%BE%BC%EB%8B%A4--%EA%B8%B0%EB%B3%B8%EC%9D%80-REPEATABLE-READ&quot; aria-label=&quot;4 격리 수준이 락을 바꾼다  기본은 REPEATABLE READ permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;4. 격리 수준이 락을 바꾼다 — 기본은 REPEATABLE READ&lt;/h2&gt;
&lt;p&gt;같은 SQL이라도 트랜잭션 격리 수준(isolation level)에 따라 잡는 락이 달라집니다. MySQL·MariaDB의 기본값은 &lt;strong&gt;REPEATABLE READ(RR)&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;일반 &lt;code class=&quot;language-text&quot;&gt;SELECT&lt;/code&gt;는 락을 안 건다.&lt;/strong&gt; RR에서 평범한 조회는 MVCC(다중 버전 동시성 제어)로 스냅샷을 읽기 때문에, 락 없이도 일관된 결과를 봅니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;하지만 쓰기는 다르다.&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;UPDATE&lt;/code&gt;, &lt;code class=&quot;language-text&quot;&gt;DELETE&lt;/code&gt;, 그리고 &lt;strong&gt;다른 테이블을 읽어 값을 넣는 &lt;code class=&quot;language-text&quot;&gt;INSERT ... SELECT&lt;/code&gt;&lt;/strong&gt;는 MVCC로 처리되지 않고 &lt;strong&gt;실제 락&lt;/strong&gt;을 잡습니다. RR이라도 MVCC는 읽기 동시성을 위한 것이며, &lt;strong&gt;쓰기에는 적용되지 않습니다.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;READ COMMITTED(RC)로 낮추면 갭 락을 거의 쓰지 않아 잠금 범위가 좁아지지만, 일관성 보장도 함께 약해집니다. 이 글과 2편의 내용은 모두 &lt;strong&gt;기본값 RR&lt;/strong&gt;을 전제로 합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-무슨-문장이-어떤-락을-잡나&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#5-%EB%AC%B4%EC%8A%A8-%EB%AC%B8%EC%9E%A5%EC%9D%B4-%EC%96%B4%EB%96%A4-%EB%9D%BD%EC%9D%84-%EC%9E%A1%EB%82%98&quot; aria-label=&quot;5 무슨 문장이 어떤 락을 잡나 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;5. 무슨 문장이 어떤 락을 잡나&lt;/h2&gt;
&lt;p&gt;이번 노트의 핵심 표입니다. 기본 격리 수준(RR) 기준으로, 자주 쓰는 문장이 거는 락을 정리했습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;문장&lt;/th&gt;
&lt;th&gt;거는 락&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;일반 &lt;code class=&quot;language-text&quot;&gt;SELECT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;없음 (MVCC 스냅샷 읽기)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;SELECT ... FOR SHARE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;읽은 행에 &lt;strong&gt;S락&lt;/strong&gt; (+ IS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;SELECT ... FOR UPDATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;읽은 행에 &lt;strong&gt;X락&lt;/strong&gt; (+ IX)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;UPDATE ... WHERE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;조건 범위에 넥스트키 락, 실제 수정 행에 &lt;strong&gt;X락&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;단순 &lt;code class=&quot;language-text&quot;&gt;INSERT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;삽입 행에 &lt;strong&gt;X락&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;INSERT ... SELECT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;읽어온 원본 행에 S락&lt;/strong&gt; + 삽입 행에 X락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;중복키 감지 시 &lt;strong&gt;먼저 S락 → 갱신하며 X락&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;마지막 두 줄이 2편 데드락의 두 축입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-text&quot;&gt;INSERT ... SELECT&lt;/code&gt;(또는 INSERT의 &lt;code class=&quot;language-text&quot;&gt;VALUES&lt;/code&gt; 안 서브쿼리):&lt;/strong&gt; 값을 읽어온 원본 행에 &lt;strong&gt;S락&lt;/strong&gt;이 걸립니다. 읽기만 한 것처럼 보여도, 그 값으로 다른 곳에 쓰는 이상 일관성을 지키기 위해 InnoDB가 S락을 잡습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-text&quot;&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt;:&lt;/strong&gt; 중복키를 만나면 그 인덱스 레코드에 &lt;strong&gt;먼저 S락&lt;/strong&gt;을 잡아 확인하고, 갱신을 진행하며 &lt;strong&gt;X락으로 올립니다.&lt;/strong&gt; 여기에 락 승격(S→X)이 들어 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;6-데드락이-만들어지는-과정&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#6-%EB%8D%B0%EB%93%9C%EB%9D%BD%EC%9D%B4-%EB%A7%8C%EB%93%A4%EC%96%B4%EC%A7%80%EB%8A%94-%EA%B3%BC%EC%A0%95&quot; aria-label=&quot;6 데드락이 만들어지는 과정 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;6. 데드락이 만들어지는 과정&lt;/h2&gt;
&lt;p&gt;데드락은 &lt;strong&gt;둘 이상의 트랜잭션이 서로가 쥔 락을 맞물려 기다리는 순환 대기(circular wait)&lt;/strong&gt;입니다. 실무에서 마주치는 형태는 크게 셋입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;패턴 A — 락 순서 역전.&lt;/strong&gt; 가장 고전적인 형태입니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;trx-1:  id=1 X락 획득 → id=2 X락 대기
trx-2:  id=2 X락 획득 → id=1 X락 대기
        └──────── 서로 물림 → 교착 ────────┘&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;두 트랜잭션이 같은 자원들을 &lt;strong&gt;반대 순서&lt;/strong&gt;로 잠그면 발생합니다. 해결책은 모든 트랜잭션이 같은 순서로 잠그도록 맞추는 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;패턴 B — S→X 승격 경쟁.&lt;/strong&gt; 1절에서 짚어 둔 락 승격이 여기서 문제가 됩니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;trx-1, trx-2:  같은 행에 S락을 함께 획득   (S끼리는 공존 OK)
trx-1:  그 행을 X로 승격하려 함 → trx-2의 S락 때문에 대기
trx-2:  그 행을 X로 승격하려 함 → trx-1의 S락 때문에 대기
        └──────── 둘 다 못 올라감 → 교착 ────────┘&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code class=&quot;language-text&quot;&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt;처럼 &lt;strong&gt;S락을 먼저 잡고 X로 올리는&lt;/strong&gt; 문장을 두 세션이 같은 키로 동시에 실행하면 이 패턴에 빠집니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;패턴 C — 갭 락과 삽입 의도 락의 충돌.&lt;/strong&gt; 3절의 비대칭(&quot;갭 락끼리는 공존, 삽입 의도 락과는 비호환&quot;)이 여기서 작용합니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;세션1: SELECT ... FOR UPDATE (없는 id=2) → 갭 락 획득
세션2: SELECT ... FOR UPDATE (없는 id=2) → 갭 락 획득   (갭 락끼리는 공존 OK)
세션1: INSERT id=2 → 삽입 의도 락 필요, 세션2의 갭 락과 충돌 → 대기
세션2: INSERT id=2 → 삽입 의도 락 필요, 세션1의 갭 락과 충돌 → 대기
        └──────── 서로 물림 → 교착 ────────┘&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;존재하지 않는 값 조회·범위 조건·보조 인덱스가 갭 락을 만들고, 거기에 두 세션이 같은 갭으로 INSERT를 시도하면 교착이 됩니다. 큐(queue)처럼 쓰는 테이블이나 빈 테이블(갭이 테이블 전체로 넓어집니다)에서 특히 자주 나타납니다. 해결책은 격리 수준을 READ COMMITTED로 낮춰 갭 락을 없애거나, 범위 조회(&lt;code class=&quot;language-text&quot;&gt;BETWEEN&lt;/code&gt;)를 기본키 동등 조회(&lt;code class=&quot;language-text&quot;&gt;IN (...)&lt;/code&gt;)로 바꾸거나, 선택도 높은 인덱스를 더해 잠금 범위를 좁히는 것입니다. (당근 기술블로그의 두 글이 이 패턴과, 페이지 끝의 &lt;code class=&quot;language-text&quot;&gt;supremum&lt;/code&gt; 갭까지 다룹니다 — 위 참고 링크.)&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;참고:&lt;/strong&gt; 패턴 B와 C는 둘 다 공존하던 약한 락(S락 / 갭 락)을 더 강한 락(X락 / 삽입 의도 락)으로 올리는 시점에서 막힌다는 공통점이 있습니다. 여럿이 함께 쥐던 락을 동시에 독점하려는 순간이 교착의 전형적인 구도입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;교착이 감지되면 InnoDB는 &lt;strong&gt;가장 가벼운 트랜잭션 하나를 골라 롤백&lt;/strong&gt;합니다. &quot;가벼움&quot;은 &lt;code class=&quot;language-text&quot;&gt;information_schema.INNODB_TRX&lt;/code&gt;의 &lt;code class=&quot;language-text&quot;&gt;trx_weight&lt;/code&gt;(변경·잠근 행 수 등을 반영한 값)로 판단합니다. 살아남은 쪽은 정상 진행하므로, &lt;strong&gt;애플리케이션은 데드락(에러 코드 1213)을 만나면 재시도하도록&lt;/strong&gt; 작성하는 것이 일반적입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;7-마무리--2편으로&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#7-%EB%A7%88%EB%AC%B4%EB%A6%AC--2%ED%8E%B8%EC%9C%BC%EB%A1%9C&quot; aria-label=&quot;7 마무리  2편으로 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;7. 마무리 — 2편으로&lt;/h2&gt;
&lt;p&gt;여기까지가 데드락 로그를 읽기 위한 최소한의 어휘입니다. 정리하면,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;S락은 공존하고 X락은 독점한다.&lt;/strong&gt; 그리고 &lt;strong&gt;S→X 승격은 다른 S락이 남아 있으면 막힌다.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;행 락은 인덱스 레코드를 잠그며, &lt;strong&gt;레코드 / 갭 / 넥스트키&lt;/strong&gt;로 범위가 나뉜다.&lt;/li&gt;
&lt;li&gt;기본 격리 수준 RR에서 &lt;strong&gt;일반 SELECT는 락이 없지만, 쓰기와 &lt;code class=&quot;language-text&quot;&gt;INSERT ... SELECT&lt;/code&gt;는 락을 잡는다.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code class=&quot;language-text&quot;&gt;INSERT ... SELECT&lt;/code&gt;는 원본 행에 &lt;strong&gt;S락&lt;/strong&gt;, &lt;code class=&quot;language-text&quot;&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt;는 중복키에 &lt;strong&gt;S락 → X락 승격&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;데드락은 &lt;strong&gt;락 순서 역전(패턴 A)&lt;/strong&gt;, &lt;strong&gt;S→X 승격 경쟁(패턴 B)&lt;/strong&gt;, &lt;strong&gt;갭 락↔삽입 의도 락 충돌(패턴 C)&lt;/strong&gt;에서 발생한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 요소들이 실제로 어떻게 맞물리는지를, 다음 편에서 운영 DB에 기록된 데드락 로그로 추적합니다. &lt;strong&gt;평범해 보이는 upsert 쿼리 하나가 위 패턴 B를 그대로 재현한 사례입니다.&lt;/strong&gt; → &lt;a href=&quot;../deadlock-insert-subquery-trigger/&quot;&gt;&lt;strong&gt;2편: INSERT 한 번에 데드락이?&lt;/strong&gt;&lt;/a&gt;에서 이어가겠습니다.&lt;/p&gt;</content:encoded></item><item><title><![CDATA[openjdk:21-slim 운영 환경에서 엑셀 다운로드 시 UnsatisfiedLinkError가 난 원인]]></title><description><![CDATA[대용량 처리를 위해 엑셀 파일 생성 구현체를 변경했습니다. 로컬과 DEV 환경에서 검증을 마치고 운영 서버에 배포했는데, 운영 환경의 엑셀 다운로드 API에서 에러가 발생했습니다.…]]></description><link>https://yunhobb.github.io/tech-blog/excel-download-font-error/</link><guid isPermaLink="false">https://yunhobb.github.io/tech-blog/excel-download-font-error/</guid><pubDate>Wed, 22 Jan 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;대용량 처리를 위해 엑셀 파일 생성 구현체를 변경했습니다. 로컬과 DEV 환경에서 검증을 마치고 운영 서버에 배포했는데, 운영 환경의 엑셀 다운로드 API에서 에러가 발생했습니다.&lt;/p&gt;
&lt;h2 id=&quot;1-현상-분석-스프링-예외로-감싸인-네이티브-에러&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#1-%ED%98%84%EC%83%81-%EB%B6%84%EC%84%9D-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%98%88%EC%99%B8%EB%A1%9C-%EA%B0%90%EC%8B%B8%EC%9D%B8-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EC%97%90%EB%9F%AC&quot; aria-label=&quot;1 현상 분석 스프링 예외로 감싸인 네이티브 에러 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;1. 현상 분석: 스프링 예외로 감싸인 네이티브 에러&lt;/h2&gt;
&lt;p&gt;로그에서 가장 먼저 확인한 것은 스프링 MVC의 예외였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&apos;gatsby-resp-image-figure&apos; style=&apos;margin-bottom: 16px;&apos;&gt;
    &lt;span class=&apos;gatsby-resp-image-wrapper&apos; style=&apos;position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 680px; &apos;&gt;
      &lt;a class=&apos;gatsby-resp-image-link&apos; href=&apos;/tech-blog/static/57d7edde873b13f61b82acc7fe761a3b/8d3a8/unsatisfied-link-error.png&apos; style=&apos;display: block&apos; target=&apos;_blank&apos; rel=&apos;noopener&apos;&gt;
    &lt;span class=&apos;gatsby-resp-image-background-image&apos; style=&quot;padding-bottom: 19.411764705882355%; position: relative; bottom: 0; left: 0; background-image: url(&apos;data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAECAYAAACOXx+WAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAzUlEQVR42jXLyU6EQBRAUf7/u9y419YGQTuEwbRQA1QV1KO5hkIXNyd5QzbamW4wqX60fKsp2auJbrR044Tygg4bOggmxJT2ERuE0QutjTRmpbeR7Ln8oRkM9WBpjaO3gUbPdMbTapeOa7txM0J9POqZRrtTNaPijhHQ8YEKQvb00sO6sK/CvhxGiAL/xvjXCuuRnHPZko8Q2I+9CDEEspgXzJec+fX99JLj8xJffOKuH/iiwl1LXJpVuLciGaobPq9Yvmqkv7PdRzZl+AVuxDDBPJiSsAAAAABJRU5ErkJggg==&apos;); background-size: cover; display: block;&quot;&gt;&lt;/span&gt;
  &lt;img class=&apos;gatsby-resp-image-image&apos; alt=&apos;에러 트래킹에 잡힌 UnsatisfiedLinkError&apos; title=&apos;&apos; src=&apos;/tech-blog/static/57d7edde873b13f61b82acc7fe761a3b/ca1dc/unsatisfied-link-error.png&apos; srcset=&apos;/tech-blog/static/57d7edde873b13f61b82acc7fe761a3b/e7570/unsatisfied-link-error.png 170w,
/tech-blog/static/57d7edde873b13f61b82acc7fe761a3b/f46e7/unsatisfied-link-error.png 340w,
/tech-blog/static/57d7edde873b13f61b82acc7fe761a3b/ca1dc/unsatisfied-link-error.png 680w,
/tech-blog/static/57d7edde873b13f61b82acc7fe761a3b/8d3a8/unsatisfied-link-error.png 877w&apos; sizes=&apos;(max-width: 680px) 100vw, 680px&apos; style=&apos;width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;&apos; loading=&apos;lazy&apos; decoding=&apos;async&apos;&gt;
  &lt;/a&gt;
    &lt;/span&gt;
    &lt;figcaption class=&apos;gatsby-resp-image-figcaption&apos;&gt;에러 트래킹에 잡힌 UnsatisfiedLinkError&lt;/figcaption&gt;
  &lt;/figure&gt;&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;org.springframework.web.util.NestedServletException: Request processing failed;
nested exception is org.springframework.web.handler.HandlerDispatchResolverException:
Handler dispatch failed; nested exception is java.lang.UnsatisfiedLinkError:
/usr/local/openjdk-21/lib/libfontmanager.so: libfreetype.so.6: cannot open shared object file: No such file or directory&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;로그 상단의 &lt;code class=&quot;language-text&quot;&gt;Handler dispatch failed&lt;/code&gt;는 Spring MVC에서 요청을 처리(컨트롤러 실행 등)하는 도중 예외가 발생했을 때 &lt;code class=&quot;language-text&quot;&gt;DispatcherServlet&lt;/code&gt;이 이를 감싸서 던지는 최상위 래퍼(Wrapper) 에러입니다.&lt;/p&gt;
&lt;p&gt;이 문구 자체는 &quot;요청 처리 중에 문제가 생겼다&quot;는 범용적인 신호이므로, 원인을 찾으려면 그 안에 중첩된 실제 예외에 집중해야 합니다.&lt;/p&gt;
&lt;h3 id=&quot;javalangUnsatisfiedLinkError&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#javalangUnsatisfiedLinkError&quot; aria-label=&quot;javalangUnsatisfiedLinkError permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;java.lang.UnsatisfiedLinkError&lt;/h3&gt;
&lt;p&gt;Java 공식 문서에 따르면, 이 예외는 JVM이 적절한 네이티브 언어 정의(Native-language definition)를 찾지 못하거나, 의존하는 네이티브 라이브러리(&lt;code class=&quot;language-text&quot;&gt;.so&lt;/code&gt; 또는 &lt;code class=&quot;language-text&quot;&gt;.dll&lt;/code&gt; 파일)를 로드하는 데 실패했을 때 발생합니다.&lt;/p&gt;
&lt;p&gt;이번 로그에서는 JVM이 JDK 내부의 &lt;code class=&quot;language-text&quot;&gt;libfontmanager.so&lt;/code&gt;를 로드하려다, 이 파일이 의존하는 OS 레벨 공유 라이브러리인 &lt;code class=&quot;language-text&quot;&gt;libfreetype.so.6&lt;/code&gt;를 찾지 못해 발생한 경우였습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-openjdk21-vs-openjdk21-slim&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#2-openjdk21-vs-openjdk21-slim&quot; aria-label=&quot;2 openjdk21 vs openjdk21 slim permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;2. openjdk:21 vs openjdk:21-slim&lt;/h2&gt;
&lt;p&gt;로컬 도커 환경도 Java 21이고 운영도 Java 21인데, 왜 로컬에만 해당 파일이 있는지 확인해야 했습니다. 원인은 Dockerfile의 베이스 이미지 태그 차이였습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;로컬 개발용 Dockerfile:&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;FROM openjdk:21&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;운영(Prod)용 Dockerfile:&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;FROM openjdk:21-slim&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Debian 공식 패키지 명세를 보면 &lt;code class=&quot;language-text&quot;&gt;libfreetype6&lt;/code&gt;는 &quot;FreeType 2 font engine, shared library files&quot;로 정의되어 있습니다.&lt;/p&gt;
&lt;p&gt;일반 &lt;code class=&quot;language-text&quot;&gt;openjdk:21&lt;/code&gt; 이미지에는 Linux 표준 폰트 엔진과 기본 OS 패키지가 포함되어 있는 반면, &lt;code class=&quot;language-text&quot;&gt;-slim&lt;/code&gt; 버전은 컨테이너 용량을 줄이기 위해 자바 실행에 필요한 최소 패키지만 남기고 폰트 엔진 관련 파일을 제거합니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-원인-SXSSFWorkbook이-시트-생성-시-폰트를-참조한다&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#3-%EC%9B%90%EC%9D%B8-SXSSFWorkbook%EC%9D%B4-%EC%8B%9C%ED%8A%B8-%EC%83%9D%EC%84%B1-%EC%8B%9C-%ED%8F%B0%ED%8A%B8%EB%A5%BC-%EC%B0%B8%EC%A1%B0%ED%95%9C%EB%8B%A4&quot; aria-label=&quot;3 원인 SXSSFWorkbook이 시트 생성 시 폰트를 참조한다 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;3. 원인: SXSSFWorkbook이 시트 생성 시 폰트를 참조한다&lt;/h2&gt;
&lt;p&gt;이번에 변경된 코드는 한 줄이었습니다. 엑셀 생성 워크북 구현체를 기존 &lt;code class=&quot;language-text&quot;&gt;XSSFWorkbook&lt;/code&gt;에서 스트리밍 방식인 &lt;code class=&quot;language-text&quot;&gt;SXSSFWorkbook&lt;/code&gt;으로 바꾼 것입니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;java&quot;&gt;&lt;pre class=&quot;language-java&quot;&gt;&lt;code class=&quot;language-java&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// AS-IS&lt;/span&gt;
&lt;span class=&quot;token class-name&quot;&gt;Workbook&lt;/span&gt; workbook &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;XSSFWorkbook&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// TO-BE&lt;/span&gt;
&lt;span class=&quot;token class-name&quot;&gt;SXSSFWorkbook&lt;/span&gt; workbook &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;SXSSFWorkbook&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;코드 어디에도 폰트 스타일을 지정하는 로직은 없습니다. 컬럼 너비를 자동 계산하는 &lt;code class=&quot;language-text&quot;&gt;autoSizeColumn()&lt;/code&gt;도 사용하지 않았고, 셀에 값만 넣고 &lt;code class=&quot;language-text&quot;&gt;write()&lt;/code&gt;를 호출할 뿐입니다. 폰트를 직접 다루지 않는데도 폰트 라이브러리를 로드하다 실패한 이유는 &lt;code class=&quot;language-text&quot;&gt;SXSSFWorkbook&lt;/code&gt;이 시트를 생성하는 순간 내부적으로 기본 폰트를 참조하기 때문입니다. Apache POI 소스 코드를 따라가 보면 이 흐름을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;code class=&quot;language-text&quot;&gt;SXSSFSheet&lt;/code&gt;는 생성자에서 무조건 &lt;code class=&quot;language-text&quot;&gt;AutoSizeColumnTracker&lt;/code&gt;를 생성합니다. 스트리밍 방식 특성상 행을 디스크로 흘려보내면서 컬럼 너비를 실시간으로 추적해야 하기 때문입니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;java&quot;&gt;&lt;pre class=&quot;language-java&quot;&gt;&lt;code class=&quot;language-java&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// org.apache.poi.xssf.streaming.SXSSFSheet&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;SXSSFSheet&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name&quot;&gt;SXSSFWorkbook&lt;/span&gt; workbook&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;XSSFSheet&lt;/span&gt; xSheet&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;IOException&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;
    _autoSizeColumnTracker &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;AutoSizeColumnTracker&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// ← 시트 생성 시 무조건 실행&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;그리고 이 &lt;code class=&quot;language-text&quot;&gt;AutoSizeColumnTracker&lt;/code&gt; 생성자는 내부적으로 기본 폰트의 글자 너비를 미리 계산해 둡니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;java&quot;&gt;&lt;pre class=&quot;language-java&quot;&gt;&lt;code class=&quot;language-java&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// org.apache.poi.xssf.streaming.AutoSizeColumnTracker&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;AutoSizeColumnTracker&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Sheet&lt;/span&gt; sheet&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    defaultCharWidth &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;SheetUtil&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getDefaultCharWidthAsFloat&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;sheet&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getWorkbook&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;이 &lt;code class=&quot;language-text&quot;&gt;getDefaultCharWidthAsFloat()&lt;/code&gt;가 바로 자바 그래픽 런타임(AWT)을 이용해 글자 폭을 재는 지점입니다. 텍스트를 가상으로 렌더링하여 픽셀 폭을 구하기 위해 &lt;code class=&quot;language-text&quot;&gt;TextLayout&lt;/code&gt;과 &lt;code class=&quot;language-text&quot;&gt;FontRenderContext&lt;/code&gt;를 사용하고, 이 과정에서 JDK 내부 폰트 네이티브 라이브러리(&lt;code class=&quot;language-text&quot;&gt;libfontmanager.so&lt;/code&gt;)를 로드하게 됩니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;java&quot;&gt;&lt;pre class=&quot;language-java&quot;&gt;&lt;code class=&quot;language-java&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// org.apache.poi.ss.util.SheetUtil&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;getDefaultCharWidthAsFloat&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;Workbook&lt;/span&gt; wb&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token class-name&quot;&gt;Font&lt;/span&gt; defaultFont &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; wb&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getFontAt&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;
    &lt;span class=&quot;token class-name&quot;&gt;TextLayout&lt;/span&gt; layout &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;TextLayout&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;str&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getIterator&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; fontRenderContext&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// ← AWT 폰트 렌더링 발생!&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; layout&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;getAdvance&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;즉 &lt;code class=&quot;language-text&quot;&gt;autoSizeColumn()&lt;/code&gt;을 직접 호출하지 않더라도, &lt;code class=&quot;language-text&quot;&gt;SXSSFWorkbook.createSheet()&lt;/code&gt;를 호출하는 순간 폰트 관련 네이티브 코드가 실행됩니다. 전체 장애 흐름을 요약하면 다음과 같습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;# ── 호출이 안으로 파고드는 단계 (call stack ↓) ──
1. 엑셀 다운로드 API 호출
2. └ new SXSSFWorkbook().createSheet()
3.   └ SXSSFSheet 생성자가 new AutoSizeColumnTracker(this) 실행
4.     └ AutoSizeColumnTracker → SheetUtil.getDefaultCharWidthAsFloat(workbook)
5.       └ 기본 폰트 글자 폭 측정 위해 AWT 폰트 렌더링(TextLayout / FontRenderContext) 작동
6.         └ JDK libfontmanager.so 로딩 시도 → 의존하는 libfreetype.so.6 호출
7.           ✗ 운영은 openjdk:21-slim 이라 libfreetype.so.6 파일이 없음!

# ── 에러가 밖으로 전파되는 단계 (call stack ↑) ──
8.         ↑ java.lang.UnsatisfiedLinkError 발생 → 호출 스택을 타고 위로 전파
9. ↑ Spring DispatcherServlet이 Handler dispatch failed로 감싸서 응답&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h3 id=&quot;XSSF는-왜-멀쩡했을까&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#XSSF%EB%8A%94-%EC%99%9C-%EB%A9%80%EC%A9%A1%ED%96%88%EC%9D%84%EA%B9%8C&quot; aria-label=&quot;XSSF는 왜 멀쩡했을까 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;XSSF는 왜 멀쩡했을까?&lt;/h3&gt;
&lt;p&gt;기존에 사용하던 &lt;code class=&quot;language-text&quot;&gt;XSSFSheet&lt;/code&gt;는 이 &lt;code class=&quot;language-text&quot;&gt;AutoSizeColumnTracker&lt;/code&gt;를 미리 생성하지 않습니다. &lt;code class=&quot;language-text&quot;&gt;autoSizeColumn()&lt;/code&gt;을 직접 호출하지 않는 한 폰트 코드가 실행되지 않습니다. 반면 &lt;code class=&quot;language-text&quot;&gt;SXSSFSheet&lt;/code&gt;는 시트를 만드는 순간 추적기를 생성하면서 폰트를 참조합니다.&lt;/p&gt;
&lt;p&gt;동일한 코드(문자열 셀만 채우고 write)를 워크북 구현체만 바꿔서 실행하면 차이가 드러납니다. 다음은 &lt;code class=&quot;language-text&quot;&gt;-verbose:class&lt;/code&gt; 옵션으로 &lt;code class=&quot;language-text&quot;&gt;sun.font.*&lt;/code&gt; 클래스 로딩 횟수를 측정한 결과입니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;워크북 구현체&lt;/th&gt;
&lt;th&gt;&lt;code class=&quot;language-text&quot;&gt;sun.font.*&lt;/code&gt; 클래스 로드 횟수&lt;/th&gt;
&lt;th&gt;slim 이미지에서의 동작 여부&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code class=&quot;language-text&quot;&gt;XSSFWorkbook&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0회&lt;/strong&gt; (폰트를 건드리지 않음)&lt;/td&gt;
&lt;td&gt;정상 작동&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code class=&quot;language-text&quot;&gt;SXSSFWorkbook&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;82회&lt;/strong&gt; (&lt;code class=&quot;language-text&quot;&gt;FontManagerNativeLibrary&lt;/code&gt; 포함)&lt;/td&gt;
&lt;td&gt;네이티브 라이브러리 로드 실패로 &lt;strong&gt;&lt;code class=&quot;language-text&quot;&gt;UnsatisfiedLinkError&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;이는 데이터의 행 수와도 무관합니다. 데이터가 10행이든 0행이든, &lt;strong&gt;시트를 생성하는 순간&lt;/strong&gt; 발생합니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;참고:&lt;/strong&gt; Apache POI에는 이 상황을 대비한 방어 코드가 있습니다. &lt;code class=&quot;language-text&quot;&gt;SXSSFSheet&lt;/code&gt; 생성자는 &lt;code class=&quot;language-text&quot;&gt;UnsatisfiedLinkError&lt;/code&gt;를 &lt;code class=&quot;language-text&quot;&gt;catch&lt;/code&gt;하지만, 에러 메시지에 &lt;code class=&quot;language-text&quot;&gt;X11FontManager&lt;/code&gt;가 포함된 경우(과거 리눅스 X11 환경)에만 예외를 무시하고 넘어갑니다. 이번 에러는 메시지가 &lt;code class=&quot;language-text&quot;&gt;libfontmanager.so: libfreetype.so.6...&lt;/code&gt; 형태였기 때문에 조건에 맞지 않아 예외가 그대로 전파되었습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-도커-이미지별-네이티브-라이브러리-특징-비교&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#4-%EB%8F%84%EC%BB%A4-%EC%9D%B4%EB%AF%B8%EC%A7%80%EB%B3%84-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%ED%8A%B9%EC%A7%95-%EB%B9%84%EA%B5%90&quot; aria-label=&quot;4 도커 이미지별 네이티브 라이브러리 특징 비교 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;4. 도커 이미지별 네이티브 라이브러리 특징 비교&lt;/h2&gt;
&lt;p&gt;도커는 호스트의 커널을 공유할 뿐, 베이스 이미지 내부의 &apos;파일 시스템&apos;을 패키징합니다. 따라서 베이스 이미지를 어떤 것으로 고르느냐에 따라 포함된 네이티브 파일 구성이 완전히 달라집니다. 흔히 쓰이는 자바 베이스 이미지들의 특징을 정리해 보았습니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;이미지 접미사 (Variant)&lt;/th&gt;
&lt;th&gt;기반 OS (Base OS)&lt;/th&gt;
&lt;th&gt;특징 및 네이티브 라이브러리 구성&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;default (기본형)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ubuntu / Debian 계열&lt;/td&gt;
&lt;td&gt;기본적인 패키지, glibc, 폰트 및 그래픽 라이브러리가 풍부하게 포함되어 있어 무겁지만 안정적임.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code class=&quot;language-text&quot;&gt;-slim&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Debian 계열 (경량화)&lt;/td&gt;
&lt;td&gt;용량을 줄이기 위해 &lt;code class=&quot;language-text&quot;&gt;fontconfig&lt;/code&gt;, &lt;code class=&quot;language-text&quot;&gt;freetype&lt;/code&gt; 등 다수의 OS 패키지가 제거됨. (이번 장애의 원인)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code class=&quot;language-text&quot;&gt;-alpine&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Alpine Linux&lt;/td&gt;
&lt;td&gt;극단적인 경량화 이미지. 단, 표준 &lt;code class=&quot;language-text&quot;&gt;glibc&lt;/code&gt; 대신 &lt;code class=&quot;language-text&quot;&gt;musl libc&lt;/code&gt;를 사용하여 일부 네이티브 라이브러리와 호환성 문제가 생길 수 있음.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;distroless&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;최소한의 런타임&lt;/td&gt;
&lt;td&gt;패키지 매니저(&lt;code class=&quot;language-text&quot;&gt;apt&lt;/code&gt;, &lt;code class=&quot;language-text&quot;&gt;apk&lt;/code&gt;)나 Shell조차 없음. 애플리케이션 실행을 위한 최소 파일만 존재하여 보안성과 경량성이 매우 높음.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-마무리-dev-stage-prod-환경은-최대한-같게-맞추자&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#5-%EB%A7%88%EB%AC%B4%EB%A6%AC-dev-stage-prod-%ED%99%98%EA%B2%BD%EC%9D%80-%EC%B5%9C%EB%8C%80%ED%95%9C-%EA%B0%99%EA%B2%8C-%EB%A7%9E%EC%B6%94%EC%9E%90&quot; aria-label=&quot;5 마무리 dev stage prod 환경은 최대한 같게 맞추자 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;5. 마무리: dev, stage, prod 환경은 최대한 같게 맞추자&lt;/h2&gt;
&lt;p&gt;이번 장애의 원인은 코드 버그가 아니라 실행 환경의 차이였습니다. 로컬·DEV는 &lt;code class=&quot;language-text&quot;&gt;openjdk:21&lt;/code&gt;(full), 운영은 &lt;code class=&quot;language-text&quot;&gt;openjdk:21-slim&lt;/code&gt;을 사용했고, 같은 자바 코드라도 그 아래의 OS 라이브러리 구성이 달랐기 때문에 사전 검증을 통과한 변경이 운영에서만 실패했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;베이스 이미지를 통일한다.&lt;/strong&gt; 운영을 &lt;code class=&quot;language-text&quot;&gt;-slim&lt;/code&gt;으로 최적화하기로 했다면, dev와 stage도 같은 &lt;code class=&quot;language-text&quot;&gt;-slim&lt;/code&gt; 이미지를 쓰는 게 맞습니다. 그래야 이번 같은 OS 의존성 문제가 운영까지 가기 전에 로컬/스테이지에서 먼저 드러납니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;환경 차이를 줄이면 검증의 신뢰도가 올라간다.&lt;/strong&gt; &quot;로컬에서 통과했다&quot;는 말이 &quot;운영에서도 통과한다&quot;와 같은 의미가 되려면, 두 환경이 충분히 닮아 있어야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;자바 애플리케이션이 플랫폼 독립적으로 보여도, Apache POI의 폰트 렌더링처럼 OS 네이티브 라이브러리에 의존하는 부분은 환경 차이에 그대로 노출됩니다. 그 차이를 가장 확실하게 없애는 방법은 검증하는 환경과 배포하는 환경을 같게 만드는 것입니다.&lt;/p&gt;</content:encoded></item><item><title><![CDATA[핵사고날 아키텍처로 파이썬 레거시 DB 격리하기 (with. ArchUnit Test)]]></title><description><![CDATA[핵사고날 아키텍처(Hexagonal Architecture)를 사용하는 프로젝트에 합류했습니다. 합류 초기에는 이 아키텍처를 도입한 이유를 파악하지 못한 상태였습니다. 포트(Port) 하나, 어댑터(Adapter…]]></description><link>https://yunhobb.github.io/tech-blog/isolate-legacy-with-archunit/</link><guid isPermaLink="false">https://yunhobb.github.io/tech-blog/isolate-legacy-with-archunit/</guid><pubDate>Thu, 26 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;핵사고날 아키텍처(Hexagonal Architecture)를 사용하는 프로젝트에 합류했습니다. 합류 초기에는 이 아키텍처를 도입한 이유를 파악하지 못한 상태였습니다.&lt;/p&gt;
&lt;p&gt;포트(Port) 하나, 어댑터(Adapter) 하나, 그 사이를 잇는 매퍼(Mapper)까지. 기능 하나를 추가할 때마다 비슷한 파일이 서너 개씩 늘어났습니다. 이 정도 복잡도에 레이어마다 의존성을 이렇게까지 끊어낼 필요가 있는지 저는 의문이었습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1-의문의-시작-너무-많은-보일러플레이트&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#1-%EC%9D%98%EB%AC%B8%EC%9D%98-%EC%8B%9C%EC%9E%91-%EB%84%88%EB%AC%B4-%EB%A7%8E%EC%9D%80-%EB%B3%B4%EC%9D%BC%EB%9F%AC%ED%94%8C%EB%A0%88%EC%9D%B4%ED%8A%B8&quot; aria-label=&quot;1 의문의 시작 너무 많은 보일러플레이트 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;1. 의문의 시작: 너무 많은 보일러플레이트&lt;/h2&gt;
&lt;p&gt;핵사고날의 핵심은 &lt;strong&gt;의존성 역전&lt;/strong&gt;입니다. 도메인(Domain)은 바깥(DB·웹·외부 API)을 모르고, 바깥이 도메인을 향하도록 의존 방향을 뒤집습니다. 그 방향을 뒤집기 위한 장치가 포트와 어댑터입니다. (잘 정리된 자료가 많은 주제라 간단하게만 언급하고 넘어가겠습니다)&lt;/p&gt;
&lt;p&gt;문제는 이 패턴이 기능 단위로 그대로 복제된다는 점이었습니다. 단순히 테이블 하나를 읽어서 내려주는 조회 API에도 관습적으로 같은 구조가 따라붙었습니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;text&quot;&gt;&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;application/
  port/out/    FooReader (interface)         ← 포트
domain/
  Foo                                         ← 도메인 모델
adapter/out/
  FooJpaEntity                                ← JPA 엔티티
  FooPersistenceAdapter (implements FooReader)← 어댑터
  FooMapper (Foo ↔ FooJpaEntity)              ← 매퍼&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;도메인 모델 &lt;code class=&quot;language-text&quot;&gt;Foo&lt;/code&gt;와 JPA 엔티티 &lt;code class=&quot;language-text&quot;&gt;FooJpaEntity&lt;/code&gt;가 거의 똑같은 필드를 가지고, 그 둘을 변환하는 매퍼가 또 한 벌 필요했습니다. 필드 하나를 추가할 때 다섯 개의 파일을 손대야 하는 구조였습니다.&lt;/p&gt;
&lt;p&gt;같은 의문을 가진 팀원이 있어 백엔드 미팅에서 안건을 올렸습니다. 도메인의 복잡도에 비해 핵사고날을 유지하는 비용이 과한 것은 아닌지가 주제였습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-핵사고날을-쓴-진짜-이유-파이썬-레거시-DB-격리&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#2-%ED%95%B5%EC%82%AC%EA%B3%A0%EB%82%A0%EC%9D%84-%EC%93%B4-%EC%A7%84%EC%A7%9C-%EC%9D%B4%EC%9C%A0-%ED%8C%8C%EC%9D%B4%EC%8D%AC-%EB%A0%88%EA%B1%B0%EC%8B%9C-DB-%EA%B2%A9%EB%A6%AC&quot; aria-label=&quot;2 핵사고날을 쓴 진짜 이유 파이썬 레거시 DB 격리 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;2. 핵사고날을 쓴 진짜 이유: 파이썬 레거시 DB 격리&lt;/h2&gt;
&lt;p&gt;팀과 논의를 거치며 이 아키텍처를 도입한 목적을 확인했습니다.&lt;/p&gt;
&lt;p&gt;이 서비스의 DB는 원래 파이썬으로 운영되던 레거시 시스템의 것이었습니다. 시스템을 자바로 전환하면서 기존 테이블들을 그대로 사용해야 했는데, 레거시 스키마를 자바나 JPA로 직접 핸들링하면 DB 구조에 의존적인 클래스가 만들어졌습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;테이블의 각 컬럼의 생명주기가 다름&lt;/li&gt;
&lt;li&gt;한 테이블에 여러 책임이 섞여 있거나, 반대로 불필요하게 흩어져 있는 구조&lt;/li&gt;
&lt;li&gt;&quot;이 모델을 도메인 객체로 그대로 쓰면, 도메인이 레거시 DB 구조에 종속된다&quot;는 문제&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그래서 선택한 방법이 도메인 엔티티(Domain Entity)와 JPA 엔티티(JPA Entity)를 분리하는 추상화였습니다. 레거시 테이블의 생김새는 &lt;code class=&quot;language-text&quot;&gt;JpaEntity&lt;/code&gt;가 온전히 떠안고, 도메인은 어댑터 너머에서 자바답게 설계한 모델로만 세상을 바라보게 한 것입니다.&lt;/p&gt;
&lt;p&gt;즉, 포트-어댑터(Port-Adapter) 패턴은 레거시 DB를 격리하기 위한 장치였습니다. 처음부터 프로젝트 전체를 핵사고날로 채우려던 것이 아니라, 레거시와 맞닿는 일부 영역에서만 이 패턴을 차용한 구조였습니다.&lt;/p&gt;
&lt;p&gt;이 분리가 주는 실질적인 효과는 리팩터링 시점에 드러납니다. 도메인은 JPA 엔티티가 아니라 포트(&lt;code class=&quot;language-text&quot;&gt;FooReader&lt;/code&gt; 같은 인터페이스)에만 의존합니다. 덕분에 훗날 레거시 테이블을 걷어내고 새 스키마로 옮기더라도, 비즈니스 로직은 한 줄도 건드리지 않은 채 DB 레이어(어댑터)만 교체하면 됩니다. 도메인은 &lt;code class=&quot;language-text&quot;&gt;JpaEntity&lt;/code&gt;의 구조를 모르기 때문에, 교체의 영향이 어댑터 안쪽에서 멈춥니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;레거시 영역&lt;/th&gt;
&lt;th&gt;(이상적인) 신규 영역&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DB 스키마&lt;/td&gt;
&lt;td&gt;파이썬 시절 그대로, 변경 권한 제약&lt;/td&gt;
&lt;td&gt;자바 도메인에 맞춰 새로 설계 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모델&lt;/td&gt;
&lt;td&gt;도메인 ↔ JPA 분리 필요&lt;/td&gt;
&lt;td&gt;굳이 분리할 이유 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;패턴&lt;/td&gt;
&lt;td&gt;포트-어댑터로 격리&lt;/td&gt;
&lt;td&gt;격리할 대상 자체가 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비용&lt;/td&gt;
&lt;td&gt;보일러플레이트를 감수할 명분 있음&lt;/td&gt;
&lt;td&gt;같은 비용을 치를 명분 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;합류 초기의 저는 비용만 보고, 이 선택에 이르기까지의 배경은 파악하지 못한 상태였습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-그렇다면-신규-기능은--레이어드로-충분하다&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#3-%EA%B7%B8%EB%A0%87%EB%8B%A4%EB%A9%B4-%EC%8B%A0%EA%B7%9C-%EA%B8%B0%EB%8A%A5%EC%9D%80--%EB%A0%88%EC%9D%B4%EC%96%B4%EB%93%9C%EB%A1%9C-%EC%B6%A9%EB%B6%84%ED%95%98%EB%8B%A4&quot; aria-label=&quot;3 그렇다면 신규 기능은  레이어드로 충분하다 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;3. 그렇다면 신규 기능은? — 레이어드로 충분하다&lt;/h2&gt;
&lt;p&gt;여기서 자연스럽게 따라오는 판단은, 격리할 레거시가 없는 신규 기능까지 같은 비용을 낼 이유는 없다는 점입니다.&lt;/p&gt;
&lt;p&gt;신규 API는 출발점부터 다릅니다. DB 모델부터 직접 설계하기 때문입니다. 도메인에 맞는 스키마를 직접 설계할 수 있으니, 도메인 엔티티와 JPA 엔티티가 어긋날 일도 거의 없습니다. 둘을 분리하고 매퍼로 잇는 비용이 그것으로 막을 수 있는 위험보다 큽니다.&lt;/p&gt;
&lt;p&gt;그래서 앞으로의 방향을 다음과 같이 정했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;레거시와 엮인 기존 기능:&lt;/strong&gt; 기존처럼 핵사고날(포트-어댑터) 구조를 유지해 레거시를 계속 격리한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;신규 기능:&lt;/strong&gt; &lt;code class=&quot;language-text&quot;&gt;Controller → Service → Repository&lt;/code&gt;로 이어지는 레이어드 아키텍처(Layered Architecture)로 단순하게 접근한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;문제는 한 코드베이스 안에 두 아키텍처가 공존하게 되었다는 점입니다. 아키텍처 규칙은 문서에만 적어두면 시간이 지날수록 지켜지지 않습니다. 레이어드로 정한 신규 모듈에서 컨트롤러가 레포지토리를 직접 호출하거나, 리팩터링 도중 서비스가 컨트롤러를 역참조하는 일이 코드 리뷰에서 누락될 수 있습니다.&lt;/p&gt;
&lt;p&gt;이러한 균열을 사람의 코드 리뷰에만 의존해서 막는 데는 한계가 있습니다. 그래서 이 경계를 자동화된 테스트로 검증하기로 했습니다. 이때 도입한 도구가 ArchUnit입니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-경계를-테스트로-박는다-ArchUnit&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#4-%EA%B2%BD%EA%B3%84%EB%A5%BC-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A1%9C-%EB%B0%95%EB%8A%94%EB%8B%A4-ArchUnit&quot; aria-label=&quot;4 경계를 테스트로 박는다 ArchUnit permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;4. 경계를 테스트로 박는다: ArchUnit&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://www.archunit.org/&quot;&gt;ArchUnit&lt;/a&gt;은 아키텍처 규칙을 일반 테스트 코드처럼 작성하여 CI에서 검증할 수 있게 도와주는 라이브러리입니다. &quot;패키지 A는 패키지 B를 참조해서는 안 된다&quot;와 같은 규칙을 JUnit 테스트로 표현할 수 있으며, 규칙을 위반하면 테스트가 실패합니다.&lt;/p&gt;
&lt;h3 id=&quot;4-1-신규-모듈의-레이어-방향-강제&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#4-1-%EC%8B%A0%EA%B7%9C-%EB%AA%A8%EB%93%88%EC%9D%98-%EB%A0%88%EC%9D%B4%EC%96%B4-%EB%B0%A9%ED%96%A5-%EA%B0%95%EC%A0%9C&quot; aria-label=&quot;4 1 신규 모듈의 레이어 방향 강제 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;4-1. 신규 모듈의 레이어 방향 강제&lt;/h3&gt;
&lt;p&gt;신규 서비스에 한해, 레이어가 단방향으로만 흐르도록 규칙을 세웠습니다. (패키지명은 예시입니다)&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;java&quot;&gt;&lt;pre class=&quot;language-java&quot;&gt;&lt;code class=&quot;language-java&quot;&gt;&lt;span class=&quot;token annotation punctuation&quot;&gt;@AnalyzeClasses&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;packages &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;net.mgrv.apis.newfeature&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;LayeredArchitectureTest&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;

    &lt;span class=&quot;token annotation punctuation&quot;&gt;@ArchTest&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;ArchRule&lt;/span&gt; 레이어_의존성은_한_방향으로만_흐른다 &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;token function&quot;&gt;layeredArchitecture&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;consideringOnlyDependenciesInLayers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;layer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Controller&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;definedBy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;..controller..&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;layer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Service&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;definedBy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;..service..&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;layer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Repository&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;definedBy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;..repository..&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;

            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;whereLayer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Controller&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;mayNotBeAccessedByAnyLayer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;           &lt;span class=&quot;token comment&quot;&gt;// 아무도 컨트롤러를 의존하지 않는다&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;whereLayer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Service&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;mayOnlyBeAccessedByLayers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Controller&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;// 서비스는 컨트롤러만 부른다&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;whereLayer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Repository&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;mayOnlyBeAccessedByLayers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Service&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;  &lt;span class=&quot;token comment&quot;&gt;// 레포지토리는 서비스만 부른다&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;이제 누군가 컨트롤러에서 레포지토리를 직접 호출하면, 코드 리뷰어들이 놓치더라도 CI에서 인지할 수 있습니다. &lt;/p&gt;
&lt;h3 id=&quot;4-2-레거시와-공존하기--점진적-적용과-Freeze&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#4-2-%EB%A0%88%EA%B1%B0%EC%8B%9C%EC%99%80-%EA%B3%B5%EC%A1%B4%ED%95%98%EA%B8%B0--%EC%A0%90%EC%A7%84%EC%A0%81-%EC%A0%81%EC%9A%A9%EA%B3%BC-Freeze&quot; aria-label=&quot;4 2 레거시와 공존하기  점진적 적용과 Freeze permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;4-2. 레거시와 공존하기 — 점진적 적용과 Freeze&lt;/h3&gt;
&lt;p&gt;여기서 한 가지 분명히 해둘 점이 있습니다. ArchUnit으로 격리하려는 건 레거시와 신규 사이의 &lt;strong&gt;의존 방향&lt;/strong&gt;이 아닙니다. 신규가 레거시에 쌓인 데이터를 읽어야 할 때도 있고, 반대로 레거시가 신규 기능을 불러 써야 할 때도 있습니다. 둘이 서로를 참조하는 것 자체는 막지 않습니다.&lt;/p&gt;
&lt;p&gt;대신 격리하는 건 &lt;strong&gt;&apos;규칙을 어디에, 얼마나 빠르게 적용하느냐&apos;&lt;/strong&gt; 입니다. 4-1의 레이어드 규칙을 신규 모듈에만 적용하면 신규는 깨끗하게 출발합니다. 하지만 같은 규칙을 레거시까지 넓히면, 레거시 내부에 이미 쌓여 있던 수백 개의 위반이 한꺼번에 드러납니다. 그렇다고 규칙을 꺼두면 레거시는 계속 방치됩니다.&lt;/p&gt;
&lt;p&gt;이 딜레마를 해결하는 도구가 &lt;code class=&quot;language-text&quot;&gt;FreezingArchRule&lt;/code&gt;입니다.&lt;/p&gt;
&lt;div class=&quot;gatsby-highlight&quot; data-language=&quot;java&quot;&gt;&lt;pre class=&quot;language-java&quot;&gt;&lt;code class=&quot;language-java&quot;&gt;&lt;span class=&quot;token annotation punctuation&quot;&gt;@ArchTest&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;ArchRule&lt;/span&gt; 레이어_규칙을_레거시까지_점진적으로_적용한다 &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;
    &lt;span class=&quot;token class-name&quot;&gt;FreezingArchRule&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;freeze&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;token function&quot;&gt;layeredArchitecture&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;consideringOnlyDependenciesInLayers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;layer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Controller&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;definedBy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;..controller..&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;layer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Service&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;definedBy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;..service..&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;layer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Repository&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;definedBy&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;..repository..&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;whereLayer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Controller&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;mayNotBeAccessedByAnyLayer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;whereLayer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Service&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;mayOnlyBeAccessedByLayers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Controller&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;whereLayer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Repository&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;mayOnlyBeAccessedByLayers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;Service&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;  &lt;span class=&quot;token comment&quot;&gt;// 레거시의 기존 위반은 baseline에 기록하고, 새로 생기는 위반만 실패시킨다&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code class=&quot;language-text&quot;&gt;freeze()&lt;/code&gt;는 현재 존재하는 위반 사항들을 기준선(baseline)으로 기록해 두고, 그 이후에 새롭게 추가되는 위반에 대해서만 테스트를 실패시킵니다. 기존의 기술 부채는 인정하되, 부채가 더 늘어나는 것만 확실히 막는 방식입니다.&lt;/p&gt;
&lt;p&gt;제가 적용한 &apos;격리(Isolation)&apos;는 이런 의미였습니다. 레거시를 당장 완벽하게 고치는 게 아니라, 레거시에 이미 쌓인 위반은 baseline에 묶어 더는 늘어나지 않게 동결하고, 신규 코드만큼은 규칙을 지키도록 테스트로 감시하는 것입니다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;규칙&lt;/th&gt;
&lt;th&gt;적용 대상&lt;/th&gt;
&lt;th&gt;효과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;layeredArchitecture()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;신규 모듈&lt;/td&gt;
&lt;td&gt;레이어 의존 방향(Controller→Service→Repository)을 강제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code class=&quot;language-text&quot;&gt;FreezingArchRule.freeze()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;레거시 포함 전체&lt;/td&gt;
&lt;td&gt;기존 위반은 baseline으로 동결, 새로 생긴 위반만 실패&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-마무리&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#5-%EB%A7%88%EB%AC%B4%EB%A6%AC&quot; aria-label=&quot;5 마무리 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;5. 마무리&lt;/h2&gt;
&lt;p&gt;처음에 핵사고날 구조를 보고 복잡도에 비해 과하다고 느꼈던 이유는, 그 아키텍처가 해결하려던 문제를 제가 제대로 이해하지 못했기 때문이었습니다. 핵사고날의 목적은 구조를 깔끔하게 그리는 것이 아니라 파이썬 레거시 DB를 격리하는 것이었고, 그 목적이 존재하는 곳에서는 보일러플레이트도 필요한 요소였습니다.&lt;/p&gt;
&lt;p&gt;반대로, 그러한 목적이 없는 신규 기능에까지 같은 패턴을 관습적으로 복제하는 것은 적절하지 않았습니다. 격리해야 할 레거시 자체가 없기 때문입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;목적을 모르는 패턴은 비용만 보이고 명분은 보이지 않습니다.&lt;/strong&gt; 핵사고날이든 레이어드든, 먼저 던져야 할 질문은 &quot;이 아키텍처가 지금 어떤 문제를 풀고 있는가&quot;입니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;한 레포지토리에 두 아키텍처가 공존할 수 있습니다.&lt;/strong&gt; 레거시는 핵사고날로 격리하고, 신규 기능은 레이어드로 단순하게 가져갑니다. 각자 해결하려는 문제가 다르기 때문입니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;단, 그 경계는 사람이 아니라 테스트가 지키게 해야 합니다.&lt;/strong&gt; 구두 약속과 컨벤션 문서는 쉽게 무너집니다. ArchUnit은 그 경계를 테스트로 검증하게 해 줍니다.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title><![CDATA[녹차 주도 개발 시작]]></title><description><![CDATA[시작 녹차 한 잔과 함께.]]></description><link>https://yunhobb.github.io/tech-blog/welcome/</link><guid isPermaLink="false">https://yunhobb.github.io/tech-blog/welcome/</guid><pubDate>Sun, 01 Sep 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;시작&quot; style=&quot;position:relative;&quot;&gt;&lt;a href=&quot;#%EC%8B%9C%EC%9E%91&quot; aria-label=&quot;시작 permalink&quot; class=&quot;heading-anchor before&quot;&gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;15&quot; height=&quot;15&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.667.796.982.812.812 1.846 1.417 3.036 1.704 1.542.371 3.194.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z&quot;/&gt;&lt;/svg&gt;&lt;/a&gt;시작&lt;/h2&gt;
&lt;p&gt;녹차 한 잔과 함께.&lt;/p&gt;</content:encoded></item></channel></rss>