만들면서 배운 것

링크 하나 열리게 하려고, 서버를 하나 더 세웠습니다 🔗

생각에 잠긴 초록이 아빠

매일 아침 06:56, 텔레그램으로 그날의 vault 브리핑이 옵니다. “확인 필요” 항목이 몇 개 뜨는데, 그중 하나를 보려면 옵시디언 앱을 열고 제목으로 다시 검색해야 했어요. 브리핑엔 분명 어떤 노트인지 다 적혀 있는데, 그걸 다시 찾아 들어가는 게 매번 걸렸습니다.

그래서 메시지에 있는 노트 제목을 바로 누르면 옵시디언이 열리게 만들기로 했습니다. 옵시디언은 obsidian://open?vault=...&file=... 같은 자체 링크 스킴을 지원하니, 그걸 메시지에 박기만 하면 될 줄 알았어요.

안 열렸다

[제목](obsidian://open?...) 형태로 링크를 걸었는데, 폰에서도 맥북에서도 안 눌렸습니다. 텍스트로만 떨어지고 링크가 아예 안 걸리는 거예요.

찾아보니 원인은 제가 몰랐던 제약이었습니다. 텔레그램 Bot API는 인라인 링크에 http / https / tg / ton 스킴만 허용합니다. obsidian://는 목록에 없으니 그냥 무시하고 텍스트로 떨어뜨리는 거였어요. 이 브리핑보다 먼저 만들어둔 아침 브리핑 잡도 도입 이후 계속 같은 상태였다는 걸 그제야 알았습니다.

http로 감싸서, 서버를 하나 세웠다

옵시디언 링크를 직접 못 거니, 우회로를 만들었습니다. 맥미니에 작은 리다이렉트 서버를 하나 세워서, 텔레그램에는 http 링크를 걸고 그 서버가 obsidian:// 로 302 리다이렉트하게 했어요.

텔레그램 링크  http://100.x.x.x:8791/n/c7c14d
      ↓ 302
      obsidian://open?vault=...&file=...
      ↓
      옵시디언 열림

개념도 — 텔레그램 링크 → 302 → obsidian:// 흐름

이 서버는 Tailscale IP에만 바인딩해뒀습니다. 사설망 밖에서는 아예 안 보이고, 폰·맥북 둘 다 Tailscale이 켜져 있어야 열려요. HTTPS까지 붙이려고 tailscale serve를 써봤는데, 이건 앱스토어로 받은 옵시디언 빌드에서 동작하지 않았습니다. 그래서 이미 돌리고 있던 다른 웹훅과 같은 방식으로, tailnet 안에서만 도는 평범한 http 서버로 갔습니다. 302가 막히는 브라우저도 있어서, 리다이렉트가 안 먹으면 수동으로 누를 수 있는 버튼 페이지도 같이 붙였고요.

LLM이 링크를 다시 타이핑하다가 깨뜨렸다 🪤

삽질하다 멘붕한 초록이 아빠

여기까지는 순조로웠는데, 검증하다가 새로운 문제를 발견했습니다. 링크를 만드는 역할을 브리핑을 분류하는 LLM 에이전트에게 같이 맡겼더니, 이 에이전트가 퍼센트 인코딩된 긴 obsidian:// URL을 응답에 옮겨 적는 과정에서 몇 글자를 바꿔버렸습니다. 노트 제목 “하루종일”이 “하루제일”로, 딱 한 글자가 달라진 채로 나온 거예요.

에러가 나는 게 아니라 그냥 조용히 다른 링크가 걸리는 거라 더 골치 아팠습니다. 누르면 열리긴 여는데, 엉뚱한 노트가 열리거나 아예 없는 파일이라 실패하는 식이었어요.

LLM에게 판단을 시키는 것과, 정확히 똑같이 재현돼야 하는 값을 만들게 시키는 것은 다른 일이었습니다.

판단은 모델에게, 링크는 스크립트에게

그래서 역할을 아예 나눴습니다. 브리핑 파이프라인을 두 개 잡으로 쪼갠 거예요.

하나는 LLM 에이전트가 “이 노트를 오늘 브리핑에 넣을지 말지, 어느 섹션에 넣을지”만 판단합니다. 실제 URL은 다루지 않아요. 대신 노트 경로를 SHA1으로 해시해서 앞 6자리만 잘라 쓰는 짧은 ID(/n/c7c14d 같은 형태)를 별도 인덱스 파일에 저장해두고, 판단 결과에는 이 짧은 ID만 실어 보냅니다.

다른 하나는 완전히 결정적인 스크립트입니다. LLM이 전혀 개입하지 않고, 짧은 ID를 인덱스에서 찾아 링크 URL을 조립하고 텔레그램 메시지를 렌더링만 합니다. 여기서는 애초에 재타이핑할 일이 없으니 깨질 수도 없습니다.

퍼센트 인코딩된 긴 문자열을 LLM 손에 아예 쥐여주지 않는 게 핵심이었어요. “정확하게 옮겨 적어라”고 프롬프트에 아무리 강조해도, 언젠가는 또 한 글자씩 밀릴 수 있으니까요.


배포 당일 에러가 떴을 때, 바로 고치지 않았다

리다이렉트 서버를 등록한 그날, 등록 직후 30분 사이에 ConnectionResetError가 10건 찍혔습니다. 새로 켠 서버에서 처음 보는 에러 로그라 순간 “설계가 잘못됐나” 싶었어요.

바로 코드를 뒤지는 대신 두 가지부터 확인했습니다. 첫째, 에러가 난 시점 이후로 소스 코드가 그대로인지 — 즉 이게 옛날 버그의 잔재가 아니라 지금 코드에서 나는 게 맞는지. 둘째, 그 뒤로 재발하는지를 지켜봤습니다. 16시간 넘게 추가 발생이 없었고, 다음날도 재발이 없었어요. 그래서 이건 클라이언트가 연결을 도중에 끊을 때 흔히 나는, socketserver의 정상적인 예외 패턴으로 판단했습니다. 이틀 더 재발이 없는 걸 확인한 뒤에야 이슈를 완전히 닫았습니다.

새로 띄운 서비스에서 처음 보는 에러 로그가 뜨면 반사적으로 코드부터 고치고 싶어지는데, 그 전에 “재발하는가”부터 봐야 진짜 버그와 그냥 흔한 잡음을 가릅니다.

따라 하기

이번엔 두 가지를 프롬프트로 남깁니다. 텔레그램·슬랙 같은 봇에 자체 스킴 링크를 걸고 싶을 때, 그리고 LLM 에이전트에게 판단과 링크 생성을 같이 시키고 있다면 이 순서대로 물어보시면 됩니다.

1. 상시 리다이렉트 서버를 세우기

텔레그램·슬랙 같은 챗봇 API는 인라인 링크에 http/https 계열만 허용하는 경우가 많습니다. 앱 전용 스킴(obsidian://, notion:// 같은)을 직접 걸 수 없다면, 사이에 http로 받아 302로 넘겨주는 작은 서버를 하나 세우면 됩니다.

📋 프롬프트 — 복사해서 AI에 붙여넣으세요

너는 macOS에서 launchd로 상시 서비스를 등록하는 걸 돕는다.
목표: 짧은 ID를 받아 <앱스킴>://... URL로 302 리다이렉트하는
파이썬 소켓 서버를 만들고, launchd로 상시 등록해줘.

- 서버는 <IP>(예: Tailscale IP처럼 사설망 IP)에만 바인딩해서
  외부 인터넷에는 노출되지 않게 해줘.
- 302가 막히는 클라이언트를 대비해 수동으로 누를 수 있는
  버튼이 있는 폴백 페이지도 같이 만들어줘.
- 각 명령을 실행하기 전에 무엇을 하는 명령인지 한 줄로 설명하고,
  내 확인을 받고 진행해.
- launchd plist를 새로 만들거나 기존 설정을 바꾸는 작업은
  실행 전에 먼저 경고해줘.

✅ 이렇게 되면 성공 — curl http://<IP>:<포트>/health 에 ok 응답이 오고, 짧은 링크를 브라우저에 붙여넣으면 원래 앱이 열립니다.

⚠️ 막히면 — 리다이렉트가 안 먹으면 “이 요청이 실제로 서버에 도달했는지 로그로 보여줘. 방화벽이나 바인딩 IP 문제인지도 같이 확인해줘”라고 되물으세요.

2. LLM에게 “판단”과 “정확한 값 생성”을 같이 시키지 않기

에이전트가 분류·판단만 하는 게 아니라 링크·숫자·코드처럼 한 글자도 틀리면 안 되는 값을 직접 만들어내고 있다면, 그 부분부터 떼어내야 합니다.

📋 프롬프트 — 복사해서 AI에 붙여넣으세요

너는 자동화 파이프라인을 검토하는 역할이야.
지금 <에이전트/스크립트 이름>이 (1) 판단(분류·우선순위 등)과
(2) 정확히 재현돼야 하는 값(URL·ID·숫자 등) 생성을 동시에 하고 있어.

- LLM이 참여하는 단계에서는 (2)의 원본 값이 출력에 절대
  등장하지 않도록, 짧은 참조 ID나 플레이스홀더만 다루게
  리팩토링 방향을 제안해줘.
- 원본 값 조립·렌더링은 LLM이 전혀 개입하지 않는
  별도의 결정적 스크립트로 분리해줘.
- 리팩토링 방향을 코드로 옮기기 전에, 어디를 어떻게 나눌지
  먼저 설명하고 내 확인을 받아.

✅ 이렇게 되면 성공 — LLM 쪽 출력에는 원본 URL·숫자 문자열이 한 번도 등장하지 않고, 짧은 ID나 참조값만 남습니다.

⚠️ 막히면 — 여전히 원본 값이 출력에 섞여 나온다면 “출력에 <원본 형식>이 하나라도 나오면 실패로 처리하는 검증 단계를 추가해줘”라고 요청하세요.

배운 것

화이팅하는 초록이 아빠

이번에 배운 건 두 가지입니다. LLM에게 판단을 맡기는 것과, 한 글자도 틀리면 안 되는 값을 만들게 하는 것은 다른 일이라는 것. 후자는 아예 LLM 손이 안 닿는 곳으로 빼야 안전합니다. 그리고 새로 배포한 서비스에서 처음 보는 에러가 뜨면, 코드를 고치기 전에 재발 여부부터 지켜봐야 진짜 문제와 흔한 잡음을 가릴 수 있다는 것.

링크 하나 열리게 하자고 서버까지 세우는 게 처음엔 좀 과해 보였는데, 지나고 보니 그 서버 덕분에 “정확해야 하는 값은 LLM에게 맡기지 않는다”는 원칙이 하나 생겼습니다.

오늘도 한 걸음. 🐴

이 글은 산업 구조와 만드는 과정을 정리한 개인 기록입니다.
특정 종목의 매수·매도 추천이 아니며, 투자 판단과 그 결과는 독자 본인에게 있습니다.

← 글 목록으로