도메인, OpenAI API, 이미 운영 중이던 한 대의 VPS. 이 셋만으로 내가 쓴 글들이 검색되고, 시멘틱 검색이 작동하는 사이트를 만들었다. 도구는 Next.js · FastAPI · docconv 셋. 공식 문서대로 따라가다 막힌 지점들과 그걸 어떻게 풀었는지 기록한다.
왜 자체 VPS인가
처음엔 Vercel을 고민했다. Next.js의 본가이고, 무료 티어로 충분히 시작 가능. 그러나 두 가지 이유로 자체 호스팅을 택했다.
비용 통제 — 트래픽 늘어나도 같은 VPS 안에서 끝남. Vercel은 이미지 최적화·serverless 호출 단위로 과금
사이드카 자유도 — Python ML 스택(docconv)을 같은 호스트에 띄워야 하는데, Vercel에선 별도 서비스로 분리해야 한다. 작은 사이트엔 과함
이미 다른 서비스(ai.sshsys.kr)가 운영되고 있던 VPS라서, 그 위에 nginx 가상호스트만 추가하는 형태가 가장 단순했다.
최종 토폴로지
VPS (Ubuntu 24.04)
├── nginx :80/:443
├── postgresql :5432 (localhost only)
│
├── ai.sshsys.kr → :8001 hssagent (기존)
├── sshsys.kr → :3000 portfolio web (Next.js)
├── api.sshsys.kr → :8002 portfolio api (FastAPI)
└── docs.sshsys.kr → :8003 docconv (지식 플랫폼)
4개의 도메인, 3개의 새 서비스. 각각 격리된 venv·systemd 유닛·DB를 가진다.
첫 함정: 공유 venv 충돌
처음에 시간을 아끼려고 portfolio 백엔드와 docconv를 같은 venv에 깔았다. 곧장 깨졌다.
fastapi.exceptions.FastAPIError: Invalid args for response field!
Hint: check that ForwardRef('UploadFile') is a valid Pydantic field type.portfolio가 FastAPI 0.115.0에 핀되어 있는데, docconv는 0.136.1을 기대해서 한 venv에 두 버전이 충돌. 결국 venv를 분리했다.
/var/www/sshsys/
├── venv/ ← portfolio 전용 (FastAPI 0.115.0)
└── docconv/venv/ ← docconv 전용 (FastAPI 0.136.1)
당연한 듯하지만 한 번 합쳐봐야 절실해진다. 운영 환경에서 venv 공유는 절대 비추.
Pydantic Settings의 까다로움
portfolio 백엔드의 config.py는 pydantic-settings로 환경변수를 관리한다. 그런데 docconv 연동을 위해 .env에 DOCCONV_URL·DOCCONV_INTERNAL_TOKEN·PUBLIC_SITE_URL을 추가하니 시작도 못 했다.
pydantic_core._pydantic_core.ValidationError: 4 validation errors for Settings
public_site_url
Extra inputs are not permitted해결: extra="ignore" 옵션 추가. 이러면 라우터에서 os.environ.get(...)로 직접 읽는 변수들이 Settings에 등록되어 있지 않아도 거부되지 않는다.
model_config = SettingsConfigDict(
env_file=".env",
case_sensitive=False,
extra="ignore",
)빌드 시점에 SSL 미발급 도메인 호출하는 함정
가장 까다로웠던 부분. 프론트엔드를 빌드할 때 Next.js가 getStaticProps를 실행하면서 https://api.sshsys.kr에 axios로 fetch한다. 그런데 그 시점엔 nginx에 api.sshsys.kr 가상호스트도, SSL 인증서도 없었다. 그래서 같은 IP의 다른 호스트의 인증서가 응답하면서 호스트 불일치 에러.
Error: Hostname/IP does not match certificate's altnames:
Host: api.sshsys.kr is not in the cert's altnames:
DNS:ai.sshsys.kr, DNS:<other-service>
해결책은 서버사이드용 API URL과 브라우저용 API URL을 분리하는 것이다.
// src/lib/api.js
export const API_URL =
(typeof window === "undefined"
? process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL
: process.env.NEXT_PUBLIC_API_URL) || "http://localhost:8000";.env.local:
NEXT_PUBLIC_API_URL=https://api.sshsys.kr # 브라우저
INTERNAL_API_URL=http://127.0.0.1:8002 # 빌드/SSR
빌드 시점은 nginx 거치지 않고 백엔드 직접 호출. 브라우저는 공개 도메인. nginx 설정 순서에 빌드가 종속되지 않는다. 같은 정보가 두 경로로 흘러야 할 때 분리하는 패턴은 다른 곳에서도 쓸 수 있다.
docconv 사이드카 — pgvector로 단순화
이 사이트에는 시멘틱 검색·Q&A·엔티티 자동 추출을 담당하는 별도 서비스가 떠 있다. docconv라는 지식 플랫폼인데, 처음엔 ChromaDB를 쓸 줄 알았다.
그런데 docconv의 store는 PostgreSQL + pgvector 백엔드를 지원한다. 이미 portfolio 백엔드용 Postgres가 있으므로, 같은 인스턴스에 docconv DB만 추가하면 끝. ChromaDB가 자체 SQLite + DuckDB 메모리를 따로 잡는 것 대비 메모리 풋프린트가 작다.
{"status":"ok","backend":"postgresql+pgvector",
"collections":{"chunks":0,"entities":0},
"embedding_model":"text-embedding-3-small"}대기 메모리 140MB. ChromaDB 구성이었으면 250MB 이상 잡았을 것.
portfolio ↔ docconv 연결
두 서비스 사이엔 내부 토큰 한 줄로 끝낸다. portfolio가 글을 저장하면 background_task로 docconv의 /wiki/ingest를 호출. docconv가 본문을 청킹·임베딩·엔티티 추출 후 entity 슬러그를 반환하면, portfolio가 그걸 DB의 entities JSONB 컬럼에 저장한다.
# portfolio backend/app/routers/blogs.py
def _index_blog(blog_id: str, payload: dict) -> None:
entities = knowledge_client.ingest(
kind="blog",
doc_id=blog_id,
title=payload.get("title") or "",
content_html=payload.get("content") or "",
url=f"{SITE_URL}/blog/{blog_id}",
tags=payload.get("tags") or [],
category=payload.get("category"),
)
if entities is None:
return
db = SessionLocal()
try:
b = db.query(Blog).filter(Blog.id == blog_id).first()
if b:
b.entities = entities or None
db.commit()
finally:
db.close()nginx에선 /wiki/ingest·/wiki/remove·/extract 같은 인덱싱 엔드포인트는 127.0.0.1만 허용하고, 검색·Q&A는 공개. 이중 격리.
메모리 측정 결과
4개 서비스가 동시에 떠 있는 정상 운영 시:
sshsys-docs (docconv) 139.6 MB
sshsys-backend (hssagent) 202.7 MB
sshsys-api (portfolio) 215.5 MB
sshsys-web (Next.js) 70~150 MB
─────────────────────────────────────
약 700 MB2GB VPS에 충분히 들어간다. 빌드 시점만 잠깐 1GB 가까이 점유하는데, 그때만 docconv를 일시 중단하면 안전하다.
이 글이 사이트에 첫 콘텐츠로 올라가면, docconv가 자동으로 entity를 뽑아낸다. /topics/fastapi·/topics/nginx·/topics/pgvector 같은 페이지가 자동 생성될 것이다.


