이 시리즈는 증시 자동화 구축기 8편의 후속입니다.
이전 글: 운영기 C-1 — Vue.js 대시보드가 12개 화면으로 늘어난 과정
다음 글: 운영기 C-3 — 백엔드 아키텍처 현재: 1차 시리즈 이후 무엇이 달라졌나
깔끔한 설계도 운영하면 부서진다
구축기 시리즈는 설계 의도와 구현 결과를 정리한 기록이다.
잘 된 것 위주로 쓸 수밖에 없다.
이 편은 반대다.
실제 운영하면서 발생한 이슈 15건을 유형별로 분류해서 정리한다.
버그, 스키마 오류, UI 실수, 외부 API 변경, 설계 개선까지 다룬다.
원인을 솔직하게 쓰는 편이 나중에 비슷한 문제를 겪을 때 더 유용하다.
15건을 4가지 유형으로 나눴다.
| 유형 | 건수 | 발생 원인 패턴 |
|---|---|---|
| 버그 · 스키마 오류 | 5건 | 유효성 검사 누락, DDL 주석 불일치 |
| UI · 설계 실수 | 4건 | 화면 구현 누락, 스케줄 시간 미검토 |
| 외부 API 변경 | 2건 | 모델 폐기, JSON 표준 비준수 응답 |
| 설계 개선 | 4건 | 공통 유틸 부재, 동기 실행 문제 |
유형 1 — 버그 · 스키마 오류 (5건)
ISSUE-001: 지난 날짜의 catalyst가 "다음 이벤트"로 표시됨
블로그 초안의 "다음 촉매 이벤트" 섹션에 이미 지난 날짜의 이벤트가 그대로 표시됐다.
// 문제 코드
analysisSummary.setNextCatalyst(analysis.getNextCatalyst()); // 날짜 검증 없음
// 수정 코드
LocalDate catalystDate = analysis.getCatalystDate();
if (catalystDate == null || catalystDate.isBefore(LocalDate.now())) {
analysisSummary.setNextCatalyst("N/A");
} else {
analysisSummary.setNextCatalyst(analysis.getNextCatalyst());
}
ChatGPT가 분석할 때 설정한 nextCatalyst가 다음 날 이미 지나도 그대로 DB에 남아 있었다.
블로그 생성 시점에 유효성 검사를 하지 않아서 과거 이벤트가 "다음 이벤트"로 출력됐다.
ISSUE-005 + ISSUE-007: 같은 기능이 두 번 연속 실패
포트폴리오 화면에서 현금 잔액 저장 시 HTTP 500이 발생했다.
ISSUE-005 조치: cash_type 값을 'MANUAL_BALANCE' → 'DEPOSIT_USD'로 변경.
그런데 ISSUE-007이 이어서 발생했다.
Data truncation: Data too long for column 'cash_type' at row 1tb_cash.cash_type이 VARCHAR(10)인데 'DEPOSIT_USD'는 11자다. DDL 주석에는 DEPOSIT_USD / DEPOSIT_KRW라고 명시되어 있었는데, 컬럼 길이를 그에 맞게 설정하지 않았다.
ALTER TABLE tb_cash
MODIFY COLUMN cash_type VARCHAR(15) NOT NULL;
운영 중인 MySQL 컨테이너에 직접 적용했다.
재시작 없이 즉시 반영됐다.
DDL 주석이 실제 제약과 불일치했다. 주석을 신뢰하고 길이를 확인하지 않은 것이 원인이다.
주석은 참고용이고, 실제 스키마는 직접 확인해야 한다는 교훈을 얻었다.
ISSUE-003: :key 바인딩에 존재하지 않는 프로퍼티
주간 블로그 목록 화면에서 Vue key 바인딩 경고가 발생하고 렌더링이 불안정했다.
// 문제 코드
<v-list-item v-for="item in weeklyDrafts" :key="item.weeklyDraftNo">
// 수정 코드
<v-list-item v-for="item in weeklyDrafts" :key="item.weeklyNo">
백엔드 응답 필드명을 확인하지 않고 weeklyDraftNo로 추측해서 작성했다.
실제 필드명은 weeklyNo였다. Vue는 key가 undefined여도 경고만 주고 렌더링은 된다.
목록이 불안정하게 동작하는 증상으로 나타나서 원인 파악이 늦었다.
ISSUE-006: Enter + blur 이중 호출
현금 입력 필드에서 Enter 키를 누르면 saveCash()가 두 번 호출됐다.
// 원인 흐름
@keydown.enter → saveCash() 실행 → editingCash.value = null
→ input DOM 제거 → @blur 자동 발생 → saveCash() 한 번 더
// 해결: 최상단 guard
const saveCash = async () => {
if (!editingCash.value) return
// ...
}
@keydown.enter 핸들러에서 상태를 null로 초기화하면 해당 input이 DOM에서 제거되고 @blur가 자동으로 발생한다는 것을 처음엔 몰랐다. DOM 이벤트 발생 순서를 고려하지 않은 설계였다.
유형 2 — UI · 설계 실수 (4건)
ISSUE-002: 설정 화면의 파이프라인 실행 순서가 실제와 반대
설정 화면에서 수동 실행 버튼 순서가 실제 스케줄과 반대였다.
화면 표시: 블로그 생성(09:10) → 메일 발송(09:00) ← 잘못됨
실제 순서: 메일 발송(09:00) → 블로그 생성(09:10) ← 맞음
Notion 동기화 버튼은 아예 누락되어 있었다. 백엔드 엔드포인트는 존재했는데 GUI 항목 추가가 빠진 것이다. 초기 구현 시 실제 스케줄 순서를 확인하지 않고 임의 배치한 결과다.
ISSUE-004: 토요일 06:30 스케줄 충돌
토요일 06:30에 환율 수집(ExchangeRateService)과
포트폴리오 스냅샷(PortfolioSnapshotService)이 동시에 실행됐다.
스냅샷은 환율 수집 완료 후 실행되어야 정확한 원화 환산이 가능하다.
두 서비스 모두 같은 cron(0 30 6 * * SAT)으로 설정되어 있어 순서가 보장되지 않았다.
스냅샷 스케줄을 06:30 → 06:45로 조정해서 해결했다.
토요일 순서 확정:
종가 수집(06:00) → 지수 수집(06:20) → 환율 수집(06:30)
→ 포트폴리오 스냅샷(06:45) → 예측 평가(06:50)
시간 기반으로 의존성을 처리하는 방식의 전형적인 문제다.
A-1편에서 이벤트 기반으로 전환한 이유 중 하나이기도 하다.
ISSUE-008 + ISSUE-010: 백엔드는 있는데 화면이 없다
두 가지 기능이 동일한 패턴으로 발생했다. 백엔드 서비스와 API는 완성됐는데,
프론트엔드 화면과 라우트 등록이 누락됐다.
ISSUE-008은 실적 캘린더 화면(EarningsView)이 없어서 BlogView 탭으로만 간접 확인이 가능했다.
ISSUE-010은 테마 키워드 관리 화면(ThemeView)이 없어서 키워드 승인·거절이 불가능했다.
백엔드 기능을 먼저 구현하고 프론트엔드를 나중에 붙이는 순서로 개발했는데,
화면 추가를 체크리스트에 넣지 않으면 누락되기 쉽다.
유형 3 — 외부 API 변경 (2건)
이 두 건은 B-3편에서 자세히 다뤘다. 여기서는 핵심만 정리한다.
ISSUE-009: DALL-E 3 모델 폐기
어느 날 아침 파이프라인의 이미지 생성 단계가 터졌다.
The model 'dall-e-3' does not exist.
OpenAI가 dall-e-3를 폐기하고 gpt-image-1로 교체했다. 응답 형식도 URL에서 base64로 바뀌었다. 코드 수정과 재배포가 필요했다.
이 이슈가 직접적인 계기가 되어 ISSUE-013(모델명 설정 외부화)이 뒤따랐다.
ISSUE-011: Claude가 +3.0을 반환하면 Jackson이 터진다
분기 AI 비중 제안 실행 시 HTTP 500이 발생했다.
Unexpected character ('+') in numeric value:
JSON spec does not allow numbers to have plus signs
프롬프트 예시에 (예: +3.0, -2.0, 0.0)을 넣었더니 Claude가 양수에 + 기호를 붙여서 반환했다. RFC 8259(JSON 표준)는 숫자 앞 +를 허용하지 않는다.
정규식 정제 + 프롬프트 수정으로 해결했다.
json = json.replaceAll(":\\s*\\+([0-9])", ": $1");
유형 4 — 설계 개선 (4건)
버그가 아니라 "처음부터 이렇게 만들었어야 했다"는 것들이다.
ISSUE-012: RetryUtil 도입
외부 AI API 호출 코드에 재시도 로직이 없었다. Rate Limit(429)이나
일시 장애(5xx)가 발생하면 재시도 없이 파이프라인 전체가 중단됐다.
common/util/RetryUtil.java를 신규 생성하고 5개 서비스에 적용했다.
DALL-E(3회, 5초), ChatGPT(3회, 2초), Claude(3회, 3초) 순으로 우선순위를 정해서 적용했다.
ISSUE-013: AI 모델명 9개 파일 하드코딩
ISSUE-009 수습 과정에서 모델명이 9개 서비스에 흩어져 있다는 것을 발견했다.
모델 교체마다 9개 파일을 고치고 재배포해야 했다.
application.properties로 모델명을 외부화하고, 10개 서비스에서 @Value로 주입하도록 변경했다.
ISSUE-014: 수동 실행 시 HTTP 응답 블로킹
설정 화면에서 파이프라인 수동 실행 버튼을 누르면 파이프라인 전체가 완료될 때까지 HTTP 응답이 반환되지 않았다.
수십 분이 걸리는 파이프라인이 Nginx 타임아웃(300초)을 넘기면 502가 떨어졌다.
@EventListener는 기본적으로 동기 실행이라, 그룹 1 이벤트가 발행되면 그룹 2, 3이 같은 스레드에서 연이어 실행됐다. @Async 메서드를 별도로 만들어서 수동 실행 경로와 자동 스케줄 경로를 분리했다.
ISSUE-015: Notion 동기화 3건 누락
EarningsBlogService, EconomicEventBlogService, WeeklyIssueBlogService에서 생성된 블로그가 DB에만 저장되고 Notion에는 동기화되지 않았다.
일간·주간·월간·ETF 배당 블로그는 Notion 동기화가 구현되어 있었는데, 나중에 추가된 세 서비스에서 NotionService.createGenericPage() 호출이 빠졌다.
// 수정 패턴 (3개 서비스 동일)
// 이미지 생성 완료 후
try {
notionService.createGenericPage(blogTitle, reportDate, content);
} catch (Exception e) {
log.warn("Notion 동기화 실패: {}", e.getMessage());
// 블로그 생성은 정상 완료 처리
}
Notion 실패는 경고 로그만 남기고 블로그 생성을 정상 완료로 처리한다.
Notion 동기화 실패가 블로그 초안 자체를 무효화하면 안 되기 때문이다.
이슈에서 발견한 패턴
15건을 정리하면서 반복되는 패턴이 보였다.
"나중에 추가된 기능에서 공통 처리가 빠진다"
ISSUE-015(Notion 누락)와 ISSUE-008, ISSUE-010(화면 누락)이 동일한 패턴이다. 초기 기능은 꼼꼼히 구현하고, 이후 추가된 유사 기능에서 공통 처리를 빠뜨린다. 신규 기능 추가 시 체크리스트가 없으면 반드시 이런 이슈가 생긴다.
"DDL 주석을 믿지 말고 실제 스키마를 확인하라"
ISSUE-007이 그 사례다. 주석에 DEPOSIT_USD라고 써 있어도 컬럼 길이가 VARCHAR(10)이면 저장이 안 된다. DDL 작성 후 검증 없이 넘어가면 운영에서 터진다.
"AI 응답 정제는 선택이 아니라 필수다"
ISSUE-011이 그 사례다. 프롬프트에 예시를 넣으면 AI는 예시를 따라한다. +3.0이라는 예시를 넣으면 +3.0을 반환한다. AI 응답은 항상 파싱 전에 정제가 필요하다.
정리
| 번호 | 유형 | 핵심 원인 | 한 줄 교훈 |
|---|---|---|---|
| 001 | 버그 | 유효성 검사 누락 | 날짜 필드는 항상 유효성 검사 |
| 002 | UI 실수 | 스케줄 순서 미확인 | 화면이 실제 동작과 일치하는지 확인 |
| 003 | 버그 | 프로퍼티명 추측 | 백엔드 응답 필드명 직접 확인 |
| 004 | 설계 실수 | 동시간 스케줄 | 같은 시간대 스케줄 충돌 체크 |
| 005 | 버그 | ENUM 미등록 값 | 삽입 전 허용 값 확인 |
| 006 | 버그 | DOM 이벤트 순서 | Enter + blur 연속 발생 가능 |
| 007 | 스키마 | DDL 주석 불일치 | 주석 말고 실제 길이 확인 |
| 008 | UI 누락 | 화면 추가 미완료 | 기능 추가 시 화면도 체크리스트에 |
| 009 | 외부 API | 모델 폐기 | 모델명은 설정 파일로 분리 |
| 010 | UI 누락 | 화면 추가 미완료 | 008과 동일 패턴 |
| 011 | 파싱 오류 | AI가 + 포함 반환 |
프롬프트 예시에 + 쓰지 않기 |
| 012 | 설계 개선 | 재시도 로직 부재 | 외부 API는 항상 RetryUtil 적용 |
| 013 | 설계 개선 | 모델명 하드코딩 | 모델명은 설정 파일로 관리 |
| 014 | 설계 개선 | @Async 누락 | 수동 실행은 @Async로 분리 |
| 015 | 기능 누락 | Notion 호출 미추가 | 유사 서비스 신규 추가 시 공통 처리 확인 |
구축기에서 설계한 시스템이 운영에서 어떤 부분에서 부서지는지 보여주는 기록이다.
버그 없는 코드가 목표가 아니라, 부서졌을 때 빠르게 발견하고 고치는 구조가 목표다.
Telegram 알림이 그 역할을 해줬다.
다음 편은 이 시리즈의 마지막이다. 1차 구축기와 현재 아키텍처를 비교하고, 완료된 Phase와 남은 과제를 정리한다.
'개발 기록 > 미국 증시 분석 자동화 시스템 운영기' 카테고리의 다른 글
| [운영기 C-3] 백엔드 아키텍처 현재 — 구축기 이후 도메인 19개, Phase 11개 완료 (0) | 2026.06.08 |
|---|---|
| [운영기 C-1] Vue.js 대시보드가 12개 화면으로 늘어난 과정 — 도메인별 API 모듈과 Axios 인터셉터 설계 (0) | 2026.06.06 |
| [운영기 B-2] ETF 배당 자동화 — 종목 유형별 GPT 프롬프트 전략과 코드 변경 없는 신규 ETF 추가 (0) | 2026.05.30 |
| [운영기 B-1] 실적·경제지표 캘린더 구현 — 예정값과 실제값이 시간차로 들어올 때 DB 설계 (0) | 2026.05.30 |
| [운영기 A-3] 주간·월간·분기 파이프라인 설계 — 7일·30일·90일 데이터를 Claude에 넘기는 방법 (1) | 2026.05.22 |