<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Dwon'story</title>
    <link>https://developer-dwon.tistory.com/</link>
    <description>Dwon의 개발이야기</description>
    <language>ko</language>
    <pubDate>Mon, 27 Jul 2026 08:38:15 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Dwony</managingEditor>
    <image>
      <title>Dwon'story</title>
      <url>https://tistory1.daumcdn.net/tistory/5728195/attach/9d79bf92233340a88894bc4055017fae</url>
      <link>https://developer-dwon.tistory.com</link>
    </image>
    <item>
      <title>Planet Palette - 우주가 넓어지다: 성간공간</title>
      <link>https://developer-dwon.tistory.com/entry/Planet-Palette-%EC%9A%B0%EC%A3%BC%EA%B0%80-%EB%84%93%EC%96%B4%EC%A7%80%EB%8B%A4-%EC%84%B1%EA%B0%84%EA%B3%B5%EA%B0%84</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;나의&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;&lt;a href=&quot;https://planetpalette.vercel.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://planetpalette.vercel.app/&lt;/a&gt;  &amp;nbsp;바로&amp;nbsp;써보기!&lt;/p&gt;
&lt;figure id=&quot;og_1782031120967&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;플래닛팔레트 &amp;mdash; 여행 사진이 나만의 태양계가 되다&quot; data-og-description=&quot;당신이 다녀온 그곳이 행성이 된다면? 사진을 올리면 색감을 분석해 나만의 우주를 만들어드립니다. 무료, 즉시 사용.&quot; data-og-host=&quot;planetpalette.vercel.app&quot; data-og-source-url=&quot;https://planetpalette.vercel.app/&quot; data-og-url=&quot;https://planetpalette.vercel.app&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/F8BE9/dJMb83kCwGF/R3H6rYM7M2bnIXOcLoBFkK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/cK5TwP/dJMb8XSfpXX/KpMigotdAk1KlAjzyOpQZK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://planetpalette.vercel.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://planetpalette.vercel.app/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/F8BE9/dJMb83kCwGF/R3H6rYM7M2bnIXOcLoBFkK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/cK5TwP/dJMb8XSfpXX/KpMigotdAk1KlAjzyOpQZK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;플래닛팔레트 &amp;mdash; 여행 사진이 나만의 태양계가 되다&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;당신이 다녀온 그곳이 행성이 된다면? 사진을 올리면 색감을 분석해 나만의 우주를 만들어드립니다. 무료, 즉시 사용.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;planetpalette.vercel.app&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;1편 보고오기   &lt;a href=&quot;https://developer-dwon.tistory.com/entry/%EB%82%98%EB%A7%8C%EC%9D%98-%EC%9A%B0%EC%A3%BC-Planet-Palette&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.05.27 - [Project/Planet Palette] - Planet Palette - 나만의 태양계&lt;/a&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;태양계 하나로는 부족했다. 여행이 쌓이면 우주가 되어야 한다고 생각했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;618&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Fgrtp/dJMcahLCwC1/UmMaVOHPsz0X6sP04wAQVk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Fgrtp/dJMcahLCwC1/UmMaVOHPsz0X6sP04wAQVk/img.gif&quot; data-alt=&quot;성간공간뷰&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Fgrtp/dJMcahLCwC1/UmMaVOHPsz0X6sP04wAQVk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/Fgrtp/dJMcahLCwC1/UmMaVOHPsz0X6sP04wAQVk/img.gif&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;800&quot; height=&quot;618&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;618&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;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;b&gt;성간공간 뷰(InterstellarView) 전면 재설계&lt;/b&gt; &amp;mdash; 12개 태양계, 단일&amp;middot;이중&amp;middot;성단 구조&lt;/li&gt;
&lt;li&gt;&lt;b&gt;UUID 기반 공유 시스템 + 내 기록 페이지&lt;/b&gt; &amp;mdash; Supabase 백엔드, 링크 공유, 소유권 검증&lt;/li&gt;
&lt;li&gt;&lt;b&gt;아이디어풀&lt;/b&gt; &amp;mdash; 유저 피드백을 직접 받는 창구&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자잘하지만 중요한 UX 개선들&lt;/b&gt; &amp;mdash; 몰입 모드, 가이드 개선, 언어 선택&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각 왜 만들었고, 어떻게 만들었는지 순서대로 적어본다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 성간공간 뷰 &amp;mdash; 여행이 쌓이면 우주가 된다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Planet Palette의 핵심 아이디어는 &quot;여행이 하나의 태양계가 된다&quot;는 거다. 그런데 태양계가 두 개, 세 개가 되면? 그냥 목록으로 보여주는 건 너무 평범하다고 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에 sc2(줌아웃 뷰)를 처음 만들 때는 태양계를 단순하게 배치하는 수준이었다. 이번에 제대로 다시 설계했다.&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;세 가지 구조를 정했다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;단일항성계&lt;/b&gt; &amp;mdash; 현재 보고 있는 내 태양계. 움직이지 않는다. 화면 한쪽에 홀로 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이중항성계&lt;/b&gt; &amp;mdash; 2개의 태양계가 공통질량중심(바리센터)을 기준으로 서로를 돌고 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;삼중항성계&lt;/b&gt; &amp;mdash; 3개씩 묶인 그룹 3개. 각 그룹이 서로 다른 속도와 방향으로 공전한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 18px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 33.217%; height: 18px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;300&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cRGw7i/dJMcac4AZWC/OM22eOrvxWIJ9NLKAAMbEK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cRGw7i/dJMcac4AZWC/OM22eOrvxWIJ9NLKAAMbEK/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cRGw7i/dJMcac4AZWC/OM22eOrvxWIJ9NLKAAMbEK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cRGw7i/dJMcac4AZWC/OM22eOrvxWIJ9NLKAAMbEK/img.gif&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;300&quot; height=&quot;300&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;300&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 33.4496%; height: 18px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;300&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oS3eS/dJMb99UloGX/t4MoChbCwgmk0lI4Wga551/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oS3eS/dJMb99UloGX/t4MoChbCwgmk0lI4Wga551/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oS3eS/dJMb99UloGX/t4MoChbCwgmk0lI4Wga551/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/oS3eS/dJMb99UloGX/t4MoChbCwgmk0lI4Wga551/img.gif&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;300&quot; height=&quot;300&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;300&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 18px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;300&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pMQTF/dJMcaiKtKVj/jBLiwsLDZhl26HfzgO3Wp1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pMQTF/dJMcaiKtKVj/jBLiwsLDZhl26HfzgO3Wp1/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pMQTF/dJMcaiKtKVj/jBLiwsLDZhl26HfzgO3Wp1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/pMQTF/dJMcaiKtKVj/jBLiwsLDZhl26HfzgO3Wp1/img.gif&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;300&quot; height=&quot;300&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;300&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최대 12개 태양계를 이 구조에 순서대로 배분한다. ordered[0]은 항상 단일항성계(현재 시스템), [1..2]는 이중, [3..5], [6..8], [9..11]은 각각 삼중.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현: CSS만으로 궤도 애니메이션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도의 애니메이션 라이브러리 없이 CSS 키프레임으로 해결했다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@keyframes pp-orbit-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
@keyframes pp-orbit-spin-rev {
  from { transform: rotate(0deg); }
  to   { transform: rotate(-360deg); }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;b&gt;래퍼 + 역회전 패턴&lt;/b&gt;이다. 래퍼 div가 공전하고, 실제 태양계(자식)가 반대 방향으로 같은 속도로 회전한다. 결과적으로 태양계는 항상 같은 방향을 바라보면서 궤도를 돈다. 라벨이 뒤집히지 않는 이유가 이것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중간 성단은 역방향(pp-orbit-spin-rev)으로 돌도록 했다. 실제 우주에서 역행 궤도가 존재하는 것처럼. 보는 사람들이 금방 알아채기도 하고, 화면이 훨씬 생동감 있어진다.&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;모바일 vs 데스크톱 위치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;768px를 기준으로 위치 계수(fx, fy)를 완전히 다르게 설정했다. 모바일은 세로로 긴 화면에서 겹치지 않도록, 데스크톱은 가로로 넉넉한 공간을 활용하도록.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;const SINGLE_POS = {
  m: { fx: 0.74, fy: 0.38 },  // 모바일
  w: { fx: 0.82, fy: 0.18 }   // 데스크톱
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 숫자들은 코드가 아니라 눈으로 조정한 것들이다. &quot;조금 위로&quot;, &quot;오른쪽 더&quot; 반복하면서 맞춰나갔다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 455px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 455px;&quot;&gt;
&lt;td style=&quot;width: 28.6046%; height: 455px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;388&quot; data-origin-height=&quot;838&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bwmOUo/dJMb99Uloyl/cdkNIuJWqKW0Lm0mBMt9dk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwmOUo/dJMb99Uloyl/cdkNIuJWqKW0Lm0mBMt9dk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwmOUo/dJMb99Uloyl/cdkNIuJWqKW0Lm0mBMt9dk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bwmOUo/dJMb99Uloyl/cdkNIuJWqKW0Lm0mBMt9dk/img.gif&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;209&quot; height=&quot;451&quot; data-origin-width=&quot;388&quot; data-origin-height=&quot;838&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 71.3954%; height: 455px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;618&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNO5FY/dJMcahLCwDc/oTh0BOZ5KakS7Xk3hlVGtk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNO5FY/dJMcahLCwDc/oTh0BOZ5KakS7Xk3hlVGtk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNO5FY/dJMcahLCwDc/oTh0BOZ5KakS7Xk3hlVGtk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cNO5FY/dJMcahLCwDc/oTh0BOZ5KakS7Xk3hlVGtk/img.gif&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;586&quot; height=&quot;453&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;618&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. UUID 공유 시스템 + 내 기록 페이지&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;태양계마다 고유 주소를&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Planet Palette의 핵심 가치 중 하나는 &quot;내 여행을 누군가에게 보여줄 수 있다&quot;는 거다. 그러려면 태양계마다 고유한 URL이 있어야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 태양계에 short_id(UUID 앞 8자리)를 부여하고, /solar/[shortId] 라우트로 접근할 수 있게 했다. 링크 하나만 공유하면 누구나 그 태양계를 볼 수 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// short_id 생성 &amp;mdash; UUID 앞 8자리
const short_id = crypto.randomUUID().slice(0, 8);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 26.5117%;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;282&quot; data-origin-height=&quot;398&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bl8cux/dJMcahY8g5g/m79f61cS7hNRy431G8cV60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bl8cux/dJMcahY8g5g/m79f61cS7hNRy431G8cV60/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bl8cux/dJMcahY8g5g/m79f61cS7hNRy431G8cV60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbl8cux%2FdJMcahY8g5g%2Fm79f61cS7hNRy431G8cV60%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;179&quot; height=&quot;253&quot; data-origin-width=&quot;282&quot; data-origin-height=&quot;398&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 73.4883%;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;347&quot; data-origin-height=&quot;80&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DGnv9/dJMcafUBK9v/KiyPG0Uj6vvbj4M9rHZ260/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DGnv9/dJMcafUBK9v/KiyPG0Uj6vvbj4M9rHZ260/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DGnv9/dJMcafUBK9v/KiyPG0Uj6vvbj4M9rHZ260/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDGnv9%2FdJMcafUBK9v%2FKiyPG0Uj6vvbj4M9rHZ260%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;494&quot; height=&quot;114&quot; data-origin-width=&quot;347&quot; data-origin-height=&quot;80&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;소유권 검증: 로그인 vs 익명&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;까다로운 부분은 소유권 판단이었다. 로그인 유저는 user_id로 확인하면 되지만, 로그인 없이 만든 태양계는 어떻게 내 것임을 증명하나?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;익명 유저에게는 태양계 생성 시 anon_token을 발급하고, 해시(sha-256)해서 DB에 저장한다. 원본 토큰은 localStorage에만 남긴다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 소유권 확인 로직
if (system.user_id) {
  // 로그인 유저: auth.getUser()와 대조
  setIsOwner(user.id === system.user_id);
} else if (system.anon_token_hash) {
  // 익명 유저: localStorage 토큰을 해시해서 대조
  const stored = localStorage.getItem(`pp_token_${system.short_id}`);
  const valid = await verifyToken(stored, system.anon_token_hash);
  setIsOwner(valid);
}
&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;middot;삭제할 수 있고(좌), 공유받은 사람은 보기만(우) 가능하다. 서버 세션 없이 브라우저 토큰만으로 이걸 처리했다는 게 나름 깔끔하다고 생각한다.&lt;/p&gt;
&lt;p style=&quot;position: absolute;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;620&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xVjpm/dJMcahkCjFR/74snjuk9hK4ktrCJJtP9t0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xVjpm/dJMcahkCjFR/74snjuk9hK4ktrCJJtP9t0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xVjpm/dJMcahkCjFR/74snjuk9hK4ktrCJJtP9t0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/xVjpm/dJMcahkCjFR/74snjuk9hK4ktrCJJtP9t0/img.gif&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;800&quot; height=&quot;620&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;620&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;620&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MAG71/dJMcahY8g9j/yltE2orVodYPmYBkybnAK1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MAG71/dJMcahY8g9j/yltE2orVodYPmYBkybnAK1/img.gif&quot; data-alt=&quot;공유된 화면 - 읽기 전용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MAG71/dJMcahY8g9j/yltE2orVodYPmYBkybnAK1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/MAG71/dJMcahY8g9j/yltE2orVodYPmYBkybnAK1/img.gif&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;800&quot; height=&quot;620&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;620&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;공유된 화면 - 읽기 전용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;내 기록 페이지 (/my)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 유저에게는 자신이 만든 모든 태양계를 한눈에 볼 수 있는 /my 페이지를 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카드 썸네일은 실제 MiniStarSystem 컴포넌트를 그대로 렌더링한다. 정적 이미지가 아니라 살아있는 미니어처 태양계다. 카드 높이를 ResizeObserver로 측정해 size prop으로 내려준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제는 조심스럽게 설계했다. 카드에 hover했을 때만 삭제 버튼이 나타나고, 클릭하면 &quot;삭제 / 취소&quot; 확인 UI가 인라인으로 뜬다. 별도 모달 없이. 확인 후엔 Supabase Storage 파일을 먼저 지우고, DB는 CASCADE로 행성까지 한 번에 삭제된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;618&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caQvSp/dJMcahx6pKB/wu9L9I4Xj4Q0Ny4rTpzBj1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caQvSp/dJMcahx6pKB/wu9L9I4Xj4Q0Ny4rTpzBj1/img.gif&quot; data-alt=&quot;내 기록 - 미니태양계 카드 보기&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caQvSp/dJMcahx6pKB/wu9L9I4Xj4Q0Ny4rTpzBj1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/caQvSp/dJMcahx6pKB/wu9L9I4Xj4Q0Ny4rTpzBj1/img.gif&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;573&quot; height=&quot;443&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;618&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;내 기록 - 미니태양계 카드 보기&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 아이디어풀 &amp;mdash; 유저의 목소리를 듣고 싶었다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 만들었나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1인 개발이라 외부 피드백이 거의 없다. SNS에 올려도 댓글이 많지 않고, 주변 사람들한테 물어봐도 &quot;좋은데?&quot; 정도다. 실제로 서비스를 쓰면서 생긴 아이디어를 바로 전달할 수 있는 창구가 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도 페이지보다 서비스 안에 녹여두는 게 낫겠다고 판단했다. 쓰다가 &quot;이런 기능 있으면 좋겠다&quot;는 생각이 들 때, 바로 그 자리에서 남길 수 있게.&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;NavBar &amp;mdash; 데스크톱에서 항상 보임, 모바일에서 아이콘만 표시&lt;/li&gt;
&lt;li&gt;태양계 뷰(sc1) 모바일 좌상단 &amp;mdash; 공유 버튼 옆&lt;/li&gt;
&lt;li&gt;성간공간 뷰(sc2) 모바일 좌상단&lt;/li&gt;
&lt;li&gt;루트 페이지 하단 &amp;mdash; 업로드 화면 맨 아래, 눈에 잘 안 띄는 위치에 조용하게&lt;/li&gt;
&lt;li&gt;/my 페이지 NavBar&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 10px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 10px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 10px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;385&quot; data-origin-height=&quot;62&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NlIXl/dJMcagML393/umKDliaIvv3OKzgmSzT3qK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NlIXl/dJMcagML393/umKDliaIvv3OKzgmSzT3qK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NlIXl/dJMcagML393/umKDliaIvv3OKzgmSzT3qK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNlIXl%2FdJMcagML393%2FumKDliaIvv3OKzgmSzT3qK%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;385&quot; height=&quot;62&quot; data-origin-width=&quot;385&quot; data-origin-height=&quot;62&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 10px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;385&quot; data-origin-height=&quot;62&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dopqTa/dJMcacctfUN/GIhXeaI9CawKyZk0ewenf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dopqTa/dJMcacctfUN/GIhXeaI9CawKyZk0ewenf1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dopqTa/dJMcacctfUN/GIhXeaI9CawKyZk0ewenf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdopqTa%2FdJMcacctfUN%2FGIhXeaI9CawKyZk0ewenf1%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;385&quot; height=&quot;62&quot; data-origin-width=&quot;385&quot; data-origin-height=&quot;62&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 0px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 0px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;385&quot; data-origin-height=&quot;62&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dLBCMa/dJMcacctfVg/FGMGPFFQutzAKRkMxG7Tk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dLBCMa/dJMcacctfVg/FGMGPFFQutzAKRkMxG7Tk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dLBCMa/dJMcacctfVg/FGMGPFFQutzAKRkMxG7Tk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdLBCMa%2FdJMcacctfVg%2FFGMGPFFQutzAKRkMxG7Tk0%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;385&quot; height=&quot;62&quot; data-origin-width=&quot;385&quot; data-origin-height=&quot;62&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 0px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;385&quot; data-origin-height=&quot;62&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rbNkh/dJMcaaeG0pD/0f5ZKkdxIQoZlJmTb5VjRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rbNkh/dJMcaaeG0pD/0f5ZKkdxIQoZlJmTb5VjRk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rbNkh/dJMcaaeG0pD/0f5ZKkdxIQoZlJmTb5VjRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrbNkh%2FdJMcaaeG0pD%2F0f5ZKkdxIQoZlJmTb5VjRk%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;385&quot; height=&quot;62&quot; data-origin-width=&quot;385&quot; data-origin-height=&quot;62&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 그냥 각자 다 넣었다. 그러다 보니 70줄짜리 SVG path가 5군데에 복붙되어 있었다. 버튼 스타일을 바꾸려면 5군데를 고쳐야 한다는 뜻이다. 같은 버튼이 여러군데에 들어가니 자연스럽게 컴포넌트로 분리했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;376&quot; data-origin-height=&quot;327&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BetqX/dJMcacjcmOx/Iep2NwdP5h4O0Zh9lbYYk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BetqX/dJMcacjcmOx/Iep2NwdP5h4O0Zh9lbYYk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BetqX/dJMcacjcmOx/Iep2NwdP5h4O0Zh9lbYYk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBetqX%2FdJMcacjcmOx%2FIep2NwdP5h4O0Zh9lbYYk0%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;376&quot; height=&quot;327&quot; data-origin-width=&quot;376&quot; data-origin-height=&quot;327&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RLS는 anon, authenticated 둘 다 INSERT 허용. 로그인 없이도 아이디어를 낼 수 있어야 한다. 조회는 authenticated만 &amp;mdash; 내가 Supabase 대시보드에서 직접 보면 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 자잘하지만 중요한 것들&lt;/h2&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;화면 배경을 탭하면 모든 UI가 사라지고 태양계만 남는다. chromeHidden state 하나로 NavBar, 버튼들, 공유 아이콘 모두에 opacity-0 pointer-events-none을 건다. 다시 탭하면 복원된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현은 단순하지만 경험 차이는 크다. 특히 모바일에서 전체 화면을 가득 채운 채 행성들이 돌아가는 모습은 처음 봤을 때 &quot;아, 이거다&quot; 싶었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;622&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tBmtB/dJMcadvBcsb/QQbiOBYYvurKjAsESvYK11/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tBmtB/dJMcadvBcsb/QQbiOBYYvurKjAsESvYK11/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tBmtB/dJMcadvBcsb/QQbiOBYYvurKjAsESvYK11/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/tBmtB/dJMcadvBcsb/QQbiOBYYvurKjAsESvYK11/img.gif&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;800&quot; height=&quot;622&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;622&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;가이드 6번 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 가이드는 줌인(태양계 뷰)까지만 안내하고 끝났다. 줌아웃해서 성간공간을 볼 수 있다는 걸 유저가 모를 수 있다.&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;가이드 리플레이 버튼(?)은 이제 모든 방문자에게 보인다. 이전엔 소유자에게만 표시했는데, 공유 링크로 처음 온 사람도 가이드를 다시 볼 수 있어야 한다고 판단했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;358&quot; data-origin-height=&quot;212&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Zpxkl/dJMcahEOCal/tan9tJEroLVsvdlJMIHkvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Zpxkl/dJMcahEOCal/tan9tJEroLVsvdlJMIHkvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Zpxkl/dJMcahEOCal/tan9tJEroLVsvdlJMIHkvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZpxkl%2FdJMcahEOCal%2Ftan9tJEroLVsvdlJMIHkvk%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;358&quot; height=&quot;212&quot; data-origin-width=&quot;358&quot; data-origin-height=&quot;212&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;성간공간 뷰에서 태양계 수가 4개 미만일 때의 레이아웃 (지금은 성단 슬롯이 비어 있다)&lt;/li&gt;
&lt;li&gt;아이디어 제출 내역을 관리자가 볼 수 있는 간단한 내부 대시보드&lt;/li&gt;
&lt;li&gt;공유된 태양계를 보는 사람이 &quot;나도 만들고 싶다&quot;는 동선을 더 명확하게&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 세 번째가 중요하다고 생각한다. 공유 링크로 유입된 사람이 그냥 보고 나가는 건 아깝다. 자연스럽게 &quot;나도 해볼게&quot;가 되는 흐름이 필요하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혼자 만드는 서비스의 장점은 &quot;이걸 왜 만드나&quot;를 매번 내가 결정할 수 있다는 거다. 기술적으로 멋진 게 아니라, 쓰고 싶은 걸 만드는 것. 이번 업데이트의 기준도 결국 그거였다.&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;그걸 만드는 데 한참 걸렸지만, 그래도 괜찮다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;a href=&quot;https://planetpalette.vercel.app/&quot;&gt;https://planetpalette.vercel.app/&lt;/a&gt;&lt;/p&gt;</description>
      <category>Project/Planet Palette</category>
      <author>Dwony</author>
      <guid isPermaLink="true">https://developer-dwon.tistory.com/33</guid>
      <comments>https://developer-dwon.tistory.com/entry/Planet-Palette-%EC%9A%B0%EC%A3%BC%EA%B0%80-%EB%84%93%EC%96%B4%EC%A7%80%EB%8B%A4-%EC%84%B1%EA%B0%84%EA%B3%B5%EA%B0%84#entry33comment</comments>
      <pubDate>Sun, 21 Jun 2026 18:48:35 +0900</pubDate>
    </item>
    <item>
      <title>Planet Palette - 나만의 태양계</title>
      <link>https://developer-dwon.tistory.com/entry/%EB%82%98%EB%A7%8C%EC%9D%98-%EC%9A%B0%EC%A3%BC-Planet-Palette</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나의 인생으로 우주를 만들어보면 어떨까요? 사진을 업로드만 하면 사진의 색감이 살아있는 행성이 됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://planetpalette.vercel.app/&quot;&gt;https://planetpalette.vercel.app/&lt;/a&gt;   바로 써보기!&lt;/p&gt;
&lt;figure id=&quot;og_1779878240392&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Planetpalette &amp;mdash; Turn Your Travel Photos into a Solar System&quot; data-og-description=&quot;Each photo you upload becomes a unique, color-extracted planet orbiting your very own star. Free, instant, no signup.&quot; data-og-host=&quot;planetpalette.vercel.app&quot; data-og-source-url=&quot;https://planetpalette.vercel.app/&quot; data-og-url=&quot;https://planetpalette.vercel.app&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/eL5zL/dJMb8Z3xXzS/Qxyu6O8MvKiNUYNQIfTP9k/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/WPv0C/dJMb8U80caY/Bxrh1JvpBDtOnbVkkCpVwk/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://planetpalette.vercel.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://planetpalette.vercel.app/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/eL5zL/dJMb8Z3xXzS/Qxyu6O8MvKiNUYNQIfTP9k/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/WPv0C/dJMb8U80caY/Bxrh1JvpBDtOnbVkkCpVwk/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Planetpalette &amp;mdash; Turn Your Travel Photos into a Solar System&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Each photo you upload becomes a unique, color-extracted planet orbiting your very own star. Free, instant, no signup.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;planetpalette.vercel.app&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Planet Palette&lt;/b&gt;는 그 욕구에서 출발했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여행 사진을 드래그앤드롭으로 올리면, 사진 속 색감을 추출해 &lt;b&gt;행성 SVG 아트워크&lt;/b&gt;로 변환합니다. 각 행성은 그 사진만의 고유한 컬러 팔레트로 만들어진 동심원 구조를 가지며, 모든 행성이 모이면 하나의 &lt;b&gt;태양계&lt;/b&gt;를 이룹니다. 여행이 끝나면 당신만의 우주가 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단은 MVP이므로 서버가 없습니다. 사진이 어디로도 전송되지 않습니다. 모든 처리가 브라우저 안에서 끝납니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주요 기능&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 사진 &amp;rarr; 행성 변환&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사진을 업로드하면 color-thief-browser 라이브러리가 대표색 1개와 팔레트 5색을 추출합니다. 총 6가지 색을 &lt;b&gt;HSL 명도(L) 기준 내림차순&lt;/b&gt;으로 정렬한 뒤, 각각 행성의 동심원 링에 배치합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가장 밝은 색 &amp;rarr; 행성 핵(core)&lt;/li&gt;
&lt;li&gt;가장 어두운 색 &amp;rarr; 행성 외곽&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;행성 표면의 크레이터(반점)는 &lt;b&gt;mulberry32 시드 기반 의사난수&lt;/b&gt;로 생성합니다. 시드는 파일명 길이 &amp;times; 파일 크기 + lastModified 값으로 계산되어, 같은 사진을 올리면 항상 같은 행성이 만들어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;행성 외곽에는 Atmosphere Glow(대기 후광)를 radialGradient로 구현했고, 점선 궤도 링과 2~3개의 위성도 달립니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 142px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 142px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 142px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1-1.gif&quot; data-origin-width=&quot;134&quot; data-origin-height=&quot;142&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmvrNa/dJMcaaeoni2/WjPkxqNLRE3yQGwkk2pHq0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmvrNa/dJMcaaeoni2/WjPkxqNLRE3yQGwkk2pHq0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmvrNa/dJMcaaeoni2/WjPkxqNLRE3yQGwkk2pHq0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cmvrNa/dJMcaaeoni2/WjPkxqNLRE3yQGwkk2pHq0/img.gif&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;134&quot; height=&quot;142&quot; data-filename=&quot;1-1.gif&quot; data-origin-width=&quot;134&quot; data-origin-height=&quot;142&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 142px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1-2.gif&quot; data-origin-width=&quot;134&quot; data-origin-height=&quot;142&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biCXvv/dJMcaayHgfL/ZwYZ6lcqFhW2zCPWaWPqok/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biCXvv/dJMcaayHgfL/ZwYZ6lcqFhW2zCPWaWPqok/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biCXvv/dJMcaayHgfL/ZwYZ6lcqFhW2zCPWaWPqok/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/biCXvv/dJMcaayHgfL/ZwYZ6lcqFhW2zCPWaWPqok/img.gif&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;134&quot; height=&quot;142&quot; data-filename=&quot;1-2.gif&quot; data-origin-width=&quot;134&quot; data-origin-height=&quot;142&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 142px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1-3.gif&quot; data-origin-width=&quot;134&quot; data-origin-height=&quot;142&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bckYBG/dJMcaaeonjV/wiYlYQgFGssmuJAS3MEiJ1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bckYBG/dJMcaaeonjV/wiYlYQgFGssmuJAS3MEiJ1/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bckYBG/dJMcaaeonjV/wiYlYQgFGssmuJAS3MEiJ1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bckYBG/dJMcaaeonjV/wiYlYQgFGssmuJAS3MEiJ1/img.gif&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;134&quot; height=&quot;142&quot; data-filename=&quot;1-3.gif&quot; data-origin-width=&quot;134&quot; data-origin-height=&quot;142&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 142px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1-4.gif&quot; data-origin-width=&quot;134&quot; data-origin-height=&quot;142&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OIbKV/dJMcahLkY5L/hSKQjkO2mskERPkiSlCIBK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OIbKV/dJMcahLkY5L/hSKQjkO2mskERPkiSlCIBK/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OIbKV/dJMcahLkY5L/hSKQjkO2mskERPkiSlCIBK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/OIbKV/dJMcahLkY5L/hSKQjkO2mskERPkiSlCIBK/img.gif&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;134&quot; height=&quot;142&quot; data-filename=&quot;1-4.gif&quot; data-origin-width=&quot;134&quot; data-origin-height=&quot;142&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 142px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1-5.gif&quot; data-origin-width=&quot;128&quot; data-origin-height=&quot;142&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpb61s/dJMcaaeonlf/yqJ2mgPDPAf9jDwjIK1jnk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpb61s/dJMcaaeonlf/yqJ2mgPDPAf9jDwjIK1jnk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpb61s/dJMcaaeonlf/yqJ2mgPDPAf9jDwjIK1jnk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bpb61s/dJMcaaeonlf/yqJ2mgPDPAf9jDwjIK1jnk/img.gif&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;128&quot; height=&quot;142&quot; data-filename=&quot;1-5.gif&quot; data-origin-width=&quot;128&quot; data-origin-height=&quot;142&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 142px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1-6.gif&quot; data-origin-width=&quot;138&quot; data-origin-height=&quot;142&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dbB6x2/dJMcahLkY5Z/88tTacup1zXLdiao6BvTZ1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dbB6x2/dJMcahLkY5Z/88tTacup1zXLdiao6BvTZ1/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dbB6x2/dJMcahLkY5Z/88tTacup1zXLdiao6BvTZ1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dbB6x2/dJMcahLkY5Z/88tTacup1zXLdiao6BvTZ1/img.gif&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;138&quot; height=&quot;142&quot; data-filename=&quot;1-6.gif&quot; data-origin-width=&quot;138&quot; data-origin-height=&quot;142&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 실시간 태양계 애니메이션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;업로드한 행성들이 중앙 별 주위를 공전합니다. requestAnimationFrame 기반의 애니메이션 루프가 매 프레임마다 행성의 공전 각도와 자전 각도를 계산해 DOM에 직접 적용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공전 속도와 자전 속도 모두 각 사진의 시드로 결정되어, 사진마다 다른 리듬으로 움직입니다. 배경에는 200개의 별이 흩뿌려져 있으며 이 역시 시드 기반으로 고정 생성됩니다.&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-filename=&quot;2.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmXhb5/dJMcaaMbFH8/cLwd3FjHMWvhLzeVnQ5fK1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmXhb5/dJMcaaMbFH8/cLwd3FjHMWvhLzeVnQ5fK1/img.gif&quot; data-alt=&quot;Planet Palette - Planet View&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmXhb5/dJMcaaMbFH8/cLwd3FjHMWvhLzeVnQ5fK1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bmXhb5/dJMcaaMbFH8/cLwd3FjHMWvhLzeVnQ5fK1/img.gif&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;574&quot; data-filename=&quot;2.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Planet Palette - Planet View&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 행성 상세 모달&lt;/h3&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;원본 사진 + 행성 SVG 나란히 표시&lt;/li&gt;
&lt;li&gt;추출된 6색 팔레트 스와치&lt;/li&gt;
&lt;li&gt;행성 이름 인라인 편집 (클릭해서 바로 수정)&lt;/li&gt;
&lt;li&gt;삭제 버튼&lt;/li&gt;
&lt;li&gt;키보드 방향키(&amp;larr;/&amp;rarr;)로 행성 간 이동&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-filename=&quot;3.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKgC06/dJMcaiXIqtq/MImu3ZAFUxAloqlSFV1tkk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKgC06/dJMcaiXIqtq/MImu3ZAFUxAloqlSFV1tkk/img.gif&quot; data-alt=&quot;Planet Palette - Planet Modal&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKgC06/dJMcaiXIqtq/MImu3ZAFUxAloqlSFV1tkk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bKgC06/dJMcaiXIqtq/MImu3ZAFUxAloqlSFV1tkk/img.gif&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;574&quot; data-filename=&quot;3.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Planet Palette - Planet Modal&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 여행 정보 등록&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중앙 별을 클릭하면 &lt;b&gt;Trip Info 모달&lt;/b&gt;이 열립니다. 여행 이름과 날짜를 입력하면 PNG/MP4 내보내기 시 함께 포함됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;4.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsqpRc/dJMcadPDwGG/OWtkd41B5XWGV2IJ4duUrk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsqpRc/dJMcadPDwGG/OWtkd41B5XWGV2IJ4duUrk/img.gif&quot; data-alt=&quot;Planet Palette - Trip Info&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsqpRc/dJMcadPDwGG/OWtkd41B5XWGV2IJ4duUrk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bsqpRc/dJMcadPDwGG/OWtkd41B5XWGV2IJ4duUrk/img.gif&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;574&quot; data-filename=&quot;4.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Planet Palette - Trip Info&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 내보내기 &amp;mdash; PNG 이미지 &amp;amp; MP4 배경화면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다운로드 버튼을 누르면 두 가지 옵션이 제공됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PNG 이미지&lt;/b&gt;: html-to-image 라이브러리로 현재 태양계 뷰를 고해상도로 캡처합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;MP4 배경화면&lt;/b&gt;: Canvas API로 매 프레임 직접 렌더링하여 15초 분량의 영상을 생성합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모바일: 1080&amp;times;1920 (세로 FHD), 데스크톱: 1440&amp;times;2560 (세로 QHD)&lt;/li&gt;
&lt;li&gt;MediaRecorder API로 WebM 인코딩 후 MP4 컨테이너로 저장&lt;/li&gt;
&lt;li&gt;진행률(%)을 실시간으로 표시&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 61.9768%; height: 578px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style16&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 40.2657%;&quot;&gt;&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;5-1.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;3480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/csDxV2/dJMcahLkZKg/c6gK5EZsBKVkFkVsU5uxcK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/csDxV2/dJMcahLkZKg/c6gK5EZsBKVkFkVsU5uxcK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/csDxV2/dJMcahLkZKg/c6gK5EZsBKVkFkVsU5uxcK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcsDxV2%2FdJMcahLkZKg%2Fc6gK5EZsBKVkFkVsU5uxcK%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;1200&quot; height=&quot;3480&quot; data-filename=&quot;5-1.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;3480&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 66.4398%;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;5-2.gif&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1920&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7Ou0X/dJMcaayHhfJ/skB6BvsSWeclpGMlKuEWck/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7Ou0X/dJMcaayHhfJ/skB6BvsSWeclpGMlKuEWck/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7Ou0X/dJMcaayHhfJ/skB6BvsSWeclpGMlKuEWck/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/b7Ou0X/dJMcaayHhfJ/skB6BvsSWeclpGMlKuEWck/img.gif&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;305&quot; height=&quot;542&quot; data-filename=&quot;5-2.gif&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1920&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;부가 기능&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다크/라이트 모드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시스템 설정을 기본값으로 따르되, 수동으로 전환하면 localStorage에 저장됩니다. Tailwind CSS의 dark: 유틸리티로 구현했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;6.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZlVXk/dJMcacQLH20/DraJ08xI35tJPxkvCw27G1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZlVXk/dJMcacQLH20/DraJ08xI35tJPxkvCw27G1/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZlVXk/dJMcacQLH20/DraJ08xI35tJPxkvCw27G1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/ZlVXk/dJMcacQLH20/DraJ08xI35tJPxkvCw27G1/img.gif&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;574&quot; data-filename=&quot;6.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;500&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;8개 언어 지원&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국어, 영어, 일본어, 중국어, 스페인어, 프랑스어, 이탈리아어, 독일어를 지원합니다. i18next + react-i18next로 구현했으며, 선택한 언어는 localStorage에 저장됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;7.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dxliMt/dJMcaayHhJw/XWRWop8aXoYdXAkX2Mt3Nk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dxliMt/dJMcaayHhJw/XWRWop8aXoYdXAkX2Mt3Nk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dxliMt/dJMcaayHhJw/XWRWop8aXoYdXAkX2Mt3Nk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dxliMt/dJMcaayHhJw/XWRWop8aXoYdXAkX2Mt3Nk/img.gif&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;574&quot; data-filename=&quot;7.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&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;첫 방문 시 driver.js로 구현한 단계별 가이드가 자동으로 실행됩니다. 행성 모달 열기/닫기 같은 UI 상태 변화를 가이드 흐름에 맞춰 제어하는 로직이 포함됩니다. 한 번 본 사용자에게는 다시 표시되지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;8.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vDbtd/dJMcag6Ix5N/AHrXbQMch1kLdqcAt6k391/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vDbtd/dJMcag6Ix5N/AHrXbQMch1kLdqcAt6k391/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vDbtd/dJMcag6Ix5N/AHrXbQMch1kLdqcAt6k391/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/vDbtd/dJMcag6Ix5N/AHrXbQMch1kLdqcAt6k391/img.gif&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;574&quot; data-filename=&quot;8.gif&quot; data-origin-width=&quot;788&quot; data-origin-height=&quot;904&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;파일 검증&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;허용 형식: JPEG, PNG, WebP&lt;/li&gt;
&lt;li&gt;최대 10장, 파일당 최대 20MB&lt;/li&gt;
&lt;li&gt;파일명+크기 기준 중복 감지&lt;/li&gt;
&lt;li&gt;이탈 시 beforeunload 경고&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;Next.js 15 (App Router)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;TypeScript 5 (strict)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Styling&lt;/td&gt;
&lt;td&gt;Tailwind CSS v4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;색 추출&lt;/td&gt;
&lt;td&gt;color-thief-browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG 렌더링&lt;/td&gt;
&lt;td&gt;React 인라인 SVG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG 내보내기&lt;/td&gt;
&lt;td&gt;html-to-image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MP4 내보내기&lt;/td&gt;
&lt;td&gt;Canvas API + MediaRecorder API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가이드 투어&lt;/td&gt;
&lt;td&gt;driver.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다국어&lt;/td&gt;
&lt;td&gt;i18next + react-i18next&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;배포&lt;/td&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;설계에서 고민했던 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;같은 사진 = 같은 행성&quot;&lt;/b&gt; &amp;mdash; 의사난수 생성기에 시드를 주어 결정론적으로 만들었습니다. 덕분에 행성이 항상 재현 가능하고, 새로고침해도 동일한 결과가 나옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서버 없이 MP4 만들기&lt;/b&gt; &amp;mdash; 서버에 영상 인코딩을 맡기는 대신, 브라우저의 Canvas API와 MediaRecorder API만으로 구현했습니다. 처리 속도가 느릴 수 있지만 비용이 0이고 사용자의 사진이 어디로도 나가지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;애니메이션과 DOM 성능&lt;/b&gt; &amp;mdash; 행성 위치를 React state로 관리하면 매 프레임 리렌더링이 발생합니다. 대신 anglesRef로 각도를 ref에 저장하고, el.style.transform으로 DOM을 직접 업데이트합니다. 10개 행성이 동시에 움직여도 React 렌더링 비용이 발생하지 않습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;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;배포: Vercel&lt;/p&gt;</description>
      <category>Project/Planet Palette</category>
      <author>Dwony</author>
      <guid isPermaLink="true">https://developer-dwon.tistory.com/32</guid>
      <comments>https://developer-dwon.tistory.com/entry/%EB%82%98%EB%A7%8C%EC%9D%98-%EC%9A%B0%EC%A3%BC-Planet-Palette#entry32comment</comments>
      <pubDate>Wed, 27 May 2026 14:17:39 +0900</pubDate>
    </item>
    <item>
      <title>Dawnport &amp;mdash; 매일 아침 뉴스 브리핑을 텔레그램으로 받아보기</title>
      <link>https://developer-dwon.tistory.com/entry/Dawnport-%E2%80%94-%EB%A7%A4%EC%9D%BC-%EC%95%84%EC%B9%A8-%EB%89%B4%EC%8A%A4-%EB%B8%8C%EB%A6%AC%ED%95%91%EC%9D%84-%ED%85%94%EB%A0%88%EA%B7%B8%EB%9E%A8%EC%9C%BC%EB%A1%9C-%EB%B0%9B%EC%95%84%EB%B3%B4%EA%B8%B0</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4GEnA/dJMcabp0lRv/ED4F8CfNgUFe3Jk4uJGmF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4GEnA/dJMcabp0lRv/ED4F8CfNgUFe3Jk4uJGmF0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4GEnA/dJMcabp0lRv/ED4F8CfNgUFe3Jk4uJGmF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4GEnA%2FdJMcabp0lRv%2FED4F8CfNgUFe3Jk4uJGmF0%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-origin-width=&quot;400&quot; data-origin-height=&quot;400&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;이름은 &lt;b&gt;Dawn-Port&lt;/b&gt;. 새벽(Dawn)에 포트폴리오(Portfolio) 브리핑을 보낸다는 의미로 지었다.&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;a href=&quot;https://github.com/dwonDeV/dawn-port&quot;&gt;https://github.com/dwonDeV/dawnport&lt;/a&gt;&lt;/h2&gt;
&lt;figure id=&quot;og_1773913015577&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - dwonDeV/dawnport&quot; data-og-description=&quot;Contribute to dwonDeV/dawnport development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/dwonDeV/dawn-port&quot; data-og-url=&quot;https://github.com/dwonDeV/dawnport&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/BH8Ju/dJMb81GWjL9/ure3PFFzpX5WIr0kz6Hxk1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/fz9I1/dJMb84XXPps/7IoGRLVi0DY1EPXxNR6Vgk/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://github.com/dwonDeV/dawn-port&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/dwonDeV/dawn-port&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/BH8Ju/dJMb81GWjL9/ure3PFFzpX5WIr0kz6Hxk1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/fz9I1/dJMb84XXPps/7IoGRLVi0DY1EPXxNR6Vgk/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - dwonDeV/dawnport&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to dwonDeV/dawnport development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어떤 서비스인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 관심 있는 키워드를 등록해두면, 매일 오전 7시에 알아서 관련 뉴스 TOP 5를 수집하고 로컬 LLM이 투자 분석까지 생성해서 텔레그램으로 보내준다. 키워드 관리도 전부 텔레그램 인라인 버튼으로 할 수 있어서 따로 앱이나 웹을 열 필요가 없다.&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;&lt;b&gt;프레임워크&lt;/b&gt;: NestJS&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DB&lt;/b&gt;: SQLite + Prisma ORM (별도 DB 서버 불필요)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;뉴스 수집&lt;/b&gt;: NewsAPI&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AI 분석&lt;/b&gt;: Ollama (qwen3:8b, 로컬)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;텔레그램&lt;/b&gt;: node-telegram-bot-api (polling 방식)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스케줄러&lt;/b&gt;: @nestjs/schedule&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB를 MySQL에서 SQLite로 바꾼 게 포인트다. prisma/dev.db 파일 하나로 끝나서 별도 DB 서버를 띄울 필요가 없다. 로컬에서 혼자 쓰는 개인 봇이라면 SQLite로 충분하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 LLM을 쓴 이유는 API 비용이 0원이기 때문이다. Ollama로 qwen3:8b 모델을 띄워두면 GPT 수준은 아니어도 투자 분석 텍스트 생성에는 충분했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 구조 잡기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 그냥 단일 파일로 만들까 했는데, 기능이 꽤 나뉘어서 NestJS 모듈 구조로 분리했다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;dawn-port/
├── prisma/
│   ├── schema.prisma        # DB 스키마 (SQLite)
│   ├── migrations/          # 마이그레이션 파일 (커밋됨)
│   └── dev.db               # SQLite DB 파일 (gitignore)
├── src/
│   ├── main.ts
│   ├── app.module.ts
│   ├── prisma/          # DB 연결
│   ├── keywords/        # 키워드 CRUD
│   ├── news/            # 뉴스 수집
│   ├── portfolio/       # AI 투자 분석
│   ├── telegram/        # 텔레그램 봇
│   └── scheduler/       # 스케줄러 + 수동 실행 API
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 기능이 독립된 모듈로 분리되어 있으니 나중에 뭘 바꿔도 영향 범위가 명확하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;앱 진입점과 전역 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;main.ts부터 보면 심플하다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  app.setGlobalPrefix('api');
  await app.listen(process.env.PORT ?? 3000);
  console.log(`Dawn-Port server running on port ${process.env.PORT ?? 3000}`);
}
bootstrap();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ValidationPipe를 전역으로 설정해서 DTO 검증이 자동으로 되고, 모든 라우트는 /api 프리픽스를 붙인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;app.module.ts에서는 ConfigModule.forRoot({ isGlobal: true })로 환경변수를 전역으로 쓸 수 있게 했고, ScheduleModule.forRoot()로 크론 스케줄러를 활성화했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Prisma로 DB 스키마 잡기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB는 3개 테이블이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Keyword&lt;/b&gt; &amp;mdash; 관심 키워드 목록. active 필드로 활성/비활성을 관리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;NewsItem&lt;/b&gt; &amp;mdash; 수집된 뉴스 캐시. @@unique([url, keyword])로 같은 키워드로 같은 기사가 중복 저장되는 걸 막았다. @@index([keyword]), @@index([createdAt])으로 조회 성능도 챙겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DailyReport&lt;/b&gt; &amp;mdash; 발송 이력. AI가 생성한 투자 분석 전문이 저장된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PrismaModule은 @Global()로 선언해서 다른 모듈에서 별도 import 없이 바로 쓸 수 있도록 했다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// prisma.module.ts
@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;// prisma.service.ts
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;키워드 CRUD&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키워드는 단순한 CRUD다. 중복 등록은 ConflictException으로 처리했다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;// keywords.service.ts
async create(dto: CreateKeywordDto) {
  const existing = await this.prisma.keyword.findUnique({ where: { name: dto.name } });
  if (existing) throw new ConflictException(`키워드 '${dto.name}'이(가) 이미 존재합니다.`);
  return this.prisma.keyword.create({ data: { name: dto.name } });
}

findActive() {
  return this.prisma.keyword.findMany({ where: { active: true }, orderBy: { name: 'asc' } });
}

async toggleActive(id: number) {
  const keyword = await this.prisma.keyword.findUnique({ where: { id } });
  if (!keyword) throw new NotFoundException('키워드를 찾을 수 없습니다.');
  return this.prisma.keyword.update({ where: { id }, data: { active: !keyword.active } });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;toggleActive는 현재 상태를 읽어서 반전시킨다. 별도 active 값을 받지 않고 항상 토글하는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API는 이렇게 노출된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메서드 경로 설명&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;/api/keywords&lt;/td&gt;
&lt;td&gt;키워드 전체 목록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;/api/keywords&lt;/td&gt;
&lt;td&gt;키워드 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PATCH&lt;/td&gt;
&lt;td&gt;/api/keywords/:id/toggle&lt;/td&gt;
&lt;td&gt;활성/비활성 토글&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DELETE&lt;/td&gt;
&lt;td&gt;/api/keywords/:id&lt;/td&gt;
&lt;td&gt;키워드 삭제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;/api/briefing/run&lt;/td&gt;
&lt;td&gt;브리핑 즉시 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;뉴스 수집 &amp;mdash; NewsAPI 연동&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뉴스 수집은 NewsService가 담당한다. 핵심 로직은 활성 키워드 전체를 OR로 연결해서 단일 API 요청을 보내는 것이다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// news.service.ts
async fetchTopNews(keywords: string[]): Promise&amp;lt;newsarticle[]&amp;gt; {
  const apiKey = this.config.get('NEWS_API_KEY');
  const query = keywords.join(' OR ');

  let articles: NewsArticle[] = [];

  try {
    // 한국어 우선 시도
    const koResponse = await axios.get('&amp;lt;https://newsapi.org/v2/everything&amp;gt;', {
      params: { q: query, language: 'ko', sortBy: 'popularity', pageSize: 5, apiKey },
    });
    articles = koResponse.data.articles ?? [];

    // 결과 없으면 영어로 재시도
    if (articles.length === 0) {
      const enResponse = await axios.get('&amp;lt;https://newsapi.org/v2/everything&amp;gt;', {
        params: { q: query, language: 'en', sortBy: 'popularity', pageSize: 5, apiKey },
      });
      articles = enResponse.data.articles ?? [];
    }
  } catch (error) {
    this.logger.error(`뉴스 조회 실패: ${error.message}`);
    return [];
  }

  const top5 = articles.slice(0, 5);
  await this.saveArticles(top5, query);
  return top5;
}
&amp;lt;/newsarticle[]&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국어 뉴스를 먼저 시도하고, 결과가 없으면 영어로 재시도하는 fallback 로직이 있다. 뉴스는 popularity 순으로 상위 5개만 선택하고 DB에 upsert로 저장해서 중복을 방지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NewsAPI 무료 플랜은 하루 100회 요청 제한이 있고 24시간 이전 뉴스만 조회된다는 점도 주의해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI 투자 분석 &amp;mdash; Ollama 연동&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PortfolioService에서 Ollama를 호출한다. 로컬에서 qwen3:8b 모델을 띄워두고 /api/chat 엔드포인트로 요청을 보낸다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// portfolio.service.ts
const prompt = `다음은 오늘의 최신 뉴스입니다:

${newsContext.join('\\n')}

위 뉴스를 바탕으로 한국 투자자 관점에서 다음을 분석해주세요:

1. 시장 동향 요약 (2-3줄)
2. 단기 투자 전략 (1-4주): 구체적인 섹터/종목/ETF 추천과 이유
3. 중기 투자 전략 (1-6개월): 포지션 구축 전략
4. 장기 투자 전략 (6개월 이상): 자산 배분 및 포트폴리오 구성
5. 리스크 요인: 주의해야 할 사항

마크다운 문법(*, **, #, - 등)은 절대 사용하지 마세요. 일반 텍스트로만 작성하세요.`;

const response = await axios.post(
  `${this.ollamaHost}/api/chat`,
  {
    model: this.model,
    messages: [{ role: 'user', content: prompt }],
    stream: false,
    options: {
      temperature: 0.7,
      num_predict: 2000,
    },
  },
  { timeout: 120000 },
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트에 마크다운 문법 사용 금지를 명시한 게 포인트다. 텔레그램으로 보낼 때 **, # 같은 기호가 섞이면 보기 불편하기 때문이다. timeout을 120초로 넉넉하게 준 이유는 로컬 LLM이 생각보다 느릴 수 있어서다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;텔레그램 봇 &amp;mdash; 인라인 버튼 UI&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텔레그램 봇의 핵심은 인라인 버튼으로 모든 걸 조작할 수 있게 한 것이다. /start를 치거나 서버가 시작되면 메인 메뉴 버튼이 나온다.&lt;/p&gt;
&lt;pre class=&quot;inform7&quot;&gt;&lt;code&gt;메인 메뉴
├──   키워드 목록
│   ├── [✅/⏸ 키워드명] &amp;rarr; 클릭 시 활성/비활성 토글
│   ├── [  삭제] &amp;rarr; 삭제 확인 &amp;rarr; 삭제
│   └── [➕ 키워드 추가] / [&amp;larr; 뒤로]
├── ➕ 키워드 추가 &amp;rarr; 텍스트 입력 대기 &amp;rarr; 추가
└──   브리핑 지금 실행 &amp;rarr; 실행 확인 &amp;rarr; 브리핑 시작
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;253&quot; data-origin-height=&quot;141&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqyOoV/dJMcafFVcVb/AUc04BBBVA9jlomkkXeU1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqyOoV/dJMcafFVcVb/AUc04BBBVA9jlomkkXeU1K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqyOoV/dJMcafFVcVb/AUc04BBBVA9jlomkkXeU1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqyOoV%2FdJMcafFVcVb%2FAUc04BBBVA9jlomkkXeU1K%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;253&quot; height=&quot;141&quot; data-origin-width=&quot;253&quot; data-origin-height=&quot;141&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼 콜백을 처리하는 방식을 보면, callback_data로 어떤 액션인지를 구분하고, 메시지를 새로 보내는 게 아니라 기존 메시지를 수정(editMessageText)하는 방식으로 채팅창이 지저분해지지 않게 했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// telegram.service.ts - 콜백 핸들러 일부
this.bot.on('callback_query', async (query) =&amp;gt; {
  if (!this.isAllowed(query.message.chat.id)) return;
  await this.bot.answerCallbackQuery(query.id);
  const data = query.data;

  if (data === 'menu')             return this.sendMainMenu(chatId, msgId);
  if (data === 'list_keywords')    return this.sendKeywordList(chatId, msgId);
  if (data === 'add_keyword')      return this.promptAddKeyword(chatId, query.from.id, msgId);
  if (data === 'run_briefing')     return this.confirmBriefing(chatId, msgId);
  if (data === 'confirm_briefing') return this.runBriefing(chatId, msgId);

  if (data.startsWith('toggle_')) {
    const id = parseInt(data.split('_')[1]);
    return this.toggleKeyword(chatId, id, msgId);
  }
  // ...
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;255&quot; data-origin-height=&quot;195&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rVwTc/dJMcagx18ro/KV4vHkpjzLwkauo7nCh8g0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rVwTc/dJMcagx18ro/KV4vHkpjzLwkauo7nCh8g0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rVwTc/dJMcagx18ro/KV4vHkpjzLwkauo7nCh8g0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrVwTc%2FdJMcagx18ro%2FKV4vHkpjzLwkauo7nCh8g0%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;255&quot; height=&quot;195&quot; data-origin-width=&quot;255&quot; data-origin-height=&quot;195&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키워드 추가는 상태 머신 방식으로 구현했다. userStates Map에 유저 ID를 키로 'adding_keyword' 상태를 저장해두고, 다음 텍스트 입력을 가로채서 키워드로 등록한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안도 간단히 처리했다. TELEGRAM_CHAT_ID에 등록된 채팅 ID만 허용하도록 isAllowed() 체크를 모든 핸들러 첫 줄에 넣었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스케줄러와 순환 의존성 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SchedulerService가 TelegramService를 쓰고, TelegramService가 SchedulerService를 쓰는 순환 의존성이 생겼다. NestJS에서는 forwardRef()로 해결할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// scheduler.module.ts
@Module({
  imports: [KeywordsModule, NewsModule, PortfolioModule, forwardRef(() =&amp;gt; TelegramModule)],
  // ...
})
export class SchedulerModule {}

// telegram.module.ts
@Module({
  imports: [KeywordsModule, forwardRef(() =&amp;gt; SchedulerModule)],
  // ...
})
export class TelegramModule {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 쪽에서도 @Inject(forwardRef(() =&amp;gt; TelegramService))처럼 주입을 받아야 한다. 양쪽 모두 수정해야 하므로 나중에 건드릴 때 주의가 필요하다.&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;크론 스케줄은 매일 UTC 22:00, 즉 KST 오전 7시에 실행된다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// scheduler.service.ts
@Cron('0 22 * * *', { timeZone: 'UTC' })
async runMorningBriefing() {
  this.logger.log('모닝 브리핑 시작...');
  await this.executeBriefing();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;executeBriefing()은 스케줄러와 텔레그램 버튼 양쪽에서 모두 호출할 수 있게 분리했다. REST API(POST /api/briefing/run)에서도 같은 메서드를 호출한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;283&quot; data-origin-height=&quot;724&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2GhRB/dJMcagEQzxN/XUvL44jmtlhz7vgEfHwfF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2GhRB/dJMcagEQzxN/XUvL44jmtlhz7vgEfHwfF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2GhRB/dJMcagEQzxN/XUvL44jmtlhz7vgEfHwfF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2GhRB%2FdJMcagEQzxN%2FXUvL44jmtlhz7vgEfHwfF1%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;283&quot; height=&quot;724&quot; data-origin-width=&quot;283&quot; data-origin-height=&quot;724&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;angelscript&quot;&gt;&lt;code&gt;매일 오전 7:00 KST 자동 실행 (또는 수동 트리거)
          │
          ▼
활성 키워드 전체를 OR로 합쳐 NewsAPI 단일 요청
(한국어 우선 &amp;rarr; 결과 없으면 영어)
          │
          ▼
popularity 순 상위 5개 뉴스 선택 &amp;rarr; DB 저장
          │
          ▼
Ollama(qwen3:8b)로 투자 분석 생성
          │
          ▼
텔레그램으로 결과 발송 (4000자 초과 시 분할)
          │
          ▼
DailyReport DB에 이력 저장
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텔레그램 메시지는 4000자 초과 시 splitMessage()로 자동 분할해서 보내도록 했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실행 방법&lt;/h2&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 의존성 설치
npm install

# 환경변수 설정
cp .env.example .env
# .env 파일에 아래 값들을 채워 넣는다

# DB 마이그레이션 (SQLite 파일 자동 생성)
npx prisma migrate deploy

# 개발 서버 실행
npm run start:dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경변수는 이렇게 구성된다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;DATABASE_URL=&quot;file:./dev.db&quot;

NEWS_API_KEY=your_newsapi_key_here
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
TELEGRAM_CHAT_ID=your_chat_id

OLLAMA_HOST=http://localhost:11434
OLLAMA_MODEL=qwen3:8b
PORT=3000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CHAT_ID는 봇에게 텔레그램에서 아무 메시지나 보낸 뒤 브라우저에서 &lt;a href=&quot;https://api.telegram.org/bot&quot;&gt;https://api.telegram.org/bot&lt;/a&gt;{TOKEN}/getUpdates에 접속하면 result[0].message.chat.id 값으로 확인할 수 있다. README.md에 자세히 적어놓았으니 참고바란다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각보다 만드는 데 시간이 많이 걸리지 않았다. NestJS 모듈 구조가 기능 분리를 강제해줘서 코드가 꽤 정돈되게 나왔다. MySQL 대신 SQLite를 쓰니까 DB 세팅 시간이 거의 없어진 것도 만족스럽다. 순환 의존성 문제가 잠깐 당황스러웠는데 forwardRef()로 해결되는 걸 알게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선하고 싶은 부분은 아직 있다. 지금은 NewsAPI 무료 플랜 한계로 24시간 이전 뉴스만 조회되는데, 유료 플랜이나 다른 뉴스 소스를 추가하면 더 유용해질 것 같다. AI 분석 품질도 모델을 바꾸거나 프롬프트를 더 다듬으면 개선될 여지가 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 이 서비스가 제공하는 투자 분석은 AI가 뉴스를 바탕으로 생성한 정보이며, 실제 투자 조언이 아니다. 투자 결정에 따른 손익은 전적으로 본인의 책임이다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>Project/Dawnport</category>
      <category>forwardRef</category>
      <category>nestjs</category>
      <category>NewsAPI</category>
      <category>Ollama</category>
      <category>SQLite</category>
      <category>순환의존성문제</category>
      <category>텔레그램봇</category>
      <category>투자</category>
      <author>Dwony</author>
      <guid isPermaLink="true">https://developer-dwon.tistory.com/27</guid>
      <comments>https://developer-dwon.tistory.com/entry/Dawnport-%E2%80%94-%EB%A7%A4%EC%9D%BC-%EC%95%84%EC%B9%A8-%EB%89%B4%EC%8A%A4-%EB%B8%8C%EB%A6%AC%ED%95%91%EC%9D%84-%ED%85%94%EB%A0%88%EA%B7%B8%EB%9E%A8%EC%9C%BC%EB%A1%9C-%EB%B0%9B%EC%95%84%EB%B3%B4%EA%B8%B0#entry27comment</comments>
      <pubDate>Thu, 19 Mar 2026 18:43:01 +0900</pubDate>
    </item>
    <item>
      <title>코인 자동매매 봇 (코이너) &amp;mdash; v2 전면 재설계, WebSocket과 3단 아키텍처</title>
      <link>https://developer-dwon.tistory.com/entry/%EC%BD%94%EC%9D%B8-%EC%9E%90%EB%8F%99%EB%A7%A4%EB%A7%A4-%EB%B4%87-%EC%BD%94%EC%9D%B4%EB%84%88-%E2%80%94-v2-%EC%A0%84%EB%A9%B4-%EC%9E%AC%EC%84%A4%EA%B3%84-WebSocket%EA%B3%BC-3%EB%8B%A8-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcYlSZ/dJMcabQTudn/ErSRWKTx1dvdNEvPvRhUk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcYlSZ/dJMcabQTudn/ErSRWKTx1dvdNEvPvRhUk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcYlSZ/dJMcabQTudn/ErSRWKTx1dvdNEvPvRhUk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcYlSZ%2FdJMcabQTudn%2FErSRWKTx1dvdNEvPvRhUk0%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;359&quot; height=&quot;359&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;v1을 3편에 걸쳐 만들고 실제 돈으로 돌렸다. 초반엔 &lt;b&gt;+18%&lt;/b&gt; 수익이 났다. 잘 된다 싶었는데, 이후 &lt;b&gt;-20%&lt;/b&gt; 손실을 맞았다. 수익을 다 반납하고 원금까지 까먹은 상황이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;손실이 난 이유를 분석해봤다. 가장 큰 문제는 &lt;b&gt;손절 반응 속도&lt;/b&gt;였다. v1은 1분 cron 단일 루프로 LLM 분석과 손절 체크를 한 큐에 처리했는데, LLM 응답을 기다리는 30~60초 동안 가격이 급락해도 아무것도 할 수 없는 구조였다. 거기에 투자금 배분 버그, LLM이 현재 RSI를 모르는 채 RSI 조건을 설정하는 구조적 모순까지 겹쳤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 v2를 설계했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;v1의 문제점&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v1 구조는 이랬다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;매 1분 0초:
  LLM 분석 (30~60초 대기) &amp;rarr; 손절/익절 체크 &amp;rarr; 매수/매도
&lt;/code&gt;&lt;/pre&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;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;손절 반응 최대 60초 지연&lt;/td&gt;
&lt;td&gt;LLM 응답 기다리는 동안 가격 급락 대응 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM이 매 분 블로킹&lt;/td&gt;
&lt;td&gt;분석 중에는 손절 체크 자체가 멈춤&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM RSI 조건 설정 모순&lt;/td&gt;
&lt;td&gt;&quot;RSI 45 이하 진입&quot; 설정하는데 현재 RSI가 62인 경우 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;투자금 불균등 배분&lt;/td&gt;
&lt;td&gt;매수할 때마다 &quot;남은 KRW &amp;times; 비율&quot;이라 3번째 종목은 1번째의 1/4 금액&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;손절이 60초 늦으면 어떤 일이 벌어지는지는 실제로 경험해봤다. 급락 구간에서 60초는 생각보다 훨씬 길다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;v2 아키텍처: 3레이어 분리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 아이디어는 단순했다. &lt;b&gt;빠른 것과 느린 것을 섞지 않는다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM은 느리다. WebSocket은 빠르다. 이 둘을 같은 루프에 넣으면 느린 쪽에 발목이 잡힌다. 그래서 3개의 독립 레이어로 분리했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│  레이어 1: WebSocket 실시간 (~수십 ms)                │
│  Upbit WSS ticker 수신 &amp;rarr; 손절 / 최대익절 즉시 실행   │
├──────────────────────────────────────────────────────┤
│  레이어 2: 지표 스케줄러 (1분마다)                    │
│  1분봉 갱신 &amp;rarr; RSI/MA 진입&amp;middot;청산 조건 체크 &amp;rarr; 매수/매도 │
├──────────────────────────────────────────────────────┤
│  레이어 3: LLM 전략 스케줄러 (15분마다)               │
│  후보 종목 선정 &amp;rarr; TradingRule 생성 (진입&amp;middot;청산 조건)   │
└──────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM은 이제 &quot;어떤 조건에서 사고 팔지&quot; 규칙만 만든다. 실제 실행은 WebSocket과 지표 스케줄러가 담당한다. LLM이 30초 걸려도 손절/익절에 아무 영향이 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;속도 비교가 이렇게 달라졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목 v1 v2&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;손절 반응&lt;/td&gt;
&lt;td&gt;최대 60초&lt;/td&gt;
&lt;td&gt;~수십 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최대익절 반응&lt;/td&gt;
&lt;td&gt;최대 60초&lt;/td&gt;
&lt;td&gt;~수십 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM 호출 빈도&lt;/td&gt;
&lt;td&gt;매 1분 (블로킹)&lt;/td&gt;
&lt;td&gt;15분마다 (백그라운드)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;94&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baY9mJ/dJMcabKaxvf/yGIsZsK7tOq8tZkqemdROK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baY9mJ/dJMcabKaxvf/yGIsZsK7tOq8tZkqemdROK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baY9mJ/dJMcabKaxvf/yGIsZsK7tOq8tZkqemdROK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaY9mJ%2FdJMcabKaxvf%2FyGIsZsK7tOq8tZkqemdROK%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;1000&quot; height=&quot;94&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;94&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;신규 파일 3개&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;174&quot; data-origin-height=&quot;101&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/duwNXw/dJMb99S45mU/2mGGFfGkhba9DmOagAieuk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/duwNXw/dJMb99S45mU/2mGGFfGkhba9DmOagAieuk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/duwNXw/dJMb99S45mU/2mGGFfGkhba9DmOagAieuk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FduwNXw%2FdJMb99S45mU%2F2mGGFfGkhba9DmOagAieuk%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;174&quot; height=&quot;101&quot; data-origin-width=&quot;174&quot; data-origin-height=&quot;101&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;trading.rule.ts&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM이 만드는 종목별 거래 규칙 인터페이스다. 진입&amp;middot;청산 조건 외에 손절/익절 기준도 종목별로 담는다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;export interface TradingRule {
  market: string;
  bias: 'bullish' | 'bearish' | 'neutral';
  entry: EntryCondition;   // rsiBelow, rsiAbove, maSignal
  exit: ExitCondition;     // rsiAbove, rsiBelow, maSignal
  stopLossPercent?: number;   // 종목별 손절 기준 (%)
  takeProfitMax?: number;     // 종목별 즉시 익절 기준 (%)
  goalProfitMin?: number;     // 종목별 매도 허용 최소 수익률 (%)
  reason: string;
  createdAt: number;
  expiresAt: number;          // 30분 TTL
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;upbit-websocket.service.ts&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Upbit WSS(wss://api.upbit.com/websocket/v1) 연결&amp;middot;구독 관리다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;subscribe(markets[]) / unsubscribe(market) &amp;mdash; 구독 목록 동적 변경 후 즉시 재전송&lt;/li&gt;
&lt;li&gt;연결이 끊기면 3초 후 자동 재연결&lt;/li&gt;
&lt;li&gt;SIMPLE 포맷 ticker 수신 &amp;rarr; onTickerUpdate 콜백 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;indicator.engine.ts&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;종목별 인메모리 캔들 버퍼다. 1분봉을 누적하며 실시간으로 지표를 계산한다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;engine.seed(market, candles);          // 초기 캔들 로드
engine.updatePrice(market, price);     // WebSocket 수신 시 현재가 반영
engine.get(market) &amp;rarr; RealtimeIndicators  // RSI, MA5/20, volumeRatio, maSignal
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;LLM 전략 고도화&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v1의 LLM이 가진 가장 큰 모순은 이거였다. RSI 조건을 직접 설정하면서 정작 해당 종목의 &lt;b&gt;현재 RSI를 모른다&lt;/b&gt;는 것이다. &quot;RSI 45 이하 진입&quot;이라고 설정해놨는데, 지금 RSI가 62라면 그 조건은 의미가 없다. LLM이 현실을 모른 채 규칙만 만들고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v2에서는 후보 종목의 1시간봉 RSI/ATR/MA를 계산해서 프롬프트에 넣었다. LLM이 실제 현황을 보고 조건을 설정하도록 바꿨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v1과 v2의 planStrategy 입력 데이터 비교다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 데이터 v1 v2&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;공포/탐욕 지수&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;김치 프리미엄&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;종목 현황 (가격/등락률/거래대금)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체 전략 성과 (DB)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BTC 1시간봉 추세&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;후보 종목 1h RSI / ATR / MA&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;종목별 봇 과거 통계 (승률&amp;middot;평균수익)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최근 6시간 손절 종목 (쿨다운)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;501&quot; data-origin-height=&quot;167&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dhNxNJ/dJMcaadplFJ/eoOgf9ulk11T1WW9Mj6kuk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dhNxNJ/dJMcaadplFJ/eoOgf9ulk11T1WW9Mj6kuk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dhNxNJ/dJMcaadplFJ/eoOgf9ulk11T1WW9Mj6kuk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdhNxNJ%2FdJMcaadplFJ%2FeoOgf9ulk11T1WW9Mj6kuk%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;501&quot; height=&quot;167&quot; data-origin-width=&quot;501&quot; data-origin-height=&quot;167&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;b&gt;쿨다운 로직&lt;/b&gt;도 추가했다. 최근 6시간 내에 손절된 종목은 MongoDB에서 조회해 LLM 프롬프트에 ⚠️최근손절 표시로 전달한다. LLM이 특별한 이유 없으면 해당 종목을 배제하도록 유도하는 방식이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;527&quot; data-origin-height=&quot;67&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ABznE/dJMcag5K9nC/1KXSFaUbwNi0SpRnwNSjw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ABznE/dJMcag5K9nC/1KXSFaUbwNi0SpRnwNSjw1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ABznE/dJMcag5K9nC/1KXSFaUbwNi0SpRnwNSjw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FABznE%2FdJMcag5K9nC%2F1KXSFaUbwNi0SpRnwNSjw1%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;527&quot; height=&quot;67&quot; data-origin-width=&quot;527&quot; data-origin-height=&quot;67&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;b&gt;종목별 봇 통계&lt;/b&gt;도 넣었다. KRW-XRP: 5회 승률 80% 평균 +3.2% 같은 정보를 제공해 이 봇에서 역사적으로 잘 동작한 종목을 LLM이 선호하도록 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;879&quot; data-origin-height=&quot;56&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bOgUfK/dJMcaakap7i/pLL7sRKbB81in9JkcVQkGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bOgUfK/dJMcaakap7i/pLL7sRKbB81in9JkcVQkGk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bOgUfK/dJMcaakap7i/pLL7sRKbB81in9JkcVQkGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbOgUfK%2FdJMcaakap7i%2FpLL7sRKbB81in9JkcVQkGk%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;879&quot; height=&quot;56&quot; data-origin-width=&quot;879&quot; data-origin-height=&quot;56&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;투자금 배분 버그 수정&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v1에 조용히 살아있던 버그다. 당연히 되겠지 하고 넘어갔던 부분이었는데, 실제로 계산해보니 의도와 전혀 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;v1 (버그)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;매수할 때마다: 현재 남은 KRW &amp;times; TRADE_RATE_KRW

예) 총 10만원, TRADE_RATE_KRW=0.3, 3종목
  1번: 10만 &amp;times; 0.3 = 3만원  (남은 KRW: 7만)
  2번:  7만 &amp;times; 0.3 = 2.1만원 (남은 KRW: 4.9만)
  3번: 4.9만 &amp;times; 0.3 = 1.47만원
  &amp;rarr; 총 투자 6.57만원 (65.7%), 종목별 금액 제각각
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TRADE_RATE_KRW=0.3이면 전체 자본의 30%를 쓰겠다는 뜻인데, 실제로는 65.7%가 투자되고 있었다. 게다가 첫 번째 종목에 두 배 넘게 몰리는 구조였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;132&quot; data-origin-height=&quot;106&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0r5vz/dJMcaiJdXsL/65OPlqtbQOpwlMKA6MEioK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0r5vz/dJMcaiJdXsL/65OPlqtbQOpwlMKA6MEioK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0r5vz/dJMcaiJdXsL/65OPlqtbQOpwlMKA6MEioK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0r5vz%2FdJMcaiJdXsL%2F65OPlqtbQOpwlMKA6MEioK%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;132&quot; height=&quot;106&quot; data-origin-width=&quot;132&quot; data-origin-height=&quot;106&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;v2 (수정)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;전체 자본(현금 + 이미 투자 중인 금액) &amp;times; TRADE_RATE_KRW &amp;divide; maxPositions

예) 총 10만원, TRADE_RATE_KRW=0.3, maxPositions=3
  종목당: 10만 &amp;times; 0.3 &amp;divide; 3 = 1만원 (균등)
  &amp;rarr; 총 투자 3만원 (30%) &amp;larr; TRADE_RATE_KRW 의도와 일치
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const totalCapital =
  krwBalance + this.positions.reduce((sum, p) =&amp;gt; sum + p.amountKrw, 0);
const amount = Math.floor(
  (totalCapital * this.tradeRateKrw) / this.maxPositions,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 자본 기준으로 계산하고 maxPositions으로 나눠서 균등 배분이 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;LLM이 손절&amp;middot;익절 기준 직접 설정&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v1에서 손절/익절 기준은 .env의 환경변수로 모든 종목에 동일하게 적용됐다. 변동성이 낮은 종목이나 높은 종목이나 같은 기준을 쓰는 게 비효율적이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v2에서는 LLM이 planStrategy 단계에서 종목별 ATR을 보고 개별 기준을 설정한다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;{
  &quot;market&quot;: &quot;KRW-XRP&quot;,
  &quot;stopLossPercent&quot;: 3,
  &quot;takeProfitMax&quot;: 8,
  &quot;goalProfitMin&quot;: 2,
  ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;변동성 낮은 종목 &amp;rarr; 손절 좁게, 익절 낮게&lt;/li&gt;
&lt;li&gt;변동성 높은 종목 &amp;rarr; 손절 넓게, 익절 높게&lt;/li&gt;
&lt;li&gt;LLM이 값을 반환하지 않으면 환경변수 기본값으로 fallback&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Position 구조도 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// v1
interface Position {
  market, startPrice, currentPrice, amountKrw, profitPercent
}

// v2
interface Position {
  market, startPrice, currentPrice, amountKrw, profitPercent,
  stopLossPercent,  // 포지션별 손절 기준
  takeProfitMax,    // 포지션별 즉시 익절 기준
  goalProfitMin,    // 포지션별 매도 허용 최소 수익률
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텔레그램 상태 메시지에도 종목별 기준이 표시된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;KRW-XRP | +2.3% | 손절: -3% / 익절: 8%
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;484&quot; data-origin-height=&quot;353&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xB0SR/dJMb99MiuDr/XbmHPUNVrLKDkgGpCmZKmK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xB0SR/dJMb99MiuDr/XbmHPUNVrLKDkgGpCmZKmK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xB0SR/dJMb99MiuDr/XbmHPUNVrLKDkgGpCmZKmK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxB0SR%2FdJMb99MiuDr%2FXbmHPUNVrLKDkgGpCmZKmK%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;484&quot; height=&quot;353&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;484&quot; data-origin-height=&quot;353&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;변경된 환경변수&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변수 v1 v2&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TRADE_CHECKING_PERIOD&lt;/td&gt;
&lt;td&gt;✅ (cron 주기)&lt;/td&gt;
&lt;td&gt;❌ 삭제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ANTHROPIC_API_KEY&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌ (시도 후 제거)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GOAL_PROFIT_MIN_PERCENT&lt;/td&gt;
&lt;td&gt;고정 적용&lt;/td&gt;
&lt;td&gt;LLM 미설정 시 fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GOAL_PROFIT_MAX_PERCENT&lt;/td&gt;
&lt;td&gt;고정 적용&lt;/td&gt;
&lt;td&gt;LLM 미설정 시 fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GOAL_STOP_LOSS_PERCENT&lt;/td&gt;
&lt;td&gt;고정 적용&lt;/td&gt;
&lt;td&gt;LLM 미설정 시 fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TRADE_RATE_KRW&lt;/td&gt;
&lt;td&gt;종목당 남은 KRW 비율&lt;/td&gt;
&lt;td&gt;전체 자본 균등 분배 비율&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;코드 변화량&lt;/h1&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;18개 파일 변경
+1,214줄 추가
-521줄 삭제
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;신규 파일 3개(trading.rule.ts, upbit-websocket.service.ts, indicator.engine.ts), 삭제 파일 3개(app.controller.ts, app.service.ts, app.controller.spec.ts).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용하지 않는 메서드와 인터페이스도 전부 정리했다. OllamaService.selectMarkets(), OllamaService.selectMarket(), UpbitService.getCurrentPrice() 등 WebSocket과 새 전략 스케줄러로 대체된 것들이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v1에서 +18% 수익을 냈다가 -20% 손실로 끝난 게 v2를 만든 직접적인 계기였다. 손절이 60초 늦는 구조, 투자금이 불균등하게 배분되는 버그, LLM이 현재 RSI를 모르는 채 조건을 설정하는 모순. 이 세 가지를 고쳐야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v2는 아직 운영 중이다. WebSocket 실시간 손절이 실제로 얼마나 차이를 만드는지는 좀 더 지켜봐야 알 수 있다. LLM 전략 고도화도 마찬가지다. 결과는 나중에 따로 정리할 예정이다.&lt;/p&gt;</description>
      <category>Project/Coiner</category>
      <category>nestjs</category>
      <category>Ollama</category>
      <category>web socket</category>
      <category>업비트API</category>
      <category>업비트웹소켓</category>
      <category>코인자동매매</category>
      <category>텔레그램봇</category>
      <category>투자</category>
      <category>트레이딩봇</category>
      <author>Dwony</author>
      <guid isPermaLink="true">https://developer-dwon.tistory.com/26</guid>
      <comments>https://developer-dwon.tistory.com/entry/%EC%BD%94%EC%9D%B8-%EC%9E%90%EB%8F%99%EB%A7%A4%EB%A7%A4-%EB%B4%87-%EC%BD%94%EC%9D%B4%EB%84%88-%E2%80%94-v2-%EC%A0%84%EB%A9%B4-%EC%9E%AC%EC%84%A4%EA%B3%84-WebSocket%EA%B3%BC-3%EB%8B%A8-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98#entry26comment</comments>
      <pubDate>Sun, 8 Mar 2026 17:21:47 +0900</pubDate>
    </item>
    <item>
      <title>코인 자동매매 봇 (코이너) &amp;mdash; 모델 업그레이드, 수익 최적화</title>
      <link>https://developer-dwon.tistory.com/entry/%EC%BD%94%EC%9D%B8-%EC%9E%90%EB%8F%99%EB%A7%A4%EB%A7%A4-%EB%B4%87-%EC%BD%94%EC%9D%B4%EB%84%88-%E2%80%94-%EB%AA%A8%EB%8D%B8-%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C-%EC%88%98%EC%9D%B5-%EC%B5%9C%EC%A0%81%ED%99%94</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mzkvB/dJMcabDf1ro/kgDpZoiVsjyW8djHs4y2QK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mzkvB/dJMcabDf1ro/kgDpZoiVsjyW8djHs4y2QK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mzkvB/dJMcabDf1ro/kgDpZoiVsjyW8djHs4y2QK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmzkvB%2FdJMcabDf1ro%2FkgDpZoiVsjyW8djHs4y2QK%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;303&quot; height=&quot;303&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;전 편에서 텔레그램과 MongoDB가 붙어서 스마트폰으로 봇을 제어할 수 있게 됐다. 이번 편에서는 판단 품질을 더 높여보고 싶어서 이것저것 좀 더 구해본 과정이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;목표 수익률을 범위값으로&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 GOAL_PROFIT_PERCENT=3이면 수익률 3% 달성하자마자 무조건 매도하는 구조였다. 하지만 생각해보면 3%를 넘어도 더 오른다 싶어서 기다리는 상황이 올 수 있다. LLM이 더 오를 것 같다고 판단하면 홀드하고, 내릴 것 같다고 판단하면 그때 파는 구조가 더 자연스럽다.&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;수익률 &amp;lt; -손절%    &amp;rarr; 즉시 손절
수익률 &amp;gt;= 정시 철수(7%)  &amp;rarr; 강제 매도

3% ~ 7% 구간:
  LLM SELL 판단 &amp;rarr; 매도
  LLM HOLD 판단 &amp;rarr; 더 기다림

3% 미만:
  LLM SELL 와도 무시 (3%는 되어야 나가준다)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드에서도 이 흐름대로 도는데, LLM 프롬프트에도 현재 수익률과 허용 범위를 알려서 LLM이 상황을 인지한 뒤 판단하도록 했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;private async handlePosition(pos: Position) {
  // 1. 손절 (최우선)
  if (pos.profitPercent &amp;lt;= -this.stopLossPercent) {
    await this.executeClose(pos, '손절');
    return;
  }

  // 2. 최대 목표 돌파 &amp;rarr; 강제 매도
  if (pos.profitPercent &amp;gt;= this.goalProfitMax) {
    await this.executeClose(pos, `최대 목표 달성 (+${pos.profitPercent.toFixed(2)}%)`);
    return;
  }

  // 3. LLM 판단
  const decision = await this.ollama.analyze(
    pos.market, candles1m, candles15m, candles1h,
    { profitPercent: pos.profitPercent, goalMin: this.goalProfitMin, goalMax: this.goalProfitMax },
  );

  // 최소 목표 이상일 때만 SELL 허용
  if (decision.signal === 'SELL' &amp;amp;&amp;amp; pos.profitPercent &amp;gt;= this.goalProfitMin) {
    await this.executeClose(pos, `LLM 매도 (+${pos.profitPercent.toFixed(2)}%)`);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.env도 두 개로 나눴다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;GOAL_PROFIT_MIN_PERCENT=3
GOAL_PROFIT_MAX_PERCENT=7
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;투자 비율 기반으로 변경&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TRADE_AMOUNT_KRW로 고정 금액을 설정하던 방식을 TRADE_RATE_KRW로 명칭을 바꾸고, &lt;b&gt;KRW 잔고의 비율&lt;/b&gt;로 투자하도록 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// 변경 전: 고정 금액
const amount = Math.min(this.tradeAmountKrw, krwBalance);

// 변경 후: 잔고 비율
const amount = Math.floor(krwBalance * this.tradeRateKrw);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잔고가 100,000원에서 TRADE_RATE_KRW=0.3으로 설정하면 첫 번째 매수는 30,000원, 두 번째 매수 시점 잔고가 70,000원이라면 21,000원 식으로 자동으로 조절된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;LLM 모델 업그레이드&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;llama3.2:3b는 3B 파라미터 모델이라 추론 품질에 한계가 있었다. JSON 포맷을 엄격하게 지켜야 하는 우리 봇 구조에서는 모델의 JSON 신뢰도가 중요했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모델 비교&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델 RAM 속도 추론 JSON 안정성&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;llama3.2:3b (기존)&lt;/td&gt;
&lt;td&gt;2GB&lt;/td&gt;
&lt;td&gt;매우 빠름&lt;/td&gt;
&lt;td&gt;낙음&lt;/td&gt;
&lt;td&gt;보통&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama3.1:8b&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;td&gt;빠름&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen2.5:7b&lt;/td&gt;
&lt;td&gt;5GB&lt;/td&gt;
&lt;td&gt;빠름&lt;/td&gt;
&lt;td&gt;중상&lt;/td&gt;
&lt;td&gt;좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;deepseek-r1:14b&lt;/td&gt;
&lt;td&gt;14GB&lt;/td&gt;
&lt;td&gt;매우 느림&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;보통&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;qwen2.5:14b&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;td&gt;빠름&lt;/td&gt;
&lt;td&gt;&lt;b&gt;높음&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;실패 적음&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;M2 Pro 16GB 환경에서 qwen2.5:14b가 가장 적합하다. 모델 크기가 약 8.2GB라 운영체제 메모리 여유를 남기면서 Apple Silicon GPU 가속으로 초당 30~50 토큰 속도가 나온다. deepseek는 내부적으로 thinking 토큰을 먼저 생성해서 응답이 느려 타임아웃 위험이 있어서 제외했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모델 변경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.env에서 한 줄만 바꾸면 된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;OLLAMA_MODEL=qwen2.5:14b
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;14b 모델은 응답이 느릴 수 있어서 타임아웃도 맞게 늘렸다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;// analyze: 60s &amp;rarr; 90s
// selectMarkets/selectMarket: 30s &amp;rarr; 60s
await axios.post(url, body, { timeout: 90000 });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;778&quot; data-origin-height=&quot;153&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baK5wc/dJMcab4i3vj/NAhSTpDjFJKCOOKvbWk2N0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baK5wc/dJMcab4i3vj/NAhSTpDjFJKCOOKvbWk2N0/img.png&quot; data-alt=&quot;qwen2.5:14b 모델을 풀받았다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baK5wc/dJMcab4i3vj/NAhSTpDjFJKCOOKvbWk2N0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaK5wc%2FdJMcab4i3vj%2FNAhSTpDjFJKCOOKvbWk2N0%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;778&quot; height=&quot;153&quot; data-origin-width=&quot;778&quot; data-origin-height=&quot;153&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;qwen2.5:14b 모델을 풀받았다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델이&amp;nbsp;다운로드됐다면&amp;nbsp;ollama&amp;nbsp;list&amp;nbsp;명령어로&amp;nbsp;확인할&amp;nbsp;수&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;359&quot; data-origin-height=&quot;67&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kDpov/dJMcabJ2wlq/fa7nuhXIEWBj8pPptf0qK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kDpov/dJMcabJ2wlq/fa7nuhXIEWBj8pPptf0qK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kDpov/dJMcabJ2wlq/fa7nuhXIEWBj8pPptf0qK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkDpov%2FdJMcabJ2wlq%2Ffa7nuhXIEWBj8pPptf0qK0%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;359&quot; height=&quot;67&quot; data-origin-width=&quot;359&quot; data-origin-height=&quot;67&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;LLM 타임아웃 문제 해결&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델을 바꿔도 &quot;LLM 분석실패&quot; 오류가 자꾸 뜨다. Ollama는 기본적으로 5분 동안 요청이 없으면 모델을 메모리에서 내린다. 5분봉 봇이니까 가끔 모델이 내린 시점에 요청이 오면 재로드에 15~20초가 걸리면서 타임아웃이 발생하는 것이었다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;await axios.post(
  `${this.baseUrl}/api/generate`,
  {
    model: this.model,
    prompt,
    stream: false,
    format: 'json',
    options: { num_predict: 200 }, // 불필요한 토큰 생성 차단
    keep_alive: '30m',             // 모델 메모리 유지 30분
  },
  { timeout: 90000 },
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;keep_alive: '30m'를 설정하니 재로드 없이 메모리에 상주해서 바로 응답한다. num_predict: 200으로 토큰 생성에도 상한을 세팅하니 생성 토큰을 딱 필요한 수준으로만 제한했다. 두 개를 적용한 후엔 타임아웃이 없어졌다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;수익 개선 아이디어 구현&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수익률을 더 올릴 수 있는 아이디어를 정리해보니 우선순위가 이렇게 나왔다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1순위: ATR 변동성 필터 &amp;mdash; 손절 횟수 감소 효과 확실
2순위: 김치 프리미엄 &amp;mdash; 무료, 한국 시장 특성에 직접적
3순위: 업비트 투자유의 공시 필터 &amp;mdash; 입출금 중단 종목 매수 회피
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 개를 한번에 적용했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. ATR 변동성 필터&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ATR(Average True Range)은 일정 기간 동안의 실제 가격 영역을 측정하는 지표다. 변동성이 클 때는 매매를 피하면 손절 횟수를 줄일 수 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;private calcATR(candles: Candle[], period = 14): number {
  if (candles.length &amp;lt; period + 1) return 0;
  const trs: number[] = [];
  for (let i = 0; i &amp;lt; period; i++) {
    const high = candles[i].high_price;
    const low = candles[i].low_price;
    const prevClose = candles[i + 1].trade_price;
    trs.push(Math.max(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose)));
  }
  return trs.reduce((a, b) =&amp;gt; a + b, 0) / period;
}

private atrDesc(candles: Candle[]): string {
  const atr = this.calcATR(candles);
  const price = candles[0]?.trade_price ?? 1;
  const atrPct = (atr / price) * 100;
  if (atrPct &amp;gt;= 3) return `${atrPct.toFixed(2)}% &amp;rarr; 매우 과도한 변동성 (진입 자제 권장)`;
  if (atrPct &amp;gt;= 1.5) return `${atrPct.toFixed(2)}% &amp;rarr; 일반 변동성`;
  return `${atrPct.toFixed(2)}% &amp;rarr; 낙은 변동성 (안정적)`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 프롬프트에는 이렇게 전달된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;변동성 (ATR 14, 1분봉 기준): 2.31% &amp;rarr; 일반 변동성
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 김치 프리미엄&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국 거래소와 해외 거래소 가격 차이인 김치 프리미엄을 LLM에 넘겨준다. 김프가 높으면 한국만 과열된 상황이라 하락 위험이 있고, 역프(음수)이면 해외 대비 저평가 신호다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Binance 공개 API로 BTC 해외 가격을 가져오고, USD/KRW 환율은 open.er-api.com에서 1시간 단위로 캐싱해둔다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;private usdKrwCache: { rate: number; cachedAt: number } | null = null;

private async getUsdKrw(): Promise {
  const now = Date.now();
  if (this.usdKrwCache &amp;amp;&amp;amp; now - this.usdKrwCache.cachedAt &amp;lt; 3600_000) {
    return this.usdKrwCache.rate;
  }
  const res = await axios.get('&amp;lt;https://open.er-api.com/v6/latest/USD&amp;gt;');
  const rate = res.data.rates.KRW;
  this.usdKrwCache = { rate, cachedAt: now };
  return rate;
}

private async fetchKimchiPremium(): Promise {
  const [upbitRes, binanceRes, usdKrw] = await Promise.all([
    axios.get&amp;lt;upbittickeritem[]&amp;gt;('&amp;lt;https://api.upbit.com/v1/ticker?markets=KRW-BTC&amp;gt;'),
    axios.get('&amp;lt;https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT&amp;gt;'),
    this.getUsdKrw(),
  ]);
  const upbitPrice = upbitRes.data[0].trade_price;
  const binanceKrw = parseFloat(binanceRes.data.price) * usdKrw;
  const premium = ((upbitPrice / binanceKrw) - 1) * 100;
  return `${premium.toFixed(2)}% (${premium &amp;gt; 3 ? '과열 &amp;mdash; 하락 주의' : premium &amp;lt; -1 ? '역프 &amp;mdash; 저평가 기회' : '정상 범위'})`;
}
&amp;lt;/upbittickeritem[]&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 업비트 투자유의 필터&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;업비트 API에서 투자유의 전제가 걸린 종목 목록을 가져와서 종목 선택 단계에서 제외한다. 투자유의는 입출금 중단, 급등락, 생태계 이슈 등이 있어 판단을 야기하기에 맞지 않는 종목들이다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// upbit.service.ts
async getMarketWarnings(): Promise&amp;lt;Set&amp;lt;string&amp;gt;&amp;gt; {
  try {
    const res = await axios.get&amp;lt;MarketDetail[]&amp;gt;(
      `${this.baseUrl}/market/all?isDetails=true`,
    );
    const warnings = new Set&amp;lt;string&amp;gt;();
    for (const m of res.data) {
      if (m.market_event?.warning) warnings.add(m.market);
    }
    return warnings;
  } catch {
    return new Set&amp;lt;string&amp;gt;();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fillPositions()에서 심용 종목과 투자유의 종목을 동시에 필터링한다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;private async fillPositions(slots: number) {
  const excludeMarkets = this.positions.map((p) =&amp;gt; p.market);
  const [topTickers, warningMarkets] = await Promise.all([
    this.upbit.getTopMarkets(20 + excludeMarkets.length),
    this.upbit.getMarketWarnings(),
  ]);

  const available = topTickers.filter(
    (t) =&amp;gt; !excludeMarkets.includes(t.market) &amp;amp;&amp;amp; !warningMarkets.has(t.market),
  );

  if (warningMarkets.size &amp;gt; 0) {
    this.logger.log(`투자유의 종목 ${warningMarkets.size}개 제외: ${[...warningMarkets].join(', ')}`);
  }
  // 필터링된 available 목록으로 LLM 종목 선택
  const decisions = await this.ollama.selectMarkets(available, slots);
  ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;925&quot; data-origin-height=&quot;33&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dI6mYB/dJMcajgVtH3/lKKpqGmFta9Boc0xeCcbR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dI6mYB/dJMcajgVtH3/lKKpqGmFta9Boc0xeCcbR1/img.png&quot; data-alt=&quot;이렇게 투자유의 종목들을 사전에 필터링 하는것을 볼 수 있다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dI6mYB/dJMcajgVtH3/lKKpqGmFta9Boc0xeCcbR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdI6mYB%2FdJMcajgVtH3%2FlKKpqGmFta9Boc0xeCcbR1%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;925&quot; height=&quot;33&quot; data-origin-width=&quot;925&quot; data-origin-height=&quot;33&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이렇게 투자유의 종목들을 사전에 필터링 하는것을 볼 수 있다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;종목별 수동 매도&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 판단이 너무 늦게 나오거나 특정 포지션을 직접 닫고 싶을 때 텔레그램에서 바로 매도할 수 있도록 수동 매도 기능도 추가했다.&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;467&quot; data-origin-height=&quot;190&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ER43a/dJMcagq0QO6/vNvdKDcce9VrrNoXREN2W1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ER43a/dJMcagq0QO6/vNvdKDcce9VrrNoXREN2W1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ER43a/dJMcagq0QO6/vNvdKDcce9VrrNoXREN2W1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FER43a%2FdJMcagq0QO6%2FvNvdKDcce9VrrNoXREN2W1%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;467&quot; height=&quot;190&quot; data-origin-width=&quot;467&quot; data-origin-height=&quot;190&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;470&quot; data-origin-height=&quot;153&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/csQlSC/dJMcaaK6zcG/osvw7KFuvyuAv1XbkzrCa1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/csQlSC/dJMcaaK6zcG/osvw7KFuvyuAv1XbkzrCa1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/csQlSC/dJMcaaK6zcG/osvw7KFuvyuAv1XbkzrCa1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcsQlSC%2FdJMcaaK6zcG%2Fosvw7KFuvyuAv1XbkzrCa1%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;153&quot; data-origin-width=&quot;470&quot; data-origin-height=&quot;153&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼을 누르면 해당 포지션의 수동 매도가 실행되고 텔레그램으로 매도 완료 메시지가 온다. 자동재시작 ON이라도 수동 매도는 재시작하지 않는다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;보안 점검&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub에 푸시하기 전에 보안 점검을 진행했다. 확인 대상은 세 가지였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목 상태&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;.env (실제 API키, 토큰)&lt;/td&gt;
&lt;td&gt;.gitignore에 등록됨 &amp;mdash; &lt;b&gt;푸시 안 됨&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;소스코드 내 비밀값&lt;/td&gt;
&lt;td&gt;전부 ConfigService로 환경변수 참조 &amp;mdash; &lt;b&gt;하드코딩 없음&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;node_modules/, dist/&lt;/td&gt;
&lt;td&gt;.gitignore에 등록됨&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 보완할 게 있었다. .claude/ 디렉토리가 .gitignore에 없어서 settings.local.json이 푸시될 수 있는 상황이었다. .claude/를 .gitignore에 추가함으로써 해결했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;최종 프로젝트 구조&lt;/h1&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;src/
├── upbit/
│   ├── upbit.service.ts    # API, JWT인증, 투자유의 필터
│   └── upbit.module.ts
├── ollama/
│   ├── ollama.service.ts   # RSI/MA/ATR/김프/공포탐욕/CoT
│   └── ollama.module.ts
├── trading/
│   ├── trading.service.ts  # 다중포지션, 범위수익률, 자동재시작
│   ├── trading.controller.ts
│   └── trading.module.ts
├── telegram/
│   ├── telegram.service.ts # 인라인버튼, 수동매도, 알림
│   └── telegram.module.ts
├── database/
│   ├── trade-record.schema.ts
│   ├── trade-record.service.ts
│   └── database.module.ts
├── app.module.ts
└── main.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3편에 걸쳐 보다 성숙해진 봇이 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델을 llama3.2:3b에서 qwen2.5:14b로 올렸고, 타임아웃 문제도 해결했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수익률 범위 설정으로 LLM이 매도 타이밍을 직접 결정할 수 있게 됐고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ATR / 김치 프리미엄 / 투자유의 필터로 더 명확한 근거에 기반한 판단이 가능해졌다.&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;실제 돈으로 돌리면서 느낀점은, LLM은 시장을 예측하는 게 아니라 &lt;b&gt;지표를 해석해서 판단에 도움을 주는&lt;/b&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;좋은 아이디어가 있으면 댓글로 알려주기 바란다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;감사하다.&lt;/p&gt;</description>
      <category>Project/Coiner</category>
      <category>ATR</category>
      <category>LLM타임아웃</category>
      <category>nestjs</category>
      <category>Ollama</category>
      <category>김프</category>
      <category>로컬llm</category>
      <category>업비트공시</category>
      <category>코인자동매매</category>
      <category>코인투자</category>
      <category>트레이딩봇</category>
      <author>Dwony</author>
      <guid isPermaLink="true">https://developer-dwon.tistory.com/25</guid>
      <comments>https://developer-dwon.tistory.com/entry/%EC%BD%94%EC%9D%B8-%EC%9E%90%EB%8F%99%EB%A7%A4%EB%A7%A4-%EB%B4%87-%EC%BD%94%EC%9D%B4%EB%84%88-%E2%80%94-%EB%AA%A8%EB%8D%B8-%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C-%EC%88%98%EC%9D%B5-%EC%B5%9C%EC%A0%81%ED%99%94#entry25comment</comments>
      <pubDate>Mon, 23 Feb 2026 23:21:26 +0900</pubDate>
    </item>
    <item>
      <title>코인 자동매매 봇 (코이너) &amp;mdash; 텔레그램 연동, MongoDB, 프롬프트 고도화</title>
      <link>https://developer-dwon.tistory.com/entry/%EC%BD%94%EC%9D%B8-%EC%9E%90%EB%8F%99%EB%A7%A4%EB%A7%A4-%EB%B4%87-%EC%BD%94%EC%9D%B4%EB%84%88-%E2%80%94-%ED%85%94%EB%A0%88%EA%B7%B8%EB%9E%A8-%EC%97%B0%EB%8F%99-MongoDB-%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8-%EA%B3%A0%EB%8F%84%ED%99%94</link>
      <description>&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;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHubi2/dJMb99SXjlg/GkCowV3dgXOGqSpbgRsjDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHubi2/dJMb99SXjlg/GkCowV3dgXOGqSpbgRsjDk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHubi2/dJMb99SXjlg/GkCowV3dgXOGqSpbgRsjDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHubi2%2FdJMb99SXjlg%2FGkCowV3dgXOGqSpbgRsjDk%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;338&quot; height=&quot;338&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;전편에서 NestJS 기반 자동매매 봇의 뼈대를 완성했다. 이번 편에서는 스마트폰으로 봇을 제어할 수 있게 텔레그램을 연동하고, 매매 기록을 MongoDB에 저장하고, LLM이 더 입체적으로 판단할 수 있도록 프롬프트를 고도화한 과정을 기록한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;종목 자동 선택&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 BTC만 보던 봇이었는데, 거래량 상위 코인 중에서 LLM이 직접 종목을 골라서 매수하는 방식으로 바꿨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작 방식은 간단하다. 포지션이 비어있을 때마다 Upbit KRW 마켓 전체 종목을 조회해서 24시간 거래대금 상위 20개를 추린 뒤, 그 목록을 LLM에 넘겨서 { signal: &quot;BUY&quot;, market: &quot;KRW-ETH&quot;, reason: &quot;...&quot; } 형태로 종목과 매매 신호를 동시에 받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포지션이 있을 때는 보유 종목의 캔들을 분석해서 SELL/HOLD만 판단한다. 종목 선택과 매도 판단이 완전히 분리된 구조다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;926&quot; data-origin-height=&quot;81&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zCjQz/dJMcadnufHv/85CTT0zxDwups50sD6STh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zCjQz/dJMcadnufHv/85CTT0zxDwups50sD6STh0/img.png&quot; data-alt=&quot;이제 코이너가 종목을 알아서 선택한다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zCjQz/dJMcadnufHv/85CTT0zxDwups50sD6STh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzCjQz%2FdJMcadnufHv%2F85CTT0zxDwups50sD6STh0%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;926&quot; height=&quot;81&quot; data-origin-width=&quot;926&quot; data-origin-height=&quot;81&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이제 코이너가 종목을 알아서 선택한다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;텔레그램 봇 연동&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 켜두고 스마트폰으로 봇을 제어하고 싶었다. 텔레그램 봇을 연동하는 게 가장 빠른 방법이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;봇 토큰과 Chat ID 발급&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텔레그램에서 @BotFather를 검색해서 /newbot 명령어로 봇을 만들면 토큰이 발급된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Chat ID는 만들어진 봇에 아무 메시지나 보낸 뒤 아래 URL에 접속하면 응답 JSON에서 확인할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;&amp;lt;https://api.telegram.org/bot여기에토큰입력/getUpdates&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답에서 &quot;chat&quot;: {&quot;id&quot;: 123456789} 부분이 Chat ID다.&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;1312&quot; data-origin-height=&quot;63&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cqOFEw/dJMb99MaIrb/q2tf4mjJf5pQkqrJ9hglQ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cqOFEw/dJMb99MaIrb/q2tf4mjJf5pQkqrJ9hglQ1/img.png&quot; data-alt=&quot;봇을 만들고 위 주소로 접속하면 이렇게 뜬다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cqOFEw/dJMb99MaIrb/q2tf4mjJf5pQkqrJ9hglQ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcqOFEw%2FdJMb99MaIrb%2Fq2tf4mjJf5pQkqrJ9hglQ1%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;1312&quot; height=&quot;63&quot; data-origin-width=&quot;1312&quot; data-origin-height=&quot;63&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;p data-ke-size=&quot;size16&quot;&gt;알아낸 봇 API토큰과 Chat ID를 .env에 추가한다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;TELEGRAM_BOT_TOKEN=여기에_봇_토큰_입력
TELEGRAM_CHAT_ID=여기에_Chat_ID_입력
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;패키지 설치&lt;/h2&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;npm install node-telegram-bot-api
npm install -D @types/node-telegram-bot-api
&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;텍스트 명령어(/start_bot 등)도 만들었지만, 버튼이 훨씬 편했다. 모든 메시지 하단에 인라인 키보드가 자동으로 붙도록 했다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;[ ▶ 봇 시작 ]  [ ⏹ 봇 중단 ]
[   자동재시작: ON ]
[   상태 확인 ]  [   자산 조회 ]
[   매매 내역 ]  [   수익 통계 ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;270&quot; data-origin-height=&quot;215&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kJYw2/dJMcaaRTFUK/eYWnub3NModHEXuvwfnLrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kJYw2/dJMcaaRTFUK/eYWnub3NModHEXuvwfnLrk/img.png&quot; data-alt=&quot;코이너 채팅 화면이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kJYw2/dJMcaaRTFUK/eYWnub3NModHEXuvwfnLrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkJYw2%2FdJMcaaRTFUK%2FeYWnub3NModHEXuvwfnLrk%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;270&quot; height=&quot;215&quot; data-origin-width=&quot;270&quot; data-origin-height=&quot;215&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;코이너 채팅 화면이다.&lt;/figcaption&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;목표 수익 달성이나 손절로 봇이 종료되면 5초 후 자동으로 다시 시작하는 기능도 추가했다. 기본값은 ON이다. 수동으로 ⏹ 봇 중단 버튼을 누른 경우에는 자동 재시작이 동작하지 않는다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;private autoRestart = true; // 기본 ON

private async finishBot(reason: string) {
  this.running = false;
  await this.telegram.send(`봇 종료: ${reason}`);

  if (this.autoRestart) {
    await this.telegram.send('5초 후 자동 재시작...');
    setTimeout(() =&amp;gt; this.start(), 5000);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HOLD 메시지 제거&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 매 5분마다 HOLD 메시지가 와서 알림이 너무 많았다. HOLD일 때는 텔레그램으로 아무것도 보내지 않도록 바꿨다. 실제로 의미 있는 이벤트(매수, 매도, 목표달성, 손절, 오류)만 알림이 온다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자산 조회 /balance&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텔레그램에서   자산 조회 버튼을 누르면 현재 Upbit 잔고를 바로 볼 수 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[업비트 자산 현황]

KRW: 70,000원
ETH: 0.007
  현재가: 4,230,000원
  평가금액: 29,610원
  수익률: +1.43%

총 평가금액: 99,610원
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;456&quot; data-origin-height=&quot;458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bt3iKR/dJMcajutK2p/KdHur5Xl2h9RAtNNku0eLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bt3iKR/dJMcajutK2p/KdHur5Xl2h9RAtNNku0eLk/img.png&quot; data-alt=&quot;이런식으로 보유 종목을 표시해준다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bt3iKR/dJMcajutK2p/KdHur5Xl2h9RAtNNku0eLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbt3iKR%2FdJMcajutK2p%2FKdHur5Xl2h9RAtNNku0eLk%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;456&quot; height=&quot;458&quot; data-origin-width=&quot;456&quot; data-origin-height=&quot;458&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이런식으로 보유 종목을 표시해준다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;MongoDB 연동&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 재시작하면 매매 기록이 날아가는 게 불편했다. MongoDB를 붙여서 기록을 영구적으로 저장하기로 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;MySQL vs MongoDB&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL + TypeORM도 고려했지만 이 프로젝트엔 MongoDB가 더 맞았다. 매매 기록에 LLM 판단 근거, RSI 수치, 이동평균 신호 같은 중첩 데이터가 포함되는데, MySQL이면 별도 테이블 설계가 필요하다. MongoDB는 JSON 그대로 저장할 수 있어서 스키마 변경 부담도 없다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Docker로 로컬 실행&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MongoDB가 로컬에 없어서 Docker로 컨테이너를 띄웠다. --restart always 옵션으로 컴퓨터를 재시작해도 자동으로 올라온다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;docker run -d --name coin-bot-mongo -p 27017:27017 --restart always mongo:7
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;npm install @nestjs/mongoose mongoose
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1998&quot; data-origin-height=&quot;688&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c7eJqy/dJMcadAZwBs/eKKA9ryfX1m8FLkuZwTeK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c7eJqy/dJMcadAZwBs/eKKA9ryfX1m8FLkuZwTeK0/img.png&quot; data-alt=&quot;위 명령어로 MongoDB 이미지를 불러와 로컬에 띄웠다. 도커 데스크탑에서 확인할 수 있다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c7eJqy/dJMcadAZwBs/eKKA9ryfX1m8FLkuZwTeK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc7eJqy%2FdJMcadAZwBs%2FeKKA9ryfX1m8FLkuZwTeK0%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;1998&quot; height=&quot;688&quot; data-origin-width=&quot;1998&quot; data-origin-height=&quot;688&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;위 명령어로 MongoDB 이미지를 불러와 로컬에 띄웠다. 도커 데스크탑에서 확인할 수 있다.&lt;/figcaption&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;매수/매도 시마다 아래 형태로 MongoDB에 자동 저장된다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;type&quot;: &quot;BUY&quot;,
  &quot;market&quot;: &quot;KRW-ETH&quot;,
  &quot;price&quot;: 4230000,
  &quot;amount&quot;: 30000,
  &quot;indicators&quot;: { &quot;rsi&quot;: 28.3, &quot;maSignal&quot;: &quot;golden_cross&quot; },
  &quot;llmReason&quot;: &quot;RSI 과매도 + 골든크로스 발생&quot;,
  &quot;timestamp&quot;: &quot;2026-02-23T10:00:00Z&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;DataGrip으로 접속&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DataGrip에서 MongoDB 접속은 간단하다. + &amp;rarr; MongoDB 선택 후 아래 정보를 입력하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목 값&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;localhost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port&lt;/td&gt;
&lt;td&gt;27017&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;coin-bot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MongoDB는 데이터가 처음 저장될 때 DB가 생성된다. 아직 매매가 없으면 coin-bot DB가 보이지 않는 게 정상이다. 첫 매수가 실행되면 자동으로 생긴다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 68px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 68px;&quot;&gt;
&lt;td style=&quot;width: 30.6977%; height: 68px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;193&quot; data-origin-height=&quot;87&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dwg4UT/dJMcab4iXqg/Wa1o54YGUPk2XRZZes9uq0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dwg4UT/dJMcab4iXqg/Wa1o54YGUPk2XRZZes9uq0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dwg4UT/dJMcab4iXqg/Wa1o54YGUPk2XRZZes9uq0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdwg4UT%2FdJMcab4iXqg%2FWa1o54YGUPk2XRZZes9uq0%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;193&quot; height=&quot;87&quot; data-origin-width=&quot;193&quot; data-origin-height=&quot;87&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 69.3023%; height: 68px;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;602&quot; data-origin-height=&quot;241&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pF3dr/dJMcahQZzVV/eQKlCZ5kQF4aMpoqJpTT3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pF3dr/dJMcahQZzVV/eQKlCZ5kQF4aMpoqJpTT3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pF3dr/dJMcahQZzVV/eQKlCZ5kQF4aMpoqJpTT3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpF3dr%2FdJMcahQZzVV%2FeQKlCZ5kQF4aMpoqJpTT3K%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;602&quot; height=&quot;241&quot; data-origin-width=&quot;602&quot; data-origin-height=&quot;241&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;  매매 내역 버튼을 누르면 최근 10건을 볼 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;455&quot; data-origin-height=&quot;253&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpeaLc/dJMcacPG7ci/kvI2qlwmk5HuLiukndHJ21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpeaLc/dJMcacPG7ci/kvI2qlwmk5HuLiukndHJ21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpeaLc/dJMcacPG7ci/kvI2qlwmk5HuLiukndHJ21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpeaLc%2FdJMcacPG7ci%2FkvI2qlwmk5HuLiukndHJ21%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;455&quot; height=&quot;253&quot; data-origin-width=&quot;455&quot; data-origin-height=&quot;253&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;455&quot; data-origin-height=&quot;283&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcLqyL/dJMcagdu7uX/zZupHcJhu89vhbHvQhPaKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcLqyL/dJMcagdu7uX/zZupHcJhu89vhbHvQhPaKK/img.png&quot; data-alt=&quot;이건 다음날 아침에 확인해본 데이터다. 60%면 꽤 괜찮은데?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcLqyL/dJMcagdu7uX/zZupHcJhu89vhbHvQhPaKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdcLqyL%2FdJMcagdu7uX%2FzZupHcJhu89vhbHvQhPaKK%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;455&quot; height=&quot;283&quot; data-origin-width=&quot;455&quot; data-origin-height=&quot;283&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이건 다음날 아침에 확인해본 데이터다. 60%면 꽤 괜찮은데?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;서버 재시작 후 포지션 복구&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코인을 보유한 채로 서버가 꺼지면 봇이 포지션을 모르는 상태로 재시작된다. 그러면 또 매수를 시도할 수도 있다. (실제로 그랬다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법으로 서버 시작 시 Upbit 잔고를 직접 조회해서 코인이 남아있으면 자동으로 포지션을 복구하도록 했다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;private async restorePositionFromBalance() {
  const balances = await this.upbit.getBalances();
  const coinBalance = balances.find(
    (b) =&amp;gt; b.currency !== 'KRW' &amp;amp;&amp;amp; parseFloat(b.balance) &amp;gt; 0
  );

  if (coinBalance) {
    const market = `KRW-${coinBalance.currency}`;
    const startPrice = parseFloat(coinBalance.avg_buy_price);
    this.positions.push({ market, startPrice, volume: parseFloat(coinBalance.balance) });
    await this.telegram.send(`기존 포지션 감지 &amp;mdash; 이어서 시작\\n종목: ${market}\\n평단가: ${startPrice.toLocaleString()}원`);
  }
}
&lt;/code&gt;&lt;/pre&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;456&quot; data-origin-height=&quot;248&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/onxjr/dJMcahQZAbP/iqbe7pmoUTSVEf97ATlCR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/onxjr/dJMcahQZAbP/iqbe7pmoUTSVEf97ATlCR1/img.png&quot; data-alt=&quot;이렇게 기존 포지션을 이어간다고 메시지가 온다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/onxjr/dJMcahQZAbP/iqbe7pmoUTSVEf97ATlCR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fonxjr%2FdJMcahQZAbP%2Fiqbe7pmoUTSVEf97ATlCR1%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;456&quot; height=&quot;248&quot; data-origin-width=&quot;456&quot; data-origin-height=&quot;248&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이렇게 기존 포지션을 이어간다고 메시지가 온다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;다중 포지션 (분산투자)&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 종목에만 투자하는 게 아니라 2~3개 종목을 동시에 보유할 수 있도록 구조를 바꿨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.env에 TRADE_MAX_POSITIONS=3을 추가하면 최대 3개 포지션을 동시에 운용한다. 비어있는 슬롯 수만큼 LLM이 종목을 골라서 매수하고, 각 포지션은 독립적으로 손절/목표 체크를 진행한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Cron('0 */5 * * * *')
async onCandle() {
  if (!this.running) return;

  // 보유 포지션 각각 체크
  await Promise.all(this.positions.map((pos) =&amp;gt; this.handlePosition(pos)));

  // 빈 슬롯만큼 새 종목 매수
  const slots = this.maxPositions - this.positions.length;
  if (slots &amp;gt; 0) await this.fillPositions(slots);
}
&lt;/code&gt;&lt;/pre&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;485&quot; data-origin-height=&quot;315&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvgziO/dJMcadnuggW/Qs4IYRl54cZPy4JcFTj7CK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvgziO/dJMcadnuggW/Qs4IYRl54cZPy4JcFTj7CK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvgziO/dJMcadnuggW/Qs4IYRl54cZPy4JcFTj7CK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbvgziO%2FdJMcadnuggW%2FQs4IYRl54cZPy4JcFTj7CK%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;485&quot; height=&quot;315&quot; data-origin-width=&quot;485&quot; data-origin-height=&quot;315&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;프롬프트 고도화&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;멀티 타임프레임&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1분봉 데이터만 보던 LLM에게 15분봉과 1시간봉을 같이 넘기도록 바꿨다. 단기 흐름과 장기 흐름을 동시에 파악해서 더 입체적인 판단이 가능해진다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;const [candles1m, candles15m, candles1h] = await Promise.all([
  this.upbit.getCandles(market, 1, 30),
  this.upbit.getCandles(market, 15, 24),
  this.upbit.getCandles(market, 60, 24),
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 프롬프트에 이렇게 전달된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[1분봉] RSI(14): 28.3 &amp;rarr; 과매도 | 골든크로스 | 거래량 급증
[15분봉] RSI(14): 45.1 &amp;rarr; 중립 | MA5 &amp;gt; MA20 | 거래량 보통
[1시간봉] RSI(14): 62.4 &amp;rarr; 중립 | 데드크로스 | 거래량 보통
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;api.alternative.me 무료 API를 활용해서 시장 전체 심리를 프롬프트에 반영했다. 별도 API 키가 필요 없다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;private async getFearGreedIndex(): Promise {
  const res = await axios.get('&amp;lt;https://api.alternative.me/fng/?limit=1&amp;gt;');
  const { value, value_classification } = res.data.data[0];
  const map: Record&amp;lt;string, string=&quot;&quot;&amp;gt; = {
    'Extreme Fear': '극도의 공포 (강한 매수 고려)',
    'Fear': '공포 (신중한 매수 고려)',
    'Neutral': '중립',
    'Greed': '탐욕 (매도 고려)',
    'Extreme Greed': '극도의 탐욕 (강한 매도 고려)',
  };
  return `${value} (${map[value_classification] ?? value_classification})`;
}
&amp;lt;/string,&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트에는 이렇게 들어간다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;시장 공포/탐욕 지수: 42 (공포 &amp;mdash; 신중한 매수 고려)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;497&quot; data-origin-height=&quot;239&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ba7YER/dJMcaf6HUg3/1QfbU7sXNfDBGTtOLEGUn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ba7YER/dJMcaf6HUg3/1QfbU7sXNfDBGTtOLEGUn0/img.png&quot; data-alt=&quot;https://api.alternative.me/fng/?limit=1 의 내용이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ba7YER/dJMcaf6HUg3/1QfbU7sXNfDBGTtOLEGUn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fba7YER%2FdJMcaf6HUg3%2F1QfbU7sXNfDBGTtOLEGUn0%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;497&quot; height=&quot;239&quot; data-origin-width=&quot;497&quot; data-origin-height=&quot;239&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://api.alternative.me/fng/?limit=1 의 내용이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Chain of Thought&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM이 결론을 바로 뱉지 않고 단계적으로 분석하도록 프롬프트를 구성했다. 추론 능력이 확실히 올라간다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;결론을 내기 전에 다음 순서로 분석해라:
1. 현재 가격의 지지선과 저항선은 어디인가?
2. 각 타임프레임의 신호가 일치하는가, 엇갈리는가?
3. 시장 심리(공포/탐욕 지수)가 현재 포지션에 유리한가?
4. 위 분석을 종합해서 최종 판단을 내려라.

반드시 JSON으로만 응답해라:
{&quot;signal&quot;: &quot;BUY|SELL|HOLD&quot;, &quot;reason&quot;: &quot;판단 근거&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 봇에 실제로 쓸 수 있는 기능들을 붙였다. 텔레그램으로 언제 어디서든 봇을 제어할 수 있게 됐고, MongoDB로 매매 기록이 영구적으로 남는다. 서버가 꺼졌다 켜져도 포지션을 이어서 유지하고, 멀티 타임프레임과 공포/탐욕 지수로 LLM이 더 많은 정보를 보고 판단하게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 편에서는 RSI 추가 지표, ATR 변동성 필터, 모델 업그레이드(llama3.2 &amp;rarr; qwen2.5:14b) 과정을 다룰 예정이다.&lt;/p&gt;</description>
      <category>Project/Coiner</category>
      <category>docker</category>
      <category>MongoDB</category>
      <category>Ollama</category>
      <category>공포/탐욕지수</category>
      <category>분산투자</category>
      <category>코인자동매매</category>
      <category>텔레그램API</category>
      <category>텔레그램봇</category>
      <category>트레이딩봇</category>
      <category>프롬프트엔지니어링</category>
      <author>Dwony</author>
      <guid isPermaLink="true">https://developer-dwon.tistory.com/24</guid>
      <comments>https://developer-dwon.tistory.com/entry/%EC%BD%94%EC%9D%B8-%EC%9E%90%EB%8F%99%EB%A7%A4%EB%A7%A4-%EB%B4%87-%EC%BD%94%EC%9D%B4%EB%84%88-%E2%80%94-%ED%85%94%EB%A0%88%EA%B7%B8%EB%9E%A8-%EC%97%B0%EB%8F%99-MongoDB-%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8-%EA%B3%A0%EB%8F%84%ED%99%94#entry24comment</comments>
      <pubDate>Mon, 23 Feb 2026 20:02:03 +0900</pubDate>
    </item>
    <item>
      <title>코인 자동매매 봇 (코이너) &amp;mdash; 프로젝트 세팅과 첫 실행</title>
      <link>https://developer-dwon.tistory.com/entry/%EC%BD%94%EC%9D%B8-%EC%9E%90%EB%8F%99%EB%A7%A4%EB%A7%A4-%EB%B4%87-%EC%BD%94%EC%9D%B4%EB%84%88-%E2%80%94-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%84%B8%ED%8C%85%EA%B3%BC-%EC%B2%AB-%EC%8B%A4%ED%96%89</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FcRPM/dJMcabDfSQC/BkKkcc7H5I5Swn89nPtzT1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FcRPM/dJMcabDfSQC/BkKkcc7H5I5Swn89nPtzT1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FcRPM/dJMcabDfSQC/BkKkcc7H5I5Swn89nPtzT1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFcRPM%2FdJMcabDfSQC%2FBkKkcc7H5I5Swn89nPtzT1%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;352&quot; height=&quot;352&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;바이브코딩으로 코인 자동매매 프로그램을 만들어봤다. Claude Code와 대화하면서 NestJS 기반의 봇을 처음부터 구축한 과정을 기록한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시리즈는 여러편으로 나눠서 작성할 예정이다. 이번 편에서는 프로젝트 초기 세팅부터 봇이 처음 실행되기까지의 과정을 다룬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 COINER는 내가 지은 이름이다. 나노바나나한테 부탁했더니 저런 이미지를 가져왔다..&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;목표 설정&lt;/h1&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;b&gt;로컬 LLM&lt;/b&gt;으로 매매 판단&lt;/li&gt;
&lt;li&gt;코드를 직접 짜지 않는 &lt;b&gt;바이브코딩&lt;/b&gt; 방식으로 개발&lt;/li&gt;
&lt;li&gt;목표 수익률 달성 시 &lt;b&gt;자동 중단&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code에 이 조건을 전달하자 5단계 개발 로드맵이 나왔다. 거래소는 Upbit, LLM은 이미 설치되어 있던 Ollama, 전략은 매 5분봉마다 LLM이 판단하는 방식으로 결정했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;기술 스택&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;NestJS&lt;/b&gt; &amp;mdash; 서버 프레임워크&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Upbit REST API&lt;/b&gt; &amp;mdash; 캔들 조회, 매수/매도&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Ollama&lt;/b&gt; &amp;mdash; 로컬 LLM 실행 (llama3.2)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@nestjs/schedule&lt;/b&gt; &amp;mdash; Cron 기반 자동 실행&lt;/li&gt;
&lt;li&gt;&lt;b&gt;jsonwebtoken, uuid&lt;/b&gt; &amp;mdash; Upbit JWT 인증&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;패키지 설치 중 권한 문제&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패키지를 설치하려고 했더니 바로 에러가 났다.&lt;/p&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;Error: EACCES: permission denied, open 'package-lock.json'
errno: -13,
code: 'EACCES',
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;node_modules와 package-lock.json이 root 소유로 되어 있어서 npm이 파일을 열지 못하는 문제였다. 아래 명령어로 소유권을 바꿔줬다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;sudo chown -R dwon /Users/dwon/Desktop/PROJECT/coin-bot/node_modules
sudo chown dwon /Users/dwon/Desktop/PROJECT/coin-bot/package-lock.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후에는 패키지를 직접 설치하는 방식으로 진행했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;npm install @nestjs/config @nestjs/schedule axios jsonwebtoken uuid
npm install -D @types/jsonwebtoken @types/uuid
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;프로젝트 구조&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모듈은 크게 세 가지로 나눴다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;src/
├── upbit/
│   ├── upbit.service.ts   # 캔들 조회, 매수/매도, 잔고 조회
│   └── upbit.module.ts
├── ollama/
│   ├── ollama.service.ts  # 캔들 &amp;rarr; 프롬프트 &amp;rarr; BUY/SELL/HOLD
│   └── ollama.module.ts
├── trading/
│   ├── trading.service.ts # 5분봉마다 실행, 목표/손절 체크
│   ├── trading.controller.ts
│   └── trading.module.ts
├── app.module.ts
└── main.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Upbit는 거래소와의 통신만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ollama는 LLM 판단만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trading은 전체 매매 흐름 조율만 담당하도록 역할을 분리했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;UpbitService 구현&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Upbit API는 JWT 인증이 필요하다. 주문 API를 호출할 때 쿼리 파라미터를 SHA512로 해시해서 JWT payload에 담아 보내야 한다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;private generateToken(query?: string): string {
  const payload: TokenPayload = {
    access_key: this.accessKey,
    nonce: uuidv4(),
  };

  if (query) {
    const hash = crypto.createHash('sha512');
    hash.update(query, 'utf8');
    payload.query_hash = hash.digest('hex');
    payload.query_hash_alg = 'SHA512';
  }

  return jwt.sign(payload as object, this.secretKey);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캔들 조회는 인증이 필요 없는 단순 GET 요청이지만, 매수/매도 주문은 반드시 이 토큰이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시장가 매수와 매도는 파라미터 방식이 다르다는 점을 주의해야 한다. 매수는 ord_type: 'price'에 원화 금액을, 매도는 ord_type: 'market'에 코인 수량을 넣는다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 시장가 매수 &amp;mdash; 원화 금액 기준
async buyMarketOrder(market: string, priceKrw: number): Promise&amp;lt;void&amp;gt; {
  const params = {
    market,
    side: 'bid',
    price: String(priceKrw),
    ord_type: 'price',
  };
  const query = querystring.stringify(params);
  const token = this.generateToken(query);
  await axios.post(`${this.baseUrl}/orders`, params, {
    headers: { Authorization: `Bearer ${token}` },
  });
}

// 시장가 매도 &amp;mdash; 코인 수량 기준
async sellMarketOrder(market: string, volume: number): Promise&amp;lt;void&amp;gt; {
  const params = {
    market,
    side: 'ask',
    volume: String(volume),
    ord_type: 'market',
  };
  // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;912&quot; data-origin-height=&quot;481&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmpgU5/dJMcacIWiqb/LHrXL0COpUe5vCB0BQrTzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmpgU5/dJMcacIWiqb/LHrXL0COpUe5vCB0BQrTzk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmpgU5/dJMcacIWiqb/LHrXL0COpUe5vCB0BQrTzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmpgU5%2FdJMcacIWiqb%2FLHrXL0COpUe5vCB0BQrTzk%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;912&quot; height=&quot;481&quot; data-origin-width=&quot;912&quot; data-origin-height=&quot;481&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://upbit.com/home&quot;&gt;업비트 사이트&lt;/a&gt;에 들어가서 [마이페이지] &amp;gt; [Open API 관리] 페이지에 접속해서 API Key를 발급받을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 모든 기능을 전부 쓸것이므로 전부 체크해주었다. 내 고유 아이피를 업비트에 화이트리스트로 등록해한다. &lt;a href=&quot;https://ip.pe.kr/&quot;&gt;여기&lt;/a&gt;를 참고하자&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;OllamaService 구현&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ollama는 로컬에서 실행 중인 LLM에 HTTP로 요청을 보내는 방식이다. http://localhost:11434/api/generate 엔드포인트에 프롬프트를 전달하면 JSON으로 응답이 온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;b&gt;프롬프트 설계&lt;/b&gt;다. 캔들 원시 데이터를 그대로 던지는 게 아니라, RSI / 이동평균선 / 거래량 지표를 미리 계산해서 LLM이 해석하기 쉬운 문장으로 변환해서 전달한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;private calcRSI(candles: Candle[], period = 14): number {
  if (candles.length &amp;lt; period + 1) return 50;
  const closes = candles
    .slice(0, period + 1)
    .reverse()
    .map((c) =&amp;gt; c.trade_price);
  let gains = 0, losses = 0;
  for (let i = 1; i &amp;lt; closes.length; i++) {
    const diff = closes[i] - closes[i - 1];
    if (diff &amp;gt; 0) gains += diff;
    else losses += Math.abs(diff);
  }
  const avgGain = gains / period;
  const avgLoss = losses / period;
  if (avgLoss === 0) return 100;
  return 100 - 100 / (1 + avgGain / avgLoss);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수치를 계산한 뒤에는 사람이 읽을 수 있는 문장으로 바꿔서 프롬프트에 담는다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;private indDesc(ind: Indicators): string {
  const rsiDesc =
    ind.rsi &amp;gt;= 70 ? '&amp;rarr; 과매수 (매도 고려)'
    : ind.rsi &amp;lt;= 30 ? '&amp;rarr; 과매도 (매수 고려)'
    : '&amp;rarr; 중립';

  return `RSI(14): ${ind.rsi.toFixed(1)} ${rsiDesc} | MA: ${maDesc} | 거래량: ${volDesc}`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM에게는 반드시 JSON만 반환하도록 지시했다. 자유로운 텍스트가 섞이면 파싱이 깨지기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;async analyze(market: string, candles1m: Candle[], ...): Promise&amp;lt;LlmDecision&amp;gt; {
  const res = await axios.post(
    `${this.baseUrl}/api/generate`,
    {
      model: this.model,
      prompt,
      stream: false,
      format: 'json',
      options: { num_predict: 200 },
      keep_alive: '30m',
    },
    { timeout: 90000 },
  );
  const parsed = JSON.parse(res.data.response?.trim() ?? '{}');
  const signal = ['BUY', 'SELL', 'HOLD'].includes(parsed.signal ?? '')
    ? parsed.signal
    : 'HOLD';
  return { signal, reason: parsed.reason ?? '' };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;keep_alive: '30m'을 설정한 이유가 있다. Ollama는 기본적으로 5분 동안 요청이 없으면 모델을 메모리에서 내린다. 5분봉 봇이니까 딱 경계에 걸리는 상황이었고, 모델이 언로드됐다가 재로드될 때 15~20초가 걸려서 타임아웃이 발생했다. 30분으로 늘리고 나서 해결됐다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;926&quot; data-origin-height=&quot;110&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FpmZP/dJMcacPG4Wt/kaTy43b8BsSaAwVfffx6sK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FpmZP/dJMcacPG4Wt/kaTy43b8BsSaAwVfffx6sK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FpmZP/dJMcacPG4Wt/kaTy43b8BsSaAwVfffx6sK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFpmZP%2FdJMcacPG4Wt%2FkaTy43b8BsSaAwVfffx6sK%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;926&quot; height=&quot;110&quot; data-origin-width=&quot;926&quot; data-origin-height=&quot;110&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;TradingService &amp;mdash; 봇의 두뇌&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Cron('0 */5 * * * *')으로 매 5분 0초마다 실행된다. 흐름은 단순하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;보유 포지션이 있으면 각각 손절/목표 체크 후 LLM 판단&lt;/li&gt;
&lt;li&gt;빈 슬롯이 있으면 새 종목 탐색 후 매수&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Cron('0 */5 * * * *')
async onCandle() {
  if (!this.running) return;

  if (this.positions.length &amp;gt; 0) {
    await Promise.all(
      this.positions.map((pos) =&amp;gt; this.handlePosition(pos)),
    );
  }

  const slots = this.maxPositions - this.positions.length;
  if (slots &amp;gt; 0) {
    await this.fillPositions(slots);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포지션 처리 우선순위도 명확하게 설계했다. 손절이 가장 먼저고, 그 다음 최대 목표 초과 시 강제 매도, 마지막으로 LLM 판단이 개입한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;private async handlePosition(pos: Position) {
  // 1. 손절 체크 (최우선)
  if (pos.profitPercent &amp;lt;= -this.stopLossPercent) {
    await this.executeClose(pos, '손절 실행');
    return;
  }

  // 2. 최대 목표 초과 시 강제 매도
  if (pos.profitPercent &amp;gt;= this.goalProfitMax) {
    await this.executeClose(pos, `최대 목표 수익 달성 (${pos.profitPercent}%)`);
    return;
  }

  // 3. LLM 판단 (최소 목표 이상일 때만 SELL 허용)
  const decision = await this.ollama.analyze(...);
  if (decision.signal === 'SELL' &amp;amp;&amp;amp; pos.profitPercent &amp;gt;= this.goalProfitMin) {
    await this.executeClose(pos, 'LLM 매도 신호');
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;REST API &amp;mdash; TradingController&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;봇 제어는 간단한 REST API로 노출했다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Controller('bot')
export class TradingController {
  @Post('start')
  start(@Body() body: { goalProfitMin?: number; goalProfitMax?: number; ... }) {
    this.trading.start(body);
    return { message: '봇 시작됨', ...this.trading.getStatus() };
  }

  @Post('stop')
  stop() {
    this.trading.stop('수동 중단');
    return { message: '봇 중단됨', ...this.trading.getStatus() };
  }

  @Get('status')
  status() {
    return this.trading.getStatus();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본값은 .env에서 읽어오고, 요청 body로 오버라이드할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;POST &amp;lt;http://localhost:1012/bot/start&amp;gt;
POST &amp;lt;http://localhost:1012/bot/start&amp;gt;  Body: { &quot;goalProfitMin&quot;: 3, &quot;goalProfitMax&quot;: 7 }
GET  &amp;lt;http://localhost:1012/bot/status&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;209&quot; data-origin-height=&quot;167&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Y0OsF/dJMcafr6eiJ/dRmIZXKA2kaQxgXm3c3dn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Y0OsF/dJMcafr6eiJ/dRmIZXKA2kaQxgXm3c3dn0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Y0OsF/dJMcafr6eiJ/dRmIZXKA2kaQxgXm3c3dn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FY0OsF%2FdJMcafr6eiJ%2FdRmIZXKA2kaQxgXm3c3dn0%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;209&quot; height=&quot;167&quot; data-origin-width=&quot;209&quot; data-origin-height=&quot;167&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;bot/start 를 호출하면 JSON 데이터로 잘 리턴되는 것을 확인했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;.env 구성&lt;/h1&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;UPBIT_ACCESS_KEY=발급받은_키
UPBIT_SECRET_KEY=발급받은_키

TRADE_CHECKING_PERIOD=1
TRADE_AMOUNT_KRW=30000
TRADE_MAX_POSITIONS=3
GOAL_PROFIT_PERCENT=3
GOAL_STOP_LOSS_PERCENT=5

OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama3.2

PORT=1012&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.env는 .gitignore에 포함되어 있는지 반드시 확인해야 한다. API 키가 GitHub에 올라가면 바로 악용될 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;첫 실행&lt;/h1&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;# Ollama 모델 다운로드
ollama pull llama3.2

# 서버 실행
npm run start:dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타입 오류 없이 서버가 올라왔다. Postman에서 POST /bot/start를 보내면 봇이 시작되고, 다음 5분 0초에 첫 판단이 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;업비트에 잔고가 없어도 실행은 된다. BUY 신호가 와도 주문하지 않고 로그만 출력하기 때문에 실제 돈을 넣기 전에 LLM이 어떤 판단을 내리는지 먼저 모니터링해볼 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;689&quot; data-origin-height=&quot;274&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxxP55/dJMcahpVOlK/gV8BquvLNVwK3jEIlKnHKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxxP55/dJMcahpVOlK/gV8BquvLNVwK3jEIlKnHKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxxP55/dJMcahpVOlK/gV8BquvLNVwK3jEIlKnHKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxxP55%2FdJMcahpVOlK%2FgV8BquvLNVwK3jEIlKnHKk%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;689&quot; height=&quot;274&quot; data-origin-width=&quot;689&quot; data-origin-height=&quot;274&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잘 시작된다!&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;정리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번편에서는 NestJS 모듈 구조 설계, Upbit JWT 인증, Ollama HTTP 연동, Cron 기반 자동 실행까지 기본 뼈대를 완성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음편에서는 텔레그램 봇 연동으로 스마트폰에서 봇을 제어하고, MongoDB로 매매 이력을 저장하고, LLM 프롬프트를 고도화하는 과정을 다룰 예정이다.&lt;/p&gt;</description>
      <category>Project/Coiner</category>
      <category>nestjs</category>
      <category>Ollama</category>
      <category>Schedule</category>
      <category>로컬llm</category>
      <category>바이브코딩</category>
      <category>업비트API</category>
      <category>코인자동매매</category>
      <category>클로드</category>
      <category>투자</category>
      <category>트레이딩봇</category>
      <author>Dwony</author>
      <guid isPermaLink="true">https://developer-dwon.tistory.com/23</guid>
      <comments>https://developer-dwon.tistory.com/entry/%EC%BD%94%EC%9D%B8-%EC%9E%90%EB%8F%99%EB%A7%A4%EB%A7%A4-%EB%B4%87-%EC%BD%94%EC%9D%B4%EB%84%88-%E2%80%94-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%84%B8%ED%8C%85%EA%B3%BC-%EC%B2%AB-%EC%8B%A4%ED%96%89#entry23comment</comments>
      <pubDate>Mon, 23 Feb 2026 19:16:58 +0900</pubDate>
    </item>
    <item>
      <title>최댓값과 최솟값</title>
      <link>https://developer-dwon.tistory.com/entry/%EC%B5%9C%EB%8C%93%EA%B0%92%EA%B3%BC-%EC%B5%9C%EC%86%9F%EA%B0%92</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;728&quot; data-origin-height=&quot;463&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eCq4g3/dJMcagj3SRb/O5tizyi5kZE96b4b13ro4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eCq4g3/dJMcagj3SRb/O5tizyi5kZE96b4b13ro4k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eCq4g3/dJMcagj3SRb/O5tizyi5kZE96b4b13ro4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeCq4g3%2FdJMcagj3SRb%2FO5tizyi5kZE96b4b13ro4k%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;728&quot; height=&quot;463&quot; data-origin-width=&quot;728&quot; data-origin-height=&quot;463&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/12939?language=kotlin&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://school.programmers.co.kr/learn/courses/30/lessons/12939?language=kotlin&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770040373639&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;프로그래머스&quot; data-og-description=&quot;SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프&quot; data-og-host=&quot;programmers.co.kr&quot; data-og-source-url=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/12939?language=kotlin&quot; data-og-url=&quot;https://programmers.co.kr/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cTt1bH/dJMb9b3NerG/ynGwJctm8Pn9uuYgkEUwb1/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960,https://scrap.kakaocdn.net/dn/7VHHA/dJMb9dHjaKM/PizA7d3K08q8tHsF33ZKjk/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960&quot;&gt;&lt;a href=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/12939?language=kotlin&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/12939?language=kotlin&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cTt1bH/dJMb9b3NerG/ynGwJctm8Pn9uuYgkEUwb1/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960,https://scrap.kakaocdn.net/dn/7VHHA/dJMb9dHjaKM/PizA7d3K08q8tHsF33ZKjk/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;프로그래머스&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;programmers.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Lv2&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;문자열에서 최댓값과 최솟값을 구하는 문제&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;이게 왜 레벨2??&lt;/s&gt;&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1770040448924&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class Solution {
    fun solution(s: String): String {
        val numbers = s.split(&quot; &quot;).map { it.toInt() }
        return &quot;${numbers.minOrNull() ?: 0} ${numbers.maxOrNull() ?: 0}&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;풀이&lt;/b&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;코틀린 1.4 이후 버전의 Null 안정성을 기대하면서 minOrNull / maxOrNull을 써봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 진짜 js랑 비슷하다.&lt;/p&gt;</description>
      <category>Algorithm/머리식히기</category>
      <category>Kotlin</category>
      <category>map</category>
      <category>Null안정성</category>
      <category>split</category>
      <category>알고리즘</category>
      <category>최대최소값</category>
      <category>코틀린</category>
      <category>프로그래머스</category>
      <author>Dwony</author>
      <guid isPermaLink="true">https://developer-dwon.tistory.com/21</guid>
      <comments>https://developer-dwon.tistory.com/entry/%EC%B5%9C%EB%8C%93%EA%B0%92%EA%B3%BC-%EC%B5%9C%EC%86%9F%EA%B0%92#entry21comment</comments>
      <pubDate>Mon, 2 Feb 2026 22:56:21 +0900</pubDate>
    </item>
    <item>
      <title>HashMap의 내부 동작 원리와 시간 복잡도</title>
      <link>https://developer-dwon.tistory.com/entry/HashMap%EC%9D%98-%EB%82%B4%EB%B6%80-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%8B%9C%EA%B0%84-%EB%B3%B5%EC%9E%A1%EB%8F%84</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;hashmap_simple.png&quot; data-origin-width=&quot;3570&quot; data-origin-height=&quot;2970&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1BZ2E/dJMcaihSAKd/cVw7j3IFXEGBo6oSMF7dlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1BZ2E/dJMcaihSAKd/cVw7j3IFXEGBo6oSMF7dlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1BZ2E/dJMcaihSAKd/cVw7j3IFXEGBo6oSMF7dlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1BZ2E%2FdJMcaihSAKd%2FcVw7j3IFXEGBo6oSMF7dlK%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;3570&quot; height=&quot;2970&quot; data-filename=&quot;hashmap_simple.png&quot; data-origin-width=&quot;3570&quot; data-origin-height=&quot;2970&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;HashMap이 뭔지 확실히 알았다&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BST를 공부하다가 HashMap을 비교하는 질문이 나와서 공부하게 됐다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Key-Value 저장 방식&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전화번호부를 떠올려보면 이해하기 쉽다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;이름(Key)  &amp;rarr; 전화번호(Value)
&quot;철수&quot;     &amp;rarr; &quot;010-1234-5678&quot;
&quot;영희&quot;     &amp;rarr; &quot;010-9876-5432&quot;
&quot;민수&quot;     &amp;rarr; &quot;010-5555-6666&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사물함에 비유해봤다:&lt;/b&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;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;해시 함수라는 게 핵심이었다&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해시 함수의 역할을 이해했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;Key를 숫자로 바꾸는 마법&quot;&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;Key        해시 함수       숫자(인덱스)
&quot;철수&quot;   ─────────&amp;rarr;        3
&quot;영희&quot;   ─────────&amp;rarr;        7
&quot;민수&quot;   ─────────&amp;rarr;        1
&lt;/code&gt;&lt;/pre&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;HashMap은 내부에 배열을 가지고 있다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;인덱스    저장된 데이터
[0]       (비어있음)
[1]       &quot;민수&quot; &amp;rarr; &quot;010-5555-6666&quot;
[2]       (비어있음)
[3]       &quot;철수&quot; &amp;rarr; &quot;010-1234-5678&quot;
[4]       (비어있음)
[5]       (비어있음)
[6]       (비어있음)
[7]       &quot;영희&quot; &amp;rarr; &quot;010-9876-5432&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열이니까 인덱스로 바로 접근할 수 있다!&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;데이터를 저장하는 과정을 따라해봤다&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;map.put(&quot;철수&quot;, &quot;010-1234-5678&quot;) 하면?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단계별로 확인해봤다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계: 해시 값 계산&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;int hash = hashCode(&quot;철수&quot;);  // 예: 12345
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계: 배열 인덱스 계산&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;int index = hash % 배열크기;  // 12345 % 16 = 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3단계: 해당 인덱스에 저장&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;[3] &amp;larr; &quot;철수&quot; &amp;rarr; &quot;010-1234-5678&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;흐름을 그려봤다&lt;/h2&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;&quot;철수&quot; 입력
    &amp;darr;
hashCode() 함수
    &amp;darr;
12345 (해시값)
    &amp;darr;
12345 % 16 = 3 (인덱스)
    &amp;darr;
[0] [1] [2] [3] [4] ...
             &amp;uarr;
        여기 저장!
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하구나. 해시 계산 &amp;rarr; 인덱스 계산 &amp;rarr; 저장!&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;데이터를 찾는 과정도 해봤다&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;map.get(&quot;철수&quot;) 하면?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계: 똑같이 해시 값 계산&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;int hash = hashCode(&quot;철수&quot;);  // 12345
int index = hash % 16;        // 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계: 해당 인덱스에서 바로 가져오기&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;[3]에 가서 값 가져옴 &amp;rarr; &quot;010-1234-5678&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;배열이니까 인덱스로 바로 접근!&lt;/b&gt; 엄청 빠르다. O(1)!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이래서 HashMap이 빠르구나 이해했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;문제 상황을 발견했다 - 충돌&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해시값이 같을 수 있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 &quot;철수&quot;와 &quot;영수&quot;의 해시값이 같은 인덱스를 가리킨다면?&lt;/p&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;&quot;철수&quot; &amp;rarr; hash &amp;rarr; 12345 % 16 = 3
&quot;영수&quot; &amp;rarr; hash &amp;rarr; 51234 % 16 = 3  &amp;larr; 똑같은 3번!
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘 다 [3]에 저장하려고 하네?&lt;/b&gt; 이게 **충돌(Collision)**이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법 1: Chaining&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java HashMap은 같은 인덱스에 &lt;b&gt;연결 리스트로 연결&lt;/b&gt;한다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;[3] &amp;rarr; [&quot;철수&quot;:&quot;010-1234&quot;] &amp;rarr; [&quot;영수&quot;:&quot;010-9999&quot;]
      &amp;uarr;첫번째              &amp;uarr;두번째
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시각적으로 보면:&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;배열 인덱스
[0]  &amp;rarr;  null
[1]  &amp;rarr;  null
[2]  &amp;rarr;  null
[3]  &amp;rarr;  ●──&amp;rarr;●──&amp;rarr;null
        │    │
     &quot;철수&quot; &quot;영수&quot;
[4]  &amp;rarr;  null
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 충돌을 해결할 수 있겠구나.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법 2: 트리로 변환 (Java 8+)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 걸 배웠다. &lt;b&gt;리스트가 8개 이상 차면 트리로 바꾼다!&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;리스트 길이 &amp;lt; 8개:  연결 리스트
리스트 길이 &amp;ge; 8개:  Red-Black 트리로 변환
&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;리스트 탐색: O(n) - 느림&lt;/li&gt;
&lt;li&gt;트리 탐색: O(log n) - 빠름&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시각화:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;Before (리스트):              After (트리):
[3] &amp;rarr; ●&amp;rarr;●&amp;rarr;●&amp;rarr;●&amp;rarr;●&amp;rarr;●&amp;rarr;●&amp;rarr;●        [3] &amp;rarr;     ●
     8개 이상...                       /  \
     O(n)                           ●     ●
                                   / \   / \
                                  ●  ●   ●  ●
                                 O(log n)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BST 공부한 게 여기서 도움이 되네!&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;용량이 부족하면 자동 확장된다&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Load Factor라는 개념&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashMap은 &lt;b&gt;너무 꽉 차면 크기를 늘린다&lt;/b&gt;.&lt;/p&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;Load Factor = 저장된 데이터 / 배열 크기
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기본값: 0.75 (75%)&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;확장 과정을 따라해봤다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;초기 상태 (크기: 16)&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[0] [1] [2] [3] [4] ... [15]
 ●   ●       ●   ●      ●
 
총 12개 저장됨
12 / 16 = 0.75  &amp;larr; 75%! 임계점 도달!
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;확장 발동!&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 새로운 배열 생성 (크기 2배)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;기존: 16 &amp;rarr; 새로운: 32
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 모든 데이터를 새 배열로 재배치 (rehashing)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;기존 [3]의 &quot;철수&quot;
&amp;rarr; 새로운 해시 계산
&amp;rarr; 12345 % 32 = 13
&amp;rarr; 새 배열의 [13]에 저장
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주의할 점:&lt;/b&gt; 이 과정은 모든 데이터를 다시 배치하기 때문에 &lt;b&gt;느리다!&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;시간 복잡도를 정리했다&lt;/h1&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;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;put&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;해시 계산 &amp;rarr; 인덱스 접근 &amp;rarr; 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;get&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;해시 계산 &amp;rarr; 인덱스 접근 &amp;rarr; 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;remove&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;해시 계산 &amp;rarr; 인덱스 접근 &amp;rarr; 삭제&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;평균 O(1)! 엄청 빠르다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최악의 경우 (모두 한 곳에 충돌)&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[0]  &amp;rarr;  null
[1]  &amp;rarr;  null
[2]  &amp;rarr;  null
[3]  &amp;rarr;  ●&amp;rarr;●&amp;rarr;●&amp;rarr;●&amp;rarr;●&amp;rarr;●&amp;rarr;●&amp;rarr;●
        모든 데이터가 여기 모임!
[4]  &amp;rarr;  null
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Java 7 이하:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;리스트만 사용 &amp;rarr; &lt;b&gt;O(n)&lt;/b&gt; (느림)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Java 8 이상:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;8개 이상이면 트리로 변환 &amp;rarr; &lt;b&gt;O(log n)&lt;/b&gt; (괜찮음)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;평균: O(1)
최악(Java 8+): O(log n)
최악(Java 7): O(n)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;BST와 비교해봤다&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HashMap vs TreeMap&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특징 HashMap TreeMap&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;자료구조&lt;/td&gt;
&lt;td&gt;해시 테이블&lt;/td&gt;
&lt;td&gt;Red-Black 트리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;시간 복잡도&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;순서&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;Key 정렬&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;null Key&lt;/td&gt;
&lt;td&gt;허용&lt;/td&gt;
&lt;td&gt;불허&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사용 케이스&lt;/td&gt;
&lt;td&gt;빠른 조회&lt;/td&gt;
&lt;td&gt;정렬/범위 검색&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;언제 뭘 쓸까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HashMap을 쓰는 경우:&lt;/b&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;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TreeMap을 쓰는 경우:&lt;/b&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;범위 검색이 필요할 때 (예: 1000원~5000원 상품)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;실제로 사용해봤다&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Java로 테스트&lt;/h2&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;HashMap&amp;lt;String, String&amp;gt; map = new HashMap&amp;lt;&amp;gt;();
map.put(&quot;철수&quot;, &quot;010-1234-5678&quot;);
map.put(&quot;영희&quot;, &quot;010-9876-5432&quot;);

System.out.println(map.get(&quot;철수&quot;));  // &quot;010-1234-5678&quot;
&lt;/code&gt;&lt;/pre&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;1000개를 저장할 거라면?&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;예상 데이터: 1000개
초기 용량: 1000 / 0.75 = 1333 &amp;rarr; 1500으로 설정
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;HashMap&amp;lt;Integer, String&amp;gt; map = new HashMap&amp;lt;&amp;gt;(1500);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 rehashing 횟수를 줄일 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;핵심 요약&lt;/h1&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;b&gt;HashMap = 배열 + 해시 함수 + 충돌 처리&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;평균 O(1)의 빠른 성능&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;충돌 발생 시: 리스트 &amp;rarr; 8개 이상이면 트리&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;75% 차면 자동으로 2배 확장&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;빠른 조회가 필요하면 HashMap, 정렬이 필요하면 TreeMap&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;동작 흐름 정리&lt;/h2&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;Key 입력
   &amp;darr;
해시 함수 (Key &amp;rarr; 숫자)
   &amp;darr;
배열 인덱스 계산
   &amp;darr;
저장/조회 (O(1))
   &amp;darr;
충돌 발생? &amp;rarr; 리스트/트리로 해결
&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;&lt;b&gt;장점:&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 평균 O(1)의 빠른 성능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ Key로 직접 접근 가능&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;b&gt;단점:&lt;/b&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;❌ 해시 충돌 발생 가능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;❌ Resize 시 성능 저하&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;❌ 메모리 오버헤드 (Load Factor로 인한 빈 공간)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;실무에서의 경험&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커머스 프로젝트에서 상품 정보를 빠르게 조회하는 기능을 만들 때 HashMap을 사용했다. 상품 ID를 Key로 하고 상품 객체를 Value로 저장해서 O(1)에 조회할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 가격 범위로 필터링하는 기능은 데이터베이스의 B-Tree 인덱스를 사용했다. HashMap으로는 범위 검색이 어려워서 그런 경우에는 BST 계열이 더 적합하다는 걸 배웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;면접에서 이런 실무 경험을 같이 설명하면 좋을 것 같다!&lt;/p&gt;</description>
      <category>Theory</category>
      <category>CS기초</category>
      <category>HashMap</category>
      <category>load factor</category>
      <category>TreeMap</category>
      <category>배열</category>
      <category>자료구조</category>
      <category>해시함수</category>
      <author>Dwony</author>
      <guid isPermaLink="true">https://developer-dwon.tistory.com/20</guid>
      <comments>https://developer-dwon.tistory.com/entry/HashMap%EC%9D%98-%EB%82%B4%EB%B6%80-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%8B%9C%EA%B0%84-%EB%B3%B5%EC%9E%A1%EB%8F%84#entry20comment</comments>
      <pubDate>Mon, 2 Feb 2026 22:42:17 +0900</pubDate>
    </item>
    <item>
      <title>이진 탐색 트리(BST)와 균형 이진 탐색 트리</title>
      <link>https://developer-dwon.tistory.com/entry/%EC%9D%B4%EC%A7%84-%ED%83%90%EC%83%89-%ED%8A%B8%EB%A6%ACBST%EC%99%80-%EA%B7%A0%ED%98%95-%EC%9D%B4%EC%A7%84-%ED%83%90%EC%83%89-%ED%8A%B8%EB%A6%AC</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;bst_example.png&quot; data-origin-width=&quot;3570&quot; data-origin-height=&quot;2390&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdMveK/dJMcacvdDiA/RqW3RhH425K01IIMvwpR30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdMveK/dJMcacvdDiA/RqW3RhH425K01IIMvwpR30/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdMveK/dJMcacvdDiA/RqW3RhH425K01IIMvwpR30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdMveK%2FdJMcacvdDiA%2FRqW3RhH425K01IIMvwpR30%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;3570&quot; height=&quot;2390&quot; data-filename=&quot;bst_example.png&quot; data-origin-width=&quot;3570&quot; data-origin-height=&quot;2390&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;트리(Tree)부터 이해하기&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;면접 준비하면서 BST를 공부하게 됐는데, 트리가 뭔지는 알고 있었지만 기초개념부터 정리했다.&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; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;      할아버지
     /      \
  아버지      삼촌
  /  \      /  \
나  동생   사촌1 사촌2&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;&lt;b&gt;루트(root)&lt;/b&gt;: 최상위 노드 (할아버지)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;부모(parent)&lt;/b&gt;: 바로 위 노드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자식(child)&lt;/b&gt;: 바로 아래 노드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;잎(leaf)&lt;/b&gt;: 자식이 없는 노드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;높이(height)&lt;/b&gt;: 루트에서 가장 먼 잎까지의 거리&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;b&gt;최대 2개의 자식&lt;/b&gt;만 가질 수 있다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;    10
   /  \
  5    15
 / \
3   7&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;이진 탐색 트리(BST) 공부하기&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;BST의 핵심 규칙을 알게 됐다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왼쪽 &amp;lt; 부모 &amp;lt; 오른쪽&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 규칙만 지키면 이진 탐색 트리다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;    50
   /  \
 30    70
 /\    /\
20 40 60 80&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;왼쪽 자식이 자기보다 작고 (30 &amp;lt; 50)&lt;/li&gt;
&lt;li&gt;오른쪽 자식이 자기보다 크다 (70 &amp;gt; 50)&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;60을 찾는다고 가정해봤다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;    50  &amp;larr; 60 &amp;gt; 50이니까 오른쪽으로!
   /  \
 30    70  &amp;larr; 60 &amp;lt; 70이니까 왼쪽으로!
 /\    /\
20 40 60 80  &amp;larr; 찾았다!
     &amp;uarr;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3번만에 찾았다!&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 그냥 배열이었다면?&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[50, 30, 70, 20, 40, 60, 80]
 1   2   3   4   5   6번째!
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6번을 봐야 찾는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이래서 BST를 쓰는구나 싶었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;문제 상황을 발견했다&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1, 2, 3, 4, 5를 순서대로 넣어봤다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과정을 하나씩 따라가봤다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1 넣기&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2 넣기&lt;/b&gt; (2 &amp;gt; 1이니까 오른쪽)&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;1
 \
  2&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3 넣기&lt;/b&gt; (3 &amp;gt; 1, 3 &amp;gt; 2니까 계속 오른쪽)&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;1
 \
  2
   \
    3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4, 5까지 넣으니...&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;1
 \
  2
   \
    3
     \
      4
       \
        5&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 뭐지? &lt;b&gt;그냥 일렬로 늘어선 거랑 똑같잖아!&lt;/b&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;5를 찾으려면?&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;1 &amp;rarr; 확인 (아님)
 \
  2 &amp;rarr; 확인 (아님)
   \
    3 &amp;rarr; 확인 (아님)
     \
      4 &amp;rarr; 확인 (아님)
       \
        5 &amp;larr; 찾았다! (5번 확인)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5번을 다 봐야 한다.&lt;/b&gt; 트리의 장점이 완전히 사라졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 **Skewed Tree(치우친 트리)**라는 걸 알게 됐다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;해결책: 균형 트리&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;같은 데이터를 균형있게 배치하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;똑같은 1, 2, 3, 4, 5를 이렇게 배치할 수 있다:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;    3
   / \
  2   4
 /     \
1       5&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5를 찾으려면?&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;    3  &amp;larr; 5 &amp;gt; 3, 오른쪽!
   / \
  2   4  &amp;larr; 5 &amp;gt; 4, 오른쪽!
 /     \
1       5  &amp;larr; 찾았다! (3번 확인)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3번만에 찾는다!&lt;/b&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;1000개가 있다면?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;치우친 트리&lt;/b&gt;: 최악의 경우 1000번&lt;/li&gt;
&lt;li&gt;&lt;b&gt;균형 트리&lt;/b&gt;: 약 10번&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;100배 차이!&lt;/b&gt; 이래서 균형이 중요하구나.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;균형을 어떻게 맞출까? - 회전(Rotation)&lt;/h1&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;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;1
 \
  2
   \
    3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 &lt;b&gt;왼쪽 회전&lt;/b&gt;하면:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;  2
 / \
1   3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;균형잡힌 트리가 됐다!&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시각적으로 보면:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;Before:          After:
  1                2
   \              / \
    2     &amp;rarr;      1   3
     \
      3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2를 중심으로 왼쪽으로 돌린 것처럼 보인다.&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, 2, 3을 순서대로 넣으면서 균형 맞추기:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계: 1 넣기&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;균형: ✓&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계: 2 넣기&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;1
 \
  2&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;균형: ✓ (높이 차이 1까지는 괜찮음)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3단계: 3 넣기 &amp;rarr; 치우침 발견!&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;삽입 직후:
1
 \
  2
   \
    3  &amp;larr; 불균형!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉시 회전:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;회전 후:
  2
 / \
1   3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;균형: ✓&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 &lt;b&gt;삽입할 때마다 자동으로 균형을 맞춘다&lt;/b&gt;는 걸 이해했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;일반 BST vs 균형 BST 정리&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심을 깨달았다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;b&gt;형태의 차이&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 데이터 1, 2, 3, 4, 5를 넣어도:&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;일반 BST (최악):    균형 BST:
    1                   3
     \                 / \
      2               2   4
       \             /     \
        3           1       5
         \
          4        높이: 3
           \       탐색: O(log n)
            5
            
높이: 5
탐색: O(n)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시간 복잡도를 비교했다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;일반 BST&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연산 평균 최악&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;탐색&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;삽입&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;삭제&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최악&lt;/b&gt;: 한쪽으로 치우쳤을 때&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;균형 BST&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연산 평균 최악&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;탐색&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;삽입&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;삭제&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심&lt;/b&gt;: 최악의 경우에도 O(log n) 보장!&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;대표적인 균형 BST들&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 종류가 있다는 걸 알게 됐다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AVL 트리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 엄격하게 균형을 유지&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 노드에서 왼쪽-오른쪽 서브트리의 &lt;b&gt;높이 차이가 최대 1&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;탐색이 가장 빠르다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;삽입/삭제 시 회전이 많이 일어날 수 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Red-Black 트리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AVL보다 조금 느슨하지만 효율적&lt;/b&gt;이다.&lt;/p&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;모든 노드는 Red 또는 Black&lt;/li&gt;
&lt;li&gt;루트는 Black&lt;/li&gt;
&lt;li&gt;Red 노드의 자식은 반드시 Black&lt;/li&gt;
&lt;li&gt;모든 경로의 Black 노드 개수 동일&lt;/li&gt;
&lt;/ol&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;Java의 TreeMap&lt;/li&gt;
&lt;li&gt;C++의 map&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;삽입/삭제가 AVL보다 빠르다&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;탐색은 AVL보다 약간 느리다&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;B-Tree&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;데이터베이스 인덱스&lt;/b&gt;에서 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특징:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;한 노드에 &lt;b&gt;여러 개의 키&lt;/b&gt; 저장&lt;/li&gt;
&lt;li&gt;트리 높이가 낮다&lt;/li&gt;
&lt;li&gt;디스크 I/O를 최소화한다&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;일반 BST:            B-Tree:
  50                [30, 50, 70]
 /  \              /    |   |   \
30  70         [10,20] [40] [60] [80,90]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B-Tree가 한 번에 더 많은 데이터를 읽을 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;실무에서는 어떻게 쓸까?&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;일반 BST는 거의 안 쓴다&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;최악의 경우 O(n)으로 너무 느리다&lt;/li&gt;
&lt;li&gt;성능을 예측할 수 없다&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;균형 BST를 쓰는 경우&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 정렬된 순서가 필요할 때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java로 해봤다:&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;TreeMap&amp;lt;Integer, String&amp;gt; map = new TreeMap&amp;lt;&amp;gt;();
map.put(3, &quot;three&quot;);
map.put(1, &quot;one&quot;);
map.put(2, &quot;two&quot;);
// 자동으로 1, 2, 3 순서로 정렬됨
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 범위 검색이 필요할 때&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// 1000원~5000원 사이 상품 찾기
map.subMap(1000, 5000);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 최대/최소를 빠르게 찾아야 할 때&lt;/h3&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;map.firstKey();  // 최소값
map.lastKey();   // 최대값
&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;커머스 시스템을 만들 때 상품 가격 범위로 필터링하는 기능이 있었다. 데이터베이스가 내부적으로 B-Tree 인덱스를 사용해서 빠르게 조회할 수 있었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;Trade-off를 생각해봤다&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;균형 BST의 장점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 최악의 경우에도 O(log n) 보장&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;✅ 범위 검색이 가능하다&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;균형 BST의 단점&lt;/h2&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;
&lt;p data-ke-size=&quot;size16&quot;&gt;❌ 추가 메모리가 필요하다 (색 정보, 높이 정보)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;❌ 단순 검색만 필요하면 HashMap이 더 빠르다 (O(1))&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;b&gt;라이브러리를 쓰자&lt;/b&gt;:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Java: TreeMap, TreeSet&lt;/li&gt;
&lt;li&gt;Python: sortedcontainers&lt;/li&gt;
&lt;li&gt;C++: map, set&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;핵심 요약&lt;/h1&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;b&gt;일반 BST vs 균형 BST = 형태 차이&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;치우친 형태 &amp;rarr; O(n), 균형 형태 &amp;rarr; O(log n)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;회전으로 즉시 균형 유지&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실무에서는 라이브러리 사용&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정렬/범위 검색이 필요하면 균형 BST, 단순 검색이면 HashMap&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>Theory</category>
      <category>AVL트리</category>
      <category>B-TREE</category>
      <category>BsT</category>
      <category>CS기초</category>
      <category>Red-Black트리</category>
      <category>Rotation</category>
      <category>균형BST</category>
      <category>복잡도</category>
      <category>이진탐색트리</category>
      <category>자료구조</category>
      <author>Dwony</author>
      <guid isPermaLink="true">https://developer-dwon.tistory.com/19</guid>
      <comments>https://developer-dwon.tistory.com/entry/%EC%9D%B4%EC%A7%84-%ED%83%90%EC%83%89-%ED%8A%B8%EB%A6%ACBST%EC%99%80-%EA%B7%A0%ED%98%95-%EC%9D%B4%EC%A7%84-%ED%83%90%EC%83%89-%ED%8A%B8%EB%A6%AC#entry19comment</comments>
      <pubDate>Mon, 2 Feb 2026 22:31:09 +0900</pubDate>
    </item>
  </channel>
</rss>