본문 바로가기

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

[운영기 C-2] 운영 이슈 15건 — 실제로 무엇이 터졌고 어떻게 고쳤나

이 시리즈는 증시 자동화 구축기 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 1
tb_cash.cash_typeVARCHAR(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와 남은 과제를 정리한다.