From c913ad1c3db9e1caac6677ea7f39dc7fd86f1e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B8=EB=AF=BC?= Date: Mon, 10 Aug 2026 03:34:28 +0000 Subject: [PATCH] docs: remove training-program references --- CONTRIBUTING.md | 4 +- docs/ai/WORKLOG.md | 22 +-- .../2026-08-03-feed-keyword-display-name.md | 4 +- .../2026-08-03-feed-keyword-display-order.md | 12 +- .../2026-08-03-search-keyword-status.md | 10 +- docs/ai/implements/README.md | 6 +- .../P42-feed-mvp-without-place-metadata.md | 4 +- .../P46-feed-keyword-display-order.md | 12 +- docs/ai/spec/ai-integration.md | 4 +- docs/ai/spec/ai-response-assembly.md | 4 +- docs/ai/spec/feed-event.md | 2 +- docs/ai/spec/feed-recommendation.md | 4 +- docs/ai/spec/feed-tests.md | 8 +- docs/backend/WORKLOG.md | 164 +++++++++--------- .../BD-02-base-entity-common-columns.md | 2 +- .../decisions/BD-03-api-response-envelope.md | 2 +- .../decisions/BD-04-cursor-pagination.md | 2 +- .../BD-05-graceful-shutdown-timing.md | 6 +- .../BD-06-framework-error-mapping.md | 2 +- .../decisions/BD-07-context-immutability.md | 2 +- .../decisions/BD-08-soft-delete-no-restore.md | 2 +- .../decisions/BD-09-no-record-update-path.md | 2 +- .../decisions/BD-10-integrity-in-database.md | 2 +- .../BD-11-minimum-holding-invariants.md | 2 +- .../BD-12-duplicate-record-idempotent.md | 2 +- .../BD-13-public-boundary-query-dto-split.md | 2 +- .../decisions/BD-14-identifier-concealment.md | 2 +- .../decisions/BD-15-shelf-not-a-table.md | 2 +- .../BD-16-ai-derived-immediate-purge.md | 2 +- .../BD-17-async-without-message-queue.md | 2 +- .../BD-18-keyword-preset-and-visibility.md | 2 +- .../backend/decisions/BD-19-place-snapshot.md | 2 +- .../BD-20-selective-denormalization.md | 2 +- .../decisions/BD-21-auth-token-model.md | 2 +- .../decisions/BD-22-signup-commit-point.md | 2 +- .../BD-23-collection-auto-publish.md | 2 +- .../decisions/BD-24-foundation-reset.md | 6 +- .../BD-25-context-origin-created-at.md | 4 +- .../decisions/BD-26-flyway-out-of-order.md | 2 +- .../BD-27-coverage-gate-bundle-80.md | 2 +- .../decisions/BD-28-readiness-includes-db.md | 4 +- .../BD-29-nullmarked-security-package.md | 2 +- .../BD-30-authorization-request-in-cookie.md | 2 +- .../BD-31-jwt-rs256-key-management.md | 4 +- ...D-32-refresh-reuse-no-family-revocation.md | 6 +- .../BD-33-published-at-database-invariant.md | 2 +- ...nistic-pagination-without-session-cache.md | 2 +- .../BD-35-refresh-reuse-family-revocation.md | 4 +- ...n-transaction-process-call-after-commit.md | 2 +- ...nvalidation-inside-deletion-transaction.md | 2 +- .../BD-38-published-predicate-per-layer.md | 6 +- ...embedding-profile-in-application-config.md | 4 +- ...cated-scheduler-and-no-distributed-lock.md | 2 +- ...n-member-check-in-authentication-filter.md | 6 +- ...-42-ci-skip-inside-job-not-paths-ignore.md | 2 +- ...in-follow-domain-under-collections-path.md | 6 +- .../BD-44-image-takes-prebuilt-jar.md | 2 +- .../BD-45-worklog-per-entry-files.md | 4 +- ...BD-46-list-sort-default-asc-with-params.md | 2 +- .../BD-47-auth-filter-infra-failure-is-503.md | 4 +- .../BD-48-unlink-before-withdrawal.md | 6 +- ...BD-49-authorization-request-cookie-json.md | 2 +- ...e-gate-filters-before-core-revalidation.md | 4 +- ...26-07-27-member-base-entity-soft-delete.md | 2 +- .../BI-03-2026-07-27-api-response-envelope.md | 2 +- .../BI-04-2026-07-27-cursor-pagination.md | 2 +- .../BI-05-2026-07-27-graceful-shutdown.md | 14 +- ...-07-27-deployment-contract-verification.md | 6 +- ...I-07-2026-07-27-framework-error-mapping.md | 2 +- ...BI-08-2026-07-28-core-domain-foundation.md | 4 +- .../BI-09-2026-07-28-record-context-api.md | 2 +- .../BI-10-2026-07-28-collection-api.md | 4 +- .../BI-11-2026-07-28-follow-library-api.md | 2 +- .../BI-12-2026-07-28-deletion-cascade.md | 4 +- .../BI-13-2026-07-28-public-scope-filter.md | 2 +- ...7-28-record-create-conflict-free-insert.md | 4 +- .../BI-15-2026-07-28-duplicate-follow-race.md | 4 +- .../BI-16-2026-07-28-input-size-limits.md | 4 +- ...-2026-07-28-alias-key-omission-contract.md | 4 +- .../BI-18-2026-07-28-jwt-cookie-session.md | 8 +- ...BI-19-2026-07-28-published-at-invariant.md | 2 +- ...I-20-2026-07-29-feed-recommendation-mvp.md | 4 +- ...6-07-29-refresh-reuse-family-revocation.md | 4 +- .../BI-22-2026-07-29-context-ai-enqueue.md | 6 +- ...07-29-ai-derived-invalidation-on-delete.md | 4 +- .../BI-24-2026-07-29-kakao-naver-login.md | 2 +- ...-29-personal-search-backend-integration.md | 4 +- ...I-26-2026-07-29-runtime-secret-workflow.md | 4 +- ...-2026-07-30-social-login-email-required.md | 4 +- .../BI-28-2026-07-30-ai-rescan-scheduler.md | 4 +- .../BI-29-2026-07-30-member-withdrawal.md | 6 +- .../implements/BI-30-2026-07-31-me-summary.md | 6 +- .../BI-31-2026-07-31-login-diagnostics.md | 4 +- .../BI-32-2026-07-31-ci-pipeline-speedup.md | 4 +- ...-33-2026-07-31-api-verification-harness.md | 2 +- ...-34-2026-08-01-image-takes-prebuilt-jar.md | 2 +- .../BI-35-2026-07-31-shelf-browse.md | 4 +- .../BI-36-2026-08-01-keyword-exposure.md | 2 +- .../BI-37-2026-08-02-load-profiles-report.md | 2 +- ...26-08-03-massive-scale-plan-observation.md | 8 +- ...-38-2026-08-04-unlink-before-withdrawal.md | 2 +- ...08-04-authorization-request-cookie-json.md | 2 +- ...-2026-08-05-google-revoke-refresh-token.md | 2 +- .../BI-42-2026-08-07-map-keyword-chips.md | 8 +- ...BI-43-2026-08-07-record-collections-api.md | 2 +- .../BI-44-2026-08-07-confidence-gate.md | 12 +- ...BI-45-2026-08-07-search-relevance-judge.md | 10 +- .../BT-01-shared-testcontainers-lifecycle.md | 2 +- ...T-02-flyway-out-of-order-version-ranges.md | 10 +- ...-health-endpoint-blocks-on-redis-outage.md | 2 +- .../BT-04-logged-in-cookie-path-unreadable.md | 2 +- ...05-dotenv-empty-value-overrides-default.md | 4 +- ...vocation-leak-under-concurrent-rotation.md | 2 +- ...r-knobs-insufficient-for-plan-assertion.md | 4 +- ...-08-01-S15P11A705-231-ci-cache-measured.md | 9 - ...-08-01-S15P11A705-238-ci-single-compile.md | 9 - .../worklog/2026-08-01-ci-cache-measured.md | 9 + .../worklog/2026-08-01-ci-single-compile.md | 9 + ...sure.md => 2026-08-01-keyword-exposure.md} | 2 +- .../2026-08-01-worklog-freeze-consistency.md | 4 +- ...2026-08-02-S15P11A705-239-load-profiles.md | 7 - .../worklog/2026-08-02-load-profiles.md | 7 + ....md => 2026-08-02-oauth-path-ownership.md} | 2 +- ...d => 2026-08-03-auth-filter-db-failure.md} | 2 +- ...08-03-collection-record-ownership-test.md} | 2 +- ...026-08-03-drop-dead-datasource-literal.md} | 2 +- ...26-08-03-follows-collection-first-page.md} | 4 +- ...md => 2026-08-03-list-sort-default-asc.md} | 2 +- ...e-bench.md => 2026-08-03-massive-bench.md} | 6 +- ...tion.md => 2026-08-03-plan-observation.md} | 6 +- ... => 2026-08-03-refresh-revocation-leak.md} | 2 +- ...8-04-authorization-request-cookie-json.md} | 2 +- ...x.md => 2026-08-04-feed-order-by-index.md} | 6 +- ...ch.md => 2026-08-04-map-keyword-search.md} | 2 +- ...=> 2026-08-04-map-latest-collection-id.md} | 2 +- ....md => 2026-08-04-place-thumbnail-mock.md} | 2 +- ...> 2026-08-04-unlink-before-soft-delete.md} | 6 +- ....md => 2026-08-05-collection-cover-url.md} | 2 +- ...2026-08-05-feed-channel-plan-test-flake.md | 2 +- ...2026-08-05-google-revoke-refresh-token.md} | 2 +- ....md => 2026-08-05-place-thumbnail-webp.md} | 8 +- .../2026-08-07-bd-46-duplicate-number.md | 2 +- ...-gate.md => 2026-08-07-confidence-gate.md} | 4 +- ...ips.md => 2026-08-07-map-keyword-chips.md} | 4 +- ...y-api.md => 2026-08-07-me-activity-api.md} | 8 +- ...pi.md => 2026-08-07-recent-records-api.md} | 2 +- ...d => 2026-08-07-record-collections-api.md} | 2 +- .../2026-08-07-search-relevance-judge.md | 2 +- docs/backend/worklog/README.md | 6 +- docs/development/api-conventions.md | 2 +- docs/development/authentication.md | 10 +- docs/development/configuration.md | 10 +- docs/development/database-conventions.md | 4 +- docs/development/error-handling.md | 2 +- docs/development/jira-workflow.md | 14 +- docs/development/package-structure.md | 6 +- docs/development/workflow.md | 6 +- loadtest/README.md | 4 +- 158 files changed, 410 insertions(+), 410 deletions(-) delete mode 100644 docs/backend/worklog/2026-08-01-S15P11A705-231-ci-cache-measured.md delete mode 100644 docs/backend/worklog/2026-08-01-S15P11A705-238-ci-single-compile.md create mode 100644 docs/backend/worklog/2026-08-01-ci-cache-measured.md create mode 100644 docs/backend/worklog/2026-08-01-ci-single-compile.md rename docs/backend/worklog/{2026-08-01-S15P11A705-240-keyword-exposure.md => 2026-08-01-keyword-exposure.md} (98%) delete mode 100644 docs/backend/worklog/2026-08-02-S15P11A705-239-load-profiles.md create mode 100644 docs/backend/worklog/2026-08-02-load-profiles.md rename docs/backend/worklog/{2026-08-02-S15P11A705-249-oauth-path-ownership.md => 2026-08-02-oauth-path-ownership.md} (99%) rename docs/backend/worklog/{2026-08-03-S15P11A705-188-auth-filter-db-failure.md => 2026-08-03-auth-filter-db-failure.md} (99%) rename docs/backend/worklog/{2026-08-03-S15P11A705-190-collection-record-ownership-test.md => 2026-08-03-collection-record-ownership-test.md} (97%) rename docs/backend/worklog/{2026-08-03-S15P11A705-282-drop-dead-datasource-literal.md => 2026-08-03-drop-dead-datasource-literal.md} (97%) rename docs/backend/worklog/{2026-08-03-S15P11A705-244-follows-collection-first-page.md => 2026-08-03-follows-collection-first-page.md} (93%) rename docs/backend/worklog/{2026-08-03-S15P11A705-265-list-sort-default-asc.md => 2026-08-03-list-sort-default-asc.md} (98%) rename docs/backend/worklog/{2026-08-03-S15P11A705-283-massive-bench.md => 2026-08-03-massive-bench.md} (94%) rename docs/backend/worklog/{2026-08-03-S15P11A705-284-plan-observation.md => 2026-08-03-plan-observation.md} (96%) rename docs/backend/worklog/{2026-08-03-S15P11A705-267-refresh-revocation-leak.md => 2026-08-03-refresh-revocation-leak.md} (99%) rename docs/backend/worklog/{2026-08-04-S15P11A705-132-authorization-request-cookie-json.md => 2026-08-04-authorization-request-cookie-json.md} (99%) rename docs/backend/worklog/{2026-08-04-S15P11A705-303-feed-order-by-index.md => 2026-08-04-feed-order-by-index.md} (94%) rename docs/backend/worklog/{2026-08-04-S15P11A705-301-map-keyword-search.md => 2026-08-04-map-keyword-search.md} (98%) rename docs/backend/worklog/{2026-08-04-S15P11A705-308-map-latest-collection-id.md => 2026-08-04-map-latest-collection-id.md} (98%) rename docs/backend/worklog/{2026-08-04-S15P11A705-305-place-thumbnail-mock.md => 2026-08-04-place-thumbnail-mock.md} (98%) rename docs/backend/worklog/{2026-08-04-S15P11A705-285-unlink-before-soft-delete.md => 2026-08-04-unlink-before-soft-delete.md} (90%) rename docs/backend/worklog/{2026-08-05-S15P11A705-322-collection-cover-url.md => 2026-08-05-collection-cover-url.md} (97%) rename docs/backend/worklog/{2026-08-05-S15P11A705-309-google-revoke-refresh-token.md => 2026-08-05-google-revoke-refresh-token.md} (99%) rename docs/backend/worklog/{2026-08-05-S15P11A705-320-place-thumbnail-webp.md => 2026-08-05-place-thumbnail-webp.md} (89%) rename docs/backend/worklog/{2026-08-07-S15P11A705-400-confidence-gate.md => 2026-08-07-confidence-gate.md} (88%) rename docs/backend/worklog/{2026-08-07-S15P11A705-388-map-keyword-chips.md => 2026-08-07-map-keyword-chips.md} (94%) rename docs/backend/worklog/{2026-08-07-S15P11A705-397-me-activity-api.md => 2026-08-07-me-activity-api.md} (86%) rename docs/backend/worklog/{2026-08-07-S15P11A705-370-recent-records-api.md => 2026-08-07-recent-records-api.md} (99%) rename docs/backend/worklog/{2026-08-07-S15P11A705-391-record-collections-api.md => 2026-08-07-record-collections-api.md} (98%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f157a45..aa7ca1f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ branch: {type}/{jira-key}-{summary} commit: {type}({jira-key}): {summary} ``` -For example, on `feat/S15P11A705-14-member-search` a commit reads `feat(S15P11A705-14): add member search`. Use `feat`, `fix`, `docs`, `refactor`, `chore`, `test`, or `perf` for `type`. +For example, on `feat/{jira-key}-member-search` a commit reads `feat({jira-key}): add member search`. Use `feat`, `fix`, `docs`, `refactor`, `chore`, `test`, or `perf` for `type`. The backend foundation reset is an exception: it is tracked solely by [GitHub Issue #9](https://github.com/Team-PinLog/back/issues/9) without Jira, and that exception applies to its branch names, commit messages, and PRs. It does not apply to normal work, which keeps using Jira keys. @@ -144,6 +144,6 @@ Before adding a new rule, decide its enforcement point first. Prefer CI: it runs Keep shared, repo-wide protections in [`.claude/settings.json`](.claude/settings.json) only. Put per-person permissions and environment settings in `.claude/settings.local.json` (Git-ignored); copy the [example file](.claude/settings.local.json.example) to start. Do not commit personal settings or add personal permissions to the team settings. -**Design docs stay local.** Brainstorming specs and plans go under `.claude/superpowers/`, which is Git-ignored — this repo deliberately excludes them (S15P11A705-28). What must outlive the branch goes to `docs/backend/` instead, where it is reviewed and preserved: decisions as `BD-##`, implementation reports and troubleshooting as their own entries. +**Design docs stay local.** Brainstorming specs and plans go under `.claude/superpowers/`, which is Git-ignored — this repo deliberately excludes them (Jira 작업). What must outlive the branch goes to `docs/backend/` instead, where it is reviewed and preserved: decisions as `BD-##`, implementation reports and troubleshooting as their own entries. **Hooks are personal, not shared.** `.claude/hooks/` is Git-ignored. Set one up if you want the same failure reported a few minutes before CI does, and register it in your own `settings.local.json` — never in the shared `settings.json`, which would error for everyone who does not have your scripts. Keep the rule itself in CI so a dead hook costs you convenience and nothing more. The example file explains the portability traps that make hand-written shell hooks fail silently. diff --git a/docs/ai/WORKLOG.md b/docs/ai/WORKLOG.md index 4cd6c551..48a5e8d8 100644 --- a/docs/ai/WORKLOG.md +++ b/docs/ai/WORKLOG.md @@ -10,21 +10,21 @@ - 작업 기록 신설. 결정·트러블슈팅·리포트 폴더를 만들었다 (back#6) — [proposals/](proposals/)·[implements/](implements/)·[troubleshooting/](troubleshooting/) - 문서 체계 재구조화. spec/proposals/implements/troubleshooting 구조와 WORKLOG를 도입하고, ADR 명칭을 P 번호로 바꿨다 (back#6) — 이 트리 전체 -## 2026-07-28 — Feed 합의 반영 (S15P11A705-125) +## 2026-07-28 — Feed 합의 반영 (Jira 작업) back#58에서 이루어진 Feed 합의를 문서에 반영했다. 구현 담당은 김가현으로 정했다. MVP에서 category·region을 제외했다. 공용 API 계약(size 20·opaque cursor·별도 requestId)을 우선하기로 했고, 향후 placeMeta를 임베딩으로 전환할 조건을 기록했다. — [P42](proposals/P42-feed-mvp-without-place-metadata.md) · [Feed 명세](spec/feed-recommendation.md) -같은 날 담당을 긴급 정정했다(S15P11A705-125). Feed Spring 구현을 이정헌에게 재배치하고, 김가현은 Feed 범위에서 제외해 별도 추가 AI 기능 담당으로 분리했다. — [P42](proposals/P42-feed-mvp-without-place-metadata.md) +같은 날 담당을 긴급 정정했다(Jira 작업). Feed Spring 구현을 이정헌에게 재배치하고, 김가현은 Feed 범위에서 제외해 별도 추가 AI 기능 담당으로 분리했다. — [P42](proposals/P42-feed-mvp-without-place-metadata.md) -## 2026-07-28 — 최신 dev 기준 Feed 계약 재검토 (S15P11A705-125) +## 2026-07-28 — 최신 dev 기준 Feed 계약 재검토 (Jira 작업) -최신 `dev` 기준으로 Feed 계약을 재검토했다. 합의 당시보다 `dev`가 5커밋 앞서 있었다. 그중 S15P11A705-117이 "요청 입력의 서버 방어 상한은 이름 붙은 상수 한 곳(`InputLimits`)에 모으고 `CursorPage.MAX_SIZE`와 같은 값을 쓴다"는 규약을 세웠다. +최신 `dev` 기준으로 Feed 계약을 재검토했다. 합의 당시보다 `dev`가 5커밋 앞서 있었다. 그중 Jira 작업이 "요청 입력의 서버 방어 상한은 이름 붙은 상수 한 곳(`InputLimits`)에 모으고 `CursorPage.MAX_SIZE`와 같은 값을 쓴다"는 규약을 세웠다. Feed 명세의 `size=20`·opaque cursor는 공통 커서 계약(`DEFAULT_SIZE` 20 · `MAX_SIZE` 100 · `normalizeSize` 보정)과 이미 일치해 충돌이 없었다. 명세에 그 근거를 명시했다. 반면 `POST .../feed/events`의 배열 상한은 "예: 50"으로 미정이라 그 규약과 어긋났고, 100으로 고정했다. 경로 표기 불일치도 함께 정리했다. §4 코드블록만 `/api/core/v1/...`로 바뀌고, 본문·`feed-event.md`·`feed-tests.md`는 옛 경로 `POST /feed/events`로 남아 있던 상태였다. — [Feed 명세](spec/feed-recommendation.md) · [Feed 이벤트](spec/feed-event.md) -## 2026-07-29 — back#74 리뷰 반영 (S15P11A705-125) +## 2026-07-29 — back#74 리뷰 반영 (Jira 작업) `page-size`를 10에서 20으로 바꿀 때 탐색 슬롯 수가 따라가지 않아, 탐색 비중이 20%에서 10%로 절반이 된 것을 발견했다. 슬롯을 일반 2→4, Cold Start 3→6으로 올려 비율을 원복했다. 정책 변경이 아니라 누락 보정이다. @@ -32,11 +32,11 @@ Place region 제거로 "AI 미완료 Collection이 점수를 얻는 경로"가 수치 서술 두 곳을 바로잡았다. 후보 풀 상한 200과 채널 배분 합 200이 같아 잘라내기 규칙이 발동할 수 없다는 사실을 적었고, "10배 여유"로 적혀 있던 값의 실제치가 팔로우 0인 사용자 기준 6배 남짓임을 적었다. `InputLimits.FEED_EVENTS_MAX`라는 상수 이름과 상한 초과 시 `400 INVALID_INPUT` 응답을 못박았다. 이번에 고정한 API 계약 4가지를 `feed-tests.md`에 §11·E9로 추가했다. — [Feed 점수](spec/feed-scoring.md) · [Feed 테스트](spec/feed-tests.md) · [P42](proposals/P42-feed-mvp-without-place-metadata.md) -## 2026-07-29 — 내부 인증 헤더 표기 정정 (S15P11A705-96) +## 2026-07-29 — 내부 인증 헤더 표기 정정 (Jira 작업) 내부 인증 헤더 표기가 구현과 어긋난 것을 바로잡았다. 명세 §7이 `X-Internal-Token: `으로 적혀 있었으나, 실제 구현은 `ai/app/core/security.py`의 `INTERNAL_SECRET_HEADER = "X-Internal-Secret"`이고 테스트·E2E 도구도 전부 `X-Internal-Secret`을 쓴다. 플레이스홀더도 실제 주입 이름인 `INTERNAL_SHARED_SECRET`으로 맞췄다. 정정 전에는 이 문서만 보고 Spring을 붙이면 FastAPI가 401을 반환하는 상태였다. — [AI 연동 명세](spec/ai-integration.md) -## 2026-07-30 — 재스캔 명세 3.1의 근거 시나리오 정정 (S15P11A705-160) +## 2026-07-30 — 재스캔 명세 3.1의 근거 시나리오 정정 (Jira 작업) 재스캔 명세 3.1이 「Finalize를 먼저」의 근거로 든 시나리오가 실제로는 일어나지 않는다는 것이 back#104 구현 중 실측으로 드러났다. 두 단계를 맞바꿔도 테스트가 통과했다. @@ -44,13 +44,13 @@ Place region 제거로 "AI 미완료 Collection이 점수를 얻는 경로"가 3.1 순서 목록에 `updated_at` 갱신을 넣고, 근거를 이미 정확하던 6.1과 일치시켰다. 창을 만드는 것은 만료 조건과 `updated_at` 갱신이고, 순서는 심층 방어다. 순서 자체는 유지했다. 나중에 어느 구현이 `updated_at` 갱신을 빠뜨리면 그때 유일하게 남는 방어선이기 때문이다. 3.1·5·6.1이 서로를 참조하도록 연결했다. 구현 변경은 없다. — [재스캔 명세](spec/ai-rescan-scheduler.md) -## 2026-08-03 — Feed keywords를 display_name으로 교체 (back#146, S15P11A705-252) +## 2026-08-03 — Feed keywords를 display_name으로 교체 (back#146, Jira 작업) Feed `keywords`가 `keyword_preset.code`를 내보내던 것을 `display_name`으로 교체했다(08 §6.1). 점수 계산(`FeedScorer.weightedJaccard`)의 키는 `code`로 유지하고 응답 조립 시점에만 매핑했다. `display_name`을 키로 쓰면 당장은 Jaccard가 그대로 성립해 테스트도 깨지지 않고 오류도 나지 않지만, 표시값이 바뀌는 순간 과거 Profile과의 매칭이 조용히 어긋나기 때문이다. 표시값을 못 찾은 code는 `code`로 대신 채우지 않고 응답에서 뺀다. 그 폴백 자체가 명세 위반이 되기 때문이다. N+1 부재는 `SqlQueryCounter`(신설)로 측정해 확인했고, 같은 카운터로 미검증 상태였던 feed-tests N4·N5도 함께 고정했다. back#145(조회 응답 3곳의 빈 keywords)는 이미 CLOSED였고 `ContextKeywordRepository`(별도 신설)로 처리돼 있어 `code`를 내보내지 않는 것을 확인했다. — [implements](implements/2026-08-03-feed-keyword-display-name.md) -## 2026-08-03 — 검색 응답 상태 노출을 금지하던 명세 §5 개정 (back#136, S15P11A705-209) +## 2026-08-03 — 검색 응답 상태 노출을 금지하던 명세 §5 개정 (back#136, Jira 작업) 검색 응답이 「분석 중」·「0건 완료」·「실패」를 구분하지 못하는 문제를 응답 조립 명세 §5 개정으로 풀었다. 직전 판까지 §5는 「상태 필드를 노출하지 않는다」·「구분할 필요도 없다」였고, 그 근거는 **처리가 짧게 끝난다는 가정**이었다. 실측이 그 가정을 벗어났다. -121·-197에서 GMS 판정이 분당 2건이었고, -198에서 PROCESSING 잔류로 10분 결빙이 관측됐다. @@ -58,7 +58,7 @@ Feed `keywords`가 `keyword_preset.code`를 내보내던 것을 `display_name` 내부 5값을 응답 3값으로 접는다. `PENDING`은 `PROCESSING`으로 합류하고, `CANCELLED`는 집계에서 제외하며, 상태 행이 없으면 `COMPLETED`로 본다. 사용자에게 필요한 판단이 「기다리면 오는가」 하나이기 때문이다. 행 없음을 `PROCESSING`으로 접으면 영영 오지 않는 것에 「분석 중」을 띄우게 된다. 그것이 바로 이 개정이 고치려던 증상이므로, 그렇게 접으면 증상이 재발한다. 타인 응답에 넣지 않은 것은 남의 처리 진행 상황이 새기 때문이다(§2 Visibility). 구현은 후속 작업으로 남겼다. — [응답 조립 명세 §5](spec/ai-response-assembly.md) -## 2026-08-03 — 검색 응답에 keywordStatus 구현 (back#136, S15P11A705-209) +## 2026-08-03 — 검색 응답에 keywordStatus 구현 (back#136, Jira 작업) 검색 응답에 Record 단위 `keywordStatus`를 노출했다(명세 §5.1 개정분). **기존 세 Keyword 쿼리는 한 글자도 바꾸지 않았다.** 그 쿼리들은 `keyword_status = 'COMPLETED'` INNER JOIN이라 미완료 Context를 애초에 만나지 못해, 상태 집계를 같은 쿼리로 합칠 수 없다. 합치려고 조건을 풀면 `keywords` 배열의 계약이 바뀐다. 그래서 `LEFT JOIN` 집계를 별도 쿼리로 두고 쿼리 1회 증가를 택했다. 하위 호환 위험을 코드 구조로 없애는 값이 왕복 1회보다 크다고 판단했다. @@ -66,7 +66,7 @@ Feed `keywords`가 `keyword_preset.code`를 내보내던 것을 `display_name` 하위 호환 검증에서 「기존 쿼리 무변경」은 코드 읽기로만 확인되는 절반이다. 그래서 **미완료 Context가 섞인 Record에서 배열이 그대로인지를 실행으로** 붙잡았고, 기존 36건이 그대로 통과하는 것이 나머지 절반이다. N+1 부재는 `SqlQueryCounter`로 측정했다. 결과가 3→12건으로 늘어도 상태 조회는 1회로 고정된다(-252 선례). 공용 계약 반영(docs#41)이 구현보다 먼저였다. 백엔드가 직렬화 테스트를 문서 근거로 고정할 수 있어야 한다는 요청이다. — [implements I17](implements/2026-08-03-search-keyword-status.md) · [응답 조립 명세 §5.1](spec/ai-response-assembly.md) -## 2026-08-03 — Feed keywords 상한 3과 동점 규칙 (ai#93, S15P11A705-278) +## 2026-08-03 — Feed keywords 상한 3과 동점 규칙 (ai#93, Jira 작업) Feed 응답 `keywords`에 상한이 없어 프론트(front#88 책장 레이아웃)가 카드를 확정하지 못하던 것을 풀었다. **개수 3과 빈도 내림차순은 프론트와의 구두 합의(2026-08-03)이고, 이 작업의 결정은 동점 규칙 하나다.** 명세에 출처 표를 두어 합의분과 결정분을 갈라 적었다. 개수를 데이터로 역산하지 않는다. 초판에서 「4 = 프리셋 축 수」로 적었던 것을 걷어냈다. 상한은 같은 날 4에서 3으로 한 번 더 바뀌었고, 그때 움직인 것도 화면 쪽이지 측정값이 아니었다. diff --git a/docs/ai/implements/2026-08-03-feed-keyword-display-name.md b/docs/ai/implements/2026-08-03-feed-keyword-display-name.md index e7bcd06f..41b91197 100644 --- a/docs/ai/implements/2026-08-03-feed-keyword-display-name.md +++ b/docs/ai/implements/2026-08-03-feed-keyword-display-name.md @@ -1,7 +1,7 @@ -# Feed keywords를 code에서 display_name으로 교체 (S15P11A705-252) +# Feed keywords를 code에서 display_name으로 교체 (Jira 작업) - **상태**: ✅ 완료 -- **관련**: back#146(정본) · back#145(선행 조건, CLOSED) · S15P11A705-252 +- **관련**: back#146(정본) · back#145(선행 조건, CLOSED) · Jira 작업 ## 무엇을 만들었나 diff --git a/docs/ai/implements/2026-08-03-feed-keyword-display-order.md b/docs/ai/implements/2026-08-03-feed-keyword-display-order.md index 0cad12a1..70c84ece 100644 --- a/docs/ai/implements/2026-08-03-feed-keyword-display-order.md +++ b/docs/ai/implements/2026-08-03-feed-keyword-display-order.md @@ -1,10 +1,10 @@ -# Feed 표시 Keyword의 동점 규칙과 상한 3 (S15P11A705-278) +# Feed 표시 Keyword의 동점 규칙과 상한 3 (Jira 작업) - **상태**: ✅ 완료 -- **관련**: S15P11A705-278 · [ai#93](https://github.com/Team-PinLog/ai/issues/93)(제기 — 프론트) · - [front#88](https://github.com/Team-PinLog/front/issues/88)(S15P11A705-279 책장 레이아웃) · +- **관련**: Jira 작업 · [ai#93](https://github.com/Team-PinLog/ai/issues/93)(제기 — 프론트) · + [front#88](https://github.com/Team-PinLog/front/issues/88)(Jira 작업 책장 레이아웃) · [P46](../proposals/P46-feed-keyword-display-order.md)(결정) · - [S15P11A705-252](2026-08-03-feed-keyword-display-name.md)(선행 — 표시값 경계) + [Jira 작업](2026-08-03-feed-keyword-display-name.md)(선행 — 표시값 경계) ## 무엇을 만들었나 @@ -22,8 +22,8 @@ FeedCollectionItemResponse KEYWORD_LIMIT = 3 (설정값 아님) ## 실측 — 조사가 설계를 바꾼 지점 티켓의 전제 하나가 사실과 달랐다. **현재 순서는 `GROUP BY` 결과 순서가 아니었다.** -`FeedService.RankedFeed.keywordsOf`는 `S15P11A705-120`(back#77) 때부터 `.sorted()`를 걸고 있었고, -`S15P11A705-252` 이후로는 **표시값 가나다순**이었다. 즉 순서는 이미 결정적이었다. +`FeedService.RankedFeed.keywordsOf`는 `Jira 작업`(back#77) 때부터 `.sorted()`를 걸고 있었고, +`Jira 작업` 이후로는 **표시값 가나다순**이었다. 즉 순서는 이미 결정적이었다. 그래서 문제는 결정성이 아니라 **기준에 의미가 없다**는 것으로 바뀌었다. 「가족과」가 「활기찬」보다 앞에 올 이유가 없고, 더 나쁜 것은 **표시 라벨을 한 글자 고치면 카드에 뜨는 Keyword 구성 자체가 diff --git a/docs/ai/implements/2026-08-03-search-keyword-status.md b/docs/ai/implements/2026-08-03-search-keyword-status.md index 9b63a7b2..7388ab53 100644 --- a/docs/ai/implements/2026-08-03-search-keyword-status.md +++ b/docs/ai/implements/2026-08-03-search-keyword-status.md @@ -1,7 +1,7 @@ -# 검색 응답에 Keyword 판정 상태 노출 (S15P11A705-209) +# 검색 응답에 Keyword 판정 상태 노출 (Jira 작업) - **상태**: ✅ 완료 -- **관련**: [back#136](https://github.com/Team-PinLog/back/issues/136)(정본) · front#58(화면 렌더링, 프론트 파트) · S15P11A705-209 +- **관련**: [back#136](https://github.com/Team-PinLog/back/issues/136)(정본) · front#58(화면 렌더링, 프론트 파트) · Jira 작업 - **PR**: back#161(명세 개정, 1/3) · [Team-PinLog/docs#41](https://github.com/Team-PinLog/docs/pull/41)(공용 계약, 2/3) · back(구현, 3/3) ## 무엇을 만들었나 @@ -31,8 +31,8 @@ back#136에서 백엔드가 이 조항을 근거로 **「백엔드는 계약대 §5의 판단은 *"내부 처리 상태는 사용자 관심사가 아니다"*였고 이는 **처리가 짧게 끝난다는 가정** 위에 있었다. 실측이 그 가정을 벗어났다. ```text -S15P11A705-121·-197 GMS 판정이 분당 약 2건만 통과한다 (429) -S15P11A705-198 PROCESSING 잔류 + PROCESSING_EXPIRY_SEC 600초로 +Jira 작업·-197 GMS 판정이 분당 약 2건만 통과한다 (429) +Jira 작업 PROCESSING 잔류 + PROCESSING_EXPIRY_SEC 600초로 Context 하나가 10분 얼린 사례 ``` @@ -141,7 +141,7 @@ GROUP BY ct.record_id ### N+1 부재 — 측정으로 확인했다 -`RecordSearchQueryCountTests`를 신설했다(`SqlQueryCounter` 사용, S15P11A705-252 선례). 결과가 3→12건으로 늘어도 **상태 조회는 1회 고정**이고, **기존 Keyword 조회 횟수도 그대로**다. 후자가 "필드를 더하면서 기존 조회를 늘리지 않았다"가 측정으로 드러나는 유일한 자리다. +`RecordSearchQueryCountTests`를 신설했다(`SqlQueryCounter` 사용, Jira 작업 선례). 결과가 3→12건으로 늘어도 **상태 조회는 1회 고정**이고, **기존 Keyword 조회 횟수도 그대로**다. 후자가 "필드를 더하면서 기존 조회를 늘리지 않았다"가 측정으로 드러나는 유일한 자리다. 결과 0건이면 상태 조회 자체가 나가지 않는 것도 함께 고정했다. 빈 `IN ()`으로 도는 쿼리는 문법 오류이거나 전체 스캔이기 때문이다. diff --git a/docs/ai/implements/README.md b/docs/ai/implements/README.md index 56982816..d1fbc8a8 100644 --- a/docs/ai/implements/README.md +++ b/docs/ai/implements/README.md @@ -20,6 +20,6 @@ | I7 | back docs README 백엔드 허브화 (back#2) | ✅ 완료 | [../README.md](../README.md) | | I8 | Flyway 도입 + ai 스키마·feed_event 마이그레이션 V1/V100~102 (back#3) | ✅ 완료 | [2026-07-23-flyway-ai-schema-migration.md](2026-07-23-flyway-ai-schema-migration.md) | | I15 | 백엔드 작업기록 신설 + 문서 재구조화(spec/proposals/implements/troubleshooting) (back#6) | ✅ 완료 | 이 트리 전체 | -| I16 | Feed `keywords`를 `code`에서 `display_name`으로 교체. 점수 계산 키는 `code`로 유지하고, N+1 검증용 쿼리 카운터를 신설 (back#146, S15P11A705-252) | ✅ 완료 | [2026-08-03-feed-keyword-display-name.md](2026-08-03-feed-keyword-display-name.md) | -| I17 | 검색 응답에 Record 단위 `keywordStatus` 노출. 상태 노출을 금지하던 응답 조립 명세 §5를 실측 근거로 개정하고(back#161) 공용 계약 반영(docs#41) 뒤 구현. 기존 Keyword 쿼리를 바꾸지 않아 하위 호환 확보 (back#136, S15P11A705-209) | ✅ 완료 | [2026-08-03-search-keyword-status.md](2026-08-03-search-keyword-status.md) | -| I18 | Feed 응답 `keywords`에 상한 3·빈도 내림차순(프론트 구두 합의)을 적용. 동점 규칙(축 내 순위, 다음 `preset.id`)은 이 작업의 판단. 실측에서 63%는 빈도가 전부 1이고 잘리는 경계의 92%가 동점이라, 동점 규칙이 화면을 정한다. 표시 정렬은 추천 점수와 분리 (S15P11A705-278) | ✅ 완료 | [2026-08-03-feed-keyword-display-order.md](2026-08-03-feed-keyword-display-order.md) | +| I16 | Feed `keywords`를 `code`에서 `display_name`으로 교체. 점수 계산 키는 `code`로 유지하고, N+1 검증용 쿼리 카운터를 신설 (back#146, Jira 작업) | ✅ 완료 | [2026-08-03-feed-keyword-display-name.md](2026-08-03-feed-keyword-display-name.md) | +| I17 | 검색 응답에 Record 단위 `keywordStatus` 노출. 상태 노출을 금지하던 응답 조립 명세 §5를 실측 근거로 개정하고(back#161) 공용 계약 반영(docs#41) 뒤 구현. 기존 Keyword 쿼리를 바꾸지 않아 하위 호환 확보 (back#136, Jira 작업) | ✅ 완료 | [2026-08-03-search-keyword-status.md](2026-08-03-search-keyword-status.md) | +| I18 | Feed 응답 `keywords`에 상한 3·빈도 내림차순(프론트 구두 합의)을 적용. 동점 규칙(축 내 순위, 다음 `preset.id`)은 이 작업의 판단. 실측에서 63%는 빈도가 전부 1이고 잘리는 경계의 92%가 동점이라, 동점 규칙이 화면을 정한다. 표시 정렬은 추천 점수와 분리 (Jira 작업) | ✅ 완료 | [2026-08-03-feed-keyword-display-order.md](2026-08-03-feed-keyword-display-order.md) | diff --git a/docs/ai/proposals/P42-feed-mvp-without-place-metadata.md b/docs/ai/proposals/P42-feed-mvp-without-place-metadata.md index 57485c39..fb0ec769 100644 --- a/docs/ai/proposals/P42-feed-mvp-without-place-metadata.md +++ b/docs/ai/proposals/P42-feed-mvp-without-place-metadata.md @@ -2,8 +2,8 @@ - **상태**: Accepted - **날짜**: 2026-07-28 -- **관련**: [back#58](https://github.com/Team-PinLog/back/issues/58), S15P11A705-125, - S15P11A705-119(Feed 정책·계약) · S15P11A705-120(Spring 구현) +- **관련**: [back#58](https://github.com/Team-PinLog/back/issues/58), Jira 작업, + Jira 작업(Feed 정책·계약) · Jira 작업(Spring 구현) - **주도(Driver)**: AI 파트 ## 맥락 diff --git a/docs/ai/proposals/P46-feed-keyword-display-order.md b/docs/ai/proposals/P46-feed-keyword-display-order.md index 00203575..383cba64 100644 --- a/docs/ai/proposals/P46-feed-keyword-display-order.md +++ b/docs/ai/proposals/P46-feed-keyword-display-order.md @@ -2,10 +2,10 @@ - **상태**: Accepted - **날짜**: 2026-08-03 -- **관련**: S15P11A705-278, +- **관련**: Jira 작업, [ai#93](https://github.com/Team-PinLog/ai/issues/93)(제기 — 프론트), - [front#88](https://github.com/Team-PinLog/front/issues/88)(S15P11A705-279 책장 레이아웃), - S15P11A705-252([back#146](https://github.com/Team-PinLog/back/pull/146) — 표시값 경계) + [front#88](https://github.com/Team-PinLog/front/issues/88)(Jira 작업 책장 레이아웃), + Jira 작업([back#146](https://github.com/Team-PinLog/back/pull/146) — 표시값 경계) - **주도(Driver)**: AI 파트 (개수·정렬은 프론트 합의, 동점 규칙만 이 문서의 결정) ## 무엇이 이 문서의 결정이고 무엇이 아닌가 @@ -29,8 +29,8 @@ 명세 어디에도 없었다. **티켓의 전제 하나는 사실과 달랐다.** 티켓은 현재 순서를 "SQL `GROUP BY` 결과 순서"로 적었으나, -`FeedService.RankedFeed.keywordsOf`는 `S15P11A705-120`(back#77) 때부터 `.sorted()`를 걸고 있었고 -`S15P11A705-252`(back#146) 이후로는 **표시값(한글) 가나다순**이었다. 순서는 이미 결정적이었다. +`FeedService.RankedFeed.keywordsOf`는 `Jira 작업`(back#77) 때부터 `.sorted()`를 걸고 있었고 +`Jira 작업`(back#146) 이후로는 **표시값(한글) 가나다순**이었다. 순서는 이미 결정적이었다. 그래서 실제 문제는 결정성이 아니었다. **표시 라벨을 한 글자 고치면 카드에 뜨는 Keyword 구성 자체가 바뀐다**는 것이었고, 이것이 동점 규칙을 `preset.id`에 거는 직접적인 근거가 됐다. @@ -110,7 +110,7 @@ Record 1~5건 규모이고 Context 하나가 Keyword를 평균 2개 받아 같 | 최근 부여순 | 기각. `ai.context_keyword`에 시각 컬럼이 없다 | | 프리셋 정의 순서 | 채택안과 같다. `id`가 곧 정의 순서다(yaml의 1xx~4xx 블록) | -`display_name`을 기각한 것이 이 결정의 핵심이다. `S15P11A705-252`(back#146)가 같은 이유로 점수 +`display_name`을 기각한 것이 이 결정의 핵심이다. `Jira 작업`(back#146)가 같은 이유로 점수 계산의 키를 `code`로 못 박았다. 표시값을 기준으로 쓰면 오류도 안 나고 테스트도 안 깨지는데 **라벨을 고친 날 결과만 조용히 바뀐다.** 순서에도 같은 이유가 그대로 적용되며, RED에서 실제로 재현됐다. diff --git a/docs/ai/spec/ai-integration.md b/docs/ai/spec/ai-integration.md index 4788bceb..d779d227 100644 --- a/docs/ai/spec/ai-integration.md +++ b/docs/ai/spec/ai-integration.md @@ -57,9 +57,9 @@ Spring은 이 값을 검색 요청에 실어 보내는 역할만 하며 해석 `@ConfigurationProperties`로 바인딩합니다. -> **정정 기록 (S15P11A705-135, 2026-07-29).** 이 절은 원래 다음 둘이 현재 구현·상위 계약과 어긋나 있었습니다. 백엔드가 발견해 `CLAUDE.md` 9번대로 표시만 남겼고, **AI 파트(중앙)가 그 판정을 받아 수정 권한을 위임해** 같은 PR에서 고쳤습니다. +> **정정 기록 (Jira 작업, 2026-07-29).** 이 절은 원래 다음 둘이 현재 구현·상위 계약과 어긋나 있었습니다. 백엔드가 발견해 `CLAUDE.md` 9번대로 표시만 남겼고, **AI 파트(중앙)가 그 판정을 받아 수정 권한을 위임해** 같은 PR에서 고쳤습니다. > -> 1. **`internal-token: ${PINLOG_AI_INTERNAL_TOKEN}`** → `internal-secret: ${PINLOG_AI_INTERNAL_SECRET:}`. 구현과 `ai` 레포는 처음부터 `secret` 이름을 썼습니다. §7의 헤더 표기는 back#83(S15P11A705-96)이 이미 고쳤으나 **이 설정 키가 남아 있었습니다.** 당시 전수 검색이 `X-Internal[-_]?(Token|Secret)` 패턴이라 헤더만 잡고 설정 키를 놓쳤습니다. `internal.token|INTERNAL_TOKEN|internal-token`으로 넓혀 `back`·`docs`·`ai` 세 레포를 다시 훑었고, 이 줄 외 잔존은 없습니다(`docs/ai/WORKLOG.md`의 back#83 기술은 이력 기록이므로 그대로 둡니다). +> 1. **`internal-token: ${PINLOG_AI_INTERNAL_TOKEN}`** → `internal-secret: ${PINLOG_AI_INTERNAL_SECRET:}`. 구현과 `ai` 레포는 처음부터 `secret` 이름을 썼습니다. §7의 헤더 표기는 back#83(Jira 작업)이 이미 고쳤으나 **이 설정 키가 남아 있었습니다.** 당시 전수 검색이 `X-Internal[-_]?(Token|Secret)` 패턴이라 헤더만 잡고 설정 키를 놓쳤습니다. `internal.token|INTERNAL_TOKEN|internal-token`으로 넓혀 `back`·`docs`·`ai` 세 레포를 다시 훑었고, 이 줄 외 잔존은 없습니다(`docs/ai/WORKLOG.md`의 back#83 기술은 이력 기록이므로 그대로 둡니다). > 2. **"배포 환경의 단일 설정에서 주입합니다" · "값 자체는 코드에 상수로 두지 않습니다"** → 위 본문. 상위 계약 `Team-PinLog/docs` `static/05_AI_설계.md` §7.1이 **2026-07-29에 이 규칙을 뒤집었습니다**(정본이 배포 설정 → 코드, 환경변수는 필수 → 덮어쓰기). > > 함께 고친 것: `base-url`도 리터럴로 적혀 있어 실제 형태(`${PINLOG_AI_BASE_URL:...}`)로 맞췄습니다. 같은 블록에 알면서 틀린 줄을 남기면 back#83의 불완전한 정정을 되풀이하게 됩니다. diff --git a/docs/ai/spec/ai-response-assembly.md b/docs/ai/spec/ai-response-assembly.md index 36536085..238e51e5 100644 --- a/docs/ai/spec/ai-response-assembly.md +++ b/docs/ai/spec/ai-response-assembly.md @@ -187,8 +187,8 @@ Record 단위 접기 규칙입니다. 한 Record에는 활성 Context가 여럿 근거는 *"내부 처리 상태는 사용자 관심사가 아니다"*였고, 그것은 **처리가 짧게 끝난다는 가정** 위에 서 있었습니다. 실측이 그 가정을 벗어났습니다. ```text -S15P11A705-121·-197 GMS 판정이 분당 약 2건만 통과한다 (429) -S15P11A705-198 PROCESSING 잔류 + PROCESSING_EXPIRY_SEC 600초로 +Jira 작업·-197 GMS 판정이 분당 약 2건만 통과한다 (429) +Jira 작업 PROCESSING 잔류 + PROCESSING_EXPIRY_SEC 600초로 Context 하나가 10분 얼린 사례 ``` diff --git a/docs/ai/spec/feed-event.md b/docs/ai/spec/feed-event.md index 9d8ffa16..e5327cd2 100644 --- a/docs/ai/spec/feed-event.md +++ b/docs/ai/spec/feed-event.md @@ -129,7 +129,7 @@ POST /api/core/v1/feed/events - `member_id`는 요청 본문에서 받지 않습니다. 인증 컨텍스트에서 가져옵니다. 본문으로 받으면 타인 이벤트를 위조할 수 있습니다. - `IMPRESSION`은 이 엔드포인트로 받지 않습니다. 서버가 기록하는 값이므로 클라이언트가 보내면 400으로 거부합니다. -- 배열로 받아 batch INSERT합니다. 이벤트마다 요청을 보내지 않습니다. 배열 크기 상한은 S15P11A705-117이 세운 규약을 따라 `global/common/InputLimits`에 **`FEED_EVENTS_MAX`**라는 이름의 상수로 두고, 값은 `RECORD_IDS_MAX`·`CursorPage.MAX_SIZE`와 같은 **100**입니다. 요청 배열마다 상한을 따로 정하면 "서버 방어 상한이 얼마인가"에 답이 여러 개가 되기 때문입니다. 이름은 기존 `RECORD_IDS_MAX` 관례(`<대상 배열>_MAX`)를 그대로 따릅니다. +- 배열로 받아 batch INSERT합니다. 이벤트마다 요청을 보내지 않습니다. 배열 크기 상한은 Jira 작업이 세운 규약을 따라 `global/common/InputLimits`에 **`FEED_EVENTS_MAX`**라는 이름의 상수로 두고, 값은 `RECORD_IDS_MAX`·`CursorPage.MAX_SIZE`와 같은 **100**입니다. 요청 배열마다 상한을 따로 정하면 "서버 방어 상한이 얼마인가"에 답이 여러 개가 되기 때문입니다. 이름은 기존 `RECORD_IDS_MAX` 관례(`<대상 배열>_MAX`)를 그대로 따릅니다. - 상한을 초과한 요청은 `400 INVALID_INPUT`으로 거부합니다. 초과분만 잘라내 저장하지 않습니다. 잘라내 저장하면 클라이언트가 조용한 유실을 인지할 방법이 없기 때문입니다. - `requestId`가 실재하는 Feed Session인지 검증하지 않습니다. 관측 로그이므로 엄격한 검증보다 수집 성공률이 중요합니다. 다만 UUID 형식은 검증합니다. - `collectionId`가 유효하지 않거나 삭제된 Collection이면 해당 이벤트만 조용히 버리고 나머지는 저장합니다. 부분 실패로 전체를 실패시키지 않습니다. diff --git a/docs/ai/spec/feed-recommendation.md b/docs/ai/spec/feed-recommendation.md index 0f871c6b..6bd664da 100644 --- a/docs/ai/spec/feed-recommendation.md +++ b/docs/ai/spec/feed-recommendation.md @@ -156,7 +156,7 @@ Collection이 Record 1~5건 규모이고 Context 하나가 Keyword를 평균 2 빈도가 갈리는 Collection에서 축이 뒤로 밀리는 것은 잃는 것이 아닙니다. 빈도가 갈렸다는 것은 「우연히 쏠린 것」이 아니라 「실제로 그 축에 쏠린 Collection」이라는 뜻입니다. -- **`preset.id`로 푸는 것**은 표시값이 언제든 바뀌기 때문입니다. `display_name` 사전순으로 두면 라벨 한 글자를 고친 날 카드의 Keyword 구성 자체가 바뀝니다(`S15P11A705-252`가 같은 이유로 점수 계산의 키를 `code`로 못 박았습니다). `GROUP BY` 결과 순서는 DB가 보장하지 않으므로 후보가 아닙니다. +- **`preset.id`로 푸는 것**은 표시값이 언제든 바뀌기 때문입니다. `display_name` 사전순으로 두면 라벨 한 글자를 고친 날 카드의 Keyword 구성 자체가 바뀝니다(`Jira 작업`가 같은 이유로 점수 계산의 키를 `code`로 못 박았습니다). `GROUP BY` 결과 순서는 DB가 보장하지 않으므로 후보가 아닙니다. - **축을 그 앞에 두는 것**은 같은 자리를 어떻게 채울지의 문제입니다. 프리셋은 `COMPANION`(누구와)·`ACTIVITY`(무엇을)·`ATMOSPHERE`(어떤 분위기)·`SITUATION`(어떤 상황) 네 축이고, 동점 무리 안에서 한 바퀴 돌리면 카드가 한 문장으로 읽힙니다. `preset.id`만 쓰면 같은 축이 앞자리를 독점할 수 있습니다. `preset.id`만으로 동점을 푸는 경우와 비교하면 상위 3개의 축 커버리지가 **16건 중 5건에서 늘고 한 건도 줄지 않습니다**(다섯 건 모두 2축 → 3축). - **엄격한 「축당 1개」는 쓰지 않습니다.** 3축 이상을 가진 Collection이 10/16이고 나머지 6건은 3축 미만입니다(1축 1건·2축 5건). 못 박으면 그 6건이 가진 것보다 적게 나갑니다. 라운드로빈이라 축이 하나뿐이어도 3칸이 찹니다. @@ -190,7 +190,7 @@ GET /api/core/v1/feed/collections?cursor={opaqueCursor}&size=20 POST /api/core/v1/feed/events ``` -- 한 페이지는 기본 20건입니다. `size`는 공통 커서 계약을 그대로 따릅니다. 기본값은 `CursorPage.DEFAULT_SIZE`(20), 서버 방어 상한은 `CursorPage.MAX_SIZE`(100)이며, 범위 밖 값은 `CursorPage.normalizeSize`가 보정합니다. Feed 전용 상한을 따로 두지 않습니다(S15P11A705-117이 세운 "서버 방어 상한의 답은 하나" 규약). +- 한 페이지는 기본 20건입니다. `size`는 공통 커서 계약을 그대로 따릅니다. 기본값은 `CursorPage.DEFAULT_SIZE`(20), 서버 방어 상한은 `CursorPage.MAX_SIZE`(100)이며, 범위 밖 값은 `CursorPage.normalizeSize`가 보정합니다. Feed 전용 상한을 따로 두지 않습니다(Jira 작업이 세운 "서버 방어 상한의 답은 하나" 규약). - `requestId`는 Feed Session 식별자이며 응답 본문과 CLICK·SAVE 이벤트 payload의 별도 필드입니다. - `cursor`는 내부 구조를 노출하지 않는 opaque 문자열입니다. 서버는 cursor로 같은 Session의 다음 위치를 복원해 페이지 간 중복·누락을 방지합니다. - 후보 풀 자체는 Session 단위로 Redis에 짧게 보관합니다. 매 페이지마다 후보를 다시 생성하면 정렬이 흔들립니다. diff --git a/docs/ai/spec/feed-tests.md b/docs/ai/spec/feed-tests.md index ea79b7ee..a4e974ed 100644 --- a/docs/ai/spec/feed-tests.md +++ b/docs/ai/spec/feed-tests.md @@ -169,7 +169,7 @@ A10은 눈에 띄지 않지만 누락 시 재스캔 만료 판정 전체가 오 ## 11. Feed API 계약 (통합) -S15P11A705-125에서 못박은 요청·응답 계약입니다. 명세에만 두면 S15P11A705-120 구현에서 빠지므로 테스트로 고정합니다. +Jira 작업에서 못박은 요청·응답 계약입니다. 명세에만 두면 Jira 작업 구현에서 빠지므로 테스트로 고정합니다. | # | 항목 | 기대 | |---|---|---| @@ -182,11 +182,11 @@ S15P11A705-125에서 못박은 요청·응답 계약입니다. 명세에만 두 | Q7 | Feed 응답 본문의 `requestId` | `data` 안의 **별도 필드**. cursor에 인코딩하거나 항목마다 반복하지 않음 | | Q8 | 응답의 `requestId`를 CLICK·SAVE payload로 되돌려 보냄 | 같은 값으로 `core.feed_event.request_id`에 저장됨 | -Q1~Q3은 Feed가 공통 커서 계약을 그대로 쓴다는 확인입니다. Feed 컨트롤러가 자체 상한을 도입하면 실패해야 합니다. 그것이 S15P11A705-117이 세운 "서버 방어 상한의 답은 하나" 규약이 깨지는 지점입니다. Q5는 응답을 문자열로 파싱해 내부 식별자가 노출되지 않음을 단언합니다. +Q1~Q3은 Feed가 공통 커서 계약을 그대로 쓴다는 확인입니다. Feed 컨트롤러가 자체 상한을 도입하면 실패해야 합니다. 그것이 Jira 작업이 세운 "서버 방어 상한의 답은 하나" 규약이 깨지는 지점입니다. Q5는 응답을 문자열로 파싱해 내부 식별자가 노출되지 않음을 단언합니다. ## 12. 표시 Keyword 선정과 정렬 -S15P11A705-278([P46](../proposals/P46-feed-keyword-display-order.md))이 정한 규칙입니다. KW1~KW7은 순수 자바 단위 테스트이고 KW8~KW11은 통합입니다. +Jira 작업([P46](../proposals/P46-feed-keyword-display-order.md))이 정한 규칙입니다. KW1~KW7은 순수 자바 단위 테스트이고 KW8~KW11은 통합입니다. 정렬 키는 `(빈도 DESC, 축 내 순위 ASC, preset id ASC)`이며 **키마다 출처가 다릅니다.** 개수 3과 빈도 내림차순은 프론트와의 구두 합의(2026-08-03)이고, 동점 규칙은 back AI 파트의 판단입니다. @@ -206,6 +206,6 @@ S15P11A705-278([P46](../proposals/P46-feed-keyword-display-order.md))이 정한 **KW2와 KW3을 함께 봐야 축의 자리가 드러납니다.** 축은 **같은 빈도 안에서만** 일하고 빈도를 뒤집지 않습니다(KW2). 그런데 실측 16건 중 10건(63%)이 모든 Keyword의 빈도가 1이고, 상한 3에서 자를 필요가 있는 12건 중 11건이 3위·4위 동점이라 잘리는 자리는 대부분 축이 정합니다(KW3). 한쪽만 검증하면 구현이 어느 쪽으로도 흘러갑니다. 특히 **축을 전역 1순위로 올리는 구현은 빈도가 전부 같은 데이터에서 결과가 똑같아** KW3만으로는 걸리지 않습니다. -KW11은 `S15P11A705-252`가 점수 계산의 키를 `code`로 못 박은 것과 같은 이유입니다. 표시값을 동점 규칙으로 삼으면 라벨을 고친 날 카드 구성이 조용히 바뀌고, 오류도 안 나고 다른 테스트도 안 깨집니다. +KW11은 `Jira 작업`가 점수 계산의 키를 `code`로 못 박은 것과 같은 이유입니다. 표시값을 동점 규칙으로 삼으면 라벨을 고친 날 카드 구성이 조용히 바뀌고, 오류도 안 나고 다른 테스트도 안 깨집니다. KW6·KW7은 "자리를 비우지 않는다"가 계약이라는 확인입니다. 필터를 자르기 **뒤에** 두면 3개를 요청했는데 2개가 나가고, 증상이 데이터에 따라 산발적으로만 나타납니다. diff --git a/docs/backend/WORKLOG.md b/docs/backend/WORKLOG.md index 33f5f80a..a6ab9f7c 100644 --- a/docs/backend/WORKLOG.md +++ b/docs/backend/WORKLOG.md @@ -9,88 +9,88 @@ | 날짜 | 작업 | 관련 문서 | |---|---|---| | 2026-07-23 | H2 제거 + Testcontainers(PostgreSQL/pgvector) 마이그레이션 검증 전환 (back#12) | [BD-01](decisions/BD-01-h2-removal-testcontainers.md) · [BI-01](implements/BI-01-2026-07-23-postgres-testcontainers-migration-tests.md) | -| 2026-07-26 | 백엔드 파트 문서 체계 신설 — spec/decisions/implements/troubleshooting + WORKLOG (S15P11A705-38) | 이 트리 전체 | -| 2026-07-27 | (Task 1) `core.member` V2 마이그레이션 추가 — id·created_at·deleted_at 3컬럼, 활성 회원 부분 인덱스 (`e755b1e`, S15P11A705-41) | [BI-02](implements/BI-02-2026-07-27-member-base-entity-soft-delete.md) | -| 2026-07-27 | (Task 2) `BaseEntity`(created_at·deleted_at 공유, updated_at 없음) + `JpaAuditingConfig` + `Member`/`MemberRepository` 구현 (`0f3c4d6`, S15P11A705-41) | [BD-02](decisions/BD-02-base-entity-common-columns.md) · [BI-02](implements/BI-02-2026-07-27-member-base-entity-soft-delete.md) | -| 2026-07-27 | (Task 3) Member soft delete 검증 테스트 작성 — `repository.delete()`가 물리 삭제 대신 `deleted_at`을 기록하고 조회에서 제외됨을 확인, raw SQL로 영속된 `deleted_at` 검증을 강화 (`d1204bf`, `02fcaa7`, S15P11A705-41) | [BI-02](implements/BI-02-2026-07-27-member-base-entity-soft-delete.md) | -| 2026-07-27 | (Task 4) `./gradlew clean check` 전체 게이트를 처음 실행해 공유 Testcontainers 컨테이너가 클래스마다 재시작되던 버그를 발견·수정(싱글톤 컨테이너 패턴, `6c61272`) + 로컬 프로파일(`application-local.yml`) 추가 + `database-conventions.md` 공통 컬럼 규약, `configuration.md` 값 정렬(`1acd804`, `67bcd11`) (S15P11A705-41) | [BD-02](decisions/BD-02-base-entity-common-columns.md) · [BT-01](troubleshooting/BT-01-shared-testcontainers-lifecycle.md) | -| 2026-07-27 | (Task 5) BaseEntity 공통 컬럼 결정·구현 리포트·Testcontainers 트러블슈팅 기록 (S15P11A705-41) | [BD-02](decisions/BD-02-base-entity-common-columns.md) · [BI-02](implements/BI-02-2026-07-27-member-base-entity-soft-delete.md) · [BT-01](troubleshooting/BT-01-shared-testcontainers-lifecycle.md) | -| 2026-07-27 | (Task 1) `ApiResponse` 공통 envelope 레코드 추가, 운영 Jackson 3 매퍼로 JSON 검증 (`a45475a`, `93f4ad6`, S15P11A705-53) | [BD-03](decisions/BD-03-api-response-envelope.md) · [BI-03](implements/BI-03-2026-07-27-api-response-envelope.md) | -| 2026-07-27 | (Task 2) `ApiResponseBodyAdvice`로 `domain` 패키지 컨트롤러 응답 자동 감싸기, actuator·springdoc 제외 (`fe52533`, `86b0dd6`, S15P11A705-53) | [BD-03](decisions/BD-03-api-response-envelope.md) · [BI-03](implements/BI-03-2026-07-27-api-response-envelope.md) | -| 2026-07-27 | (Task 3) `GlobalExceptionHandler` 네 분기가 `ApiResponse.fail(error)` 반환하도록 전환, 상태·코드·로그 레벨 변경 없음 (`a1cd90f`, S15P11A705-53) | [BD-03](decisions/BD-03-api-response-envelope.md) · [BI-03](implements/BI-03-2026-07-27-api-response-envelope.md) | -| 2026-07-27 | (Task 4) 규약 문서(`api-conventions.md`·`error-handling.md`) 갱신 + BD-03·BI-03 기록 + `clean check` 전체 게이트 통과 (`c1ab841`, `e43812d`, S15P11A705-53) | [BD-03](decisions/BD-03-api-response-envelope.md) · [BI-03](implements/BI-03-2026-07-27-api-response-envelope.md) | -| 2026-07-27 | (Task 5) `ApiResponseOpenApiCustomizer`(springdoc `OperationCustomizer`)로 `/v3/api-docs` 2xx 스키마에도 envelope 반영, BD-03·BI-03 정정 (S15P11A705-53) | [BD-03](decisions/BD-03-api-response-envelope.md) · [BI-03](implements/BI-03-2026-07-27-api-response-envelope.md) | -| 2026-07-27 | (Task 1) `global/response/Cursor` 추가 — `Base64(정렬키,id)` URL-safe·무패딩 인코딩, 디코딩은 마지막 `,` 기준 분할(뮤테이션 검사로 고정), 잘못된 커서는 `InvalidCursorException`(`INVALID_INPUT`) (`377d006`, `14888f2`, S15P11A705-42) | [BD-04](decisions/BD-04-cursor-pagination.md) · [BI-04](implements/BI-04-2026-07-27-cursor-pagination.md) | -| 2026-07-27 | (Task 2) `global/response/CursorPage` 추가 — `items`·`nextCursor`·`hasNext`, `DEFAULT_SIZE`(20)·`MAX_SIZE`(100)·`normalizeSize`, `@JsonInclude` 미적용으로 마지막 페이지 `nextCursor:null` 명시 노출 (`72b2899`, S15P11A705-42) | [BD-04](decisions/BD-04-cursor-pagination.md) · [BI-04](implements/BI-04-2026-07-27-cursor-pagination.md) | -| 2026-07-27 | (Task 3) 커서 페이지네이션 규약을 `api-conventions.md`에 반영 + BD-04·BI-04 기록 + `clean check` 전체 게이트 통과 (S15P11A705-42) | [BD-04](decisions/BD-04-cursor-pagination.md) · [BI-04](implements/BI-04-2026-07-27-cursor-pagination.md) | -| 2026-07-27 | (리뷰 반영) `Cursor.decode(null)` 500 버그 수정, `sortKeyAsInstant()` 접근자 추가, `normalizeSize(MAX_SIZE)` 경계 테스트 보강(`b29149d`) + BD-04 §1.6 충돌 기록·BI-04 `@JsonInclude` 근거 정정·`api-conventions.md` 존댓말 통일(S15P11A705-42) | [BD-04](decisions/BD-04-cursor-pagination.md) · [BI-04](implements/BI-04-2026-07-27-cursor-pagination.md) | -| 2026-07-27 | SIGTERM graceful shutdown 활성화(기본값 `immediate`였음) + 종료 타임아웃 20s를 k8s `terminationGracePeriodSeconds` 40s와 짝지어 결정. liveness·readiness probe 계약 테스트 추가, `database-conventions.md`에 backward-compatible migration 규약 신설 (S15P11A705-51) | [BD-05](decisions/BD-05-graceful-shutdown-timing.md) · [BI-05](implements/BI-05-2026-07-27-graceful-shutdown.md) | -| 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) (S15P11A705-51) | [BI-06](implements/BI-06-2026-07-27-deployment-contract-verification.md) · [BT-02](troubleshooting/BT-02-flyway-out-of-order-version-ranges.md) · [BT-03](troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md) | -| 2026-07-27 | (Task 1) `ErrorCode`에 `METHOD_NOT_ALLOWED`(405)·`UNSUPPORTED_MEDIA_TYPE`(415) 추가 (`1911dc9`, S15P11A705-40) | [BD-06](decisions/BD-06-framework-error-mapping.md) · [BI-07](implements/BI-07-2026-07-27-framework-error-mapping.md) | -| 2026-07-27 | (Task 2) `GlobalExceptionHandler`가 `ResponseEntityExceptionHandler`를 상속하도록 전환, `handleExceptionInternal`에서 body만 envelope로 교체해 malformed JSON·405·415·파라미터 누락이 500 대신 자체 상태로 응답(`cae552e`, `7e31e02`, S15P11A705-40) | [BD-06](decisions/BD-06-framework-error-mapping.md) · [BI-07](implements/BI-07-2026-07-27-framework-error-mapping.md) | -| 2026-07-27 | (Task 3) `error-handling.md` 프레임워크 예외 상태 매핑 표 갱신 + BD-06·BI-07 기록 + `clean check` 전체 게이트 통과(S15P11A705-40) | [BD-06](decisions/BD-06-framework-error-mapping.md) · [BI-07](implements/BI-07-2026-07-27-framework-error-mapping.md) | -| 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에 기록했다(정정은 후속). (S15P11A705-76) | [decisions/](decisions/) 전체 · [BD-07](decisions/BD-07-context-immutability.md) · [BD-09](decisions/BD-09-no-record-update-path.md) | -| 2026-07-27 | 프론트 요구(최초 작성 시각 보존·오래된순 정렬)로 BD-07 재검토 트리거 발동 — `origin_created_at` 도입을 BD-25로 결정, 공용 계약(06 §2.5·07·08·05-1) 선반영([docs#16](https://github.com/Team-PinLog/docs/pull/16), #15 위 스택). 구현은 S15P11A705-66에서 | [BD-25](decisions/BD-25-context-origin-created-at.md) · [BD-07](decisions/BD-07-context-immutability.md) | +| 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](implements/BI-02-2026-07-27-member-base-entity-soft-delete.md) | +| 2026-07-27 | (Task 2) `BaseEntity`(created_at·deleted_at 공유, updated_at 없음) + `JpaAuditingConfig` + `Member`/`MemberRepository` 구현 (`0f3c4d6`, Jira 작업) | [BD-02](decisions/BD-02-base-entity-common-columns.md) · [BI-02](implements/BI-02-2026-07-27-member-base-entity-soft-delete.md) | +| 2026-07-27 | (Task 3) Member soft delete 검증 테스트 작성 — `repository.delete()`가 물리 삭제 대신 `deleted_at`을 기록하고 조회에서 제외됨을 확인, raw SQL로 영속된 `deleted_at` 검증을 강화 (`d1204bf`, `02fcaa7`, Jira 작업) | [BI-02](implements/BI-02-2026-07-27-member-base-entity-soft-delete.md) | +| 2026-07-27 | (Task 4) `./gradlew clean check` 전체 게이트를 처음 실행해 공유 Testcontainers 컨테이너가 클래스마다 재시작되던 버그를 발견·수정(싱글톤 컨테이너 패턴, `6c61272`) + 로컬 프로파일(`application-local.yml`) 추가 + `database-conventions.md` 공통 컬럼 규약, `configuration.md` 값 정렬(`1acd804`, `67bcd11`) (Jira 작업) | [BD-02](decisions/BD-02-base-entity-common-columns.md) · [BT-01](troubleshooting/BT-01-shared-testcontainers-lifecycle.md) | +| 2026-07-27 | (Task 5) BaseEntity 공통 컬럼 결정·구현 리포트·Testcontainers 트러블슈팅 기록 (Jira 작업) | [BD-02](decisions/BD-02-base-entity-common-columns.md) · [BI-02](implements/BI-02-2026-07-27-member-base-entity-soft-delete.md) · [BT-01](troubleshooting/BT-01-shared-testcontainers-lifecycle.md) | +| 2026-07-27 | (Task 1) `ApiResponse` 공통 envelope 레코드 추가, 운영 Jackson 3 매퍼로 JSON 검증 (`a45475a`, `93f4ad6`, Jira 작업) | [BD-03](decisions/BD-03-api-response-envelope.md) · [BI-03](implements/BI-03-2026-07-27-api-response-envelope.md) | +| 2026-07-27 | (Task 2) `ApiResponseBodyAdvice`로 `domain` 패키지 컨트롤러 응답 자동 감싸기, actuator·springdoc 제외 (`fe52533`, `86b0dd6`, Jira 작업) | [BD-03](decisions/BD-03-api-response-envelope.md) · [BI-03](implements/BI-03-2026-07-27-api-response-envelope.md) | +| 2026-07-27 | (Task 3) `GlobalExceptionHandler` 네 분기가 `ApiResponse.fail(error)` 반환하도록 전환, 상태·코드·로그 레벨 변경 없음 (`a1cd90f`, Jira 작업) | [BD-03](decisions/BD-03-api-response-envelope.md) · [BI-03](implements/BI-03-2026-07-27-api-response-envelope.md) | +| 2026-07-27 | (Task 4) 규약 문서(`api-conventions.md`·`error-handling.md`) 갱신 + BD-03·BI-03 기록 + `clean check` 전체 게이트 통과 (`c1ab841`, `e43812d`, Jira 작업) | [BD-03](decisions/BD-03-api-response-envelope.md) · [BI-03](implements/BI-03-2026-07-27-api-response-envelope.md) | +| 2026-07-27 | (Task 5) `ApiResponseOpenApiCustomizer`(springdoc `OperationCustomizer`)로 `/v3/api-docs` 2xx 스키마에도 envelope 반영, BD-03·BI-03 정정 (Jira 작업) | [BD-03](decisions/BD-03-api-response-envelope.md) · [BI-03](implements/BI-03-2026-07-27-api-response-envelope.md) | +| 2026-07-27 | (Task 1) `global/response/Cursor` 추가 — `Base64(정렬키,id)` URL-safe·무패딩 인코딩, 디코딩은 마지막 `,` 기준 분할(뮤테이션 검사로 고정), 잘못된 커서는 `InvalidCursorException`(`INVALID_INPUT`) (`377d006`, `14888f2`, Jira 작업) | [BD-04](decisions/BD-04-cursor-pagination.md) · [BI-04](implements/BI-04-2026-07-27-cursor-pagination.md) | +| 2026-07-27 | (Task 2) `global/response/CursorPage` 추가 — `items`·`nextCursor`·`hasNext`, `DEFAULT_SIZE`(20)·`MAX_SIZE`(100)·`normalizeSize`, `@JsonInclude` 미적용으로 마지막 페이지 `nextCursor:null` 명시 노출 (`72b2899`, Jira 작업) | [BD-04](decisions/BD-04-cursor-pagination.md) · [BI-04](implements/BI-04-2026-07-27-cursor-pagination.md) | +| 2026-07-27 | (Task 3) 커서 페이지네이션 규약을 `api-conventions.md`에 반영 + BD-04·BI-04 기록 + `clean check` 전체 게이트 통과 (Jira 작업) | [BD-04](decisions/BD-04-cursor-pagination.md) · [BI-04](implements/BI-04-2026-07-27-cursor-pagination.md) | +| 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](decisions/BD-04-cursor-pagination.md) · [BI-04](implements/BI-04-2026-07-27-cursor-pagination.md) | +| 2026-07-27 | SIGTERM graceful shutdown 활성화(기본값 `immediate`였음) + 종료 타임아웃 20s를 k8s `terminationGracePeriodSeconds` 40s와 짝지어 결정. liveness·readiness probe 계약 테스트 추가, `database-conventions.md`에 backward-compatible migration 규약 신설 (Jira 작업) | [BD-05](decisions/BD-05-graceful-shutdown-timing.md) · [BI-05](implements/BI-05-2026-07-27-graceful-shutdown.md) | +| 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](implements/BI-06-2026-07-27-deployment-contract-verification.md) · [BT-02](troubleshooting/BT-02-flyway-out-of-order-version-ranges.md) · [BT-03](troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md) | +| 2026-07-27 | (Task 1) `ErrorCode`에 `METHOD_NOT_ALLOWED`(405)·`UNSUPPORTED_MEDIA_TYPE`(415) 추가 (`1911dc9`, Jira 작업) | [BD-06](decisions/BD-06-framework-error-mapping.md) · [BI-07](implements/BI-07-2026-07-27-framework-error-mapping.md) | +| 2026-07-27 | (Task 2) `GlobalExceptionHandler`가 `ResponseEntityExceptionHandler`를 상속하도록 전환, `handleExceptionInternal`에서 body만 envelope로 교체해 malformed JSON·405·415·파라미터 누락이 500 대신 자체 상태로 응답(`cae552e`, `7e31e02`, Jira 작업) | [BD-06](decisions/BD-06-framework-error-mapping.md) · [BI-07](implements/BI-07-2026-07-27-framework-error-mapping.md) | +| 2026-07-27 | (Task 3) `error-handling.md` 프레임워크 예외 상태 매핑 표 갱신 + BD-06·BI-07 기록 + `clean check` 전체 게이트 통과(Jira 작업) | [BD-06](decisions/BD-06-framework-error-mapping.md) · [BI-07](implements/BI-07-2026-07-27-framework-error-mapping.md) | +| 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/](decisions/) 전체 · [BD-07](decisions/BD-07-context-immutability.md) · [BD-09](decisions/BD-09-no-record-update-path.md) | +| 2026-07-27 | 프론트 요구(최초 작성 시각 보존·오래된순 정렬)로 BD-07 재검토 트리거 발동 — `origin_created_at` 도입을 BD-25로 결정, 공용 계약(06 §2.5·07·08·05-1) 선반영([docs#16](https://github.com/Team-PinLog/docs/pull/16), #15 위 스택). 구현은 Jira 작업에서 | [BD-25](decisions/BD-25-context-origin-created-at.md) · [BD-07](decisions/BD-07-context-immutability.md) | | 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](decisions/BD-21-auth-token-model.md) · [BD-22](decisions/BD-22-signup-commit-point.md) · [인증](../development/authentication.md) · [API](../development/api-conventions.md) · [에러](../development/error-handling.md) · [설정](../development/configuration.md) · [패키지](../development/package-structure.md) | -| 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 소유라 스키마 분리가 완전하지 않은 점을 재검토 트리거로 남겼다 (S15P11A705-86) | [BD-26](decisions/BD-26-flyway-out-of-order.md) · [BT-02](troubleshooting/BT-02-flyway-out-of-order-version-ranges.md) | -| 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)로 거짓 위반을 재현하고 새 로직에서 사라지는 것, 진짜 수정은 여전히 차단되는 것을 둘 다 확인했다 (S15P11A705-88) | [CONTRIBUTING](../../CONTRIBUTING.md) "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가 부가 정보라서다 (S15P11A705-87) | [로깅 규약](../development/logging.md) "들어온 traceId는 검증합니다" | -| 2026-07-28 | 설정 두 건. ① `open-in-view`가 미설정이라 기본값 `true`였다 — 서비스 계층 밖에서 지연 로딩이 열려 도메인이 붙으면 컨트롤러에서 N+1이 조용히 생긴다. 엔티티가 `member` 하나뿐인 지금이 가장 싸서 껐다. ② `application-prod.yml`에 datasource·redis가 없어 **어디에 접속하는지가 저장소만 봐서는 확인되지 않았다** — 인프라가 넣어 주는 환경변수에 암묵적으로 의존하는 상태였다. infra 계약(5장)대로 주소·계정을 파일에 적고 `DB_PASSWORD`만 주입으로 남겼다. 주소를 환경변수로 빼지 않은 이유는 비밀이 아니면서 출처 추적이 가능해야 하기 때문이다. `ConfigurationContractTests`가 두 설정을 파일 수준에서 감시한다(운영 주소는 클러스터 DNS라 컨텍스트를 띄우면 접속 실패하므로 YAML을 직접 읽는다). 경고가 실제로 사라졌는지는 앱을 띄워 확인했다 (S15P11A705-89) | [설정 규약](../development/configuration.md) | -| 2026-07-28 | soft delete 경로가 둘(`softDelete()` / `@SQLDelete`)인데 어느 쪽이 정식인지 규약에 없어, 엔티티가 늘면 호출부마다 갈릴 상태였다. **골라 쓰는 게 아니라 역할이 다르다**로 정리했다 — `softDelete()`가 정식 경로이고 `@SQLDelete`는 실수로 물리 삭제가 나가는 것을 막는 안전망이다. 정식 경로로 정한 근거는 호출 직후 메모리 엔티티가 이미 삭제 상태로 보인다는 것이고, `repository.delete()`는 DB 행만 갱신해 같은 인스턴스가 `isDeleted() == false`를 반환한다(그 인스턴스로 판단하는 코드가 조용히 틀린다). 이 **대조가 테스트로 없었어서** 두 경로를 각각 고정했다. `@SQLDelete`를 떼지 않는 이유는 그것이 유일한 안전망이라서다 — 떼면 `delete()`·cascade가 물리 삭제로 돌아간다. 시각 기준이 경로에 따라 갈리는 것은 감수하고 근거를 적었다. 운영 코드 변경은 없다 (S15P11A705-90) | [데이터베이스 규약](../development/database-conventions.md) "삭제하는 방법은 softDelete()입니다" | +| 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](decisions/BD-26-flyway-out-of-order.md) · [BT-02](troubleshooting/BT-02-flyway-out-of-order-version-ranges.md) | +| 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](../../CONTRIBUTING.md) "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 작업) | [로깅 규약](../development/logging.md) "들어온 traceId는 검증합니다" | +| 2026-07-28 | 설정 두 건. ① `open-in-view`가 미설정이라 기본값 `true`였다 — 서비스 계층 밖에서 지연 로딩이 열려 도메인이 붙으면 컨트롤러에서 N+1이 조용히 생긴다. 엔티티가 `member` 하나뿐인 지금이 가장 싸서 껐다. ② `application-prod.yml`에 datasource·redis가 없어 **어디에 접속하는지가 저장소만 봐서는 확인되지 않았다** — 인프라가 넣어 주는 환경변수에 암묵적으로 의존하는 상태였다. infra 계약(5장)대로 주소·계정을 파일에 적고 `DB_PASSWORD`만 주입으로 남겼다. 주소를 환경변수로 빼지 않은 이유는 비밀이 아니면서 출처 추적이 가능해야 하기 때문이다. `ConfigurationContractTests`가 두 설정을 파일 수준에서 감시한다(운영 주소는 클러스터 DNS라 컨텍스트를 띄우면 접속 실패하므로 YAML을 직접 읽는다). 경고가 실제로 사라졌는지는 앱을 띄워 확인했다 (Jira 작업) | [설정 규약](../development/configuration.md) | +| 2026-07-28 | soft delete 경로가 둘(`softDelete()` / `@SQLDelete`)인데 어느 쪽이 정식인지 규약에 없어, 엔티티가 늘면 호출부마다 갈릴 상태였다. **골라 쓰는 게 아니라 역할이 다르다**로 정리했다 — `softDelete()`가 정식 경로이고 `@SQLDelete`는 실수로 물리 삭제가 나가는 것을 막는 안전망이다. 정식 경로로 정한 근거는 호출 직후 메모리 엔티티가 이미 삭제 상태로 보인다는 것이고, `repository.delete()`는 DB 행만 갱신해 같은 인스턴스가 `isDeleted() == false`를 반환한다(그 인스턴스로 판단하는 코드가 조용히 틀린다). 이 **대조가 테스트로 없었어서** 두 경로를 각각 고정했다. `@SQLDelete`를 떼지 않는 이유는 그것이 유일한 안전망이라서다 — 떼면 `delete()`·cascade가 물리 삭제로 돌아간다. 시각 기준이 경로에 따라 갈리는 것은 감수하고 근거를 적었다. 운영 코드 변경은 없다 (Jira 작업) | [데이터베이스 규약](../development/database-conventions.md) "삭제하는 방법은 softDelete()입니다" | | 2026-07-28 | `authentication.md`에 남아 있던 **envelope opt-out 잔재 2줄**을 걷어냈다. #40의 두 번째 커밋이 "제외 장치는 필요 없다"로 정정하면서 본문과 BD-21은 고쳤는데, 목록 형태로 떨어져 있던 단일 PR 원칙 4번과 완료 조건 체크박스는 못 고치고 넘어갔다. 그 두 줄이 하필 **"하나라도 빠지면 병합하지 않는다"는 게이트**여서, 인증 담당자가 체크리스트를 성실히 따를수록 같은 문서가 금지한 advice 판정 수정으로 끌려가는 상태였다. 문서가 코드와 어긋난 게 아니라 자기 자신과 어긋난 경우다 | [인증 계약](../development/authentication.md) · [BD-21](decisions/BD-21-auth-token-model.md) | -| 2026-07-28 | envelope 판정이 런타임 advice와 문서 생성 커스터마이저 **두 곳에 각자** 있어서 `ResponseEntity>`에서 결론이 갈렸다 — 선언 타입이 `ResponseEntity`라 문서 쪽만 한 번 더 감싸, 문서는 `data.data`를 약속하고 실제 응답은 한 번만 감싼 상태였다. 두 곳을 각각 고치는 대신 판정을 `global/web/EnvelopeTargets` 한 곳으로 옮겨 같은 이유로 또 갈라지는 것을 막았다. advice는 거기에 컨버터 조건만 더하고, `beforeBodyWrite`의 `instanceof` 검사는 선언 타입으로 알 수 없는 경우(`ResponseEntity`)의 최종 방어선으로 남겼다. 같은 케이스의 테스트를 문서 쪽과 런타임 쪽에 각각 두었다 — **두 테스트가 같은 결론을 요구하는 것**이 문서와 실제 응답을 붙여 두는 장치다 (S15P11A705-85) | [API 규약](../development/api-conventions.md) 공통 응답 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에서 도입한다 (S15P11A705-66) | [BI-08](implements/BI-08-2026-07-28-core-domain-foundation.md) | -| 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개 점 사각형까지 테스트로 고정 (S15P11A705-67) | [BI-09](implements/BI-09-2026-07-28-record-context-api.md) | -| 2026-07-28 | Collection API를 냈다. 티켓 본문이 정본 스펙과 두 곳에서 어긋나 있었다 — 내부 정렬(티켓: Record createdAt ASC / 정본: collection_record.created_at DESC)과 중복 추가(티켓: 거절 / 정본: 멱등 스킵). CONTRIBUTING의 "정본 우선" 규칙대로 스펙을 따르고 Jira 댓글로 기록했다. record_count는 Collection 행 잠금 아래에서 연결 변형과 같은 트랜잭션으로 갱신하고, 목록·내부 Record 커서는 첫 페이지와 커서 페이지 쿼리를 분리했다(Instant null 파라미터 타입 추론 회피). 타인 조회는 71 전까지 404 은닉이다 (S15P11A705-68) | [BI-10](implements/BI-10-2026-07-28-collection-api.md) | -| 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이 "제거"라는 의미를 가져 필수 검증을 걸 수 없다 (S15P11A705-69) | [BI-11](implements/BI-11-2026-07-28-follow-library-api.md) | -| 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 스키마 쓰기 연동이 생기는 티켓으로 미뤘다 (S15P11A705-70) | [BI-12](implements/BI-12-2026-07-28-deletion-cascade.md) | -| 2026-07-28 | 타인 조회 공개 범위를 냈다. 소유자용·공개용 DTO를 상속 없이 분리했고(BD-13), 공개 카드의 contexts는 생성자로 받지 않는 고정 null이다 — 명세의 `"contexts": null` wire 계약을 지키면서 본문을 담을 자리 자체를 없애 실수가 컴파일 오류로 실패한다. 공개 조립 경로는 ContextRepository를 호출하지 않는다. 진입 검사(발행·활성·소유자 미탈퇴) 실패는 403이 아니라 404(존재 은닉), follow 별칭은 조회자 자신 것만 나가고, 신원 필드 부재는 응답 문자열 검사로 고정했다. 접근 권한표(명세 12장) 각 행을 테스트로 옮겼다 (S15P11A705-71) | [BI-13](implements/BI-13-2026-07-28-public-scope-filter.md) | -| 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` 실패를 확인해 검증했다 (S15P11A705-103) | [BD-27](decisions/BD-27-coverage-gate-bundle-80.md) | -| 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`가 사라지지 않는 것까지 테스트로 고정했다 (S15P11A705-105) | [BI-14](implements/BI-14-2026-07-28-record-create-conflict-free-insert.md) · [BD-12](decisions/BD-12-duplicate-record-idempotent.md) | -| 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가 부분 유니크 인덱스 이름을 돌려주는지는 가정하지 않고 테스트로 확인했다 (S15P11A705-104) | [BI-15](implements/BI-15-2026-07-28-duplicate-follow-race.md) · [BI-14](implements/BI-14-2026-07-28-record-create-conflict-free-insert.md) | -| 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 재시작으로 번진다 (S15P11A705-106) | [BD-28](decisions/BD-28-readiness-includes-db.md) · [infra#33](https://github.com/Team-PinLog/infra/issues/33) | +| 2026-07-28 | envelope 판정이 런타임 advice와 문서 생성 커스터마이저 **두 곳에 각자** 있어서 `ResponseEntity>`에서 결론이 갈렸다 — 선언 타입이 `ResponseEntity`라 문서 쪽만 한 번 더 감싸, 문서는 `data.data`를 약속하고 실제 응답은 한 번만 감싼 상태였다. 두 곳을 각각 고치는 대신 판정을 `global/web/EnvelopeTargets` 한 곳으로 옮겨 같은 이유로 또 갈라지는 것을 막았다. advice는 거기에 컨버터 조건만 더하고, `beforeBodyWrite`의 `instanceof` 검사는 선언 타입으로 알 수 없는 경우(`ResponseEntity`)의 최종 방어선으로 남겼다. 같은 케이스의 테스트를 문서 쪽과 런타임 쪽에 각각 두었다 — **두 테스트가 같은 결론을 요구하는 것**이 문서와 실제 응답을 붙여 두는 장치다 (Jira 작업) | [API 규약](../development/api-conventions.md) 공통 응답 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](implements/BI-08-2026-07-28-core-domain-foundation.md) | +| 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](implements/BI-09-2026-07-28-record-context-api.md) | +| 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](implements/BI-10-2026-07-28-collection-api.md) | +| 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](implements/BI-11-2026-07-28-follow-library-api.md) | +| 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](implements/BI-12-2026-07-28-deletion-cascade.md) | +| 2026-07-28 | 타인 조회 공개 범위를 냈다. 소유자용·공개용 DTO를 상속 없이 분리했고(BD-13), 공개 카드의 contexts는 생성자로 받지 않는 고정 null이다 — 명세의 `"contexts": null` wire 계약을 지키면서 본문을 담을 자리 자체를 없애 실수가 컴파일 오류로 실패한다. 공개 조립 경로는 ContextRepository를 호출하지 않는다. 진입 검사(발행·활성·소유자 미탈퇴) 실패는 403이 아니라 404(존재 은닉), follow 별칭은 조회자 자신 것만 나가고, 신원 필드 부재는 응답 문자열 검사로 고정했다. 접근 권한표(명세 12장) 각 행을 테스트로 옮겼다 (Jira 작업) | [BI-13](implements/BI-13-2026-07-28-public-scope-filter.md) | +| 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](decisions/BD-27-coverage-gate-bundle-80.md) | +| 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](implements/BI-14-2026-07-28-record-create-conflict-free-insert.md) · [BD-12](decisions/BD-12-duplicate-record-idempotent.md) | +| 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](implements/BI-15-2026-07-28-duplicate-follow-race.md) · [BI-14](implements/BI-14-2026-07-28-record-create-conflict-free-insert.md) | +| 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](decisions/BD-28-readiness-includes-db.md) · [infra#33](https://github.com/Team-PinLog/infra/issues/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](../../CONTRIBUTING.md) · [워크플로우](../development/workflow.md) · [코드 리뷰](../development/code-review.md) | -| 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로 등록했다 (S15P11A705-117) | [BI-16](implements/BI-16-2026-07-28-input-size-limits.md) · [docs#19](https://github.com/Team-PinLog/docs/pull/19) | -| 2026-07-28 | `global/security`를 패키지 단위 `@NullMarked`로 선언 — 표기 없는 파라미터가 Security 7의 non-null 파라미터를 재정의한다는 경고가 근본 원인이라 마킹으로 해결. 감사 중 `SecurityErrorWriter.traceId()`(MDC.get은 null 가능)와 `saveAuthorizationRequest` 파라미터 nullability 넓히기를 바로잡음 (`f026b15`, S15P11A705-63) | [BD-29](decisions/BD-29-nullmarked-security-package.md) | -| 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)에 이미 걸려 있어 새 기록을 만들지 않았다 (S15P11A705-63) | [BD-30](decisions/BD-30-authorization-request-in-cookie.md) | -| 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 없이 흡수했다 (S15P11A705-63) | [BD-31](decisions/BD-31-jwt-rs256-key-management.md) · [BD-21](decisions/BD-21-auth-token-model.md) | -| 2026-07-28 | `OAuthLoginSuccessHandler`의 `TODO(S15P11A705-63)` 한 줄을 채워 **토큰 파이프라인 전체**를 붙였다 — 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` 요청 (S15P11A705-63) | [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) · [BD-31](decisions/BD-31-jwt-rs256-key-management.md) · [인증 계약](../development/authentication.md) | -| 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개 통과 유지 (S15P11A705-63) | [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) | -| 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개 통과 유지 (S15P11A705-63) | [BD-29](decisions/BD-29-nullmarked-security-package.md) · [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) | -| 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개 통과 유지 (S15P11A705-63) | [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) · [패키지 구조](../development/package-structure.md) | -| 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개 통과 유지 (S15P11A705-63) | [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) · [패키지 구조](../development/package-structure.md) | -| 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개 통과 (S15P11A705-63) | [설정 규약](../development/configuration.md) | -| 2026-07-28 | (리팩터) 테스트 컨테이너 기반 클래스 둘(`PostgresContainerSupport`·`PostgresRedisContainerSupport`)을 `IntegrationContainerSupport` 하나로 합쳤다. 담고 있는 것을 이름에 나열하면 **의존이 늘 때마다 클래스 이름과 모든 상속 선언이 따라 바뀐다** — Redis 하나 추가하자마자 이미 두 클래스로 갈라져 있었다. Redis를 안 쓰는 테스트에도 항상 띄우기로 한 이유는 "이 테스트에 Redis가 필요한가"를 매번 판단하지 않기 위해서다. 판단을 틀리면 증상이 엉뚱한 곳에서 나온다 — 실제로 토큰 발급을 붙였을 때 `GoogleLoginCallbackTests`가 그렇게 깨졌다. 대가는 JVM당 컨테이너 하나다. 부수 효과로 **`management.health.redis.enabled=false` 우회를 8곳에서 제거**했다. Redis가 없어서 끄고 있던 것이라 이제 필요 없고, 그만큼 `/actuator/health` 집계가 운영에 가까워졌다. `clean check` 131개 통과 유지 (S15P11A705-63) | [테스트 규약](../development/testing-conventions.md) | -| 2026-07-28 | 테스트 컨테이너 경고 2건 정리. ① Testcontainers 2.x에서 `org.testcontainers.containers.PostgreSQLContainer`가 deprecated이고 `org.testcontainers.postgresql.PostgreSQLContainer`로 옮겨졌다(새 타입은 제네릭 ``가 없어 선언도 짧아진다). 이전 빌드 로그에 계속 뜨던 "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개 통과 (S15P11A705-63) | [테스트 규약](../development/testing-conventions.md) | -| 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개 통과 (S15P11A705-63) | [BT-04](troubleshooting/BT-04-logged-in-cookie-path-unreadable.md) · [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) · [인증 계약](../development/authentication.md) | -| 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`가 실제 인증 위에서 고정한다 (S15P11A705-63) | [인증 계약](../development/authentication.md) · [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) | -| 2026-07-28 | `PATCH /follows/{followId}`에 `alias` 키를 생략하면(`{}`) 별칭이 지워지는 것을 계약으로 고정했다. 요청 DTO가 컴포넌트 하나짜리 record라 Jackson이 "키 없음"과 "명시적 null"을 똑같이 null로 역직렬화하고, 서비스가 그 null을 명세 8.3의 "제거"로 해석한다. 명세는 **명시적 null만** 제거로 정의하고 키 생략은 정의하지 않아서, 정의되지 않은 입력이 사용자 데이터를 지우는 상태였다. `JsonNullable`로 구분하는 안 대신 현행 유지 + 명시를 골랐다 — Follow는 수정 가능한 필드가 alias 하나뿐이라 부분 수정 요청이 나올 이유가 없고, 장치를 먼저 깔면 모든 요청 DTO가 그 비용을 진다. 재검토 신호(필드 여러 개인 PATCH 등장)를 규약에 함께 적었다. **고정 테스트는 처음부터 통과하므로 통과만으로는 증거가 안 된다** — 기각한 대안("null이면 미변경")을 임시로 넣어 테스트가 잡는 것을 확인하고 원복했다(42번의 뮤테이션 검사와 같은 방식). 운영 코드 변경은 없다 (S15P11A705-116) | [BI-17](implements/BI-17-2026-07-28-alias-key-omission-contract.md) · [docs#20](https://github.com/Team-PinLog/docs/pull/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개 통과** (S15P11A705-63) | [BD-32](decisions/BD-32-refresh-reuse-no-family-revocation.md) · [BT-04](troubleshooting/BT-04-logged-in-cookie-path-unreadable.md) · [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) | -| 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개 통과 (S15P11A705-63) | [인증 계약](../development/authentication.md) | -| 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 런타임 구현(S15P11A705-120)은 이 PR 범위가 아니다 (S15P11A705-125) | [BD-33](decisions/BD-33-published-at-database-invariant.md) · [BI-19](implements/BI-19-2026-07-28-published-at-invariant.md) · [back#58](https://github.com/Team-PinLog/back/issues/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` 함의형)와 두 케이스는 그대로다 (S15P11A705-125) | [BI-19](implements/BI-19-2026-07-28-published-at-invariant.md) · [back#75](https://github.com/Team-PinLog/back/pull/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 쿼리 카운터 단언 (S15P11A705-120) | [BI-20](implements/BI-20-2026-07-29-feed-recommendation-mvp.md) · [BD-34](decisions/BD-34-feed-deterministic-pagination-without-session-cache.md) · [P42](../ai/proposals/P42-feed-mvp-without-place-metadata.md) | -| 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` 전량 통과 (S15P11A705-124) | [BI-23](implements/BI-23-2026-07-29-ai-derived-invalidation-on-delete.md) · [BD-37](decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md) · [back#80](https://github.com/Team-PinLog/back/pull/80) | -| 2026-07-29 | 에이전트 하네스(`CLAUDE.md`)를 정리했다. 출발점은 **읽기와 쓰기가 어긋나 있던 것** — 문서화 규칙은 `docs/backend/`에 쓰라고 하는데 읽으라는 지시는 어디에도 없었고, `development/`에서 그쪽으로 가는 링크도 없어서 spec·WORKLOG를 못 본 채 작업하는 구조였다. 그래서 2번을 규약 읽기와 파트 문서 읽기로 갈랐다. **뺀 것 둘**: H2 금지는 `database-conventions.md`가, 빈 패키지·투기적 계층 금지는 `CONTRIBUTING.md`·`package-structure.md`가 이미 authoritative라 항상 로드되는 층에 중복으로 둘 이유가 없다. Security 보류는 S15P11A705-63 병합으로 조건이 충족돼 만료됐다(BD-24의 재검토 트리거가 예고한 그대로). **넣은 것 하나**: Flyway 구간(`V2`~`V99`) — `CONTRIBUTING.md`의 강제 지점 표에서 유일하게 "의도적으로 자동화하지 않음"인 규칙이라 CI 그물이 없고, 구간을 벗어난 파일도 유효한 SQL이라 조용히 통과한다(BT-02로 이미 한 번 물렸다). 충돌 기록 규칙은 "PR에 남긴다"에서 "문서의 충돌 지점에 표시하고 PR에도 남긴다"로 고쳤다 — 실제 관행(`package-structure.md`)이 이미 그랬고, PR 본문은 머지되면 아무도 다시 읽지 않는다. 규칙을 지우고 번호를 당기는 과정에서 **다른 문서 세 곳이 조용히 틀린 규칙을 가리키게 됐다가** 최종 번호가 원래대로 돌아와 저절로 맞았다. 번호로 서로를 참조하는 구조가 깨지기 쉽다는 것이 드러났고, 이름 참조로 바꾸는 것은 후속으로 남긴다. | [BD-24](decisions/BD-24-foundation-reset.md) | -| 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곳이었다 (S15P11A705-102) | [BI-22](implements/BI-22-2026-07-29-context-ai-enqueue.md) | -| 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:` Set) — `SCAN`은 BD-32가 이미 키 공간 비례로 기각했다. **구현하며 셋이 드러났다.** ① 기존 회전 테스트가 재사용 401을 확인한 **뒤에** "회전 후 토큰은 계속 유효하다"를 단언해 정반대 사실을 고정하고 있었다. 지우지 않고 순서를 뒤집었다 — 두 계약을 한 단언에 섞으면 나중에 어느 쪽이 깨졌는지 알 수 없다 ② 폐기 대상에 **회전으로 갓 발급된 토큰**이 들어가야 한다. 유출 시나리오에서 그것이 공격자가 들고 있을 토큰이라, 빼면 폐기가 무의미하다 ③ `SADD`만 하면 Set에 TTL이 없어 **영구 키**가 된다. 발급마다 `EXPIRE`를 다시 걸었다. 과잉 폐기를 잡는 반대 방향 회귀(`normalRotationKeepsOtherSessionsAlive`)도 함께 넣었다 — 계열 폐기가 감지 밖으로 새면 세션 독립성(BD-21)이 조용히 깨진다. **감수하는 것은 오탐이다**: 클라이언트가 같은 토큰을 두 번 보내면 전체 로그아웃이 된다. 서버는 재사용과 유출을 구별할 수 없고 미탐의 대가가 훨씬 크므로 안전한 쪽을 골랐다(방어선은 08 §3.3의 "재발급은 동시에 하나만"). 회원 탈퇴(S15P11A705-65)가 같은 `revokeAll`을 쓴다. `clean check` **295개 통과** (S15P11A705-131) | [BD-35](decisions/BD-35-refresh-reuse-family-revocation.md) · [BD-32](decisions/BD-32-refresh-reuse-no-family-revocation.md) · [BI-21](implements/BI-21-2026-07-29-refresh-reuse-family-revocation.md) | -| 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개 통과** (S15P11A705-131) | [BD-35](decisions/BD-35-refresh-reuse-family-revocation.md) · [BI-21](implements/BI-21-2026-07-29-refresh-reuse-family-revocation.md) | -| 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가 되어 옛 단언이 뒤집혔다 (S15P11A705-102) | [BD-36](decisions/BD-36-pending-insert-in-transaction-process-call-after-commit.md) · [BI-22](implements/BI-22-2026-07-29-context-ai-enqueue.md) | -| 2026-07-29 | 번호 재배정이 남긴 오참조 5곳을 정정했다. back#73이 먼저 머지되며 번호를 가져가 `BD-27`~`BD-29` → `BD-29`~`BD-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(S15P11A705-63): BD-28 — 인가 요청을…`)는 머지된 뒤라 고칠 수 없고 어긋난 채 남는다 (티켓 없음 — 문서 정정) | [BD-29](decisions/BD-29-nullmarked-security-package.md) · [BD-30](decisions/BD-30-authorization-request-in-cookie.md) · [BD-31](decisions/BD-31-jwt-rs256-key-management.md) · [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) | -| 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개 통과** (S15P11A705-147) | [BI-11](implements/BI-11-2026-07-28-follow-library-api.md) · [BI-13](implements/BI-13-2026-07-28-public-scope-filter.md) | -| 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`·`ai` 0건, `back`은 그 한 줄뿐이었다. `clean check` 통과 (S15P11A705-135) | [BD-39](decisions/BD-39-embedding-profile-in-application-config.md) · [BI-25](implements/BI-25-2026-07-29-personal-search-backend-integration.md) | -| 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건 확장이라 개수는 하나만 는다) (S15P11A705-148) | [BD-38](decisions/BD-38-published-predicate-per-layer.md) · [back#84](https://github.com/Team-PinLog/back/issues/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개 통과** (S15P11A705-64) | [BI-24](implements/BI-24-2026-07-29-kakao-naver-login.md) · [BT-05](troubleshooting/BT-05-dotenv-empty-value-overrides-default.md) | -| 2026-07-29 | 운영 런타임 Secret 5개를 `pinlog-secrets-prod` Environment 경계에서만 읽어 SHA 고정 Infra action으로 넘기는 수동 workflow를 추가했다. bridge token을 포함한 참조 6개 집합·최소 권한·`github.sha` checkout/revision은 정적 계약 테스트로 고정했고 일반 `backend-ci`에는 Secret 접근을 추가하지 않았다 (S15P11A705-154) | [BI-26](implements/BI-26-2026-07-29-runtime-secret-workflow.md) | -| 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 포함) (S15P11A705-135) | [BD-39](decisions/BD-39-embedding-profile-in-application-config.md) · [BI-25](implements/BI-25-2026-07-29-personal-search-backend-integration.md) | -| 2026-07-30 | 유실·정지된 AI 처리를 복구하는 재스캔 Scheduler와 FAILED Finalizer를 붙였다(S15P11A705-159). **이 저장소에 스케줄링이 처음 들어온다** — `@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](decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md) · [BI-28](implements/BI-28-2026-07-30-ai-rescan-scheduler.md) · [패키지 구조](../development/package-structure.md) | -| 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개 통과** (S15P11A705-152) | [BI-27](implements/BI-27-2026-07-30-social-login-email-required.md) · [docs#28](https://github.com/Team-PinLog/docs/pull/28) | -| 2026-07-30 | 회원 탈퇴를 구현했다(S15P11A705-65, #34). `DELETE /v1/me` 하나로 소프트 삭제·마스킹·연쇄 삭제·AI 파생 무효화·세션 폐기를 한 트랜잭션에 담는다. **이 공백이 완료된 작업 둘을 미충족으로 붙잡고 있었다** — `S15P11A705-124`가 요구한 AI 무효화 네 지점 중 탈퇴만 비어 있었고(#80이 붙일 서비스가 없어 제외), `S15P11A705-147`이 모아 둔 `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개 통과** (S15P11A705-65) | [BI-29](implements/BI-29-2026-07-30-member-withdrawal.md) · [BD-41](decisions/BD-41-withdrawn-member-check-in-authentication-filter.md) | -| 2026-07-31 | `configuration.md`의 인프라 요청 표를 현행 계약에 맞췄다. back#122가 곁다리로 지목한 낡음 세 건이다 ① *"인프라 계약에 아직 반영되지 않았으므로 배포 전에 요청해야 합니다"* 가 사실이 아니다 — `infra/policy/sealedsecrets/back-prod.yaml`이 허용 키 8개를 규정하고 `back-owner-secrets`로 봉인돼 `envFrom`으로 주입된다(S15P11A705-154) ② **`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](decisions/BD-39-embedding-profile-in-application-config.md) · [configuration](../development/configuration.md) · [back#122](https://github.com/Team-PinLog/back/issues/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개 통과** (S15P11A705-202) | [BD-29](decisions/BD-29-nullmarked-security-package.md) · [error-handling](../development/error-handling.md) · [back#89](https://github.com/Team-PinLog/back/issues/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개 통과** (S15P11A705-201) | [BI-12](implements/BI-12-2026-07-28-deletion-cascade.md) · [back#90](https://github.com/Team-PinLog/back/issues/90) | -| 2026-07-31 | 마이페이지 요약 조회를 구현했다(S15P11A705-200, #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개 통과** (S15P11A705-200) | [BI-30](implements/BI-30-2026-07-31-me-summary.md) | -| 2026-07-31 | 소셜 로그인 진단 로그를 넣었다(S15P11A705-186, #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개 통과** (S15P11A705-186) | [BI-31](implements/BI-31-2026-07-31-login-diagnostics.md) | -| 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개 통과** (S15P11A705-206) | [BD-43](decisions/BD-43-shelf-in-follow-domain-under-collections-path.md) · [BI-35](implements/BI-35-2026-07-31-shelf-browse.md) | -| 2026-07-31 | CI 파이프라인을 빠르게 했다(S15P11A705-231). 실측 배분이 원인을 그대로 줬다 — `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개 통과** (S15P11A705-231) | [BD-42](decisions/BD-42-ci-skip-inside-job-not-paths-ignore.md) · [BI-32](implements/BI-32-2026-07-31-ci-pipeline-speedup.md) · [code-style](../development/code-style.md) | -| 2026-07-31 | 로컬 스택 전체를 상대로 한 API 종단 검증 하네스를 `loadtest/`에 만들었다(S15P11A705-192). 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](implements/BI-33-2026-07-31-api-verification-harness.md) | +| 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](implements/BI-16-2026-07-28-input-size-limits.md) · [docs#19](https://github.com/Team-PinLog/docs/pull/19) | +| 2026-07-28 | `global/security`를 패키지 단위 `@NullMarked`로 선언 — 표기 없는 파라미터가 Security 7의 non-null 파라미터를 재정의한다는 경고가 근본 원인이라 마킹으로 해결. 감사 중 `SecurityErrorWriter.traceId()`(MDC.get은 null 가능)와 `saveAuthorizationRequest` 파라미터 nullability 넓히기를 바로잡음 (`f026b15`, Jira 작업) | [BD-29](decisions/BD-29-nullmarked-security-package.md) | +| 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](decisions/BD-30-authorization-request-in-cookie.md) | +| 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](decisions/BD-31-jwt-rs256-key-management.md) · [BD-21](decisions/BD-21-auth-token-model.md) | +| 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](implements/BI-18-2026-07-28-jwt-cookie-session.md) · [BD-31](decisions/BD-31-jwt-rs256-key-management.md) · [인증 계약](../development/authentication.md) | +| 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](implements/BI-18-2026-07-28-jwt-cookie-session.md) | +| 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](decisions/BD-29-nullmarked-security-package.md) · [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) | +| 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](implements/BI-18-2026-07-28-jwt-cookie-session.md) · [패키지 구조](../development/package-structure.md) | +| 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](implements/BI-18-2026-07-28-jwt-cookie-session.md) · [패키지 구조](../development/package-structure.md) | +| 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 작업) | [설정 규약](../development/configuration.md) | +| 2026-07-28 | (리팩터) 테스트 컨테이너 기반 클래스 둘(`PostgresContainerSupport`·`PostgresRedisContainerSupport`)을 `IntegrationContainerSupport` 하나로 합쳤다. 담고 있는 것을 이름에 나열하면 **의존이 늘 때마다 클래스 이름과 모든 상속 선언이 따라 바뀐다** — Redis 하나 추가하자마자 이미 두 클래스로 갈라져 있었다. Redis를 안 쓰는 테스트에도 항상 띄우기로 한 이유는 "이 테스트에 Redis가 필요한가"를 매번 판단하지 않기 위해서다. 판단을 틀리면 증상이 엉뚱한 곳에서 나온다 — 실제로 토큰 발급을 붙였을 때 `GoogleLoginCallbackTests`가 그렇게 깨졌다. 대가는 JVM당 컨테이너 하나다. 부수 효과로 **`management.health.redis.enabled=false` 우회를 8곳에서 제거**했다. Redis가 없어서 끄고 있던 것이라 이제 필요 없고, 그만큼 `/actuator/health` 집계가 운영에 가까워졌다. `clean check` 131개 통과 유지 (Jira 작업) | [테스트 규약](../development/testing-conventions.md) | +| 2026-07-28 | 테스트 컨테이너 경고 2건 정리. ① Testcontainers 2.x에서 `org.testcontainers.containers.PostgreSQLContainer`가 deprecated이고 `org.testcontainers.postgresql.PostgreSQLContainer`로 옮겨졌다(새 타입은 제네릭 ``가 없어 선언도 짧아진다). 이전 빌드 로그에 계속 뜨던 "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 작업) | [테스트 규약](../development/testing-conventions.md) | +| 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](troubleshooting/BT-04-logged-in-cookie-path-unreadable.md) · [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) · [인증 계약](../development/authentication.md) | +| 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 작업) | [인증 계약](../development/authentication.md) · [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) | +| 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](implements/BI-17-2026-07-28-alias-key-omission-contract.md) · [docs#20](https://github.com/Team-PinLog/docs/pull/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](decisions/BD-32-refresh-reuse-no-family-revocation.md) · [BT-04](troubleshooting/BT-04-logged-in-cookie-path-unreadable.md) · [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) | +| 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 작업) | [인증 계약](../development/authentication.md) | +| 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](decisions/BD-33-published-at-database-invariant.md) · [BI-19](implements/BI-19-2026-07-28-published-at-invariant.md) · [back#58](https://github.com/Team-PinLog/back/issues/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](implements/BI-19-2026-07-28-published-at-invariant.md) · [back#75](https://github.com/Team-PinLog/back/pull/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](implements/BI-20-2026-07-29-feed-recommendation-mvp.md) · [BD-34](decisions/BD-34-feed-deterministic-pagination-without-session-cache.md) · [P42](../ai/proposals/P42-feed-mvp-without-place-metadata.md) | +| 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](implements/BI-23-2026-07-29-ai-derived-invalidation-on-delete.md) · [BD-37](decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md) · [back#80](https://github.com/Team-PinLog/back/pull/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](decisions/BD-24-foundation-reset.md) | +| 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](implements/BI-22-2026-07-29-context-ai-enqueue.md) | +| 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:` Set) — `SCAN`은 BD-32가 이미 키 공간 비례로 기각했다. **구현하며 셋이 드러났다.** ① 기존 회전 테스트가 재사용 401을 확인한 **뒤에** "회전 후 토큰은 계속 유효하다"를 단언해 정반대 사실을 고정하고 있었다. 지우지 않고 순서를 뒤집었다 — 두 계약을 한 단언에 섞으면 나중에 어느 쪽이 깨졌는지 알 수 없다 ② 폐기 대상에 **회전으로 갓 발급된 토큰**이 들어가야 한다. 유출 시나리오에서 그것이 공격자가 들고 있을 토큰이라, 빼면 폐기가 무의미하다 ③ `SADD`만 하면 Set에 TTL이 없어 **영구 키**가 된다. 발급마다 `EXPIRE`를 다시 걸었다. 과잉 폐기를 잡는 반대 방향 회귀(`normalRotationKeepsOtherSessionsAlive`)도 함께 넣었다 — 계열 폐기가 감지 밖으로 새면 세션 독립성(BD-21)이 조용히 깨진다. **감수하는 것은 오탐이다**: 클라이언트가 같은 토큰을 두 번 보내면 전체 로그아웃이 된다. 서버는 재사용과 유출을 구별할 수 없고 미탐의 대가가 훨씬 크므로 안전한 쪽을 골랐다(방어선은 08 §3.3의 "재발급은 동시에 하나만"). 회원 탈퇴(Jira 작업)가 같은 `revokeAll`을 쓴다. `clean check` **295개 통과** (Jira 작업) | [BD-35](decisions/BD-35-refresh-reuse-family-revocation.md) · [BD-32](decisions/BD-32-refresh-reuse-no-family-revocation.md) · [BI-21](implements/BI-21-2026-07-29-refresh-reuse-family-revocation.md) | +| 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](decisions/BD-35-refresh-reuse-family-revocation.md) · [BI-21](implements/BI-21-2026-07-29-refresh-reuse-family-revocation.md) | +| 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](decisions/BD-36-pending-insert-in-transaction-process-call-after-commit.md) · [BI-22](implements/BI-22-2026-07-29-context-ai-enqueue.md) | +| 2026-07-29 | 번호 재배정이 남긴 오참조 5곳을 정정했다. back#73이 먼저 머지되며 번호를 가져가 `BD-27`~`BD-29` → `BD-29`~`BD-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](decisions/BD-29-nullmarked-security-package.md) · [BD-30](decisions/BD-30-authorization-request-in-cookie.md) · [BD-31](decisions/BD-31-jwt-rs256-key-management.md) · [BI-18](implements/BI-18-2026-07-28-jwt-cookie-session.md) | +| 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](implements/BI-11-2026-07-28-follow-library-api.md) · [BI-13](implements/BI-13-2026-07-28-public-scope-filter.md) | +| 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`·`ai` 0건, `back`은 그 한 줄뿐이었다. `clean check` 통과 (Jira 작업) | [BD-39](decisions/BD-39-embedding-profile-in-application-config.md) · [BI-25](implements/BI-25-2026-07-29-personal-search-backend-integration.md) | +| 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](decisions/BD-38-published-predicate-per-layer.md) · [back#84](https://github.com/Team-PinLog/back/issues/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](implements/BI-24-2026-07-29-kakao-naver-login.md) · [BT-05](troubleshooting/BT-05-dotenv-empty-value-overrides-default.md) | +| 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](implements/BI-26-2026-07-29-runtime-secret-workflow.md) | +| 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](decisions/BD-39-embedding-profile-in-application-config.md) · [BI-25](implements/BI-25-2026-07-29-personal-search-backend-integration.md) | +| 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](decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md) · [BI-28](implements/BI-28-2026-07-30-ai-rescan-scheduler.md) · [패키지 구조](../development/package-structure.md) | +| 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](implements/BI-27-2026-07-30-social-login-email-required.md) · [docs#28](https://github.com/Team-PinLog/docs/pull/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](implements/BI-29-2026-07-30-member-withdrawal.md) · [BD-41](decisions/BD-41-withdrawn-member-check-in-authentication-filter.md) | +| 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](decisions/BD-39-embedding-profile-in-application-config.md) · [configuration](../development/configuration.md) · [back#122](https://github.com/Team-PinLog/back/issues/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](decisions/BD-29-nullmarked-security-package.md) · [error-handling](../development/error-handling.md) · [back#89](https://github.com/Team-PinLog/back/issues/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](implements/BI-12-2026-07-28-deletion-cascade.md) · [back#90](https://github.com/Team-PinLog/back/issues/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](implements/BI-30-2026-07-31-me-summary.md) | +| 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](implements/BI-31-2026-07-31-login-diagnostics.md) | +| 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](decisions/BD-43-shelf-in-follow-domain-under-collections-path.md) · [BI-35](implements/BI-35-2026-07-31-shelf-browse.md) | +| 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](decisions/BD-42-ci-skip-inside-job-not-paths-ignore.md) · [BI-32](implements/BI-32-2026-07-31-ci-pipeline-speedup.md) · [code-style](../development/code-style.md) | +| 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](implements/BI-33-2026-07-31-api-verification-harness.md) | diff --git a/docs/backend/decisions/BD-02-base-entity-common-columns.md b/docs/backend/decisions/BD-02-base-entity-common-columns.md index 0b5ede5a..e813df20 100644 --- a/docs/backend/decisions/BD-02-base-entity-common-columns.md +++ b/docs/backend/decisions/BD-02-base-entity-common-columns.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-27 -- **관련**: S15P11A705-41 (`0f3c4d6`), [BI-02](../implements/BI-02-2026-07-27-member-base-entity-soft-delete.md) +- **관련**: Jira 작업 (`0f3c4d6`), [BI-02](../implements/BI-02-2026-07-27-member-base-entity-soft-delete.md) ## 맥락 diff --git a/docs/backend/decisions/BD-03-api-response-envelope.md b/docs/backend/decisions/BD-03-api-response-envelope.md index 9861d4d4..b8b0cc8c 100644 --- a/docs/backend/decisions/BD-03-api-response-envelope.md +++ b/docs/backend/decisions/BD-03-api-response-envelope.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-27 -- **관련**: S15P11A705-53 (`a45475a`, `93f4ad6`, `fe52533`, `86b0dd6`, `a1cd90f`), [BI-03](../implements/BI-03-2026-07-27-api-response-envelope.md), `Team-PinLog/docs` PR #11 +- **관련**: Jira 작업 (`a45475a`, `93f4ad6`, `fe52533`, `86b0dd6`, `a1cd90f`), [BI-03](../implements/BI-03-2026-07-27-api-response-envelope.md), `Team-PinLog/docs` PR #11 > 번호 참고: 열린 PR #25가 아직 `dev`에 머지되지 않은 채 BD-02·BI-02·BT-01을 점유하고 있다. 이 브랜치의 `docs/backend/`에는 BD-02가 보이지 않지만, 번호 충돌을 피하기 위해 이 문서는 BD-03부터 시작한다. diff --git a/docs/backend/decisions/BD-04-cursor-pagination.md b/docs/backend/decisions/BD-04-cursor-pagination.md index e0b3c248..0b9a313b 100644 --- a/docs/backend/decisions/BD-04-cursor-pagination.md +++ b/docs/backend/decisions/BD-04-cursor-pagination.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-27 -- **관련**: S15P11A705-42 (`377d006`, `14888f2`, `72b2899`), [BI-04](../implements/BI-04-2026-07-27-cursor-pagination.md) +- **관련**: Jira 작업 (`377d006`, `14888f2`, `72b2899`), [BI-04](../implements/BI-04-2026-07-27-cursor-pagination.md) ## 맥락 diff --git a/docs/backend/decisions/BD-05-graceful-shutdown-timing.md b/docs/backend/decisions/BD-05-graceful-shutdown-timing.md index fc23cf0b..a91a43aa 100644 --- a/docs/backend/decisions/BD-05-graceful-shutdown-timing.md +++ b/docs/backend/decisions/BD-05-graceful-shutdown-timing.md @@ -2,11 +2,11 @@ - **상태**: Accepted - **날짜**: 2026-07-27 -- **관련**: S15P11A705-51, [BI-05](../implements/BI-05-2026-07-27-graceful-shutdown.md), Infra 연계 S15P11A705-47 +- **관련**: Jira 작업, [BI-05](../implements/BI-05-2026-07-27-graceful-shutdown.md), Infra 연계 Jira 작업 ## 맥락 -S15P11A705-51이 "SIGTERM graceful shutdown과 진행 중 요청 종료를 보장한다"를 요구한다. 확인해보니 `application.yml`에 `server.shutdown` 설정이 없어 기본값 `immediate`로 동작하고 있었다 — SIGTERM을 받으면 진행 중인 요청을 버리고 즉시 종료한다. +Jira 작업이 "SIGTERM graceful shutdown과 진행 중 요청 종료를 보장한다"를 요구한다. 확인해보니 `application.yml`에 `server.shutdown` 설정이 없어 기본값 `immediate`로 동작하고 있었다 — SIGTERM을 받으면 진행 중인 요청을 버리고 즉시 종료한다. 문제는 이게 애플리케이션 설정만으로 완결되지 않는다는 점이다. 무중단이 성립하려면 네 값이 맞물려야 한다. @@ -17,7 +17,7 @@ S15P11A705-51이 "SIGTERM graceful shutdown과 진행 중 요청 종료를 보 | `terminationGracePeriodSeconds` | Deployment 매니페스트 | Infra | | `preStop` hook | Deployment 매니페스트 | Infra | -S15P11A705-51의 제외 범위가 "Kubernetes·Argo CD·Secret delivery·Ingress 구현은 Infra 소유"라고 명시하므로, 백엔드는 앞 두 값만 정하고 뒤 두 값은 숫자를 제안해 넘긴다. +Jira 작업의 제외 범위가 "Kubernetes·Argo CD·Secret delivery·Ingress 구현은 Infra 소유"라고 명시하므로, 백엔드는 앞 두 값만 정하고 뒤 두 값은 숫자를 제안해 넘긴다. `preStop`이 왜 필요한지도 함께 기록한다. Kubernetes는 Pod 종료 시 Endpoints 갱신과 SIGTERM 전송을 **동시에** 시작한다. 두 작업은 순서가 보장되지 않으므로, `preStop` 지연이 없으면 이미 종료를 시작한 Pod로 트래픽이 계속 들어온다. graceful shutdown이 켜져 있어도 새 요청은 거부되므로 502가 난다. diff --git a/docs/backend/decisions/BD-06-framework-error-mapping.md b/docs/backend/decisions/BD-06-framework-error-mapping.md index e5a10dfe..57c24984 100644 --- a/docs/backend/decisions/BD-06-framework-error-mapping.md +++ b/docs/backend/decisions/BD-06-framework-error-mapping.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-27 -- **관련**: S15P11A705-40 (`1911dc9`, `cae552e`, `7e31e02`), [BI-07](../implements/BI-07-2026-07-27-framework-error-mapping.md) +- **관련**: Jira 작업 (`1911dc9`, `cae552e`, `7e31e02`), [BI-07](../implements/BI-07-2026-07-27-framework-error-mapping.md) ## 맥락 diff --git a/docs/backend/decisions/BD-07-context-immutability.md b/docs/backend/decisions/BD-07-context-immutability.md index 8af70775..34c4052c 100644 --- a/docs/backend/decisions/BD-07-context-immutability.md +++ b/docs/backend/decisions/BD-07-context-immutability.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (`Team-PinLog/docs` `c1b2869`) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 · docs `c1b2869`·`1259316`·`eaeae93`·`4541b8a`(M2 A안) +- **관련**: Jira 작업 · docs `c1b2869`·`1259316`·`eaeae93`·`4541b8a`(M2 A안) - **공용 계약**: [05_AI_설계 §4.2·5.3](https://github.com/Team-PinLog/docs/blob/main/static/05_AI_설계.md) · [06_데이터모델_및_무결성 §2.5](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) ## 맥락 diff --git a/docs/backend/decisions/BD-08-soft-delete-no-restore.md b/docs/backend/decisions/BD-08-soft-delete-no-restore.md index 61a95b85..381803fe 100644 --- a/docs/backend/decisions/BD-08-soft-delete-no-restore.md +++ b/docs/backend/decisions/BD-08-soft-delete-no-restore.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (데이터 모델 확립 시점. 단일 커밋으로 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [06_데이터모델_및_무결성 §1.1·§3.1·§7](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) · [02_정책_정의서 §1.5](https://github.com/Team-PinLog/docs/blob/main/static/02_정책_정의서.md) ## 맥락 diff --git a/docs/backend/decisions/BD-09-no-record-update-path.md b/docs/backend/decisions/BD-09-no-record-update-path.md index eba5150f..f6f8803c 100644 --- a/docs/backend/decisions/BD-09-no-record-update-path.md +++ b/docs/backend/decisions/BD-09-no-record-update-path.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-27 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [08_API_명세 §2.3](https://github.com/Team-PinLog/docs/blob/main/static/08_API_명세.md) ## 맥락 diff --git a/docs/backend/decisions/BD-10-integrity-in-database.md b/docs/backend/decisions/BD-10-integrity-in-database.md index a992cdef..1c27b921 100644 --- a/docs/backend/decisions/BD-10-integrity-in-database.md +++ b/docs/backend/decisions/BD-10-integrity-in-database.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (데이터 모델 확립 시점. 단일 커밋으로 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 · `V2__member.sql` +- **관련**: Jira 작업 · `V2__member.sql` - **공용 계약**: [06_데이터모델_및_무결성 §1.2·§1.4·§3·§4](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) ## 맥락 diff --git a/docs/backend/decisions/BD-11-minimum-holding-invariants.md b/docs/backend/decisions/BD-11-minimum-holding-invariants.md index 6b237422..10f916e4 100644 --- a/docs/backend/decisions/BD-11-minimum-holding-invariants.md +++ b/docs/backend/decisions/BD-11-minimum-holding-invariants.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (docs `3e3140b`~`c1b2869` 데이터 모델 확정 시점) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [06_데이터모델_및_무결성 §4.2·6.5~6.8](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) · [08_API_명세 §5.6~5.8·7.6](https://github.com/Team-PinLog/docs/blob/main/static/08_API_명세.md) ## 맥락 diff --git a/docs/backend/decisions/BD-12-duplicate-record-idempotent.md b/docs/backend/decisions/BD-12-duplicate-record-idempotent.md index a2f5efab..7c607fdb 100644 --- a/docs/backend/decisions/BD-12-duplicate-record-idempotent.md +++ b/docs/backend/decisions/BD-12-duplicate-record-idempotent.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-24 (API 상세 명세 확립 시점. 정확한 커밋 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [08_API_명세 §7.5](https://github.com/Team-PinLog/docs/blob/main/static/08_API_명세.md) ## 맥락 diff --git a/docs/backend/decisions/BD-13-public-boundary-query-dto-split.md b/docs/backend/decisions/BD-13-public-boundary-query-dto-split.md index 16afc3ea..c6b5a2ac 100644 --- a/docs/backend/decisions/BD-13-public-boundary-query-dto-split.md +++ b/docs/backend/decisions/BD-13-public-boundary-query-dto-split.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (데이터 모델 확립 시점. 단일 커밋으로 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [06_데이터모델_및_무결성 §5](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) · [08_API_명세 §1.2](https://github.com/Team-PinLog/docs/blob/main/static/08_API_명세.md) ## 맥락 diff --git a/docs/backend/decisions/BD-14-identifier-concealment.md b/docs/backend/decisions/BD-14-identifier-concealment.md index 7b20ec1e..765be693 100644 --- a/docs/backend/decisions/BD-14-identifier-concealment.md +++ b/docs/backend/decisions/BD-14-identifier-concealment.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (데이터 모델 확립 시점. 단일 커밋으로 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [06_데이터모델_및_무결성 §5.3](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) · [08_API_명세 §1.1](https://github.com/Team-PinLog/docs/blob/main/static/08_API_명세.md) ## 맥락 diff --git a/docs/backend/decisions/BD-15-shelf-not-a-table.md b/docs/backend/decisions/BD-15-shelf-not-a-table.md index e983db3b..e2abdf4e 100644 --- a/docs/backend/decisions/BD-15-shelf-not-a-table.md +++ b/docs/backend/decisions/BD-15-shelf-not-a-table.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (데이터 모델 확립 시점. 단일 커밋으로 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [06_데이터모델_및_무결성 §1.4](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) · [03_공식_용어사전](https://github.com/Team-PinLog/docs/blob/main/static/03_공식_용어사전.md) ## 맥락 diff --git a/docs/backend/decisions/BD-16-ai-derived-immediate-purge.md b/docs/backend/decisions/BD-16-ai-derived-immediate-purge.md index 505d3cf2..fc3079bd 100644 --- a/docs/backend/decisions/BD-16-ai-derived-immediate-purge.md +++ b/docs/backend/decisions/BD-16-ai-derived-immediate-purge.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-27 (`Team-PinLog/docs` `a0c20c2` AI 계약 정합성 정정) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [06_데이터모델_및_무결성 §1.1·§1.3·§6.6](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) · [05_AI_설계 §6.5~6.6·§9.4·§11](https://github.com/Team-PinLog/docs/blob/main/static/05_AI_설계.md) ## 맥락 diff --git a/docs/backend/decisions/BD-17-async-without-message-queue.md b/docs/backend/decisions/BD-17-async-without-message-queue.md index 18b0fc3e..53a0e65c 100644 --- a/docs/backend/decisions/BD-17-async-without-message-queue.md +++ b/docs/backend/decisions/BD-17-async-without-message-queue.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (AI 설계 확립 시점. 단일 커밋으로 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [05_AI_설계 §5.1](https://github.com/Team-PinLog/docs/blob/main/static/05_AI_설계.md) · [10_MVP_기능범위 §2](https://github.com/Team-PinLog/docs/blob/main/static/10_MVP_기능범위.md) ## 맥락 diff --git a/docs/backend/decisions/BD-18-keyword-preset-and-visibility.md b/docs/backend/decisions/BD-18-keyword-preset-and-visibility.md index 7488d324..161bbe06 100644 --- a/docs/backend/decisions/BD-18-keyword-preset-and-visibility.md +++ b/docs/backend/decisions/BD-18-keyword-preset-and-visibility.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (AI 설계·데이터 모델 확립 시점. 단일 커밋으로 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [02_정책_정의서 §6](https://github.com/Team-PinLog/docs/blob/main/static/02_정책_정의서.md) · [06_데이터모델_및_무결성 §2.9·§5.4](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) · [05_AI_설계 §4.4](https://github.com/Team-PinLog/docs/blob/main/static/05_AI_설계.md) ## 맥락 diff --git a/docs/backend/decisions/BD-19-place-snapshot.md b/docs/backend/decisions/BD-19-place-snapshot.md index a4089edf..b5bc0956 100644 --- a/docs/backend/decisions/BD-19-place-snapshot.md +++ b/docs/backend/decisions/BD-19-place-snapshot.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (데이터 모델 확립 시점. 단일 커밋으로 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [06_데이터모델_및_무결성 §2.3·§7](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) · [02_정책_정의서 §3](https://github.com/Team-PinLog/docs/blob/main/static/02_정책_정의서.md) ## 맥락 diff --git a/docs/backend/decisions/BD-20-selective-denormalization.md b/docs/backend/decisions/BD-20-selective-denormalization.md index accfc7e1..3b852a46 100644 --- a/docs/backend/decisions/BD-20-selective-denormalization.md +++ b/docs/backend/decisions/BD-20-selective-denormalization.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (데이터 모델 확립 시점. 단일 커밋으로 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [06_데이터모델_및_무결성 §2.5·§2.6·§3.3](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) ## 맥락 diff --git a/docs/backend/decisions/BD-21-auth-token-model.md b/docs/backend/decisions/BD-21-auth-token-model.md index 7323f310..90d6437e 100644 --- a/docs/backend/decisions/BD-21-auth-token-model.md +++ b/docs/backend/decisions/BD-21-auth-token-model.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-27 (`Team-PinLog/docs` `e0ba57b`·`4b0d90f` 쿠키 기반 개정) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [11_인증_설계](https://github.com/Team-PinLog/docs/blob/main/static/11_인증_설계.md) (결정 근거의 원본) · [08_API_명세 §1.1·§1.7·§1.8](https://github.com/Team-PinLog/docs/blob/main/static/08_API_명세.md) ## 맥락 diff --git a/docs/backend/decisions/BD-22-signup-commit-point.md b/docs/backend/decisions/BD-22-signup-commit-point.md index 47464296..7218e0eb 100644 --- a/docs/backend/decisions/BD-22-signup-commit-point.md +++ b/docs/backend/decisions/BD-22-signup-commit-point.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-27 (`Team-PinLog/docs` `d5288cd` 가입 흐름 변경) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [11_인증_설계 §5.1·§6](https://github.com/Team-PinLog/docs/blob/main/static/11_인증_설계.md) · [08_API_명세 §3.2](https://github.com/Team-PinLog/docs/blob/main/static/08_API_명세.md) · [02_정책_정의서 §2](https://github.com/Team-PinLog/docs/blob/main/static/02_정책_정의서.md) ## 맥락 diff --git a/docs/backend/decisions/BD-23-collection-auto-publish.md b/docs/backend/decisions/BD-23-collection-auto-publish.md index b6495a8f..a46b3cba 100644 --- a/docs/backend/decisions/BD-23-collection-auto-publish.md +++ b/docs/backend/decisions/BD-23-collection-auto-publish.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-22 (정책·데이터 모델 확립 시점. 단일 커밋으로 특정 불가) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 +- **관련**: Jira 작업 - **공용 계약**: [02_정책_정의서 §7](https://github.com/Team-PinLog/docs/blob/main/static/02_정책_정의서.md) · [06_데이터모델_및_무결성 §2.6](https://github.com/Team-PinLog/docs/blob/main/static/06_데이터모델_및_무결성.md) · [10_MVP_기능범위 §2](https://github.com/Team-PinLog/docs/blob/main/static/10_MVP_기능범위.md) ## 맥락 diff --git a/docs/backend/decisions/BD-24-foundation-reset.md b/docs/backend/decisions/BD-24-foundation-reset.md index c6fb62a9..09d653b4 100644 --- a/docs/backend/decisions/BD-24-foundation-reset.md +++ b/docs/backend/decisions/BD-24-foundation-reset.md @@ -3,13 +3,13 @@ - **상태**: Accepted - **날짜**: 2026-07-23 (`7fb4e7e`, `fa6abf1`) - **작성 시점**: 2026-07-27 — 결정 이후에 정리 -- **관련**: S15P11A705-76 · back Issue #9 · PR #10~#13 +- **관련**: Jira 작업 · back Issue #9 · PR #10~#13 ## 맥락 초기 백엔드 설정에 **잘못된 성공 신호**가 여러 개 있었다. -- 임시 `ssafy/ssafy` 계정과 `SecurityConfig`가 살아 있어, 인증 기능이 없는데도 인증 장벽이 존재했다. 실제로 보호되는 것이 없는데 보호되는 것처럼 보였다. +- 임시 `demo/demo` 계정과 `SecurityConfig`가 살아 있어, 인증 기능이 없는데도 인증 장벽이 존재했다. 실제로 보호되는 것이 없는데 보호되는 것처럼 보였다. - 테스트 런타임이 H2여서 "운영과 같은 DB로 검증한다"는 착시가 있었다. `V1`의 `CREATE EXTENSION vector`와 `VECTOR(1536)` 컬럼은 H2에서 검증 자체가 불가능했다([BD-01](BD-01-h2-removal-testcontainers.md)). - Gradle Wrapper 실행 권한이 `100755`가 아니어서 `./gradlew`를 직접 실행할 수 없었다. - 존재하지 않는 `docs/feed/` 링크와 낡은 migration 주석이 남아 있었다. @@ -44,7 +44,7 @@ 이 결정에서 나온 제약들이 [`CLAUDE.md`](../../../CLAUDE.md)의 상시 규칙으로 굳었다 — H2 금지, 빈 패키지·`.gitkeep` 금지, 투기적 도메인 계층 금지, 인증 PR 전 Security 금지. 그 규칙 중 하나를 되돌리려 할 때 먼저 이 문서를 본다. -> 2026-07-29 갱신: 네 제약 중 셋이 `CLAUDE.md`를 떠났다. H2 금지는 [데이터베이스 개발 규약](../../development/database-conventions.md)으로, 빈 패키지·`.gitkeep`·투기적 계층 금지는 [`CONTRIBUTING.md`](../../../CONTRIBUTING.md)와 [패키지 구조](../../development/package-structure.md)로 옮겼다. Security 보류는 인증 PR(S15P11A705-63, [BI-18](../implements/BI-18-2026-07-28-jwt-cookie-session.md)) 병합으로 아래 재검토 트리거가 예고한 대로 소멸했다. 제약이 풀린 것이 아니라 규약 층으로 내려간 것이고, 결정 자체는 유효하다. +> 2026-07-29 갱신: 네 제약 중 셋이 `CLAUDE.md`를 떠났다. H2 금지는 [데이터베이스 개발 규약](../../development/database-conventions.md)으로, 빈 패키지·`.gitkeep`·투기적 계층 금지는 [`CONTRIBUTING.md`](../../../CONTRIBUTING.md)와 [패키지 구조](../../development/package-structure.md)로 옮겼다. Security 보류는 인증 PR(Jira 작업, [BI-18](../implements/BI-18-2026-07-28-jwt-cookie-session.md)) 병합으로 아래 재검토 트리거가 예고한 대로 소멸했다. 제약이 풀린 것이 아니라 규약 층으로 내려간 것이고, 결정 자체는 유효하다. **재검토 트리거** diff --git a/docs/backend/decisions/BD-25-context-origin-created-at.md b/docs/backend/decisions/BD-25-context-origin-created-at.md index 3aa7c7e9..3a9f85fa 100644 --- a/docs/backend/decisions/BD-25-context-origin-created-at.md +++ b/docs/backend/decisions/BD-25-context-origin-created-at.md @@ -3,7 +3,7 @@ - **상태**: Accepted - **날짜**: 2026-07-27 - **기록**: 당시 -- **관련**: [docs#16](https://github.com/Team-PinLog/docs/pull/16) · [BD-07](BD-07-context-immutability.md) 재검토 트리거 발동 · S15P11A705-66(구현 예정) +- **관련**: [docs#16](https://github.com/Team-PinLog/docs/pull/16) · [BD-07](BD-07-context-immutability.md) 재검토 트리거 발동 · Jira 작업(구현 예정) ## 맥락 @@ -11,7 +11,7 @@ 프론트에서 그 요구가 실제로 왔다: 수정을 거쳐도 Context의 최초 작성 시각을 유지하고, 목록을 그 시각 기준 **오래된순**으로 기본 정렬해 달라. 트리거 발동이다. -Context 테이블은 아직 마이그레이션·엔티티가 없다(S15P11A705-66에서 구현 예정). 따라서 이 결정은 스키마 변경이 아니라 공용 계약(데이터모델 §2.5) 선반영으로 처리한다. +Context 테이블은 아직 마이그레이션·엔티티가 없다(Jira 작업에서 구현 예정). 따라서 이 결정은 스키마 변경이 아니라 공용 계약(데이터모델 §2.5) 선반영으로 처리한다. ## 선택지 diff --git a/docs/backend/decisions/BD-26-flyway-out-of-order.md b/docs/backend/decisions/BD-26-flyway-out-of-order.md index ab8f8341..cd257a3a 100644 --- a/docs/backend/decisions/BD-26-flyway-out-of-order.md +++ b/docs/backend/decisions/BD-26-flyway-out-of-order.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-28 -- **관련**: S15P11A705-86 · [BT-02](../troubleshooting/BT-02-flyway-out-of-order-version-ranges.md) +- **관련**: Jira 작업 · [BT-02](../troubleshooting/BT-02-flyway-out-of-order-version-ranges.md) ## 맥락 diff --git a/docs/backend/decisions/BD-27-coverage-gate-bundle-80.md b/docs/backend/decisions/BD-27-coverage-gate-bundle-80.md index 80a229b2..6a0620f8 100644 --- a/docs/backend/decisions/BD-27-coverage-gate-bundle-80.md +++ b/docs/backend/decisions/BD-27-coverage-gate-bundle-80.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-28 -- **관련**: S15P11A705-103 +- **관련**: Jira 작업 ## 맥락 diff --git a/docs/backend/decisions/BD-28-readiness-includes-db.md b/docs/backend/decisions/BD-28-readiness-includes-db.md index 86077891..46002822 100644 --- a/docs/backend/decisions/BD-28-readiness-includes-db.md +++ b/docs/backend/decisions/BD-28-readiness-includes-db.md @@ -2,11 +2,11 @@ - **상태**: Accepted - **날짜**: 2026-07-28 -- **관련**: S15P11A705-106, [infra#33](https://github.com/Team-PinLog/infra/issues/33), [BT-03](../troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md) +- **관련**: Jira 작업, [infra#33](https://github.com/Team-PinLog/infra/issues/33), [BT-03](../troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md) ## 맥락 -배포 계약(S15P11A705-51)을 검증하면서 probe 경로를 실측한 결과, `/health/liveness`·`/health/readiness`는 외부 의존성이 죽어도 항상 `200 UP`이었다. +배포 계약(Jira 작업)을 검증하면서 probe 경로를 실측한 결과, `/health/liveness`·`/health/readiness`는 외부 의존성이 죽어도 항상 `200 UP`이었다. | endpoint | 정상 | PostgreSQL·Redis 정지 | |---|---|---| diff --git a/docs/backend/decisions/BD-29-nullmarked-security-package.md b/docs/backend/decisions/BD-29-nullmarked-security-package.md index f1ac6475..6d0c1f46 100644 --- a/docs/backend/decisions/BD-29-nullmarked-security-package.md +++ b/docs/backend/decisions/BD-29-nullmarked-security-package.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-28 -- **관련**: S15P11A705-63 · `f026b15` +- **관련**: Jira 작업 · `f026b15` ## 맥락 diff --git a/docs/backend/decisions/BD-30-authorization-request-in-cookie.md b/docs/backend/decisions/BD-30-authorization-request-in-cookie.md index 99a752ab..41aa753a 100644 --- a/docs/backend/decisions/BD-30-authorization-request-in-cookie.md +++ b/docs/backend/decisions/BD-30-authorization-request-in-cookie.md @@ -3,7 +3,7 @@ - **상태**: Accepted — **직렬화 형식은 2026-08-04 [BD-49](BD-49-authorization-request-cookie-json.md)로 대체됨**(쿠키에 담는다는 결정 자체는 유효) - **날짜**: 2026-07-28 (`7f1e8ed`·`1ed1dbe` 구현 시점) - **작성 시점**: 2026-07-28 — 결정 이후에 정리(같은 날, 미푸시 커밋 점검 중 기록 누락 발견) -- **관련**: S15P11A705-63 · `7f1e8ed`(Security 도입) · `1ed1dbe`(로그인 진입) · `CookieOAuth2AuthorizationRequestRepository` +- **관련**: Jira 작업 · `7f1e8ed`(Security 도입) · `1ed1dbe`(로그인 진입) · `CookieOAuth2AuthorizationRequestRepository` - **공용 계약**: [11_인증_설계 §2](https://github.com/Team-PinLog/docs/blob/main/static/11_인증_설계.md) (인증 상태를 쿠키에 둔다 — 수용 기록은 [BD-21](BD-21-auth-token-model.md)) > **정정(2026-08-04).** 아래 (b)의 **직렬화 형식** 부분이 [BD-49](BD-49-authorization-request-cookie-json.md)로 뒤집혔다. 쿠키에 담는다는 결정과 (a)·(d) 기각 근거는 그대로 유효하다. diff --git a/docs/backend/decisions/BD-31-jwt-rs256-key-management.md b/docs/backend/decisions/BD-31-jwt-rs256-key-management.md index 478efa2b..5615a69e 100644 --- a/docs/backend/decisions/BD-31-jwt-rs256-key-management.md +++ b/docs/backend/decisions/BD-31-jwt-rs256-key-management.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-28 -- **관련**: S15P11A705-63 +- **관련**: Jira 작업 - **관련 결정**: [BD-21](BD-21-auth-token-model.md)(토큰 모델의 원본) · [BD-30](BD-30-authorization-request-in-cookie.md) ## 맥락 @@ -11,7 +11,7 @@ 먼저 확인한 것은 **외부 규범이 이 선택을 정해 주는지**였다. 결론은 "정해 주지 않는다"이고, 근처 문서가 하는 말의 적용 범위가 우리와 다르다. -- [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) §4는 JWT access token에 대해 *"use of asymmetric cryptography is RECOMMENDED **as it simplifies the process of acquiring validation information for resource servers**"* 라고 하고 `RS256` 지원을 MUST로 건다. 그러나 **권고 이유가 발급자(AS)와 검증자(RS)의 분리를 전제한다.** 우리는 BFF와 리소스 서버가 한 프로세스라(S15P11A705-63 티켓) 이 이유가 성립하지 않는다. 애초에 이 프로파일이 규정하는 "OAuth access token"도 우리 세션 쿠키와 다른 물건이다. +- [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) §4는 JWT access token에 대해 *"use of asymmetric cryptography is RECOMMENDED **as it simplifies the process of acquiring validation information for resource servers**"* 라고 하고 `RS256` 지원을 MUST로 건다. 그러나 **권고 이유가 발급자(AS)와 검증자(RS)의 분리를 전제한다.** 우리는 BFF와 리소스 서버가 한 프로세스라(Jira 작업 티켓) 이 이유가 성립하지 않는다. 애초에 이 프로파일이 규정하는 "OAuth access token"도 우리 세션 쿠키와 다른 물건이다. - [RFC 8725](https://www.rfc-editor.org/rfc/rfc8725.html)(BCP 225)는 대칭/비대칭에 **중립**이다. 알고리즘 *선택*이 아니라 *다루는 방법*을 규정한다. - `draft-ietf-oauth-browser-based-apps`(현재 -27, 아직 RFC 아님)는 BFF + 쿠키 세션을 우리와 같은 구조로 다루지만 세션 토큰의 서명 알고리즘은 규정하지 않는다. BD-21이 인용한 것도 이 문서의 *토큰 보관 위치* 권고지 서명 얘기가 아니었다. diff --git a/docs/backend/decisions/BD-32-refresh-reuse-no-family-revocation.md b/docs/backend/decisions/BD-32-refresh-reuse-no-family-revocation.md index e8d1b8f0..74cc8ff6 100644 --- a/docs/backend/decisions/BD-32-refresh-reuse-no-family-revocation.md +++ b/docs/backend/decisions/BD-32-refresh-reuse-no-family-revocation.md @@ -1,8 +1,8 @@ # BD-32. Refresh 재사용을 감지해도 세션 계열을 폐기하지 않는다 (당분간) -- **상태**: **Superseded by [BD-35](BD-35-refresh-reuse-family-revocation.md)** (2026-07-29) — 아래 재검토 트리거대로 [back#73](https://github.com/Team-PinLog/back/pull/73) 병합 직후 계열 폐기를 구현했다([S15P11A705-131](https://ssafy.atlassian.net/browse/S15P11A705-131)). 이 문서가 기록한 "감수하는 것"은 **더 이상 유효하지 않다.** +- **상태**: **Superseded by [BD-35](BD-35-refresh-reuse-family-revocation.md)** (2026-07-29) — 아래 재검토 트리거대로 [back#73](https://github.com/Team-PinLog/back/pull/73) 병합 직후 계열 폐기를 구현했다(Jira 작업). 이 문서가 기록한 "감수하는 것"은 **더 이상 유효하지 않다.** - **날짜**: 2026-07-29 -- **관련**: S15P11A705-63 · [BD-21](BD-21-auth-token-model.md)(토큰 모델) · [BI-18](../implements/BI-18-2026-07-28-jwt-cookie-session.md) +- **관련**: Jira 작업 · [BD-21](BD-21-auth-token-model.md)(토큰 모델) · [BI-18](../implements/BI-18-2026-07-28-jwt-cookie-session.md) - **작성 시점**: 2026-07-29 — 코드 리뷰에서 지적받아 정리 ## 맥락 @@ -44,5 +44,5 @@ **재검토 트리거** - **이 PR이 병합되는 즉시** 후속 티켓으로 올린다. 미루는 결정이지 포기하는 결정이 아니다. -- 회원 탈퇴(S15P11A705-65)를 구현할 때 — 그쪽도 `auth:refresh::*`를 한 번에 지워야 하므로 같은 자료구조가 필요하다. **두 작업을 같이 하는 것이 자연스럽다.** +- 회원 탈퇴(Jira 작업)를 구현할 때 — 그쪽도 `auth:refresh::*`를 한 번에 지워야 하므로 같은 자료구조가 필요하다. **두 작업을 같이 하는 것이 자연스럽다.** - 재사용 감지 `WARN`이 실제로 관측되기 시작하면 우선순위를 올린다. diff --git a/docs/backend/decisions/BD-33-published-at-database-invariant.md b/docs/backend/decisions/BD-33-published-at-database-invariant.md index 880218a8..84baee89 100644 --- a/docs/backend/decisions/BD-33-published-at-database-invariant.md +++ b/docs/backend/decisions/BD-33-published-at-database-invariant.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-28 -- **관련**: S15P11A705-125, S15P11A705-119(Feed 정책·계약), +- **관련**: Jira 작업(Feed 정책·계약), [back#58](https://github.com/Team-PinLog/back/issues/58) ## 맥락 diff --git a/docs/backend/decisions/BD-34-feed-deterministic-pagination-without-session-cache.md b/docs/backend/decisions/BD-34-feed-deterministic-pagination-without-session-cache.md index 11f22029..a97d218c 100644 --- a/docs/backend/decisions/BD-34-feed-deterministic-pagination-without-session-cache.md +++ b/docs/backend/decisions/BD-34-feed-deterministic-pagination-without-session-cache.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-29 -- **관련**: S15P11A705-120, [back#58](https://github.com/Team-PinLog/back/issues/58), +- **관련**: Jira 작업, [back#58](https://github.com/Team-PinLog/back/issues/58), [P42](../../ai/proposals/P42-feed-mvp-without-place-metadata.md), AI 파트 명세 [feed-profile-cache](../../ai/spec/feed-profile-cache.md) 7장 · [feed-recommendation](../../ai/spec/feed-recommendation.md) 4장 diff --git a/docs/backend/decisions/BD-35-refresh-reuse-family-revocation.md b/docs/backend/decisions/BD-35-refresh-reuse-family-revocation.md index 97c0943c..966b2f2c 100644 --- a/docs/backend/decisions/BD-35-refresh-reuse-family-revocation.md +++ b/docs/backend/decisions/BD-35-refresh-reuse-family-revocation.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-29 -- **관련**: S15P11A705-131 · [back#78](https://github.com/Team-PinLog/back/issues/78) · [BD-32](BD-32-refresh-reuse-no-family-revocation.md)(이 결정이 대체) · [BD-21](BD-21-auth-token-model.md)(토큰 모델) · [BI-21](../implements/BI-21-2026-07-29-refresh-reuse-family-revocation.md) +- **관련**: Jira 작업 · [back#78](https://github.com/Team-PinLog/back/issues/78) · [BD-32](BD-32-refresh-reuse-no-family-revocation.md)(이 결정이 대체) · [BD-21](BD-21-auth-token-model.md)(토큰 모델) · [BI-21](../implements/BI-21-2026-07-29-refresh-reuse-family-revocation.md) ## 맥락 @@ -46,7 +46,7 @@ BD-32가 감수 사항으로 적어 둔 구멍은 그대로였다 — Refresh가 **재검토 트리거** - 오탐이 실제로 관측되면(정상 사용자가 이유 없이 전체 로그아웃되는 문의) (c)를 다시 본다. 그때는 계열 id의 비용을 낼 가치가 생긴다. -- 회원 탈퇴([S15P11A705-65](https://ssafy.atlassian.net/browse/S15P11A705-65))가 같은 `revokeAll`을 쓴다. 두 호출자가 생기면 이 연산의 위치(`RefreshTokenStore`)가 맞는지 다시 본다. +- 회원 탈퇴(Jira 작업)가 같은 `revokeAll`을 쓴다. 두 호출자가 생기면 이 연산의 위치(`RefreshTokenStore`)가 맞는지 다시 본다. - 다중 기기 세션 관리 UI("다른 기기에서 로그아웃")가 생기면 인덱스를 조회용으로도 쓰게 된다. 지금은 폐기 전용이다. - **Redis Cluster로 옮기면 폐기 스크립트를 먼저 손봐야 한다.** 토큰 키를 `ARGV`로 조립하므로 클러스터가 요구하는 키 선언 규약과 맞지 않는다. 지금 운영은 단일 인스턴스다. - Access 무효화가 요구되면(위 30분 창을 닫아야 하면) 검증 경로에 폐기 목록 조회가 생긴다 — 모든 요청에 Redis 왕복이 붙는 큰 변경이라 별도 결정이 필요하다. diff --git a/docs/backend/decisions/BD-36-pending-insert-in-transaction-process-call-after-commit.md b/docs/backend/decisions/BD-36-pending-insert-in-transaction-process-call-after-commit.md index 59377293..00bb0c41 100644 --- a/docs/backend/decisions/BD-36-pending-insert-in-transaction-process-call-after-commit.md +++ b/docs/backend/decisions/BD-36-pending-insert-in-transaction-process-call-after-commit.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-29 -- **관련**: S15P11A705-102, [back#61](https://github.com/Team-PinLog/back/issues/61), +- **관련**: Jira 작업, [back#61](https://github.com/Team-PinLog/back/issues/61), [BI-22](../implements/BI-22-2026-07-29-context-ai-enqueue.md), [BD-35](BD-35-ai-derived-invalidation-inside-deletion-transaction.md)(back#80, 삭제 방향), AI 파트 소유 명세 `docs/ai/spec/context-state-sync.md` §2·§3·§5·§8, diff --git a/docs/backend/decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md b/docs/backend/decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md index e09326f4..d4bae97a 100644 --- a/docs/backend/decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md +++ b/docs/backend/decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-29 -- **관련**: S15P11A705-124, [back#61](https://github.com/Team-PinLog/back/issues/61), +- **관련**: Jira 작업, [back#61](https://github.com/Team-PinLog/back/issues/61), [BI-23](../implements/BI-23-2026-07-29-ai-derived-invalidation-on-delete.md), 공용 계약 `Team-PinLog/docs` `static/06_데이터모델_및_무결성.md` §1.3·§6.4~6.9, `static/08_API_명세.md` "AI 파생 데이터" diff --git a/docs/backend/decisions/BD-38-published-predicate-per-layer.md b/docs/backend/decisions/BD-38-published-predicate-per-layer.md index 5cbdcc89..a6faf099 100644 --- a/docs/backend/decisions/BD-38-published-predicate-per-layer.md +++ b/docs/backend/decisions/BD-38-published-predicate-per-layer.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-29 -- **관련**: S15P11A705-148, S15P11A705-147([back#93](https://github.com/Team-PinLog/back/pull/93)), +- **관련**: Jira 작업([back#93](https://github.com/Team-PinLog/back/pull/93)), [back#84](https://github.com/Team-PinLog/back/issues/84), [BI-11](../implements/BI-11-2026-07-28-follow-library-api.md), [BI-13](../implements/BI-13-2026-07-28-public-scope-filter.md), @@ -16,7 +16,7 @@ > `is_published`의 MVP 활용 여부 — 비공개 전환을 제공하면 Feed·타인 Shelf 조회·Collection 상세 > 세 경로에 필터 필요 -앞선 S15P11A705-147은 같은 성격의 중복인 **작성자 미탈퇴** 판정을 `MemberRepository.isActive` +앞선 Jira 작업은 같은 성격의 중복인 **작성자 미탈퇴** 판정을 `MemberRepository.isActive` 한 곳으로 모았다. 그 티켓은 "세 곳의 코드가 동일해 구현 방식을 정할 것 없다"는 전제가 성립했다. 발행 여부는 그렇지 않아서 방식을 정해야 했다. @@ -55,7 +55,7 @@ core 경로에는 `record_count > 0`이 없다. **빈 Collection은 Feed 후보 ### Java 2곳은 이미 한 곳이다 두 경로 모두 `Collection.isPublished()`를 부른다. `!x`와 `filter(x)`는 판정의 갈림이 아니라 호출 -문법의 차이다. S15P11A705-148의 본문은 "Java 2곳의 판정을 하나로 모은다"고 적었지만 그 전제가 +문법의 차이다. Jira 작업의 본문은 "Java 2곳의 판정을 하나로 모은다"고 적었지만 그 전제가 사실과 다르다. 여기서 새 술어를 뽑으면 간접층만 늘어난다. ## 선택지 diff --git a/docs/backend/decisions/BD-39-embedding-profile-in-application-config.md b/docs/backend/decisions/BD-39-embedding-profile-in-application-config.md index 873a9033..5a7eb472 100644 --- a/docs/backend/decisions/BD-39-embedding-profile-in-application-config.md +++ b/docs/backend/decisions/BD-39-embedding-profile-in-application-config.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-29 -- **관련**: S15P11A705-135, +- **관련**: Jira 작업, [BI-25](../implements/BI-25-2026-07-29-personal-search-backend-integration.md), 공용 계약 `Team-PinLog/docs` `static/05_AI_설계.md` §7.1(2026-07-29 개정)·§9.3, `ai` 레포 `docs/spec/model-profile.md` §2·§3.1, @@ -16,7 +16,7 @@ **Spring이 이 값을 어디서 얻는지가 정해져 있지 않았다.** 공용 계약 §7.1이 그 결정을 이 티켓 시점으로 명시적으로 유예했다 — *"Spring이 그 값을 어디서 얻는지는 검색 연동 시점에 정합니다 -(`S15P11A705-135`). 소비자가 없는 상태에서 정하면 붙일 때 다시 뒤집힙니다."* +(`Jira 작업`). 소비자가 없는 상태에서 정하면 붙일 때 다시 뒤집힙니다."* 같은 §7.1은 **오늘 개정됐고**, 개정 전후로 전제가 뒤집혔다. diff --git a/docs/backend/decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md b/docs/backend/decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md index 5d5b22ff..c257eb5b 100644 --- a/docs/backend/decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md +++ b/docs/backend/decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-30 -- **관련**: S15P11A705-159, +- **관련**: Jira 작업, [BI-28](../implements/BI-28-2026-07-30-ai-rescan-scheduler.md), [BD-17](BD-17-async-without-message-queue.md)(메시지 큐 없이 비동기), [BD-36](BD-36-pending-insert-in-transaction-process-call-after-commit.md), diff --git a/docs/backend/decisions/BD-41-withdrawn-member-check-in-authentication-filter.md b/docs/backend/decisions/BD-41-withdrawn-member-check-in-authentication-filter.md index 9b4c9986..30e34755 100644 --- a/docs/backend/decisions/BD-41-withdrawn-member-check-in-authentication-filter.md +++ b/docs/backend/decisions/BD-41-withdrawn-member-check-in-authentication-filter.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-30 -- **관련**: [S15P11A705-65](https://ssafy.atlassian.net/browse/S15P11A705-65), [back#34](https://github.com/Team-PinLog/back/issues/34), +- **관련**: Jira 작업, [back#34](https://github.com/Team-PinLog/back/issues/34), [BD-21](BD-21-auth-token-model.md)(Access 30분·Refresh 7일), [BD-35](BD-35-refresh-reuse-family-revocation.md)(회원 단위 Refresh 폐기) ## 맥락 @@ -22,7 +22,7 @@ Access는 자기완결적 JWT이고 폐기 목록이 없다. `JwtAuthenticationF | 안 | 장점 | 단점 | |---|---|---| -| (a) 필터에서 `MemberRepository.isActive` 확인 | 창이 즉시 닫힌다. 판정이 이미 한 곳에 모여 있다(S15P11A705-147) | 인증 요청마다 PK 조회 1회 | +| (a) 필터에서 `MemberRepository.isActive` 확인 | 창이 즉시 닫힌다. 판정이 이미 한 곳에 모여 있다(Jira 작업) | 인증 요청마다 PK 조회 1회 | | (b) 창을 감수하고 문서화 | 추가 비용 0. 스테이트리스 토큰의 알려진 대가다 | 탈퇴자가 30분간 쓰기 가능. 완료 조건을 계약 수준으로 낮춰야 한다 | | (c) Redis 탈퇴 마커(TTL 30분) | DB 부하 없음, 만료 자동 | 새 키 스페이스. 필터가 Redis에 의존하게 되어 **Redis 순단이 인증 실패로 번지고**, fail-open/closed 판단이 추가로 필요하다 | @@ -32,7 +32,7 @@ Access는 자기완결적 JWT이고 폐기 목록이 없다. `JwtAuthenticationF **① 창 안에서 가능한 것이 읽기가 아니라 쓰기다.** 조회만 새는 것이라면 (b)를 택할 만하다. 그런데 탈퇴 직후 30분간 Record·Collection 생성이 되고, 그 행들은 연쇄 삭제가 이미 지나간 뒤에 만들어져 **어떤 정리 경로에도 걸리지 않는다.** 탈퇴 후 남는 고아 데이터를 감수하는 것과 요청당 PK 조회를 감수하는 것 사이의 선택이고, 후자가 싸다. -**② 판정 지점이 이미 존재한다.** `S15P11A705-147`이 공개 조회 세 경로의 미탈퇴 판정을 `MemberRepository.isActive` 하나로 모아 두었고, 그 javadoc이 *"탈퇴 정책이 바뀌면 만질 자리는 여기 하나"*라고 적고 있다. 인증 경로가 같은 메서드를 쓰면 정책이 갈라지지 않는다. (c)를 택하면 미탈퇴 판정이 두 벌(DB·Redis)이 되고 둘의 동기화가 새 문제가 된다. +**② 판정 지점이 이미 존재한다.** `Jira 작업`이 공개 조회 세 경로의 미탈퇴 판정을 `MemberRepository.isActive` 하나로 모아 두었고, 그 javadoc이 *"탈퇴 정책이 바뀌면 만질 자리는 여기 하나"*라고 적고 있다. 인증 경로가 같은 메서드를 쓰면 정책이 갈라지지 않는다. (c)를 택하면 미탈퇴 판정이 두 벌(DB·Redis)이 되고 둘의 동기화가 새 문제가 된다. (c)를 기각한 결정적 이유는 성능이 아니라 **장애 전파**다. 지금 Redis가 죽으면 재발급만 실패하고 진행 중인 세션은 Access 만료까지 살아 있다. 필터가 Redis를 읽으면 Redis 순단이 곧 전체 인증 실패이거나(fail-closed), 아니면 순단 중에 탈퇴자가 통과한다(fail-open). readiness 그룹에 `redis`를 넣지 않기로 한 [BD-28](BD-28-readiness-includes-db.md)과 같은 방향이다 — 외부 의존성 하나가 서비스 전체를 끌어내리지 않게 한다. diff --git a/docs/backend/decisions/BD-42-ci-skip-inside-job-not-paths-ignore.md b/docs/backend/decisions/BD-42-ci-skip-inside-job-not-paths-ignore.md index e872ba33..1b7f95c9 100644 --- a/docs/backend/decisions/BD-42-ci-skip-inside-job-not-paths-ignore.md +++ b/docs/backend/decisions/BD-42-ci-skip-inside-job-not-paths-ignore.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-07-31 -- **관련**: [S15P11A705-231](https://ssafy.atlassian.net/browse/S15P11A705-231) +- **관련**: Jira 작업 ## 맥락 diff --git a/docs/backend/decisions/BD-43-shelf-in-follow-domain-under-collections-path.md b/docs/backend/decisions/BD-43-shelf-in-follow-domain-under-collections-path.md index b961efe7..cf915a70 100644 --- a/docs/backend/decisions/BD-43-shelf-in-follow-domain-under-collections-path.md +++ b/docs/backend/decisions/BD-43-shelf-in-follow-domain-under-collections-path.md @@ -2,19 +2,19 @@ - **상태**: Accepted - **날짜**: 2026-07-31 -- **관련**: [S15P11A705-206](https://ssafy.atlassian.net/browse/S15P11A705-206) · [back#85](https://github.com/Team-PinLog/back/issues/85) · [back#58](https://github.com/Team-PinLog/back/issues/58) · [back#140](https://github.com/Team-PinLog/back/pull/140) · [docs#35](https://github.com/Team-PinLog/docs/issues/35) +- **관련**: Jira 작업 · [back#85](https://github.com/Team-PinLog/back/issues/85) · [back#58](https://github.com/Team-PinLog/back/issues/58) · [back#140](https://github.com/Team-PinLog/back/pull/140) · [docs#35](https://github.com/Team-PinLog/docs/issues/35) > **파일명 주의**: 파일명에 `under-collections-path`가 남아 있으나 결정은 그 반대다(경로 유지). 초안 단계에서 경로 이동을 채택했다가 아래 근거로 뒤집었고, 보존 구역 규칙에 따라 파일명을 바꾸지 않는다. ## 맥락 -작성자 공개 책장 탐색([08 §8.1](https://github.com/Team-PinLog/docs/blob/main/static/08_API_명세.md))이 구현되지 않아 프론트 호출이 운영에서 404였다. Feed 추천 MVP(S15P11A705-120, `[AI]` 티켓)가 범위에서 제외했고 후속 티켓이 없어 생긴 공백이다. +작성자 공개 책장 탐색([08 §8.1](https://github.com/Team-PinLog/docs/blob/main/static/08_API_명세.md))이 구현되지 않아 프론트 호출이 운영에서 404였다. Feed 추천 MVP(Jira 작업, `[AI]` 티켓)가 범위에서 제외했고 후속 티켓이 없어 생긴 공백이다. 착수하면서 두 가지가 정해져 있지 않았다. **하나, 어느 도메인에 두는가.** 공용 계약의 경로가 `/feed/collections/{collectionId}/shelf`여서 Feed 도메인이 자연스러운 후보였다. 그런데 이 Endpoint는 추천 점수·Profile·`core.feed_event` 어느 것도 타지 않는다. 하는 일은 `collectionId`로 작성자를 찾아 그 작성자의 발행 Collection을 커서로 나열하고 요청자 기준 팔로우 상태를 얹는 것뿐이며, §9.3 `GET /follows/{followId}/collections`와 **같은 데이터를 다른 진입 키로 읽는다**. -Feed 런타임 담당자는 이정헌이고 AI 파트가 Feed 정책·계약을 소유한다(에픽 S15P11A705-111). back#58에서 AI 파트가 "shelf는 `-120` 범위 밖이고 AI 의존이 없으니 백엔드에서 먼저 끊어도 된다"고 명시적으로 넘겼다. +Feed 런타임 담당자는 이정헌이고 AI 파트가 Feed 정책·계약을 소유한다(에픽 Jira 작업). back#58에서 AI 파트가 "shelf는 `-120` 범위 밖이고 AI 의존이 없으니 백엔드에서 먼저 끊어도 된다"고 명시적으로 넘겼다. **둘, 경로를 옮길 것인가.** 명세 안에서 이 Endpoint의 귀속이 엇갈려 있다. diff --git a/docs/backend/decisions/BD-44-image-takes-prebuilt-jar.md b/docs/backend/decisions/BD-44-image-takes-prebuilt-jar.md index b5449ed8..6defa020 100644 --- a/docs/backend/decisions/BD-44-image-takes-prebuilt-jar.md +++ b/docs/backend/decisions/BD-44-image-takes-prebuilt-jar.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-08-01 -- **관련**: [S15P11A705-238](https://ssafy.atlassian.net/browse/S15P11A705-238) · [BD-42](BD-42-ci-skip-inside-job-not-paths-ignore.md) · [BI-32](../implements/BI-32-2026-07-31-ci-pipeline-speedup.md) +- **관련**: Jira 작업 · [BD-42](BD-42-ci-skip-inside-job-not-paths-ignore.md) · [BI-32](../implements/BI-32-2026-07-31-ci-pipeline-speedup.md) ## 맥락 diff --git a/docs/backend/decisions/BD-45-worklog-per-entry-files.md b/docs/backend/decisions/BD-45-worklog-per-entry-files.md index 3b5d80a8..bbd41c7f 100644 --- a/docs/backend/decisions/BD-45-worklog-per-entry-files.md +++ b/docs/backend/decisions/BD-45-worklog-per-entry-files.md @@ -48,10 +48,10 @@ **잃는 것.** 파일 하나를 열어 전부 훑는 방식이 없어진다. AI 파트가 이슈 댓글에서 지적한 손실이고, 실재한다. 대신 파일명이 시간순 목록을 주고 각 폴더 `README.md`가 훑는 명령을 제시한다 — 문서를 직접 읽으므로 손으로 관리하던 표보다 오히려 정확하다. -**남는 것.** 같은 날 · 추적 키 없음 · 같은 슬러그인 파일을 두 브랜치가 각각 만들면 그때는 다시 겹친다. `worklog/README.md`가 슬러그를 구체적으로 쓰라고 규정하는 것으로 갈음한다. 추적 키가 있어도 날짜와 키가 같은 작업이 겹칠 수는 있다 — `WORKLOG.md`에 같은 날 `S15P11A705-41`로 묶인 항목이 다섯 개다. 다만 이때도 구분은 슬러그가 지고, 추적 키는 그 위험을 줄일 뿐이다. +**남는 것.** 같은 날 · 추적 키 없음 · 같은 슬러그인 파일을 두 브랜치가 각각 만들면 그때는 다시 겹친다. `worklog/README.md`가 슬러그를 구체적으로 쓰라고 규정하는 것으로 갈음한다. 추적 키가 있어도 날짜와 키가 같은 작업이 겹칠 수는 있다 — `WORKLOG.md`에 같은 날 `Jira 작업`로 묶인 항목이 다섯 개다. 다만 이때도 구분은 슬러그가 지고, 추적 키는 그 위험을 줄일 뿐이다. **전역 번호는 범위 밖이다.** 원인은 같지만(브랜치마다 같은 자원을 집는다) git이 막지 않는다 — `BD-46-foo.md`와 `BD-46-bar.md`는 파일명이 달라 충돌 없이 둘 다 머지된다. 그래서 이 결정이 잰 비용에 기여하지 않는다. 지금까지 중복 0건이고, 규칙이 이미 `implements/README.md`에 있고, 겹쳐도 고치는 값이 파일 rename 하나로 싸다. 번호를 없애는 대안은 교차 참조 1232곳(96개 파일)을 깨뜨리므로 값이 맞지 않는다 — 번호가 존재하는 이유가 파일명이 바뀌어도 흔들리지 않는 고정 손잡이라는 것이다. -> **후속(2026-08-07).** 위 단락이 예로 든 `BD-46-foo.md`·`BD-46-bar.md`가 실제로 일어났다. 2026-08-03에 `S15P11A705-265`(목록 정렬)와 `S15P11A705-282`(datasource 리터럴 제거)가 각자 `BD-46`을 집어 둘 다 머지됐고, 나흘간 중복인 채로 있었다. **"중복 0건"은 이제 1건이다.** 다만 값 판단은 그대로 성립했다 — 고치는 값이 실제로 파일 rename 하나였고, 나중에 머지된 쪽을 [BD-50](BD-50-datasource-redis-config-follows-infra-env-vars.md)으로 옮기며 참조 8곳을 함께 고친 것이 전부다. 이 결정은 유지한다. 재발을 막으려면 번호 채번을 사람의 조회에 맡기지 않는 장치(예: 중복 번호를 잡는 CI 검사)가 필요하고, 그것은 이 결정의 범위 밖이다. +> **후속(2026-08-07).** 위 단락이 예로 든 `BD-46-foo.md`·`BD-46-bar.md`가 실제로 일어났다. 2026-08-03에 `Jira 작업`(목록 정렬)와 `Jira 작업`(datasource 리터럴 제거)가 각자 `BD-46`을 집어 둘 다 머지됐고, 나흘간 중복인 채로 있었다. **"중복 0건"은 이제 1건이다.** 다만 값 판단은 그대로 성립했다 — 고치는 값이 실제로 파일 rename 하나였고, 나중에 머지된 쪽을 [BD-50](BD-50-datasource-redis-config-follows-infra-env-vars.md)으로 옮기며 참조 8곳을 함께 고친 것이 전부다. 이 결정은 유지한다. 재발을 막으려면 번호 채번을 사람의 조회에 맡기지 않는 장치(예: 중복 번호를 잡는 CI 검사)가 필요하고, 그것은 이 결정의 범위 밖이다. **AI 파트.** `docs/ai/`도 같은 구조라 같은 문제를 갖지만 AI 파트 소유다. 이슈 댓글에서 "각 레포가 자기 문서 구조를 정할 문제이고 `docs/backend/`는 백엔드 판단을 따르겠다", "`docs/ai/`도 분리하신다면 같은 구조로 맞추겠다"고 답했다. 이 결정은 백엔드 구역에만 적용한다. diff --git a/docs/backend/decisions/BD-46-list-sort-default-asc-with-params.md b/docs/backend/decisions/BD-46-list-sort-default-asc-with-params.md index f60d8e45..48f77074 100644 --- a/docs/backend/decisions/BD-46-list-sort-default-asc-with-params.md +++ b/docs/backend/decisions/BD-46-list-sort-default-asc-with-params.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-08-03 -- **관련**: S15P11A705-265 · [docs#42](https://github.com/Team-PinLog/docs/pull/42) · [docs#37](https://github.com/Team-PinLog/docs/pull/37) (계승됨) +- **관련**: Jira 작업 · [docs#42](https://github.com/Team-PinLog/docs/pull/42) · [docs#37](https://github.com/Team-PinLog/docs/pull/37) (계승됨) ## 맥락 diff --git a/docs/backend/decisions/BD-47-auth-filter-infra-failure-is-503.md b/docs/backend/decisions/BD-47-auth-filter-infra-failure-is-503.md index 6ed5feac..812de792 100644 --- a/docs/backend/decisions/BD-47-auth-filter-infra-failure-is-503.md +++ b/docs/backend/decisions/BD-47-auth-filter-infra-failure-is-503.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-08-03 -- **관련**: [S15P11A705-188](https://ssafy.atlassian.net/browse/S15P11A705-188) · [back#171](https://github.com/Team-PinLog/back/issues/171) · +- **관련**: Jira 작업 · [back#171](https://github.com/Team-PinLog/back/issues/171) · [BD-41](BD-41-withdrawn-member-access-token.md)(이 문제를 남긴 결정) · [BD-28](BD-28-readiness-includes-db.md)(readiness에 `db`) · [BT-06](../troubleshooting/BT-06-refresh-revocation-leak-under-concurrent-rotation.md) · [11_인증_설계 §4.4](https://github.com/Team-PinLog/docs/blob/main/static/11_인증_설계.md)(401 → 재발급이 클라이언트 계약) @@ -39,7 +39,7 @@ BD-28로 readiness에 `db`가 들어 있어 DB가 끊기면 파드가 트래픽 따라서 인프라 실패를 401로 내보내면 **계약을 지키는 클라이언트일수록 반드시 재발급을 시도한다.** 클라이언트 버그를 가정한 시나리오가 아니다. -여기에 [S15P11A705-267](https://ssafy.atlassian.net/browse/S15P11A705-267)에서 넣은 *"재발급이 401이면 인증 쿠키를 지운다"*가 맞물린다. +여기에 Jira 작업에서 넣은 *"재발급이 401이면 인증 쿠키를 지운다"*가 맞물린다. ``` DB 순단 → isActive 던짐 → 삼킴 → 컨텍스트 빔 → 보호 경로 401 diff --git a/docs/backend/decisions/BD-48-unlink-before-withdrawal.md b/docs/backend/decisions/BD-48-unlink-before-withdrawal.md index 4b95186c..b856d504 100644 --- a/docs/backend/decisions/BD-48-unlink-before-withdrawal.md +++ b/docs/backend/decisions/BD-48-unlink-before-withdrawal.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-08-04 -- **관련**: [S15P11A705-285](https://ssafy.atlassian.net/browse/S15P11A705-285) · [S15P11A705-214](https://ssafy.atlassian.net/browse/S15P11A705-214) · +- **관련**: Jira 작업 · [back#176](https://github.com/Team-PinLog/back/issues/176) · [back#139](https://github.com/Team-PinLog/back/issues/139) · [docs#44](https://github.com/Team-PinLog/docs/pull/44) · [BD-41](BD-41-withdrawn-member-check-in-authentication-filter.md)(순서 판단의 대비) · [BD-30](BD-30-authorization-request-in-cookie.md)(인가 요청 쿠키) @@ -69,7 +69,7 @@ DB 트랜잭션과 외부 HTTP는 원자적일 수 없으므로 실현 형태는 > > **`200`이 무엇의 성공인지 확인하지 않은 것이 이 문서의 오류다.** 폐기의 연쇄는 access → refresh 방향이고 승인은 refresh token 쪽에 달려 있는데, 우리는 `access_type=offline`을 붙이지 않아 refresh token 자체를 받지 않았다. 연쇄할 대상이 없으니 토큰만 죽었다. > -> S15P11A705-309가 **탈퇴 왕복의 Google 인가 요청에만** `access_type=offline`·`prompt=consent`를 붙여 refresh token을 받고 그것을 폐기하도록 고쳤다. 로그인 진입은 그대로다 — 붙이면 매 로그인마다 동의 화면이 뜬다. 보관 원칙도 그대로다: refresh token은 토큰 교환 응답으로 와서 같은 요청 안에서 `/revoke`로 나가고 사라진다. +> Jira 작업가 **탈퇴 왕복의 Google 인가 요청에만** `access_type=offline`·`prompt=consent`를 붙여 refresh token을 받고 그것을 폐기하도록 고쳤다. 로그인 진입은 그대로다 — 붙이면 매 로그인마다 동의 화면이 뜬다. 보관 원칙도 그대로다: refresh token은 토큰 교환 응답으로 와서 같은 요청 안에서 `/revoke`로 나가고 사라진다. | 공급자 | 엔드포인트 | 해제 단위 | 폐기 대상 | 이미 폐기된 토큰 | |---|---|---|---|---| @@ -216,5 +216,5 @@ DB 트랜잭션과 외부 HTTP는 원자적일 수 없으므로 실현 형태는 - **이미 탈퇴한 회원은 소급 해제가 불가능하다.** `provider_user_id`가 이미 마스킹돼 지목할 수단이 없다. - **재검토 트리거** - 인가 왕복 이탈률이 높아 해제되지 않는 탈퇴가 유의미하게 쌓일 때 — Kakao에 한해 어드민 키 폴백을 검토한다. - - [S15P11A705-132](https://ssafy.atlassian.net/browse/S15P11A705-132)에서 인가 요청 쿠키를 JSON으로 전환할 때 — `memberId`가 실리는 지금, 서명을 붙일지 함께 판단한다. + - Jira 작업에서 인가 요청 쿠키를 JSON으로 전환할 때 — `memberId`가 실리는 지금, 서명을 붙일지 함께 판단한다. - 공급자가 연결 해제 API의 인증 방식을 바꿀 때. diff --git a/docs/backend/decisions/BD-49-authorization-request-cookie-json.md b/docs/backend/decisions/BD-49-authorization-request-cookie-json.md index dbf23a24..8d88b7cb 100644 --- a/docs/backend/decisions/BD-49-authorization-request-cookie-json.md +++ b/docs/backend/decisions/BD-49-authorization-request-cookie-json.md @@ -2,7 +2,7 @@ - **상태**: Accepted - **날짜**: 2026-08-04 -- **관련**: [S15P11A705-132](https://ssafy.atlassian.net/browse/S15P11A705-132) · [back#175](https://github.com/Team-PinLog/back/issues/175) · +- **관련**: Jira 작업 · [back#175](https://github.com/Team-PinLog/back/issues/175) · [BD-30](BD-30-authorization-request-in-cookie.md)(대체하는 결정) · [BD-48](BD-48-unlink-before-withdrawal.md)(이 쿠키에 탈퇴 판정을 실은 결정) - **범위**: 직렬화 **형식**만 바꾼다. 쿠키에 담는다는 BD-30의 결정과 쿠키 속성(`HttpOnly`·`SameSite=Lax`·`Path`·수명)은 그대로다. diff --git a/docs/backend/decisions/BD-52-confidence-gate-filters-before-core-revalidation.md b/docs/backend/decisions/BD-52-confidence-gate-filters-before-core-revalidation.md index f3815035..d6c24be8 100644 --- a/docs/backend/decisions/BD-52-confidence-gate-filters-before-core-revalidation.md +++ b/docs/backend/decisions/BD-52-confidence-gate-filters-before-core-revalidation.md @@ -4,7 +4,7 @@ - **날짜**: 2026-08-07 - **관련**: `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4 · ai 레포 [P49](../../ai/proposals/P49-multi-signal-search.md) §4-5(similarity 비노출 계약) · 오프라인 - 재측정 [S15P11A705-401](../../ai/implements/2026-08-07-gate-threshold-remeasure.md) + 재측정 [Jira 작업](../../ai/implements/2026-08-07-gate-threshold-remeasure.md) - **번호**: [BD-45](BD-45-worklog-per-entry-files.md)의 규칙대로 `dev` 머지 순서가 번호를 확정한다. 머지 시점에 52가 이미 다른 결정에 쓰였다면 [BD-50](BD-50-datasource-redis-config-follows-infra-env-vars.md)의 선례대로 번호만 옮기고 내용은 그대로 둔다. @@ -22,7 +22,7 @@ - S1(벡터 유사도)은 FastAPI 응답에 항상 있다. - S2(문자열 매치)는 `mergeLexicalMatches`가 병합하는 순간 결정된다 — 그 이전엔 아직 문자열 후보 목록조차 없다. -- S3(키워드 매치)는 ai 레포가 이미 응답 필드(`keywordMatched`, S15P11A705-399)로 실어 보낸다 — +- S3(키워드 매치)는 ai 레포가 이미 응답 필드(`keywordMatched`, Jira 작업)로 실어 보낸다 — Spring이 계산하지 않는다. ## 선택지 diff --git a/docs/backend/implements/BI-02-2026-07-27-member-base-entity-soft-delete.md b/docs/backend/implements/BI-02-2026-07-27-member-base-entity-soft-delete.md index a9c0c0aa..245b52c3 100644 --- a/docs/backend/implements/BI-02-2026-07-27-member-base-entity-soft-delete.md +++ b/docs/backend/implements/BI-02-2026-07-27-member-base-entity-soft-delete.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-27 -- **관련**: S15P11A705-41 (`e755b1e`·`0f3c4d6`·`d1204bf`·`02fcaa7`·`6c61272`·`1acd804`·`67bcd11`), [BD-02](../decisions/BD-02-base-entity-common-columns.md) +- **관련**: Jira 작업 (`e755b1e`·`0f3c4d6`·`d1204bf`·`02fcaa7`·`6c61272`·`1acd804`·`67bcd11`), [BD-02](../decisions/BD-02-base-entity-common-columns.md) ## 산출 diff --git a/docs/backend/implements/BI-03-2026-07-27-api-response-envelope.md b/docs/backend/implements/BI-03-2026-07-27-api-response-envelope.md index 48c5c05e..8d9c15f7 100644 --- a/docs/backend/implements/BI-03-2026-07-27-api-response-envelope.md +++ b/docs/backend/implements/BI-03-2026-07-27-api-response-envelope.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-27 -- **관련**: S15P11A705-53 (`a45475a`, `93f4ad6`, `fe52533`, `86b0dd6`, `a1cd90f`), [BD-03](../decisions/BD-03-api-response-envelope.md) +- **관련**: Jira 작업 (`a45475a`, `93f4ad6`, `fe52533`, `86b0dd6`, `a1cd90f`), [BD-03](../decisions/BD-03-api-response-envelope.md) > 번호 참고: 열린 PR #25가 BI-02를 점유 중이라 이 문서는 BI-03부터 시작한다. diff --git a/docs/backend/implements/BI-04-2026-07-27-cursor-pagination.md b/docs/backend/implements/BI-04-2026-07-27-cursor-pagination.md index 81ca72da..66aa23ea 100644 --- a/docs/backend/implements/BI-04-2026-07-27-cursor-pagination.md +++ b/docs/backend/implements/BI-04-2026-07-27-cursor-pagination.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-27 -- **관련**: S15P11A705-42 (`377d006`, `14888f2`, `72b2899`), [BD-04](../decisions/BD-04-cursor-pagination.md) +- **관련**: Jira 작업 (`377d006`, `14888f2`, `72b2899`), [BD-04](../decisions/BD-04-cursor-pagination.md) ## 산출 diff --git a/docs/backend/implements/BI-05-2026-07-27-graceful-shutdown.md b/docs/backend/implements/BI-05-2026-07-27-graceful-shutdown.md index 3254e60f..cecd8b13 100644 --- a/docs/backend/implements/BI-05-2026-07-27-graceful-shutdown.md +++ b/docs/backend/implements/BI-05-2026-07-27-graceful-shutdown.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-27 -- **관련**: S15P11A705-51, [BD-05](../decisions/BD-05-graceful-shutdown-timing.md) +- **관련**: Jira 작업, [BD-05](../decisions/BD-05-graceful-shutdown-timing.md) ## 산출 @@ -14,7 +14,7 @@ - `shutdownTimeoutFitsInsideKubernetesTerminationGracePeriod` — `timeout-per-shutdown-phase == 20s` - `livenessProbeIsAvailableForKubernetes` — `/api/core/actuator/health/liveness` 200 + `"status":"UP"` - `readinessProbeIsAvailableForKubernetes` — `/api/core/actuator/health/readiness` 200 + `"status":"UP"` -- `docs/development/database-conventions.md` — "무중단 배포와 backward-compatible migration" 절 신설. S15P11A705-51의 "Flyway migration을 backward-compatible하게 작성하고 기존 테이블·컬럼의 즉시 삭제를 금지한다"를 규약으로 옮겼다. +- `docs/development/database-conventions.md` — "무중단 배포와 backward-compatible migration" 절 신설. Jira 작업의 "Flyway migration을 backward-compatible하게 작성하고 기존 테이블·컬럼의 즉시 삭제를 금지한다"를 규약으로 옮겼다. ## 검증 @@ -33,11 +33,11 @@ o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete 설정 전에는 이 두 줄이 나오지 않는다. 컨텍스트 종료 시 `GracefulShutdown`이 실제로 실행됨을 확인했다. -**이 검증의 한계**: 진행 중인 요청이 실제로 *완료되는지*는 확인하지 못했다. 아직 도메인 엔드포인트가 없어서(`global/`만 존재) 배수를 관찰할 만큼 오래 걸리는 요청을 만들 수 없다. 위 로그는 "배수 절차가 실행된다"까지만 증명하고, "진행 중 요청이 잘리지 않는다"는 증명하지 않는다. Record API(S15P11A705-67)가 들어온 뒤 느린 요청 하나로 실제 배수를 확인하는 것이 남는다. +**이 검증의 한계**: 진행 중인 요청이 실제로 *완료되는지*는 확인하지 못했다. 아직 도메인 엔드포인트가 없어서(`global/`만 존재) 배수를 관찰할 만큼 오래 걸리는 요청을 만들 수 없다. 위 로그는 "배수 절차가 실행된다"까지만 증명하고, "진행 중 요청이 잘리지 않는다"는 증명하지 않는다. Record API(Jira 작업)가 들어온 뒤 느린 요청 하나로 실제 배수를 확인하는 것이 남는다. ### probe 경로 -`health.probes.enabled: true`는 이미 있었지만 liveness/readiness 경로를 검증하는 테스트가 없었다. Infra(S15P11A705-47)가 이 경로를 probe로 박을 예정이라, 경로가 바뀌면 CrashLoop로 이어진다. 회귀 감시로 테스트를 추가했다. +`health.probes.enabled: true`는 이미 있었지만 liveness/readiness 경로를 검증하는 테스트가 없었다. Infra(Jira 작업)가 이 경로를 probe로 박을 예정이라, 경로가 바뀌면 CrashLoop로 이어진다. 회귀 감시로 테스트를 추가했다. ## 반복될 함정 (다음 사람에게) @@ -56,8 +56,8 @@ Docker Desktop 실행 상태에서 PostgreSQL/pgvector Testcontainers 포함 전 ## 남은 것 -S15P11A705-51은 단발 작업이 아니라 상시 계약이라 이 커밋으로 닫히지 않는다. 남은 항목: +Jira 작업은 단발 작업이 아니라 상시 계약이라 이 커밋으로 닫히지 않는다. 남은 항목: - Infra 쪽 짝(`preStop`, `terminationGracePeriodSeconds`) 반영 — Infra 레포 이슈로 전달 -- 진행 중 요청의 실제 배수 검증 — 도메인 엔드포인트(S15P11A705-67) 이후 -- S15P11A705-48(최초 내부 배포)에서 probe·metrics 계약이 실제 클러스터에서 통하는지 확인 +- 진행 중 요청의 실제 배수 검증 — 도메인 엔드포인트(Jira 작업) 이후 +- Jira 작업(최초 내부 배포)에서 probe·metrics 계약이 실제 클러스터에서 통하는지 확인 diff --git a/docs/backend/implements/BI-06-2026-07-27-deployment-contract-verification.md b/docs/backend/implements/BI-06-2026-07-27-deployment-contract-verification.md index 6fcc76db..7ea625b2 100644 --- a/docs/backend/implements/BI-06-2026-07-27-deployment-contract-verification.md +++ b/docs/backend/implements/BI-06-2026-07-27-deployment-contract-verification.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 (검증), ⚠️ blocker 2건 별도 - **날짜**: 2026-07-27 -- **관련**: S15P11A705-51, Infra 연계 S15P11A705-46·S15P11A705-47, [BT-02](../troubleshooting/BT-02-flyway-out-of-order-version-ranges.md) · [BT-03](../troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md) +- **관련**: Jira 작업, Infra 연계 Jira 작업, [BT-02](../troubleshooting/BT-02-flyway-out-of-order-version-ranges.md) · [BT-03](../troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md) Infra가 전달한 "Backend 배포 연동 수정·확인 체크리스트"를 항목별로 검증했다. 실제로 수정한 것은 pgvector 버전 정렬뿐이고 나머지는 이미 충족 상태였다. @@ -32,7 +32,7 @@ Infra가 전달한 "Backend 배포 연동 수정·확인 체크리스트"를 항 ALTER EXTENSION vector UPDATE; -- 0.8.1 → 0.8.5 확인 ``` -운영 전환(S15P11A705-46)에서도 기존 PVC를 유지하면 같은 상황이 되므로 Infra에 전달했다. +운영 전환(Jira 작업)에서도 기존 PVC를 유지하면 같은 상황이 되므로 Infra에 전달했다. ### 운영 환경변수만으로 기동 @@ -101,5 +101,5 @@ docker build --platform linux/amd64 --build-arg BUILD_SHA=$(git rev-parse HEAD) ## 남은 blocker -1. [BT-02](../troubleshooting/BT-02-flyway-out-of-order-version-ranges.md) — 백엔드 migration 번호 구간이 AI 구간보다 낮아, AI migration이 적용된 DB에서는 백엔드 migration 추가 시 Flyway validate가 실패한다. **S15P11A705-66 착수 전에 합의가 필요하다.** +1. [BT-02](../troubleshooting/BT-02-flyway-out-of-order-version-ranges.md) — 백엔드 migration 번호 구간이 AI 구간보다 낮아, AI migration이 적용된 DB에서는 백엔드 migration 추가 시 Flyway validate가 실패한다. **Jira 작업 착수 전에 합의가 필요하다.** 2. [BT-03](../troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md) — Redis 장애 시 집계 `/health`가 60초 블로킹. probe 경로를 liveness·readiness로 한정하면 회피되지만, 그 경우 의존성 장애가 readiness에 반영되지 않는다. diff --git a/docs/backend/implements/BI-07-2026-07-27-framework-error-mapping.md b/docs/backend/implements/BI-07-2026-07-27-framework-error-mapping.md index 61d3bd6c..ca4752d6 100644 --- a/docs/backend/implements/BI-07-2026-07-27-framework-error-mapping.md +++ b/docs/backend/implements/BI-07-2026-07-27-framework-error-mapping.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-27 -- **관련**: S15P11A705-40 (`1911dc9`, `cae552e`, `7e31e02`), [BD-06](../decisions/BD-06-framework-error-mapping.md) +- **관련**: Jira 작업 (`1911dc9`, `cae552e`, `7e31e02`), [BD-06](../decisions/BD-06-framework-error-mapping.md) ## 산출 diff --git a/docs/backend/implements/BI-08-2026-07-28-core-domain-foundation.md b/docs/backend/implements/BI-08-2026-07-28-core-domain-foundation.md index 5c506af1..a0c72181 100644 --- a/docs/backend/implements/BI-08-2026-07-28-core-domain-foundation.md +++ b/docs/backend/implements/BI-08-2026-07-28-core-domain-foundation.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-66, [BD-25](../decisions/BD-25-context-origin-created-at.md), [BD-08](../decisions/BD-08-soft-delete-no-restore.md), [BD-14](../decisions/BD-14-identifier-concealment.md) +- **관련**: Jira 작업, [BD-25](../decisions/BD-25-context-origin-created-at.md), [BD-08](../decisions/BD-08-soft-delete-no-restore.md), [BD-14](../decisions/BD-14-identifier-concealment.md) ## 산출 @@ -25,7 +25,7 @@ 구 Context의 값을 승계한다(BD-25). `Collection`도 같은 방식으로 `published_at`을 생성 시각과 같은 값으로 채운다(BD-23 생성 즉시 자동 발행). - 인증 스텁·컨트롤러·서비스는 내지 않았다(티켓 명시). back#28에서 합의한 `@LoginMember` 스텁 계약은 - 첫 컨트롤러가 나오는 S15P11A705-67에서 도입한다. + 첫 컨트롤러가 나오는 Jira 작업에서 도입한다. ## 검증 diff --git a/docs/backend/implements/BI-09-2026-07-28-record-context-api.md b/docs/backend/implements/BI-09-2026-07-28-record-context-api.md index 0f18e1e9..e020a830 100644 --- a/docs/backend/implements/BI-09-2026-07-28-record-context-api.md +++ b/docs/backend/implements/BI-09-2026-07-28-record-context-api.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-67, back#28(인증 스텁 계약), [BD-12](../decisions/BD-12-duplicate-record-idempotent.md), [BD-14](../decisions/BD-14-identifier-concealment.md), [BD-07](../decisions/BD-07-context-immutability.md), [BD-25](../decisions/BD-25-context-origin-created-at.md) +- **관련**: Jira 작업, back#28(인증 스텁 계약), [BD-12](../decisions/BD-12-duplicate-record-idempotent.md), [BD-14](../decisions/BD-14-identifier-concealment.md), [BD-07](../decisions/BD-07-context-immutability.md), [BD-25](../decisions/BD-25-context-origin-created-at.md) ## 산출 diff --git a/docs/backend/implements/BI-10-2026-07-28-collection-api.md b/docs/backend/implements/BI-10-2026-07-28-collection-api.md index 2f667528..2b126120 100644 --- a/docs/backend/implements/BI-10-2026-07-28-collection-api.md +++ b/docs/backend/implements/BI-10-2026-07-28-collection-api.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-68, [BD-11](../decisions/BD-11-minimum-holding-invariants.md), [BD-23](../decisions/BD-23-collection-auto-publish.md), [BD-04](../decisions/BD-04-cursor-pagination.md) +- **관련**: Jira 작업, [BD-11](../decisions/BD-11-minimum-holding-invariants.md), [BD-23](../decisions/BD-23-collection-auto-publish.md), [BD-04](../decisions/BD-04-cursor-pagination.md) ## 산출 @@ -19,7 +19,7 @@ - `POST /v1/collections/{collectionId}/records` — **멱등 추가**(명세 7.5): 이미 담긴 Record는 건너뛰고 나머지만 담는다. `findByIdForUpdate`(PESSIMISTIC_WRITE)로 Collection을 잠근 뒤 활성 연결 판단과 `record_count` 갱신을 같은 트랜잭션에서 수행(데이터모델 6.7과 같은 경합 구조). -- 타인 조회·수정은 전부 404 은닉. 타인용 공개 응답은 S15P11A705-71에서 붙는다. +- 타인 조회·수정은 전부 404 은닉. 타인용 공개 응답은 Jira 작업에서 붙는다. ## 티켓과 정본 스펙의 충돌 (Jira 댓글로 기록) diff --git a/docs/backend/implements/BI-11-2026-07-28-follow-library-api.md b/docs/backend/implements/BI-11-2026-07-28-follow-library-api.md index eb38ad5f..f426b19b 100644 --- a/docs/backend/implements/BI-11-2026-07-28-follow-library-api.md +++ b/docs/backend/implements/BI-11-2026-07-28-follow-library-api.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-69, [BD-15](../decisions/BD-15-shelf-not-a-table.md), [BD-14](../decisions/BD-14-identifier-concealment.md) +- **관련**: Jira 작업, [BD-15](../decisions/BD-15-shelf-not-a-table.md), [BD-14](../decisions/BD-14-identifier-concealment.md) ## 산출 diff --git a/docs/backend/implements/BI-12-2026-07-28-deletion-cascade.md b/docs/backend/implements/BI-12-2026-07-28-deletion-cascade.md index 496b54cc..8224716e 100644 --- a/docs/backend/implements/BI-12-2026-07-28-deletion-cascade.md +++ b/docs/backend/implements/BI-12-2026-07-28-deletion-cascade.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-70, [BD-11](../decisions/BD-11-minimum-holding-invariants.md), [BD-08](../decisions/BD-08-soft-delete-no-restore.md), [BD-09](../decisions/BD-09-no-record-update-path.md) +- **관련**: Jira 작업, [BD-11](../decisions/BD-11-minimum-holding-invariants.md), [BD-08](../decisions/BD-08-soft-delete-no-restore.md), [BD-09](../decisions/BD-09-no-record-update-path.md) ## 산출 @@ -23,7 +23,7 @@ Record 삭제가 Collection의 record_count를 만질 때는 해당 Collection도 잠근다(6.7과 같은 경합). 잠금 순서는 Record → Collection 단방향이라 교착이 없다(addRecords는 Collection만 잠근다). - > **정정 (2026-07-31, S15P11A705-201).** 위 마지막 문장의 근거가 불완전하다. "Record → Collection + > **정정 (2026-07-31, Jira 작업).** 위 마지막 문장의 근거가 불완전하다. "Record → Collection > 단방향"은 **타입 사이의 순서만** 논증하고 **Collection 사이의 순서는 다루지 않는다.** 서로 다른 > Record를 지우는 두 트랜잭션이 같은 Collection 둘을 반대 순서로 잡으면 교착이 날 수 있고, 그 > 순서를 정하는 것은 `cascadeDelete`가 부르는 역조회 쿼리다. diff --git a/docs/backend/implements/BI-13-2026-07-28-public-scope-filter.md b/docs/backend/implements/BI-13-2026-07-28-public-scope-filter.md index 6a5232bf..449a0d8f 100644 --- a/docs/backend/implements/BI-13-2026-07-28-public-scope-filter.md +++ b/docs/backend/implements/BI-13-2026-07-28-public-scope-filter.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-71, [BD-13](../decisions/BD-13-public-boundary-query-dto-split.md), [BD-14](../decisions/BD-14-identifier-concealment.md) +- **관련**: Jira 작업, [BD-13](../decisions/BD-13-public-boundary-query-dto-split.md), [BD-14](../decisions/BD-14-identifier-concealment.md) ## 산출 diff --git a/docs/backend/implements/BI-14-2026-07-28-record-create-conflict-free-insert.md b/docs/backend/implements/BI-14-2026-07-28-record-create-conflict-free-insert.md index db88c234..ed48b652 100644 --- a/docs/backend/implements/BI-14-2026-07-28-record-create-conflict-free-insert.md +++ b/docs/backend/implements/BI-14-2026-07-28-record-create-conflict-free-insert.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-105, [BD-12](../decisions/BD-12-duplicate-record-idempotent.md), [BD-19](../decisions/BD-19-place-snapshot.md) +- **관련**: Jira 작업, [BD-12](../decisions/BD-12-duplicate-record-idempotent.md), [BD-19](../decisions/BD-19-place-snapshot.md) ## 증상 @@ -43,4 +43,4 @@ JPA에서 제약 위반이 나면 그 트랜잭션은 rollback-only로 표시되 ## 남은 것 -`POST /v1/follows`의 같은 성격의 경합(`uq_follow_active` 위반 → 500, 409 `DUPLICATE_FOLLOW`가 있는데도)은 별도 티켓 S15P11A705-104다. Follow는 되돌릴 Record가 없어 이 패턴이 그대로 적용되지 않는다 — 중복이면 기존 Follow를 돌려주는 게 아니라 409로 거절해야 하므로, 그쪽은 예외를 잡아 변환하는 편이 맞다. +`POST /v1/follows`의 같은 성격의 경합(`uq_follow_active` 위반 → 500, 409 `DUPLICATE_FOLLOW`가 있는데도)은 별도 티켓 Jira 작업다. Follow는 되돌릴 Record가 없어 이 패턴이 그대로 적용되지 않는다 — 중복이면 기존 Follow를 돌려주는 게 아니라 409로 거절해야 하므로, 그쪽은 예외를 잡아 변환하는 편이 맞다. diff --git a/docs/backend/implements/BI-15-2026-07-28-duplicate-follow-race.md b/docs/backend/implements/BI-15-2026-07-28-duplicate-follow-race.md index b0b988f2..4ea94bc8 100644 --- a/docs/backend/implements/BI-15-2026-07-28-duplicate-follow-race.md +++ b/docs/backend/implements/BI-15-2026-07-28-duplicate-follow-race.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-104, [BI-14](BI-14-2026-07-28-record-create-conflict-free-insert.md)(같은 성격의 Record 쪽 경합) +- **관련**: Jira 작업, [BI-14](BI-14-2026-07-28-record-create-conflict-free-insert.md)(같은 성격의 Record 쪽 경합) ## 증상 @@ -22,7 +22,7 @@ 같은 "확인 후 저장" 경합이지만 처방이 갈린다. -| | S15P11A705-105 (Record) | S15P11A705-104 (Follow) | +| | Jira 작업 (Record) | Jira 작업 (Follow) | |---|---|---| | 원하는 결과 | 기존 Record에 Context 추가(멱등) | **거절**(409) | | 위반 뒤 DB 작업 | 필요함 — 재조회 + Context INSERT | 없음 — 예외만 바꿔 던짐 | diff --git a/docs/backend/implements/BI-16-2026-07-28-input-size-limits.md b/docs/backend/implements/BI-16-2026-07-28-input-size-limits.md index 4485edb2..b9550d83 100644 --- a/docs/backend/implements/BI-16-2026-07-28-input-size-limits.md +++ b/docs/backend/implements/BI-16-2026-07-28-input-size-limits.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-117, [BD-04](../decisions/BD-04-cursor-pagination.md)(`CursorPage.MAX_SIZE` 방어 상한 선례), [docs#19](https://github.com/Team-PinLog/docs/pull/19) +- **관련**: Jira 작업, [BD-04](../decisions/BD-04-cursor-pagination.md)(`CursorPage.MAX_SIZE` 방어 상한 선례), [docs#19](https://github.com/Team-PinLog/docs/pull/19) ## 증상 @@ -34,7 +34,7 @@ ## 왜 상수를 한 곳에 모았나 -500이 세 DTO에, 100이 두 DTO에 들어간다. 리터럴로 흩뿌리면 한 곳만 바뀌어 **엔드포인트마다 상한이 달라지는** 상태가 조용히 생긴다. 이 레포에서 같은 성격의 사고가 이미 있었다 — envelope 판정이 런타임 advice와 문서 생성기 두 곳에 각자 있어서 결론이 갈렸다(S15P11A705-85). +500이 세 DTO에, 100이 두 DTO에 들어간다. 리터럴로 흩뿌리면 한 곳만 바뀌어 **엔드포인트마다 상한이 달라지는** 상태가 조용히 생긴다. 이 레포에서 같은 성격의 사고가 이미 있었다 — envelope 판정이 런타임 advice와 문서 생성기 두 곳에 각자 있어서 결론이 갈렸다(Jira 작업). `recordIds` 상한은 `CursorPage.MAX_SIZE`와 같은 값으로 정했다. **코드로 묶지는 않았다** — `global/common`이 `global/response`를 참조하는 방향이 되고, 두 값이 같아야 할 본질적 이유가 있는 것은 아니라서다. 대신 javadoc에 근거를 적었다. 감수하는 것: 한쪽만 바뀌면 두 기준이 갈린다. diff --git a/docs/backend/implements/BI-17-2026-07-28-alias-key-omission-contract.md b/docs/backend/implements/BI-17-2026-07-28-alias-key-omission-contract.md index 4ad50c26..7c0fffc8 100644 --- a/docs/backend/implements/BI-17-2026-07-28-alias-key-omission-contract.md +++ b/docs/backend/implements/BI-17-2026-07-28-alias-key-omission-contract.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-116, [docs#20](https://github.com/Team-PinLog/docs/pull/20) +- **관련**: Jira 작업, [docs#20](https://github.com/Team-PinLog/docs/pull/20) ## 증상 @@ -33,7 +33,7 @@ Follow에서 **수정 가능한 필드가 `alias` 하나뿐**이라 부분 수 ## 검증 — 고정 테스트에는 자연스러운 RED가 없다 -동작이 이미 그렇게 되어 있으므로 테스트는 처음부터 통과한다. **그래서 통과만으로는 이 테스트가 무언가를 고정한다는 증거가 되지 않는다.** 기각한 대안을 실제로 넣어 보고 잡히는지 확인했다(`Cursor.decode`를 뮤테이션 검사로 고정한 S15P11A705-42와 같은 방식). +동작이 이미 그렇게 되어 있으므로 테스트는 처음부터 통과한다. **그래서 통과만으로는 이 테스트가 무언가를 고정한다는 증거가 되지 않는다.** 기각한 대안을 실제로 넣어 보고 잡히는지 확인했다(`Cursor.decode`를 뮤테이션 검사로 고정한 Jira 작업와 같은 방식). `FollowService.changeAlias`에 "null이면 미변경"을 임시로 넣고 실행: diff --git a/docs/backend/implements/BI-18-2026-07-28-jwt-cookie-session.md b/docs/backend/implements/BI-18-2026-07-28-jwt-cookie-session.md index c2077afd..3974580d 100644 --- a/docs/backend/implements/BI-18-2026-07-28-jwt-cookie-session.md +++ b/docs/backend/implements/BI-18-2026-07-28-jwt-cookie-session.md @@ -2,13 +2,13 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-63 +- **관련**: Jira 작업 - **근거 결정**: [BD-21](../decisions/BD-21-auth-token-model.md)(토큰 모델) · [BD-31](../decisions/BD-31-jwt-rs256-key-management.md)(알고리즘·키 관리) - **계약**: [인증 PR 계약](../../development/authentication.md) ## 무엇을 만들었나 -`OAuthLoginSuccessHandler`가 회원만 확정하고 쿠키 없이 리다이렉트하던 자리(`TODO(S15P11A705-63)`)를 채웠다. 그 한 줄이 **OAuth Client 역할의 끝**과 **BFF·리소스 서버 역할의 시작** 사이 경계였다. +`OAuthLoginSuccessHandler`가 회원만 확정하고 쿠키 없이 리다이렉트하던 자리(`TODO(Jira 작업)`)를 채웠다. 그 한 줄이 **OAuth Client 역할의 끝**과 **BFF·리소스 서버 역할의 시작** 사이 경계였다. | 역할 | 산출 | | --- | --- | @@ -139,7 +139,7 @@ readiness에 Redis를 넣지 않은 판단은 BD-28에 있고 여기서 뒤집 - **키 회전** — `kid`만 선반영했고 다중 키 검증은 없다. 지금 키를 바꾸면 전면 로그아웃이다. - **Access 즉시 무효화** — 로그아웃해도 Access는 최대 30분 유효하다. BD-21이 감수한 범위다. -- **404(타인 자원 접근) 테스트** — 소유자가 있는 도메인 리소스가 이 브랜치에 없어 검증 대상이 없다. S15P11A705-67~71이 가져온다. +- **404(타인 자원 접근) 테스트** — 소유자가 있는 도메인 리소스가 이 브랜치에 없어 검증 대상이 없다. Jira 작업~71이 가져온다. - **`Team-PinLog/docs`의 `static/08_API_명세.md` 개정** — 별도 저장소라 이 PR 밖이다. - **인프라에 `JWT_PRIVATE_KEY` 요청** — 운영 배포 전에 Secret이 없으면 파드가 뜨지 않는다. -- **도메인 브랜치 스텁 제거** — S15P11A705-67의 `X-Debug-Member-Id` 리졸버와 `pinlog.auth.stub.enabled`를 병합 시 반드시 버려야 한다. 남으면 운영 인증 우회 구멍이다. +- **도메인 브랜치 스텁 제거** — Jira 작업의 `X-Debug-Member-Id` 리졸버와 `pinlog.auth.stub.enabled`를 병합 시 반드시 버려야 한다. 남으면 운영 인증 우회 구멍이다. diff --git a/docs/backend/implements/BI-19-2026-07-28-published-at-invariant.md b/docs/backend/implements/BI-19-2026-07-28-published-at-invariant.md index a324f755..a373c93a 100644 --- a/docs/backend/implements/BI-19-2026-07-28-published-at-invariant.md +++ b/docs/backend/implements/BI-19-2026-07-28-published-at-invariant.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-28 -- **관련**: S15P11A705-125, [back#58](https://github.com/Team-PinLog/back/issues/58), +- **관련**: Jira 작업, [back#58](https://github.com/Team-PinLog/back/issues/58), [BD-33](../decisions/BD-33-published-at-database-invariant.md) ## 산출 diff --git a/docs/backend/implements/BI-20-2026-07-29-feed-recommendation-mvp.md b/docs/backend/implements/BI-20-2026-07-29-feed-recommendation-mvp.md index 632eee46..e9762d28 100644 --- a/docs/backend/implements/BI-20-2026-07-29-feed-recommendation-mvp.md +++ b/docs/backend/implements/BI-20-2026-07-29-feed-recommendation-mvp.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 (일부 범위 후속) - **날짜**: 2026-07-29 -- **관련**: S15P11A705-120, [back#58](https://github.com/Team-PinLog/back/issues/58), +- **관련**: Jira 작업, [back#58](https://github.com/Team-PinLog/back/issues/58), [BD-34](../decisions/BD-34-feed-deterministic-pagination-without-session-cache.md), AI 파트 명세 `docs/ai/spec/feed-*.md`, [P42](../../ai/proposals/P42-feed-mvp-without-place-metadata.md) @@ -58,7 +58,7 @@ Redis Session Cache 대신 `requestId`를 무작위 채널의 seed로 삼아 페 재계산한다. 근거와 감수하는 점은 [BD-34](../decisions/BD-34-feed-deterministic-pagination-without-session-cache.md). 커서는 공통 `Cursor`(`Base64(정렬키,id)`)를 재사용해 정렬키에 `requestId`, id에 offset을 넣는다. -Feed 전용 커서 형식도, Feed 전용 `size` 상한도 만들지 않았다 — 후자는 S15P11A705-117의 +Feed 전용 커서 형식도, Feed 전용 `size` 상한도 만들지 않았다 — 후자는 Jira 작업의 "서버 방어 상한의 답은 하나" 규약이다. `requestId`는 `data` 안의 **별도 필드**다. 커서에 인코딩하면 클라이언트가 이벤트 보고에 쓸 값을 diff --git a/docs/backend/implements/BI-21-2026-07-29-refresh-reuse-family-revocation.md b/docs/backend/implements/BI-21-2026-07-29-refresh-reuse-family-revocation.md index 8db7ecf0..da892aba 100644 --- a/docs/backend/implements/BI-21-2026-07-29-refresh-reuse-family-revocation.md +++ b/docs/backend/implements/BI-21-2026-07-29-refresh-reuse-family-revocation.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-29 -- **관련**: S15P11A705-131, [back#78](https://github.com/Team-PinLog/back/issues/78), +- **관련**: Jira 작업, [back#78](https://github.com/Team-PinLog/back/issues/78), [BD-35](../decisions/BD-35-refresh-reuse-family-revocation.md)(이 구현의 결정), [BD-32](../decisions/BD-32-refresh-reuse-no-family-revocation.md)(대체된 결정), [BI-18](BI-18-2026-07-28-jwt-cookie-session.md)(세션 JWT 도입) @@ -60,7 +60,7 @@ expected: 401 ## 남은 것 -- **회원 탈퇴([S15P11A705-65](https://ssafy.atlassian.net/browse/S15P11A705-65))가 같은 `revokeAll`을 쓴다.** BD-32가 "두 작업을 같이 하는 것이 자연스럽다"고 적어 둔 이유이고, 이번에 그 연산이 준비됐다. +- **회원 탈퇴(Jira 작업)가 같은 `revokeAll`을 쓴다.** BD-32가 "두 작업을 같이 하는 것이 자연스럽다"고 적어 둔 이유이고, 이번에 그 연산이 준비됐다. - **오탐 관측 경로가 없다.** 재사용 감지 시 WARN은 남지만, 그것이 유출인지 클라이언트 버그인지 구별할 정보는 로그에 없다. 오탐이 문의로 올라오면 [BD-35](../decisions/BD-35-refresh-reuse-family-revocation.md)의 재검토 트리거를 따른다. - 공용 계약([08 §3.3](https://github.com/Team-PinLog/docs/blob/main/static/08_API_명세.md))에 이 동작을 적을지는 별도 판단으로 남겼다. 명세는 "재사용: 401"까지만 정의하고 거짓을 말하지 않으며, 클라이언트가 취할 행동이 달라지지 않는다. - **경합을 재현하는 테스트는 없다.** 원자성은 구조로 보장되는 성질(Lua 한 번의 호출)이라 단언으로 고정할 대상이 아니고, 스레드를 겹쳐 짜면 통과가 타이밍에 걸리는 테스트가 된다. 대신 `revokeAll`의 계약(전부 삭제 · 인덱스 삭제 · 회원 간 격리 · 반환 수)을 고정했다. diff --git a/docs/backend/implements/BI-22-2026-07-29-context-ai-enqueue.md b/docs/backend/implements/BI-22-2026-07-29-context-ai-enqueue.md index 695272d9..8d0f3743 100644 --- a/docs/backend/implements/BI-22-2026-07-29-context-ai-enqueue.md +++ b/docs/backend/implements/BI-22-2026-07-29-context-ai-enqueue.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-29 -- **관련**: S15P11A705-102, [back#61](https://github.com/Team-PinLog/back/issues/61), +- **관련**: Jira 작업, [back#61](https://github.com/Team-PinLog/back/issues/61), [BD-36](../decisions/BD-36-pending-insert-in-transaction-process-call-after-commit.md), [BI-21](BI-21-2026-07-29-ai-derived-invalidation-on-delete.md)(back#80, 삭제 방향), AI 파트 소유 명세 `docs/ai/spec/context-state-sync.md`·`docs/ai/spec/ai-integration.md`, @@ -110,7 +110,7 @@ pinlog: ``` 검색용 키(`search`·`embedding-profile`)는 명세에 있지만 두지 않았다. 소비자가 생기는 티켓 -(S15P11A705-135)에서 함께 들어와야 "설정은 있는데 아무도 안 읽는" 구간이 생기지 않는다. +(Jira 작업)에서 함께 들어와야 "설정은 있는데 아무도 안 읽는" 구간이 생기지 않는다. ## 검증 @@ -169,7 +169,7 @@ pinlog: - **재스캔 Scheduler와 재시도 소진 Finalizer**(별건). 큐 포화·호출 실패로 남은 PENDING을 실제로 복구하는 주체다. 붙기 전까지 버려진 호출은 실질적으로 유실이다. -- **검색 연동**(S15P11A705-135). `AiSearchClient`와 `pinlog.ai.search`·`embedding-profile` 설정. +- **검색 연동**(Jira 작업). `AiSearchClient`와 `pinlog.ai.search`·`embedding-profile` 설정. - **관찰 지표**(`ai-integration.md` §8). 호출 성공·실패 건수와 상태별 State 건수는 아직 없다. - **`docs/ai/spec/ai-integration.md` §7의 헤더 이름 불일치.** 위 "산출" 참조. 정본이 아니므로 고치지 않고 표시만 남겼다. diff --git a/docs/backend/implements/BI-23-2026-07-29-ai-derived-invalidation-on-delete.md b/docs/backend/implements/BI-23-2026-07-29-ai-derived-invalidation-on-delete.md index 1a156ccf..e68190e4 100644 --- a/docs/backend/implements/BI-23-2026-07-29-ai-derived-invalidation-on-delete.md +++ b/docs/backend/implements/BI-23-2026-07-29-ai-derived-invalidation-on-delete.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 (적용 지점 4 중 3 — 회원 탈퇴는 경로 미구현, 아래 "적용하지 못한 지점") - **날짜**: 2026-07-29 -- **관련**: S15P11A705-124, [back#61](https://github.com/Team-PinLog/back/issues/61), +- **관련**: Jira 작업, [back#61](https://github.com/Team-PinLog/back/issues/61), [BD-37](../decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md), 공용 계약 `Team-PinLog/docs` `static/06_데이터모델_및_무결성.md` §1.3·§6.4~6.9, `static/08_API_명세.md` "AI 파생 데이터" @@ -19,7 +19,7 @@ 소유이고, 백엔드가 엔티티를 들면 소유하지 않은 스키마의 형상을 코드에 고정하게 된다. **배치 기준은 호출부가 아니라 닿는 외부 경계다.** `ai` 스키마에 닿는 코드를 `domain/ai` 한곳에 -모은다 — 같은 패키지의 `ContextAiStateRepository`(S15P11A705-102, back#82)와 짝이다. 호출부 +모은다 — 같은 패키지의 `ContextAiStateRepository`(Jira 작업, back#82)와 짝이다. 호출부 둘(`RecordDeletionService`·`RecordService`)은 `domain/record/service`지만, 소비 도메인별로 흩으면 회원 탈퇴(6.9)가 붙을 때 세 번째 위치가 생기고 **`ai` 스키마 접근이 세 패키지로 갈린다.** diff --git a/docs/backend/implements/BI-24-2026-07-29-kakao-naver-login.md b/docs/backend/implements/BI-24-2026-07-29-kakao-naver-login.md index 1880776d..deaab616 100644 --- a/docs/backend/implements/BI-24-2026-07-29-kakao-naver-login.md +++ b/docs/backend/implements/BI-24-2026-07-29-kakao-naver-login.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-29 -- **관련**: S15P11A705-64, [back#33](https://github.com/Team-PinLog/back/issues/33), +- **관련**: Jira 작업, [back#33](https://github.com/Team-PinLog/back/issues/33), [BI-18](BI-18-2026-07-28-jwt-cookie-session.md)(인증 골격), [BT-05](../troubleshooting/BT-05-dotenv-empty-value-overrides-default.md)(작업 중 발견) diff --git a/docs/backend/implements/BI-25-2026-07-29-personal-search-backend-integration.md b/docs/backend/implements/BI-25-2026-07-29-personal-search-backend-integration.md index 29a44e82..475d910e 100644 --- a/docs/backend/implements/BI-25-2026-07-29-personal-search-backend-integration.md +++ b/docs/backend/implements/BI-25-2026-07-29-personal-search-backend-integration.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-29 -- **관련**: S15P11A705-135, [back#61](https://github.com/Team-PinLog/back/issues/61), +- **관련**: Jira 작업, [back#61](https://github.com/Team-PinLog/back/issues/61), [BD-39](../decisions/BD-39-embedding-profile-in-application-config.md), [BD-36](../decisions/BD-36-pending-insert-in-transaction-process-call-after-commit.md)(back#82, 데이터 생성 선행), [BD-37](../decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md)(back#80, 삭제분 제외 선행), @@ -216,7 +216,7 @@ checkstyle(main·test)·jacoco 커버리지 검증 포함. `RecordSearchApiTests "값 자체는 코드에 상수로 두지 않습니다" → 개정된 §7.1과 BD-39에 맞춘 본문. `base-url`도 리터럴로 적혀 있어 함께 맞췄다 — 같은 블록에 알면서 틀린 줄을 남기면 아래 불완전 정정을 되풀이하게 된다. - > **`internal-token` 재검색.** §7의 헤더 표기는 back#83(S15P11A705-96)이 이미 고쳤는데 이 설정 키만 + > **`internal-token` 재검색.** §7의 헤더 표기는 back#83(Jira 작업)이 이미 고쳤는데 이 설정 키만 > 남은 이유는 **당시 전수 검색이 `X-Internal[-_]?(Token|Secret)` 패턴이라 헤더만 잡고 설정 키를 > 놓쳤기 때문**이다(그 PR 본문의 "잔존 0건"은 사실과 달랐다). 패턴을 `internal.token|INTERNAL_TOKEN| > internal-token`으로 넓혀 `back`·`docs`·`ai` 세 레포를 다시 훑었다 — `docs` 0건, `ai` 0건, diff --git a/docs/backend/implements/BI-26-2026-07-29-runtime-secret-workflow.md b/docs/backend/implements/BI-26-2026-07-29-runtime-secret-workflow.md index c14bbb1a..f46e8145 100644 --- a/docs/backend/implements/BI-26-2026-07-29-runtime-secret-workflow.md +++ b/docs/backend/implements/BI-26-2026-07-29-runtime-secret-workflow.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-29 -- **관련**: S15P11A705-154, [infra#78](https://github.com/Team-PinLog/infra/pull/78) +- **관련**: Jira 작업, [infra#78](https://github.com/Team-PinLog/infra/pull/78) ## 산출 @@ -33,4 +33,4 @@ PINLOG_INFRA_SECRET_PR_TOKEN ← bridge token 근거는 `.github/workflows/seal-runtime-secrets.yml` 22~31행과 `RuntimeSecretWorkflowContractTests`의 `containsExactlyElementsOf` 단언이다. 후자가 9개를 그대로 열거하므로, 숫자가 6이었다면 이 기록과 통과하는 테스트가 서로 어긋난 상태였다. -봉인 정책(`infra/policy/sealedsecrets/back-prod.yaml`)의 **현행 owner runtime key는 8개**다. 위 목록에서 bridge token만 뺀 집합이며 `PINLOG_AI_INTERNAL_SECRET`도 포함한다([back#105](https://github.com/Team-PinLog/back/issues/105)). 이 문단은 처음에 해당 키를 누락해 7개로 기록했으나 S15P11A705-154에서 정책 원문과 workflow/test의 8-key 집합에 맞게 정정했다. 따라서 구분해야 할 숫자는 runtime 8개와 bridge를 포함한 workflow 참조 9개다. +봉인 정책(`infra/policy/sealedsecrets/back-prod.yaml`)의 **현행 owner runtime key는 8개**다. 위 목록에서 bridge token만 뺀 집합이며 `PINLOG_AI_INTERNAL_SECRET`도 포함한다([back#105](https://github.com/Team-PinLog/back/issues/105)). 이 문단은 처음에 해당 키를 누락해 7개로 기록했으나 Jira 작업에서 정책 원문과 workflow/test의 8-key 집합에 맞게 정정했다. 따라서 구분해야 할 숫자는 runtime 8개와 bridge를 포함한 workflow 참조 9개다. diff --git a/docs/backend/implements/BI-27-2026-07-30-social-login-email-required.md b/docs/backend/implements/BI-27-2026-07-30-social-login-email-required.md index dfa751f1..aa145855 100644 --- a/docs/backend/implements/BI-27-2026-07-30-social-login-email-required.md +++ b/docs/backend/implements/BI-27-2026-07-30-social-login-email-required.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-30 -- **관련**: S15P11A705-152, [back#97](https://github.com/Team-PinLog/back/issues/97), +- **관련**: Jira 작업, [back#97](https://github.com/Team-PinLog/back/issues/97), [docs#28](https://github.com/Team-PinLog/docs/pull/28)(공용 계약 개정 — 선행), [BI-24](BI-24-2026-07-29-kakao-naver-login.md)(이메일 `null` 허용을 계약으로 고정했던 기록) @@ -45,7 +45,7 @@ ## 남은 것 -- **탈퇴 구현([S15P11A705-65](https://ssafy.atlassian.net/browse/S15P11A705-65))이 마스킹 치환값을 정한다.** 계약이 정한 것은 `NULL`이 아니라는 것과 원본을 되돌릴 수 없어야 한다는 것까지다. 치환값이 회원끼리 겹쳐도 무해하다(유니크가 활성행만 대상이고 마스킹 시점엔 `deleted_at`이 채워져 인덱스 밖이다). +- **탈퇴 구현(Jira 작업)이 마스킹 치환값을 정한다.** 계약이 정한 것은 `NULL`이 아니라는 것과 원본을 되돌릴 수 없어야 한다는 것까지다. 치환값이 회원끼리 겹쳐도 무해하다(유니크가 활성행만 대상이고 마스킹 시점엔 `deleted_at`이 채워져 인덱스 밖이다). - **번호가 `BI-25`에서 `BI-27`로 두 칸 밀렸다.** 세 PR이 `BI-25`를 동시에 선점했고 머지 순서대로 확정됐다 — [#98](https://github.com/Team-PinLog/back/pull/98)이 `BI-25`, [#100](https://github.com/Team-PinLog/back/pull/100)이 `BI-26`, 이 기록이 `BI-27`이다. `#100`이 머지되기 전에 `BI-27`을 미리 배정해 재번호를 한 번만 했다. 규약대로 "미머지 브랜치의 파일명 선점은 예약이 아니다"가 실제로 세 번 적용된 사례다. ## 검증 diff --git a/docs/backend/implements/BI-28-2026-07-30-ai-rescan-scheduler.md b/docs/backend/implements/BI-28-2026-07-30-ai-rescan-scheduler.md index 2f34d8a5..62f98292 100644 --- a/docs/backend/implements/BI-28-2026-07-30-ai-rescan-scheduler.md +++ b/docs/backend/implements/BI-28-2026-07-30-ai-rescan-scheduler.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-30 -- **관련**: S15P11A705-159, +- **관련**: Jira 작업, [BD-40](../decisions/BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md), [BI-22](BI-22-2026-07-29-context-ai-enqueue.md)(접수 방향), [BI-23](BI-23-2026-07-29-ai-derived-invalidation-on-delete.md)(무효화 방향), @@ -216,7 +216,7 @@ jacoco LINE 96.45% BRANCH 82.59% (게이트 80%) - **`FAILED` 전환 로그에 "마지막 실패 사유"가 없다.** 명세 6.3이 요구하지만 **그 값이 Spring 쪽에 존재하지 않는다** — 202 이후의 실패는 FastAPI 내부에서 일어나고 통보 경로가 없다. 가진 단서(종결 직전 단계별 status·소진한 재시도 횟수)를 남기고 사유를 어디서 찾아야 하는지 문장으로 가리켰다. 사유를 - 실을 수 있으려면 FastAPI가 실패를 어딘가 기록해야 하고, 그것은 `S15P11A705-121`(`ai#44`) 소관이다. + 실을 수 있으려면 FastAPI가 실패를 어딘가 기록해야 하고, 그것은 `Jira 작업`(`ai#44`) 소관이다. - **회차 안 HTTP 호출은 순차다.** 배치 100건이 전부 재요청 대상이면 한 회차가 길어진다. `fixedDelay`라 겹치지는 않지만 만료 행이 밀린다. 그 상황이 실제로 보이면 BD-40의 재검토 트리거를 따른다. - **스레드 이름 `ai-rescan-`은 두 번째 배치가 붙는 순간 거짓말이 된다.** BD-40의 감수 목록에 있다. diff --git a/docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md b/docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md index 7265b666..cc4ef931 100644 --- a/docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md +++ b/docs/backend/implements/BI-29-2026-07-30-member-withdrawal.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-30 -- **관련**: S15P11A705-65, [back#34](https://github.com/Team-PinLog/back/issues/34), +- **관련**: Jira 작업, [back#34](https://github.com/Team-PinLog/back/issues/34), [BD-41](../decisions/BD-41-withdrawn-member-check-in-authentication-filter.md)(Access 창을 필터에서 닫은 결정), [BD-37](../decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md)(AI 무효화를 삭제 트랜잭션 안에), [BI-23](BI-23-2026-07-29-ai-derived-invalidation-on-delete.md)(나머지 삭제 경로 셋) @@ -19,8 +19,8 @@ 탈퇴 경로가 없어서 **완료된 작업 둘이 미충족 상태였다.** -- `S15P11A705-124`가 요구한 AI 파생 무효화 네 지점 중 **탈퇴만 비어 있었다.** [#80](https://github.com/Team-PinLog/back/pull/80)이 나머지 셋에 적용했지만 붙일 서비스가 없어 제외했다. -- `S15P11A705-147`이 미탈퇴 판정을 `MemberRepository.isActive` 하나로 모아 뒀는데, **`member.deleted_at`을 세팅하는 주체가 없어 그 코드가 한 번도 발동하지 않았다.** 이제 공개 조회 세 경로(Collection 상세 404·팔로우 404·팔로우한 책장 빈 목록)가 새 코드 없이 동작한다. +- `Jira 작업`가 요구한 AI 파생 무효화 네 지점 중 **탈퇴만 비어 있었다.** [#80](https://github.com/Team-PinLog/back/pull/80)이 나머지 셋에 적용했지만 붙일 서비스가 없어 제외했다. +- `Jira 작업`이 미탈퇴 판정을 `MemberRepository.isActive` 하나로 모아 뒀는데, **`member.deleted_at`을 세팅하는 주체가 없어 그 코드가 한 번도 발동하지 않았다.** 이제 공개 조회 세 경로(Collection 상세 404·팔로우 404·팔로우한 책장 빈 목록)가 새 코드 없이 동작한다. ## 이슈 본문에서 따르지 않은 것 diff --git a/docs/backend/implements/BI-30-2026-07-31-me-summary.md b/docs/backend/implements/BI-30-2026-07-31-me-summary.md index fbf5483f..2e0022b4 100644 --- a/docs/backend/implements/BI-30-2026-07-31-me-summary.md +++ b/docs/backend/implements/BI-30-2026-07-31-me-summary.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-31 -- **관련**: S15P11A705-200, [back#125](https://github.com/Team-PinLog/back/issues/125), +- **관련**: Jira 작업, [back#125](https://github.com/Team-PinLog/back/issues/125), [BI-29](BI-29-2026-07-30-member-withdrawal.md)(`MeController`와 `SocialAccountRepository.findByMemberId`를 만든 곳) ## 산출 @@ -23,7 +23,7 @@ `MemberRepository.isActive`의 javadoc이 이 질문을 별건으로 남겨 뒀다. -> `existsById`가 아니라 `findById`를 쓰는 것은 기존 세 경로와 같은 조회를 유지하려는 것이다. 바꾸면 `@SQLRestriction`이 count 쿼리에도 적용되는지가 **별개의 질문**이 되고, 그것은 중복 제거인 이 변경의 범위가 아니다(S15P11A705-147). +> `existsById`가 아니라 `findById`를 쓰는 것은 기존 세 경로와 같은 조회를 유지하려는 것이다. 바꾸면 `@SQLRestriction`이 count 쿼리에도 적용되는지가 **별개의 질문**이 되고, 그것은 중복 제거인 이 변경의 범위가 아니다(Jira 작업). 활성 기준 집계가 넷 필요해지면서 이 티켓에서 처음 답이 필요해졌다. **적용된다** — 파생 `countBy...`만으로 성립하고 `@Query`가 필요하지 않다. @@ -63,7 +63,7 @@ ## 남은 것 -- **`AiDerivedDataInvalidationTests`가 아직 공용 픽스처를 쓰지 않는다.** 아래 리팩터에서 member 도메인 두 클래스만 옮겼고, 그 파일은 **AI 파트 소유**(S15P11A705-124)라 이 PR에서 건드리지 않았다. 같은 `createRecord`·`createCollection`·`firstContextId`가 그쪽에 남아 있으므로, AI 파트가 필요할 때 `CoreApiFixtures`를 상속하면 된다. +- **`AiDerivedDataInvalidationTests`가 아직 공용 픽스처를 쓰지 않는다.** 아래 리팩터에서 member 도메인 두 클래스만 옮겼고, 그 파일은 **AI 파트 소유**(Jira 작업)라 이 PR에서 건드리지 않았다. 같은 `createRecord`·`createCollection`·`firstContextId`가 그쪽에 남아 있으므로, AI 파트가 필요할 때 `CoreApiFixtures`를 상속하면 된다. - `GET /feed/collections/{id}/shelf`가 여전히 미구현이다([#85](https://github.com/Team-PinLog/back/issues/85)). ## 리팩터 — 공용 픽스처 추출 diff --git a/docs/backend/implements/BI-31-2026-07-31-login-diagnostics.md b/docs/backend/implements/BI-31-2026-07-31-login-diagnostics.md index 6b091d70..9ebf1c3b 100644 --- a/docs/backend/implements/BI-31-2026-07-31-login-diagnostics.md +++ b/docs/backend/implements/BI-31-2026-07-31-login-diagnostics.md @@ -2,8 +2,8 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-31 -- **관련**: S15P11A705-186, [back#134](https://github.com/Team-PinLog/back/issues/134), - 후속 [S15P11A705-187](https://ssafy.atlassian.net/browse/S15P11A705-187)(이 로그로 하위 원인을 특정한다) +- **관련**: Jira 작업, [back#134](https://github.com/Team-PinLog/back/issues/134), + 후속 Jira 작업(이 로그로 하위 원인을 특정한다) ## 산출 diff --git a/docs/backend/implements/BI-32-2026-07-31-ci-pipeline-speedup.md b/docs/backend/implements/BI-32-2026-07-31-ci-pipeline-speedup.md index d54b2071..3987d464 100644 --- a/docs/backend/implements/BI-32-2026-07-31-ci-pipeline-speedup.md +++ b/docs/backend/implements/BI-32-2026-07-31-ci-pipeline-speedup.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-31 -- **관련**: [S15P11A705-231](https://ssafy.atlassian.net/browse/S15P11A705-231) · [BD-42](../decisions/BD-42-ci-skip-inside-job-not-paths-ignore.md) +- **관련**: Jira 작업 · [BD-42](../decisions/BD-42-ci-skip-inside-job-not-paths-ignore.md) ## 무엇을 만들었나 @@ -128,7 +128,7 @@ PR 총 시간은 3.2분 → **3.3분**이다. `Run checks`의 실행 편차(112~ **총 CI 시간으로는 순손실이다** — PR당 실행이 7~8회는 돼야 손익분기이고 실제로는 2~4회다. 그럼에도 **유지하기로 정했다**(담당자 판단, 2026-08-01): 사람이 기다리는 것은 PR 피드백이고 `dev` push는 배포를 96초 늦출 뿐이라, 그 교환이 값어치가 있다고 봤다. -> **정정 (2026-08-01) — 이 캐시는 같은 날 제거됐다.** 위 판단은 "캐시를 유지하면서 96초를 낸다"와 "캐시를 버리고 13초를 포기한다" 둘 중에서 고른 것인데, [S15P11A705-238](https://ssafy.atlassian.net/browse/S15P11A705-238)이 **세 번째 선택지**를 열었다 — 컨테이너에서 컴파일하지 않으면 캐시할 대상 자체가 없어져 13초를 포기하는 대신 **45초를 얻고** 96초도 함께 사라진다. 그래서 이 절이 감수하기로 한 교환은 더 이상 유효하지 않다. 근거는 [BD-44](../decisions/BD-44-image-takes-prebuilt-jar.md), 결과는 [BI-34](BI-34-2026-08-01-image-takes-prebuilt-jar.md)에 있다. +> **정정 (2026-08-01) — 이 캐시는 같은 날 제거됐다.** 위 판단은 "캐시를 유지하면서 96초를 낸다"와 "캐시를 버리고 13초를 포기한다" 둘 중에서 고른 것인데, Jira 작업이 **세 번째 선택지**를 열었다 — 컨테이너에서 컴파일하지 않으면 캐시할 대상 자체가 없어져 13초를 포기하는 대신 **45초를 얻고** 96초도 함께 사라진다. 그래서 이 절이 감수하기로 한 교환은 더 이상 유효하지 않다. 근거는 [BD-44](../decisions/BD-44-image-takes-prebuilt-jar.md), 결과는 [BI-34](BI-34-2026-08-01-image-takes-prebuilt-jar.md)에 있다. **아직 모르는 것**: 163초는 캐시를 **처음 채운** 실행이다. 다음 머지부터는 바뀐 레이어만 내보내 줄어들 수 있는데 데이터가 1건이라 단정할 수 없다. 다음 `dev` 머지에서 이 값을 다시 재고 여기 덧붙인다. diff --git a/docs/backend/implements/BI-33-2026-07-31-api-verification-harness.md b/docs/backend/implements/BI-33-2026-07-31-api-verification-harness.md index 016014b5..bca778ec 100644 --- a/docs/backend/implements/BI-33-2026-07-31-api-verification-harness.md +++ b/docs/backend/implements/BI-33-2026-07-31-api-verification-harness.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-31 -- **관련**: S15P11A705-192, +- **관련**: Jira 작업, [BD-03](../decisions/BD-03-api-response-envelope.md)·[BD-04](../decisions/BD-04-cursor-pagination.md)(검사 대상 계약), [BD-07](../decisions/BD-07-context-immutability.md)·[BD-08](../decisions/BD-08-soft-delete-no-restore.md)·[BD-11](../decisions/BD-11-minimum-holding-invariants.md)·[BD-12](../decisions/BD-12-duplicate-record-idempotent.md)·[BD-20](../decisions/BD-20-selective-denormalization.md)·[BD-25](../decisions/BD-25-context-origin-created-at.md)·[BD-33](../decisions/BD-33-published-at-database-invariant.md)·[BD-37](../decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md)(검사하는 불변식), [BI-12](BI-12-2026-07-28-deletion-cascade.md)(삭제 파급)·[BI-29](BI-29-2026-07-30-member-withdrawal.md)(탈퇴 파급) diff --git a/docs/backend/implements/BI-34-2026-08-01-image-takes-prebuilt-jar.md b/docs/backend/implements/BI-34-2026-08-01-image-takes-prebuilt-jar.md index f51cde01..708dd40c 100644 --- a/docs/backend/implements/BI-34-2026-08-01-image-takes-prebuilt-jar.md +++ b/docs/backend/implements/BI-34-2026-08-01-image-takes-prebuilt-jar.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-08-01 -- **관련**: [S15P11A705-238](https://ssafy.atlassian.net/browse/S15P11A705-238) · [BD-44](../decisions/BD-44-image-takes-prebuilt-jar.md) · [BI-32](BI-32-2026-07-31-ci-pipeline-speedup.md) +- **관련**: Jira 작업 · [BD-44](../decisions/BD-44-image-takes-prebuilt-jar.md) · [BI-32](BI-32-2026-07-31-ci-pipeline-speedup.md) ## 무엇을 만들었나 diff --git a/docs/backend/implements/BI-35-2026-07-31-shelf-browse.md b/docs/backend/implements/BI-35-2026-07-31-shelf-browse.md index 43fcd1c2..d75d670f 100644 --- a/docs/backend/implements/BI-35-2026-07-31-shelf-browse.md +++ b/docs/backend/implements/BI-35-2026-07-31-shelf-browse.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-07-31 -- **관련**: S15P11A705-206, [back#85](https://github.com/Team-PinLog/back/issues/85), +- **관련**: Jira 작업, [back#85](https://github.com/Team-PinLog/back/issues/85), [BD-43](../decisions/BD-43-shelf-in-follow-domain-under-collections-path.md)(도메인·경로 결정), [BI-30](BI-30-2026-07-31-me-summary.md)(미구현 둘 중 나머지 하나를 채운 곳) @@ -20,7 +20,7 @@ [BI-30](BI-30-2026-07-31-me-summary.md)이 "명세 대비 미구현은 둘뿐"이라고 적었고, 그 둘 중 나머지가 이것이다. 명세 §13.6의 탐색 흐름에서 가운데 한 칸이 비어 있었다. ``` -GET /feed/collections ✅ (S15P11A705-120) +GET /feed/collections ✅ (Jira 작업) → GET /collections/{id} ✅ → GET /feed/collections/{id}/shelf ← 이번 → POST /follows { collectionId } ✅ diff --git a/docs/backend/implements/BI-36-2026-08-01-keyword-exposure.md b/docs/backend/implements/BI-36-2026-08-01-keyword-exposure.md index 1589e8de..86d4a8d2 100644 --- a/docs/backend/implements/BI-36-2026-08-01-keyword-exposure.md +++ b/docs/backend/implements/BI-36-2026-08-01-keyword-exposure.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-08-01 -- **관련**: S15P11A705-240, [back#145](https://github.com/Team-PinLog/back/issues/145), +- **관련**: Jira 작업, [back#145](https://github.com/Team-PinLog/back/issues/145), [BD-18](../decisions/BD-18-keyword-preset-and-visibility.md)(원본은 Context Keyword·상위는 읽기 집계), [BI-35](BI-35-2026-07-31-shelf-browse.md)(빈 배열을 "남긴 것"으로 기록한 곳) diff --git a/docs/backend/implements/BI-37-2026-08-02-load-profiles-report.md b/docs/backend/implements/BI-37-2026-08-02-load-profiles-report.md index 9d3ab29a..37610b2a 100644 --- a/docs/backend/implements/BI-37-2026-08-02-load-profiles-report.md +++ b/docs/backend/implements/BI-37-2026-08-02-load-profiles-report.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-08-02 -- **관련**: S15P11A705-239, +- **관련**: Jira 작업, [BI-33](BI-33-2026-07-31-api-verification-harness.md)(1단계 하네스 — 이 위에 얹었다), [BD-11](../decisions/BD-11-minimum-holding-invariants.md)(경합 시나리오의 판정 대상), [BD-20](../decisions/BD-20-selective-denormalization.md)·[BD-37](../decisions/BD-37-ai-derived-invalidation-inside-deletion-transaction.md)(부하 중 정합 검증 대상) diff --git a/docs/backend/implements/BI-38-2026-08-03-massive-scale-plan-observation.md b/docs/backend/implements/BI-38-2026-08-03-massive-scale-plan-observation.md index 82b46292..9ade7c01 100644 --- a/docs/backend/implements/BI-38-2026-08-03-massive-scale-plan-observation.md +++ b/docs/backend/implements/BI-38-2026-08-03-massive-scale-plan-observation.md @@ -2,8 +2,8 @@ - **상태**: ✅ 완료 - **날짜**: 2026-08-03 -- **관련**: S15P11A705-284, - S15P11A705-283(벤치 환경 — 이 위에서 측정했다), +- **관련**: Jira 작업, + Jira 작업(벤치 환경 — 이 위에서 측정했다), [BI-37](BI-37-2026-08-02-load-profiles-report.md)(부하 관측 — 규모 축이 아니라 동시성 축), [BD-04](../decisions/BD-04-cursor-pagination.md)(커서 설계 — 이번에 규모로 검증), [BD-20](../decisions/BD-20-selective-denormalization.md)·[BD-33](../decisions/BD-33-published-at-invariant.md) @@ -11,7 +11,7 @@ ## 무엇을 쟀나 record 117k에서는 플래너가 Seq Scan을 골라도 손해가 없어 인덱스 설계가 판별되지 않았다. -S15P11A705-283이 만든 **record 1,000만 · collection 667k** 볼륨 위에서 주요 읽기 경로 4종을 +Jira 작업이 만든 **record 1,000만 · collection 667k** 볼륨 위에서 주요 읽기 경로 4종을 `EXPLAIN (ANALYZE, BUFFERS)`로 수집하고, 벤치 이전 상태(117,022건)와 대조했다. **측정 방법.** 벤치 데이터는 전부 기존 최대 id 뒤에 이어 붙었으므로, `id <= 기준점` 행만 @@ -126,7 +126,7 @@ warm에서는 배수가 1~3으로 내려간다. 구조적으로 규모에 비례 이 보고서는 보존 구역이라 본문 수치를 고치지 않는다. 대신 측정 이후 대상 코드가 바뀐 사실을 여기에 덧붙인다. -**2026-08-04, dev #180(S15P11A705-301)이 지도 마커 API에 `keyword` 검색과 이름순 정렬을 더했다.** +**2026-08-04, dev #180(Jira 작업)이 지도 마커 API에 `keyword` 검색과 이름순 정렬을 더했다.** 따라서 위 판정 4·5의 지도 수치는 **그 변경 이전 쿼리**를 잰 것이다. 바뀐 점은 셋이다. - `findMarkers`·`findMarkersWithinBounds` 양쪽 WHERE에 diff --git a/docs/backend/implements/BI-38-2026-08-04-unlink-before-withdrawal.md b/docs/backend/implements/BI-38-2026-08-04-unlink-before-withdrawal.md index fb28d4f5..1d7f6190 100644 --- a/docs/backend/implements/BI-38-2026-08-04-unlink-before-withdrawal.md +++ b/docs/backend/implements/BI-38-2026-08-04-unlink-before-withdrawal.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-08-04 -- **관련**: S15P11A705-285, [back#176](https://github.com/Team-PinLog/back/issues/176), +- **관련**: Jira 작업, [back#176](https://github.com/Team-PinLog/back/issues/176), [BD-48](../decisions/BD-48-unlink-before-withdrawal.md)(순서·수단·어휘 결정), [BD-41](../decisions/BD-41-withdrawn-member-check-in-authentication-filter.md)(Refresh 폐기를 커밋 이후로 둔 반대 방향), 공용 계약 08 §3.6 · 06 §6.9 diff --git a/docs/backend/implements/BI-40-2026-08-04-authorization-request-cookie-json.md b/docs/backend/implements/BI-40-2026-08-04-authorization-request-cookie-json.md index 90d20e8e..7d28edb4 100644 --- a/docs/backend/implements/BI-40-2026-08-04-authorization-request-cookie-json.md +++ b/docs/backend/implements/BI-40-2026-08-04-authorization-request-cookie-json.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-08-04 -- **관련**: S15P11A705-132, [back#175](https://github.com/Team-PinLog/back/issues/175), +- **관련**: Jira 작업, [back#175](https://github.com/Team-PinLog/back/issues/175), [BD-49](../decisions/BD-49-authorization-request-cookie-json.md)(결정), [BD-30](../decisions/BD-30-authorization-request-in-cookie.md)(대체된 결정), [BD-48](../decisions/BD-48-unlink-before-withdrawal.md)(이 쿠키에 탈퇴 판정을 실은 결정) diff --git a/docs/backend/implements/BI-41-2026-08-05-google-revoke-refresh-token.md b/docs/backend/implements/BI-41-2026-08-05-google-revoke-refresh-token.md index b218bc49..e02d8189 100644 --- a/docs/backend/implements/BI-41-2026-08-05-google-revoke-refresh-token.md +++ b/docs/backend/implements/BI-41-2026-08-05-google-revoke-refresh-token.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-08-05 -- **관련**: S15P11A705-309, [back#190](https://github.com/Team-PinLog/back/issues/190), +- **관련**: Jira 작업, [back#190](https://github.com/Team-PinLog/back/issues/190), [BD-48](../decisions/BD-48-unlink-before-withdrawal.md)(§① 정정), [BI-38](BI-38-2026-08-04-unlink-before-withdrawal.md)(탈퇴 왕복 구현) diff --git a/docs/backend/implements/BI-42-2026-08-07-map-keyword-chips.md b/docs/backend/implements/BI-42-2026-08-07-map-keyword-chips.md index 0c0be465..f460f8b9 100644 --- a/docs/backend/implements/BI-42-2026-08-07-map-keyword-chips.md +++ b/docs/backend/implements/BI-42-2026-08-07-map-keyword-chips.md @@ -2,8 +2,8 @@ - **상태**: ✅ 완료 - **날짜**: 2026-08-07 -- **관련**: Jira S15P11A705-388(작업 내용·완료 조건·성능 근거 요약이 본문에 있다), - 브랜치 `feat/S15P11A705-388-map-keyword-chips`, [BD-13](../decisions/BD-13-public-boundary-query-dto-split.md) +- **관련**: Jira 작업(작업 내용·완료 조건·성능 근거 요약이 본문에 있다), + 브랜치 `feat/{jira-key}-map-keyword-chips`, [BD-13](../decisions/BD-13-public-boundary-query-dto-split.md) ## 무엇을 만들었나 @@ -15,7 +15,7 @@ { "items": [{ "keywordId": 12, "displayName": "카페", "recordCount": 12 }] } ``` -`keywordId`를 반드시 싣는다. 칩을 눌러 지도 필터로 넘길 때(S15P11A705-390) 쓸 값이라, 표시 이름으로 +`keywordId`를 반드시 싣는다. 칩을 눌러 지도 필터로 넘길 때(Jira 작업) 쓸 값이라, 표시 이름으로 넘기면 동명 프리셋과 표기 변경에 깨진다. bbox 파라미터(`swLat`·`swLng`·`neLat`·`neLng`)는 `GET /v1/records/map`과 같은 규칙을 쓴다 — 넷 다 @@ -69,7 +69,7 @@ bbox 기준 집계는 화면 정합성(칩 숫자와 핀 수가 일치해야 한 ## 남은 것 -- 지도 필터(`GET /v1/records/map`에 `keywordId` 추가) — S15P11A705-390 +- 지도 필터(`GET /v1/records/map`에 `keywordId` 추가) — Jira 작업 - 공개 API 명세 반영 — [docs#52](https://github.com/Team-PinLog/docs/pull/52)로 올라감(머지 대기). `08_API_명세.md` 2.2·4.2·4.3에 지도 키워드 칩 조회 API와 마커 키워드 필터가 반영돼 있다. diff --git a/docs/backend/implements/BI-43-2026-08-07-record-collections-api.md b/docs/backend/implements/BI-43-2026-08-07-record-collections-api.md index 46046a3d..7da7defe 100644 --- a/docs/backend/implements/BI-43-2026-08-07-record-collections-api.md +++ b/docs/backend/implements/BI-43-2026-08-07-record-collections-api.md @@ -2,7 +2,7 @@ - **상태**: ✅ 완료 - **날짜**: 2026-08-07 -- **관련**: S15P11A705-391, 명세 §5.10([Team-PinLog/docs#53](https://github.com/Team-PinLog/docs/pull/53) 선행 병합 필요), +- **관련**: Jira 작업, 명세 §5.10([Team-PinLog/docs#53](https://github.com/Team-PinLog/docs/pull/53) 선행 병합 필요), [BD-46](../decisions/BD-46-list-sort-default-asc-with-params.md)(정렬 방향·커서 부등호 짝), [BI-38](BI-38-2026-08-03-massive-scale-plan-observation.md)(이 실측이 미룬 `ix_colrec_collection` vs `uq_colrec_active` 판별을 여기서 채운다) diff --git a/docs/backend/implements/BI-44-2026-08-07-confidence-gate.md b/docs/backend/implements/BI-44-2026-08-07-confidence-gate.md index 0b720c47..a8acf064 100644 --- a/docs/backend/implements/BI-44-2026-08-07-confidence-gate.md +++ b/docs/backend/implements/BI-44-2026-08-07-confidence-gate.md @@ -2,8 +2,8 @@ - **상태**: ✅ 구현 완료. 기능 플래그는 꺼진 상태로 두었다. 켜는 결정은 검색 고도화 검증 게이트 통과 뒤의 일이다. - **날짜**: 2026-08-07 -- **추적**: S15P11A705-400 -- **관련**: `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4 · ai 레포 `docs/proposals/P49-multi-signal-search.md` · ai 레포 `docs/implements/2026-08-07-gate-threshold-remeasure.md`(임계값 오프라인 재측정, S15P11A705-401) · [BD-52](../decisions/BD-52-confidence-gate-filters-before-core-revalidation.md)(게이트 위치 결정) +- **추적**: Jira 작업 +- **관련**: `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4 · ai 레포 `docs/proposals/P49-multi-signal-search.md` · ai 레포 `docs/implements/2026-08-07-gate-threshold-remeasure.md`(임계값 오프라인 재측정, Jira 작업) · [BD-52](../decisions/BD-52-confidence-gate-filters-before-core-revalidation.md)(게이트 위치 결정) ## 배경 @@ -13,8 +13,8 @@ ## 산출 -- **`ConfidenceGateProperties`** 신설. `pinlog.search.gate.enabled`는 게이트를 켜고 끄는 설정이고 기본값은 꺼짐이다. `similarity-threshold`는 S1 단독 결과를 제외하는 유사도 하한이고 기본값은 0.35 — ai 레포가 이 용도로 별도 오프라인 재측정한 채택값이다(S15P11A705-401). -- **`AiSearchResponse.Match`에 `keywordMatched` 필드 추가.** ai 레포가 검색 응답 스키마에 이미 실어 보내는 값이다(ai 레포 S15P11A705-399). 재정렬이 이미 계산하지만 순서에만 쓰고 버리던 신호를 back까지 살렸다. +- **`ConfidenceGateProperties`** 신설. `pinlog.search.gate.enabled`는 게이트를 켜고 끄는 설정이고 기본값은 꺼짐이다. `similarity-threshold`는 S1 단독 결과를 제외하는 유사도 하한이고 기본값은 0.35 — ai 레포가 이 용도로 별도 오프라인 재측정한 채택값이다(Jira 작업). +- **`AiSearchResponse.Match`에 `keywordMatched` 필드 추가.** ai 레포가 검색 응답 스키마에 이미 실어 보내는 값이다(ai 레포 Jira 작업). 재정렬이 이미 계산하지만 순서에만 쓰고 버리던 신호를 back까지 살렸다. - **`RecordSearchService` 수정.** - `mergeLexicalMatches`가 병합된 목록뿐 아니라 문자열로 매치된 Record id 집합(S2 신호)도 함께 돌려주도록 반환 타입을 `LexicalMergeResult`로 바꿨다. 그 신호는 `rrfMerge` 안에서만 알고 버려지던 것이었다. - `applyConfidenceGate`를 신설해 문자열 병합 직후·Core 재검증 이전에 게이트를 건다(위치 근거는 BD-52). S2·S3 중 하나라도 있으면 유사도와 무관하게 통과시키고, 아니면 유사도가 `similarityThreshold` 미만인 것만 뺀다. @@ -29,7 +29,7 @@ ### 임계값 0.35의 출처 — 재사용이 아니라 재측정 -인계 문서는 threshold 후보로 0.35를 들면서 그 값이 `SEARCH_KEYWORD_RERANK_FLOOR`(질의-Preset 코사인이 같은 의미인지 판단하는, 전혀 다른 용도의 값)에서 가져온 것이라 "이 재사용이 실제로 타당한지는 아직 검증되지 않았다"고 스스로 명시했다. ai 파트가 이 프로젝트의 기존 관행(같은 숫자라도 구조가 바뀌면 재측정한다, ai 레포 `app/core/config.py`의 `SEARCH_KEYWORD_RERANK_FLOOR` 주석 참고)을 따라 이 용도로 별도 오프라인 재측정을 했고(S15P11A705-401), 그 결과 0.35가 정답 손실 없이 무관 노출을 크게 줄이는 값임을 확인했다. back은 그 채택값을 설정 기본값으로만 가져왔다 — 값을 back이 직접 정하지 않았다. +인계 문서는 threshold 후보로 0.35를 들면서 그 값이 `SEARCH_KEYWORD_RERANK_FLOOR`(질의-Preset 코사인이 같은 의미인지 판단하는, 전혀 다른 용도의 값)에서 가져온 것이라 "이 재사용이 실제로 타당한지는 아직 검증되지 않았다"고 스스로 명시했다. ai 파트가 이 프로젝트의 기존 관행(같은 숫자라도 구조가 바뀌면 재측정한다, ai 레포 `app/core/config.py`의 `SEARCH_KEYWORD_RERANK_FLOOR` 주석 참고)을 따라 이 용도로 별도 오프라인 재측정을 했고(Jira 작업), 그 결과 0.35가 정답 손실 없이 무관 노출을 크게 줄이는 값임을 확인했다. back은 그 채택값을 설정 기본값으로만 가져왔다 — 값을 back이 직접 정하지 않았다. ### `keywordMatched`의 null 방어 @@ -37,5 +37,5 @@ ai 스키마는 이 필드를 항상 보내지만(필수, 기본값 없음), `Ai ## 남은 것 -- 이 구현은 오프라인 재측정(S15P11A705-401)이 재구성한 S2·S3 신호를 실서버에서 그대로 재현한다는 전제 위에 있다. `lexical_sweep.py`류의 실서버 on/off 대조가 아직 이 게이트를 대상으로 돈 적은 없다 — 검증 게이트 통과 전에 필요하다. +- 이 구현은 오프라인 재측정(Jira 작업)이 재구성한 S2·S3 신호를 실서버에서 그대로 재현한다는 전제 위에 있다. `lexical_sweep.py`류의 실서버 on/off 대조가 아직 이 게이트를 대상으로 돈 적은 없다 — 검증 게이트 통과 전에 필요하다. - `.claude/handoff/SEARCH-UPGRADE-HANDOFF.md`(인계 문서가 가리킨 위치)는 `ai`·`back`·`docs` 어디에도 없었다. 이 구현은 그 문서 대신 P49 제안서와 `search-upgrade` 브랜치의 실제 코드를 근거로 삼았다(BD-52에도 같은 내용을 남겼다). diff --git a/docs/backend/implements/BI-45-2026-08-07-search-relevance-judge.md b/docs/backend/implements/BI-45-2026-08-07-search-relevance-judge.md index 9e06e367..c31317d8 100644 --- a/docs/backend/implements/BI-45-2026-08-07-search-relevance-judge.md +++ b/docs/backend/implements/BI-45-2026-08-07-search-relevance-judge.md @@ -2,14 +2,14 @@ - **상태**: ✅ 구현 완료. 기능 플래그는 꺼진 상태로 두었다. 켜는 결정은 별도다. - **날짜**: 2026-08-07 -- **추적**: S15P11A705-403([AI] 검색 결과 신뢰도 개선). 사용자가 실배포에서 발견한 검색 순위 오류를 직접 지시해 시작한 작업이다. -- **관련**: ai 레포 `S15P11A705-relevance-judge` 브랜치(`app/client/relevance_client.py` 등, `POST /internal/v1/search/judge` 신설) · `docs/backend/implements/BI-43-2026-08-06-search-lexical-merge.md`(같은 서비스의 앞선 신호) +- **추적**: Jira 작업([AI] 검색 결과 신뢰도 개선). 사용자가 실배포에서 발견한 검색 순위 오류를 직접 지시해 시작한 작업이다. +- **관련**: ai 레포 `Jira-relevance-judge` 브랜치(`app/client/relevance_client.py` 등, `POST /internal/v1/search/judge` 신설) · `docs/backend/implements/BI-43-2026-08-06-search-lexical-merge.md`(같은 서비스의 앞선 신호) ## 배경 -배포 직후 사용자가 검색 결과 오류를 보고했다. 질의 "예전에 싸피 때 다녔던 헬스장 어디였지?"에서, 본문에 "싸피"가 그대로 있는 기록이 2위로 밀리고 그 단어가 없는 기록이 1위에 올랐다. +배포 직후 사용자가 검색 결과 오류를 보고했다. 질의 "예전에 부트캠프 때 다녔던 헬스장 어디였지?"에서, 본문에 "부트캠프"가 그대로 있는 기록이 2위로 밀리고 그 단어가 없는 기록이 1위에 올랐다. -원인은 기존 세 신호(재작성·문자열 검색·키워드 재정렬) 모두의 사각지대다. 이 질의는 문장형이라 재작성(6자 이하만 대상)과 문자열 검색(단어형만 대상)이 적용되지 않고, "싸피"는 키워드 목록에 없는 고유명사라 재정렬도 잡지 못한다. 남는 것은 순수 벡터 유사도뿐이고, 임베딩은 "본문에 그 단어가 정확히 있는가"보다 전체적인 의미 유사도를 본다. +원인은 기존 세 신호(재작성·문자열 검색·키워드 재정렬) 모두의 사각지대다. 이 질의는 문장형이라 재작성(6자 이하만 대상)과 문자열 검색(단어형만 대상)이 적용되지 않고, "부트캠프"는 키워드 목록에 없는 고유명사라 재정렬도 잡지 못한다. 남는 것은 순수 벡터 유사도뿐이고, 임베딩은 "본문에 그 단어가 정확히 있는가"보다 전체적인 의미 유사도를 본다. 사용자가 4번째 신호를 직접 제안했다. 기존 파이프라인의 최종 후보를 LLM에게 질의와 함께 보여주고 관련도 4단계(`VERY_RELEVANT`~`NOT_RELEVANT`)로 재판정해, 무관한 것은 제거하고 나머지를 재정렬한다. RAG 분야의 "LLM reranker" 패턴이다. 검색 신호 3종 동결(ai 레포 P49) 위에 얹는 4번째 신호이므로 동결의 재론이 아니라 소유자(사용자)의 확장 결정으로 처리했다. @@ -21,7 +21,7 @@ back이 직접 LLM을 호출하는 대안은 검토 후 배제했다. back에는 ## 산출 -- **ai 레포**: `POST /internal/v1/search/judge` 신설. 요청 `{query, candidates: [{contextId, placeName, body}]}` → 응답 `{results: [{contextId, relevance}]}`. 기본 꺼짐(`SEARCH_RELEVANCE_JUDGE_ENABLED`). 상세는 그 레포 커밋(`S15P11A705-relevance-judge` 브랜치). +- **ai 레포**: `POST /internal/v1/search/judge` 신설. 요청 `{query, candidates: [{contextId, placeName, body}]}` → 응답 `{results: [{contextId, relevance}]}`. 기본 꺼짐(`SEARCH_RELEVANCE_JUDGE_ENABLED`). 상세는 그 레포 커밋(`Jira-relevance-judge` 브랜치). - **`AiRelevanceJudgeClient`** 신설(`domain/ai/client`). `AiSearchClient`를 본떴지만 실패 정책은 반대다 — 이 클라이언트는 보조 신호라 실패를 흡수하지 않고 그대로 던진다. 흡수는 호출부의 책임이다. - **`RelevanceJudgeProperties`** 신설. `pinlog.search.relevance-judge.enabled`, 기본값 꺼짐. - **`AiProperties`**에 `judge` 타임아웃 필드 추가(`connect-timeout: 1s`, `read-timeout: 10s`). 후보 최대 10건의 본문을 한 번의 LLM 호출로 판정하는 동기 경로라 `search`(5s)보다 길게 잡았다. diff --git a/docs/backend/troubleshooting/BT-01-shared-testcontainers-lifecycle.md b/docs/backend/troubleshooting/BT-01-shared-testcontainers-lifecycle.md index 1725a7de..ca42b2eb 100644 --- a/docs/backend/troubleshooting/BT-01-shared-testcontainers-lifecycle.md +++ b/docs/backend/troubleshooting/BT-01-shared-testcontainers-lifecycle.md @@ -1,6 +1,6 @@ # BT-01. 공유 Testcontainers Postgres가 클래스마다 재시작되어 뒤 클래스가 죽은 포트를 물음 -- **상태**: 해결됨 (S15P11A705-41, `6c61272`) +- **상태**: 해결됨 (Jira 작업, `6c61272`) - **날짜**: 2026-07-27 - **레이어**: 테스트 런타임 / Testcontainers diff --git a/docs/backend/troubleshooting/BT-02-flyway-out-of-order-version-ranges.md b/docs/backend/troubleshooting/BT-02-flyway-out-of-order-version-ranges.md index cd33d751..4d447ea3 100644 --- a/docs/backend/troubleshooting/BT-02-flyway-out-of-order-version-ranges.md +++ b/docs/backend/troubleshooting/BT-02-flyway-out-of-order-version-ranges.md @@ -1,9 +1,9 @@ # BT-02. 백엔드 migration이 AI 구간보다 낮은 번호라 Flyway validate가 실패한다 -- **상태**: ✅ 해결 (2026-07-28, S15P11A705-86) — 아래 "해결" 절 참고. 구간 소유를 유지하고 `out-of-order`를 허용하는 (a)를 채택했다([BD-26](../decisions/BD-26-flyway-out-of-order.md)) +- **상태**: ✅ 해결 (2026-07-28, Jira 작업) — 아래 "해결" 절 참고. 구간 소유를 유지하고 `out-of-order`를 허용하는 (a)를 채택했다([BD-26](../decisions/BD-26-flyway-out-of-order.md)) - **날짜**: 2026-07-27 - **레이어**: Flyway / 배포 -- **관련**: S15P11A705-51 검증 중 발견, S15P11A705-66(다음 백엔드 migration)에 직접 영향. 해소는 S15P11A705-86 +- **관련**: Jira 작업 검증 중 발견, Jira 작업(다음 백엔드 migration)에 직접 영향. 해소는 Jira 작업 ## 증상 @@ -39,8 +39,8 @@ DB에는 `V1`·`V100`~`V102`가 적용돼 있고, 레포에는 그 뒤에 추가 ### 운영에 미치는 영향 -- 최초 배포(S15P11A705-48)는 빈 DB라 통과한다. -- 그 이후 백엔드가 migration을 하나라도 추가하면(예: S15P11A705-66의 core 테이블) **다음 배포에서 애플리케이션이 기동 실패한다.** RollingUpdate 중이라면 새 Pod가 CrashLoop에 빠지고 롤아웃이 멈춘다. +- 최초 배포(Jira 작업)는 빈 DB라 통과한다. +- 그 이후 백엔드가 migration을 하나라도 추가하면(예: Jira 작업의 core 테이블) **다음 배포에서 애플리케이션이 기동 실패한다.** RollingUpdate 중이라면 새 Pod가 CrashLoop에 빠지고 롤아웃이 멈춘다. ## 확인 방법 (진단 당시) @@ -57,7 +57,7 @@ Successfully applied 1 migration 이 1회 확인이 (a)의 근거가 되었고, 아래 "해결"에서 상시 설정으로 승격했다. -## 해결 (2026-07-28, S15P11A705-86) +## 해결 (2026-07-28, Jira 작업) **(a)를 채택했다** — 구간 소유 구조를 유지하고 `application.yml`에 `spring.flyway.out-of-order: true`를 적용했다. 결정 배경과 감수 항목은 [BD-26](../decisions/BD-26-flyway-out-of-order.md)에 있다. diff --git a/docs/backend/troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md b/docs/backend/troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md index c34583e6..52237076 100644 --- a/docs/backend/troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md +++ b/docs/backend/troubleshooting/BT-03-health-endpoint-blocks-on-redis-outage.md @@ -3,7 +3,7 @@ - **상태**: ⚠️ 미해결 — Infra와 probe 경로 합의 후 조치 - **날짜**: 2026-07-27 - **레이어**: Actuator / 배포 probe -- **관련**: S15P11A705-51 검증 중 발견, S15P11A705-47(probe 구성)에 영향 +- **관련**: Jira 작업 검증 중 발견, Jira 작업(probe 구성)에 영향 ## 증상 diff --git a/docs/backend/troubleshooting/BT-04-logged-in-cookie-path-unreadable.md b/docs/backend/troubleshooting/BT-04-logged-in-cookie-path-unreadable.md index 8701eaf1..3ddb411a 100644 --- a/docs/backend/troubleshooting/BT-04-logged-in-cookie-path-unreadable.md +++ b/docs/backend/troubleshooting/BT-04-logged-in-cookie-path-unreadable.md @@ -1,7 +1,7 @@ # BT-04. `logged_in` 쿠키가 프론트에서 읽히지 않음 — `Path`가 API 경로로 좁혀져 있었다 - **상태**: 해결됨 -- **발견**: 2026-07-28, 실제 Google 로그인 수동 검증 중 (S15P11A705-63) +- **발견**: 2026-07-28, 실제 Google 로그인 수동 검증 중 (Jira 작업) - **관련**: [BD-21](../decisions/BD-21-auth-token-model.md) · [BI-18](../implements/BI-18-2026-07-28-jwt-cookie-session.md) ## 증상 diff --git a/docs/backend/troubleshooting/BT-05-dotenv-empty-value-overrides-default.md b/docs/backend/troubleshooting/BT-05-dotenv-empty-value-overrides-default.md index e06c7a7e..d6ca7313 100644 --- a/docs/backend/troubleshooting/BT-05-dotenv-empty-value-overrides-default.md +++ b/docs/backend/troubleshooting/BT-05-dotenv-empty-value-overrides-default.md @@ -1,7 +1,7 @@ # BT-05. `.env`의 빈 값이 `application.yml` 기본값을 덮어 기동이 실패한다 - **상태**: 해결됨 -- **발견**: 2026-07-29, Kakao·Naver 등록정보를 추가하고 테스트를 돌리다가 (S15P11A705-64) +- **발견**: 2026-07-29, Kakao·Naver 등록정보를 추가하고 테스트를 돌리다가 (Jira 작업) - **관련**: [설정 규약](../../development/configuration.md) · [BI-24](../implements/BI-24-2026-07-29-kakao-naver-login.md) ## 증상 @@ -17,7 +17,7 @@ Caused by: java.lang.IllegalStateException: ## 원인 -`application.yml`이 `.env`를 **프로퍼티 소스로 올린다.** Spring Boot가 `.env`를 자동으로 읽어 주지는 않아서 S15P11A705-63에서 명시적으로 넣은 설정이다(`1ed1dbe`). +`application.yml`이 `.env`를 **프로퍼티 소스로 올린다.** Spring Boot가 `.env`를 자동으로 읽어 주지는 않아서 Jira 작업에서 명시적으로 넣은 설정이다(`1ed1dbe`). ```yaml config: diff --git a/docs/backend/troubleshooting/BT-06-refresh-revocation-leak-under-concurrent-rotation.md b/docs/backend/troubleshooting/BT-06-refresh-revocation-leak-under-concurrent-rotation.md index d17e0563..3efa1949 100644 --- a/docs/backend/troubleshooting/BT-06-refresh-revocation-leak-under-concurrent-rotation.md +++ b/docs/backend/troubleshooting/BT-06-refresh-revocation-leak-under-concurrent-rotation.md @@ -1,7 +1,7 @@ # BT-06. 재발급 401이 무한히 반복됨 — 전 세션 폐기가 동시 회전에서 새고, 실패 경로가 쿠키를 남겼다 - **상태**: 해결됨 -- **발견**: 2026-08-03, 프론트 제보 (S15P11A705-267, [back#165](https://github.com/Team-PinLog/back/issues/165)) +- **발견**: 2026-08-03, 프론트 제보 (Jira 작업, [back#165](https://github.com/Team-PinLog/back/issues/165)) - **관련**: [BD-35](../decisions/BD-35-refresh-reuse-family-revocation.md) · [BD-21](../decisions/BD-21-auth-token-model.md) ## 증상 diff --git a/docs/backend/troubleshooting/BT-07-planner-knobs-insufficient-for-plan-assertion.md b/docs/backend/troubleshooting/BT-07-planner-knobs-insufficient-for-plan-assertion.md index f017ddc3..1df699b1 100644 --- a/docs/backend/troubleshooting/BT-07-planner-knobs-insufficient-for-plan-assertion.md +++ b/docs/backend/troubleshooting/BT-07-planner-knobs-insufficient-for-plan-assertion.md @@ -2,7 +2,7 @@ - **상태**: ✅ 해소 - **날짜**: 2026-08-05 -- **관련**: S15P11A705-303([BI-38](../implements/BI-38-2026-08-03-massive-scale-plan-observation.md) 개선 후보 1의 구현) +- **관련**: Jira 작업([BI-38](../implements/BI-38-2026-08-03-massive-scale-plan-observation.md) 개선 후보 1의 구현) ## 증상 @@ -60,7 +60,7 @@ Collection 수만큼이라 싸다(천만 건 실측 0.99ms). 순서가 아니라 전체 스캔으로 새지 않는지**이므로, follow를 인덱스로 짚고 Collection을 회원 단위로 좁히는지를 단정하도록 바꿨다. -같은 이유로 S15P11A705-303이 "팔로우 채널도 고쳤다"고 읽히게 쓴 서술도 정정했다 — 그 채널의 +같은 이유로 Jira 작업이 "팔로우 채널도 고쳤다"고 읽히게 쓴 서술도 정정했다 — 그 채널의 `COALESCE` 제거는 일관성을 위한 정리이며 성능 개선이 아니다. ## 교훈 diff --git a/docs/backend/worklog/2026-08-01-S15P11A705-231-ci-cache-measured.md b/docs/backend/worklog/2026-08-01-S15P11A705-231-ci-cache-measured.md deleted file mode 100644 index 6e8e7a14..00000000 --- a/docs/backend/worklog/2026-08-01-S15P11A705-231-ci-cache-measured.md +++ /dev/null @@ -1,9 +0,0 @@ -# S15P11A705-231의 실측 정정 - -- **날짜**: 2026-08-01 -- **추적**: S15P11A705-231 -- **관련**: [BI-32](../implements/BI-32-2026-07-31-ci-pipeline-speedup.md) - -S15P11A705-231의 실측 정정. 머지 후 `dev` push가 캐시를 채우고 그것을 읽는 첫 코드 PR(#140)을 재서 보니 **이미지 검증 61초 → 48초, 13초 절약**이다 — 추정했던 27초의 절반이다. 놓친 것 둘: **캐시 가져오기를 0으로 뒀다**(레이어가 `CACHED`가 돼도 내려받아 푸는 데 6~8초를 새 러너마다 낸다), `bootJar`가 29.7 → 33.4초로 흔들려 4초를 먹었다. PR 총 시간은 3.2 → 3.3분으로 **개선이 보이지 않는다** — `Run checks` 편차(±14초)가 이득 13초보다 크다. **문서에 없던 비용이 하나 드러났다**: `image-publish`가 67초 → 163초가 됐고 로그가 `#20 exporting to GitHub Actions Cache ... DONE 91.4s`로 원인을 지목한다. 멀티스테이지에서 캐시하려는 `dependencies` 레이어가 **버려지는 build 스테이지 안에 있어** `mode=min`으로는 안 잡히므로 이 비용은 이 방식의 필수 조건이다. 즉 PR 1회 −13초 / 머지 1회 +96초이고 **총 CI 시간으로는 순손실**인데(손익분기가 PR당 7~8회 실행, 실제 2~4회) 유지하기로 정했다 — 사람이 기다리는 것은 PR 피드백이고 배포 96초 지연은 그 교환으로 감수한다. **반복 실행으로는 이득이 커지지 않는다**: `dependencies`가 이미 `CACHED`라 48초가 이 `Dockerfile`의 바닥이고, PR 실행은 읽기 전용이라 축적되는 것이 없다. 오히려 7일 미사용 삭제·저장소 10GB 한도로 밀려나면 61초로 돌아간다. 남은 지렛대는 캐시가 아니라 **이중 컴파일 제거(−33초)** 와 **Gradle configuration cache**(123초짜리 `Run checks`가 본체이고 빌드 로그가 직접 권한다)다. 163초가 첫 내보내기라 다음 머지에 줄어들지는 데이터 1건이라 미결로 남겼다. **문서 전용 건너뛰기는 이 정정 문서 자신이 첫 실사례가 되어 확인됐다** — 무거운 스텝 여섯이 건너뛰어지고 `backend-ci / check`가 success로 **보고**되어 `MERGEABLE`, 잡 전체 **14초**다(추정 30초보다 빨랐다). BD-42가 `paths-ignore`를 버린 이유가 이 보고를 잃지 않기 위함이었고 그대로 성립했다. 문서 PR에 관해서는 이 티켓의 약속이 실측으로 확인된 것이다 (티켓 없음 — 문서 정정) - -> 이 항목은 `WORKLOG.md` 동결(BD-45) 뒤에 표에 남아 있던 행을 새 규칙대로 옮긴 것이다. 원문을 고치지 않고 그대로 가져왔다. diff --git a/docs/backend/worklog/2026-08-01-S15P11A705-238-ci-single-compile.md b/docs/backend/worklog/2026-08-01-S15P11A705-238-ci-single-compile.md deleted file mode 100644 index 31850b17..00000000 --- a/docs/backend/worklog/2026-08-01-S15P11A705-238-ci-single-compile.md +++ /dev/null @@ -1,9 +0,0 @@ -# CI가 프로젝트를 두 번 컴파일하던 것을 없앴다(S15P11A705-238) - -- **날짜**: 2026-08-01 -- **추적**: S15P11A705-238 -- **관련**: [BD-44](../decisions/BD-44-image-takes-prebuilt-jar.md) · [BI-34](../implements/BI-34-2026-08-01-image-takes-prebuilt-jar.md) · [README](../../../README.md) - -CI가 프로젝트를 두 번 컴파일하던 것을 없앴다(S15P11A705-238). 러너의 `Run checks`가 컴파일하고 컨테이너가 `bootJar`로 또 컴파일했는데, 그 레이어는 `src`가 매 PR 바뀌어 **캐시로 접근할 수 없는 유일한 구간**이었다. 직전 티켓이 앞 레이어에 캐시를 붙여 13초를 얻었지만 멀티스테이지에서 그 레이어가 **버려지는 build 스테이지 안에 있어** `mode=max`가 필수였고, 그 내보내기가 dev 발행을 67 → 163초로 늘려 **13초 얻고 96초를 냈다.** 그래서 `Dockerfile`을 jar를 받는 단일 스테이지로 바꿨다 — 캐시할 대상이 사라지므로 `cache-from`·`cache-to`가 함께 빠지고 96초도 같이 갚는다(BD-44). **버린 대안이 (c) "멀티스테이지 유지 + CI만 jar 주입"이다**: 로컬 편의를 지키는 대신 Gradle 실행을 남겨야 해 캐시 설정을 못 걷고(96초 유지), CI가 검증하는 경로와 로컬 경로가 갈려 "CI는 초록인데 로컬은 깨진다"를 새로 산다 — (a)보다 나아지지 않으면서 위험만 는다. 혼자 빌드되는 성질을 쓰는 곳을 먼저 셌다: `compose.yaml`은 앱을 빌드하지 않고 `infra`는 태그로 받아쓰기만 하며 정기 빌드는 CI 두 곳뿐, 유일한 로컬 사례인 BI-06 계약 검증은 명령 한 줄이 앞에 붙을 뿐이다. **`bootJar`를 검사와 같은 Gradle 호출에 넣은 것이 배선의 핵심이다** — 따로 부르면 시작 비용을 또 내지만 같은 호출이면 방금 컴파일한 클래스를 써 태스크가 **1.0초**다(프로파일 실측). `image-publish`는 별도 러너라 jar(77.6MB)를 아티팩트로 넘기고, PR은 같은 잡에서 빌드하므로 아티팩트가 필요 없어 업로드를 push에서만 한다. **함정 하나**: `Dockerfile` 전체 문자열에 `gradlew`가 없다고 단언했더니 **"`./gradlew bootJar`가 선행"이라고 알리는 주석 자체가 걸렸다.** 지우면 안 되는 정보라 판정 대상을 명령줄로 좁혔다(약화가 아니라 원래 보려던 것으로 맞춘 것). 정적 테스트로는 이미지가 실제로 만들어지는지 못 보므로 **로컬에서 직접 빌드해 확인했다** — 컨텍스트 전송 **81.38MB로 jar 하나뿐**(`.dockerignore` 허용목록 작동), `docker inspect`가 `User=1000`·`[java -jar /app.jar]`·`8080/tcp`·`BUILD_SHA`·`amd64/linux`로 BI-06 계약 유지. RED 3/4 → GREEN 4/4, 기존 계약 테스트 5건 회귀 통과. **PR 쪽은 실측됐다: 이미지 검증 48 → 3초, 잡 전체 3.2~3.3 → 2.6분.** 추정 10~15초보다 좋았고, 로컬 8.3초에 CI 오버헤드를 더한 추정이 **틀린 방향**이었다 — 러너의 컨텍스트 전송이 0.7초(로컬 4.3초)다. `Run checks`는 `bootJar`를 넣었는데 123 → 120초로 증가가 관측되지 않는다. 빌드가 실제로 돌았음은 로그로 확인했다(베이스 추출 · `transferring context: 81.38MB`). `dev` 발행 163초는 **아직 미측정**이라 머지 후 재서 BI-34에 덧붙인다 (S15P11A705-238) - -> 이 항목은 `WORKLOG.md` 동결(BD-45) 뒤에 표에 남아 있던 행을 새 규칙대로 옮긴 것이다. 원문을 고치지 않고 그대로 가져왔다. diff --git a/docs/backend/worklog/2026-08-01-ci-cache-measured.md b/docs/backend/worklog/2026-08-01-ci-cache-measured.md new file mode 100644 index 00000000..8cf14d9b --- /dev/null +++ b/docs/backend/worklog/2026-08-01-ci-cache-measured.md @@ -0,0 +1,9 @@ +# Jira 작업의 실측 정정 + +- **날짜**: 2026-08-01 +- **추적**: Jira 작업 +- **관련**: [BI-32](../implements/BI-32-2026-07-31-ci-pipeline-speedup.md) + +Jira 작업의 실측 정정. 머지 후 `dev` push가 캐시를 채우고 그것을 읽는 첫 코드 PR(#140)을 재서 보니 **이미지 검증 61초 → 48초, 13초 절약**이다 — 추정했던 27초의 절반이다. 놓친 것 둘: **캐시 가져오기를 0으로 뒀다**(레이어가 `CACHED`가 돼도 내려받아 푸는 데 6~8초를 새 러너마다 낸다), `bootJar`가 29.7 → 33.4초로 흔들려 4초를 먹었다. PR 총 시간은 3.2 → 3.3분으로 **개선이 보이지 않는다** — `Run checks` 편차(±14초)가 이득 13초보다 크다. **문서에 없던 비용이 하나 드러났다**: `image-publish`가 67초 → 163초가 됐고 로그가 `#20 exporting to GitHub Actions Cache ... DONE 91.4s`로 원인을 지목한다. 멀티스테이지에서 캐시하려는 `dependencies` 레이어가 **버려지는 build 스테이지 안에 있어** `mode=min`으로는 안 잡히므로 이 비용은 이 방식의 필수 조건이다. 즉 PR 1회 −13초 / 머지 1회 +96초이고 **총 CI 시간으로는 순손실**인데(손익분기가 PR당 7~8회 실행, 실제 2~4회) 유지하기로 정했다 — 사람이 기다리는 것은 PR 피드백이고 배포 96초 지연은 그 교환으로 감수한다. **반복 실행으로는 이득이 커지지 않는다**: `dependencies`가 이미 `CACHED`라 48초가 이 `Dockerfile`의 바닥이고, PR 실행은 읽기 전용이라 축적되는 것이 없다. 오히려 7일 미사용 삭제·저장소 10GB 한도로 밀려나면 61초로 돌아간다. 남은 지렛대는 캐시가 아니라 **이중 컴파일 제거(−33초)** 와 **Gradle configuration cache**(123초짜리 `Run checks`가 본체이고 빌드 로그가 직접 권한다)다. 163초가 첫 내보내기라 다음 머지에 줄어들지는 데이터 1건이라 미결로 남겼다. **문서 전용 건너뛰기는 이 정정 문서 자신이 첫 실사례가 되어 확인됐다** — 무거운 스텝 여섯이 건너뛰어지고 `backend-ci / check`가 success로 **보고**되어 `MERGEABLE`, 잡 전체 **14초**다(추정 30초보다 빨랐다). BD-42가 `paths-ignore`를 버린 이유가 이 보고를 잃지 않기 위함이었고 그대로 성립했다. 문서 PR에 관해서는 이 티켓의 약속이 실측으로 확인된 것이다 (티켓 없음 — 문서 정정) + +> 이 항목은 `WORKLOG.md` 동결(BD-45) 뒤에 표에 남아 있던 행을 새 규칙대로 옮긴 것이다. 원문을 고치지 않고 그대로 가져왔다. diff --git a/docs/backend/worklog/2026-08-01-ci-single-compile.md b/docs/backend/worklog/2026-08-01-ci-single-compile.md new file mode 100644 index 00000000..052fecd7 --- /dev/null +++ b/docs/backend/worklog/2026-08-01-ci-single-compile.md @@ -0,0 +1,9 @@ +# CI가 프로젝트를 두 번 컴파일하던 것을 없앴다(Jira 작업) + +- **날짜**: 2026-08-01 +- **추적**: Jira 작업 +- **관련**: [BD-44](../decisions/BD-44-image-takes-prebuilt-jar.md) · [BI-34](../implements/BI-34-2026-08-01-image-takes-prebuilt-jar.md) · [README](../../../README.md) + +CI가 프로젝트를 두 번 컴파일하던 것을 없앴다(Jira 작업). 러너의 `Run checks`가 컴파일하고 컨테이너가 `bootJar`로 또 컴파일했는데, 그 레이어는 `src`가 매 PR 바뀌어 **캐시로 접근할 수 없는 유일한 구간**이었다. 직전 티켓이 앞 레이어에 캐시를 붙여 13초를 얻었지만 멀티스테이지에서 그 레이어가 **버려지는 build 스테이지 안에 있어** `mode=max`가 필수였고, 그 내보내기가 dev 발행을 67 → 163초로 늘려 **13초 얻고 96초를 냈다.** 그래서 `Dockerfile`을 jar를 받는 단일 스테이지로 바꿨다 — 캐시할 대상이 사라지므로 `cache-from`·`cache-to`가 함께 빠지고 96초도 같이 갚는다(BD-44). **버린 대안이 (c) "멀티스테이지 유지 + CI만 jar 주입"이다**: 로컬 편의를 지키는 대신 Gradle 실행을 남겨야 해 캐시 설정을 못 걷고(96초 유지), CI가 검증하는 경로와 로컬 경로가 갈려 "CI는 초록인데 로컬은 깨진다"를 새로 산다 — (a)보다 나아지지 않으면서 위험만 는다. 혼자 빌드되는 성질을 쓰는 곳을 먼저 셌다: `compose.yaml`은 앱을 빌드하지 않고 `infra`는 태그로 받아쓰기만 하며 정기 빌드는 CI 두 곳뿐, 유일한 로컬 사례인 BI-06 계약 검증은 명령 한 줄이 앞에 붙을 뿐이다. **`bootJar`를 검사와 같은 Gradle 호출에 넣은 것이 배선의 핵심이다** — 따로 부르면 시작 비용을 또 내지만 같은 호출이면 방금 컴파일한 클래스를 써 태스크가 **1.0초**다(프로파일 실측). `image-publish`는 별도 러너라 jar(77.6MB)를 아티팩트로 넘기고, PR은 같은 잡에서 빌드하므로 아티팩트가 필요 없어 업로드를 push에서만 한다. **함정 하나**: `Dockerfile` 전체 문자열에 `gradlew`가 없다고 단언했더니 **"`./gradlew bootJar`가 선행"이라고 알리는 주석 자체가 걸렸다.** 지우면 안 되는 정보라 판정 대상을 명령줄로 좁혔다(약화가 아니라 원래 보려던 것으로 맞춘 것). 정적 테스트로는 이미지가 실제로 만들어지는지 못 보므로 **로컬에서 직접 빌드해 확인했다** — 컨텍스트 전송 **81.38MB로 jar 하나뿐**(`.dockerignore` 허용목록 작동), `docker inspect`가 `User=1000`·`[java -jar /app.jar]`·`8080/tcp`·`BUILD_SHA`·`amd64/linux`로 BI-06 계약 유지. RED 3/4 → GREEN 4/4, 기존 계약 테스트 5건 회귀 통과. **PR 쪽은 실측됐다: 이미지 검증 48 → 3초, 잡 전체 3.2~3.3 → 2.6분.** 추정 10~15초보다 좋았고, 로컬 8.3초에 CI 오버헤드를 더한 추정이 **틀린 방향**이었다 — 러너의 컨텍스트 전송이 0.7초(로컬 4.3초)다. `Run checks`는 `bootJar`를 넣었는데 123 → 120초로 증가가 관측되지 않는다. 빌드가 실제로 돌았음은 로그로 확인했다(베이스 추출 · `transferring context: 81.38MB`). `dev` 발행 163초는 **아직 미측정**이라 머지 후 재서 BI-34에 덧붙인다 (Jira 작업) + +> 이 항목은 `WORKLOG.md` 동결(BD-45) 뒤에 표에 남아 있던 행을 새 규칙대로 옮긴 것이다. 원문을 고치지 않고 그대로 가져왔다. diff --git a/docs/backend/worklog/2026-08-01-S15P11A705-240-keyword-exposure.md b/docs/backend/worklog/2026-08-01-keyword-exposure.md similarity index 98% rename from docs/backend/worklog/2026-08-01-S15P11A705-240-keyword-exposure.md rename to docs/backend/worklog/2026-08-01-keyword-exposure.md index c1564333..ac24876c 100644 --- a/docs/backend/worklog/2026-08-01-S15P11A705-240-keyword-exposure.md +++ b/docs/backend/worklog/2026-08-01-keyword-exposure.md @@ -1,7 +1,7 @@ # 조회 응답 3곳에 Record·Collection 키워드를 채웠다 - **날짜**: 2026-08-01 -- **추적**: S15P11A705-240 +- **추적**: Jira 작업 - **관련**: [BI-36](../implements/BI-36-2026-08-01-keyword-exposure.md) · [back#145](https://github.com/Team-PinLog/back/issues/145) · [#147](https://github.com/Team-PinLog/back/pull/147) · [BD-18](../decisions/BD-18-keyword-preset-and-visibility.md) 키워드를 실제로 채우는 응답이 **AI 검색 하나뿐**이었고, Record 상세·타인 Record 카드·책장 목록(§9.3·§8.1)은 `List.of()` 고정이라 "AI가 채우기 전까지 빈 배열"이 사실상 영구 빈 배열이었다. AI 몫(FastAPI가 `ai.context_keyword`를 채움)은 끝나 있었고, 빠져 있던 것은 백엔드 몫인 읽기 조인이다(BD-16이 백엔드의 권리이자 의무로 명시). diff --git a/docs/backend/worklog/2026-08-01-worklog-freeze-consistency.md b/docs/backend/worklog/2026-08-01-worklog-freeze-consistency.md index f47d432b..eb5e1349 100644 --- a/docs/backend/worklog/2026-08-01-worklog-freeze-consistency.md +++ b/docs/backend/worklog/2026-08-01-worklog-freeze-consistency.md @@ -6,7 +6,7 @@ BD-45가 `WORKLOG.md`를 "2026-08-01 이전 이력"으로 동결했는데, 동결 시점의 표에 그 선언과 어긋나는 행이 셋 있었다. -- **2026-08-01 날짜 행 둘**(S15P11A705-238·231) — 동결 헤더가 "이전 작업 로그가 아래 표에 남는다"고 말하는 파일에 동결 당일 행이 남아 있었다. 새 규칙대로 `worklog/` 항목 파일로 옮겼다. 원문은 고치지 않았고, 상대 링크만 폴더 깊이에 맞췄다. -- **중복 행 한 쌍**(S15P11A705-192 하네스, "28개/136건" 판과 "29개/138건" 판) — 한 브랜치가 기존 행을 정정하는 동안 다른 브랜치가 원문을 들고 있어 union 병합이 두 판을 모두 남긴 경우다. WORKLOG 헤더가 예고한 바로 그 함정이며, 정정 후 판(29개)만 남겼다. +- **2026-08-01 날짜 행 둘**(Jira 작업·231) — 동결 헤더가 "이전 작업 로그가 아래 표에 남는다"고 말하는 파일에 동결 당일 행이 남아 있었다. 새 규칙대로 `worklog/` 항목 파일로 옮겼다. 원문은 고치지 않았고, 상대 링크만 폴더 깊이에 맞췄다. +- **중복 행 한 쌍**(Jira 작업 하네스, "28개/136건" 판과 "29개/138건" 판) — 한 브랜치가 기존 행을 정정하는 동안 다른 브랜치가 원문을 들고 있어 union 병합이 두 판을 모두 남긴 경우다. WORKLOG 헤더가 예고한 바로 그 함정이며, 정정 후 판(29개)만 남겼다. 기록 보존 원칙과의 관계: 삭제가 아니라 이동·중복 해소다. 옮긴 두 행은 내용 그대로 항목 파일에 있고, 지운 중복은 같은 작업의 낡은 판이다. diff --git a/docs/backend/worklog/2026-08-02-S15P11A705-239-load-profiles.md b/docs/backend/worklog/2026-08-02-S15P11A705-239-load-profiles.md deleted file mode 100644 index 035fc9c8..00000000 --- a/docs/backend/worklog/2026-08-02-S15P11A705-239-load-profiles.md +++ /dev/null @@ -1,7 +0,0 @@ -# 부하 프로파일 10회를 돌려 병목을 판정했다(S15P11A705-239) - -- **날짜**: 2026-08-02 -- **추적**: S15P11A705-239 -- **관련**: [BI-37](../implements/BI-37-2026-08-02-load-profiles-report.md) · [BI-33](../implements/BI-33-2026-07-31-api-verification-harness.md) · [BD-11](../decisions/BD-11-minimum-holding-invariants.md) - -1단계 하네스 위에 부하 프로파일을 얹어(S15P11A705-239) 4종 프로파일 × 읽기·쓰기 + 혼합 2판, 총 10회를 돌렸다. **첫 병목은 HikariCP 풀(10)이다** — 20VU에서 이미 포화하고 150VU에서 대기 140~154. 스파이크에서 목록 p50이 19→311ms로 뛴 것은 엔드포인트가 아니라 커넥션 대기다. **피드는 20VU에서 이미 p50 746ms**로 다른 읽기의 35배이며 혼합 부하에서 풀을 점유해 전 부류를 끌어내린다. 1단계가 미뤄둔 관측 2건은 실측으로 닫았다: 지도 61KB는 현 수준에서 병목이 아니고(풀 경합이 지배), BD-11 부모 행 잠금 직렬화는 실재하나 완만해(경합/처리량 비 1.33→0.45배) "개인 데이터 동시성 낮음, 수용" 전제가 확인됐다. **10조합 전부 부하 전후 back 위반 4,696 불변** — 150VU 쓰기·연쇄 삭제에서도 core 정합이 깨지지 않은 것이 가장 강한 긍정 결과다. 판단 셋: ① 경합 시나리오는 VU 10 고정(프로파일 간 비교 가능성 — 처리량 VU와 분리), ② 쓰기 여정의 삭제 순서를 Collection 먼저로 교정 — force 연쇄가 먼저 지우면 뒤 삭제가 매번 404라 오류율이 1/7 부풀었다(가짜 신호 제거), ③ read-stress 피드 p99 7.2×10⁶ms는 32건 동시 타임아웃(순간 정지 1회)의 k6 기록 산물이라 기각하고 p95를 신뢰 값으로 썼다 — 순간 정지 자체는 재현 과제로 남긴다. 만드는 중 리뷰가 잡은 것 다섯(빈 smoke·지도 heavy 죽은 교대·혼합 골든 토큰 누락·p99 미출력·가짜 404)은 전부 매트릭스 실행 **전에** 막았다. 개선 후보 5건은 BI-37 말미에 — 풀 산정과 피드 분석이 1·2순위다 (S15P11A705-239) diff --git a/docs/backend/worklog/2026-08-02-load-profiles.md b/docs/backend/worklog/2026-08-02-load-profiles.md new file mode 100644 index 00000000..589a02b4 --- /dev/null +++ b/docs/backend/worklog/2026-08-02-load-profiles.md @@ -0,0 +1,7 @@ +# 부하 프로파일 10회를 돌려 병목을 판정했다(Jira 작업) + +- **날짜**: 2026-08-02 +- **추적**: Jira 작업 +- **관련**: [BI-37](../implements/BI-37-2026-08-02-load-profiles-report.md) · [BI-33](../implements/BI-33-2026-07-31-api-verification-harness.md) · [BD-11](../decisions/BD-11-minimum-holding-invariants.md) + +1단계 하네스 위에 부하 프로파일을 얹어(Jira 작업) 4종 프로파일 × 읽기·쓰기 + 혼합 2판, 총 10회를 돌렸다. **첫 병목은 HikariCP 풀(10)이다** — 20VU에서 이미 포화하고 150VU에서 대기 140~154. 스파이크에서 목록 p50이 19→311ms로 뛴 것은 엔드포인트가 아니라 커넥션 대기다. **피드는 20VU에서 이미 p50 746ms**로 다른 읽기의 35배이며 혼합 부하에서 풀을 점유해 전 부류를 끌어내린다. 1단계가 미뤄둔 관측 2건은 실측으로 닫았다: 지도 61KB는 현 수준에서 병목이 아니고(풀 경합이 지배), BD-11 부모 행 잠금 직렬화는 실재하나 완만해(경합/처리량 비 1.33→0.45배) "개인 데이터 동시성 낮음, 수용" 전제가 확인됐다. **10조합 전부 부하 전후 back 위반 4,696 불변** — 150VU 쓰기·연쇄 삭제에서도 core 정합이 깨지지 않은 것이 가장 강한 긍정 결과다. 판단 셋: ① 경합 시나리오는 VU 10 고정(프로파일 간 비교 가능성 — 처리량 VU와 분리), ② 쓰기 여정의 삭제 순서를 Collection 먼저로 교정 — force 연쇄가 먼저 지우면 뒤 삭제가 매번 404라 오류율이 1/7 부풀었다(가짜 신호 제거), ③ read-stress 피드 p99 7.2×10⁶ms는 32건 동시 타임아웃(순간 정지 1회)의 k6 기록 산물이라 기각하고 p95를 신뢰 값으로 썼다 — 순간 정지 자체는 재현 과제로 남긴다. 만드는 중 리뷰가 잡은 것 다섯(빈 smoke·지도 heavy 죽은 교대·혼합 골든 토큰 누락·p99 미출력·가짜 404)은 전부 매트릭스 실행 **전에** 막았다. 개선 후보 5건은 BI-37 말미에 — 풀 산정과 피드 분석이 1·2순위다 (Jira 작업) diff --git a/docs/backend/worklog/2026-08-02-S15P11A705-249-oauth-path-ownership.md b/docs/backend/worklog/2026-08-02-oauth-path-ownership.md similarity index 99% rename from docs/backend/worklog/2026-08-02-S15P11A705-249-oauth-path-ownership.md rename to docs/backend/worklog/2026-08-02-oauth-path-ownership.md index d5594b1a..dafeae4c 100644 --- a/docs/backend/worklog/2026-08-02-S15P11A705-249-oauth-path-ownership.md +++ b/docs/backend/worklog/2026-08-02-oauth-path-ownership.md @@ -1,7 +1,7 @@ # OAuth 인가·콜백 경로 상수의 소유를 security로 옮겼다 - **날짜**: 2026-08-02 -- **추적**: S15P11A705-249 +- **추적**: Jira 작업 - **관련**: [back#157](https://github.com/Team-PinLog/back/issues/157) · [package-structure](../../development/package-structure.md) · [BD-31](../decisions/BD-31-jwt-rs256-key-management.md) 구조를 훑다가 `SecurityConfig`가 `domain/auth/controller/SocialLoginController`를 import하는 것을 봤다. 무엇을 쓰는지 따라가 보니 `AUTHORIZATION_BASE_URI` 문자열 하나였다. diff --git a/docs/backend/worklog/2026-08-03-S15P11A705-188-auth-filter-db-failure.md b/docs/backend/worklog/2026-08-03-auth-filter-db-failure.md similarity index 99% rename from docs/backend/worklog/2026-08-03-S15P11A705-188-auth-filter-db-failure.md rename to docs/backend/worklog/2026-08-03-auth-filter-db-failure.md index 138130de..1ce2fb3f 100644 --- a/docs/backend/worklog/2026-08-03-S15P11A705-188-auth-filter-db-failure.md +++ b/docs/backend/worklog/2026-08-03-auth-filter-db-failure.md @@ -1,7 +1,7 @@ # 인증 필터의 DB 실패를 401이 아니라 503으로 말하게 했다 - **날짜**: 2026-08-03 -- **추적**: S15P11A705-188 +- **추적**: Jira 작업 - **관련**: [BD-47](../decisions/BD-47-auth-filter-infra-failure-is-503.md) · [BD-41](../decisions/BD-41-withdrawn-member-access-token.md) · [BD-28](../decisions/BD-28-readiness-includes-db.md) · [back#171](https://github.com/Team-PinLog/back/issues/171) BD-41이 "어떻게 다룰지는 별도 티켓에서 정한다"로 남겨 둔 건이다. 결정 근거는 [BD-47](../decisions/BD-47-auth-filter-infra-failure-is-503.md)에 있고, 여기에는 고르면서 내린 판단만 적는다. diff --git a/docs/backend/worklog/2026-08-03-S15P11A705-190-collection-record-ownership-test.md b/docs/backend/worklog/2026-08-03-collection-record-ownership-test.md similarity index 97% rename from docs/backend/worklog/2026-08-03-S15P11A705-190-collection-record-ownership-test.md rename to docs/backend/worklog/2026-08-03-collection-record-ownership-test.md index f3302c04..d0efb16f 100644 --- a/docs/backend/worklog/2026-08-03-S15P11A705-190-collection-record-ownership-test.md +++ b/docs/backend/worklog/2026-08-03-collection-record-ownership-test.md @@ -1,7 +1,7 @@ # Collection이 소유자 자기 Record만 담는다는 불변식을 addRecords 경로에도 고정 - **날짜**: 2026-08-03 -- **추적**: S15P11A705-190 +- **추적**: Jira 작업 - **관련**: `CollectionService.requireAllOwnedActiveRecords` · `CollectionRecordRepository.findByCollectionIdIn` `collection_record` 링크를 만드는 프로덕션 경로는 `CollectionService.create`와 diff --git a/docs/backend/worklog/2026-08-03-S15P11A705-282-drop-dead-datasource-literal.md b/docs/backend/worklog/2026-08-03-drop-dead-datasource-literal.md similarity index 97% rename from docs/backend/worklog/2026-08-03-S15P11A705-282-drop-dead-datasource-literal.md rename to docs/backend/worklog/2026-08-03-drop-dead-datasource-literal.md index ac561196..733eba19 100644 --- a/docs/backend/worklog/2026-08-03-S15P11A705-282-drop-dead-datasource-literal.md +++ b/docs/backend/worklog/2026-08-03-drop-dead-datasource-literal.md @@ -1,7 +1,7 @@ # application-prod.yml의 죽은 datasource·Redis 리터럴을 지운다 - **날짜**: 2026-08-03 -- **추적**: S15P11A705-282 +- **추적**: Jira 작업 - **관련**: [BD-50](../decisions/BD-50-datasource-redis-config-follows-infra-env-vars.md) · [back#164](https://github.com/Team-PinLog/back/pull/164) `configuration.md`를 실제 infra 계약에 맞추다가, `application-prod.yml`과 `ConfigurationContractTests`가 여전히 "리터럴 FQDN + `DB_PASSWORD`" 옛 계약을 파일 자체로 고정하고 있는 걸 발견했다. Spring Boot는 OS 환경변수를 profile별 yaml보다 우선하므로, infra가 실제로 주입하는 `SPRING_DATASOURCE_*`·`SPRING_DATA_REDIS_*` 다섯 개가 이 yaml을 항상 이겼다 — "접속 정보는 코드로 통제한다"는 설계 의도가 애초에 지켜진 적이 없었다. diff --git a/docs/backend/worklog/2026-08-03-S15P11A705-244-follows-collection-first-page.md b/docs/backend/worklog/2026-08-03-follows-collection-first-page.md similarity index 93% rename from docs/backend/worklog/2026-08-03-S15P11A705-244-follows-collection-first-page.md rename to docs/backend/worklog/2026-08-03-follows-collection-first-page.md index 449442a9..04b0414f 100644 --- a/docs/backend/worklog/2026-08-03-S15P11A705-244-follows-collection-first-page.md +++ b/docs/backend/worklog/2026-08-03-follows-collection-first-page.md @@ -1,7 +1,7 @@ # 팔로우 목록에 책장별 Collection 첫 페이지 동봉 - **날짜**: 2026-08-03 -- **추적**: S15P11A705-244 +- **추적**: Jira 작업 - **관련**: [docs#39](https://github.com/Team-PinLog/docs/pull/39) · [docs#38](https://github.com/Team-PinLog/docs/issues/38) 내 책장 화면이 팔로우 책장 표지를 그리려면 `GET /follows` + 책장마다 `GET /follows/{id}/collections`로 @@ -22,7 +22,7 @@ docs#39, 프론트 확인 완료. - **작성자 탈퇴 필터를 집계 질의에 두지 않았다.** 팔로우 목록 질의가 탈퇴 회원을 이미 거르고, 집계는 그 결과 위에서만 돈다. - **`collectionSize` 보정은 `size`와 같은 `CursorPage.normalizeSize` 하나다** — 서버 방어 상한의 - 답은 하나(S15P11A705-117). 명세에도 보정과 권장 호출값(`size=10`·`collectionSize=5`)을 명시했다. + 답은 하나(Jira 작업). 명세에도 보정과 권장 호출값(`size=10`·`collectionSize=5`)을 명시했다. `size` 기본은 1.4의 20 그대로이며 `collectionSize`가 있어도 달라지지 않는다. - **안쪽 커서는 마지막으로 실린 항목을 가리킨다.** 초과 행(probe)을 가리키면 그 행이 건너뛰어진다. 기존 9.3 엔드포인트로 이어받는 연속성은 테스트로 고정했다. diff --git a/docs/backend/worklog/2026-08-03-S15P11A705-265-list-sort-default-asc.md b/docs/backend/worklog/2026-08-03-list-sort-default-asc.md similarity index 98% rename from docs/backend/worklog/2026-08-03-S15P11A705-265-list-sort-default-asc.md rename to docs/backend/worklog/2026-08-03-list-sort-default-asc.md index 27b7e968..fb7be371 100644 --- a/docs/backend/worklog/2026-08-03-S15P11A705-265-list-sort-default-asc.md +++ b/docs/backend/worklog/2026-08-03-list-sort-default-asc.md @@ -1,7 +1,7 @@ # 목록 정렬 기본을 오래된순으로 뒤집고 방향 파라미터를 연다 - **날짜**: 2026-08-03 -- **추적**: S15P11A705-265 +- **추적**: Jira 작업 - **관련**: [BD-46](../decisions/BD-46-list-sort-default-asc-with-params.md) · [docs#42](https://github.com/Team-PinLog/docs/pull/42) · [docs#37](https://github.com/Team-PinLog/docs/pull/37) (계승됨) Collection 목록·Collection 내부 Record·Record 상세 contexts의 기본 정렬을 최신순에서 diff --git a/docs/backend/worklog/2026-08-03-S15P11A705-283-massive-bench.md b/docs/backend/worklog/2026-08-03-massive-bench.md similarity index 94% rename from docs/backend/worklog/2026-08-03-S15P11A705-283-massive-bench.md rename to docs/backend/worklog/2026-08-03-massive-bench.md index 9bc9e402..0170fefa 100644 --- a/docs/backend/worklog/2026-08-03-S15P11A705-283-massive-bench.md +++ b/docs/backend/worklog/2026-08-03-massive-bench.md @@ -1,7 +1,7 @@ -# 천만 건 벤치 환경을 만들었다(S15P11A705-283) +# 천만 건 벤치 환경을 만들었다(Jira 작업) - **날짜**: 2026-08-03 -- **추적**: S15P11A705-283 +- **추적**: Jira 작업 - **관련**: [BI-33](../implements/BI-33-2026-07-31-api-verification-harness.md) · [BI-37](../implements/BI-37-2026-08-02-load-profiles-report.md) · [BD-20](../decisions/BD-20-selective-denormalization.md) -현재 시드(record 117k)에서는 플래너가 Seq Scan을 골라도 손해가 없어 인덱스 설계가 판별되지 않는다 — 실측으로 피드 후보 채널이 collection 12,527행 전체를 3.58ms에 훑어 `ix_collection_feed` 사용 여부를 가릴 수 없었다. 그래서 `loadtest/tools/`에 순수 SQL 생성기(`seed-massive.sql`)를 두고 **회원 10만 · record 1,000만**을 296초에 적재했다(DB 707MB → 5.9GB). 설계 셋: ① **지우지 않고 이어 붙인다** — 새 id를 현재 최대값 뒤에 만들어 골든 셋·기존 시드·하네스 참조를 보존하고, 되돌리기는 `BENCH-` 표식 기반 `teardown-massive.sql`이 맡는다. ② **분포는 구간표다** — 균등 배분은 선택도 추정을 현실과 어긋나게 하므로 monster(50명×2만)~tiny(5만명×8) 5구간으로 짜서 p50 19 · p90 103 · max 20,000이 나오게 했다(실측 형태 유지). ③ **비유니크 인덱스만 내렸다 되돌린다** — 유니크는 남겨 생성기 결함이 그 자리에서 터지게 하고, DDL은 `pg_get_indexdef` 왕복이라 마이그레이션과 어긋날 수 없다. 측정 조건도 함께 갖췄다: 로컬 Docker는 메모리 15GiB라 천만 행도 캐시에 다 들어가므로(적중률 99.99%) 운영과 같은 1Gi 한도를 `compose.bench.yaml` override로 얹는다 — 한도 상태 콜드 스캔에서 적중률 0%·디스크 읽기 2.9GB를 확인했다(cgroup이 페이지 캐시를 함께 계산). 검증(`verify-massive.sql`)은 행 수·분포·골든 보존·통계 갱신·값 정합(BD-11·20·33) 10종을 PASS/FAIL로 판정하고 실패 시 0이 아닌 코드로 끝난다 — 첫 실행에서 기존 시드의 빈 Collection 137건(BI-33 기준선)을 생성기 결함으로 오인해 FAIL이 났고, 정합 검사를 벤치 생성분으로 한정해 바로잡았다. psql 함정 둘도 기록해 둔다: DO 블록 안에서는 `:vars`가 치환되지 않고(가드를 `\gset`+`\if`로), `\quit`은 종료 코드를 못 정한다(`RAISE EXCEPTION`+`ON_ERROR_STOP`으로 코드 3). 이 볼륨 위 실행 계획 관측은 S15P11A705-284가 이어받는다 (S15P11A705-283) +현재 시드(record 117k)에서는 플래너가 Seq Scan을 골라도 손해가 없어 인덱스 설계가 판별되지 않는다 — 실측으로 피드 후보 채널이 collection 12,527행 전체를 3.58ms에 훑어 `ix_collection_feed` 사용 여부를 가릴 수 없었다. 그래서 `loadtest/tools/`에 순수 SQL 생성기(`seed-massive.sql`)를 두고 **회원 10만 · record 1,000만**을 296초에 적재했다(DB 707MB → 5.9GB). 설계 셋: ① **지우지 않고 이어 붙인다** — 새 id를 현재 최대값 뒤에 만들어 골든 셋·기존 시드·하네스 참조를 보존하고, 되돌리기는 `BENCH-` 표식 기반 `teardown-massive.sql`이 맡는다. ② **분포는 구간표다** — 균등 배분은 선택도 추정을 현실과 어긋나게 하므로 monster(50명×2만)~tiny(5만명×8) 5구간으로 짜서 p50 19 · p90 103 · max 20,000이 나오게 했다(실측 형태 유지). ③ **비유니크 인덱스만 내렸다 되돌린다** — 유니크는 남겨 생성기 결함이 그 자리에서 터지게 하고, DDL은 `pg_get_indexdef` 왕복이라 마이그레이션과 어긋날 수 없다. 측정 조건도 함께 갖췄다: 로컬 Docker는 메모리 15GiB라 천만 행도 캐시에 다 들어가므로(적중률 99.99%) 운영과 같은 1Gi 한도를 `compose.bench.yaml` override로 얹는다 — 한도 상태 콜드 스캔에서 적중률 0%·디스크 읽기 2.9GB를 확인했다(cgroup이 페이지 캐시를 함께 계산). 검증(`verify-massive.sql`)은 행 수·분포·골든 보존·통계 갱신·값 정합(BD-11·20·33) 10종을 PASS/FAIL로 판정하고 실패 시 0이 아닌 코드로 끝난다 — 첫 실행에서 기존 시드의 빈 Collection 137건(BI-33 기준선)을 생성기 결함으로 오인해 FAIL이 났고, 정합 검사를 벤치 생성분으로 한정해 바로잡았다. psql 함정 둘도 기록해 둔다: DO 블록 안에서는 `:vars`가 치환되지 않고(가드를 `\gset`+`\if`로), `\quit`은 종료 코드를 못 정한다(`RAISE EXCEPTION`+`ON_ERROR_STOP`으로 코드 3). 이 볼륨 위 실행 계획 관측은 Jira 작업가 이어받는다 (Jira 작업) diff --git a/docs/backend/worklog/2026-08-03-S15P11A705-284-plan-observation.md b/docs/backend/worklog/2026-08-03-plan-observation.md similarity index 96% rename from docs/backend/worklog/2026-08-03-S15P11A705-284-plan-observation.md rename to docs/backend/worklog/2026-08-03-plan-observation.md index 5681c020..2dcbb98c 100644 --- a/docs/backend/worklog/2026-08-03-S15P11A705-284-plan-observation.md +++ b/docs/backend/worklog/2026-08-03-plan-observation.md @@ -1,7 +1,7 @@ -# 천만 건 위에서 읽기 경로 4종의 계획을 관측했다(S15P11A705-284) +# 천만 건 위에서 읽기 경로 4종의 계획을 관측했다(Jira 작업) - **날짜**: 2026-08-03 -- **추적**: S15P11A705-284 +- **추적**: Jira 작업 - **관련**: [BI-38](../implements/BI-38-2026-08-03-massive-scale-plan-observation.md) · [BD-04](../decisions/BD-04-cursor-pagination.md) · [BD-33](../decisions/BD-33-published-at-invariant.md) -283이 만든 record 1,000만 · collection 667k 볼륨 위에서 커서 깊은 페이지·컬렉션 상세 배치 5쿼리·지도·피드 후보 3채널을 `EXPLAIN (ANALYZE, BUFFERS)`로 재고 117k 기준선과 대조했다. 기준선은 벤치 데이터가 전부 기존 최대 id 뒤에 붙는 성질을 이용해 `id <= 기준점` 행만 별도 DB로 복사해 재구성했고(record 117,022로 원본과 일치), 대상 id를 두 규모에 공존하는 것으로 고정해 표 크기 효과만 남겼다. 측정은 1Gi 한도 + 콜드 재기동 상태에서 했고 천만 건 측정의 `shared read`가 0이 아님을 확인했다(캐시에 갇힌 측정 아님). **규모에 정비례로 무너지는 것은 피드 최신 채널 하나다** — `ORDER BY COALESCE(published_at, created_at)`가 `ix_collection_feed`의 정렬 순서와 달라 활성 발행 66만 행 전수를 Parallel Seq Scan으로 정렬한다(3.48ms → 241ms, 69배, 디스크 9,673블록). COALESCE만 빼면 105행 · 1.66ms(145배 차)이고, 발행 컬렉션 66.6만 건 중 `published_at IS NULL`은 0건이라(V5 CHECK, BD-33) 이 방어는 지킬 대상이 없다 — 코드 한 줄 수정이 개선 후보 1순위다. 나머지는 규모를 통과했다: keyset 커서 깊은 페이지는 두 규모에서 똑같이 0.03ms(BD-04 설계 검증), 상세 배치 5쿼리는 전부 인덱스로 합계 5.7ms. 지도는 처음에 "계획이 평평하다"고 적었는데 **틀렸다** — 27.3ms → 13.5ms로 오히려 빨라진 것이 이상해 계획을 다시 보니 117k는 Hash Join으로 place 40,034행을 전수 해싱했고(그 단계가 26.6ms) 천만 건은 place가 24만 행이 되며 Nested Loop + `place_pkey` 697회로 갈아탔다. 규모가 커져서 빨라진 게 아니라 **117k 쪽 계획이 애초에 이 쿼리에 맞지 않았던 것**이고, 측정 순서에서 온 캐시 편차(천만 건을 먼저 재 캐시를 점유)도 겹쳐 두 수치는 직접 비교할 수 없다. 지도의 실질 위험은 쿼리가 아니라 페이로드다(레코드 2만 회원 응답 2.0MB). 이 과정에서 부하 하네스 함정도 하나 나왔다 — `load-read.js`가 `/v1/records/map`을 **bbox 없이** 불러 `findMarkers`(전량)로 가는데 이 보고서는 bbox 있는 `findMarkersWithinBounds`를 쟀다. 부하 결과와 계획 관측이 서로 다른 쿼리를 보고 있었고, 사용자 경로는 bbox 있는 쪽이다(개선 후보 3). 클라이언트 렌더링은 범위 밖으로 명시했다 — 서버 시간과 응답 바이트까지만 쟀다. 결과 정본은 BI-38, 재실행은 `loadtest/sql/explain-matrix.sql`을 두 DB에 돌리면 된다 (S15P11A705-284) +283이 만든 record 1,000만 · collection 667k 볼륨 위에서 커서 깊은 페이지·컬렉션 상세 배치 5쿼리·지도·피드 후보 3채널을 `EXPLAIN (ANALYZE, BUFFERS)`로 재고 117k 기준선과 대조했다. 기준선은 벤치 데이터가 전부 기존 최대 id 뒤에 붙는 성질을 이용해 `id <= 기준점` 행만 별도 DB로 복사해 재구성했고(record 117,022로 원본과 일치), 대상 id를 두 규모에 공존하는 것으로 고정해 표 크기 효과만 남겼다. 측정은 1Gi 한도 + 콜드 재기동 상태에서 했고 천만 건 측정의 `shared read`가 0이 아님을 확인했다(캐시에 갇힌 측정 아님). **규모에 정비례로 무너지는 것은 피드 최신 채널 하나다** — `ORDER BY COALESCE(published_at, created_at)`가 `ix_collection_feed`의 정렬 순서와 달라 활성 발행 66만 행 전수를 Parallel Seq Scan으로 정렬한다(3.48ms → 241ms, 69배, 디스크 9,673블록). COALESCE만 빼면 105행 · 1.66ms(145배 차)이고, 발행 컬렉션 66.6만 건 중 `published_at IS NULL`은 0건이라(V5 CHECK, BD-33) 이 방어는 지킬 대상이 없다 — 코드 한 줄 수정이 개선 후보 1순위다. 나머지는 규모를 통과했다: keyset 커서 깊은 페이지는 두 규모에서 똑같이 0.03ms(BD-04 설계 검증), 상세 배치 5쿼리는 전부 인덱스로 합계 5.7ms. 지도는 처음에 "계획이 평평하다"고 적었는데 **틀렸다** — 27.3ms → 13.5ms로 오히려 빨라진 것이 이상해 계획을 다시 보니 117k는 Hash Join으로 place 40,034행을 전수 해싱했고(그 단계가 26.6ms) 천만 건은 place가 24만 행이 되며 Nested Loop + `place_pkey` 697회로 갈아탔다. 규모가 커져서 빨라진 게 아니라 **117k 쪽 계획이 애초에 이 쿼리에 맞지 않았던 것**이고, 측정 순서에서 온 캐시 편차(천만 건을 먼저 재 캐시를 점유)도 겹쳐 두 수치는 직접 비교할 수 없다. 지도의 실질 위험은 쿼리가 아니라 페이로드다(레코드 2만 회원 응답 2.0MB). 이 과정에서 부하 하네스 함정도 하나 나왔다 — `load-read.js`가 `/v1/records/map`을 **bbox 없이** 불러 `findMarkers`(전량)로 가는데 이 보고서는 bbox 있는 `findMarkersWithinBounds`를 쟀다. 부하 결과와 계획 관측이 서로 다른 쿼리를 보고 있었고, 사용자 경로는 bbox 있는 쪽이다(개선 후보 3). 클라이언트 렌더링은 범위 밖으로 명시했다 — 서버 시간과 응답 바이트까지만 쟀다. 결과 정본은 BI-38, 재실행은 `loadtest/sql/explain-matrix.sql`을 두 DB에 돌리면 된다 (Jira 작업) diff --git a/docs/backend/worklog/2026-08-03-S15P11A705-267-refresh-revocation-leak.md b/docs/backend/worklog/2026-08-03-refresh-revocation-leak.md similarity index 99% rename from docs/backend/worklog/2026-08-03-S15P11A705-267-refresh-revocation-leak.md rename to docs/backend/worklog/2026-08-03-refresh-revocation-leak.md index f2f1920b..73a982f5 100644 --- a/docs/backend/worklog/2026-08-03-S15P11A705-267-refresh-revocation-leak.md +++ b/docs/backend/worklog/2026-08-03-refresh-revocation-leak.md @@ -1,7 +1,7 @@ # 동시 회전에서 새던 전 세션 폐기를 막고, 재발급 실패가 쿠키를 정리하게 했다 - **날짜**: 2026-08-03 -- **추적**: S15P11A705-267 +- **추적**: Jira 작업 - **관련**: [BT-06](../troubleshooting/BT-06-refresh-revocation-leak-under-concurrent-rotation.md) · [BD-35](../decisions/BD-35-refresh-reuse-family-revocation.md) · [back#165](https://github.com/Team-PinLog/back/issues/165) 프론트가 "`logged_in` 쿠키 수명과 Refresh TTL이 어긋난 것 같다"고 제보했는데 그건 아니었다. 세 값이 `refresh-token-ttl: 7d` 한 곳에서 나오므로 어긋날 수가 없다. 증상·원인 추적은 [BT-06](../troubleshooting/BT-06-refresh-revocation-leak-under-concurrent-rotation.md)에 남겼고, 여기에는 고치면서 내린 판단만 적는다. diff --git a/docs/backend/worklog/2026-08-04-S15P11A705-132-authorization-request-cookie-json.md b/docs/backend/worklog/2026-08-04-authorization-request-cookie-json.md similarity index 99% rename from docs/backend/worklog/2026-08-04-S15P11A705-132-authorization-request-cookie-json.md rename to docs/backend/worklog/2026-08-04-authorization-request-cookie-json.md index 4621fc0a..9d075554 100644 --- a/docs/backend/worklog/2026-08-04-S15P11A705-132-authorization-request-cookie-json.md +++ b/docs/backend/worklog/2026-08-04-authorization-request-cookie-json.md @@ -1,7 +1,7 @@ # 인가 요청 쿠키를 Java 직렬화에서 JSON으로 바꿨다 - **날짜**: 2026-08-04 -- **추적**: S15P11A705-132 +- **추적**: Jira 작업 - **관련**: [BD-49](../decisions/BD-49-authorization-request-cookie-json.md) · [BD-30](../decisions/BD-30-authorization-request-in-cookie.md)(대체된 결정) · [back#175](https://github.com/Team-PinLog/back/issues/175) BD-30이 고른 Java 직렬화를 걷어내고 Spring Security의 공식 Jackson 모듈로 바꿨다. 형식만 바뀌고 쿠키에 담는다는 결정과 속성은 그대로다. diff --git a/docs/backend/worklog/2026-08-04-S15P11A705-303-feed-order-by-index.md b/docs/backend/worklog/2026-08-04-feed-order-by-index.md similarity index 94% rename from docs/backend/worklog/2026-08-04-S15P11A705-303-feed-order-by-index.md rename to docs/backend/worklog/2026-08-04-feed-order-by-index.md index 5a769aa6..55253687 100644 --- a/docs/backend/worklog/2026-08-04-S15P11A705-303-feed-order-by-index.md +++ b/docs/backend/worklog/2026-08-04-feed-order-by-index.md @@ -1,7 +1,7 @@ -# 피드 후보 채널의 정렬키 표현식을 걷어냈다(S15P11A705-303) +# 피드 후보 채널의 정렬키 표현식을 걷어냈다(Jira 작업) - **날짜**: 2026-08-04 -- **추적**: S15P11A705-303 +- **추적**: Jira 작업 - **관련**: [BI-38](../implements/BI-38-2026-08-03-massive-scale-plan-observation.md)(근거) · [BD-33](../decisions/BD-33-published-at-invariant.md) BI-38이 1순위로 지목한 것을 고쳤다. `FeedCandidateRepository`의 최신·팔로우 채널이 `ORDER BY COALESCE(published_at, created_at)`를 써서 `ix_collection_feed (is_published, published_at DESC)`의 정렬 순서를 쓸 수 없었고, 상위 100건만 필요한데도 활성 발행 66만 행 전부를 Sort로 넘기고 있었다. 정렬키를 `c.published_at DESC, c.id DESC`로 바꾸고 SELECT의 `COALESCE`도 네 쿼리에서 모두 걷어냈다 — `is_published = true` 필터 안에서는 V5 `ck_collection_published_at` CHECK가 NOT NULL을 보장하므로(BD-33) 감쌀 대상이 없고, 따라서 `FeedScorer.recency()`와 `ScoredCandidate` 타이브레이커가 받는 값도 그대로다. @@ -12,4 +12,4 @@ BI-38이 1순위로 지목한 것을 고쳤다. `FeedCandidateRepository`의 최 측정 중 함정 둘. 앱이 죽은 상태에서 잰 "2.2초"는 응답 시간이 아니라 curl 연결 실패 시간이었다(종료 코드 7) — 폐기하고 수정된 jar로 다시 띄워 쟀다. 그리고 postgres만 재기동하면 Redis가 빠져 health가 DOWN으로 남는다. -**남은 197ms의 정체를 찾았다 — `PROFILE_KEYWORDS_SQL`이 163ms다.** `ai.context_keyword`(213,290행)를 Index Only Scan으로 전수 훑고 Merge Join한다. 벤치 데이터에 AI 파생 행이 없어 결과가 0건인데도 그렇다. 이 쿼리는 이 티켓 범위 밖이고 Feed 추천·AI 클라이언트 경계에 걸리므로 후속 후보로만 남긴다 (S15P11A705-303) +**남은 197ms의 정체를 찾았다 — `PROFILE_KEYWORDS_SQL`이 163ms다.** `ai.context_keyword`(213,290행)를 Index Only Scan으로 전수 훑고 Merge Join한다. 벤치 데이터에 AI 파생 행이 없어 결과가 0건인데도 그렇다. 이 쿼리는 이 티켓 범위 밖이고 Feed 추천·AI 클라이언트 경계에 걸리므로 후속 후보로만 남긴다 (Jira 작업) diff --git a/docs/backend/worklog/2026-08-04-S15P11A705-301-map-keyword-search.md b/docs/backend/worklog/2026-08-04-map-keyword-search.md similarity index 98% rename from docs/backend/worklog/2026-08-04-S15P11A705-301-map-keyword-search.md rename to docs/backend/worklog/2026-08-04-map-keyword-search.md index 308ff947..905f450b 100644 --- a/docs/backend/worklog/2026-08-04-S15P11A705-301-map-keyword-search.md +++ b/docs/backend/worklog/2026-08-04-map-keyword-search.md @@ -1,7 +1,7 @@ # 지도 마커 API에 keyword 검색과 이름순 정렬을 더한다 - **날짜**: 2026-08-04 -- **추적**: S15P11A705-301 +- **추적**: Jira 작업 - **관련**: [docs#45](https://github.com/Team-PinLog/docs/pull/45) (08 §4.2·§13.11 계약 선행 개정) 컬렉션에 담을 장소 선택 화면이 `GET /v1/records/map`을 목록으로 재사용하는데, 장소가 수백 개로 늘면 거를 수단이 없었다. 별도 목록 API를 파는 대신 기존 API에 선택 파라미터 `keyword`(장소명·주소 부분 일치, 대소문자 무시)를 더했다 — 이 쿼리는 `record.member_id`로 드라이빙하고 place는 PK 조인이라, 검색 조건은 회원 단위로 좁혀진 행에 필터로만 붙는다. 인덱스·마이그레이션이 필요 없는 이유다. diff --git a/docs/backend/worklog/2026-08-04-S15P11A705-308-map-latest-collection-id.md b/docs/backend/worklog/2026-08-04-map-latest-collection-id.md similarity index 98% rename from docs/backend/worklog/2026-08-04-S15P11A705-308-map-latest-collection-id.md rename to docs/backend/worklog/2026-08-04-map-latest-collection-id.md index f2a6a317..5862cbcc 100644 --- a/docs/backend/worklog/2026-08-04-S15P11A705-308-map-latest-collection-id.md +++ b/docs/backend/worklog/2026-08-04-map-latest-collection-id.md @@ -1,7 +1,7 @@ # 지도 마커 응답에 가장 최근에 담긴 컬렉션 id를 더한다 - **날짜**: 2026-08-04 -- **추적**: S15P11A705-308 +- **추적**: Jira 작업 - **관련**: [docs#48](https://github.com/Team-PinLog/docs/pull/48) (08 §4.2 계약 선행 개정) 프론트가 지도 마커 색상을 레코드가 담긴 컬렉션 기준으로 구분하기로 해서, `GET /v1/records/map` 응답 `items`의 각 마커에 `latestCollectionId`를 실었다. 값은 그 Record가 담긴 활성 연결(`collection_record`) 중 **담은 시각 최신**(`created_at DESC`, 동시각이면 `id DESC`) 기준 Collection id — 컬렉션 내부 정렬(데이터모델 2.7)과 같은 기준이라 "컬렉션에서 보이는 최신"과 "마커 색"이 어긋나지 않는다. 어느 컬렉션에도 담기지 않은 Record는 `null`이고, 컬렉션에서 뺀(소프트 삭제) 연결은 판단에서 제외된다. diff --git a/docs/backend/worklog/2026-08-04-S15P11A705-305-place-thumbnail-mock.md b/docs/backend/worklog/2026-08-04-place-thumbnail-mock.md similarity index 98% rename from docs/backend/worklog/2026-08-04-S15P11A705-305-place-thumbnail-mock.md rename to docs/backend/worklog/2026-08-04-place-thumbnail-mock.md index 1a5c7a43..5ce88dee 100644 --- a/docs/backend/worklog/2026-08-04-S15P11A705-305-place-thumbnail-mock.md +++ b/docs/backend/worklog/2026-08-04-place-thumbnail-mock.md @@ -1,7 +1,7 @@ # record 상세 응답에 place 썸네일을 추가한다 (시연용 목업) - **날짜**: 2026-08-04 -- **추적**: S15P11A705-305 +- **추적**: Jira 작업 - **관련**: [back#182](https://github.com/Team-PinLog/back/issues/182) · [docs#47](https://github.com/Team-PinLog/docs/pull/47) 카카오 로컬 API는 장소 사진을 주지 않아 place에는 이미지가 없었다. 시연에서 상세 화면이 diff --git a/docs/backend/worklog/2026-08-04-S15P11A705-285-unlink-before-soft-delete.md b/docs/backend/worklog/2026-08-04-unlink-before-soft-delete.md similarity index 90% rename from docs/backend/worklog/2026-08-04-S15P11A705-285-unlink-before-soft-delete.md rename to docs/backend/worklog/2026-08-04-unlink-before-soft-delete.md index 5d44e41e..593afdde 100644 --- a/docs/backend/worklog/2026-08-04-S15P11A705-285-unlink-before-soft-delete.md +++ b/docs/backend/worklog/2026-08-04-unlink-before-soft-delete.md @@ -1,7 +1,7 @@ # 탈퇴를 인가 왕복 뒤로 옮겨 공급자 연결을 먼저 끊게 했다 - **날짜**: 2026-08-04 -- **추적**: S15P11A705-285 +- **추적**: Jira 작업 - **관련**: [BD-48](../decisions/BD-48-unlink-before-withdrawal.md) · [BI-38](../implements/BI-38-2026-08-04-unlink-before-withdrawal.md) · [back#176](https://github.com/Team-PinLog/back/issues/176) · docs `c5b09d9`(08 §3.6 개정) `DELETE /v1/me`가 즉시 지우던 것을 왕복 시작으로 바꿨다. 공급자 인가를 다시 받아 그 access token으로 연결을 끊고, **끊긴 뒤에만** 소프트 삭제한다. @@ -10,7 +10,7 @@ 06 §6.9가 소프트 삭제와 함께 `provider_user_id` 마스킹을 요구한다. 지우고 나면 공급자에서 그 사용자를 지목할 수단이 사라지므로, 해제 실패를 **나중에 재시도할 수도 없다**. 큐에 넣어 두는 설계가 성립하지 않는 이유가 이것이다 — 큐에 넣을 식별자가 이미 파기됐다. -그래서 S15P11A705-214의 "공급자 장애가 탈퇴를 막아서는 안 된다"를 명시적으로 뒤집었다. 장애로 인한 지연은 일시적이고 재시도로 풀리지만, 반대 순서의 대가(영구 미이행 + 동의 없는 재가입)는 회복 경로가 없다. +그래서 Jira 작업의 "공급자 장애가 탈퇴를 막아서는 안 된다"를 명시적으로 뒤집었다. 장애로 인한 지연은 일시적이고 재시도로 풀리지만, 반대 순서의 대가(영구 미이행 + 동의 없는 재가입)는 회복 경로가 없다. ## 이번에 바뀐 판단 @@ -39,6 +39,6 @@ ## 프론트가 아직 이 계약을 모른다 -프론트의 탈퇴는 `[S15P11A705-162]`(front#37) 시점 그대로다. `deleteAccount()`가 응답 본문을 버리고(`Promise`), `authorizationUrl`이 레포 전체에 없고, 콜백은 `error`만 읽는다. +프론트의 탈퇴는 `[Jira 작업]`(front#37) 시점 그대로다. `deleteAccount()`가 응답 본문을 버리고(`Promise`), `authorizationUrl`이 레포 전체에 없고, 콜백은 `error`만 읽는다. **배포 순서가 어긋나면 "탈퇴가 완료되었습니다"가 뜬 채 아무것도 지워지지 않는다.** `logged_in`이 살아 있어 `/login` 가드가 홈으로 되돌리기까지 한다. 백엔드와 프론트가 함께 나가야 한다. diff --git a/docs/backend/worklog/2026-08-05-S15P11A705-322-collection-cover-url.md b/docs/backend/worklog/2026-08-05-collection-cover-url.md similarity index 97% rename from docs/backend/worklog/2026-08-05-S15P11A705-322-collection-cover-url.md rename to docs/backend/worklog/2026-08-05-collection-cover-url.md index 784fae89..355eb6c8 100644 --- a/docs/backend/worklog/2026-08-05-S15P11A705-322-collection-cover-url.md +++ b/docs/backend/worklog/2026-08-05-collection-cover-url.md @@ -1,7 +1,7 @@ # Collection에 표지 이미지 URL을 저장한다 - **날짜**: 2026-08-05 -- **추적**: S15P11A705-322 +- **추적**: Jira 작업 - **관련**: docs#49 (08 §7.4·7.7, 06 §2.6) · front#99 프론트가 이미지 서비스로 생성·선택한 표지 일러스트를 컬렉션과 함께 보여주기 위해 diff --git a/docs/backend/worklog/2026-08-05-feed-channel-plan-test-flake.md b/docs/backend/worklog/2026-08-05-feed-channel-plan-test-flake.md index 27ecdd60..ecca559d 100644 --- a/docs/backend/worklog/2026-08-05-feed-channel-plan-test-flake.md +++ b/docs/backend/worklog/2026-08-05-feed-channel-plan-test-flake.md @@ -1,6 +1,6 @@ # 계획 단정 테스트의 플레이크를 닫고 잘못된 단정을 바로잡았다 - **날짜**: 2026-08-05 -- **관련**: [BT-07](../troubleshooting/BT-07-planner-knobs-insufficient-for-plan-assertion.md) · S15P11A705-303 · [BI-38](../implements/BI-38-2026-08-03-massive-scale-plan-observation.md) +- **관련**: [BT-07](../troubleshooting/BT-07-planner-knobs-insufficient-for-plan-assertion.md) · Jira 작업 · [BI-38](../implements/BI-38-2026-08-03-massive-scale-plan-observation.md) `FeedChannelPlanTests`가 공유 테스트 DB 적재량에 따라 흔들린다는 보고를 받고 재현했다. 회원과 Collection을 1:1로 늘려가며 계획을 보니 **20~100행 구간에서만 실패**했다 — 그 구간에서 플래너가 `ix_collection_member`로 Merge Join을 골라 `ix_collection_feed`를 타지 않고, 그러면 정렬 순서를 얻지 못해 전체 Sort가 붙어 `Presorted Key`가 사라진다. 정렬키는 올바른데 테스트만 깨지는 거짓 실패였다. **스캔만 막고 조인 방식을 막지 않은 것이 원인**이므로 `enable_mergejoin`·`enable_hashjoin`을 함께 끄고, 실패했던 적재량을 픽스처로 고정하는 테스트를 더했다(0~1만 행 전 구간 통과, 표현식 정렬키는 여전히 걸러짐). 조사 중 **더 큰 문제를 찾았다 — 같은 테스트가 팔로우 채널에도 `Presorted Key`를 요구하고 있었고 그것은 채널의 성질을 잘못 본 단정이다.** 팔로우 채널은 `follow`에서 출발해 `followee_member_id`로 좁히므로 올바른 인덱스가 `ix_collection_member`이고 `ix_collection_feed`를 쓸 이유가 없다(천만 건 실측 0.99ms). 데이터 형태에 따라 우연히 통과하고 있었을 뿐이라, 정렬 순서 대신 "전체 스캔으로 새지 않는지"를 단정하도록 바꿨다. 같은 이유로 303이 "팔로우 채널도 고쳤다"로 읽히게 쓴 서술도 정정했다 — 그쪽 `COALESCE` 제거는 일관성 정리이며 성능 개선이 아니다. 교훈은 BT-07에 남겼다: 계획을 단정하려면 대안 경로를 전부 막아야 하고, 플레이크는 실패 조건을 픽스처로 고정해야 닫히며, 우연히 통과하는 단정은 없는 것보다 나쁘다 diff --git a/docs/backend/worklog/2026-08-05-S15P11A705-309-google-revoke-refresh-token.md b/docs/backend/worklog/2026-08-05-google-revoke-refresh-token.md similarity index 99% rename from docs/backend/worklog/2026-08-05-S15P11A705-309-google-revoke-refresh-token.md rename to docs/backend/worklog/2026-08-05-google-revoke-refresh-token.md index 0a0c100c..4511ce0b 100644 --- a/docs/backend/worklog/2026-08-05-S15P11A705-309-google-revoke-refresh-token.md +++ b/docs/backend/worklog/2026-08-05-google-revoke-refresh-token.md @@ -1,7 +1,7 @@ # Google 탈퇴가 승인을 남기던 것을 refresh token 폐기로 고쳤다 - **날짜**: 2026-08-05 -- **추적**: S15P11A705-309 +- **추적**: Jira 작업 - **관련**: [BD-48](../decisions/BD-48-unlink-before-withdrawal.md)(§① 정정) · [BI-41](../implements/BI-41-2026-08-05-google-revoke-refresh-token.md) · [back#190](https://github.com/Team-PinLog/back/issues/190) · [front#97](https://github.com/Team-PinLog/front/pull/97) Google로 가입한 회원이 탈퇴해도 Google 계정의 「서드파티 앱 및 서비스」에 앱이 남았다. Kakao·Naver는 정상이었다. diff --git a/docs/backend/worklog/2026-08-05-S15P11A705-320-place-thumbnail-webp.md b/docs/backend/worklog/2026-08-05-place-thumbnail-webp.md similarity index 89% rename from docs/backend/worklog/2026-08-05-S15P11A705-320-place-thumbnail-webp.md rename to docs/backend/worklog/2026-08-05-place-thumbnail-webp.md index 0514e0e2..d7a50bbf 100644 --- a/docs/backend/worklog/2026-08-05-S15P11A705-320-place-thumbnail-webp.md +++ b/docs/backend/worklog/2026-08-05-place-thumbnail-webp.md @@ -1,12 +1,12 @@ # place 썸네일 더미를 WebP로 교체하고 실제 이미지 경로를 확정한다 - **날짜**: 2026-08-05 -- **추적**: S15P11A705-320 -- **관련**: [2026-08-04 목업 도입](2026-08-04-S15P11A705-305-place-thumbnail-mock.md) · [front#94](https://github.com/Team-PinLog/front/issues/94) · S15P11A705-321 +- **추적**: Jira 작업 +- **관련**: [2026-08-04 목업 도입](2026-08-04-place-thumbnail-mock.md) · [front#94](https://github.com/Team-PinLog/front/issues/94) · Jira 작업 ## WebP로 되돌렸다 -썸네일은 처음부터 WebP로 계획했으나 로컬에 인코더가 없어 JPEG로 생성했다(S15P11A705-305). +썸네일은 처음부터 WebP로 계획했으나 로컬에 인코더가 없어 JPEG로 생성했다(Jira 작업). 이번에 Pillow로 인코딩이 되는 것을 확인해 계획대로 되돌렸다. 더미 4장을 1200×900(4:3) 그대로 변환했고 26~32KB에서 7~11KB로 줄었다 — 다만 더미가 단색에 가까워 나온 값이라 실제 사진은 이만큼 줄지 않는다. 장당 100KB 상한은 그대로 둔다. @@ -45,7 +45,7 @@ JPEG로 끝나 어긋났다. 유승주가 착수 전에 이 불일치를 지적 ## 남은 일 -실제 표지 이미지 수급과 배포 DB 연결은 S15P11A705-321이다. 이미지 출처는 아직 정하지 않았고, +실제 표지 이미지 수급과 배포 DB 연결은 Jira 작업이다. 이미지 출처는 아직 정하지 않았고, 배포 DB 쓰기 권한은 infra#189로 요청 중이다. 그때까지 배포 환경의 `thumbnail_url`은 전부 `null`이라 프론트는 폴백 경로만 검증할 수 있다. diff --git a/docs/backend/worklog/2026-08-07-bd-46-duplicate-number.md b/docs/backend/worklog/2026-08-07-bd-46-duplicate-number.md index c701e2fd..43d4e91f 100644 --- a/docs/backend/worklog/2026-08-07-bd-46-duplicate-number.md +++ b/docs/backend/worklog/2026-08-07-bd-46-duplicate-number.md @@ -27,7 +27,7 @@ - `docs/development/configuration.md` 2곳(살아 있는 문서) - `src/main/resources/application-prod.yml` 1곳 - `src/test/java/.../ConfigurationContractTests.java` 3곳 -- `docs/backend/worklog/2026-08-03-S15P11A705-282-drop-dead-datasource-literal.md` 2곳 +- `docs/backend/worklog/2026-08-03-drop-dead-datasource-literal.md` 2곳 **마지막 항목은 판단이 필요했다.** 작업 로그는 보존 구역이고 `worklog/README.md`는 갱신하지 말고 새 항목을 더하라고 한다. 그런데 파일을 옮기면 그 항목의 링크가 죽는다. 기록의 서술은 한 글자도 건드리지 diff --git a/docs/backend/worklog/2026-08-07-S15P11A705-400-confidence-gate.md b/docs/backend/worklog/2026-08-07-confidence-gate.md similarity index 88% rename from docs/backend/worklog/2026-08-07-S15P11A705-400-confidence-gate.md rename to docs/backend/worklog/2026-08-07-confidence-gate.md index 69d59ef2..e6a2e586 100644 --- a/docs/backend/worklog/2026-08-07-S15P11A705-400-confidence-gate.md +++ b/docs/backend/worklog/2026-08-07-confidence-gate.md @@ -1,11 +1,11 @@ # 결합 신뢰도 게이트를 문자열 병합 직후에 걸었다 - **날짜**: 2026-08-07 -- **추적**: S15P11A705-400 +- **추적**: Jira 작업 - **관련**: [BD-52](../decisions/BD-52-confidence-gate-filters-before-core-revalidation.md) · [BI-44](../implements/BI-44-2026-08-07-confidence-gate.md) · `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4 인계 문서가 "응답 조립 이후"에 게이트를 두라고 적었지만, 실제 코드를 보니 S1·S2·S3 세 신호가 전부 갖춰지는 가장 이른 지점은 `mergeLexicalMatches` 직후였다. 조립까지 신호를 끌고 가려면 `RecordSearchItemResponse`를 늘리거나 별도 Map을 나란히 들고 다녀야 했는데, 그러면 클라이언트에 노출하지 않기로 한 신호(키워드 매치 여부)가 DTO 경계를 넘나드는 임시 구조가 생긴다. 사용자에게 보이는 최종 응답은 어느 지점에서 걸러도 같으므로, 신호가 자연스럽게 모이는 지점으로 옮겼다 — 게이트가 뺄 Record를 Core 재검증 대상에서 아예 빼는 부수 이득도 있었다. -임계값 0.35는 back이 정하지 않았다. 인계 문서 스스로 "다른 용도(키워드 재정렬 floor)에서 가져온 값이라 이 용도로는 검증된 적이 없다"고 밝혔고, ai 파트가 별도로 오프라인 재측정해(S15P11A705-401) 같은 값을 다시 채택했다. back은 그 결과를 설정 기본값으로 가져오기만 했다. +임계값 0.35는 back이 정하지 않았다. 인계 문서 스스로 "다른 용도(키워드 재정렬 floor)에서 가져온 값이라 이 용도로는 검증된 적이 없다"고 밝혔고, ai 파트가 별도로 오프라인 재측정해(Jira 작업) 같은 값을 다시 채택했다. back은 그 결과를 설정 기본값으로 가져오기만 했다. 작업 중 `.claude/handoff/SEARCH-UPGRADE-HANDOFF.md`(인계 문서가 근거로 가리킨 파일)를 찾아봤는데 `ai`·`back`·`docs` 어디에도 없었다. 대신 ai 레포 `search-upgrade` 브랜치의 P49 제안서와 실제 코드(`RecordSearchService.rrfMerge`, `keywordMatched` 필드)를 직접 확인해 근거로 삼았다 — 이 사실을 BD-52와 BI-44에도 남겼다. diff --git a/docs/backend/worklog/2026-08-07-S15P11A705-388-map-keyword-chips.md b/docs/backend/worklog/2026-08-07-map-keyword-chips.md similarity index 94% rename from docs/backend/worklog/2026-08-07-S15P11A705-388-map-keyword-chips.md rename to docs/backend/worklog/2026-08-07-map-keyword-chips.md index c495e94a..618c1d1d 100644 --- a/docs/backend/worklog/2026-08-07-S15P11A705-388-map-keyword-chips.md +++ b/docs/backend/worklog/2026-08-07-map-keyword-chips.md @@ -1,7 +1,7 @@ # 지도 bbox 안 상위 키워드 조회 API를 더했다 - **날짜**: 2026-08-07 -- **추적**: S15P11A705-388 +- **추적**: Jira 작업 - **관련**: [BI-42](../implements/BI-42-2026-08-07-map-keyword-chips.md) · [BD-13](../decisions/BD-13-public-boundary-query-dto-split.md) 지도 화면 검색창 밑 키워드 칩을 위해 `GET /v1/records/map/keywords`를 더했다. bbox 안 내 Record @@ -16,7 +16,7 @@ ## 칩이 반영하지 않는 것 칩은 bbox만 반영한다. 장소명 검색어(`keyword`)도, 적용 중인 키워드 필터(`keywordId`, 다음 -티켓 S15P11A705-390)도 반영하지 않는다. 적용 중인 필터를 반영하면 그 키워드를 뺀 나머지 칩이 전부 +티켓 Jira 작업)도 반영하지 않는다. 적용 중인 필터를 반영하면 그 키워드를 뺀 나머지 칩이 전부 0이 되어 사라지고, 사용자가 다른 칩으로 갈아탈 방법이 없어진다. ## bbox 생략 경로를 남긴 이유와 그 한계 diff --git a/docs/backend/worklog/2026-08-07-S15P11A705-397-me-activity-api.md b/docs/backend/worklog/2026-08-07-me-activity-api.md similarity index 86% rename from docs/backend/worklog/2026-08-07-S15P11A705-397-me-activity-api.md rename to docs/backend/worklog/2026-08-07-me-activity-api.md index 9c5b01f8..75b272f5 100644 --- a/docs/backend/worklog/2026-08-07-S15P11A705-397-me-activity-api.md +++ b/docs/backend/worklog/2026-08-07-me-activity-api.md @@ -1,8 +1,8 @@ # 나의 활동 기록 집계 API를 더했다 — 날짜 경계를 KST로 고정한다 - **날짜**: 2026-08-07 -- **추적**: S15P11A705-397 -- **관련**: `GET /v1/me/activity` · [S15P11A705-388](https://ssafy.atlassian.net/browse/S15P11A705-388)(한글 정렬 함정의 출처) +- **추적**: Jira 작업 +- **관련**: `GET /v1/me/activity` · Jira 작업(한글 정렬 함정의 출처) 발표 시연에서 화면을 채울 "나의 활동 기록" 페이지를 붙이려는데, 내 기록을 집계해 내려주는 경로가 없었다. 지금까지는 단건·목록 조회뿐이라 프론트가 기록을 전부 받아 직접 세야 했다. `GET /v1/me/activity` 하나로 다섯 덩어리(`totals`·`months`·`areas`·`counts`·`highlights`)를 내려준다. @@ -32,7 +32,7 @@ ## 동점 정렬에 `COLLATE "C"`를 명시한다 -`areas`의 건수 동점은 지역명으로 끊는다. 그런데 한글 이름을 DB 기본 collation으로 정렬하면 **운영 DB와 테스트 컨테이너의 locale이 달라 순서가 갈린다** — S15P11A705-388이 같은 함정을 만나 그때는 이름 정렬 자체를 피했다(`keywordId`로 끊었다). 여기서는 파생 문자열이라 이름 말고 끊을 키가 없으므로, 피하는 대신 `ORDER BY count(*) DESC, district COLLATE "C" ASC`로 collation을 명시해 고정했다. +`areas`의 건수 동점은 지역명으로 끊는다. 그런데 한글 이름을 DB 기본 collation으로 정렬하면 **운영 DB와 테스트 컨테이너의 locale이 달라 순서가 갈린다** — Jira 작업이 같은 함정을 만나 그때는 이름 정렬 자체를 피했다(`keywordId`로 끊었다). 여기서는 파생 문자열이라 이름 말고 끊을 키가 없으므로, 피하는 대신 `ORDER BY count(*) DESC, district COLLATE "C" ASC`로 collation을 명시해 고정했다. ## 집계를 한 쿼리로 묶지 않았다 @@ -40,7 +40,7 @@ 대신 대상 집합의 정의는 한 곳에 뒀다 — `MY_RECORDS` CTE(내 활성 Record + 장소 + KST 벽시계 + 시·구)를 네 쿼리가 공유한다. -**엔티티를 거치지 않으므로 `@SQLRestriction`이 걸리지 않는다.** JPA 리포지토리에서는 삭제 조건을 적지 않는 것이 규약인데(S15P11A705-200), 이 SQL에서는 정반대로 `deleted_at IS NULL`을 직접 적어야 한다. 맥락 메모·컬렉션 카운트는 JPA 파생 쿼리를 쓰므로 그쪽은 조건을 적지 않는다. +**엔티티를 거치지 않으므로 `@SQLRestriction`이 걸리지 않는다.** JPA 리포지토리에서는 삭제 조건을 적지 않는 것이 규약인데(Jira 작업), 이 SQL에서는 정반대로 `deleted_at IS NULL`을 직접 적어야 한다. 맥락 메모·컬렉션 카운트는 JPA 파생 쿼리를 쓰므로 그쪽은 조건을 적지 않는다. ## 범위 밖 diff --git a/docs/backend/worklog/2026-08-07-S15P11A705-370-recent-records-api.md b/docs/backend/worklog/2026-08-07-recent-records-api.md similarity index 99% rename from docs/backend/worklog/2026-08-07-S15P11A705-370-recent-records-api.md rename to docs/backend/worklog/2026-08-07-recent-records-api.md index c016b7c0..a03fe385 100644 --- a/docs/backend/worklog/2026-08-07-S15P11A705-370-recent-records-api.md +++ b/docs/backend/worklog/2026-08-07-recent-records-api.md @@ -1,7 +1,7 @@ # 최근 7일 내 작성한 내 Record 목록 API를 더했다 - **날짜**: 2026-08-07 -- **추적**: S15P11A705-370 +- **추적**: Jira 작업 - **관련**: [명세 5.9](https://github.com/Team-PinLog/docs/pull/50) · [BD-13](../decisions/BD-13-public-boundary-query-dto-split.md) · [BD-18](../decisions/BD-18-keyword-preset-and-visibility.md) 홈 화면 "최근 기록" 영역이 쓸 `GET /v1/records/recent`를 더했다. 마이그레이션은 없다. diff --git a/docs/backend/worklog/2026-08-07-S15P11A705-391-record-collections-api.md b/docs/backend/worklog/2026-08-07-record-collections-api.md similarity index 98% rename from docs/backend/worklog/2026-08-07-S15P11A705-391-record-collections-api.md rename to docs/backend/worklog/2026-08-07-record-collections-api.md index 93793448..04f283a2 100644 --- a/docs/backend/worklog/2026-08-07-S15P11A705-391-record-collections-api.md +++ b/docs/backend/worklog/2026-08-07-record-collections-api.md @@ -1,7 +1,7 @@ # Record가 담긴 내 Collection 목록 조회 API를 쿼리 수 실측과 함께 마감했다 - **날짜**: 2026-08-07 -- **추적**: S15P11A705-391 +- **추적**: Jira 작업 - **관련**: [BI-43](../implements/BI-43-2026-08-07-record-collections-api.md) · `f44fea2..00f09ab`(Task 1~4, 정정 — 원래 `d8d0514`(Task 1~3)로 적었으나 실제 커밋은 `f44fea2`·`55b26de`·`d8d0514`·`00f09ab` 네 개다) · [BI-38](../implements/BI-38-2026-08-03-massive-scale-plan-observation.md) diff --git a/docs/backend/worklog/2026-08-07-search-relevance-judge.md b/docs/backend/worklog/2026-08-07-search-relevance-judge.md index 8c45bb70..9aa748c5 100644 --- a/docs/backend/worklog/2026-08-07-search-relevance-judge.md +++ b/docs/backend/worklog/2026-08-07-search-relevance-judge.md @@ -1,7 +1,7 @@ # 검색 4번째 신호(LLM 관련도 재판정)를 추가했다 - **날짜**: 2026-08-07 -- **관련**: [BI-45](../implements/BI-45-2026-08-07-search-relevance-judge.md) · ai 레포 `S15P11A705-relevance-judge` 브랜치 · [BI-43](../implements/BI-43-2026-08-06-search-lexical-merge.md)(앞선 세 신호) +- **관련**: [BI-45](../implements/BI-45-2026-08-07-search-relevance-judge.md) · ai 레포 `Jira-relevance-judge` 브랜치 · [BI-43](../implements/BI-43-2026-08-06-search-lexical-merge.md)(앞선 세 신호) 사용자가 실배포에서 발견한 검색 순위 오류(문장형 질의에 포함된 고유명사가 세 신호 모두의 사각지대에 걸려 관련 기록이 무관한 기록보다 낮은 순위로 나온 사례)를 교정하기 위해 4번째 검색 신호를 추가했다. ai가 후보의 LLM 관련도를 4단계로 재판정하고, back은 그 결과로 무관한 결과를 걸러내고 재정렬한다. diff --git a/docs/backend/worklog/README.md b/docs/backend/worklog/README.md index 621cf687..5dc4caf8 100644 --- a/docs/backend/worklog/README.md +++ b/docs/backend/worklog/README.md @@ -16,7 +16,7 @@ YYYY-MM-DD-<추적키>-<슬러그>.md | 상황 | 예 | |---|---| -| Jira 티켓이 있을 때 | `2026-07-31-S15P11A705-238-single-stage-image.md` | +| Jira 작업이 있을 때 | `2026-07-31-single-stage-image.md` | | GitHub 이슈만 있을 때 | `2026-07-28-back58-published-at-invariant.md` | | 추적 키가 없을 때 | `2026-07-31-configuration-drift.md` | @@ -30,7 +30,7 @@ YYYY-MM-DD-<추적키>-<슬러그>.md # 한 줄 요약 - **날짜**: 2026-08-01 -- **추적**: S15P11A705-238 +- **추적**: Jira 작업 - **관련**: [BD-45](../decisions/BD-45-worklog-per-entry-files.md) · `abc1234` · [back#133](https://github.com/Team-PinLog/back/issues/133) 본문 — 무엇을 왜 했는지, 그때 내린 판단. @@ -42,7 +42,7 @@ YYYY-MM-DD-<추적키>-<슬러그>.md ```bash find docs/backend/worklog -maxdepth 1 -name '20*.md' | sort # 시간순 전체 (README.md 제외) -find docs/backend/worklog -maxdepth 1 -name '20*S15P11A705-238*.md' | sort # 티켓 하나 +find docs/backend/worklog -maxdepth 1 -name '20*Jira 작업*.md' | sort # 티켓 하나 find docs/backend/worklog -maxdepth 1 -name '20*.md' -exec grep -h '^# ' {} + # 요약만 ``` diff --git a/docs/development/api-conventions.md b/docs/development/api-conventions.md index b06bf39a..04364bce 100644 --- a/docs/development/api-conventions.md +++ b/docs/development/api-conventions.md @@ -28,7 +28,7 @@ **구분이 필요한 리소스가 생기면 그 PR에서 도입하고 이 항목을 갱신합니다.** 필드가 여러 개인 PATCH가 등장하는 시점이 그 신호입니다. - 적용 예: `PATCH /v1/follows/{followId}`의 `alias` — `{}`와 `{"alias": null}`이 모두 별칭 제거입니다([08 §8.3](https://github.com/Team-PinLog/docs/blob/main/static/08_API_%EB%AA%85%EC%84%B8.md), S15P11A705-116). + 적용 예: `PATCH /v1/follows/{followId}`의 `alias` — `{}`와 `{"alias": null}`이 모두 별칭 제거입니다([08 §8.3](https://github.com/Team-PinLog/docs/blob/main/static/08_API_%EB%AA%85%EC%84%B8.md), Jira 작업). ### 공통 응답 envelope diff --git a/docs/development/authentication.md b/docs/development/authentication.md index 2ec5fa02..060d2d66 100644 --- a/docs/development/authentication.md +++ b/docs/development/authentication.md @@ -25,9 +25,9 @@ ## 배경 — 인증은 실수로 빠진 것이 아니었다 -backend foundation reset은 Spring Security, OAuth, 임시 계정, `SecurityConfig`를 **의도적으로 제거**했습니다. 인증 없이도 서비스가 실행·테스트·배포되도록 기반을 먼저 정리하기 위함이었고, 그동안 core 도메인은 **인증 스텁** 위에서 개발됐습니다(back#28 합의, S15P11A705-67). +backend foundation reset은 Spring Security, OAuth, 임시 계정, `SecurityConfig`를 **의도적으로 제거**했습니다. 인증 없이도 서비스가 실행·테스트·배포되도록 기반을 먼저 정리하기 위함이었고, 그동안 core 도메인은 **인증 스텁** 위에서 개발됐습니다(back#28 합의, Jira 작업). -**인증은 S15P11A705-63에서 한 PR로 병합됐습니다.** 스텁은 그 PR에서 제거됐습니다 — 아래는 스텁이 고정해 둔 계약 중 **그대로 이어받은 것**입니다. +**인증은 Jira 작업에서 한 PR로 병합됐습니다.** 스텁은 그 PR에서 제거됐습니다 — 아래는 스텁이 고정해 둔 계약 중 **그대로 이어받은 것**입니다. - principal 타입은 `MemberPrincipal(Long memberId)` record이고, 컨트롤러는 `@LoginMember MemberPrincipal`로 받습니다. 이 시그니처는 바뀌지 않았습니다 — 도메인 컨트롤러가 수정 대상이 되지 않도록 스텁이 미리 고정해 둔 값이고, 그 판단이 실제로 값을 했습니다. - 서비스는 `Long memberId` 파라미터를 받습니다. `SecurityContext`를 서비스에서 직접 읽지 않습니다. @@ -141,12 +141,12 @@ DB가 필요한 인증 테스트는 PostgreSQL Testcontainers를 사용합니다 - [x] ~~envelope opt-out 장치~~ — 불필요함이 확인됐습니다(위 "공통 응답 envelope의 예외"). Security entry point의 오류 envelope는 `SecurityErrorWriter`가 만듭니다 - [x] 로컬 개발·테스트에서 인증 통과 방법 문서화 (아래 §7) - [x] 성공 / 401 / 403(CSRF) / 공개 경로 / 쿠키 속성 / 본문 토큰 부재 테스트 -- [x] **404(타인 자원 접근)** — 도메인 API(S15P11A705-67~71)가 `dev`에 병합되면서 검증 대상이 생겼습니다. `PublicCollectionApiTests`의 `withdrawnOwnersCollectionIsHiddenFromOthers`·`unpublishedCollectionIsHiddenFromOthers`가 실제 인증 위에서 404를 고정합니다([BD-13](../backend/decisions/BD-13-public-boundary-query-dto-split.md)) +- [x] **404(타인 자원 접근)** — 도메인 API(Jira 작업~71)가 `dev`에 병합되면서 검증 대상이 생겼습니다. `PublicCollectionApiTests`의 `withdrawnOwnersCollectionIsHiddenFromOthers`·`unpublishedCollectionIsHiddenFromOthers`가 실제 인증 위에서 404를 고정합니다([BD-13](../backend/decisions/BD-13-public-boundary-query-dto-split.md)) - [x] `./gradlew clean check --no-daemon` 통과 - [x] 이 문서와 API 규약 갱신 - [ ] **공용 계약(`Team-PinLog/docs`) `static/08_API_명세.md` 개정** — 별도 저장소라 이 PR 밖입니다 -## 7. 구현된 형태 (S15P11A705-63) +## 7. 구현된 형태 (Jira 작업) 여기부터는 계약이 아니라 **실제로 이렇게 만들어졌다**는 기록입니다. 계약과 어긋나면 위쪽이 이깁니다. @@ -211,7 +211,7 @@ public CursorPage listMine(@LoginMember MemberPrincip } ``` -> **도메인 브랜치와의 병합 주의.** S15P11A705-67이 같은 이름의 스텁(`X-Debug-Member-Id` 헤더를 읽는 리졸버)을 먼저 만들어 뒀습니다. 병합할 때 **스텁 쪽을 버리고 이 구현을 남겨야 합니다.** 스텁의 헤더 분기와 `pinlog.auth.stub.enabled` 프로퍼티가 남으면 운영 인증 우회 구멍이 됩니다. +> **도메인 브랜치와의 병합 주의.** Jira 작업이 같은 이름의 스텁(`X-Debug-Member-Id` 헤더를 읽는 리졸버)을 먼저 만들어 뒀습니다. 병합할 때 **스텁 쪽을 버리고 이 구현을 남겨야 합니다.** 스텁의 헤더 분기와 `pinlog.auth.stub.enabled` 프로퍼티가 남으면 운영 인증 우회 구멍이 됩니다. ## 8. 로컬 개발과 테스트에서 인증 통과하기 diff --git a/docs/development/configuration.md b/docs/development/configuration.md index f9e3889d..b55a2d13 100644 --- a/docs/development/configuration.md +++ b/docs/development/configuration.md @@ -82,16 +82,16 @@ env: ### 애플리케이션이 추가로 요구하는 비밀값 -`DB_PASSWORD` 외에 아래가 필요합니다. 위 §5의 datasource 계약과 별개로, 이들은 **`back-owner-secrets`에 봉인돼 `envFrom`으로 주입됩니다**(S15P11A705-154). 허용 키 집합은 `infra/policy/sealedsecrets/back-prod.yaml`이 규정하므로, 새 키가 필요하면 저장소에 넣지 말고 인프라 담당자에게 요청해 그 집합에 더합니다. +`DB_PASSWORD` 외에 아래가 필요합니다. 위 §5의 datasource 계약과 별개로, 이들은 **`back-owner-secrets`에 봉인돼 `envFrom`으로 주입됩니다**(Jira 작업). 허용 키 집합은 `infra/policy/sealedsecrets/back-prod.yaml`이 규정하므로, 새 키가 필요하면 저장소에 넣지 말고 인프라 담당자에게 요청해 그 집합에 더합니다. | 변수 | 필수 여부 | 없으면 | | --- | --- | --- | | `JWT_PRIVATE_KEY` | **운영 필수** | 운영 프로파일은 **기동 실패**. 로컬·테스트는 임시 키쌍 생성 | | `PINLOG_AI_INTERNAL_SECRET` | **운영 필수** | 운영 프로파일은 **기동 실패**. 그 외는 경고 후 기동하고 AI 호출이 전부 401로 거절됨 | -| `GOOGLE_CLIENT_ID` | 로그인에 필요 | `unset`으로 기동은 되고 인가 요청 URL 생성까지만 동작 (S15P11A705-63) | -| `GOOGLE_CLIENT_SECRET` | 로그인에 필요 | 위와 같음 (S15P11A705-63) | -| `KAKAO_CLIENT_ID` · `KAKAO_CLIENT_SECRET` | Kakao 로그인에 필요 | 위와 같음 (S15P11A705-64) | -| `NAVER_CLIENT_ID` · `NAVER_CLIENT_SECRET` | Naver 로그인에 필요 | 위와 같음 (S15P11A705-64) | +| `GOOGLE_CLIENT_ID` | 로그인에 필요 | `unset`으로 기동은 되고 인가 요청 URL 생성까지만 동작 (Jira 작업) | +| `GOOGLE_CLIENT_SECRET` | 로그인에 필요 | 위와 같음 (Jira 작업) | +| `KAKAO_CLIENT_ID` · `KAKAO_CLIENT_SECRET` | Kakao 로그인에 필요 | 위와 같음 (Jira 작업) | +| `NAVER_CLIENT_ID` · `NAVER_CLIENT_SECRET` | Naver 로그인에 필요 | 위와 같음 (Jira 작업) | **쓰지 않는 키는 빈 값으로 두지 말고 정의 자체를 하지 않습니다.** `application.yml`이 `spring.config.import`로 `.env`를 프로퍼티로 올리므로, `KAKAO_CLIENT_ID=`처럼 정의만 하면 프로퍼티가 "없음"이 아니라 **빈 문자열**이 되어 `${KAKAO_CLIENT_ID:unset}`의 기본값이 적용되지 않습니다. 그러면 `Client id of registration 'kakao' must not be empty`로 **기동이 실패합니다.** 로컬 `.env`도, 운영 Secret도 같습니다 — 자격증명을 아직 받지 못한 공급자는 주입하지 않는 것이 정상 상태입니다([BT-05](../backend/troubleshooting/BT-05-dotenv-empty-value-overrides-default.md)). diff --git a/docs/development/database-conventions.md b/docs/development/database-conventions.md index 204e9ef0..455637c1 100644 --- a/docs/development/database-conventions.md +++ b/docs/development/database-conventions.md @@ -4,7 +4,7 @@ ## 지원 데이터베이스 -PostgreSQL만 지원합니다. 로컬 실행, 통합 테스트와 CI는 `pgvector/pgvector:0.8.5-pg16` PostgreSQL을 기준으로 합니다. 운영 이미지(S15P11A705-46)와 같은 버전으로 맞춥니다. H2를 추가하거나 PostgreSQL 전용 migration의 대체 검증으로 사용하지 않습니다. +PostgreSQL만 지원합니다. 로컬 실행, 통합 테스트와 CI는 `pgvector/pgvector:0.8.5-pg16` PostgreSQL을 기준으로 합니다. 운영 이미지(Jira 작업)와 같은 버전으로 맞춥니다. H2를 추가하거나 PostgreSQL 전용 migration의 대체 검증으로 사용하지 않습니다. `compose.yaml`은 digest까지 고정하고, Testcontainers(`PostgresContainerSupport`)는 같은 태그를 씁니다. @@ -66,7 +66,7 @@ RollingUpdate 중에는 **구 버전 Pod와 신 버전 Pod가 같은 DB를 동 2. 배포 — 구 Pod가 모두 교체될 때까지 기다린다 3. 제거 — **다음 릴리스의 새 migration**에서 구 컬럼을 지우고 `NOT NULL`을 건다 -CI의 `FlywayMigrationTests`는 **빈 DB에 전체 migration을 적용하는 것까지만** 검증합니다. 구 Pod 호환성은 자동으로 잡히지 않으므로 이 규약과 리뷰로 지킵니다. 근거: `S15P11A705-51`. +CI의 `FlywayMigrationTests`는 **빈 DB에 전체 migration을 적용하는 것까지만** 검증합니다. 구 Pod 호환성은 자동으로 잡히지 않으므로 이 규약과 리뷰로 지킵니다. 근거: `Jira 작업`. 기존 DB에 migration을 추가하는 경로는 `FlywayOutOfOrderTests`가 별도로 검증합니다 — AI 구간만 적용된 DB를 재현해 백엔드 migration이 적용되는지 확인합니다. 두 테스트의 역할이 다르므로 **둘 다 유지합니다**: 하나는 최초 배포, 하나는 그 이후를 지킵니다. diff --git a/docs/development/error-handling.md b/docs/development/error-handling.md index 468c37dc..0277adf5 100644 --- a/docs/development/error-handling.md +++ b/docs/development/error-handling.md @@ -16,7 +16,7 @@ `success`/`error` envelope는 `global/response/ApiResponse.fail(...)`이 생성하며, `global/exception/GlobalExceptionHandler`의 각 분기가 이를 반환합니다. -> `Team-PinLog/docs`의 `static/08_API_명세.md` §5.6·§5.7이 규정한 `error.impact`는 `S15P11A705-70`(Record 삭제)에서 구현했습니다. `ErrorResponse.Impact(recordDeleted, collectionIds)`이며 지금은 `DELETE_CONFIRMATION_REQUIRED`에만 실립니다. 다른 `code`에서는 `null`이라 `@JsonInclude(NON_NULL)`로 응답에서 빠집니다. +> `Team-PinLog/docs`의 `static/08_API_명세.md` §5.6·§5.7이 규정한 `error.impact`는 `Jira 작업`(Record 삭제)에서 구현했습니다. `ErrorResponse.Impact(recordDeleted, collectionIds)`이며 지금은 `DELETE_CONFIRMATION_REQUIRED`에만 실립니다. 다른 `code`에서는 `null`이라 `@JsonInclude(NON_NULL)`로 응답에서 빠집니다. 이 문서는 이 계약을 **한 곳에서 일관되게** 생성하는 방법을 정의합니다. 컨트롤러마다 제각각 오류 응답을 만들지 않습니다. diff --git a/docs/development/jira-workflow.md b/docs/development/jira-workflow.md index 96f6b628..bec5d96f 100644 --- a/docs/development/jira-workflow.md +++ b/docs/development/jira-workflow.md @@ -8,13 +8,13 @@ - 일반 개발 작업의 **작업 단위는 Jira 티켓**입니다. GitHub Issue 연결은 선택입니다. - 코드를 만지기 전에 티켓을 발급하고 **키를 확보**한 뒤, 브랜치·커밋·PR에 같은 키를 사용합니다. -- 프로젝트 키: **`S15P11A705`**. 티켓 키는 `S15P11A705-<번호>` 형식입니다. +- 프로젝트 키: **`Jira`**. 티켓 키는 `Jira-<번호>` 형식입니다. > **예외**: backend foundation reset은 Jira 없이 [Issue #9](https://github.com/Team-PinLog/back/issues/9)로 단독 추적합니다. 이 예외는 일반 작업에 적용하지 않습니다. ## 티켓 발급 -Jira에서 직접 만들거나, 자연어로 빠르게 만들고 싶으면 [`Team-PinLog/cowork`](https://github.com/Team-PinLog/cowork)("할 일 올리기")를 사용합니다. cowork는 로그인 사용자의 Jira 계정으로 `S15P11A705` 프로젝트에 **Task**를 생성합니다. +Jira에서 직접 만들거나, 자연어로 빠르게 만들고 싶으면 [`Team-PinLog/cowork`](https://github.com/Team-PinLog/cowork)("할 일 올리기")를 사용합니다. cowork는 로그인 사용자의 Jira 계정으로 `Jira` 프로젝트에 **Task**를 생성합니다. 현재 팀 운영 기준(확정): @@ -35,9 +35,9 @@ Jira에서 직접 만들거나, 자연어로 빠르게 만들고 싶으면 [`Tea | 위치 | 형식 | 예시 | | --- | --- | --- | -| 브랜치 | `{type}/{jira-key}-{summary}` | `feat/S15P11A705-14-member-search` | -| 커밋 | `{type}({jira-key}): {summary}` | `feat(S15P11A705-14): add member search` | -| PR 본문 | "Jira (필수)" 항목에 키/URL | `S15P11A705-14` | +| 브랜치 | `{type}/{jira-key}-{summary}` | `feat/{jira-key}-member-search` | +| 커밋 | `{type}({jira-key}): {summary}` | `feat(Jira 작업): add member search` | +| PR 본문 | "Jira (필수)" 항목에 키/URL | `Jira 작업` | `type`은 `feat` `fix` `docs` `refactor` `chore` `test` `perf` 중 하나입니다([CONTRIBUTING.md](../../CONTRIBUTING.md)). PR은 [PR 템플릿](../../.github/pull_request_template.md)의 "Jira (필수)" 항목을 채워야 하며, 관련 GitHub Issue는 있을 때만 선택적으로 연결합니다. @@ -63,9 +63,9 @@ To Do → In Progress (PR 생성) **전제 — GitHub for Jira 연동.** Jira가 PR 이벤트를 받으려면 조직에 GitHub for Jira 연동이 연결돼 있어야 합니다. 이슈 화면 우측 **개발(Development) 패널**이 보이면 연결된 것입니다. 없으면 Jira → **Apps → GitHub for Jira**에서 `Team-PinLog` 저장소를 연결합니다. -**매칭 근거.** 자동화는 브랜치·커밋·PR에 담긴 티켓 키(`S15P11A705-<번호>`)로 이슈를 찾습니다. [키가 흐르는 곳](#키가-흐르는-곳) 규약을 지키면 자동으로 매칭됩니다. +**매칭 근거.** 자동화는 브랜치·커밋·PR에 담긴 티켓 키(`Jira-<번호>`)로 이슈를 찾습니다. [키가 흐르는 곳](#키가-흐르는-곳) 규약을 지키면 자동으로 매칭됩니다. -**규칙 2개** (Jira → 프로젝트 `S15P11A705` → **프로젝트 설정 → Automation → 규칙 만들기**): +**규칙 2개** (Jira → 프로젝트 `Jira` → **프로젝트 설정 → Automation → 규칙 만들기**): | # | 트리거(When) | 동작(Then) | | --- | --- | --- | diff --git a/docs/development/package-structure.md b/docs/development/package-structure.md index fb797b98..0e8d99aa 100644 --- a/docs/development/package-structure.md +++ b/docs/development/package-structure.md @@ -46,9 +46,9 @@ com.pinlog.pinlogback | `collection` | 컬렉션·큐레이션 | | | `follow` | 팔로우 관계와 공개 책장 탐색 | 공개 책장 탐색(`ShelfController`)은 경로가 `/v1/feed/collections/{collectionId}/shelf`지만 이 도메인에 둡니다 — 추천 계산을 하나도 거치지 않고, 응답에 요청자 기준 팔로우 상태가 실리며, 재사용하는 조회·판정이 `FollowService`가 쓰는 것과 같습니다. **경로 접두어와 도메인이 어긋난 유일한 사례이며 의도된 것입니다**([BD-43](../backend/decisions/BD-43-shelf-in-follow-domain-under-collections-path.md)) | | `feed` | 피드 조회·서빙 API | 관측 로그 `core.feed_event` 테이블은 **AI 소유(V102)** — 재정의 금지, 조회만 | -| `auth` | 인증·인가 | S15P11A705-63의 [인증 PR](authentication.md)에서 생성됐습니다. 하위 계층은 `controller`(로그인 진입·재발급·로그아웃) · `service`(세션 토큰 발급·회전·폐기) · `dto` · `exception` · `client`(공급자 연결 해제 호출)이며, `repository`·`entity`는 없습니다 — 회원·소셜 계정은 `member`가 소유합니다. `client`를 `global/security/oauth`에 두지 않은 이유는 아래 "인증·보안 경계"에 있습니다 | +| `auth` | 인증·인가 | Jira 작업의 [인증 PR](authentication.md)에서 생성됐습니다. 하위 계층은 `controller`(로그인 진입·재발급·로그아웃) · `service`(세션 토큰 발급·회전·폐기) · `dto` · `exception` · `client`(공급자 연결 해제 호출)이며, `repository`·`entity`는 없습니다 — 회원·소셜 계정은 `member`가 소유합니다. `client`를 `global/security/oauth`에 두지 않은 이유는 아래 "인증·보안 경계"에 있습니다 | | `search` | 개인 자연어 검색 조회 API | Record를 돌려주지만 `record`에 두지 않습니다 — 진입 경로(`/v1/search/records`)와 조립 규칙(FastAPI 응답의 Core 재검증)이 Record CRUD와 다릅니다. FastAPI 호출 자체는 `ai`가 맡고 이 도메인은 그 결과를 검증·조립만 합니다 | -| `ai` | FastAPI AI Server 연동과 `ai` 스키마 접근 | 애그리거트가 아니라 **파트 경계**입니다. 하위 계층은 `repository`(백엔드가 `ai`에 쓰는 SQL과 응답 조립용 읽기) · `client`(내부 API 호출) · `event`(커밋 이후 훅) · `scheduler`(시간이 촉발하는 훅) · `service`(요청 조립·트랜잭션 경계) · `exception`(호출 실패를 도메인 오류로 옮김)이며, `controller`·`entity`는 없습니다 — 외부 진입점이 아니고 남의 스키마를 엔티티로 고정하지 않습니다. `event`와 `scheduler`를 가른 기준은 **무엇이 호출을 촉발하는가**이고, 그에 따라 트랜잭션 경계도 다릅니다 — `event`는 남의 트랜잭션이 커밋된 뒤에 얹히고, `scheduler`는 자기 트랜잭션을 열고 닫습니다(S15P11A705-159) | +| `ai` | FastAPI AI Server 연동과 `ai` 스키마 접근 | 애그리거트가 아니라 **파트 경계**입니다. 하위 계층은 `repository`(백엔드가 `ai`에 쓰는 SQL과 응답 조립용 읽기) · `client`(내부 API 호출) · `event`(커밋 이후 훅) · `scheduler`(시간이 촉발하는 훅) · `service`(요청 조립·트랜잭션 경계) · `exception`(호출 실패를 도메인 오류로 옮김)이며, `controller`·`entity`는 없습니다 — 외부 진입점이 아니고 남의 스키마를 엔티티로 고정하지 않습니다. `event`와 `scheduler`를 가른 기준은 **무엇이 호출을 촉발하는가**이고, 그에 따라 트랜잭션 경계도 다릅니다 — `event`는 남의 트랜잭션이 커밋된 뒤에 얹히고, `scheduler`는 자기 트랜잭션을 열고 닫습니다(Jira 작업) | > 검토 필요: `member` 행의 "공개 프로필·소개를 포함합니다"는 `docs/static/06_데이터모델_및_무결성.md` 2.1(익명 서비스이므로 저장하는 개인정보가 없다)과 실제 구현체(`domain/member/entity/Member` — 개인정보·프로필 컬럼 없음)에 모두 반합니다. CLAUDE.md 9번 규칙에 따라 임의로 고치지 않고 충돌로 기록만 남깁니다. > @@ -67,7 +67,7 @@ com.pinlog.pinlogback ## 인증·보안 경계 -인증은 S15P11A705-63에서 [인증 PR 계약](authentication.md)에 따라 한 PR로 들어왔습니다. `global/security`는 **책임별 하위 패키지**로 나뉘어 있습니다. +인증은 Jira 작업에서 [인증 PR 계약](authentication.md)에 따라 한 PR로 들어왔습니다. `global/security`는 **책임별 하위 패키지**로 나뉘어 있습니다. | 패키지 | 책임 | 언제 도는가 | | --- | --- | --- | diff --git a/docs/development/workflow.md b/docs/development/workflow.md index 619a8e01..26e50db9 100644 --- a/docs/development/workflow.md +++ b/docs/development/workflow.md @@ -21,7 +21,7 @@ Jira 티켓 발급 ## 0. 시작 전 — Jira 티켓을 먼저 만든다 -일반 개발 작업은 **Jira가 작업 단위**입니다. 코드를 만지기 전에 티켓을 발급하고 키(`S15P11A705-123` 형식)를 확보합니다. 자연어로 티켓만 빠르게 만들고 싶으면 [`Team-PinLog/cowork`](https://github.com/Team-PinLog/cowork)("할 일 올리기")를 사용합니다. +일반 개발 작업은 **Jira가 작업 단위**입니다. 코드를 만지기 전에 티켓을 발급하고 키(`Jira 작업` 형식)를 확보합니다. 자연어로 티켓만 빠르게 만들고 싶으면 [`Team-PinLog/cowork`](https://github.com/Team-PinLog/cowork)("할 일 올리기")를 사용합니다. > **예외**: 이번 backend foundation reset은 Jira 없이 [Issue #9](https://github.com/Team-PinLog/back/issues/9)로 단독 추적합니다. 이 예외는 일반 작업에 적용하지 않습니다. @@ -33,7 +33,7 @@ Jira 티켓 발급 git fetch origin dev git switch dev git merge --ff-only origin/dev -git switch -c feat/S15P11A705-123-member-search +git switch -c feat/{jira-key}-member-search ``` 브랜치 이름은 `{type}/{jira-key}-{summary}`, `type`은 `feat` `fix` `docs` `refactor` `chore` `test` `perf` 중 하나입니다. @@ -74,7 +74,7 @@ Docker가 없다고 DB 테스트를 건너뛰지 않습니다. Docker를 켜고 ```bash git add <파일> -git commit -m "feat(S15P11A705-123): add member search" +git commit -m "feat(Jira 작업): add member search" ``` ## 6. push하고 PR을 연다 diff --git a/loadtest/README.md b/loadtest/README.md index eaeb29cf..edf79ed1 100644 --- a/loadtest/README.md +++ b/loadtest/README.md @@ -77,7 +77,7 @@ openssl로 미리 만들어 `artifacts/tokens.json`으로 넘긴다. 6. **자연어 검색 부하** — 질의마다 OpenAI 임베딩 실 호출이 나가는 AI 파트 소유 자원이라 부하 범위에서 뺐다. 검색 부하는 담당자 합의 후 별도 티켓으로 진행한다 -## 부하 프로파일 (S15P11A705-239) +## 부하 프로파일 (Jira 작업) 기능 전수 검증과 별개로, 프로파일 4종 × 계열을 돌려 병목을 관측한다. @@ -108,7 +108,7 @@ bash tools/run-load.sh all # 유효 조합 10개 전부 (약 70~90분) 부하 실행은 `ai.context_ai_state` 고아를 **대량으로** 남긴다(쓰기 여정의 context churn). 알려진 잔여물 절 참고 — 정리 방식은 AI 파트와 공동 결정 사안이다. -## 대량 벤치 (S15P11A705-283) +## 대량 벤치 (Jira 작업) 인덱스·쿼리 계획 검증용 볼륨을 만든다. 현재 시드(record 117k)에서는 플래너가 Seq Scan을 골라도 손해가 없어 인덱스 설계의 옳고 그름이 판별되지 않는다 — record 1천만 · 회원 10만