From ac9d0e8178e9a0ef8833294b01def1355a48bbf9 Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Wed, 29 Jul 2026 17:45:12 +0900 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9D=B4=EB=A9=94=EC=9D=BC=EC=9D=84=20?= =?UTF-8?q?=ED=95=84=EC=88=98=EB=A1=9C=20=EA=B0=9C=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(06=20=C2=A72.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트에 이메일을 표시하는 화면이 있어 값이 반드시 있어야 하고, 이메일 없는 계정이 존재해서는 안 된다는 결정이다. 06 §2.2가 "미동의·미제공 시 null일 수 있다"로 정하고 있어 구현보다 이 개정이 먼저 필요하다. - 06 §2.2: 스키마 email NULL → NOT NULL, 서술을 필수로 개정 - 07_ERD: social_account.email 주석 nullable → 필수 - 08 §3.2: 콜백 실패 사유에 이메일 없는 응답을 추가 - 08 §3.5: email이 항상 있다는 것을 명시 §3.5의 한 줄이 프론트가 문제 제기한 지점을 닫는다. §1.6이 null 필드를 직렬화에서 생략하므로 지금까지는 응답에 email 키 자체가 없을 수 있었고, 그래서 클라이언트가 값 없음을 대비해야 했다. 보장 순서를 명시했다. 1차는 공급자 콘솔의 필수 동의 설정이다 — 사용자가 이메일만 거절하고 진행하는 선택지가 동의 화면에 없으므로, 거부하면 로그인이 취소되어 가입 요청 자체가 오지 않는다. 서버의 거절과 컬럼 NOT NULL은 그 설정에 의존하는 상태를 코드로 확인하는 방어선이며, 전제가 깨지는 경우(콘솔이 선택 동의로 되돌려짐, 공급자 응답 형식 변경, 카카오 비즈 앱 자격 상실)에 값 없는 계정이 조용히 만들어지는 것을 막는다. 즉 사용자가 일상적으로 밟는 흐름이 아니다. 구현은 Team-PinLog/back S15P11A705-152 (back#97) Co-Authored-By: Claude Opus 5 (1M context) --- ...0_\353\260\217_\353\254\264\352\262\260\354\204\261.md" | 7 +++++-- static/07_ERD.md | 2 +- "static/08_API_\353\252\205\354\204\270.md" | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) 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..83e8bb9 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,7 +100,10 @@ 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`을 마스킹합니다. 개인정보이므로 파기 대상입니다. - `member`와 별도로 `deleted_at`을 둡니다. 부분 유니크 인덱스의 `WHERE` 절이 자기 테이블 컬럼만 참조할 수 있기 때문이며, 이 컬럼이 없으면 탈퇴 후 동일 소셜 계정으로 재가입할 수 없습니다. diff --git a/static/07_ERD.md b/static/07_ERD.md index b4c94ca..2b4cdc1 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..8f2fdf3 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,7 @@ Set-Cookie: logged_in=1; Secure; SameSite=Lax; Path=/ - 복귀 경로는 **서버 설정값**이며 요청 파라미터로 받지 않는다. 임의 URL을 받으면 open redirect 취약점이 된다. - 로그인 이전 화면으로 되돌아가는 처리는 클라이언트가 담당한다(로그인 시작 전 경로를 `sessionStorage` 등에 보관). +- **공급자 응답에 이메일이 없으면 가입하지 않고 실패로 처리한다**(`email`은 필수다 — [06 §2.2](06_데이터모델_및_무결성.md)). 세 공급자 콘솔이 이메일을 필수 동의로 두고 있어 사용자가 이메일만 거절하고 진행할 수는 없으므로, 이 실패는 **정상 흐름이 아니라 그 설정이 깨졌을 때의 방어선**이다. ## 3.3 토큰 재발급 @@ -374,6 +375,7 @@ GET /api/core/v1/me/summary ``` - 카운트는 모두 활성 데이터 기준 집계다. +- **`email`은 항상 있다.** 이메일 없는 계정은 가입 단계에서 걸러지므로(3.2, [06 §2.2](06_데이터모델_및_무결성.md)) 이 필드가 생략되는 경우는 없다. 클라이언트에 값 없음 대비가 필요하지 않다. - 팔로워·팔로잉 목록은 제공하지 않는다. 수치는 본인만 볼 수 있다. - `memberId`는 반환하지 않는다. 개인 API는 서버가 쿠키로 사용자를 식별하므로 클라이언트가 자신의 내부 ID를 알 필요가 없다(1.1). From c25541f1479554c7b4624e58e0767bd21e92a600 Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Wed, 29 Jul 2026 17:51:18 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20ERD=EC=9D=98=20email=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EB=AC=B8=EC=84=9C=20=EA=B4=80=EB=A1=80?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋에서 "nullable"을 "필수"로 바꿨는데, 이 ERD는 **nullable인 것만 표기하고 기본은 필수**로 두는 관례를 쓴다. road_address·phone·place_url·display_name에만 nullable이 붙어 있고, 필수를 명시하는 것은 published_at "발행 시 필수 (CHECK)"처럼 조건부일 때다. email은 무조건 필수이므로 표기를 지우는 것이 관례에 맞다. 같은 테이블의 provider_user_id와 같은 형태가 됐다. --- static/07_ERD.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/07_ERD.md b/static/07_ERD.md index 2b4cdc1..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 "설정 표시용, 탈퇴 시 마스킹, 필수" + varchar email "설정 표시용, 탈퇴 시 마스킹" timestamptz created_at timestamptz deleted_at } From cdd20cd0c4bf8e321f270a3df38aca0146a15f9e Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Thu, 30 Jul 2026 08:58:16 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=8B=A4=ED=8C=A8=EB=8F=84=20OAUTH=5FFAIL?= =?UTF-8?q?ED=EB=A1=9C=20=EB=AC=B6=EB=8A=94=EB=8B=A4=EB=8A=94=20=EA=B2=83?= =?UTF-8?q?=EC=9D=84=20=EB=AA=85=EC=8B=9C=ED=95=9C=EB=8B=A4=20(08=20=C2=A7?= =?UTF-8?q?3.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트 리뷰에서 "별도 error 값으로 분리되는지" 질문이 왔다. 분리되면 콜백 라우팅에 분기를 더해야 하므로 계약이 답을 갖고 있어야 한다. 답은 묶인다이고, 이것은 새 결정이 아니라 기존 결정이다. back의 OAuthLoginFailureHandler javadoc이 이미 "실패 사유를 그대로 노출하지 않는다. 클라이언트는 고정된 code 하나만 보고 재로그인을 유도하고, 원인은 traceId로 로그에서 찾는다"로 정하고 있다. 별도 값을 두면 그 결정을 뒤집는 것이다. 사유를 가르지 않는 근거도 함께 적었다. 이 실패는 사용자가 우리 화면에서 고칠 수 있는 것이 아니다(동의는 공급자 쪽에 있다). 게다가 필수 동의 설정에서는 사용자가 이 경로에 도달하지 않으므로, 값을 가르면 클라이언트에 발생하지 않는 분기가 남는다. 프론트는 분기 추가가 필요하지 않다. --- "static/08_API_\353\252\205\354\204\270.md" | 2 ++ 1 file changed, 2 insertions(+) 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 8f2fdf3..28cbbfd 100644 --- "a/static/08_API_\353\252\205\354\204\270.md" +++ "b/static/08_API_\353\252\205\354\204\270.md" @@ -323,6 +323,8 @@ 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 토큰 재발급 From fd6505ce9fff1803a05cb266becd3547f168bc87 Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Thu, 30 Jul 2026 09:55:14 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EB=A7=88=EC=8A=A4=ED=82=B9?= =?UTF-8?q?=EC=9D=B4=20NULL=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=9D=BC?= =?UTF-8?q?=EB=8A=94=20=EA=B2=83=EC=9D=84=20=EB=AA=85=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=C2=A76.9=EC=9D=98=20=EB=88=84=EB=9D=BD=EC=9D=84=20?= =?UTF-8?q?=EC=B1=84=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit email을 NOT NULL로 바꾸면서 탈퇴 시 마스킹과 충돌할 여지가 생겼다. 계약에 문제가 둘 있었다. 1) 마스킹의 결과값이 정의되지 않았다 "마스킹합니다"만 있고 무엇으로 바뀌는지 정한 곳이 한 곳도 없었다. email이 nullable일 때는 구현자가 NULL로 해석할 수 있었고, NOT NULL이 되면 그 해석이 곧 제약 위반이 된다. 즉 이 개정이 기존 공백을 위험한 공백으로 바꾼다. 치환이라는 것을 §2.2에 명시했다. provider_user_id도 NOT NULL이면서 마스킹 대상이므로 이 저장소는 이미 "마스킹 = 치환"을 전제하고 있었고, 그 전제를 글로 옮긴 것이다. 치환값이 회원끼리 겹쳐도 된다는 것도 적었다. 유니크가 활성행만 대상이고 마스킹 시점에는 deleted_at이 이미 채워져 인덱스 밖이다. 이걸 적지 않으면 구현자가 유니크를 피하려고 회원별로 다른 값을 만들려 한다. 구체적 치환값은 탈퇴 구현의 몫으로 남겼다. 계약이 정하는 것은 NULL이 아니라는 것과 원본을 되돌릴 수 없어야 한다는 것이다. 2) §6.9 탈퇴 흐름에 email이 빠져 있었다 §2.2와 08 §3.6은 둘 다 마스킹한다고 적는데 §6.9는 provider_user_id만 적고 있었다. 탈퇴 구현자가 실제로 보고 따라가는 순서도라 이쪽이 더 위험하다. 이제 네 곳(06 §2.2, 06 §6.9, 07_ERD, 08 §3.6)이 일치한다. --- ...270_\353\260\217_\353\254\264\352\262\260\354\204\261.md" | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 83e8bb9..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" @@ -105,6 +105,9 @@ deleted_at TIMESTAMPTZ NULL - **서버도 이메일 없는 응답을 거절합니다.** 위 설정에 의존하는 상태를 코드로 확인하지 않으면, 전제가 깨졌을 때 값 없는 계정이 조용히 만들어집니다. 전제가 깨지는 경우는 콘솔 설정이 선택 동의로 되돌려지는 것, 공급자 응답 형식이 바뀌는 것, 앱의 이메일 수집 자격(카카오 비즈 앱 등)이 상실되는 것입니다. - 즉 이 경로는 **사용자가 일상적으로 밟는 흐름이 아니라 방어선**입니다. 발생하면 가입이 되지 않고 로그인 실패로 처리되며, 컬럼의 `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 @@ -581,7 +584,7 @@ Collection 소프트 삭제 ```text member 소프트 삭제 -→ social_account 소프트 삭제 + provider_user_id 마스킹 +→ social_account 소프트 삭제 + provider_user_id·email 마스킹 → Record·Context 소프트 삭제 → Collection·CollectionRecord 소프트 삭제 → 해당 User가 생성한 Follow 소프트 삭제