이전 글: 3편 — Spring Boot 자동화 파이프라인 설계
다음 글: 5편 — Gemini + GPT + Claude 분업으로 투자 분석하기
Java 내장 HttpClient를 선택한 이유
외부 API 호출에 RestTemplate이나 WebClient를 쓰지 않았다.
Java 11에서 정식 추가된 java.net.http.HttpClient를 직접 사용했다.
이유는 세 가지다.
- 추가 의존성이 없다.
build.gradle에 항목 하나가 줄어든다 - 동기/비동기 모두 지원한다. 지금은 동기로 쓰지만, 나중에 비동기로 전환해도 API가 동일하다
- 개인 프로젝트 수준에서 HTTP 추상화 레이어는 오버엔지니어링이다. 외부 API가 9개가 넘어도 HttpClient 직접 사용으로 충분히 감당된다
연결한 API 목록을 정리하면 다음과 같다.
| 시장 데이터 | Yahoo Finance Chart API | 시장 지수, 개별 종목 현재가 |
| 시장 데이터 | FRED API | 미국 국채 수익률 (10년/2년물) |
| 뉴스 | Finnhub API | 종목별 뉴스 JSON |
| 뉴스 | Yahoo Finance RSS | 종목별 뉴스 XML |
| AI | Google Gemini | 뉴스 정제 + 감성 분류 |
| AI | OpenAI ChatGPT | 종목별 투자 분석 |
| AI | Anthropic Claude | 블로그 초안 생성 |
| AI | OpenAI DALL·E | 블로그 대표 이미지 생성 |
| 출력 | Notion API | 블로그 초안 업로드 |
| 출력 | Telegram Bot API | 파이프라인 성공/실패 알림 |
모두 HttpClient로 처리했다. 서비스마다 엔드포인트와 요청 구조가 다를 뿐이다.
뉴스 수집: Finnhub + Yahoo Finance RSS
뉴스 수집은 두 소스를 병행한다. Finnhub만으로는 누락되는 뉴스가 있고, Yahoo RSS만으로는 본문 요약이 부족했다.
Finnhub API는 종목별 뉴스를 JSON으로 반환한다.
제목, URL, 본문 요약, 발행 시각이 포함된다.
종목당 최대 10건, 전일~오늘 날짜 범위로 필터링한다.
Yahoo Finance RSS는 종목별 RSS 피드를 XML로 제공한다.
com.rometools:rome 라이브러리로 파싱한다.
// Rome 라이브러리 RSS 파싱 예시
SyndFeedInput input = new SyndFeedInput();
XmlReader reader = new XmlReader(new URL(rssUrl));
SyndFeed feed = input.build(reader);
for (SyndEntry entry : feed.getEntries()) {
String title = entry.getTitle();
String url = entry.getLink();
Date published = entry.getPublishedDate();
// ...
}
중복 방지는 뉴스 URL을 기본키처럼 사용하는 방식으로 해결했다.
수집 전에 DB에서 URL 존재 여부를 확인하고, 이미 있으면 스킵한다.
URL 컬럼에 유니크 인덱스를 걸면 중복 INSERT 자체를 DB 레벨에서 차단할 수 있다.
Rate Limit 대응은 지금도 가장 불만스러운 부분이다.
Finnhub 무료 플랜은 분당 60 요청 제한이 있어서, 종목당 API 호출 사이에 Thread.sleep(500)을 넣어서 버텼다.
종목이 20개를 넘어가면 이 단계만 10분이 넘는다.

시장 지수 수집: Yahoo Finance + FRED
시장 지수(S&P500, 나스닥, 다우, WTI)는 Yahoo Finance Chart API에서 가져온다.
응답 JSON의 regularMarketPrice가 현재가다.
여기서 중요한 것은 한미 시차 문제가 있다.
한국 새벽 06:20에 수집할 때, Yahoo Finance의 chartPreviousClose 필드가
미국 시각 기준이라 전전날 값이 나오는 경우가 있다.
변동률 계산이 완전히 틀려버린다.
해결 방법은 단순했다.
Yahoo의 chartPreviousClose를 쓰지 않고, 전날 수집해서 DB에 저장된 값을 직접 꺼내 쓴다.
외부 API보다 자체 DB를 더 신뢰하는 구조다.
FRED API는 10년물·2년물 국채 수익률을 제공한다.
여기서 특이한 케이스가 하나 있다.
주말이나 미국 공휴일에는 값이 없어서 빈 값 대신 .(점)을 반환한다.
// FRED 응답의 "." 처리 — 역순으로 탐색해서 유효값 반환
for (int i = observations.size() - 1; i >= 0; i--) {
String value = observations.get(i).getValue();
if (!".".equals(value)) {
return Double.parseDouble(value);
}
}
처음에 이 케이스를 처리 안 해서 NumberFormatException으로 파이프라인이 터졌다.
실제 API를 쓰다 보면 문서에 없는 이런 케이스가 반드시 하나 이상은 발생한다.

AI API 응답 파싱: 항상 JSON이 온다는 보장이 없다
Gemini, ChatGPT, Claude 모두 HTTP POST로 JSON을 주고받는다. 엔드포인트와 요청 구조만 다를 뿐이다.
공통으로 신경 쓴 것은 AI 응답이 항상 깔끔한 JSON으로 온다는 보장이 없다는 점이다.
같은 프롬프트를 줘도 모델이 마크다운 코드블록을 씌우거나, 앞뒤에 설명을 붙이거나,
키 이름을 임의로 바꿔서 반환할 수 있다.
실제로 겪은 파싱 실패 케이스는 네 가지다.

정제 코드는 모든 AI API 호출에 공통으로 적용한다.
// JSON 응답 정제 — 마크다운 코드블록 제거 + JSON 범위 추출
String raw = response.getBody();
// ① 마크다운 코드블록 제거
String cleaned = raw
.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "")
.trim();
// ② 앞뒤 설명 텍스트 제거 — 중괄호 범위만 추출
int start = cleaned.indexOf("{");
int end = cleaned.lastIndexOf("}");
if (start >= 0 && end > start) {
cleaned = cleaned.substring(start, end + 1);
}
ObjectMapper mapper = new ObjectMapper();
AnalysisDto result = mapper.readValue(cleaned, AnalysisDto.class);
API 호출 간격은 서비스마다 다르게 설정했다.
Gemini는 3초, ChatGPT는 1초.
무료 티어와 유료 티어의 Rate Limit 차이를 반영한 값이다.
정리
이번 편에서 다룬 핵심 포인트를 요약하면 다음과 같다.
| 뉴스 중복 수집 | URL 기준 유니크 인덱스 | DB 레벨에서 차단 |
| Finnhub Rate Limit | Thread.sleep(500ms) | 종목 20개 이상 시 한계 |
| 한미 시차 보정 | DB 전일값 직접 사용 | Yahoo chartPreviousClose 미사용 |
| FRED "." 응답 | 역순 탐색으로 유효값 추출 | 문서에 없는 케이스 |
| AI JSON 파싱 실패 | 정제 유틸 공통 적용 | 4가지 케이스 모두 처리 |
다음 편에서는 이 파이프라인의 핵심인 AI 분업 구조를 다룬다.
Gemini, ChatGPT, Claude를 왜 하나로 통일하지 않고 역할별로 나눴는지,
실제 프롬프트 설계와 정확도 평가 방법을 기록할 예정이다.