<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Kua's Miscellaneous</title>
    <link>https://ratthe.tistory.com/</link>
    <description>정보 공유, 개인 정리 공간 입니다.</description>
    <language>ko</language>
    <pubDate>Wed, 6 May 2026 19:10:35 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Kua</managingEditor>
    <image>
      <title>Kua's Miscellaneous</title>
      <url>https://tistory1.daumcdn.net/tistory/3029116/attach/7e91423626b5465586e5ce39b6e312ab</url>
      <link>https://ratthe.tistory.com</link>
    </image>
    <item>
      <title>[리버스 엔지니어링] 앱 감청 (Android 16)</title>
      <link>https://ratthe.tistory.com/211</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Windows 11 기준으로 작성되었습니다.&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;1. 앱 APK 확보&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 빠르고 좋은건 APK Mirror 와 같은 사이트에서 apk 파일을 직접 다운로드 하는 것이고,&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;p data-ke-size=&quot;size16&quot;&gt;그 경우 &lt;a href=&quot;https://github.com/AbdurazaaqMohammed/AntiSplit-M&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/AbdurazaaqMohammed/AntiSplit-M&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로그램을 이용하여 설치된 앱에서도 apk 파일로 추출해낼 수 있다.&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;2. APK 디컴파일&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://apktool.org/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://apktool.org/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/iBotPeaches/Apktool&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/iBotPeaches/Apktool&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apktool을 이용하여 smali 형식의 저수준 언어로된 패키지가 반환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 엔드포인트와 &lt;span style=&quot;color: #c4c0b9; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;간단한 어플리케이션의 &lt;/span&gt;구조를 알아보기 위해서는 이 파일을 통쨰로 AI에게 맡기면 잘 해석해준다.&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;p data-ke-size=&quot;size16&quot;&gt;3. 앱 네트워크 감청 (비루팅폰 기준)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Charles, Fiddler, Proxyman(macOS Only) 와 같은 소프트웨어를 준비한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에선 Charles 5.0.3으로 진행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;612&quot; data-origin-height=&quot;231&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNvDNY/dJMb99L4QGW/yuEXYPwwQOG9sTIiDFNfqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNvDNY/dJMb99L4QGW/yuEXYPwwQOG9sTIiDFNfqK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNvDNY/dJMb99L4QGW/yuEXYPwwQOG9sTIiDFNfqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNvDNY%2FdJMb99L4QGW%2FyuEXYPwwQOG9sTIiDFNfqK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;612&quot; height=&quot;231&quot; data-origin-width=&quot;612&quot; data-origin-height=&quot;231&quot;/&gt;&lt;/span&gt;&lt;/figure&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;Root 인증서 설치 진행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;490&quot; data-origin-height=&quot;367&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZPMAR/dJMcahchDFl/pDk8SSsUAY79OboxC99iH0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZPMAR/dJMcahchDFl/pDk8SSsUAY79OboxC99iH0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZPMAR/dJMcahchDFl/pDk8SSsUAY79OboxC99iH0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZPMAR%2FdJMcahchDFl%2FpDk8SSsUAY79OboxC99iH0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;490&quot; height=&quot;367&quot; data-origin-width=&quot;490&quot; data-origin-height=&quot;367&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #c4c0b9; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;HTTPS 까지 감청을 해야 하는데 기본적으로 앱 보안상 공식 CA 인증서가 아닌 루트를 통하면 데이터가 정상적으로 송수신되지 못함으로 리버싱을 할 앱에서 사용하는 도메인만 등록하여 휴대폰 사용에 문제 없도록 한다. (개발용 단말이 따로 있다면 이 절차는 패스)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;263&quot; data-origin-height=&quot;154&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AUieQ/dJMb996pmCf/Hc63PO5oCDkXvpKRndLkB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AUieQ/dJMb996pmCf/Hc63PO5oCDkXvpKRndLkB0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AUieQ/dJMb996pmCf/Hc63PO5oCDkXvpKRndLkB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAUieQ%2FdJMb996pmCf%2FHc63PO5oCDkXvpKRndLkB0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;263&quot; height=&quot;154&quot; data-origin-width=&quot;263&quot; data-origin-height=&quot;154&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 자물쇠를 이용해 SSL 요청도 프록시를 타도록 할지 말지를 결정해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;322&quot; data-origin-height=&quot;240&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cVM86C/dJMcabC9XEW/ecLAVVkYgh6JU2rerGJdxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cVM86C/dJMcabC9XEW/ecLAVVkYgh6JU2rerGJdxk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cVM86C/dJMcabC9XEW/ecLAVVkYgh6JU2rerGJdxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcVM86C%2FdJMcabC9XEW%2FecLAVVkYgh6JU2rerGJdxk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;322&quot; height=&quot;240&quot; data-origin-width=&quot;322&quot; data-origin-height=&quot;240&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보이는 것 처럼 휴대폰에서 프록시를 타도록 해주고, chls.pro/ssl 로 접속하면 인증서를 다운로드할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot_20260216_022725_Settings.jpg&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r81fs/dJMcajunHoT/avkKXnOeIZGIa9t9ZARET1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r81fs/dJMcajunHoT/avkKXnOeIZGIa9t9ZARET1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r81fs/dJMcajunHoT/avkKXnOeIZGIa9t9ZARET1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr81fs%2FdJMcajunHoT%2FavkKXnOeIZGIa9t9ZARET1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;800&quot; data-filename=&quot;Screenshot_20260216_022725_Settings.jpg&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1728&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WIFI 프록시 설정&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;span style=&quot;color: #c4c0b9; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;chls.pro/ssl&lt;span&gt; 에 접근하여 인증서 다운로드&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageslideblock alignCenter&quot; data-image=&quot;[{&amp;quot;src&amp;quot;:&amp;quot;https://blog.kakaocdn.net/dn/bryrC5/dJMcabpDRmJ/vDNCZwhOQdvgKrmiGASkP1/img.jpg&amp;quot;},{&amp;quot;src&amp;quot;:&amp;quot;https://blog.kakaocdn.net/dn/csVpou/dJMcabpDRmI/AZBI6HmTUseF4gQG27RIQk/img.jpg&amp;quot;},{&amp;quot;src&amp;quot;:&amp;quot;https://blog.kakaocdn.net/dn/dgtIpq/dJMcahwCf7a/9StSQiWn11UK1S8rzDmuhK/img.jpg&amp;quot;}]&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span class=&quot;image-wrap selected&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bryrC5/dJMcabpDRmJ/vDNCZwhOQdvgKrmiGASkP1/img.jpg&quot; data-url=&quot;https://blog.kakaocdn.net/dn/bryrC5/dJMcabpDRmJ/vDNCZwhOQdvgKrmiGASkP1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bryrC5/dJMcabpDRmJ/vDNCZwhOQdvgKrmiGASkP1/img.jpg&quot; loading=&quot;lazy&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbryrC5%2FdJMcabpDRmJ%2FvDNCZwhOQdvgKrmiGASkP1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2226&quot; data-is-animation=&quot;false&quot;/&gt;&lt;/span&gt;&lt;span class=&quot;image-wrap &quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/csVpou/dJMcabpDRmI/AZBI6HmTUseF4gQG27RIQk/img.jpg&quot; data-url=&quot;https://blog.kakaocdn.net/dn/csVpou/dJMcabpDRmI/AZBI6HmTUseF4gQG27RIQk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/csVpou/dJMcabpDRmI/AZBI6HmTUseF4gQG27RIQk/img.jpg&quot; loading=&quot;lazy&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcsVpou%2FdJMcabpDRmI%2FAZBI6HmTUseF4gQG27RIQk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2226&quot; data-is-animation=&quot;false&quot;/&gt;&lt;/span&gt;&lt;span class=&quot;image-wrap &quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dgtIpq/dJMcahwCf7a/9StSQiWn11UK1S8rzDmuhK/img.jpg&quot; data-url=&quot;https://blog.kakaocdn.net/dn/dgtIpq/dJMcahwCf7a/9StSQiWn11UK1S8rzDmuhK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dgtIpq/dJMcahwCf7a/9StSQiWn11UK1S8rzDmuhK/img.jpg&quot; loading=&quot;lazy&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdgtIpq%2FdJMcahwCf7a%2F9StSQiWn11UK1S8rzDmuhK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2226&quot; data-is-animation=&quot;false&quot;/&gt;&lt;/span&gt;&lt;button class=&quot;btn btn-prev&quot;&gt;&lt;span class=&quot;ico-prev&quot;&gt;이전&lt;/span&gt;&lt;/button&gt;&lt;button class=&quot;btn btn-next&quot;&gt;&lt;span class=&quot;ico-next&quot;&gt;다음&lt;/span&gt;&lt;/button&gt;&lt;/div&gt;
  &lt;div class=&quot;mark&quot;&gt;&lt;span data-index=&quot;0&quot;&gt;0&lt;/span&gt;&lt;span data-index=&quot;1&quot;&gt;1&lt;/span&gt;&lt;span data-index=&quot;2&quot;&gt;2&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot_20260216_023502_Settings.jpg&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2229&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lJNNj/dJMcagj81f0/QBVEZu67HhuX38ZCLHq5mK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lJNNj/dJMcagj81f0/QBVEZu67HhuX38ZCLHq5mK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lJNNj/dJMcagj81f0/QBVEZu67HhuX38ZCLHq5mK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlJNNj%2FdJMcagj81f0%2FQBVEZu67HhuX38ZCLHq5mK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;1032&quot; data-filename=&quot;Screenshot_20260216_023502_Settings.jpg&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2229&quot;/&gt;&lt;/span&gt;&lt;/figure&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;개발 완료 후 이 인증서는 보안상 삭제해주도록 하자.&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;apktool로 디컴된 앱 소스코드에서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;./res/xml 경로에 network_security_config.xml 파일을 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1771177375489&quot; class=&quot;vbnet&quot; data-ke-language=&quot;vbnet&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&amp;gt;
&amp;lt;network-security-config&amp;gt;
  &amp;lt;base-config&amp;gt;
    &amp;lt;trust-anchors&amp;gt;
      &amp;lt;certificates src=&quot;system&quot; /&amp;gt;
      &amp;lt;certificates src=&quot;user&quot; /&amp;gt;
    &amp;lt;/trust-anchors&amp;gt;
  &amp;lt;/base-config&amp;gt;
&amp;lt;/network-security-config&amp;gt;&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;AndroidManifest.xml의 &amp;lt;application ... 부분을 찾아 &amp;nbsp;android:networkSecurityConfig=&quot;@xml/network_security_config&quot; 를 추가해준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1496&quot; data-origin-height=&quot;111&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/coZ8uP/dJMcafZPGY4/iTfs0O5AjHjafq45x9sUSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/coZ8uP/dJMcafZPGY4/iTfs0O5AjHjafq45x9sUSK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/coZ8uP/dJMcafZPGY4/iTfs0O5AjHjafq45x9sUSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcoZ8uP%2FdJMcafZPGY4%2FiTfs0O5AjHjafq45x9sUSK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1496&quot; height=&quot;111&quot; data-origin-width=&quot;1496&quot; data-origin-height=&quot;111&quot;/&gt;&lt;/span&gt;&lt;/figure&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;intellij에서 Android 에뮬을 돌려보았거나, Android Studio가 PC에 설치된 적이 있었다면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C:\Users\{Account}\AppData\Local\Android\Sdk 경로에 앱 빌드를 위한 툴과 SDK가 모두 존재할 것이다. 필자는 가장 최신버전으로 빌드 하였다.&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;PowerShell&lt;/p&gt;
&lt;pre id=&quot;code_1771177711694&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;$SDK = &quot;$env:LOCALAPPDATA\Android\Sdk&quot;
$BT  = &quot;$SDK\build-tools\36.1.0&quot;

&amp;amp; &quot;$BT\zipalign.exe&quot; -p -f 4 .\apkname-mitm.apk .\apkname-mitm-aligned.apk

&amp;amp; &quot;$BT\apksigner.bat&quot; sign `
  --ks &quot;$env:USERPROFILE\.android\debug.keystore&quot; `
  --ks-key-alias androiddebugkey `
  --ks-pass pass:android `
  --key-pass pass:android `
  --out .\apkname-mitm-signed.apk `
  .\apkname-mitm-aligned.apk

&amp;amp; &quot;$BT\apksigner.bat&quot; verify --verbose --print-certs .\apkname-mitm-signed.apk&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;881&quot; data-origin-height=&quot;521&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IvaXF/dJMcai3hLcD/VeO6PRAU0ywCcnAKIx5Pk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IvaXF/dJMcai3hLcD/VeO6PRAU0ywCcnAKIx5Pk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IvaXF/dJMcai3hLcD/VeO6PRAU0ywCcnAKIx5Pk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIvaXF%2FdJMcai3hLcD%2FVeO6PRAU0ywCcnAKIx5Pk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;881&quot; height=&quot;521&quot; data-origin-width=&quot;881&quot; data-origin-height=&quot;521&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;470&quot; data-origin-height=&quot;133&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1ZoUk/dJMcafZPHdy/L2Vg9HuEUEr5qODTpQxfb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1ZoUk/dJMcafZPHdy/L2Vg9HuEUEr5qODTpQxfb0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1ZoUk/dJMcafZPHdy/L2Vg9HuEUEr5qODTpQxfb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1ZoUk%2FdJMcafZPHdy%2FL2Vg9HuEUEr5qODTpQxfb0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;470&quot; height=&quot;133&quot; data-origin-width=&quot;470&quot; data-origin-height=&quot;133&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;762&quot; data-origin-height=&quot;589&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0kjoY/dJMcaca0zig/0MYyF6BFRORA0oMaDWvSDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0kjoY/dJMcaca0zig/0MYyF6BFRORA0oMaDWvSDK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0kjoY/dJMcaca0zig/0MYyF6BFRORA0oMaDWvSDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0kjoY%2FdJMcaca0zig%2F0MYyF6BFRORA0oMaDWvSDK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;762&quot; height=&quot;589&quot; data-origin-width=&quot;762&quot; data-origin-height=&quot;589&quot;/&gt;&lt;/span&gt;&lt;/figure&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;&lt;span style=&quot;color: #c4c0b9; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;이렇게 HTTPS도 정상적으로 모든 요청과 응답을 파싱해서 볼 수 있게 된다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>일상다반사/개발</category>
      <category>Android</category>
      <category>apk</category>
      <category>engineering</category>
      <category>Galaxy</category>
      <category>proxy</category>
      <category>reverse</category>
      <category>samsung</category>
      <author>Kua</author>
      <guid isPermaLink="true">https://ratthe.tistory.com/211</guid>
      <comments>https://ratthe.tistory.com/211#entry211comment</comments>
      <pubDate>Mon, 16 Feb 2026 02:51:04 +0900</pubDate>
    </item>
    <item>
      <title>AI 자동매매 시스템 만들기 #6 - 예외처리시간! (LLM 환각, WebSocket 재연결, API 비용 최적화)</title>
      <link>https://ratthe.tistory.com/210</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;result (6).png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/05GA3/dJMcabb2LJI/oRcob0k3SBF975KFKxnOck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/05GA3/dJMcabb2LJI/oRcob0k3SBF975KFKxnOck/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/05GA3/dJMcabb2LJI/oRcob0k3SBF975KFKxnOck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F05GA3%2FdJMcabb2LJI%2FoRcob0k3SBF975KFKxnOck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;400&quot; data-filename=&quot;result (6).png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 1: LLM이 JSON 대신 마크다운을 뱉어버렸다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 분석이 간혹 가다 실패하기 시작했습니다. JSON을 달라고 했는데 마크다운으로 답변하고 있었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Qwen3 14B 모델은 &quot;thinking 모드&quot;를 지원합니다. 사고 과정을 &amp;lt;think&amp;gt;...&amp;lt;/think&amp;gt; 태그 안에 쓰고, 그 다음에 JSON 응답을 줍니다. 그런데 간헐적으로 JSON 대신 마크다운으로 응답하는 경우가 생겼습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결: 정규표현식 폴백 파서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON 파싱 실패 시 마크다운에서 직접 데이터를 추출하는 폴백 로직을 추가했습니다. 정규표현식으로 &quot;추천: BUY&quot;, &quot;신뢰도: 75%&quot; 같은 패턴을 찾아서 JSON으로 변환합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON 파싱 성공률이 82% &amp;rarr; 98%로 올라갔습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;765&quot; data-origin-height=&quot;1155&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dg14MZ/dJMcafrY0MV/k7MJsyKlEW314TZllFvUdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dg14MZ/dJMcafrY0MV/k7MJsyKlEW314TZllFvUdk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dg14MZ/dJMcafrY0MV/k7MJsyKlEW314TZllFvUdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdg14MZ%2FdJMcafrY0MV%2Fk7MJsyKlEW314TZllFvUdk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;765&quot; height=&quot;1155&quot; data-origin-width=&quot;765&quot; data-origin-height=&quot;1155&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 2: LLM 환각으로 감시 종목 절반을 매칭해버림&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;반도체 업황 호조&quot; 같은 일반적인 뉴스에 감시 종목 30개 중 12개를 매칭했습니다. 명백한 환각(hallucination)입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM은 &quot;반도체 업황&quot;이라는 단어만 보고 반도체와 간접적으로라도 연관된 모든 종목을 매칭했습니다. 심지어 금융주까지 포함했죠 (반도체 주가 상승 &amp;rarr; 코스피 상승 &amp;rarr; 금융주 수혜라는 논리).&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 프롬프트에 엄격한 규칙 추가: &quot;직접 언급&quot; 또는 &quot;핵심 사업 연관&quot;만 허용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 환각 탐지 로직: 감시 종목의 절반 이상 매칭되면 전체를 버림&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;if (matched.size &amp;gt; watchStocks.size / 2 &amp;amp;&amp;amp; matched.size &amp;gt; 3) {
    logger.warn { &quot;LLM 매칭 환각 감지 &amp;rarr; 전체 무시&quot; }
    return emptyList()
}&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;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 3: Tavily URL 크롤링이 사이드바까지 긁어왔다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기사 주제는 &quot;삼성전자 HBM 수주&quot;인데 SK텔레콤, 현대차까지 매칭됐습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tavily는 URL의 전체 본문을 추출하는데, 뉴스 사이트의 사이드바, 관련 기사, 광고까지 포함됩니다. 전체 텍스트에서 종목명을 찾으니 관련 없는 종목까지 매칭됐습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기사 본문은 대부분 앞쪽에 있습니다. 앞 1500자만 사용하도록 수정:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;val allContent = extracts.joinToString(&quot; &quot;) {
    (it.rawContent ?: &quot;&quot;).take(1500)  // 본문 앞부분만
}&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;TAVILY 매칭 정확도가 일부 상승했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 4: Mock 모드 WebSocket 실패 시 장 상태를 모르게 됨&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 환경에서 Mock WebSocket이 실패하면 실제 장이 열려있어도 거래를 안 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방법: 시간 기반 Fallback&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebSocket 실패 시 현재 시간으로 장 상태를 추정:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1순위: WebSocket 실시간 상태&lt;/li&gt;
&lt;li&gt;2순위: 시간 기반 추정 (평일 09:00-15:30)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 환경에서 WebSocket 없이도 거래 테스트가 가능해졌습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;979&quot; data-origin-height=&quot;1161&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sLOdV/dJMcad1XzQn/2QzkXQqoCPVNMBuPseBcE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sLOdV/dJMcad1XzQn/2QzkXQqoCPVNMBuPseBcE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sLOdV/dJMcad1XzQn/2QzkXQqoCPVNMBuPseBcE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsLOdV%2FdJMcad1XzQn%2F2QzkXQqoCPVNMBuPseBcE0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;830&quot; data-origin-width=&quot;979&quot; data-origin-height=&quot;1161&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 5: Brave 검색 크레딧을 허공에 날려버림&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Brave Search API는 유료입니다(월 2,000회 무료). 그런데 &quot;코스피 장 마감&quot;, &quot;달러 환율&quot; 같은 일반 시황까지 검색하고 있었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방법: LLM으로 검색 가치 판단&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Brave 검색 전에 LLM으로 &quot;검색할 가치가 있는가?&quot;를 먼저 판단:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;data class BraveQueryRefinementResult(
    val worthSearching: Boolean,
    val searchQuery: String,
    val reason: String
)&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;LLM이 worthSearching=false를 반환하면 검색을 스킵합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약: 실전 운영에서 배운 교훈&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;LLM은 확률적 모델 &amp;rarr; 다양한 출력 포맷 허용하는 견고한 파서 필요&lt;/li&gt;
&lt;li&gt;LLM 환각 대비 &amp;rarr; 프롬프트 엔지니어링 + 코드 검증 병행&lt;/li&gt;
&lt;li&gt;Reactive Streams retry &amp;rarr; retryWhen()으로 정교한 제어 + 전역 카운터 관리&lt;/li&gt;
&lt;li&gt;웹 크롤링 노이즈 제거 &amp;rarr; 앞부분만 사용, 도메인 지식 활용&lt;/li&gt;
&lt;li&gt;외부 시스템 의존도 낮추기 &amp;rarr; 시간 기반 Fallback 전략&lt;/li&gt;
&lt;li&gt;API 비용 절감 &amp;rarr; 호출 전 LLM으로 가치 판단&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>일상다반사/개발</category>
      <category>AI</category>
      <category>API</category>
      <category>Kotlin</category>
      <category>LLM</category>
      <category>prompt</category>
      <category>spring</category>
      <category>springai</category>
      <category>springboot</category>
      <category>stock</category>
      <category>trading</category>
      <author>Kua</author>
      <guid isPermaLink="true">https://ratthe.tistory.com/210</guid>
      <comments>https://ratthe.tistory.com/210#entry210comment</comments>
      <pubDate>Thu, 12 Feb 2026 15:37:27 +0900</pubDate>
    </item>
    <item>
      <title>AI 자동매매 시스템 만들기 #5 - RAG로 AI에게 기억력을 주다(pgvector)</title>
      <link>https://ratthe.tistory.com/209</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;result (5).png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7YGsL/dJMcaajVu3r/fMhFi889SmapA68iEjW5S1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7YGsL/dJMcaajVu3r/fMhFi889SmapA68iEjW5S1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7YGsL/dJMcaajVu3r/fMhFi889SmapA68iEjW5S1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb7YGsL%2FdJMcaajVu3r%2FfMhFi889SmapA68iEjW5S1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;400&quot; data-filename=&quot;result (5).png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI가 과거를 기억하지 못한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 시스템의 치명적인 약점이 있었습니다. AI가 실시간으로 들어오는 정보만 보고 판단한다는 거예요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 텔레그램 채널에서 이런 메시지들이 왔다고 가정해봅시다:&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;월요일 오전: &quot;삼성전자 분석 - 반도체 업황 회복 조짐, 목표가 85,000원&quot;
월요일 오후: &quot;삼성전자 실적 발표 예정, 컨센서스 상회 전망&quot;
화요일 오전: &quot;삼성전자 추가 매수 의견, 기술적 지지선 돌파&quot;
수요일: &quot;삼성전자&quot; (단 3글자만)&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;AI는 수요일 메시지를 볼 때 이전 3일간의 맥락을 전혀 모릅니다. 단순히 &quot;삼성전자&quot;라는 키워드만 보고 분석하죠. 당연히 정확도가 떨어질 수밖에 없습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RAG 구축&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RAG는 간단히 말하면 &quot;관련 있는 과거 정보를 찾아서 AI에게 함께 제공하는 기술&quot;입니다.&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;이를 위해선 벡터 DB가 필요한데, Qdrant와 Pgvector 중에서 고민 끝에 pgvector로 결정하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 SQLite의 확장성 제약에 마이그레이션도 하는 겸 + 벤치마크 또한 pgvector가 우위에 있었는 점 이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고 자료: &lt;a href=&quot;https://www.tigerdata.com/blog/pgvector-vs-qdrant&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.tigerdata.com/blog/pgvector-vs-qdrant&lt;/a&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;구현 과정은 이렇습니다:&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1단계: PostgreSQL + pgvector 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 H2 인메모리 DB를 쓰고 있었는데, 벡터 검색을 위해 PostgreSQL로 마이그레이션했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pgvector는 PostgreSQL의 확장 기능으로, 벡터 데이터를 저장하고 검색할 수 있게 해줍니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계: 텔레그램 메시지 임베딩&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 텔레그램 메시지를 768차원 벡터로 변환해서 DB에 저장합니다. Ollama의 임베딩 모델을 사용했어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;869&quot; data-origin-height=&quot;1121&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bR9xCj/dJMcabiQB1P/x9kSDeTk7OnY7OaxvoYtDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bR9xCj/dJMcabiQB1P/x9kSDeTk7OnY7OaxvoYtDk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bR9xCj/dJMcabiQB1P/x9kSDeTk7OnY7OaxvoYtDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbR9xCj%2FdJMcabiQB1P%2Fx9kSDeTk7OnY7OaxvoYtDk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;774&quot; data-origin-width=&quot;869&quot; data-origin-height=&quot;1121&quot;/&gt;&lt;/span&gt;&lt;/figure&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;RAG를 도입하니 또 다른 문제가 생겼습니다. 한 종목당 임베딩 + 벡터 검색 + AI 분석 시간이 1.5초 정도 걸리는데, 종목이 10개면 15초나 걸립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결책은 멀티스레드였습니다. 5개 스레드로 병렬 처리하니 10개 종목을 3초 안에 분석할 수 있게 됐습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;인덱싱 최적화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HNSW(Hierarchical Navigable Small World) 인덱스를 채택하였습니다. 정확도는 약간 희생되지만 실시간 거래에서는 속도가 더 중요합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;컨텍스트 윈도우 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 관련 메시지를 무조건 10개씩 가져왔는데, 너무 많은 정보가 오히려 AI를 헷갈리게 만들었습니다.&lt;/p&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;최근 7일 이내&lt;/li&gt;
&lt;li&gt;유사도 상위 5개&lt;/li&gt;
&lt;li&gt;코사인 유사도 0.7 이상만&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 코드(일부)&lt;/h3&gt;
&lt;pre id=&quot;code_1770876976637&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
class VectorStoreConfig {

    @Bean
    fun embeddingModel(): EmbeddingModel {
        val openAiApi = OpenAiApi.builder()
            .baseUrl(&quot;http://localhost:11434&quot;)  // Ollama 서버
            .apiKey(&quot;ollama&quot;)  // 더미 키
            .build()

        return OpenAiEmbeddingModel(
            openAiApi,
            org.springframework.ai.document.MetadataMode.EMBED,
            OpenAiEmbeddingOptions.builder()
                .model(&quot;nomic-embed-text&quot;)  // 768차원 임베딩 모델
                .build(),
            RetryUtils.DEFAULT_RETRY_TEMPLATE,
        )
    }
}&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;pre id=&quot;code_1770876995517&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fun embedMessage(message: ChannelMessage) {
    // 메시지 내용 + 이미지 분석 결과 결합
    val content = message.content + 
        if (message.imageAnalysis != null) &quot;\n[이미지 분석] ${message.imageAnalysis}&quot; else &quot;&quot;
    
    // 메타데이터 구성
    val metadata = mutableMapOf&amp;lt;String, Any&amp;gt;(
        &quot;channelName&quot; to message.channelName,
        &quot;createdAt&quot; to message.createdAt.toString(),
        &quot;stockCode&quot; to message.matchedStockCode,
        &quot;stockName&quot; to message.matchedStockName,
        &quot;type&quot; to &quot;channel_message&quot;
    )
    
    // Spring AI Document로 변환 후 저장
    val document = Document(content, metadata)
    vectorStore.add(listOf(document))
}&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;pre id=&quot;code_1770877006570&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fun searchRelatedMessages(
    stockName: String,
    stockCode: String,
    sectorName: String? = null,
    topK: Int = 8,
    similarityThreshold: Double = 0.5,
    daysBack: Long = 7L
): List&amp;lt;RelevantMessage&amp;gt; {
    
    // 검색 쿼리: 종목명 + 섹터명 + &quot;관련 뉴스 소식&quot;
    val query = &quot;$stockName ${sectorName ?: &quot;&quot;} 관련 뉴스 소식&quot;
    
    // 시간 필터: 최근 7일 이내
    val sinceDate = LocalDateTime.now().minusDays(daysBack).toString()
    val filterExpression = &quot;createdAt &amp;gt;= '$sinceDate'&quot;
    
    // 벡터 검색 실행
    val request = SearchRequest.builder()
        .query(query)
        .topK(topK)
        .similarityThreshold(similarityThreshold)  // 0.5 이상만
        .filterExpression(filterExpression)
        .build()
    
    return vectorStore.similaritySearch(request)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1770877023920&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Scheduled(cron = &quot;0 0 4 * * *&quot;)  // 매일 오전 4시
fun cleanupOldEntries() {
    val cutoffDate = LocalDateTime.now().minusDays(30).toString()
    
    jdbcTemplate.update(
        &quot;DELETE FROM vector_store WHERE metadata-&amp;gt;&amp;gt;'createdAt' &amp;lt; ? AND metadata-&amp;gt;&amp;gt;'type' = 'channel_message'&quot;,
        cutoffDate
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. RAG는 LLM의 게임 체인저: 같은 모델이라도 컨텍스트 유무에 따라 성능이 12% 이상 차이 납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 벡터 DB 선택이 중요: 처음엔 Chroma, Qdrant 같은 전용 벡터 DB를 고려했지만, pgvector로도 충분했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 임베딩 모델 선택: 로컬 모델 nomic-embed-text(768차원)을 사용하였습니다. 한국어 성능은 약간 떨어지지만 실시간 처리엔 문제없었어요.&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>
      <category>일상다반사/개발</category>
      <category>AI</category>
      <category>Kotlin</category>
      <category>LLM</category>
      <category>ollama</category>
      <category>pgvector</category>
      <category>qdrant</category>
      <category>spring</category>
      <category>springai</category>
      <category>springboot</category>
      <author>Kua</author>
      <guid isPermaLink="true">https://ratthe.tistory.com/209</guid>
      <comments>https://ratthe.tistory.com/209#entry209comment</comments>
      <pubDate>Thu, 12 Feb 2026 15:17:20 +0900</pubDate>
    </item>
    <item>
      <title>AI 자동매매 시스템 만들기 #4 - AI가 실제로 판단하는 방법</title>
      <link>https://ratthe.tistory.com/208</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;result (2).png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KJs82/dJMcaaxsFle/X6xoRRWRGTsyteIsYHStR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KJs82/dJMcaaxsFle/X6xoRRWRGTsyteIsYHStR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KJs82/dJMcaaxsFle/X6xoRRWRGTsyteIsYHStR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKJs82%2FdJMcaaxsFle%2FX6xoRRWRGTsyteIsYHStR1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;400&quot; data-filename=&quot;result (2).png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;나의 로컬 LLM은?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 첫번째엔 Gemma3를 쓰고자 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 AI가 Gemma3 보다는 qwen을 추천한데요... 저보다 똑똑한 AI가 하는 말을 믿고 모델을 변경하였습니다.&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;와 동시에 vLLM에서 Ollama로 프레임워크를 변경하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사유는 vLLM으로 동일 모델을 Windows 환경에서 구동하기 위해서는 WSL Linux에서 구동하거나 Docker를 이용하는 것이었습니다. 하지만 Docker로 구동을 하니 GPU 액셀러레이션을 제대로 활용하지 못해 속도가 굉장히 느렸습니다. (Linux OS 였다면 vLLM으로 했을 것입니다)&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;최종적으로 Ollama를 사용해서 Qwen3 14B 모델을 로컬 서버에서 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;ollama run qwen3:14b&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;응답 속도는 0.5초 이하, 비용은 전기세뿐. API 호출 제한도 없습니다!&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;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.glukhov.org/ko/post/2026/01/choosing-best-llm-for-ollama-on-16gb-vram-gpu/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.glukhov.org/ko/post/2026/01/choosing-best-llm-for-ollama-on-16gb-vram-gpu/&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI 분석 프롬프트 설계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 &quot;이 주식 살까요?&quot;라고 물으면 제대로 된 답이 안 나옵니다. 구조화된 프롬프트가 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 필요한 모든 정보를 주고, 명확한 형식으로 답변을 요구합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1770875456295&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val prompt = &quot;&quot;&quot;
You are an AI trading analyst for the Korean stock market.
Analyze the following stock and decide whether to BUY, HOLD, or SELL.

## Stock Information
- Name: ${stockName}
- Code: ${stockCode}
- Current Price: ${currentPrice}원
- Change: ${changeRate}%
- Volume: ${volume} (평균 대비 ${volumeRatio}배)

## Technical Indicators
- RSI(14): ${rsi} ${when {
    rsi &amp;gt; 70 -&amp;gt; &quot;(과매수 구간)&quot;
    rsi &amp;lt; 30 -&amp;gt; &quot;(과매도 구간)&quot;
    else -&amp;gt; &quot;&quot;
}}
- MACD: ${macd} / Signal: ${signal} ${if (macd &amp;gt; signal) &quot;(상승 추세)&quot; else &quot;(하락 추세)&quot;}
- 20일 이동평균: ${ma20}원 (현재가 대비 ${maDiff}%)

## Recent News from Channels
${channelMessages.joinToString(&quot;\n&quot;) { &quot;- ${it.timestamp}: ${it.text}&quot; }}

## Account Status
- Available Cash: ${availableCash}원
- Current Holdings: ${holdings.size}개 종목
- Total Portfolio Value: ${totalValue}원

## Decision Rules
- Only BUY if confidence &amp;gt;= 70% and riskLevel is LOW or MEDIUM
- Set SELL if technical indicators show clear downtrend
- Use HOLD for uncertain situations

## Response Format (JSON only)
{
  &quot;decision&quot;: &quot;BUY&quot; | &quot;HOLD&quot; | &quot;SELL&quot;,
  &quot;confidence&quot;: 0-100,
  &quot;riskLevel&quot;: &quot;LOW&quot; | &quot;MEDIUM&quot; | &quot;HIGH&quot;,
  &quot;reason&quot;: &quot;간단한 한국어 설명 (2-3문장)&quot;,
  &quot;targetPrice&quot;: 목표가 (매수시에만),
  &quot;stopLoss&quot;: 손절가 (매수시에만)
}
&quot;&quot;&quot;.trimIndent()&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span&gt;실제 AI 호출 코드&lt;/span&gt;&lt;span style=&quot;color: #14181f;&quot;&gt;:&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1770875473531&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
class AiAnalysisService(
    private val chatClient: ChatClient
) {
    fun analyzeStock(stockCode: String): AnalysisResult? {
        val prompt = buildPrompt(stockCode)
        
        val response = chatClient.prompt()
            .user(prompt)
            .call()
            .content()
        
        return parseAiResponse(response)
    }
}&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;span&gt;Spring AI 덕분에 LLM 호출이 한 줄로 끝납니다&lt;/span&gt;&lt;span style=&quot;color: #2b303b;&quot;&gt;.&lt;/span&gt;&lt;span&gt; Retry 로직&lt;/span&gt;&lt;span style=&quot;color: #2b303b;&quot;&gt;,&lt;/span&gt;&lt;span&gt; 타임아웃&lt;/span&gt;&lt;span style=&quot;color: #2b303b;&quot;&gt;,&lt;/span&gt;&lt;span&gt; 스트리밍도 쉽게 추가할 수 있어요&lt;/span&gt;&lt;span style=&quot;color: #2b303b;&quot;&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JSON 파싱의 함정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 항상 완벽한 JSON을 반환할까요? 아닙니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가끔 앞뒤로 설명을 붙이는 거죠. 그래서 파싱 로직을 견고하게 만들어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3단계 폴백 전략으로 AI 응답을 안정적으로 파싱합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기술적 지표 계산&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 차트를 그대로 보여줄 수는 없으니, 기술적 지표를 계산해서 숫자로 전달합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RSI(상대강도지수), MACD(이동평균수렴확산) 같은 지표들을 계산해서 AI 프롬프트에 포함시켜서 판단 근거로 활용합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실시간 손절/익절&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 1분마다 폴링해서 가격을 체크했습니다. 문제는 1분 사이에 급락하면 손절 타이밍을 놓친다는 거죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 WebSocket 실시간 체결 데이터를 활용합니다. 체결 데이터가 오는 즉시 손절/익절 조건을 체크하고, 조건이 맞으면 바로 매도합니다. 폴링 방식 대비 최대 60배 빠른 반응 속도입니다!&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;거래 정지 알림 - SYSTEM 메시지 활용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebSocket에는 체결 데이터 외에도 SYSTEM 메시지가 옵니다. 거래 정지/재개 같은 중요한 이벤트죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;거래 정지되면 즉시 텔레그램으로 알림이 옵니다. 손해를 막을 수 있는 중요한 기능이죠.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Vision AI - 이미지 뉴스 분석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텔레그램 채널에 차트 이미지나 뉴스 스크린샷이 올라오는 경우가 있습니다. 이것도 분석해야죠!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ollama의 Vision 모델(qwen3-vl:8b)을 사용합니다. 텔레그램에서 사진이 오면 이미지 다운로드 &amp;rarr; Vision AI로 분석 &amp;rarr; 텍스트 추출 + 요약 &amp;rarr; DB에 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 차트 이미지도 놓치지 않습니다!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;420&quot; data-origin-height=&quot;1065&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qaWZX/dJMcaiPJuYb/qeka32t9mw5kH8uJ5lSc9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qaWZX/dJMcaiPJuYb/qeka32t9mw5kH8uJ5lSc9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qaWZX/dJMcaiPJuYb/qeka32t9mw5kH8uJ5lSc9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqaWZX%2FdJMcaiPJuYb%2Fqeka32t9mw5kH8uJ5lSc9k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;420&quot; height=&quot;1065&quot; data-origin-width=&quot;420&quot; data-origin-height=&quot;1065&quot;/&gt;&lt;/span&gt;&lt;/figure&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;1. 로컬 AI는 생각보다 강력하다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ChatGPT만 AI가 아닙니다. 로컬에서 돌리는 오픈소스 LLM도 충분히 실용적입니다.&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;2. 프롬프트 설계가 전부다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;주식 사줘&quot;가 아니라 구조화된 정보를 주고, 명확한 형식을 요구해야 합니다.&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;3. AI 응답은 불안정하다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항상 완벽한 JSON이 오지 않습니다. 폴백 파싱 로직이 필수입니다.&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;4. 실시간은 WebSocket이 답이다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴링으로는 빠른 시장 변화를 따라갈 수 없습니다. WebSocket 실시간 데이터가 핵심입니다.&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;5. Vision AI는 보너스&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;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 AI 분석 시스템과 실시간 거래 로직에 대해 이야기했습니다. 다음 글에서는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL + pgvector로 RAG 구축하기&lt;/li&gt;
&lt;li&gt;과거 뉴스를 벡터 검색해서 AI 컨텍스트에 추가&lt;/li&gt;
&lt;li&gt;포트폴리오 자동 리포트 생성&lt;/li&gt;
&lt;li&gt;실전 투자 전환과 최종 개선 사항&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>일상다반사/개발</category>
      <category>AI</category>
      <category>auto</category>
      <category>kiwoom</category>
      <category>Kotlin</category>
      <category>LLM</category>
      <category>spring</category>
      <category>springboot</category>
      <category>stock</category>
      <category>trading</category>
      <author>Kua</author>
      <guid isPermaLink="true">https://ratthe.tistory.com/208</guid>
      <comments>https://ratthe.tistory.com/208#entry208comment</comments>
      <pubDate>Thu, 12 Feb 2026 14:53:01 +0900</pubDate>
    </item>
    <item>
      <title>AI 자동매매 시스템 만들기 #3 - 키움증권 API 연동과 실전 거래</title>
      <link>https://ratthe.tistory.com/207</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;result (1).png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c7GQHP/dJMcahi4kLX/d8ADp6fVKk8zkswunenF11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c7GQHP/dJMcahi4kLX/d8ADp6fVKk8zkswunenF11/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c7GQHP/dJMcahi4kLX/d8ADp6fVKk8zkswunenF11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc7GQHP%2FdJMcahi4kLX%2Fd8ADp6fVKk8zkswunenF11%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;400&quot; data-filename=&quot;result (1).png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;API 연동 삽질기 - 토큰 관리의 중요성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키움 REST API는 OAuth 2.0 방식을 씁니다. access_token과 refresh_token을 발급받아서 쓰는 구조입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;access_token은 24시간 유효, refresh_token은 30일 유효&lt;/blockquote&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;kotlin&quot;&gt;&lt;code&gt;@Service
class KiwoomAuthService(
    private val kiwoomProperties: KiwoomProperties,
    private val webClient: WebClient,
) {
    @Volatile
    private var accessToken: String? = null
    @Volatile
    private var refreshToken: String? = null
    @Volatile
    private var tokenExpiry: LocalDateTime? = null

    fun getAccessToken(): String {
        // 토큰이 없거나 만료 임박(10분 이내)이면 갱신
        if (accessToken == null || isTokenExpiringSoon()) {
            refreshAccessToken()
        }
        return accessToken ?: throw IllegalStateException(&quot;토큰 없음&quot;)
    }

    private fun isTokenExpiringSoon(): Boolean {
        val expiry = tokenExpiry ?: return true
        return LocalDateTime.now().plusMinutes(10).isAfter(expiry)
    }

    private fun refreshAccessToken() {
        val response = webClient.post()
            .uri(&quot;/oauth/token&quot;)
            .bodyValue(mapOf(
                &quot;grant_type&quot; to &quot;refresh_token&quot;,
                &quot;refresh_token&quot; to refreshToken,
                &quot;client_id&quot; to kiwoomProperties.appKey,
                &quot;client_secret&quot; to kiwoomProperties.appSecret
            ))
            .retrieve()
            .bodyToMono(TokenResponse::class.java)
            .block()

        accessToken = response?.accessToken
        tokenExpiry = LocalDateTime.now().plusSeconds(response?.expiresIn ?: 86400)
    }
}&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;토큰 만료 10분 전에 미리 갱신하도록 했습니다. 장 중에 토큰이 만료되면 거래를 못하니까요!&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;API 호출 제한 - Rate Limiter 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키움 API에는 호출 제한이 있습니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초당 최대 20회&lt;/li&gt;
&lt;li&gt;분당 최대 200회&lt;/li&gt;
&lt;li&gt;시간당 최대 1,000회&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 제한을 넘으면 일시적으로 API 사용이 차단됩니다. 그래서 Rate Limiter를 만들었습니다:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Component
class KiwoomRateLimiter {
    private val buckets = ConcurrentHashMap&amp;lt;String, RateLimitBucket&amp;gt;()

    fun acquire(apiId: String) {
        val bucket = buckets.computeIfAbsent(apiId) {
            RateLimitBucket(
                perSecond = 20,
                perMinute = 200,
                perHour = 1000
            )
        }

        while (!bucket.tryAcquire()) {
            Thread.sleep(50)  // 50ms 대기 후 재시도
        }
    }

    private class RateLimitBucket(
        private val perSecond: Int,
        private val perMinute: Int,
        private val perHour: Int,
    ) {
        private val secondWindow = LinkedList&amp;lt;Long&amp;gt;()
        private val minuteWindow = LinkedList&amp;lt;Long&amp;gt;()
        private val hourWindow = LinkedList&amp;lt;Long&amp;gt;()

        @Synchronized
        fun tryAcquire(): Boolean {
            val now = System.currentTimeMillis()

            // 오래된 기록 정리
            cleanup(secondWindow, now - 1000)
            cleanup(minuteWindow, now - 60000)
            cleanup(hourWindow, now - 3600000)

            // 제한 확인
            if (secondWindow.size &amp;gt;= perSecond) return false
            if (minuteWindow.size &amp;gt;= perMinute) return false
            if (hourWindow.size &amp;gt;= perHour) return false

            // 기록 추가
            secondWindow.add(now)
            minuteWindow.add(now)
            hourWindow.add(now)
            return true
        }

        private fun cleanup(window: LinkedList&amp;lt;Long&amp;gt;, threshold: Long) {
            while (window.isNotEmpty() &amp;amp;&amp;amp; window.first &amp;lt; threshold) {
                window.removeFirst()
            }
        }
    }
}&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;슬라이딩 윈도우 방식으로 구현했습니다. API 호출 전에 acquire()를 호출하면 자동으로 제한을 지켜줍니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;WebSocket으로 장 상태 실시간 감지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API만으로는 현재 장이 열려있는지 닫혀있는지 알 수 없습니다. 매번 API를 호출해서 확인하면 비효율적이고요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 WebSocket을 사용합니다. 키움은 실시간 시세와 장 상태를 WebSocket으로 제공합니다:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Service
class KiwoomMarketStateService(
    private val kiwoomWebSocketClient: WebSocketClient,
    private val kiwoomWebSocketUrl: String,
    private val authService: KiwoomAuthService,
) {
    private val connected = AtomicBoolean(false)
    private val activePhases = ConcurrentHashMap.newKeySet&amp;lt;MarketPhase&amp;gt;()

    @PostConstruct
    fun init() {
        activePhases.add(MarketPhase.CLOSED)
        connect()
    }

    private fun connect() {
        kiwoomWebSocketClient.execute(URI.create(kiwoomWebSocketUrl)) { session -&amp;gt;
            handleSession(session)
        }
            .retryWhen(
                Retry.backoff(100, Duration.ofSeconds(5))
                    .maxBackoff(Duration.ofSeconds(60))
            )
            .subscribe()
    }

    private fun handleSession(session: WebSocketSession): Mono&amp;lt;Void&amp;gt; {
        connected.set(true)

        // LOGIN 전문 전송
        val loginMessage = buildLoginMessage()
        val sendMono = session.send(Mono.just(session.textMessage(loginMessage)))

        // 실시간 메시지 수신
        val receiveMono = session.receive()
            .map(WebSocketMessage::getPayloadAsText)
            .flatMap { payload -&amp;gt; processMessage(payload, session) }
            .then()

        return sendMono.thenMany(receiveMono).then()
    }
}&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;WebSocket이 끊기면 자동으로 재연결합니다. 최대 100회까지 5초 간격으로 재시도하고, 그래도 실패하면 텔레그램으로 알림을 보냅니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;장 상태 코드 해석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키움 API는 장 상태를 숫자 코드로 보내줍니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;215 = 장운영구분
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;01: 장 시작 전&lt;/li&gt;
&lt;li&gt;02: 정규장 시작&lt;/li&gt;
&lt;li&gt;03: 정규장 마감&lt;/li&gt;
&lt;li&gt;04: 시간외 종가&lt;/li&gt;
&lt;li&gt;등등...&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 KRX(일반 주식), NXT(야간 거래), 시간외 거래가 각각 독립적으로 움직인다는 겁니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;enum class MarketStateCode(val code: String, val description: String) {
    PRE_MARKET_ALERT(&quot;01&quot;, &quot;장 시작 전&quot;),
    MARKET_OPEN(&quot;02&quot;, &quot;정규장 시작&quot;),
    MARKET_CLOSE(&quot;03&quot;, &quot;정규장 마감&quot;),
    REGULAR_CLOSE(&quot;08&quot;, &quot;정규장 마감 확정&quot;),

    AFTER_HOURS_CLOSING_START(&quot;04&quot;, &quot;시간외 종가 시작&quot;),
    AFTER_HOURS_CLOSING_END(&quot;05&quot;, &quot;시간외 종가 종료&quot;),
    AFTER_HOURS_SINGLE_START(&quot;06&quot;, &quot;시간외 단일가 시작&quot;),
    AFTER_HOURS_SINGLE_END(&quot;07&quot;, &quot;시간외 단일가 종료&quot;),

    NXT_PRE_MARKET_START(&quot;11&quot;, &quot;NXT 프리마켓 시작&quot;),
    NXT_PRE_MARKET_END(&quot;12&quot;, &quot;NXT 프리마켓 종료&quot;),
    NXT_MAIN_MARKET_START(&quot;21&quot;, &quot;NXT 메인마켓 시작&quot;),
    NXT_MAIN_MARKET_END(&quot;22&quot;, &quot;NXT 메인마켓 종료&quot;),
    NXT_AFTER_MARKET_START(&quot;31&quot;, &quot;NXT 에프터마켓 시작&quot;),
    NXT_AFTER_MARKET_END(&quot;32&quot;, &quot;NXT 에프터마켓 종료&quot;),

    ALL_CLOSE(&quot;99&quot;, &quot;전체 마감&quot;),
    UNKNOWN(&quot;00&quot;, &quot;알 수 없음&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 장이 동시에 열릴 수 있어서, Set으로 관리합니다:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;private val activePhases = ConcurrentHashMap.newKeySet&amp;lt;MarketPhase&amp;gt;()

fun isKrxOpen(): Boolean = MarketPhase.KRX_OPEN in activePhases

fun isNxtOpen(): Boolean =
    MarketPhase.NXT_PRE_MARKET in activePhases ||
    MarketPhase.NXT_MAIN_MARKET in activePhases ||
    MarketPhase.NXT_AFTER_MARKET in activePhases

fun isAnyMarketOpen(): Boolean = isKrxOpen() || isNxtOpen()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 다른 서비스에서 간단하게 확인할 수 있습니다:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;if (!marketStateService.isKrxOpen()) {
    logger.info { &quot;장 마감 중 &amp;mdash; 주문 스킵&quot; }
    return
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Service
class KiwoomOrderService(
    private val apiClient: KiwoomApiClient,
    private val marketStateService: KiwoomMarketStateService,
) {
    fun buyStock(
        accountNumber: String,
        stockCode: String,
        quantity: Int,
        price: Long,
        exchangeType: ExchangeType = ExchangeType.KRX,
    ): String? {
        // 1. 장 상태 확인
        if (!marketStateService.canTrade(exchangeType)) {
            logger.warn { &quot;[$stockCode] 현재 거래 불가 상태 (exchange=$exchangeType)&quot; }
            return null
        }

        // 2. 주문 가능 금액 확인
        val deposit = accountService.getDeposit()
        val orderableAmount = KiwoomNumberParser.parseLong(deposit?.output?.orderableAmount)
        val totalCost = price * quantity

        if (totalCost &amp;gt; orderableAmount) {
            logger.warn { &quot;[$stockCode] 주문 가능 금액 부족: $totalCost &amp;gt; $orderableAmount&quot; }
            return null
        }

        // 3. 주문 실행
        val response = apiClient.callApi&amp;lt;KiwoomOrderResponse&amp;gt;(
            apiId = &quot;ka10007&quot;,
            uri = &quot;/api/dotr/order&quot;,
            body = mapOf(
                &quot;acno&quot; to accountNumber,
                &quot;stk_cd&quot; to stockCode,
                &quot;buy_sell_tp&quot; to &quot;1&quot;,  // 1=매수
                &quot;ord_qty&quot; to quantity.toString(),
                &quot;ord_pric&quot; to price.toString(),
                &quot;ord_tp&quot; to &quot;00&quot;,  // 00=지정가
                &quot;excg_tp&quot; to exchangeType.code,
            ),
            label = &quot;매수 주문 [$stockCode]&quot;,
        )

        return response?.orderNumber
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 전에 반드시 확인하는 것들:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;장이 열려있는가?&lt;/li&gt;
&lt;li&gt;주문 가능 금액이 충분한가?&lt;/li&gt;
&lt;li&gt;입력값이 올바른가? (음수, 0 등 체크)&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모의투자 vs 실전 - Profile로 분리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 실전 계좌로 테스트할 수는 없습니다. 그래서 Spring Profile로 환경을 분리했습니다:&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;spring:
  profiles:
    active: mock  # 또는 real

kiwoom:
  mock:
    url: https://mock.kiwoom.com
    app-key: ${MOCK_APP_KEY}
  real:
    url: https://api.kiwoom.com
    app-key: ${REAL_APP_KEY}&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;애플리케이션 시작 시 Profile을 검증합니다:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Component
class ProfileValidator(
    private val environment: Environment
) : ApplicationRunner {
    override fun run(args: ApplicationArguments) {
        val profiles = environment.activeProfiles
        val validProfiles = setOf(&quot;mock&quot;, &quot;real&quot;)

        if (profiles.none { it in validProfiles }) {
            logger.error { &quot;잘못된 프로파일: $profiles&quot; }
            logger.error { &quot;반드시 'mock' 또는 'real' 프로파일을 지정하세요&quot; }
            exitProcess(1)
        }

        logger.info { &quot;실행 환경: ${profiles.joinToString()}&quot; }
    }
}&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;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주문 체결 추적 - WebSocket 활용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문을 넣으면 바로 체결되는 게 아닙니다. 시장 상황에 따라 몇 초 ~ 몇 분 걸릴 수 있죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebSocket으로 주문 체결 상태를 실시간으로 받을 수 있습니다:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Service
class KiwoomOrderTrackingService(
    private val marketStateService: KiwoomMarketStateService,
) {
    private val pendingOrders = ConcurrentHashMap&amp;lt;String, OrderStatus&amp;gt;()

    @PostConstruct
    fun init() {
        // 주문체결 실시간 리스너 등록
        marketStateService.addOrderExecutionListener { data -&amp;gt;
            handleExecutionUpdate(data)
        }
    }

    fun trackOrder(orderNumber: String, stockCode: String, orderType: String) {
        pendingOrders[orderNumber] = OrderStatus(
            stockCode = stockCode,
            orderType = orderType,
            status = &quot;대기중&quot;,
            createdAt = LocalDateTime.now()
        )
    }

    private fun handleExecutionUpdate(data: JsonNode) {
        val orderNumber = data.get(&quot;values&quot;)?.get(&quot;주문번호&quot;)?.asText() ?: return
        val status = data.get(&quot;values&quot;)?.get(&quot;체결여부&quot;)?.asText() ?: return

        val order = pendingOrders[orderNumber] ?: return

        when (status) {
            &quot;체결&quot; -&amp;gt; {
                logger.info { &quot;[${order.stockCode}] 주문 체결 완료: $orderNumber&quot; }
                pendingOrders.remove(orderNumber)
                // DB에 거래 이력 저장
                saveTradeHistory(order, data)
            }
            &quot;취소&quot; -&amp;gt; {
                logger.warn { &quot;[${order.stockCode}] 주문 취소됨: $orderNumber&quot; }
                pendingOrders.remove(orderNumber)
            }
        }
    }
}&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;주문을 넣으면 추적 목록에 등록하고, WebSocket으로 체결 통지가 오면 DB에 저장합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;에러 처리 - 실패에 대비하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 호출은 언제든 실패할 수 있습니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;네트워크 오류&lt;/li&gt;
&lt;li&gt;API 서버 장애&lt;/li&gt;
&lt;li&gt;토큰 만료&lt;/li&gt;
&lt;li&gt;일시적인 과부하&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 모든 API 호출을 try-catch로 감싸고, 실패 시 로그를 남깁니다:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;inline fun &amp;lt;reified T&amp;gt; callApi(
    apiId: String,
    uri: String,
    body: Map&amp;lt;String, Any&amp;gt;,
    label: String,
): T? {
    return try {
        rateLimiter.acquire(apiId)

        val response = webClient.post()
            .uri(uri)
            .header(&quot;Authorization&quot;, &quot;Bearer ${authService.getAccessToken()}&quot;)
            .bodyValue(body)
            .retrieve()
            .bodyToMono(T::class.java)
            .timeout(Duration.ofSeconds(10))
            .block()

        logger.debug { &quot;$label 성공&quot; }
        response
    } catch (e: WebClientResponseException) {
        logger.error { &quot;$label 실패: HTTP ${e.statusCode} - ${e.responseBodyAsString}&quot; }
        null
    } catch (e: TimeoutException) {
        logger.error { &quot;$label 타임아웃 (10초 초과)&quot; }
        null
    } catch (e: Exception) {
        logger.error(e) { &quot;$label 오류&quot; }
        null
    }
}&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;10초 타임아웃을 걸어서 무한 대기를 방지하고, 실패하면 null을 반환합니다.&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;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;b578174 - Add improved error handling and thread-safe token management
  └─ 토큰 갱신 로직 개선
     스레드 안전성 확보
     에러 처리 강화

2a4a94f - Add Kiwoom WebSocket for real-time market state updates
  └─ WebSocket 연결 구현
     장 상태 실시간 감지
     KRX/NXT/SOR 거래소 로직&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;WebSocket 구현이 생각보다 까다로웠습니다. 연결이 끊겼다 재연결할 때 LOGIN부터 다시 해야 하는 등의 디테일이 많았거든요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 점들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 금융 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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 실시간 연결은 불안정하다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebSocket 연결은 언제든 끊길 수 있습니다. 자동 재연결 로직은 필수입니다.&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;3. Profile 분리로 실수 방지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 한 줄만 바꿔서 mock&amp;harr;real을 전환하면 위험합니다. 명시적인 Profile 설정으로 실수를 방지해야 합니다.&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;4. 로그가 생명이다&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;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 키움증권 API 연동과 실제 거래 구현에 대해 이야기했습니다. 다음 글에서는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AI가 실제로 어떻게 매매 판단을 내리는지&lt;/li&gt;
&lt;li&gt;기술적 지표 계산 로직&lt;/li&gt;
&lt;li&gt;백테스팅으로 전략 검증하기&lt;/li&gt;
&lt;li&gt;실전 투자 결과와 개선 사항&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 내용들을 다뤄볼 예정입니다. 실제로 AI가 어떤 판단을 내렸고 결과는 어땠는지 공유하겠습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>일상다반사/개발</category>
      <category>AI</category>
      <category>auto</category>
      <category>Kotlin</category>
      <category>spring</category>
      <category>springboot</category>
      <category>stock</category>
      <category>trading</category>
      <author>Kua</author>
      <guid isPermaLink="true">https://ratthe.tistory.com/207</guid>
      <comments>https://ratthe.tistory.com/207#entry207comment</comments>
      <pubDate>Thu, 12 Feb 2026 14:04:15 +0900</pubDate>
    </item>
    <item>
      <title>AI 자동매매 시스템 만들기 #2 - 채널 모니터링과 5단계 매칭 시스템</title>
      <link>https://ratthe.tistory.com/206</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서는 프로젝트 구조와 설계에 대해 이야기했는데요, 오늘은 가장 재미있었던 부분인 텔레그램 채널 모니터링 기능 구현에 대해 이야기해보려고 합니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;result (1).png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beQ7Uq/dJMcabpCfs0/bLFytsy5kr5VEI3O4TaSy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beQ7Uq/dJMcabpCfs0/bLFytsy5kr5VEI3O4TaSy1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beQ7Uq/dJMcabpCfs0/bLFytsy5kr5VEI3O4TaSy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeQ7Uq%2FdJMcabpCfs0%2FbLFytsy5kr5VEI3O4TaSy1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;400&quot; data-filename=&quot;result (1).png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&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;왜 텔레그램 채널을 모니터링하나?&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ffa9a1a6-41e2-4823-8890-87c78621a9d5.png&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dziWbl/dJMcahcfXwx/fPgXxQfh91SWt28b2pbKKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dziWbl/dJMcahcfXwx/fPgXxQfh91SWt28b2pbKKK/img.png&quot; data-alt=&quot;출처: Nano Banana&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dziWbl/dJMcahcfXwx/fPgXxQfh91SWt28b2pbKKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdziWbl%2FdJMcahcfXwx%2FfPgXxQfh91SWt28b2pbKKK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;500&quot; data-filename=&quot;ffa9a1a6-41e2-4823-8890-87c78621a9d5.png&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: Nano Banana&lt;/figcaption&gt;
&lt;/figure&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;주식 투자를 하다 보면 여러 텔레그램 채널을 구독하게 됩니다. 증권사 리포트 채널, 뉴스 속보 채널, 개별 종목 분석 채널 등등... 문제는 이 채널들에서 쏟아지는 정보를 실시간으로 다 확인하기 어렵다는 거죠.&lt;br /&gt;&lt;br /&gt;&quot;내가&amp;nbsp;관심&amp;nbsp;있는&amp;nbsp;종목에&amp;nbsp;대한&amp;nbsp;소식만&amp;nbsp;골라서&amp;nbsp;알림을&amp;nbsp;받을&amp;nbsp;수&amp;nbsp;있다면?&quot;&lt;br /&gt;&lt;br /&gt;이런&amp;nbsp;생각에서&amp;nbsp;시작했습니다.&amp;nbsp;AI가&amp;nbsp;채널&amp;nbsp;메시지를&amp;nbsp;읽고,&amp;nbsp;내가&amp;nbsp;감시&amp;nbsp;중인&amp;nbsp;종목과&amp;nbsp;관련이&amp;nbsp;있는지&amp;nbsp;판단해서&amp;nbsp;알려주면&amp;nbsp;좋겠다는&amp;nbsp;거죠.&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;TDLib - 텔레그램의 공식 라이브러리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텔레그램 봇 API를 쓸까 하다가, 더 강력한 방법을 찾았습니다. 바로 TDLib입니다.&lt;br /&gt;&lt;br /&gt;TDLib(Telegram&amp;nbsp;Database&amp;nbsp;Library)는&amp;nbsp;텔레그램이&amp;nbsp;공식적으로&amp;nbsp;제공하는&amp;nbsp;C++&amp;nbsp;라이브러리인데요,&amp;nbsp;봇&amp;nbsp;API와는&amp;nbsp;달리&amp;nbsp;일반&amp;nbsp;사용자처럼&amp;nbsp;동작할&amp;nbsp;수&amp;nbsp;있습니다.&amp;nbsp;즉,&amp;nbsp;내가&amp;nbsp;직접&amp;nbsp;가입한&amp;nbsp;채널들의&amp;nbsp;메시지를&amp;nbsp;실시간으로&amp;nbsp;받아볼&amp;nbsp;수&amp;nbsp;있죠.&lt;br /&gt;&lt;br /&gt;다만&amp;nbsp;한&amp;nbsp;가지&amp;nbsp;문제가&amp;nbsp;있었습니다.&amp;nbsp;Java/Kotlin에서&amp;nbsp;쓸&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;JNI&amp;nbsp;바인딩이&amp;nbsp;필요한데,&amp;nbsp;Windows에서&amp;nbsp;빌드하는&amp;nbsp;게&amp;nbsp;생각보다&amp;nbsp;까다로웠어요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TDLib Windows 빌드 삽질기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;많은 수행 착오를 겪었습니다.&lt;br /&gt;1.&amp;nbsp;Visual&amp;nbsp;Studio&amp;nbsp;2022&amp;nbsp;필요&lt;br /&gt;2.&amp;nbsp;vcpkg로&amp;nbsp;의존성&amp;nbsp;설치&amp;nbsp;(openssl,&amp;nbsp;zlib&amp;nbsp;등)&lt;br /&gt;3.&amp;nbsp;CMake로&amp;nbsp;빌드&lt;br /&gt;4.&amp;nbsp;Java&amp;nbsp;JNI&amp;nbsp;바인딩까지&amp;nbsp;포함해서&amp;nbsp;컴파일&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1304&quot; data-origin-height=&quot;538&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b8j3ZG/dJMcabwlej0/zymq8D8dvXDVd2BEfKe0fK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b8j3ZG/dJMcabwlej0/zymq8D8dvXDVd2BEfKe0fK/img.png&quot; data-alt=&quot;10번 이상 시도 끝에 또 실패인가 하던 순간..&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b8j3ZG/dJMcabwlej0/zymq8D8dvXDVd2BEfKe0fK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb8j3ZG%2FdJMcabwlej0%2Fzymq8D8dvXDVd2BEfKe0fK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1304&quot; height=&quot;538&quot; data-origin-width=&quot;1304&quot; data-origin-height=&quot;538&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;10번 이상 시도 끝에 또 실패인가 하던 순간..&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;901&quot; data-origin-height=&quot;371&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dbFAfZ/dJMcagdnFBL/yyFUks2gADlvkqNl7W4bQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dbFAfZ/dJMcagdnFBL/yyFUks2gADlvkqNl7W4bQK/img.png&quot; data-alt=&quot;너무 이쁜 초록 글씨 발생!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dbFAfZ/dJMcagdnFBL/yyFUks2gADlvkqNl7W4bQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbFAfZ%2FdJMcagdnFBL%2FyyFUks2gADlvkqNl7W4bQK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;901&quot; height=&quot;371&quot; data-origin-width=&quot;901&quot; data-origin-height=&quot;371&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;너무 이쁜 초록 글씨 발생!&lt;/figcaption&gt;
&lt;/figure&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;채널 모니터링 서비스 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 본격적으로 코드를 볼까요? 채널 모니터링 서비스의 핵심 구조입니다:&lt;/p&gt;
&lt;pre id=&quot;code_1770822819668&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@ConditionalOnProperty(name = [&quot;tdlib.api-id&quot;], matchIfMissing = false)
class ChannelMonitorService(
    private val tdLibProperties: TdLibProperties,
    private val channelMessageRepository: ChannelMessageRepository,
    private val watchStockRepository: WatchStockRepository,
    @param:Qualifier(&quot;general&quot;)
    private val chatClient: ChatClient,
    private val braveSearchService: BraveSearchService? = null,
    private val tavilyService: TavilyService? = null,
) {
    private var client: Client? = null
    private val authorized = AtomicBoolean(false)
    private val channelChatIds = ConcurrentHashMap&amp;lt;Long, String&amp;gt;()

    @PostConstruct
    fun init() {
        // TDLib 네이티브 라이브러리 로딩
        loadNativeLibraries()

        // 클라이언트 생성
        client = Client.create(
            { update -&amp;gt; handleUpdate(update) },
            { e -&amp;gt; logger.error(e) { &quot;TDLib 에러&quot; } },
            { e -&amp;gt; logger.error(e) { &quot;TDLib 기본 에러&quot; } },
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;ConditionalOnProperty를&amp;nbsp;써서&amp;nbsp;TDLib&amp;nbsp;설정이&amp;nbsp;없으면&amp;nbsp;이&amp;nbsp;기능&amp;nbsp;자체를&amp;nbsp;비활성화하도록&amp;nbsp;했습니다.&amp;nbsp;개발&amp;nbsp;환경에서는&amp;nbsp;끄고&amp;nbsp;싶을&amp;nbsp;때가&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;채널 메시지를 받으면, 내가 감시 중인 종목과 관련이 있는지 5단계로 판단합니다:&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1차: 직접 매칭&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 간단한 방법입니다. 메시지에 종목명이나 종목코드가 직접 나오는지 확인합니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770822860821&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private fun findDirectMatches(text: String, watchStocks: List&amp;lt;WatchStock&amp;gt;): List&amp;lt;WatchStock&amp;gt; =
    watchStocks.filter { stock -&amp;gt;
        text.contains(stock.stockName) || text.contains(stock.stockCode)
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&quot;삼성전자 실적 발표&quot; &amp;rarr; 삼성전자 매칭!&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2차: 섹터 키워드 매칭&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;종목을 섹터별로 분류하고, 각 섹터마다 키워드를 등록해뒀습니다. 메시지에 해당 키워드가 있으면 그 섹터의 모든 종목을 매칭합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1770822888629&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private fun findSectorMatches(
    text: String,
    watchStocks: List&amp;lt;WatchStock&amp;gt;,
    alreadyMatched: Set&amp;lt;WatchStock&amp;gt;,
): List&amp;lt;WatchStock&amp;gt; {
    val allKeywords = sectorKeywordRepository.findAll()
    val textLower = text.lowercase()

    val matchedSectorIds = allKeywords
        .filter { kw -&amp;gt; textLower.contains(kw.keyword.lowercase()) }
        .map { it.sector.id }
        .toSet()

    return watchStocks.filter { stock -&amp;gt;
        stock !in alreadyMatched &amp;amp;&amp;amp; stock.sector?.id in matchedSectorIds
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&quot;반도체 수출 증가&quot; &amp;rarr; 반도체 섹터의 모든 종목 매칭!&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #c4c0b9; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3차: AI 연관성 판단&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차, 2차에서 걸러지지 않은 메시지는 AI에게 물어봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1770822917173&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private fun findLlmMatches(text: String, watchStocks: List&amp;lt;WatchStock&amp;gt;): List&amp;lt;WatchStock&amp;gt; {
    val stockList = watchStocks.joinToString(&quot;\n&quot;) { &quot;- ${it.stockName}(${it.stockCode})&quot; }
    val prompt = &quot;&quot;&quot;
        아래 뉴스/메시지가 어떤 종목에 영향을 줄 수 있는지 판단하세요.
        관련 없으면 빈 배열을 반환하세요.

        ## 감시 종목 목록
        $stockList

        ## 메시지
        ${text.take(1000)}
    &quot;&quot;&quot;.trimIndent()

    val result = chatClient.prompt()
        .user(prompt)
        .call()
        .entity(ChannelMatchResult::class.java)
        ?: return emptyList()

    return watchStocks.filter { it.stockCode in result.relatedStockCodes }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&quot;전기차 배터리 화재 논란&quot; &amp;rarr; AI가 LG에너지솔루션, SK온 등을 연관 지음&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #c4c0b9; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;4차:&amp;nbsp;웹&amp;nbsp;검색&amp;nbsp;보강&amp;nbsp;(Brave&amp;nbsp;Search)&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지만으로는 맥락이 부족할 때가 있습니다. 이럴 때는 웹 검색을 해서 추가 정보를 얻습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1770822948440&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private fun findBraveMatches(text: String, watchStocks: List&amp;lt;WatchStock&amp;gt;): List&amp;lt;WatchStock&amp;gt; {
    // AI가 검색 쿼리 생성
    val refinement = refineBraveQuery(text)
    if (refinement == null || !refinement.worthSearching) {
        return emptyList()
    }

    // Brave API로 웹 검색
    val results = braveSearchService?.searchWeb(
        refinement.searchQuery,
        count = 3,
        freshness = &quot;pd&quot;  // past day
    ) ?: return emptyList()

    // 검색 결과에서 종목명 찾기
    val allText = results.joinToString(&quot; &quot;) {
        &quot;${it.title ?: &quot;&quot;} ${it.description ?: &quot;&quot;}&quot;
    }

    return watchStocks.filter { stock -&amp;gt;
        allText.contains(stock.stockName) || allText.contains(stock.stockCode)
    }
}&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;중요한 점: 모든 메시지에 대해 웹 검색을 하면 비용이 너무 많이 나갑니다. 그래서 AI에게 먼저 &quot;이 메시지가 검색할 가치가 있나?&quot;를 물어봅니다.&lt;/p&gt;
&lt;pre id=&quot;code_1770822964560&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private fun refineBraveQuery(text: String): BraveQueryRefinementResult? {
    val prompt = &quot;&quot;&quot;
        아래 텔레그램 채널 메시지를 분석하여 주식 종목 관련 웹 검색 쿼리를 만들어주세요.

        ## 규칙
        1. 특정 기업/종목/사건에 대한 메시지면 worthSearching=true
        2. 아래 유형은 worthSearching=false:
           - 코스피/코스닥 지수 마감/등락 속보
           - 일반 시황 요약
           - 단순 수치 나열

        ## 메시지
        ${text.take(500)}
    &quot;&quot;&quot;.trimIndent()

    return chatClient.prompt()
        .user(prompt)
        .call()
        .entity(BraveQueryRefinementResult::class.java)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&quot;코스피&amp;nbsp;2,600선&amp;nbsp;마감&quot;&amp;nbsp;&amp;rarr;&amp;nbsp;worthSearching=false&amp;nbsp;(검색&amp;nbsp;안&amp;nbsp;함)&lt;br /&gt;&quot;○○기업&amp;nbsp;CEO&amp;nbsp;사퇴&quot;&amp;nbsp;&amp;rarr;&amp;nbsp;worthSearching=true&amp;nbsp;(검색함)&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #c4c0b9; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;5차: URL 본문 추출 (Tavily)&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지에 URL이 포함되어 있으면, 그 링크의 실제 내용을 읽어봅니다.&lt;/p&gt;
&lt;pre id=&quot;code_1770822994049&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private fun findTavilyUrlMatches(text: String, watchStocks: List&amp;lt;WatchStock&amp;gt;): List&amp;lt;WatchStock&amp;gt; {
    val urls = URL_PATTERN.findAll(text).map { it.value }.toList().take(2)
    if (urls.isEmpty()) return emptyList()

    val extracts = tavilyService?.extract(urls) ?: return emptyList()
    val allContent = extracts.joinToString(&quot; &quot;) { it.rawContent ?: &quot;&quot; }

    return watchStocks.filter { stock -&amp;gt;
        allContent.contains(stock.stockName) || allContent.contains(stock.stockCode)
    }
}&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;텔레그램 채널에 &quot;속보 https://...&quot; 이렇게만 올라오는 경우가 많거든요. 이럴 때 링크 안의 내용까지 확인합니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;685&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/7w0G5/dJMcaajVgEm/u9f1DbYf6KjHhq6Hujli0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/7w0G5/dJMcaajVgEm/u9f1DbYf6KjHhq6Hujli0K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/7w0G5/dJMcaajVgEm/u9f1DbYf6KjHhq6Hujli0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F7w0G5%2FdJMcaajVgEm%2Fu9f1DbYf6KjHhq6Hujli0K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;685&quot; height=&quot;1040&quot; data-origin-width=&quot;685&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이미지도 분석한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텔레그램 채널에는 차트 이미지, 뉴스 캡처 등이 많이 올라옵니다. 텍스트만 보면 놓치는 정보가 많아요.&lt;br /&gt;&lt;br /&gt;그래서&amp;nbsp;Vision&amp;nbsp;AI도&amp;nbsp;추가했습니다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1770823086381&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private fun analyzePhotoMessage(
    photoContent: TdApi.MessagePhoto,
    caption: String?,
): ImageAnalysisResult? {
    // 가장 큰 사이즈의 사진 선택
    val photoSize = photoContent.photo?.sizes
        ?.maxByOrNull { it.width * it.height }
        ?: return null

    // TDLib로 이미지 다운로드
    val localPath = downloadFileWithRetry(photoSize.photo.id)
        ?: return null

    val imageBytes = File(localPath).readBytes()

    // Vision AI로 분석
    return imageAnalysisService.analyzeImage(imageBytes, mimeType, caption)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;차트&amp;nbsp;이미지가&amp;nbsp;올라오면&amp;nbsp;AI가&amp;nbsp;&quot;이&amp;nbsp;차트는&amp;nbsp;○○종목의&amp;nbsp;일봉&amp;nbsp;차트이며,&amp;nbsp;상승&amp;nbsp;추세를&amp;nbsp;보이고&amp;nbsp;있습니다&quot;라고&amp;nbsp;분석해줍니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 최적화 - 캐싱&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매 메시지마다 DB에서 감시 종목 목록을 조회하면 느립니다. 그래서 간단한 캐싱을 추가했습니다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1770823113452&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;companion object {
    private const val WATCH_STOCKS_CACHE_TTL_MS = 3 * 60 * 1000L  // 3분
}

@Volatile
private var cachedWatchStocks: List&amp;lt;WatchStock&amp;gt; = emptyList()
@Volatile
private var watchStocksCacheTime: Long = 0L

private fun getActiveWatchStocks(): List&amp;lt;WatchStock&amp;gt; {
    val now = System.currentTimeMillis()
    if (now - watchStocksCacheTime &amp;gt; WATCH_STOCKS_CACHE_TTL_MS) {
        synchronized(this) {
            val nowInLock = System.currentTimeMillis()
            if (nowInLock - watchStocksCacheTime &amp;gt; WATCH_STOCKS_CACHE_TTL_MS) {
                cachedWatchStocks = watchStockRepository.findByActiveTrue()
                watchStocksCacheTime = System.currentTimeMillis()
            }
        }
    }
    return cachedWatchStocks
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;3분마다&amp;nbsp;한&amp;nbsp;번씩만&amp;nbsp;DB&amp;nbsp;조회하고,&amp;nbsp;그&amp;nbsp;사이에는&amp;nbsp;메모리의&amp;nbsp;캐시를&amp;nbsp;씁니다.&amp;nbsp;동시성&amp;nbsp;처리도&amp;nbsp;잊지&amp;nbsp;않았죠.&amp;nbsp;(synchronized&amp;nbsp;+&amp;nbsp;double-check&amp;nbsp;locking)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #c4c0b9; text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;인증 처리 - REST API로&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TDLib를 처음 쓸 때는 전화번호와 인증 코드 입력이 필요합니다. 터미널에서 입력받을 수도 있지만, REST API로 만들어서 더 편하게 했습니다:&lt;/p&gt;
&lt;pre id=&quot;code_1770823143759&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fun submitAuthCode(code: String) {
    client?.send(TdApi.CheckAuthenticationCode().apply {
        this.code = code
    }) { result -&amp;gt;
        if (result is TdApi.Error) {
            logger.error { &quot;인증 코드 실패: ${result.message}&quot; }
        }
    }
}&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;/p&gt;
&lt;pre id=&quot;code_1770823153806&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;========================================
텔레그램 인증 코드를 입력해주세요.
POST /api/tdlib/auth-code?code=XXXXX
========================================&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;그럼 Postman이나 curl로 코드 입력하면 됩니다. 편하죠?&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 커밋 히스토리&lt;/h3&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;pre id=&quot;code_1770823191852&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;b2d6469 - Add Telegram channel monitoring via TDLib
  └─ 기본 채널 모니터링 기능
     TDLib 클라이언트 초기화
     메시지 수신 및 DB 저장

2bd4e8a - Add HTML support for Telegram messages
  └─ 텔레그램 메시지의 HTML 포맷 지원
     주식 거래 로직 검증 추가
     사용자 피드백 개선&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;처음에는 단순하게 만들고, 점점 기능을 추가해갔습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그래서 어떻게 동작하나?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 동작 했었던 모습을 말씀드리자면&lt;br /&gt;&lt;br /&gt;1.&amp;nbsp;텔레그램&amp;nbsp;채널에&amp;nbsp;메시지&amp;nbsp;도착:&amp;nbsp;&quot;○○기업,&amp;nbsp;신규&amp;nbsp;수주&amp;nbsp;1,000억원&amp;nbsp;확보&quot;&lt;br /&gt;2.&amp;nbsp;1차&amp;nbsp;매칭&amp;nbsp;실패&amp;nbsp;(종목명&amp;nbsp;직접&amp;nbsp;언급&amp;nbsp;없음)&lt;br /&gt;3.&amp;nbsp;2차&amp;nbsp;매칭&amp;nbsp;실패&amp;nbsp;(섹터&amp;nbsp;키워드&amp;nbsp;없음)&lt;br /&gt;4.&amp;nbsp;3차&amp;nbsp;AI&amp;nbsp;판단:&amp;nbsp;&quot;○○기업은&amp;nbsp;감시&amp;nbsp;종목&amp;nbsp;중&amp;nbsp;ABC주식과&amp;nbsp;관련&amp;nbsp;있음&quot;&lt;br /&gt;5.&amp;nbsp;DB에&amp;nbsp;저장:&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;nbsp;message.content&amp;nbsp;=&amp;nbsp;&quot;○○기업,&amp;nbsp;신규&amp;nbsp;수주...&quot;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;nbsp;match.stockCode&amp;nbsp;=&amp;nbsp;&quot;123456&quot;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;nbsp;match.matchType&amp;nbsp;=&amp;nbsp;&quot;LLM&quot;&lt;br /&gt;6.&amp;nbsp;AI가&amp;nbsp;이&amp;nbsp;정보를&amp;nbsp;바탕으로&amp;nbsp;매매&amp;nbsp;판단&lt;br /&gt;&lt;br /&gt;와 같은 모습이 되겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배운 점들&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 네이티브 라이브러리 연동은 생각보다 까다롭다&lt;br /&gt;TDLib 빌드하느라 반나절을 썼습니다. 의존성 버전, 빌드 옵션... 한 번 성공하면 문서화해두는 게 중요해요.&lt;br /&gt;&lt;br /&gt;2.&amp;nbsp;멀티스레드&amp;nbsp;환경에서의&amp;nbsp;캐싱&amp;nbsp;주의&lt;br /&gt;TDLib는 콜백 방식이라 여러 스레드에서 동시에 메시지가 들어올 수 있습니다. synchronized와 volatile을 적절히 써야 합니다.&lt;br /&gt;&lt;br /&gt;3.&amp;nbsp;AI&amp;nbsp;호출&amp;nbsp;비용&amp;nbsp;최적화&amp;nbsp;필요&lt;br /&gt;처음에는 모든 메시지에 대해 AI/웹검색을 했다가 비용이 급증했습니다. &quot;검색할 가치가 있나?&quot; 를 먼저 물어보는 게 중요합니다.&lt;br /&gt;&lt;br /&gt;4.&amp;nbsp;5단계&amp;nbsp;매칭은&amp;nbsp;충분하다&lt;br /&gt;처음에는 &quot;더 복잡한 매칭 알고리즘이 필요하지 않을까?&quot; 고민했는데, 5단계면 거의 모든 경우를 커버합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다음 글 예고&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 텔레그램 채널 모니터링 기능에 대해 이야기했습니다. 다음 글에서는:&lt;br /&gt;-&amp;nbsp;키움증권&amp;nbsp;REST&amp;nbsp;API&amp;nbsp;연동&amp;nbsp;과정&lt;br /&gt;-&amp;nbsp;실시간&amp;nbsp;시세&amp;nbsp;수신&amp;nbsp;구현&lt;br /&gt;- WebSocket 장 상태 감지&lt;br /&gt;-&amp;nbsp;실제&amp;nbsp;매매&amp;nbsp;주문&amp;nbsp;실행&amp;nbsp;로직&lt;br /&gt;&lt;br /&gt;이런 내용들을 다뤄볼 예정입니다. 실제로 돈이 오가는 부분이라 더 신중하게 만들었거든요!&lt;/p&gt;</description>
      <category>일상다반사/개발</category>
      <category>AI</category>
      <category>Kotlin</category>
      <category>spring</category>
      <category>springai</category>
      <category>springboot</category>
      <category>stock</category>
      <category>trading</category>
      <author>Kua</author>
      <guid isPermaLink="true">https://ratthe.tistory.com/206</guid>
      <comments>https://ratthe.tistory.com/206#entry206comment</comments>
      <pubDate>Thu, 12 Feb 2026 00:24:20 +0900</pubDate>
    </item>
    <item>
      <title>AI 자동매매 시스템 만들기 #1 - 프로젝트의 시작과 설계</title>
      <link>https://ratthe.tistory.com/205</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;오늘부터 제가 만들고 있는 AI 자동매매 시스템 프로젝트 개발기를 연재하려고 합니다. 첫 번째 글에서는 프로젝트가 어떻게 시작되었고, 어떤 기술 스택을 선택했는지 이야기해볼게요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;result.png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFCTfw/dJMcagqS7DE/OjsqFx02YyTYCEGAO4lwk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFCTfw/dJMcagqS7DE/OjsqFx02YyTYCEGAO4lwk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFCTfw/dJMcagqS7DE/OjsqFx02YyTYCEGAO4lwk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFCTfw%2FdJMcagqS7DE%2FOjsqFx02YyTYCEGAO4lwk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;400&quot; data-filename=&quot;result.png&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&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;왜 만들게 되었나?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 요즘 핫한 AI. 어떻게든 활용 능력을 키워보고 싶었습니다. (그래도 내가 백엔드 개발자인데...)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 과거 키움 증권의 Open API를 이용해서 구현을 시도하다가 32bit 프로그램을 포함한 다양한 디펜던시와 메인 PC에 깔아야 하는 보안 프로그램(매번 로그인도 번거로웠던).. 치를 떨고 줄행랑 쳤습니다. 그런데.... REST API가 나왔었더라구요!!&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;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;AI가 객관적으로 판단해서 자동으로 매매하면 어떨까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 시중에 자동매매 프로그램들이 많이 있지만, 개발자로서 직접 만들어보고 싶었어요. 내가 원하는 방식으로 AI를 훈련시키고, 나만의 전략을 코드로 구현하는 재미도 있고요.&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;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 아키텍처&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 설명하면 이렇습니다:&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;881&quot; data-origin-height=&quot;277&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5c9iT/dJMcaaEekAQ/mDZBUpWPv4zkGXJn4X4ZVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5c9iT/dJMcaaEekAQ/mDZBUpWPv4zkGXJn4X4ZVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5c9iT/dJMcaaEekAQ/mDZBUpWPv4zkGXJn4X4ZVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5c9iT%2FdJMcaaEekAQ%2FmDZBUpWPv4zkGXJn4X4ZVK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;881&quot; height=&quot;277&quot; data-origin-width=&quot;881&quot; data-origin-height=&quot;277&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #c4c0b9; text-align: start;&quot; data-ke-list-type=&quot;disc&quot; data-darkreader-inline-color=&quot;&quot;&gt;
&lt;li&gt;키움증권 REST API로 시세 정보를 가져옵니다&lt;/li&gt;
&lt;li&gt;Spring Boot 서버가 중간에서 모든 로직을 처리합니다&lt;/li&gt;
&lt;li&gt;vLLM으로 돌리는 로컬 AI 모델이 매매 판단을 내립니다&lt;/li&gt;
&lt;li&gt;텔레그램 봇으로 알림을 받고 명령도 내릴 수 있습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 결정이 하나 있었는데요, AI 모델을 외부 API가 아닌 로컬에서 돌리기로 했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 로컬 AI를 선택했나?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 고민을 할 이유도 없이 비용이죠. 사실 사이드 프로젝트인데 돈을 쓰고 싶지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제 PC도 작은 모델을 돌리기에는 훌륭한 성능이었기 때문이기도 하구요!&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;그래서 선택한 모델이 Gemma 3 12B FP8 버전입니다. 12B 모델이면 충분히 똑똑하면서도 일반 게이밍 PC에서 돌릴 수 있거든요. vLLM으로 OpenAI API와 호환되는 형식으로 서빙하면, Spring AI에서 쉽게 연동할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최종 기술 스택 선택&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Kotlin + Spring Boot 3.x: 자바 생태계의 강력함 + 코틀린의 간결함 (사실 현재 나의 메인 기술스택...)&lt;/li&gt;
&lt;li&gt;Spring AI: AI 모델과의 통신을 쉽게 해주는 프레임워크&lt;/li&gt;
&lt;li&gt;SQLite: 개인 프로젝트에는 가볍고 충분한 DB&lt;/li&gt;
&lt;li&gt;Spring Scheduler: 주기적으로 매매 로직을 돌리기 위해&lt;/li&gt;
&lt;li&gt;Telegram Bot: 모바일에서 알림 받고 명령 내리기&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&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;한 종목에 너무 많은 돈을 넣으면 위험합니다&lt;/li&gt;
&lt;li&gt;손실이 커지기 전에 자동으로 손절해야 합니다&lt;/li&gt;
&lt;li&gt;수익이 나면 적당히 익절해야 합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 코드를 보시면 이런 설정들이 있어요:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@ConfigurationProperties(prefix = &quot;trading.risk&quot;)
data class RiskProperties(
    val maxInvestmentPerStock: Long = 1_000_000,
    val stopLossRate: Double = -3.0,
    val takeProfitRate: Double = 5.0,
    val maxTotalInvestment: Long = 10_000_000,
    val buyConfidenceThreshold: Int = 70,
    val buyHighRiskConfidenceThreshold: Int = 85,
    val sellStopLossConfidenceThreshold: Int = 50,
    val sellTakeProfitConfidenceThreshold: Int = 65,
)&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;종목당 최대 투자금: 100만원&lt;/li&gt;
&lt;li&gt;총 투자 한도: 1000만원&lt;/li&gt;
&lt;li&gt;손절 기준: -3%&lt;/li&gt;
&lt;li&gt;익절 기준: +5%&lt;/li&gt;
&lt;li&gt;AI 신뢰도 임계값: 매수는 70% 이상일 때만&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 아무리 &quot;사라!&quot;고 해도, 리스크 매니저가 &quot;안 돼, 한도 초과야&quot;라고 막을 수 있는 거죠. 중요한 안전장치입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기술적 지표 계산&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주식 분석에 쓰이는 기술적 지표들도 구현했습니다. AI에게 단순히 가격 데이터만 주는 게 아니라, 이미 계산된 기술적 지표를 함께 제공합니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SMA/EMA: 이동평균선 (추세 파악)&lt;/li&gt;
&lt;li&gt;RSI: 과매수/과매도 판단&lt;/li&gt;
&lt;li&gt;MACD: 매매 신호&lt;/li&gt;
&lt;li&gt;볼린저 밴드: 변동성 측정&lt;/li&gt;
&lt;li&gt;스토캐스틱: 모멘텀 지표&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;736&quot; data-origin-height=&quot;1104&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4OGg7/dJMcafyL9kh/Mz8P8FO0QjGyhB6BRmKGQK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4OGg7/dJMcafyL9kh/Mz8P8FO0QjGyhB6BRmKGQK/img.jpg&quot; data-alt=&quot;출처: https://tr.pinterest.com/pin/ema-vs-sma-understanding-the-differences--477451998010678524/&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4OGg7/dJMcafyL9kh/Mz8P8FO0QjGyhB6BRmKGQK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4OGg7%2FdJMcafyL9kh%2FMz8P8FO0QjGyhB6BRmKGQK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;750&quot; data-origin-width=&quot;736&quot; data-origin-height=&quot;1104&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: https://tr.pinterest.com/pin/ema-vs-sma-understanding-the-differences--477451998010678524/&lt;/figcaption&gt;
&lt;/figure&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;AI 분석 서비스 코드를 보면 이런 식으로 데이터를 수집합니다:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 1. 종목 기본정보
val stockInfo = marketService.getStockInfo(stockCode)
val stockName = stockInfo.stockName!!
var currentPrice = KiwoomNumberParser.parseLong(stockInfo.currentPrice)

// 2. 일봉 차트 &amp;rarr; OHLCV 데이터 추출
val dailyChart = marketService.getDailyChart(stockCode)
val candles = dailyChart?.output?.filter {
    KiwoomNumberParser.parseLong(it.closePrice).toDouble() &amp;gt; 0
} ?: emptyList()

val closes = candles.map { KiwoomNumberParser.parseLong(it.closePrice).toDouble() }
val highs = candles.map { KiwoomNumberParser.parseLong(it.highPrice).toDouble() }
val lows = candles.map { KiwoomNumberParser.parseLong(it.lowPrice).toDouble() }
val volumes = candles.map { KiwoomNumberParser.parseLong(it.volume).toDouble() }

// 3. 기술적 지표 계산
val indicators = if (closes.size &amp;gt;= 5) {
    TechnicalIndicator.analyzeWithOhlcv(closes, highs, lows, volumes)
} else {
    emptyMap()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 모은 데이터를 AI에게 프롬프트로 전달하면, AI는 이걸 보고 &quot;지금 사야 할지 말지&quot;를 판단합니다. 마치 사람이 차트를 보고 판단하는 것처럼요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI 분석 프롬프트 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 정보를 줄 때는 체계적으로 정리해서 줍니다:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;private fun buildAnalysisPrompt(
    stockName: String,
    stockCode: String,
    currentPrice: Long,
    indicators: Map&amp;lt;String, Any?&amp;gt;,
    candles: List&amp;lt;DailyCandle&amp;gt;,
    holding: StockBalance?,
    pastDecisions: List&amp;lt;AiDecision&amp;gt;,
    channelMessages: List&amp;lt;ChannelMessage&amp;gt;,
    braveNews: List&amp;lt;BraveNewsResult&amp;gt;,
): String = buildString {
    appendLine(&quot;## 종목: $stockName ($stockCode)&quot;)
    appendLine(&quot;현재가: ${currentPrice}원&quot;)
    
    appendLine(&quot;## 기술적 지표&quot;)
    indicators.forEach { (k, v) -&amp;gt; appendLine(&quot;- $k: $v&quot;) }
    
    appendLine(&quot;## 최근 일봉 데이터&quot;)
    candles.take(10).forEach { c -&amp;gt; appendLine(formatCandle(c)) }
    
    if (holding != null) {
        appendLine(&quot;## 현재 보유&quot;)
        appendLine(&quot;- 수량: ${holding.holdingQty}&quot;)
        appendLine(&quot;- 평균매입가: ${holding.avgBuyPrice}&quot;)
        appendLine(&quot;- 수익률: ${holding.profitLossRate}%&quot;)
    }
    
    appendLine(&quot;## 텔레그램 채널 뉴스/소식&quot;)
    channelMessages.forEach { m -&amp;gt; 
        appendLine(&quot;- [${m.createdAt}] @${m.channelName}: ${m.content}&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 정리된 정보를 받은 AI는 JSON 형식으로 판단을 내려줍니다:&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;decision&quot;: &quot;BUY&quot;,
  &quot;confidence&quot;: 75,
  &quot;reasoning&quot;: &quot;RSI가 30 이하로 과매도 구간이며, MACD 골든크로스 발생...&quot;,
  &quot;targetPrice&quot;: 50000,
  &quot;riskLevel&quot;: &quot;MEDIUM&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개발 단계별 계획&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 설계할 때 Phase별로 나눠놨습니다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Phase 1-2: 프로젝트 기본 설정, 키움 인증&lt;br /&gt;Phase 3-4: 시세 조회, 주문 실행&lt;br /&gt;Phase 5-6: 기술적 지표 계산, AI 분석&lt;br /&gt;Phase 7-8: 자동매매 스케줄러, REST API&lt;br /&gt;Phase 9-10: 텔레그램 봇 (알림 + 자연어 명령)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 단계를 명확하게 나누니까 개발하기가 훨씬 수월했습니다. &quot;오늘은 Phase 3만 완성하자!&quot; 이런 식으로 집중할 수 있었거든요.&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;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;c48b099 - Initial commit
  └─ 프로젝트 기본 구조, Spring Boot 설정
     AI 분석 서비스, 대시보드 컨트롤러
     키움/텔레그램 설정 파일들

b10a85f - Add risk management enhancements
  └─ 리스크 관리 설정 추가
     기술적 지표 확장&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 커밋에서 이미 2,462줄의 코드가 들어갔네요. 미리 구조를 잘 잡아놓으니까 나중에 기능 추가하기가 훨씬 쉬웠습니다.&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;키움증권 API 연동 삽질기&lt;/li&gt;
&lt;li&gt;텔레그램 봇 구현 과정&lt;/li&gt;
&lt;li&gt;실제로 AI가 어떻게 판단하는지&lt;/li&gt;
&lt;li&gt;텔레그램 채널 모니터링 구현&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 내용들을 다뤄볼 예정입니다. 실제 코드와 함께 더 재미있는 이야기를 들려드릴게요!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혹시 궁금한 점이 있으시면 댓글로 남겨주세요. 다음 글에서 답변드리겠습니다!  &lt;/p&gt;</description>
      <category>일상다반사/개발</category>
      <category>AI</category>
      <category>auto</category>
      <category>Kotlin</category>
      <category>spring</category>
      <category>springboot</category>
      <category>sqlite</category>
      <category>stock</category>
      <category>trading</category>
      <author>Kua</author>
      <guid isPermaLink="true">https://ratthe.tistory.com/205</guid>
      <comments>https://ratthe.tistory.com/205#entry205comment</comments>
      <pubDate>Wed, 11 Feb 2026 21:47:48 +0900</pubDate>
    </item>
    <item>
      <title>2026 macOS 유틸</title>
      <link>https://ratthe.tistory.com/204</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Generated Image January 20, 2026 - 1_25AM.png&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pcsUT/dJMcab31RRT/fq2l2t0blkSg4AUj4kNF1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pcsUT/dJMcab31RRT/fq2l2t0blkSg4AUj4kNF1K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pcsUT/dJMcab31RRT/fq2l2t0blkSg4AUj4kNF1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpcsUT%2FdJMcab31RRT%2Ffq2l2t0blkSg4AUj4kNF1K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;400&quot; data-filename=&quot;Generated Image January 20, 2026 - 1_25AM.png&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Homebrew&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 맥을 설치하면 반드시 맥주 한 잔!&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot; href=&quot;https://brew.sh/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://brew.sh/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot; href=&quot;https://github.com/Homebrew/brew&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;무료 (BSD-2-Clause)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Keka&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: Windows는 반디집. macOS는 케카&lt;/li&gt;
&lt;li&gt;공식 웹사이트:&amp;nbsp;&lt;a href=&quot;https://www.keka.io/&quot;&gt;https://www.keka.io/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스:&amp;nbsp;&lt;a href=&quot;https://github.com/aonez/Keka&quot;&gt;무료&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;f.lux&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 소중한 눈을 지킵시다. mac의 기본 내장 기능(Night Shift)은 쓰레기입니다.&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://justgetflux.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://justgetflux.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 무료&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문서 유틸&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CotEditor&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: Windows의 Notepad++ 대체 앱&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://coteditor.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://coteditor.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a href=&quot;https://github.com/coteditor/CotEditor&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;무료 (Apache-2.0)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;draw.io&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 무료 중에선 원 탑 다이어그램 앱&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://www.drawio.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.drawio.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a href=&quot;https://github.com/jgraph/drawio&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;무료 (Apache-2.0)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;UpNote&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 노트 프로그램. (이것 저것 써보았으나, 다양한 생태계를 이용중인 저로서는 가장 적합한 솔루션이었습니다) 업데이트가 마음에 들어 영구라이선스 구매완료!&lt;/li&gt;
&lt;li&gt;공식 웹사이트:&amp;nbsp;&lt;a href=&quot;https://getupnote.com/&quot;&gt;https://getupnote.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 부분 무료&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Spark&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 최고의 이메일 통합 프로그램. 무료로도 충분히 이용 가능!&lt;/li&gt;
&lt;li&gt;공식 웹사이트:&amp;nbsp;&lt;a href=&quot;https://sparkmailapp.com/&quot;&gt;https://sparkmailapp.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 부분 무료&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;맞춤법 유틸&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Matchumbeop&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 한국어 맞춤법&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://matchumbeop.ssut.me/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://matchumbeop.ssut.me/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a href=&quot;https://matchumbeop.ssut.me/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;무료 (Apache-2.0)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Refine&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 해외 프로그램이나, 더욱 사용성이 좋고 한국어 성능도 충분한 것으로 보임&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://refine.sh/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://refine.sh/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 유료 (7일 체험)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;UX 유틸&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Ice&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 작업표시줄을 Awesome하게!&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://icemenubar.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://icemenubar.app/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스:&amp;nbsp;&lt;a href=&quot;https://github.com/jordanbaird/Ice&quot;&gt;무료 (GPL-3.0)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Scroll Reverser&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: Windows OS를 더 오래써오신 분이라면 필수템. Logitech Options로도 변경해서 써보았으나 쓰는 시점에서 기억은 안나지만 치명적인 오류가 있어 이 프로그램을 사용하는게 훨씬 좋았음(훨씬 가볍기도 함)&lt;/li&gt;
&lt;li&gt;공식 웹사이트:&amp;nbsp;&lt;a href=&quot;https://pilotmoon.com/scrollreverser/&quot;&gt;https://pilotmoon.com/scrollreverser/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스:&amp;nbsp;&lt;a href=&quot;https://github.com/pilotmoon/Scroll-Reverser&quot;&gt;무료 (Apache-2.0)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;맥 인프라 유틸&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;AppCleaner&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 앱 깔끔하게 지우기&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://freemacsoft.net/appcleaner/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://freemacsoft.net/appcleaner/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 무료&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Clop&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 클립보드 최적화(이미지, 영상 압축)&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://lowtechguys.com/clop/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://lowtechguys.com/clop/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a href=&quot;https://github.com/FuzzyIdeas/Clop&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;부분 무료 (GPL-3.0)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개발자 유틸&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;개발자가 아니라면 굳이... 싶은 것들!&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Gitify&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: Github 알림 수신받기&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://github.com/gitify-app/gitify&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/gitify-app/gitify&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 무료 (MIT)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Warp&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 터미널 프로그램. Terminal -&amp;gt; iTerm -&amp;gt; Ghost -&amp;gt; Warp 로 정착하였음&lt;/li&gt;
&lt;li&gt;공식 웹사이트:&amp;nbsp;&lt;a href=&quot;https://www.warp.dev/&quot;&gt;https://www.warp.dev/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 부분 무료&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Raycast&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 인체 공학적 shortcut(Spotlight 대체). 2025년도부터 Alfred에서 갈아탔는데 강력합니다.&lt;/li&gt;
&lt;li&gt;공식 웹사이트:&amp;nbsp;&lt;a href=&quot;https://www.raycast.com/&quot;&gt;https://www.raycast.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 부분 무료&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;DevToys&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: &lt;a href=&quot;https://it-tools.tech/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://it-tools.tech/&lt;/a&gt; 라는 대체제는 존재하나 설치형은 매우 빠르게 동작하며, 브라우저보다 최적화가 좋으며(메모리 누수가 적으며) 공학성이 뛰어납니다. 광고 없는 무료 프로그램중에는 탑이라 생각합니다.&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://devtoys.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://devtoys.app/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a href=&quot;https://github.com/DevToys-app/DevToys&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;무료 (MIT)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;크로스 OS 유저 유틸&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;input-leap&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/input-leap/input-leap&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/input-leap/input-leap&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spotify 유틸&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Spotify Dedup&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://spotify-dedup.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://spotify-dedup.com/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a href=&quot;https://github.com/JMPerez/spotify-dedup&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;무료 (MIT)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Spicetify&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 스포티파이 클라이언트를 씹고 뜯고 맛보고 즐기고&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://spicetify.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://spicetify.app/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a href=&quot;https://github.com/spicetify/cli&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;무료 (LGPL-2.1)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;SpotX&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 광고 제거, 트래커 제거&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://github.com/SpotX-Official/SpotX&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/SpotX-Official/SpotX&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 무료 (MIT)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ivLyrics&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 노래들을때 영상도 보고, 해외 가사도 번역해서 보고싶다면! (한국인 맞춤형. 한국인 개발자.)&lt;/li&gt;
&lt;li&gt;공식 웹사이트:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://github.com/ivLis-Studio/ivLyrics&quot;&gt;https://github.com/ivLis-Studio/ivLyrics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 무료 (LGPL-2.1)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Lyrs&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 알송 가사 플로팅 확장 (Musixmatch Plugin도 존재)&lt;/li&gt;
&lt;li&gt;공식 웹사이트:&lt;span&gt; &lt;a href=&quot;https://github.com/organization/lyrs&quot;&gt;https://github.com/organization/lyrs&lt;/a&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 무료 (Apache-2.0)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 style=&quot;color: #000000;&quot; data-ke-size=&quot;size26&quot;&gt;기타 유틸&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Cyberduck&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: macOS 최고의 무료 FTP 클라이언트&lt;/li&gt;
&lt;li&gt;공식 웹사이트:&amp;nbsp;&lt;a href=&quot;https://cyberduck.io/&quot;&gt;https://cyberduck.io/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스:&amp;nbsp;&lt;a href=&quot;https://github.com/iterate-ch/cyberduck&quot;&gt;무료 (GPL-3.0)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Input Source Pro&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 번거로운 한영 전환 최소화. IME 전환 상태 표시기&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://inputsource.pro/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://inputsource.pro/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a href=&quot;https://github.com/runjuu/InputSourcePro&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;무료 (GPL-3.0)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Karabiner&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://karabiner-elements.pqrs.org/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://karabiner-elements.pqrs.org/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a href=&quot;https://github.com/pqrs-org/Karabiner-Elements&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;무료 (The Unlicense)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Maccy&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 클립보드 히스토리 저장 프로그램. Raycast에도 있지만 이게 더 사용성이 좋고 유연함&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://maccy.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://maccy.app/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: &lt;a href=&quot;https://github.com/p0deje/Maccy&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;무료 (MIT)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Shottr&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 스크린샷 + 그림판 프로그램. 너무 잘 쓰고 있어 저는 영구라이선스 구매하였습니다.&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://shottr.cc/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://shottr.cc/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 유료 (30일 체험)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;xray&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설명: 요소 캡처 프로그램.&lt;/li&gt;
&lt;li&gt;공식 웹사이트: &lt;a href=&quot;https://github.com/wlswo/xray&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/wlswo/xray&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;라이선스: 무료 (GPL-3.0)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Tips</category>
      <category>Mac</category>
      <category>util</category>
      <author>Kua</author>
      <guid isPermaLink="true">https://ratthe.tistory.com/204</guid>
      <comments>https://ratthe.tistory.com/204#entry204comment</comments>
      <pubDate>Mon, 19 Jan 2026 18:02:29 +0900</pubDate>
    </item>
    <item>
      <title>Spring boot RestClient H2C 설정</title>
      <link>https://ratthe.tistory.com/203</link>
      <description>&lt;pre id=&quot;code_1745388485376&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class RestClientConfig {

    @Value(&quot;${...}&quot;)
    private String baseUrl;

    @Bean
    public RestClient restClient() {
        return RestClient.builder()
                .requestFactory(new JdkClientHttpRequestFactory(
                        HttpClient.newBuilder()
                                .version(HttpClient.Version.HTTP_2)
                                .build()
                ))
                .baseUrl(baseUrl)
                .defaultHeader(&quot;Content-Type&quot;, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Framework &amp;amp; Runtime/SpringBoot</category>
      <category>client</category>
      <category>H2C</category>
      <category>http2</category>
      <category>RestClient</category>
      <category>Spring Boot</category>
      <author>Kua</author>
      <guid isPermaLink="true">https://ratthe.tistory.com/203</guid>
      <comments>https://ratthe.tistory.com/203#entry203comment</comments>
      <pubDate>Wed, 23 Apr 2025 15:09:13 +0900</pubDate>
    </item>
    <item>
      <title>와우 클래식 도적 자물쇠 숙련 100-150 정확한 위치</title>
      <link>https://ratthe.tistory.com/202</link>
      <description>&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: center;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;사진의 초록색 상자를 참고해주십시오.&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;붉은마루 산맥 - 영원의 호수 (100 ~ 125)&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;469&quot; data-origin-height=&quot;321&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/75pWm/btsNjLHSpuK/t196Qv3518shYljgAzlII1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/75pWm/btsNjLHSpuK/t196Qv3518shYljgAzlII1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/75pWm/btsNjLHSpuK/t196Qv3518shYljgAzlII1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F75pWm%2FbtsNjLHSpuK%2Ft196Qv3518shYljgAzlII1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;469&quot; height=&quot;321&quot; data-origin-width=&quot;469&quot; data-origin-height=&quot;321&quot;/&gt;&lt;/span&gt;&lt;/figure&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;저습지 (125 ~ 150)&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;906&quot; data-origin-height=&quot;638&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfso8i/btsNkiemzU6/0vpLXBsxsmKj3m2ps8Ktpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfso8i/btsNkiemzU6/0vpLXBsxsmKj3m2ps8Ktpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfso8i/btsNkiemzU6/0vpLXBsxsmKj3m2ps8Ktpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbfso8i%2FbtsNkiemzU6%2F0vpLXBsxsmKj3m2ps8Ktpk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;906&quot; height=&quot;638&quot; data-origin-width=&quot;906&quot; data-origin-height=&quot;638&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>Games/그외</category>
      <category>Classic</category>
      <category>ROUGE</category>
      <category>WoW</category>
      <category>도적</category>
      <category>숙련</category>
      <category>와우</category>
      <category>자물쇠</category>
      <category>클래식</category>
      <author>Kua</author>
      <guid isPermaLink="true">https://ratthe.tistory.com/202</guid>
      <comments>https://ratthe.tistory.com/202#entry202comment</comments>
      <pubDate>Sun, 13 Apr 2025 20:37:43 +0900</pubDate>
    </item>
  </channel>
</rss>