서버는 살아 있었다
1주차 슬라이드 마지막 장에는 이런 문장이 있었다.
“오늘 만든 서버는 터미널을 닫으면 죽습니다.”
몇 달 뒤, 나는 이 문장을 조금 다르게 겪었다.
내 서버는 죽지 않았다.
프로세스도 떠 있었고, 재부팅 후에도 자동으로 다시 실행됐다. 겉으로 보기에는 아무 문제가 없었다.
그런데 서버는 살아 있는 채로 아무 일도 하지 않을 수 있었다.
그리고 나는 그것을 3주 동안 몰랐다.
이 글은 잘 만든 서비스를 소개하는 성공담이 아니다. 맥미니에서 24시간 돌아가도록 만든 개인 서버가 어떻게 조용히 실패했고, 그 실패를 통해 무엇을 고쳤는지 기록한 사례다.
결론부터 말하면, 상주 서버의 진짜 숙제는 단순히 서버를 켜두는 것이 아니었다.
내가 보고 있지 않을 때 무슨 일이 일어났는지 알 수 있게 만드는 것.
그게 더 어려운 일이었다.
진행 방법
1. 폰에서 3초 안에 자료를 수집하고 싶었다
나는 하루에도 몇 번씩 나중에 다시 보고 싶은 자료를 만난다.
유튜브 영상
블로그 글
논문
짧은 아이디어
다시 찾아보고 싶은 문장
문제는 저장 방식이었다.
“나중에 봐야지”라고 생각하면 다시 보지 않았다.
카카오톡 ‘나에게 보내기’에 저장하면 금방 묻혔다.
Obsidian에 직접 정리하려니 휴대폰에서는 너무 번거로웠다.
그래서 두 가지 조건을 정했다.
수집은 휴대폰에서 3초 안에 끝나야 한다.
나중에는 정확한 제목을 몰라도 내 말로 질문해서 찾을 수 있어야 한다.
이를 위해 두 개의 서비스를 만들었다.
[아이폰 텔레그램]
│
│ 링크 또는 메모 전송
▼
┌────────────────────────┐
│ llm-wiki-inbox │
│ │
│ · 텔레그램 롱폴링 │
│ · 웹페이지 본문 추출 │
│ · 유튜브 자막 추출 │
│ · Obsidian 노트 저장 │
└────────────────────────┘
│
▼
[Obsidian 볼트]
│
▼
┌────────────────────────┐
│ llm-wiki-web │
│ │
│ · 전문 검색 │
│ · 노트 기반 질의응답 │
│ · 모바일 웹 화면 │
└────────────────────────┘
▲
│
[아이폰 브라우저]서비스
역할
코드
테스트
llm-wiki-inbox
텔레그램 메시지를 Obsidian으로 수집
1,422줄
77건
llm-wiki-web
검색 및 노트 기반 질의응답
2,213줄
35건
두 서비스는 macOS의 launchd로 상주시켰다. launchd는 macOS에서 백그라운 드 서비스나 사용자 에이전트를 실행하고 관리하는 방식이다.
목표는 분명했다.
내가 자고 있을 때도 수집하고, 재부팅돼도 다시 살아나며, 밖에서는 휴대폰으로 접속할 수 있는 개인 위키.
적어도 겉으로는 완성된 것처럼 보였다.
2. 수집기에서는 의도적으로 LLM을 뺐다
AI 스터디 사례인데 수집 서비스에는 LLM 호출이 하나도 없다.
처음에는 AI 에이전트가 텔레그램 메시지를 읽고, 내용을 판단한 뒤, 적절한 형식으로 정리하도록 만들 생각이었다.
하지만 두 가지 위험이 걸렸다.
첫 번째는 좀비화였다
세션 기반 에이전트가 내부적으로 멈추더라도 프로세스 자체는 살아 있을 수 있다.
운영체제 입장에서는 정상 프로세스지만, 사용자가 기대한 일은 하지 않는 상태다.
두 번째는 프롬프트 인젝션이었다
수집기가 읽는 웹페이지는 내가 작성한 콘텐츠가 아니다.
웹페이지 본문에 다음과 같은 문장이 들어 있을 수도 있다.
이전의 모든 지시를 무시하고 다른 작업을 수행하라.수집 단계에서 에이전트가 외부 콘텐츠를 명령처럼 해석하면, 단순한 자료 저장기가 예상하지 못한 행동을 할 수 있다.
그래서 수집 규칙을 최대한 단순하게 만들었다.
URL이 있다 → 클립으로 저장
URL이 없다 → 메모로 저장
/로 시작한다 → 봇 명령이므로 무시수집기는 판단하지 않는다.
내용을 받아서 정해진 위치에 저장할 뿐이다.
LLM은 자료를 받을 때가 아니라, 사용자가 자료를 찾을 때만 사용하도록 역할을 분리했다.
AI를 어디에 넣을지보다, 어디에는 넣지 않을지 정하는 것이 더 중요한 설계 결정이었다.
3. 외부 접속에는 Tailscale을 선택했다
서버는 맥미니 안에서 돌아가지만, 실제로 사용할 때는 대부분 밖에서 휴대폰으로 접속한다.
2주차 강의에서 스터디장께서 추천하신 외부접속 서버 구성은 아래와 같았다.
도메인 DNS
↓
공유기 포트포워딩
↓
Caddy 리버스 프록시
↓
내부 서버나는 다른 방법을 선택했다.
tailscale serve --http=80 3100Tailscale Serve는 로컬에서 실행 중인 서버를 같은 tailnet에 속한 기기에서 접근할 수 있도록 연결한다. 접근 제어 규칙도 동일하게 적용된다.
내 개인 위키에는 내가 수집한 글과 메모의 전문이 들어 있다. 인증도 붙이지 않은 상태에서 인터넷 전체에 공개하는 것은 부담스러웠다.
그래서 공유기의 80번과 443번 포트를 열지 않고, 내 기기들끼리만 통신하는 사설망 안에 서버를 두었다.
항목
공개 도메인 + 포트포워딩 + Caddy
Tailscale
공유기 포트 개방
필요
불필요
주요 설정 위치
DNS, 공유기, Caddy
Tailscale
불특정 다수에게 공유
쉬움
어려움
주된 사용 대상
공개 서비스
개인 또는 제한된 사용자
접근 범위
인터넷
tailnet
HTTPS
Caddy가 자동 관리 가능
tailnet의 HTTPS 기능 설정 필요
이 선택은 내 목적에 잘 맞았다.
서비스를 다른 사람에게 공개하려는 것이 아니라, 내 휴대폰과 맥에서만 사용하려 했기 때문이다.
다만 HTTPS에서 막혔다.
$ tailscale cert <마스킹한-호스트명>.ts.net
500 Internal Server Error:
your Tailscale account does not support getting TLS certs현재 서버는 tailnet 내부에서만 접근할 수 있지만 HTTP로 동작한다.
Tailscale의 HTTPS 주소를 사용하려면 tailnet에서 HTTPS 인증서 기능을 활성화해야 한다. 인증서가 발급되면 machine-name.tailNNNN.ts.net 형태의 HTTPS 주소로 접근할 수 있다.
처음에는 Tailscale이 무조건 더 편한 길이라고 생각했다.
실제로는 편함의 위치가 달랐다.
Caddy는 공개 서버와 HTTPS 설정에 편리하다.
Tailscale은 포트를 열지 않고 개인 기기끼리 연결하는 데 편리하다.
어느 도구가 더 좋은지가 먼저가 아니었다.
누구에게 공개할 것인지가 먼저 정해져야 도구를 고를 수 있었다.
결과와 배운 점
사건 1. 1주 동안 수집된 자료가 0건이었다
수집 데몬을 실행했다.
7월 25일, 문득 Obsidian 볼트를 열어봤다.
7월 18일 이후에 생성된 노트가 하나도 없었다.
가능성은 두 가지였다.
A. 내가 1주 동안 텔레그램에 아무것도 보내지 않았다.
B. 수집 데몬이 죽었거나 고장 났다.문제는 이 둘을 구분할 방법이 없었다는 것이다.
프로세스 목록에는 수집기가 정상적으로 표시됐다.
하지만 프로세스가 존재한다는 사실은 수집 기능이 정상이라는 뜻이 아니었다.
로그부터 확인하려 했다
로그는 /tmp에 저장하도록 설정해두었다.
그런데 3주치 로그가 모두 사라져 있었다.
파일이 삭제된 뒤에도 프로세스는 기존 파일 핸들을 계속 쥔 채 삭제된 파일에 로그를 기록하고 있었다. 디스크에는 쓰기가 발생하지만, 경로에서는 파일을 찾을 수 없는 상태였다.
복구할 수 있는 진단 기록이 없었다.
데이터베이스도 답을 주지 못했다
수집기는 마지막으로 처리한 텔레그램 업데이트 번호만 SQLite에 저장하고 있었다.
하지만 새로운 메시지가 도착하지 않으면 이 값은 갱신되지 않는다.
따라서 데이터베이스만 봐서는 다음 두 상태가 완전히 똑같았다.
정상적으로 폴링 중이지만 새 메시지가 없음
폴링 자체가 멈춰 있음결국 lsof를 이용해 수집 프로세스가 텔레그램 서버와 네트워크 연결을 유지하고 있는지 확인했다.
결론은 허무했다.
서버는 정상이고, 내가 3주 동안 아무것도 보내지 않았던 것이 맞았다.
그런데 정상이라는 사실을 증명하는 데 반나절이 걸렸다.
서비스는 고장 나지 않았다.
하지만 서비스가 정상인지 판단할 수 없다는 것 자체가 더 큰 문제였다.
조사하다가 진짜 장애를 발견했다
수집 건수가 0건이었던 원인을 찾는 과정에서 훨씬 위험한 코드를 발견했다.
텔레그램 API가 실패 응답을 반환할 때 수집기는 다음과 같이 처리하고 있었다.
API가 ok:false를 반환한다
→ 예외를 발생시키지 않는다
→ 빈 배열을 반환한다빈 배열은 정상 상황에서 “새 메시지가 없다”는 뜻으로 사용되고 있었다.
즉 API 장애와 정상적인 무수신 상태가 같은 값으로 처리됐다.
이 때문에 재시도 대기시간도 매번 초기화됐다.
영구 장애 발생
↓
API가 계속 실패
↓
실패를 빈 배열로 처리
↓
백오프가 최소값으로 초기화
↓
즉시 다시 요청
↓
무한 고속 반복텔레그램 봇 토큰이 폐기되거나, 다른 프로세스가 같은 봇으로 중복 폴링하면 수집은 완전히 멈춘다.
하지만 프로세스는 종료되지 않는다.
오히려 CPU를 사용하며 바쁘게 반복하기 때문에 겉보기에는 건강해 보인다.
가장 나쁜 실패는 죽는 것이 아니라, 살아 있는 척하는 것이었다.
개선한 것
발견한 문제
개선한 내용
/tmp 로그 소실
로그 경로를 ~/Library/Logs로 이전
생사 확인 불가
폴링 성공 시 last_poll_at 기록
API 실패와 무수신 구분 불가
API 실패를 예외로 승격
고속 무한 재시도
1초에서 최대 60초까지 지수 백오프
장기 장애 인지 불가
연속 5회 실패 시 텔레그램 자가 알림
알림 폭주 가능성
알림 후 30분 쿨다운
조용한 예외 처리
무로그 catch에 경고 로그 추가
잘못된 운영 문서
README의 로그 경로와 점검 방법 수정
마지막으로 기존 테스트 63건에 신규 테스트 14건을 추가했다.
총 77건 테스트 통과여기서 가장 크게 배운 것은 이것이다.
관측성은 기능을 다 만든 뒤 붙이는 부가 기능이 아니었다.
기능이 존재한다고 말하기 위한 전제조건이었다.
나는 수집 기능을 만들었지만, 그것이 살아 있는지 확인할 방법은 만들지 않았다.
그래서 아무 일도 일어나지 않았던 3주를 장애처럼 조사해야 했다.
사건 2. 집 안에서는 개인 위키가 모두에게 열려 있었다
나는 Tailscale을 사용하면서 공유기의 외부 포트를 열지 않았다.
그래서 내 서버가 안전하다고 생각했다.
절 반만 맞는 생각이었다.
웹 서버를 실행하는 코드에 바인딩 주소를 명시하지 않았고, 기본값은 0.0.0.0이었다.
0.0.0.0 = 모든 네트워크 인터페이스에서 접속 허용
127.0.0.1 = 현재 컴퓨터 내부에서만 접속 허용0.0.0.0으로 서버가 열리면 같은 공유기에 연결된 다른 기기에서도 접속할 수 있다.
웹 서버에는 별도의 인증이 없었다.
따라서 우리 집 와이파이에 접속할 수 있는 사람이라면 사설 IP와 포트 번호만으로 내 개인 위키를 읽을 수 있는 상태였다.
인터넷에서는 닫혀 있었지만, 집 안에서는 열려 있었다.
“공유기 포트를 열지 않았으니 안전하다”는 말은 인터넷 방향에 대해서만 맞았다.
해결은 한 줄이었다.
host 기본값을 127.0.0.1로 변경Tailscale이 127.0.0.1:3100으로 프록시하도록 구성했기 때문에 휴대폰에서는 이전과 동일하게 사용할 수 있었다.
$ lsof -nP -iTCP:3100 -sTCP:LISTEN
bun 40846 hanmac 7u IPv4 TCP 127.0.0.1:3100 (LISTEN)수정 후 직접 확인했다.
127.0.0.1:3100에서는 정상 접속집 안의 LAN 주소로는 연결 거부
tailnet 주소에서는 정상 응답
이 사건은 기본값을 다시 보게 만들었다.
기본값은 중립이 아니다.
아무것도 정하지 않으면 가장 안전한 값이 아니라, 가장 편리한 값이 들어갈 수 있다.
밖에서 접속할 수 있도록 만드는 일은 단순히 입구를 여는 일이 아니었다.
어디까지 열고, 어디부터 닫을지를 함께 정하는 일이었다.
사건 3. ‘LLM 위키’를 검색했는데 결과가 없었다
내 Obsidian 볼트의 이름은 ‘LLM 위키’다.
그런데 검색창에 ‘LLM 위키’를 입력하니 결과가 0건이었다.
원인은 검색 색인의 최소 단위였다.
검색에는 문자열을 세 글자 단위로 나누는 trigram 방식을 사용하고 있었다.
"LLM 위키"
LLM → 3글자이므로 색인 가능
위키 → 2글자이므로 색인되지 않음검색어의 각 조건은 AND로 결합됐다.
따라서 색인에 존재하지 않는 짧은 단어가 하나라도 포함되면 결과 전체가 0건이 됐다.
LLM 조건은 일치
AND
위키 조건은 불일치
=
최종 결과 0건긴 단어로 검색할 때는 잘 작동했다.
그래서 나는 한동안 검색 기능이 정상이라고 생각했다.
하지만 짧은 단어가 섞이는 순간 결과가 모두 사라지고 있었다.
해결 방법은 검색 방식을 섞는 것이었다.
세 글자 이상인 단어는 기존 색인으로 검색
짧은 단어는 단순 문자열 포함 여부로 검사
두 결과를 결합해 최종 결과 생성
수정 전후 결과는 다음과 같았다.
검색어
수정 전
수정 후
LLM 위키
0건
24건
Tailscale AI
0건
33건
검색은 고장 나 있었지만, 내가 사용한 테스트 단어에서는 잘 작동했다.
잘 되는 사례만 테스트하면, 고장 난 기능도 잘 되는 것처럼 보인다.
나는 ‘클리퍼’처럼 비교적 긴 단어만 사용해 확인하고 있었다.
기능을 테스트한 것이 아니라, 기능이 통과할 수 있는 문제만 골라서 내고 있었던 셈이다.
사건 4. 답변이 사라졌는데 LLM 서버를 의심했다
웹 화면에서 내 노트를 기반으로 질문하면 답변이 나오지 않는 일이 있었다.
화면에는 다음과 같은 메시지가 표시됐다.
oMLX 서버 오류oMLX는 맥에서 실행하고 있는 로컬 LLM 서버다.
나는 당연히 로컬 LLM이 불안정하다고 생각했다.
모델 설정과 서버 로그를 반복해서 확인했다.
하지만 범인은 완전히 다른 곳에 있었다.
실제 원인은 SQLite 잠금이었다
검색 색인기와 대화 저장소가 같은 SQLite 파일을 사용하면서 각각 별도의 연결을 열고 있었다.
게다가 잠금이 풀리기를 기다리는 busy_timeout 값이 0이었다.
두 작업이 동시에 데이터베이스에 쓰려고 하면 한쪽이 즉시 실패했다.
사용자 질문
↓
LLM 답변 스트리밍 정상 완료
↓
대화 내용을 SQLite에 저장
↓
database is locked
↓
예외가 넓은 oMLX catch 블록에 잡힘
↓
화면에는 "oMLX 서버 오류" 표시
↓
생성된 답변은 저장되지 않고 유실실제 데이터를 확인해보니 7개 대화 세션 중 4개가 답변 없이 끝나 있었다.
절반이 넘는 대화가 유실된 것이다.
하지만 가장 큰 문제는 유실 자체만이 아니었다.
오류 메시지가 잘못된 범인을 가리키고 있었다.
데이터베이스 잠금 문제인데 화면은 LLM 서버를 탓했다.
그 결과 나는 한동안 정상적인 LLM 서버만 들여다보고 있었다.
개선한 것
검색 색인기와 대화 저장소가 데이터베이스 연결을 공유하도록 변경했다.
SQLite에 적절한
busy_timeout을 설정했다.LLM 호출 실패와 데이터베이스 저장 실패를 서로 다른 오류로 표시했다.
넓은 범위의 catch는 코드를 간단하게 만들 수 있다.
하지만 서로 다른 실패를 하나의 오류로 묶으면, 실제 원인을 숨길 수 있다.
에러 메시지가 틀리면 실패를 고치는 것이 아니라 엉뚱한 시스템을 고치게 된다.
네 사건을 관통한 한 가지
네 사건은 서로 다른 문제처럼 보였다.
텔레그램 폴링
네트워크 바인딩
검색 색인
SQLite 잠금
하지만 겉으로 보인 현상과 실제 원인을 나란히 놓자 같은 패턴이 보였다.
사건
겉으로 보인 상태
실제 상태
며칠 동안 수집 0건
프로세스는 정상
정상 여부를 확인할 방법이 없었음
집 안 네트워크 노출
외부 포트를 닫았으니 안전
같은 LAN에는 모두 열려 있었음
짧은 단어 검색 0건
검색 기능은 정상
짧은 토큰이 섞이면 결과가 전멸
대화 답변 유실
oMLX 서버 오류
실제 원인은 SQLite 잠금
네 사건 모두 고장 났지만, 고장 난 것처럼 보이지 않았다.
1주차에 터미널에서 직접 실행한 서버는 문제가 생기면 바로 알 수 있었다.
터미널에 빨간 오류가 출력된다.
프로세스가 종료된다.
curl요청에 응답하지 않는다.
하지만 상주 서버는 다르다.
내가 보고 있지 않을 때 실행되고, 내가 보고 있지 않을 때 실패한다.
그래서 24시간 서버에서 중요한 것은 단순한 실행 시간이 아니었다.
24시간 실행
≠
24시간 정상 동작상주화의 진짜 과제는 서버를 켜두는 것이 아니라 다음 질문에 답할 수 있게 만드는 것이었다.
마지막으로 정상 동작한 시각은 언제인가?
지금 처리 중인 작업은 무엇인가?
최근 몇 번이나 연속으로 실패했는가?
실패했다면 어느 단계에서 실패했는가?
내가 확인하지 않아도 장애를 알려주는가?
이 질문에 답하기 위해 뒤늦게 붙인 기능들이 있다.
삭제되지 않는 위치에 로그 저장
마지막 정상 폴링 시각 기록
실패 시 지수 백오프 적용
일정 횟수 이상 실패하면 자가 알림
오류 발생 지점을 구분한 메시지
실제 실패 조건을 재현하는 테스트
처음에는 이것들을 운영 편의를 위한 부가 기능이라고 생각했다.
지금은 다르게 생각한다.
기능을 만드는 것과, 그 기능이 계속 살아 있도록 만드는 것은 완전히 다른 일이었다.
실패를 통해 개선한다는 것
이번 프로젝트에서 가장 많이 한 일은 새 기능을 추가하는 것이 아니었다.
이미 만든 기능이 어떤 방식으로 실패할 수 있는지 확인하고, 그 실패가 보이도록 바꾸는 일이었다.
처음에는 실패를 없애려고 했다.
하지만 서버를 계속 운영할수록 실패 자체를 완전히 없애는 것은 어렵다는 것을 알게 됐다.
대신 다음 세 가지를 개선할 수 있었다.
1. 실패를 빨리 발견한다
며칠 뒤에 알아차리는 대신, 마지막 정상 동작 시각 과 자가 알림을 통해 빠르게 발견할 수 있다.
2. 실패 원인을 정확하게 말한다
모든 예외를 ‘서버 오류’로 표시하지 않고, 텔레그램 API, SQLite, LLM 서버처럼 실패 지점을 구분한다.
3. 같은 실패를 다시 만들지 않는다
문제를 고친 뒤 테스트와 문서를 함께 수정한다.
코드만 고치고 운영 문서를 그대로 두면, 다음에는 같은 문제를 다른 사람이 다시 오진할 수 있다.
실패는 프로젝트가 부족하다는 증거만은 아니었다.
실패를 관찰하고, 재현하고, 다시 일어나지 않도록 바꾸는 과정이 프로젝트를 실제 서비스에 가깝게 만들었다.
실패하지 않는 시스템보다, 실패했을 때 빨리 드러나고 회복할 수 있는 시스템이 더 현실적이었다.
현재 상태
항목
상태
두 서비스 상주
동작 중
재부팅 후 자동 실행
launchd로 구성
휴대폰 외부 접속
Tailscale을 통해 동작
인터넷 공개
하지 않음
HTTPS
미해결
로그 보존
~/Library/Logs로 이전
수집기 생사 확인
last_poll_at 기록
반복 장애 대응
지수 백오프 및 자가 알림
LAN 노출
127.0.0.1 바인딩으로 차단
검색 오류
짧은 토큰 검색 방식 보완
답변 저장 오류
DB 연결 공유 및 오류 분리
테스트
inbox 77건, web 35건 통과
에필로그
1주차 실습에서도 포트 문제를 겪었다.
슬라이드에서는 8000번 포트를 사용했지만, 내 맥에서는 다른 프로그램이 이미 8000번을 사용하고 있었다.
그래서 8099번으로 옮겼다.
이번 프로젝트에서도 같은 일이 일어났다.
3000번 포트가 이미 점유돼 있어서 3100번으로 변경했다.
몇 달 전에는 단순한 실습 문제처럼 보였던 것이 실제 프로젝트에서도 그대로 반복됐다.
규모가 커지고 코드가 많아져도, 시스템이 막히는 지점은 의외로 비슷했다.
이미 사용 중인 포트
너무 넓은 기본값
사라진 로그
잘못된 예외 처리
테스트하지 않은 경계 조건
다만 이번에는 문제를 해결하는 데서 끝나지 않았다.
왜 늦게 발견했는지, 왜 원인을 잘못 추측했는지, 다음에는 어떻 게 더 빨리 알 수 있을지까지 살펴봤다.
처음에는 24시간 돌아가는 서버를 만들고 싶었다.
지금은 목표가 조금 달라졌다.
24시간 완벽한 서버가 아니라, 실패하더라도 숨지 않는 서버를 만들고 싶다.
다섯 줄 요약
상주 서버의 진짜 과제는 켜두는 것이 아니라, 내가 보지 않을 때 무슨 일이 있었는지 알 수 있게 하는 것이다.
가장 위험한 고장은 죽는 고장이 아니라, 살아 있는 척하며 일을 하지 않는 고장이다.
기본값은 중립적이지 않다. 명시하지 않으면 안전한 값보다 편리한 값이 선택될 수 있다.
잘못된 에러 메시지는 문제를 숨기고, 개발자를 엉뚱한 원인으로 안내한다.
실패는 끝이 아니었다. 실패를 관찰하고 재현하고 고치는 과정에서 시스템은 실제 서비스에 가까워졌다.