Next.js 16으로 나만의 주식 투자 비서 만들기 (터틀 트레이딩 알고리즘 구현기)
목차
규칙은 명확한데 손이 못 따라간다
"차트를 하루 종일 보고 있자니 눈이 너무 아프다."
주식 투자를 공부하면서 전설적인 '터틀 트레이딩(Turtle Trading)' 기법을 접했습니다. 원칙 자체는 명확했습니다. "20일 신고가를 갱신하면 사고, 10일 신저가를 찍으면 팔아라." 하지만 직장인이 매일 500개가 넘는 S&P 500 종목의 차트를 돌려보며 이 신호를 찾는 건 불가능에 가까웠습니다.
개발자로서의 본능이 발동했습니다. "규칙이 명확하다면, 코드로 짤 수 있지 않을까?"
단순한 토이 프로젝트로 시작했지만, 며칠 밤을 새우며 차트 라이브러리와 씨름하고 투자 보조 지표를 객관적인 점수로 환산하는 과정이 꽤나 흥미로웠습니다. 이 글에서는 Next.js 16과 Vanilla Extract를 사용하여 S&P 500 주식 추천 서비스를 만든 경험을 공유하고자 합니다.
무엇을 보여줄 것인가
이 서비스는 복잡한 HTS(Home Trading System)를 대체하는 것이 목표가 아닙니다. 바쁜 현대인에게 **"오늘 주목해야 할 종목"**만 딱 집어 알려주는 것이 핵심입니다.
- 오늘의 추천 (Daily Signals): 매일 장 마감 후 알고리즘이 분석한 '매수/매도' 신호를 보여줍니다.
- 신뢰도 점수 (Confidence Score): 단순히 "사라/팔아라"가 아니라, 보조지표(MACD, RSI 등)를 종합하여 0~100점의 신뢰도를 제공합니다.
- 직관적인 카드 UI: 모바일에서도 한눈에 신호와 차트 패턴을 확인할 수 있도록 카드 형태의 UI를 구성했습니다.
무엇으로 만들었나
Next.js 16 (App Router)
서버 사이드 렌더링(SSR)과 정적 페이지 생성(SSG)의 장점을 모두 활용하기 위해 선택했습니다. 특히 주식 데이터는 장 마감 후 갱신되므로, 페이지를 미리 빌드해두거나 ISR(Incremental Static Regeneration)을 활용하면 사용자에게 엄청난 로딩 속도를 제공할 수 있습니다. 또한, Server Actions를 통해 DB 조작 로직을 백엔드 API 구축 없이 직관적으로 처리할 수 있었습니다.
Vanilla Extract (CSS)
Typescript 기반의 Zero-runtime CSS 라이브러리입니다.
- 타입 안정성: 테마 변수나 스타일 속성에 오타가 있으면 빌드 타임에 잡아줍니다.
- 성능: 런타임에 스타일을 주입하는 CSS-in-JS와 달리, 빌드 시점에 정적 CSS 파일을 생성하므로 초기 로딩 속도가 매우 빠릅니다.
Supabase & Vercel Cron
백엔드 구축에 힘을 빼기 싫어 Supabase를 선택했고, 매일 밤 데이터를 갱신하는 배치 작업은 Vercel Cron으로 해결했습니다. 별도의 배포 서버 없이 함수 하나만 작성하면 스케줄링이 가능하다는 점이 사이드 프로젝트에 최적화되어 있었습니다.
신고가만 보고 샀더니 계속 물렸다
처음에는 단순히 터틀 트레이딩의 원칙인 20일 신고가만 구현했습니다. 하지만 백테스팅 결과, 소위 '휩소(Whipsaw, 속임수 패턴)'에 걸려 손실이 나는 경우가 많았습니다. 신고가를 찍자마자 폭락하는 경우였죠.
이를 해결하기 위해 **3단계 점수 시스템(Tiered Scoring System)**을 도입했습니다.
- Tier 1 (추세): ADX와 MACD를 통해 현재 시장이 횡보장이 아닌 '추세장'인지 확인합니다.
- Tier 2 (진입): 거래량(Volume)과 RSI를 분석합니다. 거래량이 터지면서 상승해야 진짜 신호라고 판단합니다.
- Tier 3 (시장 리스크): 개별 종목이 좋아도 시장 전체(SPY)가 하락세거나 공포 지수(VIX)가 높으면 점수를 대폭 깎습니다.
// scoring.ts (실제 로직 요약)
// 1. 기본 신호 (터틀)
if (close > high_20) baseSignal = "BUY";
// 2. 가중치 계산
let totalScore = 50;
// ADX가 25 이상이어야 강한 추세로 인정
if (adx > 25) totalScore += 15;
// 거래량이 평균의 1.5배 이상 터져줘야 신뢰
if (volumeRatio > 1.5) totalScore += 15;
// 시장이 공포 구간(VIX > 30)이면 묻지마 감점
if (vix > 30) totalScore -= 15;가장 오래 고민한 값: ADX가 낮을 때의 감점
점수표를 짜면서 제일 오래 붙잡고 있었던 건 가점이 아니라 감점 폭이었습니다. 최종적으로 정한 값은 이렇습니다.
| 조건 | 점수 |
|---|---|
| ADX > 25 (강한 추세) | +15 |
| ADX 20~25 (보통) | +5 |
| ADX < 20 (추세 없음) | −20 |
| ADX > 50 (과열) | −5 |
| 거래량 > 200% | +20 |
| 거래량 > 150% | +15 |
| 거래량 < 80% | −15 |
| 거래량 < 50% | −25 |
| RSI 50~65 (건강한 상승) | +10 |
눈여겨볼 건 추세가 있을 때 주는 +15보다 추세가 없을 때 빼는 −20이 더 크다는 점입니다. 처음엔 대칭으로 잡았다가 백테스트를 돌리고 바꿨습니다. 신고가를 뚫었는데 ADX가 18인 종목들, 그러니까 방향성 없이 위아래로 흔들리다 우연히 고점을 스친 경우가 손실의 대부분이었기 때문입니다. 이런 신호는 걸러내는 것만으로도 성과가 크게 달라졌습니다.
ADX 50 초과에 감점을 준 것도 같은 맥락입니다. 추세가 강할수록 좋다는 직관과 반대인데, 이미 과열된 구간에 뒤늦게 올라타는 걸 막기 위한 장치입니다.
거래량 50% 미만에 −25를 준 것도 실전 경험에서 나왔습니다. 거래량 없이 슬금슬금 오르는 종목은 대부분 되돌립니다. 가격은 거짓말을 해도 거래량은 덜 한다는 말을 코드로 옮긴 셈입니다.
점수가 낮으면 아예 신호를 내지 않기
세 층의 점수를 합치고 나서 마지막으로 추가한 게 경고(warning) 배열입니다. ADX가 낮아 추세를 신뢰할 수 없는 구간에서는 매수도 매도도 아닌 횡보(Ranging) 표시를 띄우고 관망으로 처리합니다.
export interface SignalScore {
baseSignal: "BUY" | "SELL" | "HOLD";
confidence: number;
breakdown: {
trendScore: number; // Tier 1: ADX + MACD
entryScore: number; // Tier 2: Volume + RSI + Stoch
riskScore: number; // Tier 3: Market Regime
};
warnings: string[];
recommendation: string;
}이 구조로 바꾸면서 서비스의 성격 자체가 달라졌습니다. 처음엔 "오늘 살 종목"을 골라주는 도구였는데, 지금은 "지금이 판단할 만한 국면인가"를 구분해주는 도구에 가깝습니다. 화면에 횡보 표시가 잔뜩 뜨는 날이 있는데, 그건 서비스가 고장난 게 아니라 시장에 방향이 없다는 뜻입니다.
신뢰도 퍼센트는 확률이 아니다
만들고 나서 가장 후회한 건 이 숫자에 confidence라는 이름을 붙인 것입니다. 65%를 보면 누구나 "65% 확률로 오른다"로 읽습니다. 실제로는 여러 지표가 얼마나 같은 방향을 가리키는지를 하나로 합친 값일 뿐입니다. 수익 확률도, 기대 수익률도, 통계적 유의성도 아닙니다.
같은 지표들이 동시에 틀리는 일은 자주 일어납니다. 특히 시장 전체가 방향을 바꾸는 국면에서는 지표가 일제히 한 방향을 가리키다가 다 같이 틀립니다. 신뢰도가 높을수록 안전하다는 해석이 가장 위험한 지점입니다.
지금은 산출 방식 페이지에 이 점을 따로 적어 두었습니다. 계산 근거를 감추면 신뢰할 이유가 없고, 공개하면 적어도 오해할 여지는 줄어듭니다.
몇 주를 사야 하는지는 변동성이 정한다
신호를 내는 것까지 만들고 나니 빠진 게 보였습니다. "그래서 얼마나 사야 하는데?"
이건 신호보다 훨씬 중요한 질문입니다. 아무리 좋은 신호도 한 종목에 전 재산을 넣으면 한 번의 실패로 끝나고, 반대로 너무 적게 넣으면 맞혀도 의미가 없습니다. 그런데 대부분의 추천 서비스가 이 부분을 비워 둡니다.
그래서 포지션 계산기를 붙였습니다. 계좌 크기와 한 번에 감내할 위험 비율(보통 1~2%)을 넣으면, 그 종목의 변동성을 기준으로 몇 주가 적당한지 계산합니다.
기준으로 쓴 건 ATR(Average True Range)입니다. 최근 가격이 하루에 평균 얼마나 움직이는지를 나타내는 값입니다. 계산은 이렇게 됩니다.
감내할 금액 = 계좌 크기 × 위험 비율
매수 수량 = 감내할 금액 ÷ (ATR × 손절 배수)
핵심은 비싼 종목이라서 적게 사는 게 아니라, 많이 흔들리는 종목이라서 적게 산다는 점입니다. 30만원짜리 종목이라도 하루 변동이 작으면 수량이 늘어나고, 5만원짜리라도 매일 요동치면 수량이 줄어듭니다. 계좌가 한 번에 잃을 수 있는 금액을 종목과 무관하게 일정하게 유지하는 방식입니다.
이걸 넣고 나서 서비스를 보는 눈이 달라졌습니다. 신호는 "무엇을"에 대한 답이고 포지션 계산은 "얼마나"에 대한 답인데, 후자가 없으면 전자는 위험한 정보입니다. 매수 신호만 띄우고 수량은 알아서 하라고 하는 건, 결국 사용자가 감당할 수 없는 크기로 들어가게 방치하는 것이기 때문입니다.
물론 이 계산도 참고용입니다. 거래 비용과 세금을 반영하지 않고, 손절 배수도 임의로 정한 값입니다. 다만 "얼마나"를 아예 묻지 않는 것보다는 낫다고 봤습니다.
참고로 손절 배수는 ATR의 2배를 기본값으로 뒀습니다. 이 값에 정답은 없고, 크게 잡으면 잔 흔들림에 덜 털리는 대신 한 번 틀렸을 때 손실이 커집니다. 반대로 작게 잡으면 자주 털립니다. 어느 쪽이 맞는지는 개인의 성향과 매매 주기가 정할 문제라, 화면에서 직접 바꿀 수 있게 열어 뒀습니다.
광고가 뜰 때마다 화면이 덜컥거렸다
수익화를 위해 AdSense를 붙였는데, 광고 로딩 전후로 화면이 덜컥거리는 CLS(Cumulative Layout Shift) 문제가 심각했습니다. 사용자 경험을 해치는 주범이죠.
이를 해결하기 위해 광고 컨테이너에 min-height를 강제하고, 스켈레톤 UI를 적용했습니다. 데스크톱에서는 728x90, 모바일에서는 유동적 사이즈를 적용하되 미리 공간을 확보해두는 방식으로 최적화하여 쾌적한 UX를 유지했습니다.
데이터 갱신은 매일 장 마감 이후 한 번 도는 배치 작업이 맡습니다. 실시간 시세를 쓰지 않기로 한 것도 의도적인 선택이었습니다. 이 서비스가 답하려는 질문은 "지금 이 순간 사야 하나"가 아니라 "오늘 기준으로 이 종목의 추세가 어떤가"이기 때문입니다. 초 단위로 바뀌는 숫자를 띄우면 사용자는 그 화면을 계속 들여다보게 되고, 그건 이 서비스가 권하고 싶은 태도가 아닙니다. 하루에 한 번 갱신되는 화면은 하루에 한 번 보게 만듭니다.
신호를 내지 않는 것도 답이다
이 프로젝트를 시작할 때의 목표는 "매일 살 종목을 골라주는 도구"였습니다. 끝내고 보니 만든 건 그 반대에 가깝습니다. 대부분의 날에 "지금은 판단할 만한 국면이 아니다"라고 말하는 도구가 됐습니다.
처음엔 이게 실패처럼 느껴졌습니다. 화면에 횡보 표시만 가득한 날은 서비스가 아무 일도 안 하는 것처럼 보이니까요. 그런데 백테스트를 돌려 보면 손실의 대부분이 바로 그 구간에서 나옵니다. 방향이 없을 때 억지로 방향을 찍는 것, 그게 가장 비싼 실수였습니다. 아무것도 하지 않는 것도 판단이고 대체로 그게 더 저렴하다는 걸, 코드로 확인하고 나서야 납득했습니다.
기술적으로 남은 건 두 가지입니다. 하나는 점수 체계에서 감점이 가점보다 중요할 수 있다는 것. 좋은 신호를 찾는 것보다 나쁜 신호를 걸러내는 쪽이 성과를 더 크게 바꿨습니다. 다른 하나는 숫자에 붙이는 이름이 그 숫자의 해석을 결정한다는 것. confidence라는 이름 하나 때문에 지표 일치도가 확률로 읽히게 됐고, 이건 설명 페이지를 따로 만들기 전까지 계속 오해를 낳았습니다.
앞으로는 백테스트 엔진 쪽을 더 손볼 생각입니다. 지금은 거래 비용과 슬리피지를 반영하지 않아서 실제보다 결과가 좋게 나옵니다. 이걸 넣으면 아마 지금 보이는 성과의 상당 부분이 사라질 텐데, 그 숫자를 보는 게 이 프로젝트의 다음 단계인 것 같습니다.
덧붙임. 이 서비스는 투자 자문이 아닙니다. 공개된 시세에 기술적 지표를 기계적으로 적용한 계산 결과일 뿐이고, 특정 종목의 매매를 권유하지 않습니다. 투자 판단과 그 결과는 전적으로 본인 책임입니다. 만든 사람도 이 신호만 보고 매매하지 않습니다.