도구는 만든 사람을 떠나서 살아간다
MCP 도구를 짜며 다시 본 함수 호출

몇 달 전 포스팅에서 필자는 자산 관리를 일종의 상태 관리 문제로 풀어본 적이 있다. 그때 정리해둔 상태를 좀 더 잘 관리해보고 싶어서 도구를 다시 만들기 시작했는데, 그 과정에서 따라온 인터페이스 설계 관련 고민들을 한 번 풀어보려고 한다.
기존에 필자는 노션 데이터베이스와 Google Apps Script의 조합으로 자산을 관리하고 있었다. 트랜잭션을 노션에 기록하면 GAS가 환율과 시세를 가져와 평가금액을 계산하는 구조였는데, 돌아보면 동작은 했지만 오래 붙들고 가기에는 분명히 무거운 구조였다.
첫째, 입력 과정 자체가 너무 무거웠다. “어제 AAPL 15주를 211달러에 샀다”는 사실 하나를 남기려면 노션을 열고, 데이터베이스를 찾고, 새 행을 만들고, 여러 컬럼을 채우며 아주 난리 부르스를 춰야했다. 이게 한두 번은 할 만한데, 그런 한 번이 쌓이면 결국 귀찮아서 기록을 미루게 된다.
둘째, 운영 비용도 너무 무거웠다. “지난 3월 환율 변동이 내 포트폴리오에 어떤 영향을 줬지?” 같은 질문 하나를 풀려면 새 노션 뷰를 만들거나 GAS에 함수를 더 붙여야 했다. 질문 하나마다 도구를 하나 더 만드는 셈이었다. 심지어 GAS는 웹훅도 제대로 지원하지 않아서 노션에서 스크립트를 트리거하기도 어려웠다.
이 불편을 줄여보려고 이번 주말에 자산 관리 도구를 SQLite 기반의 CLI와 MCP 서버로 옮겼다. 주말 이틀 꼬박 새서 50개 정도의 MCP 함수, 그리고 이를 메뉴얼하게 호출하기 위한 CLI를 만들었고, 결과물에는 Firma라는 이름을 붙였다. CLI 커맨드와 MCP 툴은 처음부터 같이 설계했는데, 그건 필자가 모든 기능이 두 인터페이스에서 비슷한 수준으로 동작해야 한다고 봤기 때문이다.
그런데 흥미로운 점은 두 인터페이스를 같이 만들다보니 꽤 묘한 지점이 보였다는 것이다. 같은 함수를 CLI 커맨드로 노출했을 때와 MCP 툴로 노출했을 때, 분명히 비슷할 줄 알았는데 실제로는 다르게 굴러갔다. 시그니처도 같고, 인자도 같고, 반환값도 같았고 심지어 메시지 포맷도 거의 같았다. 바뀐 건 호출자가 인간인지 LLM인지뿐이었는데 그 차이가 생각보다 컸다.
이번 포스팅에서는 필자가 느꼈던 그 어색함 이야기를 해보려 한다. 같은 함수를 CLI와 MCP 양쪽에 붙여보니 인터페이스 설계에서 필자가 너무 당연하게 받아들였던 전제가 하나 보였다. MCP가 RPC와 다른 이유는 단지 프로토콜이 새로워서가 아니었다. 우리가 함수 호출이라는 행위를 너무 오래 기본값처럼 써왔기 때문이었다.
함수 호출은 생각보다 친밀한 행위다
함수 호출에는 평소 잘 의식하지 않는 가정이 하나 깔려 있다. 그건 바로 호출자와 호출 대상이 꽤 많은 맥락을 공유한다는 가정이다.
예를 들어 우리가 addTransaction(ticker, quantity, price) 같은 함수를 호출할 때를 생각해보면 쉽다. 우리는 함수의 시그니처만 보더라도 이 함수가 무슨 일을 하는지 어느 정도 알고 있다. 작성자가 왜 이런 시그니처를 잡았는지, 어떤 부작용이 있을지, 어떤 예외가 나올지를 아주 완벽하게는 아니어도 대체로 알고 있다고 믿는다. 그런 이해 없이 호출하는 건 함수 호출이라기보다 추측에 가깝다.
지난 몇십 년간의 도구들은 이런 친밀함을 더 강화하는 방향으로 발전해 왔다. 정적 타입 시스템은 인자 형식을 잘못 알았을 때 컴파일 단계에서 막아주고, IDE 자동완성은 잊고 있던 시그니처를 다시 꺼내준다. JSDoc과 docstring은 작성자의 의도를 텍스트로 보충한다. RPC, gRPC, GraphQL이 스키마를 점점 더 엄격하게 다뤄온 것도 같은 흐름 안에 있다. 호출자가 호출 대상을 더 많이 알수록 좋은 호출이 나온다는 가정이다.
필자도 이 전제 위에서 트랜잭션을 등록하는 CLI 커맨드를 만들었다. 사용자가 firma add txn을 입력하면 인터랙티브 프롬프트가 ticker를 묻고, type을 묻고, shares와 price를 차례로 묻는다.
// CLI에서는 이 시그니처만으로도 충분했다
function addTransaction(input: {
ticker: string;
type: "buy" | "sell" | "deposit" | "dividend" | "tax";
shares: number;
price: number;
date: string;
}): Promise<Transaction>여기서 사용자는 이미 자기가 뭘 하려는지 안다. CLI는 그 의도를 잘 입력받아 함수에 넘기기만 하면 된다. 그래서 함수 이름과 인자 타입만으로도 꽤 많은 것이 해결된다. 그동안 필자는 이걸 너무 자연스럽게 받아들여서, 이게 가정이라는 사실조차 잘 못 보고 있었다. MCP 툴로 같은 함수를 노출해보기 전까지는 그랬다.
시그니처가 말하지 않는 것
하지만 같은 함수를 MCP 툴로 노출하는 순간 함수의 호출자는 인간에서 LLM으로 바뀐다. 그리고 그때부터 시그니처가 말하지 않던 것들이 갑자기 문제로 튀어나왔다.
필자: "나 어제 AAPL 15주 샀당"
LLM: 네, add_balance 함수를 호출할게요.
갑자기 왜 급발진해...?
필자가 만든 Firma에는 트랜잭션을 등록하는 add_txn 말고도 월말 자산 스냅샷을 기록하는 add_balance, 월별 소득과 지출을 기록하는 add_flow가 있다.
당연히 시그니처는 다 명확하며 인자 타입도 zod 스키마로 엄격하게 정의해뒀다. 그래도 LLM은 어느 도구를 불러야 하는지 자주 헷갈렸다. 같은 발화로 몇 번 돌려보면 엉뚱한 도구를 부를 때가 꽤 있었다.
처음에는 그냥 LLM 모델이 구려서인가 싶었다. 그런데 한참 보고 있으면 문제가 다른 데 있다는 게 보인다. 시그니처는 함수가 무엇을 받는지는 잘 설명한다. 대신 언제 불려야 하는지는 거의 설명하지 못한다. add_txn이라는 이름은 식별자일 뿐이고, “사용자가 매매를 말했다면 이 도구를 써라” 같은 호출 시점은 담고 있지 않다.
함수의 이름은 사전의 한 줄에 가깝다. 단어 자체로는 어떤 문장 안에 놓일지 결정하지 못하는 것처럼 시그니처 자체로는 어떤 발화 위에서 호출되어야 할지 결정하지 못한다.
CLI에서는 그게 큰 문제가 아니었다. 함수 호출자가 인간이었고, 인간은 이미 “지금 내가 트랜잭션을 기록하려는 중이다”라는 맥락을 갖고 firma add txn을 입력하기 때문이다. 호출 시점을 고르는 책임이 호출자 머릿속에 있었다.
LLM은 다르다. LLM에게 사용자의 발화는 이미 정리된 명령이 아니라, 의도를 추론해야 하는 재료에 가깝다. “샀어”라는 한 단어만 보고 이것이 매수 트랜잭션인지, 재무상태 변화인지, 새로운 현금 흐름인지 결정해야 한다. 그때 LLM이 붙잡을 수 있는 건 도구가 스스로를 어떻게 설명하느냐다.
필자가 add_txn의 설명을 다시 쓴 뒤에야 호출이 꽤 안정됐다. 실제 운영 환경 문구에는 운영 디테일이 더 길게 따라붙지만, 이 글에서는 변화의 골자만 추려서 옮긴다.
server.tool(
"add_txn",
`Records a single trade on a specific ticker: buy, sell, dividend received, or tax paid.
Use this when the user mentions buying or selling stocks, receiving dividends, or paying taxes on a specific security.
Do NOT use this for general cash deposits, salary, or expenses. Use add_flow for those.
Do NOT use this for monthly asset snapshots. Use add_balance for those.`,
schema,
handler
)여기서 중요한 건 함수의 의도가 시그니처에서 함수의 설명 쪽으로 이동했다는 점이다. 시그니처는 여전히 인자의 형식을 강제한다. 그런데 호출의 의미는 설명이 훨씬 더 많이 결정한다. 어디에 의도를 새겨 넣어야 하는지가 바뀐 셈이다.
왜 이런 이동이 생기는 이유는 호출자가 맥락을 모르기 때문이다. 그런데 가만히 생각해 보면, 호출자가 맥락을 모른다는 게 그렇게 이상한 일인가 싶었다.
도구는 원래 만든 사람을 떠나서 살아간다
예를 들어 망치를 한번 떠올려보자. 망치라는 도구를 만든 사람은 분명히 자기 나름의 의도를 가지고 있다. 어느 정도 무게가 적당한지, 손잡이 길이는 얼마나 되어야 하는지, 무게 배분은 어떻게 해줘야 하는지 같은 판단 말이다. 그런데 그 망치를 나중에 쥐는 사람은 그런 배경을 모른다. 손잡이 곡선이 왜 그렇게 생겼는지, 무게 중심이 왜 거기에 있는지 알지 못해도 된다. 그에게 필요한 건 못을 박고 싶다는 의도 하나뿐이다.
그 정도면 충분하다. 사람은 망치를 보는 순간 대충 감을 잡는다. 어디를 잡아야 할지, 어떤 쪽으로 내려쳐야 할지, 이 도구가 어떤 종류의 일에 맞는지를 형태에서 읽어낸다. 도구는 그렇게 만든 사람을 떠나 제 나름대로 살아간다. 사용자는 만든 사람의 머릿속을 몰라도 된다. 대신 도구의 형태를 보고, 이게 자신의 의도와 맞을지 짐작한다. 사실 우리가 도구를 쓰는 대부분의 장면이 그렇다.
망치를 어떻게 쓸지는 망치를 쓰는 주체가 정하는 것이다
이건 제법 흥미로운 지점이다. 망치를 쥔 사람이 못을 박을지 벽을 부술지를 정하는 건 만든 사람이 아니라 쓰는 사람이다. (벽이 아니라 사람을 부술수도 있다…)
대장장이는 자기 머릿속에 명확한 용도를 두고 망치를 만들었겠지만, 그 망치가 실제로 어디에 휘둘려질지는 결국 망치를 쥔 손이 정한다. 도구는 만든 사람을 떠나서 살아갈 뿐 아니라, 만든 사람의 의도까지 넘어서며 살아간다.
도널드 노먼이 말한 어포던스라는 개념도 결국 이 지점을 가리킨다. 어포던스는 원래 심리학자 제임스 깁슨이 만든 개념으로, 사물이 사용자에게 어떤 행위를 가능하게 하는지를 뜻한다. 의자는 앉을 수 있게 하고, 컵은 쥘 수 있게 하고, 손잡이는 돌릴 수 있게 한다. 노먼은 이 개념을 디자인의 언어로 끌어와서 “사물의 형태가 사용 방법을 스스로 시사하는 성질”로 좁혀 썼다.
잘 설계된 문 손잡이는 “여기를 잡고 당기세요”라고 길게 설명하지 않아도 된다. 형태만으로도 어떻게 사용해야할지 표현할 수 있어야하는 것이다. 반대로 어포던스가 어긋난 도구는 사용자를 매번 헷갈리게 만든다.
노먼은 밀어야 열리는 문에 당기는 형태의 손잡이가 달린 사례를 자주 드는데, 이런 어긋난 문이 워낙 흔해서 디자인 업계에서는 아예 노먼 도어(Norman door)라는 별명이 붙었다. 손잡이는 “잡고 당기라”고 말하는데 실제 동작은 “밀어야” 한다. 형태가 거짓말을 하는 도구는 사용자에게 끊임없이 인지 부하를 떠넘긴다.
망치도 마찬가지다. 손잡이의 곡선과 두꺼운 머리는 “여기를 잡고 저쪽으로 휘둘러라”를 거의 무의식 수준에서 전달한다. 좋은 도구는 사용법을 문서보다 먼저 형태로 암시한다.
그 관점에서 보면 MCP 함수의 설명은 그냥 문서가 아니다. LLM에게는 사실상 도구의 형태에 가깝다. 망치 손잡이가 “여기를 잡으라”고 말하듯, 함수의 설명은 “이런 의도일 때 나를 불러라”고 말한다. 설명이 빈약한 도구는 손잡이가 어색한 망치와 비슷하다. 있기는 한데, 막상 손이 잘 가지 않는다.
이걸 체감한 건 필자가 read 계열 도구를 설계할 때였다. 처음에는 단일 책임 원칙을 따라 도구를 잘게 나누고 싶었다. get_holdings, get_prices, get_pnl, calculate_value처럼 역할이 뚜렷한 도구들이다. 함수 관점에서는 꽤 깔끔한 설계다.
그런데 사용자가 “내 포트폴리오 어때?”라고 묻는 상황을 놓고 보면 그림이 좀 달라진다. LLM은 그 질문에 답하려고 네다섯 번의 호출을 조합해야 한다. 호출 하나하나는 맞을지 몰라도 전체 응답은 느려지고, 중간에 어긋날 여지도 커진다. 사용자가 한 번의 의도로 묻는 질문에 너무 많은 작은 도구를 들이밀게 된다.
그래서 필자는 최종적으로 show_portfolio를 조금 무거운 도구로 잡았다. 보유 종목, 평균 매수가, 현재 가격, 평가금액, 누적 손익을 한 번에 반환한다. get_brief는 더 나아가 보유 종목, 일일 손익, 집중도, 급등락 종목, 뉴스, 실적, 거시 지표, 인사이트까지 한 번에 돌려준다. RPC만 놓고 보면 지나치게 뭉친 설계처럼 보일 수 있다.
그런데 도구의 관점에서는 오히려 이쪽이 자연스럽다. SRP는 함수를 만드는 사람에게 중요한 원칙이다. 어포던스는 도구를 쓰는 사람에게 중요한 원칙이다. 호출자가 인간이면 작은 단위를 조립해도 된다. 호출자가 LLM이면 의도에 더 직접 닿는 도구가 낫다. 어느 쪽이 더 좋은지는 절대적인 문제가 아니라, 누가 호출자인가에 따라 달라진다.
신뢰의 방향도 바뀐다
사실 MCP는 형태만 놓고 보면 RPC 계보 안에 있는 프로토콜이다. 메시지는 JSON-RPC 형식을 쓰고, 시그니처와 인자 스키마 같은 어휘도 RPC 시절의 것을 그대로 가져왔다. 그래서 필자도 처음에는 RPC와 MCP가 결국 비슷한 결로 굴러갈 거라고 짐작했다.
그런데 두 프로토콜을 들여다볼수록 결정적으로 갈라지는 지점이 따로 있었다. 시그니처에서 설명으로 무게중심이 옮겨가는 것에 더해, 신뢰의 방향까지 같이 뒤집힌다는 점이다.
RPC에서는 보통 호출자가 호출 대상을 신뢰한다. 예를 들어 시그니처가 Promise<Transaction>을 반환한다고 적혀 있으면, 호출자는 당연히 그 약속을 믿고 코드를 짠다. 호출 대상은 호출자가 어떻게 호출할지까지 굳이 신뢰하지 않아도 된다. 타입 시스템과 스키마가 형식을 강제해주기 때문이다. 잘못 부르면 컴파일이 깨지거나 런타임 에러가 난다.
MCP에서는 이야기가 조금 다르다. 도구를 만든 쪽이 호출자를 어느 정도 신뢰해야 한다. LLM이 함수의 설명을 제대로 읽고, 적절한 시점에, 적절한 인자로 도구를 부를 것이라고 기대해야 한다. 시그니처가 막아주는 건 인자의 형식까지다. 호출 타이밍과 의미는 여전히 추론의 영역에 남아 있다.
이 차이는 쓰기 도구에서 더 뚜렷하게 드러난다. Firma의 add_txn은 SQLite 데이터베이스에 실제로 기록하는 도구다. 이게 잘못 호출되어버리면 자산 기록이 바로 오염되기 때문에, 처음에는 필자도 무서워서 설명에 이렇게 적었다.
"Records a transaction. Always confirm with the user before recording."결과는 썩 좋지 않았다. LLM은 매번 “이렇게 기록할까요?”라고 물었고, 그리고 필자는 매번 “ㅇㅋ”라고 답했다. 자연어 인터페이스의 장점이 거의 사라진 것이다. 책임을 너무 많이 사용자 쪽으로 밀어버리면 도구가 갑자기 굼떠진다. 망치를 들 때마다 “정말 이 못을 박을까요?”라고 묻는 셈이다.
그래서 결국 필자는 설명을 아래와 같이 다시 작성했다.
"Record the transaction immediately when the user clearly states a trade.
Do not ask for confirmation unless the data is ambiguous (missing date, etc).
If you record incorrectly, the user can run delete_txn to undo."“즉시 기록하라”, “모호할 때만 물어라”, “잘못되면 되돌릴 수 있다” 같은 규칙은 도구를 형식적으로 설명하는 수준이 아니라 호출자에게 주는 운영 원칙에 가깝다. 그리고 이 지침이 먹히려면 결국 LLM이 어느 정도 추론할 것이라는 기대가 필요하다.
함수에는 부탁하지 않는다. 타입으로 강제한다. 반대로 추론하는 호출자에게는 어느 정도 부탁할 수밖에 없다. MCP 함수의 설명이 점점 행동 정책처럼 길어지는 이유도 여기에 있다고 생각한다.
어찌 보면 시그니처는 선언이고 설명은 부탁이다. 시그니처는 “이 함수는 이런 인자를 받는다”고 못 박는다. 반면 설명은 “이런 의도일 때 나를 불러주세요”라고 청한다. 한쪽은 강제이고 한쪽은 설득이다. 도구 설계에 설득의 언어가 들어오기 시작한 셈이다.
함수 호출이 오히려 특수한 경우였는지도 모른다
이쯤에서 처음 질문으로 돌아가보면, 호출자가 함수의 맥락을 모른다는 건 사실 그리 이상한 일이 아니다.
인간은 원래 자기가 만들지 않은 도구를 써왔다. 도공이 만든 그릇, 대장장이가 만든 칼, 목수가 만든 의자 모두 그렇다. 심지어 개발자가 만든 프로그램도 그렇다. 사용자가 항상 내가 원하는 대로 프로그램을 사용할 것이라고 기대하는 것은 항상 예측을 빗나간다.
이처럼 도구는 만든 사람을 떠나 다른 손으로 넘어가기 때문에 사용자는 만든 사람의 머릿속을 모른다. 그래서 도구의 형태를 보고 쓰는 것이 더 일반적인 장면인 것이다.
그렇다면 우리가 너무 오래 기본값처럼 여겨온 함수 호출은 오히려 꽤 특수한 관계였던 셈이다. 호출자와 작성자가 많은 맥락을 공유하고, 그 친밀함을 타입 시스템과 IDE와 문서가 계속 보강해주는 관계 말이다. 함수 호출이 원래부터 보편적인 도구 사용 방식이었다기보다, 유난히 친밀한 한 형태였다고 보는 편이 더 자연스러울지 모른다.
이제 호출자가 인간이 아니라면 선택지는 두 가지다. 하나는 LLM에게 더 많은 맥락을 집어넣어서 최대한 친밀한 호출자로 만드는 방법이다. 다른 하나는 도구 쪽에서 거리를 메우는 방법이다. 자기 사용 의도를 함수 설명에 새기고, 호출자가 모든 맥락을 알지 못해도 쓸 수 있게 만드는 방식이다.
MCP는 후자에 더 가깝다. 이건 꽤 자연스러운 선택처럼 느껴진다. 전자의 길은 결국 호출자를 계속 더 많이 아는 존재로 만들어야 한다. 반면 후자의 길은 호출자가 원래 다 알 수는 없다는 사실을 받아들인다. 그리고 그 간극을 도구 설계로 메운다. 돌아보면 인간이 도구를 만들어온 방식도 늘 그쪽에 가까웠다.
이건 어쩌면 회귀에 더 가깝다. 산업 디자인도, 건축도, UX도 이미 오래전부터 같은 자리에 서 있었다. 프로그래밍이 그동안 유난히 친밀한 한쪽 끝에 머물러 있었던 것뿐이다.
마치며
필자가 이번 주말에 Firma를 만들면서 가장 오래 붙잡고 있던 건 코드가 아니었다. 함수의 설명이었다. CLI 커맨드 30여 개와 MCP 도구 50여 개를 짰는데, 그 분량이 무색할 정도로 시간을 잡아먹은 건 결국 이 도구들이 스스로를 어떻게 설명할 것인가였다.
도구는 만들어지는 순간 만든 사람을 떠난다. 그 떠남을 전제로 설계해야 한다. 호출자가 모든 맥락을 알 거라고 기대하기보다, 도구 쪽에서 자신의 의도를 더 많이 드러내야 한다. MCP를 만지면서 흥미로웠던 건 새로운 프로토콜을 배운다는 느낌보다, 오히려 도구가 원래 어떤 식으로 쓰여왔는지를 다시 떠올리게 된다는 점이었다.
함수 호출의 시대가 끝난다고 말하고 싶은 건 아니다. 다만 함수 호출이 늘 보편적인 기본값이었다기보다, 꽤 친밀한 환경에서만 잘 작동하던 한 형태였다는 사실은 이제 더 또렷하게 보인다. 그리고 그 친밀함 바깥에서 인터페이스를 다시 설계하는 일은 생각보다 재미있다.
어쩌면 우리는 인터페이스를 설계하는 방식 자체가 조용히 바뀌고 있는 시기를 지나고 있는 게 아닐까. 시그니처에서 설명으로, 강제에서 설득으로, 친밀함의 가정에서 거리에 대한 인정으로 무게중심이 옮겨가고 있다. 도구를 만든다는 일이 다시 한 번 그 본래의 모습으로 돌아가는 길목에 서 있는 것인지도 모르겠다.