feat(S15P11A705-102): Context 생성·교체 시 AI 처리 접수 - #82
Conversation
Context가 생겨도 ai.context_ai_state에 행이 없어 AI 워커가 처리 대상을 알지 못했습니다. 임베딩·키워드가 하나도 만들어지지 않아 시연의 맥락 저장과 자연어 검색이 여기서 막혀 있었습니다. Context INSERT 지점 3곳(create·addContext·replaceContext)에서 PENDING 행을 Core 트랜잭션 안에 넣고, FastAPI 호출은 AFTER_COMMIT 리스너 한 곳에만 두었습니다. 워커는 별 프로세스라 커밋 전에 호출하면 202를 받고도 상태 행을 찾지 못하므로, "커밋 뒤에 부른다"를 관습이 아니라 트랜잭션 경계로 강제했습니다. 호출 실패는 전부 삼킵니다. PENDING이 커밋돼 있어 재스캔이 같은 Context를 다시 집으므로, 유실이 아닌 것 때문에 사용자의 기록을 롤백시키지 않습니다. 검증은 FastAPI 대역의 요청 핸들러 안에서 별 커넥션으로 조회해 도착 시점의 가시성을 단언합니다. 테스트 종료 후 조회는 "언젠가 커밋됐다"만 증명합니다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
minyongP
left a comment
There was a problem hiding this comment.
개요
Context INSERT 3지점(create·addContext·replaceContext)에서 ai.context_ai_state에 PENDING 행을 만들고, 커밋 이후 POST /internal/v1/context/process를 부른다. 호출을 @TransactionalEventListener(AFTER_COMMIT) 한 곳에 몰아 "커밋 뒤에 부른다"를 관습이 아니라 트랜잭션 경계로 만든 것이 설계의 핵심. DDL 없음.
설계 자체는 옳다고 본다. 두 가지는 특히 좋다.
- 대역이 요청 핸들러 안에서
DriverManager새 커넥션으로 DB를 본다(FastApiProcessStub#observe). "언젠가 커밋됐다"가 아니라 "도착 시점에 보였다"를 단언하는 유일한 방법이고, RED 확인표(구현 되돌려 3/5개 실패)까지 붙어 있어 테스트가 실제로 잡는 것이 검증됐다. ON CONFLICT DO NOTHING(≠DO UPDATE) 선택.COMPLETED·CANCELLED를PENDING으로 되살리는 통로를 열지 않는 쪽이 맞다.V100__ai_tables.sql의 컬럼·CHECK와 SQL이 정확히 일치한다(retry_count BETWEEN 0 AND 3에 0을 넣음).
머지 상태를 확인해 봤는데 GitHub가 표시하는 CONFLICTING은 캐시가 낡은 것이다. 현재 origin/dev(d338618, #81 머지 후) 기준으로 실제 머지해 보면 docs/backend/WORKLOG.md만 auto-merge되고 충돌은 0이다. 다만 CI 그린은 23f6a8d가 dda2ed3 기준일 때 돈 결과라 현재 dev 기준 재실행은 아니다. rebase 후 푸시하면 둘 다 해소된다.
1. 실패가 전부 조용하다 — 이 PR의 가장 큰 위험
본문 스스로 X-Internal-Token / X-Internal-Secret 불일치를 미결로 남겼는데, 하필 그 불일치의 실패 모드가 완전 무음이다. 헤더 이름이 틀리거나 PINLOG_AI_INTERNAL_SECRET이 주입되지 않으면:
- FastAPI 401 →
AiProcessClient가 삼킴 → 예외 없음 - 상태를
FAILED로 바꾸지 않음 → DB에 흔적 없음 - 관찰 지표 없음(본문 "미결"에 적힌 대로)
- 재스캔 Scheduler 아직 없음 → 복구도 안 됨
결과가 "임베딩이 하나도 만들어지지 않는데 로그 말고는 아무 신호가 없다" — 이 PR이 애초에 고치려던 상태와 같다. 배포 전 AI 파트 확인이 사실상 머지 차단 조건이라고 본다.
두 가지를 제안한다.
(a) logByStatus에서 401·403을 4xx에서 떼라. AiProcessClient#logByStatus
log.error("AI process 호출이 {}로 거절됐다(요청 형식 문제 가능성): ...")
401은 요청 형식 문제가 아니라 설정 문제다. 가장 유력한 운영 오류에 대해 로그가 엉뚱한 곳을 가리킨다.
(b) internal-secret이 빈 값이면 기동 때 최소한 WARN을 남기거나 @Validated @NotBlank로 죽여라. 현재 ${PINLOG_AI_INTERNAL_SECRET:}은 빈 문자열이 기본값이라 시크릿 미주입이 조용히 통과해 전 호출 401이 된다. AiProperties.process도 기본값이 없어 pinlog.ai.process 블록이 빠지면 aiProcessRestClient에서 NPE로 기동 실패한다 — 지금은 application.yml에 있으니 괜찮지만 @DefaultValue 하나가 싸다.
2. 테스트가 로컬 AI 서버를 실제로 부를 수 있다 (비용이 나간다)
src/test/resources가 아예 없다. 즉 **pinlog.ai.base-url의 테스트 기본값이 http://localhost:8000**이고, 이를 대역으로 덮는 것은 ContextAiEnqueueTests뿐이다.
RecordApiTests·DeletionApiTests·PublicCollectionApiTests는 전부 @SpringBootTest @AutoConfigureMockMvc이고 @Transactional이 아니다 → 커밋한다 → AFTER_COMMIT 리스너가 뜬다 → localhost:8000으로 진짜 POST가 나간다.
CI에서는 연결 거부로 삼켜져 무해하다. 문제는 로컬에서 dev/ 스택(FastAPI 8000)을 띄운 채 ./gradlew test를 돌릴 때다. Testcontainers의 임시 context_id로 실제 AI 서버가 임베딩 작업을 시작한다 — dev DB에 쓸모없는 행이 남고 임베딩 API 비용이 실제로 나간다.
src/test/resources/application.yml에 도달 불가 주소를 박아 두면 끝난다.
pinlog:
ai:
base-url: http://127.0.0.1:13. AFTER_COMMIT은 트랜잭션이 없으면 조용히 아무것도 하지 않는다
@TransactionalEventListener의 fallbackExecution 기본값은 false다. 트랜잭션 없이 발행된 이벤트는 예외 없이 버려진다.
지금은 세 지점 모두 @Transactional이라 안전하다. 그런데 이 PR의 논지가 "규칙을 관습이 아니라 구조로 만든다"인데, 여기가 그 논지에 남은 구멍이다 — 누가 비트랜잭션 경로에서 enqueueAiProcessing을 부르면 PENDING은 써지고 호출은 나가지 않는데 로그조차 없다. BD-36이 이미 인정한 "직접 AiProcessClient 호출" 구멍과 같은 종류다.
RecordService#enqueueAiProcessing에 한 줄이면 소리가 난다.
private void enqueueAiProcessing(Context context) {
Assert.state(TransactionSynchronizationManager.isActualTransactionActive(),
"AI 접수는 트랜잭션 안에서만 유효하다 — AFTER_COMMIT 리스너가 뜨지 않는다");
...
}4. 빠진 테스트 두 개 — 사문화된 헬퍼가 증거다
FastApiProcessStub#noCallWithin(long)을 아무도 쓰지 않는다. "호출이 오지 않았음을 확인할 때 쓴다"는 Javadoc까지 달려 있는데 호출처가 없다. 이 메서드가 있어야 할 테스트 두 개가 실제로 비어 있다.
| 주장 | 근거 |
|---|---|
"롤백된 트랜잭션에서는 이 리스너가 아예 실행되지 않으므로, 저장되지 않은 Context로 호출이 나가는 경로가 없다" (ContextAiRequestedListener Javadoc) |
없음 |
"이미 삭제된 Context면 호출을 생략한다" (ContextProcessRequestAssembler → Optional.empty()) |
없음 |
전자는 create를 실패시켜(소유권·제약 위반) noCallWithin(500)을 단언하면 되고, 후자는 커밋 후 Context를 지우고 이벤트를 직접 발행하면 잡힌다. 쓰지 않을 거라면 헬퍼를 지워야 한다.
5. 그 외 (작은 것들)
@EnableAsync가 도메인 설정에 있다 (AiIntegrationConfig). 빈 두 개를 소비자 옆에 두는 근거는 납득하는데,@EnableAsync는 빈이 아니라 앱 전역 스위치다. 앞으로 다른 곳에@Async가 붙으면 그 활성화 근거가domain/ai에 있게 된다.global/config가 맞는 자리라고 본다.RestClient빈에@Qualifier가 없다. 지금 컨텍스트에RestClient빈은 이것 하나뿐이라 동작하지만, 주입은 파라미터명 매칭(aiProcessRestClient)에 기대고 있다. S15P11A705-135가aiSearchRestClient를 더하는 순간 타입 모호성이 생기고, 그때 깨지는 자리는 이 파일이 아니다. 지금 붙이는 것이 싸다.placeMetaOf의orElseThrow()— Context·Record는Optional.empty()로 생략하는데 Place만 예외를 던진다(리스너가 삼켜서 결과는 같다).ContextProcessRequest.placeMeta가@Nullable인데 실제로 null이 되는 경로가 없어, 이 nullable 계약에는 지금 아무 테스트도 걸려 있지 않다.FastApiProcessStub을 아무도 멈추지 않는다.HttpServer와 스레드 2개가 JVM 끝까지 남는다. 컨테이너를 닫지 않는 것은IntegrationContainerSupport가 이유(BT-01)를 명시했지만, 대역은 클래스 하나만 쓰므로@AfterAll에서server.stop(0)이 가능하다.- 정적 대역 하나를 테스트 6개가 공유한다. 지금은 각 테스트가 자기 호출을
awaitCall()로 소비해 균형이 맞지만, 큐에서 꺼낸 호출이 내 것인지 검사하지 않는다. 테스트가 늘면contextId로 걸러 받는 쪽이 안전하다. docs/ai/spec/ai-integration.md수정. CLAUDE.md 규칙("충돌 지점에 표시하고 PR에도 남긴다")과는 일치한다. 다만 결과적으로 AI 파트 소유 파일을 건드린 커밋이니 리뷰 요청 시 명시하는 것이 좋겠다.
정리
| 항목 | 판단 |
|---|---|
| 정확성 | 설계·SQL·트랜잭션 경계 모두 맞다. ON CONFLICT DO NOTHING, IDENTITY로 인한 즉시 flush(id 확보), FK 없는 크로스 스키마 INSERT까지 전제가 맞게 성립한다 |
| 테스트 | 커밋 가시성 검증 방식은 이 레포에서 본 것 중 가장 좋다. 다만 주장한 두 경로(롤백 시 무호출 · 삭제 시 생략)에 테스트가 없다 |
| 성능 | 전용 풀 격리와 거부 정책(CallerRunsPolicy 배제)은 옳다. 상한(core 2 / max 4 / queue 100)은 본문도 근거가 약하다고 인정 — 재스캔이 붙기 전엔 포화 = 유실 |
| 보안 | 시크릿을 파일에 적지 않음 · 응답 본문 로깅 안 함. 시크릿 부재·헤더 불일치가 무음으로 실패하는 것만 문제 |
| 관례 | package-structure.md 선행 갱신, NullMarked 단위, BD/BI/WORKLOG 전부 규약대로 |
머지 전 처리를 권하는 것: 1(a)(b) · 2 · 4. 3과 5는 후속으로 빼도 무리 없다.
차단 조건: X-Internal-Secret AI 파트 확인. 코드 문제는 아니지만 틀렸을 때 무음으로 기능이 죽는다.
머지 순서: 본문의 back#80 선행 요청은 유효하다. 다만 #80도 현재 CONFLICTING 상태라 그쪽이 먼저 풀려야 한다.
back#80(AI 파생 데이터 무효화)·back#81(Refresh 재사용)·back#83(헤더 정정)이 병합된 dev 를 가져오고, back#82 리뷰 지적을 함께 반영한다. 충돌 해소 - docs/ai/spec/ai-integration.md — 이 브랜치가 남긴 "검토 필요" 표시를 걷어냈다. 헤더 이름 불일치를 back#83 이 실제로 고쳤으므로 표시할 충돌이 없다. - RecordService — #82 의 생성 동기화(ContextAiStateRepository·이벤트 발행)와 #80 의 삭제 동기화(AiDerivedDataRepository)가 한 클래스에서 만난다. 양쪽 의존을 모두 살리고 replaceContext Javadoc 도 두 방향을 함께 서술하도록 합쳤다. 리뷰 반영 - 401·403 을 나머지 4xx 에서 분리해 설정 문제로 지목한다 - 시크릿 부재를 JwtKeyProvider 와 같은 기준으로 다룬다(운영 실패, 그 외 WARN) - 통합 테스트의 pinlog.ai.base-url 을 도달 불가 주소로 고정한다 - noCallWithin 을 쓰는 테스트 둘을 채운다(롤백 시 무호출·삭제된 Context 생략) - enqueueAiProcessing 에 트랜잭션 활성 단언을 건다 - RestClient 주입에 @qualifier 를 붙인다 - 대역 HttpServer 를 @afterall 에서 닫는다 병합으로 드러난 통합 결함 - 생성이 PENDING 을 자동 삽입하게 되어 back#80 테스트의 직접 INSERT 가 중복키를 냈다. UPSERT 로 바꾸고, "파생 데이터 없음"을 전제하는 테스트 둘은 명시적으로 지운다. - 교체 시 구 Context 가 이제 실제로 CANCELLED 가 된다. PENDING 을 기대하던 단언을 뒤집었다. 검증: ./gradlew clean check --no-daemon — 317 tests, 0 failed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
리뷰 반영 — 머지 전 권고 4건 전부, 그리고 후속으로 빼도 된다던 둘도
차단 조건으로 두신 1. 실패가 전부 조용하다지적하신 그대로였습니다. 시크릿이 비면 401 → 삼킴 → DB 흔적 없음 → 지표 없음 → 재스캔 없음이라, 이 연동이 애초에 없애려던 상태와 같아집니다. (a) (b) 빈 시크릿 처리는 2. 테스트가 로컬 AI 서버를 부르던 것제안하신
결국 @TestPropertySource(properties = {
"pinlog.ai.base-url=http://127.0.0.1:1",
"pinlog.ai.internal-secret=test-internal-secret"
})지적 자체는 정확했고 해법만 달라졌습니다. 실측 경위는 그 자리 주석에 남겼습니다. 3·4. 후속으로 빼도 된다던 둘도 넣었습니다각각 한 줄이고, 3은 이 PR의 논지에 남은 마지막 구멍이라 미루면 그 논지가 반만 참이 됩니다. Assert.state(TransactionSynchronizationManager.isActualTransactionActive(),
"AI 접수는 트랜잭션 안에서만 유효하다 — AFTER_COMMIT 리스너가 뜨지 않아 호출이 조용히 사라진다");4 —
두 번째는 임의의 없는 id가 아니라 지워진 id를 씁니다. 5. 작은 것들
머지가 드러낸 통합 결함 둘각 PR은 통과하는데 합치면 깨지는 종류라 남깁니다. 하나 — 이 PR이 Context 생성 시 둘 — 머지 상태 지적도 맞았습니다 — |
두 가지를 고쳤다.
1) BT-05의 원인이 틀렸다
"Spring Boot 4가 작업 디렉터리의 .env를 자동으로 읽는다"고 적었는데, 실제로는
application.yml이 명시적으로 올린다.
config:
import: optional:file:.env[.properties]
이 줄은 1ed1dbe(S15P11A705-63)에서 들어왔고, 바로 위 주석이 "Spring Boot는 .env를
자동으로 읽지 않아서"라고 적고 있었다. 그 주석을 읽지 않고 프레임워크 기본 동작으로
단정한 결과다.
증상(빈 문자열이 ${VAR:기본값}을 덮어 기동 실패)과 해결(.env.example의 자격증명
6줄을 주석 처리)은 그대로 유효하다. 기전만 틀렸다. troubleshooting/README의
"잘못 작성된 문서도 정정으로 처리" 규칙대로 지우지 않고 고쳤고, 무엇을 어떻게
틀렸는지 BT-05 본문에 남겼다 — 같은 종류의 단정을 다시 하지 않으려면 그 기록이
필요하다.
.env.example의 같은 서술도 함께 고쳤다. README 색인 행은 기전 대신 증상의 핵심
("빈 문자열은 '없음'이 아니다")을 담도록 바꿨다.
2) 테스트 수가 리베이스로 달라졌다
BI-24와 WORKLOG가 309개를 들고 있었는데 dev 리베이스로 326개가 됐다(#82 등이
가져온 테스트). 기록이 서로 다른 수를 말하는 것은 직전 PR 리뷰에서 지적받은
항목이라 같이 맞췄다. 이 티켓이 더한 것은 6개다.
동작 변경 없음. clean check 326개 통과, 실패 0
Refs #33
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
두 가지를 고쳤다.
1) BT-05의 원인이 틀렸다
"Spring Boot 4가 작업 디렉터리의 .env를 자동으로 읽는다"고 적었는데, 실제로는
application.yml이 명시적으로 올린다.
config:
import: optional:file:.env[.properties]
이 줄은 1ed1dbe(S15P11A705-63)에서 들어왔고, 바로 위 주석이 "Spring Boot는 .env를
자동으로 읽지 않아서"라고 적고 있었다. 그 주석을 읽지 않고 프레임워크 기본 동작으로
단정한 결과다.
증상(빈 문자열이 ${VAR:기본값}을 덮어 기동 실패)과 해결(.env.example의 자격증명
6줄을 주석 처리)은 그대로 유효하다. 기전만 틀렸다. troubleshooting/README의
"잘못 작성된 문서도 정정으로 처리" 규칙대로 지우지 않고 고쳤고, 무엇을 어떻게
틀렸는지 BT-05 본문에 남겼다 — 같은 종류의 단정을 다시 하지 않으려면 그 기록이
필요하다.
.env.example의 같은 서술도 함께 고쳤다. README 색인 행은 기전 대신 증상의 핵심
("빈 문자열은 '없음'이 아니다")을 담도록 바꿨다.
2) 테스트 수가 리베이스로 달라졌다
BI-24와 WORKLOG가 309개를 들고 있었는데 dev 리베이스로 326개가 됐다(#82 등이
가져온 테스트). 기록이 서로 다른 수를 말하는 것은 직전 PR 리뷰에서 지적받은
항목이라 같이 맞췄다. 이 티켓이 더한 것은 6개다.
동작 변경 없음. clean check 326개 통과, 실패 0
Refs #33
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
두 가지를 고쳤다.
1) BT-05의 원인이 틀렸다
"Spring Boot 4가 작업 디렉터리의 .env를 자동으로 읽는다"고 적었는데, 실제로는
application.yml이 명시적으로 올린다.
config:
import: optional:file:.env[.properties]
이 줄은 1ed1dbe(S15P11A705-63)에서 들어왔고, 바로 위 주석이 "Spring Boot는 .env를
자동으로 읽지 않아서"라고 적고 있었다. 그 주석을 읽지 않고 프레임워크 기본 동작으로
단정한 결과다.
증상(빈 문자열이 ${VAR:기본값}을 덮어 기동 실패)과 해결(.env.example의 자격증명
6줄을 주석 처리)은 그대로 유효하다. 기전만 틀렸다. troubleshooting/README의
"잘못 작성된 문서도 정정으로 처리" 규칙대로 지우지 않고 고쳤고, 무엇을 어떻게
틀렸는지 BT-05 본문에 남겼다 — 같은 종류의 단정을 다시 하지 않으려면 그 기록이
필요하다.
.env.example의 같은 서술도 함께 고쳤다. README 색인 행은 기전 대신 증상의 핵심
("빈 문자열은 '없음'이 아니다")을 담도록 바꿨다.
2) 테스트 수가 리베이스로 달라졌다
BI-24와 WORKLOG가 309개를 들고 있었는데 dev 리베이스로 326개가 됐다(#82 등이
가져온 테스트). 기록이 서로 다른 수를 말하는 것은 직전 PR 리뷰에서 지적받은
항목이라 같이 맞췄다. 이 티켓이 더한 것은 6개다.
동작 변경 없음. clean check 326개 통과, 실패 0
Refs #33
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
요약
Context가 생겨도
ai.context_ai_state에 행이 없어 AI 워커가 처리 대상을 알지 못했습니다. 임베딩·키워드가 하나도 만들어지지 않아 시연 5단계 중 ① 맥락 저장과 ② 자연어 검색이 여기서 막혀 있었습니다. Context INSERT 지점 3곳에서PENDING행을 만들고POST /internal/v1/context/process를 호출합니다.DB 변경(DDL)은 없습니다.
ai스키마는V100이 이미 만들어 둔 상태이며, 이 PR은 쓰기만 추가합니다.Jira (필수)
관련 GitHub Issue (선택)
이 PR은
origin/dev를 base로 합니다. 삭제 방향(CANCELLED전이 ·context_embedding.is_deleted)을 구현한 back#80(S15P11A705-124)이 아직dev에 없어, 이 브랜치에는AiDerivedDataRepository가 존재하지 않습니다.중복 구현은 하지 않았습니다. 삭제 경로에는 손대지 않았고, 생성 경로용
PENDINGINSERT만 별 파일(ContextAiStateRepository)에 담았습니다. 같은 파일에 메서드를 더하면 add/add 충돌이 되기 때문입니다 — 근거는 BD-36의 "왜ContextAiStateRepository가AiDerivedDataRepository와 별개인가". back#80 병합 후 두 클래스를 합칠지는 그때 판단하며, 옮길 것은 SQL 상수 하나와 메서드 하나뿐입니다.배경 — 순서가 아니라 커밋 가시성이 문제였다
티켓이 정한 순서는
① PENDING INSERT → ② process 호출입니다. 그런데 그것만으로는 부족합니다.②가 ①의 커밋보다 먼저 나가면 순서를 지켜도 실패합니다. 워커는 별 프로세스라 우리 트랜잭션의 미커밋 스냅샷을 볼 수 없습니다. FastAPI는 202를 돌려주고, 우리 로그에는 "정상 접수"만 남고, 실제로는 아무 일도 일어나지 않습니다. 조용히 실패하는 종류의 버그입니다.
그래서 호출 코드를
@TransactionalEventListener(AFTER_COMMIT)한 곳에만 두었습니다. 서비스는 INSERT와 이벤트 발행만 합니다. "커밋 뒤에 부른다"가 관습이 아니라 트랜잭션 경계로 강제되므로, 새 Context 생성 지점이 늘어도 그 규칙을 사람이 기억할 필요가 없습니다.변경 사항
신규 —
domain/ai/AiPropertiespinlog.ai바인딩. base-url·시크릿·process타임아웃. 검색용 키(search·embedding-profile)는 소비자가 생기는 티켓(S15P11A705-135)에서 함께 들어와야 "설정은 있는데 아무도 안 읽는" 구간이 안 생깁니다AiIntegrationConfigRestClient(connect 1s / read 3s)와 전용 executoraiCallExecutor. 큐가 차면 버립니다 —CallerRunsPolicy를 쓰면 요청 스레드가 외부 호출을 대신 수행해 FastAPI 장애가 그대로 Core 처리량 저하가 됩니다repository/ContextAiStateRepositoryPENDINGINSERT 하나.ON CONFLICT DO NOTHING이지DO UPDATE가 아닙니다 — 상태를 되돌리는 전이는 존재하지 않으므로, 덮으면COMPLETED·CANCELLED를PENDING으로 되살리는 통로가 열립니다event/ContextAiRequestedevent/ContextAiRequestedListenerAFTER_COMMIT+@Async("aiCallExecutor"). 이 PR에서 호출이 일어나는 유일한 지점service/ContextProcessRequestAssemblercontextId로 다른text를 보내는 것은 계약 위반"이라 못 박는데, 넘겨받은 문자열을 그대로 실으면 그 계약을 코드로 보장할 수 없습니다. 이미 지워진 Context면 호출을 생략합니다client/AiProcessClientFAILED로 바꾸지 않고retry_count도 올리지 않습니다client/ContextProcessRequest{contextId, userId, recordId, text, placeMeta}수정
RecordService— Context INSERT 3곳에enqueueAiProcessing(context)한 줄.create의save()반환값을 그동안 버리고 있었는데 id가 필요해 지역 변수로 받았습니다(IDENTITY라save()시점에 id가 채워집니다).replaceContext의①③(신 INSERT 선행 · 구 softDelete)은 건드리지 않았습니다.application.yml—pinlog.ai블록. 시크릿은${PINLOG_AI_INTERNAL_SECRET:}placeholder이고 실제 값을 파일에 적지 않습니다.docs/development/package-structure.md—ai도메인 행 추가. 이 문서가 "표에 없는 새 도메인이 필요하면 파일을 만들기 전에 먼저 갱신"하라고 정합니다.문서
docs/backend/decisions/BD-36-...— 트랜잭션 안 INSERT + 커밋 이후 비동기 호출, 그 대가docs/backend/implements/BI-22-...— 구현·검증 리포트docs/backend/WORKLOG.md— 1줄🚩 문서·코드 충돌 (해결하지 않음)
docs/ai/spec/ai-integration.md§7이 내부 인증 헤더를X-Internal-Token이라고 적고 있으나, ai 레포 구현은 **X-Internal-Secret**을 요구합니다(app/core/security.py의INTERNAL_SECRET_HEADER,tests/test_api.py,tools/e2e/_common.py).공용 계약
static/05_AI_설계.md§13이 *"실제 Route와 Schema가 구현되면 ai 레포 코드가 실행 가능한 계약의 원본"*이라고 정하므로 back 구현은X-Internal-Secret과 설정 키pinlog.ai.internal-secret을 씁니다. 다만docs/ai/는 AI 파트 소유 구역이라 CLAUDE.md 9번 규칙에 따라 고치지 않고 해당 위치에> 검토 필요표시만 남겼습니다. AI 파트 확인이 필요합니다.테스트 / 검증
./gradlew clean check --no-daemon— BUILD SUCCESSFUL, 299 tests / 0 failures / 0 skipped (commit23f6a8d)ContextAiEnqueueTests6개, Testcontainerspgvector/pgvector:0.8.5-pg16커밋 가시성을 어떻게 검증했는가
테스트 종료 후 조회는 "언젠가 커밋됐다"만 증명합니다. 확인해야 하는 것은 "호출이 도착한 그 시점에 이미 커밋돼 있었다"이고, 그 시점은 요청 핸들러 안에서만 포착됩니다.
FastApiProcessStub은 JDKHttpServer이고, 요청 핸들러 안에서DriverManager로 새로 연 커넥션으로 해당context_id의ai.context_ai_state를 조회합니다. 애플리케이션 풀을 재사용하면 미커밋 변경도 보여 증명이 성립하지 않습니다 — 별 커넥션이라야 별 프로세스인 워커와 같은 조건이 됩니다. (mock 서버 라이브러리를 쓰지 않은 이유도 이것입니다. 핸들러 안에서 임의 코드를 돌려야 했고, 새 테스트 의존성도 필요 없었습니다.)이 테스트 클래스는
@Transactional롤백 테스트가 아닙니다. 롤백 테스트는 커밋하지 않으므로 별 커넥션에서 애초에 아무것도 보이지 않아, 명제를 참으로도 거짓으로도 만들 수 없습니다. 실제로 커밋시키고 남는 행은 테스트마다 다른kakaoPlaceId로 격리했습니다.RED 확인 — 구현을 되돌려 테스트가 실제로 잡는지 봤습니다
@TransactionalEventListener(AFTER_COMMIT)→@EventListener(트랜잭션 안 호출)Expecting value to be true but was false/ "호출이 도착한 시점에 PENDING 행이 별 커넥션에서 이미 보여야 한다"테스트 6개
processIsCalledOnlyAfterThePendingRowIsAlreadyCommittedPENDING+X-Internal-Secret헤더recordCreationLeavesExactlyOnePendingRowWithFullRetryBudgetretry_count = 0addingAContextToAnExistingRecordAlsoEnqueuesItreplacingTheOnlyContextEnqueuesTheNewOneAndLeavesTheOldStateToTheDeletionPathserverErrorFromFastApiDoesNotRollBackTheContextOrItsPendingRowdroppedConnectionDoesNotRollBackTheContextOrItsPendingRow리뷰 포인트
RecordService의 트랜잭션 경계 (core접점이라 @minyongP 리뷰를 요청드립니다). 세 지점 모두enqueueAiProcessing한 줄이고 그 안에는 DB INSERT와 이벤트 발행만 있습니다. 외부 호출은 여기 없습니다 — 있으면 지연만큼 커넥션과 행 잠금이 유지되고, 실패가 Record 저장을 롤백시킵니다.호출 실패를 삼키는 것이 BD-35(삭제)와 반대 방향입니다. 대상이 달라서입니다 — BD-35의 UPDATE는 DB 쓰기라 실패가 곧 정합성 붕괴지만, 여기의 실패는 외부 서버 호출이고
PENDING이 이미 커밋돼 있어 재스캔이 다시 집습니다. 유실이 아닌 것을 롤백하면 상대 서버 장애 때문에 사용자의 기록이 사라집니다.교체 검증의 한계 — 읽어 주세요. 티켓은 "마지막 Context 삭제 금지 가드가 발동하지 않는 것"을 Context 1개 상태에서 확인하라고 했고, 그대로 Context가 정확히 1개인 상태에서 검증했습니다. 다만 현재 그 가드는
RecordDeletionService#deleteContext에 있고replaceContext경로에는 걸려 있지 않습니다. 따라서 이 테스트가 고정하는 것은 "가드가 울리지 않았다"가 아니라 "활성 수가 0이 되는 중간 상태가 없다"는 관측 가능한 결과입니다. 두 서비스가 합쳐지거나 교체가 삭제 유스케이스를 재사용하게 되면 그때 가드가 실제 프록시가 됩니다. BI-22에도 같은 한계를 적어 두었습니다.큐 포화 시 조용히 버립니다. 유실이 아니라고 말할 수 있는 근거가 재스캔 Scheduler인데 그건 아직 없습니다(별건). 붙기 전까지 버려진 호출은 실질적으로 유실입니다. 지금 상한(core 2 / max 4 / queue 100)은 Context 생성 빈도를 기준으로 잡은 값이라 근거가 약합니다.
Context INSERT 지점은 3곳입니다 (티켓은 4곳).
create가 Place 중복 시 Record 생성 대신 Context 추가로 흡수하는데(BD-12) 두 갈래 모두 같은save()한 줄을 지납니다. 티켓은 이 두 갈래를 따로 센 것으로 보입니다. 놓친 지점이 있다면 알려주세요.미결 / 후속
AiSearchClient와pinlog.ai.search·embedding-profileai-integration.md§8) — 호출 성공·실패 건수, 상태별 State 건수X-Internal-Token/X-Internal-Secret불일치 — AI 파트 확인 필요traceId가 호출 경계에서 끊깁니다 —@Async스레드에 MDC가 넘어가지 않아X-Request-Id가 새 UUID가 됩니다. 우리 로그와 FastAPI 로그는 그 값으로 이어지지만 사용자 요청 하나를 끝까지 따라가려면 두 구간을 사람이 이어야 합니다ContextAiStateRepository와AiDerivedDataRepository통합 — back#80 병합 후 판단🤖 Generated with Claude Code