[개발] [Toy Project] n8n으로 '미국 주식 주간 리포트' 자동 발행하기 (feat. 삽질의 기록)
매번 증권 앱을 켜서 VOO(S&P 500) 가격을 확인하는 것이 귀찮아졌다.
"그냥 매주 토요일 아침, 자고 있을 때 내 블로그에 리포트가 자동으로 올라오면 안 되나?"
단순한 호기심으로 시작했다가, Python API 구축부터 Docker 네트워킹, DB 정렬 문제까지 풀코스로 경험한 주식 자동화 리포트 구축기를 정리해 본다.
🛠️ 전체 아키텍처 (Architecture)
단순히 n8n만 쓴 게 아니라, 데이터를 확실하게 수집하기 위해 Python 마이크로서비스를 하나 추가했다.
- Collector: Python (
yfinance+FastAPI) → Docker 컨테이너 - Controller: n8n → 전체 로직 제어 & 스케줄링
- Storage: MySQL → 주가 데이터 저장
- Visual: QuickChart.io → 차트 이미지 생성
- Publish: Ghost CMS → 리포트 발행
🚧 삽질 1: n8n에는 'Yahoo Finance' 노드가 없다?
처음엔 n8n에 있는 기본 HTTP Request 노드로 Yahoo Finance 비공식 API를 찌르면 될 줄 알았다. 하지만 쿠키 처리나 헤더 설정이 복잡했고, 무엇보다 안정성이 떨어졌다.
결국 "Python에는 yfinance라는 치트키가 있는데, 이걸 안 쓸 이유가 없지 않나?" 라는 생각에 도달했다.
🐍 Python API 서버 구축 (Docker)
n8n이 데이터를 달라고 요청하면, yfinance로 데이터를 긁어와서 JSON으로 던져주는 아주 가벼운 Microservice를 만들기로 했다. (FastAPI 사용)
# main.py (Docker 컨테이너 내부)
import yfinance as yf
from fastapi import FastAPI
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/stock/{symbol}")
def get_stock_history(symbol: str):
# 1. yfinance로 데이터 수집
ticker = yf.Ticker(symbol)
# 2. 최근 한 달 데이터 조회
hist = ticker.history(period="1mo")
# 3. DataFrame을 JSON으로 예쁘게 변환
data = hist.reset_index()
data['Date'] = data['Date'].dt.strftime('%Y-%m-%d')
return JSONResponse(content=data.to_dict(orient='records'))
⛏️ 여기서의 진짜 삽질: "왜 통신이 안 돼?" (Docker Networking)
로컬(내 컴퓨터)에서 테스트할 땐 잘 됐다. 그런데 n8n(Docker)에서 이 Python API(Docker)를 호출하니 에러가 났다.
- 시도 1:
localhost:8000호출 → 실패 (n8n 컨테이너 입장에서 localhost는 자기 자신임) - 시도 2: 내 컴퓨터의 내부 IP 호출 → 성공은 했으나 IP가 바뀌면 또 수정해야 함.
- 해결: 두 컨테이너를 같은 Docker Network로 묶었다.
# 같은 네트워크 생성
docker network create n8n-network
# 컨테이너 실행 시 네트워크 지정
docker run --name n8n --net n8n-network ...
docker run --name stock-api --net n8n-network ...
이제 n8n의 HTTP Request 노드에서 컨테이너 이름으로 호출이 가능하다.
GET http://stock-api:8000/stock/VOO → 성공! 🎉
🚧 삽질 2: DB 중복 데이터와 Upsert의 늪
데이터를 가져오는 건 성공했는데, 매일 스케줄러를 돌리다 보니 이미 저장된 날짜의 데이터가 또 쌓이는 문제가 생겼다.
n8n의 MySQL 노드에 있는 Upsert 기능을 호기롭게 켰으나, 곧바로 빨간 에러를 마주했다.
Error: Incorrect integer value: 'symbol, record_date' for column 'id'
알고 보니 Upsert 옵션이 Auto Increment인 id 컬럼까지 건드리려고 해서 생긴 문제였다. 노드 설정을 이리저리 바꿔보다가, 결국 가장 확실한 방법(Raw SQL)으로 선회했다.
✅ 해결: ON DUPLICATE KEY UPDATE
먼저 DB 테이블에서 symbol과 record_date 두 컬럼을 묶어 Unique Key로 설정해두고, 아래 쿼리를 날렸다.
INSERT INTO stock_history
(symbol, record_date, open_price, high_price, low_price, close_price)
VALUES
('{{ $json.symbol }}', '{{ $json.Date }}', {{ $json.Open }}, {{ $json.High }}, {{ $json.Low }}, {{ $json.Close }})
ON DUPLICATE KEY UPDATE
close_price = VALUES(close_price),
high_price = VALUES(high_price),
low_price = VALUES(low_price);
이제 중복 데이터가 들어오면 알아서 최신 값으로 덮어쓴다.
🚧 삽질 3: 차트가 왜 '일자(-)'로 나오지?
데이터 수집과 저장이 끝났으니 시각화를 할 차례. QuickChart.io를 이용해 차트 이미지를 생성했다.
그런데 결과물을 보고 당황했다. 그래프가 거의 일직선으로 보였기 때문이다.
- 원인: VOO 주가는 630~640불 사이를 오가는데, 차트의 Y축(가격)이 0부터 시작하다 보니 고작 1~2불의 변동은 티도 안 났던 것.
📐 해결: Code Node로 스케일링(Scaling) 직접 계산
Chart.js가 알아서 해주길 기대하지 말고, 직접 데이터의 최소값(min)과 최대값(max)을 계산해서 넣어주기로 했다.
// n8n Code Node (JavaScript)
const prices = items.map(i => Number(i.json.close_price));
// 최소값/최대값 계산 (여백 없이 꽉 차게)
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
// Chart.js 설정에 주입
scales: {
y: {
min: minPrice, // 최소값 강제 지정
max: maxPrice // 최대값 강제 지정
}
}
이렇게 설정하니 0.1달러의 미세한 변동도 아주 역동적인 등락폭으로 표현할 수 있었다. 여기에 Chart.js v3 엔진을 강제 적용하고, 라벨 폰트 크기까지 조절하니 그제야 봐줄 만한 차트가 나왔다.
🚧 삽질 4: 인터랙티브 차트 vs 이미지
욕심을 부려 마우스를 올리면 가격이 뜨는 ApexCharts(인터랙티브) 방식을 시도했었다. 웹 브라우저에서는 정말 멋지게 동작했지만, 결정적인 문제가 있었다.
- 이메일 전송 불가: 뉴스레터(이메일) 환경에서는 보안상 JavaScript가 실행되지 않는다. 구독자가 메일을 열면 빈 화면만 보게 된다.
✅ 타협: 고해상도 이미지 + 토스(Toss) 스타일
결국 QuickChart로 돌아와서 Sparkline 스타일의 깔끔한 선 그래프 이미지를 생성하는 것으로 타협했다. 대신 HTML/CSS를 활용해 토스 증권 같은 카드 UI로 포장했다.
<!-- Ghost에 전송되는 HTML 예시 -->
<div style="border: 1px solid #eaeaea; border-radius: 20px; padding: 25px;">
<div style="color: #888;">VOO · 2026-01-18</div>
<div style="font-size: 36px; font-weight: 800;">$636.60</div>
<!-- 이미지가 들어감 -->
<img src="https://quickchart.io/..." style="width: 100%; border-radius: 10px;" />
</div>
🎉 결과물 & 후기
이제 매주 토요일 아침 5시, n8n 스케줄러가 깨어나서 이 모든 과정을 수행한다.
- Python 컨테이너를 찔러서 VOO 데이터를 받아오고 (Docker 통신)
- MySQL에 중복 없이 저장(
Upsert)한 뒤 - 최근 30일 데이터를 꺼내 JavaScript로 전처리(Min/Max 계산)하고
- QuickChart로 예쁜 차트 이미지를 구워
- Ghost에 "WEEKLY VOO 1월 3주차 리포트"를 발행한다.
남들이 만들어준 통합 API만 쓰는 것보다, 필요하면 Docker로 직접 API를 만들어 붙이는 것이 훨씬 자유도가 높다는 걸 깨달았다. (물론 그만큼 삽질의 시간은 길어지지만...)
이제 당분간은 주말 아침마다 자동으로 배달되는 리포트를 누워서 즐기기만 하면 된다. 끝!
