본문 바로가기

개발 기록/미국 증시 분석 자동화 시스템 운영기

[운영기 B-2] ETF 배당 자동화 — 종목 유형별 GPT 프롬프트 전략과 코드 변경 없는 신규 ETF 추가

이 시리즈는 증시 자동화 구축기 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 도입 배경, 모델명 하드코딩을 설정 외부화로 바꾼 과정을 기록한다.