전날 로컬에서 사용자별 인증 게이트웨이 MCP를 세웠다. 봇 하나에 사용자마다 다른 계정을 물리는 구조다. 다음 단계는 공유 개발서버에 올리는 것. 코드는 손댈 일이 없을 줄 알았는데, 두 곳에서 틀렸다. 하나는 링크가 열리지 않는 문제였고 다른 하나는 모델이 링크를 지어내는 문제였다. 둘 다 서버 설정이 아니라 설계의 빈틈이었다.

1. 공유 서버의 첫 번째 규칙 — 추가만 한다

개발서버에는 다른 사람의 작업물이 함께 돈다. nginx 설정 파일 하나를 여러 서비스가 공유하고, 에이전트 컨테이너 하나에 채널별 프로필이 여섯 개 들어 있다. 기능이 겹쳐 보이는 프록시 컨테이너도 하나 떠 있었는데, 우리가 만든 것과 비슷한 일을 하는 듯 보여도 남의 진행 중 작업이었다.

그래서 원칙을 먼저 정했다.

  • nginx: 기존 블록을 수정·삭제하지 않고 우리 파일 하나 + include 한 줄만 추가
  • 적용도 컨테이너 재생성이 아니라 nginx -s reload(무중단)
  • 에이전트: 기존 프로필을 건드리지 않고 새 봇 + 새 프로필 신설

이 원칙 덕분에 배포 중 남의 서비스를 한 번도 멈추지 않았다. 대신 뒤에서 보듯 우리 것만 조심한다고 안전해지지는 않았다 — 같은 도메인을 공유한다는 사실 자체가 문제를 만들었다.

배포 파라미터는 전부 서버에서 읽었다. 방화벽이 꺼져 있다는 것도 그때 알았다.

ports:
  - "${AUTH_MCP_PUBLISH:-8787}:8787"   # 기본값은 0.0.0.0 바인딩

이 기본값을 그대로 뒀으면 인증 없는 MCP 엔드포인트가 인터넷에 열린다. 사용자 토큰을 대신 실어 보내는 엔드포인트다. 127.0.0.1:8787로 묶었다. nginx는 도커 네트워크 안에서 서비스명으로 붙으므로 퍼블리시 자체가 없어도 되지만, 진단용으로 루프백에만 남겼다.

2. /oauth/가 이미 임자가 있었다

로컬에서는 http://localhost:8787을 그대로 썼다. 서버에서는 nginx 뒤에 서야 하는데, 여기서 첫 충돌이 났다. 443의 /oauth/는 Mattermost 자신의 /oauth/authorize가 쓰고 있었다. 하필 우리 게이트웨이의 신원 증명 플로우가 바로 그 엔드포인트로 브라우저를 보낸다.

세 안을 놓고 골랐다.

장점 단점
경로 프리픽스 (/integrations/…) DNS·인증서·방화벽 변경 0 베이스 URL에 경로가 들어감
별도 포트 로컬과 URL 형태 동일 포트 개방 필요
서브도메인 가장 깔끔 DNS + 인증서 발급 선행

코드를 열어 프리픽스가 성립하는지 확인했다. 모든 리다이렉트가 베이스 URL 기반 절대 URL이고 상대 경로 리다이렉트가 0건이었다. nginx가 프리픽스를 벗겨주면 앱은 아무것도 몰라도 된다. 가장 싸고 안전해 보였다.

nginx 쪽은 정적 upstream을 피했다. 이 서버에서 nginx가 이름 해석에 실패하면 기동조차 못 하고, 그러면 Mattermost까지 함께 내려간다. 옆 블록이 쓰던 런타임 해석 방식을 그대로 따랐다.

location /integrations/oauth/ {
    resolver 127.0.0.11 valid=10s ipv6=off;
    set $upstream http://auth-gateway:8787;
    rewrite ^/integrations/(.*)$ /$1 break;   # 변수를 쓰면 프리픽스가 자동으로 안 벗겨진다
    proxy_pass $upstream;
}

두 개의 location(oauth, connect)만 열었으므로 MCP 엔드포인트는 어디에도 걸리지 않는다. 파일 확장자를 .inc로 둔 것도 의도적이다. http 레벨 include 패턴이 conf.d/*.conf라, .conf로 두면 서버 블록 밖에서도 로드되려다 "location directive is not allowed here"로 nginx가 죽는다.

3. 검증 기대값을 두 번 틀리게 잡았다

설정은 한 번에 통과했다. 그런데 검증에서 두 번 오진했다.

첫 번째. "MCP 엔드포인트가 외부에 안 열렸는지"를 상태 코드로 보려 했다.

curl -s -o /dev/null -w '%{http_code}\n' https://mattermost.example.com/integrations/mcp
# 200

200을 보고 "열렸다"고 판단할 뻔했다. 실제로는 Mattermost가 모르는 경로에 SPA HTML을 200으로 돌려준 것이었다. 채팅 앱은 404를 잘 주지 않는다. 판정 기준은 상태 코드가 아니라 누가 응답했는가여야 했다.

curl -s -H 'Accept: application/json, text/event-stream' \
     https://mattermost.example.com/integrations/mcp | head -c 200
# <!doctype html><html lang="en">…  ← 채팅 앱이 응답. 우리 것은 노출 안 됨

두 번째. 리다이렉트를 확인하려고 curl -I를 썼는데 404가 나왔다.

curl -sI https://mattermost.example.com/integrations/connect/google_calendar
# HTTP/2 404, content-type: text/plain

-I는 HEAD 요청이다. 우리 라우트는 GET만 등록돼 있어 HEAD가 매칭되지 않고, 루트에 mount된 MCP 서브앱까지 흘러가 프레임워크 기본 404를 낸다. nginx 설정을 의심하며 시간을 쓸 뻔했다. GET으로 바꾸자 곧바로 302가 나왔다.

두 번 모두 "무엇을 기대값으로 잡을 것인가"에서 틀렸다. 응답 코드는 생각보다 신호가 약하다.

4. 콘솔 세 곳이 각기 다른 이유로 막았다

개발서버는 로컬과 다른 계정을 쓰기로 해서 인증 앱을 전부 새로 만들었다. 여기서 시간이 가장 많이 들었고, 절반은 우리 코드와 무관한 벽이었다.

Google — 게시했더니 오히려 막혔다. 처음엔 테스트 사용자 목록에 없어서 액세스 차단됨이 떴다. 그래서 앱을 프로덕션으로 게시했더니 이번엔 차단된 앱이 떴다. 캘린더 스코프는 민감 스코프로 분류되는데, 게시 상태에서 미검증이면 아예 차단된다. 검증은 홈페이지·개인정보처리방침·도메인 소유 확인·심사를 요구해 수 주가 걸린다. 반대로 테스트 모드에서는 등록된 테스트 사용자가 "확인하지 않은 앱" 경고를 지나 진행할 수 있다. 게시를 취소하는 것이 해결책이었다. 대가는 refresh token이 7일 후 만료된다는 것.

GitHub — 계정이 flag돼 있었다. 새 계정으로 앱을 만들고 설치하려는데 버튼이 Install is prohibited로 비활성이었다. 권한도 설치 범위도 정상이었고 2FA를 켜도 그대로였다. 콘솔은 이유를 말해주지 않는다. 바깥에서 관측하니 두 줄로 끝났다.

GET api.github.com/users/<account>   → 404
github.com/apps/<app-name>           → 404

로그인한 본인에게는 보이는데 외부에 404. 계정이 자동으로 flag된 상태였다. 새 계정이 짧은 시간에 앱 생성과 2FA 설정을 하면 스팸 탐지에 걸리곤 한다. 이의 제기를 넣고, GitHub은 일단 뒤로 미뤘다.

메뉴 이름을 세 번 추측하다 실패한 뒤에 얻은 교훈이기도 하다. UI가 이유를 숨길 때는 API로 물어보는 편이 빠르다.

Atlassian — 등록은 1분, 승인은 미해결. 이쪽은 콘솔 작업이 아예 없다. 동적 클라이언트 등록(DCR) 한 번이면 끝이고 시크릿도 없다. 대신 세 번 막혔다. 계정에 사이트가 없어서, 리다이렉트 URL이 조직 허용목록에 없어서, 그리고 마지막으로 사이트 관리자가 앱을 승인해야 해서. 앞의 둘은 풀었고 셋째는 관리 콘솔에서 해당 항목을 끝내 찾지 못했다. 추측을 계속하는 대신 보류로 남겼다.

DCR 응답 JSON은 서버에 파일로 남겼다. 어떤 리다이렉트 URI로 등록했는지가 그 파일에만 남기 때문이다. 나중에 URI를 추가하려면 재등록해야 하고, 그러면 클라이언트 ID가 바뀌어 기존 사용자가 전원 재연결해야 한다.

5. 링크가 열리지 않았다 — 같은 도메인의 대가

봇 연동까지 끝내고 실채팅을 돌렸다. 봇은 도구를 정확히 호출했고, 연결 링크도 제대로 만들어 채널에 올렸다. 그 링크를 클릭했더니 이 화면이 나왔다.

Team Not Found
The team you're requesting is private or does not exist.

링크 자체는 정확했다. 주소창에 직접 붙여넣으면 정상 동작했다. 차이는 어디서 클릭했는가였다.

Mattermost 웹앱은 자기 도메인 링크를 브라우저에 넘기지 않고 SPA 내부 라우팅으로 처리한다. 그 라우터에게 /integrations는 팀 이름이다. 그래서 팀을 찾다가 실패한다.

2절에서 경로 프리픽스를 고를 때 나는 "우리 서버가 그 요청을 처리할 수 있는가"만 검토했다. 검토하지 않은 질문은 "그 링크를 누가 어디서 클릭하는가"였다. 연결 링크는 정의상 채팅 클라이언트 안에서 클릭된다. 기술적으로는 성립하지만 제품으로는 성립하지 않는 선택이었다.

제대로 된 해결은 서브도메인으로 옮기는 것이다(origin이 다르면 가로채지 않는다). 비용이 작지 않다 — DNS, 인증서, nginx 블록, 베이스 URL, 그리고 인증 앱 세 곳의 콜백 URL 재등록. Atlassian은 재등록이 곧 클라이언트 ID 교체라 기존 사용자가 전원 재연결해야 한다. 처음부터 서브도메인으로 정했다면 없었을 비용이다.

당장은 봇의 안내문에 "링크를 새 탭에서 열어주세요"를 넣어 막아두었다.

6. 모델이 링크를 지어냈다

같은 검증에서 더 나쁜 것이 나왔다. 아직 연결되지 않은 서비스를 물었더니, 봇이 "미연결"이라고 정확히 답한 뒤 링크를 하나 건넸다.

→ GitHub 연결하기

링크 위에 마우스를 올려보니 주소가 이랬다.

https://github.com/login/oauth/authorize?client_id=...

우리 게이트웨이 주소가 아니다. client_id=...가 자리표시자인 것이 결정적이다. 모델이 URL을 상상으로 만들어냈다.

원인은 프롬프트가 아니라 도구 출력에 있었다. 연동 상태를 반환하는 도구가 이렇게만 답하고 있었다.

- GitHub (github): 미연결

링크가 없다. 모델은 "연결하라"는 결론에 도달했는데 손에 링크가 없으니, 학습된 지식으로 그럴듯한 것을 만들었다. 전날 나는 링크에서 1회용 토큰을 빼고 정적 URL로 바꾸는 결정을 했다. 모델이 URL을 재작성하며 잘라먹는 사고가 반복됐기 때문이다. 원칙은 모델이 URL을 만들어낼 여지를 남기지 않는다 였는데, 정작 상태를 알려주는 도구에는 그 원칙을 적용하지 않았다.

고친 것은 한 줄이다.

- GitHub (github): 미연결 — 연결 링크: https://…/connect/github

프로바이더가 설정되지 않았거나 신원 확인 경로가 없으면 죽은 링크 대신 "운영자에게 문의"를 낸다. 테스트를 다섯 개 붙였다.

지침으로 막을 수도 있었다. 실제로 에이전트 성격 파일에 "링크를 지어내지 말라"는 규칙을 넣어두기도 했다. 하지만 지침은 모델이 따를 수도 있고 안 따를 수도 있는 반면, 컨텍스트에 이미 있는 문자열은 지어낼 이유가 없다. 프롬프트로 금지하는 것보다 도구 출력에 정답을 넣는 쪽이 확실하다.

7. 그 밖에 걸린 것들

환경변수를 승계하지 말 것. 관리 콘솔의 환경 파일에서 DB 접속 문자열을 그대로 가져오라고 했는데 틀렸다. 그 값은 호스트 관점(127.0.0.1:5433)이었고, 컨테이너가 서비스명으로 붙는 것은 compose가 덮어쓰기 때문이었다. 검증 단계에서 잡혔다.

sed -i는 소유자를 바꾼다. 파일을 새로 만들어 rename하기 때문에 실행 계정 소유가 된다. 다른 계정이 읽어야 하는 설정 파일에 쓰면 서비스가 조용히 죽는다. >>(append)는 파일을 새로 만들지 않아 소유권이 유지된다. 같은 함정에 두 번 물렸다 — 두 번째는 프로필 게이트웨이를 root로 띄워 상태 파일이 root 소유로 생겼을 때였다. 증상은 똑같이 "떠 있지 않은데 로그 파일도 없음"이다.

번들 스킬이 사용자별 인증을 무력화할 수 있다. 에이전트에 딸려 오는 GitHub 스킬은 gh CLI로 처리하려 든다. 그러면 컨테이너의 자격증명을 쓰게 되어 "사용자마다 자기 계정"이라는 목적 자체가 깨진다. 캘린더도 마찬가지였다. 해당 스킬들을 비활성 디렉터리로 옮겼다. 코드 조회용 스킬은 남겼다.

docker restart로는 환경변수가 안 바뀐다. 환경은 컨테이너 생성 시점에 박힌다. 재생성해야 한다. 그리고 재생성하면 컨테이너 안 파일을 고치는 방식의 패치도 함께 날아간다.

8. 정리

배포 자체는 반나절이면 끝날 일이었다. 실제로 시간을 먹은 것은 세 종류였다.

  • 남의 콘솔 정책 — Google의 게시 역설, GitHub의 계정 flag, Atlassian의 사이트 승인. 코드로 풀 수 없고, 예측하기도 어렵다. 새 계정으로 새 환경을 만들 때는 이 몫을 일정에 넣어야 한다.
  • 검증 기준의 오설정 — 상태 코드로 판정하려다 두 번 오진했다. "누가 응답했는가"가 옳은 질문이었다.
  • 설계의 빈틈 두 개 — 링크가 어디서 클릭되는지, 도구가 모델에게 무엇을 말해주는지. 둘 다 로컬에서는 드러나지 않았다. 로컬은 채팅 앱과 도메인을 공유하지 않았고, 로컬에서는 내가 링크를 직접 알고 있었다.

마지막 하나가 이번의 진짜 수확이다. LLM에게 결론만 주고 근거를 주지 않으면, 모델은 빈칸을 상상으로 채운다. 도구를 설계할 때 "이 응답을 받은 모델이 다음에 무엇을 해야 하고, 그러려면 무엇이 손에 있어야 하는가"를 같이 물어야 한다. 상태 조회 도구에 링크 한 줄이 빠진 것이 사용자에게 가짜 인증 페이지를 안내하는 결과로 이어졌다.

남은 것: 서브도메인 전환, GitHub 계정 복구, Atlassian 다중 사용자 승인. 그리고 서명되지 않은 사용자 식별자를 도커 네트워크 안에서 위장할 수 있다는 문제 — 이건 아직 그대로 남아 있다.

관련 글