<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>nak-honest.log</title>
        <link>https://velog.io/</link>
        <description>주로 티스토리를 사용합니다. https://nakhonest.tistory.com/</description>
        <lastBuildDate>Mon, 04 Aug 2025 17:11:26 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>nak-honest.log</title>
            <url>https://velog.velcdn.com/images/nak-honest/profile/8c1a2ae4-9332-417a-8c54-0a8114535303/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. nak-honest.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/nak-honest" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[멀티 서비스 환경 + 비동기 로그 추적 - MDC]]></title>
            <link>https://velog.io/@nak-honest/%EB%A9%80%ED%8B%B0-%EC%84%9C%EB%B9%84%EC%8A%A4-%ED%99%98%EA%B2%BD-%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%A1%9C%EA%B7%B8-%EC%B6%94%EC%A0%81-MDC</link>
            <guid>https://velog.io/@nak-honest/%EB%A9%80%ED%8B%B0-%EC%84%9C%EB%B9%84%EC%8A%A4-%ED%99%98%EA%B2%BD-%EB%B9%84%EB%8F%99%EA%B8%B0-%EB%A1%9C%EA%B7%B8-%EC%B6%94%EC%A0%81-MDC</guid>
            <pubDate>Mon, 04 Aug 2025 17:11:26 GMT</pubDate>
            <description><![CDATA[<h1 id="개요">[개요]</h1>
<blockquote>
<p><strong>본 프로젝트는 Spring Boot와 MySQL을 활용한 모의 면접 서비스입니다.</strong></p>
</blockquote>
<p>이 글은 사용자 요청에 대한 로그를 추적하는 과정에서 스레드 풀 재사용으로 인한 로그 추적 문제를 MDC를 통해 해결한 내용에 대해 설명합니다. ThreadLocal 이라는 MDC 특성상 비동기 스레드를 사용했을 때 MDC가 전파되지 않는 문제와 멀티 서비스 환경에서 다른 서버로 요청을 보낼 때 MDC 값이 사라지는 문제에 대한 해결에 대해서도 다룹니다.</p>
<h1 id="요약">[요약]</h1>
<p>톰캣 스레드 풀 재사용과 서버 다중화 환경에서 요청별 로그 추적이 가능하도록 MDC를 적용했습니다.</p>
<p>TaskDecorator를 통해 MDC 컨텍스트를 비동기 스레드로 전파하고, Nginx에서 전달받은 고유한 X-Request-ID 헤더를 다른 서버 요청 시에도 함께 전달하여 멀티 서비스 환경에서도 MDC 기반 로그 추적을 가능하게 했습니다.</p>
<h1 id="문제-상황-1--스레드-이름-중복으로-인한-로그-추적-문제">[문제 상황 1 : 스레드 이름 중복으로 인한 로그 추적 문제]</h1>
<p>톰캣은 스레드 풀을 재사용하기 때문에 스레드 이름만으로 로그를 추적하면 서로 다른 요청의 로그가 뒤섞이게 됩니다.</p>
<p>타임스탬프를 통해 어느 정도 구분할 수 있지만, 동시에 처리되는 여러 요청에서는 완벽한 구분이 불가능합니다. 특히 프로덕션 환경에서는 서버가 다중화되어 있어 스레드 이름과 타임스탬프만으로는 요청별 구분이 더욱 어려워집니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/4fbea26c-9829-4b46-97cf-99cc31a54090/image.png" alt=""></p>
<p>위 그림에서 볼 수 있듯이 서로 다른 요청임에도 불구하고 동일한 스레드 이름을 사용하는 것을 확인할 수 있습니다.</p>
<p>이렇게 로그가 뒤섞이면 장애 발생 시 원인을 추적하고 문제를 진단하는 데 상당한 어려움이 발생합니다.</p>
<h2 id="✅-해결-방안-1--mdc-적용">✅ [해결 방안 1 : MDC 적용]</h2>
<pre><code class="language-bash">http {
    # ...
    server {
        listen 80;
        # ...

        location / {
            proxy_set_header X-Request-ID $request_id;
            # ...
        }
    }
}</code></pre>
<pre><code class="language-java">@Slf4j
@Component
public class LoggingFilter extends OncePerRequestFilter {

    private static final List&lt;String&gt; WHITE_LIST = ...;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String requestId = request.getHeader(&quot;X-Request-ID&quot;);
        if (requestId != null) {
            MDC.put(&quot;requestId&quot;, requestId);
        }

        try {
            filterChain.doFilter(request, response);
        } finally {
            log.info(&quot;...&quot;);
            MDC.clear();
        }
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        // ...
    }
}</code></pre>
<pre><code class="language-xml">&lt;pattern&gt;
[%X{requestId:-noRequest}] %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
&lt;/pattern&gt;</code></pre>
<p>먼저 nginx.conf를 위와 같이 설정하면 각 요청마다 고유한 ID 값을 X-Request-ID 헤더에 담아 서버로 전달합니다.</p>
<p>이후 LoggingFilter에서 MDC에 requestId를 설정하고, logback 패턴에 %X{requestId:-noRequest}를 추가하면 각 요청마다 고유한 requestId가 함께 로깅됩니다.</p>
<p>finally 블록에서 MDC.clear()를 호출하는 이유는 톰캣 스레드 풀 재사용으로 인한 MDC 오염을 방지하기 위함입니다. MDC는 ThreadLocal을 사용하므로 스레드가 재사용될 때 이전 요청의 MDC 값이 남아있을 수 있습니다. 예외 발생 여부와 관계없이 반드시 정리해야 하므로 finally 블록에서 처리합니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/4789c130-40ab-4c0c-b067-80dadd19ffa1/image.png" alt=""></p>
<p>MDC를 통해 다중 서버 환경에서도 요청별로 로그를 명확하게 구분할 수 있어 추적성이 크게 향상됩니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/7a48361d-ffa4-4e85-8359-dd4f53aca623/image.png" alt=""></p>
<p>위와같이 하나의 요청에서 발생하는 모든 로그를 MDC에 저장된 고유 값으로 쉽게 추적할 수 있게 됩니다.</p>
<h1 id="문제-상황-2--비동기-스레드에서-mdc-값-누락">[문제 상황 2 : 비동기 스레드에서 MDC 값 누락]</h1>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/06ed5084-a788-4a62-97fd-9061496724ad/image.png" alt=""></p>
<p>@Async를 사용하여 비동기 스레드에서 로그를 남기는 경우, 앞서 설정한 고유 requestId 대신 &quot;noRequest&quot;가 로깅되는 문제가 발생합니다.</p>
<p>이는 MDC가 ThreadLocal을 사용하여 컨텍스트를 관리하기 때문입니다.</p>
<p>톰캣 스레드에서 설정한 MDC 컨텍스트는 해당 스레드에서만 유효하므로, 새로운 비동기 스레드가 생성되면 이 스레드는 기존 MDC 컨텍스트에 접근할 수 없습니다. 따라서 비동기 스레드에서는 MDC 값이 누락되어 추적이 불가능해집니다.</p>
<h2 id="✅-해결-방안-2--taskdecorator-적용">✅ [해결 방안 2 : TaskDecorator 적용]</h2>
<p>비동기 스레드에도 MDC 컨텍스트를 전달해야 합니다.</p>
<p>비동기 스레드 호출 시마다 파라미터로 MDC를 전달할 수도 있지만, 코드 중복과 누락 가능성을 고려하여 TaskDecorator를 통해 자동으로 MDC 값을 전파하도록 구현했습니다.</p>
<pre><code class="language-java">// AsyncConfig.java
public ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setTaskDecorator(mdcDecorator);
    // ...
    return executor;
}

// MdcDecorator.java
@Component
public class MdcDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        Map&lt;String, String&gt; contextMap = MDC.getCopyOfContextMap();

        return () -&gt; {
            if (contextMap != null) {
                MDC.setContextMap(contextMap);
            }
            try {
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}</code></pre>
<p>TaskDecorator는 비동기 작업 실행 전에 현재 스레드의 MDC 컨텍스트를 복사하고, 비동기 스레드에서 이를 설정한 후 작업을 실행합니다. 작업 완료 후에는 finally 블록에서 MDC를 정리하여 스레드 풀 재사용 시 오염을 방지합니다.</p>
<p>위와 같이 설정하면 비동기 스레드에서도 동일한 requestId 값이 로깅되어 전체 요청 흐름을 추적할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/2b052bec-e03f-4ece-9de8-9cd27cc4ae95/image.png" alt=""></p>
<p>실제로 부하 테스트를 할때 위의 로깅을 통해 성능을 분석하였는데, 이 때 MDC가 추적에 많은 도움이 되었습니다. </p>
<h1 id="문제-상황-3--멀티-서비스-환경에서-mdc-값-누락">[문제 상황 3 : 멀티 서비스 환경에서 MDC 값 누락]</h1>
<p>인터뷰에 좋아요나 조회수가 특정 값에 달성하면 사용자에게 알림을 주는 기능이 있습니다.</p>
<p>알림 서버는 인터뷰 기능과 분리되어 독립적인 서버로 운영되고 있었습니다.</p>
<p>이때 인터뷰 서버에서 알림 서버로 요청을 보낼 때 별도 조치 없이는 MDC 값이 누락되는 문제가 발생합니다.</p>
<p>알림 서버는 인터뷰 서버의 MDC 컨텍스트를 알 수 없으므로, 서버 간 요청에서는 requestId 추적이 끊어지게 됩니다. 이로 인해 하나의 사용자 요청이 여러 서버에 걸쳐 처리될 때 전체 흐름을 추적하기 어려워집니다.</p>
<h2 id="✅-해결-방안-3--clienthttprequestinterceptor로-x-request-id-헤더-설정">✅ [해결 방안 3 : ClientHttpRequestInterceptor로 X-Request-ID 헤더 설정]</h2>
<p>RestClient를 통해 알림 서버로 요청 시 ClientHttpRequestInterceptor를 활용하여 현재 스레드의 requestId 값을 X-Request-ID 헤더에 자동으로 설정하도록 구현했습니다.</p>
<pre><code class="language-java">@Component
public class MdcInterceptor implements ClientHttpRequestInterceptor {

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        String requestId = MDC.get(&quot;requestId&quot;);
        if (requestId != null) {
            request.getHeaders().add(&quot;X-Request-ID&quot;, requestId);
        }
        return execution.execute(request, body);
    }
}

// ...

RestClient.Builder notificationClientBuilder = 
        builder.requestInterceptor(mdcInterceptor)
                ...;</code></pre>
<p>MdcInterceptor는 모든 외부 서버 요청에서 현재 MDC의 requestId를 자동으로 헤더에 추가합니다. 알림 서버는 이 헤더를 받아 자신의 MDC에 설정하므로, 서버 간 경계를 넘나드는 요청에서도 동일한 requestId로 로그를 추적할 수 있습니다.</p>
<p>이를 통해 여러 서버에 걸쳐 분산된 로그를 하나의 requestId 값으로 통합 추적할 수 있어, 복잡한 멀티 서비스 환경에서도 완전한 요청 흐름 파악이 가능해집니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/c005d4b5-aa6a-4eb0-97d9-64ecf72306b0/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[LLM 호출 - 트랜잭션 분리 및 비동기 + 폴링으로 히카리 풀 및 톰캣 스레드 풀 고갈 문제 해결 (+ 블로킹 vs 논블로킹 성능 비교 테스트)]]></title>
            <link>https://velog.io/@nak-honest/LLM-%ED%98%B8%EC%B6%9C-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-%EB%B0%8F-%EB%B9%84%EB%8F%99%EA%B8%B0-%ED%8F%B4%EB%A7%81%EC%9C%BC%EB%A1%9C-%ED%9E%88%EC%B9%B4%EB%A6%AC-%ED%92%80-%EB%B0%8F-%ED%86%B0%EC%BA%A3-%EC%8A%A4%EB%A0%88%EB%93%9C-%ED%92%80-%EA%B3%A0%EA%B0%88-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EB%B8%94%EB%A1%9C%ED%82%B9-vs-%EB%85%BC%EB%B8%94%EB%A1%9C%ED%82%B9-%EC%84%B1%EB%8A%A5-%EB%B9%84%EA%B5%90-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@nak-honest/LLM-%ED%98%B8%EC%B6%9C-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-%EB%B0%8F-%EB%B9%84%EB%8F%99%EA%B8%B0-%ED%8F%B4%EB%A7%81%EC%9C%BC%EB%A1%9C-%ED%9E%88%EC%B9%B4%EB%A6%AC-%ED%92%80-%EB%B0%8F-%ED%86%B0%EC%BA%A3-%EC%8A%A4%EB%A0%88%EB%93%9C-%ED%92%80-%EA%B3%A0%EA%B0%88-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EB%B8%94%EB%A1%9C%ED%82%B9-vs-%EB%85%BC%EB%B8%94%EB%A1%9C%ED%82%B9-%EC%84%B1%EB%8A%A5-%EB%B9%84%EA%B5%90-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Mon, 04 Aug 2025 08:17:43 GMT</pubDate>
            <description><![CDATA[<h1 id="개요">[개요]</h1>
<blockquote>
<p><strong>본 프로젝트는 Spring Boot와 MySQL을 활용한 모의 면접 서비스입니다.</strong></p>
</blockquote>
<p>이 글은 모의 면접 진행 과정에서 LLM 호출로 인해 히카리 커넥션 풀과 톰캣 스레드 풀이 고갈되어 다른 API 응답이 수십 초 지연되는 문제를 해결한 과정을 다룹니다. 또한 트랜잭션 분리 과정에서 발생한 토큰 정합성 문제를 분산 락으로 해결한 방법에 대해서도 다룹니다. </p>
<p>마지막으로 비동기 방식에서 LLM 호출 시 블로킹 방식과 논블로킹 방식의 성능을 부하 테스트를 통해 비교 분석한 결과를 제시합니다.</p>
<h1 id="요약">[요약]</h1>
<p>LLM 호출로 인한 히카리 커넥션 풀 고갈 문제는 트랜잭션 분리를 통해 해결했으나, 이로 인해 발생한 토큰 정합성 문제를 Redis 분산 락으로 해결했습니다. 톰캣 스레드 풀 고갈 문제는 비동기 처리와 폴링 방식을 도입하여 해결했습니다.</p>
<h1 id="문제-상황-1--히카리-커넥션-풀-고갈">[문제 상황 1 : 히카리 커넥션 풀 고갈]</h1>
<p>면접을 진행할 때 사용자의 답변을 받아, LLM으로부터 다음 꼬리 질문을 받습니다.</p>
<p>기존에는 트랜잭션 내부에서 LLM 호출이 이루어졌습니다. LLM 호출은 평균 수초에서 길게는 10초 이상 소요되는 특성상, 이 시간 동안 히카리 커넥션 풀을 지속적으로 점유하게 됩니다.</p>
<pre><code class="language-java">@Transactional
public Optional&lt;InterviewProceedResponse&gt; proceedInterview(...) {
    // ...
    decreaseTokenCount(memberId);
    LLMResponse llmResponse = bedrockClient.requestToBedrock(questionAndAnswers);
    // ...
}</code></pre>
<p>히카리 풀 크기는 성능 테스트를 통해 조정해봐도 큰 차이가 없어 기본값인 10개로 설정되어 있었습니다.</p>
<p>이러한 상황에서 동시에 여러 LLM 호출이 발생하면 모든 커넥션을 점유하게 되어, 다른 API의 응답 시간이 수 초 지연되거나 타임아웃으로 인해 실패하는 문제가 발생했습니다.</p>
<p>실제 테스트 결과, 다음과 같이 타임아웃 오류가 발생하는 것을 확인할 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/2e3cf65d-23d8-4f8a-b5b0-5c3d5308c600/image.png" alt=""></p>
<h2 id="✅-해결-방안-1--트랜잭션-분리">✅ [해결 방안 1 : 트랜잭션 분리]</h2>
<p>LLM 호출이 히카리 풀을 점유하면 해당 API뿐만 아니라 DB에 접근하는 모든 API가 지연되는 문제가 발생합니다.
이를 해결하기 위해 먼저 LLM 호출 부분을 트랜잭션 밖으로 분리하였습니다.</p>
<p>다음은 간략화한 코드입니다.</p>
<pre><code class="language-java">// InterviewFacadeService.java
public Optional&lt;InterviewProceedResponse&gt; proceedInterview(...) {
        memberService.decreaseTokenCount(memberId);
    LlmResponse llmResponse = bedrockClient.requestToBedrock(questionAndAnswers);
    interviewService.saveFeedbackAndNextQeustion(...);
}

// MemberService.java
@Transactional
public void decreaseTokenCount(Long memberId) {
    // ...
}

// InterviewService.java
@Transactional
interviewProceedService.saveFeedbackAndNextQeustion(...) {
        // ...
}</code></pre>
<p>파사드 패턴을 통해 LLM 호출 이전과 이후로 트랜잭션을 분리하여 히카리 풀 점유 문제를 해결하였습니다.</p>
<p>하지만 새로운 문제가 발생했습니다. 이를 이해하기 위해서는 먼저 서비스의 배경에 대한 설명이 필요합니다.</p>
<h3 id="서비스-배경-설명">[서비스 배경 설명]</h3>
<p>LLM 호출 비용이 높기 때문에 토큰 방식을 도입하였습니다.</p>
<p>사용자는 각자 토큰을 보유하고 있으며, 인터뷰 진행 시 토큰을 소모하는 구조입니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/dca83fd8-7ae8-443a-9f5c-86d47a262422/image.png" alt=""></p>
<h1 id="문제-상황-1-2--토큰-개수-정합성-불일치">[문제 상황 1-2 : 토큰 개수 정합성 불일치]</h1>
<p>사용자가 동시에 다수의 요청을 보내더라도 중복 LLM 호출을 방지하기 위해, LLM 호출 전에 사용자의 토큰을 먼저 차감해야 했습니다.</p>
<p>예를 들어, 토큰이 1개만 남은 사용자가 동시에 100번 요청을 보낼 경우, 토큰을 사전에 차감하지 않으면 LLM 호출이 100번 모두 발생하게 됩니다.</p>
<p>따라서 인터뷰 진행 프로세스를 다음 순서로 구현하였습니다.</p>
<p><strong>사용자 토큰 감소 → LLM 호출 → LLM 응답 저장</strong></p>
<p>그런데 트랜잭션 분리로 인해 토큰 개수 정합성 문제가 발생했습니다.</p>
<p>사용자 토큰 감소는 성공했으나 LLM 호출이나 LLM 응답 저장에 실패하는 경우, 토큰 감소가 별도 트랜잭션으로 처리되어 롤백되지 않는 문제였습니다.</p>
<p>토큰 개수는 인터뷰 횟수와 직결되는 중요한 데이터이며, 향후 결제를 통한 토큰 구매 기능을 계획하고 있었기 때문에 인터뷰 진행 실패 시 토큰이 부당하게 차감되어서는 안 되었습니다.</p>
<h2 id="❌-해결-방안-1--보상-트랜잭션-적용">❌ [해결 방안 1 : 보상 트랜잭션 적용]</h2>
<p>LLM 호출이나 LLM 응답 저장이 실패할 경우, 보상 트랜잭션을 통해 토큰 개수를 다시 증가시키는 방법을 고려했습니다.</p>
<pre><code class="language-java">public Optional&lt;InterviewProceedResponse&gt; proceedInterview(...) {
        memberService.decreaseTokenCount(memberId);
        try {
        LlmResponse llmResponse = bedrockClient.requestToBedrock(questionAndAnswers);
        interviewService.saveFeedbackAndNextQeustion(...);
    } catch (Excpetion e) {
        memberService.increaseTokenCount(memberId);
    }
}</code></pre>
<p>하지만 이 방식은 보상 트랜잭션 자체가 실패할 경우 여전히 토큰 개수 정합성이 깨질 수 있는 근본적인 한계가 있습니다.</p>
<h2 id="✅-해결-방안-2--분산-락을-통한-정합성-보장">✅ [해결 방안 2 : 분산 락을 통한 정합성 보장]</h2>
<p>LLM 호출 이전에 토큰을 감소시킨 이유는 사용자의 동시 LLM 호출을 방지하기 위함이었습니다.</p>
<p>이를 Redis 분산 락으로 제어하고, 토큰 감소를 LLM 호출 이후로 변경하여 토큰 정합성 문제를 해결하였습니다.</p>
<pre><code class="language-java">public Optional&lt;InterviewProceedResponse&gt; proceedInterview(...) {
    String lockKey = &quot;interview:proceed:&quot; + memberId;
    boolean acquired = redisService.acquireLock(lockKey, Duration.ofSeconds(30));

    if (!lockAcquired) {
        throw new BadRequestException(&quot;이미 처리 중인 답변이 있습니다. 잠시 후 다시 시도해주세요.&quot;);
    }
    try {
        LlmResponse llmResponse = 
                bedrockClient.requestToBedrock(questionAndAnswers);
        interviewService.saveFeedbackAndNextQeustion(...); // 여기서 토큰 감소
        // ...
    } finally (Exception e) {
        redisService.releaseLock(lockKey);
    }
}</code></pre>
<p>분산 락을 통해 동일한 사용자가 동시에 여러 LLM 호출을 수행하지 못하도록 제어했습니다.</p>
<p>이 방식을 통해 LLM 호출 실패 시에도 토큰이 부당하게 차감되지 않으며, LLM 응답 저장과 토큰 감소가 하나의 트랜잭션으로 묶여 토큰 정합성이 보장됩니다.</p>
<h1 id="문제-상황-2--톰캣-스레드-풀-고갈">[문제 상황 2 : 톰캣 스레드 풀 고갈]</h1>
<p>히카리 풀 점유 문제는 해결되었지만, 톰캣 스레드 풀을 장시간 점유하는 새로운 문제가 발생했습니다.</p>
<p>프로덕션 서버의 CPU 코어가 2개인 환경에서 성능 테스트를 통해 톰캣의 max-threads 값을 30으로 설정한 상태였습니다.</p>
<p>실제로 LLM 호출 API를 동시에 100회 요청하는 테스트를 진행한 결과는 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/b3d090d8-8142-45a0-9fcf-1d957b0fdd5d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/39d8d593-a9b7-479e-b9c0-3efc3666a14a/image.png" alt=""></p>
<p>LLM 응답이 도착할 때까지 톰캣 스레드를 점유하여 약 23초 동안 스레드 풀 전체가 고갈되는 것을 확인할 수 있었습니다.</p>
<p>이를 검증하기 위해 DB 접근이 없어 평소 1ms 정도 소요되는 가벼운 API를 2초 후에 호출해본 결과, 약 22초간 대기하는 현상이 발생했습니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/2a228322-17e0-4d0b-92c4-5d4fcc655972/image.png" alt=""></p>
<p>LLM 응답이 23초에 71번째로 도착하는 순간 톰캣 스레드 하나가 해제되고, 그때부터 대기 중이던 API가 처리되는 것을 확인할 수 있었습니다.</p>
<h2 id="✅-해결-방안-3--async--폴링">✅ [해결 방안 3 : @Async + 폴링]</h2>
<p>톰캣 스레드 풀 점유를 방지하기 위해 LLM 호출을 비동기로 전환하기로 결정했습니다.</p>
<p>@Async를 활용하여 LLM 호출을 비동기로 처리하고 클라이언트에게 즉시 응답함으로써 톰캣 스레드 점유 문제를 해결했습니다.</p>
<p>또한 폴링 방식을 통해 클라이언트가 1초마다 LLM 응답 상태를 확인하고, 응답이 완료되면 면접의 다음 질문을 받아오도록 구현했습니다.</p>
<pre><code class="language-java">// InterviewFacadeService.java
public Optional&lt;InterviewProceedResponse&gt; proceedInterview(...) {
    // .. 분산락 로직

    try {
        interviewViewCountService.proceedInterviewAsync(...);
        redisService.setValue(key, &quot;PENDING&quot;, Duration.ofSeconds(300));
        // ...
        return ...; // 빠르게 응답
    } catch (Exception e) {
        redisService.releaseLock(lockKey);
        throw e;
    }
}

// InterviewViewCountService.java
@Async(&quot;llmExeutor&quot;)
public void proceedInterviewAsync(...) {
    try {
        LlmResponse llmResponse = 
                bedrockClient.requestToBedrock(questionAndAnswers);
        interviewService.saveFeedbackAndNextQeustion(...); // 여기서 토큰 감소
        redisService.setValue(key, &quot;COMPLETED&quot;, Duration.ofSeconds(300));
    } catch (Exception e) {
        redisService.setValue(key, &quot;FAILED&quot;, Duration.ofSeconds(300));
        log.error(&quot;Bedrock API 호출 실패 - questionId={}&quot;, questionId, e);
    } finally {
        redisService.releaseLock(lockKey);
    }
}</code></pre>
<p>LLM 호출을 비동기로 처리하고, 처리 상태를 Redis에서 관리했습니다.</p>
<p>상태 관리를 Redis에서 하는 이유는 예외 발생 시 클라이언트가 상태를 확인할 수 있어야 하고, 서버가 다중화되어 있어 글로벌 캐시가 필요하기 때문입니다.</p>
<p>클라이언트는 폴링을 통해 LLM 응답 상태를 주기적으로 확인합니다.</p>
<p>비동기 전환 후 인터뷰 진행 API의 응답 시간은 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/5fdc5698-bc22-45ed-8e58-1b8786e99d44/image.png" alt=""></p>
<p>동시에 100개 요청 시 평균 600ms의 응답 시간을 보이며, 톰캣 스레드 풀을 장시간 점유하지 않아 다른 API가 수십 초간 대기하는 문제가 해결되었습니다.</p>
<h1 id="문제-상황-3--비동기-스레드-풀-크기로-인한-컨텍스트-스위칭-비용-증가">[문제 상황 3 : 비동기 스레드 풀 크기로 인한 컨텍스트 스위칭 비용 증가]</h1>
<p>비동기 스레드 풀 사용 시 새로운 문제점이 발견되었습니다.</p>
<p>LLM 호출에서 블로킹 I/O를 사용할 경우, 요청 하나당 비동기 스레드 하나를 점유하는 구조입니다.</p>
<p>따라서 동시에 최대 x개의 요청을 처리하려면 비동기 스레드 풀 크기를 x 이상으로 설정해야 하는데, 이는 thread-per-request 방식이기 때문입니다.</p>
<p>그런데 스레드 풀 크기가 CPU 코어 개수에 비해 과도하게 클 경우 컨텍스트 스위칭 비용이 증가하여 성능 저하가 발생할 수 있습니다. 이는 톰캣 스레드 풀 크기 조정 과정에서 직접 경험한 문제였습니다.</p>
<p>예를 들어 동시에 100개 요청을 처리하려면 비동기 스레드 풀 크기를 100 이상으로 설정해야 하는데, 현재 프로덕션 서버의 CPU 코어가 2개인 상황에서 스레드 풀 크기 100개는 과도한 컨텍스트 스위칭으로 인해 오히려 성능이 저하될 것으로 예상되었습니다.</p>
<h2 id="❌-해결-방안-4--논블로킹-비동기">❌ [해결 방안 4 : 논블로킹 비동기]</h2>
<p>이 문제를 해결하기 위해 LLM 호출을 논블로킹 방식으로 처리하는 방법을 고려했습니다.</p>
<p>AWS Bedrock SDK에서 이벤트 루프 그룹을 활용한 논블로킹 방식을 지원하여 다음과 같이 구현했습니다.</p>
<pre><code class="language-java">// InterviewFacadeService.java
public Optional&lt;InterviewProceedResponse&gt; proceedInterview(...) {
    // .. 분산락 로직

    try {
        CompletableFuture&lt;ConverseResponse&gt; completableFuture = 
                bedrockAsyncClient.requestToBedrock(questionAndAnswers);
        completableFuture.thenAcceptAsync(
                response -&gt; callbackBedrock(...), 
                threadPoolTaskExecutor
        ).exceptionallyAsync(
                ex -&gt; handleBedrockException(...), 
                threadPoolTaskExecutor
        );
        redisService.setValue(key, &quot;PENDING&quot;, Duration.ofSeconds(300));
        // ...
        return ...; // 빠르게 응답
    } catch (Exception e) {
        redisService.releaseLock(lockKey);
    }
}</code></pre>
<p>CompletableFuture를 통해 LLM 응답 처리 및 Redis 상태를 &quot;COMPLETED&quot;로 업데이트하는 콜백과, 예외 발생 시 로그 기록 및 Redis 상태를 &quot;FAILED&quot;로 업데이트하는 예외 핸들러를 등록했습니다.</p>
<p>논블로킹 방식에서는 이벤트 루프 스레드가 다수의 LLM 호출을 처리하고, 응답 도착 시에만 비동기 스레드가 콜백 메서드를 실행합니다.</p>
<p>콜백 메서드 실행 시간은 LLM 호출 대기 시간보다 훨씬 짧기 때문에, 비동기 스레드 풀 크기를 CPU 코어 개수에 맞춰 작게 설정할 수 있습니다.</p>
<p>이를 통해 컨텍스트 스위칭 비용이 감소하여 훨씬 효율적인 성능을 보일 것으로 예상했습니다.</p>
<h3 id="성능-테스트--블로킹-vs-논블로킹">[성능 테스트 : 블로킹 vs 논블로킹]</h3>
<p>논블로킹 방식의 성능 개선 효과를 검증하기 위해 성능 테스트를 진행했습니다.</p>
<p>K6를 활용하여 다음 조건에서 테스트를 수행했으며, 프로덕션과 동일한 환경에서 서버 1대를 대상으로 총 3회씩 테스트했습니다.</p>
<ul>
<li>EC2 타입 : t4g.small</li>
<li>톰캣 스레드 : 30개</li>
<li>max connections : 2000개</li>
<li>히카리 풀 : 10개</li>
<li>블로킹 비동기 스레드 풀 : 100개</li>
<li>논블로킹 비동기 스레드 풀 : 20개</li>
<li>블로킹 비동기 HttpClient 커넥션 풀 : 1000개</li>
<li>논블로킹 비동기 HttpClient 커넥션 풀 : 1000개</li>
</ul>
<p>테스트 결과, 컨텍스트 스위칭 비용이 약 2배 정도 차이나는 것을 확인할 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/371304fd-3c65-4805-b435-14a26c38e929/image.png" alt=""></p>
<p>그러나 실제 응답 시간에는 큰 차이가 없었습니다.</p>
<p>로그를 통해 요청 시작부터 비동기 스레드 종료까지의 시간을 분석한 결과는 다음과 같습니다.</p>
<p>[블로킹]</p>
<ul>
<li>평균: 8.8초</li>
<li>최소: 3.0초</li>
<li>최대: 17.8초</li>
<li>중앙값 (P50): 8.5초</li>
<li>P95: 13.3초</li>
<li>P99: 17.3초</li>
</ul>
<p>[논블로킹]</p>
<ul>
<li>평균: 9.23초</li>
<li>최소: 5.12초</li>
<li>최대: 15.65초</li>
<li>중앙값 (P50): 9.32초</li>
<li>P95: 12.56초</li>
<li>P99: 15.37초</li>
</ul>
<p>블로킹 방식과 논블로킹 방식 간에 큰 성능 차이가 없었으며, 오히려 논블로킹 방식이 약간 더 느린 결과를 보였습니다.</p>
<p>추가로 비동기 스레드 풀 크기를 1000개로 늘리고 1000개 요청을 동시에 보내는 테스트도 진행했습니다. 이 경우 논블로킹 방식이 약간 더 빠른 성능을 보이기도 했지만, 여전히 의미 있는 차이는 없었습니다. 500개 요청으로 테스트해도 동일한 결과였습니다.</p>
<p>이러한 결과는 암달의 법칙으로 설명할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/8deb7b86-dcf5-4519-ba80-31e06e6780df/image.png" alt=""></p>
<p>LLM 호출에서는 LLM 응답을 대기하는 시간이 전체 처리 시간의 대부분을 차지합니다. 따라서 컨텍스트 스위칭이 차지하는 비율(P)이 작아, 전체 성능에 미치는 개선 효과가 제한적일 수밖에 없습니다.</p>
<p>즉, LLM 응답 대기라는 순차적 처리 부분이 워낙 큰 비중을 차지하다보니, 병렬 처리 최적화를 통한 성능 향상이 미미하게 나타난 것으로 분석됩니다.</p>
<p>하지만 컨텍스트 스위칭이 다른 API에 미치는 영향은 다를 수 있기 때문에, 단순한 조회 API를 초당 100회씩 요청하는 상황에서 블로킹 방식과 논블로킹 방식을 각각 적용하여 테스트했습니다.</p>
<p>[블로킹 방식]</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/a2521109-89c2-42ce-8686-239e3bd42b9f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/e931b557-c6e1-4d4c-a7cf-89756e60eeb3/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/8a74af7d-f8f4-4001-ae5f-6de6def73019/image.png" alt=""></p>
<p>[논블로킹 방식]</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/30904454-f109-40ec-9355-8fc055c06841/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/8b2e7a77-4202-417d-b901-054e388bb7d7/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/b72f646d-94fe-48e0-8a72-4b7d1545fcad/image.png" alt=""></p>
<p>3회 반복 테스트 결과, 각각 2번의 응답 시간 스파이크가 발생했습니다. 첫번째 스파이크는 100개의 요청이 몰리면서 발생한 것으로 추정되고, 두번째 스파이크는 LLM 응답을 처리하면서 발생한 것으로 보입니다.</p>
<p>첫 번째 스파이크는 두 방식 간 차이가 적었고, 논블로킹 방식에서 두 번째 스파이크의 크기가 다소 작은 것을 확인할 수 있었습니다.</p>
<p>논블로킹 방식을 사용해도 성능상 큰 이득을 얻을 수 없었기 때문에, 이벤트 루프 스레드 도입으로 인한 디버깅 및 추적의 복잡성을 감수하지 않고 블로킹 방식을 유지하기로 결정했습니다.</p>
<h2 id="❌-해결-방안-5--롱-폴링-or-sse-or-websocket">❌ [해결 방안 5 : 롱 폴링 or SSE or WebSocket]</h2>
<p>LLM 비동기 요청에 대한 응답을 클라이언트에게 전달하는 방식으로는 폴링 외에도 롱 폴링, SSE, WebSocket 등의 방법이 있습니다.</p>
<p>롱 폴링과 SSE 방식은 기본적으로 톰캣 스레드를 지속적으로 점유하지만, DeferredResult를 활용하면 톰캣 스레드를 점유하지 않고도 응답을 전달할 수 있습니다. WebSocket은 기본적으로 톰캣 스레드를 지속 점유하지 않는 특성이 있습니다.</p>
<p>하지만 LLM 응답이 평균 8초 정도 소요되는 상황에서 1초마다 폴링하는 것은 서버에 큰 부담이 되지 않는다고 판단했습니다. 지속적으로 요청하는 것이 아니라 클라이언트에서 설정한 타임아웃 시간까지만 요청하기 때문입니다.</p>
<p>또한 1초 정도의 지연은 허용 가능한 수준이라고 판단했습니다. 8초의 처리 시간에서 최대 1초가 추가로 지연되더라도 사용자 경험상 큰 차이가 없을 것으로 예상했기 때문입니다.</p>
<p>따라서 구현이 간단하고 유지보수가 용이한 폴링 방식을 선택하기로 결정했습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[빈번한 조회수 업데이트 - Redis write back 패턴으로 조회 성능 개선 (+ fall back 처리)]]></title>
            <link>https://velog.io/@nak-honest/%EB%B9%88%EB%B2%88%ED%95%9C-%EC%A1%B0%ED%9A%8C%EC%88%98-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8-Redis-write-back-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-fall-back-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@nak-honest/%EB%B9%88%EB%B2%88%ED%95%9C-%EC%A1%B0%ED%9A%8C%EC%88%98-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8-Redis-write-back-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-%EC%A1%B0%ED%9A%8C-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-fall-back-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Sun, 03 Aug 2025 17:13:00 GMT</pubDate>
            <description><![CDATA[<h1 id="개요">[개요]</h1>
<blockquote>
<p><strong>본 프로젝트는 Spring Boot와 MySQL을 활용한 모의 면접 서비스입니다.</strong></p>
</blockquote>
<p>이 글은 조회 API에서 조회수를 업데이트할 때 Redis write back 패턴으로 성능을 개선한 사례를 다룹니다. 또한 Redis 장애 상황에 대한 fallback 처리 방법도 함께 설명합니다.</p>
<h1 id="요약">[요약]</h1>
<p>매번 DB에 증분 쿼리를 실행하는 대신 Redis에 조회수를 저장하고 주기적으로 DB와 동기화하는 write back 패턴을 도입했습니다. 그 결과 <strong>TPS를 84% 개선</strong>(186 TPS → 342 TPS)했습니다.</p>
<p>추가로 Redis timeout 설정과 장애 시 DB 직접 조회를 통한 fallback 처리로 조회 API의 안정성을 보장했습니다.</p>
<h1 id="조회-api-성능을-개선한-이유">[조회 API 성능을 개선한 이유]</h1>
<p>조회수 기능을 구현하는 과정에서 조회 API에 조회수를 업데이트 하는 로직이 필요했습니다. 단순히 다음과 같은 증분 쿼리를 사용해도 되지만 트래픽 급증 시 성능 저하 문제가 있을 수 있습니다.</p>
<p>(JPA의 Dirty Checking은 race condition 때문에 제외하였습니다.)</p>
<pre><code class="language-sql">UPDATE interview SET view_count = view_count + 1 WHERE id = ?</code></pre>
<p>MySQL에서 UPDATE 문으로 인해 레코드 락이 걸리기 때문에 같은 interview에 대해 조회 트래픽이 급증하면 X 락 대기로 성능이 저하될 수 있습니다.</p>
<p>특히 조회 API에서 쓰기로 인한 성능 저하 문제는 피해야 한다고 생각했습니다.</p>
<h1 id="해결-방안">[해결 방안]</h1>
<h2 id="✅-redis-write-back-패턴">✅ [Redis write back 패턴]</h2>
<h3 id="redis-기반-조회수-관리">[Redis 기반 조회수 관리]</h3>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/c7616894-036e-4fab-8198-bbf3e5dce718/image.png" alt=""></p>
<p><strong>1. 중복 조회 방지</strong></p>
<pre><code class="language-yaml">Key : interview:viewCount:{interviewId}:{clientIP}
TTL : 1일 (하루 후 재조회 시 카운트 증가)</code></pre>
<p>조회수 치팅 방지를 위해 분산 락으로 제어했습니다.</p>
<p><strong>2. 조회수 저장 및 증가</strong></p>
<pre><code class="language-yaml">Key : interview:viewCount:{interviewId}
Value : 해당 면접의 조회수
Operation : INCR (원자적 연산으로 1씩 증가)
TTL : 2일</code></pre>
<p>레디스에서 조회수 값을 관리하고, 업데이트 시 INCR 를 통해 원자적으로 1씩 증가시킵니다.</p>
<p>TTL을 2일로 설정한 이유는 다음과 같습니다.</p>
<ul>
<li>데이터 안정성: Redis는 휘발 가능성이 있기 때문에, 데이터 손실 방지를 위해 주기적으로 DB에 동기화 해야합니다.</li>
<li>메모리 효율성: 조회되지 않는 오래된 데이터가 메모리를 점유하는 것을 방지해야 합니다.</li>
<li>불필요한 DB 업데이트 방지: 매일 동기화하는 주기의 2배(2일)로 설정하여 중복 업데이트를 최소화합니다.</li>
</ul>
<p><strong>3. 동시성 처리 로직</strong></p>
<pre><code class="language-yaml">1. EXPIRE interview:viewCount:{interviewId} 172800  // 2일 TTL 설정
2. EXPIRE 실패 시 → SETNX interview:viewCount:{interviewId} {DB_조회수}
3. INCR interview:viewCount:{interviewId}</code></pre>
<p>동시성 상황을 고려한 자세한 로직은 다음과 같습니다.</p>
<ul>
<li><code>interview:viewCount:{interviewId}</code> 에 대해 TTL을 2일로 EXPIRE 합니다.<ul>
<li>다음 상황으로 인해 조회수가 1로 초기화 되는 것을 방지하고자 EXPIRE를 먼저 실행합니다.</li>
<li>SETNX 실패 → key 만료 → INCR (key 가 없는 경우 1로 초기화)</li>
</ul>
</li>
<li>key가 존재하지 않아 EXPIRE에 실패하는 경우, DB에 저장된 조회수 값을 SETNX로 레디스에 저장합니다.<ul>
<li>race condition에 의해 값이 덮어 씌워지는 것을 방지하기 위함입니다.</li>
</ul>
</li>
<li>이후 INCR 연산으로 조회수 값을 원자적으로 1 증가시킵니다.</li>
</ul>
<h3 id="스케줄러를-이용한-주기적인-db-동기화">[스케줄러를 이용한 주기적인 DB 동기화]</h3>
<p>Redis는 인메모리 데이터베이스로 데이터 휘발성이 있어 주기적인 DB 동기화가 필요합니다. 사용자 트래픽이 가장 적은 새벽 5시에 @Scheduled를 통해 Redis의 조회수 데이터를 DB로 동기화하도록 구현했습니다.</p>
<pre><code class="language-java">@Scheduled(cron = &quot;0 0 5 * * *&quot;, zone = &quot;Asia/Seoul&quot;)
public void syncInterviewViewCounts() {
    if (!redisService.acquireLock(LOCK_KEY, Duration.ofHours(6))) {
        return;
    }
    List&lt;String&gt; keys = new ArrayList&lt;&gt;();
    Map&lt;Long, Long&gt; interviewViewCounts = new HashMap&lt;&gt;();   
        int scanCount = 100;

    try (Cursor&lt;String&gt; cursor = redisService.scanKeys(&quot;interview:viewCount:*&quot;, scanCount)) {
        while (cursor.hasNext()) {
            keys.add(cursor.next());
            processBatchesIfReady(keys, interviewViewCounts);
        }
        putInterviewViewCounts(keys, interviewViewCounts);
        batchUpdateInterviewViewCounts(interviewViewCounts);
    } catch (Exception e) {
        log.error(&quot;인터뷰 조회수를 DB에 반영하는 스케줄러 동작 중 에러 발생&quot;, e);
        redisService.releaseLock(INTERVIEW_VIEW_COUNT_SCHEDULER_LOCK);
    }
}</code></pre>
<p>다중 서버 환경에서 동일한 작업이 중복 실행되는 것을 방지하기 위해 Redis 분산락을 활용했습니다. 락을 먼저 획득한 서버만 스케줄러를 실행합니다.</p>
<p>조회수 값을 조회할 때 KEYS 명령어 대신 SCAN을 사용했습니다. 싱글 스레드로 동작하는 Redis 특성 상, KEYS는 O(N) 시간복잡도로 다른 작업을 밀리게 해 장애를 일으킬 수 있습니다. 따라서 SCAN을 이용하여 100개씩 나누어 조회해 다른 작업에 끼치는 영향을 최소화했습니다.</p>
<p>또한 DB에 업데이트 할 때 배치 업데이트를 적용했습니다. 개별 UPDATE 쿼리는 매번 네트워크 통신이 발생해 비효율적입니다. 배치 처리로 여러 쿼리를 한 번에 실행하여 성능을 최적화했으며, MySQL의 멀티 스레드 특성을 고려해 배치 사이즈를 Redis 보다 더 큰 1000개로 설정했습니다.</p>
<pre><code class="language-java">@Transactional
public void batchUpdateInterviewViewCount(Map&lt;Long, Long&gt; interviewViewCounts, int batchSize) {
    List&lt;Entry&lt;Long, Long&gt;&gt; entries = new ArrayList&lt;&gt;(interviewViewCounts.entrySet());

    jdbcTemplate.batchUpdate(
            &quot;UPDATE interview SET view_count = ? WHERE id = ?&quot;,
            entries,
            batchSize,
            (ps, entry) -&gt; {
                ps.setLong(1, entry.getValue());
                ps.setLong(2, entry.getKey());
            }
    );
}</code></pre>
<h3 id="성능-테스트-결과">[성능 테스트 결과]</h3>
<p>다음 조건에서 K6를 활용해 테스트 했습니다.</p>
<p>프로덕션과 동일한 환경으로 서버 1 대에만 테스트를 진행하였고, 총 3번씩 테스트 했습니다.</p>
<ul>
<li>EC2 타입 : t4g.small</li>
<li>톰캣 스레드 : 30개</li>
<li>max connections : 2000개</li>
<li>히카리 풀 : 10개</li>
</ul>
<p>[Redis를 활용한 write back 패턴]</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/26c48036-c9ea-476d-9b03-05beffb434e4/image.png" alt=""></p>
<p>평균 342 TPS가 나왔습니다.
<br></p>
<p>[DB에 직접 증분 쿼리를 날릴 때]</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/6c1732e0-74df-47c8-a3c5-450c1d43f300/image.png" alt=""></p>
<p>평균 186 TPS가 나왔습니다.</p>
<p>Redis를 활용한 방식이 약 84 % 정도 성능이 향상된 것을 볼 수 있습니다.</p>
<p>실제 프로덕션 환경에서는 다중 서버로 인해 히카리 커넥션 풀의 총합이 더 크므로, DB X 락으로 인한 대기 시간이 길어져 성능 차이는 더욱 클 것으로 예상됩니다.</p>
<h2 id="✅-redis-장애-시-fall-back-처리">✅ [Redis 장애 시 fall back 처리]</h2>
<p>서비스 운영 중 Redis 장애를 경험한 적이 있습니다. </p>
<p>메모리, CPU 사용률과 슬로우 쿼리를 확인했지만 특별한 이상은 없어 순간적인 네트워크 장애로 추정됩니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/f2fe2d31-a0ee-4655-858b-7218e8408081/image.png" alt=""></p>
<p>현재 구조에서는 Redis 장애 시 조회수 업데이트가 실패하면서 조회 API 전체가 실패하게 됩니다. </p>
<p>조회수 업데이트 때문에 조회 API가 실패해서는 안 된다고 판단하여, Redis 장애 시 DB에서 직접 조회수를 읽어오는 fallback을 구현했습니다.</p>
<p>최신 조회수는 아니지만 API 실패보다는 낫다고 판단했으며, Redis 장애가 빈번하지 않고 조회수는 정밀한 정합성이 필요하지 않는다는 특성을 고려했습니다. </p>
<p>해당 API 뿐만 아니라 조회수 값을 조회하는 모든 API에서 Redis 장애 시 DB로 fallback하도록 처리했습니다.</p>
<h2 id="❌-다른-해결-방안--kafka와-아웃박스-패턴을-이용한-조회수-업데이트">❌ [다른 해결 방안 : Kafka와 아웃박스 패턴을 이용한 조회수 업데이트]</h2>
<p>Redis에 있는 조회수 값을 주기적으로 DB에 동기화 하더라도, Redis 자체에 장애가 발생하면 데이터 손실을 완전히 방지할 수 없습니다. </p>
<p>정합성과 성능을 모두 확보하려면 Kafka로 이벤트를 publish 하여 처리하도록 하고, publish 실패 시 아웃박스 테이블에 저장 후 재처리하여 데이터 손실을 최소화 할 수 있습니다.</p>
<p>이 방식이 Redis보다 훨씬 안정적이지만, 조회수는 이런 복잡한 아키텍처를 도입할 만큼 높은 정합성이 요구되는 데이터가 아니라고 판단하여 Redis write-back 패턴을 선택했습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MySQL 쿼리 개선기 - 인덱스를 활용한 정렬보다 Using temporary, Using filesort가 더 빠르다고?]]></title>
            <link>https://velog.io/@nak-honest/MySQL-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%A0%95%EB%A0%AC%EB%B3%B4%EB%8B%A4-Using-temporary-Using-filesort%EA%B0%80-%EB%8D%94-%EB%B9%A0%EB%A5%B4%EB%8B%A4%EA%B3%A0</link>
            <guid>https://velog.io/@nak-honest/MySQL-%EC%BF%BC%EB%A6%AC-%EA%B0%9C%EC%84%A0%EA%B8%B0-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%A0%95%EB%A0%AC%EB%B3%B4%EB%8B%A4-Using-temporary-Using-filesort%EA%B0%80-%EB%8D%94-%EB%B9%A0%EB%A5%B4%EB%8B%A4%EA%B3%A0</guid>
            <pubDate>Sun, 16 Mar 2025 16:14:37 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>이번 글에서는 우테코에서 진행한 투룻 프로젝트를 통해 쿼리 개선을 경험한 내용을 공유하고자 합니다. 프로젝트에서 쿼리 성능을 개선하기 위해 <code>GROUP BY</code>와 <code>JOIN</code>의 순서를 바꿔 NL(Nested Loop) JOIN의 드라이빙 테이블 크기를 줄이고, 인덱스를 활용한 정렬이 깨지지 않도록 개선하였습니다.  그리고 서브 쿼리에서 커버링 인덱스를 적용했습니다. 최종적으로 <code>GROUP BY</code> + <code>HAVING</code> 을 여러 개의 <code>JOIN</code> 으로 변환해 임시 테이블 없이 인덱스를 더 잘 활용하도록 개선하였습니다. 이 과정을 통해 <strong>쿼리 성능을 최대 722배 개선</strong>할 수 있었습니다.</p>
<p>이 글에서는 정렬 시 인덱스를 활용하는 것이 중요하지만, 드라이빙 테이블의 크기가 쿼리 성능에 미치는 영향이 더 클 수 있다는 사실을 다룹니다. </p>
<p>또한 커버링 인덱스에서 복합 인덱스를 거는 순서에 따라 끼치는 영향에 대해 다룹니다. 이 과정에서 첫번째 컬럼의 카디널리티가 Index Skip Scan 성능에 끼치는 영향을 다룹니다.</p>
<h2 id="투룻-서비스-소개">투룻 서비스 소개</h2>
<p>투룻은 여행기 서비스로, 여행기에 달려있는 태그 기반으로 여행기 목록을 조회하는 기능이 있었습니다. 이 때 여행기 목록은 좋아요 순으로 정렬되어 표시되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/b17733d3-cf90-429d-8560-faa18d35c1f2/image.png" alt=""></p>
<p>위의 화면은 태그 기반으로 좋아요 순으로 정렬한 여행기 목록을 보여줍니다.</p>
<h2 id="간략한-테이블-구조-설명">간략한 테이블 구조 설명</h2>
<p>쿼리를 살피기 전에 테이블 구조를 간략하게 설명하겠습니다. 기본적으로 여행기 정보를 저장하는 travelogue 테이블과 태그 정보를 저장하는 tag 테이블이 있습니다. 여행기에는 여러 개의 태그가 존재할 수 있기 때문에 다대다 관계로 중간 테이블인 travelogue_tag 테이블이 존재합니다.</p>
<p>또한 travelogue 테이블에는 like_count라는 좋아요 숫자에 대한 컬럼이 반정규화되어 존재했습니다. 이는 좋아요 순 정렬 성능을 높이기 위해 추가된 컬럼입니다.</p>
<h2 id="기존-쿼리">기존 쿼리</h2>
<p>이 때 태그로 필터링 후 좋아요 순으로 정렬하는 쿼리를 다음과 같이 작성하였습니다.</p>
<pre><code class="language-sql">SELECT
    t1_0.id,
    t1_0.author_id,
    t1_0.created_at,
    t1_0.deleted_at,
    t1_0.like_count,
    t1_0.modified_at,
    t1_0.thumbnail,
    t1_0.title
FROM
    travelogue t1_0
        JOIN
    travelogue_tag tt1_0
    ON tt1_0.travelogue_id = t1_0.id
WHERE
    tt1_0.tag_id IN (?, ?)
GROUP BY
    tt1_0.travelogue_id
HAVING
    COUNT(tt1_0.id) = ?
ORDER BY
    t1_0.like_count DESC
LIMIT ? OFFSET ?;</code></pre>
<p>여기서 travelogue와 travelogue_tag 테이블을 <code>JOIN</code>한 후, <code>GROUP BY</code>와 <code>HAVING</code>을 사용하여 선택한 태그를 전부 가지고 있는 여행기를 필터링하였습니다. 이후 like_count로 내림차순 정렬하고, <code>LIMIT</code>과 <code>OFFSET</code>을 사용하여 반환하였습니다. 이 때 <code>LIMIT</code>과 <code>OFFSET</code>은 무한 스크롤을 구현하기 위해 페이지 개수만큼 데이터를 자르는 방식으로 사용되었습니다.</p>
<p>해당 쿼리의 실행 계획을 살펴보았더니 다음과 같이 <code>Using temporary; Using filesort</code>가 있는 것을 확인할 수 있었습니다.</p>
<pre><code class="language-sql">+--+-----------+-----+----------+------+---------------------------------------------------------+-------+-------+--------------------------------+-------+--------+--------------------------------------------+
|id|select_type|table|partitions|type  |possible_keys                                            |key    |key_len|ref                             |rows   |filtered|Extra                                       |
+--+-----------+-----+----------+------+---------------------------------------------------------+-------+-------+--------------------------------+-------+--------+--------------------------------------------+
|1 |SIMPLE     |tt1_0|null      |ALL   |fk_travelogue_tag_tag_id,fk_travelogue_tag_travelogue_id |null   |null   |null                            |1495116|20.61   |Using where; Using temporary; Using filesort|
|1 |SIMPLE     |t1_0 |null      |eq_ref|PRIMARY,fk_travelogue_author_id,idx_travelogue_like_count|PRIMARY|8      |touroot_test.tt1_0.travelogue_id|1      |100     |null                                        |
+--+-----------+-----+----------+------+---------------------------------------------------------+-------+-------+--------------------------------+-------+--------+--------------------------------------------+</code></pre>
<p>like_count 컬럼에 대한 인덱스가 있었지만, 옵티마이저는 이 인덱스를 활용하지 않고 직접 정렬하는 계획을 선택하였습니다. 인덱스를 활용하지 못했기 때문에 성능 저하가 우려되었고, 이에 따라 테스트 데이터를 넣고 성능을 측정하였습니다.</p>
<h3 id="테스트-데이터-정보">테스트 데이터 정보</h3>
<p>테스트를 위해 travelogue 데이터 100만 건, 태그 17개, 여행기 당 평균 태그 1.5개, 여행기 당 좋아요 수 평균 50개로 설정하고 성능을 측정하였습니다. 실제 환경과의 차이를 조금이라도 줄이기 위해 단순 랜덤으로 데이터를 생성하지 않고, 정규 분포 형태로 데이터를 생성하여 측정하였습니다.</p>
<h3 id="테스트-결과">테스트 결과</h3>
<p>그 결과 <strong>평균 1.445초</strong>가 소요되었습니다. 이는 단순히 슬로우 쿼리일 뿐만 아니라, 인덱스를 제대로 활용하지 못한 것이 원인이었기에 100ms 이내로 개선하는 것을 목표로 쿼리 튜닝을 시작했습니다.</p>
<p>실행 계획을 확인해 보니 옵티마이저는 travelogue_tag를 드라이빙 테이블로 선택하고 있었습니다. 그러나 저는 travelogue의 (like_count) 인덱스를 드라이빙 테이블로 사용하는 것이 더 효율적이라고 판단했습니다. (like_count) 인덱스에서 row를 하나씩 가지고 와 travelogue_tag와 조인을 수행하면 그 결과는 like_count 순으로 정렬될 것이기 때문입니다.</p>
<h3 id="travelogue를-강제로-드라이빙-테이블로-삼아보자">travelogue를 강제로 드라이빙 테이블로 삼아보자</h3>
<p>따라서 <code>STRAIGHT_JOIN</code>을 사용하여 travelogue를 강제적으로 드라이빙 테이블로 삼고 쿼리를 실행했으나, <strong>실행 시간이 4s</strong>가 걸리는 결과를 초래했습니다. 실행 계획을 확인해보니 드라이빙 테이블로 (like_count) 인덱스가 아닌 PK 인덱스를 활용하는 것을 확인할 수 있었습니다.</p>
<pre><code class="language-sql">-&gt; Limit: 5 row(s)  (actual time=3994..3994 rows=5 loops=1)
    -&gt; Sort: t1_0.like_count DESC  (actual time=3994..3994 rows=5 loops=1)
        -&gt; Filter: (`count(tt1_0.id)` = 2)  (actual time=1.73..3985 rows=7396 loops=1)
            -&gt; Stream results  (cost=1.79e+6 rows=489639) (actual time=1.01..3974 rows=170101 loops=1)
                -&gt; Group aggregate: count(tt1_0.id)  (cost=1.79e+6 rows=489639) (actual time=0.965..3888 rows=170101 loops=1)
                    -&gt; Nested loop inner join  (cost=1.68e+6 rows=489639) (actual time=0.939..3822 rows=177497 loops=1)
                        -&gt; Index scan on t1_0 using PRIMARY  (cost=107101 rows=990317) (actual time=0.614..568 rows=1e+6 loops=1)
                        -&gt; Filter: (tt1_0.tag_id in (2,5))  (cost=1.38 rows=0.494) (actual time=0.00306..0.00314 rows=0.177 loops=1e+6)
                            -&gt; Index lookup on tt1_0 using fk_travelogue_tag_travelogue_id (travelogue_id = t1_0.id)  (cost=1.38 rows=2.01) (actual time=0.00267..0.00294 rows=1.5 loops=1e+6)</code></pre>
<p>위의 실행 계획에서 <code>Index scan on t1_0 using PRIMARY</code> 를 통해 PK 인덱스를 드라이빙 테이블로 삼는 것을 확인할 수 있습니다.</p>
<p>(like_count) 인덱스를 통해 정렬된 순서로 travelogue row를 읽어온다 해도, <code>GROUP BY travelogue.id</code>가 집계 처리를 위해 임시 테이블을 생성하면서 정렬 상태는 깨지게 됩니다.
즉 <code>GROUP BY</code>와 <code>ORDER BY</code>의 기준 컬럼이 서로 다르기 때문에 결국 (like_count) 인덱스를 사용하더라도 그 정렬 순서가 최종 결과에 유지되지 못합니다.</p>
<p>따라서 옵티마이저는 어차피 정렬해야 하기 때문에 (like_count) 인덱스에 접근해서 룩업을 하기보단 클러스터드 인덱스에 접근하는 것을 선택하는 것으로 볼 수 있습니다.</p>
<p>그리고 위의 실행 계획이 더 느린 이유는 드라이빙 테이블의 크기가 더 크기 때문입니다. 위의 실행 계획에서 travelogue의 모든 row를 가져오기 때문에 드라이빙 테이블의 크기가 100만 개임을 확인할 수 있습니다. 따라서 travelogue_tag 를 드라이빙 테이블로 삼는 것보다 더 느린 것입니다.</p>
<p>실제로 travelogue_tag를 드라이빙 테이블로 삼는 실행 계획을 확인해보니, 드라이빙 테이블의 크기가 약 17만 개임을 알 수 있었습니다.
(드라이빙 테이블이 커지면 읽어야 하는 row 개수 자체도 많아지고, 또한 디스크에서 랜덤 I/O가 많이 발생하기 때문에 성능이 저하됩니다. 관련된 내용은 자세히 설명하지 않겠습니다.)</p>
<pre><code class="language-sql">-&gt; Limit: 5 row(s)  (actual time=1713..1713 rows=5 loops=1)
    -&gt; Sort: t1_0.like_count DESC  (actual time=1713..1713 rows=5 loops=1)
        -&gt; Filter: (`count(tt1_0.id)` = 2)  (actual time=1647..1707 rows=7255 loops=1)
            -&gt; Table scan on &lt;temporary&gt;  (actual time=1647..1699 rows=169368 loops=1)
                -&gt; Aggregate using temporary table  (actual time=1647..1647 rows=169367 loops=1)
                    -&gt; Nested loop inner join  (cost=416899 rows=315298) (actual time=1.58..1105 rows=176623 loops=1)
                        -&gt; Filter: (tt1_0.tag_id in (2,5))  (cost=152507 rows=315298) (actual time=0.872..495 rows=176623 loops=1)
                            -&gt; Table scan on tt1_0  (cost=152507 rows=1.5e+6) (actual time=0.792..415 rows=1.5e+6 loops=1)
                        -&gt; Single-row index lookup on t1_0 using PRIMARY (id=tt1_0.travelogue_id)  (cost=0.739 rows=1) (actual time=0.00327..0.0033 rows=1 loops=176623)</code></pre>
<p>travelogue_tag 를 드라이빙 테이블로 삼으면, 위의 실행 계획에 나와 있듯 <code>Filter: (tt1_0.tag_id in (2,5))</code> 를 먼저 수행 후 JOIN 하기 때문에 드라이빙 테이블의 크기가 줄어들어 성능이 더 좋은 것입니다.</p>
<h2 id="첫번째-쿼리-개선---group-by와-join의-순서를-바꿔보자">첫번째 쿼리 개선 - GROUP BY와 JOIN의 순서를 바꿔보자</h2>
<p>GROUP BY를 먼저 수행한 후 JOIN을 하도록 아래와 같이 쿼리를 수정해 보았습니다.</p>
<pre><code class="language-sql">SELECT t.id,
       t.author_id,
       t.created_at,
       t.deleted_at,
       t.like_count,
       t.modified_at,
       t.thumbnail,
       t.title
FROM travelogue t
         JOIN
     (SELECT tt.travelogue_id
      FROM travelogue_tag tt
      WHERE tt.tag_id IN (?, ?)
      GROUP BY tt.travelogue_id
      HAVING COUNT(tt.id) = ?) st
     ON t.id = st.travelogue_id
ORDER BY t.like_count DESC
LIMIT ? OFFSET ?;</code></pre>
<p>그 이유는 두가지 때문이었습니다.</p>
<ol>
<li>travelogue의 (like_count) 인덱스를 드라이빙 테이블로 삼는 경우, <code>JOIN</code> 전에 <code>GROUP BY</code>를 수행하기 때문에 마지막까지 정렬된 상태가 유지됩니다. 따라서 추가적인 정렬 작업이 필요 없게 됩니다.</li>
<li>반대로 travelogue_tag를 드라이빙 테이블로 사용할 경우, GROUP BY와 HAVING 절을 먼저 적용해 드라이빙 테이블의 row 수 자체를 줄일 수 있습니다.</li>
</ol>
<h3 id="드라이빙-테이블을-travelogue로-설정한-경우">드라이빙 테이블을 travelogue로 설정한 경우</h3>
<p>다음은 실행 계획입니다.</p>
<pre><code class="language-sql">-&gt; Limit: 5 row(s)  (cost=2.48e+6 rows=0) (actual time=325..326 rows=5 loops=1)
    -&gt; Nested loop inner join  (cost=2.48e+6 rows=0) (actual time=325..326 rows=5 loops=1)
        -&gt; Index scan on t using idx_travelogue_like_count  (cost=0.0108 rows=1) (actual time=0.0201..1.12 rows=446 loops=1)
        -&gt; Covering index lookup on ttt using &lt;auto_key0&gt; (travelogue_id=t.id)  (cost=0.25..2.5 rows=10) (actual time=0.729..0.729 rows=0.0112 loops=446)
            -&gt; Materialize  (cost=0..0 rows=0) (actual time=325..325 rows=7255 loops=1)
                -&gt; Filter: (`count(tt.id)` = 2)  (actual time=299..322 rows=7255 loops=1)
                    -&gt; Table scan on &lt;temporary&gt;  (actual time=299..314 rows=169368 loops=1)
                        -&gt; Aggregate using temporary table  (actual time=299..299 rows=169368 loops=1)
                            -&gt; Index range scan on tt using fk_travelogue_tag_tag_id over (tag_id = 2) OR (tag_id = 5), with index condition: (tt.tag_id in (2,5))  (cost=141885 rows=315298) (actual time=0.0183..218 rows=176623 loops=1)</code></pre>
<p>실행 계획을 보면 더 이상 like_count에 대해 정렬을 수행하지 않는 것을 확인할 수 있습니다. (2번째 줄에 있던 <code>Sort: t1_0.like_count DESC</code>이 더이상 없습니다.)</p>
<p>(like_count) 인덱스에서 순서대로 row를 읽고, 조건에 맞는 5개를 찾을 때까지만 JOIN을 수행하게 됩니다.
실제 row 수는 446개로 이전보다 훨씬 적게 읽는 것을 확인할 수 있습니다.</p>
<blockquote>
<p>결과적으로 실행 시간은 약 <strong>300ms</strong>로 줄어들어, 이전보다 약 6배 이상 성능이 개선되었습니다. 이는 <code>Using temporary; Using filesort</code> 로 직접 정렬하지 않고 (like_count) 인덱스를 활용하여 정렬했기 때문이며, 동시에 드라이빙 테이블의 크기도 많이 줄어들었기 때문입니다.</p>
</blockquote>
<p>하지만 <code>OFFSET</code>을 1000까지 늘리면 쿼리는 <strong>다시 1.4초로 많이 느려졌습니다.</strong> 실행 계획을 보니 <code>OFFSET</code>이 증가하게 되면서 travelogue에서 많은 row를 가져와 <code>JOIN</code>을 수행해야 했기 때문에 느려진 것이었습니다. 확인해보니 <code>OFFSET</code> 1000 기준으로 약 14만 개 정도의 row를 가져오는 것을 확인하였습니다.</p>
<h3 id="드라이빙-테이블을-travelogue_tag로-설정한-경우">드라이빙 테이블을 travelogue_tag로 설정한 경우</h3>
<p><code>OFFSET</code>이 1000인 경우에는 travelogue_tag를 드라이빙 테이블로 삼은 실행 계획이 <strong>약 500ms 정도로 오히려 더 빠른 것</strong>을 확인할 수 있었습니다. 이는 드라이빙 테이블의 크기 때문이었습니다. 다음은 실행 계획입니다.</p>
<pre><code class="language-sql">-&gt; Limit/Offset: 5/1000 row(s)  (actual time=577..577 rows=5 loops=1)
    -&gt; Sort: t.like_count DESC, limit input to 1005 row(s) per chunk  (actual time=577..577 rows=1005 loops=1)
        -&gt; Stream results  (cost=226260 rows=0) (actual time=396..568 rows=7255 loops=1)
            -&gt; Nested loop inner join  (cost=226260 rows=0) (actual time=396..561 rows=7255 loops=1)
                -&gt; Table scan on ttt  (cost=2.5..2.5 rows=0) (actual time=396..397 rows=7255 loops=1)
                    -&gt; Materialize  (cost=0..0 rows=0) (actual time=396..396 rows=7255 loops=1)
                        -&gt; Filter: (`count(tt.id)` = 2)  (actual time=373..395 rows=7255 loops=1)
                            -&gt; Table scan on &lt;temporary&gt;  (actual time=373..388 rows=169368 loops=1)
                                -&gt; Aggregate using temporary table  (actual time=373..373 rows=169368 loops=1)
                                    -&gt; Index range scan on tt using fk_travelogue_tag_tag_id over (tag_id = 2) OR (tag_id = 5), with index condition: (tt.tag_id in (2,5))  (cost=166165 rows=315298) (actual time=0.373..279 rows=176623 loops=1)
                -&gt; Single-row index lookup on t using PRIMARY (id=ttt.travelogue_id)  (cost=0.718 rows=1) (actual time=0.0223..0.0224 rows=1 loops=7255)</code></pre>
<p>인덱스를 활용하지 않고 직접 정렬(<code>Using temporary; Using filesort</code>)을 하지만, 드라이빙 테이블의 크기를 보면 row 수가 7255 개인 것을 확인할 수 있습니다. 이는 <code>WHERE</code> 조건뿐만 아니라, <code>GROUP BY</code>와 <code>HAVING</code> 절까지 먼저 적용되어 <code>JOIN</code> 전에 불필요한 row 수가 대폭 줄어들었기 때문입니다. 따라서 <code>OFFSET</code> 이 큰 경우에는 직접 정렬하는 것이 (like_count) 인덱스를 활용하는 것보다 오히려 더 빠른 결과를 보였습니다.</p>
<p>이를 통해 드라이빙 테이블의 크기가 쿼리에 얼마나 큰 영향을 미치는지를 알 수 있었습니다.
(<code>Using temporary; Using filesort</code> 가 더 빠를줄이야..)</p>
<h2 id="두번째-쿼리-개선---서브-쿼리-개선커버링-인덱스">두번째 쿼리 개선 - 서브 쿼리 개선(커버링 인덱스)</h2>
<p>이번에는 서브 쿼리를 개선하였습니다. </p>
<pre><code class="language-sql">SELECT tt.travelogue_id
      FROM travelogue_tag tt
      WHERE tt.tag_id IN (?, ?)
      GROUP BY tt.travelogue_id
      HAVING COUNT(tt.id) = ?)</code></pre>
<p>위의 쿼리를 보면 다음과 같이 3개의 컬럼을 사용하는 것을 확인할 수 있습니다.</p>
<ul>
<li>travelogue_id (<code>SELECT</code>, <code>GROUP BY</code>)</li>
<li>tag_id (<code>WHERE</code>)</li>
<li>id (<code>HAVING</code>)</li>
</ul>
<p>현재 travelogue_tag 테이블에는 외래 키로 인해 각각 travelogue_id와 tag_id에 대해 개별 인덱스가 존재하지만, 이 인덱스들 중 어느 것도 세 컬럼을 모두 포함하고 있지 않기 때문에 결국 클러스터드 인덱스를 통한 테이블 룩업이 발생합니다.</p>
<p>이를 해결하기 위해 커버링 인덱스를 적용하기로 결정했고 tag_id와 travelogue_id로 구성된 복합 인덱스를 추가했습니다.</p>
<p>이때 인덱스 순서를 (tag_id, travelogue_id)로 설정한 이유는 <code>WHRER</code> 절에 의해 tag_id로 먼저 필터링을 수행한 뒤 travelogue_id 기준으로 <code>GROUP BY</code> 하기 때문입니다. 즉 tag_id 로 필터링을 먼저하기 때문에 복합 인덱스에서 tag_id가 먼저 와야 더 효율적입니다.
바로 다음에 나오는 개선에서 위의 순서로 복합 인덱스를 구성해야 더 효율적으로 동작하게 되는데, 이는 뒤에서 자세히 설명하겠습니다.</p>
<p>인덱스 적용 이후 실행 계획을 확인해보면, 아래와 같이 커버링 인덱스를 활용한 것으로 나타납니다.</p>
<pre><code class="language-sql">-&gt; Filter: (`count(tt.id)` = 2)  (actual time=148..170 rows=7255 loops=1)
    -&gt; Table scan on &lt;temporary&gt;  (actual time=148..162 rows=169368 loops=1)
        -&gt; Aggregate using temporary table  (actual time=148..148 rows=169368 loops=1)
            -&gt; Filter: (tt.tag_id in (2,5))  (cost=68320 rows=336672) (actual time=0.074..63.6 rows=176623 loops=1)
                -&gt; Covering index range scan on tt using idx_travelogue_tag_tag_travelogue over (tag_id = 2) OR (tag_id = 5)  (cost=68320 rows=336672) (actual time=0.0723..50.4 rows=176623 loops=1)</code></pre>
<p>복합 인덱스를 적용한 후 기존 쿼리 성능을 다시 측정한 결과 다음과 같은 개선이 있었습니다:</p>
<table>
<thead>
<tr>
<th>조건</th>
<th>개선 전</th>
<th>개선 후</th>
</tr>
</thead>
<tbody><tr>
<td>OFFSET = 0, 드라이빙 테이블 = like_count</td>
<td>300ms</td>
<td>100ms</td>
</tr>
<tr>
<td>OFFSET = 1000, 드라이빙 테이블 = travelogue_tag</td>
<td>500ms</td>
<td>200ms</td>
</tr>
</tbody></table>
<p>복합 인덱스를 통해 테이블 룩업을 제거함으로써 <code>WHERE</code>, <code>GROUP BY</code>, <code>HAVING</code>이 모두 인덱스 내에서 해결될 수 있었고,
그 결과 전체 쿼리 성능이 향상되었습니다.</p>
<h2 id="세번째-쿼리-개선---서브-쿼리-개선group-by-없애기">세번째 쿼리 개선 - 서브 쿼리 개선(GROUP BY 없애기)</h2>
<p>하지만 서브 쿼리 개선은 여기서 멈추지 않았습니다. </p>
<p><code>GROUP BY</code>와 <code>HAVING</code> 절을 사용하는 기존 서브쿼리는 집계 결과를 필터링하기 위해 임시 테이블을 생성하는 구조였습니다.</p>
<p>비록 (tag_id, travelogue_id) 복합 인덱스를 통해 커버링 인덱스를 활용하고 있었지만 <code>GROUP BY</code> 대상이 <strong>인덱스의 두 번째 컬럼인 travelogue_id</strong>였기 때문에 MySQL은 여전히 임시 테이블 기반의 집계 처리를 사용하고 있었습니다.</p>
<p>반대로 인덱스 순서를 (travelogue_id, tag_id)로 바꾸면 정렬된 상태로 임시 테이블 없이 <code>GROUP BY</code>가 가능하긴 하지만, 이 경우 <code>WHERE tag_id = ?</code> 조건이 인덱스의 두 번째 컬럼에 걸려 Index Skip Scan으로 처리되며 성능이 더 저하되는 문제가 발생합니다. 투룻 서비스에서는 여행기마다 0~3 개의 태그를 가지기 때문에 travelogue_id 의 카디널리티가 높았습니다. Index Skip Scan 에서는 첫번째 선행 컬럼의 카디널리티가 높을수록 스캔해야 하는 양이 많아져 쿼리 성능이 떨어지게 됩니다.</p>
<p>(tag_id, travelogue_id) 인덱스에서 어떻게 하면 두번째 컬럼인 travelogue_id를 잘 활용할 수 있을까 고민하다가 tag_id별로 row를 나눠서 각각의 조건을 가진 JOIN을 수행하면 굳이 GROUP BY 없이도 동일한 travelogue_id를 가진 tag 조합을 구할 수 있겠다는 생각이 들었습니다.</p>
<p>즉, tag_id = 2인 travelogue와 tag_id = 5인 travelogue를 각각 필터링한 뒤 <strong>travelogue_id를 기준으로 JOIN</strong>하면, 두 태그를 모두 가진 travelogue만 추출할 수 있습니다. 이렇게 하면 임시 테이블도 필요 없고, (tag_id, travelogue_id) 인덱스를 활용하면 Index Skip Scan 도 없어지게 됩니다.</p>
<pre><code class="language-sql">SELECT tt1.travelogue_id
FROM travelogue_tag tt1 JOIN travelogue_tag tt2
                             ON tt1.travelogue_id = tt2.travelogue_id
WHERE tt1.tag_id = 2 AND tt2.tag_id = 5;</code></pre>
<p>실행 계획을 확인해보니 다음과 같았습니다.</p>
<pre><code class="language-sql">-&gt; Nested loop inner join  (cost=199667 rows=165984) (actual time=0.255..125 rows=7255 loops=1)
    -&gt; Covering index lookup on tt1 using idx_travelogue_tag_tag_travelogue (tag_id=2)  (cost=17085 rows=165984) (actual time=0.121..19.1 rows=88238 loops=1)
    -&gt; Covering index lookup on tt2 using idx_travelogue_tag_tag_travelogue (tag_id=5, travelogue_id=tt1.travelogue_id)  (cost=1 rows=1) (actual time=0.00107..0.00109 rows=0.0822 loops=88238)</code></pre>
<p>실행 계획을 확인해보면 커버링 인덱스와 JOIN만으로 처리되고 있음을 확인할 수 있습니다.
임시 테이블 없이 수행되며 tag_id는 복합 인덱스의 첫 번째 컬럼이기 때문에 Index Skip Scan 없이 효율적으로 필터링됩니다.</p>
<p>또한 이 <code>JOIN</code> 결과는 travelogue_id 기준으로 정렬된 상태를 유지하므로, 이후 travelogue 테이블과 travelogue_id를 기준으로 <code>JOIN</code>할 때 드리븐 테이블로 삼는 경우 마치 인덱스처럼 동작하여 좋은 성능을 내게 됩니다.</p>
<p>이 방식은 다음과 같은 추가적인 장점도 가지고 있습니다.</p>
<ol>
<li>투룻 서비스에서 여행기는 최대 3개의 태그만 가질 수 있기 때문에 <code>JOIN</code>도 최대 3번만 일어나며, 즉 무한히 <code>JOIN</code>이 발생하지 않습니다.</li>
<li>인덱스를 추가하게 되는 경우 쓰기 작업에 대해서는 손해가 발생하지만, 위의 경우에는 기존 (tag_id) FK 인덱스를 (tag_id, travelogue_id) 인덱스로 대체하면 되기 때문에 인덱스를 새로 추가하는 것이 아닙니다.</li>
</ol>
<p>따라서 <code>GROUP BY</code>를 <code>JOIN</code>으로 변경하여 개선하였습니다.</p>
<h2 id="최종-개선한-쿼리">최종 개선한 쿼리</h2>
<p>최종적으로는 서브쿼리로 <code>JOIN</code>하는 것이 아니라 travelogue_tag와 직접 <code>JOIN</code>하는 것으로 결정하였습니다.</p>
<pre><code class="language-sql">SELECT
    t.id,
    t.author_id,
    t.created_at,
    t.deleted_at,
    t.like_count,
    t.modified_at,
    t.thumbnail,
    t.title
FROM
    travelogue t
        JOIN
    travelogue_tag tt1 ON tt1.travelogue_id = t.id AND tt1.tag_id = 2
        JOIN
    travelogue_tag tt2 ON tt2.travelogue_id = t.id AND tt2.tag_id = 5
ORDER BY
    t.like_count DESC
LIMIT 5 OFFSET 1000;</code></pre>
<h2 id="최종-성능-측정">최종 성능 측정</h2>
<p>최종적으로 실행 시간을 정리하면 다음과 같습니다.</p>
<h3 id="1-like_count-인덱스를-드라이빙-테이블로-삼는-실행-계획-offset이-0인-경우">1. like_count 인덱스를 드라이빙 테이블로 삼는 실행 계획 (OFFSET이 0인 경우)</h3>
<p><strong>약 2ms 소요</strong></p>
<pre><code class="language-sql">-&gt; Limit: 5 row(s)  (cost=1.98e+6 rows=5) (actual time=0.275..1.89 rows=5 loops=1)
    -&gt; Nested loop inner join  (cost=1.98e+6 rows=5) (actual time=0.275..1.89 rows=5 loops=1)
        -&gt; Nested loop inner join  (cost=990318 rows=5) (actual time=0.113..1.8 rows=35 loops=1)
            -&gt; Index scan on t using idx_travelogue_like_count  (cost=0.0326 rows=5) (actual time=0.0326..1.07 rows=440 loops=1)
            -&gt; Covering index lookup on tt1 using idx_travelogue_tag_tag_travelogue (tag_id = 2, travelogue_id = t.id)  (cost=1 rows=1) (actual time=0.00154..0.00156 rows=0.0795 loops=440)
        -&gt; Covering index lookup on tt2 using idx_travelogue_tag_tag_travelogue (tag_id = 5, travelogue_id = t.id)  (cost=1 rows=1) (actual time=0.00222..0.00226 rows=0.143 loops=35)</code></pre>
<h3 id="2-like_count-인덱스를-드라이빙-테이블로-삼는-실행-계획-offset이-1000인-경우">2. like_count 인덱스를 드라이빙 테이블로 삼는 실행 계획 (OFFSET이 1000인 경우)</h3>
<p><strong>약 700ms 소요</strong></p>
<pre><code class="language-sql">-&gt; Limit/Offset: 5/1000 row(s)  (cost=1.98e+6 rows=5) (actual time=830..832 rows=5 loops=1)
    -&gt; Nested loop inner join  (cost=1.98e+6 rows=1005) (actual time=4.37..832 rows=1005 loops=1)
        -&gt; Nested loop inner join  (cost=990423 rows=1005) (actual time=3.99..808 rows=11890 loops=1)
            -&gt; Index scan on t using idx_travelogue_like_count  (cost=5.4 rows=1005) (actual time=3.8..637 rows=132407 loops=1)
            -&gt; Covering index lookup on tt1 using idx_travelogue_tag_tag_travelogue (tag_id = 2, travelogue_id = t.id)  (cost=1 rows=1) (actual time=0.00117..0.00119 rows=0.0898 loops=132407)
        -&gt; Covering index lookup on tt2 using idx_travelogue_tag_tag_travelogue (tag_id = 5, travelogue_id = t.id)  (cost=1 rows=1) (actual time=0.00186..0.00188 rows=0.0845 loops=11890)</code></pre>
<h3 id="3-travelogue_tag를-드라이빙-테이블로-삼는-실행-계획">3. travelogue_tag를 드라이빙 테이블로 삼는 실행 계획</h3>
<p>OFFSET과 상관없이 <strong>약 140ms 소요</strong></p>
<pre><code class="language-sql">-&gt; Limit/Offset: 5/1000 row(s)  (actual time=156..156 rows=5 loops=1)
    -&gt; Sort: t.like_count DESC, limit input to 1005 row(s) per chunk  (actual time=155..156 rows=1005 loops=1)
        -&gt; Stream results  (cost=312702 rows=166022) (actual time=0.554..152 rows=7396 loops=1)
            -&gt; Nested loop inner join  (cost=312702 rows=166022) (actual time=0.548..148 rows=7396 loops=1)
                -&gt; Nested loop inner join  (cost=199713 rows=166022) (actual time=0.54..113 rows=7396 loops=1)
                    -&gt; Covering index lookup on tt1 using idx_travelogue_tag_tag_travelogue (tag_id = 2)  (cost=17089 rows=166022) (actual time=0.516..16.9 rows=88757 loops=1)
                    -&gt; Covering index lookup on tt2 using idx_travelogue_tag_tag_travelogue (tag_id = 5, travelogue_id = tt1.travelogue_id)  (cost=1 rows=1) (actual time=961e-6..989e-6 rows=0.0833 loops=88757)
                -&gt; Single-row index lookup on t using PRIMARY (id = tt1.travelogue_id)  (cost=0.581 rows=1) (actual time=0.00452..0.00455 rows=1 loops=7396)</code></pre>
<blockquote>
<p>최종 성능 개선 결과, <strong>최대 1445ms에서 2ms로 개선하여 최대 약 722배 개선</strong>하였습니다.</p>
</blockquote>
<h2 id="결론">결론</h2>
<p>마지막으로 드라이빙 테이블을 무엇으로 삼을지에 대한 결정이 필요했습니다. <code>OFFSET</code>에 따라 더 빠른 실행 계획이 달라질 수 있기 때문에 특정 임계값을 기준으로 <code>STRAIGHT_JOIN</code>을 통해 직접 실행 계획을 지정할까 고민하였습니다. 하지만 결론적으로는 옵티마이저에게 직접 실행 계획 선정을 맡기기로 결정하였습니다.</p>
<p>그 이유는 다음과 같습니다:</p>
<ol>
<li>테스트 데이터와 실제 데이터의 양상 차이: 예를 들어 실제 환경에서 특정 태그가 인기가 많아 해당 태그에 데이터가 몰리는 경우, travelogue_tag를 드라이빙 테이블로 삼더라도 row 개수가 많아져 성능이 어떻게 나올지 예측하기 어렵습니다.</li>
<li>임계값 계산의 복잡성: 임계값을 찾는 것도 복잡한 로직을 통해 계산해야 하며, 이는 유지보수에 부담을 줄 수 있습니다.</li>
<li><code>OFFSET</code>이 큰 쿼리 발생 확률: 무엇보다 <code>OFFSET</code>이 큰 쿼리는 자주 발생하지 않습니다. 현재 페이지의 크기가 5개인데, <code>OFFSET</code>이 1000이라는 것은 여행기를 5000개 조회했을 때 나오는 쿼리입니다. 즉 실제 환경에서는 <code>OFFSET</code>이 큰 쿼리가 발생할 확률이 매우 낮습니다.</li>
</ol>
<p>따라서 옵티마이저에게 드라이빙 테이블 선정을 맡기기로 결정하였습니다.</p>
<p>만약 나중에 <code>OFFSET</code>이 큰 쿼리가 자주 발생하고, 옵티마이저가 비효율적으로 드라이빙 테이블을 선정한다면 그때 가서 <code>STRAIGHT_JOIN</code>을 사용하기로 결정하였습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[지속 가능한 속도(우테코 레벨 3 글쓰기)]]></title>
            <link>https://velog.io/@nak-honest/%EC%A7%80%EC%86%8D-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%86%8D%EB%8F%84%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A0%88%EB%B2%A8-3-%EA%B8%80%EC%93%B0%EA%B8%B0</link>
            <guid>https://velog.io/@nak-honest/%EC%A7%80%EC%86%8D-%EA%B0%80%EB%8A%A5%ED%95%9C-%EC%86%8D%EB%8F%84%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A0%88%EB%B2%A8-3-%EA%B8%80%EC%93%B0%EA%B8%B0</guid>
            <pubDate>Sat, 30 Nov 2024 15:45:13 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Agile processes promote sustainable development. The sponsors, developers, and users should be able to maintain a constant pace indefinitely.</p>
<p>애자일 프로세스들은 지속 가능한 개발을 장려한다. 스폰서, 개발자, 사용자는 일정한 속도를 계속 유지할 수 있어야 한다.
<br></p>
</blockquote>
<p>애자일을 주제로 테코톡을 준비하며 인상 깊었던 내용은 <code>지속 가능한 속도(Sustainable Pace)</code>에 대한 것이었다. 
단순히 애자일을 &quot;기민함&quot;, &quot;빠르게&quot;라고 알고 있었기에 신선한 충격으로 다가왔다. 
매일 과업을 하며 과속을 내는 것이 단기적으로 보았을 땐 빨라 보일 수 있겠지만, 장기적으로 보았을 땐 오히려 생산성을 떨어뜨린다. 
이는 개발자의 집중력을 떨어뜨리고, 결과적으로 코드의 품질이 떨어지는 결과를 내기 때문이다.
<br></p>
<p>이러한 관점에서, &quot;지속 가능한 속도를 유지하기 위해 개발에만 몰두하지 않고, 삶의 다른 중요한 부분들도 함께 챙기기&quot;로 목표를 세웠다. 
이는 팀 프로젝트에 적은 시간을 쓰겠다는 이야기가 아니다. 
여전히 많은 시간을 내어 몰입하겠지만, 그 과정에서 삶의 균형을 완전히 잃어버리는 것을 경계하겠다는 것이다. 
개발 외에도 삶에서 중요한 부분들이 많기 때문이다.
<br></p>
<p>특히 소중한 사람들과의 관계와 종교 생활(기독교)은 나의 정체성과 삶의 균형에 큰 영향을 미치는 요소들이다. 
과거 개발자로 진로를 결정하기 전에는 이러한 부분들에 많은 시간과 에너지를 쏟았다. 
1년간 휴학을 하며 종교 동아리 회장으로서 완전히 몰입할 정도로 내 삶의 중심이었다.
<br></p>
<p>하지만 개발자가 되기로 결심한 이후, 삶의 모습이 많이 바뀌게 되었다. 
늦은 나이에 진로를 바꿨다는 조급함, 그리고 취업이라는 두려움 때문에 삶의 다른 중요한 부분들을 소홀히 하게 되었다. 
주변 사람들에게 소홀해지고, 종교 생활에도 소홀하게 되었다. 
개발 실력을 향상하는 데에만 집중한 나머지, 내 삶의 균형이 무너져갔다.
<br></p>
<p>물론 이를 몰입의 과정으로 볼 수도 있겠지만, 시간이 지날수록 이는 내가 진정으로 원하는 삶의 모습이 아니라는 것을 깨달을 수 있었다. 
오히려 이러한 불균형은 나를 건강하지 못한 상태로 만들었고, 자존감도 낮추는 결과를 가져왔다. 
더 나아가 이는 좋은 협업을 하는 데에도 부정적인 영향을 미쳤다. 
내가 건강하지 못한 상태에서 타인과의 관계도 원활할 수 없었기 때문이다.
<br></p>
<p>따라서 여러 번의 유강스 과정을 통해 삶의 방향성을 다시 재조정했다. 
개발에 대한 시간과 노력은 쏟되, 동시에 삶의 다른 중요한 부분들도 조금씩 챙기려 노력했다. 
가족과의 대화 시간을 늘리고, 교회 사람들과 더 많은 교제를 나누었다. 
특히 종교 활동을 통해 나의 감정과 현재 상황을 돌아보았고, 어떻게 살아가야 할지에 대해서도 계속 고민할 수 있었다.
<br></p>
<p>이러한 노력의 결과, 나는 조금 더 건강하고 균형 잡힌 모습으로 팀 프로젝트에 임할 수 있었다. 
내면의 안정과 긍정적인 에너지가 자연스럽게 팀 내 분위기에 반영되어, 밝고 활기찬 협업 환경을 만드는 데 기여할 수 있었다. 
뿐만 아니라 이전보다 개발에 더 집중할 수 있게 되었다.
<br></p>
<p>지속 가능한 개발은 단순히 코드를 지속적으로 작성하는 것이 아니라, 개발자 자신이 지속 가능한 상태를 유지하는 것에서 시작한다. 
개인의 균형 잡힌 삶이 결국 팀에 기여한다는 것을 깨달았고, 이는 앞으로의 개발자 생활에서도 꾸준히 지켜나가고 싶은 원칙이 되었다. 
물론 아직 완벽하지 않고 계속해서 노력해야 할 부분이지만, 이번 경험은 나에게 큰 배움이 되었다.
<br></p>
<p>앞으로도 개발에 대한 열정과 노력은 유지하되, 동시에 삶의 다른 중요한 영역들과의 균형을 잃지 않도록 주의를 기울일 것이다. 
이를 통해 더 나은 개발자로, 더 나은 사람으로 성장해 나갈 수 있기를 희망한다.
<br></p>
<p>지속 가능한 개발은 곧 지속 가능한 성장이며, 이는 주변에 선한 영향을 미치는 <a href="https://velog.io/@nak-honest/%EB%93%A4%ED%92%80%EC%9D%98-%EA%B7%B8%EB%8A%98%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A0%88%EB%B2%A8-2-%EA%B8%80%EC%93%B0%EA%B8%B0-%EB%AF%B8%EC%85%98">들풀 같은 사람</a>으로 나아가는 길이 될 것이라 믿는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[들풀의 그늘(우테코 레벨 2 글쓰기)]]></title>
            <link>https://velog.io/@nak-honest/%EB%93%A4%ED%92%80%EC%9D%98-%EA%B7%B8%EB%8A%98%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A0%88%EB%B2%A8-2-%EA%B8%80%EC%93%B0%EA%B8%B0-%EB%AF%B8%EC%85%98</link>
            <guid>https://velog.io/@nak-honest/%EB%93%A4%ED%92%80%EC%9D%98-%EA%B7%B8%EB%8A%98%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A0%88%EB%B2%A8-2-%EA%B8%80%EC%93%B0%EA%B8%B0-%EB%AF%B8%EC%85%98</guid>
            <pubDate>Sat, 30 Nov 2024 15:43:58 GMT</pubDate>
            <description><![CDATA[<h2 id="들풀의-그늘">들풀의 그늘</h2>
<p>선한 영향력을 끼치는 삶.
돈이나 권력을 좇으며 자신만을 위해 살아가지 않고, 옆에 있는 이와 더불어 함께하는 삶.</p>
<p>지금까지 그렇게 살아가고자 하는 어른들을 심심치 않게 볼 수 있었다. 멀리 내다볼 것 없이 부모님이 그렇게 살아가고 있으니 말이다.
<br></p>
<p>어렸을 때부터 부모님은 들풀처럼 살아가라고 말씀하셨다.</p>
<p>다름에 대해 지적하고, 따가운 시선으로 실력을 평가하는 직사광선의 세상 속에서 
작은 새들이 쉬어갈 수 있는 그늘을 제공하라고 하셨다.</p>
<p>크고 화려한 나무처럼 커다란 그늘은 아닐지라도,
단 한 명이라도 좋으니 누군가의 그늘이 되어주는 들풀처럼 살아가라고 하셨다.</p>
<p>지금껏 그러한 그늘 아래에서 얼마나 많은 사랑을 받으며 자라왔는가. 힘들 때 위로가 되어주었던 선배들과 친구들이 얼마나 많았는가. 안락하고 평안한 그늘 아래에서 자라왔기 때문에 나 또한 그러한 그늘을 만들어내는 삶을 살고 싶었다. 순수한 시절의 꿈이었고, 그 꿈을 위해 살아가는 것이 정말로 행복했다.
<br></p>
<p>하지만 처음으로 그늘에서 벗어나 직사광선을 마주했을 때, 나의 그늘이 얼마나 좁디좁았는지 느낄 수 있었다.
나 하나만 신경 쓰기도 벅찬 세상에서 누군가를 돌아볼 여유가 없다고 느껴졌다.</p>
<p>특히 취업이라는 거대해 보이는 문제 앞에서 스스로가 너무나 작아졌고,
그늘을 만들어내는 삶은 점점 허황한 꿈처럼 느껴졌다.</p>
<p>이후에는 두려움과 불안함에 매몰되어 타인의 인정만 좇는 삶을 살아갔다. <a href="https://velog.io/@nak-honest/%EC%9D%B8%EC%A0%95%EC%9D%98-%EB%8B%AC%EC%BD%A4%ED%95%A8%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A0%88%EB%B2%A8-1-%EA%B8%80%EC%93%B0%EA%B8%B0">레벨 1 글쓰기</a>에 적었듯이 말이다.
<br></p>
<p>솔직하게 말하면 우테코에 들어오게 된 가장 큰 이유도 취업 때문이었다.
만약 우테코가 취업에 그다지 메리트가 없었다면, 관심조차 가지지 않았을 것이다.</p>
<p>하지만 <code>소프트웨어 생태계에 선한 영향력을</code>이라는 우테코의 비전답게 이곳에서는 단순히 취업만을 목표로 살지 말라고 이야기한다. 얼마 전에 포비와 면담을 진행했을 때도 비슷한 말씀을 해주셨다. 세상이 말하는 흐름을 맹목적으로 따르지 말고, 내가 맞다고 생각하는 길을 살아가 보라고 말씀하셨다. 내가 진짜로 원하는 삶, 그 꿈을 좇아 살아가 보라 말씀하셨다.</p>
<p>포비와의 면담 이후 내가 진정으로 원하는 것이 무엇인지 고민해 보았다. 고민을 하면 할수록 순수했던 시절의 꿈이 다시금 떠올랐다. 현실과의 간극이 너무 커서 느꼈던 괴리감 때문에 접어두었던 꿈이 조금씩 깨어났다. 아직 명확한 꿈을 찾지는 못했지만, 추상적이더라도 내가 진정 원하던 것이 무엇이었는지 깨달을 수 있었다.
<br></p>
<p>우테코는 서로가 서로의 그늘이 되어주는 들풀의 공동체라고 생각한다. 여기서는 그 누구도 남을 평가하지 않는 곳이다. 각자의 꿈을 가지고 함께 성장해 나가는 곳이다.</p>
<p>그러한 우테코에서 나의 꿈을 찾길 바란다.
커다란 그늘이 되지는 못할지라도, 세상에 엄청난 영향력을 끼치지 못할지라도,
주어진 나의 범위 안에서 선한 영향력을 끼치며 살아가고 싶다.</p>
<p>높고 화려한 나무가 되지는 못할지라도, 누군가 한 명은 쉬어갈 수 있는 들풀이 되고 싶다.</p>
<p>그것이 진정 내가 행복해지는 길이고, 나의 내면이 단단해지는 길이라 믿는다.
더 구체적이고 명확한 목표는 아직 정하지 못했지만, 남은 우테코 생활 동안 발견해 가길 간절히 바란다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인정의 달콤함(우테코 레벨 1 글쓰기)]]></title>
            <link>https://velog.io/@nak-honest/%EC%9D%B8%EC%A0%95%EC%9D%98-%EB%8B%AC%EC%BD%A4%ED%95%A8%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A0%88%EB%B2%A8-1-%EA%B8%80%EC%93%B0%EA%B8%B0</link>
            <guid>https://velog.io/@nak-honest/%EC%9D%B8%EC%A0%95%EC%9D%98-%EB%8B%AC%EC%BD%A4%ED%95%A8%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A0%88%EB%B2%A8-1-%EA%B8%80%EC%93%B0%EA%B8%B0</guid>
            <pubDate>Sat, 30 Nov 2024 15:42:25 GMT</pubDate>
            <description><![CDATA[<h3 id="인정의-달콤함">인정의 달콤함</h3>
<p>지나온 삶을 돌이켜 보면 나의 자존감을 타인의 인정으로 채우고자 했던 적이 얼마나 많았던가.
그것이 결코 나를 온전히 채워주지 못한다는 사실을 알면서도, 다른 사람으로부터 인정받기 위해 열심히 살아왔다.</p>
<p>25살에 군대를 전역한 후 개발자가 되고 싶다는 생각에 무작정 복수전공을 시작했다. 복수전공을 하는 2년 동안 학교 성적에 목숨을 걸었다.</p>
<p>학점은 타인으로부터 인정받기 좋은 수단이다. 과제와 시험에 대한 점수가 모두 공개된다. 몇 등을 했는지도 알 수 있다. 나의 위치를 한 눈에 볼 수 있다.</p>
<p>인정 받기 위해 학점과 등수에 집착하였다. 그리고 그로부터 오는 인정으로 나의 자존감을 채웠다. 그에 따라 인정받지 못하는 순간에는 나의 존재가 부정당하는 느낌이었다.</p>
<p>인정받지 못할까 봐 두려웠고, 따라서 조급했다. 매일매일 시간에 쫓기며 살았고 내가 공부하는 시간을 누군가가 조금이라도 침해한다면 크게 스트레스를 받았다.</p>
<p>하지만 그렇게 타인의 인정에 집착할수록 나와 내 주변 관계를 망가뜨렸다.</p>
<p>나와 내 건강에 소홀했고,
나에게 주어진 소중한 관계에 소홀했고,
나와 함께하던 공동체에 소홀했다.</p>
<p>그리고 무엇보다 내가 행복하지 못했다. 인정받는 순간만 달콤했지, 그 달콤함은 금방 사라졌다. 또한 이를 얻기 위한 과정에서 내가 참 많이 소진되었다.</p>
<p>심지어 좋은 학점이 결코 좋은 개발자를 만들어주지 않았다.
2년이라는 시간 동안 열심히 공부 했다. 하지만 학점과 등수를 위해 공부한 지식은 시험이 끝나고 머릿속에서 금방 사라졌다.
<br>
<br></p>
<h3 id="현재의-고통에서-배우자">현재의 고통에서 배우자</h3>
<p>우테코에 들어온 뒤 유연성 강화 스터디를 통해 내가 언제 고통을 느끼는지 돌아보았다.
인정 욕구에 매몰되어 나와 주변을 살피지 못할 때 가장 고통스럽다고 느꼈다.</p>
<p>따라서 유연성 강화 목표를 <code>나를 긍정적으로 여기는 근거를, 타인의 인정에서 찾지 않고 나의 내면에서 찾아보기</code>로 설정하였다.
나의 자존감은 다른 사람이 채울 수 있는 것이 아니라 오로지 &quot;나&quot;만이 채워줄 수 있다고 생각해서였다.
<br></p>
<p>하지만 너무 늦게 깨달은 탓이었을까. 소홀했던 시간을 없던 일로 할 수는 없었다. 지난 일들로 인해 소중한 관계를 잃게 되었다.</p>
<p>그 이후로 한동안은 우테코에 집중하기 어려웠다. 우테코 캠퍼스에서는 그나마 정신을 차리고 미션을 수행해 나갔지만, 집에만 가면 애써 무시해 오던 감정들이 터져 나왔다.</p>
<p>그래도 그 기간에 정말 감사했던 것은 유연성 강화 스터디를 통해 나 자신을 돌아볼 수 있었다는 것이었다. 스터디를 통해 이 고통에서 어떤 것을 배울 수 있는지 돌아보게 되었고, 인정만 좇는 삶이 결코 행복할 수 없다는 것을 뼈저리게 깨달았다. 만약 스터디를 진행하지 않았다면 고통에 매몰되기만 하다가 끝났을 것 같다.</p>
<p>건강하지 못한 방식으로 나의 자존감을 채우는 것은 결국 주변 관계에도, 그리고 나에게도 악영향을 끼친다. 나의 자존감을 높이는 것은 나를 있는 그대로 사랑하는 방법밖에 없다.</p>
<p>앞으로 우테코 과정을 계속 해나가면서 타인의 인정이 주는 달콤함을 경계하자. 인정 욕구 자체가 나쁘다는 것은 아니다. 다만 해당 욕구로 자존감을 채우는 것을 경계하자는 것이다.</p>
<p>이곳에서 스스로를 긍정적으로 여기는 근거를 하나씩 발견해 가길 원한다. 나의 내면을 차근차근 단단하게 만들어 가고싶다. 나조차도 사랑하지 못했던 모습을 존중해주는 안전한 우테코에서 스스로를 사랑하는 법을 배워나갔으면 좋겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] INSERT IGNORE 는 데이터 중복 시 왜 새로운 데이터의 삽입을 막을까?]]></title>
            <link>https://velog.io/@nak-honest/MySQL-INSERT-IGNORE-%EB%8A%94-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A4%91%EB%B3%B5-%EC%8B%9C-%EC%99%9C-%EC%83%88%EB%A1%9C%EC%9A%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%9D%98-%EC%82%BD%EC%9E%85%EC%9D%84-%EB%A7%89%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@nak-honest/MySQL-INSERT-IGNORE-%EB%8A%94-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A4%91%EB%B3%B5-%EC%8B%9C-%EC%99%9C-%EC%83%88%EB%A1%9C%EC%9A%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%9D%98-%EC%82%BD%EC%9E%85%EC%9D%84-%EB%A7%89%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Sat, 23 Nov 2024 16:00:09 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@nak-honest/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EB%8D%B0%EB%93%9C%EB%9D%BD-%ED%95%B4%EA%B2%B0%EA%B8%B0-X-%EB%9D%BD%EC%9D%B8%EB%8D%B0-%EC%99%9C-%EA%B3%B5%EC%9C%A0%EA%B0%80-%EA%B0%80%EB%8A%A5%ED%95%98%EC%A7%80">동시성 문제(데드락) 해결기 - X 락인데 왜 공유가 가능하지??????</a></p>
<p>이전 글에서는 동시성 문제를 <code>INSERT IGNORE</code>를 사용해 해결한 경험을 공유했습니다.</p>
<p><strong><code>INSERT IGNORE</code> 는 unique 인덱스가 설정된 상황에서 중복된 unique 키로 삽입을 시도할 경우, 에러를 발생시키지 않고 해당 삽입을 무시합니다.</strong></p>
<p>하지만 매번 <code>INSERT</code> 쿼리를 실행하면 성능이 저하가 우려되어 이를 테스트를 하는 도중, 다음과 같이 이상한 현상을 발견했습니다.</p>
<h2 id="insert-ignore-실행-시-이미-중복된-데이터가-존재한다면-pk-인덱스에-supremum-pseudo-record-락을-건다">INSERT IGNORE 실행 시, 이미 중복된 데이터가 존재한다면 PK 인덱스에 supremum pseudo record 락을 건다.</h2>
<p>먼저 이전 글과 동일하게 place 테이블은 (id, name, latitude, longitude) 로 구성되어 있고, (name, latitude, longitude) 에 unique 제약이 걸려있습니다.</p>
<p>그리고 테이블에는 다음과 같이 (&#39;place1&#39;, &#39;123.456&#39;, &#39;123.456&#39;) 레코드가 존재합니다.
<img src="https://velog.velcdn.com/images/nak-honest/post/443e7288-6812-4573-ab66-2249bf0e47fe/image.png" alt=""></p>
<p>이 상황에서 중복된 장소에 대해 <code>INSERT IGNORE</code> 를 수행하니 다음과 같이 락을 거는 것을 확인할 수 있었습니다. (트랜잭션 격리 수준은 디폴트인 REPEATABLE READ 입니다.)
<img src="https://velog.velcdn.com/images/nak-honest/post/4f2d1a12-b3b1-4477-85a4-63d566cb412f/image.png" alt=""></p>
<h3 id="관찰된-현상">관찰된 현상</h3>
<h4 id="1-unique-인덱스unique_place에-s-넥스트-키-락-발생">1. unique 인덱스(unique_place)에 S 넥스트 키 락 발생</h4>
<p>이는 데이터의 일관성을 위한 락으로, 다른 트랜잭션에서 동일한 unique 키를 가지는 데이터에 대한 수정이나 삭제를 방지합니다. 
일반적으로 unique 인덱스에서 쿼리 결과가 하나임을 보장하는 경우에는 레코드 락만 겁니다. 즉 갭 락 없이 레코드 락만 겁니다.
하지만 이 상황에서는 넥스트 키 락(레코드 락 + 갭 락)을 거는데, 자세한 이유는 뒤에서 설명합니다.</p>
<h4 id="2-ix-락-발생">2. IX 락 발생</h4>
<p>Intention eXclusive Lock 인 IX 락이 발생한다.</p>
<h4 id="3-pk-인덱스에-supremum-pseudo-record-락-발생">3. PK 인덱스에 supremum pseudo record 락 발생</h4>
<p>가장 이해하기 어려운 점은 PK 인덱스에 supremum pseudo record 락이 걸린 것입니다. 이 글을 쓰게 된 이유이기도 합니다. 
이 락은 PK 인덱스에서 가장 큰 레코드보다 큰 레코드의 삽입을 방지하는 역할을 합니다. 즉, AUTO_INCREMENT를 사용하는 경우 새로운 레코드는 항상 인덱스의 끝에 추가되기 때문에, <strong>이 락으로 인해 모든 새로운 레코드 삽입이 차단되는 상황이 발생</strong>합니다.</p>
<h3 id="의문점">의문점</h3>
<p><code>INSERT IGNORE</code> 는 자신이 삽입하고자 했던 (&#39;place1&#39;, &#39;123.456&#39;, &#39;123.456&#39;) 에 대한 락만 걸면 될텐데, 왜 전혀 관계없는 새로운 레코드의 삽입도 막는지 이해가 가지 않았습니다. </p>
<p>일반적인 <code>INSERT</code> 는 데이터 중복이 없을 때 INSERT INTENTION LOCK 만 거는데, 레코드 충돌만 없다면 락을 공유할 수 있기 때문에 동시성이 저하되지 않습니다. 반면 <code>INSERT IGNORE</code>는 중복된 데이터로 인해 supremum pseudo record 락까지 발생시키며, AUTO_INCREMENT를 사용하는 모든 삽입 시도를 차단합니다.</p>
<p>실제로 트랜잭션을 새로 열고 삽입을 시도하면, supremum pseudo record 락을 해제하길 기다리는 것을 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/65405d51-6657-4235-8eef-3b744e44c507/image.png" alt=""></p>
<h3 id="insert-ignore-의-동작-원리">INSERT IGNORE 의 동작 원리</h3>
<p><code>INSERT IGNORE</code> 가 어떻게 동작하길래 해당 락을 거는지 궁금했습니다. 중복 레코드가 존재하는 경우 모든 INSERT 를 잠근다는 것이 너무 비효율적이라 생각했기 때문입니다.</p>
<p>따라서 <a href="https://dev.mysql.com/doc/refman/8.4/en/insert.html">MySQL 의 공식 문서</a> 내용을 확인했습니다.</p>
<blockquote>
<p>If you use the IGNORE modifier, ignorable errors that occur while executing the INSERT statement are ignored. For example, without IGNORE, a row that duplicates an existing UNIQUE index or PRIMARY KEY value in the table causes a duplicate-key error and the statement is aborted. With IGNORE, the row is discarded and no error occurs. Ignored errors generate warnings instead.</p>
</blockquote>
<blockquote>
<p>Several statements in MySQL support an optional IGNORE keyword. This keyword causes the server to downgrade certain types of errors and generate warnings instead.</p>
</blockquote>
<blockquote>
<p>INSERT: With IGNORE, rows that duplicate an existing row on a unique key value are discarded.</p>
</blockquote>
<p>문서 내용에 따르면 <strong><code>INSERT IGNORE</code> 문은 레코드를 먼저 삽입하고 중복 에러가 발생하면 해당 행을 삭제하는 방식으로 동작</strong>합니다. 에러는 경고로 다운그레이드되며 <strong>이는 에러가 발생하지 않는 것이 아니라 에러를 핸들링 하는 것임을 의미</strong>합니다.</p>
<p>실제로 중복된 값에 대해 <code>INSERT IGNORE</code> 를 실행하고 커밋한 후, 새로운 레코드를 삽입했을 때 id 가 하나 건너뛰어 저장되는 것을 확인할 수 있었습니다. (AUTO INCREMENT인 경우)</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/d5087392-e0ac-40c9-8581-e9e9d07c43a9/image.png" alt=""></p>
<p>혹시 일반 <code>INSERT</code> 도 중복 에러가 발생할 경우 동일한 방식으로 동작하는지 확인하고자 실험을 하였습니다.
트랜잭션을 열어둔 상태에서 중복되는 (&#39;place1&#39;, &#39;123.456&#39;, &#39;123.456&#39;) 레코드를 <code>INSERT</code> 해 보았습니다.</p>
<p>이후 락 정보를 확인해보니 <code>INSERT IGNORE</code> 와 동일한 방시으로 락을 거는 것을 확인할 수 있었습니다.
<img src="https://velog.velcdn.com/images/nak-honest/post/9fe31443-ab38-49f4-932b-c801c2be71d3/image.png" alt=""></p>
<p>INSERT INTENTION LOCK과 unique 레코드에 대한 S 락, PK 인덱스에 대한 supremum pseudo record 락을 동일하게 거는 것을 확인할 수 있습니다.</p>
<p>따라서 <strong><code>INSERT IGNORE</code> 에서 걸리는 락은 단순 <code>INSERT</code> 쿼리에서 중복 에러가 발생할 때 걸리는 락과 동일</strong>하다는 결론을 내릴 수 있었습니다.</p>
<h3 id="그렇다면-insert-시-중복-에러가-발생하면-왜-supremum-pseudo-record-락을-거는-것일까">그렇다면 INSERT 시 중복 에러가 발생하면 왜 supremum pseudo record 락을 거는 것일까?</h3>
<p><a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html#innodb-next-key-locks">공식 문서</a>에 따르면 기본적으로 MySQL의 락 메커니즘은 다음과 같습니다.</p>
<blockquote>
<p>InnoDB performs row-level locking in such a way that when it searches or scans a table index, it sets shared or exclusive locks on the index records it encounters. Thus, the row-level locks are actually index-record locks. A next-key lock on an index record also affects the “gap” before that index record. That is, a next-key lock is an index-record lock plus a gap lock on the gap preceding the index record. If one session has a shared or exclusive lock on record R in an index, another session cannot insert a new index record in the gap immediately before R in the index order.</p>
</blockquote>
<p><strong>MySQL은 REPEATABLE READ 수준에서 인덱스를 스캔한 범위만큼 락을 겁니다.</strong> 이를 위해 넥스트 키 락과 갭 락을 사용하여 스캔된 범위를 잠급니다. (단, unique나 PK 인덱스에서 단일 레코드를 스캔하는 경우 레코드 락만 걸립니다.)</p>
<p>따라서 이 메커니즘에 따르면, 삽입 후 삭제를 하면서 PK 인덱스의 끝인 supremum record를 스캔 범위로 보는 것이 아닐까 추측됩니다.</p>
<h4 id="실험-다른-범위에서도-동일한-동작을-보이는지-확인">실험: 다른 범위에서도 동일한 동작을 보이는지 확인</h4>
<p>실제로 다른 범위에 대해서도 동일하게 동작하는지 궁금해서 실험해 보았습니다.</p>
<p>이전처럼 PK 인덱스에 <code>id = 1</code>, <code>id = 3</code>인 레코드가 존재하며, <code>id = 2</code>는 비어 있는 상태입니다.
<img src="https://velog.velcdn.com/images/nak-honest/post/262f106c-b041-451d-8d04-d6b8e6821b85/image.png" alt=""></p>
<p>이 상황에서 <code>id = 2</code> 이면서 다른 레코드와 중복되는 레코드를 삽입하면, <code>id = 1</code>인 레코드와 <code>id = 3</code>인 레코드 사이에 갭 락이 걸릴 것입니다. 즉 id 가 3인 레코드에 갭 락이 걸릴 것입니다.</p>
<p>해당 범위에 삽입 후 삭제를 할 것이기 때문입니다.</p>
<pre><code class="language-sql">INSERT IGNORE place(id, name, latitude, longitude)
VALUES (2, &#39;123.456&#39;, &#39;123.456&#39;, &#39;place1&#39;)</code></pre>
<p>결과적으로, 예상대로 id = 3 레코드에 갭 락이 설정되는 것을 확인할 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/nak-honest/post/256cfbc4-af8e-49fa-b655-d78a532379a4/image.png" alt=""></p>
<h3 id="unique-인덱스에서-넥스트-키-락이-걸리는-이유는">unique 인덱스에서 넥스트 키 락이 걸리는 이유는?</h3>
<p>앞에서 unique 인덱스에서 넥스트 키 락이 걸리는 이유에 대해 설명한다고 했는데, 다음과 같이 추론할 수 있습니다.
unique 인덱스에서는 쿼리 조회 결과가 레코드 1개임을 보장하는 경우에만 레코드 락을 겁니다.
즉 인덱스 스캔 범위가 레코드 1개인 경우에만 레코드 락을 겁니다.</p>
<p>하지만 위의 상황에서는 PK 인덱스에서 레코드를 삽입 후 삭제하게 되면서 인덱스 스캔 범위에 포함되게 됩니다. <strong>즉 인덱스 스캔 범위가 레코드 단 1개를 넘어서게 됩니다.</strong> 따라서 단순 레코드 락이 아닌 넥스트 키 락이 걸린 것임을 추론할 수 있습니다.</p>
<h2 id="insert-ignore-는-생각만큼-우아한-방식이-아니었다">INSERT IGNORE 는 생각만큼 우아한 방식이 아니었다.</h2>
<p>이전 글에서는 <code>INSERT IGNORE</code> 를 우아한 해결책으로 소개했지만, 실제로는 데이터 중복 시 새로운 레코드 삽입을 막아 동시성 저하를 초래할 수 있습니다.</p>
<p>특히 이전 글에서 언급했듯, REPEATABLE READ 격리 수준에서는 매번 INSERT IGNORE를 먼저 호출해야 했습니다. 즉 데이터가 이미 존재하는 경우에도 매번 <code>INSERT IGNORE</code> 를 먼저 호출해야 했습니다.</p>
<p>또한 비즈니스 특성상 인기 있는 장소는 데이터베이스에 이미 존재할 가능성이 높습니다. 그리고 인기 있다는 사실은 곧 경합 가능성이 더 높아질 수 있음을 의미합니다. 이러한 상황에서 매번 <code>INSERT IGNORE</code>를 호출하면 supremum record lock에 의해 순차적으로 실행되며, 결과적으로 동시성이 크게 저하됩니다.</p>
<h3 id="read-committed로-해결-가능하지만">READ COMMITTED로 해결 가능하지만..</h3>
<p>이 문제는 트랜잭션 격리 수준을 READ COMMITTED로 낮추면 해결할 수 있습니다. 하지만 이 경우 데이터 정합성이 떨어질 위험이 있습니다. 즉, 동시성은 개선되지만 데이터 일관성 문제가 발생할 가능성이 커집니다.</p>
<h3 id="더-큰-문제-insert-ignore는-다른-에러도-무시한다">더 큰 문제: INSERT IGNORE는 다른 에러도 무시한다.</h3>
<p><code>INSERT IGNORE</code> 는 단순 중복 에러 뿐만 아니라 다른 에러들에 대해서도 무시하기 때문에 데이터 정합성 문제가 발생할 수 있습니다. 예를들어 NULL 제약조건, 데이터 타입 불일치 등에 대한 에러도 무시할 수 있습니다.</p>
<p>따라서 해당 처리에 대해 인지하고, 경고에 따라 다르게 처리할 필요가 있습니다. 어플리케이션단에서 검증에 대한 책임을 지는것도 방법이 될 수 있을 것 같습니다.</p>
<p>하지만 이러한 단점들을 끌어 안으면서까지 <code>INSERT IGNORE</code>를 사용하는 것은 매력적이지 않다고 생각했습니다.</p>
<h2 id="결론적으로-선택한-방식">결론적으로 선택한 방식</h2>
<p>최종적으로는 <strong>place 테이블을 역정규화</strong>하여 해당 문제를 해결하였습니다.</p>
<p>역정규화 방식은 null 저장이나 데이터 불일치 등의 문제가 발생할 수 있지만, 비즈니스적으로 이러한 상황이 발생할 가능성은 낮다고 판단했습니다.</p>
<p>왜냐하면, 여행기는 과거의 데이터를 기록하는 특성상 장소가 변경되거나 사라져도 이를 반영할 필요가 없다고 생각했기 때문입니다. 즉, 여행지에 대한 데이터는 고정된 상태로 유지되어야 하며, 변경 사항을 반영할 필요가 없다는 점에서 역정규화가 적합하다고 판단했습니다.</p>
<p>또한 place 테이블이 비즈니스적으로 검색이나 평점 등 다른 용도로 활용되지 않기 때문에, 이 방식이 더 나은 선택이라고 결론지었습니다. 물론 미래에는 그러한 요구사항이 추가될 수 있지만, 불확실한 미래 요구사항에 대비해 정규화를 진행하는 것은 시스템의 복잡성만 증가시킨다고 생각했기 때문입니다. 만약 미래에 이러한 요구사항이 생긴다면, 그때 다시 고민하고 조정하면 될 것입니다.</p>
<h1 id="출처">출처</h1>
<ul>
<li><a href="https://dev.mysql.com/doc/refman/8.4/en/insert.html">https://dev.mysql.com/doc/refman/8.4/en/insert.html</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#ignore-effect-on-execution">https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#ignore-effect-on-execution</a></li>
<li><a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html#innodb-next-key-locks">https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html#innodb-next-key-locks</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[동시성 문제(데드락) 해결기 : X 락인데 왜 공유가 가능하지??????]]></title>
            <link>https://velog.io/@nak-honest/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EB%8D%B0%EB%93%9C%EB%9D%BD-%ED%95%B4%EA%B2%B0%EA%B8%B0-X-%EB%9D%BD%EC%9D%B8%EB%8D%B0-%EC%99%9C-%EA%B3%B5%EC%9C%A0%EA%B0%80-%EA%B0%80%EB%8A%A5%ED%95%98%EC%A7%80</link>
            <guid>https://velog.io/@nak-honest/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EB%8D%B0%EB%93%9C%EB%9D%BD-%ED%95%B4%EA%B2%B0%EA%B8%B0-X-%EB%9D%BD%EC%9D%B8%EB%8D%B0-%EC%99%9C-%EA%B3%B5%EC%9C%A0%EA%B0%80-%EA%B0%80%EB%8A%A5%ED%95%98%EC%A7%80</guid>
            <pubDate>Wed, 30 Oct 2024 14:01:53 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 팀 프로젝트에서 겪은 동시성 문제와 MySQL의 락을 사용하다가 경험한 데드락에 관해 이야기합니다. 특히 <strong>갭 락은 X 락이더라도 공유가 가능하므로 데드락이 발생할 수 있다는 사실</strong>을 강조합니다.</p>
<p>이 과정에서 MySQL의 락 메커니즘을 깊이 있게 다룹니다. 그리고 이 문제를 락을 사용하지 않고 어떻게 해결했는지 공유합니다.</p>
<p>이 글을 통해 MySQL의 락 메커니즘을 이해하고 락을 사용할 때 발생할 수 있는 데드락의 원인을 파악할 수 있습니다. MySQL의 락을 더욱 안전하게 활용하는 데 도움이 되기를 바랍니다.</p>
<h2 id="문제-발생-상황">문제 발생 상황</h2>
<p>팀에서 여행기 장소를 조회하고 저장하는 기능을 개발하던 중, 여러 사용자가 동시에 장소를 저장할 때 중복된 장소가 저장되는 문제를 겪었습니다.</p>
<p>문제 설명 전 알아둘 프로젝트의 사전 지식은 다음과 같습니다.</p>
<ul>
<li>현재 진행 중인 프로젝트는 다녀온 여행기를 기록할 수 있는 서비스입니다.</li>
<li>여행기에 사용되는 여행 장소는 여러 여행기에서 등장할 수 있습니다.</li>
<li>따라서 여행 장소는 <code>place</code> 라는 테이블 하나로 관리되고, 여러 여행기가 이 장소를 공유합니다.</li>
</ul>
<p>이 상황에서 문제가 발생한 코드는 다음과 같습니다:</p>
<pre><code class="language-java">    @Transactional
    public Place getPlace(PlanPlaceCreateRequest planRequest) {
        return placeRepository.findByNameAndLatitudeAndLongitude(
                planRequest.placeName(),
                planRequest.position().lat(),
                planRequest.position().lng()
        ).orElseGet(() -&gt; placeRepository.save(planRequest.toPlace()));
    }</code></pre>
<p><code>findByNameAndLatitudeAndLongitude</code> 메소드는 다음과 같이 정의되어 있습니다:</p>
<pre><code class="language-java">public interface PlaceRepository extends JpaRepository&lt;Place, Long&gt; {
    Optional&lt;Place&gt; findByNameAndLatitudeAndLongitude(String name, String lat, String lng);
}</code></pre>
<p>위의 코드는 다음과 같이 동작합니다.</p>
<ol>
<li>사용자가 여행기 작성을 요청하면 각 여행기 장소가 존재하는지 확인합니다. 존재한다면 바로 반환합니다.</li>
<li>만약 존재하지 않는다면 DB에 새로 추가하고 반환합니다.</li>
</ol>
<p>장소를 저장할 때 해당 장소가 존재하는지 미리 확인하기 때문에 중복으로 저장되지 않을 것이라 예상했습니다. 하지만 <strong>여러 명이 동시에 호출하면 중복 저장이 되는 문제</strong>가 발생했습니다. </p>
<h2 id="문제-재현">문제 재현</h2>
<p>테스트 코드를 통해 문제를 재현해 보면 다음과 같습니다:</p>
<pre><code class="language-java">    @Test
    void createTraveloguePlacesWithConcurrency() throws InterruptedException {
        TraveloguePlaceRequest request = new TraveloguePlaceRequest(...);

        // 스레드 10개 생성
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        // 10개의 스레드가 동시에 getPlace() 호출
        for (int i = 0; i &lt; 10; i++) {
            executorService.execute(() -&gt; traveloguePlaceService.getPlace(request));
        }

        // 모든 작업이 끝날 때까지 최대 30초 대기
        executorService.shutdown();
        executorService.awaitTermination(30, TimeUnit.SECONDS);

        String placeName = request.placeName();
        TraveloguePositionRequest position = request.position();

        // DB에 해당 place 가 하나만 저장되었는지 확인
        assertThat(placeRepository.findByNameAndLatitudeAndLongitude(placeName, position.lat(), position.lng()))
                .isPresent();
    }</code></pre>
<p>위 테스트에서 10개의 스레드가 동시에 <code>getPlace</code>를 호출한 후 <code>findByNameAndLatitudeAndLongitude</code> 메소드로 해당 장소가 1개만 저장되었는지 확인했습니다. 
하지만 다음과 같은 예외가 발생합니다:</p>
<pre><code>Query did not return a unique result: 10 results were returned</code></pre><p>이는 동일한 장소가 중복으로 10개 저장되어서 발생한 문제였습니다.
실제로 DB를 확인해 보면 다음과 같이 중복된 장소가 10개 저장된 것을 확인할 수 있습니다:</p>
<img src="https://i.imgur.com/gMbzkiW.png" width="600" />

<p><strong>원인은 모든 스레드가 해당 장소를 조회할 때 존재하지 않는다는 결과를 받고, 동시에 <code>save</code> 를 호출해서 발생한 것이었습니다.</strong></p>
<p>처음에는 해당 문제를 <strong>MySQL에서 제공해 주는 락</strong>을 이용해서 해결하려 했습니다.
하지만 그 과정에서 데드락(교착상태)이 발생하여 해당 방법으로는 문제를 해결할 수 없었습니다.</p>
<p>왜 데드락이 발생했는지 살펴보기 전에, 먼저 MySQL에서 제공해 주는 S/X 락에 대해 간략하게 설명하겠습니다.</p>
<h2 id="mysql에서의-락---s락x락">MySQL에서의 락 - S락/X락</h2>
<h3 id="s-락-shared-lock">S 락 (Shared Lock)</h3>
<p><strong>S 락</strong>(공유 락)은 읽기 락이라고도 불리며, <strong>여러 트랜잭션에서 동시에 획득할 수 있는 락</strong>입니다.</p>
<p>예를 들어, 트랜잭션 A가 S 락을 획득한 상태에서 트랜잭션 B도 S 락을 동시에 획득할 수 있습니다.
이렇게 S 락은 트랜잭션 간에 공유가 가능하므로 여러 트랜잭션이 동시에 데이터를 읽을 수 있도록 허용됩니다.</p>
<p>S 락은 SELECT 문에서 사용됩니다. S 락을 명시적으로 걸고 싶을 때는 다음과 같이 <code>SELECT ... FOR SHARE</code>를 사용하면 됩니다:</p>
<pre><code class="language-sql">SELECT * FROM place WHERE ... FOR SHARE;</code></pre>
<p>참고로 <code>FOR SHARE</code>를 붙이지 않은 일반적인 <code>SELECT</code> 문은 아무런 락을 걸지 않고 데이터를 조회합니다.
이 경우 다른 트랜잭션에서 락을 걸어 둔 상태에서도 데이터를 읽을 수 있습니다.</p>
<h3 id="x-락-exclusive-lock">X 락 (<strong>Exclusive Lock)</strong></h3>
<p><strong>X 락</strong>(배타 락)은 쓰기 락이라고도 불리며, 이름 그대로 <strong>배타적으로만 사용할 수 있는 락</strong>입니다.</p>
<p>즉, 트랜잭션 A가 X 락을 획득한 상태에서는 다른 트랜잭션 B가 <strong>어떠한 락(S락, X락)도</strong> 획득할 수 없습니다.
반대로 트랜잭션 A가 S 락을 획득한 상태에서도 트랜잭션 B가 <strong>X 락</strong>을 획득할 수 없습니다.</p>
<p>X 락은 주로 <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 같은 쓰기 작업을 수행할 때 자동으로 설정됩니다.
만약 <code>SELECT</code> 문에서 명시적으로 X 락을 걸고 싶다면, 다음과 같이 <code>FOR UPDATE</code>를 사용할 수 있습니다:</p>
<pre><code class="language-sql">SELECT * FROM place WHERE ... FOR UPDATE;</code></pre>
<p><code>FOR UPDATE</code>를 사용하면 해당 데이터를 읽어오는 동시에 X 락이 걸려, 다른 트랜잭션에서 S 락 또는 X 락을 획득하지 못하도록 방지할 수 있습니다.</p>
<p>표로 정리해 보면 다음과 같습니다:</p>
<table>
<thead>
<tr>
<th></th>
<th><strong>S-lock 요청</strong></th>
<th><strong>X-lock 요청</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>S-lock 보유</strong></td>
<td>허용</td>
<td>거부</td>
</tr>
<tr>
<td><strong>X-lock 보유</strong></td>
<td>거부</td>
<td>거부</td>
</tr>
</tbody></table>
<h2 id="s-락을-걸어보자">S 락을 걸어보자</h2>
<p>처음에는 <code>Place</code> 테이블을 조회할 때 S 락(공유 락)을 사용하면 동시성 문제를 해결할 수 있을 것이라 기대했습니다.</p>
<p>이유는 다음과 같습니다. 
만약 한 트랜잭션에서 <code>Place</code>를 조회한 후 존재하지 않아 <code>INSERT</code>를 수행하면, X 락(배타 락)이 걸리기 때문에 다른 트랜잭션에서 해당 레코드를 읽지 못할 것으로 생각했기 때문입니다.</p>
<p>이에 따라 다음과 같이 <code>SELECT</code> 시 S 락을 걸도록 설정하였습니다:</p>
<pre><code class="language-java">public interface PlaceRepository extends JpaRepository&lt;Place, Long&gt; {
    @Lock(LockModeType.PESSIMISTIC_READ)
    Optional&lt;Place&gt; findByNameAndLatitudeAndLongitude(String name, String lat, String lng);
}</code></pre>
<h3 id="데드락-발생">데드락 발생</h3>
<p>하지만, 이전과 동일한 테스트를 수행해 보니 여전히 다음과 같은 <strong>데드락</strong>이 발생했습니다:</p>
<pre><code>Exception in thread &quot;pool-3-thread-4&quot; org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [insert into place (created_at,deleted_at,google_place_id,latitude,longitude,modified_at,name) values (?,?,?,?,?,?,?)]; SQL [insert into place (created_at,deleted_at,google_place_id,latitude,longitude,modified_at,name) values (?,?,?,?,?,?,?)]</code></pre><p>지금부터 데드락이 발생한 원인을 살펴보겠습니다.</p>
<p>위의 상황에서 10개의 스레드가 동시에 <code>getPlace</code> 메서드를 호출하면, <code>findByNameAndLatitudeAndLongitude</code>를 통해 각 스레드가 S 락을 동시에 획득합니다.</p>
<p>이후 각 스레드가 <code>save</code> 메서드를 호출하여 X 락을 요청하지만, 이미 다른 스레드들이 S 락을 보유하고 있어 X 락을 획득할 수 없는 상태가 됩니다.</p>
<p>즉, <strong>10개의 스레드가 모두 S 락을 획득한 채 서로 X 락을 기다리는 데드락에 빠져, 모든 스레드가 대기하는 상황이 발생한 것입니다.</strong></p>
<h4 id="참고-사항">참고 사항</h4>
<blockquote>
<p><code>(name, latitude, longitude)</code> 컬럼에 인덱스가 설정되어 있는지에 따라 데드락의 원인이 다소 달라집니다. 위의 상황에서 인덱스가 없는 경우 테이블 전체(정확히는 기본 키(PK) 인덱스 전체)에 락이 걸리며, 인덱스가 있는 경우 갭 락이 발생하게 됩니다.</p>
<p>우선 지금은 S 락이 공유 가능하다는 특징만 알고 있어도 충분하므로, 갭 락에 대해서는 뒤에서 설명하겠습니다.</p>
</blockquote>
<h2 id="그렇다면-x-락을-걸어보자">그렇다면 X 락을 걸어보자</h2>
<h3 id="인덱스가-없으면-잘-동작한다">인덱스가 없으면 잘 동작한다.</h3>
<p>앞에서 여러 스레드가 동시에 S 락을 획득하면서 데드락이 발생하는 상황을 확인했습니다. 그렇다면 만약 SELECT 시 X 락을 걸게 된다면 결과는 어떻게 될까요?</p>
<p>조회 시 X 락을 거는 것은 동시성을 크게 저하할 수 있어 신중히 사용해야 하지만, 현재 문제를 해결할 수 있는지 확인하기 위해 X 락을 설정해 보았습니다:</p>
<pre><code class="language-java">public interface PlaceRepository extends JpaRepository&lt;Place, Long&gt; {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional&lt;Place&gt; findByNameAndLatitudeAndLongitude(String name, String lat, String lng);
}</code></pre>
<p>이렇게 설정하면 10개의 스레드가 동시에 <code>findByNameAndLatitudeAndLongitude</code>를 호출하더라도, X 락은 하나의 스레드만 획득하게 되고 나머지 9개 스레드는 락을 얻지 못한 상태에서 대기하게 됩니다.</p>
<p>이후 X 락을 획득한 스레드는 <code>save</code> 메서드를 호출할 때 다른 스레드가 추가적인 락을 걸지 않았기 때문에 저장에 성공할 수 있습니다. 이후 commit 시 X 락이 해제되고, 대기 중인 다른 스레드 중 하나가 X 락을 얻게 되면서 데드락이 발생하지 않게 됩니다.</p>
<p>(테이블이 비어 있는 상황에서는 데드락이 발생하는데, 해당 내용은 뒤에서 자세히 설명합니다.)</p>
<h3 id="인덱스를-걸면-데드락이-발생한다">인덱스를 걸면 데드락이 발생한다.</h3>
<p>우선 <code>(name, latitude, longitude)</code> 컬럼에 다음과 같이 인덱스를 추가했습니다:</p>
<pre><code class="language-sql">create index place_idx on place(name, latitude, longitude);</code></pre>
<p>이후 테스트를 진행해 보니, 데드락이 발생했습니다. 
분명 X 락은 동시에 획득할 수 없다고 했는데, 왜 이런 문제가 생겼을까요?</p>
<p>저는 데드락의 원인이 이해되지 않아 MySQL 서버에서 직접 실험해 보았습니다.
콘솔 2개를 열고 다음 명령어들을 실행해 보았습니다:</p>
<pre><code class="language-sql">start transaction;  

SELECT *  
FROM place  
WHERE name = &#39;place1&#39;  
  AND latitude = &#39;12.345&#39;  
  AND longitude = &#39;12.345&#39;  
FOR UPDATE;  

insert into place(created_at, name, latitude, longitude)  
    value (&#39;2024-01-01&#39;, &#39;place1&#39;, &#39;12.345&#39;, &#39;12.345&#39;);  

rollback;</code></pre>
<p>MySQL의 락은 트랜잭션이 커밋되거나 롤백될 때 해제됩니다. 따라서 각 콘솔에서 트랜잭션을 시작했습니다. 그 후 두 트랜잭션에서 동시에 <code>SELECT</code> 문을 호출하였습니다.</p>
<p>트랜잭션 A가 <code>SELECT ... FOR UPDATE</code> 쿼리를 먼저 실행하면 X 락이 걸고 조회에 성공합니다.
트랜잭션 B가 같은 <code>SELECT ... FOR UPDATE</code> 쿼리를 실행하면 A가 이미 X 락을 가지고 있기 때문에 대기할 것이라 예상했습니다.</p>
<p>하지만 예상과 달리 트랜잭션 B도 <code>SELECT</code> 문을 실행하자마자 결과가 바로 반환되었습니다.
혹시 락이 걸리지 않은 것인지 확인하기 위해 다음 명령어로 락 정보를 확인하였습니다:</p>
<pre><code class="language-sql">SELECT * FROM performance_schema.data_locks;</code></pre>
<p>결과는 다음과 같았습니다:</p>
<p><img src="https://i.imgur.com/BVkfm8r.png" alt=""></p>
<p>두 트랜잭션이 <code>X, GAP</code> 락을 동시에 획득한 것을 볼 수 있습니다.</p>
<h3 id="락은-인덱스와-밀접한-관계가-있다">락은 인덱스와 밀접한 관계가 있다.</h3>
<p>분명 X 락은 동시에 획득할 수 없다고 말했는데, 왜 이러한 결과가 나온 것일까요?</p>
<p>이를 위해서는 먼저 락과 인덱스 사이의 관계를 이해해야 합니다.</p>
<p><a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html">MySQL 공식 문서[1]</a>를 보면, SQL 은 S/X 락을 걸 때 레코드 단위로 락을 건다고 나와 있습니다.
좀 더 정확하게 설명하면, <strong>SQL 문을 실행할 때 스캔 되는 모든 인덱스 레코드에 락을 겁니다.</strong></p>
<p>매번 락을 걸 때마다 테이블 전체에 락을 걸면 동시성이 매우 떨어지기 때문에, MySQL에서는 인덱스를 활용한 레코드 기반의 락 메커니즘을 제공합니다.</p>
<p>하지만 SQL 문 실행 시 인덱스의 레코드를 단 하나도 스캔하지 못한다면 어떻게 될까요? 이 경우 <strong>MySQL은 팬텀 리드를 방지하기 위해 갭 락(또는 Supremum pseudo-record 락)을 걸게 됩니다.</strong>
이는 다음의 상황을 방지하기 위함입니다.</p>
<p><img src="https://i.imgur.com/zzqO1m0.png" alt=""></p>
<p>트랜잭션 A가 <code>(&#39;place1&#39;, &#39;12.345&#39;, &#39;12.345&#39;)</code>를 조회할 때 락을 걸며 데이터를 읽습니다. 
이때 다른 트랜잭션 B가 같은 <code>(&#39;place1&#39;, &#39;12.345&#39;, &#39;12.345&#39;)</code>를 삽입한다고 가정해 봅시다. 
만약 트랜잭션 A가 다시 <code>(&#39;place1&#39;, &#39;12.345&#39;, &#39;12.345&#39;)</code>를 조회하면, 트랜잭션 B가 방금 삽입한 결과를 얻게 됩니다.</p>
<p>위처럼 조회한 결과에서 레코드가 새로 추가되거나 삭제되는 현상을 팬텀 리드(Phantom Read)라고 합니다. 
(참고로 MySQL은 <code>REPEATABLE READ</code> 이상의 격리 수준에서만 팬텀 리드를 방지하기 위해 갭 락을 겁니다.)</p>
<p>그렇다면 갭 락에 대해 자세히 살펴보도록 하겠습니다.</p>
<h3 id="갭-락과-supremum-pseudo-record-락">갭 락과 Supremum pseudo-record 락</h3>
<p><strong>갭 락(Gap Lock)은 두 인덱스 레코드 사이의 간격에 대해 걸리는 락으로, 특정 구간에 새로운 레코드가 삽입되지 않도록 막는 락입니다.</strong></p>
<p>프로젝트는 조금 복잡하기 때문에 조금 더 쉬운 예제를 통해 설명하겠습니다.</p>
<p><img src="https://i.imgur.com/rG78K1q.png" alt=""></p>
<p>위처럼 age에 대한 인덱스가 있습니다. 그리고 레코드는 총 3개의 레코드만 존재합니다.</p>
<p>이 상황에서 각 레코드에 대한 갭 락은 다음과 같습니다.
<img src="https://i.imgur.com/WTuHRK5.png" alt=""></p>
<ul>
<li><strong>age가 20인 레코드(첫 번째 레코드)</strong> 에 대한 갭 락은 해당 레코드의 왼쪽에 락을 거는 것입니다. 그림에서 1번 구간을 의미합니다. 예를 들어 age = 18인 레코드가 삽입되는 것을 막습니다.</li>
<li><strong>age가 22인 레코드(두 번째 레코드)</strong> 에 대한 갭 락은 첫 번째와 두 번째 레코드 사이에 락을 거는 것입니다. 그림에서 2번 구간에 락을 겁니다. 예를 들어 age = 21인 레코드가 삽입되는 것을 막습니다.</li>
<li><strong>age가 25인 레코드(세 번째 레코드)</strong> 에 대한 갭 락은 두 번째와 세 번째 레코드 사이에 락을 거는 것입니다. 그림에서 3번 구간에 락을 겁니다. 예를 들어 age = 23인 레코드가 삽입되는 것을 막습니다.</li>
</ul>
<p>그렇다면 <strong>age가 25인 레코드(세 번째 레코드)</strong> 의 오른쪽에 다른 레코드가 삽입되는 것을 막기 위해서는 어떻게 해야 할까요? 즉 4번 구간에 락을 걸고 싶다면 어떻게 해야 할까요?
<strong>이를 위해 거는 것이 바로 supremum pseudo-record 락입니다.</strong></p>
<p><strong>supremum pseudo-record 락은 InnoDB 인덱스에서 가장 큰 레코드보다 큰 값이 삽입되지 않도록 막는 락입니다.</strong>
앞에서 본 것처럼 갭 락은 특정 레코드 앞의 간격(왼쪽)에 대해서만 잠금을 걸 수 있기 때문에 인덱스의 가장 큰 레코드 이후의 간격에는 갭 락을 걸 수 없습니다.  </p>
<p>따라서 인덱스의 가장 큰 레코드 이후의 값 삽입을 막기 위해 supremum pseudo-record 락을 사용합니다. 이 락은 인덱스의 끝을 나타내는 허수 레코드에 갭 락을 거는 방식으로 동작합니다. 
허수 레코드는 개념적인 레코드로, 실제로는 존재하지 않는 레코드입니다. 이는 인덱스의 마지막 위치를 가정하고 그 위치에 갭 락을 걸어 해당 구간에 대한 삽입을 방지합니다.</p>
<details>
<summary><h4>참고 : 갭 락이 어떻게 팬텀 리드를 방지하지?</h4></summary>
위의 예제에서 age가 23인 레코드를 X 락과 함께 조회한다고 해보겠습니다:

<pre><code class="language-sql">SELECT * FROM ... WHERE age = 23 FOR UPDATE</code></pre>
<p>이 쿼리를 실행하면 3번 구간에 갭 락이 걸리게 됩니다. 
다른 트랜잭션은 이 갭 락 때문에 age가 23인 레코드를 삽입하지 못하게 되고, 이에 따라 팬텀 리드가 발생하지 않게 됩니다.</p>
<p>하지만 age가 23인 레코드뿐만 아니라 age가 24인 레코드도 삽입할 수 없게 됩니다. 동시성이 떨어지는 방식이라고 생각할 수 있지만, 이는 MySQL이 특정 컬럼 값에 대해서만 락을 걸지 못하기 때문입니다. MySQL의 락 메커니즘은 레코드 락과 갭 락을 조합하여 사용하므로 다소 비효율적으로 보이더라도 갭 락을 통해 팬텀 리드를 방지하는 것입니다.</p>
</details>

<h3 id="갭-락과-supremum-pseudo-record-락은-공유가-가능하다">갭 락과 supremum pseudo-record 락은 공유가 가능하다.</h3>
<p>문제는 갭 락과 supremum pseudo-record 락이 서로 공유할 수 있다는 점입니다. 
<a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html">MySQL 공식 문서[1]</a>에 따르면, 갭 락의 주목적은 데이터를 삽입하는 것을 방지하는 것이기 때문에 서로 충돌하지 않고 공유가 가능합니다. 즉 insert가 되는 것만 막지, 같은 갭 락을 획득하는 것은 막지 않는다는 것입니다.</p>
<p>따라서 X 락을 걸어도 인덱스에 해당 레코드가 없다면 갭 락이 걸리게 되고, 이 갭 락은 여러 트랜잭션이 동시에 획득할 수 있어 데드락이 발생한 것입니다.</p>
<p>앞에서 보았듯이 <code>(name, latitude, longitude)</code> 컬럼에 인덱스가 없으면 데드락이 발생하지 않습니다.
이는 인덱스가 없는 경우 PK 인덱스를 스캔하기 때문입니다.</p>
<p>PK 인덱스를 스캔할 때는 모든 범위를 스캔하기 때문에 전체에 락을 걸게 됩니다. 이에 따라 여러 트랜잭션이 동시에 해당 락을 획득할 수 없게 됩니다. </p>
<p>좀 더 정확히 설명하자면 각 레코드에 넥스트 키 락이 걸립니다. 여기서 넥스트 키 락이란 레코드 락과 갭 락이 결합한 것입니다. 레코드 자체를 잠그는 레코르 락과, 해당 레코드 왼쪽에 대한 삽입을 방지하는 갭 락이 동시에 걸리는 것을 의미합니다. 넥스트 키 락에 대한 설명이 추가되면 내용이 길어질 수 있으므로 여기서는 간단한 개념만 짚고 넘어가겠습니다.</p>
<p>결론적으로 PK 인덱스에서는 모든 레코드와 각 레코드 사이의 간격이 전부 잠기게 됩니다. 그리고 레코드 락은 X 락으로 설정될 때 서로 공유할 수 없기 때문에 위의 상황에서 데드락이 발생하지 않습니다. </p>
<p>하지만 테이블이 비어 있는 경우에는 데드락이 발생합니다. PK 인덱스를 스캔해도 결과가 나오지 않게 되며, 이때 supremum pseudo-record 락이 걸리면서 여러 트랜잭션이 동시에 획득할 수 있게 됩니다:</p>
<p><img src="https://i.imgur.com/fB6J4zc.png" alt=""></p>
<p>따라서 데드락이 발생하게 됩니다.</p>
<h2 id="그렇다면-인덱스-없이-x-락을-걸면-해결인-것일까">그렇다면 인덱스 없이 X 락을 걸면 해결인 것일까?</h2>
<p>그렇다면 인덱스 없이 X 락을 걸면 동시성 문제가 해결된 것일까요? 
테이블이 비어 있는 경우에는 인덱스가 없더라도 데드락이 발생할 수 있지만, 실제 프로덕션 환경에서 테이블이 비어 있을 확률은 매우 낮으므로 이는 큰 고려 대상이 아닙니다.</p>
<p>하지만 다음과 같은 큰 단점을 안고 가야 합니다.</p>
<ul>
<li>해당 컬럼에 인덱스를 더 이상 걸 수 없게 됩니다. </li>
<li>unique를 걸게 되면 인덱스가 생성되기 때문에, 해당 컬럼들에 대해 unique 제약 조건도 걸 수 없게 됩니다. </li>
<li><code>SELECT ... FOR UPDATE</code>는 동시성이 매우 떨어지는 방식이기 때문에 성능 저하 문제도 발생할 수 있습니다.</li>
</ul>
<p>위의 단점들이 치명적이라 생각했기 때문에 다른 방식도 고려했지만, 다음과 같은 이유로 우아하지 않다고 생각했습니다.</p>
<ul>
<li><code>READ_UNCOMMITED</code> 와 함께 어플리케이션 단에서 <code>synchronized</code> 사용<ul>
<li><code>READ_UNCOMMITED</code> 는 가장 낮은 격리 수준이므로 데이터 정합성이 깨질 가능성이 높습니다.</li>
<li><code>synchronized</code> 는 성능 저하 문제가 있고, 서버가 다중화되면 사용하기 어려워집니다.</li>
</ul>
</li>
<li>unique 제약 조건을 걸고 충돌 시 전체 재시도<ul>
<li>여행기 장소 저장에 실패할 때, 여행기 작성을 처음부터 전부 다시 해야 합니다. 이미지 관련 처리도 포함되어 있기 때문에 성능 저하가 발생할 수 있습니다.</li>
</ul>
</li>
<li>전체 재시도가 아닌 <code>REQUIRES_NEW</code> 를 이용해 부분 재시도<ul>
<li>테스트에서 커넥션 풀 부족으로 인한 데드락 문제가 발생했습니다. 물론 이는 히카리 풀 사이즈를 조정하면 데드락 문제를 예방하는 것이 가능합니다.</li>
<li><code>REQUIRES_NEW</code> 를 사용하는 것은 코드의 복잡성을 증가시키고 인지 비용을 발생시킵니다.</li>
</ul>
</li>
</ul>
<h2 id="다른-해결-방법---insert-ignore">다른 해결 방법 - <code>INSERT IGNORE</code></h2>
<p>다른 방법들을 추가로 모색하던 중 <code>INSERT IGNORE</code>에 대해 알게 되었습니다. 이 쿼리는 중복된 unique key 또는 primary key를 삽입하려고 할 때 해당 삽입을 무시하고 에러를 발생시키지 않는 쿼리입니다.</p>
<p>이 방식을 사용하면 추가적인 락을 걸지 않고, unique 제약 조건이 충돌해도 롤백 및 재시도가 필요 없으므로 성능 저하를 방지할 수 있습니다. 또한 트랜잭션 격리 수준을 낮추지 않아도 되기 때문에 데이터의 정합성도 여전히 유지할 수 있습니다.</p>
<h3 id="insert-ignore-사용-시-주의할-점"><code>INSERT IGNORE</code> 사용 시 주의할 점</h3>
<p>JPQL이나 QueryDSL에서는 <code>INSERT IGNORE</code>를 직접 지원하지 않으므로, 다음과 같이 Native Query를 사용해야 합니다:</p>
<pre><code class="language-java">@Modifying(clearAutomatically = true)  
@Transactional  
@Query(value = &quot;INSERT IGNORE INTO place (name, latitude, longitude) VALUES (:name, :latitude, :longitude)&quot;, nativeQuery = true)
int saveWithoutDuplication(String name, String lat, String lng);</code></pre>
<p>이 메소드는 int를 반환합니다. 이는 삽입된 레코드의 id가 아닌 삽입 성공 여부를 나타냅니다. 따라서 삽입된 데이터의 id를 얻으려면 추가적인 조회가 필요합니다.</p>
<p>이 과정에서 주의할 점이 있습니다. 첫 번째 조회에서 데이터가 존재하지 않아 <code>INSERT IGNORE</code>를 실행하는 동안, 다른 트랜잭션이 동일한 데이터를 먼저 삽입할 수 있다는 점입니다. 이 경우 두 번째 조회에서도 데이터가 존재하지 않는다는 결과를 반환합니다. 조회 -&gt; 저장 -&gt; 조회 에서 <code>REPEATABLE READ</code> 격리 수준이 적용될 경우, 첫 번째 조회와 두 번째 조회가 동일하게 유지되어 다른 트랜잭션이 삽입한 레코드를 볼 수 없기 때문입니다.</p>
<p>이를 방지하기 위해 첫 번째 조회를 생략하고, 데이터의 존재 여부와 관계없이 매번 INSERT IGNORE를 먼저 실행하는 방식으로 접근해야 합니다.</p>
<p>매번 INSERT 쿼리를 실행하는 것이 성능을 떨어뜨릴 수는 있지만, 여행기를 저장하는 과정에서 호출되는 것이기 때문에 크게 비효율적이지는 않다고 판단했습니다.</p>
<p>실제로 테스트를 진행해 본 결과, 데드락이나 unique 충돌 없이 동시성 문제를 효과적으로 해결한 것을 확인할 수 있었습니다.</p>
<img src="https://i.imgur.com/VtZbxuH.png" width="600" />

<p>결론적으로, <code>INSERT IGNORE</code>를 활용해 동시성 문제를 해결할 수 있었습니다.</p>
<h2 id="마무리">마무리</h2>
<p>이 글을 통해 MySQL의 동시성 문제와 관련된 다양한 이슈를 살펴보았습니다. 특히 S 락과 X 락의 동작 원리, 여러 스레드가 동시에 S 락을 보유할 때 발생하는 데드락, 그리고 갭 락의 공유 가능성에 대한 내용을 설명하였습니다. 또한 <code>INSERT IGNORE</code>를 활용한 해결책이 중복된 unique 키나 기본 키 삽입 시 성능 저하 없이 에러를 방지하며 데이터의 정합성을 유지할 수 있는 효과적인 방법임을 확인했습니다. </p>
<p>이러한 경험이 여러분이 직면할 수 있는 유사한 문제를 해결하는 데 큰 도움이 되길 바랍니다. 
질문이나 잘못된 내용이 있다면 언제든지 편하게 말씀해 주세요!</p>
<h2 id="20250405-추가">2025.04.05 추가</h2>
<p><code>INSERT IGNORE</code>의 문제점을 발견해 다른 방식으로 개선하였습니다. 관련해서는 다음 글을 참고해주세요.</p>
<p><a href="https://velog.io/@nak-honest/MySQL-INSERT-IGNORE-%EB%8A%94-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A4%91%EB%B3%B5-%EC%8B%9C-%EC%99%9C-%EC%83%88%EB%A1%9C%EC%9A%B4-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%9D%98-%EC%82%BD%EC%9E%85%EC%9D%84-%EB%A7%89%EC%9D%84%EA%B9%8C">[MySQL] INSERT IGNORE 는 데이터 중복 시 왜 새로운 데이터의 삽입을 막을까?</a></p>
<h2 id="참고-자료">참고 자료</h2>
<p>[1] <a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html">https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html</a></p>
]]></description>
        </item>
    </channel>
</rss>