<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>복습 블로그</title>
    <link>https://boksup.tistory.com/</link>
    <description>Python, 데이터 분석, AWS 등 복습하기</description>
    <language>ko</language>
    <pubDate>Tue, 18 Aug 2026 13:22:51 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>woojc</managingEditor>
    <image>
      <title>복습 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/3503495/attach/0086f39c50654fa48b90890e95a80220</url>
      <link>https://boksup.tistory.com</link>
    </image>
    <item>
      <title>2025 KBO MVP 예측 (by. 머신러닝 with XAI)</title>
      <link>https://boksup.tistory.com/104</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;서론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;올해로 5년째 수행하는 토이 프로젝트, KBO 리그 MVP 맞히기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://boksup.tistory.com/95&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2024.10.12 - [데이터 분석/머신러닝] - 2024 KBO MVP를 선수 스탯을 통해 머신러닝으로 예측해보기&lt;/a&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;까먹어서 하지 않았던 2022년을 제외하고, 2021, 2023, 2024년 모두 MVP 예측에 성공하였다.&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;사실 작년의 경우 MVP 후보가 김도영으로 너무 압도적이어서 재미가 없었는데, 올해는 생각보다 강한 후보가 둘이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한화의 폰세와 삼성의 디아즈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폰세는 17승 1패 1.89의 평자책으로 투수로서 압도적인 성적을 거두며 다승, 평자책, 탈삼진, 승률 4관왕을 달성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디아즈는 전경기를 출전하며 외국인 선수 최초 50홈런 달성과 1.000이 넘는 OPS를 기록하며 홈런, 타점, OPS 3관왕을 달성했다.&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;둘 다 서로가 아니었다면 MVP를 충분히 받고도 남을 성적인데, 머신러닝 모델은 과연 누구를 더 높이 평가하였을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;예측 결과&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 45.6977%; height: 213px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style14&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;연도&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;예측&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;2021&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;미란다&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;미란다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;2022&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;(잊음)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;이정후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;2023&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;페디&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;페디&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;2024&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;김도영&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;김도영&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;2025&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;폰세&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;???&lt;/span&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;&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;h3 data-ke-size=&quot;size23&quot;&gt;디아즈 확률 분석&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;845&quot; data-origin-height=&quot;117&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JD4nc/dJMcaap1GjU/sCI5nk703yBFhxJt3VQGA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JD4nc/dJMcaap1GjU/sCI5nk703yBFhxJt3VQGA1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JD4nc/dJMcaap1GjU/sCI5nk703yBFhxJt3VQGA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJD4nc%2FdJMcaap1GjU%2FsCI5nk703yBFhxJt3VQGA1%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;845&quot; height=&quot;117&quot; data-origin-width=&quot;845&quot; data-origin-height=&quot;117&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타이틀 홀더가 3개이고 높은 OPS와 50홈런, 158타점의 힘으로 무려 88%의 확률로 MVP 수상할 것이라고 예측되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 강한 타자의 상징인 고의사구가 10개라는 점에서 3%p 낮아졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;폰세 확률 분석&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;734&quot; data-origin-height=&quot;95&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Atbdb/dJMcahJsdgs/jH1IIH2OJ57awwZ8n4mml0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Atbdb/dJMcahJsdgs/jH1IIH2OJ57awwZ8n4mml0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Atbdb/dJMcahJsdgs/jH1IIH2OJ57awwZ8n4mml0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAtbdb%2FdJMcahJsdgs%2FjH1IIH2OJ57awwZ8n4mml0%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;734&quot; height=&quot;95&quot; data-origin-width=&quot;734&quot; data-origin-height=&quot;95&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타이틀 홀더가 4개라는 압도적인 면모에서 매우 높은 점수를 받았다. (+73%p)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 MVP 수상 확률은 무려 99.9%.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디아즈도 88%로 절대 낮지 않은 확률이지만 폰세가 너무 압도적이라 개인적으로도 2025년 MVP는 폰세에게 돌아가지 않을까 싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/woojangchang/kbo_mvp/blob/master/KBO%20MVP%202025.ipynb&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;github&lt;/a&gt;&lt;/p&gt;</description>
      <category>데이터 분석/머신러닝</category>
      <author>woojc</author>
      <guid isPermaLink="true">https://boksup.tistory.com/104</guid>
      <comments>https://boksup.tistory.com/104#entry104comment</comments>
      <pubDate>Tue, 28 Oct 2025 17:37:19 +0900</pubDate>
    </item>
    <item>
      <title>MCP Python SDK로 로컬 MCP 서버 구축하기</title>
      <link>https://boksup.tistory.com/103</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;MCP란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Model Context Protocol로, 애플리케이션이 LLM에 컨텍스트를 제공하는 방법을 표준화하는 개방형 프로토콜이다. ( &lt;a href=&quot;https://docs.anthropic.com/ko/docs/agents-and-tools/mcp&quot;&gt;모델 컨텍스트 프로토콜 (MCP) - Anthropic&lt;/a&gt;, Claude 모델로 유명한 Anthropic에서 표준을 제시하였다.) 이렇게만 설명하면 어려울 수 있는데, 쉽게 말하면 &quot;툴&quot;을 만들어서 &quot;서버&quot;에 등록해놓고, LLM이 서버에 접속하여 필요한 툴을 선택하고 사용하는 것을 &quot;표준화&quot;한 것이다. LangGraph를 써 본 사람이라면 `create_react_agent`와 유사하다는 것을 파악할 수 있을 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;678&quot; data-origin-height=&quot;475&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r4F7D/btsNsOSgnxh/s4w3RYXcAEvrQv7hoUjUCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r4F7D/btsNsOSgnxh/s4w3RYXcAEvrQv7hoUjUCk/img.png&quot; data-alt=&quot;https://modelcontextprotocol.io/introduction&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r4F7D/btsNsOSgnxh/s4w3RYXcAEvrQv7hoUjUCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr4F7D%2FbtsNsOSgnxh%2Fs4w3RYXcAEvrQv7hoUjUCk%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;678&quot; height=&quot;475&quot; data-origin-width=&quot;678&quot; data-origin-height=&quot;475&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://modelcontextprotocol.io/introduction&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MCP 서버를 쓰는 이유는?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 말했던 `create_react_agent`와 비슷하다면 왜 MCP 서버를 구축해서 쓰는 걸까? 그 이유는 MCP가 &quot;표준화&quot;되어있다는 데 있다. LangGraph에서 툴을 등록하기 위해서는 프롬프트 엔지니어링과 함수에 docstring을 넣어주어야 하는데, 사람마다 쓰는 방식이 다르고 docstring의 포맷도 달라질 수 있다. 하지만 MCP에는 JSON 기반 스키마로 설명을 작성하여 &quot;표준&quot;처럼 쓸 수 있다. 또, 새로운 툴을 추가하는 확장성에 있어서도 JSON 형태이기 때문에 쉽게 등록할 수 있다는 장점이 있다. 비슷한 이유로 코드 기반이 아닌 JSON 형태의 구조이기 때문에 툴 관리가 편하다는 장점도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;MCP Python SDK&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 MCP 서버는 어떻게 구축할 수 있을까? 서버에는 대표적으로 Github MCP, Slack MCP, PostgreSQL MCP 등이 있으며, 구축하기 위한 툴에는 매우 다양한 것들이 있다.&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;1017&quot; data-origin-height=&quot;579&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/U985U/btsNsh8s1Ri/DLtUl4eU1Rm6qJfsyQDxl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/U985U/btsNsh8s1Ri/DLtUl4eU1Rm6qJfsyQDxl0/img.png&quot; data-alt=&quot;https://github.com/punkpeye/awesome-mcp-servers/blob/main/README-ko.md&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/U985U/btsNsh8s1Ri/DLtUl4eU1Rm6qJfsyQDxl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FU985U%2FbtsNsh8s1Ri%2FDLtUl4eU1Rm6qJfsyQDxl0%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;1017&quot; height=&quot;579&quot; data-origin-width=&quot;1017&quot; data-origin-height=&quot;579&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://github.com/punkpeye/awesome-mcp-servers/blob/main/README-ko.md&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MCP SDK를 이용하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/modelcontextprotocol/python-sdk&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/modelcontextprotocol/python-sdk&lt;/a&gt;&lt;/p&gt;
&lt;div class=&quot;txc-textbox&quot; style=&quot;background-color: #fefeb8; border: #f3c534 3px double; padding: 10px;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FastMCP가 유명하긴 하지만 Claude Desktop App이 있어야 작동하는 것으로 보인다. 어떻게든 써먹어보려 했지만 실패했다.&lt;/p&gt;
&lt;/div&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;h3 data-ke-size=&quot;size23&quot;&gt;설치 및 서버 구축&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 `uv`라는 것을 먼저 설치해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.astral.sh/uv/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;uv&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;uv라는 것을 MCP 프레임워크를 설치하면서 처음 알게 되었는데, `pip`보다 패키지를 더 빠르게 설치할 수 있는 거라고 한다.&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;난 윈도우를 쓰고 있기 때문에 아래 코드를 cmd에서 실행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`powershell&amp;nbsp;-ExecutionPolicy&amp;nbsp;ByPass&amp;nbsp;-c&amp;nbsp;&quot;irm&amp;nbsp;&lt;a href=&quot;https://astral.sh/uv/install.ps1&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://astral.sh/uv/install.ps1&lt;/a&gt; |&amp;nbsp;iex&quot;`&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`uv` 설치 이후, 서버를 구축할 폴더에서 `uv init mcp-server`를 입력한다. 그러면 `mcp-server`라는 폴더가 만들어지면서 그 안에 기본적인 파일 구조가 만들어진다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;197&quot; data-origin-height=&quot;161&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kCsqm/btsNtilyHVk/8eTdgRSKQk8USXQNgyseYk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kCsqm/btsNtilyHVk/8eTdgRSKQk8USXQNgyseYk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kCsqm/btsNtilyHVk/8eTdgRSKQk8USXQNgyseYk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkCsqm%2FbtsNtilyHVk%2F8eTdgRSKQk8USXQNgyseYk%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;197&quot; height=&quot;161&quot; data-origin-width=&quot;197&quot; data-origin-height=&quot;161&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;pre id=&quot;code_1745211711383&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[project]
name = &quot;mcp-server&quot;
version = &quot;0.1.0&quot;
description = &quot;Add your description here&quot;
readme = &quot;README.md&quot;
requires-python = &quot;&amp;gt;=3.13&quot;
dependencies = []&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;다음으로 `cd mcp-server`로 폴더 이동 후 `uv add &quot;mcp[cli]&quot;`로 mcp를 설치한다.&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;이후 `server_stdio.py` 파일을 생성하고 예시처럼 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1745214402058&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;from mcp.server.fastmcp import FastMCP

# Create an MCP server
mcp = FastMCP(&quot;Demo&quot;)


# Add an addition tool
@mcp.tool()
def add(a: int, b: int) -&amp;gt; int:
    &quot;&quot;&quot;Add two numbers&quot;&quot;&quot;
    return a + b


# Add a dynamic greeting resource
@mcp.resource(&quot;greeting://{name}&quot;)
def get_greeting(name: str) -&amp;gt; str:
    &quot;&quot;&quot;Get a personalized greeting&quot;&quot;&quot;
    return f&quot;Hello, {name}!&quot;

if __name__ == &quot;__main__&quot;:
    mcp.run(transport=&quot;stdio&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;또, `server_sse.py` 파일도 생성하자. 마지막 부분만 `transport=&quot;sse&quot;`로 변경해준 코드이다.&lt;/p&gt;
&lt;div class=&quot;txc-textbox&quot; style=&quot;background-color: #e7fdb5; border: #9fd331 3px double; padding: 10px;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`transport`에는 `stdio`와 `sse` 두 가지 방식이 있다. `stdio`는 클라이언트가 서버를 실행하는 것으로 보통 Cursor IDE나 Claude Desktop APP에 등록해서 사용하는 것으로 알고 있고, `sse`가 우리가 하고자 하는 네트워크로 서버를 띄우는 것이다.&lt;/p&gt;
&lt;/div&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;`client.py` 파일을 생성하자.&lt;/p&gt;
&lt;pre id=&quot;code_1745220541031&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import asyncio
import sys
from urllib.parse import urlparse

from mcp import ClientSession
from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client
from mcp import StdioServerParameters

USE_STDIO = True  # True: stdio 모드 / False: SSE 모드

async def run_stdio():
    server_params = StdioServerParameters(
        command=&quot;mcp&quot;,
        args=[&quot;run&quot;, &quot;server_stdio.py&quot;]
    )
    async with stdio_client(server_params) as (reader, writer):
        async with ClientSession(reader, writer) as session:
            await session.initialize()
            await interact(session)

async def run_sse(server_url: str):
    if urlparse(server_url).scheme not in (&quot;http&quot;, &quot;https&quot;):
        print(&quot;Error: Server URL must start with http:// or https://&quot;)
        sys.exit(1)

    try:
        async with sse_client(server_url) as (reader, writer):
            async with ClientSession(reader, writer) as session:
                await session.initialize()
                await interact(session)
    except Exception as e:
        print(f&quot;Error connecting to server: {e}&quot;)
        sys.exit(1)

async def interact(session: ClientSession):
    # 도구 호출
    add_result = await session.call_tool(&quot;add&quot;, arguments={&quot;a&quot;: 10, &quot;b&quot;: 15})
    print(&quot;Add result:&quot;, add_result.content[0].text)

    # 리소스 호출
    greeting_result = await session.read_resource(&quot;greeting://Bob&quot;)
    print(&quot;Greeting:&quot;, greeting_result.contents[0].text)

if __name__ == &quot;__main__&quot;:
    if USE_STDIO:
        asyncio.run(run_stdio())
    else:
        if len(sys.argv) != 2:
            sys.exit(1)
        asyncio.run(run_sse(sys.argv[1]))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고: &lt;a href=&quot;https://github.com/slavashvets/mcp-http-client-example/blob/main/main.py&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/slavashvets/mcp-http-client-example/blob/main/main.py&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 `interact` 함수인데, 툴은 `call_tool`을 하고 리소스는 `read_resource`를 해야 한다.&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;먼저 `stdio`를 테스트 하기 위해 `python client.py`를 하면 다음과 같은 결과를 얻을 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;199&quot; data-origin-height=&quot;47&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5SItY/btsNs41hMIA/UCaVqRn4kcZofthVxpdcak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5SItY/btsNs41hMIA/UCaVqRn4kcZofthVxpdcak/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5SItY/btsNs41hMIA/UCaVqRn4kcZofthVxpdcak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5SItY%2FbtsNs41hMIA%2FUCaVqRn4kcZofthVxpdcak%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;199&quot; height=&quot;47&quot; data-origin-width=&quot;199&quot; data-origin-height=&quot;47&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 `sse`를 테스트 하기 전에 먼저 `python server_sse.py` 또는 `uv run server_sse.py`로 서버를 띄워준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;85&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xxF3r/btsNsRbizMF/wmji807tk3xYCBcSD86Bx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xxF3r/btsNsRbizMF/wmji807tk3xYCBcSD86Bx1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xxF3r/btsNsRbizMF/wmji807tk3xYCBcSD86Bx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxxF3r%2FbtsNsRbizMF%2Fwmji807tk3xYCBcSD86Bx1%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;645&quot; height=&quot;85&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;85&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 로그가 나오면 잘 작동된 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`client.py`에서 `USE_STDIO = False`로 수정한 후 `python client.py http://localhost:8000/sse`를 하면 아까 `stdio`의 결과와 동일하게 얻을 수 있는 것을 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;MCP 활용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 뜨고 있는 CURSOR IDE에서 MCP를 등록해주자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Cursor Settings - MCP에서 'Add new gloabl MCP server' 버튼을 클릭해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 mcp.json 파일이 생성되는데, 여기에 다음과 같이 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1745285382795&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;mcpServers&quot;: {
    &quot;StdioServer&quot;: {
      &quot;command&quot;: &quot;uv&quot;,
      &quot;args&quot;: [
        &quot;--directory&quot;,
        &quot;D:/mcp-server&quot;,
        &quot;run&quot;,
        &quot;server_stdio.py&quot;
      ]
    },
    &quot;SSEServer&quot;: {
      &quot;url&quot;: &quot;http://localhost:8000/sse&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;`StdioServer`는 파일의 경로만 잘 작성해주면 되고, `SSEServer`는 미리 서버를 띄워놓아야만 작동한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;403&quot; data-origin-height=&quot;225&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bh5Kls/btsNr4AWGnG/vMZ89wRWaPsxMC4INM6IiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bh5Kls/btsNr4AWGnG/vMZ89wRWaPsxMC4INM6IiK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bh5Kls/btsNr4AWGnG/vMZ89wRWaPsxMC4INM6IiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbh5Kls%2FbtsNr4AWGnG%2FvMZ89wRWaPsxMC4INM6IiK%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;403&quot; height=&quot;225&quot; data-origin-width=&quot;403&quot; data-origin-height=&quot;225&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 위와 같이 초록색 표시가 뜨면서 사용 가능한 툴이 표시된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 StdioServer 사용 시 아나콘다 가상환경을 지정이 필요하면 아래와 같이 작성해도 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1746585176638&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;mcpServers&quot;: {
    &quot;StdioServer&quot;:{
      &quot;command&quot;: &quot;D:/conda/mcp/python.exe&quot;,
      &quot;args&quot;: [
        &quot;D:/mcp-server/server_stdio.py&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;h3 data-ke-size=&quot;size23&quot;&gt;LLM을 통해 MCP 서버 활용하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ctrl+Shift+P로 'Open Chat in Agent Mode'를 클릭하여 대화하면 LLM이 툴을 선택하는 것을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 Agent tool 활용에서 `duckduckgo_search`를 이용하여 검색하는 걸 만들어놨었는데 이를 MCP 서버에 심어주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://boksup.tistory.com/102&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2025.04.11 - [데이터 분석/LLM] - LangGraph로 만든 Agent의 응답을 Streaming으로 받기&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;345&quot; data-origin-height=&quot;268&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTyXTz/btsNLS7Ivqi/c6RYmfCmZXcTHBP5NzFKH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTyXTz/btsNLS7Ivqi/c6RYmfCmZXcTHBP5NzFKH1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTyXTz/btsNLS7Ivqi/c6RYmfCmZXcTHBP5NzFKH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTyXTz%2FbtsNLS7Ivqi%2Fc6RYmfCmZXcTHBP5NzFKH1%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;345&quot; height=&quot;268&quot; data-origin-width=&quot;345&quot; data-origin-height=&quot;268&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Run tool 버튼을 클릭해주면 MCP 툴을 이용하여 기사를 검색하고 그 결과를 정리해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 docstring을 통해 queries가 list라고 설명을 줬는데도 불구하고 string으로 던진다. LangGraph와 달리 Cursor AI에서는 docstring을 안 읽는지도 모르겠다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;343&quot; data-origin-height=&quot;594&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YufnU/btsNLcS9Kga/YoetyTgMcMymriZ8vgRCb1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YufnU/btsNLcS9Kga/YoetyTgMcMymriZ8vgRCb1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YufnU/btsNLcS9Kga/YoetyTgMcMymriZ8vgRCb1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYufnU%2FbtsNLcS9Kga%2FYoetyTgMcMymriZ8vgRCb1%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;343&quot; height=&quot;594&quot; data-origin-width=&quot;343&quot; data-origin-height=&quot;594&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://modelcontextprotocol.io/quickstart/server#windows&quot;&gt;For Server Developers - Model Context Protocol&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://rudaks.tistory.com/entry/MCP-Server-%EA%B0%9C%EB%B0%9C-Python&quot;&gt;MCP Server 개발 - Python - [루닥스 블로그] 연습만이 살길이다&lt;/a&gt;&lt;/p&gt;</description>
      <category>데이터 분석/LLM</category>
      <author>woojc</author>
      <guid isPermaLink="true">https://boksup.tistory.com/103</guid>
      <comments>https://boksup.tistory.com/103#entry103comment</comments>
      <pubDate>Sat, 10 May 2025 10:10:58 +0900</pubDate>
    </item>
    <item>
      <title>LangGraph로 만든 Agent의 응답을 Streaming으로 받기</title>
      <link>https://boksup.tistory.com/102</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 결론&lt;/h2&gt;
&lt;pre id=&quot;code_1743989793568&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;for msg, metadata in graph.stream(
    input,
    stream_mode=&quot;messages&quot;,
    ):
    if msg.content and metadata[&quot;langgraph_node&quot;] == &quot;summarizer&quot;:
        text_chunk = msg.content[0].get('text', '')
        print(text_chunk, end='', flush=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`graph.stream(input, stream_mode=&quot;messages&quot;)`를 이용하여 가장 마지막 node(위 코드에선 `summarizer`)에 대해 답변을 출력하도록 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. LangGraph의 Agent&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Agent는 LLM에게 tool을 쥐어주고, 알아서 판단해서 tool을 사용할지 말지를 정하고, 적절한 파라미터를 만들어서 tool을 실행하도록 하는 기술이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 공을 $H$m 높이에서 $v$m/s의 속도로 전방을 향해 던졌을 때 날아간 거리를 계산해주는 tool(함수)가 있을 때, LLM에게 `30m 높이에서 10m/s 속도로 공을 던지면 얼마나 날아가?`라는 질문을 하게 되면 LLM은 앞서 만들어놓은 tool에 `H=30`, `v=10`을 입력하여 답으로 받고 이를 가지고 답변을 하게 된다.&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;▼ Bedrock - Converse API tool 기능 이용한 예제&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1743657591877&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import math

def calc_projectile_distance(H, v, g=9.81):
    &quot;&quot;&quot;
    H: 초기 높이 (m)
    v: 수평 속도 (m/s)
    g: 중력 가속도 (기본값 9.81 m/s&amp;sup2;)
    반환값: 수평 거리 (m)
    &quot;&quot;&quot;
    # 공이 떨어지기까지 걸리는 시간
    t = math.sqrt(2 * H / g)

    # 수평으로 날아간 거리
    distance = v * t

    return distance


import json
import boto3

def generate_text(bedrock_client, model_id, tool_config, input_text):
    &quot;&quot;&quot;Generates text using the supplied Amazon Bedrock model. If necessary,
    the function handles tool use requests and sends the result to the model.
    Args:
        bedrock_client: The Boto3 Bedrock runtime client.
        model_id (str): The Amazon Bedrock model ID.
        tool_config (dict): The tool configuration.
        input_text (str): The input text.
    Returns:
        Nothing.
    &quot;&quot;&quot;

   # Create the initial message from the user input.
    messages = [{
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: [{&quot;text&quot;: input_text}]
    }]

    response = bedrock_client.converse(
        modelId=model_id,
        messages=messages,
        toolConfig=tool_config
    )

    output_message = response['output']['message']
    messages.append(output_message)
    stop_reason = response['stopReason']

    if stop_reason == 'tool_use':
        # Tool use requested. Call the tool and send the result to the model.
        tool_requests = response['output']['message']['content']
        for tool_request in tool_requests:
            if 'toolUse' in tool_request:
                tool = tool_request['toolUse']
                print(f&quot;Requesting tool {tool['name']}. Paramters: {tool['input']}&quot;)

                if tool['name'] == 'calc_projectile_distance':
                    tool_result = {}
                    distance = calc_projectile_distance(**tool['input'])
                    tool_result = {
                        &quot;toolUseId&quot;: tool['toolUseId'],
                        &quot;content&quot;: [{&quot;json&quot;: {&quot;distance&quot;: distance}}]
                    }

                    tool_result_message = {
                        &quot;role&quot;: &quot;user&quot;,
                        &quot;content&quot;: [
                            {
                                &quot;toolResult&quot;: tool_result

                            }
                        ]
                    }
                    messages.append(tool_result_message)

                    # Send the tool result to the model.
                    response = bedrock_client.converse(
                        modelId=model_id,
                        messages=messages,
                        toolConfig=tool_config
                    )
                    output_message = response['output']['message']

    # print the final response from the model.
    for content in output_message['content']:
        print(json.dumps(content, indent=4, ensure_ascii=False))



model_id = &quot;amazon.nova-pro-v1:0&quot;

tool_config = {
&quot;tools&quot;: [
    {
        &quot;toolSpec&quot;: {
            &quot;name&quot;: &quot;calc_projectile_distance&quot;,
            &quot;description&quot;: &quot;공을 $H$m 높이에서 $v$m/s의 속도로 전방을 향해 던졌을 때 날아간 거리 계산&quot;,
            &quot;inputSchema&quot;: {
                &quot;json&quot;: {
                    &quot;type&quot;: &quot;object&quot;,
                    &quot;properties&quot;: {
                        &quot;H&quot;: {
                            &quot;type&quot;: &quot;float&quot;,
                            &quot;description&quot;: &quot;공을 던지는 곳의 높이&quot;
                        },
                        &quot;v&quot;: {
                            &quot;type&quot;: &quot;float&quot;,
                            &quot;description&quot;: &quot;공을 던지는 속도&quot;
                        },
                        &quot;g&quot;: {
                            &quot;type&quot;: &quot;float&quot;,
                            &quot;description&quot;: &quot;중력가속도, 기본값=9.81&quot;
                        }
                    },
                    &quot;required&quot;: [
                        &quot;H&quot;, &quot;v&quot;
                    ]
                }
            }
        }
    }
]
}
bedrock_client = boto3.client(service_name='bedrock-runtime', region_name='us-east-1')
input_text = '30m 높이에서 10m/s 속도로 공을 던지면 얼마나 날아가?'

try:
    print(f&quot;Question: {input_text}&quot;)
    generate_text(bedrock_client, model_id, tool_config, input_text)

except Exception as err:
    print(f&quot;A client error occured: {err}&quot;)

else:
    print(
        f&quot;Finished generating text with model {model_id}.&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743657613954&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Question: 30m 높이에서 10m/s 속도로 공을 던지면 얼마나 날아가?
Requesting tool calc_projectile_distance. Paramters: {'v': 10.0, 'H': 30.0}
{
    &quot;text&quot;: &quot;30m 높이에서 10m/s 속도로 공을 던지면 공은 약 24.73m를 날아갑니다.&quot;
}
Finished generating text with model amazon.nova-pro-v1:0.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고: &lt;a href=&quot;https://docs.aws.amazon.com/ko_kr/bedrock/latest/userguide/tool-use-examples.html&quot;&gt;Converse API 도구 사용 예제 - Amazon Bedrock&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 Agent가 여러 툴을 쥐고 답변을 수행하게 하는 것을 돕는 또 다른 도구가 바로 LangGraph이다.&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;LangGraph Agent 예제는 다음 링크 참고 &lt;br /&gt;&lt;a href=&quot;https://langchain-ai.github.io/langgraph/tutorials/multi_agent/agent_supervisor/#create-agent-supervisor&quot;&gt;Multi-agent supervisor&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1743657730552&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;Multi-agent supervisor&quot; data-og-description=&quot;Home Guides Tutorials Agent Architectures Multi-Agent Systems Multi-agent supervisor The previous example routed messages automatically based on the output of the initial researcher agent. We can also choose to use an LLM to orchestrate the different agent&quot; data-og-host=&quot;langchain-ai.github.io&quot; data-og-source-url=&quot;https://langchain-ai.github.io/langgraph/tutorials/multi_agent/agent_supervisor/#create-agent-supervisor&quot; data-og-url=&quot;https://langchain-ai.github.io/langgraph/tutorials/multi_agent/agent_supervisor/#create-agent-supervisor&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://langchain-ai.github.io/langgraph/tutorials/multi_agent/agent_supervisor/#create-agent-supervisor&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://langchain-ai.github.io/langgraph/tutorials/multi_agent/agent_supervisor/#create-agent-supervisor&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;Multi-agent supervisor&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Home Guides Tutorials Agent Architectures Multi-Agent Systems Multi-agent supervisor The previous example routed messages automatically based on the output of the initial researcher agent. We can also choose to use an LLM to orchestrate the different agent&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;langchain-ai.github.io&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;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 상황 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회사에서 X월 X일에 생산된 OO제품에 대한 정보와 그 당시의 근무자와 그의 정보를 챗봇 형태로 파악하기를 원한다고 생각해보자. (거기에 챗봇 홍보를 위해 뉴스 기사 기반 답변도 된다고 했다고 해보자.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 필요한 tool들은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 생산 제품 정보&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 근무자 정보&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 뉴스 검색&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;그리고 각 tool을 사용하는 Agent를 매칭시켜준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생산 제품 정보 &amp;harr; `product_informer`&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근무자 정보 &amp;harr; `worker_informer`&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뉴스 검색 &amp;harr; `news_articles_searcher`&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;거기에 LangGraph의 예시대로 `supervisor`를 두어 어느 Agent에게 일을 시킬지 판단하도록 하게 하며&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`summarzier`를 둬서 최종 답변을 얻어내는 구조로 만들고자 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;809&quot; data-origin-height=&quot;333&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/T5IEU/btsM985jjFm/tbGERkbkd3iUyoXZYrmyMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/T5IEU/btsM985jjFm/tbGERkbkd3iUyoXZYrmyMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/T5IEU/btsM985jjFm/tbGERkbkd3iUyoXZYrmyMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FT5IEU%2FbtsM985jjFm%2FtbGERkbkd3iUyoXZYrmyMk%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;809&quot; height=&quot;333&quot; data-origin-width=&quot;809&quot; data-origin-height=&quot;333&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 구현&lt;/h2&gt;
&lt;pre id=&quot;code_1743989192123&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;from langgraph.graph import MessagesState
from typing import Literal
from typing_extensions import TypedDict
from langchain_aws import ChatBedrockConverse
from pydantic import BaseModel, Field

from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import create_react_agent
from datetime import datetime
from duckduckgo_search import DDGS
import requests

# The agent state is the input to each node in the graph
class AgentState(MessagesState):
    # The 'next' field indicates where to route to next
    next: str
    search_count: int
    node_count: int

members = [&quot;worker_informer&quot;, &quot;product_informer&quot;, &quot;news_articles_searcher&quot;]
options = members + [&quot;FINISH&quot;]

system_promptA = (
    &quot;You are a supervisor tasked with managing a conversation between the&quot;
    f&quot; following workers: {members}. Given the following user request,&quot;
    &quot; respond with the worker to act next. Each worker will perform a&quot;
    &quot; task and respond with their results and status. When finished,&quot;
    &quot; respond with FINISH.&quot;
    &quot; product_informer: He can inform defect rate, worker name with (product name and product date) or (product code).&quot;
    &quot; worker_informer: He can inform worker's team, age, etc.&quot;
    f&quot; Today is {datetime.today().strftime('%Y-%m-%d')}(KST).&quot;
)

system_promptB = (
    &quot;You are a supervisor tasked with managing a conversation between the&quot;
    f&quot; following workers: {members[:-1]}. Given the following user request,&quot;
    &quot; respond with the worker to act next. Each worker will perform a&quot;
    &quot; task and respond with their results and status. When finished,&quot;
    &quot; respond with FINISH.&quot;
    f&quot; Today is {datetime.today().strftime('%Y-%m-%d')}(KST).&quot;
)

# Pydantic 모델 사용
class Router(BaseModel):
    &quot;&quot;&quot;Worker to route to next. If no workers needed, route to FINISH.&quot;&quot;&quot;
    next: Literal[*options]

llm_selector = ChatBedrockConverse(
    model='us.anthropic.claude-3-5-sonnet-20241022-v2:0',
    temperature=0,
    max_tokens=None,
    region_name='us-east-1'
)

llm = ChatBedrockConverse(
    model=&quot;amazon.nova-pro-v1:0&quot;,
    temperature=0.9,
    max_tokens=1024,
    region_name='us-east-1'
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 `AgentState`를 선언하여 각 Agent가 다음 노드로 넘길 값을 갖고 있게 한다.&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;그리고 멤버 설정과 프롬프트는 LangGraph 예시와 거의 동일하게 가져갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 A, B로 나눈 것은 아직까지 LLM이 완벽하지 못해 검색을 계속 반복할 때가 있어, 검색 횟수 제한을 넘으면 검색을 선택지에서 제외하기 위함이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가적으로 `supervisor`에 사용할 모델은 클로드 소넷 3.5를 했으며 일반적인 답변은 아마존 노바 pro로 했다. (클로드가 좀 더 똑똑하지만 비싸기 때문)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743989998408&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def supervisor_node(state: AgentState) -&amp;gt; AgentState:
    if state['node_count'] &amp;gt; 10:
        return {&quot;next&quot;:&quot;summarizer&quot;}
    
    if state['search_count'] &amp;lt; 3:
        messages = [SystemMessage(content=[{&quot;type&quot;:&quot;text&quot;, &quot;text&quot;:system_promptA}])] + state['messages']
    else:
        messages = [SystemMessage(content=[{&quot;type&quot;:&quot;text&quot;, &quot;text&quot;:system_promptB}])] + state['messages']
        
    while True:
        try:
            structured_llm = llm_selector.with_structured_output(Router)
            response = structured_llm.invoke(messages)
            next_ = response.next
            if next_ == &quot;FINISH&quot;:
                next_ = &quot;summarizer&quot;
            break
        except llm_selector.client.exceptions.ModelErrorException as e:
            print(&quot;ModelErrorException&quot;, str(e))
            continue

    return {&quot;next&quot;: next_, &quot;search_count&quot;:state[&quot;search_count&quot;], &quot;node_count&quot;:state['node_count']}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드를 왔다갔다 한 횟수가 10을 넘기거나, 검색 횟수가 3을 넘기면 안 되게끔 설정을 해주었고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번씩 Bedrock 자체의 문제인지 네트워크의 문제인지 모델 에러가 나는 경우가 있어 그럴 경우를 방지하기 위해 while 반복문으로 성공할 때까지 반복하게 설정해주었다.&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_selector.with_structed_output(Router)`다. `Router` 안에는 `options`만이 들어가있어, `options`의 요소만 선택이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743990525412&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def fetch_page_content(url):
    &quot;&quot;&quot;주어진 뉴스 기사 URL에서 본문 내용을 크롤링&quot;&quot;&quot;
    try:
        headers = {&quot;User-Agent&quot;: &quot;Mozilla/5.0&quot;}
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()  # HTTP 오류 발생 시 예외 처리
        soup = BeautifulSoup(response.text, &quot;html.parser&quot;)
        
        # 본문 추출 (일반적인 뉴스/블로그 구조 적용)
        paragraphs = soup.find_all(&quot;p&quot;)
        content = &quot;\n&quot;.join([p.get_text() for p in paragraphs])
        return content.strip()
    
    except requests.RequestException as e:
        print(f&quot;Error fetching {url}: {e}&quot;)
        return None


def search_news_articles(queries, max_results=3):
    &quot;&quot;&quot;DuckDuckGo에서 뉴스 기사만 검색 후 URL 가져오기
    parameters
    ----------
    queries(list): 검색어 리스트. [&quot;query1&quot;, &quot;query2&quot;, ...]
    max_results(int): 검색 결과 갯수. default=3

    returns
    -------
    articles(string): 검색한 기사 전체
    &quot;&quot;&quot;
    articles = []
    
    for query in queries:
        for not_use_keyword in ['최근', '최신']:
            query = query.replace(not_use_keyword, '').strip()
        results = DDGS().news(keywords=query, max_results=max_results)
        for res in results:
            url = res.get(&quot;url&quot;)
            if url:
                article_content = fetch_page_content(url)  # 본문 크롤링
                title = res.get(&quot;title&quot;)
                date = res.get(&quot;date&quot;)
                articles.append(f&quot;쿼리: {query}\n제목: {title} ({url})\n날짜: {date}\n\n{article_content}&quot;)
    return articles


def worker_information(name: str) -&amp;gt; str:
    &quot;&quot;&quot;
    이름 기반으로 근무자 정보를 검색
    
    parameters
    ----------
    name(str): 검색할 사람 이름

    returns
    -------
    information(str)
    &quot;&quot;&quot;
    return f&quot;&quot;&quot;근무자 {name}의 정보
    - 생년월일: 1980.08.20
    - 소속팀: 과자생산팀
    - 학력: 서울고등학교-서울대학교(본교)
    - 현주소: 서울시 서초구 방배동&quot;&quot;&quot;


def product_information(product_name: str = '', product_date: str = '', product_code: str = '') -&amp;gt; str:
    &quot;&quot;&quot;
    제품 정보를 조회합니다.

    Parameters:
    ----------
    product_name (str): 제품명 (예: &quot;홈런볼&quot;)
    product_date (str): 생산일 (형식: YYYYMMDD, 예: &quot;20250301&quot;)
    product_code (str): 제품 코드 (예: &quot;bad39f&quot;)

    Returns:
    --------
    str: 제품 불량률 및 당시 근무자 이름 정보.

    주의:
    - product_name과 product_date는 함께 사용해야 합니다.
    - 또는 product_code 하나만 사용해도 됩니다.
    - 둘 다 비어 있으면 오류가 발생합니다.
    &quot;&quot;&quot;
    print(product_name, product_date, product_code)
    
    if product_name != '' and product_date != '':
        product_date = str(product_date)
        year = product_date[:4]
        month = product_date[4:6]
        day = product_date[6:]
        
        return f&quot;&quot;&quot;{year}년 {month}월 {day}일에 생산된 {product_name}의 생산 정보
    - 불량률: 0.00%
    - 근무자: 김롯데&quot;&quot;&quot;

    elif product_code != '':
        return f&quot;&quot;&quot;{product_code}에 해당하는 제품: 2025년 01월 30일에 생산된 라면의 정보
    - 불량률: 0.00%
    - 근무자: 이농심&quot;&quot;&quot;

    else:
        return f&quot;&quot;&quot;Paramters are wrong!!! product_name: {product_name}, product_date: {product_date}, product_code: {product_code}&quot;&quot;&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로는 뉴스 기사 검색, 근무자 정보, 제품 정보 조회하는 (가짜) 함수를 만들어준다. 원래는 DB와 연결해야하지만 예시니까 대충 넘어가자. 이 때, LLM이 함수를 파악할 수 있도록 docstring을 반드시 작성해주어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743990601774&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;search_system_msg = f&quot;&quot;&quot;Search in KOREAN and query must be string.
Today is {datetime.today().strftime('%Y-%m-%d')}(KST).
Search terms should be as specific as possible.
In particular, for dates, specify a specific date. DO NOT USE RECENT/최근/최신.
&quot;&quot;&quot;

research_agent = create_react_agent(
    llm, tools=[search_news_articles], state_modifier=search_system_msg
)

def news_search_node(state: AgentState) -&amp;gt; AgentState:
    result = research_agent.invoke(state)
    response_content = result[&quot;messages&quot;][-1].content[0]['text']
    return {
        &quot;messages&quot;: HumanMessage(content=[{&quot;type&quot;:&quot;text&quot;, &quot;text&quot;: response_content}], name=&quot;news_article_searcher&quot;),
        &quot;search_count&quot;: state['search_count']+1,
        &quot;node_count&quot;: state['node_count']+1
    }


worker_informer_agent = create_react_agent(llm, tools=[worker_information])
def worker_node(state: AgentState) -&amp;gt; AgentState:
    result = worker_informer_agent.invoke(state)
    response_content = result[&quot;messages&quot;][-1].content[0]['text']

    return {
        &quot;messages&quot;: HumanMessage(content=[{&quot;type&quot;:&quot;text&quot;, &quot;text&quot;: response_content}], name=&quot;worker_informer&quot;),
        &quot;search_count&quot;: state['search_count'],
        &quot;node_count&quot;: state['node_count']+1
    }


product_informer_agent = create_react_agent(llm, tools=[product_information])
def product_node(state: AgentState) -&amp;gt; AgentState:
    result = product_informer_agent.invoke(state)
    response_content = result[&quot;messages&quot;][-1].content[0]['text']

    return {
        &quot;messages&quot;: HumanMessage(content=[{&quot;type&quot;:&quot;text&quot;, &quot;text&quot;: response_content}], name=&quot;product_informer&quot;),
        &quot;search_count&quot;: state['search_count'],
        &quot;node_count&quot;: state['node_count']+1
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;tool을 사용하는 agent들을 만들어준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`create_react_agent`를 만들어두면 `llm`이 주어진 여러 tool 중에서 필요한 걸 선택하고 올바른 파라미터까지 생산해낸다. 다만 검색어를 만드는 건 아직 무리인 건지, 자꾸 `최신`, `최근` 같은 단어를 넣어서 검색하고 있어 해당 부분을 제외시켜주었다. (프롬프트 엔지니어링으로 설정해 놓아도 안 되는 이상한 상황)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 `state`를 다음 노드로 넘길 때 `node_count`나 `search_count`의 값을 높여서 전달한다.&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;참고로, agent에 여러 tool을 넣어두면 필요한 tool을 알아서 선택하고, 목표를 달성할 때까지 알아서 반복하기도 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 덧셈과 곱셈 함수를 tool로 주고, 3&amp;times;2+1을 하라고 하면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 곱셈 선택 후 파라미터로 3과 2 입력, 6을 받음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 덧셈 선택 후 파라미터로 6과 1 입력, 7을 받음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 과정을 1개의 agent 내에서 거친다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743991119683&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def summary_node(state: AgentState) -&amp;gt; AgentState:
    &quot;&quot;&quot;
    모든 정보를 종합하여 최종 답변을 생성하는 노드
    &quot;&quot;&quot;
    messages = [
        (&quot;system&quot;, &quot;모든 정보를 종합하여 유저 질문 또는 요구사항에 대한 최종 답변을 유저에게 전달합니다.&quot;)
    ] + state['messages']
    result = llm.invoke(messages)

    return result&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;마지막 답변 내놓는 agent는 간단하게 작성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743991158487&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def build_graph():

    builder = StateGraph(AgentState)
    builder.add_node(&quot;supervisor&quot;, supervisor_node)
    builder.add_node(&quot;news_articles_searcher&quot;, news_search_node)
    builder.add_node(&quot;worker_informer&quot;, worker_node)
    builder.add_node(&quot;product_informer&quot;, product_node)
    builder.add_node(&quot;summarizer&quot;, summary_node)
    
    builder.add_edge(START, &quot;supervisor&quot;)
    for member in members:
        # supervisor에게 작업이 완료되었음을 항상 알려주기를 원한다.
        builder.add_edge(member, &quot;supervisor&quot;)
    
    # supervisor가 FINISH를 반환하면 summary_node로 이동하도록 설정
    builder.add_conditional_edges(&quot;supervisor&quot;, lambda state: state[&quot;next&quot;])
    builder.add_edge(&quot;summarizer&quot;, END)
    
    # 마지막으로 진입점을 추가한다.
    builder.add_edge(START, &quot;supervisor&quot;)
    
    graph = builder.compile()
    return graph

graph = build_graph()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 node를 추가하고, node끼리 `add_edge`를 통해 연결시켜준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- START &amp;rarr; superviosr, 각 멤버 &amp;rarr; superviosr, summarizer &amp;rarr; END&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, `supervisor`는 여러 노드에 한 번에 이어진 것이 아니라 여러 노드 중 하나만 연결되는 조건부 연결이기 때문에 `add_conditional_edges`를 사용하여 연결시켜준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 `graph.stream(input, stream_mode=&quot;messages&quot;)`를 통해 출력을 stream으로 받을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(`llm`을 선언할 때 stream 기능을 넣지 않았는데도 작동하는 원리는 코드를 까보면 알 수 있지 않을까?)&lt;/p&gt;
&lt;pre id=&quot;code_1743991350759&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;node = ''
for msg, metadata in graph.stream(
    {&quot;messages&quot;:[HumanMessage(content=[{&quot;type&quot;:&quot;text&quot;, &quot;text&quot;:&quot;2025년 3월 1일에 생산된 홈런볼에 대해서, 당시 근무자의 상세 정보를 파악해줘.&quot;}])],
    &quot;search_count&quot;:0,
    &quot;node_count&quot;:0},
    stream_mode=&quot;messages&quot;,
    ):
    
    new_node = metadata['langgraph_node']
    if node != new_node:
        print('\n\n\n==================================')
        print(new_node.upper(), flush=True, end='\n\n')
        node = new_node
    if msg.content and isinstance(msg.content, list):
        if node == 'supervisor':
            text_chunk = msg.content[0].get('input', '')
        else:
            text_chunk = msg.content[0].get('text', '')
        print(text_chunk, end='', flush=True)
    elif msg.content and isinstance(msg.content, str):
        print(msg.content)
        print('==================================')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 출력 결과물이다.&lt;/p&gt;
&lt;pre id=&quot;code_1743999925223&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;==================================
SUPERVISOR

{&quot;next&quot;: &quot;product_informer&quot;}


==================================
AGENT

&amp;lt;thinking&amp;gt; To retrieve the detailed information about the worker who was on duty when the product &quot;홈런볼&quot; was produced on 2025-03-01, I need to use the `product_information` tool. I will provide both the product name and the production date to get the required information. &amp;lt;/thinking&amp;gt;
홈런볼 20250301 



==================================
TOOLS

2025년 03월 01일에 생산된 홈런볼의 생산 정보
    - 불량률: 0.00%
    - 근무자: 김롯데
==================================



==================================
AGENT

2025년 3월 1일에 생산된 홈런볼에 대한 정보를 확인한 결과, 당시 근무자는 김롯데였으며, 불량률은 0.00%로 나타났습니다.


==================================
PRODUCT_INFORMER

2025년 3월 1일에 생산된 홈런볼에 대한 정보를 확인한 결과, 당시 근무자는 김롯데였으며, 불량률은 0.00%로 나타났습니다.


==================================
SUPERVISOR

{&quot;next&quot;: &quot;worker_informer&quot;}


==================================
AGENT

&amp;lt;thinking&amp;gt; 문제의 핵심은 2025년 3월 1일에 생산된 홈런볼을 제조한 근무자인 김롯데의 상세 정보를 파악하는 것입니다. 이를 위해 worker_information 도구를 사용하여 김롯데의 상세 정보를 검색해야 합니다. &amp;lt;/thinking&amp;gt;



==================================
TOOLS

근무자 김롯데의 정보
    - 생년월일: 1980.08.20
    - 소속팀: 과자생산팀
    - 학력: 서울고등학교-서울대학교(본교)
    - 현주소: 서울시 서초구 방배동
==================================



==================================
AGENT

2025년 3월 1일에 생산된 홈런볼에 대해서, 당시 근무자인 김롯데의 상세 정보는 다음과 같습니다:

- 생년월일: 1980년 8월 20일
- 소속팀: 과자생산팀
- 학력: 서울고등학교, 서울대학교(본교)
- 현주소: 서울시 서초구 방배동


==================================
WORKER_INFORMER

2025년 3월 1일에 생산된 홈런볼에 대해서, 당시 근무자인 김롯데의 상세 정보는 다음과 같습니다:

- 생년월일: 1980년 8월 20일
- 소속팀: 과자생산팀
- 학력: 서울고등학교, 서울대학교(본교)
- 현주소: 서울시 서초구 방배동


==================================
SUPERVISOR

{&quot;next&quot;: &quot;FINISH&quot;}


==================================
SUMMARIZER

2025년 3월 1일에 생산된 홈런볼에 대한 정보를 종합하여 다음과 같은 상세 정보를 제공할 수 있습니다:

### 홈런볼 생산 정보 (2025년 3월 1일)
- **불량률**: 0.00%

### 당시 근무자 정보
- **이름**: 김롯데
- **생년월일**: 1980년 8월 20일
- **소속팀**: 과자생산팀
- **학력**:
  - 서울고등학교
  - 서울대학교(본교)
- **현주소**: 서울시 서초구 방배동

이 정보는 2025년 3월 1일에 생산된 홈런볼에 관련된 데이터를 바탕으로 작성되었습니다. 만약 더 많은 정보가 필요하거나 다른 날짜의 데이터를 확인하고 싶다면, 추가적인 조회가 필요할 수 있습니다.&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;1. `supervisor`가 먼저 `product_informer`를 호출한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Agent가 생각을 하여(`&amp;lt;thinking&amp;gt; ~ &amp;lt;/thinking&amp;gt;`)&amp;nbsp; `product_information` 툴을 이용하여 20250301의 홈런볼 정보를 받아와야한다고 판단하고, 이후 툴을 호출하여 생산 정보를 가져온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. `product_informer`가 툴 결과를 가지고 `supervisor`에게 전달한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. `supervisor`가 `worker_informer`를 호출한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 2~4가 반복된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6. 정보를 모두 찾았다고 판단한 `supervisor`가 `FINISH`로 넘긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7. 마지막으로 `summarizer`가 지금까지 얻은 정보를 토대로 사용자에게 글을 작성한다.&lt;/p&gt;</description>
      <category>데이터 분석/LLM</category>
      <author>woojc</author>
      <guid isPermaLink="true">https://boksup.tistory.com/102</guid>
      <comments>https://boksup.tistory.com/102#entry102comment</comments>
      <pubDate>Fri, 11 Apr 2025 18:38:19 +0900</pubDate>
    </item>
    <item>
      <title>WebSocket API Gateway로 streaming 응답 받기 (feat. bedrock)</title>
      <link>https://boksup.tistory.com/101</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM을 이용한 서비스, 예를 들면 지식 기반의 답변을 하는 챗봇을 개발하고자 할 때 API가 필요할 수 있다. 문제는 자주 사용하는 REST API는 streaming 응답을 지원하지 않기 때문에 다른 방법으로 개발할 필요가 있다. 다음과 같은 방법들이 있다.&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;1. 서버를 통한 API 생성&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2와 같은 클라우드든 온프레미스 환경이든 해당 서버에서 flask나 fastapi 같은 걸로 API를 구축하여 배포하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 사용자가 적을 경우 비용이 많이 든다는 단점과 어차피 서버를 띄운다면 API를 굳이 만들어야 하나? 하는 의문이 생기게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Lambda의 함수 URL을 이용한 streaming&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lambda의 기능 중 API Gateway 연결 없이 API처럼 사용할 수 있게 하는 함수 URL이 있다. 호출 모드를 RESPONSE_STREAM으로 설정하면 stream으로 답변을 받을 수 있는 구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.aws.amazon.com/ko_kr/lambda/latest/dg/configuration-response-streaming.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.aws.amazon.com/ko_kr/lambda/latest/dg/configuration-response-streaming.html&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, 2025년 3월 현재 Node.js 지원되므로 python 개발자에게는 익숙하지 않을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. API Gateway의 WebSocket으로 API 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api.html&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebSocket API는 클라이언트와 서버 간 실시간 양방향 통신을 가능하게 하는 프로토콜이다. 일반적인 HTTP 요청과 달리 연결을 끊지 않는 한 지속적으로 데이터를 주고 받을 수 있다. 이런 점을 이용하여 streaming 응답을 받을 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Lambda 함수 생성&lt;/h3&gt;
&lt;pre id=&quot;code_1742173473439&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import json
import boto3

bedrock_client = boto3.client('bedrock-runtime', region_name='us-east-1')

def lambda_handler(event, context):
    connection_id = event['requestContext']['connectionId']
    route_key = event['requestContext']['routeKey']

    if route_key == '$connect':
        return {'statusCode': 200, 'body': 'Connected'}

    elif route_key == '$disconnect':
        return {'statusCode': 200, 'body': 'Disconnected'}

    elif route_key == 'sendmessage':
        # 메시지 추출
        if isinstance(event['body'], str):
            body = json.loads(event['body'])
        else:
            body = event['body']
        message = body.get('message', '')

        inf_params = {&quot;max_new_tokens&quot;: 512, &quot;temperature&quot;: 0.9}

        request_body = {
            &quot;schemaVersion&quot;: &quot;messages-v1&quot;,
            &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: message}]}],
            &quot;inferenceConfig&quot;: inf_params,
        }

        # WebSocket 응답 전송 API 클라이언트 생성
        api_client = boto3.client(
            'apigatewaymanagementapi',
            endpoint_url=f&quot;https://{event['requestContext']['domainName']}/{event['requestContext']['stage']}&quot;
        )

        try:
            # Bedrock API 스트리밍 호출
            response = bedrock_client.invoke_model_with_response_stream(
                modelId='amazon.nova-pro-v1:0',
                body=json.dumps(request_body)
            )

            stream = response.get(&quot;body&quot;)
            if stream:
                for event in stream:
                    chunk = event.get(&quot;chunk&quot;)
                    if chunk:
                        chunk_json = json.loads(chunk.get(&quot;bytes&quot;).decode())
                        content_block_delta = chunk_json.get(&quot;contentBlockDelta&quot;)

                        if content_block_delta:
                            delta_text = content_block_delta.get(&quot;delta&quot;, {}).get(&quot;text&quot;, &quot;&quot;)
                            
                            if delta_text:
                                # WebSocket으로 delta text 전송
                                api_client.post_to_connection(
                                    ConnectionId=connection_id,
                                    Data=json.dumps({&quot;response&quot;: delta_text})
                                )

        except Exception as e:
            print(&quot;Error:&quot;, str(e))
            return {'statusCode': 500, 'body': 'Error processing response'}

        return {'statusCode': 200, 'body': 'Message sent'}

    else:
        return {'statusCode': 400, 'body': 'Unhandled route'}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(1) 전체적인 흐름&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 클라이언트가 WebSocket에 연결하면 `$connect` 핸들링&lt;br /&gt;- 클라이언트가 메시지를 보내면 sendmessage 핸들링: Bedrock API를 호출하여 AI 응답을 스트리밍 방식으로 받고&amp;nbsp;WebSocket을 통해 클라이언트로 전송 &lt;br /&gt;- 연결이 끊어지면 `$disconnect` 핸들링&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(2) 연결된 상태에서 streaming으로 응답 보내기&lt;/h4&gt;
&lt;pre id=&quot;code_1742173958725&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# WebSocket 응답 전송 API 클라이언트 생성
api_client = boto3.client(
    'apigatewaymanagementapi',
    endpoint_url=f&quot;https://{event['requestContext']['domainName']}/{event['requestContext']['stage']}&quot;
)

...

api_client.post_to_connection(
    ConnectionId=connection_id,
    Data=json.dumps({&quot;response&quot;: delta_text})
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 위 부분이라고 생각한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. API Gateway 생성&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(1) WebSocket&amp;nbsp;API&amp;nbsp;구축을&amp;nbsp;선택한다.&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;871&quot; data-origin-height=&quot;250&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MJLYW/btsMNG0qzAW/mztVG00wZVKVQ3kBBJNxFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MJLYW/btsMNG0qzAW/mztVG00wZVKVQ3kBBJNxFk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MJLYW/btsMNG0qzAW/mztVG00wZVKVQ3kBBJNxFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMJLYW%2FbtsMNG0qzAW%2FmztVG00wZVKVQ3kBBJNxFk%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;871&quot; height=&quot;250&quot; data-origin-width=&quot;871&quot; data-origin-height=&quot;250&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;h4 data-ke-size=&quot;size20&quot;&gt;(2) API 이름과 라우팅을 입력한다.&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;585&quot; data-origin-height=&quot;466&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ShI6R/btsMMcTE8W7/LgMaXBenki7eu9mRtwapR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ShI6R/btsMMcTE8W7/LgMaXBenki7eu9mRtwapR0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ShI6R/btsMMcTE8W7/LgMaXBenki7eu9mRtwapR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FShI6R%2FbtsMMcTE8W7%2FLgMaXBenki7eu9mRtwapR0%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;585&quot; height=&quot;466&quot; data-origin-width=&quot;585&quot; data-origin-height=&quot;466&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원하는 API 이름을 지정하며, 라우팅은 예시와 동일하게 `request.body.action`을 입력한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(3) 경로 추가&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;561&quot; data-origin-height=&quot;744&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Av5Ec/btsMLywxp8o/g16CoCsbCcPz4QK6y7vTBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Av5Ec/btsMLywxp8o/g16CoCsbCcPz4QK6y7vTBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Av5Ec/btsMLywxp8o/g16CoCsbCcPz4QK6y7vTBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAv5Ec%2FbtsMLywxp8o%2Fg16CoCsbCcPz4QK6y7vTBk%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;561&quot; height=&quot;744&quot; data-origin-width=&quot;561&quot; data-origin-height=&quot;744&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`$connect`, `$disconnect`를 추가하고 메시지를 전달할 API를 위해 사용자 지정 경로도 추가해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(4) Lambda 연결&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;577&quot; data-origin-height=&quot;688&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sPA4x/btsMMFOUiD5/yu0kxY8T4KhzQdrkKwAZFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sPA4x/btsMMFOUiD5/yu0kxY8T4KhzQdrkKwAZFK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sPA4x/btsMMFOUiD5/yu0kxY8T4KhzQdrkKwAZFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsPA4x%2FbtsMMFOUiD5%2Fyu0kxY8T4KhzQdrkKwAZFK%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;577&quot; height=&quot;688&quot; data-origin-width=&quot;577&quot; data-origin-height=&quot;688&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 생성한 Lambda를 모든 경로와 연결해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(5) 구축 완료&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스테이지 이름은 `production`, `prod` 등 본인 입맛에 따라 설정한 뒤 WebSocket API를 생성 완료하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. WebSocket 테스트 in Python&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구축한 API의 스테이지에 들어가 `wss`로 시작하는 WebSocket URL을 복사하여 아래 코드에 심어주자.&lt;/p&gt;
&lt;pre id=&quot;code_1742174889987&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import websocket
import json
import threading

# 일정 시간 동안 응답이 없으면 연결을 종료하는 타이머
disconnect_timer = None
DISCONNECT_TIMEOUT = 5  # 5초 동안 추가 응답이 없으면 연결 종료

def reset_disconnect_timer(ws):
    &quot;&quot;&quot;타이머를 리셋하여 일정 시간 후 자동으로 연결 종료&quot;&quot;&quot;
    global disconnect_timer
    if disconnect_timer:
        disconnect_timer.cancel()  # 기존 타이머 취소
    disconnect_timer = threading.Timer(DISCONNECT_TIMEOUT, lambda: ws.close())  
    disconnect_timer.start()

def on_message(ws, message):
    &quot;&quot;&quot;서버로부터 메시지를 수신했을 때 실행되는 콜백 함수&quot;&quot;&quot;
    try:
        data = json.loads(message)
        response_text = data.get(&quot;response&quot;, &quot;&quot;)
        print(response_text, end='')

        # 응답이 있으면 타이머 리셋
        reset_disconnect_timer(ws)

    except json.JSONDecodeError:
        print(&quot;Invalid JSON received:&quot;, message)

def on_open(ws):
    &quot;&quot;&quot;WebSocket 연결이 성공했을 때 실행되는 콜백 함수&quot;&quot;&quot;
    print(&quot;Connected to WebSocket API Gateway&quot;)
    payload = json.dumps({
        &quot;action&quot;: &quot;sendmessage&quot;,
        &quot;message&quot;: &quot;Your Message&quot;
    })
    ws.send(payload)

    # 연결이 열리면 응답이 없을 경우 자동 종료하는 타이머 시작
    reset_disconnect_timer(ws)

def on_close(ws, close_status_code, close_msg):
    &quot;&quot;&quot;WebSocket 연결이 종료되었을 때 실행되는 콜백 함수&quot;&quot;&quot;
    print(&quot;\nDisconnected&quot;)

def on_error(ws, error):
    &quot;&quot;&quot;WebSocket 에러 발생 시 실행되는 콜백 함수&quot;&quot;&quot;
    print(&quot;Error:&quot;, error)

# WebSocket 연결 URL
ws_url = &quot;wss://your-api-id.execute-api.ap-northeast-2.amazonaws.com/production/&quot;

# WebSocket 클라이언트 실행
ws = websocket.WebSocketApp(
    ws_url,
    on_open=on_open,
    on_message=on_message,
    on_close=on_close,
    on_error=on_error
)
ws.run_forever()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러면 streaming되어 응답이 오게 되고, 5초간 추가 응답이 없으면 연결을 종료하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 간단한 Vue.js 만들기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`my-vue-app` &amp;gt; `src` &amp;gt; `components`에 `WebSocketComponent.vue`를 새로 생성하고 아래 코드를 복붙한다. (코드는 AI가 작성해주었다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;▼ 펼치기&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1742185775022&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;template&amp;gt;
  &amp;lt;div class=&quot;chat-container&quot;&amp;gt;
    &amp;lt;h1&amp;gt;WebSocket AI 채팅&amp;lt;/h1&amp;gt;
    
    &amp;lt;div class=&quot;chat-box&quot;&amp;gt;
      &amp;lt;div v-for=&quot;(msg, index) in chatHistory&quot; :key=&quot;index&quot; :class=&quot;msg.sender&quot;&amp;gt;
        &amp;lt;div :class=&quot;msg.sender === 'user' ? 'user-message' : 'ai-message'&quot;&amp;gt;
          &amp;lt;span v-html=&quot;computedMarkdown(msg.text)&quot;&amp;gt;&amp;lt;/span&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;div class=&quot;input-container&quot;&amp;gt;
      &amp;lt;input v-model=&quot;message&quot; placeholder=&quot;메시지를 입력하세요&quot; @keyup.enter=&quot;sendMessage&quot; /&amp;gt;
      &amp;lt;button @click=&quot;sendMessage&quot;&amp;gt;전송&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;

&amp;lt;script&amp;gt;
import { marked } from &quot;marked&quot;;
import hljs from &quot;highlight.js&quot;;
import &quot;highlight.js/styles/github.css&quot;; 

marked.setOptions({
  highlight: function (code, lang) {
    const language = hljs.getLanguage(lang) ? lang : &quot;plaintext&quot;;
    return hljs.highlight(code, { language }).value;
  },
  breaks: true,
  gfm: true,
});

export default {
  data() {
    return {
      socket: null,
      message: &quot;&quot;,
      chatHistory: [],
      aiResponseBuffer: &quot;&quot;,
      isReceiving: false,
      reconnectTimeout: null,
      pingTimeout: null //   9분 후 ping 전송 타이머
    };
  },
  computed: {
    computedMarkdown() {
      return (text) =&amp;gt; {
        const rawHTML = marked(text);
        this.$nextTick(() =&amp;gt; {
          document.querySelectorAll(&quot;pre code&quot;).forEach((block) =&amp;gt; {
            if (!block.classList.contains(&quot;hljs&quot;)) {
              hljs.highlightElement(block);
            }
          });
        });
        return rawHTML;
      };
    }
  },
  methods: {
    connectWebSocket() {
      const endpoint = &quot;wss://your-api-id.execute-api.ap-northeast-2.amazonaws.com/prod&quot;;
      this.socket = new WebSocket(endpoint);

      this.socket.onopen = () =&amp;gt; {
        console.log(`✅ WebSocket 연결 성공- ${new Date().toISOString()}`);
        clearTimeout(this.reconnectTimeout);
      };

      this.socket.onmessage = this.handleWebSocketMessage;

      this.socket.onclose = this.handleSocketClose;

      this.socket.onerror = (error) =&amp;gt; {
        console.error(&quot;⚠ WebSocket 오류 발생:&quot;, error);
      };
    },

    handleSocketClose() {
      console.log(`❌ WebSocket 연결 종료됨 - ${new Date().toISOString()}`);
      this.isReceiving = false;
      clearTimeout(this.pingTimeout); //   ping 타이머 정리

      //   자동 재연결 (3초 후)
      this.reconnectTimeout = setTimeout(() =&amp;gt; {
        console.log(`  WebSocket 재연결 시도...- ${new Date().toISOString()}`);
        this.connectWebSocket();
      }, 3000);
    },

    handleWebSocketMessage(event) {
      try {
        const data = JSON.parse(event.data);
        if (data.response) {
          this.aiResponseBuffer += data.response;
          
          if (!this.isReceiving) {
            this.isReceiving = true;
            this.chatHistory.push({ sender: &quot;ai&quot;, text: &quot;&quot; });
          }

          // 마지막 AI 메시지 업데이트
          this.chatHistory[this.chatHistory.length - 1].text = this.aiResponseBuffer;
        }
      } catch (error) {
        console.error(&quot;  WebSocket 메시지 파싱 오류:&quot;, error, new Date().toISOString());
      }
    },

    sendMessage() {
      if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
        console.error(`  WebSocket 연결이 열려 있지 않습니다.- ${new Date().toISOString()}`);
        return;
      }

      const payload = { action: &quot;sendmessage&quot;, message: this.message };
      this.chatHistory.push({ sender: &quot;user&quot;, text: this.message });

      this.socket.send(JSON.stringify(payload));
      this.message = &quot;&quot;;
      this.aiResponseBuffer = &quot;&quot;;
      this.isReceiving = false;

      //   새로운 메시지가 전송되었으므로 기존 ping 타이머를 초기화하고 9분 후에 새로운 ping 설정
      this.resetPingTimeout();
    },

    resetPingTimeout() {
      clearTimeout(this.pingTimeout); // 기존 타이머 제거

      this.pingTimeout = setTimeout(() =&amp;gt; {
        if (this.socket &amp;amp;&amp;amp; this.socket.readyState === WebSocket.OPEN) {
          console.log(`  5분 후 WebSocket ping 전송... - ${new Date().toISOString()}`);
          this.socket.send(JSON.stringify({ action: &quot;ping&quot; }));

          //   핑 전송 후 다시 타이머 설정 (주기적 반복)
          this.resetPingTimeout();
        }
      }, 5 * 60 * 1000);
    }
  },
  mounted() {
    this.connectWebSocket();
  },
  beforeUnmount() { // ✅ Vue 3에서 사용해야 하는 lifecycle hook
    if (this.socket) {
      this.socket.close();
    }
    clearTimeout(this.reconnectTimeout);
    clearTimeout(this.pingTimeout); //   ping 타이머 제거
  }
};
&amp;lt;/script&amp;gt;

&amp;lt;style scoped&amp;gt;
.chat-container {
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
  text-align: center;
}

.chat-box {
  width: 100%;
  height: 600px;
  overflow-y: auto;
  border: 1px solid #ddd;
  padding: 10px;
  background-color: #f9f9f9;
}

.user {
  text-align: right;
}

.ai {
  text-align: left;
}

/* ✅ 사용자 메시지 스타일 */
.user-message {
  display: inline-block;
  max-width: 80%;
  background-color: #d9eaff;
  color: black;
  padding: 10px;
  border-radius: 10px;
  margin: 5px 0;
  text-align: left;
  word-wrap: break-word;
}

/* ✅ AI 메시지 스타일 */
.ai-message {
  display: inline-block;
  max-width: 80%;
  background-color: #dff2bf;
  color: black;
  padding: 10px;
  border-radius: 10px;
  margin: 5px 0;
  text-align: left;
  word-wrap: break-word;
}

/* ✅ 코드 블록 스타일 */
.ai-message pre {
  background-color: #282c34;
  color: #abb2bf;
  padding: 10px;
  border-radius: 5px;
  overflow-x: auto;
  font-family: monospace;
}

/* ✅ 입력창 스타일 */
.input-container {
  display: flex;
  margin-top: 10px;
}

input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ccc;
}

button {
  padding: 10px;
  background-color: #4CAF50;
  color: white;
  border: none;
  cursor: pointer;
}
&amp;lt;/style&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`my-vue-app` &amp;gt; `src` &amp;gt; `App.vue` 부분도 수정해준다.&lt;/p&gt;
&lt;pre id=&quot;code_1742185886085&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;template&amp;gt;
  &amp;lt;div id=&quot;app&quot;&amp;gt;
    &amp;lt;WebSocketComponent /&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;

&amp;lt;script&amp;gt;
import WebSocketComponent from './components/WebSocketComponent.vue';

export default {
  name: 'App',
  components: {
    WebSocketComponent
  }
};
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;1244&quot; data-origin-height=&quot;690&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lp4lZ/btsMMGUN9dp/KcfR1KE1u1tYpr2OVzcA00/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lp4lZ/btsMMGUN9dp/KcfR1KE1u1tYpr2OVzcA00/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lp4lZ/btsMMGUN9dp/KcfR1KE1u1tYpr2OVzcA00/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Flp4lZ%2FbtsMMGUN9dp%2FKcfR1KE1u1tYpr2OVzcA00%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;1244&quot; height=&quot;690&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;690&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소수 판별 함수를 작성해달라고 요청했더니 streaming으로 응답을 받아 잘 작성해주는 모습을 보여준다.&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;1254&quot; data-origin-height=&quot;577&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rXjW1/btsMNep1vzd/ciBUQec2LOiymCKH7t2qn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rXjW1/btsMNep1vzd/ciBUQec2LOiymCKH7t2qn1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rXjW1/btsMNep1vzd/ciBUQec2LOiymCKH7t2qn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrXjW1%2FbtsMNep1vzd%2FciBUQec2LOiymCKH7t2qn1%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;1254&quot; height=&quot;577&quot; data-origin-width=&quot;1254&quot; data-origin-height=&quot;577&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 대화 내용을 이어지게 하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 대화 내용이 이어지도록 구현할 수도 있지만, 여기서는 `langchain`의 `RunnableWithMessageHistory`를 이용하였다. 메모리에 대화 내용을 저장해놓고 자동으로 LLM에 던지는 방법이다. 이 때 대화 내용이 너무 길어지면 모델이 오류를 뱉거나 과다한 요금이 나가므로 어느 정도 자르는 걸 추천하는데 토큰 단위로 자르기, 대화 내역 요약해서 저장하기 등 다양한 방식이 있지만 list 길이로 자르도록 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1742188524912&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import json
import boto3
from langchain_aws.chat_models.bedrock_converse import ChatBedrockConverse
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.messages import AIMessage, HumanMessage, trim_messages
from pydantic import BaseModel, Field

# In-memory chat message history implementation
class InMemoryHistory(BaseChatMessageHistory, BaseModel):
    messages: list = Field(default_factory=list)

    def add_message(self, message):
        self.messages.append(message)

    def clear(self):
        self.messages = []

# Store chat histories
chat_histories = {}

def get_chat_history(session_id):
    &quot;&quot;&quot;세션 ID에 따른 대화 기록을 가져오고, 필요하면 새로 생성&quot;&quot;&quot;
    if session_id not in chat_histories:
        chat_histories[session_id] = InMemoryHistory()
    
    history = chat_histories[session_id]

    # 대화 내역 정리 (최근 10개 메시지만 유지)
    history.messages = trim_messages(
        messages=history.messages,
        token_counter=len,  # 메시지 개수를 기준으로 조정 (토큰이 아닌 개수 기반)
        max_tokens=10,  # 최근 10개 대화만 유지
        strategy=&quot;last&quot;,  # 최신 대화 유지
        start_on=&quot;human&quot;,  # HumanMessage가 먼저 오도록 설정
        include_system=False,  # 시스템 메시지는 유지하지 않음 (필요하면 True로 변경)
        allow_partial=False,  # 부분적 메시지 삭제 허용 안 함
    )

    return history

# LangChain Bedrock 모델 설정
llm = ChatBedrockConverse(
    model=&quot;amazon.nova-lite-v1:0&quot;,
    temperature=0.9,
    max_tokens=512,
    region_name=&quot;us-east-1&quot;,
)

# RunnableWithMessageHistory 설정
def get_session_history(session_id):
    return get_chat_history(session_id)

runnable = RunnableWithMessageHistory(
    runnable=llm,
    get_session_history=get_session_history,
)

def lambda_handler(event, context):
    connection_id = event['requestContext']['connectionId']
    route_key = event['requestContext']['routeKey']
    session_id = event['requestContext'].get('sessionId', connection_id)  # 세션 ID 설정

    if route_key == '$connect':
        return {'statusCode': 200, 'body': 'Connected'}

    elif route_key == '$disconnect':
        return {'statusCode': 200, 'body': 'Disconnected'}

    elif route_key == 'sendmessage':
        # 메시지 추출
        if isinstance(event['body'], str):
            body = json.loads(event['body'])
        else:
            body = event['body']
        message = body.get('message', '')

        # WebSocket 응답 전송 API 클라이언트 생성
        api_client = boto3.client(
            'apigatewaymanagementapi',
            endpoint_url=f&quot;https://{event['requestContext']['domainName']}/{event['requestContext']['stage']}&quot;
        )

        try:
            # 대화 기록 가져오기 및 메시지 추가
            history = get_chat_history(session_id)
            history.add_message(HumanMessage(content=message))

            # LangChain을 사용하여 Bedrock 스트리밍 응답 받기
            response = runnable.stream(
                {&quot;messages&quot;: history.messages},
                config={&quot;configurable&quot;: {&quot;session_id&quot;: session_id}}  # 세션 ID를 실제 사용
            )

            ai_response_content = ''
            for chunk in response:
                if chunk.content:
                    text_chunk = chunk.content[0].get('text', '')
                    ai_response_content += text_chunk

                    # WebSocket을 통해 클라이언트에 실시간 전송
                    api_client.post_to_connection(
                        ConnectionId=connection_id,
                        Data=json.dumps({&quot;response&quot;: text_chunk})
                    )

            # AI 응답을 대화 기록에 추가
            history.add_message(AIMessage(content=ai_response_content))

        except Exception as e:
            print(&quot;Error:&quot;, str(e))
            return {'statusCode': 500, 'body': 'Error processing response'}

        return {'statusCode': 200, 'body': 'Message sent'}

    else:
        return {'statusCode': 400, 'body': 'Unhandled route'}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 똑같이 소수 판별 함수를 작성해달라고 했을 때, 그 이후 대화가 이어지는 것을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1195&quot; data-origin-height=&quot;576&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TLoh2/btsMN6545RM/SvlORVJfOUgnLhaOYvcAB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TLoh2/btsMN6545RM/SvlORVJfOUgnLhaOYvcAB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TLoh2/btsMN6545RM/SvlORVJfOUgnLhaOYvcAB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTLoh2%2FbtsMN6545RM%2FSvlORVJfOUgnLhaOYvcAB1%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;1195&quot; height=&quot;576&quot; data-origin-width=&quot;1195&quot; data-origin-height=&quot;576&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;연결 지속 시간&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.aws.amazon.com/ko_kr/apigateway/latest/developerguide/limits.html&quot;&gt;Amazon API Gateway 할당량 및 중요 정보 - Amazon API Gateway&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1077&quot; data-origin-height=&quot;663&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/p9LIj/btsMMcfL4AF/k1W2rLKRk7w0hOfEq92Cgk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/p9LIj/btsMMcfL4AF/k1W2rLKRk7w0hOfEq92Cgk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/p9LIj/btsMMcfL4AF/k1W2rLKRk7w0hOfEq92Cgk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fp9LIj%2FbtsMMcfL4AF%2Fk1W2rLKRk7w0hOfEq92Cgk%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;1077&quot; height=&quot;663&quot; data-origin-width=&quot;1077&quot; data-origin-height=&quot;663&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최대 2시간까지 연결되며 유휴 연결 제한 시간은 10분이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 10분 동안 비활성 상태(데이터 전송 없음)이면 자동으로 연결을 종료하며, 데이터를 지속적으로 전송했어도 2시간 후에는 연결을 무조건 종료하는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1199&quot; data-origin-height=&quot;374&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bN3iKu/btsMMBMZ3LA/mpFQFah4tQV4TgjxzH06g1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bN3iKu/btsMMBMZ3LA/mpFQFah4tQV4TgjxzH06g1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bN3iKu/btsMMBMZ3LA/mpFQFah4tQV4TgjxzH06g1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbN3iKu%2FbtsMMBMZ3LA%2FmpFQFah4tQV4TgjxzH06g1%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;1199&quot; height=&quot;374&quot; data-origin-width=&quot;1199&quot; data-origin-height=&quot;374&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 아무것도 하지 않고 10분이 지나 다시 대화를 해보았을 때 연결이 끊겼다 다시 연결되면서 이전 대화를 기억하지 못하는 모습을 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div class=&quot;txc-textbox&quot; style=&quot;background-color: #e7fdb5; border: #9fd331 3px double; padding: 10px;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lambda의 제한 시간과 관계 없이 WebSocket은 계속 유지된다. 실제로 제한 시간을 30초로 잡았으나 1분 뒤 다시 대화를 해도 대화가 이어지는 것을 확인할 수 있었다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 방지하기 위한 방안으로는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1) 일정 시간마다 Ping을 날린다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 유저 입장에서 10분동안 아무것도 하지 않아도 WebSocket이 끊어지지는 않는다.&lt;/p&gt;
&lt;pre id=&quot;code_1742190701245&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;setInterval(() =&amp;gt; {
  if (socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify({ action: &quot;ping&quot; }));
  }
}, 60000); // 1분마다 ping 전송&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2) DB를 활용한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세션이 초기화되어도 바뀌지 않는 고유 식별자 ID 등을 활용하여 session ID와 함께 DB에 저장해두고 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세션이 종료되면 지금까지의 대화를 저장하고, 세션이 연결되면 이전 대화를 가져오도록 코드를 작성한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.6123%;&quot;&gt;userId&lt;/td&gt;
&lt;td style=&quot;width: 22.4031%;&quot;&gt;connectionId&lt;/td&gt;
&lt;td style=&quot;width: 20.8528%;&quot;&gt;lastActiveTime&lt;/td&gt;
&lt;td style=&quot;width: 37.1318%;&quot;&gt;conversationHistory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.6123%;&quot;&gt;user_12345&lt;/td&gt;
&lt;td style=&quot;width: 22.4031%;&quot;&gt;abc123xyz890&lt;/td&gt;
&lt;td style=&quot;width: 20.8528%;&quot;&gt;2025-03-01 12:30:50&lt;/td&gt;
&lt;td style=&quot;width: 37.1318%;&quot;&gt;...&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.6123%;&quot;&gt;user_67890&lt;/td&gt;
&lt;td style=&quot;width: 22.4031%;&quot;&gt;def456uvw123&lt;/td&gt;
&lt;td style=&quot;width: 20.8528%;&quot;&gt;2025-03-01 12:51:38&lt;/td&gt;
&lt;td style=&quot;width: 37.1318%;&quot;&gt;...&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 위와 같이 DB에 저장해놓고 userId가 `user_12345`이면 이전 대화를 가져오도록 구성할 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS의 예제에서는 DynamoDB를 추천하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 경우 대화 초기화 기능 또는 일정 시간 경과 후 DB에서 대화 내용을 삭제하는 등 스케줄링 관리도 해줘야 하는 번거로움이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(1) 유저가 2시간 이상 연속으로 사용할 서비스가 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(2) DB 비용이 부담된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(3) 대화 내용이나 사용 이력을 가지고 분석 등에 활용하지 않을 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 3개 조건을 모두 만족하면 핑만 추가하는 게 좋지 않을까 싶다.&lt;/p&gt;</description>
      <category>AWS</category>
      <author>woojc</author>
      <guid isPermaLink="true">https://boksup.tistory.com/101</guid>
      <comments>https://boksup.tistory.com/101#entry101comment</comments>
      <pubDate>Fri, 21 Mar 2025 20:15:36 +0900</pubDate>
    </item>
    <item>
      <title>Wheel File Packager (for Lambda Layer)</title>
      <link>https://boksup.tistory.com/100</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/woojangchang/tkinter_apps/tree/master/wheel_packager&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/woojangchang/tkinter_apps/tree/master/wheel_packager&lt;/a&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;Wheel File Packager는 &lt;code&gt;.whl&lt;/code&gt; 및 &lt;code&gt;.zip&lt;/code&gt; 파일을 업로드하여 자동으로 &lt;code&gt;python.zip&lt;/code&gt; 파일을 생성하도록 돕는 앱이다.&lt;br /&gt;&lt;b&gt;AWS Lambda Layer&lt;/b&gt;를 생성하기 위한 목적으로, 업로드한 &lt;code&gt;.whl&lt;/code&gt; 파일의 압축을 해제한 후, 모든 파일을 &lt;code&gt;python/&lt;/code&gt; 폴더 내부에 정리하여 &lt;code&gt;python.zip&lt;/code&gt; 파일로 압축한다. 기존 `python.zip` 파일에 추가로 압축 해제가 필요한 경우에도 사용할 수 있다.&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;code&gt;.whl&lt;/code&gt; 및 &lt;code&gt;python.zip&lt;/code&gt; 파일 업로드 지원&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 기존 &lt;code&gt;python.zip&lt;/code&gt; 파일과 병합 가능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 업로드한 &lt;code&gt;.whl&lt;/code&gt; 파일을 자동으로 &lt;code&gt;python/&lt;/code&gt; 폴더에 정리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;code&gt;python.zip&lt;/code&gt; 파일이 이미 존재하면 덮어쓰기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 처리 완료 후 업로드된 파일 목록 초기화 및 원본 &lt;code&gt;.whl&lt;/code&gt; 파일 삭제&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;사용법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. `tkinterdnd2` 설치 (`pip install tkinterdnd2`)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 프로그램 실행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. &lt;code&gt;.whl&lt;/code&gt; 또는 &lt;code&gt;python.zip&lt;/code&gt; 파일을 드래그 앤 드롭하거나 직접 선택하여 업로드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 파일 목록에서 확인 후 &lt;b&gt;Process Files&lt;/b&gt; 버튼 클릭&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. &lt;code&gt;python.zip&lt;/code&gt; 파일이 생성되면 자동으로 파일 정리&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주의사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 기존 &lt;code&gt;python.zip&lt;/code&gt; 파일이 있으면 자동으로 덮어쓴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 파일 처리가 완료되면 원본 &lt;code&gt;.whl&lt;/code&gt; 파일이 삭제된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 코드&lt;/h2&gt;
&lt;pre id=&quot;code_1742255246515&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import tkinter as tk
from tkinter import filedialog, messagebox, ttk
import zipfile
import os
import tempfile
from tkinterdnd2 import DND_FILES, TkinterDnD

class WheelPackagerApp:
    def __init__(self, root):
        self.root = root
        self.root.title(&quot;Wheel File Packager&quot;)
        self.root.geometry(&quot;600x400&quot;)
        
        # 선택한 파일 리스트
        self.selected_files = []
        
        self.python_zip_name = &quot;python.zip&quot;
        self.python_dir = &quot;python&quot;
        
        # 드래그 &amp;amp; 드랍 기능
        self.root.drop_target_register(DND_FILES)
        self.root.dnd_bind('&amp;lt;&amp;lt;Drop&amp;gt;&amp;gt;', self.on_drop)
        
        # 메인 컨테이너
        main_container = ttk.Frame(root, padding=&quot;10&quot;)
        main_container.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
        
        root.columnconfigure(0, weight=1)
        root.rowconfigure(0, weight=1)
        main_container.columnconfigure(0, weight=1)
        
        ttk.Label(main_container, text=&quot;Selected Files:&quot;).grid(row=0, column=0, sticky=tk.W)
        
        # 파일 목록 트리뷰
        self.tree = ttk.Treeview(main_container, columns=(&quot;Path&quot;,), show=&quot;tree headings&quot;)
        self.tree.heading(&quot;Path&quot;, text=&quot;File Path&quot;)
        self.tree.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
        main_container.rowconfigure(1, weight=1)
        
        # 스크롤바
        scrollbar = ttk.Scrollbar(main_container, orient=tk.VERTICAL, command=self.tree.yview)
        scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S))
        self.tree.configure(yscrollcommand=scrollbar.set)
        
        # 버튼 프레임
        button_frame = ttk.Frame(main_container)
        button_frame.grid(row=2, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
        button_frame.columnconfigure(1, weight=1)
        
        # 버튼 추가
        self.upload_btn = ttk.Button(button_frame, text=&quot;Upload Files&quot;, command=self.upload_files)
        self.upload_btn.grid(row=0, column=0, padx=5)
        
        self.clear_btn = ttk.Button(button_frame, text=&quot;Clear Selected&quot;, command=self.clear_selected)
        self.clear_btn.grid(row=0, column=1, padx=5)
        
        self.clear_all_btn = ttk.Button(button_frame, text=&quot;Clear All&quot;, command=self.clear_all)
        self.clear_all_btn.grid(row=0, column=2, padx=5)
        
        self.process_btn = ttk.Button(button_frame, text=&quot;Process Files&quot;, command=self.process_and_create_zip)
        self.process_btn.grid(row=0, column=3, padx=5)
        
        # 프로그레스 바
        self.progress_var = tk.DoubleVar()
        self.progress = ttk.Progressbar(main_container, mode='determinate', variable=self.progress_var)
        self.progress.grid(row=3, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(10, 0))
        
        # 상태 표시 라벨
        self.status_label = ttk.Label(main_container, text=&quot;&quot;)
        self.status_label.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(5, 0))
        
        self.tree.bind('&amp;lt;&amp;lt;TreeviewSelect&amp;gt;&amp;gt;', self.on_select)
        
        self.update_buttons()

    def on_drop(self, event):
        files = self.root.tk.splitlist(event.data)
        for file in files:
            if file.endswith(('.whl', '.zip')):  # zip 및 whl 파일만 허용
                self.add_file(file)
        self.update_buttons()

    def add_file(self, file):
        if file not in self.selected_files:
            self.selected_files.append(file)
            filename = os.path.basename(file)
            self.tree.insert('', 'end', text=filename, values=(file,))
    
    def on_select(self, event=None):
        self.update_buttons()

    def upload_files(self):
        files = filedialog.askopenfilenames(
            title=&quot;Select files&quot;,
            filetypes=[(&quot;Wheel and Zip files&quot;, &quot;*.whl *.zip&quot;)]
        )
        for file in files:
            self.add_file(file)
        self.update_buttons()

    def clear_selected(self):
        selected_items = self.tree.selection()
        for item in selected_items:
            file_path = self.tree.item(item)['values'][0]
            self.selected_files.remove(file_path)
            self.tree.delete(item)
        self.update_buttons()

    def clear_all(self):
        self.tree.delete(*self.tree.get_children())
        self.selected_files.clear()
        self.update_buttons()

    def update_buttons(self):
        has_files = bool(self.selected_files)
        self.process_btn.config(state='normal' if has_files else 'disabled')
        self.clear_btn.config(state='normal' if self.tree.selection() else 'disabled')
        self.clear_all_btn.config(state='normal' if has_files else 'disabled')

    def update_progress(self, value, text):
        self.progress_var.set(value)
        self.status_label.config(text=text)
        self.root.update_idletasks()

    def extract_zip_content(self, zip_file, destination):
        with zipfile.ZipFile(zip_file, 'r') as zip_ref:
            file_list = zip_ref.namelist()
            
            # python.zip 파일 처리 - 내부에 python 디렉토리가 있는지 확인
            if any(name.startswith('python/') for name in file_list):
                # python 폴더 내용만 추출
                for file_info in zip_ref.infolist():
                    if file_info.filename.startswith('python/'):
                        extracted_path = file_info.filename[len('python/'):]
                        if extracted_path:  # 빈 경로 방지
                            source = zip_ref.read(file_info.filename)
                            target_path = os.path.join(destination, extracted_path)
                            
                            # 디렉토리면 생성
                            if file_info.filename.endswith('/'):
                                os.makedirs(target_path, exist_ok=True)
                            else:
                                # 파일의 디렉토리가 없으면 생성
                                os.makedirs(os.path.dirname(target_path), exist_ok=True)
                                with open(target_path, 'wb') as f:
                                    f.write(source)
            else:
                # 일반 zip, whl 파일은 그대로 추출
                zip_ref.extractall(destination)

    def process_and_create_zip(self):
        if not self.selected_files:
            return

        self.upload_btn.config(state='disabled')
        self.clear_btn.config(state='disabled')
        self.clear_all_btn.config(state='disabled')
        self.process_btn.config(state='disabled')

        try:
            with tempfile.TemporaryDirectory() as temp_dir:
                final_python_dir = os.path.join(temp_dir, self.python_dir)
                os.makedirs(final_python_dir, exist_ok=True)

                total_files = len(self.selected_files)
                for idx, file in enumerate(self.selected_files, start=1):
                    self.update_progress((idx / total_files) * 80, f&quot;Processing {os.path.basename(file)}...&quot;)
                    
                    # zip 또는 whl 파일 처리
                    if file.endswith('.zip'):
                        self.extract_zip_content(file, final_python_dir)
                    else:  # whl 파일
                        with zipfile.ZipFile(file, 'r') as zip_ref:
                            zip_ref.extractall(final_python_dir)

                self.update_progress(90, &quot;Creating final python.zip file...&quot;)

                output_zip_path = os.path.join(os.getcwd(), self.python_zip_name)
                with zipfile.ZipFile(output_zip_path, 'w', zipfile.ZIP_DEFLATED) as zip_ref:
                    for root, _, files in os.walk(temp_dir):
                        for file in files:
                            file_path = os.path.join(root, file)
                            arcname = os.path.relpath(file_path, temp_dir)
                            zip_ref.write(file_path, arcname)

                self.update_progress(100, &quot;Processing complete!&quot;)

                # 기존 업로드된 파일 리스트 및 whl 삭제
                for file in self.selected_files:
                    if file.endswith(&quot;.whl&quot;):
                        try:
                            os.remove(file)
                        except:
                            pass

                self.clear_all()

                messagebox.showinfo(&quot;Success&quot;, f&quot;Files processed successfully!\nOutput saved as {self.python_zip_name}&quot;)

        except Exception as e:
            messagebox.showerror(&quot;Error&quot;, f&quot;An error occurred: {str(e)}&quot;)
            self.update_progress(0, &quot;Processing failed!&quot;)

        finally:
            self.upload_btn.config(state='normal')
            self.update_buttons()
            self.update_progress(0, &quot;&quot;)

if __name__ == &quot;__main__&quot;:
    root = TkinterDnD.Tk()
    app = WheelPackagerApp(root)
    root.mainloop()&lt;/code&gt;&lt;/pre&gt;</description>
      <category>파이썬 Python/tkinter</category>
      <author>woojc</author>
      <guid isPermaLink="true">https://boksup.tistory.com/100</guid>
      <comments>https://boksup.tistory.com/100#entry100comment</comments>
      <pubDate>Tue, 18 Mar 2025 20:44:58 +0900</pubDate>
    </item>
    <item>
      <title>WSL Ubuntu에서 Vue.js 앱을 로컬호스트로 올리고 코드 수정하기</title>
      <link>https://boksup.tistory.com/99</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Vue.js 앱을 로컬호스트로 올리기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) Ubuntu 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://boksup.tistory.com/96&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2025.02.28 - [데이터 분석] - Windows에서 WSL2와 Ubuntu 설치 및 Docker 사용하기&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) Node.js 설치&lt;/h3&gt;
&lt;pre id=&quot;code_1742168883666&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt update
sudo apt install nodejs npm&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(3) Vue CLI 설치&lt;/h3&gt;
&lt;pre id=&quot;code_1742168902654&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm install -g @vue/cli&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(4) 새로운 Vue 프로젝트 생성&lt;/h3&gt;
&lt;pre id=&quot;code_1742168925868&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;vue create my-vue-app&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(5) Vue 앱 실행&lt;/h3&gt;
&lt;pre id=&quot;code_1742168943469&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;cd my-vue-app
npm run serve&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;`http://localhost:8080`로 접속하여 Vue 앱이 정상적으로 실행되고 있는지 확인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 코드 수정하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) Vim 사용&lt;/h3&gt;
&lt;pre id=&quot;code_1742169296857&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;cd my-vue-app
vim src/App.vue&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- `i` 키로 편집 모드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 코드 수정 후 `Esc` 누르고 `:wq` 입력 &amp;rarr; `Enter`로 저장 후 종료&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;h3 data-ke-size=&quot;size23&quot;&gt;(2) VS Code 이용&lt;/h3&gt;
&lt;pre id=&quot;code_1742169448227&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;cd my-vue-app
code .&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;윈도우에 VS Code가 설치되어 있다면 Ubuntu 내에서 별도 설치할 필요 없이 VS Code를 `code .`으로 간단하게 실행할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>데이터 분석</category>
      <author>woojc</author>
      <guid isPermaLink="true">https://boksup.tistory.com/99</guid>
      <comments>https://boksup.tistory.com/99#entry99comment</comments>
      <pubDate>Mon, 17 Mar 2025 18:43:29 +0900</pubDate>
    </item>
    <item>
      <title>Granger 인과관계</title>
      <link>https://boksup.tistory.com/98</link>
      <description>&lt;h1&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계(Granger Causality)란?&lt;/span&gt;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계(Granger Causality)는 시계열 데이터의 인과관계를 통계적으로 분석하는 기법이다. &lt;b&gt;&quot;변수 X가 변수 Y의 미래 값을 예측하는 데 유의미한 정보를 제공하는가?&quot;&lt;/b&gt;라는 질문에 답하기 위해 고안된 방법이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계의 핵심 개념은 &lt;b&gt;시계열의 시간적 선후 관계&lt;/b&gt;에 있다. 단순한 상관관계와는 다르게, 과거의 X 값이 Y의 미래 값을 얼마나 잘 예측하는지를 살펴본다는 점이다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계의 수학적 정의&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계는 다음의 두 개의 자기회귀(Autoregressive, AR) 모델을 비교함으로써 정의된다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;1. 기준 모델 (Baseline Model)&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;$Y_t&amp;nbsp;=&amp;nbsp;\alpha_0&amp;nbsp;+&amp;nbsp;\sum_{i=1}^{p}&amp;nbsp;\alpha_i&amp;nbsp;Y_{t-i}&amp;nbsp;+&amp;nbsp;\varepsilon_t$&lt;/span&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;span style=&quot;color: #000000;&quot;&gt;$Y_t$: 시점 $t$에서의 $Y$의 값&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;$Y_{t-i}$: 과거 $i$ 시점의 $Y$의 값&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;$\alpha_i$: $Y$의 과거 값에 대한 회귀 계수&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;$\varepsilon_t$: 노이즈&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;2. 확장 모델 (Extended Model)&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;$Y_t&amp;nbsp;=&amp;nbsp;\alpha_0&amp;nbsp;+&amp;nbsp;\sum_{i=1}^{p}&amp;nbsp;\alpha_i&amp;nbsp;Y_{t-i}&amp;nbsp;+&amp;nbsp;\sum_{j=1}^{q}&amp;nbsp;\beta_j&amp;nbsp;X_{t-j}&amp;nbsp;+&amp;nbsp;\varepsilon_t$&lt;/span&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;span style=&quot;color: #000000;&quot;&gt;$X_{t-j}$: 과거 $j$ 시점의 $X$ 값&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;$\beta_j$: $X$의 과거 값에 대한 회귀 계수&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계의 핵심 논리&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;기준 모델과 확장 모델의 성능을 비교하여, 확장 모델의 예측력이 더 높다면, $X$는 $Y$의 Granger 원인(Granger cause)이라고 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;귀무가설 $H_0$&lt;/b&gt;: $X$는 $Y$의 Granger 원인이 아니다.&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;대립가설 $H_1$&lt;/b&gt;: $X$는 $Y$의 Granger 원인이다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계의 유형&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계는 다음의 유형으로 구분할 수 있다.&lt;/span&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;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;X가 Y에 인과영향을 주고, Y는 X에 인과영향을 주지 않는 경우 (또는 그 반대의 경우)&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;의미&lt;/b&gt;: X가 Y의 미래 값을 예측하는 데 유의미한 정보를 제공하지만, Y는 X의 미래 값을 예측하지 못한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;해석&lt;/b&gt;: X가 Y의 인과요인일 가능성이 높다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;X와 Y가 서로 인과영향을 주는 경우&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;의미&lt;/b&gt;: X가 Y의 미래 값을 예측하는 데 유의미한 정보를 제공하고, Y도 X의 미래 값을 예측하는 데 유의미한 정보를 제공한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;해석&lt;/b&gt;: 제3의 외부 변수가 영향을 줬을 가능성이 있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;X와 Y가 서로 인과영향을 주지 않는 경우&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;의미&lt;/b&gt;: X가 Y의 미래 값을 예측하는 데 기여하지 않고, Y도 X의 미래 값을 예측하는 데 기여하지 않는다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;해석&lt;/b&gt;: 두 변수 간에 인과관계가 없거나, 다른 형태의 관계일 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계 검정 절차 (python 코드)&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;0. 데이터 생성&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;먼저 X와 시차가 1인 Y를 생성해준다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;import pandas as pd
import numpy as np

# 0. 데이터 생성
np.random.seed(42)
n = 200

# X는 무작위 노이즈로 생성
X = np.random.randn(n)

# Y는 X의 과거 값에 영향을 받는 형태로 생성(시차=1)
Y = np.zeros(n)
for t in range(1, n):
    Y[t] = 0.8 * Y[t-1] + 0.5 * X[t-1] + np.random.randn() * 0.5

df = pd.DataFrame({'X': X, 'Y': Y})&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;1. 데이터 전처리&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;시계열의 정상성을 확보해야 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;단위근 검정(Unit Root Test, 예: ADF Test)을 통해 정상성을 확인한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;정상성이 없으면 차분(differencing)을 통해 정상화한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from statsmodels.tsa.stattools import adfuller, grangercausalitytests

# 1. 단위근 검정 (ADF Test)
def adf_test(series):
    name = series.name
    adf, pvalue, _, _, critical_values, _ = adfuller(series)
    print(f'ADF Test for {name}:')
    print(f'ADF Statistic: {adf:.3f}')
    print(f'p-value: {pvalue:.3f}')
    print(f'Critical Values: 1%: {critical_values[&quot;1%&quot;]:.3f}, 5%: {critical_values[&quot;5%&quot;]:.3f}, 10%: {critical_values[&quot;10%&quot;]:.3f}')
    if pvalue &amp;lt; 0.05:
        print(f'{name}는 정상성(Stationarity)을 가짐\n')
    else:
        print(f'{name}는 정상성이 없음. 차분이 필요함\n')

adf_test(df['X'])
adf_test(df['Y'])&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;ADF Test for X:
ADF Statistic: -14.744
p-value: 0.000
Critical Values: 1%: -3.464, 5%: -2.876, 10%: -2.575
X는 정상성(Stationarity)을 가짐

ADF Test for Y:
ADF Statistic: -3.527
p-value: 0.007
Critical Values: 1%: -3.464, 5%: -2.876, 10%: -2.575
Y는 정상성(Stationarity)을 가짐&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;검정 결과 X와 Y 모두 정상성을 가져 차분을 하지 않아도 된다. 정상성을 지니지 않아 차분이 필요할 경우 아래와 같은 방법으로 확인하고, 정상성을 가지면 `df_diff`를 이용하여 검정을 이어간다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;# 1차 차분 후 정상성 확인
df_diff = df.diff().dropna()
adf_test(df_diff['X'], 'X (1st Difference)')
adf_test(df_diff['Y'], 'Y (1st Difference)')&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;2. 시차(lag) 선택&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;몇 시차(lag)까지 고려할지를 결정해야 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Akaike 정보 기준(AIC), Bayesian 정보 기준(BIC) 등을 활용하여 최적의 시차를 선택한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;from statsmodels.regression.linear_model import OLS
import statsmodels.api as sm

# 2. 최적 시차(lag) 선택 (AIC, BIC 계산)
def select_best_lag(y, x, max_lag=10):
    &quot;&quot;&quot; AIC와 BIC를 기반으로 최적 시차를 찾는 함수 &quot;&quot;&quot;
    aic_values = []
    bic_values = []
    lags = list(range(1, max_lag + 1))

    for lag in lags:
        # 시차 데이터 생성
        df_lagged = df.copy()
        df_lagged[f'X_lag{lag}'] = df['X'].shift(lag)
        df_lagged = df_lagged.dropna()

        # OLS 회귀 분석 (Y ~ X_lag)
        X_lagged = sm.add_constant(df_lagged[f'X_lag{lag}'])
        model = OLS(df_lagged['Y'], X_lagged).fit()

        # AIC, BIC 저장
        aic_values.append(model.aic)
        bic_values.append(model.bic)

    # 최적 시차 선택
    best_aic_lag = lags[np.argmin(aic_values)]
    best_bic_lag = lags[np.argmin(bic_values)]

    return best_aic_lag, best_bic_lag

# 최적 시차 선택
best_aic_lag, best_bic_lag = select_best_lag(df['Y'], df['X'])

print(f&quot;최적 시차 선택:&quot;)
print(f&quot;AIC 기준 최적 시차: {best_aic_lag}&quot;)
print(f&quot;BIC 기준 최적 시차: {best_bic_lag}\n&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;최적 시차 선택:
AIC 기준 최적 시차: 1
BIC 기준 최적 시차: 1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;시차를 1로 설정했으니 당연하겠지만 최적 시차는 1로 선정이 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;3. Granger 인과관계 검정&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;기준 모델과 확장 모델의 성능을 비교한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;F-검정&lt;/b&gt;을 수행해 귀무가설을 기각할 수 있는지를 판단한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;테스트 시 영향을 받는 인자를 앞에 두고 실행&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;X &amp;rarr; Y를 확인 = `grangercausalitytests(df[['Y', 'X']], maxlag=max_lag)`)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;lua&quot;&gt;&lt;code&gt;# 3. Granger 인과관계 검정 (최적 시차 적용)
max_lag = max(best_aic_lag, best_bic_lag) # 보수적으로 큰 lag 선정
print(f&quot;Granger Causality Test (최대 시차 {max_lag} 적용)&quot;)
granger_results = grangercausalitytests(df[['Y', 'X']], maxlag=max_lag)&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Granger Causality Test (최대 시차 1 적용)

Granger Causality
number of lags (no zero) 1
ssr based F test:         F=216.2652, p=0.0000  , df_denom=196, df_num=1
ssr based chi2 test:   chi2=219.5754, p=0.0000  , df=1
likelihood ratio test: chi2=147.9669, p=0.0000  , df=1
parameter F test:         F=216.2652, p=0.0000  , df_denom=196, df_num=1&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;&lt;span style=&quot;color: #000000;&quot;&gt;반대로 Y &amp;rarr; X도 확인&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;lua&quot;&gt;&lt;code&gt;granger_results = grangercausalitytests(df[['X', 'Y']], maxlag=max_lag)&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Granger Causality
number of lags (no zero) 1
ssr based F test:         F=0.8887  , p=0.3470  , df_denom=196, df_num=1
ssr based chi2 test:   chi2=0.9023  , p=0.3422  , df=1
likelihood ratio test: chi2=0.9003  , p=0.3427  , df=1
parameter F test:         F=0.8887  , p=0.3470  , df_denom=196, df_num=1&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계 결과 해석&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;X &amp;rarr; Y의 F test의 p-value가 0.0000&lt;/b&gt;로 매우 낮기 때문에, 귀무가설 $H_0$ (X는 Y의 Granger 원인이 아니다)를 기각할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;한편 &lt;b&gt;Y &amp;rarr; X의 F test의 p-value가 0.3470&lt;/b&gt;로 높기 때문에, 귀무가설 $H_0$를 기각할 수 없다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;앞선 인과관계 유형 중 1번에 해당한다. 따라서 &lt;b&gt;X가 Y의 Granger 원인&lt;/b&gt;임을 확인할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;▼ 검정 결과 종류&lt;/span&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;1. F-검정 (ssr based F test, parameter F test)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;(1) SSR 기반 F-검정 (ssr based F test)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 귀무가설 $H_0$가 성립할 때, 기준 모델과 확장 모델의 SSR 차이를 검정&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 귀무가설 $H_0$ = $SSR_{baseline} \approx SSR_{extended}$&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 대립가설 $H_1$ =&amp;nbsp;&lt;span style=&quot;text-align: start;&quot;&gt;$SSR_{baseline} &amp;gt; SSR_{extended}$&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;(2) Parameter F-검정 (parameter F test)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 회귀 모델의 계수(parameter)가 0인지 여부를 검정&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 귀무가설 $H_0$ = $(\beta_1 = \beta_2 = \cdots = \beta_q = 0)$&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 대립가설 $H_1$ =&amp;nbsp;$&amp;nbsp;\exists&amp;nbsp;\,&amp;nbsp;j&amp;nbsp;\text{&amp;nbsp;such&amp;nbsp;that&amp;nbsp;}&amp;nbsp;\beta_j&amp;nbsp;\neq&amp;nbsp;0&amp;nbsp;$&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;2. 카이제곱 검정 (ssr based chi2 test)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 기준 모델과 확장 모델 간의 SSR 차이를 검정하며 샘플 크기가 매우 클 때 더 안정적인 결과를 제공&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: SSR 기반 F-검정과 귀무가설, 대립가설이 동일하며, 통계량만 카이제곱 검정으로 바꾼 것과 같음&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;3. 우도비 검정 (likelihood ratio test)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 기준 모델과 확장 모델의 로그 우도(log-likelihood) 차이를 계산하여 두 모델이 얼마나 다른지를 평가&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 귀무가설 $H_0$ = $\log L_{baseline} \approx \log L_{extended}$&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;: 대립가설 $H_1$ = $\log L_{baseline} &amp;lt; \log L_{extended}$&lt;/span&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&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;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계의 해석과 한계&lt;/span&gt;&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;Granger 인과관계는 진정한 인과관계를 의미하지 않는다&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 인과관계는 통계적 개념으로, 실제 원인과 결과의 관계와는 다르다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;외생 변수 문제&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;제3의 외부 변수가 두 변수에 동시에 영향을 미치면, 두 변수 사이의 인과관계를 잘못 해석할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;시차(lag) 설정의 중요성&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;잘못된 시차 설정은 Granger 인과관계 검정의 결과를 왜곡할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;비선형 관계를 반영하지 못함&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Granger 검정은 &lt;b&gt;선형 회귀 모델 기반&lt;/b&gt;으로 동작하기 때문에, 두 변수 간의 관계가 &lt;b&gt;비선형적(non-linear)&lt;/b&gt;일 경우 올바르게 인과관계를 평가하지 못할 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>데이터 분석</category>
      <author>woojc</author>
      <guid isPermaLink="true">https://boksup.tistory.com/98</guid>
      <comments>https://boksup.tistory.com/98#entry98comment</comments>
      <pubDate>Wed, 12 Mar 2025 20:52:16 +0900</pubDate>
    </item>
    <item>
      <title>Windows에서 Ollama + Open WebUI 이용하기 (Docker 이용)</title>
      <link>https://boksup.tistory.com/97</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Ollama&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ollama는 GPT-4o나 Gemini와 같은 LLM을 로컬 컴퓨터에서 돌릴 수 있도록 돕는 도구이다. 물론 오픈 소스로 풀린 LLM만 사용할 수 있긴 하지만 요즘엔 Llama나 Gemma 같은 성능이 좋은 오픈 소스 모델도 많이 등장했기 때문에 더욱 떠오르고 있는 툴이다.&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;&lt;b&gt;1. 보안 강화&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;딥시크 사태에서 알 수 있듯, 웹을 통해 사용하는 LLM은 내 데이터가 유출될 가능성이 있다. 딥시크 뿐만 아니라 OpenAI의 ChatGPT도 별도 설정을 하지 않을 경우 대화 내용을 모델 학습에 사용하게 되며, 임시 채팅을 하더라도 최대 30일동안 채팅 내용을 보관하게 된다. 혹시라도 OpenAI가 해킹을 당한다면 대화 내용이 유출될 가능성도 있다는 의미다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tq61E/btsMxGPerC6/zbwYqYupT4VDWHoYkDVfHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tq61E/btsMxGPerC6/zbwYqYupT4VDWHoYkDVfHK/img.png&quot; data-origin-width=&quot;429&quot; data-origin-height=&quot;216&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;53.39&quot; style=&quot;width: 52.7652%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tq61E/btsMxGPerC6/zbwYqYupT4VDWHoYkDVfHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Ftq61E%2FbtsMxGPerC6%2FzbwYqYupT4VDWHoYkDVfHK%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;429&quot; height=&quot;216&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uimUW/btsMAWvBW7W/nuqUHmyfgsmB8ozpcA6xu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uimUW/btsMAWvBW7W/nuqUHmyfgsmB8ozpcA6xu0/img.png&quot; data-origin-width=&quot;548&quot; data-origin-height=&quot;316&quot; data-is-animation=&quot;false&quot; style=&quot;width: 46.072%;&quot; data-widthpercent=&quot;46.61&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uimUW/btsMAWvBW7W/nuqUHmyfgsmB8ozpcA6xu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuimUW%2FbtsMAWvBW7W%2FnuqUHmyfgsmB8ozpcA6xu0%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;548&quot; height=&quot;316&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;ChatGPT의 내 데이터 사용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히나 기업의 경우 내부 기밀이나 코드가 유출될 가능성이 있어 ChatGPT와 같은 LLM 사이트에 접근 자체를 막았다는 뉴스도 나왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 비용 절감&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임을 위해 성능 좋은 GPU를 사놓고 게임을 안 하게 될 때, 이런 LLM을 돌리면 좋은 선택이 될 것이다. ChatGPT도 GPT-4o와 같은 수준의 모델을 사용하려면 구독을 해야 하고, API를 통해서 사용하려고 해도 어쨌든 비용이 발생한다. 이럴 때 남는 GPU로 GPT-4o에 버금가는 오픈 소스 모델을 사용하면 전기료만 지불하면 된다! 물론 이런 상황은 매우 제한적일 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ollama 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본격적으로 Ollama 설치를 해보자.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ollama.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ollama.com/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;444&quot; data-origin-height=&quot;454&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/F1QnI/btsMyFWlgrV/qcXF8uMee1loBQAKugdGI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/F1QnI/btsMyFWlgrV/qcXF8uMee1loBQAKugdGI0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/F1QnI/btsMyFWlgrV/qcXF8uMee1loBQAKugdGI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FF1QnI%2FbtsMyFWlgrV%2FqcXF8uMee1loBQAKugdGI0%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;444&quot; height=&quot;454&quot; data-origin-width=&quot;444&quot; data-origin-height=&quot;454&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다운로드! 해도 괜찮지만 우리는 Ubuntu 내에서 Ollama를 사용할 것이다. (이후 Open WebUI를 위해서이다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ubuntu 설치 방법은 다음 링크를 참고하자. 만약 Ubuntu를 이미 사용하고 있다면, 22.04 버전인지를 확인해주자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a style=&quot;background-color: #e6f5ff; color: #0070d1; text-align: start;&quot; href=&quot;https://boksup.tistory.com/96&quot;&gt;2025.02.28 - [데이터 분석] - Windows에서 WSL2와 Ubuntu 설치 및 Docker 사용하기&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 github에 들어가보면 Linux 설치 방법이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`curl&amp;nbsp;-fsSL&amp;nbsp;&lt;a href=&quot;https://ollama.com/install.sh&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ollama.com/install.sh&lt;/a&gt; |&amp;nbsp;sh`&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;987&quot; data-origin-height=&quot;265&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GWeGr/btsMAsuTO6a/jJzvWm7ooBNPa2Ke9k5bJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GWeGr/btsMAsuTO6a/jJzvWm7ooBNPa2Ke9k5bJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GWeGr/btsMAsuTO6a/jJzvWm7ooBNPa2Ke9k5bJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGWeGr%2FbtsMAsuTO6a%2FjJzvWm7ooBNPa2Ke9k5bJ1%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;987&quot; height=&quot;265&quot; data-origin-width=&quot;987&quot; data-origin-height=&quot;265&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`ollama` 명령어를 실행하여 아래와 같은 내용이 나온다면 정상적으로 설치된 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;616&quot; data-origin-height=&quot;435&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bE4RYC/btsMzuz7K7I/sGcs6mAH8OqpS6P7kNmdwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bE4RYC/btsMzuz7K7I/sGcs6mAH8OqpS6P7kNmdwK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bE4RYC/btsMzuz7K7I/sGcs6mAH8OqpS6P7kNmdwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbE4RYC%2FbtsMzuz7K7I%2FsGcs6mAH8OqpS6P7kNmdwK%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;616&quot; height=&quot;435&quot; data-origin-width=&quot;616&quot; data-origin-height=&quot;435&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ollama 사용 (Ubuntu)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용법은 매우 간단하다. `ollama run 모델명`을 입력하면 된다. 여기서 사용할 수 있는 모델 목록은 Ollama 홈페이지 좌측 상단의 Models을 클릭해보면 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;633&quot; data-origin-height=&quot;802&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o103a/btsMzri3E4s/EzhLkW80KxKNXRl3Kkz8G1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o103a/btsMzri3E4s/EzhLkW80KxKNXRl3Kkz8G1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o103a/btsMzri3E4s/EzhLkW80KxKNXRl3Kkz8G1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo103a%2FbtsMzri3E4s%2FEzhLkW80KxKNXRl3Kkz8G1%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;633&quot; height=&quot;802&quot; data-origin-width=&quot;633&quot; data-origin-height=&quot;802&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 유명한 딥시크부터 llama 등 다양한 모델이 지원되고 있다.&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;둘러보니 LG에서 제작한 exaone이 제공되고 있었다. 크게 2.4b, 7.8b, 32b 세 종류가 있는데 실험을 위해 가장 작은 모델을 써보기로 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxJfdK/btsMAF1VB5q/jtyOopK8FvFTCcZYflkuN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxJfdK/btsMAF1VB5q/jtyOopK8FvFTCcZYflkuN1/img.png&quot; data-origin-width=&quot;471&quot; data-origin-height=&quot;187&quot; data-is-animation=&quot;false&quot; style=&quot;width: 67.1111%; margin-right: 10px;&quot; data-widthpercent=&quot;67.9&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxJfdK/btsMAF1VB5q/jtyOopK8FvFTCcZYflkuN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxJfdK%2FbtsMAF1VB5q%2FjtyOopK8FvFTCcZYflkuN1%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;471&quot; height=&quot;187&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/erl73m/btsMxI7kUKw/nmEjjKZrXbr6vxoy5ImZK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/erl73m/btsMxI7kUKw/nmEjjKZrXbr6vxoy5ImZK1/img.png&quot; data-origin-width=&quot;256&quot; data-origin-height=&quot;215&quot; data-is-animation=&quot;false&quot; style=&quot;width: 31.7261%;&quot; data-widthpercent=&quot;32.1&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/erl73m/btsMxI7kUKw/nmEjjKZrXbr6vxoy5ImZK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Ferl73m%2FbtsMxI7kUKw%2FnmEjjKZrXbr6vxoy5ImZK1%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;256&quot; height=&quot;215&quot;/&gt;&lt;/span&gt;&lt;/div&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;`ollama run exaone3.5:2.4b` 명령어를 실행하면 모델을 우선 다운받고, 완료되면 모델이 실행이 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;867&quot; data-origin-height=&quot;180&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lCu8M/btsMx3je4xh/Ph4MPKZcakqTjitXwtut9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lCu8M/btsMx3je4xh/Ph4MPKZcakqTjitXwtut9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lCu8M/btsMx3je4xh/Ph4MPKZcakqTjitXwtut9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlCu8M%2FbtsMx3je4xh%2FPh4MPKZcakqTjitXwtut9k%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;867&quot; height=&quot;180&quot; data-origin-width=&quot;867&quot; data-origin-height=&quot;180&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1058&quot; data-origin-height=&quot;118&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d3ZUNA/btsMAKB5ORs/wiB7gxfctl6QZRkngPPLtk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d3ZUNA/btsMAKB5ORs/wiB7gxfctl6QZRkngPPLtk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d3ZUNA/btsMAKB5ORs/wiB7gxfctl6QZRkngPPLtk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd3ZUNA%2FbtsMAKB5ORs%2FwiB7gxfctl6QZRkngPPLtk%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;1058&quot; height=&quot;118&quot; data-origin-width=&quot;1058&quot; data-origin-height=&quot;118&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 Ubuntu 내에서 대화를 이어가게 된다. 생각보다 잘 대답하는 것으로 보인다.&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;`/bye` 명령어를 입력하면 대화를 종료한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Open WebUI&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ollama의 단점은 &quot;이쁘지 않다&quot;는 것이다. LLM에게 코드를 작성해달라고 시키거나, 문서 작성 등을 시켰을 때 가독성이 좋지 않다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1036&quot; data-origin-height=&quot;123&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uk5Kj/btsMyjlTyMx/B2kw7vIx1k888bH5OjEKk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uk5Kj/btsMyjlTyMx/B2kw7vIx1k888bH5OjEKk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uk5Kj/btsMyjlTyMx/B2kw7vIx1k888bH5OjEKk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fuk5Kj%2FbtsMyjlTyMx%2FB2kw7vIx1k888bH5OjEKk0%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;1036&quot; height=&quot;123&quot; data-origin-width=&quot;1036&quot; data-origin-height=&quot;123&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 Ollama를 GUI를 통해 제공하는 툴이 바로 Open WebUI이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/open-webui/open-webui&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/open-webui/open-webui&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Open WebUI 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 github에 다양한 설치 방법이 제공되고 있는데, 본인 입맛에 맞게 선택하면 될 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ollama까지 하나의 컨테이너로 감싸서 사용하고 싶다면 CPU/GPU에 따라 아래 명령어로 설치하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(CPU)&lt;/p&gt;
&lt;pre id=&quot;code_1740741631178&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker run -d -p 3000:8080 -v ollama:/root/.ollama -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:ollama&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(GPU)&lt;/p&gt;
&lt;pre id=&quot;code_1740741612217&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker run -d -p 3000:8080 --gpus=all -v ollama:/root/.ollama -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:ollama&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;나 같은 경우 Ubuntu에 Ollama를 이미 설치했고 Nvidia GPU를 사용하고 있기 때문에 아래 명령어를 통해 open-webui를 사용할 것이다.&lt;/s&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1740741668713&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker run -d -p 3000:8080 --gpus all --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:cuda&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;open webui에서 Ollama가 응답하지 않는 오류가 있어, Ollama까지 컨테이너로 감싸는 버전으로 설치했다. 이 경우 용량을 위해 설치했던 Exaone 모델과 Ollama를 삭제해주자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`ollama rm exaone3.5:2.4b`&lt;/p&gt;
&lt;pre id=&quot;code_1740744530612&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo systemctl stop ollama
sudo systemctl disable ollama
sudo rm /etc/systemd/system/ollama.service
sudo rm $(which ollama)&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;h4 data-ke-size=&quot;size20&quot;&gt;실행 중 GPU 오류 해결&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 GPU가 있음에도 `docker: Error response from daemon: could not select device driver &quot;&quot; with capabilities: [[gpu]]` 오류가 발생한다면 `nvidia-container-toolkit`을 설치하자.&lt;/p&gt;
&lt;pre id=&quot;code_1740742149874&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
sudo systemctl restart docker&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;또 다시 `E: Unable to locate package nvidia-container-toolkit` 오류가 발생한다면 툴킷 리포지토리를 추가하자.&lt;/p&gt;
&lt;pre id=&quot;code_1740742237406&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list&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;1086&quot; data-origin-height=&quot;152&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/E3z0H/btsMx1TlnKn/7oTh1b8IoDMqDj4kNGjuSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/E3z0H/btsMx1TlnKn/7oTh1b8IoDMqDj4kNGjuSK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/E3z0H/btsMx1TlnKn/7oTh1b8IoDMqDj4kNGjuSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FE3z0H%2FbtsMx1TlnKn%2F7oTh1b8IoDMqDj4kNGjuSK%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;1086&quot; height=&quot;152&quot; data-origin-width=&quot;1086&quot; data-origin-height=&quot;152&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 결과가 나왔다면 툴킷이 잘 추가된 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 이런 결과가 아니라 html 내용이 등장한다면 Ubuntu 24.04의 문제이다. (Nvidia에서 아직 지원을 안 한다는 것 같다?) 나는 첫 설치 시도 때 실패하여 Ubuntu 22.04를 사용하는 것으로 해결하였다.&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;그럼 다시 툴킷을 설치하고 docker를 재시작해주자.&lt;/p&gt;
&lt;pre id=&quot;code_1740742533451&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1740743206632&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo systemctl restart docker&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Open WebUI 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 docker는 만들어졌기 때문에 `docker ps -a`를 해보면 Open WebUI가 생성되었을 것이다. (STATUS: Created)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1059&quot; data-origin-height=&quot;40&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caotWe/btsMxHtRtBC/EJMIymKQxoYNGT8ScB2oE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caotWe/btsMxHtRtBC/EJMIymKQxoYNGT8ScB2oE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caotWe/btsMxHtRtBC/EJMIymKQxoYNGT8ScB2oE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcaotWe%2FbtsMxHtRtBC%2FEJMIymKQxoYNGT8ScB2oE0%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;1059&quot; height=&quot;40&quot; data-origin-width=&quot;1059&quot; data-origin-height=&quot;40&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`docker start open-webui`로 깨워주자. STATUS가 health: starting에서 healthy가 될 때까지 기다려주자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tfF8a/btsMyWcHam8/kLPwlgOfYhR1gWkU4Ar6d1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tfF8a/btsMyWcHam8/kLPwlgOfYhR1gWkU4Ar6d1/img.png&quot; data-origin-width=&quot;291&quot; data-origin-height=&quot;46&quot; data-is-animation=&quot;false&quot; style=&quot;width: 52.2327%; margin-right: 10px;&quot; data-widthpercent=&quot;52.85&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tfF8a/btsMyWcHam8/kLPwlgOfYhR1gWkU4Ar6d1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtfF8a%2FbtsMyWcHam8%2FkLPwlgOfYhR1gWkU4Ar6d1%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;291&quot; height=&quot;46&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVCHEw/btsMx2EATPC/Aku8SMqRkk9sKWYTyFdo4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVCHEw/btsMx2EATPC/Aku8SMqRkk9sKWYTyFdo4k/img.png&quot; data-origin-width=&quot;254&quot; data-origin-height=&quot;45&quot; data-is-animation=&quot;false&quot; style=&quot;width: 46.6045%;&quot; data-widthpercent=&quot;47.15&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVCHEw/btsMx2EATPC/Aku8SMqRkk9sKWYTyFdo4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVCHEw%2FbtsMx2EATPC%2FAku8SMqRkk9sKWYTyFdo4k%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;254&quot; height=&quot;45&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 http://localhost:3000/ 를 열어 잘 실행되는지 확인해주자.&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;1915&quot; data-origin-height=&quot;921&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0vpkw/btsMAq4UVf7/MSYPPVram1HjS1e1xtVN70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0vpkw/btsMAq4UVf7/MSYPPVram1HjS1e1xtVN70/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0vpkw/btsMAq4UVf7/MSYPPVram1HjS1e1xtVN70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0vpkw%2FbtsMAq4UVf7%2FMSYPPVram1HjS1e1xtVN70%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;1915&quot; height=&quot;921&quot; data-origin-width=&quot;1915&quot; data-origin-height=&quot;921&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 페이지가 나오면 잘 된 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;429&quot; data-origin-height=&quot;333&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cUkWvz/btsMxFiwR6i/u49hDPkd1p7Q28sSWZ6fK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cUkWvz/btsMxFiwR6i/u49hDPkd1p7Q28sSWZ6fK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cUkWvz/btsMxFiwR6i/u49hDPkd1p7Q28sSWZ6fK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcUkWvz%2FbtsMxFiwR6i%2Fu49hDPkd1p7Q28sSWZ6fK0%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;429&quot; height=&quot;333&quot; data-origin-width=&quot;429&quot; data-origin-height=&quot;333&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름, 이메일, 비밀번호는 대충 작성하고 넘어가자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;528&quot; data-origin-height=&quot;239&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bojQHd/btsMAi0eKVt/nQ2DVjlqdYWxqOFGd1t2J1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bojQHd/btsMAi0eKVt/nQ2DVjlqdYWxqOFGd1t2J1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bojQHd/btsMAi0eKVt/nQ2DVjlqdYWxqOFGd1t2J1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbojQHd%2FbtsMAi0eKVt%2FnQ2DVjlqdYWxqOFGd1t2J1%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;528&quot; height=&quot;239&quot; data-origin-width=&quot;528&quot; data-origin-height=&quot;239&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좌측 상단 모델 선택에서 Ollama의 모델들을 다운받을 수 있다. 다만 검색 기능은 없으니 직접 모델명을 입력해주어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 Ollama의 모델 뿐만 아니라 Hugging Face에 등록되어있는 gguf 모델들도 사용할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNVRsf/btsMzhAS2IT/0GkfZSqnaPt7qiX8kh8dT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNVRsf/btsMzhAS2IT/0GkfZSqnaPt7qiX8kh8dT0/img.png&quot; data-origin-width=&quot;269&quot; data-origin-height=&quot;246&quot; data-is-animation=&quot;false&quot; style=&quot;width: 14.1273%; margin-right: 10px;&quot; data-widthpercent=&quot;14.29&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNVRsf/btsMzhAS2IT/0GkfZSqnaPt7qiX8kh8dT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNVRsf%2FbtsMzhAS2IT%2F0GkfZSqnaPt7qiX8kh8dT0%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;269&quot; height=&quot;246&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqCiMP/btsMAHS3GTp/soAFtW48lqTOvhTuE2yErK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqCiMP/btsMAHS3GTp/soAFtW48lqTOvhTuE2yErK/img.png&quot; data-origin-width=&quot;577&quot; data-origin-height=&quot;88&quot; data-is-animation=&quot;false&quot; style=&quot;width: 84.7099%;&quot; data-widthpercent=&quot;85.71&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqCiMP/btsMAHS3GTp/soAFtW48lqTOvhTuE2yErK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqCiMP%2FbtsMAHS3GTp%2FsoAFtW48lqTOvhTuE2yErK%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;577&quot; height=&quot;88&quot;/&gt;&lt;/span&gt;&lt;/div&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;난 deepseek-r1과 llama3.2 두 개를 다운받았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;519&quot; data-origin-height=&quot;276&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cMvP5B/btsMAHFuDqq/PNlMK3OPgq5GV57yQswsm1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cMvP5B/btsMAHFuDqq/PNlMK3OPgq5GV57yQswsm1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cMvP5B/btsMAHFuDqq/PNlMK3OPgq5GV57yQswsm1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcMvP5B%2FbtsMAHFuDqq%2FPNlMK3OPgq5GV57yQswsm1%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;519&quot; height=&quot;276&quot; data-origin-width=&quot;519&quot; data-origin-height=&quot;276&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDhA5y/btsMyTAhCdA/J2EmFzkkDO6r0JPbN8SH0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDhA5y/btsMyTAhCdA/J2EmFzkkDO6r0JPbN8SH0K/img.png&quot; data-origin-width=&quot;1030&quot; data-origin-height=&quot;645&quot; data-is-animation=&quot;false&quot; style=&quot;width: 43.6484%; margin-right: 10px;&quot; data-widthpercent=&quot;44.16&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDhA5y/btsMyTAhCdA/J2EmFzkkDO6r0JPbN8SH0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDhA5y%2FbtsMyTAhCdA%2FJ2EmFzkkDO6r0JPbN8SH0K%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;1030&quot; height=&quot;645&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/7wHZC/btsMArbGoMq/ddUtohkLv8PqYtJi8ESJy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/7wHZC/btsMArbGoMq/ddUtohkLv8PqYtJi8ESJy1/img.png&quot; data-origin-width=&quot;951&quot; data-origin-height=&quot;471&quot; data-is-animation=&quot;false&quot; style=&quot;width: 55.1888%;&quot; data-widthpercent=&quot;55.84&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/7wHZC/btsMArbGoMq/ddUtohkLv8PqYtJi8ESJy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F7wHZC%2FbtsMArbGoMq%2FddUtohkLv8PqYtJi8ESJy1%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;951&quot; height=&quot;471&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;좌: llama3.2, 우: deepseek-r1&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;llama나 deepseek나 경량화 모델은 한국어가 참 아쉽다.&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;그와중에 나의 1660s GPU는 혹사당하고 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;452&quot; data-origin-height=&quot;343&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnEvzL/btsMytu9Jyj/44aGlRoE72auphxANpKyC1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnEvzL/btsMytu9Jyj/44aGlRoE72auphxANpKyC1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnEvzL/btsMytu9Jyj/44aGlRoE72auphxANpKyC1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnEvzL%2FbtsMytu9Jyj%2F44aGlRoE72auphxANpKyC1%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;452&quot; height=&quot;343&quot; data-origin-width=&quot;452&quot; data-origin-height=&quot;343&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Open WebUI로 웹 기반 답변 생성하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 직접 페이지 연결하기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`#url주소`를 입력하면 해당 URL 페이지의 내용을 Open WebUI가 긁어와서 RAG 기반 답변을 생성해준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;179&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cUJmFO/btsMzgaRX2t/3OfQ5J0pNhRKkIavNKkMm0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cUJmFO/btsMzgaRX2t/3OfQ5J0pNhRKkIavNKkMm0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cUJmFO/btsMzgaRX2t/3OfQ5J0pNhRKkIavNKkMm0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcUJmFO%2FbtsMzgaRX2t%2F3OfQ5J0pNhRKkIavNKkMm0%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;768&quot; height=&quot;179&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;179&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 이 '웹'을 눌러주면&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;765&quot; data-origin-height=&quot;154&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tZglS/btsMyBNmm3E/7kAEiisB7HW0mzJJ4HY7mk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tZglS/btsMyBNmm3E/7kAEiisB7HW0mzJJ4HY7mk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tZglS/btsMyBNmm3E/7kAEiisB7HW0mzJJ4HY7mk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtZglS%2FbtsMyBNmm3E%2F7kAEiisB7HW0mzJJ4HY7mk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;765&quot; height=&quot;154&quot; data-origin-width=&quot;765&quot; data-origin-height=&quot;154&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 문서로 추가된다.&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;1013&quot; data-origin-height=&quot;718&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMRwch/btsMAJwrjrX/qbo3xvSX7cMZkeoFxtkf50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMRwch/btsMAJwrjrX/qbo3xvSX7cMZkeoFxtkf50/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMRwch/btsMAJwrjrX/qbo3xvSX7cMZkeoFxtkf50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMRwch%2FbtsMAJwrjrX%2Fqbo3xvSX7cMZkeoFxtkf50%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;1013&quot; height=&quot;718&quot; data-origin-width=&quot;1013&quot; data-origin-height=&quot;718&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`[source_id&amp;gt;0]`과 같은 약간의 찐빠가 있긴 하지만 내용적으로는 문제없이 해당 문서 기반으로 답변을 잘 해준다.&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;2. 검색 기반 답변하기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;serpapi나 google_pse 같은 검색&amp;nbsp; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;API가 있다면 ChatGPT나 Perplexity와 같은 서비스처럼 검색기능을 활성화하여 사용할 수도 있다. (우상단 프로필 - 관리자패널 - 설정 - 웹검색)&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;128&quot; data-origin-height=&quot;432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXo5iu/btsMAEILoqv/5D5feBzlTX9xFBAnzBBFP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXo5iu/btsMAEILoqv/5D5feBzlTX9xFBAnzBBFP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXo5iu/btsMAEILoqv/5D5feBzlTX9xFBAnzBBFP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXo5iu%2FbtsMAEILoqv%2F5D5feBzlTX9xFBAnzBBFP1%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;128&quot; height=&quot;432&quot; data-origin-width=&quot;128&quot; data-origin-height=&quot;432&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 serpapi의 API 키를 연결시켜주었다. (한 달 100건 무료)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Ollama에 대해 알려줘&quot;라는 질문에, Exaone은 모른다고 대답한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1008&quot; data-origin-height=&quot;280&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Q9w0Z/btsMAusKVaI/EFLjkR4QkCMBgeke2ba2Ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Q9w0Z/btsMAusKVaI/EFLjkR4QkCMBgeke2ba2Ok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Q9w0Z/btsMAusKVaI/EFLjkR4QkCMBgeke2ba2Ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQ9w0Z%2FbtsMAusKVaI%2FEFLjkR4QkCMBgeke2ba2Ok%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;1008&quot; height=&quot;280&quot; data-origin-width=&quot;1008&quot; data-origin-height=&quot;280&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 웹 검색 기능을 켠 후 답변을 생성하면 Ollama에 대해 잘 설명해준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;761&quot; data-origin-height=&quot;123&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IX1qU/btsMzfJN4fb/k1R3g3q3ysuWSEpH3rSuj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IX1qU/btsMzfJN4fb/k1R3g3q3ysuWSEpH3rSuj1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IX1qU/btsMzfJN4fb/k1R3g3q3ysuWSEpH3rSuj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIX1qU%2FbtsMzfJN4fb%2Fk1R3g3q3ysuWSEpH3rSuj1%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;761&quot; height=&quot;123&quot; data-origin-width=&quot;761&quot; data-origin-height=&quot;123&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;996&quot; data-origin-height=&quot;334&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKIUVa/btsMyjsBzsA/lbB6vI0u3HYmD97dvZ9buK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKIUVa/btsMyjsBzsA/lbB6vI0u3HYmD97dvZ9buK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKIUVa/btsMyjsBzsA/lbB6vI0u3HYmD97dvZ9buK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKIUVa%2FbtsMyjsBzsA%2FlbB6vI0u3HYmD97dvZ9buK%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;996&quot; height=&quot;334&quot; data-origin-width=&quot;996&quot; data-origin-height=&quot;334&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ollama와 Open WebUI를 잘 이용하면 남는 PC와 GPU를 이용하여 서버를 만들고, 나만의 봇을 만들어서 지인들에게 공유해볼 수도 있을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Open WebUI를 사용하지 않더라도 Ollama를 이용하여 API를 만들어볼 수도 있을 것 같다.&lt;/p&gt;</description>
      <category>데이터 분석/LLM</category>
      <author>woojc</author>
      <guid isPermaLink="true">https://boksup.tistory.com/97</guid>
      <comments>https://boksup.tistory.com/97#entry97comment</comments>
      <pubDate>Fri, 28 Feb 2025 21:56:48 +0900</pubDate>
    </item>
    <item>
      <title>Windows에서 WSL2와 Ubuntu 설치 및 Docker 사용하기</title>
      <link>https://boksup.tistory.com/96</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Windows 환경에서 가장 접근하기 쉬운 Docker 설치 방법은 Docker Desktop를 이용하는 것이다. 하지만 Docker Desktop은 개인 용도로 사용한다면 무료지만 상업적으로 사용하려면 돈을 지불해야만 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1461&quot; data-origin-height=&quot;446&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/edSlzj/btsMzPDUmef/R8ZMsw1imPXXxugGZZUwQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/edSlzj/btsMzPDUmef/R8ZMsw1imPXXxugGZZUwQK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/edSlzj/btsMzPDUmef/R8ZMsw1imPXXxugGZZUwQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FedSlzj%2FbtsMzPDUmef%2FR8ZMsw1imPXXxugGZZUwQK%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;1461&quot; height=&quot;446&quot; data-origin-width=&quot;1461&quot; data-origin-height=&quot;446&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Docker Desktop이 아닌 Docker만 사용하는 것은 상업적으로 무료다. 그러므로 Windows에서 Linux 환경을 쓸 수만 있다면 Docker를 라이센스 문제 없이 상업적으로도 사용할 수 있다. 그걸 위하여 WSL2와 Ubuntu를 설치하고 해당 환경에서 Docker까지 설치해보도록 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;WSL2 설치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. PowerShell을 관리자 권한으로 실행&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;321&quot; data-origin-height=&quot;65&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/I4Dyx/btsMyVROIFX/uHQhNrLxu8JKZNXkYhcKx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/I4Dyx/btsMyVROIFX/uHQhNrLxu8JKZNXkYhcKx0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/I4Dyx/btsMyVROIFX/uHQhNrLxu8JKZNXkYhcKx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FI4Dyx%2FbtsMyVROIFX%2FuHQhNrLxu8JKZNXkYhcKx0%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;321&quot; height=&quot;65&quot; data-origin-width=&quot;321&quot; data-origin-height=&quot;65&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;2. `wsl --install` 명령어를 통해 WSL 설치&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;632&quot; data-origin-height=&quot;136&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfbFGJ/btsMzOxTUbH/lKAwaqp4OFjyNZuSNBMCt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfbFGJ/btsMzOxTUbH/lKAwaqp4OFjyNZuSNBMCt1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfbFGJ/btsMzOxTUbH/lKAwaqp4OFjyNZuSNBMCt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfbFGJ%2FbtsMzOxTUbH%2FlKAwaqp4OFjyNZuSNBMCt1%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;632&quot; height=&quot;136&quot; data-origin-width=&quot;632&quot; data-origin-height=&quot;136&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 WSL2가 설치되는데, 혹시 모르니 다음 명령어를 입력하여 기본 버전을 2로 맞춰준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`wsl --set-default-version 2`&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WSL2 설치가 되면 Ubuntu가 함께 자동으로 설치된다.&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;150&quot; data-origin-height=&quot;119&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FQ5KJ/btsMwZH9Tt7/WjPKJXGEk0qFuc9MrpKFlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FQ5KJ/btsMwZH9Tt7/WjPKJXGEk0qFuc9MrpKFlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FQ5KJ/btsMwZH9Tt7/WjPKJXGEk0qFuc9MrpKFlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFQ5KJ%2FbtsMwZH9Tt7%2FWjPKJXGEk0qFuc9MrpKFlK%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;150&quot; height=&quot;119&quot; data-origin-width=&quot;150&quot; data-origin-height=&quot;119&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 추가 항목에서 Ubuntu를 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;732&quot; data-origin-height=&quot;327&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cqorLG/btsMx7k4uZr/M37WVDkqmqcTcieJnk91qK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cqorLG/btsMx7k4uZr/M37WVDkqmqcTcieJnk91qK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cqorLG/btsMx7k4uZr/M37WVDkqmqcTcieJnk91qK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcqorLG%2FbtsMx7k4uZr%2FM37WVDkqmqcTcieJnk91qK%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;732&quot; height=&quot;327&quot; data-origin-width=&quot;732&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;최초 설치 시 `cd /mnt/c`를 통해 일반적인 내 파일들에 접근할 수 있다는 설명이 제공된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Ubuntu 설치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 Ubuntu가 자동으로 설치되었는데 왜 다시 설치하지? 라고 생각할 수 있다. 다시 설치하는 이유는, 위에서 설치한 Ubuntu의 기본 버전은 24.04이기 때문이다. Ubuntu의 경우 20.04 또는 22.04 버전이 안정적이고 검증되어 널리 사용되고 있다. 24.04는&amp;nbsp; Nvidia 등 설치에 복잡하거나 지원하지 않는 서비스가 있어 문제가 발생한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 PowerShell로 가서 `wsl --install -d Ubuntu-22.04` 명령어를 입력하여 내가 원하는 Ubuntu 버전을 설치해주자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;759&quot; data-origin-height=&quot;179&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLgNao/btsMztUYxAd/bMHgDkrTejqpyoLxj8rWBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLgNao/btsMztUYxAd/bMHgDkrTejqpyoLxj8rWBK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLgNao/btsMztUYxAd/bMHgDkrTejqpyoLxj8rWBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLgNao%2FbtsMztUYxAd%2FbMHgDkrTejqpyoLxj8rWBK%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;759&quot; height=&quot;179&quot; data-origin-width=&quot;759&quot; data-origin-height=&quot;179&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 username과 password를 입력하여 설치를 완료해주자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 Ubuntu 22.04가 최근 추가 항목에 표시된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;173&quot; data-origin-height=&quot;50&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3jnOM/btsMxIsIbC0/RZMx9hU4499LhK16FThEkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3jnOM/btsMxIsIbC0/RZMx9hU4499LhK16FThEkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3jnOM/btsMxIsIbC0/RZMx9hU4499LhK16FThEkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3jnOM%2FbtsMxIsIbC0%2FRZMx9hU4499LhK16FThEkk%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;173&quot; height=&quot;50&quot; data-origin-width=&quot;173&quot; data-origin-height=&quot;50&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한편 앞서 설치한 Ubuntu의 버전이 24.04인지를 Ubuntu 내에서 `lsb_release -a` 명령어를 입력하여 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;420&quot; data-origin-height=&quot;97&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nlnfI/btsMxDky6SK/pooHsE01cLTaUCku9esbaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nlnfI/btsMxDky6SK/pooHsE01cLTaUCku9esbaK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nlnfI/btsMxDky6SK/pooHsE01cLTaUCku9esbaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnlnfI%2FbtsMxDky6SK%2FpooHsE01cLTaUCku9esbaK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;420&quot; height=&quot;97&quot; data-origin-width=&quot;420&quot; data-origin-height=&quot;97&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;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Docker 설치&lt;/h2&gt;
&lt;pre id=&quot;code_1740737622563&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt update
sudo apt install apt-transport-https ca-certificates curl software-properties-common -y
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo &quot;deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io -y&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 각각의 명령어들이 수행하는 내용이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 시스템 업데이트 및 의존성 패키지 설치&lt;/p&gt;
&lt;pre id=&quot;code_1740737463355&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt update
sudo apt install apt-transport-https ca-certificates curl software-properties-common -y&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Docker GPG 키 추가 &lt;/p&gt;
&lt;pre id=&quot;code_1740737480273&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. Docker 저장소 추가 &lt;/p&gt;
&lt;pre id=&quot;code_1740737493491&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;echo &quot;deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. Docker 설치 &lt;/p&gt;
&lt;pre id=&quot;code_1740737528229&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io -y&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 사용자를 docker 그룹에 추가&lt;/p&gt;
&lt;pre id=&quot;code_1740738024608&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo usermod -aG docker $USER&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;마지막으로 Docker가 올바르게 설치됐는지 확인해주자.&lt;/p&gt;
&lt;pre id=&quot;code_1740737909789&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker run hello-world&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;732&quot; data-origin-height=&quot;502&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7gMC0/btsMAu0xQiV/B7yBuSfM87RZXvlQEnx0tk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7gMC0/btsMAu0xQiV/B7yBuSfM87RZXvlQEnx0tk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7gMC0/btsMAu0xQiV/B7yBuSfM87RZXvlQEnx0tk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb7gMC0%2FbtsMAu0xQiV%2FB7yBuSfM87RZXvlQEnx0tk%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;732&quot; height=&quot;502&quot; data-origin-width=&quot;732&quot; data-origin-height=&quot;502&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나오면 정상적으로 설치가 완료된 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 `docker: permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock:` 에러가 나온다면 앞서 docker 그룹에 유저를 추가한 게 제대로 적용이 되지 않은 것이므로 Ubuntu를 재실행해주자.&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;Docker 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 시스템 부팅 시(Ubuntu 실행 시) 자동으로 시작되도록 설정&lt;/p&gt;
&lt;pre id=&quot;code_1740737794825&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo systemctl start docker
sudo systemctl enable docker&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;- Docker 상태 확인&lt;/p&gt;
&lt;pre id=&quot;code_1740737836391&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo systemctl status docker&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;- Docker 컨테이너 목록 확인&lt;/p&gt;
&lt;pre id=&quot;code_1740738267034&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker ps -a&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;1000&quot; data-origin-height=&quot;38&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ekZ7fw/btsMzPKTgvK/BcpOywI3mwG0JO1iu0B8Jk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ekZ7fw/btsMzPKTgvK/BcpOywI3mwG0JO1iu0B8Jk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ekZ7fw/btsMzPKTgvK/BcpOywI3mwG0JO1iu0B8Jk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FekZ7fw%2FbtsMzPKTgvK%2FBcpOywI3mwG0JO1iu0B8Jk%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;38&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;38&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;- Docker 컨테이너 실행&lt;/p&gt;
&lt;pre id=&quot;code_1740738397548&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker run [옵션] 이미지명 [명령어] [인수...]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; - `-d`: 백그라운드에서 실행 &lt;br /&gt;&amp;nbsp; &amp;nbsp; - `-p 호스트포트:컨테이너포트`: 포트 연결 &lt;br /&gt;&amp;nbsp; &amp;nbsp; - `--name 컨테이너이름`: 컨테이너 이름 지정 &lt;br /&gt;&amp;nbsp; &amp;nbsp; - `-v 호스트경로:컨테이너경로`: 볼륨 마운트&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;- 정지된 Docker 컨테이너 시작&lt;/p&gt;
&lt;pre id=&quot;code_1740738520520&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker start 컨테이너명_또는_ID&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;- Docker 컨테이너 정지하기&lt;/p&gt;
&lt;pre id=&quot;code_1740738539907&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker stop 컨테이너명_또는_ID&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;- 정지된 Docker 컨테이너 삭제하기&lt;/p&gt;
&lt;pre id=&quot;code_1740738557597&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker rm 컨테이너명_또는_ID&lt;/code&gt;&lt;/pre&gt;</description>
      <category>데이터 분석</category>
      <author>woojc</author>
      <guid isPermaLink="true">https://boksup.tistory.com/96</guid>
      <comments>https://boksup.tistory.com/96#entry96comment</comments>
      <pubDate>Fri, 28 Feb 2025 19:32:05 +0900</pubDate>
    </item>
    <item>
      <title>2024 KBO MVP를 선수 스탯을 통해 머신러닝으로 예측해보기</title>
      <link>https://boksup.tistory.com/95</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;서론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;올해로 4년째 해보는 토이 프로젝트이다. 먼저 결론부터 쓰자면 2024 KBO MVP는 &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;2021년부터 계속해온 MVP 예측 결과를 정리하면 아래와 같다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 42.6744%; height: 162px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style14&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 29.2915%;&quot;&gt;연도&lt;/td&gt;
&lt;td style=&quot;width: 37.8066%;&quot;&gt;예측&lt;/td&gt;
&lt;td style=&quot;width: 32.9019%;&quot;&gt;결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 29.2915%;&quot;&gt;2021&lt;/td&gt;
&lt;td style=&quot;width: 37.8066%;&quot;&gt;미란다&lt;/td&gt;
&lt;td style=&quot;width: 32.9019%;&quot;&gt;미란다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 29.2915%;&quot;&gt;2022&lt;/td&gt;
&lt;td style=&quot;width: 37.8066%;&quot;&gt;(잊음)&lt;/td&gt;
&lt;td style=&quot;width: 32.9019%;&quot;&gt;이정후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 29.2915%;&quot;&gt;2023&lt;/td&gt;
&lt;td style=&quot;width: 37.8066%;&quot;&gt;페디&lt;/td&gt;
&lt;td style=&quot;width: 32.9019%;&quot;&gt;페디&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 29.2915%;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;2024&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.8066%;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;김도영&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 32.9019%;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;???&lt;/span&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2022년에는 잊고 있어서 빼먹었고, 2021, 2023년은 MVP 발표가 된 후 예측했던 거라 감흥이 적었다면 올해는 MVP 발표가 되기 전이라 결과가 기대된다.&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;h2 data-ke-size=&quot;size26&quot;&gt;데이터 수집 및 정제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연한 소리지만 우선 데이터를 수집해야한다. 투, 타 모두 연도별로 선수 성적 데이터를 가져오는데 전체 데이터를 가지고 오면 MVP가 매우 적은 불균형한 데이터가 된다. 이를 막기 위해 연도별 WAR 기준으로 상위 10명의 데이터만 가져왔다. 사실 그렇게 하더라도 19:1 (투타 합 20명 중 1명)이기 때문에 여전히 불균형하긴 하지만, 너무 줄이면 데이터가 너무 적어진다는 걸 감안했다.&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;선수들의 상세 스탯은 스탯티즈에서 가져왔었는데, 스탯티즈가 스포키로 바뀌면서 수집 코드를 다시 작성해야했다. 또, WAR 계산 방식이 이전과 달라져서 82년부터 새로 싹 긁어왔다.&lt;/p&gt;
&lt;pre id=&quot;code_1728612269113&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import requests
from bs4 import BeautifulSoup
import numpy as np
import pandas as pd

def batter(year):
    url = f'https://statiz.sporki.com/stats/?m=main&amp;amp;m2=batting&amp;amp;m3=default&amp;amp;so=WAR&amp;amp;ob=DESC&amp;amp;year={year}&amp;amp;sy={year}&amp;amp;ey={year}&amp;amp;te=&amp;amp;po=&amp;amp;lt=10100&amp;amp;reg=R&amp;amp;pe=&amp;amp;ds=&amp;amp;de=&amp;amp;we=&amp;amp;hr=&amp;amp;ha=&amp;amp;ct=&amp;amp;st=&amp;amp;vp=&amp;amp;bo=&amp;amp;pt=&amp;amp;pp=&amp;amp;ii=&amp;amp;vc=&amp;amp;um=&amp;amp;oo=&amp;amp;rr=&amp;amp;sc=&amp;amp;bc=&amp;amp;ba=&amp;amp;li=&amp;amp;as=&amp;amp;ae=&amp;amp;pl=&amp;amp;gc=&amp;amp;lr=&amp;amp;pr=50&amp;amp;ph=&amp;amp;hs=&amp;amp;us=&amp;amp;na=&amp;amp;ls=&amp;amp;sf1=&amp;amp;sk1=&amp;amp;sv1=&amp;amp;sf2=&amp;amp;sk2=&amp;amp;sv2='
    r = requests.get(url).text
    soup = BeautifulSoup(r, 'lxml')

    raw_data = soup.find_all('table')[0].find_all('tr')[2:12]
    data = []
    for row in raw_data:
        data.append([td.text for td in row])

    return data
    
    
def pitcher(year):
    url = f'https://statiz.sporki.com/stats/?m=main&amp;amp;m2=pitching&amp;amp;m3=default&amp;amp;so=WAR&amp;amp;ob=DESC&amp;amp;year={year}&amp;amp;sy={year}&amp;amp;ey={year}&amp;amp;te=&amp;amp;po=&amp;amp;lt=10100&amp;amp;reg=R&amp;amp;pe=&amp;amp;ds=&amp;amp;de=&amp;amp;we=&amp;amp;hr=&amp;amp;ha=&amp;amp;ct=&amp;amp;st=&amp;amp;vp=&amp;amp;bo=&amp;amp;pt=&amp;amp;pp=&amp;amp;ii=&amp;amp;vc=&amp;amp;um=&amp;amp;oo=&amp;amp;rr=&amp;amp;sc=&amp;amp;bc=&amp;amp;ba=&amp;amp;li=&amp;amp;as=&amp;amp;ae=&amp;amp;pl=&amp;amp;gc=&amp;amp;lr=&amp;amp;pr=50&amp;amp;ph=&amp;amp;hs=&amp;amp;us=&amp;amp;na=&amp;amp;ls=&amp;amp;sf1=&amp;amp;sk1=&amp;amp;sv1=&amp;amp;sf2=&amp;amp;sk2=&amp;amp;sv2='
    r = requests.get(url).text
    soup = BeautifulSoup(r, 'lxml')

    raw_data = soup.find_all('table')[0].find_all('tr')[2:12]
    data = []
    for row in raw_data:
        data.append([td.text for td in row])

    return data&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;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1728612342794&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;from tqdm.notebook import tqdm
stats_org = ['순위', '이름', '연도', 'WAR', 'oWAR', 'dWAR', 'G', '타석', 'ePA', '타수', '득점', '안타', '2타', '3타', '홈런', '루타', '타점', '도루', '도실', '볼넷', '사구', '고4', '삼진', '병살', '희타', '희비', '타율', '출루', '장타', 'OPS', 'R/ePA', 'wRC+', 'WAR2']
stats_use = ['이름', '연도', 'WAR', 'G', '타석', '타수', '득점', '안타', '2타', '3타', '홈런', '루타', '타점', '도루', '도실', '볼넷', '사구', '고4', '삼진', '병살', '희타', '희비',
         '타율', '출루', '장타', 'OPS', 'wRC+']

bat = pd.DataFrame(columns=stats_use)
for year in tqdm(range(1982, 2024+1)):
    data = pd.DataFrame(batter(year), columns=stats_org)[stats_use]
    data['연도'] = year
    bat = pd.concat([data, bat], axis=0, ignore_index=True)
    
    
stats_org = ['순위', '이름', '연도', 'WAR', '출장', '선발', '구원', 'GF', '완투', '완봉', '승', '패', '세', '홀', '이닝', '자책', '실점', 'rRA', '타자', '피안', '피2', '피3', '피홈', '피볼', '사구', '고4', '삼진', 'ROE', '보크', '폭투', 'ERA', 'RA9', 'rRA9', 'rRA9pf', 'FIP', 'WHIP', 'WAR2']
stats_use = ['이름', '연도', 'WAR', '출장', '완투', '완봉', '선발', '승', '패', '세', '홀', '이닝', '실점', '자책', '타자', '피안', '피2', '피3', '피홈', '피볼', '고4', '사구', '삼진', '보크', '폭투', 'ERA', 'FIP', 'WHIP']

pit = pd.DataFrame(columns=stats_use)
for year in tqdm(range(1982, 2024+1)):
    data = pd.DataFrame(pitcher(year), columns=stats_org)[stats_use]
    data['연도'] = year
    pit = pd.concat([data, pit], axis=0, ignore_index=True)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타이틀홀더 및 MVP 데이터는 수기로 직접 작성해서 성적 데이터와 합쳐줬다. 작년까지 넣었던 우승팀 데이터는 1. 아직 한국시리즈가 안 끝나기도 했고 2. 우승팀 소속 여부가 MVP를 결정하는데 큰 관계가 없다는 것을 과거 예측 경험을 통해 확인했기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 생각해보니 오히려 외국인 여부를 넣는게 더 좋지 않을까 생각도 든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;1. 투수, 타자 각각 MVP를 예측하는 모델을 학습한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; - 모델은 LightGBM를 사용했는데, RandomForest든 XGBoost든 다 비슷했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; - MVP=1, 나머지=0으로 놓은 분류 모델&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 1에서 학습한 모델에 올해의 투수, 타자 데이터를 넣고 확률을 예측한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 모델의 예측 확률이 더 높은 쪽이 MVP 확률이 더 높은 것으로 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; - MVP 예측 결과가 0이 나오더라도, 예측 확률이 조금이라도 더 높은 선수를 선택한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; - ex) A 타자 확률 30%, B 투수 확률 25%일 때, 예측 결과 자체는 둘 다 MVP가 아니지만 확률상 더 높은 A를 선택한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 보면 참 엉터리같지만 이상하게 결과는 잘 나온다.&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;2023년 결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 2023년 결과를 돌이켜본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타자에서는 노시환이 30홈런-100타점을 넘으면서 MVP 후보로 올랐고, 투수에서는 페디가 20승-200삼진 평자책 1위로 MVP 후보로 올랐다.&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;모델이 예측한 노시환의 확률은 18%였다. 18%까지 오르는데까지 기여한 이유로는 타이틀홀더가 2개, 피삼진이 118개 등으로 shapley value 계산 결과로 나타났다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;801&quot; data-origin-height=&quot;140&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/v6kj0/btsJ2a4ZqeJ/9Uop5X5FBJBDj39GgQ8FA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/v6kj0/btsJ2a4ZqeJ/9Uop5X5FBJBDj39GgQ8FA1/img.png&quot; data-alt=&quot;2023 노시환&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/v6kj0/btsJ2a4ZqeJ/9Uop5X5FBJBDj39GgQ8FA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fv6kj0%2FbtsJ2a4ZqeJ%2F9Uop5X5FBJBDj39GgQ8FA1%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;801&quot; height=&quot;140&quot; data-origin-width=&quot;801&quot; data-origin-height=&quot;140&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;2023 노시환&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;모델이 예측한 페디의 확률은 56%였다. 타이틀홀더가 3개, 삼진이 209개, 승수가 20개 등이 주요 이유로 나타났다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;592&quot; data-origin-height=&quot;145&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/N4FAe/btsJ13EUtrp/qk3vbQlSSmWOF1J00Qhj41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/N4FAe/btsJ13EUtrp/qk3vbQlSSmWOF1J00Qhj41/img.png&quot; data-alt=&quot;2023 페디&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/N4FAe/btsJ13EUtrp/qk3vbQlSSmWOF1J00Qhj41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FN4FAe%2FbtsJ13EUtrp%2Fqk3vbQlSSmWOF1J00Qhj41%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;592&quot; height=&quot;145&quot; data-origin-width=&quot;592&quot; data-origin-height=&quot;145&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;2023 페디&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 [페디의 확률(56%)이 노시환(18%)보다 높으므로 머신러닝 모델은 페디가 MVP라고 예측했다]고 말할 수 있겠다.&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;참고로 21년의 확률은 각각 미란다(14~35%), 최정(7~14%)였고 50%보다 확률이 낮았지만 2위인 최정보다 높았던 미란다가 MVP로 머신러닝 결과 예측됐고, 실제로도 MVP를 수상했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2024년 결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2024년은 30-30을 달성한 김도영이 MVP가 될 것으로 거의 확실시 되는 가운데 확률을 계산해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델은 단 4%라고 계산해주었다. 가장 큰 마이너스 이유로는 고의사구가 7개라는 것으로 나왔는데, [과거의 강력한 MVP 타자들에게 보이는 다수의 고의사구가 김도영에겐 적었기 때문에]라고 해석할 수 있을 것 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1271&quot; data-origin-height=&quot;122&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cU5rek/btsJ2vgNBbP/4ZS6axCg0GGNx232Zzssh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cU5rek/btsJ2vgNBbP/4ZS6axCg0GGNx232Zzssh0/img.png&quot; data-alt=&quot;2024 김도영&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cU5rek/btsJ2vgNBbP/4ZS6axCg0GGNx232Zzssh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcU5rek%2FbtsJ2vgNBbP%2F4ZS6axCg0GGNx232Zzssh0%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;1271&quot; height=&quot;122&quot; data-origin-width=&quot;1271&quot; data-origin-height=&quot;122&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;2024 김도영&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;한편 투수 쪽에서는 의외의 결과가 나온 것이, 투수 MVP라면 하트라고 생각했지만 모델의 생각은 달랐던 것 같다. 매우매우 작은 값이긴 하지만 하트의 확률은 0.00%가 나온데 비해 윌커슨은 0.03%가 나왔기 때문이다.&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;1065&quot; data-origin-height=&quot;111&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9YMel/btsJ13rBDmM/KkNCIkHdEQQYrGT2UJUQN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9YMel/btsJ13rBDmM/KkNCIkHdEQQYrGT2UJUQN1/img.png&quot; data-alt=&quot;2024 하트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9YMel/btsJ13rBDmM/KkNCIkHdEQQYrGT2UJUQN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9YMel%2FbtsJ13rBDmM%2FKkNCIkHdEQQYrGT2UJUQN1%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;1065&quot; height=&quot;111&quot; data-origin-width=&quot;1065&quot; data-origin-height=&quot;111&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;2024 하트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;787&quot; data-origin-height=&quot;116&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IFSSC/btsJ1EscO9x/U66jxk5C8W5rDycevgG6e0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IFSSC/btsJ1EscO9x/U66jxk5C8W5rDycevgG6e0/img.png&quot; data-alt=&quot;2024 윌커슨&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IFSSC/btsJ1EscO9x/U66jxk5C8W5rDycevgG6e0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIFSSC%2FbtsJ1EscO9x%2FU66jxk5C8W5rDycevgG6e0%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;787&quot; height=&quot;116&quot; data-origin-width=&quot;787&quot; data-origin-height=&quot;116&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;2024 윌커슨&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;하트는 홀더가 1개 있지만 승이 13개밖에(?) 안 되고 볼넷이 38개였던 반면 윌커슨은 홀더가 0개이지만 사구가 2개밖에 안 되고 210개의 피안타를 맞았기 때문(?)에 매우 근소하게 윌커슨이 0.03%p 높았다&quot;라고 해석할 수 있을 것 같지만 너무 작은 차이라 큰 의미는 없는 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼, 김도영의 확률(4%)가 윌커슨의 확률(0.03%)보다 높기 때문에 2024 MVP는 김도영이 받을 것으로 모델은 예측했다고 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 생각해볼 점은, 만약 2023 노시환의 성적이 올해 나왔다면 노시환이 MVP를 받을 수 있지 않았을까 하는 점이다. 2023 노시환은 18%고, 2024 김도영은 4%이니 말이다. 물론 예측한 데이터에는 클래식 및 세이버 매트릭스 데이터만 있을 뿐, 30-30 달성, 30-100 달성 등의 타이틀은 제외되었으니 이런 타이틀 기록까지 생각하면 확률은 달라질 수 있겠지만.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/woojangchang/kbo_mvp&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;github&lt;/a&gt;&lt;/p&gt;</description>
      <category>데이터 분석/머신러닝</category>
      <author>woojc</author>
      <guid isPermaLink="true">https://boksup.tistory.com/95</guid>
      <comments>https://boksup.tistory.com/95#entry95comment</comments>
      <pubDate>Sat, 12 Oct 2024 10:05:37 +0900</pubDate>
    </item>
  </channel>
</rss>