[Nest.js] 주가 데이터 수집기 구축하기
Backend/NestJS
· 2025-04-13
![[Nest.js] 주가 데이터 수집기 구축하기](/assets/posts/wetopia-cron/thumbnail-optimized.webp)
들어가며
근래 미 증시를 지켜보면서 주식과 부동산 투자에 부쩍 관심이 많아졌다. 현재 투자 중에 있으며, 무식하게 들어갔다가 물리기 시작하면서 잃은 돈을 시작으로 바보가 되지 않기 위해 공부를 시작했다(정말 한심하지 아니한가). 책을 읽고 관련 영상들을 찾아보면서 주식 투자에 대한 인사이트와 기본기를 다지고 있는 와중에, 문득 특정 지표나 표준 지식에 대해 잘 정리되어있어 건강한 투자 방식에 대해 점검해볼 수 있는 간단한 페이지가 있으면 좋겠다는 생각이 들었다. 그렇게 시작된 Wetopia 프로젝트의 프론트-백 기술 스택을 정리한 뒤, 가장 먼저 필요했던 것이 바로 주가 데이터였다. 랜딩 페이지에서 메인으로 보여질 데이터이기 때문에, 외부 API와 NestJS를 활용해 주가 데이터를 어떻게 수집하고 처리했는지에 대해 정리해보려 한다.
이 프로젝트는 단순 기술적 구현을 넘어서, 주식에 대해 누구나 직관적이고 건강한 투자 판단이 가능하도록 돕는 서비스가 되면 좋겠다 싶다. 사실 투자 방식이라는 것은 지극히 주관적이면서 개인 견해가 붙으면 편향되기 쉽겠지만, 그럼에도 월가나 저명한 전문가들의 의견을 빌려 일반인들이 두꺼운 책을 읽지 않고도 어느 정도의 인사이트를 얻어갈 수 있는 서비스면 충분히 만족스러운 취지가 되지 않을까 싶다(물론, 운영하는 필자 입장에서도 배움에 큰 도움이 되는 과정이다).
영국 유명 사상가인 토마스 모어가 만든 유토피아(utopia)를 잠시 빌려, 우리 모두 "미래를 위해 아는 만큼 이뤄내는 이상적인 세상"에 나아가자는 취지로 "We" + "Utopia"를 붙여 위토피아(Wetopia) 로 프로젝트명을 지었다. 거창한 이름에 비해 소박한 코드를 붙이면서 가장 첫 번째 숙제였던 주가 데이터를 붙여보는 작업에 대해 정리해보자.
주가 데이터 수집기
먼저, 외부 API를 찾아본다. AWS에서 많은 비용을 발생시키고 지불해본 경험이 있기에 조금은 무료 API에 집착했다. 그러기에 무료 플랜으로 하루 800 요청이 가능하고, 동시에 8개 symbol 호출이 가능한 Twelve Data를 채택하였고, 추가적으로 미국 주식뿐만 아니라 인덱스, 암호화폐, ETF까지 폭넓은 지원까지 해준다.
프로세스는 다음과 같다. 외부 API를 Wetopia 서버에서 직접 호출하여 데이터를 수집하도록 테스트를 진행해본다. 외부 API와 클라이언트 측을 완벽하게 분리하기 위해 과거 데이터는 Neon으로 (이것 역시 무료 플랜)배포된 PostgreSQL에 적재해두고, 장 마감 시마다 스케줄링을 통해 전날 데이터를 업데이트한다. 이 때, "스케줄링"은 NestJs의 @Cron
데코레이터를 활용해 주기적으로 데이터를 수집할 수 있도록 구현한다.
즉,@Cron
을 통해 다음 두 가지 목적을 도달한다.
-
서비스 API 응답시간이 Twelve Data 서버에 의존하는 것 방지
-
Twelve Data 호출량 등 제약조건으로부터 자유로워짐
1# 외부 API -> Server -> Client 2[Twelve Data REST] → [API 서버 (NestJs)] 3 ↓ 4 [React (Next.js) + React Query] 5 6 7# 외부 API -> Server -> DB / DB -> Server -> Client (외부 API 분리) 8[Twelve Data REST] → [Cron 수집기] → [PostgreSQL Neon DB] 9 ↓ 10 [API 서버 (NestJs)] 11 ↓ 12 [React (Next.js) + React Query]
Twelve Data API 다루기
Twelve Data API 사용법인 로그인부터 API Key 발급 등은 생략한다. 공식 홈페이지를 보는 것이 빠를 것.
외부 API에 의존되는 코드는 전부 src/external/
안으로 분리시켰다. Twelve Data보다 나은 플랫폼으로 갈아탄다거나 문제가 생길 시를 대비하여.
Twelve Data의 time_series
엔드포인트를 활용해 주가 데이터를 수집한다. @nestjs/axios
를 사용해 HTTP 요청을 보내고(클라이언트와 여러 API를 붙이는 과정에서 오버패칭과 같은 단점을 개선하고자 요새 자주 보이는 Apollo GraphQL을 써볼 예정), rxjs
의 firstValueFrom()
을 통해 비동기 응답을 Promise 형태로 다룰 수 있도록 구성했고, 성공시 JSON 응답 형태 또는 실패 시 status: error
를 반환하기에 예외 처리를 간단히 해두었다.
1import { HttpService } from "@nestjs/axios"; 2import { Injectable, HttpException } from "@nestjs/common"; 3import { firstValueFrom } from "rxjs"; 4 5@Injectable() 6export class TwelveDataService { 7 constructor(private readonly http: HttpService) {} 8 9 async fetch(symbol: string, interval: string, range: string) { 10 const url = `https://api.twelvedata.com/time_series?symbol=${symbol}&interval=${interval}&outputsize=800&range=${range}&apikey=${process.env.TWELVE_DATA_API_KEY}`; 11 12 const res = await firstValueFrom(this.http.get(url)); 13 14 if (res.data?.status === "error") { 15 throw new HttpException(res.data.message || "TwelveData API Error", 500); 16 } 17 18 return res.data?.values || []; 19 } 20}
사용법에 대해 간단히 정리해보자면, 여기서 symbol
이란 애플(AAPL
), 테슬라(TSLA
)와 같은 종목 태그를 의미하고, interval
은 1min, 5min, 1day 등 시간 간격, range
는 조회 기간을 의미한다. 예를 들어, symbol=AAPL&interval=1day&range=1month
와 같은 요청을 보내면 애플의 최근 1개월치 일별 시세가 응답으로 반환되는 것.!
그렇게 Twelve Data에 직접 요청을 날려서 받은 응답결과는 다음과 같다.
Postman Result
579.09KB에 1.32초. 최선인가? 아니다. 게다가 요청할 때마다 시간차가 크기 때문에 클라이언트에서 요청하는 API 응답속도를 외부 API 서버 속도가 아닌 우리 자체 서버와 데이터베이스에만 종속되도록 Cron으로 바꿔보자.
클라이언트 요청과 분리시키기
Twelve Data를 클라이언트 요청과 분리하기 위해, 즉 이미 적재되어있는 DB에 호출하기 위해 Data Access Layer를 추가하였고, DB에 적재하기 위한 시점과 로직을 정리해본다. 주가 데이터를 DB에 적재하기 위해 크게 두 플로우가 필요하다.
첫 째, 3년치(예시 기한) 동안의 데이터를 최초 1회 업데이트한다.
둘 째, 주기적인 스케줄링에 따라 현재가를 특정 날짜에만 업데이트시킨다. 즉, 장마감 후에 전날 종목 가격들을 저장한다.
셋 째, 현재가를 소켓 통신 기반의 실시간으로 받아온다. 다만, 이는 프론트엔드 작업을 일정 수준까지 올린 다음 작업할 에정이다.
Seeder
최초 1회에 한해 대량 데이터를 적재하기 위해 Seeder
를 별도로 구성했다. 일반적으로 main.ts
에 해당 로직을 작성할 수도 있지만, 이는 애플리케이션 실행 목적과 다르다고 판단하여 CLI 실행이 가능한 별도의 진입점(/src/seeder.ts
)를 만들었다. yarn seed
커맨드를 통해 종목 데이터를 DB에 저장할 수 있게 구성했다.
1// /src/seeder.ts 2 3import { NestFactory } from "@nestjs/core"; 4import { AppModule } from "./app.module"; 5import { SeederService } from "./seeder/seeder.service"; 6import { STOCK_SYMBOLS } from "./constants/symbols"; 7 8async function bootstrap() { 9 const app = await NestFactory.createApplicationContext(AppModule); 10 const seeder = app.get(SeederService); 11 await seeder.seedAllSymbols(STOCK_SYMBOLS); 12 await app.close(); 13} 14bootstrap();
실제 데이터 적재는 SeederService
에서 수행한다. 각 종목별로 Twelve Data API를 통해 n년치 일별 데이터를 받아온 뒤, 엔티티 변환과 upsert
방식으로 저장된다.
1// /src/seeder/seeder.service.ts 2 3import { Injectable } from "@nestjs/common"; 4import { Stock } from "../stocks/entities/stock.entity"; 5import { TwelveDataService } from "../external/twelve-data.service"; 6import { CustomLogger } from "src/common/logger/custom-logger.service"; 7import { setTimeout as sleep } from "timers/promises"; 8import { StocksRepository } from "src/stocks/stocks.repository"; 9 10@Injectable() 11export class SeederService { 12 private readonly context = SeederService.name; 13 constructor( 14 private readonly stockRepo: StocksRepository, 15 private readonly twelveData: TwelveDataService, 16 private readonly logger: CustomLogger 17 ) {} 18 19 async seedAllSymbols(symbols: string[]) { 20 for (const symbol of symbols) { 21 const values = await this.twelveData.fetch(symbol, "1day", "1y"); 22 const stocks = values.map((item) => { 23 const s = new Stock(); 24 s.symbol = symbol; 25 // ... 데이터 가공 ... 26 return s; 27 }); 28 29 await this.stockRepo.upsertStock(stocks); 30 this.logger.logSuccess( 31 `${this.context}/seedAllSymbols`, 32 `Seeded ${symbol}: ${stocks.length} entries` 33 ); 34 35 // 분당 요청수 제한 방지 36 await sleep(8000); 37 } 38 } 39}
Cron
정기적으로 주가 데이터를 동기화하기 위해, @nestjs/schedule
의 @Cron
데코레이터를 활용해 주식 종가를 매일 아침 7시에 갱신하도록 설정했다. 미국장은 한국 시간 기준 새벽에 마감되기 때문에, 아침 시간대 전날 데이터를 저장하기 적합하다.
1// /src/stocks/cron/stocks.cron.ts 2import { setTimeout as sleep } from "timers/promises"; 3import { Injectable } from "@nestjs/common"; 4import { Cron } from "@nestjs/schedule"; 5import { StocksService } from "../stocks.service"; 6import { CustomLogger } from "src/common/logger/custom-logger.service"; 7import { STOCK_SYMBOLS } from "src/constants/symbols"; 8 9@Injectable() 10export class StockCron { 11 private readonly context = StockCron.name; 12 constructor( 13 private readonly stocksService: StocksService, 14 private readonly logger: CustomLogger 15 ) {} 16 17 // timZone 기준, 평일(1-5) 07시 마다 동작하라. 라는 설정 18 @Cron("0 7 * * 1-5", { timeZone: "Asia/Seoul" }) 19 async syncHourlyPrice() { 20 for (const symbol of STOCK_SYMBOLS) { 21 try { 22 await this.stocksService.fetchAndSave(symbol, "1day", "1day"); 23 this.logger.logSuccess( 24 `${this.context}/syncHourlyPrice`, 25 `Updated ${symbol} on ${new Date().toISOString()}` 26 ); 27 await sleep(8000); 28 } catch (err) { 29 this.logger.logError( 30 `${this.context}/syncHourlyPrice`, 31 err instanceof Error ? err.message : String(err) 32 ); 33 } 34 } 35 } 36}
현재는 하루 1회, 전날 업데이트만 진행하기에 실시간 조회 편의성은 거의 0에 가깝지만, 실시간 데이터를 웹소켓 기반으로 효율적으로 관리하는 기능을 넣을 에정이다.
위와 같은 프로세스를 통해 현재 Neon으로 배포해둔 PostgreSQL에 쿼리 조회해보면 3년치와 전날 장마감 시 업데이트된 데이터까지 모두 잘 나오는 것을 확인할 수 있다.
Neon SQL Editor
클라이언트에서 REST API 호출하기
이제 수집된 데이터를 기반으로 외부 API를 거치지 않고 응답할 수 있게 되었다. 이를 실제로 클라이언트(Next.js
)에서 활용하도록 외부 API가 아닌, /stock/dashboard
라는 엔드포인트를 컨트롤러에 정의해주자. Data Access Layer를 담당하는 stocks.repository.ts
를 만들었고, 쿼리를 symbol, date 기준으로 반환하도록 했다. 날짜는 TO_CHAR()
을 통해 ISO 문자열로 변환하여 프론트에 반환되는 전송량을 조금 줄여보았다.
1// /src/stocks/stocks.repository.ts 2 3@Injectable() 4export class StocksRepository extends Repository<Stock> { 5 constructor(private dataSource: DataSource) { 6 super(Stock, dataSource.createEntityManager()); 7 } 8 9 async findGroupedBySymbol(): Promise< 10 { 11 symbol: string; 12 date: string; 13 open: number; 14 close: number; 15 high: number; 16 low: number; 17 }[] 18 > { 19 return await this.dataSource 20 .getRepository(Stock) 21 .createQueryBuilder("stock") 22 .select([ 23 "stock.symbol as symbol", 24 `TO_CHAR(stock.date, 'YYYY-MM-DD') as date`, 25 "stock.open as open", 26 "stock.close as close", 27 "stock.high as high", 28 "stock.low as low", 29 ]) 30 .orderBy("stock.symbol", "ASC") 31 .addOrderBy("stock.date", "DESC") 32 .getRawMany(); 33 } 34}
그 결과, 다음과 같이 전송량은 더 커졌지만 되려 속도는 줄었다! 조금 더 줄여볼까?
Postman Result
NestJS Gzip
압축 설정을 통해 서버 전송량을 줄이고 전송 속도를 높여보면, 400ms대로 줄고 전송량도 약 154KB로 대폭 줄여졌다. 다만, 응답 속도는 네트워크 환경에 따라 일부 편차가 존재한다. 이 평균 속도를 수백 ms 단위로 안정화하기 위해서는 이후 페이지네이션 또는 프론트 요청 범위 제한 등 다양한 방식을 적용해볼 수 있겠다.
1import { NestFactory } from "@nestjs/core"; 2import * as compression from "compression"; 3import { AppModule } from "./app.module"; 4 5async function bootstrap() { 6 const app = await NestFactory.create(AppModule); 7 8 // ... etc configuration ... 9 10 app.use(compression()); // gzip 응답 압축 적용 11 await app.listen(4000); 12} 13bootstrap();
Gzip 적용 결과
마치며
이렇게 주가 데이터 수집기라는 가장 기초적인 백엔드 기능을 먼저 정리해보았다. 데이터 수집이 현 프로젝트의 어떤 기능보다 선행되어야할 기반이기 때문인데, 이제는 이 데이터를 클라이언트 측에서 어떻게 관리하고 시각화할지를 붙여 사용자에게 인사이트를 줄 수 있을지에 대한 고민이 이어질 예정이다. 즉, 적재된 데이터를 Next.js
, React-Query
로 클라이언트에서 어떻게 소비할건지, 그 과정에서 SSR과 캐싱 전략을 어떻게 설계했는지 다뤄본다.