개요
택배 앱에 ‘배송 완료’가 떴는데 문 앞에 상자가 없다고 해보자. 기사는 분명히 배달했다고 하고, 나는 받은 게 없다.
내 일러스트 RAG가 딱 그랬다. Notion에 저장된 내 피드백 그림을 AI는 받아서 ‘확인했다’고 하는데, 내 채팅 화면엔 그림이 없었다.
이미지는 AI만 읽고 채팅에는 보이지 않는 상태야.
어느 구간에서 상자가 사라지는지 찾다가, 결국 상자를 뜯어 낱개로 부치는 짓까지 했다.
그림을 그려주는 스킬
지난 글의 구조는 NeonDB(지식 저장) + 로컬 Python 하네스(검색·검증) + Private Tunnel(ChatGPT와 연결)이었다. MCP 서버와 스킬을 설정하고 ChatGPT에서 인체 투시를 물어봤는데, 답이 이상했다.
AI가 피드백용 그림을 직접 그려서 설명하고, 내가 저장해 둔 관련 그림은 보여주지 않고 참조 ID만 알려줬다. 좋아하는 그림과 거기서 배운 걸 이미지 근거와 함께 다시 꺼내 보려고 만든 시스템인데, 정작 그 이미지가 안 나온다.
스킬 지침에 “요청 없이 그림 생성 금지”, “관련 원본 직접 표시”가 빠져 있었던 게 1차 원인이었다. 그런데 지침을 고쳐도 남는 문제가 있었다. ChatGPT가 저장된 그림을 직접 보고 보여줄 방법 자체가 이 구조에서는 불안했다.
Notion 하나로 줄이기
이참에 구조를 다시 봤다. 이걸 쓸 곳은 ChatGPT 채팅 하나뿐이다. 그런데 원문은 Notion에 있고, 같은 내용을 Neon에 복제해 두고, 로컬에는 데이터 미러와 임베딩 캐시, 작업 폴더가 있었다. 지난 글에서 3초대 검색을 위해 만든 장치들이다.
어차피 이거 ChatGPT에서 스킬로만 쓸 건데 그딴 게 필요하겠냐고
ChatGPT 안에서만 돌면 로컬 미러도 캐시도 쓰일 곳이 없다. LLM이 Notion 검색 결과를 보고 유사도를 판단할 수 있으면 별도 벡터 DB를 둘 이유도 줄어든다. 새로 저장하는 지식을 내가 Notion에서 바로 볼 수 있다는 것도 컸다.
| 결정 | 이유 |
|---|---|
| Notion을 단일 저장소로, Neon은 백업 후 종료 | ChatGPT에서만 쓰는데 저장소 두 개를 유지할 이유가 없다 |
| Neon에만 있던 그림-피드백 라벨 271건, 시각 설명 21건은 옮기지 않음 | 모델이 그림을 직접 보면 미리 써 둔 시각 설명이 필요 없다 |
| 근거 페이지는 지식 카드 끝에 링크로 | Notion 안에서 바로 따라갈 수 있다 |
지식 카드는 출처를 셋으로 나눴다. 강사 피드백, 외부 작품 분석, 그리고 내가 직접 관찰한 내 관찰. 답변 근거로는 채택 카드만 쓰고, AI가 만든 해석은 항상 draft로 저장한다. 채택은 내가 명시적으로 요청할 때만 하고, 결정 메모에 내 말과 날짜를 남긴다.
옮기면서 밟은 것들
| 문제 | 알게 된 것 |
|---|---|
| 피드백 그림 대부분이 안 읽힘 | 수업 피드백 표의 그림은 본문 블록이 아니라 피드백 전/후 파일 속성에 있었다 |
| 같은 도구로 읽어도 주소 형태가 다름 | 파일 속성 그림은 S3 서명 URL, 본문에 직접 올린 이미지 블록은 notion-file-block:// 내부 참조만 나왔다 |
| 근거 링크가 전부 끊김 | 워크스페이스 사이에서 페이지를 옮기면 페이지 ID가 바뀐다. 같은 수강생 이름이 여러 표에 있어서 제목만으로는 못 찾고, 경로 + 이름 + 피드백 본문 일부를 같이 대조해 새 ID를 확정했다 |
| 페이지 생성이 막힘 | 여러 명이 있는 무료 워크스페이스의 블록 수 제한. DB 전용 워크스페이스를 새로 만들어 해결했다 |
| Notion MCP SQL 질의가 막힘 | 무료 플랜 사용량 한도. 넘긴 뒤에는 검색으로 대체했다 |
서명 URL(presigned URL)은 일정 시간만 유효하도록 서명을 붙인 주소다. 관찰한 한 사례는 X-Amz-Expires=300, 5분이었다. 받으면 바로 써야 하고, 실패하면 페이지를 다시 열어 새 주소를 받아야 한다.
sandbox는 Notion 그림을 못 받는다
이제 Notion에서 그림 주소를 받는 데까지는 된다. 문제는 그다음이다. ChatGPT의 코드 실행 환경(sandbox)은 외부 DNS가 막혀 있어서, Neon Storage 주소든 Notion 서명 URL이든 직접 내려받기가 둘 다 실패했다.
그래서 ChatGPT Sites를 써 봤다. ChatGPT 안에서 만들고 배포하는 작은 웹 서비스인데, sandbox에서 접근이 된다. Sites에 더미 그림을 올려 두고 sandbox에서 받아 모델에게 보여주는 테스트를 직접 해 봤고, 다운로드도 모델의 그림 확인도 둘 다 성공했다. 여기서 “Sites를 중계 서버로 둔다”는 방향이 정해졌다.
처음 설계는 중계 서버가 Notion API 토큰으로 직접 그림을 찾고, 접근 키로 요청을 막는 방식이었다. 그런데 모델은 Notion 커넥터로 그림 주소를 이미 받는다. 서버가 Notion에 따로 접속할 이유가 없어서 키를 다 뺐다.
-
POST /fetch {"url": ...}로 서명 URL을 받아 그림 bytes를 돌려준다. URL을 query string이 아닌 본문으로 받아 접근 로그에 남을 여지를 줄였다(Sites의 실제 로깅 방식은 확인하지 않았다). - Notion 파일 호스트 주소만 받는다. 아무 주소나 대신 열어주는 open proxy가 되지 않게.
- 리다이렉트로 다른 호스트로 넘어가면 거부한다.
- 응답이 이미지가 아니거나 너무 크면 거부한다.
AI가 봤다와 내가 봤다는 다른 칸이었다
중계가 생기니 AI는 그림을 볼 수 있게 됐다. 그런데 개요의 그 상태가 됐다. AI는 그림을 설명하는데 채팅 본문에는 안 보인다.
여기서 하나씩 실험하면서 알게 된 건, 이미지 전달이 한 단계가 아니라는 거다. 서버가 원본을 받는 것, 모델이 시각 입력으로 보는 것, 실행환경에 파일 bytes가 생기는 것, 그 파일이 원본과 같은지 검증하는 것, 사용자 화면에 그려지는 것이 전부 따로였다. 택배 추적에 집하·간선·배송·문 앞이 따로 찍히듯, 한 칸이 성공해도 다음 칸은 보장되지 않는다.
| 시도 | 결과 |
|---|---|
| 공개 이미지 검색 | Chat에 보임. 다만 비공개 Notion 원본과 같은 그림이라는 보장이 없어서 공개 작품용으로만 남김 |
MCP ImageContent
|
AI는 봄. 본문 표시로 이어지지 않음 |
| 위젯·HTML·SVG·이미지 URL | 실패하거나 재현되지 않아 중단 |
resource_link + resources/read
|
서버는 원본 bytes를 정확히 돌려줬는데, Chat의 도구 결과에는 text만 남음 |
| 실행환경에서 직접 다운로드 | DNS·네트워크 오류 |
SVG 실험까지 안 되자 결과 보고만 쌓이는 게 답답해서 이렇게 말했다.
그냥 좀 시스템을 뜯든 다른 사람들의 우회 방법들을 참조하든 해서 구현해보라고
그 뒤로 resource_link를 꽤 오래 붙잡았다. 서버 로그에는 174,192 bytes 원본이 SHA-256까지 일치하게 반환됐다고 찍혀 있는데, Chat 쪽 도구 결과에는 파일 참조가 없었다. 그래서 그림 내용이나 Notion 인증, 파일 크기 문제인지 가르려고 Notion과 상관없는 606 bytes짜리 PDF와 153 bytes짜리 32×32 PNG를 대조군으로 넣어 봤다. 셋 다 서버는 정상 반환했고, Chat이 받은 결과는 {} 아니면 설명 text뿐이었다.
작은 합성 파일에서도 똑같이 막힌다는 건, 원인이 Notion이나 그림 쪽이 아니라 MCP 결과를 Chat 실행환경으로 넘기는 변환 구간이라는 뜻이었다. 그 안의 어느 함수가 참조를 떨어뜨리는지까지는 확정하지 못했다. 이 결과도 내 계정과 그 실행환경에서의 관찰이라, MCP나 ChatGPT 전체에서 불가능하다고 일반화할 근거는 아니다.
112조각으로 옮겨 붙이기
상자째로는 안 들어가는데 text는 통과한다. 그래서 상자를 뜯어 낱개로 부치기로 했다. 원본 bytes를 텍스트 조각으로 나눠 도구로 받고 Chat의 Python에서 이어 붙여 PNG로 복원하는 방법을 썼다. 689×653 PNG 한 장이 112조각이었다.
이 방식은 모델이 조각 내용을 읽고 Python 코드에 다시 적어 넣는 전사가 끼어 있다. 실제로 문제가 났다.
- 진행 중에 Python 실행환경이 초기화되면서 이어 붙인 데이터가 날아갔다. 다시 열어 보니 안전하게 남은 건 174,192 bytes 중 20,480 bytes였다. 그 뒤 조각들을 하나씩 검증했던 기록은 있었지만, 파일에 남지 않았으니 의미가 없었다.
- 조각별 검사는 다 통과했는데, 마지막 전체 SHA-256에서 데이터가 잘못 중복된 게 걸렸다.
파일 생성 승인 창이 뜰 때마다 Chat에서 “한 번만 허용”을 누르고 “진행했어”를 보내는 걸 여러 번 반복했다. 결국 전체 해시와 PNG 디코딩이 맞았고, 기존 Chat 본문에 원본이 떴다. 처음으로 “화면에 잘 보여”를 보낸 순간이었다.
다만 이걸 매번 할 수는 없다. 이미 등록된 원본은 같은 대화의 실제 파일 ID로 다시 가져와 검증 후 재표시하게 해서 112조각을 다시 옮기는 일은 없앴다. 남은 건 아직 대화에 한 번도 올라오지 않은 원본이었다.
embedded bytes와 표준입력
결국 기본 경로가 된 건 브리지의 fetch_notion_file 도구가 원본을 embedded binary resource로 돌려주는 방식이다. 링크가 아니라 bytes 자체를 결과에 담는다. 이번엔 상자째 들어갔다. 작은 원본(상자 연습 피드백 후, 33,565 bytes, 376×385)은 전사 없이 한 번에 실행환경으로 넘어가 검증과 본문 표시까지 됐다.

두 번째, 공간감 피드백 후 원본(282,542 bytes, 801×759)에서 다시 막혔다. bytes를 JavaScript 코드 문자열에 넣어 검증기로 넘기는 구조였는데, 자동 승인 검토가 이렇게 거부했다.
JavaScript execution exceeds the 64000-byte strict auto-review limit
실행 코드에 그림 데이터를 섞으니 데이터 크기가 곧 코드 크기가 된 거다. 코드를 쪼개 한도 아래로 우회하는 대신, 검증 프로그램은 고정된 Python 코드로 두고 이미지는 표준입력의 JSON 데이터로만 넘기게 바꿨다. 입력은 실행 코드가 아니라 데이터로만 파싱된다.
여기서도 하나 더 걸렸다. 프로그램에 데이터를 넘기는 가장 흔한 통로는 pipe다. 앞 명령의 출력을 그대로 다음 프로그램의 입력으로 흘려 넣는 방식인데, 이 Windows 실행 도구에서는 데이터가 들어가기도 전에 “입력 끝(EOF)” 신호가 먼저 와서 검증 프로그램이 빈 입력을 받았다.
그래서 사람이 터미널에 키보드로 치듯 넣는 방식(PTY, 가상 터미널)으로 바꿨고, 여기엔 두 가지를 손봐야 했다. 터미널은 입력한 글자를 화면에 다시 찍어 주는데(echo), 37만 자짜리 데이터가 그대로 다시 쏟아지지 않게 이걸 껐다. 키보드 입력에는 “여기까지”라는 끝 신호도 따로 없어서, “377,293자를 보낸다”는 길이를 먼저 보내고 그만큼 받으면 끝난 걸로 쳤다.
이 우회는 이 Windows 실행 도구에서 확인한 거라, 다른 환경에서도 pipe가 똑같이 막히는지는 모른다.
‘일러스트레이터’ 프로젝트의 같은 Chat에서 두 원본이 모두 본문에 보이는 걸 확인했다. 두 번째 원본은 수작업 전사 0회였다.
같아 보인다고 같은 파일은 아니다
이 과정 내내 “보인다”와 “원본이다”를 따로 확인했다. 파일명이 같거나 눈으로 비슷해 보이는 건 같은 bytes의 증거가 아니다. PNG는 메타데이터만 바뀌어도 픽셀은 같은데 파일 해시가 달라진다.
그래서 표시 전에 전체 SHA-256, byte 수, PNG chunk CRC, 전체 디코딩, 해상도를 다 맞춘다. 112조각 때 조각별 검사는 통과했는데 전체 해시에서 중복이 걸린 것처럼, 부분 검사는 부분 증거일 뿐이었다. 그리고 bytes가 맞아도 그림의 내용은 실제로 파일을 열어 봐야 말할 수 있다.
소감
AI는 그림을 봤다고 하는데 내 화면엔 안 뜨고, 실험 보고는 계속 오는데 그림은 안 나오고.. 중간에 꽤 답답했다. 결국 내 피드백 그림이 채팅 본문에 그대로 떴을 때는 3일간의 고통에서 해방되는 낙원의 시간이였다.