본문으로 건너뛰기

음성 비서에서 텍스트 비서로: MCP 개인 비서 재구축기

2025년 캡스톤으로 만든 음성 비서를, 2026년에 MCP 기반으로 다시 짰다. 음성은 뺐고, 판단과 실행을 나누는 구조는 그대로 가져갔다.

2026년 7월 4일 · 18 min read


프로젝트 레포는 여기서 볼 수 있다.

"카카오톡 켜줘", "뉴스 페이지 열어줘" 같은 말 한마디로 PC를 대신 조작해주는 개인 비서다.

2025년 캡스톤 4인 팀 프로젝트로 처음 만들었고, 2026년에 이 구조를 그대로 다시 짰다.

2025년 캡스톤 프로젝트로 시작

원래 버전은 PC에서 동작하는 시리와 빅스비와 비슷한 음성 비서였다.

마이크로 말하면 OpenAI의 Whisper로 STT를 처리하고, 명령 결과를 전달해서 MCP를 실행하는 "말로 시키면 PC가 알아서 움직인다"는 게 이 프로젝트의 재미였다.

이 프로젝트를 진행했을 당시에 구현적인 부분이나 디자인 및 MCP의 다양성 등 아쉬웠던 부분들이 많았고 이번에 개선을 진행했다.

2026년, 음성을 걷어내다

다시 열어보고 손을 대면서 제일 먼저 뺀 게 음성이었다.

음성을 뺀 이유는 단순히 내가 STT를 결합하고 테스트할 때 음성이...굳이 필요한가?란 생각이 들어서 일단은 음성에 관련된 부분은 제외한 상태이다.

나중에 생각이 바뀌면 다시 넣을 예정.

음성을 뺀 다음에 프로젝트 구조 자체를 다시 짰다.

에이전트 팀을 새로 짰고 디자인도 내가 요새 자주 쓰는 클로드와 비슷한 느낌을 가지게 새로 디자인했다.

그래서 아래와 같이 텍스트를 기반으로 한 깔끔한 디자인을 가지게 되었다.

그리고 다시 작성한 아키텍쳐는 아래와 같다.

클라이언트가 명령 입력창에서 문장을 보내면 Agent 백엔드의 API가 이를 받는다.

planner가 어떤 도구를 어떤 순서로 부를지 계획을 세우면, 파이썬 코드가 그 계획을 순서대로 MCP 서버에 넘겨 실행한다. 실행 결과는 SSE를 타고 다시 클라이언트로 흘러간다.

아래는 성공적으로 작업을 수행한 모습이다 (+_+)

구조: 판단과 실행을 나눈다

이 프로젝트의 뼈대는 "무엇을 할지 정하는 부분"과 "실제로 실행하는 부분"을 분리한 것이다.

  • Agent 백엔드 (FastAPI + AutoGen + Gemini): 사용자 문장을 받아 어떤 도구를 어떤 순서로 호출할지 계획만 세운다.
  • MCP 서버 (Python, stdio): 계획을 받아 실제로 프로그램을 켜고, 브라우저를 열고, 유튜브를 재생하는 실행을 맡는다.

MCP 서버가 제공하는 도구는 이렇다.

  • launch_program — 설치된 프로그램 실행
  • open_url — 브라우저에서 URL 열기
  • play_youtube — 유튜브 검색·재생
  • control_media — 볼륨/미디어 제어
  • close_program — 실행 중인 프로그램 종료
  • open_folder — 주요 폴더 열기

클라이언트(Next.js)가 POST /command로 문장을 보내면, 백엔드는 진행 상황을 Server-Sent Events로 스트리밍해준다.

그래서 "계획 수립 중 → 도구 호출 중 → 완료"가 화면에 그대로 보인다.

판단(Agent 백엔드)과 실행(MCP 서버)을 나눠 둔 덕분에, MCP 서버는 이 프로젝트가 아닌 다른 LLM 클라이언트에서도 그대로 재사용할 수 있다.

2025년에 진행한 부분에선 없던 부분인 MCP를 등록하는 부분을 새로 추가해보았는데 UI를 이용해서 로컬 서버(command + args)나 원격 서버(url)를 등록하기만 하면 된다.

이 부분은 2025년에는 없던 부분인데 이번에 재구축하면서 새로 추가했다.

며칠 뒤, 또 손을 봤다

글을 올리고 며칠 지나서 다시 열어봤는데 손 댈 곳이 몇 군데 더 보였다.

제일 먼저 만진 건 에이전트 구조였다.

원래는 명령 하나에 planner가 계획을 세우고, executor가 그 계획을 하나씩 실행하고, selector가 다음 차례를 매번 LLM에게 물어보는 3단계 구조였다.

그러다 보니 명령 한 번에 Gemini 호출이 3번씩 나갔고, 무료 티어를 쓰는 입장에서는 그게 은근히 아까웠고 그리고 수행시간이 굉장히 오래 걸렸다.

이건 한국인 입장에선 좋지 않다.(ㅡ.,ㅡ)

그래서 기존 구조를 다시 수정했다.

생각해보니 planner→executor 순서는 늘 고정이라 매번 LLM에게 "다음 누구 차례야?"라고 물어볼 필요가 없었다.

그래서 selector부터 없애고, 그다음엔 executor까지 걷어냈다.

지금은 planner가 세운 계획을 파이썬 코드가 그대로 순서대로 실행한다.

명령 하나당 Gemini 호출이 3번에서 1번으로 줄었다.

두 번째로 만진 건 지난 글 끝에 적어뒀던 기억에 관한 문제였고, 이번에 실제로 추가했다.

사이드바에서 대화를 여러 개 만들고 전환할 수 있고, 같은 대화 안에서는 "메모장 열어줘" 다음에 "그거 닫아줘"처럼 이전 명령을 이어받는 게 된다.

대화 기록은 어차피 ~~켜줘, ~~실행해줘와 같은 단발적인 명령이라서 굳이

DB가 필요하진 않을 것 같아서 최대한 가볍게 브라우저 localStorage에 저장하도록 했다.

그래서 브라우저 localStorage에 저장되고, Agent 백엔드는 여전히 아무 상태도 들고 있지 않는다.

맥락의 주인은 항상 클라이언트다.

그 외에 메인 화면에서 바로 MCP 서버를 추가할 수 있는 모달도 붙였다.

MCP 도구가 돌려주는 메시지가 대화 기록에 쌓여 매 턴 재전송되는 구조라, 신뢰할 수 없는 서버가 이상한 문구를 섞어 보내면 그게 다음 계획 수립에 지시처럼 오인될 수 있다는 것도 뒤늦게 깨달아서 planner 프롬프트에 안전장치를 추가했다.

성능이랑 자잘한 버그도 마저 잡았다

정리하다 보니 성능도 눈에 밟혔다.

run_command_pipeline 코드를 보니 요청이 들어올 때마다 AsyncExitStack을 새로 열어서 등록된 MCP 서버 하나하나에 McpWorkbench로 접속하고(내부적으로 각 서버 프로세스를 fork), list_tools()로 도구 목록을 긁어와 라우팅 테이블을 다시 만들고 있었다.

LLM 클라이언트도 요청마다 get_model_client로 새로 만들었다가 끝나면 close()로 버리는 구조였다.

즉 명령 한 번 처리하는 데 필요한 이 초기화 과정을 매번 처음부터 반복하고 있었던 거다.

그래서 이 연결들을 요청 단위가 아니라 앱 단위로 끌어올렸다.

McpPool이라는 클래스를 새로 만들어서 서버 접속·도구 라우팅 결과(_stack, _tools, _router)를 인스턴스에 들고 있게 하고, FastAPI의 lifespan에서 앱이 뜰 때 app.state.pool = McpPool()app.state.llm = get_model_client(...)을 한 번만 만든다.

/command 핸들러는 매 요청마다 pool.acquire()를 호출하는데, 이때 실제 접속은 첫 요청에서만 일어나고 이후에는 캐시된 도구·라우터를 그대로 돌려준다.

여러 요청이 동시에 들어와 접속 시점이 겹치는 걸 막으려고 acquire() 내부는 asyncio.Lock으로 감쌌다.

MCP 서버를 추가·삭제하는 엔드포인트에서는 pool.invalidate()를 호출해 캐시된 접속을 닫고 초기화해서, 다음 요청에서 새 서버 목록으로 다시 연결되게 했다.

앱이 내려갈 때는 lifespanfinally에서 pool.aclose()llm.close()로 정리한다.

덕분에 pipeline.py 쪽은 서버 접속 코드를 다 걷어내고 이미 만들어진 tools·router·client를 인자로 받아 쓰기만 하면 되도록 단순해졌다.

main.pylifespan은 이렇게 생겼다.

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.pool = McpPool()
    app.state.llm = get_model_client(config.PLANNER_MODEL)
    try:
        yield
    finally:
        await app.state.pool.aclose()
        await app.state.llm.close()
 
 
app = FastAPI(lifespan=lifespan)

그리고 McpPool은 이렇게 지연 연결과 캐시를 관리한다.

class McpPool:
    def __init__(self) -> None:
        self._stack: AsyncExitStack | None = None
        self._tools: list[dict] | None = None
        self._router: dict[str, McpWorkbench] | None = None
        self._lock = asyncio.Lock()
 
    async def acquire(self) -> tuple[list[dict], dict[str, McpWorkbench]]:
        async with self._lock:
            if self._stack is None:
                await self._connect()  # 서버 접속 + list_tools()는 최초 1회만
            return self._tools, self._router
 
    async def invalidate(self) -> None:
        async with self._lock:
            if self._stack is not None:
                await self._stack.aclose()
            self._stack = None
            self._tools = None
            self._router = None

버그도 하나 잡았다.

"유튜브 켜줘"처럼 검색어 없이 요청하면 planner가 play_youtube를 빈 검색어로 그대로 호출했고, 그러면 yt-dlp가 이해 못 하는 에러가 사용자에게 그대로 노출됐다.

planner 프롬프트에 검색어가 없으면 open_url로 사이트만 열도록 안내를 추가하고, play_youtube 쪽에도 빈 검색어를 미리 걸러 명확한 한국어 에러 메시지를 돌려주도록 했다.

돌아보며

음성을 빼고 나니 오히려 이 프로젝트가 원래 하려던 일이 더 또렷해졌다.

"말을 알아듣는다"는 화려함 뒤에 가려져 있던 진짜 핵심은 언제나 "의도를 계획으로, 계획을 실행으로 옮기는 파이프라인"이었다.

그리고 얼마 뒤, 데스크톱 앱으로

지난 글 마지막에 "로컬에서 동작하긴 하지만 데스크톱 앱 형태가 아니다"라고 적어뒀었는데, 이번에 그걸 손봤다.

먼저 Agent 백엔드를 PyInstaller로 onedir 실행파일로 패키징했다. 개발할 때야 가상환경 켜고 run.ps1로 띄우면 그만이지만, 쓰는 사람한테 "파이썬 설치하고 venv 만들고 requirements 깔아주세요"라고 할 순 없으니까.

그다음 Tauri로 데스크톱 셸을 스캐폴딩했다. Next.js UI는 정적으로 빌드해서 Tauri 웹뷰 안에 넣고, Rust 런타임이 창을 띄우면서 패키징해둔 agent-backend.exemcp-server.exe를 자식 프로세스로 실행·정리하도록 했다.

패키징하고 나니 개발 환경에선 안 보이던 문제들이 튀어나왔다. mcp_servers.json을 상대 경로로 찾고 있어서 exe로 묶으면 파일을 못 찾았고, .env가 빌드 결과물에 그대로 번들되고 있었다. 둘 다 고쳤고, README도 이 구조를 기준으로 다시 썼다.

보안 구멍도 몇 개 막았다

데스크톱 앱으로 묶으면서 다시 보니, 로컬 API에 인증이 아예 없었다. localhost니까 괜찮겠지 하고 넘어갔던 부분인데, 생각해보면 같은 PC의 다른 프로세스가 얼마든지 이 API를 두드려서 프로그램을 켜고 끌 수 있다는 뜻이었다.

그래서 Bearer 토큰 인증을 추가했다. Tauri 셸이 실행될 때마다 난수 토큰을 만들어 백엔드와 프론트 양쪽에 주입하고, 프론트는 이 토큰을 헤더에 실어 백엔드를 호출한다. 개발 환경에서도 같은 흐름을 맞추려고 run.ps1에도 토큰 생성을 넣었는데, 처음엔 Get-Random으로 대충 만들었다가 이건 암호학적으로 안전한 난수가 아니라는 걸 뒤늦게 깨닫고 CSPRNG로 바꿨다.

MCP 서버 등록 쪽도 손을 봤다. 원격 서버는 URL만 주면 등록되는 구조였는데, 생각해보니 이건 SSRF로 이어질 수 있는 구멍이었다. 그래서 원격 등록은 HTTPS만 허용하고 로컬·사설 대역 주소는 막았다.

로컬 MCP 서버 등록은 더 근본적인 문제였다. command와 args를 그대로 받아서 실행하는 구조라, 사실상 임의 명령 실행 기능이나 다름없었다. 그래서 폼으로 값을 채우던 방식을 JSON 붙여넣기로 바꾸고, 실행 전에 한 번 더 확인하는 게이트를 넣었다. 최소한 뭘 등록하는지 사용자가 한 번은 보게 만든 거다.

그 외에 MCP 서버 상태 확인이 실패했을 때 원시 예외 메시지가 그대로 노출되던 것도 막았고, mcp-server 쪽 도구 실행도 오작동이나 과도한 반복 실행을 막도록 강화했다. 배포 환경에서 기본 MCP 서버 경로를 못 찾던 버그, 설치 업데이트할 때마다 사용자가 추가해둔 MCP 서버 설정이 날아가던 버그도 이번에 같이 잡았다.

큰 기능을 새로 만드는 것보다 이런 구멍을 하나씩 찾아서 막는 게 훨씬 지치는 일이라는 걸 새삼 느꼈다.

실행 방법

개발 환경에서는 기존과 같다.

# .env.example을 참고해 agent-backend/.env, client/.env.local 작성
./run.ps1                       # 백엔드 + MCP 서버 기동, 인증 토큰도 같이 생성
 
npm --prefix client install
npm --prefix client run dev     # http://localhost:3000

데스크톱 앱으로 빌드하려면 이렇게 한다.

./mcp-server/build.ps1
./agent-backend/build.ps1
npm run tauri build              # src-tauri/target/release/bundle/에 설치파일 생성

빌드된 앱은 실행할 때마다 인증 토큰을 알아서 만들어 쓰기 때문에 .env에 토큰 관련 값을 따로 넣을 필요는 없다.

Windows 전용이라 프로그램 실행·미디어 제어에 Windows 네이티브 API를 쓴다.

Gemini API 키가 필요하다.

개선해야할 사항

데스크톱 앱 형태가 아니라던 문제는 이번에 해결했다.

다만 GEMINI_API_KEY는 보안상 설치 파일에 담지 않기로 해서, 설치 후 %APPDATA%\mcp-assistant\.env를 사용자가 직접 만들어 채워줘야 한다.

의도한 선택이긴 한데, 첫 실행 경험만 놓고 보면 아직 매끄럽지 않다. 이 부분은 나중에 손볼 예정.

관련 글