<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>yongmin_lee.log</title>
        <link>https://velog.io/</link>
        <description>열정 열정 열정</description>
        <lastBuildDate>Sat, 19 Sep 2026 15:45:53 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>yongmin_lee.log</title>
            <url>https://velog.velcdn.com/images/yongmin_lee/profile/9df9e475-bc89-43c5-bddd-001dd98df687/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. yongmin_lee.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/yongmin_lee" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[개발자로서 나만의 무기와 앞으로의 목표]]></title>
            <link>https://velog.io/@yongmin_lee/%EB%82%B4%EA%B0%80-%EC%83%9D%EA%B0%81%ED%95%98%EB%8A%94-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A1%9C%EC%84%9C%EC%9D%98-%EB%AC%B4%EA%B8%B0%EC%99%80-%EB%82%98%EC%9D%98-%EB%AA%A9%ED%91%9C</link>
            <guid>https://velog.io/@yongmin_lee/%EB%82%B4%EA%B0%80-%EC%83%9D%EA%B0%81%ED%95%98%EB%8A%94-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A1%9C%EC%84%9C%EC%9D%98-%EB%AC%B4%EA%B8%B0%EC%99%80-%EB%82%98%EC%9D%98-%EB%AA%A9%ED%91%9C</guid>
            <pubDate>Sat, 19 Sep 2026 15:45:53 GMT</pubDate>
            <description><![CDATA[<p>이전에 친구에게 이런 질문을 받은 적이 있다.</p>
<p>“개발자는 개발을 잘하려면 어떻게 해야 해?”</p>
<p>그 질문을 듣고 나는 당연하다는 듯이 대답했다.</p>
<p>“개발을 열심히 배우고 원하는 걸 만들다 보면 잘해져!”</p>
<p>어찌 보면 당연한 말이다.</p>
<p>달리기 선수는 달리기를 잘하면 되고,
미용사는 머리를 잘 자르면 되고,
시인은 시를 잘 쓰면 되고,
번역가는 번역을 잘하면 된다.</p>
<p>같은 논리로 개발자는 개발을 잘하면 된다.</p>
<p>그런데 개발을 잘한다는 게 뭘까?
이런 생각이 종종 든다.</p>
<p>기능을 잘 구현하는 것?
협업을 잘하는 것?
새로운 기술을 빠르게 배우는 것?</p>
<p>나는 세상의 많은 것에 ‘수요와 공급의 법칙’이 적용된다고 생각한다.
<img src="https://velog.velcdn.com/images/yongmin_lee/post/6e2cc6c0-a13d-4ef8-9f6b-3a1149fd20c3/image.png" alt="">
공급의 관점에서 보면, 어떤 능력을 가진 사람이 많아질수록 그 능력의 희소성은 낮아진다. 반대로 쉽게 대체할 수 없는 능력은 더 높은 가치를 인정받는다.</p>
<p>결국 시장에서 쉽게 대체되지 않는 능력을 가진 개발자가 가치 있는 개발자라고 생각한다.</p>
<p>그렇다면 쉽게 대체되지 않는 능력은 어떻게 가질 수 있을까?</p>
<p>AI의 발전은 개발 시장의 공급을 빠르게 늘리고 있다.</p>
<p>예전에는 개발자 한 명이 며칠 동안 작성해야 했던 코드를 이제는 AI의 도움을 받아 몇 시간 만에 만들 수 있다. 개발자의 수가 그대로라고 하더라도 한 사람이 생산할 수 있는 코드의 양이 늘었으니, 시장 전체에서 보면 코드의 공급량이 증가한 셈이다.</p>
<p>필요한 코드의 양이 그보다 빠르게 증가하지 않는다면, 같은 일을 처리하는 데 필요한 개발자의 수는 줄어든다. 특히 요구사항이 명확하고 반복적인 업무일수록 AI로 대체하거나 더 적은 인원으로 처리하기 쉬워질 것이다.</p>
<p>물론 개발자가 완전히 사라지지는 않겠지만(곧 사라질지도?), ‘코드를 작성할 수 있다’는 사실만으로 개발자의 가치를 증명하기는 점점 어려워질 것 같다.</p>
<p>코드를 작성하는 능력이 더 이상 특별하지 않게 될수록 우리는 자연스럽게 이런 질문을 받게 된다.</p>
<p>“그래서 당신은 다른 개발자와 무엇이 다른가요?”</p>
<p>이 질문에 답하려면 각자의 ‘무기’가 필요하다고 생각한다.</p>
<p>여기서 말하는 무기는 쉽게 대체되지 않는 나만의 경험이자, 내가 어떤 문제를 해결할 수 있는지를 증명하는 근거다.</p>
<p>그리고 그 무기는 다른 사람에게 보여줄 수 있는 형태여야 한다.</p>
<p>“저는 문제 해결을 잘합니다.”</p>
<p>이런 말은 누구나 할 수 있다.</p>
<p>하지만 원하는 기능을 만들어보기만 한 사람보다 실제 트래픽이 발생하는 서비스를 만들고 운영해본 사람은 자신의 문제 해결 능력을 경험으로 증명할 수 있다.</p>
<p>사용자가 늘어나면서 서버가 느려졌던 경험, 예상하지 못한 장애를 마주했던 경험, 로그를 분석해 원인을 찾았던 경험, 비용과 성능 사이에서 선택했던 경험은 튜토리얼만 따라 해서는 얻기 힘들다.</p>
<p>오픈소스 기여도 좋은 무기가 될 수 있다.</p>
<p>많은 사람이 사용하고 있는 프로젝트의 구조를 이해하고, 문제를 발견하고, 기존 코드의 방향에 맞게 수정한 뒤, 다른 개발자의 리뷰를 받아 변경 사항을 반영하는 과정은 혼자 프로젝트를 만드는 것과 전혀 다른 경험이다.</p>
<p>이 밖에도 무기의 형태는 다양할 수 있다.</p>
<p>특정 도메인을 깊이 이해하고 있을 수도 있고, 특정 기술을 깊이 파고들었을 수도 있다. 반복되는 업무를 자동화해 불필요한 시간을 줄인 경험도 좋은 무기가 될 수 있다.</p>
<p>중요한 것은 무엇을 했느냐가 아니라, 그 경험을 통해 무엇을 증명할 수 있느냐다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/3f5a5729-a8ee-498e-80ae-fba823117040/image.png" alt=""></p>
<p>구구절절 설명했지만, 결국 나의 목표는 나만의 ‘무기’를 갖는 것이다.</p>
<p>내년 상반기까지 다음 두 가지 목표를 달성해보려고 한다.</p>
<ul>
<li><p>실제 사용자가 있는 서비스를 만들고 운영하기</p>
</li>
<li><p>오픈소스 프로젝트에 기여하기</p>
</li>
</ul>
<p>서비스를 만들기만 하고 끝내는 것이 아니라, 실제 사용자를 만나고 운영 과정에서 발생하는 문제까지 경험해보고 싶다.</p>
<p>오픈소스에도 작은 수정부터 기여하면서 다른 사람들과 함께 코드를 만들고, 프로젝트에 나의 코드를 남겨보고 싶다.</p>
<p>물론 이 두 가지 목표를 달성한다고 해서 곧바로 대체 불가능한 개발자가 되는 것은 아닐 것이다.</p>
<p>그래도 누군가에게 나를 소개할 때 “저는 개발을 잘합니다”라는 막연한 말 대신, 내가 어떤 문제를 만났고 어떻게 해결했는지를 보여줄 수 있을 것이다.</p>
<p>그리고 누군가가 나에게 다시 개발을 잘하려면 어떻게 해야 하느냐고 묻는다면, 그때는 이렇게 답하고 싶다.</p>
<p>“나만이 보여줄 수 있는 경험을 만들어야 해.” 라고.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CS] 프로세스와 스레드, 코드로 직접 확인해봤다]]></title>
            <link>https://velog.io/@yongmin_lee/CS-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EC%8A%A4%EB%A0%88%EB%93%9C-%EC%BD%94%EB%93%9C%EB%A1%9C-%EC%A7%81%EC%A0%91-%ED%99%95%EC%9D%B8%ED%95%B4%EB%B4%A4%EB%8B%A4</link>
            <guid>https://velog.io/@yongmin_lee/CS-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EC%8A%A4%EB%A0%88%EB%93%9C-%EC%BD%94%EB%93%9C%EB%A1%9C-%EC%A7%81%EC%A0%91-%ED%99%95%EC%9D%B8%ED%95%B4%EB%B4%A4%EB%8B%A4</guid>
            <pubDate>Sun, 13 Sep 2026 16:23:46 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>프로세스 vs 스레드, 멀티프로그래밍/멀티태스킹/멀티프로세싱 개념 자체는 이미 정리가 잘 된 글이 많다. 그래서 이 글에서는 개념 설명은 최소화하고, 대신 자바 코드를 직접 돌려서 나온 결과로 &quot;왜 그런지&quot;를 확인하는 데 집중하려고 한다. 개념이 더 궁금하다면 <a href="https://inpa.tistory.com/entry/%F0%9F%91%A9%E2%80%8D%F0%9F%92%BB-multi-programming-tasking-processing">이 글</a>을 참고하면 좋다.</p>
</blockquote>
<h2 id="프로세스와-스레드-정의부터-다시">프로세스와 스레드, 정의부터 다시</h2>
<p>프로세스는 실행 중인 프로그램 그 자체다. OS로부터 독립된 메모리 공간(Code, Data, Heap, Stack)을 통째로 할당받는다.
<img src="https://velog.velcdn.com/images/yongmin_lee/post/2c02e58b-34a2-4199-9f9a-acd28d4cd023/image.png" alt=""></p>
<p>스레드는 그 프로세스 안에서 실제로 실행되는 흐름의 단위다. 하나의 프로세스는 여러 개의 스레드를 가질 수 있고, 이 스레드들은 자신을 만들어준 프로세스의 메모리 공간을 그대로 나눠 쓴다.
<img src="https://velog.velcdn.com/images/yongmin_lee/post/33c99e0c-384c-4f8e-9f19-c7d4be143a1b/image.png" alt=""></p>
<p>말로만 하면 &quot;그렇구나&quot; 하고 넘어가기 쉬운데, 이게 실제로 무슨 의미인지는 코드를 돌려보면 훨씬 명확해진다.</p>
<h2 id="메모리-구조로-보면-codedataheap은-공유-stack은-개별">메모리 구조로 보면: Code/Data/Heap은 공유, Stack은 개별</h2>
<p>프로세스의 메모리 공간은 보통 Code, Data, Heap, Stack 네 영역으로 나눈다. 한 프로세스 안의 여러 스레드는 이 중 <strong>Code, Data, Heap을 공유</strong>하고, <strong>Stack만 스레드마다 따로</strong> 가진다. 말로는 간단한데, &quot;공유한다&quot;는 게 실제로 어떤 부작용을 낳는지가 핵심이다. 직접 코드로 확인해봤다.</p>
<h3 id="heap이-공유된다는-증거-동기화-없이-값이-어긋난다">Heap이 공유된다는 증거: 동기화 없이 값이 어긋난다</h3>
<p>50개의 스레드가 <code>static int</code> 필드 하나를 동기화 없이 각자 50만 번씩 증가시키는 코드를 돌려봤다.</p>
<pre><code class="language-java">public class SharedMemoryDemo {

    static int sharedCounter = 0; // 모든 스레드가 공유하는 힙 영역의 필드

    public static void main(String[] args) throws InterruptedException {
        int threadCount = 50;
        int incrementsPerThread = 500_000;
        int expected = threadCount * incrementsPerThread;

        List&lt;Thread&gt; threads = new ArrayList&lt;&gt;();
        for (int i = 0; i &lt; threadCount; i++) {
            Thread t = new Thread(() -&gt; {
                for (int j = 0; j &lt; incrementsPerThread; j++) {
                    sharedCounter++; // 동기화 없이 공유 변수 증가
                }
            });
            threads.add(t);
            t.start();
        }
        for (Thread t : threads) t.join();

        System.out.println(&quot;기대값: &quot; + expected);
        System.out.println(&quot;실제 결과값: &quot; + sharedCounter);
        System.out.println(&quot;차이: &quot; + (expected - sharedCounter));
    }
}</code></pre>
<p>실행 결과는 이렇게 나왔다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/81850070-9e53-4e81-ae66-bcb8723db251/image.png" alt=""></p>
<p>500,000회 × 50개 스레드니까 이론적으로는 2,500만이 나와야 하는데, 실제로는 약 600만 개의 증가 연산이 그냥 증발했다. <code>sharedCounter++</code>는 사실 &quot;읽기 → 더하기 → 쓰기&quot; 세 단계로 나뉘는 연산인데, 여러 스레드가 동시에 같은 힙 영역의 값을 읽었다가 서로의 쓰기를 덮어써버린 것이다.</p>
<p>여기서 중요한 건 숫자 자체가 아니라, <strong>이 레이스 컨디션이 재현된다는 사실 자체가 &quot;스레드는 힙을 공유한다&quot;는 것의 증거</strong>라는 점이다. 만약 스레드가 프로세스처럼 메모리를 완전히 분리해서 가지고 있었다면, 애초에 이런 값 손실은 일어날 수가 없다. (참고로 이 코드를 실행할 때마다 정확한 손실량은 달라진다. 레이스 컨디션은 스레드 스케줄링 타이밍에 따라 결과가 매번 다른 게 정상이다.)</p>
<h3 id="stack이-독립적이라는-증거-지역-변수는-절대-안-어긋난다">Stack이 독립적이라는 증거: 지역 변수는 절대 안 어긋난다</h3>
<p>이번엔 반대로, <code>static</code> 필드 대신 각 스레드 내부의 <strong>지역 변수</strong>를 증가시키도록 바꿔봤다. 그 외 조건(스레드 50개, 각각 50만 번 증가)은 동일하다.</p>
<pre><code class="language-java">public class StackIsolationDemo {

    public static void main(String[] args) throws InterruptedException {
        int threadCount = 50;
        int incrementsPerThread = 500_000;
        int[] results = new int[threadCount];

        List&lt;Thread&gt; threads = new ArrayList&lt;&gt;();
        for (int i = 0; i &lt; threadCount; i++) {
            final int idx = i;
            Thread t = new Thread(() -&gt; {
                int localCounter = 0; // 이 스레드의 스택 프레임에만 존재
                for (int j = 0; j &lt; incrementsPerThread; j++) {
                    localCounter++;
                }
                results[idx] = localCounter;
            });
            threads.add(t);
            t.start();
        }
        for (Thread t : threads) t.join();

        int sum = 0;
        for (int r : results) sum += r;
        System.out.println(&quot;기대값: &quot; + (threadCount * incrementsPerThread));
        System.out.println(&quot;실제 결과값(각 스레드 지역 변수 합): &quot; + sum);
    }
}</code></pre>
<p>결과는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/c6374984-8032-419e-9dfa-dfa266665947/image.png" alt=""></p>
<p>동기화 코드를 단 한 줄도 넣지 않았는데도 손실이 정확히 0이다. <code>localCounter</code>는 힙이 아니라 각 스레드의 <strong>스택 프레임</strong>에 저장되는 값이라, 다른 스레드가 물리적으로 접근할 방법이 없기 때문이다. 위의 <code>SharedMemoryDemo</code>와 코드 구조는 거의 동일한데 딱 &quot;어느 영역에 변수를 뒀느냐&quot;만 다르고, 그 결과가 이렇게 극명하게 갈린다는 게 이 데모의 포인트다.</p>
<h2 id="그래서-스레드가-프로세스보다-가볍다">그래서 스레드가 프로세스보다 가볍다</h2>
<p>Heap을 공유한다는 건 위에서 본 것처럼 위험 요소이기도 하지만, 동시에 스레드가 프로세스보다 훨씬 저렴한 이유이기도 하다.</p>
<h3 id="생성-비용을-직접-재봤다">생성 비용을 직접 재봤다</h3>
<p>프로세스를 하나 새로 만든다는 건 OS 입장에서 새로운 주소 공간을 통째로 세팅하고, Code/Data/Heap을 처음부터 다시 구성한다는 뜻이다. 반면 스레드는 이미 존재하는 프로세스의 주소 공간에 스택 하나만 얹으면 되니까 훨씬 싸다. 실제로 얼마나 차이가 나는지 궁금해서 재봤다.</p>
<pre><code class="language-java">public class ThreadVsProcessCreationDemo {

    public static void main(String[] args) throws Exception {
        int count = 30;

        long threadStart = System.nanoTime();
        Thread[] threads = new Thread[count];
        for (int i = 0; i &lt; count; i++) {
            threads[i] = new Thread(() -&gt; {});
            threads[i].start();
        }
        for (Thread t : threads) t.join();
        long threadEnd = System.nanoTime();

        long processStart = System.nanoTime();
        Process[] processes = new Process[count];
        for (int i = 0; i &lt; count; i++) {
            processes[i] = new ProcessBuilder(&quot;java&quot;, &quot;-version&quot;)
                    .redirectErrorStream(true)
                    .start();
        }
        for (Process p : processes) {
            p.getInputStream().readAllBytes();
            p.waitFor();
        }
        long processEnd = System.nanoTime();

        System.out.println(&quot;스레드 &quot; + count + &quot;개: &quot; + (threadEnd - threadStart) / 1_000_000 + &quot;ms&quot;);
        System.out.println(&quot;프로세스 &quot; + count + &quot;개: &quot; + (processEnd - processStart) / 1_000_000 + &quot;ms&quot;);
    }
}</code></pre>
<p>같은 macOS 환경(OpenJDK 25, Homebrew)에서 실행한 결과다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/239cee18-6b68-40b7-ad51-62f9526c5281/image.png" alt=""></p>
<p>물론 여기서 &quot;프로세스&quot;는 완전히 새 JVM을 띄우는 것까지 포함하기 때문에 순수한 OS 레벨의 <code>fork()</code> 비용만 비교한 건 아니다. 하지만 실무에서 자바 애플리케이션이 &quot;새로운 실행 흐름&quot;을 만들 때 실제로 마주치는 선택지가 정확히 이거다. 스레드를 하나 더 만들지, 아니면 별도 프로세스를 띄울지. 매번 60~100배 가까운 차이가 나는 걸 보면, 왜 실무에서 기본적으로 스레드를 먼저 고려하는지 체감이 된다.</p>
<h3 id="통신-비용-관점에서도-스레드가-싸다">통신 비용 관점에서도 스레드가 싸다</h3>
<p>생성 비용뿐 아니라 <strong>통신 비용</strong>도 차이가 크다. 스레드끼리는 같은 힙을 보고 있으니 그냥 같은 객체의 참조를 넘기기만 하면 데이터를 주고받은 것과 다름없다. 위의 <code>SharedMemoryDemo</code>에서 50개 스레드가 <code>sharedCounter</code> 하나를 공유할 수 있었던 것도 결국 이 덕분이다.
<img src="https://velog.velcdn.com/images/yongmin_lee/post/f0d6f3e6-d891-4c66-b07b-5950b2b3de9a/image.png" alt=""></p>
<p>반면 프로세스끼리는 메모리 공간이 완전히 분리돼 있어서 이렇게 직접 값을 주고받을 방법이 없다. 파이프, 소켓, 공유 메모리, 메시지 큐 같은 별도의 <strong>IPC(Inter-Process Communication)</strong> 메커니즘을 거쳐야 한다. 커널을 거쳐 데이터를 복사하는 과정이 끼기 때문에, 같은 프로세스 안에서 참조 하나 넘기는 것과는 비용 자체가 다른 급이다.
<img src="https://velog.velcdn.com/images/yongmin_lee/post/dc6e7fa3-a9d7-4bd2-b16b-cb971cd69a77/image.png" alt=""></p>
<h2 id="컨텍스트-스위칭-비용은-짧게만">컨텍스트 스위칭 비용은 짧게만</h2>
<p>스레드 전환이 프로세스 전환보다 싼 이유도 결국 같은 맥락이다. 프로세스를 전환하려면 MMU의 주소 변환 테이블을 통째로 바꿔야 하고, 그 과정에서 TLB(Translation Lookaside Buffer)도 상당 부분 무효화된다. 반면 같은 프로세스에 속한 스레드끼리 전환할 때는 주소 공간 자체가 그대로이기 때문에 이 비용이 들지 않는다. 레지스터나 스택 포인터 같은 실행 문맥만 바꿔치기하면 된다.</p>
<p>컨텍스트 스위칭 자체를 깊게 파고들면 글이 산으로 가기 때문에, 자세한 내용은 별도 글에서 다루기로 하고 여기서는 &quot;왜 스레드 전환이 더 싼가&quot; 정도만 짚고 넘어간다.</p>
<h2 id="실무에서는-언제-뭘-쓸까-nodejs-클러스터-모드-vs-자바의-스레드-모델">실무에서는 언제 뭘 쓸까?: Node.js 클러스터 모드 vs 자바의 스레드 모델</h2>
<p>이 차이는 실제 서버 아키텍처 선택에도 그대로 반영된다.</p>
<p><strong>Node.js</strong>는 기본적으로 싱글 스레드 이벤트 루프로 동작한다. 그런데 요즘 서버는 코어가 여러 개인 게 당연하니, 코어를 놀리지 않으려면 프로세스를 여러 개 띄우는 수밖에 없다. 그래서 Node.js는 <code>cluster</code> 모듈로 <strong>CPU 코어 개수만큼 워커 프로세스를 포크</strong>하고, 각 프로세스가 독립된 V8 힙과 이벤트 루프를 가진 채 포트 하나를 같이 리스닝하는 방식을 쓴다. 프로세스 간에는 메모리를 공유하지 못하니, 세션이나 캐시 같은 상태는 Redis 같은 외부 저장소로 빼는 게 사실상 필수가 된다.</p>
<p><strong>자바(예: 톰캣 같은 서블릿 컨테이너)</strong>는 반대로 하나의 JVM 프로세스 안에서 요청마다 <strong>스레드</strong>를 할당하는 모델을 쓴다. 여러 요청을 처리하는 스레드들이 같은 힙을 보기 때문에, 커넥션 풀이나 캐시 같은 자원을 별도의 IPC 없이 프로세스 내에서 자연스럽게 공유할 수 있다. 물론 그 대가로 위에서 본 것과 같은 동시성 버그(레이스 컨디션)에 항상 신경 써야 한다는 부담을 안고 간다.</p>
<p>결국 &quot;프로세스는 격리가 필요할 때, 스레드는 자원을 공유하면서 가볍게 병렬성을 얻고 싶을 때&quot;라는 선택 기준으로 정리할 수 있다. Node.js가 프로세스를 선택한 것도, 자바 웹 서버들이 스레드를 선택한 것도 각 언어의 런타임 특성(V8의 싱글 스레드 제약 vs JVM의 멀티스레드 지원) 위에서 나온 합리적인 결정인 셈이다.</p>
<h2 id="마치며">마치며</h2>
<p>이번 글에서는 프로세스와 스레드의 차이를 이론으로만 설명하지 않고, 직접 코드를 돌려서 확인해봤다.</p>
<ul>
<li>Heap은 공유된다 → 동기화 없이 공유 변수를 건드리면 값이 어긋난다 (<code>SharedMemoryDemo</code>)</li>
<li>Stack은 독립적이다 → 지역 변수는 동기화 없이도 절대 어긋나지 않는다 (<code>StackIsolationDemo</code>)</li>
<li>그래서 스레드 생성 비용이 프로세스보다 수십 배 싸다 (<code>ThreadVsProcessCreationDemo</code>)</li>
<li>이 메모리 구조 차이가 Node.js와 자바가 서로 다른 동시성 모델을 택한 이유로 이어진다</li>
</ul>
<p>다음 편에서는 이렇게 &quot;가볍다&quot;는 스레드도 요청마다 매번 새로 만들면 결국 비효율이 쌓인다는 문제를 짚고, 그 해법으로 워커 스레드와 스레드 풀(<code>ExecutorService</code>, <code>ThreadPoolExecutor</code>)을 다뤄볼 예정이다.</p>
<h3 id="참고-문헌">참고 문헌</h3>
<ul>
<li><a href="https://inpa.tistory.com/entry/%F0%9F%91%A9%E2%80%8D%F0%9F%92%BB-multi-programming-tasking-processing">👩‍💻 멀티 프로그래밍, 멀티 태스킹, 멀티 프로세싱 - Inpa Dev</a></li>
<li><a href="https://inpa.tistory.com/entry/%F0%9F%91%A9%E2%80%8D%F0%9F%92%BB-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%E2%9A%94%EF%B8%8F-%EC%93%B0%EB%A0%88%EB%93%9C-%EC%B0%A8%EC%9D%B4">https://inpa.tistory.com/entry/%F0%9F%91%A9%E2%80%8D%F0%9F%92%BB-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%E2%9A%94%EF%B8%8F-%EC%93%B0%EB%A0%88%EB%93%9C-%EC%B0%A8%EC%9D%B4</a></li>
<li><a href="https://www.youtube.com/watch?v=QmtYKZC0lMU">https://www.youtube.com/watch?v=QmtYKZC0lMU</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] 영속성 컨텍스트 — 지연 로딩 프록시가 어떻게 N+1을 유발하는가]]></title>
            <link>https://velog.io/@yongmin_lee/JPA-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8-%EA%B7%B8-%ED%8E%B8%EB%A6%AC%ED%95%A8%EC%9D%B4-%EC%96%B4%EB%96%BB%EA%B2%8C-N1%EC%9D%84-%EC%9C%A0%EB%B0%9C%ED%95%98%EB%8A%94%EA%B0%80</link>
            <guid>https://velog.io/@yongmin_lee/JPA-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8-%EA%B7%B8-%ED%8E%B8%EB%A6%AC%ED%95%A8%EC%9D%B4-%EC%96%B4%EB%96%BB%EA%B2%8C-N1%EC%9D%84-%EC%9C%A0%EB%B0%9C%ED%95%98%EB%8A%94%EA%B0%80</guid>
            <pubDate>Fri, 28 Aug 2026 09:47:05 GMT</pubDate>
            <description><![CDATA[<h2 id="1-entitymanager와-영속성-컨텍스트">1. <code>EntityManager</code>와 영속성 컨텍스트</h2>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/1396b8d4-31d8-424a-960a-a18307845cea/image.png" alt=""></p>
<p>JPA에서 엔티티를 다루는 진입점은 <code>EntityManager</code>다. <code>persist()</code>로 저장하고, <code>find()</code>로 조회하고, <code>remove()</code>로 삭제하는 — 사실상 JPA가 제공하는 모든 CRUD 동작이 이 인터페이스를 거친다. Spring Data JPA의 <code>JpaRepository</code>도 마법을 부리는 게 아니라, 내부적으로 <code>EntityManager</code>를 호출하는 코드를 대신 만들어주는 것뿐이다. <code>orderRepository.findById(id)</code>는 결국 <code>entityManager.find(Order.class, id)</code>를 감싼 것이다.</p>
<p><code>EntityManager</code>는 조회나 저장을 처리한 엔티티를 곧바로 잊어버리지 않고, 내부의 저장소에 담아서 계속 관리한다. 이 저장소가 <strong>영속성 컨텍스트(Persistence Context)</strong>다. <code>EntityManager</code>가 &quot;일을 처리하는 창구&quot;라면, 영속성 컨텍스트는 그 창구가 관리하는 &quot;엔티티 보관함&quot;에 가깝다 — <code>EntityManager</code> 하나는 정확히 하나의 영속성 컨텍스트를 가진다. 엔티티는 이 보관함과의 관계에 따라 상태가 나뉜다.</p>
<table>
<thead>
<tr>
<th>상태</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>비영속(New)</td>
<td><code>new</code>로 막 생성돼서 아직 영속성 컨텍스트가 모르는 상태</td>
</tr>
<tr>
<td>영속(Managed)</td>
<td>영속성 컨텍스트가 관리하고 있는 상태 — <code>save()</code>했거나 조회해온 엔티티</td>
</tr>
<tr>
<td>준영속(Detached)</td>
<td>한때 관리됐지만 영속성 컨텍스트에서 분리된 상태</td>
</tr>
<tr>
<td>삭제(Removed)</td>
<td>삭제하기로 표시된 상태</td>
</tr>
</tbody></table>
<p>이 글에서 다루는 편리함은 전부 <strong>영속(Managed)</strong> 상태의 엔티티에서만 일어난다. 엔티티가 영속성 컨텍스트 안에 있는 동안 벌어지는 일을 하나씩 보자.</p>
<h2 id="2-1차-캐시">2. 1차 캐시</h2>
<p>같은 트랜잭션 안에서 서로 다른 두 회원을 조회하고, 그중 하나를 다시 조회해보자.</p>
<pre><code class="language-java">@Transactional
void printMembers(Long member1Id, Long member2Id) {
    Member member1 = memberRepository.findById(member1Id).orElseThrow();
    Member member2 = memberRepository.findById(member2Id).orElseThrow();
    Member member1Again = memberRepository.findById(member1Id).orElseThrow();

    System.out.println(member1 == member1Again); // true
}</code></pre>
<p><code>member1</code>을 조회하는 첫 줄부터 순서대로 보자.</p>
<ol>
<li><code>(Member, member1Id)</code>를 키로 1차 캐시를 먼저 찾아본다 — 아직 아무것도 조회한 적이 없으니 캐시엔 없다.</li>
<li>캐시에 없으니 DB에 <code>SELECT</code> 쿼리를 날린다.</li>
<li>DB에서 가져온 결과로 엔티티를 만들어 <strong>1차 캐시에 저장</strong>한다.</li>
<li>이 엔티티를 반환한다.</li>
</ol>
<p>이제 캐시엔 <code>member1</code>에 대한 항목 하나가 채워져 있다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/ab5f3132-eb7d-40ca-92f3-8b73556b502f/image.png" alt=""></p>
<p>이 상태에서 <code>member2Id</code>로 조회하면 어떻게 될까. <code>member1</code>은 이미 캐시에 있지만 <code>member2</code>는 아직 한 번도 조회한 적이 없는 대상이다 — 캐시를 찾아보지만 없으니(1차 캐시에 없음), <code>member1</code> 때와 똑같이 DB에서 조회한 뒤 캐시에 새로 저장한다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/f1af036f-496d-4ace-8f03-f42a927868ba/image.png" alt=""></p>
<p>이제 캐시엔 <code>member1</code>과 <code>member2</code> 둘 다 채워져 있다. 여기서 마지막 줄, <code>member1Id</code>로 다시 조회(<code>member1Again</code>)하면 상황이 또 달라진다 — 1차 캐시에 이미 있는 항목이라 DB에 묻지 않고 캐시에 있는 그 엔티티를 곧바로 돌려준다. SQL 로그를 보면 이 세 번째 조회에서는 <code>SELECT</code> 쿼리가 나가지 않는다.</p>
<p><code>member1 == member1Again</code>이 <code>true</code>인 것도 이래서 성립한다 — 세 번째 조회가 새 객체를 만든 게 아니라, 첫 조회 때 캐시에 저장해둔 <strong>바로 그 객체 참조</strong>를 그대로 돌려준 것이기 때문이다. 같은 트랜잭션 안에서는 같은 ID를 가진 엔티티가 항상 동일성(identity)을 보장받는다.</p>
<h2 id="3-쓰기-지연-transactional-write-behind">3. 쓰기 지연 (Transactional Write-Behind)</h2>
<p><code>persist()</code>로 엔티티를 저장할 때도 1차 캐시와 비슷한 일이 벌어진다 — 저장 요청을 한다고 곧바로 <code>INSERT</code>가 나가지 않는다.</p>
<pre><code class="language-java">@Transactional
void createMembers() {
    Member memberA = new Member(&quot;memberA&quot;);
    Member memberB = new Member(&quot;memberB&quot;);

    memberRepository.save(memberA); // persist() 호출 — 그런데 INSERT는 아직 안 나간다
    memberRepository.save(memberB); // 이것도 마찬가지
    // 여기까지는 SQL이 한 줄도 실행되지 않았다
}</code></pre>
<p><code>save()</code>(내부적으로 <code>persist()</code>)를 호출하는 순간 <code>memberA</code>와 <code>memberB</code>는 영속 상태가 되어 1차 캐시에 등록된다. 그런데 이 두 엔티티에 대한 <code>INSERT</code> SQL은 곧바로 실행되는 대신, 영속성 컨텍스트 안의 또 다른 저장소인 <strong>쓰기 지연 SQL 저장소</strong>에 순서대로 쌓인다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/b117cb69-a9c2-4aa6-9fdf-82b2a6b2e7b1/image.png" alt=""></p>
<p>이렇게 쌓인 SQL은 트랜잭션이 커밋될 때 한꺼번에 처리된다.</p>
<ol>
<li><code>commit()</code>이 호출된다.</li>
<li>커밋에 앞서 영속성 컨텍스트가 <strong><code>flush</code></strong>를 수행한다 — 쓰기 지연 SQL 저장소에 쌓아둔 SQL(<code>INSERT A</code>, <code>INSERT B</code>, ...)을 순서대로 DB에 실제로 전송한다.</li>
<li>실제 DB 트랜잭션이 <code>commit</code>된다.</li>
</ol>
<p>이렇게 쓰기를 지연시키는 이유는 <strong>JDBC 배치(batch) 기능을 쓸 수 있게 하기 위해서다.</strong> <code>INSERT</code>마다 곧바로 DB로 보내면 매번 별도의 네트워크 왕복이 발생하지만, 여러 개를 모아뒀다가 한 번에 보내면(<code>hibernate.jdbc.batch_size</code> 설정) 왕복 횟수를 줄일 수 있다.</p>
<p>다만 이 최적화는 엔티티의 ID 생성 전략에 따라 갈린다. <code>GenerationType.IDENTITY</code>(DB의 auto-increment에 의존하는 방식)를 쓰면, <code>INSERT</code>를 실제로 실행해봐야만 ID를 알 수 있어서 1차 캐시에 등록하는 것조차 즉시 <code>INSERT</code>를 실행하지 않고는 불가능하다 — 그래서 <code>persist()</code>를 호출하는 순간 곧바로 <code>INSERT</code>가 나가버리고, 쓰기 지연은 사실상 무력화된다. 반면 <code>GenerationType.SEQUENCE</code>나 <code>TABLE</code> 전략은 DB로부터 ID를 미리 받아올 수 있어서 쓰기 지연과 배치가 온전히 작동한다.</p>
<h2 id="4-변경-감지-dirty-checking">4. 변경 감지 (Dirty Checking)</h2>
<p>이번엔 조회한 엔티티의 필드를 바꿔보자.</p>
<pre><code class="language-java">@Transactional
void updateOrderStatus(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.changeStatus(OrderStatus.SHIPPED); // 필드만 바꾼다
    // save()를 호출하지 않았다
}</code></pre>
<p><code>save()</code>를 한 번도 부르지 않았는데도, 메서드가 끝나고 트랜잭션이 커밋되는 시점에 <code>UPDATE &quot;order&quot; SET status = &#39;SHIPPED&#39; WHERE id = ?</code> 쿼리가 실제로 나간다.</p>
<p>영속성 컨텍스트는 엔티티를 조회해올 때 그 시점의 상태를 <strong>스냅샷</strong>으로 함께 저장해둔다. 그리고 트랜잭션이 커밋되기 직전(<code>flush</code> 시점)에, 지금 관리 중인 모든 엔티티를 이 스냅샷과 비교한다. 하나라도 다르면 그 차이만큼 <code>UPDATE</code> 쿼리를 만들어 날린다. 이게 변경 감지다 — 이전 글에서 본 <code>TransactionInterceptor</code>의 의사 코드를 다시 떠올려보면, <code>joinPoint.proceed()</code>가 끝나고 <code>transactionManager.commit(status)</code>를 부르는 바로 그 지점 직전에, 이 스냅샷 비교와 <code>flush</code>가 먼저 일어난다.</p>
<h2 id="5-영속성-컨텍스트는-트랜잭션과-생명을-같이한다">5. 영속성 컨텍스트는 트랜잭션과 생명을 같이한다</h2>
<p>1차 캐시도, 변경 감지도 트랜잭션이 열려 있는 동안에만 성립한다. <code>@Transactional</code>이 시작될 때 영속성 컨텍스트(정확히는 그걸 감싸는 <code>EntityManager</code>)도 함께 열리고, 트랜잭션이 끝나면(커밋되든 롤백되든) 영속성 컨텍스트도 함께 닫힌다.</p>
<p>이 생명주기를 놓치면 이전 글에서 다룬 프록시가 다시 함정이 된다. 지연 로딩은 이런 식으로 동작한다고 했다 — Hibernate가 <code>LAZY</code> 연관관계를 조회할 때 실제 엔티티 대신 프록시 객체를 돌려주고, 그 프록시의 필드에 처음 접근하는 순간에야 진짜 <code>SELECT</code> 쿼리를 날린다고. 그런데 그 &quot;진짜 <code>SELECT</code>를 날리는&quot; 동작은 영속성 컨텍스트가 살아있어야만 가능하다.</p>
<pre><code class="language-java">Order getOrder(Long orderId) {
    return orderRepository.findById(orderId).orElseThrow(); // 메서드 종료와 함께 트랜잭션도, 영속성 컨텍스트도 닫힌다
}

void printMemberName(Long orderId) {
    Order order = getOrder(orderId);
    System.out.println(order.getMember().getName()); // 여기서 예외가 난다
}</code></pre>
<p><code>getOrder</code>가 끝나면서 트랜잭션과 영속성 컨텍스트가 이미 닫혔는데, <code>printMemberName</code>에서 <code>order.getMember()</code>가 돌려준 프록시의 <code>getName()</code>을 그 이후에 호출했다. 프록시는 SELECT를 날리려 하지만, 그 SELECT를 실행해줄 영속성 컨텍스트(세션)가 이미 죽어 있다. 이때 <code>LazyInitializationException: could not initialize proxy - no Session</code>이 던져진다.</p>
<blockquote>
<p>참고로 Spring Boot의 기본 설정(<code>spring.jpa.open-in-view=true</code>)은 이 예외를 눈에 덜 띄게 만든다. <code>@Transactional</code>이 코드 어디에도 없어도, 요청이 컨트롤러에 도달하기 전에 <code>OpenEntityManagerInViewInterceptor</code>가 영속성 컨텍스트를 하나 미리 열어서 요청 스레드에 묶어둔다 — 트랜잭션을 시작하는 게 아니라 영속성 컨텍스트만 먼저 만들어두는 것이다. 이후 <code>findById</code> 같은 리포지토리 메서드가 실행되면 <code>JpaTransactionManager</code>는 이미 스레드에 바인딩된 이 영속성 컨텍스트를 재사용해서 짧은 트랜잭션 하나를 그 안에서 처리한다. 그래서 결과로 돌아온 엔티티는 요청이 끝날 때까지 계속 영속 상태로 남고, 지연 로딩도 그대로 작동한다.</p>
<p>반대로 <code>open-in-view=false</code>라면, <code>@Transactional</code>이 없는 Service에서 <code>findById</code>를 호출했을 때 그 리포지토리 메서드 하나를 위해 영속성 컨텍스트가 새로 생겼다가 메서드가 끝나는 즉시 닫힌다 — 반환된 엔티티는 이미 준영속 상태고, 이후 지연 로딩을 시도하면 곧바로 <code>LazyInitializationException</code>이 난다. 즉 영속성 컨텍스트의 생명주기는 반드시 &quot;트랜잭션 하나&quot;와 일치하는 게 아니라, <code>open-in-view</code> 설정에 따라 &quot;요청 전체&quot;로 늘어날 수도 있다.</p>
<p>다만 <code>open-in-view=true</code>는 &quot;View 계층까지 DB 커넥션을 붙들고 있는다&quot;는 별도의 트레이드오프를 안고 있다.</p>
</blockquote>
<p>여기서 자연스럽게 의문이 하나 생긴다. <code>@PersistenceContext</code>로 주입받는 <code>EntityManager</code>는 보통 싱글톤 빈의 필드 하나에 딱 한 번만 주입되는데, 트랜잭션마다 다른 영속성 컨텍스트를 가리켜야 한다면 이게 어떻게 가능할까?</p>
<p>중요한 사실은 다음과 같다.— <strong>주입되는 <code>EntityManager</code>도 사실 프록시다.</strong> 
Spring이 <code>@PersistenceContext</code>로 주입하는 건 실제 <code>EntityManager</code> 구현체가 아니라, <code>EntityManager</code> 인터페이스를 구현한 JDK Dynamic Proxy(<code>SharedEntityManagerCreator</code>가 만든다)다. 이 프록시의 메서드가 호출될 때마다, 지금 실행 중인 스레드에 진행 중인 트랜잭션이 있는지 찾아서 그 트랜잭션에 바인딩된 진짜 <code>EntityManager</code>로 위임한다.</p>
<p>그래서 코드에서는 하나의 <code>EntityManager</code> 필드를 계속 재사용하는 것처럼 보이지만, 실제로는 요청이 올 때마다 그 뒤에서 다른 진짜 <code>EntityManager</code>(= 다른 영속성 컨텍스트)로 매번 갈아 끼워지고 있는 것이다. 클라이언트는 프록시 하나만 들고 있고, 그 뒤에서 실제로 일하는 객체는 상황에 따라 바뀐다.</p>
<p>이 사실은 자연스럽게 다음 질문으로 이어진다 — Controller → Service → Repository로 이어지는 흐름에서, 이 <code>EntityManager</code>(영속성 컨텍스트)는 API 요청 하나마다 새로 만들어지는 걸까, 아니면 서버 전체가 하나를 공유하는 걸까?</p>
<p><strong>답은 요청(정확히는 트랜잭션)마다 새로 만들어진다는 것이다.</strong> 서버 전체에서 하나를 공유한다면, 동시에 들어온 요청끼리 같은 1차 캐시와 관리 대상 엔티티를 공유하게 되고, 애초에 <code>EntityManager</code>는 스레드 세이프하지 않아서 여러 요청(= 여러 스레드)이 동시에 하나를 건드리는 순간 데이터가 깨진다. 그래서 서버 전체에서 공유되는 건 <code>EntityManager</code>가 아니라, 그걸 찍어내는 <code>EntityManagerFactory</code>뿐이다 — 커넥션 풀 설정과 매핑 메타데이터를 들고 있는 무거운 객체라 애플리케이션 시작 시 딱 한 번만 만들어지는 진짜 싱글톤이다.</p>
<p>실제 요청 하나를 따라가 보면 이렇다.</p>
<ol>
<li>요청이 스레드 하나에서 시작된다.</li>
<li><code>@Transactional</code>이 붙은 Service 메서드가 프록시를 거쳐 호출된다.</li>
<li>지금 이 스레드에 진행 중인 트랜잭션이 없으니, <code>JpaTransactionManager</code>가 <code>EntityManagerFactory</code>에서 <strong>새 <code>EntityManager</code>를 하나 만들어 이 스레드에 바인딩</strong>한다.</li>
<li>Service가 주입받은 Repository의 <code>EntityManager</code>(프록시)는 호출될 때마다 &quot;지금 이 스레드에 바인딩된 진짜 <code>EntityManager</code>가 뭐지?&quot;를 찾아서 3번의 인스턴스로 위임한다.</li>
<li>그래서 이 요청 안에서 Repository를 여러 번 호출해도(주문 조회, 결제 저장 등) 전부 같은 <code>EntityManager</code>를 쓰게 되고, 그 덕분에 1차 캐시가 같은 요청 안에서는 통한다.</li>
<li>Service 메서드가 끝나면 트랜잭션이 커밋되고, 이때 만든 <code>EntityManager</code>도 함께 닫히면서 스레드 바인딩이 풀린다.</li>
<li>완전히 새로운 요청이 오면 2~6번이 처음부터 다시 일어난다 — 이전 요청의 흔적은 전혀 남지 않는다.</li>
</ol>
<p>한 가지 예외는 이전 글에서 다룬 전파(propagation)다. 이미 트랜잭션이 진행 중인 스레드에서 <code>REQUIRED</code>(기본값)인 다른 <code>@Transactional</code> 메서드가 호출되면, 새 <code>EntityManager</code>를 만드는 게 아니라 이미 있는 걸 그대로 참여해서 쓴다. &quot;새로 만드느냐&quot;를 가르는 기준은 &quot;요청 하나냐&quot;가 아니라, 정확히는 &quot;지금 이 스레드에 이미 트랜잭션이 있느냐&quot;다.</p>
<h2 id="6-이-편리함의-진짜-대가--지연-로딩-프록시가-만드는-n1-문제">6. 이 편리함의 진짜 대가 — 지연 로딩 프록시가 만드는 N+1 문제</h2>
<p>지연 로딩이 프록시로 동작한다는 걸 알고 나면, 왜 N+1 문제가 생기는지도 같은 원리로 설명된다. 지금까지 예시로 써온 <code>Order</code>와 <code>Member</code>가 정확히 어떤 관계인지부터 보자.</p>
<pre><code class="language-java">@Entity
class Order {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY) // 주문 하나는 회원 하나에 속한다 — 그런데 즉시 로딩하지 않는다
    @JoinColumn(name = &quot;member_id&quot;)
    private Member member;

    // ...
}

@Entity
class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    // ...
}</code></pre>
<p><code>Order</code>가 <code>Member</code>를 <code>@ManyToOne(fetch = FetchType.LAZY)</code>로 참조하고 있다. 이 한 줄이 <code>order.getMember()</code>가 실제 <code>Member</code>가 아니라 프록시를 돌려주는 이유다 — <code>Order</code>를 조회할 때 연관된 <code>Member</code>까지 즉시 함께 가져오지 않고, <code>member</code> 필드에 처음 접근하는 순간까지 조회를 미루겠다는 선언이다. 먼저 <code>member</code> 필드가 정말 프록시인지부터 직접 확인해보자.</p>
<pre><code class="language-java">Order order = orderRepository.findById(orderId).orElseThrow();

System.out.println(order.getMember().getClass());
// class com.example.Member$HibernateProxy$rvxq7T3g  ← 진짜 Member가 아니다

System.out.println(order.getMember().getName());
// 이 줄에 와서야 SELECT member ... WHERE id = ? 가 실행된다</code></pre>
<p><code>order.getMember()</code>를 호출한 시점엔 아직 <code>SELECT</code> 쿼리가 나가지 않는다. 대신 Hibernate가 즉석에서 만들어낸 프록시 클래스(<code>Member$HibernateProxy$...</code>)의 인스턴스가 돌아온다 — CGLIB 프록시와 정확히 같은 방식이다. <code>getClass()</code>를 찍어보면 이게 진짜 <code>Member</code>가 아니라는 게 바로 드러난다. 이 프록시는 <code>id</code> 정도만 채워진 껍데기이고, <code>getName()</code>처럼 실제 데이터가 필요한 필드에 처음 접근하는 순간에야 내부적으로 <code>SELECT</code>를 실행해서 자기 자신을 채운다(초기화).</p>
<p>이 프록시가 여러 개 모이면 어떻게 되는지 보자.</p>
<pre><code class="language-java">List&lt;Order&gt; orders = orderRepository.findAll(); // 쿼리 1번

for (Order order : orders) {
    System.out.println(order.getMember().getName()); // 주문 개수만큼 추가 쿼리
}</code></pre>
<p><code>orderRepository.findAll()</code>은 <code>Order</code> 목록을 가져오는 쿼리 딱 한 번만 날린다. 이때 각 <code>Order</code>가 들고 있는 <code>member</code> 필드는 방금 확인한 것과 마찬가지로 전부 초기화 안 된 프록시다. 문제는 그다음이다 — <code>for</code>문 안에서 <code>order.getMember().getName()</code>을 호출할 때마다, 그 프록시가 <strong>각자 따로</strong> 초기화되면서 <code>SELECT</code>를 날린다. <code>orders</code>가 100건이면 이 초기화가 100번 일어나서, 원래 1번이었던 쿼리가 총 101번이 된다. 이게 N+1이다 — 1번의 조회(1) 뒤에 연관 엔티티 개수(N)만큼의 추가 쿼리가 따라붙는다는 뜻에서 이런 이름이 붙었다.</p>
<p>여기서 영속성 컨텍스트가 다시 관여한다. <code>order.getMember()</code>로 아직 초기화되지 않은 프록시를 만들 때, Hibernate는 무조건 새 프록시를 찍어내지 않는다 — 먼저 영속성 컨텍스트(1차 캐시)에 <code>(Member, memberId)</code> 키가 이미 있는지부터 확인한다. 이 확인 결과에 따라 두 가지 경우로 갈린다.</p>
<p><strong><code>memberId</code>가 전부 다른 경우.</strong> <code>orders</code> 100건이 서로 다른 100명의 회원 것이라면, 어떤 <code>memberId</code>로 캐시를 찾아봐도 늘 없다. 그래서 매번 캐시 미스 → DB 조회 → 캐시 등록이 반복되고, 앞서 본 101번(1 + 100)이 그대로 나온다.</p>
<p><strong><code>memberId</code>가 겹치는 경우.</strong> 반대로 100건의 주문이 단골 회원 5명한테 몰려 있다면 얘기가 달라진다. 어떤 회원이 <strong>처음</strong> 등장할 때만 캐시에 없어서 DB 조회가 나가고, 그 프록시가 1차 캐시에 등록된다. <strong>같은 회원의 나머지 주문</strong>들이 <code>member</code> 필드를 초기화하려 할 때는 이미 등록된 그 프록시를 캐시에서 그대로 찾아 쓴다 — 캐시 히트라서 DB까지 갈 필요가 없다. 그래서 회원이 5명뿐이면 캐시 미스는 딱 5번만 나고, 나머지 95건은 추가 쿼리 없이 넘어간다. 실제 쿼리는 101번이 아니라 6번(1 + 5)이다.</p>
<p>즉 N+1의 &quot;N&quot;은 <code>Order</code>의 개수가 아니라 <strong>캐시 미스가 나는 횟수 = 서로 다른 <code>Member</code> ID의 개수</strong>다. 로우가 전부 다른 회원이면 최악의 경우(N = 로우 개수)가 그대로 나오고, 로우가 소수의 회원에게 몰려 있으면 1차 캐시가 나머지를 막아줘서 N이 그보다 훨씬 작아진다.</p>
<p>이 문제가 특히 눈에 안 띄는 이유는, 데이터가 적을 때는 성능 차이가 미미하기 때문이다. 로컬에서 테스트 데이터 5건으로 확인하면 쿼리 5번쯤 늘어나는 걸 대수롭지 않게 넘기기 쉽지만, 운영 데이터가 수천 건으로 늘어나는 순간 요청 하나가 수천 번의 쿼리를 만드는 문제로 바뀐다.</p>
<h3 id="해결-1--fetch-join">해결 1 — Fetch Join</h3>
<p>가장 직접적인 해결책은 연관된 엔티티를 처음부터 같은 쿼리로 함께 가져오는 것이다.</p>
<pre><code class="language-java">@Query(&quot;SELECT o FROM Order o JOIN FETCH o.member&quot;)
List&lt;Order&gt; findAllWithMember();</code></pre>
<p>이렇게 하면 <code>Order</code>와 <code>Member</code>를 조인한 결과를 한 번의 쿼리로 가져오고, <code>member</code> 필드엔 프록시 대신 실제 <code>Member</code> 엔티티가 즉시 채워진다. <code>for</code>문 안에서 <code>getMember()</code>를 아무리 호출해도 추가 쿼리가 나가지 않는다.</p>
<p>다만 fetch join은 만능이 아니다. 컬렉션(<code>List</code>, <code>Set</code>)을 두 개 이상 fetch join하면 카테시안 곱으로 결과가 불어나고, 페이징(<code>Pageable</code>)과 함께 컬렉션을 fetch join하면 하이버네이트가 페이징을 DB가 아니라 메모리에서 흉내 내려다 경고를 던진다. 그래서 &quot;1:N&quot; 컬렉션 fetch join은 한 번에 하나까지만 쓰고, 페이징이 필요한 경우엔 다른 방법을 써야 한다.</p>
<h3 id="해결-2--batchsize">해결 2 — <code>@BatchSize</code></h3>
<p>컬렉션 fetch join이 마땅치 않은 상황에서는 배치 크기를 지정해서 N번의 쿼리를 N보다 훨씬 적은 횟수로 묶을 수 있다.</p>
<pre><code class="language-java">@Entity
class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    private Member member;
    // ...
}</code></pre>
<p>이렇게 설정하면, <code>order.getMember()</code>가 처음 호출될 때 그 <code>Order</code> 하나의 <code>Member</code>만 조회하는 게 아니라 <strong>같은 영속성 컨텍스트 안에 있는 초기화 안 된 <code>Member</code> 프록시를 최대 100개까지 모아서</strong> <code>WHERE member_id IN (?, ?, ..., ?)</code> 한 번의 쿼리로 가져온다. <code>orders</code>가 100건이어도 추가 쿼리는 1번이면 끝난다. <code>hibernate.default_batch_fetch_size</code>를 전역 설정으로 걸어두면 엔티티마다 애너테이션을 붙이지 않아도 모든 지연 로딩에 이 방식이 적용된다.</p>
<p>fetch join처럼 쿼리를 완전히 하나로 합치지는 못하지만, 카테시안 곱이나 페이징 문제 없이 N+1을 사실상 없앨 수 있어서 컬렉션이 여러 개 얽힌 상황에서는 이쪽이 더 안전한 선택이 되는 경우가 많다.</p>
<h2 id="7-그렇다면-fetchtypeeager로-해결하면-안-될까">7. 그렇다면 <code>FetchType.EAGER</code>로 해결하면 안 될까?</h2>
<p>&quot;지연 로딩이 문제라면, 즉시 로딩(<code>EAGER</code>)으로 바꾸면 되지 않을까?&quot;라는 생각이 자연스럽게 든다.</p>
<pre><code class="language-java">@Entity
class Order {
    @ManyToOne(fetch = FetchType.EAGER) // LAZY 대신 EAGER
    @JoinColumn(name = &quot;member_id&quot;)
    private Member member;
    // ...
}</code></pre>
<p>결론부터 말하면 <strong>해결되지 않는다. 오히려 더 나빠지는 경우가 많다.</strong> 왜 그런지는 <code>EAGER</code>가 실제로 언제 join을 만들고, 언제 안 만드는지를 봐야 이해가 된다.</p>
<p><code>entityManager.find(Order.class, orderId)</code>처럼 <strong>ID로 단건 조회</strong>할 때는 <code>EAGER</code>가 실제로 <code>Order</code>와 <code>Member</code>를 조인한 쿼리 하나를 만들어준다. 이 경우만 보면 &quot;된다&quot;고 착각하기 쉽다.</p>
<p>문제는 <code>orderRepository.findAll()</code>처럼 <strong>JPQL이나 쿼리 메서드로 여러 건을 조회</strong>할 때다. 이 경우 하이버네이트는 <code>EAGER</code> 연관관계를 자동으로 join 해주지 않는다 — 작성한 JPQL을 그대로 실행할 뿐, 임의로 조인을 끼워 넣지 않기 때문이다. 대신 <code>Order</code> 목록을 조회한 직후, 각 <code>Order</code>의 <code>member</code>를 채우기 위한 추가 <code>SELECT</code>를 <strong>즉시</strong> 실행한다.</p>
<pre><code class="language-java">List&lt;Order&gt; orders = orderRepository.findAll();
// 이 한 줄 안에서 이미 추가 쿼리가 전부 나간다 — for문을 돌기도 전에</code></pre>
<p><code>LAZY</code>였다면 <code>for</code>문에서 <code>getMember()</code>를 실제로 호출할 때까지 쿼리가 미뤄지기라도 했다. <code>EAGER</code>는 그 지연 자체를 없애버려서, <strong>연관 엔티티가 필요 없는 경우에도 무조건</strong> 추가 쿼리가 나간다. N+1 자체는 똑같이 발생하고, 오히려 &quot;안 쓸 수도 있으니 미룬다&quot;는 선택지만 사라지는 셈이다.</p>
<p>정리하면:</p>
<table>
<thead>
<tr>
<th></th>
<th>LAZY</th>
<th>EAGER</th>
</tr>
</thead>
<tbody><tr>
<td>단건 조회(<code>find</code>)</td>
<td>프록시만 반환, 접근 시 쿼리</td>
<td>조인 쿼리 1번으로 즉시 로딩</td>
</tr>
<tr>
<td>다건 조회(JPQL/쿼리 메서드)</td>
<td>접근 시에만 개별 초기화 (N+1 가능)</td>
<td><strong>조회 즉시</strong> 개별 초기화 (N+1 확정 발생)</td>
</tr>
</tbody></table>
<p>그래서 실무에서 통용되는 결론은 명확하다 — <strong>연관관계는 기본적으로 전부 <code>LAZY</code>로 두고, 정말로 함께 조회해야 하는 상황에서만 그때그때 Fetch Join이나 <code>@BatchSize</code>, <code>@EntityGraph</code>로 명시적으로 끌어온다.</strong> <code>EAGER</code>는 &quot;항상 같이 가져온다&quot;는 선택을 코드 전체, 모든 조회 경로에 무조건 강제하는 것이라, <code>member</code>가 필요 없는 화면·API에서도 매번 그 값을 로딩하는 낭비가 생긴다. 편리함을 기본값으로 깔아두는 대신, 필요한 지점에서 명시적으로 선언하는 쪽이 결국 더 안전하다.</p>
<h2 id="8-마치며">8. 마치며</h2>
<p>프록시 패턴에서 시작해서 이 글까지 결국 하나의 질문으로 이어져 있었다 — <strong>개발자가 매번 반복해서 챙겨야 했던 일을 프레임워크가 대신하게 하려면 어떻게 해야 하는가.</strong> 로깅은 AOP가, 트랜잭션 관리는 <code>@Transactional</code>이, 조회한 데이터와 DB 사이의 동기화는 영속성 컨텍스트가 떠맡았다. 방법은 매번 같았다 — 클라이언트와 실제 객체 사이에 대리인(프록시)을 세우고, 그 대리인이 개발자 대신 부가 작업을 처리하게 만드는 것.</p>
<p>이 편리함은 공짜가 아니다. 대리인이 정확히 언제 개입하고 언제 개입하지 못하는지 — 자기 호출은 프록시를 거치지 않는다는 것, 참여 중인 트랜잭션의 실패는 예외를 잡아도 되돌릴 수 없다는 것, 영속성 컨텍스트는 트랜잭션과 생명을 같이한다는 것 — 을 모르면 함정들을 하나씩 그대로 만나게 된다. 반대로 이 대리인의 동작 원리를 알고 나면, <code>@Transactional</code> 하나 붙이는 순간 무슨 일이 벌어지는지 처음부터 끝까지 설명할 수 있게 된다.</p>
<h3 id="참고-문헌">참고 문헌</h3>
<p><a href="https://bnzn2426.tistory.com/145">https://bnzn2426.tistory.com/145</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] @Transactional — AOP가 트랜잭션에서 실제로 어떻게 함정이 되는가]]></title>
            <link>https://velog.io/@yongmin_lee/Spring-Transactional-AOP%EA%B0%80-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%97%90%EC%84%9C-%EC%8B%A4%EC%A0%9C%EB%A1%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%A8%EC%A0%95%EC%9D%B4-%EB%90%98%EB%8A%94%EA%B0%80</link>
            <guid>https://velog.io/@yongmin_lee/Spring-Transactional-AOP%EA%B0%80-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%97%90%EC%84%9C-%EC%8B%A4%EC%A0%9C%EB%A1%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%A8%EC%A0%95%EC%9D%B4-%EB%90%98%EB%8A%94%EA%B0%80</guid>
            <pubDate>Thu, 27 Aug 2026 08:36:42 GMT</pubDate>
            <description><![CDATA[<p><code>@Transactional</code> 하나만 메서드에 붙이면 트랜잭션이 알아서 시작되고, 정상적으로 끝나면 커밋되고, 예외가 나면 롤백된다. 이게 얼마나 큰 편리함인지는 이 애너테이션이 없다고 가정하고 코드를 짜보면 바로 느껴진다. 그리고 이 편리함은 새로운 메커니즘에서 나오는 게 아니라, 지금까지 살펴본 프록시가 그대로 만들어내는 것이다 — 커스텀 애너테이션으로 로깅을 붙였던 것과 원리가 완전히 같다. 다만 이번엔 그 애너테이션이 스프링이 미리 만들어둔 <code>@Transactional</code>이고, Advice도 스프링이 미리 만들어둔 <code>TransactionInterceptor</code>라는 것만 다르다.</p>
<p>그런데 프록시 기반이라는 사실은 편리함만 주는 게 아니다. <code>@Transactional</code>도 결국 프록시인 이상, 프록시가 갖는 모든 제약을 그대로 물려받는다. 이 글은 먼저 그 편리함이 정확히 무엇을 대신해주는지 본 다음, 그 대가로 어떤 함정이 따라오는지를 순서대로 다룬다.</p>
<h2 id="1-transactional이-없다면">1. <code>@Transactional</code>이 없다면</h2>
<p><code>@Transactional</code>이 하는 일을 체감하는 가장 좋은 방법은 이게 없다고 가정하고 코드를 짜보는 것이다. 주문과 결제를 함께 저장하는 메서드가 있고, 둘 중 하나라도 실패하면 전부 되돌려야 한다고 하자.</p>
<pre><code class="language-java">void processOrder(Order order) {
    Connection conn = null;
    try {
        conn = dataSource.getConnection();
        conn.setAutoCommit(false);

        orderRepository.save(conn, order);
        paymentRepository.save(conn, new Payment(order));

        conn.commit();
    } catch (Exception e) {
        if (conn != null) {
            try {
                conn.rollback();
            } catch (SQLException rollbackEx) {
                log.error(&quot;롤백 실패&quot;, rollbackEx);
            }
        }
        throw e;
    } finally {
        if (conn != null) {
            try {
                conn.close();
            } catch (SQLException closeEx) {
                log.error(&quot;커넥션 반환 실패&quot;, closeEx);
            }
        }
    }
}</code></pre>
<p>이 메서드가 진짜 하려는 일 — &quot;주문을 저장하고 결제를 저장한다&quot; — 은 두 줄뿐이다. 나머지는 전부 커넥션을 얻고, 오토커밋을 끄고, 예외 시 롤백하고, 롤백 자체가 실패할 경우까지 처리하고, 커넥션을 반환하는 트랜잭션 관리 코드다. 그리고 이 패턴은 트랜잭션이 필요한 메서드마다 거의 그대로 반복된다 — 로깅이 클래스마다 흩어졌던 것과 똑같은 모양의 횡단 관심사다.</p>
<p><code>@Transactional</code>은 이 반복을 프록시 뒤로 숨긴다.</p>
<pre><code class="language-java">@Transactional
void processOrder(Order order) {
    orderRepository.save(order);
    paymentRepository.save(new Payment(order));
}</code></pre>
<p>이제 이 메서드에 남은 건 &quot;무엇을 저장할지&quot;라는 비즈니스 로직뿐이다. 커넥션을 언제 얻을지, 언제 커밋할지, 예외가 났을 때 롤백을 빼먹지 않았는지 같은 걱정은 프록시가 대신 떠맡는다. 개발자는 &quot;이 메서드는 원자적으로 실행되어야 한다&quot;는 의도만 애너테이션 하나로 선언하면 된다 — DB 접근의 구체적인 절차를 알 필요조차 없다.</p>
<h2 id="2-transactional의-실체">2. <code>@Transactional</code>의 실체</h2>
<p><code>TransactionInterceptor</code>가 정확히 하는 일을 걷어내고 핵심만 남기면 이렇게 생겼다.</p>
<pre><code class="language-java">// 실제 구현은 트랜잭션 전파(Propagation), 격리 수준 등을 더 다루지만
// 핵심 아이디어만 남기면 이런 Around Advice와 다르지 않다
public Object invoke(ProceedingJoinPoint joinPoint) throws Throwable {
    TransactionStatus status = transactionManager.getTransaction(txAttribute);
    try {
        Object result = joinPoint.proceed(); // 실제 메서드 실행

        transactionManager.commit(status);
        return result;
    } catch (Throwable ex) {
        if (isRollbackException(ex)) {
            transactionManager.rollback(status);
        } else {
            transactionManager.commit(status); // 롤백 대상이 아니면 그대로 커밋한다
        }
        throw ex;
    }
}</code></pre>
<p><code>proceed()</code> 앞에서 트랜잭션을 열고, 뒤에서 정상 반환이면 커밋, 예외가 던져지면 예외 종류를 보고 롤백할지 그냥 커밋할지를 결정한다. 지금까지 만든 로깅 Aspect의 <code>proceed()</code> 앞뒤에 시간을 재던 자리에, 이번엔 트랜잭션 시작/종료 코드가 들어가 있을 뿐이다.</p>
<p>이 구조를 알고 나면 두 가지 함정이 왜 생기는지가 자연스럽게 설명된다.</p>
<h2 id="3-함정-1--같은-클래스-안에서-호출하면-트랜잭션이-없다">3. 함정 1 — 같은 클래스 안에서 호출하면 트랜잭션이 없다</h2>
<p>프록시는 클라이언트가 프록시를 거쳐서 호출할 때만 동작한다. 그런데 같은 클래스 안에서 <code>this.otherMethod()</code>로 메서드를 호출하면, 이 호출은 프록시를 거치지 않고 원본 객체 안에서 곧바로 실행된다. <code>@Transactional</code>이 붙어 있어도 소용없다 — 애초에 <code>TransactionInterceptor</code>가 끼어들 지점(프록시)을 거치지 않았기 때문이다.</p>
<pre><code class="language-java">@Service
class OrderService {

    void placeOrder(Order order) {
        processOrder(order); // this.processOrder(order)와 같다 — 프록시를 거치지 않는다
    }

    @Transactional
    void processOrder(Order order) {
        orderRepository.save(order);
        paymentRepository.save(new Payment(order));

        if (order.isOverStock()) {
            throw new IllegalStateException(&quot;재고 부족&quot;);
        }
    }
}</code></pre>
<p>이 코드를 작성한 사람은 <code>processOrder</code>에 <code>@Transactional</code>이 붙어 있으니, 재고 부족으로 예외가 나면 <code>orderRepository.save()</code>와 <code>paymentRepository.save()</code>가 모두 롤백될 거라 기대한다. 그런데 <code>placeOrder</code>가 <code>processOrder</code>를 같은 클래스 안에서 호출했기 때문에 트랜잭션은 애초에 시작되지 않았다.</p>
<p>여기서 한 가지 더 — Spring Data JPA의 <code>save()</code>는 그 자체로 이미 <code>@Transactional</code>이 걸려 있는 메서드다(<code>SimpleJpaRepository</code> 구현체 기준). 그래서 <code>processOrder</code>에 감싸는 트랜잭션이 없어도 <code>orderRepository.save()</code>와 <code>paymentRepository.save()</code>는 각각 <strong>자기 자신만의 트랜잭션으로 개별적으로 즉시 커밋</strong>된다. 그 직후 재고 부족 예외가 터져도, 이미 커밋된 두 저장은 롤백할 방법이 없다. <code>@Transactional</code>을 붙였다고 믿었던 코드가 실제로는 트랜잭션 하나로 묶이지 않고, 저장 두 번이 따로따로 확정되고 있었던 것이다.</p>
<p>이 버그가 특히 고약한 이유는 에러가 하나도 안 난다는 데 있다. 컴파일도 되고, 실행도 되고, 심지어 대부분의 경우엔 결과도 정상처럼 보인다. 재고가 부족한 예외 케이스가 실제로 발생해야만, 그것도 롤백이 안 됐다는 걸 나중에 데이터를 들여다봐야만 알아챌 수 있다.</p>
<p>해결 방법은 프록시를 실제로 거치도록 만드는 것이다 — 가장 흔한 방법은 <code>processOrder</code>를 별도의 빈(예: <code>OrderProcessor</code>)으로 분리해서, <code>OrderService</code>가 그 빈을 주입받아 호출하게 만드는 것이다.</p>
<pre><code class="language-java">@Component
class OrderProcessor {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;

    OrderProcessor(OrderRepository orderRepository, PaymentRepository paymentRepository) {
        this.orderRepository = orderRepository;
        this.paymentRepository = paymentRepository;
    }

    @Transactional
    void process(Order order) {
        orderRepository.save(order);
        paymentRepository.save(new Payment(order));

        if (order.isOverStock()) {
            throw new IllegalStateException(&quot;재고 부족&quot;);
        }
    }
}

@Service
class OrderService {

    private final OrderProcessor orderProcessor;

    OrderService(OrderProcessor orderProcessor) {
        this.orderProcessor = orderProcessor;
    }

    void placeOrder(Order order) {
        orderProcessor.process(order); // 프록시(orderProcessor)를 통한 호출 — @Transactional이 정상적으로 걸린다
    }
}</code></pre>
<p>이제 <code>orderService.placeOrder(order)</code>를 호출하면, <code>placeOrder</code> 자체엔 <code>@Transactional</code>이 없으니 이 시점까진 아직 트랜잭션이 없다. 그런데 <code>orderProcessor.process(order)</code>를 호출하는 순간은 <code>OrderService</code>가 주입받은 <code>OrderProcessor</code> 프록시를 거치는 호출이다 — <code>this</code> 호출이 아니다. 그래서 <code>TransactionInterceptor</code>가 정상적으로 끼어들어, <code>@Transactional</code>의 기본 전파 옵션인 <code>Propagation.REQUIRED</code>에 따라 새 트랜잭션을 하나 연다.</p>
<p>여기서 트랜잭션 전파(propagation)가 함께 작동한다. <code>process</code> 메서드 안에서 실행되는 <code>orderRepository.save(order)</code>와 <code>paymentRepository.save(...)</code>도 각각 <code>@Transactional</code>이 걸려 있지만, 이번엔 상황이 다르다 — <code>REQUIRED</code>는 &quot;진행 중인 트랜잭션이 있으면 새로 열지 않고 거기 참여한다&quot;는 규칙이라, 두 <code>save()</code>는 이미 <code>process</code>가 열어둔 트랜잭션에 그대로 올라탄다. 각자 자기 트랜잭션을 새로 열고 즉시 커밋하던 이전과 달리, 이제 두 저장은 <code>process</code> 메서드 하나의 트랜잭션 경계 안에 함께 묶인다. 재고 부족 예외가 나면 <code>process</code>를 감싼 트랜잭션 전체가 롤백되고, 그 안에서 실행된 두 <code>save()</code>도 함께 롤백된다 — 애초에 기대했던 &quot;둘 다 저장되거나, 둘 다 안 되거나&quot;가 이제 실제로 보장된다.</p>
<h2 id="4-함정-2--체크-예외는-기본적으로-롤백되지-않는다">4. 함정 2 — 체크 예외는 기본적으로 롤백되지 않는다</h2>
<p>위에서 본 <code>invoke()</code> 의사 코드를 다시 보자. 예외가 던져지면 무조건 롤백하는 게 아니라, <code>isRollbackException(ex)</code>로 한 번 걸러낸다. 스프링의 기본 규칙은 이렇다 — <strong><code>RuntimeException</code>(언체크 예외)과 <code>Error</code>는 롤백하고, 체크 예외(<code>Exception</code>을 상속하지만 <code>RuntimeException</code>은 아닌 것)는 롤백하지 않고 그대로 커밋한다.</strong></p>
<pre><code class="language-java">@Transactional
void processOrder(Order order) throws InvalidOrderException {
    orderRepository.save(order);

    if (order.isOverStock()) {
        throw new InvalidOrderException(&quot;재고 부족&quot;); // 체크 예외
    }
}</code></pre>
<p><code>InvalidOrderException</code>이 체크 예외로 선언돼 있다면, <code>orderRepository.save(order)</code>는 <strong>커밋된 채로 남고</strong>, 그 위에서 예외만 호출한 쪽으로 던져진다. 이 코드를 작성한 사람이 &quot;예외가 나면 당연히 롤백되겠지&quot;라고 생각하고 있었다면, 저장은 이미 확정된 채로 예외 처리 로직만 별도로 실행되는 상황을 마주하게 된다.</p>
<p>이 규칙은 스프링이 임의로 정한 게 아니라 나름의 근거가 있다 — 체크 예외는 전통적으로 &quot;호출자가 처리할 수 있는, 복구 가능한 예외&quot;라는 의미로 설계되곤 했고, 언체크 예외는 &quot;예상치 못한, 복구 불가능한 상황&quot;을 뜻했다. 그래서 스프링은 &quot;복구 가능한 상황이면 지금까지의 작업은 유지하고, 복구 불가능한 상황일 때만 되돌린다&quot;는 쪽을 기본값으로 잡았다. 다만 실무에서는 이 구분을 의식하지 않고 예외를 던지는 경우가 훨씬 많아서, 기본값과 실제 의도가 어긋나는 사고로 이어지기 쉽다.</p>
<p>원하는 동작이 &quot;이 예외가 나면 무조건 롤백&quot;이라면 <code>rollbackFor</code>로 명시해야 한다.</p>
<pre><code class="language-java">@Transactional(rollbackFor = InvalidOrderException.class)
void processOrder(Order order) throws InvalidOrderException {
    // ...
}</code></pre>
<h2 id="5-함정-3--private-메서드에는-아예-걸리지-않는다">5. 함정 3 — <code>private</code> 메서드에는 아예 걸리지 않는다</h2>
<p>이건 첫 번째 함정의 극단적인 경우다. <code>private</code> 메서드는 클래스 바깥에서 호출할 수 없으니 이 메서드로 들어오는 모든 호출은 애초에 <code>this</code>를 거치는 자기 호출일 수밖에 없다 — 프록시를 거칠 방법 자체가 없다. 여기에 더해 CGLIB는 대상 클래스를 상속해서 메서드를 오버라이드하는 방식으로 프록시를 만드는데, 자바의 <code>private</code> 메서드는 애초에 오버라이드가 불가능하다. 그래서 <code>private</code> 메서드에 <code>@Transactional</code>을 붙이면, 컴파일도 되고 실행도 되지만 트랜잭션은 단 한 번도 시작되지 않는다.</p>
<pre><code class="language-java">@Transactional
private void processOrder(Order order) { // 아무리 붙여도 무시된다
    orderRepository.save(order);
}</code></pre>
<h2 id="6-함정-4--참여-중인-트랜잭션의-예외는-잡아도-소용없다">6. 함정 4 — 참여 중인 트랜잭션의 예외는 잡아도 소용없다</h2>
<p>함정 1의 해결책으로 <code>OrderProcessor</code>를 분리하고 나면, 이번엔 다른 함정이 기다리고 있다. <code>OrderService.placeOrder()</code>에도 <code>@Transactional</code>을 붙이고, <code>orderProcessor.process()</code>가 던지는 예외를 잡아서 처리하고 넘어가려는 코드를 짜보자.</p>
<pre><code class="language-java">@Component
class OrderProcessor {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;

    OrderProcessor(OrderRepository orderRepository, PaymentRepository paymentRepository) {
        this.orderRepository = orderRepository;
        this.paymentRepository = paymentRepository;
    }

    @Transactional
    void process(Order order) {
        orderRepository.save(order);
        paymentRepository.save(new Payment(order));

        if (order.isOverStock()) {
            throw new IllegalStateException(&quot;재고 부족&quot;);
        }
    }
}

@Service
class OrderService {

    private final OrderProcessor orderProcessor;

    OrderService(OrderProcessor orderProcessor) {
        this.orderProcessor = orderProcessor;
    }

    @Transactional
    void placeOrder(Order order) {
        try {
            orderProcessor.process(order); // 재고 부족 시 IllegalStateException을 던진다
        } catch (IllegalStateException e) {
            log.warn(&quot;재고 부족, 기본 처리로 넘어감: {}&quot;, e.getMessage());
            // 예외를 잡았으니 여기서 정상적으로 커밋될 거라 기대한다
        }
    }
}</code></pre>
<p><code>process()</code>는 함정 1에서 만든 그대로다 — 재고가 부족하면 <code>IllegalStateException</code>을 던진다. 이 코드를 짠 사람은 그 예외를 <code>placeOrder</code>에서 잡았으니 트랜잭션은 별문제 없이 커밋될 거라 기대한다. 실행해보면 다르다.</p>
<pre><code>WARN  - 재고 부족, 기본 처리로 넘어감: 재고 부족
org.springframework.transaction.UnexpectedRollbackException:
    Transaction silently rolled back because it has been marked as rollback-only</code></pre><p>예외는 분명히 <code>catch</code> 블록에서 잡혔고 로그도 찍혔는데, <code>placeOrder</code>가 끝나고 커밋하려는 순간 <code>UnexpectedRollbackException</code>이 던져진다.</p>
<p>원인은 이렇다. <code>placeOrder</code>가 새 트랜잭션을 열고, <code>orderProcessor.process(order)</code>는 프록시를 거치는 호출이니 <code>Propagation.REQUIRED</code>에 따라 이미 열려 있는 그 트랜잭션에 <strong>참여</strong>한다. <code>process</code> 안에서 <code>IllegalStateException</code>(롤백 대상 예외)이 던져지면, <code>process</code>를 감싼 <code>TransactionInterceptor</code>가 이를 가로챈다. 그런데 이 트랜잭션은 <code>process</code>가 새로 연 게 아니라 참여만 한 것이라, 이 인터셉터에는 물리적으로 롤백을 실행할 권한이 없다. 대신 <strong>&quot;이 트랜잭션은 실패했다&quot;는 표시(rollback-only)만 남기고</strong> 예외를 그대로 위로 던진다.</p>
<p>이 예외가 <code>placeOrder</code>의 <code>catch</code> 블록에서 잡히는 건 그다음이다. 이미 rollback-only 표시가 끝난 뒤라, <code>placeOrder</code>가 아무 문제 없이 정상 종료해도 소용없다. <code>placeOrder</code>를 감싼 프록시가 커밋을 시도하는 순간 이 표시를 발견하고, 커밋 대신 롤백한 뒤 <code>UnexpectedRollbackException</code>을 던진다.</p>
<p><strong>즉 참여 중인 트랜잭션에서 한 번이라도 롤백 대상 예외가 발생하면, 그 예외를 어디서 잡든 트랜잭션 전체는 이미 죽은 상태로 확정된다.</strong> <code>catch</code>는 예외의 전파를 막을 뿐, 트랜잭션의 운명은 되돌리지 못한다.</p>
<p>해결책은 두 가지다.</p>
<ol>
<li><strong>예외를 참여 중인 메서드 안에서 완전히 처리하고 밖으로 던지지 않는다.</strong> <code>process()</code> 내부에서 재고 부족을 예외 대신 반환값이나 별도 처리로 다루면, <code>TransactionInterceptor</code>는 애초에 롤백 대상 예외를 볼 일이 없다.</li>
<li><strong><code>process()</code>를 <code>Propagation.REQUIRES_NEW</code>로 완전히 분리된 트랜잭션으로 만든다.</strong> 그러면 <code>process</code>의 실패가 <code>placeOrder</code>의 트랜잭션과 무관해진다. 다만 이건 &quot;두 저장을 하나의 트랜잭션으로 묶는다&quot;는 함정 1의 해결책 자체를 뒤집는 것이라, 애초에 원자성 보장이 정말 필요한 상황인지부터 다시 판단해야 한다.</li>
</ol>
<h2 id="7-마치며">7. 마치며</h2>
<p>함정 1~3은 원인이 하나로 모인다 — <strong><code>@Transactional</code>은 애너테이션이 아니라 프록시를 통한 호출에 반응한다.</strong> 애너테이션은 &quot;어디에 부가 기능을 붙일지&quot; 표시하는 이름표일 뿐, 실제로 그 부가 기능이 동작하려면 호출이 반드시 프록시를 거쳐야 한다. 이 전제를 놓치면 애너테이션은 붙어 있지만 아무 일도 하지 않는, 눈에 보이는 것과 실제 동작이 다른 코드가 만들어진다.</p>
<p>함정 4는 결이 다르다. 프록시를 제대로 거쳤고 트랜잭션도 정상적으로 시작됐는데도, <strong>여러 <code>@Transactional</code> 메서드가 하나의 물리적 트랜잭션에 참여(participate)한다는 사실 자체</strong>가 함정이 된다. 참여 중인 메서드의 실패는 예외를 누가, 어디서 잡든 상관없이 전체 트랜잭션을 오염시킨다 — <code>@Transactional</code>을 여러 계층에 걸쳐 쓸 때는 프록시를 제대로 거쳤는지뿐 아니라, 그 호출들이 결국 같은 트랜잭션을 공유하고 있다는 사실까지 함께 염두에 둬야 한다.</p>
<p>트랜잭션이 정상적으로 시작되고 나면, 그 안에서 벌어지는 일 — 같은 엔티티를 두 번 조회해도 같은 객체가 돌아오는 것, 필드 값만 바꿔도 알아서 UPDATE 쿼리가 나가는 것 — 은 전부 트랜잭션과 함께 열리는 <strong>영속성 컨텍스트</strong>의 몫이다. 다음 글에서는 그 안쪽을 들여다 볼 예정이다.</p>
<h3 id="참고-문헌">참고 문헌</h3>
<p><a href="https://techblog.woowahan.com/2606/">https://techblog.woowahan.com/2606/</a>
<a href="https://study-library.tistory.com/entry/Spring-Transactional-1">https://study-library.tistory.com/entry/Spring-Transactional-1</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] AOP 개념과 Spring AOP]]></title>
            <link>https://velog.io/@yongmin_lee/Spring-AOP-%EA%B0%9C%EB%85%90%EA%B3%BC-Spring-AOP</link>
            <guid>https://velog.io/@yongmin_lee/Spring-AOP-%EA%B0%9C%EB%85%90%EA%B3%BC-Spring-AOP</guid>
            <pubDate>Wed, 26 Aug 2026 16:06:09 GMT</pubDate>
            <description><![CDATA[<h2 id="1-횡단-관심사라는-문제">1. 횡단 관심사라는 문제</h2>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/403629e8-5206-42b1-8919-02f77e24be77/image.png" alt=""></p>
<p><code>OrderService</code>, <code>PaymentService</code>, <code>MemberService</code>가 있고, 셋 다 실행 시간을 로그로 남기고 싶다고 하자. 로깅이라는 요구사항 자체는 셋 모두에게 똑같이 적용되지만, 이 로깅 코드는 각 서비스의 &quot;진짜 하는 일&quot;(주문 처리, 결제, 회원 관리)과는 아무 상관이 없다.</p>
<pre><code class="language-java">class OrderService {
    void placeOrder(Order order) {
        long start = System.currentTimeMillis();
        // ... 주문 처리 로직
        log.info(&quot;placeOrder 소요시간: {}ms&quot;, System.currentTimeMillis() - start);
    }
}

class PaymentService {
    void pay(Order order) {
        long start = System.currentTimeMillis();
        // ... 결제 로직
        log.info(&quot;pay 소요시간: {}ms&quot;, System.currentTimeMillis() - start);
    }
}</code></pre>
<p>이런 코드를 <strong>핵심 관심사(Core Concern)</strong>와 <strong>횡단 관심사(Cross-cutting Concern)</strong>로 나눠 부른다. &quot;주문을 처리한다&quot;, &quot;결제를 한다&quot;는 각 클래스 고유의 핵심 관심사고, &quot;실행 시간을 잰다&quot;는 클래스 경계를 가로질러(cross-cutting) 여러 곳에 반복되는 부가 관심사다. 로깅, 트랜잭션, 보안, 캐싱이 전형적인 예다.</p>
<p>문제는 이 두 관심사가 코드 레벨에서는 물리적으로 뒤섞인다는(tangling) 점이다. <code>placeOrder</code> 메서드 하나를 열어보면 &quot;주문 처리&quot;와 &quot;시간 측정&quot;이 같은 메서드 본문에 나란히 앉아 있다. 그리고 이 로깅 코드는 대상 클래스 수만큼 반복된다(scattering).</p>
<p>JDK Dynamic Proxy/CGLIB는 &quot;프록시 클래스를 반복해서 만드는&quot; 문제는 풀었지만, &quot;이 메서드엔 로깅을 걸고 저 메서드엔 안 건다&quot;는 판단은 여전히 사람이 <code>InvocationHandler</code> 안에 조건문으로 써야 했다. AOP가 없애려는 게 바로 이 마지막 반복이다.</p>
<h2 id="2-aop의-핵심-개념">2. AOP의 핵심 개념</h2>
<p>AOP는 횡단 관심사를 하나의 모듈(Aspect)로 뽑아내고, 그 모듈을 &quot;어디에&quot; &quot;언제&quot; 적용할지를 코드가 아니라 선언으로 지정한다. 용어가 여러 개 나오는데, 하나씩 정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th>용어</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Aspect</strong></td>
<td>횡단 관심사를 모듈화한 것. Advice + Pointcut의 묶음</td>
</tr>
<tr>
<td><strong>Advice</strong></td>
<td>실제로 실행되는 부가 기능 코드 자체. &quot;언제&quot;(Before/After/Around) 실행되는지에 따라 종류가 나뉜다</td>
</tr>
<tr>
<td><strong>Pointcut</strong></td>
<td>Advice를 적용할 대상을 고르는 규칙. &quot;어떤 메서드에 적용할지&quot;를 표현식으로 지정</td>
</tr>
<tr>
<td><strong>JoinPoint</strong></td>
<td>Advice가 적용될 수 있는 실행 지점. Spring AOP에서는 <strong>메서드 실행</strong>이 유일한 JoinPoint다</td>
</tr>
<tr>
<td><strong>Target</strong></td>
<td>실제 부가 기능이 적용되는 원본 객체.</td>
</tr>
<tr>
<td><strong>Weaving</strong></td>
<td>Aspect를 Target에 실제로 적용해서 프록시를 만드는 과정</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/50be17f5-eae7-4f17-8506-b2d61e2a4f1d/image.png" alt=""></p>
<p>여기서 Spring AOP를 이해하는 데 제일 중요한 줄은 JoinPoint 항목이다. AOP의 원조 격인 AspectJ는 필드 접근, 객체 생성, 정적 초기화 블록까지 JoinPoint로 잡을 수 있다. 반면 <strong>Spring AOP는 프록시 기반이라 딱 하나, &quot;스프링 빈의 메서드 실행&quot;만 JoinPoint가 된다.</strong> 프록시는 결국 인터페이스(혹은 클래스)의 메서드 호출을 가로채는 구조이기 때문이다 — 필드 접근이나 <code>new</code> 호출은 애초에 프록시를 거치지 않으니 가로챌 방법이 없다.</p>
<h2 id="3-spring-aop도-결국-프록시다">3. Spring AOP도 결국 프록시다</h2>
<p>Spring AOP는 이론만 보면 새로운 개념 같지만, 실체는 의외로 단순하다. 스프링이 빈을 등록하는 과정에서 <code>BeanPostProcessor</code>(정확히는 <code>AnnotationAwareAspectJAutoProxyCreator</code>)가 끼어들어, 등록하려는 빈이 어떤 Aspect의 Pointcut과 일치하면 원본 빈 대신 그 빈을 감싼 프록시를 컨테이너에 등록한다. 이때 대상 클래스가 인터페이스를 구현하고 있으면 <strong>JDK Dynamic Proxy</strong>(같은 인터페이스를 구현하는 프록시 객체를 런타임에 만드는 방식), 인터페이스가 없으면 <strong>CGLIB</strong>(대상 클래스를 상속한 서브클래스를 런타임에 만들어 메서드를 오버라이드하는 방식)로 프록시를 만든다. 즉 컨테이너에서 어떤 빈을 꺼내 쓰든, 거기에 Aspect가 적용돼 있다면 실제로 손에 쥐는 건 원본 객체가 아니라 이 프록시다.</p>
<p>이 과정을 조금 더 구체적으로 뜯어보면 이렇다.</p>
<ol>
<li>컨테이너가 빈 정의를 읽고, 생성자를 호출해 객체를 인스턴스화한다. 이 시점의 객체는 아직 프록시가 아니라 순수한 원본(Target) 객체다.</li>
<li>필드·세터로 의존성을 주입한다.</li>
<li><code>@PostConstruct</code>나 <code>InitializingBean.afterPropertiesSet()</code> 같은 초기화 콜백이 실행된다 — 이것도 여전히 원본 객체 위에서 일어난다.</li>
<li>초기화가 끝난 직후 <code>BeanPostProcessor.postProcessAfterInitialization()</code>이 호출된다. <code>AnnotationAwareAspectJAutoProxyCreator</code>가 바로 이 인터페이스의 구현체다.</li>
<li>여기서 등록된 모든 Aspect의 Pointcut을 훑어, 방금 초기화가 끝난 이 빈의 메서드 중 하나라도 매칭되는 게 있는지 확인한다.</li>
<li>매칭되면 원본 객체 대신 그 객체를 감싼 프록시를 반환하고, 매칭되는 게 하나도 없으면 원본 객체를 그대로 반환한다.</li>
<li>컨테이너는 이 반환값을 그 빈의 최종 싱글톤 인스턴스로 등록한다. 이후 <code>@Autowired</code>로 이 빈을 주입받는 모든 곳은 원본이 아니라 이 프록시를 받는다.</li>
</ol>
<p>여기서 눈여겨볼 지점은 4번이다. 프록시로 바꿔치기되는 시점이 <strong>초기화가 다 끝난 뒤</strong>라는 것 — 즉 <code>@PostConstruct</code>가 실행되는 시점엔 아직 프록시가 존재하지 않는다. <code>@PostConstruct</code> 메서드 안에서 같은 빈의 다른(Advice가 걸린) 메서드를 <code>this</code>로 호출하면, 그 시점엔 프록시로 바꿔치기되기 전이라 예외 없이 원본 메서드가 그냥 실행된다 — 뒤에서 다룰 자기 호출(self-invocation) 문제가 <code>@PostConstruct</code> 안에서는 애초에 피할 방법도 없이 항상 일어나는 셈이다.</p>
<blockquote>
<p>참고로 Spring Boot 2.0부터는 인터페이스가 있어도 기본적으로 CGLIB를 우선 사용한다(<code>spring.aop.proxy-target-class=true</code>가 기본값). JDK Dynamic Proxy로 만들면 클라이언트가 인터페이스 타입으로만 빈을 주입받아야 하는데, 실무에서는 구체 클래스 타입으로 주입받는 경우가 흔해 혼란을 줄이기 위한 결정이다.</p>
</blockquote>
<p>로깅을 남기는 Aspect를 하나 만들어보자.</p>
<pre><code class="language-java">@Aspect
@Component
class LoggingAspect {

    @Around(&quot;execution(* com.example.order..*Service.*(..))&quot;)
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        log.info(&quot;[로그] {} 호출 시작&quot;, joinPoint.getSignature().getName());

        Object result = joinPoint.proceed(); // 실제 대상 메서드로 넘어가는 지점

        long elapsed = System.currentTimeMillis() - start;
        log.info(&quot;[로그] {} 호출 종료 - {}ms&quot;, joinPoint.getSignature().getName(), elapsed);
        return result;
    }
}</code></pre>
<p><code>joinPoint.proceed()</code>가 이 Advice에서 제일 중요한 한 줄이다. 프록시가 가로챈 호출을 실제 대상 메서드로 넘겨주는 지점으로, 이 호출을 기준으로 앞쪽 코드는 메서드 실행 &quot;전&quot;에, 뒤쪽 코드는 실행 &quot;후&quot;에 동작한다. <code>proceed()</code>를 호출하지 않으면 대상 메서드 자체가 아예 실행되지 않는다는 것도 함께 기억해두면 좋다.</p>
<p>여기서 눈여겨볼 건 &quot;어떤 메서드에 적용할지&quot;를 자바 코드로 하나하나 분기하지 않고 <code>execution(...)</code> 표현식 한 줄로 선언했다는 점이다. <code>com.example.order</code> 패키지 아래(<code>..</code>는 하위 패키지 포함) <code>Service</code>로 끝나는 클래스의 모든 메서드(<code>*</code>, 파라미터는 <code>..</code>로 아무거나)에 이 Advice가 적용된다. <code>OrderService</code>, <code>PaymentService</code>, <code>MemberService</code>를 감싸는 클래스를 각각 따로 만들 필요가 없다 — Pointcut 표현식 하나가 대상 클래스 전체를 커버한다. 즉 로깅이라는 관심사 하나가 대상 클래스 수만큼 반복해서 흩어지던 문제가 여기서 완전히 사라진다.</p>
<h2 id="4-execution-대신-커스텀-어노테이션으로-지정하기">4. execution 대신 커스텀 어노테이션으로 지정하기</h2>
<p><code>execution(* com.example.order..*Service.*(..))</code>는 편리해 보이지만 실무에서는 위험한 지점이 하나 있다. <strong>패키지 이름이나 클래스 이름 규칙에 그대로 의존한다는 것.</strong> <code>order</code> 패키지 밑에 있고 이름이 <code>Service</code>로 끝나는 모든 메서드에 로깅이 걸리는데, 리팩터링하면서 패키지 구조를 바꾸거나 <code>OrderService</code>를 <code>OrderFacade</code>로 이름을 바꾸면 어떻게 될까? Pointcut 표현식은 에러 하나 없이 그냥 더 이상 매칭되지 않는다. 로깅이 조용히 사라지는 것이다.</p>
<p>더 근본적인 문제도 있다. 이 표현식은 &quot;그 패키지, 그 이름 규칙에 해당하는 모든 메서드&quot;를 통째로 대상으로 삼는데, 실제로 로깅이 필요한 메서드는 그중 일부뿐일 수 있다. 패키지 위치나 이름 규칙만으로는 &quot;이 메서드는 대상이다/아니다&quot;를 세밀하게 표현할 수 없다.</p>
<p>이 문제를 해결하는 방법이 커스텀 어노테이션이다. &quot;이 메서드에 부가 기능을 붙이겠다&quot;는 의사를 메서드 이름이나 패키지 위치가 아니라, <strong>메서드 위에 직접 선언</strong>하는 것이다.</p>
<pre><code class="language-java">@Retention(RetentionPolicy.RUNTIME) // 런타임에 리플렉션으로 읽어야 하므로 필수
@Target(ElementType.METHOD)
public @interface LogExecutionTime {
}</code></pre>
<pre><code class="language-java">@Aspect
@Component
class LoggingAspect {

    @Around(&quot;@annotation(com.example.common.LogExecutionTime)&quot;)
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        log.info(&quot;[로그] {} 호출 시작&quot;, joinPoint.getSignature().getName());

        Object result = joinPoint.proceed();

        long elapsed = System.currentTimeMillis() - start;
        log.info(&quot;[로그] {} 호출 종료 - {}ms&quot;, joinPoint.getSignature().getName(), elapsed);
        return result;
    }
}</code></pre>
<pre><code class="language-java">class OrderService {

    @LogExecutionTime // 이 메서드만 명시적으로 로깅 대상이 된다
    void placeOrder(Order order) {
        // ... 주문 처리
    }

    void cancelOrder(Order order) {
        // 어노테이션이 없으니 로깅 대상이 아니다 — 패키지·이름 규칙과 무관
    }
}</code></pre>
<p><code>@annotation(...)</code>은 execution 표현식과 완전히 다른 기준으로 Pointcut을 판단한다. 패키지가 어디든, 클래스 이름이 뭐든 상관없다 — <strong>메서드 위에 이 어노테이션이 붙어 있는가</strong>만 본다. <code>OrderService</code>를 <code>OrderFacade</code>로 리네이밍해도, <code>order</code> 패키지를 <code>commerce</code>로 옮겨도 <code>@LogExecutionTime</code>이 붙어 있는 한 로깅은 그대로 걸린다. 그리고 어노테이션이 메서드 위에 직접 보이니, &quot;이 메서드엔 부가 기능이 붙어 있다&quot;는 사실이 코드를 읽는 사람에게도 명시적으로 드러난다 — execution 표현식은 Aspect 클래스 안에 숨어 있어서 <code>OrderService</code> 코드만 봐서는 로깅이 걸려 있는지 알 방법이 없는 것과 대조적이다.</p>
<p>물론 트레이드오프도 있다. execution 표현식은 기존 코드를 한 줄도 건드리지 않고 Aspect 쪽 설정만으로 적용 범위를 조절할 수 있지만, 커스텀 어노테이션은 대상이 되는 메서드마다 하나씩 직접 붙여야 한다. 대상이 수십, 수백 개라면 이 작업 자체가 번거로울 수 있다. 그래서 실무에서는 흔히 이렇게 나눠 쓴다 — <strong>&quot;거의 모든 곳에 무조건 적용해야 하는 관심사&quot;(예: 전역 예외 로깅)는 execution으로 넓게 잡고, &quot;일부 메서드에만 선택적으로 적용해야 하는 관심사&quot;(예: 특정 API의 실행 시간 측정, 특정 메서드의 캐싱)는 어노테이션으로 명시적으로 표시한다.</strong></p>
<blockquote>
<p>실제로 <code>@Transactional</code>, <code>@Cacheable</code>, <code>@Async</code> 같은 스프링 표준 애너테이션도 전부 이 방식이다 — 클래스나 메서드에 애너테이션을 붙이면, 스프링이 내부적으로 <code>@annotation(...)</code> 방식의 Pointcut으로 이를 인식해서 프록시를 통해 부가 기능을 적용한다. 지금까지 아무 생각 없이 붙여 쓰던 <code>@Transactional</code>이 사실은 이 장에서 만든 <code>@LogExecutionTime</code>과 똑같은 메커니즘으로 동작하고 있었던 셈이다.</p>
</blockquote>
<h2 id="5-advice의-종류">5. Advice의 종류</h2>
<p><code>@Around</code>는 Target 메서드 실행 전후를 모두 감싸는 가장 강력한 Advice지만, 항상 이게 필요한 건 아니다.</p>
<table>
<thead>
<tr>
<th>Advice</th>
<th>실행 시점</th>
</tr>
</thead>
<tbody><tr>
<td><code>@Before</code></td>
<td>Target 메서드 실행 <strong>전</strong></td>
</tr>
<tr>
<td><code>@AfterReturning</code></td>
<td>Target 메서드가 <strong>정상 반환</strong>한 후</td>
</tr>
<tr>
<td><code>@AfterThrowing</code></td>
<td>Target 메서드가 <strong>예외를 던진</strong> 후</td>
</tr>
<tr>
<td><code>@After</code></td>
<td>정상 반환이든 예외든 상관없이 <strong>항상</strong> (finally와 같다)</td>
</tr>
<tr>
<td><code>@Around</code></td>
<td>실행 전후를 모두 감싸며, <code>proceed()</code> 호출 여부와 시점을 직접 제어</td>
</tr>
</tbody></table>
<p><code>@Around</code>만 있으면 나머지 넷을 전부 표현할 수 있다. 그런데도 종류를 나눠둔 이유는 의도를 명확히 드러내기 위해서다. &quot;이 Advice는 Target을 감쌀 뿐 실행을 막거나 건너뛸 생각이 없다&quot;는 걸 <code>@Before</code>로 선언하면, <code>proceed()</code>를 빼먹어서 Target이 아예 실행되지 않는 실수(<code>@Around</code>에서 흔히 나는 실수)를 애초에 만들 수 없다.</p>
<h2 id="6-pointcut을-재사용하기">6. Pointcut을 재사용하기</h2>
<p>같은 Pointcut 표현식을 여러 Advice에서 쓴다면 <code>@Pointcut</code>으로 이름을 붙여 분리할 수 있다.</p>
<pre><code class="language-java">@Aspect
@Component
class LoggingAspect {

    @Pointcut(&quot;execution(* com.example.order..*Service.*(..))&quot;)
    private void orderServiceLayer() {} // 이름만 있는 빈 메서드 — Pointcut의 이름표 역할

    @Before(&quot;orderServiceLayer()&quot;)
    public void logBefore(JoinPoint joinPoint) {
        log.info(&quot;[로그] {} 호출 시작&quot;, joinPoint.getSignature().getName());
    }

    @AfterThrowing(pointcut = &quot;orderServiceLayer()&quot;, throwing = &quot;ex&quot;)
    public void logException(JoinPoint joinPoint, Exception ex) {
        log.warn(&quot;[로그] {} 예외 발생: {}&quot;, joinPoint.getSignature().getName(), ex.getMessage());
    }
}</code></pre>
<p>Pointcut을 이름으로 분리해두면 &quot;이 Aspect가 어디에 적용되는가&quot;라는 정보가 표현식 문자열 여러 곳에 중복되지 않고 한 곳에만 존재한다 — Aspect 안에서도 결국 관심사(적용 대상 vs 실행 로직)를 분리하는 셈이다.</p>
<h2 id="7-마치며--그런데-이것도-결국-프록시다">7. 마치며 — 그런데 이것도 결국 프록시다</h2>
<p>여기까지 보면 AOP는 &quot;프록시를 대신 만들어주고, 어디에 적용할지도 대신 챙겨주는&quot; 편리한 도구처럼 보인다. 실제로도 그렇다. 다만 <strong>&quot;편리하게 자동화됐다&quot;는 것과 &quot;프록시라는 사실 자체가 사라졌다&quot;는 건 다른 이야기다.</strong> Spring AOP가 아무리 선언적으로 보여도, 그 밑바닥은 여전히 JDK Dynamic Proxy 혹은 CGLIB로 만들어진 프록시일 뿐이다. 그리고 프록시는 &quot;클라이언트가 프록시를 거쳐서 Target을 호출할 때만&quot; 작동한다.</p>
<p>여기서 함정이 하나 생긴다. 만약 <code>OrderService</code> 안의 한 메서드가 같은 클래스의 다른 메서드를 <code>this.otherMethod()</code>로 직접 호출하면 어떻게 될까? 이 호출은 스프링 컨테이너가 등록해둔 프록시를 거치지 않는다 — <code>this</code>는 프록시가 아니라 프록시 뒤에 숨어 있는 원본 Target 객체이기 때문이다.</p>
<p><code>LoggingAspect</code>(<code>com.example.order</code> 패키지의 모든 <code>*Service</code> 메서드에 적용)가 걸려 있는 상태에서, <code>OrderService</code>를 이렇게 짰다고 하자.</p>
<pre><code class="language-java">@Service
class OrderService {

    void placeOrder(Order order) {   // ① 컨트롤러가 외부에서 호출
        validate(order);             // ② 같은 클래스 내부 호출 — this.validate(order)와 같다
        // ... 주문 처리
    }

    void validate(Order order) {     // Pointcut엔 매칭되지만, 프록시를 거쳐서 불리지 않는다
        if (order.getItems().isEmpty()) {
            throw new IllegalArgumentException(&quot;상품이 없습니다&quot;);
        }
    }
}</code></pre>
<pre><code class="language-java">// 컨트롤러 → orderService.placeOrder(order) 호출 결과
[로그] placeOrder 호출 시작
[로그] placeOrder 호출 종료 - 12ms
// validate()는 Pointcut 표현식엔 분명히 매칭되는데, 로그가 한 줄도 안 찍힌다</code></pre>
<p><code>placeOrder</code>는 컨트롤러가 프록시를 통해 호출했으니 ①에서 Advice가 정상적으로 걸린다. 하지만 ②의 <code>validate(order)</code>는 사실 <code>this.validate(order)</code>다 — 이미 프록시를 통과해서 Target 내부로 들어와 있는 상태이므로, 이 호출은 프록시를 다시 거치지 않고 곧장 원본 메서드로 간다. <code>validate</code>가 아무리 Pointcut 표현식에 정확히 매칭돼도 Advice는 조용히 무시된다. 에러가 나는 것도 아니고 컴파일도 실행도 멀쩡히 되기 때문에, 원인을 찾기가 특히 까다롭다.</p>
<p><code>private</code> 메서드는 이 문제가 한 단계 더 근본적인 형태로 나타나는 경우다. <code>private</code> 메서드는 애초에 클래스 바깥에서 호출할 수 없으니, 이 메서드로 들어오는 모든 호출은 구조적으로 <code>this</code>를 거치는 자기 호출일 수밖에 없다 — 프록시를 거칠 방법 자체가 존재하지 않는다. CGLIB 방식이라면 이유가 하나 더 붙는다. CGLIB는 Target 클래스를 상속한 서브클래스를 만들어 메서드를 오버라이드하는 방식인데, 자바에서 <code>private</code> 메서드는 애초에 상속되지 않으니 오버라이드도 불가능하다. 그래서 Pointcut 표현식으로 아무리 <code>private</code> 메서드를 정확히 지정해도, 프록시가 그 호출을 가로챌 물리적인 경로 자체가 없어 Advice는 항상 조용히 무시된다. <strong>AOP는 프록시가 대신 받아줄 수 있는 호출, 즉 외부에서 들어오는 <code>public</code> 메서드 호출에만 적용된다는 걸 보여주는 가장 분명한 사례다.</strong></p>
<p>이 자기 호출(self-invocation) 문제가 제일 자주, 그리고 제일 조용히 사고로 이어지는 곳이 <code>@Transactional</code>이다. &quot;분명히 <code>@Transactional</code>을 붙였는데 트랜잭션이 안 걸린다&quot;는 흔한 버그의 상당수가 바로 이 지점에서 시작된다. 다음 글에서는 AOP가 트랜잭션 관리에 실제로 어떻게 쓰이는지, 그리고 이 프록시의 한계가 <code>@Transactional</code>에서 어떤 함정으로 나타나는지를 다룰 예정이다.</p>
<h3 id="참고-문헌">참고 문헌</h3>
<p><a href="https://coding-factory.tistory.com/1169">https://coding-factory.tistory.com/1169</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[디자인 패턴] 프록시 패턴(Proxy Pattern)]]></title>
            <link>https://velog.io/@yongmin_lee/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-%ED%94%84%EB%A1%9D%EC%8B%9C-%ED%8C%A8%ED%84%B4Proxy-Pattern</link>
            <guid>https://velog.io/@yongmin_lee/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-%ED%94%84%EB%A1%9D%EC%8B%9C-%ED%8C%A8%ED%84%B4Proxy-Pattern</guid>
            <pubDate>Sun, 23 Aug 2026 13:45:04 GMT</pubDate>
            <description><![CDATA[<p>Spring으로 백엔드를 개발하다 보면 <code>@Transactional</code> 하나만 붙여도 트랜잭션이 알아서 시작되고 커밋·롤백된다. 어떻게 메서드에 애너테이션 하나 붙였을 뿐인데 그 안에 코드가 자동으로 끼워지는 걸까? 이 질문의 답 밑바닥에는 항상 하나의 구조가 있다 — <strong>프록시(Proxy)</strong>. AOP도, <code>@Transactional</code>도, 결국은 프록시라는 하나의 디자인 패턴 위에 지어진 집이다. 그래서 AOP를 제대로 이해하려면 프록시부터 짚고 넘어가고자 한다.</p>
<h2 id="1-프록시가-필요한-순간">1. 프록시가 필요한 순간</h2>
<p>클라이언트가 어떤 객체의 기능을 쓰고 싶을 때, 가장 단순한 방법은 그 객체에 직접 접근하는 것이다. 그런데 다음과 같은 상황에서는 &quot;직접 접근&quot;이 곤란해진다.</p>
<ul>
<li><p><strong>생성 비용이 큰 객체</strong>: 고해상도 이미지, 대용량 데이터 조회 결과처럼 만드는 데 시간이나 자원이 많이 드는 객체를, 정말 필요한 순간까지 만들지 않고 미루고 싶다.</p>
</li>
<li><blockquote>
<p>이런 무거운 객체를 필요 여부와 상관없이 전부 미리 만들어서 붙들고 있으면, 실제로는 안 쓰이는 것까지 메모리를 차지한다. 특히 서버 시작 시점처럼 짧은 시간에 여러 개를 한꺼번에 만들면 그 순간 힙 사용량이 급증해 OOM(Out Of Memory)으로 이어질 수 있다.</p>
</blockquote>
</li>
<li><p><strong>접근을 통제해야 하는 객체</strong>: 아무나 호출하면 안 되고, 권한이 있는 클라이언트만 실제 기능에 닿을 수 있어야 한다.</p>
</li>
<li><p><strong>호출 전후에 뭔가를 더 하고 싶은 경우</strong>: 로깅, 캐싱, 트랜잭션 시작/종료처럼 진짜 하려는 일(비즈니스 로직) 앞뒤에 부가적인 작업을 끼우고 싶다.</p>
</li>
</ul>
<p>세 상황의 공통점은 이거다 — <strong>클라이언트의 코드는 그대로 두고 싶다.</strong> 클라이언트가 &quot;이 객체는 사실 아직 안 만들어졌어요&quot;라거나 &quot;지금 권한 검사 중이에요&quot;라는 사정을 알 필요는 없다. 클라이언트는 여전히 똑같은 방식으로 호출하는데, 그 호출과 진짜 객체 사이에 누군가 끼어들어 이 사정들을 대신 처리해주면 된다 — 그 &quot;누군가&quot;가 프록시다.</p>
<h2 id="2-프록시-패턴의-구조">2. 프록시 패턴의 구조</h2>
<p>프록시 패턴의 핵심은 <strong>클라이언트가 진짜 객체와 대리인을 구분하지 못한다</strong>는 데 있다. 이게 가능한 이유는 프록시가 진짜 객체(RealSubject)와 똑같은 인터페이스(Subject)를 구현하기 때문이다.</p>
<pre><code class="language-java">interface MessageSender {
    void send(String message);
}

class SmsMessageSender implements MessageSender {
    public void send(String message) {
        System.out.println(&quot;[SMS 발송] &quot; + message);
    }
}</code></pre>
<p>클라이언트는 이렇게 쓴다.</p>
<pre><code class="language-java">MessageSender sender = new SmsMessageSender();
sender.send(&quot;주문이 완료되었습니다&quot;);</code></pre>
<p>클라이언트는 <code>MessageSender</code>라는 타입만 보고 코드를 짠다. 이 자리에 <code>SmsMessageSender</code>가 오든, 앞으로 만들 프록시가 오든 클라이언트 코드는 한 글자도 바뀌지 않는다 — 프록시 패턴이 성립하려면 이 조건이 반드시 지켜져야 한다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/61548673-5d77-4694-b224-c3611c8a4e5d/image.png" alt=""></p>
<h2 id="3-정적-프록시를-직접-만들어보기-그리고-그-한계">3. 정적 프록시를 직접 만들어보기, 그리고 그 한계</h2>
<p>가장 단순한 방법은 프록시 클래스를 직접 만드는 것이다. <code>MessageSender</code>를 호출하기 전후로 로그를 남기는 프록시를 만들어보자.</p>
<pre><code class="language-java">class LoggingMessageSenderProxy implements MessageSender {
    private final MessageSender target; // 진짜 객체(RealSubject)에 대한 참조

    LoggingMessageSenderProxy(MessageSender target) {
        this.target = target;
    }

    @Override
    public void send(String message) {
        long start = System.currentTimeMillis();
        System.out.println(&quot;[로그] send 호출 시작&quot;);

        target.send(message); // 실제 작업은 진짜 객체에게 위임

        long elapsed = System.currentTimeMillis() - start;
        System.out.println(&quot;[로그] send 호출 종료 - &quot; + elapsed + &quot;ms&quot;);
    }
}</code></pre>
<pre><code class="language-java">MessageSender sender = new LoggingMessageSenderProxy(new SmsMessageSender());
sender.send(&quot;주문이 완료되었습니다&quot;);
// 클라이언트는 여전히 MessageSender 타입만 사용한다 — 프록시가 끼어든 걸 전혀 모른다</code></pre>
<p>이렇게 직접 만든 프록시를 <strong>정적 프록시(Static Proxy)</strong>라고 부른다. <code>send</code> 메서드가 하나뿐이라 코드가 짧아 보이지만, 실제로는 두 가지 문제가 금방 드러난다.</p>
<p>첫째, 인터페이스에 메서드가 10개면 로깅 코드를 10번 복사해서 각 메서드마다 &quot;시작 로그 → 위임 → 종료 로그&quot;를 반복해서 써야 한다. 둘째, 이 로깅을 <code>OrderService</code>나 <code>PaymentService</code>에도 붙이고 싶다면, 그때마다 <code>LoggingOrderServiceProxy</code>, <code>LoggingPaymentServiceProxy</code>를 새로 만들어야 한다. <strong>&quot;로깅&quot;이라는 관심사 하나가 대상 클래스 수만큼 복제되는 것이다.</strong></p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/135dbfe7-aba4-4309-a000-33b34d717ab6/image.png" alt=""></p>
<h2 id="4-동적-프록시-1--jdk-dynamic-proxy">4. 동적 프록시 (1) — JDK Dynamic Proxy</h2>
<p>이 반복을 없애려면 질문을 바꿔야 한다 — &quot;프록시 클래스 자체를 자바가 런타임에 대신 만들어주면 안 될까?&quot; JDK Dynamic Proxy는 정확히 이걸 해준다.</p>
<p>핵심은 <code>InvocationHandler</code>다. 프록시의 어떤 메서드가 호출되든, 그 호출은 전부 <code>InvocationHandler.invoke()</code> 한 곳으로 모인다.</p>
<pre><code class="language-java">class LoggingInvocationHandler implements InvocationHandler {
    private final Object target;

    LoggingInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        long start = System.currentTimeMillis();
        System.out.println(&quot;[로그] &quot; + method.getName() + &quot; 호출 시작&quot;);

        Object result = method.invoke(target, args); // 리플렉션으로 실제 메서드 호출

        long elapsed = System.currentTimeMillis() - start;
        System.out.println(&quot;[로그] &quot; + method.getName() + &quot; 호출 종료 - &quot; + elapsed + &quot;ms&quot;);
        return result;
    }
}</code></pre>
<pre><code class="language-java">MessageSender target = new SmsMessageSender();
MessageSender proxy = (MessageSender) Proxy.newProxyInstance(
        MessageSender.class.getClassLoader(),
        new Class[]{MessageSender.class},
        new LoggingInvocationHandler(target)
);
proxy.send(&quot;주문이 완료되었습니다&quot;);</code></pre>
<p>이제 <code>LoggingMessageSenderProxy</code>라는 클래스를 직접 쓰지 않았다. <code>Proxy.newProxyInstance()</code>가 <code>MessageSender</code> 인터페이스를 구현하는 클래스를 런타임에 즉석에서 만들어내고, 그 인스턴스의 모든 메서드 호출은 <code>LoggingInvocationHandler</code> 하나로 다 처리된다 — 메서드가 몇 개든, 대상 클래스가 몇 개든 새로 만들어야 하는 건 <code>target</code>을 갈아 끼우는 것뿐이다.</p>
<p>다만 <code>Proxy.newProxyInstance</code>가 인터페이스 배열을 받는다는 것 자체가 이 방식의 제약을 말해준다. <strong>JDK Dynamic Proxy는 인터페이스 기반이라, 대상 클래스가 구현하는 인터페이스가 있어야만 동작한다.</strong> <code>OrderService</code>가 인터페이스 없이 클래스 하나로만 존재한다면, 이 방식으로는 프록시를 만들 수 없다.</p>
<h2 id="5-동적-프록시-2--cglib">5. 동적 프록시 (2) — CGLIB</h2>
<p>인터페이스가 없는 클래스에도 프록시가 필요할 때 쓰는 게 CGLIB다. JDK Dynamic Proxy가 인터페이스를 &quot;구현&quot;하는 방식이라면, CGLIB는 대상 클래스를 &quot;상속&quot;하는 서브클래스를 런타임에 만들어 메서드를 오버라이드하는 방식이다.</p>
<pre><code class="language-java">class OrderService { // 인터페이스가 없는 클래스
    void placeOrder() {
        System.out.println(&quot;[주문 처리]&quot;);
    }
}</code></pre>
<pre><code class="language-java">Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OrderService.class);
enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -&gt; {
    System.out.println(&quot;[로그] &quot; + method.getName() + &quot; 호출 시작&quot;);
    Object result = proxy.invokeSuper(obj, args); // 부모(진짜 객체)의 메서드 호출
    System.out.println(&quot;[로그] &quot; + method.getName() + &quot; 호출 종료&quot;);
    return result;
});

OrderService proxy = (OrderService) enhancer.create();
proxy.placeOrder();</code></pre>
<p>이 방식은 상속을 전제로 하기 때문에 명확한 제약이 따라온다. <code>OrderService</code>가 <code>final class</code>라면 애초에 상속이 불가능해 프록시를 만들 수 없다. 같은 이유로 개별 메서드가 <code>final</code>이면 그 메서드만 오버라이드가 안 되는데, 이때는 에러가 나는 게 아니라 <strong>프록시를 통한 가로채기가 조용히 무시된다.</strong> <code>final</code> 메서드에 <code>@Transactional</code>을 걸면 트랜잭션이 하나도 걸리지 않는데, 컴파일도 되고 실행도 되니 알아채기가 특히 어렵다.</p>
<h2 id="6-프록시의-쓰임새들">6. 프록시의 쓰임새들</h2>
<p>지금까지 만든 건 전부 &quot;호출 전후에 로그를 남기는&quot; 프록시였다. 그런데 프록시가 그 자리에서 하는 일이 로깅이 아니라 다른 것이어도 구조는 완전히 같다. 목적에 따라 이름이 붙을 뿐이다.</p>
<ul>
<li><strong>가상 프록시(Virtual Proxy)</strong>: 생성 비용이 큰 객체를 실제로 쓰이는 순간까지 미룬다. 사실 이건 낯선 개념이 아니다 — Hibernate가 <code>LAZY</code> 연관관계를 조회할 때 실제 엔티티 대신 프록시 객체를 돌려주고, 그 프록시의 필드에 처음 접근하는 순간에야 진짜 <code>SELECT</code> 쿼리를 날리는 것과 정확히 같은 구조다.</li>
<li><strong>보호 프록시(Protection Proxy)</strong>: 클라이언트의 권한을 먼저 확인하고, 통과해야만 진짜 객체로 위임한다.</li>
<li><strong>원격 프록시(Remote Proxy)</strong>: 네트워크 너머에 있는 객체를 로컬 객체처럼 다루게 해준다. 클라이언트 입장에서는 &quot;이게 원격 호출&quot;이라는 사실 자체가 프록시 뒤에 숨는다.</li>
<li><strong>캐싱 프록시</strong>: 이전 호출 결과를 저장해뒀다가, 같은 요청이 오면 진짜 객체를 부르지 않고 캐시된 값을 바로 돌려준다.</li>
<li><strong>로깅/모니터링 프록시</strong>: 지금까지 예제로 써온 바로 그것 — 호출 시간, 호출 여부, 예외 발생 여부를 기록한다.</li>
</ul>
<p>이름은 다섯 가지로 다르지만 구조는 하나다 — <strong>클라이언트는 인터페이스만 보고, 진짜 객체 앞단에서 뭔가(지연·검사·전송·캐싱·기록)를 대신 처리하는 대리인을 세운다.</strong></p>
<h2 id="7-마치며">7. 마치며</h2>
<p>프록시는 결국 &quot;클라이언트의 코드를 바꾸지 않고, 진짜 객체 앞단에서 뭔가를 더 하거나 걸러내는&quot; 구조다. 그런데 3~5번에서 본 것처럼 이걸 손으로 만들면(정적 프록시) 대상 클래스마다 새 프록시 클래스가 필요하고, 메서드 수만큼 위임 코드가 반복된다. JDK Dynamic Proxy와 CGLIB는 이 반복을 &quot;런타임에 프록시를 자동으로 생성하는&quot; 방식으로 없앴다.</p>
<p>그런데 아직 남은 질문이 있다. <code>InvocationHandler</code>나 <code>MethodInterceptor</code> 안에 &quot;로깅을 할지, 트랜잭션을 걸지, 권한을 검사할지&quot;를 여전히 코드로 직접 써야 한다. <strong>이걸 코드가 아니라 선언적으로 — &quot;이 패키지의 이 메서드들에는 이 부가 기능을 붙여라&quot;는 식으로 지정할 수 있게 만든 것이 AOP(Aspect-Oriented Programming)다.</strong> Spring은 이 선언을 보고 내부적으로 JDK Dynamic Proxy와 CGLIB 중 무엇을 쓸지도 알아서 고른다. AOP에 대해서는 다음 글에서 자세하게 다룰 예정이다.</p>
<h3 id="참고-문헌">참고 문헌</h3>
<p><a href="https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%ED%94%84%EB%A1%9D%EC%8B%9CProxy-%ED%8C%A8%ED%84%B4-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EC%9E%90">https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%ED%94%84%EB%A1%9D%EC%8B%9CProxy-%ED%8C%A8%ED%84%B4-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EC%9E%90</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[역할, 책임, 협력 관점에서 다시 본 SOLID 원칙(feat. 객체지향의 사실과 오해)]]></title>
            <link>https://velog.io/@yongmin_lee/%EC%97%AD%ED%95%A0-%EC%B1%85%EC%9E%84-%ED%98%91%EB%A0%A5-%EA%B4%80%EC%A0%90%EC%97%90%EC%84%9C-%EB%8B%A4%EC%8B%9C-%EB%B3%B8-SOLID-%EC%9B%90%EC%B9%99feat.-%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5%EC%9D%98-%EC%82%AC%EC%8B%A4%EA%B3%BC-%EC%98%A4%ED%95%B4</link>
            <guid>https://velog.io/@yongmin_lee/%EC%97%AD%ED%95%A0-%EC%B1%85%EC%9E%84-%ED%98%91%EB%A0%A5-%EA%B4%80%EC%A0%90%EC%97%90%EC%84%9C-%EB%8B%A4%EC%8B%9C-%EB%B3%B8-SOLID-%EC%9B%90%EC%B9%99feat.-%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5%EC%9D%98-%EC%82%AC%EC%8B%A4%EA%B3%BC-%EC%98%A4%ED%95%B4</guid>
            <pubDate>Sun, 23 Aug 2026 08:07:10 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/a4ddd4ab-0939-42a1-ba6d-9eb629f2d007/image.png" alt=""></p>
<p>최근 &#39;객체지향의 사실과 오해&#39;라는 책을 읽고, 객체지향에 대해서 다시 생각하게 되었다.</p>
<p>이전에는 &#39;객체지향 = 클래스로 객체 만들기&#39;라고 단순하게 생각했지만, 책을 읽고 객체지향의 중심이 클래스(Class)나 데이터(State)가 아니라, 객체들이 주고받는 협력(Collaboration), 역할(Role), 책임(Responsibility)에 있다는 것을 알게 되었다.</p>
<p>그 과정에서 &quot;자율적인 객체들이 건강하게 협력하기 위해 지켜야 할 최소한의 에티켓&quot;이라는 관점으로 SOLID 원칙을 다시 풀어보고자 한다.</p>
<h3 id="1-srp단일-책임-원칙-객체에게-감당할-수-있는-자율적인-책임을-부여하라">1. SRP(단일 책임 원칙): 객체에게 감당할 수 있는 자율적인 책임을 부여하라</h3>
<p>전통적으로 SRP는 &quot;클래스는 단 하나의 변경 이유만 가져야 한다&quot;는 문장으로 설명된다. 그런데 이 문장만으로는 &quot;책임&quot;이 정확히 무엇을 가리키는지, &quot;변경의 이유&quot;를 어떻게 알아채야 하는지가 손에 잘 잡히지 않았다. 협력의 관점에서 보면 이 원칙이 훨씬 선명해진다.</p>
<p>객체는 혼자 존재하지 않는다. 항상 어떤 협력 안에서 누군가로부터 메시지를 받고, 그 메시지에 응답할 책임을 진 채로 존재한다. 그런데 하나의 객체가 서로 무관한 여러 협력에 동시에 참여하면서 그 각각의 협력이 요구하는 책임을 전부 떠안고 있다면 어떻게 될까. 한 협력에서 일어난 변화가 그 객체와는 아무 상관 없어 보이던 다른 협력까지 흔들어놓는다. &quot;변경의 이유가 여러 개&quot;라는 건, 사실 이 객체가 서로 다른 협력에서 온 서로 다른 역할을 동시에 수행하고 있다는 신호다.</p>
<p>그래서 SRP를 다시 쓰면 이렇게 된다 — &quot;하나의 객체는 자신이 자율적으로 책임질 수 있는 만큼의 역할만 맡아야 한다.&quot; 여기서 핵심은 &quot;자율적으로&quot;다. 책임이 너무 크면 객체는 그 책임을 스스로 완결하지 못하고, 결국 누군가(다른 객체, 혹은 그 객체를 사용하는 클라이언트)가 이 객체의 내부 사정을 들여다보고 대신 판단해줘야 하는 상황이 생긴다. 캡슐화가 깨지는 순간이다. 반대로 책임이 적절히 나뉘어 있으면, 각 객체는 자기 몫의 협력 안에서 스스로 판단하고 스스로 행동할 수 있다 — 이것이 &quot;자율적인 객체&quot;다.</p>
<p>예를 들어 &quot;주문을 검증하고, 검증에 실패하면 알림을 보내는&quot; 객체가 있다고 하자. 언뜻 하나의 자연스러운 흐름처럼 보이지만, 사실은 &quot;주문 검증&quot;이라는 협력과 &quot;알림 발송&quot;이라는 협력이 우연히 한 객체 안에 동거하고 있는 것이다. 알림을 보내는 방식이 바뀔 때(이메일 → 푸시)도, 검증 규칙이 바뀔 때(정책 변경)도 이 객체는 똑같이 흔들린다.</p>
<pre><code class="language-java">// SRP를 위반한 코드 — &quot;검증&quot;과 &quot;알림 발송&quot;이라는 서로 다른 협력이 한 객체에 뒤섞여 있다
class OrderProcessor {

    void process(Order order) {
        if (order.getItems().isEmpty()) {
            sendEmail(order.getCustomerEmail(), &quot;주문 실패: 상품이 없습니다&quot;);
            return;
        }
        // ... 주문 처리
    }

    private void sendEmail(String to, String message) {
        // 이메일 발송 로직
    }
}</code></pre>
<p>검증 규칙이 하나 더 늘어도, 알림을 이메일에서 푸시로 바꿔도 결국 손대는 곳은 <code>OrderProcessor</code> 하나다 — 서로 다른 두 이유로 계속 같은 클래스가 열린다. 두 협력을 각각 책임지는 두 역할로 분리하면 이야기가 달라진다.</p>
<pre><code class="language-java">// 책임을 분리한 코드 — 각 객체가 자기 몫의 협력만 자율적으로 책임진다
class OrderValidator {
    boolean isValid(Order order) {
        return !order.getItems().isEmpty();
    }
}

class OrderNotifier {
    void notifyFailure(Order order) {
        sendEmail(order.getCustomerEmail(), &quot;주문 실패: 상품이 없습니다&quot;);
    }

    private void sendEmail(String to, String message) {
        // 이메일 발송 로직
    }
}

class OrderProcessor {
    private final OrderValidator validator;
    private final OrderNotifier notifier;

    OrderProcessor(OrderValidator validator, OrderNotifier notifier) {
        this.validator = validator;
        this.notifier = notifier;
    }

    void process(Order order) {
        if (!validator.isValid(order)) {
            notifier.notifyFailure(order);
            return;
        }
        // ... 주문 처리
    }
}</code></pre>
<p>이제 검증 정책이 바뀌어도 <code>OrderNotifier</code>는 전혀 몰라도 되고, 알림 방식이 바뀌어도 <code>OrderValidator</code>는 그대로다. <code>OrderProcessor</code>는 둘을 조합하는 역할만 남는다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/7a6e41fd-3211-47fc-9e15-76adf5932481/image.png" alt=""></p>
<h3 id="2-ocp개방-폐쇄-원칙-협력의-각본은-그대로-두고-배역을-맡는-배우만-교체할-수-있게-하라">2. OCP(개방-폐쇄 원칙): 협력의 각본은 그대로 두고, 배역을 맡는 배우만 교체할 수 있게 하라</h3>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/59835048-11b0-4e96-b502-1f5fdc510d3d/image.png" alt=""></p>
<p>이 책이 가장 자주 꺼내는 비유가 연극 무대다. 무대에는 &quot;주인공&quot;, &quot;악당&quot; 같은 배역(Role)이 있고, 그 배역을 실제로 연기하는 배우(Object)가 있다. 관객이자 협력의 상대방은 배역에게만 관심이 있다 — 오늘 공연에서 주인공을 누가 연기하든, 대사와 동선(협력의 각본)만 지켜진다면 공연은 똑같이 진행된다. 배우는 얼마든지 바뀔 수 있지만, 배역은 안정적으로 남는다.</p>
<p>OCP(&quot;확장에는 열려 있고 변경에는 닫혀 있어야 한다&quot;)를 이 비유로 옮기면 이렇다 — 협력을 설계할 때는 협력에 참여하는 구체적인 객체에게 직접 말을 거는 게 아니라, 그 객체가 맡은 배역(역할)에게 말을 걸어야 한다. 그러면 배역을 연기하는 배우(구체 클래스)가 새로 추가되거나 교체돼도, 그 배역에게 말을 거는 협력의 각본(호출하는 쪽 코드)은 단 한 줄도 바뀌지 않는다. &quot;확장&quot;은 새 배우를 무대에 세우는 것이고, &quot;변경에 닫혀 있다&quot;는 건 기존 각본을 다시 쓸 필요가 없다는 뜻이다.</p>
<p>여기서 흔히 놓치는 지점이 있다. 인터페이스(역할)를 정의해두는 것과, 실제로 그 역할에게 말을 거는 것은 다른 문제라는 점이다. 역할을 나타내는 인터페이스를 만들어놓고도, 정작 협력을 시작하는 코드가 &quot;이 배우면 이렇게, 저 배우면 저렇게&quot; 하며 여전히 배우의 실제 타입을 하나하나 구분해 분기하고 있다면, 그 인터페이스는 이름만 다형성이지 실제로는 아무 역할도 하지 못하고 있는 것이다.</p>
<pre><code class="language-java">// OCP를 위반한 코드 — Notifier라는 역할(인터페이스)은 있지만, 호출부가 여전히 타입을 직접 분기한다
interface Notifier {
    void send(String to, String message);
}

class EmailNotifier implements Notifier {
    public void send(String to, String message) { /* 이메일 발송 */ }
}

class PushNotifier implements Notifier {
    public void send(String to, String message) { /* 푸시 발송 */ }
}

class NotificationService {
    void notify(String type, String to, String message) {
        if (type.equals(&quot;EMAIL&quot;)) {
            new EmailNotifier().send(to, message);
        } else if (type.equals(&quot;PUSH&quot;)) {
            new PushNotifier().send(to, message);
        }
        // 새 알림 수단이 하나 늘 때마다 이 메서드에 분기가 하나씩 늘어난다
    }
}</code></pre>
<p>무대 뒤에서 배우 이름표를 확인하고 나서야 대사를 정해주는 연출자와 다를 게 없다 — 배역이라는 개념이 있으나 마나 한 상태다. <code>NotificationService</code>가 배역이 아니라 배우 이름(<code>type</code> 문자열)에게 직접 말을 걸고 있기 때문이다.</p>
<pre><code class="language-java">// OCP를 지킨 코드 — NotificationService는 Notifier라는 역할에게만 말을 건다
class NotificationService {
    private final Notifier notifier;

    NotificationService(Notifier notifier) {
        this.notifier = notifier;
    }

    void notify(String to, String message) {
        notifier.send(to, message);
    }
}

// 카카오톡 알림이 새로 필요해져도 NotificationService는 한 줄도 바뀌지 않는다 — 배우만 하나 늘었을 뿐이다
class KakaoTalkNotifier implements Notifier {
    public void send(String to, String message) { /* 카카오톡 발송 */ }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/5b4bc4f4-3114-4165-ac19-0117848def24/image.png" alt=""></p>
<h3 id="3-lsp리스코프-치환-원칙-배역을-대신하려면-그-배역에게-약속된-대사를-배신하지-말라">3. LSP(리스코프 치환 원칙): 배역을 대신하려면, 그 배역에게 약속된 대사를 배신하지 말라</h3>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/523752c2-1a23-4756-a710-e90203d9995d/image.png" alt=""></p>
<p>새 배우가 배역을 물려받았는데 갑자기 각본에 없는 대사를 치거나 정해진 동선을 벗어난다면 공연 전체가 무너진다. 관객도, 상대 배우도 그 배역이 &quot;이번엔 무슨 대사를 할지&quot; 더 이상 예측할 수 없기 때문이다. LSP는 정확히 이 문제를 말한다 — 부모 타입의 자리를 자식 타입으로 바꿔 끼워도 협력이 똑같이 굴러가야 한다는 것.</p>
<p>이걸 단순히 &quot;메서드 시그니처가 호환되면 된다&quot;는 타입 이론으로만 이해하면 부족하다. 협력의 관점에서 LSP가 지키려는 건 &quot;그 역할에게 메시지를 보내는 쪽이 암묵적으로 기대하고 있는 약속&quot;이다. 예를 들어 &quot;할인율을 계산해 반환한다&quot;는 책임을 가진 역할이 있다고 하자.</p>
<pre><code class="language-java">// LSP를 위반한 코드 — 시그니처는 지켰지만 암묵적인 약속(할인이 없으면 0을 반환한다)을 깼다
interface DiscountPolicy {
    int calculate(Order order); // 약속: 적용할 할인이 없으면 0을 반환한다
}

class VipDiscountPolicy implements DiscountPolicy {
    public int calculate(Order order) {
        return order.getTotalPrice() / 10; // 10% 할인
    }
}

class NoDiscountPolicy implements DiscountPolicy {
    public int calculate(Order order) {
        throw new UnsupportedOperationException(&quot;할인 없음&quot;); // 약속을 배신했다
    }
}</code></pre>
<p>이 역할과 협력하는 상대방은 &quot;항상 숫자가 돌아온다&quot;는 약속을 믿고 코드를 짠다.</p>
<pre><code class="language-java">int finalPrice = order.getTotalPrice() - discountPolicy.calculate(order);
// discountPolicy 자리에 NoDiscountPolicy가 대신 들어오는 순간, 이 코드는 예외로 멈춘다</code></pre>
<p><code>NoDiscountPolicy</code>가 <code>DiscountPolicy</code>를 완벽히 구현하고 있고 타입도 문제없이 치환된다. 그런데도 협력은 무너진다 — 이 역할을 대신하는 어떤 배우가 기존 배우들의 암묵적 약속을 깨고 예외를 던져버리면, 그 값을 받아 다음 계산에 쓰던 상대 배우는 예상치 못한 순간에 무너진다. 시그니처는 똑같이 지켰지만, 협력의 프로토콜(행동의 약속)은 배신한 것이다.</p>
<pre><code class="language-java">// LSP를 지킨 코드 — 약속을 그대로 지킨다
class NoDiscountPolicy implements DiscountPolicy {
    public int calculate(Order order) {
        return 0;
    }
}</code></pre>
<p>즉 LSP는 사실 OCP가 실제로 작동하기 위한 전제조건에 가깝다. &quot;배우를 교체해도 각본이 안 바뀐다&quot;는 OCP의 약속은, 교체된 배우가 이전 배우들과 똑같은 방식으로 배역을 연기해줄 때만 성립한다. LSP를 깨는 자식 클래스는 다형성이라는 안전망 아래에서 몰래 다른 협력을 하고 있는 것과 같다.</p>
<h3 id="4-isp인터페이스-분리-원칙-배역마다-필요한-대본만-나눠줘라">4. ISP(인터페이스 분리 원칙): 배역마다 필요한 대본만 나눠줘라</h3>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/e5a874ba-aa1b-475e-8296-089291e08cdb/image.png" alt=""></p>
<p>한 배우에게 그 공연에 등장하지도 않는 다른 배역들의 대사까지 몽땅 적힌 통짜 대본을 건네주면 어떻게 될까. 배우는 자신이 실제로 맡은 배역과 무관한 대사까지 읽어보고 그중 자기 몫을 골라내야 한다. 그 통짜 대본이 수정될 때마다 — 설령 자신과 무관한 배역의 대사가 바뀌는 것이라도 — 이 배우는 자기 대본이 바뀌었다는 이유로 다시 확인 절차를 거쳐야 한다.</p>
<p>ISP는 &quot;인터페이스를 잘게 쪼개라&quot;는 기계적인 규칙이 아니라, &quot;하나의 배역에게는 그 배역이 실제로 참여하는 협력에 필요한 대본만 줘야 한다&quot;는 말이다. 뚱뚱한 인터페이스 하나는 사실 서로 다른 협력에서 요구된 서로 다른 역할들이 편의상 하나로 뭉뚱그려진 결과인 경우가 많다.</p>
<pre><code class="language-java">// ISP를 위반한 코드 — 서로 다른 협력에서 온 메서드가 인터페이스 하나에 뭉쳐 있다
interface PaymentProcessor {
    void pay(Order order);
    void refund(Order order);
    void issueTaxInvoice(Order order); // 세금계산서 발행 — 일부 결제 수단에만 필요한 협력
}

// 세금계산서 발행이 필요 없는 결제 수단도 이 메서드를 억지로 구현해야 한다
class SimpleCardPayment implements PaymentProcessor {
    public void pay(Order order) { /* 카드 결제 */ }
    public void refund(Order order) { /* 카드 환불 */ }
    public void issueTaxInvoice(Order order) {
        throw new UnsupportedOperationException(); // 이 결제 수단엔 세금계산서 협력이 없다
    }
}</code></pre>
<p>이 상태에서는 한 협력(세금계산서 발행)만을 위해 인터페이스에 메서드를 하나 추가해도, 그 인터페이스를 구현하는 모든 배우가 자신과 무관한 그 변화에 강제로 얽힌다 — 구현할 필요도, 알 필요도 없는 대사까지 떠안는 셈이다.</p>
<pre><code class="language-java">// ISP를 지킨 코드 — 협력 단위로 역할을 쪼갠다
interface Payable {
    void pay(Order order);
}

interface Refundable {
    void refund(Order order);
}

interface TaxInvoiceIssuable {
    void issueTaxInvoice(Order order);
}

// 자신이 실제로 참여하는 협력만큼만 역할을 맡는다
class SimpleCardPayment implements Payable, Refundable {
    public void pay(Order order) { /* 카드 결제 */ }
    public void refund(Order order) { /* 카드 환불 */ }
}

class CorporatePayment implements Payable, Refundable, TaxInvoiceIssuable {
    public void pay(Order order) { /* ... */ }
    public void refund(Order order) { /* ... */ }
    public void issueTaxInvoice(Order order) { /* 세금계산서 발행 */ }
}</code></pre>
<p>역할을 협력 단위로 촘촘하게 쪼개두면, 배우는 정확히 자신이 서는 무대(협력)만큼의 대본만 받는다. 다른 무대의 사정이 아무리 바뀌어도 이 배우는 흔들리지 않는다.</p>
<h3 id="5-dip의존관계-역전-원칙-무대를-짤-때는-배우가-아니라-배역을-먼저-캐스팅-공고에-올려라">5. DIP(의존관계 역전 원칙): 무대를 짤 때는 배우가 아니라 배역을 먼저 캐스팅 공고에 올려라</h3>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/d82050e7-780f-43c4-b4c1-0f8f870ba662/image.png" alt=""></p>
<p>이 책이 강조하는 협력 설계의 순서는 &quot;협력이 먼저, 역할은 그다음, 배우(구체 클래스)는 맨 마지막&quot;이다. 그런데 실제로 코드를 짤 때는 이 순서가 자꾸 뒤집힌다. 협력을 시작하는 쪽, 즉 정책을 결정하는 상위 코드가 처음부터 &quot;이 배우를 무대에 세우겠다&quot;며 특정 구현체를 콕 집어 의존해버리는 것이다. 그러면 나중에 그 배우를 다른 배우로 바꾸고 싶어도, 애초에 각본(상위 코드) 자체가 그 배우의 이름으로 쓰여 있어 통째로 다시 써야 한다.</p>
<p>DIP는 이 순서를 되돌리라는 말이다. 상위의 협력(정책)은 구체적인 배우가 아니라 배역(추상화, 역할)에게 말을 걸도록 설계하고, 실제로 그 배역에 어떤 배우를 세울지는 협력이 시작되는 그 순간 — 런타임, 혹은 조립 시점 — 에 바깥에서 정해준다.</p>
<pre><code class="language-java">// DIP를 위반한 코드 — 상위 정책이 구체적인 배우를 직접 캐스팅한다
class OrderService {
    private final KakaoPayGateway paymentGateway = new KakaoPayGateway();

    void pay(Order order) {
        paymentGateway.charge(order.getTotalPrice());
    }
}
// 토스페이로 바꾸고 싶으면 OrderService 내부를 직접 뜯어고쳐야 한다</code></pre>
<p>&quot;고수준 모듈이 저수준 모듈에 의존해서는 안 되고, 둘 다 추상화에 의존해야 한다&quot;는 문장이 결국 말하는 건 이것이다 — 협력의 각본을 쓰는 사람은 구체적인 배우의 사정을 몰라도 되어야 한다.</p>
<pre><code class="language-java">// DIP를 지킨 코드 — 상위 정책은 역할(추상화)에게만 의존한다
interface PaymentGateway {
    void charge(int amount);
}

class OrderService {
    private final PaymentGateway paymentGateway;

    OrderService(PaymentGateway paymentGateway) { // 어떤 배우를 세울지는 바깥에서 정한다
        this.paymentGateway = paymentGateway;
    }

    void pay(Order order) {
        paymentGateway.charge(order.getTotalPrice());
    }
}

class KakaoPayGateway implements PaymentGateway {
    public void charge(int amount) { /* 카카오페이 결제 */ }
}

class TossPayGateway implements PaymentGateway {
    public void charge(int amount) { /* 토스페이 결제 */ }
}</code></pre>
<p><code>OrderService</code>는 이제 <code>PaymentGateway</code>라는 배역에게만 말을 건다. 어떤 배우(카카오페이든 토스페이든)를 무대에 세울지는 <code>OrderService</code> 바깥에서 결정된다.</p>
<p>흥미로운 건, 이 원칙 하나만 따로 지킨다고 되는 게 아니라는 점이다. DIP가 가리키는 &quot;역할에게 의존하라&quot;는 방향은 결국 OCP가 요구하는 &quot;배우를 갈아 끼워도 각본은 그대로&quot;라는 결과를 만들어내는 구조적 조건이다. SOLID의 다섯 원칙은 따로 노는 다섯 개의 규칙이 아니라, &quot;자율적인 객체들이 건강하게 협력하는 공동체&quot;라는 하나의 그림을 다섯 개의 각도에서 비추고 있는 것에 가깝다.</p>
<h3 id="마치며">마치며</h3>
<p>다섯 원칙을 협력·역할·책임이라는 하나의 언어로 다시 읽고 나니, SOLID가 &quot;좋은 클래스를 만드는 다섯 가지 체크리스트&quot;가 아니라 &quot;좋은 협력을 설계하기 위한 하나의 태도&quot;라는 걸 알게 됐다. 정리하면:</p>
<ul>
<li><strong>SRP</strong>는 하나의 객체가 감당할 수 있는 자율적인 책임의 크기를 묻고,</li>
<li><strong>OCP</strong>는 그 책임을 수행하는 배우를 갈아 끼울 수 있는 여지를 남겨두라 말하고,</li>
<li><strong>LSP</strong>는 그 여지가 실제로 안전하게 작동하기 위한 전제, 즉 약속을 지키는 대체만 허용하라 말하고,</li>
<li><strong>ISP</strong>는 그 약속(대본)을 협력 단위로 알맞게 쪼개라 말하고,</li>
<li><strong>DIP</strong>는 애초에 그 약속을 구체적인 배우가 아니라 배역을 향해 쓰라고 말한다.</li>
</ul>
<p>결국 다섯 원칙 모두 같은 곳을 가리키고 있었다 — 클래스 하나하나를 잘 만드는 기술이 아니라, <strong>자율적인 객체들이 서로를 믿고 건강하게 협력할 수 있는 공동체를 어떻게 설계할 것인가</strong>라는 질문.</p>
<h3 id="참고-문헌">참고 문헌</h3>
<p><a href="https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5-%EC%84%A4%EA%B3%84%EC%9D%98-5%EA%B0%80%EC%A7%80-%EC%9B%90%EC%B9%99-SOLID">https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5-%EC%84%A4%EA%B3%84%EC%9D%98-5%EA%B0%80%EC%A7%80-%EC%9B%90%EC%B9%99-SOLID</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] 단일 인덱스 2개는 복합 인덱스를 이길 수 없었다.]]></title>
            <link>https://velog.io/@yongmin_lee/MySQL-%EB%8B%A8%EC%9D%BC-%EC%9D%B8%EB%8D%B1%EC%8A%A4-2%EA%B0%9C%EB%8A%94-%EB%B3%B5%ED%95%A9-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%EC%9D%B4%EA%B8%B8-%EC%88%98-%EC%97%86%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@yongmin_lee/MySQL-%EB%8B%A8%EC%9D%BC-%EC%9D%B8%EB%8D%B1%EC%8A%A4-2%EA%B0%9C%EB%8A%94-%EB%B3%B5%ED%95%A9-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%EC%9D%B4%EA%B8%B8-%EC%88%98-%EC%97%86%EC%97%88%EB%8B%A4</guid>
            <pubDate>Thu, 13 Aug 2026 17:38:53 GMT</pubDate>
            <description><![CDATA[<p>1부에서 인덱스가 어떻게 생겼는지, 2부에서 컬럼이 여러 개일 때 뭘 조심해야 하는지를 이론으로 봤다. 이번 글은 그 이론을 실제로 검증한 기록이다 — &quot;컬럼별로 단일 인덱스를 하나씩 걸어도 되지 않을까&quot;라는 질문에, 실제로 두 방식(단일 인덱스 2개 vs 복합 인덱스 1개)을 직접 만들어서 비교했다. 그리고 이 원리가 Full-Text 인덱스에서도 똑같이 반복되는지도 같이 확인했다.</p>
<h2 id="사례-1--index_merge-인덱스-두-개의-함정">사례 1 — <code>index_merge</code>, &quot;인덱스 두 개&quot;의 함정</h2>
<p>&quot;학과별 현재 개설 과목 조회&quot; 쿼리로 확인했다.</p>
<pre><code class="language-sql">SELECT c.id FROM course c
WHERE c.offering_department_id = 3 AND c.is_active = 1;</code></pre>
<p><code>offering_department_id</code>엔 FK 제약을 지지하는 단일 컬럼 인덱스가 이미 있었다. <code>is_active</code>엔 단일 컬럼 인덱스가 없어서, &quot;컬럼별로 인덱스를 각각 걸었다면 어떻게 됐을지&quot;를 재현하려고 임시로 하나 만들었다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/42f795bc-902f-4257-9176-8a2939020d71/image.png" alt=""></p>
<p><code>Intersect rows sorted by row ID</code> — 두 인덱스를 각각 스캔한 뒤 합치는 게 실행계획에 그대로 찍힌다. <code>offering_department_id = 3</code>으로 620행, <code>is_active = 1</code>로 4,481행을 각각 읽어서 교집합을 냈다. <strong>최종 결과는 536행인데, 그걸 얻으려고 5,101행(620+4,481)을 읽었다.</strong></p>
<p>원인은 <code>is_active</code>의 치우침이다. <code>true</code> 값 하나가 전체의 89.6%를 차지한다. 이 컬럼만 놓고 보면 인덱스가 있어도 사실상 거의 못 거른다 — 4,481행 중 4,481행을 다 가져와야 하는 것과 다르지 않다.</p>
<p>복합 인덱스로 바꿨다.</p>
<pre><code class="language-sql">CREATE INDEX idx_course_dept_active
    ON course (offering_department_id, is_active);</code></pre>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/10e83912-d374-4a45-85a3-53f3745ba330/image.png" alt=""></p>
<p><code>Covering index lookup</code> — 정확히 536행만 읽고 끝났다. <strong>읽은 행 5,101 → 536, 9.5배 차이.</strong> 이유는 2부에서 설명한 그대로다: <code>offering_department_id</code>로 먼저 좁혀서 620행까지 줄이고, 그 안에서 <code>is_active</code>를 확인하는 건 애초에 <code>index_merge</code>가 필요 없을 만큼 저렴하다. 인덱스를 &quot;몇 개&quot; 거느냐가 아니라 &quot;쿼리가 컬럼을 조합하는 방식&quot;에 인덱스 구조를 맞추는 게 핵심이었다.</p>
<h2 id="사례-2--full-text에서도-똑같은-실수를-할-뻔했다">사례 2 — Full-Text에서도 똑같은 실수를 할 뻔했다</h2>
<p>과목 검색(<code>name</code> 또는 <code>course_code</code> 부분 일치)에 Full-Text 인덱스를 붙이면서, 처음엔 &quot;컬럼마다 하나씩 걸고 OR로 묶으면 되겠지&quot; 생각했다.</p>
<pre><code class="language-sql">-- 컬럼별로 각각
ALTER TABLE course ADD FULLTEXT INDEX ft_course_name (name) WITH PARSER ngram;
ALTER TABLE course ADD FULLTEXT INDEX ft_course_code (course_code) WITH PARSER ngram;</code></pre>
<pre><code class="language-sql">SELECT COUNT(*) FROM course c
WHERE c.school_id = 1 AND c.is_active = 1
  AND (MATCH(c.name) AGAINST(&#39;&quot;디자인&quot;&#39; IN BOOLEAN MODE)
       OR MATCH(c.course_code) AGAINST(&#39;&quot;디자인&quot;&#39; IN BOOLEAN MODE));</code></pre>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/ac5db54d-c4dc-4cef-8e14-0afb314946f4/image.png" alt=""></p>
<p><code>Table scan on c</code> — 인덱스를 두 개나 만들었는데 <strong>하나도 안 탔다.</strong> 5,000행 전체를 스캔해서 156건을 걸러냈다. B-Tree의 <code>index_merge</code>처럼 서로 다른 Full-Text 인덱스에 걸친 OR을 병합해서 타는 전략 자체가 옵티마이저에 없었다.</p>
<p>두 컬럼을 하나의 복합 Full-Text 인덱스로 합쳤다.</p>
<pre><code class="language-sql">ALTER TABLE course ADD FULLTEXT INDEX ft_course_name_code (name, course_code) WITH PARSER ngram;</code></pre>
<pre><code class="language-sql">SELECT COUNT(*) FROM course c
WHERE c.school_id = 1 AND c.is_active = 1
  AND MATCH(c.name, c.course_code) AGAINST(&#39;&quot;디자인&quot;&#39; IN BOOLEAN MODE);</code></pre>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/9d777dfc-daff-46c1-adf3-f0ff966e2a1a/image.png" alt=""></p>
<p><code>Full-text index search on c using ft_course_name_code</code> — 5,000행 대신 180행만 후보로 가져온 뒤 156건으로 좁혔다. <strong>최종 결과 156건은 풀스캔 때와 정확히 같다</strong> — 빨라졌다고 결과가 달라진 게 아니라는 것까지 같은 화면에서 확인됐다.</p>
<p>원인 메커니즘은 사례 1과 다르다(<code>index_merge</code>의 병합 비용 vs 옵티마이저가 애초에 그 조합을 못 탐). 하지만 처방은 똑같았다 — <strong>쿼리가 여러 컬럼을 동시에 조건으로 쓴다면, 인덱스도 그 조합에 맞춰 하나로 만든다.</strong></p>
<p>검증도 따로 했다. LIKE 부분일치와 Full-Text 검색이 정말 같은 행을 찾는지, 한글·영문·숫자·영숫자 경계를 포함한 2글자 이상 검색어 13개로 비교했고 전부 일치했다. 1글자 검색어만 ngram 최소 토큰 길이(2글자) 제약으로 원천적으로 안 되는데, 이건 API 단에서 2글자 미만 검색어를 400으로 막는 걸로 정리했다 — 결과가 없는 게 아니라 검색 자체가 불가능한 거라, 조용히 빈 결과를 주는 것보다는 명시적으로 막는 게 맞다고 판단했다.</p>
<h2 id="인덱스는-공짜가-아니다">인덱스는 공짜가 아니다</h2>
<p>두 사례 모두 &quot;인덱스가 없어서&quot;가 아니라 &quot;있는 인덱스가 쿼리가 실제로 컬럼을 조합하는 방식과 맞지 않아서&quot; 생긴 문제였다. 컬럼별로 인덱스를 쌓는 대신, WHERE 절이 어떤 컬럼들을 함께 필터링하는지 먼저 보고 그 조합에 맞는 복합 인덱스 하나로 설계하는 게 원칙이었다.</p>
<p>다만 이걸 무조건 다 걸어도 되는 건 아니다. 이번에 추가한 인덱스 전체를 반영한 쓰기 경로(INSERT)를 측정했더니 약 8% 느려졌다. 1부 5번에서 말했듯 인덱스는 읽기와 쓰기를 맞바꾸는 트레이드오프이고, 이 프로젝트는 읽기가 압도적으로 빈번한 워크로드라 감수할 만하다고 판단했다.</p>
<hr>
<p>정리하면:</p>
<table>
<thead>
<tr>
<th>사례</th>
<th>이전</th>
<th>이후</th>
<th>차이</th>
</tr>
</thead>
<tbody><tr>
<td>학과별 개설 과목 조회 (<code>index_merge</code>)</td>
<td>5,101행 읽음</td>
<td>536행 읽음 (Covering index)</td>
<td>9.5배</td>
</tr>
<tr>
<td>과목 키워드 검색 (Full-Text)</td>
<td>5,000행 스캔</td>
<td>180행 검색 (Full-text index search)</td>
<td>결과는 156건으로 동일</td>
</tr>
</tbody></table>
<h2 id="돌아보며">돌아보며</h2>
<p>이 3부작을 관통하는 하나의 태도가 있다면 — <strong>추측 대신 확인</strong>이다. &quot;인덱스를 걸었으니 빠를 것이다&quot;가 아니라 <code>EXPLAIN ANALYZE</code>로 실제 실행계획을 확인했고, &quot;응답시간이 줄었으니 개선됐다&quot;가 아니라 같은 쿼리를 여러 번 측정해서 응답시간 자체가 노이즈에 얼마나 흔들리는지부터 확인한 뒤 읽은 행 수를 기준으로 삼았다. 처음 세운 가설이 재현되지 않았던 순간에도 숨기지 않고, 실제로 재현되는 사례를 다시 찾아 검증했다.</p>
<p>인덱스 설계는 결국 &quot;몇 개를 거느냐&quot;의 문제가 아니라, 내 쿼리가 실제로 데이터를 어떻게 필터링하는지를 이해하고 그 모양에 맞춰 하나씩 판단하는 문제였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] 인덱스가 2개면 2배 빠를까?]]></title>
            <link>https://velog.io/@yongmin_lee/MySQL-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EA%B0%80-2%EA%B0%9C%EB%A9%B4-2%EB%B0%B0-%EB%B9%A0%EB%A5%BC%EA%B9%8C</link>
            <guid>https://velog.io/@yongmin_lee/MySQL-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EA%B0%80-2%EA%B0%9C%EB%A9%B4-2%EB%B0%B0-%EB%B9%A0%EB%A5%BC%EA%B9%8C</guid>
            <pubDate>Thu, 13 Aug 2026 14:44:41 GMT</pubDate>
            <description><![CDATA[<p>1부에서 인덱스가 정렬된 B+Tree라는 것, 정렬 기준과 쿼리 조건이 어긋나면 못 탄다는 것, EXPLAIN으로 확인하는 법까지 봤다. 근데 아직 궁금증이 풀리지 않은 부분이 있다. — <strong>&quot;WHERE 조건에 컬럼이 두 개 이상이면 인덱스를 어떻게 걸어야 하나?&quot;</strong> 컬럼마다 인덱스를 하나씩 걸어야 하나, 아니면 묶어서 하나로 걸어야 하나. 이번 글은 이 질문에 답한다.</p>
<h2 id="6-복합-인덱스와-컬럼-순서">6. 복합 인덱스와 컬럼 순서</h2>
<p>컬럼 두 개(<code>A</code>, <code>B</code>)를 묶어서 인덱스를 걸면 — <code>CREATE INDEX idx ON table (A, B)</code> — 이건 &quot;A에 인덱스 하나 + B에 인덱스 하나&quot;가 아니다. <strong>A로 먼저 정렬하고, 같은 A값 안에서 다시 B로 정렬한</strong> 딱 하나의 구조다. 전화번호부를 &quot;성&quot;으로 먼저 정렬하고, 같은 성 안에서 &quot;이름&quot;으로 다시 정렬해두는 것과 같다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/c3cc975a-f282-44db-8161-98e515429308/image.png" alt=""></p>
<p>이 구조에서 중요한 규칙이 <strong>leftmost prefix(최좌측 접두)</strong> 다. <code>(A, B)</code> 인덱스는 &quot;A로 검색&quot;하거나 &quot;A와 B로 같이 검색&quot;할 땐 쓸 수 있지만, <strong>&quot;B로만 검색&quot;할 땐 못 쓴다.</strong> B는 전체적으로 정렬돼 있는 게 아니라 &quot;각 A값 안에서만&quot; 정렬돼 있기 때문이다. 성 없이 이름만으로 전화번호부를 찾으려는 것과 같다 — 정렬 기준(성)을 모르니 처음부터 다 훑어야 한다.</p>
<p>그럼 A와 B 중 뭘 앞에 둬야 할까? 내 프로젝트의 실제 사례로 보자. <code>student_course</code> 테이블(60,024행)에서 &quot;학생 한 명의 이수구분별 이수과목&quot;을 찾는 쿼리다.</p>
<pre><code class="language-sql">SELECT sc.id FROM student_course sc
WHERE sc.student_profile_id = 1288 AND sc.applied_division_id IN (1, 3, 5);</code></pre>
<p>여기 걸린 복합 인덱스는 <code>idx_sc_profile_division (student_profile_id, applied_division_id)</code> — <code>student_profile_id</code>가 앞이다. 이유는 <strong>선택도(cardinality)</strong> 다.</p>
<ul>
<li><code>student_profile_id</code>: 학생 한 명당 40~80행. 전체 60,024행 중 한 값이 차지하는 비중이 0.1%대 — <strong>선택도가 매우 높다.</strong></li>
<li><code>applied_division_id</code>: 값 하나가 전체의 45.6%를 차지할 만큼 치우쳐 있다 — <strong>선택도가 낮다.</strong></li>
</ul>
<p>선택도 높은 컬럼을 앞에 두면, 인덱스 탐색 한 번으로 먼저 &quot;이 학생의 행&quot;(수십 개)까지 확 좁혀지고, 그 좁은 범위 안에서 <code>applied_division_id</code>로 다시 거르는 건 설령 치우침이 있어도 비용이 거의 안 든다. 반대로 순서를 바꿔서 <code>(applied_division_id, student_profile_id)</code>로 걸었다면? 치우친 값 하나만으로 이미 27,000행 가까이(45.6% × 60,024)를 걸러야 하는 상황부터 시작하게 된다. <strong>&quot;어떤 컬럼을 인덱스에 넣을까&quot;만큼 &quot;어떤 순서로 넣을까&quot;도 결과를 완전히 바꾼다.</strong></p>
<h2 id="7-인덱스가-여러-개면-다-좋은-거-아닌가--index_merge">7. 인덱스가 여러 개면 다 좋은 거 아닌가? — <code>index_merge</code></h2>
<p>그럼 애초에 컬럼마다 단일 인덱스를 따로따로 걸어두면 되는 거 아닌가? 두 조건을 같이 쓰는 쿼리가 오면 MySQL이 알아서 두 인덱스를 섞어서 쓰지 않을까?</p>
<p>실제로 그런 기능이 있다 — <strong><code>index_merge</code></strong>. 옵티마이저가 각 인덱스를 따로 스캔해서 나온 두 결과(행 포인터 집합)를 <strong>교집합(intersect)</strong> 내는 방식이다. &quot;두 인덱스를 다 쓴다&quot;니까 그럴듯하게 들리지만, 실제로 직접 두 방식을 비교해보니 문제가 있었다. <code>course</code> 테이블(5,000행)의 &quot;학과별 개설 과목 조회&quot; 쿼리로 확인했다.</p>
<pre><code class="language-sql">SELECT c.id FROM course c
WHERE c.offering_department_id = 3 AND c.is_active = 1;</code></pre>
<pre><code>단일 인덱스 2개(offering_department_id, is_active 각각)일 때
→ index_merge (intersect) 사용
→ offering_department_id 인덱스 스캔: 620행 + is_active 인덱스 스캔: 4,481행
→ 실제로 읽은 행(합계): 5,101행
→ 최종 반환된 행: 536행

복합 인덱스 idx_course_dept_active (offering_department_id, is_active) 하나로 교체
→ 실제로 읽은 행: 536행 (Covering index lookup)</code></pre><p><strong>536행을 돌려주기 위해 5,101행을 읽었다.</strong> 9.5배 차이다. 왜 이렇게 벌어질까?</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/5370ac35-e8ef-460d-9768-5a9aac93e91c/image.png" alt=""></p>
<p><code>index_merge</code>는 <strong>각 인덱스를 &quot;그 컬럼 조건만으로&quot; 따로 스캔한다.</strong> <code>is_active</code> 인덱스는 자기 조건에 맞는 행을 전부 찾아야 하는데, 값 하나(true)가 전체의 89.6% — 4,481행이다. 이 4,481개 포인터를 전부 모은 다음에야 <code>offering_department_id</code> 인덱스가 찾은 결과(620개)와 교집합을 낼 수 있다. <strong>최종적으로 필요한 건 536행뿐인데, 그 전에 5,101개(4,481+620)를 각각 모으는 단계 자체가 이미 낭비다.</strong></p>
<p>복합 인덱스는 이 낭비 자체가 없다. <code>offering_department_id</code>로 먼저 좁혀서 애초에 &quot;이 학과의 620행&quot;만 보게 되고, 그 안에서 <code>is_active</code>를 확인하는 건 그 범위 안에서 바로 끝난다. <strong>인덱스를 몇 개 걸었느냐가 아니라, 쿼리가 실제로 컬럼을 조합하는 방식과 인덱스 구조가 맞느냐가 핵심이다.</strong> </p>
<h2 id="8-커버링-인덱스--using-index">8. 커버링 인덱스 — <code>Using index</code></h2>
<p>1부에서 세컨더리 인덱스는 보통 2단계(인덱스에서 찾기 → PK로 테이블 재조회)를 거친다고 했다. 그런데 <strong>쿼리가 필요한 컬럼이 전부 인덱스 안에 이미 들어있다면, 2단계를 건너뛸 수 있다.</strong> 이걸 <strong>커버링 인덱스(covering index)</strong> 라고 부르고, <code>EXPLAIN</code>의 <code>Extra</code>에 <code>Using index</code>로 나타난다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/f2309245-dd3a-4b16-9364-d46381c3c40b/image.png" alt=""></p>
<p>내 프로젝트의 예시. &quot;학생이 이수완료/수강중인 과목의 course_id 목록&quot;을 찾는 쿼리는 <code>course_id</code> 하나만 있으면 된다.</p>
<pre><code class="language-sql">SELECT sc.course_id FROM student_course sc
WHERE sc.student_profile_id = ? AND sc.status IN (&#39;COMPLETED&#39;, &#39;IN_PROGRESS&#39;);</code></pre>
<p>여기 걸린 인덱스가 <code>idx_sc_profile_status_course (student_profile_id, status, course_id)</code>다. <code>course_id</code>를 일부러 세 번째 컬럼으로 같이 넣어뒀다 — 정렬 기준으로 안 쓰이더라도, <strong>인덱스 리프 노드 안에 이미 들어있는 값이면 그냥 거기서 읽으면 되기 때문</strong>이다. 그 결과 이 쿼리는 테이블(클러스터드 인덱스) 접근을 아예 안 하고 인덱스만으로 끝난다.</p>
<p>실측 결과가 흥미롭다. <strong>읽은 행 수는 인덱스 적용 전후로 80행 → 80행, 변화가 없다.</strong> 그런데 응답시간은 0.351ms → 0.108ms로 줄었다. 읽는 행 수는 똑같은데 왜 빨라졌을까 — 답은 &quot;몇 번 읽었냐&quot;가 아니라 &quot;<strong>어디까지</strong> 갔다 왔냐&quot;였다. 전에는 80행마다 클러스터드 인덱스까지 왕복(2단계)했는데, 커버링 인덱스로 바뀌면서 그 왕복 자체가 사라졌다. <strong>&quot;읽은 행 수가 같다고 성능도 같은 게 아니다&quot;</strong> — 1부에서 배운 2단계 조회 개념이 실제로 측정 가능한 차이를 만든 사례다.</p>
<h2 id="9-특수한-상황을-위한-인덱스--full-text">9. 특수한 상황을 위한 인덱스 — Full-Text</h2>
<p>1부 3번에서 <code>LIKE &#39;%키워드%&#39;</code>가 구조적으로 B-Tree를 못 탄다고 했다. 값 전체를 정렬해봤자 &quot;어딘가에 포함된 것&quot;은 정렬 순서로 못 찾기 때문이다. 그럼 부분 문자열 검색이 필요한 경우(과목 이름 검색처럼)엔 어떻게 해야 할까 — B-Tree가 아닌 <strong>다른 종류의 인덱스</strong>가 필요하다.</p>
<p>MySQL의 <strong>Full-Text 인덱스</strong>는 B-Tree와 발상 자체가 다르다. &quot;행을 값 기준으로 정렬&quot;하는 대신, <strong>&quot;단어(토큰) 하나가 어느 행들에 나타나는지&quot;를 거꾸로 매핑</strong>해둔다(그래서 역색인, inverted index). &quot;디자인&quot;이라는 단어를 찾으면, &quot;디자인&quot;이 등장하는 행 목록을 바로 얻는 식이다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/7233f56b-0976-44de-b99e-bd2f3d37d1c8/image.png" alt=""></p>
<p>문제는 한국어가 영어처럼 띄어쓰기로 깔끔하게 단어가 안 나뉜다는 것. 그래서 <strong>ngram 파서</strong>를 쓴다 — 텍스트를 N글자씩 겹치게 잘라서 각 조각을 토큰으로 삼는다(기본값 N=2). &quot;디자인&quot;은 &quot;디자&quot;, &quot;자인&quot; 두 토큰으로 쪼개져 색인된다. 그래서 &quot;자인&quot;(디자인의 뒷부분)으로 검색해도 &quot;디자인&quot;이 포함된 행을 정확히 찾아낸다 — 실제로 156건 전부 일치하는 걸 확인했다. 반대로 <strong>1글자 검색어는 토큰 자체가 안 만들어져서 원천적으로 검색이 안 된다</strong> — ngram이 최소 2글자 단위이기 때문이다. (그래서 이 프로젝트의 검색 API는 2글자 미만 검색어를 아예 요청 단계에서 막는다.)</p>
<p>Full-Text 인덱스를 여러 컬럼에 걸어야 할 때(예: 과목명과 학수번호를 동시에 검색) 어떤 식으로 걸어야 하는지는 — 사실 B-Tree 복합 인덱스와는 또 다른 함정이 있다. 이건 3부에서 실제로 겪은 얘기로 풀어본다.</p>
<hr>
<p>여기까지 정리하면:</p>
<ul>
<li>복합 인덱스는 앞 컬럼 기준으로 정렬되고, 그 안에서 뒤 컬럼이 다시 정렬된다. 선택도 높은 컬럼을 앞에 둔다.</li>
<li>단일 인덱스 여러 개를 <code>index_merge</code>로 섞어 쓰는 것보다, 애초에 복합 인덱스 하나가 낫다 — 각 인덱스가 &quot;자기 조건만으로&quot; 먼저 넓게 훑기 때문이다.</li>
<li>쿼리에 필요한 컬럼이 인덱스 안에 다 있으면 테이블 재조회 자체를 생략할 수 있다(커버링 인덱스). 읽은 행 수가 같아도 이것 때문에 빨라질 수 있다.</li>
<li>B-Tree로 안 되는 것(부분 문자열 검색)은 아예 다른 구조(역색인, Full-Text)로 풀어야 한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] 인덱스 제대로 이해하기 ]]></title>
            <link>https://velog.io/@yongmin_lee/MySQL-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yongmin_lee/MySQL-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 13 Aug 2026 13:47:53 GMT</pubDate>
            <description><![CDATA[<p><code>CREATE INDEX idx_xxx ON table (column)</code> — 이 한 줄은 써봤다. &quot;쿼리가 느리면 인덱스를 걸면 된다&quot;는 것도 안다. 근데 막상 &quot;인덱스가 정확히 뭘 하길래 빨라지는 거야?&quot;라고 물으면 말이 막힌다. &quot;빠르게 찾아주는 것&quot; 정도로 뭉뚱그려 알고 있었다면, 이 글은 그 뭉뚱그림을 걷어내기 위한 글이다.</p>
<h2 id="1-왜-인덱스가-필요한가">1. 왜 인덱스가 필요한가</h2>
<p>내가 만든 서비스의 <code>student_course</code> 테이블은 학생들의 이수내역을 담고 있고, 행이 60,024개다. 특정 학생 한 명의 이수내역을 찾는 쿼리를 생각해보자.</p>
<pre><code class="language-sql">SELECT * FROM student_course WHERE student_profile_id = 1288;</code></pre>
<p>인덱스가 없으면 MySQL은 이 60,024행을 <strong>전부</strong> 읽으면서 <code>student_profile_id</code>가 1288인지 하나하나 확인한다. 학생 한 명당 이수 과목이 40~80개 정도니, 결과는 많아야 80행인데 그걸 찾겠다고 6만 행을 다 읽는 것이다. 이게 <strong>풀 테이블 스캔(full table scan)</strong> 이고, 인덱스가 존재하는 근본적인 이유는 이 낭비를 없애기 위해서다.</p>
<p>인덱스를 설명할 때 제일 흔히 쓰는 비유가 &quot;책 색인 찾아보기&quot;다. 이 비유는 절반만 맞다. 책의 색인은 &quot;빠르게 찾을 수 있는 목록&quot;이라는 결과만 보여줄 뿐, <strong>왜</strong> 빠른지는 설명하지 못한다. 책 색인이 빠른 이유는 단 하나 — <strong>정렬</strong>돼 있기 때문이다. 가나다순으로 정렬된 목록에서 &quot;ㅅ&quot;으로 시작하는 단어를 찾을 때, 우리는 처음부터 한 장씩 넘기지 않는다. 대충 중간을 펼쳐서 &quot;ㅇ&quot;이 나오면 앞쪽으로, &quot;ㄷ&quot;이 나오면 뒤쪽으로 — 이렇게 범위를 반씩 좁혀가며 찾는다.</p>
<p><strong>인덱스의 본질은 &quot;컬럼 값을 정렬된 상태로 별도로 유지하는 것&quot;이다.</strong> 정렬돼 있으면 이런 식으로 범위를 좁혀가며 찾을 수 있고(이걸 자료구조 용어로 이진 탐색에 가까운 방식이라고 한다), 그래서 60,024행이든 6억 행이든 몇 번 비교하지 않고 원하는 지점 근처로 바로 갈 수 있다. 반대로 정렬돼 있지 않으면 &quot;찾았다&quot;고 확신할 방법이 하나뿐이다 — 처음부터 끝까지 다 보는 것. 그게 풀 테이블 스캔이다.</p>
<p>그럼 &quot;정렬된 상태를 유지&quot;한다는 게 실제로는 어떤 자료구조로 구현돼 있을까?</p>
<h2 id="2-인덱스는-실제로-어떻게-생겼나--b-tree">2. 인덱스는 실제로 어떻게 생겼나 — B-Tree</h2>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/31b56c72-3b4d-4ace-93f7-0bddefde23b4/image.png" alt="">
<em>출처: <a href="https://velog.io/@emplam27/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-%EA%B7%B8%EB%A6%BC%EC%9C%BC%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-B-Tree">B-Tree 자료구조 그림으로 알아보기</a></em></p>
<p>MySQL(InnoDB)의 기본 인덱스 구조는 <strong>B-Tree</strong>(정확히는 B+Tree)다. 핵심 특징 세 가지만 기억하면 된다.</p>
<ul>
<li><strong>정렬된 키</strong>: 모든 노드 안의 값은 정렬돼 있다.</li>
<li><strong>균형 잡힌 트리</strong>: 루트에서 리프까지의 깊이가 어디를 가든 거의 같다. 그래서 행이 아무리 많아도 루트→중간 노드→리프까지 <strong>몇 번의 이동</strong>으로 원하는 값 근처에 도달한다(6만 행이든 600만 행이든 트리 깊이는 3~4단 정도밖에 안 늘어난다).</li>
<li><strong>리프 노드끼리 연결</strong>: 리프 노드들이 양방향 연결 리스트로 이어져 있어서, <code>BETWEEN</code>이나 <code>&gt;</code> 같은 범위 조건일 때 한 번 찾은 지점부터 옆으로 쭉 훑기만 하면 된다.</li>
</ul>
<p>여기서 MySQL(InnoDB)만의 중요한 특징이 하나 있다. <strong>Primary Key가 곧 테이블 자체다.</strong> 이걸 <strong>클러스터드 인덱스</strong>라고 부르는데, PK로 만든 B-Tree의 리프 노드에 그 행의 <strong>모든 컬럼 데이터</strong>가 통째로 들어있다는 뜻이다. 테이블이 디스크에 저장되는 순서 자체가 PK 정렬 순서라고 보면 된다. (AUTO_INCREMENT를 PK로 즐겨 쓰는 이유가 여기 있다 — 값이 항상 증가하니 새 행이 항상 트리의 맨 끝에 추가되고, 기존 페이지를 뒤섞을 일이 없다.)</p>
<p>반면 우리가 나중에 만드는 <strong>세컨더리 인덱스</strong>(<code>CREATE INDEX</code>로 추가하는 것들)는 다르다. 세컨더리 인덱스의 리프 노드에는 그 행의 전체 데이터가 아니라, <strong>&quot;인덱스 건 컬럼 값 + 그 행의 PK 값&quot;</strong> 딱 이 두 개만 들어있다.</p>
<p>그래서 세컨더리 인덱스로 뭔가를 찾으면 2단계를 거친다.</p>
<pre><code>1단계: 세컨더리 인덱스에서 조건에 맞는 걸 찾는다 → (컬럼 값, PK 값)을 얻는다
2단계: 그 PK 값을 들고 클러스터드 인덱스(=테이블 본체)로 다시 찾아간다 → 나머지 컬럼들을 가져온다</code></pre><p><img src="https://velog.velcdn.com/images/yongmin_lee/post/f73cc656-2f4e-4002-be10-25cbc0746d41/image.png" alt="">
<em>출처: <a href="https://mangkyu.tistory.com/96">[Database] 인덱스(index)란?</a></em></p>
<p>그림의 <code>Index</code> 테이블이 세컨더리 인덱스, <code>pointer</code>가 PK 값, <code>Table</code>이 클러스터드 인덱스(테이블 본체)다. <code>company_id = 18</code>로 쿼리하면 인덱스에서 4개 행(빨간 테두리)을 먼저 찾고, 그 <code>pointer</code> 값들로 오른쪽 <code>Table</code>을 다시 찾아가 나머지 컬럼(<code>units</code>, <code>unit_cost</code>)을 가져온다 — 화살표 두 번이 곧 위에서 말한 1단계·2단계다.</p>
<p><code>SELECT *</code>처럼 인덱스에 없는 컬럼까지 필요하면 이 2단계를 거칠 수밖에 없다. 반대로 세컨더리 인덱스에 있는 컬럼만으로 쿼리가 끝난다면 2단계를 건너뛸 수 있는데 — 이게 바로 2부에서 다룰 <strong>커버링 인덱스</strong>의 원리다. 지금은 &quot;세컨더리 인덱스로 찾으면 보통 한 번 더 찾아가야 한다&quot;는 사실만 기억해두자.</p>
<h2 id="3-인덱스를-만들었는데도-못-타는-경우들">3. 인덱스를 만들었는데도 못 타는 경우들</h2>
<p>1, 2번을 이해하고 나면 &quot;인덱스가 있다&quot;와 &quot;이 쿼리가 그 인덱스를 탄다&quot;가 왜 다른 얘기인지 설명할 수 있다. <strong>정렬된 순서를 이용해서 범위를 좁힐 수 있어야만</strong> 인덱스가 의미가 있다. 좁힐 방법이 없으면 결국 리프 노드를 처음부터 끝까지 훑어야 하고, 그럼 B-Tree를 타는 의미가 없어진다.</p>
<p><strong>선행 와일드카드 <code>LIKE &#39;%키워드%&#39;</code>.</strong> <code>name</code> 컬럼에 인덱스가 있어도 소용없다. 인덱스는 <code>name</code>을 가나다순으로 정렬해뒀을 뿐인데, &quot;어딘가에 이 글자가 포함된 것&quot;은 그 정렬 순서로 좁힐 수가 없다. <code>LIKE &#39;디자인%&#39;</code>(뒤 와일드카드만)이라면 &quot;디자인&quot;으로 시작하는 구간으로 바로 뛰어들 수 있지만, <code>LIKE &#39;%디자인%&#39;</code>은 &quot;디자인&quot;이 앞에 올지 중간에 올지 끝에 올지 알 수 없으니 정렬 순서가 아무 도움이 안 된다.</p>
<p><strong>컬럼에 함수를 씌운 조건.</strong> <code>WHERE YEAR(created_at) = 2024</code> 같은 쿼리는 <code>created_at</code>에 인덱스가 있어도 못 탄다. 인덱스는 <code>created_at</code> 원본 값 기준으로 정렬돼 있지, <code>YEAR(created_at)</code> 결과 기준으로 정렬된 게 아니기 때문이다. 이 조건이 참인 행을 찾으려면 모든 행에 대해 <code>YEAR()</code>를 계산해봐야 하고, 그건 결국 풀 스캔이다.</p>
<p><strong>암묵적 타입 변환.</strong> 문자열 컬럼을 숫자와 비교하는 식으로 타입이 안 맞으면, MySQL이 비교 전에 컬럼 값을 변환해야 할 수 있다. 이것도 위와 같은 이유(정렬된 원본 값과 실제 비교 대상이 어긋남)로 인덱스를 무력화시킬 수 있다.</p>
<p>세 가지 다 원인은 하나다 — <strong>인덱스가 정렬해둔 기준과, 쿼리가 실제로 찾으려는 기준이 어긋난다.</strong> 이 어긋남이 있으면 인덱스는 그냥 &quot;존재하지만 안 쓰이는&quot; 상태가 된다.</p>
<h2 id="4-explain으로-인덱스-제대로-읽기">4. EXPLAIN으로 인덱스 제대로 읽기</h2>
<p>지금까지는 머릿속 모델이었다. 실제로 어떤 쿼리가 인덱스를 타는지 안 타는지는 추측하지 말고 직접 확인해야 한다. 그 도구가 <code>EXPLAIN</code>이다.</p>
<pre><code class="language-sql">EXPLAIN SELECT * FROM student_course WHERE student_profile_id = 1288;</code></pre>
<p>결과에서 눈여겨봐야 할 필드들.</p>
<ul>
<li><strong><code>type</code></strong>: 접근 방식의 등급. <code>const</code>/<code>eq_ref</code>(PK나 유니크 키로 단건) → <code>ref</code>(인덱스로 여러 건 좁힘) → <code>range</code>(범위 조건) → <code>index</code>(인덱스 전체를 훑음) → <code>ALL</code>(풀 테이블 스캔) 순으로 대체로 뒤로 갈수록 느리다. <strong><code>ALL</code>이 보이면 일단 의심한다.</strong></li>
<li><strong><code>possible_keys</code> / <code>key</code></strong>: <code>possible_keys</code>는 &quot;쓸 수도 있었던&quot; 인덱스 후보 목록이고, <code>key</code>는 옵티마이저가 <strong>실제로 선택한</strong> 인덱스다. <code>possible_keys</code>에 인덱스가 있어도 <code>key</code>가 비어있으면 결국 안 쓴 것이다.</li>
<li><strong><code>rows</code></strong>: 이 단계에서 읽을 것으로 <strong>예상</strong>하는 행 수. 어디까지나 통계 기반 추정치라 실제와 다를 수 있다.</li>
<li><strong><code>filtered</code></strong>: 그 <code>rows</code> 중에서 WHERE 조건까지 실제로 만족할 것으로 예상하는 비율(%). <code>rows</code>가 크고 <code>filtered</code>가 낮으면 &quot;많이 읽어놓고 대부분 버린다&quot;는 뜻이다.</li>
<li><strong><code>Extra</code></strong>: 추가 정보가 나온다. <code>Using index</code>는 커버링 인덱스로 테이블 접근 없이 끝났다는 뜻(좋음). <code>Using filesort</code>는 인덱스 순서로 정렬이 안 돼서 별도 정렬 작업이 붙었다는 뜻(주의). <code>Using temporary</code>는 임시 테이블을 만들어야 했다는 뜻(주의).</li>
</ul>
<p>MySQL 8.0.18부터는 <code>EXPLAIN ANALYZE</code>도 쓸 수 있는데, 이건 <strong>추정치가 아니라 실제로 쿼리를 실행하고 나온 진짜 수치</strong>(<code>actual time</code>, <code>actual rows</code>)를 보여준다. 옵티마이저의 추정(<code>rows</code>, <code>filtered</code>)은 통계가 오래됐거나 데이터 분포가 특이하면 틀릴 수 있어서, 확신이 필요할 땐 <code>EXPLAIN ANALYZE</code>로 실측하는 게 안전하다. 2부와 3부에서 보여줄 결과도 전부 이 실측값이다.</p>
<h2 id="5-공짜가-아니다--인덱스의-비용">5. 공짜가 아니다 — 인덱스의 비용</h2>
<p>여기까지 보면 &quot;그럼 그냥 모든 컬럼에 인덱스 걸면 되는 거 아닌가?&quot;라는 생각이 들 수 있다. 그렇지 않다.</p>
<p>2번에서 얘기했듯 인덱스는 &quot;정렬된 상태를 유지하는&quot; 구조다. 그 말은 곧, <strong>행을 추가(INSERT)하거나 인덱스 건 컬럼 값을 바꾸면(UPDATE), 그 정렬 순서도 같이 맞춰줘야 한다</strong>는 뜻이다. 인덱스가 하나면 한 번 더 쓰기 작업이 늘어나는 거고, 인덱스가 다섯 개면 다섯 번 더 늘어난다. 읽기는 빨라지지만 쓰기는 그만큼 느려지는, 전형적인 트레이드오프다. 여기에 인덱스 자체가 차지하는 디스크 용량도 무시할 수 없다 — 인덱스는 결국 원본 컬럼 값의 &quot;정렬된 사본&quot;이기 때문이다.</p>
<p>그래서 &quot;일단 다 걸어두면 안전하다&quot;는 생각은 틀렸다. 안 쓰이는 인덱스도 쓰기 비용은 똑같이 낸다. 인덱스 설계는 &quot;얼마나 많이 거느냐&quot;가 아니라 <strong>&quot;어떤 컬럼 조합을, 어떤 순서로, 하나만 제대로 만드느냐&quot;</strong>의 문제다.</p>
<hr>
<p>여기까지가 인덱스의 기본 뼈대다. 정리하면:</p>
<ol>
<li>인덱스는 정렬된 사본이고, 정렬돼 있어야 범위를 좁혀서 빠르게 찾을 수 있다.</li>
<li>MySQL(InnoDB)은 PK가 곧 테이블이고(클러스터드 인덱스), 세컨더리 인덱스는 보통 한 번 더 테이블을 찾아가야 한다.</li>
<li>정렬 기준과 쿼리 조건이 어긋나면(선행 와일드카드, 함수, 타입 변환) 인덱스는 무용지물이 된다.</li>
<li><code>EXPLAIN</code>/<code>EXPLAIN ANALYZE</code>로 추측 대신 실제로 확인한다.</li>
<li>인덱스는 읽기와 쓰기를 맞바꾸는 트레이드오프다.</li>
</ol>
<p>다음 2부에서는 이 뼈대 위에서 실전 질문들을 다룬다 — 컬럼이 여러 개일 땐 인덱스를 어떤 순서로 만들어야 하는지, 인덱스를 여러 개 걸어두면 MySQL이 알아서 잘 섞어 쓰는지(<code>index_merge</code>), 그리고 애초에 B-Tree로는 안 되는 것(부분 문자열 검색)은 어떻게 해결하는지.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Redis] 실시간 동기화 서버에서 만난 레이스 컨디션 - Lua Script로 원자성 확보하기]]></title>
            <link>https://velog.io/@yongmin_lee/Redis-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EB%8F%99%EA%B8%B0%ED%99%94-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EB%A7%8C%EB%82%9C-%EB%A0%88%EC%9D%B4%EC%8A%A4-%EC%BB%A8%EB%94%94%EC%85%98-Lua-Script%EB%A1%9C-%EC%9B%90%EC%9E%90%EC%84%B1-%ED%99%95%EB%B3%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yongmin_lee/Redis-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EB%8F%99%EA%B8%B0%ED%99%94-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EB%A7%8C%EB%82%9C-%EB%A0%88%EC%9D%B4%EC%8A%A4-%EC%BB%A8%EB%94%94%EC%85%98-Lua-Script%EB%A1%9C-%EC%9B%90%EC%9E%90%EC%84%B1-%ED%99%95%EB%B3%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 09 Aug 2026 14:48:41 GMT</pubDate>
            <description><![CDATA[<h2 id="배경-및-문제점">배경 및 문제점</h2>
<p>영상을 함께 시청하는 기능을 방 안의 영상 상태(재생 중인지, 몇 초 지점인지, 누가 마지막으로 조작했는지)를 Redis에 저장해두고 방 전체에 브로드캐스트하는 방법으로 구현했다.</p>
<p>PLAY/PAUSE/SEEK 같은 액션이 들어오면 <code>status</code>, <code>baseTime</code>, <code>baseTimestamp</code>, <code>updatedBy</code> 이렇게 4개 필드를 순서대로 하나씩 <code>HSET</code>으로 저장하는 방식이었다.</p>
<pre><code class="language-java">ops().put(key, F_STATUS, &quot;PLAYING&quot;);
ops().put(key, F_BASE_TIME, String.valueOf(t));
ops().put(key, F_BASE_TS, String.valueOf(nowMs));
ops().put(key, F_UPDATED_BY, String.valueOf(userId));</code></pre>
<p>동작은 잘 됐다. 근데 이 코드를 다시 들여다보다가, 이게 사실 두 가지 문제를 안고 있다는 걸 알게 됐다.</p>
<blockquote>
<p>💡 <strong>원자성(atomicity)이란?</strong>
어떤 작업이 &quot;전부 실행되거나, 아예 실행 안 되거나&quot; 둘 중 하나만 가능하고 중간 상태가 다른 곳에서 절대 보이지 않는 성질을 말한다. Redis 명령어 하나(예: <code>HSET</code> 한 번)는 원자적이지만, 그 명령어를 여러 번 연달아 보내는 것 자체는 원자적이지 않다 — 그 사이에 다른 요청이 끼어들 수 있다.</p>
</blockquote>
<p><strong>1) Torn read</strong></p>
<p>Redis 명령어 하나하나는 원자적이지만, 이 4개를 순서대로 보내는 전체 과정은 원자적이지 않다. <code>status</code>만 PLAYING으로 바뀐 그 찰나에 다른 요청이 <code>getState()</code>로 이 hash를 통째로 읽어버리면, status는 새 값인데 baseTime은 옛날 값인 — 앞뒤가 안 맞는 상태를 그대로 넘겨받게 된다.</p>
<p>그림으로 보면 이런 상황이다.
<img src="https://velog.velcdn.com/images/yongmin_lee/post/bd04d659-ad83-473c-9d85-96914ed277c8/image.png" alt=""></p>
<p>읽기 요청이 하필 4번의 HSET 사이에 끼어들면, 4개 필드가 &quot;일부는 새 값, 일부는 이전 값&quot;인 상태를 그대로 받아가 버린다.</p>
<p><strong>2) 순서 역전(lost update)</strong></p>
<p>이 코드는 버전이나 순번 체크 없이 그냥 &quot;마지막에 도착한 값&quot;으로 덮어쓴다. 방장이 PLAY를 누르고 바로 이어서 PAUSE를 눌렀다고 하면, 클릭 순서는 PLAY → PAUSE인데 네트워크 지연 때문에 서버엔 PAUSE가 먼저 도착하고 PLAY가 뒤늦게 도착할 수 있다. 그러면 최종 상태는 PAUSE가 아니라 PLAY로 남아버린다. <strong>먼저 보낸 게 이겨야 하는데, 나중에 도착한 게 이기는</strong> 셈이다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/e20e261e-3d0e-4577-a60c-29675cbebe7f/image.png" alt=""></p>
<p>클릭한 순서(①→②)와 서버에 도착한 순서(②→①)가 다르면, 아무 검증 없이 마지막에 도착한 요청이 이겨버려서 실제 의도와 반대 상태가 남는다.</p>
<p>방 인원이 몇 명 안 되니 자주 터지는 문제는 아니었지만, 실시간 동기화가 핵심인 서비스에서 &quot;가끔 영상이 이상하게 튄다&quot;는 건 그냥 넘길 수 없다고 생각해서 제대로 고쳐보기로 했다.</p>
<h2 id="해결-방법">해결 방법</h2>
<h3 id="후보-검토">후보 검토</h3>
<p>원자성을 보장하는 방법이 Lua 스크립트만 있는 건 아니라서, 후보를 먼저 쭉 정리해봤다.</p>
<table>
<thead>
<tr>
<th>방법</th>
<th>torn read 해결</th>
<th>순서 역전 해결</th>
<th>네트워크 왕복</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>Java <code>synchronized</code>/<code>Lock</code></td>
<td>△</td>
<td>△</td>
<td>0회</td>
<td>이 JVM 프로세스 안에서만 유효</td>
</tr>
<tr>
<td>Redis <code>MULTI</code>/<code>EXEC</code> (순수)</td>
<td>✅</td>
<td>❌</td>
<td>1회</td>
<td>조건부 쓰기가 안 됨(무조건 실행)</td>
</tr>
<tr>
<td>Redis <code>WATCH</code>+<code>MULTI</code>/<code>EXEC</code></td>
<td>✅</td>
<td>✅</td>
<td>2~3회+재시도</td>
<td>Redis의 &quot;진짜&quot; 낙관적 락</td>
</tr>
<tr>
<td>Redis 분산락(<code>SET NX</code>)</td>
<td>△</td>
<td>✅</td>
<td>3회</td>
<td>읽기 쪽도 락을 타야 완전해짐</td>
</tr>
<tr>
<td>Lua 스크립트</td>
<td>✅</td>
<td>✅</td>
<td><strong>1회</strong></td>
<td>읽기는 아무것도 신경 안 써도 됨</td>
</tr>
</tbody></table>
<blockquote>
<p>💡 <strong>표에 나온 Redis 용어들</strong></p>
<ul>
<li><strong>MULTI/EXEC</strong>: 여러 Redis 명령을 큐에 모아뒀다가 한 번에 실행하는 Redis의 기본 트랜잭션 기능. 다만 큐에 넣은 명령은 조건 없이 무조건 실행된다.</li>
<li><strong>WATCH</strong>: 특정 키를 감시하다가, <code>MULTI</code>~<code>EXEC</code> 사이에 그 키 값이 바뀌면 트랜잭션 전체를 실패시키는 기능. 이걸로 Redis에서도 낙관적 락(먼저 진행하고, 커밋 시점에 충돌 여부만 확인하는 방식)을 흉내 낼 수 있다.</li>
<li><strong>분산락(<code>SET NX</code>)</strong>: &quot;이 키가 아직 없을 때만 값을 설정한다&quot;는 명령을 이용해서, 여러 서버 인스턴스가 공유하는 락처럼 쓰는 방법.</li>
</ul>
</blockquote>
<p><code>synchronized</code>가 제일 빠르긴 한데(왕복 0회), 결정적인 약점이 두 개 있었다.</p>
<ul>
<li>이 JVM 프로세스 안에서만 보호된다. 지금은 서버가 1대라 괜찮지만, 나중에 서버를 여러 대로 늘리는 순간(다중 서버 확장을 계획하고 있어서) 인스턴스 A의 락은 인스턴스 B가 같은 Redis 키를 건드리는 걸 전혀 모른다.</li>
<li>쓰기만 잠근다고 되는 게 아니라, <code>getState()</code> 같은 읽기 쪽도 전부 같은 락을 타야 torn read가 완전히 막힌다. 새 코드가 추가될 때마다 &quot;이것도 락 걸어야 하나&quot;를 매번 기억해야 하는 구조다.</li>
</ul>
<p>반면 Lua는 원자성이 <strong>Redis 서버 자체(싱글 스레드 실행)에서 보장</strong>되기 때문에, 인스턴스가 몇 대든 상관없고 읽는 쪽은 아무 것도 안 해도 된다. 다른 방법들에 비해서 네트워크 왕복 횟수도 비교적 적기 때문에, 최종적으로 Lua 방식으로 선택하였다.</p>
<h3 id="구현">구현</h3>
<p>먼저 순서 역전을 판단할 기준이 필요했다. 그런데 이 기준을 &quot;서버가 요청을 받은 시각&quot;으로 잡으면 사실 원래 문제랑 똑같아진다 — 그게 바로 &quot;도착 순서&quot;니까. 그래서 <strong>클라이언트가 버튼을 누른 순간의 시각</strong>을 같이 보내도록 필드를 하나 추가했다.</p>
<pre><code class="language-java">// VideoControlRequest.java
private Long clientActionTime; // Date.now(), 서버 도착 순서가 아니라 이 값으로 최신 여부 판단</code></pre>
<p>방 영상 제어는 항상 그 방의 방장 한 명만 할 수 있어서(<code>validateHost</code>), 여러 사람의 시계를 동기화해야 하는 어려운 문제가 아니라 &quot;한 브라우저의 시계는 시간 순으로 흐른다&quot;는 훨씬 쉬운 가정만 있으면 됐다.</p>
<p>그 다음 Lua 스크립트로 &quot;저장된 값보다 최신일 때만 4개 필드를 한 번에 갱신&quot;하는 CAS를 만들었다.</p>
<blockquote>
<p>💡 <strong>CAS(Compare-And-Swap)란?</strong>
&quot;지금 값을 확인하고, 그게 조건에 맞을 때만 새 값으로 바꾼다&quot;는 원자적 동작. 낙관적 락을 실제로 구현하는 대표적인 방법 중 하나다. 여기서는 &quot;새로 들어온 액션이 지금 저장된 것보다 최신일 때만 반영한다&quot;는 조건으로 썼다.</p>
</blockquote>
<pre><code class="language-lua">local key = KEYS[1]
local incomingTs = tonumber(ARGV[1])

local storedTsStr = redis.call(&#39;HGET&#39;, key, &#39;actionTs&#39;)
local storedTs = storedTsStr and tonumber(storedTsStr) or nil

if storedTs ~= nil and incomingTs &lt;= storedTs then
    return 0
end

local args = {key, &#39;actionTs&#39;, ARGV[1]}
for i = 2, #ARGV do
    args[#args + 1] = ARGV[i]
end

redis.call(&#39;HSET&#39;, unpack(args))
return 1</code></pre>
<p><code>ARGV[1]</code>이 클라이언트 액션 시각이고, 이게 저장된 <code>actionTs</code>보다 크지 않으면(옛날 요청이거나 중복이면) 아무것도 안 쓰고 0을 반환한다. 최신이면 <code>actionTs</code>를 갱신하면서 나머지 필드들도 같이 한 번에 HSET한다. 이 스크립트가 서버에서 실행되는 동안은 다른 어떤 명령도 끼어들 수 없기 때문에, torn read가 날 수 있는 &quot;중간 상태&quot; 자체가 없다.</p>
<pre><code class="language-java">Long applied = redisTemplate.execute(COMPARE_AND_SET_SCRIPT, List.of(key), luaArgs.toArray());
boolean accepted = applied != null &amp;&amp; applied == 1L;</code></pre>
<p>Spring Data Redis의 <code>execute</code>가 내부적으로 이 스크립트를 <code>EVALSHA</code>(없으면 <code>EVAL</code>)로 Redis에 보내는 방식이다.</p>
<blockquote>
<p>💡 <strong>용어 정리</strong></p>
<ul>
<li><strong>EVAL / EVALSHA</strong>: Redis에 Lua 스크립트 실행을 요청하는 명령어. <code>EVAL</code>은 스크립트 원문을 그대로 보내고, <code>EVALSHA</code>는 미리 등록해둔 스크립트를 SHA1 해시값만으로 실행시켜서 트래픽을 아낀다. 해당 해시가 Redis에 없으면(처음 실행이거나 캐시가 지워졌으면) 자동으로 <code>EVAL</code>로 재시도한다.</li>
</ul>
</blockquote>
<h3 id="정합성-검증-성능-대신-정합성으로">정합성 검증 (성능 대신 정합성으로)</h3>
<p>이건 latency를 줄인 게 아니라 정합성 버그를 없앤 거라서, &quot;얼마나 빨라졌나&quot;보다는 <strong>&quot;실제로 깨지는지 vs 안 깨지는지&quot;를 수치로 보여주는 게 맞다고 생각했다.</strong> 그래서 예전 방식(개별 HSET)과 지금 방식(Lua)을 같은 조건에서 대조하는 테스트를 짰다.</p>
<p><strong>예전 방식 재현</strong> — 필드 4개를 순서대로 개별 HSET(사이 지연 포함)하면서 리더가 계속 읽게 했더니, 실제로 이런 스냅샷이 잡혔다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/b2cf1c71-5291-49dc-9a40-0b12cfb5b7af/image.png" alt=""></p>
<p><code>f1</code>은 벌써 새 라운드로 바뀌었는데 <code>f2~f4</code>는 아직 이전 라운드에 머물러 있는 게 그대로 찍힌다. 말로만 하던 문제가 실제로 재현된 걸 보니, 이게 그냥 이론이 아니라 진짜 있었던 버그라는 게 확실해졌다.</p>
<p><strong>Lua 방식</strong>
<img src="https://velog.velcdn.com/images/yongmin_lee/post/9104952f-c769-4b7a-8c32-288b72cbba82/image.png" alt=""></p>
<p>실제 프로덕션 메서드(<code>applyControl</code>)를 3000번 반복 호출하면서 같은 방식으로 감시했는데, 단 한 번도 안 잡혔다. 예전 방식은 필드 사이에 일부러 지연을 넣어야 겨우 재현됐던 것과 다르게, Lua는 애초에 그 지연을 끼워 넣을 자리 자체가 없다 — 그게 핵심이라고 생각한다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/ce45173a-3e22-4dbe-bde5-6b990227aed7/image.png" alt=""></p>
<p>순서 역전 쪽도 따로 확인했다. 스레드 30개가 서로 다른 타임스탬프(0~29)로 동시에 요청을 쏘게 했더니, 실제 Redis 도착 순서(=스레드 스케줄링 결과)와 무관하게 여러 요청 중 <strong>가장 최신 타임스탬프를 보낸 요청만 최종 반영</strong>된 것을 확인할 수 있었다. 3번 반복 실행해도 결과는 항상 같았다.</p>
<h2 id="결론">결론</h2>
<p>Redis에 여러 필드를 나눠 쓰는 코드는 &quot;동작은 하는데 가끔 이상해지는&quot; 버그를 만든다는 걸 직접 겪고 알게 됐다. <code>synchronized</code>, <code>MULTI/EXEC</code>, <code>WATCH</code>, 분산락까지 후보를 다 검토해봤는데, 결국 <strong>Lua 스크립트로 여러 필드 쓰기 + 클라이언트 타임스탬프 기반 CAS를 왕복 1번에 묶는 방식</strong>이 우리 조건(방장 1명만 쓰는 단일 라이터, 나중에 서버를 여러 대로 늘릴 계획, 읽기가 훨씬 잦음)에 제일 잘 맞았다.</p>
<ul>
<li>torn read: 예전 방식은 실제로 재현됨(캡처된 스냅샷 확인) → Lua 방식은 3000회 반복해도 0건</li>
<li>순서 역전: 30개 스레드가 동시에 경합해도 항상 가장 최신 액션만 최종 반영됨(3회 반복 실행, 항상 동일)</li>
</ul>
<p>원자성이 Redis 자체에서 보장되는 방식이라, 나중에 서버를 여러 대로 늘려도 이 부분은 손댈 필요가 없다는 것도 큰 이유였다. 다음엔 이 방식으로 참여자 관리 로직의 원자성도 다시 한번 점검해봐야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[AWS] S3로 이미지 업로드&조회 하기(Presigned URL)]]></title>
            <link>https://velog.io/@yongmin_lee/AWS-S3%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C%EC%A1%B0%ED%9A%8C-%ED%95%98%EA%B8%B0Presigned-URL</link>
            <guid>https://velog.io/@yongmin_lee/AWS-S3%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C%EC%A1%B0%ED%9A%8C-%ED%95%98%EA%B8%B0Presigned-URL</guid>
            <pubDate>Wed, 05 Aug 2026 11:09:08 GMT</pubDate>
            <description><![CDATA[<p>이미지 업로드 기능을 구현하다 보면 가장 먼저 떠오르는 방식은 클라이언트 → 서버 → S3 순으로 이미지를 전달하는 방식이다.
그런데 이렇게 구현하면 서버가 이미지 바이너리를 그대로 받아서 S3로 다시 전달해야 하기 때문에, 서버 리소스와 트래픽을 불필요하게 잡아먹는다.</p>
<p>이미지 개수가 많아지거나 용량이 커지면 이 부담은 더 커질 텐데, 이걸 어떻게 줄일 수 있을까 고민하다가 <strong>Presigned URL</strong> 방식을 알게 되었다.</p>
<p>정리하면서 구현해보자!</p>
<h2 id="presigned-url이란">Presigned URL이란?</h2>
<p>Presigned URL은 S3 버킷에 대한 접근 권한을 임시로 부여한 URL이다.
서버가 AWS 자격증명을 이용해 &quot;이 URL로는 몇 분 동안만 특정 파일에 대해 업로드(혹은 조회)가 가능하다&quot;는 서명된 URL을 발급해주면, 클라이언트는 그 URL을 이용해서 <strong>S3에 직접</strong> 요청을 보낼 수 있다.</p>
<p>즉, 이미지 바이너리 자체는 서버를 거치지 않고 클라이언트 ↔ S3 사이에서 오간다. 서버는 URL 발급만 담당하는 것이다.</p>
<h3 id="업로드-흐름">업로드 흐름</h3>
<ol>
<li>클라이언트가 서버에 &quot;이 파일을 업로드하고 싶다&quot;고 요청 (파일명, 타입 등 전달)</li>
<li>서버는 S3에 대한 업로드용 Presigned URL을 생성해서 클라이언트에게 응답</li>
<li>클라이언트는 응답받은 URL로 S3에 <strong>직접 PUT 요청</strong>을 보내서 이미지 업로드</li>
<li>업로드가 끝나면 클라이언트는 서버에 완료를 알리거나, 파일 key를 DB에 저장 요청</li>
</ol>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/bde651e5-a47b-4513-9862-45c4166aca42/image.png" alt=""></p>
<h3 id="조회-흐름">조회 흐름</h3>
<ol>
<li>클라이언트가 서버에 이미지 조회를 요청 (게시글 id 등만 전달 — key를 클라이언트가 직접 들고 있게 하지 않는다)</li>
<li>서버는 전달받은 id로 DB를 조회해서 해당 게시글에 저장된 이미지 key를 가져옴</li>
<li>조회한 key를 기반으로 조회용 Presigned URL을 생성해서 응답</li>
<li>클라이언트는 응답받은 URL로 이미지를 바로 렌더링</li>
</ol>
<p>key는 결국 S3 객체에 접근할 수 있는 정보이기 때문에, 클라이언트가 직접 들고 있다가 그대로 요청에 실어 보내는 구조는 위험하다고 생각했다. id만 넘기고 key는 서버(DB)에서만 관리하는 쪽으로 설계했다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/a44f6922-9366-484f-8e1b-7a7c1adec193/image.png" alt=""></p>
<p>버킷을 퍼블릭으로 열어두지 않고도 이미지를 보여줄 수 있다는 점, 그리고 URL에 만료 시간이 있어서 무한정 노출되지 않는다는 점이 이 방식의 장점인 것 같다.</p>
<h2 id="사전-준비-s3-iam">사전 준비 (S3, IAM)</h2>
<p>구현에 들어가기 전에, AWS 콘솔에서 미리 해둬야 하는 것들이 있다.</p>
<p><strong>1. S3 버킷 생성</strong></p>
<p>이미지를 저장할 버킷을 하나 만든다. 이번 구현은 버킷을 퍼블릭으로 열지 않고 presigned URL로만 접근하는 방식이라, <strong>퍼블릭 액세스 차단은 그대로 켜둔 상태</strong>로 진행했다.</p>
<p><strong>2. CORS 설정</strong></p>
<p>클라이언트(브라우저)에서 presigned URL로 S3에 직접 PUT/GET 요청을 보내야 하기 때문에, 버킷 권한 → CORS 설정에서 내 프론트엔드 도메인을 허용해줘야 한다.</p>
<p><strong>3. IAM 사용자 및 정책</strong></p>
<p>서버가 presigned URL을 발급하려면 S3에 접근할 수 있는 AWS 자격증명(액세스키, 시크릿키)이 필요하다. 이걸 위해 IAM에서 별도 사용자를 만들고, 아래처럼 필요한 버킷에 대해 딱 필요한 권한만 부여했다.</p>
<pre><code class="language-json">{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Action&quot;: [
        &quot;s3:PutObject&quot;,
        &quot;s3:GetObject&quot;
      ],
      &quot;Resource&quot;: &quot;arn:aws:s3:::my-bucket-name/*&quot;
    }
  ]
}</code></pre>
<p>루트 계정 자격증명을 그대로 쓰지 않고, 이 IAM 사용자의 액세스키/시크릿키를 발급받아서 <code>application.yml</code>에 등록했다.</p>
<h2 id="구현">구현</h2>
<h3 id="1-의존성-추가">1. 의존성 추가</h3>
<pre><code class="language-gradle">implementation &#39;software.amazon.awssdk:s3:2.25.0&#39;</code></pre>
<p>AWS SDK for Java v2 기준으로 작성했다.</p>
<h3 id="2-applicationyml-설정">2. application.yml 설정</h3>
<pre><code class="language-yaml">cloud:
  aws:
    credentials:
      access-key: ${AWS_ACCESS_KEY}
      secret-key: ${AWS_SECRET_KEY}
    region: ap-northeast-2
    s3:
      bucket: my-bucket-name</code></pre>
<p>access key, secret key는 당연히 환경변수나 별도 설정 파일로 분리해서 git에 올라가지 않도록 했다.</p>
<h3 id="3-s3config">3. S3Config</h3>
<pre><code class="language-java">@Configuration
public class S3Config {

    @Value(&quot;${cloud.aws.credentials.access-key}&quot;)
    private String accessKey;

    @Value(&quot;${cloud.aws.credentials.secret-key}&quot;)
    private String secretKey;

    @Value(&quot;${cloud.aws.region}&quot;)
    private String region;

    @Bean
    public S3Presigner s3Presigner() {
        AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);

        return S3Presigner.builder()
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(credentials))
                .build();
    }
}</code></pre>
<p>Presigned URL을 생성할 때는 <code>S3Client</code>가 아니라 <code>S3Presigner</code>를 사용한다.</p>
<h3 id="4-presigned-url-발급-서비스">4. Presigned URL 발급 서비스</h3>
<p>업로드용, 조회용을 각각 만들었다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class S3PresignedUrlService {

    private final S3Presigner s3Presigner;

    @Value(&quot;${cloud.aws.s3.bucket}&quot;)
    private String bucket;

    // 업로드용 Presigned URL 생성
    public String generateUploadUrl(String key, String contentType) {
        PutObjectRequest objectRequest = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType(contentType)
                .build();

        PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(5))
                .putObjectRequest(objectRequest)
                .build();

        PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
        return presignedRequest.url().toString();
    }

    // 조회용 Presigned URL 생성
    public String generateGetUrl(String key) {
        GetObjectRequest objectRequest = GetObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build();

        GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(10))
                .getObjectRequest(objectRequest)
                .build();

        PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest);
        return presignedRequest.url().toString();
    }
}</code></pre>
<p>업로드는 짧게(5분), 조회는 조금 더 길게(10분) 만료 시간을 잡았다. 이 값은 서비스 특성에 맞게 조절하면 될 것 같다.</p>
<h3 id="5-controller">5. Controller</h3>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/api/images&quot;)
@RequiredArgsConstructor
public class ImageController {

    private final S3PresignedUrlService s3PresignedUrlService;

    @GetMapping(&quot;/upload-url&quot;)
    public ResponseEntity&lt;PresignedUrlResponse&gt; getUploadUrl(
            @RequestParam String fileName,
            @RequestParam String contentType
    ) {
        String key = &quot;images/&quot; + UUID.randomUUID() + &quot;_&quot; + fileName;
        String uploadUrl = s3PresignedUrlService.generateUploadUrl(key, contentType);

        return ResponseEntity.ok(new PresignedUrlResponse(uploadUrl, key));
    }

    @GetMapping(&quot;/view-url&quot;)
    public ResponseEntity&lt;String&gt; getViewUrl(@RequestParam String key) {
        String viewUrl = s3PresignedUrlService.generateGetUrl(key);
        return ResponseEntity.ok(viewUrl);
    }
}</code></pre>
<p>key는 파일명이 겹치지 않도록 UUID를 붙여서 생성했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[AWS] EC2 인스턴스 디스크 용량 늘리기]]></title>
            <link>https://velog.io/@yongmin_lee/AWS-EC2-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EB%94%94%EC%8A%A4%ED%81%AC-%EC%9A%A9%EB%9F%89-%EB%8A%98%EB%A6%AC%EA%B8%B0</link>
            <guid>https://velog.io/@yongmin_lee/AWS-EC2-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EB%94%94%EC%8A%A4%ED%81%AC-%EC%9A%A9%EB%9F%89-%EB%8A%98%EB%A6%AC%EA%B8%B0</guid>
            <pubDate>Sun, 02 Aug 2026 11:07:35 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/a0804efe-bf23-453f-a340-a0dd2c5b5a6b/image.png" alt="">
EC2에서 docker compose 관련 작업을 하고 다시 올렸는데, 로그인 페이지에서 502가 떴다.
뭐지? 원인을 확인해보니 EC2의 디스크가 100%를 차지하고 있었다...
docker compose build를 여러 번 반복하면서 빌드 캐시와 이미지가 계속 쌓였고, 그게 디스크 용량을 다 잡아먹은 것 같다.</p>
<p>그럼 EC2의 용량을 늘려보자!</p>
<h2 id="1-ebs-볼륨-확장">1. EBS 볼륨 확장</h2>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/e44a565d-e735-45ca-9c95-7c9f90e17a04/image.png" alt="">
우선 디스크 용량을 늘리고자 하는 EC2의 상세 정보에서 &#39;스토리지&#39;를 통해 연결된 볼륨으로 접근한다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/5a6d3e37-a254-4902-bf57-f945dab94aaf/image.png" alt="">
늘리고자 하는 볼륨을 선택하고, &#39;볼륨 수정&#39;을 누른다.
<img src="https://velog.velcdn.com/images/yongmin_lee/post/2e4d880e-de5a-4c1a-bc62-65f4e03936ba/image.png" alt="">
원하는 볼륨으로 수정한다.(기본은 8GB였는데, 나는 20GB로 늘렸다.)</p>
<h2 id="2-파일-시스템-확장">2. 파일 시스템 확장</h2>
<p>볼륨 크기를 늘리고 EC2에 직접 연결하여, 확장된 EBS 볼륨을 인식하도록 파일 시스템을 확장해야 한다.</p>
<ul>
<li><p>크기를 조정할 파티션 확인</p>
<pre><code>$lsblk</code></pre><p><img src="https://velog.velcdn.com/images/yongmin_lee/post/e94e8903-1922-43a2-b5e5-2e14a3fd983d/image.png" alt="">
lsblk로 보니 디스크(nvme0n1)가 20G로 늘었는데 루트 파티션(nvme0n1p1)은 여전히 7G인 것을 볼 수 있다. 
루트 파티션을 늘려보자!</p>
</li>
<li><p>파티션 크기 조정</p>
<pre><code>$ sudo growpart /dev/nvme0n1 1</code></pre><p><img src="https://velog.velcdn.com/images/yongmin_lee/post/3212971c-42ab-4a93-99ac-c1aa411c532b/image.png" alt=""></p>
</li>
</ul>
<p>lsblk를 통해 다시 확인하니, 루트 파티션(nvme0n1p1)이 19G로 늘어난 것을 확인할 수 있다.
마지막으로, 파티션 안에 있는 파일 시스템을 늘려야 한다.</p>
<ul>
<li>파일 시스템 확장<pre><code>$ sudo resize2fs /dev/nvme0n1p1</code></pre><img src="https://velog.velcdn.com/images/yongmin_lee/post/80b20640-8884-4727-afd8-baed83941b16/image.png" alt=""></li>
</ul>
<p>변경된 디스크 용량을 확인하니, 디스크 용량에 여유가 생긴 것을 확인할 수 있다!
docker compose를 다시 올리니, 서버도 제대로 돌아간다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[첫 팀 프로젝트 회고]]></title>
            <link>https://velog.io/@yongmin_lee/%EC%B2%AB-%ED%8C%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@yongmin_lee/%EC%B2%AB-%ED%8C%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Tue, 26 Aug 2025 13:02:07 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/6923554c-37ff-498b-b7c1-701b79d7adc8/image.png" alt="">
UMC라는 동아리에서 팀을 구성하여 방학동안 프로젝트를 진행했다! 난 Node.js 백엔드 개발자 포지션으로 참여하였다.</p>
<p>프로젝트를 하는 동안 무사히 마칠 수 있을까 걱정하였지만 동아리원들끼리 으쌰으쌰하여 여유롭게 결과를 만들어 낸 것 같아 뿌듯하다.</p>
<p>개발기간에는 기록을 하지 못해서 프로젝트가 끝난 지금 프로젝트 과정을 돌아보고자 한다.</p>
<h3 id="🎃-프로젝트-소개">🎃 프로젝트 소개</h3>
<ul>
<li><p>프로젝트 인원: 8명 (PM 1명, 디자이너 1명, 프론트 3명, 백엔드 3명)</p>
</li>
<li><p>프로젝트: 스타트업 네트워킹 및 구인구직 플랫폼</p>
</li>
<li><p>깃허브 링크: <a href="https://github.com/UMC-MyFit/my-fit-back">https://github.com/UMC-MyFit/my-fit-back</a></p>
</li>
<li><p>협업: GitHub, Notion, Discord</p>
</li>
<li><p>기술 스택</p>
<ul>
<li>Frontend: React, Redux, Tailwind, Typescript</li>
<li>Backend: Node.js, Express, MySQL, Redis, Websocket, Oauth2</li>
<li>Infra: AWS EC2, RDS, S3, Nginx, GitHub Actions, PM2</li>
</ul>
</li>
</ul>
<h3 id="👨💻나의-역할">👨‍💻나의 역할</h3>
<ul>
<li>ERD 설계 및 Prisma 스키마 작성</li>
<li>API 명세서 작성</li>
<li>공통 응답 형식 및 에러 핸들링 코드 작성</li>
<li>회원가입 및 로그인 기능 구현</li>
<li>이력/활동 카드 기능 구현</li>
<li>채팅/커피챗 기능 구현</li>
<li>알림 기능 구현</li>
</ul>
<hr>
<h3 id="🚗erd-설계">🚗ERD 설계</h3>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/82439408-326a-4d46-a0dc-c2ede456a773/image.png" alt="">
프로젝트 첫 주에는 ERD 설계랑 Prisma 스키마만 작성했던 것 같다.</p>
<p>ERD쪽은 백엔드 팀장 형의 도움을 많이 받았다. 기획서를 보고 ERD를 빠르게 설계하는 것도 중요하구나를 느꼈다..</p>
<h3 id="📄api-명서세-작성">📄API 명서세 작성</h3>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/db7aea15-f193-41d7-a4d4-9961f6667dd0/image.png" alt="">
2주차엔 일주일 내내 API 명세서만 작성했다 (작성하면서 빨리 API 구현하고 싶다는 생각밖에 없었다)</p>
<p>본격적으로 API를 구현하기 전에 어떤 API가 필요하고, 어떤 res, req 형태를 갖는지, 쿼리파라미터나 Params로 뭘 받아야 하는 지, 예외상황은 무엇이 있는지 설계하는 작업이다.</p>
<p>일주일 내내 저것만 작성하니까 이제 어떤 화면에선 어떤 API를 어떤식으로 써야할 지 자동으로 분석하게 된다😂</p>
<h3 id="🧯공통-응답--에러-핸들링">🧯공통 응답 &amp; 에러 핸들링</h3>
<p>3주차엔 일단 각 API를 구현하기 전에 공통 응답 포맷과 전역 에러 핸들링 틀을 먼저 잡았다.</p>
<p>API를 막 구현하기 시작하면 응답 모양이 제각각이 되기 쉬워서, 초기에 규약을 잡아두어 개발 속도를 높혔다.</p>
<h3 id="🔐회원가입-및-로그인-기능-구현">🔐회원가입 및 로그인 기능 구현</h3>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/ecbeece1-c0dc-4475-abd1-6c99bdc032bc/image.png" alt=""></p>
<p>3주차 부터 드디어 실질적인 API 구현 작업을 들어갔다. 로그인 방식을 세션 방식으로 구현할지, JWT로 구현할지 고민했는데 백엔드 개발자들과 회의를 통해 빠른 개발을 위해 세션 방식으로 구현하기로 했었다.</p>
<p>세션 방식으로 결정한 후, Passport.js Local 전략을 적용해 로그인 로직을 구성했고, 인증 성공 시 서버 세션을 생성해 쿠키로 전달하는 구조를 만들었다.</p>
<p>회원가입에서는 비밀번호를 bcrypt로 해시 처리하여 저장했고, 이메일 인증을 위해 Nodemailer로 인증 메일을 보내고, Redis에 인증 코드를 TTL과 함께 저장하는 방식을 구현했다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/6780a156-f102-410d-9218-7daab08049bf/image.png" alt=""></p>
<p>회원가입 로직은 크게 아이디/비밀번호 입력 -&gt; 개인정보 입력 -&gt; 첫 이력/활동 카드 작성의 세 단계로 구성되어 있다. 그런데 여기서 구현하기 어려웠던 점이 하나 있었다. 이력/활동 카드 작성은 회원가입 단계에서만 필요한 것이 아니라, 가입 후에도 자유롭게 작성할 수 있는 기능이었기 때문에 API를 별도로 분리했고, 개인정보 입력까지만 완료하면 User 테이블에 유저 정보가 저장되도록 했다.</p>
<p>문제는 사용자가 이력/활동 카드 작성 화면에서 뒤로가기를 눌렀을 때 발생했다. 이미 User 테이블에 해당 이메일을 가진 유저가 생성되어 있는데, 가입이 완전히 끝난 상태는 아니어서 중복 회원으로 처리되어 중간에 멈춰버리는 경우가 생긴 것이다.</p>
<p>이를 해결하기 위해 User 테이블에 is_profile_completed 플래그를 추가하여 첫 이력/활동 카드가 작성되기 전까지는 이 값이 false로 유지되고, 해당 상태의 유저는 &quot;미완성 회원&quot;으로 간주되도록 했다. 회원 정보가 User 테이블에 생성된 상태에서 뒤로가기를 눌러서 이미 해당 이메일을 가진 유저가 User 테이블에 존재하지만 is_profile_completed가 false인 경우 해당 유저의 정보를 지우고 다시 생성하도록 하도록 로직을 설계하였다. 이 방식으로 뒤로가기 시 발생하는 불완전한 회원 데이터 문제를 안정적으로 처리할 수 있었다.</p>
<h3 id="💳️이력활동-카드-기능-구현">💳️이력/활동 카드 기능 구현</h3>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/9edde8e1-7624-448c-8ff5-78d4208b903a/image.png" alt="">
이력/활동 카드 기능은 유저가 자신의 이력을 카드 형태로 작성하는 공간이다. 처음에는 기본적인 카드 생성, 조회, 수정, 삭제 API를 빠르게 구현했는데, 필터링이 꼭 필요하다는 요구가 나왔다.</p>
<p>그래서 카드 목록을 불러올 때 단순히 모든 데이터를 반환하지 않고, Params 기반 필터링을 지원하도록 설계했다.</p>
<p>예를 들어 특정 유저의 카드만 보고 싶을 때는 ?ownerId=, 키워드를 검색하고 싶을 때는 ?keyword= 같은 방식으로 요청을 받을 수 있게 했다.</p>
<h3 id="💬채팅커피챗-기능-구현">💬채팅/커피챗 기능 구현</h3>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/4ea4a939-9950-4e65-8e95-d1fcaee93285/image.png" alt="">
채팅과 커피챗 기능은 이번 프로젝트에서 가장 복잡했던 부분 중 하나였다. 처음에는 단수히 1:1 메시지를 주고받는 구조만 구현하면 될 줄 알았는데, 실제로 프론트엔드와 연결하면서 고려해야 할 사항들이 많았다.</p>
<p>우선 채팅은 Socket.io 기반의 실시간 통신으로 구현했다. 메시지를 보낼 때는 DB에 저장하고, 동시에 연결된 상대방에게 바로 이벤트를 전송하는 방식이다. 하지만 문제가 하나 있었는데, 채팅방에 입장할 때 모든 메시지를 한 번에 불러오면 성능이 떨어진다는 점이었다. 이를 해결하기 위해 Redis에 최근 20개 메시지를 캐싱하고, 입장 시 캐시에서 먼저 불러오도록 설계하였다. 이렇게 하니까 채팅방 로딩 속도가 눈에 띄게 빨라졌다.</p>
<p>커피챗 기능은 채팅과 연결된 일정 관리 기능이라고 할 수 있다. 단순한 메시지가 아니라 &quot;만나자&quot;라는 요청이므로 별도의 CoffeeChat 모델을 두어 구현했다. 사용자가 커피챗을 요청하면 일정(날짜, 시간, 장소)이 생성되고, 상대방은 이를 수락/거절할 수 있다. 상태는 PENDING -&gt; ACCEPTED/REJECTED -&gt; CANCELED로 전이되도록 설계했다.</p>
<h3 id="🔔알림-기능-구현">🔔알림 기능 구현</h3>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/2e4193bd-87a2-4eb3-ae7c-3b4e9ec76bd0/image.png" alt="">
알림 기능은 실시간 알림이나 인앱 알림이 아니라서 기본적인 CRUD만으로 구현할 수 있었다.</p>
<p>별도의 Notification 테이블을 두고, 네트워크 신청, 피드 좋아요, 댓글 작성 같은 이벤트가 발생하면 해당 테이블에 새로운 레코드를 추가하는 구조다. </p>
<h3 id="😅-아쉬운-점">😅 아쉬운 점</h3>
<p>이번 프로젝트를 진행하면서 몇 가지 아쉬운 점도 남았다.</p>
<p>첫 번째는 회원가입에서 OAuth 로그인을 구현하지 못한 점이다. 초기 설계 단계에서 OAuth(구글, 카카오, 네이버) 도입을 고려했지만, 세션 기반 로직과 이메일 인증 구현만으로도 시간이 빠듯해 결국 적용하지 못했다. 실제 서비스라면 접근성을 높이고 가입 장벽을 낮추기 위해 OAuth는 꼭 필요했을 텐데, 이를 구현하지 못한 것이 아쉬움으로 남았다.</p>
<p>두 번째는 알림 기능에서 중복 처리 로직을 완전히 해결하지 못한 점이다. 같은 게시글에 좋아요나 네트워크 신청을 계속해서 생성하고 취소하는 것을 반복하면 알림이 과도하게 쌓였다. 이러한 문제를 백엔드 딴에서 해결해보고 싶었는데, 당시엔 시간이 부족하여 단순 생성·조회 로직만 완성하는 데 그쳤다.</p>
<p>세 번째는 프로젝트 진행 중 코드 리뷰를 체계적으로 진행하지 못한 점이다. 대부분의 개발은 각자 맡은 기능을 빠르게 구현하는 데 집중했고, 주 1회 회의에서 구두로만 공유하는 경우가 많았다.</p>
<p>이러한 아쉬움들은 다음 프로젝트에서 꼭 보완하고 싶은 부분이다. OAuth 로그인 같은 핵심 기능은 간단한 토이 프로젝트를 진행하면서 꼭 적용해보고 싶다.</p>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/666d5ee3-de50-45e7-8300-c2d836be2dc6/image.png" alt="">
내 첫 팀 프로젝트는 좋은 팀원들을 만나 함께 했기에 끝까지 완주할 수 있었다고 생각한다.</p>
<p>개발 과정에서 나의 부족한 점도 많이 깨달을 수 있어서 더 성장하고 싶다고 느낀 계기가 된 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[대상혁 순발력 따라잡는 법]]></title>
            <link>https://velog.io/@yongmin_lee/%EB%8C%80%EC%83%81%ED%98%81-%EC%88%9C%EB%B0%9C%EB%A0%A5-%EB%94%B0%EB%9D%BC%EC%9E%A1%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@yongmin_lee/%EB%8C%80%EC%83%81%ED%98%81-%EC%88%9C%EB%B0%9C%EB%A0%A5-%EB%94%B0%EB%9D%BC%EC%9E%A1%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Sun, 05 Jan 2025 10:53:45 GMT</pubDate>
            <description><![CDATA[<h2 id="📏-자-잡기">📏 자 잡기!</h2>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/f951af6e-bf75-466a-957a-bad5fc3c3d8b/image.png" alt=""></p>
<p>떨어지는 자를 잡아서 자신의 순발력을 테스트할 수 있는 게임</p>
<p>게임은 여기서 플레이할 수 있다👇
<a href="https://ruler-psi.vercel.app/">https://ruler-psi.vercel.app/</a></p>
<h2 id="💻-개발-동기">💻 개발 동기</h2>
<p><a href="https://www.youtube.com/shorts/nWQgcMMdAtY">https://www.youtube.com/shorts/nWQgcMMdAtY</a>
페이커 선수와 도란 선수가 자 잡기 게임을 하는 영상을 보고, 웹 게임으로 만들어 보면 재밌을 것 같다고 생각해서 만들게 되었다.
아직 초보 개발자인 나는 제대로된 배포를 한 번도 해보지 않아서, 쉬운 프로젝트라도 배포를 해보고 싶었다.</p>
<h2 id="🐶-게임-방법">🐶 게임 방법</h2>
<p><img src="https://velog.velcdn.com/images/yongmin_lee/post/bbe32e4e-d346-4e24-9729-a94303f51a9e/image.png" alt="">
시작 버튼을 누르면 게임 페이지로 이동한다.
<img src="https://velog.velcdn.com/images/yongmin_lee/post/ae8882db-a797-44f9-b3e6-0808f8d94204/image.png" alt="">
게임 페이지로 들어오면 3초의 카운트 다운 후 게임이 시작된다.
<img src="https://velog.velcdn.com/images/yongmin_lee/post/babaddf4-c36b-430f-9211-63297f73719d/image.png" alt="">
3~7초 이후 자가 떨어지고 클릭하면 1초 후 결과 페이지로 이동한다.
<img src="https://velog.velcdn.com/images/yongmin_lee/post/824cc181-26dd-442c-8afc-f62dd2170fbe/image.png" alt="">
게임 결과에 따른 멘트가 나오고 다시하기를 누르면 게임이 다시 시작된다.</p>
<h2 id="🍔-기술-스택">🍔 기술 스택</h2>
<p>사실 next.js가 끝이다😅
배포는 vercel로 진행하였다!</p>
<h2 id="⏰-첫-배포-소감">⏰ 첫 배포 소감</h2>
<p>지인들한테 &#39;요즘 유행하는 게임이야~&#39;하고 보내고, 게임 결과를 공유하는 것을 보는게 정말 뿌듯했다.
다음엔 백엔드 및 DB까지 제대로 갖춘 서비스를 배포해보고 싶다.</p>
<h2 id="🥲-아쉬운-점">🥲 아쉬운 점</h2>
<p>결과의 오차가 생각보다 많이 크다..
이미지의 현재 y좌표를 통해 눈금을 도출하는데, y좌표가 커질 수록 오차가 커진다..
7cm 정도까지는 정확한데, 조금씩 오차가 생기더니 15cm 부근에선 거의 0.3cm정도 오차가 생긴다.
게임을 시작하고 끝날 때 까지 시간을 측정하여 눈금을 도출하는 방법도 구현해 봤지만 오차가 더 커서 다시 좌표 방법으로 돌아왔다.</p>
]]></description>
        </item>
    </channel>
</rss>