<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>se0o.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Wed, 29 Apr 2026 01:24:34 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>se0o.log</title>
            <url>https://velog.velcdn.com/images/se0o_129/profile/fc8cc19b-8d99-4f67-b8e6-83599d65d20b/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. se0o.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/se0o_129" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[세션에서 JWT + Redis 기반 인증으로 전환하기]]></title>
            <link>https://velog.io/@se0o_129/session-to-jwt-redis-authentication</link>
            <guid>https://velog.io/@se0o_129/session-to-jwt-redis-authentication</guid>
            <pubDate>Wed, 29 Apr 2026 01:24:34 GMT</pubDate>
            <description><![CDATA[<h3 id="0-시작하며">0. 시작하며</h3>
<p>세션 방식으로 구현되어 있던 로그인 방식을
JWT로 전환하고 Redis 적용하는 과정을 기록해보고자 한다.</p>
<h3 id="1-세션에서-jwt로-전환">1. 세션에서 JWT로 전환</h3>
<p><strong>세션</strong>은 브라우저에 세션 ID를 저장하고 서버 메모리에 사용자 정보를 저장하는 <strong>stateful</strong> 방식이다. 
이 구조는 두 가지 문제가 있다.</p>
<ol>
<li><p>서버를 재시작하면 메모리에 저장된 세션이 모두 사라져 전체 로그아웃이 발생한다. </p>
</li>
<li><p>서버가 여러 대로 확장될 경우 서버마다 세션이 따로 존재하기 때문에 로드밸런서가 다른 서버로 요청을 보내면 로그인 상태가 유지되지 않는다.</p>
</li>
</ol>
<p><strong>JWT</strong>는 서버가 아무것도 저장하지 않는 <strong>stateless</strong> 방식이다.
토큰 자체에 사용자 정보가 담겨 있어 어느 서버로 요청이 가든 토큰 검증만으로 인증이 가능하다. </p>
<p>그리고 서버가 상태를 저장하지 않기 때문에 유저가 늘어나도 서버 메모리 부담이 없다.</p>
<p>현재는 단일 서버 환경이지만 실제 서비스 운영을 고려했을 때 
확장성을 고려한 구조를 선택하고자 JWT로 전환하기로 했다.</p>
<br>
<br>

<h3 id="2-토큰-저장-전략">2. 토큰 저장 전략</h3>
<p>JWT는 <strong>Access Token</strong>과 <strong>Refresh Token</strong> 두 가지로 구성된다.</p>
<ul>
<li><strong>Access Token</strong> : API 요청 시 인증에 사용, 탈취 피해를 최소화하기 위해 만료 시간을 짧게 설정</li>
<li><strong>Refresh Token</strong> : Access Token 만료 시 재로그인 없이 재발급받기 위한 토큰, 만료 시간이 길어 탈취 시 피해가 크다</li>
</ul>
<p>짧은 토큰으로 보안을 챙기고 긴 토큰으로 편의성을 챙기는 역할 분리다.</p>
<hr>
<p>토큰 저장 위치는 두 가지 공격을 기준으로 결정했다.</p>
<ul>
<li><strong>XSS</strong> : 악성 스크립트로 JS에 접근해 토큰을 탈취하는 공격</li>
<li><strong>CSRF</strong> : 브라우저의 쿠키 자동 전송을 악용해 피해자 권한으로 요청을 위조하는 공격</li>
</ul>
<p><strong>Access Token → 클라이언트 메모리</strong>
localStorage는 XSS에 취약하고, 쿠키는 CSRF 위험이 있다. 
클라이언트 메모리도 XSS에 완전히 안전하진 않지만, XSS 방어는 토큰 저장 위치가 아닌 입력값 검증 등 별도 레이어에서 처리해야 할 문제다. 
클라이언트 메모리에 저장하고 Authorization 헤더로 직접 전송하면 브라우저 자동 전송이 없어 CSRF를 원천 차단할 수 있다는 점에서 이 방식을 선택했다.</p>
<p><strong>Refresh Token → HttpOnly 쿠키 + Redis</strong>
수명이 길어 탈취 시 피해가 크기 때문에 JS 접근을 차단하는 HttpOnly 쿠키에 저장하기로 했다. 
CSRF 위험은 CORS 정책으로 공격자가 응답을 읽을 수 없어 방어된다.
하지만 쿠키는 브라우저가 들고 있어서 서버가 직접 삭제할 수 없다. 
로그아웃해도 공격자가 복사해둔 토큰으로 재발급이 가능하다는 문제가 있기 때문에 Redis에 함께 저장하기로 했다. 
재발급 요청했을 때 쿠키 값과 Redis 값을 비교하고, 로그아웃 시 Redis에서 삭제해서 토큰을 즉시 무효화할 수 있도록 했다.
<br>
<br></p>
<h3 id="3-로그인--토큰-발급--로그아웃-플로우">3. 로그인 / 토큰 발급 / 로그아웃 플로우</h3>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/170e6dd9-cf53-4a81-8f30-8b4e0017fa52/image.png" alt=""></p>
<br>
<br>

<h3 id="4-구현--핵심-코드-설명">4. 구현 : 핵심 코드 설명</h3>
<h4 id="1-jwtprovider-토큰-생성파싱">(1) JwtProvider (토큰 생성/파싱)</h4>
<p>액세스 토큰과 리프레시 토큰을 생성하는 코드이다.</p>
<p>두 토큰은 <strong>access/refresh</strong> 라는 타입명으로 구별하고 각각의 만료시간을 관리한다.</p>
<p>타입을 구별하는 이유는 Refresh Token으로 API를 호출하거나 
Access Token으로 재발급을 요청하는 것을 방지하기 위해서이다.
<img src="https://velog.velcdn.com/images/se0o_129/post/74b2f866-7490-4b33-92dd-ade853e9e322/image.png" alt=""></p>
<br>

<h4 id="2-jwtauthenticationfilter-요청마다-검증">(2) JwtAuthenticationFilter (요청마다 검증)</h4>
<p>모든 API 요청이 들어올 때마다 Access Token을 검증하는 필터이다.</p>
<p>우선 Redis 블랙리스트를 확인하여 로그아웃된 토큰인지 확인하고, 
정상 토큰이면 CustomOAuth2User 객체에 사용자 정보를 담아 SecurityContext에 인증 정보를 저장한다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/b73cb2bf-03f6-4716-b2fc-f04b59b7a296/image.png" alt=""></p>
<br>

<h4 id="3-refreshtokenrepository-redis-crud">(3) RefreshTokenRepository (Redis CRUD)</h4>
<p>Redis에 Refresh Token을 저장하고 삭제하는 로직이다.</p>
<p>만료시간은 14일로 설정했다. 
독서기록 플랫폼 특성상 기록을 남기거나 모임이 있을 때만 접속하는 서비스라 매일 사용하지 않는 경우가 많다. </p>
<p>그래서 만료시간을 길게 잡아도 무방하다고 생각했고, 금융 서비스처럼 민감한 데이터를 다루지 않기 때문에 14일이 적절하다고 판단했다.</p>
<p>그리고 HttpOnly 쿠키만으로는 서버가 토큰을 직접 무효화할 수 없다. 
쿠키는 클라이언트가 들고 있기 때문에 로그아웃을 해도 공격자가 복사해둔 토큰은 여전히 유효하기 때문이다. 
이를 해결하기 위해 Redis에 함께 저장해 로그아웃 시 삭제함으로써 복사된 토큰도 재발급 요청에서 거부할 수 있도록 했다.
<img src="https://velog.velcdn.com/images/se0o_129/post/d359cd46-61d0-46ea-a449-34a9abaeac8e/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/b6c940ca-1423-486b-803e-a127ac2a00e7/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/e7388947-b1ba-488c-85a5-eb6b7aad6b43/image.png" alt=""></p>
<br>

<h4 id="4-authservice-토큰-재발급-및-블랙-리스트-등록">(4) AuthService (토큰 재발급 및 블랙 리스트 등록)</h4>
<p>Access Token이 만료되었을 때 Refresh Token을 통해 재발급하고, 
로그아웃 시 해당 Access Token을 블랙리스트에 등록하여 만료 전 탈취된 토큰이 사용되는 것을 방지한다.</p>
<p>Access Token은 stateless 특성상 서버가 직접 무효화할 수 없다. 
그래서 로그아웃 시점에 남은 유효시간만큼 Redis에 블랙리스트로 등록하고, 이후 요청에서 해당 토큰이 감지되면 거부하는 방식으로 구현했다.</p>
<p>재발급 시에는 Refresh Token도 함께 새로 발급하는 <strong>Rotation 방식</strong>을 적용했다. Refresh Token은 HttpOnly 쿠키라 탈취 가능성이 낮지만, 탈취됐을 경우 공격자가 재발급을 시도하면 기존 토큰과 불일치로 감지되어 차단할 수 있다. 
재발급 비용이 크지 않아 부담 없이 추가할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/c51827f1-022b-4c5a-ae60-66b02bcbc7db/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/9ed7cb81-953b-434a-b919-b8a7d3492abf/image.png" alt=""></p>
<br>
<br>

<h3 id="5-stateless에서-oauth2-state-문제와-해결">5. STATELESS에서 OAuth2 state 문제와 해결</h3>
<p>JWT 기반 stateless 환경에서는 세션이 없기 때문에 
OAuth2 로그인 시 생성되는 state 값을 저장할 공간이 없다. </p>
<p>스프링 시큐리티는 기본적으로 세션에 저장하는데 
세션을 사용하지 않으면 콜백 시점에 state 값을 비교할 수 없어서 CSRF 방어가 불가능해진다.</p>
<p>이를 해결하기 위해 <code>CookieOAuth2AuthorizationRequestRepository</code>를 구현해 state 값을 HttpOnly 쿠키에 저장했다. </p>
<p>로그인 시작할 때 저장하고, 카카오 콜백이 오면 쿠키에서 꺼내 비교한 뒤 즉시 삭제한다. 
TTL은 3분으로 설정해 로그인 완료 전 만료되지 않도록 했다.
<img src="https://velog.velcdn.com/images/se0o_129/post/b27a08be-531f-4fa2-9390-45a877a19aa7/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/caf904ff-615d-47ec-a800-77b00b84a8db/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/a7a22d12-d371-430c-979e-6c3204714d99/image.png" alt=""></p>
<br>
<br>

<h3 id="6-결론--아쉬운-점">6. 결론 / 아쉬운 점</h3>
<p>JWT + OAuth2 + Redis를 조합해 stateless 환경에서 보안을 챙기는 인증 구조를 구현했다. </p>
<p>독서 모임/기록 플랫폼은 금융 서비스처럼 즉각적인 금전 피해가 발생하는 서비스는 아니지만, 
개인의 독서 기록과 감상이 담긴 플랫폼인 만큼 개인정보 보호 측면에서 보안을 소홀히 할 수 없다고 판단했다.</p>
<p>Rotation을 적용해 탈취 감지까지 고려했지만, 
자주 접속하는 유저의 경우 Refresh Token 만료시간이 사실상 의미없어지는 한계가 있다. </p>
<p>추후 접속 빈도에 따라 만료시간을 동적으로 조정하거나, IP/디바이스 검증을 추가하는 방향으로 개선을 해야할지 고민이 필요할 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CTE로 해결하는 동적 커서 페이지네이션]]></title>
            <link>https://velog.io/@se0o_129/cte-dynamic-cursor-pagination</link>
            <guid>https://velog.io/@se0o_129/cte-dynamic-cursor-pagination</guid>
            <pubDate>Wed, 04 Mar 2026 04:44:27 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가며">0. 들어가며</h2>
<p>독크독크 플랫폼에서 개선 포인트를 찾는 중에 
데이터 양이 증가할수록 성능 저하를 유발할 수 있는 비효율적인 로직을 발견했다.</p>
<p>특히 페이지네이션 처리 방식에서 개선할 지점을 포착하게 되었고, 
이에 대한 고민과 해결 과정을 기록해보고자 한다.
<br></p>
<hr>
<h2 id="1-문제-상황-및-분석">1. 문제 상황 및 분석</h2>
<h3 id="11-페이지-특성">1.1 페이지 특성</h3>
<p>독크독크 플랫폼에서는 사용자가 읽은 책을 기록하고 관리할 수 있는 ‘<strong>내 책장</strong>’ 기능을 제공한다.</p>
<p>사용자가 직접 등록한 도서는 한 권 단위로 정리되어 표시되며, 
해당 페이지에서는 다양한 조건에 따라 목록을 조회할 수 있다.</p>
<ul>
<li>기록 상태별 조회: 기록 중 / 기록 완료</li>
<li>별점 기준 조회: 1점 ~ 5점</li>
<li>모임별 조회</li>
<li>정렬 기준 선택: 최신순 / 오래된 순</li>
</ul>
<p>이 기능을 통해 사용자는 자신의 독서 기록을 목적에 맞게 정리하고 효율적으로 관리할 수 있다.
<img src="https://velog.velcdn.com/images/se0o_129/post/9caa9e07-e08e-48fc-bea8-134fefcacb92/image.png" alt=""></p>
<br>

<h3 id="12-코드로-살펴보기">1.2 코드로 살펴보기</h3>
<p>코드를 통해 흐름을 조금 더 정리해보자.</p>
<p>우선 내 책장 페이지의 Repository 계층을 보면
해당 쿼리에는 커서 페이지네이션에 필요한 ORDER BY, 커서 조건, LIMIT이 포함되어 있지 않다.</p>
<p>즉, DB 레벨에서는 단순 조회만 수행하고 있다.
<img src="https://velog.velcdn.com/images/se0o_129/post/075d4ef7-ae08-42b7-b124-2bff300ed599/image.png" alt=""></p>
<p>반면 Service 계층을 살펴보면
정렬 기준(시간순/평점순 × 오름차순/내림차순)이 동적으로 변경된다는 이유로,</p>
<ul>
<li>정렬 처리</li>
<li>커서 조건 필터링</li>
<li>조회 개수 제한(LIMIT)</li>
</ul>
<p>이 모든 로직을 애플리케이션 레벨에서 처리하고 있다.
<img src="https://velog.velcdn.com/images/se0o_129/post/22c1cf15-d3b9-48ca-859f-2a81ebf8862c/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/3dc6a6de-b97e-4e27-bf14-80c23ae76cf5/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/2c66e16f-ec3d-42b4-9095-890ef30c1bfc/image.png" alt=""></p>
<p>그 결과 실제로는 10건만 필요한 요청임에도 불구하고,
사용자가 보유한 책 전체 데이터를 DB에서 모두 조회한 뒤</p>
<ul>
<li>메모리에서 정렬하고</li>
<li>커서를 기준으로 잘라내고</li>
<li>필요한 개수만 반환하는 방식으로 동작하고 있다.</li>
</ul>
<p>결국 정렬이 동적이라는 이유로 페이지네이션을 애플리케이션 계층으로 올려버리면서,
DB가 가장 잘할 수 있는 작업(정렬 + 제한)을 활용하지 못하고 있는 구조라고 볼 수 있다.</p>
<p>이 부분은 동적 정렬을 SQL 레벨에서 처리하도록 개선하면,
불필요한 전체 조회 없이 필요한 데이터만 효율적으로 가져올 수 있다고 생각했다.</p>
<br>

<hr>
<h2 id="2-해결-과정">2. 해결 과정</h2>
<h3 id="21-애플리케이션-레벨-→-db-레벨">2.1 애플리케이션 레벨 → DB 레벨</h3>
<p>우선 정렬, 필터링, LIMIT과 같은 작업을 애플리케이션이 아니라 
데이터베이스 레벨에서 수행하도록 구조를 변경하는 것을 목표로 했다.</p>
<p>기존 쿼리는 <code>GROUP BY</code>를 통해 책 단위의 집계 결과를 생성하고 있었다.
이 과정에서 다음과 같은 집계 컬럼들이 만들어진다.</p>
<ul>
<li><code>rating (max(br.rating))</code></li>
<li><code>addedAt (max(pb.added_at))</code></li>
<li><code>bookReadingStatus (array_agg)</code></li>
<li><code>gatherings (json_agg)</code></li>
</ul>
<p>문제는 이러한 값들이 <strong><code>GROUP BY</code>로 그룹이 만들어진 뒤, 
집계 함수에 의해 계산되어 생성되는 컬럼</strong>이라는 점이다.</p>
<p>따라서 기존 구조에서는 이 값들을 <code>WHERE</code> 절에서 바로 활용할 수 없어,
페이지네이션이나 추가 필터링을 데이터베이스에서 처리하기 어려웠다.</p>
<p>그래서 집계 결과를 먼저 생성한 뒤,
그 결과를 기준으로 필터링과 페이지네이션을 수행하는 구조로 쿼리를 재구성하기로 했다.</p>
<p>이를 위해 <strong>CTE(Common Table Expression)</strong>를 사용하여 다음과 같이 쿼리를 분리했다.</p>
<ol>
<li><strong>CTE 단계</strong><ul>
<li>GROUP BY를 통해 책 단위의 집계 결과 생성</li>
</ul>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/18e91cc2-824c-4280-8c0f-425c066f3823/image.png" alt=""></p>
<ol start="2">
<li><strong>외부 SELECT 단계</strong><ul>
<li>집계된 결과를 기준으로 rating 필터링</li>
<li>커서 기반 페이지네이션 적용</li>
<li>ORDER BY 및 LIMIT 처리</li>
</ul>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/f3fdd497-471e-4a36-9170-748e6b15277f/image.png" alt=""></p>
<p>이렇게 구조를 분리함으로써
<strong>집계 → 필터링 → 정렬 → 페이지네이션</strong>의 흐름을 명확하게 만들 수 있었고,
애플리케이션 레벨이 아니라 DB 레벨에서 데이터 양을 줄일 수 있게 되었다.</p>
<br>


<h3 id="22-테스트-코드">2.2 테스트 코드</h3>
<p><strong>테스트 환경</strong></p>
<ul>
<li><strong>DB</strong> : PostgreSQL</li>
<li><strong>데이터 수</strong>: 200 books</li>
<li><strong>테스트 방식</strong>: warm-up 5회 + 측정 10회 평균</li>
</ul>
<p>우선 내 책장에 200권의 책을 등록한 사용자를 가정하여 테스트를 진행하였다.</p>
<p>초기 실행에서 발생할 수 있는 캐시 미적중이나 JVM 워밍업 등의 영향을 최소화하기 위해 5회의 웜업 실행을 먼저 수행하였다.</p>
<p>이후 동일한 요청을 10회 반복 실행하여 평균 응답 시간을 측정하였다.</p>
<p>또한 쿼리 변경 전후의 차이를 보다 명확히 확인하기 위해 데이터베이스에서 실제로 반환되는 row 수를 함께 출력하도록 구성하였다.</p>
<img src="https://velog.velcdn.com/images/se0o_129/post/4c3b8554-b848-4a3a-affd-75fa1a11542b/image.png" width=60%>

<p><img src="https://velog.velcdn.com/images/se0o_129/post/eea89991-624f-415f-8df5-e87ac13d5116/image.png" alt=""></p>
<br>

<hr>
<h2 id="3-개선-결과">3. 개선 결과</h2>
<p>기존에는 데이터베이스에서 200개의 row를 모두 조회한 뒤 애플리케이션 레벨에서 필터링을 수행하고 있었다.
쿼리를 개선한 이후에는 필요한 <strong>11개의 row만 반환</strong>하도록 변경되었다.</p>
<p>그 결과 평균 응답 시간이 <strong>24.66ms → 17.99ms</strong>로 감소하였다.
<img src="https://velog.velcdn.com/images/se0o_129/post/f30d3eb9-b8ae-4b51-b9a5-cccdf84b059a/image.png" width=70%></p>
<p>데이터가 많아질수록 전체 조회 비용이 증가하기 때문에, 
이러한 구조 개선의 효과는 더욱 커질 것으로 예상된다.</p>
<br>

<hr>
<h2 id="4-배운점">4. 배운점</h2>
<p>이번 개선의 핵심은 단순한 응답 시간 단축보다
애플리케이션 레벨에서 처리하던 필터링과 페이지네이션을 
데이터베이스 레벨로 이동시켜 구조를 개선했다는 점에 있다.</p>
<p>그동안 성능 개선이라고 하면 
응답 시간과 같은 수치적인 변화에만 집중하는 경향이 있었다.</p>
<p>하지만 이번 작업을 통해 좋은 코드는 단순한 성능 수치뿐 아니라 
코드의 흐름과 구조를 개선하는 과정에서도 만들어질 수 있다는 점을 배울 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[외부 스토리지 이미지 생성 로직 비동기 전환으로 성능 개선하기]]></title>
            <link>https://velog.io/@se0o_129/async-minio-presigned-url-performance-improvement</link>
            <guid>https://velog.io/@se0o_129/async-minio-presigned-url-performance-improvement</guid>
            <pubDate>Fri, 20 Feb 2026 17:19:57 GMT</pubDate>
            <description><![CDATA[<h2 id="0-프로젝트-개요">0. 프로젝트 개요</h2>
<p>현재 나는 독크독크라는 독서모임 플랫폼을 개발하는 프로젝트에 참여하고 있다.</p>
<p>독크독크는 <strong>독서모임을 진행하는 사람들을 위한 플랫폼</strong>으로,
모임 중 나눈 대화와 생각들이 모임이 끝난 뒤 단순한 기억으로 사라지지 않고
개인과 모임의 기록으로 남을 수 있도록 돕는 서비스이다.</p>
<p>사용자는 독서모임에 참여할 수 있고, 
각 모임에서는 여러 회차별 약속이 생성된다. 
모임원들은 자신이 원하는 회차에 신청해 참여할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/cd24738f-e089-459c-a7e2-5810ca8ae1de/image.png" alt=""></p>
<p>이번 포스팅에서는 서비스에서 생성되는 약속 단위의 상세 정보를 조회하는 API를 중심으로 살펴보려고 한다.</p>
<br>

<hr>
<h2 id="1-문제-상황-및-분석">1. 문제 상황 및 분석</h2>
<h3 id="11-페이지-특성">1.1 페이지 특성</h3>
<p>아래 이미지는 약속 상세 정보를 조회하는 API에 대한 페이지이다.</p>
<p>사용자는 이 페이지에서 약속의 전반적인 정보와 함께
참여 멤버들의 프로필 정보를 한눈에 확인할 수 있다.<img src="https://velog.velcdn.com/images/se0o_129/post/189bf662-3ea3-4041-b409-9df4db6f0263/image.png" width="40%" /></p>
<p>이 페이지의 특징은 다음과 같다.</p>
<ul>
<li>약속 상세 정보 조회 API</li>
<li>페이지 진입 시 단 한 번 호출되는 API</li>
<li>약속에 참여한 모든 멤버의 프로필 이미지가 함께 노출</li>
<li>멤버 수는 보통 10명 내외, 최대 약 15명 수준</li>
</ul>
<p>이 페이지에서 성능상 문제가 될 수 있는 부분은
약속 멤버들의 프로필 이미지 presigned URL 생성 로직이다.</p>
<p>독크독크 플랫폼에서는 사용자 프로필 이미지를 MinIO 기반의 외부 스토리지에 저장하고 있으며,
클라이언트에서 이미지를 직접 조회할 수 있도록
서버에서 presigned URL을 생성해 전달하는 방식을 사용하고 있다.<img src="https://velog.velcdn.com/images/se0o_129/post/b6e94721-f0cd-461c-b9a5-88705f5c47e2/image.png" width="40%" /></p>
<br>

<h3 id="12-코드로-살펴보기">1.2 코드로 살펴보기</h3>
<p>이제 어떤 부분이 문제라고 느꼈는지 실제 코드 흐름을 통해 살펴보자.</p>
<p>멤버들의 정보를 가져오기 위해 약속ID 기준으로 멤버 정보를 가져오고 
프로필 이미지 프로필 이미지 presigned URL을 생성하는 순서이다.
<img src="https://velog.velcdn.com/images/se0o_129/post/6caf6e98-07f0-425a-a530-c11caa109373/image.png" alt=""></p>
<p>아래는 약속 상세 페이지 조회 시 실행되는 서비스 코드 중
약속 멤버들의 프로필 이미지 presigned URL을 생성하는 로직이다.</p>
<img src="https://velog.velcdn.com/images/se0o_129/post/d3909489-85dc-49e7-94bc-70322267b757/image.png" />

<p>해당 메서드는 다음과 같은 흐름으로 동작한다.</p>
<p>&nbsp;&nbsp;<strong>1.</strong> 약속에 참여한 모든 멤버를 순회한다.
&nbsp;&nbsp;&nbsp;<strong>2.</strong> 각 멤버의 프로필 이미지 경로를 조회한다.
&nbsp;&nbsp;&nbsp;<strong>3.</strong> MinIO에 presigned URL 생성을 요청한다.
&nbsp;&nbsp;&nbsp;<strong>4.</strong> 생성된 URL을 사용자 ID 기준으로 Map에 담아 반환한다.</p>
<br>

<h3 id="13-구조적-문제점">1.3 구조적 문제점</h3>
<p>앞서 살펴본 코드 흐름을 성능 관점에서 분석해보면
몇 가지 주목할 만한 구조적 특징이 있다.</p>
<p>프로필 이미지 presigned URL 생성은 외부 스토리지(MinIO)에 대한 네트워크 I/O 작업이다.</p>
<p>이 작업은 멤버 수만큼 반복 호출되는데 
각 호출은 서로 독립적이고, 실행 순서에 의존하지 않는다.</p>
<p>즉, 현재 구조는 순서가 필요 없는 외부 I/O 작업을 N번 순차적으로 실행하는 형태라고 볼 수 있다.</p>
<p>그럼 굳이 순차 처리할 필요가 있을까 ?
이 지점을 병렬 처리로 개선하면, 실제 사용자 체감 성능에도 차이가 날 수 있다는 생각이 들었다.</p>
<p>이러한 문제 인식을 바탕으로,
해당 로직을 비동기로 전환했을 때 어느 정도 성능 향상이 발생하는지,
그리고 사용자 경험 측면에서 의미 있는 개선으로 이어질 수 있는지를 직접 확인해보았다.</p>
<br>

<hr>
<h2 id="2-해결-과정">2. 해결 과정</h2>
<p>각 URL 생성 요청은 서로 의존성이 없는 독립적인 외부 I/O 작업이기 때문에,
동기 → 비동기 전환에 대표적인 방법인 <strong>@Async</strong>와 <strong>CompletableFuture</strong>를 활용해 병렬 처리 구조로 개선할 수 있다.</p>
<p>이를 적용하기 전 개선의 핵심이 되는 두 가지 개념을 간단히 정리해보자.</p>
<br>

<h3 id="21-async와-completablefuture">2.1 <code>@Async</code>와 <code>CompletableFuture</code></h3>
<p><strong><code>@Async</code></strong></p>
<p>Spring에서 제공하는 비동기 처리 어노테이션으로,
메서드에 적용하면 별도의 스레드에서 비동기로 실행된다.</p>
<p>해당 메서드는 호출 즉시 반환되고
실제 로직은 Spring이 관리하는 스레드 풀에서 수행된다.</p>
<p>여러 개의 독립적인 작업을 동시에 실행하고 싶을 때 유용하다.</p>
<p><strong><code>CompletableFuture</code></strong></p>
<p>비동기 작업의 결과를 담는 객체로,
작업 완료 시점을 기준으로 후속 처리를 연결할 수 있다.</p>
<p>비동기 작업의 완료를 기다릴 수 있고,
여러 비동기 작업을 조합할 수 있으며,
모든 작업이 끝난 시점을 한 번에 처리할 수 있다</p>
<br>

<h3 id="22-동기-→-비동기-전환">2.2 동기 → 비동기 전환</h3>
<blockquote>
<p><strong>(1) <code>@Async</code> 적용</strong></p>
</blockquote>
<p>각 기록 타입을 조회하는 서비스 메서드에 <code>@Async</code>를 적용하여
동기 방식으로 실행되던 로직을 비동기 방식으로 변경한다.</p>
<p><code>@Async</code>를 지정함으로써 해당 메서드는 비동기적으로 실행된다.</p>
<p>이로 인해 호출하는 쪽에서는</p>
<ul>
<li>메서드의 실행이 끝날 때까지 기다리지 않고 </li>
<li>즉시 다음 로직을 수행할 수 있다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/eb187d7e-2e8f-44bc-a2e2-edaba5fc6957/image.png" alt=""></p>
<br>

<blockquote>
<p><strong>(2) <code>CompletableFuture</code> 적용</strong></p>
</blockquote>
<p>각 비동기 메서드는 <code>CompletableFuture</code>를 반환하도록 변경하였다.</p>
<p>생성한 <code>CompletableFuture</code>를 Map에 저장한 뒤, 
<code>CompletableFuture.allOf()</code>를 사용해 한 번에 실행하고 결과를 취합한다.</p>
<p>이를 통해 여러 비동기 작업을 동시에 수행하고, 
모든 작업이 완료된 시점에 결과를 한 번에 처리할 수 있다.</p>
<p>결과적으로 이전에는 순차적으로 수행되던 프로필 presigned URL 생성 로직이 병렬로 실행되도록 개선되었다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/682f5c3d-b714-4d7f-832f-7f7493477fd8/image.png" alt=""></p>
<br>

<h3 id="23-테스트-코드">2.3 테스트 코드</h3>
<blockquote>
<p><strong>(1) 테스트 데이터 설정</strong></p>
</blockquote>
<p>이번 테스트에서는 실제 서비스에서 발생할 수 있는 상황을 최대한 가깝게 가정하여 테스트 데이터를 구성하고자 했다.</p>
<p>독서모임이라는 도메인 특성상,
한 번의 모임에 수백 명이 동시에 참여하는 경우는 현실적으로 드물다고 판단했다.</p>
<p>따라서 단순히 데이터 양을 늘리기 위해 100명, 200명 단위의 데이터를 사용하는 것은 이번 테스트의 목적과는 맞지 않다고 생각했다.</p>
<p>실제 독서모임에서 원활한 대화와 참여가 가능할 만한 규모를 기준으로 고민한 결과,
약속에 참여하는 멤버 수를 최대 15명으로 설정하고 관련 데이터를 구성하였다.</p>
<p>테스트에 사용한 데이터 구성은 다음과 같다.</p>
<ul>
<li>Book : 1권</li>
<li>Gathering : 1개</li>
<li>Gathering Member : 15명</li>
<li>Meeting : 1건</li>
<li>Meeting Member : 15명</li>
</ul>
<p>이와 같은 설정을 통해
실제 서비스 환경에서 충분히 발생할 수 있는 조건에서
약속 상세 조회 API의 동작과 성능을 확인하고,
동기 처리와 비동기 처리 방식의 차이를 비교해보고자 했다.</p>
<br>

<blockquote>
<p><strong>(2) 측정 환경 설정</strong></p>
</blockquote>
<p>네트워크 상태는 항상 일정하지 않기 때문에
실제 외부 스토리지를 그대로 호출할 경우,
동기 방식과 비동기 방식의 성능을 안정적으로 비교하기 어렵다.</p>
<p>요청 시점이나 네트워크 상황에 따라 응답 시간이 매번 달라질 수 있어,
측정 결과가 구조 차이가 아닌 외부 환경의 영향을 받게 되기 때문이다.</p>
<p>그렇기 때문에 이번 테스트에서는
네트워크 환경에 따른 변수를 최대한 제거하고,
순차 실행과 병렬 실행 구조의 차이만을 검증하는 데 초점을 맞췄다.</p>
<p>이를 위해서 Presigned URL 생성 로직을 Mock 처리하고,
모든 요청이 네트워크 I/O 비용이 50ms인 상황을 가정하도록 동일한 지연 시간을 부여했다.</p>
<p>이렇게 함으로써,
동기 방식과 비동기 방식 간의 성능 차이를 보다 명확하고 일관되게 비교할 수 있도록 하였다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/2eb5f57b-f9ed-4012-8950-a73d602180bf/image.png" alt=""></p>
<br>

<blockquote>
<p>*<em>(3) 워밍업 + 반복 측정 *</em></p>
</blockquote>
<p>성능 측정 시 초기 실행 비용에 영향 받지 않기 위해 
워밍업 5회, 반복 측정 10회로 테스트를 구성했다.
<img src = "https://velog.velcdn.com/images/se0o_129/post/450a35e3-a0b8-46c8-a2d8-0aaad8c44078/image.png" width=70%></p>
<ul>
<li>최초 5회는 워밍업 용도로 실행</li>
<li>이후 10회를 실제 측정 대상으로 삼아 평균 응답 시간을 계산</li>
</ul>
<p>이를 통해 일시적인 편차를 줄이고, 안정적인 평균 성능을 비교할 수 있도록 했다.</p>
<p>동기 방식 테스트에서는 기존의 순차 실행 메서드를 호출하고,
비동기 방식 테스트에서는 비동기 처리를 적용한 메서드를 호출한다.</p>
<p>응답 시간 측정은 Micrometer의 Timer를 사용하여
각 방식별 <strong>평균 실행 시간(mean)</strong>을 기준으로 비교했다.<img src="https://velog.velcdn.com/images/se0o_129/post/5d9ada4d-2705-4bbb-8b0c-2e3d55894673/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/3ee4669e-d9cf-40cd-bc68-aca40cb21ed9/image.png" alt=""></p>
<br>

<hr>
<h2 id="3-개선-결과">3. 개선 결과</h2>
<p>동기 방식에서 비동기 방식으로 개선한 결과,
약속 상세 조회 API의 평균 응답 시간이 <strong>약 917ms → 153ms</strong>로 단축되었다.</p>
<p>비동기 방식이 동기 방식 대비 <strong>약 17% 시간만 소요</strong>한 것으로 
<strong>약 83% 응답 시간 감소</strong>했다.</p>
<p>presigned URL 생성 로직이 멤버 수만큼 순차적으로 실행되던 구조에서
여러 개의 외부 I/O 요청이 병렬로 처리되도록 변경되면서
외부 스토리지 응답 대기 시간이 전체 응답 시간에 미치는 영향을 크게 줄일 수 있었다.</p>
<p>단일 요청 내에서 다수의 외부 I/O가 포함된 구조에서는<br>비동기 처리만으로도 충분히 의미 있는 성능 개선이 가능함을 확인할 수 있었다.<img src="https://velog.velcdn.com/images/se0o_129/post/d42177e2-c40c-419a-a6df-6220b2c48b9f/image.png" width="60%"></p>
<br>

<h3 id="31-이외-고려했던-방안--캐시">3.1 이외 고려했던 방안 : 캐시</h3>
<p><strong>비동기 처리</strong>는 기존에 순차적으로 수행되던 작업을 
<strong>동시에 얼마나 효율적으로 처리할 수 있는지</strong>를 고민하는 접근이다.</p>
<p>반면 <strong>캐시</strong>는 해당 작업을 사용자 요청마다 반복 수행해야 하는지,
<strong>재사용 가치가 있는지를 먼저 판단</strong>하는 방식이다.</p>
<p>이번 케이스에서 내가 비동기를 선택한 이유는
presigned URL 생성 자체의 연산 비용이 크다기보다는 
직렬 구조에서 발생하는 <strong>불필요한 대기 시간이 병목의 핵심</strong>이라고 판단했기 때문이다.</p>
<p>Presigned URL은 본질적으로 만료 시간을 가지는 값이며, 
만료 이후에는 반드시 재생성이 필요하다.
따라서 캐시를 적용하더라도 TTL 관리, 만료 시점 동기화 등의 추가적인 고려가 필요하다.</p>
<p>또한 약속 상세 페이지의 특성상 동일 약속을 짧은 시간 내에 반복적으로 조회할 가능성은 높지 않다고 보았다.
이 경우 캐시 히트율이 충분히 높지 않을 수 있으며, 결국 상당수 요청은 여전히 URL을 새로 생성해야 한다.</p>
<p>이러한 점을 종합했을 때, 
이번 문제는 “계산을 줄이는 문제”라기보다 
“대기 시간을 줄이는 문제”에 가까웠다고 판단했다.</p>
<p>따라서 캐싱보다는 직렬 I/O 구조를 병렬화하는 비동기 전환이 더 본질적인 개선 방향이라고 생각했다.</p>
<br>

<hr>
<h2 id="4-배운점">4. 배운점</h2>
<p>이번 포스팅을 위해 비동기에 대해 공부하면서 
코드 상에서는 단순한 반복 작업처럼 보이더라도
&quot;이 작업이 서로의 결과를 기다릴 필요가 있을까?&quot; 라는 질문 하나로 개선 포인트를 발견할 수 있었다.</p>
<p>외부 I/O처럼 순서에 의존하지 않는 작업은 순차 실행할 이유가 없고, 
비동기 전환만으로도 충분히 의미 있는 성능 개선이 가능하다는 것을 직접 수치로 확인할 수 있었다.</p>
<br>

<hr>
<h2 id="5-추가-고찰">5. 추가 고찰</h2>
<p>이 글을 작성하고 나서 구현 방식 자체를 재검토하게 되었다. </p>
<p>외부 스토리지에 대한 이해가 부족한 상태에서 성능 개선에만 집중했던 것인데, 돌아보니 프로필 이미지에는 애초에 공개 URL 방식이 더 적합했다.</p>
<p>presigned URL은 외부에 직접 노출하기 어려운 리소스에 일시적인 접근 권한을 위임하기 위한 방식이다. </p>
<p>그런데 독크독크의 프로필 이미지는 특정 사용자에게만 접근을 제한할 필요가 없는 공개 리소스다. 
이 점을 뒤늦게 인식하고, 고정 URL을 직접 반환하는 방식으로 전환했다.
<img src="https://velog.velcdn.com/images/se0o_129/post/13451132-b012-4256-b208-88d091b9c623/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/7b271cfe-d080-40c2-832b-0d7b397055b4/image.png" alt="">결과적으로 매 요청마다 URL을 생성하는 로직 자체가 사라졌다.
클라이언트가 고정 URL로 스토리지에 직접 접근하는 방식이라 URL 생성으로 인한 병목도 함께 제거되었다.</p>
<p>이번 경험을 통해 두 가지를 배웠다. </p>
<ol>
<li>기술을 적용하기에 앞서 그 기술이 현재 요구사항에 적합한지를 먼저 따졌어야 했다는 것</li>
<li>그리고 때로는 코드를 개선하는 것보다 불필요한 로직을 없애는 것이 더 나은 최적화일 수 있다는 것이다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[관리자 통계 데이터 로컬 캐시 적용하기]]></title>
            <link>https://velog.io/@se0o_129/admin-statistics-local-cache</link>
            <guid>https://velog.io/@se0o_129/admin-statistics-local-cache</guid>
            <pubDate>Tue, 27 Jan 2026 08:50:00 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가며">0. 들어가며</h2>
<p>이전 포스팅에서는 카테고리 데이터에 로컬 캐시를 적용해 상품 조회 성능을 개선했었다.</p>
<p>당시에는 변경은 드물지만 조회는 잦은 데이터를 대상으로, 
TTL과 명시적 무효화를 함께 쓰는 전략을 선택했다.</p>
<p>이번에는 조금 다른 케이스였다.
관리자 대시보드에는 일별/주별/월별 포스팅 발행 통계가 노출된다.</p>
<p>이 데이터는 단순히 잘 안 바뀌는 수준이 아니라, 과거 구간은 아예 바뀌지 않는 불변 데이터다.</p>
<p>집계가 완료된 이상 지난달 통계가 달라질 일은 없다.
이 불변성에 주목하면 캐시 전략 자체를 다르게 가져갈 수 있겠다는 생각이 들었고,
TTL 없이 condition만으로 캐시를 설계해보기로 했다.</p>
<br>

<h2 id="1-문제-상황">1. 문제 상황</h2>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/b4885117-46a4-4c4f-aaca-395a884d4544/image.png" alt=""></p>
<p>관리자 대시보드에서 주별 포스팅 통계를 조회할 때마다
통계 메서드를 호출하여 매번 DB에 접근하는 구조였다.</p>
<p>문제는 조회 대상이 과거 구간인 경우에도 동일하게 DB를 조회한다는 점이다.
지난주, 지난달처럼 이미 집계가 완료된 과거 통계는 이후에 값이 바뀔 일이 없다.</p>
<p>동일한 파라미터로 반복 조회해도 항상 같은 결과를 반환하는 구간임에도 매 요청마다 불필요한 DB I/O가 발생하고 있었다.</p>
<p>관리자가 대시보드를 반복적으로 확인하는 상황에서는
이러한 불필요한 조회가 누적되어 서비스 부하로 이어질 수 있다.
이 문제를 해결하기 위해 과거 통계처럼 불변성이 보장되는 데이터에 한해 로컬 캐시를 적용하는 방향을 고려하게 되었다.</p>
<br>

<h2 id="2-해결-과정">2. 해결 과정</h2>
<p>간단 개념 정리 : <a href="https://velog.io/@se0o_129/cache-strategy">https://velog.io/@se0o_129/cache-strategy</a></p>
<h3 id="21-캐시-선택">2.1 캐시 선택</h3>
<p>통계 데이터에는 <strong>로컬 캐시</strong>를 선택했다.</p>
<p>로컬 캐시를 선택할 때 가장 먼저 고려한 것은 데이터의 특성이었다.</p>
<p>과거 통계는 집계가 완료된 시점부터 값이 고정된다.
즉, 캐시에 저장된 데이터와 DB의 데이터가 달라질 여지가 구조적으로 없다.</p>
<p>글로벌 캐시(Redis 등)는 여러 인스턴스 간 정합성을 맞춰야 할 때 진가를 발휘하지만,
데이터 자체가 불변인 경우에는 정합성 문제가 애초에 발생하지 않는다.
네트워크 비용을 감수하면서까지 외부 캐시 서버를 거칠 이유가 없다고 생각했다.
로컬 캐시는 애플리케이션 내부 메모리에서 바로 응답하기 때문에
이 케이스에 가장 잘 맞는 선택이었다.</p>
<br>

<h3 id="22-캐시-만료-전략-선택">2.2 캐시 만료 전략 선택</h3>
<p>이번 캐시 설계에서 가장 고민했던 부분은 만료 전략이었다.</p>
<p>일반적으로 캐시 만료는 TTL을 통해 일정 시간이 지나면 자동으로 제거하는 방식을 사용한다. 하지만 이번 경우에는 <strong>TTL을 설정하지 않았다.</strong></p>
<p>그 이유는 과거 통계 데이터의 불변성에 있다.
이미 집계가 완료된 과거 구간의 통계는 이후에 값이 바뀌지 않는다.
만료 시점을 두는 것 자체가 의미 없고, 오히려 만료 후 동일한 데이터를 다시 DB에서 불러오는 낭비가 생긴다.</p>
<p>대신 @Cacheable의 condition 옵션을 활용하여
이번 달 이전 데이터만 캐시에 적재되도록 제한하기로 했다.
현재 달의 통계는 아직 집계 중인 실시간 데이터이므로 캐시 대상에서 제외했다.</p>
<p>Caffeine 캐시는 <strong>TTL 대신 maximumSize만 설정</strong>하여 메모리 상한선만 관리했다.
<br></p>
<h3 id="23-캐시-읽기-전략-선택">2.3 캐시 읽기 전략 선택</h3>
<p>캐시 읽기 전략으로는 Cache-Aside를 선택했다.
캐시에 데이터가 있으면 그대로 반환하고,
없으면 DB를 조회한 뒤 결과를 캐시에 저장하는 방식으로,
<code>@Cacheable</code> 어노테이션이 이 흐름을 자동으로 처리해준다.
관리자의 대시보드 조회가 반복될수록 캐시 히트율이 높아지고,
과거 데이터는 불변이므로 캐시와 DB 간의 정합성 문제도 발생하지 않는다.
<br></p>
<h3 id="24-코드-개선">2.4 코드 개선</h3>
<h4 id="1-cachemanager-설정">1. CacheManager 설정</h4>
<p>TTL 없이 maximumSize만 설정한 Caffeine 기반의 CacheManager를 구성했다.
캐시에 적재되는 데이터는 불변 통계이므로 만료 시점이 불필요하고,
메모리 상한선만 두어 무한 증가를 방지했다.
<img src ="https://velog.velcdn.com/images/se0o_129/post/57830083-e332-415d-82fc-01bfad75220d/image.png" width="70%"></p>
<br>

<h4 id="2-cacheable--condition-적용">2. <code>@Cacheable</code> + <code>condition</code> 적용</h4>
<p><code>@Cacheable</code>의 <code>condition</code> 옵션을 활용하여 이번 달 이전 데이터만 캐시에 적재되도록 했다.
condition 옵션은 조건이 true일 때만 캐싱을 적용하고, false면 캐시를 사용하지 않는다.
현재 달의 통계는 실시간성이 필요하므로 캐시 대상에서 제외했다.
<img src ="https://velog.velcdn.com/images/se0o_129/post/9cb25e4a-4887-4e0b-8b9c-6470a6d17219/image.png" width="80%"></p>
<br>

<h3 id="25-테스트-코드">2.5 테스트 코드</h3>
<p>캐시 적용 전후를 두 단계로 검증했다.                           첫 번째 호출에서 DB 조회가 발생하는 것을 확인하고,               동일한 파라미터로 두 번째 호출 시 캐시 히트로 DB 조회가 생략됨을     @SpyBean으로 repository 호출 횟수를 검증해 확인했다. 
<img src ="https://velog.velcdn.com/images/se0o_129/post/1f23a396-2906-452c-8549-6a0b83a840b0/image.png" width="80%"><img src ="https://velog.velcdn.com/images/se0o_129/post/2b1ef5a3-bc7a-421f-965f-2713bfcc4aa6/image.png" width="80%"></p>
<br>

<h2 id="3-결과">3. 결과</h2>
<p>캐시 적용 전에는 동일 파라미터 조회 시 <strong>263ms(DB 1회)</strong> 가 소요되었지만,
캐시 적용 후에는 <strong>6ms(DB 0회)</strong> 로 줄어드는 것을 확인할 수 있었다.</p>
<p>결과적으로 <strong>약 44배</strong>의 응답 속도 개선 효과를 확인할 수 있었다.</p>
<p>불변성이 보장된 데이터는 캐시 히트 시 DB 접근 자체가 사라지기 때문에
성능 개선 효과가 뚜렷하게 나타난다.
<img src ="https://velog.velcdn.com/images/se0o_129/post/b200c61c-e7e1-46c6-85d1-28852f614bc9/image.png" width="65%"></p>
<br>

<h2 id="4-배운점">4. 배운점</h2>
<p>두 번의 캐시 적용을 통해 공통적으로 느낀 것은
캐시 전략은 데이터의 특성을 우선적으로 고려해야한다는 점이었다.</p>
<p>이전 카테고리 캐시에서는 잘 안 바뀌는 데이터라
TTL로 안전망을 깔자는 접근이었다면,
이번에는 아예 안 바뀌는 데이터라 만료 자체가 필요 없었다.</p>
<p>데이터를 먼저 이해하고 나면 전략은 자연스럽게 따라온다는 걸 알 수 있었다.</p>
<p>또한 이번 달 데이터처럼 실시간성이 필요한 경우는 캐시 대상에서 명시적으로 제외해야 정합성이 깨지지 않는다는 점도 다시 한번 확인할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[관리자 검색 API 성능 분석: 복합 인덱스 설계와 실행 계획 비교]]></title>
            <link>https://velog.io/@se0o_129/admin-composite-search-api-index-optimization</link>
            <guid>https://velog.io/@se0o_129/admin-composite-search-api-index-optimization</guid>
            <pubDate>Wed, 14 Jan 2026 02:14:34 GMT</pubDate>
            <description><![CDATA[<h2 id="0-프로젝트-개요">0. 프로젝트 개요</h2>
<p>이 프로젝트는 <strong>상품 홍보 블로그 포스팅 자동화 플랫폼</strong>이다.</p>
<p>사용자가 상품 홍보를 위해 반복적으로 글을 작성하여 발행하는 과정을 자동화할 수 있다.
예약 시간을 지정하여 워크플로우를 생성하면, 예약한 시간에 맞춰 다음 과정이 자동 실행된다.</p>
<ul>
<li>트렌드 키워드 선정</li>
<li>상품 선택</li>
<li>AI 콘텐츠 생성</li>
<li>블로그 업로드</li>
</ul>
<p><strong>주요 개념</strong></p>
<ul>
<li><strong>Workflow(워크플로우)</strong>: 글 발행 설정 단위 (블로그, 주제, 발행 주기 정의)</li>
<li><strong>Work(워크)</strong>: 실제 발행 작업 단위, Workflow가 생성함</li>
</ul>
<p>관리자는 관리자 페이지에서 Work 실행 상태 조회와 복합 조건 검색이 가능하다.</p>
<p>이번 글에서는 복합 조건 기반 검색 쿼리에 대한 병목(성능 저하)과 이를 해결하는 과정, 결과까지 다뤄보려고 한다.</p>
<br>

<hr>
<h2 id="1-문제-상황-및-분석">1. 문제 상황 및 분석</h2>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/5172fe56-542e-42d5-b37d-9f714d0d3afa/image.png" alt=""></p>
<p>관리자 워크 검색 API에선 워크플로우 이름, 워크 상태, 생성일, 사용자 이메일로 선택적으로 검색할 수 있다.</p>
<p>API 호출 시 다음과 같은 쿼리가 실행된다.</p>
<pre><code class="language-sql"> select
        w1_0.work_id,
        w1_0.workflow_id,
        w2_0.name,
        w2_0.user_id,
        u1_0.email,
        ac.title,
        w1_0.posting_url,
        w1_0.status,
        w1_0.created_at 
    from
        work w1_0 
    join
        workflow w2_0 
            on w2_0.workflow_id=w1_0.workflow_id 
    join
        user u1_0 
            on u1_0.user_id=w2_0.user_id 
    left join
        ai_content ac1_0 
            on w1_0.work_id=ac1_0.work_id 
    where
        (
            ? is null 
            or w2_0.name like concat(&#39;%&#39;, ?, &#39;%&#39;) escape &#39;&#39;
        ) 
        and (
            ? is null 
            or w1_0.status=?
        ) 
        and (
            ? is null 
            or w1_0.created_at&gt;=?
        ) 
        and (
            ? is null 
            or w1_0.created_at&lt;=?
        ) 
        and (
            ? is null 
            or u1_0.email like concat(&#39;%&#39;, ?, &#39;%&#39;) escape &#39;&#39;
        ) 
    order by
        w1_0.created_at desc
    limit
        ?</code></pre>
<br>

<p><strong><em>쿼리 구조상 병목 가능성</em></strong></p>
<p>API 호출 시 위와 같은 쿼리가 나오는데 데이터 규모가 적을 때는 큰 문제가 발생하지 않았다.</p>
<p>하지만 이 쿼리는 여러 조건이 선택적으로 적용되고,
LIKE 검색과 최신순 정렬, 다수의 JOIN이 함께 사용된다.</p>
<p>이러한 구조는 조건 조합이 다양해
하나의 인덱스로 조회 범위를 초기에 좁히기 어렵다.</p>
<p>LIMIT이 있으면 정렬 비용도 자연스럽게 줄어들 것이라 막연히 생각했지만,
ORDER BY 대상 컬럼이 인덱스로 처리되지 않는다면
정렬이 먼저 수행될 수도 있지 않을까 하는 생각이 들었다.</p>
<br>
그래서 실제로 어떤 차이가 발생하는지 확인해보기 위해, 실제 서비스 운영 환경을 가정하여 더미데이터를 생성했다.

<p>인덱스 적용 전·후의 성능 차이를 보다 명확하게 확인할 수 있도록 <code>work</code> 테이블은 약 50만 건으로 설정했고,
연관된 테이블들 역시 유사한 비율로 데이터 수를 맞춰 구성했다.
<img src="https://velog.velcdn.com/images/se0o_129/post/c14cfffc-0f24-48e0-94a2-a33e11f47f71/image.png" width="65%"></p>
<br>

<hr>
<h3 id="11-검색-패턴별-케이스-설정">1.1 검색 패턴별 케이스 설정</h3>
<p>모든 조건을 조합한 검색은 실제 사용 빈도가 높지 않다고 생각한다.</p>
<p>그래서 관리자 화면에서 실제로 자주 활용될 가능성이 높은 검색 패턴을 기준으로
대표적인 케이스를 세 가지로 나누어 분석해보기로 했다.</p>
<p>세 가지 케이스 모두 기본적으로 최신순 정렬을 기준으로 수행된다.</p>
<br>

<blockquote>
<p><strong>Case 1. 특정 상태 + 최신순</strong></p>
</blockquote>
<p>관리자 페이지의 검색은 일반 사용자 검색과 달리
최근 발생한 작업을 빠르게 파악하고 상태를 추적하는 목적이 크다.</p>
<p>그래서 <code>status</code> 필터와 최신순 정렬을 조합한
특정 상태 + 최신순 조회가 가장 기본적이고 빈번한 케이스라고 판단했다.
<br></p>
<blockquote>
<p><strong>Case 2. 특정 상태 + 날짜 범위 + 최신순</strong></p>
</blockquote>
<p>이번 케이스는 특정 상태에 대해 기간 조건이 추가된 조회이다.</p>
<p>운영 과정에서는</p>
<ul>
<li>특정 기간 동안 발생한 장애 이력 확인</li>
<li>월별/주별 작업 처리 현황 점검</li>
</ul>
<p>과 같이 기간 단위로 데이터를 확인해야 하는 상황이 자주 발생한다고 생각한다.</p>
<p>그래서 특정 상태 + 날짜 범위 + 최신순 케이스를 통해
기간 조건이 추가되었을 때 실행 계획과 성능이 어떻게 달라지는지 확인하고자 했다.</p>
<br>

<blockquote>
<p><strong>Case 3. 사용자 이메일 + 최신순</strong></p>
</blockquote>
<p>마지막으로는 특정 사용자를 기준으로 한 조회이다.</p>
<p>관리자 페이지에서는</p>
<ul>
<li>사용자 문의 대응</li>
<li>특정 사용자 작업 이력 추적</li>
<li>이상 동작 여부 확인</li>
</ul>
<p>과 같은 목적으로
사용자 단위의 조회가 필요해지는 경우가 많다.</p>
<p>이때 사용자 기준으로 검색할 때 가장 직관적인 검색 조건이 사용자 이메일이며,
최근 작업부터 확인하는 흐름이 일반적이기 때문에
사용자 이메일 + 최신순 케이스를 별도로 분리하였다.</p>
<p>위 세 가지 케이스를 토대로 API 호출 속도를 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/839c2dc1-971c-4adc-b43e-df54b8dd3b2b/image.png" alt=""></p>
<p>Case 1. 특정 상태 + 최신순 은 <strong>2473ms</strong>
Case 2. 특정 상태 + 기간 + 최신순 은 <strong>811ms</strong>
Case 3. 이메일 + 최신순 은 <strong>1227ms</strong></p>
<p>모든 케이스에서 수백 밀리초에서 수 초 단위의 응답 시간이 발생하고 있다.</p>
<p>특히 Case 1의 경우 가장 기본적인 검색 패턴임에도 2.5초에 가까운 응답 시간이 소요되어 우선적인 최적화가 필요하다.</p>
<br>

<hr>
<h3 id="12-실행-계획-분석">1.2 실행 계획 분석</h3>
<p>이제 EXPLAIN 실행 계획 분석을 통해 해당 쿼리의 수치로 확인해보자.</p>
<p><strong>EXPLAIN</strong> 명령어는 ** 옵티마이저의 예상 실행 계획<strong>을 의미하고,
**EXPLAIN ANALYZE</strong> 명령어는 ** 실제 실행 결과 기반으로 성능 분석**한 것을 보여준다.</p>
<br>

<blockquote>
<h4 id="case-1-특정-상태--최신순">Case 1. 특정 상태 + 최신순</h4>
</blockquote>
<pre><code class="language-sql">WHERE w.status=&#39;COMPLETED&#39;
ORDER BY w.created_at DESC
LIMIT 100</code></pre>
<br>

<h4 id="-예상-실행-계획-">[ 예상 실행 계획 ]</h4>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/955ffcfb-b104-4123-8bbb-9e2f420a376c/image.png" alt=""></p>
<p>쿼리 조인 순서와 마찬가지로 실행계획 상에서도 
<code>work</code>(w) -&gt; <code>workflow</code>(wf) -&gt; <code>user</code>(u) -&gt; <code>ai_content</code>(ac) 순으로 조인할 것으로 예상된다.</p>
<p>현재 병목인 <code>work</code> 테이블의 주요 문제점을 살펴보자.</p>
<ul>
<li><p><strong>type = ALL</strong> : 전체 테이블 스캔(Full Table Scan)</p>
</li>
<li><p><strong>key = NULL</strong> : 사용할 인덱스가 없음</p>
</li>
<li><p>*<em>Extra = Using filesort *</em> : ORDER BY 절을 위한 추가 정렬 작업 필요</p>
</li>
</ul>
<p><code>status</code>, <code>created_at</code> 컬럼에 인덱스가 없어
work 테이블에서 전체 스캔과 정렬이 발생할 것으로 예상된다.</p>
<br>

<p>반면 work 이후에 조인되는 workflow, user, ai_content 테이블은 
모두 PK·UK 기반의 eq_ref 조인으로, 성능상 병목이 되지 않는다.</p>
<ul>
<li><p><strong>type = eq_ref</strong> </p>
</li>
<li><p><strong>key = PRIMARY / UNIQUE</strong></p>
</li>
<li><p><strong>rows =  1</strong></p>
</li>
</ul>
<p>결국 이 쿼리의 핵심 문제는 조인 자체가 아니라,
조인에 들어가기 전에 work 테이블에서 데이터를 충분히 줄이지 못한다는 점이다.</p>
<br>

<p>** [ 실제 실행 결과 기반 성능 분석 ] **</p>
<pre><code class="language-sql">-&gt; Limit: 100 row(s)  (cost=292472 rows=100) (actual time=471..477 rows=100 loops=1)
    -&gt; Nested loop left join  (cost=292472 rows=496391) (actual time=471..477 rows=100 loops=1)
        -&gt; Nested loop inner join  (cost=180815 rows=496391) (actual time=470..472 rows=100 loops=1)
            -&gt; Nested loop inner join  (cost=115664 rows=496391) (actual time=470..471 rows=100 loops=1)
                -&gt; Sort: w.created_at DESC  (cost=50513 rows=496391) (actual time=470..470 rows=100 loops=1)
                    -&gt; Filter: (w.`status` = &#39;COMPLETED&#39;)  (cost=50513 rows=496391) (actual time=0.99..241 rows=325000 loops=1)
                        -&gt; Table scan on w  (cost=50513 rows=496391) (actual time=0.912..180 rows=500000 loops=1)
                -&gt; Filter: (wf.user_id is not null)  (cost=0.25 rows=1) (actual time=0.0164..0.0165 rows=1 loops=100)
                    -&gt; Single-row index lookup on wf using PRIMARY (workflow_id=w.workflow_id)  (cost=0.25 rows=1) (actual time=0.0162..0.0163 rows=1 loops=100)
            -&gt; Single-row index lookup on u using PRIMARY (user_id=wf.user_id)  (cost=0.25 rows=1) (actual time=0.0123..0.0123 rows=1 loops=100)
        -&gt; Single-row index lookup on ac using UKk2kvwlai7l0sa9n5dp448f9oo (work_id=w.work_id)  (cost=1 rows=1) (actual time=0.0457..0.0457 rows=1 loops=100)</code></pre>
<br>

<p><strong><em>(1) work 테이블에서 실제로 발생한 일</em></strong></p>
<pre><code class="language-sql">Table scan on w  
(cost=50513 rows=496391) 
(actual time=0.912..180 rows=500000 loops=1)

Filter: (w.`status` = &#39;COMPLETED&#39;)  
(cost=50513 rows=496391) (actual time=0.99..241 rows=325000 loops=1)

Sort: w.created_at DESC  (cost=50513 rows=496391) 
(actual time=470..470 rows=100 loops=1)</code></pre>
<p>예상대로 work 테이블의 약 50만 건 데이터를 전체 스캔하였고,
필터링 이후에도 약 32.5만 건의 대량 데이터에 대해
created_at DESC 기준 정렬(filesort)이 수행되었다.</p>
<p>이 과정에서 실행 시간의 대부분이 소모되었다.</p>
<br>

<p><strong><em>(2) 조인 비용은 실제로도 작았다</em></strong></p>
<pre><code class="language-sql">Single-row index lookup on wf using PRIMARY (workflow_id=w.workflow_id)  
(cost=0.25 rows=1) 
(actual time=0.0162..0.0163 rows=1 loops=100)

Single-row index lookup on u using PRIMARY (user_id=wf.user_id)  
(cost=0.25 rows=1) 
(actual time=0.0123..0.0123 rows=1 loops=100)

Single-row index lookup on ac using UKk2kvwlai7l0sa9n5dp448f9oo (work_id=w.work_id)  
(cost=1 rows=1) 
(actual time=0.0457..0.0457 rows=1 loops=100)</code></pre>
<p>workflow / user / ai_content 모두 PK·UK 기반 단건 조회하여
조인 단계에서 유의미한 비용 증가 없었다.</p>
<br>

<p><em><strong>(3) LIMIT은 생각보다 늦게 적용됐다.</strong></em></p>
<pre><code class="language-sql">Limit: 100 row(s)  
(cost=292472 rows=100) 
(actual time=471..477 rows=100 loops=1)</code></pre>
<p>LIMIT 100이 존재함에도 불구하고,
전체 스캔과 정렬이 모두 끝난 이후에야 LIMIT이 적용되면서
실행 시간의 대부분이 LIMIT 이전 단계에서 소모되었다.</p>
<p>결과적으로 이 쿼리의 성능 병목은 조인이 아니라,
LIMIT이 적용되기 전에 얼마나 많은 데이터를 처리하느냐에 달려 있음을 확인할 수 있다.</p>
<br>


<blockquote>
<h4 id="case-2-특정-상태--날짜-범위--최신순">Case 2. 특정 상태 + 날짜 범위 + 최신순</h4>
</blockquote>
<p>이번 케이스는 특정 상태 + 날짜 범위 + 최신순이다.</p>
<p>특정 기간의 특정 상태를 검색하는 쿼리인 것이다.</p>
<pre><code class="language-sql">WHERE w.status=&#39;COMPLETE&#39;
    AND w.created_at BETWEEN &#39;2025-01-01&#39; AND &#39;2025-01-31&#39;
ORDER BY w.created_at DESC
LIMIT 100</code></pre>
<p>** [ 예상 실행 계획 ] **
<img src="https://velog.velcdn.com/images/se0o_129/post/aeeaf841-36d1-40d0-aa53-7198e97d6031/image.png" alt=""></p>
<p>상태 + 최신순 조건에 기간 조건을 추가했지만,
실행 계획 상에서는 접근 방식(type), 조인 순서, 사용 인덱스에 변화가 없었다.
이는 기간 조건이 인덱스 스캔으로 이어지지 못하고
Full Table Scan 이후 WHERE 절에서 처리되었기 때문이다.</p>
<p>하지만 조건의 선택도가 높아지면서,
옵티마이저가 예상하는 필터링 비율(filtered)은 이전 케이스보다 감소했다.</p>
<br>

<p>** [ 실제 실행 결과 기반 성능 분석 ] **</p>
<pre><code class="language-sql">-&gt; Limit: 100 row(s)  (cost=210347 rows=100) (actual time=254..258 rows=100 loops=1)
    -&gt; Nested loop left join  (cost=210347 rows=496391) (actual time=254..258 rows=100 loops=1)
        -&gt; Nested loop inner join  (cost=153897 rows=496391) (actual time=254..257 rows=100 loops=1)
            -&gt; Nested loop inner join  (cost=102535 rows=496391) (actual time=254..257 rows=100 loops=1)
                -&gt; Sort: w.created_at DESC  (cost=50513 rows=496391) (actual time=254..254 rows=100 loops=1)
                    -&gt; Filter: ((w.`status` = &#39;COMPLETED&#39;) and (w.created_at &gt;= TIMESTAMP&#39;2025-01-01 00:00:00&#39;) and (w.created_at &lt;= TIMESTAMP&#39;2025-01-31 00:00:00&#39;))  (cost=50513 rows=496391) (actual time=0.426..241 rows=6045 loops=1)
                        -&gt; Table scan on w  (cost=50513 rows=496391) (actual time=0.383..169 rows=500000 loops=1)
                -&gt; Filter: (wf.user_id is not null)  (cost=0.346 rows=1) (actual time=0.028..0.0282 rows=1 loops=100)
                    -&gt; Single-row index lookup on wf using PRIMARY (workflow_id=w.workflow_id)  (cost=0.346 rows=1) (actual time=0.0277..0.0278 rows=1 loops=100)
            -&gt; Single-row index lookup on u using PRIMARY (user_id=wf.user_id)  (cost=0.25 rows=1) (actual time=0.00614..0.00617 rows=1 loops=100)
        -&gt; Single-row index lookup on ac using UKk2kvwlai7l0sa9n5dp448f9oo (work_id=w.work_id)  (cost=0.988 rows=1) (actual time=0.00904..0.00907 rows=1 loops=100)</code></pre>
<pre><code class="language-sql">Filter: ((w.`status` = &#39;COMPLETED&#39;) 
    and (w.created_at &gt;= TIMESTAMP&#39;2025-01-01 00:00:00&#39;) 
    and (w.created_at &lt;= TIMESTAMP&#39;2025-01-31 00:00:00&#39;))  
(cost=50513 rows=496391) 
(actual time=0.426..241 rows=6045 loops=1)</code></pre>
<p>상태 조건만 적용했던 Case 1에서는
정렬 단계에서 약 32만 건의 row를 처리해야 했지만,
기간 조건을 추가하자 정렬 대상이 약 6천 건으로 줄어들었고,
조회 시간은 <strong>258ms</strong>로 이전 케이스와 큰 차이를 보였다.</p>
<p>비록 Table Scan 자체는 동일하게 발생했지만,
정렬 단계에서 처리해야 하는 데이터 양의 차이가 성능에 영향을 미칠 수 있다는 것을 알 수 있었다.</p>
<br>

<blockquote>
<h4 id="case-3-사용자-이메일--최신순">Case 3. 사용자 이메일 + 최신순</h4>
</blockquote>
<pre><code class="language-sql">WHERE userEmail LIKE &#39;%testuser1219%&#39;
ORDER BY w.created_at DESC
LIMIT 100</code></pre>
<p>** [ 예상 실행 계획 ] **
<img src="https://velog.velcdn.com/images/se0o_129/post/cbba6f35-2f59-4744-b11e-544f952e0450/image.png" alt=""></p>
<p>위 케이스들과 다르게 조인 순서가 
<code>user</code>(u) -&gt; <code>workflow</code>(wf) -&gt; <code>work</code>(w) -&gt; <code>ai_content</code>(ac) 순으로 
<code>user</code> 테이블이 드라이빙 테이블로 선택되었다.</p>
<p>(드라이빙 테이블은 조인을 시작할 기준 테이블을 의미한다.)</p>
<p>조건절에 있는 이메일 컬럼이 user 테이블에 있기 때문으로 보인다.</p>
<p><code>user</code> 테이블 기준으로 봤을 때,</p>
<ul>
<li><p><strong>type = ALL</strong> : 전체 테이블 스캔 </p>
</li>
<li><p><strong>key = NULL</strong> : 사용할 인덱스가 없음 </p>
</li>
<li><p><strong>Extra = Using temporary</strong> : 임시 테이블 생성</p>
</li>
<li><p><strong>Extra = Using filesort</strong> : ORDER</p>
</li>
</ul>
<p>이메일 LIKE 조건으로 필터링하는데, 
옵티마이저는 드라이빙 테이블인 <code>user</code> 테이블에 
정렬(ORDER BY) 대상 컬럼인 <code>w.created_at</code> 컬럼이 없어 
임시테이블을 만들고(Using temporary) 정렬하도록 실행계획을 세웠다.</p>
<br>

<p>** [ 실제 실행 결과 기반 성능 분석 ] **</p>
<pre><code class="language-sql">-&gt; Limit: 100 row(s)  (actual time=71.9..71.9 rows=23 loops=1)
    -&gt; Sort row IDs: w.created_at DESC, limit input to 100 row(s) per chunk  (actual time=71.8..71.8 rows=23 loops=1)
        -&gt; Table scan on &lt;temporary&gt;  (cost=87935..88604 rows=53257) (actual time=71.6..71.6 rows=23 loops=1)
            -&gt; Temporary table  (cost=87935..87935 rows=53257) (actual time=71.6..71.6 rows=23 loops=1)
                -&gt; Nested loop left join  (cost=82610 rows=53257) (actual time=19.5..71.1 rows=23 loops=1)
                    -&gt; Nested loop inner join  (cost=24505 rows=53257) (actual time=15.5..41.3 rows=23 loops=1)
                        -&gt; Nested loop inner join  (cost=5865 rows=10822) (actual time=11.6..23 rows=7 loops=1)
                            -&gt; Filter: (u.email like &lt;cache&gt;(concat(&#39;%&#39;,&#39;testuser1219&#39;,&#39;%&#39;)))  (cost=1023 rows=1109) (actual time=3.36..14.7 rows=1 loops=1)
                                -&gt; Table scan on u  (cost=1023 rows=9984) (actual time=1.88..6.77 rows=10000 loops=1)
                            -&gt; Index lookup on wf using FKav9n48jp20yik7vh3wgxcac3p (user_id=u.user_id)  (cost=3.39 rows=9.76) (actual time=8.21..8.26 rows=7 loops=1)
                        -&gt; Index lookup on w using FKbhtldpqf1j34o02ycd4154e6t (workflow_id=wf.workflow_id)  (cost=1.23 rows=4.92) (actual time=2.27..2.62 rows=3.29 loops=7)
                    -&gt; Single-row index lookup on ac using UKk2kvwlai7l0sa9n5dp448f9oo (work_id=w.work_id)  (cost=0.991 rows=1) (actual time=1.29..1.29 rows=1 loops=23)</code></pre>
<p>*<em>(1) user 테이블 : 필터링 -&gt; 조인 *</em></p>
<pre><code class="language-sql">Table scan on u  
(cost=1023 rows=9984) 
(actual time=1.88..6.77 rows=10000 loops=1)

Filter: (u.email like &lt;cache&gt;(concat(&#39;%&#39;,&#39;testuser1219&#39;,&#39;%&#39;)))  
(cost=1023 rows=1109) 
(actual time=3.36..14.7 rows=1 loops=1)</code></pre>
<p>email LIKE &#39;%testuser1219%&#39; 조건으로 인해
이번 실행계획에서는 user 테이블이 드라이빙 테이블로 선택되었다.</p>
<p>하지만 선행 와일드카드가 포함된 LIKE 조건으로 인해
user 테이블에서 인덱스를 활용하지 못하고 전체 테이블 스캔이 발생했다.</p>
<br>

<p><strong><em>(2) 임시테이블 생성</em></strong></p>
<pre><code class="language-sql">Temporary table  
(cost=87935..87935 rows=53257) 
(actual time=71.6..71.6 rows=23 loops=1)

Table scan on &lt;temporary&gt;  
(cost=87935..88604 rows=53257) 
(actual time=71.6..71.6 rows=23 loops=1)

Sort row IDs: w.created_at DESC, limit input to 100 row(s) per chunk  
(actual time=71.8..71.8 rows=23 loops=1)</code></pre>
<p>드라이빙 테이블인 <code>user</code>에
정렬 컬럼 <code>w.created_at</code>이 없어,
조인 이후 임시 테이블을 생성한 뒤 정렬(filesort)을 수행되었다.</p>
<br>

<p><strong>(3) LIMIT 적용</strong></p>
<pre><code class="language-sql">Limit: 100 row(s)  
(actual time=71.9..71.9 rows=23 loops=1)</code></pre>
<p>LIMIT은 모든 필터링과 정렬 이후에 적용되었다.</p>
<p>이 케이스를 수행하기 위해 총 <strong>71.9ms</strong>가 소요되었다.</p>
<p>결과적으로 Case1과 마찬가지로 LIMIT 100이 존재함에도 불구하고,
필터링과 정렬이 모두 완료된 이후에야 LIMIT이 적용되어
정렬 비용이 쿼리 성능을 차지하게 되었다.</p>
<br>

<hr>
<h2 id="2-해결-방안">2. 해결 방안</h2>
<h3 id="21-인덱스-설계-전략">2.1 인덱스 설계 전략</h3>
<p>각 케이스의 실행 계획을 바탕으로, 아래 세 가지 내용을 고려하여 설계하기로 했다.</p>
<ul>
<li>WHERE 조건 컬럼의 인덱스를 생성하여 Full Scan 제거</li>
<li>ORDER BY 컬럼의 인덱스에 생성하여 filesort 제거</li>
<li>다중 컬럼 인덱스의 순서는 컬럼의 카디널리티와 쿼리 패턴을 고려</li>
</ul>
<p><code>work</code> 테이블은 블로그 포스팅 작업 단위로 데이터가 계속 누적되며,
작업 생성, 상태 변경, 완료 처리 과정에서 
INSERT와 UPDATE가 매우 빈번하게 발생하는 테이블이다.</p>
<p>이러한 특성에도 불구하고
모든 조건에 만족하는 인덱스를 생성하게 된다면,
인덱스 수가 증가하면서 성능 저하로 이어질 수 있다.</p>
<p>따라서 이번 인덱스 적용에서는
모든 경우를 최적화하기보다,
실제 사용 빈도가 가장 높은 조회 시나리오를 기준으로
성능 개선 효과가 큰 인덱스를 우선 적용하는 방향으로 설계하기로 했다.</p>
<br>

<hr>
<h3 id="22-인덱스-적용">2.2 인덱스 적용</h3>
<blockquote>
<h4 id="case-1-특정-상태--최신순-1">Case 1. 특정 상태 + 최신순</h4>
</blockquote>
<p>1번 케이스의 문제점은 아래와 같다.</p>
<ul>
<li><code>status</code> 조건이 있지만 50만 건에 대한 Full Scan</li>
<li>ORDER BY 컬럼의 인덱스 부재로 인한 filesort</li>
</ul>
<p>이를 토대로 아래와 같은 복합 인덱스를 적용하려고 한다.</p>
<pre><code class="language-sql">CREATE INDEX idx_work_status_created
ON WORK(status, created_at DESC)</code></pre>
<p><strong>왜 status를 선두 컬럼으로 선택했는가?</strong></p>
<p>일반적으로 카디널리티가 높은 컬럼을 둬야 인덱스 효과를 볼 수 있는데,
둘 중 비교했을 때 카디널리티가 더 높은 <code>created_at</code>이 아닌
카디널리티가 낮은 <code>status</code>를 인덱스의 선두 컬럼으로 둔 이유는
<code>status</code>가 모든 검색에 필수 조건은 아니지만
관리자 검색에서 빈번하게 사용되는 검색 조건이기 때문이다.</p>
<p><strong><code>created_at</code>을 선두로 둘 경우</strong></p>
<ul>
<li>최신 데이터부터 인덱스를 탐색한다.</li>
<li>각 row마다 <code>status</code>를 검사한다.</li>
<li>이때 원하는 <code>status</code> 값이 아닐 경우 계속 스킵하게 된다.</li>
<li>LIMIT 100을 채우기 위해 많은 row를 탐색하게 될 수 있다.</li>
</ul>
<p><strong><code>status</code>를 선두로 둘 경우</strong></p>
<ul>
<li>원하는 <code>status</code>에 해당하는 row들로 범위를 좁힌다. </li>
<li><code>created_at</code> 기준으로 정렬이 되어있다.</li>
<li>거기서 100개만 읽고 종료하면 된다.</li>
</ul>
<p>그래서 카디널리티보다 쿼리 패턴과 LIMIT의 특성을 우선으로 하여
<code>status</code>는 선두 컬럼, <code>created_at</code>은 후행 컬럼으로 선택했다.</p>
<br>

<blockquote>
<h4 id="case-2-특정-상태--날짜-범위--최신순-1">Case 2. 특정 상태 + 날짜 범위 + 최신순</h4>
</blockquote>
<p>2번 케이스의 문제점은 아래와 같다.</p>
<ul>
<li><code>status</code> + <code>created_at</code> 두 가지 조건으로 검색 범위는 줄었지만 인덱스 미사용</li>
<li>50만 건 모두 Full scan 후 6천 건 filesort</li>
</ul>
<p>하지만 이 경우는 Case 1에서 설계한 <code>idx_work_status_created</code> 인덱스로 커버가 가능하다.</p>
<p><code>status</code> + <code>created_at</code> 이번 케이스와 같은 조건으로 설계된 인덱스이기 때문에
별도의 인덱스 생성 없이 해결할 수 있다.</p>
<br>

<blockquote>
<h4 id="case-3-사용자-이메일--최신순-1">Case 3. 사용자 이메일 + 최신순</h4>
</blockquote>
<p>3번 케이스의 문제점은 아래와 같다.</p>
<ul>
<li>선행 와일드카드가 존재하는 LIKE 조건절로 인덱스 활용 불가능</li>
<li>user 테이블의 Full Scan이 불가피하게 발생</li>
<li>임시 테이블 생성 및 정렬 작업 발생</li>
</ul>
<p>이번 케이스의 한계는 LIKE 조건절이 인덱스 활용이 불가능하여 인덱스 생성이 불가능하다.</p>
<br>

<hr>
<h2 id="3-결과">3. 결과</h2>
<p>이제 인덱스 적용 후 예상 실행 계획과 실제 실행 결과를 비교해보자.</p>
<br>

<blockquote>
<h4 id="case-1-특정-상태--최신순-2">Case 1. 특정 상태 + 최신순</h4>
</blockquote>
<p><strong>[ 예상 실행 계획 ]</strong></p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/45576829-42b3-4ebc-acba-a2fd5410430a/image.png" alt=""></p>
<p><strong>type</strong> : ALL -&gt; <strong>ref</strong>
<strong>key</strong> : NULL -&gt; <strong>idx_work_status_created</strong>
<strong>Extra</strong> : Using filesort -&gt; <strong>NULL</strong></p>
<p>실행 계획을 보면,
기존의 Full Table Scan 대신 인덱스를 이용한 동등 조건 탐색으로 변경되었고,
정렬 또한 인덱스 순서를 활용하면서 filesort가 제거되었다.</p>
<br>

<p><strong>[ 실제 실행 결과 기반 성능 분석 ]</strong></p>
<pre><code class="language-sql">-&gt; Limit: 100 row(s)  (cost=473200 rows=100) (actual time=0.48..1.81 rows=100 loops=1)
    -&gt; Nested loop left join  (cost=473200 rows=248195) (actual time=0.478..1.8 rows=100 loops=1)
        -&gt; Nested loop inner join  (cost=201176 rows=248195) (actual time=0.452..1.27 rows=100 loops=1)
            -&gt; Nested loop inner join  (cost=114308 rows=248195) (actual time=0.44..0.955 rows=100 loops=1)
                -&gt; Index lookup on w using idx_work_status_created (status=&#39;&#39;COMPLETED&#39;&#39;)  (cost=27440 rows=248195) (actual time=0.415..0.455 rows=100 loops=1)
                -&gt; Filter: (wf.user_id is not null)  (cost=0.25 rows=1) (actual time=0.00458..0.00471 rows=1 loops=100)
                    -&gt; Single-row index lookup on wf using PRIMARY (workflow_id=w.workflow_id)  (cost=0.25 rows=1) (actual time=0.00435..0.00439 rows=1 loops=100)
            -&gt; Single-row index lookup on u using PRIMARY (user_id=wf.user_id)  (cost=0.25 rows=1) (actual time=0.00289..0.00293 rows=1 loops=100)
        -&gt; Single-row index lookup on ac using UKk2kvwlai7l0sa9n5dp448f9oo (work_id=w.work_id)  (cost=0.996 rows=1) (actual time=0.00494..0.00498 rows=1 loops=100)
</code></pre>
<pre><code class="language-sql">Index lookup on w using idx_work_status_created (status=&#39;COMPLETED&#39;)  
(cost=27440 rows=248195) 
(actual time=0.331..0.363 rows=100 loops=1)</code></pre>
<p>새로 인덱스를 추가한 인덱스도 정상적으로 사용하며
LIMIT 100에 도달하자 추가 탐색 없이 바로 종료되는 것을 확인할 수 있었다.</p>
<br>

<img src="https://velog.velcdn.com/images/se0o_129/post/310980d7-6150-499e-a791-df6d6d728e75/image.png" width="65%">

<p>결과적으로 
쿼리 실행 속도는 <strong>447ms -&gt; 1.81ms</strong>,
API 호출 속도는 <strong>2473ms -&gt; 270ms</strong> 로 개선되었다.</p>
<p>Full Table Scan과 filesort가 제거되면서
처리해야 할 레코드 수와 정렬 비용이 줄어든 것으로 보인다.</p>
<br>

<blockquote>
<h4 id="case-2-특정-상태--날짜-범위--최신순-2">Case 2. 특정 상태 + 날짜 범위 + 최신순</h4>
</blockquote>
<p><strong>[ 예상 실행 계획 ]</strong>
<img src="https://velog.velcdn.com/images/se0o_129/post/d5b2a315-46d3-4f6f-ab32-9476074947e2/image.png" alt=""></p>
<p><strong>type</strong> : ALL -&gt; <strong>range</strong>
<strong>key</strong> : NULL -&gt; <strong>idx_work_status_created</strong>
<strong>Extra</strong> : Using filesort -&gt; <strong>NULL</strong></p>
<p>두 번째 케이스는 Full Table Scan에서 범위 탐색으로 변경되었고,
동일하게 정렬 또한 인덱스 순서를 활용하면서 filesort가 제거되었다.</p>
<br>

<p><strong>[ 실제 실행 결과 ]</strong></p>
<pre><code class="language-sql">-&gt; Limit: 100 row(s)  (cost=13539 rows=100) (actual time=0.276..1.62 rows=100 loops=1)
    -&gt; Nested loop left join  (cost=13539 rows=6045) (actual time=0.275..1.61 rows=100 loops=1)
        -&gt; Nested loop inner join  (cost=6952 rows=6045) (actual time=0.264..1.09 rows=100 loops=1)
            -&gt; Nested loop inner join  (cost=4836 rows=6045) (actual time=0.243..0.757 rows=100 loops=1)
                -&gt; Index range scan on w using idx_work_status_created over (status = &#39;COMPLETED&#39; AND &#39;2025-01-31 00:00:00.000000&#39; &lt;= created_at &lt;= &#39;2025-01-01 00:00:00.000000&#39;), with index condition: ((w.`status` = &#39;COMPLETED&#39;) and (w.created_at &gt;= TIMESTAMP&#39;2025-01-01 00:00:00&#39;) and (w.created_at &lt;= TIMESTAMP&#39;2025-01-31 00:00:00&#39;))  (cost=2721 rows=6045) (actual time=0.228..0.319 rows=100 loops=1)
                -&gt; Filter: (wf.user_id is not null)  (cost=0.25 rows=1) (actual time=0.00385..0.00402 rows=1 loops=100)
                    -&gt; Single-row index lookup on wf using PRIMARY (workflow_id=w.workflow_id)  (cost=0.25 rows=1) (actual time=0.00356..0.00362 rows=1 loops=100)
            -&gt; Single-row index lookup on u using PRIMARY (user_id=wf.user_id)  (cost=0.25 rows=1) (actual time=0.00292..0.00298 rows=1 loops=100)
        -&gt; Single-row index lookup on ac using UKk2kvwlai7l0sa9n5dp448f9oo (work_id=w.work_id)  (cost=0.99 rows=1) (actual time=0.00465..0.00471 rows=1 loops=100)</code></pre>
<pre><code class="language-sql">-&gt; Index range scan on w using idx_work_status_created over 
(status = &#39;COMPLETED&#39; AND &#39;2025-01-31 00:00:00.000000&#39; &lt;= created_at &lt;= &#39;2025-01-01 00:00:00.000000&#39;), 
with index condition: ((w.`status` = &#39;COMPLETED&#39;) and (w.created_at &gt;= TIMESTAMP&#39;2025-01-01 00:00:00&#39;) and (w.created_at &lt;= TIMESTAMP&#39;2025-01-31 00:00:00&#39;))  
(cost=2721 rows=6045) 
(actual time=0.228..0.319 rows=100 loops=1)</code></pre>
<p>Case 1에서 생성했던 <code>idx_work_status_created</code> 인덱스를 잘 사용하는 걸 확인할 수 있었다.</p>
<br>

<p><strong>created_at 범위가 역순으로 보이는 이유</strong></p>
<p>그런데 <code>created_at</code> 범위가 역순(끝 &lt;= <code>created_at</code> &lt;= 시작)으로 표시되어 
잘못 조회하는 것은 아닌지 의문이 들 수 있다. 내가 그랬다 ..</p>
<p>하지만 이건 인덱스가 (<code>status</code>, <code>created_at DESC</code>)로 정의되어 있어
MySQL은 인덱스를 역방향으로 스캔한다.</p>
<p>EXPLAIN에서 끝 &lt;= <code>created_at</code> &lt;= 시작 형태로 표시되는 것은
역방향 스캔의 시작점과 끝점을 보여주는 것일 뿐,
실제 WHERE 조건(2025-01-01 ~ 2025-01-31)은 정상적으로 적용된다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/2a4820b0-eee4-4553-80f0-5337804739c1/image.png" alt=""></p>
<p>결과적으로 
쿼리 실행 속도는 <strong>258ms -&gt; 1.62ms</strong>,
API 호출 속도는 <strong>811ms -&gt; 116ms</strong> 로 개선되었다.</p>
<br>

<blockquote>
<h4 id="case-3-사용자-이메일--최신순-2">Case 3. 사용자 이메일 + 최신순</h4>
</blockquote>
<p>세 번째 케이스는 인덱스를 통한 성능 개선은 어렵다고 생각되어 
이번 포스팅의 개선 대상에서 제외하였다.</p>
<p>이미 실행 계획 분석 단계에서 확인했듯이,
이 병목은 인덱스 설계로 해결할 수 있는 문제가 아니라
쿼리 구조 자체의 제약으로 인한 문제라고 생각된다.</p>
<p>따라서 인덱스 추가보다는 검색 방식 변경이나 조회 구조 분리 등
다른 방향의 개선이 필요할 것으로 보인다.</p>
<p>본 포스팅에서는 인덱스 적용을 통한 성능 개선에 초점을 두고 있기 때문에,
이 케이스의 추가적인 개선 방안은 추후 검토해볼 예정이다.</p>
<br>

<h3 id="개선-전-후-비교표">개선 전 후 비교표</h3>
<table align="center" border="1" cellspacing="0" cellpadding="5">
  <thead>
    <tr>
      <th>케이스</th>
      <th>조회 조건</th>
      <th>인덱스 적용 전 후</th>
      <th>쿼리 실행 시간</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Case 1</td>
      <td>상태 + 최신순</td>
      <td>적용 전 → 적용 후</td>
      <td><b>447ms → 1.81ms</b></td>
    </tr>
    <tr>
      <td>Case 2</td>
      <td>상태 + 기간 + 최신순</td>
      <td>적용 전 → 적용 후</td>
      <td><b>258ms → 1.62ms</b></td>
    </tr>
    <tr>
      <td>Case 3</td>
      <td>사용자 이메일 + 최신순</td>
      <td>적용 안 함</td>
      <td><b>71.9ms</b></td>
    </tr>
  </tbody>
</table>


<br>

<hr>
<h2 id="4-배운점">4. 배운점</h2>
<p><strong>인덱스 설계는 이론만으로 결정되지는 않는다는 걸 느꼈다.</strong></p>
<p>원래는 <em>&quot;인덱스 설계는 정해진 원칙대로만 하면 되는 것 아닐까&quot;</em> 라는 생각을 가지고 있었다.
하지만 이번 포스팅을 작성하면서 직접 쿼리를 분석하고 정리해보니,
쿼리 패턴이나 실제 사용 용도에 따라 이론이 항상 그대로 적용되지는 않는다는 걸 느끼게 되었다.</p>
<p>중요한 것은 인덱스 원칙 자체보다,
이 쿼리가 어떤 상황에서, 어떤 데이터를 가장 많이 조회하는지를 먼저 이해하고
그에 맞게 인덱스를 설계하는 것이라는 생각이 들었다.</p>
<br>

<p><strong>LIMIT은 만능이 아니며, 인덱스와 함께 사용할 때 의미가 있다.</strong></p>
<p>LIMIT으로 조회 건수를 줄이면 성능도 자연스럽게 좋아질 것이라고 막연하게 생각했던 적이 있다.</p>
<p>하지만 대량의 데이터를 만들어 직접 실행해보니,
LIMIT 자체보다 LIMIT이 언제 적용되느냐가 훨씬 중요하다는 걸 체감할 수 있었다.</p>
<p>정렬과 필터링이 끝난 이후에 LIMIT이 적용되는 구조라면,
LIMIT이 있어도 이미 대부분의 비용이 발생한 뒤였다.
결국 LIMIT은 인덱스와 함께 사용될 때에만
실제로 성능 개선 효과를 낼 수 있다는 점을 알게 되었다.</p>
<br>

<p><strong>모든 성능 문제를 인덱스로 해결할 수 있는 것은 아니었다.</strong></p>
<p>처음에는 세 번째 케이스 역시 인덱스를 통해 
개선할 수 있을 것이라 생각하고 분석 대상으로 포함했다.</p>
<p>하지만 실행 계획을 살펴보고 실제로 인덱스 적용을 검토하는 과정에서,
이 경우에는 인덱스로 해결하기 어려운 구조라는 점을 확인할 수 있었다.</p>
<p>이번 분석을 통해 성능 이슈라고 해서 항상 인덱스 추가가 정답은 아니며,
문제의 원인이 어디에 있는지를 먼저 파악하는 것이 더 중요하다는 점을 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[장바구니 조회 API N+1 문제 분석 및 개선 과정]]></title>
            <link>https://velog.io/@se0o_129/jpa-n1-problem-cart-optimization</link>
            <guid>https://velog.io/@se0o_129/jpa-n1-problem-cart-optimization</guid>
            <pubDate>Thu, 18 Dec 2025 00:53:42 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가며">0. 들어가며</h2>
<p>카페 주문 플랫폼 프로젝트를 리팩토링하던 중
장바구니 조회 페이지에서 예상보다 많은 쿼리가 실행되고 있는 것을 로그를 통해 발견했다.</p>
<p>특히 장바구니에 음료를 하나씩 추가할수록
조회 시 실행되는 쿼리 수가 함께 증가하는 현상이 나타났다.</p>
<p>이러한 패턴을 보며 성능 병목이 발생할 수 있는 API라고 생각했고, 
문제의 원인을 명확히 확인해보기로 했다.</p>
<p>이번 포스팅에서는
N+1 문제를 분석하고 개선한 과정까지 작성해보려 한다.</p>
<br>

<hr>
<h2 id="1-문제-상황-및-분석">1. 문제 상황 및 분석</h2>
<h3 id="11-문제-상황-확인">1.1 문제 상황 확인</h3>
<p>장바구니 조회 API를 기준으로
랜덤으로 각 상품에 3개의 옵션을 선택한 뒤 
상품을 하나씩 장바구니에 담아가며 API를 호출해보았다.</p>
<p>그 결과 상품을 하나 추가할 때마다 
쿼리 로그가 상품 수가 증가함에 따라 쿼리 수 역시 함께 증가하는 것을 발견했다.</p>
<p>이를 통해서 연관 관계 조회 과정에서 불필요한 쿼리가 반복적으로 발생하고 있을 가능성을 의심하게 되었다.</p>
<p>먼저 대시보드를 통해 전반적인 흐름을 파악한 뒤,
테이블 구조를 정리하고 테스트 코드를 통해 쿼리 수를 정확히 확인해보겠다.</p>
<br>

<h3 id="12-장바구니-도메인-구조">1.2 장바구니 도메인 구조</h3>
<p><strong>현재 ERD 구조</strong>
<img src="https://velog.velcdn.com/images/se0o_129/post/480c9510-a24b-48c6-aedd-7a7d5469274f/image.png" width=80% /></p>
<pre><code>Cart
 └─ CartItem
     ├─ Product
     └─ CartOption
         └─ ProductOption
             └─ OptionStyle</code></pre><ul>
<li><p><strong>Cart / CartItem</strong></p>
<ul>
<li><strong>Cart</strong>는 회원당 하나의 장바구니를 가지며, <strong>CartItem</strong>은 장바구니에 담긴 상품 단위이다.</li>
</ul>
</li>
<li><p><strong>CartOption</strong></p>
<ul>
<li><strong>CartOption</strong>은 <strong>CartItem</strong>에 대해 사용자가 실제로 선택한 옵션을 저장하는 테이블이다.</li>
<li>하나의 상품에는 여러 옵션이 선택될 수 있으며, 이로 인해 CartItem ↔ CartOption은 1:N 관계를 가진다. </li>
</ul>
</li>
<li><p><strong>ProductOption / OptionStyle</strong></p>
<ul>
<li>옵션 정보는 <strong>ProductOption과</strong> <strong>OptionStyle로</strong> 분리되어 있다.</li>
<li><strong>ProductOption</strong> : 상품에 어떤 옵션이 존재하는지</li>
<li><strong>OptionStyle</strong> : 옵션명과 추가 가격 등 실제 표시/계산에 필요한 정</li>
</ul>
</li>
</ul>
<p>이러한 구조에서는 CartItem 하나를 조회하더라도
선택된 옵션 수에 따라 연관 엔티티 조회가 반복적으로 발생할 가능성이 있다.</p>
<br>

<h3 id="13-문제가-발생하는-service-코드">1.3 문제가 발생하는 Service 코드</h3>
<p>N+1 문제가 발생하는 API의 서비스 코드를 살펴보자.</p>
<p>우선 멤버 아이디를 기준으로 장바구니 엔티티를 조회하면서
Fetch Join을 통해 장바구니 상품과 상품 엔티티까지 함께 조인하고 있다.<img src="https://velog.velcdn.com/images/se0o_129/post/93235ba9-7f2a-4917-bdf2-0a1cbfda5ed0/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/a5740572-286e-45f9-96ad-9587dd8e0c71/image.png" alt=""></p>
<p>하지만 상품 조회 페이지에서는 상품 정보뿐만 아니라, 
해당 상품에 대한 옵션 정보도 필요함에도 불구하고
상품 옵션 엔티티에 대해서는 Fetch Join을 사용하지 않고 있었다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/f8dde388-e99e-42b0-9758-caab6e5ddbbc/image.png" alt=""></p>
<p><code>Fetch join</code>으로 가져오지 않은 <code>cartOption</code>, <code>optionStyle</code>, <code>member</code> 엔티티의 데이터를 메서드를 통해 연관관계를 따라 체이닝 방식으로 접근하면서,
각 상품마다 그에 해당하는 데이터를 가져오기 위해서 추가 쿼리가 발생하고 있었다.</p>
<p>결과적으로 상품과 선택한 옵션 수만큼 추가 쿼리가 실행되면서 N+1 문제가 발생하고 있음을 확인할 수 있었다.</p>
<br>

<h3 id="14-n1-문제-탐지-결과">1.4 N+1 문제 탐지 결과</h3>
<p>테스트 코드 기반으로 정확한 수치를 확인해보겠다.</p>
<p><strong>테스트 환경</strong></p>
<ul>
<li>데이터베이스 : MySQL (HikariCP 커넥션 풀)</li>
<li>테스트 데이터 : 옵션 3개 이상인 상품 10개 선택, 각 상품당 3개 옵션 선택 (총 30개)</li>
<li>JPA 1차 캐시 : 매 측정마다 <code>EntityManager.clear()</code>로 초기화하여 캐시 영향 제거</li>
</ul>
<p><strong>측정 방법:</strong></p>
<ul>
<li>워밍업 : 3회 실행 (JVM 최적화 및 DB 준비)</li>
<li>실제 측정 : 10회 반복 후 평균값 산출</li>
<li>수집 지표 : 쿼리 실행 횟수, 응답 시간(평균/최대/P95), 테이블별 쿼리 분포</li>
</ul>
<p>테스트 데이터로 옵션이 3개 이상 등록된 상품 10개를 조회하여 사용하였다.
<img src="https://velog.velcdn.com/images/se0o_129/post/48d94ac1-a4a3-4c19-bb45-2893968e8261/image.png" alt=""></p>
<p>내부적으로는 실행 시간, 쿼리 수을 수집하였고
응답시간은 평균, 최대, P95 기준으로 비교하였다.<img src="https://velog.velcdn.com/images/se0o_129/post/9b27e9ba-8d81-47cd-a227-76ca86c5b3c9/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/4024e743-8333-4cce-8d9a-588b41f7d54a/image.png" width=50% /> </p>
<p>P95 기준 응답 시간은 약 <strong>475ms</strong> 정도였고,
동일 조건에서 더 다양한 옵션을 선택할 경우
<code>option_style</code>에 대한 쿼리 수가 증가하면서 응답 시간 또한 함께 증가할 것으로 예상된다.</p>
<p>옵션 3개씩 적용된 상품 10개가 담긴 장바구니를 조회한 결과
총 <strong>46개</strong>의 쿼리가 발생한 것을 확인할 수 있었다.</p>
<ul>
<li><strong>product_option</strong> : 3 × 10 = 30회</li>
<li><strong>nutrition_info</strong> : 10회</li>
<li><strong>option_style</strong> : 4회</li>
<li><strong>cart_option, cart</strong> : 각각 1회</li>
</ul>
<br>

<p><strong>그런데 여기서 추가로 발견한 문제점이 있다.</strong></p>
<p><code>product</code>와 1:1 연관관계를 맺고 있는 <code>nutrition_info</code>(영양 정보) 엔티티에서 10개의 추가 쿼리가 발생하고 있었다.
<img src="https://velog.velcdn.com/images/se0o_129/post/9999090b-0ba2-4b41-a587-2a33c75e9448/image.png" width=60% /> <img src="https://velog.velcdn.com/images/se0o_129/post/782f93a2-5e82-4cc4-9e3b-0f6f3be8fcda/image.png" width=60% /> </p>
<p>처음에는 단순한 로딩 전략 문제라고 생각했다.
하지만 원인을 찾아보니 <code>@OneToOne</code> 연관관계에서 FK의 위치로 인해 발생한 문제였다.</p>
<p><code>nutrition_info</code>는 product를 참조하고 있으며,
FK는 <code>nutrition_info</code> 테이블에 존재한다.</p>
<p>장바구니 조회에서는 <code>nutrition_info</code> 데이터를 전혀 사용하지 않음에도 불구하고,
<code>product</code> 엔티티를 로딩하는 과정에서
<code>nutrition_info</code>에 대한 추가 조회 쿼리가 발생하고 있었다.</p>
<p>이는 FK가 존재하지 않는 쪽(product)에서
@OneToOne(fetch = LAZY) 연관관계를 사용할 경우 발생하는 JPA의 특성 때문이다.</p>
<p>JPA는 프록시를 생성하기 위해 연관 엔티티의 식별자(ID)를 알아야 하지만,
이 경우 <code>product</code> 엔티티만으로는 <code>nutrition_info</code>의 ID를 알 수 없다.
따라서 <code>nutrition_info</code>가 실제로 존재하는지,
혹은 null인지 판단하기 위해 즉시 조회 쿼리를 실행할 수밖에 없다.</p>
<p>그 결과, <code>fetch = LAZY</code>로 설정했음에도
실제로는 즉시 로딩처럼 동작하게 되며,
이 조회가 상품 수만큼 반복되면서 N+1 문제가 발생하게 된다.</p>
<p>이는 단순한 Fetch 전략 변경으로 해결할 수 있는 문제가 아니라
연관관계 매핑 설계 자체에서 발생한 문제라고 볼 수 있다.</p>
<br>

<hr>
<h2 id="2-n1-문제-해결-전략">2. N+1 문제 해결 전략</h2>
<h3 id="21-대표적인-세-가지-전략">2.1 대표적인 세 가지 전략</h3>
<p>N+1 문제를 해결하는 대표적인 세 가지 전략을 간단하게 살펴보자.</p>
<br>

<p><strong>Fetch Join</strong></p>
<p>패치 조인을 사용할 경우,
연관관계에 해당하는 엔티티를 하나의 쿼리로 한 번에 조회할 수 있다.
이렇게 조회된 엔티티는 이후 접근 시에도 추가 쿼리가 발생하지 않는다.</p>
<p>목록 조회에서 항상 함께 사용하는 연관 엔티티가 명확하고,
데이터 양이 많지 않을 때 가장 확실한 해결 방법이다.</p>
<p><strong>@BatchSize</strong></p>
<p>하나의 쿼리 수행 시,
IN 절을 사용해 여러 연관 엔티티를 묶어서 조회하도록 설정하는 어노테이션이다.
지정한 배치 사이즈만큼 데이터를 한 번에 가져오며,
이를 초과할 경우 다음 쿼리를 통해 동일한 방식으로 조회한다.</p>
<p>Fetch Join을 사용하기 어렵거나,
여러 연관 엔티티가 선택적으로 사용되는 경우에 유용하다.</p>
<p><strong>@EntityGraph</strong></p>
<p>조회 시 함께 가져올 연관 엔티티를 어노테이션으로 명시하여 
필요한 연관 데이터만 즉시 로딩하도록 설정할 수 있다.</p>
<p>JPQL을 수정하지 않고,
메서드 단위로 Fetch 전략을 제어하고 싶을 때 사용한다.</p>
<br>

<h3 id="22-나의-해결-방안">2.2 나의 해결 방안</h3>
<p>우선 하나의 쿼리로 가져오던 데이터를 두 개의 쿼리로 나눠서 가져오기로 했다.</p>
<p>장바구니 조회에서 실제로 필요한 데이터는 <code>Cart</code>가 아니라 <code>CartItem</code>이었고,
<code>Cart</code>를 기준으로 <code>fetch join</code> 을 해서
DISTINCT를 통해 이를 다시 하나의 Cart 엔티티로 합치는 과정이 불필요하다고 생각했다.</p>
<p>그래서 <code>Cart</code>는 식별자 조회로 분리하고,
<code>CartItem</code>을 루트로 필요한 연관 데이터만 <code>fetch join</code> 하는 두 단계 조회 방식을 선택했다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/b7e0dfca-bf3e-4fcd-a8d5-4c12e229cae1/image.png" alt=""></p>
<br>

<p>*<em>1. 조회 방식 선택 : Fetch join vs Projection *</em></p>
<p>장바구니 조회 API에서 N+1 문제를 해결하기 위해
<code>Fetch Join</code>과 <code>Projection</code> 중 어떤 방식을 사용할지 고민했다.</p>
<p>먼저 두 방식의 특징을 간단히 정리해보았다.</p>
<br>

<p><strong>Projection</strong></p>
<ul>
<li>필요한 컬럼만 조회하므로 가져오는 데이터 양이 줄어든다.</li>
<li>엔티티를 생성하거나 영속성 컨텍스트에 등록하지 않기 때문에 
엔티티 로딩 및 관리 비용이 발생하지 않는다.</li>
<li>이러한 특성으로 인해 대량 조회 시 메모리 및 CPU 사용량을 줄이는 데 효과적이다.</li>
</ul>
<p><strong>Fetch Join</strong></p>
<ul>
<li>연관된 엔티티를 한 번의 쿼리로 함께 조회할 수 있어 N+1 문제를 방지할 수 있다.</li>
<li>엔티티를 그대로 조회하므로
연관 관계 탐색, 옵션 조합, 금액 계산 등 도메인 로직을 자연스럽게 처리할 수 있다.</li>
<li>컬렉션 구조가 유지된 상태로 로딩되기 때문에
<code>Projection</code>처럼 결과를 다시 그룹핑하거나 가공할 필요가 없다.</li>
</ul>
<br>

<p>두 방식을 비교해보았을 때,</p>
<p>장바구니 조회는 한 사용자 기준의 데이터로 규모가 크지 않고,
옵션과 같은 중첩된 연관 구조를 그대로 활용해야 하는 특성을 가지고 있었다.</p>
<p>이러한 구조를 <code>Projection</code>으로 조회할 경우
서비스 계층에서 결과를 다시 그룹핑하고 가공하는 추가 로직이 필요해져
오히려 코드 복잡도가 증가할 수 있다고 생각했다.</p>
<p>그리고 성능적 측면에서도 대량 데이터 조회에 효과 큰 <code>Projection</code>의 이점을 장바구니 조회 에선 효과가 크지 않을 것 같았다.</p>
<p>반면 <code>Fetch Join</code>을 사용하면
연관 데이터를 한 번에 조회하면서도 엔티티 구조를 그대로 활용할 수 있어
도메인 로직을 단순하게 유지하고, 유지보수성 측면에서도 유리하다고 생각했다.</p>
<p>따라서 이번 장바구니 조회 API에서는
<code>Projection</code> 이 아닌 <code>Fetch Join</code> 방식을 선택했다.</p>
<br>

<p><strong>2. Fetch join 전략 활용</strong></p>
<p><code>Fetch Join</code> 을 활용하여 장바구니 조회에 필요한 연관 데이터를 한 번의 쿼리로 가져오도록 수정하였다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/9a491bf6-1a93-4f0c-ab54-63fb4fbbf118/image.png" alt=""></p>
<p>장바구니 도메인에는 다음과 같은 다양한 상태가 존재한다.</p>
<ul>
<li>장바구니는 존재하지만 상품이 없는 경우</li>
<li>상품은 존재하지만 옵션이 선택되지 않은 경우</li>
<li>옵션 조합이 부분적으로만 선택된 상태</li>
</ul>
<p>이러한 특성상 <code>INNER JOIN FETCH</code>를 사용할 경우,
연관 데이터가 존재하지 않는 시점에서는
장바구니 또는 장바구니 아이템 자체가 조회되지 않는 문제가 발생할 수 있다고 생각했다.</p>
<p>그래서 연관 데이터의 존재 여부와 관계없이 
장바구니 정보를 안정적으로 조회하기 위해
모든 연관 관계를 <code>LEFT JOIN FETCH</code>로 조회하는 방식을 선택하였다.</p>
<br>

<p>*<em>3. 비효율적인 OneToOne 연관 관계 설계 개선 *</em></p>
<p>두 번째로 문제였던 부분은
실제로는 필요하지 않은 <code>nutritionInfo</code> 데이터가 조회되고 있다는 점이었다.</p>
<p>일반적으로 1:1 연관관계를 양방향으로 매핑하는 경우는
해당 데이터가 여러 곳에서 사용되거나, 추후 확장 가능성을 고려하는 경우가 많다.</p>
<p>하지만 현재 도메인 구조를 살펴보면 영양정보(<code>nutritionInfo</code>)를 조회하는 API는
상품 상세 조회 API 하나뿐이었다.</p>
<p>이 상태를 그대로 유지할 경우
N+1 문제를 해결하기 위해 장바구니 조회 API에서는 사용하지도 않는 영양정보까지
<code>fetch join</code>으로 함께 조회해야 하는 상황이 발생한다.</p>
<p>실제로 다른 곳에서 영양정보를 조회하는 경우가 없었기 때문에
양방향 연관관계를 제거하고 단방향 구조로 수정하였다.</p>
<p>결과적으로 
상품 상세 조회 API에서는 영양정보를 별도의 쿼리로 조회하도록 분리했고</p>
<p>장바구니 조회 API에서는 영양정보를 <code>fetch join</code> 하지 않고도
필요한 데이터만 조회할 수 있도록 구조를 개선할 수 있었다.
<img src="https://velog.velcdn.com/images/se0o_129/post/b0816337-37af-4253-8f3c-dc2cd0434471/image.png" width=60% /></p>
<br>

<hr>
<h2 id="3-개선-결과">3. 개선 결과</h2>
<p>개선한 장바구니 조회 API(<code>/users/cart</code>)를 기준으로,
개선 전과 동일한 조건으로 상품을 장바구니에 담아 API를 호출해보았다.</p>
<p>그 결과,
상품 개수와 상관없이 총 2개의 쿼리만 수행되는 것을 확인할 수 있었다.
<img src="https://velog.velcdn.com/images/se0o_129/post/78e7b586-5d64-4c10-bb01-fa9471c5aa77/image.jpg" alt=""></p>
<pre><code class="language-sql">Hibernate: 
    select
        c1_0.cart_id 
    from
        cart c1_0 
    where
        c1_0.member_id=?
Hibernate: 
    select
        distinct ci1_0.cart_item_id,
        ci1_0.cart_id,
        co1_0.cart_item_id,
        co1_0.cart_option_id,
        co1_0.product_option_id,
        po1_0.product_option_id,
        os1_0.option_style_id,
        os1_0.extra_price,
        os1_0.option_name_id,
        os1_0.option_style,
        po1_0.product_id,
        ci1_0.price,
        ci1_0.product_id,
        p1_0.product_id,
        p1_0.category_id,
        p1_0.favorite_count,
        p1_0.price,
        p1_0.product_content,
        p1_0.product_name,
        p1_0.product_photo,
        p1_0.version,
        ci1_0.quantity 
    from
        cart_item ci1_0 
    left join
        product p1_0 
            on p1_0.product_id=ci1_0.product_id 
    left join
        cart_option co1_0 
            on ci1_0.cart_item_id=co1_0.cart_item_id 
    left join
        product_option po1_0 
            on po1_0.product_option_id=co1_0.product_option_id 
    left join
        option_style os1_0 
            on os1_0.option_style_id=po1_0.option_style_id 
    where
        ci1_0.cart_id=?</code></pre>
<img src="https://velog.velcdn.com/images/se0o_129/post/9cc6efb3-f8ac-4190-a008-b7d79e9ff9de/image.png" width=60% />

<p>테스트 코드로 확인한 결과 역시 동일했다.
기존에는 상품 수에 따라 쿼리가 증가하던 구조였으나,
개선 이후에는 cart 조회 1회, cart_item 조회 1회로 
항상 총 <strong>2개</strong>의 쿼리만 실행되도록 개선되었다.</p>
<p>또한 성능 측면에서도 눈에 띄는 개선이 있었다.
P95 기준 응답 시간은 <strong>472ms → 42ms</strong>로  감소했다.</p>
<br>

<h3 id="개선-전-후-비교표">개선 전 후 비교표</h3>
<table>
<thead>
<tr>
<th align="left">지표</th>
<th align="center">개선 전 (N+1 발생)</th>
<th align="center">개선 후 (Fetch Join)</th>
<th align="center">개선율</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>총 쿼리 실행 횟수</strong></td>
<td align="center">46회</td>
<td align="center"><strong>2회</strong></td>
<td align="center"><strong>95.6% ↓</strong></td>
</tr>
<tr>
<td align="left"><strong>평균 응답 시간</strong></td>
<td align="center">200ms</td>
<td align="center"><strong>26ms</strong></td>
<td align="center"><strong>87.0% ↓</strong></td>
</tr>
<tr>
<td align="left"><strong>P95 응답 시간</strong></td>
<td align="center">472ms</td>
<td align="center"><strong>42ms</strong></td>
<td align="center"><strong>91.1% ↓</strong></td>
</tr>
</tbody></table>
<br>

<hr>
<h2 id="4-배운점">4. 배운점</h2>
<p><strong>모니터링만으로는 모든 문제를 발견할 수 없다</strong></p>
<p>Grafana 대시보드를 통해
요청 수가 늘어날수록 쿼리 수가 함께 증가하는 현상은 확인할 수 있었지만,
어떤 연관 관계에서, 어떤 코드 지점에서 쿼리가 발생하는지까지는
모니터링만으로 파악하기 어려웠다.</p>
<p>결국 실제 서비스 코드와 쿼리 로그,
테스트 코드를 함께 보면서 문제를 추적해야
N+1이 발생하는 정확한 원인을 이해할 수 있었다.</p>
<br>

<p><strong>N+1 문제는 단순한 로딩 전략 문제가 아닐 수 있다</strong></p>
<p>처음에는 fetch = LAZY / EAGER 설정만의 문제라고 생각했는데 분석해보니,
연관 관계를 어떻게 설계했는지
특히 @OneToOne 관계에서 FK의 위치와 양방향 매핑 여부에 따라서도
의도하지 않은 쿼리가 발생할 수 있다는 것을 알게 되었다.</p>
<p>즉, N+1 문제는
단순히 fetch 전략을 바꾸는 것으로 해결되지 않고,
엔티티 매핑 설계 자체를 다시 고민해야 하는 문제일 수도 있었다.</p>
<br>

<p>N+1 문제는 다양한 원인과 상황에서 발생하고
Fetch Join, @BatchSize, @EntityGraph 등 여러 해결 전략이 존재한다.
각 전략의 특성을 이해하고 상황에 맞게 선택하는 것이 중요함을 배웠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[로컬 캐시를 이용한 카테고리 캐시 적용으로 상품 조회 성능 개선]]></title>
            <link>https://velog.io/@se0o_129/improving-product-query-performance-with-local-category-cache</link>
            <guid>https://velog.io/@se0o_129/improving-product-query-performance-with-local-category-cache</guid>
            <pubDate>Mon, 15 Dec 2025 00:05:11 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가며">0. 들어가며</h2>
<p>이번 포스팅에서는 <strong>로컬 캐시를 이용한 카테고리 캐시 적용으로 상품 조회 성능 개선 과정</strong>을 다뤄보려고 한다.</p>
<p>카페 주문 플랫폼에서 카테고리는 사용자가 상품을 조회할 때 가장 먼저 접하는 정보 중 하나로,
조회 빈도는 높지만 변경은 드문 데이터라는 특징을 가진다.</p>
<p>그럼에도 불구하고 사용자의 조회 요청마다 매번 DB를 조회하는 것이 과연 효율적인지 의문이 들었고,
이에 카테고리 조회에 캐싱을 적용했을 때 어떤 성능 개선 효과를 얻을 수 있는지 직접 확인해보기로 했다.</p>
<p>이번 글에서는 그 적용 과정과 결과를 정리해본다.</p>
<br>

<h2 id="1-문제-상황">1. 문제 상황</h2>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/2b7463e2-aaf5-4e6b-ac05-62f80b927528/image.png" alt=""></p>
<p>위 이미지는 우리 프로젝트의 상품 목록 페이지이다.</p>
<p>해당 페이지에는
<code>커피</code>, <code>라떼</code>, <code>주스&amp;드링크</code>, <code>바나치노&amp;스무디</code>, <code>티&amp;에이드</code>, <code>디저트</code>, <code>세트메뉴</code>, <code>MD</code>
총 8개의 카테고리가 노출된다.</p>
<p>카테고리는 관리자가 직접 수정하지 않는 이상 거의 변경되지 않으며,
대부분의 경우 동일한 데이터가 지속적으로 유지된다는 특징을 가진다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/6861a8ac-ca5a-4dc6-8a49-88680f180fac/image.png" alt=""></p>
<p>하지만 현재 구조에서는 상품 목록을 조회할 때마다 
상품 데이터와 카테고리를 조인하여 함께 조회하고 있다.
카테고리 정보가 항상 동일함에도 불구하고 
매 요청마다 DB 접근과 조인 연산이 반복적으로 발생하는 구조인 것이다.</p>
<p>이러한 구조는 사용자 수가 적을 때는 크게 문제되지 않지만,
상품 목록처럼 다수의 사용자가 반복적으로 조회하는 화면일수록
불필요한 DB I/O와 조인 비용이 누적되어 서비스 부하로 이어질 수 있다.</p>
<p>이 문제를 해결하기 위해,
변경 빈도는 낮고 조회 빈도는 높은 카테고리 데이터에 로컬 캐시를 적용하는 방향을 고려하게 되었다.</p>
<br>

<h2 id="2-해결-과정">2. 해결 과정</h2>
<p>간단 개념 정리 : <a href="https://velog.io/@se0o_129/cache-strategy">https://velog.io/@se0o_129/cache-strategy</a></p>
<h3 id="21-캐시-선택">2.1 캐시 선택</h3>
<p>카테고리 데이터에는 <strong>로컬 캐시</strong>를 선택했다.</p>
<p>카테고리는 조회는 자주 되지만 변경은 거의 없는 데이터라서,
매번 글로벌 캐시를 거쳐 외부 서버와 통신하는 것은 불필요하다고 생각했다.</p>
<p>로컬 캐시는 애플리케이션 내부 메모리에 데이터를 저장하기 때문에
네트워크 비용 없이 바로 조회할 수 있고,
자주 바뀌지 않는 카테고리 데이터와도 잘 맞는다고 판단했다.</p>
<p>또한 카테고리는 실시간 반영이 꼭 필요한 데이터가 아니기 때문에,
로컬 캐시로 인한 데이터 불일치도 크게 문제가 되지 않을 것 같았다.
<br></p>
<h3 id="23-캐시-읽기-전략-선택">2.3 캐시 읽기 전략 선택</h3>
<p>캐시 읽기 전략으로는 <strong>Cache-Aside</strong>를 선택했다.</p>
<p>우선 상품 목록을 조회할 때마다 해당 메서드를 호출하지만, 
카테고리 변경은 관리자만 수행하며 거의 발생하지 않는다. 
조회가 많을수록 캐시를 활용했을 때 성능 개선 효과가 크다.</p>
<p>또한 카테고리 변경이 있어도 수십 분~몇 시간 뒤에 반영되어도 서비스에는 큰 영향을 주지 않는다. 
즉시 정합성이 필요한 데이터(재고, 잔액 등)는 캐시보다는 DB 직접 조회가 적합하고 생각했다.</p>
<br>

<h3 id="22-캐시-만료-전략-선택">2.2 캐시 만료 전략 선택</h3>
<p>캐시 만료 방식으로는 TTL과 명시적 무효화를 병합하여 사용하는 전략을 선택했다.</p>
<p>카테고리 데이터는 특성상 변경 빈도가 매우 낮아
데이터 변경 시점에 캐시를 직접 제거하는 명시적 무효화만으로도 충분히 관리 가능하다.</p>
<p>하지만 모든 변경 상황에서 무효화를 완벽하게 보장하기는 어렵기 때문에
무효화가 누락되는 경우를 대비해 TTL을 보조 수단으로 함께 적용하기로 했다.</p>
<p>TTL은 24시간으로 설정하여
최악의 경우에도 캐시 데이터가 하루 이상 유지되지 않도록 하였고,
이를 통해 오래된 데이터가 지속적으로 제공되는 상황을 방지하고자 한다.</p>
<br>

<h3 id="24-코드-개선">2.4 코드 개선</h3>
<p><strong>1. cacheManager 설정</strong>
<img src="https://velog.velcdn.com/images/se0o_129/post/299dafe1-0318-4b76-b820-8198d88ffd7d/image.png" alt=""></p>
<p>TTL을 24시간으로 설정하여, 최악의 경우에도 캐시 데이터가 하루 이상 유지되지 않도록 관리했다.</p>
<p>용량 초과 시에는 Caffeine의 eviction 정책(LRU 기반)에 따라
오래 사용되지 않은 데이터가 자동으로 제거되도록 구성했다.</p>
<br>

<p><strong>2.</strong> <code>@Cacheable</code> 적용
<img src="https://velog.velcdn.com/images/se0o_129/post/d133d209-f9c2-4374-a1af-039e6cb09d3c/image.png" alt=""></p>
<p><code>@Cacheable</code> 어노테이션을 카테고리를 가져오는 메서드에 적용했다.
캐시 이름은 categories로 지정하여, 
메서드 호출 결과를 이 캐시 영역에 저장하고 재사용할 수 있도록 설정했다.</p>
<br>

<p><strong>3. 조인 제거 및 캐시 데이터 활용</strong></p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/11d729fa-dbbe-4f50-8a60-c249ec4615fd/image.png" width="65%"><img src="https://velog.velcdn.com/images/se0o_129/post/468917b1-1326-45cc-b877-e15e3e8e6f13/image.png" alt=""></p>
<p>기존에는 상품 조회 시 카테고리와 조인하여 데이터를 가져왔지만,
이제는 조인을 제거하고 캐시된 카테고리 데이터를 이용해 상품과 매치하는 방식으로 개선했다.</p>
<br>

<h3 id="25-테스트-코드">2.5 테스트 코드</h3>
<p>피크타임에 여러 매장에서 동시 100명이 상품 목록을 조회하는 상황을 가정하여 테스트 코드를 구성했다.</p>
<p>캐시 적용 전과 후를 동일한 조건으로 측정하여 성능 차이를 비교하였다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/96a17df3-6bff-44a8-9f65-351f9573e6d1/image.png" width="70%"><img src="https://velog.velcdn.com/images/se0o_129/post/aa61a2d3-ebec-4ae1-8cb8-b2eb82ac9d53/image.png" width ="70%"><img src="https://velog.velcdn.com/images/se0o_129/post/9e5d8e4f-0dd5-41e9-bb2b-e9afc6cffc1b/image.png" width ="80%"></p>
<br>

<h2 id="3-결과">3. 결과</h2>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/1ca6eec4-3c81-438f-99c3-de4ebf4e2331/image.png" width ="50%"><img src = "https://velog.velcdn.com/images/se0o_129/post/b96ee5d2-7012-4c9e-a161-cbe6150bbaf3/image.png" width ="50%"></p>
<p>캐시 적용 전에는 <strong>평균 968ms</strong>가 소요되었지만,
캐시 적용 후에는 <strong>평균 300ms</strong>로 줄어드는 것을 확인할 수 있었다.</p>
<p>단일 조회 시에는 큰 병목이 발생하지 않지만,
동시 접속자가 늘어나면 DB 조회 부담은 급격히 증가하게 된다.</p>
<p>같은 조건에서도 캐시 유무에 따라 성능 차이가 뚜렷하게 발생함을 확인할 수 있었다.</p>
<h2 id="4-배운점">4. 배운점</h2>
<p>이번 캐시 적용을 통해 조회 성능을 개선할 수 있었지만,
카테고리 데이터가 8개로 매우 적고 조회 비용이 크지 않았다는 점을 고려하면
필수적인 최적화는 아니었다고 생각한다.</p>
<p>다만 조회 빈도가 높은 데이터에 대해 캐시를 적용하고,
Cache-Aside 전략과 무효화 방식(TTL + 명시적 제거)을 직접 설계해본 경험은 의미 있었다.</p>
<p>실제 서비스에서는 데이터 규모와 트래픽을 기준으로
캐시 도입 여부를 판단하는 것이 중요하다는 점을 배울 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring @Transactional 활용: 읽기 트랜잭션에서 readOnly 옵션 성능 비교 ②]]></title>
            <link>https://velog.io/@se0o_129/transactional-optimization-2</link>
            <guid>https://velog.io/@se0o_129/transactional-optimization-2</guid>
            <pubDate>Sun, 30 Nov 2025 07:19:26 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가며">0. 들어가며</h2>
<p><strong>문제 발견</strong>
지금까지 개발하면서 조회용 메서드에는 습관적으로
<code>@Transactional(readOnly = true)</code>를 붙였다.</p>
<p>조회에 <code>@Transactional(readOnly = true)</code> 를 설정함으로써 
성능상 이점을 얻을 수 있다고 알고있지만,
정작 내부에서 어떻게 동작하는지는 제대로 이해하지 못했다.</p>
<ul>
<li>왜 성능이 좋아지는 걸까?</li>
<li>내부적으로 무슨 일이 일어나는 걸까?</li>
<li>실제로 얼마나 차이가 날까?</li>
</ul>
<p>궁금증을 해결하기 위해 직접 테스트하고 내부 동작을 분석해보았다.</p>
<br>

<p><strong>테스트 환경</strong></p>
<ul>
<li>Spring Boot 3.x, JPA/Hibernate</li>
<li>MySQL 8.0</li>
</ul>
<p><strong>한계</strong></p>
<ul>
<li>실제 운영 환경의 대량 데이터(수만~수십만 건)에서는 차이가 더 클 수 있다.</li>
</ul>
<br>
---

<h2 id="1-문제-분석">1. 문제 분석</h2>
<h3 id="11-현재-코드">1.1 현재 코드</h3>
<pre><code class="language-java">@Transactional
public ProductDetailResponse findByProductId(Long productId) {
    Product product = productRepo.findProductById(productId);
    long favCount = favoriteRepo.countByProductId(productId);
    List&lt;ProductOption&gt; options = productOptRepo.findOptionByProductId(productId);

    return productConverter.toDetailDto(product, favCount, options);
}</code></pre>
<p>이 메서드는 상품 상세 조회 API의 서비스 코드로, 기본값 트랜잭션을 사용한다.</p>
<p>Spring의 <code>@Transactional</code> 기본 설정은 <code>readOnly = false</code>
즉, “쓰기 가능 트랜잭션” 으로 인식된다.</p>
<p>비록 SQL은 SELECT만 실행되지만
JPA/Hibernate는 ‘언제든 엔티티가 변경될 수 있다’고 가정한다.</p>
<br>

<h3 id="12-무엇이-문제일까-">1.2 무엇이 문제일까 ?</h3>
<p>JPA는 기본적으로 다음을 전제로 동작한다.</p>
<blockquote>
<p>&quot;<strong>트랜잭션 안에서 조회한 엔티티는 
변경될 수도 있으니, 끝날 때 반드시 확인해야 한다.</strong>&quot;</p>
</blockquote>
<p>그래서 읽기 전용이라고 명시하지 않으면,
JPA는 단순 조회도 쓰기 가능 상태로 관리한다.</p>
<br>

<p><strong>문제 1 : 영속성 컨텍스트 + 스냅샷 생성</strong></p>
<p>조회 메서드를 호출했을 때 아래와 같은 과정은 거친다.</p>
<ol>
<li>Product 엔티티 조회</li>
<li>영속성 컨텍스트에 엔티티 저장</li>
<li>동시에 스냅샷(초기 상태 복사본) 생성</li>
</ol>
<p>[ 영속성 컨텍스트 ]</p>
<ul>
<li>Product 엔티티</li>
<li>Product 스냅샷 (변경 감지용)</li>
</ul>
<p>스냅샷은 변경 감지를 위해서만 존재하는데
조회만 하는데도 엔티티 개수만큼 메모리를 사용하게 되고, 대량 조회시 메모리 압박이 증가하게 된다.
읽기 전용 트랜잭션에서는 전혀 필요없는 작업이다.</p>
<br>

<p><strong>문제 2 : 더티 체킹(변경 감지) 수행</strong></p>
<p>트랜잭션 종료시 Hibernate는 </p>
<ol>
<li>현재 엔티티 상태</li>
<li>스냅샷 상태</li>
</ol>
<p>위 두 가지의 모든 필드를 비교한다.</p>
<pre><code class="language-java">for (엔티티의 모든 필드) {
    현재값 == 스냅샷값 ?
}</code></pre>
<p>실제로는 변경한 적 없는 조회 전용 메서드에서
의미 없는 객체 필드 비교와 엔티티 수 * 필드 수 만큰 CPU를 사용하게 되어 낭비가 발생한다</p>
<br>

<p><strong>문제 3 : 불필요한 flush 가능성</strong></p>
<p>flush란 영속성 컨텍스트 내용을 DB와 동기화하는 작업을 말한다.</p>
<p>보통 트랜잭션 커밋 시 JPQL 실행 전 자동 발생 가능하다.</p>
<pre><code class="language-java">@Transactional
public List&lt;Product&gt; findByProductId(...) {
    // 다른 로직이 추가되면?
}</code></pre>
<p>의도치 않게 엔티티 상태가 변경되면 flush 발생 → UPDATE 쿼리 실행할 수 있는 상태가 되고
조회 메서드인데 DB 변경되는 사고가 날 수 있다.</p>
<br>

<hr>
<h2 id="2-해결-방법-readonly--true">2. 해결 방법: readOnly = true</h2>
<h3 id="21-개선-코드">2.1 개선 코드</h3>
<pre><code class="language-java">@Transactional(readOnly = true)
public ProductDetailResponse findByProductIdReadOnly(Long productId) {
    Product product = productRepo.findProductById(productId);
    long favCount = favoriteRepo.countByProductId(productId);
    List&lt;ProductOption&gt; options = productOptRepo.findOptionByProductId(productId);

    return productConverter.toDetailDto(product, favCount, options);
}</code></pre>
<h3 id="22-readonly--true가-하는-일">2.2 readOnly = true가 하는 일</h3>
<p><strong>(1) 스냅샷 저장 안 함</strong></p>
<p>일반 트랜잭션에서는 조회한 엔티티의 원본 상태(스냅샷)을 영속성 컨텍스트에 저장하지만
<code>readOnly = true</code>에서는 스냅샷을 생성하지 않는다 (메모리 사용량 감소)</p>
<br>

<p><strong>(2) 변경 감지(더티 체킹) 안 함</strong></p>
<p>일반 트랜잭션 종료 시 현재 상태 vs 스냅샷 비교하여 변경 여부 판단하지만
<code>readOnly = true</code>에서는 비교 대상 자체가 없다. (CPU 사용 감소)</p>
<br>

<p><strong>(3) 플러시 모드 변경</strong></p>
<p><code>readOnly = true</code> 적용 시 Hibernate FlushMode가 MANUAL 로 변경하여
트랜잭션 종료 시 자동 flush 발생하지 않는다.</p>
<br>

<p><strong>(4) 데이터베이스 힌트 (DB에 따라)</strong></p>
<p><code>@Transactional(readOnly = true)</code>가 선언되면
Spring은 JDBC Connection에 읽기 전용(read-only) 힌트를 전달한다.</p>
<p>이 힌트는</p>
<blockquote>
<p>“<strong>이 트랜잭션은 데이터를 변경하지 않는다</strong>”
는 의미를 DB 및 인프라 계층에 명시적으로 알리는 역할을 한다.</p>
</blockquote>
<p>레플리케이션 환경에서의 동작</p>
<p>DB가 다음과 같은 레플리케이션 구조로 구성된 경우:</p>
<pre><code>Master DB  →  Replica DB (복제본)
   (쓰기)         (읽기)</code></pre><ul>
<li>Master DB : INSERT / UPDATE / DELETE 담당</li>
<li>Replica DB : SELECT 전용 조회 담당</li>
</ul>
<p>읽기 전용 힌트가 전달되면,
DataSource 라우팅 설정이나 미들웨어(MySQL Router, Aurora Reader Endpoint 등)에 따라
해당 트랜잭션을 Read Replica로 라우팅할 수 있다.</p>
<p>단, readOnly = true만으로 자동 라우팅이 되는 것은 아니며
실제 라우팅은 인프라 및 애플리케이션 설정에 따라 결정된다.</p>
<p>결과적으로
조회 트래픽이 Replica로 분산됨으로써, 
Master DB 부하 감소하고 읽기 요청이 많은 서비스에서 전체 처리량 및 안정성 향상된다.</p>
<br>

<hr>
<h2 id="3-성능-테스트">3. 성능 테스트</h2>
<h3 id="31-테스트-시나리오">3.1 테스트 시나리오</h3>
<p>Product 10건을 기준으로 연관된 엔티티인
Hashtag 55건, NutritionInfo 10건, ProductOption 179건이 함께 조회되어
총 254개의 데이터가 로딩되는 상황을 가정하였다.</p>
<p>동일한 조회 조건에서
<code>@Transactional(readOnly = false)</code> 와
<code>@Transactional(readOnly = true)</code> 를 각각 10회 측정하여
평균 수행 시간을 비교하였다.</p>
<p>테스트 코드와 레파지토리 메서드는 아래와 같다.
테스트 코드는 @Transactional의 readOnly true와 false를 적용한 메서드 사용외에 모두 동일하다.
<img src="https://velog.velcdn.com/images/se0o_129/post/50c8e7bd-6b42-4176-829c-69377ccf9886/image.png" width="500" /><img src="https://velog.velcdn.com/images/se0o_129/post/1b94e9e2-5d04-4e8f-929b-334669317825/image.png" width="500" /><img src="https://velog.velcdn.com/images/se0o_129/post/b77fb3bc-a2d8-4e14-bdad-87520064d32e/image.png" width="500" /></p>
<br>

<h3 id="32-측정-결과">3.2 측정 결과</h3>
<table>
<thead>
<tr>
<th>방식</th>
<th>평균 시간</th>
<th>개선율</th>
</tr>
</thead>
<tbody><tr>
<td>@Transactional</td>
<td>35ms</td>
<td>기준</td>
</tr>
<tr>
<td>@Transactional(readOnly=true)</td>
<td>18ms</td>
<td>48.6% ↑</td>
</tr>
</tbody></table>
<img src="https://velog.velcdn.com/images/se0o_129/post/cd4a2517-5719-40fd-8389-9a5e5e3b948f/image.png" width="400" />

<p>소량 데이터 조회라 큰 변화는 없었지만
<code>readOnly=true</code>를 적용했을 때 시간이 단축된 걸 확인해볼 수 있었다.</p>
<p>이는 조회에는 불필요한 스냅샷 저장과 더티 채킹 과정을 생략함으로써 나올 수 있는 결과이다.</p>
<br>
---

<h2 id="4-주의사항">4. 주의사항</h2>
<h3 id="41-읽기-전용-트랜잭션에서-수정하면-무슨-일이-생길까-">4.1 읽기 전용 트랜잭션에서 수정하면 무슨 일이 생길까 ?</h3>
<pre><code class="language-java">@Transactional(readOnly = true)
public void updateProduct(Long productId) {
    Product product = productRepository.findById(productId);
    product.setName(&quot;변경&quot;);  // 변경 감지 안 됨
    // DB에 반영되지 않음
}</code></pre>
<p>겉보기에는 문제 없는 코드처럼 보이지만
<code>readOnly = true</code> 트랜잭션에서는
엔티티 스냅샷을 만들지 않고, Dirty Checking(변경 감지)을 하지 않기 때문에
엔티티 값은 바뀐 것처럼 보이지만 트랜잭션 종료 시 UPDATE SQL이 실행되지 않는다.</p>
<p>그렇기 때문에 readOnly 트랜잭션에서는 절대로 엔티티 상태를 변경하면 안 된다.</p>
<br>

<h3 id="42-언제-사용해야-할까-">4.2 언제 사용해야 할까 ?</h3>
<p><strong>사용해야 할 때 :</strong></p>
<ul>
<li>단순 조회 API</li>
<li>리포트 생성</li>
<li>통계 조회</li>
<li>검색 기능</li>
</ul>
<p><strong>사용하면 안 될 때 :</strong></p>
<ul>
<li>데이터 수정이 필요한 경우</li>
<li>CUD(Create, Update, Delete) 작업</li>
</ul>
<br>

<hr>
<h2 id="5-적용-팁">5. 적용 팁</h2>
<h3 id="51-service-계층-패턴">5.1 Service 계층 패턴</h3>
<pre><code class="language-java">@Service
@Transactional(readOnly = true)  // 클래스 레벨 기본값
public class ProductService {

    // 조회 - readOnly 상속
    public List&lt;Product&gt; findAll() { ... }

    // 수정 - 메서드에서 오버라이드
    @Transactional  // readOnly = false
    public void updateProduct(Long id, String name) { ... }
}</code></pre>
<p>조회 메서드가 대부분인 서비스의 경우,
클래스 레벨에 @Transactional(readOnly = true)를 두고
변경이 필요한 메서드에서만 트랜잭션을 오버라이드하는 것이
가장 자연스럽다.</p>
<br>

<h3 id="52-복제replication-환경">5.2 복제(Replication) 환경</h3>
<p>Master/Replica 구조를 사용하는 경우,
readOnly 트랜잭션은 조회 요청을
읽기 전용 DB로 분리하기 위한 기준으로 활용될 수 있다.</p>
<pre><code class="language-yaml"># application.yml
spring:
  datasource:
    hikari:
      data-source-properties:
        readOnlyRoutingDataSource: true</code></pre>
<p>그러나 실제 라우팅 동작 여부는 DataSource 구성 및 인프라 설정에 따라 달라진다.</p>
<br>

<hr>
<h2 id="6-배운-점">6. 배운 점</h2>
<p>이번 테스트로 얻은 성능 차이는 크지 않았다.</p>
<p>하지만 막연하게 사용하던 readOnly = true 설정이
단순한 성능 수치 이상의 의미를 가지며
내부 동작을 제어하는데 큰 차이를 만들어냄을 확인할 수 있었다. </p>
<p>조회 로직을 쓰기 가능 트랜잭션으로 조회한다는 것은
변경이 필요하지 않음에도 불구하고
변경에 대비한 불필요한 과정을 거침으로써 
불필요한 메모리 사용과 비용을 감수할 수 있었다는 것을 알게 되었다.</p>
<p>이번 포스팅을 통해, 어떤 기능을 사용할 때 
어떤 과정을 통해 이루어지고 어떤 이점이 있어 사용하는지 이해한 상태에서
사용하는 것이 중요하는 점을 다시 한번 느낀다.</p>
<p>끊임없이 의문을 던지면서 의식하고 공부하는 것이 중요하다고 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring @Transactional 활용: 쓰기 트랜잭션과 Bulk Delete 성능 비교 ①]]></title>
            <link>https://velog.io/@se0o_129/transactional-optimization-1</link>
            <guid>https://velog.io/@se0o_129/transactional-optimization-1</guid>
            <pubDate>Wed, 26 Nov 2025 00:03:33 GMT</pubDate>
            <description><![CDATA[<h2 id="0-들어가며">0. 들어가며</h2>
<p>카페 주문 플랫폼 장바구니 비우기 기능을 테스트하던 중, 로그에서 이상한 점을 발견했다.</p>
<p>단 10개의 상품을 삭제하는데 DELETE 쿼리가 11번이나 실행되고 있었다.</p>
<p>처음에는 단순히 비효율적인 구현 때문이라고 생각했다.
하지만 원인을 하나씩 살펴보면서,
JPA에서의 삭제 방식과 트랜잭션 처리에 대해 다시 고민하게 되었다.</p>
<p>이 글에서는 원인 분석과 여러 삭제 방식(Bulk Delete, Cascade 등)을 비교하며
직접 측정해본 과정을 정리해보려고 한다.</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/907b3752-8858-4851-9e56-614d212aa29d/image.png" alt=""></p>
<br>
<br>


<h2 id="1-문제-케이스--트랜잭션-범위-내-반복-쿼리">1. 문제 케이스 : 트랜잭션 범위 내 반복 쿼리</h2>
<blockquote>
<p><strong>문제 상황 분석</strong></p>
</blockquote>
<h4 id="현재-erd-구조">현재 ERD 구조</h4>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/b0097b8d-2033-4c5d-8322-3e507d0c5388/image.png" alt=""></p>
<p>장바구니 관련 테이블은 다음과 같이 구성되어 있다:</p>
<ul>
<li><strong>cart</strong>: 회원별 장바구니 정보를 저장하는 테이블</li>
<li><strong>cart_item</strong>: 장바구니에 담긴 상품 정보를 저장하는 테이블</li>
<li><strong>cart_option</strong>: 각 상품의 옵션 정보를 저장하는 테이블</li>
</ul>
<hr>
<blockquote>
<h4 id="문제-코드">문제 코드</h4>
</blockquote>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/dcc15582-ecfe-46b5-8d91-6a4c2cde5968/image.png" alt=""></p>
<p>사용자가 장바구니 &quot;전체 비우기&quot; 버튼을 클릭했을 때 실행되는 메서드이다.</p>
<pre><code class="language-java">@Transactional
public void clearCart(Long memberId, Long cartId) {
    List&lt;CartItem&gt; items = cartItemRepository.findByCartId(cartId);

    for (CartItem ci : items) {
        cartOptionRepository.deleteByCartItemId(ci.getId()); 
        // 반복문 안에서 매번 개별 DELETE 실행
    }

    cartItemRepository.deleteAllInBatch(items);
}</code></pre>
<p><strong>문제점 :</strong> 
장바구니 상품의 옵션을 삭제할 때, 
반복문 안에서 각 CartItem에 대해 매번 개별 DELETE 쿼리가 실행되고 있다.</p>
<hr>
<blockquote>
<h4 id="실행된-쿼리-로그-분석">실행된 쿼리 로그 분석</h4>
</blockquote>
<pre><code class="language-sql">-- 1. 장바구니 조회
SELECT c1_0.cart_id, c1_0.member_id 
FROM cart c1_0 
WHERE c1_0.cart_id=?

-- 2. 장바구니 상품 조회
SELECT ci1_0.cart_item_id, ci1_0.cart_id, ci1_0.price, ci1_0.product_id, ci1_0.quantity 
FROM cart_item ci1_0 
JOIN cart c1_0 ON c1_0.cart_id=ci1_0.cart_id 
WHERE c1_0.cart_id=?

-- 3. 장바구니 옵션 개별 삭제 (10번 반복)
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?
DELETE FROM cart_option WHERE cart_item_id=?

-- 4. 장바구니 상품 일괄 삭제
DELETE FROM cart_item 
WHERE cart_item_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)</code></pre>
<p><strong>쿼리 실행 결과 :</strong></p>
<ul>
<li>CartOption 개별 삭제: <strong>10번</strong></li>
<li>CartItem 일괄 삭제: <strong>1번</strong></li>
<li>총 DELETE 쿼리: <strong>11번</strong></li>
</ul>
<p>장바구니 상품이 10개일 때, 
DELETE 쿼리가 11번 실행되는 비효율이 발생했다.</p>
<hr>
<blockquote>
<h4 id="트랜잭션-관점의-문제">트랜잭션 관점의 문제</h4>
</blockquote>
<p><strong>불필요한 네트워크 왕복으로 인한 트랜잭션 보유 시간 과다</strong></p>
<ul>
<li>트랜잭션은 DB 커넥션을 점유하는데 트랜잭션 실행 시간이 길어지면 DB 커넥션 점유 시간이 길어진다.<ul>
<li>커넥션 풀의 크기가 제한적이므로, 트랜잭션이 길어지면 다른 트랜잭션들의 대기 시간도 길어지게 된다.</li>
<li>대용량 트래픽이 몰리는 상황에서 1번의 쿼리로 수행할 수 있는 작업을 11번의 쿼리로 수행하는 것은 매우 비효율적이다.</li>
</ul>
</li>
</ul>
<br>
<br>

<h2 id="2-해결-방법">2. 해결 방법</h2>
<h3 id="21-벌크-삭제-bulk-delete">2.1 벌크 삭제 (Bulk Delete)</h3>
<p>반복문으로 개별 DELETE를 수행할 때 발생하는 N+1 삭제 문제는 벌크 삭제를 통해 해결할 수 있다.</p>
<p>벌크 삭제는 여러 개의 데이터를 한 번의 쿼리로 한꺼번에 삭제하는 방식을 말한다.</p>
<p>영속성 컨텍스트를 거치는 단계를 모두 건너뛰고 데이터베이스에 직접 delete sql을 실행하기 때문에 빠르다.</p>
<p>벌크 삭제 방식에는 두 가지 방법이 있다.</p>
<hr>
<blockquote>
<p><strong>1. deleteAllInBatch() 메서드로 삭제 수행</strong></p>
</blockquote>
<p><code>deleteAllInBatch</code>는 Spring Data JPA가 기본 제공하는 메서드이다.</p>
<p>먼저 엔티티를 조회하고 엔티티 컬렉션을 받아서 IN절로 삭제를 수행한다. (SELECT + DELETE, 총 두 번의 쿼리)</p>
<pre><code class="language-sql">SELECT * FROM cart_item WHERE cart_id = ?;  -- 먼저 조회
DELETE FROM cart_item WHERE cart_item_id IN (?, ?, ...);  -- 그 다음 삭제</code></pre>
<hr>
<blockquote>
<p>** 2. Repository에 벌크 삭제 쿼리 추가 (@Query + @Modifying)**</p>
</blockquote>
<p><code>@Query + @Modifying</code> 방식은 <strong>조회 없이 바로 DELETE 쿼리를 실행</strong>하는 특징이 있다.</p>
<p>영속성 컨텍스트를 거치지 않고 <strong>SQL을 직접 실행</strong>하여, 엔티티 로딩 없고 메모리 사용 최소화할 수 있는 방식이다.</p>
<pre><code class="language-java">@Modifying
@Query(&quot;DELETE FROM CartOption co WHERE co.cartItem.cart.id = :cartId&quot;)
void deleteByCartId(@Param(&quot;cartId&quot;) Long cartId);</code></pre>
<p>deleteAllInBatch()와 비교했을 때 조회 없이 바로 삭제가 가능하다는 장점이 있지만, Repository에 쿼리를 직접 작성한 메서드를 추가해야 한다.</p>
<pre><code class="language-java">DELETE FROM cart_item WHERE cart_id = ?;  -- 바로 삭제 (조회 없음)</code></pre>
<br>
<br>

<h3 id="22-cascade-설정">2.2 Cascade 설정</h3>
<p>Cascade는 부모 엔티티의 작업이 자식의 엔티티에게 전파되는 기능을 말한다.</p>
<p>부모를 삭제하면 자식도 자동으로 삭제돼서, 외래키 제약 조건 문제를 자동으로 해결해준다.</p>
<pre><code class="language-java">@OneToMany(mappedBy = &quot;cartItem&quot;, cascade = CascadeType.ALL)  // 모든 작업 전파
private List&lt;CartOption&gt; options = new ArrayList&lt;&gt;();</code></pre>
<br>
<br>

<h2 id="3-성능-테스트">3. 성능 테스트</h2>
<h3 id="31-테스트-시나리오">3.1 테스트 시나리오</h3>
<p>한 명의 사용자가 3개의 옵션이 적용된 10개의 상품이 들어있는 장바구니를 비우는 상황을 가정했다.</p>
<p>개선 전 방식과 3가지 개선 방식(벌크 삭제 2가지, Cascade)을 비교하여 각각 <strong>10회씩 반복 측정</strong>한 후 평균 실행 시간을 비교했다.</p>
<br>

<h3 id="32-테스트-환경">3.2 테스트 환경</h3>
<ul>
<li><strong>테스트 사용자</strong> : memberId = 1</li>
<li><strong>상품 데이터</strong> : 기존 DB의 186개의 상품 중 옵션이 3개 이상인 상품 10개 선택</li>
<li><strong>장바구니 구성</strong><ul>
<li><strong>CartItem</strong> : 10개</li>
<li><strong>CartOption</strong> : 30개 (상품당 3개씩)
<img src="https://velog.velcdn.com/images/se0o_129/post/5c83aebc-70c1-46cb-97ad-569b0abc7655/image.png" alt=""></li>
</ul>
</li>
<li>*<em>반복 측정 *</em>: 각 방식당 10회</li>
</ul>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/e6843017-b78c-4e7e-b257-476cfc89a2fb/image.png" alt="">각 측정마다 <code>entityManager.clear()</code>로 캐시를 초기화하여 정확한 성능을 측정했다.</p>
<br>
<br>

<hr>
<h3 id="33-개선-전--반복문--deleteallinbatch">3.3 개선 전 : 반복문 + deleteAllInBatch</h3>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/215a0f2c-1a5c-4949-a952-3c53ab08cbdb/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/64b78b66-df1c-4d0f-90cb-6bbb332bd65d/image.png" alt="">반복문으로 CartOption을 개별 삭제한 후, CartItem을 일괄 삭제하는 기존 방식에 대한 메서드이다.</p>
<p>총 11번의 DELETE 쿼리 수행으로 매우 비효율적으로 장바구니를 비우고 있다.
<br></p>
<hr>
<h3 id="34-벌크-삭제-적용--querymodifying">3.4 벌크 삭제 적용 : @Query+@Modifying</h3>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/3104de72-b002-4df4-9b3e-67bed5f4811e/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/756ee8d4-ab16-4cb2-99e3-acdde900e647/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/97675b59-f9c2-4401-9fb4-c258c10848b8/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/24125da0-f375-4423-bf8a-d8f29c6338cc/image.png" alt=""></p>
<p><code>@Query+@Modifying</code> 방식은 JPQL로 직접 작성한 벌크 삭제 쿼리를 사용한다.</p>
<p>조회 없이 바로 벌크 삭제하고 쿼리 개수가 가장 적게 발생한 방식이다.</p>
<p>개선 전 대비 <strong>367ms</strong> 빠른 방식이라는 것을 알 수 있었다. *<em>(13.5% 향상)
*</em>
<br></p>
<h3 id="35-벌크-삭제-적용--deleteallinbatch">3.5 벌크 삭제 적용 : deleteAllInBatch</h3>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/b1d4b2aa-581a-4669-be5f-32d5ca135789/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/98d93035-7e6f-4d95-bf41-52ea320c67c1/image.png" alt=""><code>deleteAllInBatch</code>는 엔티티를 먼저 조회하고 조회한 엔티티들을 메모리에 로딩하여 일괄 삭제하는 방식이다.</p>
<p>CartOption과 CartItem 엔티티를 각각 한번씩 조회하여 두 번의 SELECT문과 두 번의 DELETE문이 발생하였다.</p>
<p>네가지 방식 중 가장 짧은 평균 실행시간을 보였고, 개선 전 대비 <strong>446ms</strong> 빠른 방식이었다. <strong>(16.8%)</strong></p>
<p>@Query + @Modifying 방식보다는 31ms 빠르게 측정되었다.</p>
<br>
<br>

<h3 id="36-cascade--cascadetypeall">3.6 cascade = CascadeType.ALL</h3>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/703775aa-f3fe-44de-bcca-1c3e6ab73aae/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/e2665840-cd2b-41b0-a5e4-025aa7aa07f3/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/609c6277-274c-4fc7-86ef-589e1cbdc495/image.png" alt=""></p>
<p>CartItem의 options를 <code>cascadeType.ALL</code>을 적용하여 JPA가 연관관계에 따라 자동 삭제하도록 하였다.</p>
<p>N+1 문제로 쿼리 많이 발생하지만, 그럼에도 개선 전과 비교했을 때 <strong>75ms</strong> 더 빨랐다는 걸 알 수 있었다. <strong>(2.8% 향상)</strong></p>
<br>
<br>


<h3 id="37-성능-테스트-종합-분석">3.7 성능 테스트 종합 분석</h3>
<table>
<thead>
<tr>
<th>순위</th>
<th>방식</th>
<th>평균 시간</th>
<th>쿼리 개수</th>
<th>개선율</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>deleteAllInBatch</td>
<td>227ms</td>
<td>4번</td>
<td>16.8%</td>
</tr>
<tr>
<td>2</td>
<td>@Query + @Modifying</td>
<td>258ms</td>
<td>2번</td>
<td>13.5%</td>
</tr>
<tr>
<td>3</td>
<td>Cascade</td>
<td>298ms</td>
<td>40번+</td>
<td>2.8%</td>
</tr>
<tr>
<td>4</td>
<td>개선 전 (반복문)</td>
<td>303ms</td>
<td>11번</td>
<td>기준</td>
</tr>
</tbody></table>
<p>이론적으로 <code>@Query + @Modifying</code> 방식이 조회없이 바로 삭제하기 때문에 가장 빠를 것으로 예상했다.</p>
<p>하지만 예상과 달리 실제 테스트 결과는 <code>deleteAllInBatch</code> 방식이 가장 빠른 성능을 보였다.</p>
<br>

<blockquote>
<h4 id="왜-예상과-다를까-">왜 예상과 다를까 ?</h4>
</blockquote>
<p>내가 실행한 테스트에서는 소량 데이터(상품 10개, 옵션 30개)가 이러한 결과를 보인 가장 큰 요인으로 예상된다.</p>
<br>

<p><code>deleteAllInBatch</code> 는 먼저 엔티티를 조회한 후 삭제하는 2단계 과정을 거친다.</p>
<pre><code class="language-sql">List options = cartOptionRepository.findByCartId(cartId);
// 30개 엔티티 조회

DELETE FROM cart_option 
WHERE cart_option_id IN (1, 2, 3, 4, 5, ..., 30); // IN절로 삭제</code></pre>
<p>MySQL은 이 IN 절을 처리할 때 매우 효율적으로 동작한다. </p>
<ol>
<li>IN 절의 값들을 정렬</li>
<li>인덱스를 한 번만 순차 스캔하여 해당 값들을 찾음</li>
<li>실제 삭제</li>
</ol>
<p>이러한 과정을 거쳐 삭제 처리가 완료된다.</p>
<br>

<p>반면 <code>@Query + @Modifying</code> 방식은 조회 없이 바로 삭제하지만, 서브쿼리를 실행해야 한다.</p>
<pre><code class="language-sql">DELETE FROM cart_option 
WHERE cart_item_id IN (
    SELECT cart_item_id FROM cart_item WHERE cart_id = 1
);</code></pre>
<p>MySQL이 위 쿼리를 처리하는 방법은 다음과 같다.</p>
<ol>
<li><strong>서브쿼리 실행</strong></li>
</ol>
<pre><code class="language-sql">SELECT cart_item_id FROM cart_item WHERE cart_id = 1
→ 결과: [101, 102, 103, 104, 105, 106, 107, 108, 109, 110]


이 과정에서 아래와 같은 과정이 이루어진다.
- cart_item 테이블 스캔
- WHERE 조건 평가
- 결과 10개 추출
- 임시 메모리 영역에 저장</code></pre>
<ol start="2">
<li><strong>임시 테이블 생성</strong> 
MySQL은 서브쿼리 결과를 임시 테이블(derived table)에 저장하고, 메모리 또는 디스크에 임시 공간 할당한다.</li>
</ol>
<ol start="3">
<li><p><strong>메인 쿼리와 조인</strong>
cart_option 테이블과 임시 테이블을 조인한다.</p>
<pre><code>조인 조건 : cart_option.cart_item_id = 임시테이블.cart_item_id</code></pre></li>
<li><p><strong>삭제 실행 :</strong> 조인 결과로 매칭된 행들을 삭제한다.</p>
</li>
</ol>
<br>

<p>이처럼 <code>@Query + @Modifying</code> 방식은 서브쿼리 실행, 임시 테이블 생성, 조인 처리 등의 <strong>구조적 오버헤드</strong>가 발생한다.</p>
<p>이러한 오버헤드는 데이터 개수와 무관하게 항상 발생하는 준비 작업이다.</p>
<p>소량 데이터에서는 실제 데이터를 삭제하는 시간보다 이런 준비 작업 시간이 더 오래 걸려서 비효율적이다.</p>
<p><code>deleteAllInBatch</code>의 IN 절 방식은 임시 테이블 생성 X, 서브쿼리 실행 X, 단순 인덱스 스캔으로 빠른 처리하기 때문에</p>
<p>30개 정도의 소량 데이터에서는 조회 비용을 감안하더라도,
MySQL의 IN 절 최적화가 매우 효과적으로 작동하여 서브쿼리 방식보다 빠른 성능을 보인다.</p>
<br>

<blockquote>
<h4 id="테스트-결과의-한계">테스트 결과의 한계</h4>
</blockquote>
<p>이번 테스트는 <strong>소량 데이터(상품 10개 + 옵션 30개)</strong>로만 진행했다.</p>
<p><code>deleteAllInBatch</code>가 가장 빨랐지만, 대량 데이터에서는 결과가 달라질 수 있다.</p>
<p><code>deleteAllInBatch</code>는 데이터가 적을 땐 부담이 없지만 1000개가 되면 
1000개 객체를 메모리에 로딩하고, 긴 IN 절 (1000개) 파싱해야 하기 때문에 부담이 크다.</p>
<p>반면 <code>@Query + @Modifying</code> 는 조회 없이 서브쿼리로 바로 삭제하므로
데이터가 많아져도 안정적일 것으로 예상된다.</p>
<br>

<h2 id="4-배운점">4. 배운점</h2>
<p><strong>이론대로만 생각하면 놓치는 것들이 있다</strong></p>
<p>처음에는 @Query + @Modifying 방식이
쿼리를 한 번만 실행하니 당연히 가장 빠를 것이라고 생각했다.</p>
<p>하지만 실제로 테스트해보니
deleteAllInBatch가 더 빠른 결과가 나와 예상과 달라서 꽤 당황했다.</p>
<p>이 경험을 통해
이론에서 배운 내용만으로 성능을 판단하는 데에는 한계가 있다는 것을 느꼈다.
특히 데이터 개수나 실행 상황에 따라 결과가 달라질 수 있다는 점을 직접 확인할 수 있었다.</p>
<p>데이터가 많지 않은 경우에는 deleteAllInBatch 방식이 더 효율적으로 동작했고</p>
<p>데이터가 많아질 경우에는 다른 방식이 더 적합할 수도 있다는 가능성을 알게 되었다</p>
<p>앞으로는 “이 방식이 더 좋다”라고 단정하기보다,
지금 상황에서는 왜 이 방식이 맞는지 고민해봐야겠다고 느꼈다.</p>
<br>

<p><strong>성능은 생각이 아니라 직접 재봐야 알 수 있다</strong></p>
<p>기존 코드에서 DELETE 쿼리가 여러 번 실행되는 것을 보고
“쿼리가 많으니까 무조건 느릴 것 같다”라고 생각했다.</p>
<p>그래서 쿼리 수를 줄이는 데 집중했는데,
막상 측정해보니 쿼리 수가 더 많았던 Cascade 방식이
오히려 전체 실행 시간은 조금 더 빠른 결과를 보였다.</p>
<p>이 결과를 통해
쿼리 개수가 적다고 해서 항상 빠른 것은 아니라는 점을 배웠다.
각 쿼리가 어떤 방식으로 실행되는지,
그 과정에서 어떤 비용이 드는지도 함께 봐야 한다는 것을 알게 되었다.</p>
<p>앞으로는 추측으로 결론을 내리기보다,
반드시 로그와 측정 결과를 먼저 확인하는 습관을 들여야겠다고 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[바나프레소 벤치마킹 카페 주문 시스템 즐겨찾기 동시성 제어 3가지 방법 비교 분석 (낙관적 락 vs 비관적 락 vs 원자적 UPDATE)]]></title>
            <link>https://velog.io/@se0o_129/concurrency-control-comparison</link>
            <guid>https://velog.io/@se0o_129/concurrency-control-comparison</guid>
            <pubDate>Sat, 08 Nov 2025 07:36:24 GMT</pubDate>
            <description><![CDATA[<p>조원들과 자주 이용하던 바나프레소를 벤치마킹하여 카페 주문 시스템을 개발했다.</p>
<p>이번 글에서는 카페 주문 시스템을 개발하며 경험한 동시성 이슈 중 하나인 즐겨찾기 동시성 문제에 대한 원인과 해결 과정을 정리해보고자 한다.
<br></p>
<h2 id="1-문제-발견">1. 문제 발견</h2>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/db002d02-7246-484f-af2f-f92befd2bac7/image.png" alt="">프로젝트에서 즐겨찾기 기능을 구현하고 로컬에서 테스트할 때는 문제가 없었다.
클릭하면 즐겨찾기가 잘 추가되고, 카운트도 정상적으로 올라는데</p>
<p>하지만 출근 시간대에 많은 사람들이 카페를 정말 많이 이용하는데
&quot;실제로 피크타임에 여러 사용자가 하나의 상품에 대해서 동시에 즐겨찾기를 하면 어떻게 될까?&quot;라는 의문이 들었다.</p>
<p>실제 서비스 환경에서는 다수의 사용자가 동시에 같은 기능을 사용할 수 있다.</p>
<p>특히 프로모션이나 인기 상품의 경우, 순간적으로 집중된 트래픽이 발생할 수 있다고 생각한다.</p>
<p>이런 상황을 시뮬레이션하기 위해 
<strong>1000명의 동시 요청</strong>을 테스트 시나리오로 설정했다.</p>
<p>(실제로는 100명만 동시 접속해도 충분히 문제가 발생할 수 있지만, 여유를 두고 더 극단적인 상황을 가정했다.)</p>
<br>

<h2 id="2-원인-분석">2. 원인 분석</h2>
<h4 id="race-condition이란">Race Condition이란?</h4>
<p>여러 사용자가 동시에 같은 데이터를 읽고 쓸 때, 실행 순서에 따라 결과가 달라지는 상황을 말한다.</p>
<p>즐겨찾기 카운트를 증가시키는 것을 아래 3단계로 나뉜다. </p>
<pre><code class="language-sql">1. 현재 값 읽기 (Read)
2. 1 증가시키기 (Modify)
3. 증가한 값 저장하기 (Write)</code></pre>
<p>문제는 여러 사용자가 동시에 이 과정을 실행할 때 발생한다.</p>
<pre><code class="language-sql">사용자 A : count = 5 읽기 → 6으로 변경 → 저장
사용자 B : count = 5 읽기 → 6으로 변경 → 저장
                   ↑ 둘 다 5 를 읽어버린다 !</code></pre>
<p>두 사용자가 모두 <em><strong>&quot;5&quot;</strong>_를 읽고 **</em>&quot;6&quot;_**으로 저장하면서, 
실제로는 2번 증가해야 하는데 1번만 증가하게 된다.</p>
<p>이것을 바로 <strong>Race Condition(경쟁 상태)</strong>라고 말한다. </p>
<p><strong>Race Condition의 발생 조건</strong>은 아래와 같다.</p>
<pre><code class="language-sql">1. 공유 자원 (Shared Resource)
여러 스레드가 접근하는 데이터 (예: DB의 count)

2. 동시 접근 (Concurrent Access)
여러 스레드가 동시에 접근

3. 최소 하나의 쓰기 (At Least One Write)
읽기만 하면 문제 없음, 쓰기가 있어야 문제 발생</code></pre>
<p>이를 해결하기 위해 
낙관적 락 / 비관적 락 / 원자적 UPDATE 방식을 비교하여 
최선의 해결 방안을 찾아보려고 한다.</p>
<br>

<h2 id="3-테스트-환경">3. 테스트 환경</h2>
<h4 id="개발-환경">개발 환경</h4>
<ul>
<li><strong>Java</strong>: 21 (LTS)</li>
<li><strong>Spring Boot</strong>: 3.2.x</li>
<li><strong>ORM</strong>: Spring Data JPA (Hibernate 6.x)</li>
<li><strong>Database</strong>: MySQL 8.0 (로컬 환경)</li>
</ul>
<h4 id="테스트-구성">테스트 구성</h4>
<ul>
<li><strong>테스트 프레임워크</strong> : JUnit 5</li>
<li><strong>동시성 제어</strong> : <code>CountDownLatch</code> + <code>ExecutorService</code></li>
<li><strong>시나리오</strong> : 1000명이 동시에 아이스 아메리카노 즐겨찾기 추가</li>
<li><strong>Thread Pool 크기</strong> : 100개 스레드</li>
<li><strong>반복 횟수</strong> : 각 테스트 1회 실행</li>
</ul>
<br>

<h4 id="테스트-코드-전체적인-공통-틀">테스트 코드 전체적인 공통 틀</h4>
<pre><code class="language-java">private static final int THREAD_COUNT = 1000;
private static final int POOL_SIZE = 1000;

@Test
void 낙관적_락_동시성_테스트() {
    ExecutorService executorService = Executors.newFixedThreadPool(POOL_SIZE);
    CountDownLatch latch = new CountDownLatch(THREAD_COUNT);

    long productId = 1; // 아이스아메리카노에 대한 product id

    for (int i = 0; i &lt; THREAD_COUNT; i++) {
        final long memberId = i + 1L; // 1 ~ 1000번 memberId를 사용자
        executorService.execute(() -&gt; {
            try {
                favoriteService.addFavorite(1L);
            } catch (Exception e) {

            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();
}</code></pre>
<h4 id="즐겨찾기-count를-해야하는-product-엔티티">즐겨찾기 count를 해야하는 product 엔티티</h4>
<pre><code class="language-java">public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;product_id&quot;)
    private Long id;

    @Column(name = &quot;product_name&quot;)
    private String productName;

    @Column(name = &quot;product_content&quot;)
    private String productContent;

    @Column(name = &quot;product_photo&quot;)
    private String productPhoto;

    private Integer price;

    @Column(name = &quot;favorite_count&quot;, nullable = false, columnDefinition = &quot;bigint default 0&quot;)
    @Builder.Default
    private Long favoriteCount = 0L;

    @Column(name = &quot;version&quot;, columnDefinition = &quot;bigint default 0&quot;)
    private Long version = 0L;

    // 1:N
    @OneToMany(mappedBy = &quot;product&quot;, cascade = CascadeType.ALL, orphanRemoval = true)
    private List&lt;ProductOption&gt; options = new ArrayList&lt;&gt;();

    // 1:1
    @OneToOne(mappedBy = &quot;product&quot;, cascade = CascadeType.ALL, orphanRemoval = true)
    private NutritionInfo nutritionInfo;

    // 1:N
    @OneToMany(mappedBy = &quot;product&quot;, cascade = CascadeType.ALL, orphanRemoval = true)
    private Set&lt;Allergen&gt; allergens = new HashSet&lt;&gt;();

    // 1:N - Category
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;category_id&quot;, nullable = false)
    private Category category;

    // 1:N 관계 매핑 (즐겨찾기만)
    @OneToMany(mappedBy = &quot;product&quot;, cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List&lt;Favorite&gt; favorites = new ArrayList&lt;&gt;();

    // 1:N - Hashtag
    @OneToMany(mappedBy = &quot;product&quot;, cascade = CascadeType.ALL, orphanRemoval = true)
    private Set&lt;Hashtag&gt; hashtags = new HashSet&lt;&gt;();

    public void increaseFavoriteCount() {
        if(this.favoriteCount == null) {
            this.favoriteCount = 1L;
        } else {
            this.favoriteCount++;
        }
    }

    public void decreaseFavoriteCount() {
        if(favoriteCount &gt; 0) {
            this.favoriteCount--;
        }
    }
}</code></pre>
<br>
<br>

<h2 id="4-해결-방법-탐색">4. 해결 방법 탐색</h2>
<p>위에서 말했던 세 가지 방안을 테스트하기 앞서
우선 락 없이 1000명이 동시에 즐겨찾기 하는 상황을 테스트해보았다.</p>
<hr>
<h3 id="41-락-없음-문제-상황">4.1 락 없음 (문제 상황)</h3>
<p><strong>Respository</strong>
<img src="https://velog.velcdn.com/images/se0o_129/post/80100357-6b30-4539-b201-cf74af0044e2/image.png" alt=""><strong>Service</strong>
<img src="https://velog.velcdn.com/images/se0o_129/post/6dce0669-09e0-4c37-a9f6-88e3f77a5ad6/image.png" alt=""></p>
<p>이 코드는 <strong>3단계</strong>로 동작한다 :</p>
<pre><code class="language-java">1. Product 조회 (favoriteCount 읽기)
2. Favorite 생성 및 저장
3. Product의 favoriteCount 증가 및 저장</code></pre>
<p><strong>Race Condition 발생 시나리오 :</strong></p>
<pre><code class="language-java">초기 상태 : Product의 favoriteCount = 100

[Thread A]                          [Thread B]
1. count = 100 읽기                 
                                    1. count = 100 읽기  ← 동시에 같은 값!
2. Favorite 저장                    
                                    2. Favorite 저장
3. count = 101로 증가               
                                    3. count = 101로 증가 ← 둘 다 101!
4. count = 101 저장                 
                                    4. count = 101 저장   ← 덮어씀!

결과 : favoriteCount = 101 (기대값: 102)</code></pre>
<p>두 사용자가 모두 즐겨찾기를 추가했지만, 
<strong>카운트는 &quot;1&quot;만 증가</strong>한다 !</p>
<p><img src="https://velog.velcdn.com/images/se0o_129/post/4265f337-e292-48e3-ba41-653fbfdb4e55/image.png" alt="">실제로 1~1000번의 memberId를 돌았는데도 불구하고 처참한 테스트 결과가 나왔다.</p>
<br>

<p><strong>테스트 결과 : 1540ms, 실패율 5.7%</strong>
1000개의 요청 모두 성공적으로 요청되었지만 실제로 DB에 반영된 즐겨찾기는 265개 밖에 되지 않았다.
1000명이 즐겨찾기를 시도했을 때 265명을 제외한 나머지 사람들은 즐겨찾기에 실패했다는 것이다.
실제 서비스에서는 절대 이런 일이 일어나선 안된다는 것을 체감했다.<img src="https://velog.velcdn.com/images/se0o_129/post/2e675487-15c2-43f7-b31f-2d5ae0c6f090/image.png" alt=""></p>
<br>

<hr>
<h3 id="42-낙관적-락-version">4.2 낙관적 락 (@Version)</h3>
<p>데이터 충돌이 자주 발생하지 않을 것이라 낙관적으로 가정하고, 충돌이 발생하면 그때 충돌을 처리하는 방식을 말한다.</p>
<p><strong>장점</strong></p>
<ul>
<li>동시에 여러 트랜잭션이 데이터에 접근하고 변경할 수 있기 때문에 동시성이 높아지고, 시스템의 처리량이 향상된다. (반드시 되는 건 아님)</li>
<li>락을 사용하지 않기 때문에 다른 트랜잭션에서 데이터를 읽을 수 있다.</li>
<li>충돌이 발생했을 때 롤백을 피하고 충돌을 해결할 수 있는 기회를 제공한다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>충돌이 발생할 경우 롤백이 발생할 수 있다. 다른 트랜잭션에서 변경한 데이터와 충돌이 발생하면 예외가 발생하고 롤백 발생</li>
</ul>
<br>

<p><strong>Entity</strong>
낙관적 락을 수행하기 위해 JPA에서 제공하는 @Version 애너테이션을 사용한 Version 컬럼을 추가해주었다.
<img src="https://velog.velcdn.com/images/se0o_129/post/91b2a6c6-d3ca-4231-b527-ac6e5b81f857/image.png" alt=""></p>
<p><strong>Respository</strong>
product 엔티티를 조회하는 쿼리에는 @Lock 애너테이션을 이용하여 Entity 수정시에만 발생하는 낙관적 잠금이 읽기 시에도 발생하도록 설정하였다.
이는 읽기시에도 버전을 체크하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
<img src="https://velog.velcdn.com/images/se0o_129/post/790c457d-83b6-4048-9bb1-a238f42667fd/image.png" alt=""><strong>Service</strong>
낙관적 락은 비관적 락과 달리 충돌에 낙관적이기 때문에 충돌 했을 때 재시도할 수 있도록 재시도 로직을 구현하였다.
초반엔 최대 재시도 횟수를 작성하지 않았었는데 
제한을 두지 않으면 테스트가 무한 루프에 빠져 종료되지 않는 문제가 발생했다.
그래서 제한 횟수를 정하기로 하였고, 약간 여유를 두고 10번으로 지정하였다.
<img src="https://velog.velcdn.com/images/se0o_129/post/1b65956b-0d86-48c4-af87-a14756da0068/image.png" alt=""><img src="https://velog.velcdn.com/images/se0o_129/post/2ef5c11d-8e66-472e-946d-e6a9b63da3a7/image.png" alt="">
10번을 재시도했는데도 불구하고 최대 재시도 횟수를 초과하여 실패하는 경우가 발생했다.
<br></p>
<p><strong>테스트 결과: 3781ms</strong>
낙관적 락 적용 시, 비동기 환경에서도 충돌 빈도가 감소하며 전반적인 성공률이 향상되었다. 다만 일부 요청에서는 여전히 버전 충돌이 발생했으며, 재시도 횟수를 늘릴 경우 100% 성공을 달성할 수 있었지만, 운영 환경에서의 성능 부담을 고려해 최대 재시도 횟수는 10회로 제한하였다. 10번의 재시도에도 실패하는 테스트 결과를 보니 충돌이 너무 심한 상황에서는 낙관적 락의 한계를 보였다.
<img src="https://velog.velcdn.com/images/se0o_129/post/585d9f94-5738-4db5-86c2-d411e7c306d3/image.png" alt=""></p>
<br>

<hr>
<h3 id="43-비관적-락-pessimistic_write">4.3 비관적 락 (PESSIMISTIC_WRITE)</h3>
<p>동시에 누가 수정할 것이라 비관적으로 가정하고, 데이터를 읽는 시점 부터 다른 트랜잭션이 건들지 못하도록 잠그는 방식이다.
이로 인해 데이터를 수정할 땐 다른 트랜잭션이 접근하여 읽거나 수정할 수 없다.</p>
<p><strong>장점</strong></p>
<ul>
<li>데이터를 접근하는 동안 다른 트랜잭션이 접근하지 못하도록 제어할 수 있다. 데이터의 일관성과 동시성을 보장할 수 있다.</li>
<li>데이터에 대한 잠금을 설정하여 다른 트랜잭션의 변경을 차단함으로써 충돌을 방지할 수 있다.</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>동시성이 낮아진다. 데이터를 잠그기 때문에 다른 트랜잭션에서 해당 데이터에 접근하거나 변경하는 것이 제한된다.</li>
<li>락을 사용하므로 다른 트랜잭션이 해당 데이터를 읽을 수 없다.</li>
<li>잠금을 설정한 상태에서 해당 트랜잭션의 작업이 오래 걸리면 다른 트랜잭션들이 대기하게 되어 시스템 성능이 저하될 수 있다.</li>
</ul>
<br>

<p><strong>Repository</strong>
비관적 락의 LockModeType은 다른 트랜잭션이 읽고 쓰는 동안 읽는 걸 막기 위해 PESSIMISTIC_WRITE로 걸었다.
<img src="https://velog.velcdn.com/images/se0o_129/post/92645980-96bb-40d9-b46d-bdba265c77db/image.png" alt="">** Service**
서비스 코드는 락 없이 테스트 했을 때와 동일하게 작성하였다.
<br></p>
<p><strong>테스트 결과: 2274ms, 성공률 100%</strong>
성공률 100%를 확인했고 낙관적 락에 비해 실행시간이 적게 걸렸지만 여전히 느린 속도였다.
<img src="https://velog.velcdn.com/images/se0o_129/post/742bf723-6046-4553-bc60-ab5bcf83dd5b/image.png" alt=""></p>
<br>

<h3 id="44-원자적-update">4.4 원자적 UPDATE</h3>
<p>여러 스레드나 트랜잭션이 동시에 같은 데이터를 수정하더라도 데이터가 꼬이지 않도록 보장하는 갱신 방식이다.</p>
<p>읽기-수정-쓰기를 데이터베이스 레벨에서 한 번에 처리하여 
중간에 다른 작업이 끼어들 수 없다.</p>
<br>

<p><strong>Respository</strong>
JPA에서는 @Modifying과 JPQL UPDATE를 사용해 한 번의 SQL로 수정 연산을 처리할 수 있다.
이 방식으로 원자적 UPDATE 방식을 수행했다.
<img src="https://velog.velcdn.com/images/se0o_129/post/48bdf0e3-a2c8-424b-9935-11b0b1650f6a/image.png" alt=""></p>
<br>

<p><strong>테스트 결과: 954ms, 성공률 100%</strong>
테스트 결과 모든 테스트 케이스 중에서 가장 짧은 실행시간을 보였고 100% 성공률을 확인했다.<img src="https://velog.velcdn.com/images/se0o_129/post/db8db3c3-c1f8-4212-9739-f8a243ac47f0/image.png" alt=""></p>
<br>
<br>

<h2 id="5-성능-비교">5. 성능 비교</h2>
<table>
<thead>
<tr>
<th>방식</th>
<th>실행시간</th>
<th>성공률</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>락 없음</td>
<td>1540ms</td>
<td>26.5%</td>
<td>빠르지만 데이터 손실</td>
</tr>
<tr>
<td>낙관적 락</td>
<td>3781ms</td>
<td>89.6%</td>
<td>안전하지만 느림</td>
</tr>
<tr>
<td>비관적 락</td>
<td>2274ms</td>
<td>100%</td>
<td>안전하지만 느림</td>
</tr>
<tr>
<td>원자적 UPDATE</td>
<td>954ms</td>
<td>100%</td>
<td>빠르고 안전</td>
</tr>
</tbody></table>
<br>
<br>

<h2 id="6-최종-선택과-이유">6. 최종 선택과 이유</h2>
<p>세 가지 동시성 제어 방식을 비교 분석한 결과, 최종적으로 원자적 UPDATE 방식을 선택했다.</p>
<p><strong>선택 근거</strong></p>
<ol>
<li><p><strong>단순성과 효율성의 균형</strong></p>
<ul>
<li>별도의 버전 관리 필드나 락 획득 로직이 필요 없어 코드가 간결하다.</li>
<li>데이터베이스 수준에서 원자성이 보장되므로 추가적인 동시성 제어 로직이 불필요하다.</li>
</ul>
</li>
<li><p><strong>성능상의 이점</strong></p>
<ul>
<li>낙관적 락처럼 재시도 로직이 필요 없어 불필요한 오버헤드가 없다.</li>
<li>비관적 락보다 락 대기 시간이 짧아 처리량이 높다.</li>
<li>단일 쿼리로 조회와 업데이트를 동시에 처리할 수 있다.</li>
</ul>
</li>
<li><p><strong>좋아요 기능의 특성에 적합</strong></p>
<ul>
<li>좋아요 카운트는 단순 증감 연산이므로 복잡한 비즈니스 로직이 필요 없다.</li>
<li>높은 동시성 환경에서도 안정적으로 동작한다.</li>
<li>일시적인 정확도보다 최종적인 일관성이 더 중요한 요구사항에 부합한다.</li>
</ul>
</li>
</ol>
<br>

<h2 id="7-배운-점">7. 배운 점</h2>
<p>이번에 다양한 락 전략을 직접 테스트해보면서, 
이론으로만 알고 있던 개념들이 
실제 환경에서 어떻게 다른 결과를 만드는지 체감할 수 있었다.</p>
<p>특히 세 가지 방식을 동일한 조건에서 비교해보니 생각보다 차이가 분명했다.</p>
<p>락 없이 동작하던 코드는 실제 동시 요청 상황에서 데이터 손실이 발생했고, 테스트에서도 1000명 중 265명만 정상 반영되었다. 
이를 통해 <strong>동시성 제어가 선택이 아니라 필수</strong>라는 점을 다시 느꼈다.</p>
<p><strong>낙관적 락</strong>은 충돌이 적을 때는 효율적이지만, 
충돌이 많아지면 재시도 비용이 빠르게 증가했다. 
재시도 횟수를 늘리면 성공률을 높일 수 있었지만 그만큼 시스템 부하도 커졌다.</p>
<p>반면 <strong>비관적 락</strong>은 안정적으로 동시성을 제어했지만, 
처리량이 줄어 전체 실행 시간이 가장 오래 걸렸다.</p>
<p>흥미로웠던 점은 <strong>원자적 UPDATE 방식</strong>이 가장 빠르고 안정적인 결과를 보였다는 것이었다.
복잡한 락 없이도 DB의 원자성을 활용하면 애플리케이션 부담을 줄이면서 문제를 해결할 수 있다는 점을 직접 확인할 수 있었다.</p>
<p>이번 테스트를 통해 단순히 기능이 동작하는지를 넘어, 
동시 요청과 높은 부하 상황에서 어떤 문제가 발생할 수 있는지까지 고려하는 시각이 중요하다는 것을 배웠다.</p>
<br>
]]></description>
        </item>
    </channel>
</rss>