이 시리즈는 증시 자동화 구축기 8편의 후속입니다.
이전 글: 운영기 B-1 — 실적·경제지표 캘린더 구현: 데이터가 시간차로 들어올 때
다음 글: 운영기 B-3 — 외부 API 안정화: RetryUtil 도입과 모델 폐기 대응
ETF 배당을 별도 파이프라인으로 분리한 이유
ETF 배당 발표는 뉴스와 성격이 다르다. 매일 발생하는 게 아니라 배당락일이 됐을 때 발생한다.
수집해서 분석하고 끝나는 게 아니라, 신규 배당이 감지됐을 때만 파이프라인 전체가 실행된다.
이벤트 기반 파이프라인과 섞으면 트리거 조건이 복잡해진다.
그래서 독립적인 @Scheduled로 분리했다. 매일 06:45에 수집만 하고, 신규 배당이 없으면 그냥 끝난다.
신규가 있으면 GPT 분석 → Claude 블로그 → DALL-E 이미지 → Notion 동기화까지 자동으로 이어진다.
현재 모니터링 중인 ETF는 6개다.
| ETF | 유형 | 배당 주기 | 특성 |
|---|---|---|---|
| SCHD | DIVIDEND_GROWTH | 분기 | 배당 성장률 중심 |
| JEPI | COVERED_CALL | 월배당 | 옵션 프리미엄 인컴 |
| JEPQ | COVERED_CALL | 월배당 | 나스닥 기반 커버드콜 |
| QQQI | COVERED_CALL | 월배당 | QQQ 기반 커버드콜 |
| O | REIT | 월배당 | 부동산 인컴 |
| OHI | REIT | 월배당 | 헬스케어 REIT |
수집 소스 전략 — AUTO 모드
각 ETF마다 수집 소스를 설정할 수 있다. FINNHUB, YAHOO, AUTO 세 가지 옵션이 있고,
기본값은 AUTO다.
AUTO 모드는 Finnhub를 먼저 시도하고 실패하면 Yahoo Finance로 폴백한다.
private List<EtfDividendDto> collectDividends(String ticker, String source) {
if ("FINNHUB".equals(source)) {
return collectFromFinnhub(ticker);
} else if ("YAHOO".equals(source)) {
return collectFromYahoo(ticker);
} else { // AUTO
try {
return collectFromFinnhub(ticker);
} catch (Exception e) {
log.warn("[ETF] Finnhub 실패, Yahoo 폴백: {} - {}", ticker, e.getMessage());
return collectFromYahoo(ticker);
}
}
}
두 소스의 응답 구조가 완전히 다르다.
Finnhub /stock/dividend2:
{ "data": [{ "exDate": "2025-01-10", "payDate": "2025-01-20", "amount": 0.3456 }] }
Yahoo Finance chart API (events=div):
chart.result[0].events.dividends → { "1736500800": { "amount": 0.3456, "date": 1736500800 } }
Yahoo 응답은 Unix timestamp가 키다. 날짜 변환이 필요하다.
// Unix timestamp → LocalDate
long timestamp = Long.parseLong(entry.getKey());
LocalDate exDate = Instant.ofEpochSecond(timestamp)
.atZone(ZoneId.of("America/New_York"))
.toLocalDate();
미국 동부 시간 기준으로 변환하지 않으면 자정 전후 타이밍에 날짜가 하루 어긋난다.
신규 배당 감지 — INSERT IGNORE 패턴
수집한 배당 데이터를 DB에 넣을 때 INSERT IGNORE를 사용한다.
INSERT IGNORE INTO tb_etf_dividend
(etf_no, ticker, ex_date, pay_date, amount, frequency)
VALUES
(#{etfNo}, #{ticker}, #{exDate}, #{payDate}, #{amount}, #{frequency})
uk_ticker_ex_date UNIQUE KEY가 걸려 있어서,
동일 (ticker, ex_date) 조합이 이미 있으면 INSERT가 무시된다.
리턴값(affected rows)이 1이면 신규, 0이면 기존이다.
int inserted = etfDividendMapper.insertIgnore(dto);
if (inserted == 1) {
// 신규 배당 — 분석 파이프라인 트리거
calculateChangeRate(dto);
analysisService.analyzeAndGenerateBlog(dto);
}
// inserted == 0이면 이미 처리된 배당 — 아무것도 하지 않음
스케줄러가 매일 실행돼도 이미 처리한 배당을 다시 처리하지 않는다.
[이미지 1 — ETF 배당 파이프라인 전체 흐름 + 중복 방지 구조]
변화율 계산 — mom과 qoq를 따로 계산하는 이유
배당 변화율은 두 가지를 계산한다.
mom (Month-over-Month): 직전 배당 대비 변화율
qoq (Quarter-over-Quarter): 3건 전 배당 대비 변화율
월배당 ETF(JEPI, JEPQ 등)는 mom이 주요 지표다.
매달 배당이 얼마나 바뀌는지를 본다. 분기배당 ETF(SCHD)는 qoq가 의미 있다.
3개월 전 같은 분기와 비교해야 계절성을 제거할 수 있다.
// mom_change_rate: 직전 1건과 비교
BigDecimal prev1 = getLatestDividend(ticker, 1); // 바로 직전
if (prev1 != null && prev1.compareTo(BigDecimal.ZERO) != 0) {
BigDecimal mom = (current.subtract(prev1))
.divide(prev1, 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
dto.setMomChangeRate(mom);
}
// qoq_change_rate: 3건 전과 비교 (분기 배당 기준)
BigDecimal prev3 = getLatestDividend(ticker, 3); // 3건 전
if (prev3 != null && prev3.compareTo(BigDecimal.ZERO) != 0) {
BigDecimal qoq = (current.subtract(prev3))
.divide(prev3, 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
dto.setQoqChangeRate(qoq);
}
// UP/DOWN/FLAT 판정
boolean isUp = mom.abs().compareTo(new BigDecimal("0.5")) > 0 && mom.compareTo(BigDecimal.ZERO) > 0;
boolean isDown = mom.abs().compareTo(new BigDecimal("0.5")) > 0 && mom.compareTo(BigDecimal.ZERO) < 0;
dto.setChangeDirection(isUp ? "UP" : isDown ? "DOWN" : "FLAT");
|mom| > 0.5%를 FLAT 기준으로 쓰는 이유가 있다.
JEPI 같은 커버드콜 ETF는 옵션 프리미엄 수입에 따라 매달 배당이 소폭 변동한다.
0.01달러 차이로 UP/DOWN을 판정하면 의미 없는 알림이 너무 많아진다.
ETF 유형별 GPT 프롬프트 전략
이 파이프라인에서 가장 공들인 부분이다. 같은 배당 발표라도 ETF 유형에 따라 분석 관점이 완전히 다르다.
COVERED_CALL (JEPI, JEPQ, QQQI)
커버드콜 ETF는 배당의 원천이 옵션 프리미엄이다.
GPT에게 물어야 할 것이 다르다.
- 이번 달 옵션 프리미엄 수익이 전월 대비 어떻게 변했는가
- 변동성 환경(VIX)이 배당에 어떤 영향을 줬는가
- 현재 금리 환경에서 커버드콜 전략의 적합성
- 업사이드 제한 vs 안정적 인컴 트레이드오프
DALL-E 비주얼 힌트도 다르다.
"옵션 계약서 + 인컴 스트림 패턴"을 프롬프트에 포함한다.
REIT (O, OHI)
REIT는 배당의 원천이 임대 수익이다.
FFO(Funds From Operations)가 핵심이다.
- FFO 대비 배당 커버리지 비율
- 현재 금리가 REIT 밸류에이션에 미치는 영향
- 부동산 섹터(소매 vs 헬스케어) 동향
- 배당 지속 가능성 (Payout Ratio)
DALL-E 비주얼 힌트: "건물 실루엣 + 수익 흐름 선"
DIVIDEND_GROWTH (SCHD)
SCHD는 배당 성장이 핵심이다.
현재 배당 크기보다 성장 추세가 중요하다.
- DGR(Dividend Growth Rate) 추세
- 편입 종목의 이익 커버리지
- 장기 배당 성장 지속 가능성
- 경기 사이클에서의 방어적 특성
DALL-E 비주얼 힌트: "배당 캘린더 + 상승 막대그래프"
코드에서 이 분기는 단순하다.
String systemPrompt = switch (etfType) {
case "COVERED_CALL" -> COVERED_CALL_PROMPT;
case "REIT" -> REIT_PROMPT;
case "DIVIDEND_GROWTH" -> DIVIDEND_GROWTH_PROMPT;
default -> DEFAULT_ETF_PROMPT;
};
새로운 ETF 유형이 추가되면 switch에 case 하나와 프롬프트 상수 하나만 추가하면 된다.
[이미지 2 — ETF 유형별 분석 전략 비교 카드]
신규 ETF 추가가 코드 변경 없이 가능한 이유
이 파이프라인의 가장 큰 장점이다. 새로운 ETF를 모니터링하려면 tb_etf 테이블에 행 하나만 INSERT하면 된다.
INSERT INTO tb_etf (ticker, name, etf_type, source, active)
VALUES ('DGRO', 'iShares Core Dividend Growth ETF', 'DIVIDEND_GROWTH', 'AUTO', 1);
다음 06:45 스케줄 실행부터 DGRO가 자동으로 수집 대상에 포함된다.
코드를 건드릴 필요가 없다.
반대로 특정 ETF를 잠시 수집 중단하고 싶으면 active = 0으로 토글하면 된다.
프론트엔드 SettingsView에서 버튼 하나로 처리할 수 있다.
PUT /api/etf/{etfNo}/active ← active 토글 엔드포인트
이 유연성이 가능한 이유는 서비스 코드가 ETF 목록을 하드코딩하지 않기 때문이다.
항상 DB에서 WHERE active=1로 조회해서 처리한다.
이중 중복 방지 설계
같은 배당을 두 번 처리하지 않도록 두 단계로 막는다.
1차 차단: UNIQUE KEY + INSERT IGNORE
UNIQUE KEY uk_ticker_ex_date (ticker, ex_date)
-- 같은 (ticker, ex_date) 조합이면 INSERT 무시
2차 차단: blog_triggered 플래그
-- 블로그 생성 완료 후
UPDATE tb_etf_dividend SET blog_triggered = 1 WHERE dividend_no = #{no}
-- 분석 서비스에서 체크
SELECT * FROM tb_etf_dividend
WHERE blog_triggered = 0 AND dividend_no = #{no}
-- 이미 1이면 결과 없음 → 파이프라인 중단
1차 차단은 중복 수집을 막는다. 2차 차단은 블로그 중복 생성을 막는다.
스케줄러가 오작동해서 같은 날 두 번 실행되거나, 수동으로 재실행해도 이미 완료된 건은 건너뛴다.
운영 중 발견한 엣지 케이스
SCHD 분기 전환 타이밍
SCHD는 분기배당이다. 12월 배당이 들어왔을 때 3건 전은 9월 배당이다.
배당 이력이 3건 미만인 신규 ETF는 qoq_change_rate를 계산할 수 없다.
이 경우 null로 기록하고 GPT 프롬프트에서 qoq 분석을 생략한다.
Notion 동기화 실패 처리
Notion API 타임아웃이 간헐적으로 발생한다.
Notion 실패는 log.warn으로 기록하고 블로그 초안을 DB에 유지한다.
blog_triggered는 그대로 1로 업데이트한다(블로그 생성은 완료).
재시도는 SettingsView의 "ETF 배당 수집" 수동 버튼으로 가능하다.
단, blog_triggered = 1이므로 블로그는 재생성하지 않고 Notion 동기화만 다시 시도한다.
배당 주기 감지 하드코딩
현재 frequency(MONTHLY/QUARTERLY)는 ETF별로 하드코딩되어 있다.
detectFrequency() 메서드에서 과거 배당 이력의 간격을 계산해서 자동 감지하는 방식으로 개선할 수 있지만,
6개 ETF 수준에서는 하드코딩이 더 단순하다.
정리
| 항목 | 설계 방식 | 이유 |
|---|---|---|
| 수집 소스 | Finnhub 우선 + Yahoo 폴백 | 단일 소스 장애 대응 |
| 신규 감지 | INSERT IGNORE + affected rows | 코드 분기 없이 신규/기존 판별 |
| 유형별 분석 | switch문 + 유형별 프롬프트 상수 | 유형 추가 시 최소 수정 |
| 신규 ETF 추가 | DB INSERT만으로 충분 | 서비스 코드 무변경 |
| 중복 방지 | UNIQUE KEY + blog_triggered 이중 | 재실행 안전성 보장 |
| Notion 실패 | warn + 초안 유지 | 핵심 산출물(블로그) 보호 |
다음 편에서는 운영하면서 외부 API 때문에 겪은 문제들을 정리한다.
DALL-E 모델 폐기 대응, RetryUtil 도입 배경, 모델명 하드코딩을 설정 외부화로 바꾼 과정을 기록한다.
'개발 기록 > 미국 증시 분석 자동화 시스템 운영기' 카테고리의 다른 글
| [운영기 C-2] 운영 이슈 15건 — 실제로 무엇이 터졌고 어떻게 고쳤나 (0) | 2026.06.07 |
|---|---|
| [운영기 C-1] Vue.js 대시보드가 12개 화면으로 늘어난 과정 — 도메인별 API 모듈과 Axios 인터셉터 설계 (0) | 2026.06.06 |
| [운영기 B-1] 실적·경제지표 캘린더 구현 — 예정값과 실제값이 시간차로 들어올 때 DB 설계 (0) | 2026.05.30 |
| [운영기 A-3] 주간·월간·분기 파이프라인 설계 — 7일·30일·90일 데이터를 Claude에 넘기는 방법 (1) | 2026.05.22 |
| [운영기 A-2] 이벤트 파이프라인 옆에서 돌아가는 독립 파이프라인들 — 실적·경제지표·ETF 배당 자동화 (0) | 2026.05.20 |