Skip to content

feat(S15P11A705-102): Context 생성·교체 시 AI 처리 접수 - #82

Merged
colosair merged 3 commits into
devfrom
feat/S15P11A705-102-context-ai-enqueue
Jul 29, 2026
Merged

feat(S15P11A705-102): Context 생성·교체 시 AI 처리 접수#82
colosair merged 3 commits into
devfrom
feat/S15P11A705-102-context-ai-enqueue

Conversation

@colosair

Copy link
Copy Markdown
Member

요약

Context가 생겨도 ai.context_ai_state에 행이 없어 AI 워커가 처리 대상을 알지 못했습니다. 임베딩·키워드가 하나도 만들어지지 않아 시연 5단계 중 ① 맥락 저장과 ② 자연어 검색이 여기서 막혀 있었습니다. Context INSERT 지점 3곳에서 PENDING 행을 만들고 POST /internal/v1/context/process를 호출합니다.

DB 변경(DDL)은 없습니다. ai 스키마는 V100이 이미 만들어 둔 상태이며, 이 PR은 쓰기만 추가합니다.

Jira (필수)

관련 GitHub Issue (선택)

⚠️ 병합 순서 — back#80 뒤에 병합해 주세요

이 PR은 origin/dev를 base로 합니다. 삭제 방향(CANCELLED 전이 · context_embedding.is_deleted)을 구현한 back#80(S15P11A705-124)이 아직 dev에 없어, 이 브랜치에는 AiDerivedDataRepository가 존재하지 않습니다.

중복 구현은 하지 않았습니다. 삭제 경로에는 손대지 않았고, 생성 경로용 PENDING INSERT만 별 파일(ContextAiStateRepository)에 담았습니다. 같은 파일에 메서드를 더하면 add/add 충돌이 되기 때문입니다 — 근거는 BD-36의 "왜 ContextAiStateRepositoryAiDerivedDataRepository와 별개인가". back#80 병합 후 두 클래스를 합칠지는 그때 판단하며, 옮길 것은 SQL 상수 하나와 메서드 하나뿐입니다.

배경 — 순서가 아니라 커밋 가시성이 문제였다

티켓이 정한 순서는 ① PENDING INSERT → ② process 호출입니다. 그런데 그것만으로는 부족합니다.

②가 ①의 커밋보다 먼저 나가면 순서를 지켜도 실패합니다. 워커는 별 프로세스라 우리 트랜잭션의 미커밋 스냅샷을 볼 수 없습니다. FastAPI는 202를 돌려주고, 우리 로그에는 "정상 접수"만 남고, 실제로는 아무 일도 일어나지 않습니다. 조용히 실패하는 종류의 버그입니다.

그래서 호출 코드를 @TransactionalEventListener(AFTER_COMMIT) 한 곳에만 두었습니다. 서비스는 INSERT와 이벤트 발행만 합니다. "커밋 뒤에 부른다"가 관습이 아니라 트랜잭션 경계로 강제되므로, 새 Context 생성 지점이 늘어도 그 규칙을 사람이 기억할 필요가 없습니다.

변경 사항

신규 — domain/ai/

파일 무엇을·왜
AiProperties pinlog.ai 바인딩. base-url·시크릿·process 타임아웃. 검색용 키(search·embedding-profile)는 소비자가 생기는 티켓(S15P11A705-135)에서 함께 들어와야 "설정은 있는데 아무도 안 읽는" 구간이 안 생깁니다
AiIntegrationConfig RestClient(connect 1s / read 3s)와 전용 executor aiCallExecutor. 큐가 차면 버립니다CallerRunsPolicy를 쓰면 요청 스레드가 외부 호출을 대신 수행해 FastAPI 장애가 그대로 Core 처리량 저하가 됩니다
repository/ContextAiStateRepository PENDING INSERT 하나. ON CONFLICT DO NOTHING이지 DO UPDATE가 아닙니다 — 상태를 되돌리는 전이는 존재하지 않으므로, 덮으면 COMPLETED·CANCELLEDPENDING으로 되살리는 통로가 열립니다
event/ContextAiRequested 식별자 3개만. 엔티티를 실으면 영속성 컨텍스트 밖에서 지연 로딩이 터집니다
event/ContextAiRequestedListener AFTER_COMMIT + @Async("aiCallExecutor"). 이 PR에서 호출이 일어나는 유일한 지점
service/ContextProcessRequestAssembler 별 읽기 트랜잭션에서 본문과 Place를 다시 읽습니다. 명세 §4.4가 "같은 contextId로 다른 text를 보내는 것은 계약 위반"이라 못 박는데, 넘겨받은 문자열을 그대로 실으면 그 계약을 코드로 보장할 수 없습니다. 이미 지워진 Context면 호출을 생략합니다
client/AiProcessClient 실패를 전부 삼킵니다. FAILED로 바꾸지 않고 retry_count도 올리지 않습니다
client/ContextProcessRequest {contextId, userId, recordId, text, placeMeta}

수정

  • RecordService — Context INSERT 3곳에 enqueueAiProcessing(context) 한 줄. createsave() 반환값을 그동안 버리고 있었는데 id가 필요해 지역 변수로 받았습니다(IDENTITYsave() 시점에 id가 채워집니다). replaceContext①③(신 INSERT 선행 · 구 softDelete)은 건드리지 않았습니다.
  • application.ymlpinlog.ai 블록. 시크릿은 ${PINLOG_AI_INTERNAL_SECRET:} placeholder이고 실제 값을 파일에 적지 않습니다.
  • docs/development/package-structure.mdai 도메인 행 추가. 이 문서가 "표에 없는 새 도메인이 필요하면 파일을 만들기 전에 먼저 갱신"하라고 정합니다.

문서

  • 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.pyINTERNAL_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-daemonBUILD SUCCESSFUL, 299 tests / 0 failures / 0 skipped (commit 23f6a8d)
  • DB 변경 시 PostgreSQL 통합 테스트 — ContextAiEnqueueTests 6개, Testcontainers pgvector/pgvector:0.8.5-pg16
  • migration 변경 시 빈 DB migration 테스트 — 해당 없음 (DDL 추가 없음)
  • API 계약 변경 시 관련 문서 갱신 — 외부 API 계약 변경 없음. 내부 연동은 BI-22
  • 되돌리기 어려운 결정 — BD-36

커밋 가시성을 어떻게 검증했는가

테스트 종료 후 조회는 "언젠가 커밋됐다"만 증명합니다. 확인해야 하는 것은 "호출이 도착한 그 시점에 이미 커밋돼 있었다"이고, 그 시점은 요청 핸들러 안에서만 포착됩니다.

FastApiProcessStub은 JDK HttpServer이고, 요청 핸들러 안에서 DriverManager로 새로 연 커넥션으로 해당 context_idai.context_ai_state를 조회합니다. 애플리케이션 풀을 재사용하면 미커밋 변경도 보여 증명이 성립하지 않습니다 — 별 커넥션이라야 별 프로세스인 워커와 같은 조건이 됩니다. (mock 서버 라이브러리를 쓰지 않은 이유도 이것입니다. 핸들러 안에서 임의 코드를 돌려야 했고, 새 테스트 의존성도 필요 없었습니다.)

이 테스트 클래스는 @Transactional 롤백 테스트가 아닙니다. 롤백 테스트는 커밋하지 않으므로 별 커넥션에서 애초에 아무것도 보이지 않아, 명제를 참으로도 거짓으로도 만들 수 없습니다. 실제로 커밋시키고 남는 행은 테스트마다 다른 kakaoPlaceId로 격리했습니다.

RED 확인 — 구현을 되돌려 테스트가 실제로 잡는지 봤습니다

되돌린 것 결과
@TransactionalEventListener(AFTER_COMMIT)@EventListener (트랜잭션 안 호출) 3 failed — Expecting value to be true but was false / "호출이 도착한 시점에 PENDING 행이 별 커넥션에서 이미 보여야 한다"
위 + 클라이언트가 예외를 되던지게 5 failed — 실패 경로 2개가 추가로 깨짐 (Context·PENDING이 롤백됨)
(원복 후) 6 passed

테스트 6개

테스트 무엇을 고정하는가
processIsCalledOnlyAfterThePendingRowIsAlreadyCommitted 도착 시점 가시성 + 두 status가 PENDING + X-Internal-Secret 헤더
recordCreationLeavesExactlyOnePendingRowWithFullRetryBudget retry_count = 0
addingAContextToAnExistingRecordAlsoEnqueuesIt 두 번째 INSERT 지점
replacingTheOnlyContextEnqueuesTheNewOneAndLeavesTheOldStateToTheDeletionPath 교체 — 아래 참조
serverErrorFromFastApiDoesNotRollBackTheContextOrItsPendingRow 503이어도 Context·PENDING 유지
droppedConnectionDoesNotRollBackTheContextOrItsPendingRow 연결 끊김도 동일

리뷰 포인트

  1. RecordService의 트랜잭션 경계 (core 접점이라 @minyongP 리뷰를 요청드립니다). 세 지점 모두 enqueueAiProcessing 한 줄이고 그 안에는 DB INSERT와 이벤트 발행만 있습니다. 외부 호출은 여기 없습니다 — 있으면 지연만큼 커넥션과 행 잠금이 유지되고, 실패가 Record 저장을 롤백시킵니다.

  2. 호출 실패를 삼키는 것이 BD-35(삭제)와 반대 방향입니다. 대상이 달라서입니다 — BD-35의 UPDATE는 DB 쓰기라 실패가 곧 정합성 붕괴지만, 여기의 실패는 외부 서버 호출이고 PENDING이 이미 커밋돼 있어 재스캔이 다시 집습니다. 유실이 아닌 것을 롤백하면 상대 서버 장애 때문에 사용자의 기록이 사라집니다.

  3. 교체 검증의 한계 — 읽어 주세요. 티켓은 "마지막 Context 삭제 금지 가드가 발동하지 않는 것"을 Context 1개 상태에서 확인하라고 했고, 그대로 Context가 정확히 1개인 상태에서 검증했습니다. 다만 현재 그 가드는 RecordDeletionService#deleteContext에 있고 replaceContext 경로에는 걸려 있지 않습니다. 따라서 이 테스트가 고정하는 것은 "가드가 울리지 않았다"가 아니라 "활성 수가 0이 되는 중간 상태가 없다"는 관측 가능한 결과입니다. 두 서비스가 합쳐지거나 교체가 삭제 유스케이스를 재사용하게 되면 그때 가드가 실제 프록시가 됩니다. BI-22에도 같은 한계를 적어 두었습니다.

  4. 큐 포화 시 조용히 버립니다. 유실이 아니라고 말할 수 있는 근거가 재스캔 Scheduler인데 그건 아직 없습니다(별건). 붙기 전까지 버려진 호출은 실질적으로 유실입니다. 지금 상한(core 2 / max 4 / queue 100)은 Context 생성 빈도를 기준으로 잡은 값이라 근거가 약합니다.

  5. Context INSERT 지점은 3곳입니다 (티켓은 4곳). create가 Place 중복 시 Record 생성 대신 Context 추가로 흡수하는데(BD-12) 두 갈래 모두 같은 save() 한 줄을 지납니다. 티켓은 이 두 갈래를 따로 센 것으로 보입니다. 놓친 지점이 있다면 알려주세요.

미결 / 후속

  • 재스캔 Scheduler · 재시도 소진 Finalizer (별건) — 4번 리뷰 포인트의 전제입니다
  • 검색 연동 (S15P11A705-135) — AiSearchClientpinlog.ai.search·embedding-profile
  • 관찰 지표 (ai-integration.md §8) — 호출 성공·실패 건수, 상태별 State 건수
  • X-Internal-Token / X-Internal-Secret 불일치 — AI 파트 확인 필요
  • traceId가 호출 경계에서 끊깁니다@Async 스레드에 MDC가 넘어가지 않아 X-Request-Id가 새 UUID가 됩니다. 우리 로그와 FastAPI 로그는 그 값으로 이어지지만 사용자 요청 하나를 끝까지 따라가려면 두 구간을 사람이 이어야 합니다
  • ContextAiStateRepositoryAiDerivedDataRepository 통합 — back#80 병합 후 판단

🤖 Generated with Claude Code

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 minyongP left a comment

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.

개요

Context INSERT 3지점(create·addContext·replaceContext)에서 ai.context_ai_statePENDING 행을 만들고, 커밋 이후 POST /internal/v1/context/process를 부른다. 호출을 @TransactionalEventListener(AFTER_COMMIT) 한 곳에 몰아 "커밋 뒤에 부른다"를 관습이 아니라 트랜잭션 경계로 만든 것이 설계의 핵심. DDL 없음.

설계 자체는 옳다고 본다. 두 가지는 특히 좋다.

  • 대역이 요청 핸들러 안에서 DriverManager 새 커넥션으로 DB를 본다(FastApiProcessStub#observe). "언젠가 커밋됐다"가 아니라 "도착 시점에 보였다"를 단언하는 유일한 방법이고, RED 확인표(구현 되돌려 3/5개 실패)까지 붙어 있어 테스트가 실제로 잡는 것이 검증됐다.
  • ON CONFLICT DO NOTHING(≠ DO UPDATE) 선택. COMPLETED·CANCELLEDPENDING으로 되살리는 통로를 열지 않는 쪽이 맞다. 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 그린은 23f6a8ddda2ed3 기준일 때 돈 결과라 현재 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:1

3. AFTER_COMMIT은 트랜잭션이 없으면 조용히 아무것도 하지 않는다

@TransactionalEventListenerfallbackExecution 기본값은 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면 호출을 생략한다" (ContextProcessRequestAssemblerOptional.empty()) 없음

전자는 create를 실패시켜(소유권·제약 위반) noCallWithin(500)을 단언하면 되고, 후자는 커밋 후 Context를 지우고 이벤트를 직접 발행하면 잡힌다. 쓰지 않을 거라면 헬퍼를 지워야 한다.

5. 그 외 (작은 것들)

  • @EnableAsync가 도메인 설정에 있다 (AiIntegrationConfig). 빈 두 개를 소비자 옆에 두는 근거는 납득하는데, @EnableAsync는 빈이 아니라 앱 전역 스위치다. 앞으로 다른 곳에 @Async가 붙으면 그 활성화 근거가 domain/ai에 있게 된다. global/config가 맞는 자리라고 본다.
  • RestClient 빈에 @Qualifier가 없다. 지금 컨텍스트에 RestClient 빈은 이것 하나뿐이라 동작하지만, 주입은 파라미터명 매칭(aiProcessRestClient)에 기대고 있다. S15P11A705-135가 aiSearchRestClient를 더하는 순간 타입 모호성이 생기고, 그때 깨지는 자리는 이 파일이 아니다. 지금 붙이는 것이 싸다.
  • placeMetaOforElseThrow() — 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 상태라 그쪽이 먼저 풀려야 한다.

colosair and others added 2 commits July 29, 2026 15:25
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>
@colosair
colosair marked this pull request as ready for review July 29, 2026 06:31
@colosair

Copy link
Copy Markdown
Member Author

리뷰 반영 — 머지 전 권고 4건 전부, 그리고 후속으로 빼도 된다던 둘도

317 tests / 0 failed. 커밋 f9b2e9f(dev 병합 포함)입니다.

차단 조건으로 두신 X-Internal-Secret AI 파트 확인은 해소됐습니다. 명세 §7이 X-Internal-Token으로 적혀 있던 것이 문서 쪽 드리프트였고, 구현 정본(app/core/security.pyINTERNAL_SECRET_HEADER)에 맞춰 #83으로 고쳐 머지했습니다. 세 레포 전수 검색으로 그 한 줄 외 잔존이 없음을 확인했습니다.

1. 실패가 전부 조용하다

지적하신 그대로였습니다. 시크릿이 비면 401 → 삼킴 → DB 흔적 없음 → 지표 없음 → 재스캔 없음이라, 이 연동이 애초에 없애려던 상태와 같아집니다.

(a) logByStatus에서 401·403을 나머지 4xx에서 뗐습니다. 로그가 요청 형식이 아니라 설정을 가리키도록, 무엇을 확인해야 하는지까지 문장에 넣었습니다.

AI process 호출이 401로 거절됐다(시크릿·헤더 설정 문제):
pinlog.ai.internal-secret과 ai 레포의 INTERNAL_SHARED_SECRET이 같은 값인지,
헤더 이름이 X-Internal-Secret인지 확인하라. contextId=…, requestId=…

(b) 빈 시크릿 처리는 JwtKeyProvider와 같은 기준으로 맞췄습니다 — 운영 프로파일은 기동 실패, 그 외는 WARN. @Validated @NotBlank도 후보였지만 프로파일을 구분하지 못해 로컬 개발이 막힙니다. 이 레포에 이미 같은 문제를 푼 선례가 있어 그쪽을 따랐습니다.

2. 테스트가 로컬 AI 서버를 부르던 것

제안하신 src/test/resources/application.yml은 쓸 수 없었습니다. test classpath가 우선이라 그 파일을 만들면 main의 application.yml이 통째로 가려집니다 — DB·인증·Feed 정책 설정이 전부 사라집니다.

@DynamicPropertySource로 바꿔 봤더니 이번엔 상위 클래스가 하위를 덮어 ContextAiEnqueueTests 5개가 "호출이 오지 않음"으로 깨졌습니다. 두 쪽 다 @DynamicPropertySource면 상위가 나중에 등록됩니다.

결국 IntegrationContainerSupport에 **@TestPropertySource**로 한 단계 낮춰 두는 형태가 답이었습니다. DynamicValuesPropertySource가 우선순위 최상위라 대역 테스트의 @DynamicPropertySource가 이깁니다.

@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 리스너가 뜨지 않아 호출이 조용히 사라진다");

4noCallWithin을 쓰는 테스트 둘을 채웠습니다.

테스트 무엇을 고정하나
rollingBackTheTransactionNeverReachesFastApi 바깥 트랜잭션을 setRollbackOnly로 되돌리면 호출이 나가지 않고 Context도 남지 않는다
anAlreadyDeletedContextIsNotSentToFastApi replaceContext실제 소프트 삭제된contextId에 이벤트를 늦게 발행해도 호출을 생략한다

두 번째는 임의의 없는 id가 아니라 지워진 id를 씁니다. @SQLRestriction이 소프트 삭제를 실제로 걸러 내는지까지 확인하려면 그래야 합니다.

5. 작은 것들

@Qualifier는 지금 붙였습니다 — 말씀대로 -135aiSearchRestClient를 더하는 순간 깨질 자리고, 그때 깨지는 파일이 여기가 아닙니다. 대역 HttpServer@AfterAll에서 닫고 실행자도 함께 내립니다(server.stop이 executor를 건드리지 않습니다).

placeMetaOforElseThrow와 정적 대역 공유는 후속으로 둡니다. 전자는 nullable 계약에 테스트가 없다는 지적이 맞지만 실제로 null이 되는 경로가 없어 지금 테스트를 만들면 그 경로를 인위적으로 열어야 하고, 후자는 테스트가 더 늘 때 contextId 필터링을 넣는 편이 낫다고 봤습니다.

AiIntegrationConfigglobal/config 이동은 하지 않았습니다 — domain/ai가 이제 정식 도메인이 됐고(package-structure.md 등록), 설정과 그 설정이 조립하는 구현을 같은 패키지에 두는 기준은 보안 설정과 같습니다. 이견 있으시면 별도로 옮기겠습니다.

머지가 드러낸 통합 결함 둘

각 PR은 통과하는데 합치면 깨지는 종류라 남깁니다. #80 머지 후 dev를 가져오자 14건이 실패했습니다.

하나 — 이 PR이 Context 생성 시 PENDING을 자동 삽입하게 되면서, #80 테스트의 직접 INSERT중복키를 냈습니다(9건). 새 동작을 들인 쪽이 여파를 흡수하는 게 맞다고 보아 insertState를 UPSERT로 바꿨습니다. *"파생 데이터 없음"*을 전제하던 테스트 둘은 이제 그 상태가 저절로 생기지 않으므로 명시적으로 지우도록 고쳤습니다 — 워커가 아직 아무것도 만들지 않았거나 재스캔이 정리한 뒤 삭제가 들어오는 경우가 실재하고, 그때 UPDATE 0건이 예외 없이 지나가야 합니다.

replacingTheOnlyContext… 테스트가 구 Context 상태를 PENDING으로 단언하고 있었는데, #80이 머지되어 replaceContext가 실제로 무효화를 부르면서 CANCELLED가 됐습니다. 낡은 전제였지 결함이 아니라서 단언을 뒤집고 그 경위를 Javadoc에 적었습니다.

머지 상태 지적도 맞았습니다 — CONFLICTING이 캐시였고, 실제 충돌은 #81·#91이 들어온 뒤 WORKLOG·색인에서 났습니다. 지금은 dev(1511bf6) 기준으로 해소돼 있습니다.

@colosair
colosair merged commit f7e1e1e into dev Jul 29, 2026
2 checks passed
@colosair
colosair deleted the feat/S15P11A705-102-context-ai-enqueue branch July 29, 2026 06:39
cherry-go-round added a commit that referenced this pull request Jul 29, 2026
두 가지를 고쳤다.

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>
cherry-go-round added a commit that referenced this pull request Jul 29, 2026
두 가지를 고쳤다.

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>
cherry-go-round added a commit that referenced this pull request Jul 29, 2026
두 가지를 고쳤다.

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>
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.

2 participants