<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>풀소유</title>
    <link>https://kyoulho.tistory.com/</link>
    <description>Starting is the perfect condition</description>
    <language>ko</language>
    <pubDate>Sat, 20 Jun 2026 18:32:41 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>kyoulho</managingEditor>
    <item>
      <title>Codex App 개발 환경에 OmO, Codesight, agentmemory 붙이기</title>
      <link>https://kyoulho.tistory.com/442</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이 글의 기준은 &lt;span&gt;&lt;b&gt;Codex App / Codex Desktop&lt;/b&gt;&lt;/span&gt;&lt;span&gt;이다. 터미널에서 &lt;/span&gt;codex&lt;span&gt;를 직접 실행하는 &lt;/span&gt;&lt;span&gt;&lt;b&gt;Codex CLI&lt;/b&gt;&lt;/span&gt;&lt;span&gt; 기준이 아니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codex Desktop은 프로젝트 폴더를 열고, 여러 thread와 worktree를 병렬로 굴리는 데스크톱 작업 공간이다. 그래서 도구를 붙일 때도 CLI 기준으로 생각하면 헷갈린다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 만들려는 구조는 이렇다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;Codex Desktop
&amp;rarr; 여러 thread/worktree로 작업한다.

Codesight
&amp;rarr; 프로젝트 구조를 미리 스캔해서 AI가 읽을 지도를 만든다.

agentmemory
&amp;rarr; thread와 세션 사이의 작업 기억을 이어준다.

OmO / LazyCodex
&amp;rarr; Codex lifecycle에 보조 hook/plugin을 붙인다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;&lt;b&gt;&lt;br /&gt;&lt;/b&gt;왜 이 셋을 붙이는가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codex Desktop을 쓰면 thread를 여러 개 만들 수 있다. 이건 강점이다. 하지만 thread가 많아지면 같은 문제가 반복된다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;프로젝트 구조를 매번 다시 읽는다.
이전 thread에서 실패한 접근을 다른 thread가 다시 시도한다.
작업 종료 후 다음 thread로 넘길 맥락이 사라진다.
어떤 문서가 진짜 기준인지 헷갈린다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;그래서 역할을 나눈다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; width=&quot;100%&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;도구&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;무엇을 해주는가&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;왜 필요한가&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;Codesight&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;코드베이스 지도를 만든다&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;새 thread가 프로젝트 구조를 빨리 이해하게 한다&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;agentmemory&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;작업 기억을 저장하고 검색한다&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;이전 thread의 결정&amp;middot;실패&amp;middot;handoff를 이어받게 한다&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;OmO&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;Codex hook/plugin으로 실행 보조를 붙인다&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;rules, checker, LSP, ultrawork, continuation 같은 보조 기능을 쓴다&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;b&gt;&lt;br /&gt;&lt;/b&gt;Codex Desktop 기준 작업 흐름&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codex Desktop에서는 보통 이렇게 작업한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 앱에서 프로젝트 폴더를 연다.
2. thread를 만든다.
3. Codex가 별도 worktree에서 작업한다.
4. 변경사항을 리뷰한다.
5. 필요하면 반영하거나 버린다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기에 세 도구를 붙이면 흐름은 이렇게 바뀐다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;1. 프로젝트 루트에 AGENTS.md와 docs/**를 둔다.
2. Codesight로 .codesight/wiki를 만든다.
3. agentmemory 서버를 띄우고 MCP를 연결한다.
4. 필요하면 agentmemory hook을 연결한다.
5. OmO/LazyCodex를 설치한다.
6. Codex Desktop thread 시작 시 AGENTS.md, Codesight, memory를 읽게 한다.
7. 작업 종료 전 handoff를 남긴다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;CLI 명령을 아예 안 쓰는 것은 아니다. Codex Desktop을 쓰더라도 설치, 갱신, 서버 실행은 터미널에서 한다.&lt;/p&gt;
&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;b&gt;&lt;br /&gt;Codesight&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;무엇을 해주는가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codesight는 코드베이스 지도를 만든다. Codex Desktop에서 새 thread를 만들면 thread는 프로젝트를 다시 이해해야 한다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;백엔드는 어디인가?
프론트엔드는 어디인가?
테스트는 어디인가?
도메인 문서는 어디인가?
어떤 파일을 먼저 읽어야 하는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Codesight는 이 탐색을 미리 해둔다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;프로젝트 스캔
&amp;rarr; .codesight/wiki 생성
&amp;rarr; Codex Desktop thread가 구조 파악용으로 읽음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉, Codesight는 코드를 고치는 도구가 아니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;Codesight = AI가 읽는 프로젝트 지도&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;어떻게 적용하는가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 루트에서 실행한다.&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;npx codesight --wiki&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;생성 결과를 확인한다.&lt;/p&gt;
&lt;pre class=&quot;lua&quot;&gt;&lt;code&gt;find .codesight -maxdepth 3 -type f | sort&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;AGENTS.md에는 짧게 적는다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;## Codesight

- .codesight/wiki가 있으면 구조 파악용으로 읽어라.
- Codesight 결과물은 Source of Truth가 아니다.
- 정책 판단은 AGENTS.md와 docs/**에서 다시 확인해라.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;코드 변경 시 어떻게 하는가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codesight는 생성 산출물이다. 코드가 바뀌면 지도도 낡는다. 하지만 모든 수정마다 돌릴 필요는 없다.&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;작은 버그 수정
&amp;rarr; 갱신 안 함

문서 구조 변경
&amp;rarr; npx codesight --wiki

패키지 구조 변경
&amp;rarr; npx codesight --wiki

큰 리팩토링 전
&amp;rarr; npx codesight --wiki

큰 리팩토링 중 구조가 계속 바뀜
&amp;rarr; npx codesight --watch

커밋마다 자동 갱신
&amp;rarr; 기본값으로 쓰지 않음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;--hook&lt;span&gt;은 조심한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;npx codesight --hook&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;커밋마다 Codesight 생성 파일이 바뀌면 커밋 범위가 지저분해질 수 있다. 개인 프로젝트에서는 처음부터 hook을 기본값으로 둘 필요 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;Codex Desktop worktree 주의점&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codex Desktop은 thread별 worktree를 만들 수 있다. 그래서 Codesight를 실행할 때는 &lt;span&gt;&lt;b&gt;현재 갱신하려는 worktree 경로&lt;/b&gt;&lt;/span&gt;에서 실행해야 한다.&lt;/p&gt;
&lt;pre class=&quot;dos&quot;&gt;&lt;code&gt;cd &amp;lt;codex-desktop-worktree-path&amp;gt;
npx codesight --wiki&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;원본 프로젝트 루트에서 만든. codesight/wiki와 Codex Desktop thread의 worktree 안 파일 상태가 다를 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이를 모르면 낡은 지도를 보고 작업하게 된다.&lt;/p&gt;
&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;b&gt;&lt;br /&gt;agentmemory&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;무엇을 해주는가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;agentmemory는 작업 기억을 저장한다. Codex Desktop에서는 thread가 여러 개 생긴다. 그러면 이런 문제가 생긴다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;A thread에서 실패한 접근을 B thread가 다시 시도한다.
어제 thread에서 남긴 다음 작업을 오늘 thread가 모른다.
사용자가 금지한 방향을 다른 thread가 다시 제안한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;agentmemory는 이런 기억을 저장하고 다시 꺼내게 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장할 것은 이 정도다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;완료한 작업
실패한 접근
다음 작업
주의사항
handoff
정책 문서 위치&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;저장하면 안 되는 것도 있다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;긴 로그 전문
비밀값
검증되지 않은 추측
docs에 없는 정책 단정
이미 docs에 있는 내용을 그대로 복붙한 장문&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;정책은 docs에 두고, memory에는 작업 기억과 정책 문서 위치를 남긴다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;어떻게 실행하는가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;a href=&quot;https://github.com/rohitg00/agentmemory&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/rohitg00/agentmemory&lt;/a&gt; 에서 확인한다.&amp;nbsp;&amp;nbsp;&lt;/span&gt;나는 설치 방식을 선호하고 작업 중에는 터미널에서 agentmemory 서버를 항상 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;Codex Desktop에 어떻게 붙이는가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codex Desktop에서 생각할 통로는 두 개다.&lt;/p&gt;
&lt;pre class=&quot;mel&quot;&gt;&lt;code&gt;MCP
&amp;rarr; Codex Desktop이 memory를 검색하고 읽는 통로

hook
&amp;rarr; 작업 이벤트를 memory에 자동 기록하는 통로&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;처음에는 MCP부터 붙인다. 자동 기록보다 &lt;span&gt;&lt;b&gt;검색이 되는지&lt;/b&gt;&lt;/span&gt; 먼저 확인하는 게 낫다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MCP 등록은 Codex Desktop 설정에서 하거나, 환경에 따라 다음 명령으로 Codex 설정에 추가한다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;codex mcp add agentmemory -- npx -y @agentmemory/mcp&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;AGENTS.md에도 추가한다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;## agentmemory

- agentmemory MCP가 연결되어 있다면 현재 프로젝트의 최근 memory를 검색해라.
- 검색 결과가 없으면 없다고 말해라.
- memory 내용이 AGENTS.md 또는 docs/**와 충돌하면 AGENTS.md와 docs/**를 우선한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;hook까지 쓰려면 CodexApp의 훅 설정을 하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 명령어는 Codex Desktop에서 plugin-local hook이 기대대로 동작하지 않을 때 global hook으로 우회 연결하는 용도다. &lt;b&gt;때문에 훅이 중복 등록되어 중복 호출될 수 있다. 잘 확인하고 실행하자&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;agentmemory connect codex --with-hooks&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 적용 순서는 이렇다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. agentmemory 서버 실행
2. MCP 연결
3. Codex Desktop thread에서 memory 검색 테스트
4. handoff를 수동으로 기록하도록 지시
5. 필요하면 hook 연결
6. hook이 실제로 기록하는지 확인&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;b&gt;&lt;br /&gt;OmO / LazyCodex&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;무엇을 해주는가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OmO는 Codex 또는 OpenCode의 실행 흐름을 보조하는 harness다. Codex 쪽에서는 LazyCodex Light edition으로 붙는다.&lt;br /&gt;설치하면 Codex plugin cache 아래에 OmO plugin이 들어가고, hooks/hooks.json을 통해 Codex lifecycle에 붙는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 이런 경로를 확인할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;~/.codex/plugins/cache/sisyphuslabs/omo/&amp;lt;version&amp;gt;/hooks/hooks.json&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Codex Desktop 기준에서 OmO에 기대하는 역할은 이렇다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;rules 보조
comment checker 보조
LSP 기반 피드백 보조
ultrawork / ulw 흐름 보조
작업 continuation 보조
telemetry
git-bash 보조&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉, OmO를 이렇게 보면 안 된다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;OmO = SparkShell&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;더 정확히는 이렇다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;OmO = Codex lifecycle 보조 hook/plugin 묶음&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;어떻게 설치하는가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codex용 LazyCodex를 설치한다.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;npx lazycodex-ai install&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;자동화 옵션까지 적용하려면 다음을 쓴다.&lt;/p&gt;
&lt;pre class=&quot;brainfuck&quot;&gt;&lt;code&gt;npx lazycodex-ai install --no-tui --codex-autonomous&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;설정이 들어갔는지 확인한다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;grep -n &quot;omo&quot; ~/.codex/config.toml
grep -n &quot;plugin&quot; ~/.codex/config.toml
ls ~/.codex&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;plugin cache를 확인한다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;find ~/.codex/plugins/cache/sisyphuslabs/omo -path '*/hooks/hooks.json'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;omo 명령이 안 잡히면 PATH를 추가한다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;echo 'export PATH=&quot;$HOME/.local/bin:$PATH&quot;' &amp;gt;&amp;gt; ~/.zshrc
source ~/.zshrc&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;주의할 점도 있다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Codex용
&amp;rarr; npx lazycodex-ai install

OpenCode용
&amp;rarr; bunx oh-my-openagent install

npx omo / bunx omo
&amp;rarr; 쓰지 않는다. 다른 패키지로 해석될 수 있다.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;OmO를 실제로 어떻게 쓰는가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Codex Desktop에서 OmO는 별도 명령으로 매번 실행하는 도구가 아니다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;틀린 이해:
작업할 때마다 OmO 명령을 직접 실행한다.

맞는 이해:
OmO plugin/hook을 설치하고 승인한다.
Codex lifecycle에 붙은 보조 기능이 필요한 이벤트에서 개입하게 둔다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ulw 또는 ultrawork 키워드를 써서 깊은 작업 모드를 유도하는 쪽이 현실적이다.&lt;br /&gt;하지만 나는 훅으로 등록해서 사용중이다. thread가 작업하는 동안 발생하는 로그를 보면 에이전트가 필요한 명령을 판단해서 사용하는걸 볼 수 있다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;ulw: 이 작업을 끝까지 진행해라.
먼저 계획을 세우고, 작은 단위로 구현하고, 테스트 결과까지 확인해라.
중간에 멈추지 말고 실패 원인을 보고해라.

또는

ultrawork 모드로 진행해라.
작업 계획, 구현, 검증, 남은 리스크를 순서대로 보고해라.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 style=&quot;text-align: center;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;b&gt;AGENTS.md&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도구를 설치해도 Codex Desktop이 알아서 잘 쓰지는 않는다. 명시적으로 사용방법을 가이드한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;짧게 쓴다고 쓴건데... 너무 길다&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# 저장소 작업 규칙

이 파일은 AI coding agent가 이 저장소에서 작업할 때 따르는 공통 규칙이다.

특정 도구나 실행 환경에 종속되지 않는다.  
Codex Desktop, Codex CLI, Cursor, Claude Code, OpenCode 등 어떤 에이전트가 실행되더라도 이 파일을 먼저 따른다.

---

## 문서와 도구의 우선순위

- **AGENTS.md**: 모든 에이전트가 항상 따르는 공통 작업 규칙.
- **docs/**: Source of Truth. 오래가는 제품&amp;middot;도메인&amp;middot;아키텍처 정책을 둔다.
- **Codesight**: 코드베이스 지도와 탐색 보조 도구. 생성 산출물이며 Source of Truth가 아니다.
- **agentmemory**: 최근 결정, 금지사항, 반복 회귀 원인, handoff, 세션 요약을 저장&amp;middot;조회하는 작업 메모리. Source of Truth가 아니다.
- **OmO / LazyCodex**: Codex lifecycle에 보조 hook/plugin을 붙이는 실행 보조 도구. Source of Truth가 아니다.

에이전트는 자신이 어떤 실행 환경에서 동작하는지 확실히 알 수 없으므로, 이 파일에서는 실행 환경별 역할을 전제하지 않는다.

---

## 기본 원칙

- Source of Truth는 `AGENTS.md`와 `docs/**`이다.
- Codesight, agentmemory, OmO 결과는 모두 보조 정보다.
- 보조 정보가 Source of Truth와 충돌하면 Source of Truth를 우선한다.
- 작업 범위는 사용자의 요청 범위로 제한한다.
- 오래가는 제품&amp;middot;도메인&amp;middot;아키텍처 정책은 docs에 둔다.
- 정책 변경이 필요한 작업은 docs를 먼저 수정한다.
- 단순 버그 수정에서 정책이 바뀌지 않으면 docs를 건드리지 않는다.
- 테스트 통과만으로 UI 완료를 선언하지 않는다.
- 실제 화면을 확인하지 못했으면 수동 확인 미완료라고 보고한다.
- 모르는 것을 추측해서 확정하지 않는다.
- 근거가 부족하면 부족하다고 보고하고, 확인 가능한 근거를 우선한다.

---

## 기본 작업 순서

작업자는 항상 다음 순서를 따른다.

1. `AGENTS.md`
2. 작업이 단순 문구 수정이 아니라면 agentmemory에서 관련 memory recall
3. `docs/README.md`가 있으면 읽는다
4. `docs/project/status.md`가 있으면 읽는다
5. `docs/project/roadmap.md`가 있으면 읽는다
6. `.codesight/wiki/index.md`가 있으면 읽는다
7. 현재 작업 entrypoint를 찾는다
8. `.codesight/wiki/*` 중 현재 작업과 직접 관련된 article 1~2개를 읽는다
9. 관련 `docs/architecture/*`가 있으면 읽는다
10. 필요 시 `docs/reference/*`를 읽는다
11. 필요한 원본 코드 파일을 읽는다

위 문서나 디렉터리가 없으면 없는 것으로 보고하고, 존재하는 문서를 기준으로 진행한다.

장기 결정 배경은 가능하면 `docs/project/decision-log.md`에 둔다.  
구현 근거는 현재 `docs/**`와 실제 코드를 우선한다.

---

## 문서 유지&amp;middot;삭제 정책

문서는 개발 판단과 구현에 직접 도움이 될 때만 유지한다.

삭제&amp;middot;이동 판단 기준은 다음과 같다.

- 현재 구현&amp;middot;정책&amp;middot;운영 판단에 쓰이지 않는 문서는 유지하지 않는다.
- Source of Truth를 반복하는 중복 문서는 하나로 합친다.
- 과거 결정 배경은 필요하면 decision log에 남기고, 긴 임시 문서는 삭제 또는 archive 처리한다.
- generated output은 재생성 가능한 산출물로 보고 수동 보존 가치를 두지 않는다.
- 삭제가 코드 탐색이나 정책 판단을 더 어렵게 만들면 삭제하지 않는다.
- 문서 삭제나 이동은 작업 보고에 명시한다.

---

## Codesight 사용 정책

Codesight는 코드 탐색 비용과 토큰 사용량을 줄이기 위한 보조 도구다.

Codesight output은 Source of Truth가 아니며, docs 또는 실제 코드와 충돌하면 docs와 실제 코드를 우선한다.

### 기본 원칙

- 세션 시작 시 `.codesight/CODESIGHT.md` 전체를 읽지 않는다.
- 먼저 `.codesight/wiki/index.md`만 읽고 현재 작업에 필요한 article을 고른다.
- 좁은 작업은 관련 wiki article 1개와 entrypoint 코드만 읽고 시작한다.
- 넓은 리팩토링, 영향도 분석, 구조 파악이 필요할 때만 Codesight graph/raw output을 추가로 참고한다.
- Codesight output이 stale로 보이면 stale 가능성을 보고하고, 실제 코드와 Source of Truth 문서를 기준으로 판단한다.
- Codesight 결과는 위치 탐색에 사용하고, 정책 판단에는 사용하지 않는다.

### 권장 사용 방식

- **Backend/API 작업**: `index.md` &amp;rarr; 관련 route/service/database article &amp;rarr; 관련 docs
- **Frontend/UI 작업**: `index.md` &amp;rarr; 관련 page/component/state article &amp;rarr; 관련 docs
- **DB/Migration 작업**: `index.md` &amp;rarr; database article &amp;rarr; 관련 migration과 roadmap
- **아키텍처 변경**: `index.md` &amp;rarr; overview article &amp;rarr; 관련 architecture docs
- **외부 연동 작업**: Codesight보다 먼저 `docs/reference/**`와 provider별 reference 문서를 확인한다.

### 금지

- 매 작업마다 `.codesight/CODESIGHT.md` 전체를 읽지 않는다.
- `.codesight/wiki/*`를 Source of Truth처럼 취급하지 않는다.
- `.codesight/` 아래에 사람이 작성한 수동 topic map을 만들거나 유지하지 않는다.
- generated Codesight raw output을 agentmemory에 저장하지 않는다.
- Codesight stale 가능성을 무시하고 구현 판단을 확정하지 않는다.

---

## agentmemory 사용 정책

agentmemory는 최근 결정, 금지사항, 반복 회귀 원인, handoff, 세션 요약을 저장&amp;middot;조회하는 작업 메모리다.

agentmemory는 Source of Truth가 아니다.  
agentmemory 내용이 `AGENTS.md` 또는 `docs/**`와 충돌하면 반드시 Source of Truth를 우선한다.

agentmemory는 다음 방식으로 사용할 수 있다.

- MCP를 통한 수동 recall/remember
- 세션 종료 시 자동 세션 요약
- hook 기반 자동 기록
- hook 기반 자동 요약
- 다음 세션 시작 시 최근 handoff/context recall

자동 기록과 자동 요약은 전체 로그를 무제한 저장하는 용도가 아니다.  
저장 대상은 다음 작업에 반복적으로 영향을 줄 짧은 맥락으로 제한한다.

### 작업 시작 시

단순 문구 수정이 아닌 작업은 시작 시 agentmemory를 recall한다.

다음 중 하나에 해당하면 작업 시작 시 반드시 agentmemory를 recall한다.

- 넓은 코드 변경
- 리팩토링
- 도메인 정책 변경
- 데이터 정합성 관련 변경
- 인증, 권한, 결제, 금융, 보안, 외부 연동 관련 변경
- 외부 provider ingest 또는 adapter 관련 변경
- 이전 회귀 가능성이 있는 버그 수정
- 사용자가 &quot;전에 정한 것&quot;, &quot;기존 정책&quot;, &quot;최근 결정&quot;을 언급한 작업
- 다음 작업자에게 이어질 가능성이 있는 작업

기본 recall 대상:

- PROJECT HOT
- PROJECT FORBIDDEN
- PROJECT HANDOFF
- 최근 세션 요약

필요 시 추가 recall 대상:

- PROJECT STALE
- 현재 작업 키워드
- 관련 도메인 키워드

recall한 memory는 최근 작업 맥락으로만 사용한다.

memory가 현재 docs와 충돌하거나 오래된 것으로 보이면 작업을 멈추고 stale 가능성을 보고한다.

agentmemory가 비활성화되어 있거나 recall에 실패해도 작업은 `AGENTS.md`와 docs 기준으로 진행하되, 보고서에 recall 실패를 명시한다.

### 작업 종료 시

작업 종료 시 agentmemory에 짧은 요약을 남길 수 있다.

특히 다음 중 하나가 생기면 remember한다.

- 새로 확정된 프로젝트 정책
- 반복 회귀 원인
- 금지된 구현 방식
- 다음 작업자가 알아야 할 짧은 handoff
- 향후 에이전트 작업에 반복적으로 영향을 줄 결정
- 현재 docs 반영 전까지 임시로 기억해야 할 주의사항
- 긴 작업을 중단하거나 다음 세션으로 넘겨야 하는 경우의 현재 상태

정책이 바뀌지 않은 일반 구현 작업이라도, 다음 세션에서 바로 이어서 작업할 가능성이 있으면 짧은 HANDOFF를 남길 수 있다.

### 자동 세션 요약 / hook 사용

agentmemory hook 기반 자동 기록&amp;middot;자동 요약은 사용할 수 있다.

다만 hook은 다음 원칙을 따른다.

- 전체 세션 로그를 장기 memory로 그대로 저장하지 않는다.
- 최종 요약은 짧은 handoff 중심으로 남긴다.
- 장기 정책은 PROJECT HOT 또는 PROJECT FORBIDDEN으로 명시적으로 저장한다.
- 임시 디버깅 과정, 긴 테스트 출력, raw response, credential, 로컬 DB 값은 저장하지 않는다.
- Codesight raw output은 저장하지 않는다.
- 자동 요약 내용이 Source of Truth와 충돌하면 Source of Truth를 우선하고 memory를 stale로 표시한다.

### 저장할 수 있는 memory

- 새로 확정된 프로젝트 정책
- 반복 회귀 원인
- 금지된 구현 방식
- 다음 작업자가 알아야 할 짧은 handoff
- 향후 에이전트 작업에 반복적으로 영향을 줄 결정
- stale 가능성이 확인된 과거 결정
- 다음 세션에서 이어서 작업하기 위한 짧은 세션 요약

### 저장하지 않는 memory

- API key, app secret, token, credential
- 계좌번호 전체, 주민번호, 식별 가능한 민감정보
- raw API response
- local DB 내용
- agent 실행 로그 전문
- 긴 테스트 출력
- 전체 작업 보고서 전문
- 임시 디버깅 메모 전체
- 곧 바뀔 가능성이 높은 구현 세부사항
- generated Codesight raw output
- Codex/Cursor/Claude 세션 전체 원문

### Memory 형식

memory는 짧고 명시적으로 작성한다.

권장 prefix:

- **PROJECT HOT**: 현재 유효한 작업 규칙
- **PROJECT FORBIDDEN**: 금지된 구현 방식
- **PROJECT HANDOFF**: 다음 작업자가 알아야 할 짧은 인계
- **PROJECT STALE**: 더 이상 유효하지 않은 과거 결정
- **PROJECT SUMMARY**: 다음 세션 연결을 위한 짧은 세션 요약

예시:

- PROJECT HOT: Workspace-affecting mutations must update local state, not only refresh the router.
- PROJECT FORBIDDEN: Do not restore deprecated fallback path.
- PROJECT HANDOFF: Current work stopped after adding provider ingest tests; next step is wiring mapper integration.
- PROJECT SUMMARY: Session focused on docs, Codesight/wiki, and agentmemory policy; Source of Truth remains AGENTS.md and docs.

---

## OmO / LazyCodex 사용 정책

OmO / LazyCodex는 Codex lifecycle 보조 hook/plugin이다. Source of Truth가 아니다.

- OmO를 SparkShell 자동 압축 도구로 전제하지 않는다.
- Codex Desktop에서는 SparkShell은 CLI에서만 사용 가능한 것으로 보이며, 기본 Bash 출력에 자동 개입한다고 가정하지 않는다.
- `ulw` 또는 `ultrawork` 키워드는 긴 작업 모드 유도에 사용할 수 있다.
- OmO hook/plugin 동작은 실제 실행 결과로 확인한다.
- OmO 결과가 `AGENTS.md` 또는 `docs/**`와 충돌하면 `AGENTS.md`와 `docs/**`를 우선한다.
- OmO가 없거나 동작하지 않아도 작업은 `AGENTS.md`와 docs 기준으로 진행한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>kyoulho</author>
      <guid isPermaLink="true">https://kyoulho.tistory.com/442</guid>
      <comments>https://kyoulho.tistory.com/442#entry442comment</comments>
      <pubDate>Sun, 31 May 2026 08:41:04 +0900</pubDate>
    </item>
    <item>
      <title>Spring @Bean메서드 직접 호출은 일반 Java 호출과 다르다</title>
      <link>https://kyoulho.tistory.com/440</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring의 @Configuration 클래스 안에서 @Bean 메서드를 직접 호출하면, 일반 Java 메서드 호출처럼 보이지만 실제로는 다르게 동작할 수 있다. 핵심은 @Configuration(proxyBeanMethods = true)와 Spring proxy다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;문제 코드&lt;/span&gt;&lt;/h2&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Bean
public Job reportJob() {
    return new JobBuilder(&quot;reportJob&quot;, jobRepository)
            .start(reportStep(null))
            .build();
}

@Bean
@JobScope
public Step reportStep(
        @Value(&quot;#{jobParameters['reportDate']}&quot;) String reportDate
) {
    return new StepBuilder(&quot;reportStep&quot;, jobRepository)
            .tasklet((contribution, chunkContext) -&amp;gt; {
                System.out.println(&quot;reportDate = &quot; + reportDate);
                return RepeatStatus.FINISHED;
            }, transactionManager)
            .build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;겉으로는 reportStep(null)&lt;span&gt;이다. 순수 Java라면 당연히 &lt;/span&gt;reportDate&lt;span&gt;는 &lt;/span&gt;null&lt;span&gt;이어야 한다. &lt;/span&gt;그런데 Spring Batch에서 JobParameter를 넘기면 실제 값이 출력될 수 있다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;./gradlew bootRun --args='--spring.batch.job.name=reportJob reportDate=2026-05-27'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;reportDate = 2026-05-27&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;이유&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;null&lt;span&gt;이 실제 &lt;/span&gt;Step&lt;span&gt; 객체에 들어간 것이 아니다. &lt;/span&gt;@Configuration(proxyBeanMethods = true)에서는 Spring이 @Bean 메서드 호출을 CGLIB proxy로 가로챌 수 있다. 그리고 @JobScope가 붙은 Bean은 애플리케이션 시작 시점에 실제 객체가 만들어지지 않는다. 대신 scoped proxy가 먼저 들어간다. 흐름은 이렇다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;애플리케이션 시작
&amp;rarr; reportStep(null)처럼 보이는 호출 발생
&amp;rarr; 실제 Step 생성 X
&amp;rarr; scoped proxy 반환

Job 실행
&amp;rarr; JobScope 활성화
&amp;rarr; JobParameters 사용 가능
&amp;rarr; 실제 Step delegate 생성
&amp;rarr; reportDate 값 바인딩&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉 핵심은 이것이다.&lt;/p&gt;
&lt;pre class=&quot;d&quot;&gt;&lt;code&gt;null은 delegate에 전달되지 않았다.
null은 proxy를 얻는 과정의 더미 인자에 가깝다.
실제 객체는 scope가 활성화된 뒤 생성된다.&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;proxyBeanMethods = false &lt;/span&gt;&lt;span&gt;면 다르다&lt;/span&gt;&lt;/h2&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Configuration(proxyBeanMethods = false)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이면 @Bean 메서드 직접 호출을 Spring이 가로채지 않는다. 그러면 다음 호출은 정말 일반 Java 메서드 호출처럼 동작할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;reportStep(null)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;정리하면 다음과 같다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; width=&quot;100%&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;설정&lt;/td&gt;
&lt;td&gt;@Bean 직접 호출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;proxyBeanMethods = true&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;Spring proxy가 가로챌 수 있음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;proxyBeanMethods = false&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;일반 Java 호출처럼 동작할 수 있음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;@Component&lt;span&gt; 내부 메서드 호출&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;일반 Java 호출&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;권장 방식&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Bean&lt;span&gt; 메서드에 &lt;/span&gt;null&lt;span&gt;을 넘기는 코드는 동작할 수 있어도 읽기 어렵다. &lt;/span&gt;이렇게 쓰는 편이 낫다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Bean
public Job reportJob(Step reportStep) {
    return new JobBuilder(&quot;reportJob&quot;, jobRepository)
            .start(reportStep)
            .build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;그리고 Spring Batch에서는 더 좋은 구조가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Job&lt;span&gt;과 &lt;/span&gt;Step&lt;span&gt;은 구조 정의 객체로 두고, 실행 시점 값이 필요한 &lt;/span&gt;Tasklet&lt;span&gt;, &lt;/span&gt;Reader&lt;span&gt;, &lt;/span&gt;Writer&lt;span&gt;에 scope를 붙인다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Bean
public Step reportStep(Tasklet reportTasklet) {
    return new StepBuilder(&quot;reportStep&quot;, jobRepository)
            .tasklet(reportTasklet, transactionManager)
            .build();
}

@Bean
@StepScope
public Tasklet reportTasklet(
        @Value(&quot;#{jobParameters['reportDate']}&quot;) String reportDate
) {
    return (contribution, chunkContext) -&amp;gt; {
        System.out.println(&quot;reportDate = &quot; + reportDate);
        return RepeatStatus.FINISHED;
    };
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;결론&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Bean 메서드 직접 호출은 일반 Java 호출처럼 보이지만, Spring proxy 때문에 실제 의미가 달라질 수 있다. 특히 scoped proxy가 끼면 null을 넘긴 것처럼 보여도 실제 delegate는 나중에 scope가 활성화된 뒤 생성된다. 실무 원칙은 간단하다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Bean 메서드 직접 호출에 의존하지 말 것.
Job/Step은 scope 없이 둔다.
실행 시점 값은 Tasklet/Reader/Writer 같은 하위 Bean에 @StepScope로 주입한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JVM/Spring</category>
      <author>kyoulho</author>
      <guid isPermaLink="true">https://kyoulho.tistory.com/440</guid>
      <comments>https://kyoulho.tistory.com/440#entry440comment</comments>
      <pubDate>Wed, 27 May 2026 20:21:58 +0900</pubDate>
    </item>
    <item>
      <title>[종목 검색 API] 5. Redis Sentinel과 CircuitBreaker 장애 대응 실험</title>
      <link>https://kyoulho.tistory.com/438</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;Redis Sentinel&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis Sentinel은 Redis master 장애를 감지하고 replica를 새 master로 승격시키는 HA 구조다. Redis Cluster처럼 데이터를 여러 노드에 분산 저장하는 구조가 아니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; width=&quot;100%&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;Redis Sentinel&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;Redis Cluster&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;목적&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;master 장애 대응&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;sharding + HA&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;데이터 분산&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;없음&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;있음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;주요 기능&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;failover&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;hash slot 분산&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;적합한 상황&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;단일 Redis의 HA&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;Redis 용량&amp;middot;처리량 분산&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Redis cache-aside 구조에서는 Redis가 정상일 때 반복 검색어를 캐시로 흡수한다. 하지만 Redis가 죽으면 cache layer가 사라지고 DB fallback이 증가한다. 따라서 이번 실험의 대상은 Redis Cluster가 아니라 Redis Sentinel이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;실험 조건&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국투자증권 Open API에서 제공하는 실제 종목 데이터를 사용했다. 해당 데이터를 Batch로 적재해 stock_master 테이블에 저장했고, 총 9,092건의 종목 데이터를 기준으로 실험했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;부하는 k6로 주었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;시나리오: &lt;/span&gt;30s@10 -&amp;gt; 1m@30 -&amp;gt; 30s@0&lt;/li&gt;
&lt;li&gt;장애 주입: 시작 후 75초에 Redis master stop&lt;/li&gt;
&lt;li&gt;Redis timeout: 100ms&lt;/li&gt;
&lt;li&gt;Sentinel 구성: master 1 / replica 1 / sentinel 2&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인한 지표는 다음이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;p95 / p99&lt;/li&gt;
&lt;li&gt;DB query count&lt;/li&gt;
&lt;li&gt;Redis get/set error&lt;/li&gt;
&lt;li&gt;CircuitBreaker bypassed get&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;장애 흐름&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sentinel 환경의 Redis master 장애 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;Redis master 장애
&amp;rarr; Sentinel failover 진행
&amp;rarr; Redis get/set error 발생
&amp;rarr; CircuitBreaker OPEN
&amp;rarr; Redis 호출 bypass
&amp;rarr; DB fallback 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Sentinel은 Redis master를 복구한다. 하지만 CircuitBreaker가 OPEN 상태라면 애플리케이션은 복구된 Redis를 호출하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sentinel은 HA 계층이고, CircuitBreaker는 장애 격리 계층이다. 둘은 대체 관계가 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sentinel의 효과를 보려면 CircuitBreaker가 복구된 Redis로 돌아갈 수 있어야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;기존 CircuitBreaker 설정&lt;/span&gt;&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; width=&quot;100%&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 25.2325%;&quot;&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 17.5582%;&quot;&gt;&lt;b&gt;값&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 57.093%;&quot;&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 25.2325%;&quot;&gt;&lt;span&gt;sliding window type&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 17.5582%;&quot;&gt;&lt;span&gt;COUNT_BASED&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 57.093%;&quot;&gt;&lt;span&gt;실패율을 시간 기준이 아니라 최근 호출 개수 기준으로 계산한다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 25.2325%;&quot;&gt;&lt;span&gt;sliding window size&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 17.5582%;&quot;&gt;&lt;span&gt;20&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 57.093%;&quot;&gt;&lt;span&gt;최근 Redis 호출 20개를 기준으로 실패율을 계산한다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 25.2325%;&quot;&gt;&lt;span&gt;minimum number of calls&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 17.5582%;&quot;&gt;&lt;span&gt;10&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 57.093%;&quot;&gt;&lt;span&gt;최소 10번 이상 호출이 쌓여야 실패율을 계산한다.&lt;br /&gt;호출 수가 너무 적을 때 성급하게 OPEN 되는 것을 막는다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 25.2325%;&quot;&gt;&lt;span&gt;failure rate threshold&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 17.5582%;&quot;&gt;&lt;span&gt;50&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 57.093%;&quot;&gt;&lt;span&gt;최근 호출 중 실패율이 50% 이상이면 CircuitBreaker를 OPEN 한다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 25.2325%;&quot;&gt;&lt;span&gt;wait duration in open state&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 17.5582%;&quot;&gt;&lt;span&gt;10s&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 57.093%;&quot;&gt;&lt;span&gt;OPEN 상태가 되면 10초 동안 Redis 호출을 차단한다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 25.2325%;&quot;&gt;&lt;span&gt;permitted calls in half-open&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 17.5582%;&quot;&gt;&lt;span&gt;3&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 57.093%;&quot;&gt;&lt;span&gt;10초가 지난 뒤 HALF_OPEN 상태에서 Redis에 시험 호출 3개를 허용한다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 25.2325%;&quot;&gt;&lt;span&gt;automatic transition&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 17.5582%;&quot;&gt;&lt;span&gt;true&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 57.093%;&quot;&gt;&lt;span&gt;OPEN 상태에서 10초가 지나면 자동으로 HALF_OPEN으로 전환한다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 25.2325%;&quot;&gt;&lt;span&gt;Redis timeout&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 17.5582%;&quot;&gt;&lt;span&gt;100ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 57.093%;&quot;&gt;&lt;span&gt;Redis 명령이 100ms 안에 끝나지 않으면 실패로 본다.&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Sentinel 환경에서는 OPEN에서 HALF_OPEN으로 전환되는 시점이 중요하다. 너무 빨리 Redis를 다시 호출하면 failover가 끝나기 전에 error가 증가한다. 너무 늦게 Redis를 다시 호출하면 Redis가 복구됐는데도 DB fallback을 계속 탄다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;후보 선정&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 실험에서는 주로 Redis 복귀 시점을 앞당기는 방향을 확인했다. 하지만 CircuitBreaker 튜닝에는 다른 축도 있다. Redis 장애를 얼마나 빠르게 감지하고 차단할 것인가다. slidingWindowSize와 minimumNumberOfCalls를 줄이면 더 적은 샘플로 실패율을 판단한다.&lt;br /&gt;장애 감지는 빨라질 수 있지만, 일시적인 오류에도 민감해질 수 있다. 이번에는 복귀 시점과 장애 감지 민감도를 함께 비교했다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 152px;&quot; border=&quot;1&quot; width=&quot;100%&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;b&gt;후보&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;b&gt;설계 의도&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;A baseline-current&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;현재 설정 기준선&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;B faster-wait&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;waitOpen만 줄였을 때 Redis 복귀가 빨라지는지 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;C stable-window&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;waitOpen&lt;span&gt;을 줄이되 &lt;/span&gt;window&lt;span&gt;, &lt;/span&gt;minCalls&lt;span&gt;를 키워 일시 오류에 덜 민감해지는지 확인&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;D balanced-probe&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;C에서 halfOpen probe만 늘렸을 때 회복 확인이 나아지는지 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;E sensitive-window&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;window&lt;span&gt;, &lt;/span&gt;minCalls&lt;span&gt;를 줄였을 때 장애 감지가 빨라지는지 확인&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;F sensitive-recovery&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;민감한 감지와 빠른 복귀를 같이 적용했을 때의 변화 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;G medium-sensitive&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;&lt;span&gt;A와 E 사이의 중간 민감도 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;후보별 설정은 다음과 같다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; width=&quot;100%&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;후보&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;waitOpen&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;halfOpen&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;slidingWindow&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;SizeminCalls&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;failRate&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;A baseline-current&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;10s&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;3&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;20&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;10&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;50&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;B faster-wait&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;5s&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;3&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;20&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;10&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;50&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;C stable-window&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;6s&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;3&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;30&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;12&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;50&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;D balanced-probe&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;6s&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;4&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;30&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;12&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;50&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;E sensitive-window&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;10s&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;3&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;10&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;5&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;50&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;F sensitive-recovery&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;6s&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;3&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;10&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;5&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;50&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;G medium-sensitive&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;8s&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;3&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;15&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;8&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;50&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Redis timeout은 모든 후보에서 100ms로 고정했다. 이번 실험에서는 Redis timeout이 아니라 CircuitBreaker 회복 정책을 비교한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;측정 결과&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;후보별 3회 측정 결과의 평균은 다음과 같다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; width=&quot;100%&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 7.7907%;&quot;&gt;&lt;b&gt;후보&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 16.2791%;&quot;&gt;&lt;b&gt;p95 평균&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.2326%;&quot;&gt;&lt;b&gt;p99 평균&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.372%;&quot;&gt;&lt;b&gt;DB query 평균&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.8373%;&quot;&gt;&lt;b&gt;get_error 평균&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.4884%;&quot;&gt;&lt;b&gt;set_error 평균&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 7.7907%;&quot;&gt;&lt;span&gt;A&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 16.2791%;&quot;&gt;&lt;span&gt;22.90ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.2326%;&quot;&gt;&lt;span&gt;239.41ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.372%;&quot;&gt;&lt;span&gt;960.33&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.8373%;&quot;&gt;&lt;span&gt;18.67&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.4884%;&quot;&gt;&lt;span&gt;9.00&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 7.7907%;&quot;&gt;&lt;span&gt;B&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 16.2791%;&quot;&gt;&lt;span&gt;27.54ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.2326%;&quot;&gt;&lt;span&gt;175.77ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.372%;&quot;&gt;&lt;span&gt;946.67&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.8373%;&quot;&gt;&lt;span&gt;28.67&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.4884%;&quot;&gt;&lt;span&gt;13.67&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 7.7907%;&quot;&gt;&lt;span&gt;C&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 16.2791%;&quot;&gt;&lt;span&gt;30.96ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.2326%;&quot;&gt;&lt;span&gt;245.57ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.372%;&quot;&gt;&lt;span&gt;943.33&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.8373%;&quot;&gt;&lt;span&gt;30.67&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.4884%;&quot;&gt;&lt;span&gt;14.33&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 7.7907%;&quot;&gt;&lt;span&gt;D&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 16.2791%;&quot;&gt;&lt;span&gt;30.52ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.2326%;&quot;&gt;&lt;span&gt;275.08ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.372%;&quot;&gt;&lt;span&gt;945.33&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.8373%;&quot;&gt;&lt;span&gt;36.00&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.4884%;&quot;&gt;&lt;span&gt;19.00&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 7.7907%;&quot;&gt;&lt;span&gt;E&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 16.2791%;&quot;&gt;&lt;span&gt;27.40ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.2326%;&quot;&gt;&lt;span&gt;190.24ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.372%;&quot;&gt;&lt;span&gt;953.67&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.8373%;&quot;&gt;&lt;span&gt;15.67&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.4884%;&quot;&gt;&lt;span&gt;7.00&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 7.7907%;&quot;&gt;&lt;span&gt;F&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 16.2791%;&quot;&gt;&lt;span&gt;28.69ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.2326%;&quot;&gt;&lt;span&gt;229.31ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.372%;&quot;&gt;&lt;span&gt;940.67&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.8373%;&quot;&gt;&lt;span&gt;24.33&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.4884%;&quot;&gt;&lt;span&gt;9.33&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 7.7907%;&quot;&gt;&lt;span&gt;G&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 16.2791%;&quot;&gt;&lt;span&gt;31.56ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 20.2326%;&quot;&gt;&lt;span&gt;282.83ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.372%;&quot;&gt;&lt;span&gt;926.33&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.8373%;&quot;&gt;&lt;span&gt;25.00&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 18.4884%;&quot;&gt;&lt;span&gt;8.33&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;HTTP 실패율은 모든 후보에서 0이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;결과 해석&lt;/span&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;A baseline-current&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A는 기존 설정이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;p95 평균은 22.90ms로 가장 낮다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 get_error 평균은 18.67이고, set_error 평균은 9.00이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기준선으로 나쁘지 않지만, cache error 관점에서는 개선 여지가 있었다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;B faster-wait&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B는 waitOpen만 10초에서 5초로 줄인 후보다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;p99 평균은 낮아졌지만, get_error와 set_error가 모두 증가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis 복귀를 앞당긴 효과보다, failover 직후 Redis를 다시 호출하면서 생기는 error 증가가 더 컸다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;C stable-window&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C는 waitOpen&lt;span&gt;을 6초로 줄이고, &lt;/span&gt;slidingWindowSize&lt;span&gt;와 &lt;/span&gt;minimumNumberOfCalls&lt;span&gt;를 키웠다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB query 평균은 A보다 낮아졌지만, p95와 cache error가 증가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패율 판단을 둔감하게 만든다고 장애 구간 안정성이 좋아지지는 않았다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;D balanced-probe&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;D는 C에서 halfOpen probe를 3에서 4로 늘린 후보다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;get_error와 set_error가 후보 중 가장 높다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;half-open probe를 늘리면 Redis 회복 확인은 더 적극적으로 할 수 있지만, failover 직후 불안정한 Redis에 더 많은 요청을 보낼 수 있다. 이번 조건에서는 불리했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;E sensitive-window&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;E는 slidingWindowSize&lt;span&gt;를 20에서 10으로, &lt;/span&gt;minimumNumberOfCalls&lt;span&gt;를 10에서 5로 줄인 후보다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;waitOpen과 halfOpen은 기존 설정과 동일하게 유지했다. p95는 A보다 높지만, get_error와 set_error가 후보 중 가장 낮다. E는 장애 감지를 더 민감하게 하면서도 Redis 복귀 시점은 기존처럼 보수적으로 유지한 설정이다. 이번 실험에서는 이 조합이 가장 안정적이었다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; width=&quot;100%&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;지표&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;A&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;E&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;p95 평균&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;22.90ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;27.40ms&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;p99 평균&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;239.41ms&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;190.24ms&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;DB query 평균&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;960.33&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;953.67&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;get_error 평균&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;18.67&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;15.67&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;set_error 평균&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;9.00&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span&gt;7.00&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;F sensitive-recovery&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;F는 E에서 waitOpen을 10초에서 6초로 줄인 후보다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB query 평균은 가장 낮은 편이지만, get_error가 E보다 크게 증가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;민감한 장애 감지와 빠른 복귀를 같이 적용하면 Redis error가 다시 늘어난다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;G medium-sensitive&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;G는 A와 E 사이의 중간 민감도 후보다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB query 평균은 가장 낮지만, p95와 p99가 모두 느린 편이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;set_error는 낮지만 get_error가 높아 선택 후보로 보기는 어렵다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;선택 후보&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실험에서는 E sensitive-window를 선택한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; width=&quot;100%&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;설정&lt;/td&gt;
&lt;td&gt;값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;wait duration in open state&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;10s&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;permitted calls in half-open&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;3&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;sliding window size&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;10&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;minimum number of calls&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;5&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;failure rate threshold&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;50&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;Redis timeout&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;100ms&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;E는 Redis 복귀 시점을 앞당기지 않았다. 대신 장애 감지 window를 줄였다. 즉 &amp;ldquo;더 빨리 Redis로 돌아가는 설정&amp;rdquo;이 아니라, &amp;ldquo;장애 Redis를 더 빨리 차단하는 설정&amp;rdquo;이다. 이번 측정에서는 이 방향이 더 안정적이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;B, F처럼 waitOpen을 줄인 후보는 Redis 복귀를 앞당겼지만 error가 증가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C, D처럼 window를 키운 후보도 p95와 error 측면에서 불리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;E는 get/set error가 가장 낮고, p99 평균도 A보다 낮다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;현재 실험 조건에서는 E가 가장 안정적인 선택이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;운영 반영 기준&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 결과는 현재 조건에서의 선택이다. 운영 반영 전에는 같은 지표를 다시 본다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;p95 / p99&lt;/li&gt;
&lt;li&gt;DB query count&lt;/li&gt;
&lt;li&gt;get_error / set_error&lt;/li&gt;
&lt;li&gt;bypassed_get&lt;/li&gt;
&lt;li&gt;HTTP 실패율&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영에서는 Redis 배포 방식, 네트워크 지연, 실제 검색 트래픽이 달라질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 운영값은 운영 관측치를 기준으로 확정한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;정리&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis 장애 대응은 한 계층으로 끝나지 않는다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis timeout은 응답 대기 시간을 제한한다.&lt;/li&gt;
&lt;li&gt;CircuitBreaker는 장애 Redis 호출을 막는다.&lt;/li&gt;
&lt;li&gt;Sentinel은 Redis master를 복구한다.&lt;/li&gt;
&lt;li&gt;DB fallback은 최종 방어선이다.&lt;/li&gt;
&lt;li&gt;Sentinel을 붙였다고 CircuitBreaker가 필요 없어지는 것이 아니다.&lt;/li&gt;
&lt;li&gt;CircuitBreaker가 있다고 Sentinel이 필요 없어지는 것도 아니다.&lt;/li&gt;
&lt;li&gt;Sentinel의 효과를 보려면 CircuitBreaker가 복구된 Redis로 돌아갈 수 있어야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실험에서는 Redis로 더 빨리 돌아가는 후보보다, 장애 감지를 더 민감하게 하는 후보가 더 안정적이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 실험 조건에서는 E 설정을 다음 운영 검증 후보로 둔다.&lt;/p&gt;</description>
      <category>종목 검색 api</category>
      <author>kyoulho</author>
      <guid isPermaLink="true">https://kyoulho.tistory.com/438</guid>
      <comments>https://kyoulho.tistory.com/438#entry438comment</comments>
      <pubDate>Tue, 26 May 2026 20:09:03 +0900</pubDate>
    </item>
    <item>
      <title>[종목 검색 API] 4. Redis 장애 격리 실험: Timeout과 CircuitBreaker로 충분했을까?</title>
      <link>https://kyoulho.tistory.com/437</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서는 자동완성 검색 API에 Redis Cache를 붙이고 실제 입력 패턴으로 부하 테스트를 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동완성 API의 1차 방어선은 Redis가 아니라 debounce였다. debounce를 적용하자 API 요청 수와 DB query count가 크게 줄었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Redis 장애 테스트에서는 다른 문제가 드러났다. Redis가 운영 중 내려가자 API 실패율은 0%였지만, p95와 p99가 크게 튀었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;DB 검색 쿼리 평균 실행 시간은 12.05ms 수준이었다. 즉 전체 지연의 핵심은 DB 자체가 아니었다. 문제는 Redis 장애 시 매 요청마다 Redis get/set 실패를 기다리는 구조였다. 그래서 이번 글에서는 Redis timeout을 줄이고, CircuitBreaker를 적용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 문제: fallback은 실패율을 막지만 latency는 막지 못한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 구조는 cache-aside였다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;request
&amp;rarr; Redis get
    hit  &amp;rarr; return
    miss &amp;rarr; DB search
&amp;rarr; Redis set
&amp;rarr; response
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Redis가 정상일 때는 문제가 없다. 하지만 Redis가 죽으면 흐름이 달라진다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;request
&amp;rarr; Redis get 실패 대기
&amp;rarr; local stock_master search
&amp;rarr; Redis set 실패 대기
&amp;rarr; response
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;fallback은 동작한다. 그래서 API는 5xx를 내지 않는다. 하지만 사용자는 느려진 응답을 그대로 맞는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 fallback만으로 부족한 이유다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;fallback = 실패율 방어
circuit breaker = 장애 전파 시간 방어
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;둘은 다르다. fallback은 &amp;ldquo;서비스가 죽지 않게&amp;rdquo; 만든다. CircuitBreaker는 &amp;ldquo;계속 기다리지 않게&amp;rdquo; 만든다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;적용한 개선&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Redis timeout 단축&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Redis command timeout을 짧게 가져갔다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;spring:
  data:
    redis:
      timeout: 100ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis가 죽었을 때 오래 기다리지 않는다. 자동완성 API에서 Redis는 성능 최적화 계층이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis가 느리거나 죽었다고 검색 API가 Redis를 오래 기다리면 안 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;br /&gt;CircuitBreaker 적용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음 Redis cache adapter 앞에 Resilience4j CircuitBreaker를 뒀다. 구조는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;StockSearchCache
  └── CircuitBreakingStockSearchCache
        └── RedisStockSearchCache
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;동작 원칙은 다음이다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;상황&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;처리&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Redis get 성공 + hit&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;cache hit 반환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Redis get 성공 + miss&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;null 반환, local stock_master 검색&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Redis get 실패&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;null 반환, local stock_master 검색&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;CircuitBreaker OPEN&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Redis 호출 생략, local stock_master 검색&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Redis put 실패&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;예외 삼킴, API 응답 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;CircuitBreaker OPEN 상태의 put&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Redis set 생략&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;중요한 점은 서비스가 Redis 장애를 몰라도 된다는 것이다. StockSearchService는 여전히 이렇게만 본다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;val cached = cache.get(cacheKey)
if (cached != null) {
    return cached
}

val results = searchLocal(...)
cache.put(cacheKey, results, ttl)

return results
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Redis 장애 격리는 cache adapter 내부 책임이다. 검색 서비스는 cache hit이면 반환하고, cache miss처럼 보이면 local DB를 조회한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;Kotlin null 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현 중 의외로 중요한 지점이 있었다. Redis cache miss는 정상적인 null이다.&lt;br /&gt;그런데 Resilience4j의 executeSupplier에서 Kotlin null을 그대로 다루면 문제가 생길 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 miss를 별도 non-null wrapper로 감쌌다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;private sealed interface CacheLookup {
    data class Hit(val results: List&amp;lt;StockSearchResult&amp;gt;) : CacheLookup
    data object Miss : CacheLookup
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이렇게 하면 구분이 명확해진다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;CacheLookup.Miss = 정상적인 cache miss
Exception = Redis 장애
CallNotPermittedException = CircuitBreaker OPEN
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 구분이 중요하다. cache miss를 실패로 잡으면 CircuitBreaker가 잘못 열린다. 반대로 Redis 장애를 miss처럼만 처리하면 장애 상태를 계속 반복한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;재측정 결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis timeout과 CircuitBreaker를 적용한 뒤 다시 테스트했다. 동일하게 debounced 입력 패턴으로 부하를 주고, 테스트 중간에 Redis를 중단했다. k6 결과는 다음과 같았다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;&lt;b&gt;값&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;k6 http_reqs&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;854&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;p95 latency&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;1.81s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;p99 latency&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;30.78s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;max latency&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;31.3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;http_req_failed&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;0.70%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;DB search query count&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;601&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;DB mean_exec_time&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;15.43ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;PostgreSQL CPU avg&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;18.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;PostgreSQL CPU max&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;76.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;Redis CPU avg&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;1.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;Redis CPU max&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;2.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;Keycloak CPU avg&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;0.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 46.7442%;&quot;&gt;Keycloak CPU max&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 53.1395%;&quot;&gt;1.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;표면적으로 보면 애매하다. p95는 이전 Redis 장애 테스트보다 낮아졌다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;이전 p95 = 2.56s
개선 후 p95 = 1.81s
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;하지만 p99는 오히려 크게 튀었다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;이전 p99 = 3.55s
개선 후 p99 = 30.78s
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉 timeout과 CircuitBreaker만으로 문제가 끝나지는 않았다. 다만 이 결과를 바로 실패로 판단하면 안 된다. Redis metric을 같이 봐야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;Redis Cache Metric 확인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 후 Micrometer pwm.stock_search.cache.* metric은 다음과 같았다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 121px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 50.6977%; height: 19px;&quot;&gt;Metric&lt;/td&gt;
&lt;td style=&quot;width: 49.186%; height: 19px;&quot;&gt;값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50.6977%; height: 17px;&quot;&gt;pwm.stock_search.cache.hit&lt;/td&gt;
&lt;td style=&quot;width: 49.186%; height: 17px;&quot;&gt;248&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50.6977%; height: 17px;&quot;&gt;pwm.stock_search.cache.miss&lt;/td&gt;
&lt;td style=&quot;width: 49.186%; height: 17px;&quot;&gt;129&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50.6977%; height: 17px;&quot;&gt;pwm.stock_search.cache.get_error&lt;/td&gt;
&lt;td style=&quot;width: 49.186%; height: 17px;&quot;&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50.6977%; height: 17px;&quot;&gt;pwm.stock_search.cache.set_error&lt;/td&gt;
&lt;td style=&quot;width: 49.186%; height: 17px;&quot;&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50.6977%; height: 17px;&quot;&gt;pwm.stock_search.cache.bypassed get&lt;/td&gt;
&lt;td style=&quot;width: 49.186%; height: 17px;&quot;&gt;459&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50.6977%; height: 17px;&quot;&gt;pwm.stock_search.cache.bypassed set&lt;/td&gt;
&lt;td style=&quot;width: 49.186%; height: 17px;&quot;&gt;345&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;핵심은 이 부분이다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;get_error = 13
set_error = 9
bypassed = 804
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Redis 장애 이후에도 Redis를 계속 때린 것이 아니다. 초반에 소수의 get/set 실패가 발생했고, 그 뒤에는 CircuitBreaker가 열리면서 Redis 호출을 우회했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉 Redis CircuitBreaker 자체는 정상 동작했다. 기존에는 Redis 장애 후 매 요청마다 Redis 실패를 계속 맞았다. 이제는 장애를 감지한 뒤 Redis 호출을 생략한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;그런데 왜 p99는 여전히 흔들렸나?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis CircuitBreaker는 동작했다. DB도 병목은 아니었다. PostgreSQL 검색 쿼리는 601회 실행되었고, 평균 실행 시간은 15.43ms였다.&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;DB search query mean_exec_time = 15.43ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Docker stats도 비슷한 결론을 보여준다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;pwm-postgres cpu_avg = 18.5%
pwm-postgres cpu_max = 76.8%
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;DB가 완전히 무너진 것은 아니다. 그런데 p99는 30초까지 튀었다. 이 말은 하나다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;Redis 장애 격리만으로는 전체 tail latency를 안정화할 수 없다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;CircuitBreaker는 Redis 호출 반복을 막았다. 하지만 단일 Redis 인스턴스가 죽는 순간 cache 계층 자체는 사라진다.&lt;br /&gt;Redis가 살아 있을 때는 반복 요청을 Redis가 흡수한다. Redis가 죽으면 요청은 local DB로 내려간다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Redis 정상
&amp;rarr; 반복 요청은 Redis가 흡수

Redis 장애
&amp;rarr; Redis 우회
&amp;rarr; DB fallback 증가
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉 CircuitBreaker는 Redis 장애를 빠르게 우회하게 만들지만, Redis가 제공하던 부하 흡수 능력까지 대체하지는 못한다. &lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 실험의 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis timeout은 Redis 실패 대기 시간을 줄인다. CircuitBreaker는 장애 Redis 호출 반복을 줄인다. 하지만 Redis 단일 장애 자체를 없애지는 못한다. Redis가 단일 인스턴스이면, Redis 장애 순간 cache 계층 전체가 사라진다. Redis가 죽을 때마다 DB가 직접 부하를 받아야 한다면 운영 안정성은 여전히 부족하다. 따라서 다음 단계는 Redis 자체를 단일 장애점에서 빼는 것이다.&lt;/p&gt;</description>
      <category>종목 검색 api</category>
      <author>kyoulho</author>
      <guid isPermaLink="true">https://kyoulho.tistory.com/437</guid>
      <comments>https://kyoulho.tistory.com/437#entry437comment</comments>
      <pubDate>Sat, 23 May 2026 18:17:00 +0900</pubDate>
    </item>
    <item>
      <title>[종목 검색 API] 3. API 부하 테스트: debounce, Redis Cache, Redis 장애</title>
      <link>https://kyoulho.tistory.com/434</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;1편에서는 PostgreSQL 검색 쿼리의 실행 계획을 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 실제 자동완성 API에 부하를 걸어봤다. 단순히 &amp;ldquo;Redis를 붙이면 빨라지는가?&amp;rdquo;를 확인하려는 실험은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;자동완성 API에서 더 중요한 질문은 따로 있다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;사용자가 검색창에 입력할 때 서버에는 어떤 요청이 발생하는가?
Redis Cache는 DB 부하를 얼마나 줄이는가?
Redis가 운영 중 내려가면 병목은 어디로 이동하는가?
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;결론부터 말하면, 자동완성 검색 API의 1차 방어선은 Redis가 아니라 debounce였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis는 반복 요청을 흡수한다. 하지만 잘못된 입력 패턴 자체를 줄이지는 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;자동완성 API의 요청 경로는 조회 중심으로 설계했다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;Redis
&amp;rarr; local stock_master
&amp;rarr; response
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;자동완성 API는 사용자의 입력 과정에서 반복 호출된다. 따라서 요청 경로에는 Redis 조회와 DB 검색처럼 예측 가능한 작업만 둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;stock_master는 검색 요청 경로 밖에서 배치를 통해 주기적으로 갱신한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;현재 검색 API 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 검색 API는 Redis cache-aside 구조로 동작한다. 요청 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;trim
  &amp;rarr; len &amp;lt; 2 &amp;rarr; emptyList, cache/DB 미사용
  &amp;rarr; normalize &amp;rarr; cache key 생성
  &amp;rarr; Redis get
      hit &amp;rarr; return
      miss / get error / CB bypass &amp;rarr; local stock_master search
  &amp;rarr; non-empty &amp;rarr; cache put (TTL 300s)
  &amp;rarr; empty     &amp;rarr; cache put (TTL 30s)
  &amp;rarr; return
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Cache key는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;stock-search:{symbol}:{nameKo}:{nameEn}:{limit}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;TTL 정책도 단순하다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;조건&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;TTL&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;결과 있음&lt;/td&gt;
&lt;td&gt;local stock_master hit&lt;/td&gt;
&lt;td&gt;300초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;결과 없음&lt;/td&gt;
&lt;td&gt;local stock_master empty&lt;/td&gt;
&lt;td&gt;30초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;검색어 길이 &amp;lt; 2&lt;/td&gt;
&lt;td&gt;요청 차단&lt;/td&gt;
&lt;td&gt;저장 안 함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 흐름에서 검색 API의 책임은 명확하다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;저장된 stock_master를 조회한다.
반복 요청은 Redis로 흡수한다.
Redis가 실패하면 DB로 fallback한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;검색 요청은 stock_master를 갱신하지 않는다. stock_master는 검색 요청 경로 밖에서 주기적으로 갱신한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 local DB 검색 결과가 없으면 짧은 TTL로 empty result를 캐시한다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;local search result exists
&amp;rarr; 300초 캐시

local search result empty
&amp;rarr; 30초 negative cache

query length &amp;lt; 2
&amp;rarr; cache/DB 모두 사용하지 않음
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;자동완성 API의 요청 경로는 조회 중심이어야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;측정 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 부하는 k6로 만들었다. k6에서 본 값은 다음이다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;지표&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;http_reqs&lt;/td&gt;
&lt;td&gt;API 요청 수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;http_req_duration p(95)&lt;/td&gt;
&lt;td&gt;대부분의 사용자가 체감하는 응답 시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;http_req_duration p(99)&lt;/td&gt;
&lt;td&gt;느린 요청이 얼마나 튀는지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;http_req_failed&lt;/td&gt;
&lt;td&gt;HTTP 실패율&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;DB query count는 pg_stat_statements로 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;테스트 전 통계를 초기화했다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;SELECT pg_stat_statements_reset();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;테스트 후 검색 쿼리 호출 수를 확인했다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;SELECT calls,
       total_exec_time,
       mean_exec_time,
       rows,
       query
FROM pg_stat_statements
WHERE query ILIKE '%stock_master%'
ORDER BY calls DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기서 가장 중요한 값은 calls다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;calls = 실제 PostgreSQL 검색 쿼리 실행 수
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;k6 http_reqs와 pg_stat_statements.calls는 같을 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis hit이 발생하면 API 요청은 존재하지만 DB query는 발생하지 않는다. 그래서 다음 비율을 함께 봤다.&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;DB query/request 비율 = DB search query count / k6 http_reqs
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;DB CPU는 Docker 기준으로 확인했다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;docker stats pwm-postgres
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Redis hit / miss / error / bypass는 Micrometer Counter로 확인했다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;pwm.stock_search.cache.hit
pwm.stock_search.cache.miss
pwm.stock_search.cache.get_error
pwm.stock_search.cache.set_error
pwm.stock_search.cache.bypassed
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;테스트 시나리오&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;naive 입력 패턴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째는 debounce가 없다고 가정했다. 즉 사용자가 입력하는 중간 상태가 모두 서버로 전달된다. 자동완성에서 흔히 실수하는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 요청은 한글 종목명, 한국 숫자 티커, 미국 티커, 영문 검색어, 존재하지 않는 검색어를 섞었다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;한글 종목명/테마
삼 &amp;rarr; 삼ㅅ &amp;rarr; 삼서 &amp;rarr; 삼성
반 &amp;rarr; 반ㄷ &amp;rarr; 반도 &amp;rarr; 반돛 &amp;rarr; 반도체
카 &amp;rarr; 캌 &amp;rarr; 카카 &amp;rarr; 카캌 &amp;rarr; 카카오

한국 숫자 티커
0 &amp;rarr; 00 &amp;rarr; 005 &amp;rarr; 0059 &amp;rarr; 00593 &amp;rarr; 005930
0 &amp;rarr; 00 &amp;rarr; 000 &amp;rarr; 0006 &amp;rarr; 00066 &amp;rarr; 000660
0 &amp;rarr; 03 &amp;rarr; 035 &amp;rarr; 0357 &amp;rarr; 03572 &amp;rarr; 035720

미국 티커
A &amp;rarr; AA &amp;rarr; AAP &amp;rarr; AAPL
T &amp;rarr; TS &amp;rarr; TSL &amp;rarr; TSLA
N &amp;rarr; NV &amp;rarr; NVD &amp;rarr; NVDA

영문 검색어
ap &amp;rarr; app &amp;rarr; appl &amp;rarr; apple
te &amp;rarr; tes &amp;rarr; tesl &amp;rarr; tesla
sa &amp;rarr; sam &amp;rarr; sams &amp;rarr; samsung

없는 검색어
없 &amp;rarr; 없는 &amp;rarr; 없는종 &amp;rarr; 없는종목
z &amp;rarr; zz &amp;rarr; zzz &amp;rarr; zzzz &amp;rarr; zzzznotfound
테 &amp;rarr; 텟 &amp;rarr; 테스 &amp;rarr; 테스트 &amp;rarr; 테스트검색어
1 &amp;rarr; 12 &amp;rarr; 123 &amp;rarr; 1234 &amp;rarr; 12345 &amp;rarr; 123456
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;debounced 입력 패턴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 debounce가 적용되었다고 가정했다. 중간 입력 상태는 서버로 보내지 않고 최종 검색어만 요청한다. 다만 삼성, 레버리지, AAPL만 반복하지 않았다. 그렇게 하면 Redis hit이 지나치게 잘 나오는 이상적인 테스트가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 debounced 테스트도 현실적인 검색어 그룹을 섞었다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;한국 종목명/테마
삼성, 레버리지, 반도체, 현대차, 카카오

한국 숫자 티커
005930, 000660, 035420, 035720, 373220

미국 티커
AAPL, TSLA, NVDA, MSFT, GOOGL

영문 검색어
apple, tesla, samsung, semiconductor, energy

없는 검색어
없는종목, zzzznotfound, 삼성없는, 123456, 테스트검색어
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;운영 중 Redis 장애&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째는 Redis 장애 테스트다. Redis를 테스트 전에 끄지 않았다. k6 부하가 진행 중인 상태에서 Redis를 중단했다. 이 테스트의 목적은 단순히 API가 실패하는지 보는 것이 아니다. 운영 관점에서는 Redis가 내려갔을 때 병목이 어디로 이동하는지가 더 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장애 경로는 단순하다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Redis get 실패
&amp;rarr; local DB fallback
&amp;rarr; Redis set 실패
&amp;rarr; response
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉 봐야 할 것은 이것이다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Redis 장애가 API 실패로 전파되는가?
Redis 장애 후 tail latency가 얼마나 튀는가?
DB가 fallback 부하를 감당하는가?
Redis 실패를 계속 기다리는 구조인가?
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;naive 입력 패턴 결과&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;&lt;b&gt;값&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;k6 http_reqs&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;4,946&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;p95 latency&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;53.8ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;p99 latency&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;172.98ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;http_req_failed&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;DB search query count&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;1,065회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;DB search query/request 비율&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;21.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;DB mean_exec_time&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;27.04ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;DB CPU max&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;174.98%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;Redis hit count&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;2,724&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;Redis miss count&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;1,065&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;Redis hit ratio&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;71.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 50.814%;&quot;&gt;Redis error count&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 49.0697%;&quot;&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;naive 입력 패턴에서는 4,946건의 API 요청이 발생했다. 이 중 실제 검색 쿼리는 1,065회 실행되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis hit은 2,724건, miss는 1,065건이었다. Redis hit ratio는 71.9%였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수치만 보면 Redis가 어느 정도 일을 했다. 하지만 naive 패턴의 문제는 여전히 남아 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 입력 중간 상태가 모두 서버로 들어오기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;debounced 입력 패턴 결과&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;&lt;b&gt;값&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;k6 http_reqs&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;1,770&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;p95 latency&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;22.93ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;p99 latency&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;219.35ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;http_req_failed&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;DB search query count&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;501회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;DB search query/request 비율&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;28.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;DB mean_exec_time&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;1.26ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;DB CPU max&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;38%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;Redis hit count&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;1,268&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;Redis miss count&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;501&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;Redis hit ratio&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;71.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50.4651%; text-align: center;&quot;&gt;Redis error count&lt;/td&gt;
&lt;td style=&quot;width: 49.4186%; text-align: center;&quot;&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;naive와 비교하면 다음과 같다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 124px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px; width: 31.9767%;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px; width: 23.8372%;&quot;&gt;&lt;b&gt;naive&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px; width: 22.3255%;&quot;&gt;&lt;b&gt;debounced&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px; width: 21.6279%;&quot;&gt;&lt;b&gt;변화&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 31.9767%;&quot;&gt;k6 http_reqs&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 23.8372%;&quot;&gt;4,946&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 22.3255%;&quot;&gt;1,770&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 21.6279%;&quot;&gt;-64.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 18px; width: 31.9767%;&quot;&gt;p95 latency&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 18px; width: 23.8372%;&quot;&gt;53.8ms&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 18px; width: 22.3255%;&quot;&gt;22.93ms&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 18px; width: 21.6279%;&quot;&gt;-57.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 31.9767%;&quot;&gt;p99 latency&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 23.8372%;&quot;&gt;172.98ms&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 22.3255%;&quot;&gt;219.35ms&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 21.6279%;&quot;&gt;+26.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 31.9767%;&quot;&gt;DB search query count&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 23.8372%;&quot;&gt;1,065&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 22.3255%;&quot;&gt;501&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 21.6279%;&quot;&gt;-53.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 31.9767%;&quot;&gt;DB CPU max&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 23.8372%;&quot;&gt;174.98%&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 22.3255%;&quot;&gt;38%&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 17px; width: 21.6279%;&quot;&gt;-78.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px; width: 31.9767%;&quot;&gt;Redis hit ratio&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px; width: 23.8372%;&quot;&gt;71.9%&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px; width: 22.3255%;&quot;&gt;71.7%&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px; width: 21.6279%;&quot;&gt;거의 동일&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Debounce 적용 후 API 요청 수는 4,946건에서 1,770건으로 줄었다. 요청 수는 64.2% 감소했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 검색 쿼리도 1,065회에서 501회로 줄었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB CPU max는 174.98%에서 38%로 낮아졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 점은 Redis hit ratio가 크게 올라간 것이 아니라는 점이다. naive와 debounced의 Redis hit ratio는 각각 71.9%, 71.7%로 거의 비슷했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 개선의 핵심은 hit ratio 상승이 아니었다. 요청 수 자체를 줄여서 Redis miss와 DB query의 총량을 줄인 것이다. 다만 p99는 오히려 172.98ms에서 219.35ms로 증가했다. p99는 일부 느린 요청의 존재를 보여준다. 평균이나 p95만 보고 끝내면 이런 요청을 놓친다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;정리하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;현실적인 검색 패턴에서는 debounce가 DB 부하를 크게 줄인다.
하지만 p99 문제를 완전히 제거하지는 못한다.
Redis hit ratio만 볼 것이 아니라, miss 경로와 tail latency를 함께 봐야 한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;운영 중 Redis 장애 테스트 결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis가 정상 동작하는 상태에서 debounced 입력 패턴으로 실행했다. 그 다음 테스트 중간에 Redis를 중단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;측정 결과는 다음과 같았다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 49.0698%; text-align: center;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50.814%; text-align: center;&quot;&gt;&lt;b&gt;값&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 49.0698%; text-align: center;&quot;&gt;k6 http_reqs&lt;/td&gt;
&lt;td style=&quot;width: 50.814%; text-align: center;&quot;&gt;1,254&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 49.0698%; text-align: center;&quot;&gt;p95 latency&lt;/td&gt;
&lt;td style=&quot;width: 50.814%; text-align: center;&quot;&gt;2.56s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 49.0698%; text-align: center;&quot;&gt;p99 latency&lt;/td&gt;
&lt;td style=&quot;width: 50.814%; text-align: center;&quot;&gt;3.55s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 49.0698%; text-align: center;&quot;&gt;max latency&lt;/td&gt;
&lt;td style=&quot;width: 50.814%; text-align: center;&quot;&gt;4.26s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 49.0698%; text-align: center;&quot;&gt;http_req_failed&lt;/td&gt;
&lt;td style=&quot;width: 50.814%; text-align: center;&quot;&gt;0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 49.0698%; text-align: center;&quot;&gt;DB search query count&lt;/td&gt;
&lt;td style=&quot;width: 50.814%; text-align: center;&quot;&gt;812회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 49.0698%; text-align: center;&quot;&gt;DB mean_exec_time&lt;/td&gt;
&lt;td style=&quot;width: 50.814%; text-align: center;&quot;&gt;12.05ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 49.0698%; text-align: center;&quot;&gt;DB CPU max&lt;/td&gt;
&lt;td style=&quot;width: 50.814%; text-align: center;&quot;&gt;103.65%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 49.0698%; text-align: center;&quot;&gt;Redis error count&lt;/td&gt;
&lt;td style=&quot;width: 50.814%; text-align: center;&quot;&gt;1,079&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;HTTP 실패율은 0%였다. 즉 Redis 장애가 API 5xx로 직접 전파되지는 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 성능은 크게 흔들렸다. Redis 정상 상태의 debounced 테스트에서는 p95가 22.93ms였다. Redis 장애 시 p95는 2.56초까지 증가했다. 이것은 단순히 DB 쿼리가 느려져서 생긴 문제로 보기 어렵다. DB 검색 쿼리의 평균 실행 시간은 12.05ms였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 tail latency의 상당 부분은 다음 경로에서 발생했을 가능성이 높다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Redis get 실패 대기
&amp;rarr; DB fallback
&amp;rarr; Redis set 실패 대기
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;로그에서도 Redis get/set error가 반복적으로 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lettuce는 Redis가 내려간 뒤에도 재연결을 계속 시도했다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;Cannot reconnect to localhost:6379
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Redis 장애 테스트에서 확인한 것은 다음이다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;질문&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;API가 실패했는가?&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;아니다. http_req_failed=0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Redis 장애 후 tail latency가 튀었는가?&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;그렇다. p95 2.56s, p99 3.55s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;DB가 완전히 병목이 되었는가?&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;DB CPU max 103.65%, DB mean 12.05ms로 DB만의 문제는 아니었다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Redis timeout / 재연결 대기가 영향을 줬는가?&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;가능성이 높다. Redis error 1,079회와 Lettuce reconnect 로그가 반복되었다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;fallback만으로 충분한가?&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;아니다. 실패율은 막았지만 latency는 막지 못했다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;정리하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;현재 구조는 Redis 장애 시 API 실패는 막는다.
하지만 tail latency는 막지 못한다.
fallback만으로는 부족하고, Redis 장애를 감지한 뒤 일정 시간 Redis 호출을 우회하는 circuit breaker가 필요하다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;최종 정리&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;테스트&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;https_reqs&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;p95&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;p99&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;DB query count&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;DB cpu max&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;Redis hit ratio&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;b&gt;비고&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;naive&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;4,946&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;53.8ms&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;172.98ms&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;1,065&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;174.98%&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;71.9%&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;입력마다 요청&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;debounced&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;1,770&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;22.93ms&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;219.35ms&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;501&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;38%&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;71.7%&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;최종 검색어만 요청&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Redis 장애&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;1,254&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;2.56s&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;3.55s&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;812&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;103.65%&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;측정 제외&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;운영 중 Redis 중단&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이번 실험의 결론은 세 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;첫째, 자동완성 검색 API에서 가장 먼저 줄여야 할 것은 DB 쿼리가 아니라 불필요한 입력 이벤트 요청이다. 현실적인 naive 입력 패턴에서는 API 요청이 4,946건 발생했고, DB 검색 쿼리는 1,065회 실행되었다. Debounce 적용 후 API 요청은 1,770건으로 줄었고, DB 검색 쿼리도 501회로 감소했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;둘째, Redis hit ratio만 보면 안 된다. 현실적인 시나리오에서는 naive와 debounced의 Redis hit ratio가 각각 71.9%, 71.7%로 거의 비슷했다. 하지만 요청 수 자체가 줄어들면서 DB query 총량과 DB CPU는 크게 낮아졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;셋째, Redis fallback만으로는 운영 안정성이 충분하지 않다. Redis 장애 시 HTTP 실패율은 0%였지만, p95는 2.56초, p99는 3.55초까지 증가했다. 즉 fallback은 API 실패를 막았지만, tail latency는 막지 못했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;다음 병목 해결 방향&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실험 이후 해결해야 할 병목은 Redis 장애 시 tail latency였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Redis timeout 단축&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis 장애 후 p95가 2.56초까지 튀었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 검색 쿼리 평균 실행 시간은 낮았으므로, 전체 지연의 대부분은 DB 자체보다 Redis 실패 대기와 재연결 영향일 가능성이 높았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Redis command timeout을 짧게 가져가야 한다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;spring:
  data:
    redis:
      timeout: 100ms
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Redis circuit breaker&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis가 죽은 상태에서도 매 요청마다 Redis get/set을 시도하면 fallback은 성공해도 p99는 계속 흔들린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis error가 반복되면 일정 시간 Redis 호출을 생략해야 한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Redis error 반복
&amp;rarr; CircuitBreaker OPEN
&amp;rarr; Redis get/set 우회
&amp;rarr; DB fallback으로 바로 진행
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 Redis 장애를 매 요청마다 다시 확인하지 않는 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실험은 Redis Cache를 붙인 뒤에도 자동완성 API 병목이 사라지지 않는다는 것을 보여준다. Redis는 효과가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Redis보다 먼저 입력 요청 패턴을 줄여야 한다. 그리고 Redis는 장애가 발생하면 또 다른 병목 지점이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>종목 검색 api</category>
      <author>kyoulho</author>
      <guid isPermaLink="true">https://kyoulho.tistory.com/434</guid>
      <comments>https://kyoulho.tistory.com/434#entry434comment</comments>
      <pubDate>Fri, 22 May 2026 14:55:07 +0900</pubDate>
    </item>
    <item>
      <title>[종목 검색 API] 2. Local DB 검색 Baseline 실험</title>
      <link>https://kyoulho.tistory.com/433</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;현재 Repository 쿼리는 대략 다음 구조다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;where s.is_active = true
  and (
    s.search_symbol = :symbol
    or s.search_symbol like concat(:symbol, '%')
    or s.search_name_ko like concat('%', :nameKo, '%')
    or s.search_name_en like concat('%', :nameEn, '%')
  )
order by
  case
    when s.search_symbol = :symbol then 0
    when s.search_symbol like concat(:symbol, '%') then 1
    when s.search_name_ko like concat('%', :nameKo, '%') then 2
    when s.search_name_en like concat('%', :nameEn, '%') then 3
    else 4
  end,
  s.country asc,
  s.market asc,
  s.symbol asc&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 실제 검색에는 다음 요소가 함께 들어간다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;exact 검색&lt;/li&gt;
&lt;li&gt;prefix 검색&lt;/li&gt;
&lt;li&gt;contains 검색&lt;/li&gt;
&lt;li&gt;OR 조건&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ORDER BY CASE&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LIMIT&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;이번 실험의 목적은 단순하다. &lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;LIKE 검색이 실제로 어떤 실행 계획을 타는지 보고, 필요한 인덱스를 결정한다.&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;실험 환경&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL: 17.9&lt;/li&gt;
&lt;li&gt;데이터 수: 300,006건&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 인덱스는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;create index ix_stock_master_search_symbol
    on stock_master (search_symbol);

create index ix_stock_master_search_name_ko
    on stock_master (search_name_ko);

create index ix_stock_master_search_name_en
    on stock_master (search_name_en);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;text-align: center;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;br /&gt;개별 조건 Baseline&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;먼저 실제 API 쿼리를 구성하는 조건을 따로 떼어서 확인했다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.2558%; text-align: center;&quot;&gt;&lt;b&gt;Query&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 11.7442%; text-align: center;&quot;&gt;&lt;b&gt;유형&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&lt;b&gt;Plan&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&lt;b&gt;Rows&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&lt;b&gt;Time&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.2558%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #e6f5ff; color: #333333; text-align: start;&quot;&gt;search_symbol = 'AAPL'&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 11.7442%; text-align: center;&quot;&gt;exact&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;Index Scan&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;0.122 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.2558%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #e6f5ff; color: #333333; text-align: start;&quot;&gt;search_symbol LIKE 'AAP%'&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 11.7442%; text-align: center;&quot;&gt;prefix&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;Parallel Seq Scan&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;71.175 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.2558%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #e6f5ff; color: #333333; text-align: start;&quot;&gt;search_name_ko LIKE '%삼성%'&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 11.7442%; text-align: center;&quot;&gt;contains&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;Seq Scan&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;120,001&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;123.354 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;exact 검색&lt;/h4&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT *
FROM stock_master
WHERE search_symbol = 'AAPL';&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Index Scan using ix_stock_master_search_symbol
Index Cond: search_symbol = 'AAPL'
Buffers: shared hit=2 read=2
Execution Time: 0.122 ms&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확 검색은 기존 B-Tree Index를 정상적으로 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 Index Scan이라고 해서 index만 읽는 것은 아니다. PostgreSQL은 index에서 row 위치를 찾은 뒤 실제 row는 heap에서 다시 읽는다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;prefix 검색&lt;/h4&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SELECT *
FROM stock_master
WHERE search_symbol LIKE 'AAP%';&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;Gather
  Workers Planned: 2
  Workers Launched: 2
  -&amp;gt; Parallel Seq Scan on stock_master
       Filter: search_symbol LIKE 'AAP%'
       Rows Removed by Filter: 100002
Execution Time: 71.175 ms&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prefix 검색이라 B-Tree Index를 사용할 것으로 예상했다. 하지만 실제로는 Parallel Seq Scan이 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문자열은 단순 바이트 순서가 아니라 collation 규칙을 따른다. 그래서 PostgreSQL은 일반 B-Tree Index만으로 &lt;code&gt;LIKE 'prefix%'&lt;/code&gt; 범위를 안전하게 좁히기 어렵다고 판단할 수 있다. 이때 검토할 수 있는 것이 &lt;code&gt;text_pattern_ops&lt;/code&gt;다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;contains 검색&lt;/h4&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SELECT *
FROM stock_master
WHERE search_name_ko LIKE '%삼성%';&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Seq Scan on stock_master
  Filter: search_name_ko LIKE '%삼성%'
  Rows Removed by Filter: 180005
Execution Time: 123.354 ms&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;LIKE '%삼성%'&lt;/code&gt;는 앞부분이 &lt;code&gt;%&lt;/code&gt;로 열려 있다. B-Tree는 정렬된 구조이기 때문에 시작 지점을 잡아야 빠르게 찾을 수 있다. contains 검색은 시작 지점이 없으므로 기존 B-Tree Index와 맞지 않는다.&lt;/p&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;prefix 검색 개선: text_pattern_ops&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prefix 검색을 위해 인덱스를 추가했다. 적용 후 실행 계획이 바뀌었다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;CREATE INDEX ix_stock_master_search_symbol_pattern
ON stock_master (search_symbol text_pattern_ops);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Bitmap Heap Scan on stock_master
  Heap Blocks: exact=1
  -&amp;gt; Bitmap Index Scan on ix_stock_master_search_symbol_pattern
       Index Cond: search_symbol &amp;gt;= 'AAP' AND search_symbol &amp;lt; 'AAQ'
Execution Time: 0.047 ms&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 24.2635%; text-align: center;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 36.7055%; text-align: center;&quot;&gt;&lt;b&gt;개선 전&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 39.0309%; text-align: center;&quot;&gt;&lt;b&gt;개선 후&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 24.2635%; text-align: center;&quot;&gt;Plan&lt;/td&gt;
&lt;td style=&quot;width: 36.7055%; text-align: center;&quot;&gt;Parallel Seq Scan&lt;/td&gt;
&lt;td style=&quot;width: 39.0309%; text-align: center;&quot;&gt;Bitmap Heap Scan + Bitmap Index Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 24.2635%; text-align: center;&quot;&gt;Execution Time&lt;/td&gt;
&lt;td style=&quot;width: 36.7055%; text-align: center;&quot;&gt;71.175 ms&lt;/td&gt;
&lt;td style=&quot;width: 39.0309%; text-align: center;&quot;&gt;0.047 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 24.2635%; text-align: center;&quot;&gt;Buffers&lt;/td&gt;
&lt;td style=&quot;width: 36.7055%; text-align: center;&quot;&gt;hit=6511 read=2760&lt;/td&gt;
&lt;td style=&quot;width: 39.0309%; text-align: center;&quot;&gt;hit=4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;PostgreSQL은 &lt;code&gt;LIKE 'AAP%'&lt;/code&gt;를 &lt;code&gt;AAP 이상, AAQ 미만&lt;/code&gt;의 범위 탐색으로 바꿨다. 즉 &lt;code&gt;text_pattern_ops&lt;/code&gt; 적용 후 prefix 검색이 B-Tree 범위 탐색으로 처리되었다.&lt;/p&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;contains 검색 개선: pg_trgm + GIN Index&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;contains 검색을 위해 &lt;code&gt;pg_trgm + GIN Index&lt;/code&gt;를 적용했다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX ix_stock_master_search_name_ko_trgm
ON stock_master
USING gin (search_name_ko gin_trgm_ops);

CREATE INDEX ix_stock_master_search_name_en_trgm
ON stock_master
USING gin (search_name_en gin_trgm_ops);&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 70px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 19px; text-align: center;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px; text-align: center;&quot;&gt;개선 전&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px; text-align: center;&quot;&gt;개선 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;Plan&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;Seq Scan&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;Seq Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;Rows&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;120,001&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;120,001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;Execution Time&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;123.354 ms&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;70.333 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;br /&gt;처음에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;%삼성%&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;검색도 GIN Index를 사용할 것으로 예상했다. 하지만 결과는 여전히 Seq Scan이었다. &lt;/span&gt;이유는 결과 row가 너무 많기 때문이다. 전체 300,006건 중 120,001건이 &lt;code&gt;%삼성%&lt;/code&gt; 조건에 걸렸다.&lt;br /&gt;&lt;br /&gt;Planner는 &quot;GIN Index를 타더라도 결국 12만 row를 heap에서 다시 읽어야 한다. 그렇다면 차라리 Seq Scan이 더 싸다.&quot; 고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉 &lt;code&gt;pg_trgm + GIN Index&lt;/code&gt;를 만든다고 contains 검색이 무조건 index scan으로 바뀌는 것은 아니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;planner는 statistics를 기반으로 예상 row 수를 계산하고, index 탐색 비용과 heap 접근 비용을 비교해서 실행 계획을 선택한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;pg_trgm이 실제로 사용되는 경우&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색어를 &lt;code&gt;%레버리지%&lt;/code&gt;로 바꿔 확인했다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SELECT *
FROM stock_master
WHERE search_name_ko LIKE '%레버리지%';&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;Bitmap Heap Scan on stock_master
  Recheck Cond: search_name_ko LIKE '%레버리지%'
  Heap Blocks: exact=9271
  -&amp;gt; Bitmap Index Scan on ix_stock_master_search_name_ko_trgm
       Index Cond: search_name_ko LIKE '%레버리지%'
Execution Time: 53.795 ms&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;b&gt;Query&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.907%; text-align: center;&quot;&gt;&lt;b&gt;Plan&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 19.8837%; text-align: center;&quot;&gt;&lt;b&gt;Rows&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 17.2093%; text-align: center;&quot;&gt;&lt;b&gt;Time&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #e6f5ff; color: #333333; text-align: start;&quot;&gt;LIKE '%삼성%'&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.907%; text-align: center;&quot;&gt;Seq Scan&lt;/td&gt;
&lt;td style=&quot;width: 19.8837%; text-align: center;&quot;&gt;120,001&lt;/td&gt;
&lt;td style=&quot;width: 17.2093%; text-align: center;&quot;&gt;70.333 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #e6f5ff; color: #333333; text-align: start;&quot;&gt;LIKE '%레버리지%'&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.907%; text-align: center;&quot;&gt;Bitmap Heap Scan + Bitmap Index Scan&lt;/td&gt;
&lt;td style=&quot;width: 19.8837%; text-align: center;&quot;&gt;60,000&lt;/td&gt;
&lt;td style=&quot;width: 17.2093%; text-align: center;&quot;&gt;53.795&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 GIN Index가 있어도 planner의 선택은 달라질 수 있다. &lt;b&gt;핵심은 index 존재 여부가 아니라 검색 조건의 선택도다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 style=&quot;text-align: center;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;br /&gt;실제 Repository 쿼리 기준 측정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;개별 조건 실험은 부품 테스트에 가깝다. 실제 API 쿼리는 여러 조건이 OR로 묶이고, 결과를 다시 &lt;code&gt;ORDER BY CASE&lt;/code&gt;로 정렬한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Case 1. keyword = '삼성'&lt;/h4&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;Limit
  -&amp;gt; Gather Merge
       -&amp;gt; Sort
            Sort Method: top-N heapsort
            -&amp;gt; Parallel Seq Scan on stock_master
                 Filter: is_active AND (...)
                 Rows Removed by Filter: 60002
Execution Time: 125.647 ms&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 32.1705%; text-align: center;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.4961%; text-align: center;&quot;&gt;&lt;b&gt;값&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 32.1705%; text-align: center;&quot;&gt;Top Plan&lt;/td&gt;
&lt;td style=&quot;width: 34.4961%; text-align: center;&quot;&gt;Limit &amp;rarr; Gather Merge &amp;rarr; Sort &amp;rarr; Parallel Seq Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 32.1705%; text-align: center;&quot;&gt;Index&lt;/td&gt;
&lt;td style=&quot;width: 34.4961%; text-align: center;&quot;&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 32.1705%; text-align: center;&quot;&gt;OR 처리&lt;/td&gt;
&lt;td style=&quot;width: 34.4961%; text-align: center;&quot;&gt;Filter에서 직접 평가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 32.1705%; text-align: center;&quot;&gt;Sort&lt;/td&gt;
&lt;td style=&quot;width: 34.4961%; text-align: center;&quot;&gt;top-N heapsort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 32.1705%; text-align: center;&quot;&gt;Rows&lt;/td&gt;
&lt;td style=&quot;width: 34.4961%; text-align: center;&quot;&gt;40,000 &amp;times; 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 32.1705%; text-align: center;&quot;&gt;Execution Time&lt;/td&gt;
&lt;td style=&quot;width: 34.4961%; text-align: center;&quot;&gt;125.647 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;code&gt;삼성&lt;/code&gt;은 결과 row가 너무 많았다. planner는 OR 조건을 BitmapOr로 조합하지 않고 Parallel Seq Scan을 선택했다. &lt;code&gt;LIMIT 10&lt;/code&gt;이 있어도 &lt;code&gt;ORDER BY CASE&lt;/code&gt; 때문에 정렬 단계는 필요했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;Case 2. keyword = '레버리지'&lt;/h4&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;Limit
  -&amp;gt; Gather Merge
       -&amp;gt; Sort
            Sort Method: top-N heapsort
            -&amp;gt; Parallel Bitmap Heap Scan on stock_master
                 -&amp;gt; BitmapOr
                      -&amp;gt; Bitmap Index Scan on ix_stock_master_search_symbol_pattern
                      -&amp;gt; Bitmap Index Scan on ix_stock_master_search_name_ko_trgm
                      -&amp;gt; Bitmap Index Scan on ix_stock_master_search_name_en_trgm
Execution Time: 76.863 ms&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 106px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 19px;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 19px;&quot;&gt;&lt;b&gt;값&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 17px;&quot;&gt;Top Plan&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 17px;&quot;&gt;Limit &amp;rarr; Gather Merge &amp;rarr; Sort &amp;rarr; Parallel Bitmap Heap Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 19px;&quot;&gt;OR 처리&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 19px;&quot;&gt;BitmapOr&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 17px;&quot;&gt;Index&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 17px;&quot;&gt;symbol pattern, name_ko trgm, name_en trgm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 17px;&quot;&gt;Rows&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 17px;&quot;&gt;20,000 &amp;times; 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 17px;&quot;&gt;Execution Time&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 17px;&quot;&gt;76.863 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;레버리지&lt;/code&gt; 케이스에서는 planner가 OR 조건을 BitmapOr로 조합했다. 특히 &lt;code&gt;search_name_ko LIKE '%레버리지%'&lt;/code&gt; 조건에서 &lt;code&gt;ix_stock_master_search_name_ko_trgm&lt;/code&gt; 인덱스를 사용했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;Case 3. keyword = 'AAPL'&lt;/h4&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;Limit
  -&amp;gt; Sort
       Sort Method: quicksort
       -&amp;gt; Bitmap Heap Scan on stock_master
            Heap Blocks: exact=1
            -&amp;gt; BitmapOr
                 -&amp;gt; Bitmap Index Scan on ix_stock_master_search_symbol_pattern
                 -&amp;gt; Bitmap Index Scan on ix_stock_master_search_name_ko_trgm
                 -&amp;gt; Bitmap Index Scan on ix_stock_master_search_name_en_trgm
Execution Time: 2.566 ms&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 123px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;값&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;Top Plan&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;Limit &amp;rarr; Sort &amp;rarr; Bitmap Heap Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 19px; text-align: center;&quot;&gt;OR 처리&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 19px; text-align: center;&quot;&gt;BitmapOr&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;Index&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;symbol pattern, name_ko trgm, name_en trgm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;Rows&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;Heap Blocks&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;exact=1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;Execution Time&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;2.566 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AAPL&lt;/code&gt;은 선택도가 높았다. planner는 OR 조건을 BitmapOr로 조합했고, 실제 heap 접근도 1 block 수준으로 끝났다. 정렬은 발생했지만 대상 row가 1건뿐이라 비용은 거의 없었다.&lt;/p&gt;
&lt;h3 style=&quot;text-align: center;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;br /&gt;실제 API 쿼리 결과 정리&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 65px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style15&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 14.4186%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;keyword&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;Top Plan&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;OR 처리&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;Rows&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;Time&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 12px;&quot;&gt;
&lt;td style=&quot;width: 14.4186%; height: 12px; text-align: center;&quot;&gt;삼성&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 12px; text-align: center;&quot;&gt;Parallel Seq Scan&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 12px; text-align: center;&quot;&gt;Filter에서 직접 평가&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 12px; text-align: center;&quot;&gt;40,000 &amp;times; 3&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 12px; text-align: center;&quot;&gt;125.647 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 14.4186%; height: 17px; text-align: center;&quot;&gt;레버리지&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 17px; text-align: center;&quot;&gt;Parallel Bitmap Heap Scan&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 17px; text-align: center;&quot;&gt;BitmapOr&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 17px; text-align: center;&quot;&gt;20,000 &amp;times; 3&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 17px; text-align: center;&quot;&gt;76.863 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 14.4186%; height: 17px; text-align: center;&quot;&gt;AAPL&lt;/td&gt;
&lt;td style=&quot;width: 25.5814%; height: 17px; text-align: center;&quot;&gt;Bitmap Heap Scan&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 17px; text-align: center;&quot;&gt;BitmapOr&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 17px; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 17px; text-align: center;&quot;&gt;2.566 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;검색어의 선택도가 좋아질수록 planner는 인덱스를 더 적극적으로 사용한다. 반대로 결과 row가 너무 많은 검색어는 인덱스가 있어도 Seq Scan으로 갈 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;그리고 &lt;code&gt;ORDER BY CASE&lt;/code&gt; 때문에 Sort는 계속 발생한다. &lt;code&gt;LIMIT 10&lt;/code&gt;은 정렬 비용을 줄여주지만, 후보 row를 찾고 정렬하는 단계 자체를 없애지는 못한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;text-align: center;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;br /&gt;최종 적용할 인덱스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실험 기준으로 유지할 인덱스는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- exact 검색용
CREATE INDEX ix_stock_master_search_symbol
ON stock_master (search_symbol);

-- prefix 검색용
CREATE INDEX ix_stock_master_search_symbol_pattern
ON stock_master (search_symbol text_pattern_ops);

-- contains 검색용
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX ix_stock_master_search_name_ko_trgm
ON stock_master
USING gin (search_name_ko gin_trgm_ops);

CREATE INDEX ix_stock_master_search_name_en_trgm
ON stock_master
USING gin (search_name_en gin_trgm_ops);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 인덱스를 만든다고 항상 사용되는 것은 아니다. planner는 검색어의 선택도, 예상 row 수, heap 접근 비용을 함께 계산한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 단계에서는 이 실제 API 쿼리를 기준으로 k6 부하 테스트를 수행한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;p95 latency&lt;/li&gt;
&lt;li&gt;p99 latency&lt;/li&gt;
&lt;li&gt;DB CPU 사용률&lt;/li&gt;
&lt;li&gt;DB shared hit/read 변화&lt;/li&gt;
&lt;li&gt;검색어별 응답 시간 차이&lt;/li&gt;
&lt;li&gt;동일 검색어 반복 시 Redis Cache 적용 효과&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>종목 검색 api</category>
      <author>kyoulho</author>
      <guid isPermaLink="true">https://kyoulho.tistory.com/433</guid>
      <comments>https://kyoulho.tistory.com/433#entry433comment</comments>
      <pubDate>Thu, 21 May 2026 19:48:04 +0900</pubDate>
    </item>
    <item>
      <title>[종목 검색 API] 1. 자동완성 검색 API는 어떻게 서버를 터뜨리는가</title>
      <link>https://kyoulho.tistory.com/432</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;PWM 서비스를 만들면서 종목 검색 API를 구현했다. 처음에는 단순 조회 API라고 생각했다. 하지만 자동완성 UI를 붙이는 순간 검색 API의 성격이 달라진다. 사용자는 검색창에 한 번 입력하지만, 서버는 여러 번 요청을 받는다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;삼
삼성
삼성전
삼성전자&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;입력이 바뀔 때마다 API를 호출하면 검색 한 번이 여러 요청으로 증폭된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;종목 조회, 포트폴리오 편입, 관심 종목, 차트 조회, 지수 비교가 모두 검색에서 시작된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 종목 검색 API는 단순 부가 기능이 아니라 트래픽이 몰릴 수 있는 지점이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;현재 구조&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 검색은 PostgreSQL의 local master DB를 먼저 조회한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과가 충분하면 바로 반환하고, 결과가 없으면 외부 Provider를 호출하는 구조를 고려하고 있다.&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;Local DB 검색
&amp;rarr; 결과 있으면 반환
&amp;rarr; 결과 없으면 KIS / Yahoo 같은 외부 Provider 호출&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;병목도 두 단계로 나뉜다. 첫 번째는 Local DB 검색 병목이다. 두 번째는 외부 Provider 호출 병목이다. 이번 글에서는 첫 번째 병목만 본다. 외부 Provider는 그다음 문제다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;문제 지점&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 검색 쿼리는 대략 이런 형태다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;where is_active = true
  and (
    search_symbol = :symbol
    or search_symbol like :symbol || '%'
    or search_name_ko like '%' || :keyword || '%'
    or search_name_en like '%' || :keyword || '%'
  )&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;search_symbol = ?&lt;span&gt; 는 괜찮다. &lt;/span&gt;search_symbol like 'AAP%'도문제는 이쪽이다.&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;search_name_ko like '%삼성%'
search_name_en like '%tesla%'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;contains 검색이다. 일반적인 B-Tree Index는 이런 검색에 적합하지 않다. B-Tree는 정렬된 구조라서 시작 지점이 명확한 검색에 강하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 like 'AAP%'는AAP&lt;span&gt;로 시작하는 위치를 찾고 그 이후를 보면 된다. &lt;/span&gt;하지만 like '% AAP%'는 앞부분이 고정되어 있지 않다.&lt;br /&gt;어디에 AAP가 들어있는지 알 수 없기 때문에 많은 row를 직접 검사할 가능성이 높다. 자동완성과 결합되면 더 위험하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;입력
&amp;rarr; API 호출
&amp;rarr; LIKE '%keyword%' 검색
&amp;rarr; 반복&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;개발 환경에서는 잘 안 보인다. 데이터도 적고 동시 요청도 거의 없기 때문이다. 하지만 데이터가 늘고 요청이 반복되면 Local DB가 첫 번째 &lt;br /&gt;병목이 될 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;실행 계획 확인&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;감으로 판단하면 안 된다. 일단 실행 계획을 봐야 한다. 예를 들어 다음 쿼리를 확인한다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;EXPLAIN ANALYZE
SELECT *
FROM stock_master
WHERE is_active = true
  AND search_name_ko LIKE '%a%'
LIMIT 10;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기서 봐야 할 것은 단순하다.&lt;/p&gt;
&lt;pre class=&quot;fortran&quot;&gt;&lt;code&gt;Seq Scan이 발생하는가?
Index Scan이 발생하는가?
실제 실행 시간은 얼마인가?
읽은 row는 얼마나 되는가?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;contains 검색에서 Seq Scan이 발생하면 예상했던 문제다. PostgreSQL이 인덱스로 바로 좁히지 못하고 많은 데이터를 검사하고 있다는 의미다. 물론 데이터가 적으면 실행 시간 자체는 짧게 나올 수 있다. 하지만 중요한 건 현재 시간이 아니라 증가 방향이다. 데이터가 늘고 자동완성 요청이 반복되면 이 비용은 계속 커진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;&lt;br /&gt;이번 시리즈의 방향&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 바로 Redis를 붙이지 않을 생각이다. Redis를 먼저 붙이면 DB 병목이 가려진다. 캐시는 느린 쿼리를 숨길 수는 있지만, 쿼리 자체를 개선하지는 못한다. 먼저 Local DB 검색 병목을 확인하고 해결한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진행 순서는 이렇게 잡았다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;1. 현재 검색 쿼리 실행 계획 확인
2. API 부하 테스트로 병목 확인
3. pg_trgm + GIN Index 적용
4. 실행 계획과 응답 시간 재측정
5. 더 높은 부하에서 다시 테스트
6. Redis Cache 적용
7. 반복 요청 감소 효과 측정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉 순서는 이렇다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;DB 검색 최적화
&amp;rarr; API 부하 확인
&amp;rarr; Cache 적용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;먼저 DB가 기본기를 갖춰야 한다. 그다음에 Redis로 반복 요청을 줄이는 게 맞다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;다음 글&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 실제 검색 쿼리의 실행 계획을 확인한다. 목표는 하나다. LIKE '% keyword%' 검색이 실제로 어떤 실행 계획을 타는지 확인하는 것. Seq Scan이 발생한다면 그 이유를 보고, 이후 pg_trgm + GIN Index로 어떻게 바뀌는지 비교할 예정이다.&lt;/p&gt;</description>
      <category>종목 검색 api</category>
      <author>kyoulho</author>
      <guid isPermaLink="true">https://kyoulho.tistory.com/432</guid>
      <comments>https://kyoulho.tistory.com/432#entry432comment</comments>
      <pubDate>Wed, 20 May 2026 18:56:05 +0900</pubDate>
    </item>
    <item>
      <title>유동성의 진화: 가격의 시대에서 '접근권'의 시대로</title>
      <link>https://kyoulho.tistory.com/431</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;시장의 유동성을 판단하는 기준점이 이동하고 있다. 과거의 질서에서 유동성은 연준(Fed)이라는 단일 공급원이 금리라는 가격 조절 장치를 통해 전 세계에 흘려보내는 공공재에 가까웠다. 그러나 탈세계화라는 거대한 흐름 속에서 이 공급 체계 자체가 전략적 자산으로 변화하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 붕괴되는 구질서: 달러 패권 유지를 위한 인위적 환경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거 15년은 금리가 곧 유동성을 결정하는 시대였다. 이 구질서는 미국이 달러 패권을 공고히 하고 세계화의 이득을 극대화하기 위해 설계한 '유동성 무한 공급' 체계였다. 연준은 QE와 달러 스왑라인(Swap Lines)을 동원해 글로벌 소방수를 자처했으나, 이는 본질적으로 달러 시스템의 붕괴를 막아 자국의 지배력을 유지하기 위한 선택이었다. 시장 참여자들은 미국이 자국 이익을 위해 만들어놓은 이 인위적인 디폴트 세팅값 위에서 사고했다. 하지만 이제 미국이 다시 자국 우선주의를 내걸고 세계화의 판을 깨기 시작하면서, 유동성은 '보편적 복지'에서 '전략적 통제'로 이동하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 케빈 워시의 비판: QE의 역설과 서민의 고통&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연준의 새로운 의장 케빈 워시는 구질서의 핵심 기제였던 QE에 대해 매우 냉소적이었다. 그는 과거 QE가 자산 가격을 띄워 금융권(Wall Street)에는 막대한 수익을 안겨주었지만, 그 결과로 초래된 고물가와 이를 잡기 위한 고금리는 결국 실물 경제의 서민(Main Street)들에게 고통을 전가했다고 지적한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그의 지명은 연준의 정책 우선순위가 바뀔 것임을 시사한다. 단순히 자산 시장을 부양하기 위한 유동성 공급은 억제될 것이며, 달러 공급은 미국의 국가 전략과 실물 경제의 건전성을 위해 철저히 계산된 방식으로 이루어질 것이다. 유동성의 가격보다 '누가 이 라인에 접속할 자격이 있는가'라는 문제가 부각되는 이유다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 유로 레포라인(EUREP)의 강화: 달러가 막힐 때를 대비한 '유로 벙커'&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유럽중앙은행(ECB)이 EUREP(유로 레포 라인)를 강화한 것은 연준의 달러 스왑라인이 더 이상 '상수'가 아니라는 신호다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EUREP는 유럽 이외의 중앙은행들이 보유한 유로화 표시 국채를 담보로 ECB에서 유로화를 빌려가는 장치다. 이게 왜 중요할까? 시장에 달러가 마르면, 달러가 급한 국가들은 현금을 만들기 위해 자기들이 들고 있던 유로화 국채부터 시장에 던지게 된다. 그러면 유로화 자산 가치가 폭락하고 유럽 금융 시스템까지 도미노처럼 무너진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EUREP는 이때 국채를 '매도'하는 대신 ECB에 '담보'로 맡기고 유로화를 빌려가게 함으로써, 시장의 투매를 막는 일종의 방어막 역할을 한다. &lt;b&gt;달러 부족을 직접 해결해주지는 못하지만, 달러가 막혔을 때 유로 자산이 함께 타버리는 연쇄 반응을 끊어내겠다는 계산이다.&lt;/b&gt; '거래적 동맹' 시대에 연준의 선의를 기다리는 대신, 스스로 방어벽을 쌓기 시작한 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 탈세계화의 시그널: SWIFT 탈피와 금 비축&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;탈세계화의 흐름은 이제 가격 데이터에서 확인된다. 특히 달러 중심의 결제망(SWIFT)이 전략적 무기로 활용되면서, 이에 대한 의존도를 낮추려는 움직임이 가속화되고 있다. 중국이 지속적으로 금 매입을 늘리며 외환보유고 체질을 바꾸는 것은 달러 단일 체제에서 이탈하려는 실질적인 리스크 헤지다. 위안화의 강세와 원자재 가격의 버티기는 단순히 공급망 분절을 넘어, 가치 저장의 기준점 자체가 다극화되고 있음을 보여준다. 자본은 더 이상 달러라는 단일 시스템에 올인하지 않고 대안을 찾는 중이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 정치적 가변성과 구조적 변곡점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트럼프 행정부의 정책 기조가 시장을 흔들고 있지만 정치적 유효 수명도 고려할 필요가 있다. 트럼프의 지지율 하락과 다가오는 중간선거에서의 패배 가능성, 그리고 그로 인한 레임덕이 발생한다면 &lt;b&gt;시장은 다시 '과거의 세계화'로 돌아갈 수 있을까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 세계화의 종말을 전제한 여러 국가의 구조적 변화는 되돌리기 힘든 임계점을 넘었을 가능성이 크다. 동맹의 해체와 각자도생의 정책적 선회는 이미 글로벌 금융 질서를 바꾸어 놓았다. 일시적인 정치적 풍향에 따라 과거로의 회귀를 기대하기보다는, 이미 시작된 이 비대칭적 유동성 구조와 각국의 대응이 지속될지 여부를 주시하며 포지션을 적절히 옮기는 유연함이 필요한 시점이다.&lt;/p&gt;</description>
      <category>금융</category>
      <author>kyoulho</author>
      <guid isPermaLink="true">https://kyoulho.tistory.com/431</guid>
      <comments>https://kyoulho.tistory.com/431#entry431comment</comments>
      <pubDate>Thu, 19 Feb 2026 18:14:47 +0900</pubDate>
    </item>
    <item>
      <title>IS-LM 곡선으로 경험한 경제학</title>
      <link>https://kyoulho.tistory.com/430</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;투자자산운용사 공부를 하면서 가장 이해하기 힘들었던 건 IS-LM 곡선이었다. 특히 &amp;ldquo;실질 국민소득(Y)이 증가하면 이자율(R)이 증가한다&quot;는 LM 곡선의 원리는 내 직관에 맞지 않는 것 같았다. 소득이 늘면 시중에 돈이 많아졌으니 돈의 가치가 내려가야 하는 것 아닌가? LM 곡선을 이해하려고 제미나이와 씨름하다 보니, &lt;b&gt;경제학에서 말하는 '소득'이 일반적인 의미의 수입이 아니라는 걸 깨달았다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;1. 변수 Y의 재정의: 소득은 수입이 아니다.&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;우리가 일상에서 쓰는 '소득'은 내 지갑에 들어오는 현금을 떠올리게 한다. 하지만 경제학에서 Y를 소득(Income)이라 부르는 건 국가 전체가 만든 가치가 결국 국민 개개인에게 분배되기 때문일 뿐이다. 공부할 때는 이를 '물리적인 생산량'이나 '거래 규모(Volume)'로 해석하는 게 훨씬 명확하다. 이 관점으로 보면 왜 Y가 늘 때 R이 오르는지 비로소 이해가 간다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;이자율과 생산량의 상관관계 분석
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;생산량 증가:&amp;nbsp;나라 전체에서 사고파는 물건과 서비스의 양이 많아진다. 즉, 경제의 활동 부피가 커진다.&lt;/li&gt;
&lt;li&gt;결제 자금 수요 폭증:&amp;nbsp;거래 규모가 커지면 결제를 위해 손에 쥐고 있어야 할 '현금'이 더 많이 필요해진다. &lt;s&gt;이게 거래적 수요였군요. 케인즈 선생님&lt;/s&gt;&lt;/li&gt;
&lt;li&gt;돈의 희소성 발생:&amp;nbsp;하지만 중앙은행이 시중에 푼 돈의 양(M)은 일정하게 고정되어 있다.&lt;/li&gt;
&lt;li&gt;이자율 상승:&amp;nbsp;시중에 돈은 한정적인데, 거래를 위해 돈을 쓰려는 주체들이 많아진다. 결국 한정된 자원을 선점하려는 경쟁이 붙으면서 돈을 빌리는 가격인&amp;nbsp;이자율이 상승하게 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;결국 &quot;소득이 늘면 금리가 내려가야 한다&quot;는 생각은 '소득'이라는 단어의 뉘앙스에 속아 중앙은행이 돈을 같이 풀어준 상황과 혼동한 결과다. 공급이 고정된 상태에서 경제의 덩치(Y)만 커지면, 돈은 오히려 귀한 대접을 받으며 몸값인 금리를 올리게 된다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;2. 생산량이 곧 소득?? 그러면 GDP는?&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;실질 GDP는 결국 우리 경제가 1년 동안 만들어낸 '물리적 생산량'의 합계다. 그런데 왜 단순히 많이 만드는 것(Y)이 우리의 실제 풍요로움이나 소득으로 곧바로 이어지지 않는 걸까? 우리는 왜 이 숫자의 증감에 그토록 집착하고 있는 걸까?&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;경제학의 전제에는 '재고'라는 함정이 숨어 있다. 회계 원칙상 팔리지 않은 물건이 창고에 쌓여도, 이는 기업이 스스로의 물건을 구매한 '재고투자'로 처리되어 GDP 수치를 올려버린다. 즉, 의도치 않은 재고가 쌓일수록 지표상 GDP는 화려하게 상승하지만, 실제 경제는 속에서부터 멍들고 있는 셈이다. 이것이 바로 수요 없는 생산이 만드는 '가짜 GDP'의 실체다. 과거 중국의 유령 도시에 수요도 없는 아파트를 지어 올려서 GDP 수치를 왜곡하기도 했다. 하지만, 그것은 국민의 실제 삶을 개선하는 질 좋은 성장이 아니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;최근 시진핑 중국 국가 주석이 무리한 수치 위주의 성장을 강하게 비판한 것도 같은 맥락이다. 2025년 12월 15일 보도된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://www.straitstimes.com/asia/east-asia/chinas-xi-warns-officials-against-chasing-reckless-gdp-expansion&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;China's Xi warns officials against chasing 'reckless' GDP expansion&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;기사에 따르면, 그는 '무모한 GDP 확장'과 통계 조작을 당장 멈추라고 경고했다. 이는 중국 정부가 보여주기식 GDP의 한계를 깨닫고, 경제 시스템을 근본적으로 리팩터링 하겠다는 선언과도 같다. 결국 중국조차 성장의 '질'을 따지는 방향으로 선회했다는 점은, 투자자 입장에서 가짜 지표 뒤에 숨겨진 리스크를 걸러낼 새로운 필터가 생겼음을 의미한다. &amp;nbsp;&lt;s&gt;과거의 자신을 비판하는 남자&amp;hellip; 멋있어&amp;hellip;&lt;/s&gt;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;br /&gt;3. 소비 함수 C(Y-T): 인간은 감정이 있어요&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;IS 곡선을 구성하는 핵심 요소인 소비 함수 C(Y-T)를 보고 나는 솔직히 많이 놀랐다. &lt;s&gt;먹고 싸는 기계라고 적혀있는 걸 본 기분이랄까?&lt;/s&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;세금(T)을 제외한 소득(Y)이 늘어나면 정해진 비율만큼 소비(C)가 증가한다는 이 공식은 인간의 복잡한 심리 기제를 완전히 무시한다. 경제학 공식 안에서 인간은 소득이라는 데이터가 입력되면 정해진 수식에 따라 소비를 출력하는 단순한 계산기처럼 묘사된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;하지만 실제 인간의 소비 행태는 미래에 대한 불안감, 타인과의 비교를 통한 과시욕, 혹은 평생에 걸친 자산 계획에 따라 움직인다. 당장 오늘의 소득이 늘어났다고 해도 내일이 불안하다면 인간은 결코 소비를 늘리지 않는다. 경제학은 소득 증가가 다시 소비로 이어지는 선순환을 강조하지만, 인간의 심리라는 변수가 개입하는 순간 이러한 공식은 작동을 멈춘다. 인간의 경제 활동은 숫자보다 심리에 더 큰 영향을 받는다는 사실을 간과해서는 안 된다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;br /&gt;4. 결론&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;지금까지 살펴본 경제학 모델들은 현실을 담아내기에 지나치게 기계적이고 단순하다. 재고를 투자로 간주하거나 인간의 심리를 배제하는 관점은 분명 실제 삶과 큰 괴리가 존재한다. 하지만 이러한 '허상'일지도 모르는 모델을 공부해야 하는 이유는 명확하다.&lt;b&gt; 우리가 마주하는 거대한 경제 시스템이 바로 이러한 논리 체계를 바탕으로 설계되고 운영되기 때문이다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;전 세계의 주요 경제 주체들이 이 모델을 신뢰하고 그에 따라 의사결정을 내린다면, 그것은 설령 이론적 한계가 있을지라도 현실을 움직이는 가장 강력한 실체가 된다. 그러니 현실과의 괴리에 냉소하기보다는, 그 로직이 시장을 어떻게 지배하고 있는지를 철저히 파악하는 것이 우선이다. 이론이 주는 찝찝함은 잠시 접어두고&amp;hellip; 일단 공부하자.&lt;/p&gt;</description>
      <category>금융</category>
      <author>kyoulho</author>
      <guid isPermaLink="true">https://kyoulho.tistory.com/430</guid>
      <comments>https://kyoulho.tistory.com/430#entry430comment</comments>
      <pubDate>Sat, 31 Jan 2026 19:37:48 +0900</pubDate>
    </item>
    <item>
      <title>현금은 태생이 쓰레기였다</title>
      <link>https://kyoulho.tistory.com/429</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;유튜브를 보다 보면 심심치 않게 마주치는 영상이 있다.&lt;br /&gt;5년 뒤면 네 돈은 휴지 조각 된다, 초인플레이션의 서막, 당장 이 자산으로 도망쳐라 같은 빨간 글씨와 자극적인 배경음악.&lt;br /&gt;사실 이런 영상들을 본다고 해서 심장이 덜컥 내려앉지는 않는다.&lt;br /&gt;다만, 이런 공포가 대중에게 소비되는 방식과 그 이면에 숨겨진 논리적 빈틈이 흥미로울 뿐이다.&lt;br /&gt;정말로 내 돈의 가치가 조금씩 떨어지는 게 세상에서 가장 무서운 일일까?&lt;br /&gt;&lt;b&gt;경제의 구조를 뜯어보면, 진짜 비극은 돈이 귀중품이 되었을 때 시작된다는 것을 알 수 있다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;돈의 어원: 돌고 돌아야 생명이 유지된다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 흔히 화폐를 돈이라고 부른다.&lt;br /&gt;이 말의 어원을 찾아가 보면 흥미로운 지점이 있다.&lt;br /&gt;돈은 &quot;돌고 돈다&quot;는 말에서 유래했다는 설이 지배적이다.&lt;br /&gt;즉, 돈의 본질은 소유나 축적이 아니라 순환에 있다는 뜻이다.&lt;br /&gt;경제학적으로 볼 때 돈은 유기체의 혈액과 같다.&lt;br /&gt;혈액이 온몸을 구석구석 돌아야 산소와 영양분이 공급되듯, 돈도 시장을 돌아야 가치가 창출되고 경제가 숨을 쉰다.&lt;br /&gt;만약 돈이 귀중품이 되어 누군가의 장롱 속에 고여버린다면 어떻게 될까?&lt;br /&gt;그것은 혈관 속에서 피가 굳어버리는 혈전과 같다.&lt;br /&gt;피가 돌지 않는 신체 부위가 괴사 하듯, 돈이 돌지 않는 경제는 활력을 잃고 서서히 죽어간다.&lt;br /&gt;돈은 누군가에게 건네지고 다시 돌아오는 과정 속에 있을 때만 비로소 그 생명력을 유지한다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;돈이 보물이 된 나라의 비극&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현금 가치가 떨어지지 않는 세상이 천국처럼 보일 수도 있다.&lt;br /&gt;하지만 그 세상을 30년 넘게 직접 겪어본 일본의 사례는 전혀 다른 이야기를 들려준다.&lt;br /&gt;일본의 잃어버린 30년 동안 엔화는 국민들에게 쓰레기가 아니라 가만히 있어도 가치가 오르는 보물이었다.&lt;br /&gt;일본 내부에서는 물가가 오르지 않거나 오히려 떨어지는 상황이 지속되었다.&lt;br /&gt;그러다 보니 사람들은 돈을 안 쓰고 갖고만 있어도 내일 더 많은 물건을 살 수 있게 되었다.&lt;br /&gt;이것이 바로 돈이 귀중품대접을 받는 상황이다.&lt;br /&gt;결과는 참혹했다.&lt;br /&gt;사람들은 지갑을 닫았고 기업들은 투자를 멈췄다.&lt;br /&gt;밖에서는 엔화가 싸다고 난리였을지 몰라도, 일본 안에서는 돈이 너무 귀해져서 아무도 쓰지 않는 현상이 벌어졌다.&lt;br /&gt;돈이 쓰레기가 되지 않으려고 버티는 순간, 그 경제라는 유기체는 서서히 말라죽어간다는 것을 일본이 증명한 셈이다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;돈이 보물일 때 미소 짓는 수혜자들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돈이 쓰레기가 되는 것을 극도로 경계하고, 반대로 돈이 보물처럼 대접받기를 원하는 이들은 누구일까?&lt;br /&gt;우선, 대규모 채권 자산이나 고정 금리 예금을 보유한 사람들이다.&lt;br /&gt;채권은 미래에 정해진 금액을 받기로 한 약속이다.&lt;br /&gt;물가가 오르면 내가 미래에 받을 돈의 실제 구매력은 떨어진다.&lt;br /&gt;반대로 물가가 정체되거나 떨어져서 돈이 보물이 되면, 가만히 앉아 있어도 자산의 실질적인 힘은 강해진다.&lt;br /&gt;인플레이션은 이들의 자산을 갉아먹는 존재인 것이다.&lt;br /&gt;또한, 돈이 귀중품이 되는 세상은 자산가들에게 위험이 존재하지 않는 수익을 안겨준다.&lt;br /&gt;새로운 기술을 개발하거나 공장을 짓는 등의 리스크를 감수하지 않아도, 돈 가치가 알아서 오르기 때문에 투자할 이유가 사라진다.&lt;br /&gt;결국 현금은 쓰레기다라며 공포를 조장하는 목소리의 이면에는, 자신들의 부가 유지되거나 오히려 높아지기를 바라는 열망이 담겨 있을 가능성이 크다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;돈은 적당히 쓰레기여야 세상을 구른다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전 세계 중앙은행이 연 2% 수준의 인플레이션을 목표로 삼는 이유는 명확하다.&lt;br /&gt;돈을 아주 조금씩 타들어가게 만들기 위해서다.&lt;br /&gt;들고 있으면 조금씩 손해를 본다는 느낌이 있어야 비로소 돈이 세상 밖으로 나온다.&lt;br /&gt;그래야 누군가는 물건을 사서 기업의 매출을 올리고, 그 매출이 다시 노동자의 월급으로 돌아가는 선순환이 일어난다.&lt;br /&gt;현금은 태생적으로 흐르는 쓰레기가 될 숙명을 타고났다.&lt;br /&gt;그래야만 우리 손을 떠나 세상을 풍요롭게 만들 수 있기 때문이다.&lt;br /&gt;현금이 귀중품이 되어 장롱 속으로 들어가는 순간, 그 사회의 청년들은 일자리를 잃고 성장은 멈춘다.&lt;br /&gt;우리가 이런 경제적 전체 맥락과 구조를 이해하지 못한다면, 자극적인 썸네일 뒤에 숨겨진 누군가의 의도에 따라 생각하고 행동하게 될지도 모른다.&lt;br /&gt;&lt;b&gt;공포는 가장 강력한 통제 수단이다.&lt;/b&gt;&lt;br /&gt;&quot;돈이 휴지가 된다&quot;는 말에 떨기보다는, 왜 세상이 돈을 계속 흐르게 하려고 설계되었는지 그 구조를 들여다보는 눈이 필요하다.&lt;br /&gt;결국 현금의 가치가 조금씩 닳아 없어지는 것은 경제가 살아있다는 신호다.&lt;br /&gt;진짜 두려워해야 할 것은 돈이 고여서 썩어가는 세상, 즉 돈이 사람보다 더 귀해지는 차가운 세상이다.&lt;/p&gt;</description>
      <category>금융</category>
      <author>kyoulho</author>
      <guid isPermaLink="true">https://kyoulho.tistory.com/429</guid>
      <comments>https://kyoulho.tistory.com/429#entry429comment</comments>
      <pubDate>Sat, 31 Jan 2026 17:14:29 +0900</pubDate>
    </item>
  </channel>
</rss>