Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
30 changes: 30 additions & 0 deletions docs/ai/decisions/20260607-user-withdrawal-soft-delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# users soft delete: partial unique index + CHECK 채택

- **상태**: 결정
- **날짜**: 2026-06-07
- **관련**: docs/superpowers/specs/2026-06-07-user-withdrawal-design.md

## 배경

이용약관/개인정보 정책상 회원 탈퇴 기능이 필요하다. 채팅 등 공동 협업 데이터의 무결성을 유지하면서 탈퇴자의 개인정보는 즉시 제거되어야 한다. 동시에 `users.email`과 `(provider, provider_id)`에 걸린 UNIQUE 제약 때문에 단순히 컬럼을 NULL로 비울 수 없다.

## 결정

`users`를 soft delete 모델로 운영한다.

- 활성 회원: `email`, `nickname`, `provider`, `provider_id`는 NOT NULL.
- 탈퇴 회원: 위 컬럼들 NULL 허용. `deleted_at`이 NOT NULL.
- 활성 회원에 한해 unique 강제: PostgreSQL partial unique index (`WHERE deleted_at IS NULL`).
- 조건부 NOT NULL은 CHECK `users_active_required`로 보장.
- 엔티티에 `@SQLRestriction("deleted_at IS NULL")`을 적용해 JPA 조회에서 탈퇴자를 자동 제외.

## 대안 검토

- **placeholder 값**(`deleted+{id}@deleted.local` 등): NOT NULL/UNIQUE 제약을 그대로 둘 수 있으나 통계/검색에 가짜 값이 섞이고, 재가입 시 충돌 회피가 복잡하다. 동일 계정 재가입 차단이 정책 요건이 아닌 본 프로젝트에서는 이점이 없다.
- **분리된 `deleted_users` 테이블**: row 이관 비용과 추가 join이 필요하다. soft delete의 단순성을 잃는다.

## 영향

- DDL 변경(`V1.7__users_withdrawal.sql`): NOT NULL drop, 기존 UNIQUE 제약 제거, partial unique index 및 CHECK 추가.
- `User` 엔티티에 `deletedAt`, `anonymize()`, `isWithdrawn()` 추가.
- 동일 OAuth 계정 재가입 가능. 정책상 차단할 필요가 있을 때만 별도 결정 기록으로 변경한다.
18 changes: 13 additions & 5 deletions docs/ai/erd.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,23 @@ Google OAuth 기반 사용자 정보
| 컬럼 | 타입 | 제약조건 | 설명 |
|------|------|----------|------|
| id | BIGINT | PK, AUTO_INCREMENT | 사용자 고유 ID |
| email | VARCHAR(255) | UNIQUE, NOT NULL | 구글 이메일 |
| nickname | VARCHAR(50) | NOT NULL | 표시 이름 |
| email | VARCHAR(255) | 활성 회원 NOT NULL, 활성 회원 간 UNIQUE | 구글 이메일. 탈퇴 시 NULL |
| nickname | VARCHAR(50) | 활성 회원 NOT NULL | 표시 이름. 탈퇴 시 NULL |
| profile_image_url | VARCHAR(500) | NULLABLE | 프로필 이미지 URL |
| provider | VARCHAR(20) | NOT NULL, DEFAULT 'GOOGLE' | OAuth 제공자 |
| provider_id | VARCHAR(255) | NOT NULL | OAuth 제공자 측 사용자 ID |
| provider | VARCHAR(20) | 활성 회원 NOT NULL | OAuth 제공자. 탈퇴 시 NULL |
| provider_id | VARCHAR(255) | 활성 회원 NOT NULL, 활성 회원 간 provider와 조합 UNIQUE | OAuth 제공자 측 사용자 ID. 탈퇴 시 NULL |
| deleted_at | TIMESTAMP | NULLABLE | 탈퇴 시각. NOT NULL이면 익명화된 탈퇴 회원 |
Comment thread
parkjuyeong0312 marked this conversation as resolved.
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 가입일시 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 수정일시 |

**제약:** UNIQUE(provider, provider_id)
**제약:**
- CHECK `users_active_required`: `deleted_at IS NOT NULL OR (email IS NOT NULL AND nickname IS NOT NULL AND provider IS NOT NULL AND provider_id IS NOT NULL)`
- partial unique index `users_email_unique_active`: `email` WHERE `deleted_at IS NULL`
- partial unique index `users_provider_provider_id_unique_active`: `(provider, provider_id)` WHERE `deleted_at IS NULL`

**인덱스 (탈퇴자 필터):** `users_deleted_at_idx` ON `(deleted_at)` WHERE `deleted_at IS NOT NULL`

> 활성 회원은 email/nickname/provider/provider_id가 NOT NULL이며 이메일과 (provider, provider_id) 조합이 unique. 탈퇴 회원은 모두 NULL 가능하며 unique 검사 대상에서 제외되어 동일 OAuth 계정으로 재가입할 수 있다.

---

Expand Down
1 change: 1 addition & 0 deletions docs/ai/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭
| `[x]` | 토큰 재발급 (Refresh) | Refresh Token Rotation: UUID 기반 HTTP-only 쿠키(path=/auth/refresh), Redis `refresh:token:{uuid}`→userId(TTL 14일) / `refresh:user:{userId}`→Set\<uuid\>. Replay Detection 으로 탈취 시 전체 무효화 | Redis |
| `[x]` | 로그아웃 | 단일 기기 로그아웃: 요청한 토큰만 삭제 | Redis |
| `[x]` | 내 정보 조회 | 로그인된 사용자 프로필 조회 | users |
| `[x]` | 회원 탈퇴 | `DELETE /users/me`. users는 soft delete + 익명화(email/nickname/profile/provider/provider_id NULL, deleted_at 설정), room_members는 hard delete. HOST 방은 사전 위임 필요(422 + roomsRequiringDelegation), 1인 HOST 방은 자동 hard delete. Redis RTK는 AFTER_COMMIT에서 일괄 폐기. 채팅/북마크/일정의 BIGINT 작성자 ID는 유지하며 클라이언트는 members에 없는 ID를 "(알 수 없음)"으로 표시 | users, room_members, Redis |

---

Expand Down
Loading