Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/backend/WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@
| 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) |
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# BD-41. 탈퇴 회원의 남은 Access 토큰을 인증 필터의 DB 조회로 막는다

- **상태**: Accepted
- **날짜**: 2026-07-30
- **관련**: [S15P11A705-65](https://ssafy.atlassian.net/browse/S15P11A705-65), [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 폐기)

## 맥락

탈퇴가 하는 두 조치로는 **이미 발급된 Access 토큰을 회수할 수 없다.**

| 조치 | 덮는 범위 | 못 덮는 것 |
|---|---|---|
| `AuthCookies.clear` | 요청을 보낸 **그 기기**의 쿠키 | `Set-Cookie`는 그 응답을 받은 클라이언트에만 도달한다 |
| `RefreshTokenStore.revokeAll` | **재발급** | 이미 발급된 Access는 만료(30분)까지 유효 |

Access는 자기완결적 JWT이고 폐기 목록이 없다. `JwtAuthenticationFilter`는 서명과 만료만 확인하므로, **다른 기기에 남은 쿠키로 최대 30분간 조회와 쓰기가 모두 가능하다.** 탈퇴한 회원이 새 Record를 만들 수 있다는 뜻이고, 그 행은 삭제 표시된 `member`를 참조한다.

공용 계약은 이 창에 대해 말하지 않는다. `08 §3.6`이 요구하는 것은 "해당 회원의 모든 Refresh 무효화와 쿠키 만료"까지다. 반면 `back#34`의 완료 조건은 *"탈퇴 후 기존 인증 쿠키로 보호 경로를 요청하면 401"*로 계약보다 강하다.

## 선택지

| 안 | 장점 | 단점 |
|---|---|---|
| (a) 필터에서 `MemberRepository.isActive` 확인 | 창이 즉시 닫힌다. 판정이 이미 한 곳에 모여 있다(S15P11A705-147) | 인증 요청마다 PK 조회 1회 |
| (b) 창을 감수하고 문서화 | 추가 비용 0. 스테이트리스 토큰의 알려진 대가다 | 탈퇴자가 30분간 쓰기 가능. 완료 조건을 계약 수준으로 낮춰야 한다 |
| (c) Redis 탈퇴 마커(TTL 30분) | DB 부하 없음, 만료 자동 | 새 키 스페이스. 필터가 Redis에 의존하게 되어 **Redis 순단이 인증 실패로 번지고**, fail-open/closed 판단이 추가로 필요하다 |

## 결정

**(a)** — 능동적 선택이다. 결정적 이유 둘.

**① 창 안에서 가능한 것이 읽기가 아니라 쓰기다.** 조회만 새는 것이라면 (b)를 택할 만하다. 그런데 탈퇴 직후 30분간 Record·Collection 생성이 되고, 그 행들은 연쇄 삭제가 이미 지나간 뒤에 만들어져 **어떤 정리 경로에도 걸리지 않는다.** 탈퇴 후 남는 고아 데이터를 감수하는 것과 요청당 PK 조회를 감수하는 것 사이의 선택이고, 후자가 싸다.

**② 판정 지점이 이미 존재한다.** `S15P11A705-147`이 공개 조회 세 경로의 미탈퇴 판정을 `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)과 같은 방향이다 — 외부 의존성 하나가 서비스 전체를 끌어내리지 않게 한다.

### 함께 정한 것 — Refresh 폐기는 커밋 이후에, 실패를 삼켜서

Redis는 DB 트랜잭션에 참여할 수 없다. **트랜잭션 안에서는 "폐기는 됐는데 탈퇴는 롤백된" 상태를 없앨 수 없다** — 폐기가 반환한 뒤 커밋이 실패하거나, 폐기가 키를 일부 지우고 던지면 그대로 남는다. 마지막에 두는 것으로는 창이 좁아질 뿐이다.

> **정정(2026-07-31).** 이 ADR의 초기 판단은 *"마지막에 두면 그 상태가 남지 않는다"*였다. 틀렸다. `#120` 리뷰에서 지적받아 고쳤다. 순서는 창의 크기만 바꾼다.

| 배치 | 폐기 실패 | 커밋 실패 |
|---|---|---|
| 트랜잭션 안, 마지막 | DB 롤백 → 아무 일도 없었음 | **폐기만 반영된 상태가 남는다** |
| **커밋 이후, 실패 삼킴** | 탈퇴는 확정, Refresh가 살아남음 | 폐기 전이라 아무 일도 없었음 |

**커밋 이후를 택했다.** 잔여 위험이 무해한 방향이기 때문이다 — 폐기가 실패해 Refresh가 살아남아도 그것으로 받는 Access는 이 ADR이 넣은 탈퇴 판정에 막힌다. 즉 **이 결정과 그 결정이 서로를 보완한다.**

실패를 삼키는 이유는 따로다. 삼키지 않으면 이미 커밋된 탈퇴가 500으로 응답하고 컨트롤러가 도달하지 못해 **쿠키도 지워지지 않는다** — 사용자는 "실패했다"고 보는데 계정은 사라진 상태가 된다.

## 결과

- **감수하는 것**
- 인증된 요청마다 `SELECT ... WHERE id = ? AND deleted_at IS NULL` 한 번. PK 조회이고 트랜잭션 밖이라 커넥션을 짧게 쓰지만, 공짜는 아니다.
- **정확히는 조회 1회가 아니라 커넥션 체크아웃 1회가 늘어난다.** `open-in-view: false`라 필터의 `isActive`가 자체 `EntityManager`와 커넥션을 열고 닫은 뒤, 컨트롤러의 `@Transactional`이 두 번째 커넥션을 잡는다. 저트래픽에서는 보이지 않지만 인증 경로가 쓰는 풀 여유를 그만큼 깎는다([#120](https://github.com/Team-PinLog/back/pull/120) 리뷰에서 지적).
- **필터가 인프라 사유로 던질 수 있게 됐다.** 이 변경 전까지 이 필터는 순수 JWT 검증이라 던질 이유가 없었다. `DataAccessException`은 `DispatcherServlet` 밖이라 `@RestControllerAdvice`도 `SecurityErrorWriter`도 타지 않아 **공통 오류 envelope을 우회한다.** 어떻게 다룰지는 별도 티켓에서 정한다.
- `global/security/authentication`이 `domain/member/repository`를 참조한다. `SecurityConfig`가 이미 `domain.auth`를 참조하는 것과 같은 방향이라 새 위반은 아니지만, 의존이 하나 늘었다.
- 캐시가 없다. 같은 회원이 초당 여러 요청을 보내면 그만큼 조회한다.
- **재검토 트리거**
- 인증 경로의 DB 조회가 지연·커넥션 풀에서 실제로 문제로 관측될 때. 그때는 캐시(짧은 TTL) 또는 (c)를 다시 본다.
- 토큰 폐기 목록이나 키 회전이 도입될 때. 그 장치가 생기면 이 검사가 중복이 될 수 있다.
- 탈퇴에 유예 기간이 도입될 때. "탈퇴 = 즉시 차단"이 아니게 되면 `isActive`의 의미부터 다시 정해야 한다.
1 change: 1 addition & 0 deletions docs/backend/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,4 @@
| [BD-38](BD-38-published-predicate-per-layer.md) | 발행 여부 판정은 층마다 자체 보유하고(아홉 곳), 대가를 각 자리를 지키는 테스트로 갚는다 | Accepted | S15P11A705-148 |
| [BD-39](BD-39-embedding-profile-in-application-config.md) | Embedding Profile을 `application.yml` 리터럴로 두고 환경변수는 덮어쓰기로만 — 런타임 대조가 성립하려면 Spring도 값을 가져야 한다 | Accepted | S15P11A705-135 |
| [BD-40](BD-40-scheduling-with-dedicated-scheduler-and-no-distributed-lock.md) | 스케줄링을 `@EnableScheduling` + 자체 `taskScheduler` Bean으로 들이고, 다중 인스턴스 조정은 분산 락 없이 `SKIP LOCKED`에 맡긴다 | Accepted | S15P11A705-159 |
| [BD-41](BD-41-withdrawn-member-check-in-authentication-filter.md) | 탈퇴 회원의 남은 Access 토큰을 인증 필터의 PK 조회로 막는다 — 창 안에서 새는 것이 읽기가 아니라 쓰기이고, Redis 마커는 순단을 인증 실패로 번지게 한다 | Accepted | S15P11A705-65 |
Loading
Loading