Skip to content

Commit 0f62907

Browse files
authored
Merge pull request #132 from InningLog/feat/#131/member-withdraw
feat : 회원 탈퇴 기능 추가 (카카오 연동 해제 + Soft Delete) #131
2 parents cc47322 + 8c1c850 commit 0f62907

17 files changed

Lines changed: 506 additions & 3 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ jobs:
4747
docker buildx build \
4848
--platform linux/amd64 \
4949
-t ${{ secrets.ECR_REGISTRY }}/${{ secrets.ECR_REPOSITORY }}:latest \
50-
--push \
50+
--push \`
5151
.
5252
5353
- name: Deploy via SSH
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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 엔드포인트 테스트 (성공, 이미 탈퇴 케이스)

src/main/java/com/inninglog/inninglog/domain/kakao/service/KakaoService.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
package com.inninglog.inninglog.domain.kakao.service;
22

33
import com.inninglog.inninglog.domain.kakao.dto.KakaoUserInfoResDTO;
4+
import com.inninglog.inninglog.global.exception.CustomException;
5+
import com.inninglog.inninglog.global.exception.ErrorCode;
46
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
58
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.http.MediaType;
610
import org.springframework.stereotype.Service;
11+
import org.springframework.util.LinkedMultiValueMap;
12+
import org.springframework.util.MultiValueMap;
13+
import org.springframework.web.reactive.function.BodyInserters;
714
import org.springframework.web.reactive.function.client.WebClient;
15+
import org.springframework.web.reactive.function.client.WebClientResponseException;
816

17+
@Slf4j
918
@Service
1019
@RequiredArgsConstructor
1120
public class KakaoService {
@@ -16,6 +25,9 @@ public class KakaoService {
1625
@Value("${kakao.redirect_uri}")
1726
private String redirectUri;
1827

28+
@Value("${kakao.admin_key}")
29+
private String adminKey;
30+
1931
private final WebClient kakaoWebClient; // https://kauth.kakao.com
2032
private final WebClient kakaoApiClient; // https://kapi.kakao.com
2133

@@ -44,4 +56,26 @@ public KakaoUserInfoResDTO getUserInfo(String accessToken) {
4456
.bodyToMono(KakaoUserInfoResDTO.class)
4557
.block();
4658
}
59+
60+
public void unlinkKakaoUser(Long kakaoId) {
61+
try {
62+
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
63+
body.add("target_id_type", "user_id");
64+
body.add("target_id", String.valueOf(kakaoId));
65+
66+
kakaoApiClient.post()
67+
.uri("/v1/user/unlink")
68+
.header("Authorization", "KakaoAK " + adminKey)
69+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
70+
.body(BodyInserters.fromFormData(body))
71+
.retrieve()
72+
.bodyToMono(String.class)
73+
.block();
74+
75+
log.info("📌 [unlinkKakaoUser] kakaoId={} 카카오 연동 해제 완료", kakaoId);
76+
} catch (WebClientResponseException e) {
77+
log.error("📌 [unlinkKakaoUser] kakaoId={} 카카오 연동 해제 실패: {}", kakaoId, e.getMessage());
78+
throw new CustomException(ErrorCode.KAKAO_UNLINK_FAILED);
79+
}
80+
}
4781
}

src/main/java/com/inninglog/inninglog/domain/like/repository/LikeRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,7 @@ public interface LikeRepository extends JpaRepository<Like, Long> {
3232
// 마이페이지: 내가 좋아요 누른 콘텐츠 ID 조회 (최신순)
3333
@Query("SELECT l.targetId FROM Like l WHERE l.member = :member AND l.contentType = :contentType ORDER BY l.createdAt DESC")
3434
Slice<Long> findTargetIdsByMemberAndContentType(Member member, ContentType contentType, Pageable pageable);
35+
36+
// 회원 탈퇴: 해당 회원의 좋아요 전체 삭제
37+
void deleteByMember(Member member);
3538
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.inninglog.inninglog.domain.member.controller;
2+
3+
import com.inninglog.inninglog.domain.member.service.MemberWithdrawService;
4+
import com.inninglog.inninglog.global.auth.CustomUserDetails;
5+
import com.inninglog.inninglog.global.response.SuccessCode;
6+
import com.inninglog.inninglog.global.response.SuccessResponse;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
12+
import org.springframework.web.bind.annotation.DeleteMapping;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RestController;
15+
16+
@RestController
17+
@RequiredArgsConstructor
18+
@RequestMapping("/member")
19+
@Tag(name = "회원", description = "회원 관련 API")
20+
public class MemberDeleteController {
21+
22+
private final MemberWithdrawService memberWithdrawService;
23+
24+
@Operation(
25+
summary = "회원 탈퇴",
26+
description = "카카오 연동을 해제하고 회원을 탈퇴 처리합니다. 작성한 게시글/댓글은 '알 수 없는 사용자'로 표시됩니다."
27+
)
28+
@DeleteMapping("/me")
29+
public ResponseEntity<SuccessResponse<Void>> withdraw(
30+
@AuthenticationPrincipal CustomUserDetails user
31+
) {
32+
memberWithdrawService.withdraw(user.getMemberId());
33+
return ResponseEntity.ok(SuccessResponse.success(SuccessCode.WITHDRAW_SUCCESS));
34+
}
35+
}

0 commit comments

Comments
 (0)