sam2-mcp: 30초 호출 한도에 맞춘 비전 MCP 서버와 이미지형 PDF 실패 추적
GPU 없는 머신에서 SAM 2 세그멘테이션과 비전 OCR을 에이전트 도구로 제공하는 FastMCP 서버입니다. 호출당 30초 한도와 세션당 단일 호출이라는 플랫폼 제약을 실측으로 확인해 병렬 처리를 서버 안으로 옮겼고, 이미지형 PDF에 몰린 심의 실패를 서로 독립된 결함들로 분해해 고쳤습니다.
Key Results
- 0/12 → 4.1~4.9초
- 12프레임 영상 검사에이전트 측 12개 병렬 호출은 30.7초에 전부 실패, 서버 내부 병렬화 후 단일 호출로 처리
- 0자 → 5,545자
- 초장문 PDF 페이지 OCR1916×19,983px 페이지, 고정 2분할 대신 적응형 타일링
- 7 → 0건
- 필수문구 누락 오판같은 이미지형 PDF, 문자열 유사도 대신 3-gram 커버리지(첫 측정 임계 0.35)
보험 광고 심의를 XGEN 워크플로우로 자동화할 수 있는지 확인하는 PoC를 진행하면서, 광고 속 동전·지폐 같은 금전 이미지와 특약명을 정밀하게 가리려면 SAM 2 세그멘테이션이 필요했습니다. 작업 머신에는 GPU가 없었습니다. 그래서 SAM 2와 비전 OCR을 에이전트가 tool calling으로 호출할 수 있는 MCP 서버(sam2-mcp)로 감쌌고, XGEN 캔버스의 에이전트 노드와 로컬 코딩 에이전트가 같은 서버를 쓰게 했습니다.
서버를 처음 만드는 데는 하루가 걸렸지만, XGEN 워크플로우와 실제 심의 데이터에 연결하면서 결함이 차례로 드러났습니다. Windows에서만 나는 교착으로 도구 호출이 무한 대기했고, 영상 심의에서는 에이전트가 병렬로 보낸 호출이 30초 근처에서 전부 실패했고, 실제 심의 문서에서는 실패가 텍스트 레이어 없는 PDF에만 몰려 있었습니다.
이 글은 세 가지 문제의 원인을 좁혀 간 과정과, 플랫폼 제약을 고치는 대신 도구 서버가 제약 안으로 들어가도록 설계를 바꾼 판단을 정리합니다. 도구를 다 고친 뒤에도 판정이 흔들렸던 원인이 도구 밖, 즉 프롬프트와 평가 하네스에 있었다는 점도 함께 다룹니다.
GPU 없는 머신에서 비전 도구 서버를 구성하기
도구는 실행 위치에 따라 둘로 나뉩니다. 세그멘테이션과 영상 추적은 로컬 CPU에서 ultralytics의 SAM 2.1 tiny(가중치 74.5MB)로 처리합니다. 영상 추적은 첫 프레임에 준 포인트나 박스를 나머지 프레임으로 전파하는 방식이고, 합성 48프레임 실측에서 프레임당 약 0.68초(입력 512px)가 걸렸습니다. OCR과 의심 영역 탐지는 Claude Sonnet 계열 비전 API에 맡기고, SAM 2가 그 bbox를 정밀 마스크로 다듬습니다. 여기에 음성 전사, PDF 전 페이지 검사, LLM 없이 동작하는 필수문구 판정과 증빙 수치 대조가 더해져 최종 도구는 11개가 됐습니다.
XGEN과의 연결 구조는 아래와 같습니다.
XGEN 캔버스 (에이전트 노드 + MCP 로더 노드)
→ MCP station 컨테이너 (세션마다 브리지 프로세스 생성, 메모리 512MB 제한)
→ 브리지 (stdio ⇄ streamable-http, httpx만 사용)
→ 호스트의 sam2-mcp HTTP 서버 (torch 로컬 실행)station 컨테이너는 메모리 제한 때문에 torch를 안에서 실행할 수 없어서, 가벼운 브리지만 컨테이너에 두고 무거운 서버는 호스트에 두었습니다. stdio 전송에서는 stdout이 곧 프로토콜 채널이라 모델 로딩 메시지가 stdout에 찍히면 MCP 메시지가 오염됩니다. 그래서 모델 호출은 모두 출력을 stderr로 리다이렉트한 상태에서 실행합니다. FastMCP가 생성 시점에 localhost 기준으로 Host 허용 목록을 고정해 컨테이너에서 오는 요청을 421로 거절하던 문제는 허용 호스트를 명시해 해결했습니다.
초기 설계에서 영상 추적은 호스티드 종량제 API(Replicate)로 이원화했습니다. 나중에 실제 심의 9건의 원본 로그에서 도구 호출 시작 마커를 세어 보니 94회 호출 중 이 도구는 0회였습니다. 등록만 돼 있어도 에이전트가 종량제 도구를 고를 위험만 남는다고 판단해 코드에서 제거했고, 과금 경로는 비전 API와 음성 전사·임베딩 두 곳만 남겼습니다. 처음에 문자열 등장 횟수로 센 "파일당 12~76회"는 도구 목록과 프롬프트까지 함께 센 틀린 값이었다는 것도 이때 확인했습니다.
tools/call만 멈추는 교착
첫 결함은 서버를 처음 띄운 날 나왔습니다. initialize와 tools/list는 정상 응답하는데 tools/call만 무한 대기했고, 공식 SDK 클라이언트와 raw JSON-RPC 양쪽에서 같은 증상이었습니다.
먼저 도구 로직과 MCP 디스패치를 분리했습니다. 상태 도구 함수를 직접 호출하면 1.7초에 끝났으므로 문제는 디스패치 경로에 있었습니다. 다음으로 최소 서버로 재현했습니다. 즉시 반환하는 ping 도구는 응답했고, import torch 한 줄만 있는 도구는 타임아웃이 났습니다. 같은 import를 일반 threading.Thread에서 실행하면 1.9초에 정상 완료됐습니다. 정리하면 FastMCP가 도구를 실행하는 anyio 워커 스레드에서 torch를 처음 import할 때만 교착이 발생했고, 이 현상은 Windows에서 관찰됐습니다.
해결은 무거운 모듈을 서버 기동 전에 메인 스레드에서 미리 로딩하고, 도구 함수 안의 지연 import를 전부 제거하는 것이었습니다.
# 무거운 라이브러리는 mcp.run() 전에 메인 스레드에서 로딩한다.
# FastMCP 도구 호출(anyio 워커 스레드) 안에서 torch를 처음 import하면 Windows에서 교착된다.
with contextlib.redirect_stdout(sys.stderr):
import cv2
import numpy as np
import torch
from ultralytics import SAMSDK 클라이언트는 Windows 종료 경로에서도 멈춰서, 스모크 테스트는 initialize, tools/list, 상태 도구 호출을 순서대로 보내는 raw JSON-RPC 클라이언트로 바꿨습니다.
30초 벽: 병렬 호출이 같은 시각에 전부 실패한 이유
영상 심의를 XGEN 안에서 실행하자 에이전트는 프레임마다 비전 검사 도구를 12개 동시에 호출했고, 전부 실패했습니다. 병렬 수를 바꿔 측정하니 6개 병렬은 4개 성공에 총 30.5초, 12개 병렬은 0개 성공에 총 30.7초였습니다.
비전 API의 처리량 문제였다면 실패 수가 병렬 수에 따라 늘거나 끝나는 시각이 흩어져야 합니다. 두 경우 모두 30초 근처에서 끊겼다는 점이 타임아웃을 가리켰습니다. 플랫폼 코드를 확인하니 원인은 두 가지였습니다. 첫째, MCP 로더의 HTTP 클라이언트가 timeout=30.0으로 생성돼 도구 호출 1회에 30초 하드 한도가 걸려 있었습니다. 둘째, station의 프로세스 관리자가 공용 stdout 큐에서 JSON-RPC id를 대조하지 않고 첫 줄을 응답으로 반환했습니다. 세션 하나에 두 호출이 동시에 들어가면 응답이 뒤바뀔 수 있으므로, 세션당 동시 호출은 1개만 안전했습니다.
30초를 넘기면 그 호출만 실패하는 것도 아니었습니다. 늦게 도착한 응답이 큐에 남아 다음 호출의 응답 자리를 차지하고, 이어서 "Session not found"가 연쇄로 나며 세션 전체가 오염됐습니다. 이 서버 문서의 철칙 첫 줄이 "초과는 실패가 아니라 세션 오염"인 이유입니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 플랫폼의 타임아웃과 id 대조 수정 | 근본 원인 제거 | XGEN 제품 상수라 이 프로젝트 범위 밖, 다른 MCP 서버에도 영향 | 채택하지 않음 |
| 브리지를 스레드로 병렬화 | 에이전트 쪽 변경 없음 | id 대조가 없어 응답이 뒤바뀔 위험이 오히려 커짐 | 기각 |
| 팬아웃을 도구 서버 안으로 이동 | 호출 1회로 두 제약을 동시에 만족 | 도구마다 내부 병렬화와 시간 예산 관리 필요 | 채택 |
세 번째 안에 따라 영상 검사 도구(inspect_video)를 새로 만들었습니다. 프레임 추출, 비전 병렬 검사, 라벨별 집계(등장 프레임, 처음·마지막 시각, 추정 노출 시간, 최대 높이, 최소 글자 높이 충족 여부)를 한 번의 호출 안에서 하고, 집계는 LLM이 아니라 코드가 합니다. 12프레임 처리에 4.1~4.9초가 걸렸습니다. 영상 추적은 최대 추적 프레임 수(기본 30)로 균일 서브샘플링하고 결과를 원본 타임스탬프로 매핑했습니다. 6초·24fps 영상 144프레임은 stride 6으로 24프레임만 추적해 16.3초에 끝났습니다. 브리지는 순차 처리로 되돌리고, 에이전트 프롬프트에도 "도구는 한 번에 하나씩"을 명시했습니다.
첫 실측에서는 비전 모델이 프레임마다 라벨 이름을 조금씩 다르게 써서 코드 집계가 실패했습니다. 라벨 표기를 고정하라는 지시를 프롬프트에 넣어 해결했습니다. 합성 정답과 비교하면 보험료 노출 구간 0.0~1.5초(정답 0~2.0초), 동전 0.0~5.75초 전 구간 추적, 유의문구 높이 12px(법정 기준 12px)와 노출 0.5초(정답 0.5초)가 모두 일치했습니다. 이후 장편 영상 도구에는 내부 마감 시간을 두어, 마감에 닿으면 처리한 범위까지만 그대로 보고하게 했습니다.
PDF도 같은 문제를 겪었습니다. 페이지마다 도구를 따로 부르자 9페이지 중 3~4장이 30초 한도로 실패했고, 실패한 페이지를 "필수문구 누락"으로 오독해 승인 광고를 반려하는 사고가 났습니다. 그래서 전 페이지를 한 호출 안에서 병렬 검사하고 실패 페이지를 명시적으로 반환하는 도구(inspect_pdf)로 바꿨습니다.
실패가 이미지형 PDF에 전부 몰려 있었다
실제 심의 제출물로 검증하면서 아직 실행하지 않았던 두 건을 처리했더니 둘 다 실패했습니다. 원인을 찾으려고 PDF의 텍스트 레이어 길이를 재 보니 규칙이 보였습니다.
| 케이스 | 정답 | 텍스트 레이어 | 결과 |
|---|---|---|---|
| A | 반려 | 0자 | 지적 1/2, 과잉 지적 3건 |
| B | 승인 | 0자 | 오탐 |
| C | 승인 | 0자 | 오탐(조건부 승인) |
| D | 반려 | 2,245자 | 정확 |
| E | 승인 | 2,084자 | 정확 |
실패 3건은 모두 이미지형, 성공 2건은 모두 텍스트형이었습니다. 하나의 원인처럼 보였지만 파고드니 독립된 결함이 여러 개 겹쳐 있었습니다.
첫째는 타일 분할이었습니다. 케이스 B의 첫 페이지는 렌더링하면 1916×19,983px였는데, PDF 도구는 "높이 2600px 초과면 2분할"로 고정돼 있어 타일 하나가 약 1만 px이 됐고 비전 API가 크기 초과로 400을 반환했습니다. 그 페이지의 OCR은 0자였고 에이전트는 인상으로 심의했습니다. 단일 이미지 도구에는 적응형 분할이 있었는데 PDF 도구만 하드코딩으로 남아 있었습니다. 가독성 목표(약 1200px 띠)와 API 한도(7500px)를 함께 만족하는 함수로 통일하자 OCR은 0자에서 5,545자가 됐습니다.
def _tiles_for_height(h: int) -> int:
floor = max(1, -(-h // 7500)) # API 한도: 이보다 적게 자르면 결과가 0
want = max(1, -(-h // 1200)) # 가독성: 본문의 1/40 크기 유의문구가 읽히는 띠
return max(floor, min(12, want)) # 12개 상한은 비용 선호일 뿐, 한도를 이기면 안 된다마지막 줄도 처음에는 min(12, max(floor, want))였습니다. 상한 12가 하드 제약을 덮어써서 높이 120,000px 이미지에서는 타일이 다시 1만 px이 되고 전부 유실됐습니다. 상한은 비용에 대한 선호이고 한도는 넘으면 결과가 0이 되는 제약이므로 순서를 바꿨습니다.
둘째는 가로 방향이었습니다. 위 수정 뒤에도 케이스 A는 "필수문구 7개 전부 누락"이었는데, 300dpi로 다시 렌더링해 육안으로 확인하니 문구가 그대로 있었습니다. 페이지 폭이 2250px이고 비전 API는 긴 변을 1568px로 줄이므로(0.70배), 본문의 1/40 크기인 유의문구가 판독할 수 없을 만큼 뭉개졌습니다. 세로만 보던 분할을 행×열 그리드로 확장하고 bbox는 x·y 양쪽 오프셋을 보정했습니다. 케이스 A의 랜딩 페이지는 3행×2열(타일 1125×938, 축소 없음)로 나뉘었고, 문구 커버리지는 0.00에서 0.92, 0.93, 0.77로 올랐습니다. 7페이지 검사는 12.5초로 예산 안이었습니다.
셋째는 판별자였습니다. 필수문구 판정 도구는 텍스트 레이어만 읽었기 때문에 이미지형 문서에서는 입력이 아예 없었고, 에이전트가 그 공백을 OCR 인상으로 메우며 실재하는 문구 7개를 모두 누락으로 지적했습니다. OCR 결과를 입력으로 받도록 바꾸자 새 문제가 보였습니다. 한글 OCR은 시각적으로 닮은 음절로 치환합니다("광고심의"가 "공고실의"로, "약관을 참조"가 "영업 공은 침조"로). 그래서 문자열 유사도는 문구가 있을 때 0.56~0.79, 없을 때 0.12~0.19로 분포가 가까워 임계값을 잡을 수 없었습니다. 문구를 3-gram 집합으로 보고 페이지에 남은 비율을 세는 커버리지로 바꾸자 있을 때 0.49~0.71, 없을 때 0.00~0.03으로 16배 분리됐습니다. 텍스트 레이어 경로(유사도 임계 0.82)는 손대지 않았고, 텍스트형 두 건의 결과가 그대로인 것으로 무회귀를 확인했습니다. 케이스 B의 누락 판정은 7개에서 0개가 됐습니다.
처음 잡은 커버리지 임계 0.35는 단발 측정에서 나온 값이었습니다. 반복 4회 측정해 보니 가장 긴 문구(46자)의 커버리지가 0.49, 0.61, 0.46, 0.32로 흔들려 네 번 중 한 번 판정이 뒤집혔습니다. 긴 문구일수록 OCR 오류가 깎아 내는 3-gram이 많기 때문입니다. 없는 문구는 0.00~0.03이므로 0.20으로 낮춰도 7배 분리가 남았고, 낮춘 뒤 대조군 7개는 모두 미검출(오탐 0), 5케이스×2회 반복은 완전히 동일했습니다.
넷째는 가장 파괴적이었던 작업 디렉터리 충돌입니다. 출력 디렉터리 이름이 초 단위 시각이라, PDF 페이지를 병렬로 처리하면 같은 초에 시작한 호출들이 같은 디렉터리에 같은 이름의 타일을 쓰며 서로 덮어썼습니다. 인코딩 중에 덮이면 400 오류가 났고, 그렇지 않으면 다른 페이지의 타일을 그 페이지 것으로 OCR했습니다. 디렉터리 이름에 원자 카운터와 PID를 넣어 해결했습니다. 같은 문서를 3회 실행한 OCR 총량이 1,237/1,460/2,106자(편차 70%)에서 2,065/2,080/2,096자(편차 1.5%)로, 타일 손실 페이지 수가 3/1/7에서 0/0/0으로 바뀌었습니다.
그 밖에 밀집 타일의 OCR이 출력 토큰 상한 1200을 넘어 JSON이 잘리고 타일 전체가 폐기되던 문제는 상한을 8000/12000으로 올리고 잘린 JSON에서도 OCR 텍스트를 회수하는 것으로, OCR 텍스트가 병합본과 페이지별로 이중 전송되던 문제는 페이지에 좌표만 남기는 것으로(22,730자에서 12,018자, 47% 감소) 고쳤습니다. 증빙 파일이 하나도 없을 때 모든 수치를 "불일치"로 보던 증빙 대조 도구는 "대조 불가"를 포함한 3상태로 나눴습니다.
한 축을 고치면 반대 축이 무너진다
출력 토큰 상한을 올린 수정은 곧바로 지연 회귀를 만들었습니다. 정확도 모델이 밀집 타일에서 텍스트를 끝까지 쓰면서 타일당 생성 시간이 몇 배로 늘었고, 에이전트는 고속 옵션을 지정하지 않고 도구를 호출하므로 타일 순회가 30초를 넘겨 8케이스 배치가 전멸했습니다. 로그의 시간선은 호출, 30.3초 타임아웃, 늦은 응답으로 인한 큐 어긋남, "Session not found" 연쇄 순이었습니다. 총 타일 수가 2개를 넘으면 빠른 모델을, 작은 단일 패스에는 정확도 모델을 쓰는 자동 모드를 기본값으로 두었고, 에이전트식 호출로 6타일 13.4초, 2타일 21.4초에 타일 손실 0을 확인했습니다.
동시성도 비슷한 경로를 거쳤습니다. 디렉터리 충돌을 찾기 전에는 비전 요청 수가 문제라고 보고 프로세스 전역 동시 상한을 8로 낮췄는데, 이 조치가 한 케이스를 30초 한도로 밀어 넣었습니다(호출부터 오류까지 정확히 30.0초). 충돌과 잘림을 고친 뒤 다시 측정하니 16이 안정적이고 24는 이득이 없어 16으로 환원했습니다. 동시성은 원인이 아니었고, 틀린 가설에 맞춘 조치가 다른 제약을 건드린 경우였습니다.
도구를 다 고친 뒤에도 워크플로우 판정문은 실행마다 달랐습니다. 원인은 도구 밖에 있었습니다. 에이전트 프롬프트가 "텍스트 레이어가 없으면 문구 판정을 하지 말고 직접 보라"는 수정 이전 규칙을 그대로 담고 있어서, 도구가 OCR 기반으로 누락 없음을 반환해도 에이전트가 그 결과를 버렸습니다. 판정 기준을 텍스트 레이어 유무가 아니라 판정 출처(텍스트 레이어, OCR, 판정 불가)로 다시 썼습니다. 도구 반환값의 의미를 바꾸면 그 값을 읽는 프롬프트도 같은 변경에서 바꿔야 합니다. 그러지 않으면 도구 수정이 무효가 되는데, 겉으로는 "모델이 이상하다"로 보입니다.
평가 하네스에도 구멍이 있었습니다. 모든 실행이 같은 대화 식별자를 공유해 각 심의가 직전 심의의 대화를 이어받았고, 한 케이스를 요청했는데 다른 케이스에 대한 완결된 판정문이 돌아온 기록이 있었습니다. 오류가 아니라 그럴듯한 오심이라 눈으로는 잡히지 않았습니다. 같은 워크플로우를 다른 러너가 동시에 실행하고 있어 SSE 스트림 하나에 실행 4개의 이벤트가 섞이기도 했습니다. 세션이 죽은 채 실행돼 0~42바이트짜리 판정문이 나왔는데, 채점기가 이를 "지적 0건"으로 읽어 승인·오탐 0으로 잘못 보고한 일도 있었습니다. 실행마다 고유 식별자를 주고, 워크플로우가 실제로 받은 입력과 요청 케이스를 대조하고, 300자 미만 결과는 저장을 거부하고, 실행 전에 세션의 도구 수를 확인하도록 고쳤습니다. 실제로 사고를 낸 조건 네 가지(전 구간 타일 높이 8000px 미만, 동시 호출 시 디렉터리 고유성, 입력이 없으면 판정 거부, 증빙 0개면 불일치 0건)는 회귀 테스트의 불변식으로 고정했습니다.
결과와 남은 과제
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 12프레임 영상 검사 | 0/12 성공, 30.7초 | 4.1~4.9초 | 에이전트 측 12개 병렬 호출 대비 서버 내부 병렬 단일 호출 |
| 초장문 페이지 OCR | 0자 | 5,545자 | 1916×19,983px 이미지형 PDF 페이지 |
| 필수문구 누락 오판 | 7건 | 0건 | 같은 이미지형 PDF, 유사도 대신 3-gram 커버리지 |
| OCR 총량 편차 | 70% | 1.5% | 같은 문서 3회 실행, 디렉터리 충돌 수정 전후 |
| 판정 일치 | 4/9 | 10/10 | 실제 심의 케이스, 도구 수정과 프롬프트 조정(v5·v6)을 합친 결과 |
| 승인 광고 무오탐 | 1/5 | 4/4 | 같은 조건 |
판정 일치는 도구 수정만의 효과가 아닙니다. 도구를 고친 뒤에도 판정 라벨이 조건부 승인으로 쏠려, 지적 수로 라벨을 기계적으로 정하는 규칙(v5)과 실무 재량 규칙(v6)을 프롬프트에 더한 결과까지 포함한 수치입니다. 반려 지적 재현율은 61%에서 69%가 됐습니다. 남은 미검출 5건은 절차 검출이나 생성 이미지 판별 같은 기능 부재 또는 재량의 회색지대였고, 이미지를 잘못 읽어 틀린 건은 0건이었습니다. 10케이스에 프롬프트를 더 맞추면 과적합이라고 보고 그 지점에서 수렴으로 판단했습니다.
30초 한도와 세션당 단일 호출은 플랫폼 제약이므로 앞으로도 도구가 그 안에 들어와야 합니다. 비전 bbox로 잰 문구 높이는 근사치라 경계값(±3px)은 사람이 확인해야 하고, 노출 시간 해상도는 프레임 간격(기본 0.5초)에 묶여 있습니다. 시안 잘림이나 첨부 누락 같은 절차 항목은 LLM보다 파일 검사 규칙으로 다루는 편이 맞다고 보고 있습니다.
이 작업에서 남은 교훈은 두 가지입니다. 하드 제약이 있는 시스템에서는 정확도를 올리는 수정이 지연 한도를 넘게 만들 수 있으므로, 한 축을 고치면 반대 축을 반드시 다시 측정해야 합니다. 그리고 수치를 믿기 전에 측정 장치(프롬프트, 러너, 채점기)가 측정하려는 것을 실제로 측정하고 있는지 먼저 확인해야 합니다.