MCP 서버와 에이전트 호스트, 왜 둘로 나누었는가

MCP 서버와 에이전트 호스트, 왜 둘로 나누었는가

 “하나로 만들면 프로젝트가 하나 줄 텐데”라고 생각했던 순간부터, 실제 문제 해결은 시작되었습니다.

사내 플랫폼에 자연어로 서비스를 조작할 수 있는 AI 에이전트를 붙이기로 하였습니다. 그런데 첫 질문은 “무엇을 만들 것인가”가 아니라 “무엇을 몇 개 만들 것인가”였습니다. 대화 UI를 기존 서비스 화면 안에 두기로 했으니, MCP 서버와 에이전트 호스트를 한 프로젝트에 함께 두면 안 되는지 판단이 필요했습니다.

합치는 쪽이 매력적으로 보였습니다. 배포 단위가 하나 줄고, 프로세스 간 통신도 없어지고, 코드를 오갈 일도 없습니다. 실제로 처음에는 그 방향으로 검토하였습니다.

결론은 분리였습니다. 그리고 이 결정이 단순히 프로젝트를 몇 개 둘 것인가의 문제가 아니었다는 점이 이후에 드러났습니다. 통제를 어디에 둘 것인가, 승인 화면의 정의를 누가 소유할 것인가 같은 질문들이 전부 이 경계 위에서 결정되었기 때문입니다.

이 글에서는 두 프로젝트로 나눈 근거와, 그 경계 위에서 통제 계층을 설계하며 겪은 시행착오를 정리하였습니다. 통제를 두 곳에 두었다가 바깥 계층이 안쪽 계층의 더 나은 응답을 가로챈 일, 그리고 승인 화면의 정의를 그리는 쪽이 들고 있어 조용한 실패가 재생산되던 구조를 다룹니다.

1. 기술 선택 배경 — 왜 하나로 두면 안 되는가

판단이 서지 않았던 이유는 MCP가 무엇을 위한 프로토콜인지가 저에게 불분명했기 때문이었습니다. 그 질문을 정면으로 놓고 보니 답이 나왔습니다. MCP는 프로세스 경계를 넘기 위한 프로토콜입니다. 같은 프로세스 안이라면 MCP는 자기 자신에게 JSON-RPC를 보내는 구조가 됩니다. 즉, 통합하는 순간 MCP를 쓸 이유 자체가 사라집니다.

이 관점에서 보니 나머지 근거도 따라왔습니다.

  • 통제 원칙이 성립하지 않습니다. 호스트가 승인해도 MCP 서버가 정책으로 최종 재검증한다는 이중 통제는, 두 계층이 실제로 분리되어야 의미가 있습니다.

  • MCP 서버는 여러 호스트가 공유합니다. 사내 에이전트뿐 아니라 외부 MCP 클라이언트도 같은 서버에 붙습니다. 호스트에 종속된 서버는 그 순간 재사용이 끝납니다.

  • 지연과 비용 특성이 정반대입니다. LLM 호출은 느리고 비싸며, 도구 조회는 빠르고 쌉니다. 같이 배포하면 서로 발목을 잡습니다.

검토 과정에서 걸러낸 선택지도 있었습니다. 기성 에이전트 SDK를 쓰면 프로젝트 수가 줄지 않겠느냐는 안이었는데, 두 가지 이유로 맞지 않았습니다. 사내 스택이 Java인데 해당 SDK는 Python과 TypeScript만 지원했고, 무엇보다 프로젝트 수를 줄여주지도 않았습니다. 언어가 다르면 합칠 수 없으므로 오히려 분리가 강제됩니다.

이 논의에서 정리된 오해가 하나 더 있었습니다. 에이전트를 직접 만든다는 것이 추론을 직접 만든다는 뜻은 아니라는 점입니다. 판단은 언제나 범용 LLM이 하고, 우리가 만드는 것은 실행 루프와 도구 연결, 그리고 정책과 감사입니다. 그래서 원칙을 한 줄로 정했습니다. 추론은 범용 LLM에 위임하고, 실행 통제는 플랫폼이 직접 소유한다.

2. 적용 과정 및 구현

2.1 두 프로세스와 책임의 분배

구성은 두 서비스로 확정하였습니다. MCP 서버는 도구 목록과 지도, 그리고 정책·승인·감사를 담당합니다. 에이전트 호스트는 대화 처리와 LLM 호출, 실행 루프를 담당합니다. 스택은 양쪽 모두 Java 25 / Spring Boot 4.1입니다.

여기서 중요한 것은 프로세스를 나눈 사실 자체가 아니라, 나눈 뒤에 무엇을 어느 쪽에 둘 것인지였습니다. 그 결정이 흔들리면 프로세스만 둘이고 책임은 뒤섞인 상태가 됩니다. 실제로 그 일이 곧바로 일어났습니다.

2.2 통제의 소유자를 하나로 — 이중 통제의 함정

업로드처럼 실제로 무언가를 바꾸는 도구를 만들면서, 실행 전에 사람이 확인하는 승인 카드를 붙였습니다. 기준은 “아이디를 치게 하지 말고, 되묻지 말고, 전체 과정을 화면보다 쉽게”였습니다.

그런데 첫 구현에서 승인이 필요한 도구를 부르면 카드가 아니라 에러 문자열이 돌아왔습니다. 모델은 그 문자열을 받아 말로 풀어 설명하면서 사용자에게 내부 ID를 되물었습니다. 카드는 뜨지도 않았습니다.

원인은 두 가지였습니다. 하나는 승인 대기를 예외로 던지고 있었다는 점이고, 다른 하나는 에이전트 쪽에도 승인 차단 로직이 있어서 MCP 서버가 제안을 만들 기회조차 없었다는 점이었습니다. 통제가 두 계층에 이중으로 있으니, 바깥 계층이 안쪽 계층의 더 나은 응답을 가로챈 것입니다.

그래서 승인 판단의 소유자를 MCP 서버 한 곳으로 정하고, 에이전트는 통과시키도록 하였습니다. 그리고 승인 대기를 오류가 아니라 구조화된 실행 제안으로 정상 반환하도록 바꾸었습니다.

[승인 대기를 오류가 아니라 제안으로]

// A pending approval is not an error. Returning it as a structure keeps the
// decision with the person, instead of letting the model narrate a failure.
static String of(String approvalId, MethodSignature signature, Object[] args,
                 McpTool mcpTool, NaviToolPolicy policy, ObjectNode form) {

    ObjectNode proposal = OBJECT_MAPPER.createObjectNode();
    proposal.put("proposalType", "approval.required");
    proposal.put("approvalId", approvalId);
    proposal.put("tool", mcpTool.name());
    proposal.put("risk", policy.risk());

    // 'arguments' keeps the original values for re-execution after approval.
    // 'fields' carries only what a person needs to read on the card.
    ObjectNode arguments = proposal.putObject("arguments");
    ArrayNode fields = proposal.putArray("fields");
    for (int i = 0; i < parameters.length && i < args.length; i++) {
        arguments.putPOJO(names[i], args[i]);
        if (isHidden(names[i])) continue;          // internal ids stay out of sight
        fields.addObject().put("label", label(parameters[i], names[i]))
                          .putPOJO("value", args[i]);
    }
    return proposal.toString();
}

arguments와 fields를 나눈 것이 이 설계의 핵심입니다. 실행에 필요한 원본 인자는 arguments에 그대로 보존해 승인 후 재실행에 쓰고, 카드에는 사람이 확인할 값만 보여줍니다. 내부 ID처럼 사용자가 알 이유가 없는 키는 감춥니다.

다만 인자를 감추고 나니 새로운 문제가 생겼습니다. 업로드 도구의 인자가 전부 내부 ID여서, 감추고 나면 빈 카드가 되었습니다. 그래서 도구에 표시 전용 파라미터를 추가하였습니다. 모델이 사용자가 아는 이름을 채우고, 카드에는 그 값만 보입니다.

권한 통제도 같은 원칙으로 두었습니다. 도구 정책에 필요한 롤을 선언하고, 도구 목록을 내려줄 때 호출자의 권한으로 필터링합니다. 그리고 누군가 MCP를 직접 호출해 목록 단계를 우회하더라도, 실행 직전에 같은 검사를 한 번 더 합니다.

[목록에서 한 번, 실행 직전에 한 번]

// The list is filtered by the caller's grants, but the check is repeated here:
// a client that bypasses tools/list must not bypass the policy.
if (StringUtils.hasText(toolPolicy.requiredRole())) {
    grant = grantResolver.resolveGrant(
            toolPolicy.executionLocation(), toolPolicy.requiredRole());
    if (grant == null) {
        String reason = "required role not granted: "
                + toolPolicy.requiredRole() + "@" + toolPolicy.executionLocation();
        audit(context, mcpTool.name(), riskLevel, false, reason);
        return denied(reason);
    }
}

2.3 정의는 그것을 아는 쪽이 소유한다

승인 카드가 동작하기 시작한 뒤, 같은 원칙을 적용하지 못한 곳이 하나 남아 있었습니다. 카드에서 값을 수정할 때 쓰는 편집 필드 목록이 프론트엔드에 하드코딩되어 있었습니다.

이 구조의 문제는 실패가 조용하다는 점입니다. 도구가 필드를 추가하거나 이름을 바꿔도 프론트엔드가 모르면, 그 필드만 편집 불가가 됩니다. 오류가 나지 않으므로 누가 발견해 줄 때까지 그대로 남습니다.

원인은 폼 정의를 그리는 쪽이 소유하고 있었다는 것이었습니다. 실제로 어떤 필드가 있는지 아는 쪽은 도구 소유자인데 계약이 반대로 서 있었습니다. 그래서 MCP 서버가 도구와 타입 조합별로 폼을 만들어 승인 제안에 함께 실어 보내고, 프론트엔드는 모양만 검증하고 그리도록 바꾸었습니다. 어긋나면 읽기 전용으로 떨어집니다.

결과적으로 프론트엔드의 폼 스키마 파일은 190줄에서 31줄이 되었고, 필드가 바뀌면 서버만 고치면 되는 구조가 되었습니다.

3. 시행착오를 통해 얻은 교훈

첫째, 통제를 두 계층에 이중으로 두면 안쪽 계층이 만들 수 있는 더 나은 응답을 바깥 계층이 가로챕니다. 승인 카드가 뜨지 않던 문제가 정확히 그것이었습니다. 통제의 소유자를 한 곳으로 정하고 나머지는 위임하는 결정을 먼저 했어야 했습니다. “두 군데서 막으면 더 안전하지 않나”는 직관은 차단에는 맞지만, 차단 대신 더 나은 것을 제시해야 하는 경우에는 틀립니다.

둘째, 정의는 그리는 쪽이 아니라 아는 쪽이 소유해야 합니다. 프론트엔드가 폼 스키마를 들고 있는 구조를 처음 봤을 때 의심했어야 했습니다. 승인 통제를 서버로 몰았던 결정과 같은 원칙인데, 폼에는 적용을 빠뜨렸습니다. 같은 원칙을 세워두고도 적용 범위를 좁게 잡으면 그 틈에서 같은 종류의 실패가 재생산됩니다.

셋째, 오류로 반환할 것과 정상 응답으로 반환할 것을 구분해야 합니다. 승인 대기는 실패가 아니라 사람의 결정을 기다리는 상태입니다. 그것을 예외로 던지는 순간 구조가 사라지고 문자열만 남아, 모델이 그 문자열을 해석해 말로 풀어 쓰게 됩니다. 사람이 버튼으로 결정해야 할 것을 모델이 문장으로 설명하고 있다면, 대개 응답 형태가 잘못된 것입니다.

넷째, “기능을 켰다”와 “기능이 돈다”는 다릅니다. 이 시기에 native tool calling을 켜 두었는데 실제로는 매번 다른 경로로 돌고 있었습니다. 설정 파일의 맵 키에 콜론이 들어 있어 바인딩이 엉뚱한 키로 되었고, 조회가 빈손이라 조용히 폴백 경로를 탔습니다. 에러가 한 줄도 없었습니다. 폴백이 있는 시스템은 실패가 조용하므로, 켰다고 믿는 경로가 실제로 타는지 로그로 확인하는 습관이 필요합니다.

다섯째, 배포 단위를 나누는 논의에서는 트랜잭션과 데이터베이스 경계를 가장 먼저 봐야 합니다. 나중에 “별도 MCP 프로세스가 기존 서비스를 의존성으로 갖고 직접 호출한다”는 안을 검토한 적이 있는데, 해당 서비스의 조회 계층이 트랜잭션과 영속성에 묶여 있어 사실상 그 서비스를 한 번 더 띄우는 그림이 되었습니다. 그림으로는 그럴듯했지만 코드 한 곳을 열어보는 데 1분이면 무너질 안이었습니다.

4. 적용 결과

분리 결정과 통제 계층 설계의 결과는 다음과 같이 정리할 수 있습니다.

항목

통합했다면

분리한 결과

MCP의 역할

자기 자신에게 JSON-RPC — 프로토콜을 쓸 이유 소멸

프로세스 경계를 넘는 실제 통신

통제

호스트와 서버가 같은 곳 — 재검증이 무의미

호스트가 통과시켜도 서버가 최종 판단

재사용

특정 호스트 전용

여러 MCP 클라이언트가 같은 서버 공유

승인 응답

예외 → 문자열 → 모델이 ID를 되물음

구조화된 제안 → 프론트가 카드로 렌더

폼 정의

프론트가 복제 (190줄, 조용한 실패)

도구 소유자가 제공 (31줄)

실사용 검증에서는 “upload-test를 Global Gallery 허브에 업로드해줘”라는 한 문장으로 listConnectedHubs → listCatalogKollexes → uploadKollexToHub 가 자동으로 연쇄되었습니다. 승인 카드에는 “Kollex: upload-test / 업로드할 허브: Global Gallery”처럼 사용자가 아는 이름만 표시되었고, 내부 ID 누출은 0건이었습니다.

부수적으로 응답 크기 문제도 이 시기에 잡았습니다. 조회 응답에 아이콘 이미지가 통째로 실려 있어 도구 응답이 1.1MB에 달했는데, 서버가 응답에서 해당 필드를 재귀적으로 제거해 약 5,900자로 줄였습니다. 프론트엔드로 가는 원본은 그대로 두고 모델에게 넘기는 관측만 줄인 것입니다.

5. 한계 및 향후 계획

첫째, 이 시점의 승인은 카드까지였습니다. 승인 버튼을 눌렀을 때의 실제 처리, 즉 승인 식별자를 발급하고 검증해 원래 호출을 이어서 실행하는 부분은 구현되지 않은 상태였고, 카드에 “승인 기능은 준비 중”이라고 정직하게 표기해 두었습니다. 이 부분은 이후 MCP 표준의 elicitation을 이용해 서버가 직접 사람에게 묻는 방식으로 완성하였습니다.

둘째, 게이트웨이 기능으로 명시했던 호출량 제한이 미구현이었고, 감사 로그가 애플리케이션 로그로만 남아 있었습니다. 통제의 소유자를 정하는 것과 그 통제를 완비하는 것은 다른 일입니다.

셋째, 여기서 만든 통제 계층은 이후 두 번 더 흔들렸습니다. 권한 검사를 정교하게 만들었다가 상당 부분을 다시 걷어냈고, 승인의 소유자도 결국 더 아래쪽으로 내려갔습니다. 만들어 둔 것을 지우는 판단이 어떤 근거로 이루어졌는지는 별도로 정리할 계획입니다.

이번 결정을 통해 확인한 것은, 프로젝트를 몇 개 둘 것인가라는 질문이 실은 책임을 어디에 둘 것인가라는 질문이었다는 점입니다. 프로세스를 나누는 것 자체는 쉽습니다. 어려운 것은 나눈 뒤에도 각 계층이 자기 책임만 지도록 유지하는 일이었고, 이 글에서 다룬 세 가지 시행착오는 모두 그 경계가 한 번씩 어긋났던 사례였습니다.

참고 문헌

Junny

Site footer