Skip to content

feat(S15P11A705-159): 유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer 추가 - #104

Merged
colosair merged 6 commits into
devfrom
feat/S15P11A705-159-ai-rescan-scheduler
Jul 30, 2026
Merged

feat(S15P11A705-159): 유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer 추가#104
colosair merged 6 commits into
devfrom
feat/S15P11A705-159-ai-rescan-scheduler

Conversation

@colosair

@colosair colosair commented Jul 30, 2026

Copy link
Copy Markdown
Member

요약

유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer를 추가합니다. AI 연동의 실패 경로 네 곳이 모두 "재스캔이 복구한다"를 안전망으로 전제하는데 그 재스캔이 없어, 한 번 실패한 Context가 영구히 PENDING으로 남았습니다. 이 저장소에 스케줄링이 처음 들어옵니다(@Scheduled가 0건이었습니다). 도메인 스키마·마이그레이션 변경은 없습니다.

Jira (필수)

관련 GitHub Issue (선택)

구현 기록 번호를 BI-26BI-28로 재배정했습니다. 착수 시점에 26이 비어 있었는데, back#100(인프라 S15P11A705-154)이 원래 쓰던 BI-25를 back#98(개인 검색)이 먼저 병합해 가져가면서 26으로 옮겨 겹쳤습니다. 나중에 병합되는 쪽이 재배정한다는 규칙에 따라 우리가 옮겼고, 27은 back#103이 선점 중이라 건너뛰었습니다(브랜치별 contents API로 직접 확인: dev 24·25·26, back#103 27). BD-40은 어느 브랜치와도 겹치지 않아 그대로 둡니다.

배경

증상은 정상과 구별되지 않는 실패입니다. 아래 네 곳이 각자 주석으로 재스캔을 근거로 들고 있었습니다.

실패 지점 코드가 하는 말
AiIntegrationConfig 큐 포화 "버려진 요청은 PENDING으로 남아 재스캔 대상이 되므로 유실이 아니다"
AiProcessClient (401 포함 전부 삼킴) "PENDING이 남아 있으므로 재스캔이 같은 Context를 다시 집는다"
ContextAiRequestedListener 조립 실패 "PENDING은 이미 커밋되어 있어 재스캔이 같은 Context를 다시 집는다"
FastAPI가 202 이후 내부에서 실패 통보 경로가 없습니다

정책의 정본은 AI 파트가 소유한 docs/ai/spec/ai-rescan-scheduler.md이며 이 PR은 그 명세를 고치지 않았습니다. 파라미터(5분·5분·10분·3회·100건, Backoff 없음)와 "리더 선출을 두지 않는다"는 명세가 정한 값입니다.

변경 사항

회차

AiRescanScheduler#runOnce   @Scheduled(fixedDelayString = "${pinlog.ai.rescan.interval}")
 1. AiFailedFinalizer#finalizeExpired      @Transactional   ← retry_count >= 3 만료 → FAILED
 2. AiRescanCandidateService#claimStale    @Transactional   ← retry_count < 3 만료 잠금 + retry 증가
    ────────── 커밋 ──────────
 3. ContextProcessRequestAssembler#assemble(contextId)      ← Core 재조회 = 삭제 확인
 4. AiProcessClient#process                                 ← 트랜잭션 밖
  • domain/ai/scheduler/AiRescanScheduler@Scheduled 진입점과 트랜잭션 밖 외부 호출. 이 클래스는 트랜잭션을 열지 않습니다 — 열면 뒤의 HTTP 호출 시간만큼 행 잠금과 DB 커넥션이 붙잡힙니다.
  • domain/ai/service/AiFailedFinalizer — 명세 6장. 미완료 단계만 FAILED로 종결하고 FastAPI를 부르지 않습니다.
  • domain/ai/service/AiRescanCandidateService — 명세 4·5장. 후보 잠금 + retry_count 증가가 이 트랜잭션 경계 안에서 끝납니다. 삭제된 Context의 상태를 다시 읽는 단건 조회도 여기 있습니다(명세 5.1의 정합성 경고는 후보 조회 결과로 판정할 수 없습니다 — 후보 조회가 CANCELLED를 애초에 잡지 않기 때문입니다).
  • domain/ai/service/AiRescanProperties@ConfigurationProperties("pinlog.ai.rescan"). AiProperties에 합치지 않은 이유는 저쪽이 FastAPI 연결 계약이고 이쪽은 복구 정책이라 바뀌는 이유가 다르기 때문입니다.
  • domain/ai/repository/AiRescanStateRepository · ContextAiStateRow — SQL 전부. 후보 조회는 호출자의 트랜잭션 안에서만 부르는 계약입니다.
  • domain/ai/service/ContextProcessRequestAssemblerassemble(long contextId) 진입점을 더했습니다. 기존 이벤트 경로의 동작은 바꾸지 않았고 공통 본문만 private 메서드로 뺐습니다.
  • domain/ai/AiIntegrationConfig@EnableScheduling과 전용 ThreadPoolTaskScheduler. @EnableAsync와 같은 사정으로 여기 둡니다(전역 스위치인데 켜야 하는 이유가 이 연동에만 있습니다).
  • application.ymlpinlog.ai.rescan 다섯 값.
  • docs/backend/decisions/BD-40 — 스케줄링을 들이는 방식과 분산 락을 두지 않는 결정.
  • docs/backend/implements/BI-28 — 구현·검증과, 명세의 근거 하나가 실측에서 관측되지 않은 경위.
  • docs/development/package-structure.mdai 행에 scheduler 계층 추가(파일을 만들기 전에 먼저 갱신했습니다).

Bean이 셋으로 갈린 이유

트랜잭션 프록시입니다. 한 클래스에 두면 자기 메서드 호출이 프록시를 지나지 않아 트랜잭션 없이 돌고, 그러면 FOR UPDATE SKIP LOCKED의 잠금이 조회 직후 풀려 중복 방어가 조용히 사라집니다. 리더 선출을 두지 않는 근거가 그 잠금이므로, 이 경계가 곧 조정 장치입니다.

만료 술어를 하나로 공유합니다

명세 6.1이 "만료 기준은 재스캔과 동일하다"고 정하므로 두 SQL에 따로 적으면 한쪽만 고쳐 기준이 갈라집니다. String.formatted로 한 상수를 두 쿼리에 끼웠습니다. 시각 비교는 DB now()로 합니다 — 자바에서 컷오프를 계산하면 인스턴스마다 시계가 달라 만료 시점이 갈라집니다.

테스트 / 검증

  • ./gradlew clean check --no-daemonBUILD SUCCESSFUL, 390 tests, 0 failures, 0 skipped (checkstyle·jacoco 포함, jacoco LINE 96.45% / BRANCH 82.80%, 게이트 80%). dev 병합(back#100) 후 재실행한 결과입니다
  • DB 변경 → PostgreSQL Testcontainers 통합 테스트 AiRescanSchedulerTests 13개 (pgvector/pgvector:0.8.5-pg16)
  • migration 변경 없음 — 스키마는 V100에 이미 있습니다
  • API 계약 변경 없음 — 외부 진입점이 없습니다
  • 되돌리기 어려운 결정 → docs/backend/decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md

만료를 만드는 방법

updated_at을 과거로 밀어 넣습니다(5분을 기다릴 수 없습니다). 임계값을 기본값이 아닌 2분·4분으로 덮고 세 행의 나이를 그 사이에 배치해 한 회차에서 갈라지는 것을 봅니다 — 임계값이 코드 상수라면 이 배치가 성립하지 않으므로 이 테스트가 곧 "설정으로 주입된다"의 확인입니다. 기본값 자체는 어느 통합 테스트도 지키지 못하므로(각자 덮습니다) ConfigurationContractTestsapplication.yml의 다섯 값을 파일로 고정합니다.

SKIP LOCKED를 실제로 관측합니다

다른 커넥션(DriverManager)이 한 행을 FOR UPDATE로 붙잡은 채 회차를 돌리고, 그 행의 retry_count가 그대로이며 다른 만료 행은 처리된 것을 확인합니다. 회차를 별 스레드에서 돌립니다SKIP LOCKED가 빠지면 후보 조회가 잠금을 기다리며 멈추는데, 같은 스레드에서 부르면 테스트가 실패하는 대신 영원히 매달립니다. 15초 타임아웃으로 실패로 드러나게 했습니다.

RED 확인 (6건)

구현을 하나씩 되돌려 테스트가 실제로 잡는지 확인했습니다.

되돌린 것 실패한 테스트
runOnce의 Finalize를 재스캔 뒤로 finalizeRunsBeforeTheCandidateClaimInEveryRound (통합 테스트는 통과 — 리뷰 포인트 1)
Finalizer 후보 조회에서 만료 조건 제거 anExhaustedRowThatIsNotExpiredYetIsLeftAlone
FOR UPDATE SKIP LOCKEDFOR UPDATE skipLockedLeavesALockedRowToWhoeverHoldsIt (15초 타임아웃)
종결 CASE를 화이트리스트 → 블랙리스트 theFinalizerKeepsCompletedStagesAndNeverOverwritesCancelled (CANCELLEDFAILED로 뒤집힙니다)
PROCESSING 만료값을 PENDING과 같게 expiryThresholdsComeFromConfigurationAndDifferByStage
fixedDelayStringfixedRateString theRoundIsScheduledWithFixedDelayAndReadsTheConfiguredInterval

회귀

기존 ContextAiEnqueueTests 8개와 AiDerivedDataInvalidationTests 9개가 그대로 통과합니다 — 조립기에 진입점을 더하면서 이벤트 경로를 건드리지 않았음을 이 둘이 지킵니다.

리뷰 포인트

  1. 명세가 든 근거 하나가 실측에서 관측되지 않았습니다. 명세 3.1은 Finalize를 먼저 두는 이유를 *"나중에 두면 같은 회차에서 방금 retry_count를 3으로 올린 행을 곧바로 FAILED로 종결한다"*로 듭니다. 그것을 결과로 고정하려고 테스트를 썼는데 runOnce의 두 줄을 맞바꿔도 통과했습니다. 재시도 증가가 updated_at을 함께 갱신해 그 행이 만료 상태에서 벗어나 Finalizer 후보 조건(6.1의 만료 조건)에 걸리지 않기 때문입니다. 명세 6.1도 그 둘이 "함께 그 창을 확보한다"고 쓰고 있어 명세와 어긋나는 관측은 아니지만, 순서만으로 그 창이 생긴다는 읽기는 사실이 아닙니다. 검증을 셋으로 나누고(결과 / 만료 조건 / 호출 순서) 순서는 심층 방어로 남겼습니다 — 누군가 증가 UPDATE에서 updated_at 갱신을 빼면 순서가 유일한 보호가 됩니다. 경위는 BI-28에 적었습니다. 명세를 고치는 판단이 필요하면 AI 파트 소관이라 손대지 않았습니다.
  2. taskScheduler라는 Bean 이름을 점유합니다. Spring은 스케줄러를 작업별로 고르지 않고 ScheduledTaskRegistrar 단위로 하나 고르므로 @Scheduled 메서드가 자기 스케줄러를 지목할 방법이 없습니다. 이름을 다르게 두면 타입이 유일할 때만 해석되고 두 번째 TaskScheduler Bean이 생기는 순간 조용히 로컬 단일 스레드 실행자로 떨어집니다. 그래서 이름을 점유해 해석을 확정적으로 만들고, TaskScheduler Bean이 하나뿐임을 테스트로 단언했습니다. 대가로 Boot의 spring.task.scheduling.* 프로퍼티가 효력을 잃고, 스레드 이름 ai-rescan-이 두 번째 배치가 붙는 순간 거짓말이 됩니다(BD-40 감수 목록).
  3. @EnableSchedulingAiIntegrationConfig에 두었습니다. 전역 스위치를 도메인 설정 클래스에 두는 것이라 판단이 필요합니다. @EnableAsync가 같은 자리에 있는 선례를 따랐고 근거도 같습니다 — 켜야 하는 이유가 이 연동에만 있어 global/config로 올리면 스위치와 유일한 소비자가 떨어져 앉습니다.
  4. interval만 ISO-8601(PT5M)입니다. 같은 블록의 다른 Duration은 5m인데, 이 값만은 Boot의 완화된 바인딩이 아니라 @Scheduled(fixedDelayString)이 직접 파싱하고 그쪽은 숫자(밀리초)나 ISO-8601만 받습니다. 5m으로 두면 NumberFormatException으로 기동이 실패합니다. 형식 불일치가 눈에 걸릴 자리라 yml 주석·AiRescanProperties javadoc·ConfigurationContractTests 셋에 이유를 남겼습니다.
  5. 테스트 격리를 UPDATE ai.context_ai_state SET updated_at = now()로 합니다. @BeforeEach·@AfterEach 양쪽에서 돕니다. 컨테이너를 JVM이 공유하고 이 클래스가 실제로 커밋하므로, 앞선 테스트가 남긴 만료 행이 다음 회차에 후보로 섞이면 "호출이 오지 않아야 한다" 류 단언이 남의 행 때문에 깨집니다. 뒤도 정리하는 이유는 다른 테스트 클래스의 배경 회차에 만료 행을 물려주지 않기 위해서입니다. Core에 없는 context_id음수로 만듭니다(ai.context_ai_statecore.context FK가 없고, 음수면 IDENTITY 값과 절대 겹치지 않습니다).
  6. @EnableScheduling이 전역이라 모든 @SpringBootTest가 스케줄러를 함께 띄웁니다. IntegrationContainerSupport에서 주기를 PT1H로 늘려 배경 실행을 없앴습니다. 조건부로 끄지 않은 이유는 등록 자체(fixedDelay인지, 전용 스케줄러를 쓰는지)가 검증 대상이기 때문입니다.

미결 / 후속

  • 관측·알림 파이프라인(명세 8장). 회차별 후보·종결 건수는 INFO 로그 한 줄로만 남습니다. 지표(retry_count 분포, 상태별 잔량, retry_count >= 3이면서 PENDING/PROCESSING인 잔량)는 없습니다. 마지막 항목이 Finalizer의 건강 상태이며 정상이면 한 주기 안에 0으로 수렴해야 합니다. 이 티켓 범위는 Finalizer가 FAILED로 드러내는 것까지입니다.
  • FAILED 전환 로그에 "마지막 실패 사유"가 없습니다. 명세 6.3이 요구하지만 그 값이 Spring 쪽에 존재하지 않습니다 — 202 이후의 실패는 FastAPI 내부에서 일어나고 통보 경로가 없습니다. 가진 단서(종결 직전 단계별 status·소진한 재시도 횟수)를 남기고 사유를 어디서 찾아야 하는지 문장으로 가리켰습니다. 실을 수 있으려면 FastAPI가 실패를 기록해야 하고 그것은 S15P11A705-121(ai#44) 소관입니다.
  • 회차 안 HTTP 호출은 순차입니다. 배치 100건이 전부 재요청 대상이면 한 회차가 길어집니다. fixedDelay라 겹치지는 않지만 만료 행이 밀립니다. 실제로 보이면 BD-40의 재검토 트리거를 따릅니다.

colosair and others added 3 commits July 30, 2026 12:53
AI 연동의 실패 경로 네 곳이 모두 "재스캔이 복구한다"를 안전망으로 전제하는데 그 재스캔이
없었습니다. 큐 포화로 버려진 요청, 삼켜진 호출 실패, 커밋 이후 리스너의 조립 실패, 그리고
FastAPI가 202 이후 내부에서 실패한 경우 — 네 경우 모두 한 번 실패한 Context가 영구히 PENDING
으로 남았습니다. 상태만 보면 "처리 대기 중"이라 정상과 구별되지 않습니다.

정본은 AI 파트가 소유한 docs/ai/spec/ai-rescan-scheduler.md 3~7장입니다.

한 회차의 순서는 Finalize → 후보 잠금·retry 증가 → 커밋 → Context 재조회 → 삭제 확인 →
FastAPI 호출입니다. Bean을 셋으로 가른 것은 트랜잭션 프록시 때문입니다 — 한 클래스에 두면 자기
메서드 호출이 프록시를 지나지 않아 트랜잭션 없이 돌고, 그러면 FOR UPDATE SKIP LOCKED의 잠금이
조회 직후 풀려 중복 방어가 조용히 사라집니다. 스케줄러 자신은 트랜잭션을 열지 않습니다 —
열면 뒤의 HTTP 호출 시간만큼 행 잠금과 DB 커넥션이 붙잡힙니다.

만료 술어는 재스캔과 Finalizer가 한 상수를 공유합니다. 명세 6.1이 "만료 기준은 재스캔과
동일하다"고 정하므로 따로 적으면 한쪽만 고쳐 기준이 갈라집니다. 시각 비교는 DB now()로 합니다 —
자바에서 컷오프를 계산하면 인스턴스마다 시계가 달라 만료 시점이 갈라집니다.

스케줄링이 이 저장소에 처음 들어옵니다. @EnableScheduling은 @EnableAsync와 같은 사정으로
AiIntegrationConfig에 두었고, 전용 ThreadPoolTaskScheduler로 Boot의 기본 단일 스레드 스케줄러를
대체합니다. Bean 이름을 taskScheduler로 점유한 이유와 감수하는 것은 BD-40에 적었습니다.

pinlog.ai.rescan.interval만 ISO-8601인 것은 실수가 아닙니다. 이 값은 Boot의 완화된 바인딩이
아니라 @scheduled(fixedDelayString)이 직접 파싱하고, 그쪽은 숫자나 ISO-8601만 받습니다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PostgreSQL Testcontainers 통합 13개와 대역·리플렉션 단위 2개, 설정 계약 1개입니다.

만료는 updated_at을 과거로 밀어 만듭니다(5분을 기다릴 수 없습니다). 임계값을 기본값이 아닌
2분·4분으로 덮고 세 행의 나이를 그 사이에 배치해 한 회차에서 갈라지는 것을 봅니다 — 임계값이
코드 상수라면 이 배치가 성립하지 않으므로, 이 테스트가 곧 "설정으로 주입된다"의 확인입니다.
기본값 자체는 어느 통합 테스트도 지키지 못하므로 ConfigurationContractTests가 파일로 고정합니다.

SKIP LOCKED는 주장으로 두지 않고 다른 커넥션이 행을 FOR UPDATE로 붙잡은 채 회차를 돌려 실제로
건너뛰는지 봅니다. 회차를 별 스레드에서 돌리는 이유는 SKIP LOCKED가 빠지면 후보 조회가 잠금을
기다리며 멈추는데 같은 스레드에서는 테스트가 실패하는 대신 영원히 매달리기 때문입니다.
15초 타임아웃을 걸어 실패로 드러나게 했습니다.

"Finalize를 먼저"에 대해 명세가 든 근거는 실측에서 관측되지 않았습니다. runOnce의 두 줄을
맞바꿔도 통합 테스트가 통과합니다 — retry 증가가 updated_at을 함께 갱신해 그 행이 만료 상태에서
벗어나 Finalizer 후보 조건에 걸리지 않기 때문입니다. 창을 실제로 확보하는 것은 만료 조건과
updated_at 갱신이고, 순서는 심층 방어로 남겨 InOrder 단위 테스트로 고정했습니다. 근거와 나눈
검증 셋은 BI-26에 적었습니다.

@EnableScheduling은 전역이라 모든 @SpringBootTest가 스케줄러를 함께 띄우므로,
IntegrationContainerSupport에서 주기를 늘려 배경 실행을 없앴습니다. 끄지 않은 이유는 등록
자체가 검증 대상이기 때문입니다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BD-40은 이 저장소에 스케줄링을 들이는 방식을 남깁니다. 리더 선출을 두지 않는 것은 AI 파트
명세가 정한 제약이고, 제약 안에서 우리가 한 선택은 그 전제를 코드로 지키는 방식(후보 조회를
트랜잭션 안에서만 부르고 외부 호출은 커밋 뒤에 하는 것)입니다. 자체 taskScheduler Bean은
능동적 선택이며, Boot 자동설정이 물러나는 것과 스레드 이름이 언젠가 거짓말이 되는 것을
감수 목록에 적었습니다.

BI-26은 구현과 검증, 그리고 명세의 근거 하나가 실측에서 관측되지 않은 경위를 적습니다.

package-structure.md의 ai 행에 scheduler 계층을 더했습니다. event와 가른 기준은 무엇이 호출을
촉발하는가이고, 그에 따라 트랜잭션 경계도 다릅니다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@colosair
colosair requested a review from minyongP July 30, 2026 03:56
@colosair

Copy link
Copy Markdown
Member Author

AI 파트 중앙입니다. 리뷰 포인트 1의 지적이 맞습니다. 명세 쪽 결함이고 구현이 옳습니다.

실측 확인

AiRescanStateRepository.INCREMENT_RETRY_SQL 을 직접 읽었습니다.

UPDATE ai.context_ai_state
SET retry_count = retry_count + 1,
    updated_at = now()
WHERE context_id IN (:contextIds)

updated_at = now() 가 함께 갱신되므로 그 행은 즉시 만료 술어(updated_at < now() - interval)를 벗어납니다. 따라서 같은 회차에서 Finalizer 후보 조건(명세 6.1)에 걸릴 수 없습니다. runOnce 두 줄을 맞바꿔도 통과한다는 관측이 이것과 일치합니다.

명세가 두 곳에서 다르게 말하고 있었습니다

서술
3.1 "Finalize를 먼저 수행합니다. 나중에 두면 같은 회차에서 방금 retry_count를 3으로 올린 행을 곧바로 FAILED로 종결해…"순서가 유일한 방어로 읽힌다
6.1 "만료 조건이 없으면 retry_count를 3으로 올린 그 회차에서 곧바로 종결되어… 3.1의 「Finalize를 먼저」 순서와 이 만료 조건이 함께 그 창을 확보합니다"

6.1 이 이미 "함께"라고 적었는데 3.1 이 만료 조건을 언급하지 않아, 3.1 만 읽으면 순서가 창을 만드는 것처럼 보입니다.

그리고 updated_at 갱신은 명세 어디에도 없습니다. 3.1 의 처리 순서는 retry_count 증가 → Commit 까지만 적습니다. 스키마의 updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 는 INSERT 기본값이라 UPDATE 를 자동 갱신하지 않습니다.

구현이 명세보다 한 겹 더 안전합니다.

명세가 규정한 것    만료 조건 + Finalize 선행
구현이 더한 것      retry 증가 시 updated_at 갱신  ← 실제로 창을 확보하는 것

판정

순서를 심층 방어로 남기고 InOrder 단위 테스트로 고정한 것이 맞습니다. 근거가 하나 무너졌다고 순서를 뒤집으면, updated_at 갱신이 나중에 빠졌을 때 아무도 모르게 됩니다 — 지금은 두 장치가 서로를 받치고 있습니다.

명세를 고치지 않은 것도 맞습니다. 이 PR 의 범위는 구현이었고 계약 수정은 금지 범위였습니다.

AI 파트 후속

docs/ai/spec/ai-rescan-scheduler.md 를 두 곳 고치겠습니다. AI 파트 소유 문서이므로 저희가 처리합니다.

  1. 3.1 처리 순서에 updated_at 갱신을 명시 — 실제로 창을 확보하는 장치가 그것이므로 순서 목록에 빠져 있으면 다른 구현이 누락할 수 있다
  2. 3.1 의 「Finalize 먼저」 근거를 6.1 과 일치시킨다 — 순서는 심층 방어이고, 창은 만료 조건 + updated_at 갱신이 만든다

별건으로 티켓을 발급합니다. 이 PR 병합을 막지 않습니다.

리뷰 요청 확인

@EnableSchedulingback 전역 설정이라 AI 파트가 접점으로 판정하고 리뷰를 요청했습니다. 전용 ThreadPoolTaskScheduler 로 기본 스케줄러를 공유하지 않지만, 스케줄링 자체가 이 레포에 처음 들어옵니다. @minyongP 판단을 기다린 뒤 병합하겠습니다.

colosair and others added 2 commits July 30, 2026 13:19
back#100(S15P11A705-154 인프라)이 BI-26을 함께 선점했습니다. 착수 시점에는 26이 비어
있었는데, 그쪽이 원래 쓰던 BI-25를 back#98(개인 검색)이 먼저 병합해 가져가면서 26으로
옮긴 것입니다. 나중에 병합되는 쪽이 재배정한다는 규칙(check-number.sh)에 따라 우리가
옮깁니다.

27은 back#103(소셜 로그인 이메일 필수화)이 선점 중이라 건너뛰고 28을 씁니다. 확인은
브랜치별 contents API로 직접 했습니다 — dev는 24·25·26, back#103은 27, 우리가 26입니다.

BD-40은 어느 브랜치와도 겹치지 않아 그대로 둡니다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-rescan-scheduler

# Conflicts:
#	docs/backend/implements/README.md
@colosair

Copy link
Copy Markdown
Member Author

오늘 16:00 KST 에 자체 병합하려 합니다

AI 파트 중앙입니다. 접점 리뷰를 요청해 둔 PR 인데 오늘이 MVP 개발 마감이라 시한을 밝혀 둡니다. 그 전에 보시면 반영하고, 못 보시면 병합한 뒤 지적을 후속 PR 로 처리하겠습니다.

현재 상태 (14:25 KST 실측)

CI                    backend-ci / check  SUCCESS
mergeStateStatus      CLEAN
required approvals    0

승인 요청이 아닙니다. dev 는 승인 0건 설정이라 이 PR 은 기술적으로 이미 병합 가능합니다. 리뷰를 요청한 것은 아래 접점을 알고 계시게 하려는 것입니다.

접점은 하나입니다 — @EnableScheduling

이 PR 이 back 에 스케줄링을 처음 들여옵니다. 애플리케이션 전역 설정이라 리뷰를 요청했습니다.

격리는 해 뒀습니다.

  • 전용 ThreadPoolTaskScheduler 를 별도 Bean 으로 둡니다 — 기본 단일 스레드 스케줄러를 쓰지 않습니다
  • fixedDelay 를 씁니다. fixedRate 가 아니라서 이전 실행이 길어져도 겹쳐 돌지 않습니다
  • 현재 back 에 다른 @Scheduled 가 없어 지금 시점의 영향 범위는 이 스케줄러 하나입니다

나중에 다른 배치가 붙을 때 기본 스케줄러를 공유하지 않도록 하는 것이 이 구조의 의도입니다. 그 판단에 이견이 있으시면 그 부분이 가장 듣고 싶은 지점입니다.

왜 미루기 어려운가

이 구현이 없으면 IS#99 에 적은 네 경로가 안전망 없이 남습니다. 특히 AiProcessClient 가 401 을 포함한 모든 실패를 삼키므로, 시크릿이 어긋난 채 배포되면 오류 응답 하나 없이 임베딩만 생기지 않습니다. 통합 검증에서도 정상과 구별되지 않는 형태라, 마감 전에 들어가 있어야 그 경로를 실제로 관측할 수 있습니다.

병합 후에도

IS#99 에 무엇을 어떻게 했는지 남기겠습니다. 이후 지적은 후속 PR 로 받습니다 — 되돌릴 수 없는 변경이 아닙니다.

@colosair
colosair merged commit 4a9ab36 into dev Jul 30, 2026
2 checks passed
@colosair
colosair deleted the feat/S15P11A705-159-ai-rescan-scheduler branch July 30, 2026 05:44
cherry-go-round added a commit that referenced this pull request Jul 30, 2026
BD-41 — 탈퇴 회원의 남은 Access 토큰을 인증 필터의 PK 조회로 막은 결정.

창 안에서 새는 것이 읽기가 아니라 쓰기라는 것이 결정적이었다. 탈퇴 직후 30분간
Record·Collection 생성이 되고 그 행들은 연쇄 삭제가 지나간 뒤에 만들어져 어떤 정리
경로에도 걸리지 않는다. 요청당 PK 조회를 감수하는 편이 고아 데이터를 감수하는 것보다
싸다고 봤다.

Redis 탈퇴 마커를 기각한 이유는 성능이 아니라 장애 전파다. 필터가 Redis를 읽으면
순단이 곧 전체 인증 실패(fail-closed)이거나 순단 중 탈퇴자 통과(fail-open)가 된다.
readiness에 redis를 넣지 않기로 한 BD-28과 같은 방향이다.

Refresh 폐기를 트랜잭션 마지막에 둔 이유도 함께 적었다 — 실패가 "아무 일도 없었음"으로
수렴하는 순서이고, 뒤집으면 "로그아웃됐는데 탈퇴는 안 된" 상태가 남는다.

BI-29 — 구현 보고. 설계 판단 넷(잠금 없음·record_count 미갱신·폐기 순서·연쇄를 한
서비스에), 드러난 것 셋(CSRF가 인가보다 먼저 돎, loginAs로는 필터를 검증할 수 없음,
기존 테스트 무영향), 뮤테이션 3종 결과.

이슈 본문에서 따르지 않은 것도 표로 남겼다. #34이 2026-07-27 작성이라 email = null과
"유일값이 필요하다"가 지금 기준으로 틀렸고, "범위 밖"이라던 연쇄 삭제·AI 무효화는
이번 범위에 들어왔다.

번호는 pull 후 확정했다. BD-40이 #104(재스캔 Scheduler)에 선점돼 BD-41이다.

Refs #34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant