feat(S15P11A705-135): 개인 자연어 검색 백엔드 연동 - #98
Conversation
`POST /api/core/v1/search/records`를 붙여 시연 5단계 중 ② 자연어 검색의 빈 자리를 채웁니다. FastAPI `/internal/v1/search`를 호출하고 그 결과를 Core 기준으로 재검증한 뒤 Record 단위로 조립합니다. 먼저 정해야 했던 것은 `embeddingProfile`을 Spring이 어디서 얻는가입니다. 공용 계약 05 §7.1이 그 결정을 이 티켓 시점으로 유예해 두었고, 같은 절이 오늘 개정되면서 전제가 뒤집혔습니다(정본이 "배포 환경의 단일 설정"에서 "코드"로). 개정된 기준을 Spring 쪽에 적용해 `application.yml` 리터럴 + 환경변수 덮어쓰기로 정하고 BD-39로 남깁니다. 구현에서 지킨 두 가지입니다. - 422를 빈 결과로 치환하지 않습니다. 빈 결과는 "일치하는 기록이 없음"과 구분되지 않아 설정 오류를 숨깁니다. 다만 상태 코드만으로 불일치를 단정하지도 않습니다 — FastAPI는 요청 검증 실패에도 422를 쓰므로 응답 본문의 `serverProfile` 유무로 가릅니다. - FastAPI 응답을 믿지 않습니다. `ai.context_embedding.user_id`는 비정규화 값이라 검색 범위 필터로는 충분해도 인가 근거로는 부족합니다. 테스트 대역이 일부러 거짓말을 하도록 만든 이유가 그것입니다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rsonal-search # Conflicts: # docs/backend/decisions/README.md # docs/backend/implements/README.md
AI 파트 소유 구역이라 표시만 남겼던 두 지점을, 중앙이 수정 권한을 위임해 고칩니다.
- `internal-token: ${PINLOG_AI_INTERNAL_TOKEN}` → `internal-secret: ${PINLOG_AI_INTERNAL_SECRET:}`
- "배포 환경의 단일 설정에서 주입" · "값 자체는 코드에 상수로 두지 않는다" → 개정된
05 §7.1과 BD-39에 맞춘 본문(리터럴 + 환경변수 덮어쓰기, 런타임 대조)
- `base-url`도 리터럴로 적혀 있어 함께 맞춥니다. 같은 블록에 알면서 틀린 줄을 남기면
아래 불완전 정정을 되풀이하게 됩니다.
§7의 헤더 표기는 back#83이 이미 고쳤는데 이 설정 키만 남은 것은, 당시 전수 검색이
`X-Internal[-_]?(Token|Secret)` 패턴이라 헤더만 잡고 설정 키를 놓쳤기 때문입니다.
패턴을 `internal.token|INTERNAL_TOKEN|internal-token`으로 넓혀 back·docs·ai 세 레포를
다시 훑었습니다 — docs 0건, ai 0건, back은 이 줄 하나뿐이었습니다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
리뷰 요청 — 공통 기반 넷이 접점입니다민용님, AI 파트 작업이지만
1.
|
BD-39·BI-25가 "ai 레포에 아직 기본값도 P45도 없다"고 적어 두었으나 낡은 관측이었습니다. ai#36(463cad5)이 그 사이에 병합되어 config.py의 임베딩 설정 넷에 기본값이 붙었고 P45와 model-profile.md 2.1 개정이 함께 들어왔습니다. 계약(docs#27)과 구현(ai#36)은 22분 간격의 같은 흐름이었고 그 사이에 관측이 끼었습니다. 결정은 바꾸지 않습니다 — BD-39는 애초에 어느 쪽이든 성립하도록 섰고, 이제 양쪽이 같은 형태(코드 기본값 + 환경변수 덮어쓰기)로 대칭이 됐습니다. 결정 시점의 관측은 그대로 두고 날짜를 밝힌 해소 기록을 덧붙입니다(결정 기록은 보존 구역). 확인하는 김에 아무도 대조하지 않았던 것을 대조했습니다 — 두 리터럴이 바이트 단위로 같습니다. 어긋나면 런타임 대조가 모든 검색을 422로 만드므로, "두 정본이 코드에 있다"보다 "두 값이 같다"가 실제로 지켜야 할 명제입니다. 지금 그 대조에 자동 장치는 없습니다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
minyongP
left a comment
There was a problem hiding this comment.
Claude Code 자동 리뷰입니다. 판정은 항상 comment이며, 승인·변경 요청은 @minyongP가 직접 남깁니다.
필수 변경은 없습니다. 「검증하지 않은 것」에 대역만 썼다고 적어 두신 구간을 ai 레포 코드로 직접 대조했고 네 가지가 다 맞았습니다. ① 와이어 필드명 — app/schema/search.py의 SearchRequest가 camelCase(userId·embeddingProfile)이고 이 레포에 Jackson naming strategy 설정이 없어 그대로 맞습니다 ② 리뷰 포인트 2의 판정 근거 — app/main.py의 _profile_mismatch가 serverProfile·requestProfile을 본문 최상위에 싣습니다. body.has("serverProfile")가 실제로 동작합니다 ③ 「미결 1」의 리터럴 — config.py의 기본값과 application.yml의 값이 같습니다 ④ size 상한 — SearchRequest.limit이 le=100이라 CursorPage.MAX_SIZE와 맞습니다. 어긋났다면 400이어야 할 것이 503으로 나갔을 자리입니다.
선택 제안 둘을 줄 단위로 달았습니다. 무게가 있는 쪽은 embedding-profile에 기동 검사가 없는 것입니다 — 같은 PR이 고친 ai-integration.md §2.1에 "빈 Profile을 실어 보내면 모든 검색이 죽고 그 실패가 첫 검색 시점에 나타난다"고 적어 두셨는데, yaml 기본값은 변수 미설정만 막고 PINLOG_AI_EMBEDDING_PROFILE=(설정했으나 빈 값)은 막지 못합니다.
리뷰 포인트에 대한 의견. 3번(매칭 Context가 죽으면 Record 전체 제외)은 제외가 맞다고 봅니다 — similarity를 응답 필드로 내리기로 한 이상 그 값이 어느 Context의 것인지가 계약이고, 대체하면 정렬 근거와 표시값이 갈라집니다. 4번(ContextKeywordRepository를 domain/ai에 둔 것)도 세 번째 소비자까지 미루는 판단에 동의합니다. 지금 합치려면 FeedKeywordRepository의 가시성 화이트리스트와 이쪽을 하나로 만들어야 하는데, 두 경로의 공개 범위가 같아야 한다는 근거가 아직 없습니다.
좋았던 점. 대역이 일부러 거짓말을 하게 만든 것이 이 PR에서 가장 값어치 있는 선택이었습니다 — "남의 Record를 0.99로 돌려줘도 응답에 없다"는 진짜 FastAPI로는 증명할 수 없는 단정입니다. 커버리지 미달을 채우다 실제 방어 구멍 네 개를 찾은 경로도 좋았습니다.
범위. diff가 2249줄이라 절차대로 src/**만 봤고 docs/backend/**는 보지 않았습니다. 다만 docs/ai/spec/ai-integration.md는 다른 파트 소유 구역이라 따로 읽었습니다 — 정정 기록을 근거까지 붙여 남긴 형태라 문서로서는 문제가 없었고, internal-token 잔존을 함께 정리한 것도 맞는 방향입니다. 위임 사실 자체는 이 PR 안에서 확인할 수 없으니 그 부분만 사람이 확인해 주세요.
minyongP
left a comment
There was a problem hiding this comment.
Claude Code 자동 리뷰입니다. 판정은 항상 comment이며, 승인·변경 요청은 @minyongP가 직접 남깁니다.
필수 변경 없음. 앞선 리뷰에서 이미 짚은 줄 단위 제안 2건(embeddingProfile 빈 문자열 기동 검사, distinctByRecord의 match == null 가드)은 반복하지 않고, 그 위에 새로 확인한 것만 적습니다.
계약 대조 결과 — 어긋난 곳 없음
- 응답 형태가 08 §6.1 예시와 필드 단위로 일치합니다(
bounds·items[].similarity·matchedContext.contextId/body/createdAt·keywords). ContextKeywordRepository의 가시성 화이트리스트가 05 §8.4 "본인 조회"와 정확히 같습니다(keyword_status = COMPLETED+is_active+PUBLIC/PRIVATE_ONLY).- 05 §13.2의 요청·응답 필드, 개정된 §7.1의 두 기준(정본은 코드 / 불일치는 런타임 대조)과도 맞습니다.
AiProcessClient는 이미@Qualifier("aiProcessRestClient")를 갖고 있어,RestClientBean이 둘이 되면서 모호해지는 주입부는 없습니다.
리뷰 포인트 1에 대한 이견 — (c) 기각 논거가 두 가지를 섞고 있습니다
BD-39이 "기동 시 FastAPI 조회"를 기각한 근거는 *"상대 값을 받아 상대에게 되돌려 주면 대조가 항상 통과한다"*입니다. 이건 상대 값을 채택하는 것에 대한 반박으로는 맞지만, 상대 값과 대조하는 것까지 함께 배제해 버립니다. 이 둘은 분리됩니다.
- 정본은 그대로
application.yml리터럴로 둔다(= (a) 유지). - 기동 시 FastAPI 설정값을 한 번 읽어 다르면 ERROR 로그 + readiness 실패. 값은 절대 덮어쓰지 않는다.
이러면 대조 장치는 무의미해지지 않습니다(정본이 여전히 우리 코드에 있으므로). 그리고 「미결 1」에서 사람 손으로 한 바이트 비교와 「미결 5」의 배포 스모크가 기동 시점 자동 검사로 당겨집니다. (c)의 "기동 순서 의존" 문제는 fail-fast가 아니라 로그 + readiness로 두면 사라집니다.
단, 이건 ai 쪽에 Profile을 노출하는 엔드포인트가 있어야 성립합니다. 없다면 AI 파트 협의가 필요한 후속이고, 그 사이의 값싼 대안은 두 레포 리터럴을 비교하는 CI 잡입니다. 지금 상태에서 "두 값이 같다"는 (BD-39 자신이 실제로 지켜야 할 명제라고 쓴) 것을 지키는 장치는 사람의 눈뿐입니다.
테스트 커버리지 — 구멍 3개 (권장)
23개 테스트의 품질은 높습니다. 대역이 일부러 거짓말을 하게 만든 설계가 이 PR에서 가장 값어치 있는 부분이고, "남의 Record를 0.99로 돌려줘도 응답에 없다"는 진짜 FastAPI로는 증명할 수 없는 단정입니다. 다만 세 자리가 비어 있습니다.
SEARCH_QUERY_MAX(500)가 테스트되지 않습니다. 이 PR이 명세를 넘어 새로 발명한 유일한 상한인데(미결 3), 501자 요청 테스트가 없습니다. 빈 질의 400과size: 101400은 있습니다. 누군가RecordSearchRequest의@Size를 떼도 342개가 전부 통과합니다.PRIVATE_ONLY를 한 번도 넣지 않습니다. 테스트가 쓰는 visibility는PUBLIC(포함)과BLOCKED(제외)뿐입니다. 그런데PRIVATE_ONLY는 05 §8.4에서 본인 조회와 타인 조회를 가르는 바로 그 값입니다. 화이트리스트를IN ('PUBLIC')으로 좁혀도 테스트는 전부 초록이고, 실제로는 소유자 Keyword의 절반이 조용히 사라집니다. 가시성 필터를 WHERE 절에 둔 이유가 "조용한 누락 방지"인데, 그 누락을 잡을 단언이 없습니다.insertPreset(..., boolean active)의active가 죽은 파라미터입니다. 세 호출이 모두true라서kp.is_active = true조건이 검증되지 않습니다. 폐기된 Preset 하나를false로 넣고 제외를 단언하면 파라미터도 살고 조건도 덮입니다.
낮은 우선순위: AiSearchClient.translate의 403 분기와 read-timeout(5s) 경로는 미검증입니다(401·연결 끊김은 덮여 있습니다).
문서·계약 갭 (후속)
- 08 §1.5의 권장 상태 코드 표에
503이 없고, 새error.code2종이 공용 계약 어디에도 등록되지 않았습니다. 명세는 *"code는 클라이언트가 분기하는 안정적인 문자열"*이라고 정했는데, 프론트가 검색에서 503 +SEARCH_PROFILE_MISMATCH/SEARCH_UNAVAILABLE을 분기해야 한다는 사실이 계약에 없습니다. 미결 목록에 질의 길이(§1.9)는 올렸는데 이쪽은 빠졌습니다.docsPR 한 건으로 닫힙니다. docs/ai/spec/ai-integration.md에 같은 블록의 낡은 줄 2개가 남습니다. §2 표의 위치가com.pinlog.pinlogback.ai.client(실제는domain.ai.client)이고, "RestClient는RestClient.Builder로 Bean 하나를 만들고"는 이 PR이 Bean을 둘로 만든 뒤로 사실과 다릅니다.base-url을 지시 범위 밖인데도 함께 맞춘 것과 똑같은 논거가 적용되는 자리입니다(같은 블록에 알면서 틀린 줄을 남기지 않는다).
위험 — 변경 요청은 아님
FastAPI 장애 시 검색 요청 하나가 워커 스레드를 최대 5초 붙잡습니다. 재시도를 없앤 판단은 맞지만 동시 점유에는 상한이 없습니다 — 검색 트래픽이 몰린 상태에서 FastAPI가 죽으면 워커 고갈이 무관한 엔드포인트까지 끌고 갑니다. 시연 규모에서는 문제가 아니고, 막으려면 세마포어 하나(또는 서킷 브레이커)면 되므로 후속으로 충분합니다. 같은 맥락에서 SimpleClientHttpRequestFactory는 사용자 경로에서 매 검색마다 TCP 연결을 새로 엽니다.
사소한 것
BoundsResponse.enclosing은null을 반환하는 것이 계약의 전부인데@Nullable이 없습니다.RecordSearchResponse.bounds와MapResponse는 nullable로 다루고, 이 PR은domain/ai/exception에@NullMarkedpackage-info까지 새로 넣었습니다.global/response에는 package-info가 없으니 반환 타입에@Nullable한 줄만 붙이는 것이 범위에 맞습니다.size를limit으로 그대로 넘긴 뒤 재검증에서만 줄어들므로,ai인덱스가 삭제를 늦게 반영하면 20을 요청한 사용자가 3건을 받고 응답만으로는 그 이유를 알 수 없습니다. 명세 6.3의 "조용히 제외"를 따른 의도된 동작이지만, 운영 중 체감되면 과다 조회 계수가 필요해집니다.- 테스트 헬퍼
newRecord가java.util.UUID를 전체 경로로 인라인 호출합니다(같은 파일의 다른 타입은 import).insertPreset의max(id) + 1은 병렬 실행 시 충돌합니다.
결론
병합 전 처리 값어치가 가장 큰 것은 앞선 리뷰의 줄 단위 제안 2건이고, 그다음이 위 테스트 3건입니다. 리뷰 포인트 1은 **(a)를 유지한 채 "대조만 하는 기동 검사"**로 미결 1·5를 함께 닫을 수 있는지 판단해 주시면 됩니다. 리뷰 포인트 3(매칭 Context 사멸 시 Record 전체 제외)과 4(ContextKeywordRepository를 domain/ai에 둔 것)는 앞선 리뷰 의견에 동의합니다.
back#98 리뷰의 줄 단위 지적 2건입니다. 둘 다 javadoc이 약속한 범위와 실제 방어 범위가
어긋난 자리입니다.
1. AiSearchClient — embeddingProfile이 비어 있으면 기동 시점에 끊습니다.
생성자 javadoc이 "시크릿 검사를 여기 두지 않는 이유"로 든 논거는 "같은 키를 읽는
AiProcessClient가 이미 검사한다"인데, embedding-profile은 이 클라이언트만 읽는 새 키라
그 논거가 적용되지 않습니다. 값이 빈 문자열이면 FastAPI가 자기 Profile과 대조해 422를
주므로 결과는 모든 검색이 503이고, 그 사실을 첫 검색까지 아무도 모릅니다.
application.yml의 기본값은 변수를 설정하지 않은 경우만 막습니다. PINLOG_AI_EMBEDDING_PROFILE=
처럼 빈 값으로 정의하면 빈 문자열이 기본값을 이깁니다 — 이 저장소가 BT-05로 한 번 겪은
형태입니다. AiProcessClient.requireSecret와 같은 기준(운영은 기동 실패, 그 외는 경고)으로
갈랐습니다.
BD-39가 기각한 (c)(기동 시 FastAPI 조회)가 아닙니다 — 상대에게 아무것도 묻지 않고 우리
값이 있는지만 봅니다. 그래서 두 값이 다른 경우(진짜 불일치)는 그대로 첫 검색에서
드러나며, 그 구간을 당길지는 리뷰 포인트 1로 아직 판단 중입니다.
2. RecordSearchService.distinctByRecord — match 자체가 null인 경우를 막습니다.
javadoc이 이 가드의 목적을 "상대 응답의 결함이 우리 500으로 나타나는 것을 막는다"고 적어
두었는데 그 경우가 빠져 있었습니다. {"results": [null]}이면 match.recordId()에서 NPE가
되어 500입니다. Pydantic이 list[SearchResultItem]에 null을 허용하지 않아 진짜 FastAPI는
이 형태를 만들지 못하지만, AiSearchClient가 최상위 results == null을 이미 방어하고 있고
그것도 계약상 똑같이 올 수 없는 형태입니다 — 최상위는 믿지 않고 원소는 믿으면 층이
어긋납니다.
테스트
RED를 실제로 관측했습니다.
- AiSearchClientTest(신규 3개, 순수 단위 + MockEnvironment): Environment 파라미터만 붙인
상태에서 "운영 + 빈 Profile" 테스트가 실패(예외가 던져지지 않음) → 가드 추가로 GREEN.
- NULL_MATCH_ELEMENT 대역 모드({"results":[null]}): 500 관측(리뷰가 예측한 그대로) →
가드 추가로 200 + 빈 배열.
리뷰가 지목한 테스트 구멍 3건도 함께 메웠습니다. 셋 다 코드는 이미 맞는데 그것을 지키는
단언이 없던 자리라 평소의 RED가 나오지 않습니다. 그래서 세 가드를 일부러 부순 뒤
(가시성 화이트리스트를 IN ('PUBLIC')으로 좁히고, kp.is_active 조건을 지우고, @SiZe를 떼고)
새 테스트 4개만 실패하고 기존 24개는 전부 통과하는 것을 관측해 RED를 대신했습니다.
리뷰가 "그렇게 고쳐도 전부 초록"이라고 한 것이 그대로 재현됐습니다.
- SEARCH_QUERY_MAX(500): 501자 400 + 상한값 500은 통과(off-by-one 고정). 이 티켓이 명세를
넘어 새로 정한 유일한 상한인데 단언이 없었습니다.
- PRIVATE_ONLY: 05 §8.4에서 본인 조회와 타인 조회를 가르는 값인데 한 번도 넣지 않았습니다.
PUBLIC 포함·BLOCKED 제외와 한 자리에서 단언합니다.
- insertPreset(..., active): 세 호출이 모두 true라 죽은 파라미터였습니다. 폐기 Preset을
false로 넣고 제외를 단언해 파라미터도 살리고 조건도 덮습니다.
검증: ./gradlew clean check --no-daemon — BUILD SUCCESSFUL, 370개 통과(실패·skip 0).
checkstyle(main·test)·jacoco 포함. RecordSearchApiTests 23개 → 28개.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- BI-25에 "리뷰 반영 (2026-07-30)" 절을 더합니다. 고친 둘의 논거, 테스트 구멍 셋을 메운 방법(가드를 부숴 RED를 대신한 것), 부수면 어느 테스트가 잡는지의 대응표, 2026-07-30 검증 결과입니다. 2026-07-29 절은 그날의 기록이므로 그대로 둡니다(보존 구역). - BD-39 "결과"에 좁혀진 부분을 덧붙입니다. "잡히는 시점은 배포가 아니라 첫 검색"이 두 경우 중 빈 값 쪽에는 더 이상 해당하지 않습니다. 기각된 (c)가 아니라는 것과, 진짜 불일치는 그대로 첫 검색에서 드러나며 그 구간을 당길지는 리뷰 포인트 1로 판단 중이라는 것을 함께 적었습니다. 결정 자체는 바꾸지 않습니다. - BI-25 "남긴 것"에 두 건을 올립니다. docs/ai/spec/ai-integration.md §2의 낡은 줄 2개 (패키지 위치가 domain.ai.client가 아닌 것, "Bean 하나"가 이 PR 뒤로 사실과 다른 것 — 같은 문서 아래쪽 문장과 문서 안에서 모순입니다)는 §2.1 때 받은 위임 범위 밖이라 CLAUDE.md 9번대로 고치지 않고 남깁니다. 08 §1.5의 503·error.code 미등록은 docs 레포 소관이고 중앙이 판단 중입니다. - WORKLOG 한 줄. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다. 공용 계약이 먼저 바뀌어야 했다. 06 §2.2가 "미동의·미제공 시 null일 수 있다"로 정하고 있어 구현만 바꾸면 계약 위반이다(CLAUDE.md 9번). docs#28을 먼저 병합했고, 그 PR에서 두 가지를 함께 정리했다. - 1차 보장은 공급자 콘솔의 필수 동의 설정이다. 사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없으므로, 이 구현이 막는 것은 일상 흐름이 아니라 방어선이다 - 마스킹은 치환이며 NULL이 아니다. 적지 않으면 탈퇴 구현이 NULL을 넣어 이 제약과 부딪힌다. provider_user_id가 이미 NOT NULL이면서 마스킹 대상이라 전제는 원래 있었다 RED — 정규화 4건 + 콜백 3건 실패(기존 계약을 반대로 고정하는 것이라 뒤집는 것 자체가 RED) GREEN - V6__social_account_email_not_null.sql - OAuthUserInfo 세 분기 모두 이메일을 required(...)로 끊음, @nullable 제거 - SocialAccount @column(nullable = false), 팩토리 파라미터 @nullable 제거 - FlywayMigrationTests에 스키마 제약 단언 추가 드러난 것 - 뒤집을 테스트가 예상보다 많았다. 사전 조사로 3건을 찾았는데 clean check에서 SocialAccountPersistenceTests가 걸렸다. emailIsOptional이 영속성 층에서 null 저장을 고정하고 있었고, 같은 파일의 조회 테스트도 준비 코드에 null 이메일이 섞여 함께 깨졌다 — useEmail(null)·isNull() 검색으로는 안 걸리는 형태다 - 두 방어선이 독립임을 뮤테이션이 보여 줬다. 정규화의 required(...)만 되돌리면 단위 테스트 4건은 실패하지만 콜백 테스트 3건은 통과한다 — DB NOT NULL이 대신 잡아 같은 OAUTH_FAILED로 귀결하기 때문이다. 콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를 고정한다. 반대 방향은 FlywayMigrationTests가 잡는다 - 백필을 넣지 않았다. 운영 DB에 NULL 행이 없고, 있었다면 채울 값이 없다 — 이메일은 공급자가 주는 값이다. 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨지므로 그런 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 SQL 주석에 남겼다 프론트 질문(실패 사유를 별도 error 값으로 가르는지)에는 기존 결정대로 OAUTH_FAILED로 묶인다고 답하고 08 §3.2에 명시했다. BI-25 번호는 #98·#100과 경합 중이다. dev 최대값이 BI-24라 규약대로 다음 값을 썼고, 나중에 머지되는 쪽이 재배정한다. clean check 342개 통과, 실패 0 Refs #97 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다. 공용 계약이 먼저 바뀌어야 했다. 06 §2.2가 "미동의·미제공 시 null일 수 있다"로 정하고 있어 구현만 바꾸면 계약 위반이다(CLAUDE.md 9번). docs#28을 먼저 병합했고, 그 PR에서 두 가지를 함께 정리했다. - 1차 보장은 공급자 콘솔의 필수 동의 설정이다. 사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없으므로, 이 구현이 막는 것은 일상 흐름이 아니라 방어선이다 - 마스킹은 치환이며 NULL이 아니다. 적지 않으면 탈퇴 구현이 NULL을 넣어 이 제약과 부딪힌다. provider_user_id가 이미 NOT NULL이면서 마스킹 대상이라 전제는 원래 있었다 RED — 정규화 4건 + 콜백 3건 실패(기존 계약을 반대로 고정하는 것이라 뒤집는 것 자체가 RED) GREEN - V6__social_account_email_not_null.sql - OAuthUserInfo 세 분기 모두 이메일을 required(...)로 끊음, @nullable 제거 - SocialAccount @column(nullable = false), 팩토리 파라미터 @nullable 제거 - FlywayMigrationTests에 스키마 제약 단언 추가 드러난 것 - 뒤집을 테스트가 예상보다 많았다. 사전 조사로 3건을 찾았는데 clean check에서 SocialAccountPersistenceTests가 걸렸다. emailIsOptional이 영속성 층에서 null 저장을 고정하고 있었고, 같은 파일의 조회 테스트도 준비 코드에 null 이메일이 섞여 함께 깨졌다 — useEmail(null)·isNull() 검색으로는 안 걸리는 형태다 - 두 방어선이 독립임을 뮤테이션이 보여 줬다. 정규화의 required(...)만 되돌리면 단위 테스트 4건은 실패하지만 콜백 테스트 3건은 통과한다 — DB NOT NULL이 대신 잡아 같은 OAUTH_FAILED로 귀결하기 때문이다. 콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를 고정한다. 반대 방향은 FlywayMigrationTests가 잡는다 - 백필을 넣지 않았다. 운영 DB에 NULL 행이 없고, 있었다면 채울 값이 없다 — 이메일은 공급자가 주는 값이다. 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨지므로 그런 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 SQL 주석에 남겼다 프론트 질문(실패 사유를 별도 error 값으로 가르는지)에는 기존 결정대로 OAUTH_FAILED로 묶인다고 답하고 08 §3.2에 명시했다. 기록은 BI-27이다. 세 PR이 BI-25를 동시에 선점했고 머지 순서대로 확정됐다 — #98이 BI-25, #100이 BI-26, 이 기록이 BI-27이다. #100 머지 전에 BI-27을 미리 배정해 재번호를 한 번만 했다. clean check 378개 통과, 실패 0 (dev의 #98·#100 머지분 위에서 재실행) Refs #97 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다. 공용 계약이 먼저 바뀌어야 했다. 06 §2.2가 "미동의·미제공 시 null일 수 있다"로 정하고 있어 구현만 바꾸면 계약 위반이다(CLAUDE.md 9번). docs#28을 먼저 병합했고, 그 PR에서 두 가지를 함께 정리했다. - 1차 보장은 공급자 콘솔의 필수 동의 설정이다. 사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없으므로, 이 구현이 막는 것은 일상 흐름이 아니라 방어선이다 - 마스킹은 치환이며 NULL이 아니다. 적지 않으면 탈퇴 구현이 NULL을 넣어 이 제약과 부딪힌다. provider_user_id가 이미 NOT NULL이면서 마스킹 대상이라 전제는 원래 있었다 RED — 정규화 4건 + 콜백 3건 실패(기존 계약을 반대로 고정하는 것이라 뒤집는 것 자체가 RED) GREEN - V6__social_account_email_not_null.sql - OAuthUserInfo 세 분기 모두 이메일을 required(...)로 끊음, @nullable 제거 - SocialAccount @column(nullable = false), 팩토리 파라미터 @nullable 제거 - FlywayMigrationTests에 스키마 제약 단언 추가 드러난 것 - 뒤집을 테스트가 예상보다 많았다. 사전 조사로 3건을 찾았는데 clean check에서 SocialAccountPersistenceTests가 걸렸다. emailIsOptional이 영속성 층에서 null 저장을 고정하고 있었고, 같은 파일의 조회 테스트도 준비 코드에 null 이메일이 섞여 함께 깨졌다 — useEmail(null)·isNull() 검색으로는 안 걸리는 형태다 - 두 방어선이 독립임을 뮤테이션이 보여 줬다. 정규화의 required(...)만 되돌리면 단위 테스트 4건은 실패하지만 콜백 테스트 3건은 통과한다 — DB NOT NULL이 대신 잡아 같은 OAUTH_FAILED로 귀결하기 때문이다. 콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를 고정한다. 반대 방향은 FlywayMigrationTests가 잡는다 - 백필을 넣지 않았다. 운영 DB에 NULL 행이 없고, 있었다면 채울 값이 없다 — 이메일은 공급자가 주는 값이다. 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨지므로 그런 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 SQL 주석에 남겼다 프론트 질문(실패 사유를 별도 error 값으로 가르는지)에는 기존 결정대로 OAUTH_FAILED로 묶인다고 답하고 08 §3.2에 명시했다. 기록은 BI-27이다. 세 PR이 BI-25를 동시에 선점했고 머지 순서대로 확정됐다 — #98이 BI-25, 했다. clean check 378개 통과, 실패 0 (dev의 #98·#100 머지분 위에서 재실행) Refs #97 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다. 공용 계약이 먼저 바뀌어야 했다. 06 §2.2가 "미동의·미제공 시 null일 수 있다"로 정하고 있어 구현만 바꾸면 계약 위반이다(CLAUDE.md 9번). docs#28을 먼저 병합했고, 그 PR에서 두 가지를 함께 정리했다. - 1차 보장은 공급자 콘솔의 필수 동의 설정이다. 사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없으므로, 이 구현이 막는 것은 일상 흐름이 아니라 방어선이다 - 마스킹은 치환이며 NULL이 아니다. 적지 않으면 탈퇴 구현이 NULL을 넣어 이 제약과 부딪힌다. provider_user_id가 이미 NOT NULL이면서 마스킹 대상이라 전제는 원래 있었다 RED — 정규화 4건 + 콜백 3건 실패(기존 계약을 반대로 고정하는 것이라 뒤집는 것 자체가 RED) GREEN - V6__social_account_email_not_null.sql - OAuthUserInfo 세 분기 모두 이메일을 required(...)로 끊음, @nullable 제거 - SocialAccount @column(nullable = false), 팩토리 파라미터 @nullable 제거 - FlywayMigrationTests에 스키마 제약 단언 추가 드러난 것 - 뒤집을 테스트가 예상보다 많았다. 사전 조사로 3건을 찾았는데 clean check에서 SocialAccountPersistenceTests가 걸렸다. emailIsOptional이 영속성 층에서 null 저장을 고정하고 있었고, 같은 파일의 조회 테스트도 준비 코드에 null 이메일이 섞여 함께 깨졌다 — useEmail(null)·isNull() 검색으로는 안 걸리는 형태다 - 두 방어선이 독립임을 뮤테이션이 보여 줬다. 정규화의 required(...)만 되돌리면 단위 테스트 4건은 실패하지만 콜백 테스트 3건은 통과한다 — DB NOT NULL이 대신 잡아 같은 OAUTH_FAILED로 귀결하기 때문이다. 콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를 고정한다. 반대 방향은 FlywayMigrationTests가 잡는다 - 백필을 넣지 않았다. 운영 DB에 NULL 행이 없고, 있었다면 채울 값이 없다 — 이메일은 공급자가 주는 값이다. 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨지므로 그런 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 SQL 주석에 남겼다 프론트 질문(실패 사유를 별도 error 값으로 가르는지)에는 기존 결정대로 OAUTH_FAILED로 묶인다고 답하고 08 §3.2에 명시했다. 기록은 BI-27이다. 세 PR이 BI-25를 동시에 선점했고 머지 순서대로 확정됐다 — #98이 BI-25, 했다. clean check 378개 통과, 실패 0 (dev의 #98·#100 머지분 위에서 재실행) Refs #97 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
요약
POST /api/core/v1/search/records를 붙여 시연 5단계 중 ② 자연어 검색의 빈 자리를 채웁니다. 계약(08 §6.1, 05 §9)과 FastAPI 구현은 이미 있었고 소비하는 백엔드 엔드포인트만 없었습니다. 선행 둘이 오늘 병합돼(back#82 데이터 생성 · back#80 삭제분 제외) 검색 대상이 갖춰진 시점입니다.읽기 전용입니다 — DDL·마이그레이션 없음,
ai스키마 쓰기 없음, Context 생성·삭제 경로 변경 없음.Jira (필수)
S15P11A705-135배경 — 먼저 정해야 했던 것
POST /internal/v1/search요청 본문의embeddingProfile은 필수 필드이고, FastAPI는 이 값을 자기 설정과 대조해 다르면 422로 거절합니다. Spring이 이 값을 어디서 얻는지가 정해져 있지 않았습니다 — 공용 계약 05 §7.1이 그 결정을 이 티켓 시점으로 명시적으로 유예했습니다.그리고 같은 §7.1이 오늘 개정되면서 전제가 뒤집혔습니다(docs#27).
개정된 기준을 Spring 쪽에 적용해
application.yml리터럴 + 환경변수 덮어쓰기로 정했습니다. 기각한 것 중 핵심은 "기동 시 FastAPI에서 조회"입니다 — 상대 값을 받아 상대에게 되돌려 주면 대조가 항상 통과해, 불일치를 막으려고 만든 장치가 아무것도 검증하지 않게 됩니다. 근거는 BD-39.변경 사항
엔드포인트와 조립 (
domain/search, 신규 도메인)RecordSearchController—POST /v1/search/records. 조회지만 POST인 것은 명세가 정한 바이며, 검색어가 URL·로그에 남지 않는 이점이 있습니다.RecordSearchRequest—query(필수) ·size(1~100, 기본 20). 검색 대상 사용자를 요청이 정하지 않습니다 — 필드가 없으면 실수로 남의 범위를 넘길 수 없습니다.RecordSearchService— FastAPI 호출 → Core 재검증 → 조립 → bounds.@Transactional을 붙이지 않았습니다: 붙이면 FastAPI 호출(읽기 타임아웃 5s) 내내 DB 커넥션이 잡히는데,ai-integration.md§4.1이 금지하는 형태가 정확히 그것입니다.SearchRecordRepository—ai-response-assembly.md§6.1의 SQL 그대로. 네 조건이 재검증 항목 하나씩(소유권·Record 삭제·활성 Context·Place)에 대응합니다.SearchPlaceResponse는record의PlaceSummaryResponse를 재사용하지 않습니다. 그쪽 상위집합을 쓰면 상세 응답에 필드가 늘 때마다 검색 응답이 조용히 따라 넓어집니다(BD-13).FastAPI 호출 (
domain/ai)AiSearchClient—AiProcessClient와 같은 패턴이되 실패 정책이 정반대입니다. 저쪽이 모든 실패를 삼키는 건 fire-and-forget이고PENDING이 남아 재스캔이 줍기 때문인데, 검색엔 그런 뒷수습이 없습니다. 삼키면 사용자에게 빈 결과가 보이고 그것은 "일치하는 기록이 없음"과 구분되지 않습니다.AiSearchException+ErrorCode2종 — 둘 다 503이지만code를 가릅니다. 사람이 해야 할 일이 정반대여서입니다(불일치는 배포 설정을 고쳐야 풀리고 재시도해도 그대로 / 나머지는 대개 기다리면 낫습니다). Profile 값 자체는 로그에만 남기고 응답에 싣지 않습니다.error.codeserverProfileSEARCH_PROFILE_MISMATCHSEARCH_UNAVAILABLEContextKeywordRepository— Record 단위 Keyword 집계. 가시성 필터를 SQL WHERE 절에 두고BLOCKED는 화이트리스트 밖이라 어느 쿼리에도 등장하지 않습니다(블랙리스트로 쓰면 나중에 추가되는 값이 통과합니다).AiIntegrationConfig—aiSearchRestClientBean 추가. 타임아웃이 다르기 때문이며(process 3s / search 5s), 타입이 같은 Bean이 둘이 되므로 양쪽 주입부 모두@Qualifier가 필요합니다.AiProperties—embeddingProfile·search타임아웃 추가. 이 클래스 javadoc이 *"소비자가 생기는 티켓(S15P11A705-135)에서 함께 들어와야 한다"*고 예약해 둔 자리입니다.곁다리
BoundsResponse→global/response. 검색 응답이 지도(4.2)와 같은 규칙으로 같은 형태를 내려주므로 두 도메인 공유가 됐고, 그러면 어느 한 도메인에 두지 않는다는 패키지 규약을 따랐습니다. min/max 계산도enclosing정적 팩터리로 함께 올렸습니다 — 값만 공유하고 계산을 각자 짜면 "결과 없음이 null인가 점 사각형인가"가 조용히 갈라집니다.InputLimits.SEARCH_QUERY_MAX(=500) — 아래 "미결" 참조.docs/development/package-structure.md—search도메인 행 추가,ai행에exception하위 계층 추가. 규약이 *"표에 없는 새 도메인은 파일을 만들기 전에 이 문서를 먼저 갱신한다"*고 정한 절차입니다.문서
BD-39(결정) ·BI-25(구현 기록) · WORKLOG 1줄 · 각 README 인덱스. 번호는check-number.sh back:BD|BI로 열린 PR까지 훑어 확인했습니다(다음 값 39·25).테스트 / 검증
./gradlew clean check --no-daemon— 통과. 342개, 실패 0 (기존 318 + 24)RecordSearchApiTests23개가 Testcontainers 기반BI-25에 구현 기록BD-39브랜치 커버리지 0.79 → 0.8178(게이트 0.80). 초기 실행에서 게이트에 0.01 미달해 상대 응답이 계약을 어겼을 때의 방어 경로 4개를 테스트로 덮었습니다 — 커버리지를 채우려고 넣은 게 아니라 덮이지 않았다는 사실이 실제 구멍을 가리켰습니다(401 경로, JSON이 아닌 422,
results필드 누락, 필드가null인 항목).RED
pinlog.ai.embedding-profile이 없어 컨텍스트 기동 실패 → 19개 전부 FAILED(PlaceholderResolutionException). 이 티켓이 가장 먼저 정해야 할 것에서 빨갛게 시작합니다.GREEN — 대역이 일부러 거짓말을 합니다
FastApiSearchStub은 남의 Context, Core에서 지운 Context, 지운 Record, 존재하지 않는 id를 결과로 돌려줄 수 있습니다. 진짜에 가까운 대역은 "Spring이 FastAPI를 믿지 않는다"를 증명하지 못합니다 — 진짜는 틀린 답을 주지 않기 때문입니다.matchedContext·keywords·bounds·similaritySEARCH_PROFILE_MISMATCHSEARCH_UNAVAILABLE로 갈림SEARCH_UNAVAILABLEresults누락·필드null→ 500이 아니라 빈 결과BLOCKED제외embeddingProfile· 인증 memberId · 시크릿 헤더size101 400 · 미인증 401ConfigurationContractTests에 BD-39를 파일 자체로 고정하는 단언 1개를 더했습니다.Regression
RecordMapApiTests6개(BoundsResponse이동·enclosing추출의 회귀 방어선) 포함 기존 318개 전부 통과.ContextAiEnqueueTests·AiDerivedDataInvalidationTests도 그대로입니다 —RestClientBean이 둘이 되면서AiProcessClient주입이 모호해지지 않았다는 증거입니다.검증하지 않은 것 (솔직히)
ai.context_embedding.is_deleted = false필터 — FastAPI의 SQL 필터(05 §9.3)이고ai레포 테스트의 몫입니다. 이 PR이 고정한 건 그 필터가 늦었을 때 Core 재검증이 막는다는 쪽입니다(삭제와 검색 사이의 짧은 창을 대역으로 재현).리뷰 포인트
embeddingProfile취득 경로 (BD-39). §7.1이 유예한 결정을 여기서 확정합니다. 개정 후 §7.1이 세운 두 기준(정본은 코드 / 불일치는 런타임 대조) 위에 서 있는데, 그 해석이 타당한지 봐 주세요. 특히 기동 시 FastAPI 조회를 기각한 근거(상대 값을 되돌려 주면 대조가 무의미)에 이견이 있는지.serverProfile유무로 가릅니다. FastAPI는 Pydantic 검증 실패에도 422를 쓰기 때문인데, 이 판정 방식이 상대 구현 세부에 결합합니다. 대안은 "422면 전부 불일치로 본다"인데 그러면 운영자가 있지도 않은 설정 불일치를 쫓습니다. 결합을 감수할 값어치가 있는지.similarity가 그 Context의 값이 아니게 되어 정렬 근거가 거짓이 됩니다. 대체 대신 제외가 맞는지.ContextKeywordRepository를domain/ai에 둔 것.FeedKeywordRepository(같은ai.context_keyword를 읽음)는domain/feed에 있습니다.AiDerivedDataRepositoryjavadoc의 *"ai스키마에 닿는 코드는domain/ai에 모은다"*를 따랐는데, 그 결과ai읽기가 두 패키지로 갈렸습니다. 합칠지는 세 번째 소비자가 생길 때 판단하려 합니다.BoundsResponse이동.domain/record를 건드리는 유일한 변경입니다. 규약("두 도메인이 함께 쓰는 코드는global로")을 따랐지만 이 PR 범위를 넓힙니다.미결 / 후속
→ 해소됐습니다. 제 관측이 낡은 것이었습니다.ai레포 쪽 §7.1 대칭이 아직 없습니다.ai#36(463cad5)이 그 사이에 병합되어config.py의 임베딩 설정 넷 전부에 기본값이 붙었고,P45와model-profile.md§2.1 개정이 함께 들어왔습니다 — 계약(docs#2707:52)과 구현(ai#3607:59)은 22분 간격의 같은 흐름이었고 그 사이에 제 관측이 끼었습니다. 이제 양쪽이 같은 형태(코드 기본값 + 환경변수 덮어쓰기)로 대칭입니다.확인하는 김에 아무도 대조하지 않았던 것을 대조했습니다 — 두 리터럴이 바이트 단위로 같습니다.
application.ymlopenai-text-embedding-3-small-1536-cosine-v1config.py:53(aiorigin/main)openai-text-embedding-3-small-1536-cosine-v1이것이 어긋나면 런타임 대조가 모든 검색을 422로 만듭니다. "두 정본이 코드에 있다"보다 **"두 값이 같다"**가 실제로 지켜야 할 명제인데, 지금 그 대조는 사람이 한 것이고 자동 장치가 없습니다 — 아래 5번(배포 스모크)이 그 자리입니다.
→ 위임받아 이 PR에서 고쳤습니다 (docs/ai/spec/ai-integration.md§2.1이 개정 전 §7.1을 그대로 담고 있습니다.b6a8ebd). 처음에는 AI 파트 소유 구역이라CLAUDE.md9번대로 충돌 표시만 남겼고, AI 파트(중앙)가 그 판정을 받아 수정 권한을 위임했습니다. 고친 것은 셋입니다.internal-token: ${PINLOG_AI_INTERNAL_TOKEN}internal-secret: ${PINLOG_AI_INTERNAL_SECRET:}base-urlhttp://localhost:8000(리터럴)${PINLOG_AI_BASE_URL:http://localhost:8000}base-url은 지시 범위 밖이었지만 같은 블록에 알면서 틀린 줄을 남기면 아래 불완전 정정을 되풀이하게 되므로 함께 맞췄습니다. 이제 §2.1의 YAML 블록은 실제application.yml과 값이 완전히 같습니다.internal-token재검색 결과. §7의 헤더 표기는 back#83이 이미 고쳤는데 이 설정 키만 남은 이유는, 당시 전수 검색이X-Internal[-_]?(Token|Secret)패턴이라 헤더만 잡고 설정 키를 놓쳤기 때문입니다(그 PR 본문의 "잔존 0건"은 사실과 달랐습니다). 패턴을internal.token|INTERNAL_TOKEN|internal-token으로 넓혀 세 레포를 다시 훑었습니다 —docs0건 ·ai0건 ·back은 그 한 줄뿐.docs/ai/WORKLOG.md의 back#83 기술은 옛 값을 인용하는 이력 기록이라 그대로 두었습니다.검색 질의 길이 상한이 08 §1.9 표에 없습니다. 명세가 금지한 게 아니라 아직 다루지 않은 자리이고, 상한이 없으면 임의 길이 문자열이 곧바로 외부 임베딩 호출 비용이 됩니다(FastAPI 스키마도
min_length만 있고 상한 없음). Context 본문과 같은 500자를 백엔드 방어로 두었습니다 — 질의도 그대로 임베딩 입력이라 이유가 같습니다. 명세에 반영되면 그 값이 정본입니다.Profile 전환 절차가 없습니다. 전환하려면 두 레포를 각각 배포해야 하고 그 사이 구간은 검색이 422입니다. 무중단 전환 방법(양쪽 Profile 동시 허용 등)은 §7.1에도 없고 이 PR에서도 정하지 않습니다.
배포 스모크에 검색을 한 번 태워야 합니다. 두 Profile이 어긋나는 것은 런타임 대조가 잡지만 잡히는 시점이 배포가 아니라 첫 검색입니다. 그 사이를 메우려면 배포 게이트가 검색을 한 번 호출해야 합니다.
🤖 Generated with Claude Code