<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>loaded_diaper.log</title>
        <link>https://velog.io/</link>
        <description>끝까지 가면 내가 다 이겨</description>
        <lastBuildDate>Fri, 28 Aug 2026 03:31:39 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>loaded_diaper.log</title>
            <url>https://velog.velcdn.com/images/loaded_diaper/profile/bc086b32-f33c-445a-93a4-4e234266ea12/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. loaded_diaper.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/loaded_diaper" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Sale Commerce 조회 API DTO 직접 조회]]></title>
            <link>https://velog.io/@loaded_diaper/Sale-Commerce-%EC%A1%B0%ED%9A%8C-API-DTO-%EC%A7%81%EC%A0%91-%EC%A1%B0%ED%9A%8C</link>
            <guid>https://velog.io/@loaded_diaper/Sale-Commerce-%EC%A1%B0%ED%9A%8C-API-DTO-%EC%A7%81%EC%A0%91-%EC%A1%B0%ED%9A%8C</guid>
            <pubDate>Fri, 28 Aug 2026 03:31:39 GMT</pubDate>
            <description><![CDATA[<h1 id="🔍-조회-api에서-엔티티-전체를-가져올-필요가-있을까--필요한-7개-컬럼만-직접-조회하기">🔍 조회 API에서 엔티티 전체를 가져올 필요가 있을까? — 필요한 7개 컬럼만 직접 조회하기</h1>
<p>Sale Commerce의 환불 상세 조회 API를 구현한 뒤 코드를 다시 살펴보다 한 가지 의문이 생겼다.</p>
<blockquote>
<p>화면에는 일부 정보만 필요한데 굳이 Refund 엔티티 전체를 조회해야 할까?</p>
</blockquote>
<p>기존 API는 정상적으로 동작하고 있었다.</p>
<p>기능적으로 버그가 있었던 것도 아니다.</p>
<p>하지만 읽기 전용 API의 목적에 비해 불필요한 데이터를 조회하고 있었다.</p>
<p>이번 글에서는 <strong>엔티티 전체 조회 방식에서 실제 응답에 필요한 데이터만 DTO로 직접 조회하도록 리팩토링한 과정</strong>을 정리한다.</p>
<hr>
<h2 id="🔥-기존-조회-구조">🔥 기존 조회 구조</h2>
<p>기존 환불 상세 조회 흐름은 다음과 같았다.</p>
<pre><code>Controller
 ↓
Query Service
 ↓
Refund Entity 전체 조회
 ↓
영속성 컨텍스트 관리
 ↓
DTO 변환
 ↓
Response</code></pre><p>Repository에서 환불 ID와 회원 ID를 조건으로 Refund Entity를 조회한 뒤 Service에서 응답 DTO로 변환했다.</p>
<p>일반적인 JPA 조회 방식이고 기능 자체에는 문제가 없었다.</p>
<p>하지만 실제 API 응답을 다시 살펴보니 필요한 값은 일부뿐이었다.</p>
<pre><code>환불 ID

주문 ID

결제 ID

환불 금액

환불 상태

환불 요청 시각

환불 완료 시각</code></pre><p>실제 응답에 필요한 값은 총 <strong>7개</strong>였다.</p>
<hr>
<h2 id="🤔-데이터를-변경하기-위한-조회인가-보여주기-위한-조회인가">🤔 데이터를 변경하기 위한 조회인가, 보여주기 위한 조회인가?</h2>
<p>해당 API는 환불 데이터를 변경하지 않는다.</p>
<p>사용자가 자신의 환불 상세 정보를 확인하기 위한 <strong>읽기 전용 API</strong>다.</p>
<p>Entity를 조회하면 JPA는 해당 객체를 영속성 컨텍스트에서 관리한다.</p>
<p>이는 데이터를 변경해야 하는 로직에서는 매우 유용하다.</p>
<p>하지만 이번 API에서는</p>
<pre><code>Entity 조회
 ↓
아무런 상태 변경 없음
 ↓
DTO로 변환
 ↓
응답</code></pre><p>만 수행하고 있었다.</p>
<p>Entity 자체가 가지고 있는 변경 기능이나 도메인 로직이 필요하지 않았다.</p>
<p>그래서 한 가지 기준을 세웠다.</p>
<pre><code>데이터 변경이 필요한 기능
→ Entity 조회

화면에 보여주기만 하는 조회
→ 필요한 데이터만 직접 조회</code></pre><hr>
<h2 id="✅-필요한-컬럼만-dto로-직접-조회">✅ 필요한 컬럼만 DTO로 직접 조회</h2>
<p>조회 전용 객체를 별도로 만들고 Repository에서 실제 응답에 필요한 값만 직접 조회하도록 변경했다.</p>
<p>기존 구조는</p>
<pre><code>DB
 ↓
Refund Entity 전체
 ↓
응답 DTO 변환</code></pre><p>이었다.</p>
<p>변경 후에는</p>
<pre><code>DB
 ↓
필요한 7개 컬럼 조회
 ↓
조회 DTO</code></pre><p>형태가 됐다.</p>
<p>QueryDSL을 사용해 응답에 필요한 컬럼만 Projection했다.</p>
<hr>
<h2 id="🔄-api를-사용하는-쪽에서는-아무것도-바뀌지-않도록">🔄 API를 사용하는 쪽에서는 아무것도 바뀌지 않도록</h2>
<p>이번 작업의 목적은 내부 조회 구조를 개선하는 것이었다.</p>
<p>따라서 프론트엔드나 API 사용자에게까지 변경을 요구하면 불필요한 영향 범위가 커진다.</p>
<p>기존 API 주소인</p>
<pre><code>GET /refunds/{refundId}</code></pre><p>를 그대로 유지했다.</p>
<p>응답 형식 역시 동일하게 유지했다.</p>
<p>즉</p>
<pre><code>외부 API 계약
→ 그대로 유지

내부 조회 방식
→ Entity 전체 조회에서 DTO 직접 조회로 변경</code></pre><p>한 것이다.</p>
<hr>
<h2 id="🧪-테스트로-기존-응답이-유지되는지-확인">🧪 테스트로 기존 응답이 유지되는지 확인</h2>
<p>내부 구현을 변경했다고 해서 기존 기능의 결과까지 달라지면 안 된다.</p>
<p>그래서 조회 Service 테스트에서 응답 필드들을 다시 검증했다.</p>
<pre><code>refundId

orderId

paymentId

refundAmount

status

requestedAt

completedAt</code></pre><p>기존 API와 동일한 데이터가 반환되는 것을 확인해 리팩토링 과정에서 발생할 수 있는 회귀를 방지했다.</p>
<hr>
<h2 id="📊-결과">📊 결과</h2>
<p>기존에는 Refund Entity 전체를 조회한 뒤 DTO로 변환했다.</p>
<p>변경 후에는 DB에서 <strong>응답에 실제 필요한 7개 컬럼만 직접 조회</strong>하도록 만들었다.</p>
<p>이번 작업은</p>
<pre><code>500ms → 50ms</code></pre><p>처럼 눈에 띄는 성능 수치를 만든 최적화는 아니다.</p>
<p>그래서 측정하지 않은 성능 개선 수치를 억지로 만들지 않았다.</p>
<p>대신</p>
<blockquote>
<p>읽기 전용 API에서 필요하지 않은 Entity 전체를 조회할 필요가 있는가?</p>
</blockquote>
<p>라는 관점에서 조회 구조를 다시 생각했고, API의 목적에 맞게 필요한 데이터만 가져오도록 개선했다.</p>
<hr>
<h2 id="💡-이번-리팩토링에서-배운-점">💡 이번 리팩토링에서 배운 점</h2>
<p>JPA를 사용한다고 해서 모든 조회를 반드시 Entity로 해야 하는 것은 아니다.</p>
<p>Entity는 단순히 DB의 데이터를 담는 객체가 아니라 도메인 상태와 행동을 관리하는 역할을 가진다.</p>
<p>반대로 화면에 데이터를 보여주는 것만이 목적인 조회 API에서는 Entity 전체가 필요하지 않을 수도 있다.</p>
<p>이번 작업을 통해 앞으로 조회 API를 구현할 때</p>
<blockquote>
<p><strong>&quot;이 기능에서 정말 Entity가 필요한가?&quot;</strong></p>
</blockquote>
<p>를 먼저 생각해볼 수 있게 됐다.</p>
<p>데이터 변경이 필요한 기능과 단순 조회 기능의 목적을 구분하고, 상황에 맞는 조회 방식을 선택하는 것이 중요하다는 것을 배웠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Sale Commerce 결제 실패 재고 복구]]></title>
            <link>https://velog.io/@loaded_diaper/Sale-Commerce-%EA%B2%B0%EC%A0%9C-%EC%8B%A4%ED%8C%A8-%EC%9E%AC%EA%B3%A0-%EB%B3%B5%EA%B5%AC</link>
            <guid>https://velog.io/@loaded_diaper/Sale-Commerce-%EA%B2%B0%EC%A0%9C-%EC%8B%A4%ED%8C%A8-%EC%9E%AC%EA%B3%A0-%EB%B3%B5%EA%B5%AC</guid>
            <pubDate>Fri, 28 Aug 2026 03:31:02 GMT</pubDate>
            <description><![CDATA[<h1 id="🔄-결제-실패-재고-복구도-동시성-문제였다--재고-8-→-9-→-10-검증하기">🔄 결제 실패 재고 복구도 동시성 문제였다 — 재고 8 → 9 → 10 검증하기</h1>
<p>동시성 문제라고 하면 보통 여러 사용자가 동시에 상품을 구매하면서 <strong>재고를 감소시키는 상황</strong>을 먼저 떠올린다.</p>
<p>나 역시 처음에는 선착순 구매 과정만 안전하게 만들면 된다고 생각했다.</p>
<p>하지만 특가상품 구매 이후 결제에 실패하면 차감했던 재고를 다시 증가시켜야 한다.</p>
<p>이 <strong>재고 복구 과정에서도 동시성 문제가 발생할 수 있었다.</strong></p>
<p>이번 글에서는 결제 실패 처리 과정에서</p>
<ul>
<li>동일 주문의 중복 재고 복구</li>
<li>서로 다른 주문의 동시 재고 복구</li>
<li>Lost Update</li>
</ul>
<p>문제를 어떻게 처리하고 테스트했는지 정리한다.</p>
<hr>
<h2 id="🔥-문제-1-같은-주문이-두-번-실패-처리된다면">🔥 문제 1. 같은 주문이 두 번 실패 처리된다면?</h2>
<p>특가상품 재고가 10개 있다고 생각해보자.</p>
<p>사용자가 상품 하나를 구매한다.</p>
<pre><code>재고 10
 ↓
주문 생성
 ↓
재고 9</code></pre><p>이후 결제에 실패하면 차감했던 재고를 다시 복구해야 한다.</p>
<pre><code>9 → 10</code></pre><p>그런데 동일 주문에 대한 결제 실패 요청이 두 번 들어온다면 어떻게 될까?</p>
<p>아무런 검증이 없다면</p>
<pre><code>첫 번째 실패 요청
9 → 10

두 번째 실패 요청
10 → 11 ❌</code></pre><p>실제 초기 재고보다 더 많은 재고가 생길 수 있다.</p>
<p>따라서 동일한 주문의 결제 실패가 이미 처리됐는지 확인하고 <strong>한 번만 재고가 복구되도록 해야 했다.</strong></p>
<hr>
<h2 id="🔥-문제-2-서로-다른-주문이-동시에-실패한다면">🔥 문제 2. 서로 다른 주문이 동시에 실패한다면?</h2>
<p>이번에는 서로 다른 두 주문이 같은 특가상품을 하나씩 구매했다고 생각해보자.</p>
<pre><code>초기 재고 : 10

주문 A 구매
10 → 9

주문 B 구매
9 → 8</code></pre><p>이후 두 주문의 결제가 거의 동시에 실패한다.</p>
<p>최종적으로는 두 재고가 모두 돌아와야 하기 때문에</p>
<pre><code>8 → 9 → 10</code></pre><p>이 되어야 한다.</p>
<p>하지만 두 Thread가 동시에 현재 재고를 8로 읽는다면 다음과 같은 문제가 생길 수 있다.</p>
<pre><code>Request A
현재 재고 8 읽음
→ 9 저장

Request B
현재 재고 8 읽음
→ 9 저장</code></pre><p>두 번의 복구가 발생했지만 최종 재고는 9가 된다.</p>
<p>한 번의 업데이트가 사라지는 <strong>Lost Update</strong>다.</p>
<hr>
<h2 id="✅-같은-주문의-중복-실패는-주문-lock으로-제어">✅ 같은 주문의 중복 실패는 주문 Lock으로 제어</h2>
<p>먼저 동일한 주문이 두 번 실패 처리되는 것을 막아야 했다.</p>
<p>결제 실패 처리 시 주문 Row Lock을 획득하고 현재 주문과 결제 상태를 확인했다.</p>
<p>이미 실패 처리가 완료된 주문이라면 다시 실패 처리하지 않는다.</p>
<pre><code>결제 실패 요청
 ↓
주문 Row Lock
 ↓
이미 처리된 주문인가?
 ↓
YES → 중복 처리 차단

NO
 ↓
결제 실패 처리 진행</code></pre><p>이렇게 동일한 주문이 여러 번 처리되면서 재고가 반복적으로 복구되는 것을 방지했다.</p>
<hr>
<h2 id="✅-서로-다른-주문의-재고-경쟁은-상품-lock으로-제어">✅ 서로 다른 주문의 재고 경쟁은 상품 Lock으로 제어</h2>
<p>하지만 주문 Row Lock만으로는 두 번째 문제가 해결되지 않는다.</p>
<p>주문 A와 주문 B는 서로 다른 주문이기 때문이다.</p>
<pre><code>주문 A Lock
주문 B Lock

→ 서로 다른 Row이므로 동시에 획득 가능</code></pre><p>하지만 두 주문이 변경하려는 <strong>특가상품 재고는 동일하다.</strong></p>
<p>그래서 실제 재고를 복구할 때는 특가상품 Row에도 Lock을 적용했다.</p>
<pre><code>Order A Lock
       \
        → 동일 Promotion Product Lock
       /
Order B Lock</code></pre><p>같은 상품의 재고를 수정하는 작업을 순서대로 처리해 Lost Update를 방지했다.</p>
<hr>
<h2 id="✅-결제-실패와-재고-복구-이력을-하나의-트랜잭션으로">✅ 결제 실패와 재고 복구 이력을 하나의 트랜잭션으로</h2>
<p>결제 실패 시에는 단순히 재고만 증가시키는 것이 아니다.</p>
<p>다음 작업들이 함께 수행된다.</p>
<pre><code>실패 Payment 저장

+

재고 복구

+

StockHistory 복구 이력 저장</code></pre><p>만약</p>
<pre><code>Payment 저장 성공
재고 복구 성공
StockHistory 저장 실패</code></pre><p>처럼 일부만 DB에 반영된다면 이후 재고가 왜 변경됐는지 추적하기 어려워진다.</p>
<p>그래서 세 가지 작업을 하나의 트랜잭션으로 묶었다.</p>
<pre><code>BEGIN

실패 Payment 저장

재고 +1

재고 복구 StockHistory 저장

COMMIT</code></pre><p>중간에 하나라도 실패하면 전체 DB 변경을 Rollback하도록 구성했다.</p>
<hr>
<h2 id="🧪-실제-동시성-테스트">🧪 실제 동시성 테스트</h2>
<p>테스트 조건은 다음과 같이 구성했다.</p>
<pre><code>초기 이벤트 재고 : 10개

주문 A 구매 이후 : 9개

주문 B 구매 이후 : 8개

동시에 처리할 결제 실패 요청 : 2건</code></pre><p>두 결제 실패 요청을 동시에 실행했다.</p>
<p>정상적인 결과는 다음과 같아야 한다.</p>
<pre><code>8 → 9 → 10</code></pre><p>테스트 결과 최종 재고는 정확히 <strong>10개</strong>로 복구됐다.</p>
<p>추가로 다음 데이터도 함께 확인했다.</p>
<pre><code>FAILED Payment
→ 2건

StockHistory RESTORE
→ 2건</code></pre><p>재고 이력에도</p>
<pre><code>8 → 9
9 → 10</code></pre><p>순서로 정확하게 기록되는 것을 확인했다.</p>
<hr>
<h2 id="💡-이번-구현에서-배운-점">💡 이번 구현에서 배운 점</h2>
<p>재고 동시성은 <strong>재고를 줄이는 순간에만 발생하는 문제가 아니었다.</strong></p>
<p>같은 재고 값은 여러 기능에서 변경된다.</p>
<pre><code>구매 성공
→ 재고 감소

결제 실패
→ 재고 증가

환불
→ 재고 증가</code></pre><p>선착순 구매에서 재고 감소만 안전하게 만든다고 해서 전체 재고 정합성이 보장되는 것이 아니었다.</p>
<p>이번 경험을 통해 공유 데이터를 변경하는 기능을 구현할 때는</p>
<blockquote>
<p><strong>&quot;이 데이터를 변경하는 다른 기능은 없는가?&quot;</strong></p>
</blockquote>
<p>를 함께 확인해야 한다는 기준이 생겼다.</p>
<p>하나의 API만 정상적으로 동작하는지 보는 것이 아니라 <strong>같은 데이터를 변경하는 여러 기능이 동시에 실행됐을 때도 결과가 일관적인지 확인하는 것</strong>이 중요하다는 것을 배웠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Sale Commerce PortOne 환불 부분 실패]]></title>
            <link>https://velog.io/@loaded_diaper/Sale-Commerce-PortOne-%ED%99%98%EB%B6%88-%EB%B6%80%EB%B6%84-%EC%8B%A4%ED%8C%A8</link>
            <guid>https://velog.io/@loaded_diaper/Sale-Commerce-PortOne-%ED%99%98%EB%B6%88-%EB%B6%80%EB%B6%84-%EC%8B%A4%ED%8C%A8</guid>
            <pubDate>Fri, 28 Aug 2026 03:30:21 GMT</pubDate>
            <description><![CDATA[<h1 id="💸-돈은-환불됐는데-우리-db는-실패했다면--portone-환불-부분-실패-다루기">💸 돈은 환불됐는데 우리 DB는 실패했다면? — PortOne 환불 부분 실패 다루기</h1>
<p>Sale Commerce에서 환불 기능을 개발하면서 가장 어려웠던 부분은 PortOne 환불 API를 호출하는 것 자체가 아니었다.</p>
<p>진짜 고민은 다음 상황이었다.</p>
<blockquote>
<p>PortOne에서는 실제 환불에 성공했는데<br>이후 우리 서버의 주문 상태 변경이나 재고 복구가 실패하면 어떻게 해야 할까?</p>
</blockquote>
<p>외부 PG와 우리 MySQL DB를 하나의 데이터베이스 트랜잭션으로 묶을 수는 없다.</p>
<p>이번 글에서는 <strong>외부 환불 성공과 내부 처리를 단계별로 분리해 부분 실패가 발생해도 현재 상태를 추적할 수 있도록 개선한 과정</strong>을 정리한다.</p>
<hr>
<h2 id="🔥-외부-api와-db는-함께-rollback되지-않는다">🔥 외부 API와 DB는 함께 Rollback되지 않는다</h2>
<p>일반적인 DB 작업이라면 하나의 트랜잭션 안에서 여러 작업을 수행하다가 하나가 실패하면 전체 작업을 Rollback할 수 있다.</p>
<p>하지만 외부 PG 환불은 다르다.</p>
<pre><code>PortOne 환불 성공 ✅
        ↓
우리 DB 처리 실패 ❌</code></pre><p>우리 DB 트랜잭션을 Rollback한다고 해서 이미 PortOne을 통해 사용자에게 반환된 돈이 다시 자동으로 결제되는 것은 아니다.</p>
<p>즉 다음과 같은 상황이 발생할 수 있다.</p>
<pre><code>실제 PG 상태
→ 환불 완료

우리 DB
→ 환불 처리 실패</code></pre><p><strong>외부 시스템의 상태와 내부 DB의 상태가 서로 달라질 수 있는 것이다.</strong></p>
<hr>
<h2 id="❌-하나의-흐름으로만-처리하면">❌ 하나의 흐름으로만 처리하면?</h2>
<p>환불을 다음과 같은 하나의 흐름으로만 구현했다고 생각해보자.</p>
<pre><code>환불 요청
 ↓
PortOne 환불
 ↓
주문 상태 변경
 ↓
특가상품 재고 복구
 ↓
환불 완료 저장</code></pre><p>PortOne 환불까지는 성공했다.</p>
<p>그런데 이후 재고 복구 과정에서 예외가 발생했다.</p>
<p>그러면 실제 사용자의 돈은 이미 환불됐지만 DB에는 최종 환불 완료가 기록되지 않을 수 있다.</p>
<pre><code>사용자
→ 실제 환불 완료

PortOne
→ 환불 완료

우리 DB
→ 내부 처리 실패</code></pre><p>운영자가 우리 DB만 확인한다면</p>
<blockquote>
<p>&quot;이 환불이 실제 PG에서도 성공했던 건가?&quot;</p>
</blockquote>
<p>를 판단하기 어려울 수 있다.</p>
<hr>
<h2 id="✅-외부-환불-성공-사실을-먼저-확정">✅ 외부 환불 성공 사실을 먼저 확정</h2>
<p>그래서 환불 흐름을 단계별로 나눴다.</p>
<pre><code>1. 환불 요청 상태 저장

2. PortOne 환불 API 호출

3. 외부 환불 성공 정보 별도 저장

4. 주문 상태 변경
   특가상품 재고 복구

5. 내부 처리까지 성공하면 최종 환불 완료</code></pre><p>가장 중요하게 본 것은 <strong>3번 단계</strong>였다.</p>
<p>PortOne에서 실제 환불이 성공했다면 이후 내부 처리에서 문제가 발생하더라도</p>
<blockquote>
<p>&quot;PG 환불 자체는 이미 성공했다.&quot;</p>
</blockquote>
<p>는 사실만큼은 반드시 DB에 남아 있어야 한다고 판단했다.</p>
<p>그래서 PortOne 성공 결과를 <strong>별도의 트랜잭션으로 먼저 저장</strong>하도록 했다.</p>
<hr>
<h2 id="📌-어떤-정보를-먼저-저장했나">📌 어떤 정보를 먼저 저장했나?</h2>
<p>PortOne 환불 성공 직후 다음 3개의 정보를 DB에 기록했다.</p>
<pre><code>PortOne 취소 ID

PortOne 취소 상태

실제 취소 성공 시각</code></pre><p>환불 상태도 단순히</p>
<pre><code>환불 중
→ 환불 완료</code></pre><p>두 단계로만 두지 않았다.</p>
<p>외부 환불 성공과 내부 처리 완료를 구분할 수 있도록 중간 상태를 두었다.</p>
<pre><code>환불 요청

   ↓

PG 환불 성공

   ↓

내부 주문·재고 처리

   ↓

최종 환불 완료</code></pre><p>덕분에 이후 주문 상태 변경이나 재고 복구에 실패하더라도 DB를 통해</p>
<blockquote>
<p>&quot;돈 자체는 이미 환불된 건이다.&quot;</p>
</blockquote>
<p>라는 사실을 확인할 수 있다.</p>
<hr>
<h2 id="🚨-portone-자체가-실패했다면">🚨 PortOne 자체가 실패했다면?</h2>
<p>반대로 PortOne API 호출 자체가 실패했다면 실제 돈은 반환되지 않았다.</p>
<p>이 상황은</p>
<blockquote>
<p>PG 환불 성공 → 내부 처리 실패</p>
</blockquote>
<p>와 완전히 다르다.</p>
<p>그래서 외부 환불 자체가 실패한 경우에는 환불 실패 상태를 기록하고 주문을 기존 결제 완료 상태로 복구하도록 처리했다.</p>
<p>즉 시스템에서 구분해야 할 상황은 크게 다음과 같았다.</p>
<pre><code>1. 환불 요청이 생성됨

2. PG 환불 자체가 실패함

3. PG 환불은 성공했지만 내부 처리가 남아 있음

4. PG 환불 + 내부 처리 모두 완료됨</code></pre><hr>
<h2 id="🤔-왜-try-catch-하나로-끝내지-않았을까">🤔 왜 try-catch 하나로 끝내지 않았을까?</h2>
<p>전체 환불 코드를 하나의 Service에 넣고 예외를 잡는 방식으로도 기능 자체는 구현할 수 있다.</p>
<p>하지만 외부 API와 내부 DB는 <strong>실패의 의미 자체가 다르다.</strong></p>
<pre><code>PortOne 실패
→ 실제 돈이 반환되지 않음

PortOne 성공 + DB 실패
→ 실제 돈은 이미 반환됨</code></pre><p>이 둘을 같은 실패로 처리하면 운영 중 실제 상황을 파악하기 어려워진다.</p>
<p>그래서</p>
<pre><code>환불 요청 저장

외부 환불 성공 기록

내부 처리 완료

외부 실패 처리</code></pre><p>과정을 단계별 Transaction Service로 분리했다.</p>
<p>Facade에서는 전체 환불 흐름을 조율하도록 구성했다.</p>
<hr>
<h2 id="💡-이번-구현에서-배운-점">💡 이번 구현에서 배운 점</h2>
<p>외부 시스템과 내부 DB가 함께 등장하는 기능에서는 단순한 DB Transaction만으로 모든 정합성을 해결할 수 없다.</p>
<p>특히 결제나 환불처럼 실제 돈의 이동이 발생하는 기능에서는</p>
<pre><code>외부 요청은 실제로 성공했는가?

우리 DB에는 어디까지 반영됐는가?

처리 도중 실패했다면 현재 상태를 판단할 근거가 남아 있는가?</code></pre><p>가 매우 중요했다.</p>
<p>이번 경험을 통해</p>
<blockquote>
<p><strong>실패 자체를 완전히 없애는 것만이 안정적인 시스템은 아니다.</strong></p>
</blockquote>
<p>라는 점을 배웠다.</p>
<p>실패가 발생하더라도 <strong>어디까지 성공했고 어디서 실패했는지를 확인할 수 있도록 상태를 남기는 것</strong> 역시 시스템 안정성을 위해 중요하다는 것을 알게 됐다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Sale Commerce Redisson 분산 락]]></title>
            <link>https://velog.io/@loaded_diaper/Sale-Commerce-Redisson-%EB%B6%84%EC%82%B0-%EB%9D%BD</link>
            <guid>https://velog.io/@loaded_diaper/Sale-Commerce-Redisson-%EB%B6%84%EC%82%B0-%EB%9D%BD</guid>
            <pubDate>Fri, 28 Aug 2026 03:29:28 GMT</pubDate>
            <description><![CDATA[<h1 id="🔒-재고-5개에-요청-20개--redisson으로-선착순-구매-제어하기">🔒 재고 5개에 요청 20개 — Redisson으로 선착순 구매 제어하기</h1>
<p>Sale Commerce에서 선착순 특가상품 구매 기능을 구현하면서 가장 먼저 고려해야 했던 문제는 동시성이었다.</p>
<p>재고가 5개뿐인데 20명의 사용자가 거의 동시에 구매 버튼을 누르면 어떻게 될까?</p>
<p>단순히</p>
<pre><code>재고 조회
→ 재고가 0보다 크면
→ 재고 차감</code></pre><p>으로 구현하면 실제로 가지고 있는 재고보다 더 많은 주문이 성공할 가능성이 있다.</p>
<p>이번 글에서는 <strong>Redisson 분산 락과 DB Lock을 이용해 특가상품의 재고 정합성을 제어한 과정</strong>을 정리한다.</p>
<hr>
<h2 id="🔥-문제-상황">🔥 문제 상황</h2>
<p>현재 재고가 1개라고 생각해보자.</p>
<p>두 개의 요청이 동시에 들어온다.</p>
<pre><code>Request A → 재고 조회 → 1
Request B → 재고 조회 → 1

Request A → 구매 가능
Request B → 구매 가능

Request A → 재고 차감
Request B → 재고 차감</code></pre><p>두 요청 모두 자신이 데이터를 읽었던 시점에는 재고가 존재한다고 판단했다.</p>
<p>실제로 판매할 수 있는 상품은 하나였지만 두 주문이 모두 성공할 수 있는 상황이다.</p>
<p>선착순 판매에서는 바로 <strong>초과 판매</strong>로 이어질 수 있다.</p>
<hr>
<h2 id="🤔-synchronized로-막으면-되지-않을까">🤔 synchronized로 막으면 되지 않을까?</h2>
<p>애플리케이션 서버가 하나라면 Java의 <code>synchronized</code>를 이용해 한 번에 하나의 Thread만 구매 로직에 진입하도록 만들 수도 있다.</p>
<p>하지만 애플리케이션 서버가 여러 개라면 문제가 달라진다.</p>
<pre><code>Server A
→ synchronized Lock A

Server B
→ synchronized Lock B</code></pre><p>각 서버는 서로 다른 JVM에서 실행되기 때문에 Server A가 Lock을 획득해도 Server B는 그 사실을 알지 못한다.</p>
<p>여러 애플리케이션 인스턴스에서도 동일한 Lock을 공유할 수 있도록 <strong>Redis 기반 Redisson 분산 락</strong>을 사용했다.</p>
<hr>
<h2 id="✅-상품-단위로-lock-범위-설정">✅ 상품 단위로 Lock 범위 설정</h2>
<p>모든 특가상품 구매 요청을 하나의 Lock으로 막으면 문제가 생긴다.</p>
<p>예를 들어 상품 A가 구매 처리 중이라고 해서 전혀 관계없는 상품 B의 구매 요청까지 기다릴 필요는 없다.</p>
<p>그래서 Lock을 <strong>특가상품 ID 단위</strong>로 구분했다.</p>
<p>개념적으로는 다음과 같은 형태다.</p>
<pre><code>promotion-product:{상품 ID}</code></pre><p>같은 상품을 구매하는 요청끼리만 동일한 Lock을 두고 경쟁한다.</p>
<p>덕분에 서로 다른 상품의 구매는 동시에 처리할 수 있다.</p>
<hr>
<h2 id="🔄-lock을-획득한-다음-재고를-다시-조회">🔄 Lock을 획득한 다음 재고를 다시 조회</h2>
<p>분산 락을 요청하기 전에 조회한 상품 데이터를 Lock 획득 이후에도 그대로 사용하면 안 된다.</p>
<p>Lock을 기다리는 동안 다른 요청이 먼저 재고를 변경했을 수 있기 때문이다.</p>
<p>그래서 구매 흐름을 다음과 같이 구성했다.</p>
<pre><code>구매 대상 상품 확인
 ↓
상품 단위 분산 Lock 획득
 ↓
상품 최신 상태 재조회
 ↓
현재 재고 확인
 ↓
구매 처리
 ↓
Lock 해제</code></pre><p>Lock을 획득한 이후 최신 데이터를 다시 조회함으로써 오래된 재고 값을 기준으로 구매가 처리되지 않도록 했다.</p>
<hr>
<h2 id="🔐-db-lock도-함께-사용한-이유">🔐 DB Lock도 함께 사용한 이유</h2>
<p>분산 락은 여러 애플리케이션 인스턴스가 동일한 임계 영역에 동시에 진입하는 것을 제어한다.</p>
<p>하지만 최종 재고 데이터가 저장되는 곳은 MySQL이다.</p>
<p>그래서 실제 재고를 수정하는 과정에서는 DB의 비관적 쓰기 Lock도 함께 적용해 DB 갱신 과정의 정합성을 보강했다.</p>
<p>또 구매 과정에서만 Lock을 적용하는 것으로 끝내지 않았다.</p>
<p>같은 재고는 다음과 같은 상황에서도 변경된다.</p>
<pre><code>구매 성공
→ 재고 감소

결제 실패
→ 재고 복구

환불
→ 재고 복구</code></pre><p>재고를 감소시키는 로직에는 Lock을 적용하고 증가시키는 경로에서는 아무런 제어를 하지 않는다면 다른 동시성 문제가 생길 수 있다.</p>
<p>그래서 구매·결제 실패·환불 과정에서도 동일한 상품 Lock 기준을 사용하도록 했다.</p>
<hr>
<h2 id="🧪-재고-5개에-동시-요청-20건">🧪 재고 5개에 동시 요청 20건</h2>
<p>동시성 테스트 조건은 다음과 같이 구성했다.</p>
<pre><code>초기 이벤트 재고 : 5개

동시 구매 요청 : 20건

요청당 구매 수량 : 1개</code></pre><p>단순히 성공 요청 개수만 확인한 것은 아니다.</p>
<p>다음 조건들을 함께 검증했다.</p>
<pre><code>성공 주문 수 &lt;= 초기 재고

남은 재고 &gt;= 0

성공 주문 수 + 남은 재고 = 5

생성된 주문 수
= 실제 성공한 구매 수

생성된 주문상품 수
= 실제 성공한 구매 수

생성된 재고이력 수
= 실제 성공한 구매 수</code></pre><p>테스트 결과 <strong>초과 판매와 음수 재고가 발생하지 않는 것</strong>을 확인했다.</p>
<hr>
<h2 id="💡-이번-구현에서-배운-점">💡 이번 구현에서 배운 점</h2>
<p>동시성 문제는 코드를 순서대로 읽을 때는 잘 보이지 않는다.</p>
<pre><code>재고 확인
→ 재고 차감</code></pre><p>이라는 로직 하나만 보면 아무런 문제가 없어 보인다.</p>
<p>하지만 여러 요청이 동시에 실행되는 순간 전혀 다른 문제가 된다.</p>
<p>그래서 이번 기능에서는 구현 자체보다</p>
<blockquote>
<p>&quot;실제로 여러 요청이 같은 순간 들어오면 어떤 결과가 만들어질까?&quot;</p>
</blockquote>
<p>를 테스트로 재현하는 것을 중요하게 생각했다.</p>
<p>또 Lock을 적용할 때 단순히</p>
<blockquote>
<p>&quot;여기에 Lock을 붙이면 되겠지.&quot;</p>
</blockquote>
<p>라고 생각하는 것이 아니라</p>
<pre><code>무엇을 Lock Key로 사용할 것인지

언제 Lock을 획득할 것인지

Lock 획득 후 어떤 데이터를 다시 읽어야 하는지

어디까지를 임계 영역으로 설정할 것인지

재고 복구 경로에도 같은 기준을 적용해야 하는지</code></pre><p>까지 함께 설계해야 한다는 것을 배웠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Sale Commerce 결제 승인 트랜잭션]]></title>
            <link>https://velog.io/@loaded_diaper/Sale-Commerce-%EA%B2%B0%EC%A0%9C-%EC%8A%B9%EC%9D%B8-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98</link>
            <guid>https://velog.io/@loaded_diaper/Sale-Commerce-%EA%B2%B0%EC%A0%9C-%EC%8A%B9%EC%9D%B8-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98</guid>
            <pubDate>Fri, 28 Aug 2026 03:28:37 GMT</pubDate>
            <description><![CDATA[<h1 id="💳-외부-결제-api를-트랜잭션-안에서-호출하면-안-되는-이유--결제-승인-구조-개선">💳 외부 결제 API를 트랜잭션 안에서 호출하면 안 되는 이유 — 결제 승인 구조 개선</h1>
<p>Sale Commerce 프로젝트에서 PortOne을 이용한 결제 승인 기능을 구현했다.</p>
<p>처음 결제 로직을 보면</p>
<blockquote>
<p>&quot;결제와 관련된 작업이니까 전부 하나의 <code>@Transactional</code>로 묶는 게 더 안전하지 않을까?&quot;</p>
</blockquote>
<p>라는 생각을 하기 쉽다.</p>
<p>하지만 외부 API 호출까지 DB 트랜잭션 안에 포함하면 오히려 DB Lock을 불필요하게 오래 유지할 수 있다는 문제가 있었다.</p>
<p>이번 글에서는 <strong>외부 결제 API와 DB 트랜잭션의 범위를 분리하고, 동시에 들어오는 결제 승인 요청까지 방어한 과정</strong>을 정리한다.</p>
<hr>
<h2 id="🔥-문제-상황">🔥 문제 상황</h2>
<p>결제 승인에는 크게 두 가지 종류의 작업이 있었다.</p>
<pre><code>1. PortOne에서 실제 결제 상태와 금액 확인

2. 우리 DB에서
   주문 상태 확인
   Payment 저장
   주문 상태 변경</code></pre><p>만약 이 전체 과정을 하나의 트랜잭션으로 묶고 주문에 Row Lock을 획득한 상태에서 PortOne API를 호출한다면 다음과 같은 구조가 된다.</p>
<pre><code>Transaction 시작
 ↓
주문 Row Lock 획득
 ↓
PortOne API 호출
 ↓
외부 응답 대기...
 ↓
Payment 저장
 ↓
주문 상태 변경
 ↓
Commit</code></pre><p>문제는 외부 API의 응답시간을 우리가 제어할 수 없다는 것이다.</p>
<p>PortOne이 빠르게 응답할 수도 있지만 네트워크나 외부 시스템 상황에 따라 응답이 늦어질 수도 있다.</p>
<p>그 시간 동안 DB의 주문 Row Lock도 계속 유지된다.</p>
<p>같은 주문에 접근하려는 다른 요청은 PortOne의 응답과 아무런 관계가 없음에도 Lock이 풀릴 때까지 기다리게 된다.</p>
<hr>
<h2 id="✅-외부-api-호출과-db-트랜잭션-분리">✅ 외부 API 호출과 DB 트랜잭션 분리</h2>
<p>그래서 전체 결제 승인 과정을 두 단계로 분리했다.</p>
<pre><code>[트랜잭션 밖]

주문 1차 검증
 ↓
PortOne 결제 상태 조회
 ↓
결제 금액 검증


[짧은 DB 트랜잭션]

주문 Row Lock 획득
 ↓
주문 상태 재검증
 ↓
Payment 저장
 ↓
주문 상태 변경
 ↓
Commit</code></pre><p>핵심은 <strong>외부 네트워크 응답을 기다리는 동안 DB Lock을 유지하지 않는 것</strong>이었다.</p>
<hr>
<h2 id="🤔-주문을-앞에서-확인했는데-왜-다시-확인할까">🤔 주문을 앞에서 확인했는데 왜 다시 확인할까?</h2>
<p>PortOne을 호출하기 전에 주문이 결제 가능한 상태인지 이미 확인했다.</p>
<p>그런데 DB 트랜잭션 안에서 다시 한번 주문 상태를 확인했다.</p>
<p>왜 같은 검증을 두 번 했을까?</p>
<p>동시 요청 때문이다.</p>
<p>예를 들어 두 개의 요청이 거의 동시에 들어온다고 생각해보자.</p>
<pre><code>Request A : 주문 확인 → 결제 가능
Request B : 주문 확인 → 결제 가능

Request A : PortOne 확인
Request B : PortOne 확인

Request A : Payment 저장
Request B : Payment 저장 ❌</code></pre><p>PortOne을 호출하는 동안 다른 요청이 먼저 결제를 완료했을 수 있다.</p>
<p>따라서 실제 DB를 변경하는 시점에는 주문에 Row Lock을 획득한 뒤 <strong>현재 시점의 주문 상태를 다시 확인</strong>해야 했다.</p>
<p>즉 두 검증의 목적이 다르다.</p>
<pre><code>외부 API 호출 전 검증
→ 잘못된 요청을 빠르게 차단하기 위한 검증

DB Lock 획득 후 검증
→ 동시 요청에서도 정합성을 보장하기 위한 최종 검증</code></pre><hr>
<h2 id="🔐-db-unique-constraint를-최종-방어선으로">🔐 DB Unique Constraint를 최종 방어선으로</h2>
<p>애플리케이션 코드에서 중복 결제를 검사하더라도 예측하지 못한 동시 요청이 존재할 수 있다.</p>
<p>그래서 PortOne 결제 식별자에도 DB Unique Constraint를 적용했다.</p>
<p>최종적으로 중복 결제를 여러 단계에서 방어하도록 구성했다.</p>
<pre><code>애플리케이션 검증
+
주문 Row Lock
+
DB Unique Constraint</code></pre><hr>
<h2 id="🧪-동일-주문에-결제-요청-2건을-동시에-보내봤다">🧪 동일 주문에 결제 요청 2건을 동시에 보내봤다</h2>
<p>실제 동시성을 확인하기 위해 테스트를 작성했다.</p>
<p>조건은 다음과 같다.</p>
<pre><code>동일한 주문

동시 결제 승인 요청 : 2건

각 요청의 PortOne Payment ID는 서로 다름</code></pre><p>결과는 다음과 같았다.</p>
<pre><code>결제 성공                : 1건
이미 결제 완료된 요청     : 1건

DB에 저장된 Payment      : 1건
최종 Order 상태          : PAID</code></pre><p>동일 주문에 두 개의 결제 승인 요청이 동시에 발생해도 실제 Payment는 하나만 생성되는 것을 검증했다.</p>
<hr>
<h2 id="💡-이번-개선에서-배운-점">💡 이번 개선에서 배운 점</h2>
<p>이전에는 <code>@Transactional</code>의 범위가 넓으면 넓을수록 데이터를 안전하게 보호할 수 있다고 생각하기 쉬웠다.</p>
<p>하지만 트랜잭션 내부에 외부 네트워크 호출이 들어가는 순간 문제가 달라진다.</p>
<p>트랜잭션은 무조건 크게 묶는 것이 아니라</p>
<blockquote>
<p><strong>정합성을 위해 반드시 함께 처리되어야 하는 DB 작업만 가능한 짧게 묶는 것</strong></p>
</blockquote>
<p>이 중요했다.</p>
<p>특히 외부 시스템이 포함된 기능에서는</p>
<pre><code>외부 API 성공
≠
내부 DB 성공</code></pre><p>이라는 점도 함께 고려해야 한다는 것을 배웠다.</p>
<p>이 경험은 이후 환불 기능을 설계하면서 외부 시스템과 내부 DB의 부분 실패를 고민하는 데에도 이어졌다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[RoomPick 인기 숙소 Redis 랭킹 / 캐시]]></title>
            <link>https://velog.io/@loaded_diaper/RoomPick-%EC%9D%B8%EA%B8%B0-%EC%88%99%EC%86%8C-Redis-%EB%9E%AD%ED%82%B9-%EC%BA%90%EC%8B%9C</link>
            <guid>https://velog.io/@loaded_diaper/RoomPick-%EC%9D%B8%EA%B8%B0-%EC%88%99%EC%86%8C-Redis-%EB%9E%AD%ED%82%B9-%EC%BA%90%EC%8B%9C</guid>
            <pubDate>Fri, 28 Aug 2026 03:26:11 GMT</pubDate>
            <description><![CDATA[<h1 id="🚀-redis-캐시를-붙였더니-처리량이-457배-증가했다--인기-숙소-랭킹-개선">🚀 Redis 캐시를 붙였더니 처리량이 4.57배 증가했다 — 인기 숙소 랭킹 개선</h1>
<p>RoomPick에는 사용자에게 인기 숙소를 보여주는 기능이 있다.</p>
<p>초기 구현은 단순 누적 조회수를 기준으로 인기 숙소를 계산했다.</p>
<p>처음에는 문제가 없어 보였지만 기능을 다시 살펴보면서 두 가지 문제가 보였다.</p>
<ol>
<li>과거에 조회수가 높았던 숙소가 계속 상위에 남는다.</li>
<li>인기 숙소 요청마다 Redis와 MySQL을 반복해서 조회한다.</li>
</ol>
<p>이번 글에서는 이를 Redis Sorted Set과 응답 캐시를 이용해 개선한 과정을 정리한다.</p>
<hr>
<h2 id="🔥-누적-조회수-방식의-문제">🔥 누적 조회수 방식의 문제</h2>
<p>기존 방식은 숙소가 조회될 때마다 단순하게 조회수를 증가시키는 구조였다.</p>
<pre><code>숙소 A : 10,000
숙소 B : 8,000
숙소 C : 5,000</code></pre><p>문제는 시간이 지나도 과거에 많이 조회된 숙소가 계속 유리하다는 점이었다.</p>
<p>최근 인기가 높아진 숙소가 있어도 과거 누적 데이터가 워낙 크다면 상위 순위에 진입하기 어려웠다.</p>
<p>그래서 랭킹을 기간별로 나누기로 했다.</p>
<pre><code>DAILY  → 오늘 인기 숙소
WEEKLY → 이번 주 인기 숙소</code></pre><hr>
<h2 id="✅-redis-sorted-set">✅ Redis Sorted Set</h2>
<p>랭킹은 Redis Sorted Set으로 구성했다.</p>
<pre><code>member = 숙소 ID
score  = 예약 발생 횟수</code></pre><p>예약이 발생하면 해당 숙소의 Score를 증가시키는 방식이다.</p>
<p>Sorted Set은 Score를 기준으로 이미 정렬된 데이터를 조회할 수 있기 때문에 인기 순위를 구현하기 적합했다.</p>
<p>일간 데이터는 날짜별로 분리했고, 주간 데이터는 해당 주의 월요일을 기준으로 관리했다.</p>
<p>서버 실행 환경에 따라 날짜 기준이 달라지는 것을 막기 위해 <code>Asia/Seoul</code>을 기준으로 처리했고, 테스트에서는 <code>Clock</code>을 주입해 원하는 날짜를 고정한 상태로 검증할 수 있도록 했다.</p>
<hr>
<h2 id="🔒-score-증가와-ttl-설정을-하나의-작업으로">🔒 Score 증가와 TTL 설정을 하나의 작업으로</h2>
<p>새로운 랭킹 Key가 생성되면</p>
<pre><code>Score 증가
+
TTL 설정</code></pre><p>두 작업이 함께 수행되어야 한다.</p>
<p>두 명령을 각각 실행할 경우 첫 번째 작업은 성공했지만 두 번째 작업이 실패하면 만료시간이 없는 랭킹 데이터가 남을 수 있다.</p>
<p>그래서 Lua Script를 사용해 Score 증가와 TTL 설정을 하나의 원자적인 작업으로 처리했다.</p>
<hr>
<h2 id="🔥-랭킹은-redis인데-왜-db-조회가-계속-발생할까">🔥 랭킹은 Redis인데 왜 DB 조회가 계속 발생할까?</h2>
<p>Redis Sorted Set에는 숙소 ID와 순위 정보가 저장되어 있다.</p>
<p>하지만 사용자에게 최종적으로 응답하려면</p>
<ul>
<li>숙소 이름</li>
<li>이미지</li>
<li>가격</li>
<li>상태</li>
</ul>
<p>등의 추가 정보가 필요하다.</p>
<p>그래서 요청마다</p>
<pre><code>Redis 랭킹 조회
      ↓
숙소 ID 획득
      ↓
MySQL 숙소 조회
      ↓
DTO 생성</code></pre><p>과정이 반복되고 있었다.</p>
<p>인기 숙소 API는 짧은 시간 동안 같은 결과가 반복적으로 요청될 가능성이 높다.</p>
<p>그래서 최종 응답에도 <strong>Cache Aside 방식의 응답 캐시</strong>를 추가했다.</p>
<p>TTL은 <strong>60초</strong>로 설정했다.</p>
<pre><code>Request
 ↓
Response Cache
 ↓
HIT → 바로 반환

MISS
 ↓
Redis Ranking
 ↓
MySQL
 ↓
DTO 생성
 ↓
Cache 저장</code></pre><hr>
<h2 id="🔍-랭킹을-요청-개수보다-더-많이-가져온-이유">🔍 랭킹을 요청 개수보다 더 많이 가져온 이유</h2>
<p>랭킹에 존재하는 숙소라고 해서 반드시 사용자에게 보여줄 수 있는 숙소라는 보장은 없다.</p>
<p>랭킹에 들어간 이후 운영 중 비활성 상태로 변경된 숙소가 있을 수도 있다.</p>
<p>예를 들어 사용자가 인기 숙소 5개를 요청했는데 상위 5개 중 2개가 비활성 상태라면 최종적으로 3개밖에 반환하지 못한다.</p>
<p>그래서 정확히 <code>limit</code>만 조회하지 않고 <strong><code>limit × 5</code> 범위의 후보를 먼저 가져온 후 현재 ACTIVE 상태인 숙소만 필터링</strong>했다.</p>
<p>이를 통해 비활성 숙소 때문에 최종 응답 개수가 부족해지는 상황을 줄였다.</p>
<hr>
<h2 id="🚨-redis가-장애-나면-인기-숙소-api도-죽어야-할까">🚨 Redis가 장애 나면 인기 숙소 API도 죽어야 할까?</h2>
<p>Redis는 인기 순위를 빠르게 조회하기 위한 구성 요소다.</p>
<p>Redis에 장애가 생겼다고 해서 숙소 목록 자체를 사용자에게 제공하지 못하는 상황은 피하고 싶었다.</p>
<p>그래서 Redis 랭킹 조회에 실패하면 MySQL에서 최신 ACTIVE 숙소를 조회하도록 fallback을 구성했다.</p>
<pre><code>Redis 정상
→ 인기 랭킹 반환

Redis 장애
→ DB 최신 ACTIVE 숙소 반환</code></pre><p>Redis에서 계산한 정확한 인기 순위는 아니더라도 사용자에게 숙소 데이터 자체는 계속 제공할 수 있도록 한 것이다.</p>
<hr>
<h2 id="🚀-성능-테스트">🚀 성능 테스트</h2>
<p>10 VU, 30초 조건으로 캐시 적용 전후를 비교했다.</p>
<h3 id="캐시-적용-전">캐시 적용 전</h3>
<pre><code>평균 응답시간 : 6.27ms
p95          : 9.15ms
처리량        : 1566.50 RPS
에러율        : 0%</code></pre><h3 id="warm-cache">Warm Cache</h3>
<pre><code>평균 응답시간 : 1.31ms
p95          : 2.21ms
처리량        : 7166.16 RPS
에러율        : 0%</code></pre><p>결과적으로</p>
<pre><code>평균 응답시간
6.27ms → 1.31ms
79.1% 감소

처리량
1566.50 → 7166.16 RPS
4.57배 향상</code></pre><p>을 확인했다.</p>
<hr>
<h2 id="💡-이번-개선에서-배운-점">💡 이번 개선에서 배운 점</h2>
<p>Redis를 사용한다고 무조건 모든 조회가 빨라지는 것은 아니었다.</p>
<p>Redis에서 순위만 빠르게 조회하더라도 이후 DB 조회와 DTO 생성이 요청마다 반복된다면 동일한 작업은 계속 발생한다.</p>
<p>그래서</p>
<pre><code>랭킹 데이터 관리
→ Redis Sorted Set

반복되는 최종 응답
→ Response Cache

Redis 장애
→ DB fallback</code></pre><p>처럼 문제에 따라 Redis의 역할을 나누어 설계했다.</p>
<p>이번 경험을 통해 캐시를 단순히 성능 향상을 위한 기술로 사용하는 것이 아니라 <strong>반복되는 작업은 무엇인지, 장애가 발생하면 서비스는 어떻게 동작해야 하는지까지 함께 생각해야 한다</strong>는 것을 배웠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[RoomPick Cache Stampede / Single Flight]]></title>
            <link>https://velog.io/@loaded_diaper/RoomPick-Cache-Stampede-Single-Flight</link>
            <guid>https://velog.io/@loaded_diaper/RoomPick-Cache-Stampede-Single-Flight</guid>
            <pubDate>Fri, 28 Aug 2026 03:25:24 GMT</pubDate>
            <description><![CDATA[<h1 id="⚡-캐시를-붙였더니-db가-10번-조회됐다--cache-stampede와-single-flight">⚡ 캐시를 붙였더니 DB가 10번 조회됐다 — Cache Stampede와 Single Flight</h1>
<p>RoomPick의 인기 숙소 API에 Redis 캐시를 적용하면서 조회 성능은 크게 개선됐다.</p>
<p>하지만 테스트 도중 이상한 상황을 발견했다.</p>
<p>캐시가 존재할 때는 문제가 없었다.</p>
<p>문제는 <strong>캐시가 만료되는 순간</strong>이었다.</p>
<p>동일한 API 요청 10건을 동시에 보내자 Redis 랭킹 조회와 MySQL 조회가 각각 10번씩 발생했다.</p>
<p>캐시를 적용했는데 순간적으로 오히려 DB에 요청이 몰릴 수 있는 구조였다.</p>
<p>이번 글에서는 이 <strong>Cache Stampede 문제를 Single Flight 방식으로 해결한 과정</strong>을 정리한다.</p>
<hr>
<h2 id="🔥-문제-상황">🔥 문제 상황</h2>
<p>인기 숙소 조회는 Cache Aside 구조를 사용하고 있었다.</p>
<pre><code>Request
   ↓
Cache 조회
   ↓
HIT ─────────→ Response

MISS
   ↓
Redis 랭킹 조회
   ↓
MySQL 숙소 조회
   ↓
DTO 생성
   ↓
Cache 저장
   ↓
Response</code></pre><p>평상시에는 문제가 없었다.</p>
<p>하지만 TTL이 만료된 직후 여러 요청이 동시에 들어오면 상황이 달라진다.</p>
<p>예를 들어 요청 10건이 거의 동시에 들어오면 모든 요청이 캐시가 존재하지 않는다는 사실을 확인한다.</p>
<pre><code>Request 1  → Cache MISS → 원본 조회
Request 2  → Cache MISS → 원본 조회
Request 3  → Cache MISS → 원본 조회
...
Request 10 → Cache MISS → 원본 조회</code></pre><p>모든 요청이 똑같은 데이터를 가져오기 위해 Redis와 MySQL을 반복해서 조회하는 것이다.</p>
<p>실제 테스트에서도</p>
<pre><code>동일 요청       : 10건
Redis 랭킹 조회 : 10회
MySQL SELECT    : 10회</code></pre><p>가 발생했다.</p>
<p>이것이 <strong>Cache Stampede</strong>다.</p>
<hr>
<h2 id="🤔-같은-데이터를-왜-10번-조회해야-할까">🤔 같은 데이터를 왜 10번 조회해야 할까?</h2>
<p>10개의 요청이 원하는 결과는 완전히 동일하다.</p>
<p>그렇다면 최초 요청 하나만 실제 데이터를 조회하고 나머지 요청은 그 결과를 기다렸다가 공유하면 된다.</p>
<pre><code>Request 1 ─────────────→ 실제 조회
                           │
Request 2 ─────────────────┤
Request 3 ─────────────────┤
Request 4 ─────────────────┤
                           ↓
                       결과 공유</code></pre><p>이런 방식을 <strong>Single Flight</strong> 방식으로 구현했다.</p>
<hr>
<h2 id="✅-concurrenthashmap--completablefuture">✅ ConcurrentHashMap + CompletableFuture</h2>
<p>동일한 작업이 현재 진행 중인지 관리하기 위해 <code>ConcurrentHashMap</code>을 사용했다.</p>
<p>그리고 최초 요청의 결과를 여러 요청이 함께 기다릴 수 있도록 <code>CompletableFuture</code>를 사용했다.</p>
<p>전체 흐름은 다음과 같다.</p>
<pre><code>요청
 ↓
동일 Key 작업 진행 중?
 ↓
NO ---------------------- YES
 ↓                         ↓
최초 요청                 기존 Future 대기
 ↓                         ↓
Redis / DB 조회           동일 결과 반환
 ↓
Future 완료
 ↓
진행 중 작업에서 제거</code></pre><p>Single Flight의 Key에는</p>
<pre><code>기간
기준 날짜
조회 개수</code></pre><p>를 포함했다.</p>
<p>단순히 API 주소가 같다는 이유만으로 묶는 것이 아니라 <strong>실제로 동일한 결과를 요구하는 요청만 하나의 작업을 공유</strong>하도록 했다.</p>
<hr>
<h2 id="🚨-redis-장애-fallback도-single-flight에-포함">🚨 Redis 장애 fallback도 Single Flight에 포함</h2>
<p>처음에는 정상적인 Redis 조회만 Single Flight 대상으로 생각할 수도 있다.</p>
<p>하지만 Redis 장애가 발생하면 DB fallback이 실행된다.</p>
<p>만약 fallback 로직이 Single Flight 밖에 있다면</p>
<pre><code>Redis 장애

Request 1 → DB fallback
Request 2 → DB fallback
Request 3 → DB fallback
...
Request 10 → DB fallback</code></pre><p>처럼 장애 순간에 DB로 요청이 몰릴 수 있다.</p>
<p>결국 Cache Stampede의 위치만 Redis 장애 상황으로 이동하는 셈이다.</p>
<p>그래서 정상 Redis 조회부터 Redis 장애 시 DB fallback까지를 <strong>하나의 최종 작업</strong>으로 묶었다.</p>
<pre><code>Single Flight
      ↓
Redis 조회
      ↓
성공 → 결과 반환

실패
 ↓
DB fallback
 ↓
결과 반환</code></pre><p>이렇게 하면 Redis가 정상일 때뿐 아니라 장애가 발생한 상황에서도 동일 요청에 대한 DB fallback이 중복 실행되는 것을 막을 수 있다.</p>
<hr>
<h2 id="⏱-최초-요청이-끝나지-않으면">⏱ 최초 요청이 끝나지 않으면?</h2>
<p>Single Flight를 적용하면 하나의 요청 결과를 여러 요청이 기다리게 된다.</p>
<p>그런데 최초 요청에 문제가 생겨 작업이 끝나지 않는다면 나머지 요청도 계속 기다릴 수 있다.</p>
<p>그래서 최대 대기시간을 <strong>5초</strong>로 제한했다.</p>
<pre><code>5초 이내 완료
→ 정상 응답

5초 초과
→ HTTP 503

Thread Interrupt
→ Interrupt 상태 복구
→ HTTP 503</code></pre><p>또한 대기 중인 요청 하나가 Timeout 됐다는 이유로 최초 작업 자체를 취소하지 않도록 했다.</p>
<p>하나의 최초 작업을 여러 요청이 기다리고 있을 수 있기 때문이다.</p>
<hr>
<h2 id="🧹-작업-정리도-동시성을-고려해야-했다">🧹 작업 정리도 동시성을 고려해야 했다</h2>
<p>작업이 끝나면 진행 중인 요청을 저장하고 있는 Map에서 제거해야 한다.</p>
<p>하지만 단순히</p>
<pre><code class="language-java">remove(key);</code></pre>
<p>와 같이 제거하면 타이밍에 따라 같은 Key에 새롭게 등록된 다른 작업까지 제거할 가능성이 있다.</p>
<p>그래서 현재 Map에 등록되어 있는 작업이 자신이 생성한 작업과 동일한 경우에만 제거하도록 했다.</p>
<pre><code class="language-java">remove(cacheKey, currentRequest);</code></pre>
<p>최초 작업이 성공하거나 실패해 실제 작업이 종료된 경우에만 안전하게 정리할 수 있도록 했다.</p>
<hr>
<h2 id="🚀-테스트-결과">🚀 테스트 결과</h2>
<p>Cold Cache 상태에서 동일한 요청 10건을 동시에 발생시켰다.</p>
<h3 id="적용-전">적용 전</h3>
<pre><code>Redis 랭킹 조회 : 10회
MySQL SELECT    : 10회
평균 응답시간    : 70.52ms</code></pre><h3 id="적용-후">적용 후</h3>
<pre><code>Redis 랭킹 조회 : 1회
MySQL SELECT    : 1회
평균 응답시간    : 20.86ms</code></pre><p>결과적으로</p>
<pre><code>원본 조회
10회 → 1회
90% 감소

평균 응답시간
70.52ms → 20.86ms
약 70% 개선</code></pre><p>이라는 결과를 확인했다.</p>
<hr>
<h2 id="💡-redis-분산-락을-사용하지-않은-이유">💡 Redis 분산 락을 사용하지 않은 이유</h2>
<p>Single Flight를 구현할 때 Redis 분산 락도 고려할 수 있었다.</p>
<p>하지만 현재 구조에서는 먼저 <strong>하나의 애플리케이션 인스턴스 내부에서 발생하는 중복 요청</strong>을 해결하는 것이 목적이었다.</p>
<p>그래서 네트워크 통신이 필요한 분산 락 대신</p>
<pre><code>ConcurrentHashMap
+
CompletableFuture</code></pre><p>를 이용해 JVM 내부에서 해결했다.</p>
<p>다만 이 구조는 <strong>단일 JVM 범위에서만 동일 요청을 합칠 수 있다.</strong></p>
<p>향후 여러 애플리케이션 인스턴스를 운영하면서 인스턴스 간 Cache Stampede까지 제어해야 한다면 Redis 기반 분산 제어 등을 추가로 고려할 수 있다.</p>
<hr>
<h2 id="💡-이번-문제에서-배운-점">💡 이번 문제에서 배운 점</h2>
<p>처음에는</p>
<blockquote>
<p>&quot;Redis 캐시를 적용했으니 DB 부하는 줄어들 것이다.&quot;</p>
</blockquote>
<p>라고 단순하게 생각했다.</p>
<p>하지만 캐시에는 반드시 <strong>없는 순간과 만료되는 순간</strong>이 존재한다.</p>
<p>캐시 HIT 상황만 테스트해서는 실제 서비스에서 발생할 수 있는 문제를 모두 발견할 수 없었다.</p>
<p>이번 경험을 통해 캐시를 설계할 때는</p>
<pre><code>Cache HIT
Cache MISS
Cache Expire
Redis 장애
동시 요청</code></pre><p>까지 함께 고려해야 한다는 것을 배웠다.</p>
<p>특히 캐시를 적용하는 것만큼 <strong>캐시가 존재하지 않는 순간 시스템이 어떻게 동작하는지</strong> 역시 중요하다는 것을 알게 됐다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[RoomPick 위치 검색 최적화]]></title>
            <link>https://velog.io/@loaded_diaper/RoomPick-%EC%9C%84%EC%B9%98-%EA%B2%80%EC%83%89-%EC%B5%9C%EC%A0%81%ED%99%94</link>
            <guid>https://velog.io/@loaded_diaper/RoomPick-%EC%9C%84%EC%B9%98-%EA%B2%80%EC%83%89-%EC%B5%9C%EC%A0%81%ED%99%94</guid>
            <pubDate>Fri, 28 Aug 2026 03:23:45 GMT</pubDate>
            <description><![CDATA[<h1 id="📍-5만-건-위치-검색-573ms를-91ms로-줄인-과정--bounding-box와-elasticsearch-비교">📍 5만 건 위치 검색, 573ms를 91ms로 줄인 과정 — Bounding Box와 Elasticsearch 비교</h1>
<p>숙박 예약 서비스 <strong>RoomPick</strong>을 개발하면서 사용자의 현재 위치를 기준으로 일정 반경 안의 숙소를 검색하는 기능을 구현했다.</p>
<p>기능 자체는 어렵지 않았다.</p>
<p>문제는 데이터가 많아졌을 때였다.</p>
<p>약 5만 건의 숙소 데이터를 넣고 테스트하자 위치 검색의 평균 응답시간이 <strong>573.62ms</strong>까지 증가했다.</p>
<p>이번 글에서는 단순 거리 계산 방식에서 시작해</p>
<ul>
<li>Bounding Box</li>
<li>복합 인덱스</li>
<li><code>ST_Distance_Sphere()</code></li>
<li>Elasticsearch <code>geo_point</code></li>
</ul>
<p>까지 비교하고, 최종적으로 왜 Elasticsearch가 아닌 MySQL 방식을 Production에 선택했는지 정리해보려고 한다.</p>
<hr>
<h2 id="🔥-문제-상황">🔥 문제 상황</h2>
<p>처음 위치 검색은 MySQL의 <code>ST_Distance_Sphere()</code>를 이용했다.</p>
<p>사용자의 위도와 경도를 기준으로 각 숙소와의 거리를 계산한 뒤 일정 거리 이내의 숙소만 조회하는 방식이었다.</p>
<p>개념적으로는 간단했다.</p>
<pre><code>사용자 위치
   ↓
모든 숙소와 거리 계산
   ↓
5km 이하 숙소 필터링</code></pre><p>하지만 숙소가 약 5만 건까지 증가하자 문제가 발생했다.</p>
<p><code>ST_Distance_Sphere()</code>를 모든 숙소에 적용해야 했기 때문에 DB는 사실상 전체 데이터를 대상으로 거리 계산을 수행했다.</p>
<p>실행 계획을 확인했을 때 실제 조회 대상은 약 <strong>50,040건</strong>이었다.</p>
<p>위도와 경도에 인덱스가 존재하더라도 모든 행을 대상으로 거리 함수를 계산하는 구조에서는 인덱스의 장점을 충분히 활용하기 어려웠다.</p>
<hr>
<h2 id="🤔-모든-숙소의-거리를-계산할-필요가-있을까">🤔 모든 숙소의 거리를 계산할 필요가 있을까?</h2>
<p>예를 들어 서울 노원구 주변 5km 숙소를 검색한다고 생각해보자.</p>
<p>부산이나 제주도에 있는 숙소까지 정확한 거리를 계산할 필요는 없다.</p>
<p>그래서 먼저 생각한 방법이 <strong>Bounding Box</strong>였다.</p>
<p>현재 위치를 중심으로 검색 반경을 포함하는 사각형을 먼저 만든다.</p>
<pre><code>┌────────────────────┐
│                    │
│       ● 사용자      │
│      ( 5km )        │
│                    │
└────────────────────┘</code></pre><p>그리고 사각형 밖에 있는 숙소는 정확한 거리를 계산하기 전에 제외한다.</p>
<p>즉 검색을 두 단계로 나눴다.</p>
<pre><code>1. 위도·경도 범위로 후보 숙소 검색
                ↓
2. 후보 숙소만 정확한 거리 계산</code></pre><hr>
<h2 id="✅-bounding-box--정확한-거리-계산">✅ Bounding Box + 정확한 거리 계산</h2>
<p>최종 검색 과정은 다음과 같이 변경했다.</p>
<pre><code>전체 숙소 약 50,000건
        ↓
Bounding Box
        ↓
후보 숙소 약 6,384건
        ↓
ST_Distance_Sphere()
        ↓
실제 반경 5km 숙소 약 5,018건</code></pre><p>정확한 거리 계산 대상이 약 <strong>5만 건 → 6천 건 수준</strong>으로 줄어든 것이다.</p>
<p>여기에 <code>(latitude, longitude)</code> 복합 인덱스를 적용했다.</p>
<p>Bounding Box 검색은 다음처럼 범위 조건을 사용할 수 있다.</p>
<pre><code class="language-sql">latitude BETWEEN :minLatitude AND :maxLatitude
AND longitude BETWEEN :minLongitude AND :maxLongitude</code></pre>
<p>먼저 인덱스를 활용해 후보를 줄인 뒤, 후보 데이터에 대해서만 <code>ST_Distance_Sphere()</code>를 사용해 실제 반경 안에 들어오는 숙소인지 판별했다.</p>
<hr>
<h2 id="🧐-경계에-있는-숙소가-후보에서-빠질-수도-있었다">🧐 경계에 있는 숙소가 후보에서 빠질 수도 있었다</h2>
<p>Bounding Box를 계산하면서 한 가지 더 고려해야 할 부분이 있었다.</p>
<p>Bounding Box를 계산할 때 사용하는 지구 반지름과 MySQL의 <code>ST_Distance_Sphere()</code>가 내부적으로 사용하는 기본 반지름이 다르면, 검색 반경 경계에 위치한 숙소의 포함 여부가 미세하게 달라질 가능성이 있었다.</p>
<p>그래서 Bounding Box 계산에 사용하는 반지름을 <strong>MySQL SRID 0 <code>ST_Distance_Sphere()</code>의 기본 반지름과 일치</strong>시켰다.</p>
<p>이를 통해 Bounding Box 단계에서 실제 검색 반경에 포함되는 숙소가 후보에서 누락될 가능성을 줄였다.</p>
<hr>
<h2 id="🚀-성능-결과">🚀 성능 결과</h2>
<p>k6를 이용해 동일한 데이터와 조건에서 비교했다.</p>
<h3 id="기존-mysql-거리-계산">기존 MySQL 거리 계산</h3>
<pre><code>평균 응답시간 : 573.62ms
p50          : 508.77ms
p95          : 1133.05ms
p99          : 1438.76ms
처리량        : 17.75 RPS</code></pre><h3 id="bounding-box--index">Bounding Box + Index</h3>
<pre><code>평균 응답시간 : 91.69ms
p50          : 90.98ms
p95          : 123.69ms
p99          : 180.91ms
처리량        : 109.04 RPS</code></pre><p>결과적으로</p>
<pre><code>평균 응답시간
573.62ms → 91.69ms
84.01% 감소

처리량
17.75 RPS → 109.04 RPS
6.14배 향상</code></pre><p>이라는 결과를 얻었다.</p>
<hr>
<h2 id="🔍-그런데-elasticsearch가-더-빠르지-않을까">🔍 그런데 Elasticsearch가 더 빠르지 않을까?</h2>
<p>위치 검색을 구현하면서 Elasticsearch의 <code>geo_point</code>를 이용한 위치 검색도 별도로 구현해 비교했다.</p>
<p>결과는 예상대로 Elasticsearch가 훨씬 빨랐다.</p>
<h3 id="elasticsearch-위치-검색">Elasticsearch 위치 검색</h3>
<pre><code>평균 응답시간 : 6.12ms
처리량        : 1586.91 RPS</code></pre><p>세 가지 방식을 비교하면 다음과 같다.</p>
<pre><code>MySQL Baseline     573.62ms
Bounding Box        91.69ms
Elasticsearch        6.12ms</code></pre><p>성능만 본다면 Elasticsearch를 선택하는 것이 당연해 보였다.</p>
<p>하지만 실제 서비스에 기술을 적용할 때는 속도만 볼 수는 없었다.</p>
<hr>
<h2 id="🤔-가장-빠른-기술이-항상-가장-좋은-선택일까">🤔 가장 빠른 기술이 항상 가장 좋은 선택일까?</h2>
<p>Elasticsearch를 Production에 도입하면 검색 성능은 크게 향상된다.</p>
<p>하지만 그만큼 관리해야 하는 요소도 늘어난다.</p>
<pre><code>MySQL 데이터 변경
        ↓
Elasticsearch 동기화
        ↓
두 저장소 사이 정합성 관리</code></pre><p>Elasticsearch 자체 장애 대응과 인덱스 운영 역시 추가로 필요하다.</p>
<p>반면 현재 RoomPick의 데이터 규모에서는 Bounding Box를 적용한 MySQL만으로도 평균 응답시간을 약 91ms까지 낮출 수 있었다.</p>
<p>그래서 현재 규모에서는</p>
<blockquote>
<p>새로운 검색 엔진을 추가하기보다 기존 MySQL을 충분히 최적화하는 것이 적절하다.</p>
</blockquote>
<p>고 판단했다.</p>
<p>최종적으로 Production에서는</p>
<pre><code>Bounding Box
+
(latitude, longitude) 복합 인덱스
+
ST_Distance_Sphere() 정확 거리 계산</code></pre><p>구조를 선택했다.</p>
<p>Elasticsearch 구현 자체는 제거하지 않고 향후 데이터 규모가 커질 경우 검색 엔진을 변경할 수 있도록 구조를 남겨두었다.</p>
<hr>
<h2 id="💡-이번-개선에서-배운-점">💡 이번 개선에서 배운 점</h2>
<p>처음에는 단순히</p>
<blockquote>
<p>&quot;Elasticsearch를 사용하면 훨씬 빠르지 않을까?&quot;</p>
</blockquote>
<p>라는 생각에서 시작했다.</p>
<p>실제로 테스트 결과 Elasticsearch가 가장 빨랐다.</p>
<p>하지만 이번 경험을 통해 <strong>가장 빠른 기술과 현재 서비스에 가장 적절한 기술은 다를 수 있다</strong>는 것을 배웠다.</p>
<p>약 5만 건 규모에서는 MySQL Bounding Box만으로도</p>
<pre><code>573.62ms → 91.69ms</code></pre><p>까지 충분히 개선할 수 있었다.</p>
<p>성능 수치만 비교하는 것이 아니라 서비스 규모, 운영 복잡도, 데이터 정합성, 향후 확장성까지 함께 고려해 기술을 선택해본 경험이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🚀 Redis 인기 숙소 캐시 V1 → V2 고도화: 빠르기만 한 캐시에서 최신성까지 고려한 구조로]]></title>
            <link>https://velog.io/@loaded_diaper/Redis-%EC%9D%B8%EA%B8%B0-%EC%88%99%EC%86%8C-%EC%BA%90%EC%8B%9C-V1-V2-%EA%B3%A0%EB%8F%84%ED%99%94-%EB%B9%A0%EB%A5%B4%EA%B8%B0%EB%A7%8C-%ED%95%9C-%EC%BA%90%EC%8B%9C%EC%97%90%EC%84%9C-%EC%B5%9C%EC%8B%A0%EC%84%B1%EA%B9%8C%EC%A7%80-%EA%B3%A0%EB%A0%A4%ED%95%9C-%EA%B5%AC%EC%A1%B0%EB%A1%9C</link>
            <guid>https://velog.io/@loaded_diaper/Redis-%EC%9D%B8%EA%B8%B0-%EC%88%99%EC%86%8C-%EC%BA%90%EC%8B%9C-V1-V2-%EA%B3%A0%EB%8F%84%ED%99%94-%EB%B9%A0%EB%A5%B4%EA%B8%B0%EB%A7%8C-%ED%95%9C-%EC%BA%90%EC%8B%9C%EC%97%90%EC%84%9C-%EC%B5%9C%EC%8B%A0%EC%84%B1%EA%B9%8C%EC%A7%80-%EA%B3%A0%EB%A0%A4%ED%95%9C-%EA%B5%AC%EC%A1%B0%EB%A1%9C</guid>
            <pubDate>Wed, 05 Aug 2026 08:38:34 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/loaded_diaper/post/18be23c2-4f3e-4563-92c7-c77afa045cfa/image.png" alt="">
RoomPick의 인기 숙소 기능은 사용자가 숙소 상세 페이지를 조회할 때 Redis에 점수를 기록하고, 점수가 높은 숙소를 인기 숙소 목록으로 제공한다.</p>
<p>처음에는 동일한 인기 숙소 요청이 반복될 때마다 Redis와 MySQL을 다시 조회하는 비용을 줄이기 위해 <strong>인기 숙소 응답 캐시 V1</strong>을 구현했다.</p>
<p>V1을 적용한 뒤 응답 속도와 처리량은 크게 개선됐다.</p>
<p>하지만 성능이 빨라진 것과 별개로 새로운 문제가 보이기 시작했다.</p>
<blockquote>
<p>캐시는 최신 데이터로 다시 만들어지고 있었지만, 그 캐시를 만드는 랭킹 원본 자체가 과거 조회수를 계속 누적하고 있었다.</p>
</blockquote>
<p>즉, V1은 <strong>반복 조회 성능 문제</strong>는 해결했지만, <strong>최근 인기 흐름을 제대로 보여주는 문제</strong>까지 해결하지는 못했다.</p>
<p>이번 글에서는 인기 숙소 캐시 V1이 어떤 구조였고, 왜 V2 고도화가 필요했으며, 누적 랭킹을 DAILY·WEEKLY 기간별 랭킹으로 어떻게 개선했는지 정리한다.</p>
<hr>
<h2 id="📌-인기-숙소-기능의-기본-흐름">📌 인기 숙소 기능의 기본 흐름</h2>
<p>RoomPick에서는 숙소 상세 조회가 정상적으로 완료되면 해당 숙소의 인기 점수를 Redis에 기록한다.</p>
<pre><code class="language-text">숙소 상세 조회 성공
→ Redis 인기 점수 증가
→ 인기 숙소 API에서 점수가 높은 숙소 조회</code></pre>
<p>Redis에는 Sorted Set을 사용한다.</p>
<pre><code class="language-text">member
→ 숙소 ID

score
→ 숙소 조회 점수</code></pre>
<p>예를 들면 다음과 같다.</p>
<pre><code class="language-text">숙소 1번 → 150점
숙소 2번 → 120점
숙소 3번 → 95점</code></pre>
<p>인기 숙소 API는 Redis에서 점수가 높은 숙소 ID를 조회한 뒤 MySQL에서 실제 공개 숙소 정보를 가져온다.</p>
<pre><code class="language-text">Redis Sorted Set
→ 인기 숙소 ID 조회

MySQL
→ ACTIVE 상태 숙소 정보 조회

애플리케이션
→ Redis 순서 복원
→ 비공개·삭제 숙소 제외
→ rank 계산
→ 응답 DTO 생성</code></pre>
<hr>
<h1 id="1️⃣-v1-인기-숙소-완성-응답-캐시-도입">1️⃣ V1: 인기 숙소 완성 응답 캐시 도입</h1>
<h2 id="v1-이전의-문제">V1 이전의 문제</h2>
<p>응답 캐시가 없을 때 동일한 인기 숙소 요청이 들어오면 매번 다음 작업을 반복했다.</p>
<pre><code class="language-text">1. Redis에서 인기 숙소 ID 조회
2. MySQL에서 ACTIVE 숙소 정보 조회
3. Redis 랭킹 순서 복원
4. 비공개·삭제 숙소 필터링
5. rank 계산
6. 응답 DTO 생성</code></pre>
<p>예를 들어 다음 요청이 짧은 시간 동안 계속 들어온다고 가정해 보자.</p>
<pre><code class="language-text">GET /accommodations/popular?limit=10</code></pre>
<p>인기 순위가 바뀌지 않았는데도 요청마다 Redis와 MySQL을 다시 조회해야 했다.</p>
<pre><code class="language-text">요청 1
→ Redis 조회
→ MySQL 조회
→ DTO 생성

요청 2
→ Redis 조회
→ MySQL 조회
→ DTO 생성

요청 3
→ Redis 조회
→ MySQL 조회
→ DTO 생성</code></pre>
<p>사용자에게 반환되는 결과는 거의 같은데 내부 계산은 계속 반복되고 있었다.</p>
<hr>
<h2 id="v1의-목표">V1의 목표</h2>
<p>V1의 목표는 단순했다.</p>
<blockquote>
<p>Redis 랭킹 ID만 캐싱하는 것이 아니라, 사용자에게 반환할 완성된 TOP N 응답 전체를 캐싱하자.</p>
</blockquote>
<p>기존 조회 결과를 다음 캐시에 저장했다.</p>
<pre><code class="language-text">cache name
→ popularAccommodations

cache value
→ List&lt;PopularAccommodationResponseDto&gt;</code></pre>
<p>즉, 캐시에는 숙소 ID만 저장하는 것이 아니라 다음 정보가 포함된 완성 응답을 저장했다.</p>
<pre><code class="language-text">rank
accommodationId
name
address</code></pre>
<hr>
<h2 id="v1의-조회-흐름">V1의 조회 흐름</h2>
<h3 id="cache-miss">Cache Miss</h3>
<p>캐시가 없으면 기존 조회 로직을 실행한다.</p>
<pre><code class="language-text">인기 숙소 API 요청
→ 응답 캐시 조회
→ Cache Miss
→ Redis 랭킹 ID 조회
→ MySQL ACTIVE 숙소 조회
→ 순서 복원 및 필터링
→ rank와 DTO 생성
→ 완성 응답 Redis 캐시에 저장
→ 사용자에게 반환</code></pre>
<h3 id="cache-hit">Cache Hit</h3>
<p>캐시에 완성 응답이 있으면 내부 조회 로직을 전부 생략한다.</p>
<pre><code class="language-text">인기 숙소 API 요청
→ 응답 캐시 조회
→ Cache Hit
→ 완성된 DTO 목록 즉시 반환</code></pre>
<p>Cache Hit에서는 다음 작업이 모두 사라진다.</p>
<pre><code class="language-text">Redis Sorted Set 랭킹 조회 생략
MySQL ACTIVE 숙소 조회 생략
Redis 순서 복원 생략
숙소 필터링 생략
rank 계산 생략
DTO 재생성 생략</code></pre>
<hr>
<h2 id="v1-적용-결과">V1 적용 결과</h2>
<p>인기 숙소 응답 캐시를 적용한 결과, 반복 요청 성능은 크게 개선됐다.</p>
<table>
<thead>
<tr>
<th>지표</th>
<th align="right">응답 캐시 비활성화</th>
<th align="right">Warm Cache</th>
</tr>
</thead>
<tbody><tr>
<td>평균 응답시간</td>
<td align="right">6.27ms</td>
<td align="right">1.31ms</td>
</tr>
<tr>
<td>p95</td>
<td align="right">-</td>
<td align="right">2.21ms</td>
</tr>
<tr>
<td>p99</td>
<td align="right">-</td>
<td align="right">3.84ms</td>
</tr>
<tr>
<td>처리량</td>
<td align="right">1,566.50 RPS</td>
<td align="right">7,166.16 RPS</td>
</tr>
<tr>
<td>오류율</td>
<td align="right">0%</td>
<td align="right">0%</td>
</tr>
</tbody></table>
<p>10 VU, 30초 조건에서 Warm Cache 요청은 약 214,981건 처리됐다.</p>
<pre><code class="language-text">평균 응답시간
6.27ms → 1.31ms

처리량
1,566.50 RPS → 7,166.16 RPS</code></pre>
<p>V1은 동일한 인기 숙소 조회에서 발생하던 Redis·MySQL·DTO 조합 비용을 효과적으로 줄였다.</p>
<hr>
<h1 id="2️⃣-그런데-v1에서-새로운-문제가-발견됐다">2️⃣ 그런데 V1에서 새로운 문제가 발견됐다</h1>
<p>V1 캐시는 성능 측면에서는 정상적으로 동작했다.</p>
<p>캐시 TTL이 끝나면 최신 Redis 랭킹을 다시 조회하고 응답을 재생성했다.</p>
<p>처음에는 이것만으로 인기 숙소도 최신 상태를 유지할 수 있다고 생각했다.</p>
<p>하지만 문제는 <strong>응답 캐시가 아니라 랭킹 원본</strong>에 있었다.</p>
<hr>
<h2 id="v1은-누적-랭킹이었다">V1은 누적 랭킹이었다</h2>
<p>V1의 Redis 인기 점수는 날짜나 기간 구분 없이 계속 누적됐다.</p>
<pre><code class="language-text">roompick:popular:accommodations</code></pre>
<p>예를 들어 숙소 A가 서비스 초기에 많은 조회수를 얻었다고 가정해 보자.</p>
<pre><code class="language-text">숙소 A
→ 과거 누적 조회수 10,000

숙소 B
→ 오늘 조회수 500

숙소 C
→ 오늘 조회수 400</code></pre>
<p>최근에는 숙소 B와 C가 더 많은 관심을 받고 있어도, 누적 점수가 큰 숙소 A가 계속 1위를 유지할 수 있다.</p>
<pre><code class="language-text">과거에 크게 인기 있었던 숙소
→ 누적 점수가 계속 남음
→ 현재 조회가 적어도 상위권 유지</code></pre>
<p>이 구조에서는 “인기 숙소”가 사실상 다음 의미가 된다.</p>
<blockquote>
<p>서비스가 시작된 이후 가장 많은 조회수를 기록한 숙소</p>
</blockquote>
<p>하지만 사용자에게 보여주고 싶은 것은 다음에 더 가까웠다.</p>
<pre><code class="language-text">오늘 많이 조회된 숙소
이번 주 많이 조회된 숙소
최근 관심이 증가한 숙소</code></pre>
<hr>
<h2 id="ttl-만료로-해결되지-않는-이유">TTL 만료로 해결되지 않는 이유</h2>
<p>여기서 중요한 점은 캐시 TTL과 랭킹 기간은 서로 다른 문제라는 것이다.</p>
<p>V1의 응답 캐시 TTL이 60초라고 가정해 보자.</p>
<pre><code class="language-text">캐시 생성
→ 60초 동안 재사용
→ 60초 후 캐시 만료
→ Redis 랭킹 원본 재조회
→ 새로운 응답 생성</code></pre>
<p>겉으로 보면 캐시가 60초마다 최신 데이터로 바뀌는 것처럼 보인다.</p>
<p>하지만 Redis 랭킹 원본이 누적 점수라면 결과는 다시 비슷하게 만들어진다.</p>
<pre><code class="language-text">응답 캐시 만료
→ 누적 랭킹 다시 조회
→ 과거 인기 숙소가 다시 TOP N 선정
→ 새로운 캐시에도 같은 숙소 저장</code></pre>
<p>즉, TTL을 아무리 짧게 설정해도 누적 랭킹 자체는 초기화되지 않는다.</p>
<pre><code class="language-text">TTL 60초
→ 캐시만 60초마다 재생성

누적 랭킹
→ 과거 조회수 계속 유지</code></pre>
<p>따라서 V1의 문제는 “캐시가 너무 오래 유지된다”가 아니었다.</p>
<blockquote>
<p>캐시를 새로 만들 때 사용하는 랭킹 데이터에 기간 개념이 없다는 것이 핵심 문제였다.</p>
</blockquote>
<hr>
<h1 id="3️⃣-v2-고도화의-목표">3️⃣ V2 고도화의 목표</h1>
<p>V2의 목표는 V1의 캐시를 제거하거나 완전히 새로 만드는 것이 아니었다.</p>
<p>V1에서 얻은 성능 이점은 유지하면서 랭킹 최신성을 개선하는 것이 목표였다.</p>
<pre><code class="language-text">V1
→ 완성 응답 캐시로 반복 조회 비용 감소

V2
→ V1 성능 구조 유지
→ DAILY·WEEKLY 기간별 랭킹 추가
→ 최근 인기 흐름 반영</code></pre>
<p>한 문장으로 정리하면 다음과 같다.</p>
<blockquote>
<p>V1은 “같은 결과를 빠르게 반환하는 구조”였다면, V2는 “최근 기간에 맞는 결과를 빠르게 반환하는 구조”다.</p>
</blockquote>
<hr>
<h1 id="4️⃣-v2-daily·weekly-기간별-랭킹-도입">4️⃣ V2: DAILY·WEEKLY 기간별 랭킹 도입</h1>
<p>V2에서는 하나의 누적 랭킹을 사용하지 않고 기간별로 Redis Sorted Set을 분리했다.</p>
<p>지원하는 기간은 다음과 같다.</p>
<pre><code class="language-text">DAILY
→ 오늘 인기 숙소

WEEKLY
→ 이번 주 인기 숙소</code></pre>
<hr>
<h2 id="daily-랭킹-키">DAILY 랭킹 키</h2>
<p>일간 랭킹에는 현재 날짜를 포함한다.</p>
<pre><code class="language-text">roompick:popular:accommodations:daily:2026-08-05</code></pre>
<p>다음 날이 되면 새로운 키를 사용한다.</p>
<pre><code class="language-text">2026-08-05
roompick:popular:accommodations:daily:2026-08-05

2026-08-06
roompick:popular:accommodations:daily:2026-08-06</code></pre>
<p>따라서 전날의 높은 조회수가 오늘의 인기 순위에 계속 영향을 주지 않는다.</p>
<pre><code class="language-text">어제 조회수
→ 어제 DAILY 키에 저장

오늘 조회수
→ 오늘 DAILY 키에 저장</code></pre>
<hr>
<h2 id="weekly-랭킹-키">WEEKLY 랭킹 키</h2>
<p>주간 랭킹에는 해당 주의 기준 날짜를 포함한다.</p>
<p>예를 들어 월요일을 주간 시작일로 사용한다면 다음과 같다.</p>
<pre><code class="language-text">roompick:popular:accommodations:weekly:2026-08-03</code></pre>
<p>같은 주의 조회수는 동일한 WEEKLY 키에 기록된다.</p>
<pre><code class="language-text">월요일 조회
→ weekly:2026-08-03

수요일 조회
→ weekly:2026-08-03

일요일 조회
→ weekly:2026-08-03</code></pre>
<p>다음 주가 시작되면 새로운 키를 사용한다.</p>
<pre><code class="language-text">이번 주
→ weekly:2026-08-03

다음 주
→ weekly:2026-08-10</code></pre>
<p>이를 통해 과거 전체 누적 조회수가 아니라 <strong>현재 주의 조회수만으로 주간 인기 순위를 계산</strong>할 수 있다.</p>
<hr>
<h1 id="5️⃣-숙소-상세-조회-시-daily와-weekly를-함께-기록">5️⃣ 숙소 상세 조회 시 DAILY와 WEEKLY를 함께 기록</h1>
<p>사용자가 숙소 상세 페이지를 정상적으로 조회하면 DAILY와 WEEKLY 점수를 각각 증가시킨다.</p>
<pre><code class="language-text">숙소 상세 조회 성공
    ↓
DAILY Sorted Set 점수 +1
    ↓
WEEKLY Sorted Set 점수 +1</code></pre>
<p>개념적인 흐름은 다음과 같다.</p>
<pre><code class="language-java">for (
    PopularAccommodationPeriod period
    : PopularAccommodationPeriod.values()
) {
    recordView(
        accommodationId,
        period
    );
}</code></pre>
<p>각 기간은 별도의 Redis 키를 사용한다.</p>
<pre><code class="language-text">DAILY
→ roompick:popular:accommodations:daily:2026-08-05

WEEKLY
→ roompick:popular:accommodations:weekly:2026-08-03</code></pre>
<p>한 기간의 Redis 기록이 실패하더라도 다른 기간 기록까지 막지 않도록 각각 독립적으로 처리했다.</p>
<pre><code class="language-text">DAILY 기록 실패
→ 로그 기록
→ WEEKLY 기록은 계속 시도</code></pre>
<p>또한 인기 점수 기록 실패 때문에 숙소 상세 조회 자체가 실패하지 않도록 기존 장애 정책도 유지했다.</p>
<hr>
<h1 id="6️⃣-redis-sorted-set-ttl-관리">6️⃣ Redis Sorted Set TTL 관리</h1>
<p>날짜가 포함된 키를 계속 생성하면 오래된 키가 Redis에 영구적으로 남을 수 있다.</p>
<p>그래서 기간별 랭킹 키에는 TTL을 적용했다.</p>
<p>조회 점수 증가와 TTL 설정은 Lua Script로 원자적으로 처리했다.</p>
<pre><code class="language-lua">redis.call(&#39;ZINCRBY&#39;, KEYS[1], ARGV[1], ARGV[2])

local ttl = redis.call(&#39;TTL&#39;, KEYS[1])

if ttl &lt; 0 then
    redis.call(&#39;EXPIRE&#39;, KEYS[1], ARGV[3])
end</code></pre>
<p>이 방식의 목적은 다음 두 명령 사이의 중간 상태를 막는 것이다.</p>
<pre><code class="language-text">ZINCRBY 성공
→ 서버 장애
→ EXPIRE 실행 실패
→ 랭킹 키가 영구적으로 남음</code></pre>
<p>Lua Script를 사용하면 Redis 내부에서 하나의 명령 흐름으로 실행된다.</p>
<pre><code class="language-text">점수 증가
+
TTL 확인 및 설정
→ 원자적으로 처리</code></pre>
<p>오래된 기간 키를 애플리케이션이 직접 배치 삭제하지 않아도 Redis가 만료시킬 수 있도록 구성했다.</p>
<hr>
<h1 id="7️⃣-v2-응답-캐시-key도-기간별로-분리">7️⃣ V2 응답 캐시 Key도 기간별로 분리</h1>
<p>랭킹 원본만 기간별로 바꾸고 응답 캐시 Key를 그대로 사용하면 문제가 생긴다.</p>
<p>예를 들어 다음 두 요청이 동일한 응답 캐시를 사용하면 안 된다.</p>
<pre><code class="language-text">DAILY TOP 10
WEEKLY TOP 10</code></pre>
<p>따라서 V2 응답 캐시 Key에는 다음 값이 모두 포함된다.</p>
<pre><code class="language-text">period + 기준 날짜 + limit</code></pre>
<p>예시는 다음과 같다.</p>
<pre><code class="language-text">popularAccommodations::
roompick:popular:accommodations:daily:2026-08-05:10</code></pre>
<p>주간 요청은 다른 키를 사용한다.</p>
<pre><code class="language-text">popularAccommodations::
roompick:popular:accommodations:weekly:2026-08-03:10</code></pre>
<hr>
<h2 id="왜-limit도-key에-포함했을까">왜 <code>limit</code>도 Key에 포함했을까?</h2>
<p>다음 요청은 서로 반환해야 하는 결과가 다르다.</p>
<pre><code class="language-text">limit=10
→ TOP 10 전체 응답

limit=20
→ TOP 20 전체 응답</code></pre>
<p>V2 캐시는 완성된 DTO 목록 전체를 저장하기 때문에 <code>limit</code>을 Key에서 제외하면 잘못된 크기의 응답을 재사용할 수 있다.</p>
<pre><code class="language-text">TOP 10 캐시 생성
→ TOP 20 요청에서 같은 캐시 사용
→ 10개밖에 반환하지 못함</code></pre>
<p>따라서 다음 요청은 별도의 캐시로 관리한다.</p>
<pre><code class="language-text">DAILY + 날짜 + limit 10
DAILY + 날짜 + limit 20
WEEKLY + 주 시작일 + limit 10</code></pre>
<hr>
<h1 id="8️⃣-v2-전체-조회-흐름">8️⃣ V2 전체 조회 흐름</h1>
<p>V2에서 사용자가 DAILY 인기 숙소 TOP 10을 조회한다고 가정해 보자.</p>
<pre><code class="language-text">GET /accommodations/popular
    ?period=DAILY
    &amp;limit=10</code></pre>
<h2 id="cache-hit-1">Cache Hit</h2>
<pre><code class="language-text">1. DAILY 기준 날짜 생성
2. DAILY + 날짜 + limit으로 응답 캐시 Key 생성
3. Redis 응답 캐시 조회
4. Cache Hit
5. 완성된 TOP 10 DTO 목록 즉시 반환</code></pre>
<p>이 경우 다음 작업은 실행하지 않는다.</p>
<pre><code class="language-text">Sorted Set 랭킹 조회 생략
MySQL 조회 생략
순서 복원 생략
필터링 생략
DTO 재생성 생략</code></pre>
<hr>
<h2 id="cache-miss-1">Cache Miss</h2>
<pre><code class="language-text">1. DAILY 기준 날짜 생성
2. DAILY + 날짜 + limit으로 응답 캐시 Key 생성
3. 응답 캐시 조회
4. Cache Miss
5. DAILY Sorted Set에서 인기 숙소 ID 조회
6. MySQL에서 ACTIVE 숙소 정보 일괄 조회
7. Redis 랭킹 순서 복원
8. 비공개·삭제 숙소 제외
9. rank 및 응답 DTO 생성
10. 완성된 응답을 Redis 캐시에 저장
11. 사용자에게 반환</code></pre>
<p>V1의 성능 최적화 구조는 그대로 유지하면서 랭킹 원본만 기간별로 개선한 것이다.</p>
<hr>
<h1 id="9️⃣-active-숙소가-부족할-때-추가-랭킹-조회">9️⃣ ACTIVE 숙소가 부족할 때 추가 랭킹 조회</h1>
<p>Redis 인기 랭킹에 포함된 모든 숙소가 항상 사용자에게 공개 가능한 것은 아니다.</p>
<p>랭킹에 포함된 숙소가 다음 상태로 바뀔 수 있다.</p>
<pre><code class="language-text">숙소 비공개 전환
숙소 삭제
DB에 존재하지 않음</code></pre>
<p>예를 들어 TOP 10을 요청했지만 Redis 상위 10개 중 4개가 비공개라면 실제 응답은 6개밖에 만들지 못한다.</p>
<pre><code class="language-text">Redis 상위 10개 조회
→ ACTIVE 숙소 6개
→ limit 10을 충족하지 못함</code></pre>
<p>V2에서는 필요한 개수를 채우기 위해 Redis 랭킹을 일정 범위씩 추가 조회한다.</p>
<pre><code class="language-text">limit 10
→ Redis에서 우선 50개 조회
→ ACTIVE 숙소 필터링
→ 10개가 채워지면 종료
→ 부족하면 다음 범위 조회</code></pre>
<p>배치 크기는 요청 <code>limit</code>의 일정 배수로 계산한다.</p>
<pre><code class="language-java">long batchSize =
    (long) limit * RANKING_BATCH_MULTIPLIER;</code></pre>
<p>이미 처리한 숙소 ID는 중복 조회하지 않도록 Set으로 관리한다.</p>
<pre><code class="language-text">processedAccommodationIds
→ 이미 확인한 숙소 ID 저장
→ 다음 배치에서 중복 처리 방지</code></pre>
<p>이를 통해 비공개 숙소가 섞여 있어도 가능한 범위에서 요청한 TOP N을 채운다.</p>
<hr>
<h1 id="🔟-랭킹-순서를-다시-복원하는-이유">🔟 랭킹 순서를 다시 복원하는 이유</h1>
<p>MySQL에서 다음과 같이 ID 목록을 조회한다고 해보자.</p>
<pre><code class="language-text">Redis 랭킹 순서
→ [5, 2, 9]</code></pre>
<p>JPA의 <code>WHERE id IN (...)</code> 조회 결과는 입력한 ID 순서를 반드시 보장하지 않는다.</p>
<pre><code class="language-text">MySQL 조회 결과
→ [2, 9, 5]</code></pre>
<p>이 결과를 그대로 응답하면 Redis 인기 순위가 깨진다.</p>
<p>따라서 DB 조회 결과를 Map으로 변환한 뒤 Redis ID 순서대로 다시 조합한다.</p>
<pre><code class="language-text">DB 조회 결과
→ accommodationId 기준 Map 생성

Redis 랭킹 ID 순회
→ ID에 해당하는 숙소를 Map에서 조회
→ 원래 랭킹 순서대로 응답에 추가</code></pre>
<p>개념적인 흐름은 다음과 같다.</p>
<pre><code class="language-java">Map&lt;Long, AccommodationListResponseDto&gt;
    accommodationById = new HashMap&lt;&gt;();

for (
    AccommodationListResponseDto accommodation
    : activeAccommodations
) {
    accommodationById.put(
        accommodation.accommodationId(),
        accommodation
    );
}

for (Long accommodationId : rankedAccommodationIds) {
    AccommodationListResponseDto accommodation =
        accommodationById.get(accommodationId);

    if (accommodation == null) {
        continue;
    }

    result.add(
        PopularAccommodationResponseDto.from(
            result.size() + 1,
            accommodation
        )
    );
}</code></pre>
<hr>
<h1 id="1️⃣1️⃣-응답-캐시-ttl-60초-정책">1️⃣1️⃣ 응답 캐시 TTL 60초 정책</h1>
<p>V2의 완성 응답 캐시 TTL은 기본 60초로 유지했다.</p>
<p>여기서 두 가지 시간을 구분해야 한다.</p>
<h2 id="랭킹-원본">랭킹 원본</h2>
<p>숙소 상세 조회가 발생하면 DAILY·WEEKLY Sorted Set 점수는 즉시 증가한다.</p>
<pre><code class="language-text">상세 조회 성공
→ Redis 랭킹 원본 즉시 변경</code></pre>
<h2 id="사용자에게-보이는-완성-응답">사용자에게 보이는 완성 응답</h2>
<p>이미 만들어진 응답 캐시는 TTL 동안 유지된다.</p>
<pre><code class="language-text">Redis 랭킹 원본 변경
→ 기존 응답 캐시는 유지
→ 최대 약 60초 후 Cache Miss
→ 최신 랭킹으로 응답 재생성</code></pre>
<p>따라서 사용자에게 보이는 인기 순위에는 최대 약 60초의 지연이 있을 수 있다.</p>
<hr>
<h2 id="왜-조회수-증가-때마다-캐시를-지우지-않았을까">왜 조회수 증가 때마다 캐시를 지우지 않았을까?</h2>
<p>숙소 상세 조회가 발생할 때마다 인기 숙소 응답 캐시를 제거하면 랭킹 변경을 더 빠르게 반영할 수 있다.</p>
<p>하지만 상세 조회 트래픽이 증가할수록 캐시가 계속 무효화된다.</p>
<pre><code class="language-text">숙소 상세 조회
→ 인기 응답 캐시 삭제

다음 인기 숙소 요청
→ Cache Miss
→ Redis 조회
→ MySQL 조회
→ 응답 재생성

다시 숙소 상세 조회
→ 캐시 삭제</code></pre>
<p>결국 캐시 적중률이 매우 낮아질 수 있다.</p>
<p>인기 순위는 예약 가능 여부나 결제 금액처럼 거래 정합성을 결정하는 데이터가 아니기 때문에 조회수 변화는 TTL 만료 시 반영하도록 했다.</p>
<pre><code class="language-text">조회수 증가에 따른 순위 변화
→ TTL 만료 후 반영
→ 최대 약 60초 지연 허용</code></pre>
<hr>
<h1 id="1️⃣2️⃣-ttl과-즉시-cache-evict의-역할-분리">1️⃣2️⃣ TTL과 즉시 Cache Evict의 역할 분리</h1>
<p>TTL이 모든 정합성 문제를 해결하는 것은 아니다.</p>
<p>변경 종류에 따라 정책을 나눴다.</p>
<table>
<thead>
<tr>
<th>변경 상황</th>
<th>처리 방식</th>
</tr>
</thead>
<tbody><tr>
<td>숙소 조회수 증가</td>
<td>60초 TTL 만료로 반영</td>
</tr>
<tr>
<td>DAILY 날짜 변경</td>
<td>새로운 날짜의 랭킹·캐시 Key 사용</td>
</tr>
<tr>
<td>WEEKLY 기간 변경</td>
<td>새로운 주간 랭킹·캐시 Key 사용</td>
</tr>
<tr>
<td>숙소 비공개 전환</td>
<td>관련 인기 응답 캐시 즉시 제거</td>
</tr>
<tr>
<td>숙소 공개 정보 수정</td>
<td>관련 인기 응답 캐시 즉시 제거</td>
</tr>
<tr>
<td>Redis 장애</td>
<td>최신 ACTIVE 숙소 DB fallback</td>
</tr>
</tbody></table>
<p>조회수 증가는 잠시 늦게 반영돼도 사용자에게 잘못된 숙소 정보를 보여주는 것은 아니다.</p>
<p>하지만 숙소가 비공개로 바뀌었는데 캐시에 남아 있다면 비공개 숙소가 계속 노출될 수 있다.</p>
<pre><code class="language-text">조회수 변화
→ 약간의 최신성 지연 허용
→ TTL 사용

숙소 비공개·공개 정보 변경
→ 오래된 정보 노출 가능
→ 즉시 Cache Evict</code></pre>
<hr>
<h1 id="1️⃣3️⃣-redis-장애-fallback-유지">1️⃣3️⃣ Redis 장애 fallback 유지</h1>
<p>V2에서는 랭킹과 응답 캐시가 Redis에 의존한다.</p>
<p>Redis 장애가 발생했을 때 인기 숙소 API 전체가 실패하면 사용자 경험에 큰 영향을 줄 수 있다.</p>
<p>그래서 Redis 랭킹을 사용할 수 없는 경우 MySQL에서 최신 ACTIVE 숙소를 조회하는 fallback을 유지했다.</p>
<pre><code class="language-text">Redis 랭킹 조회 실패
→ PopularAccommodationRankingUnavailableException
→ AccommodationFacade에서 예외 처리
→ MySQL 최신 ACTIVE 숙소 조회
→ 임시 순번 부여
→ 정상 응답 반환</code></pre>
<p>fallback 응답의 <code>rank</code>는 실제 인기 순위가 아니다.</p>
<pre><code class="language-text">1, 2, 3...
→ API 응답 형식을 유지하기 위한 임시 순번</code></pre>
<p>중요한 점은 Redis 장애 때문에 인기 숙소 API 전체가 500 오류로 실패하지 않도록 했다는 것이다.</p>
<hr>
<h2 id="fallback-성능">fallback 성능</h2>
<p>Redis 장애 fallback 측정에서는 오류율 0%와 HTTP 200 응답을 유지했다.</p>
<p>다만 MySQL을 직접 조회해야 하므로 정상 Warm Cache보다 응답 속도는 느렸다.</p>
<pre><code class="language-text">평균 응답시간
→ 약 67.22ms

처리량
→ 약 148.10 RPS

오류율
→ 0%</code></pre>
<p>fallback은 정상 경로보다 빠르게 만들기 위한 기능이 아니다.</p>
<blockquote>
<p>Redis 장애가 발생해도 핵심 API를 완전히 중단시키지 않기 위한 비상 경로다.</p>
</blockquote>
<hr>
<h1 id="1️⃣4️⃣-캐시-비활성화-설정도-유지">1️⃣4️⃣ 캐시 비활성화 설정도 유지</h1>
<p>캐시 적용 전후의 성능을 같은 코드에서 비교하기 위해 응답 캐시 활성화 여부를 설정으로 분리했다.</p>
<pre><code class="language-properties">roompick.cache.popular-accommodations-enabled=false</code></pre>
<p>캐시를 비활성화해도 다음 기능은 그대로 유지된다.</p>
<pre><code class="language-text">Redis Sorted Set 점수 증가
DAILY·WEEKLY 랭킹 관리
Redis 인기 랭킹 조회
Redis 장애 fallback</code></pre>
<p>비활성화되는 것은 완성 응답 캐시뿐이다.</p>
<pre><code class="language-text">@Cacheable condition
→ false
→ 요청마다 랭킹과 DB를 다시 조회</code></pre>
<p>이를 통해 다음 조건을 비교할 수 있다.</p>
<pre><code class="language-text">동일 코드
동일 데이터
동일 API
동일 부하 조건

응답 캐시 OFF
vs
응답 캐시 ON</code></pre>
<hr>
<h1 id="1️⃣5️⃣-v1과-v2-비교">1️⃣5️⃣ V1과 V2 비교</h1>
<table>
<thead>
<tr>
<th>구분</th>
<th>V1</th>
<th>V2</th>
</tr>
</thead>
<tbody><tr>
<td>랭킹 기준</td>
<td>전체 누적 조회수</td>
<td>DAILY·WEEKLY 기간별 조회수</td>
</tr>
<tr>
<td>Redis 랭킹 Key</td>
<td>고정된 누적 키</td>
<td>기간과 기준 날짜 포함</td>
</tr>
<tr>
<td>최근 인기 반영</td>
<td>어려움</td>
<td>가능</td>
</tr>
<tr>
<td>응답 캐시</td>
<td>완성 DTO 목록 저장</td>
<td>완성 DTO 목록 저장</td>
</tr>
<tr>
<td>응답 캐시 Key</td>
<td>단순 조회 조건 중심</td>
<td>period + 기준 날짜 + limit</td>
</tr>
<tr>
<td>응답 캐시 TTL</td>
<td>60초</td>
<td>60초</td>
</tr>
<tr>
<td>Cache Hit 성능</td>
<td>Redis·DB 조회 생략</td>
<td>동일하게 유지</td>
</tr>
<tr>
<td>과거 인기 영향</td>
<td>계속 누적</td>
<td>기간 변경 시 새 키 사용</td>
</tr>
<tr>
<td>장애 대응</td>
<td>DB fallback</td>
<td>DB fallback 유지</td>
</tr>
<tr>
<td>주요 목표</td>
<td>반복 조회 성능 개선</td>
<td>성능 유지 + 랭킹 최신성 개선</td>
</tr>
</tbody></table>
<hr>
<h1 id="1️⃣6️⃣-핵심은-v1을-버리지-않았다는-것">1️⃣6️⃣ 핵심은 V1을 버리지 않았다는 것</h1>
<p>V2 고도화에서 중요한 점은 V1을 실패한 구조로 보고 전부 제거하지 않았다는 것이다.</p>
<p>V1은 실제로 반복 조회 성능을 크게 개선했다.</p>
<pre><code class="language-text">평균 응답시간
6.27ms → 1.31ms

처리량
1,566.50 RPS → 7,166.16 RPS</code></pre>
<p>문제는 응답 캐시 자체가 아니라 누적 랭킹의 의미였다.</p>
<p>따라서 잘 동작하는 부분은 유지하고 부족한 부분만 개선했다.</p>
<pre><code class="language-text">유지한 것
→ 완성 응답 캐시
→ Cache Hit 시 내부 조회 생략
→ TTL 60초
→ Redis 장애 fallback
→ 캐시 비활성화 성능 비교 설정

변경한 것
→ 누적 랭킹을 DAILY·WEEKLY로 분리
→ Redis Key에 기간 기준 날짜 포함
→ 응답 캐시 Key에 period·날짜·limit 포함
→ 기간별 랭킹 Key TTL 관리</code></pre>
<p>이 과정을 통해 불필요한 전면 리팩토링 없이 기존 성능 이점을 보존할 수 있었다.</p>
<hr>
<h1 id="1️⃣7️⃣-테스트한-내용">1️⃣7️⃣ 테스트한 내용</h1>
<h2 id="기간별-랭킹-분리">기간별 랭킹 분리</h2>
<pre><code class="language-text">DAILY 조회
→ DAILY Redis Key 사용

WEEKLY 조회
→ WEEKLY Redis Key 사용</code></pre>
<p>두 기간이 서로 다른 점수와 순위를 가지는지 검증했다.</p>
<hr>
<h2 id="날짜·주간-변경">날짜·주간 변경</h2>
<pre><code class="language-text">오늘 DAILY Key
→ daily:2026-08-05

다음 날 DAILY Key
→ daily:2026-08-06</code></pre>
<p>기준 날짜가 변경되면 이전 기간과 다른 랭킹을 조회하는지 확인했다.</p>
<hr>
<h2 id="limit별-응답-캐시-분리">limit별 응답 캐시 분리</h2>
<pre><code class="language-text">DAILY + limit 10
DAILY + limit 20</code></pre>
<p>서로 다른 완성 응답 캐시를 사용하는지 검증했다.</p>
<hr>
<h2 id="cache-hit-2">Cache Hit</h2>
<p>첫 번째 요청에서 캐시를 만든 뒤 두 번째 동일 요청에서는 Redis 랭킹 조회와 MySQL 조회가 다시 실행되지 않는지 확인했다.</p>
<pre><code class="language-text">첫 요청
→ Cache Miss
→ 랭킹 및 DB 조회
→ 캐시 저장

두 번째 요청
→ Cache Hit
→ 내부 조회 생략</code></pre>
<hr>
<h2 id="ttl-만료">TTL 만료</h2>
<p>TTL이 끝난 뒤 다음 요청에서 원본을 다시 조회하고 새로운 응답을 만드는지 검증했다.</p>
<hr>
<h2 id="캐시-비활성화">캐시 비활성화</h2>
<p>캐시 비활성화 상태에서는 동일 요청을 반복해도 매번 Redis 랭킹을 조회하는지 확인했다.</p>
<hr>
<h2 id="redis-장애-fallback">Redis 장애 fallback</h2>
<p>Redis 랭킹 장애에서만 최신 ACTIVE 숙소 fallback이 실행되는지 검증했다.</p>
<pre><code class="language-text">Redis 장애
→ fallback 실행

MySQL 장애
→ fallback하지 않고 DB 예외 전달

잘못된 limit
→ fallback하지 않고 비즈니스 예외 전달</code></pre>
<hr>
<h1 id="💡-이번-고도화에서-배운-점">💡 이번 고도화에서 배운 점</h1>
<h2 id="1-캐시-최신성과-원본-데이터-최신성은-다르다">1. 캐시 최신성과 원본 데이터 최신성은 다르다</h2>
<p>캐시 TTL을 짧게 설정한다고 해서 원본 데이터가 최신 의미를 가지는 것은 아니다.</p>
<pre><code class="language-text">응답 캐시 TTL
→ 완성된 응답을 얼마나 오래 재사용할지 결정

랭킹 기간 정책
→ 어떤 조회수를 순위 계산에 포함할지 결정</code></pre>
<p>두 설정은 완전히 다른 문제를 해결한다.</p>
<hr>
<h2 id="2-성능이-좋다고-기능-의미까지-올바른-것은-아니다">2. 성능이 좋다고 기능 의미까지 올바른 것은 아니다</h2>
<p>V1의 성능은 분명히 개선됐다.</p>
<p>하지만 인기 숙소라는 기능이 과거 전체 누적 조회수만 보여준다면 사용자가 기대하는 “최근 인기”와 차이가 생길 수 있다.</p>
<p>성능 지표뿐 아니라 데이터가 사용자에게 어떤 의미로 보이는지도 검토해야 한다.</p>
<hr>
<h2 id="3-잘-동작하는-구조는-유지하고-문제-지점만-개선할-수-있다">3. 잘 동작하는 구조는 유지하고 문제 지점만 개선할 수 있다</h2>
<p>V1의 완성 응답 캐시는 효과가 있었다.</p>
<p>따라서 캐시 전체를 새로 만들지 않고 랭킹 원본과 Key 전략을 개선했다.</p>
<pre><code class="language-text">V1 성능 구조 유지
+
V2 기간별 최신성 추가</code></pre>
<p>이 방식으로 변경 범위와 회귀 위험을 줄일 수 있었다.</p>
<hr>
<h2 id="4-key-설계가-캐시-구조의-핵심이다">4. Key 설계가 캐시 구조의 핵심이다</h2>
<p>V2에서는 다음 값이 Key에 포함된다.</p>
<pre><code class="language-text">period
기준 날짜
limit</code></pre>
<p>Key에 필요한 조건이 빠지면 서로 다른 요청이 같은 응답을 공유할 수 있다.</p>
<p>반대로 필요 이상의 값을 포함하면 캐시가 지나치게 세분화되어 적중률이 떨어질 수 있다.</p>
<p>캐시에서는 저장 방식만큼 Key 설계가 중요하다는 것을 배웠다.</p>
<hr>
<h2 id="5-최신성이-중요하다고-모든-변경에서-캐시를-삭제하면-안-된다">5. 최신성이 중요하다고 모든 변경에서 캐시를 삭제하면 안 된다</h2>
<p>조회수 증가마다 캐시를 삭제하면 최신성은 높아질 수 있지만 캐시가 거의 사용되지 못한다.</p>
<p>변경의 성격을 구분해야 한다.</p>
<pre><code class="language-text">자주 발생하지만 약간 늦어도 되는 변경
→ TTL

사용자에게 잘못된 정보를 노출할 수 있는 변경
→ 즉시 Cache Evict</code></pre>
<hr>
<h2 id="6-fallback은-성능-최적화가-아니라-장애-격리다">6. fallback은 성능 최적화가 아니라 장애 격리다</h2>
<p>Redis 장애 fallback은 Warm Cache보다 느리다.</p>
<p>하지만 Redis 장애가 인기 숙소 API 전체 장애로 번지는 것을 막는다.</p>
<pre><code class="language-text">정상 경로
→ 빠른 응답

장애 경로
→ 느리더라도 정상 HTTP 응답 유지</code></pre>
<p>fallback의 목적은 속도가 아니라 가용성이다.</p>
<hr>
<h1 id="🎯-마무리">🎯 마무리</h1>
<p>인기 숙소 캐시 V1에서는 완성된 TOP N 응답을 Redis에 캐싱하여 반복 요청의 Redis 랭킹 조회, MySQL 조회, 순서 복원, DTO 생성 비용을 제거했다.</p>
<p>그 결과 평균 응답시간은 6.27ms에서 1.31ms로 줄었고, 처리량은 1,566.50 RPS에서 7,166.16 RPS로 증가했다.</p>
<p>하지만 V1의 랭킹 원본은 전체 조회수를 계속 누적하고 있었다.</p>
<p>캐시 TTL이 만료되어 응답을 새로 만들어도 과거 조회수가 높은 숙소가 다시 TOP N으로 선정되기 때문에 최근 인기 흐름을 제대로 보여주기 어려웠다.</p>
<p>V2에서는 이 문제를 해결하기 위해 다음과 같이 고도화했다.</p>
<pre><code class="language-text">누적 랭킹
→ DAILY·WEEKLY 기간별 랭킹

고정 Redis Key
→ period와 기준 날짜가 포함된 Key

단순 응답 캐시 구분
→ period + 기준 날짜 + limit별 캐시 분리</code></pre>
<p>V1에서 얻은 응답 캐시의 성능 이점은 그대로 유지하면서, 기간이 변경되면 새로운 Redis Sorted Set을 사용하도록 하여 과거의 높은 누적 조회수가 현재 인기 순위에 계속 영향을 주지 않도록 개선했다.</p>
<p>이번 작업을 한 문장으로 정리하면 다음과 같다.</p>
<blockquote>
<p>V1에서는 인기 숙소를 빠르게 조회하도록 만들었고, V2에서는 그 빠른 결과가 최근 사용자의 관심을 제대로 반영하도록 개선했다.</p>
</blockquote>
<hr>
<h2 id="🏷️-tags">🏷️ Tags</h2>
<p><code>Spring Boot</code> <code>Redis</code> <code>Sorted Set</code> <code>Spring Cache</code> <code>Cache</code> <code>TTL</code> <code>캐시 고도화</code> <code>인기 랭킹</code> <code>DAILY 랭킹</code> <code>WEEKLY 랭킹</code> <code>성능 최적화</code> <code>MySQL</code> <code>Fallback</code> <code>Testcontainers</code></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🚨 캐시가 있는데 DB 조회가 10번? Cache Stampede를 Single Flight로 해결한 과정]]></title>
            <link>https://velog.io/@loaded_diaper/%EC%BA%90%EC%8B%9C%EA%B0%80-%EC%9E%88%EB%8A%94%EB%8D%B0-DB-%EC%A1%B0%ED%9A%8C%EA%B0%80-10%EB%B2%88-Cache-Stampede%EB%A5%BC-Single-Flight%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%9C-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@loaded_diaper/%EC%BA%90%EC%8B%9C%EA%B0%80-%EC%9E%88%EB%8A%94%EB%8D%B0-DB-%EC%A1%B0%ED%9A%8C%EA%B0%80-10%EB%B2%88-Cache-Stampede%EB%A5%BC-Single-Flight%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%9C-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Wed, 05 Aug 2026 08:35:22 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/loaded_diaper/post/6b7a5e91-db16-408a-b909-ddf2249d8d9a/image.png" alt="">
인기 숙소 조회 성능을 개선하기 위해 Redis 응답 캐시를 적용했다.</p>
<p>캐시가 존재하는 동안에는 동일한 인기 숙소 요청이 들어와도 Redis에 저장된 완성 응답을 바로 반환하기 때문에 Redis 랭킹 조회와 MySQL 조회를 생략할 수 있었다.</p>
<p>그런데 캐시를 적용했다고 해서 모든 문제가 끝난 것은 아니었다.</p>
<p>캐시의 TTL이 만료되는 순간 동일한 요청이 동시에 들어오면, 모든 요청이 Cache Miss로 판단하여 같은 Redis 랭킹과 MySQL 데이터를 반복해서 조회할 수 있었다.</p>
<p>이번 글에서는 이 <strong>Cache Stampede 문제를 직접 재현하고</strong>, 여러 해결 방법을 비교한 뒤 <strong>Key 단위 Single Flight</strong>를 적용하여 원본 조회를 10회에서 1회로 줄인 과정을 정리한다.</p>
<hr>
<h2 id="📌-기존-인기-숙소-조회-구조">📌 기존 인기 숙소 조회 구조</h2>
<p>RoomPick의 인기 숙소 조회는 다음 순서로 동작한다.</p>
<pre><code class="language-text">사용자 요청
→ 인기 숙소 응답 캐시 조회
→ Cache Hit라면 완성된 응답 반환
→ Cache Miss라면 Redis 인기 랭킹 조회
→ MySQL에서 ACTIVE 숙소 정보 일괄 조회
→ Redis 랭킹 순서 복원
→ 비공개·삭제 숙소 필터링
→ rank 계산 및 응답 DTO 생성
→ 완성된 응답을 Redis 캐시에 저장</code></pre>
<p>응답 캐시 키에는 다음 값이 포함된다.</p>
<pre><code class="language-text">조회 기간 + 기간 기준 날짜 + limit</code></pre>
<p>예를 들면 다음과 같다.</p>
<pre><code class="language-text">DAILY + 2026-08-05 + limit 10
WEEKLY + 2026-08-04 + limit 10
DAILY + 2026-08-05 + limit 20</code></pre>
<p>기간이나 <code>limit</code>이 다르면 서로 다른 캐시로 관리된다.</p>
<hr>
<h2 id="⏱️-인기-숙소-캐시-ttl을-60초로-정한-이유">⏱️ 인기 숙소 캐시 TTL을 60초로 정한 이유</h2>
<p>현재 인기 숙소 응답 캐시의 기본 TTL은 60초다.</p>
<p>60초는 업계에서 공통으로 사용하는 표준값이 아니라, RoomPick의 데이터 특성과 성능 요구를 기준으로 정한 초기 절충값이다.</p>
<p>인기 숙소 순위는 예약 가능 여부나 결제 금액처럼 거래 정합성을 결정하는 데이터가 아니다.</p>
<pre><code class="language-text">예약 가능 여부가 오래된 경우
→ 이미 예약된 객실을 예약할 수 있다고 판단할 수 있음
→ 거래 정합성 문제 발생

인기 숙소 순위가 약 60초 오래된 경우
→ 최근 조회수가 순위에 잠시 늦게 반영됨
→ 거래 정합성에는 직접적인 영향이 없음</code></pre>
<p>따라서 인기 숙소 순위에는 약간의 최신성 지연을 허용하고, 그 대신 반복되는 Redis 랭킹 조회와 MySQL 조회를 줄이는 방향을 선택했다.</p>
<h3 id="ttl이-너무-짧다면">TTL이 너무 짧다면</h3>
<p>TTL을 10초나 30초로 설정하면 최근 랭킹을 더 빠르게 반영할 수 있다.</p>
<p>하지만 다음 비용이 증가한다.</p>
<pre><code class="language-text">캐시 만료 횟수 증가
→ Cache Miss 증가
→ Redis 랭킹 조회 증가
→ MySQL 조회 증가
→ DTO 재생성 증가
→ Cache Stampede 발생 기회 증가</code></pre>
<p>현재 인기 순위를 수 초 단위로 갱신해야 한다는 제품 요구는 없었다.</p>
<h3 id="ttl이-너무-길다면">TTL이 너무 길다면</h3>
<p>TTL을 3분이나 5분으로 설정하면 Cache Hit 비율은 높아질 수 있다.</p>
<p>그러나 캐시가 만들어진 직후 랭킹이 변경되면 사용자는 최대 5분 전의 인기 순위를 보게 된다.</p>
<p>인기 숙소는 최근 사용자의 관심 흐름을 보여주는 기능이므로, 초기 정책에서는 최대 반영 지연을 약 1분으로 제한했다.</p>
<blockquote>
<p>60초가 최적값이라고 단정한 것은 아니다.
실제 트래픽을 기준으로 30초, 60초, 180초, 300초 등을 비교한 뒤 조정할 수 있는 운영 설정으로 관리한다.</p>
</blockquote>
<hr>
<h2 id="💥-cache-stampede란">💥 Cache Stampede란?</h2>
<p>캐시가 유지되는 동안에는 같은 요청이 여러 번 들어와도 문제가 없다.</p>
<pre><code class="language-text">요청 10건
→ 모두 Cache Hit
→ Redis 랭킹 조회 0회
→ MySQL 조회 0회</code></pre>
<p>문제는 캐시가 만료되는 순간이다.</p>
<p>캐시가 사라진 직후 동일한 요청 10건이 동시에 들어오면 모든 요청이 다음과 같이 판단할 수 있다.</p>
<pre><code class="language-text">요청 1 → 캐시가 없다
요청 2 → 캐시가 없다
요청 3 → 캐시가 없다
...
요청 10 → 캐시가 없다</code></pre>
<p>각 요청은 다른 요청이 동일한 응답을 만들고 있다는 사실을 알 수 없다.</p>
<p>결국 모든 요청이 동일한 원본 조회를 실행한다.</p>
<pre><code class="language-text">요청 1 → Redis 조회 → MySQL 조회 → 응답 생성
요청 2 → Redis 조회 → MySQL 조회 → 응답 생성
요청 3 → Redis 조회 → MySQL 조회 → 응답 생성
...
요청 10 → Redis 조회 → MySQL 조회 → 응답 생성</code></pre>
<p>최종 결과는 같지만 Redis와 DB에는 불필요한 요청이 반복된다.</p>
<p>이처럼 캐시가 만료된 순간 다수의 요청이 동시에 원본 데이터로 몰리는 현상을 <strong>Cache Stampede</strong>, 즉 캐시 쇄도 현상이라고 한다.</p>
<hr>
<h2 id="🍽️-식당-안내판으로-이해해-보기">🍽️ 식당 안내판으로 이해해 보기</h2>
<p>식당에 오늘의 인기 메뉴가 적힌 안내판이 있다고 생각해 보자.</p>
<p>안내판이 유효한 동안 손님들은 안내판만 확인하면 된다.</p>
<pre><code class="language-text">손님 10명
→ 안내판 확인
→ 직원에게 질문하지 않음</code></pre>
<p>그런데 안내판 교체 시간이 되어 안내판이 비어 있을 때 손님 10명이 동시에 들어오면 어떻게 될까?</p>
<p>기존 방식에서는 손님 10명이 모두 주방 직원에게 같은 질문을 한다.</p>
<pre><code class="language-text">손님 10명
→ 오늘의 인기 메뉴가 무엇인지 각각 질문
→ 직원이 같은 내용을 10번 확인
→ 같은 안내판을 10번 작성</code></pre>
<p>우리가 원하는 방식은 다음과 같다.</p>
<pre><code class="language-text">첫 번째 손님
→ 주방 직원에게 인기 메뉴 질문
→ 새 안내판 작성

나머지 손님 9명
→ 첫 번째 손님이 받아올 결과를 기다림
→ 완성된 같은 안내판을 함께 사용</code></pre>
<p>요청을 거부하는 것이 아니라 <strong>이미 진행 중인 동일 작업의 결과를 공유하는 것</strong>이다.</p>
<hr>
<h2 id="🧪-추측하지-않고-먼저-문제를-재현했다">🧪 추측하지 않고 먼저 문제를 재현했다</h2>
<p>처음부터 락을 적용하지 않았다.</p>
<p>실제로 현재 코드에서 Cache Stampede가 발생하는지 통합 테스트로 먼저 확인했다.</p>
<p>테스트 조건은 다음과 같다.</p>
<pre><code class="language-text">캐시 상태: Cold cache
조회 기간: DAILY
limit: 1
동시 요청: 10건</code></pre>
<p>Cold cache는 해당 요청의 응답이 캐시에 존재하지 않는 상태를 의미한다.</p>
<h3 id="동시에-요청을-시작시키기">동시에 요청을 시작시키기</h3>
<p>단순히 스레드 10개를 실행하는 것만으로는 정확한 재현이 어려웠다.</p>
<p>첫 번째 요청이 너무 빨리 캐시를 생성하면 나머지 요청은 Cache Hit가 되어 문제를 재현하지 못할 수 있기 때문이다.</p>
<p>그래서 <code>CountDownLatch</code>를 사용했다.</p>
<pre><code class="language-text">ready
→ 모든 요청 스레드가 시작 준비를 마쳤는지 확인

start
→ 준비된 요청 10건을 동시에 출발

rankingLookupEntered
→ 요청이 실제 Redis 랭킹 조회에 진입했는지 확인

releaseRankingLookup
→ 첫 요청이 너무 빨리 캐시를 만들지 못하도록 원본 조회 반환을 잠시 차단</code></pre>
<p>핵심 흐름은 다음과 같다.</p>
<pre><code class="language-java">CountDownLatch ready =
    new CountDownLatch(THREAD_COUNT);

CountDownLatch start =
    new CountDownLatch(1);

CountDownLatch rankingLookupEntered =
    new CountDownLatch(THREAD_COUNT);

CountDownLatch releaseRankingLookup =
    new CountDownLatch(1);</code></pre>
<p>모든 요청이 Cache Miss 이후 원본 조회에 진입할 때까지 Redis 랭킹 결과를 반환하지 않도록 구성했다.</p>
<h3 id="재현-결과">재현 결과</h3>
<p>동일한 캐시 Key로 요청 10건을 동시에 실행한 결과는 다음과 같았다.</p>
<pre><code class="language-text">Redis 인기 랭킹 조회: 10회
MySQL ACTIVE 숙소 조회: 10회
동일 응답 생성: 10회</code></pre>
<p>캐시가 최종적으로 저장되기는 했지만, 캐시가 만들어지기 전 동일한 계산을 10번 반복하고 있었다.</p>
<p>즉, Cache Stampede가 이론적으로만 가능한 문제가 아니라 <strong>현재 RoomPick 코드에서 실제로 발생하는 문제</strong>임을 확인했다.</p>
<hr>
<h2 id="🔍-해결-방법-비교">🔍 해결 방법 비교</h2>
<p>문제를 재현한 뒤 다음 세 가지 대안을 비교했다.</p>
<ol>
<li><code>@Cacheable(sync = true)</code></li>
<li>Redis Locking Cache Writer 또는 분산 락</li>
<li>애플리케이션 내부 Key 단위 Single Flight</li>
</ol>
<hr>
<h2 id="1️⃣-cacheablesync--true">1️⃣ <code>@Cacheable(sync = true)</code></h2>
<p>Spring Cache에는 동일한 캐시 Key의 동시 조회를 동기화하기 위한 옵션이 있다.</p>
<pre><code class="language-java">@Cacheable(
    cacheNames = &quot;popularAccommodations&quot;,
    key = &quot;...&quot;,
    sync = true
)</code></pre>
<p>처음에는 가장 간단한 해결 방법으로 보였다.</p>
<p>하지만 RoomPick의 Redis Cache Manager는 기본 Non-locking Redis Cache Writer를 사용하고 있었다.</p>
<p>중요한 것은 문서만 보고 동작한다고 가정하지 않는 것이었다.</p>
<p>실제로 <code>sync = true</code>를 적용한 뒤 동일한 Stampede 테스트를 다시 실행했다.</p>
<p>결과는 다음과 같았다.</p>
<pre><code class="language-text">동시 요청 10건
→ Redis 랭킹 조회 10회
→ MySQL 조회 10회</code></pre>
<p>현재 Redis 캐시 구성에서는 <code>sync = true</code>만 추가해도 원본 조회가 하나로 합쳐지지 않았다.</p>
<p>효과가 없는 설정이므로 다시 제거했고 커밋에도 포함하지 않았다.</p>
<hr>
<h2 id="2️⃣-redis-locking-cache-writer">2️⃣ Redis Locking Cache Writer</h2>
<p>Redis Cache Writer를 Locking 방식으로 바꾸는 방법도 검토했다.</p>
<p>그러나 우리가 원하는 잠금 범위는 <strong>동일한 캐시 Key</strong>다.</p>
<pre><code class="language-text">DAILY:2026-08-05:10
→ 이 키와 동일한 요청만 대기</code></pre>
<p>캐시 이름 전체를 잠그는 방식은 서로 관계없는 요청까지 기다리게 할 수 있다.</p>
<pre><code class="language-text">DAILY + limit 10 갱신 중

WEEKLY + limit 10
→ 다른 요청이지만 대기할 가능성

DAILY + limit 20
→ 다른 요청이지만 대기할 가능성</code></pre>
<p>Stampede를 막는 대신 캐시 전체가 새로운 병목 지점이 될 수 있다.</p>
<hr>
<h2 id="3️⃣-redis-분산-락">3️⃣ Redis 분산 락</h2>
<p>Redis 분산 락을 사용하면 여러 애플리케이션 서버 사이에서도 중복 갱신을 제어할 수 있다.</p>
<p>하지만 분산 락에는 다음과 같은 추가 설계가 필요하다.</p>
<pre><code class="language-text">락 획득 실패 처리
락 대기 시간
락 만료 시간
재시도 정책
락 소유권 확인
서버 장애 시 락 해제
Redis 장애 처리</code></pre>
<p>현재 RoomPick은 단일 애플리케이션 인스턴스를 기준으로 운영하고 있었다.</p>
<p>아직 존재하지 않는 다중 인스턴스 문제를 해결하기 위해 분산 락의 복잡성을 미리 추가하는 것은 과도하다고 판단했다.</p>
<hr>
<h2 id="✅-최종-선택-key-단위-single-flight">✅ 최종 선택: Key 단위 Single Flight</h2>
<p>현재 구조에서 가장 적은 복잡도로 문제를 해결할 수 있는 <strong>애플리케이션 내부 Key 단위 Single Flight</strong>를 선택했다.</p>
<p>Single Flight는 다음과 같은 방식이다.</p>
<blockquote>
<p>동일한 작업이 이미 진행 중이라면 새로운 작업을 실행하지 않고, 진행 중인 한 번의 작업 결과를 함께 사용한다.</p>
</blockquote>
<p>새로운 Service를 추가했다.</p>
<pre><code class="language-text">PopularAccommodationSingleFlightService</code></pre>
<p>전체 호출 구조는 다음처럼 변경됐다.</p>
<h3 id="변경-전">변경 전</h3>
<pre><code class="language-text">Controller
→ AccommodationFacade
→ PopularAccommodationQueryService
→ Redis 랭킹 조회
→ MySQL 숙소 조회</code></pre>
<h3 id="변경-후">변경 후</h3>
<pre><code class="language-text">Controller
→ AccommodationFacade
→ PopularAccommodationSingleFlightService
→ PopularAccommodationQueryService
→ Redis 랭킹 조회
→ MySQL 숙소 조회</code></pre>
<p>각 계층의 책임은 다음과 같다.</p>
<pre><code class="language-text">AccommodationFacade
→ 인기 숙소 조회 흐름 조율
→ Redis 장애 fallback 처리

PopularAccommodationSingleFlightService
→ 동일한 캐시 Key의 동시 요청 제어

PopularAccommodationQueryService
→ 인기 숙소 데이터 조합
→ @Cacheable을 통한 응답 캐싱

PopularAccommodationRankingService
→ Redis 인기 랭킹 조회

AccommodationService
→ MySQL ACTIVE 숙소 조회</code></pre>
<p>동시성 제어 책임을 별도 Service로 분리하여 QueryService나 Repository가 과도한 책임을 가지지 않도록 했다.</p>
<hr>
<h2 id="🗂️-single-flight의-핵심-자료구조">🗂️ Single Flight의 핵심 자료구조</h2>
<p>구현에서는 다음 두 가지를 사용했다.</p>
<pre><code class="language-java">ConcurrentHashMap
CompletableFuture</code></pre>
<p>진행 중인 작업을 다음 형태로 관리한다.</p>
<pre><code class="language-java">private final ConcurrentMap&lt;
    String,
    CompletableFuture&lt;List&lt;PopularAccommodationResponseDto&gt;&gt;
&gt; inFlightRequests = new ConcurrentHashMap&lt;&gt;();</code></pre>
<p>개념적으로는 다음 정보가 들어 있다.</p>
<pre><code class="language-text">Key
→ DAILY:2026-08-05:10

Value
→ 현재 진행 중인 인기 숙소 조회 작업</code></pre>
<hr>
<h2 id="🥇-최초-요청-처리">🥇 최초 요청 처리</h2>
<p>동일 키로 들어온 요청 중 최초 요청만 실제 조회를 수행한다.</p>
<pre><code class="language-java">CompletableFuture&lt;List&lt;PopularAccommodationResponseDto&gt;&gt;
    newRequest = new CompletableFuture&lt;&gt;();

CompletableFuture&lt;List&lt;PopularAccommodationResponseDto&gt;&gt;
    existingRequest = inFlightRequests.putIfAbsent(
        cacheKey,
        newRequest
    );</code></pre>
<p><code>putIfAbsent()</code>는 다음과 같이 동작한다.</p>
<pre><code class="language-text">Map에 해당 Key가 없다
→ 내가 만든 Future 등록
→ 내가 최초 요청

Map에 해당 Key가 이미 있다
→ 기존 Future 반환
→ 다른 요청이 이미 작업 중</code></pre>
<p>여러 요청이 동시에 접근해도 <code>ConcurrentHashMap</code>과 <code>putIfAbsent()</code>를 사용하기 때문에 한 요청만 최초 요청이 된다.</p>
<pre><code class="language-text">요청 A → 등록 성공 → 실제 조회 실행
요청 B → 기존 Future 발견 → A의 결과 대기
요청 C → 기존 Future 발견 → A의 결과 대기</code></pre>
<hr>
<h2 id="⏳-나머지-요청-처리">⏳ 나머지 요청 처리</h2>
<p>이미 같은 Key의 요청이 진행 중이라면 새로운 원본 조회를 시작하지 않는다.</p>
<pre><code class="language-java">if (existingRequest != null) {
    return awaitResult(
        existingRequest
    );
}</code></pre>
<p>최초 요청의 <code>CompletableFuture</code>가 완료될 때까지 기다린 뒤 같은 결과를 반환한다.</p>
<pre><code class="language-text">최초 요청
→ Redis 랭킹 조회
→ MySQL 조회
→ 응답 생성
→ Future 완료

대기 요청
→ 기존 Future 대기
→ 최초 요청이 만든 응답 반환</code></pre>
<hr>
<h2 id="🎉-성공-결과-공유">🎉 성공 결과 공유</h2>
<p>최초 요청이 정상적으로 처리되면 결과로 Future를 완료한다.</p>
<pre><code class="language-java">currentRequest.complete(
    result
);</code></pre>
<p>대기 중인 모든 요청은 같은 결과를 전달받는다.</p>
<pre><code class="language-text">요청 10건
→ 실제 원본 조회 1회
→ 결과 객체를 10개 요청이 공유</code></pre>
<hr>
<h2 id="🚨-실패-결과도-공유해야-한다">🚨 실패 결과도 공유해야 한다</h2>
<p>성공 결과만 공유해서는 안 된다.</p>
<p>Redis 조회에 실패했을 때 최초 요청만 예외를 받고, 대기 요청들이 멈추거나 다른 동작을 하면 안 된다.</p>
<p>따라서 최초 요청에서 예외가 발생하면 Future도 예외 상태로 완료한다.</p>
<pre><code class="language-java">currentRequest.completeExceptionally(
    exception
);</code></pre>
<p>대기 요청은 <code>CompletionException</code> 내부의 원본 예외를 꺼내 다시 전달한다.</p>
<pre><code class="language-java">if (cause instanceof RuntimeException runtimeException) {
    throw runtimeException;
}

if (cause instanceof Error error) {
    throw error;
}</code></pre>
<p>이를 통해 성공뿐만 아니라 실패도 같은 작업의 결과로 공유한다.</p>
<pre><code class="language-text">최초 요청 성공
→ 모든 대기 요청이 같은 성공 결과 반환

최초 요청 Redis 장애
→ 모든 대기 요청이 같은 Redis 장애 예외 전달</code></pre>
<hr>
<h2 id="🧹-완료된-작업은-반드시-map에서-제거한다">🧹 완료된 작업은 반드시 Map에서 제거한다</h2>
<p>Single Flight Map은 현재 진행 중인 요청만 관리해야 한다.</p>
<p>완료된 Future가 계속 남아 있으면 이후 요청이 오래된 결과를 계속 재사용하거나 메모리가 증가할 수 있다.</p>
<p>따라서 성공과 실패 여부에 상관없이 <code>finally</code>에서 제거한다.</p>
<pre><code class="language-java">finally {
    inFlightRequests.remove(
        cacheKey,
        currentRequest
    );
}</code></pre>
<p>단순히 다음처럼 제거하지 않았다.</p>
<pre><code class="language-java">inFlightRequests.remove(cacheKey);</code></pre>
<p>대신 다음 메서드를 사용했다.</p>
<pre><code class="language-java">inFlightRequests.remove(
    cacheKey,
    currentRequest
);</code></pre>
<p>이는 Map에 저장된 값이 현재 요청의 Future와 같을 때만 제거한다는 의미다.</p>
<p>혹시 새로운 작업이 같은 Key에 등록된 상태라면 그 작업을 잘못 제거하지 않도록 방어할 수 있다.</p>
<hr>
<h2 id="🔑-서로-다른-요청까지-기다리지-않도록-key를-분리했다">🔑 서로 다른 요청까지 기다리지 않도록 Key를 분리했다</h2>
<p>Single Flight 식별 Key는 기존 응답 캐시 Key와 동일한 조건으로 생성했다.</p>
<pre><code class="language-java">return popularAccommodationKeyGenerator
    .generateCurrentKey(period)
    + &quot;:&quot;
    + limit;</code></pre>
<p>따라서 아래 요청들은 같은 작업을 공유한다.</p>
<pre><code class="language-text">DAILY + 2026-08-05 + limit 10
DAILY + 2026-08-05 + limit 10</code></pre>
<p>반면 다음 요청들은 서로 독립적으로 실행된다.</p>
<pre><code class="language-text">DAILY + limit 10
DAILY + limit 20
WEEKLY + limit 10</code></pre>
<p>즉, 캐시 전체에 락을 거는 것이 아니라 <strong>동일한 Key의 요청만 묶는다.</strong></p>
<hr>
<h2 id="🛟-기존-redis-장애-fallback-유지">🛟 기존 Redis 장애 fallback 유지</h2>
<p>RoomPick에는 Redis 인기 랭킹을 사용할 수 없을 때 API 전체 장애를 막기 위한 fallback이 존재한다.</p>
<pre><code class="language-text">Redis 인기 랭킹 조회 실패
→ PopularAccommodationRankingUnavailableException 발생
→ AccommodationFacade에서 예외 처리
→ 최신 ACTIVE 숙소를 MySQL에서 조회
→ 임시 rank 부여
→ fallback 응답 반환</code></pre>
<p>Single Flight가 예외를 내부에서 삼켜버리면 기존 fallback 흐름이 깨진다.</p>
<p>그래서 Single Flight는 예외를 처리하는 것이 아니라 대기 요청에도 동일한 예외를 전달하는 역할만 한다.</p>
<pre><code class="language-text">Single Flight
→ 동일 요청의 성공 또는 실패 결과 공유

Facade
→ Redis 장애일 때 DB fallback 실행</code></pre>
<p>책임을 분리하여 기존 장애 대응 흐름을 유지했다.</p>
<h3 id="redis-장애">Redis 장애</h3>
<pre><code class="language-text">Redis 랭킹 조회 실패
→ 예외 공유
→ Facade에서 최신 ACTIVE 숙소 fallback</code></pre>
<h3 id="mysql-장애">MySQL 장애</h3>
<pre><code class="language-text">MySQL 조회 실패
→ Redis 장애가 아니므로 fallback하지 않음
→ DB 예외 그대로 전달</code></pre>
<h3 id="비즈니스-예외">비즈니스 예외</h3>
<pre><code class="language-text">잘못된 limit 등 비즈니스 예외
→ fallback으로 숨기지 않음
→ 예외 그대로 전달</code></pre>
<hr>
<h2 id="📊-캐시-비활성화-시-single-flight도-우회">📊 캐시 비활성화 시 Single Flight도 우회</h2>
<p>RoomPick에서는 캐시 적용 전후의 성능을 비교하기 위해 캐시를 비활성화할 수 있다.</p>
<pre><code class="language-properties">roompick.cache.popular-accommodations-enabled=false</code></pre>
<p>캐시만 비활성화하고 Single Flight는 유지하면 동시 요청이 하나로 합쳐진다.</p>
<p>그러면 캐시 미적용 상태의 원래 비용을 정확하게 측정할 수 없다.</p>
<p>따라서 캐시가 비활성화된 경우 Single Flight도 우회했다.</p>
<pre><code class="language-java">if (!popularAccommodationCacheCondition.isEnabled()) {
    return popularAccommodationQueryService
        .getPopularAccommodations(
            period,
            limit
        );
}</code></pre>
<p>정책은 다음과 같다.</p>
<pre><code class="language-text">캐시 활성화
→ Single Flight 적용
→ 동일 Key의 원본 조회를 하나로 묶음

캐시 비활성화
→ Single Flight 우회
→ 모든 요청이 기존 조회 흐름 실행</code></pre>
<p>성능 테스트의 기준 조건을 유지하기 위한 결정이다.</p>
<hr>
<h2 id="🧪-개선-후-통합-테스트">🧪 개선 후 통합 테스트</h2>
<p>기존 재현 테스트를 Single Flight 개선 검증 테스트로 변경했다.</p>
<p>동일한 조건으로 다시 실행했다.</p>
<pre><code class="language-text">Cold cache
동일 캐시 Key
동시 요청 10건</code></pre>
<p>최초 요청이 원본 조회에 진입하면 Redis 랭킹 응답을 잠시 막아 나머지 요청들이 동일한 진행 중 작업을 발견할 수 있도록 했다.</p>
<pre><code class="language-java">CountDownLatch rankingLookupEntered =
    new CountDownLatch(1);

CountDownLatch releaseRankingLookup =
    new CountDownLatch(1);</code></pre>
<p>모든 요청은 다음 Service를 호출한다.</p>
<pre><code class="language-java">popularAccommodationSingleFlightService
    .getPopularAccommodations(
        DAILY,
        LIMIT
    );</code></pre>
<p>최종 검증 조건은 다음과 같다.</p>
<pre><code class="language-java">then(popularAccommodationRankingService)
    .should(times(1))
    .findRankedAccommodationIds(
        DAILY,
        LIMIT,
        0L,
        4L
    );

then(accommodationService)
    .should(times(1))
    .findAllActiveSummaryByIds(
        rankedAccommodationIds
    );</code></pre>
<hr>
<h2 id="📈-개선-전후-결과">📈 개선 전후 결과</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th align="right">개선 전</th>
<th align="right">개선 후</th>
</tr>
</thead>
<tbody><tr>
<td>동시 요청 수</td>
<td align="right">10건</td>
<td align="right">10건</td>
</tr>
<tr>
<td>Redis 랭킹 조회</td>
<td align="right">10회</td>
<td align="right">1회</td>
</tr>
<tr>
<td>MySQL 숙소 조회</td>
<td align="right">10회</td>
<td align="right">1회</td>
</tr>
<tr>
<td>응답 DTO 생성</td>
<td align="right">10회</td>
<td align="right">1회</td>
</tr>
<tr>
<td>사용자 요청 결과</td>
<td align="right">모두 성공</td>
<td align="right">모두 성공</td>
</tr>
<tr>
<td>최종 Redis 캐시 저장</td>
<td align="right">성공</td>
<td align="right">성공</td>
</tr>
</tbody></table>
<p>사용자가 받는 응답은 동일하지만 내부에서 발생하는 중복 비용은 크게 감소했다.</p>
<pre><code class="language-text">개선 전

동일 요청 10건
→ Redis 조회 10회
→ MySQL 조회 10회
→ 같은 응답 10번 생성</code></pre>
<pre><code class="language-text">개선 후

동일 요청 10건
→ Redis 조회 1회
→ MySQL 조회 1회
→ 최초 요청 결과를 10건이 공유</code></pre>
<p>특히 MySQL 조회가 10회에서 1회로 줄어 DB Connection과 쿼리 실행 비용을 줄일 수 있었다.</p>
<hr>
<h2 id="✅-회귀-테스트">✅ 회귀 테스트</h2>
<p>Single Flight 적용 후 기존 기능이 깨지지 않았는지 다음 테스트를 실행했다.</p>
<h3 id="인기-숙소-관련-통합-테스트">인기 숙소 관련 통합 테스트</h3>
<pre><code class="language-bash">./gradlew integrationTest \
  --tests &quot;*PopularAccommodation*&quot;</code></pre>
<h3 id="stampede-통합-테스트">Stampede 통합 테스트</h3>
<pre><code class="language-bash">./gradlew integrationTest \
  --tests &quot;*PopularAccommodationCacheStampedeIntegrationTest&quot;</code></pre>
<h3 id="전체-단위-테스트">전체 단위 테스트</h3>
<pre><code class="language-bash">./gradlew clean test</code></pre>
<p>Facade가 기존 <code>PopularAccommodationQueryService</code> 대신 <code>PopularAccommodationSingleFlightService</code>를 호출하도록 변경됐기 때문에 기존 Facade 단위 테스트의 Mock 대상도 함께 수정했다.</p>
<p>모든 테스트가 최종적으로 성공했다.</p>
<hr>
<h2 id="🏗️-최종-구조">🏗️ 최종 구조</h2>
<pre><code class="language-text">Controller
    ↓
AccommodationFacade
    ↓
PopularAccommodationSingleFlightService
    ↓
PopularAccommodationQueryService
    ↓
PopularAccommodationRankingService
    ↓
Redis Repository</code></pre>
<p>그리고 숙소 공개 정보는 다음 흐름으로 조회된다.</p>
<pre><code class="language-text">PopularAccommodationQueryService
    ↓
AccommodationService
    ↓
AccommodationRepository
    ↓
MySQL</code></pre>
<p>각 계층은 다음 책임을 가진다.</p>
<table>
<thead>
<tr>
<th>계층</th>
<th>책임</th>
</tr>
</thead>
<tbody><tr>
<td>Controller</td>
<td>요청 수신 및 응답 반환</td>
</tr>
<tr>
<td>Facade</td>
<td>전체 비즈니스 흐름 조율, Redis 장애 fallback</td>
</tr>
<tr>
<td>SingleFlightService</td>
<td>동일 캐시 Key의 동시 요청 제어</td>
</tr>
<tr>
<td>QueryService</td>
<td>랭킹과 숙소 데이터를 조합하고 응답 캐싱</td>
</tr>
<tr>
<td>RankingService</td>
<td>Redis 랭킹 조회</td>
</tr>
<tr>
<td>AccommodationService</td>
<td>ACTIVE 숙소 DB 조회</td>
</tr>
<tr>
<td>Repository</td>
<td>Redis 및 MySQL 명령 실행</td>
</tr>
</tbody></table>
<hr>
<h2 id="⚠️-현재-구현의-한계">⚠️ 현재 구현의 한계</h2>
<p>이번 Single Flight는 <strong>애플리케이션 인스턴스 내부에서만 동작한다.</strong></p>
<p>서버가 한 대라면 다음과 같다.</p>
<pre><code class="language-text">서버 A에 동일 요청 10건
→ 서버 A 내부에서 원본 조회 1회</code></pre>
<p>서버가 두 대라면 각 서버가 자신의 Map을 가지고 있다.</p>
<pre><code class="language-text">서버 A에 요청 5건
→ 서버 A에서 원본 조회 1회

서버 B에 요청 5건
→ 서버 B에서 원본 조회 1회</code></pre>
<p>전체적으로는 원본 조회가 2회 발생할 수 있다.</p>
<p>하지만 현재 RoomPick은 단일 인스턴스 구조이므로 애플리케이션 내부 Single Flight가 현재 문제를 해결하기 위한 가장 단순하고 적절한 방식이라고 판단했다.</p>
<p>향후 다중 인스턴스 환경에서 인스턴스 간 중복 갱신이 실제 문제로 확인된다면 다음 방법을 검토할 수 있다.</p>
<pre><code class="language-text">Redis 분산 락
분산 Single Flight
Soft TTL
캐시 선제 갱신
백그라운드 Refresh</code></pre>
<p>실제 문제가 발생하기 전에 분산 락의 복잡성을 미리 추가하지 않는 것도 중요한 설계 결정이라고 생각한다.</p>
<hr>
<h2 id="💡-이번-작업에서-배운-점">💡 이번 작업에서 배운 점</h2>
<h3 id="1-캐시-적용이-성능-문제의-끝은-아니다">1. 캐시 적용이 성능 문제의 끝은 아니다</h3>
<p>캐시는 평상시 원본 조회를 줄여주지만 캐시 만료 시점에는 요청이 원본 데이터로 한꺼번에 몰릴 수 있다.</p>
<p>Cache Hit 성능뿐 아니라 다음 상황도 함께 검증해야 한다.</p>
<pre><code class="language-text">Cold cache
TTL 만료 직후
동시 요청
Redis 장애
캐시 비활성화</code></pre>
<h3 id="2-동시성-문제는-테스트에서-실제로-재현해야-한다">2. 동시성 문제는 테스트에서 실제로 재현해야 한다</h3>
<p>“아마 중복 조회가 발생할 것이다”라는 추측만으로 복잡한 락을 추가하지 않았다.</p>
<p><code>CountDownLatch</code>와 다중 스레드 테스트를 통해 실제로 Redis와 MySQL 조회가 각각 10회 발생한다는 것을 확인한 뒤 개선했다.</p>
<h3 id="3-가장-복잡한-기술이-항상-가장-좋은-해결책은-아니다">3. 가장 복잡한 기술이 항상 가장 좋은 해결책은 아니다</h3>
<p>Redis 분산 락은 강력하지만 현재 단일 인스턴스 구조에서는 과도한 복잡성이 될 수 있다.</p>
<p>현재 문제의 범위에 맞춰 Key 단위 애플리케이션 Single Flight를 선택했다.</p>
<h3 id="4-성공뿐-아니라-실패도-공유해야-한다">4. 성공뿐 아니라 실패도 공유해야 한다</h3>
<p>동일 작업을 하나로 합칠 때 성공 결과만 공유하면 안 된다.</p>
<p>최초 요청에서 발생한 예외도 대기 요청에 전달해야 기존 예외 처리와 fallback 흐름을 유지할 수 있다.</p>
<h3 id="5-성능-개선은-기존-장애-정책을-깨뜨리지-않아야-한다">5. 성능 개선은 기존 장애 정책을 깨뜨리지 않아야 한다</h3>
<p>Redis 중복 조회를 줄이는 것만큼 기존 DB fallback과 비즈니스 예외 정책을 유지하는 것도 중요했다.</p>
<p>최적화 때문에 예외 의미가 달라지거나 잘못된 fallback이 실행되지 않도록 책임을 분리했다.</p>
<h3 id="6-설정값의-이유를-문서로-남겨야-한다">6. 설정값의 이유를 문서로 남겨야 한다</h3>
<p>TTL 60초라는 숫자만 남아 있으면 이후 팀원이 변경 여부를 판단하기 어렵다.</p>
<p>다음을 문서화했다.</p>
<pre><code class="language-text">왜 60초인가
왜 더 짧지 않은가
왜 더 길지 않은가
어떤 최신성 지연을 허용하는가
어떤 변경은 즉시 캐시를 무효화해야 하는가
현재 결정의 한계는 무엇인가
향후 어떤 비교 측정이 필요한가</code></pre>
<p>숫자보다 중요한 것은 숫자를 선택한 근거라고 느꼈다.</p>
<hr>
<h2 id="🎯-마무리">🎯 마무리</h2>
<p>이번 작업에서는 인기 숙소 응답 캐시가 만료된 직후 동일 요청이 동시에 유입되면 Redis 랭킹과 MySQL 숙소 정보를 중복 조회하는 Cache Stampede 문제를 발견했다.</p>
<p>먼저 10개의 동시 요청을 사용한 통합 테스트로 실제 문제를 재현했다.</p>
<pre><code class="language-text">개선 전
→ Redis 랭킹 조회 10회
→ MySQL 조회 10회</code></pre>
<p>이후 <code>@Cacheable(sync = true)</code>, Redis Locking Cache Writer, Redis 분산 락을 비교했다.</p>
<p>현재 RoomPick의 단일 인스턴스 구조에서는 동일 캐시 Key만 제어하는 애플리케이션 내부 Single Flight가 가장 적은 복잡도로 문제를 해결할 수 있다고 판단했다.</p>
<p><code>ConcurrentHashMap</code>과 <code>CompletableFuture</code>를 사용해 최초 요청의 성공 결과 또는 예외를 공유하도록 구현했고, 동일한 테스트 조건에서 다음 결과를 확인했다.</p>
<pre><code class="language-text">개선 후
→ Redis 랭킹 조회 1회
→ MySQL 조회 1회
→ 동시 요청 10건 모두 동일한 결과 반환</code></pre>
<p>또한 캐시 비활성화 성능 측정 조건과 Redis 장애 시 DB fallback 흐름도 그대로 유지했다.</p>
<p>이번 경험을 통해 단순히 캐시를 적용하는 것을 넘어, <strong>캐시가 비어 있거나 만료되는 순간에 시스템이 어떻게 동작하는지까지 검증해야 한다는 것</strong>을 배울 수 있었다.</p>
<hr>
<h2 id="🏷️-tags">🏷️ Tags</h2>
<p><code>Spring Boot</code> <code>Redis</code> <code>Cache</code> <code>Cache Stampede</code> <code>Single Flight</code> <code>CompletableFuture</code> <code>ConcurrentHashMap</code> <code>동시성</code> <code>성능 최적화</code> <code>MySQL</code> <code>Testcontainers</code> <code>Spring Cache</code></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블슈팅] Kafka + Outbox를 붙이면서 만난 Spring Boot 테스트 실패 해결기 📨🔥]]></title>
            <link>https://velog.io/@loaded_diaper/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-Kafka-Outbox%EB%A5%BC-%EB%B6%99%EC%9D%B4%EB%A9%B4%EC%84%9C-%EB%A7%8C%EB%82%9C-Spring-Boot-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8B%A4%ED%8C%A8-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@loaded_diaper/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-Kafka-Outbox%EB%A5%BC-%EB%B6%99%EC%9D%B4%EB%A9%B4%EC%84%9C-%EB%A7%8C%EB%82%9C-Spring-Boot-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8B%A4%ED%8C%A8-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Mon, 20 Jul 2026 00:35:07 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/loaded_diaper/post/374d2710-ede0-45e4-aea8-63206187a002/image.png" alt=""></p>
<blockquote>
<p>커피 주문 프로젝트에 Kafka + Outbox 패턴을 적용하면서 발생한 여러 문제와 해결 과정을 정리한 글입니다.</p>
</blockquote>
<hr>
<h2 id="1-구현하려던-기능-🚀">1. 구현하려던 기능 🚀</h2>
<p>커피 주문 프로젝트에서 주문이 완료되면 주문 데이터를 외부 데이터 플랫폼으로 전송해야 했다.</p>
<p>처음에는 주문이 성공한 뒤 바로 Kafka로 이벤트를 보내는 방식을 생각했다.</p>
<p>하지만 그렇게 하면 이런 문제가 생길 수 있다.</p>
<pre><code class="language-text">DB 주문 저장 성공
→ Kafka 발행 실패
→ 주문은 성공했지만 데이터 플랫폼에는 전달되지 않음</code></pre>
<p>그래서 Outbox 패턴을 적용했다.</p>
<p>구조는 다음과 같다.</p>
<pre><code class="language-text">주문 요청
→ OrderFacade
→ 포인트 차감
→ 주문 저장
→ 포인트 이력 저장
→ outbox_events 테이블에 주문 완료 이벤트 저장
→ 트랜잭션 커밋
→ OutboxEventPublisher가 PENDING 이벤트 조회
→ Kafka로 발행
→ 성공 시 SENT 처리
→ DataPlatformConsumer가 수신
→ Mock 데이터 플랫폼 전송 로그 출력</code></pre>
<p>주문 트랜잭션 안에서는 Kafka를 직접 호출하지 않고, 먼저 <code>outbox_events</code> 테이블에 이벤트를 저장했다.</p>
<hr>
<h2 id="2-첫-번째-문제-jackson-예외-클래스를-찾지-못함-🧩">2. 첫 번째 문제: Jackson 예외 클래스를 찾지 못함 🧩</h2>
<p>Outbox 이벤트를 JSON 문자열로 저장하기 위해 <code>ObjectMapper</code>를 사용했다.</p>
<p>처음에는 아래처럼 작성했다.</p>
<pre><code class="language-java">import tools.jackson.core.JsonProcessingException;
import tools.jackson.databind.ObjectMapper;</code></pre>
<p>그런데 IntelliJ에서 에러가 발생했다.</p>
<pre><code class="language-text">Cannot resolve symbol &#39;JsonProcessingException&#39;</code></pre>
<hr>
<h2 id="3-원인-🔍">3. 원인 🔍</h2>
<p>프로젝트 환경이 Spring Boot 4 계열이었고, Jackson 3이 사용되고 있었다.</p>
<p>기존 Jackson 2에서는 보통 아래 패키지를 많이 사용한다.</p>
<pre><code class="language-java">com.fasterxml.jackson.core.JsonProcessingException</code></pre>
<p>하지만 현재 환경에서는 <code>tools.jackson</code> 패키지를 사용하고 있었고, 예외 타입도 <code>JsonProcessingException</code>이 아니라 <code>JacksonException</code>을 사용해야 했다.</p>
<hr>
<h2 id="4-해결-✅">4. 해결 ✅</h2>
<p>아래처럼 변경했다.</p>
<pre><code class="language-java">import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;</code></pre>
<p>그리고 catch 문도 수정했다.</p>
<pre><code class="language-java">private String serializeEvent(OrderCompletedEvent event) {
    try {
        return objectMapper.writeValueAsString(event);
    } catch (JacksonException e) {
        throw new CustomException(ErrorCode.OUTBOX_EVENT_CREATE_FAILED);
    }
}</code></pre>
<p>이후 빌드는 통과했다.</p>
<hr>
<h2 id="5-두-번째-문제-kafkatemplate-bean을-찾지-못함-🚨">5. 두 번째 문제: KafkaTemplate Bean을 찾지 못함 🚨</h2>
<p>Outbox 이벤트를 Kafka로 발행하기 위해 <code>OutboxEventPublisher</code>를 만들었다.</p>
<pre><code class="language-java">@Component
public class OutboxEventPublisher {

    private final OutboxService outboxService;
    private final KafkaTemplate&lt;String, String&gt; kafkaTemplate;

    public OutboxEventPublisher(
        OutboxService outboxService,
        KafkaTemplate&lt;String, String&gt; kafkaTemplate
    ) {
        this.outboxService = outboxService;
        this.kafkaTemplate = kafkaTemplate;
    }
}</code></pre>
<p>그런데 테스트를 실행하니 전체 빌드가 실패했다.</p>
<pre><code class="language-bash">./gradlew clean build</code></pre>
<p>에러는 다음과 같았다.</p>
<pre><code class="language-text">CoffeeOrderApplicationTests &gt; contextLoads() FAILED

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException</code></pre>
<p>핵심은 이거였다.</p>
<pre><code class="language-text">No beans of &#39;KafkaTemplate&lt;String, String&gt;&#39; type found</code></pre>
<hr>
<h2 id="6-원인-💥">6. 원인 💥</h2>
<p><code>OutboxEventPublisher</code>는 Spring Bean으로 등록된다.</p>
<p>따라서 테스트 환경에서 <code>contextLoads()</code>가 실행될 때도 Spring은 <code>OutboxEventPublisher</code>를 생성하려고 한다.</p>
<p>그런데 이 Bean을 만들기 위해서는 <code>KafkaTemplate&lt;String, String&gt;</code>이 필요하다.</p>
<p>문제는 테스트 컨텍스트에서 <code>KafkaTemplate</code> Bean이 준비되어 있지 않았다는 점이다.</p>
<p>즉, 운영 코드에 Kafka 관련 컴포넌트를 추가한 순간, 테스트 컨텍스트도 영향을 받게 된 것이다.</p>
<hr>
<h2 id="7-해결-kafkaproducerconfig-추가-🛠️">7. 해결: KafkaProducerConfig 추가 🛠️</h2>
<p><code>KafkaTemplate&lt;String, String&gt;</code> Bean을 명시적으로 등록했다.</p>
<pre><code class="language-java">package com.sparta.coffeeorder.config;

import java.util.HashMap;
import java.util.Map;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;

@Configuration
public class KafkaProducerConfig {

    @Value(&quot;${spring.kafka.bootstrap-servers:127.0.0.1:9092}&quot;)
    private String bootstrapServers;

    @Bean
    @ConditionalOnMissingBean
    public ProducerFactory&lt;String, String&gt; producerFactory() {
        Map&lt;String, Object&gt; configProps = new HashMap&lt;&gt;();

        configProps.put(
            ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,
            bootstrapServers
        );
        configProps.put(
            ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
            StringSerializer.class
        );
        configProps.put(
            ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
            StringSerializer.class
        );

        return new DefaultKafkaProducerFactory&lt;&gt;(configProps);
    }

    @Bean
    @ConditionalOnMissingBean
    public KafkaTemplate&lt;String, String&gt; kafkaTemplate(
        ProducerFactory&lt;String, String&gt; producerFactory
    ) {
        return new KafkaTemplate&lt;&gt;(producerFactory);
    }
}</code></pre>
<p>이후 다시 빌드를 실행했다.</p>
<pre><code class="language-bash">./gradlew clean build</code></pre>
<p>빌드가 성공했다.</p>
<hr>
<h2 id="8-세-번째-문제-테스트에서-kafka-listener와-scheduler가-같이-뜰-수-있음-⚠️">8. 세 번째 문제: 테스트에서 Kafka Listener와 Scheduler가 같이 뜰 수 있음 ⚠️</h2>
<p>Kafka Consumer와 Outbox Scheduler를 추가하면 테스트 환경에서도 이들이 같이 실행될 가능성이 있다.</p>
<p>예를 들어 아래와 같은 컴포넌트가 있었다.</p>
<pre><code class="language-java">@Scheduled(fixedDelay = 5000)
public void publishPendingEvents() {
    List&lt;OutboxEvent&gt; outboxEvents = outboxService.findPublishTargets(PUBLISH_LIMIT);

    for (OutboxEvent outboxEvent : outboxEvents) {
        publish(outboxEvent);
    }
}</code></pre>
<p>그리고 Kafka Consumer도 있었다.</p>
<pre><code class="language-java">@KafkaListener(
    topics = &quot;order-completed&quot;,
    groupId = &quot;coffee-order-data-platform&quot;
)
public void consumeOrderCompletedEvent(String payload) {
    log.info(&quot;[MockDataPlatform] 주문 데이터 전송 완료. payload={}&quot;, payload);
}</code></pre>
<p>운영 환경에서는 필요하지만, 테스트 환경에서는 외부 인프라와 연결되면서 테스트가 불안정해질 수 있다.</p>
<hr>
<h2 id="9-해결-테스트-환경에서-kafka-listener와-scheduling-비활성화-✅">9. 해결: 테스트 환경에서 Kafka Listener와 Scheduling 비활성화 ✅</h2>
<p><code>src/test/resources/application-test.yml</code>에 아래 설정을 추가했다.</p>
<pre><code class="language-yaml">spring:
  kafka:
    listener:
      auto-startup: false

  task:
    scheduling:
      enabled: false</code></pre>
<p>전체 테스트 설정은 이런 형태였다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:h2:mem:coffee_order_test;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
    open-in-view: false

  kafka:
    listener:
      auto-startup: false

  task:
    scheduling:
      enabled: false</code></pre>
<p>이렇게 해서 테스트 중에는 Kafka Listener와 Scheduler가 자동으로 동작하지 않도록 했다.</p>
<hr>
<h2 id="10-네-번째-문제-테스트-클래스-생성자-주입-실패-🧪">10. 네 번째 문제: 테스트 클래스 생성자 주입 실패 🧪</h2>
<p>동시성 테스트를 작성하면서 테스트 클래스에 생성자 주입을 사용했다.</p>
<pre><code class="language-java">@SpringBootTest
@ActiveProfiles(&quot;test&quot;)
class OrderFacadeConcurrencyTest {

    private final OrderFacade orderFacade;
    private final PointService pointService;

    OrderFacadeConcurrencyTest(
        OrderFacade orderFacade,
        PointService pointService
    ) {
        this.orderFacade = orderFacade;
        this.pointService = pointService;
    }
}</code></pre>
<p>그런데 테스트 실행 시 아래 에러가 발생했다.</p>
<pre><code class="language-text">ParameterResolutionException</code></pre>
<hr>
<h2 id="11-원인-🔍">11. 원인 🔍</h2>
<p>운영 코드에서는 생성자 주입을 사용하면 Spring이 Bean을 주입해준다.</p>
<p>하지만 테스트 클래스는 JUnit이 먼저 객체를 생성하려고 한다.</p>
<p>테스트 클래스 생성자에 여러 파라미터가 있을 때 JUnit이 이 파라미터들을 어떻게 주입해야 할지 명확히 알지 못하면 <code>ParameterResolutionException</code>이 발생할 수 있다.</p>
<hr>
<h2 id="12-해결-생성자에-autowired-추가-✅">12. 해결: 생성자에 @Autowired 추가 ✅</h2>
<p>테스트 클래스 생성자에 <code>@Autowired</code>를 명시했다.</p>
<pre><code class="language-java">import org.springframework.beans.factory.annotation.Autowired;

@Autowired
OrderFacadeConcurrencyTest(
    OrderFacade orderFacade,
    PointService pointService,
    MenuRepository menuRepository,
    OrderRepository orderRepository,
    PointWalletRepository pointWalletRepository,
    PointHistoryRepository pointHistoryRepository,
    OutboxEventRepository outboxEventRepository
) {
    this.orderFacade = orderFacade;
    this.pointService = pointService;
    this.menuRepository = menuRepository;
    this.orderRepository = orderRepository;
    this.pointWalletRepository = pointWalletRepository;
    this.pointHistoryRepository = pointHistoryRepository;
    this.outboxEventRepository = outboxEventRepository;
}</code></pre>
<p>이후 동시성 테스트가 정상적으로 실행되었다.</p>
<hr>
<h2 id="13-동시성-테스트-내용-🧵">13. 동시성 테스트 내용 🧵</h2>
<p>포인트 결제에서 가장 중요한 것은 잔액보다 많이 차감되지 않는 것이다.</p>
<p>테스트 시나리오는 다음과 같았다.</p>
<pre><code class="language-text">사용자 포인트: 10,000원
메뉴 가격: 4,500원
동시 주문 요청: 3건</code></pre>
<p>정상이라면 3건 중 2건만 성공해야 한다.</p>
<pre><code class="language-text">성공 2건: 4,500원 × 2 = 9,000원
실패 1건: 포인트 부족
최종 잔액: 1,000원</code></pre>
<p>테스트에서는 <code>CountDownLatch</code>를 사용해 여러 스레드가 동시에 주문을 시작하도록 했다.</p>
<pre><code class="language-java">CountDownLatch readyLatch = new CountDownLatch(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);</code></pre>
<p>그리고 결과를 검증했다.</p>
<pre><code class="language-java">assertThat(successCount.get()).isEqualTo(2);
assertThat(failureCount.get()).isEqualTo(1);

PointWallet pointWallet = pointWalletRepository.findByUserId(userId)
    .orElseThrow();

assertThat(pointWallet.getBalance()).isEqualTo(1000);
assertThat(orderRepository.findAll()).hasSize(2);
assertThat(outboxEventRepository.findAll()).hasSize(2);</code></pre>
<p>이 테스트를 통해 포인트 차감이 동시에 들어와도 잔액을 초과해서 차감되지 않는 것을 확인했다.</p>
<hr>
<h2 id="14-outbox-이벤트-발행-확인-📬">14. Outbox 이벤트 발행 확인 📬</h2>
<p>주문이 성공하면 <code>outbox_events</code> 테이블에 이벤트가 먼저 저장된다.</p>
<p>초기 상태는 다음과 같다.</p>
<pre><code class="language-text">status = PENDING
event_type = ORDER_COMPLETED
topic = order-completed</code></pre>
<p>이후 <code>OutboxEventPublisher</code>가 주기적으로 PENDING 이벤트를 조회해서 Kafka로 발행한다.</p>
<p>발행 성공 후에는 상태가 변경된다.</p>
<pre><code class="language-text">PENDING → SENT</code></pre>
<p>DB에서 확인한 쿼리는 다음과 같다.</p>
<pre><code class="language-sql">SELECT
    id,
    aggregate_id,
    event_type,
    status,
    retry_count,
    published_at,
    last_error
FROM outbox_events
ORDER BY id DESC;</code></pre>
<p>정상 결과는 다음과 같았다.</p>
<pre><code class="language-text">status = SENT
retry_count = 0
published_at = 값 있음
last_error = NULL</code></pre>
<hr>
<h2 id="15-kafka-consumer-수신-확인-📡">15. Kafka Consumer 수신 확인 📡</h2>
<p>Kafka Consumer도 정상적으로 메시지를 수신했다.</p>
<p>서버 로그에는 다음과 같은 로그가 출력되었다.</p>
<pre><code class="language-text">Subscribed to topic(s): order-completed
partitions assigned: [order-completed-0]
[MockDataPlatform] 주문 데이터 전송 완료. payload={...}</code></pre>
<p>즉, 최종 흐름이 정상적으로 동작했다.</p>
<pre><code class="language-text">주문 성공
→ outbox_events 저장
→ Kafka 발행
→ Consumer 수신
→ Mock 데이터 플랫폼 전송 로그 출력</code></pre>
<hr>
<h2 id="16-최종-구조-정리-🧱">16. 최종 구조 정리 🧱</h2>
<p>최종 구조는 다음과 같다.</p>
<pre><code class="language-text">OrderController
→ OrderFacade
→ MenuService
→ PointService
→ OrderService
→ OutboxService
→ Repository</code></pre>
<p>Kafka + Outbox 흐름은 다음과 같다.</p>
<pre><code class="language-text">OrderFacade
→ 주문/포인트 처리 트랜잭션
→ outbox_events 저장
→ 트랜잭션 커밋
→ OutboxEventPublisher
→ Kafka order-completed topic 발행
→ DataPlatformConsumer
→ Mock 데이터 플랫폼 전송 로그 출력</code></pre>
<hr>
<h2 id="17-이번-과정에서-배운-점-📌">17. 이번 과정에서 배운 점 📌</h2>
<p>이번 Kafka + Outbox 적용 과정에서 배운 점은 많았다.</p>
<h3 id="1-외부-인프라를-붙이면-테스트-컨텍스트도-같이-고려해야-한다">1. 외부 인프라를 붙이면 테스트 컨텍스트도 같이 고려해야 한다</h3>
<p>Kafka Producer, Consumer, Scheduler는 운영 코드에서만 영향을 주는 것이 아니었다.</p>
<p><code>@SpringBootTest</code>는 애플리케이션 컨텍스트를 전체 로딩하기 때문에 관련 Bean들이 테스트에서도 생성된다.</p>
<p>따라서 테스트 환경에서는 필요한 설정을 분리해야 한다.</p>
<hr>
<h3 id="2-outbox는-kafka-발행-실패로-인한-이벤트-유실-가능성을-줄인다">2. Outbox는 Kafka 발행 실패로 인한 이벤트 유실 가능성을 줄인다</h3>
<p>Kafka를 주문 트랜잭션 안에서 바로 호출하면 DB 저장과 Kafka 발행 사이에 불일치가 생길 수 있다.</p>
<p>Outbox를 사용하면 주문 데이터와 이벤트 데이터를 같은 DB 트랜잭션으로 저장할 수 있다.</p>
<pre><code class="language-text">주문 저장 성공 = Outbox 이벤트 저장 성공
주문 저장 실패 = Outbox 이벤트도 롤백</code></pre>
<p>이후 Kafka 발행은 별도 Publisher가 처리하기 때문에 재시도와 상태 관리가 가능하다.</p>
<hr>
<h3 id="3-동시성-테스트는-구현의-안정성을-설명할-수-있는-좋은-근거가-된다">3. 동시성 테스트는 구현의 안정성을 설명할 수 있는 좋은 근거가 된다</h3>
<p>단순히 &quot;비관적 락을 적용했다&quot;라고 말하는 것보다, 테스트로 검증하는 것이 훨씬 설득력 있다.</p>
<p>이번 테스트에서는 10,000원을 가진 사용자에게 4,500원 주문 3건을 동시에 요청했고, 2건만 성공하는 것을 확인했다.</p>
<hr>
<h2 id="18-정리-🧹">18. 정리 🧹</h2>
<p>이번 트러블슈팅을 요약하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>문제</th>
<th>원인</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>JsonProcessingException 인식 실패</td>
<td>Jackson 3 환경에서 예외 타입 차이</td>
<td>JacksonException 사용</td>
</tr>
<tr>
<td>KafkaTemplate Bean 없음</td>
<td>테스트 컨텍스트에서 KafkaTemplate Bean 미등록</td>
<td>KafkaProducerConfig 추가</td>
</tr>
<tr>
<td>테스트에서 Kafka/Scheduler 실행 가능성</td>
<td>테스트 환경에서도 전체 Bean 로딩</td>
<td>application-test.yml에서 비활성화</td>
</tr>
<tr>
<td>테스트 생성자 주입 실패</td>
<td>JUnit이 생성자 파라미터를 주입하지 못함</td>
<td>생성자에 @Autowired 추가</td>
</tr>
<tr>
<td>동시 주문 잔액 초과 가능성</td>
<td>동시에 포인트 차감 요청 발생</td>
<td>PESSIMISTIC_WRITE 락 + 동시성 테스트</td>
</tr>
</tbody></table>
<p>이번 경험을 통해 Kafka나 Outbox 같은 외부 인프라 기반 구조를 붙일 때는 단순히 기능 구현만 보는 것이 아니라,<br><strong>트랜잭션 경계, 테스트 환경, Bean 등록, 동시성 검증까지 함께 고려해야 한다</strong>는 점을 배웠다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블슈팅] Postman은 성공했는데 Docker MySQL에는 데이터가 없었다 🐳🧐]]></title>
            <link>https://velog.io/@loaded_diaper/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-Postman%EC%9D%80-%EC%84%B1%EA%B3%B5%ED%96%88%EB%8A%94%EB%8D%B0-Docker-MySQL%EC%97%90%EB%8A%94-%EB%8D%B0%EC%9D%B4%ED%84%B0%EA%B0%80-%EC%97%86%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@loaded_diaper/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-Postman%EC%9D%80-%EC%84%B1%EA%B3%B5%ED%96%88%EB%8A%94%EB%8D%B0-Docker-MySQL%EC%97%90%EB%8A%94-%EB%8D%B0%EC%9D%B4%ED%84%B0%EA%B0%80-%EC%97%86%EC%97%88%EB%8B%A4</guid>
            <pubDate>Mon, 20 Jul 2026 00:07:13 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Spring Boot 프로젝트에서 Postman 테스트는 정상적으로 성공했는데, Docker MySQL에 접속해서 확인하니 테이블이 하나도 보이지 않았던 문제를 정리한 글입니다.</p>
</blockquote>
<hr>
<h2 id="1-문제-상황-🚨">1. 문제 상황 🚨</h2>
<p>커피 주문 프로젝트에서 메뉴 조회, 포인트 충전, 주문 API를 구현한 뒤 Postman으로 테스트를 진행했다.</p>
<p>Postman 결과는 모두 성공이었다.</p>
<ul>
<li>포인트 충전 성공</li>
<li>주문 성공</li>
<li>포인트 차감 성공</li>
<li>주문 응답 정상 반환</li>
</ul>
<p>그래서 당연히 MySQL에도 데이터가 들어갔을 거라고 생각했다.</p>
<p>그런데 Docker MySQL 컨테이너에 접속해서 확인해보니 이상한 결과가 나왔다.</p>
<pre><code class="language-bash">docker exec -it coffee-order-mysql mysql -uroot -p&#39;비밀번호&#39;</code></pre>
<p>MySQL 접속 후 DB 선택:</p>
<pre><code class="language-sql">USE coffee_order;

SHOW TABLES;</code></pre>
<p>결과:</p>
<pre><code class="language-text">Empty set</code></pre>
<p>분명 Postman에서는 주문이 성공했는데, DB에는 테이블조차 없었다.</p>
<p>심지어 주문 테이블을 조회하면 이런 에러가 발생했다.</p>
<pre><code class="language-sql">SELECT * FROM coffee_orders;</code></pre>
<pre><code class="language-text">ERROR 1146 (42S02): Table &#39;coffee_order.coffee_orders&#39; doesn&#39;t exist</code></pre>
<p>처음에는 정말 이해가 되지 않았다.</p>
<hr>
<h2 id="2-처음-의심한-것들-🤔">2. 처음 의심한 것들 🤔</h2>
<p>처음에는 아래 문제들을 의심했다.</p>
<h3 id="1-jpa-ddl-auto-문제인가">1. JPA ddl-auto 문제인가?</h3>
<p><code>application.yml</code>에는 아래처럼 설정되어 있었다.</p>
<pre><code class="language-yaml">spring:
  jpa:
    hibernate:
      ddl-auto: update</code></pre>
<p><code>ddl-auto=update</code>라면 애플리케이션 실행 시 Entity를 기준으로 테이블이 생성되어야 한다.</p>
<p>그런데 Docker MySQL에는 테이블이 없었다.</p>
<hr>
<h3 id="2-서버가-h2-테스트-db를-보고-있나">2. 서버가 H2 테스트 DB를 보고 있나?</h3>
<p>테스트 환경에는 H2를 사용하고 있었기 때문에 혹시 서버가 <code>test</code> profile로 실행 중인지 의심했다.</p>
<p>서버 로그를 확인했다.</p>
<pre><code class="language-text">No active profile set, falling back to 1 default profile: &quot;default&quot;</code></pre>
<p><code>test</code> profile은 아니었다.</p>
<p>즉, H2를 보고 있는 것도 아니었다.</p>
<hr>
<h3 id="3-applicationyml이-잘못되었나">3. application.yml이 잘못되었나?</h3>
<p><code>application.yml</code>의 datasource 설정도 확인했다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/coffee_order
    username: root
    password: &quot;비밀번호&quot;</code></pre>
<p>겉으로 보기에는 MySQL을 잘 보고 있는 것처럼 보였다.</p>
<hr>
<h2 id="3-결정적인-단서-🔍">3. 결정적인 단서 🔍</h2>
<p>서버 실행 로그를 자세히 보던 중 이상한 점을 발견했다.</p>
<p>Spring Boot 로그에는 DB 버전이 이렇게 찍혀 있었다.</p>
<pre><code class="language-text">Database JDBC URL [jdbc:mysql://127.0.0.1:3306/coffee_order?...]
Database version: 9.3</code></pre>
<p>그런데 내가 Docker 컨테이너에 접속했을 때의 MySQL 버전은 이랬다.</p>
<pre><code class="language-text">Server version: 8.0.46 MySQL Community Server</code></pre>
<p>즉, 두 MySQL의 버전이 달랐다.</p>
<p>여기서 문제의 원인을 알 수 있었다.</p>
<hr>
<h2 id="4-원인-💥">4. 원인 💥</h2>
<p>결론은 이거였다.</p>
<pre><code class="language-text">Spring Boot가 보고 있는 MySQL과
내가 docker exec로 확인한 MySQL이 서로 달랐다.</code></pre>
<p>내 프로젝트의 <code>application.yml</code>은 아래 주소를 보고 있었다.</p>
<pre><code class="language-text">127.0.0.1:3306</code></pre>
<p>그런데 내 로컬 환경에서는 이미 Homebrew MySQL이 3306 포트를 사용하고 있었다.</p>
<p>즉, 실제 구조는 이런 상태였다.</p>
<pre><code class="language-text">Spring Boot
→ 127.0.0.1:3306
→ Homebrew MySQL 9.3.0

docker exec
→ coffee-order-mysql 컨테이너 내부
→ Docker MySQL 8.0.46</code></pre>
<p>나는 Docker MySQL을 확인하고 있었지만, Spring Boot는 Homebrew MySQL에 데이터를 저장하고 있었다.</p>
<hr>
<h2 id="5-확인-방법-✅">5. 확인 방법 ✅</h2>
<p>Spring Boot가 실제로 바라보는 주소와 동일한 방식으로 MySQL에 접속했다.</p>
<pre><code class="language-bash">mysql -h127.0.0.1 -P3306 -uroot -p&#39;비밀번호&#39;</code></pre>
<p>접속 후 버전을 확인했다.</p>
<pre><code class="language-sql">SELECT VERSION();</code></pre>
<p>결과:</p>
<pre><code class="language-text">9.3.0</code></pre>
<p>Spring Boot 로그에 찍힌 DB 버전과 같았다.</p>
<p>이제 진짜 애플리케이션이 보고 있는 DB에 들어온 것이다.</p>
<p>DB를 선택하고 테이블을 확인했다.</p>
<pre><code class="language-sql">USE coffee_order;

SHOW TABLES;</code></pre>
<p>결과:</p>
<pre><code class="language-text">coffee_orders
menus
point_histories
point_wallets</code></pre>
<p>테이블이 정상적으로 보였다.</p>
<p>주문 데이터도 확인했다.</p>
<pre><code class="language-sql">SELECT * FROM coffee_orders;</code></pre>
<p>결과:</p>
<pre><code class="language-text">id | menu_name  | payment_amount | status | user_id
1  | 아메리카노 | 4500           | PAID   | 1
2  | 아메리카노 | 4500           | PAID   | 1
...</code></pre>
<p>포인트 이력도 정상적으로 들어가 있었다.</p>
<pre><code class="language-sql">SELECT * FROM point_histories;</code></pre>
<p>충전 이력과 사용 이력이 모두 정상적으로 저장되어 있었다.</p>
<hr>
<h2 id="6-해결-방법-🛠️">6. 해결 방법 🛠️</h2>
<p>이번 문제는 코드 문제가 아니라, <strong>내가 확인한 DB와 애플리케이션이 사용하는 DB가 달랐던 문제</strong>였다.</p>
<p>그래서 해결 방법은 두 가지 중 하나다.</p>
<hr>
<h3 id="방법-1-현재처럼-homebrew-mysql을-계속-사용한다">방법 1. 현재처럼 Homebrew MySQL을 계속 사용한다</h3>
<p>이 경우 DB 확인 시 <code>docker exec</code>가 아니라 아래 명령어를 사용해야 한다.</p>
<pre><code class="language-bash">mysql -h127.0.0.1 -P3306 -uroot -p</code></pre>
<p>그리고 DB 확인 순서는 다음과 같다.</p>
<pre><code class="language-sql">USE coffee_order;

SHOW TABLES;

SELECT * FROM coffee_orders;

SELECT * FROM point_wallets;

SELECT * FROM point_histories;</code></pre>
<hr>
<h3 id="방법-2-docker-mysql을-사용하도록-포트를-정리한다">방법 2. Docker MySQL을 사용하도록 포트를 정리한다</h3>
<p>Docker MySQL을 사용하고 싶다면 3306 포트를 점유 중인 Homebrew MySQL을 멈추거나, Docker MySQL 포트를 다르게 매핑해야 한다.</p>
<p>예를 들면 Docker MySQL을 3307로 열 수 있다.</p>
<pre><code class="language-yaml">ports:
  - &quot;3307:3306&quot;</code></pre>
<p>그리고 Spring Boot 설정도 맞춰야 한다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3307/coffee_order</code></pre>
<p>중요한 것은 애플리케이션이 접속하는 주소와 내가 확인하는 DB 주소가 같아야 한다는 점이다.</p>
<hr>
<h2 id="7-이번-문제에서-배운-점-📌">7. 이번 문제에서 배운 점 📌</h2>
<p>이번 문제를 통해 가장 크게 배운 점은 이것이다.</p>
<pre><code class="language-text">Postman 성공 = 내가 확인한 DB에 저장됨
이라고 단정하면 안 된다.</code></pre>
<p>반드시 아래를 함께 확인해야 한다.</p>
<ul>
<li>Spring Boot 로그의 JDBC URL</li>
<li>Spring Boot 로그의 Database version</li>
<li>실제 MySQL 접속 방식</li>
<li><code>docker exec</code>로 들어간 DB와 <code>127.0.0.1:3306</code>으로 접속한 DB가 같은지</li>
<li>현재 3306 포트를 누가 사용하고 있는지</li>
</ul>
<p>포트 확인은 아래 명령어로 할 수 있다.</p>
<pre><code class="language-bash">lsof -nP -iTCP:3306 -sTCP:LISTEN</code></pre>
<hr>
<h2 id="8-정리-🧹">8. 정리 🧹</h2>
<p>이번 문제의 핵심은 단순했다.</p>
<pre><code class="language-text">Docker MySQL에 데이터가 없는 것이 아니라,
Spring Boot가 Docker MySQL을 보고 있지 않았다.</code></pre>
<p>정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>문제</td>
<td>Postman은 성공했지만 Docker MySQL에는 테이블이 없었음</td>
</tr>
<tr>
<td>원인</td>
<td>Spring Boot는 Homebrew MySQL을 보고 있었고, 나는 Docker MySQL을 확인하고 있었음</td>
</tr>
<tr>
<td>단서</td>
<td>Spring Boot 로그의 DB version은 9.3, Docker MySQL은 8.0.46</td>
</tr>
<tr>
<td>해결</td>
<td>Spring Boot와 같은 방식으로 MySQL에 접속해서 확인</td>
</tr>
<tr>
<td>배운 점</td>
<td>애플리케이션이 보는 DB와 내가 확인하는 DB가 같은지 반드시 확인해야 함</td>
</tr>
</tbody></table>
<p>이번 경험 덕분에 앞으로 DB 문제를 볼 때는 단순히 <code>SHOW TABLES</code>만 확인하는 것이 아니라,<br><strong>애플리케이션이 실제로 어디에 연결되어 있는지부터 확인하는 습관</strong>을 가지게 되었다.</p>
<hr>
<br/>

<p><img src="https://velog.velcdn.com/images/loaded_diaper/post/e0b9fb4d-82ac-403a-87f4-cae210532139/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CH5 플러스 스프링 모의면접 질문범위]]></title>
            <link>https://velog.io/@loaded_diaper/CH5-%ED%94%8C%EB%9F%AC%EC%8A%A4-%EC%8A%A4%ED%94%84%EB%A7%81-%EB%AA%A8%EC%9D%98%EB%A9%B4%EC%A0%91-%EC%A7%88%EB%AC%B8%EB%B2%94%EC%9C%84</link>
            <guid>https://velog.io/@loaded_diaper/CH5-%ED%94%8C%EB%9F%AC%EC%8A%A4-%EC%8A%A4%ED%94%84%EB%A7%81-%EB%AA%A8%EC%9D%98%EB%A9%B4%EC%A0%91-%EC%A7%88%EB%AC%B8%EB%B2%94%EC%9C%84</guid>
            <pubDate>Mon, 29 Jun 2026 03:14:41 GMT</pubDate>
            <description><![CDATA[<h1 id="ch5-플러스-스프링-모의면접-예상질문-정리">CH5 플러스 스프링 모의면접 예상질문 정리</h1>
<blockquote>
<p>QueryDSL, Cache, Redis, 인덱스, 쿼리 튜닝, 동시성, AOP, 테스트 영역에서 나올 수 있는 면접 질문과 핵심 답변을 정리했습니다.</p>
</blockquote>
<hr>
<h1 id="querydsl-영역">QueryDSL 영역</h1>
<h2 id="q1-querydsl의-booleanexpression을-활용한-동적-쿼리-작성-방식을-설명해주세요">Q1. QueryDSL의 BooleanExpression을 활용한 동적 쿼리 작성 방식을 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p>QueryDSL에서는 검색 조건을 <code>BooleanExpression</code> 메서드로 분리해서 동적 쿼리를 만들 수 있습니다.</p>
<p>예를 들어 검색어, 카테고리, 상태값이 있을 때 값이 존재하면 조건을 반환하고, 값이 없으면 <code>null</code>을 반환합니다.</p>
<pre><code class="language-java">private BooleanExpression keywordContains(String keyword) {
    if (keyword == null || keyword.isBlank()) {
        return null;
    }

    return product.name.contains(keyword);
}</code></pre>
<p>그리고 <code>where()</code>에 여러 조건을 넣으면 필요한 조건만 적용됩니다.</p>
<pre><code class="language-java">.where(
    keywordContains(keyword),
    categoryEq(category),
    statusEq(status)
)</code></pre>
<p>이 방식은 조건이 많아져도 코드가 깔끔하고, 각 조건을 메서드로 분리할 수 있어 유지보수에 좋습니다.</p>
<h3 id="꼬리질문-1-booleanexpression을-null로-반환하는-방식이-왜-null-safe하다고-하나요">꼬리질문 1. BooleanExpression을 null로 반환하는 방식이 왜 null-safe하다고 하나요?</h3>
<p>QueryDSL의 <code>where()</code>는 여러 조건 중 <code>null</code>인 조건을 무시합니다.</p>
<p>그래서 검색 조건이 없을 때 억지로 <code>1=1</code> 같은 조건을 만들 필요가 없습니다.</p>
<p>즉, 조건이 있으면 적용되고 없으면 제외되기 때문에 동적 쿼리를 안전하고 간결하게 작성할 수 있습니다.</p>
<h3 id="꼬리질문-2-querydsl-없이-jpa-specification으로도-동적-쿼리를-만들-수-있는데-querydsl을-선택한-이유가-있나요">꼬리질문 2. QueryDSL 없이 JPA Specification으로도 동적 쿼리를 만들 수 있는데, QueryDSL을 선택한 이유가 있나요?</h3>
<p>JPA Specification도 동적 쿼리를 만들 수 있지만, QueryDSL은 타입 안정성이 좋고 코드 가독성이 더 좋다고 느꼈습니다.</p>
<p>특히 복잡한 검색 조건, 정렬, 페이징, 조인 쿼리를 작성할 때 QueryDSL은 Java 코드처럼 읽히기 때문에 유지보수에 유리합니다.</p>
<p>또한 QClass를 기반으로 필드명을 컴파일 시점에 확인할 수 있어 문자열 기반 쿼리보다 실수를 줄일 수 있습니다.</p>
<hr>
<h2 id="q2-querydsl로-페이징을-구현할-때-count-쿼리를-분리해서-최적화하는-이유가-무엇인가요">Q2. QueryDSL로 페이징을 구현할 때 count 쿼리를 분리해서 최적화하는 이유가 무엇인가요?</h2>
<p><strong>A.</strong></p>
<p>페이징을 할 때는 실제 데이터를 가져오는 content 쿼리와 전체 개수를 구하는 count 쿼리가 필요합니다.</p>
<p>하지만 content 쿼리에 필요한 join이 count 쿼리에는 필요하지 않은 경우가 많습니다.</p>
<p>그래서 count 쿼리를 따로 작성하면 불필요한 join을 줄여 성능을 개선할 수 있습니다.</p>
<pre><code class="language-text">content query: 실제 목록 조회
count query: 전체 개수 조회</code></pre>
<p>데이터가 많아질수록 count 쿼리 비용도 커지기 때문에, 별도로 최적화하는 것이 중요합니다.</p>
<h3 id="꼬리질문-1-count-쿼리에서-불필요한-join을-제거하면-왜-성능이-개선되나요">꼬리질문 1. count 쿼리에서 불필요한 join을 제거하면 왜 성능이 개선되나요?</h3>
<p>join이 많으면 DB가 여러 테이블을 결합하고 조건을 비교해야 합니다.</p>
<p>하지만 count 쿼리는 보통 “조건에 맞는 데이터 개수”만 필요하기 때문에, 응답 DTO를 만들기 위한 join이나 fetch join은 필요 없는 경우가 많습니다.</p>
<p>예를 들어 상품 목록 조회에서 판매자 이름을 보여주기 위해 seller를 join했다면, 전체 상품 개수를 세는 count 쿼리에서는 seller join이 필요 없을 수 있습니다.</p>
<p>이런 join을 제거하면 DB가 읽어야 하는 데이터와 연산량이 줄어 성능이 개선됩니다.</p>
<h3 id="꼬리질문-2-offset-페이징과-no-offset-페이징의-성능-차이가-데이터가-많아질수록-커지는-이유를-설명해주세요">꼬리질문 2. Offset 페이징과 No-Offset 페이징의 성능 차이가 데이터가 많아질수록 커지는 이유를 설명해주세요.</h3>
<p>Offset 페이징은 <code>offset</code>만큼 데이터를 건너뛴 뒤 필요한 데이터를 가져옵니다.</p>
<p>예를 들어 100만 번째 데이터부터 20개를 가져오려면 DB는 앞의 100만 개를 건너뛰는 작업을 해야 합니다.</p>
<p>반면 No-Offset 페이징은 마지막으로 조회한 ID나 기준값 이후의 데이터를 조회합니다.</p>
<pre><code class="language-text">Offset 방식:
LIMIT 20 OFFSET 1000000

No-Offset 방식:
WHERE id &lt; 마지막으로 조회한 id
LIMIT 20</code></pre>
<p>데이터가 많아질수록 Offset 방식은 건너뛰는 비용이 커지고, No-Offset 방식은 인덱스를 활용해 다음 데이터를 바로 찾을 수 있어 더 효율적입니다.</p>
<hr>
<h1 id="cache-영역">Cache 영역</h1>
<h2 id="q3-cacheable을-적용할-때-캐시-키를-어떻게-설계했나요">Q3. @Cacheable을 적용할 때 캐시 키를 어떻게 설계했나요?</h2>
<p><strong>A.</strong></p>
<p>캐시 키는 같은 요청이면 같은 캐시를 사용하고, 다른 요청이면 다른 캐시를 사용하도록 설계해야 합니다.</p>
<p>예를 들어 상품 검색 결과를 캐싱한다면 검색어, 카테고리, 정렬 조건, 페이지 번호, 페이지 크기 등을 키에 포함해야 합니다.</p>
<pre><code class="language-java">@Cacheable(
    value = &quot;productSearch&quot;,
    key = &quot;#keyword + &#39;:&#39; + #category + &#39;:&#39; + #page + &#39;:&#39; + #size&quot;
)</code></pre>
<p>캐시 키를 너무 단순하게 만들면 서로 다른 검색 조건이 같은 캐시를 공유하는 문제가 생길 수 있습니다.</p>
<p>반대로 너무 복잡하게 만들면 캐시 재사용률이 떨어질 수 있습니다.</p>
<h3 id="꼬리질문-1-캐시가-적용된-메서드를-같은-클래스-내부에서-호출했을-때-캐시가-동작하지-않는-경우가-있습니다-원인이-무엇인가요">꼬리질문 1. 캐시가 적용된 메서드를 같은 클래스 내부에서 호출했을 때 캐시가 동작하지 않는 경우가 있습니다. 원인이 무엇인가요?</h3>
<p>Spring의 <code>@Cacheable</code>은 프록시 기반 AOP로 동작합니다.</p>
<p>외부에서 프록시 객체를 통해 메서드를 호출해야 캐시 로직이 적용됩니다.</p>
<p>하지만 같은 클래스 내부에서 <code>this.method()</code>처럼 호출하면 프록시를 거치지 않기 때문에 <code>@Cacheable</code>이 동작하지 않을 수 있습니다.</p>
<p>이것을 Self-invocation 문제라고 합니다.</p>
<h3 id="꼬리질문-2-cacheput과-cacheevict를-동시에-쓰는-방식과-db-갱신-후-캐시를-무효화하는-방식-중-어떤-기준으로-선택하나요">꼬리질문 2. @CachePut과 @CacheEvict를 동시에 쓰는 방식과, DB 갱신 후 캐시를 무효화하는 방식 중 어떤 기준으로 선택하나요?</h3>
<p><code>@CachePut</code>은 DB를 갱신한 뒤 캐시도 즉시 최신 값으로 갱신할 때 사용합니다.</p>
<p><code>@CacheEvict</code>는 DB 갱신 후 기존 캐시를 삭제하고, 다음 조회 시 다시 캐싱하게 할 때 사용합니다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>사용 상황</th>
</tr>
</thead>
<tbody><tr>
<td><code>@CachePut</code></td>
<td>갱신 결과가 명확하고 캐시에 바로 반영하고 싶을 때</td>
</tr>
<tr>
<td><code>@CacheEvict</code></td>
<td>여러 캐시 키에 영향이 있거나 갱신 범위가 넓을 때</td>
</tr>
</tbody></table>
<p>검색 결과 캐시처럼 하나의 데이터 변경이 여러 페이지, 여러 검색 조건에 영향을 주는 경우에는 캐시를 직접 갱신하기보다 무효화하는 방식이 더 안전합니다.</p>
<hr>
<h2 id="q4-redis-ttl-설정-기준을-어떻게-결정했나요">Q4. Redis TTL 설정 기준을 어떻게 결정했나요?</h2>
<p><strong>A.</strong></p>
<p>Redis TTL은 데이터의 변경 빈도와 실시간성이 얼마나 중요한지 기준으로 결정합니다.</p>
<p>자주 바뀌지 않고 조회가 많은 데이터는 TTL을 길게 가져가고, 자주 바뀌는 데이터는 짧게 가져갑니다.</p>
<p>예를 들어 다음과 같이 설정할 수 있습니다.</p>
<table>
<thead>
<tr>
<th>데이터</th>
<th>TTL 기준</th>
</tr>
</thead>
<tbody><tr>
<td>상품 상세</td>
<td>비교적 길게</td>
</tr>
<tr>
<td>인기 검색어</td>
<td>짧게</td>
</tr>
<tr>
<td>재고 정보</td>
<td>매우 짧게 또는 캐시 제외</td>
</tr>
<tr>
<td>이벤트 상품 목록</td>
<td>이벤트 기간과 변경 빈도에 맞게</td>
</tr>
</tbody></table>
<p>특히 재고처럼 정확성이 중요한 데이터는 캐시보다 DB나 Redis 원자 연산을 기준으로 처리하는 것이 안전합니다.</p>
<h3 id="꼬리질문-1-ttl이-동시에-만료된-다수의-키에-대량-요청이-몰리면-어떤-문제가-생기나요">꼬리질문 1. TTL이 동시에 만료된 다수의 키에 대량 요청이 몰리면 어떤 문제가 생기나요?</h3>
<p>여러 캐시가 동시에 만료되고 요청이 몰리면 많은 요청이 한꺼번에 DB로 향할 수 있습니다.</p>
<p>이것을 Cache Stampede 또는 Cache Avalanche라고 합니다.</p>
<p>완화 방법은 다음과 같습니다.</p>
<ul>
<li>TTL에 랜덤 값을 추가해 만료 시점을 분산</li>
<li>캐시 재생성 시 락을 사용해 한 요청만 DB 조회</li>
<li>인기 데이터는 만료 전에 미리 갱신</li>
<li>너무 짧은 TTL을 피함</li>
</ul>
<h3 id="꼬리질문-2-redis-캐시와-db-데이터가-불일치하는-상황이-실제로-생길-수-있는-시나리오를-하나-들어보세요">꼬리질문 2. Redis 캐시와 DB 데이터가 불일치하는 상황이 실제로 생길 수 있는 시나리오를 하나 들어보세요.</h3>
<p>예를 들어 상품 가격을 DB에서 수정했는데 기존 상품 상세 캐시를 삭제하지 않으면 문제가 생길 수 있습니다.</p>
<p>DB에는 10,000원으로 수정되었지만 Redis에는 이전 가격인 12,000원이 남아 있을 수 있습니다.</p>
<p>이 상태에서 사용자가 상품을 조회하면 오래된 가격을 보게 됩니다.</p>
<p>이런 문제를 줄이려면 DB 수정 후 관련 캐시를 무효화하거나 TTL을 짧게 설정해야 합니다.</p>
<hr>
<h1 id="인덱스--쿼리-튜닝-영역">인덱스 / 쿼리 튜닝 영역</h1>
<h2 id="q5-복합-인덱스를-설계할-때-컬럼-순서를-어떤-기준으로-결정했나요">Q5. 복합 인덱스를 설계할 때 컬럼 순서를 어떤 기준으로 결정했나요?</h2>
<p><strong>A.</strong></p>
<p>복합 인덱스는 자주 사용되는 조회 조건과 정렬 조건을 기준으로 설계합니다.</p>
<p>일반적으로 다음 기준을 고려합니다.</p>
<ol>
<li>자주 사용되는 조건인지</li>
<li>동등 조건인지 범위 조건인지</li>
<li>카디널리티가 높은 컬럼인지</li>
<li>정렬에 사용되는 컬럼인지</li>
<li>실제 쿼리의 where, order by 순서와 맞는지</li>
</ol>
<p>예를 들어 프로모션 상품을 상태와 시작 시간 기준으로 조회한다면 다음과 같은 인덱스를 고려할 수 있습니다.</p>
<pre><code class="language-sql">(status, start_at, id)</code></pre>
<p>단, 무조건 컬럼을 많이 넣는 것이 좋은 것은 아니고, 실제 실행 계획을 보고 결정해야 합니다.</p>
<h3 id="꼬리질문-1-복합-인덱스에서-선행-컬럼을-조건에-포함하지-않고-후행-컬럼만-조회하면-어떻게-동작하나요">꼬리질문 1. 복합 인덱스에서 선행 컬럼을 조건에 포함하지 않고 후행 컬럼만 조회하면 어떻게 동작하나요?</h3>
<p>복합 인덱스는 왼쪽부터 순서대로 사용됩니다.</p>
<p>예를 들어 <code>(status, category_id, created_at)</code> 인덱스가 있을 때 <code>category_id</code>만 조건에 사용하면 인덱스를 제대로 활용하기 어렵습니다.</p>
<pre><code class="language-sql">-- 인덱스 활용에 유리
WHERE status = &#39;OPEN&#39; AND category_id = 1

-- 선행 컬럼이 빠져 인덱스 활용이 어려울 수 있음
WHERE category_id = 1</code></pre>
<p>그래서 복합 인덱스를 만들 때는 실제 쿼리에서 자주 함께 사용되는 조건 순서를 고려해야 합니다.</p>
<h3 id="꼬리질문-2-카디널리티가-낮은-컬럼에-단독-인덱스를-생성하는-것이-비효율적인-이유를-설명해주세요">꼬리질문 2. 카디널리티가 낮은 컬럼에 단독 인덱스를 생성하는 것이 비효율적인 이유를 설명해주세요.</h3>
<p>카디널리티가 낮다는 것은 값의 종류가 적다는 뜻입니다.</p>
<p>예를 들어 성별, 판매 상태처럼 값의 종류가 적은 컬럼은 인덱스를 타도 많은 행을 읽어야 할 수 있습니다.</p>
<p>이 경우 DB 입장에서는 인덱스를 사용하는 것보다 테이블 전체를 읽는 것이 더 빠를 수도 있습니다.</p>
<p>그래서 카디널리티가 낮은 컬럼은 단독 인덱스보다 다른 조건과 함께 복합 인덱스로 설계하는 경우가 많습니다.</p>
<hr>
<h2 id="q6-explain-실행-계획에서-type-컬럼이-all로-표시될-때-어떤-의미이고-어떻게-접근해서-개선했나요">Q6. EXPLAIN 실행 계획에서 type 컬럼이 ALL로 표시될 때 어떤 의미이고, 어떻게 접근해서 개선했나요?</h2>
<p><strong>A.</strong></p>
<p><code>type</code>이 <code>ALL</code>로 표시된다는 것은 Full Table Scan을 의미합니다.</p>
<p>즉, 조건에 맞는 데이터를 찾기 위해 테이블 전체를 읽는다는 뜻입니다.</p>
<p>개선할 때는 다음 순서로 접근합니다.</p>
<ol>
<li>실제 쿼리의 <code>WHERE</code>, <code>ORDER BY</code>, <code>JOIN</code> 조건 확인</li>
<li>사용 가능한 인덱스가 있는지 확인</li>
<li>인덱스 컬럼 순서가 쿼리 조건과 맞는지 확인</li>
<li>불필요한 join이나 select 컬럼 제거</li>
<li>데이터 분포와 카디널리티 확인</li>
<li>다시 <code>EXPLAIN</code>으로 실행 계획 확인</li>
</ol>
<p>단순히 인덱스를 추가하는 것이 아니라, 실제 쿼리 패턴과 실행 계획을 함께 봐야 합니다.</p>
<h3 id="꼬리질문-1-인덱스가-존재하는데도-옵티마이저가-full-scan을-선택하는-경우가-있습니다-어떤-상황인가요">꼬리질문 1. 인덱스가 존재하는데도 옵티마이저가 Full Scan을 선택하는 경우가 있습니다. 어떤 상황인가요?</h3>
<p>인덱스가 있어도 옵티마이저가 Full Scan이 더 빠르다고 판단하면 인덱스를 사용하지 않을 수 있습니다.</p>
<p>대표적인 경우는 다음과 같습니다.</p>
<ul>
<li>조건에 맞는 데이터가 너무 많을 때</li>
<li>카디널리티가 낮은 컬럼일 때</li>
<li>인덱스 컬럼에 함수나 연산을 적용했을 때</li>
<li>LIKE 검색이 <code>%keyword</code>처럼 앞에 와일드카드로 시작할 때</li>
<li>통계 정보가 오래되었을 때</li>
<li>조회 컬럼이 많아 결국 테이블 접근이 많이 필요한 경우</li>
</ul>
<h3 id="꼬리질문-2-인덱스를-추가했는데-오히려-쓰기-성능이-나빠질-수-있습니다-어떤-트레이드오프가-있나요">꼬리질문 2. 인덱스를 추가했는데 오히려 쓰기 성능이 나빠질 수 있습니다. 어떤 트레이드오프가 있나요?</h3>
<p>인덱스는 조회 성능을 높일 수 있지만, 쓰기 작업에는 비용이 추가됩니다.</p>
<p>데이터를 INSERT, UPDATE, DELETE할 때 테이블뿐만 아니라 인덱스도 함께 갱신해야 하기 때문입니다.</p>
<p>따라서 인덱스가 많아질수록 저장 공간도 늘어나고 쓰기 성능은 떨어질 수 있습니다.</p>
<p>그래서 인덱스는 “조회에 자주 사용되는 쿼리”를 기준으로 필요한 만큼만 추가해야 합니다.</p>
<hr>
<h1 id="동시성-영역">동시성 영역</h1>
<h2 id="q7-재고-차감처럼-동시성-이슈가-생기는-도메인-로직에-낙관적-락과-비관적-락-중-무엇을-선택했고-그-이유가-무엇인가요">Q7. 재고 차감처럼 동시성 이슈가 생기는 도메인 로직에 낙관적 락과 비관적 락 중 무엇을 선택했고, 그 이유가 무엇인가요?</h2>
<p><strong>A.</strong></p>
<p>재고 차감처럼 초과 판매가 발생하면 안 되는 로직에서는 비관적 락을 선택할 수 있습니다.</p>
<p>비관적 락은 데이터를 수정하기 전에 먼저 락을 걸어서 다른 트랜잭션이 동시에 수정하지 못하게 막습니다.</p>
<p>재고가 적고 요청이 몰리는 선착순 이벤트 상황에서는 충돌 가능성이 높기 때문에, 낙관적 락보다 비관적 락이 더 단순하고 안전할 수 있습니다.</p>
<p>다만 비관적 락은 락 대기 시간이 길어지면 성능 저하가 발생할 수 있으므로 트랜잭션 범위를 짧게 가져가야 합니다.</p>
<h3 id="꼬리질문-1-낙관적-락에서-충돌이-발생했을-때-재시도-로직은-어떻게-구현했나요">꼬리질문 1. 낙관적 락에서 충돌이 발생했을 때 재시도 로직은 어떻게 구현했나요?</h3>
<p>낙관적 락은 <code>version</code> 컬럼을 기준으로 충돌을 감지합니다.</p>
<p>동시에 여러 요청이 같은 재고를 수정하면 먼저 커밋한 요청만 성공하고, 나중 요청은 버전 불일치로 예외가 발생합니다.</p>
<p>이때 재시도 로직을 둘 수 있습니다.</p>
<pre><code class="language-text">1. 재고 조회
2. 재고 차감 시도
3. OptimisticLockException 발생
4. 짧은 대기 후 재시도
5. 최대 재시도 횟수 초과 시 실패 처리</code></pre>
<p>재시도 횟수는 무한정으로 두면 안 되고, 예를 들어 3회처럼 제한을 두는 것이 안전합니다.</p>
<h3 id="꼬리질문-2-비관적-락-사용-시-데드락이-발생할-수-있는-시나리오를-설명해주세요">꼬리질문 2. 비관적 락 사용 시 데드락이 발생할 수 있는 시나리오를 설명해주세요.</h3>
<p>데드락은 두 트랜잭션이 서로가 가진 락을 기다리면서 멈추는 상황입니다.</p>
<p>예를 들어 주문 A는 상품 1번을 먼저 잠그고 상품 2번을 기다리고, 주문 B는 상품 2번을 먼저 잠그고 상품 1번을 기다리면 데드락이 발생할 수 있습니다.</p>
<p>이를 줄이려면 여러 row에 락을 걸 때 항상 같은 순서로 접근해야 합니다.</p>
<p>예를 들어 상품 ID 오름차순으로 정렬한 뒤 락을 획득하는 방식이 있습니다.</p>
<hr>
<h2 id="q8-redis-기반-분산-락을-구현할-때-락을-보유한-서버가-갑자기-죽으면-어떻게-되나요">Q8. Redis 기반 분산 락을 구현할 때, 락을 보유한 서버가 갑자기 죽으면 어떻게 되나요?</h2>
<p><strong>A.</strong></p>
<p>Redis 분산 락은 보통 TTL을 함께 설정합니다.</p>
<p>락을 획득한 서버가 갑자기 죽더라도 TTL이 지나면 락이 자동으로 만료되어 다른 서버가 다시 락을 획득할 수 있습니다.</p>
<p>만약 TTL 없이 락을 잡으면 서버 장애 시 락이 영원히 풀리지 않는 문제가 생길 수 있습니다.</p>
<p>그래서 분산 락에서는 다음이 중요합니다.</p>
<ul>
<li>락에 TTL 설정</li>
<li>락 소유자 식별값 저장</li>
<li>락 해제 시 내가 잡은 락인지 확인</li>
<li>비즈니스 로직보다 TTL이 너무 짧지 않게 설정</li>
</ul>
<h3 id="꼬리질문-1-lettuce의-스핀-락-방식과-redisson의-pubsub-방식-중-어떤-상황에서-redisson이-더-적합한가요">꼬리질문 1. Lettuce의 스핀 락 방식과 Redisson의 pub/sub 방식 중 어떤 상황에서 Redisson이 더 적합한가요?</h3>
<p>Lettuce로 직접 분산 락을 구현하면 보통 락을 얻을 때까지 Redis에 반복 요청하는 스핀 락 방식이 됩니다.</p>
<p>요청이 적으면 단순해서 사용할 수 있지만, 요청이 많으면 Redis에 부담이 커질 수 있습니다.</p>
<p>Redisson은 pub/sub 방식으로 락 해제 이벤트를 기다릴 수 있어 불필요한 반복 요청을 줄일 수 있습니다.</p>
<p>따라서 트래픽이 많고 락 경합이 자주 발생하는 상황에서는 Redisson이 더 적합할 수 있습니다.</p>
<h3 id="꼬리질문-2-분산-락을-aop로-분리한다면-어떤-장점이-있고-주의해야-할-점은-무엇인가요">꼬리질문 2. 분산 락을 AOP로 분리한다면 어떤 장점이 있고, 주의해야 할 점은 무엇인가요?</h3>
<p>분산 락을 AOP로 분리하면 비즈니스 로직에서 락 획득과 해제 코드를 제거할 수 있습니다.</p>
<p>장점은 다음과 같습니다.</p>
<ul>
<li>중복 코드 감소</li>
<li>락 처리 로직 일관성 유지</li>
<li>비즈니스 로직 가독성 향상</li>
<li>여러 메서드에 공통 적용 가능</li>
</ul>
<p>주의할 점은 다음과 같습니다.</p>
<ul>
<li>락 획득 후 트랜잭션이 시작되는 순서를 고려해야 함</li>
<li>락 해제가 반드시 <code>finally</code>에서 수행되어야 함</li>
<li>락 key를 잘못 설계하면 불필요하게 넓은 범위가 잠김</li>
<li>Self-invocation으로 AOP가 적용되지 않을 수 있음</li>
</ul>
<hr>
<h1 id="aop--테스트-영역">AOP / 테스트 영역</h1>
<h2 id="q9-aop를-통해-횡단-관심사를-분리했을-때-transactional과-커스텀-aop-어드바이스가-같은-메서드에-적용되면-실행-순서가-어떻게-되나요">Q9. AOP를 통해 횡단 관심사를 분리했을 때, @Transactional과 커스텀 AOP 어드바이스가 같은 메서드에 적용되면 실행 순서가 어떻게 되나요?</h2>
<p><strong>A.</strong></p>
<p><code>@Transactional</code>도 Spring AOP 기반으로 동작하고, 커스텀 AOP도 프록시 기반으로 동작합니다.</p>
<p>둘 다 같은 메서드에 적용되면 <code>@Order</code> 설정이나 우선순위에 따라 실행 순서가 달라질 수 있습니다.</p>
<p>예를 들어 분산 락 AOP와 트랜잭션이 함께 있다면 보통 다음 순서를 의도합니다.</p>
<pre><code class="language-text">락 획득
→ 트랜잭션 시작
→ 비즈니스 로직 실행
→ 트랜잭션 커밋
→ 락 해제</code></pre>
<p>이렇게 해야 트랜잭션이 끝나기 전에 락이 먼저 풀려서 다른 요청이 들어오는 문제를 줄일 수 있습니다.</p>
<h3 id="꼬리질문-1-self-invocation-문제가-발생하는-원인이-무엇인가요-transactional과-cacheable에서도-같은-문제가-생기나요">꼬리질문 1. Self-invocation 문제가 발생하는 원인이 무엇인가요? @Transactional과 @Cacheable에서도 같은 문제가 생기나요?</h3>
<p>Self-invocation은 같은 클래스 내부에서 자기 자신의 메서드를 직접 호출할 때 발생합니다.</p>
<p>Spring AOP는 프록시 객체를 거쳐야 동작하는데, 내부 호출은 프록시를 거치지 않고 <code>this.method()</code>로 호출됩니다.</p>
<p>그래서 <code>@Transactional</code>, <code>@Cacheable</code>, 커스텀 AOP 모두 같은 문제가 발생할 수 있습니다.</p>
<p>해결 방법은 다음과 같습니다.</p>
<ul>
<li>메서드를 다른 Bean으로 분리</li>
<li>외부에서 프록시를 통해 호출되도록 구조 변경</li>
<li>필요 시 AspectJ 방식 고려</li>
</ul>
<h3 id="꼬리질문-2-aop-어드바이스-내부에서-예외가-발생했을-때-트랜잭션-롤백은-어떻게-되나요">꼬리질문 2. AOP 어드바이스 내부에서 예외가 발생했을 때 트랜잭션 롤백은 어떻게 되나요?</h3>
<p>트랜잭션 안에서 예외가 발생하고 해당 예외가 밖으로 전파되면 롤백됩니다.</p>
<p>기본적으로 Spring은 RuntimeException과 Error에 대해 롤백합니다.</p>
<p>다만 AOP 어드바이스에서 예외를 잡고 삼켜버리면 트랜잭션은 정상 처리로 판단할 수 있습니다.</p>
<p>그래서 롤백이 필요한 예외는 반드시 다시 던지거나, <code>rollbackFor</code> 옵션을 명확히 설정해야 합니다.</p>
<hr>
<h2 id="q10-멀티-스레드-환경에서-동시성-이슈를-재현하는-테스트를-어떻게-작성했나요">Q10. 멀티 스레드 환경에서 동시성 이슈를 재현하는 테스트를 어떻게 작성했나요?</h2>
<p><strong>A.</strong></p>
<p>동시성 테스트는 <code>ExecutorService</code>와 <code>CountDownLatch</code>를 사용해 여러 스레드가 동시에 같은 로직을 실행하도록 작성할 수 있습니다.</p>
<p>흐름은 다음과 같습니다.</p>
<ol>
<li>테스트용 상품과 재고를 생성합니다.</li>
<li>여러 개의 스레드를 준비합니다.</li>
<li><code>CountDownLatch</code>로 동시에 시작하도록 맞춥니다.</li>
<li>각 스레드가 재고 차감 요청을 보냅니다.</li>
<li>모든 스레드가 끝날 때까지 기다립니다.</li>
<li>최종 재고와 성공한 요청 수를 검증합니다.</li>
</ol>
<pre><code class="language-java">ExecutorService executorService = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(10);

for (int i = 0; i &lt; 10; i++) {
    executorService.submit(() -&gt; {
        try {
            stockService.decreaseStock(productId);
        } finally {
            latch.countDown();
        }
    });
}

latch.await();</code></pre>
<p>이 테스트를 통해 동시에 요청이 들어와도 재고가 음수가 되지 않는지 확인할 수 있습니다.</p>
<h3 id="꼬리질문-1-countdownlatch--executorservice로-동시-요청을-시뮬레이션하는-방식의-한계가-무엇인가요">꼬리질문 1. CountDownLatch + ExecutorService로 동시 요청을 시뮬레이션하는 방식의 한계가 무엇인가요?</h3>
<p>이 방식은 동시성 문제를 재현하는 데 도움이 되지만 실제 운영 환경을 완전히 대체할 수는 없습니다.</p>
<p>한계는 다음과 같습니다.</p>
<ul>
<li>실제 네트워크 지연이 반영되지 않음</li>
<li>여러 서버 인스턴스 환경을 완전히 재현하기 어려움</li>
<li>DB 커넥션 풀, Redis, 외부 API 병목을 모두 반영하기 어려움</li>
<li>스레드 스케줄링에 따라 테스트 결과가 달라질 수 있음</li>
<li>테스트에서 통과해도 운영 트래픽에서는 문제가 생길 수 있음</li>
</ul>
<p>그래서 단위 테스트뿐만 아니라 통합 테스트, 부하 테스트도 함께 고려해야 합니다.</p>
<h3 id="꼬리질문-2-동시성-테스트가-로컬에서는-통과했는데-운영-환경에서-문제가-생기는-경우가-있다면-어떤-요인-때문일까요">꼬리질문 2. 동시성 테스트가 로컬에서는 통과했는데 운영 환경에서 문제가 생기는 경우가 있다면 어떤 요인 때문일까요?</h3>
<p>운영 환경은 로컬보다 훨씬 많은 변수가 있습니다.</p>
<p>대표적인 원인은 다음과 같습니다.</p>
<ul>
<li>서버 인스턴스가 여러 대로 늘어난 경우</li>
<li>DB 커넥션 풀이 부족한 경우</li>
<li>트랜잭션 시간이 길어진 경우</li>
<li>Redis Lock TTL이 너무 짧거나 긴 경우</li>
<li>네트워크 지연이 발생한 경우</li>
<li>실제 요청량이 테스트보다 많은 경우</li>
<li>락 획득 순서가 일정하지 않아 데드락이 발생한 경우</li>
<li>외부 API 응답 지연으로 커넥션이 오래 점유된 경우</li>
</ul>
<p>따라서 운영 환경을 고려한 동시성 제어는 단순히 로컬 테스트 통과만으로 끝내면 안 되고, 락 범위, 트랜잭션 범위, 커넥션 풀, 장애 복구까지 함께 검토해야 합니다.</p>
<hr>
<h1 id="마무리-정리">마무리 정리</h1>
<p>CH5 플러스 스프링 면접에서는 단순 CRUD보다 <strong>성능 최적화와 동시성 제어</strong>를 얼마나 이해하고 있는지 확인할 가능성이 높습니다.</p>
<p>특히 다음 키워드는 꼭 연결해서 설명할 수 있어야 합니다.</p>
<ul>
<li>QueryDSL 동적 쿼리</li>
<li>BooleanExpression과 null-safe 조건 처리</li>
<li>페이징 count 쿼리 최적화</li>
<li>Offset / No-Offset 페이징 차이</li>
<li>Redis Cache와 TTL</li>
<li>Cache Stampede</li>
<li>복합 인덱스 컬럼 순서</li>
<li>EXPLAIN 실행 계획 분석</li>
<li>낙관적 락과 비관적 락</li>
<li>Redis 분산 락</li>
<li>Lettuce와 Redisson 차이</li>
<li>AOP 실행 순서</li>
<li>Self-invocation 문제</li>
<li>동시성 테스트 한계</li>
</ul>
<p>면접에서는 “기능을 구현했다”보다
<strong>왜 이 방식을 선택했는지</strong>, <strong>어떤 문제가 생길 수 있는지</strong>, <strong>그 문제를 어떻게 완화했는지</strong>까지 함께 말하는 것이 중요합니다.</p>
<p>출처: 내일배움 스파르타 클럽<img src="https://velog.velcdn.com/images/loaded_diaper/post/d9a254e9-26fe-4425-b52b-efb75198128f/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CH4 클라우드·결제시스템 모의면접 예상질문 리스트]]></title>
            <link>https://velog.io/@loaded_diaper/CH4-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EA%B2%B0%EC%A0%9C%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%AA%A8%EC%9D%98%EB%A9%B4%EC%A0%91-%EC%98%88%EC%83%81%EC%A7%88%EB%AC%B8-%EB%A6%AC%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@loaded_diaper/CH4-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EA%B2%B0%EC%A0%9C%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%AA%A8%EC%9D%98%EB%A9%B4%EC%A0%91-%EC%98%88%EC%83%81%EC%A7%88%EB%AC%B8-%EB%A6%AC%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Mon, 29 Jun 2026 03:11:31 GMT</pubDate>
            <description><![CDATA[<h1 id="ch4-클라우드·결제-시스템-모의면접-예상질문-정리">CH4 클라우드·결제 시스템 모의면접 예상질문 정리</h1>
<blockquote>
<p>AWS 배포, Docker, HTTPS, PortOne V2 결제, Webhook, 포인트 결제, 환불, 재고 정합성, 정기 구독 결제까지 CH4 프로젝트에서 나올 수 있는 면접 질문과 핵심 답변을 정리했습니다.</p>
</blockquote>
<hr>
<h1 id="aws-·-배포-영역">AWS · 배포 영역</h1>
<h2 id="q1-ec2-rds-s3를-함께-사용하는-이유가-뭔가요-각각-어떤-역할을-담당했나요">Q1. EC2, RDS, S3를 함께 사용하는 이유가 뭔가요? 각각 어떤 역할을 담당했나요?</h2>
<p><strong>A.</strong></p>
<p>EC2, RDS, S3는 각각 역할이 다르기 때문에 함께 사용했습니다.</p>
<ul>
<li><strong>EC2</strong>: Spring Boot 애플리케이션을 실행하는 서버</li>
<li><strong>RDS</strong>: MySQL 데이터베이스를 관리하는 서비스</li>
<li><strong>S3</strong>: 이미지, 파일 같은 정적 리소스를 저장하는 스토리지</li>
</ul>
<p>EC2에 애플리케이션을 배포하고, 데이터는 RDS에 저장하며, 파일은 S3에 저장해서 서버와 저장소의 책임을 분리했습니다.</p>
<h3 id="꼬리질문-1-ec2와-rds를-같은-vpc-안에-두면서-보안-그룹은-어떻게-설정했나요">꼬리질문 1. EC2와 RDS를 같은 VPC 안에 두면서 보안 그룹은 어떻게 설정했나요?</h3>
<p>EC2와 RDS는 같은 VPC 안에 두고, RDS는 외부에서 직접 접근하지 못하도록 설정했습니다.</p>
<p>보안 그룹은 보통 다음과 같이 구성합니다.</p>
<ul>
<li>EC2: 80, 443, 필요 시 22번 포트 허용</li>
<li>RDS: 3306 포트를 EC2 보안 그룹에서 오는 요청만 허용</li>
</ul>
<p>즉, 사용자는 EC2에만 접근하고, DB는 EC2를 통해서만 접근할 수 있게 구성합니다.</p>
<h3 id="꼬리질문-2-rds-대신-ec2에-직접-mysql을-설치하는-방식과-비교했을-때-단점은-없나요">꼬리질문 2. RDS 대신 EC2에 직접 MySQL을 설치하는 방식과 비교했을 때 단점은 없나요?</h3>
<p>RDS는 관리형 서비스라 편리하지만 비용이 더 들 수 있고, 세부 설정을 완전히 직접 제어하기는 어렵습니다.</p>
<p>반대로 EC2에 직접 MySQL을 설치하면 비용이나 설정 자유도는 장점이 될 수 있지만, 백업, 장애 복구, 보안 패치, 모니터링을 직접 관리해야 해서 운영 부담이 커집니다.</p>
<hr>
<h2 id="q2-docker로-애플리케이션을-컨테이너화한-이유가-뭔가요">Q2. Docker로 애플리케이션을 컨테이너화한 이유가 뭔가요?</h2>
<p><strong>A.</strong></p>
<p>Docker를 사용하면 애플리케이션 실행 환경을 이미지로 고정할 수 있습니다.</p>
<p>개발 환경과 배포 환경의 Java 버전, 라이브러리, 실행 방식 차이를 줄일 수 있고, 어디서 실행하더라도 동일한 방식으로 애플리케이션을 띄울 수 있습니다.</p>
<p>즉, 배포 환경의 차이로 발생하는 문제를 줄이기 위해 사용했습니다.</p>
<h3 id="꼬리질문-1-dockerfile-작성-시-이미지-크기를-줄이기-위해-고려한-것이-있나요">꼬리질문 1. Dockerfile 작성 시 이미지 크기를 줄이기 위해 고려한 것이 있나요?</h3>
<p>이미지 크기를 줄이기 위해 다음을 고려할 수 있습니다.</p>
<ul>
<li>빌드 단계와 실행 단계를 분리하는 Multi-stage build 사용</li>
<li>실행에 필요 없는 Gradle 캐시, 테스트 파일, 소스 파일 제외</li>
<li>JDK 대신 실행만 가능한 JRE 기반 이미지 사용</li>
<li>불필요한 패키지 설치 최소화</li>
</ul>
<p>이렇게 하면 최종 이미지에는 애플리케이션 실행에 필요한 파일만 포함할 수 있습니다.</p>
<h3 id="꼬리질문-2-cicd-파이프라인에서-테스트-실패-시-배포가-어떻게-차단되나요">꼬리질문 2. CI/CD 파이프라인에서 테스트 실패 시 배포가 어떻게 차단되나요?</h3>
<p>CI/CD 파이프라인에서 보통 <code>test</code> 단계와 <code>deploy</code> 단계를 분리합니다.</p>
<p><code>./gradlew test</code>가 실패하면 해당 job이 실패 상태가 되고, 그 다음 배포 job이 실행되지 않도록 설정합니다.</p>
<p>즉, 테스트를 통과한 코드만 Docker 이미지 빌드와 배포 단계로 넘어가게 해서 안정성을 높입니다.</p>
<hr>
<h2 id="q3-https를-적용한-이유와-적용-방식을-설명해주세요">Q3. HTTPS를 적용한 이유와 적용 방식을 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p>HTTPS는 클라이언트와 서버 사이의 통신을 암호화하기 위해 적용합니다.</p>
<p>로그인 정보, 결제 정보, 개인정보가 네트워크에서 평문으로 노출되는 것을 막을 수 있습니다.</p>
<p>적용 방식은 보통 다음과 같습니다.</p>
<ul>
<li>도메인 연결</li>
<li>SSL/TLS 인증서 발급</li>
<li>Nginx 또는 ALB에 인증서 적용</li>
<li>HTTP 요청을 HTTPS로 리다이렉트</li>
</ul>
<h3 id="꼬리질문-1-lets-encrypt를-쓰는-방식과-acm을-쓰는-방식의-차이가-뭔가요">꼬리질문 1. Let&#39;s Encrypt를 쓰는 방식과 ACM을 쓰는 방식의 차이가 뭔가요?</h3>
<p><strong>Let&#39;s Encrypt</strong>는 무료 인증서를 발급받아 EC2나 Nginx에 직접 적용하는 방식입니다.</p>
<p><strong>ACM</strong>은 AWS Certificate Manager에서 인증서를 발급받고, ALB나 CloudFront 같은 AWS 서비스에 연결하는 방식입니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Let&#39;s Encrypt</th>
<th>ACM</th>
</tr>
</thead>
<tbody><tr>
<td>적용 위치</td>
<td>EC2, Nginx 등</td>
<td>ALB, CloudFront 등</td>
</tr>
<tr>
<td>관리 방식</td>
<td>직접 갱신 설정 필요</td>
<td>AWS 서비스와 연동 시 관리 편리</td>
</tr>
<tr>
<td>사용 상황</td>
<td>EC2 단독 구성</td>
<td>ALB, CloudFront 사용 구성</td>
</tr>
</tbody></table>
<h3 id="꼬리질문-2-alb-없이-ec2-한-대에-https를-적용하려면-어떻게-해야-하나요">꼬리질문 2. ALB 없이 EC2 한 대에 HTTPS를 적용하려면 어떻게 해야 하나요?</h3>
<p>EC2에 Nginx를 설치하고, Certbot으로 Let&#39;s Encrypt 인증서를 발급받아 Nginx에 적용할 수 있습니다.</p>
<p>흐름은 다음과 같습니다.</p>
<ol>
<li>도메인을 EC2 Public IP에 연결</li>
<li>EC2 보안 그룹에서 80, 443 포트 허용</li>
<li>Nginx 설치</li>
<li>Certbot으로 SSL 인증서 발급</li>
<li>Nginx에서 443 포트 SSL 설정</li>
<li>80번 HTTP 요청을 443번 HTTPS로 리다이렉트</li>
</ol>
<hr>
<h1 id="결제-시스템-영역">결제 시스템 영역</h1>
<h2 id="q4-portone-v2-기반-결제-흐름-client-confirm-방식을-처음부터-끝까지-설명해주세요">Q4. PortOne V2 기반 결제 흐름, Client Confirm 방식을 처음부터 끝까지 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p>Client Confirm 방식은 클라이언트에서 결제창을 띄우고, 서버에서 최종 검증과 확정 처리를 하는 방식입니다.</p>
<p>전체 흐름은 다음과 같습니다.</p>
<ol>
<li>사용자가 주문을 요청합니다.</li>
<li>서버가 주문 금액, 할인 금액, 포인트 사용 금액을 계산합니다.</li>
<li>서버가 결제 대기 상태의 주문과 결제 정보를 생성합니다.</li>
<li>클라이언트가 PortOne SDK로 결제창을 호출합니다.</li>
<li>결제 성공 후 클라이언트가 <code>paymentId</code> 등을 서버로 전달합니다.</li>
<li>서버가 PortOne 결제 정보를 조회하거나 confirm API를 호출합니다.</li>
<li>서버가 주문 금액과 실제 결제 금액을 검증합니다.</li>
<li>검증 성공 시 결제 상태를 <code>PAID</code>로 변경합니다.</li>
<li>주문 상태, 포인트 내역, 재고 상태를 함께 확정합니다.</li>
</ol>
<p>핵심은 클라이언트 응답만 믿지 않고, 서버가 최종적으로 금액과 상태를 검증한 뒤 결제를 확정하는 것입니다.</p>
<h3 id="꼬리질문-1-사전-금액-검증을-서버에서-하는-이유가-뭔가요-클라이언트에서-처리하면-안-되나요">꼬리질문 1. 사전 금액 검증을 서버에서 하는 이유가 뭔가요? 클라이언트에서 처리하면 안 되나요?</h3>
<p>클라이언트 값은 사용자가 조작할 수 있기 때문입니다.</p>
<p>상품 금액, 할인 금액, 포인트 사용 금액을 클라이언트에서만 계산하면 결제 금액을 임의로 낮춰 보낼 위험이 있습니다.</p>
<p>따라서 최종 결제 금액은 반드시 서버가 DB 기준으로 다시 계산하고 검증해야 합니다.</p>
<h3 id="꼬리질문-2-결제-검증-후-확정-처리-중-서버가-다운되면-어떻게-되나요">꼬리질문 2. 결제 검증 후 확정 처리 중 서버가 다운되면 어떻게 되나요?</h3>
<p>결제는 PG에서는 성공했지만, 내부 DB에는 반영되지 않은 상태가 될 수 있습니다.</p>
<p>이를 대비해 결제 확정 로직은 멱등하게 설계해야 합니다.</p>
<p>예를 들어 같은 <code>paymentId</code>로 다시 요청이 들어와도 이미 처리된 결제라면 중복 처리하지 않고 기존 결과를 반환합니다.</p>
<p>또한 Webhook이나 결제 상태 재조회 배치를 통해 PG 결제 상태와 내부 결제 상태를 다시 맞출 수 있습니다.</p>
<hr>
<h2 id="q5-webhook-멱등성-처리를-어떻게-구현했나요">Q5. Webhook 멱등성 처리를 어떻게 구현했나요?</h2>
<p><strong>A.</strong></p>
<p>Webhook은 같은 이벤트가 여러 번 올 수 있다고 가정하고 멱등하게 처리해야 합니다.</p>
<p>보통 다음 기준으로 중복 처리를 막습니다.</p>
<ul>
<li><code>webhookEventId</code></li>
<li><code>paymentId</code></li>
<li><code>orderId</code></li>
<li>결제 상태값</li>
</ul>
<p>처리 흐름은 다음과 같습니다.</p>
<ol>
<li>Webhook 요청을 받습니다.</li>
<li>HMAC signature를 검증합니다.</li>
<li>이벤트 ID 또는 paymentId로 이미 처리된 이벤트인지 확인합니다.</li>
<li>이미 처리된 경우 바로 성공 응답을 반환합니다.</li>
<li>아직 처리되지 않았다면 결제 상태를 조회하고 내부 상태를 변경합니다.</li>
<li>처리 완료 이벤트를 저장합니다.</li>
</ol>
<p>핵심은 같은 Webhook이 여러 번 와도 결제, 포인트, 재고가 중복 변경되지 않게 하는 것입니다.</p>
<h3 id="꼬리질문-1-hmac-서명-검증은-어느-시점에-처리했나요">꼬리질문 1. HMAC 서명 검증은 어느 시점에 처리했나요?</h3>
<p>Webhook 요청을 받은 직후, 비즈니스 로직을 실행하기 전에 처리합니다.</p>
<p>서명 검증에 실패한 요청은 신뢰할 수 없는 요청이므로 결제 상태 변경, 포인트 환불, 재고 복구 같은 내부 로직을 실행하면 안 됩니다.</p>
<p>즉, HMAC 검증은 Webhook 처리의 가장 앞단에서 수행해야 합니다.</p>
<h3 id="꼬리질문-2-webhook-수신에-실패했을-때-portone이-재전송을-보장하나요">꼬리질문 2. Webhook 수신에 실패했을 때 PortOne이 재전송을 보장하나요?</h3>
<p>외부 결제사의 Webhook 재전송 정책에만 의존하면 위험합니다.</p>
<p>서버 장애, 네트워크 장애, 응답 지연 등으로 Webhook이 누락되거나 늦게 도착할 수 있기 때문입니다.</p>
<p>따라서 Webhook은 보조 수단으로 보고, 서버에서는 결제 단건 조회 API나 상태 동기화 배치를 통해 결제 상태를 다시 확인할 수 있게 설계하는 것이 안전합니다.</p>
<hr>
<h2 id="q6-포인트--pg-복합-결제에서-pg-실결제-금액을-어떻게-산정했나요">Q6. 포인트 + PG 복합 결제에서 PG 실결제 금액을 어떻게 산정했나요?</h2>
<p><strong>A.</strong></p>
<p>PG 실결제 금액은 최종 주문 금액에서 사용 포인트를 차감해서 계산했습니다.</p>
<pre><code class="language-text">PG 실결제 금액 = 최종 주문 금액 - 사용 포인트</code></pre>
<p>예를 들어 주문 금액이 50,000원이고 포인트를 10,000원 사용하면 PG 결제 금액은 40,000원입니다.</p>
<p>다만 포인트 사용 가능 여부는 서버에서 검증해야 합니다.</p>
<ul>
<li>사용자의 포인트 잔액이 충분한지</li>
<li>주문 금액보다 많은 포인트를 사용하지 않았는지</li>
<li>최소 결제 금액 정책을 만족하는지</li>
<li>이미 사용 처리된 포인트가 아닌지</li>
</ul>
<h3 id="꼬리질문-1-결제-취소-시-포인트-환불은-어떻게-처리했나요">꼬리질문 1. 결제 취소 시 포인트 환불은 어떻게 처리했나요?</h3>
<p>결제 취소나 환불이 성공하면 사용했던 포인트를 다시 복구합니다.</p>
<p>이때 단순히 잔액만 증가시키는 것이 아니라, 포인트 사용 취소 내역을 포인트 거래 원장에 남기는 방식이 좋습니다.</p>
<p>예를 들어 다음과 같이 기록합니다.</p>
<ul>
<li>결제 시: <code>USE</code></li>
<li>환불 시: <code>REFUND</code></li>
</ul>
<p>이렇게 하면 포인트가 왜 증가하거나 감소했는지 추적할 수 있습니다.</p>
<h3 id="꼬리질문-2-포인트-잔액보다-많은-포인트를-사용하려는-요청은-어떻게-처리했나요">꼬리질문 2. 포인트 잔액보다 많은 포인트를 사용하려는 요청은 어떻게 처리했나요?</h3>
<p>서버에서 사용자의 현재 포인트 잔액을 조회하고, 요청 포인트가 잔액보다 크면 예외를 발생시킵니다.</p>
<p>이 경우 결제창을 띄우기 전에 요청을 차단해야 합니다.</p>
<p>응답은 보통 <code>400 Bad Request</code>로 처리하고, 메시지는 “보유 포인트보다 많은 포인트를 사용할 수 없습니다.”처럼 반환할 수 있습니다.</p>
<hr>
<h2 id="q7-부분-환불-시-결제수단별-비율-분리-산정과-반올림-오차-보정을-어떻게-구현했나요">Q7. 부분 환불 시 결제수단별 비율 분리 산정과 반올림 오차 보정을 어떻게 구현했나요?</h2>
<p><strong>A.</strong></p>
<p>포인트와 PG가 함께 사용된 결제에서는 환불 금액을 결제수단별로 나눠야 합니다.</p>
<p>예를 들어 총 결제 금액이 100,000원이고, 포인트 20,000원, PG 80,000원으로 결제했다면 비율은 다음과 같습니다.</p>
<ul>
<li>포인트: 20%</li>
<li>PG: 80%</li>
</ul>
<p>부분 환불 금액이 30,000원이라면 비율대로 계산합니다.</p>
<ul>
<li>포인트 환불: 6,000원</li>
<li>PG 환불: 24,000원</li>
</ul>
<p>다만 나눗셈 과정에서 소수점이 생길 수 있기 때문에 반올림 오차를 보정해야 합니다.</p>
<pre><code class="language-text">PG 환불 금액 = 전체 환불 금액 - 포인트 환불 금액</code></pre>
<p>이렇게 계산하면 포인트와 PG 환불 금액의 합이 항상 전체 환불 금액과 일치합니다.</p>
<h3 id="꼬리질문-1-반올림-오차를-pg에-몰아주는-이유가-있나요">꼬리질문 1. 반올림 오차를 PG에 몰아주는 이유가 있나요?</h3>
<p>포인트는 내부 시스템에서 직접 관리할 수 있지만, PG 환불 금액은 외부 결제사에 정확한 금액으로 요청해야 합니다.</p>
<p>따라서 전체 환불 금액과 결제수단별 환불 합계가 어긋나면 안 됩니다.</p>
<p>한쪽을 기준으로 계산하고 나머지를 차액으로 보정하면 총액 불일치 문제를 줄일 수 있습니다.</p>
<p>실무에서는 회사 정책에 따라 오차를 포인트에 둘 수도 있고 PG에 둘 수도 있지만, 중요한 것은 합계가 정확히 맞도록 일관된 기준을 두는 것입니다.</p>
<h3 id="꼬리질문-2-여러-상품-중-일부만-환불하는-부분-환불과-전체-금액의-일부만-환불하는-경우를-구분해서-처리했나요">꼬리질문 2. 여러 상품 중 일부만 환불하는 부분 환불과, 전체 금액의 일부만 환불하는 경우를 구분해서 처리했나요?</h3>
<p>네, 구분해서 처리하는 것이 좋습니다.</p>
<ul>
<li><strong>상품 단위 부분 환불</strong>: 특정 주문 상품의 수량과 금액을 기준으로 환불</li>
<li><strong>금액 단위 부분 환불</strong>: 전체 결제 금액 중 일부 금액만 환불</li>
</ul>
<p>상품 단위 환불은 재고 복구, 주문 상품 상태 변경, 환불 가능 수량 검증이 함께 필요합니다.</p>
<p>반면 금액 단위 환불은 주로 결제 금액 정산과 환불 한도 검증이 중요합니다.</p>
<hr>
<h2 id="q8-재고-선차감-정책과-단일-트랜잭션-기반-정합성-보장을-어떻게-설계했나요">Q8. 재고 선차감 정책과 단일 트랜잭션 기반 정합성 보장을 어떻게 설계했나요?</h2>
<p><strong>A.</strong></p>
<p>재고 선차감 정책은 주문 생성 시점에 재고를 먼저 차감하는 방식입니다.</p>
<p>결제까지 기다렸다가 재고를 차감하면 동시에 많은 사용자가 결제할 때 초과 판매가 발생할 수 있습니다.</p>
<p>그래서 주문 생성 시점에 재고를 차감하고, 결제 실패나 주문 취소가 발생하면 재고를 복구하는 방식으로 설계합니다.</p>
<p>흐름은 다음과 같습니다.</p>
<ol>
<li>주문 요청을 받습니다.</li>
<li>상품 재고를 조회합니다.</li>
<li>재고가 충분한지 검증합니다.</li>
<li>재고를 차감합니다.</li>
<li>주문을 생성합니다.</li>
<li>결제 대기 상태로 저장합니다.</li>
<li>결제 실패 또는 취소 시 재고를 복구합니다.</li>
</ol>
<p>이 과정은 하나의 트랜잭션 안에서 처리해서 주문 생성과 재고 차감이 함께 성공하거나 함께 실패하도록 해야 합니다.</p>
<h3 id="꼬리질문-1-비관적-락-대신-낙관적-락을-사용하지-않은-이유가-있나요">꼬리질문 1. 비관적 락 대신 낙관적 락을 사용하지 않은 이유가 있나요?</h3>
<p>재고처럼 동시에 많은 요청이 몰리고, 초과 판매가 절대 발생하면 안 되는 경우에는 비관적 락이 더 단순하고 안전할 수 있습니다.</p>
<p>낙관적 락은 충돌이 적을 때 유리하지만, 충돌이 많으면 재시도가 많이 발생할 수 있습니다.</p>
<p>반면 비관적 락은 먼저 데이터에 락을 걸고 처리하므로 같은 재고를 동시에 수정하는 상황을 강하게 제어할 수 있습니다.</p>
<h3 id="꼬리질문-2-인스턴스가-n대로-늘어나도-지금의-재고-동시성-제어가-그대로-유효할까요">꼬리질문 2. 인스턴스가 N대로 늘어나도 지금의 재고 동시성 제어가 그대로 유효할까요?</h3>
<p>DB의 비관적 락을 사용한다면 애플리케이션 인스턴스가 여러 대로 늘어나도 같은 DB row에 대한 락이 걸리기 때문에 기본적인 정합성은 유지됩니다.</p>
<p>하지만 애플리케이션 내부의 <code>synchronized</code> 같은 JVM 락만 사용했다면 인스턴스가 여러 대일 때는 유효하지 않습니다.</p>
<p>인스턴스가 N대로 늘어나는 환경에서는 DB Lock, Redis Lock 같은 외부 공유 자원을 기준으로 동시성을 제어해야 합니다.</p>
<h3 id="꼬리질문-3-커넥션-점유가-늘어나면-어떤-일이-발생할까요">꼬리질문 3. 커넥션 점유가 늘어나면 어떤 일이 발생할까요?</h3>
<p>트랜잭션이 오래 걸리거나 락 대기가 길어지면 DB 커넥션이 오래 점유됩니다.</p>
<p>그 결과 커넥션 풀이 고갈되고, 다른 요청들이 DB 커넥션을 얻지 못해 지연되거나 실패할 수 있습니다.</p>
<p>그래서 트랜잭션 범위를 짧게 유지하고, 외부 API 호출을 트랜잭션 안에서 오래 수행하지 않도록 주의해야 합니다.</p>
<hr>
<h2 id="q9-빌링키-기반-정기-구독-결제와-스케줄러-멱등-처리를-어떻게-구현했나요">Q9. 빌링키 기반 정기 구독 결제와 스케줄러 멱등 처리를 어떻게 구현했나요?</h2>
<p><strong>A.</strong></p>
<p>빌링키 기반 정기 결제는 사용자가 최초 1회 결제수단을 등록하면, 이후에는 서버가 빌링키를 이용해 정해진 주기마다 자동 결제를 요청하는 방식입니다.</p>
<p>구현 흐름은 다음과 같습니다.</p>
<ol>
<li>사용자가 결제수단을 등록합니다.</li>
<li>PortOne을 통해 빌링키를 발급받습니다.</li>
<li>서버에 빌링키와 구독 정보를 저장합니다.</li>
<li>스케줄러가 결제 예정 구독을 조회합니다.</li>
<li>결제 요청을 보냅니다.</li>
<li>결제 성공 시 다음 결제일을 갱신합니다.</li>
<li>결제 실패 시 실패 상태와 재시도 정보를 저장합니다.</li>
</ol>
<p>스케줄러는 중복 실행될 수 있으므로 멱등하게 처리해야 합니다.</p>
<p>예를 들어 <code>subscriptionId + billingDate</code> 기준으로 결제 시도 이력을 저장하고, 이미 처리된 결제일이면 다시 결제하지 않도록 막습니다.</p>
<h3 id="꼬리질문-1-정기-결제-실패-시-재시도-정책은-어떻게-설정했나요">꼬리질문 1. 정기 결제 실패 시 재시도 정책은 어떻게 설정했나요?</h3>
<p>정기 결제 실패 시 바로 구독을 종료하기보다는 재시도 정책을 둘 수 있습니다.</p>
<p>예를 들어 다음과 같이 처리할 수 있습니다.</p>
<ul>
<li>1차 실패: 실패 이력 저장 후 다음 날 재시도</li>
<li>2차 실패: 사용자에게 결제 실패 알림</li>
<li>3차 실패: 구독 일시 중지 또는 만료 처리</li>
</ul>
<p>재시도 횟수와 주기는 서비스 정책에 따라 정하고, 중복 결제가 발생하지 않도록 결제 시도 이력을 반드시 저장해야 합니다.</p>
<h3 id="꼬리질문-2-등급별-적립률-차등-적용은-어떻게-관리했나요">꼬리질문 2. 등급별 적립률 차등 적용은 어떻게 관리했나요?</h3>
<p>회원 등급별 적립률은 코드에 하드코딩하기보다 정책 테이블이나 Enum으로 관리할 수 있습니다.</p>
<p>예를 들어 <code>BASIC</code>, <code>VIP</code>, <code>VVIP</code> 등급에 따라 적립률을 다르게 두고, 결제 완료 시점에 회원 등급을 조회해서 적립 포인트를 계산합니다.</p>
<p>중요한 점은 결제 당시 적용된 적립률을 이력으로 남기는 것입니다.</p>
<p>나중에 등급 정책이 바뀌더라도 과거 결제의 적립 내역은 그대로 추적할 수 있어야 합니다.</p>
<hr>
<h2 id="q10-결제-성공-응답과-webhook-이벤트가-동시에-들어오거나-서로-다른-시점에-지연-도착하는-상황을-고려해-결제-확정-로직을-어떻게-설계했나요">Q10. 결제 성공 응답과 Webhook 이벤트가 동시에 들어오거나, 서로 다른 시점에 지연 도착하는 상황을 고려해 결제 확정 로직을 어떻게 설계했나요?</h2>
<p><strong>A.</strong></p>
<p>결제 성공 응답과 Webhook은 도착 순서가 보장되지 않습니다.</p>
<p>클라이언트 성공 응답이 먼저 올 수도 있고, Webhook이 먼저 올 수도 있으며, 둘 다 동시에 들어올 수도 있습니다.</p>
<p>그래서 결제 확정 로직은 하나의 공통 메서드로 모으고, 멱등하게 처리해야 합니다.</p>
<p>예를 들어 다음과 같이 설계할 수 있습니다.</p>
<ol>
<li>클라이언트 성공 응답 또는 Webhook 수신</li>
<li><code>paymentId</code> 기준으로 결제 정보 조회</li>
<li>이미 <code>PAID</code> 상태라면 추가 처리 없이 종료</li>
<li>아직 대기 상태라면 PortOne 결제 상태 조회</li>
<li>금액, 주문번호, 상태 검증</li>
<li>검증 성공 시 내부 결제 상태를 <code>PAID</code>로 변경</li>
<li>주문, 포인트, 재고 상태를 확정</li>
<li>처리 이력 저장</li>
</ol>
<p>핵심은 어느 경로로 들어오든 같은 확정 로직을 타게 만드는 것입니다.</p>
<p>그래야 중복 요청, 지연 요청, 동시 요청 상황에서도 결제가 한 번만 확정됩니다.</p>
<hr>
<h2 id="q11-결제-실패가-발생했을-때-원인을-어떻게-추적할-수-있나요">Q11. 결제 실패가 발생했을 때 원인을 어떻게 추적할 수 있나요?</h2>
<p><strong>A.</strong></p>
<p>결제 실패 원인을 추적하려면 외부 PG 응답과 내부 서버 로그를 연결할 수 있어야 합니다.</p>
<p>이를 위해 다음 값을 로그와 DB에 남기는 것이 좋습니다.</p>
<ul>
<li><code>orderId</code></li>
<li><code>paymentId</code></li>
<li>PortOne transaction id</li>
<li>요청 금액</li>
<li>실제 결제 금액</li>
<li>결제 상태</li>
<li>실패 코드</li>
<li>실패 메시지</li>
<li>Webhook event id</li>
<li>요청 시각과 응답 시각</li>
</ul>
<p>이렇게 남겨두면 결제 실패가 외부 PG 문제인지, 서버 검증 실패인지, 내부 트랜잭션 문제인지 추적할 수 있습니다.</p>
<h3 id="꼬리질문-외부-pg-webhook-내부-트랜잭션-장애-발생-시-어떻게-대응할-수-있나요">꼬리질문. 외부 PG, Webhook, 내부 트랜잭션 장애 발생 시 어떻게 대응할 수 있나요?</h3>
<p>장애 위치에 따라 대응 방식이 달라집니다.</p>
<table>
<thead>
<tr>
<th>장애 위치</th>
<th>대응 방식</th>
</tr>
</thead>
<tbody><tr>
<td>외부 PG 장애</td>
<td>실패 응답 저장, 사용자에게 결제 실패 안내, 재시도 유도</td>
</tr>
<tr>
<td>Webhook 장애</td>
<td>결제 단건 조회 API나 배치로 상태 재동기화</td>
</tr>
<tr>
<td>내부 트랜잭션 장애</td>
<td>결제 상태와 내부 주문 상태 불일치 여부 확인 후 보정</td>
</tr>
<tr>
<td>DB 장애</td>
<td>트랜잭션 롤백, 실패 로그 저장, 재처리 대상 등록</td>
</tr>
<tr>
<td>네트워크 장애</td>
<td>timeout 설정, 재시도 정책, 중복 처리 방지</td>
</tr>
</tbody></table>
<p>중요한 것은 결제 성공 여부를 클라이언트 응답만으로 판단하지 않고, PG 결제 상태와 내부 DB 상태를 비교해서 불일치를 복구할 수 있게 만드는 것입니다.</p>
<hr>
<h1 id="마무리-정리">마무리 정리</h1>
<p>CH4 클라우드·결제 시스템 면접에서는 단순히 기능 구현 여부보다 <strong>장애 상황에서 정합성을 어떻게 보장했는지</strong>를 많이 물어볼 수 있습니다.</p>
<p>특히 다음 키워드는 꼭 연결해서 답변하는 것이 좋습니다.</p>
<ul>
<li>EC2, RDS, S3의 역할 분리</li>
<li>VPC와 보안 그룹 설정</li>
<li>Docker를 통한 실행 환경 통일</li>
<li>HTTPS 적용 이유</li>
<li>서버 기준 결제 금액 검증</li>
<li>Webhook 멱등성</li>
<li>포인트 원장 관리</li>
<li>부분 환불 금액 산정</li>
<li>재고 선차감과 복구</li>
<li>트랜잭션과 락</li>
<li>결제 실패 추적과 상태 보정</li>
</ul>
<p>면접에서는 “구현했습니다”에서 끝내기보다,
<strong>왜 그렇게 설계했는지</strong>, <strong>장애가 나면 어떻게 복구할 수 있는지</strong>, <strong>중복 요청이 와도 안전한지</strong>까지 함께 설명하는 것이 중요합니다.</p>
<p>출처: 내일배움 스파르타 클럽<img src="https://velog.velcdn.com/images/loaded_diaper/post/d41d4e81-946f-4727-960b-2c4904f3589f/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CH1-3 Java ~ 숙련 Spring 모의면접 예상질문 리스트]]></title>
            <link>https://velog.io/@loaded_diaper/CH1-3-Java-%EC%88%99%EB%A0%A8-Spring-%EB%AA%A8%EC%9D%98%EB%A9%B4%EC%A0%91-%EC%98%88%EC%83%81%EC%A7%88%EB%AC%B8-%EB%A6%AC%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@loaded_diaper/CH1-3-Java-%EC%88%99%EB%A0%A8-Spring-%EB%AA%A8%EC%9D%98%EB%A9%B4%EC%A0%91-%EC%98%88%EC%83%81%EC%A7%88%EB%AC%B8-%EB%A6%AC%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Mon, 29 Jun 2026 03:08:34 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/loaded_diaper/post/04916cb9-667d-48b7-95d0-1826f75313ad/image.png" alt=""></p>
<h1 id="ch1-3-java--숙련-spring-모의면접-예상질문-정리">CH1-3 Java ~ 숙련 Spring 모의면접 예상질문 정리</h1>
<blockquote>
<p>Java / OOP, HTTP / Web, Spring, JPA, 인증 영역에서 자주 나올 수 있는 면접 질문과 핵심 답변을 정리했습니다.</p>
</blockquote>
<hr>
<h1 id="java--oop-영역">Java / OOP 영역</h1>
<h2 id="q1-oop의-4가지-특징을-설명해주세요">Q1. OOP의 4가지 특징을 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p>OOP의 4가지 특징은 <strong>캡슐화, 상속, 다형성, 추상화</strong>입니다.</p>
<ul>
<li><strong>캡슐화</strong>: 데이터와 기능을 하나의 객체로 묶고, 외부에서 직접 접근하지 못하게 보호하는 것</li>
<li><strong>상속</strong>: 부모 클래스의 필드와 메서드를 자식 클래스가 물려받아 재사용하는 것</li>
<li><strong>다형성</strong>: 같은 타입으로 여러 구현 객체를 다룰 수 있는 것</li>
<li><strong>추상화</strong>: 공통된 특징만 뽑아 복잡한 내부 구현을 숨기고 필요한 기능만 보여주는 것</li>
</ul>
<h3 id="꼬리질문-1-캡슐화와-은닉화는-같은-개념인가요">꼬리질문 1. 캡슐화와 은닉화는 같은 개념인가요?</h3>
<p>완전히 같은 개념은 아닙니다.</p>
<p><strong>은닉화</strong>는 필드를 <code>private</code>으로 막는 것처럼 내부 데이터를 숨기는 개념이고,
<strong>캡슐화</strong>는 데이터와 메서드를 객체 안에 묶고 필요한 기능만 외부에 제공하는 더 넓은 개념입니다.</p>
<h3 id="꼬리질문-2-다형성을-실제-코드에서-어떻게-활용했나요">꼬리질문 2. 다형성을 실제 코드에서 어떻게 활용했나요?</h3>
<p>예를 들어 <code>PaymentService</code>가 <code>KakaoPay</code>, <code>TossPay</code> 같은 구체 클래스가 아니라 <code>PaymentClient</code> 인터페이스에 의존하게 만들 수 있습니다.</p>
<p>그러면 결제 방식이 바뀌어도 서비스 코드는 크게 변경하지 않고 구현체만 교체할 수 있습니다.</p>
<hr>
<h2 id="q2-오버로딩과-오버라이딩의-차이를-설명해주세요">Q2. 오버로딩과 오버라이딩의 차이를 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p><strong>오버로딩</strong>은 같은 이름의 메서드를 매개변수 개수나 타입을 다르게 해서 여러 개 정의하는 것입니다.</p>
<p><strong>오버라이딩</strong>은 부모 클래스나 인터페이스의 메서드를 자식 클래스에서 재정의하는 것입니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>오버로딩</th>
<th>오버라이딩</th>
</tr>
</thead>
<tbody><tr>
<td>기준</td>
<td>메서드 이름 같음</td>
<td>부모 메서드 재정의</td>
</tr>
<tr>
<td>매개변수</td>
<td>달라야 함</td>
<td>같아야 함</td>
</tr>
<tr>
<td>반환 타입</td>
<td>자유로움</td>
<td>같거나 하위 타입</td>
</tr>
<tr>
<td>시점</td>
<td>컴파일 시점</td>
<td>런타임 시점</td>
</tr>
</tbody></table>
<h3 id="꼬리질문-1-override를-붙이지-않아도-동작하는데-왜-붙이나요">꼬리질문 1. <code>@Override</code>를 붙이지 않아도 동작하는데 왜 붙이나요?</h3>
<p><code>@Override</code>를 붙이면 컴파일러가 실제로 오버라이딩이 맞는지 검사해줍니다.</p>
<p>메서드 이름이나 파라미터를 실수로 잘못 작성했을 때 컴파일 단계에서 오류를 잡을 수 있어서 안정성이 높아집니다.</p>
<h3 id="꼬리질문-2-오버라이딩된-메서드에서-부모-메서드를-호출하려면-어떻게-하나요">꼬리질문 2. 오버라이딩된 메서드에서 부모 메서드를 호출하려면 어떻게 하나요?</h3>
<p><code>super.메서드명()</code>을 사용합니다.</p>
<p>예를 들어 <code>super.print()</code>처럼 호출하면 부모 클래스의 원래 메서드를 실행할 수 있습니다.</p>
<hr>
<h2 id="q3-인터페이스와-추상-클래스의-차이를-설명하고-어떤-상황에서-각각-선택하나요">Q3. 인터페이스와 추상 클래스의 차이를 설명하고, 어떤 상황에서 각각 선택하나요?</h2>
<p><strong>A.</strong></p>
<p><strong>인터페이스</strong>는 객체가 반드시 구현해야 하는 기능의 규칙을 정의할 때 사용합니다.</p>
<p><strong>추상 클래스</strong>는 공통 필드나 공통 로직을 자식 클래스에 물려주면서 일부 메서드는 자식이 직접 구현하게 할 때 사용합니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>인터페이스</th>
<th>추상 클래스</th>
</tr>
</thead>
<tbody><tr>
<td>목적</td>
<td>기능 명세, 규칙 정의</td>
<td>공통 로직 재사용</td>
</tr>
<tr>
<td>다중 구현</td>
<td>가능</td>
<td>불가능</td>
</tr>
<tr>
<td>필드</td>
<td>상수 중심</td>
<td>인스턴스 필드 가능</td>
</tr>
<tr>
<td>생성자</td>
<td>없음</td>
<td>있음</td>
</tr>
</tbody></table>
<h3 id="꼬리질문-1-java-8-이후-default-메서드가-생겼는데-추상-클래스와-차이가-없어졌나요">꼬리질문 1. Java 8 이후 default 메서드가 생겼는데 추상 클래스와 차이가 없어졌나요?</h3>
<p>아닙니다.</p>
<p>인터페이스에 <code>default</code> 메서드가 추가되어 기본 구현은 가능해졌지만, 인터페이스는 여전히 상태를 가지는 인스턴스 필드나 생성자를 가질 수 없습니다.</p>
<p>추상 클래스는 공통 상태와 공통 로직을 함께 관리할 수 있다는 차이가 있습니다.</p>
<h3 id="꼬리질문-2-다중-상속이-필요한-상황에서-인터페이스를-선택하는-이유는-무엇인가요">꼬리질문 2. 다중 상속이 필요한 상황에서 인터페이스를 선택하는 이유는 무엇인가요?</h3>
<p>Java는 클래스 다중 상속을 지원하지 않지만, 인터페이스는 여러 개 구현할 수 있습니다.</p>
<p>그래서 여러 역할이나 기능을 동시에 부여해야 할 때 인터페이스를 사용합니다.</p>
<hr>
<h2 id="q4-java-컬렉션-프레임워크에서-list-set-map의-특징과-차이를-설명해주세요">Q4. Java 컬렉션 프레임워크에서 List, Set, Map의 특징과 차이를 설명해주세요.</h2>
<p><strong>A.</strong></p>
<ul>
<li><strong>List</strong>: 순서가 있고, 중복을 허용합니다.</li>
<li><strong>Set</strong>: 순서가 보장되지 않는 경우가 많고, 중복을 허용하지 않습니다.</li>
<li><strong>Map</strong>: Key-Value 구조로 데이터를 저장하고, Key는 중복될 수 없습니다.</li>
</ul>
<table>
<thead>
<tr>
<th>구분</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>List</td>
<td>순서 있음, 중복 허용</td>
</tr>
<tr>
<td>Set</td>
<td>중복 불가</td>
</tr>
<tr>
<td>Map</td>
<td>Key-Value 저장, Key 중복 불가</td>
</tr>
</tbody></table>
<h3 id="꼬리질문-1-arraylist와-linkedlist의-시간복잡도-차이는-무엇인가요">꼬리질문 1. ArrayList와 LinkedList의 시간복잡도 차이는 무엇인가요?</h3>
<p><code>ArrayList</code>는 배열 기반이라 인덱스 조회가 빠릅니다.</p>
<ul>
<li>조회: <code>O(1)</code></li>
<li>중간 삽입/삭제: <code>O(n)</code></li>
</ul>
<p><code>LinkedList</code>는 노드 연결 구조라 중간 삽입/삭제에 유리할 수 있지만, 특정 위치까지 이동해야 하므로 조회가 느립니다.</p>
<ul>
<li>조회: <code>O(n)</code></li>
<li>삽입/삭제: 위치를 알고 있으면 <code>O(1)</code></li>
</ul>
<p>일반적인 조회가 많으면 <code>ArrayList</code>를 주로 사용합니다.</p>
<h3 id="꼬리질문-2-hashmap의-키-중복-판단-기준은-무엇인가요">꼬리질문 2. HashMap의 키 중복 판단 기준은 무엇인가요?</h3>
<p><code>HashMap</code>은 먼저 <code>hashCode()</code>로 저장 위치를 찾고, 같은 해시 위치에 값이 있으면 <code>equals()</code>로 실제 같은 Key인지 비교합니다.</p>
<p>즉, Key 중복 판단에는 <code>hashCode()</code>와 <code>equals()</code>가 함께 사용됩니다.</p>
<hr>
<h1 id="http--web-기초-영역">HTTP / Web 기초 영역</h1>
<h2 id="q5-get과-post의-차이를-데이터-전달-방식과-사용-목적-관점에서-설명해주세요">Q5. GET과 POST의 차이를 데이터 전달 방식과 사용 목적 관점에서 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p><strong>GET</strong>은 주로 데이터를 조회할 때 사용하고, 요청 데이터는 보통 URL의 Query Parameter로 전달합니다.</p>
<p><strong>POST</strong>는 데이터를 생성하거나 처리할 때 사용하고, 요청 데이터는 주로 Body에 담아 전달합니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>GET</th>
<th>POST</th>
</tr>
</thead>
<tbody><tr>
<td>목적</td>
<td>조회</td>
<td>생성, 처리</td>
</tr>
<tr>
<td>데이터 전달</td>
<td>Query Parameter</td>
<td>Body</td>
</tr>
<tr>
<td>멱등성</td>
<td>일반적으로 있음</td>
<td>일반적으로 없음</td>
</tr>
<tr>
<td>캐싱</td>
<td>가능</td>
<td>보통 캐싱하지 않음</td>
</tr>
</tbody></table>
<h3 id="꼬리질문-1-get-요청에-body를-담아-보내면-어떻게-되나요">꼬리질문 1. GET 요청에 Body를 담아 보내면 어떻게 되나요?</h3>
<p>기술적으로 Body를 담는 것이 완전히 불가능한 것은 아닙니다.</p>
<p>하지만 HTTP 표준 관점에서 일반적인 사용 방식이 아니고, 서버나 프록시, 라이브러리에서 Body를 무시할 수 있기 때문에 권장하지 않습니다.</p>
<h3 id="꼬리질문-2-조회-api인데-요청-파라미터가-많아-url-길이-제한에-걸리면-어떻게-설계하나요">꼬리질문 2. 조회 API인데 요청 파라미터가 많아 URL 길이 제한에 걸리면 어떻게 설계하나요?</h3>
<p>복잡한 검색 조건이라면 <code>POST /search</code>처럼 Body에 검색 조건을 담는 방식으로 설계할 수 있습니다.</p>
<p>단, 이 경우에도 실제 의미는 조회이므로 API 이름과 문서에서 검색용 요청이라는 것을 명확히 하는 것이 좋습니다.</p>
<hr>
<h2 id="q6-http-상태코드-200-201-400-404-500의-의미를-설명해주세요">Q6. HTTP 상태코드 200, 201, 400, 404, 500의 의미를 설명해주세요.</h2>
<p><strong>A.</strong></p>
<table>
<thead>
<tr>
<th>상태코드</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>200 OK</td>
<td>요청 성공</td>
</tr>
<tr>
<td>201 Created</td>
<td>리소스 생성 성공</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td>잘못된 요청</td>
</tr>
<tr>
<td>404 Not Found</td>
<td>리소스를 찾을 수 없음</td>
</tr>
<tr>
<td>500 Internal Server Error</td>
<td>서버 내부 오류</td>
</tr>
</tbody></table>
<h3 id="꼬리질문-1-생성-api에서-200-대신-201을-반환해야-하는-이유가-있나요">꼬리질문 1. 생성 API에서 200 대신 201을 반환해야 하는 이유가 있나요?</h3>
<p><code>201 Created</code>는 새로운 리소스가 생성되었다는 의미를 더 명확하게 전달합니다.</p>
<p>따라서 생성 API에서는 <code>200 OK</code>보다 <code>201 Created</code>가 RESTful한 의미에 더 잘 맞습니다.</p>
<h3 id="꼬리질문-2-없는-리소스를-요청했을-때-404-대신-200과-빈-데이터를-반환하는-방식도-있는데-어떤-방식이-더-적절한가요">꼬리질문 2. 없는 리소스를 요청했을 때 404 대신 200과 빈 데이터를 반환하는 방식도 있는데, 어떤 방식이 더 적절한가요?</h3>
<p>단건 조회에서 특정 ID의 리소스가 없다면 <code>404 Not Found</code>가 더 적절합니다.</p>
<p>반면 목록 조회 결과가 비어 있는 경우에는 요청 자체는 성공한 것이므로 <code>200 OK</code>와 빈 배열을 반환하는 것이 자연스럽습니다.</p>
<hr>
<h2 id="q7-cookie와-session의-차이를-저장-위치와-보안-관점에서-설명해주세요">Q7. Cookie와 Session의 차이를 저장 위치와 보안 관점에서 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p><strong>Cookie</strong>는 클라이언트 브라우저에 저장되는 데이터입니다.</p>
<p><strong>Session</strong>은 서버에 사용자 상태를 저장하고, 클라이언트는 Session ID만 Cookie로 들고 있습니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Cookie</th>
<th>Session</th>
</tr>
</thead>
<tbody><tr>
<td>저장 위치</td>
<td>클라이언트</td>
<td>서버</td>
</tr>
<tr>
<td>보안</td>
<td>상대적으로 취약</td>
<td>상대적으로 안전</td>
</tr>
<tr>
<td>서버 부담</td>
<td>적음</td>
<td>있음</td>
</tr>
<tr>
<td>사용 예시</td>
<td>자동 로그인, 사용자 설정</td>
<td>로그인 상태 관리</td>
</tr>
</tbody></table>
<h3 id="꼬리질문-1-session-사용-중-서버가-여러-대로-확장되면-어떤-문제가-생기나요">꼬리질문 1. Session 사용 중 서버가 여러 대로 확장되면 어떤 문제가 생기나요?</h3>
<p>서버마다 세션 저장소가 다르면 사용자가 처음 로그인한 서버가 아닌 다른 서버로 요청했을 때 세션을 찾지 못할 수 있습니다.</p>
<p>이를 해결하기 위해 Redis 같은 중앙 세션 저장소를 사용하거나 Sticky Session을 사용할 수 있습니다.</p>
<h3 id="꼬리질문-2-cookie의-httponly와-secure-속성은-각각-어떤-보안-위협을-방지하나요">꼬리질문 2. Cookie의 HttpOnly와 Secure 속성은 각각 어떤 보안 위협을 방지하나요?</h3>
<p><code>HttpOnly</code>는 JavaScript에서 쿠키에 접근하지 못하게 해서 XSS 공격으로 쿠키가 탈취되는 위험을 줄입니다.</p>
<p><code>Secure</code>는 HTTPS 통신에서만 쿠키가 전송되도록 해서 네트워크 구간에서 쿠키가 노출되는 위험을 줄입니다.</p>
<hr>
<h1 id="spring-기초-영역">Spring 기초 영역</h1>
<h2 id="q8-3-layer-architecture를-사용하는-이유는-무엇인가요">Q8. 3 Layer Architecture를 사용하는 이유는 무엇인가요?</h2>
<p><strong>A.</strong></p>
<p>3 Layer Architecture는 역할을 분리해서 코드의 유지보수성과 테스트 편의성을 높이기 위해 사용합니다.</p>
<ul>
<li><strong>Controller</strong>: 요청과 응답 처리</li>
<li><strong>Service</strong>: 비즈니스 로직 처리</li>
<li><strong>Repository</strong>: 데이터베이스 접근</li>
</ul>
<p>각 계층의 책임이 분리되면 변경이 필요한 부분을 찾기 쉽고, 테스트하기도 쉬워집니다.</p>
<h3 id="꼬리질문-1-service-레이어에서-다른-service를-호출하는-것이-왜-문제가-될-수-있나요">꼬리질문 1. Service 레이어에서 다른 Service를 호출하는 것이 왜 문제가 될 수 있나요?</h3>
<p>Service끼리 무분별하게 호출하면 의존 관계가 복잡해지고 순환 참조가 발생할 수 있습니다.</p>
<p>또한 하나의 Service가 너무 많은 책임을 가지게 되어 유지보수가 어려워질 수 있습니다.</p>
<p>여러 도메인이 엮이는 로직은 Facade 계층을 두어 흐름을 조율하는 방식도 사용할 수 있습니다.</p>
<h3 id="꼬리질문-2-controller에-비즈니스-로직을-바로-작성하면-어떤-문제가-생기나요">꼬리질문 2. Controller에 비즈니스 로직을 바로 작성하면 어떤 문제가 생기나요?</h3>
<p>Controller가 요청 처리뿐만 아니라 비즈니스 로직까지 담당하게 되어 책임이 커집니다.</p>
<p>그 결과 코드 중복이 생기고, 테스트가 어려워지며, API 변경 시 비즈니스 로직까지 영향을 받을 수 있습니다.</p>
<hr>
<h2 id="q9-spring의-ioc와-di가-무엇인지-생성자-주입이-권장되는-이유를-설명해주세요">Q9. Spring의 IoC와 DI가 무엇인지, 생성자 주입이 권장되는 이유를 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p><strong>IoC</strong>는 객체 생성과 의존성 관리의 제어권을 개발자가 아니라 Spring 컨테이너가 가지는 것입니다.</p>
<p><strong>DI</strong>는 필요한 의존 객체를 직접 생성하지 않고 외부에서 주입받는 방식입니다.</p>
<p>생성자 주입이 권장되는 이유는 다음과 같습니다.</p>
<ul>
<li>의존성을 <code>final</code>로 선언할 수 있음</li>
<li>객체 생성 시점에 필수 의존성을 보장할 수 있음</li>
<li>테스트 코드 작성이 쉬움</li>
<li>순환 의존성을 빠르게 발견할 수 있음</li>
</ul>
<h3 id="꼬리질문-1-필드-주입은-편한데-왜-권장하지-않나요">꼬리질문 1. 필드 주입은 편한데 왜 권장하지 않나요?</h3>
<p>필드 주입은 외부에서 의존성을 주입하기 어렵기 때문에 테스트가 불편합니다.</p>
<p>또한 의존성이 숨겨져 있어 객체가 어떤 의존성을 필요로 하는지 명확히 드러나지 않습니다.</p>
<h3 id="꼬리질문-2-순환-의존성이란-무엇이고-생성자-주입에서는-왜-더-빨리-감지되나요">꼬리질문 2. 순환 의존성이란 무엇이고, 생성자 주입에서는 왜 더 빨리 감지되나요?</h3>
<p>순환 의존성은 A가 B를 필요로 하고, B도 다시 A를 필요로 하는 구조입니다.</p>
<p>생성자 주입은 객체 생성 시점에 모든 의존성이 필요하기 때문에 순환 구조가 있으면 애플리케이션 시작 단계에서 바로 오류가 발생합니다.</p>
<hr>
<h2 id="q10-spring-bean이-무엇이고-싱글톤으로-관리되는-이유가-무엇인가요">Q10. Spring Bean이 무엇이고, 싱글톤으로 관리되는 이유가 무엇인가요?</h2>
<p><strong>A.</strong></p>
<p>Spring Bean은 Spring 컨테이너가 생성하고 관리하는 객체입니다.</p>
<p>Spring은 기본적으로 Bean을 싱글톤으로 관리합니다.</p>
<p>그 이유는 매 요청마다 객체를 새로 만들지 않고 하나의 객체를 재사용해서 메모리 사용을 줄이고 성능을 높이기 위해서입니다.</p>
<h3 id="꼬리질문-1-스프링-빈이-싱글톤이면-멀티스레드-환경에서-상태를-가지면-어떤-문제가-생기나요">꼬리질문 1. 스프링 빈이 싱글톤이면 멀티스레드 환경에서 상태를 가지면 어떤 문제가 생기나요?</h3>
<p>싱글톤 Bean은 여러 요청이 동시에 공유합니다.</p>
<p>따라서 인스턴스 필드에 사용자별 상태를 저장하면 다른 요청과 데이터가 섞이는 동시성 문제가 발생할 수 있습니다.</p>
<p>그래서 Service나 Controller Bean은 보통 상태를 가지지 않도록 설계합니다.</p>
<h3 id="꼬리질문-2-component와-bean의-차이는-무엇인가요">꼬리질문 2. <code>@Component</code>와 <code>@Bean</code>의 차이는 무엇인가요?</h3>
<p><code>@Component</code>는 클래스에 붙여서 Spring이 자동으로 스캔해 Bean으로 등록하게 합니다.</p>
<p><code>@Bean</code>은 설정 클래스에서 메서드에 붙여 직접 객체를 생성하고 Bean으로 등록할 때 사용합니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>사용 상황</th>
</tr>
</thead>
<tbody><tr>
<td><code>@Component</code></td>
<td>내가 만든 클래스를 자동 등록할 때</td>
</tr>
<tr>
<td><code>@Bean</code></td>
<td>외부 라이브러리 객체를 Bean으로 등록할 때</td>
</tr>
</tbody></table>
<hr>
<h2 id="q11-dto를-사용하는-이유가-무엇인가요-entity를-직접-반환하면-안-되는-이유를-설명해주세요">Q11. DTO를 사용하는 이유가 무엇인가요? Entity를 직접 반환하면 안 되는 이유를 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p>DTO는 계층 간 데이터를 전달하기 위한 객체입니다.</p>
<p>Entity를 직접 반환하면 다음과 같은 문제가 생길 수 있습니다.</p>
<ul>
<li>DB 구조가 API 응답에 그대로 노출됨</li>
<li>민감한 필드가 노출될 수 있음</li>
<li>Entity 변경이 API 스펙 변경으로 이어짐</li>
<li>양방향 연관관계에서 무한 참조 문제가 발생할 수 있음</li>
</ul>
<p>따라서 API 요청과 응답에는 Entity 대신 DTO를 사용하는 것이 좋습니다.</p>
<h3 id="꼬리질문-1-requestdto와-responsedto를-분리하는-이유는-무엇인가요">꼬리질문 1. RequestDTO와 ResponseDTO를 분리하는 이유는 무엇인가요?</h3>
<p>요청 데이터와 응답 데이터의 목적이 다르기 때문입니다.</p>
<p>RequestDTO는 클라이언트가 보내는 값을 검증하는 데 집중하고,
ResponseDTO는 클라이언트에게 보여줄 데이터만 담는 데 집중합니다.</p>
<h3 id="꼬리질문-2-entity에-jsonignore를-붙여-민감한-필드를-숨기면-dto-없이도-되지-않나요">꼬리질문 2. Entity에 <code>@JsonIgnore</code>를 붙여 민감한 필드를 숨기면 DTO 없이도 되지 않나요?</h3>
<p>간단한 경우에는 가능할 수 있지만 권장되지는 않습니다.</p>
<p><code>@JsonIgnore</code>는 직렬화 문제만 일부 해결할 뿐, API 응답 구조와 Entity 구조가 강하게 묶이는 문제는 해결하지 못합니다.</p>
<p>DTO를 사용하면 API 스펙과 DB 모델을 분리할 수 있습니다.</p>
<hr>
<h1 id="jpa-영역">JPA 영역</h1>
<h2 id="q12-jpa의-영속성-컨텍스트란-무엇이고-어떤-이점이-있나요">Q12. JPA의 영속성 컨텍스트란 무엇이고, 어떤 이점이 있나요?</h2>
<p><strong>A.</strong></p>
<p>영속성 컨텍스트는 JPA가 Entity를 관리하는 1차 캐시 공간입니다.</p>
<p>EntityManager가 Entity를 저장하고 관리하는 영역이라고 볼 수 있습니다.</p>
<p>주요 이점은 다음과 같습니다.</p>
<ul>
<li>1차 캐시</li>
<li>동일성 보장</li>
<li>변경 감지</li>
<li>쓰기 지연</li>
<li>지연 로딩 지원</li>
</ul>
<h3 id="꼬리질문-1-같은-트랜잭션-내에서-같은-id로-두-번-조회하면-sql이-몇-번-실행되나요">꼬리질문 1. 같은 트랜잭션 내에서 같은 ID로 두 번 조회하면 SQL이 몇 번 실행되나요?</h3>
<p>처음 조회할 때만 SQL이 실행되고, 두 번째 조회는 영속성 컨텍스트의 1차 캐시에서 가져옵니다.</p>
<p>따라서 같은 트랜잭션 안에서는 같은 ID의 Entity에 대해 동일성이 보장됩니다.</p>
<h3 id="꼬리질문-2-준영속-상태란-무엇이고-언제-발생하나요">꼬리질문 2. 준영속 상태란 무엇이고, 언제 발생하나요?</h3>
<p>준영속 상태는 한 번 영속성 컨텍스트에서 관리되었지만 현재는 분리되어 관리되지 않는 상태입니다.</p>
<p>예를 들어 트랜잭션이 끝나거나, <code>detach()</code>, <code>clear()</code>, <code>close()</code>가 호출되면 Entity가 준영속 상태가 될 수 있습니다.</p>
<hr>
<h2 id="q13-더티-체킹이란-무엇인가요-save를-호출하지-않아도-update가-되는-이유를-설명해주세요">Q13. 더티 체킹이란 무엇인가요? save()를 호출하지 않아도 UPDATE가 되는 이유를 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p>더티 체킹은 영속성 컨텍스트가 관리 중인 Entity의 변경 사항을 감지해서 트랜잭션 커밋 시점에 자동으로 UPDATE 쿼리를 실행하는 기능입니다.</p>
<p>JPA는 Entity를 처음 조회했을 때의 스냅샷을 가지고 있다가, 커밋 시점에 현재 Entity와 비교해 변경 사항이 있으면 UPDATE를 실행합니다.</p>
<h3 id="꼬리질문-1-transactional이-없는-메서드에서-entity를-수정하면-더티-체킹이-동작하나요">꼬리질문 1. <code>@Transactional</code>이 없는 메서드에서 Entity를 수정하면 더티 체킹이 동작하나요?</h3>
<p>일반적으로 동작하지 않습니다.</p>
<p>더티 체킹은 영속성 컨텍스트와 트랜잭션 안에서 의미가 있습니다.</p>
<p>트랜잭션 없이 Entity를 수정하면 변경 사항이 DB에 반영되지 않을 수 있습니다.</p>
<h3 id="꼬리질문-2-더티-체킹으로-발생하는-update-쿼리는-변경된-컬럼만-업데이트하나요-전체-컬럼을-업데이트하나요">꼬리질문 2. 더티 체킹으로 발생하는 UPDATE 쿼리는 변경된 컬럼만 업데이트하나요, 전체 컬럼을 업데이트하나요?</h3>
<p>기본적으로 JPA는 전체 컬럼을 업데이트하는 경우가 많습니다.</p>
<p>변경된 컬럼만 업데이트하고 싶다면 Hibernate의 <code>@DynamicUpdate</code>를 사용할 수 있습니다.</p>
<p>다만 무조건 사용하는 것이 좋은 것은 아니고, 상황에 따라 선택해야 합니다.</p>
<hr>
<h2 id="q14-즉시-로딩보다-지연-로딩을-권장하는-이유를-설명해주세요">Q14. 즉시 로딩보다 지연 로딩을 권장하는 이유를 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p>즉시 로딩은 Entity를 조회할 때 연관된 Entity까지 바로 조회합니다.</p>
<p>이로 인해 예상하지 못한 SQL이 실행되거나 성능 문제가 발생할 수 있습니다.</p>
<p>반면 지연 로딩은 실제로 연관 Entity가 필요할 때 조회하기 때문에 불필요한 조회를 줄일 수 있습니다.</p>
<p>그래서 실무에서는 기본적으로 <code>LAZY</code>를 권장합니다.</p>
<h3 id="꼬리질문-1-지연-로딩-상태에서-연관-entity에-접근할-때-트랜잭션이-없으면-어떤-예외가-발생하나요">꼬리질문 1. 지연 로딩 상태에서 연관 Entity에 접근할 때 트랜잭션이 없으면 어떤 예외가 발생하나요?</h3>
<p><code>LazyInitializationException</code>이 발생할 수 있습니다.</p>
<p>이는 영속성 컨텍스트가 닫힌 상태에서 프록시 객체를 초기화하려고 할 때 발생합니다.</p>
<h3 id="꼬리질문-2-n1-문제가-무엇이고-fetch-join으로-어떻게-해결하나요">꼬리질문 2. N+1 문제가 무엇이고, Fetch Join으로 어떻게 해결하나요?</h3>
<p>N+1 문제는 처음에 1번의 쿼리로 목록을 조회한 뒤, 각 Entity의 연관 데이터를 조회하기 위해 추가 쿼리가 N번 발생하는 문제입니다.</p>
<p>Fetch Join을 사용하면 처음 조회할 때 연관 Entity를 함께 가져와 추가 쿼리 발생을 줄일 수 있습니다.</p>
<hr>
<h1 id="인증-영역">인증 영역</h1>
<h2 id="q15-세션-방식과-jwt-방식의-차이를-서버-상태-관리와-확장성-관점에서-설명해주세요">Q15. 세션 방식과 JWT 방식의 차이를 서버 상태 관리와 확장성 관점에서 설명해주세요.</h2>
<p><strong>A.</strong></p>
<p>세션 방식은 서버가 로그인 상태를 저장하고, 클라이언트는 Session ID를 쿠키로 전달합니다.</p>
<p>JWT 방식은 로그인 정보를 담은 토큰을 클라이언트가 가지고 있고, 서버는 토큰을 검증해서 인증합니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>세션</th>
<th>JWT</th>
</tr>
</thead>
<tbody><tr>
<td>상태 관리</td>
<td>서버가 상태 저장</td>
<td>서버가 상태 저장하지 않음</td>
</tr>
<tr>
<td>확장성</td>
<td>세션 저장소 필요</td>
<td>상대적으로 확장 쉬움</td>
</tr>
<tr>
<td>무효화</td>
<td>쉬움</td>
<td>상대적으로 어려움</td>
</tr>
<tr>
<td>저장 위치</td>
<td>서버</td>
<td>클라이언트</td>
</tr>
</tbody></table>
<h3 id="꼬리질문-1-jwt-토큰이-탈취됐을-때-즉시-무효화하기-어려운-이유는-무엇인가요">꼬리질문 1. JWT 토큰이 탈취됐을 때 즉시 무효화하기 어려운 이유는 무엇인가요?</h3>
<p>JWT는 서버가 상태를 저장하지 않고 토큰 자체를 검증하는 방식입니다.</p>
<p>그래서 한 번 발급된 Access Token은 만료 시간 전까지 유효합니다.</p>
<p>즉시 무효화하려면 블랙리스트를 관리해야 하는데, 그러면 서버가 별도의 상태를 관리해야 합니다.</p>
<h3 id="꼬리질문-2-access-token과-refresh-token을-분리해서-사용하는-이유는-무엇인가요">꼬리질문 2. Access Token과 Refresh Token을 분리해서 사용하는 이유는 무엇인가요?</h3>
<p>Access Token은 API 요청에 사용되기 때문에 탈취 위험을 줄이기 위해 만료 시간을 짧게 가져갑니다.</p>
<p>Refresh Token은 Access Token을 재발급받기 위해 사용하며, 상대적으로 긴 만료 시간을 가집니다.</p>
<p>이렇게 분리하면 보안성과 사용자 편의성을 함께 챙길 수 있습니다.</p>
<hr>
<h1 id="마무리-정리">마무리 정리</h1>
<p>이번 면접 질문들은 Java 기본기부터 Spring, JPA, 인증 방식까지 백엔드 개발자가 반드시 이해해야 하는 핵심 개념들입니다.</p>
<p>단순히 키워드를 외우기보다는 다음 흐름으로 답변하는 연습이 중요합니다.</p>
<ol>
<li>개념을 한 문장으로 설명한다.</li>
<li>왜 사용하는지 말한다.</li>
<li>실제 프로젝트에서 어떻게 적용했는지 예시를 든다.</li>
<li>장단점이나 주의점을 함께 말한다.</li>
</ol>
<p>면접에서는 완벽한 암기보다, 개념을 실제 코드와 연결해서 설명하는 것이 중요합니다.
<img src="https://velog.velcdn.com/images/loaded_diaper/post/904443bb-0393-4b53-a185-e7b4fb5f714d/image.png" alt=""></p>
<p>출처: 내일배움 스파르타 클럽</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블슈팅] RedissonClient Bean 등록 후 테스트 빌드 실패 해결]]></title>
            <link>https://velog.io/@loaded_diaper/Troubleshooting-RedissonClient-Bean-%EB%93%B1%EB%A1%9D-%ED%9B%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B9%8C%EB%93%9C-%EC%8B%A4%ED%8C%A8-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@loaded_diaper/Troubleshooting-RedissonClient-Bean-%EB%93%B1%EB%A1%9D-%ED%9B%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%B9%8C%EB%93%9C-%EC%8B%A4%ED%8C%A8-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Tue, 23 Jun 2026 10:50:06 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>선착순 이벤트 기능에서 Redis Lock을 사용하기 위해 <code>RedisLockManager</code>를 구현했다.</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class RedisLockManager {

    private final RedissonClient redissonClient;
}</code></pre>
<p>하지만 이후 빌드를 실행하자 테스트 단계에서 실패했다.</p>
<pre><code class="language-bash">bash ./gradlew clean build</code></pre>
<p>처음 발생한 에러는 다음과 같았다.</p>
<pre><code class="language-text![](https://velog.velcdn.com/images/loaded_diaper/post/d38b840a-2703-4f59-9569-76d5b96884a2/image.png)">
Team8SaleCommerceApplicationTests &gt; contextLoads() FAILED
java.lang.IllegalStateException
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException</code></pre>
<h2 id="1차-원인">1차 원인</h2>
<p><code>RedisLockManager</code>는 <code>RedissonClient</code>를 생성자 주입으로 받고 있다.</p>
<p>그런데 프로젝트에 아직 <code>RedissonClient</code>를 Spring Bean으로 등록하는 설정 클래스가 없었다.</p>
<p>즉, Spring이 애플리케이션 컨텍스트를 로딩하면서 다음 흐름으로 실패했다.</p>
<pre><code class="language-text">RedisLockManager Bean 생성 시도
↓
RedissonClient 필요
↓
Spring Context 안에 RedissonClient Bean 없음
↓
NoSuchBeanDefinitionException 발생</code></pre>
<h2 id="1차-해결">1차 해결</h2>
<p><code>RedissonClient</code>를 Bean으로 등록하기 위해 <code>RedissonConfig</code>를 추가했다.</p>
<pre><code class="language-java">package com.example.team8salecommerce.global.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Redisson 설정 클래스
 *
 * Redis Lock을 사용하기 위해 RedissonClient를 Spring Bean으로 등록한다.
 */
@Configuration
public class RedissonConfig {

    @Value(&quot;${spring.data.redis.host:localhost}&quot;)
    private String redisHost;

    @Value(&quot;${spring.data.redis.port:6379}&quot;)
    private int redisPort;

    @Bean(destroyMethod = &quot;shutdown&quot;)
    public RedissonClient redissonClient() {
        Config config = new Config();

        config.useSingleServer()
                .setAddress(&quot;redis://&quot; + redisHost + &quot;:&quot; + redisPort);

        return Redisson.create(config);
    }
}</code></pre>
<p>이제 <code>RedissonClient</code> Bean은 정상적으로 등록되었다.</p>
<p>하지만 다시 빌드하자 또 다른 에러가 발생했다.</p>
<pre><code class="language-text">Caused by: org.redisson.client.RedisConnectionException
Caused by: java.net.ConnectException</code></pre>
<h2 id="2차-원인">2차 원인</h2>
<p>이번에는 Bean이 없어서 실패한 것이 아니라, 실제 Redis 서버 연결에 실패한 문제였다.</p>
<p><code>RedissonConfig</code>가 추가되면서 테스트 실행 시에도 실제 Redis 서버에 연결하려고 했다.</p>
<p>하지만 로컬에서 Redis가 실행 중이 아니었기 때문에 다음 흐름으로 실패했다.</p>
<pre><code class="language-text">Spring Context 로딩
↓
RedissonClient Bean 생성
↓
localhost:6379 Redis 연결 시도
↓
Redis 서버 실행 중 아님
↓
RedisConnectionException 발생</code></pre>
<p>즉, 문제의 핵심은 다음과 같았다.</p>
<pre><code class="language-text">실제 애플리케이션 실행에서는 Redis를 사용해야 한다.
하지만 contextLoads 테스트에서는 실제 Redis 서버 연결까지 필요하지 않다.</code></pre>
<p><code>contextLoads()</code> 테스트는 실제 Redis Lock 동작을 검증하는 테스트가 아니라, Spring 애플리케이션 컨텍스트가 정상적으로 로딩되는지만 확인하는 테스트이기 때문이다.</p>
<h2 id="최종-해결">최종 해결</h2>
<p>테스트 환경에서는 <code>RedissonClient</code>를 실제 Bean이 아니라 Mock Bean으로 대체했다.</p>
<pre><code class="language-java">package com.example.team8salecommerce;

import org.junit.jupiter.api.Test;
import org.redisson.api.RedissonClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

/**
 * 애플리케이션 컨텍스트 로딩 테스트
 *
 * RedisLockManager는 RedissonClient를 필요로 한다.
 * 하지만 contextLoads 테스트에서 실제 Redis 서버까지 연결할 필요는 없으므로
 * RedissonClient를 MockitoBean으로 대체한다.
 */
@SpringBootTest
class Team8SaleCommerceApplicationTests {

    /**
     * 테스트용 RedissonClient Mock Bean
     *
     * 실제 Redis 서버가 켜져 있지 않아도
     * Spring Context가 정상적으로 로딩될 수 있게 한다.
     */
    @MockitoBean
    private RedissonClient redissonClient;

    @Test
    void contextLoads() {
    }
}</code></pre>
<p>수정 후 다시 빌드를 실행했다.</p>
<pre><code class="language-bash">bash ./gradlew clean build</code></pre>
<p>결과적으로 빌드가 성공했다.</p>
<pre><code class="language-text">BUILD SUCCESSFUL</code></pre>
<h2 id="실제-redis를-안-쓰는-건가">실제 Redis를 안 쓰는 건가?</h2>
<p>아니다.</p>
<p>실제 프로젝트에서는 Redis를 사용한다.</p>
<p>다만 테스트 중 <code>contextLoads()</code>에서는 실제 Redis 서버에 연결할 필요가 없기 때문에 Mock Bean으로 대체한 것이다.</p>
<p>정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>Redis 사용 여부</th>
</tr>
</thead>
<tbody><tr>
<td>실제 서버 실행</td>
<td>실제 Redis 사용</td>
</tr>
<tr>
<td>선착순 구매 Redis Lock</td>
<td>실제 Redis 사용</td>
</tr>
<tr>
<td>결제 실패 재고 복구 Lock</td>
<td>실제 Redis 사용</td>
</tr>
<tr>
<td>환불 재고 복구 Lock</td>
<td>실제 Redis 사용</td>
</tr>
<tr>
<td>contextLoads 테스트</td>
<td>Mock RedissonClient 사용</td>
</tr>
</tbody></table>
<p>실제 기능 테스트나 Postman 시연에서는 Redis 서버를 실행해야 한다.</p>
<pre><code class="language-bash">docker run -d --name team8-sale-redis -p 6379:6379 redis:7-alpine</code></pre>
<p>이미 Redis 컨테이너가 존재한다면 다음 명령어로 실행할 수 있다.</p>
<pre><code class="language-bash">docker start team8-sale-redis</code></pre>
<h2 id="정리">정리</h2>
<p>이번 문제는 두 단계로 나눠서 발생했다.</p>
<p>첫 번째 문제는 <code>RedissonClient</code> Bean이 등록되어 있지 않아 발생한 <code>NoSuchBeanDefinitionException</code>이었다.
이를 해결하기 위해 <code>RedissonConfig</code>를 추가하여 <code>RedissonClient</code>를 Spring Bean으로 등록했다.</p>
<p>두 번째 문제는 테스트 실행 시 실제 Redis 서버에 연결하려다가 실패한 <code>RedisConnectionException</code>이었다.
이를 해결하기 위해 <code>contextLoads()</code> 테스트에서는 <code>RedissonClient</code>를 Mock Bean으로 대체했다.</p>
<p>결과적으로 실제 애플리케이션에서는 Redis Lock을 정상적으로 사용할 수 있고, 기본 빌드 테스트는 Redis 서버 실행 여부에 의존하지 않도록 개선할 수 있었다.</p>
<h2 id="배운-점">배운 점</h2>
<p>외부 인프라를 사용하는 Bean을 추가할 때는 실제 실행 환경과 테스트 환경을 분리해서 생각해야 한다.</p>
<p>Redis, Kafka, S3, 외부 API처럼 별도 서버나 외부 서비스가 필요한 의존성은 테스트에서 실제 연결이 필요하지 않은 경우 Mock 처리하거나 테스트 전용 설정을 따로 두는 것이 좋다.</p>
<p>이번 경험을 통해 단순히 Bean을 등록하는 것뿐 아니라, 테스트 환경에서 해당 Bean이 어떻게 동작하는지도 함께 고려해야 한다는 점을 배웠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] CH 5 플러스 Spring 과제 초기 설정 트러블슈팅]]></title>
            <link>https://velog.io/@loaded_diaper/Spring-CH-5-%ED%94%8C%EB%9F%AC%EC%8A%A4-Spring-%EA%B3%BC%EC%A0%9C-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@loaded_diaper/Spring-CH-5-%ED%94%8C%EB%9F%AC%EC%8A%A4-Spring-%EA%B3%BC%EC%A0%9C-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85</guid>
            <pubDate>Fri, 19 Jun 2026 01:31:07 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/loaded_diaper/post/466e12ef-298f-49f2-bc3d-f1dbf2fbd30c/image.png" alt=""></p>
<h2 id="들어가며">들어가며</h2>
<p>CH 5 플러스 Spring 과제를 진행하면서 본격적인 기능 구현에 들어가기 전, 프로젝트 실행 환경을 맞추는 과정에서 몇 가지 문제가 발생했다.</p>
<p>이번 과제는 기존 GitHub Repository를 fork한 뒤 local로 clone해서 진행하는 방식이었다.
프로젝트를 처음 실행하고 테스트를 돌리는 과정에서 Gradle Wrapper, Java 버전, JWT 설정값 관련 문제가 연속으로 발생했고, 이를 해결한 과정을 정리해보려고 한다.</p>
<hr>
<h2 id="1-gradle-wrapper-설정-파일-누락-문제">1. Gradle Wrapper 설정 파일 누락 문제</h2>
<h3 id="문제-상황">문제 상황</h3>
<p>프로젝트를 clone한 뒤 테스트를 실행했다.</p>
<pre><code class="language-bash">./gradlew test</code></pre>
<p>그런데 아래와 같은 에러가 발생했다.</p>
<pre><code class="language-text">Wrapper properties file &#39;.../gradle/wrapper/gradle-wrapper.properties&#39; does not exist.</code></pre>
<h3 id="원인">원인</h3>
<p>프로젝트에는 <code>gradlew</code> 파일과 <code>gradle-wrapper.jar</code> 파일은 존재했지만, Gradle Wrapper가 어떤 Gradle 버전을 사용할지 정의하는 <code>gradle-wrapper.properties</code> 파일이 없었다.</p>
<p>Gradle Wrapper가 정상적으로 동작하려면 보통 아래 구조가 필요하다.</p>
<pre><code class="language-text">gradle
└── wrapper
    ├── gradle-wrapper.jar
    └── gradle-wrapper.properties</code></pre>
<p>하지만 현재 프로젝트에는 <code>gradle-wrapper.properties</code>가 누락되어 있어서 <code>./gradlew</code> 명령어를 실행할 수 없었다.</p>
<h3 id="해결">해결</h3>
<p><code>gradle/wrapper/gradle-wrapper.properties</code> 파일을 직접 생성하고 Gradle 배포 URL을 설정했다.</p>
<pre><code class="language-properties">distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists</code></pre>
<p>이후 다시 Gradle 명령어를 실행하자 Wrapper 관련 에러는 해결되었다.</p>
<hr>
<h2 id="2-java-버전-불일치-문제">2. Java 버전 불일치 문제</h2>
<h3 id="문제-상황-1">문제 상황</h3>
<p>Gradle Wrapper 문제를 해결한 뒤 다시 테스트를 실행했다.</p>
<pre><code class="language-bash">./gradlew test</code></pre>
<p>이번에는 아래와 같은 에러가 발생했다.</p>
<pre><code class="language-text">Unsupported class file major version 67</code></pre>
<h3 id="원인-1">원인</h3>
<p><code>major version 67</code>은 Java 23에 해당한다.</p>
<p>현재 터미널에서 Java 23이 사용되고 있었고, 프로젝트와 Gradle 환경은 Java 17 기준으로 맞추는 것이 적절했다.
즉, 프로젝트에서 기대하는 Java 버전과 실제 터미널에서 사용하는 Java 버전이 달라서 문제가 발생한 것이다.</p>
<h3 id="해결-1">해결</h3>
<p>터미널과 IntelliJ 설정을 Java 17로 맞췄다.</p>
<p>터미널에서는 <code>JAVA_HOME</code>을 Java 17로 설정했다.</p>
<pre><code class="language-bash">export JAVA_HOME=$(/usr/libexec/java_home -v 17)
export PATH=&quot;$JAVA_HOME/bin:$PATH&quot;</code></pre>
<p>이후 확인했다.</p>
<pre><code class="language-bash">java -version</code></pre>
<p>또한 IntelliJ에서도 아래 설정을 Java 17로 맞췄다.</p>
<ul>
<li>Project SDK: Java 17</li>
<li>Gradle JVM: Java 17</li>
</ul>
<p>이후 Gradle daemon을 정리하고 다시 테스트를 실행했다.</p>
<pre><code class="language-bash">./gradlew --stop
./gradlew clean test</code></pre>
<p>Java 버전 문제는 해결되었다.</p>
<hr>
<h2 id="3-jwt-secret-key-누락-문제">3. JWT secret key 누락 문제</h2>
<h3 id="문제-상황-2">문제 상황</h3>
<p>Java 버전 문제를 해결한 뒤 테스트를 실행했지만, 이번에는 Spring Context 로딩 중 실패가 발생했다.</p>
<p>에러의 핵심은 다음과 같았다.</p>
<pre><code class="language-text">PropertyPlaceholderHelper</code></pre>
<p>프로젝트 코드를 확인해보니 <code>JwtUtil</code>에서 아래 설정값을 사용하고 있었다.</p>
<pre><code class="language-java">@Value(&quot;${jwt.secret.key}&quot;)
private String secretKey;</code></pre>
<h3 id="원인-2">원인</h3>
<p>테스트 환경에서 <code>jwt.secret.key</code> 값이 설정되어 있지 않았다.</p>
<p>Spring은 <code>${jwt.secret.key}</code> 값을 찾아 주입해야 하는데, 테스트 실행 시 해당 설정값을 찾지 못해 ApplicationContext 로딩에 실패했다.</p>
<h3 id="해결-2">해결</h3>
<p>테스트 전용 설정 파일을 생성했다.</p>
<pre><code class="language-text">src/test/resources/application.properties</code></pre>
<p>그리고 테스트용 JWT secret key를 추가했다.</p>
<p>처음에는 일반 문자열을 넣었지만, 이후 Base64 디코딩 과정에서 다시 오류가 발생했다.</p>
<pre><code class="language-text">IllegalArgumentException at Base64.java</code></pre>
<p><code>JwtUtil</code> 내부에서 secret key를 Base64로 디코딩하고 있었기 때문에, 일반 문자열이 아니라 Base64 형식의 값이 필요했다.</p>
<p>그래서 테스트용 값을 Base64 형식으로 수정했다.</p>
<pre><code class="language-properties">jwt.secret.key=c3BhcnRhLXNwcmluZy1wbHVzLXRlc3Qtand0LXNlY3JldC1rZXktMTIzNDU2Nzg5MA==</code></pre>
<p>이후 Spring Context가 정상적으로 로딩되었다.</p>
<hr>
<h2 id="4-controller-테스트-기대값-불일치-문제">4. Controller 테스트 기대값 불일치 문제</h2>
<h3 id="문제-상황-3">문제 상황</h3>
<p>초기 설정 문제를 해결한 뒤에도 아래 테스트가 실패했다.</p>
<pre><code class="language-text">todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다()</code></pre>
<p>테스트 코드를 확인해보니 서비스에서 예외를 던지도록 설정되어 있었다.</p>
<pre><code class="language-java">when(todoService.getTodo(todoId))
        .thenThrow(new InvalidRequestException(&quot;Todo not found&quot;));</code></pre>
<p>하지만 테스트의 기대 응답은 <code>200 OK</code>로 되어 있었다.</p>
<pre><code class="language-java">.andExpect(status().isOk())</code></pre>
<h3 id="원인-3">원인</h3>
<p><code>InvalidRequestException</code>은 <code>GlobalExceptionHandler</code>에서 <code>400 BAD_REQUEST</code>로 처리되고 있었다.</p>
<p>즉 실제 응답은 <code>400 BAD_REQUEST</code>인데, 테스트는 <code>200 OK</code>를 기대하고 있었기 때문에 실패한 것이다.</p>
<h3 id="해결-3">해결</h3>
<p>테스트 기대값을 실제 예외 처리 결과에 맞게 수정했다.</p>
<pre><code class="language-java">.andExpect(status().isBadRequest())
.andExpect(jsonPath(&quot;$.status&quot;).value(HttpStatus.BAD_REQUEST.name()))
.andExpect(jsonPath(&quot;$.code&quot;).value(HttpStatus.BAD_REQUEST.value()))
.andExpect(jsonPath(&quot;$.message&quot;).value(&quot;Todo not found&quot;));</code></pre>
<p>수정 후 테스트가 정상적으로 통과했다.</p>
<hr>
<h2 id="최종-결과">최종 결과</h2>
<p>위 문제들을 해결한 뒤 전체 테스트를 다시 실행했다.</p>
<pre><code class="language-bash">./gradlew clean test</code></pre>
<p>결과적으로 테스트가 정상적으로 통과했다.</p>
<pre><code class="language-text">BUILD SUCCESSFUL</code></pre>
<hr>
<h2 id="정리">정리</h2>
<p>이번 트러블슈팅을 통해 프로젝트를 시작할 때 기능 구현만큼이나 실행 환경을 정확히 맞추는 것이 중요하다는 것을 느꼈다.</p>
<p>특히 다음 내용을 다시 확인할 수 있었다.</p>
<ul>
<li>Gradle Wrapper는 <code>gradle-wrapper.properties</code> 파일이 필요하다.</li>
<li>프로젝트 Java 버전과 터미널/IDE Java 버전이 일치해야 한다.</li>
<li>테스트 환경에서도 필요한 설정값은 별도로 제공해야 한다.</li>
<li>JWT secret key처럼 내부에서 Base64 디코딩하는 값은 형식까지 맞춰야 한다.</li>
<li>테스트는 실제 예외 처리 정책과 기대값이 일치해야 한다.</li>
</ul>
<p>이번 과제에서는 초반 설정 문제를 해결한 뒤, 이후 필수 기능들을 하나씩 구현하며 전체 테스트를 통과시킬 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# 🛠️ [트러블슈팅] 전액 환불 시 `ORDER_004` 오류가 발생한 이유]]></title>
            <link>https://velog.io/@loaded_diaper/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EC%A0%84%EC%95%A1-%ED%99%98%EB%B6%88-%EC%8B%9C-ORDER004-%EC%98%A4%EB%A5%98%EA%B0%80-%EB%B0%9C%EC%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@loaded_diaper/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EC%A0%84%EC%95%A1-%ED%99%98%EB%B6%88-%EC%8B%9C-ORDER004-%EC%98%A4%EB%A5%98%EA%B0%80-%EB%B0%9C%EC%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Tue, 09 Jun 2026 07:21:08 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/loaded_diaper/post/8af95136-47e4-43a8-98f2-536be4d649ad/image.png" alt=""></p>
<h2 id="✅-문제-상황">✅ 문제 상황</h2>
<p>커머스 결제 시스템 프로젝트에서 <strong>주문 환불 API</strong>를 테스트하던 중, 요청 Body 형식은 올바르게 작성했는데 계속 아래와 같은 오류가 발생했다.</p>
<pre><code class="language-json">{
  &quot;code&quot;: &quot;ORDER_004&quot;,
  &quot;message&quot;: &quot;유효하지 않은 주문 상태입니다.&quot;
}</code></pre>
<p>Postman 요청은 다음과 같았다.</p>
<pre><code class="language-http">POST /api/orders/8/refunds</code></pre>
<pre><code class="language-json">{
  &quot;items&quot;: [
    {
      &quot;orderItemId&quot;: 8,
      &quot;quantity&quot;: 3
    }
  ],
  &quot;reason&quot;: &quot;단순 변심으로 인한 환불&quot;
}</code></pre>
<p>테스트 데이터도 정상적으로 준비되어 있었다.</p>
<pre><code class="language-text">orders.status = COMPLETED
payment.status = COMPLETED
order_item.quantity = 3</code></pre>
<p>즉, 이미 결제가 완료된 주문이었고, 전액 환불을 진행할 수 있는 상태라고 생각했다.</p>
<p>그런데 계속 <code>ORDER_004</code>가 발생했다.</p>
<hr>
<h2 id="🔍-원인-분석">🔍 원인 분석</h2>
<p>처음에는 Request Body 형식이 잘못된 줄 알았다.</p>
<p>하지만 Body 구조는 DTO와 일치했다.</p>
<pre><code class="language-json">{
  &quot;items&quot;: [
    {
      &quot;orderItemId&quot;: 8,
      &quot;quantity&quot;: 3
    }
  ],
  &quot;reason&quot;: &quot;단순 변심으로 인한 환불&quot;
}</code></pre>
<p>그래서 오류 코드를 기준으로 검색해보았다.</p>
<pre><code class="language-bash">grep -R &quot;INVALID_ORDER_STATUS&quot; -n src/main/java</code></pre>
<p>검색 결과, <code>Order.java</code> 안에서 <code>INVALID_ORDER_STATUS</code>가 발생하고 있었다.</p>
<pre><code class="language-text">src/main/java/com/paymentsystemproject/domain/order/entity/Order.java:92</code></pre>
<p>문제는 기존 <code>Order.cancel()</code> 메서드였다.</p>
<pre><code class="language-java">public void cancel() {
    if (this.status != OrderStatus.PENDING_PAYMENT) {
        throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS);
    }

    this.status = OrderStatus.CANCELLED;
}</code></pre>
<p>기존 <code>cancel()</code> 메서드는 <strong>결제 대기 상태 주문 취소</strong>를 위한 메서드였다.</p>
<p>즉, 아래 흐름만 허용하고 있었다.</p>
<pre><code class="language-text">PENDING_PAYMENT → CANCELLED</code></pre>
<p>하지만 전액 환불은 이미 결제가 완료된 주문에서 발생한다.</p>
<p>전액 환불의 상태 전이는 아래와 같아야 한다.</p>
<pre><code class="language-text">COMPLETED → CANCELLED</code></pre>
<p>결국 문제는 이것이었다.</p>
<blockquote>
<p>주문 직접 취소와 전액 환불 취소가 같은 <code>order.cancel()</code> 메서드를 공유하고 있었다.</p>
</blockquote>
<hr>
<h2 id="💥-실제-오류-흐름">💥 실제 오류 흐름</h2>
<p>전액 환불 요청이 들어오면 내부 흐름은 다음과 같았다.</p>
<pre><code class="language-text">환불 요청
→ 전체 환불 여부 판단
→ Payment 상태를 REFUNDED로 변경
→ Order 상태를 CANCELLED로 변경하기 위해 order.cancel() 호출
→ order.cancel()은 PENDING_PAYMENT만 허용
→ 현재 주문 상태는 COMPLETED
→ INVALID_ORDER_STATUS 발생</code></pre>
<p>즉, 전액 환불 로직 자체가 틀렸다기보다
<strong>주문 상태를 변경하는 메서드의 책임이 너무 넓게 묶여 있었던 것</strong>이 문제였다.</p>
<hr>
<h2 id="✅-해결-방법">✅ 해결 방법</h2>
<p>취소 목적에 따라 메서드를 분리했다.</p>
<p>주문 취소는 크게 두 가지 상황이 있다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>시작 상태</th>
<th>변경 상태</th>
</tr>
</thead>
<tbody><tr>
<td>사용자가 결제 전 주문 취소</td>
<td><code>PENDING_PAYMENT</code></td>
<td><code>CANCELLED</code></td>
</tr>
<tr>
<td>결제 완료 주문을 전액 환불</td>
<td><code>COMPLETED</code></td>
<td><code>CANCELLED</code></td>
</tr>
</tbody></table>
<p>둘 다 결과는 <code>CANCELLED</code>지만, <strong>출발 상태가 다르기 때문에 하나의 메서드로 처리하면 안 된다.</strong></p>
<p>그래서 <code>Order</code> 엔티티에 메서드를 분리했다.</p>
<hr>
<h2 id="🔧-수정-코드">🔧 수정 코드</h2>
<h3 id="기존-코드">기존 코드</h3>
<pre><code class="language-java">public void cancel() {
    if (this.status != OrderStatus.PENDING_PAYMENT) {
        throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS);
    }

    this.status = OrderStatus.CANCELLED;
}</code></pre>
<hr>
<h3 id="수정-후-코드">수정 후 코드</h3>
<pre><code class="language-java">/**
 * 결제 대기 상태의 주문을 회원이 직접 취소합니다.
 */
public void cancelPendingOrder() {
    if (this.status != OrderStatus.PENDING_PAYMENT) {
        throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS);
    }

    this.status = OrderStatus.CANCELLED;
}

/**
 * 전액 환불로 인해 주문을 취소 상태로 변경합니다.
 */
public void cancelByFullRefund() {
    if (this.status != OrderStatus.COMPLETED) {
        throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS);
    }

    this.status = OrderStatus.CANCELLED;
}</code></pre>
<hr>
<h2 id="🔁-호출부-수정">🔁 호출부 수정</h2>
<h3 id="주문-직접-취소-api">주문 직접 취소 API</h3>
<p>기존에는 주문 취소에서도 <code>order.cancel()</code>을 사용하고 있었다.</p>
<pre><code class="language-java">order.cancel();</code></pre>
<p>이를 주문 직접 취소 전용 메서드로 변경했다.</p>
<pre><code class="language-java">order.cancelPendingOrder();</code></pre>
<hr>
<h3 id="전액-환불-처리">전액 환불 처리</h3>
<p>전액 환불 시에는 아래 메서드를 사용하도록 수정했다.</p>
<pre><code class="language-java">order.cancelByFullRefund();</code></pre>
<p>최종적으로 전액 환불 흐름은 다음과 같이 정리되었다.</p>
<pre><code class="language-text">RefundService
→ 전체 환불 판단
→ Payment 상태를 REFUNDED로 변경
→ OrderService.cancelByFullRefund()
→ order.cancelByFullRefund()
→ COMPLETED 주문을 CANCELLED로 변경</code></pre>
<hr>
<h2 id="✅-테스트-결과">✅ 테스트 결과</h2>
<p>수정 후 다시 Postman으로 전액 환불 요청을 보냈다.</p>
<pre><code class="language-http">POST /api/orders/8/refunds</code></pre>
<pre><code class="language-json">{
  &quot;items&quot;: [
    {
      &quot;orderItemId&quot;: 8,
      &quot;quantity&quot;: 3
    }
  ],
  &quot;reason&quot;: &quot;단순 변심으로 인한 환불&quot;
}</code></pre>
<p>이후 DB를 확인했다.</p>
<pre><code class="language-sql">SELECT id, status
FROM orders
WHERE id = 8;

SELECT id, status
FROM payment
WHERE order_id = 8;</code></pre>
<p>결과는 정상적으로 변경되었다.</p>
<pre><code class="language-text">orders.status = CANCELLED
payment.status = REFUNDED</code></pre>
<p>상품 재고도 환불 수량만큼 정상 복구되었다.</p>
<p>또한 빌드도 성공했다.</p>
<pre><code class="language-bash">sh ./gradlew clean compileJava</code></pre>
<hr>
<h2 id="📌-배운-점">📌 배운 점</h2>
<p>이번 문제를 통해 <strong>같은 결과 상태를 만들더라도, 상태 전이의 출발점이 다르면 메서드를 분리해야 한다</strong>는 점을 배웠다.</p>
<p>처음에는 단순히 주문을 취소하는 행위라고 생각해서 <code>cancel()</code> 하나로 처리했다.</p>
<p>하지만 실제 도메인에서는 다음 두 흐름이 전혀 다르다.</p>
<pre><code class="language-text">결제 전 주문 취소
PENDING_PAYMENT → CANCELLED</code></pre>
<pre><code class="language-text">결제 완료 후 전액 환불
COMPLETED → CANCELLED</code></pre>
<p>둘 다 최종 상태는 <code>CANCELLED</code>지만, 의미와 검증 조건이 다르다.</p>
<p>그래서 도메인 메서드는 단순히 “결과 상태”만 기준으로 만들면 안 되고,
<strong>어떤 비즈니스 상황에서 호출되는 메서드인지 이름과 검증 조건에 드러나야 한다.</strong></p>
<hr>
<h2 id="✅-정리">✅ 정리</h2>
<p>이번 오류의 핵심은 Request Body 문제가 아니었다.</p>
<p>문제의 원인은 다음과 같았다.</p>
<blockquote>
<p>주문 직접 취소와 전액 환불 취소가 같은 <code>order.cancel()</code> 메서드를 공유하면서,
<code>COMPLETED</code> 주문의 전액 환불까지 <code>PENDING_PAYMENT</code> 검증에 걸려 실패한 문제</p>
</blockquote>
<p>해결은 다음과 같이 진행했다.</p>
<pre><code class="language-text">order.cancel()
→ cancelPendingOrder()
→ cancelByFullRefund()</code></pre>
<p>이를 통해 주문 직접 취소와 전액 환불의 상태 전이 책임을 명확히 분리할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# [Spring Boot + Docker MySQL] Access denied for user 'root'@'localhost' 해결 — 원인은 비밀번호가 아니라 포트 충돌이었다]]></title>
            <link>https://velog.io/@loaded_diaper/Spring-Boot-Docker-MySQL-Access-denied-for-user-rootlocalhost-%ED%95%B4%EA%B2%B0-%EC%9B%90%EC%9D%B8%EC%9D%80-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%ED%8F%AC%ED%8A%B8-%EC%B6%A9%EB%8F%8C%EC%9D%B4%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@loaded_diaper/Spring-Boot-Docker-MySQL-Access-denied-for-user-rootlocalhost-%ED%95%B4%EA%B2%B0-%EC%9B%90%EC%9D%B8%EC%9D%80-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%ED%8F%AC%ED%8A%B8-%EC%B6%A9%EB%8F%8C%EC%9D%B4%EC%97%88%EB%8B%A4</guid>
            <pubDate>Wed, 27 May 2026 11:35:11 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/loaded_diaper/post/803d7388-986a-4ba1-b708-ea426daba02c/image.png" alt="">
지피티랑 1시간 넘게 싸웠는데 알고 보니 포트 번호 문제;;</p>
<hr>
<h2 id="문제-상황">문제 상황</h2>
<p>Spring Boot 프로젝트에서 Docker로 MySQL을 띄운 뒤 애플리케이션을 실행하려고 했다.</p>
<p>처음 설정은 다음과 같았다.</p>
<h3 id="docker-composeyml">docker-compose.yml</h3>
<pre><code class="language-yml">services:
  mysql:
    image: mysql:8
    container_name: sparta-payment-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root1234
      MYSQL_DATABASE: payment_db
    ports:
      - &quot;3306:3306&quot;
    volumes:
      - mysql_data:/var/lib/mysql
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

volumes:
  mysql_data:</code></pre>
<h3 id="applicationyaml">application.yaml</h3>
<pre><code class="language-yml">spring:
  datasource:
    url: jdbc:mysql://localhost:3306/payment_db?useSSL=false&amp;allowPublicKeyRetrieval=true&amp;serverTimezone=Asia/Seoul
    username: root
    password: root1234
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MySQLDialect</code></pre>
<p>하지만 Spring Boot를 실행하면 계속 아래 에러가 발생했다.</p>
<pre><code class="language-text">Access denied for user &#39;root&#39;@&#39;localhost&#39; (using password: YES)</code></pre>
<p>최종적으로는 JPA가 DB 커넥션을 얻지 못해 <code>entityManagerFactory</code> 생성에 실패했고, 애플리케이션 실행이 중단됐다.</p>
<pre><code class="language-text">Failed to initialize JPA EntityManagerFactory
Unable to open JDBC Connection for DDL execution
Application run failed</code></pre>
<hr>
<h2 id="처음에-의심한-것-root-비밀번호-문제">처음에 의심한 것: root 비밀번호 문제</h2>
<p>에러 메시지만 보면 가장 먼저 의심되는 것은 MySQL 비밀번호였다.</p>
<pre><code class="language-text">Access denied for user &#39;root&#39;@&#39;localhost&#39;</code></pre>
<p>그래서 처음에는 <code>root</code> 계정의 비밀번호가 잘못되었거나, IntelliJ가 예전 비밀번호를 저장하고 있다고 생각했다.</p>
<p>내가 확인했던 비밀번호 후보는 다음과 같았다.</p>
<pre><code class="language-text">root1234
dlatjsrn1!</code></pre>
<p>먼저 Docker 컨테이너 안에서 root 계정으로 접속해봤다.</p>
<pre><code class="language-bash">docker exec -it sparta-payment-mysql mysql -uroot -p</code></pre>
<p>비밀번호로 <code>root1234</code>를 입력하니 정상 접속됐다.</p>
<pre><code class="language-text">Welcome to the MySQL monitor.</code></pre>
<p>그리고 DB도 정상적으로 선택됐다.</p>
<pre><code class="language-sql">USE payment_db;
SHOW TABLES;</code></pre>
<p>결과는 다음과 같았다.</p>
<pre><code class="language-text">Database changed
Empty set</code></pre>
<p>즉, Docker 컨테이너 내부의 MySQL에서는 <code>root / root1234</code>가 정상적으로 동작했다.</p>
<hr>
<h2 id="root-비밀번호-직접-확인">root 비밀번호 직접 확인</h2>
<p>그래도 확실히 하기 위해 명령어로 비밀번호를 직접 검증했다.</p>
<pre><code class="language-bash">docker exec -i sparta-payment-mysql mysql -uroot -proot1234 -e &quot;SELECT &#39;root1234 OK&#39;;&quot;</code></pre>
<p>결과는 성공이었다.</p>
<pre><code class="language-text">root1234 OK
root1234 OK</code></pre>
<p>반대로 예전에 다른 프로젝트에서 쓰던 비밀번호도 확인했다.</p>
<pre><code class="language-bash">docker exec -i sparta-payment-mysql mysql -uroot -p&#39;dlatjsrn1!&#39; -e &quot;SELECT &#39;old password OK&#39;;&quot;</code></pre>
<p>결과는 실패였다.</p>
<pre><code class="language-text">ERROR 1045 (28000): Access denied for user &#39;root&#39;@&#39;localhost&#39; (using password: YES)</code></pre>
<p>따라서 현재 Docker MySQL의 root 비밀번호는 <code>root1234</code>가 맞았다.</p>
<hr>
<h2 id="헷갈렸던-부분-터미널에서는-되는데-spring-boot는-안-됨">헷갈렸던 부분: 터미널에서는 되는데 Spring Boot는 안 됨</h2>
<p>이상했던 점은 터미널에서는 접속이 되는데 Spring Boot에서는 계속 실패한다는 점이었다.</p>
<p>컨테이너 내부에서는 아래 명령어가 정상 동작했다.</p>
<pre><code class="language-bash">docker exec -it sparta-payment-mysql mysql -uroot -p</code></pre>
<p>하지만 Spring Boot 실행 로그에서는 계속 아래 에러가 발생했다.</p>
<pre><code class="language-text">Access denied for user &#39;root&#39;@&#39;localhost&#39; (using password: YES)</code></pre>
<p>처음에는 이 차이를 이해하지 못해서 다음과 같은 것들을 의심했다.</p>
<pre><code class="language-text">1. application.yaml 비밀번호 오타
2. IntelliJ Database 탭의 비밀번호 캐시 문제
3. MySQL root 계정 권한 문제
4. Docker 볼륨에 예전 비밀번호가 남아있는 문제</code></pre>
<p>그래서 <code>payment_user</code>라는 새 계정도 만들어보고, 권한도 부여해봤다.</p>
<pre><code class="language-sql">CREATE USER &#39;payment_user&#39;@&#39;%&#39; IDENTIFIED BY &#39;payment1234&#39;;
GRANT ALL PRIVILEGES ON payment_db.* TO &#39;payment_user&#39;@&#39;%&#39;;
FLUSH PRIVILEGES;</code></pre>
<p>하지만 이것도 근본 해결이 아니었다.</p>
<p>나중에 확인해보니 문제는 계정이 아니었다.</p>
<hr>
<h2 id="intellij-database-연결도-실패">IntelliJ Database 연결도 실패</h2>
<p>IntelliJ 오른쪽 Database 탭에서도 MySQL 연결을 시도했다.</p>
<p>처음에는 다음과 같이 설정했다.</p>
<pre><code class="language-text">Host: localhost
Port: 3306
User: root
Password: root1234
Database: payment_db</code></pre>
<p>하지만 IntelliJ Database에서도 계속 연결이 실패했다.</p>
<pre><code class="language-text">Access denied for user &#39;root&#39;@&#39;localhost&#39; (using password: YES)</code></pre>
<p>이때까지만 해도 계속 비밀번호나 계정 문제라고 생각했다.</p>
<p>하지만 Docker 컨테이너 안에서는 <code>root / root1234</code>가 정상 접속되기 때문에 앞뒤가 맞지 않았다.</p>
<hr>
<h2 id="applicationyaml-확인">application.yaml 확인</h2>
<p>설정 파일이 실제로 반영됐는지도 확인했다.</p>
<p>처음에는 파일명을 <code>application.yml</code>로 착각해서 확인했다.</p>
<pre><code class="language-bash">cat src/main/resources/application.yml | grep &quot;jdbc:mysql&quot;</code></pre>
<p>하지만 실제 파일명은 <code>application.yaml</code>이었다.</p>
<pre><code class="language-bash">find . -name &quot;application.yml&quot; -o -name &quot;application.yaml&quot; -o -name &quot;application.properties&quot;</code></pre>
<p>결과는 다음과 같았다.</p>
<pre><code class="language-text">./build/resources/main/application.yaml
./src/main/resources/application.yaml</code></pre>
<p>그래서 실제 파일을 다시 확인했다.</p>
<pre><code class="language-bash">cat src/main/resources/application.yaml | grep &quot;jdbc:mysql&quot;
cat build/resources/main/application.yaml | grep &quot;jdbc:mysql&quot;</code></pre>
<p>둘 다 정상적으로 같은 설정을 가지고 있었다.</p>
<pre><code class="language-text">url: jdbc:mysql://127.0.0.1:3306/payment_db?useSSL=false&amp;allowPublicKeyRetrieval=true&amp;serverTimezone=Asia/Seoul</code></pre>
<p>즉, Spring Boot 설정 파일 문제도 아니었다.</p>
<hr>
<h2 id="root-계정-host-권한-확인">root 계정 host 권한 확인</h2>
<p>혹시 root 계정이 외부 접속을 허용하지 않는 문제일 수도 있어서 MySQL 계정의 host 값을 확인했다.</p>
<pre><code class="language-bash">docker exec -i sparta-payment-mysql mysql -uroot -proot1234 -e &quot;SELECT user, host FROM mysql.user WHERE user = &#39;root&#39;;&quot;</code></pre>
<p>결과는 다음과 같았다.</p>
<pre><code class="language-text">user    host
root    %
root    localhost</code></pre>
<p><code>root@%</code>가 있다는 것은 외부 접속도 허용된다는 뜻이다.</p>
<p>따라서 root 계정 권한 문제도 아니었다.</p>
<hr>
<h2 id="진짜-원인-확인-3306-포트-충돌">진짜 원인 확인: 3306 포트 충돌</h2>
<p>결국 3306 포트를 누가 사용하고 있는지 확인했다.</p>
<pre><code class="language-bash">lsof -iTCP:3306 -sTCP:LISTEN -n -P</code></pre>
<p>결과는 다음과 같았다.</p>
<pre><code class="language-text">COMMAND     PID    USER     NAME
mysqld      1529   imsungu  TCP 127.0.0.1:3306 (LISTEN)
com.docke   52511  imsungu  TCP *:3306 (LISTEN)</code></pre>
<p>여기서 핵심은 이 부분이었다.</p>
<pre><code class="language-text">mysqld ... TCP 127.0.0.1:3306 (LISTEN)</code></pre>
<p>내 Mac에 예전에 설치되어 있던 로컬 MySQL이 이미 <code>127.0.0.1:3306</code>을 사용하고 있었다.</p>
<p>즉, Spring Boot가 아래 주소로 접속할 때,</p>
<pre><code class="language-yml">jdbc:mysql://127.0.0.1:3306/payment_db</code></pre>
<p>내가 의도한 Docker MySQL이 아니라 Mac 로컬 MySQL로 접속하고 있었던 것이다.</p>
<p>그래서 Docker MySQL의 비밀번호인 <code>root1234</code>를 넣어도 Mac 로컬 MySQL에서는 맞지 않기 때문에 계속 로그인 실패가 발생했다.</p>
<pre><code class="language-text">Access denied for user &#39;root&#39;@&#39;localhost&#39;</code></pre>
<hr>
<h2 id="원인-정리">원인 정리</h2>
<p>문제의 흐름은 다음과 같았다.</p>
<pre><code class="language-text">1. Mac 로컬 MySQL이 127.0.0.1:3306 사용 중
2. Docker MySQL도 3306으로 열려고 함
3. Spring Boot는 127.0.0.1:3306으로 접속
4. 실제로는 Docker MySQL이 아니라 Mac 로컬 MySQL에 접속
5. Docker용 비밀번호 root1234를 넣음
6. Mac 로컬 MySQL 입장에서는 틀린 비밀번호
7. Access denied 발생</code></pre>
<p>즉, 원인은 비밀번호가 아니라 포트 충돌이었다.</p>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<p>Docker MySQL의 외부 포트를 <code>3306</code>에서 <code>3307</code>로 변경했다.</p>
<h3 id="docker-composeyml-수정-전">docker-compose.yml 수정 전</h3>
<pre><code class="language-yml">ports:
  - &quot;3306:3306&quot;</code></pre>
<h3 id="docker-composeyml-수정-후">docker-compose.yml 수정 후</h3>
<pre><code class="language-yml">ports:
  - &quot;3307:3306&quot;</code></pre>
<p>최종 <code>docker-compose.yml</code>은 다음과 같다.</p>
<pre><code class="language-yml">services:
  mysql:
    image: mysql:8
    container_name: sparta-payment-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root1234
      MYSQL_DATABASE: payment_db
      MYSQL_ROOT_HOST: &#39;%&#39;
    ports:
      - &quot;3307:3306&quot;
    volumes:
      - mysql_data:/var/lib/mysql
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

volumes:
  mysql_data:</code></pre>
<p>그리고 Spring Boot의 <code>application.yaml</code>도 <code>3307</code>로 변경했다.</p>
<pre><code class="language-yml">spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3307/payment_db?useSSL=false&amp;allowPublicKeyRetrieval=true&amp;serverTimezone=Asia/Seoul
    username: root
    password: root1234
    driver-class-name: com.mysql.cj.jdbc.Driver</code></pre>
<hr>
<h2 id="docker-재실행">Docker 재실행</h2>
<p>설정 변경 후 Docker 컨테이너를 재실행했다.</p>
<pre><code class="language-bash">docker compose down
docker compose up -d</code></pre>
<p>이미 데이터 초기화가 필요한 상황은 아니었기 때문에 <code>-v</code>는 붙이지 않았다.</p>
<pre><code class="language-bash">docker compose down -v</code></pre>
<p><code>-v</code>를 붙이면 볼륨까지 삭제되어 DB 데이터가 초기화된다.</p>
<hr>
<h2 id="intellij-database-연결도-다시-설정">IntelliJ Database 연결도 다시 설정</h2>
<p>IntelliJ Database 탭에서도 기존 <code>localhost:3306</code> 연결은 삭제하고 새로 만들었다.</p>
<p>새 연결 정보는 다음과 같다.</p>
<pre><code class="language-text">Host: 127.0.0.1
Port: 3307
User: root
Password: root1234
Database: payment_db</code></pre>
<p>URL은 다음과 같이 잡혔다.</p>
<pre><code class="language-text">jdbc:mysql://127.0.0.1:3307/payment_db</code></pre>
<p>그 결과 Test Connection이 성공했다.</p>
<pre><code class="language-text">Succeeded
DBMS: MySQL 8.4.9</code></pre>
<p>드디어 IntelliJ Database 탭에서도 <code>payment_db</code>가 정상적으로 보이기 시작했다.</p>
<hr>
<h2 id="최종-정리">최종 정리</h2>
<p>이번 문제는 단순한 비밀번호 문제가 아니었다.</p>
<p>처음에는 root 비밀번호, MySQL 계정 권한, IntelliJ 비밀번호 저장 문제라고 생각해서 한참을 헤맸다.</p>
<p>하지만 실제 원인은 Mac 로컬 MySQL과 Docker MySQL이 같은 <code>3306</code> 포트를 사용하면서 발생한 포트 충돌이었다.</p>
<pre><code class="language-text">Mac 로컬 MySQL  → 127.0.0.1:3306 사용 중
Docker MySQL    → 원래 3306으로 열려고 함
Spring Boot     → 127.0.0.1:3306으로 접속
결과             → Docker가 아니라 Mac 로컬 MySQL로 접속</code></pre>
<p>해결 후 구조는 다음과 같다.</p>
<pre><code class="language-text">Mac 로컬 MySQL       → 127.0.0.1:3306
Docker MySQL 외부접속 → 127.0.0.1:3307
Docker 내부 MySQL    → 3306</code></pre>
<p>즉, 내 Mac에서 Docker MySQL에 접속할 때는 <code>3307</code>을 사용해야 하고, Docker 컨테이너 내부에서는 여전히 <code>3306</code>을 사용한다.</p>
<hr>
<h2 id="배운-점">배운 점</h2>
<p><code>docker exec</code>로 MySQL 접속이 성공한다고 해서 Spring Boot도 같은 MySQL에 접속하고 있다고 단정하면 안 된다.</p>
<pre><code class="language-bash">docker exec -it sparta-payment-mysql mysql -uroot -p</code></pre>
<p>이 명령어는 컨테이너 내부에서 MySQL에 접속하는 것이다.</p>
<p>반면 Spring Boot는 Mac에서 Docker MySQL에 접속한다.</p>
<p>그래서 로컬 PC에 이미 MySQL이 설치되어 있고 <code>3306</code> 포트를 사용 중이라면, Spring Boot가 Docker MySQL이 아니라 로컬 MySQL로 접속할 수 있다.</p>
<p>포트 충돌이 의심될 때는 아래 명령어로 확인하자.</p>
<pre><code class="language-bash">lsof -iTCP:3306 -sTCP:LISTEN -n -P</code></pre>
<hr>
<h2 id="한-줄-요약">한 줄 요약</h2>
<blockquote>
<p>Docker MySQL 비밀번호 문제가 아니라, Mac 로컬 MySQL이 3306 포트를 이미 사용하고 있어서 Spring Boot와 IntelliJ Database가 Docker가 아닌 로컬 MySQL에 접속하던 문제였다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[CH4. 클라우드 아키택처 설계 & 배포 트러블 슈팅]]></title>
            <link>https://velog.io/@loaded_diaper/CH4.-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EC%95%84%ED%82%A4%ED%83%9D%EC%B2%98-%EC%84%A4%EA%B3%84-%EB%B0%B0%ED%8F%AC-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85</link>
            <guid>https://velog.io/@loaded_diaper/CH4.-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C-%EC%95%84%ED%82%A4%ED%83%9D%EC%B2%98-%EC%84%A4%EA%B3%84-%EB%B0%B0%ED%8F%AC-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85</guid>
            <pubDate>Tue, 26 May 2026 01:16:07 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/loaded_diaper/post/c69bf062-3649-4326-bf89-8fad8b15989d/image.png" alt=""></p>
<h1 id="aws-rds-mysql-접속-중-unknown-database-3306-오류-해결">[AWS RDS] MySQL 접속 중 Unknown database &#39;3306&#39; 오류 해결</h1>
<h2 id="문제-상황">문제 상황</h2>
<p>AWS RDS에 MySQL 데이터베이스를 생성한 뒤, EC2에서 RDS에 접속 테스트를 진행했다.</p>
<p>RDS Endpoint는 정상적으로 복사했고, EC2에서 MySQL 클라이언트도 설치한 상태였다.</p>
<p>처음에는 아래 명령어로 접속을 시도했다.</p>
<pre><code class="language-bash">mysql -h team-profile-rds.c50wseaoq4uw.ap-northeast-2.rds.amazonaws.com -p 3306 -u admin -p</code></pre>
<p>그런데 다음과 같은 에러가 발생했다.</p>
<pre><code class="language-bash">ERROR 1049 (42000): Unknown database &#39;3306&#39;</code></pre>
<p>처음에는 RDS 보안 그룹 문제인지, 데이터베이스가 제대로 생성되지 않은 문제인지 헷갈렸다.</p>
<hr>
<h2 id="원인-분석">원인 분석</h2>
<p>에러 메시지를 보면 다음과 같이 나온다.</p>
<pre><code class="language-bash">Unknown database &#39;3306&#39;</code></pre>
<p>즉, MySQL은 <code>3306</code>을 포트 번호가 아니라 <strong>데이터베이스 이름</strong>으로 해석하고 있었다.</p>
<p>문제는 접속 명령어에서 사용한 옵션이었다.</p>
<p>내가 작성한 명령어는 다음과 같았다.</p>
<pre><code class="language-bash">mysql -h RDS_ENDPOINT -p 3306 -u admin -p</code></pre>
<p>여기서 실수한 부분은 이 옵션이다.</p>
<pre><code class="language-bash">-p 3306</code></pre>
<p>MySQL 명령어에서:</p>
<pre><code class="language-text">-p  → password 옵션
-P  → port 옵션</code></pre>
<p>즉, 포트 번호를 지정하려면 소문자 <code>-p</code>가 아니라 <strong>대문자 <code>-P</code></strong> 를 사용해야 한다.</p>
<p>나는 포트 번호 <code>3306</code>을 지정하려고 했지만, 소문자 <code>-p</code>를 사용해서 MySQL이 명령어를 잘못 해석한 것이다.</p>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<p>포트 번호 옵션을 대문자 <code>-P</code>로 수정했다.</p>
<pre><code class="language-bash">mysql -h team-profile-rds.c50wseaoq4uw.ap-northeast-2.rds.amazonaws.com -P 3306 -u admin -p</code></pre>
<p>수정 후 비밀번호를 입력하자 정상적으로 접속되었다.</p>
<pre><code class="language-bash">Welcome to the MariaDB monitor.
Commands end with ; or \g.

MySQL [(none)]&gt;</code></pre>
<p>그다음 데이터베이스 목록을 확인했다.</p>
<pre><code class="language-sql">SHOW DATABASES;</code></pre>
<p>결과는 다음과 같았다.</p>
<pre><code class="language-text">+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| teamprofiledb      |
+--------------------+</code></pre>
<p><code>teamprofiledb</code>가 정상적으로 보이는 것을 확인했다.</p>
<hr>
<h2 id="최종-정리">최종 정리</h2>
<h3 id="잘못된-명령어">잘못된 명령어</h3>
<pre><code class="language-bash">mysql -h RDS_ENDPOINT -p 3306 -u admin -p</code></pre>
<h3 id="올바른-명령어">올바른 명령어</h3>
<pre><code class="language-bash">mysql -h RDS_ENDPOINT -P 3306 -u admin -p</code></pre>
<hr>
<h2 id="배운-점">배운 점</h2>
<p>이번 문제는 RDS 설정이나 보안 그룹 문제가 아니라, MySQL 접속 명령어 옵션을 잘못 사용해서 발생한 문제였다.</p>
<p>특히 MySQL CLI에서는 소문자와 대문자 옵션이 다른 의미를 가진다.</p>
<pre><code class="language-text">-p : 비밀번호 입력 옵션
-P : 포트 번호 지정 옵션</code></pre>
<p>앞으로 MySQL에 접속할 때는 포트 번호를 지정할 경우 반드시 대문자 <code>-P</code>를 사용해야 한다.</p>
<p>또한 에러 메시지에서 <code>Unknown database &#39;3306&#39;</code>이라고 나왔기 때문에, MySQL이 <code>3306</code>을 포트가 아닌 데이터베이스 이름으로 해석했다는 점을 통해 원인을 추적할 수 있었다.</p>
]]></description>
        </item>
    </channel>
</rss>