방문 기록 저장 API는 성공했는데, 웹사이트의 카테고리는 비어 있었습니다. 외부 분류 API가 복구된 뒤 같은 웹사이트를 다시 방문해도 달라지지 않았는데요.
Retoday는 기록 저장이 분류 응답을 기다리지 않도록, 커밋 이후 비동기 이벤트로 카테고리를 분류했습니다. 이 구조에서 실패한 분류 요청은 어떻게 다시 실행할 수 있을까요? 외부 API 오류를 재현하고 이벤트의 실행 경로를 따라가며, 미완료 작업을 아웃박스에 남기도록 바꾼 과정을 소개합니다.
문제 현상
외부 분류 API가 요청마다 50% 확률로 오류를 반환하고, 50% 확률로 분류에 성공하도록 모킹했습니다. 서로 다른 사용자 100명이 각각 새로운 웹사이트를 방문하는 테스트를 진행했습니다.
모든 요청과 이벤트 처리가 끝난 뒤, 다음 쿼리로 웹사이트의 분류 상태를 확인했습니다.
1SELECT COUNT(*) AS `웹사이트 개수`,
2 SUM(category IS NOT NULL) AS `분류된 웹사이트 개수`,
3 SUM(category IS NULL) AS `분류되지 않은 웹사이트 개수`
4FROM website
5WHERE domain LIKE 'mixed-%.example.com';
| 웹사이트 개수 | 분류된 웹사이트 개수 | 분류되지 않은 웹사이트 개수 |
|---|---|---|
| 100 | 48 | 52 |
방문 기록 API는 100건 모두 성공했고 DB에도 기록 100건이 저장됐습니다. 하지만 분류에 실패한 웹사이트 52개는 카테고리가 비어 있었습니다.
외부 API를 정상 응답으로 바꾼 뒤, 기존 도메인을 재방문하는 경우와 새 도메인을 방문하는 경우도 비교했습니다.
| 확인 단계 | 방문 기록 API 성공 | 추가 분류 API 호출 | 미분류 / 분류 완료 |
|---|---|---|---|
| 정상 응답으로 변경 후 15초 관찰 | 요청 없음 | 0회 | 52 / 48 |
| 기존 도메인 100개 재방문 후 5초 관찰 | 100 / 100 | 0회 | 52 / 48 |
| 정상 응답 상태에서 새 도메인 100개 방문 | 100 / 100 | 100회 | 0 / 100 |
새 도메인은 정상적으로 분류됐지만, 앞서 실패한 도메인은 재방문해도 분류 요청이 다시 발생하지 않았습니다.
문제 원인
첫 번째 원인: 비동기 예외 처리
먼저 이벤트가 전달되지 않은 것인지, 전달된 뒤 처리가 실패한 것인지 확인했습니다.
Unexpected exception occurred invoking async method: public void com.retoday.core.domain.history.event.RecordHistoryEventListener.handle(com.retoday.core.domain.history.dto.event.RecordHistoryEvent)
at com.retoday.core.domain.history.client.WebsiteClient.categorizeWebsite(WebsiteClient.kt:32) ~[core-0.0.1-plain.jar!/:?]
at com.retoday.core.domain.history.service.WebsiteService.categorizeWebsite(WebsiteService.kt:61) ~[core-0.0.1-plain.jar!/:?]
at com.retoday.core.domain.history.event.RecordHistoryEventListener.handle(RecordHistoryEventListener.kt:17) ~[core-0.0.1-plain.jar!/:?]
Caused by: com.google.genai.errors.ServerException: 503 Service Unavailable. Injected category API outage
로그에는 이벤트 리스너에서 외부 API를 호출한 뒤 예외가 발생한 과정이 남아 있었습니다. 이벤트가 전달되지 않은 것이 아니라, 전달된 뒤 분류에 실패한 경우였는데요. 분류 결과를 받지 못했으므로 웹사이트의 카테고리도 저장되지 않았습니다.
방문 기록 API가 성공한 이유는 리스너의 실행 시점에 있었습니다.
1@Async
2@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
3fun handle(event: RecordHistoryEvent) {
4 websiteService.categorizeWebsite(event.websiteId)
5}AFTER_COMMIT 리스너는 방문 기록 저장이 커밋된 뒤 실행됩니다.
여기에 @Async가 붙어 별도 스레드에서 실행되는데요. Spring 공식 문서에서는 비동기 이벤트 리스너의 예외를 다음과 같이 설명합니다.
If an asynchronous event listener throws an
Exception, it is not propagated to the caller.
- Spring 공식 문서
분류 중 발생한 예외는 호출자에게 전달되지 않습니다. 따라서 방문 기록은 저장됐지만 카테고리는 비어 있는 상태로 남을 수 있었습니다.
두 번째 원인: 실패 작업의 복구 경로 부재
그렇다면 외부 API가 정상으로 돌아온 뒤에도 카테고리가 비어 있는 이유는 무엇일까요? 기존 구현에는 실패한 분류 요청을 저장하거나 재시도하는 처리가 없었습니다.
By default, the exception is merely logged.
- Spring 공식 문서
반환값이 없는 비동기 메서드의 예외는 기본적으로 로그에만 남습니다. 실패한 분류 요청을 다시 실행하려면 별도 처리가 필요한데요. 재방문 시에도 이벤트를 다시 발행하지 않아 복구되지 않았습니다.
1if (inserted) {
2 eventPublisher.publishEvent(
3 RecordHistoryEvent(
4 websiteId = id!!
5 )
6 )
7}이미 존재하는 도메인에서는 inserted가 거짓이므로 이벤트를 다시 발행하지 않습니다.
실패한 도메인의 재방문에서 외부 호출이 0회였던 결과와도 일치했습니다.
문제 해결
실패한 분류 요청을 다시 처리할 수 있도록 아웃박스(Outbox)를 도입했습니다. 처리할 요청을 DB에 먼저 저장하고, 스케줄러가 미완료 요청을 가져와 실행하는 방식입니다.
첫 번째 변경: 분류 요청 저장
새 웹사이트와 분류 요청을 같은 트랜잭션에서 저장했습니다.
분류 요청은 website_category_classification_outbox 테이블에 웹사이트 ID와 처리 상태로 남깁니다.
1@Transactional
2fun upsertWebsite(command: UpsertWebsiteCommand): Website =
3 with(command) {
4 ...
5 val websiteId = createUuid()
6 val upsertedWebsite =
7 websiteRepository.upsertByDomain(
8 Website(
9 id = websiteId,
10 ...
11 )
12 )
13 val isNew = upsertedWebsite.id == websiteId
14
15 if (isNew) {
16 websiteCategoryClassificationOutboxRepository.save(
17 WebsiteCategoryClassificationOutbox(
18 websiteId = websiteId
19 )
20 )
21 }
22
23 return upsertedWebsite
24 }이번 요청에서 만든 UUID와 Upsert가 반환한 ID가 같을 때만 아웃박스를 저장합니다.
다른 요청이 먼저 만든 웹사이트라면 추가하지 않으며, website_id에도 유니크 인덱스를 두었습니다.
아웃박스 저장이 실패하면 웹사이트 저장도 롤백됩니다. 반대로 커밋이 끝났다면 서버가 종료돼도 미완료 분류 요청을 DB에서 다시 찾을 수 있습니다.
두 번째 변경: 작업 선점과 재처리
여러 작업자가 같은 작업을 동시에 선점하지 않도록 FOR UPDATE SKIP LOCKED로 처리할 아웃박스 한 건을 조회합니다.
1SELECT *
2FROM website_category_classification_outbox
3WHERE (
4 status = 'PENDING'
5 AND (
6 last_attempted_at IS NULL
7 OR last_attempted_at <= :retryableAttemptedBefore
8 )
9) OR (
10 status = 'PROCESSING'
11 AND last_attempted_at <= :recoverableAttemptedBefore
12)
13ORDER BY last_attempted_at ASC, created_at ASC
14LIMIT 1
15FOR UPDATE SKIP LOCKED;
다른 트랜잭션이 잠근 행은 건너뛰고, 선택한 행을 같은 트랜잭션에서 PROCESSING으로 변경합니다.
이때 시도 횟수와 마지막 시도 시각도 갱신하는데요. 외부 API는 커밋 후 호출해 응답을 기다리는 동안 DB 락과 커넥션을 유지하지 않도록 했습니다.
분류에 성공하면 COMPLETED로, 실패하면 재시도할 수 있도록 PENDING으로 변경합니다.
허용한 시도 횟수에 도달한 작업은 FAILED로 남겨 자동 처리를 중단합니다.
| 작업 상태 | 다음 처리 조건 |
|---|---|
한 번도 시도하지 않은 PENDING | 바로 선점 |
실패 후 돌아온 PENDING | 마지막 시도에서 retryDelay가 지난 뒤 선점 |
처리 중 남겨진 PROCESSING | 마지막 시도에서 processingTimeout이 지난 뒤 다시 선점 |
서버가 처리 도중 종료되면 예외 처리도 실행되지 않아 PROCESSING이 남을 수 있습니다. 마지막 시도 시각을 기준으로 다시 선점하는 조건은 이 경우를 복구하기 위한 것입니다.
카테고리만 저장하고 완료 상태를 남기지 못한 경우도 고려했습니다.
재처리 시 이미 카테고리가 있으면 WebsiteCategoryAlreadyExistsException을 완료로 처리해, 외부 호출 없이 아웃박스를 COMPLETED로 바꿉니다.
회귀 테스트
외부 AI 응답을 모킹한 서비스 테스트와 MySQL 저장소 테스트로 변경한 처리 흐름을 확인했습니다.
| 검증 대상 | 확인한 내용 | 결과 |
|---|---|---|
| 웹사이트 서비스 | 신규 아웃박스 저장, 기존 행·동시 생성 경쟁 시 추가 저장 생략, 분류 결과 저장 | 9개 통과 |
| 아웃박스 서비스 | 선점 후 완료, 처리 대상 없음, 마지막 허용 시도 실패 시 FAILED 저장 | 3개 통과 |
| 아웃박스 저장소 | 미시도·재시도 후보 선택, 만료된 PROCESSING 재조회 | 2개 통과 |
14개 테스트가 모두 통과했습니다. 다만 서비스 테스트에서 저장소와 트랜잭션 실행을 모킹했으므로, 실제 커밋·롤백과 서버 강제 종료 후 복구까지 검증한 결과는 아닙니다. 변경 후 전체 API 흐름도 별도 검증이 필요합니다.