Skip to content
1 change: 1 addition & 0 deletions docs/backend/WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@
| 2026-07-29 | Kakao·Naver 소셜 로그인을 붙였다(#33). **새로 들인 것은 응답 형태뿐이다** — 토큰 발급·회전·쿠키·회원 확정은 공급자와 무관한 경로라 BI-18이 이미 만들어 뒀고, 이 티켓이 더한 것은 공급자마다 다른 사용자 정보를 하나로 옮기는 일이다. 셋이 다 다르다: Google `sub`(최상위), Kakao `id`(최상위, **숫자**)+`kakao_account.email`, Naver `response.id`+`response.email`. **드러난 것 넷.** ① **Naver만 Spring의 식별자 검사를 우회한다** — `user-name-attribute: response`가 감싼 Map을 지목하므로 Spring은 그 키의 존재만 확인하고 안의 `id`는 보지 않는다. Google·Kakao는 최상위 스칼라라 앞단에서 걸러 주는데 Naver만 그 보증이 없어, `required(nested(attributes,"response","id"))`가 유일한 방어선이다(없으면 `provider_user_id` NOT NULL 위반이 되어 원인이 DB까지 내려간다) ② **Kakao는 client secret을 본문으로 받는다** — 기본값 `basic`이면 토큰 교환이 401이라 `client_secret_post`를 명시했다. 게다가 콘솔에서 활성화해야 검사되는 **선택 항목**이라 콘솔 상태와 설정이 어긋나면 콜백 마지막 단계에서 실패한다 ③ **이메일 없는 가입이 기본 경로일 수 있었다** — Kakao는 동의항목을 콘솔에 설정하지 않으면 인가 요청 자체가 `KOE205`로 거절되고, 이메일 수집은 비즈 앱 전환을 요구한다. 네 층(DB·엔티티·팩토리·정규화)이 모두 nullable이라 통과하며 `callbackSucceedsWithoutEmail`로 고정했다 — 선택 동의라 **동의한 사용자도 철회할 수 있어** 비즈 앱 전환 후에도 유효한 경로다 ④ 등록정보를 추가하자 **무관한 테스트 11개가 컨텍스트 실패**했는데 원인은 이 티켓이 아니라 `.env.example`이 자격증명을 빈 값으로 정의하던 기존 함정이었다(BT-05). **실제 Kakao·Naver 계정으로 수동 검증했다**: Kakao `provider_user_id=5013244578`(숫자→문자열), Naver는 43자 식별자로 저장돼 ①의 방어선이 실제로 값을 했다(Map `toString()`이 아니다), 회원 2건 분리, `auth:refresh-index` 회원별 생성·TTL, 쿠키 4종. `08 §3.1`은 이미 세 공급자를 적고 있어 **구현이 명세를 따라잡은 것**이라 공용 문서 변경은 없다. `clean check` **326개 통과** (S15P11A705-64) | [BI-24](implements/BI-24-2026-07-29-kakao-naver-login.md) · [BT-05](troubleshooting/BT-05-dotenv-empty-value-overrides-default.md) |
| 2026-07-29 | 운영 런타임 Secret 5개를 `pinlog-secrets-prod` Environment 경계에서만 읽어 SHA 고정 Infra action으로 넘기는 수동 workflow를 추가했다. bridge token을 포함한 참조 6개 집합·최소 권한·`github.sha` checkout/revision은 정적 계약 테스트로 고정했고 일반 `backend-ci`에는 Secret 접근을 추가하지 않았다 (S15P11A705-154) | [BI-26](implements/BI-26-2026-07-29-runtime-secret-workflow.md) |
| 2026-07-30 | back#98 리뷰 반영. `dev`가 `required_conversation_resolution`이라 **미해결 스레드가 그대로 병합 게이트**여서, 판정이 `COMMENTED`·내용이 `nit`인 줄 단위 지적 2건과 리뷰가 지목한 테스트 구멍 3건을 닫았다. 고친 둘은 **javadoc이 약속한 범위와 실제 방어 범위가 어긋난 자리**라는 점에서 성격이 같다 — ① `AiSearchClient` 생성자 javadoc이 "시크릿 검사를 여기 두지 않는 이유"로 든 논거는 "같은 키를 읽는 `AiProcessClient`가 이미 검사한다"인데, `embedding-profile`은 **이 클라이언트만 읽는 새 키**라 그 논거가 적용되지 않는다. 빈 문자열이면 FastAPI가 Profile 대조에서 422를 주므로 결과는 모든 검색이 503이고, `application.yml` 기본값은 변수를 **설정하지 않은** 경우만 막는다(`PINLOG_AI_EMBEDDING_PROFILE=`처럼 빈 값으로 정의하면 빈 문자열이 이긴다 — BT-05로 이미 겪은 형태). `requireSecret`과 같은 기준(운영 기동 실패/그 외 경고)으로 기동 시점에 끊었다. 기각된 (c)(기동 시 FastAPI 조회)가 **아니다** — 상대에게 묻지 않고 우리 값 유무만 본다. ② `distinctByRecord`는 "상대 결함이 우리 500이 되지 않게 한다"고 적어 두고 `match` **자체가 null**인 경우가 빠져 있었다. `AiSearchClient`가 최상위 `results == null`을 이미 방어하는데 그것도 계약상 올 수 없는 형태라 **층이 어긋난** 것이라, 원소 쪽 층을 맞췄다. 테스트 구멍 셋(`SEARCH_QUERY_MAX` 501자 미검증 · `PRIVATE_ONLY` 한 번도 미투입 · `insertPreset`의 `active`가 죽은 파라미터)은 **코드는 이미 맞는데 지키는 단언이 없던** 자리라 평소의 RED가 안 나온다 — 세 가드를 일부러 부순 뒤(화이트리스트를 `IN ('PUBLIC')`으로 좁히고 `is_active` 조건을 지우고 `@Size`를 떼고) **새 테스트만 실패하고 기존 24개는 전부 통과하는 것**을 관측해 RED를 대신했다. 리뷰가 "그렇게 고쳐도 전부 초록"이라고 한 것이 그대로 재현됐다. `match == null`은 보통의 RED였다(대역 `{"results":[null]}` → **500** 관측 → 가드 → 200). `docs/ai/spec/ai-integration.md` §2의 낡은 줄 2개(패키지 위치 · "Bean 하나")는 **위임 범위 밖이라 고치지 않고 남겼다**(CLAUDE.md 9). `clean check` **370개 통과**(checkstyle·jacoco 포함) (S15P11A705-135) | [BD-39](decisions/BD-39-embedding-profile-in-application-config.md) · [BI-25](implements/BI-25-2026-07-29-personal-search-backend-integration.md) |
| 2026-07-30 | 유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer를 붙였다(S15P11A705-159). **이 저장소에 스케줄링이 처음 들어온다** — `@Scheduled`가 0건이었다. AI 연동의 실패 경로 네 곳이 모두 *"재스캔이 복구한다"*를 안전망으로 전제하고 있었는데 그 재스캔이 없어, 한 번 실패한 Context가 영구히 `PENDING`으로 남았다(상태만 보면 정상과 구별되지 않는다). Bean을 셋으로 가른 것은 **트랜잭션 프록시 때문**이다 — 한 클래스에 두면 자기 메서드 호출이 프록시를 지나지 않아 트랜잭션 없이 돌고, 그러면 `FOR UPDATE SKIP LOCKED`의 잠금이 조회 직후 풀려 중복 방어가 조용히 사라진다. 명세가 근거로 든 것을 **실측으로 뒤집은 지점이 하나 있다**: "Finalize를 먼저 두는 이유는 방금 `retry_count`를 3으로 올린 행이 곧바로 종결되기 때문"이라는데, `runOnce`의 두 줄을 맞바꿔도 테스트가 통과했다 — 증가가 `updated_at`을 함께 갱신해 그 행이 **만료 상태에서 벗어나** Finalizer 후보 조건에 걸리지 않는다. 창을 실제로 확보하는 것은 순서가 아니라 만료 조건 + `updated_at` 갱신이고, 순서는 심층 방어로 남겨 `InOrder` 단위 테스트로 고정했다(그 사실을 BI-28에 적었다). `SKIP LOCKED`는 주장으로 두지 않고 **다른 커넥션이 행을 붙잡은 채 회차를 돌려** 실제로 건너뛰는지 봤다 — 없으면 테스트가 매달리므로 별 스레드 + 15초 타임아웃으로 실패로 드러나게 했다. 함정 둘: `@Scheduled(fixedDelayString)`은 Boot의 완화된 바인딩을 쓰지 않아 `5m`이면 기동이 실패한다(`PT5M`로 두고 `ConfigurationContractTests`가 고정), Spring은 스케줄러를 **작업별로 고르지 않아** 전용 스케줄러라도 Bean 이름 `taskScheduler`를 점유해야 해석이 확정된다(BD-40). RED 6건 확인. `clean check` 386개 통과 | [BD-40](decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md) · [BI-28](implements/BI-28-2026-07-30-ai-rescan-scheduler.md) · [패키지 구조](../development/package-structure.md) |
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# BD-40. 스케줄링을 `@EnableScheduling` + 자체 `taskScheduler` Bean으로 들이고, 다중 인스턴스 조정은 분산 락 없이 `SKIP LOCKED`에 맡긴다

- **상태**: Accepted
- **날짜**: 2026-07-30
- **관련**: S15P11A705-159,
[BI-28](../implements/BI-28-2026-07-30-ai-rescan-scheduler.md),
[BD-17](BD-17-async-without-message-queue.md)(메시지 큐 없이 비동기),
[BD-36](BD-36-pending-insert-in-transaction-process-call-after-commit.md),
AI 파트 소유 명세 `docs/ai/spec/ai-rescan-scheduler.md` §3·§4.2,
공용 계약 `Team-PinLog/docs` `static/05_AI_설계.md` §10.3·§10.4

## 맥락

**이 저장소에 스케줄링이 없었다.** `@Scheduled`·`@EnableScheduling`이 0건이다. 재스캔 회차가 첫 스케줄
작업이므로, 이 티켓은 "재스캔을 만든다"에 더해 **"이 레포가 주기 작업을 어떻게 돌리는가"**를 함께
정하게 된다. 뒤에 붙는 배치가 이 자리를 그대로 물려받으므로 되돌리기 비용이 코드 몇 줄이 아니다.

정해야 할 것이 둘이다.

**하나. 무엇이 회차를 돌리는가.** Boot는 `@EnableScheduling`이 켜지면 `TaskSchedulingAutoConfiguration`이
**단일 스레드** `taskScheduler`를 만들어 준다. AI 파트 명세 §3은 그것을 쓰지 말고 "전용
`ThreadPoolTaskScheduler`"를 두라고 정한다.

**둘. 인스턴스가 여러 대일 때 같은 행을 두 번 집는 것을 무엇이 막는가.** 명세 §3은 "리더 선출을 두지
않는다"고 정하고, 그 근거로 후보 선택이 `FOR UPDATE SKIP LOCKED` 기반이라는 점을 든다. 우리 쪽에서
확인할 것은 그 전제가 실제로 성립하느냐다 — 잠금은 트랜잭션이 살아 있는 동안만 유효하므로, 후보
조회를 트랜잭션 밖에서 부르면 이 결정은 근거를 잃는다.

## 선택지

| 안 | 장점 | 단점 |
|---|---|---|
| (a) `@EnableScheduling` + 자체 `taskScheduler` Bean, 조정은 `SKIP LOCKED` | 의존성이 늘지 않는다. 조정이 이미 필요한 DB 트랜잭션 안에서 공짜로 끝난다. 인스턴스가 서로 다른 행을 집으므로 처리량이 대수로 늘어난다 | `taskScheduler`라는 Bean 이름을 우리가 점유한다. 조정의 정확성이 SQL 한 구절(`SKIP LOCKED`)과 트랜잭션 경계에 달려 있다 |
| (b) `@EnableScheduling` + Boot 기본 스케줄러 | 코드가 없다 | 스레드 하나를 앞으로 생기는 모든 배치가 공유한다. 한 회차가 배치 크기(100)만큼의 HTTP 호출을 순차로 내보내므로 **다른 배치를 굶긴다.** 명세 §3이 명시적으로 거부한다 |
| (c) ShedLock 등 분산 락 라이브러리 | "한 번에 한 인스턴스"가 라이브러리 계약으로 보장된다 | 의존성과 락 테이블이 늘고, **처리량이 인스턴스 수와 무관해진다**(항상 한 대만 일한다). 무엇보다 `SKIP LOCKED`가 이미 해결한 문제를 한 번 더 해결한다 |
| (d) Kubernetes CronJob으로 외부화 | 애플리케이션에 스케줄링을 안 들인다 | Core 도메인 코드(조립기·클라이언트)를 재사용할 수 없어 재구현이 필요하다. 인프라 소관이 늘고 배포 단위가 둘이 된다 |
| (e) 작업별 전용 스케줄러 | 이름이 사실과 일치한다 | **Spring이 지원하지 않는다.** 스케줄러 선택은 `ScheduledTaskRegistrar` 단위이지 작업 단위가 아니다 |

## 결정

**(a).** 성격이 두 층으로 갈린다.

- **리더 선출을 두지 않는 것은 제약으로 주어졌다.** AI 파트 명세 §3이 정한다. 제약 안에서 우리가 한
선택은 그 전제를 코드로 지키는 방식이다 — 후보 조회를 `@Transactional` 서비스 안에서만 부르고,
외부 호출은 그 트랜잭션이 커밋된 **뒤에** 한다(명세 §3.1의 커밋 경계). 잠금은 트랜잭션과 수명이
같으므로 이 경계가 곧 조정 장치다. 그래서 스케줄러 진입점과 후보 잠금이 **서로 다른 Bean**이다 —
같은 Bean이면 자기 메서드 호출이 프록시를 지나지 않아 트랜잭션 없이 돌고, 조정이 조용히 사라진다.
- **자체 `taskScheduler` Bean은 능동적 선택이다.** (b)·(c)·(d)를 두고 골랐다.

Bean 이름을 `taskScheduler`로 둔 것도 선택이다. (e)가 불가능하므로 어차피 애플리케이션 전체가 하나를
공유하는데, 이름을 다르게 두면 **타입이 유일할 때만** 해석되고 두 번째 `TaskScheduler` Bean이 생기는
순간 조용히 로컬 단일 스레드 실행자로 떨어진다. 이름을 점유해 해석을 확정적으로 만들고, 대신 그 상태를
테스트로 고정했다(`TaskScheduler` Bean이 하나뿐임을 단언한다).

## 결과

- 이 결정으로 감수하는 것:
- **Boot의 스케줄링 자동설정이 물러난다.** `spring.task.scheduling.*` 프로퍼티가 더 이상 효력이
없다. 풀 크기·스레드 이름을 바꾸려면 `AiIntegrationConfig`를 고쳐야 한다.
- **스레드 이름 `ai-rescan-`이 언젠가 거짓말이 된다.** 두 번째 배치가 붙으면 같은 풀을 공유한다.
그때 이름을 중립적으로 바꾸거나 그 배치에 자기 registrar를 주는 판단이 필요하다.
- **조정의 정확성이 SQL 한 구절에 달려 있다.** `SKIP LOCKED`를 잃으면 후보 조회가 잠금을 기다리며
멈추고, `FOR UPDATE` 자체를 잃으면 두 인스턴스가 같은 Context를 중복 처리한다. 둘 다 조용한
실패다 — 그래서 다른 커넥션이 행을 붙잡은 채 회차를 돌려 실제로 건너뛰는지 보는 테스트를 두었다.
- **처리량 상한이 인스턴스 수 × 배치 크기다.** 락 기반((c))보다 낫지만, 한 인스턴스가 병렬로
처리하지는 않는다(회차 안에서 HTTP 호출은 순차다).
- **`@EnableScheduling`은 전역이라 모든 `@SpringBootTest`가 스케줄러를 함께 띄운다.** 테스트에서
주기를 늘려 배경 실행을 없앴다. 끄지 않은 이유는 등록 자체가 검증 대상이기 때문이다.
- 재검토 트리거(이 조건이 오면 다시 논의):
- **두 번째 스케줄 배치가 생길 때.** 풀 공유·스레드 이름·registrar 분리를 그 시점에 함께 본다.
- **회차 소요가 주기(5분)에 근접할 때.** `fixedDelay`라 겹치지는 않지만 만료 행이 밀린다는 뜻이며,
회차 안 병렬화나 배치 크기 조정이 먼저다.
- **인스턴스를 늘려도 잔량이 줄지 않을 때.** `SKIP LOCKED`가 기대대로 분산되지 않는다는 신호다.
- **배치를 애플리케이션 밖으로 빼야 할 때**(예: 배포 단위 분리). (d)를 다시 본다.
1 change: 1 addition & 0 deletions docs/backend/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,4 @@
| [BD-37](BD-37-ai-derived-invalidation-inside-deletion-transaction.md) | AI 파생 데이터 무효화를 삭제 트랜잭션 안에서 백엔드가 직접 쓴다 | Accepted | S15P11A705-124 |
| [BD-38](BD-38-published-predicate-per-layer.md) | 발행 여부 판정은 층마다 자체 보유하고(아홉 곳), 대가를 각 자리를 지키는 테스트로 갚는다 | Accepted | S15P11A705-148 |
| [BD-39](BD-39-embedding-profile-in-application-config.md) | Embedding Profile을 `application.yml` 리터럴로 두고 환경변수는 덮어쓰기로만 — 런타임 대조가 성립하려면 Spring도 값을 가져야 한다 | Accepted | S15P11A705-135 |
| [BD-40](BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md) | 스케줄링을 `@EnableScheduling` + 자체 `taskScheduler` Bean으로 들이고, 다중 인스턴스 조정은 분산 락 없이 `SKIP LOCKED`에 맡긴다 | Accepted | S15P11A705-159 |
Loading
Loading