Skip to content

Latest commit

 

History

History
96 lines (92 loc) · 109 KB

File metadata and controls

96 lines (92 loc) · 109 KB

WORKLOG — Backend 파트 (이전 이력)

이 파일은 여기서 멈춥니다. 2026-08-01 이전 작업 로그가 아래 표에 남아 있고, 이후 로그는 worklog/에 작업마다 파일 하나로 쌓입니다. 규칙은 worklog/README.md에 있습니다.

표의 행을 옮기지 않고 그대로 둡니다 — 이미 한 줄짜리라 파일로 쪼개도 늘어나는 정보가 없습니다.

왜 바꿨나. 이 파일은 모든 작업이 표 끝에 한 행을 더하는 구조라, 열린 PR이 둘 이상이면 반드시 충돌했습니다. .gitattributes의 merge=union은 로컬 병합만 풀어줄 뿐 GitHub 서버 병합에는 걸리지 않아, gh pr update-branch가 두 번째 PR부터 항상 실패했습니다. BD-45 · back#133

날짜 작업 관련 문서
2026-07-23 H2 제거 + Testcontainers(PostgreSQL/pgvector) 마이그레이션 검증 전환 (back#12) BD-01 · BI-01
2026-07-26 백엔드 파트 문서 체계 신설 — spec/decisions/implements/troubleshooting + WORKLOG (Jira 작업) 이 트리 전체
2026-07-27 (Task 1) core.member V2 마이그레이션 추가 — id·created_at·deleted_at 3컬럼, 활성 회원 부분 인덱스 (e755b1e, Jira 작업) BI-02
2026-07-27 (Task 2) BaseEntity(created_at·deleted_at 공유, updated_at 없음) + JpaAuditingConfig + Member/MemberRepository 구현 (0f3c4d6, Jira 작업) BD-02 · BI-02
2026-07-27 (Task 3) Member soft delete 검증 테스트 작성 — repository.delete()가 물리 삭제 대신 deleted_at을 기록하고 조회에서 제외됨을 확인, raw SQL로 영속된 deleted_at 검증을 강화 (d1204bf, 02fcaa7, Jira 작업) BI-02
2026-07-27 (Task 4) ./gradlew clean check 전체 게이트를 처음 실행해 공유 Testcontainers 컨테이너가 클래스마다 재시작되던 버그를 발견·수정(싱글톤 컨테이너 패턴, 6c61272) + 로컬 프로파일(application-local.yml) 추가 + database-conventions.md 공통 컬럼 규약, configuration.md 값 정렬(1acd804, 67bcd11) (Jira 작업) BD-02 · BT-01
2026-07-27 (Task 5) BaseEntity 공통 컬럼 결정·구현 리포트·Testcontainers 트러블슈팅 기록 (Jira 작업) BD-02 · BI-02 · BT-01
2026-07-27 (Task 1) ApiResponse<T> 공통 envelope 레코드 추가, 운영 Jackson 3 매퍼로 JSON 검증 (a45475a, 93f4ad6, Jira 작업) BD-03 · BI-03
2026-07-27 (Task 2) ApiResponseBodyAdvice로 domain 패키지 컨트롤러 응답 자동 감싸기, actuator·springdoc 제외 (fe52533, 86b0dd6, Jira 작업) BD-03 · BI-03
2026-07-27 (Task 3) GlobalExceptionHandler 네 분기가 ApiResponse.fail(error) 반환하도록 전환, 상태·코드·로그 레벨 변경 없음 (a1cd90f, Jira 작업) BD-03 · BI-03
2026-07-27 (Task 4) 규약 문서(api-conventions.md·error-handling.md) 갱신 + BD-03·BI-03 기록 + clean check 전체 게이트 통과 (c1ab841, e43812d, Jira 작업) BD-03 · BI-03
2026-07-27 (Task 5) ApiResponseOpenApiCustomizer(springdoc OperationCustomizer)로 /v3/api-docs 2xx 스키마에도 envelope 반영, BD-03·BI-03 정정 (Jira 작업) BD-03 · BI-03
2026-07-27 (Task 1) global/response/Cursor 추가 — Base64(정렬키,id) URL-safe·무패딩 인코딩, 디코딩은 마지막 , 기준 분할(뮤테이션 검사로 고정), 잘못된 커서는 InvalidCursorException(INVALID_INPUT) (377d006, 14888f2, Jira 작업) BD-04 · BI-04
2026-07-27 (Task 2) global/response/CursorPage<T> 추가 — items·nextCursor·hasNext, DEFAULT_SIZE(20)·MAX_SIZE(100)·normalizeSize, @JsonInclude 미적용으로 마지막 페이지 nextCursor:null 명시 노출 (72b2899, Jira 작업) BD-04 · BI-04
2026-07-27 (Task 3) 커서 페이지네이션 규약을 api-conventions.md에 반영 + BD-04·BI-04 기록 + clean check 전체 게이트 통과 (Jira 작업) BD-04 · BI-04
2026-07-27 (리뷰 반영) Cursor.decode(null) 500 버그 수정, sortKeyAsInstant() 접근자 추가, normalizeSize(MAX_SIZE) 경계 테스트 보강(b29149d) + BD-04 §1.6 충돌 기록·BI-04 @JsonInclude 근거 정정·api-conventions.md 존댓말 통일(Jira 작업) BD-04 · BI-04
2026-07-27 SIGTERM graceful shutdown 활성화(기본값 immediate였음) + 종료 타임아웃 20s를 k8s terminationGracePeriodSeconds 40s와 짝지어 결정. liveness·readiness probe 계약 테스트 추가, database-conventions.md에 backward-compatible migration 규약 신설 (Jira 작업) BD-05 · BI-05
2026-07-27 Infra 배포 연동 체크리스트 검증 — pgvector 0.8.5-pg16 정렬(compose digest 핀 + Testcontainers)만 수정하고 나머지는 충족 확인. 검증 중 blocker 2건 발견: 버전 구간 소유로 인한 Flyway out-of-order(BT-02), Redis 장애 시 집계 /health 60s 블로킹(BT-03) (Jira 작업) BI-06 · BT-02 · BT-03
2026-07-27 (Task 1) ErrorCode에 METHOD_NOT_ALLOWED(405)·UNSUPPORTED_MEDIA_TYPE(415) 추가 (1911dc9, Jira 작업) BD-06 · BI-07
2026-07-27 (Task 2) GlobalExceptionHandler가 ResponseEntityExceptionHandler를 상속하도록 전환, handleExceptionInternal에서 body만 envelope로 교체해 malformed JSON·405·415·파라미터 누락이 500 대신 자체 상태로 응답(cae552e, 7e31e02, Jira 작업) BD-06 · BI-07
2026-07-27 (Task 3) error-handling.md 프레임워크 예외 상태 매핑 표 갱신 + BD-06·BI-07 기록 + clean check 전체 게이트 통과(Jira 작업) BD-06 · BI-07
2026-07-27 그동안 안 남긴 도메인·데이터 모델 결정 18건(BD-07~BD-24)을 뒤늦게 정리. 이유의 성격을 넷으로 분류하는 규칙(능동적 선택·기본값 수용·제약으로 주어짐·근거 소실)과 BD·P## 경계를 decisions/README.md·TEMPLATE.md에 신설했다. 공용 계약(Team-PinLog/docs)에 근거가 있는 항목은 재서술하지 않고 절대 URL로 링크하고, 본문은 백엔드가 감수하는 것과 재검토 트리거에 집중했다. 규약 문서 4종에 역링크를 걸고 CONTRIBUTING.md·PR 템플릿에 결정 기록 트리거를 배선했다. 정리하다 Record 수정이 정책·MVP에만 있고 API·유저플로우엔 없는 공용 문서 불일치를 발견해 BD-09에 기록했다(정정은 후속). (Jira 작업) decisions/ 전체 · BD-07 · BD-09
2026-07-27 프론트 요구(최초 작성 시각 보존·오래된순 정렬)로 BD-07 재검토 트리거 발동 — origin_created_at 도입을 BD-25로 결정, 공용 계약(06 §2.5·07·08·05-1) 선반영(docs#16, #15 위 스택). 구현은 Jira 작업에서 BD-25 · BD-07
2026-07-28 dev 전체 코드 리뷰에서 찾은 계약 문서 불일치 5건 정정 — 인증이 쿠키 기반으로 개정된 것을 BD-21·BD-22에 반영하고, 규약 문서가 아직 Bearer·403 기준이던 것을 바로잡았다. ① 권한 실패는 403이 아니라 404이고 403은 CSRF 전용(공용 계약 08 §1) ② 기본 경로 /api/core/v1 — /v1은 컨트롤러가 직접 붙인다(전역 prefix 금지, v1·v2 공존을 위해) ③ 인증 엔드포인트 성공 응답은 본문이 없어(302·204) envelope가 적용되지 않는다는 사실을 계약으로 명시 — 처음엔 opt-out 장치가 필요하다고 적었는데 공용 계약 08 §3.2~3.4를 확인해 정정했다 ④ ErrorCode에 401·403·409가 없어 폴백이 INVALID_INPUT이 되는 것을 명시 ⑤ 비밀값 주입은 DB_PASSWORD 하나(${DB_URL} 예시는 인프라 계약과 불일치)·actuator는 health+prometheus 둘 다 필수. global/response·global/web이 패키지 구조 문서에서 빠져 있던 것도 채웠다. 66번 착수 전 팀원(인증)과 계약을 맞추기 위한 선행 작업 BD-21 · BD-22 · 인증 · API · 에러 · 설정 · 패키지
2026-07-28 BT-02(구간 소유로 인한 Flyway out-of-order 기동 실패) 해소 — 구간 구조를 유지하고 out-of-order: true를 적용했다. 핵심은 설정 한 줄이 아니라 "CI가 못 잡는다"던 공백을 테스트로 메운 것이다. FlywayOutOfOrderTests가 AI 구간만 적용된 DB를 재현해(전체 적용 후 백엔드 몫만 이력에서 되돌린다 — cherryPick이 상용 기능이라 community 판에서 못 쓴다) 백엔드 마이그레이션이 적용되는지 확인한다. 설정 적용 전 이 테스트가 BT-02에 기록된 예외를 그대로 재현하는 것을 확인한 뒤 GREEN으로 넘겼고, 실제 앱 기동으로도 Current version: 102 → Migrating to "2 - member" [out of order]를 확인했다. 감수한 것은 환경마다 적용 순서가 달라지는 것이며, core.feed_event가 AI 소유라 스키마 분리가 완전하지 않은 점을 재검토 트리거로 남겼다 (Jira 작업) BD-26 · BT-02
2026-07-28 레포 보호 장치 2건이 "내 로컬에서만 동작하거나 병렬 작업에서 오작동"하던 것을 고쳤다. ① 설계 문서 제외가 커밋되지 않는 .git/info/exclude에만 있어서, 다른 사람이 클론하면 .claude/superpowers/가 추적 대상이었다 → .gitignore로 옮기고 CONTRIBUTING.md에 근거를 적었다. ② 마이그레이션 불변성 CI가 계속 움직이는 base.sha와 직접 diff해서, dev가 앞서 나가면 남이 dev에 넣은 마이그레이션이 삭제로 잡혀 정상 PR이 막힐 수 있었다 → merge-base 기준으로 바꿨다. 실제 히스토리(299ea9c 브랜치 vs ce5b6f2 dev)로 거짓 위반을 재현하고 새 로직에서 사라지는 것, 진짜 수정은 여전히 차단되는 것을 둘 다 확인했다 (Jira 작업) CONTRIBUTING "Claude settings boundary" · "Where each rule is enforced"
2026-07-28 TraceIdFilter가 클라이언트 X-Request-Id를 무검증으로 MDC·로그 패턴·응답 헤더·오류 응답에 흘리던 것을 막았다. logging.md가 "사용자 입력의 개행 주입에 주의"라고 정해둔 지점을 정작 이 필터가 위반하고 있었다. 허용 목록([A-Za-z0-9-], 1~64자, 전체 일치)을 통과한 값만 채택하고 나머지는 서버 UUID로 대체한다. 검증 여부만 단정하면 부족해서 운영 로그 패턴으로 실제 렌더링해 줄 수가 하나로 유지되는지까지 테스트로 고정했다 — 개행이 통과하면 이 단정이 깨진다. 거절한 값은 로그에 남기지 않는다(남기면 막으려던 주입이 다시 열린다). 요청을 400으로 거절하지 않은 이유는 상관관계 ID가 부가 정보라서다 (Jira 작업) 로깅 규약 "들어온 traceId는 검증합니다"
2026-07-28 설정 두 건. ① open-in-view가 미설정이라 기본값 true였다 — 서비스 계층 밖에서 지연 로딩이 열려 도메인이 붙으면 컨트롤러에서 N+1이 조용히 생긴다. 엔티티가 member 하나뿐인 지금이 가장 싸서 껐다. ② application-prod.yml에 datasource·redis가 없어 어디에 접속하는지가 저장소만 봐서는 확인되지 않았다 — 인프라가 넣어 주는 환경변수에 암묵적으로 의존하는 상태였다. infra 계약(5장)대로 주소·계정을 파일에 적고 DB_PASSWORD만 주입으로 남겼다. 주소를 환경변수로 빼지 않은 이유는 비밀이 아니면서 출처 추적이 가능해야 하기 때문이다. ConfigurationContractTests가 두 설정을 파일 수준에서 감시한다(운영 주소는 클러스터 DNS라 컨텍스트를 띄우면 접속 실패하므로 YAML을 직접 읽는다). 경고가 실제로 사라졌는지는 앱을 띄워 확인했다 (Jira 작업) 설정 규약
2026-07-28 soft delete 경로가 둘(softDelete() / @SQLDelete)인데 어느 쪽이 정식인지 규약에 없어, 엔티티가 늘면 호출부마다 갈릴 상태였다. 골라 쓰는 게 아니라 역할이 다르다로 정리했다 — softDelete()가 정식 경로이고 @SQLDelete는 실수로 물리 삭제가 나가는 것을 막는 안전망이다. 정식 경로로 정한 근거는 호출 직후 메모리 엔티티가 이미 삭제 상태로 보인다는 것이고, repository.delete()는 DB 행만 갱신해 같은 인스턴스가 isDeleted() == false를 반환한다(그 인스턴스로 판단하는 코드가 조용히 틀린다). 이 대조가 테스트로 없었어서 두 경로를 각각 고정했다. @SQLDelete를 떼지 않는 이유는 그것이 유일한 안전망이라서다 — 떼면 delete()·cascade가 물리 삭제로 돌아간다. 시각 기준이 경로에 따라 갈리는 것은 감수하고 근거를 적었다. 운영 코드 변경은 없다 (Jira 작업) 데이터베이스 규약 "삭제하는 방법은 softDelete()입니다"
2026-07-28 authentication.md에 남아 있던 envelope opt-out 잔재 2줄을 걷어냈다. #40의 두 번째 커밋이 "제외 장치는 필요 없다"로 정정하면서 본문과 BD-21은 고쳤는데, 목록 형태로 떨어져 있던 단일 PR 원칙 4번과 완료 조건 체크박스는 못 고치고 넘어갔다. 그 두 줄이 하필 "하나라도 빠지면 병합하지 않는다"는 게이트여서, 인증 담당자가 체크리스트를 성실히 따를수록 같은 문서가 금지한 advice 판정 수정으로 끌려가는 상태였다. 문서가 코드와 어긋난 게 아니라 자기 자신과 어긋난 경우다 인증 계약 · BD-21
2026-07-28 envelope 판정이 런타임 advice와 문서 생성 커스터마이저 두 곳에 각자 있어서 ResponseEntity<ApiResponse<T>>에서 결론이 갈렸다 — 선언 타입이 ResponseEntity라 문서 쪽만 한 번 더 감싸, 문서는 data.data를 약속하고 실제 응답은 한 번만 감싼 상태였다. 두 곳을 각각 고치는 대신 판정을 global/web/EnvelopeTargets 한 곳으로 옮겨 같은 이유로 또 갈라지는 것을 막았다. advice는 거기에 컨버터 조건만 더하고, beforeBodyWrite의 instanceof 검사는 선언 타입으로 알 수 없는 경우(ResponseEntity<?>)의 최종 방어선으로 남겼다. 같은 케이스의 테스트를 문서 쪽과 런타임 쪽에 각각 두었다 — 두 테스트가 같은 결론을 요구하는 것이 문서와 실제 응답을 붙여 두는 장치다 (Jira 작업) API 규약 공통 응답 envelope
2026-07-28 core 도메인 6개 테이블(place·record·context·collection·collection_record·follow)을 V3 하나로 냈다 — FK와 부분 유니크로 서로 물려 있어 따로 내면 중간 상태가 생긴다. 유니크는 전부 활성행 부분 유니크(place만 전체 유니크)이고, 소프트 삭제 후 재저장이 실제로 통과하는지까지 테스트로 고정했다. 엔티티는 연관관계 대신 Long FK 컬럼으로 매핑했다(BD-14의 memberId 파라미터 구조와 정합, 잠금·카운트 쿼리 단순화). context.origin_created_at(BD-25)은 @PrePersist에서 채우는데, 상위 클래스의 감사 리스너가 엔티티 자신의 콜백보다 먼저 실행된다는 JPA 순서가 근거다. 인증 스텁은 티켓 명시대로 내지 않았다 — back#28 계약은 첫 컨트롤러가 나오는 67에서 도입한다 (Jira 작업) BI-08
2026-07-28 첫 도메인 API(Record·Context)와 인증 스텁을 냈다. 스텁은 back#28 계약 그대로 — Security 없이 순수 MVC 리졸버가 @LoginMember MemberPrincipal을 만들고, pinlog.auth.stub.enabled(local·test만)일 때만 X-Debug-Member-Id를 채택하며 그 외 401(fail-closed). 인증 도착 시 교체 지점은 리졸버 본문과 AuthTestSupport 두 곳뿐이다. Context 수정은 교체 유스케이스로 구현했다 — Record 잠금 후 생성이 먼저, 삭제가 나중이라 활성 수가 0이 되는 중간 상태가 없고, "마지막 Context 삭제 금지"는 삭제 유스케이스에만 적용된다. Place upsert는 ON CONFLICT DO NOTHING 후 재조회이며 기존 행을 전달값으로 갱신하지 않는다(스냅샷). 지도 bounds는 0개 null·1개 점 사각형까지 테스트로 고정 (Jira 작업) BI-09
2026-07-28 Collection API를 냈다. 티켓 본문이 정본 스펙과 두 곳에서 어긋나 있었다 — 내부 정렬(티켓: Record createdAt ASC / 정본: collection_record.created_at DESC)과 중복 추가(티켓: 거절 / 정본: 멱등 스킵). CONTRIBUTING의 "정본 우선" 규칙대로 스펙을 따르고 Jira 댓글로 기록했다. record_count는 Collection 행 잠금 아래에서 연결 변형과 같은 트랜잭션으로 갱신하고, 목록·내부 Record 커서는 첫 페이지와 커서 페이지 쿼리를 분리했다(Instant null 파라미터 타입 추론 회피). 타인 조회는 71 전까지 404 은닉이다 (Jira 작업) BI-10
2026-07-28 Follow·Library API를 냈다. Follow 생성 진입점은 collectionId 하나다(식별자 은닉) — 서버가 발행·활성·작성자 활성까지 확인해 followee를 식별하고, 자기 Shelf는 422, 중복은 409로 갈랐다(ErrorCode 신설). "탈퇴 User 제외"는 두 층에서 지켰다: 팔로우 목록은 Member entity join(@SQLRestriction이 join에도 적용)으로 행 자체를 걸러 Library 정의와 같은 결과를 내고, 책장 Collection 목록은 404가 아니라 빈 목록을 준다("목록에서 제외"는 존재 은닉과 다른 규칙이라서다). 별칭 정규화(strip → 빈 값 null)는 DTO 검증이 아니라 서비스에 뒀다 — null이 "제거"라는 의미를 가져 필수 검증을 걸 수 없다 (Jira 작업) BI-11
2026-07-28 삭제 5개 엔드포인트와 409 DELETE_CONFIRMATION_REQUIRED를 냈다. impact(recordDeleted·collectionIds)는 ErrorResponse의 NON_NULL 선택 필드로 실어 "일부 code는 추가 필드"(명세 1.5)를 계약대로 구현했다. 활성 수를 세고 분기한 뒤 쓰는 경로는 전부 부모 행을 잠갔고(Record→Collection 단방향이라 교착 없음), 활성 Context 2개에 동시 삭제를 보내 정확히 한쪽만 204인 것을 동시성 테스트로 고정했다. 티켓의 "Record 재생성 시 연결 승계"는 정본(2.4·6.2)과 충돌해 구현하지 않았고(Jira 댓글), AI 파생 무효화는 ai 스키마 쓰기 연동이 생기는 티켓으로 미뤘다 (Jira 작업) BI-12
2026-07-28 타인 조회 공개 범위를 냈다. 소유자용·공개용 DTO를 상속 없이 분리했고(BD-13), 공개 카드의 contexts는 생성자로 받지 않는 고정 null이다 — 명세의 "contexts": null wire 계약을 지키면서 본문을 담을 자리 자체를 없애 실수가 컴파일 오류로 실패한다. 공개 조립 경로는 ContextRepository를 호출하지 않는다. 진입 검사(발행·활성·소유자 미탈퇴) 실패는 403이 아니라 404(존재 은닉), follow 별칭은 조회자 자신 것만 나가고, 신원 필드 부재는 응답 문자열 검사로 고정했다. 접근 권한표(명세 12장) 각 행을 테스트로 옮겼다 (Jira 작업) BI-13
2026-07-28 커버리지 하한선을 check에 걸었다. 그전까지 jacoco는 리포트만 만들고 판정이 없어서 커버리지가 얼마로 떨어져도 빌드가 통과했다. 기준을 BUNDLE(전체 합계) LINE 80%·BRANCH 80%로 둔 이유는 두 지표의 여유가 다르다는 실측이다 — LINE은 98.5%로 18.5pp 여유라 사실상 유휴고, 실제로 작동할 게이트는 6.3pp 여유인 BRANCH 쪽이다. 클래스별 기준은 지금 4개(Collection BRANCH 50% 등)를 당장 실패시켜 게이트 도입과 테스트 보강이 한 PR에 섞이므로 미뤘다. PinlogBackApplication은 main()뿐이라 제외했고, 제외 목록을 JacocoReportBase 전체에 적용해 리포트 수치와 게이트 판정 수치가 갈라지지 않게 했다. 게이트가 실제로 막는지는 BRANCH를 0.99로 올려 Rule violated ... ratio is 0.86 실패를 확인해 검증했다 (Jira 작업) BD-27
2026-07-28 같은 장소 동시 저장이 500으로 나가던 것을 고쳤다. 조회로 "내 활성 Record 없음"을 판정하고 INSERT하는 구조라 동시 요청 둘이 나란히 통과해 uq_record_active를 위반했다. 그동안 안전해 보였던 건 의도한 방어가 아니라 Place upsert의 부수 효과였다 — Place가 없으면 ON CONFLICT가 미커밋 키에서 블로킹해 통째로 직렬화되지만, Place가 이미 있으면 그 직렬화가 사라진다(운영에선 이쪽이 일반 케이스다). 예외를 잡는 대신 Record에도 같은 ON CONFLICT DO NOTHING을 적용해 충돌 자체를 없앴고, 영향 행 수가 곧 분기 조건이 되어 순차·동시 경로가 한 갈래로 합쳐졌다(1=RECORD_CREATED, 0=CONTEXT_ADDED). 잡는 방식을 택하지 않은 이유는 제약 위반이 트랜잭션을 rollback-only로 만들어 같은 트랜잭션에서 되돌릴 수 없어서다(티켓 상세와 다른 선택이라 Jira 댓글 기록). 경합에 진 요청의 contextBody가 사라지지 않는 것까지 테스트로 고정했다 (Jira 작업) BI-14 · BD-12
2026-07-28 중복 팔로우 동시 요청이 500으로 나가던 것을 고쳤다. 중복 확인과 저장 사이가 원자적이지 않아 두 트랜잭션이 나란히 "중복 아님"으로 판정했고, 409 DUPLICATE_FOLLOW가 이미 있는데도 DataIntegrityViolationException이 전역 catch-all까지 올라가 500이 됐다. 105(Record)와 같은 성격의 경합이지만 처방은 갈렸다 — 105는 위반 뒤 재조회·INSERT를 이어가야 해서 rollback-only에 걸려 ON CONFLICT로 충돌을 없앴고, 104는 거절이 목적이라 위반 뒤 DB를 더 건드리지 않으므로 잡아서 예외만 바꾸면 된다. 모든 제약 위반을 409로 뭉뚱그리지 않고 uq_follow_active만 좁혀 잡았다(FK 위반이 "이미 팔로우한 책장입니다"로 나가면 원인을 가린다). Hibernate가 부분 유니크 인덱스 이름을 돌려주는지는 가정하지 않고 테스트로 확인했다 (Jira 작업) BI-15 · BI-14
2026-07-28 readiness 그룹에 db를 넣었다. probes.enabled: true만 켠 상태에서는 구성원이 readinessState 하나뿐이어서 DB가 죽어도 Ready로 남아 요청이 500으로 실패했다 — 인프라 체크리스트의 "PostgreSQL 정상 연결 시 readiness UP"은 문구대로는 충족하지만 "장애 시 트래픽에서 빠진다"는 의도는 충족하지 않는 상태였다. 세 안(현행 유지 / db / db+redis)의 장단점을 infra#33에 올려 운영 판단을 요청했고 인프라가 db만으로 확정했다(BD-28 — 백엔드가 고른 것이 아니라 제약으로 받았다). include에 readinessState를 함께 적어야 한다 — 대체 방식이라 db만 쓰면 기동 완료 전에도 UP이 된다. 테스트는 공유 컨테이너를 멈추지 않고(멈추면 JVM 전체가 깨진다 — BT-01) 닫힌 포트를 향하는 DataSource로 DB 장애를 만들었는데, 이때 Flyway·ddl-auto를 끄고 Hikari 초기화 실패를 허용해야 컨텍스트가 떠서 health를 조회할 수 있다. Redis 제외는 죽은 주소 + 테스트 전용 250ms timeout으로 readiness가 UP을 유지하는 것으로 고정했다(운영의 60초 블로킹은 back#65로 분리). liveness는 손대지 않았다 — 외부 의존성을 넣으면 DB 순단이 Pod 재시작으로 번진다 (Jira 작업) BD-28 · infra#33
2026-07-28 규약 문서가 "승인 1건"을 병합 조건으로 적어둔 것을 실제와 맞췄다. 세 PR을 머지하려다 발견했는데, branch protection과 ruleset 둘 다 required_approving_review_count: 0이었고 조직 표준(infra git-governance.md)도 이미 "승인 리뷰 0 — 단일 운영자 구조에서 형식적 self-approval은 요구하지 않음" 이었다. 즉 설정이 틀린 게 아니라 back 문서만 뒤처져 있었고, CONTRIBUTING이 "충돌하면 infra가 이긴다"고 선언해둔 터라 방향도 정해져 있었다. 하필 CONTRIBUTING의 "각 규칙의 강제 지점" 표가 이 항목의 권위를 "branch protection"으로 지목하고 있어서, 문서가 스스로 지목한 강제 지점과 어긋난 상태였다. 4개 파일 8곳(CONTRIBUTING·workflow·code-review·/pr 스킬)을 고치면서 "승인을 안 한다"가 아니라 "승인이 병합 게이트가 아니다"로 적었다 — 리뷰 절차 자체는 그대로 유효하다 CONTRIBUTING · 워크플로우 · 코드 리뷰
2026-07-28 recordIds 배열과 Context 본문에 서버 방어 상한을 넣었다(100개·500자). 목록 조회는 CursorPage.MAX_SIZE로 상한을 두면서 이 둘만 무제한이라 "서버 방어 상한이 얼마인가"에 답이 둘이었다. recordIds는 Collection 행을 잠근 채 건당 INSERT를 돌아 큰 배열이 다른 요청을 막고, Context 본문은 그대로 임베딩 입력이 되어 비용과 직결된다(데이터모델 8장 미확정 항목). 값은 상수 한 곳(InputLimits)에 모았다 — 500이 세 DTO, 100이 두 DTO에 들어가서 리터럴로 흩뿌리면 엔드포인트마다 상한이 달라지는 상태가 조용히 생긴다(envelope 판정이 두 곳에 갈려 있던 85번과 같은 성격). 티켓은 DTO 넷만 적었는데 RecordCreateRequest.contextBody까지 다섯 곳에 걸었다 — 그 경로로 501자를 넣으면 상한을 우회할 수 있어서다(Jira 댓글 기록). 프론트가 같은 값으로 입력 UI를 막아야 하는 건 05-1 §1.5로 등록했다 (Jira 작업) BI-16 · docs#19
2026-07-28 global/security를 패키지 단위 @NullMarked로 선언 — 표기 없는 파라미터가 Security 7의 non-null 파라미터를 재정의한다는 경고가 근본 원인이라 마킹으로 해결. 감사 중 SecurityErrorWriter.traceId()(MDC.get은 null 가능)와 saveAuthorizationRequest 파라미터 nullability 넓히기를 바로잡음 (f026b15, Jira 작업) BD-29
2026-07-28 미푸시 커밋(58651a4..ee9e80e)을 훑어 기록이 빠진 결정 1건을 뒤늦게 남겼다 — 인가 요청(state·PKCE verifier)을 HttpSession 대신 쿠키에 담은 선택. STATELESS 선언과 기본 구현(HttpSessionOAuth2AuthorizationRequestRepository)이 어긋나는 것이 출발점이었고, Redis에 두는 안을 "로그인 진입을 Redis 장애에 묶는 대가"로 물렸다. 쿠키를 서명하지 않은 대가가 무결성이 아니라 역직렬화 DoS 표면(필터에 maxdepth·maxbytes 없음)에 있다는 점, 쿠키 속성과 조작 쿠키 fail-closed가 아직 테스트로 고정되지 않은 것을 함께 적었다. 나머지 커밋의 판단은 기존 근거(BD-08·BD-10·BD-22·BD-26, 공용 계약 08 §1.2·§1.7)에 이미 걸려 있어 새 기록을 만들지 않았다 (Jira 작업) BD-30
2026-07-28 BD-21이 토큰 모델까지만 정하고 비워 둔 서명 알고리즘·키 관리를 BD-31로 결정했다. 먼저 외부 규범이 정해 주는지 확인했고 아니었다 — RFC 9068이 비대칭을 RECOMMENDED로 걸지만 그 이유("리소스 서버가 검증 정보를 얻는 과정을 단순화")가 발급자·검증자 분리를 전제하는데 우리는 한 프로세스다. RFC 8725는 대칭/비대칭에 중립이고, browser-based-apps 드래프트도 서명은 규정하지 않는다. 그래서 "IETF가 요구해서"가 아니라 되돌리는 비용의 비대칭 때문에 RS256을 골랐다(지금 필요해서가 아니라 나중 분리 시 알고리즘·키 배포·발급 토큰 호환을 한꺼번에 갈지 않으려는 선불). 함께 정한 것: 키는 환경변수 PEM(기존 DB_PASSWORD 경로 재사용), 미주입 시 로컬·테스트는 임시 키쌍 생성하되 운영은 fail-fast(파드마다 다른 키가 생기면 스케일아웃 때 전면 로그아웃이라 조용히 망가지는 것보다 안 뜨는 게 낫다), kid는 회전 미구현이어도 선반영, JWKS는 두지 않음, 검증 시 RFC 8725 §3.1대로 알고리즘 고정. 라이브러리(nimbus)는 트레이드오프가 한 줄뿐이라 별도 BD 없이 흡수했다 (Jira 작업) BD-31 · BD-21
2026-07-28 OAuthLoginSuccessHandler의 TODO(Jira 작업) 한 줄을 채워 토큰 파이프라인 전체를 붙였다 — RS256 발급(JwtTokenProvider)·키 공급(JwtKeyProvider)·쿠키 3종(AuthCookies)·Access 검증 필터·principal 계약(@LoginMember)·Redis Refresh 회전(RefreshTokenStore)·/v1/auth/refresh·/logout. 구현하다 세 가지가 드러났다. ① CsrfConfigurer.spa()만으로는 클라이언트가 CSRF 토큰을 얻을 수 없다 — 토큰이 지연 로딩이라 조회 요청에서 XSRF-TOKEN이 안 나가고, 그러면 첫 상태 변경 요청이 영영 403이다. 기존 테스트가 음성 경로(토큰 없으면 403)만 보고 있어 안 드러나 있었고 /auth/refresh 정상 경로를 처음 테스트하며 잡혔다 → CsrfCookieFilter 추가. ② Filter 빈은 서블릿 체인에도 자동 등록된다 — @Component로 둔 검증 필터가 Security 체인 안팎에 두 번 걸렸다. 테스트는 통과했지만 체인 밖 인스턴스가 SecurityContextHolderFilter보다 먼저 도는 순서 의존 버그라 빈에서 뺐다. ③ 만료에 60초 시계 오차 관용이 있다(nimbus 기본값) — -1초 만료 토큰이 통과해서 알았고, 없애려면 명시적으로 줄여야 한다는 뜻이라 관용의 존재 자체를 테스트로 고정했다. 로그인이 Redis에 의존하게 되어(GoogleLoginCallbackTests가 깨져 드러남) PostgresRedisContainerSupport를 만들었다. 가장 값 있는 테스트는 공개키를 HMAC 비밀로 삼은 HS256 위조 토큰을 거부하는 alg confusion 회귀다(RFC 8725 §3.1). clean check 131개 통과. 남은 것은 키 회전·404 권한 테스트(도메인 리소스 부재)·공용 API 명세 개정·인프라에 JWT_PRIVATE_KEY 요청 (Jira 작업) BI-18 · BD-31 · 인증 계약
2026-07-28 (리팩터) 세션 JWT 구현을 동작 변경 없이 정리했다. 순증 −142줄. 가장 큰 것은 테스트 중복 — AuthTokenContractTests와 GoogleLoginCallbackTests가 스텁 공급자 설정과 로그인→인가→콜백 리다이렉트 추적을 각자 복제하고 있어 콜백 경로가 바뀌면 두 곳을 고쳐야 했다. SocialLoginTestSupport로 뽑았고 앞으로 인증 흐름 테스트는 이걸 상속한다(단 SocialLoginRedirectTests는 제외 — 스텁이 아니라 실제 Google 설정으로 인가 URL을 검증하는 것이 목적이라 base를 물리면 목적이 사라진다). 나머지는 WebUtils.getCookie()로 수동 쿠키 순회 대체, nimbus keyIDFromThumbprint()로 RSAKey 이중 빌드 제거, sub 이중 파싱 제거, 테스트의 JSON 정규식을 SignedJWT.parse()로, Refresh 쿠키 부재 판단을 컨트롤러에서 서비스로 이동(토큰의 의미는 서비스가 소유한다). clean check 131개 통과 유지 (Jira 작업) BI-18
2026-07-28 (리팩터) JSpecify 마킹을 BD-29의 기준 그대로 global/config와 domain/auth/{controller,dto,service,exception}까지 넓혔다. 출발점은 마킹하지 않으면 @Nullable이 아무 의미도 없다는 것 — JwtProperties.privateKey에 표기를 붙여 뒀지만 global/config가 미마킹이라 도구가 무시하고 있었고, JwtKeyProviderTest의 properties(null) 경고로 드러났다. 마킹이 실제 구멍 셋을 드러냈다. ① JwtKeyProvider 생성자가 hasPrivateKey()로 검사하고 fromPem(properties.privateKey())에 nullable을 non-null 자리로 넘기고 있었다(술어 메서드는 검사와 사용의 연결을 컴파일러에 못 알려 준다 → 지역 변수 + 흐름 검사) ② OAuthUserInfo.email이 javadoc엔 "null이다"인데 타입은 non-null인 거짓 보증 ③ OAuthUserInfo.providerUserId가 nullable을 non-null 컴포넌트에 받아, sub가 없으면 조용히 통과해 provider_user_id NOT NULL 위반으로 DB까지 내려가서야 터졌다 → requiredStringValue로 진입점에서 끊는다(동작 변경). 부수적으로 ApiResponseOpenApiCustomizer의 두 파라미터도 표기했다 — 이미 null 방어 분기가 있는데 표기가 없던 자리다. global/web·global/response·global/common은 BD-29이 제외한 범위라 그대로 뒀고, BD-29의 재검토 트리거에 근접했으나 전체 도입은 다른 파트 파일 감사가 필요해 이 PR 밖으로 판단했다. clean check 131개 통과 유지 (Jira 작업) BD-29 · BI-18
2026-07-28 (리팩터) global/security에 14개 클래스가 쌓여 응집이 무너져 책임별 하위 패키지 넷으로 나눴다 — oauth(공급자와의 OAuth2 흐름, 클라이언트 역할) · token(세션 토큰 서명·검증·쿠키 전달, BFF 역할) · authentication(요청→인증 주체 변환과 principal, 리소스 서버 역할) · error(필터 체인이 직접 만드는 401·403). 경계의 근거는 역할이고, 이 티켓 내내 따져 온 "OAuth Client 역할의 끝과 BFF 역할의 시작"을 패키지로 굳힌 것이다. 발급(token)과 검증(authentication)을 더 가른 기준은 수명과 호출 빈도 — 발급은 로그인에 한 번, 검증은 모든 요청에서 돈다. 나누기 전에 의존이 단방향(oauth→token, authentication→token, error 독립)임을 확인했다. CsrfCookieFilter는 CSRF가 세션 토큰과 다른 관심사라 어디에도 안 넣고 루트에 남겼다 — 억지로 끼우면 패키지 이름이 거짓말이 된다. 하위 패키지마다 package-info에 @NullMarked를 다시 선언했다: 패키지 애노테이션은 상속되지 않아 빠뜨리면 이동만으로 마킹이 조용히 사라지고 컴파일은 통과한다. package-structure.md의 "인증·보안 경계" 절이 아직 "지금 만들지 않습니다" 상태여서 실제 구조와 하위 패키지 추가 시 주의사항으로 갱신했다. clean check 131개 통과 유지 (Jira 작업) BI-18 · 패키지 구조
2026-07-28 (리팩터) 보안 설정 3종을 global/config에서 global/security 안으로 옮겼다 — 설정과 그 설정이 조립하는 구현이 떨어져 있으면 한쪽만 고치게 된다. SecurityConfig는 하위 패키지 넷을 조립하는 유일한 지점이라 security 루트, JwtProperties는 소비자(JwtKeyProvider·JwtTokenProvider·AuthCookies)와 같은 security/token, MVC 리졸버 등록은 security/authentication으로 옮기면서 이름을 WebMvcConfig → LoginMemberArgumentResolverConfig로 바꿨다(내용이 @LoginMember 리졸버 등록 하나뿐인데 이름이 일반 MVC 설정처럼 보여 오해를 부른다. WebMvcConfigurer는 여러 개가 공존하므로 일반 MVC 설정이 필요해지면 global/config에 따로 만들면 되고, 하나의 거대한 configurer로 모으지 않는다). global/config에는 도메인·보안과 무관한 셋(ApiResponseOpenApiCustomizer·JpaAuditingConfig·OpenApiConfig)만 남았고, 두 패키지의 package-info와 package-structure.md를 실제 구조에 맞게 고쳤다. clean check 131개 통과 유지 (Jira 작업) BI-18 · 패키지 구조
2026-07-28 compose.yaml의 redis에 org.springframework.boot.service-connection: redis 라벨을 붙여 postgres와 같은 방식으로 서비스 커넥션에 등록했다. 표준 redis 이미지라 Boot가 이름으로 자동 인식하긴 하지만(postgres는 pgvector/pgvector가 인식 대상이 아니라 라벨이 필수였다), 두 서비스의 등록 방식이 달라 보이면 다음 사람이 "redis는 왜 없지"를 다시 확인해야 한다. 확인하다 테스트 컨테이너 태그가 compose와 어긋난 것을 발견해 함께 맞췄다 — PostgresRedisContainerSupport가 부동 태그 redis:7-alpine을 쓰고 있어 compose의 redis:7.4.5-alpine과 다른 판이 될 수 있었다. postgres 쪽은 "테스트 컨테이너도 같은 태그를 쓴다"가 이미 주석으로 고정돼 있던 규약인데 redis만 빠져 있었다. clean check 131개 통과 (Jira 작업) 설정 규약
2026-07-28 (리팩터) 테스트 컨테이너 기반 클래스 둘(PostgresContainerSupport·PostgresRedisContainerSupport)을 IntegrationContainerSupport 하나로 합쳤다. 담고 있는 것을 이름에 나열하면 의존이 늘 때마다 클래스 이름과 모든 상속 선언이 따라 바뀐다 — Redis 하나 추가하자마자 이미 두 클래스로 갈라져 있었다. Redis를 안 쓰는 테스트에도 항상 띄우기로 한 이유는 "이 테스트에 Redis가 필요한가"를 매번 판단하지 않기 위해서다. 판단을 틀리면 증상이 엉뚱한 곳에서 나온다 — 실제로 토큰 발급을 붙였을 때 GoogleLoginCallbackTests가 그렇게 깨졌다. 대가는 JVM당 컨테이너 하나다. 부수 효과로 management.health.redis.enabled=false 우회를 8곳에서 제거했다. Redis가 없어서 끄고 있던 것이라 이제 필요 없고, 그만큼 /actuator/health 집계가 운영에 가까워졌다. clean check 131개 통과 유지 (Jira 작업) 테스트 규약
2026-07-28 테스트 컨테이너 경고 2건 정리. ① Testcontainers 2.x에서 org.testcontainers.containers.PostgreSQLContainer가 deprecated이고 org.testcontainers.postgresql.PostgreSQLContainer로 옮겨졌다(새 타입은 제네릭 <SELF>가 없어 선언도 짧아진다). 이전 빌드 로그에 계속 뜨던 "uses or overrides a deprecated API"가 이것이었다. ② GenericContainer가 AutoCloseable이라 IDE가 try-with-resources를 권하는데, 여기서 따르면 안 된다 — 컨테이너는 JVM 전체가 공유하는 싱글턴이라 첫 테스트 클래스가 끝날 때 닫히면 나머지가 죽은 포트를 본다(PostgresContainerSupport 주석에 이미 기록된 함정). @SuppressWarnings("resource")에 그 이유를 달았다. 확인 방법으로 -Xlint:all을 한시적으로 켜 전수 확인했고(정리 후 0건), 빌드에는 남기지 않았다 — 다른 파트 코드까지 영향을 주는 정책이라 별도 합의가 필요하다. Redis는 Testcontainers 2.0.5 BOM에 전용 모듈이 없어 GenericContainer + 이미지 이름 기반 @ServiceConnection이 맞다. clean check 131개 통과 (Jira 작업) 테스트 규약
2026-07-28 실제 Google 계정으로 로그인 흐름을 수동 검증하고 버그 1건(BT-04)을 잡았다. logged_in 쿠키를 Path=/api/core로 발급해 프론트 JS가 읽을 수 없는 상태였다 — 이 쿠키의 존재 이유가 "JS가 읽는 UI 힌트"(BD-21)인데 목적을 달성할 수 없었다. 기존 테스트가 HttpOnly가 아닌지만 확인하고 있었고, HttpOnly가 아니라는 것은 읽을 수 있다는 뜻이 아니다 — Path가 안 맞으면 document.cookie에 아예 나타나지 않는다. Path=/로 고치고 회귀 단언을 추가했다(고치기 전 expected "/" but was "/api/core" 실패 확인). 세 쿠키의 Path 기준을 "누가 읽어야 하는가"로 정리해 코드·문서에 남겼다. 함께 확인된 것: OIDC 경로가 실제로 동작한다(테스트는 openid를 빼고 돌아 OidcUserService 경로에 커버리지가 0건이었다), redirect_uri 정확 일치·PKCE S256·nonce 통과, Secure 쿠키가 http://localhost에서 왕복된다(주석에 주장만 해두고 검증한 적 없던 지점). 이 흐름은 동의 화면이 브라우저를 요구해 CI에 넣을 수 없으므로 수동 절차로 문서화했다. clean check 131개 통과 (Jira 작업) BT-04 · BI-18 · 인증 계약
2026-07-28 dev를 병합했다(15커밋). 도메인 API 4개와 인증 스텁이 dev에 들어와 있어, 제가 문서에 경고로 남겨둔 병합 지점이 실제로 발생했다. 텍스트 충돌 7건 외에 의미 충돌 5건이 있었다. ① 마이그레이션 번호 충돌 — dev의 V3__core_domain.sql과 내 V3__social_account.sql이 같은 번호여서 Flyway가 기동을 거부한다. 내 것을 V4로 밀었다 ② BD·BI 번호 충돌 — dev가 BD-28·BI-16까지 써서 내 BD-27/28/29 → BD-29/30/31, BI-08 → BI-18로 재배정(BI-17로 밀었다가 dev가 그 번호를 가져가 한 번 더)(decisions/README의 "번호는 dev 머지 기준" 규칙). Java 주석·문서 링크 참조까지 따라 고쳤고 dev 소유 참조(BD-27 커버리지, BD-28 readiness)는 건드리지 않았다 ③ 스텁 중복 — dev의 global/security/{LoginMember,MemberPrincipal,LoginMemberArgumentResolver}와 WebMvcConfig를 제거하고 도메인 컨트롤러 3개의 임포트를 security.authentication으로 돌렸다. X-Debug-Member-Id·pinlog.auth.stub.enabled도 제거 — 남기면 운영 인증 우회 구멍이다 ④ YAML 중복 키 — 병합으로 pinlog: 루트 키가 두 개 생겨 SnakeYAML이 거부할 상태였다 ⑤ 테스트 기반 클래스 이름 — 내가 IntegrationContainerSupport로 합친 것을 dev의 새 테스트 13개가 구 이름으로 참조했다. AuthTestSupport.loginAs 본문만 spring-security-test(authentication() + csrf())로 바꿨더니 dev 도메인 테스트 110여 개 호출부가 한 줄도 안 바뀌고 통과했다 — 헬퍼를 한 곳에 모아 둔 선행 판단이 값을 한 지점이다. 그 과정에 dev README의 누락 행(BD-28, BI-08~13)도 채웠다. clean check 223개 통과, 실패 0. 병합으로 완료 조건 "404(타인 자원 접근)"가 채워졌다 — PublicCollectionApiTests가 실제 인증 위에서 고정한다 (Jira 작업) 인증 계약 · BI-18
2026-07-28 PATCH /follows/{followId}에 alias 키를 생략하면({}) 별칭이 지워지는 것을 계약으로 고정했다. 요청 DTO가 컴포넌트 하나짜리 record라 Jackson이 "키 없음"과 "명시적 null"을 똑같이 null로 역직렬화하고, 서비스가 그 null을 명세 8.3의 "제거"로 해석한다. 명세는 명시적 null만 제거로 정의하고 키 생략은 정의하지 않아서, 정의되지 않은 입력이 사용자 데이터를 지우는 상태였다. JsonNullable로 구분하는 안 대신 현행 유지 + 명시를 골랐다 — Follow는 수정 가능한 필드가 alias 하나뿐이라 부분 수정 요청이 나올 이유가 없고, 장치를 먼저 깔면 모든 요청 DTO가 그 비용을 진다. 재검토 신호(필드 여러 개인 PATCH 등장)를 규약에 함께 적었다. 고정 테스트는 처음부터 통과하므로 통과만으로는 증거가 안 된다 — 기각한 대안("null이면 미변경")을 임시로 넣어 테스트가 잡는 것을 확인하고 원복했다(42번의 뮤테이션 검사와 같은 방식). 운영 코드 변경은 없다 (Jira 작업) BI-17 · docs#20
2026-07-29 코드 리뷰 반영. 🔴 두 건이 실제 버그였다. ① XSRF-TOKEN 쿠키가 Path=/api/core로 나갔다 — BT-04(logged_in)와 같은 종류인데 CSRF 쿠키에는 적용하지 않았다. CsrfConfigurer.spa()의 기본 저장소는 cookiePath가 비면 context path를 쓴다. 프론트는 루트 아래에서 돌아 document.cookie로 읽을 수 없고, 그러면 상태 변경 요청이 전부 403이 된다. 실행 중인 앱에 curl로 Path=/api/core를 실증한 뒤 저장소를 직접 주입해 Path=/로 고쳤다(spa()의 요청 핸들러는 CsrfConfigurer 내부 클래스라 직접 지정할 수 없어 spa() 뒤에 저장소만 덮어쓴다). 테스트가 못 잡은 이유도 같이 고쳤다 — postWithCsrf가 Set-Cookie 원문에서 값을 꺼내 브라우저의 Path 제한을 우회하고 있었다. ② 성공 핸들러에 오류 경로가 없었다 — 필터 체인 안이라 @RestControllerAdvice를 안 타는데 provider 미지원·sub 누락·토큰 발급 실패가 그대로 새어 나갔다. 본문을 감싸 실패 핸들러(OAUTH_FAILED 복귀)로 보낸다. 신규 가입 동시성(부분 유니크 위반)은 성공 핸들러가 트랜잭션 밖이라 재호출이 새 트랜잭션을 여는 점을 이용해 한 번 재시도로 흡수한다 — BI-14가 ON CONFLICT로 간 이유(같은 트랜잭션은 rollback-only)가 여기엔 해당하지 않는다. 테스트 트리거는 email VARCHAR(255) 상한을 넘기는 값으로 잡았다(빈 sub는 Spring 내부에서 먼저 죽어 우리 경로에 도달하지 않는다 — 이 한계도 BI-18에 적었다). 정리·문서화: 죽은 pinlog.auth.stub.enabled 6곳과 무의미해진 management.health.redis.enabled=false 12곳 제거(ReadinessProbe* 둘은 의도적이라 유지), 역직렬화 필터에 자원 한도(maxdepth·maxbytes 등) 추가로 SerialDOS 차단, 인가 요청 쿠키 수명 180초 → 600초(2단계 인증 포함 시 3분이 빠듯), BD-32로 "Refresh 재사용 감지 시 계열 폐기를 하지 않는다"를 명시하고 감지 WARN 추가, BI-18에 Redis 경성 의존·Java 직렬화 결합·규격 밖 응답 한계 기록. clean check 226개 통과 (Jira 작업) BD-32 · BT-04 · BI-18
2026-07-29 2차 리뷰 반영 두 건. ① SecurityContractTests가 존재하지 않는 경로(/v1/me/summary)를 때리고 있었다 — 실제로 검증한 것은 "매핑 없는 URL도 401"이라 DeploymentContractTests와 같은 내용이었다. 매핑된 /v1/collections로 바꿨다. 다만 리뷰의 근거는 절반만 맞았다 — "실제 엔드포인트가 permitAll에 들어가도 통과한다"를 실험으로 확인해 보니, 바꾼 뒤에도 통과한다. LoginMemberArgumentResolver가 fail-closed라 인증이 없으면 거기서 401을 던지기 때문이다. 즉 permitAll 실수는 리졸버가 막아 주고(의도한 방어 두 겹), 이 테스트가 고정하는 것은 "어느 층이 막는가"가 아니라 바깥에서 관측 가능한 계약(실재하는 보호 경로에 미인증 → 401 envelope)이다. 그 사실을 테스트 주석에 적었다 — 처음엔 나도 "permitAll을 잡는다"고 잘못 적었다가 실험으로 반증했다. ② CSRF 쿠키의 Secure가 환경 의존이었다 — CookieCsrfTokenRepository는 secure를 request.isSecure()에 맡겨서, 보안 속성이 프록시의 X-Forwarded-Proto에 걸린다. AuthCookies는 같은 이유로 이미 secure(true)를 고정하고 있었다. 리뷰가 제안한 setSecure(true)는 Spring Security 7.1.0에 없는 메서드라 유일한 경로인 setCookieCustomizer로 처리했다(커스터마이저가 기본값 뒤에 실행되는 것을 소스로 확인). SameSite=Lax도 빠져 있어 함께 넣었다. 고치기 전 Path=/만 있고 Secure가 없는 것을 실패로 확인했다. clean check 226개 통과 (Jira 작업) 인증 계약
2026-07-28 back#58 Feed 선합의에서 나온 published_at 불변식을 DB로 내렸다. 엔티티 @PrePersist가 published_at = created_at을 채우고 있었지만 컬럼은 nullable이라 JPA를 우회한 native INSERT나 결함 있는 쓰기 경로가 NULL을 만들 수 있었고, PostgreSQL의 DESC는 NULL을 먼저 두므로 Feed 최신순 후보 상단이 통째로 오염된다 — 앱이 아니라 DB에 무결성을 두는 BD-10의 적용 사례다. V5에서 기존 NULL을 created_at으로 백필한 뒤 CHECK (NOT is_published OR published_at IS NOT NULL)을 걸었다. 초안은 NOT NULL DEFAULT now()였는데 리뷰에서 그 형태가 "is_published는 false일 수 있다"는 BD-23의 전제와 모순되고 결함 있는 쓰기 경로의 실패를 그럴듯한 기본값으로 덮는다는 지적을 받아 함의형 CHECK로 바꿨다. Feed 후보 쿼리 세 개가 전부 is_published = true를 걸므로 오늘 얻는 보호는 동일하고, 비공개 전환이나 발행 취소가 들어와도 제약을 풀 필요가 없다. RollingUpdate 중 구 Pod도 @PrePersist로 값을 채우므로 배포 호환된다. 검증은 통과만으로는 증거가 안 되므로 target("3")으로 V3까지만 적용한 별도 DB에 NULL 행을 심고 마이그레이션한 뒤 백필·제약 정의·컬럼 nullable 유지를 확인하고, 미발행+NULL은 통과하고 발행+NULL은 거부되는 두 케이스로 제약의 의도를 고정했다. Feed 런타임 구현(Jira 작업)은 이 PR 범위가 아니다 (Jira 작업) BD-33 · BI-19 · back#58
2026-07-29 back#73(Google 소셜 로그인)이 먼저 병합돼 dev를 기준으로 rebase했다. 같은 번호를 세 종류나 선점당했다 — V4(social_account)·BD-29·BI-18. 마이그레이션은 두 파일 이름이 달라 git이 충돌로 잡지 않는다 — 그대로 병합되면 Flyway가 중복 버전으로 기동을 거부하므로 텍스트 충돌이 없는 것이 더 위험한 경우다. 착수 시 dev와 열린 브랜치 전부를 조회해 미사용 번호를 골랐고(dev 최댓값+1로 단정하지 않는다), V5·BD-33·BI-19로 재배정했다. 부수로 테스트 하나가 의미를 잃을 뻔했다 — PublishedAtMigrationTests는 target("3")으로 심은 NULL 행이 이 마이그레이션으로 백필되는지를 보는데, 사이에 V4가 끼면서 "최신까지 적용했다"만으로는 V5가 실제로 돌았는지 알 수 없게 됐다. V4는 core.collection을 건드리지 않아 검증 자체는 유효하지만, 전제가 주석으로만 남는 것이 문제라 적용 버전 단언 둘(심는 시점이 정확히 {1,2,3} · 두 번째 마이그레이션에 V5 포함)을 추가했다. 제약 형태(CHECK 함의형)와 두 케이스는 그대로다 (Jira 작업) BI-19 · back#75
2026-07-29 Feed 추천 MVP를 Spring에 붙였다 — 후보 3채널(최신·팔로우·무작위), 결정적 점수, GET /v1/feed/collections, opaque cursor, POST /v1/feed/events. 정책은 AI 파트 소유라 수치를 하나도 바꾸지 않고 전부 @ConfigurationProperties로 뺐다(상수로 박으면 재배포 없이 튜닝할 수 없고, "가중치를 바꿔도 순위가 안 바뀌는" 회귀를 테스트가 못 잡는다). 판단이 갈린 지점은 페이지네이션이었다 — 명세는 Redis Session Cache로 후보 풀을 얼리는데, 같은 명세가 "Redis 장애 시 정상 응답"과 "Session 만료 시 첫 페이지부터"를 함께 요구하므로 Cache 없이 도는 경로를 어차피 만들어야 한다. 그래서 requestId를 무작위 채널의 seed로 삼아 페이지마다 결정적으로 재계산하는 쪽 하나만 두고, Cache는 나중에 그 앞에 얹기로 했다(BD-34). 결정성은 세 곳에서 만든다 — seed 고정 표본(ORDER BY random() 전체 스캔 회피와 같은 방향), 동점을 publishedAt·id로 끝까지 가르는 비교자, 난수 없는 탐색 슬롯 배치. DDL은 한 줄도 추가하지 않았다 — core.feed_event(V102)도 ix_collection_feed(V3)도 이미 있어서 조회·삽입만 한다. 공개 경계는 BD-13 방식 그대로 필드 자리를 없애서 강제했고(응답 DTO에 소유자 식별자를 담을 자리가 없다), Keyword 가시성 필터는 자바가 아니라 WHERE 절에 뒀다. ai.keyword_preset.embedding을 한 번도 읽지 않는 것이 "요청 경로에서 임베딩을 쓰지 않는다"는 경계의 구조적 증거다. 다양성 조정은 page-size 블록 단위로 적용했다 — 전체 목록에 한 번만 걸면 두 번째 페이지부터 규칙이 사라진다. 후보 0건만 대역으로 검증했는데, 컨테이너를 공유해 다른 테스트가 만든 Collection이 항상 후보에 들어오기 때문이다(같은 이유로 통합 단언은 내가 만든 id로 걸러낸 부분에만 건다). clean check 292개 통과, 라인 96.6%·브랜치 82.2%. 남은 것은 Redis Cache 3종·IMPRESSION 비동기화·N1~N7 쿼리 카운터 단언 (Jira 작업) BI-20 · BD-34 · P42
2026-07-29 삭제 경로에서 ai 스키마 파생 데이터를 무효화한다(context_ai_state 두 status → CANCELLED, context_embedding.is_deleted = true). RecordDeletionService javadoc이 "ai 스키마 연동이 아직 없어서" 빼 놨다고 적어 뒀지만, 공용 계약(06 §1.3 쓰기 매트릭스)은 이 두 컬럼만은 백엔드가 쓴다고 못 박는다(back#61). 증상이 지금 없는 이유는 자연어 검색이 아직 없기 때문이고, 검색 제외가 is_deleted = false 필터에 단독으로 의존하므로 플래그가 꺼진 채 검색이 붙으면 삭제한 Context가 결과에 계속 나온다. 두 UPDATE를 기존 삭제 트랜잭션 안에 뒀다 — 06 §6.6의 근거가 "동일 인스턴스이므로 단일 트랜잭션"이고, 나누면 이 작업의 목적인 "부분 실패 없음"이 깨진다(BD-37). CANCELLED 전이에 조건을 걸지 않은 것도 계약이다: ai.context_keyword에 is_deleted가 없어 키워드 조회 제외를 keyword_status가 단독으로 담당하므로 COMPLETED를 남기면 지운 Context의 Keyword가 계속 노출된다. 백엔드가 ai에 쓰는 경로를 AiDerivedDataRepository 한 파일로 좁혀 쓰기 매트릭스 위반을 리뷰에서 한 파일만 보고 판정할 수 있게 했다. 적용 지점 넷 중 셋(6.4·6.5·6.6)에 붙였고 회원 탈퇴(6.9)는 경로 자체가 없어 못 붙였다 — 컨트롤러 전수 조사에 /v1/members가 없고 SocialAccount javadoc도 "탈퇴 티켓에서 추가"라고 적어 둔 상태다. 리뷰에서 테스트가 주장하는 것과 증명하는 것의 간격이 드러났다. 초안은 rejectedDeleteLeavesDerivedDataUntouched를 "무효화가 트랜잭션 안에 있다는 증거"라고 네 곳에 적었는데, 409가 무효화 호출보다 앞에서 나므로 그 테스트가 고정하는 것은 "거절 시 호출하지 않는다"는 제어 흐름 사실뿐이고 무효화를 트랜잭션 밖으로 옮겨도 통과한다 — 결과적으로 BD-37이 (b)·(c)를 기각하며 택한 원자성이 테스트로 하나도 덮이지 않았다. cascadeDelete가 무효화를 Collection 루프 앞에서 부르는 구조를 이용해 collectionRepository.findByIdForUpdate를 @MockitoSpyBean으로 던지게 만들고, verify(invalidate)(제어 흐름이 거기까지 갔다)와 파생 데이터가 그대로임(그 UPDATE가 되돌아갔다)을 짝으로 단언하는 롤백 테스트를 추가했다. REQUIRES_NEW 분리와 호출 위치 이동을 각각 주입해 둘 다 이 테스트가 잡는 것을 확인했고, 반대로 스파이의 callRealMethod로 무효화 안쪽에서 던지는 구성은 트랜잭션 프록시를 우회해 REQUIRES_NEW를 놓치는 것도 실측했다. 패키지 배치는 리뷰 권고대로 domain/record/repository로 옮겼다가 되돌렸다 — 리뷰 근거 하나가 *"package-structure.md가 ai 도메인을 만들지 않는 방향"*이었는데 직후 back#82(-102)가 domain/ai를 정식 도메인으로 만들고 그 문서에 도메인 행까지 등록해 전제가 뒤집혔다. -124만 옮기면 ai 스키마 접근이 ContextAiStateRepository(domain/ai)와 두 패키지로 갈리고 회원 탈퇴가 붙으면 세 번째가 생긴다. 배치 기준을 "소비 도메인"이 아니라 **"닿는 외부 경계"**로 두고 domain/ai/repository에 유지한다. package-structure.md는 back#82가 이미 갱신했으므로 건드리지 않았다. 부수로 BD-35·BI-21을 back#81에 선점당해 BD-37·BI-23으로 재배정했다 — 파일명이 달라 git이 충돌로 잡지 않고 WORKLOG.md는 merge=union이라 두 행이 나란히 들어가는, 이 파일이 이미 한 번 기록한 실패 형태 그대로다. BD-36·BI-22도 back#82가 쓰고 있어 check-number.sh가 준 다음 번호를 그대로 썼다. RED 6/8 실패 → GREEN 9개 통과, clean check 전량 통과 (Jira 작업) BI-23 · BD-37 · back#80
2026-07-29 에이전트 하네스(CLAUDE.md)를 정리했다. 출발점은 읽기와 쓰기가 어긋나 있던 것 — 문서화 규칙은 docs/backend/에 쓰라고 하는데 읽으라는 지시는 어디에도 없었고, development/에서 그쪽으로 가는 링크도 없어서 spec·WORKLOG를 못 본 채 작업하는 구조였다. 그래서 2번을 규약 읽기와 파트 문서 읽기로 갈랐다. 뺀 것 둘: H2 금지는 database-conventions.md가, 빈 패키지·투기적 계층 금지는 CONTRIBUTING.md·package-structure.md가 이미 authoritative라 항상 로드되는 층에 중복으로 둘 이유가 없다. Security 보류는 Jira 작업 병합으로 조건이 충족돼 만료됐다(BD-24의 재검토 트리거가 예고한 그대로). 넣은 것 하나: Flyway 구간(V2~V99) — CONTRIBUTING.md의 강제 지점 표에서 유일하게 "의도적으로 자동화하지 않음"인 규칙이라 CI 그물이 없고, 구간을 벗어난 파일도 유효한 SQL이라 조용히 통과한다(BT-02로 이미 한 번 물렸다). 충돌 기록 규칙은 "PR에 남긴다"에서 "문서의 충돌 지점에 표시하고 PR에도 남긴다"로 고쳤다 — 실제 관행(package-structure.md)이 이미 그랬고, PR 본문은 머지되면 아무도 다시 읽지 않는다. 규칙을 지우고 번호를 당기는 과정에서 다른 문서 세 곳이 조용히 틀린 규칙을 가리키게 됐다가 최종 번호가 원래대로 돌아와 저절로 맞았다. 번호로 서로를 참조하는 구조가 깨지기 쉽다는 것이 드러났고, 이름 참조로 바꾸는 것은 후속으로 남긴다. BD-24
2026-07-29 Context 생성·교체를 AI 처리 접수에 연결했다 — ai.context_ai_state PENDING INSERT와 POST /internal/v1/context/process. 까다로운 건 순서가 아니라 커밋 가시성이었다 — INSERT를 호출보다 먼저 두는 것만으로는 부족하고, 워커가 별 프로세스라 커밋 전에 호출하면 202를 받고도 상태 행을 못 찾는다. 서비스는 INSERT와 이벤트 발행만 하고 호출은 @TransactionalEventListener(AFTER_COMMIT) 한 곳에만 둬서, "커밋 뒤에 부른다"를 관습이 아니라 트랜잭션 경계로 강제했다. 검증도 같은 문제를 그대로 겪는다 — 테스트가 끝난 뒤 조회하면 "언젠가 커밋됐다"만 증명되므로, FastAPI 대역의 요청 핸들러 안에서 별 커넥션으로 조회해 도착 시점의 가시성을 단언했고 그래서 이 클래스는 롤백 테스트가 아니다. 호출 실패는 전부 삼킨다(BD-35의 삭제 방향과 반대인데, 저쪽은 DB 쓰기라 실패가 곧 정합성 붕괴지만 이쪽은 PENDING이 남아 재스캔이 줍는다). 티켓은 Context INSERT 지점을 4곳이라 했으나 실제 3곳이었다 (Jira 작업) BI-22
2026-07-29 BD-32가 "이 PR 병합 즉시"로 걸어 둔 트리거대로 Refresh 재사용 시 회원 단위 폐기를 구현했다(BD-32 → BD-35로 대체). 미루기로 했던 구멍은 이것이다 — 유출 시 공격자가 먼저 회전하면 정상 사용자만 401로 끊기고 공격자가 방금 받은 토큰은 최대 7일 살아남는다(RFC 9700 §4.14.2). RED는 정확히 그 지점에서 났다: 한 회원의 두 세션 중 하나를 회전 후 재사용했을 때 다른 기기가 expected 401 but was 204. 구조는 회원별 jti 인덱스(auth:refresh-index:<memberId> Set) — SCAN은 BD-32가 이미 키 공간 비례로 기각했다. 구현하며 셋이 드러났다. ① 기존 회전 테스트가 재사용 401을 확인한 뒤에 "회전 후 토큰은 계속 유효하다"를 단언해 정반대 사실을 고정하고 있었다. 지우지 않고 순서를 뒤집었다 — 두 계약을 한 단언에 섞으면 나중에 어느 쪽이 깨졌는지 알 수 없다 ② 폐기 대상에 회전으로 갓 발급된 토큰이 들어가야 한다. 유출 시나리오에서 그것이 공격자가 들고 있을 토큰이라, 빼면 폐기가 무의미하다 ③ SADD만 하면 Set에 TTL이 없어 영구 키가 된다. 발급마다 EXPIRE를 다시 걸었다. 과잉 폐기를 잡는 반대 방향 회귀(normalRotationKeepsOtherSessionsAlive)도 함께 넣었다 — 계열 폐기가 감지 밖으로 새면 세션 독립성(BD-21)이 조용히 깨진다. 감수하는 것은 오탐이다: 클라이언트가 같은 토큰을 두 번 보내면 전체 로그아웃이 된다. 서버는 재사용과 유출을 구별할 수 없고 미탐의 대가가 훨씬 크므로 안전한 쪽을 골랐다(방어선은 08 §3.3의 "재발급은 동시에 하나만"). 회원 탈퇴(Jira 작업)가 같은 revokeAll을 쓴다. clean check 295개 통과 (Jira 작업) BD-35 · BD-32 · BI-21
2026-07-29 코드 리뷰 반영(#81). 🔴 두 건이 실제 결함이었고, 둘 다 이 PR이 막으려는 시나리오에서 정확히 열렸다. ① revokeAll이 원자적이지 않았다 — SMEMBERS로 목록을 읽고 지우고 인덱스를 지우는 세 왕복 사이에 정상 회전 한 건이 끼면, 새 jti는 읽은 목록에 없어 삭제를 피하고 뒤따르는 인덱스 삭제가 그것을 인덱스에서도 지운다. 결과는 "유효하지만 이후 어떤 폐기로도 잡히지 않는 토큰". 재사용 감지는 회전 요청이 트리거이므로 유출 시나리오에서 이 창은 구조적으로 열린다. 내가 놓친 것은 판단의 종류였다 — BD-35에 "멱등하므로 동시 요청도 문제없다"고 적었는데, 멱등성은 같은 연산을 두 번 해도 같다는 성질이고 필요한 것은 다른 연산이 중간에 끼지 못한다는 성질이었다. 멱등성을 확인하고 원자성을 확인했다고 착각했다 ② save의 세 왕복도 같은 종류 — 토큰만 저장되고 SADD가 실패하면 인덱스에 없는 유효 토큰(7일간 폐기 불가), EXPIRE가 실패하면 직전 커밋이 고쳤다고 적은 바로 그 영구 키가 생긴다. 명령을 추가해 문제를 고쳤지만 그 명령이 실행되지 않을 경우를 보지 않았다. 둘 다 Lua 스크립트 하나로 묶어 닫았다(대가: 폐기 스크립트가 키를 ARGV로 조립해 Redis Cluster 규약과 어긋난다 — BD-35 트리거에 기록). ③ EXPIRE 한 줄을 지켜 주는 단언이 없었다 — 지우면 증상이 "회원 수만큼 영구 키 누적"뿐이고 HTTP 계약 테스트는 전부 통과한다. RefreshTokenStoreTest로 Redis에 직접 만료를 묻고, EXPIRE를 임시로 빼서 -1로 실패하는 것을 확인한 뒤 원복했다(뮤테이션 검사). ④ BD-35의 감수 사항 둘을 보강 — revokeAll은 Access를 끊지 못해 "즉시 끊긴다"가 최대 30분 어긋난다(stateless RS256, 30분 수명), 그리고 만료 전 구 Refresh가 대상 회원을 반복해서 전 기기 로그아웃시키는 수단이 된다(오탐의 악의적 쌍. 비대칭 논거는 유효해 결정은 유지). ⑤ BI-21·WORKLOG의 테스트 수를 229 → 295로 정정했다(dev 병합 전 수치를 그대로 두고 있었다). 번호 충돌(#80이 같은 BD-35·BI-21 사용)은 현행 유지하고 머지 순서에 따라 처리한다. clean check 300개 통과 (Jira 작업) BD-35 · BI-21
2026-07-29 back#82 리뷰 반영. 실패가 전부 무음이던 것이 가장 큰 지적이었다 — 시크릿이 비면 FastAPI가 401을 주고 클라이언트가 삼키고 상태는 PENDING으로 남고 재스캔은 아직 없어서, 임베딩이 하나도 안 생기는데 신호가 로그뿐이었다. JwtKeyProvider와 같은 기준(운영은 기동 실패, 그 외 WARN)을 붙이고 401·403을 나머지 4xx에서 떼어 설정 문제로 지목하게 했다. 테스트가 로컬 AI 서버를 실제로 부르던 것도 막았다(base-url 기본값이 localhost:8000이고 통합 테스트 대부분이 실제 커밋해 AFTER_COMMIT이 뜬다 — 로컬에 스택을 띄운 채 돌리면 임베딩 비용이 나간다). 리뷰가 제안한 src/test/resources/application.yml은 main 설정을 통째로 가려 쓸 수 없었고, @DynamicPropertySource는 상위가 하위를 덮어 대역 테스트 5개를 깨뜨려, 우선순위가 한 단계 낮은 @TestPropertySource로 내려야 했다. 사문화돼 있던 noCallWithin 헬퍼의 테스트 둘(롤백 시 무호출·소프트 삭제 Context 생략)을 채웠고, AFTER_COMMIT이 트랜잭션 없이는 조용히 버려지는 구멍은 Assert.state로 막았다. back#80 병합과 만나며 드러난 통합 결함 둘도 함께 고쳤다 — 생성이 PENDING을 자동 삽입하게 되어 back#80 테스트의 직접 INSERT가 중복키가 났고(UPSERT로 전환), 교체 시 구 Context가 이제 실제로 CANCELLED가 되어 옛 단언이 뒤집혔다 (Jira 작업) BD-36 · BI-22
2026-07-29 번호 재배정이 남긴 오참조 5곳을 정정했다. back#73이 먼저 머지되며 번호를 가져가 BD-27BD-29 → BD-29BD-31, BI-08 → BI-18로 재배정했는데(BI-19 이력에 기록), 파일명과 decisions/README 표만 따라가고 H1 제목 넷은 그대로 남았다 — BI-18의 제목이 아직 BI-08이었다. 더 나쁜 쪽은 BI-18 본문이다: global/web·global/response·global/common을 제외한 근거를 BD-27로 적고 있었는데 지금 BD-27은 커버리지 게이트 문서다. 같은 문단 78행은 링크여서 재배정 때 함께 고쳐졌고, 92행은 맨텍스트 번호라서 조용히 틀린 채 남았다 — 링크였다면 깨진 링크로 드러났을 것이다. 가리키려던 문서가 BD-29임은 인용된 재검토 트리거("마킹 패키지가 늘어 혼재가 부담이 될 때")가 BD-29에만 있는 것으로 확정했고, 정정하면서 링크로 바꿨다. 파일명 번호와 H1을 전수 비교해 찾았으므로 같은 클래스의 남은 오참조는 없다. 앞선 로그가 후속으로 남긴 "번호 참조를 이름 참조로"에 근거가 하나 더 쌓였다 — 사람이 지키는 규칙으로는 계속 새므로 파일명↔H1 일치 검사와 상대 링크 존재 검사를 CI에 거는 것을 제안한다. 커밋 메시지에 박힌 번호(docs(Jira 작업): BD-28 — 인가 요청을…)는 머지된 뒤라 고칠 수 없고 어긋난 채 남는다 (티켓 없음 — 문서 정정) BD-29 · BD-30 · BD-31 · BI-18
2026-07-29 작성자 미탈퇴 판정을 세 공개 조회 경로가 공유하는 한 곳(MemberRepository.isActive)으로 모았다. 추출 자리를 서비스 새 클래스가 아니라 리포지토리 기본 메서드로 잡았다 — 판정의 근거가 도메인 규칙이 아니라 @SQLRestriction("deleted_at IS NULL")이라는 영속성 사실("행이 조회되면 곧 미탈퇴")이고, 세 호출부가 이미 MemberRepository를 주입받고 있어 협력자를 늘릴 이유가 없었다. existsById로 줄이지 않은 것도 의도다 — @SQLRestriction이 count 쿼리에도 붙는지는 별개의 질문이고, 중복 제거인 이 변경의 범위가 아니다(javadoc에 근거를 남겼다). RED를 만든 방식이 평소와 달랐다: 동작이 바뀌지 않는 변경이라 테스트를 새로 써도 곧바로 통과해 아무것도 증명하지 못한다. 그래서 세 검사를 실제로 지우고 세 테스트가 각자의 단정에서 실패하는 것을 먼저 확인한 뒤(PublicCollectionApiTests:152·FollowApiTests:205·:227) 되살렸다 — 추출이 보존해야 하는 것이 무엇인지를 통과가 아니라 실패로 고정한 것이다. 그 과정에서 세 경로 중 팔로우 진입만 테스트가 없었다는 것이 드러났다(검사를 지워도 빨개지지 않는 경로가 하나 있었다는 뜻이라 followingWithdrawnOwnersShelfIs404을 추가했다 — 404와 함께 행이 남지 않는 것까지 본다). 실패 응답은 통합하지 않았다: 상세·팔로우 404와 팔로우한 Shelf 목록의 빈 목록은 규칙이 아니라 표현이라 호출부에 남긴다(BI-11). 발행 여부 판정의 Java·JPQL 갈림(-148)과 Feed native SQL 3곳은 범위 밖이다. clean check 318개 통과 (Jira 작업) BI-11 · BI-13
2026-07-29 POST /v1/search/records를 붙여 시연 5단계 중 ② 자연어 검색의 빈 자리를 채웠다. 먼저 정해야 했던 것은 embeddingProfile을 Spring이 어디서 얻는가였다 — 공용 계약 05 §7.1이 그 결정을 이 티켓 시점으로 명시적으로 유예해 두었고, 그 §7.1이 오늘 개정되면서 전제가 뒤집혔다(정본이 "배포 환경의 단일 설정"에서 "코드"로, 환경변수는 필수에서 덮어쓰기로). 개정된 기준을 Spring 쪽에 적용해 application.yml에 리터럴 + 환경변수 덮어쓰기로 정하고 BD-39로 남겼다. 기각한 것 중 핵심은 기동 시 FastAPI 조회다 — 상대 값을 받아 상대에게 되돌려 주면 대조가 항상 통과해, 불일치를 막으려고 만든 장치가 아무것도 검증하지 않게 된다. 구현에서 지킨 두 가지: (1) 422를 빈 결과로 치환하지 않는다 — 빈 결과는 "일치하는 기록이 없음"과 구분되지 않아 설정 오류를 숨긴다. 다만 상태 코드만으로 불일치를 단정하지도 않는다(FastAPI는 요청 검증 실패에도 422를 쓴다) — 응답 본문의 serverProfile 유무로 갈라 SEARCH_PROFILE_MISMATCH/SEARCH_UNAVAILABLE을 나눴다. 사람이 해야 할 일이 정반대라서다. (2) FastAPI 응답을 믿지 않는다 — ai.context_embedding.user_id는 비정규화 값이라 범위 필터로는 충분해도 인가 근거로는 부족하다. 테스트 대역이 일부러 거짓말을 하도록 만든 것이 그래서다(남의 Context·지운 Context·없는 id를 최상위로 돌려준다). 진짜에 가까운 대역은 이 증명을 못 한다. 곁다리로 BoundsResponse를 global/response로 올렸다(검색이 지도와 같은 규칙을 쓰게 되어 두 도메인 공유가 됐고, 값만 공유하고 min/max를 각자 짜면 "결과 없음이 null인가"가 조용히 갈라진다). docs/ai/spec/ai-integration.md §2.1이 개정 전 §7.1을 그대로 담고 있고 internal-token도 구현과 달랐다 — AI 파트 소유 구역이라 CLAUDE.md 9번대로 표시만 남겼고, 중앙이 그 판정을 받아 수정 권한을 위임해 같은 PR에서 고쳤다. §7의 헤더는 back#83이 이미 고쳤는데 이 설정 키만 남은 이유는 당시 전수 검색이 `X-Internal[-_]?(Token Secret)패턴이라 헤더만 잡고 설정 키를 놓쳤기 때문이다 — 패턴을 넓혀 세 레포를 다시 훑었고docs·ai0건,back은 그 한 줄뿐이었다. clean check` 통과 (Jira 작업)
2026-07-29 발행 여부 판정을 모으지 않기로 정하고(BD-38) 그 대가를 테스트로 갚았다. 147과 같은 성격의 중복인데 결론이 반대인 이유는 셋이다 ① 비공개 전환이 MVP 범위 밖으로 확정됐다 — 06 §8이 미결로 남긴 것이라 누락 위험이 가설이다 ② 공유할 하나의 술어가 없다 — Feed 5곳은 record_count > 0을 포함한 네 조건 덩어리인데 core에는 그 조건이 없다. 빈 Collection은 Feed 후보가 아니지만 상세·팔로우 목록에는 나오므로, 묶으면 중복 제거가 아니라 동작 변경이다 ③ JPQL·native SQL은 Java 술어를 재사용할 수 없다. 티켓 본문의 전제 하나가 틀렸다 — "Java 2곳의 판정을 하나로 모은다"고 했지만 두 곳은 이미 Collection.isPublished()를 부른다. !x와 filter(x)는 판정의 갈림이 아니라 호출 문법이라 뽑을 것이 없었다. back#84의 숫자도 틀렸다(일곱 → 아홉): 탐색 채널이 SQL 상수 2개로 갈려 있고 VERIFY_SQL은 후보 채널이 아니라 조립 단계라 빠졌다. 여기까지면 코드 변경 0인 문서 티켓인데, 확인하다 성격이 바뀌었다 — 판정 네 곳 중 셋은 지워도 테스트가 빨개지지 않았다. followedShelfCollectionsReturnOnlyPublishedActiveOnes가 이름으로 "Published만"을 약속하면서 픽스처는 소프트 삭제만 만들고 있었다(이름이 검증보다 앞서 있었다). 자체 보유를 택하는 층은 스스로 지켜져야 하므로 세 자리에 감시자를 붙였다: 팔로우 진입 404 신규, 첫 페이지에 미발행 픽스처, 커서 페이지는 미발행을 가운데 배치(최신에 두면 첫 페이지 쿼리만 지켜지고 findPublishedPageByMemberIdAfter는 여전히 아무도 안 본다 — 두 쿼리가 별개 메서드다). RED은 147과 같은 뮤테이션 방식이되 이번엔 지워도 초록인 것이 먼저 확인됐다는 점이 달랐다. 판정 3곳 제거 → 3개 실패 → 되살림, 프로덕션 diff 0. 부수로 decisions/README에 BD-36 줄이 누락된 것을 채웠다(back#82가 파일만 넣고 인덱스를 안 고쳤다 — 번호 관리가 또 샜다). clean check 319개 통과(신규 1건 + 기존 2건 확장이라 개수는 하나만 는다) (Jira 작업) BD-38 · back#84
2026-07-29 Kakao·Naver 소셜 로그인을 붙였다(#33). 새로 들인 것은 응답 형태뿐이다 — 토큰 발급·회전·쿠키·회원 확정은 공급자와 무관한 경로라 BI-18이 이미 만들어 뒀고, 이 티켓이 더한 것은 공급자마다 다른 사용자 정보를 하나로 옮기는 일이다. 셋이 다 다르다: Google sub(최상위), Kakao id(최상위, 숫자)+kakao_account.email, Naver response.id+response.email. 드러난 것 넷. ① Naver만 Spring의 식별자 검사를 우회한다 — user-name-attribute: response가 감싼 Map을 지목하므로 Spring은 그 키의 존재만 확인하고 안의 id는 보지 않는다. Google·Kakao는 최상위 스칼라라 앞단에서 걸러 주는데 Naver만 그 보증이 없어, required(nested(attributes,"response","id"))가 유일한 방어선이다(없으면 provider_user_id NOT NULL 위반이 되어 원인이 DB까지 내려간다) ② Kakao는 client secret을 본문으로 받는다 — 기본값 basic이면 토큰 교환이 401이라 client_secret_post를 명시했다. 게다가 콘솔에서 활성화해야 검사되는 선택 항목이라 콘솔 상태와 설정이 어긋나면 콜백 마지막 단계에서 실패한다 ③ 이메일 없는 가입이 기본 경로일 수 있었다 — Kakao는 동의항목을 콘솔에 설정하지 않으면 인가 요청 자체가 KOE205로 거절되고, 이메일 수집은 비즈 앱 전환을 요구한다. 네 층(DB·엔티티·팩토리·정규화)이 모두 nullable이라 통과하며 callbackSucceedsWithoutEmail로 고정했다 — 선택 동의라 동의한 사용자도 철회할 수 있어 비즈 앱 전환 후에도 유효한 경로다 ④ 등록정보를 추가하자 무관한 테스트 11개가 컨텍스트 실패했는데 원인은 이 티켓이 아니라 .env.example이 자격증명을 빈 값으로 정의하던 기존 함정이었다(BT-05). 실제 Kakao·Naver 계정으로 수동 검증했다: Kakao provider_user_id=5013244578(숫자→문자열), Naver는 43자 식별자로 저장돼 ①의 방어선이 실제로 값을 했다(Map toString()이 아니다), 회원 2건 분리, auth:refresh-index 회원별 생성·TTL, 쿠키 4종. 08 §3.1은 이미 세 공급자를 적고 있어 구현이 명세를 따라잡은 것이라 공용 문서 변경은 없다. clean check 326개 통과 (Jira 작업) BI-24 · BT-05
2026-07-29 운영 런타임 Secret 5개를 pinlog-secrets-prod Environment 경계에서만 읽어 SHA 고정 Infra action으로 넘기는 수동 workflow를 추가했다. bridge token을 포함한 참조 6개 집합·최소 권한·github.sha checkout/revision은 정적 계약 테스트로 고정했고 일반 backend-ci에는 Secret 접근을 추가하지 않았다 (Jira 작업) BI-26
2026-07-30 back#98 리뷰 반영. dev가 required_conversation_resolution이라 미해결 스레드가 그대로 병합 게이트여서, 판정이 COMMENTED·내용이 nit인 줄 단위 지적 2건과 리뷰가 지목한 테스트 구멍 3건을 닫았다. 고친 둘은 javadoc이 약속한 범위와 실제 방어 범위가 어긋난 자리라는 점에서 성격이 같다 — ① AiSearchClient 생성자 javadoc이 "시크릿 검사를 여기 두지 않는 이유"로 든 논거는 "같은 키를 읽는 AiProcessClient가 이미 검사한다"인데, embedding-profile은 이 클라이언트만 읽는 새 키라 그 논거가 적용되지 않는다. 빈 문자열이면 FastAPI가 Profile 대조에서 422를 주므로 결과는 모든 검색이 503이고, application.yml 기본값은 변수를 설정하지 않은 경우만 막는다(PINLOG_AI_EMBEDDING_PROFILE=처럼 빈 값으로 정의하면 빈 문자열이 이긴다 — BT-05로 이미 겪은 형태). requireSecret과 같은 기준(운영 기동 실패/그 외 경고)으로 기동 시점에 끊었다. 기각된 (c)(기동 시 FastAPI 조회)가 아니다 — 상대에게 묻지 않고 우리 값 유무만 본다. ② distinctByRecord는 "상대 결함이 우리 500이 되지 않게 한다"고 적어 두고 match 자체가 null인 경우가 빠져 있었다. AiSearchClient가 최상위 results == null을 이미 방어하는데 그것도 계약상 올 수 없는 형태라 층이 어긋난 것이라, 원소 쪽 층을 맞췄다. 테스트 구멍 셋(SEARCH_QUERY_MAX 501자 미검증 · PRIVATE_ONLY 한 번도 미투입 · insertPreset의 active가 죽은 파라미터)은 코드는 이미 맞는데 지키는 단언이 없던 자리라 평소의 RED가 안 나온다 — 세 가드를 일부러 부순 뒤(화이트리스트를 IN ('PUBLIC')으로 좁히고 is_active 조건을 지우고 @Size를 떼고) 새 테스트만 실패하고 기존 24개는 전부 통과하는 것을 관측해 RED를 대신했다. 리뷰가 "그렇게 고쳐도 전부 초록"이라고 한 것이 그대로 재현됐다. match == null은 보통의 RED였다(대역 {"results":[null]} → 500 관측 → 가드 → 200). docs/ai/spec/ai-integration.md §2의 낡은 줄 2개(패키지 위치 · "Bean 하나")는 위임 범위 밖이라 고치지 않고 남겼다(CLAUDE.md 9). clean check 370개 통과(checkstyle·jacoco 포함) (Jira 작업) BD-39 · BI-25
2026-07-30 유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer를 붙였다(Jira 작업). 이 저장소에 스케줄링이 처음 들어온다 — @Scheduled가 0건이었다. AI 연동의 실패 경로 네 곳이 모두 *"재스캔이 복구한다"*를 안전망으로 전제하고 있었는데 그 재스캔이 없어, 한 번 실패한 Context가 영구히 PENDING으로 남았다(상태만 보면 정상과 구별되지 않는다). Bean을 셋으로 가른 것은 트랜잭션 프록시 때문이다 — 한 클래스에 두면 자기 메서드 호출이 프록시를 지나지 않아 트랜잭션 없이 돌고, 그러면 FOR UPDATE SKIP LOCKED의 잠금이 조회 직후 풀려 중복 방어가 조용히 사라진다. 명세가 근거로 든 것을 실측으로 뒤집은 지점이 하나 있다: "Finalize를 먼저 두는 이유는 방금 retry_count를 3으로 올린 행이 곧바로 종결되기 때문"이라는데, runOnce의 두 줄을 맞바꿔도 테스트가 통과했다 — 증가가 updated_at을 함께 갱신해 그 행이 만료 상태에서 벗어나 Finalizer 후보 조건에 걸리지 않는다. 창을 실제로 확보하는 것은 순서가 아니라 만료 조건 + updated_at 갱신이고, 순서는 심층 방어로 남겨 InOrder 단위 테스트로 고정했다(그 사실을 BI-28에 적었다). SKIP LOCKED는 주장으로 두지 않고 다른 커넥션이 행을 붙잡은 채 회차를 돌려 실제로 건너뛰는지 봤다 — 없으면 테스트가 매달리므로 별 스레드 + 15초 타임아웃으로 실패로 드러나게 했다. 함정 둘: @Scheduled(fixedDelayString)은 Boot의 완화된 바인딩을 쓰지 않아 5m이면 기동이 실패한다(PT5M로 두고 ConfigurationContractTests가 고정), Spring은 스케줄러를 작업별로 고르지 않아 전용 스케줄러라도 Bean 이름 taskScheduler를 점유해야 해석이 확정된다(BD-40). RED 6건 확인. clean check 386개 통과 BD-40 · BI-28 · 패키지 구조
2026-07-30 소셜 로그인 이메일을 필수로 만들었다(#97). 프론트에 이메일을 표시하는 화면이 있어 값 없는 계정을 둘 수 없다는 결정이고, 공용 계약이 먼저 바뀌어야 했다 — 06 §2.2가 "미동의·미제공 시 null일 수 있다"로 정하고 있어 구현만 바꾸면 계약 위반이다(CLAUDE.md 9번). docs#28을 먼저 병합했고 거기서 두 가지를 함께 정리했다: 1차 보장은 공급자 콘솔의 필수 동의 설정(사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없으므로 이 구현이 막는 것은 일상 흐름이 아니라 방어선), 그리고 마스킹은 치환이며 NULL이 아니다(적지 않으면 탈퇴 구현이 NULL을 넣어 이 제약과 부딪힌다. provider_user_id가 이미 NOT NULL이면서 마스킹 대상이라 전제는 원래 있었다). 구현하며 드러난 것 셋. ① 뒤집을 테스트가 예상보다 많았다 — 사전 조사로 3건을 찾았는데 clean check에서 SocialAccountPersistenceTests가 걸렸다. emailIsOptional이 영속성 층에서 null 저장을 고정하고 있었고, 같은 파일의 조회 테스트도 준비 코드에 null 이메일이 섞여 함께 깨졌다 — useEmail(null)·isNull() 검색으로는 안 걸리는 형태다 ② 두 방어선이 독립임을 뮤테이션이 보여 줬다 — 정규화의 required(...)만 되돌리면 단위 테스트 4건은 실패하지만 콜백 테스트 3건은 통과한다. DB NOT NULL이 대신 잡아 같은 OAUTH_FAILED로 귀결하기 때문이다. 결함이 아니라 층이 갈린 결과다 — 콜백 테스트는 관측 가능한 계약을, 단위 테스트는 어느 층이 막는가를 고정한다. 반대 방향(ALTER 주석 처리)은 FlywayMigrationTests가 잡는다 ③ 백필을 넣지 않았다 — 운영 DB에 NULL 행이 없고, 있었다면 채울 값이 없다(이메일은 공급자가 주는 값이다). 임의 값을 넣으면 "표시할 이메일"이라는 목적이 깨지므로 그런 환경에서는 마이그레이션이 실패하는 편이 맞다고 보고 SQL 주석에 남겼다. 프론트 질문(실패 사유를 별도 error 값으로 가르는지)에는 기존 결정대로 OAUTH_FAILED로 묶인다고 답하고 08 §3.2에 명시했다 — 사용자가 우리 화면에서 고칠 수 있는 실패가 아니고, 필수 동의 설정에서는 도달하지 않으므로 값을 가르면 발생하지 않는 분기가 남는다. clean check 342개 통과 (Jira 작업) BI-27 · docs#28
2026-07-30 회원 탈퇴를 구현했다(Jira 작업, #34). DELETE /v1/me 하나로 소프트 삭제·마스킹·연쇄 삭제·AI 파생 무효화·세션 폐기를 한 트랜잭션에 담는다. 이 공백이 완료된 작업 둘을 미충족으로 붙잡고 있었다 — Jira 작업가 요구한 AI 무효화 네 지점 중 탈퇴만 비어 있었고(#80이 붙일 서비스가 없어 제외), Jira 작업이 모아 둔 MemberRepository.isActive는 member.deleted_at을 세팅하는 주체가 없어 한 번도 발동하지 않았다. 가장 큰 판단은 Access 창이다 — 쿠키 만료는 요청을 보낸 기기에만 도달하고 Refresh 폐기는 재발급만 막으므로, 다른 기기에 남은 Access로 최대 30분간 쓰기까지 된다. 그 행들은 연쇄 삭제가 지나간 뒤에 만들어져 어떤 정리 경로에도 걸리지 않으므로, 인증 필터가 isActive를 보게 하고 요청당 PK 조회 1회를 대가로 냈다(BD-41). Redis 마커는 순단을 인증 실패로 번지게 해서 기각했다(BD-28과 같은 방향). 드러난 것 셋. ① CSRF가 인가보다 먼저 돌아 토큰 없는 DELETE는 인증 여부와 무관하게 403이다 — 403은 CSRF 전용이라는 계약에 맞춰 "미인증 → 401"과 "CSRF 누락 → 403"으로 갈랐다 ② loginAs로는 인증 필터를 검증할 수 없다. SecurityContext에 직접 주입해 필터가 통째로 건너뛰어진다. 처음 실패의 원인이 구현이 아니라 테스트였고, 그 한 건만 실제 토큰을 쿠키에 실어 탈퇴 전 200 → 후 401로 원인을 특정했다. 앞쪽 단언이 없으면 안 된다 — 쿠키 이름을 틀리게 바꾸면 앞쪽만 실패하고 뒤쪽은 통과하는 vacuous pass가 된다 ③ 행 잠금을 쓰지 않았다. cascadeDelete가 잠그는 이유는 개수 분기와 record_count 산술의 경합인데 탈퇴는 전부 지우므로 둘 다 없고, Collection이 소유자 자기 Record만 담아 타 회원과 경합하지 않는다. 뮤테이션 3종으로 방어선의 독립을 확인했고, 이메일 치환은 DB NOT NULL이 잡지 못해 테스트가 유일한 방어선이다. clean check 407개 통과 (Jira 작업) BI-29 · BD-41
2026-07-31 configuration.md의 인프라 요청 표를 현행 계약에 맞췄다. back#122가 곁다리로 지목한 낡음 세 건이다 ① "인프라 계약에 아직 반영되지 않았으므로 배포 전에 요청해야 합니다" 가 사실이 아니다 — infra/policy/sealedsecrets/back-prod.yaml이 허용 키 8개를 규정하고 back-owner-secrets로 봉인돼 envFrom으로 주입된다(Jira 작업) ② PINLOG_AI_INTERNAL_SECRET이 표에 없었다. 운영 필수인데(없으면 기동 실패) 이 문서에 PINLOG_AI 문자열 자체가 0건이었다 ③ PINLOG_AI_EMBEDDING_PROFILE은 요청 대상이 아니다 — application.yml:130에 리터럴 기본값이 있어 환경변수는 덮어쓰기 수단이다(BD-39). 표에 행 하나를 더하니 아래 문단이 거짓이 됐다 — "셋 중 이것만 기동을 막습니다" 가 이제 둘이라, 함께 고치고 두 값이 같은 이유(없어도 뜨게 두면 조용히 망가진다)로 묶인다는 것을 적었다. 체크리스트의 "비밀값은 DB_PASSWORD만 환경변수" 도 같은 계열의 낡음이라 고쳤다. §5의 "infra가 정한 주입 계약은 DB_PASSWORD 하나"는 건드리지 않았다 — infra/docs/backend-conventions.md를 확인하니 그 문장의 범위가 datasource·redis라 지금도 정확하다. PINLOG_AI_BASE_URL이 운영에 없는 것은 문서가 아니라 인프라 쪽 미결이라 사실만 적고 back#122로 넘겼다 (티켓 없음 — 문서 정정) BD-39 · configuration · back#122
2026-07-31 follow 전용 예외 둘을 domain/follow/exception으로 옮겼다(#89). error-handling.md가 "도메인별 구체 예외는 해당 도메인에서 베이스를 상속합니다"로 정했는데 이 둘만 global/exception에 있었다. 이슈가 던진 (a) 코드 이동 / (b) 규약 수정 중 (a)를 택한 근거는 비용 계산이 뒤집힌 것이다. (b)가 "코드를 안 건드리는 쪽"으로 보였지만, 확인해 보니 domain/ai/exception·domain/auth/exception이 이미 그 규약을 지키고 있었다. "모든 예외를 global에 모은다"를 규약으로 세우면 밖에 있는 두 개가 새로 어긋나므로 (b)도 파일 2개를 옮겨야 하고, 그중 AiSearchException은 AI 담당 경계에 걸쳐 있어 합의가 선행된다. 즉 (b)는 (a)보다 싸지 않고 남의 영역을 건드린다. DeleteConfirmationRequiredException은 record·collection 공용이라 global에 남으며, 이것이 규약이 이미 제대로 작동하고 있다는 증거다 — 공용은 global, 전용은 도메인. RED은 이번에도 없다(순수 이동이라 동작이 안 바뀐다). 다만 -201과 달리 기존 테스트가 실제로 계약을 지킨다: followingMyOwnShelfIs422가 422 + SELF_FOLLOW_NOT_ALLOWED를, duplicateFollowIs409WithoutDuplicateRow가 409 + DUPLICATE_FOLLOW를 각각 상태·코드 양쪽으로 단언하고 있어, 이동이 ErrorCode 배선을 깨뜨렸다면 빨개진다. 새 테스트를 더하면 같은 단언의 사본이 될 뿐이라 더하지 않았다. GlobalExceptionHandler는 BusinessException 베이스로 받으므로 패키지를 가정하지 않고, ArchUnit류 패키지 검사도 없어 따라 고칠 것이 없었다. @NullMarked는 일부러 붙이지 않았다 — 형제인 두 */exception 패키지는 붙어 있지만 domain/follow에는 package-info가 하나도 없고, @NullMarked는 하위 상속이 안 되므로 예외 패키지만 마킹하면 BD-29가 재검토 트리거로 지목한 "혼재"를 이 도메인 안에 만든다. follow 도메인 전체를 마킹할지는 별 티켓의 판단이다. clean check 409개 통과 (Jira 작업) BD-29 · error-handling · back#89
2026-07-31 cascadeDelete의 Collection 락 순서를 쿼리가 보장하게 했다(#90). 역조회를 findByRecordIdOrderByCollectionIdAsc로 바꾼 한 줄이지만, RED을 만들 수 없었다는 점이 이 작업의 실제 내용이다. 링크를 collectionId 역순으로 넣고 조회 순서를 단언하는 테스트를 썼는데 정렬 없이도 통과했고, 12개까지 늘려도 같았다. 실행 계획이 uq_colrec_active (collection_id, record_id)를 훑어 collection_id 순서를 공짜로 돌려주기 때문이다 — 이슈가 지목한 "우연히 그런 것"이 바로 이 인덱스다. -147·-148에서 쓰던 뮤테이션 방식(가드를 지우고 빨개지는지 본다)도 여기서는 통하지 않는다. 정렬을 지워도 초록이라 지우는 것과 남기는 것이 동작으로 구별되지 않는다. 그래서 보증을 테스트가 아니라 쿼리 이름에 실었다(파생 쿼리라 메서드명이 곧 ORDER BY이고 컴파일 대상이다). 남긴 테스트는 증명이 아니라 계약의 서술이며, 오늘은 정렬 없이도 통과한다는 사실을 javadoc에 그대로 적었다 — 적지 않으면 다음 사람이 이 테스트를 회귀 방어로 착각한다. BI-12 정정은 근거의 불완전함이 요지다: "Record → Collection 단방향"은 타입 사이 순서만 논증하고 Collection 사이 순서는 다루지 않는다. 서로 다른 Record를 지우는 두 트랜잭션이 같은 Collection 둘을 반대로 잡으면 교착이 나며, 그 순서를 정하는 것은 역조회 쿼리다. 기록 문서는 보존 구역이라 본문을 고치지 않고 정정 노트를 덧붙였다. 호출부 둘(cascadeDelete·lastCollectionIds) 모두 정렬이 붙어도 동작이 같다. clean check 410개 통과 (Jira 작업) BI-12 · back#90
2026-07-31 마이페이지 요약 조회를 구현했다(Jira 작업, #125). 명세를 구현과 대조해 보니 미구현이 둘뿐이었고(/me/summary와 /feed/collections/{id}/shelf #85) 프론트가 요구한 두 기능(내 프로필, 팔로잉·팔로워 수)이 이 엔드포인트 하나로 해결된다 — 08 §3.5가 계정 정보와 카운트 넷을 한 응답에 담아 뒀다. 함께 요청된 "내 기록 조회"는 계약에 없어 범위에서 뺐다(기록 장의 조회는 지도 마커·상세·장소별 셋뿐이다). 열려 있던 질문에 답이 나왔다 — MemberRepository.isActive의 javadoc이 "@SQLRestriction이 count 쿼리에도 적용되는지는 별개의 질문"으로 남겨 뒀던 것이 활성 기준 집계 넷이 필요해지며 답이 필요해졌고, 적용된다. 파생 countBy...만으로 성립하고 @Query가 필요하지 않다. 뮤테이션으로 확인했다 — 조건 없는 native 쿼리로 바꾸면 소프트 삭제 제외 테스트 1건만 실패한다. 조건을 명시하려다 빠뜨리는 쪽이 오히려 위험하다는 것도 같은 실험이 보여 줬고, 결론을 원래 질문을 남긴 자리에도 적었다. 설계 판단 셋. ① 카운트를 넷으로 나눴다 — 조인으로 묶으면 카운트가 곱해진다(Record 3 × Collection 2 = 6). 화면 진입당 1회 호출이라 count(DISTINCT ...)의 복잡도를 살 이유가 없다 ② 팔로워 수에 DISTINCT가 불필요하다 — 유니크가 (followee, follower) 활성 기준이라 한 사람이 여러 Collection을 팔로우해도 행이 하나이고 두 번째 시도는 409다. 팔로우 단위가 Collection이 아니라 작성자라는 뜻이라 테스트로 고정했다 ③ 소셜 계정이 없으면 IllegalStateException — 조용히 빈 값을 내보내면 "email은 항상 있다"는 계약이 깨진 채 프론트로 나간다. 픽스처 계산을 틀려 collectionCount를 3으로 기대했는데 2였고(세 번째는 남의 소유) 구현이 아니라 기대값이 틀린 경우였다. clean check 417개 통과 (Jira 작업) BI-30
2026-07-31 소셜 로그인 진단 로그를 넣었다(Jira 작업, #134). 조사가 실제로 막혀서 만든 티켓이다 — 운영의 간헐적 OAUTH_FAILED를 Loki로 조사해 분류([authorization_request_not_found])까지는 갔는데, 그 하위 원인 둘(중복 콜백 / 로그인 두 번 시작)은 다른 요청이 성공했는가를 봐야 갈리고 성공 경로가 로그를 남기지 않았다. created concurrently로 확인하려 했지만 그 로그는 첫 가입 경합에서만 찍혀 기존 회원에게는 애초에 답을 줄 수 없었다. 그래서 성공·가입·실패 세 줄을 넣고, 운영에서 본 실패를 테스트로 재현했다 — 같은 state로 콜백을 두 번 부르면 성공 한 줄과 실패 한 줄이 함께 남는 것을 고정했고 그게 이 티켓의 완료 근거다. INFO를 택한 근거 넷: 로그인은 요청마다가 아니라 세션당 1회, 가입은 logging.md가 든 "주요 상태 변화", DEBUG는 운영에서 출력되지 않아 조사에 못 쓰고, 실패가 WARN이라 짝지어 보려면 같은 레벨대여야 한다. 걷어낸 것이 있다 — 처음엔 실패 로그에 stage=normalize 같은 단계 라벨을 실었고 메서드 경계 때문에 Stage 상자 클래스까지 만들었는데, 리뷰 지적으로 다시 보니 ① 상자는 설계가 아니라 우회였고 ② stage=redirect는 도달 불가였다(sendRedirect가 IOException을 던져 catch (RuntimeException)에 안 걸린다) ③ 예외 타입·메시지가 이미 단계를 말해 정보가 중복이었다. 라벨을 빼고 테스트를 타입·메시지 기준으로 바꿨다. static final String으로 두자는 제안은 재대입 불가에 더해 싱글턴 빈에서 요청 간 공유라 26ms 차 동시 콜백이 실측된 이 저장소에서는 서로의 단계를 덮어쓴다. 로그에 이메일·provider_user_id는 남기지 않고 그것을 테스트로 고정했다. clean check 425개 통과 (Jira 작업) BI-31
2026-07-31 작성자 공개 책장 탐색 GET /v1/feed/collections/{collectionId}/shelf 구현 — 명세 §8.1의 마지막 미구현 항목. 새 질의 없이 기존 발행 Collection 조회·팔로우 조회·미탈퇴 판정으로 조립하고, 도메인은 Feed가 아니라 Follow에 뒀고, 경로는 계약대로 /v1/feed 아래를 유지했다 — 처음엔 /collections 아래로 옮기려 개정안(docs#36)까지 올렸는데, 라이브러리 집계 조회와 식별자 은닉 재검토가 함께 열려서 지금 따로 확정하면 프론트가 경로를 두 번 고치게 된다. 경로 통일은 그 재편과 한 번에 정한다(BD-43). 404 테스트 셋은 매핑이 없어도 통과해서 구현을 이끈 것은 200 경로의 실패였고, 작성자 id 유출은 값 문자열이 아니라 필드 이름 집합으로 단언했다(Collection id가 작성자 id 자릿수를 포함하면 유출 없이도 실패한다). keywords가 빈 배열로 남아 Feed 응답과 어긋나는 것은 별건으로 남겼다. clean check 445개 통과 (Jira 작업) BD-43 · BI-35
2026-07-31 CI 파이프라인을 빠르게 했다(Jira 작업). 실측 배분이 원인을 그대로 줬다 — clean check 121초, PR 이미지 검증 61초, 나머지 20초. 이미지 검증 61초의 대부분은 다시 하는 일이었다: Dockerfile이 컨테이너 안에서 의존성 해석과 bootJar를 처음부터 하고, 레이어 분할은 이미 잘 돼 있는데 캐시 설정이 없어 매 PR이 의존성 내려받기를 반복했다. 가장 큰 판단은 문서 전용 건너뛰기를 어디에 두는가다(BD-42). paths-ignore가 한 줄이라 당연해 보였지만 그러면 잡이 실행되지 않고, dev가 backend-ci / check를 필수 상태 검사로 요구하므로(실측: 필수 검사 이 하나 · strict: true · 승인 0건) 보고되지 않는 검사는 실패가 아니라 영구 대기다 — 문서 PR이 머지 불가가 된다. 그것을 고치려면 게이트를 목록에서 빼야 하니 paths-ignore를 고르면 결국 게이트 제거로 밀린다. 잡은 항상 돌리고 무거운 스텝만 껐고, 남는 러너 부팅 20초는 그 대가로 싸다고 봤다. 캐시는 PR에서 읽기만 한다 — Actions 캐시는 기본 브랜치가 쓴 항목을 모든 브랜치가 읽고 이 저장소의 기본 브랜치가 dev(PR의 대상)라 image-publish가 채운 것을 PR이 그대로 읽는다. PR도 쓰게 하면 브랜치별 항목이 용량을 먹어 정작 재사용되는 그 항목을 밀어내는데, 재사용되는 것은 build.gradle이 바뀔 때만 무효화되는 의존성 레이어이고 src가 바뀌는 bootJar 레이어는 PR이 캐시에 넣어도 다음 push에서 다시 미스라 쓸 실익이 없다. 판정 기준을 "무엇이 문서인가"가 아니라 "빌드에 닿는 것이 하나도 없는가" 로 뒤집어 새 종류의 파일이 들어와도 전체 실행으로 틀리게 했고, git diff가 빈 결과일 때 그 조건이 참이 되는 구멍은 [ -n "$changed" ]로 닫았다(대표 변경 8종을 스크립트로 떼어 내 직접 돌려 확인했다 — 빈 목록 포함). 함정 하나를 기록해 둔다: 이 워크플로에는 secrets라는 문자열을 쓸 수 없다. RuntimeSecretWorkflowContractTests가 파일을 문자열로 읽어 허용된 GITHUB_TOKEN 한 번을 지운 나머지에 그 단어가 없다고 단언하고, 소문자로 내린 전체 본문이 대상이라 주석도 포함된다(BI-26의 경계 계약을 문자열 수준에서 지키는 게이트다). 캐시 설계를 설명하는 주석에서 걸릴 수 있었다. CI에서만 관측되는 것들이라 워크플로 파일을 계약으로 읽는 BackendCiSpeedContractTests 3건으로 고정했다(RED 3/3 → GREEN 3/3, 기존 계약 테스트 5건 회귀 통과). code-style.md가 "backend-ci / check가 실행하는 ./gradlew clean check" 로 적고 있어 함께 고쳤다 — CI와 로컬이 이제 다른 명령을 쓴다. 첫 PR은 아직 빨라지지 않는다: dev가 캐시를 채우기 전이라 이 PR이 머지되며 처음 항목이 쓰이고 그다음 PR부터 효과가 난다. 기대치를 실측으로 정정했다: 이미지 빌드 62초의 내부가 dependencies 26.7초 + bootJar 29.7초 + 나머지 5초인데, src가 매 PR 바뀌어 bootJar는 절대 캐시되지 않는다. 캐시가 지우는 것은 26.7초뿐이라 코드 PR은 3.9분 → 약 3.4분이고 처음 적은 2.5분이 아니다 — 실질적 이득은 문서 PR이고 코드 PR의 병목은 손대지 않은 Run checks 126초다. clean 제거도 속도 항목이 아니다(새 러너엔 지울 것이 없다). 같은 로그가 CI가 프로젝트를 두 번 컴파일한다는 것도 드러냈다 — 러너의 Run checks와 컨테이너의 bootJar. 러너 jar를 넘기면 29.7초가 사라지지만 "Dockerfile이 실제로 빌드되는가"라는 검증 의미와 맞바꾸는 판단이라 이 티켓에서 정하지 않았다. clean check 432개 통과 (Jira 작업) BD-42 · BI-32 · code-style
2026-07-31 로컬 스택 전체를 상대로 한 API 종단 검증 하네스를 loadtest/에 만들었다(Jira 작업). k6가 29개 엔드포인트를 전수 호출하며 계약 138건을 검사하고 만진 행 id를 표식으로 흘리면, SQL 두 겹이 그 id를 지목 검증하고 전역 불변식 17종을 훑는다 — k6는 SQL도 RSA 서명도 못 하므로 이 분리는 선택이 아니다. 전역 스윕이 back 소유 위반 4,696건을 찾았고 삼분류 결과 전부 시드 생성기 산물, 코드 결함 0건이다 — 위반 행 100%가 대량 더미 대역이고, 하네스 자신의 쓰기(생성·교체·409→force 연쇄·탈퇴)가 존재하는 채로 스윕이 돌아도 4,696이 불변이었으며, 탈퇴 시나리오가 MemberWithdrawalService 파급 전량을 실증했다. 만들면서 하네스가 잡은 것: 엔드포인트 열거 누락(DELETE /v1/me — 뒤처진 체크아웃에서 열거한 탓, 27→28 — dev 전진분 GET /v1/me/summary까지 리베이스 후 29), CSRF 토큰은 캐시 불가(서버가 매 응답 회전), 시드 문서의 낡은 SEED-0008 참조, Windows 함정 셋(CP949 본문·WSL bash·MSYS 경로). 관측 2건은 기록만 했다(지도 응답 61KB 페이지네이션 없음, 검색 p95 415ms). Java 코드 무변경이라 기존 테스트는 그대로이고 clean check 통과 BI-33