Skip to content

비동기 Executor 안정화 및 AI 연동 실패 처리 전환#257

Open
ckdals4600 wants to merge 2 commits into
mainfrom
feature/#256-async-executor-stabilization
Open

비동기 Executor 안정화 및 AI 연동 실패 처리 전환#257
ckdals4600 wants to merge 2 commits into
mainfrom
feature/#256-async-executor-stabilization

Conversation

@ckdals4600

@ckdals4600 ckdals4600 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

관련 이슈

PR 설명

비동기 처리의 자원 사용을 제어하고, AI 연동 실패를 null 반환에서 명시적 예외로
전환하여 실패 흐름을 명확하게 만들었습니다. 부하 시 자원 통제와 실패 추적의 기반을 마련합니다.

배경

  • @async 가 자원 제한 없이 동작하여 부하 시 스레드가 무제한 생성될 수 있었습니다.
  • AI 연동 실패가 null 반환으로 처리되어, 호출부에서 잠재적 NPE 가 발생하고 실패 원인을 추적하기 어려웠습니다.
  • 비동기 작업 최종 실패 메트릭이 실제 실패와 무관하게 매 작업마다 집계되는 버그가 있었습니다.

변경 사항

1. 비동기 Executor 안정화

  • @async 전용 Executor 에 거부 정책과 graceful shutdown 을 추가했습니다.
  • 풀 사이즈는 보수적으로 설정(core 5 / max 20 / queue 100)했습니다. 트래픽 특성을 모르는 초기 단계이므로 작게 시작하고, 관측성 메트릭을 모니터링하며 추후 튜닝할 계획입니다.
  • 거부 정책은 CallerRunsPolicy 를 선택했습니다. AI 동기화·요약 작업은 유실되면 안 되므로, 큐·풀이 포화되면 작업을 버리는 대신 호출 스레드가 직접 처리하여 백프레셔로 처리량을 자연스럽게 조절합니다.
  • graceful shutdown(진행 중 작업 30초 대기)으로 종료 시 작업이 중간에 끊기지 않도록 했습니다.

2. AI 연동 실패 → typed exception 전환

  • AI 클라이언트(summary/answer)의 null 반환을 명시적 예외로 전환했습니다.
    • 통신 오류 → EXTERNAL_API_COMMUNICATION_ERROR
    • 빈 응답 → EmptyAiResponseException (신규)
  • 기존 null 반환은 호출부에서 NPE 로 이어졌으나, 명시적 예외로 전환하여 실패 원인이 로그·예외 타입으로 드러나도록 했습니다.
  • Feign ErrorDecoder 가 변환한 도메인 예외(BusinessException)는 그대로 전파하여 기존 에러 의미를 보존합니다.

3. 빈 응답 재시도 제외

  • 통신 오류와 빈 응답의 성격을 구분했습니다.
    • 통신 오류: 일시적 장애일 가능성이 높아 재시도 유지
    • 빈 응답: 입력 콘텐츠 자체가 요약 불가한 경우가 많아, 재시도해도 동일한 결과가 나오기 쉬움
  • 빈 응답 전용 예외(EmptyAiResponseException)를 별도 타입으로 분리하고 @RetryablenoRetryFor 에 지정하여, 불필요한 재시도 비용과 외부 서버 부하를 줄였습니다.

4. (버그 수정) 비동기 작업 실패 메트릭 집계 위치

  • async.task.failures 카운터가 try 진입 전에 무조건 증가되어, 성공·실패와 무관하게 모든 작업을 실패로 집계하던 버그를 수정했습니다.
  • 재시도까지 소진된 최종 실패(catch) 시점에만 집계되도록 위치를 이동했습니다.

5. Async Executor 포화 관측

  • executor를 bounded(core5/max20/queue100)로 만들면서 생긴 포화 리스크를 관측 가능하도록 대시보드·알림 추가. (자동계측된 executor_* 지표 사용, 애플리케이션 코드 변경 없음)
  • Grafana(AI/Async 대시보드): 큐 대기(capacity 100, 80/100 임계선) / 스레드 active·pool·max / 큐 사용률 게이지 패널 추가.
  • Prometheus 알림 룰: AsyncExecutorQueueSaturation(큐 대기 ≥ 80, 2m), AsyncExecutorPoolExhausted(풀 = max, 2m). 지속 포화 시 CallerRunsPolicy 발동(요청/이벤트 스레드 직접 실행)을 사전 감지.
  • 워크로드별 executor 분리는 별도 이슈(#NNN)로 트래킹 — 포화 지표로 실제 점유율 관측 후 근거 기반 설계 예정.

6. 정책상 변경하지 않은 클라이언트 (의도적)

  • RagTitleClient: 제목 생성 실패 시 기본 제목(truncate) fallback 을 유지합니다. 제목은 부가 기능이므로 예외로 전환하면 채팅 생성 자체가 실패합니다. 부가 기능 실패가 주요 흐름을 막지 않도록 fallback 을 유지했습니다.
  • RagLinkSyncClient: void 반환이며 이미 예외를 전파하고, 호출부(LinkSyncEventListener)가 @retryable·@recover 로 최종 실패 처리까지 갖추고 있어 변경하지 않았습니다.

@ckdals4600 ckdals4600 linked an issue Jul 4, 2026 that may be closed by this pull request
@ckdals4600 ckdals4600 requested review from Goder-0 and minibr July 4, 2026 07:58
@ckdals4600 ckdals4600 self-assigned this Jul 4, 2026
부하 시 자원 사용을 제어하기 위해 거부 정책(CallerRunsPolicy)과
graceful shutdown을 적용. 풀 사이즈는 보수적으로 설정(core 5, max 20,
queue 100)하고 메트릭 모니터링을 통해 추후 튜닝 예정.
@ckdals4600 ckdals4600 force-pushed the feature/#256-async-executor-stabilization branch from e5d1aea to 5ce77ed Compare July 5, 2026 04:36
AI 연동 실패를 명시적 예외로 전환하여 실패 흐름을 명확화.
- 통신 오류는 EXTERNAL_API_COMMUNICATION_ERROR로 전파
- 빈 응답은 EmptyAiResponseException으로 구분, 재시도에서 제외
- 변경된 실패 처리에 맞게 단위 테스트 수정
@ckdals4600 ckdals4600 force-pushed the feature/#256-async-executor-stabilization branch from 5ce77ed to 0e71134 Compare July 5, 2026 04:38
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

📊 코드 커버리지 리포트

Overall Project 92.67% -0.61% 🍏
Files changed 77.38% 🍏

File Coverage
EmptyAiResponseException.java 100% 🍏
SummaryWorker.java 100% 🍏
RagSummaryClient.java 86.24% -13.76% 🍏
RagAnswerClient.java 86.21% -13.79% 🍏
RequestLoggingFilter.java 79.58% 🍏

@Goder-0

Goder-0 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

현재 AI 호출 수를 기록하는 ai.client.calls 메트릭 정의를 보면, RagAnswerClient.buildCounter()에서는 태그를 client, result두 개만 사용하고 있고, RagSummaryClient.buildCounter()에서는 같은 메트릭 이름에 client, operation, result 세 개를 사용하고 있습니다. 동일한 메트릭 이름이라면 태그 키셋도 동일해야 해서, 나중에 Prometheus/Micrometer 등록 충돌이 날 가능성이 있습니다.
이전 PR에서 놓친 포인트를 발견한 것 같긴한데요, 여기서 바로 blocking으로 잡기보다는 별도 이슈로 분리해서 정리해도 좋을 것 같습니다. 이 부분은 별도 이슈로 만들까 하는데 어떠신가요?

Comment on lines +34 to 43
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);

executor.setTaskDecorator(taskDecorator);

executor.initialize();
return executor;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CallerRunsPolicy 자체가 잘못된 건 아니지만, 현재처럼 AI 연동이나 후속 이벤트 처리까지 같은 전역 async executor에 태우는 구조에서는 포화 시 비동기 작업이 요청/이벤트 스레드에서 직접 실행될 수 있다는 점이 조금 우려됩니다.
이 작업들이 사용자 응답 시간과 분리되어야 하는 성격이라면, 워크로드별로 executor를 분리하거나 포화 시에는 명시적으로 reject/관측 가능하게 처리하는 쪽이 운영상 더 예측 가능할 것 같습니다.
만약 CallerRunsPolicy를 유지하는 게 의도라면, 포화 시에도 호출 스레드 실행을 허용해도 괜찮은 이유가 함께 있으면 좋겠습니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 지적 감사합니다.
먼저 CallerRunsPolicy를 선택한 이유는 해당 executor에 올라가는 작업들의 성격때문입니다.
현재 이 전역 async executor에 태우는 작업들(링크 동기화, 요약 큐 적재, 상태 알림 등)은 대부분 유실되면 안 되는 정합성 작업입니다.
특히 이들은 대부분 @TransactionalEventListener(AFTER_COMMIT)에서 트리거되는데, 여기서 AbortPolicy로 드롭되면 트랜잭션은 이미 커밋됐는데 후속 작업만 유실되어 정합성이 깨져 동기화 누락이나 요약 영구 미실행 등의 문제가 발생할 수 있습니다.
게다가 해당 rejectionsubmit 시점에 호출 스레드로 던져져 리스너의 @Retryable로도 잡기 불가능합니다.
이러한 이유로 포화 시 작업을 잃기보다 프로듀서를 느리게 만드는 백프레셔로 CallerRunsPolicy를 택했습니다.
또한 큐의 경우도 bounded(100)으로 설정하여 무한정 쌓이지 않고, 각 설정 값에 대해서도 아래와 같은 의 보수적sizing이라 caller-runs 발동은 지속 포화 상황의 edge 케이스라고 생각됩니다.

  • core 5(평상시 상주 스레드 5개)
  • max 20(부하 시 최대 20개까지 확장)
  • queue 100(스레드가 다 찼을 때 대기 작업을 최대 100개까지 queuing)

다만 말씀하신 워크로드 혼재와 포화 시 요청/이벤트 스레드 지연 문제에 대해서는 충분히 발생 가능한 문제로 확인되어 아래와 같은 방법을 통해 보완하도록 하겠습니다.

  • 관측: applicationTaskExecutor는 자동계측되어 executor_active_threads / executor_queued_tasks / executor_pool_size_threads 원본 지표가 /actuator/prometheus에 노출됩니다. 다만 이걸 보는 대시보드 패널과 포화 알림 룰은 존재하지 않아, executor 포화 패널과 알림 룰(queued가 capacity(100)에·active가 max(20)에 근접)을 이번 PR에 함께 작업해서 올리도록 하겠습니다.
  • 분리: AI 연동 계열과 이벤트 후속 처리 계열을 별도 executor로 나누는 것에 대해서는 동의합니다. 다만 이번 PR 범위가 "전역 executor bounded화 + graceful shutdown 안정화"라, executor 분리 작업은 별도 이슈를 통해 작업하는 것이 좋을 것 같습니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다. 그렇게 하시죠.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Goder-0 말씀 주신 포화 관측 작업 진행했고, PR에도 반영했습니다.

  • Grafana: AI/Async 대시보드에 executor 포화 패널 3개 추가 (큐 대기 / 스레드 active·pool·max / 큐 사용률 게이지)
  • Prometheus: 포화 알림 룰 2개 추가 (AsyncExecutorQueueSaturation, AsyncExecutorPoolExhausted, for: 2m)

이제 CallerRunsPolicy가 발동하는 포화 상황을 그래프·알림으로 사전 감지할 수 있습니다.

워크로드별 executor 분리는 이번 PR 범위와 성격이 달라 별도 이슈(#260)로 분리했습니다.

@ckdals4600

Copy link
Copy Markdown
Contributor Author

현재 AI 호출 수를 기록하는 ai.client.calls 메트릭 정의를 보면, RagAnswerClient.buildCounter()에서는 태그를 client, result두 개만 사용하고 있고, RagSummaryClient.buildCounter()에서는 같은 메트릭 이름에 client, operation, result 세 개를 사용하고 있습니다. 동일한 메트릭 이름이라면 태그 키셋도 동일해야 해서, 나중에 Prometheus/Micrometer 등록 충돌이 날 가능성이 있습니다. 이전 PR에서 놓친 포인트를 발견한 것 같긴한데요, 여기서 바로 blocking으로 잡기보다는 별도 이슈로 분리해서 정리해도 좋을 것 같습니다. 이 부분은 별도 이슈로 만들까 하는데 어떠신가요?

정확한 지적 감사합니다. RagSummaryClientinitial·regenerate 두 오퍼레이션이 있어 operation 태그를 넣었고, RagAnswerClient 는 오퍼레이션이 하나뿐이라 넣지 않아서 태그 키셋이 달라졌습니다. 각 클라이언트만 보면 자연스러웠지만, 같은 메트릭 이름(ai.client.calls)을 공유한다는 점을 놓쳤습니다. 등록 충돌·집계 오류 가능성이 있어 통일이 필요하고, #256 범위와 성격이 달라 별도 이슈로 분리하는 데 동의합니다. RagAnswerClientoperation=generate 를 추가해 양쪽을 client·operation·result 로 맞추는 방향으로 진행하는 것이 좋아보입니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

비동기 Executor 안정화 및 AI 응답 실패 처리 전환

2 participants