아무도 추천하지 않은 Stack

발행: (2026년 4월 5일 PM 08:41 GMT+9)
21 분 소요
원문: Dev.to

Source: Dev.to

Source:

백엔드: FastAPI

저는 JavaScript와 TypeScript 배경을 가지고 있습니다—프론트엔드에서는 수년간 React를, 백엔드에서는 Express와 Fastify를 사용했습니다. 이 프로젝트를 Python(왜냐하면 AI/ML 생태계가 그곳에 있기 때문)으로 하기로 결정했을 때, 낯설지 않은 무언가가 필요했습니다.

FastAPI는 바로 마음에 들었습니다. async/await 모델, 데코레이터 기반 라우팅, 실제로 동작하는 타입 힌트 덕분에 Python에서 Fastify를 쓰는 느낌이었습니다. 이 친숙함이 전부는 아니었지만, 그렇지 않다고 말하기는 어려웠습니다.

기술적인 이유도 충분히 설득력이 있었습니다. 시스템은 n8n으로부터 오는 동시 웹훅 콜백, React 대시보드의 실시간 폴링, 그리고 PostgreSQL에 대한 지속적인 asyncpg 연결을 처리합니다. 이 모든 것이 async I/O이며, FastAPI는 바로 그 패턴을 중심으로 설계되었습니다. Django도 이제 async 지원을 제공하지만, 여전히 사후에 추가된 느낌이 들어 설계 단계부터 고려된 것은 아닙니다.

또한 저는 의도적으로 ORM 사용을 피했습니다. 시스템의 모든 쿼리는 asyncpg를 통해 직접 작성한 SQL입니다. 9개 도메인에 걸쳐 95개 이상의 테이블이 있기 때문에, 데이터베이스에 정확히 어떤 쿼리가 전달되는지 직접 보고 싶었습니다—마법 같은 처리, N+1 문제, 혹은 제가 읽어보지 않은 SQL을 생성하는 마이그레이션 프레임워크 없이 말이죠.

Django를 건너뛰면서 치른 대가

  • 무료 관리자 패널이 없음 – 처음부터 React 대시보드를 직접 만들었으며, 이는 몇 주가 걸렸습니다.
  • 내장 마이그레이션 시스템이 없음 – 스키마 변경을 raw SQL 파일로 관리하고, 이를 SSH를 통해 Docker에 파이프하는 방식을 사용합니다. 이 과정에서 한두 번은 (SSH → Docker → psql을 거치면서 쉘 인용 문제가 복잡한 문장을 망가뜨리는) 문제에 부딪혔습니다.
  • Django가 20년 동안 제공해 온 기능을 필요로 할 때, 플러그인 생태계가 상대적으로 얇음.

사용자 계정, 관리자 패널, 폼이 포함된 웹 앱을 만든다면, 그냥 Django를 사용하세요.
FastAPI는 백엔드가 서비스 간 조정을 담당하는 API 레이어일 때 의미가 있습니다. 이는 제 상황에 해당합니다.

The Database: PostgreSQL

이것은 어려운 결정이 아니었습니다. 내 데이터는 깊게 관계형입니다: 거래는 은행 계좌와 연결되고, 이메일 분류는 메시지를 참조하며, 지식 사실은 여러 출처에 걸쳐 강화되고, 스케줄러 작업은 모델을 참조하는 에이전트를 참조합니다. 이를 MongoDB에서 구현하려면 모든 것을 비정규화하고, 문서 안에 문서를 중첩시키며, 일관성을 수동으로 처리해야 합니다.

PostgreSQL은 단순히 관계형 저장소 이상의 기능을 제공했으며, 이는 매우 중요한 역할을 했습니다.

LISTEN/NOTIFY

보통 메시지 큐가 필요할 상황을 대체했습니다. 이메일이 분류될 때 트리거가 알림을 발생시킵니다. 뇌 서비스는 asyncpg를 통해 밀리초 단위로 이를 포착하고 반응합니다. Kafka도 RabbitMQ도 필요 없습니다—수년간 PostgreSQL에 내장된 기능입니다:

CREATE OR REPLACE FUNCTION notify_email_classified()
RETURNS TRIGGER AS $$
BEGIN
    PERFORM pg_notify(
        'email_classified',
        json_build_object(
            'id', NEW.id,
            'category', NEW.category,
            'urgency', NEW.urgency
        )::text
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

내 규모(시간당 50‑100 이벤트 정도)에서는 충분히 충분합니다. Kafka를 추가하면 또 다른 컨테이너, 또 다른 설정을 유지해야 하고, 새벽 3시에 문제가 발생할 가능성도 늘어납니다. 실제로 필요해질 때 추가하겠습니다.

CHECK Constraints

전체 프로젝트에서 가장 좋은 결정 중 하나입니다. 데이터베이스가 AI가 출력할 수 있는 카테고리를 강제합니다:

category VARCHAR(50) CHECK (category IN (
    'billing', 'shipping', 'subscription',
    'employment', 'legal', 'marketing',
    'personal', 'automated' ...
));

LLM은 때때로 지시를 무시합니다. 추출기가 허용된 목록에 없는 카테고리를 만든 적이 있었고, INSERT가 실패했습니다. 바로 이런 상황이 발생해야 합니다: 큰 오류는 잘못된 카테고리로 데이터가 조용히 오염되는 것보다 훨씬 낫습니다.

Window Functions & Interval Queries

속도 제한, 쿨다운, 회로 차단기 등을 구현하는 데 사용합니다—보통 Redis를 사용하던 작업들입니다. 스택에 컨테이너가 하나 줄어듭니다.

MongoDB가 이길 수 있는 경우: 가변 스키마를 가진 진정한 문서 형태 데이터(예: CMS 콘텐츠, 이질적인 사용자 프로필, 서로 다른 페이로드를 가진 이벤트 로그). 내 데이터는 이러한 유형에 해당하지 않습니다.

Source:

The Workflow Engine: n8n

이 결정에 대해 가장 복잡한 감정을 가지고 있습니다.

n8n은 자체 호스팅이 가능한 시각적 워크플로 편집기입니다. 트리거, HTTP 요청, 데이터베이스 쿼리, 코드 노드를 연결합니다. 이메일 파이프라인에서는 흐름을 다이어그램으로 볼 수 있다는 점이 실제로 큰 가치가 있습니다. 무언가가 깨지면 어느 단계에서 실패했는지, 어떤 데이터를 가지고 있었는지를 정확히 확인할 수 있습니다.

자체 호스팅이라는 점 때문에 Zapier와 Make는 즉시 제외되었습니다. 워크플로는 이메일 본문과 재무 데이터를 처리하므로 제3자를 거칠 수 없습니다. n8n의 코드 노드를 사용하면 JavaScript를 직접 워크플로 단계에 삽입할 수 있어 Ollama 호출을 위한 복잡한 JSON 페이로드를 만들 수 있습니다.

Pain points

  • Production incidents – n8n이 기본적으로 동시 실행을 방지하지 않기 때문에 겹치는 스케줄 워크플로가 발생합니다. 이전 실행이 아직 진행 중인지 확인하기 위해 데이터베이스 수준의 가드를 직접 구축해야 했습니다.
  • Silent truncation – API가 오류 없이 긴 SQL 쿼리를 조용히 잘라냅니다.
  • Sandbox quirks – 코드 노드는 process.env가 존재하지 않는 샌드박스된 V8 격리 환경에서 실행됩니다(대신 $env를 사용해야 함).
  • Fragile expressions – HTTP Request 표현식에서 JSON을 만드는 것은 취약합니다; 복잡한 페이로드는 항상 코드 노드를 통해 처리해야 합니다.

단점에도 불구하고 시각적인 특성과 커스텀 JavaScript를 삽입할 수 있는 능력 때문에 n8n은 현재 스택에 남아 있습니다. 만약 고통이 이점을 초과한다면, Temporal이나 맞춤형 오케스트레이터와 같은 보다 프로그래밍적인 솔루션으로 교체하는 것을 고려할 것입니다.

Source:

de first

None of these are dealbreakers individually. But collectively, n8n demands a level of defensive programming that I didn’t expect from a workflow tool. Every workflow that involves an LLM call now has a stacking check, every SQL query gets verified after deployment, and I’ve learned to build payloads in Code nodes instead of expression fields.

번역:
이 항목들 각각이 치명적인 문제는 아니지만, 모두 합치면 n8n이 워크플로우 도구에서 기대하지 않았던 수준의 방어적 프로그래밍을 요구합니다. 이제 LLM 호출이 포함된 모든 워크플로우는 스택 검사를 거치고, 모든 SQL 쿼리는 배포 후 검증되며, 표현식 필드 대신 Code 노드에서 페이로드를 구축하는 법을 배우게 되었습니다.

If your workflows are mostly code with minimal visual benefit, write Python scripts with a scheduler. The visual editor is n8n’s actual advantage. If you don’t need it, you’re adding complexity for nothing.

번역:
워크플로우가 대부분 코드이고 시각적 이점이 거의 없다면, 스케줄러와 함께 파이썬 스크립트를 작성하세요. 시각 편집기가 n8n의 실제 장점이므로 필요하지 않다면 불필요하게 복잡성을 더하는 셈입니다.

Local LLM Serving: Ollama

Ollama won on simplicity and nothing else. Install it, run

ollama pull qwen3:14b

and a model‑serving API appears on localhost:11434. No CUDA configuration, no Python environment management, no Docker GPU‑passthrough headaches.

Switching between models is just changing one string in the request payload. The API is consistent across every model (/api/chat, /api/generate, /api/embed), which makes the routing logic in my system trivial.

What I gave up:
vLLM offers tensor parallelism, continuous batching, and quantisation control that Ollama hides behind its abstraction. For a platform serving many concurrent users, vLLM is the right choice. For a single‑user system running one model at a time on a Mac mini, Ollama’s defaults are fine, and the setup‑time difference is measured in hours.

번역:

로컬 LLM 서빙: Ollama

Ollama는 단순함에서 승리했으며 그 외에는 없습니다. 설치하고 다음을 실행합니다.

ollama pull qwen3:14b

그러면 localhost:11434에 모델 서빙 API가 나타납니다. CUDA 설정, 파이썬 환경 관리, Docker GPU 패스스루와 같은 번거로운 작업이 필요 없습니다.

모델을 전환하려면 요청 페이로드의 문자열 하나만 바꾸면 됩니다. API는 모든 모델에서 (/api/chat, /api/generate, /api/embed) 동일하게 동작하므로 시스템의 라우팅 로직이 매우 간단해집니다.

포기한 것:
vLLM은 텐서 병렬성, 연속 배치, 양자화 제어 등을 제공하지만 Ollama는 이를 추상화 뒤에 숨깁니다. 다수의 동시 사용자를 지원하는 플랫폼이라면 vLLM이 적합합니다. 반면 한 번에 하나의 모델만 실행하는 Mac mini 같은 단일 사용자 시스템에서는 Ollama의 기본 설정으로 충분하며, 설정 시간 차이는 몇 시간 수준에 불과합니다.

Communication: Mattermost (For Now)

I need human‑in‑the‑loop (HITL) approval for every consequential action. The system posts to a chat with context and Approve/Reject buttons. I click, a webhook fires, and the workflow continues.

I picked Mattermost because it’s open‑source, self‑hosted, and has interactive message attachments. That was the full evaluation – it wasn’t strategic. It was “this runs in Docker and has buttons.”

It works, but I’m planning to migrate to Rocket.Chat. I want voice interaction with the assistant eventually, and Mattermost’s audio calling is limited. Rocket.Chat also has more mature mobile apps, which matters because the whole point of HITL is approving actions when I’m not at my desk.

번역:

커뮤니케이션: Mattermost (현재)

중요한 모든 작업에 대해 인간‑인‑루프(HITL) 승인이 필요합니다. 시스템은 컨텍스트와 Approve/Reject 버튼이 포함된 채팅에 메시지를 보내고, 제가 클릭하면 웹훅이 트리거되어 워크플로우가 계속됩니다.

Mattermost를 선택한 이유는 오픈소스이며 자체 호스팅이 가능하고, 인터랙티브 메시지 첨부 기능이 있기 때문입니다. 이것이 전부 평가 기준이었으며, 전략적인 이유는 아니었습니다. “Docker에서 실행되고 버튼이 있다”는 점이 결정적이었습니다.

동작은 하지만, 곧 Rocket.Chat으로 마이그레이션할 계획입니다. 최종적으로는 어시스턴트와 음성 인터랙션을 원하고, Mattermost의 오디오 통화 기능은 제한적이기 때문입니다. Rocket.Chat은 모바일 앱이 더 성숙해 있어, 책상에 없을 때도 HITL을 통해 작업을 승인할 수 있다는 점이 중요합니다.

Networking: Tailscale

Three machines need to talk to each other. Tailscale gives each one a stable IP that works regardless of the physical network. No port‑forwarding, no dynamic DNS, no opening ports on my router. Setup took about 10 minutes.

I could have configured WireGuard manually for the same encryption and performance, but then I’d be managing key rotation, endpoint configs, and NAT traversal myself. For a three‑node network, Tailscale’s convenience is worth it.

One thing people ask: why not Cloudflare Tunnels? Because they solve a different problem. Cloudflare Tunnels expose services to the internet through Cloudflare’s network. My services don’t need to be on the internet; they need to talk to each other privately – a mesh VPN, not a reverse proxy.

번역:

네트워킹: Tailscale

세 대의 머신이 서로 통신해야 합니다. Tailscale은 물리적 네트워크와 무관하게 작동하는 안정적인 IP를 각 머신에 할당합니다. 포트 포워딩, 동적 DNS, 라우터 포트 개방이 필요 없습니다. 설정은 약 10분 정도 걸렸습니다.

같은 암호화와 성능을 위해 WireGuard를 수동으로 설정할 수도 있었지만, 그 경우 키 회전, 엔드포인트 설정, NAT 트래버설 등을 직접 관리해야 합니다. 세 노드 네트워크에서는 Tailscale의 편리함이 충분히 가치가 있습니다.

자주 묻는 질문: 왜 Cloudflare Tunnels를 안 쓰나요?
답은 문제의 성격이 다르기 때문입니다. Cloudflare Tunnels는 서비스를 인터넷에 노출시키는 반면, 내 서비스는 인터넷에 공개될 필요 없이 서로 비공개로 통신해야 합니다—즉, 메쉬 VPN이며 리버스 프록시가 아닙니다.

Search: Elasticsearch (Added Later)

I didn’t start with Elasticsearch. I began with ChromaDB because it’s lighter, runs in Docker, has a simple Python API, and is good enough for basic vector search.

The problem appeared when the knowledge base grew. I had thousands of facts, entities, and patterns, and I needed to search by meaning and by exact keywords in the same query. ChromaDB handles vectors; PostgreSQL handles keywords. Running two searches across two systems and merging results is fragile and slow.

Elasticsearch does both natively (BM25 for exact keyword matching, k‑NN for vector similarity) in a single query. That’s why I migrated. The trade‑off is ~4 GB of heap memory on a machine that was already tight. For smaller datasets or pure vector search, ChromaDB or pgvector are lighter options.

I’ll cover the migration in a dedicated post.

번역:

검색: Elasticsearch (추가됨)

처음에는 Elasticsearch를 사용하지 않았습니다. ChromaDB로 시작했는데, 가볍고 Docker에서 실행되며 간단한 파이썬 API를 제공하고 기본 벡터 검색에 충분했기 때문입니다.

지식 베이스가 커지면서 문제가 발생했습니다. 수천 개의 사실, 엔터티, 패턴이 있었고, 의미 기반 검색과 정확한 키워드 검색을 동시에 수행해야 했습니다. ChromaDB는 벡터를, PostgreSQL은 키워드를 처리합니다. 두 시스템에서 각각 검색하고 결과를 병합하는 방식은 취약하고 느립니다.

Elasticsearch는 단일 쿼리에서 두 가지를 모두 지원합니다(BM25는 정확한 키워드 매칭, k‑NN은 벡터 유사도). 그래서 마이그레이션을 결정했습니다. 단점은 이미 메모리가 부족했던 머신에서 약 4 GB의 힙 메모리를 추가로 사용한다는 점입니다. 작은 데이터셋이나 순수 벡터 검색이라면 ChromaDB나 pgvector가 더 가벼운 선택입니다.

마이그레이션 과정은 별도의 포스트에서 다루겠습니다.

내가 다르게 할 것

배포. 현재 나는 Windows 호스트에 SSH로 접속해 PowerShell 명령을 실행하여 배포하고 있다. CI/CD도 없고 GitHub Actions도 없다. 내가 유일한 개발자라서 작동은 하지만, 다른 사람이 기여하려 하면 가장 먼저 부서질 부분이다.

다시 시작한다면, 첫날부터 Linux를 사용하고 기본적인 GitHub Actions 파이프라인을 구축할 것이다: main에 푸시 → 컨테이너 빌드 → 배포. Kubernetes도, Terraform도 아니다—현재 수동으로 실행하고 있는 90초 스크립트를 자동화하는 것뿐이다.

이 글은 “One Developer, 22 Containers”의 Part 2이다. 다음 주제는 ChromaDB에서 Elasticsearch로 마이그레이션하고, 하이브리드 검색이 내 AI 시스템의 정보 탐색 방식을 어떻게 바꾸었는지다.

비슷한 선택을 했든, 다른 선택을 했든, 댓글로 의견을 공유해 주세요. GitHub에서 찾아볼 수 있습니다.

0 조회
Back to Blog

관련 글

더 보기 »

Python에서 행렬

행렬 정의 python matrix = 1, 2, 3, 4, 5, 6, 7, 8, 9 3x3 행렬 만들기 python matrix_3x3 = 0 3 for in range3 일반적인 행렬 문제 행렬 전치…

config.py 설정

모든 프로젝트는 같은 방식으로 시작합니다… 몇 가지 값을 hardcode하고, os.getenv 호출을 여기저기 뿌린 뒤, “나중에 정리하겠어”라고 스스로에게 말합니다. 나중은 결코 오지 않습니다. In...