최근의 AI Client는 단순히 정해진 답변만 하는 것을 넘어, MCP와 같은 프로토콜을 기반으로 외부 도구를 활용하고 여러 AI 모델의 능력을 조합하여 복잡한 문제를 해결하는 방향으로 진화하고 있습니다. 이번 글에서는 사용자의 질문에 맞춰 최적의 도구를 동적으로 선택하고, 여러 AI 에이전트의 답변을 종합하여 최종 결과를 제공하는 챗봇을 만드는 방법을 소개합니다.

이 MCP Client는 Streamlit으로 만든 UI 뒤에서, LangChain과 MCP(Model Context Protocol)라는 핵심 기술을 사용하여 구현되었습니다. 제공된 코드를 단계별로 분석하며 이 챗봇이 어떻게 작동하는지 살펴보겠습니다.
Github 링크 부터 공유합니다: https://github.com/choonzang/streamlit-mcp-chatbot
아키텍처 한눈에 보기
이 챗봇의 가장 큰 특징은 ‘지능형 라우팅‘과 ‘병렬 에이전트 실행‘입니다. 사용자가 질문을 던지면, 챗봇은 다음과 같은 과정을 거칩니다.
- LLM 라우터 (Router): 먼저 대형 언어 모델(LLM)이 사용자의 질문 의도를 파악합니다. 그리고 미리 정의된 MCP 서버(도구 모음) 목록과 그 설명을 보고, 이 질문을 해결하는 데 가장 적합한 도구가 어떤 것인지 스스로 판단하여 선택합니다.
- 동적 에이전트 생성: 선택된 MCP 서버에 연결하고, 해당 서버가 제공하는 도구(Tool)들을 불러옵니다. 그리고 이 도구를 사용할 수 있는 LangChain 에이전트(Agent)를 실시간으로 생성합니다.
- 에이전트 실행:
- 만약 하나의 도구만 선택되었다면, 해당 에이전트를 즉시 실행하여 답변을 얻습니다.
- 만약 여러 도구가 필요하다면(예: “서울 날씨 알려주고 거실 불 켜줘”), 각 도구에 해당하는 에이전트들을 병렬로 동시에 실행합니다.
- 답변 종합 (Synthesis): 여러 에이전트로부터 각각의 답변이 오면, 마지막으로 마스터 AI가 이 답변들을 종합하여 하나의 자연스럽고 일관된 문장으로 재구성하여 사용자에게 최종 답변을 제공합니다.
일전에 crew AI의 경우가 아래 그림의 왼쪽에 해당하고, 이번 포스트에 소개된 방식이 아래 그림의 오른쪽에 해당합니다.

이 모든 과정이 단 몇 초 안에 이루어집니다. 이제 각 구성 요소를 자세히 살펴보겠습니다.
프로젝트 파일 구조 및 역할
이 챗봇은 여러 파일이 유기적으로 연결되어 동작합니다.
app.py
: Streamlit으로 UI를 구성하고, 사용자의 입력을 받아 전체 프로세스를 관장하는 메인 애플리케이션입니다.mcp/youtube_transcript/youtube.py
: MCP 서버의 실제 예시입니다. 유튜브 영상의 자막을 추출하거나, 키워드로 영상을 검색하는 등의 도구를 제공하는 stdio 방식의 독립적인 MCP Server 프로그램 예시입니다. (본 코드는 github.com/dabidstudio에서 얻었습니다.)mcp.json.sample
: 챗봇이 사용할 수 있는 MCP 서버들의 목록과 주소, 그리고 가장 중요한 **’도구에 대한 설명’**이 담긴 설정 파일입니다. 라우터 LLM은 이 설명을 보고 어떤 도구를 쓸지 결정합니다.requirements.txt
: 프로젝트 실행에 필요한 모든 파이썬 라이브러리 목록입니다.README.md
: 가상환경 설정, 라이브러리 설치, 실행 방법 등 프로젝트의 기본적인 사용법을 안내합니다.
주요 기능 및 코드 분석
1. Streamlit 기반 UI로 MCP Client (app.py)
Streamlit을 사용하여 매우 직관적인 웹 UI를 제공합니다.
- 인증: 간단한 비밀번호 인증 기능이 있어 허가된 사용자만 접근할 수 있습니다.
- LLM 선택: 사이드바에서 OpenAI(gpt-4o, gpt-4.1 등)와 Anthropic(Claude-3.5-Sonnet 등)의 다양한 모델 중 원하는 것을 실시간으로 변경하며 사용할 수 있습니다.
- 대화 관리:
- ‘새로운 채팅 열기’ 버튼으로 언제든 새로운 대화를 시작할 수 있습니다.
- 모든 대화는 타임스탬프가 찍힌 JSON 파일로 자동 저장되며 (
chat_histories/
폴더), 사이드바에서 이전 대화를 불러오거나 삭제하는 것이 가능합니다.
- MCP 서버 관리: 사이드바에서 현재 연결된 MCP 서버 목록을 확인하고, 새로운 서버를 추가하거나 기존 서버를 삭제하는 기능을 제공합니다. 이를 통해 챗봇의 능력을 동적으로 확장할 수 있습니다.
2. 지능형 라우팅의 핵심 (app.py의 select_mcp_servers
)
이 프로젝트의 주요 코드 인 app.py 입니다.
import streamlit as st
import os
import json
import time
import asyncio
from dotenv import load_dotenv
from typing import List, Dict, Any
from datetime import datetime
from pathlib import Path
# LangChain 관련 라이브러리
from langchain_core.tools import tool
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain.agents import AgentExecutor
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableParallel
from langchain_core.output_parsers import StrOutputParser
from langgraph.prebuilt import create_react_agent
# --- 환경 변수 및 설정 로드 ---
load_dotenv()
# -----------------------------------------------------------------------------
# 실제 라이브러리 사용 시 아래 주석을 해제하고, 위의 Mock 객체들은 삭제하세요.
from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_mcp_adapters.tools import load_mcp_tools
# -----------------------------------------------------------------------------
# --- 상수 및 전역 변수 설정 ---
MCP_CONFIG_FILE = "mcp.json"
HISTORY_DIR = Path("chat_histories")
HISTORY_DIR.mkdir(exist_ok=True) # 대화 기록 저장 폴더 생성
global selected_category
global selected_item
selected_category = None
selected_item = None
llm_options = {
"OpenAI":['o4-mini','o3','o3-mini','o1','o1-mini','gpt-4o','gpt-4.1'],
"Claude":['claude-3-7-sonnet-20250219', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022','claude-3-5-sonnet-20240620','claude-sonnet-4-20250514','claude-opus-4-20250514']
}
# --- 헬퍼 함수 ---
def run_async(func):
"""비동기 함수를 Streamlit에서 실행하기 위한 헬퍼"""
return asyncio.run(func)
def generate_filename_with_timestamp(prefix="chat_", extension="json"):
"""타임스탬프를 포함한 파일명을 생성합니다."""
now = datetime.now()
timestamp_str = now.strftime("%Y%m%d_%H%M%S")
if prefix:
filename = f"{prefix}{timestamp_str}.{extension}"
else:
filename = f"{timestamp_str}.{extension}"
return filename
# @st.cache_resource
def get_llm():
"""LLM 모델을 초기화하고 캐시합니다."""
if selected_category == 'Claude':
llm = ChatAnthropic(model=selected_item, temperature=0, max_tokens=4096)
elif selected_category == 'OpenAI':
llm = ChatOpenAI(model=selected_item, max_tokens=8000)
else:
llm = ChatOpenAI(model="o4-mini", max_tokens=8000)
return llm
# @st.cache_data
def load_mcp_config():
"""mcp.json 설정 파일을 로드하고 캐시합니다."""
with open(MCP_CONFIG_FILE, "r", encoding="utf-8") as f:
return json.load(f)
def save_mcp_config(config):
"""MCP 서버 설정을 mcp.json 파일에 저장합니다."""
with open(MCP_CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
# --- 핵심 로직 함수 (변경 없음) ---
# ... (기존 process_query, select_mcp_servers 함수들은 변경 없이 그대로 유지) ...
async def select_mcp_servers(query: str, servers_config: Dict) -> List[str]:
"""사용자 질의에 기반하여 사용할 MCP 서버를 LLM을 통해 선택합니다."""
llm = get_llm()
system_prompt = "You are a helpful assistant that selects the most relevant tools for a given user query."
prompt_template = """
사용자의 질문에 가장 적합한 도구를 그 'description'을 보고 선택해주세요.
선택된 도구의 이름(키 값)을 쉼표로 구분하여 목록으로만 대답해주세요. (예: weather,Home Assistant)
만약 적합한 도구가 없다면 'None'이라고만 답해주세요.
[사용 가능한 도구 목록]
{tools_description}
[사용자 질문]
{user_query}
[선택된 도구 목록]
"""
descriptions = "\n".join([f"- {name}: {config['description']}" for name, config in servers_config.items()])
prompt = ChatPromptTemplate.from_template(prompt_template).format(
tools_description=descriptions,
user_query=query
)
response = await llm.ainvoke([SystemMessage(content=system_prompt), HumanMessage(content=prompt)])
selected = [s.strip() for s in response.content.split(',') if s.strip() and s.strip().lower() != 'none']
return selected
async def process_query(query: str, chat_history: List):
"""
사용자 질의를 받아 서버 선택, 에이전트 생성, 질의 실행의 전체 과정을 처리합니다.
"""
mcp_config = load_mcp_config()["mcpServers"]
llm = get_llm()
# 1. MCP 서버 라우팅
st.write("`1. MCP 서버 라우팅 중...`")
selected_server_names = await select_mcp_servers(query, mcp_config)
# 요청 1: 연결할 MCP 서버가 없을 경우, LLM으로 직접 질의
if not selected_server_names:
st.info("✅ 적합한 도구를 찾지 못했습니다. LLM이 직접 답변합니다.")
messages = chat_history + [HumanMessage(content=query)]
response = await llm.ainvoke(messages)
return response.content
# 2. 선택된 서버 처리
st.write(f"`2. 선택된 서버: {', '.join(selected_server_names)}`")
agents = {}
st.write("`3. 선택된 MCP 서버 세션 및 도구 로딩 중...`")
for name in selected_server_names:
config = mcp_config[name]
tools = []
try:
conn_type = config.get("transport")
if conn_type == "stdio":
server_params = StdioServerParameters(command=config.get("command"), args=config.get("args", []))
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await load_mcp_tools(session)
if not tools:
st.warning(f"✅ '{name}' 서버에 연결했으나, 사용 가능한 도구가 없습니다.")
continue
st.success(f"✅ '{name}' 서버 연결 및 도구 로드 성공: `{[tool.name for tool in tools]}`")
agent = create_react_agent(llm, tools)
agent_input = {"messages": chat_history + [HumanMessage(content=query)]}
if len(selected_server_names) == 1:
response = await agent.ainvoke(agent_input)
return response.get('output', response['messages'][-1].content if 'messages' in response and isinstance(response['messages'][-1], AIMessage) else "응답 내용 파싱 실패")
else:
agents[name] = agent
elif conn_type == "sse":
url = config.get("url")
headers = config.get("headers", {})
async with sse_client(url, headers=headers) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await load_mcp_tools(session)
if not tools:
st.warning(f"✅ '{name}' 서버에 연결했으나, 사용 가능한 도구가 없습니다.")
continue
st.success(f"✅ '{name}' 서버 연결 및 도구 로드 성공: `{[tool.name for tool in tools]}`")
agent = create_react_agent(llm, tools)
agent_input = {"messages": chat_history + [HumanMessage(content=query)]}
if len(selected_server_names) == 1:
response = await agent.ainvoke(agent_input)
return response.get('output', response['messages'][-1].content if 'messages' in response and isinstance(response['messages'][-1], AIMessage) else "응답 내용 파싱 실패")
else:
agents[name] = agent
else:
st.warning(f"⚠️ '{name}' 서버의 연결 타입 ('{conn_type}')을 지원하지 않습니다.")
continue
except Exception as e:
st.error(f"❌ '{name}' MCP 서버 연결 또는 도구 로딩 중 오류 발생: {e}")
continue
if not agents:
return "선택된 모든 서버에 연결하지 못했거나, 에이전트를 생성할 수 있는 서버가 없습니다."
st.write(f"`4. {len(agents)}개 에이전트 생성 완료. 질의 실행...`")
parallel_runnable = RunnableParallel(**{name: agent for name, agent in agents.items()})
try:
all_agent_results = await parallel_runnable.ainvoke(agent_input)
final_responses = {}
for name, result in all_agent_results.items():
if 'output' in result:
final_responses[name] = result['output']
elif 'messages' in result and isinstance(result['messages'][-1], AIMessage):
final_responses[name] = result['messages'][-1].content
else:
final_responses[name] = f"[{name}] 응답 내용을 파싱할 수 없습니다."
history_str = "\n".join([f"{'User' if isinstance(m, HumanMessage) else 'Assistant'}: {m.content}" for m in chat_history])
synthesis_prompt_template = """
당신은 여러 AI 에이전트의 응답을 종합하여 사용자에게 최종 답변을 제공하는 마스터 AI입니다.
아래 대화 기록을 참고하여 사용자의 질문 의도를 파악하고, 각 에이전트의 응답을 바탕으로 하나의 일관되고 자연스러운 문장으로 답변을 재구성해주세요.
[대화 기록]
{chat_history}
[사용자 현재 질문]
{original_query}
[각 에이전트의 응답]
{agent_responses}
[종합된 최종 답변]
"""
synthesis_prompt = ChatPromptTemplate.from_template(synthesis_prompt_template)
synthesis_chain = synthesis_prompt | llm | StrOutputParser()
final_answer = await synthesis_chain.ainvoke({
"chat_history": history_str,
"original_query": query,
"agent_responses": json.dumps(final_responses, ensure_ascii=False, indent=2)
})
return final_answer
except Exception as e:
st.error(f"❌ 병렬 에이전트 실행 중 오류 발생: {e}")
return f"병렬 에이전트 실행 중 오류가 발생했습니다: {e}"
# --- Streamlit UI 구성 ---
st.set_page_config(page_title="MCP Client on Streamlit", layout="wide")
st.title("🤖 MCP(Model Context Protocol) 클라이언트")
# 1. 인증 처리
if "authenticated" not in st.session_state:
st.session_state.authenticated = False
if not st.session_state.authenticated:
password = st.text_input("비밀번호를 입력하세요:", type="password")
if st.button("로그인"):
if password == os.getenv("APP_PASSWORD"):
st.session_state.authenticated = True
st.rerun()
else:
st.error("비밀번호가 일치하지 않습니다.")
st.stop()
# 2. 메인 애플리케이션 (인증 후)
# 사이드바 구성
with st.sidebar:
st.header("메뉴")
if st.button("로그아웃"):
for key in list(st.session_state.keys()):
del st.session_state[key]
st.rerun()
# --- 채팅 기록 관리 함수 (★★★★★ 로직 변경 ★★★★★) ---
def start_new_chat():
"""세션을 초기화하여 새로운 채팅을 시작합니다."""
st.session_state.messages = []
st.session_state.current_chat_file = None
def auto_save_chat():
"""현재 대화를 활성 파일에 자동으로 저장합니다."""
if st.session_state.get("current_chat_file") and st.session_state.get("messages"):
save_path = HISTORY_DIR / st.session_state.current_chat_file
with open(save_path, "w", encoding="utf-8") as f:
json.dump(st.session_state.messages, f, ensure_ascii=False, indent=2)
def load_chat(filename: str):
"""선택한 파일을 읽고 활성 채팅으로 설정합니다."""
load_path = HISTORY_DIR / filename
with open(load_path, "r", encoding="utf-8") as f:
st.session_state.messages = json.load(f)
st.session_state.current_chat_file = filename
def delete_chat(filename: str):
"""채팅 기록 파일을 삭제하고, 활성 세션이었다면 초기화합니다."""
if st.session_state.get("current_chat_file") == filename:
start_new_chat() # 활성 채팅을 삭제하면 화면도 초기화
file_to_delete = HISTORY_DIR / filename
if file_to_delete.exists():
file_to_delete.unlink()
st.toast(f"'{filename}'을 삭제했습니다.")
st.button("새로운 채팅 열기", on_click=start_new_chat, use_container_width=True)
st.divider()
# --- LLM 및 MCP 서버 관리 ---
st.header("LLM 관리")
selected_category = st.selectbox("LLM를 선택하세요:", list(llm_options.keys()))
if selected_category:
st.subheader(f"{selected_category} 모델 선택")
selected_item = st.selectbox(f"{selected_category} 중에서 선택하세요:", llm_options[selected_category])
else:
selected_item = None
st.divider()
st.header("MCP 서버 관리")
# ... (기존 MCP 서버 관리 코드는 변경 없음) ...
mcp_config = load_mcp_config()
with st.expander("서버 목록 보기/관리"):
st.json(mcp_config)
servers = list(mcp_config["mcpServers"].keys())
server_to_delete = st.selectbox("삭제할 서버 선택", [""] + servers)
if st.button("선택된 서버 삭제", type="primary"):
if server_to_delete and server_to_delete in mcp_config["mcpServers"]:
del mcp_config["mcpServers"][server_to_delete]
save_mcp_config(mcp_config)
st.success(f"'{server_to_delete}' 서버가 삭제되었습니다.")
time.sleep(1); st.rerun()
st.markdown("---")
st.write("**새 서버 추가**")
new_server_name = st.text_input("새 서버 이름")
new_server_config_str = st.text_area("새 서버 JSON 설정", height=200, placeholder='{\n "description": "...",\n ...}')
if st.button("새 서버 추가"):
if new_server_name and new_server_config_str:
try:
new_config = json.loads(new_server_config_str)
mcp_config["mcpServers"][new_server_name] = new_config
save_mcp_config(mcp_config)
st.success(f"'{new_server_name}' 서버가 추가되었습니다.")
time.sleep(1); st.rerun()
except json.JSONDecodeError: st.error("잘못된 JSON 형식입니다.")
else: st.warning("서버 이름과 설정을 모두 입력해주세요.")
st.divider()
# --- 저장된 대화 목록 표시 ---
st.header("저장된 대화")
saved_chats = sorted([f for f in os.listdir(HISTORY_DIR) if f.endswith(".json")], reverse=True)
if not saved_chats:
st.write("저장된 대화가 없습니다.")
for filename in saved_chats:
col1, col2 = st.columns([0.85, 0.15])
with col1:
# 현재 활성 채팅은 다르게 표시 (예: 볼드체)
is_active_chat = st.session_state.get("current_chat_file") == filename
button_label = f"**{filename}**" if is_active_chat else filename
if st.button(button_label, key=f"load_{filename}", use_container_width=True):
load_chat(filename)
st.rerun()
with col2:
if st.button("X", key=f"delete_{filename}", use_container_width=True, help=f"{filename} 삭제"):
delete_chat(filename)
st.rerun()
# --- 메인 채팅 인터페이스 ---
# 채팅 기록 및 활성 파일 초기화 (★★★★★ 로직 변경 ★★★★★)
if "messages" not in st.session_state:
st.session_state.messages = []
if "current_chat_file" not in st.session_state:
st.session_state.current_chat_file = None
# 채팅 기록 표시
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])
# 사용자 입력 처리 (★★★★★ 로직 변경 ★★★★★)
if prompt := st.chat_input("질문을 입력하세요. (예: 서울 날씨 알려줘 그리고 거실 불 켜줘)"):
# 1. 새 채팅인 경우, 활성 파일 이름 생성
if not st.session_state.get("current_chat_file"):
st.session_state.current_chat_file = generate_filename_with_timestamp()
# 2. 사용자 메시지 추가 및 표시
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
# 3. 어시스턴트 응답 처리 및 표시
with st.chat_message("assistant"):
with st.spinner("생각 중..."):
history = [
HumanMessage(content=m['content']) if m['role'] == 'user' else AIMessage(content=m['content'])
for m in st.session_state.messages[:-1]
]
response = run_async(process_query(prompt, history))
st.markdown(response)
st.badge("Answer by "+selected_item+"", icon=":material/check:", color="green")
# 4. 어시스턴트 메시지 추가
st.session_state.messages.append({"role": "assistant", "content": response})
# 5. 대화 턴이 끝난 후, 전체 대화를 활성 파일에 자동 저장
auto_save_chat()
# 6. 화면 새로고침 (저장된 파일 목록 업데이트 등)
#st.rerun()
위 코드처럼 mcp.json
파일에 있는 서버들의 description
을 프롬프트에 담아 LLM에게 전달합니다. LLM은 이 설명을 참고하여 사용자의 질문(user_query
)에 가장 적합한 서버의 이름(들)을 반환합니다. 만약 적합한 도구가 없다면 ‘None’을 반환하고, 챗봇은 도구 없이 LLM의 일반 지식으로만 답변합니다.
3. MCP 서버: 도구의 실제 구현 (mcp/youtube_transcript/youtube.py)
MCP 서버는 특정 기능을 수행하는 도구(함수)들을 네트워크를 통해 제공하는 역할을 합니다. youtube.py
는 FastMCP
를 사용하여 간단하게 MCP 서버를 구축하는 예시를 보여줍니다.
이 MCP server는 https://github.com/dabidstudio/python_mcp_agent 에서 2_mcp_server.py 의 코드를 사용했습니다.
from mcp.server.fastmcp import FastMCP
# ... (생략) ...
# Create an MCP server
mcp = FastMCP("youtube_agent_server")
@mcp.tool()
def get_youtube_transcript(url: str) -> str:
""" 유튜브 영상 URL에 대한 자막을 가져옵니다."""
# ... (구현) ...
@mcp.tool()
def search_youtube_videos(query: str) :
"""유튜브에서 특정 키워드로 동영상을 검색하고 세부 정보를 가져옵니다"""
# ... (구현) ...
@mcp.tool()
def get_channel_info(video_url: str) -> dict:
"""YouTube 동영상 URL로부터 채널 정보와 최근 5개의 동영상을 가져옵니다"""
# ... (구현) ...
@mcp.tool()
데코레이터 하나만 붙이면, 평범한 파이썬 함수가 LangChain 에이전트가 사용할 수 있는 ‘도구’로 변신합니다. 함수에 작성된 설명 문자열(docstring)은 에이전트가 이 도구의 용도를 파악하는 데 사용됩니다.
설치 및 실행 방법
프로젝트를 직접 실행해보는 것은 매우 간단합니다. README.md
파일에 친절한 가이드가 제공됩니다.
- 가상환경 설정:
python -m venv env source env/bin/activate
- 필요 라이브러리 설치:
pip install -r requirements.txt
requirements.txt
파일에는streamlit
,langchain
,langchain-mcp-adapters
등 약 80여 개의 라이브러리가 포함되어 있습니다. - 환경 변수 설정:.env.sample 파일을 복사하여 .env 파일을 만들고, 내부에 APP_PASSWORD, OPENAI_API_KEY, ANTHROPIC_API_KEY를 입력합니다.
- MCP 서버 설정:mcp.json.sample 파일을 mcp.json으로 복사하고, 내가 사용하고자 하는 MCP 서버들의 주소와 설명을 채워 넣습니다.
- 애플리케이션 실행:
streamlit run app.py
이제 웹 브라우저가 열리면서 여러분만의 다재다능한 챗봇을 만나보실 수 있습니다.
맺음말
이 프로젝트는 단순히 정보를 검색하고 대답하는 챗봇을 넘어, 상황을 판단하고, 필요한 도구를 동적으로 선택하며, 여러 에이전트의 능력을 조합하여 복잡한 요청을 해결하는 지능형 시스템으로 확장해 나갈수 있다고 생각됩니다.
MCP와 LangChain의 강력한 조합은 챗봇의 능력을 무한히 확장할 수 있는 길을 열어줍니다. 여러분도 이 코드를 기반으로 날씨 정보, 스마트홈 제어, 사내 데이터베이스 검색 등 자신만의 MCP 서버를 구축 / 연결하여 개인용 AI MCP Client를 만들어보시면 좋을 것 같습니다.
답글 남기기