<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>woody_dev.log</title>
        <link>https://velog.io/</link>
        <description>developer</description>
        <lastBuildDate>Thu, 03 Jul 2025 18:48:58 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>woody_dev.log</title>
            <url>https://velog.velcdn.com/images/woody_ahn/profile/6e5189ba-a4ea-4d1f-b3d1-d2f9f272e597/image.JPG</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. woody_dev.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/woody_ahn" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[회의 요약 MCP Server 개발과 Notion MCP 연동]]></title>
            <link>https://velog.io/@woody_ahn/%ED%9A%8C%EC%9D%98%EC%9D%98-%EB%AA%A8%EB%93%A0-%EA%B2%83%EC%9D%84-Notion%EC%97%90-%EA%B8%B0%EB%A1%9D-LLM-%EC%9A%94%EC%95%BD%EA%B3%BC-MCP-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@woody_ahn/%ED%9A%8C%EC%9D%98%EC%9D%98-%EB%AA%A8%EB%93%A0-%EA%B2%83%EC%9D%84-Notion%EC%97%90-%EA%B8%B0%EB%A1%9D-LLM-%EC%9A%94%EC%95%BD%EA%B3%BC-MCP-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Thu, 03 Jul 2025 18:48:58 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/woody_ahn/post/0e069444-c791-446a-afb8-2bb39ada8bbb/image.png" alt=""></p>
<p>이번 글에서는 회의 내용을 요약하는 MCP 서버를 개발하고, 이를 Notion MCP 서버와 연동하여 회의 내용을 Notion에 정리하는 과정을 다루겠습니다.</p>
<h1 id="mcp란-무엇인가">MCP란 무엇인가?</h1>
<p><strong>MCP (Model Context Protocol)</strong> 는 애플리케이션이 LLM에 컨텍스트를 제공하는 방법을 표준화하는 개방형 프로토콜입니다.</p>
<p>예를 들어 사용자가 “이 회의 내용을 요약해줘”라고 요청하면, LLM은 그 요청을 처리하기 위해 여러 단계를 거쳐야 합니다.</p>
<pre><code>1.    음성 파일을 받아서 텍스트로 전사하고,
2.    전사된 텍스트를 요약한 뒤,
3.    요약 결과를 데이터베이스에 저장</code></pre><p>이처럼 하나의 요청을 처리하려면 여러 기능이 순차적으로 실행되어야 하는데, MCP는 이러한 기능들을 ‘LLM이 호출할 수 있는 작업 단위’로 정의하고, 그 작업들을 표준화된 방식으로 연결할 수 있도록 해줍니다.</p>
<p>각 작업은 “회의 전사”, “요약”, “저장”처럼 구체적인 목적을 가진 독립적인 단위로 구성되며, LLM은 이들을 직접 호출하거나, 연속된 흐름 속에서 조합하여 사용할 수 있습니다.</p>
<p>결과적으로 MCP는 LLM이 단순한 대화 엔진을 넘어, <strong>실제 업무를 자동으로 처리할 수 있는 실행 주체(에이전트)</strong>로 확장되도록 만들어주는 핵심 기술입니다.</p>
<h1 id="mcp의-구성-host-client-server">MCP의 구성: Host, Client, Server</h1>
<p>MCP는 세 가지 주요 구성 요소인 Host, Client, Server로 이루어져 있으며, 각 요소는 다음과 같은 역할을 담당합니다.</p>
<h2 id="mcp-host">MCP Host</h2>
<p>MCP Host는 LLM과 사용자 인터페이스를 통합하는 애플리케이션입니다. 예를 들어 Claude Desktop, Cursor 같은 도구들이 여기에 해당하며, 사용자의 요청을 받아 LLM을 통해 처리하고 결과를 반환하는 흐름의 중심이 됩니다.</p>
<h2 id="mcp-client">MCP Client</h2>
<p>MCP Client는 LLM과 MCP Server 사이의 브릿지(중개자) 역할을 합니다. LLM으로부터 작업 요청을 받아 적절한 MCP Server에 전달하고, 서버의 응답 결과를 다시 LLM으로 반환하는 역할을 수행합니다.</p>
<p>즉, LLM이 외부 기능을 직접 호출할 수 있게 만들어주는 연결 계층입니다.</p>
<h2 id="mcp-server">MCP Server</h2>
<p>MCP Server는 LLM이 호출할 수 있는 <strong>구체적인 작업(예: STT, 요약, 데이터 저장 등)</strong>을 실제로 수행합니다. 또한 외부 데이터베이스나 API, 파일 시스템 등에 접근하여 필요한 처리를 수행할 수 있도록 합니다.</p>
<p>개발자는 이 MCP Server에 기능 단위를 정의하고, HTTP API 등의 형태로 외부에 노출함으로써 LLM이 이를 자유롭게 사용할 수 있게 만듭니다.</p>
<p>공식 MCP GitHub 저장소에서는 다양한 기능을 수행하는 서버들이 오픈소스로 공개되어 있습니다. <a href="https://github.com/modelcontextprotocol/servers">https://github.com/modelcontextprotocol/servers</a></p>
<h1 id="구현">구현</h1>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/f004f095-76a8-46b3-aaa5-0a27ddcb4a91/image.png" alt=""></p>
<p>이번 프로젝트에서는 데이터베이스에 저장된 회의 내용을 불러와 요약한 뒤, 그 결과를 Notion에 자동으로 기록하는 시스템을 구성했습니다. 이를 위해 두 개의 MCP 서버를 사용합니다:</p>
<ul>
<li>Summary MCP Server</li>
<li>Notion MCP Server</li>
</ul>
<h2 id="summary-mcp-server">Summary MCP Server</h2>
<ul>
<li>FastMCP로 구현</li>
<li>데이터베이스에서 회의 원문을 불러옵니다.</li>
<li>LLM을 사용해 회의 내용을 요약하고 액션 아이템을 추출합니다.</li>
</ul>
<p><code>FastMCP</code>는 MCP기반의 작업들을 FastAPI를 이용해 간단하게 실행할 수 있도록 도와주는 경량 서버 프레임워크입니다. 
<code>@mcp.tool() 데코레이터</code>로 <code>summarize()</code> 함수를 MCP에서 호출 가능한 도구(tool)로 등록합니다. LLM이 &quot;summarize&quot;라는 이름으로 이 함수를 실행할 수 있게 됩니다.
LLM 앱과 로컬 통신이기 때문에 <code>stdio</code>로 실행합니다.</p>
<pre><code class="language-python">from mcp.server.fastmcp import FastMCP
from tools.get_transcript import get_transcript
from tools.generate_summary import generate_summary

mcp = FastMCP(&quot;Meeting_Summary&quot;)

@mcp.tool()
def summarize(room_id: str) -&gt; dict:
    &quot;&quot;&quot;Summarizes the transcript of a meeting and returns the summary.&quot;&quot;&quot;
    transcript = get_transcript(room_id)
    summary = generate_summary(transcript)
    return summary

if __name__ == &quot;__main__&quot;:
    mcp.run(transport=&quot;stdio&quot;)</code></pre>
<p>회의 내용은 <code>get_transcript(room_id)</code> 함수를 통해 데이터베이스에서 불러옵니다.
현재는 DB에 저장된 전사 데이터를 기반으로 회의 내용을 요약하지만, 오디오 파일로부터 직접 회의 내용을 가져오고 싶다면, 이 부분을 다음과 같은 흐름으로 대체할 수 있습니다.</p>
<pre><code>audio → STT 모델 → 전사 텍스트 추출(transcribe)</code></pre><p>즉, <code>get_transcript()</code> 함수 대신, 오디오 파일을 입력으로 받아 Whisper 등의 STT 모델을 이용해 텍스트를 추출하는 함수를 사용하면 됩니다.</p>
<p>이처럼 MCP 구조에서는 입력 소스(DB, 오디오 등)에 따라 기능을 유연하게 교체할 수 있다는 장점이 있습니다.</p>
<pre><code class="language-python">from db import get_db_connection

def get_transcript(room_id: str) -&gt; list[dict]:
    &quot;&quot;&quot;
    특정 room_id에 해당하는 회의 내용을 시간순으로 반환합니다.
    각 항목은 {speaker_id, text, timestamp} 형태입니다.
    &quot;&quot;&quot;
    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute(&quot;&quot;&quot;
        SELECT speaker_id, text, timestamp
        FROM transcripts
        WHERE room_id = %s
        ORDER BY timestamp ASC
    &quot;&quot;&quot;, (room_id,))
    rows = cur.fetchall()
    cur.close()
    conn.close()

    return [
        {
            &quot;speaker_id&quot;: row[0],
            &quot;text&quot;: row[1],
            &quot;timestamp&quot;: row[2].isoformat()
        }
        for row in rows
    ]</code></pre>
<p>LLM에 회의 내용 요약을 요청하는 함수입니다. 예제에서는 OpenAI의 gpt-4o-mini 모델을 사용하여 수행되지만, MCP 구조에서는 이 부분을 쉽게 다른 LLM으로 교체할 수 있습니다. 예를 들어, HuggingFace의 mistral, claude, gemini, 또는 사내에 배포한 전용 모델 등을 사용할 수 있으며, <code>generate_summary()</code> 함수 내부에서 LLM 객체만 바꿔주면 동일한 흐름으로 작동합니다.</p>
<pre><code class="language-python">from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate

llm = ChatOpenAI(model=&quot;gpt-4o-mini&quot;, temperature=0.3)

prompt = ChatPromptTemplate.from_messages([
    (&quot;system&quot;, &quot;너는 회의 요약 도우미야.&quot;),
    (&quot;user&quot;, &quot;&quot;&quot;다음 회의 내용을 바탕으로:
1. 회의 시작/종료(마지막 시간기준)시간과 참석자 목록을 포함한 주요 논의 내용을 자세히 요약하고,
2. 회의에서 결정된 액션 아이템들을 항목으로 정리해줘.

{transcript_text}&quot;&quot;&quot;)
])

chain = prompt | llm

def generate_summary(transcript: list[dict]) -&gt; dict:
    transcript_text = &quot;\n&quot;.join(f&quot;{t[&#39;timestamp&#39;]} - {t[&#39;speaker_id&#39;]}: {t[&#39;text&#39;]}&quot; for t in transcript)
    result = chain.invoke({&quot;transcript_text&quot;: transcript_text})
    return {&quot;summary&quot;: result.content.strip()}
</code></pre>
<h2 id="notion-mcp-server">Notion MCP Server</h2>
<ul>
<li>Summary 서버로부터 전달받은 요약 결과를 지정된 Notion 페이지에 저장합니다.</li>
<li><a href="https://github.com/makenotion/notion-mcp-server">makenotion/notion-mcp-server</a> 오픈소스를 사용하여 구성했습니다.</li>
</ul>
<p>이 서버는 MCP 형식에 맞춰 설계되어 있어, LLM이 직접 호출하여 Notion에 기록할 수 있습니다.</p>
<p>Notion 페이지에 접근하려면 다음 두 가지 정보가 필요합니다:</p>
<ul>
<li>Integration Token (Notion API 토큰)</li>
<li>Page ID (내용을 기록할 대상 페이지 ID)</li>
</ul>
<p>해당 정보는 위 공식 저장소의 <a href="http://github.com/makenotion/notion-mcp-server/blob/main/README.md">README</a>에서 설정 방법이 자세히 설명되어 있으니, 사용 전 참고하시면 됩니다.</p>
<h2 id="mcp-host-1">MCP Host</h2>
<p>앞서 정의한 두 MCP 서버(Summary MCP Server, Notion MCP Server)를 사용하여, 회의 내용을 요약하고 Notion에 저장하는 전체 워크플로우를 실행하는 Host 코드를 작성합니다.</p>
<pre><code class="language-python">from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from langchain_mcp_adapters.tools import load_mcp_tools
from langgraph.prebuilt import create_react_agent
import asyncio
import os
from contextlib import AsyncExitStack
from types import SimpleNamespace

load_dotenv()

notion_token = os.getenv(&quot;NOTION_TOKEN&quot;)

servers = [
    {
        &quot;name&quot;: &quot;meeting_summary_server&quot;,
        &quot;params&quot;: StdioServerParameters(
            command=&quot;python&quot;,
            args=[&quot;mcp_server.py&quot;]
        )
    },
    {
        &quot;name&quot;: &quot;notionApi&quot;,
        &quot;params&quot;: SimpleNamespace(
            command=&quot;npx&quot;,
            args=[&quot;-y&quot;, &quot;@notionhq/notion-mcp-server&quot;],
            cwd=None,
            encoding=&quot;utf-8&quot;,
            encoding_error_handler=&quot;replace&quot;,
            env={
                &quot;OPENAPI_MCP_HEADERS&quot;: f&#39;{{&quot;Authorization&quot;: &quot;Bearer {notion_token}&quot;, &quot;Notion-Version&quot;: &quot;2022-06-28&quot;}}&#39;
            }
        )
    }
]


async def connect_to_server(server_config, stack: AsyncExitStack):
    &quot;&quot;&quot;서버에 연결하고 MCP 세션 및 툴 로딩&quot;&quot;&quot;
    name = server_config[&quot;name&quot;]
    params = server_config[&quot;params&quot;]

    read, write = await stack.enter_async_context(stdio_client(params))
    session = await stack.enter_async_context(ClientSession(read, write))
    await session.initialize()
    tools = await load_mcp_tools(session)

    return {
        &quot;name&quot;: name,
        &quot;tools&quot;: tools,
    }


async def run_multi_server_agent():
    async with AsyncExitStack() as stack:
        connections = []
        for server in servers:
            conn = await connect_to_server(server, stack)
            connections.append(conn)

        all_tools = [tool for conn in connections for tool in conn[&quot;tools&quot;]]
        llm = ChatOpenAI(model=&quot;gpt-4o-mini&quot;)
        agent = create_react_agent(llm, all_tools)

        room_id = &quot;test-room&quot;
        page_id = os.getenv(&quot;NOTION_PAGE_ID&quot;)

        return await agent.ainvoke({
            &quot;messages&quot;: [
                (&quot;system&quot;,
                    f&quot;당신은 회의 내용을 요약하고 액션 아이템을 추출한 후, Notion에 저장하는 도우미입니다.\n&quot;
                    f&quot;Notion에 저장할 때는 다음 지침을 따르세요:\n&quot;
                    f&quot;- 지정된 `page_id`를 부모로 사용합니다. (page_id: {page_id})\n&quot;
                    f&quot;- 새 페이지의 제목은 회의 날짜(YYYY년 MM월 DD일)로 설정합니다.\n&quot;
                    f&quot;- 본문은 다음과 같이 구성합니다:\n&quot;
                    f&quot;  1. 요약(Summary) 섹션: 회의 내용을 간단히 요약한 텍스트 블록\n&quot;
                    f&quot;  2. 액션 아이템(Action Items) 섹션: 각 액션 아이템을 bullet 블록으로 정리\n&quot;
                    f&quot;모든 정보를 JSON 형식으로 MCP Tool에 전달해 저장을 요청하세요.&quot;
                ),
                (&quot;user&quot;,
                f&quot;{room_id} 회의 내용을 요약하고 액션 아이템을 추출해 주세요. 그리고 위 기준에 따라 Notion에 저장해 주세요.&quot;)
            ]
        })


# 비동기 함수 실행
if __name__ == &quot;__main__&quot;:
    result = asyncio.run(run_multi_server_agent())</code></pre>
<p><strong>구성 설명</strong></p>
<ul>
<li><p><strong>meeting_summary_server</strong>:
로컬에서 실행되는 Python 기반 MCP 서버로, 회의 내용을 요약하는 기능을 제공합니다.</p>
</li>
<li><p><strong>notionApi</strong>:
Notion 공식 MCP 서버를 npx를 통해 실행합니다.
이 서버는 Notion에 요약 결과를 저장하는 기능을 담당하며,
환경변수로 인증 토큰(NOTION_TOKEN)과 Notion API 버전을 함께 전달합니다.</p>
</li>
</ul>
<p>이 서버들을 <code>ClientSession</code> 으로 연결한 뒤, <code>load_mcp_tools()</code>로 MCP 도구 목록을 로드합니다.</p>
<p>또한, LLM이 요약 결과를 Notion에 저장할 때 따라야 할 포맷을 명확히 안내하기 위해 시스템 프롬프트에 지침을 명시해야 합니다.</p>
<h1 id="테스트">테스트</h1>
<p>테스트를 위해 데이터베이스에 다음과 같은 예시 회의 데이터를 삽입했습니다.</p>
<pre><code class="language-json">(&#39;test-room&#39;, &#39;alice&#39;, &#39;프로젝트 일정 조정이 필요합니다.&#39;, &#39;2025-07-01 10:00:00&#39;),
(&#39;team-sync&#39;, &#39;david&#39;, &#39;팀 전체 회의는 내일 오전 9시입니다.&#39;, &#39;2025-07-01 10:00:05&#39;),
(&#39;test-room&#39;, &#39;bob&#39;, &#39;디자인 시안은 오늘 중으로 공유하겠습니다.&#39;, &#39;2025-07-01 10:00:10&#39;),
(&#39;team-sync&#39;, &#39;emma&#39;, &#39;SNS 캠페인은 다음 주부터 시작합니다.&#39;, &#39;2025-07-01 10:00:15&#39;),
(&#39;test-room&#39;, &#39;carol&#39;, &#39;QA는 다음 주까지 완료 예정입니다.&#39;, &#39;2025-07-01 10:00:20&#39;);</code></pre>
<p>테스트를 위해 아래 명령어로 클라이언트를 실행합니다:</p>
<pre><code class="language-bash">python3 client.py</code></pre>
<p>잠시 후 Notion에 요약된 회의 내용이 아래와 같이 기록된 것을 볼 수 있습니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/1ce919bf-b6c0-43f4-959f-08fcaf8743a7/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/8fe3db01-e052-40a6-9ddf-a2197554d792/image.png" alt=""></p>
<h1 id="마무리">마무리</h1>
<p>이번 글에서는 MCP (Model Context Protocol) 기반으로 회의 내용을 요약하고, 그 결과를 자동으로 Notion에 기록하는 시스템을 구축해 보았습니다.</p>
<p>MCP 구조로 얻은 가장 큰 장점은 다음과 같습니다.</p>
<ul>
<li>기능 단위를 Tool로 분리하고, LLM이 이를 조합해 사용할 수 있어 높은 유연성</li>
<li>각 MCP 서버는 독립적으로 실행되므로, 확장성과 테스트 용이성</li>
<li>다양한 입력 소스(STT, DB, API 등)에 맞춰 구성 변경이 간단</li>
</ul>
<p>향후에는 STT 도구를 추가해 오디오 기반 회의 요약을 실시간으로 처리하거나, 요약 결과를 Slack, Google Docs 등 다른 협업 도구로 확장하는 것도 어렵지 않습니다.</p>
<p>전체 코드는 GitHub 저장소에서 확인하실 수 있습니다. <a href="https://github.com/hissinger/meeting_summary">https://github.com/hissinger/meeting_summary</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React-Native WebRTC 앱에서 Android 오디오 Route 제어: 네이티브 모듈 개발기]]></title>
            <link>https://velog.io/@woody_ahn/React-Native-WebRTC-%EC%95%B1%EC%97%90%EC%84%9C-Android-%EC%98%A4%EB%94%94%EC%98%A4-Route-%EC%A0%9C%EC%96%B4-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EB%AA%A8%EB%93%88-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@woody_ahn/React-Native-WebRTC-%EC%95%B1%EC%97%90%EC%84%9C-Android-%EC%98%A4%EB%94%94%EC%98%A4-Route-%EC%A0%9C%EC%96%B4-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EB%AA%A8%EB%93%88-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Sat, 12 Apr 2025 13:05:19 GMT</pubDate>
            <description><![CDATA[<p>개발 중인 서비스에서는 WebRTC 기반의 영상 통화 기능을 제공하고 있으며, 통화 중 다음과 같은 기능이 필요했습니다:
    •    오디오 디바이스(Bluetooth, 유선, 스피커) 간 자동 전환 및 수동 제어
    •    화면 꺼짐 방지</p>
<p>초기에는 <a href="https://github.com/react-native-webrtc/react-native-incall-manager"><code>react-native-incall-manager</code></a> 라이브러리를 사용하여 이 기능을 구현했습니다. 그러나 이 라이브러리는 Android 12(API 31) 이상부터 <code>android.permission.BLUETOOTH_CONNECT</code> 권한 없이 Bluetooth 기기의 연결 상태를 확인하거나 제어할 수 없습니다.</p>
<p>문제는 이 권한 요청 모달이 아래와 같이 블루투스 관련이라는 표시가 없어 사용자에게 거부당할 가능성이 높다는 것입니다. 사용자 거부 시 Bluetooth 기기 연결 인식이 안됩니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/26d14636-4e4b-459e-8c7a-5c76512ea4ef/image.png" alt="">
이로 인해 Bluetooth 기기가 연결되었음에도 인식되지 않거나, 오디오 라우팅이 동작하지 않는 문제가 발생했습니다.</p>
<p>이에 따라, <code>BluetoothAdapter</code>를 직접 사용하지 않고, 대신 <code>AudioManager</code>를 활용한 오디오 라우팅 방식으로 전환하였습니다.
<code>AudioManager</code>를 통해 system-level에서 인식된 블루투스 오디오 기기에 대해서는 <code>android.permission.BLUETOOTH_CONNECT</code> 권한 없이도 라우팅이 가능합니다.</p>
<h2 id="네이티브-모듈-작성--audiodevicemodule">네이티브 모듈 작성 – AudioDeviceModule</h2>
<p>React Native 앱에서 Android의 오디오 디바이스 상태를 감지하고, Bluetooth/Wired/Speaker 디바이스로 오디오 라우팅을 제어하기 위해 커스텀 네이티브 모듈인 AudioDeviceModule을 구현했습니다.</p>
<h3 id="audiodevicecallback으로-디바이스-감지">AudioDeviceCallback으로 디바이스 감지</h3>
<p>Android에서는 오디오 디바이스의 연결/해제 이벤트를 감지하기 위해 <code>AudioDeviceCallback</code>을 등록할 수 있습니다.</p>
<pre><code class="language-java">@TargetApi(Build.VERSION_CODES.M)
private final AudioDeviceCallback audioDeviceCallback = new AudioDeviceCallback() {
  @Override
  public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
    Log.d(&quot;AudioDeviceModule&quot;, &quot;onAudioDevicesAdded: &quot; + addedDevices.length);
    AudioDeviceInfo[] allDevices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
    applyPreferredAudioRouteAndSelect(allDevices);
  }

  @Override
  public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) {
    Log.d(&quot;AudioDeviceModule&quot;, &quot;onAudioDevicesRemoved: &quot; + removedDevices.length);
    AudioDeviceInfo[] allDevices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
    applyPreferredAudioRouteAndSelect(allDevices);
  }
};</code></pre>
<ul>
<li>addedDevices / removedDevices에는 이벤트 시점에 추가/제거된 디바이스 목록이 들어옵니다.</li>
<li>하지만 오디오 라우팅 우선순위(bluetooth &gt; wired &gt; speaker)를 일관되게 유지하기 위해, 이벤트 발생 시마다 전체 디바이스 목록을 조회하여 라우팅을 결정하도록 구성했습니다.</li>
</ul>
<h3 id="오디오-라우팅-로직-우선순위-기반-설정">오디오 라우팅 로직: 우선순위 기반 설정</h3>
<p>라우팅 우선순위는 다음과 같습니다:</p>
<ol>
<li>Bluetooth SCO</li>
<li>Bluetooth A2DP</li>
<li>Wired (유선 헤드셋/USB)</li>
<li>Built-in Speaker</li>
</ol>
<pre><code class="language-java">private AudioDeviceInfo applyPreferredAudioRouteAndSelect(AudioDeviceInfo[] devices) {
  Log.d(&quot;AudioRouting&quot;, &quot;Applying preferred audio route...&quot;);
  AudioManager audioManager = (AudioManager) reactContext.getSystemService(Context.AUDIO_SERVICE);
  audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);

  AudioDeviceInfo bluetoothSco = null;
  AudioDeviceInfo bluetoothA2dp = null;
  AudioDeviceInfo wired = null;
  AudioDeviceInfo speaker = null;

  for (int i = devices.length - 1; i &gt;= 0; i--) {
    AudioDeviceInfo device = devices[i];
    if (!device.isSink()) {
      continue;
    }
    Log.d(&quot;AudioRouting&quot;, &quot;Detected output device: &quot; +
        getDeviceTypeString(device.getType()) + &quot; / ID: &quot; + device.getId() +
        &quot; / Name: &quot; + device.getProductName());

    int type = device.getType();
    if (type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO &amp;&amp; bluetoothSco == null) {
      bluetoothSco = device;
    } else if (type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP &amp;&amp; bluetoothA2dp == null) {
      bluetoothA2dp = device;
    } else if ((type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
        type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
        type == AudioDeviceInfo.TYPE_USB_HEADSET ||
        type == AudioDeviceInfo.TYPE_USB_DEVICE) &amp;&amp; wired == null) {
      wired = device;
    } else if (type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER &amp;&amp; speaker == null) {
      speaker = device;
    }
  }

  try {
    if (bluetoothSco != null) {
      stopBluetoothIfOn(audioManager);
      audioManager.setSpeakerphoneOn(false);
      audioManager.startBluetoothSco();
      audioManager.setBluetoothScoOn(true);
      Log.d(&quot;AudioRouting&quot;, &quot;Bluetooth SCO route applied: &quot; + bluetoothSco.getProductName());
      return bluetoothSco;
    }

    if (bluetoothA2dp != null) {
      stopBluetoothIfOn(audioManager);
      audioManager.setSpeakerphoneOn(false);
      Log.d(&quot;AudioRouting&quot;, &quot;Bluetooth A2DP route applied: &quot; + bluetoothA2dp.getProductName());
      return bluetoothA2dp;
    }

    if (wired != null) {
      audioManager.setSpeakerphoneOn(false);
      stopBluetoothIfOn(audioManager);
      Log.d(&quot;AudioRouting&quot;, &quot;Wired route applied: &quot; + wired.getProductName());
      return wired;
    }

    if (speaker != null) {
      stopBluetoothIfOn(audioManager);
      audioManager.setSpeakerphoneOn(true);
      Log.d(&quot;AudioRouting&quot;, &quot;Speaker route applied: &quot; + speaker.getProductName());
      return speaker;
    }
  } catch (Exception e) {
    Log.e(&quot;AudioRouting&quot;, &quot;Error while applying preferred route&quot;, e);
  }

  Log.w(&quot;AudioRouting&quot;, &quot;No suitable output device found&quot;);
  return null;
}

private void stopBluetoothIfOn(AudioManager audioManager) {
  if (audioManager.isBluetoothScoOn()) {
    audioManager.stopBluetoothSco();
    audioManager.setBluetoothScoOn(false);
  }
}</code></pre>
<h2 id="마무리">마무리</h2>
<p>처음엔 Android OS가 오디오 라우팅을 자동으로 잘 처리해줄 줄 알았는데, WebRTC 기반의 통화 앱에서는 그렇지 않더라고요. 특히 Bluetooth 기기가 연결되었다고 해서 바로 통화용 SCO 오디오가 활성화되는 것도 아니었습니다.</p>
<p>그래서  <a href="hhttps://github.com/react-native-webrtc/react-native-incall-manager"><code>react-native-incall-manager</code></a>를 사용했지만, Android 12(API 31) 이상부터는 <code>android.permission.BLUETOOTH_CONNECT</code> 권한이 없으면 Bluetooth 기기를 인식하거나 라우팅하는 데 제약이 생겼습니다.</p>
<p>결국 <code>BluetoothAdapter</code> 대신 <code>AudioManager</code>를 활용한 방식으로 전환했고, 별도 권한 요청 없이도 Bluetooth 오디오 기기에 자동 라우팅이 가능해졌습니다. 이제 <code>AndroidManifest.xml</code>에서 <code>android.permission.BLUETOOTH_CONNECT</code>를 제거해도 Bluetooth로 정상 라우팅됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Serverless로 GatherTown 스타일의 "Small Village" 구현 - Part 3: Tilemap으로 배경 만들기]]></title>
            <link>https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-3-Tilemap%EC%9C%BC%EB%A1%9C-%EB%B0%B0%EA%B2%BD-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-3-Tilemap%EC%9C%BC%EB%A1%9C-%EB%B0%B0%EA%B2%BD-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Sat, 23 Nov 2024 16:58:20 GMT</pubDate>
            <description><![CDATA[<blockquote>
<ol>
<li><a href="https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-1">Serverless로 GatherTown 스타일의 &quot;Small Village&quot; 구현 - Part 1</a></li>
<li><a href="https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-2">Serverless로 GatherTown 스타일의 &quot;Small Village&quot; 구현 - Part 2</a></li>
<li><a href="https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-3-Tilemap%EC%9C%BC%EB%A1%9C-%EB%B0%B0%EA%B2%BD-%EB%A7%8C%EB%93%A4%EA%B8%B0">Serverless로 GatherTown 스타일의 &quot;Small Village&quot; 구현 - Part 3: Tilemap으로 배경 만들기</a></li>
</ol>
</blockquote>
<p>이번 글에서는 Tiled와 Phaser를 사용하여 타일맵 기반 배경을 구현해 보도록 하겠습니다.</p>
<p>아래 링크를 클릭해 &quot;Small Village&quot;를 바로 방문해 보실 수 있습니다.
👉 <a href="https://smallvillage.netlify.app/">smallvillage.netlify.app</a></p>
<h1 id="tilemap과-tileset-개념">Tilemap과 Tileset 개념</h1>
<h2 id="tilemap이란">Tilemap이란?</h2>
<p>Tilemap은 2D 게임의 배경, 지형, 구조물을 구성하기 위해 사용하는 데이터 구조로, 작은 정사각형 또는 직사각형 이미지를 격자(Grid) 형태로 배치하여 맵을 만듭니다. Tilemap은 JSON 또는 TMX 형식으로 저장되며, 각 타일이 어떤 이미지인지, 위치는 어디인지를 나타내는 정보를 포함합니다.</p>
<p>다음은 Super Mario Bros의 전체 맵 예시입니다. 이 맵은 Tileset의 이미지 타일을 조합하여 구성되었습니다. Tilemap은 이런 맵을 구성하는 타일 ID와 위치 정보를 포함하고 있습니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/929bebf9-bea6-4e51-8fca-7a8290ae4d9b/image.png" alt=""></p>
<h2 id="tileset이란">Tileset이란?</h2>
<p>Tileset은 Tilemap에서 사용하는 타일 이미지들의 집합입니다. 여러 개의 타일을 하나의 스프라이트 시트 형태로 묶은 이미지 파일로 제공됩니다. 게임 엔진은 Tileset에서 특정 타일을 참조하여 Tilemap에 배치합니다.</p>
<p>다음은 Super Mario Bros의 Tileset 예시입니다. 이 Tileset에는 풀밭, 길, 블록 등 다양한 요소가 포함되어 있으며, 타일 하나하나가 Tilemap의 일부로 사용됩니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/e71faa32-e38b-44ca-8403-0737da933309/image.png" alt=""></p>
<h1 id="small-village의-tileset은">Small Village의 Tileset은</h1>
<p>Small Village 프로젝트의 맵은 <a href="https://limezu.itch.io/serenevillagerevamped">Serene Village - revamped - RPG Tileset</a>을 사용해 제작되었습니다. 이 Tileset은 RPG 스타일의 마을을 만들기에 적합한 타일을 포함하고 있으며, 게임의 분위기를 한층 더 풍부하게 만들어줍니다.</p>
<p>다음은 Serene Village Tileset의 일부입니다. 여기에는 길, 나무, 건물, 꽃 등의 타일이 포함되어 있어 다양한 맵을 제작할 수 있습니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/39ee6b8c-dcd5-40b9-9764-2d7751bf4fc7/image.png" alt=""></p>
<h1 id="tiled로-맵-생성하기">Tiled로 맵 생성하기</h1>
<h2 id="tiled란-무엇인가">Tiled란 무엇인가?</h2>
<p><a href="https://www.mapeditor.org/">Tiled</a>는 2D 타일 기반 게임 맵을 제작할 수 있는 오픈소스 도구입니다. 간단한 레이아웃부터 복잡한 맵까지 직관적으로 제작이 가능합니다.</p>
<h3 id="새-맵-생성">새 맵 생성</h3>
<ul>
<li>Tiled를 열고 File &gt; New &gt; New Map를 클릭합니다.</li>
<li>타일 크기를 설정합니다. tileset의 크기에 맞게 넣어줍니다.</li>
<li>맵 크기도 정해줍니다.</li>
<li>Tile layer format은 <code>Base64 uncompressed</code>로 선택합니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/687922cb-e135-4f17-ae6f-e98424a7b00a/image.png" alt=""></p>
<h3 id="tileset-추가">Tileset 추가</h3>
<ul>
<li>File &gt; New &gt; New Tileset을 선택하여 사용할 tileset 이미지를 추가합니다.</li>
<li>tileset 크기는 맵의 타일 크기와 동일해야 합니다.</li>
<li><code>Embed in map</code>을 체크해줍니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/b18bf4bf-9dad-49b6-b210-c1c99d3c77e4/image.png" alt=""></p>
<h1 id="tilemap-로딩">Tilemap 로딩</h1>
<p>Tilemap을 Phaser로 로딩하려면, Tileset 이미지와 Tiled로 생성한 맵 파일을 각각 로드하고 연결해야 합니다. 이 과정에서 Phaser의 <code>tilemapTiledJSON()</code>과 <code>addTilesetImage()</code> 메서드를 활용합니다.</p>
<p><code>addTilesetImage()</code>의 첫 번째 매개변수는 Tiled에서 Tileset을 생성할 때 입력한 이름입니다. (예: &quot;Serene_Village_32x32&quot;) 두 번째 매개변수는 Phaser에서 로드한 Tileset 이미지의 키입니다. (예: &quot;serene-village&quot;)</p>
<pre><code class="language-typescript">preload() {
  // map
  this.load.image(
    &quot;serene-village&quot;,
    &quot;/assets/tilesets/Serene_Village_32x32.png&quot;
  );
  this.load.tilemapTiledJSON(&quot;default&quot;, &quot;/assets/tilemaps/default.json&quot;);
}

async create() {
  // map
  const map = this.make.tilemap({ key: &quot;default&quot;, tileWidth: 32, tileHeight: 32 });
  const tileset = map.addTilesetImage(&quot;Serene_Village_32x32&quot;, &quot;serene-village&quot;);

  const groundLayer = map.createLayer(&quot;ground&quot;, tileset, 0, 0);
  const decoration0Layer = map.createLayer(&quot;decoration_0&quot;, tileset, 0, 0);
  const decoration1Layer = map.createLayer(&quot;decoration_1&quot;, tileset, 0, 0);
  const decoration2Layer = map.createLayer(&quot;decoration_2&quot;, tileset, 0, 0);
  const above0Layer = map.createLayer(&quot;above_0&quot;, tileset, 0, 0);
  const above1Layer = map.createLayer(&quot;above_1&quot;, tileset, 0, 0);

  above0Layer.setDepth(10);
  above1Layer.setDepth(11);

}</code></pre>
<p>Tiled로 작업할 때, 레이어를 여러 개로 분리하는 것은 일반적인 디자인 패턴입니다. 이렇게 하면 맵 요소를 &quot;깊이&quot;에 따라 배치할 수 있어 시각적 효과와 상호작용을 더 자연스럽게 구현할 수 있습니다.</p>
<p>예를 들어 집 앞쪽에 우체통, 집 뒤쪽에 나무들을 겹쳐서 배치할 수 있습니다. 또한 캐릭터가 집/나무 뒤쪽에 있을 때는 집/나무가 캐릭터 위쪽에 표시되도록 할 수 있습니다.</p>
<p>기본적으로 레이어의 순서는 생성된 순서에 따라 결정됩니다. 그러나 above0Layer와 above1Layer와 같이 캐릭터 위에 표시되어야 하는 레이어의 경우, <code>setDepth()</code> 메서드를 사용하여 깊이 값을 명시적으로 설정해 캐릭터보다 상위에 렌더링되도록 합니다.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/7b97db37-3abb-432a-b37b-93293802549f/image.gif" alt=""></p>
<h1 id="충돌처리">충돌처리</h1>
<p>맵을 만들었다면, 이제 캐릭터가 물 위를 걷거나 집을 통과하지 못하도록 충돌 처리를 설정해야 합니다. 이를 위해 Tiled에서 특정 타일에 충돌 속성을 추가한 후, Phaser에서 이를 반영합니다.</p>
<p>Tiled에서 Tilesets 화면 하단의 Edit Tileset 버튼을 클릭해서 타일셋 편집기를 엽니다.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/ac13fe33-5c00-428e-96dc-fd2fd2b19f79/image.png" alt=""></p>
<p>캐릭터와 충돌처리가 되어야 하는 타일들을 선택하고 편집기 왼쪽 하단의 + 버튼을 누르고 &quot;collides&quot;라는 이름의 bool 속성을 추가하고 체크합니다.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/1d222790-8191-405d-b156-8a2ecb651995/image.png" alt=""></p>
<p>Tiled에서 설정한 &quot;collides&quot; 속성을 기반으로, Phaser에서 해당 타일과 캐릭터 간의 충돌 처리를 설정합니다.</p>
<pre><code class="language-typescript">decoration0Layer.setCollisionByProperty({ collides: true });
decoration1Layer.setCollisionByProperty({ collides: true });
decoration2Layer.setCollisionByProperty({ collides: true });

this.physics.add.collider(this.sprite, decoration0Layer);
this.physics.add.collider(this.sprite, decoration1Layer);
this.physics.add.collider(this.sprite, decoration2Layer);</code></pre>
<p>개발 중 충돌 처리가 제대로 설정되었는지 확인하려면, 충돌 타일을 시각적으로 표시할 수 있습니다.</p>
<pre><code class="language-typescript">const debugGraphics = this.add.graphics().setAlpha(0.75);

decoration0Layer.renderDebug(debugGraphics, {
  tileColor: null,
  collidingTileColor: new Phaser.Display.Color(243, 134, 48, 255), // 충돌 타일 색상
  faceColor: new Phaser.Display.Color(40, 39, 37, 255), // 타일의 모서리 색상
});</code></pre>
<blockquote>
</blockquote>
<p>캐릭터 이동 처리 시에 <code>setVelocityX()</code>, <code>setVelocityY()</code>을 사용해야 충돌 감지가 됩니다. </p>
<h1 id="마치며">마치며</h1>
<p>이번 글에서는 Tilemap을 활용해 Small Village의 기본 모습을 구현해봤습니다. Tiled로 타일맵을 디자인하고, Phaser에서 이를 로드해 맵을 구성하는 과정을 함께 살펴봤습니다. 이렇게 맵을 만들어 보니, 정말로 Gather Town과 비슷한 분위기가 느껴지네요!</p>
<p>맵을 제작하면서 Tilemap과 Tileset의 관계를 이해하고, 레이어를 분리하여 시각적 깊이감과 충돌 처리를 구현해 봤습니다. 특히, 집 뒤에 캐릭터가 숨거나 나무를 피해가는 모습은 단순한 기능이지만, 게임의 완성도를 크게 높여주는 중요한 요소인 것 같습니다.</p>
<p>프로젝트 전체 코드는 GitHub 저장소에서 확인하실 수 있습니다. <a href="https://github.com/hissinger/small-village">https://github.com/hissinger/small-village</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Serverless로 GatherTown 스타일의 "Small Village" 구현 - Part 2]]></title>
            <link>https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-2</link>
            <guid>https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-2</guid>
            <pubDate>Mon, 11 Nov 2024 15:17:23 GMT</pubDate>
            <description><![CDATA[<blockquote>
<ol>
<li><a href="https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-1">Serverless로 GatherTown 스타일의 &quot;Small Village&quot; 구현 - Part 1</a></li>
<li><a href="https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-2">Serverless로 GatherTown 스타일의 &quot;Small Village&quot; 구현 - Part 2</a></li>
<li><a href="https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-3-Tilemap%EC%9C%BC%EB%A1%9C-%EB%B0%B0%EA%B2%BD-%EB%A7%8C%EB%93%A4%EA%B8%B0">Serverless로 GatherTown 스타일의 &quot;Small Village&quot; 구현 - Part 3: Tilemap으로 배경 만들기</a></li>
</ol>
</blockquote>
<p>지난 글에서는 Small Village 프로젝트의 첫 번째 단계로 캐릭터 선택 기능을 구현했습니다. 이번 글에서는 선택한 캐릭터를 방향키로 이동하게 하고, Supabase를 사용하여 캐릭터의 위치를 다른 유저들과 실시간으로 동기화하는 기능을 추가하겠습니다.</p>
<p>아래 링크를 클릭해 &quot;Small Village&quot;를 바로 방문해 보실 수 있습니다.
👉 <a href="https://smallvillage.netlify.app/">smallvillage.netlify.app</a></p>
<h1 id="캐릭터-이미지-변경">캐릭터 이미지 변경</h1>
<p>우선 캐릭터 이미지 리소스를 더 다양하게 활용하기 위해, 이전에 사용한 3개의 캐릭터 대신, 구글링을 통해 40개의 캐릭터를 포함한 새로운 리소스(Jephed, Game Between The Lines, <a href=https://gamebetweenthelines.com>https://gamebetweenthelines.com</a>)를 가져왔습니다. 이 이미지들은 캐릭터 단위로 개별적인 파일로 나뉘어 있으며, 각 캐릭터의 애니메이션을 구현하기 용이한 형태로 되어 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/db538afe-6673-4527-8a0d-a971c9e75972/image.png" alt=""></p>
<p>이미지 프레임 구성은 다음과 같습니다.</p>
<ul>
<li>0, 1, 2 프레임: 아래 방향 걷기</li>
<li>3, 4, 5 프레임: 왼쪽 방향 걷기</li>
<li>6, 7, 8 프레임: 오른쪽 방향 걷기</li>
<li>9, 10, 11 프레임: 위 방향 걷기</li>
</ul>
<p>이를 바탕으로 Phaser에서 캐릭터 애니메이션을 생성하는 코드를 작성해 보겠습니다. 각 캐릭터마다 상하좌우로 걷는 애니메이션을 구현하여 실제 게임 환경에서 다양한 캐릭터의 움직임을 표현할 수 있습니다.</p>
<pre><code class="language-typescript">const NUM_CHARACTERS = 40;

preload() {
  for (let i = 0; i &lt; NUM_CHARACTERS; i++) {
    const index = i.toString().padStart(3, &quot;0&quot;);
    this.load.spritesheet(`character_${i}`, `/assets/${index}.png`, {
      frameWidth: 20,
      frameHeight: 32,
    });
  }
}

createAnimations() {
  for (let i = 0; i &lt; NUM_CHARACTERS; i++) {
    this.createWalkAnimation(i, `walk_down_${i}`, 0, 3); // 0,1,2 프레임
    this.createWalkAnimation(i, `walk_left_${i}`, 3, 3); // 3,4,5 프레임
    this.createWalkAnimation(i, `walk_right_${i}`, 6, 3); // 6,7,8 프레임
    this.createWalkAnimation(i, `walk_up_${i}`, 9, 3); // 9,10,11 프레임
  }
}

createWalkAnimation(
  characterIndex: number,
  key: string,
  startFrame: number,
  frameCount: number
): void {
  this.anims.create({
    key,
    frames: this.anims.generateFrameNumbers(`character_${characterIndex}`, {
      start: startFrame,
      end: startFrame + frameCount - 1,
    }),
    frameRate: 3,
    repeat: -1,
  });
}</code></pre>
<p>위 코드에서 <code>preload()</code>는 40개의 캐릭터 이미지를 한 번에 로드하며, <code>createAnimations()</code>에서는 각 캐릭터의 네 방향 걷기 애니메이션을 설정합니다.</p>
<h1 id="방향키로-캐릭터-이동-구현하기">방향키로 캐릭터 이동 구현하기</h1>
<blockquote>
<p>방향키 입력 구현에 들어가기 전에, phaser의 주요 메서드 <code>preload()</code>, <code>create()</code>, <code>update()</code>를 간단히 살펴보겠습니다.</p>
</blockquote>
<ul>
<li><code>preload</code>: 게임이 시작되기 전에 한 번 호출되며, 필요한 리소스를 미리 로드합니다.</li>
<li><code>create</code>: 게임 오브젝트를 생성하고 초기 설정을 수행합니다.</li>
<li><code>update</code>: 초당 여러 번 호출되며, 사용자 입력을 받아 캐릭터 이동, 충돌 체크 등 게임의 상태를 지속적으로 업데이트합니다</li>
</ul>
<h2 id="phaser의-입력-처리">Phaser의 입력 처리</h2>
<p>이제 Phaser에서 방향키 입력을 처리하여 캐릭터가 상하좌우로 자연스럽게 움직일 수 있도록 구현해 보겠습니다. Phaser의 <code>this.input.keyboard.createCursorKeys()</code>를 사용하여 방향키 입력을 처리할 수 있으며, <code>isDown</code> 상태를 통해 키가 눌린 방향으로 캐릭터를 이동시킬 수 있습니다.</p>
<pre><code class="language-typescript">async create() {
  // 방향키 입력 설정
  this.cursors = this.input.keyboard?.createCursorKeys() || null;

  // 애니메이션 및 캐릭터 생성 코드
  // 생략 ...
}

async update() {
  const speed = 2;

  if (this.cursors.left?.isDown) {
    this.sprite.play(`walk_left_${this.characterIndex}`, true);
    this.sprite.x -= speed;
  } else if (this.cursors.right?.isDown) {
    this.sprite.play(`walk_right_${this.characterIndex}`, true);
    this.sprite.x += speed;
  } else if (this.cursors.up?.isDown) {
    this.sprite.play(`walk_up_${this.characterIndex}`, true);
    this.sprite.y -= speed;
  } else if (this.cursors.down?.isDown) {
    this.sprite.play(`walk_down_${this.characterIndex}`, true);
    this.sprite.y += speed;
  } else {
    this.sprite.anims.stop();
  }
}</code></pre>
<p>위 코드에서 <code>create()</code> 메서드에서는 방향키 입력을 감지할 수 있도록 설정하고, <code>update()</code> 메서드에서는 각 방향키 입력에 따라 캐릭터의 위치를 변경해 줍니다. 이 <code>update()</code> 메서드는 매 프레임마다 호출되기 때문에 자연스러운 캐릭터 이동이 가능합니다. <code>this.characterIndex</code> 은 내가 선택한 캐릭터의 index 입니다. 방향키가 입력되면 위에서 생성한 애니매이션 중에 선택된 캐릭터의 해당 방향의 애니메이션이 play 되는 거죠.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/c9a18282-8b43-4ce4-962e-ae4ea67581c0/image.gif" alt=""></p>
<h2 id="캐릭터와-이름-출력">캐릭터와 이름 출력</h2>
<p>이제 본인의 캐릭터와 해당 캐릭터 이름을 화면에 표시해 보겠습니다. <code>Phaser.Text</code> 객체를 활용하여 캐릭터 이름을 캐릭터 위에 출력할 수 있습니다.</p>
<pre><code class="language-typescript">async create() {

  // 생략 ...

  // 캐릭터 생성
  this.sprite = this.physics.add
    .sprite(width / 2, height / 2, `character_${this.characterIndex}`, 0)
    .setScale(2)
    .setCollideWorldBounds(true)
    .setOrigin(0.5, 0.5);

  // 유저 이름 text 생성
  this.nameText = this.add
    .text(
    this.sprite.x,
    this.sprite.y - 50,
    this.characterName,
    {
      fontSize: &quot;16px&quot;,
      color: &quot;#fff&quot;,
      align: &quot;center&quot;,
    }
  )
  .setOrigin(0.5, 0.5);

  // 생략 ...
}</code></pre>
<p>자 이렇게 하면 본인 캐릭터는 준비가 끝났습니다. 이제 다른 유저와 실시간으로 위치 정보를 동기화 해 봅시다.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/31e6a216-d584-4746-b287-8073bb6422b6/image.png" alt=""></p>
<h1 id="supabase를-활용한-위치-동기화">Supabase를 활용한 위치 동기화</h1>
<p>다른 사용자들과 캐릭터 위치를 실시간으로 공유하기 위해 Supabase의 실시간 데이터베이스 기능을 활용해 보겠습니다. Supabase는 실시간 데이터베이스 기능을 제공하여, 캐릭터 위치 정보를 쉽게 동기화할 수 있습니다. 각 유저의 위치와 상태는 users 테이블에 저장되며, 모든 유저가 변경 사항을 구독함으로써 서로의 최신 위치 정보를 즉시 받아볼 수 있습니다.</p>
<h2 id="supabase-프로젝트-생성-및-설정">Supabase 프로젝트 생성 및 설정</h2>
<h3 id="1-supabase-프로젝트-생성">1. Supabase 프로젝트 생성</h3>
<p>Supabase 대시보드에서 Create new project 버튼을 클릭하여 새로운 프로젝트를 생성합니다. 
<img src="https://velog.velcdn.com/images/woody_ahn/post/ab7cdcd0-78c0-4672-8866-f82f19ec03bc/image.png" alt=""></p>
<p>프로젝트가 생성되면 프로젝트의 URL과 API Key가 제공됩니다. 이 정보는 후속 설정에 필요하니 기록해 둡니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/54024460-0fea-42c4-a651-c4c04c7de35d/image.png" alt=""></p>
<h3 id="2-supabase-패키지-설치">2. Supabase 패키지 설치</h3>
<p>프로젝트에서 Supabase 클라이언트를 사용하기 위해, 다음 명령어로 <code>@supabase/supabase-js</code> 패키지를 설치합니다.</p>
<pre><code class="language-bash">npm install @supabase/supabase-js --save</code></pre>
<h3 id="3-supabase-클라이언트-설정">3. Supabase 클라이언트 설정</h3>
<p>Supabase 클라이언트를 설정하기 위해 <code>supabaseClient.ts</code> 파일을 생성하고 다음 코드를 추가합니다. 환경 변수에 <code>REACT_APP_SUPABASE_URL</code>과 <code>REACT_APP_SUPABASE_KEY</code> 값을 저장하여 보안을 유지합니다.</p>
<pre><code class="language-typescript">import { createClient } from &quot;@supabase/supabase-js&quot;;

const supabaseUrl = process.env.REACT_APP_SUPABASE_URL as string;
const supabaseKey = process.env.REACT_APP_SUPABASE_KEY as string;

export const supabase = createClient(supabaseUrl, supabaseKey);</code></pre>
<h2 id="users-table-생성">Users Table 생성</h2>
<p>사용자 정보와 위치를 저장할 users 테이블을 생성합니다.</p>
<pre><code class="language-sql">create table
  public.users (
    user_name text not null default &#39;&#39;::text,
    character_index integer not null,
    created_at timestamp with time zone not null default now(),
    x integer not null,
    y integer not null,
    user_id text not null,
    constraint users_pkey primary key (user_id)
  ) tablespace pg_default;</code></pre>
<h2 id="내-캐릭터의-상태-저장-및-업데이트">내 캐릭터의 상태 저장 및 업데이트</h2>
<p>캐릭터가 생성된 후, 현재 상태를 users 테이블에 삽입하여 위치 정보를 기록합니다. 캐릭터가 움직일 때마다 위치가 업데이트되도록 구현하여 실시간 위치 동기화가 이루어질 수 있도록 합니다.</p>
<pre><code class="language-typescript">await supabase.from(&quot;users&quot;).insert({
  user_id: this.userId,
  user_name: this.characterName,
  character_index: this.characterIndex,
  x: Math.floor(this.sprite.x),
  y: Math.floor(this.sprite.y),
});</code></pre>
<p>그리고 캐릭터가 이동될때 마다 위치를 업데이트 합니다. phaser의 <code>update()</code>는 매 프레임마다 호출되므로, 캐릭터의 위치가 실제로 변경되었을 때만 업데이트가 이루어지도록 조건을 추가합니다. 이렇게 하면 불필요한 업데이트 호출을 줄이고 성능을 최적화할 수 있습니다.</p>
<pre><code class="language-typescript">if (isMoving) {
  await supabase.from(&quot;users&quot;).upsert({
    user_id: this.userId,
    user_name: this.characterName,
    character_index: this.characterIndex,
    x: Math.floor(this.sprite.x),
    y: Math.floor(this.sprite.y),
  });
}</code></pre>
<h2 id="다른-유저의-캐릭터-상태-동기화">다른 유저의 캐릭터 상태 동기화</h2>
<p>Supabase 실시간 데이터베이스를 통해 모든 사용자가 다른 유저들의 최신 위치 정보를 실시간으로 반영할 수 있도록 구독 기능을 설정합니다. 실시간 데이터 수신 및 반영 과정은 다음과 같은 세 가지 단계로 이루어집니다.</p>
<h3 id="1-초기-데이터-로딩">1. 초기 데이터 로딩</h3>
<p>컴포넌트가 처음 렌더링될 때, users 테이블에서 현재 접속한 유저 데이터를 불러옵니다. 각 유저의 <code>user_id</code>, <code>character_index</code>, <code>user_name</code>, <code>x</code>, <code>y</code> 정보를 가져와 SmallVillageScene 추가합니다. 이를 통해 초기 화면에 접속한 유저들의 상태를 표시할 수 있습니다.</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const fetchData = async () =&gt; {
    const { data, error } = await supabase.from(&quot;users&quot;).select(&quot;*&quot;);
    if (error) {
      console.error(&quot;Error loading initial data:&quot;, error);
      return;
    }
    gameInstanceRef.current?.scene.getScene(&quot;SmallVillageScene&quot;)?.updateUsers(data);
  };

  fetchData();
}, []);</code></pre>
<h3 id="2-다른-유저의-캐릭터-상태-동기화">2. 다른 유저의 캐릭터 상태 동기화</h3>
<p>Supabase의 실시간 기능을 통해 users 테이블의 변경 사항을 실시간으로 구독하여, 다른 유저가 접속하거나 위치를 업데이트하거나, 게임에서 나갈 때마다 해당 변경 사항을 화면에 반영합니다.</p>
<ul>
<li><p>INSERT 이벤트: 새로운 유저가 접속할 때 발생하며, 해당 유저의 캐릭터가 화면에 추가됩니다.</p>
</li>
<li><p>UPDATE 이벤트: 다른 유저의 위치가 변경되면, 캐릭터의 위치와 애니메이션이 업데이트됩니다.</p>
</li>
<li><p>DELETE 이벤트: 유저가 게임에서 나가거나 비활성 상태가 되면 발생하며, 해당 유저의 캐릭터가 화면에서 제거됩니다.</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
const usersChannel = supabase
  .channel(&quot;realtime:public:users&quot;)
  .on(&quot;postgres_changes&quot;, { event: &quot;INSERT&quot;, schema: &quot;public&quot;, table: &quot;users&quot; }, (payload) =&gt; {
    if (payload.new.user_id !== userId) {
      const scene = gameInstanceRef.current?.scene.getScene(&quot;SmallVillageScene&quot;);
      if (scene) {
        scene.addUser(payload.new); // 새로운 유저를 추가
      }
    }
  })
  .on(&quot;postgres_changes&quot;, { event: &quot;UPDATE&quot;, schema: &quot;public&quot;, table: &quot;users&quot; }, (payload) =&gt; {
    if (payload.new.user_id !== userId) {
      const scene = gameInstanceRef.current?.scene.getScene(&quot;SmallVillageScene&quot;);
      if (scene) {
        scene.updateUser(payload.new); // 유저 위치 업데이트
      }
    }
  })
  .on(&quot;postgres_changes&quot;, { event: &quot;DELETE&quot;, schema: &quot;public&quot;, table: &quot;users&quot; }, (payload) =&gt; {
    const scene = gameInstanceRef.current?.scene.getScene(&quot;SmallVillageScene&quot;);
    if (scene) {
      scene.removeUser(payload.old.user_id); // 유저 삭제
    }
  })
  .subscribe();

return () =&gt; {
  usersChannel.unsubscribe();
};
}, [userId]);</code></pre>
</li>
</ul>
<h3 id="3-캐릭터-상태를-smallvillagescene에-반영">3. 캐릭터 상태를 SmallVillageScene에 반영</h3>
<p>users 테이블의 실시간 정보를 기반으로 각 유저의 최신 위치 정보에 맞추어 캐릭터의 위치와 애니메이션이 변경되도록 합니다.
아래 코드는 업데이트 된 유저들 정보를 바탕으로 화면에 표시하는 함수입니다. 이 함수는 매 프레임마다 호출되는 phaser의 <code>update()</code>에서 호출되어야 합니다.
실시간이기는 하지만 네트워크를 통해서 데이터를 받다 보니 움직임이 부자연스러울 수 있습니다. 트윈 애니메이션을 사용해서 위치가 큰 차이가 있을 때에도 부드러운 이동을 유지할 수 있습니다.</p>
<pre><code class="language-typescript">private updateOtherUsers() {
  const MIN_DISTANCE = 2;

  Object.entries(this.userSprites).forEach(([userId, userSprite]) =&gt; {
    let isMoving = false;
    const userData = this.users.find((u) =&gt; u.user_id === userId);
    if (userData) {
      const sprite = userSprite.sprite;
      const distanceX = Math.abs(userData.x - sprite.x);
      const distanceY = Math.abs(userData.y - sprite.y);
      const characterIndex = userData.character_index;

      // X축 이동 처리
      if (distanceX &gt; MIN_DISTANCE) {
        if (userData.x &lt; sprite.x) {
          isMoving = true;
          sprite.play(`walk_left_${characterIndex}`, true);
        } else {
          isMoving = true;
          sprite.play(`walk_right_${characterIndex}`, true);
        }
      }

      // Y축 이동 처리
      if (distanceY &gt; MIN_DISTANCE) {
        if (userData.y &lt; sprite.y) {
          isMoving = true;
          sprite.play(`walk_up_${characterIndex}`, true);
        } else {
          isMoving = true;
          sprite.play(`walk_down_${characterIndex}`, true);
        }
      }

      // 이동이 없는 경우 애니메이션 멈춤
      if (!isMoving) {
        sprite.anims.stop();
      }

      // 부드러운 이동을 위한 트윈 설정
      this.tweens.add({
        targets: sprite,
        x: userData.x,
        y: userData.y,
        duration: 100,
        ease: &quot;Linear&quot;,
        onUpdate: () =&gt; {
          userSprite.nameText?.setPosition(sprite.x, sprite.y - 50);
        },
      });
    }
  });
}</code></pre>
<p>자 이제 다 끝났습니다.</p>
<p align="center">
    <img src="https://velog.velcdn.com/images/woody_ahn/post/c33a811e-c80a-4abd-a45a-c922b3bf0106/image.gif" width="900px">
</p>

<p>아직은 배경도 없고 채팅, 음성/영상 대화도 할 수 없지만 조금은 gather town 스럽지 않나요? ^^;</p>
<h1 id="마치며">마치며</h1>
<p>이번 글에서는 “Small Village” 프로젝트에서 캐릭터 이동과 위치 동기화를 구현하는 과정을 살펴보았습니다. Supabase의 실시간 데이터베이스 기능을 활용하여 서버리스 환경에서도 사용자 간의 위치 데이터를 실시간으로 공유하고, Phaser를 통해 캐릭터 애니메이션을 자연스럽게 구현하는 방법을 소개했습니다.
더 나은 구현 방법이나 개선점이 있다면 댓글로 알려주세요.</p>
<p>다음 글에서는 supabase의 Realtime 기능을 활용해서 간단한 채팅 기능을 넣어 보겠습니다. 프로젝트 전체 코드는 GitHub 저장소에서 확인하실 수 있습니다. <a href="https://github.com/hissinger/small-village">https://github.com/hissinger/small-village</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Serverless로 GatherTown 스타일의 "Small Village" 구현 - Part 1]]></title>
            <link>https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-1</link>
            <guid>https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-1</guid>
            <pubDate>Wed, 30 Oct 2024 16:33:35 GMT</pubDate>
            <description><![CDATA[<blockquote>
<ol>
<li><a href="https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-1">Serverless로 GatherTown 스타일의 &quot;Small Village&quot; 구현 - Part 1</a></li>
<li><a href="https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-2">Serverless로 GatherTown 스타일의 &quot;Small Village&quot; 구현 - Part 2</a></li>
<li><a href="https://velog.io/@woody_ahn/Serverless%EB%A1%9C-GatherTown-%EC%8A%A4%ED%83%80%EC%9D%BC%EC%9D%98-Small-Village-%EA%B5%AC%ED%98%84-Part-3-Tilemap%EC%9C%BC%EB%A1%9C-%EB%B0%B0%EA%B2%BD-%EB%A7%8C%EB%93%A4%EA%B8%B0">Serverless로 GatherTown 스타일의 &quot;Small Village&quot; 구현 - Part 3: Tilemap으로 배경 만들기</a></li>
</ol>
</blockquote>
<p>GatherTown은 가상 공간에서 아바타를 통해 다른 사용자들과 실시간으로 소통할 수 있는 메타버스 플랫폼입니다. 전에는 이런 걸 어떻게 만드나 싶었는데 얼마전에 우연히 phaser 게임 엔진을 알게 되면서 가능할 것 같다는 생각이 들어 시작합니다.</p>
<p>아래 링크를 클릭해 &quot;Small Village&quot;를 바로 방문해 보실 수 있습니다.
👉 <a href="https://smallvillage.netlify.app/">smallvillage.netlify.app</a></p>
<h1 id="small-village">Small Village</h1>
<p>GatherTown의 모든 기능을 구현하기는 어려우니, 작은 마을 정도의 규모로 시작해보려고 합니다. 따라서 프로젝트명을 <strong>Small Village</strong>로 정했습니다. serverless 아키텍처를 활용하여 개발할 예정입니다. </p>
<p>필요한 기술스택을 정리해 보면</p>
<ul>
<li><p>Supabase: Firebase와 유사한 기능을 제공하는 오픈소스 백엔드 플랫폼입니다. 무료 플랜으로도 토이 프로젝트에 충분하며, WebRTC 시그널링 서버 역할도 수행할 수 있습니다.</p>
</li>
<li><p>ReactJS: 프론트엔드 개발을 위한 JavaScript 라이브러리입니다.</p>
</li>
<li><p>WebRTC: 실시간 음성/화상 통화 및 데이터 통신을 위한 웹 표준 기술입니다.</p>
</li>
<li><p>Phaser: HTML5 게임 개발을 위한 2D 게임 엔진으로, 캐릭터 애니메이션과 상호작용을 쉽게 구현할 수 있습니다.</p>
</li>
<li><p>Netlify: 정적 웹사이트 호스팅과 CI/CD를 제공하는 플랫폼입니다. GitHub 저장소와 연동하여 자동 배포가 가능합니다.</p>
</li>
</ul>
<h2 id="supabase">Supabase</h2>
<p>Supabase는 Firebase와 비슷한 기능을 제공하면서도, 무료 플랜이 toy 프로젝트나 간단한 백엔드로 사용하기에 적합한 장점이 있습니다. 또한 WebRTC 시그널 서버로도 쓸 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/fc3965b9-95db-480c-8436-e5494df6b942/image.png" alt=""></p>
<h2 id="netlify">Netlify</h2>
<p>Netlify는 정적 웹사이트와 프론트엔드 애플리케이션을 손쉽게 배포하고, CI/CD 파이프라인 및 서버리스 기능을 제공하는 클라우드 플랫폼입니다. github repo에 커밋만 하면 바로 deploy 할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/74508188-4516-4459-9e83-bd4c2ff0c7d5/image.png" alt=""></p>
<h2 id="phaser">Phaser</h2>
<p>Phaser는 2D 게임 개발을 위한 오픈소스 프레임워크로, 웹 브라우저에서 동작하는 HTML5 게임을 쉽게 만들 수 있게 해줍니다. 이미지를 사용해서 2D 애니메이션 sprite을 만들고 움직이게 할 수 있습니다.</p>
<p>아래와 같은 이미지로 애니메이션을 만들게 됩니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/18aa2778-5a28-42dd-b1c9-b486534b0157/image.png" alt=""></p>
<p>이번 글에서는 캐릭터 선택하는 화면을 만들어 볼 예정입니다.</p>
<h1 id="small-village-프로젝트-생성">Small Village 프로젝트 생성</h1>
<h2 id="react--typescript-기반의-프로젝트를-생성">react + typescript 기반의 프로젝트를 생성</h2>
<pre><code class="language-bash">npx create-react-app smallvillage --template typescript</code></pre>
<p>phaser도 설치해 줍니다.</p>
<pre><code class="language-bash">npm install -s phaser</code></pre>
<h2 id="캐릭터-선택을-위한-scene-생성">캐릭터 선택을 위한 scene 생성</h2>
<h3 id="sprite-sheet-로드">sprite sheet 로드</h3>
<p>사용할 이미지를 load 합니다. 이미지파일은 <code>/public/assets</code> 폴더에 넣으시면 됩니다. 그리고 우리가 사용하는 이미지의 각 캐릭터 사이즈는 16 pixel x 16 pixel 입니다.</p>
<pre><code class="language-typescript">this.load.spritesheet(&quot;characters&quot;, &quot;/assets/characters.png&quot;, {
    frameWidth: 16,
    frameHeight: 16,
});</code></pre>
<p>우리가 load한 이미지는 여러 캐릭터 이미지를 포함하고 있습니다. 이것을 스프라이트 시트라고 합니다. 스프라이트 시트의 각 이미지는 프레임이라고 부르고 왼쪽 상단이 0번 프레임입니다.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/f4b1bdb3-9926-4c0b-9eaf-1d62abed7d2e/image.png" alt=""></p>
<h3 id="애니메이션-생성">애니메이션 생성</h3>
<p>이미지의 여러 캐릭터들 중에서 왼쪽 상단의 캐릭터로부터 오른쪽으로 총 3개의 캐릭터만 사용합니다. 그리고 캐릭터 선택 화면에서는 앞을 보고 있는 이미지만 사용할 예정입니다.</p>
<ul>
<li>첫번째 캐릭터의 start 프레임은 0이고, end 프레임은 2입니다.</li>
<li>두번째 캐릭터의 start 프레임은 3이고, end 프레임은 5입니다.</li>
<li>세번째 캐릭터의 start 프레임은 6이고, end 프레임은 8입니다.</li>
</ul>
<pre><code class="language-typescript">private createAnimations() {
  for (let i = 0; i &lt; 3; i++) {
    // 각 캐릭터에 대한 애니메이션 정의
    this.anims.create({
      key: `walk_${i}`,
      frames: this.anims.generateFrameNumbers(&quot;characters&quot;, {
        start: i * 3, // 각 캐릭터의 첫 번째 프레임 인덱스
        end: i * 3 + 2, // 세 번째 프레임까지 사용 (0, 1, 2)
      }),
      frameRate: 5, // 초당 프레임 수
      repeat: -1, // 무한 반복
    });
  }
}</code></pre>
<h3 id="캐릭터-출력">캐릭터 출력</h3>
<p>각 캐릭터별로 애니메이션을 생성했으니 이제 화면에 출력해 봅시다.</p>
<pre><code class="language-typescript">private showCharacter(index: number) {
  if (this.sprite) {
    this.sprite.destroy();
  }

  const frameIndex = index * 3;
  this.sprite = this.add.sprite(60, 50, &quot;characters&quot;, frameIndex);
  this.sprite.setScale(4);
  this.sprite.setOrigin(0.5, 0.5);
  this.sprite.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
  this.sprite.play(`walk_${index}`); // 애니메이션 실행
}</code></pre>
<p>캐릭터의 사이즈가 가로 16p, 세로 16p 이다 보니 <code>setScale()</code>을 사용해서 사이즈를 4배 스케일링 해줍니다. 그리고 <code>NEAREST</code> 필터링으로 픽셀의 선명도를 유지합니다.</p>
<h3 id="characterpreviewscene-class">CharacterPreviewScene Class</h3>
<p>Phaser Scene을 생성하는 전체 코드 입니다.</p>
<pre><code class="language-typescript">interface CharacterSelectModalProps {
  onSelect: (characterIndex: number, name: string) =&gt; void;
}

class CharacterPreviewScene extends Phaser.Scene {
  private currentIndex = 0;
  private sprite: Phaser.GameObjects.Sprite | null = null;

  constructor() {
    super({ key: &quot;CharacterPreviewScene&quot; });
  }

  preload() {
    this.load.spritesheet(&quot;characters&quot;, &quot;/assets/characters.png&quot;, {
      frameWidth: 16,
      frameHeight: 16,
    });
  }

  create() {
    this.createAnimations();
    this.showCharacter(this.currentIndex);
  }
  private createAnimations() {
    for (let i = 0; i &lt; 3; i++) {
      // 각 캐릭터에 대한 애니메이션 정의 (위쪽 방향 걷기)
      this.anims.create({
        key: `walk_${i}`,
        frames: this.anims.generateFrameNumbers(&quot;characters&quot;, {
          start: i * 3, // 각 캐릭터의 첫 번째 프레임 인덱스
          end: i * 3 + 2, // 세 번째 프레임까지 사용 (0, 1, 2)
        }),
        frameRate: 5, // 초당 프레임 수
        repeat: -1, // 무한 반복
      });
    }
  }

  updateCharacter(index: number) {
    this.currentIndex = index;
    this.showCharacter(this.currentIndex);
  }

  private showCharacter(index: number) {
    if (this.sprite) {
      this.sprite.destroy();
    }

    const frameIndex = index * 3;
    this.sprite = this.add.sprite(60, 50, &quot;characters&quot;, frameIndex);
    this.sprite.setScale(4);
    this.sprite.setOrigin(0.5, 0.5);
    this.sprite.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
    this.sprite.play(`walk_${index}`); // 애니메이션 실행
  }
}</code></pre>
<h2 id="phaser-생성">Phaser 생성</h2>
<p><code>Phaser.Game</code>를 생성하면서 위에서 만든 scene을 넣어줍니다.</p>
<pre><code class="language-typescript">const config: Phaser.Types.Core.GameConfig = {
  type: Phaser.AUTO,
  width: 120,
  height: 100,
  parent: previewContainer.current as HTMLDivElement,
  scene: CharacterPreviewScene,
  pixelArt: true,
};

const game = new Phaser.Game(config);</code></pre>
<p>픽셀아트 최적화를 위해서 <code>pixelArt</code>: <code>true</code> 설정으로 이미지 스케일링 시 블러 현상을 방지합니다.</p>
<h3 id="characterselectmodal-component">CharacterSelectModal Component</h3>
<p>생성한 Scene으로 Phaser Game 객체를 만들고 React Component와 연동하는 전체 코드입니다.</p>
<pre><code class="language-typescript">const CharacterSelectModal: React.FC&lt;CharacterSelectModalProps&gt; = ({
  onSelect,
}) =&gt; {
  const [name, setName] = useState(&quot;&quot;);
  const [currentIndex, setCurrentIndex] = useState(0);
  const previewContainer = useRef&lt;HTMLDivElement&gt;(null);
  const gameInstance = useRef&lt;Phaser.Game | null&gt;(null);
  const sceneRef = useRef&lt;CharacterPreviewScene | null&gt;(null);

  useEffect(() =&gt; {
    if (!gameInstance.current) {
      const config: Phaser.Types.Core.GameConfig = {
        type: Phaser.AUTO,
        width: 120,
        height: 100,
        parent: previewContainer.current as HTMLDivElement,
        scene: CharacterPreviewScene,
        pixelArt: true,
      };

      const game = new Phaser.Game(config);
      gameInstance.current = game;

      game.events.once(Phaser.Scenes.Events.READY, () =&gt; {
        const scene = game.scene.getScene(
          &quot;CharacterPreviewScene&quot;
        ) as CharacterPreviewScene;
        if (scene) {
          sceneRef.current = scene;
        }
      });
    }

    return () =&gt; {
      gameInstance.current?.destroy(true);
      gameInstance.current = null;
    };
  }, []);

  const handleNext = () =&gt; {
    if (!sceneRef.current) return;
    const nextIndex = (currentIndex + 1) % 3;
    setCurrentIndex(nextIndex);
    sceneRef.current.updateCharacter(nextIndex);
  };

  const handlePrevious = () =&gt; {
    if (!sceneRef.current) return;
    const prevIndex = (currentIndex - 1 + 3) % 3;
    setCurrentIndex(prevIndex);
    sceneRef.current.updateCharacter(prevIndex);
  };

  const handleNameChange = React.useCallback(
    (e: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
      setName(e.target.value);
    },
    []
  );

  return (
    &lt;Modal show centered&gt;
      &lt;Modal.Header&gt;
        &lt;Modal.Title&gt;Select Your Character&lt;/Modal.Title&gt;
      &lt;/Modal.Header&gt;
      &lt;Modal.Body&gt;
        &lt;Container&gt;
          &lt;Row className=&quot;justify-content-center mb-3 align-items-center&quot;&gt;
            &lt;Col xs=&quot;auto&quot;&gt;
              &lt;Button variant=&quot;outline-secondary&quot; onClick={handlePrevious}&gt;
                ◀
              &lt;/Button&gt;
            &lt;/Col&gt;
            &lt;Col xs=&quot;auto&quot;&gt;
              &lt;div
                ref={previewContainer}
                style={{ width: &quot;120px&quot;, height: &quot;100px&quot; }}
              /&gt;
            &lt;/Col&gt;
            &lt;Col xs=&quot;auto&quot;&gt;
              &lt;Button variant=&quot;outline-secondary&quot; onClick={handleNext}&gt;
                ▶
              &lt;/Button&gt;
            &lt;/Col&gt;
          &lt;/Row&gt;
          &lt;Form.Group className=&quot;mt-3&quot;&gt;
            &lt;Form.Label&gt;Enter Your Name&lt;/Form.Label&gt;
            &lt;Form.Control
              type=&quot;text&quot;
              placeholder=&quot;Name&quot;
              value={name}
              onChange={handleNameChange}
            /&gt;
          &lt;/Form.Group&gt;
        &lt;/Container&gt;
      &lt;/Modal.Body&gt;
      &lt;Modal.Footer&gt;
        &lt;Button
          variant=&quot;primary&quot;
          onClick={() =&gt; onSelect(currentIndex, name)}
          disabled={!name}
        &gt;
          Confirm
        &lt;/Button&gt;
      &lt;/Modal.Footer&gt;
    &lt;/Modal&gt;
  );
};</code></pre>
<h1 id="netlify으로-배포">Netlify으로 배포</h1>
<p>처음 배포하는 분들을 위해 단계별로 자세히 설명해드리겠습니다. 저는 GitHub와 연동해서 사용 중이어서 GitHub으로 설명하겠습니다.</p>
<h3 id="1-netlify-가입하기">1. Netlify 가입하기</h3>
<ul>
<li>Netlify 웹사이트(<a href="https://www.netlify.com)%EC%97%90">https://www.netlify.com)에</a> 접속합니다</li>
<li>우측 상단의 &quot;Sign up&quot; 버튼을 클릭합니다</li>
<li>GitHub, GitLab, Bitbucket, 이메일 중 선택하여 가입합니다</li>
</ul>
<h3 id="2-새-프로젝트-추가하기">2. 새 프로젝트 추가하기</h3>
<ul>
<li>Netlify 대시보드에서 &quot;Add new site&quot; 버튼을 클릭합니다</li>
<li>드롭다운 메뉴에서 &quot;Import an existing project&quot; 선택합니다</li>
</ul>
<h3 id="3-github-연동하기">3. GitHub 연동하기</h3>
<ul>
<li>&quot;Deploy with GitHub&quot; 버튼을 클릭합니다</li>
<li>GitHub 계정 접근 권한을 허용합니다</li>
<li>배포하고 싶은 레포지토리를 검색하고 선택합니다</li>
</ul>
<h3 id="4-배포-설정하기">4. 배포 설정하기</h3>
<ul>
<li><p>&quot;Site name&quot;에 원하는 사이트 이름을 입력합니다</p>
</li>
<li><p>이 이름은 [사이트이름].netlify.app 형태의 URL로 사용됩니다.</p>
</li>
<li><p>&quot;Deploy site&quot; 버튼을 클릭하여 배포를 시작합니다</p>
</li>
</ul>
<p>배포가 시작되면 자동으로 빌드와 배포가 진행됩니다. 보통 2-3분 정도 소요되며, 배포가 완료되면 [사이트이름].netlify.app 주소로 접속할 수 있습니다.</p>
<blockquote>
<ul>
<li>Site name은 나중에 &#39;Site settings&#39;에서 언제든지 변경할 수 있습니다</li>
</ul>
</blockquote>
<ul>
<li>GitHub 레포지토리에 변경사항을 push하면 자동으로 재배포됩니다</li>
<li>배포 과정은 &#39;Deploys&#39; 탭에서 실시간으로 확인할 수 있습니다</li>
</ul>
<p>이제 Small Village 프로젝트를 전 세계 어디서나 접속할 수 있게 되었습니다! 🎉</p>
<h1 id="마치며">마치며</h1>
<p>Small Village의 첫 번째 단계인 캐릭터 선택 기능 구현을 완료했습니다. </p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/10b7f90f-9254-4e29-be8e-223c364d9c66/image.gif" alt=""></p>
<p>메타버스 플랫폼 개발이라는 도전적인 프로젝트를 시작하면서, ChatGPT와 같은 AI 도구들의 도움을 받아 진행했습니다. 처음 시도해보는 분야다 보니 일부 설명이나 접근 방식에 개선의 여지가 있을 수 있습니다. 더 나은 구현 방법이나 개선점이 있다면 댓글로 알려주세요</p>
<p>다음번 글에서는 선택한 캐릭터로 Small Village에 입장해보도록 하겠습니다.
프로젝트 전체 코드는 GitHub 저장소에서 확인하실 수 있습니다. <a href="https://github.com/hissinger/small-village">https://github.com/hissinger/small-village</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MacOS 에서 UDP만을 Block 하거나 대역폭을 제한해야 할때]]></title>
            <link>https://velog.io/@woody_ahn/MacOS-%EC%97%90%EC%84%9C-UDP%EB%A7%8C%EC%9D%84-Block-%ED%95%98%EA%B1%B0%EB%82%98-%EB%8C%80%EC%97%AD%ED%8F%AD%EC%9D%84-%EC%A0%9C%ED%95%9C%ED%95%B4%EC%95%BC-%ED%95%A0%EB%95%8C</link>
            <guid>https://velog.io/@woody_ahn/MacOS-%EC%97%90%EC%84%9C-UDP%EB%A7%8C%EC%9D%84-Block-%ED%95%98%EA%B1%B0%EB%82%98-%EB%8C%80%EC%97%AD%ED%8F%AD%EC%9D%84-%EC%A0%9C%ED%95%9C%ED%95%B4%EC%95%BC-%ED%95%A0%EB%95%8C</guid>
            <pubDate>Thu, 17 Oct 2024 16:12:10 GMT</pubDate>
            <description><![CDATA[<p>WebRTC로 뭔가를 하다보면 테스트를 위해서 signaling(TCP)은 그대로 두고 media(UDP)만 막아야 하는 경우가 왕왕 생깁니다. MacOS에서 음성/영상 패킷을 주고 받지 못하는 네트워크 상황을 만들어야 할때 <code>pfctl</code> 으로 가능합니다.</p>
<p>먼저, <code>pfctl</code>를 enable 시켜줍니다.</p>
<pre><code class="language-bash">sudo pfctl -e</code></pre>
<h2 id="block-udp">Block UDP</h2>
<p>10000~65535 port로 들어오는 UDP를 block 하려면</p>
<pre><code class="language-bash">echo &quot;block in proto udp from any to any port 10000:65535&quot; | sudo pfctl -f -</code></pre>
<p>설정을 원래대로 바꾸려면</p>
<pre><code class="language-bash">sudo pfctl -f /etc/pf.conf</code></pre>
<h2 id="대역폭-제한">대역폭 제한</h2>
<p>Inbound UDP에 대해서 대역폭을 80kbit/s로 제한하려면</p>
<pre><code class="language-bash">sudo dnctl pipe 1 config bw 80Kbit/s
echo &quot;dummynet in proto udp pipe 1&quot; | sudo pfctl -f -</code></pre>
<p>설정을 원래대로 바꾸려면</p>
<pre><code class="language-bash">sudo dnctl -q flush
sudo pfctl -f /etc/pf.conf</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[LangChain과 Tavily를 활용한 ReAct 기반 챗봇 Agent 구현하기]]></title>
            <link>https://velog.io/@woody_ahn/LangChain%EA%B3%BC-Tavily%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-ReAct-%EA%B8%B0%EB%B0%98-%EC%B1%97%EB%B4%87-Agent-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@woody_ahn/LangChain%EA%B3%BC-Tavily%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-ReAct-%EA%B8%B0%EB%B0%98-%EC%B1%97%EB%B4%87-Agent-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 09 Aug 2024 06:04:41 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/woody_ahn/post/4d992148-839f-4b43-81f3-a34edf604157/image.jpeg" alt=""></p>
<p>이전 포스팅들에서는 기본적인 chatbot과 RAG(Retrieval-Augmented Generation) 기반의 chatbot을 구현했고, 이번에는 ReAct(Reasoning and Acting) 기반의 chat을 구현해 보려고 합니다.</p>
<p>이전 포스트들</p>
<ul>
<li><a href="https://velog.io/@woody_ahn/LangChain%EA%B3%BC-GPT-4o-mini%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94-%EA%B0%84%EB%8B%A8%ED%95%9C-%ED%8E%98%EB%A5%B4%EC%86%8C%EB%82%98-%EC%B1%97%EB%B4%87">LangChain과 GPT-4o-mini로 구현하는 간단한 페르소나 챗봇</a></li>
<li><a href="https://velog.io/@woody_ahn/Tavily-LLM%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%B5%9C%EC%A0%81%ED%99%94%EB%90%9C-%EA%B2%80%EC%83%89-API">Tavily: LLM을 위한 최적화된 검색 API</a></li>
<li><a href="https://velog.io/@woody_ahn/Llama-3.1%EB%A1%9C-%EB%A1%9C%EC%BB%AC%ED%99%98%EA%B2%BD-RAG-%EA%B5%AC%ED%98%84">Llama 3.1로 로컬환경 RAG 구현</a></li>
</ul>
<h1 id="react란-무엇인가요">ReAct란 무엇인가요?</h1>
<p>ReAct는 Reasoning and Acting의 약자로, 챗봇이 <strong>추론(Reasoning)</strong>과 <strong>행동(Acting)</strong>을 체계적으로 결합하여 문제를 해결하고 정보를 제공하는 프레임워크입니다. ReAct 모델의 핵심은 두 가지 주요 기능을 포함합니다.</p>
<ol>
<li><strong>추론 (Reasoning)</strong>: 모델이 문제 해결을 위해 필요한 계획을 유도하고 추적하며 업데이트할 수 있도록 합니다. 이를 통해 예외 상황도 처리할 수 있습니다.</li>
<li><strong>행동 (Acting)</strong>: 외부 환경(예: 데이터베이스, API)과 상호작용하여 필요한 정보를 수집하고, 이를 바탕으로 작업을 수행합니다. 이는 모델이 동적인 환경에서도 효과적으로 대응할 수 있게 합니다.</li>
</ol>
<h2 id="react-prompting-프로세스">ReAct Prompting 프로세스</h2>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/8488094a-cb1f-49af-8fd7-12bf6713ea14/image.png" alt=""></p>
<ol>
<li>사용자 입력 받기</li>
<li>추론: 필요한 행동 계획</li>
<li>행동: 외부 도구 사용 (예: 검색, 데이터베이스, API)</li>
<li>관찰: 행동 결과 분석</li>
<li>최종 응답 생성 또는 추가 추론/행동 반복</li>
</ol>
<p>관찰 단계에서 얻은 정보가 충분하지 않거나 추가 작업이 필요할 경우, 다시 LLM으로 돌아가서 새로운 행동 계획을 세우고 이를 반복합니다. 이 루프는 최종적으로 만족스러운 답변을 얻을 때까지 계속됩니다.</p>
<h2 id="프로세스-과정">프로세스 과정</h2>
<p>다음은 Author David Chanoff has collaborated with a U.S. Navy admiral who served as the ambassador to the United Kingdom under which President?” 라는 질문의 답변을 생성하는 과정입니다.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/67e76e53-ac9b-4113-a930-07fce12c7093/image.png" alt=""></p>
<p><strong>Thought 1 &amp; Action 1</strong>
<strong>Thought</strong>: LLM이 &quot;David Chanoff와 U.S. Navy admiral, 그리고 United Kingdom 대사를 찾아야 한다&quot;고 추론합니다.
<strong>Action</strong>: &quot;David Chanoff&quot; 검색을 실행합니다.</p>
<p><strong>Thought 2 &amp; Action 2</strong></p>
<p><strong>Thought</strong>: 첫 번째 검색 결과를 바탕으로, LLM이 &quot;David Chanoff가 U.S. Navy admiral과 협업했다&quot;고 추론하고, 해당 admiral을 찾아야 한다고 판단합니다.
<strong>Action</strong>: &quot;U.S. Navy admiral&quot; 검색을 실행합니다.</p>
<p><strong>Thought 3 &amp; Action 3</strong></p>
<p><strong>Thought</strong>: 이전 검색 결과를 통해 LLM이 &quot;Admiral William J. Crowe를 검색하고 United Kingdom 대사를 찾아야 한다&quot;고 추론합니다.
<strong>Action</strong>: &quot;Admiral William J. Crowe&quot; 검색을 실행합니다.</p>
<p><strong>Thought 4 &amp; Final Answer</strong></p>
<p><strong>Thought</strong>: 마지막 검색 결과를 바탕으로, LLM이 &quot;Admiral William J. Crowe가 Bill Clinton 대통령 시절 United Kingdom 대사였다&quot;고 추론합니다.
<strong>Final Answer</strong>: LLM이 최종 답변으로 &quot;Bill Clinton&quot;을 제시합니다.</p>
<h1 id="react-chatbot-구현">ReAct Chatbot 구현</h1>
<h3 id="의존성-설치">의존성 설치</h3>
<p>코드 실행에 필요한 의존성을 설치합니다.</p>
<pre><code class="language-bash">pip3 install langchain langchain_openai langchain_community langchainhub</code></pre>
<h3 id="외부-검색-도구-설정">외부 검색 도구 설정</h3>
<p><a href="https://tavily.com/">Tavily</a> 검색 API를 사용하여 외부 정보 검색 기능을 구현합니다.</p>
<pre><code class="language-python">from langchain_community.tools.tavily_search.tool import TavilySearchResults

# Tavily API key
os.environ[&quot;TAVILY_API_KEY&quot;] = &quot;your-api-key&quot;

tools = [TavilySearchResults(max_results=5)]</code></pre>
<h3 id="프롬프트-설정">프롬프트 설정</h3>
<p>LangChain Hub에서 사전 정의된 ReAct 채팅 프롬프트 [hwchase17/react-chat] (<a href="https://smith.langchain.com/hub/hwchase17/react-chat">https://smith.langchain.com/hub/hwchase17/react-chat</a>) 를 사용합니다.</p>
<pre><code class="language-python">from langchain import hub
from langchain.tools.render import render_text_description

prompt = hub.pull(&quot;hwchase17/react-chat&quot;)
prompt = prompt.partial(
    tools=render_text_description(tools),
    tool_names=&quot;, &quot;.join([t.name for t in tools]),
)</code></pre>
<p>해당 프롬프트를 보시면 다음과 같이 <code>{tools}</code>, <code>{tool_names}</code> 로 정의된 부분이 있습니다. <code>prompt.partial()</code>로 우리가 사용할 tool 정보도 넣어줍니다.
 <img src="https://velog.velcdn.com/images/woody_ahn/post/dd6162bf-ed7d-4243-bab6-7de9de43be90/image.png" alt=""></p>
<h3 id="대화-기록-관리">대화 기록 관리</h3>
<p>챗봇이니 이전 대화 유지를 위해서 memory도 설정합니다.</p>
<pre><code class="language-python">from langchain.memory import ConversationBufferMemory

memory = ConversationBufferMemory(memory_key=&quot;chat_history&quot;)</code></pre>
<h3 id="모델-설정">모델 설정</h3>
<p>모델을 설정합니다. 요즘은 무료 모델들도 많으니 이미 쓰고 계신 모델이 있다면 그걸로 대체하시면 됩니다.</p>
<pre><code class="language-python">from langchain_openai import ChatOpenAI

# OpenAI API Key
os.environ[&quot;OPENAI_API_KEY&quot;] = &quot;your-api-key&quot;

llm = ChatOpenAI(model=&quot;gpt-4o-mini&quot;, temperature=0)
llm_with_stop = llm.bind(stop=[&quot;\nObservation&quot;])</code></pre>
<h3 id="agent-chain-구성">Agent Chain 구성</h3>
<p>LLM, 프롬프트, 도구, 메모리를 결합하여 Agent Executor를 생성합니다.</p>
<pre><code class="language-python">from langchain.agents import AgentExecutor
from langchain.agents.format_scratchpad import format_log_to_str
from langchain.agents.output_parsers import ReActSingleInputOutputParser

agent = (
    {
        &quot;input&quot;: lambda x: x[&quot;input&quot;],
        &quot;agent_scratchpad&quot;: lambda x: format_log_to_str(x[&quot;intermediate_steps&quot;]),
        &quot;chat_history&quot;: lambda x: x[&quot;chat_history&quot;],
    }
    | prompt
    | llm_with_stop
    | ReActSingleInputOutputParser()
)

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    memory=memory,
    verbose=True,
)</code></pre>
<h3 id="챗봇-실행">챗봇 실행</h3>
<pre><code class="language-python">response = agent_executor.invoke({&quot;input&quot;: user_input})
print(&quot;Assistant: &quot; + response[&quot;output&quot;])</code></pre>
<h1 id="마치며">마치며</h1>
<p>LangChain을 사용하면 간단하게 ReAct 기반의 챗봇을 만들 수 있습니다. 위의 코드를 실행하고 질문하면 LLM이 Tavily를 통해서 답변을 생성해 가는 것을 볼 수 있습니다.</p>
<p>예를 들어서
&quot;대한민국의 스타벅스 매장 수를 찾고, 2024년 미국 스타벅스 매장 수와 비교해봐&quot;</p>
<p>이 질문에 대해 ReAct 기반 챗봇은 다음과 같은 과정을 거칩니다</p>
<ol>
<li>한국의 스타벅스 매장 수 검색</li>
<li>미국의 스타벅스 매장 수 검색</li>
<li>두 정보를 비교 분석</li>
<li>최종 응답 생성</li>
</ol>
<pre><code>You: 대한민국의 스타벅스 매장 수를 찾고, 2024년 미국 스타벅스 매장 수와 비교해봐

&gt; Entering new AgentExecutor chain...
Thought: Do I need to use a tool? Yes  
Action: tavily_search_results_json  
Action Input: &quot;대한민국 스타벅스 매장 수 2023&quot;  [{&#39;url&#39;: &#39;https://www.mk.co.kr/news/business/10941096&#39;, &#39;content&#39;: &#39;한국이 스타벅스 매장 수에서 미국, 중국, 일본에 이은 세계 4위를 기록했다. 지난 12일 스타벅스 글로벌 웹사이트 자료에 따르면 2023년 말 기준으로 국내 스타벅스 매장 수는 1893개로 2022년보다 116개 늘어났다. 이는 3위인 일본(1901개)보다 8개 적은 수치다. 일본 인구가 한국 인구의 2.5배에 달하는 것을 ...&#39;}, {&#39;url&#39;: &#39;https://www.joongang.co.kr/article/25228101&#39;, &#39;content&#39;: &#39;한국이 스타벅스 매장 수 세계 4위인 것으로 나타났다. ... 2022년 17개에 이어 2023년엔 8개로 좁혀졌다. 이런 추세라면 매장 수는 이르면 2년 안에 일본을 넘어설 가능성이 있다. 일본은 스타벅스가 북미 이외 지역 최초로 진출한 나라다. 일본 1호 매장은 1996년 도쿄 ...&#39;}, {&#39;url&#39;: &#39;https://caffemocha.tistory.com/entry/한국-스타벅스-매장&#39;, &#39;content&#39;: &#39;한국과 일본의 스타벅스 매장 수 차이는 2013년 400개, 2019년 153개, 2020년 121개, 2021년 67개, 2022년 17개로 꾸준히 감소하여 2023년에는 8개에 불과했다. 현재의 추세라면 1 ~ 2년 안에 한국의 스타벅스 매장 수가 일본을 추월할 가능성이 높다.&#39;}, {&#39;url&#39;: &#39;https://news.koreadaily.com/2024/02/11/economy/business/20240211172434492.html&#39;, &#39;content&#39;: &#39;한국의 스타벅스 매장 수가 미국·중국·일본에 이어 전 세계에서 네번째 많은 것으로 나타났다. 12일 스타벅스 글로벌 웹사이트에 따르면 지난해 말 기준 ... 났다. 이후 그 격차는 2019년 153개로 줄었고 2020년 121개, 2021년 67개, 2022년 17개에 이어 2023년엔 8개로 좁혀 ...&#39;}, {&#39;url&#39;: &#39;https://namu.wiki/w/스타벅스/대한민국&#39;, &#39;content&#39;: &#39;2022년 기준 국내 매장 수는 약 1,700여 개이다. 2023년 기준 국내 매장 수는 1841개로 같은 시기의 일본 스타벅스 전체 매점 수(1846개)를 따라잡았다. # 미국과 중국에 이어 매장 기준으로 3위 국가이다.&#39;}]Do I need to use a tool? Yes  
Action: tavily_search_results_json  
Action Input: &quot;2024 미국 스타벅스 매장 수&quot;  [{&#39;url&#39;: &#39;https://www.hankookilbo.com/News/Read/A2024021213580001753&#39;, &#39;content&#39;: &#39;한국 스타벅스의 매장 수가 미국, 중국, 일본에 이어 세계 4위인 것으로 나타났다. ... 2024.02.12 16:00. ... 한국 스타벅스 매장 수 세계 4위…3위 일본도 ...&#39;}, {&#39;url&#39;: &#39;https://www.chosun.com/economy/market_trend/2024/02/13/K2K66OPJNFF5PEXGZQOPWBTMKY/&#39;, &#39;content&#39;: &#39;송혜진 기자. 업데이트 2024.03.29. 16:27. 0. 우리나라 스타벅스 매장 수가 미국·중국·일본에 이어 전 세계에서 넷째로 많은 것으로 나타났다. 한국이 스타벅스 매장 수에서 미국, 중국, 일본에 이은 세계 4위로 일본을 거의 따라잡은 것으로 나타났다. 12일 스타벅스 ...&#39;}, {&#39;url&#39;: &#39;https://m.blog.naver.com/archer1123/223339429225&#39;, &#39;content&#39;: &#39;2024년 1월 30일, 스타벅스 ( 증권 시세 표시기, Financial )은 2024년 1분기 재무 실적을 상세히 담 은 8-K 보고서를 발표했다. 80개국 이상에 38,000개 이상의 매장을 보유한 커피 리테일 분야의 글로벌 리더인 이 회사는 연결 순수익이 8% 증가하여 사상 최대인 94억 달러를 ...&#39;}, {&#39;url&#39;: &#39;https://biz.chosun.com/distribution/food/2024/02/12/3POHM6W6S5ETPPENYBJPNGOSKU/&#39;, &#39;content&#39;: &#39;입력 2024.02.12. 14:02 업데이트 2024.02.12. 14:06 한국이 스타벅스 매장 수가 미국, 중국, 일본에 이어 세계에서 네번째로 많은 국가로 나타났다.&#39;}, {&#39;url&#39;: &#39;https://aliceinsaltyland.tistory.com/entry/스타벅스SBUX-2024-1분기-실적발표&#39;, &#39;content&#39;: &#39;스타벅스(sbux) 2024 1분기 실적발표를 자세히 살펴보겠습니다. *이미지 및 내용은 공식홈페이지의 ir자료를 활용해 편집했습니다. 1. 실적 요약하기 매출, 영업이익, 순이익, 주당순이익, 매장 개수의 변화를 먼저 확인해보겠습니다. 2024 1분기 전년 동기 대비 변화 매출 94억 달러 +8% 영업이익 14.9억 달러 ...&#39;}]Do I need to use a tool? No  
Final Answer: 2023년 대한민국의 스타벅스 매장 수는 1,893개로, 이는 일본(1,901개)보다 8개 적은 수치입니다. 2024년 미국의 스타벅스 매장 수에 대한 구체적인 정보는 확인되지 않았지만, 스타벅스는 전 세계적으로 38,000개 이상의 매장을 운영하고 있습니다. 미국은 스타벅스의 본국으로, 매장 수는 가장 많을 것으로 예상됩니다.

&gt; Finished chain.
Assistant: 2023년 대한민국의 스타벅스 매장 수는 1,893개로, 이는 일본(1,901개)보다 8개 적은 수치입니다. 2024년 미국의 스타벅스 매장 수에 대한 구체적인 정보는 확인되지 않았지만, 스타벅스는 전 세계적으로 38,000개 이상의 매장을 운영하고 있습니다. 미국은 스타벅스의 본국으로, 매장 수는 가장 많을 것으로 예상됩니다.</code></pre><p>전체 코드는 GitHub 레포지토리에서 확인할 수 있습니다. <a href="https://github.com/hissinger/langchain-react-agent">링크</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Llama 3.1로 로컬환경 RAG 구현]]></title>
            <link>https://velog.io/@woody_ahn/Llama-3.1%EB%A1%9C-%EB%A1%9C%EC%BB%AC%ED%99%98%EA%B2%BD-RAG-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@woody_ahn/Llama-3.1%EB%A1%9C-%EB%A1%9C%EC%BB%AC%ED%99%98%EA%B2%BD-RAG-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Fri, 02 Aug 2024 17:24:51 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/woody_ahn/post/0cfc8359-0bea-40fb-a29e-26a760c9b3eb/image.png" alt=""></p>
<h1 id="시작하며">시작하며</h1>
<p>최신 오픈소스로 릴리즈된 Llama3.1의 8B 모델을 사용해서 로컬로만 동작하는 RAG(Retrieval-Augmented Generation) 시스템을 구현 할 수 있지 않을까 하는 궁금증이 생겼습니다. 그리고 한국어도 처리가 가능한지도 확인해 볼겸 간단한 RAG를 구현해 보겠습니다.</p>
<h1 id="llama-31-소개">Llama 3.1 소개</h1>
<p>Llama 3.1은 Meta AI에서 개발한 대규모 언어 모델의 최신 버전입니다. 이전 버전들에 비해 성능이 크게 향상되었으며, 다양한 자연어 처리 작업에서 뛰어난 성능을 보여줍니다. 특히, 8B 파라미터 버전은 상대적으로 작은 모델 크기에도 불구하고 높은 품질의 텍스트 생성이 가능합니다.</p>
<h1 id="ollama를-사용하여-llama-31-설치하기">Ollama를 사용하여 Llama 3.1 설치하기</h1>
<p>Ollama는 다양한 언어 모델을 로컬 환경에서 쉽게 실행할 수 있게 해주는 도구입니다. Llama 3.1을 Ollama를 통해 설치하고 사용하는 방법은 다음과 같습니다.</p>
<ol>
<li><p>Ollama 설치: <a href="https://ollama.com/">Ollama</a> 공식 사이트에서 설치 파일을 다운로드하여 실행합니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/080011f0-1abc-47d9-9e7f-225386f42605/image.png" alt=""></p>
</li>
<li><p>Llama 3.1 모델 다운로드: 터미널에서 다음 명령어를 실행합니다.</p>
<pre><code>ollama pull llama3.1:8b</code></pre><p>이 명령은 Llama 3.1의 8B 파라미터 버전을 다운로드합니다. 4.7G 라서 시간이 좀 걸릴 수 있습니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/735782c3-82f5-4622-bd17-bd65f09af964/image.png" alt=""></p>
</li>
</ol>
<ol start="3">
<li>모델 실행 확인: 다음 명령어로 모델이 정상적으로 실행되는지 확인할 수 있습니다.<pre><code>ollama run llama3.1:8b &quot;Hello, how are you?&quot;</code></pre></li>
</ol>
<p>이제 Llama 3.1 모델이 로컬 환경에 설치되었으며, 우리의 RAG 시스템에서 사용할 준비가 되었습니다.</p>
<h1 id="faiss-facebook-ai-similarity-search-소개">FAISS (Facebook AI Similarity Search) 소개</h1>
<p>FAISS는 Facebook AI Research에서 개발한 라이브러리로, 대규모 벡터 집합에서 효율적인 유사성 검색과 클러스터링을 수행하는 데 사용됩니다. RAG 시스템에서 FAISS의 역할은 매우 중요합니다.</p>
<h2 id="faiss의-주요-특징">FAISS의 주요 특징</h2>
<ul>
<li>고성능: FAISS는 대량의 고차원 벡터에 대해 빠른 검색을 수행할 수 있습니다.</li>
<li>메모리 효율성: 대규모 데이터셋을 효율적으로 처리할 수 있는 인덱싱 기법을 제공합니다.</li>
<li>GPU 지원: GPU를 활용하여 검색 속도를 더욱 향상시킬 수 있습니다.</li>
<li>다양한 인덱스 유형: 데이터의 특성과 요구사항에 따라 다양한 인덱스 유형을 선택할 수 있습니다.</li>
</ul>
<h2 id="rag-시스템에서의-faiss-활용">RAG 시스템에서의 FAISS 활용</h2>
<p>우리의 RAG 시스템에서 FAISS는 다음과 같은 역할을 수행합니다</p>
<ul>
<li>문서 임베딩 저장: PDF에서 추출한 텍스트 청크의 임베딩 벡터를 저장합니다.</li>
<li>유사성 검색: 사용자 쿼리의 임베딩과 가장 유사한 문서 청크를 빠르게 검색합니다.</li>
<li>로컬 저장 및 로딩: 생성된 인덱스를 로컬에 저장하고 필요할 때 로드하여 재사용할 수 있습니다.</li>
</ul>
<p>FAISS를 사용하여 벡터 저장소를 생성하고 검색하는 방법을 살펴보겠습니다.</p>
<pre><code class="language-python">from langchain_community.vectorstores import FAISS

# 벡터 저장소 생성
vectorstore = FAISS.from_documents(chunked_documents, embeddings)

# 로컬에 저장
vectorstore.save_local(&quot;./vector_store_path&quot;)

# 로컬에서 로드
loaded_vectorstore = FAISS.load_local(&quot;./vector_store_path&quot;, embeddings)

# 검색 수행
retriever = loaded_vectorstore.as_retriever()
relevant_documents = retriever.get_relevant_documents(query)</code></pre>
<p>이 코드에서 <code>FAISS.from_documents()</code>는 문서 청크와 임베딩 모델을 사용하여 FAISS 인덱스를 생성합니다. <code>save_local()</code>과 <code>load_local()</code>메서드를 통해 인덱스를 파일로 저장하고 불러올 수 있습니다. <code>as_retriever()</code>메서드는 FAISS 인덱스를 기반으로 검색을 수행할 수 있는 retriever 객체를 생성합니다.
FAISS를 사용함으로써, 우리의 RAG 시스템은 대량의 문서에서도 빠르고 정확한 검색을 수행할 수 있게 되어, 사용자 쿼리에 대해 관련성 높은 컨텍스트를 신속하게 제공할 수 있습니다.</p>
<h1 id="로컬-rag-시스템-구현">로컬 RAG 시스템 구현</h1>
<p>이제 Llama 3.1과 HuggingFace 임베딩을 사용하여 로컬에서 동작하는 RAG 시스템을 구현해 보겠습니다. </p>
<h2 id="주요-구성-요소">주요 구성 요소</h2>
<ul>
<li>벡터 저장소: <code>FAISS</code>를 사용하여 문서의 벡터 표현을 저장하고 검색합니다.</li>
<li>임베딩 모델: HuggingFace의 <code>intfloat/multilingual-e5-small</code> 모델을 사용하여 다국어 지원이 가능한 임베딩을 생성합니다.</li>
<li>언어 모델: <code>ChatOllama</code>를 통해 <code>Llama 3.1의 8B</code> 파라미터 버전을 사용합니다.</li>
</ul>
<h2 id="상세-구현-과정">상세 구현 과정</h2>
<p>이제 RAG 시스템의 각 구성 요소를 상세히 살펴보고 구현해 보겠습니다.</p>
<h3 id="1-필요한-라이브러리-설치">1. 필요한 라이브러리 설치</h3>
<p>먼저, 필요한 라이브러리들을 설치합니다.</p>
<pre><code class="language-python">pip3 install langchain_community langchain_huggingface langchainhub pypdf faiss-cpu</code></pre>
<h3 id="2-벡터-저장소-생성">2. 벡터 저장소 생성</h3>
<p>PDF 문서를 로드하고, 이를 청크로 분할한 후 벡터 저장소를 생성합니다.</p>
<pre><code class="language-python">import os
from langchain_community.vectorstores import FAISS
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import CharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings

VECTOR_STORE_PATH = &quot;./vectorstore&quot;
EMBEDDINGS = HuggingFaceEmbeddings(model_name=&quot;intfloat/multilingual-e5-small&quot;)

def create_vectorstore():
    list_of_pdfs = [
        &quot;pdfs/2024 노무관리 가이드 북.pdf&quot;,
    ]

    text_splitter = CharacterTextSplitter(
        separator=&quot;\n&quot;,
        chunk_size=1000,
        chunk_overlap=200,
        length_function=len,
        is_separator_regex=False,
    )

    documents = []
    for pdf in list_of_pdfs:
        loader = PyPDFLoader(pdf)
        documents += loader.load()

    chunked_documents = text_splitter.split_documents(documents)

    vectorstore = FAISS.from_documents(chunked_documents, EMBEDDINGS)
    vectorstore.save_local(VECTOR_STORE_PATH)

    return vectorstore</code></pre>
<p>이 코드는 PDF 문서를 로드하고, 텍스트를 1000자 길이의 청크로 분할한 후, FAISS를 사용하여 벡터 저장소를 생성합니다.</p>
<h3 id="3-프롬프트-템플릿-정의">3. 프롬프트 템플릿 정의</h3>
<p>사용자의 질문과 검색된 문서 내용을 결합하여 LLM에 전달할 프롬프트를 정의합니다.</p>
<pre><code class="language-python">from langchain import hub

prompt = hub.pull(&quot;rlm/rag-prompt&quot;)</code></pre>
<h3 id="4-llama-31-모델-초기화">4. Llama 3.1 모델 초기화</h3>
<p>Ollama를 통해 설치한 Llama 3.1 모델을 초기화합니다.</p>
<pre><code class="language-python">from langchain_community.chat_models import ChatOllama

llm = ChatOllama(model=&quot;llama3.1:8b&quot;)</code></pre>
<h3 id="5-rag-체인-구성">5. RAG 체인 구성</h3>
<p>Retriever, 프롬프트, LLM을 연결하여 RAG 체인을 구성합니다.</p>
<pre><code class="language-python">from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableParallel

def format_docs(docs):
    return &quot;\n\n&quot;.join(doc.page_content for doc in docs)

retriever = vectorstore.as_retriever()

rag_chain_from_docs = (
    RunnablePassthrough.assign(context=(lambda x: format_docs(x[&quot;context&quot;])))
    | prompt
    | llm
    | StrOutputParser()
)

rag_chain_with_source = RunnableParallel(
    {&quot;context&quot;: retriever, &quot;question&quot;: RunnablePassthrough()}
).assign(answer=rag_chain_from_docs)</code></pre>
<p>이 체인은 다음과 같이 작동합니다:</p>
<ol>
<li>사용자의 질문을 받아 관련 문서를 검색합니다.</li>
<li>검색된 문서와 질문을 프롬프트 템플릿에 삽입합니다.</li>
<li>생성된 프롬프트를 LLM에 전달하여 답변을 생성합니다.</li>
<li>답변과 함께 사용된 문서의 출처 정보를 반환합니다.</li>
</ol>
<h3 id="6-쿼리-실행-및-결과-출력">6. 쿼리 실행 및 결과 출력</h3>
<p>이제 사용자의 질문에 대해 RAG 시스템을 실행하고 결과를 출력합니다.</p>
<pre><code class="language-python">def main():
    query = &quot;연차 계산 방법을 알려주세요.&quot;
    response = rag_chain_with_source.invoke(query)

    print(&quot;Answer:\n&quot;, response[&quot;answer&quot;] + &quot;\n&quot;)
    print(&quot;Sources:&quot;)
    sources = [doc.metadata for doc in response[&quot;context&quot;]]
    for source in sources:
        print(source)

if __name__ == &quot;__main__&quot;:
    main()</code></pre>
<p>아래와 같이 사용자의 질문에 대한 답변을 생성하고, 답변의 근거가 된 문서의 출처 정보도 함께 제공합니다. 한국어도 잘되네요.</p>
<pre><code>Answer:
 제공된 내용에 따르면, 신입사원의 유급휴가 부여 및 사용은 다음과 같이 이루어집니다.

*   입사 후 1년 미만(1년차)까지는 1개월 개근시 1일씩 유급휴가 발생(최대 11일)
    *   최초 1년의 근로가 끝날 때까지 사용 가능
    *   입사 후 1년간(1년차)의 출근율이 80% 이상인 경우, 2년차에는 총 15일의 유급휴가가 발생

제60조 제4항에 따른 연차유급휴가는 다음과 같이 규정됩니다.

*   제60조제4항의 연차휴가 사용 권리는 전년도 1년간 근로를 마친 다음 날 발생하며, 법 제60조제2항의 연차휴가 사용 권리도 1개월의 근로를 마친 다음 날 발생
    *   정규직·계약직 모두 1년(365일) 근로 후 퇴직하면 법 제60조제1항의 15일 연차휴가 미사용수당을 청구할 수 없고, 다음 날인 366일째 근로관계 존속 후 퇴직하면 15일 연차휴가 전부에 대해 수당 청구 가능
    *   법 제60조제2항의 연차휴가도 그 1개월 근로를 마친 다음 날 근로관계 존속 후 퇴직해야 퇴직 전월의 개근에 대한 연차 미사용 수당 청구 가능

또한, 대법원 판결 (2021 다 227100)에서 규정된 바와 같이 법 제60조 제4항의 연차휴가는 근로한 년수의 365일을 초과하여 근무하고 퇴직하는 경우, 80% 이상 출근율을 달성하더라도 유급휴가 미사용수당 청구 불가능합니다.

Sources:
{&#39;source&#39;: &#39;pdfs/2024 노무관리 가이드 북.pdf&#39;, &#39;page&#39;: 96}
{&#39;source&#39;: &#39;pdfs/2024 노무관리 가이드 북.pdf&#39;, &#39;page&#39;: 104}
{&#39;source&#39;: &#39;pdfs/2024 노무관리 가이드 북.pdf&#39;, &#39;page&#39;: 100}
{&#39;source&#39;: &#39;pdfs/2024 노무관리 가이드 북.pdf&#39;, &#39;page&#39;: 103}</code></pre><h1 id="마치며">마치며</h1>
<p>모델들의 저장위치와 사이즈는</p>
<ul>
<li>Hugging Face Embedding 모델 <code>intfloat/multilingual-e5-small</code>: 약 476MB의 크기로, <code>~/.cache/huggingface/hub</code> 경로에 저장됩니다.</li>
<li>Llama3.1 8B 모델: 약 4.3GB의 크기를 가지며, <code>~/.ollama/models</code> 경로에 저장됩니다.</li>
</ul>
<p>그리고, M1 맥북 에어에서 위 질문에 대한 답변은 약 4~5s 정도 소요 되었습니다.</p>
<p>상대적으로 작은 규모의 Llama 3.1 8B 버전으로도 RAG 시스템은 가능하지 않을까 싶습니다.</p>
<p>전체 코드는 GitHub 레포지토리에서 확인할 수 있습니다. <a href="https://github.com/hissinger/langchain-rag-llama3.1">링크</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Tavily: LLM을 위한 최적화된 검색 API]]></title>
            <link>https://velog.io/@woody_ahn/Tavily-LLM%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%B5%9C%EC%A0%81%ED%99%94%EB%90%9C-%EA%B2%80%EC%83%89-API</link>
            <guid>https://velog.io/@woody_ahn/Tavily-LLM%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%B5%9C%EC%A0%81%ED%99%94%EB%90%9C-%EA%B2%80%EC%83%89-API</guid>
            <pubDate>Thu, 01 Aug 2024 07:19:25 GMT</pubDate>
            <description><![CDATA[<h1 id="시작하며">시작하며</h1>
<p>대규모 언어 모델(Large Language Models, LLMs)은 자연어 처리 분야에서 혁명적인 발전을 이루었지만, 여전히 중요한 한계점을 가지고 있습니다. 그 중 하나는 최신 정보 처리의 어려움입니다.
대부분의 LLM은 특정 시점까지의 데이터로 학습되어 있어, 그 이후의 정보나 실시간 변화하는 상황에 대해 정확한 답변을 제공하기 어렵습니다.</p>
<blockquote>
<p>OpenAI의 GPT-4: 2023년 4월까지의 데이터
Anthropic의 Claude: 2024년 4월까지의 데이터</p>
</blockquote>
<p>이러한 한계를 극복하기 위해 RAG(Retrieval-Augmented Generation) 기술이 주목받고 있으며, 이 과정에서 효과적인 검색 도구의 중요성이 부각되고 있습니다.</p>
<p>다음은 gpt-4o-mini를 사용하여 최신 정보를 요청했을 때의 한계를 보여주는 예시입니다.</p>
<p>gpt-4o-mini에게 &quot;2024년 한국 뮤지컬 시카고의 주연 배우는 누구인가요?&quot; 라고 질문을 해 봅시다.</p>
<pre><code class="language-python">import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

# OpenAI API Key
os.environ[&quot;OPENAI_API_KEY&quot;] = (
    &quot;your-api-key&quot;
)

prompt = ChatPromptTemplate.from_messages([(&quot;user&quot;, &quot;{question}&quot;)])
llm = ChatOpenAI(model=&quot;gpt-4o-mini&quot;, temperature=0)

chain = prompt | llm

result = chain.invoke(
    {&quot;question&quot;: &quot;2024년 한국 뮤지컬 시카고의 주연 배우들은 누구인가요?&quot;}
)

print(result.content)</code></pre>
<p>이 질문에 대한 AI의 답변은 대체로 다음과 같습니다:</p>
<pre><code>2024년 한국 뮤지컬 &quot;시카고&quot;의 주연 배우들은 아직 공식적으로 발표되지 않았습니다. 뮤지컬 캐스팅은 공연 일정에 따라 변동이 있을 수 있으므로, 최신 정보를 확인하려면 공식 웹사이트나 관련 뉴스 매체를 참고하는 것이 좋습니다.</code></pre><h1 id="tavily-소개">Tavily 소개</h1>
<p>Tavily Search API는 LLM과 RAG 시스템에 최적화된 검색 엔진입니다. 기존의 일반 검색 엔진들과 달리, Tavily는 AI 애플리케이션의 요구사항에 맞춰 설계되었습니다.</p>
<h2 id="tavily의-주요-특징">Tavily의 주요 특징</h2>
<ul>
<li>AI 최적화: LLM과의 원활한 통합을 위해 설계되었습니다.</li>
<li>최신 정보: 실시간으로 업데이트되는 정보를 제공합니다.</li>
<li>다양한 검색 모드: 일반 검색, 뉴스 검색, 이미지 검색 등 다양한 모드를 제공합니다.</li>
<li>콘텐츠 필터링: 신뢰할 수 있는 소스의 정보만을 제공합니다.</li>
<li>무료 사용량: 월 1,000회의 무료 API 호출을 제공합니다.</li>
</ul>
<h1 id="tavily-사용하기">Tavily 사용하기</h1>
<p>Tavily를 사용하기 위해서는 먼저 API 키를 발급받아야 합니다. Tavily 웹사이트에서 회원가입 후 API 키를 얻을 수 있습니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/3d06916f-d12b-4209-8fac-ed9417f98735/image.png" alt=""></p>
<h2 id="기본-사용-예시">기본 사용 예시</h2>
<p>LangChain의 TavilySearchAPIRetriever를 사용하여 Tavily를 쉽게 활용할 수 있습니다. 다음은 기본적인 사용 예시입니다:</p>
<pre><code class="language-python">import os
from langchain_community.retrievers import TavilySearchAPIRetriever


# Tavily API key
os.environ[&quot;TAVILY_API_KEY&quot;] = &quot;your-api-key&quot;

retriever = TavilySearchAPIRetriever(k=3)
result = retriever.invoke(&quot;2024년 한국 뮤지컬 시카고의 주연 배우들은 누구인가요?&quot;)

print(result)</code></pre>
<p>이 코드는 관련성 높은 상위 3개의 문서를 반환합니다. 각 문서에는 제목, 출처, 관련성 점수, 그리고 페이지 내용이 포함됩니다.</p>
<pre><code class="language-yml">[
  Document(
    metadata={
      &#39;title&#39;: &#39;&quot;2024 뮤지컬 시카고 완벽 가이드: 줄거리, 캐스팅, 공연장, 좌석배치도, 예매 방법, 티켓 가격 및 할인 정보 총정리&quot;&#39;, 
      &#39;source&#39;: &#39;https://blog.naver.com/PostView.naver?blogId=fightlovemann&amp;logNo=223477200781&amp;noTrackingCode=true&#39;, 
      &#39;score&#39;: 0.99981904,
      &#39;images&#39;: []
    }, 
    page_content=&#39;2024 뮤지컬 〈시카고〉 한국 프로덕션은 역사상 최고 성적을 낸 2021년 주, 조연 멤버 최정원, 윤공주, 아이비, 티파니 영, 민경아, 박건형, 최재림, 김영주, 김경선, 차정현, S.J.Kim과 오디션을 거쳐 새롭게 합류한 정선아 (벨마 켈리 役)와 앙상블이 함께한다.&#39;
  ), 
  Document(
    metadata={
      &#39;title&#39;: &#39;[풀영상] 2024 뮤지컬 ′시카고′ 주연배우 인터뷰｜최정원·윤공주·정선아·아이비·티파니 영·민경아·박건형·최재림｜Musical ...&#39;, 
      &#39;source&#39;: &#39;https://m.korean-vibe.com/news/newsview.php?ncode=179573404581419&#39;, 
      &#39;score&#39;: 0.99974084, 
      &#39;images&#39;: []
    }, 
    page_content=&#39;#최재림 #민경아 #뮤지컬시카고 2024년 5월 23일 뮤지컬 ′시카고′의 연습실 장면이 공개됐다. 이번 시즌에서는 배우 최재림과 박건형, 최정원, 윤공주, 민경아, 티파니 영 등 기존 시즌 출연진들이 다시 참여했다. 2024 뮤지컬 ′시카고′는 오는 6월 7일 개막한다. On May 23, 2024, a rehearsal room scene f ...&#39;
  ), 
  Document(
    metadata={
      &#39;title&#39;: &#39;2024 뮤지컬 시카고 줄거리 및 캐스팅, 티켓 예매(추가정보 업데이트) - blvl blog&#39;, 
      &#39;source&#39;: &#39;https://blvlblog.com/2024-뮤지컬-시카고-줄거리-및-캐스팅-예매-티켓-오픈3차/&#39;, 
      &#39;score&#39;: 0.99908894, 
      &#39;images&#39;: []
    },
    page_content=&#39;뮤지컬 시카고 공연 정보. 2024년 뮤지컬 시카고가 돌아왔습니다. 코로나 펜데믹에도 불구, 2021 뮤지컬 시카고 한국 프로덕션 최고 흥행 기록을 새로 쓴 멤버들과 새롭게 오디션을 통해 합류하게된 뮤지컬 디바 정선아(벨마 켈리 역)와 앙상블 그리고 2007년 뮤지컬 시카고 레플리카 프로뎍션 첫 ...&#39;
  )
]</code></pre>
<h2 id="고급-기능-검색-모드-설정">고급 기능: 검색 모드 설정</h2>
<p>Tavily는 다양한 검색 모드를 제공합니다. 예를 들어, 뉴스 검색 모드를 사용하려면 다음과 같이 설정할 수 있습니다:</p>
<pre><code class="language-python">retriever = TavilySearchAPIRetriever(
    k=3, search_depth=&quot;advanced&quot;, include_domains=[&quot;news&quot;]
)</code></pre>
<p>이렇게 설정하면 뉴스 도메인에서만 정보를 검색하게 됩니다.</p>
<h1 id="llm과-tavily-통합하기">LLM과 Tavily 통합하기</h1>
<p>Tavily의 진정한 가치는 LLM과 통합했을 때 발휘됩니다. 다음은 OpenAI의 gpt-4o-mini와 Tavily를 결합하는 예시입니다.</p>
<pre><code class="language-python">import os
from langchain_openai import ChatOpenAI
from langchain_community.retrievers import TavilySearchAPIRetriever
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough

# OpenAI API Key
os.environ[&quot;OPENAI_API_KEY&quot;] = (
    &quot;your-open-api-key&quot;
)

# Tavily API key
os.environ[&quot;TAVILY_API_KEY&quot;] = &quot;your-tavily-api-key&quot;


def format_docs(docs):
    return &quot;\n\n&quot;.join([d.page_content for d in docs])


template = &quot;&quot;&quot;Answer the question based only on the following context:

{context}

Question: {question}
&quot;&quot;&quot;
prompt = ChatPromptTemplate.from_template(template)
llm = ChatOpenAI(model=&quot;gpt-4o-mini&quot;, temperature=0)
retriever = TavilySearchAPIRetriever(k=3)

chain = (
    {&quot;context&quot;: retriever | format_docs, &quot;question&quot;: RunnablePassthrough()}
    | prompt
    | llm
)

result = chain.invoke(&quot;2024년 한국 뮤지컬 시카고의 주연 배우들은 누구인가요?&quot;)

print(result.content)</code></pre>
<p>이 예시에서는 Tavily가 검색한 최신 정보를 바탕으로 gpt-4o-mini가 응답을 생성합니다. 이를 통해 LLM의 지식 기반을 실시간으로 확장할 수 있습니다.</p>
<pre><code>2024년 한국 뮤지컬 &#39;시카고&#39;의 주연 배우로는 최재림, 박건형, 최정원, 윤공주, 민경아, 티파니 영 등이 참여합니다.</code></pre><h1 id="tavily의-장점">Tavily의 장점</h1>
<ul>
<li>정확성: AI에 최적화된 검색 결과를 제공하여 LLM의 응답 정확도를 높입니다.</li>
<li>최신성: 실시간으로 업데이트되는 정보를 제공하여 LLM의 지식 기반을 확장합니다.</li>
<li>유연성: 다양한 검색 모드와 옵션을 제공하여 다양한 사용 사례에 대응할 수 있습니다.</li>
<li>신뢰성: 신뢰할 수 있는 소스의 정보만을 제공하여 잘못된 정보의 전파를 방지합니다.</li>
<li>비용 효율성: 무료 사용량을 제공하여 소규모 프로젝트나 실험에 적합합니다.</li>
</ul>
<h1 id="결론">결론</h1>
<p>Tavily Search API는 LLM과 RAG 시스템을 위한 강력한 도구입니다. 최신 정보에 대한 접근성을 제공함으로써, LLM의 한계를 극복하고 더욱 정확하고 시의적절한 응답을 생성할 수 있게 합니다.
Tavily를 활용하면 뉴스 요약, 실시간 시장 분석, 최신 트렌드 파악 등 다양한 분야에서 LLM의 성능을 크게 향상시킬 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[LangChain과 GPT-4o-mini로 구현하는 간단한 페르소나 챗봇]]></title>
            <link>https://velog.io/@woody_ahn/LangChain%EA%B3%BC-GPT-4o-mini%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94-%EA%B0%84%EB%8B%A8%ED%95%9C-%ED%8E%98%EB%A5%B4%EC%86%8C%EB%82%98-%EC%B1%97%EB%B4%87</link>
            <guid>https://velog.io/@woody_ahn/LangChain%EA%B3%BC-GPT-4o-mini%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94-%EA%B0%84%EB%8B%A8%ED%95%9C-%ED%8E%98%EB%A5%B4%EC%86%8C%EB%82%98-%EC%B1%97%EB%B4%87</guid>
            <pubDate>Mon, 29 Jul 2024 15:54:54 GMT</pubDate>
            <description><![CDATA[<h1 id="시작하며">시작하며</h1>
<p>OpenAI의 gpt-4o-mini API 가격이 엄청 낮습니다. 1M 입력 로큰이 US$0.15 이고, 1M 출력 토큰이 US$0.6 입니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/e66e4d02-d5b2-454b-9c00-2aa3a5eecfff/image.png" alt="">
OpenAI가 제공하는 <a href="https://platform.openai.com/tokenizer">tokenizer</a> 로 확인해 보니 gpt-3.5 &amp; gpt-4 기준으로 한 글자당 한 토큰이네요.
<img src="https://velog.velcdn.com/images/woody_ahn/post/12fc3331-998e-4c7c-9ae3-4429d9f09182/image.png" alt=""></p>
<p>gpt-4o-mini도 한 글자당 한 토큰이라면 1,048,576 글자를 출력하는데 US$0.6 이라는거죠.
참고로 &#39;해리 포터와 마법사의 돌&#39;의 글자 수가 약 20만 자임을 감안하면, 이 가격이 얼마나 저렴한지 실감할 수 있습니다.</p>
<blockquote>
<p>그래서 써보기로 하고 US$10 지르고 API Key 발급 받았습니다.</p>
</blockquote>
<h1 id="langchain이란">LangChain이란?</h1>
<p>OpenAI에서 제공하는 API로 구현 할 수도 있지만 LangChain으로 해볼까 합니다. </p>
<blockquote>
<p>claude에게 LangChain에 대해서 설명해 달라고 하니 아래처럼 알려주네요. ^^</p>
</blockquote>
<p>LangChain은 대규모 언어 모델(LLM)을 활용한 애플리케이션 개발을 위한 프레임워크입니다. 이 프레임워크는 다양한 AI 모델과 도구들을 쉽게 통합할 수 있게 해주며, 복잡한 작업 흐름을 구현하는 데 필요한 여러 컴포넌트를 제공합니다.</p>
<p>LangChain의 주요 특징:</p>
<ul>
<li>다양한 LLM 지원</li>
<li>프롬프트 관리 및 최적화</li>
<li>메모리 기능을 통한 대화 컨텍스트 유지</li>
<li>외부 데이터 소스와의 연동</li>
<li>복잡한 작업을 위한 체인과 에이전트 구현</li>
</ul>
<h1 id="주요-코드-분석">주요 코드 분석</h1>
<h3 id="필요한-패키지를-설치하고-임포트">필요한 패키지를 설치하고 임포트</h3>
<p>LangChain을 사용하기 위해서 필요한 패키지를 설치합니다.</p>
<pre><code class="language-bash">pip3 install langchain langchain_openai</code></pre>
<p>그리고 필요한 모듈을 import 합니다.</p>
<pre><code class="language-python">from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.chains.conversation.memory import ConversationBufferMemory
from langchain.schema.runnable import RunnableLambda, RunnablePassthrough</code></pre>
<h3 id="언어-모델-설정">언어 모델 설정</h3>
<p>GPT-4o-mini를 사용하기 위해 API 키를 설정하고 ChatOpenAI 컴포넌트를 초기화합니다:</p>
<pre><code class="language-python"># OpenAI API Key
os.environ[&quot;OPENAI_API_KEY&quot;] = (
    &quot;your-openai-api-key&quot;
)

# initialize language model
llm = ChatOpenAI(model=&quot;gpt-4o-mini&quot;, temperature=0.0)</code></pre>
<h3 id="대화-히스토리용-메모리-설정">대화 히스토리용 메모리 설정</h3>
<p>LLM은 본질적으로 stateless이기 때문에, 대화의 맥락을 유지하기 위해서는 대화 히스토리를 저장하고 매번 LLM에 전송해야 합니다:</p>
<pre><code class="language-python"># memory key
HISTORY_MEMORY_KEY = &quot;chat_history&quot;

# initialize memory for chat history
memory = ConversationBufferMemory(
    memory_key=HISTORY_MEMORY_KEY,
    return_messages=True,
)
runnable = RunnablePassthrough.assign(
    chat_history=RunnableLambda(memory.load_memory_variables)
    | itemgetter(HISTORY_MEMORY_KEY)
)</code></pre>
<p>여기서는 <code>ConversationBufferMemory</code>를 사용했지만, LangChain은 다양한 메모리 유형을 제공합니다.</p>
<p><strong>LangChain에서 제공하는 메모리</strong></p>
<ul>
<li><code>ConversationBufferMemory</code>: 모든 대화 유지</li>
<li><code>ConversationBufferWindowMemory</code>: 최근 K개의 대화만을 유지.</li>
<li><code>ConversationTokenBufferMemory</code>: 토큰 길이로 대화 내용 유지.</li>
<li><code>ConversationEntityMemory</code>: 대화에서 특정 entity에 대한 주어진 사실을 기억. 대화에서 entity에 대한 정보를 추출하고 해당 entity에 대한 지식을 축적함.</li>
<li><code>ConversationKGMemory</code>: 지식 그래프를 사용한다고 함.</li>
<li><code>ConversationSummaryMemory</code>: 대화가 진행되는 동안 대화를 요약하여 저장.</li>
<li><code>ConversationSummaryBufferMemory</code>: 이전 대화는 요약해서 저장하고, 최근 대화 내용도 유지</li>
<li><code>VectorStoreRetrieverMemory</code>: 메모리로 벡터 DB를 사용. 상위 K개 쿼리.</li>
</ul>
<h3 id="프롬프트-템플릿-정의">프롬프트 템플릿 정의</h3>
<p><code>ChatPromptTemplate</code>을 사용하여 LLM에 전달할 프롬프트 템플릿을 정의합니다.</p>
<pre><code class="language-python"># create prompt for persona chat
system_prompts = &quot;&quot;.join(
    [
        &quot;너의 이름은 민지.&quot;,
        &quot;취미: 영화보기, 요리하기, 쇼핑하기&quot;,
        &quot;너는 대화할 때 이모티콘을 자주 써.&quot;,
    ]
)

prompt = ChatPromptTemplate.from_messages(
    [
        (&quot;system&quot;, system_prompts),
        MessagesPlaceholder(variable_name=HISTORY_MEMORY_KEY),
        (&quot;human&quot;, &quot;{question}&quot;),
    ]
)</code></pre>
<p>여기서는 챗봇에 간단한 페르소나를 부여하고, 대화 히스토리와 사용자 입력을 포함하도록 템플릿을 구성했습니다.</p>
<h3 id="체인-구성">체인 구성</h3>
<p>LCEL(LangChain Expression Language)을 사용해서 memory, prompt, llm을 하나의 체인으로 묶어줍니다.</p>
<pre><code class="language-python"># create chain
chain = runnable | prompt | llm | StrOutputParser()</code></pre>
<p>이렇게 구성된 체인은 다음과 같은 흐름으로 동작합니다.
<img src="https://velog.velcdn.com/images/woody_ahn/post/ed900317-9dff-40d0-9035-bcabb56d5207/image.png" alt=""></p>
<ol>
<li>대화 히스토리 로드</li>
<li>프롬프트 템플릿에 히스토리와 사용자 입력 적용</li>
<li>LLM을 통한 응답 생성</li>
<li>응답을 문자열로 파싱</li>
</ol>
<h3 id="대화">대화</h3>
<p>마지막으로 CLI로 사용자 입력을 받아서 chain을 통해 LLM에게 질문하고. 응답을 출력, 저장합니다.</p>
<pre><code class="language-python">while True:
    user_input = input(&quot;You: &quot;)

    response = chain.invoke({&quot;question&quot;: user_input})
    memory.save_context({&quot;input&quot;: user_input}, {&quot;output&quot;: response})

    print(&quot;Assistant:&quot;, response)
</code></pre>
<h1 id="마치며">마치며</h1>
<p>이렇게 LangChain을 사용하면 페르소나를 가진 챗봇을 비교적 적은 코드로 구현할 수 있습니다. 이 예제에서는 간단하게 페르소나를 정의하고 대화 기록 유지 기능만을 구현했지만, LangChain의 다양한 기능을 활용하면 더욱 복잡하고 흥미로운 챗봇을 만들 수 있을 것입니다.
전체 코드는 GitHub 레포지토리에서 확인할 수 있습니다. <a href="https://github.com/hissinger/simple-ai-chatbot">링크</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NestJS에서 유저 프로필 이미지 S3 업로드 구현]]></title>
            <link>https://velog.io/@woody_ahn/NestJS%EC%97%90%EC%84%9C-%EC%9C%A0%EC%A0%80-%ED%94%84%EB%A1%9C%ED%95%84-%EC%9D%B4%EB%AF%B8%EC%A7%80-S3-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@woody_ahn/NestJS%EC%97%90%EC%84%9C-%EC%9C%A0%EC%A0%80-%ED%94%84%EB%A1%9C%ED%95%84-%EC%9D%B4%EB%AF%B8%EC%A7%80-S3-%EC%97%85%EB%A1%9C%EB%93%9C-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Sat, 20 Jul 2024 15:06:03 GMT</pubDate>
            <description><![CDATA[<p>nestjs에서 <code>multerS3</code>와 <code>@aws-sdk/client-s3</code>을 사용한 유저 프로필 이미지 업로드 예제입니다. 그리고 aws의 s3와 완벽하게 호환되는 minio와 postman으로 테스트 하는 방법을 소개합니다.</p>
<h1 id="구현">구현</h1>
<p>s3 업로드를 위해서 <code>multerS3</code>와 <code>@aws-sdk/client-s3</code> 를 설치</p>
<pre><code class="language-zsh">npm i @aws-sdk/client-s3 multer-s3 --save
npm i @types/multer-s3 --save-dev</code></pre>
<p><code>FileInterceptor</code>의 <code>storage</code>를 s3로 설정하면 <code>FileInterceptor</code>가 업로드를 처리하고 완료된 후에 업로드된 file 정보만 users controller로 넘겨준다. 매우 간단하다.</p>
<pre><code class="language-typescript">// src/users.contoller.ts

import {
  BadRequestException,
  Controller,
  FileTypeValidator,
  MaxFileSizeValidator,
  ParseFilePipe,
  Post,
  UploadedFile,
  UseInterceptors,
} from &#39;@nestjs/common&#39;;
import { UsersService } from &#39;./users.service&#39;;
import { FileInterceptor } from &#39;@nestjs/platform-express&#39;;
import * as multerS3 from &#39;multer-s3&#39;;
import { S3Client } from &#39;@aws-sdk/client-s3&#39;;

const multerS3Config = multerS3({
  s3: new S3Client({
    endpoint: &#39;http://127.0.0.1:9000/&#39;,
    region: &#39;ap-northeast-2&#39;,
    credentials: {
      accessKeyId: &#39;KQQsW1NEMrfv7r8buxax&#39;,
      secretAccessKey: &#39;u4AAMddrra9Oa1QaM4pIw40VyiacFgCzz39Y99He&#39;,
    },
  }),
  bucket: &#39;test&#39;,
  acl: &#39;public-read&#39;,
  contentType: multerS3.AUTO_CONTENT_TYPE,
  key: (req, file, cb) =&gt; {
    cb(null, `${Date.now().toString()}-${file.originalname}`);
  },
});

@Controller(&#39;/users&#39;)
export class UsersController {
  constructor(private usersService: UsersService) {}

  // upload profile image
  @Post(&#39;:id/profile-image&#39;)
  @UseInterceptors(FileInterceptor(&#39;file&#39;, { storage: multerS3Config }))
  async uploadProfileImage(
    @UploadedFile(
      new ParseFilePipe({
        validators: [
          new FileTypeValidator({ fileType: &#39;.(png|jpeg|jpg)&#39; }),
          new MaxFileSizeValidator({
            maxSize: 1024 * 1024 * 1, // 1MB
            message: &#39;file size is too large&#39;,
          }),
        ],
      }),
    )
    file: Express.MulterS3.File,
  ) {
    if (!file) {
      throw new BadRequestException(&#39;file not found&#39;);
    }

    return { fileUrl: file.location };
  }
}
</code></pre>
<h1 id="코드를-만들었으니-테스트를-해봐야지">코드를 만들었으니 테스트를 해봐야지...</h1>
<p>aws s3를 설정하고 쓰면 좋겠지만 당장 비용 때문에 쓸 수 없을 때 사용할 수 있는게 <code>minio</code>다. </p>
<blockquote>
<p><code>minio</code>는 오브젝트 스토리지 오픈소스이고 aws의 s3와 완벽하게 호환된다.</p>
</blockquote>
<p>아래와 같이 <code>minio</code>를 실행하자.</p>
<pre><code class="language-yml">services:  
  minio:
    image: quay.io/minio/minio
    container_name: minio
    ports:
      - &quot;9000:9000&quot;
      - &quot;9001:9001&quot;
    environment:
      MINIO_ROOT_USER: root
      MINIO_ROOT_PASSWORD: root1234
    volumes:
      - ./docker_data/minio/data:/data
    command: server /data --console-address &quot;:9001&quot;</code></pre>
<p><code>minio</code>가 실행되면 브라우저에서 <a href="http://127.0.0.1:9001">http://127.0.0.1:9001</a> 로 접속한 후에 <code>accessKey</code>와 <code>secretAccessKey</code>를 생성해주고, <code>bucket</code>도 만들어 주면 된다. 그리고 이 정보는 위 코드에서 <code>S3Client</code> 생성코드의 <code>endpoint</code>, <code>credentials</code>, <code>endpoint</code>에 넣어주면 된다.
나중에 aws의 s3로 변경해도 s3 설정 외에는 코드를 바꿀 필요가 없다.</p>
<p>그리고, 검색해보니 postman로 이미지 업로드 테스트를 할 수가 있네요. body를 <code>form-data</code>로 선택한 후에 key의 타입을 <code>File</code>로 변경하고 아래처럼 key를 만들어주면 된다.</p>
<ul>
<li>key: &quot;file&quot;</li>
<li>value: 테스트용 이미지
<img src="https://velog.velcdn.com/images/woody_ahn/post/b3da7f2f-f14e-4a02-aa54-5e681211eae4/image.png" alt=""></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[WebRTC 기반 영상 통화 Flutter 앱 개발하기]]></title>
            <link>https://velog.io/@woody_ahn/WebRTC-Video-Calling-with-Flutter</link>
            <guid>https://velog.io/@woody_ahn/WebRTC-Video-Calling-with-Flutter</guid>
            <pubDate>Mon, 01 Jul 2024 16:58:31 GMT</pubDate>
            <description><![CDATA[<p>Flutter를 사용해 WebRTC 기반의 영상 통화 앱을 만들어보려고 합니다. 평소에 Flutter에 관심이 있었고, 회사에서 이미 React Native를 사용 중이어서 새로운 기술을 시도해보고 싶었죠. 이번 프로젝트는 간단한 연결과 종료 기능만 구현된 샘플 앱 수준입니다.</p>
<h2 id="개발-환경-구축">개발 환경 구축</h2>
<p>먼저, Flutter 개발 환경 구축을 해야 합니다. 이 부분은 구글에 이미 많은 자료들이 있으니 자세한 설명보다는 생각나는 것들만 간단히 정리해 보겠습니다.</p>
<ol>
<li>Flutter 설치
•    Flutter SDK를 다운로드하여 설치합니다.
•    환경 변수를 설정합니다.</li>
<li>Android Studio 설치
•    Android Studio를 다운로드하여 설치합니다.
•    Flutter 및 Dart 플러그인을 설치합니다.</li>
<li>Android Studio에서 Flutter 프로젝트 생성
•    Android Studio를 실행하고 새로운 Flutter 프로젝트를 생성합니다.</li>
</ol>
<p>만약 iOS 앱까지 빌드하시려면 추가로 다음이 필요합니다:</p>
<ol>
<li>Xcode 설치</li>
<li>Xcode에서 프로젝트의 Signing 설정
•    Xcode에서 프로젝트를 열고, iOS 앱의 Signing &amp; Capabilities에서 적절한 팀과 프로비저닝 프로파일을 설정합니다.</li>
</ol>
<p>Flutter가 제대로 설치되었는지는 <code>flutter doctor</code> 명령어로 확인할 수 있습니다. 아래는 <code>flutter doctor</code> 실행 예시입니다:</p>
<pre><code class="language-bash">&gt; flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.22.2, on macOS 14.5 23F79 darwin-arm64, locale ko-KR)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.4)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.1)
[✓] VS Code (version 1.90.2)
[✓] Connected device (4 available)
[✓] Network resources

• No issues found!</code></pre>
<p><code>flutter doctor</code>를 통해 환경 설정이 올바르게 되었는지 확인할 수 있으며, 만약 문제가 있다면 여기서 표시된 정보를 통해 문제를 해결할 수 있습니다.</p>
<h2 id="signaling-server">Signaling Server</h2>
<p>WebRTC를 사용한 영상 통화 앱을 만들려면 앱 이외에 시그널링 서버가 필요합니다. 시그널링 서버를 통해 SDP(Session Description Protocol) 교환 과정을 통해 미디어 연결이 가능해집니다. 시그널링 서버는 WebRTC 피어 간의 초기 연결을 설정하는 데 필요한 정보를 교환하는 역할을 합니다.</p>
<ul>
<li>SDP 교환: 연결을 설정하기 위해 클라이언트 간에 세션 설명을 교환합니다.</li>
<li>ICE Candidate 교환: NAT 방화벽을 통과할 수 있도록 ICE(Interactive Connectivity Establishment) 후보를 교환합니다.</li>
</ul>
<blockquote>
<p>SDP, ICE Candidate에 대한 설명과 이것들이 서로 교환되어야 하는지에 대해서 이미 많은 분들이 정리해두었으니 생략합니다. 이 글은 Flutter 앱을 만드는 것에 집중하겠습니다.</p>
</blockquote>
<p>SDP와 ICE Candidate를 교환하는 방식에 대해서는 정해진 것이 없습니다. HTTP를 사용해도 되고, WebSocket을 사용해도 됩니다. Socket.IO나 Redis를 사용할 수도 있습니다. 앱이 SDP와 ICE Candidate를 주고받을 수 있기만 하면 됩니다. 이 프로젝트에서는 Socket.IO를 사용했습니다.</p>
<p>시그널링 서버 코드는 <a href="https://github.com/hissinger/simple-video-chat-server">repo</a>를 참고하시면 됩니다.</p>
<blockquote>
<p>매우 간단하게 구현되어 있습니다. 같은 room ID로 두 명이 join을 하면 한쪽에 <code>start</code>를 보내서 SDP, ICE Candidate 교환이 시작되도록 되어 있습니다.
당연한 이야기지만 시그널링 서버는 앱에서 접근할 수 있는 서버에 배포되어 있어야 합니다.</p>
</blockquote>
<h2 id="flutter-code">Flutter Code</h2>
<p>이제부터 Flutter 코드 설명입니다. 기본적으로 WebRTC를 사용하려면 아래와 같은 과정이 필요합니다.</p>
<ol>
<li>로컬 스트림 생성</li>
<li>피어 연결(PeerConnection) 생성</li>
<li>시그널링 서버를 통한 SDP, ICE Candidate 교환</li>
<li>Video Renderer</li>
<li>종료</li>
</ol>
<h3 id="1-로컬-스트림-생성">1. 로컬 스트림 생성</h3>
<p>영상 통화를 위해서 카메라와 마이크의 권한을 설정해야 영상과 음성을 사용할 수 있습니다.</p>
<p><strong>Android</strong>
<code>android/app/src/main/AndroidManifest.xml</code> 파일에 다음 권한을 추가합니다.</p>
<pre><code class="language-xml">&lt;uses-permission android:name=&quot;android.permission.CAMERA&quot; /&gt;
&lt;uses-permission android:name=&quot;android.permission.RECORD_AUDIO&quot; /&gt;</code></pre>
<p><strong>iOS</strong>
<code>ios/Runner/Info.plist</code> 파일에 다음 권한 설명을 추가합니다.</p>
<pre><code>&lt;key&gt;NSCameraUsageDescription&lt;/key&gt;
&lt;string&gt;We need access to the camera for video calls&lt;/string&gt;
&lt;key&gt;NSMicrophoneUsageDescription&lt;/key&gt;
&lt;string&gt;We need access to the microphone for audio calls&lt;/string&gt;</code></pre><p>위와 같이 각 플랫폼의 권한 설정을 해주고, 카메라와 마이크로부터 영상과 음성을 얻어옵니다.</p>
<p>아래는 권한을 요청하고 로컬 스트림을 생성하는 코드입니다.</p>
<pre><code class="language-dart">// 카메라 권한상태를 조회하고 요청
Future&lt;PermissionStatus&gt; _checkCameraPermission() async {
  var status = await Permission.camera.status;
  if (status.isDenied) {
    status = await Permission.camera.request();
  }
  return status;
}

// 마이크의 권한 상태를 조회하고 요청
Future&lt;PermissionStatus&gt; _checkMicrophonePermission() async {
  var status = await Permission.microphone.status;
  if (status.isDenied) {
    status = await Permission.microphone.request();
  }
  return status;
}

// 로컬스트림 생성
_createLocalStream() async {
  var cameraStatus = await _checkCameraPermission();
  var micStatus = await _checkMicrophonePermission();

  if (cameraStatus.isGranted &amp;&amp; micStatus.isGranted) {
      // 오디오와 비디오 설정
    final Map&lt;String, dynamic&gt; mediaConstraints = {
      &#39;audio&#39;: true,
      &#39;video&#39;: {
        &#39;facingMode&#39;: &#39;user&#39;, // 전면 카메라 사용
        &#39;mandatory&#39;: {
          &#39;minWidth&#39;: &#39;640&#39;,
          &#39;minHeight&#39;: &#39;320&#39;,
        },
      },
    };

    // 로컬스트림 생성
    MediaStream stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
    _localStream = stream;

    // 로컬 비디오 렌더러에 생성된 로컬스트림을 연결
    _localRenderer.srcObject = stream;

    // 생성된 로컬 스트림의 모든 트랙을 RTCPeerConnection에 추가하여 원격 피어와 공유
    _localStream!.getTracks().forEach((track) {
      _peerConnection!.addTrack(track, _localStream!);
    });

    // 스트림이 설정된 후 UI 업데이트
    setState(() {});
  }
}</code></pre>
<h3 id="2-피어-연결peerconnection-생성">2. 피어 연결(PeerConnection) 생성</h3>
<p>상대방과 연결을 위해 WebRTC의 <code>RTCPeerConnection</code> 객체를 생성하고 필요한 이벤트 리스너를 설정합니다.</p>
<pre><code class="language-dart">_createPeerConnection() async {
  Map&lt;String, dynamic&gt; configuration = {
    &quot;iceServers&quot;: [
      {
        &quot;urls&quot;: [&quot;stun:stun.l.google.com:19302&quot;]
      },
    ],
    &#39;sdpSemantics&#39;: &#39;unified-plan&#39;,
  };

  // 새로운 RTCPeerConnection 객체를 생성
  final pc = await createPeerConnection(configuration);

  // 새로운 ICE candidate 발견될 때 호출되는 콜백 함수.
  pc.onIceCandidate = (candidate) {
      // 발견된 ICE 후보를 시그널링 서버로 전송하여 상대방에게 전달
    socket!.emit(&#39;ice_candidate&#39;, {
      &#39;candidate&#39;: candidate.candidate,
      &#39;sdpMid&#39;: candidate.sdpMid,
      &#39;sdpMLineIndex&#39;: candidate.sdpMLineIndex,
    });
  };

  // 원격 피어에서 새로운 미디어 스트림 트랙을 수신할 때 호출되는 콜백 함수
  pc.onTrack = (event) {
      // 수신한 스트림을 `_remoteRenderer`에 설정하여 원격 비디오를 표시
    _remoteRenderer.srcObject = event.streams[0];
    setState(() {});
  };

  _peerConnection = pc;
}</code></pre>
<p>이 코드는 WebRTC를 사용하여 피어 간의 미디어 스트림을 교환하기 위한 기본 설정을 다룹니다. <code>RTCPeerConnection</code> 객체는 WebRTC를 통해 미디어 데이터를 주고받는 데 핵심적인 역할을 합니다.</p>
<h3 id="3-시그널링-서버를-통한-sdp-ice-candidate-교환">3. 시그널링 서버를 통한 SDP, ICE Candidate 교환</h3>
<p>위에서 설명한 것 처럼 Socket.IO를 사용해서 SDP, ICE Candidate를 교환합니다.</p>
<pre><code class="language-dart">void _connectSocket() {
  const url = SIGNALING_SERVER_URL;
  socket = IO.io(url, &lt;String, dynamic&gt;{
    &#39;transports&#39;: [&#39;websocket&#39;],
    &#39;autoConnect&#39;: false,
  });
  socket!.connect();

  // 시그널링 서버와 연결되면 이름과 room id를 join 메시지로 전달
  socket!.on(&#39;connect&#39;, (_) {
    print(&#39;connected to signaling server&#39;);
    socket!.emit(&#39;join&#39;, {&#39;name&#39;: widget.name, &#39;room&#39;: widget.roomId});
  });

  // start 메시지 수신
  socket!.on(&quot;start&quot;, (_) async {
    // offer SDP를 생성하고 시그널링 서버로 전달
    _createOffer();
  });

  // offer 메시지 수신 
  socket!.on(&#39;offer&#39;, (data) async {
    // 상대방의 offer SDP를 set하고, answer SDP 생성
    await _peerConnection!.setRemoteDescription(
      RTCSessionDescription(data[&#39;sdp&#39;], data[&#39;type&#39;]),
    );
    _createAnswer();
  });

  // answer 메시지 수신
  socket!.on(&#39;answer&#39;, (data) async {
    // 상대방의 answer SDP를 set
    await _peerConnection!.setRemoteDescription(
      RTCSessionDescription(data[&#39;sdp&#39;], data[&#39;type&#39;]),
    );
  });

  // ice_candidate 메시지 수신
  socket!.on(&#39;ice_candidate&#39;, (data) async {
    // 상대방의 ice candidate를 추가
    await _peerConnection!.addCandidate(
      RTCIceCandidate(
        data[&#39;candidate&#39;],
        data[&#39;sdpMid&#39;],
        data[&#39;sdpMLineIndex&#39;],
      ),
    );
  });

  socket!.on(&#39;disconnect&#39;, (_) {
    print(&#39;disconnected from signaling server&#39;);
  });
}

// offer SDP를 생성하고, 시그널링 서버로 offer 메시지 전달
void _createOffer() async {
  RTCSessionDescription description = await _peerConnection!
      .createOffer({&#39;offerToReceiveVideo&#39;: 1, &#39;offerToReceiveAudio&#39;: 1});
  await _peerConnection!.setLocalDescription(description);
  socket!.emit(&#39;offer&#39;, {
    &#39;sdp&#39;: description.sdp,
    &#39;type&#39;: description.type,
    &#39;room&#39;: widget.roomId,
  });
}

// answer SDP를 생성하고, 시그널링 서버로 answer 메시지 전달
void _createAnswer() async {
  RTCSessionDescription description = await _peerConnection!
      .createAnswer({&#39;offerToReceiveVideo&#39;: 1, &#39;offerToReceiveAudio&#39;: 1});
  await _peerConnection!.setLocalDescription(description);
  socket!.emit(&#39;answer&#39;, {
    &#39;sdp&#39;: description.sdp,
    &#39;type&#39;: description.type,
    &#39;room&#39;: widget.roomId,
  });
}</code></pre>
<h3 id="4-video-renderer">4. Video Renderer</h3>
<p>영상 통화 애플리케이션에서 비디오를 표시하기 위해서는 <code>RTCVideoRenderer</code>를 사용해야 합니다.
먼저, <code>RTCVideoRenderer</code> 객체를 초기화해야 합니다. 로컬 및 원격 비디오 스트림을 위한 두 개의 렌더러를 설정합니다.</p>
<pre><code class="language-dart">final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();

@override
void initState() {
  super.initState();

  await _localRenderer.initialize();
  await _remoteRenderer.initialize();
}</code></pre>
<p><strong>로컬 비디오 렌더링</strong></p>
<pre><code class="language-dart">// 로컬 스트림 생성
MediaStream stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);

// 로컬 비디오 렌더러에 생성된 로컬 스트림을 연결
_localRenderer.srcObject = stream;

// 스트림이 설정된 후 UI 업데이트
setState(() {});</code></pre>
<p><strong>원격 비디오 렌더링</strong></p>
<pre><code class="language-dart">pc.onTrack = (event) {
    _remoteRenderer.srcObject = event.streams[0];
    setState(() {});
};</code></pre>
<h3 id="5-종료">5. 종료</h3>
<p>통화가 종료될 때 <code>로컬스트림</code>과 <code>RTCPeerConnection</code>을 꼭 정리해주어야 합니다. </p>
<pre><code class="language-dart">void _stopCall() {
  // 로컬 스트림의 모든 트랙을 정지
  _localStream?.getTracks().forEach((track) =&gt; track.stop());
  _localStream?.dispose();

  // peerconnection 종료
  _peerConnection?.close();
}</code></pre>
<h2 id="앱-테스트하기">앱 테스트하기</h2>
<p>이제 두 개의 폰을 준비하고 Android Studio나 Xcode에서 빌드를 완료하고 실행하면 아래 왼쪽 이미지와 같은 화면이 보입니다. 이름에는 아무거나 넣고, 방 번호에 같은 숫자를 넣어준 후에 통화 참여를 누르면 👍
<img src="https://velog.velcdn.com/images/woody_ahn/post/a071ed36-c1ae-4535-8626-a36db770552e/image.jpg" width="600"></p>
<h2 id="마치며">마치며</h2>
<p>React-Native에서는 <a href="https://github.com/react-native-webrtc/react-native-webrtc/issues/450">로컬영상의 radius가 안 먹히는 문제</a>가 있던데, Flutter에서는 문제 없이 잘 되네요.
전체 코드는 <a href="https://github.com/hissinger/simple-video-chat">repo</a> 에 올려 두었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ChartJS로 WebRTC 통계를 시각화]]></title>
            <link>https://velog.io/@woody_ahn/ChartJS%EB%A1%9C-WebRTC-%ED%86%B5%EA%B3%84%EB%A5%BC-%EC%8B%9C%EA%B0%81%ED%99%94</link>
            <guid>https://velog.io/@woody_ahn/ChartJS%EB%A1%9C-WebRTC-%ED%86%B5%EA%B3%84%EB%A5%BC-%EC%8B%9C%EA%B0%81%ED%99%94</guid>
            <pubDate>Fri, 07 Jun 2024 17:49:36 GMT</pubDate>
            <description><![CDATA[<h1 id="chromewebrtc-internals">chrome://webrtc-internals/</h1>
<p>WebRTC를 접해본 개발자라면 이미 알고 있는 것이겠지만, chrome 브라우저에서는 <code>chrome://webrtc-internals/</code> 으로 WebRTC 연결에 대한 자세한 통계를 볼 수 있다. bitrate, jitter, loss, rtt 등의 통계정보가 실시간으로 업데이트 되며 디버깅 할때 매우 유용하다.</p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/b5545a58-1d25-4504-bf9b-c8268723da93/image.png" alt=""></p>
<p>디버깅할 때는 <code>chrome://webrtc-internals/</code> 페이지를 열어서 연결 상태를 확인하면 됩니다. </p>
<blockquote>
<p>하지만 현재 연결 상태 정보를 사용자에게 실시간으로 이쁘게 보여주어야 한다면?</p>
</blockquote>
<p>이 통계 데이터는 <code>RTCPeerConnection</code>의 <code>getStats()</code>를 기반으로 시각화한 것이기 때문에 <code>ChartJS</code>으로 직접 그릴 수 있다.</p>
<h1 id="webrtcstatsvisualizer">WebRTCStatsVisualizer</h1>
<p>통계를 보여줄 <code>ChartJS</code> wrapper component는 아래 코드 참조. chat-gpt가 만들어준 코드 ^^ <del>(코딩 테스트가 무슨 의미가 있는가)</del></p>
<pre><code class="language-ts">import React from &quot;react&quot;;
import { Chart, ChartConfiguration, registerables } from &quot;chart.js&quot;;
import &quot;chartjs-adapter-luxon&quot;;
import StreamingPlugin from &quot;chartjs-plugin-streaming&quot;;

Chart.register(...registerables, StreamingPlugin);

type WebRTCStatsVisualizerProps = {
  label: string;
  borderColor: string;
  width: number;
  height: number;
};

export type WebRTCStatsVisualizerHandle = {
  pushData: (timestamp: number, value: number) =&gt; void;
};

const WebRTCStatsVisualizer = React.forwardRef&lt;
  WebRTCStatsVisualizerHandle,
  WebRTCStatsVisualizerProps
&gt;((props, ref) =&gt; {
  const { label, borderColor, width, height } = props;

  const chartRef = React.useRef&lt;Chart | null&gt;(null);
  const canvasRef = React.useRef&lt;HTMLCanvasElement&gt;(null);

  const data = React.useMemo(
    () =&gt; ({
      labels: [],
      datasets: [
        {
          label,
          data: [],
          fill: false,
          borderColor,
        },
      ],
    }),
    [label, borderColor]
  );

  React.useEffect(() =&gt; {
    const ctx = canvasRef.current!.getContext(&quot;2d&quot;)!;
    const config: ChartConfiguration&lt;&quot;line&quot;&gt; = {
      type: &quot;line&quot;,
      data,
      options: {
        responsive: true,
        scales: {
          x: {
            type: &quot;realtime&quot;,
            realtime: {
              duration: 60_000 * 10,
              delay: 5_000,
              refresh: 5_000,
            },
          },
          y: {
            beginAtZero: true,
          },
        },
      },
    };
    chartRef.current = new Chart(ctx, config);

    return () =&gt; {
      if (chartRef.current) {
        chartRef.current.destroy();
      }
    };
  }, [data]);

  React.useImperativeHandle(ref, () =&gt; ({
    pushData(timestamp: number, value: number) {
      if (chartRef.current) {
        chartRef.current.data.datasets[0].data.push({ x: timestamp, y: value });
        chartRef.current.update(&quot;none&quot;);
      }
    },
  }));

  return (
    &lt;div&gt;
      &lt;canvas ref={canvasRef} width={width} height={height} /&gt;
    &lt;/div&gt;
  );
});

export default WebRTCStatsVisualizer;
</code></pre>
<h1 id="이제-원하는-곳에-넣자">이제 원하는 곳에 넣자.</h1>
<p><code>getStats()</code>으로 원하는 metric을 가져와서 WebRTCStatsVisualizer에 주기적으로 push 해주면 된다.</p>
<pre><code class="language-ts">  // Collect stats  
    pc.getStats().then((stats) =&gt; {
      stats.forEach((report) =&gt; {
        if (report.type === &quot;candidate-pair&quot;) {
          rttRef.current!.pushData(
            new Date().getTime(),
            report.currentRoundTripTime
          );
        }
      });
    });

...

  &lt;WebRTCStatsVisualizer
      ref={rttRef}
      label=&quot;RTT&quot;
      borderColor=&quot;rgb(255, 0, 0, 1)&quot;
      width={300}
      height={200}
  /&gt;
</code></pre>
<p><strong>GITHUB</strong>: <a href="https://github.com/hissinger/webrtc-stats-visualizer">https://github.com/hissinger/webrtc-stats-visualizer</a></p>
<p><img src="https://velog.velcdn.com/images/woody_ahn/post/7b1e937c-c449-4b5c-a183-0ae31205abac/image.png" alt=""></p>
]]></description>
        </item>
    </channel>
</rss>