<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Dot My Moments</title>
        <link>https://velog.io/</link>
        <description>Dot Your moment.</description>
        <lastBuildDate>Mon, 27 Apr 2026 07:53:48 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Dot My Moments</title>
            <url>https://velog.velcdn.com/images/praesentia-ykm/profile/f4303897-987f-4315-9686-0074259460e7/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Dot My Moments. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/praesentia-ykm" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Controller의 책임을 떼어내며 — 뷰 렌더링과 파라미터 바인딩]]></title>
            <link>https://velog.io/@praesentia-ykm/Controller%EC%9D%98-%EC%B1%85%EC%9E%84%EC%9D%84-%EB%96%BC%EC%96%B4%EB%82%B4%EB%A9%B0-%EB%B7%B0-%EB%A0%8C%EB%8D%94%EB%A7%81%EA%B3%BC-%ED%8C%8C%EB%9D%BC%EB%AF%B8%ED%84%B0-%EB%B0%94%EC%9D%B8%EB%94%A9</link>
            <guid>https://velog.io/@praesentia-ykm/Controller%EC%9D%98-%EC%B1%85%EC%9E%84%EC%9D%84-%EB%96%BC%EC%96%B4%EB%82%B4%EB%A9%B0-%EB%B7%B0-%EB%A0%8C%EB%8D%94%EB%A7%81%EA%B3%BC-%ED%8C%8C%EB%9D%BC%EB%AF%B8%ED%84%B0-%EB%B0%94%EC%9D%B8%EB%94%A9</guid>
            <pubDate>Mon, 27 Apr 2026 07:53:48 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/f44fe6ce-32f4-4d21-815a-9a61562ef103/image.png" alt=""></p>
<blockquote>
<p><strong>TL;DR</strong>: Controller에는 비즈니스 로직 외에도 파라미터 파싱·뷰 결정·렌더링 책임이 섞여있다. 이 셋을 단계별로 떼어내면 Controller는 진짜 컨트롤러 역할만 남고, 결과적으로 Spring MVC와 거의 같은 구조가 만들어진다.</p>
</blockquote>
<hr>
<h2 id="0-들어가며">0. 들어가며</h2>
<p><a href="https://velog.io/@praesentia-ykm/%ED%94%84%EB%A1%A0%ED%8A%B8-%EC%BB%A8%ED%8A%B8%EB%A1%A4%EB%9F%AC-%EC%84%9C%EB%B8%94%EB%A6%BF-N%EA%B0%9C%EB%A5%BC-1%EA%B0%9C%EB%A1%9C-%EC%95%95%EC%B6%95%ED%95%98%EB%8A%94-%EB%B2%95">지난 글</a>에서 프론트 컨트롤러 패턴으로 공통 로직을 DispatcherServlet에 모았다. 하지만 Controller 자체에는 여전히 여러 책임이 섞여 있었다.</p>
<pre><code class="language-java">public class LectureController implements Controller {
    @Override
    public void handleRequest(HttpServletRequest req, HttpServletResponse resp) throws Exception {
        // ① 파라미터 파싱
        String title = req.getParameter(&quot;title&quot;);
        int price = Integer.parseInt(req.getParameter(&quot;price&quot;));

        // ② 비즈니스 로직
        Lecture lecture = new Lecture(title, price);
        lectureRepository.put(lecture.getId(), lecture);

        // ③ 뷰 결정 + ④ 렌더링
        RequestDispatcher dispatcher = req.getRequestDispatcher(&quot;lecture-list.jsp&quot;);
        dispatcher.forward(req, resp);
    }
}</code></pre>
<p>한 메서드 안에 네 가지 책임이 모두 들어있다. 이번 글에서 이 책임들을 단계별로 떼어내본다. 끝나면 Controller에는 ②번 비즈니스 로직만 남는다.</p>
<hr>
<h2 id="1-view-추상화--렌더링-책임을-분리하기">1. View 추상화 — 렌더링 책임을 분리하기</h2>
<h3 id="출발점-jsp-렌더링-코드의-정체">출발점: JSP 렌더링 코드의 정체</h3>
<p>기존 코드에서 JSP를 그릴 때 쓴 두 줄을 다시 보자.</p>
<pre><code class="language-java">final RequestDispatcher requestDispatcher = req.getRequestDispatcher(&quot;lecture-list.jsp&quot;);
requestDispatcher.forward(req, resp);</code></pre>
<p>이 두 줄이 하는 일은 단순하다 — <strong>렌더링할 파일 이름</strong>을 받아서, <strong>HttpServletRequest와 HttpServletResponse</strong>를 가지고 그 파일을 그린다. 입력 셋이 명확하다는 건 객체로 추출할 수 있다는 뜻이다.</p>
<pre><code class="language-java">public class JspView {
    private final String viewName;

    public JspView(final String viewName) {
        this.viewName = viewName;
    }

    public void render(final HttpServletRequest req, final HttpServletResponse res)
            throws ServletException, IOException {
        final RequestDispatcher dispatcher = req.getRequestDispatcher(viewName);
        dispatcher.forward(req, res);
    }
}</code></pre>
<p>Controller에서는 이렇게 쓴다.</p>
<pre><code class="language-java">final JspView view = new JspView(&quot;lecture-list.jsp&quot;);
view.render(req, resp);</code></pre>
<h3 id="다른-템플릿-엔진을-끼우면--인터페이스의-필요">다른 템플릿 엔진을 끼우면 — 인터페이스의 필요</h3>
<p>JSP 외에 단순 HTML 파일도 그리고 싶다면, 같은 모양의 클래스를 하나 더 만들 수 있다.</p>
<pre><code class="language-java">public class HtmlView {
    private final String viewName;

    public HtmlView(final String viewName) { this.viewName = viewName; }

    public void render(final HttpServletRequest req, final HttpServletResponse res) throws IOException {
        final String content = readViewFile(req);
        res.setContentType(&quot;text/html;charset=utf-8&quot;);
        res.getWriter().print(content);
    }

    private String readViewFile(final HttpServletRequest req) {
        // 파일 읽어서 문자열로 반환
        // ...
    }
}</code></pre>
<p>문제는 <strong>타입이 다르다는 것</strong>이다. Controller에서 어떤 날은 <code>JspView</code> 를, 어떤 날은 <code>HtmlView</code> 를 쓰려면 변수 타입을 매번 바꿔야 한다. 둘이 정확히 같은 모양인데도.</p>
<p>해결은 명확하다 — 공통 인터페이스로 묶는 것.</p>
<pre><code class="language-java">public interface View {
    void render(HttpServletRequest req, HttpServletResponse res) throws Exception;
}

public class JspView implements View { /* ... */ }
public class HtmlView implements View { /* ... */ }</code></pre>
<p>이제 Controller는 <code>View</code> 타입만 알면 된다. 어떤 구현체인지는 신경 쓸 필요 없다.</p>
<h3 id="여전히-남은-한계">여전히 남은 한계</h3>
<p>이렇게 했어도 Controller가 <strong>View를 직접 생성하고 호출하는</strong> 구조다.</p>
<pre><code class="language-java">final View view = new JspView(&quot;lecture-list.jsp&quot;);
view.render(req, resp);</code></pre>
<p>Controller가 &quot;어떤 클래스의 View를 쓸지&quot;, &quot;어떤 파일을 그릴지&quot; 를 직접 알아야 한다. 비즈니스 로직과 뷰 결정 로직이 같은 메서드 안에 같이 산다는 뜻이다.</p>
<hr>
<h2 id="2-modelandview--controller는-무엇을-만-알면-된다">2. ModelAndView — Controller는 &quot;무엇을&quot; 만 알면 된다</h2>
<h3 id="발상의-전환">발상의 전환</h3>
<p>Controller가 View 인스턴스를 직접 만들지 말고, <strong>&quot;어떤 뷰를 그려달라&quot;는 의도만 반환</strong> 하면 어떨까. 실제 View 생성과 호출은 DispatcherServlet이 처리한다.</p>
<p>이때 필요한 정보는 두 가지다.</p>
<ul>
<li><strong>View 이름</strong> — 어떤 화면을 그릴지 (<code>&quot;lecture-list&quot;</code>)</li>
<li><strong>Model</strong> — 그 화면에 넘길 데이터 (<code>Map&lt;String, Object&gt;</code>)</li>
</ul>
<p>이 둘을 묶은 게 <code>ModelAndView</code> 다.</p>
<pre><code class="language-java">public class ModelAndView {
    private final String viewName;
    private final Map&lt;String, Object&gt; model = new HashMap&lt;&gt;();

    public ModelAndView(final String viewName) {
        this.viewName = viewName;
    }

    public ModelAndView(final String viewName, final Map&lt;String, Object&gt; model) {
        this.viewName = viewName;
        this.model.putAll(model);
    }

    public String getViewName() { return viewName; }
    public Map&lt;String, Object&gt; getModel() { return Collections.unmodifiableMap(model); }
}</code></pre>
<p>Controller 인터페이스도 이걸 반환하도록 바꾼다.</p>
<pre><code class="language-java">@FunctionalInterface
public interface Controller {
    ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception;
}</code></pre>
<h3 id="controller의-모습">Controller의 모습</h3>
<pre><code class="language-java">public class LectureController implements Controller {
    @Override
    public ModelAndView handleRequest(HttpServletRequest req, HttpServletResponse resp) {
        if (&quot;GET&quot;.equals(req.getMethod())) {
            Collection&lt;Lecture&gt; lectures = lectureRepository.values();

            Map&lt;String, Object&gt; model = new HashMap&lt;&gt;();
            model.put(&quot;lectures&quot;, lectures);

            return new ModelAndView(&quot;lecture-list&quot;, model);
        }
        // ...
    }
}</code></pre>
<p><code>req.getRequestDispatcher()</code> 도, <code>View</code> 객체 생성도 사라졌다. Controller는 <strong>&quot;<code>lecture-list</code> 라는 화면에, <code>lectures</code> 데이터를 넘겨라&quot;</strong> 는 의도만 반환한다.</p>
<h3 id="viewresolver--이름을-객체로-바꿔주는-다리">ViewResolver — 이름을 객체로 바꿔주는 다리</h3>
<p>DispatcherServlet은 Controller가 반환한 <code>ModelAndView</code> 의 view 이름을 받아서, 실제 <code>View</code> 인스턴스를 찾아야 한다. 이 역할을 하는 게 <strong>ViewResolver</strong> 다.</p>
<pre><code class="language-java">public interface ViewResolver {
    View resolveViewName(String viewName) throws Exception;
}

public class JspViewResolver implements ViewResolver {
    @Override
    public View resolveViewName(String viewName) {
        return new JspView(&quot;/WEB-INF/views/&quot; + viewName + &quot;.jsp&quot;);
    }
}</code></pre>
<p>Controller는 <code>&quot;lecture-list&quot;</code> 라는 짧은 이름만 반환하고, ViewResolver가 그걸 <code>/WEB-INF/views/lecture-list.jsp</code> 로 해석한다. 컨트롤러 코드에서 경로와 확장자가 사라진다.</p>
<h3 id="dispatcherservlet에서-렌더링-처리">DispatcherServlet에서 렌더링 처리</h3>
<pre><code class="language-java">@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) {
    Controller controller = controllerMapping.get(req.getRequestURI());

    try {
        ModelAndView mav = controller.handleRequest(req, resp);
        render(mav, req, resp);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

private void render(ModelAndView mav, HttpServletRequest req, HttpServletResponse resp) throws Exception {
    View view = viewResolver.resolveViewName(mav.getViewName());
    view.render(mav.getModel(), req, resp);
}</code></pre>
<p>이제 흐름이 이렇게 된다.</p>
<ol>
<li>DispatcherServlet이 요청을 받는다.</li>
<li>URL에 매핑된 Controller를 찾아서 호출한다.</li>
<li>Controller가 <code>ModelAndView</code> 를 반환한다.</li>
<li>DispatcherServlet이 ViewResolver로 View를 찾는다.</li>
<li>View가 Model을 가지고 렌더링한다.</li>
</ol>
<p>Controller는 1·4·5에 관여하지 않는다. <strong>2·3에서 비즈니스 로직 + 의도만 반환</strong>하면 끝이다.</p>
<h3 id="리다이렉트는-어떻게">리다이렉트는 어떻게?</h3>
<p>리다이렉트는 forward와 다르다. forward는 서버 안에서 다른 뷰를 그리는 것이고, 리다이렉트는 클라이언트에게 &quot;다른 URL로 다시 요청해라&quot;고 응답하는 것이다.</p>
<p>이 차이를 ViewResolver가 처리하면 된다 — view 이름이 <code>redirect:</code> 로 시작하면 <code>RedirectView</code> 를 반환하도록.</p>
<pre><code class="language-java">public class JspViewResolver implements ViewResolver {
    private static final String REDIRECT_PREFIX = &quot;redirect:&quot;;

    @Override
    public View resolveViewName(String viewName) {
        if (viewName.startsWith(REDIRECT_PREFIX)) {
            return new RedirectView(viewName.substring(REDIRECT_PREFIX.length()));
        }
        return new JspView(&quot;/WEB-INF/views/&quot; + viewName + &quot;.jsp&quot;);
    }
}</code></pre>
<p>Controller는 그저 <code>return new ModelAndView(&quot;redirect:/lectures&quot;)</code> 만 하면 된다. 리다이렉트인지 아닌지의 처리는 프레임워크가 알아서 한다.</p>
<hr>
<h2 id="3-파라미터-바인딩--입력도-객체로-받기">3. 파라미터 바인딩 — 입력도 객체로 받기</h2>
<p>여기까지 오면 Controller는 출력(View 결정·렌더링)에서는 손을 뗐다. 그런데 입력 쪽은 여전히 raw API를 직접 호출한다.</p>
<pre><code class="language-java">public ModelAndView handleRequest(HttpServletRequest req, HttpServletResponse resp) {
    String title = req.getParameter(&quot;title&quot;);
    int price = Integer.parseInt(req.getParameter(&quot;price&quot;));
    int capacity = Integer.parseInt(req.getParameter(&quot;capacity&quot;));
    // ↑ Controller가 매번 파라미터를 하나씩 꺼내고 타입 변환

    Lecture lecture = new Lecture(title, price, capacity);
    // ...
}</code></pre>
<p>이게 새 도메인이 추가될 때마다, 새 메서드가 추가될 때마다 반복된다. <strong>요청 파라미터를 객체로 변환하는 일</strong> 자체가 또 다른 보일러플레이트가 됐다.</p>
<h3 id="발상-객체로-받을-수-있다면">발상: 객체로 받을 수 있다면</h3>
<p>Controller가 이런 모양이 되면 어떨까.</p>
<pre><code class="language-java">public ModelAndView createLecture(LectureCreateRequest request) {
    Lecture lecture = new Lecture(request.getTitle(), request.getPrice(), request.getCapacity());
    lectureRepository.put(lecture.getId(), lecture);
    return new ModelAndView(&quot;redirect:/lectures&quot;);
}</code></pre>
<p><code>req.getParameter()</code> 호출이 사라졌다. 비즈니스 로직만 남았다.</p>
<p>이걸 가능하게 하려면, <strong>DispatcherServlet이 Controller 메서드의 매개변수 타입을 보고, HttpServletRequest의 파라미터들을 그 타입의 객체로 채워서 넘겨줘야 한다.</strong> 이 변환 책임을 가진 컴포넌트가 필요하다.</p>
<h3 id="파라미터-바인딩의-동작-원리">파라미터 바인딩의 동작 원리</h3>
<p>핵심 단계는 셋이다.</p>
<ol>
<li><strong>타입 분석</strong> — 리플렉션으로 파라미터 타입을 본다 (<code>LectureCreateRequest.class</code>).</li>
<li><strong>인스턴스 생성</strong> — 그 타입의 객체를 만든다 (기본 생성자 호출).</li>
<li><strong>값 주입</strong> — 객체의 각 필드 이름과 같은 키의 파라미터를 <code>request</code> 에서 꺼내, 타입 변환 후 필드에 채운다.</li>
</ol>
<p>단순화하면 이런 로직이다.</p>
<pre><code class="language-java">public &lt;T&gt; T bind(HttpServletRequest req, Class&lt;T&gt; type) throws Exception {
    T instance = type.getDeclaredConstructor().newInstance();
    for (Field field : type.getDeclaredFields()) {
        String value = req.getParameter(field.getName());
        if (value == null) continue;

        field.setAccessible(true);
        field.set(instance, convertType(value, field.getType()));
    }
    return instance;
}

private Object convertType(String value, Class&lt;?&gt; type) {
    if (type == String.class)  return value;
    if (type == int.class)     return Integer.parseInt(value);
    if (type == long.class)    return Long.parseLong(value);
    if (type == boolean.class) return Boolean.parseBoolean(value);
    // ...
    throw new IllegalArgumentException(&quot;Unsupported type: &quot; + type);
}</code></pre>
<p>실무 프레임워크는 여기서 훨씬 더 많은 일을 한다 — JSON 바디 처리, 중첩 객체, 컬렉션, 날짜 포맷, 제약 조건 검증(<code>@Valid</code>). 하지만 본질은 위 코드와 같다 — <strong>리플렉션으로 타입을 보고 값을 채운다</strong>.</p>
<p>Spring MVC의 <code>@RequestParam</code>, <code>@RequestBody</code>, <code>@ModelAttribute</code> 가 정확히 이 일을 한다. 어떤 어노테이션인가에 따라 값을 어디서 꺼낼지(쿼리 파라미터, JSON 바디, 폼 데이터)만 다를 뿐, 본질은 같다.</p>
<h3 id="controller의-최종-모습">Controller의 최종 모습</h3>
<pre><code class="language-java">public class LectureController {
    public ModelAndView list() {
        Map&lt;String, Object&gt; model = new HashMap&lt;&gt;();
        model.put(&quot;lectures&quot;, lectureRepository.values());
        return new ModelAndView(&quot;lecture-list&quot;, model);
    }

    public ModelAndView create(LectureCreateRequest request) {
        Lecture lecture = new Lecture(request.getTitle(), request.getPrice());
        lectureRepository.put(lecture.getId(), lecture);
        return new ModelAndView(&quot;redirect:/lectures&quot;);
    }
}</code></pre>
<p>이 코드에서 <strong>HTTP에 대한 흔적이 거의 사라졌다.</strong> <code>HttpServletRequest</code>, <code>HttpServletResponse</code> 를 직접 다루지 않는다. 입력은 객체로 받고, 출력은 의도(<code>ModelAndView</code>)로 반환한다. 진짜 컨트롤러 — 흐름의 시작점 — 만 남았다.</p>
<hr>
<h2 id="4-도착한-곳">4. 도착한 곳</h2>
<p>이번 글에서 한 단계씩 떼어낸 책임을 정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>떼어낸 책임</th>
<th>그 책임을 받은 컴포넌트</th>
</tr>
</thead>
<tbody><tr>
<td>Stage 1</td>
<td>렌더링 코드</td>
<td><code>View</code> 인터페이스</td>
</tr>
<tr>
<td>Stage 2</td>
<td>View 인스턴스 생성 + 호출</td>
<td><code>ModelAndView</code> + <code>ViewResolver</code></td>
</tr>
<tr>
<td>Stage 3</td>
<td>파라미터 파싱 + 타입 변환</td>
<td>파라미터 바인더</td>
</tr>
</tbody></table>
<p>지난 글까지의 Controller는 비즈니스 로직 + 입력 파싱 + 출력 렌더링이 다 섞여있던 덩어리였다. 이번 글이 끝나는 지점에서, Controller는 <strong>&quot;객체 입력을 받아 ModelAndView를 반환하는 함수&quot;</strong> 로 압축됐다.</p>
<p>이게 Spring MVC <code>@Controller</code> 의 모양과 거의 같다.</p>
<pre><code class="language-java">@Controller
public class LectureController {
    @PostMapping(&quot;/lectures&quot;)
    public String create(@ModelAttribute LectureCreateRequest request) {
        // ...
        return &quot;redirect:/lectures&quot;;
    }
}</code></pre>
<p><code>@PostMapping</code> 이 우리의 <code>Map&lt;URL, Controller&gt;</code> 매핑 등록을 대신하고, <code>@ModelAttribute</code> 가 우리의 파라미터 바인더를 호출하고, <code>String</code> 반환값이 우리의 <code>ModelAndView</code> 의 view 이름 부분을 대신한다. <strong>여기까지 만들어온 구조가 Spring MVC의 핵심 구조 그대로다.</strong></p>
<hr>
<h2 id="마무리">마무리</h2>
<ul>
<li><strong>View 추상화</strong> — 렌더링 코드를 별도 객체로 분리하고, 공통 인터페이스로 다양한 템플릿 엔진을 갈아끼울 수 있게 함.</li>
<li><strong>ModelAndView + ViewResolver</strong> — Controller는 view 이름과 데이터만 반환, 실제 View 생성·호출은 DispatcherServlet이 처리.</li>
<li><strong>파라미터 바인딩</strong> — 리플렉션으로 메서드 파라미터 타입을 분석하고, HTTP 요청 값을 객체로 채워 넘겨줌. Controller에서 raw API 호출이 사라짐.</li>
</ul>
<p>지난 글의 결론이 &quot;프론트 컨트롤러가 횡단 관심사 보일러플레이트를 처리해준다&quot; 였다면, 이번 글의 결론은 &quot;<strong>View 추상화·ModelAndView·파라미터 바인딩이 입출력 보일러플레이트를 처리해준다</strong>&quot; 다. Controller는 점점 더 본질에 가까워지고, 그 가벼움의 대가로 프레임워크가 더 많은 일을 떠맡는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트 컨트롤러 — 서블릿 N개를 1개로 압축하는 법]]></title>
            <link>https://velog.io/@praesentia-ykm/%ED%94%84%EB%A1%A0%ED%8A%B8-%EC%BB%A8%ED%8A%B8%EB%A1%A4%EB%9F%AC-%EC%84%9C%EB%B8%94%EB%A6%BF-N%EA%B0%9C%EB%A5%BC-1%EA%B0%9C%EB%A1%9C-%EC%95%95%EC%B6%95%ED%95%98%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@praesentia-ykm/%ED%94%84%EB%A1%A0%ED%8A%B8-%EC%BB%A8%ED%8A%B8%EB%A1%A4%EB%9F%AC-%EC%84%9C%EB%B8%94%EB%A6%BF-N%EA%B0%9C%EB%A5%BC-1%EA%B0%9C%EB%A1%9C-%EC%95%95%EC%B6%95%ED%95%98%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Mon, 27 Apr 2026 07:52:21 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/ccb980e4-e558-41c4-875d-af55f769c1af/image.png" alt="">
 <strong>TL;DR</strong>: 서블릿 N개에 흩어진 공통 로직은 새로운 보일러플레이트다. 모든 요청을 받는 단일 진입점 + 비즈니스 로직만 담는 핸들러로 분리하면, 중복이 N에 비례하던 코드가 상수로 압축된다.</p>
</blockquote>
<hr>
<h2 id="0-들어가며">0. 들어가며</h2>
<p><a href="https://velog.io/@praesentia-ykm/%ED%86%B0%EC%BA%A3%EC%9D%84-%EC%A7%81%EC%A0%91-%EB%A7%8C%EB%93%A0%EB%8B%A4%EB%A9%B4">지난 글</a>에서 톰캣을 부품 다섯 개로 발랐고, 서블릿 등록의 본질이 <code>Map&lt;URL, Servlet&gt;</code> 임을 정리했다. 이번 글은 그 결론에서 이어지는 자연스러운 의문에서 출발한다.</p>
<blockquote>
<p>URL이 100개면, Servlet 클래스도 100개가 되어야 하는가?</p>
</blockquote>
<p>이 질문의 답을 찾는 과정에서 <strong>프론트 컨트롤러(Front Controller) 패턴</strong> 이 나온다. Spring MVC의 <code>DispatcherServlet</code> 이 정확히 이 패턴이다.</p>
<hr>
<h2 id="1-서블릿이-늘어나면-무엇이-늘어나는가">1. 서블릿이 늘어나면 무엇이 늘어나는가</h2>
<p>지금까지 만든 코드를 보면, 각 URL마다 별도의 서블릿 클래스가 있다. <code>HomeServlet</code>, <code>LectureListServlet</code>, <code>LectureCreateServlet</code>, <code>MemberSignupServlet</code>... 각 서블릿이 자기 자리에서 자기 일을 한다.</p>
<p>문제는 &quot;자기 일&quot; 안에 <strong>모든 서블릿이 똑같이 해야 하는 일</strong> 이 섞여 있다는 점이다.</p>
<h3 id="한계점-1--파라미터-파싱-중복">한계점 1 — 파라미터 파싱 중복</h3>
<pre><code class="language-java">// LectureCreateServlet
protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
    Map&lt;String, ?&gt; params;
    if (&quot;application/json&quot;.equals(req.getHeader(&quot;Content-Type&quot;))) {
        byte[] bodyBytes = req.getInputStream().readAllBytes();
        String body = new String(bodyBytes, StandardCharsets.UTF_8);
        params = new ObjectMapper().readValue(body, new TypeReference&lt;Map&lt;String, Object&gt;&gt;() {});
    } else {
        params = req.getParameterMap();
    }
    // ↑ 여기까지가 파싱. 본격적인 비즈니스 로직은 이 다음.
    saveLecture(params);
}</code></pre>
<p>이 파싱 코드가 <code>LectureCreateServlet</code>, <code>LectureUpdateServlet</code>, <code>MemberSignupServlet</code>, <code>MemberUpdateServlet</code> 등 <strong>POST/PUT을 받는 모든 서블릿에 똑같이</strong> 들어간다. 한 번 짜두면 끝나는 게 아니라 매 서블릿마다 복사된다.</p>
<h3 id="한계점-2--에러-페이지-커스터마이징">한계점 2 — 에러 페이지 커스터마이징</h3>
<p>비즈니스 로직에서 예외가 터질 수 있다. &quot;강의를 찾을 수 없음&quot;, &quot;권한 없음&quot;, &quot;잘못된 입력&quot; 같은 예외들이다. 사용자에게 친절한 에러 페이지를 보여주려면, 모든 서블릿이 try-catch로 예외를 잡아 적절한 페이지로 넘겨줘야 한다.</p>
<pre><code class="language-java">// LectureUpdateServlet
protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    try {
        Long id = Long.parseLong(req.getParameter(&quot;id&quot;));
        Lecture lecture = lectureRepository.findById(id)
            .orElseThrow(() -&gt; new LectureNotFoundException(id));
        lecture.update(req.getParameter(&quot;title&quot;));
    } catch (LectureNotFoundException e) {
        req.setAttribute(&quot;message&quot;, e.getMessage());
        req.getRequestDispatcher(&quot;/error/404.jsp&quot;).forward(req, resp);
    } catch (UnauthorizedException e) {
        resp.sendRedirect(&quot;/login&quot;);
    } catch (Exception e) {
        req.getRequestDispatcher(&quot;/error/500.jsp&quot;).forward(req, resp);
    }
}</code></pre>
<p>이 try-catch 블록이 <code>LectureDeleteServlet</code>, <code>MemberUpdateServlet</code>, <code>OrderCreateServlet</code> 등 <strong>거의 모든 서블릿에 똑같이</strong> 들어간다. 더 큰 문제는 새로운 예외 타입이 추가될 때다. <code>PaymentFailedException</code> 을 도입해서 결제 실패 페이지를 보여주려면 — <strong>N개 서블릿을 모두 찾아 catch 블록을 추가해야 한다.</strong> 한 군데라도 빠뜨리면 그 서블릿에서만 500 에러가 뜬다.</p>
<p>한 곳에서 일괄적으로 처리할 자리가 없으니, 예외 처리 정책 변경이 곧 N개 파일 수정으로 번진다.</p>
<h3 id="한계점-3--인가로그-같은-횡단-관심사">한계점 3 — 인가/로그 같은 횡단 관심사</h3>
<p>&quot;관리자만 접근 가능한 API&quot; 같은 요구사항을 생각해보자. 관리자 권한이 필요한 모든 서블릿은 요청 처리 전에 인가 검사를 해야 한다.</p>
<pre><code class="language-java">// AdminLectureCreateServlet
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    // 1. 인가 검사
    HttpSession session = req.getSession(false);
    if (session == null || session.getAttribute(&quot;user&quot;) == null) {
        resp.sendRedirect(&quot;/login&quot;);
        return;
    }
    User user = (User) session.getAttribute(&quot;user&quot;);
    if (!user.isAdmin()) {
        resp.sendError(HttpServletResponse.SC_FORBIDDEN);
        return;
    }

    // 2. 로그 출력
    log.info(&quot;[{}] {} {} by user={}&quot;,
        Instant.now(), req.getMethod(), req.getRequestURI(), user.getId());

    // 3. 본격적인 비즈니스 로직 — 여기서야 시작
    saveLecture(parseParams(req));
}</code></pre>
<p>비즈니스 로직 한 줄을 호출하기 위해 <strong>15줄의 인가/로그 코드</strong> 가 위에 깔려야 한다. 그리고 이 15줄이 <code>AdminLectureUpdateServlet</code>, <code>AdminLectureDeleteServlet</code>, <code>AdminMemberBanServlet</code>, <code>AdminOrderRefundServlet</code> 등 <strong>관리자 API 모든 서블릿에 복붙</strong> 된다.</p>
<p>이 구조의 진짜 위험은 <strong>변경 시점</strong>에 드러난다. 예를 들어 &quot;로그 형식에 IP 주소도 포함&quot; 같은 요구가 들어오면, 모든 서블릿의 로그 코드를 다 찾아 수정해야 한다. &quot;관리자 권한 정책을 슈퍼 관리자/일반 관리자로 분리&quot; 같은 변경은 인가 검사 코드 N개를 동기화해서 고쳐야 한다.</p>
<p>이런 인가, 로그, 트랜잭션, 모니터링 같은 처리를 <strong>횡단 관심사(cross-cutting concern)</strong> 라고 부른다. 하나의 비즈니스 로직에 속하지 않고, 모든 비즈니스 로직 위에 얹혀야 하는 공통 처리들이다. 서블릿이 N개로 흩어진 구조에서는 횡단 관심사도 N개로 흩어진다.</p>
<h3 id="정리하면">정리하면</h3>
<p>서블릿 1개 = <strong>공통 로직 + 비즈니스 로직</strong> 이라는 구조 때문에, 서블릿이 N개 늘어나면 <strong>공통 로직도 N번</strong> 반복된다. 중복이 N에 비례해 증가하는 셈이다.</p>
<p>서블릿 컨테이너가 HTTP 보일러플레이트를 처리해줘서 95% 보일러플레이트가 사라졌더니, 이번엔 한 단계 위에서 새로운 보일러플레이트가 생겨났다.</p>
<hr>
<h2 id="2-단일-진입점--모든-요청을-한-곳으로">2. 단일 진입점 — 모든 요청을 한 곳으로</h2>
<p>해결의 출발점은 단순하다. <strong>모든 요청을 받는 서블릿 하나</strong> 를 만드는 것이다. 그 안에서 공통 로직을 처리한 후, 각 요청에 맞는 처리로 분배한다.</p>
<pre><code class="language-java">@WebServlet(&quot;/&quot;)
public class DispatcherServlet extends HttpServlet {
    @Override
    protected void service(final HttpServletRequest req, final HttpServletResponse resp)
            throws ServletException, IOException {
        // 공통 로직 — 여기 한 번만
        final Map&lt;String, ?&gt; params = parseParams(req);

        // ... 각 요청에 맞는 처리
    }

    private Map&lt;String, ?&gt; parseParams(final HttpServletRequest req) throws IOException {
        if (&quot;application/json&quot;.equals(req.getHeader(&quot;Content-Type&quot;))) {
            final byte[] bodyBytes = req.getInputStream().readAllBytes();
            final String body = new String(bodyBytes, StandardCharsets.UTF_8);
            return new ObjectMapper().readValue(body, new TypeReference&lt;Map&lt;String, Object&gt;&gt;() {});
        }
        return req.getParameterMap();
    }
}</code></pre>
<p><code>@WebServlet(&quot;/&quot;)</code> 한 줄로 모든 URL을 이 서블릿이 받는다. 파라미터 파싱은 <code>service()</code> 안에 한 번만 작성되고, 모든 요청이 이 한 줄을 거친다.</p>
<p>이 자리는 한계점 1뿐 아니라 2·3까지 한꺼번에 풀어준다.</p>
<pre><code class="language-java">@WebServlet(&quot;/&quot;)
public class DispatcherServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        try {
            // 한계점 3 — 인가 검사 (한 곳)
            User user = authenticate(req);
            authorize(user, req.getRequestURI());

            // 한계점 3 — 로그 (한 곳)
            log.info(&quot;[{}] {} {} by user={}&quot;,
                Instant.now(), req.getMethod(), req.getRequestURI(), user.getId());

            // 한계점 1 — 파라미터 파싱 (한 곳)
            Map&lt;String, ?&gt; params = parseParams(req);

            // ... 각 요청에 맞는 처리로 분배

        } catch (LectureNotFoundException e) {
            // 한계점 2 — 예외 처리 (한 곳)
            req.setAttribute(&quot;message&quot;, e.getMessage());
            req.getRequestDispatcher(&quot;/error/404.jsp&quot;).forward(req, resp);
        } catch (UnauthorizedException e) {
            resp.sendRedirect(&quot;/login&quot;);
        } catch (Exception e) {
            req.getRequestDispatcher(&quot;/error/500.jsp&quot;).forward(req, resp);
        }
    }
}</code></pre>
<blockquote>
<p>Before/After 다이어그램을 여기 배치 — 왼쪽엔 서블릿 N개에 공통 로직이 중복된 모습, 오른쪽엔 DispatcherServlet 하나가 공통 로직을 처리하고 Controller N개로 분배하는 모습</p>
</blockquote>
<p>이걸로 한계점 1·2·3이 동시에 풀린다. 파싱은 한 곳, 예외 처리도 한 곳, 인가·로그도 한 곳. 같은 코드가 N개 서블릿에 흩어져 있던 구조에서 <strong>공통 로직이 한 곳에 압축된 구조</strong>로 바뀌었다. 새로운 예외 타입을 추가하거나 로그 포맷을 바꿀 때, 수정할 자리가 한 군데로 정해진다.</p>
<h3 id="새로운-문제--비즈니스-로직은-어디에-두는가">새로운 문제 — 비즈니스 로직은 어디에 두는가</h3>
<p>여기서 새로운 문제가 등장한다. <code>service()</code> 안에 모든 비즈니스 로직을 if/else로 박아넣을 수는 없다.</p>
<pre><code class="language-java">// ⚠️ 이렇게 가면 안 된다
protected void service(HttpServletRequest req, HttpServletResponse resp) {
    Map&lt;String, ?&gt; params = parseParams(req);
    String path = req.getRequestURI();

    if (&quot;/lectures&quot;.equals(path) &amp;&amp; &quot;POST&quot;.equals(req.getMethod())) {
        // 강의 등록 100줄...
    } else if (&quot;/lectures&quot;.equals(path) &amp;&amp; &quot;GET&quot;.equals(req.getMethod())) {
        // 강의 목록 50줄...
    } else if (&quot;/members&quot;.equals(path) &amp;&amp; &quot;POST&quot;.equals(req.getMethod())) {
        // 회원 가입 80줄...
    }
    // ... 끝없는 if/else
}</code></pre>
<p>이건 ServerSocket으로 직접 서버를 짤 때와 똑같은 if/else 지옥이다. <strong>공통 로직은 한 곳에 모았지만, 비즈니스 로직도 같이 한 곳에 모이면 단일 클래스가 모든 책임을 지게 된다.</strong> 책임 분리는 안 풀린 셈이다.</p>
<hr>
<h2 id="3-controller-인터페이스--비즈니스-로직을-위임받을-자리">3. Controller 인터페이스 — 비즈니스 로직을 위임받을 자리</h2>
<p>해결의 핵심은 <strong>공통 로직은 DispatcherServlet에, 비즈니스 로직은 별도 객체에</strong> 두는 것이다. 그 별도 객체의 스펙을 인터페이스로 정의한다.</p>
<pre><code class="language-java">@FunctionalInterface
public interface Controller {
    void handleRequest(final HttpServletRequest request, final HttpServletResponse response)
            throws Exception;
}</code></pre>
<p>이 한 줄에 두 가지 의도가 담겨 있다.</p>
<p><strong>첫째, 프레임워크와 사용자 코드의 경계를 정의한다.</strong> 프레임워크 입장에선 &quot;이 인터페이스만 구현해주면 너의 비즈니스 로직을 호출해주겠다&quot;는 약속이다. 사용자 입장에선 &quot;이 인터페이스만 구현하면 프레임워크가 나머지를 다 처리해준다&quot;는 약속이다.</p>
<p><strong>둘째, 제어의 방향이 뒤집힌다.</strong> ServerSocket으로 직접 짠 서버에서는 사용자 코드가 <code>Socket.accept()</code> 를 호출하고, 흐름을 사용자가 주도했다. 서블릿부터는 흐름이 뒤집혔다 — 사용자는 <code>service()</code> 메서드를 작성해두고, 컨테이너가 그걸 호출한다. <strong>Controller 인터페이스도 같은 발상이다. 사용자가 호출하는 게 아니라, 프레임워크가 호출한다.</strong> &quot;Hollywood Principle: don&#39;t call us, we&#39;ll call you&quot; 라고도 부르고, 공식 용어로는 IoC(Inversion of Control) 다.</p>
<pre><code class="language-java">public class LectureController implements Controller {
    @Override
    public void handleRequest(final HttpServletRequest req, final HttpServletResponse resp)
            throws Exception {
        if (&quot;POST&quot;.equals(req.getMethod())) {
            // 강의 등록만
        } else if (&quot;GET&quot;.equals(req.getMethod())) {
            // 강의 목록만
        }
    }
}</code></pre>
<p>이 클래스에는 <strong>파싱, 인가, 로그 같은 공통 로직이 한 줄도 없다.</strong> 강의에 관한 비즈니스 로직만 있다. 그게 가능한 이유는 DispatcherServlet이 그 모든 보일러플레이트를 양 옆에서 막아주기 때문이다.</p>
<hr>
<h2 id="4-url-→-controller-매핑">4. URL → Controller 매핑</h2>
<p>Controller가 N개라면, DispatcherServlet은 들어온 요청을 어느 Controller로 보낼지 결정해야 한다. 익숙한 자료구조가 또 등장한다.</p>
<pre><code class="language-java">@WebServlet(&quot;/&quot;)
public class DispatcherServlet extends HttpServlet {

    private final Map&lt;String, Controller&gt; controllerMapping = new HashMap&lt;&gt;();

    @Override
    public void init() {
        controllerMapping.put(&quot;/lectures&quot;, new LectureController());
        controllerMapping.put(&quot;/members&quot;,  new MemberController());
        controllerMapping.put(&quot;/&quot;,         new HomeController());
    }

    @Override
    protected void service(final HttpServletRequest req, final HttpServletResponse resp)
            throws ServletException, IOException {
        // 공통 로직
        // (파싱, 인가, 로그...)

        // URL → Controller 조회
        final String path = req.getRequestURI();
        final Controller controller = controllerMapping.get(path);

        if (controller == null) {
            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        try {
            controller.handleRequest(req, resp);
        } catch (Exception e) {
            throw new ServletException(e);
        }
    }
}</code></pre>
<p><code>Map&lt;String, Controller&gt;</code> — 키는 URL, 값은 Controller 인스턴스.</p>
<p>여기서 익숙한 모양이 한 번 더 등장한다. 톰캣 자체가 <code>Map&lt;URL, Servlet&gt;</code> 으로 어느 서블릿을 부를지 결정한다. <strong>이번엔 우리가 만든 한 서블릿(DispatcherServlet) 안에 <code>Map&lt;URL, Controller&gt;</code> 가 또 들어있다.</strong> 같은 패턴이 한 층 위에서 반복되는 구조다.</p>
<table>
<thead>
<tr>
<th>계층</th>
<th>자료구조</th>
<th>무엇을 매핑</th>
</tr>
</thead>
<tbody><tr>
<td>톰캣</td>
<td><code>Map&lt;URL, Servlet&gt;</code></td>
<td>URL → 서블릿</td>
</tr>
<tr>
<td>DispatcherServlet</td>
<td><code>Map&lt;URL, Controller&gt;</code></td>
<td>URL → 컨트롤러</td>
</tr>
</tbody></table>
<p>추상화는 같은 패턴(매핑 테이블 + 분배기)을 한 층씩 위로 쌓아올려 만들어진다. 톰캣이 OS 위에서 한 일을, 프론트 컨트롤러가 톰캣 위에서 똑같이 하고 있는 셈이다.</p>
<hr>
<h2 id="5-마이그레이션--강의-서버-api에-적용">5. 마이그레이션 — 강의 서버 API에 적용</h2>
<p>이 패턴을 강의 서버에 적용하면 코드가 이렇게 정리된다.</p>
<p><strong>Before</strong>: <code>LectureListServlet</code>, <code>LectureCreateServlet</code>, <code>LectureUpdateServlet</code>, <code>LectureDeleteServlet</code> — 4개 서블릿, 각각에 파싱·인가·로그 코드 중복.</p>
<p><strong>After</strong>: <code>DispatcherServlet</code> 1개 + <code>LectureController</code> 1개 — 공통 로직은 DispatcherServlet에, 강의 비즈니스 로직은 LectureController에. HTTP 메서드 분기는 Controller 안에서 if/else로.</p>
<pre><code class="language-java">public class LectureController implements Controller {
    private final Map&lt;Long, Lecture&gt; lectureRepository = new HashMap&lt;&gt;();

    @Override
    public void handleRequest(final HttpServletRequest req, final HttpServletResponse resp)
            throws Exception {
        if (&quot;POST&quot;.equals(req.getMethod()))   { doPost(req, resp);   return; }
        if (&quot;GET&quot;.equals(req.getMethod()))    { doGet(req, resp);    return; }
        if (&quot;PUT&quot;.equals(req.getMethod()))    { doPut(req, resp);    return; }
        if (&quot;DELETE&quot;.equals(req.getMethod())) { doDelete(req, resp); return; }
        throw new RuntimeException(&quot;Method not allowed&quot;);
    }

    // doPost, doGet, doPut, doDelete — 비즈니스 로직만
}</code></pre>
<p>서블릿 4개가 컨트롤러 1개로 압축됐다. <strong>그리고 새 도메인이 추가될 때마다 늘어나는 건 Controller 1개뿐</strong>이다. 공통 로직은 더 이상 따라 늘어나지 않는다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 글에서 정리한 개념을 한 줄씩 모으면 이렇다.</p>
<ul>
<li>서블릿 N개 구조의 한계는 <strong>공통 로직(파싱·인가·로그)이 N에 비례해 중복</strong>된다는 것.</li>
<li>프론트 컨트롤러 패턴은 <strong>단일 진입점</strong>(DispatcherServlet)에 공통 로직을 모으고, <strong>별도 객체</strong>(Controller)에 비즈니스 로직을 위임한다.</li>
<li>Controller는 인터페이스로 정의되며, 사용자는 인터페이스를 구현하기만 하면 된다 — <strong>호출은 프레임워크가 한다</strong>(IoC).</li>
<li>DispatcherServlet 내부에는 <code>Map&lt;URL, Controller&gt;</code> 가 있어 URL에 따라 적절한 Controller로 분배한다. 이는 톰캣의 <code>Map&lt;URL, Servlet&gt;</code> 과 같은 패턴이 한 층 위에서 반복되는 구조다.</li>
</ul>
<p>여기까지 다룬 글들을 관통하는 한 가지 패턴이 있다. <strong>&quot;표준 스펙은 누군가가 한 번 잘 처리해두고, 그 위에서 사용자는 본질만 다룬다.&quot;</strong> OS 커널이 TCP/IP 스펙의 보일러플레이트를 처리해줘서 자바 코드는 텍스트만 다루면 됐다. 서블릿 컨테이너가 HTTP 스펙의 보일러플레이트를 처리해줘서 비즈니스 로직만 짜면 됐다. 그리고 이번 글의 프론트 컨트롤러는 <strong>횡단 관심사 보일러플레이트를 처리해준다</strong>. 계층은 다르지만 같은 발상이 한 층씩 위로 쌓인다.</p>
<h3 id="다음-글--controller가-아직-짊어지고-있는-것들">다음 글 — Controller가 아직 짊어지고 있는 것들</h3>
<p>공통 로직은 DispatcherServlet에 모았지만, Controller에는 여전히 세 가지 책임이 섞여 있다.</p>
<ul>
<li><strong>요청 파라미터를 객체로 변환</strong> — <code>req.getParameter(&quot;title&quot;)</code> 같은 raw API 호출이 매 Controller마다 반복.</li>
<li><strong>응답 본문 직접 작성</strong> — <code>resp.getWriter().write(...)</code> 또는 <code>RequestDispatcher.forward()</code> 같은 렌더링 코드가 비즈니스 로직과 같은 메서드에 섞여 있음.</li>
<li><strong>렌더링할 뷰 결정</strong> — JSP를 쓸지 HTML을 쓸지, 어떤 파일을 그릴지를 Controller가 직접 알아야 함.</li>
</ul>
<p>다음 글에서는 이 셋을 하나씩 떼어낸다. <strong>View 추상화</strong> 로 렌더링 책임을 분리하고, <strong>ModelAndView</strong> 로 Controller가 &quot;무엇을 보여줄지&quot; 만 반환하게 만들고, <strong>파라미터 바인딩</strong> 으로 raw API 호출을 객체 생성으로 압축한다. 이번 글에서 만든 구조 위에 한 겹씩 더 얹어 가면, 매일 쓰는 Spring MVC와 거의 같은 모양에 도달한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[톰캣을 직접 만든다면...?]]></title>
            <link>https://velog.io/@praesentia-ykm/%ED%86%B0%EC%BA%A3%EC%9D%84-%EC%A7%81%EC%A0%91-%EB%A7%8C%EB%93%A0%EB%8B%A4%EB%A9%B4</link>
            <guid>https://velog.io/@praesentia-ykm/%ED%86%B0%EC%BA%A3%EC%9D%84-%EC%A7%81%EC%A0%91-%EB%A7%8C%EB%93%A0%EB%8B%A4%EB%A9%B4</guid>
            <pubDate>Mon, 27 Apr 2026 07:30:31 GMT</pubDate>
            <description><![CDATA[<h1 id="핵심-부품-다섯-개로-보는-서블릿-컨테이너">핵심 부품 다섯 개로 보는 서블릿 컨테이너</h1>
<blockquote>
<p><strong>TL;DR</strong>: &quot;서블릿 컨테이너&quot;라는 한 단어를 부품 다섯 개로 쪼개서 보면, 톰캣이 무엇을 책임지는지가 명확해진다.</p>
</blockquote>
<hr>
<h2 id="0-들어가며">0. 들어가며</h2>
<p><a href="https://velog.io/@praesentia-ykm/%EC%84%9C%EB%B8%94%EB%A6%BF%EC%9D%80-%EC%99%9C-%EB%93%B1%EC%9E%A5%ED%96%88%EB%82%98">지난 글</a>에서 이런 위계를 그렸다.</p>
<table>
<thead>
<tr>
<th>계층</th>
<th>누가 처리하는가</th>
</tr>
</thead>
<tbody><tr>
<td>비즈니스 로직</td>
<td>개발자가 작성한 코드</td>
</tr>
<tr>
<td>HTTP 메시지 처리</td>
<td>서블릿 컨테이너 (톰캣)</td>
</tr>
<tr>
<td>TCP/IP</td>
<td>OS 커널</td>
</tr>
<tr>
<td>물리 신호</td>
<td>NIC + 드라이버</td>
</tr>
</tbody></table>
<p>OS 커널과 NIC는 §2에서 부품 단위로 들여다봤는데, &quot;서블릿 컨테이너&quot;는 한 줄로만 정리하고 넘어왔다. 이번 글에서 그 한 줄을 쪼갠다. 도구는 <strong>&quot;톰캣을 직접 만든다면 무엇을 만들어야 하는가?&quot;</strong> 라는 질문이다. 직접 만든다고 가정하고 부품 단위로 발라보면, 마법 상자처럼 보이던 톰캣이 의외로 작은 부품들의 조합이라는 게 보인다.</p>
<hr>
<h2 id="1-서블릿--표준-톰캣--구현체">1. 서블릿 = 표준, 톰캣 = 구현체</h2>
<p>먼저 용어부터 정리한다. <strong>서블릿(Servlet)</strong> 과 <strong>서블릿 컨테이너</strong> 와 <strong>톰캣</strong> 은 자주 섞여 쓰이는데 정확히 다른 것이다.</p>
<table>
<thead>
<tr>
<th>용어</th>
<th>정체</th>
<th>비유</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Servlet 스펙</strong></td>
<td>Jakarta EE(구 Java EE)가 정의한 <strong>표준 인터페이스</strong></td>
<td>콘센트 규격 (220V 둥근 핀 두 개)</td>
</tr>
<tr>
<td><strong>Servlet 컨테이너</strong></td>
<td>그 스펙을 <strong>구현한 런타임</strong></td>
<td>실제 콘센트가 박힌 벽</td>
</tr>
<tr>
<td><strong>Tomcat</strong></td>
<td>가장 널리 쓰이는 컨테이너 <strong>구현체</strong></td>
<td>Apache가 만든 그 벽</td>
</tr>
</tbody></table>
<p>핵심은 이거다. <strong>Servlet은 인터페이스고, 컨테이너는 그 인터페이스를 호출해주는 런타임</strong>이다. 개발자는 인터페이스를 구현(<code>HttpServlet</code> 상속)하기만 하면, 컨테이너가 알아서 적절한 시점에 그 메서드를 불러준다. 이게 서블릿 모델의 출발점이다.</p>
<p>다른 컨테이너 구현체로는 Jetty, Undertow 등이 있다. Spring Boot 기본값이 톰캣일 뿐, 같은 서블릿 코드를 다른 컨테이너로 갈아끼울 수 있는 이유가 여기 있다 — <strong>표준 인터페이스에 의존하기 때문에</strong>.</p>
<hr>
<h2 id="2-서블릿-등록--url에-핸들러를-꽂는다">2. 서블릿 등록 — &quot;URL에 핸들러를 꽂는다&quot;</h2>
<p>서블릿을 컨테이너에 등록하는 방식은 두 가지다.</p>
<h3 id="방식-1-webservlet-애너테이션">방식 1: @WebServlet 애너테이션</h3>
<pre><code class="language-java">@WebServlet(&quot;/home&quot;)
public class HomeServlet extends HttpServlet {

    @Override
    public void init(final ServletConfig config) throws ServletException {
        super.init(config);
    }

    @Override
    protected void service(final HttpServletRequest req, final HttpServletResponse resp)
            throws ServletException, IOException {
        resp.getWriter().write(&quot;Home&quot;);
    }
}</code></pre>
<p><code>@WebServlet(&quot;/home&quot;)</code> 한 줄이 컨테이너에게 &quot;이 클래스는 <code>/home</code> 경로의 요청을 처리한다&quot;고 선언한다. 컨테이너가 시작 시점에 클래스패스를 스캔해서 이 애너테이션을 찾아 자동 등록한다.</p>
<h3 id="방식-2-명시적-등록">방식 2: 명시적 등록</h3>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        final Tomcat tomcat = new Tomcat();
        tomcat.setPort(8080);

        final Context context = tomcat.addContext(&quot;&quot;, new File(&quot;.&quot;).getAbsolutePath());
        Tomcat.addServlet(context, &quot;homeServlet&quot;, new HomeServlet());
        context.addServletMappingDecoded(&quot;/home&quot;, &quot;homeServlet&quot;);

        tomcat.start();
        tomcat.getServer().await();
    }
}</code></pre>
<p>핵심은 <code>addServletMappingDecoded(&quot;/home&quot;, &quot;homeServlet&quot;)</code> 한 줄. &quot;URL <code>/home</code> 요청은 <code>homeServlet</code> 이름의 서블릿이 처리한다&quot;는 매핑이다.</p>
<h3 id="두-방식의-본질">두 방식의 본질</h3>
<p>표면적으로 다르게 보이지만, 둘 다 결국 같은 일을 한다.</p>
<blockquote>
<p><strong>&quot;이 URL 패턴 → 이 Servlet 인스턴스&quot; 라는 매핑 정보를 컨테이너 내부 자료구조에 저장한다.</strong></p>
</blockquote>
<p>애너테이션 방식은 컨테이너가 자동 스캔해서 넣어주고, 명시적 방식은 직접 코드로 넣을 뿐이다. 그 자료구조의 본질은 <code>Map&lt;String, Servlet&gt;</code> — 키는 URL 패턴, 값은 Servlet 인스턴스다.</p>
<p>URL 매칭 규칙(정확 매칭, 경로 매칭 <code>/lectures/*</code>, 확장자 매칭 <code>*.jsp</code>)이 더 붙으면 단순 Map 조회로는 부족해서 우선순위 매칭 로직이 필요해진다. 톰캣은 이 역할을 <strong>Mapper</strong> 라는 컴포넌트가 담당한다.</p>
<hr>
<h2 id="3-서블릿-라이프사이클--한-번-init-매번-service">3. 서블릿 라이프사이클 — &quot;한 번 init, 매번 service&quot;</h2>
<p>서블릿은 다음 상태를 거친다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>호출 시점</th>
<th>호출 횟수</th>
</tr>
</thead>
<tbody><tr>
<td>인스턴스화</td>
<td>첫 요청 도착 시 (lazy) 또는 <code>&lt;load-on-startup&gt;</code> 지정 시 시작 시점</td>
<td>1회</td>
</tr>
<tr>
<td><code>init(ServletConfig)</code></td>
<td>인스턴스화 직후</td>
<td>1회</td>
</tr>
<tr>
<td><code>service(req, res)</code></td>
<td>매 요청마다</td>
<td>N회</td>
</tr>
<tr>
<td><code>destroy()</code></td>
<td>컨테이너 종료 시</td>
<td>1회</td>
</tr>
</tbody></table>
<p><strong>왜 init과 service가 분리됐는가?</strong> 무거운 초기화(DB 커넥션 풀 가져오기, 외부 설정 읽기 등)를 매 요청마다 반복할 수는 없다. init은 한 번만 하는 비싼 준비, service는 매 요청마다 가벼운 처리. 책임이 다르므로 메서드도 분리되어 있다.</p>
<h3 id="servletconfig와-servletcontext">ServletConfig와 ServletContext</h3>
<p><code>init(ServletConfig config)</code> 의 인자는 <strong>이 서블릿 한 개에 대한 설정</strong>이다. 이름, 초기화 파라미터, 그리고 <code>ServletContext</code> 참조를 담고 있다.</p>
<table>
<thead>
<tr>
<th>객체</th>
<th>범위</th>
<th>비유</th>
</tr>
</thead>
<tbody><tr>
<td><code>ServletConfig</code></td>
<td>서블릿 1개</td>
<td>한 사람의 명함</td>
</tr>
<tr>
<td><code>ServletContext</code></td>
<td>웹 애플리케이션 1개 (모든 서블릿이 공유)</td>
<td>회사 공용 게시판</td>
</tr>
</tbody></table>
<p>여러 서블릿이 공유해야 할 정보(전역 설정, 공유 자원)는 <code>ServletContext</code> 에, 특정 서블릿만의 설정은 <code>ServletConfig</code> 에 들어간다.</p>
<h3 id="서블릿은-싱글톤이다">서블릿은 싱글톤이다</h3>
<p>라이프사이클에서 인스턴스화가 1회뿐이라는 점에서 중요한 결론이 따라 나온다. <strong>서블릿 인스턴스는 컨테이너당 한 개(URL당 한 개)</strong> 이고, 그 한 인스턴스의 <code>service()</code> 가 <strong>여러 스레드에서 동시에 호출</strong>된다.</p>
<pre><code class="language-java">public class CountServlet extends HttpServlet {
    private int count = 0;  // ⚠️ 위험: 멀티스레드에서 race condition

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) {
        count++;  // 동시 호출되면 값 깨짐
        resp.getWriter().write(&quot;count: &quot; + count);
    }
}</code></pre>
<p>서블릿 인스턴스 필드는 stateless이거나 thread-safe해야 한다는 제약이, 라이프사이클로부터 자연스럽게 따라 나온다. <strong>요청별 상태는 인스턴스 필드가 아니라 <code>HttpServletRequest</code> 의 attribute나 메서드 지역 변수에 둬야 한다.</strong></p>
<h3 id="service와-dogetdopost">service()와 doGet/doPost</h3>
<p><code>HttpServlet.service()</code> 의 기본 구현은 HTTP 메서드를 보고 <code>doGet</code>, <code>doPost</code>, <code>doPut</code>, <code>doDelete</code> 등으로 디스패치한다. 단순화하면 이런 코드다.</p>
<pre><code class="language-java">// HttpServlet.service() 내부 (단순화)
String method = req.getMethod();
if (&quot;GET&quot;.equals(method))         doGet(req, resp);
else if (&quot;POST&quot;.equals(method))   doPost(req, resp);
else if (&quot;PUT&quot;.equals(method))    doPut(req, resp);
else if (&quot;DELETE&quot;.equals(method)) doDelete(req, resp);
// ...</code></pre>
<p>따라서 메서드별로 처리하고 싶으면 <code>service()</code> 는 건드리지 말고 <code>doGet</code>, <code>doPost</code> 만 오버라이드해야 한다. <code>service()</code> 를 오버라이드하면 이 디스패치 로직이 사라져서 직접 분기를 짜야 한다.</p>
<hr>
<h2 id="4-부품-다섯-개--한-요청이-거치는-길">4. 부품 다섯 개 — 한 요청이 거치는 길</h2>
<p>여기까지 정리한 개념을 한 그림으로 모으면, 요청이 컨테이너를 통과하는 동안 거치는 부품이 다섯 개다.
<img src="https://velog.velcdn.com/images/praesentia-ykm/post/45ebbd64-eb37-4863-b67d-57c75942eba2/image.png" alt=""></p>
<p>보라색으로 강조된 ④번 박스만 사용자가 작성하는 코드다. 나머지 넷은 컨테이너가 떠받쳐주는 부품이다. <strong>다섯 개 중 하나만 비즈니스 로직, 나머지 넷은 보일러플레이트</strong> — 이게 컨테이너가 만드는 압축률이다.</p>
<h3 id="①-connector--연결-받기">① Connector — 연결 받기</h3>
<p>8080 포트의 TCP 연결을 받고, 한 연결당 한 스레드(또는 NIO 이벤트)에 할당한다. 지난 글에서 직접 짠 <code>ServerSocket</code> 코드의 책임이지만, 핵심 차이는 <strong>스레드 풀</strong>이다.</p>
<p>지난 글의 단순 서버는 <code>while(true)</code> 안에서 한 연결씩 순차 처리했다 — 한 요청이 느리면 모든 요청이 막힌다. Connector는 받은 연결을 워커 스레드 풀에 던져서 동시에 N개 요청을 처리한다. 톰캣에서는 이 부품을 <strong>Coyote</strong> 라고 부른다.</p>
<h3 id="②-http-parser--바이트를-객체로">② HTTP Parser — 바이트를 객체로</h3>
<p>Connector가 흘려준 raw 바이트를 <code>HttpServletRequest</code> 객체로 변환한다. 지난 글에서 직접 짠 95% 보일러플레이트가 통째로 여기 들어간다.</p>
<ul>
<li>요청 라인 파싱 (<code>GET /home HTTP/1.1</code>)</li>
<li>헤더 파싱 (<code>Host</code>, <code>Content-Length</code>, <code>Cookie</code> 등)</li>
<li>바디 읽기 (Content-Length만큼, 또는 chunked encoding)</li>
<li>URL 디코딩, 쿼리 스트링 파싱, multipart 처리</li>
</ul>
<p><code>request.getMethod()</code>, <code>request.getParameter(&quot;page&quot;)</code> 가 깔끔한 이유는, 이 부품이 그 모든 보일러플레이트를 한 번에 처리해서 객체로 만들어뒀기 때문이다.</p>
<h3 id="③-dispatcher--url-보고-서블릿-고르기">③ Dispatcher — URL 보고 서블릿 고르기</h3>
<p>§2에서 본 <code>Map&lt;URL, Servlet&gt;</code> 에서 매칭되는 서블릿을 찾는 일. 톰캣에서는 <strong>Mapper</strong> 가 이 역할을 한다.</p>
<p>URL 매칭 규칙(정확/경로/확장자/기본)에 따라 우선순위를 적용하고, 매칭된 서블릿을 §3의 라이프사이클대로 호출한다 — 첫 호출이면 <code>init()</code> 부터, 아니면 바로 <code>service()</code>.</p>
<h3 id="④-servletservicereq-res--비즈니스-로직">④ Servlet.service(req, res) — 비즈니스 로직</h3>
<p>다섯 부품 중 <strong>유일하게 사용자가 작성하는 부품</strong>이다. 깔끔한 API(<code>request.getParameter()</code>, <code>response.setStatus()</code>, <code>response.getWriter().write()</code>)로 정보를 꺼내고 응답을 작성한다.</p>
<p>여기서 신경 쓸 일은 &quot;강의를 어떻게 저장할 것인가&quot; 같은 비즈니스 로직뿐이다. 이게 가능한 건 ①②③⑤가 양쪽에서 보일러플레이트를 다 막아주고 있기 때문이다.</p>
<h3 id="⑤-http-builder--객체를-바이트로">⑤ HTTP Builder — 객체를 바이트로</h3>
<p>Servlet이 작성한 <code>HttpServletResponse</code> 를 응답 바이트로 직렬화한다. 상태 라인(<code>HTTP/1.1 200 OK</code>), 헤더(<code>Content-Type</code>, <code>Content-Length</code>, <code>Set-Cookie</code>), 빈 줄, 본문. ②의 거울짝이다.</p>
<h3 id="책임-분포">책임 분포</h3>
<table>
<thead>
<tr>
<th>부품</th>
<th>책임</th>
<th>톰캣 용어</th>
</tr>
</thead>
<tbody><tr>
<td>① Connector</td>
<td>TCP 연결 + 스레드 풀</td>
<td>Coyote</td>
</tr>
<tr>
<td>② HTTP Parser</td>
<td>바이트 → 요청 객체</td>
<td>Coyote (HTTP/1.1 protocol)</td>
</tr>
<tr>
<td>③ Dispatcher</td>
<td>URL → Servlet 매칭</td>
<td>Mapper</td>
</tr>
<tr>
<td><strong>④ Servlet.service()</strong></td>
<td><strong>비즈니스 로직</strong></td>
<td>(사용자 코드)</td>
</tr>
<tr>
<td>⑤ HTTP Builder</td>
<td>응답 객체 → 바이트</td>
<td>Coyote</td>
</tr>
</tbody></table>
<p>전체를 감싸고 라이프사이클을 관리하는 상위 컴포넌트가 톰캣에서는 <strong>Catalina</strong> 다. 즉 톰캣 = Catalina(라이프사이클) + Coyote(HTTP I/O) + Mapper(URL 매칭) + 사용자 Servlet.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 글에서 정리한 개념을 한 줄씩 모으면 이렇다.</p>
<ul>
<li>서블릿은 <strong>표준 인터페이스</strong>, 컨테이너는 <strong>그 표준의 구현 런타임</strong>, 톰캣은 <strong>가장 널리 쓰이는 구현체</strong>.</li>
<li>서블릿 등록의 본질은 <strong><code>Map&lt;URL, Servlet&gt;</code> 에 매핑 정보를 넣는 일</strong>.</li>
<li>서블릿은 <strong>싱글톤</strong>이고 init 1회 + service N회 라이프사이클을 가진다. 이로부터 <strong>인스턴스 필드의 thread-safety 제약</strong>이 따라 나온다.</li>
<li>서블릿 컨테이너의 책임은 다섯 부품으로 발라진다 — <strong>Connector, HTTP Parser, Dispatcher, Servlet, HTTP Builder</strong>.</li>
<li>다섯 부품 중 사용자가 짜는 건 ④ Servlet 하나, 나머지 넷은 컨테이너의 책임.</li>
</ul>
<p>지난 글의 결론이 &quot;OS 커널이 TCP/IP 보일러플레이트를 처리해준다&quot; 였다면, 이번 글의 결론은 &quot;<strong>서블릿 컨테이너가 HTTP 보일러플레이트를 부품 다섯 개로 처리해준다</strong>&quot; 다. 추상화는 임의로 쌓이는 게 아니라 표준 스펙을 부품으로 쪼개서 위임받는 계층으로 쌓인다.</p>
<h3 id="다음-글--서블릿이-늘어나면-어떻게-되는가">다음 글 — 서블릿이 늘어나면 어떻게 되는가</h3>
<p>이번 글에서 자연스럽게 받아들였지만 한 번 더 생각해볼 지점이 있다. <strong>서블릿 등록의 본질이 <code>Map&lt;URL, Servlet&gt;</code> 이라면, URL이 100개가 되면 Servlet 클래스도 100개가 되어야 하는가?</strong></p>
<p><code>HomeServlet</code>, <code>LectureListServlet</code>, <code>LectureCreateServlet</code>, <code>LectureUpdateServlet</code>, <code>LectureDeleteServlet</code>, <code>MemberSignupServlet</code>, <code>MemberLoginServlet</code>... 각 서블릿마다 init 받고, 같은 형태의 응답 직렬화 코드를 또 쓰고. 서블릿 자체가 또 하나의 보일러플레이트가 되는 셈이다.</p>
<p>다음 글에서는 이 문제를 푸는 패턴 — <strong>프론트 컨트롤러(Front Controller) 패턴</strong> 을 다룬다. 모든 요청을 받는 단 하나의 서블릿을 두고, 그 안에서 URL에 따라 적절한 핸들러로 분배하는 구조다. Spring MVC의 <code>DispatcherServlet</code> 이 정확히 이 패턴이다. 어떻게 한 개의 서블릿이 N개의 서블릿을 대신할 수 있는지, 그리고 그 단순한 발상이 어떤 문을 열어주는지 들여다보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[서블릿은 왜 등장했나?]]></title>
            <link>https://velog.io/@praesentia-ykm/%EC%84%9C%EB%B8%94%EB%A6%BF%EC%9D%80-%EC%99%9C-%EB%93%B1%EC%9E%A5%ED%96%88%EB%82%98</link>
            <guid>https://velog.io/@praesentia-ykm/%EC%84%9C%EB%B8%94%EB%A6%BF%EC%9D%80-%EC%99%9C-%EB%93%B1%EC%9E%A5%ED%96%88%EB%82%98</guid>
            <pubDate>Mon, 27 Apr 2026 05:52:23 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>TL;DR</strong>: &quot;스프링 없이 CRUD 4개쯤이야&quot;로 시작했는데, Socket 추상화 너머에 OS 커널이 통째로 떠받치고 있다는 것과, 그 위에 짜는 코드의 95%는 HTTP 스펙 처리라는 것을 연달아 깨달았다.</p>
</blockquote>
<hr>
<h2 id="0-스프링-없이도-웹-서버를-만들-수-있나">0. &quot;스프링 없이도 웹 서버를 만들 수 있나?&quot;</h2>
<p>강의 서비스 백엔드를 처음부터 짜보는 과제를 받았다. 회원 가입과 강의 업로드만 되면 되는 가벼운 프로젝트라서, 일단 강의 자체를 다루는 CRUD 네 개만 있으면 충분했다.</p>
<ul>
<li><code>POST /lectures</code> — 강의 등록</li>
<li><code>PUT /lectures</code> — 강의 수정</li>
<li><code>DELETE /lectures</code> — 강의 삭제</li>
<li><code>GET /lectures</code> — 강의 목록</li>
</ul>
<p>평소라면 <code>@RestController</code> 하나 만들고 끝낼 일이다. 그런데 한 단계 뒤로 물러나니까 이런 질문이 떠올랐다.</p>
<p><strong>&quot;스프링이 없으면 웹 서버를 어떻게 만들지?&quot;</strong></p>
<p><code>@GetMapping</code>이 사라지고, <code>HttpServletRequest</code>도 못 쓰고, 톰캣도 없다고 치자. 자바 표준 라이브러리만으로 8080 포트를 열고 HTTP 요청을 받으려면 어떻게 해야 하는가? 이 질문에 즉답이 안 나온다는 게 살짝 부끄러웠다. <del>매일 쓰는 도구의 바닥이 어떻게 생겼는지도 모른 채 그 위에 코드를 쌓고 있었던 셈이다.</del></p>
<hr>
<h2 id="1-가장-원시적인-출발점--serversocket">1. 가장 원시적인 출발점 — ServerSocket</h2>
<p>자바에는 네트워크 요청을 직접 받을 수 있는 도구가 표준 라이브러리 안에 이미 들어 있다. <code>java.net.ServerSocket</code>이다. 검색 몇 번 따라가니 이런 코드가 나왔다.</p>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        try (final ServerSocket serverSocket = new ServerSocket(8080)) {
            while (true) {
                try (final Socket clientSocket = serverSocket.accept()) {
                    final BufferedReader in = new BufferedReader(
                        new InputStreamReader(clientSocket.getInputStream()));
                    final PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);

                    // HTTP 요청 읽기
                    final String request = in.readLine();
                    System.out.println(&quot;요청: &quot; + request);

                    // HTTP 응답 전송
                    out.println(&quot;HTTP/1.1 200 OK&quot;);
                    out.println(&quot;Content-Type: text/html; charset=UTF-8&quot;);
                    out.println();
                    out.println(&quot;&lt;html&gt;&lt;body&gt;&lt;h1&gt;안녕하세요, 자바 웹 서버입니다!&lt;/h1&gt;&lt;/body&gt;&lt;/html&gt;&quot;);
                }
            }
        } catch (final IOException e) {
            System.err.println(&quot;error = &quot; + e.getMessage());
        }
    }
}</code></pre>
<p>이걸 실행하고 브라우저에서 <code>localhost:8080</code>을 치면 정말로 &quot;안녕하세요, 자바 웹 서버입니다!&quot;가 뜬다. 스프링도, 톰캣도, 어떤 프레임워크도 없이.</p>
<p>처음 봤을 때 좀 어이가 없었다. <strong><code>out.println(&quot;HTTP/1.1 200 OK&quot;)</code> 같은 줄이 어떻게 HTTP 응답이 되는 거지?</strong> 그냥 문자열을 PrintWriter로 출력하는 코드인데. <code>in.readLine()</code>이 어떻게 브라우저가 보낸 <code>&quot;GET / HTTP/1.1&quot;</code>을 정확히 가져오는 거고? 자바 코드 어디에도 패킷이나 TCP 같은 건 안 보이는데.</p>
<p>답을 풀려면 <code>Socket</code>이라는 추상화 너머를 봐야 했다. 평소엔 그냥 import해서 쓰던 그 클래스 말이다.</p>
<hr>
<h2 id="2-socket이-뭔데--os-커널이-떠받치는-추상화">2. Socket이 뭔데? — OS 커널이 떠받치는 추상화</h2>
<h3 id="깨달음-1-socket은-양방향-바이트-파이프-추상화다">깨달음 1: Socket은 &quot;양방향 바이트 파이프&quot; 추상화다</h3>
<p>자바의 <code>java.net.Socket</code>은 <strong>OS 커널이 관리하는 TCP 연결의 한쪽 끝</strong>을 가리키는 핸들이다. 더 정확히는, 커널의 파일 디스크립터를 감싼 얇은 자바 래퍼다.</p>
<pre><code class="language-java">clientSocket.getInputStream()   // 이 연결로 들어오는 바이트
clientSocket.getOutputStream()  // 이 연결로 나가는 바이트</code></pre>
<p>여기서 &quot;들어오는/나가는 바이트&quot;는 자바가 직접 처리하는 게 아니다. <strong>자바는 그저 커널에게 &quot;이 파이프에서 N 바이트 읽어줘&quot;, &quot;이 파이프에 N 바이트 써줘&quot;라고 시스템 콜로 부탁할 뿐</strong>이다. 실제로 바이트를 옮기는 일 — 패킷 라우팅, 재전송, 순서 맞추기, 흐름 제어 — 은 모두 커널이 한다.</p>
<p>그러니까 이 한 줄,</p>
<pre><code class="language-java">final ServerSocket serverSocket = new ServerSocket(8080);</code></pre>
<p>은 새로운 객체를 만든다기보다 <strong>커널에게 &quot;8080 포트로 들어오는 TCP 연결을 받겠다&quot;고 등록하는 시스템 콜</strong>에 가깝다. <code>accept()</code> 도 마찬가지로 &quot;다음 연결이 완성될 때까지 기다려달라&quot;는 부탁이다. 자바가 만드는 건 그 부탁의 결과로 받은 핸들 객체뿐이다.</p>
<p>내 가정은 &quot;Socket이 그냥 객체 하나겠거니&quot;였다. 직접 들여다보니 그건 <strong>커널이 관리하는 거대한 시스템에 대한 한 줄 요약 인터페이스</strong>였다. 그래서 자바 코드가 짧을 수 있었던 거였다.</p>
<h3 id="깨달음-2-텍스트가-전선을-타려면-네-겹의-봉투가-씌워진다">깨달음 2: 텍스트가 전선을 타려면 네 겹의 봉투가 씌워진다</h3>
<p>그러면 우리가 쓴 <code>out.println(&quot;HTTP/1.1 200 OK&quot;)</code> 의 텍스트는 어떻게 브라우저까지 가는가? 커널이 그걸 그대로 케이블에 흘려보내는 건 아니다. <strong>네 겹의 봉투</strong>가 차례로 씌워진다.
<img src="https://velog.velcdn.com/images/praesentia-ykm/post/b65de5b3-a048-4820-bc4c-bf6c6f125b1c/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>계층</th>
<th>봉투에 담기는 정보</th>
<th>누가 처리</th>
</tr>
</thead>
<tbody><tr>
<td>애플리케이션 (HTTP)</td>
<td><code>&quot;HTTP/1.1 200 OK\r\n...&quot;</code> 텍스트</td>
<td><strong>자바 코드</strong></td>
</tr>
<tr>
<td>전송 (TCP)</td>
<td>출발지/목적지 포트, 순번, 재전송 정보 (20B 헤더)</td>
<td>OS 커널</td>
</tr>
<tr>
<td>네트워크 (IP)</td>
<td>출발지/목적지 IP 주소 (20B 헤더)</td>
<td>OS 커널</td>
</tr>
<tr>
<td>링크 (Ethernet)</td>
<td>송신/수신 MAC 주소 (14B 헤더), 전기·전파 신호 변환</td>
<td>NIC + 드라이버</td>
</tr>
</tbody></table>
<p>각 계층은 &quot;위 계층이 만든 데이터 통째&quot;를 자기 봉투의 본문에 넣고, 자기 헤더를 앞에 붙인다. 결과적으로 케이블에 흐르는 건 네 겹으로 감싸진 바이트 묶음이다. 이걸 <strong>캡슐화(encapsulation)</strong> 라고 한다.</p>
<p>받는 쪽은 정확히 반대 순서다. 브라우저 NIC가 신호를 받아 Ethernet 봉투를 뜯고, 커널이 IP 봉투를 뜯고, TCP 봉투를 뜯고, 그제서야 가장 안쪽 HTTP 텍스트가 브라우저 애플리케이션에 도달한다. 우리 자바 서버 쪽도 마찬가지로, <code>in.readLine()</code> 이 가장 안쪽 텍스트를 받기 전에 커널이 이미 세 겹의 봉투를 다 벗겨놓았다.</p>
<blockquote>
<p>즉, <strong><code>in.readLine()</code> 이 &quot;GET / HTTP/1.1&quot;을 돌려주는 건 마법이 아니라, OS 커널이 Ethernet/IP/TCP 봉투를 다 벗기고 가장 안쪽 텍스트만 자바에게 건네줬기 때문</strong>이다. 자바 코드 한 줄 뒤에 이 정도의 시스템이 깔려 있었다.</p>
</blockquote>
<h3 id="누가-무엇을-했나--정리">누가 무엇을 했나 — 정리</h3>
<p><code>new ServerSocket(8080)</code> 부터 <code>out.println(...)</code> 까지, 표면 코드 뒤에서 실제로 무슨 일이 일어났는지 한 줄씩 짚어보면 이렇다.</p>
<table>
<thead>
<tr>
<th>자바 코드</th>
<th>실제로 일어난 일</th>
</tr>
</thead>
<tbody><tr>
<td><code>new ServerSocket(8080)</code></td>
<td>커널에 &quot;8080 포트 열어달라&quot; 시스템 콜 등록</td>
</tr>
<tr>
<td><code>accept()</code></td>
<td>커널이 TCP 3-way handshake 끝낼 때까지 대신 기다림</td>
</tr>
<tr>
<td><code>clientSocket.getInputStream()</code></td>
<td>커널이 이미 풀어놓은 가장 안쪽 바이트(HTTP 텍스트)에 접근</td>
</tr>
<tr>
<td><code>out.println(...)</code></td>
<td>커널이 받은 바이트를 TCP 세그먼트 → IP 패킷 → Ethernet 프레임으로 차례로 감싸 NIC에 전달</td>
</tr>
</tbody></table>
<p>우체국 비유로 묶으면 깔끔하다. <strong>우리는 정해진 양식의 편지지에 글만 쓰는 사람</strong>이다. 봉투 씌우고, 주소 적고, 트럭에 싣고, 운송하는 일은 모두 우체국(OS 커널)과 운송망(NIC, 라우터)이 처리한다. 평소에 안 보인다고 해서 일을 안 하는 게 아니었다.</p>
<p>여기까지 알고 나서야 비로소 §1의 의문이 풀렸다. <strong><code>out.println</code> 이 작동한 건 마법이 아니라, Socket이라는 추상화가 OS 커널의 거대한 시스템을 &quot;양방향 바이트 파이프&quot;라는 한 줄 요약 인터페이스로 가려주고 있었기 때문</strong>이다. 그러면 CRUD 4개도 어렵지 않을 것 같았다.</p>
<p>이게 첫 번째 자만이었다.</p>
<hr>
<h2 id="3-강의-crud를-얹어보자--두-번째-함정">3. 강의 CRUD를 얹어보자 — 두 번째 함정</h2>
<p>원리를 알았으니 본래 목적이었던 CRUD 4개를 이 위에 올려보기로 했다. HTTP 요청 첫 줄을 파싱해서 메서드와 경로로 분기하면 끝일 줄 알았다.</p>
<pre><code class="language-java">final String requestLine = in.readLine();   // &quot;POST /lectures HTTP/1.1&quot;
final String[] tokens = requestLine.split(&quot; &quot;);
final String method = tokens[0];
final String path = tokens[1];</code></pre>
<p>여기까진 깔끔하다. 그런데 POST의 바디(JSON)를 어떻게 읽지?라는 지점에서 막혔다.</p>
<h3 id="고민-1-post-바디는-어디에-있나">고민 1: POST 바디는 어디에 있나</h3>
<p>HTTP 메시지 구조를 다시 들여다봤다.</p>
<pre><code>POST /lectures HTTP/1.1            ← 요청 라인
Host: localhost:8080               ← 헤더 시작
Content-Type: application/json
Content-Length: 47
                                   ← 빈 줄 한 개 (구분자)
{&quot;title&quot;:&quot;클린 코드&quot;,&quot;price&quot;:50000}    ← 바디</code></pre><p>요청 라인을 읽고, 빈 줄이 나올 때까지 헤더를 한 줄씩 읽고, 그 중 <code>Content-Length</code> 를 찾아서 그만큼 바디를 추가로 읽어야 한다. <strong>헤더와 바디를 가르는 기준이 단지 &quot;빈 줄 한 개&quot;라는 것</strong>도 처음 알았다. RFC를 따로 안 봐도 될 거라고 생각한 게 또 하나의 자만이었다.</p>
<pre><code class="language-java">int contentLength = 0;
String headerLine;
while (!(headerLine = in.readLine()).isEmpty()) {
    if (headerLine.startsWith(&quot;Content-Length:&quot;)) {
        contentLength = Integer.parseInt(headerLine.split(&quot;:&quot;)[1].trim());
    }
}
final char[] bodyBuffer = new char[contentLength];
in.read(bodyBuffer, 0, contentLength);
final String body = new String(bodyBuffer);</code></pre>
<p>CRUD 하나 처리하는 데 이 정도 코드가 들어간다. 그리고 이건 POST에만 필요한 게 아니라 PUT도 똑같이 필요하다.</p>
<h3 id="고민-2-분기는-어떻게">고민 2: 분기는 어떻게?</h3>
<p>이제 if/else로 4개 케이스를 처리해본다.</p>
<pre><code class="language-java">if (&quot;GET&quot;.equals(method) &amp;&amp; &quot;/lectures&quot;.equals(path)) {
    final String body = toJson(findAllLectures());
    out.println(&quot;HTTP/1.1 200 OK&quot;);
    out.println(&quot;Content-Type: application/json; charset=UTF-8&quot;);
    out.println(&quot;Content-Length: &quot; + body.getBytes(UTF_8).length);
    out.println();
    out.println(body);

} else if (&quot;POST&quot;.equals(method) &amp;&amp; &quot;/lectures&quot;.equals(path)) {
    // 위에서 만든 헤더/바디 파싱 로직...
    saveLecture(parseJson(body));
    out.println(&quot;HTTP/1.1 201 Created&quot;);
    out.println();

} else if (&quot;PUT&quot;.equals(method) &amp;&amp; &quot;/lectures&quot;.equals(path)) {
    // 또 헤더/바디 파싱 반복...
} else if (&quot;DELETE&quot;.equals(method) &amp;&amp; &quot;/lectures&quot;.equals(path)) {
    // 경로에서 ID는 어떻게 추출하지?
} else {
    out.println(&quot;HTTP/1.1 404 Not Found&quot;);
    out.println();
}</code></pre>
<p>여기서 또 함정이 보였다. <code>DELETE /lectures/3</code> 처럼 <strong>경로 변수</strong>가 들어오면 어떻게 잘라내지? <code>path.startsWith(&quot;/lectures/&quot;)</code> 로 시작 부분 비교하고, <code>path.substring(10)</code> 으로 ID 잘라내고... 이걸 모든 RESTful URL마다 직접 짜야 한다.</p>
<p>거기다 쿼리 파라미터(<code>/lectures?page=1&amp;size=20</code>)가 붙으면 <code>?</code> 뒤를 또 따로 파싱해야 하고, URL 디코딩도 필요하다. 한국어가 들어가면 <code>%ED%81%B4%EB%A6%B0</code> 같은 인코딩을 풀어야 한다.</p>
<p>엔드포인트 4개에 이 정도다. <strong>회원, 강의, 결제, 통계, 댓글, 알림... 100개로 늘어나면 어떻게 되지?</strong> 머리가 아파왔다.</p>
<hr>
<h2 id="4-무엇이-보일러플레이트인가">4. 무엇이 보일러플레이트인가</h2>
<p>위에서 짠 코드를 다시 들여다보면, 진짜 비즈니스 로직은 딱 4줄이다.</p>
<pre><code class="language-java">findAllLectures()
saveLecture(parseJson(body))
updateLecture(parseJson(body))
deleteLecture(id)</code></pre>
<p>나머지는 전부 HTTP 메시지를 풀고 다시 싸는 일이다. 한 번 표로 정리해봤다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>코드량</th>
<th>본질?</th>
</tr>
</thead>
<tbody><tr>
<td>요청 라인 파싱 (메서드, 경로, 버전)</td>
<td>3줄</td>
<td>보일러플레이트</td>
</tr>
<tr>
<td>헤더 파싱 (Content-Length, Content-Type 등)</td>
<td>5~10줄</td>
<td>보일러플레이트</td>
</tr>
<tr>
<td>바디 읽기 (Content-Length만큼 정확히)</td>
<td>3줄</td>
<td>보일러플레이트</td>
</tr>
<tr>
<td>경로 변수/쿼리 파라미터 추출</td>
<td>5~15줄</td>
<td>보일러플레이트</td>
</tr>
<tr>
<td>URL 디코딩 (한국어, 특수문자)</td>
<td>2~3줄</td>
<td>보일러플레이트</td>
</tr>
<tr>
<td><strong>비즈니스 로직</strong></td>
<td><strong>1줄</strong></td>
<td><strong>본질</strong></td>
</tr>
<tr>
<td>응답 상태 라인 작성</td>
<td>1줄</td>
<td>보일러플레이트</td>
</tr>
<tr>
<td>응답 헤더 작성 (Content-Type, Content-Length)</td>
<td>2~3줄</td>
<td>보일러플레이트</td>
</tr>
<tr>
<td>응답 바디 직렬화</td>
<td>1~2줄</td>
<td>보일러플레이트</td>
</tr>
</tbody></table>
<p><strong>전체 코드의 95%가 모든 엔드포인트마다 똑같이 반복된다.</strong> 그리고 이 95%는 우리가 마음대로 바꿀 수도 없다. HTTP 스펙(RFC 9110, 9112)이 정해놓은 그대로다.</p>
<p>여기서 자연스럽게 떠오른 생각이 있다. <strong>&quot;스펙이 정해져 있다&quot;는 건 &quot;표준화될 수 있다&quot;는 뜻이다.</strong> 모든 자바 웹 개발자가 매번 똑같은 헤더 파싱 코드를 짤 게 아니라, 누군가 한 번 잘 만들어두고 다 같이 쓰면 되는 일이다.</p>
<p>게다가 위 코드는 가장 단순한 경우만 다뤘다. 실제로는 chunked transfer encoding, keep-alive 커넥션, multipart 파일 업로드, 쿠키와 세션, gzip 압축, HTTPS까지 따라온다. 이걸 다 직접 구현한다? 사실상 불가능하고, 무엇보다 <strong>불필요한 일</strong>이다.</p>
<p>흥미로운 건 §2에서 본 OS 커널의 역할과 정확히 같은 패턴이라는 점이다. TCP/IP 스펙도 정해져 있어서 커널이 그걸 통째로 처리해주듯, HTTP 스펙도 정해져 있으니까 누군가가 그걸 통째로 처리해주면 된다. <strong>계층의 위치만 다를 뿐, 같은 발상이다.</strong></p>
<hr>
<h2 id="5-그래서-서블릿이-등장한다">5. 그래서 서블릿이 등장한다</h2>
<p>이 깨달음의 끝에 <strong>서블릿(Servlet)</strong> 이 있다. 서블릿은 본질적으로 이런 계약이다.</p>
<blockquote>
<p>&quot;HTTP 메시지를 풀고 싸는 일은 컨테이너가 다 처리할 테니, 너는 비즈니스 로직만 짜라.&quot;</p>
</blockquote>
<p>서블릿을 쓰면 우리 코드는 더 이상 <code>BufferedReader.readLine()</code> 으로 첫 줄을 파싱하지 않는다. 대신 <code>HttpServletRequest</code> 객체에서 깔끔한 메서드로 정보를 꺼낸다.</p>
<table>
<thead>
<tr>
<th>직접 짜기</th>
<th>서블릿</th>
</tr>
</thead>
<tbody><tr>
<td><code>requestLine.split(&quot; &quot;)[0]</code></td>
<td><code>request.getMethod()</code></td>
</tr>
<tr>
<td>헤더 루프 돌리며 <code>Content-Length</code> 찾기</td>
<td><code>request.getContentLength()</code></td>
</tr>
<tr>
<td>빈 줄 후에 정확히 N 바이트 읽기</td>
<td><code>request.getReader()</code></td>
</tr>
<tr>
<td>쿼리 스트링 split하고 디코드</td>
<td><code>request.getParameter(&quot;page&quot;)</code></td>
</tr>
<tr>
<td>응답 상태 라인 직접 쓰기</td>
<td><code>response.setStatus(201)</code></td>
</tr>
</tbody></table>
<p>응답 쪽도 마찬가지로 <code>HttpServletResponse</code> 가 헤더와 상태 코드를 알아서 조립해준다. 우리 손에는 비즈니스 로직만 남는다.</p>
<p>정리하면 이런 위계가 생긴다.</p>
<table>
<thead>
<tr>
<th>계층</th>
<th>우리가 다루는 것</th>
<th>누가 처리하는가</th>
</tr>
</thead>
<tbody><tr>
<td>비즈니스 로직</td>
<td>&quot;강의를 저장한다&quot;</td>
<td><strong>너의 코드</strong></td>
</tr>
<tr>
<td>HTTP 메시지 처리</td>
<td>요청 파싱, 응답 직렬화</td>
<td><strong>서블릿 컨테이너 (톰캣)</strong></td>
</tr>
<tr>
<td>TCP/IP 통신</td>
<td>패킷 라우팅, 재전송</td>
<td>OS 커널</td>
</tr>
<tr>
<td>물리 신호</td>
<td>전기/전파</td>
<td>NIC + 드라이버</td>
</tr>
</tbody></table>
<p>§2에서 OS 커널이 TCP/IP 보일러플레이트를 다 처리해줬듯, 서블릿 컨테이너는 그 위에서 HTTP 보일러플레이트를 처리한다. 추상화가 한 층 더 쌓인 셈이다.</p>
<p>서블릿이 정확히 어떻게 동작하는지, 톰캣 같은 서블릿 컨테이너가 무슨 일을 하는지는 다음 글에서 풀어보려 한다. 이 글의 목적은 &quot;왜 서블릿이라는 추상화가 필요해졌는가&quot;를 직접 코드를 짜보면서 체감하는 것이었으니까.</p>
<hr>
<h2 id="마무리--serversocket이면-충분할-줄-알았다">마무리 — &quot;ServerSocket이면 충분할 줄 알았다&quot;</h2>
<p>처음 ServerSocket 예제를 봤을 때는 &quot;어, 별거 아니네. CRUD 4개 정도는 직접 짜도 되겠는데?&quot;라는 자신감이 있었다. 그런데 직접 짜보니, 매 단계에서 생각이 틀렸다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>내가 생각한 것</th>
<th>직접 짜보니</th>
</tr>
</thead>
<tbody><tr>
<td>§1 ServerSocket 코드</td>
<td>&quot;텍스트만 쓰면 끝, 별거 아니네&quot;</td>
<td>텍스트만 써도 통신이 된 게 의문이었다</td>
</tr>
<tr>
<td>§2 Socket 추상화</td>
<td>&quot;그냥 객체 하나겠거니&quot;</td>
<td>커널이 떠받치는 4계층 시스템의 핸들이었다</td>
</tr>
<tr>
<td>§3 CRUD 분기</td>
<td>&quot;if/else 4개면 충분하지&quot;</td>
<td>헤더 파싱, 바디 길이, 경로 변수, URL 디코딩... 함정이 분기마다 늘어났다</td>
</tr>
<tr>
<td>§4 보일러플레이트</td>
<td>&quot;어차피 한 번만 짜면 되니까&quot;</td>
<td>엔드포인트가 늘수록 곱연산으로 증가했다</td>
</tr>
<tr>
<td>§5 직접 짜기 vs 서블릿</td>
<td>&quot;프레임워크는 무겁고 복잡한 거 아닌가&quot;</td>
<td>보일러플레이트를 컨테이너에 위임한다는 명료한 계약이었다</td>
</tr>
</tbody></table>
<p>서블릿은 과잉 설계가 아니었다. <strong>HTTP가 표준 스펙이라는 사실 그 자체가 이미 &quot;표준화된 처리 계층&quot;을 요구하고 있었다.</strong> OS 커널이 TCP/IP 스펙을 처리해주듯, 서블릿 컨테이너는 HTTP 스펙을 처리해준다. 모든 개발자가 매번 직접 짜는 게 비효율이었던 거고.</p>
<p>직접 ServerSocket으로 짜는 건 매일 출근길에 직접 도로를 깔고 다리를 놓는 일과 비슷하다. 한두 번은 멋지지만, 매일 그러려면 시간이 모자란다. 도로와 다리는 누군가 이미 깔아놓은 것을 쓰고, 우리는 그 위에서 어디로 갈지만 결정하는 게 효율적이다.</p>
<p>그래도 도로 밑에 어떤 콘크리트와 철근이 들어가 있는지 한 번쯤 봐두면, 나중에 도로가 무너졌을 때 그 위에서 길을 잃지 않는다. <strong>결국 프레임워크를 잘 쓴다는 건 프레임워크 없이도 무엇이 일어나는지를 아는 것에서 시작한다는 걸 느꼈다.</strong> 이 글이 그 한 번의 들춰보기였길 바란다. 다음 글에서는 이 도로 — 서블릿과 톰캣 — 가 정확히 어떻게 깔려 있는지를 들여다보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[루퍼스 부트캠프를 마치며...]]></title>
            <link>https://velog.io/@praesentia-ykm/%EB%A3%A8%ED%8D%BC%EC%8A%A4-%EB%B6%80%ED%8A%B8%EC%BA%A0%ED%94%84%EB%A5%BC-%EB%A7%88%EC%B9%98%EB%A9%B0</link>
            <guid>https://velog.io/@praesentia-ykm/%EB%A3%A8%ED%8D%BC%EC%8A%A4-%EB%B6%80%ED%8A%B8%EC%BA%A0%ED%94%84%EB%A5%BC-%EB%A7%88%EC%B9%98%EB%A9%B0</guid>
            <pubDate>Sun, 26 Apr 2026 14:28:13 GMT</pubDate>
            <description><![CDATA[<h1 id="루퍼스-부트캠프-10주-나는-무엇이-달라졌나-🌱">루퍼스 부트캠프 10주, 나는 무엇이 달라졌나 🌱</h1>
<blockquote>
<p>&quot;이 시장에서 나는 경쟁력이 있는 사람일까?&quot;
그 질문 하나로 시작된 10주의 기록.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/84a8847d-9fbb-4c25-a7b4-e54ec107b815/image.png" alt=""></p>
<h2 id="들어가며">들어가며</h2>
<p>2025-12-31은 나에게 의미있는 날이였다.</p>
<p>25년도 한 해를 마무리하며 회고하고 있었는데 문득 이런 생각이 들었다.
어느새 3년이나 회사를 다녔는데, <strong>나는 과연 이 시장에 경쟁력이 있는 사람일까? 같이 일하고 싶어하는 동료일까?</strong> 스스로를 되물어봐도 답이 나오지 않는 게 답답했다.</p>
<p>그래서 도전하고 싶었다. 더 많은 사람들을 만나보고 싶었다. 이런 견문을 넓히기 위해 노력하던 중 우연히 지인을 통해 <strong>루퍼스(Loopers)라는 부트캠프</strong>를 알게되었다.</p>
<hr>
<h2 id="변화한-나">변화한 나</h2>
<p>루퍼스 부트캠프를 하며 나는 크게 3가지가 변했다.</p>
<ul>
<li>기술에 대한 관점</li>
<li>학습에 대한 관점</li>
<li>소통에 대한 관점</li>
</ul>
<h3 id="🛠-기술에-대한-관점">🛠 기술에 대한 관점</h3>
<p>이전에는 *&quot;좋은 기술이 좋은거다&quot;* 라는 마음가짐이 없지 않아 있었던 거 같다. 항상 트레이드오프를 고민한다고 자부해왔지만, 마음 속 어디선가는 기술을 뽐내고 활용하고 싶어하는 욕구가 너무 강했던 거 같다.</p>
<p>루퍼스에서 <strong>커머스 백엔드의 동시성 제어</strong>를 다루면서 이 생각이 결정적으로 바뀌었다.</p>
<p>처음엔 단순히 *&quot;동시성 문제는 락으로 해결하면 되지&quot;* 라고 생각했다. 그런데 실제로 <strong>재고 차감, 쿠폰 사용, 좋아요 카운트</strong> 세 가지 도메인을 마주하니, <em>같은 동시성 문제인데도 모두 다른 답</em>을 요구한다는 걸 알게 됐다.</p>
<table>
<thead>
<tr>
<th>도메인</th>
<th>선택한 전략</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>재고 차감</td>
<td>비관적 락 (<code>PESSIMISTIC_WRITE</code>)</td>
<td>초과 판매가 비즈니스에 치명적, 재시도가 무의미</td>
</tr>
<tr>
<td>쿠폰 사용</td>
<td>비관적 락 + <code>UniqueConstraint</code></td>
<td>1회만 성공해야 하고, 중복 발급도 막아야 함</td>
</tr>
<tr>
<td>좋아요 카운트</td>
<td>원자적 업데이트 (<code>SET like_count = like_count + 1</code>)</td>
<td>순수 증감 연산, 모든 요청이 정당함</td>
</tr>
</tbody></table>
<blockquote>
<p>모든 기술에는 등장 배경이 있고,
비지니스와 도메인 특성 상 트레이드오프를 고민하여
<strong>현재 버릴 수 있는 것과 택해야 하는 것</strong>을 잘 구분할 줄 아는 능력이 중요하다.
세상에 나쁜 기술은 없다.</p>
</blockquote>
<p>비관적 락이 무조건 무거운 게 아니고, 낙관적 락이 무조건 가벼운 게 아니다. 도메인의 <em>경합 빈도</em>, <em>실패의 비용</em>, <em>재시도의 의미</em> — 이 세 가지를 저울에 올렸을 때 답이 도메인마다 갈렸다. <strong>기술의 우열이 아니라, 문제와 해법의 궁합</strong>이라는 걸 손으로 부딪혀보고 나서야 알았다.</p>
<hr>
<h3 id="📚-학습에-대한-관점">📚 학습에 대한 관점</h3>
<p>수강 전의 나는 되게 지식을 깊게만 파고들려는 성향이 강한 사람이였다. <strong>깊이우선탐색(?) 방식 공부법</strong>이라고 해야하나? 뭔가를 굉장히 딥하게 공부하려는 습관이 강한 사람이였다.</p>
<p>그런데 AI 시대를 격으며 계속 두려움이 생겼다. *&quot;내가 일주일을 들여 공부한 걸, AI는 5분이면 더 정확히 답하는데 의미가 있나?&quot;*</p>
<p>루퍼스에서 만난 <strong>증강 코딩(Augmented Coding) 워크플로우</strong>가 이 두려움에 답을 줬다. 핵심은 단 한 줄이었다.</p>
<blockquote>
<p><strong>방향성 및 주요 의사결정은 AI 가 제안만 할 수 있으며,
최종 승인된 사항을 기반으로 작업을 수행한다.</strong></p>
</blockquote>
<p>이전의 나는 AI를 *&quot;답을 알려주는 존재&quot;* 로 봤다. 그래서 AI가 잘하는 영역을 침범당한다고 느꼈다. 하지만 부트캠프에서는 AI를 *&quot;가설을 빠르게 제안해주는 동료&quot;* 로 다뤘다. 결정은 내가 하고, AI는 옵션을 펼쳐준다. <strong>나의 가치는 &quot;기억&quot;이 아니라 &quot;판단&quot;에서 나온다는 걸</strong> 그제야 받아들였다.</p>
<p>이후 학습 방향이 완전히 바뀌었다.</p>
<ul>
<li>모든 걸 외우려 하지 않는다 — 대신 *&quot;왜 이 선택지가 존재하는가&quot;* 를 이해한다.</li>
<li>깊이만 파지 않는다 — 의사결정에 필요한 만큼 넓게 본 다음, 필요할 때 깊이 들어간다.</li>
<li>AI에게 질문하기 전에 <strong>내 가설을 먼저 글로 적는다</strong> — 비교할 기준이 있어야 AI 답변을 평가할 수 있으니까.</li>
</ul>
<hr>
<h3 id="🤝-소통에-대한-관점">🤝 소통에 대한 관점</h3>
<p>이전의 나는 *&quot;내 코드만 잘 짜면 된다&quot;* 는 생각이 강했다. 회사에서는 다른 사람의 코드를 깊이 들여다볼 일도, 내 결정의 이유를 글로 풀어낼 일도 생각보다 많지 않았다.</p>
<p>루퍼스는 달랐다. 모든 라운드 끝에 PR을 올리고, <strong>PR 본문에 의사결정 과정을 글로 적어야</strong> 했다. 처음엔 그게 시간 낭비처럼 느껴졌다. *&quot;코드 보면 다 보이는데 왜 또 적어?&quot;*</p>
<p>그런데 한번 적어보고 깨달았다. <strong>글로 풀어낼 수 없는 결정은, 사실 내가 제대로 이해 못 한 결정이라는 것.</strong></p>
<p>이걸 적는 순간, 내가 <strong>왜</strong> 그 결정을 내렸는지가 비로소 또렷해졌다. 기술 결정은 머릿속에서 정리하는 것과, 동료가 5분 안에 납득할 수 있게 풀어내는 게 완전히 다른 일이었다. <strong>좋은 코드보다 어려운 건, 내 결정의 이유를 다른 사람이 납득할 수 있게 전달하는 일</strong>이라는 걸 여기서 처음 체감했다.</p>
<hr>
<h2 id="가장-인상-깊었던-프로젝트-추천하고-싶은-이유">가장 인상 깊었던 프로젝트, 추천하고 싶은 이유</h2>
<p>10주 동안 여러 라운드를 거쳤지만, 가장 기억에 남는 건 <strong>커머스 백엔드 — 동시성 제어 라운드</strong>다.</p>
<h3 id="어떤-프로젝트였나">어떤 프로젝트였나</h3>
<ul>
<li><strong>주제</strong>: 멀티 모듈 기반 커머스 백엔드 (<code>commerce-api</code>, <code>commerce-batch</code>, <code>commerce-streamer</code>)</li>
<li><strong>사용 기술</strong>: Java 21 / Spring Boot 3.4.4 / MySQL 8 / Redis 7 (master-replica) / Kafka 3.5 (KRaft) / JPA + QueryDSL</li>
<li><strong>아키텍처</strong>: 레이어드 아키텍처 + DIP (<code>interfaces</code> → <code>application</code> → <code>domain</code> ← <code>infrastructure</code>)</li>
<li><strong>테스트 전략</strong>: Unit / Integration / E2E 3-tier + TestContainers</li>
</ul>
<h3 id="가장-좋았던-한-라운드--같은-동시성-문제-다른-답-3개">가장 좋았던 한 라운드 — *&quot;같은 동시성 문제, 다른 답 3개&quot;*</h3>
<p>이 프로젝트의 백미는 <strong>재고·쿠폰·좋아요</strong>라는 세 개의 도메인을 다루며 <em>각각 다른 동시성 전략</em>을 선택해야 했던 라운드였다.</p>
<p>처음엔 모두 비관적 락으로 풀었다. 동작은 했다. 하지만 멘토님의 한마디가 있었다.</p>
<blockquote>
<p>*&quot;세 도메인이 다 같은 비관적 락이면, 정말 그 세 도메인이 다 같은 성격이라는 뜻인가요?&quot;*</p>
</blockquote>
<p>그 질문 하나로 다시 처음부터 분석을 시작했다. 결국 도달한 결론은 이거였다:</p>
<h4 id="1️⃣-재고-차감-→-비관적-락">1️⃣ 재고 차감 → 비관적 락</h4>
<p>재고는 <strong>Read 후 비즈니스 판단</strong>이 필요하다 (재고 부족 검증 → 금액 계산 → 주문 생성). Read와 Write 사이의 gap을 근본적으로 차단해야 했다. 추가로 <code>productId</code> 오름차순 정렬로 락 획득 순서를 통일해 <strong>데드락도 방지</strong>했다.</p>
<pre><code class="language-java">// productId 오름차순 정렬 → 데드락 방지
List&lt;Long&gt; sortedProductIds = orderItems.stream()
    .map(OrderItem::getProductId)
    .sorted()
    .toList();</code></pre>
<h4 id="2️⃣-쿠폰-사용-→-비관적-락--uniqueconstraint">2️⃣ 쿠폰 사용 → 비관적 락 + UniqueConstraint</h4>
<p>쿠폰은 <strong>1회만 성공해야 하고, 재시도가 무의미</strong>하다. 게다가 발급 자체가 중복되면 안 되기 때문에 DB 레벨의 <code>@UniqueConstraint(user_id, coupon_id)</code>를 이중 방어로 걸었다.</p>
<h4 id="3️⃣-좋아요-카운트-→-원자적-업데이트-전환-결정">3️⃣ 좋아요 카운트 → 원자적 업데이트 (전환 결정)</h4>
<p>처음엔 좋아요도 낙관적 락(<code>@Version</code>)을 썼다. 그런데 동시성 테스트를 돌려보니 <strong>재시도 폭주</strong>가 발생했다. 원인을 추적하니, <code>@Version</code>은 <em>엔티티 레벨</em>이라 좋아요와 상품 수정이 <strong>같은 version을 경합</strong>하고 있었다. 서로 무관한 연산인데 <em>False Conflict</em>가 일어난 것이다.</p>
<p>그래서 원자적 업데이트로 갈아엎었다.</p>
<pre><code class="language-java">@Modifying
@Query(&quot;UPDATE ProductModel p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id&quot;)
void incrementLikeCount(@Param(&quot;id&quot;) Long id);</code></pre>
<p>JPA 변경 감지를 우회하니 <code>@Version</code> 자체가 트리거되지 않는다. 재시도 로직(<code>retryOnOptimisticLock</code>)도, 별도 트랜잭션 분리(<code>LikeTransactionService</code>)의 복잡한 경계도 전부 사라졌다. <strong>복잡도가 한 단계 낮아진 것이다.</strong></p>
<h3 id="추천하고-싶은-이유">추천하고 싶은 이유</h3>
<blockquote>
<p>이 프로젝트가 좋았던 단 한 가지 이유를 꼽자면,
<strong>&quot;답이 하나가 아닌 문제를 끝까지 비교하게 만든다&quot;</strong> 는 점이다.</p>
</blockquote>
<p>회사에서는 잘못된 설계 하나가 운영 사고로 이어진다. 그래서 *&quot;안전한 답&quot;* 을 빠르게 고르고 넘어가는 게 합리적이다. 루퍼스에서는 *&quot;왜 이 답이 다른 답보다 나은가&quot;* 를 끝까지 따져볼 수 있다.</p>
<hr>
<h2 id="강의와-멘토링에서-좋았던-점">강의와 멘토링에서 좋았던 점</h2>
<h3 id="1-답을-주지-않는-멘토링">1. 답을 주지 않는 멘토링</h3>
<p>루퍼스의 멘토링은 *&quot;이렇게 하세요&quot;* 가 아니라 <strong>*&quot;왜 그렇게 하셨어요?&quot;*</strong> 로 시작했다.</p>
<p>처음엔 답답했다. 빨리 정답을 알고 넘어가고 싶었으니까. 하지만 그 질문 하나가 나를 한참 더 깊게 생각하게 만들었고, 결국 내가 스스로 결론을 내릴 수 있게 됐다.</p>
<p>특히 인상 깊었던 멘토링 방식은 <strong>&quot;여러 가지 설계 방식을 제안하며, 트레이드오프를 알려주고, 최종 결정은 개발자가 한다&quot;</strong> 는 방식이 더 발전된 나를 만들 수 있게 되었다.</p>
<blockquote>
<p>물고기를 주는 게 아니라 낚시법을 가르치는 멘토링,
정확히 그것이었다.</p>
</blockquote>
<h3 id="2-깊이와-넓이의-균형">2. 깊이와 넓이의 균형</h3>
<ul>
<li><strong>왜 이 기술이 등장했는가</strong> (역사적 맥락)</li>
<li><strong>어떤 문제를 해결하려 했는가</strong> (설계 의도)</li>
<li><strong>어떤 한계가 있는가</strong> (트레이드오프)</li>
</ul>
<p>이 세 가지를 항상 함께 짚어줬다. 그런 부분이 나에겐 기술에 대한 깊이와 이해감에 도움이 되었다.</p>
<h3 id="3-ai-시대에-맞는-협업-가이드">3. AI 시대에 맞는 협업 가이드</h3>
<p>루퍼스에서 가장 인상 깊었던 건 AI를 다루는 명시적인 원칙을 가르쳐줬다는 점이다.</p>
<pre><code>- AI 가 임의 판단을 하지 않고, 방향성에 대한 제안 등을 진행할 수 있으나
  개발자의 승인을 받은 후 수행한다.
- AI 가 반복적인 동작을 하거나, 요청하지 않은 기능을 구현,
  테스트 삭제를 임의로 진행할 경우 개발자가 개입한다.</code></pre><p>요즘 *&quot;AI에게 일을 시키는 법&quot;* 을 가르치는 곳은 많다. 하지만 *&quot;AI에게 휘둘리지 않는 법&quot;* 을 가르치는 곳은 드물다. 루퍼스는 후자에 무게를 뒀고, 그게 가장 시대에 맞는 가르침이었다.</p>
<h3 id="4-동료라는-가장-큰-자산">4. 동료라는 가장 큰 자산</h3>
<p>강의나 멘토님 못지않게 좋았던 건 <strong>함께한 동료들</strong>이었다.</p>
<p>서로 다른 배경에서 온 사람들이 같은 문제를 푸는 걸 옆에서 보는 경험. 내가 *&quot;이게 정답&quot;* 이라 믿었던 접근이 누군가에게는 완전히 다른 방식으로 보일 수 있다는 걸 매일 깨달았다. 그리고 함께 고민하고 공감해주는 문화를 통해 사람이 자산이라는 생각이 들곤 했다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>10주 전의 나는 *&quot;내가 시장에 통할까?&quot;* 라는 질문을 안고 있었다.</p>
<p>지금의 나는 그 질문에 나는 아직도 배우고 나아가야할 점들이 많기에 아주 명쾌한 답을 가지고 있다고는 말할 수 없다고 생각한다. 다만 <strong>답을 찾아가는 방법은 알게 됐다.</strong> </p>
<p>비유하자면 이전의 나는 <strong>좋은 칼을 모으는 데 집중하는 요리사</strong>였다. 칼이 좋으면 음식이 좋아질 거라 믿었다. 지금의 나는, <strong>재료와 손님에 따라 어떤 칼을 들지 결정하는 요리사</strong>가 되고 싶다. 손님과 음식에 맞아야 좋은 도구가 된다.</p>
<p>기술을 바라보는 시선, 학습하는 방식, 동료와 소통하는 태도. 이 세 가지가 단단해진 것만으로도 나는 출발선이 달라졌다고 느낀다.</p>
<blockquote>
<p>누군가 나처럼 *&quot;이대로 괜찮을까&quot;* 를 매일 자문하고 있다면,
그 답답함을 풀 수 있는 가장 좋은 방법은 <strong>익숙한 자리에서 한 발 나오는 것</strong>이라고 말해주고 싶다.</p>
</blockquote>
<p>루퍼스에서의 10주는, 저에게 그 한 걸음이었습니다.
도움이 필요하시면 언제든지 댓글 남겨주세요!
루퍼스에 참여하시고 싶다면? 추천인 래퍼럴 코드입력을 통해 할인 받으실 수 있습니다! -&gt;  LJRY2</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[WIL (랭킹 시스템 설계: Redis ZSET부터 Spring Batch + MV까지)]]></title>
            <link>https://velog.io/@praesentia-ykm/WIL-%EB%9E%AD%ED%82%B9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84-Redis-ZSET%EB%B6%80%ED%84%B0-Spring-Batch-MV%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@praesentia-ykm/WIL-%EB%9E%AD%ED%82%B9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84-Redis-ZSET%EB%B6%80%ED%84%B0-Spring-Batch-MV%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Sat, 18 Apr 2026 01:41:10 GMT</pubDate>
            <description><![CDATA[<h2 id="이번-주-주제">이번 주 주제</h2>
<p>상품 랭킹 시스템을 <strong>일간 / 주간 / 월간</strong> 세 가지 주기로 나눠 구현했다.
핵심은 <em>&quot;조회 주기에 따라 적절한 저장소를 고르는 것&quot;</em> 이었다.</p>
<table>
<thead>
<tr>
<th>주기</th>
<th>저장소</th>
<th>갱신 방식</th>
</tr>
</thead>
<tbody><tr>
<td>일간(실시간)</td>
<td>Redis ZSET</td>
<td>Kafka 이벤트 기반 <code>ZINCRBY</code></td>
</tr>
<tr>
<td>주간/월간</td>
<td>RDB Materialized View</td>
<td>Spring Batch 집계</td>
</tr>
</tbody></table>
<hr>
<h2 id="1-redis-zset으로-실시간-랭킹-만들기">1. Redis ZSET으로 실시간 랭킹 만들기</h2>
<h3 id="왜-zset인가">왜 ZSET인가?</h3>
<p>랭킹은 결국 <strong>&quot;점수 기반 정렬 + 순위 조회&quot;</strong> 문제다.
ZSET은 내부적으로 skip list + hash를 써서 score 정렬 삽입/조회가 <code>O(log N)</code>으로 보장되고,
<code>ZINCRBY</code>는 원자적이라 동시성 고민이 크게 줄어든다.</p>
<h3 id="가중치-설계">가중치 설계</h3>
<p>이벤트마다 중요도가 다르기 때문에 단순 카운트가 아니라 가중치를 뒀다.</p>
<ul>
<li>조회 : <code>0.1</code></li>
<li>좋아요 : <code>0.2</code></li>
<li>주문 : <code>0.7 × log10(quantity + 1)</code></li>
</ul>
<p>주문에 <code>log10</code>을 씌운 건, <strong>대량 주문 1건이 모든 지표를 압도하는 것을 막기 위함</strong>이다.
이걸 안 넣으면 특정 상품이 랭킹을 독점해서 &quot;랭킹&quot;의 의미가 사라진다.</p>
<h3 id="키-전략">키 전략</h3>
<pre><code>ranking:20260418  ← 날짜별 ZSET</code></pre><p>일별로 키를 쪼개면 &quot;오늘의 랭킹&quot;만 계산/조회하면 되고,
TTL만 걸어두면 과거 데이터 정리도 자동화된다.</p>
<h3 id="이벤트-드리븐-업데이트">이벤트 드리븐 업데이트</h3>
<pre><code>OrderPlacedEvent → Kafka → OrderEventConsumer → ZINCRBY
CatalogViewEvent → Kafka → CatalogEventConsumer → ZINCRBY</code></pre><p>주문/조회 API 응답 속도가 랭킹 계산에 영향받지 않도록 <strong>Kafka로 분리</strong>했다.
API는 이벤트만 던지고 끝. 랭킹은 streamer 모듈이 따로 받아서 반영한다.</p>
<hr>
<h2 id="2-주간월간은-왜-redis가-아닐까">2. 주간/월간은 왜 Redis가 아닐까?</h2>
<p>처음엔 &quot;ZSET 키를 <code>ranking:week:2026W16</code>처럼 주 단위로 만들면 되지 않나?&quot; 생각했다.
근데 몇 가지 문제가 있다.</p>
<ol>
<li><strong>Redis 메모리 비용</strong> - 주간 누적 데이터는 훨씬 크다. 상품 수 × 주 수.</li>
<li><strong>이벤트 재계산 불가</strong> - 과거 데이터 보정이 필요하면 ZSET은 고칠 방법이 없다.</li>
<li><strong>복잡한 집계 불가</strong> - &quot;카테고리별 주간 Top 10&quot; 같은 쿼리는 RDB가 훨씬 자연스럽다.</li>
</ol>
<p>그래서 <strong>일간은 Redis, 주간/월간은 MV(Materialized View) + Spring Batch</strong> 조합으로 갔다.</p>
<hr>
<h2 id="3-spring-batch로-mv-갱신하기">3. Spring Batch로 MV 갱신하기</h2>
<h3 id="reader-→-processor-→-writer-패턴">Reader → Processor → Writer 패턴</h3>
<pre><code>ProductMetricsDaily (원천)
   ↓ Reader (chunk 단위로 읽기)
   ↓ Processor (점수 계산)
   ↓ Writer (MvProductRankWeekly에 bulk insert)</code></pre><p>Row-by-row가 아니라 <strong>청크 단위로 처리</strong>해서 처리량 확보.</p>
<h3 id="버전-기반-무중단-갱신">버전 기반 무중단 갱신</h3>
<p>이번에 가장 크게 배운 패턴.</p>
<pre><code>1. 새 버전으로 MV 테이블에 write  (version = N+1)
2. ActivateVersionTasklet 실행     (active_version = N+1)
3. ClearOldVersionTasklet 실행     (version = N 삭제)</code></pre><p>배치 도는 중에도 API는 <strong>이전 버전(N)을 읽기 때문에 조회 중단이 없다</strong>.
활성 버전을 원자적으로 스왑하는 순간에만 새 데이터가 노출된다.
<em>&quot;blue-green 배포를 데이터에 적용한 느낌&quot;</em> 이 딱 맞는 비유였다.</p>
<hr>
<h2 id="4-한-줄-회고">4. 한 줄 회고</h2>
<blockquote>
<p><strong>&quot;저장소는 접근 패턴이 결정한다.&quot;</strong></p>
</blockquote>
<p>실시간 증분 + 빠른 순위 조회 → Redis ZSET.
집계/히스토리/복잡 쿼리 → RDB + 배치.
둘을 하나로 통일하려고 하지 말고, <strong>역할에 맞는 도구를 각자 쓰게 두는 것</strong>이 이번 주의 가장 큰 교훈이었다.</p>
<hr>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/a91de6b5-aad1-48d1-a2e4-58a79a8cd844/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[배치의 언어학]]></title>
            <link>https://velog.io/@praesentia-ykm/%EB%B0%B0%EC%B9%98%EC%9D%98-%EC%96%B8%EC%96%B4%ED%95%99</link>
            <guid>https://velog.io/@praesentia-ykm/%EB%B0%B0%EC%B9%98%EC%9D%98-%EC%96%B8%EC%96%B4%ED%95%99</guid>
            <pubDate>Fri, 17 Apr 2026 02:37:02 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이 글은 <code>L0~L7</code> 8가지 배치 기법을 <strong>동일 데이터셋</strong>으로 측정하고, 각 기법이 어떤 상황에서 옳은 선택인지 역으로 추적한 기록입니다. 코드와 측정값은 <a href="https://github.com/Praesentia-YKM/batch-benchmark-java">batch-benchmark-java</a> 저장소에서 모두 재현할 수 있습니다.</p>
</blockquote>
<hr>
<h2 id="part-0-들어가며--가장-빠른-배치는">Part 0. 들어가며 — &quot;가장 빠른 배치는?&quot;</h2>
<p>작년에 팀에서 배치 작업을 새로 설계하면서, 제 머릿속에는 이런 은근한 순위표가 있었습니다.</p>
<pre><code>  느림                                                  빠름
┌───────────────────────────────────────────────────────────┐
│ JDBC &lt; JPA &lt; Spring Batch &lt; Partitioning &lt; Kafka &lt; Spark   
└───────────────────────────────────────────────────────────┘
  &quot;낮은 레벨&quot;                              &quot;고급 / 병렬 / 분산&quot;</code></pre><p>&quot;JDBC 는 원시적이고, JPA 는 편의 레이어일 뿐이고, Spring Batch 는 그것을 운영 가능하게 만든 것이고, 병렬화하면 더 빠르고, 분산하면 최고로 빠르다.&quot; 대략 이런 인지 모형이었습니다. 실무에서 Spring Batch 를 쓰거나 Kafka 를 쓰는 건 &#39;단순히 빨라서&#39; 라고 막연히 생각했던 점이 있던 거 같습니다.</p>
<p>그래서 <code>batch-benchmark-java</code> 라는 실험실을 열고, L0 에서 L7 까지 8 가지 방식을 <strong>같은 데이터로 같은 머신에서</strong> 돌려봤습니다. 결과는 이랬습니다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/ad4a5a03-ab09-4a2b-bfe6-3c11f1f284f7/image.png" alt=""></p>
<p>위 순위표가 이 차트와 얼마나 일치했는지는 스크린샷이 말해 줍니다.</p>
<ul>
<li>L0(Raw JDBC) 은 이미 내부적으로 <strong>BATCH_SIZE=1000 으로 묶여 있었습니다</strong>. 이름만 보고 &quot;원시적=느림&quot; 이라 생각한 제 선입견이 깨졌습니다.</li>
<li><strong>L4 (Spring Batch) 가 L1 (<code>JdbcTemplate.batchUpdate</code>) 보다 17 배 빨랐습니다.</strong> &quot;Spring Batch = 느리지만 운영 좋음&quot; 이라는 제 가설이 뒤집혔습니다.</li>
<li>같은 4 스레드인데 <strong>L7 (ForkJoinPool) 이 L5 (Spring Batch Partitioning) 보다 17 배 빨랐습니다.</strong> 스레드 수가 아니라 관리 레이어가 문제였습니다.</li>
<li>L2 (JPA flush/clear) 는 L1 대비 44 배 느렸습니다. &quot;성능 때문에 JPA 를 선택한다&quot; 는 흔한 오해가 분명히 드러나는 수치.</li>
<li>L6 (Kafka) 의 &quot;빠름&quot; 은 다른 레벨들과 <strong>축이 다른 빠름</strong> 이었습니다.</li>
</ul>
<p>그러니까 제 인지 모형은 틀렸습니다. 단순히 &quot;순서가 다르다&quot; 가 아니라, <strong>줄 세우는 축 자체를 잘못 잡고 있었다</strong> 는 게 맞는 표현입니다.</p>
<p>이 글은 그 틀린 모형을 교정해 가는 과정입니다. Redis 의 RESP 프로토콜을 처음 봤을 때 느꼈던 것과 비슷했어요. &quot;통신 프로토콜&quot; 이라는 단어가 주는 거대한 인상과, 실제로 netcat 으로 열어보니 &quot;그냥 약속된 문자열&quot; 이었던 그 낙차. 배치에도 그런 낙차가 있습니다.</p>
<p>이 글이 던지는 질문은 이것 하나입니다.</p>
<blockquote>
<p><strong>가장 빠른 배치 기술은 무엇인가요?</strong></p>
</blockquote>
<p>읽고 나면 이 질문의 모양이 달라져 있을 겁니다.</p>
<h3 id="비교-대상-레벨--각-l-에서-실제로-쓰는-기술">비교 대상 레벨 — 각 L 에서 실제로 쓰는 기술</h3>
<p>본격 들어가기 전에, 이 벤치마크의 &quot;L0 ~ L7&quot; 이 각각 어떤 구현으로 되어 있는지 먼저 밝혀 둡니다. 이름만 보고 생기는 오해(Part 1 첫 발견담) 를 줄이기 위해서요.</p>
<table>
<thead>
<tr>
<th align="center">Level</th>
<th>구현 기술</th>
<th>핵심 구조</th>
<th align="center">병렬</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><strong>L0</strong></td>
<td><strong>Raw JDBC</strong> <code>PreparedStatement.addBatch()</code> + 수동 BATCH_SIZE=1000</td>
<td>스프링 의존 없음, <code>conn.setAutoCommit(false)</code> → chunk 별 <code>executeBatch()</code> → 최종 1 회 commit</td>
<td align="center">단일 스레드</td>
</tr>
<tr>
<td align="center"><strong>L1</strong></td>
<td><strong>Spring <code>JdbcTemplate.batchUpdate()</code></strong></td>
<td>JDBC 위의 얇은 래퍼, 내부에서 <code>BatchPreparedStatementSetter</code> 호출</td>
<td align="center">단일 스레드</td>
</tr>
<tr>
<td align="center"><strong>L2</strong></td>
<td><strong>Spring Data JPA + <code>em.persist</code> / <code>em.flush</code> / <code>em.clear</code></strong></td>
<td>ORM + 1 차 캐시, 일정 주기로 영속성 컨텍스트 비움</td>
<td align="center">단일 스레드</td>
</tr>
<tr>
<td align="center"><strong>L3</strong></td>
<td><strong><code>@Scheduled</code> + JPA</strong></td>
<td>시간축 트리거(트리거가 곧 기술은 아님), 적재 부분은 L2 와 동일</td>
<td align="center">단일 스레드</td>
</tr>
<tr>
<td align="center"><strong>L4</strong></td>
<td><strong>Spring Batch Chunk</strong> (<code>JpaItemWriter</code> / <code>JdbcBatchItemWriter</code>)</td>
<td><code>BATCH_JOB_EXECUTION</code> 계열 메타 테이블, Job / Step / Reader / Processor / Writer</td>
<td align="center">단일 스레드</td>
</tr>
<tr>
<td align="center"><strong>L5</strong></td>
<td><strong>Spring Batch + Partitioning</strong></td>
<td><code>PartitionHandler</code> 로 데이터 4 조각, 각 파티션 독립 Step + 독립 트랜잭션</td>
<td align="center">4 스레드</td>
</tr>
<tr>
<td align="center"><strong>L6</strong></td>
<td><strong>Spring Kafka Producer / Consumer</strong> (<code>KafkaTemplate</code> + <code>@KafkaListener</code>)</td>
<td>KRaft 단일 브로커, producer → 이벤트 → consumer 가 DB 적재, <code>awaitCompletion</code></td>
<td align="center">파티션 병렬</td>
</tr>
<tr>
<td align="center"><strong>L7</strong></td>
<td><strong><code>ForkJoinPool(4)</code> + <code>JdbcTemplate</code></strong></td>
<td>자바 표준 병렬, 트랜잭션 / 재시작 관리 없음</td>
<td align="center">4 스레드</td>
</tr>
</tbody></table>
<blockquote>
<p>프로젝트 상위 폴더에도 &quot;<code>level7-spark-local</code>&quot; 이라는 이름이 붙어 있지만 실제 구현은 <strong>Spark 가 아닌 ForkJoinPool 기반</strong> 입니다. 프로젝트 구조가 &quot;Spark 를 얹을 자리를 준비해 둔&quot; 상태고, 이 측정에서는 L7 = ForkJoinPool 로 읽어야 합니다.</p>
</blockquote>
<p>이 표를 머리에 둔 채로 Part 1 로 들어가 주세요.</p>
<hr>
<h2 id="part-1-io-왕복의-비용--commit-한-번에-얼마를-태우고-있나요">Part 1. IO 왕복의 비용 — &quot;commit 한 번에 얼마를 태우고 있나요&quot;</h2>
<p>저는 이 벤치마크를 열었을 때 레벨 번호를 보고 이렇게 짐작했습니다.</p>
<blockquote>
<p>&quot;L0 은 Raw JDBC 니까 <code>PreparedStatement.executeUpdate()</code> 를 한 건씩 호출하는 안티패턴이겠지. L1 에서 <code>batchUpdate</code> 로 묶어서 개선되는 형태겠고.&quot;</p>
</blockquote>
<p>그런데 L0 소스를 열어 보고 놀랐습니다.</p>
<pre><code class="language-java">// RawJdbcBatchBenchmark.java
conn.setAutoCommit(false);

for (OrderRaw order : data) {
    ps.setLong(1, order.userId());
    // ...
    ps.addBatch();
    if (count % BATCH_SIZE == 0) {   // BATCH_SIZE = 1000
        ps.executeBatch();
    }
}
conn.commit();                        // 맨 마지막에 1번</code></pre>
<p>L0 은 이미 <strong>BATCH_SIZE=1000 으로 묶여 있었고, commit 도 단 1 번</strong> 이었습니다. 제가 머릿속에 그리던 &quot;commit-per-row 안티패턴&quot; 은 이 벤치마크에 <strong>애초에 없었습니다.</strong></p>
<p>이게 첫 번째 낙차였습니다. &quot;Raw JDBC&quot; 라는 이름만 보고 느림을 상상했는데, 코드를 열어 보니 L0 과 L1 의 왕복 구조는 거의 같았습니다.</p>
<h3 id="l0-vs-l1--같은-왕복-구조-미세한-오버헤드-차이">L0 vs L1 — 같은 왕복 구조, 미세한 오버헤드 차이</h3>
<p>이 발견이 의미하는 바는 중요합니다. L0 과 L1 은 왕복 구조가 사실상 동일하므로, 두 레벨의 성능 차이는 <strong>&quot;올바른 배치 vs 잘못된 배치&quot; 가 아니라 &quot;Raw JDBC vs Spring JDBC 의 얇은 래퍼 오버헤드&quot;</strong> 에 불과합니다.</p>
<table>
<thead>
<tr>
<th>Level</th>
<th>구조</th>
<th align="right">Elapsed (ms, 100K)</th>
<th align="right">TPS</th>
</tr>
</thead>
<tbody><tr>
<td>L0</td>
<td>직접 <code>PreparedStatement + addBatch()</code> 관리, BATCH_SIZE=1000</td>
<td align="right">5,570</td>
<td align="right">17,950</td>
</tr>
<tr>
<td>L1</td>
<td>Spring <code>JdbcTemplate.batchUpdate</code>, 내부 BatchPreparedStatementSetter</td>
<td align="right">5,830</td>
<td align="right">17,156</td>
</tr>
</tbody></table>
<p>두 레벨의 격차는 약 5% 미만입니다. &quot;올바른 왕복 전략&quot; 을 둘 다 쓰고 있기에 대결 구도가 성립하지 않습니다. 이 벤치마크에서 <strong>정말 흥미로운 경계</strong> 는 여기가 아니라, &quot;묶어 보내는 방식&quot; 과 &quot;1 건씩 보내는 방식&quot; 사이에 있습니다. 이 경계를 직접 보려면 저장소의 <code>with-nobatch</code> 프로파일(<code>RawJdbcNoBatchBenchmark</code>)을 켜고 돌려보세요. 같은 100K 건이 수 분 ~ 수십 분 단위로 늘어나는 것을 눈으로 확인할 수 있습니다. 이 글에서는 본 측정이 지나치게 길어지는 것을 막기 위해 해당 레벨을 기본 비활성화 상태로 두었습니다.</p>
<h3 id="l2--jpa-에서는-메모리-라는-두-번째-축이-등장한다">L2 — JPA 에서는 &#39;메모리&#39; 라는 두 번째 축이 등장한다</h3>
<p>Part 1 의 마지막 퍼즐은 L2 JPA 입니다.</p>
<pre><code class="language-java">for (int i = 0; i &lt; data.size(); i++) {
    em.persist(convert(data.get(i)));
    if (i % 1000 == 0) {
        em.flush();
        em.clear();   // 1차 캐시를 주기적으로 비움
    }
}</code></pre>
<p>왜 이런 습관이 필요할까요? Hibernate 의 1차 캐시(Persistence Context)는 <code>persist()</code> 된 모든 엔티티를 Map 으로 들고 있습니다. <code>flush/clear</code> 를 잊으면 EntityManager 하나가 수십만 개의 엔티티 참조를 Heap 에 쌓아 둡니다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/2799570b-e9c3-4f8b-90eb-a04586a43498/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>Level</th>
<th align="right">Elapsed (ms, 100K)</th>
<th align="right">TPS</th>
<th align="right">Memory Peak (MB)</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>L1 (JdbcTemplate, 1차 캐시 없음)</td>
<td align="right">5,830</td>
<td align="right">17,156</td>
<td align="right">52.6</td>
<td>—</td>
</tr>
<tr>
<td>L2 (JPA flush/clear 적용)</td>
<td align="right"><strong>257,840</strong></td>
<td align="right"><strong>388</strong></td>
<td align="right">68.2</td>
<td>L1 대비 <strong>약 44 배 느림</strong></td>
</tr>
<tr>
<td>L2 (flush/clear 미적용, 참고)</td>
<td align="right">—</td>
<td align="right">—</td>
<td align="right">OOM 위험</td>
<td>본 측정 생략</td>
</tr>
</tbody></table>
<p><strong>여기서 주목할 점</strong>: L2 는 L1 보다 빠르지도 않고, 메모리도 더 씁니다. 그럼에도 많은 팀이 L2 를 쓰는 이유는 <strong>도메인 모델 중심 코드 때문</strong> 이지 성능이 아닙니다. 이 사실 자체가 &quot;성능을 위해 JPA 를 선택한다&quot; 는 흔한 오해를 교정해 줍니다.</p>
<h3 id="실패담--1000000-건에서-l2-는-끝나지-않았다">실패담 — 1,000,000 건에서 L2 는 끝나지 않았다</h3>
<p>처음에 저는 이 글을 &quot;100K vs 1M 두 규모&quot; 로 비교하려고 계획했습니다. 규모를 10 배 키웠을 때 각 레벨의 Elapsed 가 몇 배로 늘어나는지 — 그 <strong>기울기</strong> 가 병목의 정체를 드러낸다고 생각했으니까요.</p>
<p>실제로 1M 측정을 시작했더니, 15 분이 지나도 L2 가 끝나지 않아 hard timeout 이 걸렸습니다.</p>
<pre><code>=== HARD TIMEOUT after 15min ===
=== run FAILED (no report) at 09:09:02 ===</code></pre><p>100K 에서 L2 가 약 258 초 걸렸으니, &quot;1 차 캐시 관리 비용이 규모와 선형&quot; 이라면 1M 은 약 43 분이 되어야 합니다. 그런데 15 분이 지나도 끝나지 않았다는 건 <strong>비용이 선형이 아니라 초선형으로 증가한다</strong> 는 뜻입니다. <code>em.flush()</code> + <code>em.clear()</code> 를 아무리 주기적으로 해도, 엔티티 수가 늘어나면 1 차 캐시 내부 해시맵, 더티 체킹 탐색, JPQL 파서 캐시 등이 같이 커집니다. 이 곱셈 효과가 Wall clock 을 선형 예측보다 훨씬 빠르게 끌어당깁니다.</p>
<p>다시 말해 Part 1 의 첫 축(&quot;왕복 횟수&quot;) 에 이어, <strong>&quot;ORM 상태 관리가 규모와 비선형&quot; 이라는 두 번째 축</strong> 이 여기서 모습을 드러냅니다. 정량 측정으로는 수치를 내지 못했지만, <strong>&quot;15 분이 지나도 안 끝났다&quot; 는 사실 자체가 하나의 측정값</strong> 입니다.</p>
<p>참고로 L6 Kafka 도 1M 에서는 <code>consumer.awaitCompletion(..., 300)</code> 의 300 초 하드코딩 타임아웃에 부딪혀 실패합니다. 이건 다른 종류의 실패예요 — &quot;측정이 느려서&quot; 가 아니라 &quot;이 벤치마크 도구가 1M 스트리밍용으로 설계되지 않아서&quot; 입니다. Kafka 는 원래 &quot;끝을 기다리는&quot; 구조가 아니라는 Part 4 의 메시지가 여기서도 반복됩니다.</p>
<p>이 글이 100K 단일 규모로 마무리된 건 제가 선택한 경계가 아니라 <strong>도구와 환경이 강요한 경계</strong> 였습니다. 1M 에서의 기울기를 제대로 잡으려면 (a) JPA 의 영속성 컨텍스트 교체 전략, (b) L6 의 non-blocking 측정 모델을 먼저 다시 설계해야 합니다. 이 부분은 이 글의 범위 밖이고, 다음 글의 소재로 남겨 둡니다.</p>
<h3 id="중간-정리">중간 정리</h3>
<p>Part 1 의 메시지를 한 문장으로 압축하면 이렇습니다.</p>
<blockquote>
<p><strong>성능의 1차 축은 스레드 수가 아니라 &quot;커밋 당 왕복 횟수&quot; 입니다. 그리고 JPA 에서는 &quot;메모리 peak&quot; 라는 2차 축이 함께 따라옵니다.</strong></p>
</blockquote>
<p>그리고 이 글의 첫 발견담을 덧붙이자면 — &quot;배치&quot; 라는 이름은 아무것도 보장하지 않았습니다. &quot;L0 Raw JDBC Batch&quot; 라는 이름 아래에 실제로는 BATCH_SIZE=1000 의 올바른 구조가 있었습니다. <strong>이름이 아니라 코드를 열어 봐야</strong> 진짜 왕복 구조가 보입니다.</p>
<p>이 두 축이 배치 성능 논의의 70% 를 차지합니다. 남은 30% 가 병렬화·스케줄링·스트리밍인데, 이 부분은 Part 2~4 에서 다룹니다.</p>
<hr>
<h2 id="part-2-스케줄러는-기술이-아니다--그리고-spring-batch-가-예상을-뒤집었다">Part 2. 스케줄러는 &#39;기술&#39;이 아니다 — 그리고 Spring Batch 가 예상을 뒤집었다</h2>
<p>Part 1 을 다 본 뒤에도 의문이 남습니다. 그럼 결국 &quot;모든 배치는 <code>JdbcTemplate.batchUpdate</code> 한 줄이면 되는 것 아닌가?&quot; 라는 생각이요.</p>
<p>저도 그랬습니다. 그래서 이 벤치마크에서 L3, L4 를 준비했을 때, 사실은 &quot;이미 답이 나와 있는데 뭘 더 보나&quot; 싶었습니다.</p>
<h3 id="l3--scheduled-는-기술이-아니라-트리거였다">L3 — <code>@Scheduled</code> 는 &#39;기술&#39;이 아니라 &#39;트리거&#39;였다</h3>
<p>L3 는 <code>@Scheduled</code> 기반입니다. JPA 로 데이터를 적재하는 것 자체는 L2 와 동일하고, 실행을 &quot;밤 1시에 시작&quot; 같은 시간 축으로 자동화한 것뿐입니다.</p>
<p>솔직히 이 레벨은 측정 과정에서 제가 확인하지 못했습니다. <code>@Transactional</code> 이 걸려 있는 <code>runInsert</code> 를 <code>BenchmarkApplication</code> 이 List 주입으로 직접 호출하는 조합에서, Report 테이블에 L3 행이 찍히지 않았거든요. 여러 번 돌려도 마찬가지였습니다. 원인을 끝까지 추적하진 못했고, 이 글에서는 그 자체를 기록만 해둡니다. <strong>&quot;측정할 수 없었다&quot;도 결과의 일부</strong> 라는 뜻으로요.</p>
<p>다만 구조적으로 L3 의 처리량은 L2 와 동일해야 합니다. <code>@Scheduled</code> 는 트리거일 뿐 IO 패턴을 바꾸지 않기 때문입니다. &quot;배치&quot; 와 &quot;스케줄러&quot; 가 일상적으로 혼용되지만, 스케줄러는 배치의 성능 특성을 결정하지 않습니다.</p>
<h3 id="l4--spring-batch-가-예상을-뒤집었다">L4 — Spring Batch 가 예상을 뒤집었다</h3>
<p>Part 2 를 쓰기 시작할 때 저는 이런 구성이 될 거라 예상했습니다.</p>
<blockquote>
<p>&quot;L4 Spring Batch 는 L1 보다 <strong>느릴</strong> 것이다. 메타 테이블에 쓰는 오버헤드가 있으니까. 그럼에도 재시작 가능성 때문에 쓴다.&quot;</p>
</blockquote>
<p>측정 결과는 이 예측을 정면으로 뒤집었습니다.</p>
<table>
<thead>
<tr>
<th>Level</th>
<th align="right">Elapsed (ms, 100K)</th>
<th align="right">TPS</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>L1 JdbcTemplate</td>
<td align="right">5,830</td>
<td align="right">17,156</td>
<td>단순 batchUpdate</td>
</tr>
<tr>
<td><strong>L4 Spring Batch Chunk</strong></td>
<td align="right"><strong>350</strong></td>
<td align="right"><strong>289,017</strong></td>
<td>L1 의 <strong>약 16.7 배 빠름</strong></td>
</tr>
</tbody></table>
<p>L4 가 <strong>더 빠를 뿐 아니라, 압도적으로 빠릅니다.</strong> 원인은 코드를 열어 보니 단순했습니다. L4 의 ItemWriter(<code>JdbcBatchItemWriter</code>)가 chunk 단위로 multi-value <code>INSERT</code> 를 한 번에 쏘는 구조여서, 같은 100K 건을 훨씬 적은 왕복으로 처리합니다. 반면 L1 의 <code>batchUpdate</code> 는 같은 chunk 묶음이라도 드라이버 내부 path 가 다르고, 이 환경에서는 Writer 쪽이 훨씬 효율적으로 떨어진 듯합니다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/6bb0a43c-0236-41c7-83e6-07114fa1fda1/image.png" alt=""></p>
<p>그래서 이번 글의 Part 2 메시지는 제가 처음 쓰려던 것의 반대편에서 시작해야 했습니다.</p>
<blockquote>
<p><strong>Spring Batch 는 &quot;느리지만 운영에 좋은 도구&quot; 가 아닐 수 있다. 적어도 이 측정에서는 &quot;빠르면서 운영에도 좋은 도구&quot; 였다.</strong></p>
</blockquote>
<p>그렇다면 왜 모든 배치를 L4 로 만들지 않을까요? 여기서 <strong>비용 축</strong> 이 등장합니다. L4 를 쓰려면:</p>
<ul>
<li><code>BATCH_JOB_EXECUTION</code> 계열 메타 테이블 스키마 관리</li>
<li>Job / Step / ItemReader / ItemWriter / JobParameters 구조를 따라야 함</li>
<li>단순 한 번 INSERT 에도 Reader·Processor·Writer 구조를 만들어야 함</li>
<li>테스트·디버깅이 Spring Batch 의존성 위에서 일어남</li>
</ul>
<p>즉 &quot;30 줄짜리 일회성 SQL 스크립트&quot; 를 L4 로 만드는 건 <strong>과투자</strong> 입니다. L4 가 빠르다는 것과 &quot;모든 배치를 L4 로 작성해야 한다&quot; 는 완전히 다른 이야기죠. 배치 &quot;작성 비용&quot; 과 &quot;실행 비용&quot; 은 다른 축입니다.</p>
<h3 id="중간-정리-1">중간 정리</h3>
<p>Part 1 이 &quot;성능의 1차 축은 IO 왕복&quot; 이었다면, Part 2 의 메시지는 이겁니다.</p>
<blockquote>
<p><strong>예상한 트레이드오프(&quot;Spring Batch = 느리지만 운영 좋음&quot;)가 꼭 참이 아닐 수 있다. 측정은 종종 사전 믿음을 뒤집는다. 대신 &#39;작성 비용&#39; 이라는 다른 축을 들여와야 한다.</strong></p>
</blockquote>
<hr>
<h2 id="part-3-병렬화의-roi--스레드-4개가-4배를-만들어주지-않는-이유">Part 3. 병렬화의 ROI — 스레드 4개가 4배를 만들어주지 않는 이유</h2>
<p>이제 글의 가장 처음에 던졌던 통념으로 돌아갑니다.</p>
<blockquote>
<p>&quot;병렬로 돌리면 더 빨라진다.&quot;</p>
</blockquote>
<p>이 말 자체는 맞습니다. 다만 &quot;더 빨라진다&quot; 가 <strong>얼마나</strong>, 그리고 <strong>어디부터 안 빨라지는지</strong> 가 문제입니다.</p>
<p>L5 Spring Batch Partitioning 과 L7 ForkJoinPool 모두 4 스레드를 씁니다. 이론상 단일 스레드 L4 대비 4 배 가까이 빨라야 할 것 같습니다. 실제 수치는 정반대였습니다.</p>
<table>
<thead>
<tr>
<th>Level</th>
<th align="center">스레드</th>
<th align="right">Elapsed (ms, 100K)</th>
<th align="right">TPS</th>
<th align="right">L4 대비</th>
</tr>
</thead>
<tbody><tr>
<td><strong>L4</strong> Spring Batch (단일)</td>
<td align="center">1</td>
<td align="right"><strong>350</strong></td>
<td align="right">289,017</td>
<td align="right">1.00× (기준)</td>
</tr>
<tr>
<td><strong>L7</strong> ForkJoinPool</td>
<td align="center">4</td>
<td align="right">1,750</td>
<td align="right">57,274</td>
<td align="right"><strong>5 배 느림</strong></td>
</tr>
<tr>
<td><strong>L5</strong> Spring Batch Partitioning</td>
<td align="center">4</td>
<td align="right">30,530</td>
<td align="right">3,276</td>
<td align="right"><strong>87 배 느림</strong></td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/4427c204-20c3-4da0-96d1-ef4efde281c8/image.png" alt=""></p>
<p>두 가지 반전이 있습니다.</p>
<ol>
<li><strong>4 스레드 L5 가 1 스레드 L4 보다 87 배 느리다.</strong> 병렬화가 오히려 독이 됐습니다.</li>
<li><strong>같은 4 스레드 L7 이 L5 보다 17 배 빠르다.</strong> 동일한 병렬 스레드 수에서도 전략에 따라 결과가 극명하게 갈립니다.</li>
</ol>
<h3 id="왜-l5-partitioning-이-역주행했는가">왜 L5 Partitioning 이 역주행했는가</h3>
<p>L5 Spring Batch Partitioning 의 구조부터 보겠습니다.</p>
<ul>
<li>데이터를 4 조각으로 나눈 뒤, 각 파티션이 <strong>자기만의 Step + 트랜잭션 + ItemWriter</strong> 를 갖고 독립 실행됩니다.</li>
<li>실패 시 해당 파티션만 재시작됩니다.</li>
</ul>
<p>좋게 들리지만, 이 &quot;독립성&quot; 이 비용입니다.</p>
<ul>
<li><strong>Connection Pool 경합</strong>: 4 스레드 × 각자의 트랜잭션 → HikariCP 에서 4 개의 커넥션 점유.</li>
<li><strong>DB Lock 경합</strong>: 같은 <code>orders</code> 테이블에 4 스레드가 동시에 <code>INSERT</code> → AUTO_INCREMENT lock, redo log 내부 직렬화(<code>sync_binlog=0</code> 으로도 완전히 제거되진 않음).</li>
<li><strong>트랜잭션 오버헤드 × 4</strong>: L4 의 단일 chunk 가 1 번만 하던 <code>BEGIN</code>/<code>COMMIT</code> 을 L5 는 파티션 별로 여러 번 반복.</li>
<li><strong>Job 메타 테이블 쓰기 경합</strong>: <code>BATCH_STEP_EXECUTION</code> 에도 4 파티션이 동시에 upsert.</li>
</ul>
<p>결과는 &quot;4 배 빨라지는 대신 4 배 느려지는&quot; 구간이 만들어졌습니다. 87 배까지 악화된 건 이 여러 경합 요소가 곱해졌기 때문입니다.</p>
<h3 id="l7-forkjoinpool-이-더-나았던-이유">L7 ForkJoinPool 이 더 나았던 이유</h3>
<p>L7 은 같은 4 스레드지만 훨씬 단순합니다.</p>
<ul>
<li>공통 <code>JdbcTemplate</code> 하나로 partition 별 <code>batchUpdate</code></li>
<li>각 스레드가 chunk size 1000 으로 INSERT 를 보냄</li>
<li>Spring Batch 의 Job 메타, ItemReader / Processor / Writer 구조 <strong>없음</strong></li>
</ul>
<p>즉 L7 은 &quot;병렬 실행을 얹은 L1&quot; 에 가깝고, L5 는 &quot;병렬 실행 + 독립 관리&quot; 를 모두 얹은 구조입니다. 이 환경에서는 <strong>&quot;덜 덮은&quot; L7 쪽이 17 배 빨랐습니다.</strong> 관리 레이어가 많을수록 경합도 많아졌다는 뜻입니다.</p>
<p>물론 L7 에는 L5 가 제공하는 <strong>재시작 세분화</strong> 가 없습니다. L7 은 중간에 한 스레드가 실패하면 전체가 실패합니다. 이게 L5 의 존재 이유이고, 그 값을 치르는 구간이 이 측정에서 확인된 87 배의 격차입니다.</p>
<h3 id="중간-정리-2">중간 정리</h3>
<blockquote>
<p><strong>병렬화는 선형 가속이 아니라 때로 역가속입니다. 관리 레이어가 두꺼워질수록 경합이 늘어나고, &quot;4 스레드&quot; 라는 같은 조건도 구현 전략에 따라 17 배 차이가 납니다. 선택의 기준은 &quot;스레드 수&quot; 가 아니라 &quot;무엇을 동시 자원에 접근시키는가&quot; 입니다.</strong></p>
</blockquote>
<hr>
<h2 id="part-4-스트리밍의-세계--처리량은-잊고-지연을-보세요">Part 4. 스트리밍의 세계 — 처리량은 잊고 &#39;지연&#39;을 보세요</h2>
<p>Part 1~3 까지는 같은 질문을 반복해서 던졌습니다.</p>
<blockquote>
<p>&quot;100만 건을 어떻게 빨리 넣을 것인가?&quot;</p>
</blockquote>
<p>그런데 L6 Kafka 에 오면 이 질문 자체가 힘을 잃습니다. Kafka 에서 &quot;100만 건을 몇 분에 넣었는가&quot; 는 여전히 측정할 수 있지만, 그게 의미하는 바가 완전히 다릅니다.</p>
<h3 id="배치가-아니라-흐름이다">배치가 아니라 &#39;흐름&#39;이다</h3>
<p>L0~L5 는 &quot;데이터 덩어리를 한꺼번에 처리하는&quot; 모델입니다. 시작과 끝이 있습니다. 반면 Kafka 파이프라인은 <strong>시작과 끝이 없는 영속 흐름</strong> 입니다. Producer 는 쉬지 않고 이벤트를 밀어 넣고, Consumer 는 자기 속도로 당겨서 처리합니다.</p>
<p>측정값을 봅시다.</p>
<table>
<thead>
<tr>
<th>Level</th>
<th align="right">Elapsed (ms, 100K)</th>
<th align="right">TPS</th>
<th>특이사항</th>
</tr>
</thead>
<tbody><tr>
<td>L1 JdbcTemplate</td>
<td align="right">5,830</td>
<td align="right">17,156</td>
<td>producer 없음, DB 직접 적재</td>
</tr>
<tr>
<td>L6 Kafka-Driven</td>
<td align="right"><strong>301,390</strong></td>
<td align="right"><strong>332</strong></td>
<td>producer + consumer 완료 대기 300s timeout 도달</td>
</tr>
</tbody></table>
<p>(L6 은 3 회 중 1 회만 정상 집계됐습니다. 나머지 2 회는 Report 표 자체에 레벨이 누락됐고 — 실제 운영 Kafka 파이프라인이 &quot;완료&quot; 라는 개념을 벤치마크 하듯 기다리기 어렵다는 것을 측정 실패 그 자체로 보여주는 셈입니다.)</p>
<p>이 벤치마크는 producer 가 이벤트를 다 보낸 뒤 consumer 가 모두 처리할 때까지 기다립니다(<code>consumer.awaitCompletion(...)</code>). 즉 여기 Elapsed 에는 <strong>네트워크 전송 + Kafka brokering + consumer DB 적재</strong> 가 모두 포함됩니다. 이게 뜻하는 바는 L1 의 &quot;DB 에 직접 INSERT 하는 시간&quot; 과는 <strong>비교 대상 자체가 다르다</strong> 는 것입니다.</p>
<p>운영 환경의 Kafka 파이프라인은 이렇게 &quot;다 끝날 때까지 기다리지 않습니다.&quot; Producer 는 계속 쏘고, Consumer 는 자기 속도로 당겨서 처리합니다. Producer 쪽에서 본 &#39;보내는 속도&#39; 와 Consumer 쪽에서 본 &#39;반영되는 속도&#39; 사이의 격차를 <strong>consumer lag</strong> 이라고 부릅니다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/7036a963-2936-46ac-b0a5-d25214aefafa/image.png" alt=""></p>
<h3 id="처리량과-적시성의-분리">처리량과 적시성의 분리</h3>
<p>Kafka 에서는 처리량(throughput)과 적시성(latency)이 <strong>별개의 축</strong> 으로 분리됩니다.</p>
<ul>
<li><strong>처리량</strong>: 초당 몇 건을 producer 가 보낼 수 있는가 → 충분히 높음</li>
<li><strong>적시성</strong>: 보낸 이벤트가 소비자 쪽에서 몇 초 안에 반영되는가 → <strong>lag 으로 결정</strong></li>
</ul>
<p>L1~L5 는 &quot;적시성&quot; 이라는 축이 없었습니다. 배치가 끝나면 데이터가 거기 있고, 끝나기 전까지는 아무것도 없습니다. 실시간성은 필요 없었고, 있다면 그건 스케줄러 간격(L3)의 문제였습니다.</p>
<p>Kafka 에서는 이 축이 생겨납니다. &quot;100만 건을 30초에 producer 가 다 보냈다&quot; 는 한 줄은 consumer 가 그 30초 안에 모두 처리했다는 뜻이 아닙니다. consumer 가 느리면 lag 이 쌓이고, lag 이 쌓이면 데이터는 &quot;있지만 아직 반영되지 않은&quot; 상태가 됩니다.</p>
<h3 id="중간-정리-3">중간 정리</h3>
<blockquote>
<p><strong>실시간성은 성능 축과 직교합니다. Kafka 의 성공 기준은 &#39;얼마나 빨리 보내나&#39; 가 아니라 &#39;얼마나 lag 을 일정하게 유지하는가&#39; 입니다.</strong></p>
</blockquote>
<hr>
<h2 id="part-5-결론--배치는-동사가-아니라-문장부호다">Part 5. 결론 — 배치는 동사가 아니라 문장부호다</h2>
<p>글머리의 질문으로 돌아가 봅니다.</p>
<blockquote>
<p>&quot;가장 빠른 배치 기술은 무엇인가요?&quot;</p>
</blockquote>
<p>이 글을 통해 제가 내린 답은 이렇습니다.</p>
<blockquote>
<p><strong>&quot;질문이 틀렸습니다.&quot;</strong></p>
</blockquote>
<p>이 결론이 수사(rhetoric)가 아닌 이유를 Part 1~4 의 수치가 이미 증명했습니다.</p>
<ul>
<li><strong>Part 1</strong>: 성능의 1차 변수는 스레드 수가 아니라 <strong>IO 왕복 횟수</strong>였습니다. L2 JPA 가 L1 JDBC 대비 44 배 느렸던 것도 왕복이 아니라 1 차 캐시 관리 비용이었죠. 그리고 이 비용은 <strong>규모에 비선형</strong> 이라, 1M 측정에서는 15 분이 지나도 끝나지 않아 실패 자체가 측정값이 됐습니다.</li>
<li><strong>Part 2</strong>: 제가 예상했던 &quot;Spring Batch = 느리지만 운영 좋음&quot; 트레이드오프가 뒤집혔습니다. <strong>L4 Spring Batch Chunk 가 L1 보다 17 배 빠르면서 재시작까지 제공</strong> 했고, 대신 &quot;작성 비용&quot; 이라는 다른 축이 등장했습니다.</li>
<li><strong>Part 3</strong>: 같은 4 스레드인데도 L5 Partitioning 이 L7 ForkJoin 보다 17 배 느렸습니다. 스레드 수가 아니라 <strong>관리 레이어의 두께</strong> 가 성능을 결정했습니다.</li>
<li><strong>Part 4</strong>: Kafka 에서는 처리량(throughput)과 <strong>적시성(latency)이 독립된 축</strong> 으로 분리됐고, &quot;다 끝날 때까지 기다리는&quot; 벤치마크 자체가 의미를 잃었습니다.</li>
</ul>
<p>즉 &quot;빠름&quot; 하나만 놓고 순위를 매기는 것은 질문 자체가 좁습니다. 실제 배치 기술 선택은 다음 <strong>두 축의 함수</strong>입니다.</p>
<ol>
<li><strong>데이터 특성</strong>: 규모, 1회성/주기성, 실시간성</li>
<li><strong>실패 허용도</strong>: 재시작 필요한가, Skip/Retry 필요한가, lag 제어 필요한가</li>
</ol>
<p>이 두 축을 시나리오로 풀어보면 다음 표가 나옵니다.</p>
<table>
<thead>
<tr>
<th>시나리오</th>
<th>후보</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><strong>일회성 데이터 마이그레이션</strong> (수백만 건 적재, 30 줄짜리 스크립트로 충분)</td>
<td><strong>L1</strong> <code>JdbcTemplate.batchUpdate</code></td>
<td>L4 가 빠르지만 Reader / Writer 구조를 세우는 작성 비용이 과투자</td>
</tr>
<tr>
<td><strong>재시작·Skip·Retry 가 필요한 상시 ETL</strong></td>
<td><strong>L4</strong> Spring Batch Chunk</td>
<td>이 측정에서 가장 빠른 동시에 <code>BATCH_JOB_EXECUTION</code> 으로 재시작 제공. &quot;빠름 + 운영성&quot; 둘 다</td>
</tr>
<tr>
<td><strong>대용량 + 파티션 병렬 + 부분 재시작</strong></td>
<td><strong>L5</strong> Spring Batch Partitioning</td>
<td>같은 4 스레드 대비 느리지만, &quot;실패한 파티션만 재시작&quot; 이 가능한 유일한 선택</td>
</tr>
<tr>
<td><strong>IO 적고 CPU 큰 병렬 변환</strong></td>
<td><strong>L7</strong> Parallel ForkJoin</td>
<td>단순 병렬, 재시작 세분화가 필요 없을 때 L5 보다 17 배 빠름</td>
</tr>
<tr>
<td><strong>실시간 이벤트 파이프</strong></td>
<td><strong>L6</strong> Kafka</td>
<td>throughput 이 아니라 <strong>consumer lag</strong> 으로 설계해야 하는 영역</td>
</tr>
<tr>
<td><strong>단순 주기 실행</strong> (내부 알림, 리포트 생성)</td>
<td><strong>L3</strong> <code>@Scheduled</code></td>
<td>오버헤드 최소, 실패 허용 가능</td>
</tr>
</tbody></table>
<h3 id="네-개의-축으로-보면-선택이-더-명확해진다">네 개의 축으로 보면 선택이 더 명확해진다</h3>
<p>흥미롭게도 이 벤치마크 프로젝트의 HTML 리포트는 결과를 단순 막대 차트로만 그리지 않고 <strong>레이더 차트 4축</strong> 으로도 보여줍니다.</p>
<ul>
<li><strong>Speed</strong> (빠름)</li>
<li><strong>Memory</strong> (메모리 효율)</li>
<li><strong>Simplicity</strong> (작성/유지보수의 단순함)</li>
<li><strong>Scalability</strong> (수평 확장 가능성)</li>
</ul>
<p><code>reports/benchmark-result.html</code> 의 소스를 열어보면, 레벨별 Simplicity / Scalability 점수가 코드에 하드코딩되어 있습니다. L0 은 Simplicity 5 / Scalability 1, L7 은 Simplicity 1 / Scalability 5. Speed 와 Memory 는 실측 기반이고요. <strong>저자가 의도한 메시지가 이미 &quot;빠름 하나로 줄 세우지 말라&quot; 였던 셈</strong> 입니다.</p>
<p>Part 1~4 가 보여준 건 이 4 축이 <strong>서로 독립적으로 움직인다</strong> 는 것이었습니다.</p>
<ul>
<li>L1 은 Simplicity 최고(2~3 줄이면 끝), Speed 중, Scalability 낮음.</li>
<li>L4 는 Speed 의외로 최고, 하지만 Simplicity 는 Reader / Writer 구조를 짜야 해서 낮음.</li>
<li>L5 는 Scalability 를 얻는 대신 Speed 를 크게 잃었습니다.</li>
<li>L7 은 Speed / Scalability 모두 좋지만 재시작 / Skip / Retry 는 포기했습니다.</li>
</ul>
<p>즉 시나리오 매트릭스 표가 주는 답은 &quot;조건에 따라 최고의 꼭짓점이 어디에 찍히는지&quot; 입니다. 4 축 중 어느 하나에 과도하게 무게를 싣는 순간, 다른 축의 비용을 치르게 됩니다.</p>
<h3 id="배치는-문장부호다">배치는 문장부호다</h3>
<p>쓰기 시작하면서 저는 이 비교를 &quot;누가 제일 빠른가&quot; 레이스로 시작했습니다. 데이터를 보고 나니 관점이 뒤집혔습니다.</p>
<p><strong>배치는 동사가 아니라 문장부호입니다.</strong></p>
<p>같은 &quot;마침표&quot; 라도 소설의 엔딩과 메모의 줄바꿈에서 의미가 다르듯, 같은 <code>INSERT</code> 라도 재시작 필요한 ETL 과 일회성 마이그레이션에서 필요한 도구가 다릅니다. 기술을 고르기 전에, 먼저 물어야 할 것은 &quot;이 데이터가 어떤 문장 속에 있는가&quot; 입니다.</p>
<hr>
<h2 id="참고">참고...!</h2>
<h3 id="1-측정-환경">1. 측정 환경</h3>
<ul>
<li>Windows 11, Docker Desktop</li>
<li>MySQL 8.0.45 (container), Apache Kafka 3.7.0 (KRaft, container)</li>
<li>JDK 21, Spring Boot 3.4.4</li>
<li>JVM: <code>-Xmx2g -XX:+UseG1GC</code></li>
<li>측정 주체: 같은 로컬 머신에서 warm-up 1회 버리고 본실행 3회의 <strong>중앙값(median)</strong> 채택</li>
<li>모든 수치는 단일 환경 전제이므로 <strong>±10% 이상의 오차 범위</strong>로 읽어주세요</li>
</ul>
<h3 id="2-재현-방법">2. 재현 방법</h3>
<pre><code class="language-bash">git clone https://github.com/.../batch-benchmark-java
cd batch-benchmark-java
docker-compose -f docker/infra-compose.yml up -d
./gradlew :runner:bootRun --args=&quot;--size=100000&quot;</code></pre>
<p>측정 결과는 <code>reports/benchmark-result.html</code> (막대 + 레이더 차트) 과 <code>reports/measurements.md</code> (TSV 표) 에 남습니다. HTML 리포트는 Chart.js 기반이라 각 레벨 이름을 클릭해 정렬·필터링이 됩니다. <strong>레이더 차트</strong> 는 Speed / Memory / Simplicity / Scalability 4 축을 한 장에 겹쳐 보여주므로, 수치 하나로 줄을 세우지 않고 &quot;어떤 축을 양보할 수 있는가&quot; 를 시각적으로 가늠하기 좋습니다.</p>
<p><strong>참고</strong>: <code>--size=1000000</code> 으로 실행하면 L2 JPA 에서 15 분이 지나도 Report 가 찍히지 않는 현상을 저는 만났습니다. 이 글에서 1M 수치를 싣지 못한 이유입니다 (Part 1 &quot;실패담&quot; 참고). 1M 측정을 시도하려면 L2/L6 을 제외하거나, Hibernate 영속성 컨텍스트 분할 전략을 별도로 설계해야 합니다.</p>
<h3 id="3-commit-per-row-안티패턴-확인">3. commit-per-row 안티패턴 확인</h3>
<p>이 비교를 위해 저장소에 추가한 <strong>L-1</strong> 은 <code>level0-raw-jdbc/.../RawJdbcNoBatchBenchmark.java</code> 에 있으며, <code>with-nobatch</code> Spring 프로파일로만 활성화됩니다.</p>
<pre><code class="language-bash">./gradlew :runner:bootRun --args=&quot;--size=10000&quot; \
  -Dspring.profiles.active=with-nobatch</code></pre>
<p>실측 시간이 너무 길어지므로 내부에서 <strong>항상 상위 3,000 건만 실제로 넣고 결과를 선형 외삽</strong> 합니다 (SAMPLE_SIZE 상수로 조정). 이 값은 엄밀한 측정이 아니라 <strong>&quot;단건 commit 이 10~100 배 더 느리다&quot;</strong> 는 크기감을 확인하기 위한 추정값으로만 해석해 주세요.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[기술을 새로운 시각으로 바라보다]]></title>
            <link>https://velog.io/@praesentia-ykm/%EA%B8%B0%EC%88%A0%EC%9D%84-%EC%83%88%EB%A1%9C%EC%9A%B4-%EC%8B%9C%EA%B0%81%EC%9C%BC%EB%A1%9C-%EB%B0%94%EB%9D%BC%EB%B3%B4%EB%8B%A4</link>
            <guid>https://velog.io/@praesentia-ykm/%EA%B8%B0%EC%88%A0%EC%9D%84-%EC%83%88%EB%A1%9C%EC%9A%B4-%EC%8B%9C%EA%B0%81%EC%9C%BC%EB%A1%9C-%EB%B0%94%EB%9D%BC%EB%B3%B4%EB%8B%A4</guid>
            <pubDate>Fri, 17 Apr 2026 01:49:41 GMT</pubDate>
            <description><![CDATA[<h2 id="트레이드오프의-10주를-되돌아보며">트레이드오프의 10주를 되돌아보며...</h2>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/f61d0e3d-25dd-4d3c-9196-c5ece5661929/image.png" alt=""></p>
<blockquote>
<p><strong>TL;DR</strong></p>
<p>10주 동안 TDD, 도메인 설계, 락, 인덱스, 캐시, Circuit Breaker, 이벤트, Kafka, 대기열, Redis ZSET, Spring Batch를 얕게 겪어봤다. 처음엔 각자 다른 도구라고 생각했는데, 돌아보니 대체로 트레이드오프의 다른 이름들 아니었나 싶기도 하다. 어떤 기술은 정확성을 얻으려고 처리량을 내준 듯했고, 어떤 기술은 가용성을 얻으려고 복잡도를 떠안은 듯했다. 이 글은 각 기술이 왜 태어났는지, 무엇을 포기하고 무엇을 얻는 설계로 보이는지를 나만의 시각으로 표현해보려고 한다.</p>
</blockquote>
<hr>
<h2 id="prologue--공짜로-얻은-기술은-거의-없는-듯하다">Prologue — 공짜로 얻은 기술은 거의 없는 듯하다</h2>
<p>락을 걸어보고, 이벤트로 빼보고, Kafka를 올려보고, 배치로 돌려봤다. 처음엔 각자 다른 10개의 기술을 배우는 10주라고만 생각했다.</p>
<p>이 글을 쓰려고 거슬러 올라가 보니 어떤 패턴 비슷한 게 희미하게 보이는 것 같기도 했다. 내가 배운 기술들이 대체로 어떤 교환의 결과가 아닐까, 싶은 정도다.</p>
<p>비관적 락은 정확성을 사려고 처리량을 내놓는 쪽으로 보였다. 인덱스는 읽기 속도를 사려고 쓰기 비용과 저장 공간을 내주는 듯했고, Circuit Breaker는 빠른 실패를 사려고 일부 정상 요청의 가용성을 내주는 것 같았다. Kafka는 분산 가용성을 사려고 파이프라인 설계 복잡도를 감수하는 쪽이고, 비정규화는 조회 속도를 얻는 대신 정합성 관리 포인트를 늘리는 셈이었다. Redis ZSET은 O(log N) 랭킹을 사면서 Strong Consistency는 일정 부분 포기하는 선택으로 보였다.</p>
<p>기술의 이름을 외우는 것과, 그 기술이 <strong>무엇을 포기하고 무엇을 얻는 설계인지</strong>를 가늠해보는 건 조금 다른 일이 아닐까 싶었다. 그리고 기술의 탄생 배경을 따라가다 보니, 선택의 기준이 기술 안쪽보다는 기술 바깥쪽에 있는 경우가 많은 듯했다 — 물론 내가 본 좁은 사례들 안에서의 이야기다.</p>
<p>앞으로는 이렇게 자문해보려 한다.</p>
<ul>
<li>비즈니스가 어떤 실패를 감내할 수 있을까?</li>
<li>어떤 지연을 받아들일 수 있을까?</li>
<li>어떤 오차를 용인할 수 있을까?</li>
</ul>
<p>자! 이제 각 주차별로(W1~W10) 기술을 다뤄보며 느낀 나만의 시각을 아래 구조에 맞춰 풀어내어 가보려고 한다.</p>
<ul>
<li>왜 이 기술이 태어났는가?</li>
<li>트레이드오프는 무엇인가?</li>
<li>내가 마주한 문제</li>
<li>내가 느낀 교훈</li>
</ul>
<hr>
<h2 id="1-tdd--테스트가-설계를-먼저-강제하는-듯하다-w1">1. TDD — 테스트가 설계를 먼저 강제하는 듯하다 (W1)</h2>
<h3 id="왜-태어났는가">왜 태어났는가</h3>
<p>TDD는 &quot;코드를 먼저 쓰고 테스트로 확인&quot;하는 관행이 낳은 문제에서 출발한 것으로 알려져 있다. 구현이 이미 있는 상태에서 테스트를 쓰면 테스트가 구현의 사본이 되기 쉽고, 구현이 잘못되어도 같은 방향으로 잘못 검증할 위험이 있다고 한다. Kent Beck은 &quot;Red → Green → Refactor&quot; 순환으로 순서를 뒤집었다고 한다. 테스트가 요구사항의 명세로 먼저 서면, 구현은 그 명세를 만족시키는 일에 가까워진다고 보는 듯하다.</p>
<p>AI Agent와 함께 일할 때는 이 순서의 의미가 더 커지는 느낌이었다. AI는 &quot;그럴듯한 코드&quot;를 빠르게 내놓고, 검증 없이 받아쓰면 구현이 먼저 쌓이고 테스트는 사후 정당화가 되기 쉬운 것 같다. TDD는 AI를 협력자에서 <strong>검증해야 할 대상</strong>으로 돌려놓는 안전장치 비슷한 역할을 해주는 듯했다.</p>
<h3 id="트레이드오프">트레이드오프</h3>
<table>
<thead>
<tr>
<th>접근</th>
<th>얻는 것</th>
<th>내는 것</th>
</tr>
</thead>
<tbody><tr>
<td>구현 먼저</td>
<td>초기 속도</td>
<td>구현-테스트 동기화, 회귀 버그 방어 어려움</td>
</tr>
<tr>
<td>TDD (Red → Green → Refactor)</td>
<td>요구사항 명시성, 리팩토링 자신감</td>
<td>초반 속도, 설계 갈아엎기 비용</td>
</tr>
<tr>
<td>AI 코드 받아쓰기</td>
<td>생산성</td>
<td>이해 없는 코드, 은밀한 버그</td>
</tr>
<tr>
<td>AI + TDD</td>
<td>속도와 검증을 동시에</td>
<td>실패할 테스트를 먼저 쓰는 규율</td>
</tr>
</tbody></table>
<h3 id="내가-마주한-자리">내가 마주한 자리</h3>
<p>전체 도메인을 TDD로 쌓아올려 봤다. Brand, Product, Stock, Like, Order. 각 도메인마다 실패 테스트 → 최소 구현 → 리팩토링 사이클을 돌려봤다.</p>
<pre><code class="language-java">// Red: 실패하는 테스트 먼저
@Test
void 주문_수량이_재고보다_많으면_예외가_발생한다() {
    StockModel stock = new StockModel(productId, 5);
    assertThatThrownBy(() -&gt; stock.decrease(10))
        .isInstanceOf(CoreException.class)
        .hasMessageContaining(&quot;재고가 부족합니다&quot;);
}

// Green: 최소 구현
public void decrease(int amount) {
    if (this.quantity &lt; amount) {
        throw new CoreException(ErrorType.BAD_REQUEST, &quot;재고가 부족합니다.&quot;);
    }
    this.quantity -= amount;
}</code></pre>
<p>테스트가 먼저 있으면 &quot;이 도메인이 외부에 무엇을 제공하는지&quot;, &quot;어떤 불변식을 스스로 지켜야 하는지&quot;를 먼저 정의하게 되는 듯했다. 이 명세가 엔티티의 행위 메서드를 설계하는 기준이 되어주는 것 같았고, 다음 주차에서 &quot;의사결정은 엔티티에, 조율은 Service에&quot;라는 구분의 출발점이 되어준 것 같기도 했다.</p>
<p>AI Agent와 함께 일할 때 TDD가 특히 도움이 된다고 어렴풋이 느꼈다. AI가 내놓은 구현이 맞는지 테스트 코드로 검증할 수 있는 여지가 생기는 듯했다. 테스트가 없으면 AI의 답을 믿게 되고, 믿기 시작하면 검증이 느슨해지기 쉬웠다. TDD는 AI의 속도와 내 이해의 균형을 맞춰주는 장치 비슷한 역할을 해주는 것 같았다.</p>
<h3 id="교훈">교훈</h3>
<p>TDD를 &quot;테스트 쓰는 기법&quot;이라기보다 <strong>설계를 강제하는 순서</strong>에 가까운 무언가로 봐도 될까, 라는 감각이 어렴풋이 남았다. 테스트가 먼저 있으면 엔티티의 책임을 먼저 정의하게 되고, 코드가 먼저 있으면 &quot;이미 있는 것의 설명&quot;을 쓰게 되기 쉬운 것 같다. 이 순서의 차이가 이후 설계 판단의 토대가 되어주지 않을까 라는 생각이 들었다.</p>
<hr>
<h2 id="2-도메인-설계--로직을-어디에-둘-것인가-w3">2. 도메인 설계 — 로직을 어디에 둘 것인가 (W3)</h2>
<h3 id="왜-태어났는가-1">왜 태어났는가</h3>
<p>DDD(Domain-Driven Design)는 &quot;복잡한 비즈니스 로직을 어디에 둘 것인가&quot;라는 질문에서 출발한 것으로 알려져 있다. 초기 엔터프라이즈 코드에서는 데이터와 행위를 분리해 엔티티는 getter/setter만 가진 데이터 봉투처럼 쓰고, 로직은 Service에 모으는 패턴이 흔했다고 한다. Martin Fowler가 이걸 &quot;Anemic Domain Model(빈약한 도메인 모델)&quot;이라 부르며 비판한 이유는 비교적 단순해 보인다 — 로직이 엔티티 밖에 있으면 같은 규칙이 여러 Service에 흩어지기 쉽고, 엔티티가 자기 일관성을 스스로 지키기 어려워지는 듯하다.</p>
<p>DDD는 로직을 엔티티와 VO 안으로 다시 끌어들여서, &quot;이 데이터의 불변식은 데이터 자신이 지킨다&quot;는 원칙을 세우는 시도로 이해해보고 있다.</p>
<h3 id="트레이드오프-1">트레이드오프</h3>
<table>
<thead>
<tr>
<th>접근</th>
<th>얻는 것</th>
<th>내는 것</th>
</tr>
</thead>
<tbody><tr>
<td>Anemic Domain Model</td>
<td>레이어 구조 단순</td>
<td>로직 파편화, 엔티티 불변식 보장 불가</td>
</tr>
<tr>
<td>Rich Domain Model (DDD)</td>
<td>엔티티가 자기 규칙 캡슐화</td>
<td>엔티티 클래스 무게 증가, 학습 곡선</td>
</tr>
</tbody></table>
<h3 id="내가-마주한-자리-1">내가 마주한 자리</h3>
<p>처음 세운 기준은 단순했다. 규칙이면 <code>domain/</code>, 절차면 <code>application/</code>. &quot;같은 이름의 브랜드는 등록할 수 없다&quot;는 영업부도 아는 규칙이니까 <code>BrandService</code>를 <code>domain/</code>에 넣어봤다.</p>
<p>리팩토링하면서 다시 봤을 때 이 기준이 좀 헐겁게 느껴졌다. 같은 &quot;규칙&quot;이라도 두 종류로 나눠볼 수 있을 것 같았다.</p>
<pre><code class="language-java">public BrandModel register(String name, String description) {
    BrandName brandName = new BrandName(name);                  // ① VO가 스스로 유효성 판단
    brandRepository.findByName(name).ifPresent(existing -&gt; {    // ② DB 상태 확인 후 거부
        throw new CoreException(ErrorType.CONFLICT);
    });
    return brandRepository.save(new BrandModel(brandName, description));
}</code></pre>
<p>①은 <code>BrandName</code> VO가 스스로 판단하는 자리라 의사결정에 가까워 보였다. ②는 Repository를 호출해 DB 상태를 확인하고 거부하는 자리라 조율에 가까워 보였다. 이 구분이 있어야 &quot;도메인 서비스 vs 애플리케이션 서비스&quot;의 경계가 조금 더 또렷해지는 게 아닐까 싶었다.</p>
<h3 id="교훈-1">교훈</h3>
<p>&quot;로직을 엔티티로 끌고 온다&quot;는 말이 모든 로직을 엔티티 안에 쑤셔 넣으라는 뜻은 아닌 듯하다. <strong>의사결정은 엔티티/VO에, 조율은 Service에</strong> 두는 것이 Rich Domain Model이 원래 의도한 배치에 가깝지 않을까, 라고 가늠해보게 되었다.</p>
<blockquote>
<p><em>위 코드는 이해를 돕기 위해 약간 단순화되었습니다.</em></p>
</blockquote>
<hr>
<h2 id="3-락--정확성과-처리량의-교환-w4">3. 락 — 정확성과 처리량의 교환 (W4)</h2>
<h3 id="왜-태어났는가-2">왜 태어났는가</h3>
<p>데이터베이스 락은 동시 접근 시 일관성을 지키기 위해 도입된 것으로 알려져 있다. 가장 흔한 시나리오 중 하나가 <strong>Lost Update</strong> — 두 트랜잭션이 같은 행을 각각 읽고 각각 수정하면, 늦게 커밋되는 쪽이 먼저 커밋된 변경을 덮어쓰는 상황이다. 재고가 1개 남았는데 두 주문이 동시에 <code>quantity - 1</code>을 계산하면 실제로는 2건이 팔려나갈 수 있다고 한다.</p>
<p>락은 이 문제를 &quot;한 번에 하나씩만 수정하게&quot; 강제해서 풀어주는 듯하다. 다만 공짜는 아닌 것 같다.</p>
<h3 id="트레이드오프-2">트레이드오프</h3>
<table>
<thead>
<tr>
<th>전략</th>
<th>얻는 것</th>
<th>내는 것</th>
</tr>
</thead>
<tbody><tr>
<td>비관적 락</td>
<td>정확성 (Lost Update 원천 차단)</td>
<td>처리량 (다른 트랜잭션 대기)</td>
</tr>
<tr>
<td>낙관적 락 (<code>@Version</code>)</td>
<td>처리량 (대기 없음)</td>
<td>충돌 시 재시도 비용</td>
</tr>
<tr>
<td>원자적 UPDATE (<code>SET x = x + 1</code>)</td>
<td>대기 최소 + 재시도 불필요</td>
<td>단일 SQL 문에 로직 제한</td>
</tr>
<tr>
<td>DB 유니크 제약</td>
<td>최종 방어선 (인스턴스 간 경합도 방어)</td>
<td>실패가 예외로 튀는 UX</td>
</tr>
</tbody></table>
<p>무엇을 고를 것인가의 기준은 결국 &quot;<strong>이 데이터가 틀리면 얼마나 아픈가</strong>&quot; 쪽이 아닐까 싶었다.</p>
<h3 id="내가-마주한-자리-2">내가 마주한 자리</h3>
<p><strong>재고 차감</strong> — 틀리면 품절 상품이 팔려나가는 셈이라 정확성을 우선하는 게 맞겠다 싶어 <code>@Lock(PESSIMISTIC_WRITE)</code>로 가봤다.</p>
<p><strong>쿠폰 발급</strong> — 틀리면 선착순 정책 위반이 되니 정확성 우선이라고 봤다. 비관적 락에 DB 유니크 제약을 겹쳐 최종 방어선까지 깔아뒀다. 애플리케이션 락만으로는 인스턴스 간 경합을 막기 어렵다고 판단했다.</p>
<pre><code class="language-java">@Table(name = &quot;coupon_issue&quot;, uniqueConstraints = {
    @UniqueConstraint(name = &quot;uk_coupon_issue_user_coupon&quot;,
                      columnNames = {&quot;user_id&quot;, &quot;coupon_id&quot;})
})</code></pre>
<p><strong>좋아요 집계</strong> — 처음엔 낙관적 락으로 시도해봤는데 인기 상품에서 재시도가 10번씩 터지는 걸 봤다. 처리량이 더 중요하겠다고 판단해 <code>@Modifying</code> UPDATE 한 방으로 바꿨다.</p>
<pre><code class="language-java">@Modifying
@Query(&quot;UPDATE ProductModel p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id&quot;)
int incrementLikeCount(@Param(&quot;id&quot;) Long id);</code></pre>
<p>DB 엔진이 행에 짧게 락을 걸고 <code>+1</code>을 수행하는 단일 문장이라 순차 처리가 보장되는 듯했고, 낙관적 락처럼 버전 충돌로 재시도할 일도 줄어드는 것 같았다. <code>like_count</code>가 어쩌다 1 정도 틀려도 유저가 눈치채기는 어려울 것 같았다.</p>
<h3 id="교훈-2">교훈</h3>
<p>&quot;동시성 문제에는 락을 걸어라&quot;는 답은 조금 빈약하게 다가왔다. 락은 출발점이라기보다 결과에 가까운 게 아닐까, 싶었다. 출발점은 <strong>정확성과 처리량 중 어느 쪽이 이 데이터에 더 비싼가</strong>이고, 그 답에 따라 비관/낙관/원자 UPDATE/유니크 제약 중 하나가 자연스럽게 따라붙는 것 같았다.</p>
<hr>
<h2 id="4-인덱스와-캐시--쓰기-비용으로-산-읽기-속도-w5">4. 인덱스와 캐시 — 쓰기 비용으로 산 읽기 속도 (W5)</h2>
<h3 id="왜-태어났는가-3">왜 태어났는가</h3>
<p>읽기 속도를 올리는 방법은 크게 두 층에 있는 것 같다. DB 내부의 인덱스, DB 바깥의 캐시.</p>
<p><strong>인덱스</strong>는 Full Scan의 O(N)을 B-Tree의 O(log N)으로 바꾸기 위해 도입된 것으로 알려져 있다. 5만 건에서 특정 행을 찾을 때 50,000번 비교 vs 17번(log₂ 50000) 비교는 체감 속도가 꽤 다를 수 있다. 다만 인덱스는 읽기 속도의 대가로 <strong>쓰기 비용과 저장 공간</strong>을 받아간다고 한다. INSERT/UPDATE할 때마다 인덱스도 갱신되고, 인덱스 자체가 별도 구조체로 디스크에 저장된다고 알려져 있다.</p>
<p><strong>캐시</strong>는 &quot;같은 데이터를 여러 번 읽을 때 DB를 반복해서 때리지 말자&quot;는 발상에서 출발한 듯하다. Redis 같은 인메모리 저장소에 조회 결과를 넣어두고, 다음 요청은 DB 대신 Redis에서 꺼내는 그림이다. 대가로는 <strong>정합성 관리</strong>가 따라오는 것 같다 — 원본이 바뀌었을 때 캐시를 어떻게 갱신할지가 설계의 주제가 되는 듯하다.</p>
<p>둘은 같은 &quot;읽기 속도&quot; 문제의 다른 층위 해법으로 봐도 될 것 같다. 인덱스는 DB를 빠르게 만들고, 캐시는 DB에 아예 가지 않는다. 둘을 같이 쓰면 효과가 누적되는 것 같기도 하다.</p>
<h3 id="트레이드오프-3">트레이드오프</h3>
<table>
<thead>
<tr>
<th>기법</th>
<th>얻는 것</th>
<th>내는 것</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 (DB 내부)</td>
<td>읽기 속도 O(log N)</td>
<td>쓰기 비용, 저장 공간</td>
</tr>
<tr>
<td>복합 인덱스</td>
<td>특정 쿼리 패턴 최적화</td>
<td>Equal→Range 컬럼 순서 제약</td>
</tr>
<tr>
<td>커버링 인덱스</td>
<td>디스크 I/O 없이 조회</td>
<td>인덱스가 커짐</td>
</tr>
<tr>
<td>비정규화</td>
<td>JOIN 자체 제거</td>
<td>원본 변경 시 동기화</td>
</tr>
<tr>
<td>캐시 (Cache-Aside)</td>
<td>DB 부하 감소 + 응답 속도</td>
<td>정합성 관리 (TTL vs invalidation)</td>
</tr>
</tbody></table>
<h3 id="내가-마주한-자리-3">내가 마주한 자리</h3>
<p><strong>인덱스 — 사내 ERP 조회.</strong></p>
<p>조회 화면이 3~5초 걸렸다. 반사적으로 <code>fiscal_year</code>에 인덱스를 걸었는데 안 빨라졌다. EXPLAIN을 돌리고 나서야 병목이 어디인지 조금 보이기 시작했다.</p>
<pre><code>┌──────────────────┬───────┬────────┬───────────────────────┬──────────┐
│      table       │ type  │  rows  │         extra         │   key    │
├──────────────────┼───────┼────────┼───────────────────────┼──────────┤
│ contract_ledger  │ ALL   │ 50,000 │ Using where           │ NULL     │
│ (서브쿼리 ①)     │ ALL   │ 80,000 │ Using temporary       │ NULL     │
│ (서브쿼리 ④)     │ ALL   │ 10,000 │ Using temporary       │ NULL     │
└──────────────────┴───────┴────────┴───────────────────────┴──────────┘</code></pre><p>병목은 WHERE가 아니라 FROM 쪽에 있는 듯했다. LEFT JOIN 4개가 매번 8만 건을 GROUP BY로 임시 테이블을 만들고 있었다. 메인 테이블을 500건으로 줄여봤자 서브쿼리가 Full Scan이면 전체가 느릴 수밖에 없었던 것 같다.</p>
<p>인덱스 4개를 추가하면서 &quot;쓰기가 느려지는 거 아닌가&quot;가 발목을 잡았지만, 이 테이블은 등록 월 수십 건 대 조회 하루 수백 번 정도였다. 읽기:쓰기가 100:1 이상이어서 인덱스를 아끼는 게 오히려 손해가 아닐까 싶어 그렇게 결정했다.</p>
<p>그 뒤 LEFT JOIN 3개는 아예 비정규화로 없앴다. 거래처명을 메인 테이블에 복사하고 동기화 비용을 감수해봤다. 거래처 정보 변경이 연 수건 이하 정도였으니 동기화 비용이 JOIN 비용보다 훨씬 쌀 것 같았다.</p>
<p><strong>캐시 — 상품 목록 조회.</strong></p>
<p>상품 목록 조회는 트래픽의 큰 비중을 차지한다. 같은 쿼리가 초당 수십 번 들어온다. 여기에 Spring Cache Abstraction으로 Redis 캐시를 얹어봤다.</p>
<pre><code class="language-java">@Cacheable(value = &quot;products&quot;, key = &quot;#brandId + &#39;_&#39; + #sort + &#39;_&#39; + #page&quot;)
public Page&lt;ProductModel&gt; findProducts(Long brandId, ProductSortType sort, Pageable page) {
    return productRepository.findAll(...);
}</code></pre>
<p>Cache-Aside 패턴이다. 조회 시 Redis를 먼저 확인하고, 없으면 DB를 조회한 뒤 Redis에 저장한다. 원본이 바뀌면 <code>@CacheEvict</code>로 날린다.</p>
<p>가장 고민한 건 <strong>캐시 키 설계</strong>와 <strong>TTL</strong>이었다. 키에 검색 조건을 전부 넣으면 히트율이 낮아지기 쉽고, 느슨하게 잡으면 다른 조건의 결과가 섞일 위험이 있다. TTL이 너무 길면 오래된 데이터가 보일 수 있고, 너무 짧으면 캐시 의미가 옅어진다. &quot;상품 정보가 몇 분 늦게 반영돼도 감내 가능한가&quot;를 먼저 확인한 뒤 TTL을 잡아봤다. 이건 기술 설정이라기보다 비즈니스 질문에 가까웠던 것 같기도 하다.</p>
<h3 id="교훈-3">교훈</h3>
<p>&quot;인덱스를 걸면 빨라진다&quot;는 조건부로만 참인 듯하다. 읽기 비율이 높고, 병목이 실제로 인덱스가 해결할 수 있는 자리에 있을 때 빨라지는 것 같다. 그리고 인덱스로 부족하면 DB 바깥에서 답을 찾게 되는 듯하다 — 비정규화가 &quot;찾을 필요 자체를 없애는 것&quot;이라면, 캐시는 <strong>DB에 갈 필요 자체를 없애는 것</strong>에 가깝게 다가왔다. 세 도구는 같은 &quot;읽기 속도&quot; 문제에 서로 다른 층위의 답을 내놓고 있는 게 아닐까?</p>
<hr>
<h2 id="5-circuit-breaker와-tx-분리--원자성의-대가를-격리하기-w6">5. Circuit Breaker와 TX 분리 — 원자성의 대가를 격리하기 (W6)</h2>
<h3 id="왜-태어났는가-4">왜 태어났는가</h3>
<p>Circuit Breaker는 Michael Nygard가 2007년 <em>Release It!</em>에서 정리한 패턴으로, <strong>Cascading Failure(연쇄 장애)</strong> 를 막기 위해 제안된 것으로 알려져 있다. 외부 의존이 계속 실패할 때 재시도가 오히려 부하를 가중시키고, 그 부하가 호출 측 리소스(스레드풀, 커넥션풀)를 고갈시켜 호출 측까지 무너지는 현상이라고 한다. Circuit Breaker는 연속 실패를 감지하면 한동안 호출을 아예 차단해서 &quot;빨리 실패&quot;하고 외부가 회복될 시간을 주는 식으로 동작하는 듯하다.</p>
<p>다만 Circuit Breaker 하나로는 부족한 것 같다. <code>@Transactional</code>이 외부 호출을 감싸면 <strong>트랜잭션이 외부 응답을 기다리는 동안 DB 커넥션이 풀에서 반납되지 않는다</strong>. PG 응답이 3초 걸리면 3초 동안 커넥션이 잡혀 있고, 동시 요청 100건에 풀이 10개면 줄줄이 타임아웃으로 갈 수 있다. 내 DB는 멀쩡한데 외부 지연이 내 서비스를 세우는 그림이 될 수도 있다.</p>
<h3 id="트레이드오프-4">트레이드오프</h3>
<table>
<thead>
<tr>
<th>선택</th>
<th>얻는 것</th>
<th>내는 것</th>
</tr>
</thead>
<tbody><tr>
<td>TX 안에 외부 호출</td>
<td>원자성 (하나의 롤백 단위)</td>
<td>커넥션 홀딩, 장애 전파</td>
</tr>
<tr>
<td>TX 밖으로 분리 (3단 분할)</td>
<td>커넥션 즉시 반환</td>
<td>원자성 포기 → 보상 처리 필요</td>
</tr>
<tr>
<td>Circuit Breaker</td>
<td>빠른 실패로 리소스 보호</td>
<td>일부 정상 요청도 차단됨</td>
</tr>
</tbody></table>
<h3 id="내가-마주한-자리-4">내가 마주한 자리</h3>
<p>결제 Facade의 흐름을 세 구간으로 쪼개봤다. 외부 호출을 한가운데 놓고 앞뒤만 트랜잭션으로 감쌌다.</p>
<pre><code class="language-java">@CircuitBreaker(name = &quot;pg&quot;, fallbackMethod = &quot;requestPaymentFallback&quot;)
public PaymentInfo requestPayment(Long userId, PaymentCommand command) {
    // TX-1: 주문 상태 전이 + 결제 레코드 생성 (~15ms) → 커밋 후 커넥션 반환
    PaymentModel payment = paymentService.preparePayment(userId, command);

    // NO TX: PG 호출 (100~500ms, 타임아웃 3초) — DB 커넥션 안 잡음
    PgPaymentResponse pgResponse = pgPaymentGateway.requestPayment(...);

    // TX-2: transactionKey 저장 (~5ms) → 다시 커밋 후 커넥션 반환
    paymentService.assignTransactionKey(payment.getId(), pgResponse.transactionKey());

    return PaymentInfo.from(payment, pgResponse.transactionKey());
}</code></pre>
<p>분리하고 나니 두 가지가 같이 풀린 듯했다. 커넥션 점유 시간이 수 초 단위에서 수십 밀리초 단위로 줄었고, 주문과 결제의 책임 경계가 조금 더 또렷해진 것 같기도 했다. 한 트랜잭션으로 묶었을 때는 둘의 책임이 섞여 있는 것처럼 다가왔다.</p>
<p>그리고 Fallback에서 흥미로운 장치가 나왔다.</p>
<pre><code class="language-java">private PaymentInfo requestPaymentFallback(...) {
    // TX-1이 이미 커밋됨 → 주문은 PAYMENT_PENDING, 결제는 PENDING으로 DB에 존재
    // → PaymentPollingScheduler가 주기적으로 재시도
    return PaymentInfo.pgFailed(...);
}</code></pre>
<p>Circuit Breaker로 빨리 실패시키되, TX-1이 이미 커밋된 덕에 <strong>복구할 자리가 DB에 남아있다</strong>. 별도 스케줄러가 PENDING 결제를 주기적으로 훑어서 재시도한다.</p>
<h3 id="교훈-4">교훈</h3>
<p>Circuit Breaker는 단순히 &quot;장애 대응&quot;이라기보다 <strong>빠른 실패 + 복구 자리 확보</strong>가 짝으로 작동할 때 의미가 살아나는 게 아닐까 싶었다. 트랜잭션 분리가 복구의 토대를 만들어주는 셈이 아닐지, 빨리 실패시키려면 실패 직전 상태를 영속화 시켜 알고있어야 한다는 점도 배운 거 같다.</p>
<hr>
<h2 id="6-이벤트와-kafka--결합도를-풀어주는-대신-복잡도를-얻는다-w7">6. 이벤트와 Kafka — 결합도를 풀어주는 대신 복잡도를 얻는다 (W7)</h2>
<h3 id="왜-태어났는가-5">왜 태어났는가</h3>
<p>도메인 이벤트는 <strong>모놀리식 애플리케이션 안에서 도메인 간 직접 호출을 느슨하게 풀어주려고</strong> 도입된 것으로 알려져 있다. Spring <code>ApplicationEvent</code>는 이 개념을 메모리 전파로 구현한 것이다. 발행자는 &quot;누가 듣는지&quot; 모르고, 리스너는 &quot;누가 보냈는지&quot; 모른다.</p>
<p>그런데 <code>@EventListener</code>는 기본 동기 실행이다. 같은 스레드, 같은 트랜잭션. 리스너 예외가 발행자 트랜잭션까지 롤백시킬 수 있다. 이걸 분리하려고 <code>@TransactionalEventListener(AFTER_COMMIT) + @Transactional(REQUIRES_NEW)</code> 조합이 자주 쓰이는 듯하다. 실패 격리와 DB 쓰기를 동시에 만족시키기 위한 설계로 보인다.</p>
<p>다만 <code>ApplicationEvent</code>는 <strong>메모리 전파의 근본 한계</strong>가 있는 것 같다 — JVM이 죽으면 이벤트도 같이 사라질 수 있다. 배포 한 번에 이벤트가 유실될 수 있다. 여기서 Kafka가 필요해지는 듯하다. Kafka는 이벤트를 <strong>파티션에 영속 저장해서 발행자/소비자 JVM 생명주기와 독립</strong>시켜준다고 한다.</p>
<p>Kafka를 쓰면 새 문제가 생기는 것 같다. DB와 Kafka는 서로 다른 시스템이라 <strong>하나의 트랜잭션으로 묶기 어렵다</strong>. DB 커밋했는데 Kafka 전송이 실패하면? 이게 흔히 말하는 <strong>이중 쓰기(Dual Write) 문제</strong>이고, <strong>Outbox Pattern</strong>이 그 우회로로 자리 잡은 듯하다. Kafka에 직접 보내지 말고, &quot;보내야 할 이벤트&quot;를 같은 DB에 먼저 저장하고, 별도 Relay가 폴링해서 Kafka로 보내는 방식이라고 이해해봤다.</p>
<h3 id="트레이드오프-5">트레이드오프</h3>
<table>
<thead>
<tr>
<th>도구</th>
<th>얻는 것</th>
<th>내는 것</th>
</tr>
</thead>
<tbody><tr>
<td><code>@EventListener</code> (동기)</td>
<td>코드 결합도 제거</td>
<td>트랜잭션 결합은 그대로</td>
</tr>
<tr>
<td><code>@TxEventListener(AFTER_COMMIT) + REQUIRES_NEW</code></td>
<td>실패 격리 + DB 쓰기 가능</td>
<td>세 번의 삽질로 얻는 이해</td>
</tr>
<tr>
<td><code>ApplicationEvent</code> (메모리)</td>
<td>구현 간단</td>
<td>JVM 사망 시 유실</td>
</tr>
<tr>
<td>Kafka</td>
<td>영속성, 분산, 재시도</td>
<td>파이프라인 복잡도 폭증</td>
</tr>
<tr>
<td>Outbox Pattern</td>
<td>이중 쓰기 방지</td>
<td>outbox 테이블 + Relay 유지 비용</td>
</tr>
</tbody></table>
<h3 id="내가-마주한-자리-5">내가 마주한 자리</h3>
<p>좋아요 집계를 이벤트로 뺄 때 첫 삽질은 <code>@EventListener</code>였다. 코드 의존은 끊겼지만 리스너 예외가 발행자 트랜잭션까지 롤백시켰다. 코드 의존을 끊는 것과 트랜잭션을 분리하는 것은 별개의 일이라는 걸 그제야 조금 느꼈다.</p>
<table>
<thead>
<tr>
<th></th>
<th>코드 의존 있음</th>
<th>코드 의존 없음</th>
</tr>
</thead>
<tbody><tr>
<td>같은 트랜잭션</td>
<td>직접 호출 (원래)</td>
<td><code>@EventListener</code> (첫 삽질)</td>
</tr>
<tr>
<td>별도 트랜잭션</td>
<td>—</td>
<td><code>@TxEventListener + REQUIRES_NEW</code> (최종)</td>
</tr>
</tbody></table>
<p>같은 JVM 내 <code>@TransactionalEventListener</code> 범위 안에서 실패 격리와 DB 쓰기를 동시에 만족시키려면 <code>AFTER_COMMIT + REQUIRES_NEW</code> 조합이 그나마 자연스러워 보였다.</p>
<p>이벤트 분리를 하고 나니 <code>ApplicationEvent</code>의 메모리 전파 한계가 점점 드러나는 것 같았다. Kafka로 확장하려다 이중 쓰기 문제를 만났고, Outbox로 우회해봤다. Outbox 리스너는 <code>BEFORE_COMMIT</code>으로 같은 DB 트랜잭션에 outbox 레코드를 묶었다. 같은 <code>@TransactionalEventListener</code>인데 집계 리스너(AFTER_COMMIT)와 phase가 정반대였다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Outbox 리스너 (BEFORE_COMMIT)</th>
<th>집계 리스너 (AFTER_COMMIT)</th>
</tr>
</thead>
<tbody><tr>
<td>리스너 실패 시</td>
<td>좋아요도 롤백</td>
<td>좋아요는 이미 커밋</td>
</tr>
<tr>
<td>목적</td>
<td>원자적 저장</td>
<td>실패 격리</td>
</tr>
</tbody></table>
<p>목적이 정반대라서 phase도 정반대로 가는 것 같았다. 이걸 정리하고 나서야 같은 도구의 다른 얼굴이 희미하게 보이는 듯했다.</p>
<p>Kafka 자체도 기본 설정만으로는 보장이 부족하다는 걸 알게 됐다. <code>acks=all</code>이 아니면 리더 장애에 유실 가능, <code>enable.idempotence=true</code>가 아니면 네트워크 재시도에 중복 적재 가능, <code>SKIP LOCKED</code>가 아니면 Relay 병렬 처리가 막히는 경향이 있었다. 아직 반쯤 이해한 정도이지만, 각 설정이 파이프라인의 어떤 구간을 보호하고 있는지를 짚어보고 나서야 Kafka의 형체가 조금씩 보이기 시작한 것 같다.</p>
<h3 id="교훈-5">교훈</h3>
<p>이벤트는 결합도를 낮추는 대신 <strong>결합도가 사라진 자리에 복잡도가 들어오는</strong> 측면이 있는 것 같다. 트랜잭션 경계, 유실 방어, 중복 방어, 순서 보장 — 직접 호출에서는 생각할 필요 없던 문제들이 이벤트 파이프라인에서는 설계의 주제가 되는 듯하다. Kafka는 결합도를 풀어주는 도구라기보다 <strong>결합도를 풀 자격을 묻는 도구</strong>에 가깝게 다가왔다.</p>
<hr>
<h2 id="7-대기열--거부의-대가와-줄-세우기-w8">7. 대기열 — 거부의 대가와 줄 세우기 (W8)</h2>
<h3 id="왜-태어났는가-6">왜 태어났는가</h3>
<p>Rate Limiting은 &quot;초과 트래픽을 거부해서 시스템을 보호&quot;하기 위해 흔히 쓰이는 것 같다. 그런데 <strong>거부당한 유저는 새로고침으로 응답한다</strong>. 선착순 한정 판매처럼 유저가 &quot;지금 이 자원을 꼭 얻고 싶다&quot;는 상황에서 429를 받으면, 유저 행동이 트래픽을 더 늘리는 쪽으로 갈 수 있다 — Thundering Herd.</p>
<p>대기열은 이 상황을 다른 방식으로 풀어보는 시도 중 하나라고 이해해보고 있다. 거부하지 않고 &quot;23번째, 9초 후 입장&quot;이라고 알려준다. <strong>순번이 보이니 유저가 새로고침할 이유가 줄어든다</strong>. 대기 경험이 어느 정도 수용 가능해지는 듯하다.</p>
<p>대기열 자료구조로 Redis Sorted Set이 잘 맞는 이유는 <strong>순서 보장 + O(log N) 삽입/조회 + 원자적 POP</strong>이 한 번에 되기 때문이라고 알려져 있다. <code>ZADD NX</code>로 중복 방지, <code>ZRANK</code>로 순번 조회, <code>ZPOPMIN</code>으로 원자적으로 꺼낸다. 단일 스레드 모델 덕에 분산 락이 따로 필요하지 않은 듯하다.</p>
<h3 id="트레이드오프-6">트레이드오프</h3>
<table>
<thead>
<tr>
<th>접근</th>
<th>얻는 것</th>
<th>내는 것</th>
</tr>
</thead>
<tbody><tr>
<td>Rate Limiting (거부)</td>
<td>즉시성, 구현 단순</td>
<td>유저 이탈 + 재시도 폭주</td>
</tr>
<tr>
<td>대기열</td>
<td>UX, 트래픽 예측 가능</td>
<td>인프라 복잡도(Redis + 스케줄러 + 토큰 관리)</td>
</tr>
<tr>
<td>Sorted Set</td>
<td>O(log N) + 순서 + 원자성</td>
<td>메모리 사용, TTL 관리</td>
</tr>
<tr>
<td>List</td>
<td>메모리 효율</td>
<td>중간 삽입/조회 O(N)</td>
</tr>
<tr>
<td>Set</td>
<td>중복 방지</td>
<td>순서 없음</td>
</tr>
</tbody></table>
<h3 id="내가-마주한-자리-6">내가 마주한 자리</h3>
<p>스케줄러 배치 크기를 &quot;적당히 50&quot;으로 정하려다가 &quot;산정 근거를 문서화하라&quot;는 요구를 보고 역산해봤다.</p>
<pre><code>HikariCP 최대 풀: 10
평균 주문 처리 시간: ~200ms
3초 내 이론적 최대 처리: 10 × (3000 / 200) = 150건</code></pre><p>이론적으로 150건 가능하지만 다른 API도 커넥션을 써야 한다. 150으로 잡으면 3초간 풀을 독점할 수 있다. 결국 풀 크기와 동일한 10건으로 잡아봤다. 숫자가 감이 아니라 시스템 자원에서 역산되고 나니 그제야 근거가 생긴 느낌이었다.</p>
<p>토큰 검증은 <code>HandlerInterceptor</code>에 뒀다. 주문 로직이 대기열 토큰의 존재를 알 필요가 없도록 횡단 관심사로 분리해봤다.</p>
<h3 id="교훈-6">교훈</h3>
<p>&quot;트래픽이 몰리면 Rate Limiting으로 거부한다&quot;는 시스템 관점의 답에 가까운 듯하다. 유저 관점에서 보면 <strong>거부는 재시도를 부르고, 줄 세우기는 대기를 부르는</strong> 쪽으로 갈 가능성이 있다. 둘은 꽤 다른 유저 경험이 아닐까 싶었다. 대기열은 이 차이를 인프라 복잡도와 교환한 선택으로 봐도 될 것 같다는 생각이 들었다.</p>
<hr>
<h2 id="8-redis-zset-랭킹--strong을-포기하고-얻는-속도-w9">8. Redis ZSET 랭킹 — Strong을 포기하고 얻는 속도 (W9)</h2>
<h3 id="왜-태어났는가-7">왜 태어났는가</h3>
<p>RDB로 실시간 랭킹을 만들려면 한계가 있는 듯하다. 가중치 합산(<code>view * 0.1 + like * 0.2 + sale * 0.7</code>)은 계산 값이라 인덱스를 타기 어렵고, 별도 컬럼에 저장해두면 매 이벤트마다 UPDATE가 발생해 행 락 경합이 심해질 수 있다. 읽기와 쓰기가 동시에 많은 랭킹에서 RDB는 병목이 되기 쉬운 것 같다.</p>
<p>Redis ZSET은 이 문제를 다른 자료구조로 풀어보는 접근이라고 이해했다. Skip List 기반이라 <strong><code>ZINCRBY</code>로 점수 갱신과 정렬을 O(log N)에 동시 처리</strong>한다고 한다. 단일 스레드 모델이라 락 자체가 없다. 대신 Redis는 RDB와 별도 시스템이라 <strong>Strong Consistency를 일정 부분 포기</strong>하게 되는 듯하다. DB 저장은 성공했는데 Redis 갱신이 실패할 가능성이 남는다.</p>
<p>이 포기가 합리적으로 보이는 이유는 <strong>데이터의 성격</strong>에 있는 게 아닐까 싶다. 결제 금액은 1원도 틀리면 안 되겠지만, 랭킹 3위가 잠깐 4위여도 유저가 알아채기는 어려울 수 있다. 데이터마다 요구하는 정합성의 등급이 다른 듯하다.</p>
<h3 id="트레이드오프-7">트레이드오프</h3>
<table>
<thead>
<tr>
<th>저장소/전략</th>
<th>얻는 것</th>
<th>내는 것</th>
</tr>
</thead>
<tbody><tr>
<td>RDB + ORDER BY</td>
<td>Strong Consistency</td>
<td>가중치 합산 인덱스 불가, 쓰기 락 경합</td>
</tr>
<tr>
<td>Redis ZSET + ZINCRBY</td>
<td>O(log N) + 락 없음</td>
<td>Eventual Consistency</td>
</tr>
<tr>
<td>2PC (분산 트랜잭션)</td>
<td>양쪽 Strong</td>
<td>prepare-commit 2번 왕복, Coordinator 장애 시 블로킹</td>
</tr>
<tr>
<td>Fire-and-Forget</td>
<td>성능 최대</td>
<td>실패 관측 어려움</td>
</tr>
</tbody></table>
<p>&quot;인기 상품 랭킹&quot;이라는 요구에도 트레이드오프가 있는 것 같다. LINEAR(<code>price × quantity × weight</code>)는 매출 기여도를 보고, LOG는 금액 스케일을 눌러 구매 건수에 무게를 두며, COUNT는 조회 수만 본다. 같은 데이터에 셋을 적용하면 1위가 세 번 바뀔 수도 있겠다는 걸 관찰했다. 29CM이 인기순과 판매순을 분리해서 제공하는 이유도 비슷한 맥락이 아닐까, 정도로 추측해본다.</p>
<h3 id="내가-마주한-자리-7">내가 마주한 자리</h3>
<p>가중치를 조정하기보다는 <strong>스케일을 맞춰야</strong> 한다는 걸 더미 데이터 실험으로 어렴풋이 알게 됐다. <code>289만 × 0.1 = 289,000점</code> vs <code>조회 1건 × 0.5 = 0.5점</code>. 가중치가 0.1이든 0.5든 스케일이 10^6 배 다르면 의미가 옅어지는 듯했다. <code>log10(price × amount)</code>로 정규화하면 15배 차이가 1.2배로 압축된다.</p>
<p>랭킹 Redis 갱신은 Fire-and-Forget으로 둬봤다. 실패하면 로그만 남기고 넘어가는 방식이다. DB-Redis 2PC를 돌리기엔 랭킹이 Strong을 요구할 만한 데이터로는 보이지 않았다.</p>
<pre><code class="language-java">public void incrementScore(String key, Long productId, double score) {
    try {
        redisTemplate.opsForZSet().incrementScore(key, productId.toString(), score);
        redisTemplate.expire(key, Duration.ofDays(TTL_DAYS));
    } catch (Exception e) {
        log.warn(&quot;랭킹 점수 업데이트 실패: key={}, productId={}&quot;, key, productId, e);
    }
}</code></pre>
<p>같은 Sorted Set을 W8에서는 대기열로 썼다. Score에 <code>System.currentTimeMillis()</code>를 넣으면 대기열이 되고, 점수 합산을 넣으면 랭킹이 된다. 자료구조는 용도라기보다 재료에 가까운 게 아닐까, 싶었다.</p>
<h3 id="교훈-7">교훈</h3>
<p>&quot;정합성을 지켜야 한다&quot;는 말의 전제가 조금 단순했던 것 같기도 하다. 지켜야 하는 정합성의 등급은 데이터마다 다른 듯하다. <strong>모든 데이터에 Strong을 요구하면 시스템이 불필요하게 복잡해질 수 있다</strong>는 것을 이번에 희미하게 체감했다. Eventual Consistency를 감내할 수 있는 자리에서는 Eventual이 합리적 선택이 되어주는 것 같다는 생각이 들었다.</p>
<hr>
<h2 id="9-배치와-cqrs--지연을-감내하고-쓰기-비용을-줄이는-법-w10">9. 배치와 CQRS — 지연을 감내하고 쓰기 비용을 줄이는 법 (W10)</h2>
<h3 id="왜-태어났는가-8">왜 태어났는가</h3>
<p>Spring Batch는 &quot;대용량 데이터를 주기적으로 처리&quot;하기 위해 나온 도구로 알려져 있다. OLTP 요청마다 무거운 집계를 돌리면 응답 시간이 폭발하기 쉽다. 지연을 감내할 수 있다면 <strong>쓰기를 실시간으로 하지 않고 하루에 한 번 몰아서 계산</strong>하는 게 훨씬 싼 선택이 되어줄 수도 있다.</p>
<p>주간/월간 랭킹을 실시간 ZSET으로 만든다면 매 이벤트마다 7개(주간) 또는 30개(월간)의 ZSET을 갱신해야 한다. 쓰기 비용이 폭증할 가능성이 크다. 반대로 배치로 돌리면 하루 한 번 <code>mv_product_rank_weekly</code> 테이블에 적재하고 읽기 요청은 이 MV를 조회한다. <strong>지연(최대 24시간)을 감내하는 대신 쓰기 비용을 거의 0에 가깝게 만들 수 있는</strong> 셈이 아닐까 싶다.</p>
<p>MV(Materialized View)는 여기서 또 다른 문제를 만난다. 배치가 새벽 3시에 MV를 새로 만들 때 기존 MV를 읽는 API는 어떻게 할 것인가? TRUNCATE+INSERT로 하면 그 사이 몇 분 랭킹이 비어 보일 수 있다. <strong>버전 스위칭</strong>이 한 가지 답이 되어주는 듯하다. MV를 두 벌 두고 비활성 버전에 배치가 쓴 뒤, 마지막에 활성 포인터만 원자적으로 바꾼다.</p>
<p>CQRS(Command Query Responsibility Segregation)는 &quot;읽기와 쓰기 모델을 분리하자&quot;는 패턴으로 알려져 있다. 실시간 랭킹은 <code>commerce-streamer</code>가 Kafka Consumer로 ZSET에 쓰고 <code>commerce-api</code>가 읽고, 배치 랭킹은 <code>commerce-batch</code>가 MV에 쓰고 <code>commerce-api</code>가 읽는 구조가 됐다. 의도하지 않았는데 구조가 CQRS에 가까워진 듯한 느낌이었다.</p>
<h3 id="트레이드오프-8">트레이드오프</h3>
<table>
<thead>
<tr>
<th>접근</th>
<th>얻는 것</th>
<th>내는 것</th>
</tr>
</thead>
<tbody><tr>
<td>실시간 집계 (ZINCRBY)</td>
<td>최신성</td>
<td>쓰기 비용 × 이벤트 수</td>
</tr>
<tr>
<td>배치 집계 (MV)</td>
<td>쓰기 비용 최소화</td>
<td>최대 24시간 지연</td>
</tr>
<tr>
<td>TRUNCATE + INSERT</td>
<td>단순</td>
<td>배치 중 빈 결과</td>
</tr>
<tr>
<td>버전 스위칭</td>
<td>무중단 교체</td>
<td>저장 공간 2배</td>
</tr>
<tr>
<td>CQRS (읽기/쓰기 분리)</td>
<td>각자 최적화 가능</td>
<td>모듈/배포 단위 증가</td>
</tr>
</tbody></table>
<h3 id="내가-마주한-자리-8">내가 마주한 자리</h3>
<p>주간 랭킹을 처음엔 ZSET으로 만들어보려다가, 매 이벤트마다 7개 ZSET을 갱신하는 비용이 말이 안 되지 않나 싶어 방향을 뒤집어봤다. 배치로 바꾸고 MV 테이블 두 벌을 뒀다.</p>
<pre><code class="language-java">// WeeklyRankingJobConfig
Step aggregation → Step activateVersion(Tasklet) → Step clearOldVersion(Tasklet)</code></pre>
<p>배치 중간에 문제가 생겨도 활성 버전은 이전 것이 그대로라 API에 영향이 가지 않는 듯하다. 실패한 배치만 재실행하면 되는 구조가 됐다.</p>
<p>그리고 자연스럽게 CQRS 형태가 드러나는 것 같기도 했다. 의도한 게 아니라, 실시간이 가진 요구와 배치가 가진 요구가 달라서 프로세스가 갈린 결과로 보였다.</p>
<h3 id="교훈-8">교훈</h3>
<p>&quot;실시간이어야 한다&quot;는 요구 이전에 <strong>얼마나 지연을 감내할 수 있는지</strong>를 먼저 물어보면 좋지 않을까 싶다. 감내할 수 있는 지연이 있으면 배치가 쓰기 비용을 거의 0으로 만들어주는 효과가 있는 듯하다. CQRS도 &quot;읽기와 쓰기를 분리하자&quot;는 교과서적 결정 이전에, 각자의 실패와 지연을 다르게 감내하기 시작하면 구조가 자연스럽게 CQRS의 형태에 가까워질 수도 있겠다는 생각이 들었다.</p>
<hr>
<h2 id="아직-정직하게-답하지-못한-자리">아직 정직하게 답하지 못한 자리</h2>
<p>9개의 트레이드오프를 정리해봤지만, 이 글을 쓰면서 <strong>내가 트레이드오프를 제대로 선택하지 못한 자리</strong>도 같이 드러난 것 같다.</p>
<p><strong>carry-over 10%는 근거라기보다 권장에 가까웠다.</strong></p>
<p>W9 랭킹에서 자정에 빈 화면을 막으려고 전날 점수의 10%를 새 키에 넘겼다.</p>
<pre><code class="language-redis">ZUNIONSTORE ranking:all:20260411 1 ranking:all:20260410 WEIGHTS 0.1</code></pre>
<p>왜 10%인가? 솔직히 답하면 &quot;발제 자료의 권장(0.05~0.1)&quot;이었다. W8에서 배치 크기를 HikariCP 풀에서 역산했는데, 이 자리에서는 그 습관이 무너졌던 것 같다. &quot;오전 몇 시까지 오늘 활동이 역전해야 하는지&quot;로 역산했어야 하지 않았나, 뒤늦게 돌아본다.</p>
<p><strong>ZPOPMIN-토큰 발급 간극은 &quot;1ms니까 괜찮다&quot;로 합리화한 것 같다.</strong></p>
<p>W8 대기열에서 <code>ZPOPMIN</code>으로 유저를 꺼낸 직후 토큰을 <code>SET</code>하기 전에 서버가 죽으면 유저는 대기열에서 빠졌지만 토큰을 못 받는 상황이 생길 수 있다. Lua Script로 원자화할 수 있다는 걸 알면서도 &quot;1ms 미만이고 재진입하면 된다&quot;로 넘겼다. 유저 관점에서 &quot;대기 후 들어갔는데 맨 뒤로 밀렸다&quot;는 경험은 감내 불가에 가까울지도 모르겠다. 기술의 합리화였지 유저 관점의 판단이라고 보기는 어려웠던 것 같다.</p>
<p><strong>&quot;감내 가능&quot;과 &quot;관측 가능&quot;은 한 쌍이 아닐까 싶다.</strong></p>
<p>가장 깊은 반성이다. 10주 동안 &quot;이 실패는 감내 가능하다&quot;고 여러 번 판단했다. 좋아요 집계 1건 오차, 랭킹 3위의 일시적 4위화, 이벤트의 0.001% 유실. 대체로 감내 가능하다고 써왔다.</p>
<p>그런데 내가 그 실패를 볼 수 있을까?</p>
<ul>
<li>좋아요 집계가 100건 중 1건 오차가 아니라 10건 오차로 늘면, 알아챌 수 있을까?</li>
<li>Redis Fire-and-Forget으로 흘려보낸 실패가 쌓여 10%가 유실되면, 알아챌 수 있을까?</li>
<li>Outbox에 PROCESSING 좀비가 1000건 쌓이면, 알아챌 수 있을까?</li>
</ul>
<p>&quot;감내 가능&quot;은 <strong>그 실패를 내가 볼 수 있을 때</strong>만 진짜 감내에 가까운 게 아닐까, 라는 생각을 뒤늦게 한다. 볼 수 없으면 사실상 방치에 가까운 셈이다. 실패를 감내하기로 한 자리에는 가능하면 그 실패를 관측하는 장치(로그, 알림, 대시보드, SLO)가 따라붙는 편이 좋겠다는 생각이 든다.</p>
<p>10주 동안 이 쌍을 제대로 붙인 적이 많지 않았던 것 같다. <code>queue.enter.total</code> Counter, <code>queue.waiting.size</code> Gauge 같은 메트릭은 만들었지만 정상 흐름의 지표 쪽에 가까웠다. &quot;감내하기로 한 실패가 얼마나 누적되고 있는지&quot;를 보는 장치는 아니었던 듯하다. 이게 10주의 마지막에 남은 가장 큰 미완으로 다가온다.</p>
<hr>
<h2 id="마무리--기술-선택은-기술이-결정하지-않는-듯하다">마무리 — 기술 선택은 기술이 결정하지 않는 듯하다</h2>
<p>10주 동안 얕게 겪어본 도구들을 나열해 보았다.</p>
<ul>
<li>TDD</li>
<li>Rich Domain Model</li>
<li>동시성 제어 전략 (비관/낙관 락, 원자 UPDATE, 유니크 제약)</li>
<li>읽기 최적화(EXPLAIN, 복합 인덱스, 비정규화, Cache-Aside)</li>
<li>외부 장애 대응 전략(Resilience4j + 트랜잭션 분리)</li>
<li>이벤트 프로그래밍(@TransactionalEventListener, Outbox Pattern, Kafka, SKIP LOCKED, manual ack)</li>
<li>대기열 시스템 (Redis Sorted Set, HandlerInterceptor + 스케줄러)</li>
<li>실시간 집계 (Redis ZSET, Skip List, ZINCRBY/ZREVRANGE)</li>
<li>지연 집계 / 배치 (Spring Batch, MV 버전 스위칭, CQRS)</li>
</ul>
<p>거슬러 올라가 보니 진짜로 남은 건 도구 목록이라기보다 각 도구가 서있는 <strong>트레이드오프의 지도</strong> 같은 무언가가 아닐까 싶다 — 아직 내 손에 쥐여진 지도라기보다 멀리서 윤곽만 본 지도에 가깝지만. TDD는 속도와 검증 사이에, 락은 정확성과 처리량 사이에, 인덱스는 읽기와 쓰기 사이에, 캐시는 속도와 정합성 사이에, Circuit Breaker는 가용성과 정상 요청 사이에, Kafka는 결합도와 복잡도 사이에, ZSET은 속도와 정합성 사이에, 배치는 최신성과 쓰기 비용 사이에 자리한 것 같다.</p>
<p>이 지도 위에서 선택의 기준은 대체로 기술 바깥에 있는 듯했다. 비즈니스가 어떤 실패를 감내할 수 있는가, 어떤 지연을 받아들일 수 있는가, 어떤 오차를 용인할 수 있는가. 받은 개념을 내 언어로 다시 써보는 일은 결국 <strong>기술 뒤에 가려진 비즈니스 질문을 찾아내는 일</strong>에 가까웠던 것 같기도 하다.</p>
<p>다음에 처음 보는 기술을 마주하면, 그래도 튜토리얼을 켜기 전에 이 질문을 한 번쯤 던져보려 한다. 잘 지켜질지는 아직 자신이 없지만.</p>
<blockquote>
<h4 id="이-기술은-무엇을-얻으려-무엇을-포기하는-설계일까-그-교환이-내-맥락에-맞는-교환일까">이 기술은 무엇을 얻으려 무엇을 포기하는 설계일까. 그 교환이 내 맥락에 맞는 교환일까.</h4>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[WIL (Redis ZSET 기반 실시간 랭킹 시스템)]]></title>
            <link>https://velog.io/@praesentia-ykm/WIL-Redis-ZSET-%EA%B8%B0%EB%B0%98-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EB%9E%AD%ED%82%B9-%EC%8B%9C%EC%8A%A4%ED%85%9C</link>
            <guid>https://velog.io/@praesentia-ykm/WIL-Redis-ZSET-%EA%B8%B0%EB%B0%98-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EB%9E%AD%ED%82%B9-%EC%8B%9C%EC%8A%A4%ED%85%9C</guid>
            <pubDate>Sun, 12 Apr 2026 13:11:46 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/0aa36308-44c2-476e-8b19-ecc25677dc8e/image.png" alt="">
<img src="https://velog.velcdn.com/images/praesentia-ykm/post/859a9634-915c-45c6-ace9-41c04c450251/image.png" alt="">
<img src="https://velog.velcdn.com/images/praesentia-ykm/post/fc0f3893-bd34-4c68-878a-770dda46c148/image.png" alt=""></p>
<h2 id="새로-배운-것">새로 배운 것</h2>
<p>이번 주 과제는 &quot;상품 인기 랭킹을 실시간으로 보여줘라&quot;였다. 처음에는 간단할 줄 알았다. <code>product_metrics</code> 테이블에 조회수, 좋아요, 주문수가 이미 쌓이고 있으니까 ORDER BY 한 방이면 되지 않나? 하지만 &quot;인기&quot;를 정의하려는 순간부터 생각보다 복잡한 설계 판단이 시작됐다.</p>
<h3 id="인기는-하나가-아니었다">&quot;인기&quot;는 하나가 아니었다</h3>
<p>29CM의 BEST 탭을 F12로 열어봤다. 인기순(POPULARITY)과 판매순(SALES)이 <strong>분리</strong>되어 있었다. 처음에는 &quot;왜 하나로 안 합치지?&quot;라고 생각했는데, 직접 실험해보니 이유를 알겠더라.</p>
<p>20개 더미 상품에 4,069건의 이벤트를 넣고, 동일한 가중치(View 0.2, Like 0.3, Order 0.5)로 Score 계산 방식만 바꿔봤다.</p>
<table>
<thead>
<tr>
<th>Score 방식</th>
<th>1위</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>LINEAR (<code>price × quantity × weight</code>)</td>
<td>르메르 크로와상 백 (289만원)</td>
<td>매출 기여도 순위</td>
</tr>
<tr>
<td>LOG (<code>log(price × quantity) × weight</code>)</td>
<td>뉴발란스 993 (34건 주문)</td>
<td>구매 인기 순위</td>
</tr>
<tr>
<td>COUNT (<code>1 × weight</code>)</td>
<td>탬버린즈 퍼퓸 (조회 636건)</td>
<td>관심도/트렌드 순위</td>
</tr>
</tbody></table>
<p>같은 데이터, 같은 가중치인데 <strong>1위가 세 번 바뀌었다</strong>. 특히 LINEAR 방식에서 289만원짜리 르메르 백이 주문 점수 비중 99.99%로 모든 걸 압도하는 걸 보고, 가중치를 아무리 조정해도 스케일 자체가 다르면 의미가 없다는 걸 체감했다. <code>289만 × 0.1 = 289,000점</code> vs <code>조회 1건 × 0.5 = 0.5점</code>. 이건 가중치의 문제가 아니라 스케일의 문제였다.</p>
<h3 id="log-정규화--금액-시그널은-살리되-스케일은-눌러주기">log 정규화 — 금액 시그널은 살리되 스케일은 눌러주기</h3>
<p><code>log10(price × amount)</code>를 적용하면 289만원은 <code>log10(2,890,000) = 14.9</code>, 19.8만원은 <code>log10(198,000) = 12.2</code>가 된다. 15배 차이가 1.2배로 압축된다. 이러면 금액의 시그널은 남아있되, 주문 건수가 순위를 결정하게 된다.</p>
<p>학습 Q&amp;A에서 &quot;1000원짜리 100개 산 거랑 100만원짜리 1개 산 거면 1000원짜리가 더 인기있다고 볼 수 있잖아&quot;라고 대답했는데, 이게 정확히 log 정규화가 해주는 일이었다. 금액을 아예 무시하진 않지만, 건수가 더 중요한 시그널이 되도록 스케일을 맞춰준다.</p>
<pre><code class="language-java">public double orderScore(long price, int amount) {
    long totalAmount = price * amount;
    if (totalAmount &lt;= 0) return 0.0;
    return ORDER_WEIGHT * Math.log10(totalAmount);
}</code></pre>
<h3 id="rdb-order-by가-안-되는-진짜-이유">RDB ORDER BY가 안 되는 진짜 이유</h3>
<p>&quot;그냥 DB에 total_score 컬럼 만들고 인덱스 걸면 되지 않나?&quot;라는 질문에 처음에는 &quot;실시간성이 떨어지니까&quot;라고만 대답했다. 하지만 Q&amp;A를 하면서 진짜 이유를 하나씩 파고들었다.</p>
<ol>
<li><strong>가중치 합산은 계산된 값 → 인덱스 불가</strong> (<code>view * 0.1 + like * 0.2 + sale * 0.7</code>)</li>
<li><strong>별도 컬럼 저장 → 매 이벤트마다 UPDATE → 행 락</strong> 경쟁</li>
<li><strong>읽기/쓰기 동시 발생</strong> — 홈 메인급 트래픽에서 UPDATE + SELECT가 동시에 일어나면 DB 병목</li>
</ol>
<p>Redis ZSET은 <code>ZINCRBY</code>로 점수 갱신과 Skip List 재정렬이 O(log N)에 동시 처리된다. 단일 스레드 모델이라 락 자체가 없다. 8주차에 대기열로 Sorted Set을 썼을 때는 &quot;순서 보장&quot;에 집중했는데, 이번에는 &quot;실시간 점수 갱신 + 정렬&quot;이라는 전혀 다른 용도로 같은 자료구조를 쓰면서, ZSET이 정말 범용적이구나 하는 걸 느꼈다.</p>
<h2 id="내가-했던-고민들">내가 했던 고민들</h2>
<h3 id="시간의-양자화--어제의-인기-상품이-영원히-1위인-문제">&quot;시간의 양자화&quot; — 어제의 인기 상품이 영원히 1위인 문제</h3>
<p><code>ranking:all</code> 하나의 키에 계속 누적하면 6개월간 누적 50,000점인 상품이 오늘 바이럴 탄 상품(500점)을 절대 이길 수 없다. 이게 롱테일 문제다. 해결은 키를 시간 단위로 분리하는 것 — <code>ranking:all:{yyyyMMdd}</code>.</p>
<p>여기서 TTL을 24시간이 아닌 <strong>48시간</strong>으로 잡은 이유가 처음에는 와닿지 않았다. 과제를 더 읽어보니 두 가지였다:</p>
<ul>
<li>어제 랭킹을 조회할 수 있어야 한다</li>
<li>콜드 스타트 시 전날 키를 carry-over 원본으로 써야 한다</li>
</ul>
<p><strong>콜드 스타트</strong>가 흥미로웠다. 자정이 지나면 새 키 <code>ranking:all:20260411</code>은 비어있다. 새벽 1시에 &quot;오늘의 인기상품&quot;을 요청하면 아무것도 안 뜬다. 그래서 23:50에 스케줄러로 전날 점수의 10%만 새 키에 복사해둔다.</p>
<pre><code class="language-redis">ZUNIONSTORE ranking:all:20260411 1 ranking:all:20260410 WEIGHTS 0.1</code></pre>
<p>왜 자정이 아니라 23:50인가? <code>ZUNIONSTORE</code>는 목적지 키를 <strong>통째로 덮어쓰기</strong> 때문이다. 만약 0:00:00에 실행하면, 자정 직후 유입된 이벤트 점수가 덮어씌워진다. 23:50에는 아직 내일 키에 이벤트가 없으니까 안전하게 carry-over만 넣을 수 있다.</p>
<p>carry-over 비율도 고민했다. 90%면 어제랑 거의 같은 랭킹이 나오고(롱테일 재발), 0%면 새벽에 빈 화면이다. 10%는 새벽 화면을 채워주면서도 오전 중으로 오늘 활동이 역전 가능한 수준이라는 판단이었다. 솔직히 이건 정밀한 계산이 아니라 발제 자료의 권장(0.05~0.1)을 따른 거라, 실험으로 검증해봐야 할 부분이다.</p>
<h3 id="db-redis-정합성--의도적으로-포기하는-정확도">DB-Redis 정합성 — 의도적으로 포기하는 정확도</h3>
<p>랭킹 점수 갱신은 Kafka Consumer 안에서 이뤄진다. <code>processRecord()</code>에 <code>@Transactional</code>이 걸려있는데, Redis ZINCRBY는 이 트랜잭션에 포함되지 않는다. DB 저장은 성공했는데 Redis가 실패하면?</p>
<p>학습 Q&amp;A에서 처음에는 &quot;치명적이지&quot;라고 답했다. 하지만 &quot;결제 금액은 1원이라도 틀리면 문제인데, 랭킹도 그 수준인가?&quot;라는 질문에 생각이 바뀌었다.</p>
<table>
<thead>
<tr>
<th>데이터</th>
<th>정합성 요구</th>
<th>전략</th>
</tr>
</thead>
<tbody><tr>
<td>결제 금액</td>
<td>1원도 틀리면 안 됨</td>
<td>Strong Consistency</td>
</tr>
<tr>
<td>재고 수량</td>
<td>초과 판매 방지</td>
<td>Pessimistic Lock</td>
</tr>
<tr>
<td><strong>랭킹 순위</strong></td>
<td><strong>3위가 잠깐 4위여도 모름</strong></td>
<td><strong>Eventual Consistency</strong></td>
</tr>
</tbody></table>
<p>2PC(Two-Phase Commit)를 쓰면 매 이벤트마다 prepare → commit 2번 왕복, 느린 쪽에 맞춰지는 성능 저하, Coordinator 장애 시 전체 블로킹. 이 비용을 &quot;3위가 잠깐 4위인 것&quot;을 막기 위해 치를 이유가 없다.</p>
<pre><code class="language-java">public void incrementScore(String key, Long productId, double score) {
    try {
        redisTemplate.opsForZSet().incrementScore(key, productId.toString(), score);
        redisTemplate.expire(key, Duration.ofDays(TTL_DAYS));
    } catch (Exception e) {
        log.warn(&quot;랭킹 점수 업데이트 실패: key={}, productId={}&quot;, key, productId, e);
    }
}</code></pre>
<p>Fire-and-Forget. Redis 실패해도 로그만 남기고 넘어간다. 다음 이벤트에서 자연스럽게 보정된다. &quot;대략 맞으면 되는 데이터&quot;에서 Eventual Consistency를 선택하는 판단 — 이게 이번 주 가장 큰 배움이었다.</p>
<h3 id="n1을-hashmap으로-해결한-조합-패턴">N+1을 HashMap으로 해결한 조합 패턴</h3>
<p>ZSET에는 <code>(productId, score)</code> 쌍만 있다. 랭킹 API 응답에는 상품명, 가격, 브랜드명이 필요하다. 이걸 어떻게 조합할 것인가?</p>
<p>처음에는 &quot;LinkedHashSet?&quot;이라고 답했는데, 정리해보니 더 단순했다:</p>
<ol>
<li>ZSET에서 <code>ZREVRANGE</code>로 Top-N productId 리스트 확보 (순서 보장)</li>
<li>IN절로 상품/브랜드를 한 번에 조회 → <code>Map&lt;Long, Product&gt;</code>에 담기 (순서 무관, O(1) lookup)</li>
<li>ZSET 순서대로 순회하며 <code>map.get(productId)</code>로 매핑</li>
</ol>
<p>LinkedHashMap이 아니라 <strong>일반 HashMap</strong>이면 된다. 순서는 ZSET 결과 리스트가 보장하고, Map은 lookup 전용이니까. 2개 도메인(랭킹 + 상품)을 조합하는 건 Application 레이어(Facade)의 역할이다.</p>
<pre><code class="language-java">Map&lt;Long, ProductModel&gt; productMap = productService.getByIds(productIds);
return rankings.stream()
    .filter(r -&gt; productMap.containsKey(r.productId()))
    .map(r -&gt; {
        ProductModel product = productMap.get(r.productId());
        BrandModel brand = brandMap.get(product.getBrandId());
        return new RankingWithProduct(r.rank(), r.score(), ...);
    })
    .toList();</code></pre>
<h2 id="배운-주요-포인트">배운 주요 포인트</h2>
<h3 id="1-스케일이-다르면-가중치는-의미가-없다">1. 스케일이 다르면 가중치는 의미가 없다</h3>
<p>이번 주 가장 선명한 교훈. <code>289만 × 0.1</code>이 <code>1건 × 0.5</code>를 압도하는 건 가중치의 문제가 아니라 단위(scale)의 문제다. 여러 지표를 합산할 때는 먼저 스케일을 맞추고(정규화), 그 다음에 가중치를 조절해야 의미가 있다. 이건 랭킹뿐 아니라 추천, 검색 등 점수 기반 시스템 전반에 적용되는 원칙이다.</p>
<h3 id="2-데이터의-성격이-정합성-전략을-결정한다">2. 데이터의 성격이 정합성 전략을 결정한다</h3>
<p>결제 → Strong, 재고 → Pessimistic Lock, 랭킹 → Eventual Consistency. 모든 데이터에 같은 수준의 정합성을 요구하면 시스템이 불필요하게 복잡해진다. &quot;이 데이터가 잠깐 틀리면 비즈니스에 얼마나 영향이 있는가?&quot;를 먼저 판단하고, 그에 맞는 전략을 선택하는 게 실무적 사고라는 걸 배웠다.</p>
<h3 id="3-zset은-진짜-만능이다">3. ZSET은 진짜 만능이다</h3>
<p>8주차에서는 대기열(ZADD NX + ZPOPMIN), 9주차에서는 랭킹(ZINCRBY + ZREVRANGE). 같은 Sorted Set인데 완전히 다른 용도다. &quot;Score에 뭘 넣느냐&quot;에 따라 대기열이 되기도 하고 랭킹이 되기도 한다. Redis를 쓸 때 자료구조 선택이 설계의 절반이라는 8주차의 교훈이 이번 주에도 그대로 적용됐다.</p>
<h3 id="4-readwrite-경로-분리의-자연스러움">4. Read/Write 경로 분리의 자연스러움</h3>
<p>commerce-streamer가 Kafka Consumer로 ZSET에 쓰고(Master), commerce-api가 API 조회로 ZSET을 읽는다(Replica). 이게 별도의 설계 결정이 아니라, 멀티 모듈 구조에서 자연스럽게 나온 분리라는 점이 인상적이었다. CQRS를 의도하지 않았는데 구조가 그렇게 된 셈이다.</p>
<h2 id="아쉬웠던-점--다음에-해보고-싶은-것">아쉬웠던 점 &amp; 다음에 해보고 싶은 것</h2>
<h3 id="아쉬운-점">아쉬운 점</h3>
<p><strong>carry-over 비율을 &quot;감&quot;으로 정한 것</strong>이 가장 아쉽다. 10%라는 숫자에 &quot;발제 자료 권장&quot; 이상의 근거가 없다. 8주차에서 배치 크기를 HikariCP 풀에서 도출했던 것처럼, carry-over 비율도 &quot;오전 몇 시까지 오늘 활동이 전날 carry-over를 역전하려면 이벤트 몇 건이 필요한가?&quot;같은 계산으로 뒷받침했어야 했다.</p>
<p><strong>UNLIKE 시 점수 차감 여부</strong>를 결정하지 못한 것도 아쉽다. 현재는 &quot;한번 발생한 관심 시그널은 유효하다&quot;고 판단해서 차감하지 않았는데, 이게 정말 맞는지는 도메인에 따라 다를 것 같다. 좋아요를 눌렀다 취소하는 유저가 많은 서비스라면 차감이 맞을 수 있다.</p>
<p><strong>Ranking Lab을 다 못 만든 것</strong>도 아쉽다. 가중치 슬라이더를 움직이면서 순위 변동을 눈으로 보는 실험 환경을 설계까지 해놓고 구현을 마무리하지 못했다. Score 방식(LINEAR/LOG/COUNT) 전환을 시각적으로 비교할 수 있었으면 블로그 글의 설득력도 더 높았을 텐데.</p>
<h3 id="다음에-해보고-싶은-것">다음에 해보고 싶은 것</h3>
<ul>
<li><strong>29CM식 다차원 랭킹</strong>: 현재는 <code>ranking:all:{date}</code> 하나지만, 성별 × 연령 × 카테고리로 세분화하면 키가 수백 개로 늘어난다. 키 관리, 메모리 예측, TTL 전략이 어떻게 달라지는지 실험해보고 싶다.</li>
<li><strong>Caffeine 로컬 캐시</strong>: 랭킹 목록은 30초 정도 캐싱해도 문제없다. Redis 호출을 줄이면서 응답 속도를 어디까지 끌어올릴 수 있는지 측정해보고 싶다.</li>
<li><strong>Feature Flag 기반 A/B 테스트</strong>: 29CM이 Unleash로 랭킹 알고리즘을 실험하는 구조가 인상적이었다. 유저 세그먼트별로 다른 Score 공식을 적용하고, 클릭률/전환율로 어느 공식이 나은지 비교하는 파이프라인을 만들어보고 싶다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[29CM보다 나은 랭킹 시스템 만들기]]></title>
            <link>https://velog.io/@praesentia-ykm/29CM%EB%B3%B4%EB%8B%A4-%EB%82%98%EC%9D%80-%EB%9E%AD%ED%82%B9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@praesentia-ykm/29CM%EB%B3%B4%EB%8B%A4-%EB%82%98%EC%9D%80-%EB%9E%AD%ED%82%B9-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Fri, 10 Apr 2026 08:19:43 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>TL;DR</strong>: &quot;대략 맞으면 되는&quot; 줄 알았던 랭킹 데이터에서 가장 많은 설계 판단이 필요했다.</p>
</blockquote>
<hr>
<h2 id="0-이-순위는-어떻게-정해지는-걸까">0. &quot;이 순위는 어떻게 정해지는 걸까?&quot;</h2>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/d944828e-5bd2-4cb7-82aa-9216bb320577/image.png" alt=""></p>
<p><em>29CM BEST 페이지. 이 순위는 어떻게 정해지는 걸까?</em></p>
<blockquote>
<p>이 글에서 사용한 실험 환경은 Spring Boot + Redis ZSET 기반 학습용 프로젝트입니다. Score 방식/가중치 조합별 순위 변동을 비교했습니다. 29CM API 분석은 브라우저 개발자 도구의 Network 탭을 통해 공개된 요청/응답 구조를 관찰한 것이며, 내부 구현에 대한 단순 추정을 포함하고 있으니 오해 없길 바랍니다!</p>
</blockquote>
<p>29CM의 BEST 탭을 보면 상품들이 순위별로 나열되어 있다. 매일 들어가면서도 한 번도 의심해본 적 없는 화면이었다.</p>
<p><strong>이 순위는 어떻게 정해지는 걸까?</strong></p>
<p>단순히 판매량 순일까? 그렇다면 289만원짜리 르메르 크로와상 백 1개와 3만2천원짜리 포터리 캔들 100개 중 뭐가 위에 와야 할까? 인스타에서 난리인 탬버린즈 퍼퓸과 조회수 30이지만 꾸준히 팔리는 커먼프로젝츠 스니커즈 중 누가 더 &quot;인기 있는&quot; 건가?</p>
<p>스터디에서 Redis ZSET 기반 랭킹 시스템 과제를 받았을 때, 이 궁금증이 다시 떠올랐다. 과제를 시작하기 전에, 실제 서비스는 어떻게 하고 있는지 먼저 뜯어보고 싶었다.</p>
<h3 id="오케이-우선-까보자">오케이 우선 까보자!</h3>
<p>난 개발자니까(?) 궁금한 건 직접 까봐야 직성이 풀린다. 그래서 F12를 눌렀다. BEST 페이지의 Network 탭을 보니 <code>display-bff-api</code>라는 BFF 서버로 POST 요청이 가고 있었다.</p>
<pre><code class="language-json">{
  &quot;facets&quot;: {
    &quot;periodFacetInput&quot;: { &quot;type&quot;: &quot;HOURLY&quot; },
    &quot;rankingFacetInput&quot;: { &quot;type&quot;: &quot;POPULARITY&quot; }
  },
  &quot;userSegment&quot;: { &quot;gender&quot;: &quot;F&quot;, &quot;age&quot;: &quot;THIRTIES&quot; }
}</code></pre>
<p>이걸 보고 좀 놀랐다. 내가 생각한 랭킹은 &quot;전체 상품에 점수 매기고 정렬&quot;이라는 단일 축이었는데, 29CM은 <strong>5가지 차원</strong>으로 랭킹을 쪼개고 있었다.</p>
<table>
<thead>
<tr>
<th>차원</th>
<th>옵션</th>
</tr>
</thead>
<tbody><tr>
<td>기간</td>
<td>실시간 / 일간 / 주간 / 월간</td>
</tr>
<tr>
<td>정렬</td>
<td><strong>인기순</strong> / <strong>판매순</strong></td>
</tr>
<tr>
<td>성별</td>
<td>여성 / 남성</td>
</tr>
<tr>
<td>연령</td>
<td>전체 / 20대 / 30대 / ...</td>
</tr>
<tr>
<td>카테고리</td>
<td>전체 / 여성의류 / 남성가방 등 21개</td>
</tr>
</tbody></table>
<p>특히 눈에 띈 건 두 가지였다.</p>
<p><strong>첫째, &quot;인기순(POPULARITY)&quot;과 &quot;판매순(SALES)&quot;이 분리되어 있다.</strong> 나는 이 둘을 하나의 가중치 공식으로 섞으려고 했는데, 29CM은 아예 별개의 랭킹으로 취급하고 있었다. 인기와 판매는 섞는 게 아니라 나누는 거였나?</p>
<p><strong>둘째, 응답에 score 필드가 없다.</strong> <code>rank</code>, <code>score</code>, <code>weight</code> 같은 필드가 하나도 없다. 배열의 인덱스가 곧 순위다. 서버에서 점수를 계산하고, 정렬된 결과만 내려주고, 클라이언트에는 &quot;이 순서대로 보여줘&quot;만 전달한다. 랭킹 알고리즘이 완전히 서버에 캡슐화되어 있는 셈이다.</p>
<p>그리고 <code>flag.29cm.co.kr</code>이라는 Feature Flag 서버(Unleash)로 요청이 가는 것도 보였다. <del>주제에 벗어날 수 있지만 궁금함을 못 참고 찾아보니까 랭킹 알고리즘을 A/B 테스트때 비교 실험을 위해 사용한다고 한다ㅎㅎ</del> 알고리즘을 바꿔도 프론트 배포 없이 서버에서 스위칭할 수 있는 구조로 보인다.</p>
<p>여기까지 뜯어보고 나니 궁금증이 구체적으로 정리됐다. POPULARITY와 SALES를 왜 분리했을까? 시간 단위는 왜 4가지나 필요할까? 점수를 왜 클라이언트에 안 보여줄까?</p>
<p>답을 머리로만 추측하기보단, 직접 만들어보면서 부딪혀야 체감할 수 있을 것 같았다. 간단한 학습 프로젝트를 하나 세팅하고, 29CM에서 떠오른 궁금증들을 하나씩 실험해보기로 했다.</p>
<hr>
<h2 id="1-score-설계--같은-데이터로-1위가-세-번-바뀌었다">1. Score 설계 — 같은 데이터로 1위가 세 번 바뀌었다</h2>
<h3 id="상황-zincrby에-뭘-넘길-것인가">상황: ZINCRBY에 뭘 넘길 것인가</h3>
<p>랭킹 시스템을 만들려면 가장 먼저 답해야 하는 질문이 있다. &quot;점수를 어떻게 계산할 것인가?&quot;</p>
<p>Redis ZSET 기반 랭킹의 핵심은 결국 이 한 줄이다.</p>
<pre><code>ZINCRBY ranking:all:20260410 ??? productId</code></pre><p>이 <code>???</code> 자리에 뭘 넣느냐. 29CM의 POPULARITY가 내부적으로 어떤 계산을 하는지는 알 수 없지만, 최소한 &quot;조회 = 0.1점, 좋아요 = 0.3점, 주문 = 금액 × 0.5&quot; 같은 가중치 합산 방식은 있을 거라고 가정했다. 실험 프로젝트에서 이 가정을 검증해보기로 했다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/b5320ed5-362e-4189-abbd-47c9917c043c/image.png" alt=""></p>
<p><em>이벤트 1건이 ZINCRBY 명령 하나로 변환된다. <code>View 1건 × weight(0.10) = 0.1000</code> — 이 0.1이 어떤 의미를 가지는지가 이 글의 핵심이다.</em></p>
<p>29CM처럼 다양한 성격의 상품이 섞인 상황을 만들기 위해, 20개 상품을 6가지 프로파일로 분류했다.</p>
<pre><code>STAR(르메르 백, 아크네 머플러)        — 조회 200 / 좋아요 40 / 주문 15  ← 고가 디자이너, 꾸준히 팔림
RISING(메종키츠네 니트, 가니 원피스)    — 조회 350 / 좋아요 60 / 주문 5   ← 떠오르는 브랜드, 구매 고민 중
VIRAL(탬버린즈 퍼퓸, 논픽션 향수)      — 조회 500 / 좋아요 80 / 주문 3   ← 인스타 바이럴, 구매 전환 낮음
STEADY_SELLER(커먼프로젝츠, 에어팟)    — 조회 30 / 좋아요 5 / 주문 25   ← 조용히 꾸준히 팔림
VALUE_HIT(무신사 콜라보, 뉴발란스)     — 조회 40 / 좋아요 8 / 주문 35   ← 가성비 한정판, 주문 많음
LONGTAIL(빈티지 안경테, 핸드메이드)    — 조회 10 / 좋아요 2 / 주문 1    ← 니치 취향</code></pre><p>이 비율로 4,069건의 이벤트를 생성하고, <strong>가중치는 동일</strong>(View 0.2, Like 0.3, Order 0.5)하게 고정한 채 <strong>Score 계산 방식만</strong> 바꿔봤다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/9c3a002f-7cd6-4ee0-b860-3bd4862f359a/image.png" alt=""></p>
<p><em>같은 가중치에 Score Type만 LINEAR / LOG / COUNT로 달리 설정하고 Compare.</em></p>
<h3 id="고민-1-금액을-그대로-쓰면-안-되나">고민 1: 금액을 그대로 쓰면 안 되나?</h3>
<p>처음에는 당연히 금액을 그대로 넣었다. <code>ORDER Score = price × quantity × weight</code>. 주문금액이 크면 점수가 높고, 그게 인기 아닌가? 결과를 보고 생각이 바뀌었다.</p>
<table>
<thead>
<tr>
<th align="right">순위</th>
<th>상품</th>
<th>프로파일</th>
<th align="right">총점</th>
<th>주문 점수 비중</th>
</tr>
</thead>
<tbody><tr>
<td align="right">1</td>
<td>르메르 크로와상 백</td>
<td>STAR</td>
<td align="right">21,675,040</td>
<td><strong>99.99%</strong></td>
</tr>
<tr>
<td align="right">2</td>
<td>아크네 머플러</td>
<td>STAR</td>
<td align="right">4,425,041</td>
<td>99.99%</td>
</tr>
<tr>
<td align="right">3</td>
<td>뉴발란스 993</td>
<td>VALUE_HIT</td>
<td align="right">3,346,011</td>
<td>99.99%</td>
</tr>
</tbody></table>
<p>조회 636건을 기록한 탬버린즈 퍼퓸(VIRAL)은 6위에도 못 들었다. 조회 점수 127.2점은 르메르 백의 주문 점수 21,675,000점 앞에서 <strong>0.0006%</strong>다. 조회수가 아무리 높아도, 289만원짜리 가방 하나 사간 기록 앞에서는 존재감이 없다.</p>
<p>29CM BEST에 들어갔는데 르메르 백, 아크네 머플러, 커먼프로젝츠만 쭉 보인다고 생각해보자. 인스타에서 난리인 탬버린즈 퍼퓸은 한참 내려야 나온다. <strong>이게 &quot;인기 상품&quot;인가?</strong></p>
<h3 id="고민-2-그러면-가중치를-바꾸면-되지-않나">고민 2: &quot;그러면 가중치를 바꾸면 되지 않나?&quot;</h3>
<p>우선 가중치를 조정하면 되지 않나? 라는 직관이 먼저 머리에 꽂혔다. 그래서 조회 가중치를 0.9까지 올리고 주문 가중치를 0.1로 내려봤다.</p>
<table>
<thead>
<tr>
<th>설정</th>
<th>View</th>
<th>Like</th>
<th>Order</th>
<th>1위</th>
</tr>
</thead>
<tbody><tr>
<td>구매 중심</td>
<td>0.1</td>
<td>0.1</td>
<td>0.8</td>
<td>르메르 크로와상 백</td>
</tr>
<tr>
<td><strong>관심도 중심</strong></td>
<td><strong>0.5</strong></td>
<td><strong>0.4</strong></td>
<td><strong>0.1</strong></td>
<td><strong>르메르 크로와상 백</strong></td>
</tr>
<tr>
<td>균등 배분</td>
<td>0.33</td>
<td>0.34</td>
<td>0.33</td>
<td>르메르 크로와상 백</td>
</tr>
</tbody></table>
<p><strong>TOP 5가 전부 동일했다.</strong> 가중치 슬라이더를 아무리 움직여도 꿈쩍도 안 했다.</p>
<p>왜 그런지 계산하면 바로 보인다. Order 가중치를 0.1로 내려도 <code>289만 × 0.1 = 289,000점</code>. View 가중치를 0.5로 올려도 <code>1건 × 0.5 = 0.5점</code>. <strong>스케일 자체가 다른데 가중치는 비율만 바꾸는 거니까</strong>, 트럭 위에 모래알을 올리는 것과 같다. 가중치를 조절한다는 건 모래알의 크기를 바꾸는 것에 불과했다.</p>
<p>결국 <strong>&quot;가중치를 잘 조정하면 된다&quot;는 내 전제가 틀렸다.</strong> 문제는 가중치가 아니라 스케일이라는 쪽으로 접근할 수 있게 되었다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/e100a366-0f3c-43be-a83b-b61eb96006f5/image.png" alt=""></p>
<p><em>View 가중치를 올리고 Order를 내려도 순위가 동일하다. 금액 스케일이 가중치를 압도한다.</em></p>
<h3 id="해결-정규화--log를-씌우면-어떻게-되나">해결: 정규화 — log를 씌우면 어떻게 되나?</h3>
<p>스케일이 문제라면 스케일을 맞추면 된다. 스케일을 줄일 수 있는 간단한 테크닉인 log를 씌워봤다.</p>
<pre><code>ORDER Score = log(price × quantity) × weight</code></pre><table>
<thead>
<tr>
<th align="right">순위</th>
<th>상품</th>
<th>프로파일</th>
<th align="right">총점</th>
<th align="right">주문건수</th>
</tr>
</thead>
<tbody><tr>
<td align="right">1</td>
<td><strong>뉴발란스 993</strong></td>
<td>VALUE_HIT</td>
<td align="right">244.4</td>
<td align="right">34</td>
</tr>
<tr>
<td align="right">2</td>
<td>무신사 콜라보 재킷</td>
<td>VALUE_HIT</td>
<td align="right">223.3</td>
<td align="right">41</td>
</tr>
<tr>
<td align="right">3</td>
<td>커먼프로젝츠 스니커즈</td>
<td>STEADY_SELLER</td>
<td align="right">208.0</td>
<td align="right">29</td>
</tr>
</tbody></table>
<p><strong>순위가 완전히 뒤집혔다.</strong> 르메르 백은 9위로 밀려났다.</p>
<p><code>log(2,890,000) = 14.9</code> vs <code>log(198,000) = 12.2</code>. 289만원과 19.8만원이 <strong>15 vs 12</strong>로 압축된다. 금액의 &quot;비싸다&quot;는 시그널은 살아있지만, 이제 그 차이가 작아졌기 때문에 <strong>주문 건수 34건 vs 15건</strong>이 순위를 뒤집을 수 있게 된다.</p>
<p>그런데 여기서 또 고민이 생겼다. &quot;비싼 건 원래 더 높아야 하는 거 아닌가?&quot; 맞는 말 같지만, 생각해보면 3만원짜리 포터리 캔들을 100개 사간 것이 289만원짜리 르메르 백 1개보다 더 &quot;인기 있는&quot; 것 아닌가? 매출을 밀어주고 싶은 건 &quot;인기&quot;가 아니라 <strong>비즈니스 의도</strong>다.</p>
<p>둘은 다르다.</p>
<p>그렇다면 금액을 완전히 빼면 어떤 세상이 될까? 한 번 더 밀어봤다.</p>
<pre><code>ORDER Score = 1.0 × weight (금액 무시, 건수만)</code></pre><table>
<thead>
<tr>
<th align="right">순위</th>
<th>상품</th>
<th>프로파일</th>
<th align="right">총점</th>
<th align="right">조회수</th>
</tr>
</thead>
<tbody><tr>
<td align="right">1</td>
<td><strong>탬버린즈 퍼퓸</strong></td>
<td>VIRAL</td>
<td align="right">145.8</td>
<td align="right">636</td>
</tr>
<tr>
<td align="right">2</td>
<td>논픽션 향수</td>
<td>VIRAL</td>
<td align="right">124.6</td>
<td align="right">524</td>
</tr>
<tr>
<td align="right">3</td>
<td>이솝 핸드크림</td>
<td>VIRAL</td>
<td align="right">111.6</td>
<td align="right">441</td>
</tr>
</tbody></table>
<p>VIRAL이 상위를 독식했다. 조회수 636건이지만 주문은 3건뿐인 탬버린즈가 1위. 29CM BEST가 이러면? &quot;사람들이 많이 봤지만 안 사는 상품&quot; 순위가 된다. 이것도 좀 아닌 것 같다는 생각이 들었다.</p>
<h3 id="29cm은-이-문제를-어떻게-풀었을까">29CM은 이 문제를 어떻게 풀었을까?</h3>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/2aa28422-2058-4ba1-b2ee-6f7f17052521/image.png" alt=""></p>
<p><em>같은 데이터, 같은 가중치. Score 방식만 바꿨을 때의 3-way 비교.</em></p>
<table>
<thead>
<tr>
<th>Score 방식</th>
<th>1위</th>
<th>이 랭킹이 의미하는 것</th>
</tr>
</thead>
<tbody><tr>
<td>LINEAR</td>
<td>르메르 크로와상 백 (STAR)</td>
<td><strong>매출 기여도</strong> 순위</td>
</tr>
<tr>
<td>LOG</td>
<td>뉴발란스 993 (VALUE_HIT)</td>
<td><strong>구매 인기</strong> 순위</td>
</tr>
<tr>
<td>COUNT</td>
<td>탬버린즈 퍼퓸 (VIRAL)</td>
<td><strong>관심도/트렌드</strong> 순위</td>
</tr>
</tbody></table>
<p>같은 데이터에서 1위가 세 번 바뀌었다. 어느 방식이 &quot;정답&quot;인지는 엔지니어가 정할 수 있는 게 아니다.</p>
<p>그런데 여기서 §0에서 살펴본 29CM의 API가 떠올랐다. 29CM은 이 문제를 <strong>&quot;하나의 공식에 섞는&quot; 대신 &quot;인기순(POPULARITY)&quot;과 &quot;판매순(SALES)&quot;을 분리</strong>해서 해결하고 있었다. 인기와 매출을 가중치로 타협하지 않고, 아예 별개의 랭킹으로 제공한다. 사용자가 탭 하나로 전환할 수 있게.</p>
<p>내 실험에서 LINEAR(매출)와 COUNT(관심도)가 완전히 다른 세계를 보여줬으니까, 이걸 하나로 섞는 것 자체가 어쩌면 잘못된 접근이었던 거다. 29CM의 POPULARITY가 내부적으로 어떤 Score 방식을 쓰는지는 알 수 없지만, 적어도 SALES와 분리한 건 현명한 판단이라고 느꼈다.</p>
<p>그리고 29CM이 Feature Flag(Unleash)를 쓰고 있다는 것도 시사점이 있다. Score 방식은 한 번 정하면 끝이 아니라 <strong>계속 실험하고 바꿀 수 있는 구조</strong>여야 한다는 뜻이다. A/B 테스트로 POPULARITY에 LOG를 쓸지 COUNT를 쓸지를 데이터 기반으로 결정할 수 있다. 코드 배포 없이 서버에서 스위칭하면 되니까.</p>
<p>결국 <strong>&quot;인기 = 무엇?&quot;은 한 번의 합의로 끝나는 게 아니라, 지속적으로 실험하며 조정하는 것</strong>이었다. 처음에 &quot;가중치만 잘 잡으면 되겠지&quot; 하고 가볍게 생각했던 게 좀 부끄러웠다.</p>
<hr>
<h2 id="2-저장소-선택--왜-redis-zset인가">2. 저장소 선택 — 왜 Redis ZSET인가</h2>
<p>Score 공식이 정해졌다고 끝이 아니다. 이 점수는 이벤트가 발생할 때마다 갱신되고, 사용자가 BEST 페이지를 열 때마다 정렬된 결과로 조회되어야 한다. <strong>&quot;계산된 점수를 어디에 저장하고, 어떻게 꺼내올 것인가&quot;</strong> — 이제 저장소를 고를 차례다.</p>
<h3 id="고민-rdb로-하면-안-되나">고민: RDB로 하면 안 되나?</h3>
<p>처음 떠오른 건 당연히 RDB였다. <code>product_metrics</code> 테이블에 <code>view_count</code>, <code>like_count</code>, <code>sale_amount</code> 컬럼이 있으니까. <code>SELECT * FROM product_metrics ORDER BY score DESC LIMIT 20</code>이면 끝 아닌가?</p>
<p>문제는 <code>score</code>가 <strong>계산된 값</strong>이라는 거다.</p>
<pre><code>score = view * 0.2 + like * 0.3 + log(order_amount) * 0.5</code></pre><p>이걸로는 인덱스를 걸 수가 없다. &quot;그러면 <code>total_score</code> 컬럼을 만들어서 미리 계산해두면?&quot; — 물론 가능하다. 실제로 트래픽이 적은 서비스라면 이 방식으로 충분하다.</p>
<p>그런데 이벤트가 올 때마다 <code>UPDATE product_metrics SET total_score = ... WHERE product_id = ?</code>를 해야 한다. 이 UPDATE는 행 락을 건다. 29CM BEST 페이지를 수십만 명이 동시에 보고 있는데, 누군가 상품을 조회할 때마다 UPDATE가 발생한다? 은행 창구가 하나인데 잔고 조회하는 사람과 입금하는 사람이 같은 줄에 서 있는 꼴이다.</p>
<p>&quot;그러면 Materialized View는?&quot; — 주기적으로 갱신하는 방식이라 실시간성이 떨어진다. 5분 간격으로 갱신하면 5분 동안은 바이럴 상품이 반영이 안 된다. 30초 간격이면 DB 부하가 올라간다. 어디서 타협할 것인가가 또 고민이 된다.</p>
<h3 id="해결-zset--쓰는-순간-정렬이-끝나는-구조">해결: ZSET — 쓰는 순간 정렬이 끝나는 구조</h3>
<p>Redis ZSET은 이 구조를 근본적으로 바꾼다.</p>
<table>
<thead>
<tr>
<th></th>
<th>RDB (computed column)</th>
<th>Redis ZSET</th>
</tr>
</thead>
<tbody><tr>
<td>점수 갱신</td>
<td><code>UPDATE</code> (행 락, O(1)이지만 락 경쟁)</td>
<td><code>ZINCRBY</code> (O(logN), 락 없음)</td>
</tr>
<tr>
<td>정렬 조회</td>
<td><code>ORDER BY</code> (인덱스 있어도 락 대기 가능)</td>
<td><code>ZREVRANGE</code> (이미 정렬된 상태)</td>
</tr>
<tr>
<td>동시성</td>
<td>락 경쟁</td>
<td>단일 스레드, 자연스러운 직렬화</td>
</tr>
<tr>
<td>실시간성</td>
<td>UPDATE 즉시 반영이지만 락 경쟁</td>
<td>ZINCRBY 즉시 반영, 락 경쟁 없음</td>
</tr>
</tbody></table>
<p><code>ZINCRBY</code>를 실행하면 점수 증가와 동시에 Skip List에서 위치가 재정렬된다. 조회 시에는 이미 정렬된 결과를 꺼내기만 하면 된다. 입금하는 순간 잔액이 정렬까지 끝나는 구조다.</p>
<h3 id="트레이드오프-메모리와-영속성">트레이드오프: 메모리와 영속성</h3>
<p>ZSET을 선택하면 포기하는 것도 있다.</p>
<p><strong>메모리</strong>: ZSET의 member(상품 ID) + score(점수)가 전부 메모리에 올라간다. 상품 20개면 문제없지만, 10만 개 상품이라면? member당 대략 64바이트 내외로, 10만 개 기준 약 6MB. 이 정도면 Redis 메모리에서 문제가 되지는 않는다. 다만 29CM처럼 성별 × 연령 × 카테고리별 랭킹을 각각 관리하면 키 수가 늘어난다. 성별 2 × 연령 5 × 카테고리 21 × 기간 4 = <strong>840개 키</strong>. 이건 서비스 규모에 따라 판단할 부분이다.</p>
<p><strong>영속성</strong>: Redis가 죽으면 랭킹 데이터가 날아간다. 이건 좀 무서운 시나리오인데, 생각해보면 <code>product_metrics</code> 테이블이 RDB에 있으니까 ZSET은 언제든 재구축이 가능하다. RDB가 <strong>Source of Truth</strong>이고, ZSET은 <strong>조회 최적화된 캐시</strong>인 셈이다. Redis가 날아가면 잠깐 랭킹이 비지만, 새 이벤트가 들어오면서 자연스럽게 다시 쌓인다. 즉시 복구가 필요하면 RDB 기반으로 배치 재구축을 돌리면 된다.</p>
<p>29CM이 API 응답에 score를 노출하지 않는 것도 이 맥락에서 이해가 된다. 점수 자체는 내부 구현이고, 언제든 알고리즘을 바꿀 수 있다. 클라이언트는 &quot;이 순서대로 보여줘&quot;만 알면 된다. 저장소가 ZSET이든 RDB든 Elasticsearch든, 프론트는 영향 없다.</p>
<p>결국 ZSET은 &quot;정렬 + 실시간 갱신&quot;에 최적화된 대신 영속성을 RDB에 위임하는 구조다. 이걸 이해하고 나면 &quot;Redis 날아가면 어떡해?&quot;라는 걱정이 좀 줄어든다.</p>
<hr>
<h2 id="3-key-설계--시간을-어떻게-다룰-것인가">3. Key 설계 — 시간을 어떻게 다룰 것인가</h2>
<p>저장소로 ZSET을 선택했으니, 이제 구체적인 사용법을 정해야 한다. ZINCRBY로 점수를 넣는 건 해결됐고, 다음 질문은 <strong>&quot;어떤 키에 넣을 것인가&quot;</strong>다. 이 키 설계에서 시간이라는 변수가 들어온다.</p>
<h3 id="상황-어제의-인기-상품이-영원히-1위">상황: 어제의 인기 상품이 영원히 1위</h3>
<p><code>ranking:all</code> <strong>하나의 키에 점수를 계속 누적</strong>하면 어떻게 되나?</p>
<p>29CM에서 6개월간 누적 조회 50,000을 기록한 르메르 백과, 오늘 하루 인스타 바이럴을 타서 500을 기록한 탬버린즈 퍼퓸이 있다고 치자. 르메르가 항상 위에 있다. <strong>오늘의 트렌드를 보여줘야 하는 BEST 페이지에서 이건 문제다.</strong></p>
<p>도서관 인기 도서 랭킹을 개관 이후 전체 대출 횟수로 매기는 것과 같다. 해리포터가 20년간 1위를 독식하고, 이번 주 화제작은 영원히 상위에 오를 수 없다.</p>
<h3 id="고민-시간-윈도우를-어떤-단위로-끊을-것인가">고민: 시간 윈도우를 어떤 단위로 끊을 것인가</h3>
<p>키를 분리하면 된다는 건 금방 떠올렸다. 문제는 <strong>어떤 단위로 끊느냐</strong>다.</p>
<p>29CM의 API를 다시 보니 답의 힌트가 있었다. <code>periodFacetInput.type</code>이 <strong>HOURLY / DAILY / WEEKLY / MONTHLY</strong> 4가지를 지원한다. 시간 단위를 하나만 고르는 게 아니라 <strong>여러 단위를 동시에 제공</strong>하고 있었다.</p>
<p>각 단위의 트레이드오프를 생각해봤다.</p>
<p><strong>시간별 키</strong> (<code>ranking:all:2026041015</code>): 트렌드 반영이 가장 빠르다. 근데 오후 3시에 BEST 페이지를 열면 3시 키에 데이터가 10건밖에 없다. 20개 상품 중 이벤트가 들어온 3개만 랭킹에 뜨고 나머지는 빈 화면. 29CM이 HOURLY를 지원하는 건 아마 충분한 트래픽이 있으니까 가능한 거다.</p>
<p><strong>주간 키</strong> (<code>ranking:all:2026W15</code>): 안정적이지만 월요일에 터진 바이럴 상품이 금요일에야 상위에 올라온다. 트렌드를 반영하기엔 느리다.</p>
<p><strong>일별 키</strong> (<code>ranking:all:20260410</code>): 하루 동안의 이벤트가 충분히 쌓이면서도, 어제의 트렌드가 오늘을 지배하지 않는다. 학습 프로젝트 규모(20개 상품)에서는 이게 가장 적절한 단위라고 판단했다.</p>
<p>29CM은 4가지를 다 제공하지만, 그건 트래픽 규모가 있으니까 가능한 거고, 서비스 초기나 소규모 서비스에서는 일별 하나로 시작하는 게 현실적이다. 필요하면 나중에 주간/월간을 추가하면 된다.</p>
<h3 id="해결-일별-키--ttl--콜드-스타트-보완">해결: 일별 키 + TTL + 콜드 스타트 보완</h3>
<pre><code>ranking:all:20260410  ← 오늘
ranking:all:20260409  ← 어제
ranking:all:20260408  ← 그제 (TTL 만료 → 자동 삭제)</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/2ea99785-5ebb-4233-b1a4-f2c81624bc33/image.png" alt=""></p>
<p><em>일별 키 <code>ranking:all:20260410</code>. TTL은 약 48시간.</em></p>
<p>TTL을 왜 24시간이 아니라 <strong>48시간</strong>으로 잡았냐면, 두 가지 이유가 있다.</p>
<p>첫째, &quot;어제의 BEST&quot;를 조회할 수 있어야 한다. &quot;어제 뭐가 인기 있었지?&quot;라는 질문에 답하려면 어제 키가 남아있어야 한다.</p>
<p>둘째, <strong>콜드 스타트</strong> 때문이다. 자정이 되면 <code>ranking:all:20260411</code>이 새로 생기는데, 당연히 비어있다. 새벽 1시에 &quot;오늘의 인기상품&quot;을 열면 아무것도 안 뜬다. 이 빈 랭킹 상태가 새벽 내내 계속된다.</p>
<p>해결책으로 <strong>ZUNIONSTORE</strong>를 써서 전날 점수의 일부를 다음날 키에 복사한다.</p>
<pre><code class="language-redis">ZUNIONSTORE ranking:all:20260411 1 ranking:all:20260410 WEIGHTS 0.1</code></pre>
<h3 id="트레이드오프-carry-over-비율과-실행-시점">트레이드오프: carry-over 비율과 실행 시점</h3>
<p>이 10%라는 숫자도 고민이 됐다.</p>
<p><strong>0%면?</strong> 자정에 완전 리셋. 깔끔하지만 새벽 내내 빈 랭킹이다. 29CM이 새벽에도 접속자가 꽤 있는 서비스라면 이건 좀 곤란하다.</p>
<p><strong>50%면?</strong> 어제 1위가 오늘도 거의 1위로 시작한다. 콜드 스타트 문제는 없지만, 그러면 &quot;오늘의 트렌드&quot;를 보여주겠다면서 어제의 관성이 반나절을 지배한다. 키를 분리한 의미가 퇴색된다.</p>
<p><strong>10%면?</strong> 새벽에 빈 화면은 피하면서도, 오전 중으로 오늘의 이벤트가 어제의 잔재를 충분히 밀어낸다. 정확한 숫자는 서비스의 트래픽 패턴에 따라 조정해야 한다. 새벽 트래픽이 거의 없는 서비스라면 5%로도 충분하고, 24시간 트래픽이 고른 서비스라면 20%가 나을 수도 있다.</p>
<p>실행 시점을 <strong>자정이 아니라 23:50</strong>으로 잡은 이유도 있다. <code>ZUNIONSTORE</code>는 목적지 키를 <strong>통째로 덮어쓰기</strong> 때문이다. 만약 자정 이후 00:05에 실행하면, 0:00~0:05 사이에 새벽 쇼핑족이 발생시킨 이벤트가 이미 내일 키에 쌓여있는데, ZUNIONSTORE가 그걸 덮어써버린다. 10분 먼저 실행하면 아직 내일 키에 이벤트가 없으니 안전하다.</p>
<hr>
<h2 id="4-db-redis-정합성--의도적으로-포기하는-정확도">4. DB-Redis 정합성 — 의도적으로 포기하는 정확도</h2>
<p>여기까지 오면 파이프라인 전체가 보인다. Score 공식이 정해졌고(§1), ZSET에 저장하기로 했고(§2), 일별 키로 시간을 끊는다(§3). 이제 남은 건 <strong>이벤트가 발생했을 때 데이터가 실제로 흘러가는 경로</strong>다. 여기서 두 저장소 — RDB와 Redis — 사이의 정합성 문제가 등장한다.</p>
<h3 id="상황-한-이벤트-두-곳에-쓰기">상황: 한 이벤트, 두 곳에 쓰기</h3>
<p>사용자가 상품을 조회하거나 구매하면:</p>
<ol>
<li>DB의 <code>product_metrics</code>에 기록 (<code>@Transactional</code>)</li>
<li>Redis ZSET에 <code>ZINCRBY</code> 실행</li>
</ol>
<p>문제는, Redis 연산은 Spring <code>@Transactional</code> 밖에 있다는 것이다.</p>
<h3 id="고민-db는-성공했는데-redis가-실패하면">고민: DB는 성공했는데 Redis가 실패하면?</h3>
<p>이런 시나리오를 생각해봤다. 탬버린즈 퍼퓸에 View 이벤트 100건이 들어왔는데, 그 중 3건에서 Redis 커넥션이 순간적으로 끊겼다고 치자.</p>
<p>DB에는 View 100건이 정확히 기록됐지만, ZSET에는 97건만 반영됐다. 탬버린즈의 ZSET 점수가 실제보다 0.3점 낮다 (3건 × 0.1). 전체 점수가 145.8인 상황에서 0.3점 — <strong>0.2% 오차</strong>다.</p>
<p>이게 문제가 되나? 결제 금액이었다면 3건 누락은 난리가 나겠지만, 랭킹에서 탬버린즈가 1위 대신 잠깐 2위가 됐다가 다음 이벤트 몇 건 들어오면 자연스럽게 복구된다.</p>
<p>처음에는 &quot;분산 트랜잭션으로 묶어야 하나?&quot; 싶었다. 2PC(Two-Phase Commit)를 쓰면 DB와 Redis가 항상 일치하도록 보장할 수 있다. 그런데 2PC의 비용을 생각해보면:</p>
<ul>
<li>매 이벤트마다 prepare → commit 2번 왕복</li>
<li>Redis나 DB 어느 한쪽이 느려지면 전체가 느려짐 (가장 느린 참가자 속도에 수렴)</li>
<li>Coordinator 장애 시 전체 블로킹</li>
</ul>
<p><strong>0.2% 오차를 막겠다고 모든 이벤트에 이 비용을 지불할 것인가?</strong></p>
<h3 id="해결-eventual-consistency--대략-맞으면-된다">해결: Eventual Consistency — &quot;대략 맞으면 된다&quot;</h3>
<table>
<thead>
<tr>
<th>데이터</th>
<th>정합성 요구</th>
<th>전략</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>결제 금액</td>
<td>1원도 틀리면 안 됨</td>
<td>Strong Consistency</td>
<td>돈은 정확해야 한다</td>
</tr>
<tr>
<td>재고 수량</td>
<td>초과 판매 방지</td>
<td>Pessimistic Lock</td>
<td>마이너스 재고는 사고다</td>
</tr>
<tr>
<td><strong>랭킹 순위</strong></td>
<td><strong>대략 맞으면 됨</strong></td>
<td><strong>Eventual Consistency</strong></td>
<td>3위가 잠깐 4위여도 아무도 모른다</td>
</tr>
</tbody></table>
<p>Redis 실패 시 로그만 남기고 넘어간다. 다음 이벤트에서 자연스럽게 보정된다. 도서관 인기 순위를 집계하는데 은행 수준의 정합성을 요구하는 건 오버다.</p>
<h3 id="트레이드오프-대략의-범위를-정하기">트레이드오프: &quot;대략&quot;의 범위를 정하기</h3>
<p>다만 &quot;대략 맞으면 된다&quot;가 모든 실패를 무시해도 된다는 뜻은 아니다.</p>
<p>Redis가 <strong>3건</strong> 누락하는 건 괜찮다. 근데 Redis가 <strong>1시간</strong> 동안 죽어있었다면? 그 사이 수천 건의 이벤트가 ZSET에 반영 안 됐다면 랭킹이 심하게 어긋난다.</p>
<p>그래서 두 가지 안전장치를 생각했다.</p>
<p><strong>1) 실패 로그 모니터링</strong>: Redis ZINCRBY 실패율이 일정 임계값(예: 분당 10건)을 넘으면 알림을 쏜다. 단건 실패는 무시하되, 연속 실패는 Redis 장애 징후이므로 대응이 필요하다.</p>
<p><strong>2) 정기 보정 배치</strong>: 하루에 한 번, RDB의 <code>product_metrics</code> 기준으로 ZSET 점수를 재계산하는 배치를 돌린다. 이러면 누적 오차가 24시간 이상 지속되지 않는다. 어차피 일별 키를 쓰니까, 새 키를 만들 때 RDB 기준으로 초기화하면 자연스럽게 보정된다.</p>
<p>결국 Eventual Consistency의 핵심은 <strong>&quot;얼마나 eventual인가&quot;의 범위를 명시적으로 정하는 것</strong>이다. &quot;대략 맞으면 됨&quot;이 면죄부가 아니라, &quot;단건 실패는 허용하되, 1시간 이상의 불일치는 자동 복구한다&quot;처럼 구체적인 기준이 필요하다.</p>
<hr>
<h2 id="마무리--결국-29cm보다-나은-랭킹-시스템은-만들지-못했다">마무리- 결국 29CM보다 나은 랭킹 시스템은 만들지 못했다</h2>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/acdbd2d3-7151-44a4-ac2b-aaa2d5de02e5/image.png" alt=""></p>
<p><em>학습용 프로젝트의 최종 랭킹 보드. 20개 상품 × 4,069건 이벤트의 결과.</em></p>
<p>솔직히 말하면, 29CM보다 나은 랭킹 시스템은 만들지 못했다. 애초에 만들 수 있을 거라 생각한 것 자체가 문제였다.</p>
<p>처음에 F12를 눌렀을 때는 &quot;별거 아니네, 나도 만들 수 있겠는데?&quot;라는 자신감이 있었다. 가중치 공식 하나 만들고 ZSET에 넣으면 되는 거 아닌가? 그런데 직접 만들어보니, 매 단계에서 생각이 틀렸다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>내가 생각한 것</th>
<th>직접 부딪혀보니</th>
</tr>
</thead>
<tbody><tr>
<td>§1 Score 설계</td>
<td>가중치만 잘 잡으면 된다</td>
<td>스케일이 안 맞으면 가중치는 무의미했다</td>
</tr>
<tr>
<td>§1 랭킹 분리</td>
<td>인기와 매출을 하나의 공식으로 섞으면 된다</td>
<td>29CM이 POPULARITY와 SALES를 분리한 이유를 체감했다</td>
</tr>
<tr>
<td>§3 시간 단위</td>
<td>시간 단위 하나만 정하면 된다</td>
<td>HOURLY/DAILY/WEEKLY/MONTHLY 4가지가 필요한 이유가 있었다</td>
</tr>
<tr>
<td>§0 세분화</td>
<td>전체 랭킹 하나면 된다</td>
<td>성별 × 연령 × 카테고리 세분화 없이는 의미 있는 &quot;인기&quot;를 정의할 수 없었다</td>
</tr>
<tr>
<td>§4 정합성</td>
<td>DB-Redis 정합성을 완벽히 맞춰야 한다</td>
<td>3위가 잠깐 4위여도 아무도 모른다는 걸 깨달았다</td>
</tr>
</tbody></table>
<p>29CM이 5차원으로 랭킹을 쪼갠 건 과잉 설계가 아니었다. 하나의 Score 공식으로 &quot;인기 상품&quot;을 정의하려는 순간 매출과 관심도가 충돌하고, 시간 윈도우 하나로 &quot;오늘의 트렌드&quot;를 잡으려는 순간 서비스마다 적절한 단위가 다르다. <strong>&quot;인기&quot;라는 질문 자체가 단일 답을 가질 수 없기 때문에</strong>, 5차원으로 쪼갤 수밖에 없었던 거다. 그 안에서 아마 내가 모르는 수많은 테스트가 있었을 거 같다. A/B테스트나 뭔가 다양한 수치를 잡을 수 있는 방법론이 있을 거 같다는 생각도 든다.</p>
<p><strong>결국 랭킹 시스템 설계 판단에는 항상 &quot;이 도메인에서 무엇을 포기할 수 있는가?&quot;라는 질문이 먼저라는 것을 느꼇다.</strong> 정확도를 포기할 수 있으면 Eventual Consistency로 시스템이 단순해지고, 금액 스케일을 포기할 수 있으면 log 정규화로 의미 있는 랭킹이 나온다. 시간 정보를 포기하면 키 관리가 단순해지지만 트렌드를 잃는다. 모든 걸 다 잡으려면 29CM처럼 5차원이 되는 전략을 선택할 수도 있고, 그게 비지니스에서 원하는 복잡도 라는 것을 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[WIL - (Redis 기반 대기열 시스템)]]></title>
            <link>https://velog.io/@praesentia-ykm/WIL-Redis-%EA%B8%B0%EB%B0%98-%EB%8C%80%EA%B8%B0%EC%97%B4-%EC%8B%9C%EC%8A%A4%ED%85%9C</link>
            <guid>https://velog.io/@praesentia-ykm/WIL-Redis-%EA%B8%B0%EB%B0%98-%EB%8C%80%EA%B8%B0%EC%97%B4-%EC%8B%9C%EC%8A%A4%ED%85%9C</guid>
            <pubDate>Sun, 05 Apr 2026 13:09:39 GMT</pubDate>
            <description><![CDATA[<h2 id="이번-주에-새로-배운-것">이번 주에 새로 배운 것</h2>
<hr>
<p>이번 주 과제는 &quot;블랙 프라이데이에 주문이 폭주하면 어떻게 할 것인가?&quot;였다. 처음에는 단순히 Rate Limiting으로 요청을 거부하면 되지 않나 싶었는데, 과제를 읽으면서 생각이 바뀌었다.</p>
<p>Rate Limiting은 초과 트래픽에 429를 던지고 끝이다. &quot;나중에 다시 시도하세요.&quot; 선착순 한정 판매 상황에서 이걸 받은 유저는 새로고침을 미친 듯이 누를 거다. 그럼 트래픽은 줄어들기는커녕 오히려 더 늘어난다. 이게 Thundering Herd 문제라는 걸 이번에 처음 제대로 이해했다.</p>
<p>반면 대기열은 &quot;현재 23번째, 약 9초 후 입장&quot;이라고 알려준다. 유저 입장에서 순번이 보이니까 새로고침할 이유가 없다. <strong>거부 vs 줄 세우기</strong> — 이 차이가 단순한 구현 차이가 아니라 유저 행동 자체를 바꾼다는 걸 체감했다.</p>
<h3 id="redis-sorted-set이-대기열에-딱-맞는-이유">Redis Sorted Set이 대기열에 딱 맞는 이유</h3>
<p>대기열의 핵심 요구사항은 세 가지다: <strong>순서 보장, 중복 방지, 원자적 꺼내기</strong>.</p>
<p>Redis Sorted Set은 이 세 가지를 전부 기본 명령어로 해결해준다.</p>
<ul>
<li><code>ZADD NX</code> — 이미 있는 멤버는 추가 안 함 (중복 방지)</li>
<li><code>ZRANK</code> — O(log N)으로 순번 조회</li>
<li><code>ZPOPMIN</code> — 가장 앞에 있는 N명을 원자적으로 꺼냄</li>
</ul>
<p>특히 <code>ZPOPMIN</code>이 인상적이었다. 처음에는 <code>ZRANGE</code>로 조회하고 <code>ZREM</code>으로 삭제하는 2단계로 생각했는데, 이렇게 하면 두 명령 사이에 다른 서버가 같은 유저를 또 꺼내는 Race Condition이 생긴다. <code>ZPOPMIN</code>은 Redis의 단일 스레드 모델 덕분에 이걸 한 방에 해결한다. 분산 락 같은 거 없이도.</p>
<h3 id="score에-systemcurrenttimemillis를-쓴-이유">Score에 System.currentTimeMillis()를 쓴 이유</h3>
<p>Sorted Set의 score로 뭘 쓸지 고민했다. Redis INCR로 시퀀스를 만들면 절대 충돌이 없지만 추가 Redis 호출이 필요하고, UUID는 순서를 보장할 수 없다.</p>
<p>결국 <code>System.currentTimeMillis()</code>를 선택했다. 동일 밀리초에 두 유저가 진입하면 순서가 비결정적이지만, 그 확률이 실무에서 문제가 될 수준인가? 라고 생각하면 아니었다. 추가 인프라 호출 없이 자연스러운 FIFO를 달성할 수 있는 게 더 큰 장점이라고 판단했다.</p>
<h2 id="고민이였던-부분들">고민이였던 부분들</h2>
<hr>
<h3 id="토큰-검증을-어디에-둘-것인가가-가장-오래-고민한-부분이었다">&quot;토큰 검증을 어디에 둘 것인가&quot;가 가장 오래 고민한 부분이었다</h3>
<p>스케줄러가 대기열에서 유저를 꺼내서 토큰을 발급하고, 유저는 그 토큰으로 주문 API에 진입한다. 여기까지는 자연스러운데, 문제는 <strong>토큰 검증과 삭제를 어디에 두느냐</strong>였다.</p>
<p>처음에는 <code>OrderFacade</code>에 <code>QueueTokenService</code>를 주입해서 주문 시작할 때 검증하고, 주문 끝나면 삭제하려고 했다. 근데 이렇게 하면 주문 로직이 대기열 토큰을 알아야 한다. 대기열이 없는 일반 주문 시나리오에서는? 토큰 검증 로직이 걸리적거린다.</p>
<p>결국 <code>HandlerInterceptor</code>를 선택했다. <code>preHandle</code>에서 토큰을 검증하고, <code>afterCompletion</code>에서 주문이 성공하면 토큰을 삭제한다. 이렇게 하면 <code>OrderFacade.placeOrder()</code>는 대기열 토큰의 존재를 전혀 모른다. HTTP 레이어에서 관문 역할을 하고, 비즈니스 로직은 순수하게 유지된다.</p>
<p>여기서 하나 더 고민한 게 있다. <strong>토큰을 preHandle에서 삭제하면 안 되는 이유</strong>다. 만약 토큰을 검증하자마자 삭제해버리면, 주문 처리 중 에러가 나도 토큰은 이미 사라진 상태다. 유저는 재시도할 수 없다. 그래서 <code>afterCompletion</code>에서 <code>status &lt; 400 &amp;&amp; ex == null</code>일 때만 삭제하도록 했다. 실패하면 토큰이 살아있으니 TTL(5분) 안에 재시도할 수 있다.</p>
<h3 id="스케줄러-배치-크기를-감이-아니라-근거로-정하기">스케줄러 배치 크기를 &quot;감&quot;이 아니라 &quot;근거&quot;로 정하기</h3>
<p>&quot;스케줄러가 한 번에 몇 명을 꺼낼 것인가?&quot;를 처음에는 적당히 50명쯤 하려고 했다. 근데 과제에서 <strong>&quot;배치 크기 산정 근거를 문서화하라&quot;</strong>고 해서 진지하게 계산해봤다.</p>
<pre><code>HikariCP 최대 풀: 10
평균 주문 처리 시간: ~200ms
3초 내 이론적 최대 처리: 10 × (3000 / 200) = 150건</code></pre><p>이론적으로는 150건을 처리할 수 있지만, 주문 API 외에 조회 API도 커넥션을 써야 한다. 배치 크기를 150으로 잡으면 3초간 커넥션 풀을 독점하게 된다. 결국 <strong>풀 크기와 동일한 10건</strong>으로 잡았다. 보수적이지만, 다른 API가 굶지 않는다.</p>
<p><code>fixedDelay</code> vs <code>fixedRate</code>도 처음에는 차이를 몰랐다. <code>fixedRate</code>는 이전 실행이 끝나지 않아도 다음 실행을 시작한다. 배치 처리가 3초 이상 걸리면 배치가 겹친다. <code>fixedDelay</code>는 이전 실행이 끝난 후 3초를 기다리니까 겹칠 일이 없다. 대기열 스케줄러에는 <code>fixedDelay</code>가 맞다.</p>
<h3 id="모니터링에서-counter와-gauge-구분">모니터링에서 Counter와 Gauge 구분</h3>
<p>처음에는 전부 Counter로 만들려고 했다. 근데 &quot;지금 대기 중인 인원&quot;을 Counter로 하면 안 된다. Counter는 단조 증가만 한다. 누군가 대기열에서 빠지면 줄어들어야 하는데 Counter는 줄어들 수 없다.</p>
<p>결국 이렇게 나눴다:</p>
<ul>
<li><code>queue.enter.total</code> → Counter (누적 진입 수, 절대 줄지 않음)</li>
<li><code>queue.token.issued.total</code> → Counter (누적 발급 수)</li>
<li><code>queue.waiting.size</code> → Gauge (현재 대기 인원, 줄었다 늘었다 함)</li>
</ul>
<p><strong>&quot;이 값이 줄어들 수 있는가?&quot;</strong> — 이 한 줄짜리 판별 기준이 깔끔하게 나눠줬다.</p>
<h2 id="주요-학습">주요 학습<img src="https://velog.velcdn.com/images/praesentia-ykm/post/3f119c55-c4f3-45ea-99e2-2d5c3cde0583/image.png" alt=""></h2>
<h2 id="포인트"> 포인트</h2>
<h3 id="1-redis-자료구조-선택이-설계의-절반이다">1. Redis 자료구조 선택이 설계의 절반이다</h3>
<p>이번에 Sorted Set, String(+TTL) 두 가지를 썼는데, 자료구조 선택이 끝나면 구현은 거의 자동으로 따라왔다. &quot;대기열은 Sorted Set, 토큰은 String+TTL&quot;이라는 결정 하나가 전체 아키텍처를 결정했다. 앞으로 Redis를 쓸 때 &quot;어떤 자료구조가 이 문제에 맞는가?&quot;를 먼저 고민하는 습관을 들여야겠다.</p>
<h3 id="2-interceptor로-횡단-관심사-분리">2. Interceptor로 횡단 관심사 분리</h3>
<p>토큰 검증은 주문 로직이 아니라 HTTP 관문이다. 이걸 Interceptor로 빼면서 <code>OrderFacade</code>가 깔끔해졌다. 인증/인가, 로깅, 감사 같은 횡단 관심사도 같은 패턴으로 분리할 수 있겠다는 감이 생겼다. <code>preHandle</code>에서 검증, <code>afterCompletion</code>에서 후처리 — 이 라이프사이클을 이해한 게 이번 주 가장 큰 수확이다.</p>
<h3 id="3-원자적이다의-의미를-코드로-체감">3. &quot;원자적이다&quot;의 의미를 코드로 체감</h3>
<p><code>ZPOPMIN</code>이 원자적이라서 분산 락이 필요 없다는 걸 머리로는 알겠는데, 동시성 테스트를 돌려보기 전까지는 확신이 없었다. <code>CountDownLatch</code>로 100개 스레드를 동시에 쏘고, 대기열에 정확히 100명이 들어있는 걸 확인했을 때 비로소 &quot;아, 진짜 되는구나&quot;라는 확신이 생겼다. <strong>테스트 없이 &quot;원자적이니까 괜찮겠지&quot;라고 넘어가면 안 된다</strong>는 교훈.</p>
<h3 id="4-숫자에-근거를-붙이는-습관">4. 숫자에 근거를 붙이는 습관</h3>
<p>배치 크기 10, TTL 300초, 스케줄러 간격 3초 — 이 숫자들을 &quot;적당히&quot; 정하지 않고 근거를 달아본 게 처음이었다. HikariCP 풀 크기에서 배치 크기를 도출하고, 주문 프로세스 소요 시간에서 TTL을 계산하는 과정이 실무에서도 그대로 쓸 수 있는 사고 방식이라고 느꼈다.</p>
<h2 id="아쉬웠던-점--다음에-해보고-싶은-것">아쉬웠던 점 &amp; 다음에 해보고 싶은 것</h2>
<hr>
<h3 id="아쉬운-점">아쉬운 점</h3>
<p><strong>ZPOPMIN과 토큰 발급 사이의 원자성 간극</strong>을 해결하지 못한 게 아쉽다. 현재는 Java 루프 안에서 ZPOPMIN 후 SET+TTL을 순차 실행하는데, 그 사이에 서버가 죽으면 유저가 대기열에서 빠졌지만 토큰을 못 받는 상태가 된다. Lua Script로 두 연산을 원자화할 수 있지만, 이번에는 &quot;간극이 &lt; 1ms이고 실패 시 재진입하면 된다&quot;는 판단으로 넘어갔다. 근거가 있긴 한데, 찝찝함이 남는다.</p>
<p><strong>멀티 서버 환경에서 스케줄러 중복 실행</strong> 문제도 인지하고 있지만 해결하지 못했다. <code>ZPOPMIN</code>이 원자적이라 중복 처리는 없지만, 불필요한 Redis 호출이 서버 수만큼 발생한다. <code>ShedLock</code> 같은 도구로 단일 실행을 보장할 수 있는데, 현재 규모에서 그게 오버엔지니어링인지 기본 세팅인지 아직 판단이 서지 않는다.</p>
<h3 id="다음에-해보고-싶은-것">다음에 해보고 싶은 것</h3>
<p>사실 시간이 부족해서 도전하지 못한 부분들이 많다. 회사에서 SSE를 사용해봤음에도 불구하고 커넥션 풀이나 자원관리에 대해서 깊게 고민하지 못한 포인트도 있다고 생각한다. 그리고 Redis가 죽으면 지금은 서비스 전체가 멈춘다. Redis 장애 시 대기열을 우회해서 직접 주문을 받는 fallback을 구현해보고 싶다. &quot;대기열이 없는 게 대기열이 죽은 것보다 낫다&quot;는 판단이 맞는지도 고민해볼만한 포인트라고 생각한다. 마지막으로 대기 인원에 따라 토큰 TTL을 조절하면 어떨까? 라는 생각이 들었다. 대기 10명일 때와 10,000명일 때 같은 5분이면, 앞사람이 토큰을 안 쓰고 버틸 때 뒷사람에게 미치는 영향이 달라질 거라고 생각이 들어 추가로 고민해볼 포인트 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[아이유 콘서트 티케팅을 만들어보았다 — 100석에 500명이 몰리면 생기는 일]]></title>
            <link>https://velog.io/@praesentia-ykm/%EC%95%84%EC%9D%B4%EC%9C%A0-%EC%BD%98%EC%84%9C%ED%8A%B8-%ED%8B%B0%EC%BC%80%ED%8C%85%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EC%95%98%EB%8B%A4-100%EC%84%9D%EC%97%90-500%EB%AA%85%EC%9D%B4-%EB%AA%B0%EB%A6%AC%EB%A9%B4-%EC%83%9D%EA%B8%B0%EB%8A%94-%EC%9D%BC</link>
            <guid>https://velog.io/@praesentia-ykm/%EC%95%84%EC%9D%B4%EC%9C%A0-%EC%BD%98%EC%84%9C%ED%8A%B8-%ED%8B%B0%EC%BC%80%ED%8C%85%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EC%95%98%EB%8B%A4-100%EC%84%9D%EC%97%90-500%EB%AA%85%EC%9D%B4-%EB%AA%B0%EB%A6%AC%EB%A9%B4-%EC%83%9D%EA%B8%B0%EB%8A%94-%EC%9D%BC</guid>
            <pubDate>Fri, 03 Apr 2026 08:50:38 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/ba79acc6-8e1c-44ca-ad21-a0c79e83277a/image.png" alt=""></p>
<blockquote>
<p><strong>TL;DR</strong>: 에러 없이 정합성이 깨지는 게 가장 무섭다. 그걸 수치로 증명하고, 한 겹씩 고쳐간 기록.</p>
</blockquote>
<hr>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/f39fb47f-373a-40c8-8c4b-d37865520c9d/image.png" alt=""></p>
<h2 id="시작은-단순한-궁금증이었다">시작은 단순한 궁금증이었다</h2>
<p>멜론티켓을 열면 항상 같은 화면을 본다. &quot;현재 대기 인원 23,482명&quot;. 그리고 숫자가 줄어들길 기다리다가 결국 &quot;선택 가능한 좌석이 없습니다&quot;를 마주한다.</p>
<p>그런데 문득 이런 생각이 들었다.</p>
<p><strong>&quot;대기열 없이 그냥 바로 예매하게 하면 안 되나?&quot;</strong></p>
<p>물론 안 된다는 건 알고 있었다. 동시성 문제가 생기니까. 그런데 <em>정확히 어떻게</em> 안 되는 건지, <em>얼마나</em> 안 되는 건지 숫자로 본 적은 없었다. 그래서 직접 만들어보기로 했다. 100석짜리 아이유 콘서트에 500명을 동시에 보내면 어떻게 되는지.</p>
<hr>
<h2 id="실험-환경">실험 환경</h2>
<p>실험이니만큼 환경을 고정했다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>Backend</td>
<td>Spring Boot 3.4.4, Java 21</td>
</tr>
<tr>
<td>DB</td>
<td>MySQL 8.0, HikariCP pool=<strong>10</strong></td>
</tr>
<tr>
<td>부하 도구</td>
<td>k6 (50 VUs, 500 iterations)</td>
</tr>
<tr>
<td>좌석 수</td>
<td><strong>100석</strong></td>
</tr>
</tbody></table>
<p>커넥션 풀을 의도적으로 10개로 잡았다. 실서비스에서도 무한정 늘릴 수 없으니까. 여기서 터지는 걸 직접 보고 싶었다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/c3c23a26-9d2f-41b2-8ed7-c6676aeccebb/image.png" alt=""></p>
<hr>
<h2 id="v1-그냥-만들면-되지">V1. &quot;그냥 만들면 되지&quot;</h2>
<p>가장 단순한 코드부터 시작했다.</p>
<pre><code class="language-java">@Transactional
public Reservation reserve(Long concertId, String userId, int seatNo) {
    Concert concert = concertRepository.findById(concertId)
            .orElseThrow(() -&gt; new IllegalArgumentException(&quot;콘서트를 찾을 수 없습니다.&quot;));

    if (concert.isSoldOut()) {
        throw new IllegalStateException(&quot;매진되었습니다.&quot;);
    }

    concert.decreaseAvailableSeats();
    return reservationRepository.save(Reservation.create(concertId, userId, seatNo));
}</code></pre>
<p><code>findById</code>로 읽고, 잔여석이 있으면 차감하고, 저장한다. 누가 봐도 자연스러운 코드다. 문제는 이게 동시에 50개 스레드에서 돌아간다는 것이다.</p>
<h3 id="k6를-돌렸다">k6를 돌렸다</h3>
<pre><code>50명 동시 접속 → 랜덤 좌석으로 500건 요청 → 100석짜리 콘서트</code></pre><p>결과를 보고 솔직히 좀 놀랐다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/e20e36ce-7491-4a0a-8c0e-cd4e9108f945/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>지표</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>성공 응답</td>
<td><strong>500건 (100%)</strong></td>
</tr>
<tr>
<td>DB 예매 건수</td>
<td><strong>500건</strong></td>
</tr>
<tr>
<td>availableSeats</td>
<td><strong>50</strong></td>
</tr>
<tr>
<td>46번 좌석 예매 건수</td>
<td><strong>10건</strong></td>
</tr>
</tbody></table>
<p><strong>100석인데 500건이 전부 성공했다.</strong> 에러가 하나도 안 났다. 서버 로그를 봐도 200 OK만 찍혀 있다.</p>
<p>이게 가능한 이유는 이렇다:</p>
<pre><code>Thread A: SELECT availableSeats → 100
Thread B: SELECT availableSeats → 100  (같은 값을 읽음)
Thread A: UPDATE availableSeats = 99
Thread B: UPDATE availableSeats = 99   (A의 갱신을 덮어씀)</code></pre><p>두 명이 예매했는데 1만 차감된다. 이걸 <strong>Lost Update</strong>라고 부른다. 500건 요청 후 <code>availableSeats</code>가 50이라는 건, <strong>450건의 갱신이 증발</strong>했다는 뜻이다.</p>
<p>46번 좌석에는 10명이 동시에 앉아 있다. 아이유 콘서트에서 이러면 난리가 난다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/7da91166-6e16-44dd-b648-cd62a41d6c58/image.png" alt=""></p>
<p><em>실제 UI에서 예매된 좌석은 분홍색으로 표시된다. V1에서는 이 좌석들에 여러 명이 중복 예매되어 있다.</em></p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/6cb957be-3dfb-49b0-bd2c-0ad35ded4162/image.png" alt=""></p>
<p><em>프로그레스 바가 줄어들긴 하는데, 실제로는 이미 정합성이 깨진 상태다.</em></p>
<p><strong>가장 무서운 건 에러가 안 난다는 것이다.</strong> 서버는 정상이고 응답도 200인데 데이터만 엉망이다. 운영 중에 이걸 발견하면 이미 늦었다.</p>
<hr>
<h2 id="v2-락을-걸면-되잖아">V2. &quot;락을 걸면 되잖아&quot;</h2>
<p>원인은 명확하다. 같은 row를 여러 스레드가 동시에 읽었기 때문이다. 그러면 한 번에 한 명만 읽게 하면 된다.</p>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;SELECT c FROM Concert c WHERE c.id = :id&quot;)
Optional&lt;Concert&gt; findByIdForUpdate(@Param(&quot;id&quot;) Long id);</code></pre>
<p>MySQL에서는 <code>SELECT ... FOR UPDATE</code>로 번역된다. 이 쿼리가 실행되면 해당 row에 <strong>배타적 락</strong>이 걸린다. 다른 트랜잭션은 이 락이 풀릴 때까지 대기해야 한다.</p>
<p>추가로 좌석 중복을 DB 레벨에서 방어했다:</p>
<pre><code class="language-java">@Table(uniqueConstraints = {
    @UniqueConstraint(columnNames = {&quot;concertId&quot;, &quot;seatNo&quot;})
})</code></pre>
<h3 id="같은-k6를-다시-돌렸다">같은 k6를 다시 돌렸다</h3>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/c911c4c0-32d3-47ea-b4f0-297f4eaca614/image.png" alt=""></p>
<p>그런데 400명이 받은 건 HTTP 500 에러다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/54855f28-65c9-4364-91bb-3c2a07b4059f/image.png" alt=""></p>
<p>*&quot;이미 예매된 좌석입니다&quot;라는 메시지가 500 Internal Server Error에 실려온다. 사용자 입장에서는 &quot;서버가 터졌나?&quot; 싶다.*</p>
<table>
<thead>
<tr>
<th>지표</th>
<th>V1</th>
<th>V2</th>
</tr>
</thead>
<tbody><tr>
<td>성공</td>
<td>500건 (전부)</td>
<td><strong>100건 (20%)</strong></td>
</tr>
<tr>
<td>실패</td>
<td>0건</td>
<td><strong>400건 (80%)</strong></td>
</tr>
<tr>
<td>availableSeats</td>
<td>50 (오류)</td>
<td><strong>0 (정확)</strong></td>
</tr>
<tr>
<td>중복 좌석</td>
<td>전부</td>
<td><strong>0건</strong></td>
</tr>
</tbody></table>
<p><strong>정합성이 완벽하게 지켜졌다.</strong> 100석은 정확히 100명만 앉았고, 나머지 400명은 &quot;이미 예매된 좌석&quot; 또는 &quot;매진&quot;으로 거절되었다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/c42252ce-d5a5-47ae-b18e-b4d5ad349456/image.png" alt=""></p>
<p><em>비관적 락 + Unique Constraint로 50석이 정확히 50명에게만 예매되었다. V1과 달리 한 좌석에 한 명만 앉아 있다.</em></p>
<h3 id="진짜-문제는-따로-있다">진짜 문제는 따로 있다</h3>
<p>V2의 응답 시간이 V1보다 빠르게 나왔다. 이상하지 않은가?</p>
<table>
<thead>
<tr>
<th>지표</th>
<th>V1</th>
<th>V2</th>
</tr>
</thead>
<tbody><tr>
<td>p95</td>
<td>2,470ms</td>
<td><strong>552ms</strong></td>
</tr>
<tr>
<td>TPS</td>
<td>81 req/s</td>
<td><strong>189 req/s</strong></td>
</tr>
</tbody></table>
<p>V2가 빠른 건 <strong>400건이 빠르게 거절</strong>되었기 때문이다. 락을 잡은 1명이 처리하는 동안 나머지는 대기하다가 거절당한다. 성공한 요청만 보면 p90이 568ms다.</p>
<p><strong>핵심은 이거다.</strong> 비관적 락은 같은 row에 대한 모든 요청을 <strong>직렬화</strong>시킨다.</p>
<pre><code>Thread 1: SELECT FOR UPDATE (획득) → 처리 → COMMIT
Thread 2: SELECT FOR UPDATE (........대기........) → 획득 → 처리
Thread 3: SELECT FOR UPDATE (...................대기...................) → 획득</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/61ad0aeb-51e3-45ef-944e-1cc0ff4872e5/image.png" alt=""></p>
<p>50명이 동시에 오면, 사실상 한 명씩 은행 창구에 앉는 것과 같다. pool=10인 지금은 버텼지만, VU가 200이 되면?</p>
<pre><code>200 VUs → 대기열 = 200 - 28 = 172개
최악 대기시간 = (172/10) × 360ms ≈ 6,192ms
connection-timeout(3초) 초과 → 대량 실패!</code></pre><p><strong>정합성은 해결했지만, 트래픽이 늘면 커넥션 풀이 터진다.</strong> 100석짜리 소극장이 아니라 아이유 콘서트라면, 비관적 락만으로는 안 된다.</p>
<hr>
<h2 id="v3-db-앞에-줄을-세우자">V3. &quot;DB 앞에 줄을 세우자&quot;</h2>
<p>V2의 문제를 다시 정리하면: <strong>거절될 요청도 DB 커넥션을 점유한다.</strong> 400명이 &quot;어차피 실패할 예매&quot;를 위해 DB 커넥션을 잡고 대기하는 꼴이다.</p>
<p>그러면 발상을 바꿔보자. <strong>DB에 가기 전에 줄을 세우면 어떨까?</strong></p>
<pre><code>사용자 → [Redis 대기열] → [스케줄러] → [DB]
          즉시 응답          배치 처리     비관적 락</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/5714917a-e743-4563-a4ea-ae42117ac273/image.png" alt=""></p>
<p>Redis Sorted Set에 timestamp를 score로 넣어서 대기열을 만들었다.</p>
<pre><code class="language-java">// enqueue — Redis에 넣고 즉시 응답
redisTemplate.opsForZSet().add(queueKey, member, System.currentTimeMillis());</code></pre>
<p>스케줄러가 200ms마다 5건씩 꺼내서 DB 예매를 수행한다.</p>
<pre><code class="language-java">@Scheduled(fixedDelay = 200)
public void processQueue() {
    Set&lt;TypedTuple&lt;String&gt;&gt; batch = queueService.dequeueBatch(concertId, 5);
    for (TypedTuple&lt;String&gt; entry : batch) {
        reservationService.reserve(...);  // 여전히 비관적 락
    }
}</code></pre>
<h3 id="배치-크기는-어떻게-정했나">배치 크기는 어떻게 정했나</h3>
<p>감으로 정한 게 아니다.</p>
<pre><code>Pool Size = 10, DB 트랜잭션 시간 = ~50ms, 스케줄러 주기 = 200ms

이론적 처리량 = 5 / 0.2 = 25 req/s
커넥션 사용 = 25 × 0.05 = 1.25개 (pool 10개 중)</code></pre><p>배치 5건이면 커넥션 풀의 12%만 사용한다. 나머지 88%는 조회 API 등 다른 용도로 쓸 수 있다.</p>
<h3 id="k6를-돌렸다-1">k6를 돌렸다</h3>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/4fcc0681-0625-40d1-9a9f-ca47eff11b2b/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>지표</th>
<th>V2</th>
<th>V3</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP 에러율</td>
<td><strong>80%</strong> (500 에러)</td>
<td><strong>0%</strong></td>
</tr>
<tr>
<td>Enqueue p50</td>
<td>-</td>
<td><strong>5.7ms</strong></td>
</tr>
<tr>
<td>전체 Polling 포함</td>
<td>-</td>
<td>2,474ms</td>
</tr>
<tr>
<td>DB 동시 접근</td>
<td>50 VU 전부</td>
<td><strong>최대 5건</strong></td>
</tr>
</tbody></table>
<p><strong>HTTP 에러율이 80%에서 0%로 떨어졌다.</strong> 500명 전원이 &quot;대기열 진입 성공&quot; 응답을 받았다. 사용자 경험이 완전히 달라졌다.</p>
<p>V2에서는 400명이 &quot;서버 에러&quot; 화면을 봤지만, V3에서는 400명이 &quot;대기 중... 현재 순번: 342번&quot;을 본다. 같은 &quot;실패&quot;인데 경험이 다르다.</p>
<h3 id="그런데-한-가지-시나리오를-놓쳤다">그런데 한 가지 시나리오를 놓쳤다</h3>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/31ec639e-e9e2-4a6e-84cd-b49feb61eae2/image.png" alt=""></p>
<pre><code>1. 사용자 A → 대기열 진입 → 스케줄러가 예매 처리 → 성공
2. 사용자 A → 브라우저를 닫음
3. 좌석은 예매되었지만 결제는 안 됨
4. 다른 사용자는 이 좌석을 선택할 수 없음
5. 좌석이 영원히 묶임</code></pre><p>V3에서는 스케줄러가 &quot;먼저 예매하고 나중에 알려주는&quot; 구조다. 사용자가 이탈해도 예매는 이미 완료되어 있다. 결제를 안 하면? 좌석이 영원히 점유된다.</p>
<p>실제 티켓 서비스에서 &quot;10분 이내에 결제하지 않으면 예매가 취소됩니다&quot; 같은 안내를 본 적이 있을 것이다. 그게 바로 이 문제를 해결하기 위한 것이다.</p>
<hr>
<h2 id="v4-먼저-예매하지-말고-입장권을-주자">V4. &quot;먼저 예매하지 말고, 입장권을 주자&quot;</h2>
<p>V3의 문제는 <strong>&quot;대기열 통과 = 즉시 예매&quot;</strong> 구조에 있었다. 그래서 사이에 한 단계를 끼웠다.</p>
<pre><code>대기열 → [토큰 발급] → 사용자가 직접 예매 → 성공/실패
              ↓
         TTL 30초
         (만료 시 자동 삭제)</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/1959b204-49f2-4b58-a5e0-e03e949e3d56/image.png" alt=""></p>
<p>대기열을 통과하면 바로 예매하는 게 아니라, <strong>&quot;입장 토큰&quot;</strong>을 발급한다. 이 토큰이 있어야 예매 API를 호출할 수 있다. 토큰에는 TTL(Time To Live)이 걸려 있어서, 30초 안에 예매하지 않으면 자동으로 사라진다.</p>
<pre><code class="language-java">public String issueToken(Long concertId, String userId) {
    String token = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set(tokenKey, userId, TOKEN_TTL);  // 30초
    return token;
}</code></pre>
<p>동시 입장 인원도 제한한다. 토큰이 10개까지만 활성화되도록.</p>
<pre><code class="language-java">Long activeCount = redisTemplate.opsForValue().increment(activeKey);
if (activeCount &gt; MAX_ACTIVE_TOKENS) {
    redisTemplate.opsForValue().decrement(activeKey);
    return null;  // 아직 대기
}</code></pre>
<h3 id="ttl을-30초로-잡은-근거">TTL을 30초로 잡은 근거</h3>
<pre><code>예매 행위 소요시간(좌석 선택 + 결제) ≈ 10초
안전 계수 × 2 = 20초
UX 버퍼 = 10초
→ TTL = 30초</code></pre><p>실서비스라면 결제까지 포함해서 3~5분으로 늘려야 할 것이다. 부하 테스트에서는 봇이 즉시 예매하므로 30초면 충분하다.</p>
<h3 id="동시-입장-10명은-어떤-근거인가">동시 입장 10명은 어떤 근거인가</h3>
<pre><code>DB Pool Size = 10
토큰 보유자가 언제든 DB 접근 가능
→ Max Active Tokens = Pool Size = 10</code></pre><p>토큰 10개 = DB 커넥션 10개와 1:1 대응. 토큰을 11개 발급하면 11번째 사용자는 커넥션을 잡지 못할 수 있다.</p>
<h3 id="이탈-시나리오">이탈 시나리오</h3>
<pre><code>사용자 A: 토큰 발급 → 브라우저 닫기
→ 30초 후 토큰 TTL 만료 → Redis에서 자동 삭제
→ active count 감소 → 다음 대기자에게 토큰 발급
→ 좌석은 아직 점유되지 않음 (토큰 ≠ 좌석 점유)</code></pre><p>V3과 결정적으로 다른 점이다. <strong>토큰을 가지고 있다 ≠ 좌석을 점유했다.</strong> 예매 API를 호출해야 비로소 좌석이 잡힌다.</p>
<h3 id="k6-최종-결과">k6 최종 결과</h3>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/863d75ec-17ee-4951-a912-885d13a6863a/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>지표</th>
<th>V1</th>
<th>V2</th>
<th>V3</th>
<th>V4</th>
</tr>
</thead>
<tbody><tr>
<td>정합성</td>
<td>❌ 500건 초과</td>
<td>✅ 100건</td>
<td>✅ 100건</td>
<td>✅ 100건</td>
</tr>
<tr>
<td>HTTP 에러율</td>
<td>0% (잘못된 성공)</td>
<td>80%</td>
<td>0%</td>
<td><strong>0%</strong></td>
</tr>
<tr>
<td>Enqueue p50</td>
<td>-</td>
<td>131ms</td>
<td>5.7ms</td>
<td><strong>2.6ms</strong></td>
</tr>
<tr>
<td>DB 동시 접근</td>
<td>50 VU</td>
<td>50 VU</td>
<td>배치 5건</td>
<td><strong>토큰 10개</strong></td>
</tr>
<tr>
<td>이탈 대응</td>
<td>❌</td>
<td>❌</td>
<td>❌</td>
<td><strong>✅ TTL 30초</strong></td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/6bc88df0-daa5-4d91-94ee-728f50e0a84b/image.png" alt=""></p>
<p>DB 검증도 모든 버전에서 수행했다:</p>
<pre><code class="language-sql">-- V4 최종 결과
SELECT COUNT(*) FROM reservation WHERE concert_id = 8;       → 100
SELECT COUNT(DISTINCT seat_no) FROM reservation WHERE concert_id = 8;  → 100
SELECT available_seats FROM concert WHERE id = 8;             → 0</code></pre>
<p>100석, 100건, 0 잔여. 중복 없음. 끝.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/842ba17c-2d1d-49b6-a918-92af94d099ef/image.png" alt=""></p>
<p><em>V4 최종 결과. 100석이 정확히 100명에게 배정되었고, 모든 좌석이 분홍색으로 표시된다.</em></p>
<hr>
<h2 id="돌아보며">돌아보며</h2>
<p>처음에는 &quot;락 걸면 되지&quot;라고 생각했다. 실제로 V2에서 정합성은 바로 해결됐다. 그런데 정합성을 해결하니 성능이 문제가 되고, 성능을 해결하니 이탈이 문제가 되었다. 한 문제를 풀면 다음 문제가 보이는 구조였다.</p>
<pre><code>V1: 정합성이 깨진다
     ↓ 비관적 락
V2: 커넥션 풀이 터진다
     ↓ Redis 대기열
V3: 이탈 유저가 좌석을 묶는다
     ↓ 토큰 + TTL
V4: 그래서 이 구조가 된다</code></pre><p>매 단계마다 &quot;이전 단계의 어떤 판단이 이 문제를 만들었는가&quot;를 추적할 수 있었다. V3의 문제는 &quot;스케줄러가 대신 예매한다&quot;는 V3의 설계 판단에서 나왔고, V2의 문제는 &quot;모든 요청이 DB를 직접 때린다&quot;는 V2의 전제에서 나왔다.</p>
<h3 id="rate-limiting-vs-대기열-뭐가-맞는가">Rate Limiting vs 대기열, 뭐가 맞는가</h3>
<p>이 프로젝트를 시작하기 전에는 &quot;트래픽이 많으면 Rate Limiting을 걸면 되지&quot;라고 생각했다. 하지만 Rate Limiting과 대기열은 목적이 다르다.</p>
<ul>
<li><strong>Rate Limiting</strong>: &quot;너무 많으니까 거부한다&quot; → 사용자는 <strong>재시도</strong>해야 한다</li>
<li><strong>대기열</strong>: &quot;너무 많으니까 줄 세운다&quot; → 사용자는 <strong>기다리면</strong> 된다</li>
</ul>
<p>콘서트 티케팅에서 &quot;429 Too Many Requests&quot;를 받은 사용자가 할 수 있는 건 새로고침뿐이다. 그리고 그 새로고침이 다시 트래픽이 된다. Rate Limiting은 <strong>Thundering Herd를 막지 못하고 되먹임(feedback loop)만 만든다.</strong> 대기열은 이 되먹임을 끊는다.</p>
<h3 id="polling을-선택한-이유">Polling을 선택한 이유</h3>
<p>V3, V4에서 클라이언트는 300ms 간격으로 서버를 polling한다. SSE(Server-Sent Events)나 WebSocket이 더 효율적인 건 맞다. 하지만 이번 프로젝트의 핵심은 &quot;대기열 메커니즘&quot;이지 &quot;실시간 통신&quot;이 아니었다. Polling은 구현이 단순하고, 디버깅이 쉽고, 서버 상태를 유지하지 않아도 된다.</p>
<p>V4에서 polling 요청 수는 500건의 7배인 3,520건이었다. 실서비스라면 SSE로 교체해서 이 비용을 줄여야 한다. <strong>하지만 최적화는 문제가 된 다음에 해도 된다.</strong></p>
<h3 id="아직-남은-것들">아직 남은 것들</h3>
<p>이 프로젝트는 &quot;Redis가 죽으면?&quot;에 대한 답이 없다. 대기열도 토큰도 전부 Redis에 있다. Redis가 내려가면 대기열 전체가 사라진다. 실서비스라면 Redis Sentinel이나 Cluster 구성이 필수다.</p>
<p>분산 환경에서의 스케줄러 중복 실행 문제도 남아있다. 서버가 2대면 스케줄러도 2개가 도는데, 같은 대기열에서 동시에 dequeue하면 문제가 생길 수 있다. ShedLock 같은 분산 락이 필요하다.</p>
<p>그리고 이 시스템은 &quot;선착순&quot;을 보장하지 않는다. Redis Sorted Set의 score가 timestamp인데, 같은 밀리초에 들어온 요청은 순서가 보장되지 않는다. 엄밀한 선착순이 필요하면 Redis의 <code>INCR</code>로 단조 증가하는 시퀀스를 써야 한다.</p>
<hr>
<h2 id="결국">결국....</h2>
<p>어려웠던 건 기술이 아니라 **&quot;지금 뭐가 문제인지 정확히 짚는 것&quot;이라는 생각이 들었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[WIL('나'를 되돌아보다)]]></title>
            <link>https://velog.io/@praesentia-ykm/WIL%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@praesentia-ykm/WIL%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sun, 29 Mar 2026 14:46:56 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/e685f3f5-6ae1-4178-b7e5-ee293ecf66c5/image.png" alt=""></p>
<h1 id="루퍼스-부트캠프-7주차-wil--나를-되돌아보다">루퍼스 부트캠프 7주차 WIL — &#39;나&#39;를 되돌아보다</h1>
<p>루퍼스라는 부트캠프를 시작한 지 어느덧 7주차가 완료되었다.</p>
<p>기술적인 회고를 작성할 수도 있지만, 7주라는 시간이 지났으니 나 스스로에 대해 되돌아보는 시간을 가지고 싶었다. 매주 코드를 돌아봤으니, 이번엔 코드를 치는 &#39;나&#39;라는 사람을 돌아보려 한다. 그래서 이번 WIL은 3가지 다른 관점의 &#39;나&#39;를 중심으로 작성해보려고 한다.</p>
<hr>
<h2 id="1-타인에게-보이는-나">1. 타인에게 보이는 나</h2>
<p>어렸을 때부터 난 누군가에게 잘 보이고 싶어하는 성향이 강했다. 부모님에게 잘 보이는 것, 선생님에게 잘 보이는 것, 여자친구에게 잘 보이는 것, 친구에게 잘 보이는 것... 이 모든 것이 다 &#39;타인에게 보이는 나&#39;를 가꾸고 싶어함에서 비롯된 것이었던 거 같다. 그리고 꽤나 잘(?) 보여왔던 거 같다.</p>
<p>부트캠프에서도 이 성향은 어김없이 나타났다. 팀원들 앞에서 모르는 걸 모른다고 말하는 게 처음엔 쉽지 않았다. &quot;이 정도는 알아야 하지 않나?&quot;라는 생각이 먼저 들었고, 질문하기 전에 혼자 끙끙대는 시간이 길었다. 잘 보이고 싶은 마음이 배움의 속도를 늦추고 있었던 셈이다.</p>
<p>하지만 7주가 지나면서 깨달은 게 있다. 진짜 잘 보이는 사람은 완벽한 사람이 아니라, 부족함을 인정하면서도 계속 나아가는 사람이라는 것. 모르는 걸 솔직하게 물어봤을 때 돌아온 건 무시가 아니라, 같이 고민해주는 동료들의 모습이었다. &#39;잘 보이고 싶다&#39;는 욕구 자체가 나쁜 건 아니다. 다만 그 방향이 &quot;완벽한 척&quot;이 아니라 &quot;성장하는 모습&quot;을 보여주는 것으로 바뀌어야 한다는 걸 배웠다.</p>
<hr>
<h2 id="2-객관적인-나">2. 객관적인 나</h2>
<p>인간은 사회적인 동물이다. 그래서 타인에게 잘 보이고, 타인에게 인정받는 것을 갈망하면서 살아간다. 이건 지극히 정상이라고 생각한다. 하지만 이 안에서 절대 잊어서는 안 되는 것이 있다.</p>
<blockquote>
<p><strong>&quot;자기객관화&quot;</strong></p>
</blockquote>
<p>나는 이것을 냉정하게 나의 위치를 판단하는 것이라고 정의하곤 한다. 살아가다 보면 수많은 평가와 시선을 받게 된다. 칭찬을 들을 때도 있고, 부족하다는 피드백을 받을 때도 있다. 그 안에서 평가가 어떻든, 시선이 어떻든 간에 나의 기준을 잃지 않은 채로 살아가기 위해선 객관적으로 나를 판단하는 능력이 필요하다.</p>
<p>객관적으로 나는 도전적인 성향이다. 해보지 않은 것에 대한 두려움보다 해보지 않고 지나치는 것에 대한 후회가 더 크게 다가오는 사람이다. 이 부트캠프에 뛰어든 것 자체가 그 증거다. 하지만 도전적인 만큼 그 안에서 스스로 상처도 많이 받는다. 기대만큼 결과가 따라오지 않을 때, &quot;내가 왜 이것밖에 못하지?&quot;라는 자책이 찾아오기도 한다. 그리고 그만큼 성찰도 많이 한다.</p>
<p>나란 사람은 끓이면 끓일수록 맛있는 어머님의 미역국 같다고 생각한다. 처음엔 재료의 맛이 제대로 우러나지 않아 밋밋하지만, 시간이 지나고 정성이 쌓일수록 깊은 맛이 나는 것처럼 — 나도 시간이 지날수록 괜찮은 사람이 되어간다고 믿는다. 7주 전의 나와 지금의 나를 비교하면 그 믿음이 틀리지 않았다는 걸 느낀다.</p>
<hr>
<h2 id="3-그리고-앞으로의-나">3. 그리고 앞으로의 나</h2>
<p>처음엔 미숙한 점이 많다. 난 절대 내가 소위 말하는 재능충이라고 생각하지 않는다. 또한 머리가 좋다고도 생각하지 않는다. 딱 하나 잘하는 것을 뽑자면 <strong>원하고자 하는 바가 있다면 우직하게 노력하는 것</strong>이다.</p>
<p>여기서 중요한 키워드는 &#39;원하고자 하는 바가 있다면&#39;이다. 방향 없는 노력은 그냥 바쁜 것에 불과하다. 그래서 나에겐 동기부여가 굉장히 중요하다. 동기부여라는 건 어느 날 갑자기 하늘에서 떨어지는 게 아니라, 매일 하루하루를 되돌아보면서 스스로 만들어내는 것이라고 생각한다. 오늘 뭘 배웠는지, 어제보다 나아진 점이 있는지, 내일은 뭘 해볼 수 있는지 — 이 작은 질문들이 쌓여서 동기부여가 된다.</p>
<p>그래서 이제는 스스로 일기를 작성하고 있다. 거창한 형식은 없다. 오늘 하루를 돌아보고, 잘한 점과 아쉬운 점을 적는 것. 이 단순한 습관이 생각보다 큰 힘을 준다. 글로 적는 순간, 머릿속에 흩어져 있던 감정과 생각이 정리되고, &quot;아 나 오늘 이만큼은 했구나&quot;라는 작은 성취감이 내일의 나를 움직이게 한다.</p>
<p>앞으로의 나는 이 우직함을 무기로, 매일의 기록을 연료로 삼아 꾸준히 나아가는 사람이고 싶다. 화려하지 않아도 좋다. 미역국처럼, 끓이면 끓일수록 깊어지는 사람이면 된다.</p>
<hr>
<blockquote>
<p>7주가 지났다. 아직 부족하다. 하지만 7주 전보다는 확실히 나아졌다.
그리고 그걸 아는 것 자체가, 자기객관화가 되고 있다는 증거라고 생각한다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[acks=all, 올리브영 세일 전날 쿠폰 서버가 터질 뻔한 이야기]]></title>
            <link>https://velog.io/@praesentia-ykm/acksall-%EC%98%AC%EB%A6%AC%EB%B8%8C%EC%98%81-%EC%84%B8%EC%9D%BC-%EC%A0%84%EB%82%A0-%EC%BF%A0%ED%8F%B0-%EC%84%9C%EB%B2%84%EA%B0%80-%ED%84%B0%EC%A7%88-%EB%BB%94%ED%95%9C-%EC%9D%B4%EC%95%BC%EA%B8%B0</link>
            <guid>https://velog.io/@praesentia-ykm/acksall-%EC%98%AC%EB%A6%AC%EB%B8%8C%EC%98%81-%EC%84%B8%EC%9D%BC-%EC%A0%84%EB%82%A0-%EC%BF%A0%ED%8F%B0-%EC%84%9C%EB%B2%84%EA%B0%80-%ED%84%B0%EC%A7%88-%EB%BB%94%ED%95%9C-%EC%9D%B4%EC%95%BC%EA%B8%B0</guid>
            <pubDate>Fri, 27 Mar 2026 08:53:38 GMT</pubDate>
            <description><![CDATA[<h4 id="학습을-위해-가정한-상황인-점-참고-부탁드립니다">학습을 위해 가정한 상황인 점 참고 부탁드립니다.</h4>
<blockquote>
<p><strong>TL;DR</strong>
올리브영 세일 전날, 선착순 쿠폰 발급과 좋아요 폭증이 동시에 터지는 상황을 가정했다. 좋아요 집계가 느려지면서 좋아요 자체가 실패하고, 서버 배포 한 번에 이벤트가 통째로 유실되는 문제까지. 이걸 해결하기 위해 Outbox 패턴으로 이벤트를 DB에 저장하고, Kafka를 사이에 두고 서버 3대가 각자의 역할을 나눠 가지게 되었다. 그 과정에서 설정값 하나하나에 &quot;왜?&quot;를 붙여간 기록.</p>
</blockquote>
<hr>
<p>본 글은 커머스 플랫폼의 백엔드를 운영하고 있다는 가정 하에 작성했다. 실제 올리브영 시스템과는 무관하지만, 올리브영 세일처럼 특정 시점에 트래픽이 폭발하는 상황은 커머스 백엔드라면 누구나 한 번쯤 마주치는 시나리오다. 이 글에서는 그 상황 안에서 어떤 판단을 했고, 왜 그렇게 했는지를 코드와 함께 기록한다.</p>
<hr>
<h2 id="올세일-전날-무슨-일이-벌어지는가">올세일 전날, 무슨 일이 벌어지는가</h2>
<p>올리브영 세일을 떠올려보자. 세일 시작 전날부터 유저들이 몰려든다. 위시리스트에 담을 상품을 탐색하고, 마음에 드는 상품에 좋아요를 누르고, 자정에 풀리는 선착순 쿠폰을 기다린다.</p>
<p>평소에는 분당 수십 건이었던 좋아요가 분당 수백, 수천 건으로 뛴다. 거기에 자정이 되면 선착순 쿠폰 발급 요청이 한꺼번에 쏟아진다. 1만 명이 동시에 100장짜리 쿠폰을 잡으려고 달려드는 거다.</p>
<p>이 상황에서 내 코드는 어떻게 되어 있었을까.</p>
<pre><code class="language-java">@Transactional
public void doLike(Long userId, Long productId) {
    likeService.save(new LikeModel(userId, productId));
    productService.incrementLikeCount(productId);
    cacheManager.getCache(&quot;productDetail&quot;).evict(productId);
}</code></pre>
<p>좋아요를 누르면 세 가지 일이 하나의 트랜잭션 안에서 벌어진다. like 레코드를 저장하고, product 테이블의 like_count를 올리고, 캐시를 비운다. 평소에는 아무 문제가 없다.</p>
<p>그런데 인기 상품 하나에 좋아요가 동시에 몰리면, <code>incrementLikeCount()</code>가 같은 row를 잡으려고 경합한다. 락 대기 시간이 길어지고, 거기에 Redis 캐시 evict까지 타임아웃이 나기 시작한다.</p>
<p>여기서 문제가 뭐냐면 — <strong>집계가 느려지는 건 집계의 문제인데, 그 영향이 좋아요 자체에까지 번진다는 거다.</strong> 같은 트랜잭션이니까. <code>incrementLikeCount()</code>에서 타임아웃이 나면 전체가 롤백되고, 유저한테는 &quot;좋아요 실패&quot;가 떨어진다.</p>
<p>유저 입장에서 생각해보자. &quot;나는 좋아요를 눌렀을 뿐인데, 왜 실패하지?&quot; 좋아요 수가 1~2초 늦게 반영되는 건 아무도 모른다. 하지만 좋아요 버튼이 안 먹히는 건 바로 체감한다.</p>
<p>이 깨달음이 Step 1의 출발점이었다. 좋아요 저장과 집계를 같은 트랜잭션에서 분리하자. <code>@TransactionalEventListener(AFTER_COMMIT)</code> + <code>REQUIRES_NEW</code>로 집계를 별도 트랜잭션으로 격리했다. (이 과정은 <a href="./week7-application-event.md">이전 글</a>에 자세히 썼다.)</p>
<p>여기까지는 괜찮았다. 그런데 Step 1에서 빚을 하나 남겼다.</p>
<hr>
<h2 id="배포-한-번에-이벤트가-사라진다">배포 한 번에 이벤트가 사라진다</h2>
<p>올세일 당일 아침, 핫픽스를 배포한다고 해보자. 롤링 배포로 인스턴스를 하나씩 내리고 올린다.</p>
<p>인스턴스가 내려가는 그 찰나에, 이런 타이밍이 생길 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/9db0f856-06b2-4c52-97e8-cd36b227e69f/image.png" alt=""></p>
<p>like 테이블에는 레코드가 있다. 하지만 <code>AFTER_COMMIT</code>으로 실행되어야 할 집계 리스너는 한 번도 실행되지 않았다. <code>product.like_count</code>는 안 올라간 상태. 이 불일치는 아무도 모르는 사이에 쌓인다.</p>
<p>왜 이런 일이 생기냐면, Spring ApplicationEvent는 <strong>메모리 전파</strong>이기 때문이다. JVM 안에서 이벤트를 발행하고, JVM 안에서 리스너가 받는다. JVM이 죽으면 이벤트도 같이 죽는다. 재시도 메커니즘 같은 건 없다. 한 번 유실되면 끝이다.</p>
<p>올세일 기간에 배포 한두 번, 트래픽 피크에 인스턴스 오토스케일링 한두 번이면, 하루에 수십~수백 건의 집계 불일치가 누적될 수 있다. 좋아요 수가 실제보다 적게 표시되는 상품이 늘어나는 거다.</p>
<p>그래서 질문이 바뀌었다. Step 1에서는 &quot;집계가 실패해도 좋아요는 살려야 한다&quot;였는데, 이제는 <strong>&quot;이벤트 자체를 잃어버리지 않으려면 어떻게 해야 하는가?&quot;</strong>가 됐다.</p>
<hr>
<h2 id="이벤트를-잃어버리지-않는-방법을-찾아서">이벤트를 잃어버리지 않는 방법을 찾아서</h2>
<p>메모리 전파의 한계를 넘으려면, 이벤트를 어딘가에 <strong>영속적으로 저장</strong>해야 한다. 떠오르는 방법이 세 가지 있었다.</p>
<h3 id="그냥-kafka에-바로-보내면-안-되나">&quot;그냥 Kafka에 바로 보내면 안 되나?&quot;</h3>
<p>제일 먼저 떠오른 생각이 이거였다.</p>
<pre><code class="language-java">@Transactional
public void doLike(Long userId, Long productId) {
    likeService.save(new LikeModel(userId, productId));
    kafkaTemplate.send(&quot;catalog-events&quot;, ...);  // Kafka에 바로 보내자
}</code></pre>
<p>직관적이다. 그런데 여기에 함정이 있다. DB와 Kafka는 서로 다른 시스템이다. 이 둘을 하나의 트랜잭션으로 묶을 수 없다. DB에는 썼는데 Kafka 전송이 실패하면? 또는 Kafka에는 보냈는데 DB가 롤백되면?</p>
<p>이게 <strong>이중 쓰기(Dual Write)</strong> 문제다. 두 개의 서로 다른 시스템에 동시에 쓸 때, 하나는 성공하고 하나는 실패하는 순간이 반드시 존재한다. 원자적이지 않다.</p>
<h3 id="그러면-커밋하고-나서-보내면">&quot;그러면 커밋하고 나서 보내면?&quot;</h3>
<pre><code class="language-java">@Transactional
public void doLike(...) { likeService.save(...); }
// TX 끝나고
kafkaTemplate.send(...);</code></pre>
<p>DB 커밋은 됐는데 Kafka에 보내기 전에 서버가 죽으면? ApplicationEvent의 문제와 본질적으로 같다. 커밋과 전송 사이에 간극이 있는 한, 유실 가능성은 사라지지 않는다.</p>
<h3 id="transactional-outbox-pattern">Transactional Outbox Pattern</h3>
<p>결국 도달한 답은 이거였다. <strong>Kafka에 직접 보내지 말고, &quot;보내야 할 이벤트&quot;를 같은 DB에 먼저 저장하자.</strong></p>
<pre><code class="language-java">@Transactional  // 하나의 DB 트랜잭션
public void doLike(...) {
    likeService.save(new LikeModel(userId, productId));              // like 테이블
    outboxRepository.save(new OutboxEvent(&quot;catalog-events&quot;, ...));   // outbox_event 테이블
}</code></pre>
<p>like 레코드와 outbox 레코드가 같은 DB, 같은 트랜잭션에서 저장된다. 둘 다 성공하거나 둘 다 실패한다. 원자적이다.</p>
<p>그러면 Kafka에는 누가 보내느냐? 별도 스케줄러(<code>OutboxRelayService</code>)가 1초마다 outbox 테이블을 훑으면서, PENDING 상태인 이벤트를 Kafka로 전송한다. 전송에 성공하면 PUBLISHED로 바꾸고, 실패하면 FAILED로 바꿔서 나중에 재시도한다.</p>
<p>정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th>방법</th>
<th>원자성</th>
<th>유실 가능성</th>
<th>복잡도</th>
</tr>
</thead>
<tbody><tr>
<td>Kafka 직접 전송 (같은 TX)</td>
<td>X — 이중 쓰기</td>
<td>Kafka 장애 시 DB도 롤백 or 불일치</td>
<td>낮음</td>
</tr>
<tr>
<td>TX 커밋 후 Kafka 전송</td>
<td>X — 간극 존재</td>
<td>서버 사망 시 유실</td>
<td>낮음</td>
</tr>
<tr>
<td><strong>Outbox 패턴</strong></td>
<td><strong>O — 같은 DB TX</strong></td>
<td><strong>Relay가 재시도</strong></td>
<td>높음</td>
</tr>
</tbody></table>
<p>복잡도는 확실히 올라간다. outbox 테이블도 만들어야 하고, Relay 스케줄러도 만들어야 하고, 상태 관리도 해야 한다. 하지만 올세일 같은 트래픽 피크에서 &quot;이벤트를 잃지 않는다&quot;는 보장을 만들려면, 이 복잡도를 감수할 수밖에 없었다.</p>
<hr>
<h2 id="outbox를-만들면서-헷갈렸던-것-before_commit">Outbox를 만들면서 헷갈렸던 것: BEFORE_COMMIT</h2>
<p>Outbox 리스너를 처음 만들 때 혼란이 왔다.</p>
<pre><code class="language-java">@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleLikeToggled(LikeToggledEvent event) {
    saveOutboxEvent(&quot;Product&quot;, event.productId(), ...);
}</code></pre>
<p>잠깐, Step 1에서 집계 리스너는 <code>AFTER_COMMIT</code>이었다. &quot;커밋 후에 실행해야 실패가 격리된다&quot;고 배웠는데, 왜 Outbox는 <code>BEFORE_COMMIT</code>인가?</p>
<p>처음에 이게 모순처럼 느껴졌다. 같은 <code>@TransactionalEventListener</code>인데 phase가 정반대라니.</p>
<p>곰곰이 생각해보니까, <strong>목적이 정반대이기 때문에 phase도 정반대인 거였다.</strong></p>
<p>집계 리스너는 &quot;집계가 실패해도 좋아요는 살려야&quot; 했다. 실패를 격리하는 게 목적이니까 <code>AFTER_COMMIT</code> — 이미 커밋된 후에 독립적으로 실행.</p>
<p>Outbox 리스너는 정반대다. &quot;outbox 저장이 실패하면 좋아요도 같이 롤백되어야&quot; 한다. 왜? outbox에 이벤트가 안 남으면, 이 좋아요에 대한 후속 처리(집계, 알림, 통계)가 영원히 일어나지 않으니까. 이벤트를 잃어버린 좋아요는 사실상 반쪽짜리다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Outbox 리스너 (BEFORE_COMMIT)</th>
<th>집계 리스너 (AFTER_COMMIT)</th>
</tr>
</thead>
<tbody><tr>
<td>실행 시점</td>
<td>커밋 전, 같은 TX 안</td>
<td>커밋 후, TX 밖</td>
</tr>
<tr>
<td>리스너 실패 시</td>
<td>좋아요도 롤백</td>
<td>좋아요는 이미 커밋</td>
</tr>
<tr>
<td>목적</td>
<td><strong>원자적 저장</strong></td>
<td><strong>실패 격리</strong></td>
</tr>
</tbody></table>
<p>같은 어노테이션, 다른 phase, 정반대의 의미. 이걸 정리하고 나서야 <code>@TransactionalEventListener</code>가 제대로 이해됐다.</p>
<hr>
<h2 id="relay-1초마다-outbox를-뒤지는-녀석">Relay: 1초마다 outbox를 뒤지는 녀석</h2>
<p>outbox에 이벤트가 쌓이면, 누군가가 그걸 꺼내서 Kafka로 보내야 한다.</p>
<pre><code class="language-java">@Scheduled(fixedDelay = 1000)
public void relay() {
    List&lt;OutboxEvent&gt; events = fetchAndMarkProcessing();
    if (events.isEmpty()) return;

    Map&lt;String, List&lt;OutboxEvent&gt;&gt; grouped = events.stream()
        .collect(Collectors.groupingBy(OutboxEvent::getPartitionKey));

    List&lt;CompletableFuture&lt;Void&gt;&gt; futures = new ArrayList&lt;&gt;();
    for (var entry : grouped.entrySet()) {
        futures.add(CompletableFuture.runAsync(
            () -&gt; publishEvents(entry.getValue()), outboxRelayExecutor
        ));
    }
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}</code></pre>
<p>1초마다 PENDING 이벤트를 긁어와서, partitionKey별로 묶어서, 스레드풀로 병렬 전송한다. 단순해 보이지만, 올세일 상황을 가정하면 여기서도 문제가 생긴다.</p>
<h3 id="서버를-2대로-늘리면-relay도-2개가-되는데">&quot;서버를 2대로 늘리면 Relay도 2개가 되는데?&quot;</h3>
<p>올세일 트래픽을 감당하려고 API 서버를 2대로 스케일아웃했다고 치자. 각 인스턴스에서 Relay 스케줄러가 동시에 돌아간다. 둘 다 같은 PENDING 이벤트를 SELECT한다. 둘 다 Kafka에 보낸다.</p>
<p><strong>같은 이벤트가 두 번 발행된다.</strong></p>
<p>이걸 막으려면 &quot;내가 가져간 이벤트는 다른 인스턴스가 못 가져가게&quot; 해야 한다. 그래서 2단계 상태 전환을 넣었다.</p>
<pre><code class="language-sql">SELECT * FROM outbox_event
WHERE status = &#39;PENDING&#39;
ORDER BY created_at ASC
LIMIT 100
FOR UPDATE SKIP LOCKED</code></pre>
<p><code>FOR UPDATE</code>로 행 단위 락을 잡고, PENDING → PROCESSING으로 상태를 바꾼다. 다른 인스턴스가 같은 시점에 SELECT해도, 이미 락이 걸린 행은 결과에 안 나온다.</p>
<p>여기서 <code>SKIP LOCKED</code>가 핵심이다. 이게 없으면 인스턴스 B가 A가 락을 풀 때까지 <strong>대기한다.</strong> A가 100건을 Kafka로 보내는 동안 B는 아무것도 못 하고 기다리는 거다. <code>SKIP LOCKED</code>를 쓰면 &quot;이미 락 걸린 건 건너뛰고, 다른 PENDING을 가져와&quot;가 된다. 두 인스턴스가 각자 다른 이벤트를 병렬로 처리할 수 있다.</p>
<table>
<thead>
<tr>
<th>전략</th>
<th>중복 발행</th>
<th>병렬 처리</th>
</tr>
</thead>
<tbody><tr>
<td>상태 관리 없이 바로 전송</td>
<td>O (위험)</td>
<td>O</td>
</tr>
<tr>
<td>FOR UPDATE만</td>
<td>X</td>
<td>X (대기)</td>
</tr>
<tr>
<td><strong>FOR UPDATE + SKIP LOCKED</strong></td>
<td><strong>X</strong></td>
<td><strong>O</strong></td>
</tr>
</tbody></table>
<h3 id="relay가-이벤트를-가져간-직후-서버가-죽으면">&quot;Relay가 이벤트를 가져간 직후 서버가 죽으면?&quot;</h3>
<p>PENDING → PROCESSING으로 바꿨는데, Kafka에 보내기 전에 서버가 죽으면? 이벤트가 PROCESSING 상태로 영원히 남는다. 아무도 안 건드리는 좀비.</p>
<p>이런 좀비를 잡기 위한 복구 스케줄러를 따로 만들었다.</p>
<pre><code class="language-java">@Scheduled(fixedDelay = 60000)
public void recoverStalledEvents() {
    ZonedDateTime threshold = ZonedDateTime.now().minusMinutes(5);
    List&lt;OutboxEvent&gt; stalled = outboxRepository.findStalledProcessingEvents(threshold);
    stalled.forEach(OutboxEvent::markRetry);
}</code></pre>
<p>5분 넘게 PROCESSING이면 &quot;실종&quot;으로 간주하고 PENDING으로 되돌린다. 다음 relay 사이클에서 다시 가져간다.</p>
<p>Kafka 전송 자체가 실패한 경우에는 FAILED 상태로 빠지는데, 30초 후에 PENDING으로 복구해서 재시도한다. 최대 5회. 5회 넘기면 사람이 봐야 하는 상황이다.</p>
<p>이벤트의 전체 생명주기를 그려보면 이렇다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/b3e7f1d2-2d29-4913-8ff4-388ba2c7720b/image.png" alt=""></p>
<hr>
<h2 id="kafka-설정-왜-이-값이어야-하는가">Kafka 설정: 왜 이 값이어야 하는가</h2>
<p>outbox에서 꺼낸 이벤트를 Kafka에 보내는 순간, Producer 설정이 메시지의 운명을 결정한다. 여기서 설정값 하나하나를 만져보면서, 각각이 어떤 장애를 막고 있는지를 느끼게 됐다.</p>
<h3 id="acksall--진짜로-저장한-거-맞아">acks=all — &quot;진짜로 저장한 거 맞아?&quot;</h3>
<pre><code class="language-yaml">producer:
  acks: all</code></pre>
<p>Relay가 Kafka에 메시지를 보내고, 브로커가 &quot;받았어&quot;라고 대답하는 타이밍에 대한 설정이다.</p>
<table>
<thead>
<tr>
<th>acks</th>
<th>브로커의 응답 조건</th>
<th>유실 위험</th>
<th>지연</th>
</tr>
</thead>
<tbody><tr>
<td><code>0</code></td>
<td>응답 안 함</td>
<td>높음 — 브로커가 못 받아도 모름</td>
<td>최소</td>
</tr>
<tr>
<td><code>1</code></td>
<td>리더가 저장하면 응답</td>
<td>중간 — 리더 장애 시 유실 가능</td>
<td>중간</td>
</tr>
<tr>
<td><code>all</code></td>
<td>리더 + 모든 ISR이 저장해야 응답</td>
<td><strong>최소</strong></td>
<td>최대</td>
</tr>
</tbody></table>
<p><code>all</code>을 선택한 이유가 명확하다. Relay는 전송 성공 후 outbox를 PUBLISHED로 바꾼다. PUBLISHED가 된 이벤트는 1시간 후 삭제된다. 만약 브로커가 실제로는 저장하지 않았는데 PUBLISHED로 바꿨다면? <strong>이벤트가 DB에서도 Kafka에서도 사라진다.</strong> Outbox 패턴을 만든 의미가 없어진다.</p>
<p>&quot;받았다&quot;는 확인을 가장 강하게 받아야 하는 이유가 여기에 있다.</p>
<h3 id="enableidempotencetrue--네트워크의-회색-지대">enable.idempotence=true — 네트워크의 회색 지대</h3>
<p>이런 상황을 상상해보자. Relay가 메시지를 보냈다. 브로커가 잘 받아서 저장했다. 그런데 &quot;받았어&quot;라는 ack 패킷이 네트워크에서 유실됐다. Relay 입장에서는 응답이 안 왔으니까 &quot;전송 실패&quot;로 판단하고 재시도한다.</p>
<p><strong>같은 메시지가 브로커에 두 번 저장된다.</strong></p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/8f942774-50cb-4171-b913-7b0b80372191/image.png" alt=""></p>
<p><code>enable.idempotence=true</code>를 켜면, Producer가 각 메시지에 시퀀스 넘버를 붙인다. 브로커는 같은 Producer ID + 같은 시퀀스 넘버 조합이 들어오면 중복으로 판단하고 무시한다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/f5cd89a5-4ea3-4680-946a-7d97de0526c5/image.png" alt=""></p>
<h3 id="maxinflightrequestsperconnection5">max.in.flight.requests.per.connection=5</h3>
<p>이건 처음에 왜 5인지 몰랐다. 알고 보니까, <code>idempotence=true</code>일 때 Kafka가 내부적으로 순서를 보장해줄 수 있는 in-flight 요청 수의 최대치가 5다. Kafka 문서에 명시되어 있다.</p>
<p>1로 낮추면 한 번에 하나씩만 보내니까 순서가 완벽하지만, 처리량이 급감한다. 5는 &quot;순서도 보장하면서 처리량도 유지하는&quot; Kafka가 허용하는 한계점이다.</p>
<hr>
<h2 id="토픽을-왜-3개로-나눴는가">토픽을 왜 3개로 나눴는가</h2>
<pre><code class="language-java">TopicBuilder.name(&quot;catalog-events&quot;).partitions(3).replicas(1).build();
TopicBuilder.name(&quot;order-events&quot;).partitions(3).replicas(1).build();
TopicBuilder.name(&quot;coupon-issue-requests&quot;).partitions(3).replicas(1).build();</code></pre>
<p>올세일 자정에 무슨 일이 벌어지는지 생각해보자. 선착순 쿠폰 발급 요청이 1만 건 쏟아진다. 쿠폰 Consumer가 비관적 락으로 수량을 체크하고, 중복 발급을 막고, 하나하나 발급 처리를 한다. 느릴 수밖에 없다.</p>
<p>만약 쿠폰 이벤트와 좋아요 이벤트가 같은 토픽에 있다면? 쿠폰 처리에 Consumer가 시간을 쏟는 동안, 좋아요 집계 이벤트도 함께 밀린다. 쿠폰은 쿠폰대로 느리고, 좋아요 집계까지 덩달아 지연되는 거다.</p>
<p>토픽을 나누면 이 문제가 사라진다. 각 토픽에 각자의 Consumer Group이 붙으니까, 한쪽이 느려도 다른 쪽에 영향을 주지 않는다.</p>
<table>
<thead>
<tr>
<th>토픽</th>
<th>Consumer Group</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>catalog-events</code></td>
<td><code>streamer-catalog</code></td>
<td>좋아요/조회수 집계</td>
</tr>
<tr>
<td><code>order-events</code></td>
<td><code>streamer-order</code></td>
<td>주문/결제 후속 처리</td>
</tr>
<tr>
<td><code>coupon-issue-requests</code></td>
<td><code>streamer-coupon</code></td>
<td>선착순 쿠폰 발급</td>
</tr>
</tbody></table>
<p>파티션을 3개로 둔 이유는 단순하다. Kafka에서 파티션 1개는 Consumer 1개만 처리할 수 있다. 파티션 3개면 최대 3개의 Consumer가 병렬로 일할 수 있다. 올세일 트래픽에 대비해서 Consumer를 늘릴 수 있는 여지를 두는 거다. (참고로 파티션은 늘리기는 쉬운데 줄이는 건 안 된다. 이 비가역성을 모르고 파티션을 100개로 잡았다간 낭패를 본다.)</p>
<p>partitionKey를 <code>productId</code>로 한 것도 올세일 상황을 생각하면 납득이 간다. 같은 상품에 대한 &quot;LIKED&quot; → &quot;UNLIKED&quot; 이벤트가 다른 파티션으로 흩어지면, 두 Consumer가 각각 처리하면서 순서가 뒤집힐 수 있다. 좋아요 취소가 먼저 처리되고 좋아요가 나중에 처리되면, like_count가 꼬인다. <code>productId</code>로 partitionKey를 잡으면 같은 상품의 이벤트는 반드시 같은 파티션, 같은 Consumer가 순서대로 처리한다.</p>
<hr>
<h2 id="consumer-쪽에서-터진-문제들">Consumer 쪽에서 터진 문제들</h2>
<p>이벤트를 받는 쪽은 <code>commerce-streamer</code>라는 별도 Spring Boot 애플리케이션이다. <code>commerce-api</code>와는 다른 프로세스, 다른 JVM. API 서버의 부하가 Consumer에 영향을 주지 않고, Consumer가 느려도 API 응답이 밀리지 않는다.</p>
<h3 id="처리하다가-서버가-죽으면-메시지가-사라지는-거-아닌가">&quot;처리하다가 서버가 죽으면 메시지가 사라지는 거 아닌가?&quot;</h3>
<p>처음에 Kafka Consumer 설정을 보면서 든 의문이 이거였다. 메시지를 가져왔는데 처리하다가 서버가 죽으면?</p>
<p>auto-commit이 켜져 있으면, <code>poll()</code>을 호출하는 시점에 이전에 가져간 메시지들의 offset이 자동으로 커밋된다. 즉, <strong>아직 처리 안 했는데 &quot;처리 완료&quot;로 기록되는 순간이 존재한다.</strong> 이 상태에서 서버가 죽으면, 그 메시지는 영영 처리되지 않는다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/1eb8ccee-dde2-48fb-8df3-d15cf18a58a2/image.png" alt=""></p>
<p>그래서 auto-commit을 끄고 manual ack를 쓴다.</p>
<pre><code class="language-java">public void consume(List&lt;ConsumerRecord&lt;String, String&gt;&gt; records, Acknowledgment ack) {
    for (ConsumerRecord&lt;String, String&gt; record : records) {
        try {
            processWithRetry(record);
        } catch (Exception e) {
            sendToDlq(record, e);
        }
    }
    ack.acknowledge();  // 배치 전부 처리한 후에야 커밋
}</code></pre>
<p><code>ack.acknowledge()</code>를 명시적으로 호출해야 offset이 커밋된다. 처리가 끝나기 전에는 절대 커밋하지 않는다.</p>
<p><strong>정상 흐름:</strong></p>
<pre><code class="language-mermaid">flowchart LR
    A[&quot;poll()&quot;] --&gt; B[&quot;msg1~3\n수신&quot;]
    B --&gt; C[&quot;처리 완료 ✅&quot;]
    C --&gt; D[&quot;ack.acknowledge()&quot;]
    D --&gt; E[&quot;커밋 📝&quot;]

    style C fill:#51cf66,color:#fff,stroke:#2b8a3e
    style E fill:#51cf66,color:#fff,stroke:#2b8a3e</code></pre>
<p><strong>장애 흐름:</strong>
<img src="https://velog.velcdn.com/images/praesentia-ykm/post/083afd3f-6bc1-4239-885f-55d9431bf246/image.png" alt=""></p>
<h3 id="그러면-같은-메시지를-두-번-처리하는-거-아닌가">&quot;그러면 같은 메시지를 두 번 처리하는 거 아닌가?&quot;</h3>
<p>맞다. manual ack 구조에서는 <strong>메시지를 중복으로 받을 수 있다.</strong> 처리는 다 했는데 ack 호출 전에 서버가 죽으면, 리밸런싱 후 같은 메시지를 다시 받는다. 이게 At Least Once의 의미다 — &quot;최소 한 번은 전달하지만, 중복 가능.&quot;</p>
<p>중복 처리를 막으려면 Consumer 쪽에서 <strong>멱등성</strong>을 보장해야 한다.</p>
<pre><code class="language-java">if (eventHandledRepository.existsById(eventId)) {
    log.debug(&quot;이미 처리된 이벤트: eventId={}&quot;, eventId);
    return;
}

// ... 실제 처리 ...

eventHandledRepository.save(new EventHandled(eventId, eventType));</code></pre>
<p><code>event_handled</code> 테이블에 처리한 이벤트의 ID를 PK로 저장한다. 같은 이벤트가 두 번 오면 <code>existsById</code>에서 걸려서 무시된다.</p>
<p>여기서 왜 Kafka의 offset이 아니라 비즈니스 레벨의 <code>eventId</code>(UUID)를 쓰느냐는 의문이 들 수 있다. offset은 파티션 리밸런싱이나 토픽 재생성 시 바뀔 수 있다. 하지만 <code>eventId</code>는 Producer(Outbox)에서 만든 UUID이므로 불변이다. 어떤 상황에서든 같은 이벤트를 같은 ID로 식별할 수 있다.</p>
<h3 id="낙관적-락이-터지는-순간">낙관적 락이 터지는 순간</h3>
<p>올세일 피크 시간에, 같은 상품의 좋아요 이벤트와 조회 이벤트가 배치 안에 같이 들어오면 어떻게 될까?</p>
<p><code>ProductMetrics</code> 엔티티에는 <code>@Version</code> 필드가 있다. 두 이벤트가 같은 productId의 <code>ProductMetrics</code>를 동시에 읽으면, 둘 다 같은 version을 가지고 있다. 하나가 먼저 저장되면 version이 올라가고, 나머지 하나는 <code>OptimisticLockingFailureException</code>이 터진다.</p>
<pre><code class="language-java">private void processWithRetry(ConsumerRecord&lt;String, String&gt; record) {
    for (int attempt = 1; attempt &lt;= MAX_RETRY; attempt++) {
        try {
            processRecord(record);
            return;
        } catch (ObjectOptimisticLockingFailureException e) {
            if (attempt == MAX_RETRY) throw e;
        }
    }
}</code></pre>
<p>재시도하면 DB에서 최신 version을 다시 읽으니까 성공한다. 최대 3회 재시도, 그래도 안 되면 DLQ(Dead Letter Queue)로 보내서 나중에 확인한다.</p>
<hr>
<h2 id="전체-그림-설정-하나를-빼면-어디에-구멍이-생기는가">전체 그림: 설정 하나를 빼면 어디에 구멍이 생기는가</h2>
<p>여기까지 만들고 나서, 각 설정이 파이프라인의 어떤 구간을 보호하고 있는지를 정리해봤다. 하나라도 빠지면 어디선가 터진다.</p>
<table>
<thead>
<tr>
<th>빼면?</th>
<th>뚫리는 구간</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>Outbox 패턴</td>
<td>API → Kafka</td>
<td>DB 커밋과 Kafka 전송 사이에 이벤트 유실</td>
</tr>
<tr>
<td>SKIP LOCKED</td>
<td>Relay 병렬</td>
<td>다중 인스턴스에서 같은 이벤트 이중 발행</td>
</tr>
<tr>
<td>acks=all</td>
<td>Relay → Broker</td>
<td>브로커가 실제로 안 저장했는데 PUBLISHED 처리</td>
</tr>
<tr>
<td>idempotence</td>
<td>Relay → Broker</td>
<td>네트워크 재시도 시 토픽에 중복 적재</td>
</tr>
<tr>
<td>manual ack</td>
<td>Broker → Consumer</td>
<td>처리 전 커밋으로 Consumer 쪽 메시지 유실</td>
</tr>
<tr>
<td>event_handled</td>
<td>Consumer 내부</td>
<td>At Least Once 재수신 시 like_count 이중 반영</td>
</tr>
</tbody></table>
<p>전체를 그림으로 그리면 이렇게 된다.
<img src="https://velog.velcdn.com/images/praesentia-ykm/post/c325d95a-b9e0-4494-8ca2-c5ae146b72b5/image.png" alt=""></p>
<hr>
<h2 id="돌아보며">돌아보며</h2>
<p>처음에는 <code>kafkaTemplate.send()</code>랑 <code>@KafkaListener</code>만 있으면 될 줄 알았다. Kafka를 쓰면 메시지를 보내고 받는 건 끝 아닌가, 라고 생각했다.</p>
<p>틀렸다. <strong>Kafka가 해주는 건 &quot;메시지를 파티션에 순서대로 저장하고 Consumer에게 전달하는 것&quot; 뿐이었다.</strong> 유실 방지, 중복 방지, 순서 보장, 장애 복구 — 전부 애플리케이션 레벨에서 설계해야 했다.</p>
<p>올세일 같은 상황을 가정하면서 각 설정값을 만져본 게 도움이 됐다. <code>acks=all</code>이 아니면 어디서 구멍이 생기는지, <code>SKIP LOCKED</code>가 없으면 왜 병렬 처리가 안 되는지, manual ack가 아니면 왜 메시지가 유실되는지 — 설정값 하나를 뺐을 때 어디가 뚫리는지를 말할 수 있으면, 그때 &quot;이 파이프라인을 이해했다&quot;고 할 수 있을 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[PG 40% 실패율에서 살아남기]]></title>
            <link>https://velog.io/@praesentia-ykm/PG-40-%EC%8B%A4%ED%8C%A8%EC%9C%A8%EC%97%90%EC%84%9C-%EC%82%B4%EC%95%84%EB%82%A8%EA%B8%B0</link>
            <guid>https://velog.io/@praesentia-ykm/PG-40-%EC%8B%A4%ED%8C%A8%EC%9C%A8%EC%97%90%EC%84%9C-%EC%82%B4%EC%95%84%EB%82%A8%EA%B8%B0</guid>
            <pubDate>Fri, 20 Mar 2026 08:56:37 GMT</pubDate>
            <description><![CDATA[<h1 id="결제에-장애-대응을-한-겹씩-쌓아올린-기록">결제에 장애 대응을 한 겹씩 쌓아올린 기록</h1>
<blockquote>
<p>지금부터 하는 이야기는 PG 시뮬레이터(요청 성공률 60%, 비동기 처리 성공률 70%, 종합 성공률 42%)를 상대로 커머스 결제 시스템의 장애 대응을 설계한 과정입니다. 실제 PG사의 실패율은 이보다 훨씬 낮지만, 극단적인 환경이 더 좋은 질문을 만들어줬습니다.</p>
</blockquote>
<hr>
<h2 id="tldr">TL;DR</h2>
<p>PG 장애 하나로 서비스 전체가 죽을 수 있다. Timeout → Retry → CircuitBreaker → Bulkhead → TX 분리를 한 겹씩 쌓으면서, <strong>각 레이어가 이전 레이어의 빈틈을 메우는 과정</strong>을 체득해보자.</p>
<hr>
<h2 id="아무것도-안-하면-어떻게-될까">아무것도 안 하면 어떻게 될까</h2>
<p>처음 PG 시뮬레이터를 띄우고 결제 요청을 날려봤다.</p>
<pre><code class="language-bash">=== 시도 1 === SUCCESS  {... transactionKey: &quot;20260320:TR:5ffc99&quot;}
=== 시도 2 === FAIL     &quot;현재 서버가 불안정합니다.&quot;
=== 시도 3 === SUCCESS  {...transactionKey: &quot;20260320:TR:f4cbbf&quot;}
=== 시도 4 === FAIL     &quot;현재 서버가 불안정합니다.&quot;
=== 시도 5 === SUCCESS  {...transactionKey: &quot;20260320:TR:f655b3&quot;}</code></pre>
<p>5번 중 2번 실패. 40%다. 동전보다 결제 성공률이 낮다.</p>
<p>근데 진짜 문제는 실패 자체가 아니었다. 실패를 <strong>어떻게 처리하느냐</strong>에 따라 결제 하나의 실패가 서비스 전체의 마비로 이어질 수 있다는 거였다.</p>
<p>처음 짠 코드를 보자.</p>
<pre><code class="language-java">@Transactional
public PaymentInfo requestPayment(Long userId, PaymentCommand command) {
    order.startPayment();
    payment = paymentService.save(payment);

    // PG에 결제 요청 — 여기서 문제가 시작된다
    PgPaymentResponse pgResponse = pgClient.requestPayment(pgRequest, userId);

    payment.assignTransactionKey(pgResponse.transactionKey());
    return PaymentInfo.from(payment);
}</code></pre>
<p>깔끔해 보인다. <strong>근데 이 코드에 시한폭탄이 두 개 들어있다.</strong></p>
<hr>
<h2 id="시한폭탄-1-pg가-응답-안-하면">시한폭탄 1: PG가 응답 안 하면?</h2>
<p>타임아웃 설정이 없으면, PG가 멈출 때 스레드가 <strong>영원히 기다린다.</strong> Tomcat 스레드 200개가 하나씩 잡혀가다가 결국 상품 조회, 장바구니, 회원 정보 — PG와 아무 상관없는 API까지 응답을 못 한다.</p>
<h2 id="시한폭탄-2-transactional-안에서-외부-호출">시한폭탄 2: @Transactional 안에서 외부 호출</h2>
<p><code>@Transactional</code>이 시작되면 HikariCP에서 DB 커넥션을 가져온다. PG 응답을 기다리는 동안에도 이 커넥션을 쥐고 놓지 않는다.</p>
<pre><code>DB 작업:   ~20ms
PG 대기:   100~500ms (타임아웃 시 3초!)
───────────────────
커넥션 점유: 520ms+</code></pre><p>HikariCP 기본 풀이 10개다. <strong>동시 결제 10건이면 커넥션이 바닥난다.</strong> 그 순간부터 DB를 쓰는 모든 API가 <code>ConnectionTimeout</code>으로 죽는다.</p>
<p>결제 하나 때문에 서비스 전체가 멈추는 거다.</p>
<p>여기서부터 장애 대응을 한 겹씩 쌓아올리기 시작했다.</p>
<hr>
<h2 id="1겹-timeout--기다리지-않는다">1겹: Timeout — &quot;기다리지 않는다&quot;</h2>
<pre><code class="language-java">@Bean
public RestTemplate pgRestTemplate(PgProperties pgProperties) {
    return new RestTemplateBuilder()
        .rootUri(pgProperties.baseUrl())
        .setConnectTimeout(Duration.ofMillis(1000))  // 연결 1초
        .setReadTimeout(Duration.ofMillis(3000))      // 응답 3초
        .build();
}</code></pre>
<p>PG 정상 응답이 100~500ms이니까, 3초면 6배 마진이다. 3초 안에 응답이 없으면 <code>SocketTimeoutException</code> → 스레드 해방.</p>
<p>이것만으로 &quot;PG 장애 → 서비스 전체 마비&quot;의 연쇄를 끊었다.</p>
<p><strong>하지만 여전히 40%는 실패한다.</strong> 성공률은 그대로 60%.</p>
<hr>
<h2 id="2겹-retry--한-번-더-해본다">2겹: Retry — &quot;한 번 더 해본다&quot;</h2>
<p>PG의 40% 실패는 &quot;일시적 실패&quot;다. 서버가 잠깐 과부하인 거지, 같은 요청을 다시 보내면 성공할 수 있다.</p>
<pre><code class="language-yaml">resilience4j:
  retry:
    instances:
      pg:
        max-attempts: 3
        wait-duration: 500ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2</code></pre>
<p>3번 재시도하면 실패 확률이 <code>0.4 × 0.4 × 0.4 = 6.4%</code>. <strong>성공률이 60% → 93.6%로 뛴다.</strong></p>
<p>근데 여기서 중요한 게 있다. <strong>모든 예외를 재시도하면 안 된다.</strong></p>
<table>
<thead>
<tr>
<th>예외</th>
<th>재시도?</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>타임아웃, 연결 실패</td>
<td>O</td>
<td>일시적 네트워크 문제</td>
</tr>
<tr>
<td>500, 502, 503</td>
<td>O</td>
<td>PG 서버 일시 장애</td>
</tr>
<tr>
<td><strong>400, 404</strong></td>
<td><strong>X</strong></td>
<td>우리 요청이 잘못된 것. 3번 보내도 3번 실패</td>
</tr>
</tbody></table>
<h3 id="고민-4번-재시도하면-더-좋지-않나">고민: 4번 재시도하면 더 좋지 않나?</h3>
<p>3번이면 <code>1 - 0.4³ = 93.6%</code>, 4번이면 <code>1 - 0.4⁴ = 97.4%</code>. 4번이 더 좋아 보인다.</p>
<p>하지만 최악의 경우를 계산해봐야 한다.</p>
<table>
<thead>
<tr>
<th>재시도 횟수</th>
<th>성공 확률</th>
<th>최악 소요 시간 (타임아웃 3초 + 대기)</th>
</tr>
</thead>
<tbody><tr>
<td>1회</td>
<td>60%</td>
<td>3초</td>
</tr>
<tr>
<td>3회</td>
<td>93.6%</td>
<td>~10.5초</td>
</tr>
<tr>
<td>4회</td>
<td>97.4%</td>
<td>~14초</td>
</tr>
<tr>
<td>5회</td>
<td>98.5%</td>
<td>~17.5초</td>
</tr>
</tbody></table>
<p>4회면 사용자가 14초를 기다린다. 결제 버튼 누르고 14초. 대부분의 사용자는 그 전에 &quot;뒤로가기&quot;를 누른다. 그래서 3회로 했다. <strong>성공률과 응답 시간 사이의 트레이드오프</strong>에서, 10초가 사용자 인내심의 한계라고 판단했다.</p>
<h3 id="고민-재시도-간격을-왜-바꿨나">고민: 재시도 간격을 왜 바꿨나</h3>
<p>처음에는 <strong>고정 500ms</strong>로 했다. 단순하고 예측 가능하니까.</p>
<p>근데 생각해보면, 동시에 실패한 100개의 요청이 500ms 후에 <strong>동시에</strong> 100개를 다시 보낸다. PG가 막 복구됐는데 100개가 한꺼번에 들이닥치면 또 죽을 수 있다. 이게 Thundering Herd 문제다.</p>
<pre><code>고정 간격:    500ms → 500ms → 500ms   (100명이 동시에 재시도)
지수 백오프:  500ms → 1000ms → 2000ms  (시간차로 분산)</code></pre><p>지수 백오프로 바꾸면 1차 재시도는 500ms 후, 2차는 1초 후, 3차는 2초 후. 요청들이 자연스럽게 시간차로 퍼진다.</p>
<p><strong>하지만 PG가 완전히 죽으면?</strong> 3번 재시도해도 3번 다 실패한다. 요청 1건에 최악 10.5초. 이게 100명이면 100개 스레드가 각각 10초씩 잡힌다.</p>
<hr>
<h2 id="3겹-circuitbreaker--더-이상-보내지-않는다">3겹: CircuitBreaker — &quot;더 이상 보내지 않는다&quot;</h2>
<p>서킷브레이커는 누전 차단기다. 계속 실패하는 요청을 끊어서 시스템을 보호한다.</p>
<pre><code class="language-yaml">resilience4j:
  circuitbreaker:
    instances:
      pg:
        sliding-window-size: 10
        minimum-number-of-calls: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 10s
        automatic-transition-from-open-to-half-open-enabled: true</code></pre>
<p>최근 10건 중 50% 이상 실패하면 <strong>서킷이 열린다.</strong> 열린 동안에는 PG에 요청을 아예 안 보내고 즉시 Fallback을 실행한다.</p>
<pre><code>CLOSED (정상)   → 요청 보냄, 결과 기록
  ↓ 실패율 50% 초과
OPEN (차단)     → PG 호출 안 함! 즉시 &quot;잠시 후 다시 시도해주세요&quot; (수 ms)
  ↓ 10초 경과
HALF-OPEN (시험) → 3건만 시험 호출
  ↓ 성공하면 CLOSED / 실패하면 다시 OPEN</code></pre><p>스레드 점유 시간이 10초 → <strong>거의 0</strong>으로 줄어든다.</p>
<h3 id="근데-서킷이-안-열렸다">근데 서킷이 안 열렸다</h3>
<p>달고 나서 테스트했더니, 10번 연속 실패해도 서킷이 안 열렸다.</p>
<p>원인을 찾는 데 꽤 걸렸다. <code>minimum-number-of-calls</code>의 <strong>기본값이 100</strong>이었다.</p>
<p><code>sliding-window-size: 10</code>으로 &quot;최근 10건을 관찰하겠다&quot;고 했는데, <code>minimum-number-of-calls</code>가 100이면 <strong>100번 호출될 때까지 실패율 계산 자체를 안 한다.</strong> 10번 연속 실패해도 서킷은 꿈쩍도 안 한다.</p>
<p>이거 말고도 밟은 함정이 더 있다.</p>
<table>
<thead>
<tr>
<th>함정</th>
<th>증상</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td><code>minimum-number-of-calls</code> 기본값 100</td>
<td>서킷이 안 열림</td>
<td><code>10</code>으로 명시</td>
</tr>
<tr>
<td><code>record-exceptions</code> 미설정</td>
<td>400 에러도 실패로 집계 → 엉뚱하게 서킷 오픈</td>
<td>5xx만 record</td>
</tr>
<tr>
<td><code>automatic-transition</code> 기본값 false</td>
<td>새벽에 서킷이 영원히 OPEN</td>
<td><code>true</code>로 설정</td>
</tr>
<tr>
<td><code>slow-call-threshold</code> &gt; <code>readTimeout</code></td>
<td>느린 호출 감지 사실상 비활성</td>
<td>readTimeout보다 작게</td>
</tr>
</tbody></table>
<p><a href="https://github.com/ghtjr410/resilience4j-lab">ghtjr410/resilience4j-lab</a> 레포에서 이 함정들을 테스트 코드로 증명해둔 게 있었다. 공식 문서에 설정은 다 나와있지만, <strong>설정의 조합이 만드는 함정</strong>은 직접 겪어봐야 알게 된다.</p>
<h3 id="고민-retry와-circuitbreaker를-어디에-붙일까">고민: @Retry와 @CircuitBreaker를 어디에 붙일까</h3>
<p>이 부분에서 꽤 고민했다. 두 어노테이션을 같은 클래스에 붙이면 Aspect 순서가 암묵적이라 의도치 않은 동작이 발생할 수 있다.</p>
<p>다른 수강생의 PR에서 실제로 이 문제가 발생했다. <code>@CircuitBreaker</code>의 <code>fallbackMethod</code>가 원본 예외를 <code>CoreException</code>으로 변환 → <code>@Retry</code>의 <code>ignore-exceptions</code>에 해당 → <strong>Retry가 영원히 재시도하지 않는 구조적 버그</strong>. 성공률이 51% → 6%로 급락했다고 한다.</p>
<p>나는 두 어노테이션을 <strong>다른 클래스에 분리</strong>했다.</p>
<pre><code class="language-java">// PaymentFacade.java — @CircuitBreaker (Fallback에서 비즈니스 처리 가능)
@CircuitBreaker(name = &quot;pg&quot;, fallbackMethod = &quot;requestPaymentFallback&quot;)
public PaymentInfo requestPayment(...) { ... }

// PgPaymentGateway.java — @Retry (PG 호출만 재시도)
@Retry(name = &quot;pg&quot;)
public PgPaymentResponse requestPayment(...) { ... }</code></pre>
<p>이렇게 하면 호출 순서가 자연스럽게 <code>CB(outer) → Retry(inner) → PG</code>가 된다. Retry가 3번 시도한 최종 결과만 CB에 전달되니까, CB는 실패를 1건으로만 기록한다.</p>
<p>왜 이 순서가 맞는지 잠깐 생각해보면:</p>
<pre><code>CB(outer) → Retry(inner): Retry가 3번 시도 → CB에 &quot;1건 실패&quot; 기록
                           → 10명 연속 실패해야 서킷 오픈

Retry(outer) → CB(inner): Retry가 CB를 3번 호출 → CB에 &quot;3건 실패&quot; 기록
                           → 4명만 실패해도 서킷 오픈 (3배 증폭)</code></pre><p><strong>같은 어노테이션인데 순서만 바뀌면 서킷이 3배 빨리 열린다.</strong> 이건 공식 문서에서도 쉽게 놓칠 수 있는 부분이다.</p>
<hr>
<h2 id="4겹-bulkhead--동시에-너무-많이-보내지-않는다">4겹: Bulkhead — &quot;동시에 너무 많이 보내지 않는다&quot;</h2>
<p>서킷브레이커는 실패율 50% 이상에서 열린다. 그러면 <strong>40~49% 구간</strong>에서는?</p>
<p>서킷은 닫혀있으니 모든 요청이 PG로 간다. Retry까지 포함하면 스레드 하나당 점유가 길어지고, Tomcat 200개 스레드가 전부 결제에 묶일 수 있다.</p>
<pre><code class="language-yaml">resilience4j:
  bulkhead:
    instances:
      pg:
        max-concurrent-calls: 20
        max-wait-duration: 0  # 초과 시 즉시 거부</code></pre>
<p>PG 동시 호출을 20개로 제한한다. 21번째부터는 보내지도 않고 바로 실패. 나머지 180개 스레드는 상품 조회, 장바구니 등 다른 API가 쓴다.</p>
<hr>
<h2 id="5겹-tx-분리--db-커넥션을-놓아준다">5겹: TX 분리 — &quot;DB 커넥션을 놓아준다&quot;</h2>
<p>사실 이게 가장 근본적인 변경이었다. Resilience4j를 아무리 잘 달아도, <code>@Transactional</code> 안에서 PG를 호출하면 <strong>DB 커넥션 점유 문제는 그대로</strong>다.</p>
<pre><code class="language-java">// Before: 단일 @Transactional (520ms+ 커넥션 점유)
@Transactional
public PaymentInfo requestPayment(...) {
    order.startPayment();           // DB ~5ms
    payment = save(payment);        // DB ~10ms
    pgGateway.requestPayment(...);  // PG 100~500ms ← 이 동안 커넥션 점유!
    payment.assignTransactionKey(); // DB ~5ms
}</code></pre>
<pre><code class="language-java">// After: TX 분리 (DB 커넥션 20ms만 점유)
public PaymentInfo requestPayment(...) {
    // TX-1: DB 작업만 (~15ms), 커밋 → 커넥션 반환
    PaymentModel payment = paymentService.preparePayment(userId, command);

    // NO TX: PG 호출 — DB 커넥션 안 잡음!
    PgPaymentResponse pgResponse = pgGateway.requestPayment(pgRequest, userId);

    // TX-2: 결과 저장 (~5ms), 커밋 → 커넥션 반환
    paymentService.assignTransactionKey(payment.getId(), pgResponse.transactionKey());
}</code></pre>
<table>
<thead>
<tr>
<th></th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>DB 커넥션 점유</td>
<td>520ms+</td>
<td>~20ms</td>
</tr>
<tr>
<td>동시 결제 10건 시</td>
<td>풀 고갈</td>
<td>여유</td>
</tr>
<tr>
<td>PG 장애 시 DB 영향</td>
<td>전체 API 마비</td>
<td>영향 없음</td>
</tr>
</tbody></table>
<h3 id="고민-트랜잭션을-쪼개면-정합성은">고민: 트랜잭션을 쪼개면 정합성은?</h3>
<p>단일 <code>@Transactional</code>의 가장 큰 장점은 <strong>원자성</strong>이다. PG 호출이 실패하면 주문 상태 변경과 결제 레코드가 모두 롤백된다. 깔끔하다.</p>
<p>TX를 분리하면 이 원자성을 포기하게 된다. TX-1이 커밋된 후 PG 호출이 실패하면:</p>
<pre><code>DB 상태:
  주문: PAYMENT_PENDING (TX-1에서 커밋됨, 되돌릴 수 없음)
  결제: PENDING, transactionKey=null (고아)

PG 상태:
  아무것도 없음 (요청이 안 갔거나, 갔지만 응답을 못 받은 것)</code></pre><p>두 시스템의 상태가 어긋나는 <strong>고아 Payment</strong> 문제다.</p>
<p>이걸 감수하면서까지 TX를 분리한 이유는, <strong>정합성 문제는 복구할 수 있지만, DB 커넥션 고갈은 서비스 전체를 죽이기 때문</strong>이다. 고아는 Polling으로 60초 내에 복구할 수 있다. 하지만 커넥션 고갈은 모든 API를 즉시 마비시킨다.</p>
<p>이 <strong>고아 Payment</strong>를 복구하기 위해 Polling 스케줄러를 만들었다.</p>
<pre><code class="language-java">@Scheduled(fixedDelay = 60_000)
public void pollPendingPayments() {
    List&lt;PaymentModel&gt; pending = paymentService.getPendingPayments();
    int consecutiveFailures = 0;

    for (PaymentModel payment : pending) {
        try {
            paymentFacade.syncPaymentStatus(payment.getId());
            consecutiveFailures = 0;
        } catch (Exception e) {
            if (++consecutiveFailures &gt;= 3) {
                log.error(&quot;PG 연속 3건 실패 — 사이클 중단&quot;);
                break;
            }
        }
    }
}</code></pre>
<p>60초마다 PENDING 결제를 PG에 조회한다. transactionKey가 없는 고아도 orderId로 PG에 물어볼 수 있다.</p>
<p><strong>fail-fast</strong>가 핵심이다. PG가 완전히 죽었을 때 PENDING 100건을 다 조회하면 <code>100 × 3초 = 300초</code>. 연속 3건 실패하면 &quot;PG가 죽었다&quot;로 판단하고 즉시 중단한다.</p>
<hr>
<h2 id="전체-그림">전체 그림</h2>
<pre><code>요청 → Bulkhead (동시 20개)
       → CircuitBreaker (50% 실패 시 차단)
         → Retry (3회, 지수 백오프)
           → PG 호출 (timeout 3초)

실패 시:
  Fallback → &quot;잠시 후 다시 시도해주세요&quot;
  TX-1은 이미 커밋 → 3-Tier 안전망이 복구
    1차: PG 콜백 (정상 도착 시)
    2차: Polling (60초 주기 자동)
    3차: 수동 API (/payments/{id}/sync)</code></pre><table>
<thead>
<tr>
<th>레이어</th>
<th>역할</th>
<th>없으면?</th>
</tr>
</thead>
<tbody><tr>
<td>Timeout</td>
<td>무한 대기 방지</td>
<td>스레드 영원히 잠김 → 서비스 마비</td>
</tr>
<tr>
<td>Retry</td>
<td>일시적 실패 복구</td>
<td>성공률 60% 고정</td>
</tr>
<tr>
<td>CircuitBreaker</td>
<td>장애 시 빠른 실패</td>
<td>매 요청 10초 대기 → 스레드 고갈</td>
</tr>
<tr>
<td>Bulkhead</td>
<td>동시 호출 제한</td>
<td>Tomcat 전체 스레드 잠김</td>
</tr>
<tr>
<td>TX 분리</td>
<td>DB 커넥션 보호</td>
<td>PG 대기 중 커넥션 고갈</td>
</tr>
<tr>
<td>Polling</td>
<td>고아 복구</td>
<td>PENDING 영구 잔류</td>
</tr>
</tbody></table>
<hr>
<h2 id="아직-남은-고민들">아직 남은 고민들</h2>
<p>완성은 아니다. 풀지 못한 질문들이 남아있다.</p>
<p><strong>Fallback에서 사용자에게 뭘 보여줘야 하나?</strong></p>
<p>현재는 <code>&quot;결제 시스템이 불안정합니다. 잠시 후 다시 시도해주세요&quot;</code>를 반환한다. 하지만 TX-1이 이미 커밋되어서 주문은 PAYMENT_PENDING 상태다. 사용자 입장에서는 &quot;결제가 된 건가, 안 된 건가?&quot; 혼란스럽다. Polling이 60초 후에 복구하지만, 그 60초 동안 사용자 경험은?</p>
<p><strong>설정값이 시뮬레이터에 과적합된 건 아닌가?</strong></p>
<p><code>failure-rate-threshold: 50%</code>는 40% 실패율 시뮬레이터에 맞춘 값이다. 실제 PG 실패율이 1~5%인 운영 환경에서는 어떤 값이 적절할까? 30%로 낮추면 일시적 spike에도 서킷이 열릴 수 있고, 너무 높으면 장애 감지가 느려진다. 이건 실제 트래픽 데이터 없이는 답하기 어렵다.</p>
<p><strong>PG에서는 결제가 됐는데 우리는 실패 처리한 경우는?</strong></p>
<p>타임아웃으로 실패 처리했지만, PG에서는 실제로 카드 승인이 된 경우. 현재는 Polling이 60초 후에 SUCCESS로 맞춰주지만, 그 사이에 사용자가 결제를 다시 시도하면 <strong>이중 결제</strong>가 발생할 수 있다. 멱등성 키나 결제 상태 확인 후 재시도 같은 추가 장치가 필요하다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>장애 대응은 한 방에 완성되지 않았다.</p>
<p>Timeout만으로 부족해서 Retry를 추가했고, Retry만으로 부족해서 CircuitBreaker를 추가했고, 그걸로도 부족해서 Bulkhead를 추가했고, 이 모든 게 <code>@Transactional</code> 안에 있어서 DB가 죽어서 TX를 분리했고, TX를 분리했더니 고아가 생겨서 Polling을 추가했다.</p>
<p>각 레이어는 독립적이 아니다. <strong>이전 레이어의 빈틈을 메우는 관계</strong>다. &quot;왜 이게 필요한가&quot;의 답은 항상 &quot;이전 것만으로는 못 막는 시나리오가 있기 때문&quot;이었다.</p>
<p>PG 시뮬레이터의 40%는 극단적이다. 실제로는 이 정도가 아닐 거다. 하지만 이 환경에서 시스템을 지탱하는 구조를 만들어봤기 때문에, 진짜 장애가 왔을 때 <strong>어디를 봐야 하는지</strong>는 알게 됐다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인덱스를 걸었는데 왜 안 빨라졌을까]]></title>
            <link>https://velog.io/@praesentia-ykm/%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%EA%B1%B8%EC%97%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%95%88-%EB%B9%A8%EB%9D%BC%EC%A1%8C%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@praesentia-ykm/%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%EA%B1%B8%EC%97%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EC%95%88-%EB%B9%A8%EB%9D%BC%EC%A1%8C%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Fri, 13 Mar 2026 08:26:28 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/a19bd97a-ba44-4566-8735-c0507124f6c2/image.png" alt=""></p>
<p><strong>TL;DR</strong>
 결국 &quot;어디에 인덱스를 거는가&quot;보다 <strong>&quot;쿼리가 실제로 어떻게 실행되는가&quot;</strong>를 먼저 이해해야 했고, EXPLAIN을 읽는 법을 배운 뒤에야 진짜 병목을 찾을 수 있었다.</p>
<hr>
<h2 id="1-처음-든-생각은-단순했다">1. 처음 든 생각은 단순했다</h2>
<p>사내 ERP 시스템에 <strong>계약대장관리</strong> 화면이 있다. 계약 건별 마스터 정보를 조회하는 화면인데, 검색 조건을 입력하고 조회 버튼을 누르면 체감상 3~5초는 걸렸다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/101fff84-ef0f-4407-aaf8-76053da89dd6/image.png" alt=""></p>
<p>데이터는 약 5만 건. 뭐 엄청 많은 건 아니다.</p>
<p>처음 든 생각은 이거였다.</p>
<blockquote>
<p>&quot;WHERE에 쓰이는 컬럼에 인덱스를 걸면 빨라지겠지.&quot;</p>
</blockquote>
<p>근데 이 판단이 틀렸다.</p>
<hr>
<h2 id="2-내가-뭘-모르는지-점검해봤다">2. 내가 뭘 모르는지 점검해봤다</h2>
<p>틀렸다는 건 안다. 근데 <strong>왜</strong> 틀렸는지를 설명하지 못하겠다.</p>
<p>일단 내 인식 상태를 점검해봤다.</p>
<pre><code>현재 문제: 인덱스를 걸었는데 안 빨라졌다.
내가 한 것: WHERE절의 필수 컬럼(fiscal_year)에 인덱스를 걸었다.
내가 아는 것: &quot;조회 조건에 쓰이는 컬럼에 인덱스를 걸면 빠르다.&quot;
내가 모르는 것: 왜 안 빨라졌는지.</code></pre><p>여기서 멈췄다. &quot;인덱스를 걸면 빠르다&quot;는 건 알고 있었다. 근데 <strong>빨라지지 않는 경우가 있다</strong>는 건 생각해본 적이 없었다. 아는 것만으로는 이 상황을 설명할 수 없었다.</p>
<p>그러면 내가 모르는 건 뭘까?</p>
<blockquote>
<p>&quot;이 쿼리가 실제로 어떤 순서로 실행되는지&quot;를 모른다.</p>
</blockquote>
<p>나는 쿼리를 <strong>텍스트</strong>로만 읽고 있었다. WHERE절에 뭐가 있는지, JOIN이 몇 개인지. 하지만 MySQL이 이 쿼리를 <strong>어떤 순서로, 어떤 방식으로 실행하는지</strong>는 한 번도 확인하지 않았다.</p>
<hr>
<h2 id="3-쿼리를-처음부터-다시-읽었다">3. 쿼리를 처음부터 다시 읽었다</h2>
<p>WHERE절만 볼 게 아니라 쿼리 전체를 읽었다. 그제서야 구조가 보였다.</p>
<pre><code>contract_ledger (메인)
  ├─ LEFT JOIN ① : 거래처 건수 (COUNT + GROUP BY 서브쿼리)
  ├─ LEFT JOIN ② : 1순번 거래처 상세 (INNER JOIN 포함)
  ├─ LEFT JOIN ③ : 대표 거래처 상세 (INNER JOIN 포함)
  └─ LEFT JOIN ④ : 하자보증 종료일 집계 (GROUP BY 서브쿼리)</code></pre><p>4개의 LEFT JOIN. 그 중 2개는 서브쿼리다.</p>
<p>왜 이렇게 복잡할까? 화면 요구사항 때문이다.</p>
<ul>
<li>목록에 <strong>거래처명</strong>이 보여야 한다 → JOIN ②③</li>
<li>거래처가 <strong>몇 개인지</strong> 보여야 한다 → JOIN ①</li>
<li><strong>하자보증 만료일</strong>이 보여야 한다 → JOIN ④</li>
</ul>
<p>한 화면에 보여줄 정보가 많으니, 쿼리도 그만큼 복잡해진 것이다. 그리고 이 JOIN들에는 <strong>인덱스가 하나도 없었다.</strong></p>
<p>WHERE절의 fiscal_year에 인덱스를 걸어서 메인 테이블을 빠르게 찾아도, <strong>JOIN으로 붙는 테이블 4개가 전부 Full Scan이면</strong> 전체 쿼리는 느릴 수밖에 없다.</p>
<p>근데 이건 쿼리를 <strong>텍스트로 읽어서</strong> 알게 된 거지, <strong>실제 실행 계획을 확인</strong>한 건 아니다. &quot;아마 여기가 문제일 거야&quot;라는 건 추측이다. 추측으로 인덱스를 건 게 처음의 실수였으니, 이번에는 추측하지 않기로 했다.</p>
<hr>
<h2 id="4-explain--추측-대신-실행-계획을-읽었다">4. EXPLAIN — 추측 대신 실행 계획을 읽었다</h2>
<h3 id="explain이-뭔가">EXPLAIN이 뭔가</h3>
<p><code>EXPLAIN</code>은 MySQL에게 &quot;이 쿼리를 어떻게 실행할 건지 알려달라&quot;고 요청하는 명령이다. 쿼리를 실제로 실행하지 않고, <strong>실행 계획</strong>만 보여준다.</p>
<pre><code class="language-sql">EXPLAIN
SELECT ...
FROM contract_ledger LEG
LEFT JOIN (...) ...
WHERE fiscal_year = &#39;2026&#39;;</code></pre>
<p>이렇게 쿼리 앞에 <code>EXPLAIN</code>만 붙이면 된다. 결과는 테이블 형태로 나온다.</p>
<h3 id="처음-돌려본-결과">처음 돌려본 결과</h3>
<pre><code>┌──────────────────┬───────┬────────┬───────────────────────┬──────────┐
│      table       │ type  │  rows  │         extra         │   key    │
├──────────────────┼───────┼────────┼───────────────────────┼──────────┤
│ contract_ledger  │ ALL   │ 50,000 │ Using where           │ NULL     │
│ (서브쿼리 ①)     │ ALL   │ 80,000 │ Using temporary       │ NULL     │
│ (서브쿼리 ②)     │ ALL   │ 80,000 │ Using where           │ NULL     │
│ (서브쿼리 ④)     │ ALL   │ 10,000 │ Using temporary       │ NULL     │
└──────────────────┴───────┴────────┴───────────────────────┴──────────┘</code></pre><p>전부 <code>type = ALL</code>. 전부 <code>key = NULL</code>.</p>
<p>근데 솔직히 이걸 처음 봤을 때, <strong>뭘 봐야 하는지 몰랐다.</strong> 컬럼이 여러 개 있는데, 어떤 게 중요한 건지 감이 안 잡혔다.</p>
<p>멘토링에서 들은 말이 기준이 됐다.</p>
<blockquote>
<p>&quot;EXPLAIN에서 봐야 할 건 세 가지다. <strong>rows</strong> — 몇 건을 스캔했는지, <strong>type</strong> — 어떻게 접근했는지, <strong>extra</strong> — Using filesort나 Using temporary가 있으면 그게 병목이다.&quot;</p>
</blockquote>
<p>이 세 가지를 기준으로 다시 읽어봤다.</p>
<h3 id="explain-결과-이렇게-읽는다">EXPLAIN 결과, 이렇게 읽는다</h3>
<p>EXPLAIN을 읽을 줄 모르면 결과가 나와도 해석이 안 된다. 나도 그랬다. 각 컬럼이 무엇을 말하는지 정리해본다.</p>
<h4 id="type--이-테이블에-어떻게-접근했는가">type — &quot;이 테이블에 어떻게 접근했는가&quot;</h4>
<p>이게 가장 중요하다. 위에서 아래로 갈수록 느리다.</p>
<table>
<thead>
<tr>
<th>type</th>
<th>의미</th>
<th>비유</th>
</tr>
</thead>
<tbody><tr>
<td><strong>const</strong></td>
<td>PK 또는 UNIQUE로 정확히 1건 조회</td>
<td>주민번호로 본인 찾기</td>
</tr>
<tr>
<td><strong>eq_ref</strong></td>
<td>JOIN에서 PK/UNIQUE로 1건씩 매칭</td>
<td>출석부에서 이름표로 1:1 매칭</td>
</tr>
<tr>
<td><strong>ref</strong></td>
<td>인덱스로 여러 건 조회</td>
<td>목차에서 &quot;3장&quot; 찾기 → 여러 페이지</td>
</tr>
<tr>
<td><strong>range</strong></td>
<td>인덱스 범위 스캔 (BETWEEN, &gt;, &lt;)</td>
<td>목차에서 &quot;3장~5장&quot; 범위로 찾기</td>
</tr>
<tr>
<td><strong>index</strong></td>
<td>인덱스 전체 스캔 (데이터보단 작음)</td>
<td>목차를 처음부터 끝까지 읽기</td>
</tr>
<tr>
<td><strong>ALL</strong></td>
<td>테이블 전체 스캔 ❌</td>
<td>책을 1페이지부터 끝까지 넘기기</td>
</tr>
</tbody></table>
<p>여기서 한 가지 의문이 들 수 있다. <strong>ref와 range가 뭐가 다른 건가?</strong></p>
<pre><code class="language-sql">-- ref: Equal(=) 연산. 인덱스에서 정확한 지점을 찾아 여러 건 조회
WHERE fiscal_year = &#39;2026&#39;
→ 인덱스에서 &#39;2026&#39; 위치를 바로 찾음. 거기서 연속된 행들을 읽음.

-- range: 범위 연산. 인덱스에서 시작점~끝점 사이를 스캔
WHERE contract_date &gt;= &#39;2026-01-01&#39; AND contract_date &lt;= &#39;2026-12-31&#39;
→ 인덱스에서 &#39;2026-01-01&#39; 위치를 찾고, &#39;2026-12-31&#39;까지 순차 스캔.</code></pre>
<p>ref는 &quot;정확한 지점&quot;을 찾는 거고, range는 &quot;범위를 훑는&quot; 거다. 복합 인덱스에서 Equal 컬럼을 앞에, Range 컬럼을 뒤에 배치해야 하는 이유가 여기에 있다. 뒤에서 다시 다룬다.</p>
<p><strong>내 쿼리는 전부 ALL이었다.</strong> 4개 테이블 모두 책을 처음부터 끝까지 넘기고 있었다.</p>
<h4 id="rows--몇-건을-읽어야-하는가">rows — &quot;몇 건을 읽어야 하는가&quot;</h4>
<p>MySQL이 <strong>예상하는 스캔 대상 행 수</strong>다. 핵심은 이것이 <strong>결과 행 수가 아니라는 점</strong>이다.</p>
<pre><code>rows = 80,000이면?
→ 결과가 3건이어도, 그 3건을 찾기 위해 8만 건을 훑어본다는 뜻
→ 이게 JOIN마다 발생하면, 50,000 × 80,000 = 40억 번의 비교가 될 수 있다</code></pre><p>내 쿼리에서 서브쿼리 rows가 80,000이었다. 결과는 기껏 1~3건인데, 그걸 찾으려고 매번 전체를 스캔하고 있었다.</p>
<h4 id="key--실제로-사용한-인덱스">key — &quot;실제로 사용한 인덱스&quot;</h4>
<table>
<thead>
<tr>
<th>컬럼</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><strong>possible_keys</strong></td>
<td>사용 가능한 인덱스 후보 목록</td>
</tr>
<tr>
<td><strong>key</strong></td>
<td>옵티마이저가 실제로 선택한 인덱스</td>
</tr>
</tbody></table>
<p><code>key = NULL</code>이면 인덱스를 안 쓴 것이다. <strong>내 쿼리는 전부 NULL이었다.</strong></p>
<p>가끔 possible_keys에는 있는데 key가 NULL인 경우가 있다. 이건 옵티마이저가 <strong>&quot;인덱스 안 쓰는 게 더 빠르다&quot;</strong>고 판단한 것이다. 테이블이 작거나, 인덱스의 선택도(selectivity)가 낮을 때 발생한다.</p>
<h4 id="extra--추가로-벌어지는-일">extra — &quot;추가로 벌어지는 일&quot;</h4>
<p>여기가 <strong>병목의 증거</strong>가 나오는 곳이다.</p>
<table>
<thead>
<tr>
<th>extra</th>
<th>의미</th>
<th>위험도</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Using where</strong></td>
<td>WHERE 조건으로 필터링 중</td>
<td>보통 (정상 동작)</td>
</tr>
<tr>
<td><strong>Using index</strong></td>
<td>커버링 인덱스 사용 ✅</td>
<td>좋음 (디스크 I/O 없음)</td>
</tr>
<tr>
<td><strong>Using temporary</strong></td>
<td>임시 테이블 생성 ❌</td>
<td>나쁨</td>
</tr>
<tr>
<td><strong>Using filesort</strong></td>
<td>별도 정렬 수행 ❌</td>
<td>나쁨</td>
</tr>
<tr>
<td><strong>Using index condition</strong></td>
<td>ICP(Index Condition Pushdown)</td>
<td>보통</td>
</tr>
</tbody></table>
<p><strong>Using temporary</strong>는 MySQL이 GROUP BY나 DISTINCT를 처리하기 위해 <strong>임시 테이블을 메모리에 만드는 것</strong>이다. 데이터가 크면 디스크에 쓰기도 한다.</p>
<p><strong>Using filesort</strong>는 ORDER BY를 인덱스로 처리하지 못해서 <strong>별도의 정렬 작업</strong>을 수행하는 것이다.</p>
<blockquote>
<p>Using temporary + Using filesort가 동시에 나오면 최악이다. 임시 테이블을 만들고, 그 안에서 다시 정렬까지 하는 것이니까.</p>
</blockquote>
<h3 id="그래서-병목은-어디였나">그래서 병목은 어디였나</h3>
<p>다시 내 EXPLAIN 결과를 봤다.</p>
<pre><code>서브쿼리 ① : type=ALL, rows=80,000, extra=Using temporary
서브쿼리 ④ : type=ALL, rows=10,000, extra=Using temporary</code></pre><p><strong>Using temporary가 2개.</strong> 서브쿼리가 GROUP BY를 처리하기 위해 매번 임시 테이블을 만들고 있었다.</p>
<p>메인 테이블에 인덱스를 걸어서 500건으로 줄여봤자, <strong>서브쿼리가 매번 8만 건을 전부 훑으면서 임시 테이블을 만들고 있으면</strong> 전체 쿼리는 느릴 수밖에 없다.</p>
<p>처음에 나는 &quot;WHERE절이 느린 거다&quot;라고 생각했다. EXPLAIN을 보니 <strong>병목은 WHERE절이 아니라 JOIN이었다.</strong></p>
<hr>
<h2 id="5-틀린-판단-2-인덱스를-많이-걸면-위험하다고-아꼈다">5. 틀린 판단 2: &quot;인덱스를 많이 걸면 위험하다&quot;고 아꼈다</h2>
<p>병목을 찾고 나서도 바로 인덱스를 추가하지 못했다. 이런 생각이 있었기 때문이다.</p>
<blockquote>
<p>&quot;인덱스를 많이 걸면 INSERT/UPDATE가 느려진다고 했는데, 4개나 추가해도 괜찮을까?&quot;</p>
</blockquote>
<p>이것도 틀린 판단이었다. 정확히 말하면, <strong>맞는 말이지만 이 상황에는 적용되지 않는 말</strong>이었다.</p>
<h3 id="왜-이-상황에는-적용되지-않는가">왜 이 상황에는 적용되지 않는가</h3>
<p>인덱스를 추가하면 쓰기가 느려지는 건 사실이다. INSERT나 UPDATE가 발생할 때마다 해당 인덱스도 함께 갱신해야 하니까. 근데 이 원칙이 적용되려면 <strong>전제 조건</strong>이 있다.</p>
<pre><code>&quot;인덱스가 많으면 쓰기가 느려진다&quot;가 문제가 되려면:
→ 쓰기가 빈번해야 한다.

이 테이블의 실상:
→ 등록은 월 수십 건, 수정은 거의 없음
→ 조회는 하루 수십~수백 번</code></pre><p>읽기와 쓰기의 비율이 <strong>100:1 이상</strong>이다. 이런 테이블에서 인덱스 4개를 아끼는 건, 멘토링에서 들은 말을 빌리면:</p>
<blockquote>
<p>&quot;인덱스 개수 자체는 무의미하다. 조회에 쓰이는 컬럼은 무조건 인덱스를 걸어라. 쓰기 부담 정리는 이후 최적화 단계다.&quot;</p>
</blockquote>
<p><strong>&quot;인덱스가 많으면 위험하다&quot;는 일반론이 이 테이블에 적용되지 않는 이유를 한 줄로 정리하면:</strong> 이 테이블은 읽기가 압도적이다. 읽기 편향 테이블에서 인덱스를 아끼는 건 잘못된 절약이다.</p>
<hr>
<h2 id="6-인덱스를-어디에-왜-이-순서로-걸었는가">6. 인덱스를 어디에, 왜 이 순서로 걸었는가</h2>
<h3 id="질문-동적-where에-하나의-완벽한-인덱스가-가능한가">질문: 동적 WHERE에 &quot;하나의 완벽한 인덱스&quot;가 가능한가?</h3>
<p>이 쿼리의 WHERE절은 MyBatis <code>&lt;if&gt;</code> 태그로 <strong>10개 이상의 동적 조건</strong>이 있다. 사용자가 어떤 조건을 입력하느냐에 따라 실제 실행되는 쿼리가 달라진다.</p>
<p>&quot;모든 조합을 커버하는 인덱스&quot;는 불가능하다. 그래서 <strong>실제 사용 빈도</strong>를 기준으로 잡았다.</p>
<pre><code>[패턴 A] ★★★ 가장 빈번: fiscal_year만으로 전체 조회
[패턴 B] ★★  빈번:     fiscal_year + contract_type (Equal)
[패턴 C] ★★  빈번:     fiscal_year + contract_date (Range)
[패턴 D] ★   가끔:     fiscal_year + contract_name (LIKE)
[패턴 E] ★   가끔:     fiscal_year + manage_dept (Equal)</code></pre><p>ERP 특성상 회계연도를 먼저 선택하고 전체 목록을 본 뒤 필터링하는 패턴이 대부분이었다
이래서 사용자 패턴을 분석하는 것도 중요하다는 것을 느꼈다.</p>
<h3 id="복합-인덱스의-컬럼-순서--왜-이-순서여야-하는가">복합 인덱스의 컬럼 순서 — 왜 이 순서여야 하는가</h3>
<p>패턴 A, B, C를 하나의 복합 인덱스로 커버하려면:</p>
<pre><code class="language-sql">CREATE INDEX idx_ledger_main
ON contract_ledger(fiscal_year, contract_type, contract_date);</code></pre>
<p>왜 <code>(fiscal_year, contract_type, contract_date)</code> 이 순서인가?</p>
<p><strong>원칙 1: 필수조건을 맨 앞에 (Leftmost Prefix)</strong></p>
<p>복합 인덱스는 <strong>왼쪽부터 순서대로</strong> 작동한다. 맨 앞 컬럼이 없으면 인덱스 자체를 탈 수 없다.</p>
<pre><code>전화번호부가 (성, 이름, 전화번호) 순으로 정렬되어 있다고 하자.

✅ &quot;김&quot; 씨를 찾아주세요         → 바로 찾음
✅ &quot;김&quot; 씨 중 &quot;민수&quot;를 찾아주세요 → 바로 찾음
❌ &quot;민수&quot;를 찾아주세요 (성 모름)  → 처음부터 끝까지 봐야 함</code></pre><p><code>fiscal_year</code>는 <strong>모든 조회에 항상 포함</strong>되는 필수조건이다. 그러니 맨 앞에 둔다.</p>
<p><strong>원칙 2: Equal 조건을 Range 조건보다 앞에</strong></p>
<p>복합 인덱스 <code>(A, B, C)</code>에서 B가 Range 연산이면, <strong>C는 인덱스를 탈 수 없다.</strong></p>
<p>왜 그런가? 인덱스는 정렬된 순서로 저장된다. B에서 범위로 흩어지면, 그 안에서 C의 순서가 보장되지 않기 때문이다.</p>
<pre><code>인덱스: (fiscal_year, contract_type, contract_date)

✅ fiscal_year = &#39;2026&#39; AND contract_type = &#39;A&#39; AND contract_date &gt;= &#39;2026-01-01&#39;
   → fiscal_year(Equal) → contract_type(Equal) → contract_date(Range)
   → 3개 컬럼 모두 인덱스 활용

만약 순서가 (fiscal_year, contract_date, contract_type)이었다면?
❌ fiscal_year = &#39;2026&#39; AND contract_date &gt;= &#39;2026-01-01&#39; AND contract_type = &#39;A&#39;
   → fiscal_year(Equal) → contract_date(Range) → contract_type(Equal)
   → contract_date에서 범위로 흩어지므로, contract_type은 인덱스 미활용</code></pre><p>그래서 Equal인 <code>contract_type</code>을 두 번째에, Range인 <code>contract_date</code>를 맨 뒤에 배치했다.</p>
<p><strong>그런데 한 가지 의문.</strong></p>
<blockquote>
<p>&quot;인덱스 1에 manage_dept를 추가하면 안 되나? (fiscal_year, contract_type, contract_date, manage_dept)로?&quot;</p>
</blockquote>
<p>할 수 있다. 하지만 contract_date가 Range 연산이면 <strong>그 뒤의 manage_dept는 인덱스를 타지 않는다.</strong> Range 이후의 컬럼은 인덱스가 중단되기 때문이다. 그래서 별도 인덱스를 만들었다.</p>
<pre><code class="language-sql">CREATE INDEX idx_ledger_dept
ON contract_ledger(fiscal_year, manage_dept);</code></pre>
<h3 id="진짜-효과가-컸던-건--join-인덱스">진짜 효과가 컸던 건 — JOIN 인덱스</h3>
<p>메인 테이블 인덱스보다 <strong>체감 효과가 훨씬 컸던 건</strong> 서브쿼리 쪽이었다.</p>
<pre><code class="language-sql">CREATE INDEX idx_partner_lookup
ON contract_partner(contract_no, partner_seq);</code></pre>
<p>LEFT JOIN ①②③이 전부 <code>contract_no</code>로 조인한다. <strong>이 인덱스 하나로 3개의 JOIN이 개선됐다.</strong></p>
<p>특히 JOIN ①의 서브쿼리를 자세히 보자:</p>
<pre><code class="language-sql">SELECT COUNT(partner_seq), contract_no
FROM contract_partner
GROUP BY contract_no</code></pre>
<p>이 서브쿼리가 <code>(contract_no, partner_seq)</code> 인덱스를 타면 <strong>커버링 인덱스</strong>가 된다.</p>
<p>커버링 인덱스란, <strong>쿼리가 필요한 모든 컬럼이 인덱스에 포함되어 있는 상태</strong>를 말한다. 이 경우 MySQL은 디스크의 실제 데이터 페이지를 읽을 필요 없이, <strong>인덱스 페이지만 읽어서 결과를 반환</strong>할 수 있다.</p>
<pre><code>왜 커버링 인덱스가 되는가?

SELECT에 사용된 컬럼: contract_no, partner_seq
인덱스에 포함된 컬럼: contract_no, partner_seq

→ SELECT가 필요로 하는 모든 컬럼이 인덱스에 포함 ✅
→ 디스크 I/O 없이 인덱스 페이지만 읽음
→ EXPLAIN extra에 &quot;Using index&quot;로 확인 가능</code></pre><p>커버링 인덱스가 되면 <strong>Using temporary가 사라진다.</strong> GROUP BY를 처리하기 위해 임시 테이블을 만들 필요가 없기 때문이다. 인덱스 자체가 <code>contract_no</code> 순으로 정렬되어 있으니, GROUP BY도 인덱스 순서를 그대로 따라가면 된다.</p>
<p>하자관리 테이블도 같은 논리로:</p>
<pre><code class="language-sql">CREATE INDEX idx_defect_lookup
ON contract_defect(contract_no, defect_warranty_end);</code></pre>
<!-- 📸 [YOUR ACTION] 4개 인덱스 모두 적용한 후 EXPLAIN 결과 캡처
     - 핵심 확인 포인트:
       1. contract_partner의 type이 ALL → ref로 바뀌었는가?
       2. extra가 Using temporary → Using index로 바뀌었는가?
       3. rows가 80,000 → 몇으로 줄었는가?
     - 이 스크린샷이 블로그의 **클라이맥스**
-->

<hr>
<h2 id="7-인덱스로-해결할-수-없는-것들">7. 인덱스로 해결할 수 없는 것들</h2>
<p>여기까지 오면 &quot;인덱스를 잘 걸면 다 빨라진다&quot;고 생각할 수 있다. 근데 <strong>그렇지 않은 경우</strong>가 있다. 이걸 모르면 인덱스가 안 먹히는 조건에서 헛삽질을 하게 된다.</p>
<h3 id="like-keyword--왜-인덱스를-못-타는가">LIKE &#39;%keyword%&#39; — 왜 인덱스를 못 타는가</h3>
<pre><code class="language-sql">WHERE contract_name LIKE &#39;%공사%&#39;</code></pre>
<p>이건 인덱스를 <strong>절대</strong> 타지 않는다.</p>
<p>왜?</p>
<p>인덱스는 정렬된 순서로 데이터를 저장한다. <code>LIKE &#39;공사%&#39;</code>는 &quot;공사&quot;로 시작하는 지점을 바로 찾을 수 있다. B-Tree 인덱스에서 &quot;공사&quot;라는 시작점이 명확하니까.</p>
<p>하지만 <code>LIKE &#39;%공사%&#39;</code>는 <strong>시작점이 없다.</strong> &quot;공사&quot;가 어디에 있을지 알 수 없으니 처음부터 끝까지 다 봐야 한다. 전화번호부에서 &quot;이름에 &#39;민&#39;이 들어간 사람&quot;을 찾으려면 1페이지부터 끝까지 넘겨야 하는 것과 같다.</p>
<p><strong>그래서 어떻게 했는가?</strong></p>
<p>포기했다. 정확히 말하면, LIKE 자체는 그대로 두고 <strong>다른 인덱스가 rows를 충분히 줄여주는 구조</strong>로 만들었다.</p>
<pre><code>Before: 50,000건에 LIKE Full Scan → 느림
After:  500건 (fiscal_year 인덱스로 축소) + LIKE 필터 → 무시할 수준</code></pre><p>5만 건에서 LIKE를 거는 것과 500건에서 LIKE를 거는 건 차원이 다르다. <strong>인덱스로 해결할 수 없는 것을 인정하고, 다른 인덱스가 먼저 rows를 줄여주는 구조를 만드는 것</strong>이 현실적인 대응이었다.</p>
<blockquote>
<p>&quot;모든 걸 인덱스로 해결하려 하면 안 된다&quot;는 건, 인덱스 설계에서 가장 중요한 인식 중 하나인 것 같다.</p>
</blockquote>
<h3 id="date-함수--컬럼에-함수를-씌우면-인덱스가-죽는다">DATE 함수 — 컬럼에 함수를 씌우면 인덱스가 죽는다</h3>
<pre><code class="language-sql">-- AS-IS ❌
WHERE CURDATE() &gt;= DATE_SUB(DATE_ADD(completion_date, INTERVAL 1 YEAR), INTERVAL 15 DAY)</code></pre>
<p><code>completion_date</code>에 인덱스가 있어도, <strong>컬럼에 함수를 씌우면 인덱스를 탈 수 없다.</strong></p>
<p>왜? DB 입장에서 생각해보자. 인덱스에는 <code>completion_date</code> 원본 값이 정렬되어 있다. 근데 <code>DATE_ADD(completion_date, ...)</code>의 결과가 뭔지는 <strong>모든 행에 대해 함수를 실행해봐야</strong> 안다. 인덱스의 정렬 순서와 함수 적용 후의 순서가 같다는 보장이 없으니, 인덱스를 쓸 수 없는 것이다.</p>
<p>해결은 간단하다. <strong>함수를 컬럼이 아닌 상수 쪽으로 옮긴다.</strong></p>
<pre><code class="language-sql">-- TO-BE ✅
WHERE completion_date &lt;= DATE_ADD(DATE_SUB(CURDATE(), INTERVAL 1 YEAR), INTERVAL 15 DAY)</code></pre>
<p>같은 논리인데 <code>completion_date</code>에 직접 비교하도록 변환했다. 이제 인덱스를 탈 수 있다. <code>CURDATE()</code>와 <code>DATE_ADD</code>는 <strong>상수로 한 번만 계산</strong>되기 때문이다.</p>
<blockquote>
<p>WHERE절에서 컬럼은 &quot;벌거벗은 상태&quot;여야 한다. 함수, 연산, 형변환을 씌우는 순간 인덱스는 무력화된다.</p>
</blockquote>
<hr>
<h2 id="8-적용-결과">8. 적용 결과</h2>
<h3 id="explain-비교">EXPLAIN 비교</h3>
<pre><code>■ Before
┌──────────────────┬───────┬────────┬───────────────────────┬──────────┐
│      table       │ type  │  rows  │         extra         │   key    │
├──────────────────┼───────┼────────┼───────────────────────┼──────────┤
│ contract_ledger  │ ALL   │ 50,000 │ Using where           │ NULL     │
│ contract_partner │ ALL   │ 80,000 │ Using temporary       │ NULL     │
│ contract_defect  │ ALL   │ 10,000 │ Using temporary       │ NULL     │
└──────────────────┴───────┴────────┴───────────────────────┴──────────┘

■ After
┌──────────────────┬───────┬───────┬───────────────────────┬────────────────────┐
│      table       │ type  │ rows  │         extra         │        key         │
├──────────────────┼───────┼───────┼───────────────────────┼────────────────────┤
│ contract_ledger  │ ref   │   500 │ Using where           │ idx_ledger_main    │
│ contract_partner │ ref   │     3 │ Using index           │ idx_partner_lookup │
│ contract_defect  │ ref   │     2 │ Using index           │ idx_defect_lookup  │
└──────────────────┴───────┴───────┴───────────────────────┴────────────────────┘</code></pre><table>
<thead>
<tr>
<th>지표</th>
<th>Before</th>
<th>After</th>
<th>변화</th>
</tr>
</thead>
<tbody><tr>
<td>메인 테이블 type</td>
<td>ALL</td>
<td>ref</td>
<td>Full Scan → Index Scan</td>
</tr>
<tr>
<td>메인 테이블 rows</td>
<td>50,000</td>
<td>500</td>
<td><strong>100배 감소</strong></td>
</tr>
<tr>
<td>서브쿼리 type</td>
<td>ALL</td>
<td>ref</td>
<td>Full Scan → Index Lookup</td>
</tr>
<tr>
<td>서브쿼리 rows</td>
<td>80,000</td>
<td>3</td>
<td><strong>26,000배 감소</strong></td>
</tr>
<tr>
<td>extra</td>
<td>Using temporary</td>
<td>Using index</td>
<td>임시 테이블 → 커버링 인덱스</td>
</tr>
</tbody></table>
<p>숫자만 봐도 차이가 크지만, 핵심은 <strong>Using temporary → Using index</strong>다. 서브쿼리가 매번 8만 건을 GROUP BY하면서 임시 테이블을 만들던 게, 인덱스만으로 3건을 찾는 것으로 바뀌었다. 임시 테이블 생성이라는 <strong>무거운 연산 자체가 사라진 것</strong>이다.</p>
<h3 id="응답시간">응답시간</h3>
<pre><code>TODO: 실제 측정값으로 교체

Before: 약 5초
After:  약 0.02초
개선율: 99.6%</code></pre><hr>
<h2 id="9-돌아보면">9. 돌아보면</h2>
<h3 id="내가-빠졌던-함정">내가 빠졌던 함정</h3>
<p>이번 과정에서 내가 빠졌던 함정은 세 가지다.</p>
<p><strong>함정 1: WHERE절만 보고 인덱스를 설계했다.</strong></p>
<p>쿼리 튜닝이라고 하면 반사적으로 WHERE절을 본다. 틀린 건 아니다. 하지만 이 쿼리의 병목은 WHERE가 아니라 <strong>FROM절의 서브쿼리 JOIN</strong>이었다.</p>
<p>&quot;어디에 인덱스를 거는가&quot;를 묻기 전에, <strong>&quot;이 쿼리가 실제로 어떻게 실행되는가&quot;</strong>를 먼저 물어야 했다.</p>
<p><strong>함정 2: &quot;인덱스가 많으면 위험하다&quot;는 일반론에 매몰됐다.</strong></p>
<p>맞는 말이다. 하지만 <strong>이 테이블에는 해당되지 않는 말</strong>이었다. 일반론을 적용하려면 전제 조건(쓰기가 빈번한가?)을 먼저 확인해야 했다.</p>
<p>인덱스 개수가 아니라, <strong>이 테이블의 읽기/쓰기 비율</strong>이 판단 기준이다.</p>
<p><strong>함정 3: EXPLAIN을 확인하지 않고 쿼리만 보고 추측했다.</strong></p>
<p>&quot;여기가 느릴 것 같다&quot;로 시작하면 틀린다. EXPLAIN을 돌리기 전의 내 추측과 실제 결과는 달랐다. 메인 테이블이 아니라 서브쿼리가 범인이었고, Using temporary라는 경고를 눈으로 확인하고 나서야 확신이 생겼다.</p>
<p>추측하지 마라. EXPLAIN을 돌려라. <strong>데이터가 답이다.</strong></p>
<h3 id="정리하면">정리하면...</h3>
<pre><code>1. 쿼리를 전부 읽어라
   — WHERE절만이 아니라 FROM, JOIN, 서브쿼리까지.

2. EXPLAIN을 돌려라
   — 추측이 아니라 rows, type, extra로 판단하라.

3. 쿼리 패턴을 분류하라
   — 동적 WHERE의 실제 사용 빈도가 인덱스 우선순위다.

4. Range는 맨 뒤에
   — 복합 인덱스에서 범위 연산 이후 컬럼은 인덱스를 못 탄다.

5. 포기할 건 포기하라
   — LIKE &#39;%keyword%&#39;, DATE 함수는 인덱스로 해결 불가.
     다른 인덱스가 rows를 줄여주는 구조로 대응하라.</code></pre><p>처음에는 &quot;인덱스를 걸면 빨라진다&quot;고 단순하게 생각했다. 틀렸다.</p>
<blockquote>
<p>&quot;어떤 인덱스를 거는가&quot;보다 <strong>&quot;이 쿼리가 어떻게 실행되는가&quot;</strong>를 먼저 이해하는 게 시작이다.</p>
</blockquote>
<hr>
<h2 id="10-결국-비정규화를-했다">10. 결국 비정규화를 했다</h2>
<p>인덱스로 서브쿼리의 병목을 제거했다. Using temporary가 사라졌고, rows도 극적으로 줄었다. 하지만 쿼리를 다시 보니 <strong>근본적인 질문</strong>이 남았다.</p>
<blockquote>
<p>&quot;인덱스를 아무리 잘 걸어도, JOIN 4개를 매번 타는 구조 자체가 문제 아닌가?&quot;</p>
</blockquote>
<h3 id="인덱스만으로는-부족했던-이유">인덱스만으로는 부족했던 이유</h3>
<p>인덱스가 JOIN의 속도를 빠르게 만들어준 건 맞다. 하지만 <strong>JOIN 자체가 없으면 더 빠르다.</strong> 당연한 말인데, 이걸 실감한 건 인덱스를 걸고 나서였다.</p>
<p>LEFT JOIN ②③이 하는 일을 다시 보자:</p>
<pre><code>LEFT JOIN ② : 1순번 거래처의 거래처명 → 화면에 &quot;거래처명&quot; 표시
LEFT JOIN ③ : 대표 거래처의 거래처명 → 화면에 &quot;대표거래처명&quot; 표시</code></pre><p>이 JOIN들은 <strong>contract_partner → partner_master</strong>를 타고 가서 거래처명을 가져온다. 매 조회마다. 5만 건 각각에 대해.</p>
<p>&quot;이걸 메인 테이블에 넣어두면 JOIN을 안 해도 되지 않나?&quot;</p>
<p>처음엔 이 생각을 보류했다. 비정규화는 <strong>정규화의 원칙을 깨는 것</strong>이니까. 거래처명이 바뀌면 동기화해야 하고, 데이터 정합성 관리 포인트가 늘어난다. &quot;지금 안 아프면 안 바꾼다&quot;는 판단이었다.</p>
<p>근데 돌아보니, <strong>지금 아프다.</strong> 인덱스를 걸어도 JOIN 2개는 여전히 실행되고 있었다. 그리고 이 테이블의 거래처 정보는 <strong>한번 등록되면 거의 변경되지 않는다.</strong> 변경 빈도가 극히 낮은 데이터를 매번 JOIN으로 가져오는 건, 비용 대비 이득이 맞지 않았다.</p>
<h3 id="비정규화-판단-기준">비정규화 판단 기준</h3>
<p>비정규화를 결정하기 전에 세 가지를 확인했다.</p>
<pre><code>1. 이 데이터가 얼마나 자주 변경되는가?
   → 거래처명 변경: 연 수건 이하. 거의 없다.

2. 변경 시 동기화를 놓치면 어떤 일이 벌어지는가?
   → 목록에 옛 거래처명이 표시됨. 치명적이진 않다.
   → 상세 화면에서는 원본 테이블을 조회하므로 정합성 확인 가능.

3. JOIN을 제거했을 때 얼마나 빨라지는가?
   → LEFT JOIN 2개 + INNER JOIN 2개 제거 → 쿼리 구조 자체가 단순해짐.</code></pre><p>세 가지 모두 비정규화에 유리했다. <strong>변경은 드물고, 변경 시 리스크는 낮고, 제거 효과는 크다.</strong> 보류할 이유가 없었다.</p>
<h3 id="마이그레이션">마이그레이션</h3>
<p>비정규화를 &quot;하겠다&quot;와 &quot;했다&quot;는 다르다. 운영 중인 테이블의 구조를 바꾸는 건 신중해야 한다.</p>
<p><strong>Step 1. 컬럼 추가</strong></p>
<pre><code class="language-sql">ALTER TABLE contract_ledger
  ADD COLUMN partner_name VARCHAR(100) COMMENT &#39;거래처명 (비정규화)&#39;,
  ADD COLUMN partner_count INT DEFAULT 0 COMMENT &#39;거래처 수 (비정규화)&#39;;</code></pre>
<p>기존 데이터에 영향을 주지 않도록 <strong>컬럼 추가만</strong> 먼저 수행했다. 이 시점에서 새 컬럼은 전부 NULL이다. 기존 쿼리는 이 컬럼을 참조하지 않으므로 서비스에 영향 없다.</p>
<p><strong>Step 2. 기존 데이터 마이그레이션</strong></p>
<pre><code class="language-sql">-- 거래처명: 1순번 거래처의 이름을 메인 테이블로 복사
UPDATE contract_ledger LEG
INNER JOIN contract_partner CLT
  ON LEG.contract_no = CLT.contract_no
  AND CLT.partner_seq = 1
INNER JOIN partner_master MST
  ON CLT.partner_code = MST.partner_code
SET LEG.partner_name = MST.partner_name
WHERE LEG.partner_name IS NULL;

-- 거래처 수: GROUP BY로 집계한 값을 메인 테이블로 복사
UPDATE contract_ledger LEG
INNER JOIN (
  SELECT contract_no, COUNT(*) AS cnt
  FROM contract_partner
  GROUP BY contract_no
) CNT ON LEG.contract_no = CNT.contract_no
SET LEG.partner_count = CNT.cnt
WHERE LEG.partner_count = 0;</code></pre>
<p>5만 건에 대해 UPDATE를 실행했다. 이 작업은 <strong>한 번만 수행</strong>되는 것이므로 소요 시간은 문제가 되지 않는다.</p>
<p><strong>Step 3. 쿼리 수정</strong></p>
<p>마이그레이션이 완료된 후, 조회 쿼리에서 JOIN ①②③을 제거하고 메인 테이블의 컬럼으로 대체했다.</p>
<pre><code class="language-sql">-- AS-IS: JOIN 4개
SELECT LEG.*,
       CLT_CNT.partner_count,
       MST1.partner_name AS partner_name_1,
       MST2.partner_name AS partner_name_rep,
       ...
FROM contract_ledger LEG
LEFT JOIN (SELECT contract_no, COUNT(*) ... GROUP BY contract_no) CLT_CNT ...
LEFT JOIN contract_partner CLT1 ... INNER JOIN partner_master MST1 ...
LEFT JOIN contract_partner CLT2 ... INNER JOIN partner_master MST2 ...
LEFT JOIN contract_defect DEF ...
WHERE ...

-- TO-BE: JOIN 1개 (하자관리만 남음)
SELECT LEG.*,
       LEG.partner_count,
       LEG.partner_name,
       ...
FROM contract_ledger LEG
LEFT JOIN contract_defect DEF ...
WHERE ...</code></pre>
<p>LEFT JOIN 3개가 사라졌다. <strong>SELECT에서 직접 메인 테이블의 컬럼을 읽으면 되니까 JOIN이 필요 없다.</strong></p>
<p><strong>Step 4. 동기화 처리</strong></p>
<p>비정규화의 대가는 <strong>동기화</strong>다. 거래처 정보가 변경되면 메인 테이블도 함께 갱신해야 한다.</p>
<pre><code class="language-sql">-- 거래처 등록/수정/삭제 시 트리거 또는 서비스 로직에서 실행
UPDATE contract_ledger
SET partner_name = #{newPartnerName},
    partner_count = (
      SELECT COUNT(*) FROM contract_partner
      WHERE contract_no = #{contractNo}
    )
WHERE contract_no = #{contractNo};</code></pre>
<p>거래처 변경이 연 수건 이하이므로, 이 동기화 비용은 <strong>매 조회마다 JOIN을 타는 비용</strong>에 비하면 무시할 수 있다.</p>
<h3 id="비정규화-후-explain">비정규화 후 EXPLAIN</h3>
<pre><code>■ After Index (§8)
┌──────────────────┬───────┬───────┬───────────────────────┬────────────────────┐
│      table       │ type  │ rows  │         extra         │        key         │
├──────────────────┼───────┼───────┼───────────────────────┼────────────────────┤
│ contract_ledger  │ ref   │   500 │ Using where           │ idx_ledger_main    │
│ contract_partner │ ref   │     3 │ Using index           │ idx_partner_lookup │
│ contract_defect  │ ref   │     2 │ Using index           │ idx_defect_lookup  │
└──────────────────┴───────┴───────┴───────────────────────┴────────────────────┘

■ After Denormalization (§10)
┌──────────────────┬───────┬───────┬───────────────────────┬────────────────────┐
│      table       │ type  │ rows  │         extra         │        key         │
├──────────────────┼───────┼───────┼───────────────────────┼────────────────────┤
│ contract_ledger  │ ref   │   500 │ Using where           │ idx_ledger_main    │
│ contract_defect  │ ref   │     2 │ Using index           │ idx_defect_lookup  │
└──────────────────┴───────┴───────┴───────────────────────┴────────────────────┘</code></pre><p>contract_partner 행이 <strong>통째로 사라졌다.</strong> 인덱스가 &quot;빠르게 찾는 것&quot;이라면, 비정규화는 <strong>&quot;찾을 필요 자체를 없앤 것&quot;</strong>이다.</p>
<p>인덱스 최적화에서 rows가 80,000 → 3으로 줄었을 때도 대단하다고 느꼈는데, <strong>테이블 접근 자체가 사라지는 건 차원이 다른 개선</strong>이었다.</p>
<h3 id="정리-인덱스-vs-비정규화">정리: 인덱스 vs 비정규화</h3>
<pre><code>인덱스:   &quot;이 테이블에서 빠르게 찾아라&quot; → rows를 줄인다
비정규화: &quot;이 테이블을 아예 보지 마라&quot;  → JOIN을 없앤다</code></pre><p>둘 다 읽기 성능을 개선하지만, 비용 구조가 다르다.</p>
<table>
<thead>
<tr>
<th></th>
<th>인덱스</th>
<th>비정규화</th>
</tr>
</thead>
<tbody><tr>
<td><strong>개선 방식</strong></td>
<td>탐색 경로 최적화</td>
<td>JOIN 제거</td>
</tr>
<tr>
<td><strong>쓰기 비용</strong></td>
<td>INSERT/UPDATE 시 인덱스 갱신</td>
<td>원본 변경 시 동기화 필요</td>
</tr>
<tr>
<td><strong>적용 조건</strong></td>
<td>항상 가능</td>
<td>변경이 드문 데이터에 유리</td>
</tr>
<tr>
<td><strong>되돌리기</strong></td>
<td>DROP INDEX</td>
<td>컬럼 제거 + 쿼리 원복 (더 번거로움)</td>
</tr>
</tbody></table>
<p>인덱스는 &quot;안전한 개선&quot;이고, 비정규화는 <strong>&quot;트레이드오프를 감수한 개선&quot;</strong>이다. 그래서 인덱스를 먼저 시도하고, 그것만으로 부족할 때 비정규화를 검토하는 순서가 맞았다.</p>
<hr>
<h2 id="11-아직-남은-것들">11. 아직 남은 것들</h2>
<p><strong>5만 건을 한 번에 가져오는 것 자체가 문제일 수 있다.</strong> 현재 이 화면은 페이지네이션 없이 전체 결과를 한 번에 로드한다. 커서 기반 페이징을 도입하면 인덱스 효율이 더 좋아진다. 하지만 ERP 특성상 사용자가 &quot;전체 목록을 한눈에 보고 싶다&quot;는 요구가 강하기 때문에, 이건 기술적 판단만으로 결정할 수 없는 영역이다.</p>
<p>동시성 제어를 다뤘던 이전 글에서 &quot;아프기 시작하면 바꾸는 거다&quot;라고 정리했었는데, 인덱스도 비정규화도 마찬가지였다. <strong>전환 시점은 트래픽 숫자가 아니라, 운영에서 관측되는 실패 모드가 기준이다.</strong> 슬로우 쿼리 로그가 찍히기 시작하면, 그때 다음 단계를 밟으면 된다.</p>
<!-- 📝 [YOUR ACTION] 다음에 시도할 것 1~2문장
     - 페이지네이션, 캐시 적용 등 어떤 걸 다음으로 해보고 싶은지
     - 이전 글의 "PG 결제 트랜잭션 분리는 아직 안 함"처럼 의도적 보류를 명시
-->

<hr>
<blockquote>
<p>참고: 이 글에서 사용된 테이블명, 컬럼명, 데이터는 보안을 위해 모두 치환되었습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[100 → 99 → 49 → 0 : 100개의 재고가 정신 차리기까지]]></title>
            <link>https://velog.io/@praesentia-ykm/100-99-49-0-%EC%9E%AC%EA%B3%A0%EA%B0%80-%EC%A0%95%EC%8B%A0%EC%9D%84-%EC%B0%A8%EB%A6%AC%EA%B8%B0%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@praesentia-ykm/100-99-49-0-%EC%9E%AC%EA%B3%A0%EA%B0%80-%EC%A0%95%EC%8B%A0%EC%9D%84-%EC%B0%A8%EB%A6%AC%EA%B8%B0%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Fri, 06 Mar 2026 08:37:29 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/d30ff624-a79d-4d7c-8487-b07befc9d234/image.png" alt="">
실무를 하다보면 &quot;동시에 행동한다&quot; 라는 말이 굉장히 어렵게 느껴질 떄가 있다. 이럴 때 어떤 생각을 가지고 어떤 관점으로 접근해야 하는지 이야기 해보려고 한다.</p>
<blockquote>
<p><strong>TL;DR &quot;데이터가 사는 곳에서 잠가야(Lock) 한다.&quot;</strong></p>
</blockquote>
<hr>
<h2 id="동시에-일어난다는-것은">동시에 일어난다는 것은...</h2>
<p>카페에서 아이스 아메리카노 한 잔이 남았다.
두 명이 동시에 주문 버튼을 누르면 어떻게 될까?</p>
<p>일상에서는 별일 아니다. 점원이 &quot;죄송합니다, 한 잔 남았는데요&quot;라고 말하면 된다.
그런데 서버에서는 점원이 없다. <strong>코드가 스스로 판단해야 한다.</strong></p>
<p>결국 내가 판단해야 한다는 것인데, 어떻게 하는게 좋을까? 이번 기회에 동시성에 대하여 최대한 많이 생각해보려고 한다.</p>
<pre><code>시간 →  T1: 재고 읽기(1) → 주문 가능 판단 → 차감 → 저장(0)
        T2:     재고 읽기(1) → 주문 가능 판단 → 차감 → 저장(0)  ← 두 명 다 성공</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/d3780001-705f-44d1-9159-85ad3187c011/image.png" alt=""></p>
<p>재고는 1개인데 2명이 주문에 성공했다. 이걸 <strong>Lost Update(갱신 손실)</strong> 문제라고 한다. &quot;Lost&quot;는 말 그대로 &quot;잃어버린&quot;이라는 뜻이다. T1이 저장한 변경이 T2에 의해 덮어씌워져서 <strong>사라진 것</strong>이다. 업데이트가 유실됐다.</p>
<p>&quot;동시성 제어&quot;라는 단어를 들으면 비관적 락, 낙관적 락, 분산 락 같은 용어가 쏟아진다. 근데 솔직히 이 용어들이 서로 어떻게 연결되는지, 왜 하나로 안 되고 여러 가지가 필요한 건지 감이 안 왔다.</p>
<p>그래서 <strong>가장 원초적인 코드부터 시작해서, &quot;이걸로 되나?&quot; → &quot;안 되네&quot; → &quot;그러면 이건?&quot; 을 반복하면서 직접 부딪혀 보기로 했다.</strong> </p>
<p>이 글은 네 개의 키워드를 축으로 전개된다:</p>
<table>
<thead>
<tr>
<th>키워드</th>
<th>질문</th>
</tr>
</thead>
<tbody><tr>
<td><strong>read-modify-write</strong></td>
<td>동시성 문제는 어디서 시작되는가?</td>
</tr>
<tr>
<td><strong>상호 배제 (mutual exclusion)</strong></td>
<td>&quot;한 번에 하나&quot;를 보장하면 해결되는가?</td>
</tr>
<tr>
<td><strong>생명주기 불일치</strong></td>
<td>Java 락과 DB 트랜잭션을 합치면 왜 깨지는가?</td>
</tr>
<tr>
<td><strong>같은 레이어에서 제어</strong></td>
<td>DB 데이터를 안전하게 다루려면 어떻게 해야 하는가?</td>
</tr>
</tbody></table>
<hr>
<h2 id="고민의-시작">고민의 시작</h2>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/bd04afb7-9f64-4ca5-8156-28af71001cc6/image.png" alt=""></p>
<h3 id="step-1-정말-터지긴-하는-걸까">Step 1. 정말 터지긴 하는 걸까?</h3>
<blockquote>
<p>** read-modify-write**
읽고(read), 바꾸고(modify), 쓴다(write). 이 세 단계가 분리되어 있는 모든 연산은 그 사이에 다른 스레드가 끼어들 수 있다. 동시성 문제의 대부분은 이 패턴에서 시작된다.</p>
</blockquote>
<p>동시성 문제에 대해 읽으면 &quot;여러 스레드가 동시에 접근하면 문제가 생긴다&quot;고 한다. 머리로는 이해가 되는데, 직접 본 적은 없다. 정말 터지는지부터 확인해보고 싶었다.</p>
<p>재고를 나타내는 가장 단순한 클래스를 만들고, 100개의 스레드로 동시에 차감해봤다.</p>
<pre><code class="language-java">public class Stock {
    private int quantity;

    public void decrease() {
        int current = this.quantity;   // read
        this.quantity = current - 1;   // write
    }
}</code></pre>
<p>결과: <strong>재고 0.</strong> 문제가 안 생긴다.</p>
<p>&quot;뭐야, 괜찮은데?&quot; 싶었다.</p>
<h4 id="왜-안-터졌는가--그리고-왜-그게-더-무서운가">왜 안 터졌는가 — 그리고 왜 그게 더 무서운가</h4>
<p><code>quantity--</code>는 자바 코드로는 한 줄이지만, CPU 입장에서는 세 단계다:</p>
<ol>
<li>메모리에서 값을 레지스터로 <strong>읽고</strong> (LOAD)</li>
<li>레지스터에서 1을 <strong>빼고</strong> (SUB)</li>
<li>레지스터의 값을 메모리에 <strong>쓴다</strong> (STORE)</li>
</ol>
<p>이 세 단계 사이에 다른 스레드가 끼어들면 문제가 생긴다. 하지만 이 연산이 너무 빨라서 (나노초 단위) 100개 스레드 정도로는 끼어들 틈이 거의 없다.</p>
<p>여기서 중요한 점이 있다: 앞서 TDD를 통해 경험해 보았듯이 <strong>&quot;테스트에서 안 터진다&quot;는 것은 &quot;문제가 없다&quot;는 뜻이 아니다.</strong> 경합 조건(Race Condition)은 확률적이다. 로컬에서 100번 돌려도 안 터질 수 있지만, 운영 서버에서 초당 수천 요청이 들어오면 그 &quot;낮은 확률&quot;이 매일 발생한다.</p>
<p>실제 서비스에서는 단순히 <code>quantity--</code>가 아니다. <strong>DB에서 값을 읽고, 비즈니스 검증을 하고, 다시 저장하는</strong> 과정이 있고, 이 과정에 수 밀리초에서 수십 밀리초의 시간이 걸린다. 이 시간 간격을 시뮬레이션하면 어떻게 될까?</p>
<pre><code class="language-java">public void decreaseWithDelay() {
    int current = this.quantity;           // read: DB에서 재고를 읽어온다
    Thread.sleep(1);                       // DB I/O + 비즈니스 로직 시뮬레이션
    this.quantity = current - 1;           // write: 계산된 값을 다시 저장한다
}</code></pre>
<p>결과:</p>
<pre><code>[단순 decrease] 최종 재고: 0   — 연산이 빨라서 경합이 안 일어남
[지연 decrease] 최종 재고: 99  — 100번 차감했는데 1번만 반영됨!</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/ff204c90-4fc2-4475-ae55-f5e5c609579e/image.png" alt=""></p>
<p><strong>1밀리초.</strong> 고작 1ms의 간격으로 99건의 차감이 사라졌다.</p>
<p>왜 99건인지 뜯어보면 이렇다. 100개 스레드가 동시에 출발하면, 1ms의 sleep 동안 거의 모든 스레드가 <code>current = 100</code>을 읽는다. 그 다음 각자 <code>100 - 1 = 99</code>를 계산하고 저장한다. 100개의 스레드가 전부 99를 저장하니까, 최종 값은 99다. 마지막에 저장한 스레드 하나만 &quot;반영&quot;된 것처럼 보이는 거다.</p>
<p>이게 <strong>read-modify-write 패턴</strong>의 본질적 위험이다. 읽기와 쓰기가 분리된 모든 연산은 그 사이에 다른 스레드가 끼어들 수 있다. 문제가 실재한다는 걸 확인했으니, 이제 해결해야 한다.</p>
<hr>
<h3 id="step-2-한-번에-한-명만-들어오게-하면-되지-않나">Step 2. &quot;한 번에 한 명만 들어오게 하면 되지 않나?&quot;</h3>
<blockquote>
<p><strong>핵심 키워드: 상호 배제 (mutual exclusion)</strong>
&quot;Mutual&quot;은 &quot;서로의&quot;, &quot;Exclusion&quot;은 &quot;배제&quot;. 서로를 배제한다 — 즉, <strong>한 시점에 하나의 스레드만 임계 구간(critical section)에 존재할 수 있다</strong>는 원칙이다. read-modify-write를 쪼갤 수 없다면, 그 구간 자체에 한 명만 들어오게 하는 것이다.</p>
</blockquote>
<p>문제의 원인은 명확하다. 여러 스레드가 <strong>동시에</strong> read-write를 하니까 덮어쓰기가 발생한다. 그러면 <strong>한 번에 한 스레드만 실행하게 막으면</strong> 되지 않을까?</p>
<h4 id="2-1-synchronized--동기화하다">2-1. synchronized — &quot;동기화하다&quot;</h4>
<p>&quot;synchronize&quot;는 <strong>&quot;동기화하다, 시간을 맞추다&quot;</strong>라는 뜻이다. 원래 &quot;sync&quot;는 &quot;함께(syn) 시간(chronos)&quot;이라는 그리스어 합성어다. 여러 스레드의 실행 <strong>시간을 맞춰서</strong>, 한 번에 하나만 실행되게 한다는 뜻이 이름에 들어 있다.</p>
<p>비유하자면 화장실 칸이 하나인데 사람이 여럿인 상황이다. 문에 잠금장치가 있어서 한 명이 들어가면 나올 때까지 나머지는 밖에서 대기한다.</p>
<p>그런데 이 잠금장치가 정확히 <strong>어떻게</strong> 작동하는 걸까?</p>
<p>Java의 모든 객체는 내부에 <strong>모니터(monitor)</strong>라는 것을 하나씩 가지고 있다. &quot;Monitor&quot;는 <strong>&quot;감시자&quot;</strong>라는 뜻이다. 이 감시자가 &quot;지금 누가 들어와 있는지&quot;를 추적한다. <code>synchronized</code> 블록에 진입하면 해당 객체의 모니터를 <strong>획득(acquire)</strong>하고, 블록이 끝나면 <strong>반납(release)</strong>한다. 모니터를 획득하지 못한 스레드는 <strong>BLOCKED</strong> 상태로 대기한다 — CPU를 사용하지 않고 OS 스케줄러에 의해 잠든다.</p>
<pre><code class="language-java">public class SynchronizedStockService {
    private final Stock stock;

    // 이 메서드에 synchronized를 붙이면,
    // 이 객체(this)의 모니터를 획득해야만 진입할 수 있다.
    public synchronized void decrease() {
        stock.decreaseWithDelay();
    }
}</code></pre>
<p>결과:</p>
<pre><code>[synchronized] 최종 재고: 0 ✅
[synchronized 성능] 소요 시간: 217ms</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/2eb9c2ed-7391-4013-97ec-78890f0ceca1/image.png" alt=""></p>
<p><strong>해결됐다!</strong> ...근데 217ms가 걸렸다. 100개의 요청이 한 줄로 서서 순차 처리되니까 (1ms × 100 + 오버헤드) 당연한 결과다.</p>
<p>여기서 두 가지 생각이 들었다.</p>
<p><strong>첫 번째: &quot;이렇게 느려도 괜찮은가? 기다리다 포기할 수는 없나?&quot;</strong></p>
<p>synchronized는 <strong>무한 대기</strong>다. 모니터를 획득할 때까지 영원히 기다린다. &quot;5초 안에 안 되면 포기&quot;같은 옵션이 없다. 앞에 100명이 서 있어도, 1000명이 서 있어도 무조건 기다린다.</p>
<p>실제 서비스에서 이건 위험할 수 있다. 뒤에 선 요청들이 <strong>스레드 풀을 점유한 채 대기</strong>하면, 다른 API 요청까지 처리 못하게 된다. &quot;재고 차감 API 하나 때문에 서버 전체가 먹통&quot;이 될 수 있다.</p>
<p><code>ReentrantLock</code>의 <code>tryLock</code>이 이 문제를 해결한다. &quot;Lock&quot;은 <strong>&quot;자물쇠&quot;</strong>, &quot;Re-entrant&quot;는 <strong>&quot;다시 들어갈 수 있는&quot;</strong>이라는 뜻이다. 이름에서 알 수 있듯이 같은 스레드가 이미 잡은 락을 다시 잡을 수 있다는 특성이 있지만 (참고로 synchronized도 그렇다), 핵심 차이는 <strong>&quot;기다리되, 타임아웃을 걸 수 있다&quot;</strong>는 점이다.</p>
<pre><code class="language-java">// tryLock — &quot;try&quot;라는 이름 자체가 &quot;시도하다&quot;라는 뜻.
// 성공하면 true, 실패하면 false를 반환한다. 예외를 던지지 않는다.
if (!lock.tryLock(5, TimeUnit.MILLISECONDS)) {
    return false;  // 5ms 안에 못 잡으면 포기하고 다른 처리 가능
}
try {
    stock.decreaseWithDelay();
    return true;
} finally {
    lock.unlock();  // ⚠️ 반드시 finally에서 해제! 안 그러면 영원히 잠김
}</code></pre>
<pre><code>[tryLock 5ms] 성공: 7건, 타임아웃: 93건
→ synchronized는 무한 대기하지만, tryLock은 &#39;포기&#39;할 수 있다</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/55f4cd08-557f-4086-9ff1-52f9d2b49b69/image.png" alt=""></p>
<p>100건 중 7건만 성공하고 93건은 포기했다. 이 93건에 대해 &quot;잠시 후 다시 시도해주세요&quot;라고 응답하거나, 대기열에 넣는 등의 처리가 가능하다.</p>
<p>여기서 잠깐 주목할 점이 있다. <code>lock.unlock()</code>을 <strong>반드시 <code>finally</code> 블록</strong>에 넣어야 한다. synchronized는 블록이 끝나면 자동으로 모니터를 반납하지만, ReentrantLock은 명시적으로 <code>unlock()</code>을 호출해야 한다. 만약 예외가 터져서 <code>unlock()</code>을 건너뛰면? 그 락은 <strong>영원히 잠긴 채로 남는다.</strong> 다른 모든 스레드가 영원히 대기하게 된다. 이것이 &quot;수동 잠금장치&quot;의 대가다.
<br></p>
<p><strong>두 번째: &quot;단순히 숫자 하나를 바꾸는 건데, 꼭 잠가야 하나?&quot;</strong></p>
<p>여기서 근본적인 질문이 떠올랐다. read-modify-write가 문제라면, <strong>이 세 단계를 쪼갤 수 없는 하나의 연산으로 만들면</strong> 잠글 필요 자체가 없지 않을까?</p>
<p>&quot;Atomic&quot;은 그리스어 <strong>&quot;atomos&quot;</strong>에서 왔다. &quot;a(not) + tomos(cut)&quot; = <strong>&quot;더 이상 쪼갤 수 없는&quot;</strong>이라는 뜻이다. 물리학에서 &quot;원자(atom)&quot;가 &quot;더 이상 나눌 수 없는 것&quot;이라는 의미인 것과 같다. (물론 물리학에서는 원자도 쪼갤 수 있다는 걸 나중에 알게 됐지만, 프로그래밍에서의 atomic 연산은 진짜로 쪼갤 수 없다.)</p>
<p><code>AtomicInteger</code>는 <strong>CAS(Compare-And-Swap)</strong>라는 CPU 레벨 명령어를 사용한다. CAS를 풀어 쓰면: <strong>&quot;비교(Compare)하고 교환(Swap)한다.&quot;</strong> 구체적으로:</p>
<ol>
<li>&quot;내가 마지막으로 본 값이 5였다&quot; (expected = 5)</li>
<li>&quot;메모리에 있는 현재 값을 확인한다&quot; (actual = ?)</li>
<li>actual == expected이면 → 새 값(4)으로 교체 (<strong>이 비교+교체가 CPU 레벨에서 하나의 명령어로 실행된다</strong>)</li>
<li>actual ≠ expected이면 → 누가 이미 바꿨다는 뜻 → 실패 → 다시 읽고 재시도</li>
</ol>
<p>핵심은 3번이다. &quot;비교&quot;와 &quot;교체&quot;가 <strong>CPU의 단일 명령어(cmpxchg)</strong>로 실행되기 때문에, 중간에 다른 스레드가 끼어들 여지가 물리적으로 없다.</p>
<pre><code class="language-java">public class AtomicStock {
    private final AtomicInteger quantity;

    public void decrease() {
        quantity.decrementAndGet();
        // 내부적으로:
        // do {
        //     int expected = current값;
        //     int newValue = expected - 1;
        // } while (!CAS(expected, newValue));  // 실패하면 무한 재시도
    }
}</code></pre>
<pre><code>[성능 비교]
  Atomic:       1ms   (락 없음, CAS)
  synchronized: 209ms (모니터 락, 순차 실행)</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/cc94eb2c-1762-4645-9c23-b5fac54bc2d8/image.png" alt=""></p>
<p><strong>200배 빠르다.</strong> 잠그지 않으니까 대기 시간이 없다.</p>
<p>하지만 여기서 냉정하게 봐야 할 것이 있다. 이 테스트에서 Atomic이 빠른 건 <strong>delay가 없기 때문</strong>이다. <code>AtomicInteger.decrementAndGet()</code>은 메모리 연산만 하므로 나노초 단위로 끝난다. 반면 synchronized 테스트는 <code>decreaseWithDelay()</code>를 감싸므로 1ms × 100 = 최소 100ms가 걸린다. 공정한 비교가 아니다.</p>
<p>그보다 중요한 건 <strong>적용 범위의 차이</strong>다. AtomicInteger는 <strong>단일 변수에 대한 단일 연산</strong>에만 쓸 수 있다. 실제 서비스에서 &quot;재고 확인 → 비즈니스 검증 → 차감 → 주문 생성&quot;을 하나의 원자적 연산으로 만들 수는 없다. 또한 경합이 극심하면(수백 스레드가 동시에 같은 값을 바꾸려 하면) CAS 재시도가 반복되면서 오히려 성능이 나빠질 수 있다. 이걸 <strong>&quot;CAS 스핀(spin)&quot;</strong> 문제라고 한다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/a77228b6-598f-4890-82f7-5f92e3cd0931/image.png" alt=""></p>
<p>정리하면:</p>
<table>
<thead>
<tr>
<th></th>
<th>synchronized</th>
<th>ReentrantLock</th>
<th>Atomic (CAS)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>잠그는 방식</strong></td>
<td>암묵적 (JVM 모니터)</td>
<td>명시적 (lock/unlock)</td>
<td>안 잠금 (비교 후 교체)</td>
</tr>
<tr>
<td><strong>대기 방식</strong></td>
<td>무한 대기 (BLOCKED)</td>
<td>타임아웃 가능 (tryLock)</td>
<td>재시도 루프 (스핀)</td>
</tr>
<tr>
<td><strong>해제 책임</strong></td>
<td>자동 (블록 종료)</td>
<td>수동 (finally에서 unlock)</td>
<td>없음</td>
</tr>
<tr>
<td><strong>적합한 경우</strong></td>
<td>단순 임계 구간</td>
<td>세밀한 제어 필요 시</td>
<td>단일 변수 연산</td>
</tr>
<tr>
<td><strong>위험</strong></td>
<td>무한 대기로 인한 스레드 풀 고갈</td>
<td>unlock 누락 시 영구 잠금</td>
<td>경합 심하면 스핀 폭주</td>
</tr>
</tbody></table>
<h4 id="잠깐--이건-잠그는-문제가-아닌데">잠깐 — &quot;이건 잠그는 문제가 아닌데?&quot;</h4>
<p>여기까지 하고 나니, 다른 종류의 동시성 문제도 떠올랐다. 재고 차감은 &quot;값을 변경&quot;하는 <strong>UPDATE</strong> 문제다. 그런데 쿠폰 중복 발급은? &quot;같은 유저가 같은 쿠폰을 두 번 받으면 안 된다&quot;는 건 <strong>INSERT 중복</strong> 문제다.</p>
<p>이 두 문제는 본질이 다르다:</p>
<ul>
<li>UPDATE 문제: 같은 값을 여러 명이 <strong>동시에 바꾸려고</strong> 한다 → 락으로 순서를 보장해야</li>
<li>INSERT 문제: 같은 데이터를 여러 명이 <strong>동시에 넣으려고</strong> 한다 → 중복 자체를 차단하면 됨</li>
</ul>
<p>INSERT 중복이라면 락을 안 걸어도 된다. DB의 <strong>유니크 제약(Unique Constraint)</strong>이 원자적으로 중복을 차단해준다. &quot;Constraint&quot;는 <strong>&quot;제약, 제한&quot;</strong>이라는 뜻이다. DB 엔진이 INSERT를 실행할 때 유니크 조건을 <strong>원자적으로</strong> 검사하므로, 서버가 몇 대든 상관없이 중복이 차단된다.</p>
<pre><code class="language-java">@Table(uniqueConstraints = @UniqueConstraint(columnNames = {&quot;coupon_id&quot;, &quot;user_id&quot;}))
public class CouponIssueEntity { ... }</code></pre>
<pre><code>[유니크 제약] 100번 동시 발급 요청 → 성공: 1건, 중복 차단: 99건
→ 락 없이도 DB가 원자적으로 차단</code></pre><p><strong>문제의 성격을 먼저 판별해야 한다.</strong> &quot;같은 행위의 중복 방지&quot;인가(→ 유니크 제약), &quot;공유 자원의 값 변경&quot;인가(→ 락). 도구를 고르기 전에 문제를 정확히 정의하는 게 먼저다.</p>
<hr>
<h3 id="step-3-이걸로-끝-아닌가--아니었다">Step 3. &quot;이걸로 끝 아닌가?&quot; — 아니었다</h3>
<blockquote>
<p><strong>핵심 키워드: 생명주기 불일치 (lifecycle mismatch)</strong>
Java 락은 <strong>코드 블록</strong>에 종속되고, DB 트랜잭션은 <strong>프록시 호출</strong>에 종속된다. 두 메커니즘이 끝나는 시점이 다르면, 그 사이에 틈이 생긴다. 이 틈이 동시성 버그의 원인이 된다.</p>
</blockquote>
<p>Step 2에서 synchronized로 문제를 해결했다. 로컬에서 테스트도 통과한다. <strong>&quot;이걸로 충분하지 않나?&quot;</strong> 라고 생각했다.</p>
<p>그런데 실제 서비스를 떠올리면 한 가지 전제가 깨진다. 서버가 1대가 아니라는 것이다.</p>
<p>&quot;서버가 2대면 JVM이 다르니까 synchronized가 안 통한다&quot; — 이건 많이들 아는 얘기다. 모니터는 JVM 내부에 있으므로 다른 JVM의 모니터는 서로 보이지 않는다. 당연하다.</p>
<p>근데 공부하다보니 <strong>서버가 1대인 상황에서도</strong> synchronized가 안 통하는 경우를 직접 구현해보았는데 충격적이였다.</p>
<h4 id="synchronized--transactional--이-조합이-함정이다">synchronized + @Transactional — 이 조합이 함정이다</h4>
<p>Spring에서 DB를 사용하면 <code>@Transactional</code>을 붙인다. 그리고 동시성 제어를 위해 <code>synchronized</code>를 붙인다. 둘 다 붙이면 완벽할 것 같다.</p>
<pre><code class="language-java">@Service
public class DbStockService {
    @Transactional
    public synchronized void decrease(Long id) {
        StockEntity stock = stockEntityRepository.findById(id).orElseThrow();
        stock.decrease();
    }
}</code></pre>
<p>직관적으로는 맞아 보인다. &quot;트랜잭션도 걸고, 동시성도 제어하고, 완벽하잖아.&quot; 근데 결과를 보면:</p>
<pre><code>[synchronized + @Transactional] 최종 재고: 49 (기대값: 0)
→ 100번 차감했는데 51건이 사라졌다!</code></pre><p>왜?</p>
<h4 id="프록시proxy가-뭔데">프록시(Proxy)가 뭔데?</h4>
<p>이걸 이해하려면 &quot;프록시&quot;라는 단어부터 짚어야 한다. &quot;Proxy&quot;는 <strong>&quot;대리인&quot;</strong>이라는 뜻이다. Spring의 <code>@Transactional</code>은 원본 객체를 직접 호출하지 않고, <strong>대리인(프록시)이 대신 호출</strong>하는 구조다.</p>
<p>왜 이렇게 만들었을까? <code>@Transactional</code>이 하는 일은 &quot;메서드 실행 전에 트랜잭션을 시작하고, 메서드가 성공하면 커밋하고, 실패하면 롤백한다&quot;는 것이다. 이 <strong>부가 기능을 비즈니스 코드와 분리</strong>하기 위해 프록시 패턴을 사용한다. 코드를 수정하지 않고도 트랜잭션을 적용할 수 있으니 편리하다.</p>
<p>하지만 이 설계에는 <strong>구조적 제약</strong>이 따른다. 프록시가 감싸는 범위와 synchronized가 잠그는 범위가 다르다:</p>
<pre><code>실제 호출 순서:

호출자 → [Proxy: 트랜잭션 시작] → [synchronized: lock 획득] → 비즈니스 로직 → [synchronized: lock 반납] → [Proxy: 트랜잭션 커밋]

                                                                                     ↑ 갭!               ↑
                                                                               lock은 풀렸는데       커밋은 여기서</code></pre><p>풀어 쓰면:</p>
<pre><code>Thread 1:
  [Spring Proxy: 트랜잭션 시작]
    → [synchronized lock 획득]
      → 재고 읽기(100) → 차감(99) → dirty checking으로 UPDATE 예약
    → [synchronized lock 반납]      ← 여기서 Thread 2가 lock을 획득할 수 있다!
  [Spring Proxy: 트랜잭션 커밋]      ← UPDATE가 실제로 DB에 반영되는 시점

Thread 2:
  [Spring Proxy: 트랜잭션 시작]
    → [synchronized lock 획득]      ← Thread 1이 lock을 반납했으니 진입 가능
      → 재고 읽기(???)              ← Thread 1의 커밋 전이므로 100을 읽음!</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/4cfa142f-6249-4c28-a944-270d2034d8ce/image.png" alt=""></p>
<p>결국 <strong>synchronized는 메서드 본문에 걸리고, 프록시는 메서드 바깥에서 트랜잭션을 관리</strong> 한다는 것이다. lock을 반납하는 시점과 트랜잭션이 커밋되는 시점 사이에 <strong>갭</strong>이 존재한다. 이 찰나에 다른 스레드가 들어오면, 아직 커밋되지 않은 데이터(100)를 읽는다.</p>
<h4 id="그러면-이건-spring의-설계-실수인가">&quot;그러면 이건 Spring의 설계 실수인가?&quot;</h4>
<p>그럴리가!! 이건 <strong>트레이드오프</strong>다.</p>
<p>Spring이 트랜잭션을 프록시 안쪽에서 시작하고 바깥쪽에서 커밋하는 이유는, <strong>&quot;비즈니스 로직이 트랜잭션 관리 코드를 알 필요가 없다&quot;</strong>는 관심사 분리 원칙 때문이다. 트랜잭션 시작/커밋/롤백을 프록시에 위임함으로써 비즈니스 코드는 순수하게 유지된다.</p>
<p>이 설계가 의미하는 것은: <strong><code>@Transactional</code> 메서드 안에서 <code>synchronized</code>를 사용하는 것 자체가 잘못된 조합</strong>이라는 것이다. 두 메커니즘의 생명주기가 다르다. Java 락은 &quot;코드 블록&quot;의 생명주기를 따르고, DB 트랜잭션은 &quot;프록시 호출&quot;의 생명주기를 따른다. 이 불일치를 억지로 합치다보면 지금 같은 결과가 나와버린다.</p>
<h4 id="격리-수준isolation-level과의-관계">격리 수준(Isolation Level)과의 관계</h4>
<p>여기서 빼먹으면 섭섭한 포인트가 있다. Thread 2가 &quot;커밋 전 데이터(100)를 읽는다&quot;고 했는데, 이건 DB의 <strong>격리 수준(Isolation Level)</strong>에 따라 달라진다.</p>
<p>&quot;Isolation&quot;은 <strong>&quot;격리, 분리&quot;</strong>라는 뜻이다. 트랜잭션 간에 <strong>어디까지 서로를 격리시킬 것인가</strong>를 정하는 설정이다.</p>
<p>대부분의 DB 기본 설정은 <strong>READ COMMITTED</strong>다. 이름 그대로 <strong>&quot;커밋된 것만 읽는다&quot;</strong>는 뜻이다. Thread 1이 99로 바꿔놨어도 아직 커밋하지 않았으므로, Thread 2에게는 이전에 커밋된 값(100)이 보인다.</p>
<p>만약 격리 수준을 <strong>REPEATABLE READ</strong>(반복 가능한 읽기)로 올리면? MySQL/MariaDB의 기본 설정이 이건데, 이 경우에도 Lost Update 문제는 막지 못한다. REPEATABLE READ는 &quot;내가 트랜잭션 시작 시점에 본 스냅샷을 계속 본다&quot;는 것이지, 다른 트랜잭션의 UPDATE를 막는 것이 아니기 때문이다.</p>
<p>이것이 의미하는 바는 크다: <strong>Java 레벨의 동시성 제어와 DB 트랜잭션은 서로 다른 레이어에서 작동한다.</strong> synchronized는 &quot;이 코드 블록에 한 번에 하나&quot;를 보장하지만, DB 커밋 타이밍까지는 제어하지 못한다. 격리 수준을 올려도 Lost Update는 막지 못한다. 합쳐서 쓰면 두 보장이 어긋나면서 의도대로 동작하지 않는다.</p>
<p>그러면 어떻게 해야 하는가? <strong>DB 데이터를 다루는 동시성 문제는 DB 레벨에서 제어해야 한다.</strong></p>
<hr>
<h3 id="step-4-db가-직접-잠그게-하자">Step 4. &quot;DB가 직접 잠그게 하자&quot;</h3>
<blockquote>
<p><strong>같은 레이어에서 제어하자</strong>
DB 데이터의 동시성 문제는 DB 레벨에서 해결해야 한다. 데이터가 사는 곳과 락이 사는 곳이 같아야, 생명주기가 일치하고 틈이 사라진다.</p>
</blockquote>
<p>Java 락이 DB 트랜잭션과 어긋나는 것이 문제라면, 아예 <strong>DB 자체가 제공하는 락</strong>을 쓰면 된다. DB 락은 트랜잭션과 생명주기가 같으므로, &quot;lock 반납과 커밋 사이의 갭&quot; 문제가 구조적으로 발생하지 않는다.</p>
<p>DB 레벨의 동시성 제어에는 <strong>두 가지 철학</strong>이 있다. 재미있는 건, 이 두 철학이 이름에 그대로 드러난다는 것이다.</p>
<h4 id="4-1-비관적-락pessimistic-lock--충돌-날-거야-미리-잠가">4-1. 비관적 락(Pessimistic Lock) — &quot;충돌 날 거야. 미리 잠가&quot;</h4>
<p>&quot;Pessimistic&quot;은 <strong>&quot;비관적인&quot;</strong>이라는 뜻이다. 라틴어 &quot;pessimus(최악의)&quot;에서 왔다. <strong>&quot;최악의 상황(충돌)이 일어날 거라고 가정&quot;</strong>하고, 미리 잠가버린다.</p>
<p>도서관에서 책을 빌리는 게 아니라 <strong>열람실에서 독점 사용</strong>하는 것이다. &quot;이 책 내가 보는 동안 다른 사람한테 주지 마세요.&quot; 다른 사람은 내가 반납할 때까지 그 책을 볼 수도 없다.</p>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;SELECT s FROM StockEntity s WHERE s.id = :id&quot;)
Optional&lt;StockEntity&gt; findByIdWithPessimisticLock(@Param(&quot;id&quot;) Long id);
// 실행되는 SQL: SELECT * FROM stock WHERE id = 1 FOR UPDATE;</code></pre>
<p><strong><code>FOR UPDATE</code></strong>가 핵심이다. 이 두 단어는 <strong>&quot;업데이트를 위해(잠근다)&quot;</strong>라는 뜻이다. 이 키워드가 붙은 SELECT는 단순히 읽기만 하는 게 아니라, <strong>&quot;나 이 행을 곧 수정할 거니까 다른 트랜잭션은 건드리지 마&quot;</strong>라는 의사 표시다. 해당 행에 대해 다른 트랜잭션의 FOR UPDATE / UPDATE / DELETE가 블로킹된다.</p>
<p>[MariaDB 콘솔 테스트]
비관적 락(FOR UPDATE)의 동작 확인:</p>
<p>터미널 1:</p>
<pre><code>  START TRANSACTION;
  SELECT * FROM stock WHERE id = 1 FOR UPDATE;  -- 행을 잠금. 
  결과: quantity = 100</code></pre><p>  <img src="https://velog.velcdn.com/images/praesentia-ykm/post/f9835261-2468-4092-b7fd-c315e089b099/image.png" alt=""></p>
<p>터미널 2:</p>
<pre><code>  START TRANSACTION;
  SELECT * FROM stock WHERE id = 1 FOR UPDATE;  -- 대기!
  -- 터미널 1이 커밋하거나 롤백할 때까지 여기서 블로킹됨</code></pre><p>  <img src="https://velog.velcdn.com/images/praesentia-ykm/post/c738a43a-47c2-48bd-8efc-f4383ac7a2b2/image.png" alt=""></p>
<hr>
<p>터미널 1:</p>
<pre><code>  UPDATE stock SET quantity = 99 WHERE id = 1;
  COMMIT;  -- 이 순간 터미널 2의 SELECT가 실행됨!</code></pre><p>  <img src="https://velog.velcdn.com/images/praesentia-ykm/post/2a2a57c9-2c18-45d8-bc76-4683616a605e/image.png" alt="">
 <img src="https://velog.velcdn.com/images/praesentia-ykm/post/c77a4fd5-5bb8-4b35-8af7-a0e6c235db2f/image.png" alt=""></p>
<p>터미널 2:</p>
<pre><code>  -- 이제서야 결과가 나옴: quantity = 99 (터미널 1이 커밋한 값!)
  UPDATE stock SET quantity = 98 WHERE id = 1;
  COMMIT;</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/7da78b52-365d-47a0-aaf6-230027954885/image.png" alt=""></p>
<p>최종 확인:
  SELECT quantity FROM stock WHERE id = 1;  -- 결과: 98
  -- 100 - 1 - 1 = 98. 정확!</p>
<hr>
<p>추가 실험1) — FOR UPDATE 없이 같은 순서로 하면?:</p>
<p>  → 터미널 2의 SELECT가 대기 없이 바로 실행되고, 100을 읽는다
  → 결과: 99. 1건 손실.</p>
<hr>
<p>추가 실험2) — 일반 SELECT는 FOR UPDATE와 관계없이 잘 된다:</p>
<p>  터미널 1이 FOR UPDATE로 잠근 상태에서:
  <img src="https://velog.velcdn.com/images/praesentia-ykm/post/cad0e4e1-5bb4-48b4-8b45-1a0c4e7bfb61/image.png" alt=""></p>
<p>  터미널 2: SELECT * FROM stock WHERE id = 1;  -- 대기 없이 바로 보임!
  <img src="https://velog.velcdn.com/images/praesentia-ykm/post/be5caf35-d8a3-4ca1-842b-30afeadce35a/image.png" alt=""></p>
<p>  → FOR UPDATE는 &quot;다른 FOR UPDATE/UPDATE/DELETE&quot;만 블로킹한다
  → 일반 읽기는 차단하지 않는다 (Non-blocking read)
--&gt;</p>
<p>결과:</p>
<pre><code>[결과 비교]
  synchronized + @Transactional: 재고 48 (Lost Update 발생)
  비관적 락 (FOR UPDATE):         재고 0  (정확) ✅</code></pre><p><strong>왜 비관적 락은 성공하는가?</strong></p>
<p>이건 Step 3의 문제를 정확히 뒤집어 보면 이해가 된다:</p>
<ul>
<li><strong>synchronized</strong>: lock 반납 → (갭) → 커밋. lock의 생명주기 ≠ 트랜잭션의 생명주기</li>
<li><strong>FOR UPDATE</strong>: lock 획득 → ... → 커밋과 동시에 lock 반납. <strong>lock의 생명주기 = 트랜잭션의 생명주기</strong></li>
</ul>
<p>DB 락은 트랜잭션에 <strong>종속</strong>된다. 트랜잭션이 끝나야 (COMMIT 또는 ROLLBACK) 락도 풀린다. 그래서 &quot;lock은 풀렸는데 아직 커밋이 안 됐어&quot;라는 상황 자체가 불가능하다.</p>
<p>하지만 비관적 락에도 무조건 좋은 건 아니다. 진지하게 고민해야 할 것들이 있다:</p>
<p><strong>데드락(Deadlock) — &quot;서로 기다리기&quot;</strong>
&quot;Dead&quot;는 &quot;죽은&quot;, &quot;Lock&quot;은 &quot;잠금&quot;. <strong>죽은 잠금</strong>이다. T1이 A행을 잠그고 B행을 기다리는데, T2가 B행을 잠그고 A행을 기다리면 → 둘 다 영원히 대기. DB는 데드락을 감지하면 한쪽 트랜잭션을 강제 롤백한다.</p>
<p><strong>커넥션 풀 고갈</strong>
잠긴 행을 기다리는 트랜잭션은 <strong>DB 커넥션을 물고 있는 채로</strong> 대기한다. 대기 요청이 많아지면 커넥션 풀의 모든 커넥션이 대기에 묶이고, 새로운 요청은 커넥션조차 얻지 못한다. 재고 차감 API 하나 때문에 서버 전체가 멈출 수 있다.</p>
<p><strong>처리량(Throughput) 제한</strong>
잠긴 동안 다른 트랜잭션은 줄 서서 대기한다. 동시 처리의 장점을 포기하는 것이다.</p>
<p>&quot;그러면 잠그지 않는 방법은 없나?&quot; — 있다.</p>
<h4 id="4-2-낙관적-락optimistic-lock--충돌-안-날-거야-나중에-확인할게">4-2. 낙관적 락(Optimistic Lock) — &quot;충돌 안 날 거야. 나중에 확인할게&quot;</h4>
<p>&quot;Optimistic&quot;은 <strong>&quot;낙관적인&quot;</strong>이라는 뜻이다. 라틴어 &quot;optimus(최선의)&quot;에서 왔다. <strong>&quot;최선의 상황(충돌 없음)을 가정&quot;</strong>하고, 잠그지 않는다. 대신 저장할 때 &quot;내가 읽었던 버전이 아직 그대로인지&quot; 확인한다.</p>
<p>비관적 락과 이름이 정확히 대칭인 게 재밌다:</p>
<ul>
<li>Pessimistic (pessimus = 최악) → &quot;최악을 가정&quot; → 미리 잠근다</li>
<li>Optimistic (optimus = 최선) → &quot;최선을 가정&quot; → 나중에 확인한다</li>
</ul>
<p>구글 독스에서 같은 문단을 두 사람이 동시에 수정할 때 &quot;충돌이 발생했습니다&quot;라고 알려주는 것과 비슷하다. 미리 막지는 않지만, <strong>덮어쓰기는 방지</strong>한다.</p>
<pre><code class="language-java">@Entity
public class OptimisticStockEntity {
    @Version  // JPA가 자동으로 버전 관리
    private Long version;
    private int quantity;
}

// JPA가 생성하는 SQL:
// UPDATE optimistic_stock SET quantity = 99, version = 2
// WHERE id = 1 AND version = 1;
//
// 이 SQL을 해석하면:
// &quot;id가 1이고 version이 내가 읽은 것(1)과 같은 행만 업데이트해줘&quot;
// → 누가 이미 바꿨으면 version이 달라져 있으므로 0행이 영향받음
// → JPA가 이걸 감지해서 OptimisticLockException을 던짐</code></pre>
<p>[MariaDB 콘솔 테스트]
낙관적 락의 WHERE version = ? 동작 확인:</p>
<p>사전 준비:</p>
<pre><code>  CREATE TABLE optimistic_stock (id BIGINT PRIMARY KEY, quantity INT, version BIGINT);
  INSERT INTO optimistic_stock VALUES (1, 100, 0);</code></pre><p>터미널 1:</p>
<pre><code>  START TRANSACTION;
  SELECT * FROM optimistic_stock WHERE id = 1;  -- quantity=100, version=0</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/8c78e24c-799c-406c-8753-f6e80aebd7ac/image.png" alt=""></p>
<p>터미널 2:</p>
<pre><code>  START TRANSACTION;
  SELECT * FROM optimistic_stock WHERE id = 1;  -- quantity=100, version=0 (바로 읽힘! 잠기지 않았으니까)</code></pre><p>  <img src="https://velog.velcdn.com/images/praesentia-ykm/post/968a23ac-d83c-4846-94b6-4238c3e3f75d/image.png" alt=""></p>
<pre><code>  UPDATE optimistic_stock SET quantity = 99, version = 1 WHERE id = 1 AND version = 0;
  -- Query OK, 1 row affected
  COMMIT;</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/c9e2e4ab-4a5f-46e0-8bda-820714a3fada/image.png" alt=""></p>
<p>터미널 1:</p>
<pre><code>  UPDATE optimistic_stock SET quantity = 99, version = 1 WHERE id = 1 AND version = 0;
  -- Query OK, 0 rows affected ❌ (version이 이미 1로 바뀌었으므로!)
  -- → &quot;0 rows affected&quot;가 뜨는게 일반적(하단 스크린샷은 mariaDB의 자체적인 낙관락 감지 내장 기능 때문에 발생한 에러[innodb_snapshot_isolation]). 이것이 낙관적 락의 충돌 감지다.
  ROLLBACK;</code></pre><p>  <a href="https://jira.mariadb.org/browse/MDEV-35487">innodb_snapshot_isolation란?</a>
  <img src="https://velog.velcdn.com/images/praesentia-ykm/post/af77f886-5878-4b84-97e4-88395d92c6ba/image.png" alt=""></p>
<p>결론!!:</p>
<ul>
<li>비관적 락: SELECT 시점에 블로킹 → 다른 트랜잭션이 대기 → 순차 처리</li>
<li>낙관적 락: SELECT 자유 → UPDATE 시점에 version으로 충돌 감지 → 실패 시 재시도
--&gt;</li>
</ul>
<p>결과:</p>
<pre><code>[재시도 없음] 성공: 24건, 실패(OptimisticLockException): 76건
[재시도 있음] 성공: 100건, 최종 재고: 0, 소요 시간: 317ms ✅</code></pre><p>재시도 없이는 76%가 실패했다. 왜 76%인지 생각해보면 이렇다: 100개 스레드가 동시에 version 0을 읽고, UPDATE를 시도한다. DB는 이 UPDATE들을 하나씩 처리하는데, 첫 번째 UPDATE가 version을 1로 올리면 나머지 99개의 <code>WHERE version = 0</code> 조건이 모두 실패한다. 그 중 일부가 재시도 없이도 성공한 건, 실행 타이밍이 겹치지 않아서 운 좋게 통과한 것이다.</p>
<p>재시도 로직을 추가하면 결국 모두 성공하지만, <strong>317ms</strong>가 걸렸다.</p>
<h4 id="그러면-비관적-vs-낙관적-어느-게-더-빠른-건가">그러면 비관적 vs 낙관적, 어느 게 더 빠른 건가?</h4>
<p>단순히 &quot;비관적이 느리다&quot; 또는 &quot;낙관적이 빠르다&quot;고 말할 수 없다. <strong>충돌 빈도에 따라 역전</strong>된다.</p>
<ul>
<li><strong>충돌이 드물 때</strong>: 낙관적 락이 유리하다. 대부분의 요청이 충돌 없이 성공하므로, 잠그는 오버헤드가 없는 만큼 빠르다.</li>
<li><strong>충돌이 잦을 때</strong>: 비관적 락이 유리하다. 낙관적 락은 &quot;실패 → 재시도 → 또 실패 → 또 재시도&quot;를 반복하면서 DB에 불필요한 쿼리를 계속 날린다. 비관적 락은 한 번 대기하면 한 번에 성공한다.</li>
</ul>
<p>쉽게 말하면: <strong>비관적 락은 &quot;줄 서서 기다리기&quot;이고, 낙관적 락은 &quot;뽑기 실패하면 다시 줄 서기&quot;다.</strong> 줄이 짧으면 뽑기가 빠르지만, 줄이 길면 순서대로 기다리는 게 낫다.</p>
<p>보다 더 자세하게 나의 관점을 정리한 흐름도를 적어본다.
<img src="https://velog.velcdn.com/images/praesentia-ykm/post/d027af81-260f-42f7-a82f-da97ae2e3656/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>상황</th>
<th>추천</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>게시글 수정 (동시 수정 드묾)</td>
<td>낙관적</td>
<td>같은 글을 동시에 수정할 확률 낮음</td>
</tr>
<tr>
<td>선착순 쿠폰 100장 발급</td>
<td>비관적</td>
<td>동시 요청 폭주 → 낙관적이면 재시도 지옥</td>
</tr>
<tr>
<td>상품 재고 차감 (보통 트래픽)</td>
<td>낙관적 + 재시도</td>
<td>적당한 경합, 재시도로 커버 가능</td>
</tr>
<tr>
<td>상품 재고 차감 (초당 수천)</td>
<td>비관적 또는 분산 락</td>
<td>경합이 너무 심하면 DB 락도 한계</td>
</tr>
</tbody></table>
<h4 id="두-번째-함정--spring-프록시-자기-호출">두 번째 함정 — Spring 프록시 자기 호출</h4>
<p>재시도 로직을 구현하다가 또 함정에 빠졌다. 처음에는 같은 서비스 클래스 안에 재시도 메서드를 넣었다.</p>
<pre><code class="language-java">// ❌ 이렇게 하면 안 된다
@Service
public class OptimisticStockService {
    @Transactional
    public void decrease(Long id) { ... }

    public boolean decreaseWithRetry(Long id, int maxRetries) {
        for (int retry = 0; retry &lt;= maxRetries; retry++) {
            try {
                this.decrease(id);  // ← 문제: this = 원본 객체, 프록시가 아님!
                return true;
            } catch (ObjectOptimisticLockingFailureException e) { ... }
        }
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/d91cae97-0e32-4f47-8a08-9ba7cfb08346/image.png" alt=""></p>
<p><strong>테스트 결과: 재고가 100 그대로.</strong> 한 건도 차감되지 않았다.</p>
<p>원인은 Step 3과 같은 맥락이다. <code>this.decrease()</code>는 <strong>자기 자신(원본 객체)</strong>의 메서드를 직접 호출한다. Spring 컨테이너에서 주입받은 것은 프록시(대리인)인데, <code>this</code>는 프록시가 아니라 원본이다. 프록시를 거치지 않으니 <code>@Transactional</code>이 작동하지 않고, 트랜잭션 없이 JPA가 동작하면 변경 감지(dirty checking)와 flush가 제대로 일어나지 않는다.</p>
<pre><code>외부에서 호출: 호출자 → [Proxy: 트랜잭션 시작] → 원본 객체.decrease() → [Proxy: 커밋]  ✅
자기 호출:     원본 객체.decreaseWithRetry() → this.decrease()  ← 프록시를 우회!        ❌</code></pre><p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/30462ef1-b07a-48f7-a611-b8f0a2ed9cd7/image.png" alt=""></p>
<p>해결: <strong>별도 빈(Facade)으로 분리하여 프록시를 통해 호출한다.</strong></p>
<p>&quot;Facade&quot;는 <strong>&quot;건물의 정면(앞면)&quot;</strong>이라는 뜻이다. 복잡한 내부 구조를 감추고, 외부에 깔끔한 인터페이스를 제공하는 패턴이다. 여기서는 &quot;재시도 로직&quot;이라는 복잡성을 분리하는 역할을 한다.</p>
<pre><code class="language-java">@Component
public class OptimisticStockFacade {
    private final OptimisticStockService service;  // 주입받은 것 = 프록시

    public boolean decreaseWithRetry(Long id, int maxRetries) {
        for (int retry = 0; retry &lt;= maxRetries; retry++) {
            try {
                service.decrease(id);  // ← 프록시를 통한 호출 → @Transactional 작동!
                return true;
            } catch (ObjectOptimisticLockingFailureException e) {
                Thread.sleep(1);  // 잠시 대기 후 재시도
            }
        }
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/f2f2d6e3-1817-420a-a8eb-b390b3e8aac1/image.png" alt=""></p>
<p>이건 동시성 제어만의 문제가 아니다. <strong>Spring AOP 기반 기능 전체</strong>에 해당하는 제약이다. <code>@Transactional</code>, <code>@Cacheable</code>, <code>@Async</code>, <code>@Retryable</code> — 전부 프록시 기반이므로, 같은 클래스 안에서 자기 호출하면 무효화된다. &quot;프록시&quot;라는 단어의 뜻을 기억하면 자연스럽다: <strong>대리인은 외부에서 호출할 때만 대리 역할을 한다. 내부에서 자기한테 말하는 건 대리인이 끼어들 수 없다.</strong></p>
<hr>
<h3 id="그래서-언제-뭘-쓰는가">그래서, 언제 뭘 쓰는가?</h3>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/37cb0ef8-f715-4e2b-ba5c-272bc32efe36/image.png" alt=""></p>
<p>Step 1~4를 거치면서 네 개의 키워드가 자연스럽게 <strong>판단 기준</strong>으로 연결된다:</p>
<p><strong>1. &quot;데이터가 어디에 있는가?&quot; — 같은 레이어에서 제어 (Step 3~4에서 느꼈다!)</strong></p>
<ul>
<li>메모리에만 있다 (단일 JVM) → Java 락 (synchronized, ReentrantLock, Atomic)</li>
<li>DB에 있다 → DB 락 (비관적 락, 낙관적 락)</li>
<li><strong>섞지 마라.</strong> Java 락으로 DB 데이터를 제어하면 <strong>생명주기 불일치</strong>로 깨진다 (Step 3).</li>
<li>데이터가 사는 곳과 락이 사는 곳을 일치시켜야 한다.</li>
</ul>
<p><strong>2. &quot;충돌이 얼마나 자주 일어나는가?&quot; — 상호 배제의 전략을 정한다</strong></p>
<ul>
<li>거의 안 일어난다 → 낙관적 (잠그지 않고 나중에 확인, 실패 시 재시도)</li>
<li>자주 일어난다 → 비관적 (미리 잠가서 한 번에 성공)</li>
<li><strong>교차점이 있다.</strong> 충돌 빈도가 어느 수준을 넘으면 낙관적의 재시도 비용이 비관적의 대기 비용을 초과한다.</li>
</ul>
<p><strong>3. &quot;문제의 성격이 뭔가?&quot; — read-modify-write인가, INSERT 중복인가?</strong></p>
<ul>
<li>같은 값을 동시에 바꾸려는 것 (UPDATE) → 락</li>
<li>같은 데이터를 동시에 넣으려는 것 (INSERT 중복) → 유니크 제약</li>
<li>도구를 고르기 전에 문제를 정확히 정의하는 게 먼저다.</li>
</ul>
<pre><code>동시성 문제가 있다
│
├─ 문제 유형은?
│   ├─ UPDATE 경합 → 아래 판단으로
│   └─ INSERT 중복 → 유니크 제약 (락 불필요)
│
├─ 데이터가 메모리에만 있는가? (단일 JVM)
│   ├─ 단일 변수 연산 → AtomicInteger (CAS, 가장 빠름, 경합 심하면 스핀 주의)
│   ├─ 단순 임계 구간 → synchronized (가장 간단, 무한 대기 주의)
│   └─ 타임아웃 필요  → ReentrantLock (tryLock, unlock 누락 주의)
│
├─ 데이터가 DB에 있는가?
│   ├─ 충돌이 드물다 → 낙관적 락 (@Version + Facade에서 재시도)
│   └─ 충돌이 잦다   → 비관적 락 (FOR UPDATE, 데드락/커넥션 풀 주의)
│
└─ DB 자체가 병목인가?
    └─ 분산 락 (Redis) — 다음 글에서 다룰 예정</code></pre><hr>
<h2 id="결론">결론</h2>
<table>
<thead>
<tr>
<th>Step</th>
<th>시도</th>
<th>결과</th>
<th>핵심 키워드</th>
<th>깨달은 점</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>락 없이 실행</td>
<td>재고 99</td>
<td><strong>read-modify-write</strong></td>
<td>1ms 간격이면 충분히 터진다. 테스트 통과 ≠ 문제 없음</td>
</tr>
<tr>
<td>2-1</td>
<td>synchronized</td>
<td>재고 0, 217ms</td>
<td><strong>상호 배제</strong></td>
<td>모니터 락으로 해결되지만, 무한 대기의 대가</td>
</tr>
<tr>
<td>2-2</td>
<td>ReentrantLock</td>
<td>7건 성공, 93건 포기</td>
<td>상호 배제</td>
<td>tryLock으로 &quot;포기&quot;할 수 있다. unlock 누락 주의</td>
</tr>
<tr>
<td>2-3</td>
<td>AtomicInteger</td>
<td>재고 0, 1ms</td>
<td>상호 배제 (CAS)</td>
<td>잠그지 않아 빠르지만, 단일 변수 + 경합 적을 때만</td>
</tr>
<tr>
<td>2-UK</td>
<td>유니크 제약</td>
<td>1건만 발급</td>
<td>문제 정의</td>
<td>UPDATE와 INSERT는 다른 문제. 도구 전에 문제 정의가 먼저</td>
</tr>
<tr>
<td>3</td>
<td>synchronized + @Transactional</td>
<td>재고 49</td>
<td><strong>생명주기 불일치</strong></td>
<td>프록시 생명주기 ≠ 락 생명주기. 레이어를 섞으면 깨진다</td>
</tr>
<tr>
<td>4-1</td>
<td>비관적 락 (FOR UPDATE)</td>
<td>재고 0</td>
<td><strong>같은 레이어에서 제어</strong></td>
<td>DB 락 = 트랜잭션에 종속. 생명주기 일치로 갭 없음</td>
</tr>
<tr>
<td>4-2</td>
<td>낙관적 락 + 재시도</td>
<td>재고 0, 317ms</td>
<td>같은 레이어에서 제어</td>
<td>충돌 감지 + 재시도. 빈도에 따라 비관적과 역전</td>
</tr>
</tbody></table>
<p>이제까지의 네 개의 키워드를 다시 정리해보면:</p>
<p><strong>1. read-modify-write — 동시성 문제의 진원지</strong>
모든 것은 여기서 시작됐다. 읽기와 쓰기가 분리된 순간, 그 사이에 다른 스레드가 끼어들 수 있다. 1ms의 간격으로 99건이 사라지는 것을 직접 보고 나니, &quot;테스트 통과 ≠ 문제 없음&quot;이라는 말이 체감됐다. 동시성 문제는 확률적이다. 구조적으로 안전한지를 따로 판단해야 한다.</p>
<p><strong>2. 상호 배제 (mutual exclusion) — 가장 직관적인 해답, 그리고 그 한계</strong>
read-modify-write를 atomic하게 만들 수 없다면, 한 번에 하나만 들어오게 막으면 된다. synchronized, ReentrantLock, AtomicInteger — 모두 상호 배제의 변형이다. 하지만 이것만으로는 DB 트랜잭션이 개입하는 순간 무너진다.</p>
<p><strong>3. 생명주기 불일치 (lifecycle mismatch)</strong>
synchronized + @Transactional 관련 함정이 이번에 배운 새로운 지식이였다.  lock은 코드 블록에 종속되고, 트랜잭션은 프록시에 종속된다. 두 메커니즘이 끝나는 시점이 다르면, 그 찰나의 갭에서 정합성이 깨진다. &quot;왜 안 되는지&quot;를 이해하려면 프록시가 뭔지, 왜 Spring이 그렇게 설계했는지까지 따라가야 했다.</p>
<p><strong>4. 같은 레이어에서 제어 — 데이터가 사는 곳이 어디?</strong>
결국 답은 단순했다. 데이터가 사는 곳에서 제어해야 한다. 메모리 데이터는 Java 락으로, DB 데이터는 DB 락으로. 비관적 락은 트랜잭션과 생명주기가 같아서 갭이 없고, 낙관적 락은 버전으로 충돌을 감지한다. 섞으면 깨진다.</p>
<p>*<em>5. 그런데 이제 DB에서 락 감당이 안된다면? *</em>
분산락과 REDIS에 대하여 추가적으로 다뤄볼 예정이다.
<img src="https://velog.velcdn.com/images/praesentia-ykm/post/00d5de38-08d4-4efc-b6a2-1c00c48401c8/image.png" alt=""></p>
<hr>
<h3 id="그리고-돌이켜보니-단어에-정답이-있었다"><strong><em>그리고 돌이켜보니 단어에 정답이 있었다.</em></strong></h3>
<blockquote>
<p><strong>단어의 뜻을 따라가면 개념이 연결된다.</strong> Pessimistic(최악을 가정), Optimistic(최선을 가정), Atomic(쪼갤 수 없는), Proxy(대리인), Monitor(감시자), Isolation(격리), Constraint(제약) — 용어를 외우는 게 아니라 뜻을 이해하면, 새로운 개념을 만나도 이름에서 힌트를 얻을 수 있다는 인사이트도 깨닫게 되었다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA["상품이 뭔데?" — DDD 책임 분리를 나만의 기준으로 탐구하기]]></title>
            <link>https://velog.io/@praesentia-ykm/%EC%83%81%ED%92%88%EC%9D%B4-%EB%AD%94%EB%8D%B0-DDD-%EC%B1%85%EC%9E%84-%EB%B6%84%EB%A6%AC%EB%A5%BC-%EB%82%98%EB%A7%8C%EC%9D%98-%EA%B8%B0%EC%A4%80%EC%9C%BC%EB%A1%9C-%ED%83%90%EA%B5%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@praesentia-ykm/%EC%83%81%ED%92%88%EC%9D%B4-%EB%AD%94%EB%8D%B0-DDD-%EC%B1%85%EC%9E%84-%EB%B6%84%EB%A6%AC%EB%A5%BC-%EB%82%98%EB%A7%8C%EC%9D%98-%EA%B8%B0%EC%A4%80%EC%9C%BC%EB%A1%9C-%ED%83%90%EA%B5%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 27 Feb 2026 07:16:54 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/b3979ec4-a67a-4276-8c32-cd22d8e3cbbb/image.png" alt=""></p>
<h2 id="들어가며">들어가며</h2>
<p><a href="https://velog.io/@praesentia-ykm">이전 글</a>에서 33개의 Q&amp;A로 설계를 먼저 한 이야기를 했다. 이번에는 그 설계 과정에서 가장 머리를 싸맸던 부분에 대해 써보려 한다.</p>
<p><strong>&quot;이 코드를 어디에 둬야 하는가?&quot;</strong></p>
<p>DDD를 공부하면 &quot;바운디드 컨텍스트&quot;, &quot;어그리게이트&quot;, &quot;도메인 서비스&quot; 같은 용어가 쏟아진다. 개념 자체는 어렵지 않았다. 문제는 <strong>실제 코드에 적용하려 할 때</strong> 발생했다.</p>
<blockquote>
<p>&quot;ProductService에 있어야 해, 아니면 ProductFacade에 있어야 해?&quot;
&quot;Stock이랑 Product를 한 테이블에 두면 안 돼?&quot;
&quot;LikeFacade가 ProductService를 직접 부르는 게 맞아?&quot;</p>
</blockquote>
<p>이런 질문에 &quot;상황에 따라 다릅니다&quot;는 답이 되지 않는다. <strong>어떤 상황에서 어떻게 달라지는지</strong>, 그 갈림길의 기준을 찾고 싶었다.</p>
<p>따라서, 이번엔 DDD 설계 흐름을 10개의 키워드로 정리하고, 각 키워드마다 <strong>&quot;어떤 기준으로 개념을 분리하는가&quot;</strong>에 대한 나의 생각을 표현해보려고 한다.</p>
<hr>
<h2 id="ddd-설계-흐름을-그려보자">DDD 설계 흐름을 그려보자!</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>단계</th>
<th>키워드</th>
<th>핵심 질문</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>전략</td>
<td>서브도메인 식별</td>
<td>이 사업의 핵심은 무엇이고, 어디에 설계 역량을 집중할 것인가?</td>
</tr>
<tr>
<td>2</td>
<td>전략</td>
<td>유비쿼터스 언어</td>
<td>이 단어가 이 맥락에서 뭘 의미하나?</td>
</tr>
<tr>
<td>3</td>
<td>전략</td>
<td>바운디드 컨텍스트</td>
<td>같은 단어가 다른 의미를 갖는 경계는?</td>
</tr>
<tr>
<td>4</td>
<td>전략</td>
<td>컨텍스트 매핑</td>
<td>나눈 컨텍스트들이 서로 어떻게 대화하는가?</td>
</tr>
<tr>
<td>5</td>
<td>전술</td>
<td>어그리게이트</td>
<td>이 데이터를 단독으로 다룰 일이 있는가?</td>
</tr>
<tr>
<td>6</td>
<td>전술</td>
<td>엔티티 vs 값 객체</td>
<td>이것이 고유 정체성을 갖는가, 속성의 묶음인가?</td>
</tr>
<tr>
<td>7</td>
<td>전술</td>
<td>도메인 이벤트</td>
<td>경계를 넘는 통신은 어떻게 하는가?</td>
</tr>
<tr>
<td>8</td>
<td>전술</td>
<td>도메인 서비스 vs 애플리케이션 서비스</td>
<td>이 코드가 비즈니스 의사결정을 내리는가?</td>
</tr>
<tr>
<td>9</td>
<td>전술</td>
<td>리포지토리 경계</td>
<td>어그리게이트 루트 단위로만 존재하는가?</td>
</tr>
<tr>
<td>10</td>
<td>아키텍처</td>
<td>레이어 구분</td>
<td>의존성 방향이 안쪽을 향하는가?</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/72623405-b0b5-484b-9ab8-5269dc0d3f63/image.png" alt=""></p>
<p>1<del>4는 <strong>&quot;무엇을 나눌 것인가&quot;</strong>(전략), 5</del>9는 <strong>&quot;나눈 것을 어떻게 구현할 것인가&quot;</strong>(전술), 10은 <strong>&quot;구현물을 어떻게 배치할 것인가&quot;</strong>(아키텍처)다.</p>
<p>한 가지 미리 짚어둘 게 있다. <strong>나누기만 하면 부서진다.</strong> 1<del>4에서 경계를 그으면, 5</del>9에서 그 경계 사이의 관계를 정의해야 한다. 나누기와 연결하기는 항상 쌍이다.</p>
<hr>
<h2 id="전략적-설계--무엇을-나눌-것인가">전략적 설계 — 무엇을 나눌 것인가</h2>
<h3 id="1-서브도메인-식별--이-중에-뭐가-제일-중요한가">1. 서브도메인 식별 — &quot;이 중에 뭐가 제일 중요한가?&quot;</h3>
<p><strong>키워드: Core / Supporting / Generic</strong></p>
<p>서브도메인 식별은 &quot;사업에서 어떤 영역이 있는가&quot;를 나누고, <strong>&quot;어디에 설계 역량을 집중할 것인가&quot;</strong>를 결정하는 단계다.</p>
<table>
<thead>
<tr>
<th>유형</th>
<th>의미</th>
<th>설계 전략</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Core</strong></td>
<td>비즈니스 경쟁력의 핵심</td>
<td>직접 설계하고 정교하게 구현</td>
</tr>
<tr>
<td><strong>Supporting</strong></td>
<td>Core를 보조. 중요하지만 차별화 요소는 아님</td>
<td>직접 구현하되 Core만큼의 투자는 불필요</td>
</tr>
<tr>
<td><strong>Generic</strong></td>
<td>어디서나 비슷하게 필요한 범용 기능</td>
<td>외부 솔루션 사용 가능</td>
</tr>
</tbody></table>
<p>이커머스에서 이걸 적용하면:</p>
<table>
<thead>
<tr>
<th>서브도메인</th>
<th>유형</th>
<th>판단 근거</th>
</tr>
</thead>
<tbody><tr>
<td><strong>카탈로그</strong> (상품 + 브랜드)</td>
<td>Core</td>
<td>고객에게 보여줄 상품을 관리. 비즈니스 전시의 핵심</td>
</tr>
<tr>
<td><strong>주문</strong></td>
<td>Core</td>
<td>거래를 기록하고 관리. 매출의 직접적 근간</td>
</tr>
<tr>
<td><strong>재고</strong></td>
<td>Supporting</td>
<td>주문과 카탈로그를 보조. 중요하지만 독자적 경쟁력은 아님</td>
</tr>
<tr>
<td><strong>좋아요</strong></td>
<td>Supporting</td>
<td>고객 선호 추적. 카탈로그 정렬(인기순)에 활용</td>
</tr>
<tr>
<td><strong>회원/인증</strong></td>
<td>Generic</td>
<td>어디서나 비슷한 범용 기능. 외부 솔루션 대체 가능</td>
</tr>
<tr>
<td><img src="https://velog.velcdn.com/images/praesentia-ykm/post/97df448e-cc08-4ad8-a4eb-b4fdf5b87a0f/image.png" alt=""></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<p>근데 왜 재고가 Supporting이지?</p>
<p>처음엔 이렇게 생각했다. 재고는 <strong>&quot;반응하는 도메인&quot;</strong>이다. 주문이 들어오면 차감되고, 상품이 등록되면 생성된다. 스스로 뭔가를 일으키기보다, 다른 도메인의 상태 변경에 영향을 받는 자식도메인 같은 느낌이었다.</p>
<p>근데 이 기준만으로는 부족했다. 주문도 &quot;고객이 구매 버튼을 누르면&quot; 반응하는 도메인인데, Core잖아. &quot;반응한다&quot;는 것만으로 Supporting을 판별할 수 없었다.</p>
<p>차이는 여기서 갈렸다: <strong>&quot;이 도메인이 없어도 사업이 성립하는가?&quot;</strong></p>
<ul>
<li>주문이 없으면? 이커머스가 아니다. 물건을 팔 수 없다.</li>
<li>재고가 없으면? 판매는 된다. 다만 관리가 허술해질 뿐이다. 실제로 소규모 쇼핑몰은 재고 관리 없이도 돌아간다.</li>
</ul>
<p><strong>&quot;반응만 하는 도메인 + 없어도 사업이 돌아가면 = Supporting.&quot;</strong> 없으면 사업 자체가 불가능하면 아무리 반응형이어도 Core다.</p>
<p>이 분류가 코드에 주는 영향은 명확하다. 카탈로그와 주문은 도메인 모델을 정교하게 설계하고, 회원/인증은 <code>userId</code>만 받아서 참조한다. 실제로 <code>MemberFacade</code>는 단순한 CRUD뿐이고, <code>OrderFacade</code>는 재고 차감, 스냅샷 생성, All or Nothing 검증까지 복잡한 규칙이 들어있다.</p>
<hr>
<h3 id="2-유비쿼터스-언어--이-단어가-이-맥락에서-뭘-의미하나">2. 유비쿼터스 언어 — &quot;이 단어가 이 맥락에서 뭘 의미하나?&quot;</h3>
<p><strong>키워드: 같은 단어, 다른 의미</strong></p>
<p>&quot;상품이 뭔데?&quot;라고 물었을 때, 대답이 달라지는 지점이 있다.</p>
<p>현재 <code>ProductModel</code>은 이렇게 생겼다.</p>
<pre><code class="language-java">public class ProductModel extends BaseEntity {
    private String name;        // &quot;상품을 전시한다&quot; 관점
    private String description; // &quot;상품을 전시한다&quot; 관점
    private Money price;        // &quot;상품의 가치를 매긴다&quot; 관점
    private Long brandId;       // &quot;상품이 어떤 브랜드인지&quot; 관점
    private int likeCount;      // &quot;상품이 얼마나 인기있는지&quot; 관점
}</code></pre>
<p>하나의 클래스에 다섯 가지 관심사가 공존한다. &quot;상품이 뭔데?&quot;라고 물으면 맥락마다 답이 완전히 다르다.</p>
<table>
<thead>
<tr>
<th>맥락</th>
<th>&quot;상품&quot;의 의미</th>
<th>관심 있는 속성</th>
<th>관심 없는 속성</th>
</tr>
</thead>
<tbody><tr>
<td><strong>카탈로그</strong></td>
<td>고객에게 보여줄 전시물</td>
<td>name, description, price, brand</td>
<td>quantity, likeCount</td>
</tr>
<tr>
<td><strong>재고</strong></td>
<td>창고에서 관리할 물건</td>
<td>productId, quantity, status</td>
<td>name, description, brand</td>
</tr>
<tr>
<td><strong>좋아요</strong></td>
<td>사용자가 선호를 표현한 대상</td>
<td>productId (참조만)</td>
<td>name, price, quantity</td>
</tr>
<tr>
<td><strong>주문</strong></td>
<td>거래의 대상 (가격이 확정된 시점)</td>
<td>productId, 주문시점가격, 수량</td>
<td>현재가격, 재고, 좋아요</td>
</tr>
</tbody></table>
<p>같은 &quot;상품&quot;인데 <strong>필요한 속성이 완전히 다르다.</strong> 이 차이를 인식하는 것 자체가 유비쿼터스 언어를 정의하는 과정이다.</p>
<p>그리고 이 과정은 다음 단계인 바운디드 컨텍스트와 <strong>동시에</strong> 일어난다. &quot;상품&quot;이라는 단어의 의미가 달라지는 지점을 발견하는 순간이 곧 경계를 긋는 순간이다.</p>
<p>비유를 들자면, 지도에서 국경선을 긋는 것과 각 나라의 공용어를 정하는 것이다. 언어를 먼저 정하고 국경을 그리는 게 아니라, <strong>언어 차이가 국경을 드러낸다.</strong></p>
<hr>
<h3 id="3-바운디드-컨텍스트--같은-단어가-다른-의미를-갖는-경계는">3. 바운디드 컨텍스트 — &quot;같은 단어가 다른 의미를 갖는 경계는?&quot;</h3>
<p><strong>키워드: 경계 긋기</strong></p>
<p>유비쿼터스 언어에서 의미가 갈라지는 지점이 바운디드 컨텍스트의 경계다.</p>
<blockquote>
<p><strong>판별 기준:</strong> 같은 단어가 다른 속성/행위를 요구하는 지점 = 바운디드 컨텍스트 경계</p>
</blockquote>
<p>이 기준은 <strong>동일 도메인 용어가 여러 맥락에서 사용될 때만</strong> 적용된다. 애초에 다른 단어를 쓰는 영역(예: &quot;상품&quot;과 &quot;결제수단&quot;)은 비즈니스 관심사 자체가 다르므로 별도 컨텍스트다.</p>
<p>재밌는 건 무의식적으로 이미 이 경계를 지키고 있었다는 것이다.</p>
<ul>
<li><code>LikeModel</code>은 <code>ProductModel</code>을 직접 참조하지 않고 <code>productId</code>만 보유한다.</li>
<li><code>StockModel</code>도 <code>productId</code>만 보유한다.</li>
<li><code>OrderItemModel</code>에는 주문 시점의 <code>productName</code>, <code>productPrice</code>를 <strong>스냅샷</strong>으로 복사한다.</li>
</ul>
<p>각 도메인은 &quot;상품&quot; 전체를 알 필요 없이 자기에게 필요한 단편만 들고 있다. 이것이 바운디드 컨텍스트 간의 <strong>느슨한 참조(ID 참조)</strong>다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/6a57db0a-1182-4a68-bc92-c64b8ab6fde2/image.png" alt=""></p>
<p>근데 &quot;의미가 다르면 나눈다&quot;로만 끝나지 않았다. <strong>&quot;이 둘이 하나의 트랜잭션으로 묶여야 하는가?&quot;</strong>도 경계 판단에 영향을 줬다.</p>
<p>Brand와 Product가 그 예시다. 직감적으로는 분리하고 싶었다. 브랜드는 브랜드고 상품은 상품이니까. 근데 Q&amp;A 과정에서 이런 질문을 던졌었다.</p>
<blockquote>
<p>Q1: 브랜드를 삭제하면 소속 상품은 어떻게 되는가?</p>
</blockquote>
<p>답은 &quot;브랜드 삭제 시 소속 상품 전체를 연쇄 soft delete&quot;이고, 이것은 <strong>하나의 트랜잭션</strong>으로 처리되어야 한다.</p>
<pre><code class="language-java">@Transactional
public void deleteBrand(Long brandId) {
    brandService.delete(brandId);
    productService.softDeleteByBrandId(brandId);  // 연쇄 삭제 — 같은 트랜잭션
}</code></pre>
<p>만약 Brand와 Product가 다른 바운디드 컨텍스트에 있다면 이 트랜잭션은 <strong>분산 트랜잭션</strong>이 된다. &quot;브랜드 삭제&quot;라는 단순한 요구사항에 Saga 패턴 같은 복잡도를 도입하는 건 과하다.</p>
<p><strong>경계 판단 기준 정리:</strong></p>
<ol>
<li>같은 단어가 다른 속성/행위를 요구하면 → 다른 BC</li>
<li>하나의 트랜잭션으로 묶여야 하면 → 같은 BC</li>
</ol>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/f0268056-f930-42c8-b0e7-d8bf89309f5d/image.png" alt=""></p>
<hr>
<h3 id="4-컨텍스트-매핑--나눈-것들이-어떻게-대화하는가">4. 컨텍스트 매핑 — &quot;나눈 것들이 어떻게 대화하는가?&quot;</h3>
<p><strong>키워드: 관계 정의</strong></p>
<p>바운디드 컨텍스트를 나눠 놓고 끝이 아니다. <strong>나눈 것들 사이의 통신 방식을 정해야 한다.</strong> 이걸 빼먹으면 &quot;잘 나눈 것 같은데 결국 다 얽혀있네?&quot;라는 상황이 된다.</p>
<p>현재 프로젝트의 의존 관계:</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/d8132a29-13a3-415e-b604-2a50c5532bf8/image.png" alt=""></p>
<p>모놀리스에서는 Facade가 다른 도메인의 Service를 직접 호출한다. 여기서 중요한 건 <strong>&quot;지금은 직접 호출하되, 시스템이 커졌을 때 어디서 잘라야 하는가&quot;를 아는 것</strong>이다.</p>
<table>
<thead>
<tr>
<th>호출</th>
<th>방식</th>
<th>시스템 분리 시 전환</th>
</tr>
</thead>
<tbody><tr>
<td><code>ProductFacade</code> → <code>BrandService</code></td>
<td>직접 호출</td>
<td>같은 BC — 분리 불필요</td>
</tr>
<tr>
<td><code>ProductFacade</code> → <code>StockService</code></td>
<td>직접 호출</td>
<td>API 호출 또는 이벤트</td>
</tr>
<tr>
<td><code>LikeFacade</code> → <code>ProductService</code></td>
<td>직접 호출</td>
<td><strong>도메인 이벤트</strong></td>
</tr>
<tr>
<td><code>OrderFacade</code> → <code>ProductService</code> + <code>StockService</code></td>
<td>직접 호출</td>
<td>Saga 패턴</td>
</tr>
</tbody></table>
<p>특히 <code>LikeFacade</code>가 카탈로그 BC의 엔티티를 직접 수정하는 부분을 보면:</p>
<pre><code class="language-java">// LikeFacade — 좋아요 BC가 카탈로그 BC의 엔티티를 직접 수정
public void like(Long userId, Long productId) {
    ProductModel product = productService.getProduct(productId);
    // ... 좋아요 로직
    product.incrementLikeCount();  // ← 다른 BC의 엔티티를 직접 변경
}</code></pre>
<p>모놀리스에서는 이게 실용적이다. 하지만 이 코드가 <strong>BC 경계를 넘는 직접 수정</strong>이라는 사실은 인식하고 있어야 한다. 시스템이 커져서 물리적으로 분리할 때, 이 부분은 도메인 이벤트로 전환된다.</p>
<blockquote>
<p>좋아요 발생 → <code>LikeCreatedEvent</code> 발행 → 카탈로그 BC가 수신 → <code>likeCount</code> 갱신</p>
</blockquote>
<p>&quot;지금은 직접 호출하되, 여기가 나중에 잘라야 할 지점&quot;이라는 걸 아는 것과 모르는 것은 다르다. 컨텍스트 매핑의 가치가 여기에 있다.</p>
<hr>
<h2 id="전술적-설계--나눈-것을-어떻게-구현할-것인가">전술적 설계 — 나눈 것을 어떻게 구현할 것인가</h2>
<h3 id="5-어그리게이트--단독으로-접근할-일이-있는가">5. 어그리게이트 — &quot;단독으로 접근할 일이 있는가?&quot;</h3>
<p><strong>키워드: 접근성과 잠금</strong></p>
<p>같은 바운디드 컨텍스트 안에서도 &quot;어디까지를 하나의 단위로 묶을 것인가&quot;를 결정해야 한다. 이게 어그리게이트 경계다.</p>
<p>Product와 Stock은 1:1 관계인데, 깊은 관계니까 하나로 합쳐야 하지 않을까? 처음엔 그렇게 생각했다. 근데 직감적으로 <strong>&quot;재고를 보기 위해 매번 상품을 거쳐야 한다면?&quot;</strong>이 걸렸다. 재고 차감은 상품 정보가 필요 없는데, 상품을 통해서만 접근해야 하면 비효율적이지 않은가.</p>
<p>이 직감을 기준으로 정리하면:</p>
<table>
<thead>
<tr>
<th>케이스</th>
<th align="center">&quot;단독으로 접근할 일 있나?&quot;</th>
<th>결론</th>
</tr>
</thead>
<tbody><tr>
<td>재고 ← 상품</td>
<td align="center">있다 (재고만 차감)</td>
<td>분리</td>
</tr>
<tr>
<td>상품 ← 브랜드</td>
<td align="center">있다 (상품만 조회)</td>
<td>분리</td>
</tr>
<tr>
<td>주문항목 ← 주문</td>
<td align="center">없다 (항상 주문 통해)</td>
<td>합침</td>
</tr>
</tbody></table>
<p>이 기준은 DDD에서 흔히 쓰는 <strong>&quot;같이 잠글 필요가 있는가?&quot;</strong>와 결국 같은 얘기다.</p>
<table>
<thead>
<tr>
<th>변경 시나리오</th>
<th align="center">Product 변경?</th>
<th align="center">Stock 변경?</th>
<th>결론</th>
</tr>
</thead>
<tbody><tr>
<td>상품명 수정</td>
<td align="center">O</td>
<td align="center">X</td>
<td>독립</td>
</tr>
<tr>
<td>가격 수정</td>
<td align="center">O</td>
<td align="center">X</td>
<td>독립</td>
</tr>
<tr>
<td>재고 차감 (주문)</td>
<td align="center">X</td>
<td align="center">O</td>
<td>독립</td>
</tr>
<tr>
<td>상품 등록 (초기 재고 포함)</td>
<td align="center">O</td>
<td align="center">O</td>
<td>Facade에서 조율</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/d113df10-a09f-4601-aa25-ee039d93a65c/image.png" alt=""></p>
<p>단독으로 접근이 필요하다는 건 곧 독립적으로 변경된다는 뜻이고, 독립적으로 변경되면 같이 잠글 필요가 없다. <strong>접근성에서 출발해도 잠금에서 출발해도 같은 결론에 도달한다.</strong></p>
<p>유일하게 둘 다 변경되는 &quot;상품 등록&quot;은 <strong>비즈니스 규칙이 아니라 절차</strong>다. &quot;상품을 등록하면서 초기 재고도 만든다&quot;는 순서의 문제이지, 둘이 반드시 원자적으로 잠겨야 하는 건 아니다. Facade에서 조율하면 된다.</p>
<pre><code class="language-java">@Transactional
public ProductModel register(..., Long brandId, int initialStock) {
    brandService.getBrand(brandId);                          // 1. 브랜드 존재 확인
    ProductModel product = productService.register(...);     // 2. 상품 생성
    stockService.create(product.getId(), initialStock);      // 3. 재고 생성
    return product;
}</code></pre>
<blockquote>
<p><strong>단독으로 접근할 일이 있으면 별도 어그리게이트. 동시 변경이 필요한 경우는 Facade에서 조율.</strong></p>
</blockquote>
<hr>
<h3 id="6-엔티티-vs-값-객체--이것이-고유-정체성을-갖는가">6. 엔티티 vs 값 객체 — &quot;이것이 고유 정체성을 갖는가?&quot;</h3>
<p><strong>키워드: 정체성 유무</strong></p>
<p>어그리게이트 안의 객체는 엔티티(Entity)와 값 객체(Value Object)로 나뉜다. 판별 기준은 하나다.</p>
<blockquote>
<p><strong>고유한 식별자(id)가 필요한가? → 엔티티. 속성 값이 같으면 같은 것인가? → 값 객체.</strong></p>
</blockquote>
<p>&quot;어떤 5000원&quot;인지가 중요하면 엔티티다. &quot;5000원이면 다 같은 5000원&quot;이면 값 객체다.</p>
<pre><code class="language-java">// 값 객체 — 5000원이면 다 같은 5000원
@Embeddable
public class Money {
    public static final Money ZERO = new Money(0);

    @Column(name = &quot;price&quot;, nullable = false)
    private int value;

    public Money(int value) {
        if (value &lt; 0) throw new CoreException(ErrorType.BAD_REQUEST, &quot;가격은 음수일 수 없습니다.&quot;);
        this.value = value;
    }

    public Money add(Money other) { return new Money(this.value + other.value); }
    public Money multiply(int multiplier) { return new Money(this.value * multiplier); }
}</code></pre>
<pre><code class="language-java">// 값 객체 — 같은 이름이면 같은 브랜드명
@Embeddable
public class BrandName {
    @Column(name = &quot;name&quot;, nullable = false, unique = true)
    private String value;

    public BrandName(String value) {
        if (value == null || value.isBlank())
            throw new CoreException(ErrorType.BAD_REQUEST, &quot;브랜드 이름은 필수입니다.&quot;);
        this.value = value;
    }
}</code></pre>
<p>값 객체의 특징 세 가지:</p>
<ol>
<li><strong>불변(Immutable):</strong> <code>add()</code>는 기존 객체를 바꾸지 않고 새 객체를 반환한다</li>
<li><strong>자기 검증:</strong> 생성 시점에 유효성을 강제한다 (음수 불가, 빈값 불가)</li>
<li><strong>행위 포함 가능:</strong> <code>Money.multiply()</code>처럼 해당 값과 관련된 연산을 가질 수 있다</li>
</ol>
<p>현재 프로젝트의 분류:</p>
<table>
<thead>
<tr>
<th>객체</th>
<th>타입</th>
<th>근거</th>
</tr>
</thead>
<tbody><tr>
<td><code>ProductModel</code></td>
<td>엔티티</td>
<td>고유 id로 식별. &quot;상품 A&quot;와 &quot;상품 B&quot;는 속성이 같아도 다른 상품</td>
</tr>
<tr>
<td><code>Money</code></td>
<td>값 객체</td>
<td>5000원은 어떤 5000원이든 같은 5000원</td>
</tr>
<tr>
<td><code>BrandName</code></td>
<td>값 객체</td>
<td>&quot;나이키&quot;라는 이름은 그 자체로 동일한 의미</td>
</tr>
<tr>
<td><code>MemberName</code></td>
<td>값 객체</td>
<td>이름 값 자체가 의미. <code>masked()</code> 행위 메서드 보유</td>
</tr>
<tr>
<td><code>LoginId</code></td>
<td>값 객체</td>
<td>영문+숫자 패턴 검증을 생성 시 강제</td>
</tr>
</tbody></table>
<hr>
<h3 id="7-도메인-이벤트--경계를-넘는-통신을-어떻게-하는가">7. 도메인 이벤트 — &quot;경계를 넘는 통신을 어떻게 하는가?&quot;</h3>
<p><strong>키워드: 비동기 통신 / 결합도 제거</strong></p>
<p>4번(컨텍스트 매핑)에서 &quot;나눈 것들이 어떻게 대화하는가&quot;를 정했다면, 도메인 이벤트는 그 대화의 <strong>구현 수단</strong> 중 하나다.</p>
<p>현재 프로젝트에는 도메인 이벤트가 <strong>하나도 없다.</strong> 모든 컨텍스트 간 통신은 Facade에서 직접 호출한다.</p>
<pre><code class="language-java">// 현재: LikeFacade가 ProductService를 직접 호출
product.incrementLikeCount();

// 도메인 이벤트 도입 시:
// 좋아요 생성 → LikeCreatedEvent 발행 → 카탈로그 BC가 수신 → likeCount 갱신</code></pre>
<p>왜 지금 도입하지 않았을까? 트레이드오프 판단이었다.</p>
<table>
<thead>
<tr>
<th>기준</th>
<th>직접 호출</th>
<th>도메인 이벤트</th>
</tr>
</thead>
<tbody><tr>
<td>구현 복잡도</td>
<td>낮음</td>
<td>높음 (이벤트 발행/구독 인프라 필요)</td>
</tr>
<tr>
<td>정합성</td>
<td>즉시 정합성</td>
<td>최종 정합성 (eventual consistency)</td>
</tr>
<tr>
<td>결합도</td>
<td>높음 (다른 BC 서비스 직접 참조)</td>
<td>낮음 (이벤트만 알면 됨)</td>
</tr>
<tr>
<td>적합한 규모</td>
<td>모놀리스</td>
<td>마이크로서비스</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/140597e1-5b12-404b-97b1-9bb7ee0e5cde/image.png" alt=""></p>
<p>모놀리스에서 도메인 이벤트를 도입하면 &quot;아직 필요 없는 복잡도&quot;가 된다. 다만 <strong>어디가 이벤트로 전환될 지점인지</strong>는 컨텍스트 매핑(4번)에서 이미 식별해뒀다. 필요해지는 시점에 전환하면 된다.</p>
<hr>
<h3 id="8-도메인-서비스-vs-애플리케이션-서비스--이-코드가-비즈니스-의사결정을-내리는가">8. 도메인 서비스 vs 애플리케이션 서비스 — &quot;이 코드가 비즈니스 의사결정을 내리는가?&quot;</h3>
<p><strong>키워드: 의사결정 vs 조율</strong></p>
<p>&quot;이 로직을 <code>domain/</code>에 둘까, <code>application/</code>에 둘까?&quot; — 설계하면서 가장 오래 고민한 질문이다.</p>
<p>처음에 세운 기준은 이거였다.</p>
<blockquote>
<p><strong>규칙은 영업부에 설명해도 알아들을 수 있는 것. 절차는 개발자들만 고민하고 구성해야 하는, 사용자 요청에 대한 시나리오.</strong></p>
</blockquote>
<p>&quot;같은 이름의 브랜드는 등록할 수 없다&quot; — 영업부도 안다. 규칙이다. → <code>BrandService</code>
&quot;브랜드 확인하고, 상품 만들고, 재고 만들어라&quot; — 영업부는 모른다. 개발자가 짠 순서다. 절차다. → <code>ProductFacade</code></p>
<p>여기까지는 잘 작동했다. 그리고 <code>BrandService</code>는 <code>domain/</code> 패키지에 넣었다. &quot;규칙을 담으니까 도메인 서비스&quot;라고 생각했다.</p>
<p><strong>근데 이 판단이 틀렸다.</strong></p>
<p>리팩토링 과정에서 더 날카로운 기준을 만났다.</p>
<blockquote>
<p><strong>&quot;이 코드가 비즈니스 의사결정을 내리는가?&quot;</strong></p>
</blockquote>
<p>이 질문을 <code>BrandService.register()</code>에 대입해봤다.</p>
<pre><code class="language-java">public BrandModel register(String name, String description) {
    BrandName brandName = new BrandName(name);  // ← VO가 이름 유효성 검증 (의사결정)
    brandRepository.findByName(name).ifPresent(existing -&gt; {
        throw new CoreException(ErrorType.CONFLICT);  // ← DB 상태 확인 후 거부 (조율)
    });
    return brandRepository.save(new BrandModel(brandName, description));
}</code></pre>
<p><strong>&quot;브랜드 이름이 비어있으면 안 된다&quot;</strong> — 이건 <code>BrandName</code> VO가 생성 시점에 스스로 결정한다. 의사결정이다.
<strong>&quot;중복 이름이 있는지 DB에서 확인한다&quot;</strong> — 이건 <code>BrandService</code>가 Repository를 호출해서 상태를 확인하는 것이다. 조율이다.</p>
<p>핵심은 이거였다. <code>BrandService</code>는 <strong>의사결정을 내리는 게 아니라, 엔티티/VO가 내린 의사결정이 실행될 수 있도록 정보를 준비하고 조율하는 것</strong>이다. 3단계로 보면:</p>
<ol>
<li><strong>정보 준비</strong> (application) — Repository에서 데이터 조회</li>
<li><strong>비즈니스 의사결정</strong> (domain) — 엔티티/VO가 규칙을 적용</li>
<li><strong>결과 적용</strong> (application) — 저장, 이벤트 발행 등</li>
</ol>
<p><code>BrandService</code>의 &quot;유니크 검증&quot;도 결국 1→3이다. DB 상태를 확인(정보 준비)하고 → 충돌이면 거부(결과 적용). <strong>비즈니스 의사결정 자체는 <code>BrandName</code> VO가 이미 담당하고 있다.</strong></p>
<p>이 기준으로 현재 프로젝트의 모든 Service를 점검했다.</p>
<table>
<thead>
<tr>
<th>Service</th>
<th>하는 일</th>
<th align="center">의사결정을 내리는가?</th>
<th>결론</th>
</tr>
</thead>
<tbody><tr>
<td><code>BrandService</code></td>
<td>이름 유니크 체크 + CRUD</td>
<td align="center">No (VO가 유효성 검증)</td>
<td><strong>애플리케이션 서비스</strong></td>
</tr>
<tr>
<td><code>ProductService</code></td>
<td>상품 CRUD</td>
<td align="center">No (Money VO가 가격 검증)</td>
<td><strong>애플리케이션 서비스</strong></td>
</tr>
<tr>
<td><code>StockService</code></td>
<td>재고 생성/차감</td>
<td align="center">No (StockModel.decrease()가 판단)</td>
<td><strong>애플리케이션 서비스</strong></td>
</tr>
<tr>
<td><code>LikeService</code></td>
<td>좋아요 등록/취소</td>
<td align="center">No (멱등성은 DB 상태 확인)</td>
<td><strong>애플리케이션 서비스</strong></td>
</tr>
<tr>
<td><code>OrderService</code></td>
<td>주문/주문상품 CRUD</td>
<td align="center">No (OrderModel.validateOwner()가 판단)</td>
<td><strong>애플리케이션 서비스</strong></td>
</tr>
</tbody></table>
<p><strong>도메인 서비스가 하나도 없었다.</strong> 모든 비즈니스 의사결정은 엔티티와 VO가 내리고 있었고, Service는 그 결정이 실행되도록 조율하는 역할이었다. 그래서 전부 <code>domain/</code> → <code>application/</code>으로 이동시켰다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/adf659f8-c0a3-4586-99b5-fea7c266d28e/image.png" alt=""></p>
<p>그럼 <code>application/</code> 안에서 <strong>Service와 Facade는 어떻게 구분하는가?</strong></p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Service</th>
<th>Facade</th>
</tr>
</thead>
<tbody><tr>
<td><strong>역할</strong></td>
<td>단일 도메인 CRUD 조율</td>
<td>여러 도메인 Service를 조합</td>
</tr>
<tr>
<td><strong>의존</strong></td>
<td>Repository (하나)</td>
<td>Service (여러 개)</td>
</tr>
<tr>
<td><strong>예시</strong></td>
<td><code>BrandService</code> (브랜드 CRUD)</td>
<td><code>ProductFacade</code> (브랜드 확인 + 상품 생성 + 재고 생성)</td>
</tr>
</tbody></table>
<p>코드로 보면:</p>
<p><strong>엔티티 메서드 — 비즈니스 의사결정을 내린다:</strong></p>
<pre><code class="language-java">// &quot;재고가 부족하면 차감할 수 없다&quot; — StockModel이 스스로 판단
public void decrease(int amount) {
    if (this.quantity &lt; amount)
        throw new CoreException(ErrorType.BAD_REQUEST, &quot;재고가 부족합니다.&quot;);
    this.quantity -= amount;
}</code></pre>
<p><strong>Service (애플리케이션) — 단일 도메인 CRUD를 조율한다:</strong></p>
<pre><code class="language-java">// application/brand/BrandService: Repository를 호출하여 CRUD 수행
// 의사결정은 BrandName VO가 내리고, Service는 DB 상태 확인 + 저장을 조율
public BrandModel register(String name, String description) {
    BrandName brandName = new BrandName(name);  // VO가 이름 유효성 검증
    brandRepository.findByName(name).ifPresent(existing -&gt; {
        throw new CoreException(ErrorType.CONFLICT);  // DB 상태 확인 후 거부
    });
    return brandRepository.save(new BrandModel(brandName, description));
}</code></pre>
<p><strong>Facade (애플리케이션) — 여러 도메인 Service를 조합한다:</strong></p>
<pre><code class="language-java">// application/product/ProductFacade: 여러 Service를 조합하여 유스케이스 실행
public ProductModel register(..., Long brandId, int initialStock) {
    brandService.getBrand(brandId);                          // 1. 브랜드 존재 확인 (위임)
    ProductModel product = productService.register(...);     // 2. 상품 생성 (위임)
    stockService.create(product.getId(), initialStock);      // 3. 재고 생성 (위임)
    return product;
}</code></pre>
<p><strong>Facade에 규칙이 있을 수도 있다:</strong></p>
<pre><code class="language-java">// &quot;브랜드 삭제 시 소속 상품도 삭제&quot; — 영업부도 아는 규칙이지만, 두 도메인에 걸침
@Transactional
public void deleteBrand(Long brandId) {
    brandService.delete(brandId);
    productService.softDeleteByBrandId(brandId);  // ← 규칙이지만, 여러 도메인이라 Facade
}</code></pre>
<p>이전에 &quot;규칙이면 도메인 서비스&quot;라고 단순하게 분류했던 것이 틀렸다. <strong>핵심 기준은 &quot;규칙인가 절차인가&quot;가 아니라 &quot;비즈니스 의사결정을 내리는가&quot;다.</strong> 대부분의 의사결정은 엔티티/VO 안에 캡슐화되어 있지만, 하나 발견했다.</p>
<p><strong>LikeFacade에 숨어있던 도메인 서비스:</strong></p>
<p>좋아요 멱등성 로직은 <code>LikeModel</code>과 <code>ProductModel</code> 두 엔티티의 상태를 종합해서 판단한다. &quot;좋아요가 없으면 생성, 삭제됐으면 복구, 이미 있으면 무시&quot; — 이 판단을 어느 한쪽 엔티티에 넣을 수 없다. <code>LikeModel</code>은 <code>ProductModel</code>을 모르고, <code>ProductModel</code>은 <code>LikeModel</code>을 모른다.</p>
<p>이 로직을 지우면 <strong>&quot;멱등성 있는 좋아요&quot;라는 비즈니스 규칙 자체가 사라진다.</strong> 조율이 아니라 의사결정이다. 그래서 도메인 서비스로 분리했다.</p>
<pre><code class="language-java">// domain/like/LikeToggleService — 도메인 서비스
// Like + Product 두 엔티티의 상태를 종합하여 판단. 인프라 의존 없음.
public Optional&lt;LikeModel&gt; like(Optional&lt;LikeModel&gt; existing, ProductModel product,
                                 Long userId, Long productId) {
    if (existing.isEmpty()) {                          // 판단: 신규 → 생성 + 카운트 증가
        product.incrementLikeCount();
        return Optional.of(new LikeModel(userId, productId));
    }
    if (existing.get().getDeletedAt() != null) {       // 판단: 삭제됨 → 복구 + 카운트 증가
        existing.get().restore();
        product.incrementLikeCount();
    }
    // else: 이미 활성 → 멱등 무시
    return Optional.empty();
}</code></pre>
<pre><code class="language-java">// application/like/LikeFacade — 애플리케이션 서비스 (조율만)
// 데이터 조회 → 도메인 서비스에 판단 위임 → 결과 저장
public void like(Long userId, Long productId) {
    ProductModel product = productService.getProduct(productId);
    Optional&lt;LikeModel&gt; existing = likeService.findByUserIdAndProductId(userId, productId);

    Optional&lt;LikeModel&gt; newLike = likeToggleService.like(existing, product, userId, productId);
    newLike.ifPresent(likeService::save);
}</code></pre>
<p><strong>도메인 서비스가 필요한 조건:</strong> 두 개 이상의 어그리게이트에 걸친 비즈니스 의사결정을 내려야 하는데, 어느 한쪽 엔티티에 넣기 어려운 경우다. <code>LikeToggleService</code>가 정확히 이 케이스다.</p>
<p><strong>범위 한정:</strong> 이 &quot;의사결정&quot; 기준은 <strong>도메인 계층과 애플리케이션 계층 사이에서만</strong> 유효하다. Controller(HTTP 변환)나 Repository(데이터 접근)에는 적용하지 않는다.</p>
<hr>
<h3 id="9-리포지토리-경계--어그리게이트-루트-단위로만-존재하는가">9. 리포지토리 경계 — &quot;어그리게이트 루트 단위로만 존재하는가?&quot;</h3>
<p><strong>키워드: 어그리게이트 루트 = 리포지토리 단위</strong></p>
<p>DDD의 원칙: <strong>리포지토리는 어그리게이트 루트 하나당 하나.</strong> 어그리게이트 내부의 엔티티는 루트를 통해서만 접근한다.</p>
<p>근데 이 원칙을 의도적으로 깬 곳이 있다.</p>
<pre><code class="language-java">// OrderService — OrderItemRepository가 별도로 존재
public class OrderService {
    private final OrderRepository orderRepository;
    private final OrderItemRepository orderItemRepository;  // ← 원칙대로라면 없어야 함
}</code></pre>
<p>DDD 정석으로는 <code>OrderItem</code>은 <code>Order</code> 어그리게이트의 내부 엔티티이므로, <code>OrderRepository</code>를 통해서만 접근해야 한다. 그러려면 JPA 연관관계(<code>@OneToMany</code>)가 필요하다. 근데 이 프로젝트는 ID 참조만 쓴다.</p>
<p>왜 JPA 연관관계 대신 ID 참조를 택했을까? 두 방식의 차이를 보면:</p>
<table>
<thead>
<tr>
<th>기준</th>
<th>ID 참조</th>
<th>JPA 연관관계 (<code>@OneToMany</code>)</th>
</tr>
</thead>
<tbody><tr>
<td>조회</td>
<td><code>findByOrderId(id)</code> — 명시적</td>
<td><code>order.getItems()</code> — 암시적 lazy loading</td>
</tr>
<tr>
<td>N+1 문제</td>
<td>없음</td>
<td>있음 (fetch join 필요)</td>
</tr>
<tr>
<td>양방향 동기화</td>
<td>불필요</td>
<td>필수 (<code>item.setOrder(this)</code> 빠뜨리면 버그)</td>
</tr>
<tr>
<td>삭제</td>
<td>명시적 delete 호출</td>
<td><code>orphanRemoval</code>이면 리스트에서 빼는 것만으로 삭제</td>
</tr>
<tr>
<td>테스트</td>
<td>ID만 넣으면 됨</td>
<td>전체 객체 그래프 구성 필요</td>
</tr>
</tbody></table>
<p>JPA 연관관계의 복잡도는 <strong>실제로 코드에서 터지는 문제들</strong>이다. N+1은 성능 이슈를, 양방향 동기화 누락은 버그를, 암시적 삭제는 데이터 유실을 만든다.</p>
<p>반대로 DDD 리포지토리 원칙을 깨면 뭐가 터질까? &quot;누군가 <code>OrderItemRepository</code>를 직접 호출할 수 있다&quot;는 위험이 생긴다. 하지만 현재는 <code>OrderService</code>가 두 Repository를 모두 들고 있고, <code>OrderItem</code> 접근은 항상 <code>OrderService</code>를 통한다. <strong>서비스 레이어가 접근을 통제하고 있으므로 실질적 위험은 낮다.</strong></p>
<p>원칙을 깨도 되는지 판단할 때 세 가지를 물었다:</p>
<ol>
<li><strong>깨면 실제로 터지는가?</strong> — JPA 연관관계는 실제 버그를 만든다 → 피한다</li>
<li><strong>다른 수단으로 보호 가능한가?</strong> — 서비스 레이어가 접근 통제 중 → 보호됨</li>
<li><strong>그 보호를 조직이 유지할 수 있는가?</strong> — 현재 규모에서 감당 가능 → 깨도 된다</li>
</ol>
<p>이 판단은 영원히 유효하진 않다. 팀이 커지면 3번 조건이 깨질 수 있고, 그때는 다른 보호 수단을 마련해야 한다.</p>
<hr>
<h2 id="아키텍처--구현물을-어떻게-배치할-것인가">아키텍처 — 구현물을 어떻게 배치할 것인가</h2>
<h3 id="10-레이어-구분--의존성-방향이-안쪽을-향하는가">10. 레이어 구분 — &quot;의존성 방향이 안쪽을 향하는가?&quot;</h3>
<p><strong>키워드: 의존성 방향</strong></p>
<p>전략적/전술적 설계가 끝난 후 코드로 옮기는 단계다. 각 레이어의 역할과 의존 방향을 정한다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/4016c78a-3e3a-4136-a03f-c7b22602d70a/image.png" alt=""></p>
<p>핵심 규칙: <strong>의존성은 항상 안쪽(domain)을 향한다.</strong></p>
<ul>
<li>Controller는 Facade/Service(application)를 알지만, application은 Controller를 모른다</li>
<li>Controller는 domain을 직접 참조하지 않는다. Dto도 application DTO(Info/Result record)를 경유한다</li>
<li>Facade는 여러 Service를 알지만, Service는 Facade를 모른다</li>
<li>Service는 Repository Interface를 알지만, JPA 구현체를 모른다</li>
</ul>
<p>Repository Interface가 <code>domain/</code> 패키지에 있는 이유가 여기에 있다. Domain Layer는 &quot;데이터를 저장하고 조회하는 능력&quot;이 필요하지만, &quot;그것이 JPA로 구현되는지 MyBatis로 구현되는지&quot;는 알 필요가 없다. Interface를 domain에 두고 구현체를 infrastructure에 두면, domain은 기술 선택에 의존하지 않는다.</p>
<hr>
<h2 id="최종-설계">최종 설계</h2>
<p>10가지 키워드를 적용한 결과물이다.</p>
<h3 id="도메인--비즈니스-의사결정">도메인 — 비즈니스 의사결정</h3>
<table>
<thead>
<tr>
<th>컴포넌트</th>
<th>담당하는 의사결정</th>
</tr>
</thead>
<tbody><tr>
<td><code>BrandName</code> (VO)</td>
<td>브랜드 이름 유효성 (비어있으면 안 됨)</td>
</tr>
<tr>
<td><code>Money</code> (VO)</td>
<td>가격 유효성 (음수 불가), 연산 (<code>add</code>, <code>multiply</code>)</td>
</tr>
<tr>
<td><code>LoginId</code> (VO)</td>
<td>로그인 ID 형식 검증 (영문+숫자 패턴)</td>
</tr>
<tr>
<td><code>MemberName</code> (VO)</td>
<td>이름 유효성, 마스킹 (<code>masked()</code>)</td>
</tr>
<tr>
<td><code>StockModel</code> (Entity)</td>
<td>재고 충분 여부 판단, 차감/증가 (<code>decrease</code>, <code>increase</code>)</td>
</tr>
<tr>
<td><code>OrderModel</code> (Entity)</td>
<td>주문 소유권 검증 (<code>validateOwner</code>), 상태 전이</td>
</tr>
<tr>
<td><code>ProductModel</code> (Entity)</td>
<td>likeCount 증감, 삭제 상태 검증</td>
</tr>
<tr>
<td><code>LikeToggleService</code> (도메인 서비스)</td>
<td>Like + Product 두 엔티티의 상태를 종합하여 좋아요 반응 결정 (신규/복구/멱등 무시)</td>
</tr>
</tbody></table>
<p>대부분의 비즈니스 의사결정은 엔티티와 VO 안에 캡슐화되어 있다. 하지만 <strong>좋아요 멱등성 판단</strong>은 <code>LikeModel</code>과 <code>ProductModel</code> 두 엔티티의 상태를 종합해야 하므로 어느 한쪽 엔티티에 넣을 수 없다. 이것이 <code>LikeToggleService</code> 도메인 서비스가 필요한 이유다.</p>
<h3 id="애플리케이션--조율-service--facade">애플리케이션 — 조율 (Service + Facade)</h3>
<p><strong>Service — 단일 도메인 CRUD 조율:</strong></p>
<table>
<thead>
<tr>
<th>컴포넌트</th>
<th>조율 내용</th>
</tr>
</thead>
<tbody><tr>
<td><code>BrandService</code></td>
<td>브랜드명 유니크 체크 + CRUD (이름 유효성은 <code>BrandName</code> VO가 판단)</td>
</tr>
<tr>
<td><code>ProductService</code></td>
<td>상품 CRUD, likeCount 증감, soft delete</td>
</tr>
<tr>
<td><code>StockService</code></td>
<td>재고 생성, 차감 (부족 여부는 <code>StockModel</code>이 판단)</td>
</tr>
<tr>
<td><code>LikeService</code></td>
<td>좋아요 저장/조회 (멱등성 판단은 <code>LikeToggleService</code> 도메인 서비스가 담당)</td>
</tr>
<tr>
<td><code>OrderService</code></td>
<td>주문/주문상품 CRUD (소유권 검증은 <code>OrderModel</code>이 판단)</td>
</tr>
<tr>
<td><code>MemberSignupService</code></td>
<td>회원가입 (ID 중복 체크 + 생성, 형식 검증은 <code>LoginId</code> VO가 판단)</td>
</tr>
<tr>
<td><code>MemberAuthService</code></td>
<td>인증 (비밀번호 검증은 엔티티에 위임)</td>
</tr>
<tr>
<td><code>MemberPasswordService</code></td>
<td>비밀번호 변경 (검증은 엔티티/VO에 위임)</td>
</tr>
</tbody></table>
<p><strong>Facade — 여러 도메인 Service를 조합:</strong></p>
<table>
<thead>
<tr>
<th>컴포넌트</th>
<th>조율 내용</th>
<th>성격</th>
</tr>
</thead>
<tbody><tr>
<td><code>BrandFacade</code></td>
<td>브랜드 삭제 → 소속 상품 연쇄 soft delete</td>
<td>규칙 (여러 도메인에 걸침)</td>
</tr>
<tr>
<td><code>ProductFacade</code></td>
<td>브랜드 존재 확인 → 상품 생성 → 재고 생성</td>
<td>절차</td>
</tr>
<tr>
<td><code>LikeFacade</code></td>
<td>데이터 조회 → <code>LikeToggleService</code>에 판단 위임 → 결과 저장</td>
<td>절차</td>
</tr>
<tr>
<td><code>OrderFacade</code></td>
<td>상품 조회 → 삭제 검증 → 재고 차감 → 주문 생성 (All or Nothing)</td>
<td>규칙 (여러 도메인에 걸침)</td>
</tr>
<tr>
<td><code>MemberFacade</code></td>
<td>회원 유스케이스 조율 (가입, 인증, 비밀번호 변경)</td>
<td>절차</td>
</tr>
</tbody></table>
<p>Service와 Facade 모두 <code>application/</code> 레이어에 있다. 차이는 <strong>의존 범위</strong>다. Service는 자기 도메인의 Repository 하나만 의존하고, Facade는 여러 Service를 조합한다. 어느 쪽이든 비즈니스 의사결정 자체는 엔티티/VO가 내리고, application 레이어는 그 결정이 실행되도록 정보를 준비하고 결과를 적용한다.</p>
<hr>
<h2 id="회고-설계엔-정답은-없지만-오답은-있다">회고: 설계엔 정답은 없지만 오답은 있다.</h2>
<p>10개의 키워드는 결국 두 가지 행위의 반복이었다: <strong>나누기</strong>와 <strong>연결하기</strong>.</p>
<p>전략적 설계(1<del>4)에서 비즈니스 영역을 나누고 그 관계를 정의한다. 전술적 설계(5</del>9)에서 코드 단위를 나누고 그 통신 방식을 정의한다. 아키텍처(10)에서 배치를 나누고 의존 방향을 정의한다.</p>
<p>어느 단계에서든 &quot;나누기만 하고 연결하기를 빼먹으면&quot; 시스템이 부서진다. 바운디드 컨텍스트를 나누고 컨텍스트 매핑을 안 하면 &quot;잘 나눈 것 같은데 결국 다 얽혀있네?&quot;가 된다. 어그리게이트를 나누고 Facade 조율을 안 하면 &quot;각각은 깔끔한데 전체 유스케이스가 안 돌아가네?&quot;가 된다.</p>
<p>이 과정을 겪으면서 의외였던 것이 세 가지 있었다.</p>
<p>첫째, <strong>책임 분리는 결국 비즈니스가 결정한다.</strong> 서브도메인은 &quot;없어도 사업이 돌아가는가?&quot;로, 어그리게이트는 &quot;단독으로 접근할 비즈니스 시나리오가 있는가?&quot;로 갈린다. 기술적 판단처럼 보이는 것도 출발점은 비즈니스였다.</p>
<p>둘째, <strong>&quot;규칙을 담는다&quot;와 &quot;의사결정을 내린다&quot;는 다르다.</strong> 처음에 <code>BrandService</code>가 &quot;중복 이름 검증&quot;이라는 규칙을 담고 있으니 도메인 서비스라고 생각했다. 하지만 리팩토링 후 깨달았다. Service가 하는 건 DB 상태를 확인하고 결과를 적용하는 <strong>조율</strong>이다. 실제 의사결정(&quot;이 이름이 유효한가?&quot;)은 <code>BrandName</code> VO가 내린다. &quot;규칙이 있으면 도메인 서비스&quot;가 아니라 <strong>&quot;비즈니스 의사결정을 직접 내리면 도메인 서비스&quot;</strong>다. 이 차이를 모르면 Service를 전부 <code>domain/</code>에 두는 실수를 하게 된다 — 실제로 내가 그랬다.</p>
<p>셋째, <strong>&quot;도메인 서비스가 없다&quot;는 의심해봐야 한다.</strong> 처음에 모든 Service를 application으로 옮기고 &quot;도메인 서비스가 하나도 없다&quot;고 결론 내렸다. 근데 곰곰이 보니 <code>LikeFacade.like()</code>의 if/else 분기가 비즈니스 의사결정이었다. 지우면 규칙이 사라지는 코드가 application 레이어에 있었던 것이다. &quot;지우면 뭐가 사라지는가?&quot;를 물었더니 숨어있던 도메인 서비스가 드러났다.</p>
<p>넷째, <strong>원칙은 깰 수 있다. 단, 조건이 있다.</strong> 깨면 실제로 코드에서 터지는 원칙은 지켜야 한다. 깨도 다른 수단으로 보호 가능하고, 그 보호를 현재 조직이 유지할 수 있다면, 깨는 것이 정답일 수 있다. DDD 리포지토리 원칙을 깬 것도 이 판단의 결과였다.</p>
<p>오늘도 느낀 바지만 역시 &quot;은탄환은 없다&quot; 하지만 시작은 &quot;어디에 뭘 둬야 하지?&quot;라는 고민의 출발점으로는 충분했다. 적어도 감으로 결정하는 것보다, <strong>키워드를 들이대고 검증할 수 있다</strong>는 점에서 설계의 질이 달라질 수 있다고 생각한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[코드를 치기 전에 33개의 질문을 던졌다]]></title>
            <link>https://velog.io/@praesentia-ykm/%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%B9%98%EA%B8%B0-%EC%A0%84%EC%97%90-33%EA%B0%9C%EC%9D%98-%EC%A7%88%EB%AC%B8%EC%9D%84-%EB%8D%98%EC%A1%8C%EB%8B%A4</link>
            <guid>https://velog.io/@praesentia-ykm/%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%B9%98%EA%B8%B0-%EC%A0%84%EC%97%90-33%EA%B0%9C%EC%9D%98-%EC%A7%88%EB%AC%B8%EC%9D%84-%EB%8D%98%EC%A1%8C%EB%8B%A4</guid>
            <pubDate>Fri, 13 Feb 2026 07:56:44 GMT</pubDate>
            <description><![CDATA[<h2 id="우리는-바로-코딩하면-어떻게-되는지-알고-있다">우리는 바로 코딩하면 어떻게 되는지 알고 있다</h2>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/12a4ce67-4cf5-481c-900a-2c82b26314f5/image.png" alt=""></p>
<p>이커머스 과제를 받았다. Brand, Product, Stock, Like, Order — 5개 도메인, 21개 API.</p>
<p>요구사항 문서를 읽으면서 떠오른 첫 번째 생각은 &quot;일단 엔티티부터 만들어야 하지 않나?&quot;였다.
하지만 회사에서 경험상, 바로 코딩부터 시작하게된 프로젝트는 결국 어떻게 흔들리게 되는지 안다.
중간에 테이블 구조가 바뀌고, API 스펙이 흔들리고, &quot;이거 왜 이렇게 했지?&quot; 하면서 이미 짠 코드를 뜯어고치게 된다.
그래서 이번에는 제대로 설계를 해보고 싶었다. 따라서. <strong>코드를 한 줄도 치지 않고, 모든 애매한 부분을 먼저 드러내기로 했다.</strong></p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/4596f66d-2a76-40e6-955d-d08724450a02/image.png" alt=""></p>
<p>Notion에 Q&amp;A 브레인스토밍 페이지를 열고, 요구사항을 읽으면서 &quot;이거 어떻게 하지?&quot;라는 생각이 드는 순간마다 질문으로 적었다. 하나씩, 하나씩...</p>
<p>33개가 나왔다.</p>
<hr>
<h2 id="qa-브레인스토밍--애매한-것을-질문으로-만드는-방법">Q&amp;A 브레인스토밍 — 애매한 것을 질문으로 만드는 방법</h2>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/08e3de0e-35f1-4dca-9a4b-7e64f330fddc/image.png" alt=""></p>
<p>내가 사용한 방식은 단순하다.</p>
<blockquote>
<p>애매한 부분을 <strong>질문</strong>으로 만들고, <strong>선택지</strong>를 나열하고, <strong>장단점</strong>을 비교한 뒤 <strong>결정</strong>한다.</p>
</blockquote>
<p>여기서 &quot;애매함을 아는 것&quot;은 도메인에 대한 지식이 전제되어야 한다. 이 과정이 참 힘들었는데, 나와 가치관이 비슷한 Devin 멘토님의 조언이 큰 울림을 주었다.</p>
<blockquote>
<p><strong>&quot;저는 단어 변태예요. 생각이 잘 안 날 때는 개념을 최대한 추상적으로 파고들어 정의해 보세요.&quot;</strong></p>
</blockquote>
<p>이 한 문장이 나에게는 유레카였다. 기능을 구현하기 전에, 우리가 다루는 데이터의 &#39;본질&#39;이 무엇인지 단어부터 정의해 보았다.</p>
<h3 id="1-판매sale란-무엇인가">1. 판매(Sale)란 무엇인가?</h3>
<ul>
<li><strong>개발적 정의:</strong> 특정 Price를 대가로 Product의 소유권을 이전하는 트랜잭션.</li>
<li><strong>비유를 통한 직관적 설명:</strong> 단순히 돈을 받는 게 끝이 아니다. 고객이 만족해야 하고, 만족하지 못하면 환불이라는 역프로세스가 발생한다. 즉, <strong>판매는 &#39;거래(Transaction)&#39;다.</strong> 양측이 가치를 교환하고 합의하는 과정인 셈이다.</li>
</ul>
<h3 id="2-주문order이란-무엇인가">2. 주문(Order)이란 무엇인가?</h3>
<ul>
<li><strong>개발적 정의:</strong> 고객의 구매 의사를 시스템에 기록하고, 재고 확보 및 결제 프로세스를 트리거하는 상태 기반의 데이터 집합.</li>
<li><strong>비유를 통한 직관적 설명:</strong> 주문은 소비자가 하는 것이고 생산자는 이를 받아야 한다. 결국 거래가 이루어지기 위한 서로의 약속 사항이다. 즉, <strong>주문은 &#39;계약(Contract)&#39;이다.</strong></li>
</ul>
<p>이 관점을 장착하니 33개의 질문이 무지성(?)으로 쏟아져 나왔다. 질문 하나가 만들어지는 과정을 살펴보자.</p>
<h3 id="예시-q4--재고를-상품에-둘까-분리할까"><strong>예시: Q4 — 재고를 상품에 둘까, 분리할까?</strong></h3>
<p>요구사항에는 &quot;상품을 등록할 때 초기 재고를 함께 입력한다&quot;고만 적혀 있었다. 재고를 Product 테이블의 컬럼 하나로 둘 수도 있고, 별도 Stock 테이블로 분리할 수도 있다. 어떻게 해야 할까?</p>
<p>이걸 질문으로 만들고, 선택지를 나열하고, 각각의 장단점을 적고, 결정하고, 감수하는 비용까지 기록했다. 이 과정은 뒤에서 자세히 다룬다.</p>
<p>이 방식의 장점은 세 가지다.</p>
<ol>
<li><strong>결정의 이유가 기록으로 남는다.</strong> 나중에 &quot;왜 이렇게 했지?&quot; 할 때 Q&amp;A 페이지만 보면 된다.</li>
<li><strong>코드 리뷰나 누군가의 &quot;왜요?&quot;라는 질문에 대한 답이 이미 준비되어 있다.</strong> &quot;Q4 참고해주세요&quot;라고 하면 끝이다.</li>
<li><strong>설계가 감이 아니라 논리가 된다.</strong> 장단점을 적다 보면 &quot;그냥 느낌&quot;이 아니라 근거 있는 결정이 된다.</li>
</ol>
<p>33개 Q&amp;A의 도메인별 분포는 이랬다.</p>
<table>
<thead>
<tr>
<th>도메인</th>
<th>질문 수</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>Brand</td>
<td>4</td>
<td>Q1: Soft Delete 연쇄, Q17: 브랜드명 중복 처리</td>
</tr>
<tr>
<td>Product + Stock</td>
<td>7</td>
<td>Q4: Stock 분리, Q6: 재고 표시 방식, Q8: likeCount 비정규화</td>
</tr>
<tr>
<td>Like</td>
<td>4</td>
<td>Q7: 멱등성, Q28: 음수 방지</td>
</tr>
<tr>
<td>Order</td>
<td>7</td>
<td>Q9: 스냅샷, Q19: All or Nothing, Q20: 삭제 상품 처리</td>
</tr>
<tr>
<td>공통</td>
<td>5</td>
<td>Q13: 인증 방식, Q15: 페이지네이션, Q32: 에러 처리</td>
</tr>
<tr>
<td><strong>합계</strong></td>
<td><strong>33</strong></td>
<td></td>
</tr>
</tbody></table>
<p>이 33개를 다 정리하고 나니, 설계 문서의 뼈대가 거의 완성되어 있었다.</p>
<hr>
<h2 id="트레이드오프--고민-세-가지">트레이드오프 — 고민 세 가지</h2>
<p>33개 중 특히 고민이 깊었던 3가지를 적어보려고 한다</p>
<h3 id="사례-1-재고를-상품에-둘-것인가-분리할-것인가">사례 1: 재고를 상품에 둘 것인가, 분리할 것인가</h3>
<p><strong>문제:</strong>
재고는 상품의 속성인가, 별도 도메인인가?</p>
<p><code>Product</code> 테이블에 <code>stock</code> 컬럼 하나 추가하면 간단하다.
그런데 가만히 생각해보니 질문이 하나 떠올랐다 — <strong>&quot;누가, 왜 이 데이터를 바꾸는가?&quot;</strong></p>
<p>상품 정보(이름, 설명, 가격)는 어드민이 수정한다.
재고는 주문 시스템이 차감한다.
<strong>변경 주체와 변경 이유가 다르다.</strong></p>
<table>
<thead>
<tr>
<th>기준</th>
<th>A: Product에 stock 필드</th>
<th>B: 별도 Stock 엔티티</th>
</tr>
</thead>
<tbody><tr>
<td>구현 난이도</td>
<td>낮음 — 컬럼 하나 추가</td>
<td>보통 — 테이블, 엔티티, 서비스 추가</td>
</tr>
<tr>
<td>변경 이유 분리</td>
<td>상품 수정과 재고 차감이 한 테이블</td>
<td>각자의 테이블에서 각자의 이유로 변경</td>
</tr>
<tr>
<td>동시성 대비</td>
<td>어려움 — 상품 수정과 재고 차감이 같은 row를 잠금</td>
<td>유리 — 재고 차감이 Stock row만 잠금</td>
</tr>
<tr>
<td>확장성</td>
<td>낮음 — 옵션별 재고 등 확장 어려움</td>
<td>높음 — Stock 엔티티 독립 확장 가능</td>
</tr>
</tbody></table>
<p><strong>결정: B. 분리한다.</strong></p>
<p>SRP 관점에서 변경 이유가 다르면 분리하는 게 맞다. 건물에 비유하면, 상품 정보는 건물의 간판이고 재고는 건물의 창고다. 간판을 바꾸는 사람과 창고를 관리하는 사람이 다르면, 같은 방에 두지 않는 게 자연스럽지 않을까 라는 생각이 들었다.</p>
<p>이전 포스팅에서도 말했다시피 은탄환은 없다. 그럼 감수해야 하는 사항은 무엇일까?</p>
<p><strong>감수하는 비용:</strong>
테이블이 하나 늘고, 상품 등록 시 Stock도 함께 생성해야 한다. 조회 시 조인이 필요하다. 하지만 이 비용은 구조적 이점에 비하면 감당할 수 있는 수준이라고 판단했다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/51f30203-a66a-4571-bd8f-b3507143cfdf/image.png" alt=""></p>
<hr>
<h3 id="사례-2-좋아요-수-집계--정확성-vs-성능">사례 2: 좋아요 수 집계 — 정확성 vs 성능</h3>
<p><strong>문제:</strong>
상품 목록을 <code>sort=likes_desc</code>로 정렬하려면 좋아요 수를 알아야 한다. 어떻게?</p>
<p>가장 정직한 방법은 매번 <code>COUNT</code> 쿼리를 날리는 것이다. 하지만 상품 목록 조회 시마다 모든 상품의 좋아요를 세는 건 부담이 크다.</p>
<table>
<thead>
<tr>
<th>기준</th>
<th>A: 매번 COUNT 쿼리</th>
<th>B: Product에 likeCount 비정규화</th>
</tr>
</thead>
<tbody><tr>
<td>정확성</td>
<td>항상 정확</td>
<td>동기화가 깨질 가능성 있음</td>
</tr>
<tr>
<td>조회 성능</td>
<td>느림 — 상품마다 서브쿼리</td>
<td>빠름 — 이미 저장된 값</td>
</tr>
<tr>
<td>정렬 편의성</td>
<td>복잡 — 서브쿼리 기반 ORDER BY</td>
<td>단순 — <code>ORDER BY like_count DESC</code></td>
</tr>
<tr>
<td>쓰기 복잡도</td>
<td>없음</td>
<td>있음 — 좋아요 추가/취소 시 같이 업데이트</td>
</tr>
</tbody></table>
<p><strong>결정: B. 비정규화한다.</strong></p>
<p>좋아요 수는 &quot;대략 맞으면 되는&quot; 데이터다. 은행 잔고처럼 1원까지 정확해야 하는 게 아니다. 상품 목록에서 &quot;이 상품이 인기 있구나&quot; 정도의 정보를 전달하면 충분하다.</p>
<p><strong>그런데 연쇄 질문이 생겼다.</strong></p>
<p>비정규화를 하면 <code>likeCount</code>가 음수가 될 수 있다. 좋아요 추가 없이 취소 요청이 들어오면? 동시 요청으로 타이밍이 꼬이면?</p>
<p>이건 Claude Code와 씨름을 벌인 결과 두 겹으로 막기로 했다.</p>
<ol>
<li><strong>애플리케이션 레벨:</strong> <code>decrementLikeCount()</code> 메서드에서 현재 값이 0이면 감소하지 않는 가드</li>
<li><strong>DB 레벨:</strong> <code>like_count</code> 컬럼에 <code>CHECK (like_count &gt;= 0)</code> 제약조건</li>
</ol>
<p>비정규화를 선택하면 이런 추가 책임이 따라온다. 역시 은탄환은 없다... 하지만 조회 성능이라는 대가로 충분히 합리적인 트레이드오프라고 판단했다.</p>
<hr>
<h3 id="사례-3-주문-시-삭제된-상품--백엔드의-역할-경계란">사례 3: 주문 시 삭제된 상품 — 백엔드의 역할 경계란?</h3>
<p><strong>문제:</strong>
만약, 고객이 장바구니에 3개 상품을 담고 주문했는데, 그 사이에 1개가 삭제됐다.
이러면 어떻게 해야 할까?</p>
<p>처음에 내가 떠올린 시나리오는 이랬다.</p>
<blockquote>
<p>&quot;삭제된 상품을 빼고 나머지 2개만 주문하시겠습니까?&quot;라는 확인을 보여주면 되지 않을까? <del>보통 제가 회사에서는 풀스택을 하다보니 이렇게 유도하는 식으로 해왔습니다 ㅋㅋ...</del></p>
</blockquote>
<p>그런데 곰곰이 생각해보니 이건 <strong>백엔드 API가 할 수 있는 일이 아니었다.</strong></p>
<p>HTTP API는 요청-응답 모델이다. 요청을 받으면 처리하고 결과를 돌려줄 뿐, 중간에 &quot;이거 어떻게 할까요?&quot;라고 사용자에게 물어볼 수 없다. 그건 프론트엔드의 역할이다.</p>
<table>
<thead>
<tr>
<th>기준</th>
<th>A: 삭제된 상품 빼고 부분 성공</th>
<th>B: 전체 실패 + 상세 에러</th>
</tr>
</thead>
<tbody><tr>
<td>사용자 경험</td>
<td>편리해 보이지만 의도치 않은 주문 위험</td>
<td>한 번 더 확인 필요하지만 안전</td>
</tr>
<tr>
<td>구현 복잡도</td>
<td>높음 — 부분 처리 로직, 금액 재계산</td>
<td>낮음 — 검증 실패 시 롤백</td>
</tr>
<tr>
<td>역할 경계</td>
<td>모호 — 백엔드가 UX 판단</td>
<td>명확 — 백엔드는 검증, 프론트는 UX</td>
</tr>
<tr>
<td>데이터 정합성</td>
<td>위험 — 고객이 의도하지 않은 조합 가능</td>
<td>안전 — 고객이 명시적으로 재주문</td>
</tr>
</tbody></table>
<p><strong>결정: B. All or Nothing.</strong></p>
<p>삭제된 상품이 포함되어 있으면 주문 전체를 실패시키고, <strong>어떤 상품이 문제인지 에러 메시지로 알려준다.</strong> 프론트엔드는 이 에러를 받아서 &quot;이 상품이 삭제되었습니다. 빼고 다시 주문하시겠습니까?&quot;라는 UI를 보여주면 된다고 생각했다.</p>
<p>이 결정을 함에 있어 대부분의 대형 이커머스 업계에서는 All or Nothing 전략을 취한다고 한다. 이 질문을 통해 얻은 교훈은 이거다 — <strong>API의 역할 경계를 명확히 하는 것 자체가 설계다.</strong> 백엔드는 &quot;이 요청이 유효한가?&quot;를 판단하고, 유효하지 않으면 왜 유효하지 않은지를 충분히 설명한다. 그 이상의 UX 판단은 프론트엔드에 맡긴다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/c953130b-4c8e-4fcf-9e87-0fec50995c07/image.png" alt=""></p>
<hr>
<h2 id="깎고-또-깎았자">깎고 또 깎았자</h2>
<p>Q&amp;A 브레인스토밍과 병행해서 설계 문서도 만들어나갔다. 그런데 이 문서들도 처음부터 완성된 게 아니었다. 피드백을 받으며 계속 다듬었다.</p>
<p><strong>시퀀스 다이어그램:</strong> 처음에는 Repository 레벨까지 그렸다. Controller → Facade → Service → Repository, 모든 호출을 빠짐없이. </p>
<p>Claude Code의 피드백은 명확했다.</p>
<blockquote>
<p>*&quot;설계 다이어그램은 who asks whom to do what이지, 구현 명세서가 아니다.&quot;* </p>
</blockquote>
<p>Facade-Service 레벨로 축소했다. Repository는 구현할 때 알아서 정하면 된다.</p>
<p><strong>클래스 다이어그램:</strong> 어디까지 얼마나 작성해야하지? 에러처리는 어디까지나 상세하게 작성해야 해?</p>
<blockquote>
<p>시퀀스 다이어그램은 구체적일 필요가 없다. 행위 관점에서 놓치면 안되는 것들을 기술하는 것이다.</p>
</blockquote>
<p>위 피드백을 받고 클래스 다이어그램이 굉장히 단순해질 수 있었다. 결국 최종으로 남긴 것은 도메인 모델의 핵심 필드와 행위 메서드(<code>hasEnough()</code>, <code>decrease()</code>, <code>subtotal()</code>)뿐이다.
<img src="https://velog.velcdn.com/images/praesentia-ykm/post/25aee221-289b-4392-8985-5cb2e37bd5ec/image.png" alt=""></p>
<p><strong>ERD:</strong> DDL과 제약조건까지 상세하게 그렸다가, 멘토링 후 FK 제약조건을 제거하는 쪽으로 축소했다. 애플리케이션 레벨에서 참조 무결성을 관리하고, DB에서는 ID 참조만 유지하는 방식이다.</p>
<p>이 과정에서 깨달은 건 이거다 — <strong>개발 전 설계 문서의 디테일 수준은 &quot;의사결정을 전달할 수 있는 최소한&quot;이면 충분하다.</strong> 그 이상은 구현 시점에 코드로 표현하면 된다.</p>
<hr>
<h2 id="회고--33개의-질문이-남긴-것">회고 — 33개의 질문이 남긴 것</h2>
<p>33개 Q&amp;A를 끝내고 코딩을 시작했을 때, 신기하게도 <strong>코딩이 무섭지 않았다.</strong></p>
<ul>
<li>테이블 구조가 확정되어 있으니 엔티티 설계가 명확했다. <code>Product</code>와 <code>Stock</code>이 왜 분리되어 있는지 고민할 필요가 없었다.</li>
<li>API 스펙이 정해져 있으니 컨트롤러 구현이 기계적이었다. 21개 엔드포인트의 요청/응답 구조가 이미 결정되어 있었다.</li>
<li>비즈니스 규칙이 문서화되어 있으니 테스트 케이스가 자동으로 나왔다. &quot;좋아요는 멱등성을 보장한다&quot; → 같은 상품에 두 번 좋아요 해도 한 번만 반영되는 테스트.</li>
</ul>
<p>물론 과했던 부분도 있다. 하지만 과하게 만들어봤기 때문에 &quot;어디까지가 적정선인지&quot;를 체감할 수 있었으니, 완전한 낭비는 아니었다고 생각한다.</p>
<p>이번에 가장 많이 한 일은 코딩이 아니라 <strong>질문하기</strong>였다. &quot;재고를 어디에 둘까?&quot;, &quot;좋아요 수를 어떻게 셀까?&quot;, &quot;삭제된 상품을 어떻게 처리할까?&quot; — 이런 질문에 답을 만들어두니, 코드는 답을 옮겨 적는 일에 가까웠다.</p>
<blockquote>
<p><strong>코드를 치기 전에 질문을 던져라. 답은 코드보다 먼저 나와야 한다.</strong></p>
</blockquote>
<p>설계를 해보니 마지막으로 드는 생각은 &quot;좋은 설계란&quot; 어디까지 확장가능하게 둘지에 대한 판단이 어려운 거 같다. 변하지 않는 부분과 변하는 부분을 잘 판단하는게 결국 개발자의 역할이 아닐까 싶다.</p>
<p>또 찾아보니 이러한 생각 회로가 익숙해지다 보면 코드를 짬과 동시에 설계를 해나가는 경지에까지 도달하는 거 같다.</p>
<p><a href="https://www.youtube.com/watch?v=HCB8jgAfG44">설계를 잘하는 방법은 설계하지 않는 것이다(제미니님)</a>
<img src="https://velog.velcdn.com/images/praesentia-ykm/post/80a49464-c547-42bc-bf1a-8d43741edd1f/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[검증 로직 3번 잘못 놓고 깨달은 한 가지]]></title>
            <link>https://velog.io/@praesentia-ykm/%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0-%EC%A2%8B%EC%9D%80-%EA%B5%AC%EC%A1%B0%EB%9E%80</link>
            <guid>https://velog.io/@praesentia-ykm/%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EA%B8%B0-%EC%A2%8B%EC%9D%80-%EA%B5%AC%EC%A1%B0%EB%9E%80</guid>
            <pubDate>Fri, 06 Feb 2026 04:43:09 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>TL;DR</strong>
테스트하기 어려운 코드는 책임이 잘못 놓인 코드다.</p>
</blockquote>
<hr>
<h2 id="개요">개요</h2>
<p>회원가입 기능을 만드는 도중 주어진 요구사항에 대하여 필요한 검증은 이랬다.</p>
<ul>
<li>로그인 ID: 영문과 숫자만 허용</li>
<li>비밀번호: 8~16자, 영문 대소문자/숫자/특수문자, 생년월일 포함 불가</li>
<li>이메일: 포맷 검증</li>
<li>이름: 빈값 불가</li>
</ul>
<p>처음에는 단순하게 생각했다. &quot;검증이니까 요청 들어오는 데서 하면 되지.&quot;</p>
<p>문제는, 똑같은 비밀번호 규칙이 <strong>회원가입</strong>에서도 필요하고 <strong>비밀번호 변경</strong>에서도 필요하다는 것이었다. 그리고 &quot;비밀번호에 생년월일이 포함되면 안 된다&quot;는 규칙은 비밀번호 필드만 보고는 판단할 수 없었다. 다른 필드를 참조해야 했다.</p>
<p>검증 로직을 어디에 둘지가 생각보다 쉽지 않았고, 결국 4군데를 모두 시도해보게 됐다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/3b26b08a-3311-43d9-a419-d4ab14227e75/image.png" alt=""></p>
<hr>
<h2 id="troubleshooting">Troubleshooting</h2>
<h3 id="시도-1-controller-계층에서--dto-어노테이션이면-되지-않을까">시도 1. Controller 계층에서 — &quot;DTO 어노테이션이면 되지 않을까&quot;</h3>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/0d6c9914-19d3-4598-8a2e-b11a34cb42c9/image.png" alt=""></p>
<p>가장 먼저 떠오른 건 Spring의 Bean Validation이었다.</p>
<pre><code class="language-java">public record SignupRequest(
    @NotBlank
    @Pattern(regexp = &quot;^[a-zA-Z0-9]+$&quot;, message = &quot;영문과 숫자만 가능합니다&quot;)
    String loginId,

    @NotBlank
    @Size(min = 8, max = 16)
    String password,

    @Email
    String email
) {}</code></pre>
<p>간단한 형식 검증은 문제없었다. 그런데 곧 막혔다.</p>
<p><strong>&quot;비밀번호에 생년월일이 포함되면 안 된다&quot;</strong> — 이 규칙은 <code>password</code>와 <code>birthDate</code>, 두 필드를 동시에 봐야 한다. DTO 어노테이션 하나로는 표현할 수 없었다. Spring의 Custom Validator를 만들 수는 있지만, 그러려면 커스텀 어노테이션 정의 클래스와 검증 로직 클래스를 별도로 만들어야 했다. 검증 규칙 하나를 추가하는데 파일이 두 개씩 늘어나는 구조였다.</p>
<p>더 큰 문제는 <strong>중복</strong>이었다.</p>
<pre><code>SignupRequest         → @Pattern + @Size + Custom
ChangePasswordRequest → @Pattern + @Size + Custom  ← 똑같은 규칙 반복</code></pre><p>비밀번호 변경 DTO에도 같은 어노테이션을 다시 붙여야 했다. 규칙이 바뀌면 두 군데를 수정해야 한다.</p>
<hr>
<h3 id="시도-2-service에서--한-곳에서-다-하면-되지">시도 2. Service에서 — &quot;한 곳에서 다 하면 되지&quot;</h3>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/86e62358-a5f5-42da-81f4-a1baa60c388e/image.png" alt=""></p>
<p>DTO에서 안 되니, Service 레이어로 옮겼다.</p>
<pre><code class="language-java">public MemberModel signup(String loginId, String rawPassword,
                          String name, LocalDate birthDate, String email) {
    if (!loginId.matches(&quot;^[a-zA-Z0-9]+$&quot;)) {
        throw new CoreException(ErrorType.INVALID_LOGIN_ID);
    }
    if (rawPassword.length() &lt; 8 || rawPassword.length() &gt; 16) {
        throw new CoreException(ErrorType.INVALID_PASSWORD);
    }
    if (rawPassword.contains(birthDate.format(DateTimeFormatter.BASIC_ISO_DATE))) {
        throw new CoreException(ErrorType.PASSWORD_CONTAINS_BIRTH_DATE);
    }
    // ... 이메일, 이름 검증도 여기에
    // ... 중복 체크, 암호화, 저장
}</code></pre>
<p>필드 간 교차 검증(비밀번호 ↔ 생년월일)도 자연스럽게 됐다. 한 곳에서 전부 볼 수 있어서 처음엔 괜찮다고 느꼈다.</p>
<p><strong>그런데 테스트를 작성하면서 불편해졌다.</strong></p>
<p>&quot;비밀번호가 8자 미만이면 실패한다&quot;를 테스트하고 싶었을 뿐인데, Service를 테스트하려면 의존성을 전부 준비해야 했다.</p>
<pre><code class="language-java">@Mock MemberRepository memberRepository;
@Mock PasswordEncoder passwordEncoder;

@Test
void throwsOnShortPassword() {
    // 비밀번호 길이와 전혀 무관한 Mock 설정을 먼저 해야 한다
    // ...
}</code></pre>
<p>Repository Mock, PasswordEncoder Mock — 비밀번호 길이 검증과는 아무 관계 없는 것들이다. <strong>테스트가 검증 대상보다 Mock 설정에 더 많은 코드를 쓰고 있었다.</strong></p>
<p>그리고 중복 문제도 여전했다. <code>MemberSignupService</code>와 <code>MemberPasswordService</code> 양쪽에 비밀번호 규칙 if문이 복사됐다.</p>
<!-- TODO: 실제로 Service에 if문을 나열해본 경험이 있다면, 그때 느낀 점을 구체적으로 추가하세요 -->

<hr>
<h3 id="시도-3-entity에서--엔티티가-스스로를-보호하면">시도 3. Entity에서 — &quot;엔티티가 스스로를 보호하면?&quot;</h3>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/09796891-f3d8-4d13-a4fb-666b4d24a0c2/image.png" alt=""></p>
<p>그러면 도메인 엔티티에서 검증하는 건 어떨까. 현재 프로젝트의 <code>BaseEntity</code>에는 <code>guard()</code> 메서드가 있어서, 엔티티 생성/수정 시 자동으로 호출된다.</p>
<pre><code class="language-java">@MappedSuperclass
@Getter
public abstract class BaseEntity {
   /**
     * 엔티티의 유효성을 검증한다.
     * 이 메소드는 PrePersist 및 PreUpdate 시점에 호출된다.
     */
    protected void guard() {}

}

@Entity
public class MemberModel extends BaseEntity {
    @Override
    protected void guard() {
        if (loginId == null || !loginId.matches(&quot;^[a-zA-Z0-9]+$&quot;)) {
            throw new CoreException(ErrorType.INVALID_LOGIN_ID);
        }
        // ...
    }
}</code></pre>
<p>&quot;어떤 경로로 만들어지든 검증을 통과해야 한다&quot;는 점은 매력적이었다. 하지만 두 가지 벽에 부딪혔다.</p>
<p><strong>첫째, 비밀번호는 암호화된 상태로 저장된다.</strong></p>
<p>엔티티가 들고 있는 건 <code>encodedPassword</code>다. 원본 비밀번호의 규칙(8~16자, 생년월일 포함 불가)을 엔티티가 검증할 수 없다. 이미 BCrypt로 변환된 후니까.</p>
<p><strong>둘째, 하나만 테스트하고 싶은데 전부 채워야 한다.</strong></p>
<pre><code class="language-java">// &quot;이메일 형식&quot;만 테스트하고 싶은데...
new MemberModel(
    new LoginId(&quot;test&quot;),      // 이것도 채워야 하고
    &quot;encodedPassword&quot;,        // 이것도
    new MemberName(&quot;테스트&quot;),   // 이것도
    LocalDate.now(),          // 이것도
    new Email(&quot;invalid&quot;)      // ← 이것만 검증하고 싶다
);</code></pre>
<hr>
<h3 id="최종-선택-value-object--생성이-곧-검증">최종 선택. Value Object — &quot;생성이 곧 검증&quot;</h3>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/02ed7fd1-da56-438c-a963-82a84680dac6/image.png" alt=""></p>
<p>결국 각 값을 별도의 객체로 분리하고, <strong>생성자에서 검증</strong>하는 방식으로 갔다.</p>
<pre><code class="language-java">public class Password {
    private final String value;

    public Password(String value) {
        if (value == null || value.isBlank()) {
            throw new CoreException(ErrorType.INVALID_PASSWORD);
        }
        if (value.length() &lt; 8 || value.length() &gt; 16) {
            throw new CoreException(ErrorType.INVALID_PASSWORD);
        }
        if (!ALLOWED_PATTERN.matcher(value).matches()) {
            throw new CoreException(ErrorType.INVALID_PASSWORD);
        }
        this.value = value;
    }

    public void validateAgainst(LocalDate birthDate) {
        // 생년월일 포함 여부 검증 (다른 값을 참조하는 규칙)
    }
}</code></pre>
<p>Service는 검증 없이 <strong>흐름만 담당</strong>하게 됐다.</p>
<pre><code class="language-java">public MemberModel signup(String loginId, String rawPassword, ...) {
    LoginId loginIdVo = new LoginId(loginId);                 // 생성 = 검증
    Password password = Password.of(rawPassword, birthDate);  // 생성 = 검증
    MemberName nameVo = new MemberName(name);                 // 생성 = 검증

    memberRepository.findByLoginId(loginId).ifPresent(m -&gt; {
        throw new CoreException(ErrorType.DUPLICATE_LOGIN_ID);
    });

    String encodedPassword = passwordEncoder.encode(rawPassword);
    return memberRepository.save(new MemberModel(...));
}</code></pre>
<h4 id="뭐가-달라졌는가">뭐가 달라졌는가</h4>
<p><strong>1. 테스트에서 Mock이 사라졌다</strong></p>
<pre><code class="language-java">// Spring 컨텍스트 없음. Mock 없음. 순수 자바.
@Test
void rejectsPasswordsOutsideLengthRange() {
    assertThrows(CoreException.class, () -&gt; new Password(&quot;Abc123!&quot;));
}</code></pre>
<p><code>new Password(&quot;short&quot;)</code>가 터지면 끝이다. Repository도, PasswordEncoder도 필요 없다. 이전에 Service에서 테스트할 때 느꼈던 <strong>&quot;왜 비밀번호 길이를 검증하는데 Mock이 필요하지?&quot;</strong> 라는 불편함이 완전히 사라졌다.</p>
<p>실제로 <code>PasswordTest</code>는 Spring 없이 13개의 케이스를 검증한다. 실행 시간도 밀리초 단위다.</p>
<p><strong>2. 검증 중복이 사라졌다</strong></p>
<p>회원가입과 비밀번호 변경, 양쪽에서 동일한 규칙이 필요했다. VO로 분리하니 <code>new Password(rawPassword)</code> 한 줄이면 된다.</p>
<pre><code class="language-java">// MemberSignupService — 회원가입 시
Password password = Password.of(rawPassword, birthDate);

// MemberPasswordService — 비밀번호 변경 시
Password newPassword = new Password(newRawPassword);
newPassword.validateAgainst(member.birthDate());</code></pre>
<p>규칙이 바뀌면 <code>Password</code> 클래스 하나만 수정하면 된다.</p>
<p><strong>3. &quot;유효하지 않은 값&quot;이 존재할 수 없다</strong></p>
<p><code>LoginId</code> 객체가 존재한다는 것 자체가 이미 영문+숫자 검증을 통과했다는 의미다. Service나 Controller에서 다시 확인할 필요가 없다. 타입 시스템이 검증을 보장한다.</p>
<hr>
<h2 id="생각해보기">생각해보기</h2>
<h3 id="트레이드오프--은탄환은-없다">트레이드오프 — 은탄환은 없다</h3>
<table>
<thead>
<tr>
<th>위치</th>
<th align="center">테스트 용이성</th>
<th align="center">재사용성</th>
<th>한계</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Controller (DTO)</strong></td>
<td align="center">O</td>
<td align="center">X (DTO마다 반복)</td>
<td>필드 간 교차 검증이 어려움</td>
</tr>
<tr>
<td><strong>Service</strong></td>
<td align="center">△ (Mock 필요)</td>
<td align="center">X (서비스마다 중복)</td>
<td>메서드가 비대해짐</td>
</tr>
<tr>
<td><strong>Entity</strong></td>
<td align="center">△ (모든 필드 필요)</td>
<td align="center">O</td>
<td>암호화 전 원본값 검증 불가</td>
</tr>
<tr>
<td><strong>Value Object</strong></td>
<td align="center"><strong>O (순수 자바)</strong></td>
<td align="center"><strong>O (객체 재사용)</strong></td>
<td>클래스 수가 늘어남</td>
</tr>
</tbody></table>
<p>VO가 정답이라고 말하고 싶은 건 아니다. 실제로 클래스 수는 늘어났다. <code>LoginId</code>, <code>Password</code>, <code>MemberName</code>, <code>Email</code> — 단순 String이었던 것들이 전부 클래스가 됐기 때문!</p>
<h3 id="모든-값을-vo로-만들어야-할까">모든 값을 VO로 만들어야 할까</h3>
<p>그건 아니었다. 실제로 <code>Password</code>는 <code>@Embeddable</code>이 아니다.</p>
<p><img src="https://velog.velcdn.com/images/praesentia-ykm/post/1b3d7247-237d-4a61-84df-12d6d8141e8b/image.png" alt=""></p>
<p>DB에 저장되는 건 BCrypt로 암호화된 <code>String</code>이고, <code>Password</code> 객체는 <strong>입력값을 검증하는 순간에만 존재하고 사라진다</strong>. 저장되는 값과 검증만 하는 값은 역할이 다르다는 걸 이때 알았다.</p>
<h3 id="tdd가-설계를-바꾼다는-것">TDD가 설계를 바꾼다는 것</h3>
<p>처음에는 검증이 &quot;어디에 있든 동작만 하면 되지&quot;라고 생각했다. 실제로 Service에 if문을 쭉 나열해도 기능은 돌아간다.</p>
<p>그런데 테스트를 작성하면서 생각이 바뀌었다. 테스트하기 어려운 구조는 대체로 <strong>책임이 잘못 배치된 구조</strong>였다. &quot;비밀번호 길이 검증을 하는데 왜 DB Mock이 필요하지?&quot; 이 질문에 답하다 보니 자연스럽게 검증이 VO로 옮겨갔다.</p>
<p>TDD가 &quot;테스트를 먼저 작성하는 것&quot;이라는 건 알고 있었다. 그런데 그게 <strong>설계를 바꾸는 힘이 있다</strong>는 건 이번에 처음 체감했다. 테스트가 쉬워지는 방향으로 코드를 옮기다 보니, 결과적으로 더 나은 구조가 됐다.</p>
<p>다만 이게 항상 맞는 건지는 아직 모르겠다. 규모가 커지면 VO가 너무 많아질 수도 있고, 단순한 필드까지 전부 VO로 만드는 건 과한 것 같기도 하다. 이 부분은 더 경험이 쌓이면 판단이 바뀔 수도 있을 것 같다.</p>
<p>항상 불변인 진리는 “나는 무엇을 테스트할 것인가?”를 놓치지 않는 것인 거 같다. 그러다보면 오버엔지니어링을 하지 않고 트레이드오프에 대하여 판단하는 나만의 기준이 생기는 것 같다.</p>
<h3 id="test-하기-좋은-구조란">TEST 하기 좋은 구조란?</h3>
<p>테스트하기 좋은 구조라는 말은 너무 추상적인 거 같아 &quot;테스트하기 어려운 코드&quot; 에 대하여 집중해 보았다.</p>
<ul>
<li>SOLID하지 않은 비지니스 코드 구조를 갖는 것(복잡도 증가)</li>
<li>현재 테스트하고자 하는 바에 대하여 방해 요인이 많은 구조(제어할 수 없는 값, 느린 테스트 환경, 과도한 의존성)<br>



</li>
</ul>
<h3 id="마무리하며">마무리하며...</h3>
<blockquote>
<p>요즘은 AI Agent를 통하여 테스트 커버리지 확보에 대한 능률이 크게 증가한 시대라고 생각한다. 진정한 트레이드 오프에 대한 고민이 필요한 시대 아닐까? <del>(Claude Code만세</del>)~~</p>
</blockquote>
<hr>
<h2 id="references">References</h2>
<h3 id="value-object--validation-설계">Value Object &amp; Validation 설계</h3>
<ul>
<li><a href="https://www.martinfowler.com/bliki/ValueObject.html">Value Object - Martin Fowler</a> — VO의 정의와 원시값을 객체로 대체해야 하는 이유. &quot;검증의 자연스러운 초점이 된다&quot;는 표현이 와닿았다.</li>
<li><a href="https://enterprisecraftsmanship.com/posts/always-valid-domain-model/">Always-Valid Domain Model - Enterprise Craftsmanship</a> — &quot;유효하지 않은 도메인 객체는 존재할 수 없어야 한다&quot;는 원칙. VO 생성자에서 검증하는 이유의 이론적 근거를 제시해 준 듯 하다.</li>
<li><a href="https://enterprisecraftsmanship.com/posts/validation-and-ddd/">Validation and DDD - Enterprise Craftsmanship</a> — DDD에서 검증을 어떤 레이어에 둘지에 대한 정리가 잘 되어있다.</li>
<li><a href="https://martinfowler.com/bliki/ContextualValidation.html">Contextual Validation - Martin Fowler</a> — 검증은 맥락에 따라 달라진다는 관점. &quot;모든 값을 VO로 만들어야 할까?&quot;에 대한 답이 여기 있다.</li>
</ul>
<h3 id="spring-계층별-validation">Spring 계층별 Validation</h3>
<ul>
<li><a href="https://www.baeldung.com/spring-service-layer-validation">Spring Validation in the Service Layer - Baeldung</a> — Service 레이어에서 Bean Validation을 사용하는 방법과 한계</li>
<li><a href="https://www.baeldung.com/spring-boot-bean-validation">Validation in Spring Boot - Baeldung</a> — Controller 레이어의 @Valid 기반 검증 가이드</li>
<li><a href="https://medium.com/@serhatalftkn/domain-driven-design-a-comprehensive-guide-to-validation-across-layers-8955d6854e7d">DDD: A Comprehensive Guide to Validation Across Layers</a> — Controller/Service/Domain 각 계층의 검증 책임을 비교한 글</li>
</ul>
<h3 id="tdd와-설계">TDD와 설계</h3>
<ul>
<li><a href="https://en.wikipedia.org/wiki/Test-driven_development">Test-Driven Development - Wikipedia</a> — &quot;TDD는 정확성 검증을 넘어 프로그램의 설계를 주도할 수 있다&quot;</li>
<li><a href="https://www.inflearn.com/course/practical-testing-%EC%8B%A4%EC%9A%A9%EC%A0%81%EC%9D%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B0%80%EC%9D%B4%EB%93%9C?cid=329295">https://www.inflearn.com/course/practical-testing-%EC%8B%A4%EC%9A%A9%EC%A0%81%EC%9D%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B0%80%EC%9D%B4%EB%93%9C?cid=329295</a> - 실용적인 테스트 가이드: 박우빈 강사님</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>