Skip to content

feat(S15P11A705-124): 삭제 경로의 AI 파생 데이터 무효화 - #80

Merged
colosair merged 5 commits into
devfrom
feat/S15P11A705-124-ai-derived-invalidation
Jul 29, 2026
Merged

feat(S15P11A705-124): 삭제 경로의 AI 파생 데이터 무효화#80
colosair merged 5 commits into
devfrom
feat/S15P11A705-124-ai-derived-invalidation

Conversation

@colosair

@colosair colosair commented Jul 29, 2026

Copy link
Copy Markdown
Member

요약

Context가 소프트 삭제될 때 ai 스키마의 파생 데이터에 무효화 표시를 남깁니다 — ai.context_ai_state의 두 status를 CANCELLED로 전이하고 ai.context_embedding.is_deleted를 켭니다. #61이 제기한 누락을 해소합니다.

core 삭제 트랜잭션 안에 두 UPDATE를 추가하는 변경이라 RecordDeletionService·RecordService가 접점입니다. DB 스키마 변경(DDL·마이그레이션)은 없습니다.

리뷰 반영(2026-07-29) — 아래 세 가지가 바뀌었습니다. 설계 판단(단일 트랜잭션·조건 없는 CANCELLED·회원 탈퇴 미적용)은 그대로입니다.

  1. 롤백을 실제로 증명하는 테스트를 추가하고, 네 곳의 과장된 서술을 증명 범위로 낮췄습니다 → 리뷰 포인트 4
  2. BD-35·BI-21BD-37·BI-23 재배정, decisions/README.md·implements/README.md 색인 2행 추가 → 문서 번호
  3. 패키지 배치는 domain/ai/repository 유지 — 리뷰 권고대로 옮겼다가 back#82 때문에 되돌렸습니다 → 리뷰 포인트 1

Jira (필수)

관련 GitHub Issue

  • #61 — AI 파생 데이터 무효화가 빠져 있다. 자동 close 키워드를 일부러 쓰지 않았습니다 — #61의 "정해야 할 것" 4항 중 2항(작업 폴더 규칙 갱신)이 이 PR 밖에 남고, 3항(적용 범위)도 회원 탈퇴가 빠져 있습니다. 두 항목이 처리된 뒤 닫아 주세요

배경

RecordDeletionService의 javadoc은 이 쓰기를 "백엔드의 ai 스키마 연동이 아직 없어서" 뺐다고 적어 두었습니다. 그런데 공용 계약 06_데이터모델_및_무결성.md §1.3 쓰기 매트릭스는 이 두 컬럼만은 백엔드가 쓴다고 명시합니다. #61이 짚은 충돌이 그것입니다.

지금 증상이 없는 이유는 자연어 검색이 아직 붙지 않았기 때문입니다. 검색 제외는 context_embedding.is_deleted = false 필터가 단독으로 담당하므로(MVP는 정확 cosine 검색이라 ANN 인덱스가 없습니다), 플래그가 꺼진 채 POST /search/records가 붙으면 삭제한 Context가 검색 결과에 계속 나옵니다. 자연어 검색이 MVP 시연 범위라 이 작업이 선행이어야 합니다.

키워드 쪽은 더 직접적입니다. ai.context_keyword에는 is_deleted에 해당하는 컬럼이 아예 없어, 키워드 조회 제외를 context_ai_state.keyword_status = 'CANCELLED'가 단독으로 담당합니다.

반대 방향(늦게 도착한 AI 결과가 삭제된 Context를 되살리는 것)의 방어선은 이미 AI 워커 쪽에 있습니다. ai_state_repocomplete()·fail()WHERE {col} = 'PROCESSING' 가드를, try_start()status IN ('PENDING','PROCESSING')을 걸어 CANCELLED 이후에는 워커가 덮지 못합니다. 빠져 있던 것은 플래그를 켜는 쪽뿐이었습니다.

변경 사항

신규

  • domain/ai/repository/AiDerivedDataRepository — 백엔드가 ai 스키마에 쓰는 유일한 경로입니다. NamedParameterJdbcTemplate 기반 UPDATE 두 개만 담았습니다. 파일 하나로 좁힌 것이 의도입니다 — 쓰기 매트릭스(§1.3) 위반 여부를 리뷰에서 이 파일만 보고 판정할 수 있습니다. embedding·embedding_profile·PROCESSING/COMPLETED 전이에 닿는 코드는 어디에도 없고, ai.context_keyword에도 쓰지 않습니다(조회가 context_ai_state를 조인해 자동 제외). 'CANCELLED' 문자열은 이 클래스의 public static final CANCELLED 하나로 모았습니다 — 값 집합의 소유가 AI 파트라, SQL 두 개와 테스트가 각자 문자열을 들면 AI 쪽이 어휘를 바꿀 때 고칠 자리가 흩어집니다. invalidate의 인자는 JSpecify @NonNull로 계약을 명시했습니다.
  • domain/ai/AiDerivedDataInvalidationTests — 적용 지점별 검증 + 파생 데이터 부재·409 거절 경계 + 트랜잭션 롤백. 아래 "테스트 / 검증" 참조.
  • docs/backend/decisions/BD-37-… — 왜 별도 트랜잭션·이벤트가 아니라 삭제 트랜잭션 안인가. 선택지 (a)/(b)/(c)와 감수하는 것·재검토 트리거.
  • docs/backend/implements/BI-23-… — 구현 산출과 검증 결과, 적용하지 못한 지점의 근거.

변경

  • RecordDeletionServicedeleteContext(§6.5)와 cascadeDelete(§6.6, 일반·강제 공용)에 무효화를 추가했습니다. cascadeDeletefindByRecordId() 결과를 지역 변수로 받아 소프트 삭제와 무효화가 같은 목록을 쓰게 했습니다 — 두 번 조회하면 @SQLRestriction이 이미 지워진 행을 걸러 두 번째 목록이 빕니다. javadoc의 "연동이 아직 없어서" 문단도 현행에 맞게 고쳤습니다.
  • RecordService#replaceContext — Context 수정의 구 Context(§6.4)에 적용했습니다. 생성이 먼저·삭제가 나중이라는 기존 순서는 그대로입니다.
  • docs/backend/decisions/README.md·docs/backend/implements/README.md — 색인 각 1행.
  • docs/backend/WORKLOG.md — 한 줄 추가(merge=union).

지킨 세 가지

  • CANCELLED 전이에 조건을 걸지 않습니다. COMPLETED·FAILED도 덮습니다. COMPLETED를 남기면 임베딩 검색에서는 걸러지지만 키워드 조회에서 지운 Context의 Keyword가 계속 노출됩니다.
  • 두 status를 모두 바꿉니다. embedding_statuskeyword_status는 독립 전이하는 별개 축입니다.
  • 영향 행 0은 오류가 아닙니다. 임베딩·Keyword는 비동기 생성이라 커밋 직후에는 파생 데이터가 아직 없습니다. 반환값을 검사하지 않고, 빈 목록이면 쿼리 자체를 보내지 않습니다(IN ()은 문법 오류).

적용하지 못한 지점 — 회원 탈퇴(§6.9)

계약이 무효화를 요구하는 지점은 넷인데(#61 "정해야 할 것" 3항), 셋에만 적용했습니다. 회원 탈퇴는 백엔드에 경로 자체가 없습니다.

  • 컨트롤러 전수 조사(@RequestMapping·@PostMapping·@DeleteMapping)에 /v1/members 계열 매핑이 없습니다. AuthTokenControllerrefresh·logout만 갖습니다.
  • domain/member에 서비스 클래스가 없습니다(entity·repository뿐).
  • src/mainsoftDelete() 호출 지점 전수 조사에서 Context를 지우는 경로는 RecordDeletionService·RecordService 둘뿐입니다.
  • SocialAccount javadoc이 "마스킹과 deleted_at을 한 UPDATE에 담는 도메인 메서드를 쓴다(탈퇴 티켓에서 추가)" 라고 적어, 탈퇴가 별도 티켓임을 코드가 이미 명시합니다.

탈퇴는 Refresh 전량 무효화·인증 쿠키 만료·provider_user_id 마스킹·양방향 Follow 정리를 함께 요구하는 별개 유스케이스라 이 PR에서 만들면 범위를 벗어납니다. 탈퇴 티켓에서 삭제 절차 끝에 aiDerivedDataRepository.invalidate(contextIds) 한 줄을 붙이면 됩니다.

문서 번호 재배정

BD-35·BI-21#81이 같은 번호로 쓰고 있어 BD-37·BI-23으로 옮겼습니다. 파일명이 달라 git이 충돌로 잡지 않고 WORKLOG.mdmerge=union이라 두 행이 나란히 들어가는, 이 레포가 V4·BD-29·BI-18로 한 번 겪은 형태 그대로입니다. decisions/README.md"미머지 브랜치가 파일명으로 선점한 번호는 예약이 아니며 머지 순서대로 확정된다" 규칙에 따라 나중에 머지될 쪽인 이 PR이 비켰습니다.

back#81이 병합돼 BD-35·BI-21을 확정했습니다(d338618). 재배정이 실제로 필요했다는 사후 확인입니다 — dev에는 지금 BD-35-refresh-reuse-family-revocation.md·BI-21-2026-07-29-refresh-reuse-family-revocation.md가 있고, 이 PR의 BD-37·BI-23과 나란히 섭니다. dev를 병합했고 색인 두 파일 모두 두 항목이 정상적으로 공존합니다.

BD-36·BI-22도 비어 있지 않습니다#82가 쓰고 있어 BD-36/BI-22가 아니라 BD-37/BI-23입니다. 기본 브랜치 + 열린 PR을 전부 훑는 스크립트로 확인했고, 재배정 후 다시 돌려 충돌 경고가 사라지는 것까지 봤습니다.

색인 2행도 함께 추가했습니다 — 초안에서 decisions/README.md·implements/README.md 갱신이 아예 빠져 있었습니다.

테스트 / 검증

  • ./gradlew clean check --no-daemonBUILD SUCCESSFUL, 309 tests / 0 failed, line 96.6% · branch 81.9% (커버리지 게이트 통과). dev 병합 후 기준
  • DB 변경 시 PostgreSQL 통합 테스트 — AiDerivedDataInvalidationTests (Testcontainers pgvector/pgvector:0.8.5-pg16)
  • migration 변경 시 빈 DB migration 테스트 — 해당 없음 (DDL·마이그레이션 변경 없음)
  • API 계약 변경 시 관련 문서 갱신 — 해당 없음 (요청·응답 계약 무변경). 공용 계약은 이미 docs#14로 병합돼 있고 이 PR은 그 구현입니다
  • BD 추가 — BD-37

AI 워커의 State·Embedding 생성이 아직 없으므로, 테스트는 파생 데이터를 직접 INSERT해 워커가 만들어 둔 상태를 재현합니다.

테스트 고정하는 것
contextDeleteCancelsStateAndMarksEmbeddingDeleted §6.5 — 두 status CANCELLED + is_deleted
contextReplaceInvalidatesOnlyTheOldContext §6.4 — 구 Context만. 같은 Record의 살아남은 Context는 그대로
recordDeleteInvalidatesEveryActiveContextOfThatRecord §6.6 일반 — 활성 Context 전체. 다른 Record는 무사
forceRecordDeleteInvalidatesEveryActiveContextOfThatRecord §6.6 강제
terminalStatusesAreOverwrittenByCancelled COMPLETED·FAILED도 덮는다(조건 없음)
deletingAContextWithoutDerivedDataSucceeds Embedding 없음 / State·Embedding 둘 다 없음 → 204
forceDeletingARecordWithoutDerivedDataSucceeds 파생 데이터 없는 Record 통째 삭제 → 204
rejectedDeleteLeavesDerivedDataUntouched 409로 거절된 삭제는 무효화를 호출하지 않는다(제어 흐름)
invalidationRollsBackWhenTheDeletionTransactionFails 무효화 UPDATE가 삭제 트랜잭션과 함께 되돌아간다(원자성)

RED(무효화 자체): 세 호출부를 주석 처리하고 실행 → 8 tests completed, 6 failed. 실패하지 않은 둘은 forceDeletingARecordWithoutDerivedDataSucceeds·rejectedDeleteLeavesDerivedDataUntouched로, 과잉 적용을 막는 음성 테스트라 무효화가 없어도 통과하는 것이 정상입니다.
RED A(원자성): invalidate@Transactional(REQUIRES_NEW)를 붙여 실행 → 9 tests completed, 1 failed. 안쪽 트랜잭션이 먼저 커밋돼 CANCELLED가 남습니다.
RED B(호출 위치): invalidate를 Collection 루프 뒤로 옮겨 실행 → 9 tests completed, 1 failed. verify가 깨집니다.
GREEN: 복원 후 --tests "*AiDerivedDataInvalidationTests*" → 9 tests / 0 failed.
Regression: clean check 309개 전량 통과(dev 병합 후). 기존 테스트를 고치거나 지운 것은 없습니다.

리뷰 포인트

  1. 패키지 배치 — domain/ai/repository로 유지합니다(권고대로 옮겼다가 되돌렸습니다). 논거 두 개 중 (a)는 그대로 맞습니다 — 초안이 든 "record·collection 어느 쪽에 넣어도 다른 쪽에서 쓴다" 는 이 diff에서 성립하지 않습니다. 호출부 둘이 전부 domain/record/service이고 Collection 삭제는 Context를 소프트 삭제하지 않습니다(§6.7).
    • 다만 (b)의 전제가 리뷰 직후에 뒤집혔습니다. #82(S15P11A705-102, Context 생성 AI 연동)가 domain/ai를 정식 도메인으로 만들고 docs/development/package-structure.md에 도메인 행까지 등록했습니다 — AiIntegrationConfig·AiProperties·client/AiProcessClient·event/ContextAiRequestedListener·repository/ContextAiStateRepository·service/ContextProcessRequestAssembler. 리뷰 시점에는 그 PR이 없었습니다.
    • 이 상태에서 -124만 옮기면 ai 스키마 접근이 두 패키지로 갈립니다ContextAiStateRepositorydomain/ai/repository, AiDerivedDataRepositorydomain/record/repository. 회원 탈퇴가 붙으면 세 번째 위치가 생깁니다.
    • 그래서 배치 기준을 "소비 도메인"이 아니라 "닿는 외부 경계" 로 두고 domain/ai/repository에 유지합니다. 테스트도 #82의 domain/ai/ContextAiEnqueueTests와 같은 위치입니다. package-structure.md는 #82가 이미 갱신했으므로 이 PR에서 건드리지 않습니다(같은 파일 같은 줄을 두 PR이 고치면 충돌합니다).
    • 커밋 이력에 왕복이 남아 있습니다(7865253 이동 → e2a4211 복귀). BI-23에도 경위를 적었습니다.
  2. 무효화를 삭제 트랜잭션 안에 둔 것(BD-37). 감수하는 것은 ai 스키마 장애가 core 삭제까지 롤백시킨다는 점입니다. §6.6이 근거로 든 것이 "동일 인스턴스이므로 단일 트랜잭션으로 처리된다" 여서 계약을 따랐습니다. 이 트레이드오프에 이견이 있으면 여기서 갈라 주세요.
  3. cascadeDelete의 Context 목록을 지역 변수로 받은 것. 소프트 삭제와 무효화가 같은 목록을 봐야 합니다. 두 번 조회하면 @SQLRestriction 때문에 두 번째가 빈 목록이 됩니다.
  4. 원자성 검증 — 초안 서술이 틀렸고 고쳤습니다. rejectedDeleteLeavesDerivedDataUntouched가 고정하는 것은 "409가 무효화 호출보다 에서 난다"는 제어 흐름 사실이고, 무효화를 트랜잭션 밖으로 옮겨도 그대로 통과합니다. 지적대로 BD-37이 (b)·(c)를 기각하며 택한 원자성이 테스트로 하나도 덮이지 않은 상태였습니다. 제안하신 구성으로 invalidationRollsBackWhenTheDeletionTransactionFails를 추가했습니다 — collectionRepository.findByIdForUpdate@MockitoSpyBean으로 던지게 만들고, verify(invalidate)(제어 흐름이 무효화까지 갔다)와 파생 데이터 불변(그 UPDATE가 되돌아갔다)을 짝으로 단언합니다. 하나만으로는 "예외 때문에 호출되지 않았다"와 구분되지 않습니다.
    • 실패 지점을 AiDerivedDataRepository 바깥에 두는 것이 중요했습니다. 처음엔 스파이의 callRealMethod로 무효화 안쪽에서 던지는 형태를 시도했는데(호출 순서 결합이 없어서 더 깔끔합니다), 그 경로가 트랜잭션 프록시를 우회해 REQUIRES_NEW 주입을 통과시켰습니다. 실측 후 되돌렸습니다.
    • 알려진 한계: 이 테스트는 "무효화가 Collection 루프보다 앞"이라는 순서에 의존합니다(RED B가 그 증거입니다). 무효화를 루프 뒤로 옮기는 변경이 들어오면 트랜잭션 안이어도 깨지므로 그때는 주입 지점도 함께 옮겨야 합니다. BI-23 "검증"에 적어 뒀습니다.
  5. AI 파트 관점 검토 — 별도 코멘트로 남겼습니다(CANCELLED 어휘·is_deleted 의미의 소유, PROCESSING 가드와의 정합, ai 쓰기 범위).

미결 / 후속

  • 회원 탈퇴(§6.9) 적용 — 탈퇴 경로가 생기는 티켓에서. 위 "적용하지 못한 지점" 참조.
  • context_ai_state 최초 생성(PENDING) — 쓰기 매트릭스상 백엔드 몫이지만 Context 생성 쪽이라 이 PR 범위 밖입니다. 그때까지는 삭제 시 State 행이 없어 UPDATE가 0행을 칩니다(정상). 다만 그동안은 "삭제 → 나중에 State 생성"이라는 창이 이론상 남으므로, 생성 쪽 티켓을 검색 티켓보다 앞에 두는 편이 안전합니다.
  • docs/development/database-conventions.md 한 줄 — 소유 경계 절에 "V100~V199 구간의 테이블이라도 06 §1.3이 지정한 컬럼은 백엔드가 DML로 쓴다. DDL 소유는 그대로 AI 파트다"를 추가하는 것이 좋겠습니다(AI 파생 데이터 무효화가 빠져 있다 — ai 스키마 쓰기 주체 확정 필요 (06 §1.3 ↔ 작업 규칙 충돌) #61 "정해야 할 것" 2항). 이 PR의 범위 밖이라 넣지 않았고, 백엔드 소유 문서이므로 남에게 요청하지 않고 후속으로 직접 처리합니다.
  • 대량 무효화 시 인덱스 — 두 UPDATE 모두 PK(context_id) 조회라 현재는 인덱스가 필요 없습니다. 탈퇴 배치가 붙어 IN 목록이 커질 때 재확인합니다.

🤖 Generated with Claude Code

Context가 소프트 삭제될 때 `ai.context_ai_state`의 두 status를 `CANCELLED`로
전이하고 `ai.context_embedding.is_deleted`를 켭니다.

`RecordDeletionService`의 javadoc은 "ai 스키마 연동이 아직 없어서" 이 쓰기를 뺐다고
적어 두었지만, 공용 계약(`06_데이터모델_및_무결성.md` §1.3 쓰기 매트릭스)은 이 두
컬럼만은 백엔드가 쓴다고 명시합니다(back#61).

지금 증상이 없는 이유는 자연어 검색이 아직 붙지 않았기 때문입니다. 검색 제외가
`context_embedding.is_deleted = false` 필터에 단독으로 의존하므로, 플래그가 꺼진 채
`POST /search/records`가 붙으면 삭제한 Context가 결과에 계속 노출됩니다.

두 UPDATE는 기존 삭제 트랜잭션 안에 둡니다. §6.6이 근거로 든 것이 "동일 인스턴스이므로
단일 트랜잭션으로 처리된다"이고, 분리하면 부분 실패가 남아 이 변경의 목적이 깨집니다.

- `AiDerivedDataRepository` — 백엔드가 `ai` 스키마에 쓰는 유일한 경로입니다. 파일
  하나로 좁혀 쓰기 매트릭스 위반을 리뷰에서 한 곳만 보고 판정할 수 있게 했습니다.
- `RecordDeletionService` — Context 삭제(§6.5)와 Record 삭제·강제 삭제(§6.6)에 적용합니다.
- `RecordService#replaceContext` — Context 수정의 구 Context(§6.4)에 적용합니다.
- 회원 탈퇴(§6.9)는 백엔드에 경로 자체가 없어 적용하지 못했습니다. 근거는 BI-21에 있습니다.

`CANCELLED` 전이에는 조건을 걸지 않습니다. `ai.context_keyword`에 `is_deleted`가 없어
키워드 조회 제외를 `keyword_status`가 단독으로 담당하므로, `COMPLETED`를 남기면 지운
Context의 Keyword가 계속 노출됩니다.

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.

로컬 dev(dda2ed3)와 대조해 봤습니다. 계약 §1.3 위반 여부를 한 파일로 판정 가능하게 만든 설계, CANCELLED에 조건을 걸지 않은 근거(context_keywordis_deleted가 없다), 회원 탈퇴를 못 붙인 이유의 전수 조사 — 판단과 근거가 모두 단단합니다. V100__ai_tables.sql의 CHECK 제약에 CANCELLED가 있고 두 테이블 모두 context_id 단독 PK·updated_at 보유도 확인했습니다. SQL 두 개 자체에는 문제가 없습니다.

🔴 #81과 BD-35·BI-21 번호가 충돌합니다

같이 열려 있는 #81이 BD-35BI-21을 똑같이 씁니다.

이 PR #81
BD-35 BD-35-ai-derived-invalidation-inside-deletion-transaction.md BD-35-refresh-reuse-family-revocation.md
BI-21 BI-21-2026-07-29-ai-derived-invalidation-on-delete.md BI-21-2026-07-29-refresh-reuse-family-revocation.md

파일 이름이 달라 git이 충돌로 잡지 않습니다. WORKLOG.mdmerge=union이라 두 행이 나란히 들어가고, 각각 서로 다른 파일을 BD-35·BI-21로 링크합니다. 이 PR의 WORKLOG 항목 바로 위에 있는 2026-07-29 기록이 V4·BD-29·BI-18로 이미 한 번 남긴 실패 형태 그대로입니다 — "텍스트 충돌이 없는 것이 더 위험한 경우".

decisions/README.md의 규칙("미머지 브랜치가 파일명으로 선점한 번호는 예약이 아니며, 머지 순서대로 확정된다")대로 나중에 머지되는 쪽이 BD-36·BI-22로 재배정해야 합니다. Flyway와 달리 CI 그물이 없어서 머지 순서를 정한 뒤 사람이 처리해야 합니다.

그리고 이 PR은 decisions/README.md·implements/README.md 색인을 아예 갱신하지 않았습니다. #81은 양쪽에 행을 추가했는데 이 PR의 변경 파일 목록에는 없습니다. 번호를 재배정하든 안 하든 색인 행 2개가 빠진 상태입니다.

🔴 rejectedDeleteLeavesDerivedDataUntouched가 주장하는 것을 증명하지 않습니다

PR 본문(리뷰 포인트 4)·BD-35·BI-21·WORKLOG 네 곳이 이 테스트를 *"무효화가 삭제 트랜잭션 안에 있다는 증거"*라고 적었습니다. 그런데 RecordDeletionService#deleteContext를 보면:

if (contextRepository.countByRecordId(recordId) <= 1) {
    throw new DeleteConfirmationRequiredException(...);   // ← 409는 여기서 던진다
}
context.softDelete();
aiDerivedDataRepository.invalidate(contextId);            // ← 여기까지 오지 않는다

deleteRecord도 같습니다 — 409가 cascadeDelete 에서 납니다. 즉 거절 경로에서는 invalidate애초에 호출되지 않습니다. 이 테스트가 고정하는 것은 "거절 시 무효화를 호출하지 않는다"는 제어 흐름 사실이고 롤백과는 무관합니다. 무효화 호출부를 트랜잭션 밖 별도 메서드로 옮겨도 이 테스트는 그대로 통과합니다.

결과적으로 BD-35가 (b)·(c)를 기각하며 택한 원자성이 테스트로 하나도 덮이지 않았습니다. 확인해 보니 커스텀 DataSource·TransactionManager 빈이 없어 Boot의 단일 JpaTransactionManagerDataSource를 공유하고 NamedParameterJdbcTemplate이 진행 중 트랜잭션에 참여하므로 현재 동작은 맞습니다. 다만 두 번째 DataSource가 붙거나 누가 @Transactional(propagation = REQUIRES_NEW)를 끼워 넣으면 조용히 깨집니다.

cascadeDeleteinvalidate를 Collection 루프 에서 부르는 구조가 마침 검증에 유리합니다 — collectionRepository.findByIdForUpdate@MockitoSpyBean으로 던지게 만들고 ai.context_ai_state가 그대로인지 보면 진짜 롤백 단언이 됩니다. 그리고 네 곳의 서술을 실제로 증명되는 범위로 낮춰 주세요.

🟡 패키지 배치 — 리뷰 포인트 1에 대한 답

domain/record/repository/를 권합니다. 근거가 둘입니다.

  1. 선례가 이미 있습니다. FeedKeywordRepositoryai.keyword_preset을 읽으면서 소비 도메인인 domain/feed 아래 있습니다. package-structure.md도 같은 방향입니다 — ai 도메인을 만들지 않고 feed 행 비고에 *"core.feed_event는 AI 소유(V102) — 재정의 금지, 조회만"*을 적어 뒀습니다. BI-21이 "JPA 매핑을 안 한 이유는 FeedKeywordRepository와 같다"고 그 선례를 인용하면서 배치만 다르게 갔습니다.
  2. (a)의 전제가 이 diff에서 성립하지 않습니다. *"record·collection 어느 쪽에 넣어도 다른 쪽에서 쓴다"*고 하셨는데, 호출부 둘(RecordDeletionService·RecordService)이 **전부 domain/record/service**입니다. Collection 삭제는 Context를 소프트 삭제하지 않습니다(§6.7). 회원 탈퇴가 붙으면 domain/member가 두 번째 소비 도메인이 되지만, feed 선례가 그 상황을 이미 용인하고 있고 그때 승격을 다시 보는 편이 낫습니다.

그리고 domain/ai를 유지한다면 docs/development/package-structure.md 도메인 행 추가는 이 PR에 들어가야 합니다. 본문은 *"docs/development/는 백엔드 소유 문서라 이 PR에서 건드리지 않았다"*며 요청으로 남기셨는데, 그 파일은 이 레포 안에 있고 작성자가 백엔드 담당입니다. 소유가 백엔드라는 것이 오히려 직접 고칠 근거입니다. 지금 상태로 머지되면 남에게 갱신을 요청한 규칙을 위반한 채로 들어갑니다.

🟡 AI 파트 승인을 받아 두는 편이 좋겠습니다

ai 스키마에 쓰는 첫 백엔드 코드입니다. 계약 §1.3이 근거로 충분하긴 하지만, 이제 AI 쪽이 status 어휘나 is_deleted 의미를 바꾸면 백엔드가 깨집니다. BD-35의 "감수하는 것"도 그 결합을 인정하고 있습니다. 문서 링크만으로 머지하지 말고 AI 파트 승인을 이 PR에 남겨 두는 게 좋겠습니다.

🟢 확인한 것 / 사소한 것

  • cascadeDelete의 지역 변수 처리(리뷰 포인트 3)는 맞습니다. Hibernate가 쿼리 전 auto-flush를 하므로 두 번째 findByRecordId@SQLRestriction에 걸려 빈 목록이 됩니다. 놓치기 쉬운 지점을 정확히 짚으셨습니다.
  • invalidate(List)null에 NPE입니다. 내부 호출뿐이라 실해는 없지만 @Nullable 계약이 명시되지 않았습니다.
  • 'CANCELLED' 문자열이 SQL 2곳·테스트에 흩어져 있습니다. AI 파트가 값 집합을 소유하므로, 상수 하나로 모으면 ADR이 말하는 "결합 표면 최소화"에 더 맞습니다.
  • invalidate가 Collection FOR UPDATE 획득보다 앞서므로 ai 행 잠금 구간이 길어지지만, AI 워커는 ai 스키마만 만지므로 잠금 순환이 없습니다. 현재 위치 유지가 맞습니다(위 롤백 테스트에도 그쪽이 유리합니다).

정리하면 설계 판단 자체는 되돌릴 필요가 없고, 문서가 주장하는 것과 테스트가 증명하는 것의 간격이 남은 일입니다. 머지 전 필수는 번호 재배정과 README 색인 2행입니다.

back#80 리뷰가 짚은 "문서가 주장하는 것과 테스트가 증명하는 것의 간격"을 메웁니다.
설계 판단(단일 트랜잭션·조건 없는 CANCELLED·회원 탈퇴 미적용)은 그대로 둡니다.

- 롤백 테스트 추가: `rejectedDeleteLeavesDerivedDataUntouched`가 고정하는 것은
  409가 무효화 호출보다 앞에서 난다는 제어 흐름 사실이라 무효화를 트랜잭션 밖으로
  옮겨도 통과합니다. `collectionRepository.findByIdForUpdate`를 `@MockitoSpyBean`으로
  던지게 만들어 무효화 UPDATE가 나간 뒤 트랜잭션이 실패하는 상황을 만들고,
  `verify(invalidate)`와 파생 데이터 불변을 짝으로 단언합니다.
  REQUIRES_NEW 분리·호출 위치 이동을 각각 주입해 둘 다 잡히는 것을 확인했습니다.
- 서술 정정: BD·BI·WORKLOG 세 곳의 "무효화가 트랜잭션 안에 있다는 증거" 표현을
  실제 증명 범위로 낮췄습니다(PR 본문은 별도 갱신).
- 번호 재배정: back#81이 BD-35·BI-21을 선점해 BD-37·BI-23으로 옮겼습니다.
  BD-36·BI-22는 back#82가 쓰고 있어 check-number.sh가 준 번호를 그대로 썼습니다.
- 색인 2행 추가: decisions/README.md·implements/README.md.
- 패키지 이동: `domain/ai/repository` → `domain/record/repository`.
  FeedKeywordRepository 선례와 같고 호출부 둘이 모두 domain/record/service입니다.
- `invalidate` 인자에 `@NonNull` 계약 명시, `'CANCELLED'`를 상수 하나로 모음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@colosair

colosair commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

리뷰 반영했습니다. 세 지적 모두 맞았고, 특히 테스트가 주장하는 것과 증명하는 것의 간격은 저 혼자서는 못 봤을 지점입니다. 커밋 7865253입니다.

🔴 원자성이 테스트로 안 덮여 있던 것

말씀대로입니다. rejectedDeleteLeavesDerivedDataUntouched가 고정하는 것은 409가 무효화 호출보다 앞에서 난다는 제어 흐름 사실뿐이고, 무효화를 트랜잭션 밖으로 옮겨도 그대로 통과합니다. BD-37이 (b)·(c)를 기각하며 택한 원자성이 하나도 덮이지 않은 상태였습니다.

제안하신 구성으로 invalidationRollsBackWhenTheDeletionTransactionFails를 추가했습니다. collectionRepository.findByIdForUpdate@MockitoSpyBean으로 던지게 만들고 단언 둘을 짝으로 겁니다:

  • verify(aiDerivedDataRepository).invalidate(List.of(contextId)) — 제어 흐름이 무효화까지 갔다
  • ai.context_ai_stateCOMPLETED, context_embedding.is_deleted = false — 그 UPDATE가 되돌아갔다

하나만으로는 "예외 때문에 애초에 호출되지 않았다""호출됐지만 롤백됐다" 가 구분되지 않아서 둘 다 넣었습니다. 회귀를 각각 주입해 확인했습니다:

주입 결과
invalidate@Transactional(REQUIRES_NEW) 9 tests, 1 failed — 안쪽이 먼저 커밋돼 CANCELLED가 남습니다
invalidate를 Collection 루프 뒤로 이동 9 tests, 1 failedverify가 깨집니다

한 가지 놓칠 뻔한 게 있어 적어 둡니다. 처음엔 스파이의 callRealMethod로 무효화 안쪽에서 던지는 형태를 썼습니다. 호출 순서에 의존하지 않아 더 깔끔해 보였는데, 돌려 보니 REQUIRES_NEW 주입을 통과시켰습니다callRealMethod가 트랜잭션 프록시를 우회하기 때문입니다. 실측하고 되돌렸습니다. 실패 지점은 AiDerivedDataRepository 바깥에 있어야 합니다.

알려진 한계: 이 테스트는 "무효화가 Collection 루프보다 앞"이라는 순서에 의존합니다(위 표의 두 번째 줄이 그 증거입니다). 무효화를 루프 뒤로 옮기는 변경이 들어오면 트랜잭션 안이어도 깨지므로 그때는 주입 지점도 함께 옮겨야 합니다. BI-23 "검증"에 남겼습니다.

네 곳(PR 본문·BD·BI·WORKLOG)의 서술도 실제 증명 범위로 낮췄습니다. rejectedDelete…는 "거절 시 무효화를 호출하지 않는다"로 적었습니다.

🔴 번호 충돌 — BD-36·BI-22도 비어 있지 않았습니다

decisions/README.md의 머지 순서 규칙대로 이 PR이 비켰습니다. 다만 BD-36·BI-22가 아니라 BD-37·BI-23입니다#82(-102 Context AI enqueue)가 이미 BD-36·BI-22를 쓰고 있었습니다.

기본 브랜치 + 열린 PR을 전부 훑어 다음 미사용 번호를 주는 스크립트로 확인했고, 재배정 후 다시 돌려 충돌 경고가 사라지는 것까지 봤습니다. dev 최댓값+1로 판단했으면 또 물릴 자리였습니다.

색인 2행도 추가했습니다 — 지적대로 decisions/README.md·implements/README.md 갱신이 아예 빠져 있었습니다.

🟡 패키지 배치 — domain/record/repository로 옮겼습니다 정정됨

⚠️ 이 절은 뒤집혔습니다. #82가 domain/ai를 정식 도메인으로 만들면서 아래 근거 (2)의 전제가 깨졌습니다. 최종 배치는 domain/ai/repository 유지입니다 — 정정 코멘트 참조. 아래는 당시 기록으로 남깁니다.

논거 두 개 다 맞습니다. FeedKeywordRepository 선례가 이미 ai 스키마를 읽으면서 소비 도메인에 있고, 초안 근거 (a)는 이 diff에서 성립하지 않습니다 — 호출부 둘이 전부 domain/record/service이고 Collection 삭제는 Context를 소프트 삭제하지 않습니다(§6.7). 스키마 소유와 코드 배치가 별개라는 게 핵심이었습니다.

package-structure.md 갱신 요청도 철회합니다. 새 도메인을 만들지 않으므로 불필요해졌습니다. database-conventions.md 쪽 한 줄은 이 PR 범위 밖이라 넣지 않았지만, 지적하신 대로 백엔드 소유 문서이니 남에게 요청하지 않고 후속으로 직접 처리하겠습니다.

🟡 AI 파트 관점 검토

문서 링크만으로 머지하지 않도록 별도 코멘트로 남겼습니다. AI 레포 코드를 직접 읽고 확인한 내용입니다.

🟢 사소한 것 둘

  • invalidate(List)null 계약 — JSpecify @NonNull로 명시하고 javadoc에 근거를 적었습니다. "지울 것이 없다"(빈 목록)와 "목록을 못 구했다"(null)를 호출부가 섞어 넘기면 무효화가 조용히 빠지므로, @Nullable로 완화하지 않고 계약을 못 박는 쪽을 골랐습니다.
  • 'CANCELLED' 흩어짐AiDerivedDataRepository.CANCELLED 상수 하나로 모아 SQL 두 개와 테스트가 같은 것을 참조합니다.

검증

./gradlew clean check --no-daemon302 tests / 0 failed, line 96.3% · branch 81.3%. 기존 테스트를 고치거나 지운 것은 없습니다.

cascadeDelete의 지역 변수 처리(리뷰 포인트 3)와 invalidate의 현재 위치는 말씀대로 유지했습니다.

@colosair

colosair commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

AI 파트 관점 검토 — ai 스키마에 쓰는 첫 백엔드 코드

ai 스키마에 백엔드가 쓰는 첫 코드라, 계약 §1.3 링크만으로 머지하지 않고 AI 레포(Team-PinLog/ai b6b2df5) 코드를 직접 읽고 확인한 것을 남깁니다. AI 파트 확인 부탁드립니다.

1. 소유 경계 — 두 컬럼이 AI 파트 소유의 어휘에 의존합니다

이 PR이 ai 스키마에 쓰는 것은 두 테이블의 두 종류 컬럼뿐입니다.

테이블 쓰는 것 쓰지 않는 것
ai.context_ai_state embedding_status·keyword_status'CANCELLED', updated_at retry_count, PENDING 최초 생성(후속 티켓), PROCESSING·COMPLETED 전이
ai.context_embedding is_deleted = true, updated_at embedding, embedding_profile, INSERT 자체

그 외 ai 테이블에는 한 줄도 쓰지 않습니다. ai.context_keyword는 조회가 context_ai_state를 조인해 자동 제외하므로 건드리지 않고, ai.keyword_preset은 기존 FeedKeywordRepository가 읽기만 합니다. 백엔드 전체에서 ai. 스키마에 쓰는 경로는 AiDerivedDataRepository 한 파일이며, SQL 두 개가 전부입니다 — 경계 위반 여부를 이 파일 하나만 보고 판정할 수 있게 한 것이 의도입니다.

여기서 생기는 결합을 명시합니다.

  • 'CANCELLED'라는 status 어휘의 소유는 AI 파트입니다. V100__ai_tables.sqlCHECK (embedding_status IN ('PENDING','PROCESSING','COMPLETED','FAILED','CANCELLED'))가 정본이고, 백엔드는 그 값 집합에서 하나를 골라 씁니다. AI 쪽에서 이 어휘를 바꾸면 백엔드가 CHECK 위반으로 깨집니다.
  • context_embedding.is_deleted의 의미도 AI 파트 소유입니다. 백엔드는 "삭제됨"으로 켜기만 하고, 그 플래그가 검색에서 무엇을 의미하는지는 AI 쪽 쿼리가 정합니다.

BD-37의 "감수하는 것"이 이 결합을 이미 인정하고 있습니다. 결합 표면을 줄이려고 JPA 엔티티 매핑 대신 JDBC SQL로 두고, 'CANCELLED' 문자열도 상수 하나로 모았습니다. 다만 표면이 좁아진 것이지 없어진 것은 아닙니다 — 위 두 가지를 바꾸실 계획이 있으면 알려 주세요.

2. 워커의 PROCESSING 가드와 정합합니다 (코드로 확인)

"백엔드가 CANCELLED로 바꿨는데 늦게 도착한 워커 결과가 되살리는" 반대 방향은 AI 쪽 가드가 이미 막습니다. app/repository/ai_state_repo.py를 읽어 확인했습니다.

  • try_start()AND {col} IN ('PENDING','PROCESSING'). CANCELLED면 전이 자체가 안 됩니다. Keyword 단계는 AND embedding_status = 'COMPLETED'가 추가로 걸려, 우리가 두 status를 모두 바꾸므로 이중으로 막힙니다.
  • complete() / fail()AND {col} = 'PROCESSING'. fail()의 docstring이 "PROCESSING 가드로 CANCELLED를 덮지 않는다" 라고 명시합니다.

임베딩 저장 경로도 확인했습니다. app/service/embedding_service.py가 한 트랜잭션 안에서 lock_stateembedding_status != 'PROCESSING'이면 PersistDiscarded → 롤백하고, upsertcomplete()가 0행이면 다시 롤백합니다. CANCELLED 이후에는 임베딩이 저장되지 않습니다.

context_embedding_repo의 UPSERT가 ON CONFLICT SET 절에서 is_deleted를 의도적으로 빼 둔 것도 봤습니다 — 이미 켜 둔 플래그를 워커가 되돌리지 않습니다. 두 방향이 맞물립니다.

참고로 남은 창은 하나입니다. 백엔드의 context_ai_state 최초 생성(PENDING)이 아직 없어서, State 행이 생기기 전에 삭제되면 우리 UPDATE가 0행을 칩니다. 다만 검색 쿼리(personal-search)가 JOIN ai.context_ai_state ... AND s.embedding_status = 'COMPLETED'라 State 행이 없으면 결과에 나오지 않고, 생성 티켓(-102, #82)이 붙으면 닫힙니다. 검색 티켓보다 생성 티켓을 앞에 두는 편이 안전합니다.

3. 트랜잭션 — ai 장애가 core 삭제를 롤백시킵니다

두 UPDATE를 삭제 트랜잭션 안에 뒀습니다(BD-37). 계약 06 §6.6이 "소유는 AI 파트이나 트랜잭션 일관성을 위해 백엔드가 직접 쓴다. 동일 인스턴스이므로 단일 트랜잭션으로 처리된다" 로 지시한 방향입니다.

AI 파트 입장에서 확인해 주실 것: 이 선택의 대가는 ai 스키마 쪽 장애·잠금 대기가 core 삭제까지 실패시킨다는 점입니다. 두 UPDATE 모두 context_id PK 조회라 잠금 구간이 짧고, 워커는 ai 스키마만 만지므로 잠금 순환은 없습니다. 다만 ai가 별도 인스턴스로 분리되면 이 결정의 전제가 깨집니다 — BD-37의 재검토 트리거에 적어 뒀습니다.

4. DDL은 만들지 않았습니다

마이그레이션 추가가 없습니다. V100V199(AI 구간)는 물론 V2V99(백엔드 구간)도 이 PR에서 변화가 없습니다. DDL 소유는 그대로 AI 파트이고, 백엔드는 DML만 씁니다.

5. 덧붙임 — ai-integration.md §7의 인증 헤더 불일치

back/docs/ai/spec/ai-integration.md §7이 내부 호출 헤더를 X-Internal-Token으로 적었는데 ai 구현은 X-Internal-Secret입니다(app/core/security.py:12 INTERNAL_SECRET_HEADER). back#82가 이 지점에 > 검토 필요 표시를 남겼습니다.

AI 파트 판정: 구현이 맞고 문서가 틀렸습니다. 05 §13이 *"ai 레포 코드가 실행 가능한 계약의 원본"*이라 정한 대로입니다. 문서 정정은 AI 파트가 별도로 처리하며 back#80·back#82 어느 쪽에서도 고치지 않습니다. 이 PR은 ai 스키마에 DB로만 쓰고 FastAPI를 호출하지 않으므로 영향을 받지 않습니다.


확인 요청: 1번의 두 항목(CANCELLED 어휘·is_deleted 의미)을 바꿀 계획이 있는지, 2번의 가드 정합 판단에 빠진 게 없는지 봐 주시면 됩니다. 이견이 없으시면 이 코멘트를 승인 근거로 삼겠습니다.

back#80 리뷰 권고로 `domain/record/repository`로 옮겼던 것을 되돌립니다.

리뷰 근거 하나가 "`package-structure.md`가 `ai` 도메인을 만들지 않는 방향을
명시한다"였는데, 직후 올라온 back#82(S15P11A705-102)가 `domain/ai`를 정식
도메인으로 만들고 그 문서에 도메인 행까지 등록해 전제가 뒤집혔습니다.

`-124`만 `domain/record`에 두면 `ai` 스키마 접근이 `ContextAiStateRepository`
(`domain/ai/repository`)와 두 패키지로 갈리고, 회원 탈퇴가 붙으면 세 번째
위치가 생깁니다. 배치 기준을 "소비 도메인"이 아니라 "닿는 외부 경계"로 두고
`domain/ai/repository`에 유지합니다.

`package-structure.md`는 back#82가 이미 갱신했으므로 건드리지 않습니다.
테스트도 back#82의 `domain/ai/ContextAiEnqueueTests`와 같은 위치로 되돌립니다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@colosair

Copy link
Copy Markdown
Member Author

정정 — 패키지 배치는 domain/ai/repository로 유지합니다

앞선 코멘트에서 "domain/record/repository로 옮겼습니다" 라고 답했는데 되돌렸습니다. 커밋 이력에 왕복이 남아 있습니다 — 7865253(이동) → e2a4211(복귀).

이유 — 근거 (2)의 전제가 리뷰 직후에 뒤집혔습니다

주신 논거는 둘이었습니다.

  1. FeedKeywordRepositoryai.keyword_preset을 읽으면서 domain/feed 아래 있다 (선례)
  2. package-structure.mdai 도메인을 만들지 않는 방향을 명시한다 (문서)

리뷰 시점에는 (2)가 맞았습니다. 그런데 그 직후 올라온 #82(S15P11A705-102, Context 생성 AI 연동)가 domain/ai를 정식 도메인으로 만들었습니다.

domain/ai/AiIntegrationConfig.java
domain/ai/AiProperties.java
domain/ai/client/AiProcessClient.java
domain/ai/event/ContextAiRequestedListener.java
domain/ai/repository/ContextAiStateRepository.java   ← 같은 패키지
domain/ai/service/ContextProcessRequestAssembler.java
docs/development/package-structure.md  +1            ← 도메인 행 등록까지 했습니다

이 상태에서 -124만 옮기면 ai 스키마 접근이 두 패키지로 갈립니다ContextAiStateRepositorydomain/ai/repository, AiDerivedDataRepositorydomain/record/repository. 회원 탈퇴(§6.9)가 붙으면 세 번째 위치가 생깁니다.

그래서 배치 기준을 "소비 도메인"이 아니라 "닿는 외부 경계" 로 두고 domain/ai/repository에 유지합니다. 테스트도 #82의 domain/ai/ContextAiEnqueueTests와 같은 위치입니다.

(a)에 대한 지적은 그대로 유효합니다. 초안이 든 "record·collection 어느 쪽에 넣어도 다른 쪽에서 쓴다" 는 이 diff에서 성립하지 않습니다 — 호출부 둘이 전부 domain/record/service이고 Collection 삭제는 Context를 소프트 삭제하지 않습니다(§6.7). 그 근거는 폐기하고, "ai 접근을 한 패키지에 모은다" 로 갈아탑니다.

package-structure.md이 PR에서 건드리지 않습니다. #82가 이미 갱신했고, 두 PR이 같은 파일 같은 줄을 고치면 충돌합니다. (앞 코멘트에서 "갱신 요청을 철회한다"고 한 것은 그대로입니다 — 이제는 요청도 불필요합니다.)

경위는 BI-23 "산출"에도 인용 블록으로 남겼습니다. 판단이 한 번 뒤집힌 기록이라 지우지 않고 남기는 편이 맞다고 봤습니다.

덧붙임 — ai-integration.md §7의 인증 헤더 불일치

두 PR을 함께 보실 때 같은 의문이 생길 수 있어 먼저 적습니다.

back/docs/ai/spec/ai-integration.md §7이 내부 호출 헤더를 X-Internal-Token으로 적었는데, ai 구현은 X-Internal-Secret입니다 (app/core/security.py:12 INTERNAL_SECRET_HEADER = "X-Internal-Secret"). #82가 이 지점에 > 검토 필요 표시를 남겼습니다.

AI 파트 판정은 "구현이 맞고 문서가 틀렸다"입니다. 05 §13이 "ai 레포 코드가 실행 가능한 계약의 원본" 이라 정한 대로입니다. 문서 정정은 AI 파트가 별도로 처리하며, back#80·back#82 어느 쪽에서도 고치지 않습니다.

이 PR은 ai 스키마에 DB로만 쓰고 FastAPI를 호출하지 않으므로 헤더 불일치의 영향을 받지 않습니다.

나머지는 그대로입니다

롤백 테스트(🔴 1)·번호 재배정(🔴 2)·색인 2행(🔴 3)·AI 파트 관점 검토(🟡)·사소한 것 둘(🟢)은 앞 코멘트대로입니다. 번호도 다시 확인했고 BD-37·BI-23 그대로 유효합니다(충돌 경고 없음).

./gradlew clean check --no-daemon 재실행 — 302 tests / 0 failed, line 96.6% · branch 81.8%.

@colosair

Copy link
Copy Markdown
Member Author

패키지 배치 — domain/ai/repository 유지로 병합합니다

리뷰 포인트 1(🟡)에 주신 권고와 반대 방향으로 갑니다. 근거가 리뷰 이후에 생긴 사실이라 그 시점에는 알 수 없으셨던 정보입니다. 병합 전에 남겨 둡니다.

논거 (2)의 전제가 리뷰 직후 뒤집혔습니다

주신 근거는 둘이었습니다.

  1. FeedKeywordRepositoryai.keyword_preset을 읽으면서 소비 도메인 domain/feed 아래 있다 — 선례
  2. package-structure.mdai 도메인을 만들지 않는 방향을 명시한다 — 문서

리뷰 시점에는 (2)가 정확했습니다. 그런데 그 직후 올라온 #82(S15P11A705-102)가 domain/ai를 정식 도메인으로 만들고 package-structure.md에 도메인 행까지 등록했습니다.

domain/ai/AiIntegrationConfig.java
domain/ai/AiProperties.java
domain/ai/client/AiProcessClient.java
domain/ai/event/ContextAiRequestedListener.java
domain/ai/repository/ContextAiStateRepository.java      ← 같은 패키지
domain/ai/service/ContextProcessRequestAssembler.java
docs/development/package-structure.md  +1               ← 도메인 행 등록

이 상태에서 -124만 옮기면 ai 스키마에 쓰는 코드가 두 패키지로 흩어집니다. BD-37이 택한 설계 의도가 *"경계 위반 여부를 이 파일 하나만 보고 판정할 수 있게 한다"*였는데, 두 곳으로 나뉘면 그 성질이 사라집니다. ContextAiStateRepository(생성 방향)와 AiDerivedDataRepository(무효화 방향)는 같은 두 테이블을 반대 방향으로 쓰는 한 쌍입니다.

논거 (1)은 지금도 유효합니다. 다만 FeedKeywordRepository읽기 전용이고 이쪽은 쓰기입니다. 소유 경계 판정이 필요한 것은 쓰기 쪽이고, 그래서 한곳에 모으는 이점이 선례를 따르는 이점보다 크다고 판단했습니다.

되돌린 이력을 남깁니다

한 번 옮겼다가 복귀했습니다 — 7865253(이동) → e2a4211(복귀). 커밋 이력에 왕복이 그대로 있습니다. 리뷰 반영 시점에는 (2)가 맞다고 보고 옮겼고, #82를 확인한 뒤 되돌렸습니다.

나머지 지적

  • 🔴 번호 충돌BD-37·BI-23으로 재배정했습니다. BD-36·BI-22가 아니라 +2인 것은 그 둘을 #82가 선점 중이기 때문입니다. 색인 2행도 추가했습니다
  • 🔴 테스트가 주장을 증명하지 않던 것invalidationRollsBackWhenTheDeletionTransactionFails를 제안하신 구성으로 추가했고, 회귀 2종(REQUIRES_NEW 주입 · 무효화를 루프 뒤로 이동)을 넣어 실제로 깨지는 것을 확인했습니다. 네 곳의 서술도 증명 범위로 낮췄습니다. 이 지적이 이번 리뷰에서 가장 값이 컸습니다 — 문서 네 곳이 같은 주장을 반복하고 있어서 저 혼자서는 되짚지 못했을 지점입니다
  • 🟡 AI 파트 승인 — AI 레포 코드(b6b2df5)를 직접 읽고 결합 표면을 위 코멘트에 정리했습니다. 'CANCELLED' 어휘와 is_deleted 의미가 AI 파트 소유라는 점, 그 둘을 바꾸면 백엔드가 깨진다는 점을 명시했습니다

back#81 병합으로 색인 3파일이 충돌 상태라 해소 후 병합합니다. 배치 판단에 이견이 있으시면 병합 후에라도 알려 주십시오 — 별도 커밋으로 되돌리겠습니다.

…-derived-invalidation

# Conflicts:
#	docs/backend/decisions/README.md
#	docs/backend/implements/README.md
@colosair
colosair merged commit db6bd81 into dev Jul 29, 2026
2 checks passed
@colosair
colosair deleted the feat/S15P11A705-124-ai-derived-invalidation branch July 29, 2026 06:00
colosair added a commit that referenced this pull request Jul 29, 2026
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>
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