|
| 1 | +# 기능 기획서: 회원 탈퇴 (카카오 연동 해제 + Soft Delete) |
| 2 | + |
| 3 | +> 작성일: 2026-03-21 |
| 4 | +> 관련 이슈: 미정 |
| 5 | +> 상태: 구현 완료 |
| 6 | +
|
| 7 | +--- |
| 8 | + |
| 9 | +## 1. 개요 |
| 10 | + |
| 11 | +카카오 소셜 로그인 유저가 서비스를 탈퇴할 수 있는 기능. |
| 12 | + |
| 13 | +- **카카오 연동 해제**: 카카오 Admin Key를 이용해 서버 측에서 연결 끊기 API 호출 |
| 14 | +- **Soft Delete**: `Member` 엔티티에 `deletedAt` 필드 추가, 실제 Row는 삭제하지 않음 |
| 15 | +- **작성 콘텐츠 익명화**: 탈퇴한 회원의 게시글/댓글 조회 시 작성자 정보를 "알 수 없는 사용자"로 반환 |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## 2. 현재 구조 분석 |
| 20 | + |
| 21 | +### Member 엔티티 현황 |
| 22 | +- `deletedAt` 필드 없음 → 추가 필요 |
| 23 | +- `kakaoId` 필드 존재 → 카카오 연동 해제에 활용 |
| 24 | +- `unique` 컬럼 다수 (`kakaoId`, `kakao_nickname`, `kakao_profile_url`, `nickname`) → soft delete 시 재가입 가능 여부 고려 필요 |
| 25 | + |
| 26 | +### 작성 콘텐츠 연관 관계 |
| 27 | + |
| 28 | +| 엔티티 | Member 참조 | optional | |
| 29 | +|--------|------------|----------| |
| 30 | +| Post | `@ManyToOne` | false | |
| 31 | +| Comment | `@ManyToOne` | false | |
| 32 | +| Journal | `@ManyToOne` | false | |
| 33 | +| SeatView | `@ManyToOne` | false | |
| 34 | +| Like | `@ManyToOne` | false | |
| 35 | +| Scrap | `@ManyToOne` | false | |
| 36 | + |
| 37 | +→ Post/Comment/Journal/SeatView는 Member FK를 유지한 채로 soft delete로 처리 가능 |
| 38 | +→ Like/Scrap은 탈퇴 시 hard delete (의미 없는 데이터) |
| 39 | + |
| 40 | +### MemberShortResDto |
| 41 | +```java |
| 42 | +public record MemberShortResDto(String nickName, String profile_url) { |
| 43 | + public static MemberShortResDto from(Member member) { ... } |
| 44 | +} |
| 45 | +``` |
| 46 | +→ `from(Member)` 팩토리 메서드에 `isDeleted` 분기 추가 |
| 47 | + |
| 48 | +### Comment 기존 Soft Delete 패턴 |
| 49 | +- Comment는 이미 `isDeleted` 플래그 사용 |
| 50 | +- 삭제된 댓글은 `memberShortResDto = null`, `content = "삭제된 댓글입니다."`로 처리 |
| 51 | +- 회원 탈퇴 익명화도 동일 레이어(DTO 변환 시점)에서 처리 → **일관성 확보** |
| 52 | + |
| 53 | +### KakaoService 현황 |
| 54 | +- `kakaoApiClient` (WebClient, `https://kapi.kakao.com`) 이미 존재 |
| 55 | +- 카카오 연동 해제 API: `POST /v1/user/unlink` |
| 56 | + - Header: `Authorization: KakaoAK {admin_key}` |
| 57 | + - Body: `target_id_type=user_id&target_id={kakaoId}` |
| 58 | + - Admin Key 방식 사용 → 액세스 토큰 재발급 불필요 |
| 59 | + |
| 60 | +--- |
| 61 | + |
| 62 | +## 3. API 설계 |
| 63 | + |
| 64 | +### DELETE /api/member/me |
| 65 | + |
| 66 | +| 항목 | 내용 | |
| 67 | +|------|------| |
| 68 | +| HTTP Method | DELETE | |
| 69 | +| URL | `/api/member/me` | |
| 70 | +| 인증 | JWT 필수 (현재 로그인 유저) | |
| 71 | +| Request Body | 없음 | |
| 72 | +| Response | `200 OK` / 공통 성공 응답 | |
| 73 | + |
| 74 | +**응답 예시:** |
| 75 | +```json |
| 76 | +{ |
| 77 | + "status": 200, |
| 78 | + "message": "success", |
| 79 | + "data": null |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +**에러 케이스:** |
| 84 | +| 상황 | HTTP 상태 | ErrorCode | |
| 85 | +|------|-----------|-----------| |
| 86 | +| 이미 탈퇴한 회원 | 400 | `ALREADY_DELETED_MEMBER` | |
| 87 | +| 카카오 연동 해제 실패 | 500 | `KAKAO_UNLINK_FAILED` (신규 추가) | |
| 88 | + |
| 89 | +--- |
| 90 | + |
| 91 | +## 4. 구현 계획 |
| 92 | + |
| 93 | +### 4-1. Member 엔티티 수정 |
| 94 | +**파일:** `domain/member/domain/Member.java` |
| 95 | + |
| 96 | +```java |
| 97 | +// 추가 필드 |
| 98 | +private LocalDateTime deletedAt; |
| 99 | + |
| 100 | +// 추가 메서드 |
| 101 | +public void softDelete() { |
| 102 | + this.deletedAt = LocalDateTime.now(); |
| 103 | +} |
| 104 | + |
| 105 | +public boolean isDeleted() { |
| 106 | + return this.deletedAt != null; |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +> **unique 컬럼 충돌 문제**: 탈퇴 후 재가입 시 `kakaoId`, `nickname` 등 unique 제약 위반 |
| 111 | +> → 탈퇴 시 `kakaoId`를 null로 변경 (카카오 연동 해제되므로 더 이상 식별 불필요) |
| 112 | +> → `kakao_nickname`, `kakao_profile_url`, `nickname`은 추후 null 처리 혹은 suffix 방식 고려 |
| 113 | +> → **1차 구현에서는 kakaoId만 null 처리** |
| 114 | +
|
| 115 | +### 4-2. KakaoService 수정 |
| 116 | +**파일:** `domain/kakao/service/KakaoService.java` |
| 117 | + |
| 118 | +```java |
| 119 | +@Value("${kakao.admin_key}") |
| 120 | +private String adminKey; |
| 121 | + |
| 122 | +public void unlinkKakaoUser(Long kakaoId) { |
| 123 | + // POST https://kapi.kakao.com/v1/user/unlink |
| 124 | + // Header: Authorization: KakaoAK {adminKey} |
| 125 | + // Body: target_id_type=user_id&target_id={kakaoId} |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +환경변수 추가: |
| 130 | +- `.env.local`, `.env` → `KAKAO_ADMIN_KEY=...` |
| 131 | +- `application.yml` → `kakao.admin_key: ${KAKAO_ADMIN_KEY}` |
| 132 | + |
| 133 | +### 4-3. MemberWithdrawService 신규 생성 |
| 134 | +**파일:** `domain/member/service/MemberWithdrawService.java` |
| 135 | + |
| 136 | +처리 순서: |
| 137 | +1. 회원 조회 및 이미 탈퇴 여부 확인 |
| 138 | +2. KakaoService.unlinkKakaoUser(member.kakaoId) 호출 |
| 139 | +3. Like, Scrap hard delete (deleteByMember) |
| 140 | +4. member.softDelete() + member.kakaoId = null |
| 141 | + |
| 142 | +```java |
| 143 | +@Service |
| 144 | +@RequiredArgsConstructor |
| 145 | +@Transactional |
| 146 | +public class MemberWithdrawService { |
| 147 | + private final MemberRepository memberRepository; |
| 148 | + private final LikeRepository likeRepository; |
| 149 | + private final ScrapRepository scrapRepository; |
| 150 | + private final KakaoService kakaoService; |
| 151 | + |
| 152 | + public void withdraw(Long memberId) { |
| 153 | + Member member = memberRepository.findById(memberId) |
| 154 | + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); |
| 155 | + |
| 156 | + if (member.isDeleted()) { |
| 157 | + throw new CustomException(ErrorCode.ALREADY_DELETED_MEMBER); |
| 158 | + } |
| 159 | + |
| 160 | + kakaoService.unlinkKakaoUser(member.getKakaoId()); |
| 161 | + likeRepository.deleteByMember(member); |
| 162 | + scrapRepository.deleteByMember(member); |
| 163 | + member.softDelete(); |
| 164 | + member.setKakaoId(null); // unique 컬럼 해제 |
| 165 | + } |
| 166 | +} |
| 167 | +``` |
| 168 | + |
| 169 | +> **Post/Comment/Journal/SeatView는 삭제하지 않음** |
| 170 | +> → FK를 유지하면서 soft delete 상태의 Member를 참조 |
| 171 | +> → 조회 시 `member.isDeleted()` 분기로 "알 수 없는 사용자" 처리 |
| 172 | +
|
| 173 | +### 4-4. MemberDeleteController 신규 생성 |
| 174 | +**파일:** `domain/member/controller/MemberDeleteController.java` |
| 175 | + |
| 176 | +```java |
| 177 | +@RestController |
| 178 | +@RequestMapping("/api/member") |
| 179 | +@Tag(name = "Member") |
| 180 | +@RequiredArgsConstructor |
| 181 | +public class MemberDeleteController { |
| 182 | + private final MemberWithdrawService memberWithdrawService; |
| 183 | + |
| 184 | + @DeleteMapping("/me") |
| 185 | + @Operation(summary = "회원 탈퇴") |
| 186 | + public ApiResponse<Void> withdraw(@AuthenticationPrincipal CustomUserDetails userDetails) { |
| 187 | + memberWithdrawService.withdraw(userDetails.getMemberId()); |
| 188 | + return ApiResponse.ok(null); |
| 189 | + } |
| 190 | +} |
| 191 | +``` |
| 192 | + |
| 193 | +### 4-5. MemberShortResDto 수정 |
| 194 | +**파일:** `domain/member/dto/res/MemberShortResDto.java` |
| 195 | + |
| 196 | +```java |
| 197 | +public static MemberShortResDto from(Member member) { |
| 198 | + if (member == null || member.isDeleted()) { |
| 199 | + return new MemberShortResDto("알 수 없는 사용자", null); |
| 200 | + } |
| 201 | + return new MemberShortResDto(member.getNickname(), member.getKakao_profile_url()); |
| 202 | +} |
| 203 | +``` |
| 204 | + |
| 205 | +### 4-6. Repository 메서드 추가 |
| 206 | + |
| 207 | +**LikeRepository:** |
| 208 | +```java |
| 209 | +void deleteByMember(Member member); |
| 210 | +``` |
| 211 | + |
| 212 | +**ScrapRepository:** |
| 213 | +```java |
| 214 | +void deleteByMember(Member member); |
| 215 | +``` |
| 216 | + |
| 217 | +### 4-7. ErrorCode 추가 |
| 218 | +**파일:** `global/exception/ErrorCode.java` |
| 219 | + |
| 220 | +```java |
| 221 | +ALREADY_DELETED_MEMBER(400, "이미 탈퇴한 회원입니다."), |
| 222 | +KAKAO_UNLINK_FAILED(500, "카카오 연동 해제에 실패했습니다."), |
| 223 | +``` |
| 224 | + |
| 225 | +--- |
| 226 | + |
| 227 | +## 5. 설계 결정 및 트레이드오프 |
| 228 | + |
| 229 | +### Q1. Post/Comment/Journal은 왜 삭제하지 않나? |
| 230 | +- 콘텐츠를 삭제하면 다른 사용자들의 댓글/좋아요 컨텍스트가 손실됨 |
| 231 | +- Soft delete + 익명화로 콘텐츠 흐름 보존 |
| 232 | +- 운영자 관리 목적으로 데이터 보존 필요 |
| 233 | + |
| 234 | +### Q2. 익명화는 왜 DB가 아닌 DTO 변환 시점에 처리하나? |
| 235 | +- DB의 FK/제약조건 변경 없이 처리 가능 |
| 236 | +- Comment의 기존 soft delete 패턴과 일관성 |
| 237 | +- 추후 "작성자 복원" 같은 운영 처리 여지 남김 |
| 238 | + |
| 239 | +### Q3. unique 컬럼 충돌 문제 |
| 240 | +- 탈퇴 후 같은 카카오 계정으로 재가입 시 `kakaoId`, `kakao_nickname` unique 위반 발생 |
| 241 | +- 1차: `kakaoId = null` 처리로 재가입 가능 |
| 242 | +- `kakao_nickname`, `nickname` unique 위반은 재가입 시 새 닉네임 입력으로 회피 가능 |
| 243 | +- 장기적으로는 `nickname + "_deleted_" + id` suffix 방식도 고려 |
| 244 | + |
| 245 | +--- |
| 246 | + |
| 247 | +## 6. 체크리스트 |
| 248 | + |
| 249 | +### 엔티티 |
| 250 | +- [x] `Member.java` - `deletedAt` 필드, `softDelete()`, `isDeleted()` 메서드 추가 |
| 251 | + |
| 252 | +### 서비스 |
| 253 | +- [x] `KakaoService.java` - `unlinkKakaoUser(Long kakaoId)` 메서드 추가 |
| 254 | +- [x] `MemberWithdrawService.java` - 신규 생성 |
| 255 | + |
| 256 | +### 컨트롤러 |
| 257 | +- [x] `MemberDeleteController.java` - 신규 생성, `DELETE /member/me` |
| 258 | + |
| 259 | +### DTO |
| 260 | +- [x] `MemberShortResDto.java` - `from(Member)` 익명화 분기 추가 |
| 261 | + |
| 262 | +### Repository |
| 263 | +- [x] `LikeRepository.java` - `deleteByMember(Member)` 추가 |
| 264 | +- [x] `ScrapRepository.java` - `deleteByMember(Member)` 추가 |
| 265 | + |
| 266 | +### 예외 |
| 267 | +- [x] `ErrorCode.java` - `ALREADY_DELETED_MEMBER`, `KAKAO_UNLINK_FAILED` 추가 |
| 268 | + |
| 269 | +### 환경변수 |
| 270 | +- [ ] `.env.local`, `.env` - `KAKAO_ADMIN_KEY` 추가 (직접 추가 필요) |
| 271 | +- [x] `application-local/dev/prod.yml` - `kakao.admin_key` 바인딩 추가 |
| 272 | + |
| 273 | +### 테스트 |
| 274 | +- [x] `MemberWithdrawControllerTest.java` - API 엔드포인트 테스트 (성공, 이미 탈퇴 케이스) |
0 commit comments