diff --git "a/static/06_\353\215\260\354\235\264\355\204\260\353\252\250\353\215\270_\353\260\217_\353\254\264\352\262\260\354\204\261.md" "b/static/06_\353\215\260\354\235\264\355\204\260\353\252\250\353\215\270_\353\260\217_\353\254\264\352\262\260\354\204\261.md" index d1f427a..2cfd220 100644 --- "a/static/06_\353\215\260\354\235\264\355\204\260\353\252\250\353\215\270_\353\260\217_\353\254\264\352\262\260\354\204\261.md" +++ "b/static/06_\353\215\260\354\235\264\355\204\260\353\252\250\353\215\270_\353\260\217_\353\254\264\352\262\260\354\204\261.md" @@ -92,7 +92,7 @@ id BIGINT PK member_id BIGINT FK -> member provider VARCHAR(20) -- GOOGLE / KAKAO / NAVER provider_user_id VARCHAR(255) -email VARCHAR(255) NULL +email VARCHAR(255) NOT NULL created_at TIMESTAMPTZ NOT NULL deleted_at TIMESTAMPTZ NULL ``` @@ -100,8 +100,14 @@ deleted_at TIMESTAMPTZ NULL - `provider_user_id`는 공급자가 발급한 식별자입니다(Google `sub`, Kakao `id`, Naver `response.id`). 식별 기준으로는 이메일이나 닉네임을 사용하지 않습니다. - 공급자 간 값이 충돌할 수 있으므로 유니크는 `(provider, provider_user_id)` 복합입니다. - 숫자로 보이는 값도 문자열로 저장합니다. -- `email`은 설정 화면 표시용으로 저장합니다. 공급자가 제공하며, 미동의·미제공 시 `null`일 수 있습니다. 식별키가 아니므로 유니크를 걸지 않습니다. +- `email`은 설정 화면 표시용으로 저장하며 **필수입니다.** 식별키가 아니므로 유니크는 걸지 않지만, 값이 없는 행은 두지 않습니다. 설정 화면이 이 값을 반드시 표시해야 하고, 값이 없는 계정은 그 화면을 채울 수 없습니다. + - **1차 보장은 공급자 콘솔의 필수 동의 설정입니다.** 세 공급자 모두 이메일을 필수 동의로 설정해 두었으므로, 사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 나오지 않습니다. 동의를 거부하면 로그인이 취소되어 애초에 가입 요청이 오지 않습니다. + - **서버도 이메일 없는 응답을 거절합니다.** 위 설정에 의존하는 상태를 코드로 확인하지 않으면, 전제가 깨졌을 때 값 없는 계정이 조용히 만들어집니다. 전제가 깨지는 경우는 콘솔 설정이 선택 동의로 되돌려지는 것, 공급자 응답 형식이 바뀌는 것, 앱의 이메일 수집 자격(카카오 비즈 앱 등)이 상실되는 것입니다. + - 즉 이 경로는 **사용자가 일상적으로 밟는 흐름이 아니라 방어선**입니다. 발생하면 가입이 되지 않고 로그인 실패로 처리되며, 컬럼의 `NOT NULL`이 마지막 방어선입니다. - 탈퇴 시 `provider_user_id`와 `email`을 마스킹합니다. 개인정보이므로 파기 대상입니다. + - **마스킹은 치환이며 `NULL`로 만드는 것이 아닙니다.** 두 컬럼 모두 `NOT NULL`이라 `NULL`을 넣을 수 없습니다. 원본을 식별 불가한 값으로 덮어쓰는 것이 마스킹입니다. + - 치환값이 회원끼리 겹쳐도 됩니다. 유니크는 `(provider, provider_user_id)`이고 **활성행만**(`WHERE deleted_at IS NULL`) 대상이므로, 마스킹 시점에는 이미 `deleted_at`이 채워져 인덱스 밖입니다. 유니크를 피하려고 회원별로 다른 값을 만들 필요가 없습니다. + - 구체적 치환값은 탈퇴 구현이 정합니다. 이 문서가 정하는 것은 **`NULL`이 아니라는 것**과 **원본을 되돌릴 수 없어야 한다는 것**입니다. - `member`와 별도로 `deleted_at`을 둡니다. 부분 유니크 인덱스의 `WHERE` 절이 자기 테이블 컬럼만 참조할 수 있기 때문이며, 이 컬럼이 없으면 탈퇴 후 동일 소셜 계정으로 재가입할 수 없습니다. ### 2.3 place @@ -578,7 +584,7 @@ Collection 소프트 삭제 ```text member 소프트 삭제 -→ social_account 소프트 삭제 + provider_user_id 마스킹 +→ social_account 소프트 삭제 + provider_user_id·email 마스킹 → Record·Context 소프트 삭제 → Collection·CollectionRecord 소프트 삭제 → 해당 User가 생성한 Follow 소프트 삭제 diff --git a/static/07_ERD.md b/static/07_ERD.md index b4c94ca..8c0fe04 100644 --- a/static/07_ERD.md +++ b/static/07_ERD.md @@ -29,7 +29,7 @@ erDiagram bigint member_id FK varchar provider "GOOGLE/KAKAO/NAVER" varchar provider_user_id "탈퇴 시 마스킹" - varchar email "설정 표시용, 탈퇴 시 마스킹, nullable" + varchar email "설정 표시용, 탈퇴 시 마스킹" timestamptz created_at timestamptz deleted_at } diff --git "a/static/08_API_\353\252\205\354\204\270.md" "b/static/08_API_\353\252\205\354\204\270.md" index 468e8bc..28cbbfd 100644 --- "a/static/08_API_\353\252\205\354\204\270.md" +++ "b/static/08_API_\353\252\205\354\204\270.md" @@ -322,6 +322,9 @@ Set-Cookie: logged_in=1; Secure; SameSite=Lax; Path=/ - 복귀 경로는 **서버 설정값**이며 요청 파라미터로 받지 않는다. 임의 URL을 받으면 open redirect 취약점이 된다. - 로그인 이전 화면으로 되돌아가는 처리는 클라이언트가 담당한다(로그인 시작 전 경로를 `sessionStorage` 등에 보관). +- **공급자 응답에 이메일이 없으면 가입하지 않고 실패로 처리한다**(`email`은 필수다 — [06 §2.2](06_데이터모델_및_무결성.md)). 세 공급자 콘솔이 이메일을 필수 동의로 두고 있어 사용자가 이메일만 거절하고 진행할 수는 없으므로, 이 실패는 **정상 흐름이 아니라 그 설정이 깨졌을 때의 방어선**이다. + - 복귀는 다른 실패와 같은 **`error=OAUTH_FAILED`** 다. **사유별로 `error` 값을 가르지 않는다** — 위 규칙대로 클라이언트는 고정된 값 하나만 보고 재로그인을 유도하고, 원인은 서버 로그에서 찾는다. 따라서 클라이언트에 분기를 더할 필요가 없다. + - 사유를 노출하지 않는 이유: 이 실패는 사용자가 우리 화면에서 고칠 수 있는 것이 아니다(동의는 공급자 쪽에 있다). 값을 가르면 클라이언트에 **발생하지 않는 분기**가 남는다. ## 3.3 토큰 재발급 @@ -374,6 +377,7 @@ GET /api/core/v1/me/summary ``` - 카운트는 모두 활성 데이터 기준 집계다. +- **`email`은 항상 있다.** 이메일 없는 계정은 가입 단계에서 걸러지므로(3.2, [06 §2.2](06_데이터모델_및_무결성.md)) 이 필드가 생략되는 경우는 없다. 클라이언트에 값 없음 대비가 필요하지 않다. - 팔로워·팔로잉 목록은 제공하지 않는다. 수치는 본인만 볼 수 있다. - `memberId`는 반환하지 않는다. 개인 API는 서버가 쿠키로 사용자를 식별하므로 클라이언트가 자신의 내부 ID를 알 필요가 없다(1.1).