오랜만의 MCP와의 재회는 최악이었다: MCP Resources

Minhan Cho·2026년 3월 2일

MCP 개발기

목록 보기
3/5

LexLink-ko MCP: 카카오 PlayMCP, Smithery.ai, Github

이 글은 Anthropic 서버가 아픈 관계로 일을 하지 못해 배설된 똥글임을 미리 알립니다..

제발 고쳐주세요 anthropic 서버 및 백엔드 개발자님들.. (timestamp KST 22:30)


0. 프롤로그: “민법 제2조 알려줘”가 이렇게 어려운 질문일 줄은 몰랐다

"민법 제2조 알려줘."

사람 기준으로 보면 굉장히 쉬운 질문이다. 학부 민법(+가족관계법, 채권법에도 나왔던 듯) 첫 몇 주는 신의성실의 원칙을 배우기 마련이고, 수많은 판례와 함께 배우게 되는 '그' 조문이다.

그런데 이 질문을 LLM + 법령 API 조합으로 처리하려고 하면, 생각보다 일이 커진다.
왜냐하면 LLM은 “민법”을 바로 읽을 수 없기 때문이다.
API는 늘 이렇게 말한다.

"민법? 좋아요.
법령ID나 MST(법령일련번호)부터 주세요."

즉, LLM이 이 질문에 답하려면 내부적으로는 대략 이런 단계를 밟아야 한다.

  1. "민법"이라는 문자열을 보고
  2. 법령 검색 API를 호출해서
  3. 결과 목록에서
    • 법령ID (예: 001706)
    • 또는 MST (예: 268611)
      을 찾아내고
  4. 그 다음에야 조문 조회 API를 다시 호출할 수 있다

사람 입장에서는 "민법 = 민법"인데,
LLM 입장에서는 두 단계짜리 퍼즐인 셈이다.

LexLink MCP는 실제로 아래와 같이 처리하게 된다.

# 1단계: 민법 검색
eflaw_search(query="민법")
→ 결과:
  - 법령ID: 001706
  - MST: 268611
  - 법령명: 민법
  - 공포일자: ...

# 2단계: 조문 조회
eflaw_service(
  id="001706",
  jo="000100"   # 제1조
)

문제는 이 과정이 생각보다 자주 삐끗한다는 것이었다.

  • 어떤 도구는 id를 요구하고
  • 어떤 도구는 mst를 요구하는데
  • LLM은 가끔 이 둘을 바꿔 넣는다
  • 심하면 "아무 숫자나" 넣는다 😇 (gpt-4.1은 000020으로 ID를 넣더라.. 대체 왜? 하하하! 뒤질라고..)

ultimate_fury

그래서 이 글은,

"LLM이 멍청해서 생긴 문제"

라기보다는,

"법령 API를 쓰려면 반드시 거쳐야 하는 ‘ID/MST 관문’을 LLM에게 어떻게 안정적으로 넘길 것인가"

에서 시작한 이야기다.

그리고 이 문제를 해결하려다 MCP resources를 만들게 되었고,
거기서 또 다른 현실을 만나게 된다.

(이때까지만 해도 "아, resources 쓰면 깔끔하게 해결되겠지"라고 생각했다... 가여운 것...)


1. LLM은 법령을 바로 못 읽는다 (ID와 MST라는 관문)

앞에서 봤듯이,
LLM이 “민법 제1조”에 답하려면 반드시 한 번은 검색 단계를 거쳐야 한다.

문제는 이 검색 결과에서 나오는 값들이 생각보다 까다롭다는 점이다.

  • 법령ID
    • 법령마다 부여된 불변의 ID
    • 예: 001706
    • 일부 API는 이 값을 요구한다
  • MST(법령일련번호)
    • 법령 개정마다 버저닝되는 가변의 ID
    • 예: 268611
    • 다른 API들은 이 값을 요구한다

사람이 보면 “대강 둘 다 민법을 가리키는 식별자구나” 하고 넘어가지만,
LLM 입장에서는 서로 다른 두 개의 숫자 토큰이다.

그리고 실제로 이런 일이 벌어진다.

  • eflaw_serviceid를 받아야 하는데
    → LLM이 mst=268611을 그대로 넣어버림
  • eflaw_josubmst를 받는데
    → 검색 결과에서 본 id="001706"을 넣어버림
  • 심하면 둘 다 틀려서
    → “해당 법령을 찾을 수 없습니다” 에러 🫠

이게 몇 번 반복되다 보니 깨달은 게 하나 있었다.

이건 "LLM이 똑똑하냐 아니냐"의 문제가 아니라
"중간 단계에 사람이 암묵적으로 처리하던 매핑을 LLM에게 떠넘긴 구조 자체의 문제"라는 점이다.

그렇다면,

“자주 쓰는 법령 정도는 애초에 검색 단계를 건너뛰게 할 수 없을까?”

👉 “그럼 법령ID를 캐시로 만들어서, LLM이 바로 참고하게 하면 되지 않을까?”

그리고 그 결과가, MCP resources였다.


2. 그래서 MCP resources를 만들었다 (완벽한 계획!)

ID/MST 문제를 계속 겪다 보니, 이런 생각이 들었다.

“이건 매번 검색해서 추론할 문제가 아니라,
자주 쓰는 값은 그냥 데이터로 주는 게 맞지 않나?

마침 MCP 스펙을 보면, 딱 그 용도로 보이는 개념이 하나 나온다.
바로 Resources다.

MCP 문서에서는 resources를 이렇게 설명한다(요지).

Resources are application-provided data that models can reference.
They are not actions, but contextual information supplied by the server.

이걸 보고 든 생각은 단순했다.

  • 법령ID는 행동(action) 이 아니다
  • 검색해서 추론할 것도 아니다
  • 그냥 참고용 데이터

그럼 이건 tools가 아니라, resources로 주는 게 맞다는 결론이 자연스럽게 나온다.


내가 원했던 그림

내가 기대했던 이상적인 흐름은 이거였다.

  1. MCP 서버가
    • “자주 쓰는 법령명 ↔ 법령ID”를 resources로 제공
  2. LLM이 질의를 처리하다가
    • “민법”이라는 단어를 보면
  3. resources에서
    • 민법 → 001706을 확인
  4. 검색을 건너뛰고
    • 바로 eflaw_service(id="001706") 호출

즉, search step 자체를 없애서 지연시간과 오류를 줄이자는 것!이 목표였다.


그래서 LexLink에 추가한 resources

v1.4.0에서 LexLink에는 실제로 두 개의 resource가 추가됐다.

  • lexlink://laws/frequently-used
    → 자주 쓰는 법령 ~20개의 정적 캐시
  • lexlink://law/{name}
    → 법령명을 주면 캐시에서 법령ID를 찾아주는 템플릿 resource

server.py에 들어간 코드는 대략 이런 형태다.

@server.resource(
    "lexlink://laws/frequently-used",
    name="frequently-used-laws",
    description=(
        "Cached mapping of frequently-used Korean law names "
        "to stable 법령ID codes, intended to skip the search step."
    ),
    mime_type="application/json",
)
def get_frequently_used_laws() -> str:
    entries = _law_cache.get_unique_entries(limit=20)
    return json.dumps(entries, ensure_ascii=False, indent=2)

그리고 개별 lookup용 템플릿 resource도 있다.

@server.resource(
    "lexlink://law/{name}",
    name="law-code-lookup",
    description=(
        "Look up a specific Korean law's stable 법령ID by name "
        "or common abbreviation (e.g., 민법, 형법)."
    ),
    mime_type="application/json",
)
def get_law_by_name(name: str) -> str:
    entry = _law_cache.lookup(name)
    if entry is None:
        return json.dumps(
            {"status": "not_found", "law_name": name},
            ensure_ascii=False,
        )
    return json.dumps(entry, ensure_ascii=False, indent=2)

구현 자체는 꽤 정석적이다 (뿌듯)

  • resource URI는 명확하고
  • description에 "search step을 skip하기 위함"이 분명히 적혀 있고
  • 캐시 miss 케이스도 명시적으로 처리돼 있다

이 시점까지는 정말로 잘 작동할 줄 알았다


3. 그런데 PlayMCP / Smithery에서는 LLM이 resources를 못 본다 (폭싹 무너진 현실)

resources를 추가하고 나서도 PlayMCP나 Smithery 환경에서 LLM의 행동은 전혀 달라지지 않았다.
여전히 이랬다.

  • “민법 제1조 알려줘”
  • eflaw_search("민법")
  • → 검색 결과에서 ID/MST 추출
  • eflaw_service(...)

resources를 전혀 참고하지 않은 것처럼 보였다.

처음엔 당연히 의심했다.

“내가 resource를 잘못 만든 건가?”
“URI가 이상한가?”
“description이 부족한가?”

그래서 하나씩 확인해봤다.

  • resources/list는 정상적으로 뜨는지
  • resources/read는 제대로 JSON을 반환하는지
  • resource description에 의도가 잘 드러나는지

전부 문제 없었다.

그런데 여기서 결정적인 사실을 하나 알게 된다.


MCP에서 resources는 ‘LLM 채널’이 아니다

MCP 스펙을 다시 읽어보면, resources는 이런 성격을 가진다.

  • server가 제공하는 application-level data
  • tool처럼 LLM이 “호출”하는 대상이 아님
  • 클라이언트가 필요하면 읽어서, 컨텍스트로 넣어주는 구조

즉,

server가 resources를 제공한다고 해서
그게 자동으로 LLM 프롬프트에 들어가는 건 아니다.

그리고 PlayMCP / Smithery 같은 프록시 기반 MCP 클라이언트에서는,

  • tools ✔️
  • instructions ✔️
  • resources ❌ (혹은 제한적으로만)

이렇게 전달되는 경우가 있었다.

그래서 실제로는 이런 상황이 된다.

  • 서버 입장:

    "자주 쓰는 법령ID 캐시 다 만들어 놨는데요?"

  • LLM 입장:

    "그게 뭔데요?"

  • 나:

    😇 (혈압 올라 사망)

LLM이 resources를 “안 보는 것처럼” 행동한 이유는, 사실 볼 기회 자체가 없었던 것이다.
이전 포스팅 로그 분석 결과1.1 메소드별 분포 에서 나오듯, resources 호출하는 비율은 전체 메소드 호출의 0.2%에 불과하다...


여기서의 깨달음

이 시점에서 생각이 완전히 바뀌었다.

"resources를 더 잘 만들어야 하나?" ❌
"LLM이 항상 볼 수 있는 채널에 데이터를 둬야 하나?" ⭕️

MCP 환경에서, 프록시를 거쳐도 무조건 LLM에게 전달되는 채널은 하나밖에 없다: System Prompt


4. 그래서 최후의 수단: 시스템 프롬프트에 테이블 박기

그래서 결국, 가장 원시적인 방법을 택했다.

“자주 쓰는 법령ID 테이블을 시스템 프롬프트에 그대로 박자.”


v1.5.1: 우아함을 내려놓은 선택

v1.5.1에서 LexLink는 이렇게 바뀌었다.

  • 자주 쓰는 법령 ~20개의
    • 법령명
    • 법령ID
  • 이 매핑 테이블을
    SERVER_INSTRUCTIONS 안에 하드코딩

server.py 안에는 실제로 이런 섹션이 들어간다.

## Common Law 법령IDs (verified, stable across amendments)
Use these IDs directly with eflaw_service(id=...), law_service(id=...), etc.
to SKIP the search step.

| Law | 법령ID |
|-----|--------|
| 민법 | 001706 |
| 형법 | 001692 |
| 상법 | 001693 |
...

이제 LLM이 “민법 제1조”를 보면,

  • resources를 찾을 필요도 없고
  • search를 먼저 할 필요도 없고
  • 시스템 프롬프트에서 "민법 = 001706"을 바로 확인할 수 있다

그리고 아래처럼 박히겠지.

eflaw_service(
  id="001706",
  jo="000100"
)

이게 왜 효과가 있었냐면

이 방식이 잘 먹힌 이유는 단순하다.

  • resources → “있으면 참고할 수도 있는 데이터”
  • system instructions → “지금 이 작업에서 반드시 지켜야 할 규칙”

그 결과,

  • 검색을 먼저 하려던 패턴이 사라지고
  • 곧바로 eflaw_service(id="001706") 같은 호출로 이어진다.

뭐 이제 LLM 도사님들, 박사님들이 워낙 많아서 system prompt에 대한 설명은 이 정도로 충분할 것 같다.


물론 이 방식은 예쁘지 않다.

  • 데이터와 로직이 섞이고
  • 캐시는 코드 밖에 있지도 않고
  • 스펙적으로도 가장 세련된 해법은 아니다

하지만,

프록시 환경에서도 깨지지 않고 실제로 동작한다는 점에서는 이보다 확실한 방법이 없었다.


5. 최신 모델이면 더 잘 읽겠지? → 그래서 실험해봤다

잘 동작할지 감으로 판단하면 안 되는 문제라, 아예 실험을 해봤다.
이것 말고도 더 많은 세팅에서 다양한 실험을 했는데, arXiv에 preprint 올리면 링크 걸 예정.


실험 설정 (아주 단순하게)

질문은 하나로 고정했다.

"민법 제1조 알려줘"
TMI: 법적 판단을 할 때 법령, elif 관습법, elif 조리 (일반원칙 내지 인간적 이성) 를 봐야 한다는 굉장히 중요한 조문이다

그리고 PASS 조건도 명확히 정했다.

  • eflaw_search("민법") 같은 검색 호출이 먼저 나오면 FAIL
  • 바로
    • eflaw_service(id="001706", ...) 또는
    • eflaw_josub(id="001706", ...)
      를 첫 호출로 쓰면 PASS

즉, “시스템 프롬프트의 법령ID 테이블을 보고 검색 단계를 스킵했는가?” 만 보는 실험이다.

각 모델마다 50회씩 반복했다.


결과 요약 (일부만 뽑아보면)

결과는… 솔직히 말하면 꽤 당황스러웠다.

  • gpt-4.1
    → 100% PASS (50 / 50) ✅
  • gemini-2.5-pro
    → 100% PASS (50 / 50) ✅
  • gpt-4o-mini
    → 84% PASS (42 / 50) 🙂

여기까지만 보면
"캬 역시 최신 모델이 제일이죠?" 싶은데


그리고 무너진 기대

  • gpt-5 / gpt-5-mini / gpt-5-nano
    → 0% PASS (0 / 50) 😇
  • gemini-3-flash-preview / gemini-3-pro-preview
    → 0% PASS (0 / 50) 😇
  • gpt-5.1
    → 94% PASS (47 / 50) ✅
  • gpt-5.2
    → 14% PASS (7 / 50) 🤔

요약하면 이렇다.

“모델 세대가 높아질수록
시스템 프롬프트를 더 잘 따른다”는 가설은
그냥 가설이었다.

(Claude 계열도 모두 테스트했는데, 전체 실험 결과 + ablation study는 arXiv에 preprint로 올릴 계획이라 생략함)


왜 이런 결과가 나왔을까? (추정)

모델 내부를 볼 수는 없지만,
행동 패턴을 보면 대충 이런 요소들이 섞여 보인다.

  • tool-use prior
    • “법령 질문 → 일단 검색부터”라는 강한 습관
  • instruction prioritization
    • 길거나 테이블 형태의 지시는 "참고 사항"으로 밀리는 경우
  • verify-first 성향
    • 이미 값이 있어도 "한 번 더 확인하는 게 안전"하다고 판단
  • 프리뷰/미니 계열의 보수적 라우팅
    • 표준 패턴(search → service)으로 회귀

즉, 이건

“이 모델이 똑똑하냐 아니냐”

의 문제가 아니라,

“어떤 행동 패턴이 기본값으로 깔려 있느냐”

의 문제에 가깝다.


그래서 얻은 결론

이 실험으로 확실해진 건 하나다.

“한 모델에서 잘 된다”는 건 “배포해도 안전하다”는 뜻이 아니다.

  • 어떤 모델은 시스템 프롬프트를 잘 읽고
  • 어떤 모델은 거의 무시하고
  • 심지어 같은 계열의 마이너 버전에서도 행동이 크게 갈린다

그래서 “최신 모델이면 괜찮겠지”라는 기대는 설계 기준으로 쓰기엔 너무 위험했다.


6. 이번 일로 배운 것들 (MCP, LLM, 그리고 캐시)

이번 일을 겪으면서, 처음 예상했던 것과는 전혀 다른 교훈들을 얻게 됐다.

처음에는 이렇게 생각했다.

“법령ID 캐시를 resource로 잘 만들어주면
LLM이 알아서 잘 쓰겠지.”

하지만 실제로 마주한 현실은 꽤나 많이 달랐다.


1️⃣ MCP에서 resources는 ‘저장소’이지 ‘행동 유도 장치’가 아니다

resources는 분명 좋은 개념이다.

  • 데이터와 행동(tool)을 분리할 수 있고
  • 캐시나 참조 정보를 깔끔하게 표현할 수 있다

하지만 중요한 점은 이거다.

resources는 LLM이 직접 “보는” 채널이 아니다.

클라이언트가 읽어서 컨텍스트에 넣어주지 않으면,
LLM 입장에서는 존재하지 않는 데이터나 다름없다.


2️⃣ LLM이 신뢰하는 채널에는 우선순위가 있다

이번 실험을 통해 체감한 LLM의 “신뢰도 순서”는 대략 이랬다.

  1. tool schema / tool output
  2. system instructions (시스템 프롬프트)
  3. resources (있다면... 뭐 안 봐도 무관...)

같은 내용이라도,

  • resource에 있으면 “참고 정보”
  • system instructions에 있으면 “규칙”
  • tool output에 있으면 “사실”

처럼 받아들이는 느낌이었다.


3️⃣ “최신 모델이면 잘 듣겠지”는 위험한 가정이다

가장 의외였던 부분이기도 하다.

  • 어떤 최신 모델은 시스템 프롬프트를 거의 무시했고
  • 어떤 이전 세대 모델은 100% 잘 따랐다
  • 심지어 같은 계열의 마이너 버전에서도 결과가 갈렸다

그래서 이제는 이렇게 생각하게 됐다.

“한 모델에서 잘 된다”는 건
“설계가 안전하다”는 뜻이 아니다.


4️⃣ 캐시는 성능 문제가 아니라 ‘행동 설계’ 문제다

처음엔 이 문제를
“검색을 한 번 줄이자”는 성능 최적화로 봤다.

하지만 지금은 관점이 완전히 바뀌었다.

  • 캐시를 어디에 두느냐는
  • 몇 ms를 줄이느냐의 문제가 아니라
  • LLM이 어떤 행동을 하게 만들 것인가의 문제였다
    (이제 LLM 행동심리학이 생기려나..)

그래서 결국 선택은 이렇게 정리된다.

LLM이 반드시 따라야 하는 데이터라면
resource가 아니라 system instructions나 tool로 만들어야 한다.


마무리하며

이번 경험 덕분에 확실히 알게 됐다.

  • resources는 잘못이 없다
  • MCP 스펙도 잘못이 없다
  • LLM도 딱히 멍청하지 않다

다만,

프록시 환경에서
“LLM이 실제로 보는 것”과 “서버가 제공하는 것” 사이에는
생각보다 큰 간극이 있다.

그래서 오늘도 나는 시스템 프롬프트에 테이블을 박고 있고, 시스템 프롬프트를 깎고 있다..

우아하진 않지만, 적어도 동작은 한다.

최신 버전에서는 system prompt와 함께 lookup table (resource-as-a-tool이라고 해야할까?) 을 실험해봤는데, 꽤나 고무적인 성과가 나왔다. 이것도 역시 arxiv preprint에 얹을 예정!


References

profile
multidisciplinary

0개의 댓글