10년 된 노트북으로 비서를 고용하기 (3) — Ollama와 n8n 올리기
10년 된 노트북으로 비서를 고용하기
- 서버 만들기 — 오래된 노트북에 Ubuntu Server 올리기
- SSH 보안 설정과 Docker — SSH·방화벽 기본과 Docker
- Ollama와 n8n 올리기 — 로컬 모델과 자동화 도구 ← 지금 글
- 비서 고용 — 외부 접속 경로와 도구를 쓰는 봇
서론
지난 편에서 SSH 접속을 키 인증으로만 제한하고 Docker를 설치했습니다.
이번 편에서는 그 위에 도구 두 가지를 올립니다. 언어 모델을 로컬에서 실행하는 Ollama와, 정해진 시간에 그 모델을 호출해줄 자동화 도구 n8n입니다. 그리고 마지막에 둘을 한 번 이어봅니다. 비서를 만들기 전에 두 도구가 실제로 연결되는지부터 확인하는 단계입니다.
본론
1. compose 파일 하나로 시작하기
docker run 대신 compose를 고른 이유
docker로 컨테이너를 띄워 사용하는 방법은 두 가지 입니다.
- 인라인 명령어로 관련 옵션들을 함께 호출하는 방법.
- 설정 파일(yaml)에 관련 옵션들을 정의 후 파일 기반으로 명령어 호출
사실, 1번 방법이 잠깐 테스트 해보거나 이런 글에서 예시로 보여주기 위해서는 간편하고 좋습니다. 다만, 같은 옵션값을 매번 설정해주어야하기 때문에, 번거로운 단점이 있습니다. 이러한 방법 말고 위에서 이야기한 yaml 설정 파일로 컨테이너를 띄우는 방식을 docker compose라고 합니다. 저는 지속적으로 같은 옵션을 사용해야 했기에 docker compose를 사용했습니다.
ollama와 n8n을 설치 및 셋팅하기 위해서 아래와 같이 yaml 파일을 구성하였으며, 각 옵션들에 대한 설명을 해두었습니다.
# ~/home-server/compose.yaml
# version: 3.8
services:
ollama:
image: ollama/ollama:latest
container_name: ollama
restart: unless-stopped
ports:
- "127.0.0.1:11434:11434"
volumes:
- ./ollama:/root/.ollama
n8n:
image: docker.n8n.io/n8nio/n8n
container_name: n8n
restart: unless-stopped
ports:
- "<서버 고정 IP>:5678:5678"
environment:
- N8N_PORT=5678
- N8N_PROTOCOL=http
- NODE_ENV=production
- GENERIC_TIMEZONE=Asia/Seoul
- TZ=Asia/Seoul
- N8N_SECURE_COOKIE=false
volumes:
- ./n8n:/home/node/.n8n
depends_on:
- ollamacompose 파일을 읽기 전에
용어compose 파일의 항목들
services: 아래에 서비스를 하나씩 적고, 그 안에 어떻게 띄울지를 씁니다. 위 파일에 쓴 것만 모으면 이렇습니다.
| 항목 | 뜻 |
|---|---|
image | 어떤 이미지로 띄울지. 이름:태그 형식이고 태그를 생략하면 latest |
container_name | 컨테이너에 붙일 이름. 안 쓰면 프로젝트명-서비스명-1처럼 자동 생성됩니다 |
restart | 죽거나 재부팅됐을 때 다시 띄울지 |
ports | 호스트의 어느 포트로 내보낼지. 앞에 붙인 주소가 왜 필요한지는 3절에서 |
volumes | 호스트의 어느 경로를 컨테이너 안에 연결할지 |
environment | 컨테이너 안에 넣어줄 환경변수 |
depends_on | 먼저 시작할 서비스 |
services: 아래 붙인 이름(ollama, n8n)이 두 가지 역할을 합니다. compose가 부르는 서비스 이름이면서, 동시에 컨테이너끼리 서로를 부를 때 쓰는 호스트명이 됩니다. 뒤에서 다시 나옵니다.
version: '3.8' 같은 줄은 이제 적지 않습니다. 예전 예제에는 파일 맨 위에 거의 항상 붙어 있는데, Compose V2부터는 이 필드를 읽지 않고 경고만 출력합니다.
용어volumes — 바인드 마운트와 명명 볼륨
컨테이너 안에 쓴 파일은 컨테이너를 지우면 같이 사라집니다. 남겨야 하는 것은 밖으로 빼둬야 하고, 그 방법이 두 가지입니다.
volumes:
- ./ollama:/root/.ollama # 바인드 마운트 — 호스트의 실제 경로
- ollama-data:/root/.ollama # 명명 볼륨 — Docker가 관리하는 저장소| 방식 | 실제 위치 | 성격 |
|---|---|---|
| 바인드 마운트 | 내가 지정한 경로 (./ollama) | ls로 바로 보이고 백업하기 쉽습니다 |
| 명명 볼륨 | /var/lib/docker/volumes/... | Docker가 관리합니다. 경로를 신경 쓸 필요가 없습니다 |
이 서버는 바인드 마운트를 사용해보려고 합니다. 모델 파일이나 워크플로처럼 남아야 하는 것이 어느 경로에 떨어지는지를 제가 직접 정해야 하기 때문입니다. Docker가 알아서 두는 자리에 맡기면 그걸 통제할 수 없습니다.
./ollama의 .은 compose 파일이 있는 디렉터리를 뜻합니다. ~/home-server/compose.yaml에 적었으니 ~/home-server/ollama/가 됩니다.
용어restart 정책 네 가지
컨테이너가 죽거나 서버가 재부팅됐을 때 어떻게 할지를 정합니다.
| 값 | 동작 |
|---|---|
no | 아무것도 하지 않습니다. 기본값 |
on-failure | 비정상 종료일 때만 다시 띄웁니다 |
always | 항상 다시 띄웁니다. docker stop으로 직접 멈춰도 재부팅하면 다시 뜹니다 |
unless-stopped | 항상 다시 띄우되, 직접 멈춘 것은 그대로 둡니다 |
always와 unless-stopped의 차이가 실제로 갈리는 지점은 재부팅입니다. 점검하려고 컨테이너를 내려둔 상태에서 서버를 재부팅하면, always는 제 의도를 무시하고 다시 띄웁니다. unless-stopped는 내려둔 채로 둡니다.
그래서, 이 서버는 unless-stopped를 씁니다. 홈서버는 전원이 나갔다 들어오는 일이 있어서 자동으로 복구되어야 하지만, 제가 일부러 멈춘 것까지 되살아나면 곤란하기 때문입니다.
주의주의 — 이 파일에서 처음에 틀렸던 값들
위 파일은 처음부터 이렇게 적었던 것이 아닙니다. 동작은 했지만 나중에 문제가 될 값이 섞여 있었고, 네 가지를 고친 뒤의 상태입니다.
① N8N_HOST=0.0.0.0 — 지웠습니다. “모든 IP의 접근을 허용한다”는 뜻으로 알고 넣었는데, 이 값은 n8n이 자기 주소를 만들 때 쓰는 기준값입니다. 그대로 두면 웹훅 주소가 http://0.0.0.0:5678/webhook/...이 됩니다. 0.0.0.0은 “모든 인터페이스”라는 뜻의 특수 표기라 목적지 주소로는 쓸 수 없습니다. 접속 허용 범위를 정하는 것은 포트 바인딩과 방화벽이지 이 값이 아닙니다. 지우면 n8n이 요청에서 호스트를 추론합니다. 이름이 하는 일을 말해주지 않는 설정값이 있습니다.
② 시간대는 두 군데를 맞춰야 합니다. 처음에는 GENERIC_TIMEZONE만 넣어두었습니다.
| 변수 | 무엇을 정하나 |
|---|---|
GENERIC_TIMEZONE | n8n 스케줄러가 해석하는 시간대 |
TZ | 컨테이너 운영체제의 시간대 |
컨테이너는 기본이 UTC이고, 호스트가 KST여도 따라오지 않습니다. TZ가 UTC로 남아 있으면 스케줄은 제시간에 도는데 로그 타임스탬프가 9시간 어긋나고, Code 노드에서 new Date()로 날짜를 계산하면 자정 근처에서 하루가 밀립니다. 매일 남은 일정을 정리하는 비서라면 두 번째가 치명적인데, 스케줄이 제시간에 돌기 때문에 한동안 눈치채지 못합니다.
③ depends_on은 준비를 기다려주지 않습니다. “Ollama가 켜진 것을 확인한 뒤 n8n이 실행된다”는 뜻으로 이해했는데, 실제로는 컨테이너가 시작됐는지만 봅니다. 그 안의 프로세스가 요청을 받을 준비가 됐는지는 확인하지 않습니다. 실제로 기다리게 하려면 두 가지가 필요합니다.
ollama:
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 10s
retries: 5
n8n:
depends_on:
ollama:
condition: service_healthy흔히 보이는 예제는 test에 curl을 쓰는데, ollama/ollama 이미지에는 curl이 들어 있지 않습니다. 그대로 쓰면 판정이 영원히 실패하고 컨테이너는 unhealthy로 남습니다. 이미지 안에 확실히 있는 ollama 명령 자체를 판정에 쓰는 편이 안전합니다.
healthcheck가 “준비됐는지 판정하는 방법”을 정의하고, condition: service_healthy가 “그 판정이 통과할 때까지 기다려라”가 됩니다. 지금은 넣지 않았습니다. n8n이 Ollama를 부르는 시점이 기동 직후가 아니라 워크플로 실행 시점이라 실제로 문제가 되지 않기 때문입니다. 다만 알고 두는 것과 모르고 두는 것은 다릅니다.
④ N8N_SECURE_COOKIE=false — 일부러 낮춰둔 값입니다. n8n은 로그인 세션 쿠키에 Secure 속성을 붙이고, 그러면 브라우저는 HTTPS로 접속할 때만 그 쿠키를 보냅니다. 지금은 집 안에서 http://<서버 고정 IP>:5678로 접속하니, 로그인이 되는 것 같은데 계속 로그인 화면으로 돌아오는 상태가 됩니다. 그래서 껐습니다. 같이 있는 N8N_PROTOCOL=http도 같은 성격입니다. 임시로 낮춰둔 것은 낮춰뒀다고 적어두어야 합니다. 마지막 편에서 외부 접속을 열고 HTTPS를 붙이는 날 두 줄 다 되돌려야 합니다.
주의컨테이너 안의 localhost 는 다른 곳입니다
컨테이너는 각 컨테이너별로 네트워크 공간을 가집니다. 그래서 컨테이너 안에서 localhost는 그 컨테이너 자신을 가리킵니다. 호스트도 아니고 옆 컨테이너도 아닙니다.
curl http://localhost:11434 # 호스트에서 — 됩니다
docker exec n8n wget -qO- http://localhost:11434 # n8n 안에서 — 안 됩니다n8n 컨테이너 안에 11434를 듣고 있는 것이 없기 때문입니다. 부를 때는 서비스 이름을 씁니다.
docker exec n8n wget -qO- http://ollama:11434 # 됩니다여기서 curl이 아니라 wget인 이유가 있습니다. n8n 이미지는 Alpine 기반이라 curl이 들어 있지 않습니다. docker exec n8n curl ...은 접속 실패가 아니라 executable file not found로 끝나서, 네트워크 문제라고 오해하기 딱 좋은 자리입니다. Alpine에 기본으로 있는 wget을 씁니다.
Compose가 프로젝트마다 네트워크를 하나 만들고 서비스 이름을 그 네트워크의 호스트명으로 등록해주기 때문에 가능한 일입니다. 별도로 IP를 알아낼 필요가 없고, 컨테이너를 다시 띄워 IP가 바뀌어도 이름은 그대로입니다.
두 서비스를 굳이 한 파일에 둔 이유가 여기 있습니다. 같은 프로젝트에 있어야 같은 네트워크에 들어갑니다.
호스트 자신을 가리켜야 한다면 host.docker.internal이라는 별도의 이름을 씁니다. 다만 이 이름은 Docker Desktop(맥·윈도우)에서만 기본으로 붙습니다. 지금처럼 리눅스에 설치한 Docker Engine에서는 해당 서비스에 아래를 직접 적어줘야 이름이 생깁니다.
extra_hosts:
- "host.docker.internal:host-gateway"이 글에서 반복해서 쓰는 docker 명령
| 명령 | 하는 일 |
|---|---|
docker compose up -d | compose 파일대로 띄웁니다. 파일을 고친 뒤 다시 실행하면 바뀐 것만 다시 만듭니다 |
docker compose down | 이 프로젝트의 컨테이너를 내립니다. 볼륨은 남습니다 |
docker compose stop / start | 컨테이너를 지우지 않고 멈추거나 다시 시작합니다 |
docker stop <컨테이너> | 컨테이너 하나를 멈춥니다. restart 정책 설명에 나오는 “직접 멈춘다”가 이것입니다 |
docker compose logs -f <서비스> | 로그를 이어서 봅니다 |
docker ps | 지금 떠 있는 컨테이너 목록 |
docker logs <컨테이너> | 그 컨테이너의 로그 |
docker exec <컨테이너> <명령> | 컨테이너 안에서 명령을 실행합니다 |
docker stats | 컨테이너별 CPU·메모리 사용량을 실시간으로 |
-d는 detached, 터미널을 붙잡지 않고 뒤로 보내라는 뜻입니다. 이게 없으면 로그가 화면에 계속 흐르고, 터미널을 닫으면 컨테이너도 내려갑니다.
docker exec가 이 글에서 특히 자주 나옵니다. Ollama는 컨테이너 안에서 도는 프로그램이라, 모델 목록을 보거나 명령을 주려면 밖에서 부를 방법이 필요합니다.
docker exec ollama ollama list # 컨테이너 안의 ollama 명령을 실행
docker exec -it ollama bash # 아예 셸을 열고 들어가기-it는 두 옵션을 붙여 쓴 것입니다. -i는 입력을 계속 받겠다는 뜻(interactive), -t는 터미널처럼 보이게 하라는 뜻(tty)입니다. 셸을 열 때는 둘 다 필요합니다.
2. Ollama — 로컬 모델 올리기
Ollama가 하는 일
Ollama는 언어 모델을 내려받아 로컬에서 실행하고, 그 결과를 HTTP API로 노출해주는 도구입니다. 모델 파일 관리, 양자화 형식 해석, 메모리 배분 같은 것을 알아서 처리해줍니다.
이런 도구가 필요한 이유는, 모델 가중치 파일 하나만 있어도 그것을 실행하려면 붙여야 할 것이 많기 때문입니다. Ollama는 그 과정을 명령 하나로 줄여줍니다.
docker exec ollama ollama pull gemma4:e2b
docker exec ollama ollama listNAME SIZE
gemma4:e2b 7.2 GB
gemma4:e4b 9.6 GB둘 다 텍스트뿐 아니라 이미지와 오디오까지 받는 멀티모달 모델이고, 컨텍스트는 128K입니다.
모델을 내려받기 전에
용어ollama 하위 명령 읽기
| 명령 | 하는 일 |
|---|---|
ollama pull <모델> | 모델을 내려받기만 합니다 |
ollama run <모델> | 없으면 받고, 이어서 대화를 시작합니다 |
ollama list | 받아둔 모델 목록 |
ollama ps | 지금 메모리에 올라와 있는 모델. 여기가 중요합니다 |
ollama rm <모델> | 모델 파일을 지웁니다 |
list와 ps가 헷갈리기 쉬운데, 리눅스의 ls와 ps 관계와 같습니다. list는 디스크에 있는 것, ps는 지금 돌고 있는 것입니다.
ollama ps에는 PROCESSOR라는 열이 있습니다. 그 모델이 지금 CPU로 돌고 있는지 GPU로 돌고 있는지를 비율로 보여줍니다.
측정할 때는 --verbose를 붙입니다.
docker exec -it ollama ollama run gemma4:e2b --verbose이걸 붙이면 응답이 끝난 뒤에 소요 시간과 속도가 함께 출력됩니다.
용어모델 이름 읽는 법 — gemma4:e2b
이름:태그 구조는 Docker 이미지와 같습니다. 콜론 뒤가 어떤 변형인지를 나타냅니다.
e2b, e4b의 b는 billion, 파라미터 개수입니다. 파라미터는 모델이 학습으로 얻은 숫자들이고, 이게 많을수록 대체로 품질이 좋고 대신 무겁습니다. 흔히 보는 1b, 7b, 70b가 그 숫자입니다.
앞에 붙은 e는 유효(effective) 파라미터를 뜻합니다. 전체 파라미터는 더 많은데, 돌릴 때 그만큼을 다 들고 있지 않아도 되도록 만든 구조라서 그 기준으로 이름을 붙인 것입니다.
| 태그 | 전체 파라미터 | 유효 파라미터 |
|---|---|---|
e2b | 약 5.1B | 약 2.3B |
e4b | 약 8B | 약 4.5B |
이게 가능한 이유는 레이어별 임베딩(PLE, Per-Layer Embedding)입니다. 각 디코더 레이어가 토큰마다 쓸 작은 임베딩을 따로 갖는 구조인데, 이 표들은 크기는 커도 조회에만 쓰이기 때문에 가속기 메모리에 함께 올려둘 필요가 없습니다. 그래서 “돌리는 데 드는 몫”이 전체 파라미터보다 작아집니다.
다만 반대 방향으로 착각하면 안 됩니다. 구글 문서도 정적 가중치를 올리는 데 필요한 총 메모리는 유효 파라미터 수가 시사하는 것보다 크다고 적어두었습니다. e2b라고 해서 일반적인 2B 모델만큼 가볍지는 않습니다.
디스크 용량, 로딩에 필요한 메모리, 추론 중에 실제로 쓰는 메모리가 전부 다릅니다. 얼마인지는 ollama ps와 로그로 확인해야 합니다.
용어양자화(quantization)란
같은 모델인데 파일 크기가 여러 가지로 배포되는 이유입니다.
모델의 파라미터는 원래 숫자 하나에 16비트 또는 32비트를 씁니다. 양자화는 그 정밀도를 낮춰 4비트나 8비트로 다시 저장하는 것입니다. 소수점 아래를 잘라내는 것과 비슷합니다.
- 파일이 작아집니다. 16비트를 4비트로 줄이면 대략 4분의 1이 됩니다
- 메모리를 적게 씁니다. 8GB RAM 같은 환경에서는 이게 되느냐 안 되느냐를 가릅니다
- 품질이 조금 떨어집니다. 다만 4비트 정도까지는 체감 차이가 크지 않다고 알려져 있습니다
q4_K_M 같은 꼬리표가 붙은 모델 이름을 본 적이 있다면 그게 양자화 방식 표시입니다. Q는 양자화, 4는 4비트, K는 압축 방식, M은 중간 품질이라는 뜻입니다.
Ollama는 태그를 생략하면 대체로 이 Q4_K_M 버전을 받아옵니다. 위에서 받은 gemma4:e2b도 그렇고, 목록의 7.2GB가 이미 양자화된 뒤의 크기입니다. 그래서 대부분 신경 쓸 일이 없지만, “파라미터 수가 같은데 왜 용량이 다르지” 싶을 때의 답이 보통 이것입니다.
이 노트북처럼 메모리가 빠듯한 환경에서는 양자화가 선택이 아니라 전제입니다.
3. n8n과 첫 파이프라인
n8n에 접속하기
docker compose up -d로 두 컨테이너를 올렸으면 브라우저로 n8n에 들어갑니다. 접속 주소는 1절 compose의 ports에 적은 그대로입니다.
2편과 마찬가지로 꺾쇠로 감싼 <서버 고정 IP>는 환경마다 다르고 직접 정해야 하는 값입니다. 그대로 복사하면 동작하지 않습니다.
ollama:
ports:
- "127.0.0.1:11434:11434" # 서버 자신에게만
n8n:
ports:
- "<서버 고정 IP>:5678:5678" # 집 안에서 브라우저로"5678:5678"처럼 앞자리를 생략하면 0.0.0.0, 즉 모든 네트워크 인터페이스에 열립니다. 대부분의 예제가 그렇게 적혀 있습니다.
Ollama는 호스트에 열 이유가 없습니다. n8n이 같은 Docker 네트워크 안에서 http://ollama:11434로 부르기 때문에 호스트 포트를 거칠 일이 없고, 11434에는 인증이 없어서 열어두면 집 안의 아무 기기나 모델을 부르고 지울 수 있습니다.
n8n 쪽에 적는 주소는 바뀌지 않는 것이어야 합니다. Docker는 존재하지 않는 주소에 바인딩할 수 없어서, DHCP로 받은 주소를 적어두면 임대가 갱신되는 날 컨테이너가 뜨지 못합니다. 공유기의 DHCP 예약(MAC 주소에 특정 주소를 묶어두는 기능)을 쓰거나, 서버에서 netplan 설정 파일(/etc/netplan/*.yaml)에 주소를 직접 지정합니다. 어느 쪽이든 공유기의 DHCP 할당 범위 밖에서 골라야 다른 기기와 겹치지 않습니다.
http://<서버 고정 IP>:5678로 접속하면 오너 계정을 만드는 화면이 나옵니다. 이후 만드는 워크플로와 자격 증명은 전부 ./n8n 볼륨에 저장되므로, 이 디렉터리를 잃으면 워크플로가 전부 사라집니다. 백업 대상 목록의 첫 줄에 올려두어야 할 디렉터리입니다.
주의주의 — Docker가 게시한 포트는 ufw로 막히지 않습니다
2편에서 ufw를 기본 거부 상태 그대로 두었으니 컨테이너 포트도 거기에 걸릴 것 같지만, 그렇지 않습니다.
sudo ufw deny 5678/tcp # 막아도여전히 접속됩니다. 규칙이 틀린 게 아니라 규칙에 도달하지 않습니다. Docker는 포트를 게시할 때 iptables에 규칙을 직접 넣는데, 그 규칙이 ufw의 규칙보다 앞에서 평가되기 때문입니다. 컨테이너가 수시로 뜨고 지는 동안 사용자의 방화벽 설정을 건드리지 않으려는 구조라 버그가 아니라 설계입니다.
그래서 ufw status가 보여주는 목록과 실제로 열려 있는 포트는 서로 무관합니다. 지금 무엇이 어느 주소를 잡고 있는지는 이 둘로 봅니다.
docker ps --format "table {{.Names}}\t{{.Ports}}" # Docker가 게시한 포트
sudo ss -tulpn # 실제로 듣고 있는 소켓저는 ufw allow를 넣고 브라우저가 열리는 것을 보고 끝났다고 생각했는데, 접속이 된 이유는 규칙을 넣어서가 아니라 넣기 전에도 이미 열려 있었기 때문이었습니다. 원하는 결과가 나온다는 것은 설정이 적용됐다는 증거가 못 됩니다. 확인하려면 막았을 때 실제로 막히는지를 봐야 합니다.
그래서 이번 편에서는 ufw 규칙을 정리하지 않았습니다. 무엇을 열고 막을지는 밖에서 들어올 경로가 정해지는 4편에서 함께 손봅니다.
파이프라인 만들기
매일 아침 10시에 Google News RSS를 읽어 로컬 모델이 요약하고 Discord로 보내는 흐름입니다. 비서를 만들기 전에 두 도구가 실제로 이어지는지를 확인하는 것이 목적이라, 입력은 인증이 필요 없는 RSS로 골랐습니다. ([ ]으로 감싸진 것은 n8n에서 실사용되는 노드의 이름입니다. 검색하여 선택하면 됩니다.)
[Schedule Trigger] 매일 아침 10시
▼
[RSS Read] Google News RSS → 아이템 20개
▼
[Filter] 오늘 것만 남김
▼
[Aggregate] 필요한 필드만 뽑아 아이템 하나로
▼
[Ollama] 하루치를 정리해 1900자 이내로 요약
▼
[HTTP Request] Discord 웹훅으로 한 번 전송① Schedule Trigger — 매일 10시
워크플로에는 시작점이 하나 있어야 합니다. Schedule Trigger 를 선택한 뒤에 아래와 같이 선택해주세요
Trigger Interval
-> Days
Days Between Triggers
-> 1
Trigger at Hour
-> 10am
Trigger at minute
-> 0② RSS Read — Google News
URL에 아래 주소를 적습니다. 검색어를 넣은 주소가 아니라 헤드라인 뉴스 피드입니다.
https://news.google.com/rss?hl=ko&gl=KR&ceid=KR:ko이 피드가 하루 치만 주지는 않습니다. 발행 시각이 제각각이라 최근 것만 남기는 일은 다음 노드가 합니다.
③ Filter — 24시간 이동창
n8n에서는 값에 표현식을 사용하여 동적인 값도 직접 사용하는 것이 가능합니다 expression 탭을 클릭 후에 아래와 같이 작성해주세요
Value 1 {{ DateTime.fromISO($json.isoDate) > $now.minus({ hours: 24 }) }}
Operator Boolean → is true조건식을 통째로 표현식에 넣고 “참이면 통과”만 시켰습니다. 날짜 연산자를 골라도 isoDate가 문자열이라 DateTime.fromISO()로 감싸야 하는 건 같습니다.
다 걸러진 날에는 아무 일도 일어나지 않습니다. n8n의 노드는 입력 아이템이 0개면 실행되지 않아서 흐름이 여기서 끝나고 Discord로 가지 않습니다. 이게 원하는 동작입니다. 다만 뒤 노드의 Always Output Data를 켜면 안 됩니다. 켜두면 빈 아이템이 흘러가고, 모델은 빈 목록을 받아 아무것도 없는데 뉴스 요약처럼 보이는 문장을 지어냅니다.
④ Aggregate — 버릴 것을 정하는 자리
모드를 Individual Fields로 두고 title과 contentSnippet 두 필드만 지정합니다. 기본값인 All Item Data로 두면 아이템이 필드째 통째로 넘어가는데, 그 안에는 link·guid·content처럼 모델에게 뜻은 없으면서 분량만 큰 필드가 함께 들어갑니다. 스무 건이면 5만 자를 넘고, 두 필드만 남기면 4천 자 안팎으로 끝납니다.
필드를 둘 지정하면 길이가 같은 배열이 두 개 나옵니다. 다음 노드에서는 둘을 따로 꺼내지 않고 {{ $json }}으로 통째로 넘깁니다.
⑤ Ollama — 데이터와 함께 명령을
제가 작성한 것은 참고만 해도 될 것 같습니다. 우선 아래 그대로 적어 두긴했습니다.
Credential
-> Create New Credential
-> Base Url: http://ollama:11434
-> API Key: <empty> (그냥 비워도 됩니다.)
-> Allowed HTTP Request Domains: All
-> Ollama Account 으로 선택되어있을 겁니다.
Resource
-> Text
Operation
-> Message a Model
Model
-> from list, gemma4:e2b
(선택은 자유입니다)
Messages
value 1
Content -> Expression 탭 선택
->
{{ $json }}
하루 간 출간된 기사의 제목과 contentSnippet을 첨부하였습니다.
당신은 뉴스 브리핑을 작성합니다.
요약하여 하루에 어떤 일들이 있었는지 알려주세요. 단, 추측, 예측 그리고 이모지는 금지합니다.
해당 내용을 discord webhook으로 보내질 예정입니다.
1900자 이내로 작성해주세요.
Role
-> User
Simplify Output
-> ✅⑥ HTTP Request — Discord 웹훅
웹훅은 “이 주소로 POST를 보내면 그 채널에 메시지를 올려주겠다”는 주소입니다. 채널 설정에서 만들 수 있고 봇이나 토큰이 필요 없습니다.
용어Discord 웹훅 주소 만들기
메시지를 받을 채널에서 만듭니다. 서버 전체가 아니라 채널 단위입니다.
- 채널 이름 옆 톱니바퀴(채널 편집) → 연동(Integrations)
- 웹후크(Webhooks) → 새 웹후크
- 이름과 아바타를 정합니다. 여기 적은 이름이 메시지를 보낸 사람으로 표시됩니다
- 웹후크 URL 복사
만들려면 그 서버에서 웹후크 관리 권한이 필요합니다. 채널 설정에 연동 항목이 보이지 않으면 권한이 없는 것입니다.
복사한 주소는 이런 모양입니다.
https://discord.com/api/webhooks/<웹훅 ID>/<토큰>뒷부분 토큰이 곧 인증입니다. 별도의 헤더나 API 키가 없어서, 이 주소를 아는 사람은 누구나 그 채널에 글을 올릴 수 있습니다. 아래 HTTP Request 노드의 URL 칸에 그대로 붙여 넣으면 동작은 하지만, 워크플로를 내보내거나 화면을 캡처하는 순간 같이 나가는 값이라는 점은 알고 있어야 합니다. 노출됐다면 같은 화면에서 웹후크를 삭제하고 새로 만들면 이전 주소는 즉시 죽습니다.
Method POST
URL <Discord 웹훅 주소>
Send Body
-> ✅
Body Content Type
-> JSON
Specify Body
Using Fields Below
Body Parameters
Name
-> content
value
-> {{ ($json.content || '데이터가 없습니다.').slice(0, 1900) }}body에 표현식을 쓴 이유가 둘입니다. 메시지 하나가 2000자를 넘으면 요청 자체가 거부되고, content가 빈 문자열이어도 400으로 거부됩니다. 모델은 글자 수를 보장해주지 않으므로 — 애초에 글자를 세지 못합니다 — 노드를 늘리는 대신 이 한 줄로 잘라내고 기본값을 채웠습니다.
$json.content는 Message a Model 노드가 내놓는 필드입니다. 이 파이프라인에는 content라는 이름이 세 군데 나오는데 전부 다른 것입니다 — RSS 아이템의 content(④에서 버린 5만 자짜리 HTML), 모델 출력의 content, Discord가 요구하는 content. 앞의 둘을 헷갈리면 워크플로가 조용히 엉뚱한 것을 보냅니다.
결론
서론에서 말한 대로 도구 두 가지를 올리고 마지막에 둘을 이어봤습니다. Ollama와 n8n이 같은 Docker 네트워크에서 연결됐고, RSS를 읽어 로컬 모델이 정리한 것이 Discord까지 도달했습니다.
아직 비서는 아닙니다. 이 파이프라인은 정해진 시각에 정해진 일만 합니다. 아침 10시에 요약이 오고, 제가 무언가를 요청할 방법은 없습니다. 요청을 받는 자리도, 무엇을 할지 고르는 자리도 비어 있습니다.
다음 편에서 그 두 자리를 채웁니다. 밖에서 서버로 들어가는 경로를 Cloudflare Tunnel로 만들고, 모델이 스스로 도구를 고르게 하는 Discord 봇을 코드로 붙입니다. 이번에 임시로 낮춰둔 N8N_SECURE_COOKIE=false와 N8N_PROTOCOL=http도 그때 되돌립니다.