<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Loopy.log</title>
        <link>https://velog.io/</link>
        <description>개인용으로 공부하는 공간입니다. 피드백 환영합니다 🙂</description>
        <lastBuildDate>Fri, 03 Apr 2026 15:42:08 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Loopy.log</title>
            <url>https://velog.velcdn.com/images/semi-cloud/profile/57df21b6-bd69-4219-ac3b-1fd40380a039/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Loopy.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/semi-cloud" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[AI] Claude 활용 컨텍스트 엔지니어링으로 생산성 높이기 ]]></title>
            <link>https://velog.io/@semi-cloud/TIL-Claude-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4%EB%A7%81%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0</link>
            <guid>https://velog.io/@semi-cloud/TIL-Claude-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4%EB%A7%81%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0</guid>
            <pubDate>Fri, 03 Apr 2026 15:42:08 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>컨텍스트 엔지니어링을 넘은 하네스 엔지니어링에 맞추어 회사에서도 Claude를 적극적으로 활용하고 있다. 이에 시행착오를 거쳐가면서 팀에서 사용 가능한 범용 Agent를 만들었고, 이를 통해 현재도 굉장히 빠르고 정확하게 개발을 하고 있다. Claude를 사용할 때 가장 중요한 것은 토큰을 적게 사용하면서 AI가 효율적으로 코드를 짤 수 있도록 하는 것인데, 어떻게 만들었는지 공유하고자 한다.</p>
</blockquote>
<p>직접 생성한 팀의 Java 백엔드 개발 자동화 플러그인은 1)Java 프로젝트에서 Claude Code로 <code>티켓 분석 및 요구사항 구체화 → 아키텍처 설계 → 코드 분석 및 생성 → 테스트 및 커버리지 자동 검증 → 코드 리뷰 → MR</code> 까지 자동 처리하거나, 2)신규 프로젝트를 초기화하는 기능을 가지고 있다.</p>
<p>작업 project 루트 폴더 하위에 <code>docs/domain-knowledge/</code> 폴더를 생성하고 도메인 지식을 담은 Markdown 파일을 구성하면, 에이전트가 이를 바탕으로 작업을 수행한다. 이렇게 파일을 미리 만들어두는 이유는 아래에서 나올 토큰 절약 방법 중 하나인 <code>lazy-loading</code> 기법을 위해서이다.</p>
<blockquote>
</blockquote>
<ul>
<li><code>01-project-structure.md</code></li>
<li><code>02-domain-model.md</code></li>
<li><code>03-api-design.md</code></li>
<li><code>04-database.md</code></li>
<li><code>05-configuration.md</code></li>
<li><code>06-conventions.md</code> </li>
</ul>
<hr>
<h2 id="plugin-프로젝트-구조">Plugin 프로젝트 구조</h2>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/b4851332-74dc-44c3-a4f8-2a861ef3eac1/image.png" alt=""></p>
<pre><code>cse-agent/
├── agents/                      ← 서브 에이전트 (sonnet)
│   ├── codebase-explorer.md     ← 코드 탐색 (컨텍스트 격리)
│   └── pr-writer.md             ← 리뷰 + MR 생성 (컨텍스트 격리)
├── skills/                      ← 스킬 (슬래시 커맨드)
│   ├── dev-pipeline/            ← 전체 파이프라인 오케스트레이션
│   ├── init-project/            ← 신규 프로젝트 초기화
│   ├── analyze-ticket/          ← 티켓 분석
│   ├── triage/                  ← 복잡도 판정
│   ├── explore-codebase/        ← 코드 탐색
│   ├── design-code/             ← 구현 설계
│   ├── generate-code/           ← 코드 생성
│   ├── db-migration/            ← Flyway 마이그레이션
│   ├── write-tests/             ← 테스트 생성
│   ├── pr-review/               ← 자체 리뷰
│   ├── write-pr/                ← MR 생성
│   └── start/                   ← 로컬 환경 세팅
└── rules/                       ← 프로젝트 규칙
    ├── branch-strategy.md       ← Git 브랜치 전략
    ├── coding-convention.md     ← 코딩 컨벤션
    └── verification.md          ← 검증 규칙</code></pre><p>플러그인은 Claude Code에서 제공하는 확장 기능으로, 한 번 만들어두면 팀 전체가 공유해서 사용할 수 있다. 설치 명령어 하나로 동일한 자동화 파이프라인을 팀원 누구나 바로 쓸 수 있게 된다.</p>
<p>다만 도메인 컨텍스트는 프로젝트마다 다르기 때문에, <code>CLAUDE.md</code>나 <code>docs/</code> 폴더는 각자 정의해야 한다. 그래서 공통으로 재사용할 수 있는 부분은 <code>template/</code> 폴더에 모아두고, 각자 복사해서 자기 프로젝트에 맞게 커스텀해서 쓸 수 있도록 해놨다.</p>
<hr>
<h2 id="핵심-스킬">핵심 스킬</h2>
<h3 id="dev-pipeline--티켓-→-mr-자동화"><code>/dev-pipeline</code> — 티켓 → MR 자동화</h3>
<p>PRD/아키텍처 문서가 아니더라도 티켓 텍스트 하나로도 end-to-end 개발 파이프라인을 실행할 수 있도록 구현했다. </p>
<pre><code>/dev-pipeline
비즈톡 API를 활용한 알림톡 발송 방식을 DB INSERT에서 API 직접 호출로 전환해주세요.
BiztalkApiClient 구현하고, 실패 시 SMS fallback 처리, 발송 이력 DB 저장도 필요합니다.</code></pre><p><strong>파이프라인 흐름:</strong></p>
<pre><code>티켓 텍스트 or 프로젝트 문서(PRD, 아키텍처 등)
         │
         ▼
┌────────────────┐
│ analyze-ticket │  
│                │  문서 - 명확하지 않은 요구사항은 사용자 질문 루프로 구체화
│                │  텍스트 - 단순 질문 VS 코드 작업 판별
│                │  코드 작업인 경우 티켓 → 기술 스펙 변환
│                │  ⤷ ticket.md 생성
└────────┬───────┘
         ▼
┌────────────────┐
│ triage         │  5가지 조건으로 복잡도 판정
│                │  ⤷ light / full 모드 결정
└────────┬───────┘
         │
         ├─── light ─────────────────────┐
         │    (explore 스킵, design 간소화) │
         │                               │
         │  full                         │
         ▼                               ▼
┌────────────────┐              ┌────────────────┐
│ explore-codebase│             │ design-code    │  ticket.md → 간소화 설계
│  ← sonnet 서브   │             │                │  ⤷ design.md + tasks/00-*.md
│  ⤷ exploration.md│            └────────┬───────┘
└────────┬───────┘                       │
         ▼                               │
┌────────────────┐                       │
│ design-code    │  ticket.md +          │
│                │  exploration.md →     │
│                │  전체 구현 설계 및 사용자 피드백 루프
│                │  tasks 폴더에 단계별 태스크로 나누어 순차/병렬 실행
│  ⤷ design.md                           │
│  ⤷ tasks/*.md │                        │
└────────┬───────┘                       │
         │                               │
         ├───────────────────────────────┘
         ▼
┌────────────────┐
│ generate-code  │  tasks/*.md 기반 코드 생성
│                │  ⤷ Java 소스 파일 + 태스크별 자동 git commit
│                │  ⤷ generated.json (생성 파일 목록)
└────────┬───────┘
         ▼
┌────────────────┐
│ db-migration   │  Entity 변경 감지 시 자동 실행
│                │  ⤷ Flyway SQL 마이그레이션 파일
└────────┬───────┘
         ▼
┌────────────────┐
│ write-tests    │  generated.json + design.md 기반
│                │  ⤷ JUnit5 테스트 (커버리지 70%+ 달성 루프)
└────────┬───────┘
         ▼
┌────────────────┐
│ code-review &amp;  │
│ write-pr       │  ← sonnet 서브에이전트
│                │  자체 리뷰 체크리스트 검증 → MR 생성
└────────┬───────┘
         ▼
       MR URL</code></pre><p><strong>산출물 흐름 요약:</strong></p>
<pre><code>ticket.md → exploration.md → design.md + tasks/*.md → generated.json → 테스트 → MR
 (스펙)      (코드 분석)       (설계 + 태스크)         (생성 파일 목록)</code></pre><p>각 단계는 이전 단계의 산출물만 읽고, 불필요해진 산출물은 다시 읽지 않아 토큰을 절약한다.</p>
<hr>
<h2 id="설계-포인트">설계 포인트</h2>
<h3 id="1-서브-에이전트로-컨텍스트-최소화">1. 서브 에이전트로 컨텍스트 최소화</h3>
<p>메인 에이전트(opus)가 모든 단계를 직접 수행하면 컨텍스트 윈도우가 빠르게 소진된다. 코드 탐색과 MR 생성처럼 대량의 파일을 읽어야 하는 단계는 sonnet 서브 에이전트에 위임하여 메인의 컨텍스트를 보존한다.</p>
<table>
<thead>
<tr>
<th>에이전트</th>
<th>모델</th>
<th>역할</th>
<th>왜 분리?</th>
</tr>
</thead>
<tbody><tr>
<td><code>codebase-explorer</code></td>
<td>sonnet</td>
<td>코드 탐색 → <code>exploration.md</code></td>
<td>수십 개 파일을 읽어야 하는 탐색 작업 격리</td>
</tr>
<tr>
<td><code>pr-writer</code></td>
<td>sonnet</td>
<td>자체 리뷰 + MR 생성</td>
<td>전체 diff 분석을 메인 컨텍스트 밖에서 처리</td>
</tr>
</tbody></table>
<p>서브 에이전트 실패 시 메인이 직접 실행하여 fallback한다.</p>
<h3 id="2-복잡도-판정으로-프로세스-분기">2. 복잡도 판정으로 프로세스 분기</h3>
<p>모든 티켓을 동일한 무게로 처리하지 않는다. triage 단계에서 5가지 조건(파일 수, 패턴 재사용, DB 변경, 의존성, 레이어 수)을 평가하여 light/full 모드를 자동 결정한다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Light 모드</th>
<th>Full 모드</th>
</tr>
</thead>
<tbody><tr>
<td>대상</td>
<td>CRUD, 단순 FIX 등</td>
<td>신규 기능, 복잡한 변경</td>
</tr>
<tr>
<td>explore-codebase</td>
<td><strong>스킵</strong></td>
<td>sonnet 서브에이전트 실행</td>
</tr>
<tr>
<td>design-code</td>
<td>간소화</td>
<td>전체 설계 + 피드백 루프</td>
</tr>
<tr>
<td>효과</td>
<td>시간 및 토큰 절약</td>
<td>정확도 우선</td>
</tr>
</tbody></table>
<hr>
<h2 id="개별-스킬">개별 스킬</h2>
<p>파이프라인 전체가 아닌, 필요한 단계만 개별 실행할 수 있다.</p>
<table>
<thead>
<tr>
<th>스킬</th>
<th>명령</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>analyze-ticket</strong></td>
<td><code>/analyze-ticket</code></td>
<td>티켓이나 텍스트를 바탕으로 요구사항 파일인 <code>ticket.md</code> 생성</td>
</tr>
<tr>
<td><strong>triage</strong></td>
<td><code>/triage</code></td>
<td>요구사항 복잡도 판정 (light / full)</td>
</tr>
<tr>
<td><strong>explore-codebase</strong></td>
<td><code>/explore-codebase</code></td>
<td>프로젝트 코드 탐색 후 <code>exploration.md</code> 생성</td>
</tr>
<tr>
<td><strong>design-code</strong></td>
<td><code>/design-code</code></td>
<td>아키텍처 설계도인 <code>design.md</code> 파일과 실제 구현 상세 설계가 있는 <code>tasks/*.md</code> 생성</td>
</tr>
<tr>
<td><strong>generate-code</strong></td>
<td><code>/generate-code</code></td>
<td>코드 탐색 결과를 바탕으로 코드 생성, 태스크 별로 자동 커밋</td>
</tr>
<tr>
<td><strong>db-migration</strong></td>
<td><code>/db-migration</code></td>
<td>Entity 변경이 발생하면 Flyway SQL 생성</td>
</tr>
<tr>
<td><strong>write-tests</strong></td>
<td><code>/write-tests</code></td>
<td>JUnit5 테스트와 커버리지 70% 이상 될때까지 반복</td>
</tr>
<tr>
<td><strong>pr-review</strong></td>
<td><code>/pr-review</code></td>
<td>자체 리뷰 체크리스트 검증</td>
</tr>
<tr>
<td><strong>write-pr</strong></td>
<td><code>/write-pr</code></td>
<td>커밋 내역에 대한 MR 설명과 MR 자동 생성</td>
</tr>
</tbody></table>
<p>스킬을 이렇게 잘게 쪼갠 데는 이유가 있다.</p>
<ol>
<li>하나의 스킬이 너무 많은 일을 하면 중간 단계에서 AI가 지시를 놓치거나 우선순위를 혼동할 가능성이 높아진다. </li>
<li>탐색은 잘 됐는데 설계만 다시 하고 싶은 경우처럼, <strong>일부 단계만 재실행</strong>하고 싶을 때 전체 파이프라인을 다시 돌려야 하는 비효율도 생긴다.</li>
</ol>
<p>특히 코드 탐색(<code>explore-codebase</code>)과 설계(<code>design-code</code>)를 분리한 것이 핵심이다. 두 작업은 성격 자체가 다른데, 탐색은 Grep/Glob/Read로 기존 코드를 파악하는 <strong>입력 중심</strong> 작업이고, 설계는 <code>design.md</code>와 <code>tasks/</code>를 만들어내는 <strong>출력 중심</strong> 작업이다. 이 둘을 한 스킬에 묶으면 길이가 길어져서 AI가 중간 지시를 흘리기 쉽고 탐색 결과를 재활용하기도 어려워지므로, 분리해두면 탐색 결과(<code>exploration.md</code>)는 그대로 두고 설계만 다시 실행할 수 있어 훨씬 유연하다.</p>
<hr>
<h2 id="rules">Rules</h2>
<table>
<thead>
<tr>
<th>규칙</th>
<th>핵심 내용</th>
</tr>
</thead>
<tbody><tr>
<td><strong>branch-strategy</strong></td>
<td>보호 브랜치 규칙, feature/hotfix 브랜치 생성 절차</td>
</tr>
<tr>
<td><strong>coding-convention</strong></td>
<td>기존 코드 스타일 준수, 외부 API는 @Transactional 밖, 개인정보 마스킹</td>
</tr>
<tr>
<td><strong>verification</strong></td>
<td>증거 없이 완료 주장 금지, 테스트 통과 결과 필수</td>
</tr>
</tbody></table>
<p>Rules 폴더가 필요한 이유는, AI에게 코드를 맡겼을 때 가장 자주 발생하는 문제가 기술적인 오류가 아니라 <strong>팀 규칙을 모르고 어기는 것</strong>이기 때문이다. 예를 들어 외부 API 호출을 트랜잭션 안에 넣거나, 개인정보를 로그에 그대로 출력하는 식의 실수들이 존재하는데 이런 규칙들은 코드베이스만 봐서는 파악하기 어렵고 그렇다고 매번 프롬프트에 일일이 적어주기도 번거롭다.</p>
<p>Rules 폴더에 명시적으로 정의해두면 <strong>Claude가 작업할 때마다 자동으로 참조</strong>하여, <strong>따로 지시하지 않아도 팀 컨벤션에 맞는 코드를 생성</strong>한다.</p>
<hr>
<h2 id="claude를-어떻게-잘-활용할-수-있을까">Claude를 어떻게 잘 활용할 수 있을까?</h2>
<h3 id="ai를-통제한다---하네스-엔지니어링">AI를 통제한다 - 하네스 엔지니어링</h3>
<p>에이전트로 개발을 하며 느낀점은, 너무 많은 컨텍스트와 지시 사항들을 써놓을 경우 AI가 내가 지시해놓은 규칙을 따르지 않는다는 것이다. 예를 들어 자동으로 테스트 커버리지를 돌리며 70% 이상 넘을 때 까지 테스트들을 추가해야 하는데 그냥 넘어가버렸다.</p>
<p>이렇게 정보는 다 알고 있으나 엉뚱한 짓을 하는 경우가 종종 있는데, 이를 위해서 필요한 것이 바로 컨텍스트 엔지니어링을 넘은 &#39;<strong>하네스 엔지니어링</strong>&#39;이다. 단순히 에이전트가 규칙을 어겼을 때 프롬프트/스킬에 규칙을 추가하지 않고 <strong>마구를 씌우는 것처럼 AI를 통제하고 훈련시킬 수 있는 장비</strong>, 즉 <strong>그 규칙을 어겼을 때 실패하도록 시스템을 바꿔 강제</strong>해야 하는 것이다.</p>
<blockquote>
<p>☁️ <strong>하네스 엔지니어링 기둥 종류</strong></p>
</blockquote>
<ol>
<li>컨텍스트 파일 배치 및 규칙 정의</li>
<li>Hook을 이용해 규칙을 시스템에 내장: 자동으로 막고, 실패했을 경우 자동으로 고치며 교정을 해나가는 루프를 돈다.</li>
<li>지속적 피드백 루프 : 기존 코드에 나쁜 패턴이 있으면 그대로 따라하게 되므로, 주기적으로 자동으로 청소되게 하는 시스템이 필요하다. (ex)코딩 규칙 위반 감지 / 중복 코드 리팩토링 PR 자동 생성 / 미사용 코드 자동 제거)</li>
</ol>
<p>이렇게 시스템적으로 강제하면 한번 실수한 것에 대해서 두번 다시 같은 실수를 하지 않도록, 시간이 지날수록 견고한 시스템이 만들어지게 된다. 이 부분은 현재 계속해서 테스트를 하며 보완해나가고 있다.</p>
<h3 id="lazy-loading으로-컨텍스트-비용-줄이기">Lazy-loading으로 컨텍스트 비용 줄이기</h3>
<p>JPA를 써본 사람이라면 lazy loading과 eager loading의 차이를 잘 알 것이다. Claude에서도 정확히 같은 개념이 적용된다. <code>CLAUDE.md</code>나 <code>.claude</code> 내부의 내용은 모든 요청마다 기본으로 읽힌다. 즉, 여기에 내용이 길어질수록 실제 작업과 무관한 내용까지 매 턴마다 컨텍스트를 차지하며 토큰을 낭비하게 된다.</p>
<p>이를 해결하는 방법은 생각보다 단순하다. <strong>관련 문서를 최대한 잘게 쪼개어 별도의 Markdown 파일로 분리</strong>해두고, <code>CLAUDE.md</code>나 <code>Skill</code> 에는 <strong>파일 목록과 간단한 설명</strong>만 적어두는 것이다. 그러면 AI가 요청 내용과 관련된 문서만 &#39;필요할 때&#39; 직접 읽어오도록 유도할 수 있다.</p>
<p>예를 들어 <code>CLAUDE.md</code>에 아래와 같이 작성해두면, </p>
<pre><code class="language-markdown">## 도메인 지식
필요한 경우 아래 문서를 참조하세요.
- docs/domain-knowledge/01-project-structure.md — 프로젝트 구조
- docs/domain-knowledge/03-api-design.md — API 설계 원칙
- docs/domain-knowledge/05-database.md — DB 스키마 및 쿼리 규칙</code></pre>
<p>Claude는 DB 관련 작업이 들어왔을 때만 <code>05-database.md</code>를 읽고, API 작업이 들어왔을 때만 <code>03-api-design.md</code>를 읽는다. 모든 문서를 처음부터 통째로 올리는 것보다 훨씬 효율적이다.</p>
<h3 id="컨텍스트를-유지하지-않아도-되는-것은-서브-에이전트에-위임">컨텍스트를 유지하지 않아도 되는 것은 서브 에이전트에 위임</h3>
<p>Claude의 컨텍스트 윈도우는 대화가 길어질수록 점점 채워진다. 이때 문제가 되는 것은 단순히 토큰 비용만이 아니라, 컨텍스트가 가득 찰수록 Claude의 집중도와 정확도가 함께 떨어진다는 점이다.</p>
<p>따라서 메인 에이전트가 &#39;기억하고 있을 필요가 없는&#39; 작업은 과감하게 서브 에이전트에 위임하는 것이 중요하다. 이 플러그인에서는 두 가지 단계를 서브 에이전트로 분리했다.</p>
<ul>
<li><strong>코드 탐색(<code>codebase-explorer</code>)</strong>: 수십 개의 파일을 열어 읽어야 하는 탐색 작업. 결과만 <code>exploration.md</code>로 요약해서 메인에 돌려준다.</li>
<li><strong>MR 생성(<code>pr-writer</code>)</strong>: 전체 diff를 읽고 리뷰 체크리스트를 검증하는 작업. 완료 후 MR URL만 반환한다.</li>
</ul>
<p>이렇게 하면 메인 에이전트는 &#39;무엇을 만들지&#39;에만 집중할 수 있고, 파일을 대량으로 읽는 지저분한 작업은 서브 에이전트가 맡아서 격리된 컨텍스트에서 처리한다. 서브 에이전트가 실패하면 메인이 직접 실행하는 fallback도 구현해두었다.</p>
<h3 id="자가-피드백-루프">자가 피드백 루프</h3>
<p>단순히 코드를 생성하고 끝내는 것이 아니라 생성한 결과물을 스스로 검증하고 기준을 충족할 때까지 반복하도록 설계하는 자가 피드백 루프를 만들었다. </p>
<p>예를 들어<code>write-tests</code> 단계에서는 테스트를 생성한 뒤 실제로 실행해서 커버리지를 측정하고, 70%에 미치지 못하면 부족한 부분을 스스로 파악해서 테스트를 보완하는 루프를 돌고 있다. 사람이 &quot;커버리지가 부족하니 더 써줘&quot;라고 피드백을 주지 않아도 되는 것이다. <code>design-code</code> 단계에서도 설계안을 작성한 뒤 스스로 도메인 지식 문서와 비교해 누락된 요구사항이나 설계상의 허점을 검토하는 피드백 루프를 포함시켰다.</p>
<p>즉 <strong>명확한 완료 기준을 정의해서</strong> 이&quot;테스트를 작성해라&quot;가 아니라 &quot;커버리지 70%를 달성할 때까지 테스트를 작성해라&quot;처럼, AI가 스스로 완료 여부를 판단할 수 있는 기준을 함께 제공했다.</p>
<h3 id="산출물은-json보다-markdown">산출물은 JSON보다 Markdown</h3>
<p>초기에는 단계별 산출물을 JSON 형식으로 저장해봤지만 실제 사용해보니 Claude가 Markdown을 훨씬 자연스럽게 읽고 쓴다는 것을 깨달았다. JSON은 다음 단계의 프로그램이 파싱하기 좋은 형식이지, AI가 맥락을 파악하며 읽기 좋은 형식이 아니다. </p>
<p>결론적으로 AI가 읽고 쓰는 산출물은 Markdown, 코드가 파싱해야 하는 데이터(생성된 파일 목록 등)만 JSON으로 남겨두는 것이 맞다. (ex) <code>generated.json</code>)</p>
<h3 id="스킬-내-파일-탐색은-grepglobread를-명시">스킬 내 파일 탐색은 Grep/Glob/Read를 명시</h3>
<p>스킬 파일 안에서 코드베이스를 탐색하는 단계를 작성할 때, 그냥 &quot;관련 파일을 찾아봐&quot;라고만 적으면 Claude가 bash로 <code>find</code>나 <code>ls</code>를 실행하거나 엉뚱한 방법을 쓰는 경우가 있다. 그보다 스킬 지시문 안에 <code>Grep</code>, <code>Glob</code>, <code>Read</code> 도구를 명시적으로 지정해주는 것이 훨씬 안정적이다.</p>
<pre><code class="language-markdown">## 코드 탐색
1. Glob으로 `src/main/**/*.java` 패턴에 해당하는 파일 목록을 가져온다
2. Grep으로 티켓에 언급된 클래스명을 검색하여 관련 파일을 좁힌다
3. Read로 후보 파일들을 읽고 구조를 파악한다</code></pre>
<p>도구를 명시하면 Claude가 일관되게 같은 방식으로 탐색하고, 의도치 않은 bash 명령 실행도 방지할 수 있다.</p>
<hr>
<h2 id="개발자의-미래에-대한-생각">개발자의 미래에 대한 생각</h2>
<p>정말 모든 것이 너무 빠르게 변하고 있다. 매일 Claude 관련 새로운 기능이 업데이트되고, 사람이 따라가기 힘든 속도로 새로운 버전들이 나오고 있다. sub-agent가 나온 지 얼마 되지도 않아 Agent-teams가 나왔고, 머지않아 AGI나 완전 자율 SaaS 형태의 무언가가 나오지 않을까 싶기도 하다.</p>
<p>단순히 코딩하는 것뿐만 아니라 프로덕트를 만들어내고 문제를 해결하는 것을 좋아하는 나로서는 여전히 재미를 느끼고 있기는 하지만.. 점점 불안해지는 것도 사실이다. 이제 막 경력을 시작하는 입장에서 미래에 개발자라는 직종 자체가 남아 있기는 할까라는 의문이 계속 든다.</p>
<p>그럼에도 아직까지는 AI가 있어도 오더를 내리는 사람의 퀄리티에 따라 결과물의 퀄리티도 달라진다. 무엇을 만들어야 하는지, 왜 만들어야 하는지를 정의하는 능력은 여전히 사람의 영역이다. 그리고 AI가 틀린 것을 알아채는 능력, 즉 생성된 코드나 설계의 허점을 판별하는 눈도 아직은 깊은 도메인 이해 없이는 갖추기 어렵다.</p>
<p>그래서 나는 아직 경력이 많이 없는 만큼 AI 도입 전과 똑같이 개발 공부를 열심히 하고 싶다. AI를 잘 쓰는 것과 개발을 잘 이해하는 것은 서로 대체재가 아니라 보완재라고 생각하기 때문이다. AI를 지휘하는 사람이 되려면, 결국 지휘할 줄 아는 사람이 되어야 한다.</p>
<blockquote>
<p>참고 자료
<a href="https://www.youtube.com/watch?v=vxEvo2BLM6A&amp;t=1418s">https://www.youtube.com/watch?v=vxEvo2BLM6A&amp;t=1418s</a>
<a href="https://www.youtube.com/watch?v=6gvnDSAcZww&amp;t=196s">https://www.youtube.com/watch?v=6gvnDSAcZww&amp;t=196s</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring MVC에서 WebClient 도입 효과 테스트]]></title>
            <link>https://velog.io/@semi-cloud/Spring-MVC%EC%97%90%EC%84%9C-WebClient-%EB%8F%84%EC%9E%85-%ED%9A%A8%EA%B3%BC-%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@semi-cloud/Spring-MVC%EC%97%90%EC%84%9C-WebClient-%EB%8F%84%EC%9E%85-%ED%9A%A8%EA%B3%BC-%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Mon, 02 Mar 2026 14:33:12 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p>현재 프로젝트에서 외부 API를 호출해서 열차 실시간 정보를 받아오고 있지만, <a href="https://velog.io/@semi-cloud/%EC%82%BD%EC%A7%88%EB%A1%9C%EA%B7%B8-%EC%99%B8%EB%B6%80-API-%EC%9E%A5%EC%95%A0-%EC%83%81%ED%99%A9%EC%97%90%EC%84%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%95%88%EC%A0%95%EC%84%B1%EC%9D%84-%EB%B3%B4%EC%9E%A5%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C-feat.-%EC%84%9C%ED%82%B7-%EB%B8%8C%EB%A0%88%EC%9D%B4%EC%BB%A4">해당 포스팅</a>에서 볼 수 있듯이 Spring MVC에서 Non-Blocking I/O인 WebClient를 정말 Reactive하게 사용할 수 있는지, 그리고 도입 시의 이점이 궁금해져 해당 포스팅을 작성해보았다.</p>
<p><a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html?utm_source=chatgpt.com">Spring MVC 공식 문서</a>를 참고해보면, MVC도 컨트롤러 반환값으로 async/reactive 타입을 지원하고 있다. 이 경우 Servlet async processing을 통해 원래 요청 스레드에서 빠져나온 뒤, 나중에 결과가 준비되면 다시 dispatch해서 응답을 완료하게 된다.</p>
<p>즉 Blocking I/O와 다르게 <strong>외부 I/O 대기 동안 Tomcat 요청 스레드가 계속 붙잡혀 있지 않도록</strong> 만드는 것이 가능하다. 또한 상황에 따라서 Event Loop 방식으로 동작하기 때문에 <strong>latency / CPU 사용률</strong>에서도 이점을 얻을 수 있다. 실제로 효과가 있는지 테스트해보자.  </p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/6b6d7376-f1f2-4ccf-a6a6-cadcb72cc0ab/image.png" alt=""></p>
<h3 id="☁️-webclient-부하-테스트">☁️ Webclient 부하 테스트</h3>
<h4 id="1-resttemplaterestclient">1) RestTemplate/RestClient</h4>
<pre><code class="language-kotlin">override fun getTrainRealTimes(stationName: String, startIndex: Int, endIndex: Int): TrainRealTimeDto {
      val url = &quot;${publicDataProperties.realTimeStationArrivalPrefixUri}/${publicDataProperties.realTimeStationArrivalToken}/${publicDataProperties.realTimeStationArrivalSuffixUri}/$startIndex/$endIndex/$stationName&quot;
      val response = restTemplate.exchange(url, HttpMethod.GET, null, TrainRealTimeDto::class.java).body!!
      return response.takeIf { it.status == null } ?: TrainRealTimeDto(500, null, emptyList())
}</code></pre>
<h4 id="2-webclient--block">2) WebClient + block</h4>
<pre><code class="language-kotlin">// build.gradle
implementation(&quot;org.springframework.boot:spring-boot-starter-webflux&quot;)</code></pre>
<pre><code class="language-kotlin">override fun getTrainRealTimesByMono(stationName: String, startIndex: Int, endIndex: Int): Mono&lt;TrainRealTimeDto&gt; {
    val url =
          &quot;${publicDataProperties.realTimeStationArrivalPrefixUri}/&quot; +
                 &quot;${publicDataProperties.realTimeStationArrivalToken}/&quot; +
                 &quot;${publicDataProperties.realTimeStationArrivalSuffixUri}/&quot; +
                 &quot;$startIndex/$endIndex/$stationName&quot;

    return webClient.get()
               .uri(url)
            .retrieve()
            .onStatus(HttpStatusCode::isError) {
                Mono.error(BusinessException(ResponseCode.FAILED_TO_GET_TRAIN_INFO))
            }
            .bodyToMono(TrainRealTimeDto::class.java)
}</code></pre>
<blockquote>
</blockquote>
<ol>
<li>Tomcat request thread</li>
<li>WebClient async HTTP 요청</li>
<li>Netty event loop가 응답 처리</li>
<li>Tomcat thread가 결과 받아서 반환</li>
</ol>
<p>실제로도 해당 포스팅과 같이 <strong>톰캣 스레드가 대기하지 않고 바로 스레드 풀로 반환되어 DB 커넥션 에러나, 다른 요청에 영향을 주지 않는지 테스트</strong> 해보자.</p>
<h4 id="jmeter---요청-성공실패-및-tps-확인">Jmeter - 요청 성공/실패 및 TPS 확인</h4>
<p>일부 요청은 Delay API로, 일부 요청은 열차와 관계없는 유저 API를 호출해보았다. 만약 읽기 트랜잭션과 외부 API 호출을 분리하지 않았다면, WebClient를 사용하더라도 스레드는 반환되지만 커넥션은 트랜잭션이 끝날 때까지 계속 점유되어 DB Connection Timeout이 발생했을 것이다.</p>
<p>하지만 <strong>읽기 트랜잭션과 외부 API 호출을 분리</strong>해두었기 때문에, <strong>커넥션 타임아웃 없이 I/O 요청 시 스레드도 즉시 반환</strong>된다. 덕분에 *<em>다른 기능에 영향을 주지 않고 각각의 요청을 안정적으로 처리하고 있는 *</em>것을 확인할 수 있다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/semi-cloud/post/e5c2d22c-98c2-4a1b-8c45-7040b8b0b991/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/semi-cloud/post/4f9e35b5-49bc-41ee-be40-8fd7b08131a4/image.png" alt=""></th>
</tr>
</thead>
</table>
<ul>
<li><code>왼쪽</code> : DB 호출이 발생하는 유저 정보 조회 API, 10초 1000 User</li>
<li><code>오른쪽</code> : DB &lt;-&gt; 외부 API 호출 분리된 열차 정보 조회 API, 10초 1000 User </li>
</ul>
<p>만약 jpa <code>open-in-view</code> 설정을 다시 <code>true</code> 로 설정한다면? 이전 포스팅에서와 같이 어마무시한 <code>Connection Time Out</code> 예외를 마주하게 될 것이다. </p>
<h4 id="thread-dump---스레드-상태-확인">Thread Dump - 스레드 상태 확인</h4>
<p>실제 Thread Dump를 떠서 확인해보면, <code>reactor-http-nio-X</code> 스레드에서 blocking request thread가 외부 응답을 붙잡고 기다리지 않고, <strong>이벤트 루프가 커널에게 준비되면 알려줘 하고 감시하는 전형적인 NIO 패턴</strong>으로 동작하고 있는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/be8da639-ba89-4bda-85e7-3efb36667b6e/image.png" alt=""></p>
<p>이와 반대로 톰캣의 <code>http-nio-8080-exec-X</code> 스레드는 <code>TIMED_WAITING (parking)</code> 상태로, 스레드가 <code>LockSupport.park()</code> 로 잠들어 있다. 즉 아래 로그를 보면 알 수 있듯이, <strong>Tomcat 스레드가 할 일이 없어서 요청 큐(TaskQueue)에서 다음 작업을 기다리는 중</strong>인 것이다. 이로써 <strong>우리는 정말 WebClient가 Non-Blocking 방식으로 동작한다는 것</strong>을 눈으로 확인할 수 있다.</p>
<pre><code>at java.util.concurrent.LinkedBlockingQueue.poll
at org.apache.tomcat.util.threads.TaskQueue.poll
at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask</code></pre><p><img src="https://velog.velcdn.com/images/semi-cloud/post/1e6d5eac-c09e-4ce3-a059-e22a38f6850f/image.png" alt=""></p>
<p>혹은 이 시간 동안 다른 요청을 처리하고 있기도 한다.</p>
<p>  <img src="https://velog.velcdn.com/images/semi-cloud/post/8fcc355c-6bca-45ed-a26b-28fa92817194/image.png" alt=""></p>
<blockquote>
<p>만약 <code>WebClient.block()</code> 을 활용하게 되면, 그림과 같이 <code>Mono.block()</code> 에서 <strong>Tomcat Thread가 Waiting</strong> 하고 있는 것을 볼 수 있다.
 <img src="https://velog.velcdn.com/images/semi-cloud/post/a4a6b715-ad3b-45a2-82bf-c55bdb07d623/image.png" alt=""></p>
</blockquote>
<h3 id="☁️-webclient-성능-테스트">☁️ Webclient 성능 테스트</h3>
<p>이번에는 앞서 말한 것처럼, WebClient는 Netty 기반이라 HTTP connection 관리가 좋다고 한다. 즉, TPS와 스레드 수는 비슷할지라도 <strong>CPU 사용률과 API latency 측면에서 이득</strong>을 볼 수 있는 것이다. </p>
<p>그렇다면 정말 Webclient는 연결을 효율적으로 처리할 수 있을까? 한번 테스트해보자.</p>
<blockquote>
<p><strong>1. thread 사용량 테스트</strong></p>
</blockquote>
<h4 id="resttemplate">RestTemplate</h4>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/8210858a-02b2-4cb4-8ff0-2484998a20f4/image.png" alt=""></p>
<ul>
<li>RestTemplate은 200개 요청에 대해 229개의 스레드가 살아있다.</li>
</ul>
<h4 id="webclient">WebClient</h4>
<p> <img src="https://velog.velcdn.com/images/semi-cloud/post/53e54ce0-e8aa-4593-99c6-025067be5b1f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/344325c6-bef9-491e-a69f-870c8ed522a5/image.png" alt=""></p>
<ul>
<li>WebClient는 200개 요청에 대해 236개의 스레드가 살아있다.</li>
</ul>
<p>이처럼 WebClient는 <strong>Netty event-loop 기반이라 보통 event-loop thread 2~8개만 사용</strong>하지만, 결국은 요청을 받는 톰캣 스레드 자원도 필요하므로 <strong>실제로는 WebClient가 thread 개수가 좀 더 많은 것을 볼 수 있다.</strong></p>
<blockquote>
<p><strong>2. connection pool 효율 테스트 - Latency, CPU usage</strong></p>
</blockquote>
<p>WebClient는 Netty 기반의 event-loop와 non-blocking I/O를 사용하여, 적은 수의 스레드로 다수의 HTTP connection을 효율적으로 관리한다고 한다.</p>
<p>그렇다면 왜 CPU 이용률이 줄어들까? <strong>스레드가 적을 수록 OS context switching 비용(커널 &lt;-&gt; 유저 모드 전환/캐시 초기화 등)</strong>이 줄어들기 때문이다. 반면 Blocking은 요청과 스레드가 비례하기 때문에 스레드 관리를 위한 CPU의 일만 더 많아지게 된다. (어짜피 I/O 요청이 대다수이므로 CPU를 많이 쓸 필요가 없다.)</p>
<h4 id="resttemplate-1">RestTemplate</h4>
<p>  <img src="https://velog.velcdn.com/images/semi-cloud/post/a69ad9e8-0320-4807-bb16-9c6d8613f449/image.png" alt=""></p>
<p>  <img src="https://velog.velcdn.com/images/semi-cloud/post/9175f4bc-ec9f-4bf1-ad06-bbd66aa2727c/image.png" alt=""></p>
<h4 id="webclient-1">Webclient</h4>
<p>  <img src="https://velog.velcdn.com/images/semi-cloud/post/db70e9d6-0146-449e-907d-4162f61b878b/image.png" alt=""></p>
<p>  <img src="https://velog.velcdn.com/images/semi-cloud/post/f98bfd3d-3053-457f-8ef5-cfe42f3fcc50/image.png" alt=""></p>
<p>실제 같은 요청을 보냈을 때 CPU 이용률은 <strong><code>19.2%</code> -&gt; <code>9.7%</code> 로 감소</strong>하고, API 응답 시간도 <strong><code>6000ms</code> -&gt; <code>4742ms</code></strong>로 줄어든 것을 볼 수 있다.</p>
<h3 id="☁️-spring-mvc-vs-spring-webflux-reactive인-webflux가-항상-좋은가에-대한-고찰">☁️ Spring MVC VS Spring Webflux. Reactive인 Webflux가 항상 좋은가에 대한 고찰</h3>
<p>자 그러면 현재까지 테스트 한 것처럼 Non-Blocking I/O가 효율적이라면, 왜 많은 서비스가 여전히 Spring MVC 기반을 유지하고 있을까? 내 기준에서는 &#39;서비스 특성에 따라 선택이 달라진다&#39;가 가장 현실적인 답이라고 생각한다.</p>
<p>일반적인 웹 서비스의 경우 DB 접근 비중이 높은데, <strong>우리가 흔히 사용하는 JPA, MyBatis, JdbcTemplate은 모두 Blocking 방식</strong>이다. 이 상태에서 <strong>Webflux로 전환하더라도 DB 구간에서 여전히 Blocking</strong>이 발생하기 때문에, 기대만큼의 성능 개선을 얻기 어려운 경우가 많다.</p>
<p>또한 Webflux로 전환할 경우 단순히 컨트롤러 레벨이 아니라 서비스 전반의 구조를 변경해야 하고, 디버깅 난이도나 러닝 커브까지 고려하면 전환 비용이 적지 않다. 이런 점까지 포함해서 보면, 대부분의 CRUD 중심 서비스에서는 MVC 구조를 유지하는 것이 더 합리적인 선택일 수 있다.</p>
<p>그리고 무엇보다 MVC 구조에서도 스레드 풀 튜닝, 커넥션 풀 관리, 캐싱 전략 등을 통해 충분히 높은 수준의 성능을 확보할 수 있으니, 아직 Spring MVC를 유지하는 기업들이 많은 것으로 판단된다.</p>
<blockquote>
<p><strong>Webflux 효과를 얻을 수 있는 서비스</strong></p>
</blockquote>
<ul>
<li>스트리밍 서비스</li>
<li>DB 호출 거의 없이 외부 API 호출이 많을 경우(feat. API Gateway)</li>
</ul>
<h3 id="☁️-결론-webclient를-도입하지-않은-이유">☁️ (결론) Webclient를 도입하지 않은 이유</h3>
<p>종합하자면 WebClient는 Non-Blocking I/O로 동작하기 때문에 응답 지연 상황에서도 서버 장애를 방지할 수 있고, CPU와 Latency 측면에서도 이점이 있다는 것을 테스트로 확인했다.</p>
<p>다만 WebClient는 스레드 블로킹은 해소하지만, 외부 API가 느린 동안 <strong>사용자는 여전히 응답을 기다려야 하는 문제</strong>가 존재한다. <strong>도메인 특성 상 실시간 열차 도착 정보는 &quot;지금 즉시&quot; 확인해야 의미가 있는 데이터</strong>이므로, 3초간 대기하게 하는 것보다 서킷 브레이커가 열린 상태에서 로컬 캐시의 스테일 데이터를 즉시 반환하는 것이 사용자 경험 측면에서 더 적합하다고 판단했고,   <a href="https://velog.io/@semi-cloud/%EC%82%BD%EC%A7%88%EB%A1%9C%EA%B7%B8-%EC%99%B8%EB%B6%80-API-%EC%9E%A5%EC%95%A0-%EC%83%81%ED%99%A9%EC%97%90%EC%84%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%95%88%EC%A0%95%EC%84%B1%EC%9D%84-%EB%B3%B4%EC%9E%A5%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C-feat.-%EC%84%9C%ED%82%B7-%EB%B8%8C%EB%A0%88%EC%9D%B4%EC%BB%A4">해당 포스팅에서의</a> 서킷 브레이커 적용을 통해서 문제가 해결이 되었기 때문에 이 상황에서는 더 합리적이라고 판단했다. </p>
<p>따라서 최종 해결책으로 도입하지는 않았지만, 그래도 이론적으로만 Non-Blocking I/O에 대해 아는 것보다 직접 스레드 덤프와 부하 테스트를 수행해보며 더욱 깊이 있게 파악할 수 있었던 시간이었던 것 같다.</p>
<blockquote>
<p>참고 자료
<a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html?utm_source=chatgpt.com">https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html?utm_source=chatgpt.com</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 캐시 미스 시 외부 서버로의 요청 폭주 제어하기(feat.Cache Stamphede 현상)]]></title>
            <link>https://velog.io/@semi-cloud/Spring-%EC%BA%90%EC%8B%9C-%EB%AF%B8%EC%8A%A4-%EC%8B%9C-%EC%99%B8%EB%B6%80-%EC%84%9C%EB%B2%84%EB%A1%9C%EC%9D%98-%EC%9A%94%EC%B2%AD-%ED%8F%AD%EC%A3%BC-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0feat.Cache-Stamphede-%ED%98%84%EC%83%81</link>
            <guid>https://velog.io/@semi-cloud/Spring-%EC%BA%90%EC%8B%9C-%EB%AF%B8%EC%8A%A4-%EC%8B%9C-%EC%99%B8%EB%B6%80-%EC%84%9C%EB%B2%84%EB%A1%9C%EC%9D%98-%EC%9A%94%EC%B2%AD-%ED%8F%AD%EC%A3%BC-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0feat.Cache-Stamphede-%ED%98%84%EC%83%81</guid>
            <pubDate>Sat, 21 Feb 2026 07:02:33 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>대량 트래픽이 유입되는 상황에서 외부 API의 처리량이 낮을 경우, 전체 서비스가 영향을 받는 문제가 발생할 수 있다.</p>
</blockquote>
<p>예를 들어, 다음과 같은 상황을 가정해보자. 우리 서버는 300~400 TPS 처리가 가능하고, 외부 API 서버는 약 10 TPS 수준(문제 발생 상태)이다. 이때 <strong>동시에 수만 건의 요청이 유입되는 상황에서 사용자에게는 정상 응답(실패 없이)을 빠르게 제공</strong>해야 한다면 어떻게 대응해야 할까?</p>
<hr>
<p>현재 서비스 로직은 다음과 같다.</p>
<ol>
<li>Redis에 캐시 데이터가 있는지 확인하고, 있다면 바로 반환한다.</li>
<li>캐시 데이터가 없다면 외부 API를 호출하고, 응답을 Redis에 캐싱한 뒤 반환한다.</li>
</ol>
<p>하지만 캐시 데이터가 아직 없는 상태에서 동시에 대량 요청이 들어오는 경우(출퇴근 집중 시간), 혹은 Redis 캐시 데이터가 늦게 저장되는 경우에서는 아직 <strong>Redis에는 캐시 데이터 없기 때문에 대량의 요청이 동시에 유입되면 모든 요청이 외부 API를 향해 쏟아지게 된다.</strong> 이처럼 특정 시점에 요청이 한꺼번에 몰려 외부 API로 폭발적으로 유입되는 현상을 <strong>Thundering Herd(Cache Stampede)</strong> 라고 부른다. </p>
<p>그러면 결국 외부 서버 처리량이 낮기 때문에 응답이 늦어지고 → 외부 API 응답을 기다리는 동안 Tomcat 스레드가 blocking 상태에 머물게 되고 → 이로 인해 스레드 풀이 점점 고갈되면서 관련 없는 다른 요청들도 처리되지 못하고 대기 상태에 빠지게 되는 문제가 발생할 것이다.</p>
<p>실제 이 이슈는 해당 포스팅에서 직접 테스트해보았고, <strong>서킷 브레이커</strong>를 도입해 장애가 전체 서비스로 전파되는 것을 막을 수 있었다. 하지만 서킷 브레이커는 어디까지나 장애가 <em>이미 감지된 이후의 대응책일 뿐, cache stampede 자체를 원천적으로 막지는 못한다</em>. </p>
<blockquote>
<p>그렇다면 애초에 외부 API 호출 수를 최대한 줄여 장애 발생 자체를 방지하고, <strong>외부 서버의 처리량이 낮은 상황에서도 우리 서버는 정상 처리량을 유지하면서 정상 응답을 보내려면 어떻게 해야 할까?</strong></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/b5dbac91-a129-4449-b807-3477865b9f05/image.png" alt=""></p>
<hr>
<h3 id="고려-방안-1--동일-요청에-대한-호출을-제한-redis-분산락-활용">고려 방안 1 : 동일 요청에 대한 호출을 제한 (Redis 분산락 활용)</h3>
<p>서비스의 특징상, 데이터는 사용자마다 다른 것이 아니라 <strong>호선-역 기준으로 동일한 데이터</strong>이다.</p>
<p>즉, 동일 요청은 결과도 동일하다는 점을 활용하면, 같은 <code>subwayLineIdentity</code>와 <code>stationId</code> 조합에 대해 여러 요청이 동시에 들어왔을 때 <strong>최초 1개의 요청만 외부 API를 호출하고, 나머지는 해당 결과를 기다리게</strong> 하면 된다. 10만 건이 들어오는 대용량 트래픽에 관계없이 <strong>서울의 지하철역이 최대 <code>600</code>개 정도</strong>인 것을 감안하면, 최악의 상황에서도 외부 API 호출 수를 극적으로 제한할 수 있다고 판단된다.</p>
<h4 id="로직-흐름">로직 흐름</h4>
<ol>
<li>Redis에서 캐시 데이터가 있는지 조회한다.</li>
<li><strong>캐시 데이터가 없다면</strong> 바로 외부 API를 호출하는 것이 아니라, <code>호선-역</code> 별로 <strong>락을 획득</strong>한다. 락을 얻은 요청만 외부 API를 호출할 수 있다.</li>
<li>외부 API로부터 <strong>값을 가져와 캐시를 업데이트하고 락을 해제</strong>하면, <strong>대기 중이던 다른 요청들은 캐시된 값을 바로 반환하고 종료</strong>한다.</li>
</ol>
<blockquote>
<p><strong>Q. 그렇다면 어떤 lock을 활용해야 할까?</strong></p>
</blockquote>
<p>사실 실사용자가 아직 없어 파드가 하나만 띄워져 있는 상황이라 분산락을 사용하지 않아도 되지만, 학습 목적과 더불어 추후 사용자가 늘어났을 때의 확장성을 고려해 Redis 분산락을 선택하기로 결정했다. 치명적인 단점이나 도입 오버헤드가 크지 않기 때문에 적절한 선택이지 않을까 싶다.</p>
<hr>
<h3 id="☁️-redis-분산락이-뭐지">☁️ Redis 분산락이 뭐지?</h3>
<p>단일 서버(단일 프로세스)에서는 <code>synchronized</code>, <code>mutex</code> 같은 로컬 락으로 해결되지만, 다음 상황에서는 로컬 락이 의미가 없어진다.</p>
<ol>
<li>서버가 여러 대(Scale-out)</li>
<li>같은 작업이 여러 워커/컨테이너에서 동시에 수행</li>
<li>장애/재시작이 발생할 수 있음</li>
</ol>
<p>따라서 <strong>모든 노드가 공통으로 접근 가능한 저장소</strong>(Redis, DB, ZooKeeper/etcd 등)를 이용해 락 상태를 공유해야 하는데, 이때 사용하는 것이 분산락이다.</p>
<h4 id="redis-분산락의-원리">Redis 분산락의 원리</h4>
<p>Redis 분산락은 <code>SET key value NX PX</code> 명령을 통해 구현할 수 있으며, <strong>key가 없을 때만 생성에 성공하므로, 동시에 다른 요청이 들어오는 경우 이미 key가 존재해 락 획득에 실패</strong>하게 된다. 참고로 중간에 클라이언트가 죽으면 락이 영구히 남아 데드락 위험이 생기므로, <code>PX</code>를 통해 TTL을 반드시 설정하는 것이 좋다.</p>
<p><code>SET key value NX PX</code> 명령은 키 생성과 TTL 설정을 <strong>하나의 원자적인 연산</strong>으로 처리하기 때문에, 별도의 키 존재 확인 없이도 race condition을 방지할 수 있다. Redis는 명령 실행 자체가 단일 스레드로 처리되기 때문에, 여러 클라이언트가 동시에 같은 명령을 보내더라도 오직 하나만 성공하는 것이 보장되는 것이다.</p>
<h4 id="redis-lock의-종류">Redis Lock의 종류</h4>
<p>락을 얻지 못하면 자동으로 큐에 대기하는 형태가 아니라 바로 fail-fast로 실패 응답이 반환되기 때문에, 별도로 재시도 처리가 필요하다.</p>
<ol>
<li><p><strong>Spin Lock 방식</strong> : 락을 얻을 수 있는지 <strong>지속적으로 확인</strong>한다. 락을 얻을 때까지 스레드가 Redis에 반복적으로 요청하기 때문에 불필요한 부하를 주게 된다.</p>
</li>
<li><p><strong>Pub/Sub 방식</strong> : 락을 <strong>얻지 못하면 wait 상태로 잠들어 있다가</strong>, 락이 해제되면 통지를 받아 다시 running 상태로 깨어난다.</p>
</li>
</ol>
<p><code>Redisson</code> 라이브러리를 활용하면 분산락을 위한 모든 것을 직접 구현해야 하는 <code>Lettuce</code> 와 달리 해당 기능을 고수준으로 추상화하여 제공하기 때문에 , 생산성 측면에서 큰 장점이 있다고 생각되어 해당 라이브러리를 활용해 구현했다.</p>
<ol>
<li><p><strong>락 획득</strong> - 내부적으로 <code>lua script</code>(원자성을 보장하는 스크립트)를 활용한다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/d0499e44-9439-4602-81bf-f335a22f556c/image.png" alt=""></p>
</li>
<li><p><strong>락 대기</strong> - 내부적으로 구독/해제 이벤트에 대해 세마포어로 동시성을 제어한다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/55ed369e-9dd8-4ca7-b327-93158c7e530f/image.png" alt=""></p>
</li>
</ol>
<blockquote>
<p>참고로 Redisson은 <strong>Netty 기반의 비동기 네트워크 클라이언트</strong>이다. Redis에 요청을 보낼 때 블로킹 없이 요청을 날리고, 결과가 돌아오면 콜백 기반으로 후처리를 진행한다. Lettuce도 동일한 방식이지만, 분산락 기능을 고수준으로 직접 제공하지 않는 반면 Redisson은 이를 추상화하여 제공한다는 차이가 있다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/98e637c3-3e7b-4988-a790-4072853184e0/image.png" alt=""></p>
<hr>
<h3 id="고려-방안-2--선-계산-후-키가-만료되기-전에-미리-값을-갱신">고려 방안 2 : 선 계산 후 키가 만료되기 전에 미리 값을 갱신</h3>
<p>혹은 캐시 미스 현상 자체를 막기 위해 <strong>만료되기 이전에 확률적으로 미리 캐시를 갱신하여 Thundering Herd를 방지하는 방식</strong>도 있을 것이다. 즉, 애초에 대량 캐시 미스 자체가 발생하지 않도록 선제적으로 막는 것이다. 해결 방안이 훨씬 간단하다는 장점이 있다.</p>
<h4 id="단점">단점</h4>
<p>실제 조회 요청이 없는 역까지 전부 갱신하므로, <strong>불필요한 외부 API 호출</strong>이 일어날 수 있다. 또한 <strong>캐시에 한 번도 올라간 적 없는 최초 요청에는 무력한데</strong>, 서비스 첫 구동 시나 새로운 역이 추가된 시점에는 스케줄러가 해당 키를 아직 모르기 때문에 결국 그 순간만큼은 Thundering Herd가 그대로 발생한다.</p>
<p>아래는 확률적으로 미리 갱신하는 XFetch 알고리즘 스타일의 예시 코드다.</p>
<pre><code class="language-kotlin">fun getCacheWithProbabilisticRefresh(
    key: String,
    ttl: Duration,
    fetchFunction: () -&gt; T
): T {
    val cached = cache.get(key)
    val remainingTtl = cache.getExpire(key)  // ms 단위
    val fetchDurationMs = measureFetchTime()  // 이전 fetch에 걸린 시간(delta)
    val beta = 1.0  // 조정 계수. 높을수록 더 일찍 갱신 시도

    // XFetch: currentTime - (beta * delta * ln(random)) &gt; expiryTime 이면 미리 갱신
    // fetch 시간이 길수록, TTL이 얼마 안 남을수록 갱신 확률이 높아진다.
    val expiryTime = System.currentTimeMillis() + remainingTtl
    val shouldRefresh = cached == null ||
        System.currentTimeMillis() - (beta * fetchDurationMs * ln(Random.nextDouble())) &gt; expiryTime

    if (!shouldRefresh) {
        return cached!!
    }

    // 락 획득 후 갱신 (방안 1과 조합)
    return refreshWithLock(key, fetchFunction)
}</code></pre>
<blockquote>
<p>따라서 위와 같은 단점이 있기에, 1번 방안을 선택했다.</p>
</blockquote>
<h3 id="☁️-구현--부하-테스팅-결과">☁️ 구현 &amp; 부하 테스팅 결과</h3>
<p>이제 코드로 구현하면 아래와 같다.</p>
<h4 id="1단계---캐시-확인-cache-hit">1단계 - 캐시 확인 (Cache Hit)</h4>
<p>가장 먼저 캐시를 조회하고, 데이터가 있으면 바로 반환한다. 락을 획득할 필요도 없이 빠르게 종료된다.</p>
<h4 id="2단계---락-획득-및-외부-api-호출-cache-miss">2단계 - 락 획득 및 외부 API 호출 (Cache Miss)</h4>
<p>캐시 미스가 발생하면 호선-역 조합을 키로 분산락을 획득 시도한다. 락을 얻은 단 하나의 요청만 외부 API를 호출하고, 응답 결과를 Redis에 캐싱한 뒤 락을 해제한다. 동시간대에 같은 데이터를 요청하는 수많은 요청들이 모두 외부 서버로 쏟아지는 것이 아니라, 단 한 번의 API 호출로 수렴되는 것이다.</p>
<h4 id="3단계---double-checked-locking">3단계 - Double-Checked Locking</h4>
<p>락 대기 중에 다른 스레드가 이미 캐시를 채워뒀을 수 있기 때문에, 락을 획득한 직후 캐시를 한 번 더 확인한다. 이미 캐싱된 데이터가 있다면 외부 API 호출 없이 바로 반환한다. 이 과정이 없으면 대기 중이던 모든 스레드가 락을 획득할 때마다 외부 API를 중복 호출하게 된다.</p>
<pre><code class="language-kotlin">override fun getTrainRealTimes(stationId: Long, subwayLineId: Long, upDownType: UpDownType?): List&lt;GetTrainRealTimesDto.TrainRealTime&gt; {
    val station = stationLineReader.getById(stationId)
    val subwayLine = subwayLineReader.getById(subwayLineId)
    val subwayLineIdentity = subwayLine.identity
    val lockKey = &quot;${subwayLineIdentity}-${stationId}&quot;

    // 1. cache hit 상황에서는 바로 응답을 반환한다.
    trainCacheUtils.getCache(subwayLineIdentity, stationId)?.let {
        logger.info(&quot;[cache hit] 응답 반환: lockKey=$lockKey&quot;)
        return it
    }

    // 2. cache miss 상황에서는 같은 lock key 조합으로 들어온 다수의 요청 중 1개만 외부 API를 호출할 수 있도록 한다.
    val lock = redissonClient.getLock(lockKey)

    try {
        val acquired = lock.tryLock(10, 15, TimeUnit.SECONDS)

        if (!acquired) {
            logger.error(&quot;분산 락 획득 실패: lockKey=$lockKey&quot;)
            throw BusinessException(ResponseCode.LOCK_ACQUISITION_FAILED)
        }

        // 3. Double-Checked Locking: 락을 기다리는 동안 다른 스레드가 이미 캐시를 채워뒀을 수 있으므로,
        //    락 획득 후 반드시 캐시를 한 번 더 확인해야 불필요한 외부 API 중복 호출을 막을 수 있다.
        trainCacheUtils.getCache(subwayLineIdentity, stationId)?.let {
            logger.info(&quot;[cache miss] 이미 캐싱된 데이터 존재 → 락 해제 후 바로 반환: lockKey=$lockKey&quot;)
            return it
            // finally 블록에서 isHeldByCurrentThread 체크 후 unlock이 수행되므로 중복 해제 걱정 없음
        }

        logger.info(&quot;[cache miss] 외부 API 호출 시작 (분산 락 획득): lockKey=$lockKey&quot;)
        val result = requestTrainRealTimesAndSorting(station.name)
        result.forEach { (key, value) -&gt;
            trainCacheUtils.setCache(key.toLong(), stationId, value)
        }

        val trainRealTimes = result.getOrElse(subwayLine.identity.toString()) { emptyList() }
        return upDownType?.let { type -&gt;
            trainRealTimes.filter { it.upDownType == type }.take(4)
        } ?: trainRealTimes

    } catch (e: InterruptedException) {
        Thread.currentThread().interrupt()
        logger.error(&quot;Lock 획득 중 인터럽트 발생&quot;, e)
        throw BusinessException(ResponseCode.LOCK_ACQUISITION_FAILED)
    } finally {
        // Double-Checked Locking 분기에서 이미 return한 경우에도 finally는 실행되므로,
        // isHeldByCurrentThread로 현재 스레드가 락을 보유 중일 때만 해제하도록 한다.
        if (lock.isHeldByCurrentThread) {
            lock.unlock()
            logger.info(&quot;[cache miss] 외부 API 호출 완료 (분산 락 해제): lockKey=$lockKey&quot;)
        }
    }
}</code></pre>
<h4 id="redis-lock-반영-이전-테스트-결과">Redis Lock 반영 이전 테스트 결과</h4>
<p>일부러 2초간의 외부 API 응답 지연을 주었고 1초에 약 500건의 요청을 보내보았을 때, 약 2초간의 딜레이 시간 동안 불필요하게 외부 API가 호출이 되고 있는 것을 볼 수 있다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/74c31d8d-fbb4-4120-8b5b-1423e1233059/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/7e254ad3-a279-478b-9059-0d39934ddf37/image.png" alt=""></p>
<ul>
<li>TPS : <code>178</code></li>
</ul>
<h4 id="redis-lock-반영-이후-테스트-결과">Redis Lock 반영 이후 테스트 결과</h4>
<p>위의 요청 파라미터 값은 그대로 한 채 다시 테스트 해보면, 첫 번째 응답이 늦어지면서 캐시에 늦게 세팅이 되고 -&gt; 이 순간에 발송된 대량의 요청들이 Lock에 의해 외부 API 호출이 제한되고 -&gt; 이후 정상적으로 캐시된 값을 읽어서 반환하는 것을 확인할 수 있다. 즉, 외부 API 요청이 1번으로 제한이 되는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/eba65850-aa55-43f5-aca0-67a59acbe5ca/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/29bc4389-1f35-4f17-923a-befb728eda64/image.png" alt=""></p>
<ul>
<li>TPS : <code>265</code></li>
</ul>
<blockquote>
<p>☁️ <strong>테스트 결론</strong>
실제 운영 환경에서 이런 상황이 얼마나 자주 발생할지는 미지수지만, 결국 장애는 예상치 못한 순간에 터지지 않을까? 평소에 트래픽 집중 구간이나 외부 의존성이 있는 코드 경로를 한 번쯤 의심해보는 습관이 나중에 실제 장애 앞에서 당황하지 않는 힘이 된다고 생각한다. 또한 이번 문제 해결 작업이 단순한 기능 구현을 넘어 서비스의 안전성을 더욱 고민하는 좋은 계기가 되었다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 분산 환경에서는 트랜잭션 원자성을 어떻게 보장할 수 있을까?]]></title>
            <link>https://velog.io/@semi-cloud/TIL-%EB%B6%84%EC%82%B0-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%9B%90%EC%9E%90%EC%84%B1%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%B3%B4%EC%9E%A5%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@semi-cloud/TIL-%EB%B6%84%EC%82%B0-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%9B%90%EC%9E%90%EC%84%B1%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%B3%B4%EC%9E%A5%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Thu, 04 Sep 2025 10:41:03 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>분산 환경에서 발생하는 문제와 해결 방안을 학습하고, 이를 기존에 진행했었던 페이 프로젝트에도 적용해 설계를 고도화해보자.</p>
</blockquote>
<h3 id="모놀로식에서-분산-환경으로의-변화">모놀로식에서 분산 환경으로의 변화</h3>
<p>모놀리식 환경에서는 단일 데이터베이스 내에서 <strong>로컬 트랜잭션을 통해 ACID</strong> 특성을 쉽게 보장할 수 있었다.</p>
<p>하지만 MSA로 전환되어 서버와 데이터베이스가 각각 분리되고, 하나의 비즈니스 로직이 여러 서비스와 DB에 걸쳐 수행되기 시작하면서 이야기가 달라진다. 이제는 더 이상 하나의 로컬 트랜잭션으로 전체 과정을 묶을 수 없게 되었고, 특정 서버에서 작업이 실패하더라도 다른 서버가 그 사실을 알 수 없어 원자성이 깨질 위험이 생긴다.</p>
<blockquote>
</blockquote>
<p>가령 상품 구매 서버, 재고 관리 서버, 결제 서버가 분리되어 있는 상황에서 <code>상품구매(성공) -&gt; 재고 관리(성공) -&gt; 결제 서버(실패)</code> 의 흐름이라면 결제 서버의 실패로 인해 상품과 재고 로직까지 모두 원상태로 복구를 해야 원자성이 보장될 것이다.</p>
<p>따라서 이러한 <strong>분산 환경에서는 여러 서비스 간 데이터 정합성을 보장하기 위한 글로벌 트랜잭션 관리 방식</strong>이 필요하다. 이번 글에서는 그중 대표적인 설계 패턴인 2PC(2-Phase Commit) 과 사가(Saga) 패턴을 중심으로, 각각의 동작 원리와 어떤 상황에서 적용하는 것이 적절한지를 살펴보고자 한다.</p>
<h3 id="1-2-page-commit-방식">1. 2 Page Commit 방식</h3>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/2d29ed25-0240-4748-8cf6-09b97d90f3b9/image.png" alt=""></p>
<p>여러 노드가 하나의 트랜잭션을 동시에 처리해야 할 때, <strong>모든 노드가 &#39;성공&#39;이라고 동의해야만 커밋이 되는</strong> 분산 트랜잭션 프로토콜이다. 트랜잭션 상태(커밋/롤백)를 조정하는 조정자와, 트랜잭션의 대상이 되는 참가자들로 구성이 된다. </p>
<ol>
<li>투표 단계</li>
</ol>
<ul>
<li><p>트랜잭션 참여자들에게 커밋 가능 여부 질의하면, 참여자들은 트랜잭션 열고 커밋 가능 여부를 조정자에게 응답한다.</p>
<ol start="2">
<li>커밋 단계</li>
</ol>
</li>
<li><p>조정자는 모든 참여자들에게 커밋 가능이라고 응답이 오면, 그때 실제 커밋 요청을 보내 트랜잭션을 종료시킨다. 만약 이때 단 하나라도 커밋 불가능 요청을 보낸 경우, 롤백 요청을 보내 트랜잭션을 실패로 종료시킨다. </p>
</li>
</ul>
<pre><code>Coordinator                                          Participant
                         QUERY TO COMMIT
                --------------------------------&gt;
                         VOTE YES/NO             prepare*/abort*
                &lt;-------------------------------
commit*/abort*           COMMIT/ROLLBACK
                --------------------------------&gt;
                         ACKNOWLEDGEMENT          commit*/abort*
                &lt;--------------------------------  
end</code></pre><h4 id="장점">장점</h4>
<p>2 Page Commit(2PC) 방식은 시간이 오래 걸리더라도 결과적으로 <strong>강하게 일관성을 보장</strong>할 수 있다. 사전 투표를 통해 커밋 가능 여부가 확정된 이후에 커밋이 이루어지기 때문이다.</p>
<p>또한 그림을 자세히 보면, DB는 <strong>쓰기 시작부터 커밋이 완료될 때까지 락</strong>을 걸고 있다. 이는 두 데이터베이스가 불일치된 상태로 조회되는 것을 막아주며 데이터 정합성을 보장해준다.</p>
<h4 id="단점">단점</h4>
<p>그렇다면 단점은 어떤 것들이 있을까? 우선 코디네이터에 장애가 발생하다면 각 데이터베이스는 커밋과 롤백 여부를 스스로 결정할 수 없는 <strong>SPOF 문제</strong>가 존재한다.</p>
<p>무엇보다 특정 참여자가 응답을 늦게 준다면, 다른 서버들은 응답이 느린 참여자까지 기다리면서 조정자에게 커밋 가능 요청이 올 때 까지 트랜잭션을 열고 있어야 한다. <strong>즉, 최종 커밋 명령을 내릴 때까지 참여자들은 해당 리소스에 잠금이 걸려 성능이 떨어지게 되는 것이다.</strong></p>
<p>문제는 현실에서는 항공권 예약, 결제, 알림과 같이 몇 분을 넘어 몇 시간, 심지어 며칠 동안 이어지는 <strong>장기 실행 트랜잭션</strong> (Long-Lived Transaction)들이 존재한다. 이런 경우는 2PC 방식으로 처리하기가 거의 불가능 했기에, 2 Page Commit의 단점을 보완한 대안으로 사가(SAGA) 패턴이라는 것이 출범하였다.</p>
<h3 id="2-saga-패턴">2. SAGA 패턴</h3>
<p>SAGA 패턴은 <strong>개별 노드에서 로컬 트랜잭션이 실행되고 연속적으로 이어지면서 전체 비즈니스 트랜잭션이 구성</strong>되는 방식이다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/efbdd28f-62a4-45a1-871d-635cedf7934e/image.png" alt=""></p>
<p>즉 <code>트랜잭션1 완료 -&gt; 트랜잭션2 트리거 -&gt; 트랜잭션2 완료 -&gt; 트랜잭션3 트리거 -&gt; 트랜잭션 3완료</code> 의 형태를 띄게 된다.</p>
<blockquote>
<p>2PC 방식과의 차이점</p>
</blockquote>
<ul>
<li>문제가 발생했을 때는 롤백을 하지 않고 <strong>보상을 통해 트랜잭션의 전체의 일관성</strong>을 간접적으로 유지한다.</li>
<li>각각 관련된 단계를 독립적으로 수행하기 때문에 때문에 2PC와 다르게 <strong>오랜 시간 동안 리소스를 잠그지 않아도 된다.</strong></li>
</ul>
<p>여기서 가장 주목해야할 차이점은 사가 패턴에서는 하나의 비즈니스 로직이 여러 서비스에 걸쳐 수행되므로, <strong>중간에 일부 단계가 실패하더라도 전체를 롤백하기보다는 이전에 수행된 작업을 보상 트랜잭션으로 되돌려 논리적 일관성을 유지한다는 것이다.</strong></p>
<p>이처럼 사가 패턴에서 서비스 간 통신은 상태를 주고받기 위해 주로 <strong>이벤트 기반 아키텍처</strong>로 설계가 된다. 그렇기 때문에 이벤트 결과를 통해 어떤 작업을 수행할지 결정하기 위한 <code>state machine</code> 을 가지고 있어야 한다.</p>
<p>사가 패턴에는 아래와 같이  오케스트레이션과 메시징 두 가지 방식이 존재한다.</p>
<h4 id="1-오케스트레이션-방식">1. 오케스트레이션 방식</h4>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/da4b0567-e676-4c4e-b331-cfa57143fd30/image.png" alt=""></p>
<p><strong>중앙 제어자</strong>가 서비스들에게 <strong>트랜잭션과 보상 트랜잭션을 직접 명령</strong>하는 방식이다. 예를 들어 주문-결제-배송의 흐름이라면, 오케스트레이터가 ‘주문 생성 → 결제 승인 → 배송 요청’을 순차적으로 지시하고, 중간 단계에서 실패가 발생하면 대응되는 보상 트랜잭션(ex) 결제 취소, 주문 취소)을 실행하도록 제어한다.</p>
<p>중앙에서 전체 트랜잭션 흐름을 제어하기 때문에 비즈니스 로직을 한눈에 파악하기 쉽고, 중간 상태들을 쉽게 <strong>모니터링하고 추적</strong>할 수 있다는 장점이 있다.</p>
<p>하지만 중앙 제어자가 오히려 모든 흐름을 관리하기 때문에 <strong>단일 장애 지점(SPOF)</strong>이 될 수 있으며, 구현의 복잡도 또한 증가한다는 단점도 있다. </p>
<h4 id="2-메시징-방식">2. 메시징 방식</h4>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/c3c0de83-ec5a-476b-ac84-23f213b3c787/image.png" alt=""></p>
<p>중앙 제어자 없이 메시지 큐로 명령을 전송하는 방식으로, 오케스트레이션 방식과 다르게 1) <strong>비동기</strong> 방식인 메시지 브로커로 인해 <strong>각 서비스들이 느슨하게 결합되고</strong> 2) 중앙 제어자로 인한 <strong>SPOF가 존재하지 않는다</strong>는 장점이 있다.</p>
<p>하지만 문제는 반대로 중앙 제어자가 없으므로 현재 진행중인 트랜잭션 상태를 추적하거나 디버깅하기 어렵다. 메시지 브로커를 사용하므로 &quot;어디까지 완료됐는지&quot;, &quot;어디서 실패했는지&quot;를 DB만 보고 바로 알 수 없기 때문이다.</p>
<blockquote>
<p>🔗 *<em>언제 사용하면 좋을까? *</em>
데이터의 상태를 실시간으로 추적하고 관리해야 하는 경우, 즉 모니터링이 필요하다면 오케스트레이션 사가가 더 적합하고, 그렇지 않다면 대게 메시징 방식이 더 유리하다.</p>
</blockquote>
<h3 id="사가-패턴에서-실패를-핸들링-하는-방법">사가 패턴에서 실패를 핸들링 하는 방법</h3>
<h4 id="정상적-실패-처리--보상-트랜잭션-발생">정상적 실패 처리 : 보상 트랜잭션 발생</h4>
<p>앞서 말했듯이, 비즈니스 예외와 같은 정상적 실패의 경우 <strong>보상 트랜잭션</strong>을 통해 원자성을 보장할 수 있다. 보상 트랜잭션은 전통적인 DB 롤백과 달리 <strong>비즈니스 레벨에서의 논리적 복구</strong>를 의미한다.</p>
<p>예를 들어 환전 로직이 <code>출금 -&gt; 입금</code> 순서대로 이루어진다고 가정해보자. 고객/계좌 거래 제한 등으로 인해 입출금이 실패하는 경우 다음과 같이 처리할 수 있다.</p>
<ol>
<li>출금이 실패하는 경우 : 추가적은 요청 없이 그냥 실패로 마무리한다.</li>
<li>입금이 실패하는 경우 : 앞서서 수행했던 출금 요청을 되돌리기 위해 X원을 다시 입금하는 보상 트랜잭션을 수행한다.</li>
</ol>
<h4 id="보상-트랜잭션의-원자성-보장--트랜잭션-아웃박스-패턴">보상 트랜잭션의 원자성 보장 : 트랜잭션 아웃박스 패턴</h4>
<blockquote>
<p>하지만 다음과 같은 최악의 상황을 가정해보자. 보상 트랜잭션을 수행하기 위해 메시지 브로커에 이벤트를 발행해야 하는데, 만약 비즈니스 로직은 성공했지만 &quot;메시지 발행이 실패&quot;한다면 어떻게 될까?</p>
</blockquote>
<pre><code class="language-java">
// 출금 비즈니스 로직 수행 : 성공
PaymentService.withdraw();

// 다른 서버가 입금 로직을 수행하기 위해 메시지 브로커로 이벤트 전송 : 실패
MessageBroker.send(new Event(...)); 
</code></pre>
<p>위와 같은 경우 출금은 성공했는데 메시지 브로커 특성 상 비동기로 인해 보상 메시지는 전송되지 않아 다른 서비스(입금 서비스)는 상태를 복구하지 못하게 된다. 즉, 비즈니스 트랜잭션과 메시지 발행의 원자성이 깨지는 문제가 발생하는 것이다.</p>
<p>이를 해결하기 위한 대표적인 접근법 중 하나가 <code>트랜잭션 아웃박스(Transaction Outbox) 패턴</code> 이다. 트랜잭션 아웃박스 패턴은 <strong>메시지 생성 자체를 로컬 DB 트랜잭션에 포함시켜 비즈니스 로직과 메시지 발행이 항상 함께(원자적으로) 처리</strong>되도록 하는 방법을 의미한다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/bb0397de-9244-4ac6-9f02-c3d733505bd8/image.png" alt=""></p>
<pre><code class="language-java">
// 출금 비즈니스 로직 수행
PaymentService.withdraw();

// 다른 서버가 입금 로직을 수행하기 위해 메시지 브로커로 이벤트 전송
OUTBOXRepository.save(new Event(...));
</code></pre>
<p>즉 <strong>이벤트를 바로 브로커에 발행하지 않고, 별도의 OUTBOX 테이블에 저장하는 것 까지를 하나의 트랜잭션</strong>으로 묶는다. 만약 트랜잭션 커밋이 성공적으로 되었다면 OUTBOX 테이블에 저장된 이벤트 데이터를, 이후 별도의 메시지 전송 프로세스가 OUTBOX를 읽어 메시지 브로커(ex) Kafka, RabbitMQ)에 실제로 발행한다.</p>
<p>이러한 방식은 1)데이터베이스 트랜잭션이 커밋되면 메시지가 발행되고 트랜잭션이 롤백되면 메시지를 보내지 않음을 보장할 수 있고, 2) 메시지 서비스는 보낸 순서를 유지한 채로 브로커로 전송됨을 보장할 수 있다.</p>
<blockquote>
<p>그렇다면 Outbox 데이터는 어떻게 메시지 브로커로 전송될까?</p>
</blockquote>
<p>일반적으로 두 가지 방식이 사용된다.</p>
<ul>
<li><p><a href="https://microservices.io/patterns/data/polling-publisher.html">Polling Publisher</a> : Outbox 테이블에 쌓인 메시지를 일정 주기로 polling(조회)하여 브로커로 전송하는 방식이다. 구현이 단순하지만, DB polling으로 인한 부하가 생길 수 있어 잘 사용되지 않는다.</p>
</li>
<li><p><a href="https://microservices.io/patterns/data/transaction-log-tailing.html">Transaction Log Tailing</a>  : DB의 트랜잭션 로그(binlog)를 읽어 커밋된 이벤트를 감지하고 브로커로 전송하는 방식이다. CDC(Change Data Capture) 기술을 활용하며 대표적으로 Debezium + Kafka Connect 조합을 많이 사용한다.</p>
</li>
</ul>
<h3 id="기존-시스템을-분산-트랜잭션으로-리팩토링-해본다면">기존 시스템을 분산 트랜잭션으로 리팩토링 해본다면?</h3>
<p>사실 여기까지는 일반적인 내용이였고, 좀 더 고도화해서 <a href="https://velog.io/@semi-cloud/DB-%EC%86%A1%EA%B8%88%EC%97%90%EC%84%9C%EC%9D%98-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%EA%B8%B0-feat.-%EB%9D%BD%EC%9D%84-%EC%B5%9C%EC%86%8C%ED%95%9C%EC%9C%BC%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EC%9E%90">해당 포스팅</a>에서 작성했었던 페이 프로젝트를 위에 학습한 내용을 적용해 다시 설계해본다면, 대략적으로 아래와 같이 구현할 수 있어보인다. </p>
<p>기존 시스템(단일 트랜잭션)과 달라진 점이라고 하면 <strong>A 차감과 B 입금 트랜잭션이 분리되고, 그 사이에 이벤트 브로커로 이벤트를 비동기적으로 전송</strong>한다는 것이다.</p>
<p>현재는 락을 걸지는 않았지만 만약 A에 비관적 락을 걸었을 경우 B 로직이 끝날 때 까지 기다리지 않고 락을 빠르게 반환할 수 있을 것이며, 사용자가 직접 확인을 하고 수취를 하는 송금 Pending 상태를 구현할 수 있다. 또한 비동기이기 때문에 장애 상황에서도 우선 사용자에게 빠른 응답을 반환하게끔 하고 뒷단에서 장애를 극복할 시간을 벌 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/215c4dc3-25e2-4bf9-a865-7782552f2276/image.png" alt=""></p>
<p>다만 해당 과정에서 Transactional Outbox 패턴으로 차감 로직과 이벤트 발행을 원자적으로 처리해야 할 것이고, B 입금 로직이 실패하는 경우를 대비해서 X 번의 재시도 이후 다시 A 입금 보상 트랜잭션 로직을 적용해 데이터 정합성을 맞춰야 할 것이다.</p>
<p>(2025.12.09 수정)
송금은 비동기로 하게 되면 사용자 입장에서 분명 완료되었다고 했는데 B입금이 최종적으로도 실패하면 다시 송금 실패 알림이 뜨게 되므로, 부자연스럽다고 판단해 프로젝트에서는 동기 방식으로 수정해 놓은 상태이다.</p>
<h3 id="분산-환경의-트레이드-오프를-고려하자">분산 환경의 트레이드 오프를 고려하자!</h3>
<p>물론 MSA는 잘 설계되고 운영되는 환경에서는 독립적인 배포와 높은 확장성, 장애 전파 방지로 격리성 확보, 기술 스택의 자율성 등 매우 강력한 장점을 가진다. </p>
<p>하지만 모든건 장단점이 있듯이 이 역시 원자성을 보장하기 위해 사가 패턴이나 트랜잭션 아웃박스 패턴, CDC 같은 여러 패턴을 도입하게 되면서, 그만큼 구현 난이도와 시스템 복잡도는 급격히 증가하고 모니터링 및 운영 비용 또한 함께 커지게 된다. 또한 이러한 부분들을 제대로 고려하지 않으면 정합성이 깨져 장애가 발생하는 더 큰 문제로 발생할 수 있다.</p>
<p>따라서 아키텍처의 트레이드오프를 충분히 고려하고, MSA가 오버엔지니어링이 되지 않도록 현재 시스템의 규모와 운영 역량을 면밀히 검토한 뒤 단계적으로 도입하는 것이 좋지 않을까 생각한다.</p>
<blockquote>
<p>참고 자료
<a href="https://microservices.io/patterns/data/saga.html">https://microservices.io/patterns/data/saga.html</a>
<a href="https://learn.microsoft.com/ko-kr/azure/architecture/patterns/saga">https://learn.microsoft.com/ko-kr/azure/architecture/patterns/saga</a>
<a href="https://medium.com/nerd-for-tech/transactions-in-distributed-systems-b5ceea869d7d">https://medium.com/nerd-for-tech/transactions-in-distributed-systems-b5ceea869d7d</a>
<a href="https://www.youtube.com/watch?v=xpwRTu47fqY">https://www.youtube.com/watch?v=xpwRTu47fqY</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 외부 API 장애 상황에서 어떻게 안정성을 보장할 수 있을까?]]></title>
            <link>https://velog.io/@semi-cloud/%EC%82%BD%EC%A7%88%EB%A1%9C%EA%B7%B8-%EC%99%B8%EB%B6%80-API-%EC%9E%A5%EC%95%A0-%EC%83%81%ED%99%A9%EC%97%90%EC%84%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%95%88%EC%A0%95%EC%84%B1%EC%9D%84-%EB%B3%B4%EC%9E%A5%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C-feat.-%EC%84%9C%ED%82%B7-%EB%B8%8C%EB%A0%88%EC%9D%B4%EC%BB%A4</link>
            <guid>https://velog.io/@semi-cloud/%EC%82%BD%EC%A7%88%EB%A1%9C%EA%B7%B8-%EC%99%B8%EB%B6%80-API-%EC%9E%A5%EC%95%A0-%EC%83%81%ED%99%A9%EC%97%90%EC%84%9C-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%95%88%EC%A0%95%EC%84%B1%EC%9D%84-%EB%B3%B4%EC%9E%A5%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C-feat.-%EC%84%9C%ED%82%B7-%EB%B8%8C%EB%A0%88%EC%9D%B4%EC%BB%A4</guid>
            <pubDate>Fri, 15 Aug 2025 14:49:51 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>프로젝트에서 외부 API를 호출해서 열차 정보들을 받아오고 있는데, 만약 외부  API 서버가 장애가 발생해서 응답이 지연된다면 우리 서버에는 어떤 상황이 발생하고, 어떻게 해결할 수 있을까? 라는 궁금증이 생겨 작성한 글이다.</p>
</blockquote>
<p>현재 서버에서는 <code>Blocking I/O</code> 방식인 <code>RestTemplate</code> 을 활용해 외부 API를 호출하고 있다. </p>
<pre><code class="language-kotlin"> val response = restTemplate.exchange(url, HttpMethod.GET, null, TrainRealTimeDto::class.java).body!!</code></pre>
<p>그러다 보니 만약 외부 API가 느려진다면? 이론적으로 생각해보면 톰캣은 미리 스레드를 생성해놓는 스레드 풀 방식을 사용하니 대규모 트래픽이 들어오는 상황에서 <code>maxThreads</code> 만큼 스레드가 모두 사용 중이면 그 이후의 요청은 큐에서 대기하게 된다. <strong>즉 전체 처리량이 매우 낮아지게 되는 문제</strong>가 발생할 것이다.</p>
<p>따라서 이런 현상을 정상/장애 상황에서 직접 부하테스트를 통해 TPS 값을 측정해보고 스레드 덤프로 스레드 상태를 확인해보며, <strong>서버 안전성을 보장하기 위한 방법(서킷 브레이커 도입 등)</strong>에 대해 알아보자.</p>
<h2 id="테스트-환경-세팅">테스트 환경 세팅</h2>
<h3 id="1-wiremock을-통해-외부-api-지연시키기">1. WireMock을 통해 외부 API 지연시키기</h3>
<p>우선 외부 API 응답을 인위적으로 늦추기 위해, HTTP 요청을 가로채고 원하는 응답을 조작할 수 있는 가짜 서버인 WireMock을 사용해볼 수 있다.</p>
<p><a href="https://wiremock.org/docs/standalone/java-jar/">해당 링크</a>에서 JAR 파일을 다운 받고, 다운받은 폴더로 들어가서 아래 명령어를 통해 실행시키면 된다.</p>
<pre><code class="language-java"> java -jar wiremock-standalone-4.0.0-beta.15.jar --port 9090 </code></pre>
<p>처음 실행시키면 <code>mappings</code> 란 폴더가 자동으로 생성되는데, 해당 폴더 내부에 <code>.json</code> 형식의 stub 파일을 생성해두면 된다. 예를 들어 1) 정상 상태, 2) 응답 지연 2초, 3) 응답 지연 5초 + 500 에러 상황별로 총 세개의 상황을 테스트하기 위한 파일을 생성해둘 수 있다.</p>
<p>이후 스프링 서버에서 해당 임시 경로를 호출하도록 수정해주면 설정은 완료된다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/8279b580-6fe8-415a-b4f2-76205e75a0d4/image.png" alt=""></p>
<h4 id="주의사항">주의사항</h4>
<p>WireMock 응답의 Content-Type이 <code>application/octet-stream</code> 이기 때문에, <code>HttpMessageConverter</code> 가 제대로 동작하기 위해서는 응답 헤더에 Content-Type을 <code>application/json</code> 으로 명시 해줘야 한다. </p>
<h3 id="2-스레드-덤프로-스레드-상태-확인하기">2. 스레드 덤프로 스레드 상태 확인하기</h3>
<p>다음으로 스레드의 상태를 확인해보자. 시각적으로 빠르게 파악하려면 <a href="https://visualvm.github.io/download.html">Java VisualVM</a> 프로그램을 활용해볼 수 있고, 혹은 직접 스레드 덤프 명령어를 활용해볼 수 있다. 실제 스프링 부트 서버를 켜서 해당 스레드 정보들을 확인해보면, 아래처럼 다양한 스레드가 존재하는 것을 볼 수 있다.</p>
<p>우리가 집중적으로 확인해야 하는 것은 <strong>톰캣에서 요청을 처리하는 <code>http-nio</code> 로 시작하는 스레드</strong>이기 때문에 해당 정보 위주로 파악을 해보자.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/e796f1fe-263b-4476-86ab-9d84ed138924/image.png" alt=""></p>
<h4 id="톰캣의-nio-커넥터-구조">톰캣의 NIO 커넥터 구조</h4>
<p><code>http-nio-8080-Acceptor</code>, <code>http-nio-8080-Poller</code> 같은 소수의 셀렉터 스레드가 연결과 이벤트를 처리하며, 실제 요청을 실행하는 워커 스레드는 <code>http-nio-8080-exec-#</code> 이다. </p>
<p>초기에는 기본값인 <code>minSpareThreads(기본 10)</code> 를 생성해두고, 부하가 증가할 때 exec 스레드를 늘려나가면서 최대 <code>maxThreads(기본 200)</code> 개까지 확장시킨다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/d9ab152b-7c50-4cd8-ab93-49d4e8ee65df/image.png" alt=""></p>
<ul>
<li><code>Acceptor</code> : 소켓 요청을 받고, Poller Event 를 발생키셔 PollerEvent 큐에 저장한다.</li>
<li><code>Poller</code> : 하나의 스레드로, 내부에서 유지되는 셀렉터에 Poller Event의 <code>NIO 채널</code> 을 등록한다. 셀렉터에 등록되어 있는 다수의 채널 중 <code>select()</code> 를 통해 요청이 온(읽을 데이터가 있는) 소켓을 얻고, 해당 요청에 대한 처리를 워커 스레드풀에서 가져온 <code>워커 스레드</code> 에 할당시킨다.</li>
</ul>
<p>BIO 커넥터와 다른 점은, 요청이 들어오면 바로 워커 스레드에 할당시키지 않고, 셀렉터에 등록해두었다가 실제 데이터 처리가 가능한 경우일 때 워커 스레드를 할당시킨다는 점이다. 즉 <strong>연결은 많지만 실제 요청은 드문 상황에서 발생하는 스레드 낭비(idle)를 줄여준다는 장점이 있다.</strong></p>
<h4 id="thread-dump를-통해-스레드-상태-파악하기">Thread Dump를 통해 스레드 상태 파악하기</h4>
<p>스레드 상태를 파악하려면, 우선 스레드 덤프를 통해 자세한 정보를 추출해내야 한다. 아래와 같은 명령어를 직접 사용하거나 Java VisualVM 내부 기능을 활용하면 편하다.</p>
<pre><code class="language-bash"># spring boot server의 port인 8080을 사용하고 있는 프로세스를 찾아서 스레드 덤프를 뜬다.
jstack $(lsof -t -iTCP:8080 -sTCP:LISTEN) &gt; thread_dump.txt</code></pre>
<p>Java VisualVM 스레드 탭을 보면 다음과 같은 상태들을 확인할 수 있는데, 실제 <a href="https://docs.oracle.com/en/java/javase/23/docs/api/java.base/java/lang/Thread.State.html">자바 공식 문서</a>에도 나와있듯이 스레드는 아래와 같은 총 5가지의 상태를 가진다. </p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/9391c928-ce7e-4ddc-b420-bf20c8a20cae/image.png" alt=""></p>
<ol>
<li><p><code>NEW</code> : 스레드가 생성되었으나 <strong>아직 시작되지 않은 상태</strong>로, <code>Thread.start()</code> 를 호출해야 OS가 실제로 스레드를 생성하도록 신호를 보냄</p>
</li>
<li><p><code>RUNNABLE(RUNNING)</code> : 스레드가 <strong>실행 가능</strong>하여 운영 체제의 자원(ex) CPU)을 기다리고 있거나 스케줄러에 의해 선택되어 JVM 내에서 실행중인 상태</p>
</li>
<li><p><code>BLOCKED(MONITOR)</code> : 스레드가 <strong>모니터 락을 기다리며 블록</strong>된 상태로, synchronized 블록/메서드에 들어가기 위해 또는 <code>Object.wait</code> 호출 시 해당 상태가 됌</p>
</li>
<li><p><code>WAITING(WAIT/PARK)</code> : <strong>무한정 대기 중</strong>인 상태로, <code>Object.wait(타임아웃 X)</code>, <code>Thread.join(타임아웃 X)</code>, <code>LockSupport.park</code> 호출 시 해당 상태가 됌</p>
</li>
<li><p><code>TIMED_WAITING(SLEEPING/PARK)</code> : 스레드가 <strong>지정된 시간 동안 대기</strong>하는 상태로, 
<code>Thread.sleep</code>, <code>Object.wait (타임아웃 O)</code>, <code>Thread.join (타임아웃 O)</code>, <code>LockSupport.parkNanos</code>, <code>LockSupport.parkUntil</code> 호출 시 해당 상태가 됌</p>
</li>
<li><p><code>TERMINATED</code> : <strong>실행 완료</strong>되었거나 예외가 발생하여 종료된 스레드의 상태</p>
</li>
</ol>
<blockquote>
<p>그러면 이중에 Blocking I/O 가 발생한 스레드는 어떤 상태를 가지게 될까?</p>
</blockquote>
<p>우선 <strong>요청을 받고 있지 않는 초기 상태의 스레드는</strong> 아래 사진에서 보면 알 수 있듯이 <code>LockSupport.park</code> 메서드 호출로 인해 <code>WAITING</code> 상태가 된다. </p>
<p>사실 로그를 자세히 보면 <code>Thread.run</code> 시 어떤 일들이 발생하고 어떤 스레드 풀 모델을 사용하는지 등을 알 수 있는데, <code>ForkJoinPool</code> 이나 <code>LockSupport</code> 개념 관련해서는 너무 길어지는 관계로 추후 따로 공부해서 포스팅해보겠다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/2bde4dbb-a26d-445b-b275-ba77c4f2b7b1/image.png" alt=""></p>
<p>따라서 <code>Blocking I/O</code> 가 발생한 스레드는 모니터 락이랑은 관련이 없기 때문에, 스레드 상태는 <strong><code>Runnable</code></strong> 이며 스택 트레이스를 확인해보면 <code>socketRead</code> 에서 멈춰 있는 것을 볼 수 있다.</p>
<pre><code class="language-java">&quot;http-nio-8080-exec-97&quot; #271 prio=5 tid=0x00007fcb7c123000 nid=0x6628 runnable [0x00007fcb64f9d000]
   java.lang.Thread.State: RUNNABLE
        at java.net.SocketInputStream.socketRead0(Native Method)
        at java.net.SocketInputStream.read(SocketInputStream.java:150)
        at org.apache.http.impl.io.AbstractSessionInputBuffer.fillBuffer(AbstractSessionInputBuffer.java:160)
        at ...</code></pre>
<h3 id="3-jmeter로-부하-테스트하기">3. Jmeter로 부하 테스트하기</h3>
<p>부하 테스트를 수행할 때는 별도 테스트 서버가 존재하는 Ngrinder를 사용할 수도 있지만, Jmeter가 이미 깔려있었고 러닝커브가 낮은 터라 선택했다. 단 로컬에서 테스트가 되기 때문에 서버 환경에 따라 결과가 영향을 받을 수 있다는 점을 주의하자.</p>
<p>Thread group을 생성할 때 다음과 같은 값들을 설정해서 부하를 생성하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/e18a4d13-efba-4726-9492-31a3c3b57b89/image.png" alt=""></p>
<ol>
<li><code>Number of Threads (users)</code> : 스레드수 - 유저 수</li>
<li><code>Ramp-up period (seconds)</code> : 지정된 유저가 모두 로딩될 시간</li>
<li><code>Loop Count</code> : 반복 횟수</li>
</ol>
<h4 id="동시-요청-상황">동시 요청 상황</h4>
<p><code>Number of Threads</code> 을 증가시키고, <code>Ramp-up period</code> 을 0으로 하면 1초 동안 트래픽이 몰리는 상황을 만들 수 있다. </p>
<h4 id="요청이-천천히-증가하는-상황">요청이 천천히 증가하는 상황</h4>
<p>Ram-up period를 설정하면 <strong>서버가 실제로 부하를 점진적으로 받는 상황</strong>을 시뮬레이션할 수 있다. 즉, 해당 시간동안 Jmeter가 총 요청을 분산시켜 점점 증가하는 형태로 요청을 보내므로 TPS 그래프는 보통 아래와 같이 나온다.</p>
<pre><code>TPS
│
│
│       /‾‾‾‾‾‾‾‾‾‾‾
│      /
│     /
│    /
│   /
│__/____________________→ 시간</code></pre><p>참고로 아래에서 다시 언급하겠지만, 외부 API가 지연되는 상황에서 서킷 브레이커를 제대로 테스트 하려면 <code>Ramp-up period</code> 을 늘려야 한다.</p>
<p>왜냐하면 Resilience4j는 호출이 완료될 때 통계를 갱신하고 상태를 전이하는데, 만약 외부 API ReadTimeout이 3초라면 첫 실패들이 최소 3초 뒤에야 완료되고 실패로 기록되어 서킷이 OPEN 되기 때문이다.</p>
<p>문제는 OPEN 되기까지 이미 CLOSED 상태에서 외부 API로 요청이 출발해버리기 때문에, 추후 OPEN 되더라도 이미 출발한 요청을 막을 수 없어 fallback 메서드가 수행되지 않는다. 따라서 <strong>OPEN 이전에 이미 출발한 요청을 최대한 줄이려면 요청을 나눠서 요청</strong>하는 것이 좋다.</p>
<blockquote>
<p>자 그러면 여기까지 테스트를 하기 위한 사전 지식과 준비는 완료되었다.</p>
</blockquote>
<h2 id="✅-시나리오-1--외부-api-정상-호출">✅ 시나리오 1 : 외부 API 정상 호출</h2>
<p>우선 외부 API가 지연 없이 정상적으로 호출되며, 1초에 400건(총 4000)씩 10초간 요청이 들어오는 상황을 테스트해보았다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/1d1b316b-eec6-4f19-aea8-c2ab11824278/image.png" alt=""></p>
<h3 id="1-resttemplate-커넥션-풀-x-타임아웃-설정-x">1) RestTemplate 커넥션 풀 X 타임아웃 설정 X</h3>
<p>기본적으로 아무 설정을 해주지 않으면 RestTemplate은 <code>SimpleClientHttpRequestFactory</code> 를 사용해서 <strong>매 요청마다 새로운 HTTP 연결을 수립</strong>하고, 응답이 오지 않아도 <strong>계속 대기</strong>(timeout 기본값 무제한)를 한다. </p>
<pre><code class="language-kotlin">@Configuration
class RestTemplateConfig {
    @Bean
    fun restTemplate(): RestTemplate {
        return RestTemplate()
    }
}</code></pre>
<h4 id="tps-결과">TPS 결과</h4>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/b39b870c-ba15-4c0e-9ec6-0b71512a9f77/image.png" alt=""></p>
<p>외부 API의 영향으로 인해 특정 지점 이후 일정하기보다 그래프가 요동치는 형태를 보인다. 평균적으로 <strong>180~200(평균 184) TPS</strong>가 나오며, 최대 250TPS가 나오는 것을 볼 수 있다. 초당 400 TPS 수준의 요청을 보냈지만 실제 처리율은 어플리케이션(스레드), 네트워크 I/O, DB(커넥션)단의 병목으로 인해 전체 요청 대비 대략 50%인 것을 볼 수 있다.</p>
<h4 id="스레드-상태">스레드 상태</h4>
<p>스레드 상태를 확인해보면 기본 개수를 넘어 증가한 것을 볼 수 있으며, 요청을 처리한 이후 나머지 스레드들은 <code>Keep-alive time</code> 만큼의 시간이 지나면 자연스럽게 사라진다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/semi-cloud/post/2969edcd-05d4-4f18-9949-324c2e6bd42a/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/semi-cloud/post/29e7da81-5752-4265-8eaa-0e2d17a26b89/image.png" alt=""></th>
</tr>
</thead>
</table>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/523d8c84-b5fc-4f34-956c-ef82ca57e1b6/image.png" alt=""></p>
<h3 id="2-resttemplate-커넥션-풀--외부-타임아웃-설정에서-테스트">2. RestTemplate 커넥션 풀 + 외부 타임아웃 설정에서 테스트</h3>
<p>이제 커넥션 풀을 사용해서  미리 커넥션을 맺어두고, 3초 타임아웃을 지정해보자.</p>
<pre><code class="language-kotlin">@Configuration
class RestTemplateConfig {

    @Bean
    fun poolingHttpClientConnectionManager(): PoolingHttpClientConnectionManager {
        // 커넥션 풀 설정
        val connectionManager = PoolingHttpClientConnectionManager().apply {
            maxTotal = 200                      // 전체 커넥션 최대 수
            defaultMaxPerRoute = 50             // 라우트(target host)당 최대 커넥션 수
        }
        return connectionManager
    }

    @Bean
    fun restTemplate(
        connectionManager: PoolingHttpClientConnectionManager
    ): RestTemplate {
        // 타임아웃 설정
        val requestConfig = RequestConfig.custom()
            .setConnectionRequestTimeout(Timeout.of(3000, TimeUnit.MILLISECONDS)) // 커넥션 풀에서 커넥션을 가져올 때 타임아웃
            .setResponseTimeout(Timeout.of(3000, TimeUnit.MILLISECONDS))     // 응답 대기 시간 (소켓 읽기)
            .build()

        val httpClient = HttpClients.custom()
            .setConnectionManager(connectionManager)
            .setDefaultRequestConfig(requestConfig)
            .build()

        val requestFactory = HttpComponentsClientHttpRequestFactory(httpClient)
        return RestTemplate(requestFactory)
    }
}</code></pre>
<h4 id="tps-확인">TPS 확인</h4>
<p>매번 외부 서버와 <strong>3-way handshaking으로 연결을 맺고,4-way handshaking으로 연결을 끊으며 발생하는 Timeout 오버헤드</strong>가 없어져서 그런지 평균 <code>214TPS</code> 로 처리량이 증가한 것을 볼 수 있다.</p>
<p>힙 메모리를 보면 <strong>커넥션 자원 사용량 측면에서도 유의미한 변화</strong>가 있는 것으로 보이며(600~700MB -&gt; 400MB), 추후 응답이 딜레이 되는 상황에서 timeout 설정도 굉장히 큰 역할을 하게 될 것이다. 따라서 웬만하면 커넥션 풀과 타임아웃을 설정해주자.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/965391c7-a912-4b83-bcbb-8c1099934095/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/99bdd06d-ca16-400f-a0c8-db82d7ca3a54/image.png" alt=""></p>
<h3 id="외부-api에-캐싱-도입하기">외부 API에 캐싱 도입하기</h3>
<p>만약 일정 시간 동안 같은 결과 값을 조회해도 괜찮거나, 클라이언트 단에서 값 조정이 가능한 경우에는 외부 API 결과값을 캐싱해서 전반적인 TPS를 향상시킬 수 있다.</p>
<blockquote>
<p>테스트 상황 : 1초에 200개씩 요청(총 4000개)</p>
</blockquote>
<p>왼쪽이 캐싱을 도입했을때, 오른쪽이 같은 상황에서 캐싱을 제거했을 때이다.</p>
<p>초반 구간을 제외하면 외부 API를 호출하지 않고 캐시 데이터를 바로 반환하므로 요청이 들어온 만큼 안정적으로 모두 처리(약 200TPS)를 하고 있는 것을 볼 수 있다.</p>
<table>
<thead>
<tr>
<th><img src="https://velog.velcdn.com/images/semi-cloud/post/8d85296d-1ca8-473d-9ba1-44e735dcae48/image.png" alt=""></th>
<th><img src="https://velog.velcdn.com/images/semi-cloud/post/645f4187-2ea8-40d2-b94d-a6153c719ee9/image.png" alt=""></th>
</tr>
</thead>
</table>
<h2 id="🔥-시나리오-2--외부-api-5초-지연장애-상황">🔥 시나리오 2 : 외부 API 5초 지연/장애 상황</h2>
<p>이번에는 외부 API 응답이 5초가 되는 경우를 테스트해보자. 요청은 똑같이 1초에 400개씩 총 10초간 4000개의 요청을 보내도록 했다.</p>
<h3 id="1-resttemplate-커넥션-풀-x-외부-타임아웃-설정-x">1. RestTemplate 커넥션 풀 X 외부 타임아웃 설정 X</h3>
<p>앞서서 말했듯이 아무런 설정을 해주지 않았기에, 외부 API 응답이 늦어지면 <strong>다양한 문제 상황이 발생</strong>하는 것을 볼 수 있다.</p>
<h4 id="🔗-hikaripooldb-커넥션-풀-자원-고갈-문제-발생">🔗 HikariPool(DB 커넥션 풀) 자원 고갈 문제 발생</h4>
<p>우선 첫번재 문제로는 타임 아웃 시간인 30초 동안 DB 커넥션을 얻지 못하는 문제가 발생한다.</p>
<p>왜냐하면 외부 API 호출 이전에 DB에 값을 조회해오는 부분 때문에 커넥션이 필요하지만, 한 트랜잭션이 너무 길어지게 되면서 <strong>커넥션 풀에 빠르게 자원을 반납하지 못하게 되고, 따라서 다른 요청들이 커넥션을 얻지 못하는 상황</strong>이 나타나기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/0b19177c-2d38-42ae-9982-2750446bf3f5/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/adf0c60b-7004-46c5-b325-edb1e7df0e06/image.png" alt=""></p>
<p>위에 테스트 결과를 참고해서 현재 서버가 평균 200TPS를 처리한다고 하면 아래와 같이 계산할 수 있는데, HikariCP의 <code>minimumIdle</code> 과 <code>maximumPoolSize</code> 기본값이 10이기 때문에 장애가 난 것으로 판단이 된다.</p>
<pre><code class="language-java">DB 커넥션 필요량 ≈ TPS × 커넥션 보유시간</code></pre>
<ul>
<li>트랜잭션 구간이 50ms:  200 TPS × 0.05s ≈ 10개의 작은 풀로도 버틸 수 있음</li>
<li>트랜잭션 구간이 5초: 200 TPS × 5s ≈  1,000개가 필요해 웬만한 풀(20~50)은 바로 고갈</li>
</ul>
<h4 id="🔗-tps가-급격히-떨어지는-문제-발생">🔗 TPS가 급격히 떨어지는 문제 발생</h4>
<p>당연히 처리량도 급격히 떨어진 것을 볼 수 있다. DB 커넥션 타임아웃으로 500 에러가 발생하면서 실패되는 요청이 <code>30초</code> 이후부터 급격히 발생하고 있는 것을 볼 수 있고, 정상 요청은 커넥션 풀 개수에 맞추어서 <code>10TPS</code>의 처리량을 보이고 있다.</p>
<ul>
<li><p>확대본
<img src="https://velog.velcdn.com/images/semi-cloud/post/639ada3e-089e-4795-9f54-717508e98088/image.png" alt=""></p>
</li>
<li><p>전체 결과본
<img src="https://velog.velcdn.com/images/semi-cloud/post/072f8372-120e-44e6-be0f-def80053c66f/image.png" alt=""></p>
</li>
</ul>
<h4 id="스레드">스레드</h4>
<p>스레드는 커넥션 타임아웃에 의해 외부 API가 호출되지 않은 요청들을 제외하고는, 아래에서 볼 수 있듯이 5초 동안 <code>Running</code> 상태에 있는 것을 볼 수 있다. 
<img src="https://velog.velcdn.com/images/semi-cloud/post/b82f8b33-ba92-4b80-bc85-52c773a2140a/image.png" alt=""></p>
<p>사전 단계에서 살펴봤듯이, 스레드 덤프로 확인해보면 <strong>Blocking I/O는 JVM이 직접 wait를 건 게 아니라 OS 시스템콜 차원에서 막힌 거라서 RUNNABLE</strong>로 나오게 된다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/73c2c5a7-8194-4ea9-a647-1f7939464472/image.png" alt=""></p>
<h3 id="2-resttemplate-커넥션-풀-o-외부-타임아웃-설정-o-테스트">2. RestTemplate 커넥션 풀 O 외부 타임아웃 설정 O 테스트</h3>
<p>그렇다면 이제 다시 커넥션 풀과 <strong>3초 타임아웃</strong>을 설정해주고 부하 테스트를 해보자. 더 정확한 테스트를 위해 외부 API가 호출되는 요청(blocking-test)과, 아예 관련없는 다른 기능(normal-test)도 함께 호출해주었다.</p>
<ul>
<li>외부 장애 API 호출 : 10초 동안 3000</li>
<li>정상 API 호출 : 10초 동안 1000</li>
</ul>
<p>마찬가지로 테스트 결과는 3초동안 반드시 기다려야 하므로 <code>Connection timeout</code> 예외가 발생하지만, 이번에는 <code>Read time out</code> 예외가 함께 발생하는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/7d1ef014-b1ec-48fa-ae22-f46db86ed752/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/3c943b4f-e447-4ad0-b415-01d546d2afcb/image.png" alt=""></p>
<h4 id="tps-측정-결과">TPS 측정 결과</h4>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/0f9ed63c-0b95-4e64-9384-244621ad54e1/image.png" alt=""></p>
<p>너무 오래 걸려서 중간에 중단했지만, 타임아웃이 없을 때랑 비슷한 그래프 양상을 띄며 평균적으로 10TPS 정도로 처리되고 있는 것을 볼 수 있다. 트래픽을 적게 해서 테스트해보면 아래와 같이 자세히 볼 수 있다.</p>
<ol>
<li><p>장애 API(외부 API 호출 기능)
<img src="https://velog.velcdn.com/images/semi-cloud/post/f9c75c3c-8448-40a2-994f-77ed37141773/image.png" alt=""></p>
<p>큰 스파이크가 나는 시점은 한꺼번에 30초 커넥션 타임아웃이 완료되는 순간이며, 그 직전까지는 3초 read 타임 아웃에 대한 실패 응답이 반환된다.</p>
</li>
<li><p>정상 API(다른 기능)
<img src="https://velog.velcdn.com/images/semi-cloud/post/f0089335-3b44-4629-bc7a-effbac3c176e/image.png" alt=""></p>
<p>시작 직후엔 서버가 아직 여유라 TPS가 높게 치솟고, 곧 느린 API가 워커 스레드/커넥션을 대량 점유하면서 정상 요청에 대기가 발생하며 TPS가 10~15 수준으로 줄어들고, 이후 느린 쪽 API의 타임아웃이 한꺼번에 터져 스레드가 해방되는 순간에 잠깐 TPS가 회복되어 반등하는 패턴을 보인다.</p>
</li>
</ol>
<h4 id="스레드-덤프-결과">스레드 덤프 결과</h4>
<p>스레드 상태를 확인해보면 마찬가지로 Blocking I/O로 인해 <code>RUNNALBE</code> 이지만, 앞과 다르게 <code>PoolingHttpClientConnectionManager</code> 를 사용하고 있다는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/2d7bcf68-e7a9-4782-9676-d618f331301e/image.png" alt=""></p>
<h3 id="3-db-커넥션-풀-사이즈-조정-및-트랜잭션-분리해보기">3. DB 커넥션 풀 사이즈 조정 및 트랜잭션 분리해보기</h3>
<blockquote>
<p>지연 상황에서 DB 커넥션 타임아웃 에러를 해결하려면 어떻게 해야할까?</p>
</blockquote>
<p>TPS 향상은 어짜피 톰캣 스레드 풀에서 병목이 존재하니 효과가 없겠지만, 커넥션 timeout 에러 해결만을 놓고 본다면 hikariCP 커넥션 풀의 개수를 늘려보는 방향으로 접근해볼 수 있다.</p>
<p>다만 이론적으로 생각해보면 스레드가 block 되는 시간이 3초이기 때문에 몇천개 단위로 늘려야 안정적으로 감당할 수 있을 것 같아 보인다. 트랜잭션 구간이 최소 3초라고 가정하면, 아래 공식에 따라 200 TPS × 3s ≈ 600개 정도가 넉넉하게 필요할 것이다.</p>
<pre><code class="language-java">필요한 동시 커넥션 ≈ 목표 TPS × 평균 DB 사용시간(초)</code></pre>
<pre><code class="language-yml">spring:
  datasource:
    driver-class-name: org.h2.Driver
    ...
    hikari:
      maximum-pool-size: 600   // h2 자체 max_connection X</code></pre>
<h4 id="테스트-결과">테스트 결과</h4>
<p>실제 600개까지 과도하게 늘려보니 connection timeout 없이 정상적으로 처리가 된 것을 볼 수 있으며, 평균 처리량은 139TPS가 나온다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/2b4c8cfd-bef4-42f0-827b-3a1366da1fd8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/706f9d6c-5066-417b-801c-78167528a8a5/image.png" alt=""></p>
<blockquote>
<p><strong>Q. 하지만 커넥션 풀 사이즈를 늘리는게 항상 좋은걸까?</strong></p>
</blockquote>
<p>주의할 점이 위의 공식은 굉장히 이상적인 상황이고, 현실에서는 DB를 기준으로 <strong>안정적으로 처리 가능한 동시 쿼리 개수 만큼만 커넥션 풀을 설정</strong>해야 하는 것이 좋다.</p>
<p>실제 HikarCP와 PostgreSQL에서는 아래와 같은 공식을 권장하고 있다. 아래 공식은 CPU 코어수와 DB의 동시 처리 능력을 고려한 크기 설정 방식이다.</p>
<pre><code class="language-java">core_count * 2 + effective_spindle_count</code></pre>
<ul>
<li><code>core_count * 2</code> : CPU 컨텍스트 스위칭으로 인한 오버헤드를 고려해도 I/O로 블로킹되는 시간에 다른 작업들을 처리할 수 있다.</li>
<li><code>effective_spindle_count</code> : 하드 디스크는 spindle을 회전시켜 요청을 처리하기에 디스크가 N개 있다면 동시에 N개의 I/O 요청을 처리할 수 있다.</li>
</ul>
<pre><code class="language-java">// local 환경 기준
CPU 코어: 8코어 (성능 4 + 효율 4)
SSD이므로 effective_spindle_count = 1
connections = (8 * 2) + 1 = 17</code></pre>
<p>기본적으로 최소한으로 유지하고 있는 커넥션 개수인 <code>minimum-idle</code> 은 기본값이 최대로 생성 가능한 커넥션 개수인 <code>maximum-pool-size</code> 이다. 따라서 만약  <code>minimum-idle</code> 을 작게 설정하고 <code>maximumPoolSize</code> 를 늘리는 경우는 <strong>런타임에 커넥션을 추가로 생성하는 부분에서 오버헤드가 발생할 수 있어 고정적인 성능</strong>을 내기 힘들다. </p>
<p>그래서 <strong>두 설정 값을 같게</strong> 가져가되, <strong><code>maximum-pool-size</code> 만  적절하게 조정</strong>해주는 것을 실제로도 권장하고 있다. 그렇기 때문에 풀 사이즈를 엄청나게 크게 잡게되면 요청이 적게 들어오고 트랜잭션이 짧은 경우 오히려 불필요하게 생성되어 있는 <strong>커넥션 자원이 낭비로 이어지고, 어짜피 DB 서버에서 처리할 수 있는 동시 커넥션 수가 존재하기 때문에 CPU 컨텍스트 스위칭으로 인한 오버헤드(CPU 부하 증가)</strong>만 발생할 수 있다.</p>
<p>따라서 해당 API 지연 상황에서는 적합하지 않은 방법이라고 볼 수 있다.</p>
<blockquote>
<p><strong>Q. 혹은 트랜잭션 수행 시간을 줄여서 빠르게 커넥션이 반납되도록 하면 되지 않을까?</strong></p>
</blockquote>
<p>맞다. 트랜잭션 수행 시간을 <code>ms</code> 까지 줄이기 위해 <strong>DB 커넥션이 필요한 부분과 외부 API 호출 부분을 아예 별도로 분리하고, JPA OSIV 설정을 끄는 방식</strong>으로도 해결할 수 있다.</p>
<p>기본적으로 JPA <code>Open‑Session‑In‑View(OSIV)</code> 설정값이 켜져 있다면 커넥션을 트랜잭션 종료가 아닌 <strong>API 응답이 종료될 때 까지 유지</strong>하고 있기 때문이다.   이렇게 되면 트랜잭션이 끝난 이후의 영역에서 지연 로딩과 같은 기능을 활용하지 못해 자칫 예외가 발생할 수 있지만, 다행히 프로젝트 전반적으로 엔티티를 서비스 계층 외부로 노출하지 않고 <code>DTO</code> 를 활용하고 있었기에 설정값을 쉽게 끌 수 있었다.</p>
<p>주의할 점은 <code>@Transactional(readOnly=True)</code> 가 동작하려면 같은 클래스에서 내부 메서드로 DB 조회 로직을 분리하면 안되고, 별도 클래스로 생성해줘야 한다. 내부로 분리하면 메서드 진입점인 <code>getTrainRealTimes</code> 에는 어노테이션이 없어 프록시 적용 대상이 되지 않기 때문이다. </p>
<pre><code class="language-yml">spring:
   jpa:
       open-in-view: false</code></pre>
<pre><code class="language-kotlin"> // 프록시가 정상적으로 동작하기 위해서 별도 클래스로 분리(같은 클래스의 내부 호출은 프록시 적용 불가)
  @Service
  class TrainReadTxService(
    private val stationLineReader: StationLineReader,
    private val subwayLineReader: SubwayLineReader,
) {
    @Transactional(readOnly = true)
    fun fetchStationAndLine(stationId: Long, subwayLineId: Long): Pair&lt;String, Long&gt; {
        val station = stationLineReader.getById(stationId)
        val line = subwayLineReader.getById(subwayLineId)
        return station.name to line.identity
    }
}

  override fun getTrainRealTimes(stationId: Long, subwayLineId: Long, upDownType: UpDownType?): List&lt;GetTrainRealTimesDto.TrainRealTime&gt; {
        // 여기까지가 짧은 read-only 트랜잭션 (커넥션 보유)
        val (stationName, subwayLineIdentity) = readTx.fetchStationAndLine(stationId, subwayLineId)
        // 트랜잭션 종료 → 커넥션 반납
        ...
        // 이후 외부 API 호출 로직 수행
  }</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/d0b865ff-9731-4a13-858d-7c4b743b4d2b/image.png" alt=""></p>
<p>이렇게 분리를 하고 다시 테스트를 해보니, 풀 사이즈를 조정하지 않고도 connection timeout 에러 상황을 피할 수는 있지만 <strong>여전히 3초 타임아웃으로 인해 다른 관련 없는 요청들은 정상적인 처리량을 가질 수가 없어 서버에 장애가 전파된다.</strong></p>
<p>따라서 커넥션 풀 사이즈를 늘리는 방식은 현재 상황에서는 적절한 해결 방안은 되지 않는다고 생각해 기가했고, <strong>DB 트랜잭션과 외부 API 요청을 분리하는 것이 가능한 경우는 떨어트리는게 필요하다 생각되어 2번 방안만 채택했다.</strong></p>
<h3 id="4-장애-전파를-위한-서킷-브레이커를-도입해보기">4. 장애 전파를 위한 서킷 브레이커를 도입해보기</h3>
<blockquote>
<p>그렇다면 결국 DB Connection timeout을 해결하려면 아예 장애 상황에서 요청 자체를 차단하는 기술이 필요해진다. 가장 대표적인 방법인 서킷 브레이커를 도입해보자.</p>
</blockquote>
<p><code>resilience4j</code> 라이브러리를 활용해서 간단하게 적용을 해보자.</p>
<pre><code class="language-kotlin">    @CircuitBreaker(name = CUSTOM_CIRCUIT_BREAKER, fallbackMethod = &quot;fallbackOnExternalTrainApiGet&quot;)
    override fun getTrainRealTimes(stationId: Long, subwayLineId: Long, upDownType: UpDownType?): List&lt;GetTrainRealTimesDto.TrainRealTime&gt; {
          ...
    }</code></pre>
<pre><code class="language-kotlin">    fun fallbackOnExternalTrainApiGet(
        stationId: Long, subwayLineId: Long, upDownType: UpDownType?, e: Exception
    ): List&lt;GetTrainRealTimesDto.TrainRealTime&gt; {
        when (e) {
            // 200 반환(원래 400, 그래프에서 fallback 메서드 구별 위해 변경)
            is CallNotPermittedException -&gt; {
                logger.error(&quot;circuit breaker opened for external train api&quot;)
                throw CommonException(ResponseCode.FAILED_TO_GET_TRAIN_INFO, e)
            }
            else -&gt; {
            // 500 반환
                throw CommonException(ResponseCode.INTERNAL_SERVER_ERROR, e)
            }
        }
    }</code></pre>
<p>사전 준비 단계에서 언급했듯이, 서킷이 정상적으로 OPEN 되기까지 불필요하게 호출되는 API를 최대한 줄여야하기 때문에 위와 세팅 값을 변경해보았다.</p>
<ul>
<li>부하는 <code>20초</code> 동안 총 <code>1000개</code> 요청을 점진적으로 보내도록 했다.</li>
<li>서킷 브레이커 설정 값들도 빠르게 OPEN될 수 있도록 조금씩 변경해주었다.</li>
</ul>
<pre><code class="language-yml">resilience4j:
  circuit-breaker:
    failure-rate-threshold: 10           # 실패율 10 % 이상 시 서킷 오픈
    slow-call-duration-threshold: 1000    # 1000ms 이상 소요 시 실패로 간주
    slow-call-rate-threshold: 10         # slowCallDurationThreshold 초과 비율이 10% 이상 시 서킷 오픈
    wait-duration-in-open-state: 30000   # OPEN -&gt; HALF-OPEN 전환 전 기다리는 시간
    minimum-number-of-calls: 50          # 집계에 필요한 최소 호출 수
    sliding-window-size: 50            # 서킷 CLOSE 상태에서 윈도우 사이즈 만큼 호출 도달 시 failureRateThreshold 실패 비율 계산
    permitted-number-of-calls-in-half-open-state: 10   # HALFOPEN -&gt; CLOSE or OPEN 으로 판단하기 위해 호출 횟수
</code></pre>
<h4 id="tps-결과본">TPS 결과본</h4>
<ol>
<li><p>장애 API(외부 API 호출 기능)</p>
<p>초반에 OPEN되기 이전에 빠르게 호출된 API 들은 모두 500에러가 발생하고, 이후 요청부터는 <strong>서킷이 OPEN됨으로 인해 외부 API 호출이 나가지 않으며 50TPS 처리량을 가지고 fallback 메서드가 정상적으로 수행</strong>되고 있는 것을 볼 수 있다. 
<img src="https://velog.velcdn.com/images/semi-cloud/post/d673e5e1-c360-46d9-b132-95dc5fb05972/image.png" alt=""></p>
</li>
<li><p>정상 API(다른 기능)</p>
<p>다른 <strong>정상 요청쪽도 <code>50TPS</code> 로 들어온 요청을 모두 안정적으로 처리</strong>하고 있는 것을 볼 수 있다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/36b13ca3-c5df-415f-ad46-fa50dc1e7959/image.png" alt=""></p>
</li>
</ol>
<blockquote>
<p>만약 위와 동일한 테스트 환경에서 다시 서킷 브레이커를 제거하면?</p>
</blockquote>
<ol>
<li><p>장애 API(외부 API 호출 기능) : RestTemplate 커넥션 풀 O 외부 타임아웃 설정 테스트와 비슷한 양상을 보인다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/3333ad3e-5eac-4c44-ae31-62993f814eb8/image.png" alt=""></p>
</li>
<li><p>정상 API(다른 기능) : 대기 상태에 있을 때는 10TPS, 그게 아니라면 약 60TPS 정도 처리할 수 있다. 평균은 15TPS 정도 된다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/364087bb-3b49-4f3d-923f-f088de1d5328/image.png" alt=""></p>
</li>
</ol>
<p>이처럼 테스트를 통해 서킷 브레이커가 <strong>외부 장애 상황에서 메서드 호출 자체를 차단함으로써 , 스레드가 고갈이 되는 문제를 방지해 관련이 없는 다른 요청들까지 TPS가 떨어지는 것을 막아준다</strong>는 역할을 하는 것을 확인해볼 수 있었다. 만약 MSA 환경이라면 각 모듈간에 장애가 전파가 되는 것을 막을 수 있어 더욱 그 효과를 발휘할 수 있을 것이다.</p>
<h3 id="☁️-테스트-결론">☁️ 테스트 결론</h3>
<ol>
<li><p>RestTemplate를 사용해 외부 API를 호출하는 로직의 핵심 병목은 Blocking I/O이기 때문에, WebClient(Non-Blocking I/O)를 도입할 수 있는 상황이라면 우선적으로 고려하자.</p>
</li>
<li><p>그러지 못한 상황이라면 RestTemplate은 커넥션 풀과 타임아웃을 무조건 설정해주자.</p>
</li>
<li><p>또한 지연과 같은 장애 상황에 대응하기 위해서는, DB 커넥션 풀 사이즈나 톰캣 스레드 사이즈를 증가시키는 것보다 서킷 브레이커를 도입하는게 적절한 선택이 될 수 있다.</p>
</li>
</ol>
<blockquote>
<p><strong>(추가) Non-Blocking I/O 검토 및 도입하지 않은 이유</strong>
<a href="https://velog.io/@semi-cloud/Spring-Spring-MVC%EC%97%90%EC%84%9C-Webclient%EC%9D%98-%ED%9A%A8%EA%B3%BC%EB%A5%BC-%EB%B3%BC-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C">https://velog.io/@semi-cloud/Spring-Spring-MVC%EC%97%90%EC%84%9C-Webclient%EC%9D%98-%ED%9A%A8%EA%B3%BC%EB%A5%BC-%EB%B3%BC-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</a></p>
</blockquote>
<blockquote>
<p>참고 자료
WebClient - <a href="https://velog.io/@greentea/WebClient-%EC%82%AC%EC%9A%A9%EB%B0%A9%EB%B2%95">https://velog.io/@greentea/WebClient-%EC%82%AC%EC%9A%A9%EB%B0%A9%EB%B2%95</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 이벤트 기반 아키텍처(EDA)와 Kafka의 구조에 대해 알아보자]]></title>
            <link>https://velog.io/@semi-cloud/TIL-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%B0%98-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98EDA-%EA%BC%AD-Kafka%EC%97%AC%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@semi-cloud/TIL-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%B0%98-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98EDA-%EA%BC%AD-Kafka%EC%97%AC%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Tue, 24 Jun 2025 10:15:51 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>기존에 채팅 시스템을 구현할 때 RabbitMQ를 사용했었는데, 이와 비교해서 왜 Kafka가 왜 대용량 스트리밍 시스템에 널리 사용되는지 학습해보고자 한다.</p>
</blockquote>
<h2 id="event-driven-architecture">Event Driven Architecture</h2>
<p>EDA(Event Driven Architecture)란, MSA 환경에서 분리된 서비스들 간에 상태 변화/데이터 변경/사용자 행동이 발생한 경우, 해당 이벤트를 <strong>비동기적으로 발행(Publish)하고, 소비자(Consumer)가 이벤트를 수신해 동작을 처리하는 아키텍처</strong> 를 의미한다.</p>
<p>MSA 환경에서 각 서비스가 서로 직접 호출하지 않고,
상태 변화나 비즈니스 이벤트를 비동기적으로 발행(Publish)하면
이를 구독(Consume)하는 다른 서비스가 독립적으로 처리할 수 있게 해주는 아키텍처.
이 방식은 서비스 간 결합도를 낮추고, 확장성과 장애 내성을 높여준다.</p>
<p>비동기 통신 방식이기 때문에, 대표적으로 메시지 브로커/이벤트 브로커가 Event Bus 역할을 수행한다. (ex) Kafka, RabbitMQ, Redis Stream..)</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/2595f9cc-9ce2-4e54-91f3-f41fc0d95471/image.png" alt=""></p>
<h4 id="장점">장점</h4>
<p>핵심은 Producer가 Consumer를 <strong>직접 호출하지 않기 때문에 서비스 간 결합도가 낮아진다</strong>는 점이다. 이로 인해 다음과 같은 장점이 생긴다.</p>
<ol>
<li>새로운 이벤트나 처리 로직을 추가할 때 기존 컴포넌트에 미치는 영향을 최소화해 구현할 수 있다.</li>
<li>한 서비스에서 장애가 발생했을 때(ex) Consumer) 전체 시스템에 미치는 영향을 최소화 할 수 있기에 장애가 격리되고, 가용성이 극대화된다.</li>
<li>시스템에 부하가 증가할 때 Producer와 Consumer를 수평적으로 확장해 유연하게 대응할 수 있다.</li>
</ol>
<h4 id="단점">단점</h4>
<p>구조적 복잡성도 증가하지만 가장 큰 단점은 비동기 방식이기 때문에, 추가적인 처리가 없다면 <strong>데이터 일관성을 100% 보장하기 어렵다</strong>는 점이다. 이를 해결하기 위해 분산 트랜잭션 환경에서 ACID를 보장하기 위한 SAGA 패턴 등 다양한 보완 기법이 필요하다.</p>
<blockquote>
<p>EDA에서는 Event Sourcing, CQRS, SAGA 등 다양한 패턴이 존재하며, 이에 대해서는 추후 별도 포스팅에서 다룰 예정이다.</p>
</blockquote>
<h2 id="이벤트-기반-아키텍처의-통신--kafka">이벤트 기반 아키텍처의 통신 : Kafka</h2>
<p>EDA를 위해서 RabbitMQ, Kafka등 다양한 기술이 사용될 수 있지만 이번 포스팅에서는 Kafka에 대해서 알아보겠다. 사실 밑에서 나오는 내용은 정말 기본 개념이라, 입문 느낌으로 학습해보고 추후에 더 깊이 있게 이해해보도록 하자.</p>
<h3 id="🔗-카프카-배경">🔗 카프카 배경</h3>
<p><strong>고성능 분산 이벤트 스트리밍 플랫폼</strong>인 카프카는 소스 애플리케이션과 타겟 애플리케이션의 커플링을 약하게 하기 위해 나왔다. 소스 애플리케이션은 카프카에 데이터를 전송하고, 타겟 애플리케이션은 카프카에서 데이터를 가져오는 방식이다.</p>
<p>주로 시스템 또는 애플리케이션 간에 실시간 데이터 파이프라인을 구축할 때, MSA 환경에서 서비스 간 이벤트가 실시간으로 처리되어야 할 때 사용된다.</p>
<img src="https://velog.velcdn.com/images/semi-cloud/post/bb84ed71-629f-44a1-8b5d-774bb612bc4c/image.png" width=90% height=90%>

<p>카프카의 주요 장점으로는 <strong>확장성, 가용성, 높은 처리량</strong> 등이 존재한다.</p>
<ol>
<li><code>확장성(Scalability)</code>
토픽을 여러 파티션으로 나눠 여러 브로커에 분산 → 처리량이 선형적으로 증가</li>
<li><code>가용성(Availability)</code>
파티션을 여러 브로커에 복제 → 일부 서버 장애에도 데이터 손실 없이 서비스 지속</li>
<li><code>높은 처리량(High Throughput)</code>
디스크 순차 쓰기, 배치 전송, 압축 등으로 초당 수백만 건 이상의 메시지를 처리</li>
</ol>
<p>이러한 장점을 이해하려면, 먼저 Kafka의 기본 구조와 동작 원리를 알아야 하기 때문에 다음 절에서 이를 살펴봐보자.</p>
<h3 id="🔗-topic이란">🔗 TOPIC이란?</h3>
<p>카프카에는 다양한 데이터가 들어갈 수 있고, 이러한 <strong>데이터가 들어가는 공간</strong>을 <strong>Topic</strong>이라고 한다. 토픽의 이름을 정할때는, 목적에 따라 무슨 데이터를 담는지 명확하게 명시하면 추후 유지보수 시 편리하게 관리할 수 있다.</p>
<h4 id="topic-구조">TOPIC 구조</h4>
<p><strong>하나의 토픽은 여러개의 파티션</strong>으로 구성될 수 있으며, 첫 번째 파티션 번호는 0번부터 시작한다. 하나의 파티션은 큐와 같이 내부의 데이터가 파티션 끝에서부터 차곡차곡 쌓이게 된다. 이후 Kafaka Consumer가 데이터를 가져갈때는 가장 오래된 순서대로 가져간다.</p>
<blockquote>
<p>🔗 파티션이란?
토픽이 카프카에서 일종의 논리적인 개념이라면, 파티션은 토픽에 속한 레코드를 실제 저장소에 저장하는 가장 작은 단위이다. 하나의 토픽에 여러 파티션을 가질 수 있다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/6fc760d6-9183-423e-96d6-49df16dbb347/image.png" alt=""></p>
<p>파티션의 중요한 특징은 다음과 같다.</p>
<ol>
<li><code>로그 구조</code> : 각 파티션은 시간 순서가 유지되는 <strong>불변의 레코드 시퀀스</strong>로, 장기간 <strong>디스크에 append-only 방식</strong>으로 저장된다. 또한 가능한 <strong>연속적인 블록에 저장해 순차 I/O 방식</strong>으로 처리될 수 있도록 한다. (쓰기 성능 극대화)</li>
<li><code>오프셋(Offset)</code> : <strong>파티션 내 메시지의 고유 순번</strong>으로, 메시지 도착 시 브로커에서 부여되며 변경이 불가능하다.</li>
</ol>
<p>따라서 중요한 점은, 메시지 브로커처럼 메모리에 저장되고 소비되면 삭제되는 구조가 아니라 <strong>디스크에 저장되기 때문에 Consumer가 데이터(record)를 가져가도 데이터는 삭제되지 않는다는 것이다.</strong> </p>
<p>메세지 보존 기간 내에는 언제든지 읽어갈 수 있으므로, <code>push</code> 가 아니라 컨슈머가 <code>pull</code> 해오는 방식이 가능해진다. 따라서 이렇게 남은 파티션의 데이터는, 새로운 Consumer가 붙었을 때 다시 오래된 데이터부터 가져올 수 있게 된다. 단, 아래와 같은 조건이 두가지 있다.</p>
<blockquote>
</blockquote>
<p>1) 컨슈머 그룹이 달라야함
2) <code>auto.offset.reset</code> = <code>earliest</code></p>
<p>즉 <strong>동일 데이터를 2번 이상 용도에 맞게 다르게 처리(로그 분석, 모니터링 등)</strong>할 수 있으며, 이는 카프카를 사용하는 중요한 이유 중 하나이기도 하다.</p>
<h3 id="🔗-kafka-producer">🔗 Kafka Producer</h3>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/47362d22-6ed4-4748-a0c3-78beaf2b2403/image.png" alt=""></p>
<p>Kafka Producer는, <strong>데이터를 보낼 때 키(Key)를 지정</strong>하여 어느 파티션으로 보낼 지 결정할 수 있다.</p>
<ol>
<li><p><strong>Key가 NUll이고, 기본 파티셔너 사용</strong>
라운드 로빈(Round Robin) 방식으로 할당된다.</p>
</li>
<li><p><strong>Key가 NUll이 아니고, 기본 파티셔너 사용</strong>
키의 해시(hash)값을 구하고, 특정 파티션에 할당된다. 동일한 메시지 키를 가진 레코드들은, 동일한 파티션에 들어가기 때문에 순서대로 처리할 수 있다는 장점이 있다.</p>
</li>
</ol>
<h3 id="🔗-kafka-consumer">🔗 Kafka Consumer</h3>
<p>각 토픽의 파티션에 데이터를 넣게 되면, 데이터 마다 <strong>오프셋(Offset)</strong>이 붙게 되고, <strong>카프카 컨슈머는 이 오프셋을 기준으로 다음에 읽을 메시지를 결정</strong>한다. 컨슈머가 Offset을 갱신하는 과정을 <code>COMMIT</code> 이라고 하는데, 커밋의 종류는 두 가지 방식이 존재한다.</p>
<ol>
<li><p><strong>Automatic Commit</strong>
일정 간격마다 자동으로 저장되며, 편리하지만 처리 중 장애가 발생하면 중복 처리나 데이터 유실이 발생할 수 있다.</p>
</li>
<li><p><strong>Manual Commit</strong>
개발자가 메시지 처리가 끝난 후 직접 commitSync() 또는 commitAsync() 호출하는 방식이다.</p>
</li>
</ol>
<img src="https://velog.velcdn.com/images/semi-cloud/post/dc93c893-05bf-45d6-b156-fe5dbf4f2bc0/image.png" width=90% height=70%>

<p><strong>같은 컨슈머 그룹 내에서 하나의 파티션</strong>은 <strong>단 하나의 컨슈머 인스턴스</strong>에만 할당되기에, 다른 컨슈머 그룹에 있는 컨슈머 인스턴스가 읽는 것만 가능하다.</p>
<p>따라서 만약 <strong>컨슈머 개수를 늘려서 데이터 처리를 분산</strong>시키고 싶은 경우(데이터 처리 속도를 높이고 싶은 경우), 파티션을 다음과 같이 늘릴 수 있다. 하지만 한번 늘린 파티션은 다시 줄일 수 없다는 것을 기억하자.</p>
<blockquote>
<p><strong>당연히 처리 순서는 “파티션 내부”에서만 보장</strong>되기 때문에, 파티션이 여러 개면 토픽 전체의 전역 순서는 보장되지 않는다. 따라서 <strong>순서를 보장하고 싶다면 하나의 파티션만 두는 구조</strong>로 가져가자.</p>
</blockquote>
<p><em>ex) 1개의 토픽에 4개의 파티션이 있으면, 컨슈머 그룹에서 최대 4개의 컨슈머 인스턴스가 병렬로 데이터를 처리 가능하다.</em></p>
<h3 id="카프카의-장점--확장성-고가용성-높은-처리량">카프카의 장점 : 확장성, 고가용성, 높은 처리량</h3>
<p>앞서 카프카의 기본 구조를 살펴봤으니, 이제 왜 Kafka가 대용량 데이터 처리에 강점을 가지는지 구체적으로 알아보자.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/67596c3e-cbd1-44e9-bc5c-cfe758c7d2ed/image.png" alt=""></p>
<h4 id="1-여러-브로커에-파티션-분산-처리-가능--확장성-보장">1. 여러 브로커에 파티션 분산 처리 가능 : 확장성 보장</h4>
<p>Kafka는 처음부터 분산 로그 저장소로 설계됐다. 즉 하나의 토픽을 여러 파티션으로 쪼개고, <strong>이 파티션들을 여러 브로커(카프카 서버)로 분산 배치함으로써 대량의 메시지를 병렬 처리</strong>할 수 있다.</p>
<blockquote>
<p> <em>Q.하나의 브로커에 여러 파티션을 두는 것도 병렬 처리로 처리량을 늘릴 수 있는것 아닌가요?</em></p>
</blockquote>
<p>물론 그렇지만, 하나의 브로커 안에서 여러 파티션을 두었을 때는 다음과 같은 단점이 존재한다.</p>
<ol>
<li>모든 파티션이 같은 하드웨어 리소스(CPU, 디스크, 네트워크 I/O, 메모리)를 공유하므로 처리량이 <strong>브로커 1대의 스펙 한계에</strong> 묶여버려 수직 확장(Scale-up)이 필요하다.</li>
<li>브로커 장애 시 해당 브로커의 모든 파티션이 동시에 영향을 받아 <strong>가용성이 떨어진다.</strong></li>
</ol>
<p>이와 반대로 여러 브로커로 분산 배치하면, 처리량이 브로커 수만큼 <strong>수평적으로 확장</strong>이 용이하고(Scale-out) 브로커 하나가 죽어도 다른 브로커에 있는 파티션이 계속 서비스를 할 수 있어 <strong>고가용성이 보장된다.</strong></p>
<h4 id="2-파티션-복제-가능--고가용성-보장">2. 파티션 복제 가능 : 고가용성 보장</h4>
<p>만약 브로커가 3개인 카프카에서 <code>replication=1</code> <code>partition = 1</code> 인 토픽이 존재한다고 가정해보자. 갑자기 브로커가 어떠한 이유로 사용 불가능하게 된다면, 해당 파티션 내부의 데이터들은 복구할 수 없게 된다. </p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/35226be2-7657-4332-9ee3-8f3ed550eee4/image.png" alt=""></p>
<p>허나 만약 레플리카가 존재한다면, 브로커 1개가 죽더라도 나머지 한개의 Follwer Partition이 존재하기 때문에 데이터의 복구가 가능해지는 것이다. 즉, Follower Partition이 Leader Partition 역할을 승계하게 된다.</p>
<p><strong>단 레플리케이션은 많을 수록 좋은 것이 아니다.</strong> 그만큼 브로커의 리소스 사용량도 늘어나기 때문에, 카프카에 들어오는 데이터량과 저장 시간을 잘 생각해서 레플리케이션 개수를 정해야 한다.</p>
<h3 id="그렇다면-eda를-위해서는-kafka가-항상-좋은-선택일까">그렇다면 EDA를 위해서는 Kafka가 항상 좋은 선택일까?</h3>
<blockquote>
<p>당연히 모든 기술에는 <strong>트레이드 오프</strong>가 있기 때문에 그렇지 않고, 상황에 따라 적절한 기술을 선택해야 한다고 생각한다.</p>
</blockquote>
<p>예를 들어서 위에서 봤듯이 순서를 보장해야 하는 경우(ex) 대기열)라면 단일 파티션 + 단일 컨슈머 구조로 사용해야 하고, 그렇다면 분산 처리가 불가능하므로 kafka를 사용하는 이점이 사라질 수 있다. 따라서 이런 경우에는 <code>단일 큐 기반의 플랫폼</code> 이 더 적절할 것이다.</p>
<p>따라서 개인적인 생각이지만 아래와 같이 정리해볼 수 있다.</p>
<h4 id="kafka와-같은-고성능-기술이-더-적절한-경우">kafka와 같은 고성능 기술이 더 적절한 경우</h4>
<ol>
<li>대규모의 실시간 데이터를 처리해야 해서, 높은 처리량/고가용성/분산 처리/스케일 아웃 등이 중요한 경우</li>
<li>하나의 메시지를 여러 컨슈머에서 처리해야 해서 데이터 영속성이 필요한 경우</li>
</ol>
<h4 id="rabbitmq와-같은-다른-경량화-기술이-적절한-경우">RabbitMQ와 같은 다른 경량화 기술이 적절한 경우</h4>
<ol>
<li>메시지 순서 전역 보장이 필수이며, 처리량보다 순차성이 중요한 경우</li>
<li>데이터 영속성이 불필요하고, 즉시 처리 후 폐기되는 메시지인 경우</li>
<li>인프라/운영 비용과 관리 복잡성을 최소화해야 하는 소규모 환경</li>
</ol>
<p>위와 같은 환경이라면 오히려 kafka 클러스터가 최소 3대의 브로커(서버)로 구성되므로 비용과 관리 복잡도가 증가해 <strong>오버 엔지니어링</strong>이 될 수 있다. 항상 적절하게 기술을 선택하자.</p>
<blockquote>
<p>참고 자료
<a href="https://ibm-cloud-architecture.github.io/refarch-eda/technology/kafka-consumers/">https://ibm-cloud-architecture.github.io/refarch-eda/technology/kafka-consumers/</a>
<a href="https://www.inflearn.com/course/%EC%95%84%ED%8C%8C%EC%B9%98-%EC%B9%B4%ED%94%84%EC%B9%B4-%EC%9E%85%EB%AC%B8/dashboard">https://www.inflearn.com/course/아파치-카프카-입문/dashboard</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java/TIL]  도커 환경에서 파일 경로를 못찾는 문제 해결하기]]></title>
            <link>https://velog.io/@semi-cloud/Spring-Boot-Multi-module-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%8C%8C%EC%9D%BC-%EA%B2%BD%EB%A1%9C-%EB%AA%BB%EC%B0%BE%EB%8A%94-%EC%98%88%EC%99%B8-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@semi-cloud/Spring-Boot-Multi-module-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%8C%8C%EC%9D%BC-%EA%B2%BD%EB%A1%9C-%EB%AA%BB%EC%B0%BE%EB%8A%94-%EC%98%88%EC%99%B8-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 16 Feb 2025 06:50:27 GMT</pubDate>
            <description><![CDATA[<h3 id="문제-상황">문제 상황</h3>
<p>로컬에서는 파일의 경로를 찾지만, 도커 환경에서는 Spring Boot 컨테이너에서 <code>logback-spring.xml</code> 파일 경로 못찾는 에러가 발생했다.</p>
<pre><code class="language-java">Caused by: java.io.FileNotFoundException: src/main/resources/logback-spring.xml (No such file or directory)
    at java.base/java.io.FileInputStream.open0(Native Method)
    at java.base/java.io.FileInputStream.open(FileInputStream.java:216)
    at java.base/java.io.FileInputStream.&lt;init&gt;(FileInputStream.java:157)
    at ch.qos.logback.core.joran.GenericXMLConfigurator.doConfigure(GenericXMLConfigurator.java:92)</code></pre>
<h3 id="문제-원인">문제 원인</h3>
<p>우선 문제가 발생한 logback 파일을 사용하는 SimpleSockerServer 의 내부 구성 코드를 파악해보니, FileInputStream을 활용해 파일에 접근하고 있었다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/def112a6-c2e0-4ec6-8210-04e03b33499f/image.png" alt=""></p>
<h4 id="1-파일이-jar-내부에-포함되지-않았을-가능성">1. 파일이 JAR 내부에 포함되지 않았을 가능성</h4>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/ecb88a21-3ab4-437b-ab95-eba5c5091ced/image.png" alt=""></p>
<p>우선 <code>src/main/resources/</code> 내부에 있는 파일들은 Jar 파일의 <code>BOOT-INF/classes/</code> 에 위치한다. 따라서 혹시나 빌드 과정에서 파일이 포함되지 않았는지 확인해보자.</p>
<p>나와 같은 경우는 스프링 부트 컨테이너 내부로 접속해서, JAR 파일 압축을 풀어 확인해봤더니 logback-spring.xml 이 위치하고 있었다.</p>
<pre><code class="language-bash">sudo docker exec -it {컨테이너 ID} /bin/bash</code></pre>
<h4 id="2-컨테이너-내부에서-잘못된-경로로-접근을-하는-경우">2. 컨테이너 내부에서 잘못된 경로로 접근을 하는 경우</h4>
<p>1번에서 파일이 잘 있는 것을 확인했으니, 컨테이너에서 제대로 인식을 하지 못하는 원인만 남아있었다. 근데 곰곰히 생각해보니 완전 기본적인 것을 놓치고 있었던 것이다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/7d24d8bb-27eb-435b-8eee-22e4b299e009/image.png" alt=""></p>
<p><code>File</code> 클래스는 <strong>파일 시스템 내의 리소스</strong>를 읽는다. 즉 <code>FileReader</code> 가 데이터를 가져오는 스트림인 <code>FileInputStream</code> 이 OS 파일의 경로를 읽기 때문에 파일 시스템 경로를 참조해야 한다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/aa96cac3-3635-4543-a6fb-27fdf4caf126/image.png" alt=""></p>
<p>따라서, 파일 시스템 경로를 참조하는 경우가 아니라면 일반적으로 프로젝트 루트 디렉터리 기준 경로 또는 <code>classpath:logback-xml</code> 과 같은 클래스패스 경로를 통해 파일을 참조할 수 있다.</p>
<p>예를 들어, CSV 파일을 읽을때는 아래와 같이 읽을 수 있다. <code>CSVReader</code> 는 Reader(문자 스트림) 기반으로 동작하므로 <code>InputStreamReader</code> 로 감싼 <code>getResourceAsStream()</code> 결과를 바로 전달할 수 있기 때문이다.</p>
<pre><code class="language-java">InputStream inputStream = getClass().getResourceAsStream(&quot;/user.csv&quot;);
CSVReader csvReader = new CSVReader(new InputStreamReader(inputStream));
// CSVReader csvReader = new CSVReader(new FileReader(Objects.requireNonNull(getClass().getResource(&quot;/user.csv&quot;)).getFile()));</code></pre>
<blockquote>
<p>🔗 classpath: 접두사
JVM이 리소스를 찾을 때 사용하는 특별한 경로 접두사이다. 도커 컨테이너에서는 JAR 내부의 파일을 직접 참조하는 게 일반적이기 때문에, OS 시스템에 독립적인 <code>classpath:</code> 을 사용하는게 더 권장된다.</p>
</blockquote>
<p>하지만 당연히 <code>logback-spring.xml</code> 파일은 압축된 <strong>JAR 파일 내부에 포함된 리소스</strong>기 때문에, <strong>파일 시스템 상에 직접 존재하지 않아 파일 경로를 찾지 못한다는 오류</strong>가 발생했다.</p>
<h3 id="해결-방법">해결 방법</h3>
<p>일반적인 해결 방법은 두가지가 있다.</p>
<ol>
<li>실제 <strong>도커 컨테이너의 파일 시스템으로 복사</strong>해서 <code>FileReader</code> 가 파일 경로를 읽을 수 있도록 한다.</li>
<li><code>FileReader</code> 를 사용하지 않고, <strong><code>InputStream → InputStreamReader → BufferedReader</code></strong> 흐름을 활용해 가져온다.</li>
</ol>
<pre><code class="language-java">InputStream inputStream = getClass().getClassLoader().getResourceAsStream(&quot;logback-spring.xml&quot;);
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
}</code></pre>
<p>사실상 해당 방식이 <code>FileReader</code> 보다 코드가 조금 길어지는거 빼고는 1)JAR 내부 / 파일 시스템의 리소스 모두 접근이 가능하고 2)명시적으로 인코딩도 지정해줄 수 있어 훨씬 범용적인 방식이다.</p>
<p>하지만 2번 방식은 현재와 같이 <code>SimpleSockerServer</code> 라이브러리를 활용하는 특수한 상황에서는 해결이 되지 않기 때문에, 1번 방식으로 어떻게 해결할 수 있는지 확인해보자. 빌드 과정에서 해당 <strong>파일을 도커 컨테이너의 파일 시스템으로 복사</strong>해주면 문제를 해결할 수 있다.</p>
<h4 id="🔗-1-컨테이너-파일-시스템">🔗 1. 컨테이너 파일 시스템</h4>
<p>도커 파일 시스템은 크게 <strong>컨테이너 레이어와 이미지 레이어</strong>로 나누어진다. </p>
<ul>
<li>컨테이너 레이어 :  <code>docker run</code> 이후 만들어지는 <strong>Read-Write</strong> 레이어</li>
<li>이미지 레이어 : <code>docker build</code> 시 만들어지는 <strong>Only Read</strong> 레이어이다.  </li>
</ul>
<p>이때 도커에서는 <code>Copy-On-Write</code> 전략에 의해, 쓰기 작업은 상위 컨테이너 레이어로 복사되어 이루어진다. 따라서 하나의 이미지로부터 복수의 컨테이너를 실행시켜도 정상적으로 동작하게 된다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/a62e1f6f-6c3f-4fc1-87dd-50acb104e7b8/image.png" alt=""></p>
<p>실제 컨테이너 사이즈를 출력했을 때 나오는 크기는 컨테이너 레이어의 크기를 의미한다.</p>
<pre><code class="language-bash">sudo docker ps -s  -- size 출력</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/04fb5a08-8d93-45ac-9f2d-9b43dc723839/image.png" alt=""></p>
<ul>
<li><code>Size</code> : 컨테이너 레이어의 데이터의 크기</li>
<li><code>Virtual Size</code> : 컨테이너 레이어 + 이미지 레이어 데이터 크기</li>
</ul>
<h4 id="🔗-빌드-과정에서-복사하기">🔗 빌드 과정에서 복사하기</h4>
<p>깃허브 액션을 통해서 배포를 하고 있었기 때문에, CI 서버 내에 파일을 업로드
할 때 <code>logback-spring.xml</code> 을 추가해주면 된다.</p>
<pre><code class="language-yml">      - name: Build with Gradle
        run: SPRING_PROFILES_ACTIVE=test ./gradlew :${{ env.MODULE_NAME }}:clean :${{ env.MODULE_NAME }}:build
        shell: bash

      - name: Upload build artifact (JAR and Dockerfile)
        uses: actions/upload-artifact@v4
        with:
          name: build-artifacts
          path: |
            ./${{ env.MODULE_NAME }}/build/libs/*.jar
            ./${{ env.MODULE_NAME }}/build/resources/main/logback-spring.xml
            ./${{ env.MODULE_NAME }}/*.Dockerfile</code></pre>
<p>그리고 DockerFile에 COPY 명령어를 통해 CI 서버에 있던 <code>logback-spring.xml</code> 파일을 도커 이미지의 config/ 디렉토리로 복사하자.</p>
<pre><code class="language-yml">FROM eclipse-temurin:17-jdk-jammy
...
COPY build/resources/main/logback-spring.xml config/logback-spring.xml
ENTRYPOINT [&quot;java&quot;, &quot;-Duser.timezone=Asia/Seoul&quot;,  &quot;-jar&quot;, &quot;app.jar&quot;, &quot;--spring.profiles.active=${PROFILE}&quot;]</code></pre>
<ul>
<li><code>COPY</code> : 도커 이미지 빌드 시, <strong>해당 파일을 이미지 내부로 포함</strong>시킨다.</li>
</ul>
<p>즉 아까 위에서 언급한 파일 시스템을 참고해보면, 다음과 같이 복사 과정이 일어나게 된다.</p>
<ol>
<li>이미지 빌드 단계에서 <code>logback-spring.xml</code> 파일이 새로운 이미지 레이어에 저장된다.</li>
<li>도커 컨테이너 실행 시, 이미지에서 읽기 전용 파일 시스템을 로드한다.</li>
<li>만약 파일을 수정하면 컨테이너 레이어에서 만들어진 새로운 복사본에서 수정 작업이 일어난다.</li>
</ol>
<h3 id="결론">결론</h3>
<p>나와 같이 사용하는 라이브러리에서 FileInputStream을 사용하고 있어 바꾸지 못하는 경우가 아니라면, 1번 방식은 파일이 1개가 불필요하게 더 생성된다는 단점이 있다.</p>
<p>따라서 2번 방식으로 FileReader를 사용하지 않고 InputStreamReader로 읽어서 해결하는게 가장 좋은 방식으로 생각된다.</p>
<blockquote>
<p>참고 자료
<a href="https://docs.docker.com/reference/dockerfile/#copy">https://docs.docker.com/reference/dockerfile/#copy</a>
<a href="https://docs.docker.com/get-started/docker-concepts/building-images/understanding-image-layers/#create-a-base-image">https://docs.docker.com/get-started/docker-concepts/building-images/understanding-image-layers/#create-a-base-image</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring Boot 프로젝트 멀티 모듈로 분리하기]]></title>
            <link>https://velog.io/@semi-cloud/TIL-Spring-Boot-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EB%B6%84%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@semi-cloud/TIL-Spring-Boot-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EB%B6%84%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 05 Nov 2024 07:00:16 GMT</pubDate>
            <description><![CDATA[<h2 id="멀티-모듈이란">멀티 모듈이란?</h2>
<p>멀티 모듈이란, 여러 개의 모듈로 구성된 단일 프로젝트를 의미한다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/35760dad-228a-4b35-8fef-fa03f1e7f034/image.png" alt=""></p>
<p>멀티 모듈로 구성하면 코드의 재사용성이 높아지고, 의존성을 명확히 분리해 높은 유지보수성을 가지고 갈 수 있다. 무엇보다, 각 모듈은 <strong>독립적으로 빌드되고 배포</strong>될 수 있다.</p>
<blockquote>
<p>현재 우리 프로젝트에서는 스케줄러 기능이 있는데,  단일 어플리케이션 서버에서 ECS로 바꾸면서 파드가 여러대가 되었을 경우 스케줄러가 정상적으로 &quot;한번만&quot; 동작하는 것을 보장하기 위해 스케줄러 서버를 별도로 분리했다. </p>
</blockquote>
<p>이렇게 서로 다른 서버에 배포해야 하는 경우, 각각 단일 모듈 프로젝트로 구성한다면 공유하고 있는 코드들이 없어 <strong>코드의 중복</strong>이 무지막지하게 생길 것이다.  따라서, 멀티 모듈로 분리하는 작업을 진행했는데 해당 과정과 트러블 슈팅을 기록해보려고 한다.</p>
<h2 id="1-모듈-나누는-기준-정하기">1) 모듈 나누는 기준 정하기</h2>
<p>그렇다면 모듈을 어떤 기준으로 어떻게 분리해야 할까?</p>
<p>우리 프로젝트에서는 헥사고날 아키텍처를 사용하고 있었기에, 멀티 모듈을 어떻게 나눠야할지 정말 고민을 많이 했다. 헥사고날 아키텍처란, 고수준 모듈은 저수준 모듈에 의존하지 않고, 구현을 추상화한 인터페이스에만 의존하는 DIP(의존성 역전 원칙)를 지킨 클린 아키텍처를 의미한다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/c9a6850d-7bad-413e-af21-3b4f62bf9e36/image.png" alt=""></p>
<ol>
<li><p><code>엔티티</code>
영속성에 의존하지 않는 순수 엔티티 로직이며, 데이터와 비즈니스적 로직이 모두 들어있다. 예를 들어, ORM 에 의존적인 @Entity 가 클래스에 붙으면 안된다.</p>
</li>
<li><p><code>유즈케이스(Application Service)</code>
인터페이스 형태이며, 구현체인 서비스는 어댑터의 구현체인 입력 포트와 출력 포트 두개를 가진다. 그리고 포트를 통해 실제 구현체인 외부 어댑터들과 통신한다.</p>
</li>
<li><p><code>포트(Port)</code>
유즈케이스에 어댑터에 대한 명세만을 제공하는 계층을 의미한다. 단순히 인터페이스 정의만 존재하며, Dependecny Injection 을 위해 사용된다. ex) IN 포트 : 컨트롤러 인터페이스 / OUT 포트 : DB, AWS 등과 연결하는 인터페이스</p>
</li>
<li><p><code>어댑터(Adapter)</code>
격리된 도메인 로직에서 포트를 통해 실제 인프라와 연결하는 부분을 담당한 구현체를 의미하며, 실질적으로 포트 인터페이스의 구현체이다.</p>
</li>
</ol>
<p>이런 구조를 지니고 있기 때문에 처음에 생각했던 방향은 가운데 코어(엔티티 + 서비스 + 포트)를 이루는 모듈, 그리고 외부 모듈(어댑터)들로 분리하면 좋을 것 같다고 생각했다. 실제로, <a href="https://tech.kakaobank.com/posts/2311-hexagonal-architecture-in-messaging-hub/">유일한 멀티모듈 헥사고날 아키텍처 : 메시지 허브 적용기</a> 에서도 비슷하게 구성을 했다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/76730244-7605-48e3-8674-4c6bbf289819/image.png" alt=""></p>
<p>하지만 다음과 같은 이유로 우선 스케줄러 및 API에서 공통으로 필요한 엔티티와 레포지토리만 core에 넣어두고, 각각에서 의존해서 공유하는 형태로 구성했다.</p>
<blockquote>
</blockquote>
<ol>
<li>외부 어댑터를 모두 별도 모듈로 분리하기에는 공수가 너무 커지고, 일관성 있게 분리도 불가능했다. 순수 도메인이 아닌 JPA 엔티티를 사용하고 있었기 때문이다.</li>
<li>따라서 크게 스케줄러와 API 모듈 두개로 분리하는것을 생각해보았고, 스케줄러에서 필요한 도메인 및 레포지토리 클래스들을 위해 별도 Core 모듈을 생성해서 공유하는 형태로 구성했다.</li>
</ol>
<h4 id="1-core">1. core</h4>
<p>엔티티, 레포지토리(영속성 계층) 관련 로직이 담긴 모듈이다. </p>
<h4 id="2-application">2. application</h4>
<p>컨트롤러(뷰 계층)와 서비스 관련 로직이 담긴 모듈이, 코어 패키지를 공유하고 있다. @SpringBootApplication이 존재한다.</p>
<h4 id="3-scheduler">3. scheduler</h4>
<p>스케줄러 관련 의존성, 패키지가 담긴 모듈이며, 코어 패키지를 공유하고 있다.
@SpringBootApplication이 존재한다.</p>
<h2 id="2-buildgradle-의존성-분리">2) build.gradle 의존성 분리</h2>
<p>그렇다면 이제 위에서 분리한 모듈을 바탕으로, 가장 핵심인 <code>build.gradle</code> 을 수정해보자.</p>
<h3 id="runtimeonly-vs-compileonly-vs-implementation">runtimeOnly vs compileOnly vs Implementation</h3>
<p>우선 build.gradle을 보면 의존성 관리에 여러 어노테이션들이 존재하는 것을 볼 수 있다.</p>
<ol>
<li><code>runtimeOnly</code>
해당 라이브러리가 <strong>런타임</strong>에서만 필요하다는 것을 나타낸다. 따라서 런타임에 필요한 경우 동적으로 라이브러리를 프로젝트에 포함한다. ex) DB Connector 라이브러리</li>
</ol>
<pre><code class="language-java">runtimeOnly(&quot;com.h2database:h2:2.1.214&quot;)
runtimeOnly(&quot;com.mysql:mysql-connector-j&quot;)</code></pre>
<ol start="2">
<li><p><code>compileOnly</code>
해당 라이브러리가 <strong>컴파일 시점</strong>에만 필요하다는 것을 나타낸다. 따라서 프로젝트 빌드 시점에 해당 라이브러리를 참조해서 컴파일에 사용하고, 빌드된 결과물에는 포함하지 않는다.</p>
</li>
<li><p><code>Implementation</code>
해당 라이브러리가 <strong>컴파일 및 런타임 시점 모두</strong> 필요하다는 것을 나타낸다.따라서 프로젝트 빌드 시점에 해당 라이브러리를 컴파일에 사용하고, 빌드된 결과물에도 포함시킨다. 이 경우 라이브러리 클래스 및 메서드를 프로젝트에서 직접 참조할 수 있다.</p>
</li>
</ol>
<pre><code class="language-java">implementation(&quot;org.springframework.boot:spring-boot-starter&quot;)
implementation(&quot;org.springframework.boot:spring-boot-starter-data-jpa&quot;)</code></pre>
<ol start="4">
<li>testImplementation
테스트시에만 사용된다.<pre><code class="language-java">testImplementation(&quot;org.junit.jupiter:junit-jupiter:5.8.1&quot;)
testImplementation(&quot;org.testcontainers:testcontainers:1.18.1&quot;)
testImplementation(&quot;org.testcontainers:junit-jupiter:1.18.1&quot;)</code></pre>
<h3 id="project를-통해-하위-모듈-생성하기">project를 통해 하위 모듈 생성하기</h3>
</li>
</ol>
<p>이제 각각의 의존성들에 자세히 알았으니, 모듈 분리 기준에 따라 필요한 의존성들을 적절히 이동시켜보자. 각 모듈은 아래와 같이 <code>project</code> 를 사용해 의존성을 분리할 수 있다.</p>
<pre><code class="language-java">project(&quot;:core&quot;) {
     dependencies {}
}</code></pre>
<p>각 프로젝트를 생성했다면, <code>settings.gradle</code> 에 아래와 같이 하위 모듈들을 추가해주자.</p>
<pre><code class="language-java">include(&quot;core&quot;)
include(&quot;scheduler&quot;)
include(&quot;application&quot;)</code></pre>
<p>그리고 모든 <strong>하위 프로젝트</strong>들에서 공통적으로 적용하고 싶은게 있다면, <code>subprojects</code> 를 사용해서 코드 중복을 줄일 수 있다. 나 같은 경우는 플러그인, 각종 spring boot 공통 의존성, 서브 모듈의 application.yml을 실제 resources/ 경로로 복사하는 태스크 등을 추가했다. </p>
<pre><code class="language-java">subprojects { // &lt;-&gt; allprojects : 루트 + 하위 모듈 모두 적용

    // 해당 내용이 core, scheduler, application 하위 모듈들에 적용됌
    apply(plugin = &quot;org.springframework.boot&quot;) 
    ...

    dependencies {
        ...
    }
}</code></pre>
<ul>
<li><code>apply</code> : 멀티 모듈 프로젝트에서 공통 플러그인(빌드 기능 확장 도구)를 적용할 때 사용</li>
</ul>
<h3 id="bootjar-vs-jar">BootJar vs Jar</h3>
<p>단, 여기서 주의할 점이 하나 있다. 바로 <code>@SpringBootApplication</code> 이 붙은 메인 클래스가 존재하는 모듈은, <strong>bootJar를 활성화시키고 Jar을 비활성화</strong> 시켜야 한다는 것이다.</p>
<blockquote>
<p>bootJar는 뭐고 일반 Jar는 뭘까? </p>
</blockquote>
<p>Java 애플리케이션을 배포하고 실행하기 위해 압축된 Jar 파일에는 두가지 유형이 있다.</p>
<h4 id="1-plain-jar">1. Plain jar</h4>
<p>단순 컴파일러를 통해 변환한 <strong>바이트 코드 모음</strong>(클래스 파일)을 의미한다. 단독적으로 실행은 불가능하며, 단순히 라이브러리로 제공할 때 사용된다. 아래처럼 외부 라이브러리에서 보이는 jar는 다 plain jar이다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/6143e3f4-93a8-4d4f-9fff-4c6433f62437/image.png" alt=""></p>
<h4 id="2-executable-jarbootjar">2. Executable Jar(BootJar)</h4>
<p>JVM에서 <strong>실행이 가능한 JAR</strong> (Executable Jar)를 의미하며, 스프링 부트의 장점 중 하나가 바로 이렇게 BootJar를 통해 프로젝트를 바로 실행할 수 있다는 것이다.</p>
<blockquote>
<p>🔗 <strong>압축 파일 구조</strong></p>
</blockquote>
<ol>
<li>BOOT-INF
개발자가 직접 작성한 클래스 파일, 리소스 파일, 의존성 주입을 통한 jar 파일들로 구성된다.</li>
</ol>
<pre><code class="language-java"> &quot;spring-boot-starter-web-2.3.4.RELEASE.jar&quot;
 &quot;spring-boot-starter-data-jpa-2.3.4.RELEASE.jar&quot;
 &quot;querydsl-jpa-4.3.1.jar&quot;</code></pre>
<ol start="2">
<li><p>META-INF
jar 실행을 위한, 즉 메인 클래스 실행을 위한 메타 데이터를 포함하는 폴더이다.
일반적인 JAR 파일에서는 <code>Main-Class</code> 속성이 실제 메인 클래스의 경로를 가리키지만, Spring Boot에서는 <strong>JarLauncher</strong> 클래스로 설정된다. <br></p>
<p>즉, 내장 jar를 인식해와서 Launcher를 실행시키면, Launcher에서 리플렉션을 통해 Start-Class에 선언된 메인 메서드 실행하는 방식이다.</p>
<pre><code>my-app.jar
├── META-INF/
│   ├── MANIFEST.MF  &lt;-실행 정보를 포함한 파일
├── com/
│   ├── example/
│   │   ├── Main.class  &lt;-프로그램의 시작점
├── lib/  &lt;- 의존성 라이브러리 (fat JAR일 경우 포함)
│   ├── spring-core.jar
│   ├── jackson.jar
└── application.properties  &lt;- 설정 파일</code></pre><pre><code class="language-yml">Manifest-Version: 1.0
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Start-Class: com.example.demo.DemoApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Version: 2.3.4.RELEASE
Main-Class: org.springframework.boot.loader.JarLauncher</code></pre>
</li>
<li><p>org
Spring Boot Loader 클래스 모듈들이 존재한다.</p>
</li>
</ol>
<p>이 과정에들에서 <code>Spring Boot Gradle Plugin</code> 이 중요한 역할을 한다.
애플리케이션을 실행할 수 있도록 내장 컨테이너(예: Tomcat)를 포함하고,
애플리케이션과 그 의존 라이브러리를 하나의 Fat JAR로 패키징하는 작업을 수행한다.</p>
<blockquote>
<p>따라서, 프로젝트를 실행하지 않고 단순 코드 공유용으로 사용되는 core module은 bootJar가 아닌 <code>Plain Jar</code> 를 생성하도록 하고, 프로젝트를 실행시켜야 하는 어플리케이션 및 스케줄러 모듈은 <code>bootJar</code> 를 생성하도록 해주는 것이 좋다.</p>
</blockquote>
<pre><code class="language-java">project(&quot;:core-module&quot;) {
     ...
    tasks.getByName&lt;Jar&gt;(&quot;bootJar&quot;) {  // BootJar 비활성화
        enabled = false
    }

    tasks.getByName&lt;Jar&gt;(&quot;jar&quot;) {   // Jar 활성화
        enabled = true
    }
}</code></pre>
<pre><code class="language-java">project(&quot;:schedule-module&quot;) {
    ...
    tasks.getByName&lt;Jar&gt;(&quot;bootJar&quot;) {  // BootJar 활성화
        enabled = true
    }

    tasks.getByName&lt;Jar&gt;(&quot;jar&quot;) {  // Jar 비활성화
        enabled = false
    }
}</code></pre>
<h3 id="공통-모듈-의존성-가져오기---트러블-슈팅">공통 모듈 의존성 가져오기 - 트러블 슈팅</h3>
<p>이제 application, scheduler 모듈이 core 모듈을 의존하기 위한 설정을 추가해주자. <code>project()</code> 함수를 이용해서 의존성을 추가해주면, 해당 모듈(core)의 클래스들만 사용할 수 있도록 연결해주고, core 모듈이 먼저 빌드되도록 순서를 지정해줄 수 있다.</p>
<pre><code class="language-java">implementation(project(&quot;:core-module&quot;))</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/c4fed636-4a42-4273-8587-17c4812e7b99/image.png" alt=""></p>
<p>단, 주의할 점이 core 모듈의 <strong>의존성까지 자동으로 가져오는 것</strong>이 아니다. 즉, 해당 모듈과 중복으로 작성해야 하는 의존성들이 있더라도 별도로 작성해줘야 한다는 것이다.</p>
<blockquote>
<p>Q. 그러면 의존성 자체를 상속 받을 수 있는 함수는 없을까?</p>
</blockquote>
<p>바로 <code>api()</code>를 사용하면 해당 모듈을 의존하는 다른 모듈들도 그 의존성을 함께 상속받을 수 있다. </p>
<p>단, 해당 함수는  <code>runtimeClasspath</code> 뿐만 아니라 <code>compileClasspath</code> 까지 의존을 하는 쪽에 노출된다는 단점이 있다. 컴파일 타임에 불필요한 의존성이 노출되어 모듈 간 <strong>결합도</strong>가 올라가는 것이다. </p>
<p>반면 <code>implementation</code> 은 <code>runtimeClasspath</code> 종속성으로만 나타난다. 그러니 웬만하면 <code>implementation</code> 를 사용하자!</p>
<ul>
<li><code>compileClasspath</code> : 컴파일 타임에 사용되는 종속성으로, 컴파일 타임에 자바 컴파일러는 소스 코드(.java 파일)를 바이트 코드로 변환한다.</li>
<li><code>runtimeClasspath</code> : 런타임에 사용되는 종속성으로, 런타임에 JVM은 바이트 코드를 기계어로 해석해서 실행될 수 있도록 한다.</li>
</ul>
<p>이제 진짜 거의 끝났다. build.gradle이 완성되었다면, 다음과 같이 모듈을 새롭게 생성해 코드를 분리해주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/494bf61c-dfb4-4ef5-b713-e8aecfae47d4/image.png" alt=""></p>
<h2 id="4-모듈-별-배포-파일-생성">4) 모듈 별 배포 파일 생성</h2>
<p>어플리케이션 모듈은 ECS, 스케줄러 모듈은 일반 EC2에 배포하는 구성이다. 배포 파일은 깃허브 액션을 사용했으며, 다음과 같이 두개의 워크플로우를 생성하면 된다. </p>
<p>이때, 다른 모듈을 변경했는데 배포 워크플로우가 동작하면 매우 비효율적이니 <code>paths-ignore</code> 를 통해 서로 <strong>다른 모듈의 변경 사항을 무시</strong>해서 <strong>독립적으로 동작</strong>할 수 있도록 해주자.</p>
<h4 id="application-deploy">application-deploy</h4>
<pre><code class="language-yml">name: CI/CD Pipeline for Dev API

on:
  push:
    branches:
      - develop
    paths-ignore:
      - &#39;scheduler/**&#39;   # 다른 모듈의 변경과 독립적으로 동작하도록 경로 무시 필요
      - &#39;.github/**&#39;
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      MODULE_NAME: application
    ...</code></pre>
<h4 id="scheduler-deploy">scheduler-deploy</h4>
<pre><code class="language-yml">name: CI/CD Pipeline for Dev Batch

on:
  push:
    branches:
      - develop
    paths-ignore:
      - &#39;application/**&#39;  # 다른 모듈의 변경과 독립적으로 동작하도록 경로 무시 필요
      - &#39;.github/**&#39;
      - &#39;scheduler/cron.Dockerfile&#39;

  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      MODULE_NAME: scheduler</code></pre>
<blockquote>
</blockquote>
<p>참고 자료
<a href="https://docs.gradle.org/current/userguide/java_library_plugin.html">https://docs.gradle.org/current/userguide/java_library_plugin.html</a>
<a href="https://docs.gradle.org/current/userguide/declaring_dependencies_basics.html#sec:project-dependencies">https://docs.gradle.org/current/userguide/declaring_dependencies_basics.html#sec:project-dependencies</a>
<a href="https://docs.spring.io/spring-boot/docs/3.2.5/gradle-plugin/reference/htmlsingle/">https://docs.spring.io/spring-boot/docs/3.2.5/gradle-plugin/reference/htmlsingle/</a>
<a href="https://medium.com/@garvitbhardwaj06022000/difference-between-gradle-plugin-and-dependencies-1d95fa928eca">https://medium.com/@garvitbhardwaj06022000/difference-between-gradle-plugin-and-dependencies-1d95fa928eca</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Django] Django Rest Framework 핵심 개념 정리]]></title>
            <link>https://velog.io/@semi-cloud/TIL-Django-Rest-Framework-%ED%95%B5%EC%8B%AC-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@semi-cloud/TIL-Django-Rest-Framework-%ED%95%B5%EC%8B%AC-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 16 Sep 2024 06:29:22 GMT</pubDate>
            <description><![CDATA[<p>DRF이란 장고 안에서 RESTful API 서버를 유연하게 구축할 수 있도록 도와주는 라이브러리이다. API 생성, 요청 처리, 권한 관리, 데이터 직렬화 등 API 개발에 필요한 추가 기능을 제공한다.</p>
<h2 id="repository-계층-orm">Repository 계층: ORM</h2>
<p>Django ORM에서 <code>objects</code>는 쿼리셋을 관리하는 매니저 역할을 한다.</p>
<pre><code class="language-java">makemigrations -&gt; 생성된 SQL 문 항상 확인하는거 필요</code></pre>
<h3 id="1-queryset">1. QuerySet</h3>
<p>Django ORM에서는 데이터베이스 질의를 QuerySet을 통해 수행한다. QuerySet은 <strong>Lazy Loading(지연 로딩)</strong>과 <strong>Caching(캐싱)</strong>을 지원하여, 불필요한 데이터베이스 호출을 줄일 수 있다.</p>
<pre><code class="language-jsx">user_queryset : QuerySet[User] = User.objects.all()</code></pre>
<h4 id="1-lazy-loading--caching">1. Lazy Loading &amp; Caching</h4>
<p>QuerySet은 바로 실행되지 않고, 실제 데이터가 필요할 때 실행된다. 아래 예제를 봐보자.</p>
<pre><code class="language-python">user_queryset: QuerySet[User] = User.objects.all()</code></pre>
<p>위 코드는 데이터베이스에서 사용자 데이터를 가져오는 쿼리를 정의했지만, 아직 실행되지는 않는다.</p>
<pre><code class="language-python">if user_queryset.exists():  # select 1 from user where username=1234 limit 1;</code></pre>
<p>exists()를 호출하면 <strong>즉시 실행(Eager Execution)</strong>되며, 최소한의 데이터만 조회하는 SQL이 실행된다.</p>
<pre><code class="language-python">user_list: List[User] = list(User.objects.all())  # 쿼리 즉시 수행</code></pre>
<p>list로 변환하려면 데이터가 필요하기 때문에 QuerySet이 즉시 실행되며, <strong>결과가 캐싱</strong>된다. 이후 같은 QuerySet을 재사용하면, 캐싱된 데이터를 활용하여 SQL 실행을 줄일 수 있다.</p>
<pre><code class="language-python">user1: User = user_queryset[0]</code></pre>
<p>만약 캐싱이 안되어있었으면 queryset[0] 때문에 limit 1로 가져오는 SQL이 나갔을 것이다.</p>
<blockquote>
<p>QuerySet 내부 구조</p>
</blockquote>
<p>Django의 QuerySet은 내부적으로 여러 속성을 가지고 있다.</p>
<pre><code class="language-python">class QuerySet: 
   query = Query()  # 메인 쿼리
   prefetch_related_lookups = (&quot;order_set&quot;, &quot;menu_set&quot;)  # 추가 쿼리셋 정보 저장
   result_cache = []  # 쿼리 결과 캐싱
   iterable_class = ModelIterable  # 데이터 반환 방식 결정
</code></pre>
<h4 id="2-queryset-메서드-활용-꿀팁">2. QuerySet 메서드 활용 꿀팁</h4>
<ul>
<li><p><code>next()</code>
리스트 컴프리헨션을 사용하면 데이터가 없을 때 에러가 발생할 수 있다.
이때 next()를 활용하면 안전하게 데이터를 조회할 수 있다. <code>None</code> 을 기본값으로 지정하면, 데이터가 없을 경우 에러가 발생하지 않고 None을 반환한다.</p>
<pre><code class="language-python">next((user for user in user_list if user.first_name == &quot;aa&quot;), None)</code></pre>
</li>
<li><p><code>get()</code>
단일 객체를 조회할 때 사용하며, 고유한 데이터를 찾을 때만 사용해야 한다. 데이터가 없으면 DoesNotExist 예외 발생하며, 여러 개의 데이터가 존재하면 MultipleObjectsReturned 예외가 발생한다. 따라서 try except로 예외 처리를 해줘야 한다.</p>
<pre><code class="language-python">user = User.objects.get(username=&quot;test_user&quot;)</code></pre>
</li>
<li><p><code>filter()</code>
filter()는 QuerySet을 반환하므로 지연 로딩이 적용된다.
즉시 로딩하려면 [0]을 사용해야 하지만, 데이터가 없으면 IndexError가 발생할 수 있으므로 별도 처리가 필요하다.</p>
<pre><code class="language-python">user = User.objects.filter(username=&quot;test_user&quot;).first()  # 안전한 조회</code></pre>
<ul>
<li><code>first()</code> : 데이터가 존재하지 않을 때 예외가 발생하지 않고, None이 반환되기 때문에 안전하게 처리할 수 있다.</li>
</ul>
</li>
<li><p><code>count()</code>
QuerySet의 개수를 구할 때 count()를 사용해야 한다.
<code>len(QuerySet)을</code>  사용하면 지연 로딩이 풀려서, 불필요한 SQL 실행이 발생할 수 있기 때문이다.</p>
<pre><code class="language-python">user_count = User.objects.filter(is_active=True).count()  # select count(*) from user;</code></pre>
</li>
</ul>
<h4 id="3-쿼리-매니저-커스텀">3. 쿼리 매니저 커스텀</h4>
<p>Django에서는 쿼리 매니저를 직접 커스텀하여 유지보수성을 높일 수 있다.</p>
<pre><code class="language-python">class ContractManager(models.Manager):
    def recently_expired(self, store: Store) -&gt; QuerySet:
        return self.get_queryset().filter(store=store).order_by(&quot;-end_date&quot;)</code></pre>
<p>위와 같이 매니저를 정의하면, Contract.objects.recently_expired(store)와 같이 사용할 수 있다</p>
<h3 id="django에서의-n1-문제">Django에서의 N+1 문제</h3>
<p>장고에서 N+1 문제는 어떻게 해결할까? 아래 예제를 보면, <code>menu.restaurant.name</code> 을 참조할 때마다 추가 SQL이 실행되므로 성능이 저하된다.</p>
<pre><code class="language-jsx">menu_queryset = Menu.objects.filter(name_contains=&quot;파스타&quot;)  
for menu in menu_queryset:
    menu.name      # 1. 첫 번째 SQL 쿼리 수행
    menu.price     # 2. 캐싱된 데이터 사용
    menu.restarant.name   # 3. 다른 엔티티의 name 은 캐싱 X -&gt; N+1 문제 발생</code></pre>
<p>따라서 아래와 같은 즉시 로딩 기법을 사용해, N+1 문제를 해결할 수 있다.</p>
<h4 id="즉시-로딩-기법-2가지">즉시 로딩 기법 2가지</h4>
<ol>
<li><code>select_related()</code></li>
</ol>
<ul>
<li><p>Join 문법을 유도하지만, <code>N:1</code> 관계에서만 사용 가능하다.</p>
<pre><code class="language-jsx">  Menu.objects
      .select_related(&quot;a&quot;, &quot;b&quot;) # 여러개 가능
      .filter()</code></pre>
</li>
</ul>
<ol start="2">
<li><code>prefetch_related()</code></li>
</ol>
<ul>
<li><p>추가 쿼리셋을 유도하며, 원본 쿼리  + <code>SQL IN</code> 쿼리가 나간다. 즉 <code>1 + N</code> 이 아닌 <code>1 + 1</code> 이 된다.</p>
<pre><code class="language-jsx">  Restaurant.objects.filter.prefetch_related(
      Prefetch(&quot;order_set&quot;, queryset = Order.objects.all(),  # 내부에 쿼리 셋을 직접 지정 가능
      Prefecth(&quot;menu_set&quot;, queryset = Menu.objects.filter(name__contains = &quot;파스타&quot;))</code></pre>
</li>
</ul>
<h2 id="직렬화--serializers">직렬화 : Serializers</h2>
<p>Django Rest Framework(DRF)에서는 Serializer를 활용하여 데이터를 직렬화 및 역직렬화할 수 있다.</p>
<pre><code class="language-python">s = UserSerializer(data=request.data)
s.is_valid(raise_exception=True)
s.save()</code></pre>
<ul>
<li><p><code>is_valid()</code>
모든 유효성 검증이 수행되며, 데이터 타입을 직렬화에 선언한 필드 타입으로 변환해주는 작업이 수행된다.
request로 받은 딕녀너리 데이터가, <code>validated_data</code> 라는 값으로 생성된다.</p>
</li>
<li><p><code>save()</code>
내부적으로 create() 또는 update() 메서드를 호출한다. POST 요청은  create, PATCH 요청은 update 메서드가 자동으로 호출되며, 각 메서드에서 위에서 검증된 <code>validated_data</code> 를 사용해서 데이터를 처리한다.</p>
</li>
</ul>
<pre><code class="language-python"># 통합 커스텀 메서드 : 객체 수준이 아니라 비즈니스 수준에서 필요한 검증을 작성
def validate(): 

 # 자동으로 유효성 검증 시 해당 메서드 수행
def validate_{필드명}():

 # 여러 필드 혹은 객체 수준에서 복합적으로 유효성 검증이 필요한 경우 사용
class Meta:  
    validators = [UniqueTogetherValidator(
                queryset = User.objects.all(),
                fields = [&quot;name&quot;, &quot;phone&quot;]
 ]</code></pre>
<p>또한, 아래와 같이 커스텀 validator + SerializerField 에 부여해서 재사용 높은 코드를 만들 수 있다.</p>
<pre><code class="language-jsx">class EnglishOnlyValidator:
    message = &quot;{attr_name} 에는 숫자가 포함되면 안 됩니다.&quot;
    def __init__(self, message):
        self.message= message


    def __call__(self, value: str, serializer_field):
        if value.isalpha():
            raise serializers.ValidationError(
                detail = self.message.format(attr_name=serializer_field.name)
            )

class SignUpSerializer(serializers.Serializer):
    first_name = serailizer.CharField(
        max_length=127,
        validators=[EnglishOnlyValidator()]
    )</code></pre>
<h2 id="view">View</h2>
<p>장고에서 View(controller)의 경로를 설정하려면 다음과 같이 선언해주면 된다.</p>
<pre><code class="language-python">path(route, view, name)</code></pre>
<ul>
<li>route : 경로</li>
<li>view : 컨트롤러(http-request, http-response)를 인자로 받는다.</li>
<li>name : API 고유한 이름</li>
</ul>
<h4 id="drf-함수-기반-뷰-생성-방법">DRF 함수 기반 뷰 생성 방법</h4>
<p>다음과 같이 함수 기반 뷰를 사용하려면 <code>GenericViewSet</code>을, 클래스 기반 뷰를 사용하고 싶다면 <code>ModelViewSet</code> 을 상속받으면 된다.</p>
<ul>
<li>@api_view(http_method_names=[”GET”, “POST”]) 선언 필요</li>
<li>@action(url_path=”approval”, detail=True. methods=[”PATCH”])<ul>
<li>기본 CRUD가 아닌 추가 API 확장시 선언 필요(RESTFul API를 만들 수 없는 상황에서)</li>
</ul>
</li>
</ul>
<pre><code class="language-python">memo_list = MemoModelViewSet.as_view({&quot;get&quot;: &quot;list&quot;, &quot;post&quot;: &quot;create&quot;})
memo_detail = MemoModelViewSet.as_view(
            {&quot;get&quot;: &quot;retrieve&quot;, &quot;patch&quot;: &quot;partial_update&quot;, &quot;delete&quot;: &quot;destroy&quot;})

# 경로 매핑 과정
urlpatterns = [
            path(&quot;memo&quot;, memo_list),  // RESTFUL O
            path(&quot;memo/&lt;int:pk&gt;&quot;, memo_detail),
            path(&quot;memo/recent&quot;, MemoModelViewSet.as_view({&quot;get&quot;: &quot;recent_memo&quot;})). // RESTFUL X 
 ]</code></pre>
<h4 id="좋은-뷰-설계를-만드려면">좋은 뷰 설계를 만드려면?</h4>
<p><code>ModelViewSet + ModelSerializer</code> 조합은 안티 패턴이므로 사용하지 않는 것이 좋다. 아래와 같이 비즈니스 로직 없이 자동으로 테이블과 1:1 매칭되는 CRUD API 를 사용하는 것은 개발 편의성은 높더라도 추후 유지보수성이 떨어지기 때문이다.</p>
<pre><code class="language-python">class UserViewSet(viewsets.GenericViewSet):
       queryset = User.objects.all()
     serializer_class = UserSchema</code></pre>
<h2 id="exception">EXCEPTION</h2>
<p>views 내부에 직렬화 / 모델 / 유틸 모듈이 존재하며, 이거를 ExceptionHandler가 밖에서 감싸고 있다. 예외는 DRF의 예외 체계인 <code>APIException</code>을 사용하자.</p>
<pre><code class="language-python">class CustomException(APIException):
    status_code = status.HTTP_400_BAD_REQUEST
    default_detail = &quot;&quot;</code></pre>
<p>위와 같이 모델 계층에서 비즈니스 로직을 처리한 후, 커스텀 예외를 생성하여 반환하면 의존성을 줄일 수 있다.</p>
<blockquote>
<p>참고 자료
<a href="https://www.aladin.co.kr/shop/UsedShop/wuseditemall.aspx?ItemId=317457407">백엔드 개발을 위한 핸즈온 장고</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] Django는 어떻게 다수의 요청을 처리할까?]]></title>
            <link>https://velog.io/@semi-cloud/Infra</link>
            <guid>https://velog.io/@semi-cloud/Infra</guid>
            <pubDate>Sat, 24 Aug 2024 07:43:12 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>상반기 채용 과정, 그리고 회사 입사 후에도 일이 너무 많아서 블로그 포스팅을 아예 작성하지 못했다..😰 노션에 대충 작성해놨던 글들을 다시 정리해보려고 한다.</p>
</blockquote>
<p>스프링에서는 스레드 풀을 통해 다수의 요청이 처리가 되는데, 장고는 어떻게 처리해주는지 궁금해서 포스팅해보았다.</p>
<h2 id="장고가-다수의-요청을-처리하는-방법">장고가 다수의 요청을 처리하는 방법</h2>
<p>장고는 <code>Gunicorn</code>, 즉 스프링에서 요청을 처리해주는 <code>Tomcat</code> 과 동일한 역할의 웹 어플리케이션 서버가 존재한다. <code>WSGI</code> 라고 하며, 파이썬 어플리케이션이 <strong>웹서버와 통신하기 위한 인터페이스</strong>로 웹서버의 요청을 해석해서 어플리케이션에게 전달한다.</p>
<p>장고는 매 요청마다 프로세스를 생성하는 방식이 아니고, <code>master - slave</code> 워커 프로세스 구조로 돌아가며, 동시에 돌아가는 프로세스와 스레드 개수를 조정할 수 있다.</p>
<blockquote>
<p>참고로 WSGI는 동기식 코드만 지원하고, 비동기식 이벤트를 처리해야 한다면 ASGI를 사용해야 한다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/7a1695a5-455c-4a9b-9eeb-9ada14b5f6c7/image.png" alt=""></p>
<h3 id="그렇다면-장고-worker의-최적-개수는">그렇다면 장고 worker의 최적 개수는?</h3>
<p><a href="https://docs.gunicorn.org/en/stable/design.html#how-many-workers">공식 문서</a>에서 나와있듯이, Gunicorn은 초당 수백 또는 수천 건의 요청을 처리하는 데 <code>4~12</code> 개의 작업자 프로세스만 필요하다. 요청을 처리할 때 모든 로드 밸런싱을 제공하기 위해 운영 체제에 의존하며, 일반적으로 처음 시작할 작업자 수로 <code>(2 x 코어 개수) + 1</code> 을 권장하고 있다.</p>
<p>회사에서는 Devops 팀이 따로 있었는데, 아래와 같은 프로세스로 워커 개수를 설정하고 있었다.</p>
<p>1) datadog metric을 참고해서 각 Pod가 <strong>초당 몇개의 요청을 처리</strong>할 수 있어야 하는지, 그리고 <code>평균 duration</code> 을 계산한다. </p>
<blockquote>
</blockquote>
<p>현재 분당 400,000개의 요청을 처리 중이라면, pod은 133대이므로 <code>400,000/133/60 = 50</code>, pod 1대가 초당 50개의 요청을 처리 중이다.
avg duration은 150ms라고 가정해보자.</p>
<p>2) <code>percentile</code> 에 따른 필요한 총 thread 수를 구한다. </p>
<ol>
<li><code>p50</code> - pod 1대가 1초 동안 150ms 요청 50개를 처리하려면 최소한 thread가 7.5개 필요</li>
<li><code>p90</code> - pod 1대가 1초 동안 400ms 요청 50개를 처리하려면 최소한 thread가 20개 필요</li>
<li><code>p99</code> - pod 1대가 1초 동안 1500ms 요청 50개를 처리하려면 최소한 thread가 75개 필요</li>
</ol>
<p>추가로 I/O bound요청이 많은지, CPU bound 요청이 많은지 고려하고 I/O bound가 많은 경우 thread를 더 늘리고, worker 개수는 웬만하면 core당 1개로 설정하고 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/5fd181cb-b3e9-400f-8008-74b10d4bb770/image.png" alt=""></p>
<h3 id="파이썬의-gil과-멀티-스레딩">파이썬의 GIL과 멀티 스레딩</h3>
<blockquote>
<p><strong>Q. 아니 근데 파이썬에서는 GIL로 인해 멀티 스레딩의 효과가 없는거 아닌가요?</strong></p>
</blockquote>
<p><code>GIL</code> 은 일종의 뮤텍스로써, 한 프로세스 내에서, 파이썬 인터프리터가 한 시점에 하나의 쓰레드에 의해서만 실행될 수 있게 한다. 왜냐? 파이썬에서는 <strong>모든 것이 객체</strong>이기 때문에 가비지 컬렉션이 동작했을 때 여러 스레드가 동시에 접근해 <strong>객체들의 참조 횟수에 있어서 동시성 이슈</strong>가 발생하는 것을 막기 위함이기 때문이다.</p>
<p>원래 멀티 코어라면 멀티 쓰레딩 시에 여러 개의 쓰레드가 여러 코어에서 병렬(Parallel) 실행될 수 있는데, Python에서는 그러한 병렬 실행이 불가능하다는 것뿐이다.</p>
<p>따라서 <code>CPU Bound</code>, 즉 CPU가 코드에 접근해서 연산을 하고 있는 시간이 많다면 오히려 컨텍스트 스위칭 비용만 높아지므로 성능이 느려지겠지만, <code>I/O Bound</code> 면 충분한 효과를 볼 수 있다. 외부 연산(I/O, Sleep 등)을 하느라 CPU가 아무것도 하지 않고 기다리기만 할 때는 다른 쓰레드로의 문맥 전환을 시도하기 때문이다.</p>
<blockquote>
<p>코어 개수 설정 값</p>
</blockquote>
<pre><code class="language-python"># XX-api(cpu bound &gt; i/o bound)
 - name: DJANGO_SETTINGS_MODULE
          value: &quot;XX.settings.production&quot;
        - name: GUNICORN_CMD_ARGS
          value: &quot;--workers 7 --threads 15 --max-requests-jitter 100 --max-requests 100000 --graceful-timeout 90 --keep-alive 0 --limit-request-field_size 32768 --limit-request-line 0&quot;</code></pre>
<pre><code class="language-python"># webview-api(cpu bound &lt; i/o bound)
- name: DJANGO_SETTINGS_MODULE
          value: &quot;XX.settings.production&quot;
        - name: GUNICORN_CMD_ARGS
          value: &quot;--workers=12 --threads=20 --max-requests-jitter=100 --max-requests 100000 --graceful-timeout 90 --keep-alive 0 --limit-request-field_size 32768 --limit-request-line 0&quot;</code></pre>
<h3 id="번외-장고-커넥션-풀">(번외) 장고 커넥션 풀</h3>
<p>장고는 스프링과 달리 DB 커넥션 풀이 없다. (정확히는 기본적으로 제공해주지 않는것이고, 따로 <a href="https://pypi.org/project/django-db-connection-pool/">플러그인</a>을 깔아야 한다)</p>
<p><strong>요청 당 커넥션</strong>을 무한정으로 들고 있는 방식으로 동작하기 때문에, 따로 타임 아웃이 설정되지 않으면 절대 요청이 끊어지지 않고 <strong>계속 재사용</strong> 하는 방식이므로 <strong>타임 아웃 설정</strong>이 매우 중요하다. </p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/6e3b7dea-bb06-4b68-8e06-60c12a287f50/image.png" alt=""></p>
<p><a href="https://docs.djangoproject.com/en/4.1/ref/databases/#persistent-connections">https://docs.djangoproject.com/en/4.1/ref/databases/#persistent-connections</a></p>
<p>그래서 실무에서도 초기에 <code>워커수 x 스레드수 x pod 수</code> 해서 엄청나게 커넥션이 늘어나서 장애가 발생했었고, 이후 pod 하나당 들고 있는 커넥션 수가 하향되었다고 한다.</p>
<blockquote>
<p>참고 자료</p>
</blockquote>
<p><a href="https://ttu.github.io/servers-handling-requests/">https://ttu.github.io/servers-handling-requests/</a>
<a href="https://blog.hwahae.co.kr/all/tech/5567">https://blog.hwahae.co.kr/all/tech/5567</a>
<a href="https://docs.gunicorn.org/en/stable/faq.html#does-gunicorn-suffer-from-the-thundering-herd-problem">https://docs.gunicorn.org/en/stable/faq.html#does-gunicorn-suffer-from-the-thundering-herd-problem</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] 송금에서의 동시성 이슈 해결기 (feat. 락을 최소한으로 사용해보자!)]]></title>
            <link>https://velog.io/@semi-cloud/DB-%EC%86%A1%EA%B8%88%EC%97%90%EC%84%9C%EC%9D%98-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%EA%B8%B0-feat.-%EB%9D%BD%EC%9D%84-%EC%B5%9C%EC%86%8C%ED%95%9C%EC%9C%BC%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@semi-cloud/DB-%EC%86%A1%EA%B8%88%EC%97%90%EC%84%9C%EC%9D%98-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%EA%B8%B0-feat.-%EB%9D%BD%EC%9D%84-%EC%B5%9C%EC%86%8C%ED%95%9C%EC%9C%BC%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Thu, 08 Feb 2024 14:29:06 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>만약 여러 사람이 한 계좌에 송금하거나, 한 계좌에 송금과 적금이 동시에 일어나는 경우 동시성 이슈를 어떻게 해결할 수 있을까에 대해 고민해보고 작성했다.</p>
</blockquote>
<h4 id="1-여러-사람이-한-계좌에-송금">1. 여러 사람이 한 계좌에 송금</h4>
<p>아래 코드는 송금 로직 예시로,  잔액이 부족하면 충전을 하고, 내 잔액을 차감시키고 친구의 잔액을 증가시키는 로직이다.</p>
<pre><code class="language-java">    @Transactional
    public TransferAccountDto.Res transfer(
        long accountId, String transferAccountNumber, long transferAmount
    ) {
        Account account = accountRepository.findById(accountId)
            .orElseThrow(ErrorCode.INVALID_ACCOUNT::businessException);

        Account transferAccount = accountRepository.findByAccountNumbertransferAccountNumber)
            .orElseThrow(ErrorCode.INVALID_ACCOUNT::businessException);

        // 1. 잔액이 부족할 경우 10000원 단위로 자동 충전한다.
        if (account.isAmountLackToWithDraw(transferAmount)) {
            chargeService.autoChargeByUnit(accountId, transferAmount);
        }

        // 2. 잔액이 여유로워졌다면, 내 계좌의 잔액을 차감시키고 친구의 메인 계좌로 송금한다.
        account.withdraw(transferAmount);
        transferAccount.charge(transferAmount);

        long resultAmount = accountRepository.findAmount(accountId);
        return new TransferAccountDto.Res(resultAmount);
    }</code></pre>
<p>테스트 시나리오는 다음과 같다. </p>
<p>이 상태에서 그대로 100명의 사람이 한 계좌(초기값 0원)에 접근하는 상황을 가정해서 테스트해보자. 테스트가 성공했다면 <code>5000</code> 원씩 송금했으므로 해당 계좌의 잔액은 <code>5000 * 100 = 500000</code> 원이 되어야 한다.</p>
<pre><code class="language-java">    @Test
    void 동시에_같은_계좌에_송금이_발생한다() throws InterruptedException {
        // given
        // 편의상 100명의 회원을 생성하지 않고 한명만 생성, 대신 돈은 100명이서 보내는 만큼 충전
        // 1. 회원 가입 -&gt; 메인 계좌 자동 생성
        var userA = memberService.register(&quot;email1&quot;, &quot;password1&quot;);
        var userB = memberService.register(&quot;email2&quot;, &quot;password2&quot;);

        var userAAccountId = userA.accountId();
        var userBAccountId = userB.accountId();   // userB 에게 동시에 전송

        var transferAmount = 500;
        var chargeAmount = 1000000;

        // 2. B 계좌로 보낼 수 있도록 잔액을 여유롭게 충전한다.
        var userBAccountNumber = accountRepository.findById(userBAccountId).orElseThrow().getAccountNumber();
        chargeService.charge(userAAccountId, chargeAmount);

        var concurrentUser = 1000;
        List&lt;CompletableFuture&lt;Void&gt;&gt; futures = new ArrayList&lt;&gt;();

        // when
        for (int i = 0; i &lt; concurrentUser; i++) {
            futures.add(CompletableFuture.runAsync(() -&gt; {
                accountService.transfer(userAAccountId, userBAccountNumber, transferAmount);
            }));
        }

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

        // then
        var accountAEntity = accountRepository.findById(userAAccountId).orElseThrow();
        var accountBEntity = accountRepository.findById(userBAccountId).orElseThrow();
        assertThat(accountAEntity.getAmount()).isEqualTo(chargeAmount - transferAmount * concurrentUser);
        assertThat(accountBEntity.getAmount()).isEqualTo(transferAmount * concurrentUser);  // 동시성 이슈 확인 필요
    }</code></pre>
<p>하지만 아래와 같이 계좌에는 40000원 밖에 들어오지 않게 되는데, 동시성 이슈가 발생해 테스트가 깨진 것이다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/8b0ca8bf-af7e-4f1a-a56d-ee334c3b1894/image.png" alt=""></p>
<h4 id="송금과-적금이-동시간대에-발생하는-경우">송금과 적금이 동시간대에 발생하는 경우</h4>
<p>테스트 시나리오는 다음과 같다. </p>
<p>계좌에 10000원이 있는 상황에서, 적금으로 자동 이체가 10000원 빠져나가고 동시에 해당 계좌의 주인이 5000원을 친구에게 송금하려고 한다. 당연히 동시성 이슈가 발생하지 않는다면, 자동 이체나 송금 둘중에 하나만 성공해야 한다.</p>
<pre><code class="language-java">    @Test
    void 송금_도중_발생한_적금_자동_이체는_실패한다() {
        // given
        // 1. 회원 가입 및 메인 계좌 생성
        var userA = memberService.register(&quot;email1&quot;, &quot;password1&quot;);
        var userB = memberService.register(&quot;email2&quot;, &quot;password2&quot;);

        var userAId = userA.memberId();
        var userAAccountId = userA.accountId();
        var userBAccountNumber = accountRepository.findById(userB.accountId()).orElseThrow().getAccountNumber();

        // 2. 적금 계좌 생성
        var withdrawAmount = 10000;
        var savingsAccountId = savingsAccountService.createSavingsAccount(userAId, &quot;name&quot;, withdrawAmount, SavingsType.REGULAR).id();

        // 3. 잔액 충전
        var chargeAmount = 10000;
        chargeService.charge(userAAccountId, chargeAmount);

        // when + then
        var transferAmount = 5000;

        var future1 = CompletableFuture.runAsync(() -&gt;
        {
            try {
                savingsAccountService.transferForRegularSavings(userAId);
            } catch (BusinessException ex) {
                assertThat(ex.getErrorMessage()).isEqualTo(ErrorCode.ACCOUNT_LACK_OF_AMOUNT.getMessage());
            }
        });

        var future2 = CompletableFuture.runAsync(() -&gt;
            accountService.transfer(userAAccountId, userBAccountNumber, transferAmount)
        );

        CompletableFuture.allOf(future1, future2).join();  // wait

        var transferResultAmount = accountRepository.findAmount(userAAccountId);
        var savingsResultAmount = savingsAccountRepository.findById(savingsAccountId).orElseThrow().getAmount();
        assertThat(transferResultAmount).isEqualTo(chargeAmount - transferAmount);  // 송금 성공
        assertThat(savingsResultAmount).isEqualTo(0);   // 적금 실패
    }</code></pre>
<p>하지만 테스트를 해보면, 적금 계좌에 그대로 10000원이 들어갔으며 송금도 성공한 것을 볼 수 있다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/b1cd087a-6e3c-41c2-b654-e6caa8459fe0/image.png" alt=""></p>
<blockquote>
<p>☁️ <strong>CompletableFuture</strong>
Java에서 비동기 프로그래밍을 지원하는 클래스중 하나이다. </p>
</blockquote>
<p>그렇다면 문제를 해결하기 위해서는 어떻게 하는게 좋을까?</p>
<h2 id="🙃-비관적-락을-사용해보자">🙃 비관적 락을 사용해보자!</h2>
<p>첫째로, 락을 걸어서 다른 트랜잭션에서 아예 접근할 수 없도록 해보자. 비관적 락의 종류에는 읽기 락과, 쓰기 락 두가지가 존재한다.</p>
<h4 id="읽기-락과-쓰기-락">읽기 락과 쓰기 락</h4>
<ul>
<li><p>읽기 락</p>
<ul>
<li>다른 트랜잭션에서 잠긴 데이터를 읽을 수 있고 다른 공유 락을 획득할 수 있지만, 쓰기는 허용하지 않는다.</li>
<li>참고로 InnoDB는 기본 격리 레벨이 Repeatable Read 이므로 읽기 잠금을 걸지 않아도, 언두 로그를 통해 원본 데이터를 읽어올 수 있다. </li>
</ul>
</li>
<li><p>쓰기 락 </p>
<ul>
<li>다른 트랜잭션에서 같은 레코드에 읽기 / 쓰기 잠금 모두 걸 수 없다. </li>
</ul>
</li>
</ul>
<p>물론 읽기 락을 사용하는게 성능적인 측면에서 낫지만, 읽기 락을 사용한다면 아래 <code>Seralizable Transaction Level</code> 에 나오는 것처럼 <strong>데드락</strong>이 발생하게 된다.</p>
<p>따라서 조회 자체를 막기 위해 쓰기 락을 사용해보자. 아래와 같은 방식으로 송금할 계좌를 DB에서 가져올 때, 비관적 락을 걸어주면 동시성 이슈를 보장할 수 있다.</p>
<pre><code class="language-java">public interface AccountRepository extends JpaRepository&lt;Account, Long&gt; {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(&quot;select ac from Account ac where ac.id = :id&quot;)
    Optional&lt;Account&gt; findByIdWithWriteLock(@Param(&quot;id&quot;) long id);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(&quot;select ac from Account ac where ac.accountNumber = :accountNumber&quot;)
    Optional&lt;Account&gt; findByAccountNumberWithWriteLock(String accountNumber);
</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/1ab04fc3-0b70-49cd-a2ce-00fec9b7edb8/image.png" alt=""></p>
<h2 id="🤔-쓰기락을-사용하지-않고-해결할-수-있을까">🤔 쓰기락을 사용하지 않고 해결할 수 있을까?</h2>
<p>하지만 위와 같은 방식은 동시에 일어날 확률이 낮은 상황에서 성능을 희생시키는 방식이므로 좋지 않다. 특정 계좌의 잔액을 조회하려고만 해도, 송금 트랜잭션이 끝나 락을 반환할 때까지 기다려야 하기 때문이다. 😰</p>
<h3 id="트랜잭션-레벨-조정하기">트랜잭션 레벨 조정하기</h3>
<p>트랜잭션 레벨만 조정해서 이슈를 해결할 수 있을까?</p>
<pre><code class="language-java">@Transactional(isolation=Isolation.SEARLIZEABLE)  // 추가
public TransferAccountDto.Res transfer(
        long accountId, String transferAccountNumber, long transferAmount
) {
    ...
}</code></pre>
<p>트랜잭션 레벨 4인 <code>Serializable</code> 은 해당 트랜잭션 안에서 조회되는 모든 row(SELECT)에 대해서 <strong>읽기락</strong>을 획득하고 읽어오며, 해당 row를 수정할 땐 <strong>쓰기락</strong>을 획득하고 수정한다. </p>
<p>오, 그러면 직접 락을 걸어주지 않아도 레벨을 4로 설정해두면 동시성 이슈가 해결되지 않을까? 라고 생각하면 오산이다. 다음과 같은 상황을 생각해보자.</p>
<blockquote>
</blockquote>
<ol>
<li><code>A</code> 트랜잭션에서 K row를 읽었다. (읽기 락 획득)</li>
<li>이후 <code>B</code> 트랜잭션에서 K row K를 읽었다. (읽기 락 획득)</li>
<li>이후 <code>A</code> 트랜잭션에서 업데이트를 위해 쓰기락을 획득하려고 하지만, B 트랜잭션이 잡은 읽기 락이 반환될 때 까지 대기하게 된다.</li>
<li>마찬가지로 <code>B</code> 트랜잭션에서 업데이트를 위해 쓰기락을 획득하려고 하지만, B 트랜잭션이 잡은 읽기 락이 반환될 때 까지 대기하게 된다.</li>
</ol>
<p>왜 이런 현상이 발생할까? </p>
<p>읽기 락은 여러 트랜잭션에서 동시에 획득할 수 있지만, 다른 트랜잭션이 읽기 락을 획득해 놓은 상태에서는 쓰기 락을 획득하지 못하고 대기해야 하기 때문이다. </p>
<p>따라서 <strong>두 트랜잭션이 동시에 한 row를 읽어오고 수정</strong>하려 한다면 무한히 자원을 기다리게 되는 <strong>데드락 현상</strong>이 발생한다. 직접 테스트를 해봐도, 데드락이 발생하고 만다.. 🥹
<img src="https://velog.velcdn.com/images/semi-cloud/post/e58dfed8-0a88-4901-a74d-c63b7ffed9f3/image.png" alt=""></p>
<p>결론적으로 트랜잭션 격리 수준은 <strong>트랜잭션 동안의 일관된 데이터 읽기</strong>를 고려하기 위해 적용하는거지, 동시성 이슈 해결과는 연관이 없다.</p>
<h3 id="비관적-락을-사용하지-않고-atomic-update를-통해-해결하기">비관적 락을 사용하지 않고 atomic update를 통해 해결하기</h3>
<p>근데 생각해보면, 꼭 처음 트랜잭션에 들어갈 때 락을 잡지 않고 해결할 수 있는 방법이 있어보인다.</p>
<blockquote>
</blockquote>
<p>UPDATE 또는 DELETE 문의 경우 InnoDB는 업데이트하거나 삭제하는 행에 대해서만 잠금을 유지합니다. 일치하지 않는 행에 대한 레코드 잠금은 MySQL이 WHERE 조건을 평가한 후에 해제됩니다. 이는 데드락이 발생할 확률을 크게 낮춥니다. (공식 문서 발췌)</p>
<p>어짜피 가장 중요한 업데이트를 수행할 때 자동으로 쓰기 락을 획득하니, <strong>업데이트 하는 시점에만 현재 대상 잔액이 정확한지 체크</strong>해도 되지 않을까?</p>
<p>즉 읽는 것은 자유롭게 놔두고, 실제 업데이트 할 때 내가 5000원을 송금하려고 했으면 내 계좌의 잔액이 5000원 이상 남아있는지 업데이트문에 조건을 추가해주는 것이다. 그리고 <code>set ac.amount = ac.amount - :amount</code>, <code>set ac.amount = ac.amount + :amount</code> 을 통해서** 업데이트 시점에 읽은 데이터를 바탕으로 차감을 하거나 증감을 해주면 된다.**</p>
<p>MySQL에서는 업데이트의 결과로 성공한 <code>row</code> 수를 반환해주니, 만약 차감 과정에서 이 값이 <code>0</code>이라면 그 사이에 다른 트랜잭션으로 인해 잔액 데이터가 변경되었음을 뜻한다.  따라서 그때 잔액이 부족하다고 예외를 반환해주거나, 재시도를 해주면 된다.</p>
<h4 id="차감-로직">차감 로직</h4>
<pre><code class="language-java">public interface AccountRepository extends JpaRepository&lt;Account, Long&gt; {

    ...
    @Modifying
    @Query(&quot;update Account ac &quot;
        + &quot;set ac.amount = ac.amount - :amount &quot;
        + &quot;where ac.id = :id and ac.amount &gt;= :amount&quot;)
    int withdraw(@Param(&quot;id&quot;) long id, @Param(&quot;amount&quot;) long amount);
    ...
}</code></pre>
<h4 id="입금-로직">입금 로직</h4>
<pre><code class="language-java">
public interface AccountRepository extends JpaRepository&lt;Account, Long&gt; {

    ...
    @Modifying
    @Query(&quot;update Account ac &quot;
        + &quot;set ac.amount = ac.amount + :amount where ac.id = :id&quot;)
    void deposit(@Param(&quot;id&quot;) long id, @Param(&quot;amount&quot;) long amount);
    ...
}
</code></pre>
<p>JPA의 변경감지를 활용하지 않고, 직접 @Query를 통해 조건을 추가해 작성해주었다. 서비스 로직은 다음과 같다.</p>
<pre><code class="language-java">    @Transactional
    public TransferAccountDto.Res transfer(
        long accountId, String transferAccountNumber, long transferAmount
    ) {
        Account account = accountRepository.findById(accountId)
            .orElseThrow(ErrorCode.INVALID_ACCOUNT::businessException);

        // 1. 잔액이 부족할 경우 10000원 단위로 자동 충전한다.
        if (account.isAmountLackToWithDraw(transferAmount)) {
            chargeService.autoChargeByUnit(accountId, transferAmount);
        }

        // 2. 잔액이 여유로워졌다면, 내 계좌의 잔액을 차감시키고 친구의 메인 계좌로 송금한다.
        minusMyAccount(accountId, transferAmount);
        plusTargetAccount(transferAccountNumber, transferAmount);

        long resultAmount = accountRepository.findAmount(accountId);
        return new TransferAccountDto.Res(resultAmount);
    }

    private void minusMyAccount(long accountId, long transferAmount) {
         /**
            atomic update로 해결
         **/
        int effectedRowCnt = accountRepository.withdraw(accountId, transferAmount);
        if (effectedRowCnt == 0) {  // 실패했다면 우선 예외를 던지도록 처리
            throw ErrorCode.ACCOUNT_LACK_OF_AMOUNT.businessException();
        }
    }

    private void plusTargetAccount(String accountNumber, long transferAmount) {
        /**
            atomic update로 해결
        **/
        Account transferAccount = accountRepository.findByAccountNumber(accountNumber)
            .orElseThrow(ErrorCode.INVALID_ACCOUNT::businessException);

        accountRepository.deposit(transferAccount.getId(), transferAmount);
    }
</code></pre>
<h4 id="테스트-결과">테스트 결과</h4>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/7b8056a6-f6dc-493e-9cd0-db911763c451/image.png" alt=""></p>
<h2 id="번외-트랜잭션-레벨에-따른-locking-전략">(번외) 트랜잭션 레벨에 따른 locking 전략</h2>
<p>사실 InnoDB는 각 <strong>트랜잭션 레벨마다, 서로 다른 locking 전략</strong>을 사용한다. </p>
<h3 id="select-구문">SELECT 구문</h3>
<p>InnoDB <code>언두 로그</code> 를 이용한 consistent read 수행하므로, 트랜잭션 레벨 4단계가 아니라면 락을 걸지 않는다.</p>
<h3 id="locking-reads-select-with-for-update-or-for-share-update-delete-구문">locking reads (SELECT with FOR UPDATE or FOR SHARE), UPDATE, DELETE 구문</h3>
<blockquote>
<p>1️⃣ SELECT with FOR UPDATE or FOR SHARE</p>
</blockquote>
<h4 id="level-3-repeatable-read">LEVEL 3 (Repeatable Read)</h4>
<p>유니크 인덱스, 세컨더리 인덱스 여부에 따라 사용하는 락이 달라진다.</p>
<ul>
<li>유니크 인덱스를 사용하거나, 유니크한 조건으로 검색하는 경우 -&gt; 레코드 락을 사용</li>
<li>그 외 세컨더리 인덱스를 사용하거나, 범위 검색하는 경우 -&gt; 넥스트 키 / 갭락을 사용</li>
</ul>
<p>즉, 팬텀 READ 문제를 해결하기 위해 넥스트 키 락(갭 락 범위 앞 뒤), 갭 락(범위)을 사용하므로 성능이 좋지 않다.</p>
<h4 id="level-2-read-commited">LEVEL 2 (Read Commited)</h4>
<p>레벨 3와 다르게 <code>검색 및 인덱스 스캔</code> 에 대해 갭 락이 비활성화되며, 대신 <strong>레코드 락</strong>이 걸린다. 갭락은 외래 키 제약 조건 확인 및 중복 키 확인에만 사용되므로, 새로운 레코드가 중간에 추가되는 <code>phantom READ</code> 현상이 발생할 수 있다.</p>
<blockquote>
<p>2️⃣ UPDATE / DELETE FROM WHERE</p>
</blockquote>
<h4 id="level-3-repeatable-read-1">LEVEL 3 (Repeatable Read)</h4>
<p>범위 검색 시 마주치는 모든 레코드에 베타적 넥스트 키 락을 건다.</p>
<h4 id="level-2-read-commited-1">LEVEL 2 (Read Commited)</h4>
<p>범위 검색 시 업데이트하거나 삭제하는 행에 대해서만 잠금을 유지하고, 일치하지 않는 행에 대한 레코드 잠금은 WHERE 조건을 평가한 후에 바로 해제된다.</p>
<pre><code class="language-java">// LEVEL 3
x-lock(1,2); retain x-lock
x-lock(2,3); update(2,3) to (2,5); retain x-lock
x-lock(3,2); retain x-lock
x-lock(4,3); update(4,3) to (4,5); retain x-lock
x-lock(5,2); retain x-lock</code></pre>
<pre><code class="language-java"> // LEVEL 2
x-lock(1,2); unlock(1,2)
x-lock(2,3); update(2,3) to (2,5); retain x-lock
x-lock(3,2); unlock(3,2)
x-lock(4,3); update(4,3) to (4,5); retain x-lock
x-lock(5,2); unlock(5,2)</code></pre>
<p> 요런 특성을 사용해서, <code>phantom read</code> 를 감수하고 성능적인 측면을 올릴 수도 있을 것 같다.</p>
<blockquote>
<p>참고 자료
<a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html">https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html</a>
<a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html">https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html</a>
<a href="https://hudi.blog/jpa-concurrency-control-optimistic-lock-and-pessimistic-lock/">https://hudi.blog/jpa-concurrency-control-optimistic-lock-and-pessimistic-lock/</a>
<a href="https://www.linkedin.com/pulse/read-committed-pessimistic-locking-distributed-sql-databases-pachot/">https://www.linkedin.com/pulse/read-committed-pessimistic-locking-distributed-sql-databases-pachot/</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] 자바 File I/O 구조 및 성능에 대해 알아보자]]></title>
            <link>https://velog.io/@semi-cloud/%EC%9E%90%EB%B0%94-File-IO-%EA%B5%AC%EC%A1%B0-%EB%B0%8F-%EC%84%B1%EB%8A%A5%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@semi-cloud/%EC%9E%90%EB%B0%94-File-IO-%EA%B5%AC%EC%A1%B0-%EB%B0%8F-%EC%84%B1%EB%8A%A5%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Thu, 18 Jan 2024 02:45:02 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>최근 프로젝트에서 대용량 파일을 읽어올 일이 많이 생겼다. 파일을 읽어와서 <code>Json</code> 형태로 변환을 해줘야 했기에 한줄을 읽어와서 편하게 처리할 수 있도록 <code>BufferedReader.readLine()</code> 을 사용했었다. 하지만 성능상 문제가 있다는 글을 보고, 메서드 별로 어떻게 동작하는지 궁금해서 작성해보는 글이다.</p>
</blockquote>
<p>참고로 자바에서 I/O 는 모두 기본적으로 <code>Blocking I/O</code> 이다. <code>Non-Blocking I/O</code> 를 사용하고 싶다면 NIO 패키지에 존재하는 <code>AsynchronusFileChannel</code> 을 사용해야 한다.</p>
<p>우선, I/O 기본 동작 원리를 보면 다음과 같다. </p>
<h3 id="java-io-가-어떻게-이루어지는가">JAVA I/O 가 어떻게 이루어지는가?</h3>
<blockquote>
<p>먼저 기본 OS의 I/O 에 대해 알아보자.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/60e7850b-466b-431c-bc78-61d9071d98b3/image.png" alt=""></p>
<h4 id="1-사용자가-read-요청">1. 사용자가 read() 요청</h4>
<p>사용자 프로세스는 유저 영역에서만 동작하기 때문에, 하드웨어에 직접 접근하기 위해서는 파일을 읽어달라는 <code>read()</code> 시스템콜을 통해 I/O를 수행해야 한다.시스템콜이 수행되면, 컨텍스트 스위칭은 아니지만 CPU 모드 비트가 유저 모드에서 커널 모드로 스위칭된다. </p>
<p>이후 CPU 주도권을 잡은 커널이 1차적으로 커널 영역의 버퍼(캐시 메모리)에 요청한 데이터가 존재하는지 확인한다. 존재한다면 해당 데이터를 <code>read()</code> 호출 시 전달받은 <strong>메모리 영역에 복사</strong>하고 사용자 프로세스에게 다시 CPU 제어권을 양보한다. </p>
<p>하지만 이때 문제는, 커널 영역의 캐시 메모리에도 존재하지 않는다면 DMA를 통해 디스크로부터 데이터를 가져오는 과정이 추가되면서 느려진다.</p>
<h4 id="2-커널이-디스크-io를-요청">2. 커널이 디스크 I/O를 요청</h4>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/d33d6e16-36a3-4270-865a-8142b7d084d9/image.png" alt=""></p>
<p>주의할 것이 CPU는 너무 많은 인터럽트에 효율적으로 작동하지 못하는 것을 막기 위해, 직접적으로 디스크에 접근하지 않는다.
대신 <code>DMA Controller</code> 라는 중간 계층을 두어, 대신해서 디스크로부터 직접 <code>read</code> / <code>write</code> 연산을 수행하도록 한다.</p>
<ol>
<li><p><code>DMA Controller</code> 가 디스크에게 읽기를 요청하면, 실질적으로 각 장치에 달린 <code>Device Controller</code> 가 디스크에 접근한다.</p>
</li>
<li><p>디스크 데이터 전송이 완료되면 <code>DMA Controller</code> 는 <code>Device Controller</code> 버퍼에 저장된 데이터를 다시 <strong>커널 버퍼 메모리 영역에 블럭 단위로 복사</strong> 후 작업을 끝냈다는 CPU 인터럽트 수행한다.</p>
</li>
<li><p>CPU 완료 인터럽트 발생을 감지한 CPU는, 커널 영역 버퍼 메모리의 데이터를 다시 <strong>유저 영역 버퍼 메모리에 복사</strong>한 뒤 <code>read()</code> 과정을 종료한다. (유저 프로세스가 Spring Boot 였다면 JVM 힙 메모리에 복사가 될것이다.) 그제서야 <code>Block</code> 되어 있단 유저 프로세스 상태가 풀리고, 사이후 요청한 데이터를 사용할 수 있게 된다.</p>
</li>
</ol>
<p>이 모든 과정동안 자바에서 <code>read()</code> 를 호출하면 <strong>스레드가 block 상태로 멈춰있게 되니, 오랜 시간 스레드가 점유되는 문제</strong>가 발생할 것이다.</p>
<h4 id="가상-메모리를-사용하는-방식">가상 메모리를 사용하는 방식</h4>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/0e2da6af-696f-4702-8528-32f64aabd4ab/image.png" alt=""></p>
<p>가상 메모리의 장점은 다음과 같다.</p>
<ol>
<li>1개 이상의 가상 주소가 실제로 같은 물리 메모리 위치와 연결될 수 있다.</li>
<li>가상 메모리 크기는 실제 디스크 크기 보다 클 수 있다.</li>
</ol>
<p>이러한 장점을 활용한다면, <code>Disk Controller</code> 가 하나의 물리 메모리 영역에다가만 데이터를 복사해두고 유저 영역의 버퍼와 커널 영역의 버퍼가 이를 참조하는 형태로 둘 수 있다. 즉 커널 영역에서 유저 영역으로 다시 한번 복사하는 과정이 줄어들어 메모리도 아끼고 성능도 좋아진다.</p>
<p>물론 이때 버퍼의 크기는 블럭(페이지) 사이즈의 배수여야 한다.</p>
<blockquote>
<p>하지만 자바에서는 가상 메모리를 통한 커널 &lt;-&gt; 사용자 영역 간 메모리 공유가 <strong>불가능</strong>하다는데, 자세한 이유는 더 찾아봐야 할 것 같다.</p>
</blockquote>
<h3 id="fileinputstream">FileInputStream</h3>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/240552af-2c87-4024-9ad5-2e430b251d0a/image.png" alt=""></p>
<p>버퍼가 존재하지 않는 <strong>단방향</strong> 스트림이다.</p>
<blockquote>
<p>흔히 <code>BufferedInputStream</code> 이 더 성능이 좋다 하는 것은 <code>read()</code> 처럼 <code>1</code> 바이트씩 파일을 읽어왔을 때 이야기이다. </p>
</blockquote>
<p><code>read(byte[] b)</code> 메서드를 사용한다면, <code>BufferedInputStream</code> 을 사용했을 때 같은 버퍼 사이즈라는 가정 하에 성능 차이가 나지 않기 때문에 사실상 똑같다.</p>
<p>따라서 만약 파일과 같이 적절한 버퍼 사이즈(파일 크기)를 정할 수 있는 상황이라면 <code>FileInputStream</code> 을 사용하고, 웹상의 파일 전송과 같이 정확한 버퍼 사이즈를 모르겠다면 <code>BufferedInputStream</code> 를 사용하는 것이 좋다. (서버는 content-length 필드를 제공하지 않는다)</p>
<h3 id="bufferedinputstream">BufferedInputStream</h3>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/9b8b98ec-f3c8-41a1-ad72-79a5612f3c1b/image.png" alt=""></p>
<p>JAVA I/O 동작 과정의 그림과 같이  <strong>버퍼가 존재</strong>하는 <strong>단방향</strong> 스트림이다. 버퍼를 사용하기 때문에 높은 성능을 보여준다.</p>
<p>예를 들어 파일 크기가 32768 바이트이고, 버퍼 사이즈가 8192 바이트라고 해보자.</p>
<ol>
<li><p><code>FileInputStream.read()</code>
시스템 콜 호출이 <code>32768</code> 번 일어난다.</p>
</li>
<li><p><code>BufferedInputStream.read()</code> 
시스템 콜 한번에 <code>8192</code> 사이즈 만큼의 버퍼에 데이터가 복사되기 때문에, 총 4번의 시스템 콜만 호출된다. 이후 내부 버퍼에서 차례대로 하나씩 데이터를 가져온다.</p>
</li>
</ol>
<h3 id="bufferedreader-vs-bufferedinputstream">BufferedReader VS BufferedInputStream</h3>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/2c714940-ff6c-489c-9a3f-987841003b72/image.png" alt=""></p>
<p><code>BufferedReader</code> 가 <code>BufferedInputStream</code> 보다 통상 느리다고 하는 이유는 문자열로 반환문자 디코딩 작업이 추가되어 있기 때문이다. 디코딩 작업은 다음과 같이 동작한다.</p>
<ol>
<li>2중 루프를 돈다. 첫번째 루프는 버퍼를 돌고, 두번째 루프는 해당 버퍼에서 한줄씩 읽기 위해 <code>\n</code> 과 같은 문자가 나오기 전까지 탐색하는 로직이다. <pre><code class="language-java">BufferLoop:     // 버퍼 전체를 탐색한다.
 CharLoop:   // 한 줄을 탐색한다.</code></pre>
</li>
<li>처음 생성한 <code>StringBuilder</code> 인스턴스에 문자를 추가하고, 바로 <code>String</code> 으로 변환하기 때문에 문자당 <strong>두 개의 복사본</strong>이 생성된다.<pre><code class="language-java">String readLine(boolean ignoreLF, boolean[] term) throws IOException {
 ...
 String str;
 if (s == null) {
     str = new String(cb, startChar, i - startChar);
 } else {
     s.append(cb, startChar, i - startChar);
     str = s.toString();    // 계속 해서 새로운 문자열 객체를 생성해낸다.
 }
 ...
}</code></pre>
</li>
</ol>
<p>따라서 <code>BufferedReader.readLine()</code> 은 모든 I/O 중에 메모리 사용률도 현저히 높다. 속도도 당연히 느릴 것이라 생각하고 테스트를 해봤는데, 예상 밖의 결과가 나왔다.</p>
<h3 id="각-file-io-별로-속도-테스트하기">각 file i/o 별로 속도 테스트하기</h3>
<p>먼저 굉장히 큰 파일을 하나 생성해준다.</p>
<p>리눅스 기준으로 all.json1, all.json2.. all.jsonX 파일이 있다고 할 때 다음과 같이 기존 파일의 크기를 5배 불린 파일을 만들 수 있다.</p>
<pre><code class="language-bash">ls all.json* | xargs cat &gt; total-all.json </code></pre>
<p><code>300MB</code> 정도의 파일 기준으로 테스트해보았다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/363c3555-10e7-4d76-9a8a-8cd7416edceb/image.png" alt=""></p>
<h4 id="1-bufferedinputstream_read">1. BufferedInputStream_read()</h4>
<pre><code class="language-kotlin">   @Test
    fun buffered_input_stream_read_test() {
        var totalCnt = 0

        val start = System.currentTimeMillis()
        BufferedInputStream(FileInputStream(readPath)).use { stream -&gt;  // read() -&gt; buffer 크기 만큼 채워서 하나씩 가져온다.
            while (stream.read() != -1) {   // default buffer size = 8192 byte = 2^13
                totalCnt += 1
            }
        }
        val end = System.currentTimeMillis()
        println(&quot;time : ${end - start}, total read cnt : $totalCnt&quot;)   // 6
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/ca72049b-298c-44f1-8503-18ee707b479b/image.png" alt=""></p>
<p><code>BufferedInputStream</code> 의 <code>read()</code> 메서드는 4초 정도 걸렸다.</p>
<h4 id="2-bufferedreader_read">2. BufferedReader_read()</h4>
<pre><code class="language-kotlin">@Test
    fun buffered_reader_read_test() {
        var totalCnt = 0
        val start = System.currentTimeMillis()
        BufferedReader(FileReader(readPath)).use { reader -&gt;
            while (reader.read() != -1) {
                totalCnt += 1
            }
        }
        val end = System.currentTimeMillis()
        println(&quot;time : ${end - start}, total read cnt : $totalCnt&quot;)
    }</code></pre>
<p><code>BufferedReader</code> 의 <code>read()</code> 메서드 역시 4초 정도 걸렸다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/0af9c896-2ef7-486c-8cba-6814d3153476/image.png" alt=""></p>
<h4 id="3-bufferedreader_readline">3. BufferedReader_readLine()</h4>
<pre><code class="language-kotlin">   @Test
   fun buffered_reader_read_line_test() {
        var totalCnt = 0
        val start = System.currentTimeMillis()
        BufferedReader(FileReader(readPath)).use { reader -&gt;
            var line: String?
            while (reader.readLine().also { line = it } != null) {
                totalCnt += 1
            }
        }
        val end = System.currentTimeMillis()
        println(&quot;time : ${end - start}, total read cnt : $totalCnt&quot;)
    }</code></pre>
<p>놀랍게도, <code>BufferedReader</code> 의 <code>readLine()</code> 메서드는 1초 정도 걸렸다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/a13c38e6-da9f-4fb5-9858-943fe17bb5ed/image.png" alt=""></p>
<p>왜 이렇게 성능에서 큰 차이가 나는지 나름대로 분석을 해보았다. 
현재 읽어오려는 파일이 다음과 같은 형식인데, 아마 한줄 당 길이가 너무 짧아서 2중 반복문이 아니고 1중 반복문만 도는 시간 복잡도가 나왔을 거라 추정한다.</p>
<pre><code class="language-json">[
   {
          &quot;ID&quot;: &quot;V0000483I11300010&quot;,
          &quot;title&quot;: &quot;회색 천가방&quot;,
          &quot;getDate&quot;: &quot;2023-11-30 21시경&quot;,
          &quot;getPlace&quot;: &quot;신창(순천향대)역(한국철도공사)&quot;,
          &quot;type&quot;: &quot;가방 &gt; 기타가방&quot;,
          &quot;receiptPlace&quot;: &quot;신창(순천향대)역(한국철도공사)&quot;,
          &quot;storagePlace&quot;: &quot;신창(순천향대)역(한국철도공사)&quot;,
          &quot;lostStatus&quot;: &quot;보관중&quot;,
          &quot;phone&quot;: &quot;041-543-7788&quot;,
          &quot;context&quot;: &quot;...&quot;,
          &quot;image&quot;: &quot;https://www.lost112.go.kr/lostnfs/images/sub/img04_no_img.gif&quot;,
          &quot;source&quot;: &quot;lost112&quot;,
          &quot;page&quot;: &quot;https://www.lost112.go.kr/find/findDetail.do?ATC_ID=V0000483I11300010&amp;FD_SN=1&quot;
      },
    ...
]</code></pre>
<p>물론 메모리 사용량이 매우 높은거는 사실이다. 아직까지는 이로 인해 메모리 초과 등의 문제가 발생하지 않아서 우선 문자열을 처리하기 쉽도록 <code>readline()</code> 을 선택하고, 이후 문제가 발생하면 조금 느리더라도 메모리 사용량이 적은 <code>read()</code> 로 리팩토링해야겠다.</p>
<blockquote>
<p>NIO 패키지에 속해있는 FileChannel, AsynchronusFileChannel 은 다음 시간에 알아보도록 하자.</p>
</blockquote>
<blockquote>
<p>참고자료
<a href="https://howtodoinjava.com/java/io/how-java-io-works-internally/">https://howtodoinjava.com/java/io/how-java-io-works-internally/</a>
<a href="https://stackoverflow.com/questions/17473863/how-bufferedinputstream-makes-the-read-operation-faster">https://stackoverflow.com/questions/17473863/how-bufferedinputstream-makes-the-read-operation-faster</a>
<a href="https://docs.oracle.com/javase/8/docs/api/java/io/BufferedReader.html">https://docs.oracle.com/javase/8/docs/api/java/io/BufferedReader.html</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 레디스는 싱글 스레드인데 어떻게 동시 요청을 처리할까?]]></title>
            <link>https://velog.io/@semi-cloud/TIL-Single-threaded-Concurrency</link>
            <guid>https://velog.io/@semi-cloud/TIL-Single-threaded-Concurrency</guid>
            <pubDate>Thu, 30 Nov 2023 09:28:15 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Redis는 싱글 스레드인데, 어떻게 수많은 요청을 동시에 처리할 수 있는걸까? </p>
</blockquote>
<p>라는 궁금증에서 해당 포스팅을 작성해보았다. 결론부터 말하자면, <code>Redis</code> 를 제외하고도 <code>Node.js</code> 나 <code>JavaScript</code> 는 싱글 스레드면서 <strong>이벤트 루프를 통해 동시성을 보장</strong>한다.</p>
<p>참고로 현재 Redis 6.0 버전부터는 다른 부가적인 처리를 위해 부분적으로 <code>Multi Thread</code> 이지만, 명령의 실행 자체는 <code>Single Thread</code> 로 동작한다. 즉, <code>read</code> / <code>write</code> 에 대한 작업을 메인 스레드가 각 I/O 전용 스레드에 할당한다.</p>
<p>우선은 <code>I/O</code> 를 별도 스레드로 처리하는 기능을 사용하지 않는다고 했을 때(<code>스레드=1</code>)를 먼저 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/363b8411-e477-485e-a0eb-026243e84ee9/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/39c06ea8-fea4-4857-8c6c-32aea145b0c8/image.png" alt=""></p>
<p>이벤트 루프를 알려면, 먼저 동기 및 비동기의 개념 부터 시작해서 <code>I/O MultiPlexing</code> 이 무엇인지 알아야 한다.</p>
<h2 id="blocking-io">Blocking I/O</h2>
<p><code>Blocking I/O</code> 란, <code>I/O</code> 요청이 수행될 동안 호출한 스레드가 멈추거나 블락되고 끝나기를 기다리는 것을 의미한다. </p>
<p>기본적으로 <code>Synchronous</code> 방식이며, 즉 파일과 같은 입출력이 일어났을 때 시스템 콜 함수가 완료될 때 까지 다른 일을 하지 못하고 언제 끝나나 계속 확인을 하면서 기다리게 된다. 글만 봐도 매우 비효율적일 것 같지 않는가?</p>
<ol>
<li>호출된 함수가 바로 리턴해서 제어권을 넘겨주지 않는다.(<code>Blocking</code>)</li>
<li>호출한 함수가 작업 완료 여부를 계속 확인한다.(<code>Synchronous</code>)</li>
</ol>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/db8f729b-a27d-475b-92d3-65100fe53c09/image.png" alt=""></p>
<blockquote>
<p>그렇다면 스레드를 여러개로 늘려서, 병렬적으로 처리할 수 있도록 해보자.</p>
</blockquote>
<h4 id="멀티스레드--blocking-io-문제점">멀티스레드 + Blocking I/O 문제점</h4>
<p>물론 처리 속도는 빨라지겠지만, 여전히 문제가 발생한다.</p>
<ol>
<li><p>여러 스레드가 트리거되어 대규모 컨텍스트 전환과 높은 메모리 사용량으로 인해 성능 문제가 발생한다. 이 경우 <code>CPU</code> 는 전환, 예약, 스레드 수명주기 유지 등에 대부분의 시간을 소비하게 된다.</p>
</li>
<li><p>각 스레드는 클라이언트가 데이터를 보내고 디스크 <code>I/O</code> 작업을 수행하기 위한 연결을 기다리느라 블락 상태가 되어버린다.</p>
</li>
</ol>
<blockquote>
<p>이렇듯 <code>Blocking I/O</code> 접근을 사용하는 <code>thread per connection</code> 방식은 <strong>많은 동시 연결 상황에 적합하지 않다.</strong></p>
</blockquote>
<h2 id="non-blocking-io-model">Non-Blocking I/O Model</h2>
<p>이제 연결당 하나의 스레드 대신, <code>Non-Blocking</code> 방식으로 연결을 허용하는 단일 스레드를 사용해보자. <code>Non-Blocking I/O</code> 란 <code>I/O</code> <strong>요청이 수행될 동안 스레드가 멈추거나 블락되지 않는 것</strong>을 의미한다.</p>
<p>기본적으로 <code>Non-Blocking + Asynchronous</code> 방식으로 동작한다.</p>
<ol>
<li>호출된 함수가 바로 리턴해서 제어권을 넘겨준다. (<code>Non-Blocking</code>)</li>
<li>호출한 함수가 작업 완료 여부를 확인하며 기다리지 않는다.(<code>Asynchronous</code>)</li>
</ol>
<p>바로 제어권을 넘겨주는 상황에서 데이터가 준비되지 않았다면, 즉 요청에 대한 결과를 반환할 수 없는 상태라면 <code>-1</code> 을 리턴해서 호출한 스레드가 다른 작업을 수행할 수 있도록 한다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/39bd7159-e819-4e65-b3e9-87f44112d119/image.png" alt=""></p>
<p>여기서 찐한 파란 부분은 웨이터의 도움을 받아야 하는, 즉 CPU Active 한 시간이고 연한 파란 부분은 웨이터의 도움이 필요 없는, 즉 I/O (CPU Inactive) 시간이다.</p>
<blockquote>
<p><strong>그럼 싱글 스레드인 상황에서, 각각 언제 써야 할까?</strong></p>
</blockquote>
<ol>
<li><p><code>CPU Burst Time &gt; I/O Burst Time</code> 인 프로세스
<code>Blocking I/O</code> 가 유리할 수도 있으며, <code>Non-BlocKing I/O</code> 가 커다란 도움을 주지 못할 수 있다. 왜냐하면 어차피 대부분 시간은 CPU가 계산하고 있으며 I/O는 드물게 발생하고 짧게 끝나기 때문이다. </p>
</li>
<li><p><code>CPU Burst Time &lt; I/O Burst Time</code> 인 프로세스
<code>Non-Blocking I/O</code> 가 절대적으로 유리하다. <code>Blocking I/O</code> 면 CPU가 싱글 스레드라 다른 요청도 처리하지 못하기 때문에, <code>Non-Blocking</code> 이여야지 다른 작업을 처리하면서 I/O 완료를 기다릴 수 있기 때문이다. <code>Node.js</code> 와 <code>Redis</code> 역시 많은 네트워크 요청(I/O)을 주고 받기 때문에 여기에 속한다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/d7969f08-05ca-4900-84e5-4a15bef58322/image.png" alt=""></p>
</li>
</ol>
<blockquote>
<p>하지만 호출한 스레드가 어떻게 I/O 작업이 완료되었는지 알 수 있지?</p>
</blockquote>
<p>이걸 가능하게 하는 방법이 여러가지가 있다.</p>
<h3 id="1-주기적으로-확인--polling">1. 주기적으로 확인 : Polling</h3>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/b700f20c-dc58-4f53-a8f5-ae638fe1c4ea/image.png" alt=""></p>
<p>주기적으로 확인하는 것을 폴링 방식이라 하는데, 확인을 한다는 점에서 폴링은 <code>Non-Blocking + Synchronous</code> 로 동작한다.</p>
<p>커널로부터 제어권을 받어 효율적이라 보일 수 있지만, <strong>결과를 반환하기 까지 계속 데이터를 반환했는지 확인</strong>하는 <code>busy-waiting</code> 상태가 되어버린다.  </p>
<p>이렇게 되면 다른 작업을 하다가도 상태를 확인하기 위해 컨텍스트 스위칭이 일어나고, 다시 작업을 수행하다가 또 컨텍스트 스위칭이 일어나는 <strong>의미 없이 컨텍스트 스위칭 비용만 낭비</strong>해 성능을 떨어트릴 수 있다. 또한 이렇게 Polling 주기에 따라 성능에 영향을 미치므로 설정이 매우 중요해진다.</p>
<h3 id="2-준비됨을-알림--io-multiplexing">2. 준비됨을 알림 : I/O MultiPlexing</h3>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/a90ce5a7-81ea-4e78-ba6d-f1854b222a1a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/bd899c4e-d03a-4729-822f-550e5148b802/image.png" alt=""></p>
<p><code>I/O MultiPlexing</code> 이란, <strong>관심 있는 I/O 작업들을 동시에 모니터링 하고 그 중에 완료된 I/O 작업들을 한번에 알려주는</strong> 기법이다. 선택 혹은 폴링 시스템 호출을 통해 운영체제에서 단일 스레드가, 여러 소켓 요청을 동시에 기다릴 수 있게 된다. </p>
<blockquote>
<p>리눅스에서는 모든 것이 파일로 귀속된다. 따라서, <strong>소켓 또한 파일</strong>이므로 스레드가 하나라도 여러 개의 파일을 동시에 관리할 수 있다면, <strong>다수 유저의 요청을 처리</strong>할 수 있게 된다.</p>
</blockquote>
<h4 id="이전-동작-방식">이전 동작 방식</h4>
<p>이전에는, 하나의 메인 스레드에서 소켓이 열리면 그 소켓에서 <code>read</code> 할 데이터가 있을 때까지 무한정 기다렸기 때문에 <strong>스레드가 블락</strong>되었다. 즉 무조건 <code>read</code> 요청을 하고, 데이터가 버퍼에 복사될 때까지 기다리는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/3e730e7a-3ade-4256-b41a-2825e30cda86/image.png" alt=""></p>
<h4 id="io-멀티플렉싱-동작-방식">I/O 멀티플렉싱 동작 방식</h4>
<p>I/O 멀티플렉싱에서는 <strong>하나의 스레드가 여러 개의 소켓들을 탐색</strong>하면서, <code>read</code> 할 데이터가 있는지 검사한다.</p>
<p>검사 기법은 다음과 같이 다양하다.</p>
<ul>
<li><code>select</code> , <code>poll</code> 같은 기법에서는 스레드가 <code>file descriptor</code> 테이블을 순회하면서 데이터가 들어왔는지 검사하므로 <code>O(N)</code> 시간복잡도를 가진다. 이 때 계속 상태를 확인하는 작업이 일어나므로 <code>Synchornous</code> + <code>Non-Blocking</code> 인 방식이다.</li>
<li><code>epoll</code> 과 같은 기법에서는 커널이 직접 <code>fd</code> 의 상태를 관리해 상태가 바뀐 것을 <strong>통지</strong>하므로 <code>Asynchornous</code> + <code>Non-Blocking</code> 인 방식이다.</li>
</ul>
<p>즉 어찌 되었던 커널에서 결과 값이 준비되었다는 콜백 신호가 오면, 그때서야 유저 프로세스는 자신의 버퍼로 데이터를 복사하므로 실제 디스크에서 커널 버퍼까지 가져오는 시간을 대기하지 않아도 되는 장점이 있다.</p>
<blockquote>
<p>명확히 구분하자면 유저 프로세스에서의 <code>I/O</code> 작업 자체가 블락 되는 것이 아니라 <code>select</code>, <code>poll</code> 같은 멀티플렉싱 관련 <code>system call</code> 에 대한 <strong>커널의 응답이 블락</strong>된다고 봐야 한다. </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/8dc54ac8-cffe-4b2d-bb0d-35a9a2c823f4/image.png" alt=""></p>
<h3 id="event-loop">Event-Loop</h3>
<p>자바 스크립트의 <code>Event-Loop</code> 방식과 비슷하게 동작한다. 이벤트 루프는 콜 스택을 계속 감시하고 있다가 비어있는 상태가 되면, 콜백 큐에 있던 콜백을 전달해준다. 즉 콜 스택과 콜백 큐를 항상 모니터링 하고 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/f4859bc2-15bd-49dd-aace-d5d998788c2c/image.png" alt=""></p>
<p>싱글 스레드 형식의 <code>Redis Event Loop</code> 에서는, 하나의 이벤트 루프에서 앞서서 말했던 <code>IO Multiplexing</code> 을 이용해서 <code>read</code> / <code>write</code> 이벤트를 받아온다. 커널에 할당된 폴링공간에 모니터링할 이벤트를 등록하고, 발생한 이벤트를 리턴받아 <code>Multiple I/O Event</code> 를 처리할 수 있도록 해주는 방식이다.</p>
<p>즉 <code>TCP</code> 연결을 비동기 방식으로 수락한 다음, 이벤트 루프에서 수락된 각각의 연결을 처리한다. 이때 읽기/쓰기 작업에 사용이 가능하도록 준비가 된 <code>fd</code> 를 알기 위해, <code>epoll()</code>을 사용한다.</p>
<blockquote>
<p>참고자료
<a href="https://betterprogramming.pub/internals-workings-of-redis-718f5871be84">https://betterprogramming.pub/internals-workings-of-redis-718f5871be84</a>
<a href="https://engineering.linecorp.com/ko/blog/do-not-block-the-event-loop-part1">https://engineering.linecorp.com/ko/blog/do-not-block-the-event-loop-part1</a>
<a href="https://blog.naver.com/n_cloudplatform/222189669084">https://blog.naver.com/n_cloudplatform/222189669084</a>
<a href="https://www.youtube.com/watch?v=wB9tIg209-8">https://www.youtube.com/watch?v=wB9tIg209-8</a>
<a href="https://notes.shichao.io/unp/ch6/">https://notes.shichao.io/unp/ch6/</a>
<a href="https://www.youtube.com/watch?v=mb-QHxVfmcs">https://www.youtube.com/watch?v=mb-QHxVfmcs</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] HashSet과 HashMap의 차이와 해시 충돌을 어떻게 해결하는지 알아보자!]]></title>
            <link>https://velog.io/@semi-cloud/Java-HashSet-VS-HashMap-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4</link>
            <guid>https://velog.io/@semi-cloud/Java-HashSet-VS-HashMap-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4</guid>
            <pubDate>Thu, 09 Nov 2023 10:57:57 GMT</pubDate>
            <description><![CDATA[<p>결론부터 말하자면, <code>HashSet</code> 역시 내부적으로 <code>HashMap</code> 을 사용해서 동작한다. 그리고 <code>HashMap</code> 은 해시 테이블 기반으로 동작한다.</p>
<h3 id="☁️-hashmap">☁️ HashMap</h3>
<p>자바에서 해시맵은 <code>Node 객체의 배열</code> 을 저장소로 활용한다. <code>Node</code> 클래스는 <code>해싱한 결과값</code>, <code>키</code>, <code>값</code>, 그리고 <code>Seperate Chaining</code> 을 위한 <code>다음 노드를 가리키기 위한 Node 객체</code> 를 필드로 가진다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/0cd61820-e65f-4fa4-be44-bd661a6fd0aa/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/41b27b43-b5a1-4091-be3d-b58ad6c21d83/image.png" alt=""></p>
<h4 id="저장-연산">저장 연산</h4>
<p><code>key</code> 로 들어온 값을 해싱하고, 그 결과값을 <code>배열의 인덱스</code> 로 사용해서 값을 집어넣는다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/4d010c64-012f-4e78-bbfb-528ddb19081a/image.png" alt=""></p>
<p>사용되는 해시 함수는 다음과 같다.</p>
<pre><code class="language-java"> static final int hash(Object key) {
     int h;
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h &gt;&gt;&gt; 16);
}</code></pre>
<h3 id="☁️-hashset">☁️ HashSet</h3>
<blockquote>
<p>해시셋도 해시맵을 내부적으로 사용하면, 대체 다른 점이 뭔가?</p>
</blockquote>
<h4 id=""></h4>
<p>앞서서 <code>Node</code> 클래스에 <code>Key</code> 와 <code>Value</code> 필드가 있었다. </p>
<p>HashSet은 <code>Key</code> 자체에 <code>Set에 넣으려는 해당 객체</code> 가 들어가고, <code>객체의 해시값이 인덱스</code> 로 사용된다. 즉 <code>value</code> 에는 아래에서 볼 수 있듯이 <strong>비어 있는 객체</strong>가 들어간다는 차이가 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/f4b8be77-471f-4f8a-8315-dde0f608672d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/9b3d42a4-b7bf-45de-a943-14ab2f751af3/image.png" alt=""></p>
<h4 id="해시-자료구조에서-데이터의-중복을-판별하는-법">해시 자료구조에서 데이터의 중복을 판별하는 법</h4>
<p>해시 맵이나, 해시셋이나 <strong>해시 충돌을 파악</strong>해서 후처리를 해야 한다.</p>
<ol>
<li>해시 맵은 해시 충돌이 일어난다면 데이터를 이어서 붙이던지(<code>seperate chaining</code>), 빈 공간에 넣던지(<code>open adressing</code>) 등의 처리를 한다.</li>
<li>해시 셋은 <code>Set</code> 자료구조의 <code>중복 불가</code> 특성을 고려함에 있어서 기존에 있던 값과 원래 있던 값이 같은지 파악해야 한다.</li>
</ol>
<p>즉 <strong>새로 들어온 객체와 기존에 존재하는 객체가 정말 주소값도, 논리적인 값도 일치</strong>하는지 판단해야 한다. 그리고 바로 해당 근거를 판단하는 기준으로 <code>hashCode</code> 와 <code>equals</code> 를 사용한다.</p>
<blockquote>
</blockquote>
<ol>
<li>hashCode() 를 통해 객체 고유의 값을 비교한다. 같으면 2번 과정으로 넘어간다.</li>
<li>equals() 를 통해 논리적인 값까지 비교한다.</li>
</ol>
<p>다시 한번 코드로 봐보자!</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/b5856970-7488-4f25-a5ed-bd8df5cc5297/image.png" alt=""></p>
<p><code>e</code> 는 새롭게 바꿔치기 할 노드, <code>p</code> 는 기존 해시 테이블에 있던 값을 의미한다.</p>
<ol>
<li><p><code>해시값</code> 으로 찾은 배열의 인덱스에 값이 아직 없다면( <code>null</code> ) 새롭게 할당을 한다.</p>
</li>
<li><p>이미 해시 테이블에 값이 존재한다면, <code>Key 중복</code> 을 판별한다.</p>
<blockquote>
<ol>
<li>해시값 동일 여부 판단</li>
<li>키 값으로 넣으려는 객체의 주소값 판단</li>
<li>키 값으로 넣으려는 객체의 논리적 동등성 판단</li>
</ol>
</blockquote>
<p>기존에 존재하는 데이터와 함수 인자로 넘어온 <code>key</code> 를 비교해서** 완벽하게 같다면 기존 노드가 그대로 대체**된다. 해당 과정에서 <code>hashCode()</code> 와 <code>equals()</code> 가 활용된다.</p>
</li>
<li><p>만약 <code>Key</code> 가 완벽하게 같은 노드가 아니라면, <code>seperate chaining</code> 이 일어난다. 레드 블랙 트리의 노드 구조인 <code>TreeNode</code> 의 인스턴스일 경우 트리에 값을 추가한다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/589cc945-67ed-4124-a003-4688cbaca1f0/image.png" alt=""></p>
</li>
</ol>
<ol start="4">
<li>만약 <code>Key</code> 가 완벽하게 같은 노드가 아닌데 <code>TreeNode</code> 의 인스턴스가 아니라면, <strong>연결 리스트 형태로 노드를 추가</strong>해나가다가 총 사이즈가 <code>TREEIFY_THRESHOLD</code> 값인 <code>8</code> 개 이상이 된다면 <strong>트리 형태로 바뀌는 작업</strong>이 일어난다. 
<img src="https://velog.velcdn.com/images/semi-cloud/post/637ea3e5-4cc8-4eb6-bc10-3ecd725877ea/image.png" alt=""></li>
</ol>
<h3 id="☁️-linkedhashmap">☁️ LinkedHashMap</h3>
<p>추가적으로, <code>LinkedHashMap</code> 은 해시맵에 넣은 <strong>순서가 보장이 되는 자료구조</strong>이다. 내부적으로 <code>Doubly-Linked List</code> 형태로 구현되어 있다.</p>
<blockquote>
<p>어떻게 순서를 보장할 수 있을까?</p>
</blockquote>
<p><code>Entry</code> 클래스는 현재 값을 담고 있는 <code>기본 노드</code> 에, <code>이전을 가르키는 노드</code> 하나와 <code>다음을 가르키는 노드</code> 로 구성되어 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/415ca8c6-cf99-4e6d-9668-56559125f40b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/7bee17ac-a2fd-4318-b046-f7f10fe9dafa/image.png" alt=""></p>
<p>기본 노드에 들어있는 <code>next</code> 포인터는 <code>seperate chaining</code> 방식에서 사용될 연결 리스트의 다음 노드를 가르키는 역할을 한다. </p>
<p>반면 <code>Entry</code> 클래스에 들어있는 <code>before</code>, <code>after</code> 포인터는 키 충돌 발생과 관계 없이 추가되는 순서대로 연결이 되도록 가르키는 역할을 하므로 넣은 순서를 기억할 수 있는 것이다.</p>
<p>만약 새로운 노드가 추가된다고 가정해보자.</p>
<ol>
<li><code>tail</code> 노드가 <code>null</code> , 즉 아무런 데이터가 없다면 첫 번째로 들어오는 데이터를 <code>header</code> 로 설정한다.</li>
<li>이미 데이터가 존재한다면 넣으려는 데이터의 <code>before</code> 이 기존에 존재했던 마지막 <code>Entry</code>를 가리키게 하고, 마지막 존재했던 <code>Entry</code> 의 <code>next</code> 를 본인으로 설정해서 연결한다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/0f764bbe-90f4-4318-b866-38f7599ae06a/image.png" alt=""></p>
<blockquote>
<p>구조를 그림으로 보면 다음과 같다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/92f44c67-6490-4aba-adc2-993e8c0e0db5/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] @Cacheable 동작 과정과 Redis 직렬화 방식에 대해 알아보자]]></title>
            <link>https://velog.io/@semi-cloud/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%BA%90%EC%8B%9C-%EC%A0%81%EC%9A%A9-%EA%B3%BC%EC%A0%95%EC%97%90%EC%84%9C%EC%9D%98-%EC%82%BD%EC%A7%88%EA%B8%B0</link>
            <guid>https://velog.io/@semi-cloud/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%BA%90%EC%8B%9C-%EC%A0%81%EC%9A%A9-%EA%B3%BC%EC%A0%95%EC%97%90%EC%84%9C%EC%9D%98-%EC%82%BD%EC%A7%88%EA%B8%B0</guid>
            <pubDate>Sun, 10 Sep 2023 12:54:01 GMT</pubDate>
            <description><![CDATA[<h3 id="☁️-redis를-사용한-이유">☁️ Redis를 사용한 이유?</h3>
<p><code>Redis</code> 나 <code>Memcached</code> 를 사용하는 방법은 글로벌 캐싱 전략이다.</p>
<p>즉, 서버마다 각각 캐시 저장소를 두지 않고 한 곳에서 관리한다. 이로써 서버가 확장되었을 때 서버 간 데이터 동기화가 필요하지 않아지지만, 그만큼 네트워크 트래픽을 더 탄다는 단점이 존재한다.</p>
<p>우리 서비스에서는 추후 확장 가능성을 고려하여, <strong>글로벌 캐시 전략</strong>을 사용하였다. 마치 CPU에 존재하는 L1, L2 캐시와 외부에 존재하는 L3 캐시가 바로 글로벌 캐시이다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/e6b7bb20-49c6-4cc9-8064-e85dc69d6ead/image.png" alt=""></p>
<h3 id="☁️-cacheable이란">☁️ @Cacheable이란?</h3>
<p>우리는 쉽게 스프링의 <code>@Cacheable</code> 어노테이션을 통해 캐싱을 적용할 수 있다. </p>
<p>해당 어노테이션은 <code>@transactional</code> 과 유사하게 <code>AOP</code> 방식으로 동작하며, 스프링 부트에서 일종의 다양한 인메모리 데이터베이스들의 <strong>캐시 과정을 추상화</strong>해놓은 것이라 생각하면 된다.</p>
<blockquote>
</blockquote>
<p>인메모리 캐시 데이터베이스에는 Redis, Hazelcast, Apache Geode, Java Map/ConcurrentMap 등이 존재한다.</p>
<p>즉, 스프링 부트에서 다음의 과정을 내부적으로 처리해주는 것이다.</p>
<ol>
<li>기존 데이터가 있다면 즉시 반환하고 메서드를 실행시키지 않는다.</li>
<li>기존 데이터가 없다면 메서드를 실행시키고, 반환된 데이터를 캐시 데이터로 저장한다.</li>
</ol>
<p><code>Spring Boot</code> 에서 제공하는 캐시는 <code>Cache</code> 와 <code>CacheManager</code> 인터페이스를 통해 추상화 되어 있다.</p>
<pre><code class="language-java">public interface Cache {
    @Nullable
    &lt;T&gt; T get(Object key, @Nullable Class&lt;T&gt; type); // 데이터를 조회
    void put(Object key, @Nullable Object value); // 데이터를 저장
    void evict(Object key);
    void clear();
}</code></pre>
<pre><code class="language-java">public interface CacheManager {
    @Nullable
    Cache getCache(String name);  // 이름에 해당하는 캐시 조회
    Collection&lt;String&gt; getCacheNames();
}</code></pre>
<p><code>Redis</code> 를 예시로 들어보자.
<img src="https://velog.velcdn.com/images/semi-cloud/post/24b435b1-bbc1-4531-afe8-717e15b1bc82/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/7ed1b790-5f04-4113-abf8-dc40ee138f5f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/d46931da-8696-4dd9-8871-70c57ff6dabf/image.png" alt=""></p>
<p>만약 위와 같은 <code>CacheManager</code> 나 <code>CacheResolver</code> 를 특별히 빈으로 등록하지 않으면, 스프링 부트가 자동으로 아래 우선순위에 따라 존재하는지 체크하고 결정해서 사용하게 된다.</p>
<blockquote>
</blockquote>
<ol>
<li>Generic</li>
<li>JCache (JSR-107) (EhCache 3, Hazelcast, Infinispan, and others)</li>
<li>Hazelcast</li>
<li>Infinispan</li>
<li>Couchbase</li>
<li>Redis   &lt;-- 선택</li>
<li>Caffeine</li>
<li>Cache2k</li>
<li>Simple  // 메모리 map 기반</li>
</ol>
<p>나와 같은 경우 <code>Redis</code> 설정 정보가 존재했기 때문에 <code>RedisCacheManager</code> 가 선택된 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/fa126660-4ea0-4c87-892a-670fc300dfa8/image.png" alt=""></p>
<p>그렇다면 <code>Aspect</code> 에서는 어떠한 과정이 일어나는 걸까?</p>
<h4 id="cacheaspectsupport">CacheAspectSupport</h4>
<p>먼저, <code>CacheInterceptor</code> 는 단순히 AOP 호출용, 실제 모든 캐시 공통 로직은 <code>CacheAspectSupport</code> 에서 수행된다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/8001f850-2111-47e0-877f-93b939dd49e4/image.png" alt=""></p>
<p><code>CacheAspectSupport</code> 는 <code>Strategy</code> 패턴을 활용해 동적으로 달라지는 알고리즘을 인터페이스로 추상화하고, 구성 방식을 활용해 갈아끼울 수 있도록 해 유연성을 증가시켰다. 아래 세가지 필드를 세터로 받는다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/91a8563e-dcd7-464c-9e02-2b6c178e670d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/0841330a-0a5f-48a4-9360-64f684995e6f/image.png" alt=""></p>
<p>우선 주어진 메서드와 클래스에 대해 적용 가능한 모든 캐시 연산 <code>(CacheOperation=@CachePut, @Cacheable, @CacheEvict 등</code> ) 들을 찾는다. 그리고 아래 과정들이 발생한다.</p>
<h4 id="1-cacheable">1. @Cacheable</h4>
<ul>
<li>메서드 결과를 캐시에 저장하고, 이 후 같은 파라미터로 메서드가 호출될 경우 캐시 된 결과를 반환한다. (읽기 작업)</li>
</ul>
<h4 id="2-cacheput">2. @CachePut</h4>
<ul>
<li>메서드를 실행하고 그 결과를 캐시에 저장한다. (쓰기 작업)</li>
</ul>
<h4 id="3-cacheevict">3. @CacheEvict</h4>
<ul>
<li>캐시에서 하나 이상의 엔트리를 제거하며, 메서드가 실행된 후 지정된 키 또는 캐시 이름의 엔트리를 제거한다.</li>
</ul>
<blockquote>
<p>자세한 동작 과정을 직접 코드로 확인해보자.</p>
</blockquote>
<ol>
<li>이미 캐시 데이터가 있는지 인메모리 DB에서 키 값으로 조회하여 확인하고, <strong>캐시 미스</strong>가 난 경우 <code>@Cacheable</code> 이 붙어있는 메서드들을 불러와서 이후 캐시를 업데이트하도록 <strong>쓰기 요청을 날리기 위해</strong> <code>cachePutRequests</code> 에 추가해둔다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/b7c431bd-edde-406f-ac86-51501d80ccda/image.png" alt=""></li>
</ol>
<ol start="2">
<li><p><strong>캐시 히트가 발생했고 별도 저장 요청이 없다면</strong> 캐시에 있는 데이터를 가져와서 옵셔널 형태로 감싸서 바로 반환한다. 
만약 그렇지 않다면 <code>invoke</code> 를 통해 <strong>실제 메서드를 실행시켜서 원본 저장소로부터 값을 받아오고</strong>, 반환 값을 <code>unwrap</code> (옵셔널 형태라면 <code>get()</code> 을 하는 과정)한다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/63902504-ad89-4b81-b5ca-f092d12608c6/image.png" alt=""></p>
</li>
<li><p>원본 저장소로부터 가져온 값을 업데이트 하기 위해 <strong>다시 인메모리 DB로 저장 요청</strong>을 날리는 과정이 수행된다. 이때 내부적으로 <code>RedisCache</code> 가 동작해서 실제 쓰기 연산이 수행된다.
<img src="https://velog.velcdn.com/images/semi-cloud/post/109c6c75-7fdd-460f-b5c5-91d61988fb7d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/75869d51-6bca-4416-ad9b-245e2a257104/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/300bf018-76ac-4050-955a-7112492b9df3/image.png" alt=""></p>
</li>
</ol>
<h4 id="cacheresolver">CacheResolver</h4>
<p><code>CacheResolver</code> 는 <code>CacheManager</code> 를 내부 필드로 가지고 있으며, 해당 클래스를 활용해 인터셉트된 메서드 호출에 사용할 캐시 인스턴스를 결정한다.</p>
<pre><code class="language-java">@FunctionalInterface
public interface CacheResolver {
    Collection&lt;? extends Cache&gt; resolveCaches(CacheOperationInvocationContext&lt;?&gt; context);
}</code></pre>
<h2 id="🧚🏻-직렬화를-하지-못하는-이슈">🧚🏻 직렬화를 하지 못하는 이슈</h2>
<p>하지만, 단순히 <code>@Cacheable</code> 와 <code>@EnableCaching</code> 만 등록시켜놓으면 다음과 같은 에러가 나게 된다.</p>
<blockquote>
</blockquote>
<p>org.springframework.data.redis.serializer.SerializationException: Cannot serialize org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize </p>
<h3 id="☁️-step-01-직렬화-관련-이슈-해결하기">☁️ STEP 01: 직렬화 관련 이슈 해결하기</h3>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/fa126660-4ea0-4c87-892a-670fc300dfa8/image.png" alt=""></p>
<p>왜 직렬화에 실패했을까? 이유는 바로 우리가 따로 직렬화 구현체를 빈으로 등록하지 않으면, <code>Default</code> 로 <code>JDKSerializationRedisSerializer</code> 가 등록되기 때문이다.</p>
<p>실제로 디버깅해보면 <code>RedisCacheConfiguration</code> 에서 <code>key</code> 직렬화/역직렬화에는 <code>StringRedisSerializer</code> 를, <code>value</code> 에는 <code>JDKSerializationRedisSerializer</code> 를 기본값으로 채택하고 있는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/5a5fef47-93fa-48ff-bd12-eea0bddd8921/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/a2f5395f-1c41-4282-89d2-7d7d41d7a6cd/image.png" alt=""></p>
<p>당연히 우리는 캐싱하려는 객체에 <code>Seralizable</code> 을 구현해주지 않았다. 따라서 직렬화를 할 수 없다는 에러가 났던 것이므로, 안전하게 <code>UID</code> 까지 붙여주면 성공이다.</p>
<pre><code class="language-kotlin">class SearchSubwayLineDto {
    data class Response(
        val subwayLines: List&lt;SubwayLine&gt;
    ): Serializable {
        companion object {
            private const val serialVersionUID: Long = -4129628067395047900L
        }
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/426552b8-2b76-4011-8d89-67a5b4a96eac/image.png" alt=""></p>
<h4 id="jdkserializationredisserializer-단점">JDKSerializationRedisSerializer 단점</h4>
<ol>
<li><p><code>serialVersionUID</code> 설정을 하지 않으면, 자동으로 클래스의 구조와 필드 값을 활용해 만든 기본 해쉬값을 <code>serialVersionUID</code> 로 사용한게 된다. </p>
<p>따라서 만약에 <strong>클래스 구조가 변경</strong>되면, <code>serialVersionUID</code> 값이 달라서 기존 저장 데이터의 <strong>역직렬화에 실패</strong>하게 된다. 하지만 이 역시 <code>serialVersionUID</code> 를 수동으로 설정해도 타입이 바뀌면 역직렬화 에러가 발생하고, 구조가 바뀌면 데이터가 누락되어 저장된다.</p>
<blockquote>
<p>즉 직렬화 구현은 언제 터질지 모르는 시한폭탄이 되버린다.</p>
</blockquote>
</li>
<li><p>직렬화 데이터에 타입에 대한 모든 메타 데이터들까지 포함되기 때문에 용량이 커진다. 만약 <code>Redis</code> 와 같은 인메모리 DB에 저장하게 된다면, 이는 고려 대상이 된다.
<a href="https://techblog.woowahan.com/2551/">https://techblog.woowahan.com/2551/</a></p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/e438f756-b0bf-4309-a1c2-4743d5f19e44/image.png" alt=""></p>
<p>나와 같은 경우 <code>DTO</code> 를 직렬화 시켜야 하므로 응용 계층에 인접한 특성상, 변경에 대한 여지가 높았다. 이처럼 자바 직렬화는 변경에 매우 취약하기 때문에 다른 직렬화 방식을 도입해보기로 했다.</p>
<h3 id="☁️-step-02-다른-직렬화-방식-사용">☁️ STEP 02: 다른 직렬화 방식 사용</h3>
<p><code>GenericJackson2JsonRedisSerializer</code>  를 쓰도록 결정했다.</p>
<ul>
<li><code>Jackson2JsonRedisSerializer</code> : 직접 클래스 타입을 지정해주어야 해서 글로벌 설정에서는 한정적이였다.</li>
<li><code>StringRedisSerializer</code> : 매번 <code>ObjectMapper</code> 를 통해 인코딩과 디코딩을 해야 하는데 나와 같은 경우는 <code>@Cacheable</code> 때문에 스프링 내부에서 해당 과정이 일어나서 적합하지 않다고 판단했다. </li>
</ul>
<pre><code class="language-java">.serializeValuesWith(
      RedisSerializationContext.SerializationPair
               .fromSerializer(GenericJackson2JsonRedisSerializer(objectMapper))
 )</code></pre>
<h4 id="genericjackson2jsonredisserializer">GenericJackson2JsonRedisSerializer</h4>
<p>별도의 <code>Class Type</code> 을 지정할 필요 없이 자동으로 <code>Object</code>를 <code>Json</code> 으로 직렬화해주지만, 해당 <code>Class Type</code> 을 포함한 데이터까지 저장하게 된다는 단점이 존재한다.</p>
<p>이때, 문제가 되는 것이 해당 클래스의 패키지까지 함께 저장되게 되면서 만약 서버가 다수라면 해당 데이터를 역직렬화하기 위해서는 무조건 루트, 경로에 같은 이름으로 DTO Class를 생성해야만 에러가 나지 않는다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/25b0c4a4-99ae-4744-a72b-3fdaa5c12331/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/11338161-aa37-4b42-827e-2d5c91035182/image.png" alt=""></p>
<h4 id="문제-발생">문제 발생</h4>
<p>하지만 위 코드와 같이 그냥 <code>objectMapper</code> 만 넣어주었더니 아래와 같은 문제가 발생했다. </p>
<blockquote>
</blockquote>
<p>java.lang.ClassCastException:
class java.util.LinkedHashMap cannot be cast to class backend.team.ahachul_backend.api.common.adapter.in.dto.SearchSubwayLineDto$Response</p>
<p>현재 저장하려는 데이터 구조는 다음과 같다. 하지만 이런 경우 <code>objectMapper</code> 가 원소 타입을 모르기 때문에, 역직렬화 시도에서 대상 유형 정보가 제공되지 않으면 기본 유형인 <code>LinkedHashMap</code> 을 사용하게 된다.</p>
<p><a href="https://javadoc.io/doc/com.fasterxml.jackson.core/jackson-databind/2.3.1/com/fasterxml/jackson/databind/ObjectMapper.DefaultTyping.html">https://javadoc.io/doc/com.fasterxml.jackson.core/jackson-databind/2.3.1/com/fasterxml/jackson/databind/ObjectMapper.DefaultTyping.html</a></p>
<pre><code class="language-kotlin">class SearchSubwayLineDto {
     data class Response(
        val subwayLines: List&lt;SubwayLine&gt;
     )

     data class SubwayLine(
        val id: Long,
        val name: String,
        val phoneNumber: String,
        val stations: List&lt;Station&gt;
    ) 
 }</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/db4436c3-5c58-48cf-a469-127d172fb2d2/image.png" alt=""></p>
<p>따라서 <code>objectMapper</code> 에 따로 타입을 유추하도록 하는 설정, 즉 <code>enableDeafultTyping</code> 을 추가했다. <code>ObjectMapper</code> 는 기본적으로 직렬화/역직렬화 시 <code>class type</code> 정보를 포함하지 않기 때문에, 직렬화된 데이터에는 <code>type</code> 정보가 존재하지 않는다.</p>
<blockquote>
<p>🔖 <strong>enableDefaultTyping</strong>
사용될 클래스의 타입을 지정하며, 명시적으로 유형 정보를 지정해주지 않은 경우에만 사용된다.</p>
</blockquote>
<pre><code class="language-kotlin">{
    @Bean
    fun redisCacheManager(redisConnectionFactory: RedisConnectionFactory, objectMapper: ObjectMapper): RedisCacheManager {
        val validator = BasicPolymorphicTypeValidator.builder().build()
        objectMapper.activateDefaultTyping(validator, ObjectMapper.DefaultTyping.NON_FINAL)

        val configuration = RedisCacheConfiguration.defaultCacheConfig()
            .disableCachingNullValues()
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(GenericJackson2JsonRedisSerializer(objectMapper))
            )

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(redisConnectionFactory)
            .cacheDefaults(configuration).build()
    }</code></pre>
<p><a href="https://stackoverflow.com/questions/28821715/java-lang-classcastexception-java-util-linkedhashmap-cannot-be-cast-to-com-test">https://stackoverflow.com/questions/28821715/java-lang-classcastexception-java-util-linkedhashmap-cannot-be-cast-to-com-test</a></p>
<p>하지만 또 다른 에러가 발생했으니.. <code>JSON</code> 관련 파싱 에러이다. 이 문제는 래퍼 클래스로 고쳤다고 하는데, 도무지 안고쳐져서 아예 <code>objectMapper</code> 를 제거해보았다.</p>
<blockquote>
<p> Resolved [org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Unexpected token (START_OBJECT), expected START_ARRAY:</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/80f40ba8-eeb8-41b0-92b6-6cc489cdd40a/image.png" alt=""></p>
<h3 id="☁️-step-03-objectmapper-전달-x">☁️ STEP 03: ObjectMapper 전달 X</h3>
<p>결국 <code>objectMapper</code> 를 내가 직접 지정해주는 과정에서 기존 <code>GenericJackson2JsonRedisSerializer</code> 의 로직에서 혼동이 생겼던 것 같다. 아예 생성자에 전달하지 않으니, 이제 위에서 봤던 에러는 나오지 않았다. </p>
<h4 id="기본-생성자가-없는-이슈">기본 생성자가 없는 이슈</h4>
<blockquote>
</blockquote>
<p>Could not read JSON: Cannot construct instance of </p>
<p>하지만 <code>JSON</code> 을 클래스의 인스턴스로 역직렬화 하는 과정에서 기본 생성자가 없다는 에러가 발생했다. <code>kotlin data class</code> 를 사용하고 있어서 롬복을 적용하지 못하는 상황이였고, 가장 간단한 방법인 <code>@JsonProperty</code> 를 적용해서 해결하였다.</p>
<blockquote>
<p>🔖 <strong>@JsonProperty</strong>
해당 객체를 만드는 설명서 역할을 <code>Jackson</code> 에게 전달하는 방안 중 하나로, 기본 생성자가 없어도 해당 정보를 보고 자동으로 객체를 생성해낸다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/c91d3a1b-0e96-43a3-8d36-679dfde4bc78/image.png" alt=""></p>
<p>  <img src="https://velog.velcdn.com/images/semi-cloud/post/a0654d5b-ac15-4cd0-84b5-4897b110b065/image.png" alt=""></p>
<blockquote>
<p>참고 자료
<a href="https://shanepark.tistory.com/374">https://shanepark.tistory.com/374</a>
<a href="https://stackoverflow.com/questions/72092382/does-redis-cache-have-advantage-over-spring-cache-if-used-only-for-simple-cache">https://stackoverflow.com/questions/72092382/does-redis-cache-have-advantage-over-spring-cache-if-used-only-for-simple-cache</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Design Pattern] Proxy 패턴]]></title>
            <link>https://velog.io/@semi-cloud/Design-Pattern-Proxy-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@semi-cloud/Design-Pattern-Proxy-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Sat, 09 Sep 2023 02:58:18 GMT</pubDate>
            <description><![CDATA[<h2 id="원격-프록시">원격 프록시</h2>
<p>원격 프록시는 실제 객체처럼 행동하지만, 실제로는 네트워크로 진짜 객체와 데이터를 주고 받게 된다. 즉 원격 프록시는 각 사용자의 JVM 내부의 힙 영역에 존재하는 로컬 객체라고도 할 수 있다.</p>
<ol>
<li>클라이언트가 로컬에서 원격 프록시를 호출한다.(대변자 역할)</li>
<li>프록시가 실제 원격 힙에 존재하는 객체를 호출하며, 네트워크 통신과 저수준 작업 또한 해당 과정에서 처리된다.</li>
<li>원격 힙 객체의 메서드가 호출된다.</li>
</ol>
<blockquote>
<p>클라이언트는 프록시 객체임을 몰라야 하고, 실제 서비스를 제공한다고 생각한다.</p>
</blockquote>
<h3 id="원격-호출-과정">원격 호출 과정</h3>
<pre><code class="language-java">Duck d = new Duck();  // 같은 힙 공간에 존재해야만 함</code></pre>
<p>다른 컴퓨터의 힙에 들어있는 객체 레퍼런스를 가져오기 위해서는, 자바의 원격 메서드 호출( <code>RMI:Remote Method Invocation</code> ) 기능을 알아야 한다.</p>
<h4 id="rmi-과정">RMI 과정</h4>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/855a5407-ebc0-4e11-b9ce-05669e74beff/image.png" alt=""></p>
<ol>
<li>클라이언트 힙에 존재하는 보조 객체가 메소드 호출에 관한 정보(이름, 인자)등을 전달한다.</li>
<li>실제 객체가 존재하는 서버의 힙에서 서비스 보조 객체가 소켓 연결을 통해 보조 객체의 요청을 받고, 호출 정보를 해석해서 실제 객체의 메서드를 호출한다. </li>
<li>실제 객체로부터 리턴값을 받고, 소켓의 출력 스트림으로 클라이언트 보조 객체에게 전달한다.</li>
</ol>
<p><strong>네트워킹 및 입출력 관련 코드가 프록시 내부</strong>에 존재하니, 클라이언트는 단순히 <code>JVM</code>에 있는 메서드를 호출하듯이 <strong>로컬에서도 원격 메서드를 호출</strong>할 수 있게 되는 것이다.</p>
<h4 id="인터페이스">인터페이스</h4>
<p>실제 객체와 프록시 객체가 공통으로 사용할 원격 인터페이스를 만들고 구현한다.</p>
<pre><code class="language-java">import java.rmi.*;

public interface MyRemote extends Remote { 
    public String sayHello() throws RemoteException;
}</code></pre>
<ul>
<li><code>Remote</code> : 해당 인터페이스에서 원격 호출을 지원한다는 것을 알려주는 마커용 인터페이스</li>
<li>네트워크 장애에 대비한 예외 처리가 필요하다.</li>
<li>원격 메서드의 인자와 리턴값은 원시 형식 혹은 네트워크로 전달될 때 직렬화가 가능한 <code>Serializable</code> 형식이여야 한다.</li>
</ul>
<h4 id="스텁-객체프록시">스텁 객체(프록시)</h4>
<pre><code class="language-java">import java.rmi.*;

public class MyRemoteImpl extends UnicastRemoteObject implemtents MyRemote {
    private static final long serialVersionUID = 1L;

    public MyRemoteImpl() throws RemoteException {}

    public String sayHello() {
        return &quot;hi&quot;;
    }

    public static void main(String[] args) {
        try {
            MyRemote service = new MyRemoteImpl();
            Naming.rebind(&quot;RemoteHello&quot;, service);  // Rmi 레지스트리에 결합
        }
    }
}</code></pre>
<ul>
<li><p>원격 객체 기능을 사용하기 위해 상속받은 <code>UnicastRemoteObject</code> 는 <code>Serializable</code> 을 구현하고 있으며, 예외를 던지기 때문에 모두 잡아서 처리해줘야 한다.</p>
</li>
<li><p><code>rebind()</code> : 서버의 RMI 레지스트리에 등록한다. 이후에 클라이언트 쪽에서 요청이 오면 해당 레지스트리에 등록된 프록시 객체를 반환한다.</p>
</li>
</ul>
<h4 id="클라이언트">클라이언트</h4>
<p>콘솔에서 프로그램 시작 전에,  <code>rmiregistry</code> 를 먼저 실행시켜야 한다.</p>
<pre><code class="language-java">% rmiregistry</code></pre>
<pre><code class="language-java">try {
    MyRemote service = (MyRemote) Naming.lookup(&quot;rmi://127.0.0.1/RemoteHello&quot;);
    String s = service.sayHello();  // 프록시 객체 호출
} catch (Exception e) {}</code></pre>
<ul>
<li><code>lookup()</code> : 스텁 객체(프록시 객체)를 요청하는 메서드, 인자로 서비스가 돌아가고 있는 시스템의 호스트 이름 혹은 IP 주소를 전달한다.</li>
</ul>
<h3 id="프록시-패턴">프록시 패턴</h3>
<p>특정 객체로의 접근을 제어하는 대리인을 제공한다. 프록시 객체와 실제 객체 모두 같은 인터페이스를 구현하여, 클라이언트에서 어느 객체를 호출하고 있는지 모르도록 할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/924e3dcd-a51d-4bce-9f11-524687b2fc4e/image.png" alt=""></p>
<p>프록시 패턴의 변형으로는 다음의 세가지가 존재하며, 모두 형태는 같지만 그 쓰임이 다르다.</p>
<ol>
<li>원격 프록시 패턴 : 다른 JVM에 들어있는 객체의 대리인에 해당하는 로컬 객체이다.</li>
<li>가상 프록시 패턴 : 생성하는데 많은 비용이 드는 객체를 대신한다.</li>
</ol>
<h2 id="가상-프록시">가상 프록시</h2>
<p>앞단에서 프록시 객체를 통해 생성 비용이 비싼 객체의 생성을 실제 요청이 오기 전까지 뒤로 미루는 기능을 제공한다. </p>
<p>혹은, 생성 비용이 오래 걸리는데 클라이언트에게 높은 응답 속도를 제공해줘야 할 때 프록시 객체에서 실제 객체의 작업이 완료되기 전까지 다른 반환값을 보여줄 수도 있다.</p>
<h3 id="jpa에서도-프록시가-있다고">JPA에서도 프록시가 있다고?</h3>
<p>JPA 역시 가상 프록시 방식으로 동작한다. 이를 통해, 연관되어 있는 실제 객체의 메서드가 호출되기 전까지(필드를 불러오는 메서드) ID만 담은 프록시 객체가 동작한다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/a2457357-0400-4e68-b99d-289b9e57099f/image.png" alt=""></p>
<p>메서드가 호출되면, 프록시 객체가 DB 호출을 통해 실제 객체에 값을 담고 이후로는 실제 객체로 메서드 호출을 위임하게 되는 것이다. 마찬가지로 이를 통해 불필요한 쿼리가 나가는 성능 저하를 막고, 비용을 아낄 수 있다.</p>
<h2 id="동적-프록시-패턴">동적 프록시 패턴</h2>
<p>컴파일 시점이 아닌 런타임 시점에 동적으로 프록시 클래스를 만들어주는 패턴으로, 자바에서 공식적으로 지원하는 기능이다.</p>
<pre><code class="language-java">public class CustomInvocationHandler implements InvocationHandler {
    Person person;

    public Object invoke(Object Proxy, Method method, Object[] args) {
        method.invoke(args);
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] ORDER BY, GROUP BY, DISTINCT 절에서 옵티마이저는 어떻게 동작하는가]]></title>
            <link>https://velog.io/@semi-cloud/ORDER-BY-GROUP-BY-DISTINCT-%EC%A0%88%EC%97%90%EC%84%9C-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%99%EC%9E%91%ED%95%98%EB%8A%94%EA%B0%80</link>
            <guid>https://velog.io/@semi-cloud/ORDER-BY-GROUP-BY-DISTINCT-%EC%A0%88%EC%97%90%EC%84%9C-%EC%98%B5%ED%8B%B0%EB%A7%88%EC%9D%B4%EC%A0%80%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%99%EC%9E%91%ED%95%98%EB%8A%94%EA%B0%80</guid>
            <pubDate>Wed, 30 Aug 2023 10:51:57 GMT</pubDate>
            <description><![CDATA[<h2 id="order-by-처리">ORDER BY 처리</h2>
<p>MySQL에서 정렬을 처리하는 방법은 크게 두가지이다.</p>
<p><strong>1. 인덱스를 통해 정렬하는 방법</strong></p>
<p><code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 쿼리가 실행될 때 <strong>이미 인덱스가 정렬이 되어있으므로 별도의 작업이 필요 없이 순서대로 읽기만 하면 되서</strong> 성능이 매우 빠르다. </p>
<p>하지만 당연히 조회 말고 다른 변경 쿼리에 대해서는 느리고, 인덱스가 저장될 때 디스크 공간과 <code>InnoDB</code> 버퍼 풀을 위한 메모리가 많이 필요하다는 단점도 있다.</p>
<blockquote>
<p>이렇게 인덱스를 타서 성능을 향상시킬 수 있는 경우는 한정적이다. </p>
</blockquote>
<ol>
<li>정렬 기준이 많아서 모두 인덱스를 생성하는 것이 불가능한 경우</li>
<li><code>GROUP BY</code> 나 <code>DISTINCT</code> 같은 처리의 결과를 정렬해야 하는 경우</li>
<li><code>UNION</code> 결과와 같이 임시 테이블의 결과를 다시 정렬해야 하는 경우</li>
<li>랜덤하게 결과 레코드를 가져와야 하는 경우</li>
</ol>
<p><strong>2. FileSort를 이용하는 방법</strong></p>
<p><code>FileSort</code> 는 인덱스를 이용하지 않고 <code>MySQL</code> 서버가 <strong>별도의 정렬 처리</strong>를 수행했다는 것을 나타낸다. 문제는 정렬을 수행하기 위해, <strong>소트 버퍼(Sort buffer)</strong>라고 하는 별도의 메모리 공간을 할당받는다는 것이다.</p>
<p>물론 메모리에 할당된 소트 버퍼 공간으로만으로 정렬 처리를 수행할 수 있다면 문제가 발생하지 않는다. 하지만 정렬 처리를 해야할 데이터가 너무 많아서 할당된 소트 버퍼 공간을 초과한다면?</p>
<p>MySQL는 이런 경우 정렬 레코드를 여러 조각으로 나누고, <strong>임시 저장을 위해 디스크 공간을 사용</strong>해버린다. 결국 디스크를 왔다갔다 하면서 정렬을 하기 때문에 수많은 디스크 <code>I/O</code> 가 발생하고, 마지막에 각 정렬된 레코드들을 다시 병합하면서 정렬이 완료되므로 성능이 매우 느려진다.</p>
<blockquote>
<p>그렇다면 소트 버퍼의 크기를 매우 크게 늘려주면 되는거 아닌가요?</p>
</blockquote>
<p>소트 버퍼도 메모리이고, 특히나 여러 클라이언트가 공유하는 영역이 아닌 <strong>세션 메모리 영역</strong>에 해당한다. </p>
<p>따라서 해당 영역 크기가 커지게 될수록 OS는 <strong>메모리 부족 현상</strong>을 겪게 될것이고, 이는 결국 <code>OOM-Killer</code>가 여유 메모리를 확보하기 위해 가장 많은 메모리를 사용하고 있는 대상 1순위인 MySQL 서버를 강제종료시키는 지경까지 이르를 수 있다.</p>
<h3 id="☁️-소트-버퍼-저장-기준-정렬-방식">☁️ 소트 버퍼 저장 기준 정렬 방식</h3>
<p>위에서 레코드를 정렬할 때 레코드 전체를 소트 버퍼에 담을지, 혹은 정렬 기준 칼럼만 소트 버퍼에 담을지에 따라 또 방식이 2가지로 나눠진다.</p>
<h4 id="싱글-패스-정렬-방식">싱글 패스 정렬 방식</h4>
<p>싱글 패스 정렬 방식은, 정렬 키와 <code>SELECT</code> <strong>칼럼을 모두 가져와서</strong> 정렬한다. 이러한 경우 정렬에 필요하지 않은 칼럼들까지 불필요하게 소트 버퍼에 담기기 때문에 공간이 많이 필요하다.</p>
<p>따라서, <strong>정렬 대상 레코드 크기나 건수가 작은 경우</strong> 빠른 성능을 보이게 된다.</p>
<pre><code class="language-sql">SELECT emp_no, first_name, last_name
FROM employees
ORDER BY first_name;</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/958ab186-1372-4cbd-9c2c-598cc90289ff/image.png" alt=""></p>
<blockquote>
<p>참고로 최신 버전의 MySQL에서는 해당 방식을 기본적으로 사용하지만, 다음과 같은 경우는 싱글 패스 정렬 방식을 사용하지 못한다.</p>
</blockquote>
<h4 id="투-패스-정렬-방식">투 패스 정렬 방식</h4>
<p>투 패스 정렬 방식은 <strong>정렬 대상 칼럼</strong>과 <code>PK</code> 값만 소트 버퍼에 담아 정렬을 수행한다. 우선 정렬을 하고, PK를 통해 나머지 필요한 칼럼들을 가져와서 클라이언트에게 넘기는 방식이다.</p>
<p>하지만 그만큼 DISK I/O가 2번이나 발생하기 때문에(테이블을 한번 더 읽어야 하므로) 정렬 대상 레코드의 크기나 건수가 상당히 많은 경우 싱글 패스 정렬 방식보다 효과적이다.</p>
<blockquote>
<p>🫧 *<em>SELECT * 를 지양해야 하는 이유? *</em>
앞서 말했듯이 <code>*</code> 는 모든 칼럼을 가져오게 하므로, 정렬 버퍼에 불필요한 칼럼이 들어가 비효율적으로 동작한다. 따라서 꼭 필요한 칼럼만 조회하도록 하자.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/3265a208-a069-495d-88db-db11d7d7bd10/image.png" alt=""></p>
<h3 id="☁️-정렬-처리-방법">☁️ 정렬 처리 방법</h3>
<p>앞서 말했듯이, <code>ORDER BY</code> 는 무조건 아래 두 가지 방식 중 하나로 실행된다. </p>
<ol>
<li><strong>인덱스</strong>를 사용할 수 있다면 순서대로 읽어서 반환 
이 경우, <code>ORDER BY</code> 에 명시된 칼럼이 제일 먼저 읽는 테이블(조인에서 드라이빙 테이블) 에 속해야 하고 해당 칼럼의 인덱스가 있어야 한다. 또한, <code>WHERE</code> 절 칼럼과 <code>ORDER BY</code> 절 칼럼이 같아야 한다.<pre><code class="language-sql">SELECT * 
 FROM employees e, salaries s
 WHERE s.emp_no = e.emp_no AND e.emp_no BETWEEN 10002 AND 100020
 ORDER BY e.emp_no;</code></pre>
<img src="https://velog.velcdn.com/images/semi-cloud/post/b5134cc2-3675-445e-b883-e7d17cd8663e/image.png" alt=""></li>
</ol>
<blockquote>
<p>🫧 <strong>Nested Loop 조인</strong>
두 테이블이 조인을 할 때, 드라이빙 테이블(Outer 테이블)에서 결합 조건에 일치하는 레코드를 내부 테이블(Inner Table)에서 조인하는 방식</p>
</blockquote>
<ol start="2">
<li>인덱스를 사용할 수 없는 경우 조건에 해당하는 레코드를 <strong>정렬 버퍼</strong>에 저장하면서 정렬을 처리</li>
</ol>
<p>이때, <code>Filesort</code> 를 이용하는 2번 과정에서 옵티마이저는 <strong>정렬 대상 레코드를 최소화</strong>하기 위해 다음 2가지 방법 중 하나를 선택한다.</p>
<h4 id="1-조인의-드라이빙-테이블만-정렬">1. 조인의 드라이빙 테이블만 정렬</h4>
<p>조인이 수행되면 레코드 크기가 배로 불어나기 때문에, 조인 수행 전에 <strong>첫 번째 테이블의 레코드를 먼저 정렬한 이후 조인을 수행</strong>하는 방식이다. 단, <code>ORDER BY</code> 칼럼이 드라이빙 테이블의 칼럼만으로 구성되어야 한다.</p>
<pre><code class="language-sql">SELECT * 
    FROM employees e, salaries s
    WHERE s.emp_no = e.emp_no AND e.emp_no BETWEEN 10002 AND 100020
    ORDER BY e.last_name;</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/2e341050-e43f-43e3-9f50-55bb508fa4c1/image.png" alt=""></p>
<p><code>ORDER BY</code> 절에 인덱스가 걸려있지 않은 상황이여도, 옵티마이저는 드라이빙 테이블에 포함된 칼럼임을 보고 알아서 정렬을 먼저 수행할 수 있다.</p>
<ol>
<li><code>emp_no</code> 인덱스를 이용해 <code>BETWEEN</code> 조건 만족 레코드 검색</li>
<li>해당 검색 결과를 소트 버퍼를 이용해 <code>last_name</code> 칼럼 기준으로 정렬 수행</li>
<li>정렬된 결과를 차례로 읽으면서 <code>salaries</code> 테이블과 조인 수행</li>
</ol>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/867e29ce-2edd-4094-b6a3-71f1a75981e2/image.png" alt=""></p>
<h4 id="2-조인이-끝나고-일치하는-레코드를-모두-가져온-후-정렬-수행">2. 조인이 끝나고 일치하는 레코드를 모두 가져온 후 정렬 수행</h4>
<p>이 경우, 조인 수행한 결과를 소트 버퍼가 아닌 <strong>임시 테이블</strong>에 저장한다. 메모리에 위치하는 소트 버퍼와 달리 임시 테이블은 디스크 영역에 있으므로, 당연히 세가지중 가장 성능이 느리다.</p>
<ol>
<li>조인을 수행한다.</li>
<li>조인 수행한 모든 결과를 임시 테이블에 저장하고, 정렬한다. </li>
</ol>
<pre><code class="language-sql">SELECT * 
    FROM employees e, salaries s
    WHERE s.emp_no = e.emp_no AND e.emp_no BETWEEN 10002 AND 100020
    ORDER BY s.salary;   // salries 테이블 칼럼으로 정렬</code></pre>
<p>위 쿼리에서 임시 테이블이 사용된 이유는, <code>ORDER BY</code> <strong>칼럼이 드라이빙 테이블이 아닌 드리븐 테이블에 속해있기</strong> 때문이다. 정렬을 수행하기 전에 <code>salaries</code> 테이블의 데이터가 필요하므로, 조인이 먼저 일어나게 된다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/b9993ee1-2204-49b2-b14f-967a71414a86/image.png" alt=""></p>
<blockquote>
<p>🫧 <strong>정렬에 따른 <code>Extra</code> 표기법</strong></p>
</blockquote>
<ol>
<li>인덱스를 사용한 정렬 : 별도 표기 <code>X</code></li>
<li>조인에서 드라이빙 테이블만 정렬 : <code>Using filesort</code></li>
<li>조인에서 조인 결과를 임시 테이블로 저장 후 정렬 : <code>Using temporary; Using filesort</code> </li>
</ol>
<h3 id="☁️-정렬-처리-방법-성능-비교-limit의-효과">☁️ 정렬 처리 방법 성능 비교: Limit의 효과</h3>
<p>그렇다면 이제 왜 <code>ORDER BY</code> 와 <code>GROUP BY</code> 작업이 <code>LIMIT</code> 을 써도 성능이 좋지 않은지에 대해 살펴보자.</p>
<p><code>LIMIT</code> 은 레코드 처리 결과의 일부만 가져오도록 해서, MYSQL 서버가 처리해야할 작업량을 줄이는 역할을 한다. 결론부터 말하면, <code>ORDER BY</code> 는 버퍼링 방식으로 동작하기 때문에 <code>LIMIT</code> 을 써도 느리다.</p>
<p><strong>1. 스트리밍 처리 방식</strong></p>
<p>서버 쪽에서 처리할 데이터가 어느정도 되는지에 관계없이, 조건에 일치하는 레코드가 검색될 때 마다 바로 클라이언트로 전송하므로 매우 빠른 응답 시간을 보장한다. </p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/4d1dd021-de44-4214-af9b-ba8451d0a129/image.png" alt=""></p>
<p>주로 <strong>인덱스를 사용해 정렬</strong>을 할 때 스트리밍 방식으로 처리된다. <code>limit</code> 가 없어도 스캔의 결과가 아무런 버퍼링 처리나 필터링 과정 없이 바로 클라이언트로 전송되기에 빠르다.</p>
<p>하지만 여기에 <code>limit</code> 까지 붙이면 전체적으로 가져오는 레코드 건수를 줄이기 때문에 훨씬 빨라진다. 클라이언트에 바로바로 반환을 하기에 개수를 충족하는 순간 바로 동작을 멈추기 때문이다.</p>
<p>*<em>2. 버퍼링 처링 방식 *</em></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/32a92d5c-3d6e-46a1-b858-8a039d32eca7/image.png" alt=""></p>
<p><code>ORDER BY</code> 나 <code>GROUP BY</code> 조건이 들어가있는 상황에서 인덱스를 사용할 수 없다면, 스트리밍 방식을 사용할 수 없다. </p>
<p>우선 <code>WHERE</code> 조건에 일치하는 레코드를 모두 가져온 이후 정렬하거나 그루핑하는 과정이 일어나야 하기 때문인데, 결국 클라이언트로 응답 속도가 매우 늦어진다. </p>
<p>따라서 해당 방식은 <code>limit</code> 로 결과 건수를 제한해도, 네트워크로 전송되는 레코드의 건수만 줄일 뿐 <code>MySQL</code> 작업의 성능 향상에는 효과가 크지 않다.</p>
<h2 id="group-by-처리">GROUP BY 처리</h2>
<p><code>GROUP BY</code> 역시 <code>ORDER BY과</code>  마찬가지로 스트리밍 처리를 할 수 없게 한다. 즉, 조건에 일치하는 레코드가 검색되어도 바로바로 클라이언트로 전송되지 않는다.</p>
<p>따라서 굳이 필터링 역할을 하는 <code>HAVING</code> 절을 튜닝하려고 인덱스를 생성하거나 할 필요는 없다.</p>
<h3 id="인덱스를-사용하는-경우">인덱스를 사용하는 경우</h3>
<p><code>group by</code> 에서 인덱스가 사용되는 경우, 인덱스 스캔과 루스 인덱스 스캔이 실행되는 두 가지 방식이 있다.</p>
<h4 id="인덱스-스캔-이용">인덱스 스캔 이용</h4>
<p>조인의 드라이빙 테이블에 속하는 컬럼을 이용해 <code>grouping</code> 을 할 때 <strong>해당 칼럼에 인덱스</strong>가 있다면, 당연하게 <strong>정렬된 인덱스를 차례대로 읽으면서 그루핑 작업을 수행</strong>한다.</p>
<p>하지만, 예외로 <code>MAX</code> 와 같은 그룹 함수를 처리해야 하는 경우나, 인덱스를 사용하지 못하는 경우 그룹핑을 처리할 임시 테이블이 따로 필요하므로 실행 계획에서 <code>Extra</code> 칼럼에 아래와 같이 <code>Using temporary</code> 가 표시된다.</p>
<h4 id="루스-인덱스-스캔-이용">루스 인덱스 스캔 이용</h4>
<p>만약 인덱스가 <code>(emp_no, from_date)</code> 처럼 멀티 인덱스로 생성되어 있는 상태에서 아래와 같이 <code>where</code> 절 조건이 첫 번째 인덱스에 포함되지 않는 SQL 쿼리문에 대해서는, 루스 인덱스 스캔이 실행된다.</p>
<pre><code class="language-sql">explain select emp_no
  from salaries
  where from_date=&#39;1985-03-01&#39;
  GROUP BY emp_no;
</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/5408e70f-276f-4d31-a77a-df735d9982c9/image.png" alt=""></p>
<p>루스 인덱스 스캔에 대한 자세한 설명은 <a href="">여기</a>를 참조하고, 쿼리 실행 과정은 다음과 같다.</p>
<ol>
<li><code>(emp_no, from_date)</code> 를 스캔하면서 <code>emp_no</code> 의 첫번째 유일한 값을 찾아낸다. </li>
<li>알아낸 <code>emp_no</code> 값과 <code>from date</code> 조건을 <code>and</code> 로 합쳐서 해당 레코드만 가져온다.</li>
<li>다시 인덱스를 스캔하면서 <code>emp_no</code> 의 그 다음 유니크한 값을 가져온다.(이후 반복)    </li>
</ol>
<p>하지만 주의할점은 인덱스 레인지 스캔과 달리 <strong>카디널리티(중복)가 높을수록 성능이 향상된다.</strong> 중복이 낮은 경우, <code>MySQL</code> 옵티마이저가 인덱스에서 스캔해야할 시작 지점을 검색하는 작업이 많이 필요해지기 때문이다.</p>
<blockquote>
<p>🫧 <strong>인덱스 스킵 스캔</strong>
MySQL 8.0 이후 인덱스 스킵 스캔 최적화 방식이 등장하면서, 꼭 <code>GROUP BY</code> 절이 아니더라도 <code>WHERE</code> 조건절이라면 루스 인덱스 스캔 방식을 사용할 수 있어졌다. 인덱스 스킵 스캔은 루스 인덱스 스캔을 이용해서 동작하기 때문이다. </p>
</blockquote>
<h3 id="임시-테이블을-사용하는-경우">임시 테이블을 사용하는 경우</h3>
<p><code>GROUP BY</code> 기준 칼럼이 드라이빙/드리븐 테이블 어디에 있느냐에 상관 인덱스를 전혀 사용하지 못할때는, 임시 테이블 방식으로 실행된다.</p>
<h4 id="임시-테이블이란">임시 테이블이란?</h4>
<p><code>GROUP BY</code> 절 칼럼들로 구성된 <strong>유니크 인덱스</strong>를 가진 테이블을 의미한다. 해당 별도로 생성하고, 중복 제거와 집합 함수 연산을 수행한다. </p>
<blockquote>
<p>🫧 <strong>임시 테이블이 저장되는 위치</strong>
<code>ORDER BY</code>나 <code>GROUP BY</code> 처럼 별도의 데이터 가공 작업이 수행되기 위해 만들어지는 임시 테이블은 메모리에 우선 생성되었다가 크기가 커지면 디스크로 이동한다.</p>
</blockquote>
<ol>
<li><code>메모리</code> 에 저장될 때 : 가변 길이 타입을 지원하는 <code>TempTable</code> 스토리지 엔진</li>
<li><code>디스크</code> 에 저장될 때 : 트랜잭션이 가능한 <code>InnoDB</code> 스토리지 엔진</li>
</ol>
<pre><code class="language-sql">EXPLAIN
    SELECT e.last_name, AVG(s.salary)
    FROM employees e, salaries s
    WHERE s.emp_no=e.emp_no
    GROUP BY e.last_name;</code></pre>
<p>위와 같은 쿼리에서, 인덱스를 전혀 사용하지 못하므로 아래와 같은 임시 테이블이 생성된다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/2af33fdb-3bf6-44ff-ab13-694de141ec1c/image.png" alt=""></p>
<pre><code class="language-sql">CREATE TEMPORARY TABLE ... (
    last_name VARCHAR(16),
    salary INT,
    UNIQUE INDEX ux_lastname (last_name)  // 유니크 인덱스
);</code></pre>
<ol>
<li>임시 테이블이 생성된다.</li>
<li>조인 이후, 결과를 한건씩 가져와서 <code>INSERT</code> 한다. 이때, 유니크 인덱스이므로 중복 체크 과정이 필요한데 읽기 잠금이 사용되고 쓸 때는 다시 쓰기 잠금이 사용된다.</li>
<li>이미 <code>last_name</code> 칼럼이 있다면 해당 칼럼에 <code>salary</code> 값을 업데이트 한다. </li>
</ol>
<p>이렇게 인덱스 자체에서 정렬이 되어있기 때문에, 별도로 <code>ORDER BY e.last_name</code> 조건을 명시하지 않는 이상 <code>MySQL 8.0</code> 부터는 <code>Using Filesort</code> 와 같이 별도의 정렬 연산을 수행하지 않는다. </p>
<blockquote>
<p>MySQL 5.7 까지는 GROUP BY 조건에 있는 그루핑 칼럼을 기준으로 정렬이 사용되었다.</p>
</blockquote>
<h2 id="disticnt-처리">DISTICNT 처리</h2>
<p>칼럼이 아닌 <strong>레코드의 중복을 제거</strong>하기 위해 사용되는 <code>DISTICNT</code> 는 집합 함수와 함께 사용되는 경우와 그렇지 않은 경우 영향을 미치는 범위가 다르다.</p>
<h3 id="순수-distinct">순수 DISTINCT</h3>
<p>순수 <code>SELECT</code> 절에서 사용된 <code>DISTINCT</code> 는 <code>GROUP BY</code>와 동일한 방식으로 처리된다. 아래 쿼리와 같이 인덱스를 사용할 수 있다면 임시 테이블을 사용하지 않지만, 인덱스를 사용할 수 없는 경우에도 임시 테이블의 유니크 인덱스를 통해 중복이 제거되기 때문이다.</p>
<pre><code class="language-sql">SELECT DISTINCT emp_no FROM salaries;
SELECT emp_no FROM salaries GROUP BY emp_no;</code></pre>
<p>또한 이렇게 <code>SELECT</code> 절에 사용된 <code>DISTINCT</code> 키워드는 <strong>조회되는 모든 칼럼에 영향을 미친다.</strong> 예를 들어, 아래와 같은 경우 특정 <code>first_name</code> 이 유니크한 칼럼을 가져오는게 아니라 <code>(first_name, last_name)</code> 조합 자체가 유니크한 칼럼을 가져온다.</p>
<pre><code class="language-sql">SELECT DISTINCT first_name, last_name FROM employees;</code></pre>
<h3 id="집합-함수와-함께-사용된-distinct">집합 함수와 함께 사용된 DISTINCT</h3>
<p>반면, 집합 함수와 함께 사용된다면 모든 칼럼 조합이 유니크한 것이 아닌** 주어진 특정 칼럼값이 유니크한 것**들만 가져온다.</p>
<pre><code class="language-sql">EXPLAIN SELECT COUNT(DISTINCT s.salary)  // 임시 테이블 생성
    FROM employees e, salaries s 
    WHERE e.emp_no=s.emp_no
    AND e.emp_no BETWEEN 100001 AND 100100;</code></pre>
<ol>
<li>인덱스를 사용할 수 있다면 일반 <code>GROUP BY</code> 처럼 동작한다.</li>
<li>인덱스를 사용할 수 없다면 유니크 인덱스를 가진 임시 테이블을 생성한다.</li>
</ol>
<p>이 역시 <code>COUNT(DISTINCT emp_no)</code> 처럼 인덱스를 사용할 수 있다면 임시 테이블 없이 최적화된 처리를 수행할 수 있지만, 예제에서 <code>salary</code> 에는 인덱스가 걸려 있지 않은 상황이다.</p>
<p>따라서 <code>salary</code> 값만 저장하기 위한 임시 테이블이 생성되는데, 이때 유니크 인덱스가 걸리므로 특정 칼럼만이 아닌 다른 칼럼까지 고려된다면 레코드 건수가 많아질수록 디스크 I/O가 많아져 느려질 수 있기 때문이다.</p>
<blockquote>
<p>반면 예외적으로 <code>DISTINCT</code> 를 사용하는 쿼리에서는 임시 테이블을 사용한다는 메시지인 <code>Using temporary</code> 가 표시되지는 않으니 성능에 주의하자. </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/f0e2ea3f-1fe3-4191-b464-d4329b2eb969/image.png" alt=""></p>
<blockquote>
<p>그렇다면, 이제 최종적으로 언제 임시 테이블이 생성되는지 정리해보자. </p>
</blockquote>
<h3 id="☁️-임시-테이블이-필요한-쿼리">☁️ 임시 테이블이 필요한 쿼리</h3>
<p>해당 조건을 가진 경우는 유니크 인덱스를 가지는 내부 임시 테이블이 생성된다.</p>
<ol>
<li><code>ORDER BY</code> 와 <code>GROUP BY</code> 에 명시된 칼럼이 다른 경우</li>
<li><code>ORDER BY</code> 와 <code>GROUP BY</code> 에 명시된 칼럼이 조인의 순서상 첫 번재 테이블이 아닌 경우</li>
<li><code>DISTINCT</code> 와 <code>ORDER BY</code> 가 동시에 쿼리에 존재하는 경우 또는 DISTINCT가 인덱스로 처리되지 못하는 쿼리</li>
<li><code>UNION</code>이나 <code>UNION DISTINCT</code> 가 사용되는 쿼리</li>
</ol>
<p>그리고 만약 <code>GROUP BY</code> 나 <code>DISTINCT</code> 칼럼 크기가 <code>512 byte</code> 이상이라면, 임시 테이블이 디스크에 생성된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] MySQL 검색 쿼리 성능 개선기]]></title>
            <link>https://velog.io/@semi-cloud/DB-MySQL-%EA%B2%80%EC%83%89-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%EA%B8%B0</link>
            <guid>https://velog.io/@semi-cloud/DB-MySQL-%EA%B2%80%EC%83%89-%EC%BF%BC%EB%A6%AC-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0%EA%B8%B0</guid>
            <pubDate>Wed, 23 Aug 2023 10:29:53 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>60만건의 유실물 데이터를 내용으로 검색할 때, 프론트에서 가끔 응답 시간이 너무 오래 걸린다는 피드백을 받고 성능을 올려보기로 했다.</p>
</blockquote>
<h2 id="1-성능-하락-원인-파악">1. 성능 하락 원인 파악</h2>
<h3 id="기존-쿼리-성능">기존 쿼리 성능</h3>
<pre><code class="language-sql">select * from tb_lost_post
        where title like &#39;%1호선%&#39; or content like &#39;%1호선%&#39;
        order by created_at desc
        limit 10 offset 10000;  // 4.7</code></pre>
<p>MySQL 책을 읽기전에는, <code>limit</code> 과 <code>offset</code> 를 걸었지만 실행계획을 봤을 때 테이블 풀 스캔이 일어난 이유가 무엇인지 이해가 안됐었다.</p>
<p>우선, 60만건 기준으로 아래 방법으로 각 조건에 대해 측정해보자.</p>
<h4 id="측정하는-방법">측정하는 방법</h4>
<pre><code class="language-sql">SET PROFILING=1;
SET PROFILING_HISTORY_SIZE=30;
SHOW profiles;

EXPLAIN ANALYZE ...; # 2025.06 추가</code></pre>
<h3 id="1-아무런-조건이-없을-때">1. 아무런 조건이 없을 때</h3>
<pre><code class="language-sql">select * from tb_lost_post;</code></pre>
<p>특정 범위가 주어진 것도 아니기 때문에 클러스터링 인덱스로 조회되지 못하고, <strong>풀 테이블 스캔</strong>이 일어난다. 풀 테이블 스캔은 랜덤 <code>I/O</code> 가 아닌 순차 <code>I/O</code> 방식이므로, 디스크 헤더를 한번만 움직이고 쭉 읽으면 되므로 생각보다 많은 데이터에도 빠른 성능을 보여준다.</p>
<p>실제로 테스트해보면 <code>0.02s</code> 정도로 측정되었다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/9f09a3e0-55af-47a6-91d0-c86ddc10d61d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/c12b342b-76f2-44af-a138-923b75ce4e8c/image.png" alt=""></p>
<h4 id="🫧-풀-테이블-스캔이-일어나는-조건">🫧 풀 테이블 스캔이 일어나는 조건</h4>
<ol>
<li>테이블 레코드 건수가 너무 작아 페이지 <code>1</code> 개로 구성되는 경우, 인덱스를 통해 읽는 것보다 풀 테이블 스캔이 빠르다.</li>
<li><code>WHERE</code> 절이나 <code>ON</code> 절에 인덱스를 이용할 수 있는 적절한 조건이 없는 경우</li>
<li>인덱스 레인지 스캔을 사용할 수 있어도, 조건이 일치하는 레코드 건수가 전체 테이블의 20% 이상이라면 풀 테이블 스캔이 빠르다.</li>
</ol>
<p>따라서 위에서는 우선 <code>2</code> 번 조건에 의해 풀 테이블 스캔이 일어났을 것이다.</p>
<h3 id="2-아무런-조건이-없을-때---페이징-적용">2. 아무런 조건이 없을 때  + 페이징 적용</h3>
<p>다음으로 페이징을 적용해보자.</p>
<pre><code class="language-sql">select * from tb_lost_post
    limit 10 offset 0;</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/d8be350b-74d4-4b62-90e4-3083e52ddcb3/image.png" alt=""></p>
<blockquote>
<p>성능이 갑자기 엄청 빨라졌다. 왜 이런 현상이 발생한 것일까?</p>
</blockquote>
<p>MYSQL은 두 가지 방식으로 쿼리가 처리되는데, 스트리밍과 버퍼링 방식이다.</p>
<ol>
<li><code>스트리밍 처리 방식</code></li>
</ol>
<p>서버 쪽에서 처리할 데이터가 어느정도 되는지에 관계없이, 조건에 일치하는 레코드가 검색될 때 마다 바로 클라이언트로 전송하므로 매우 빠른 응답 시간을 보장한다. </p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/4d1dd021-de44-4214-af9b-ba8451d0a129/image.png" alt=""></p>
<p>주로 <strong>인덱스를 사용해 정렬</strong>을 할 때 스트리밍 방식으로 처리된다. <code>limit</code> 가 없어도 스캔의 결과가 아무런 버퍼링 처리나 필터링 과정 없이 바로 클라이언트로 전송되기에 빠르다.</p>
<p>하지만 여기에 <code>limit</code> 까지 붙이면 클라이언트에 바로바로 반환을 하기 때문에, 개수를 충족하는 순간 바로 동작을 멈추므로 매우 빨라진다.</p>
<ol start="2">
<li><code>버퍼링 처링 방식</code> </li>
</ol>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/32a92d5c-3d6e-46a1-b858-8a039d32eca7/image.png" alt=""></p>
<p><code>ORDER BY</code> 나 <code>GROUP BY</code> 조건이 있는데 <strong>해당 컬럼에 인덱스가 없는 경우</strong> 스트리밍 방식을 사용할 수 없다. </p>
<p>우선 <code>WHERE</code> 조건에 일치하는 레코드를 모두 가져온 이후 정렬하거나 그루핑하는 과정이 일어나야 하기 때문인데, 결국 클라이언트로 응답 속도가 매우 늦어진다. </p>
<blockquote>
<p>따라서 해당 방식은 <code>limit</code> 로 결과 건수를 제한해도, 네트워크로 전송되는 레코드의 건수만 줄일 뿐 MySQL 작업의 성능 향상에는 효과가 크지 않다.</p>
</blockquote>
<p><strong>결론적으로 앞에서는 정렬이나 그룹핑 조건이 없었고, 이로 인해 스트리밍 처리 방식이 적용되어서 속도가 엄청 빨라진것으로 볼 수 있다.</strong></p>
<h3 id="3-where-조건--페이징-적용">3. WHERE 조건 + 페이징 적용</h3>
<p>where 조건이 인덱스를 타기만 한다면, 위에서와 큰 차이는 없다.</p>
<pre><code class="language-sql">select * from tb_lost_post
    where lost_post_id &lt; 10000
    limit 10 offset 0;</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/eaf94d15-c8d7-4897-a05e-b234f184296c/image.png" alt=""></p>
<h3 id="4-정렬-조건이-붙을-경우">4. 정렬 조건이 붙을 경우</h3>
<p>그렇다면 실제로 이제 정렬 조건을 붙여보자. 현재 <code>created_at</code> 필드에는 인덱스를 생성하지 않았다.</p>
<pre><code class="language-sql">select * from tb_lost_post
    order by created_at desc;</code></pre>
<p>정렬 조건이 붙었더니, 시간이 <code>5</code> 초대로 매우 느려진 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/e43bb4c3-e46a-4053-b99b-057a329add3f/image.png" alt=""></p>
<pre><code class="language-sql">select * from tb_lost_post
    order by created_at desc
    limit 10 offset 0;</code></pre>
<p>앞에서 얘기했듯이, 혹시나 해서 페이징을 적용해도 <code>4</code> 초대로 큰 차이로 성능 개선 효과가 존재하지 않는다. </p>
<blockquote>
<p>🫧 <strong>정렬 조건과 페이징 적용</strong>
페이징을 적용해서 레코드 건수를 줄여도, 조건에 해당하는 모든 데이터를 대상으로 최신순으로 정렬이 일어나야 하기 때문에 버퍼링 방식이 선택된다.</p>
</blockquote>
<p>따라서 실행계획을 분석해보았더니, 처음 보는 <code>Filesort</code> 라는 방식이 사용되었다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/adb4fb2f-4d18-4814-a721-c1b0edfe339c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/3c5208a4-6f80-41ff-9c04-4f87e505f4d0/image.png" alt=""></p>
<blockquote>
<p>Q. <code>FileSort</code> 가 뭐지?</p>
</blockquote>
<p>MySQL에서 정렬을 처리하는 방법은 크게 두가지이다.</p>
<p><strong>1. 인덱스를 통해 정렬하는 방법</strong></p>
<p><code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 쿼리가 실행될 때 <strong>이미 인덱스가 정렬이 되어있으므로 별도의 작업이 필요 없이 순서대로 읽기만 하면 되서</strong> 성능이 매우 빠르다. 하지만 당연히 조회 말고 다른 변경 쿼리에 대해서는 느리고, 인덱스가 저장될 때 디스크 공간과 <code>InnoDB</code> 버퍼 풀을 위한 메모리가 많이 필요하다는 단점도 있다.</p>
<pre><code class="language-sql">select * from tb_lost_post
    order by lost_post_id desc
    limit 10 offset 0;</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/9cc958bb-e4a8-4506-aaa1-bf661d5782b0/image.png" alt=""></p>
<p>위와 같은 쿼리에 대해서는, <strong>클러스터링 인덱스에 대한 역방향 스캔</strong>이 정상적으로 적용된다. <a href="https://velog.io/@semi-cloud/MySQL-B-Tree-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EA%B5%AC%EC%A1%B0%EC%99%80-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%8A%A4%EC%BA%94">해당 포스팅</a>에서 말했듯이 인덱스는 정방향 스캔이 역방향 스캔보다 빠르다. 이런 경우, 아예 <code>내림차순 인덱스</code> 를 만드는게 나을 수도 있다.</p>
<p><strong>2. FileSort를 이용하는 방법</strong></p>
<p>인덱스를 이용하지 않고 <code>MySQL</code> 서버가 <strong>별도의 정렬 처리</strong>를 수행했다는 것을 나타낸다. 문제는 정렬을 수행하기 위해, <code>소트 버퍼(Sort buffer)</code> 라고 하는 별도의 메모리 공간을 할당받는다. </p>
<p>하지만 할당된 범위를 벗어날 만큼 많은 데이터가 들어오는 경우 <code>MySQL</code> 는 <strong>정렬 레코드를 여러 조각으로 나누고, 임시 저장을 위해 디스크 공간</strong>을 사용해버린다. 결국 디스크를 왔다갔다 하면서 정렬을 하기 때문에 수많은 디스크 <code>I/O</code> 가 발생하고, 마지막에 각 정렬된 레코드들을 다시 병합하면서 정렬이 완료되므로 성능이 매우 느려진다.</p>
<p>위 테스트 쿼리와 같은 경우 전체 데이터를 정렬해야 하므로 <code>external sort</code> 가 발생해 <code>5</code> 초라는 느린 성능이 나오게 되었다.</p>
<h3 id="5--형식의-like-검색-조건이-붙은-경우">5. <code>%%</code> 형식의 LIKE 검색 조건이 붙은 경우</h3>
<pre><code class="language-sql">select * from tb_lost_post
        where title like &#39;%1호선%&#39; or content like &#39;%1호선%&#39;
        order by created_at desc
        limit 10 offset 0;</code></pre>
<p><code>%char</code> 가 아닌 <code>%char%</code> 조건의 like가 포함되면 인덱스를 통해 범위를 줄이는게 불가능하고, 전체 데이터를 탐색해야 한다. 즉 (1)정렬 컬럼에 인덱스가 없어서 <code>where</code> 조건 필터링이 끝난 이후 데이터를 정렬해야 하는 <strong>버퍼링 방식</strong>으로 처리되며, (2) 심지어 <code>where</code> 조건이 <code>%%</code> 이기 때문에 <strong>전체 데이터를 탐색</strong>해야 한다. </p>
<p>(추가) 따라서 <code>explain anaylze</code> 를 통해 분석해보았을 때 다음과 같은 방식으로 동작하게 된다. 참고로 아래 테스트에서 인덱스를 생성해버려서 인덱스가 없는 다른 컬럼으로 테스트했다.</p>
<blockquote>
</blockquote>
<ol>
<li><strong>Full Table Scan</strong>으로 전체 데이터를 읽는다. (가장 많은 시간 소요)</li>
<li>그중 <strong>LIKE 조건을 만족하는 row</strong>를 찾고, 대상 row들을 직접 정렬하며 <strong>FileSort</strong>가 발생한다. (현재 상황에서는 sort buffer로 처리되었지만, 데이터가 많아 external sort가 발생하면 두번째로 많은 시간 소요)</li>
<li>이후 <strong>LIMIT로 10개를 잘라낸다.</strong></li>
</ol>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/2f10f83d-c1b7-4574-af45-51e68713961c/image.png" alt=""></p>
<blockquote>
<h4 id="그렇다면-어떻게-해결할-수-있을까">그렇다면 어떻게 해결할 수 있을까?</h4>
</blockquote>
<p>핵심은 현재 가장 병목이 되는 <code>Full Table Scan</code> 이 발생하지 않도록 해야한다.</p>
<ol>
<li><p>텍스트 검색 조건을 개선하여, LIKE 대신 <strong>전문 검색 인덱스(full-text index)</strong> 를 활용하면 텍스트 기반 필터링에서의 탐색 범위를 줄일 수 있다.</p>
</li>
<li><p>LIKE 조건을 유지하되, <strong>정렬 조건이 인덱스</strong>를 활용할 수 있도록 설계하면 <strong>스트리밍 처리로 성능</strong>을 개선할 수 있다.</p>
</li>
</ol>
<p>그럼 이제 두가지 방법을 모두 테스트해보고 최적의 결과를 도출해보자.</p>
<h2 id="2-해결을-위한-다양한-시도들">2. 해결을 위한 다양한 시도들</h2>
<h3 id="1-1-정렬-필드에-인덱스를-도입">1-1. 정렬 필드에 인덱스를 도입</h3>
<pre><code class="language-sql">explain select * from tb_lost_post
    where title like &#39;%1호선%&#39; or content like &#39;%1호선%&#39; # 4.7
    order by created_at desc
    limit 10 offset 10;  // offset 10 주의 </code></pre>
<pre><code class="language-sql">create INDEX idx ON tb_lost_post (created_at desc);</code></pre>
<p><code>createdAt</code> 필드에 <strong>내림차순 인덱스</strong>를 생성해보았다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/88f4274b-8ebf-4bd9-af91-47d592b5f811/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/968ce592-99fb-4abf-844c-653e18963932/image.png" alt=""></p>
<p><code>type=index</code> 인것을 보아 위 쿼리는 <code>like</code> 절로 인해서 인덱스 데이터를 풀로 스캔하며, 이후 mysql 서버에서 검색 조건에 대한 필터링이 일어난다. <strong>중요한 것은 이제 더이상 <code>filesort</code> 는 발생하지 않는다는 것!</strong></p>
<p>아래처럼 실제로 어떻게 동작했는지 확인해보면, <code>Index scan</code> 을 통해 탐색한 rows가 <code>533</code> 개 인것을 보아 인덱스 전체를 읽어나가면서 조건을 확인하며 10개가 되면 종료하는 <strong>스트리밍 방식</strong>으로 처리되고 있는 것을 볼 수 있다.</p>
<pre><code class="language-sql"># (추가) explain analyze 결과
-&gt; Limit/Offset: 10/10 row(s)  (cost=2.66 rows=0) (actual time=19.5..34.6 rows=10 loops=1)
    -&gt; Filter: ((tb_lost_post.title like &#39;%1호선%&#39;) or (tb_lost_post.content like &#39;%1호선%&#39;)) 
       (cost=2.66 rows=4.2) (actual time=7.16..34.6 rows=20 loops=1)
        -&gt; Index scan on tb_lost_post using created_at_desc_idx 
        (cost=2.66 rows=20) (actual time=1.47..33 rows=533 loops=1)</code></pre>
<ul>
<li><code>actual time</code> : 첫번째 행을 읽어오는데 들었던 시간의 평균(ms) ~ 모든 행을 읽어오는데 들었던 시간의 평균(ms)</li>
</ul>
<h4 id="🔗-offset-기반-페이지네이션-단점">🔗 offset 기반 페이지네이션 단점</h4>
<p>하지만 <strong>이러한 방식은 <code>offset</code> 이 뒤로 갈 수록 읽는 행의 개수가 증가되어 성능이 나빠진다.</strong> 쿼리를 다음과 같이 바꿔보면 총 읽은 행이 <code>30010</code> 개나 되며 <code>2</code> 초 정도로 느려지는 것을 볼 수 있다.</p>
<pre><code class="language-sql">explain select * from tb_lost_post
    where title like &#39;%1호선%&#39; or content like &#39;%1호선%&#39;
    order by created_at desc
    limit 10 offset 30000;  // offset 30000 주의</code></pre>
<blockquote>
<p>오프셋은 PK 가 아니고 말그대로 <strong>몇번째 레코드</strong>인지를 나타낸다. MySQL 입장에서는 해당 몇 번째 레코드인지 줘도 실제 위치를 모르니 다시 처음부터 읽은 행을 <strong>중복</strong>으로 읽게 된다. 따라서 <code>100000</code> 번째 데이터부터 시작해서 실제 가져올 값이 <code>10</code> 개라면, <code>1000010</code> 개의 모든 데이터를 읽고 앞에 <code>100000</code> 개는 버리는 비효율적인 방식으로 동작한다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/1b908254-d3b9-4a5a-a109-d8199978dd87/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/d8b11e67-9509-47c4-9cd7-1ec434740541/image.png" alt=""></p>
<p>문제는 현재 무한 스크롤 방식이기는 하지만 악의적인 사용자가 url의 offset을 맨 뒤에 있는 데이터로 변경해 요청을 한다면? </p>
<p>옵티마이저는 조건의 대상이 너무 많아지게 되면 더 이상 인덱스를 사용하는 것이 효율적이라고 생각하지 않고, 그냥 풀 테이블 스캔을 하고 정렬 방법으로 <code>FileSort</code> 을 선택해버리는 상황이 발생할 수 있다.</p>
<p>따라서 오프셋 기반이 아닌, <strong>커서 기반(no-offset) 페이지네이션</strong>을 고려해보았다.</p>
<h3 id="1-2-no-offset-페이지네이션-도입">1-2. no-offset 페이지네이션 도입</h3>
<p><code>No-Offset</code> 방식은 이미 읽은 행을 매번 중복으로 읽는 상황이 발생하지 않도록, 조회하려는 <strong>시작 부분을 인덱스로 빠르게 찾아서 매번 첫 페이지만 읽도록 하는 방식</strong>이다. </p>
<p>전체 레코드를 다시 읽는게 아니고, <code>B+ Tree</code> 를  읽기 때문에(클러스터, 넌클러스터 인덱스) 시작 지점을 바로 찾을 수 있는 것이다. <strong>따라서 조건에 인덱스를 걸지 않는다면 커서 기반 페이지네이션을 사용하지 않는거나 마찬가지가 된다.</strong></p>
<pre><code class="language-sql">select * from tb_lost_post
where created_at &lt; timestamp(&#39;2024-04-30 21:00:00&#39;) and
      (title like &#39;%1호선%&#39; or content like &#39;%1호선%&#39;)  # 조건문 집중
order by created_at desc
limit 10;</code></pre>
<p>실제 위 쿼리의 실행 결과는 아래와 같이 <code>40ms</code> 정도로 측정이 되며, 실행 계획을 보면 <code>Index full scan</code> 이 아니라 <code>range scan</code> 이 발생했다.</p>
<p> <img src="https://velog.velcdn.com/images/semi-cloud/post/951dc33f-9afd-4412-bdcd-0714d3ed2b14/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/1f4be891-8709-49fe-9c77-bc417fe5023c/image.png" alt=""></p>
<p>(2025.06 추가) 다시 테스트했을 때는 <code>200ms</code> 정도 소요된다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/44cd8b7a-2742-48ce-9a17-621ad968be42/image.png" alt=""></p>
<blockquote>
<p>하지만 이는 조건이 유니크한 칼럼일 때 이야기이고, 나와 같이 <strong>조건 칼럼이 중복</strong>될 가능성이 높다면? 우리는 <strong>조건에 해당하는 온전한 데이터를 받지 못할</strong> 가능성이 높다.</p>
</blockquote>
<h3 id="1-3-조건-칼럼이-unique-하지-않을-때">1-3. 조건 칼럼이 Unique 하지 않을 때</h3>
<p>아래와 같이 우선 최신순으로 정렬된 레코드에서 1호선이 포함된 레코드만 3개씩 가져오고 싶다고 하자.  마지막으로 받은 데이터의 날짜(<code>id = 1인 레코드</code> )가 <code>2023-03-01</code> 이므로, 다음 쿼리는 <code>where created_at &lt; timestamp(&#39;2023-03-01&#39;)</code> 형태로 나가게 된다.</p>
<pre><code class="language-java">id = 5 created_at = 2023.03.04   1호선  
id = 4 created_at = 2023.03.03   2호선   (X)
id = 2 created_at = 2023.03.02   1호선
id = 1 created_at = 2023.03.01   1호선   
id = 3 created_at = 2023.03.01   1호선
id = 6 created_at = 2023.02.29   1호선
id = 7 created_at = 2023.02.29   1호선
id = 7 created_at = 2023.02.29   1호선</code></pre>
<p>하지만 이러면 데이터베이스 입장에서는 <code>id=3</code> 인 데이터를 건너뛰고 <code>2023-02-29</code> 날짜를 가진 <code>id=6</code> 부터 3개를 가져오게 된다.</p>
<p>물론 예시에서는 중복을 보여주기 위해 저렇게 잡았고, <code>created_at</code> 를 밀리초까지 기록되게 하면 중복을 피할 수 있긴 하다. 하지만 지금 내 상황과 같이 <code>created_at</code> 가 이미 외부에서 시간이 정해진 데이터였고, 그렇기에 중복을 피할 수가 없었다.</p>
<p>따라서 이런 경우, <strong>해당 레코드를 함께 판별할 수 있는 다른 유니크한 기준이 있는지 확인</strong>해야 한다. </p>
<pre><code class="language-java">id = 5 unique_id = 1 created_at = 2023.03.04   1호선  
id = 4 unique_id = 2 created_at = 2023.03.03   2호선   (X)
id = 2 unique_id = 3 created_at = 2023.03.02   1호선
id = 1 unique_id = 4 created_at = 2023.03.01   1호선   
id = 3 unique_id = 5 created_at = 2023.03.01   1호선
id = 6 unique_id = 6 created_at = 2023.02.29   1호선
id = 7 unique_id = 7 created_at = 2023.02.29   1호선
id = 8 unique_id = 8 created_at = 2023.02.29   1호선</code></pre>
<p>pk인 <code>id</code> 나 <code>unique_id</code> 와 같이 또 다른 유니크 칼럼이 있다면, <code>조인</code> 을 통해 위와 같은 형태를 만들어 준 이후 아래 쿼리와 같이 조회해주면 된다. 여기서는 pk를 사용해보자.</p>
<pre><code class="language-sql">explain select * from tb_lost_post
where
    (title like &#39;%1호선%&#39; or content like &#39;%1호선%&#39;) and
    (
        received_date &lt; timestamp(&#39;2023-12-07 12:00:16&#39;) or
        (
            received_date = timestamp(&#39;2023-12-07 12:00:16&#39;) and
             lost_post_id &gt; 30000
        )
    )
order by received_date desc, lost_post_id
limit 10;</code></pre>
<p>첫 번째 조건으로 인해 이전에는 누락된 <code>id=3</code> 레코드를 조회할 수 있고, 두 번째 조건으로 인해 <code>2023-03-01</code> 이전의 데이터 들까지 성공적으로 가져올 수가 있어진다.</p>
<p>실제 쿼리를 수행해보면 아래처럼 <code>index range scan을</code>  통해 데이터를 가져오고 있는 것을 볼 수 있으며, <strong>최종적으로 <code>300ms</code> 정도로 개선</strong>이 된 것을 볼 수 있다. <em>(추가 : 2-2 섹션의 결과는 offset이 30000 기준으로 테스트된 결과가 아니라 비교 대상이 정확하지 않다고 판단했다)</em></p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/7a884e55-c020-4e4f-9eb2-cc48decf0722/image.png" alt=""></p>
<pre><code class="language-sql"># explain analyze 결과
-&gt; Limit: 10 row(s)  (cost=40457 rows=10) (actual time=14.8..293 rows=10 loops=1)
    -&gt; Filter: ((tb_lost_post.title like &#39;%1호선%&#39;) or (tb_lost_post.content like &#39;%1호선%&#39;))  (cost=40457 rows=21084) (actual time=14.8..293 rows=10 loops=1)
        -&gt; Index range scan on tb_lost_post using received_date_desc_idx over (received_date = &#39;2023-12-07 12:00:16&#39; AND 30000 &lt; lost_post_id) OR (&#39;2023-12-07 12:00:16&#39; &lt; received_date &lt; NULL), with index condition: ((tb_lost_post.received_date &lt; &lt;cache&gt;(cast(&#39;2023-12-07 12:00:16&#39; as datetime))) or ((tb_lost_post.received_date = &lt;cache&gt;(cast(&#39;2023-12-07 12:00:16&#39; as datetime))) and (tb_lost_post.lost_post_id &gt; 30000)))  (cost=40457 rows=100467) (actual time=3.73..290 rows=213 loops=1)</code></pre>
<p>반대로 오프셋 기반 페이지네이션을 다시 테스트 해본다면? 오프셋이 앞번호면 인덱스를 잘 타지만 뒤로 가면 <code>filesort</code> 가 일어나게 된다.</p>
<pre><code class="language-sql">explain select * from tb_lost_post
order by received_date desc, lost_post_id
limit 10 offset 30000;    // 똑같이 30000부터 탐색하도록 설정</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/eaf9402c-f15e-48f3-96f7-711904717b33/image.png" alt=""></p>
<h4 id="내림차순-인덱스-방식의-단점">내림차순 인덱스 방식의 단점</h4>
<blockquote>
<p><em>(2025.08 수정)</em></p>
</blockquote>
<p>하지만 내림차순 인덱스를 활용하는 방식의 가장 치명적인 단점은, <strong>성능이 고정적이지 않다는 것이다.</strong> <code>index range scan</code> 을 통해 찾고자 하는 데이터가 빠르게 <code>limit</code> 개수만큼 나오면 속도가 빠르지만, 그렇지 않다면 결국 인덱스를 통해 읽는 데이터가 너무 많아지면서 <code>full table scan</code> 이 발생하게 된다.</p>
<p>따라서 이제 다음 어느정도 고정적인 성능을 보장해주는 다음 대안인 전문 검색 인덱스에 대해 알아보자.</p>
<h3 id="2-full-text-search-도입전문-검색-인덱스">2. Full-Text Search 도입(전문 검색 인덱스)</h3>
<h4 id="저장-방식">저장 방식</h4>
<p>전문 검색 인덱스란 <strong>역색인 방식</strong>의 인덱스로, 그 중에서 <code>n-gram</code> 알고리즘은 본문을 n개의 글자로 잘라서(문자 조각 단위) 인덱싱하는 방법이다. 예를 들어서 에어팟이라면 <code>에어</code>와 <code>어팟</code> 2개의 토큰으로 나눠서 저장이 되기 때문에 마치 부분검색인 <code>%keyword%</code> 와 같은 효과를 낼 수 있고, 특정 키워드를 포함한 레코드를 빠르게 찾을 수 있다.</p>
<ol>
<li><code>stop-word</code> : 단어(공백) 단위로 토큰화, 단어가 무조건 일치해야 확인 가능</li>
<li><code>n-gram</code> : 문자 개수 단위로 토큰화, 부분 검색도 가능
<code>에어</code> / <code>어컨</code>, <code>에어</code> / <code>어팟</code>으로 토큰이 생성되면 <code>에어팟</code>으로 검색 시 <code>에어</code>와 <code>어팟</code>을 모두 만족하는 레코드 반환하기에 정확도가 높다.</li>
</ol>
<h4 id="검색-방식">검색 방식</h4>
<p>검색 방식에는 아래와 같이 두가지가 존재한다.</p>
<ol>
<li><p><code>natural language search</code>
단어 단위로 분리한 후 해당 단어가 하나라도 포함되는 레코드가 있다면 반환한다. 에어, 에어팟으로 검색했을 때 에어팟 레코드가 추출된다.</p>
</li>
<li><p><code>boolean search</code>
특정한 규칙을 가지고 단어가 포함되는 레코드를 찾는 방식으로, 예를 들어
&#39;+A -B&#39; 라면 A가 포함되는 것은 찾되 B가 포함된 레코드는 제외할 수 있다. 
단순히 &#39;에어팟&#39; 이라고 검색하면 &#39;+에어 +어팟&#39;와 같이 분리되어 검색된다.</p>
</li>
</ol>
<p>당연히 검색의 질(정확도)은 <code>boolean search</code> 가 더 좋지만, 간단한 검색 기능은 <code>natural search</code> 방식도 품질 저하가 심하지 않으며 속도가 <code>natural search</code>이 훨씬 빠르기 때문에 NL 방식을 선택했다.</p>
<h4 id="인덱스-생성-방법-및-테스트">인덱스 생성 방법 및 테스트</h4>
<pre><code class="language-sql">//  인덱스 생성
CREATE FULLTEXT INDEX ft_index ON tb_lost_post(title, content) WITH PARSER ngram;</code></pre>
<pre><code class="language-sql">explain analyze select * from tb_lost_post
where MATCH(title, content) AGAINST (&#39;1호선&#39; IN NATURAL LANGUAGE MODE)
order by created_at desc
limit 10 offset 30000;</code></pre>
<p>아래 테스트 결과를 보면 대략 <strong><code>0.8</code></strong> 초 정도의 속도로 개선이 된 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/acaf0d07-3318-4791-b6b8-0aaea3f5964c/image.png" alt=""></p>
<pre><code class="language-sql"># explain analyze 결과
-&gt; Limit/Offset: 10/30000 row(s)  (cost=1.1 rows=0) (actual time=821..821 rows=10 loops=1)
    -&gt; Sort row IDs: tb_lost_post.created_at DESC, limit input to 30010 row(s) per chunk  (cost=1.1 rows=1) (actual time=744..819 rows=30010 loops=1)
        -&gt; Filter: (match tb_lost_post.title,tb_lost_post.content against (&#39;1호선&#39;))  (cost=1.1 rows=1) (actual time=63.4..670 rows=146553 loops=1)
            -&gt; Full-text index search on tb_lost_post using ft_index (title=&#39;1호선&#39;)  (cost=1.1 rows=1) (actual time=62.6..657 rows=146553 loops=1)
</code></pre>
<ul>
<li>Full-text index search : FTS 인덱스를 통해 토큰 단위의 초기 후보 row들을 찾음</li>
<li>Filter : 최종 매칭 여부는 별도 필터 단계에서 다시 수행</li>
<li>Sort : 인덱스를 타지 않고 MySQL 서버에서 직접 정렬 수행</li>
</ul>
<p>물론 주의할 점은 MySQL에서 보통 하나의 쿼리에 대해 하나의 인덱스만 사용하며, <code>Index Merge로</code>  여러 인덱스를 사용하는 경우도 있지만 전문 검색 인덱스는 이 경우에 해당되지 않기 때문에 <strong>FTX를 사용하면 다른 인덱스를 사용할 수 없다</strong>. </p>
<p>따라서 FTX 결과 대상 개수가 너무 많다면 <strong>MySQL 서버에서 직접 필터링 &amp; 정렬</strong>할 때 <code>external sort(외부 정렬)</code> 로 인해 성능이 나빠질 수 있지만, <code>sort_buffer_size</code> 를 늘릴 수 있고 10만건 정도도 200ms 정도로 처리되는 것을 보면 생각보다 서버에서 처리하는 비용이 크지 않기 때문에 감안하고 갈 수 있어 보인다.</p>
<p>결론적으로 1)근본적인 원인이였던 풀 테이블 스캔이 발생하지 않는다는 점과 2)정렬과 필터링도 데이터가 db 서버에서 처리하는게 큰 오버헤드가 아니라는 점을 근거로 해당 방식을 통해 최적화하는 것으로 결정했다. 하지만 전문 검색 인덱스 역시 아래와 같은 치명적인 단점이 존재하기는 한다.</p>
<h4 id="전문-검색-인덱스-단점">전문 검색 인덱스 단점</h4>
<p> %like%와 마찬가지로 의미 없는 단어 또는 불완전한 형태가 인덱싱되어 <strong>검색 품질이 저하</strong>될 수 있다. 예를 들어 &#39;테스트입니다&#39;가 &#39;트입&#39;처럼 쪼개지기 때문에 의미 없는 단어까지 검색에 포함돼 정확도가 떨어질 수 있는데, 이런 문제를 해결해주는게 바로 <code>ElasticSearch</code> 이다.</p>
<p><code>Elasticsearch</code> 는 단어의 의미 단위(형태소)로 분리하는 형태소 분석기(Tokenizer + Analyzer) 를 사용하며, <code>테스트입니다</code>가 <code>[&quot;테스트&quot;, &quot;이다&quot;]</code> 와 같은 의미가 있는 단어들로 쪼개지기 때문에 훨씬 검색 품질이 좋다.</p>
<p>다만 현재 데이터 기준으로는 FTX 인덱스로도 충분히 개선이 되었고, <code>Elasticsearch</code> 는 학습해야 할 양이 좀 있기 때문에 다음번에 도입해보는것으로 하자!</p>
<blockquote>
<p>☁️ 참고 자료
RealMySQL 1편
<a href="https://jojoldu.tistory.com/529?category=637935">https://jojoldu.tistory.com/529?category=637935</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] 클러스터링 인덱스와 유니크 인덱스에 대하여]]></title>
            <link>https://velog.io/@semi-cloud/MySQL-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0%EB%A7%81-%EC%9D%B8%EB%8D%B1%EC%8A%A4</link>
            <guid>https://velog.io/@semi-cloud/MySQL-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0%EB%A7%81-%EC%9D%B8%EB%8D%B1%EC%8A%A4</guid>
            <pubDate>Wed, 16 Aug 2023 12:14:10 GMT</pubDate>
            <description><![CDATA[<h2 id="클러스터링-인덱스란">클러스터링 인덱스란?</h2>
<blockquote>
<p>🫧 <strong>클러스터링이란?</strong>
서로 연관이 있는 데이터들을 하나로 묶는 것을 의미한다. </p>
</blockquote>
<p>따라서 클러스터링 인덱스란, 테이블의 레코드를 프라이머리키가 비슷한 것 끼리 묶는 것을 의미한다. 비슷한 시점에 생성된 데이터들을 동시에 조회하는 경우가 많다는 점에서 착안되었다. </p>
<p>중요한 것은 <code>InnoDB</code> 에서는 <strong>프라이머리 키에 의해, 삽입 시의 레코드 위치가 결정</strong>된다는 것이다. 반면, <code>MyISAM</code> 이나 메모리 스토리지에서는 클러스터링 테이블이 아니기 때문에 처음 삽입될 때 저장된 공간에 고정된다.</p>
<p>아래 그림에서 볼 수 있듯이, <code>PK</code> 값으로 인덱스가 걸려있으며 리프 노드에 실제 레코드의 모든 칼럼(테이블 자체)이 저장되어 있는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/4993f745-bb29-4043-97df-ac5556208e9a/image.png" alt=""></p>
<h3 id="☁️-세컨더리-인덱스에-미치는-영향">☁️ 세컨더리 인덱스에 미치는 영향</h3>
<pre><code class="language-sql">SELECT * FROM employees WHERE first_name=&#39;Aamer&#39;;</code></pre>
<ol>
<li><code>MyISAM</code> : <code>ix_firstname</code> 인덱스를 검색해서 레코드의 주소를 확인한 후, 해당 주소를 이용해 최종 레코드를 가져온다.</li>
<li><code>InnoDB</code> : <code>ix_firstname</code> 인덱스를 검색해 레코드의 PK를 확인한 후, 프라이머리 키 인덱스를 검색해서 최종 레코드를 가져온다. </li>
</ol>
<h3 id="☁️-클러스터링-인덱스-장점">☁️ 클러스터링 인덱스 장점</h3>
<h4 id="1-빠른-조회-성능">1. 빠른 조회 성능</h4>
<p>이러한 구조 덕분에, PK 기반 <strong>다량의 데이터 범위 검색에 매우 뛰어난 조회 성능</strong>을 가진다. </p>
<p>사실 PK를 기반으로 조회하는 경우가 굉장히 많기 때문에, 당연히 PK 기준으로 원본 데이터 자체를 정렬해서 저장하면 풀 테이블 스캔 보다 검색도 빠르고 공간 지역성을 적용 받아(PK 인접한 데이터는 사용될 확률이 높음) 효율성이 증가할 것이다.</p>
<h4 id="2-커버링-인덱스">2. 커버링 인덱스</h4>
<p>모든 세컨더리 인덱스가 PK를 가지고 있기 때문에, 커버링 인덱스와 같이 테이블에 접근하지 않고 인덱스로만 처리될 수가 있어 효율적이다.</p>
<h3 id="☁️-클러스터링-인덱스-단점">☁️ 클러스터링 인덱스 단점</h3>
<h4 id="1-레코드-위치-변경-시-오버헤드">1. 레코드 위치 변경 시 오버헤드</h4>
<p><code>PK</code> 값이 변경되는 경우, 바뀐 값에 알맞는 위치에 새롭게 추가하고 기존 레코드를 삭제하는 작업이 필요하여 처리 성능이 느리다. 물론, 추가 자체도 위치 결정을 위해 클러스터 인덱스를 한번 더 검색하는 과정이 포함되므로 더 느려진다. </p>
<p><code>InnoDB</code> 를 제외한 테이블의 데이터 레코드는, <code>PK</code> 값이나 인덱스 키 값이 변경될지라도 실제 레코드의 위치까지 변경되지는 않는다. 참고로 이렇게 레코드가 저장된 주소를 <code>ROW_ID</code> 라고 한다. 그리고 PK나 인덱스의 각 키는, 그 주소를 이용해 실제 데이터의 레코드를 찾아온다.</p>
<h4 id="2-클러스터링-키-크기에-비례하여-세컨더리-인덱스-크기도-커진다">2. 클러스터링 키 크기에 비례하여 세컨더리 인덱스 크기도 커진다.</h4>
<p>모든 세컨더리 인덱스가 PK를 가지고 있으므로 비례하여 커진다.</p>
<h3 id="☁️-innodb에서의-주의사항">☁️ InnoDB에서의 주의사항</h3>
<h4 id="1-pk-는-꼭-명시하자">1. <code>PK</code> 는 꼭 명시하자!</h4>
<blockquote>
<p>🫧 만약 <code>PK</code> 가 없는 테이블은 어떻게 클러스터링 테이블로 구성되는가?</p>
</blockquote>
<p><code>PK</code> 가 없다면, <code>InnoDB</code> 스토리지 엔진이 다음의 우선순위대로 PK를 대체할 칼럼을 선택한다.</p>
<ol>
<li><code>PK</code> 가 있으면 클러스터링 키로 택</li>
<li><code>NOT NULL</code> 옵션의 유니크 인덱스 중 첫번째 인덱스를 택</li>
<li>자동으로 유니크한 값을 가지도록 증가되는 칼럼을 내부적으로 추가한 후 택</li>
</ol>
<p>하지만 3번에서 자동으로 생성된 일련번호 칼럼은 사용자에게 노출되지 않기 때문에, 실질적으로 아무런 소용이 없으므로 반드시 <code>PK</code> 를 명시해 <code>InnoDB</code> 만의 수혜를 얻도록 하는 것이 좋다.</p>
<h4 id="2-auto-increment-칼럼을-식별자로-사용할-경우">2. AUTO-INCREMENT 칼럼을 식별자로 사용할 경우</h4>
<p>만약 PK로 지정하려는 칼럼의 크기가 매우 큰데, 세컨더리 인덱스가 필요한 상황이라면 <code>AUTO_INCREMENT</code> 칼럼을 추가하고 이를 PK로 설정하는 것이 좋다. </p>
<p>INSERT 위주의 테이블들은, 해당 인조 식별자를 프라이머리 키로 설정하는 것이 성능 향상에 도움이 되기 때문이다.</p>
<h2 id="유니크-인덱스란">유니크 인덱스란?</h2>
<p>유니크 인덱스는,** 테이블이나 인덱스에 같은 값이 중복으로 저장될 수 없음을 의미하는 일종의 제약 조건**을 가진 일반 인덱스이다. MySQL에서는 인덱스 없이, 유니크 제약만 설정할 방법이 없다. </p>
<blockquote>
<p>🫧 Q. 프라이머리 키와 유니크 인덱스가 같은거 아닌가요?</p>
</blockquote>
<p><code>MyISAM</code> 이나 <code>MEMORY</code> 테이블에서 <code>PK</code> 는 NULL이 허용되지 않은 유니크 인덱스와 같지만, <code>InnoDB</code> 기준으로는 맞는 설명이 아니다.</p>
<ol>
<li><code>Primary Key</code> : 클러스터링 키의 역할을 하며, MySQL에서는 PK가 생성될 때 자동으로 <code>NULL</code>를 허용하지 않는 유니크 속성이 부여된다. </li>
<li><code>유니크 인덱스</code> : <code>NULL</code>이 중복으로 저장될 수 있다.</li>
</ol>
<h3 id="☁️-세컨더리-인덱스와의-성능-비교">☁️ 세컨더리 인덱스와의 성능 비교</h3>
<h4 id="인덱스-조회">인덱스 조회</h4>
<p>많은 사람들이 유니크 인덱스는 중복이 <code>0</code> 이므로 한번만 읽으면 되지만, 세컨더리 인덱스는 여러 칼럼을 읽어야 하는 경우가 많으므로 조회 성능이 더 느리다고 한다. </p>
<p>하지만, 단순히 읽어야 하는 칼럼이 많은 것인지 인덱스 자체의 특성 때문에 조회 성능이 느려지는 것은 아니므로 <strong>유니크와 세컨더리 인덱스 모두 큰 차이가 없다.</strong></p>
<h4 id="인덱스-쓰기">인덱스 쓰기</h4>
<p>중요하게 봐야 할 것은, 조회가 아닌 인덱스 쓰기 성능이다.</p>
<p><strong>인덱스 쓰기는 유니크 인덱스</strong>가 세컨더리 인덱스보다 다음과 같은 이유로 <strong>월등히 느리다.</strong></p>
<ol>
<li>삽입 시 중복된 값이 있는지 없는지 확인하는 과정에서 <strong>읽기 잠금</strong>을 사용한다.</li>
<li>중복이 없다면, 데이터를 삽입하는데 이 때 <strong>쓰기 잠금</strong>을 사용한다.</li>
<li>기본적으로 <strong>인덱스 쓰기 작업</strong>은 메모리인 체인지 버퍼에서 버퍼링이 되어 모아놓았다가 한꺼번에 처리되도록 하는데(비동기), <strong>중복 체크 때문에 버퍼링되지 못한다.</strong> 실제 인덱스 페이지를 반드시 읽고 검사해야 하므로 디스크에서 페이지를 로드해야 하기 때문이다.</li>
</ol>
<blockquote>
<p>같은 칼럼에 대해 유니크 인덱스와 세컨더리 인덱스를 동일하게 생성하거나, 유니크 인덱스와 PK를 동일하게 생성하는 것은 불필요한 중복이므로 주의하자!</p>
</blockquote>
<h2 id="외래키">외래키</h2>
<p>MySQL에서 외래키는, <code>InnoDB</code> 스토리지 엔진만 생성할 수 있으며 <strong>한번 외래키 제약이 설정되면</strong>, 자동으로 테이블의 칼럼에 <strong>인덱스</strong>까지 생성된다.</p>
<p>하지만 외래키는 다음과 같은 특성 때문에 <strong>잠금 경합을 발생시켜, 쿼리 처리의 성능 저하</strong>를 일으킬 수 있다는 문제점이 있다.</p>
<ol>
<li>테이블 변경(쓰기 잠금)이 발생하는 경우에만, 잠금 대기가 발생한다.</li>
<li>외래키와 연관되지 않은 칼럼의 변경은, 최대한 잠금 대기를 발생시키지 않는다.</li>
</ol>
<p>그렇다면 실제 테스트를 해보자.</p>
<h3 id="☁️-자식-테이블의-변경이-대기하는-경우">☁️ 자식 테이블의 변경이 대기하는 경우</h3>
<p>세션을 두개를 만들고, 한쪽은 부모 테이블에서 id가 <code>2</code> 인 레코드에 업데이트를 실행한다. 그러면 해당 레코드에 쓰기 잠금이 걸리고, 다른 세션의 자식 테이블에서 <code>FK</code>로 설정해놓은 부모 id가 <code>2</code> 인 레코드에 접근하려 하는 상황이다.</p>
<ol>
<li><p>부모 테이블 레코드 변경
<img src="https://velog.velcdn.com/images/semi-cloud/post/f459131a-0f35-48da-90fe-685858b8d6d7/image.png" alt=""></p>
</li>
<li><p>커밋하지 않은 상태에서 자식 테이블에서 FK 값을 변경 
<img src="https://velog.velcdn.com/images/semi-cloud/post/414934be-e1be-43d8-87ee-2d39a61ca3d5/image.png" alt=""></p>
</li>
</ol>
<p>이렇게 되면 자식 테이블의 외래 키 컬럼의 변경은 부모 테이블 확인이 필요한데, 해당 레코드에 쓰기 잠금이 걸려있는 상태이므로 해제될 때까지 기다려야 하는 상황이 발생한다.</p>
<blockquote>
<p>🫧 <strong>부모 테이블 확인이 필요한 이유? **
물리적으로 외래키를 생성하면, 자식 테이블에 레코드가 추가되는 경우 해당 참조키가 실제 부모 테이블에 존재하는지 확인해야 한다. 하지만 이러한 **체크 작업에는 읽기 잠금</strong>을 걸어야 한다.</p>
</blockquote>
<p>이후 부모 테이블에서 Rollback을 하거나 커밋을 하는 경우, 대기가 풀리면서 2번째 세션에서 업데이트에 성공한다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/c12258be-e23c-40d5-b304-12ce018ebf05/image.png" alt=""></p>
<h3 id="☁️-부모-테이블이-대기하는-경우">☁️ 부모 테이블이 대기하는 경우</h3>
<p>이 역시 반대로 만약 자식 테이블의 레코드를 변경하고자 해서 쓰기 잠금을 걸었을 때, 해당 참조키를 PK로 가지는 부모 레코드를 삭제하려는 경우 쓰기 잠금이 해제될 때까지 기다려야 하는 상황이 발생한다. </p>
<p><code>ON DELETE CASCADE</code> 특성 때문에, <strong>부모 레코드가 삭제되면 자동으로 자식 레코드도 삭제</strong>되는 방식으로 동작하기 때문에 변경 작업이 끝나기를 기다려야 하는 것이다.</p>
<blockquote>
<p>중요한 것은, 참조키 존재 여부 확인을 위해 읽기 잠금을 걸게 되고, 이러한 잠금이 여러 테이블로 확장되면 쿼리의 동시 처리 성능에 영향을 미치게 된다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] B-Tree 인덱스 구조와 인덱스 스캔 ]]></title>
            <link>https://velog.io/@semi-cloud/MySQL-B-Tree-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EA%B5%AC%EC%A1%B0%EC%99%80-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%8A%A4%EC%BA%94</link>
            <guid>https://velog.io/@semi-cloud/MySQL-B-Tree-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EA%B5%AC%EC%A1%B0%EC%99%80-%EC%9D%B8%EB%8D%B1%EC%8A%A4-%EC%8A%A4%EC%BA%94</guid>
            <pubDate>Tue, 08 Aug 2023 14:26:42 GMT</pubDate>
            <description><![CDATA[<h2 id="인덱스란">인덱스란?</h2>
<h3 id="☁️-디스크-읽기-방식">☁️ 디스크 읽기 방식</h3>
<p>CPU와 메모리는 전기적 특성을 띄고 있는 장치이기 때문에, 속도가 빠르다.
하지만 이에 반해 디스크와 같은 <strong>기계적 장치</strong>는 여전히 느리므로, 데이터베이스 성능 튜닝은 디스크 I/O를 얼마나 줄이느가 관건이라 할 수 있다.</p>
<h4 id="hdd-vs-ssd">HDD VS SSD</h4>
<p>하드 디스크 드라이브는 기계식 장치이므로, 이를 대체하기 위해 나온 것이 전자식 저장 매체인 SSD(Solid State Drive)이다. SSD는 HDD에서 데이터 저장용 플래터인 원판을 제거하고, 플래시 메모리를 장착하고 있다.</p>
<blockquote>
</blockquote>
<p>🔖 <strong>플래시 메모리란?</strong>
전기적으로 데이터를 지우고, 다시 기록할 수 있는 <strong>비휘발성 컴퓨터 기억 장치</strong></p>
<p>플래시 메모리는 전원이 공급되지 않아도 데이터가 삭제되지 않는다. 여전히 속도는 메모리(D-Ram) 보다는 느리지만, 기계식 HDD 보다는 훨씬 빠르다(1000배 정도)는 장점을 가진다.</p>
<h3 id="☁️-랜덤-io-vs-순차-io">☁️ 랜덤 I/O VS 순차 I/O</h3>
<p>사실 데이터를 읽는 속도는, 데이터의 <strong>위치를 찾고 접근</strong>하기까지의 시간에 비례한다. 즉, 디스크의 성능은 <strong>디스크 헤더의 위치 이동 없이 한번에 얼마나 많은 데이터를 읽고 기록하느냐</strong>에 결정되는 것이다. 이는 원판이 없는 SSD에서도 마찬가지이다.</p>
<p>만약 3개의 페이지를 기록하는 상황에서 각각 어떻게 작동하는지 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/fc325f4e-e426-497d-84bb-b68588699e01/image.png" alt=""></p>
<h4 id="1-랜덤-io">1. 랜덤 I/O</h4>
<p><code>랜덤 I/O</code> 에서는, <strong>3개의 페이지 각각에 대해 시스템 콜</strong>을 요청한다. 따라서 디스크 헤더가 총 3번이나 움직이게 되어 속도가 매우 느리다.</p>
<p><strong>인덱스 레인지 스캔</strong>이 주로 데이터를 읽기 위해 랜덤 I/O를 사용한다.</p>
<h4 id="2-순차-io">2. 순차 I/O</h4>
<p><code>순차 I/O</code> 에서는, <strong>3개의 페이지를 하나의 시스템 콜 요청</strong>으로 처리한다. 따라서 디스크 헤더가 한 번만 움직여서 속도가 빠르다. </p>
<p><strong>풀 테이블 스캔</strong>이 주로 데이터를 읽기 위해 순차 I/O를 사용한다. 따라서 큰 테이블의 레코드 대부분을 읽는 작업에서는, 인덱스를 사용하지 않고 풀 테이블 스캔을 사용하도록 최적화 될 때도 있다. (주로 서비스 트랜잭션 보다는 데이터 웨어하우스나 통계 작업에서 사용된다)</p>
<p>데이터베이스 대부분의 작업은, 이러한 작은 데이터를 빈번히 읽고 쓰기 때문에 MySQL 서버에는 그룹 커밋, 바이너리 로그 버퍼, InnoDB 로그 버퍼 등의 기능이 내장되어 있다.</p>
<blockquote>
<p>쿼리를 튜닝한다는 것은, 이러한 <strong>랜덤 I/O 발생 자체를 줄이는 것</strong>에 목적이 있다.
즉, 쿼리 요청에 대해 <strong>꼭 필요한 데이터만 읽도록</strong> 개선하는 것이다. (불필요한 데이터를 읽는 것을 막으면 데이터 양이 줄어들고, 그만큼 DB I/O 요청도 감소한다.)</p>
</blockquote>
<h3 id="☁️-인덱스란">☁️ 인덱스란?</h3>
<p>인덱스란, 테이블의 모든 데이터를 검색하지 않고 빠르게 조회하기 위한 별도의 자료구조를 의미한다. 어떤 방식으로 구현되어 있길래 빠르게 조회할 수 있을까?</p>
<ol>
<li><strong>테이블 칼럼의 값과 해당 레코드가 저장된 주소</strong>가 <code>key</code> -<code>value</code> 형태로 담겨있으므로 바로 접근할 수 있다.</li>
<li>인덱스는 자바의 <code>SortedList</code> 와 같이 <strong>항상 정렬된 상태</strong>로 유지되므로 검색 속도가 빠르다. 하지만 새로운 인덱스 추가, 수정, 삭제 작업에 대해서는 오버헤드가 발생한다.</li>
</ol>
<p>앞서서 말했듯이 <code>InnoDB</code> 스토리지 엔진에서는 레코드 잠금이나 넥스트 키락(갭락)이 검색을 수행한 <strong>인덱스를 잠근 후</strong>, 테이블의 레코드를 잠그는 방식으로 구현되어 있다. 따라서 <code>UPDATE</code> 와 <code>DELETE</code> 에서 사용할 수 있는 인덱스가 없다면 최악의 경우 테이블의 모든 레코드가 잠금이 걸리기 때문에 특히나 인덱스를 잘 설계하는 것이 매우 중요하다.</p>
<h4 id="인덱스-종류">인덱스 종류</h4>
<p>인덱스는 역할별로 크게 두가지로 구분할 수 있다. </p>
<ol>
<li><p><strong>프라이머리 키</strong>
레코드를 대표하는 칼럼의 값으로 만들어진 인덱스이다. 식별자라고도 하며, <code>NULL</code> 과 중복을 허용하지 않는다.</p>
</li>
<li><p><strong>세컨더리 인덱스</strong>
프라이머리 키를 제외한 나머지 모든 인덱스를 의미한다. </p>
</li>
</ol>
<p>인덱스를 검색하는 작업은, <code>B-Tree</code> 루트 노드부터 시작해 최종 리프 노드까지 이동하면서 작업을 수행하는데 <code>SELECT</code> 뿐만 아니라 <code>UPDATE</code>나 <code>DELETE</code> 를 처리하기 위해 해당 레코드를 먼저 검색해야 할 경우에도 사용된다.</p>
<h4 id="인덱스-구조">인덱스 구조</h4>
<p>인덱스 자료구조는, <code>B-Tree</code> 알고리즘이나 <code>Hash</code> 인덱스 알고리즘으로 구성되어 있다. <code>B-Tree</code> 인덱스는 칼럼의 값을 변형하지 않고 원본을 이용해 인덱싱 하며, <code>Hash</code> 인덱스는 칼럼의 값으로 해시값을 계산해서 인덱싱한다. </p>
<p>속도 측면에서는 당연히 트리 탐색 시간이 소요되지 않는 <code>Hash</code> 가 빠르지만, 값이 변형되는 단점으로 인해 <code>100%</code> 동등 일치가 아닌 <code>prefix</code> 일치나, 값의 일부만 검색하거나, 범위를 검색할 때에는 사용할 수 없다. </p>
<p>따라서 가장 범용적으로 사용되는 알고리즘은 <code>B-Tree</code> 이다.</p>
<h3 id="☁️-b-tree-인덱스">☁️ B-Tree 인덱스</h3>
<p>가장 범용적으로 사용되는 인덱스 알고리즘으로, <code>B-Tree</code>와 <code>B+Tree</code> 두 가지 종류가 있다. 주의할 점은, <code>B</code> 가 바이너리의 약자가 아닌 <code>Balanced</code> , 즉 균형 트리의 약자라는 것이다.</p>
<p>자식 노드의 개수가 이진 트리처럼 2개로 고정되어 있지 않고, 가변적이다.</p>
<h4 id="🫧-balanced-tree란">🫧 Balanced Tree란?</h4>
<p>기본 이진 탐색 트리는 데이터 양이 많아지면 아래와 같이 한 쪽으로 편향이 되어, 최악의 경우 시간 복잡도가 <code>O(N)</code> 이 될 수도 있다는 단점을 가진다.</p>
<p>이러한 단점을 극복하고자 나온 것이 <code>균형 이진 탐색 트리</code> 로, 최악의 경우에도 항상 <code>O(logN)</code> 의 성능을 보장한다. <strong>노드의 삽입과 삭제가 일어나는 경우에, 자동으로 높이를 작게 유지하려고 조정</strong>하는 과정이 일어나기 때문이다.</p>
<p>균형 이진 탐색트리의 종류로는 <code>AVL</code> 트리 , <code>레드-블랙트리(Red-Black Tree)</code> , <code>B 트리</code>, <code>B+ 트리</code>, <code>B* 트리</code> 등이 존재한다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/a75c524c-c462-4f0b-a162-599442490918/image.png" alt=""></p>
<h4 id="b-tree-구조">B-Tree 구조</h4>
<p>최상위에 루트 노드가 존재하고, 하위에 자식 노드가 붙어있다. 
가장 하위 노드를 리프 노드라고 하며, <strong>리프 노드에는 실제 데이터 레코드를 찾아가기 위한 주소값</strong>을 가지고 있다다.</p>
<p>아래에서 볼 수 있듯이 <strong>인덱스 데이터와 실제 데이터 파일은 다른 영역에서 관리</strong>된다. 단, <strong>프라이머리 키 인덱스는 같은 데이터 파일</strong>에 존재한다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/f22adbdb-a8a3-4aa3-830d-2e4da80dbbcc/image.png" alt=""></p>
<p>주의할점은 인덱스 키 값은 항상 정렬된 상태로 유지되지만, <strong>데이터 파일 레코드는 순서대로 저장이 되지 않는다.</strong> 만약 순서대로 삽입되다가, 레코드가 삭제되어 빈 공간이 생기면 그다음 삽입은 삭제된 공간을 재활용하기 때문에 뒤죽박죽 될 수 있기 때문이다.</p>
<blockquote>
<p>참고로 <code>InnoDB</code> 는 클러스터되어 디스크에 저장되는 것이 디폴트이기 때문에 기본적으로 <code>PK</code> 순서로 정렬되어 저장된다.</p>
</blockquote>
<p>위의 그림에서 레코드 주소는 DBMS 종류나 MySQL의 스토리지 엔진에 따라 의미가 달라진다. <code>MyISAM</code> 에서는 리프 노드에서 바로 <strong>레코드 주소</strong>(실제 물리적인 주소)를 참조하고 있지만, <code>InnoDB</code> 에서는 <strong>프라이머리 키(PK)를 참조</strong>하고 있다. </p>
<p>따라서 인덱스를 통해 레코드를 읽을 때는 데이터 파일을 바로 찾아갈 수 없다. 먼저 1차적으로 세컨더리 인덱스 트리를 탐색하고, 얻은 PK 값을 바탕으로 프라이머리 키 인덱스를 한번 더 검색한다. 그리고 마지막으로 프라이머리 키 인덱스 리프 노드에 저장되어 있는 레코드를 읽는다.</p>
<p>간단히 생각하면 성능이 떨어질 것 같지만, 더 자세한거는 클러스터링 인덱스에서 살펴보자.</p>
<h3 id="☁️-b-tree-인덱스-사용에-영향을-미치는-요소">☁️ B-Tree 인덱스 사용에 영향을 미치는 요소</h3>
<h4 id="1-인덱스를-구성하는-칼럼의-크기">1. 인덱스를 구성하는 칼럼의 크기</h4>
<p>디스크에 데이터를 저장하는 가장 기본 단위는 페이지(Block)이다.</p>
<ul>
<li>디스크 읽기 및 쓰기 작업의 최소 단위</li>
<li>InnoDB 스토리지 엔진 버퍼 풀에서 데이터 버퍼링의 기본 단위</li>
<li>인덱스가 관리되는 단위(루프 / 브랜치 / 리프)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/62cbd3f3-d62e-4acd-81e6-57d35a7e4a4b/image.png" alt=""></p>
<blockquote>
<p>인덱스 키 값이 길어지면 어떤 일이 발생할까?</p>
</blockquote>
<ol>
<li><p>인덱스 칼럼의 크기가 크면, 그만큼 하나의 인덱스 페이지에 저장할 수 있는 키의 개수가 줄어든다. 즉 하나의 인덱스 페이지가 담을 수 있는 개수가 적어져 <code>B-Tree</code> 깊이가 깊어져 디스크 읽기가 더 많이 필요해진다.</p>
</li>
<li><p>인덱스를 캐시해두는 InnoDB 버퍼 풀 영역은 제한적이기 때문에, 캐시해둘 수 있는 인덱스 수가 줄어들어 메모리 효율이 떨어진다.</p>
</li>
</ol>
<p>예를 들어, 주로 페이지는 16KB가 기본값이다. 이때 키가 16바이트, 자식 노드 주소가 12바이트라 하면 16 * 1024 / (16+12) 해서 총 585개를 저장할 수 있다. </p>
<h4 id="2-카디널리티-선택도기수성cardinality">2. 카디널리티: 선택도(기수성(Cardinality))</h4>
<p>인덱스 선택도란, <strong>모든 인덱스 키 값 가운데 유니크한 값의 수</strong>를 의미한다. 즉 인덱스로 선정된 컬럼에 중복된 값이 많아질수록 선택도 값은 감소한다.</p>
<pre><code class="language-sql">mysql&gt; SELECT *
        FROM tb_test
        WHERE country=&#39;KOREA&#39; AND city=&#39;SEOUL&#39;</code></pre>
<p>총 데이터가 만개라고 가정해보자.</p>
<p>첫 번째 경우는 칼럼의 유니크한 값의 개수가 <code>10</code> 개이다.
즉 인덱스를 통해 <code>1000</code> 건(10000 / 10)을 읽었는데, 실제 데이터는 <code>1</code>건일 때 나머지 <code>999</code>는 불필요가 데이터를 읽어 낭비가 존재하게 되는 셈이다.(누누히 말하지만 불필요한 데이터 읽기를 줄이는게 성능 최적화 핵심이다.) </p>
<p>따라서 <strong>카디널리티가 낮은 컬럼에는 인덱스를 걸지 않는 것이 좋다.</strong></p>
<pre><code class="language-sql">korea A -&gt; 유니크가 1000건 데이터 중에 10개만 유니크(나머지 다 중복)
korea B
korea C
korea D
korea E
korea SEOUL
... * 1000</code></pre>
<p>두 번째 경우는 유니크한 값의 개수가 <code>1000</code> 개이다.
즉, 인덱스를 통해 <code>10</code> 건(10000 / 1000) 을 읽고, 그 중 실제 데이터는 <code>1</code>건이기 때문에 <code>9</code> 건만 불필요하게 읽어 앞에 경우보다 성능이 더 좋다고 볼 수 있다.</p>
<pre><code>korea SEOUL -&gt; 1000건 모두 유니크
korea BUSAN
... * 10</code></pre><h4 id="3-읽어야-하는-레코드-건수">3. 읽어야 하는 레코드 건수</h4>
<p>단건 기준으로는 인덱스를 통해 데이터를 읽는 것과 테이블에서 직접 1건을 읽는 것의 성능이 비슷하다.</p>
<p>*<em>하지만 인덱스를 통해 읽어야 하는 데이터가 정말 많은 경우에는, 테이블을 모두 읽어서 필요한 레코드만 가려내는 필터링 방식이 더 효율적이다. *</em>왜냐하면, random i/o로 인해 디스크 헤드를 이동시키는 비용이 누적되면서 결국 디스크 헤드 이동 한번에 플래터를 회전시키면 되는 풀 테이블 스캔이 더 유리하다고 판단하기 때문이다.</p>
<p>이러한 이유 때문에 사용자가 강제로 인덱스를 사용하도록 힌트를 줘도, 옵티마이저에서 알아서 테이블을 직접 읽는 방식으로 처리된다. 역시 직접 테스트해보자.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/d3f89b06-66ba-44f7-bdd4-81ddd63cfcbd/image.png" alt=""></p>
<p>현재 <code>first_name</code> 에 인덱스가 걸려있으므로 <code>using idex</code> 가 보여야 하지만, 보이지 않으므로 테이블을 그냥 쭉 읽었다고 볼 수 있다. 왜냐면 총 <code>172</code>개 데이터중에 과반수가 넘는 <code>80</code> 개의 데이터를 필터링 하고자 하였기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/10b1f55f-7b92-4ed3-8c23-0ffc26c2d6a7/image.png" alt=""></p>
<p>데이터의 범위를 좀 줄여보면, 아래와 같이 인덱스를 타는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/e4b2ab75-9e6e-4278-ade9-88857860dc7d/image.png" alt=""></p>
<h2 id="b-tree-인덱스를-통한-데이터-읽기">B-Tree 인덱스를 통한 데이터 읽기</h2>
<pre><code class="language-sql">SELECT * from employees WHERE fist_name BETWEEN &#39;Ebbe&#39; AND &#39;Gad&#39;;</code></pre>
<h3 id="☁️-인덱스-레인지-스캔">☁️ 인덱스 레인지 스캔</h3>
<p>인덱스 레인지 스캔이란, 가장 대표적인 접근방식으로 검색해야 할 <code>Between</code> 과 같이 <strong>인덱스 범위가 결정되었을 때</strong> 사용하는 방식이다.</p>
<h4 id="인덱스-레인지-스캔-동작-과정">인덱스 레인지 스캔 동작 과정</h4>
<ol>
<li><p>먼저, 탐색이 필요한 레코드의 <strong>시작 지점</strong>을 찾는다. 루트 노드부터 비교를 시작해, 리프 노드까지 찾아 들어가는 과정이다. </p>
</li>
<li><p>리프 노드에 도달하면 해당 시작 위치부터 필요한 만큼 순서대로 레코드를 읽는다. 스캔하다가 페이지의 마지막에 다다르면, <strong>리프 노드 간의 링크</strong>를 이용해 다음 리프 노드를 찾아서 다시 스캔한다.</p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/9137fc12-74b9-445c-a077-5f81401c7fbe/image.png" alt=""></p>
<ol start="3">
<li>쿼리가 필요한 데이터가 모두 인덱스만으로 찾을 수 있는 경우(<code>covering index</code>)가 아니라면, <strong>레코드 주소를 기반으로 실제 데이터 파일에 접근</strong>하는 과정이 일어난다. 중요한 점은 이 때 실제 데이터 파일 레코드 당 한 건 단위로 <strong>랜덤 <code>I/O</code></strong> 가 일어난다는 것이다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/9820986c-a9ef-463d-a01d-4d2f68221987/image.png" alt=""></p>
<p>이러한 점 때문에 인덱스를 통해 데이터 레코드를 읽는 작업은, 비용이 많이 든다. </p>
<h4 id="인덱스를-얼마나-탔는지-확인하는-방법">인덱스를 얼마나 탔는지 확인하는 방법</h4>
<pre><code class="language-sql">SHOW STATUS LIKE &#39;Handler_%&#39;;</code></pre>
<ul>
<li><code>Handler_read_key</code> : 인덱스에서 조건을 만족하는 값이 저장된 위치를 찾는 과정(인덱스 탐색)이 실행된 횟수</li>
<li><code>Handler_read_next</code> : 탐색 위치부터 필요한 만큼 인덱스를 읽은 레코드 건수(인덱스 스캔)</li>
</ul>
<h3 id="☁️-인덱스-풀-스캔">☁️ 인덱스 풀 스캔</h3>
<p>인덱스 풀 스캔은, 인덱스 레인지 스캔과 다르게 리프 노드를 연결하는 링크드 리스트를 따라 <strong>처음부터 끝까지 모두 읽는 방식</strong>을 의미한다. 테이블 풀 스캔하고 다른 점이 없어 보이겠지만, 다음과 같은 두 가지에서 장점을 가진다. </p>
<ol>
<li>인덱스 크기는 테이블 크기보다 작으므로, 페이지 내부에 더 많은 데이터가 저장되기 때문에 비교적 읽어야 하는 페이지의 개수가 더 작다.</li>
<li>인덱스에 포함된 컬럼만으로 쿼리를 처리할 수 있다면 테이블의 레코드를 읽을 필요가 없어 랜덤 I/O를 방지할 수 있다.</li>
</ol>
<p>인덱스 풀 스캔은 대표적으로, <strong>쿼리의 조건절에 사용된 컬럼이 인덱스 첫 번째 컬럼이 아닌 경우</strong> 사용된다. 예를 들어 (A, B, C) 순서로 만들어져 있을 때 쿼리 조건절을 B 혹은 C 칼럼으로 검색하는 경우이다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/73c3e7de-a5b1-4a02-98e6-3956752f1f54/image.png" alt=""></p>
<blockquote>
<p>사실, 인덱스 풀 스캔 방식은 인덱스를 효율적으로, 잘 사용하지 못하는 방식이다.</p>
</blockquote>
<h3 id="☁️-루스-인덱스-스캔">☁️ 루스 인덱스 스캔</h3>
<p>오라클 DBMS의 &quot;인덱스 스킨 스캡&quot; 과 작동 방식이 비슷하다. 
루스 인덱스 스캔은, 느슨하게 듬성듬성 인덱스를 읽는 방식으로 처리된다. 일반적으로, <code>GROUP BY</code> 혹은 집합 함수 가운데 <code>Min</code>, <code>Max</code> 함수에 대해 최적화를 하는 경우에 사용된다.</p>
<pre><code class="language-sql">mysql &gt; SELECT dept_no, MIN(emp_no)
        FROM dept_emp
        WHERE dep_no BETWEEN &#39;d002&#39; AND &#39;d004&#39;
        GROUPY BY dept_no;</code></pre>
<p>인덱스는 (dept_no, emp_no) 순으로 정렬되어 있기 때문에 dept_no 별로 첫 번째 레코드의 emp_no 값만 읽으면 된다. 즉 Min 값이므로 WHERE 조건의 모든 데이터를 읽지 않아도 된다는 것을 옵티마이저가 판단하여, 알아서 조건에 만족하지 않는 레코드는 무시하고 다음 레코드로 이동하게 된다.</p>
<h3 id="☁️-인덱스-스킵-스캔">☁️ 인덱스 스킵 스캔</h3>
<p>다중 컬럼에서, 일반적으로 <strong>맨 첫번째에 있는 컬럼에 대한 비교 조건이 없는 경우는 인덱스를 타지 못한다.</strong> 예를 들어 <code>(gender, birth_date)</code> 순으로 인덱스를 생성했다고 하자. 아래는 어느 경우 인덱스를 탈 수 있고, 언제 탈 수 없는지를 보여준다.</p>
<pre><code class="language-sql">SELECT gender, birth_date
FROM employees 
WHERE birth_date &gt;= &#39;1964-12-20&#39;;  // 인덱스를 타지 못함</code></pre>
<p>첫 번째 칼럼에 대한 검색 조건이 없으므로 인덱스를 타지 못한다.</p>
<pre><code class="language-sql">SELECT gender, birth_date
FROM employees 
WHERE gender=&#39;M&#39; AND birth_date &gt;= &#39;1964-12-20&#39;;  // 인덱스를 탐</code></pre>
<p>첫 번째 칼럼에 대한 검색 조건이 있으므로 인덱스를 탈 수 있다. </p>
<blockquote>
<p>이렇게 <strong>인덱스 구성 컬럼의 순서</strong>는 매우 중요하다.</p>
</blockquote>
<p>하지만 <code>MySQL 8.0</code> 버전부터는, 옵티마이저가 <code>gender</code> 칼럼을 건너뒤어서 <code>birth_date</code> 칼럼만으로 인덱스 검색이 가능하게 해주는 인덱스 스킵 스캔(Index Skip Scan) 최적화 기능이 도입되었다.</p>
<h4 id="1-skip-scan-활성화">1. skip-scan 활성화</h4>
<pre><code class="language-sql">SET optimizer_switch=&#39;skip_scan=on&#39;;  // skip-scan 활성화</code></pre>
<pre><code class="language-sql">EXPLAIN
SELECT gender, birth_date
FROM employees 
WHERE birth_date &gt;= &#39;1964-12-20&#39;;</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/7ba9b49d-09ad-400c-932f-563b85edae79/image.png" alt=""></p>
<p><code>type=range</code> 는 필요한 부분만 읽었다는 것을 의미하며, <code>57</code> 개의 데이터만 스킵 스캔으로 가져온 것을 볼 수 있다. 어떻게 이게 가능한 것일까?</p>
<ol>
<li>루스 인덱스 스캔 방식으로 맨 첫번째 인덱스에 칼럼에 대해 모든 값을 추출한다.(중복을 제외한 고유 값)</li>
<li>추출한 값에 대해 인덱스 스킵 스캔을 실행한다. </li>
</ol>
<p>해당 예제에서는 <code>Gender</code> 값이 &#39;M&#39; 과 &#39;F&#39; 만 있으므로, 아래와 같이 비슷한 형태로 최적화를 실행하게 된다.</p>
<pre><code class="language-sql">SELECT gender, birth_date FROM employees
WHERE Gender=&#39;M&#39; and birth_date &gt;= &#39;1964-12-20&#39;;  </code></pre>
<pre><code class="language-sql">SELECT gender, birth_date FROM employees
WHERE Gender=&#39;F&#39; and birth_date &gt;= &#39;1964-12-20&#39;; </code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/58bd2472-e3d3-4427-b264-cc84a44fb565/image.png" alt=""></p>
<h4 id="2-skip-scan-비활성화">2. skip-scan 비활성화</h4>
<pre><code class="language-sql">SET optimizer_switch=&#39;skip_scan=off&#39;;  // skip-scan 활성화</code></pre>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/2c5ecaeb-8466-4560-a174-e58e1ba22166/image.png" alt=""></p>
<p><code>type</code> 값이 <code>range</code> 가 아니고 <code>index</code> 이므로 인덱스 풀 스캔이 일어나서 비효율적으로 사용이 되었다. 따라서, <code>172</code>개의 모든 데이터를 읽어온 것을 볼 수 있다.</p>
<p>만약, 커버링 인덱스가 아니였고 <code>SELECt *</code> 로 테이블 모든 데이터를 가져와야 했다면 테이블 풀 스캔이 수행되었을 것이다.</p>
<h4 id="🫧-주의사항">🫧 주의사항</h4>
<p>인덱스 스킵 스캔은, 아래 두 가지를 지켜야만 실행될 수 있다.</p>
<ol>
<li>WHERE 조건절에, 조건이 없는 인덱스 선행 칼럼의 카디널리티가 작아야함(중복도가 높아야 함)</li>
<li>SELECT 대상이 인덱스에 존재하는 컬럼만으로 처리 가능한 경우(커버링 인덱스)
<img src="https://velog.velcdn.com/images/semi-cloud/post/4acd620a-753b-42d7-9382-ca731c202853/image.png" alt=""></li>
</ol>
<h3 id="☁️-다중-컬럼-인덱스">☁️ 다중 컬럼 인덱스</h3>
<p><strong>두 개 이상의 칼럼으로 구성된 인덱스</strong>를 의미하며, 복합 인덱스라고도 불린다. 인덱스의 <strong>후행 칼럼은 선행 칼럼에 의존해서 정렬</strong>되어있다는 것에 주의해야 한다. (그래서 인덱스 구성 칼럼의 순서가 중요하다)</p>
<p>아래 그림에서도 볼 수 있듯이, <code>depth_no</code> 에 의존해서 <code>emp_no</code> 가 정렬되어 있다. 따라서 <code>emp_no</code> 값의 정렬 순서가 빠르다고 하더라도, <code>dept_no</code> 의 정렬 순서가 늦으면 인덱스 뒤쪽에 위치하게 되는 것이다.</p>
<p>실제로 <code>emp_no = 10003</code> 이 인덱스 하단에 위치하고 있다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/876cc0a3-c8c7-496e-a6d0-b1a45864e6ab/image.png" alt=""></p>
<h3 id="☁️-b-tree-인덱스-정렬-및-스캔-방향">☁️ B-Tree 인덱스 정렬 및 스캔 방향</h3>
<p>인덱스 생성 시점에는 오름차순, 혹은 내림차순 한 방향으로만 실제로 정렬이 일어나지만 옵티마이저가 어떻게 최적화해서 어느 방향으로 읽느냐에 따라 또 다른 정렬 효과를 얻을 수 있다. </p>
<pre><code class="language-sql">SELECT * from employees 
    ORDER BY first_name DESC LIMIT 5; </code></pre>
<p>예를 들어 위 쿼리는 오름차순으로 모든 데이터를 읽고 뒤 5개의 데이터를 가져오는게 아니라, <strong>뒤에서부터 인덱스를 역순으로 읽으면서 필요한 5개의 데이터만 레코드</strong>만 가져오게 된다. </p>
<p><code>ORDER BY</code>, <code>MIN()</code>, <code>MAX()</code> 함수 등의 최적화가 필요한 경우에도 옵티마이저는 인덱스 읽기 방향을 전환해서 사용하도록 실행 계획을 만들어낸다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/1c58ae49-8a9d-4f43-87c5-4e3ffc5613eb/image.png" alt=""></p>
<h4 id="내림차순-인덱스와-오름차순-인덱스">내림차순 인덱스와 오름차순 인덱스</h4>
<p>인덱스 역순 스캔이 인덱스 정순 스캔보다 조금 느리다. 기본 <code>B-Tree</code> 의 리프 노드에서, 하나의 페이지 내부서 레코드가 <strong>단방향 오름차순 형태</strong>로 만 연결이 되어 있기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/06337aa5-2d69-47da-adef-74e5aef4564f/image.png" alt=""></p>
<p>소량의 레코드라면 둘 중 어느 것을 사용하던 상관 없지만, <strong>많은 레코드를 조회하면서 빈번하게 실행되어야 하는 경우라면 아예 내림차순 인덱스로 구성을 역방향으로 하고, 위-&gt;아래로 정수 스캔 방식으로 읽는게 더 효율적</strong>이다.</p>
<h3 id="☁️-b-tree-인덱스는-어떤-경우에-효율적으로-잘-썼다고-소문이-날까">☁️ B-Tree 인덱스는 어떤 경우에 효율적으로 잘 썼다고 소문이 날까?</h3>
<p><code>WHERE</code>, <code>GROUP BY</code>, <code>ORDER BY</code> 절이 어떤 경우에 인덱스를 어떤 방식으로 타는지 알아야, 데이터를 적게 탐색하게 하는 방향으로 쿼리를 잘 짤 수 있다.</p>
<p>먼저 인덱스 탐색 결정 조건은, 다음과 같이 두가지가 존재한다.</p>
<ol>
<li><code>작업 범위 결정 조건</code> : 비교 작업의 범위를 줄인다. 많을 수록 쿼리 처리 성능이 높아진다.</li>
<li><code>필터링/체크 조건</code> : 범위를 줄이지 못하고 단순 조건에 맞는지 검사하는 용도로 사용된다.(성능이 높이지는 못한다)</li>
</ol>
<h4 id="1-비교-조건">1. 비교 조건</h4>
<p>다중 칼럼 인덱스에서, <strong>칼럼의 순서</strong>와 <strong>칼럼에 사용된 조건이 동등 비교인지 크다/작다 와 같은 범위 조건</strong>인지에 따라 효율성이 달라진다. </p>
<pre><code class="language-sql">SELECT * FROM dept_emp WHERE dept_no=&#39;d002&#39; AND emp_no &gt;= 10114;</code></pre>
<ol>
<li><code>index</code> 가 <code>(dept_no, emp_no)</code> 인 경우
<img src="https://velog.velcdn.com/images/semi-cloud/post/11b9519c-a66a-411c-a811-c63cdc18e410/image.png" alt=""></li>
</ol>
<p><code>dept_no</code>가 <code>d001</code>면서 <code>emp_no</code>가 <code>10144</code>인 레코드를 찾고, <code>dept_no</code>가 아닐때까지 인덱스를 쭉 읽으면 된다. 즉 <code>dept_no</code> 와 <code>emp_no</code> 모두 검색 범위를 줄이는데 사용 되었다. 인덱스를 정상적으로 탔으며 총 <code>44</code> 개의 레코드만 검색되었다.</p>
<ol start="2">
<li><code>index</code> 가 <code>(emp_no, dept_no)</code> 인 경우
<img src="https://velog.velcdn.com/images/semi-cloud/post/042bfb0f-99f7-4efe-8c60-9c1a9ee89775/image.png" alt=""></li>
</ol>
<p><code>emp_no</code>가 <code>10144</code> 이상인 레코드를 찾고, <code>dept_no</code>가 <code>d001</code>이라는 조건에 맞는지 하나하나 검사해봐야 한다. 따라서 <code>dept_no</code> 는 위에서 말했던 필터링 조건으로 사용되었고, 인덱스를 못탔으며 총 53개의 레코드가 검색되었다.</p>
<h4 id="2-인덱스의-왼쪽-규칙">2. 인덱스의 왼쪽 규칙</h4>
<p><code>B-Tree</code> 인덱스는 <strong>왼쪽 값에 기준해서 오른쪽 값이 정렬</strong>되어 있다. 컬럼이 하나일 때도, 다중 컬럼 인덱스 칼럼일때도 마찬가지이다. 그리고 이 규칙은 <code>WHERE</code>, <code>GROUP BY</code>, <code>ORDER BY</code> 모두 해당된다.</p>
<ol>
<li>인덱스 컬럼이 하나인 경우<pre><code class="language-sql">SELECT * FROM employees WHERE first_name LIKE &#39;%mer&#39;;</code></pre>
왼쪽 값부터 한 글자씩 비교하면서 일치하는 레코드를 찾아야 하는데, 왼쪽 부분이 고정되어있지 않으므로 전체 레코드를 탐색하게 되어 인덱스를 이용할 수 없는 쿼리가 된다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/semi-cloud/post/e32e456e-24d5-4915-894c-5525bc61d205/image.png" alt=""></p>
<p>위에 사진에서도 <code>%mer</code> 는 전체 테이블 스캔, <code>A%</code> 는 인덱스 레인지 스캔이 사용된 것을 볼 수 있다.</p>
<blockquote>
<p>다시 기억해보자! 첫번째 쿼리는 찾으려고 하는 데이터가 <code>20%</code> 이상이므로 <code>랜덤 I/O</code>를 사용하는 인덱스가 아닌 <code>순차 I/O</code> 사용하는 테이블 스캔으로 최적화되었다.</p>
</blockquote>
<ol start="2">
<li>다중 컬럼인 경우<pre><code class="language-sql">SELECT * FROM dept_emp WHERE emp_no &gt;= 10114;</code></pre>
마찬가지로 인덱스가 <code>(dept_no, emp_no)</code> 칼럼 순서대로 생성되어 있다면 선행 칼럼인 <code>dept_no</code> 조건 없이는 인덱스를 효율적으로 사용할 수 없다.</li>
</ol>
<h4 id="3-작업-범위-결정-조건으로-인덱스를-사용할-수-없는-경우">3. 작업 범위 결정 조건으로 인덱스를 사용할 수 없는 경우</h4>
<p>총 정리를 해보자. 다음과 같은 경우에서는, 범위를 줄이는 용도로 인덱스를 사용할 수 없다.</p>
<blockquote>
<p>단일 컬럼</p>
</blockquote>
<ol>
<li><p><code>NOT-EQUAL</code> 조건으로 비교될 경우
<code>&lt;&gt;</code>, <code>NOT IN</code>, <code>NOT BETWEEN</code>, <code>IS NOT NULL</code> 조건이 포함된다. </p>
<pre><code class="language-sql">WHERE column NOT IN (10, 11, 12);</code></pre>
</li>
<li><p><code>LIKE &#39;%??&#39;</code> 형태로 문자열 패턴이 비교될 경우</p>
<pre><code class="language-sql">WHERE column LIKE &#39;%승환&#39;;
WHERE column LIKE &#39;_승환&#39;;
WHERE column LIKE &#39;%승%&#39;;</code></pre>
</li>
<li><p>다른 연산자로 인덱스 칼럼이 변형된 후 비교된 경우</p>
<pre><code class="language-sql">WHERE SUBSTRING(colum, 1, 1) = &#39;X&#39;;
WHERE DAYOFMONTH(column) = 1;</code></pre>
</li>
<li><p>데이터 타입이 서로 다른 비교</p>
<pre><code class="language-sql">WHERE char_column = 10;</code></pre>
</li>
</ol>
<blockquote>
<p>다중 컬럼</p>
</blockquote>
<pre><code class="language-sql">INDEX ix_test (column1, column2, colum3 ... columnn)</code></pre>
<p>다음과 같은 인덱스가 있다고 가정해보자.</p>
<ol>
<li>작업 범위 결정 조건으로 인덱스를 사용하지 못하는 경우</li>
</ol>
<ul>
<li>column1에 대한 조건이 없는 경우</li>
<li>column1의 비교 조건이 인덱스 사용 불가 조건 중 하나인 경우</li>
</ul>
<ol start="2">
<li>작업 범위 결정 조건으로 인덱스를 사용할 수 있는 경우</li>
</ol>
<ul>
<li><p>column1~column(i-1) 까지 동등 비교 형태(&#39;=&#39; 혹은 &quot;IN&quot;) </p>
</li>
<li><p>column(i) 칼럼에 대해 다음 연산자 중 하나로 비교</p>
<ul>
<li><p>동등 비교</p>
</li>
<li><p>크다 작다 형태(&quot;&gt;&quot;, &quot;&lt;&quot;)</p>
</li>
<li><p>LIKE 좌측 일치 패턴(LIKE &#39;승환 %&#39;)</p>
</li>
<li><p>이렇게 되면 columni 까지는 작업 범위 결정 조건, column(i+1)부터 나머지 까지 조건은 체크 조건으로 사용된다.</p>
</li>
</ul>
</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>