Skip to content

feat(S15P11A705-135): 개인 자연어 검색 백엔드 연동 - #98

Merged
colosair merged 6 commits into
devfrom
feat/S15P11A705-135-personal-search
Jul 30, 2026
Merged

feat(S15P11A705-135): 개인 자연어 검색 백엔드 연동#98
colosair merged 6 commits into
devfrom
feat/S15P11A705-135-personal-search

Conversation

@colosair

@colosair colosair commented Jul 29, 2026

Copy link
Copy Markdown
Member

요약

POST /api/core/v1/search/records를 붙여 시연 5단계 중 ② 자연어 검색의 빈 자리를 채웁니다. 계약(08 §6.1, 05 §9)과 FastAPI 구현은 이미 있었고 소비하는 백엔드 엔드포인트만 없었습니다. 선행 둘이 오늘 병합돼(back#82 데이터 생성 · back#80 삭제분 제외) 검색 대상이 갖춰진 시점입니다.

읽기 전용입니다 — DDL·마이그레이션 없음, ai 스키마 쓰기 없음, Context 생성·삭제 경로 변경 없음.

Jira (필수)

배경 — 먼저 정해야 했던 것

POST /internal/v1/search 요청 본문의 embeddingProfile필수 필드이고, FastAPI는 이 값을 자기 설정과 대조해 다르면 422로 거절합니다. Spring이 이 값을 어디서 얻는지가 정해져 있지 않았습니다 — 공용 계약 05 §7.1이 그 결정을 이 티켓 시점으로 명시적으로 유예했습니다.

Spring이 그 값을 어디서 얻는지는 검색 연동 시점에 정합니다(S15P11A705-135). 소비자가 없는 상태에서 정하면 붙일 때 다시 뒤집힙니다.

그리고 같은 §7.1이 오늘 개정되면서 전제가 뒤집혔습니다(docs#27).

개정 전 개정 후(현행)
Profile 정본 배포 환경의 단일 설정 코드
환경변수 주입 필수 실험·롤백용 덮어쓰기, 필수 아님
불일치 방지 장치 단일 주입 런타임 대조(422)

개정된 기준을 Spring 쪽에 적용해 application.yml 리터럴 + 환경변수 덮어쓰기로 정했습니다. 기각한 것 중 핵심은 "기동 시 FastAPI에서 조회"입니다 — 상대 값을 받아 상대에게 되돌려 주면 대조가 항상 통과해, 불일치를 막으려고 만든 장치가 아무것도 검증하지 않게 됩니다. 근거는 BD-39.

변경 사항

엔드포인트와 조립 (domain/search, 신규 도메인)

  • RecordSearchControllerPOST /v1/search/records. 조회지만 POST인 것은 명세가 정한 바이며, 검색어가 URL·로그에 남지 않는 이점이 있습니다.
  • RecordSearchRequestquery(필수) · size(1~100, 기본 20). 검색 대상 사용자를 요청이 정하지 않습니다 — 필드가 없으면 실수로 남의 범위를 넘길 수 없습니다.
  • RecordSearchService — FastAPI 호출 → Core 재검증 → 조립 → bounds. @Transactional을 붙이지 않았습니다: 붙이면 FastAPI 호출(읽기 타임아웃 5s) 내내 DB 커넥션이 잡히는데, ai-integration.md §4.1이 금지하는 형태가 정확히 그것입니다.
  • SearchRecordRepositoryai-response-assembly.md §6.1의 SQL 그대로. 네 조건이 재검증 항목 하나씩(소유권·Record 삭제·활성 Context·Place)에 대응합니다.
  • DTO 4종SearchPlaceResponserecordPlaceSummaryResponse를 재사용하지 않습니다. 그쪽 상위집합을 쓰면 상세 응답에 필드가 늘 때마다 검색 응답이 조용히 따라 넓어집니다(BD-13).

FastAPI 호출 (domain/ai)

  • AiSearchClientAiProcessClient와 같은 패턴이되 실패 정책이 정반대입니다. 저쪽이 모든 실패를 삼키는 건 fire-and-forget이고 PENDING이 남아 재스캔이 줍기 때문인데, 검색엔 그런 뒷수습이 없습니다. 삼키면 사용자에게 빈 결과가 보이고 그것은 "일치하는 기록이 없음"과 구분되지 않습니다.

  • AiSearchException + ErrorCode 2종 — 둘 다 503이지만 code를 가릅니다. 사람이 해야 할 일이 정반대여서입니다(불일치는 배포 설정을 고쳐야 풀리고 재시도해도 그대로 / 나머지는 대개 기다리면 낫습니다). Profile 값 자체는 로그에만 남기고 응답에 싣지 않습니다.

    상황 응답 error.code
    422 + 본문에 serverProfile 503 SEARCH_PROFILE_MISMATCH
    그 외 422 · 401 · 403 · 5xx · 연결 실패 · 타임아웃 503 SEARCH_UNAVAILABLE
  • ContextKeywordRepository — Record 단위 Keyword 집계. 가시성 필터를 SQL WHERE 절에 두고 BLOCKED는 화이트리스트 밖이라 어느 쿼리에도 등장하지 않습니다(블랙리스트로 쓰면 나중에 추가되는 값이 통과합니다).

  • AiIntegrationConfigaiSearchRestClient Bean 추가. 타임아웃이 다르기 때문이며(process 3s / search 5s), 타입이 같은 Bean이 둘이 되므로 양쪽 주입부 모두 @Qualifier가 필요합니다.

  • AiPropertiesembeddingProfile · search 타임아웃 추가. 이 클래스 javadoc이 *"소비자가 생기는 티켓(S15P11A705-135)에서 함께 들어와야 한다"*고 예약해 둔 자리입니다.

곁다리

  • BoundsResponseglobal/response. 검색 응답이 지도(4.2)와 같은 규칙으로 같은 형태를 내려주므로 두 도메인 공유가 됐고, 그러면 어느 한 도메인에 두지 않는다는 패키지 규약을 따랐습니다. min/max 계산도 enclosing 정적 팩터리로 함께 올렸습니다 — 값만 공유하고 계산을 각자 짜면 "결과 없음이 null인가 점 사각형인가"가 조용히 갈라집니다.
  • InputLimits.SEARCH_QUERY_MAX(=500) — 아래 "미결" 참조.
  • docs/development/package-structure.mdsearch 도메인 행 추가, 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)
  • DB 변경 시 PostgreSQL 통합 테스트 — RecordSearchApiTests 23개가 Testcontainers 기반
  • migration 변경 시 빈 DB migration 테스트 — 해당 없음(DDL 없음)
  • API 계약 변경 시 관련 문서 갱신 — 08 §6.1 신규 소비, 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를 믿지 않는다"를 증명하지 못합니다 — 진짜는 틀린 답을 주지 않기 때문입니다.

항목 단언
계약 조립 Record 단위 item · matchedContext · keywords · bounds · similarity
Profile 불일치 422 → 200+빈 배열이 아니라 503 SEARCH_PROFILE_MISMATCH
422 오분류 검증 오류 422·JSON 아닌 422는 SEARCH_UNAVAILABLE로 갈림
호출 실패 401 · 5xx · 연결 끊김 → 503 SEARCH_UNAVAILABLE
타인 소유 대역이 남의 Record·Context를 최상위(0.99)로 돌려줘도 응답에 없음
삭제 Core에서 지운 Context·Record가 결과에 없음
계약 위반 200 results 누락·필드 null → 500이 아니라 빈 결과
Record 단위 같은 Record의 Context 둘 → item 1개, 최고 유사도가 대표
순서 FastAPI 유사도 내림차순이 재검증 뒤에도 유지
Keyword Record 전체 Context 집계 · BLOCKED 제외
설정 전달 설정에서 읽은 embeddingProfile · 인증 memberId · 시크릿 헤더
요청 검증 빈 질의 400(호출 자체가 안 나감) · size 101 400 · 미인증 401

ConfigurationContractTests에 BD-39를 파일 자체로 고정하는 단언 1개를 더했습니다.

Regression

RecordMapApiTests 6개(BoundsResponse 이동·enclosing 추출의 회귀 방어선) 포함 기존 318개 전부 통과. ContextAiEnqueueTests·AiDerivedDataInvalidationTests도 그대로입니다 — RestClient Bean이 둘이 되면서 AiProcessClient 주입이 모호해지지 않았다는 증거입니다.

검증하지 않은 것 (솔직히)

  • ai.context_embedding.is_deleted = false 필터 — FastAPI의 SQL 필터(05 §9.3)이고 ai 레포 테스트의 몫입니다. 이 PR이 고정한 건 그 필터가 늦었을 때 Core 재검증이 막는다는 쪽입니다(삭제와 검색 사이의 짧은 창을 대역으로 재현).
  • 실제 FastAPI와의 통합 — 대역만 썼습니다. 두 서버를 붙인 스모크는 배포 게이트의 몫입니다.

리뷰 포인트

  1. embeddingProfile 취득 경로 (BD-39). §7.1이 유예한 결정을 여기서 확정합니다. 개정 후 §7.1이 세운 두 기준(정본은 코드 / 불일치는 런타임 대조) 위에 서 있는데, 그 해석이 타당한지 봐 주세요. 특히 기동 시 FastAPI 조회를 기각한 근거(상대 값을 되돌려 주면 대조가 무의미)에 이견이 있는지.
  2. 422를 둘로 가른 것. 상태 코드만으로 Profile 불일치를 단정하지 않고 본문의 serverProfile 유무로 가릅니다. FastAPI는 Pydantic 검증 실패에도 422를 쓰기 때문인데, 이 판정 방식이 상대 구현 세부에 결합합니다. 대안은 "422면 전부 불일치로 본다"인데 그러면 운영자가 있지도 않은 설정 불일치를 쫓습니다. 결합을 감수할 값어치가 있는지.
  3. 매칭 Context가 죽으면 Record 전체를 뺍니다. Record엔 다른 활성 Context가 남아 있어도 결과에서 제외합니다. 다른 Context로 대체하면 similarity가 그 Context의 값이 아니게 되어 정렬 근거가 거짓이 됩니다. 대체 대신 제외가 맞는지.
  4. ContextKeywordRepositorydomain/ai에 둔 것. FeedKeywordRepository(같은 ai.context_keyword를 읽음)는 domain/feed에 있습니다. AiDerivedDataRepository javadoc의 *"ai 스키마에 닿는 코드는 domain/ai에 모은다"*를 따랐는데, 그 결과 ai 읽기가 두 패키지로 갈렸습니다. 합칠지는 세 번째 소비자가 생길 때 판단하려 합니다.
  5. BoundsResponse 이동. domain/record를 건드리는 유일한 변경입니다. 규약("두 도메인이 함께 쓰는 코드는 global로")을 따랐지만 이 PR 범위를 넓힙니다.

미결 / 후속

  1. ai 레포 쪽 §7.1 대칭이 아직 없습니다.해소됐습니다. 제 관측이 낡은 것이었습니다. ai#36(463cad5)이 그 사이에 병합되어 config.py의 임베딩 설정 넷 전부에 기본값이 붙었고, P45model-profile.md §2.1 개정이 함께 들어왔습니다 — 계약(docs#27 07:52)과 구현(ai#36 07:59)은 22분 간격의 같은 흐름이었고 그 사이에 제 관측이 끼었습니다. 이제 양쪽이 같은 형태(코드 기본값 + 환경변수 덮어쓰기)로 대칭입니다.

    확인하는 김에 아무도 대조하지 않았던 것을 대조했습니다 — 두 리터럴이 바이트 단위로 같습니다.

    Spring application.yml openai-text-embedding-3-small-1536-cosine-v1
    FastAPI config.py:53 (ai origin/main) openai-text-embedding-3-small-1536-cosine-v1

    이것이 어긋나면 런타임 대조가 모든 검색을 422로 만듭니다. "두 정본이 코드에 있다"보다 **"두 값이 같다"**가 실제로 지켜야 할 명제인데, 지금 그 대조는 사람이 한 것이고 자동 장치가 없습니다 — 아래 5번(배포 스모크)이 그 자리입니다.

  2. docs/ai/spec/ai-integration.md §2.1이 개정 전 §7.1을 그대로 담고 있습니다.위임받아 이 PR에서 고쳤습니다 (b6a8ebd). 처음에는 AI 파트 소유 구역이라 CLAUDE.md 9번대로 충돌 표시만 남겼고, AI 파트(중앙)가 그 판정을 받아 수정 권한을 위임했습니다. 고친 것은 셋입니다.

    이전 이후
    설정 키 internal-token: ${PINLOG_AI_INTERNAL_TOKEN} internal-secret: ${PINLOG_AI_INTERNAL_SECRET:}
    Profile 규칙 "배포 환경의 단일 설정에서 주입" · "값 자체는 코드에 상수로 두지 않는다" 개정된 §7.1·BD-39에 맞춘 본문 (리터럴 + 환경변수 덮어쓰기, 런타임 대조)
    base-url http://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으로 넓혀 세 레포를 다시 훑었습니다 — docs 0건 · ai 0건 · back은 그 한 줄뿐. docs/ai/WORKLOG.md의 back#83 기술은 옛 값을 인용하는 이력 기록이라 그대로 두었습니다.

  3. 검색 질의 길이 상한이 08 §1.9 표에 없습니다. 명세가 금지한 게 아니라 아직 다루지 않은 자리이고, 상한이 없으면 임의 길이 문자열이 곧바로 외부 임베딩 호출 비용이 됩니다(FastAPI 스키마도 min_length만 있고 상한 없음). Context 본문과 같은 500자를 백엔드 방어로 두었습니다 — 질의도 그대로 임베딩 입력이라 이유가 같습니다. 명세에 반영되면 그 값이 정본입니다.

  4. Profile 전환 절차가 없습니다. 전환하려면 두 레포를 각각 배포해야 하고 그 사이 구간은 검색이 422입니다. 무중단 전환 방법(양쪽 Profile 동시 허용 등)은 §7.1에도 없고 이 PR에서도 정하지 않습니다.

  5. 배포 스모크에 검색을 한 번 태워야 합니다. 두 Profile이 어긋나는 것은 런타임 대조가 잡지만 잡히는 시점이 배포가 아니라 첫 검색입니다. 그 사이를 메우려면 배포 게이트가 검색을 한 번 호출해야 합니다.

🤖 Generated with Claude Code

colosair and others added 3 commits July 29, 2026 17:57
`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>
@colosair
colosair requested a review from minyongP July 29, 2026 09:23
@colosair

Copy link
Copy Markdown
Member Author

리뷰 요청 — 공통 기반 넷이 접점입니다

민용님, AI 파트 작업이지만 back/src/** 공통 기반이 바뀌어 리뷰를 요청드립니다. 나머지(domain/search/**·domain/ai/**·검색 계약)는 AI 소관이라 그쪽은 참고만 하셔도 됩니다.

파일 변경
global/response/BoundsResponse.java 신규(이동)domain/record/dto에서 올림
global/common/InputLimits.java +11 — 검색 요청 상한
global/exception/ErrorCode.java +9 — 검색 오류 코드
docs/development/package-structure.md +2 -1 — domain/search

1. BoundsResponse 이동이 이 PR에서 가장 봐 주셨으면 하는 부분입니다

검색 응답이 지도와 같은 bounds 규칙을 쓰게 되어 domain/record·domain/search 두 도메인이 공유하게 됐습니다. 값만 공유하고 min/max 계산을 각자 짜면 "결과 없음일 때 null인가"가 조용히 갈라진다는 것이 올린 이유입니다.

다만 공통 기반을 늘리는 결정이고, global/에 무엇을 두는지는 백엔드 기준이 있을 수 있습니다. 도메인 간 공유가 둘뿐일 때 올리는 게 이른지, 아니면 지금이 맞는지 판단해 주십시오. 되돌려야 하면 domain/record/dto에 두고 search가 참조하는 형태로 바꾸겠습니다.

2. package-structure.md를 AI 파트가 고쳤습니다

domain/search 행 추가입니다. docs/development/가 백엔드 소유 문서라 먼저 여쭙는 게 맞았는데 새 도메인을 만들면서 함께 넣었습니다. 형식이나 표현이 그쪽 기준과 다르면 알려 주십시오.

back#80 때 *"소유가 백엔드라는 것이 오히려 직접 고칠 근거"*라고 하신 것은 작성자가 백엔드 담당일 때였고, 이번은 AI 파트라 사정이 다릅니다.

3. ErrorCode·InputLimits 추가값

SEARCH_PROFILE_MISMATCH·SEARCH_UNAVAILABLE 둘로 나눈 이유는 사람이 해야 할 일이 정반대라서입니다. 전자는 배포 설정이 어긋난 것이고 후자는 AI 서버가 응답하지 않는 것입니다. FastAPI가 요청 검증 실패에도 422를 쓰므로 상태 코드만으로 단정하지 않고 응답 본문의 serverProfile 유무로 갈랐습니다.

참고 — AI 소관이라 리뷰 대상은 아니지만

embeddingProfile 취득 경로를 이 PR에서 확정했습니다(BD-39). 공용 계약 05 §7.1이 그 결정을 이 티켓 시점으로 명시적으로 유예해 뒀고, 오늘 그 §7.1이 개정되면서(정본이 "배포 환경 단일 설정" → "코드") 전제가 바뀌어 application.yml 리터럴 + 환경변수 덮어쓰기로 정했습니다.

docs/ai/spec/ai-integration.md §2.1의 internal-token 오기도 함께 고쳤습니다. back#83에서 §7 헤더만 고치고 이 설정 키를 놓쳤던 것인데, 당시 전수 검색 패턴이 헤더 표기만 잡았기 때문입니다. 패턴을 넓혀 세 레포를 다시 훑어 잔존이 없음을 확인했습니다.

CI는 통과했고(backend-ci / check), clean check도 로컬에서 그린입니다.

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

Claude Code 자동 리뷰입니다. 판정은 항상 comment이며, 승인·변경 요청은 @minyongP가 직접 남깁니다.

필수 변경은 없습니다. 「검증하지 않은 것」에 대역만 썼다고 적어 두신 구간을 ai 레포 코드로 직접 대조했고 네 가지가 다 맞았습니다. ① 와이어 필드명 — app/schema/search.pySearchRequest가 camelCase(userId·embeddingProfile)이고 이 레포에 Jackson naming strategy 설정이 없어 그대로 맞습니다 ② 리뷰 포인트 2의 판정 근거 — app/main.py_profile_mismatchserverProfile·requestProfile본문 최상위에 싣습니다. body.has("serverProfile")가 실제로 동작합니다 ③ 「미결 1」의 리터럴 — config.py의 기본값과 application.yml의 값이 같습니다 ④ size 상한 — SearchRequest.limitle=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번(ContextKeywordRepositorydomain/ai에 둔 것)도 세 번째 소비자까지 미루는 판단에 동의합니다. 지금 합치려면 FeedKeywordRepository의 가시성 화이트리스트와 이쪽을 하나로 만들어야 하는데, 두 경로의 공개 범위가 같아야 한다는 근거가 아직 없습니다.

좋았던 점. 대역이 일부러 거짓말을 하게 만든 것이 이 PR에서 가장 값어치 있는 선택이었습니다 — "남의 Record를 0.99로 돌려줘도 응답에 없다"는 진짜 FastAPI로는 증명할 수 없는 단정입니다. 커버리지 미달을 채우다 실제 방어 구멍 네 개를 찾은 경로도 좋았습니다.

범위. diff가 2249줄이라 절차대로 src/**만 봤고 docs/backend/**는 보지 않았습니다. 다만 docs/ai/spec/ai-integration.md는 다른 파트 소유 구역이라 따로 읽었습니다 — 정정 기록을 근거까지 붙여 남긴 형태라 문서로서는 문제가 없었고, internal-token 잔존을 함께 정리한 것도 맞는 방향입니다. 위임 사실 자체는 이 PR 안에서 확인할 수 없으니 그 부분만 사람이 확인해 주세요.

Comment thread src/main/java/com/pinlog/pinlogback/domain/ai/client/AiSearchClient.java Outdated

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

Claude Code 자동 리뷰입니다. 판정은 항상 comment이며, 승인·변경 요청은 @minyongP가 직접 남깁니다.

필수 변경 없음. 앞선 리뷰에서 이미 짚은 줄 단위 제안 2건(embeddingProfile 빈 문자열 기동 검사, distinctByRecordmatch == 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")를 갖고 있어, RestClient Bean이 둘이 되면서 모호해지는 주입부는 없습니다.

리뷰 포인트 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로는 증명할 수 없는 단정입니다. 다만 세 자리가 비어 있습니다.

  1. SEARCH_QUERY_MAX(500)가 테스트되지 않습니다. 이 PR이 명세를 넘어 새로 발명한 유일한 상한인데(미결 3), 501자 요청 테스트가 없습니다. 빈 질의 400과 size: 101 400은 있습니다. 누군가 RecordSearchRequest@Size를 떼도 342개가 전부 통과합니다.
  2. PRIVATE_ONLY를 한 번도 넣지 않습니다. 테스트가 쓰는 visibility는 PUBLIC(포함)과 BLOCKED(제외)뿐입니다. 그런데 PRIVATE_ONLY는 05 §8.4에서 본인 조회와 타인 조회를 가르는 바로 그 값입니다. 화이트리스트를 IN ('PUBLIC')으로 좁혀도 테스트는 전부 초록이고, 실제로는 소유자 Keyword의 절반이 조용히 사라집니다. 가시성 필터를 WHERE 절에 둔 이유가 "조용한 누락 방지"인데, 그 누락을 잡을 단언이 없습니다.
  3. insertPreset(..., boolean active)active가 죽은 파라미터입니다. 세 호출이 모두 true라서 kp.is_active = true 조건이 검증되지 않습니다. 폐기된 Preset 하나를 false로 넣고 제외를 단언하면 파라미터도 살고 조건도 덮입니다.

낮은 우선순위: AiSearchClient.translate의 403 분기와 read-timeout(5s) 경로는 미검증입니다(401·연결 끊김은 덮여 있습니다).

문서·계약 갭 (후속)

  • 08 §1.5의 권장 상태 코드 표에 503이 없고, 새 error.code 2종이 공용 계약 어디에도 등록되지 않았습니다. 명세는 *"code는 클라이언트가 분기하는 안정적인 문자열"*이라고 정했는데, 프론트가 검색에서 503 + SEARCH_PROFILE_MISMATCH/SEARCH_UNAVAILABLE을 분기해야 한다는 사실이 계약에 없습니다. 미결 목록에 질의 길이(§1.9)는 올렸는데 이쪽은 빠졌습니다. docs PR 한 건으로 닫힙니다.
  • docs/ai/spec/ai-integration.md에 같은 블록의 낡은 줄 2개가 남습니다. §2 표의 위치가 com.pinlog.pinlogback.ai.client(실제는 domain.ai.client)이고, "RestClientRestClient.BuilderBean 하나를 만들고"는 이 PR이 Bean을 둘로 만든 뒤로 사실과 다릅니다. base-url을 지시 범위 밖인데도 함께 맞춘 것과 똑같은 논거가 적용되는 자리입니다(같은 블록에 알면서 틀린 줄을 남기지 않는다).

위험 — 변경 요청은 아님

FastAPI 장애 시 검색 요청 하나가 워커 스레드를 최대 5초 붙잡습니다. 재시도를 없앤 판단은 맞지만 동시 점유에는 상한이 없습니다 — 검색 트래픽이 몰린 상태에서 FastAPI가 죽으면 워커 고갈이 무관한 엔드포인트까지 끌고 갑니다. 시연 규모에서는 문제가 아니고, 막으려면 세마포어 하나(또는 서킷 브레이커)면 되므로 후속으로 충분합니다. 같은 맥락에서 SimpleClientHttpRequestFactory는 사용자 경로에서 매 검색마다 TCP 연결을 새로 엽니다.

사소한 것

  • BoundsResponse.enclosingnull을 반환하는 것이 계약의 전부인데 @Nullable이 없습니다. RecordSearchResponse.boundsMapResponse는 nullable로 다루고, 이 PR은 domain/ai/exception@NullMarked package-info까지 새로 넣었습니다. global/response에는 package-info가 없으니 반환 타입에 @Nullable 한 줄만 붙이는 것이 범위에 맞습니다.
  • sizelimit으로 그대로 넘긴 뒤 재검증에서만 줄어들므로, ai 인덱스가 삭제를 늦게 반영하면 20을 요청한 사용자가 3건을 받고 응답만으로는 그 이유를 알 수 없습니다. 명세 6.3의 "조용히 제외"를 따른 의도된 동작이지만, 운영 중 체감되면 과다 조회 계수가 필요해집니다.
  • 테스트 헬퍼 newRecordjava.util.UUID를 전체 경로로 인라인 호출합니다(같은 파일의 다른 타입은 import). insertPresetmax(id) + 1은 병렬 실행 시 충돌합니다.

결론

병합 전 처리 값어치가 가장 큰 것은 앞선 리뷰의 줄 단위 제안 2건이고, 그다음이 위 테스트 3건입니다. 리뷰 포인트 1은 **(a)를 유지한 채 "대조만 하는 기동 검사"**로 미결 1·5를 함께 닫을 수 있는지 판단해 주시면 됩니다. 리뷰 포인트 3(매칭 Context 사멸 시 Record 전체 제외)과 4(ContextKeywordRepositorydomain/ai에 둔 것)는 앞선 리뷰 의견에 동의합니다.

colosair and others added 2 commits July 30, 2026 10:18
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>
@colosair
colosair merged commit 1a5091e into dev Jul 30, 2026
2 checks passed
@colosair
colosair deleted the feat/S15P11A705-135-personal-search branch July 30, 2026 01:28
cherry-go-round added a commit that referenced this pull request Jul 30, 2026
프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다.

공용 계약이 먼저 바뀌어야 했다. 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>
cherry-go-round added a commit that referenced this pull request Jul 30, 2026
프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다.

공용 계약이 먼저 바뀌어야 했다. 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>
minyongP pushed a commit that referenced this pull request Jul 30, 2026
프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다.

공용 계약이 먼저 바뀌어야 했다. 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>
minyongP pushed a commit that referenced this pull request Jul 30, 2026
프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다.

공용 계약이 먼저 바뀌어야 했다. 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>
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