[개발] [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 테이블에서 symbolrecord_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 스케줄러가 깨어나서 이 모든 과정을 수행한다.

  1. Python 컨테이너를 찔러서 VOO 데이터를 받아오고 (Docker 통신)
  2. MySQL에 중복 없이 저장(Upsert)한 뒤
  3. 최근 30일 데이터를 꺼내 JavaScript로 전처리(Min/Max 계산)하고
  4. QuickChart로 예쁜 차트 이미지를 구워
  5. Ghost"WEEKLY VOO 1월 3주차 리포트"를 발행한다.

남들이 만들어준 통합 API만 쓰는 것보다, 필요하면 Docker로 직접 API를 만들어 붙이는 것이 훨씬 자유도가 높다는 걸 깨달았다. (물론 그만큼 삽질의 시간은 길어지지만...)

이제 당분간은 주말 아침마다 자동으로 배달되는 리포트를 누워서 즐기기만 하면 된다. 끝!