Skip to content

[Feat] 회원 탈퇴 구현 및 투표 조회시 투표 득표수 보여주도록 수정#292

Merged
hd0rable merged 57 commits into
developfrom
feat/#176-user-delete
Sep 8, 2025
Merged

[Feat] 회원 탈퇴 구현 및 투표 조회시 투표 득표수 보여주도록 수정#292
hd0rable merged 57 commits into
developfrom
feat/#176-user-delete

Conversation

@hd0rable
Copy link
Copy Markdown
Member

@hd0rable hd0rable commented Sep 4, 2025

#️⃣ 연관된 이슈

closes #176

📝 작업 내용

  • 회원 탈퇴 api를 구현했습니다.
  • 회원탈퇴에대한 흐름은 서비스 메서드 및 테스트 코드에 상세히 작성했으니 확인해주시면 감사하겠습니다.
  • 회원탈퇴 api 요청 -> 회원탈퇴 할수있는 유저인지 검증 -> 유저의 oauthId prefix 추가 -> 연관된 엔티티 삭제 -> 토큰 블랙리스트 등록
  • 워낙 연관관계가 많은 엔티티들을 한번에 삭제하려다보니 서비스코드에 삭제되는 엔티티를 나열하다보니 주석때문에 서비스가 길어지는 감이있는데 리뷰받고 머지되기전에 가독성을 향상시키도록 서비스에 있는 주석들을 최소화 해두겠습니다.
  • 회의 내용에따라 투표 조회시 투표 퍼센트가아닌 득표수를 보여주도록 수정했습니다.
  • 회원 탈퇴시 탈퇴한 회원의 토큰을 토큰의 만료시점까지 레디스에 저장하기때문에 따로 삭제하는 메서드는 작성하지않았습니다. 이때 레디스에 블랙리스트 토큰을 저장하는 시점에 이미 만료된 토큰의 유효기간을 추출할수있도록 try catch문을 사용했습니다. 이미 만료된 토큰을 레디스에 넣어도 만료되는 시점이 지났기때문에 해당 토큰은 무시되고 정상적으로 회원탈퇴 로직이 진행됩니다.
    --> 회원탈퇴를 하는 도중에 토큰이 만료되어 다시 그 유저를 로그인으로 리다이렉트 시키는것보다 위와 같이 구현하는게 더 낫다고 판단했습니다.
  • 회원 탈퇴시 유저 soft delete 시 delete가아닌 softDelete메서드를 사용한 이유는 user의 oauth2Id를 업데이트 -> save -> delete 메서드를 사용하니 db에 제대로 저장되지않는 문제가있어서 해당 방식으로 구현했습니다.

📸 스크린샷

image

💬 리뷰 요구사항

  • 구현 하면서 아래의 고민사항들이 있는데 제가 구현한 방식보다 더 좋은, 최선의 방식이있다면 리뷰부탁드립니다!!
    1.최대한 join을 사용하지 않으려고 엔티티에 직접 수를 가지고있는 관련 엔티티들을 삭제할때(팔로워 수/투표 수 etc...) 해당 엔티티 id들을 1차적으로 조회 -> 얼리 리턴 -> id들가지고 해당 엔티티들 재조회 와 같이 해당 방식으로 구현했습니다.
  1. 탈퇴한유저가 작성한 댓글, 게시글 좋아요 삭제 시에 작성한 댓글의 게시글의 댓글 수, 게시글 좋아요 수 감소를 하기 위해 각각 JOIN FETCH, JOIN을 사용하여 연관된 postJpaEntity를 조회하여 각 게시글 타입에 맞게 저장하도록 구현했습니다.
  2. 위와같이 댓글/게시글에서 게시글에 따른 분기문처리때문에 중복코드가 꽤 많이 생기는데 헬퍼서비스를 도입하는 건 어떨까요?.. 영속성 어댑터 계층에 서비스가 역의존성을 가지는건 좋지않다고생각하지만 둘다 정말 같은 역할을하는 느낌이 들어서..
  3. 블랙리스트 토큰을 저장하는 어댑터의 이름을 현재 UserTokenBlacklistRedisAdapter로 작성하고 패키지도 user아래 위치해있는데 이건 auth역할이 더 강하다고 생각하긴하는데 auth에 어댑터라는 패키지가 없다보니.. 이름이랑 패키지 구조 추천받습니다 허헣

📌 PR 진행 시 이러한 점들을 참고해 주세요

* P1 : 꼭 반영해 주세요 (Request Changes) - 이슈가 발생하거나 취약점이 발견되는 케이스 등
* P2 : 반영을 적극적으로 고려해 주시면 좋을 것 같아요 (Comment)
* P3 : 이런 방법도 있을 것 같아요~ 등의 사소한 의견입니다 (Chore)

Summary by CodeRabbit

  • New Features

    • 회원 탈퇴 API(DELETE /users) 추가 — 탈퇴 시 팔로우·저장(피드/도서)·검색기록·출석·투표(참여/작성)·댓글/좋아요·포스트(피드/레코드/투표) 등이 일괄 정리(삭제/소프트삭제)됩니다.
    • 활성 상태의 방(모집/진행) 호스트는 탈퇴 불가(명확한 오류 반환).
    • 투표 결과 표시가 퍼센트에서 개수(count) 기준으로 변경.
  • Security

    • 탈퇴 시 JWT 토큰을 블랙리스트에 등록하여 재사용 차단.
    • 요청 처리 중 인증 토큰 주입 지원(@authtoken) 추가.
  • Documentation

    • Swagger에 회원 탈퇴 응답/에러(USER_DELETE) 사례 추가.
  • Tests

    • 회원 탈퇴 통합 테스트 추가 (다수 관계의 일괄 정리 검증).

@buzz0331
Copy link
Copy Markdown
Contributor

buzz0331 commented Sep 8, 2025

  1. 위와같이 댓글/게시글에서 게시글에 따른 분기문처리때문에 중복코드가 꽤 많이 생기는데 헬퍼서비스를 도입하는 건 어떨까요?.. 영속성 어댑터 계층에 서비스가 역의존성을 가지는건 좋지않다고생각하지만 둘다 정말 같은 역할을하는 느낌이 들어서..

리뷰 답글에 말한 것처럼 방 진행도를 계산하는 로직을 RoomProgressManager쪽에다가 모으는 것도 좋을 것 같습니다~!

  1. 블랙리스트 토큰을 저장하는 어댑터의 이름을 현재 UserTokenBlacklistRedisAdapter로 작성하고 패키지도 user아래 위치해있는데 이건 auth역할이 더 강하다고 생각하긴하는데 auth에 어댑터라는 패키지가 없다보니.. 이름이랑 패키지 구조 추천받습니다 허헣

저희가 adapter가 그렇게 많은 건 아니라서 크게 문제되진 않아보입니다! 네이밍도 좋습니다!

seongjunnoh and others added 7 commits September 8, 2025 17:42
- CommentJpaRepository 에서 유저가 작성한 댓글과 연관되는 게시글을 fetch join 으로 가져올 때, 불필요한 type 조건 삭제

- 영속성 어댑터에서 posts의 commentCount 먼저 업데이트 한 후, comments 의 soft delete(벌크 jpql 및 em flush) 하도록 순서 변경
- 수정된 deleteAllByUserId 메서드가 제대로 동작함을 보여주기 위해 테스트 코드 작성
buzz0331
buzz0331 previously approved these changes Sep 8, 2025
Copy link
Copy Markdown
Contributor

@buzz0331 buzz0331 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정사항 확인했습니다~ 👍🏻 👍🏻

Comment on lines +90 to +111
@Override
public void deleteAllByUserId(Long userId) {

// 1. 탈퇴 유저가 작성한 댓글과 연관된 게시글을 JOIN FETCH로 함께 조회
List<CommentJpaEntity> commentsWithPosts = commentJpaRepository.findAllCommentsWithPostsByUserId(userId);
if (commentsWithPosts == null || commentsWithPosts.isEmpty()) {
return; //early return
}
// 2. 탈퇴한 유저의 모든 댓글, 댓글의 좋아요 삭제
commentLikeJpaRepository.deleteAllByCommentAuthorUserId(userId);
commentJpaRepository.deleteAllByUserId(userId);
// 3. 게시글 타입별로 댓글 수 감소가 필요한 게시글 Map 생성
Map<PostType, List<PostJpaEntity>> postsByType = new HashMap<>();
for (CommentJpaEntity comment : commentsWithPosts) {
PostJpaEntity post = comment.getPostJpaEntity();
post = (PostJpaEntity) Hibernate.unproxy(post); // 프록시 강제 초기화 및 타입 변경
post.setCommentCount(Math.max(0, post.getCommentCount() - 1));

// 4. 엔티티에서 직접 게시글 댓글 수 감소
PostType postType = PostType.from(post.getDtype());
postsByType.computeIfAbsent(postType, k -> new ArrayList<>()).add(post);
}
// 5. 게시글 타입별로 저장 처리
postsByType.forEach(this::savePostsJpaEntities);
}

// 2. 삭제될 댓글이 어느 Post에 몇 개씩 붙어있는지 집계 (postId 기준 추천)
Map<PostJpaEntity, Long> decMap = commentsWithPosts.stream()
.collect(Collectors.groupingBy(CommentJpaEntity::getPostJpaEntity, Collectors.counting()));


private void savePostsJpaEntities(PostType postType, List<PostJpaEntity> posts) {
switch (postType) {
case FEED:
feedJpaRepository.saveAll(posts.stream()
.filter(p -> p instanceof FeedJpaEntity)
.map(p -> (FeedJpaEntity) p)
.collect(Collectors.toList()));
break;
case RECORD:
recordJpaRepository.saveAll(posts.stream()
.filter(p -> p instanceof RecordJpaEntity)
.map(p -> (RecordJpaEntity) p)
.collect(Collectors.toList()));
break;
case VOTE:
voteJpaRepository.saveAll(posts.stream()
.filter(p -> p instanceof VoteJpaEntity)
.map(p -> (VoteJpaEntity) p)
.collect(Collectors.toList()));
voteJpaRepository.flush();
break;
// 3. 댓글 수를 집계만큼 한 번에 감소
for (PostJpaEntity p : decMap.keySet()) {
long dec = decMap.getOrDefault(p, 0L);
p.setCommentCount(Math.max(0, p.getCommentCount() - (int) dec));
}

// 4. 탈퇴한 유저의 모든 댓글, 댓글의 좋아요 삭제
commentLikeJpaRepository.deleteAllByCommentAuthorUserId(userId);
commentJpaRepository.softDeleteAllByUserId(userId);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 로직이 확실히 깔끔해졌네요 굿굿 !!

Comment on lines +3 to +7
public interface RoomStatsRow {
Long getRoomId();
Double getAvgPercentage();
Long getMemberCount();
} No newline at end of file
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3: 리뷰 답글에도 남겨두긴했는데 네이밍이 조금 애매해서 RoomAggregateProjection 같이 바꿔보는거 어떨까요?

Comment on lines +79 to +101
@Override
public void deleteAllByUserId(Long userId) {
// 방 참여 관계 삭제 (member는 진행/모집/만료, host는 만료)
// 방 멤버수 감소, 방 진행도 업데이트

// 1. 유저가 참여한 방 ID 리스트 조회
List<Long> roomIds = roomParticipantJpaRepository.findRoomIdsByUserId(userId);
if (roomIds.isEmpty()) {
return; // early return
}
// 2. 유저의 모든 방 참여 관계 일괄 삭제
roomParticipantJpaRepository.deleteAllByUserId(userId);
// 3. 해당 ID들로 JPA 엔티티 직접 조회
List<RoomJpaEntity> roomJpaEntities = roomJpaRepository.findAllByIds(roomIds);

// 4. 유저가 탈퇴 처리된 각 방에 대해 진행률 및 멤버 수 업데이트
for (RoomJpaEntity room : roomJpaEntities) {
// 현재 방의 참가자 전체 조회 (탈퇴한 유저는 이미 삭제됨)
List<RoomParticipantJpaEntity> roomParticipantEntities = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId());

// 평균 진행률 계산
double totalProgress = roomParticipantEntities.stream()
.mapToDouble(RoomParticipantJpaEntity::getUserPercentage)
.sum();
double avgProgress = roomParticipantEntities.isEmpty() ? 0.0 : (totalProgress / roomParticipantEntities.size());

// 방 정보(진행률, 멤버수) 업데이트
room.updateRoomPercentage(avgProgress);
room.setMemberCount(roomParticipantEntities.size()); // 남은 참가자 수로 설정

roomParticipantJpaRepository.softDeleteAllByUserId(userId);

// 3. 남은 ACTIVE 참여자 기준 방별 평균/인원 집계
List<RoomStatsRow> stats = roomParticipantJpaRepository.aggregateStatsByRoomIds(roomIds);

// 4. 방 정보(진행률, 멤버수) 업데이트
for (RoomStatsRow row : stats) {
roomJpaRepository.updateRoomStats(
row.getRoomId(),
row.getAvgPercentage() == null ? 0.0 : row.getAvgPercentage(),
row.getMemberCount().intValue()
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굿 확인했습니다

Comment on lines +41 to +62
LoginUser loginUser = jwtUtil.getLoginUser(token);
LocalDateTime withdrawalTime = LocalDateTime.now();
String key = makeBlacklistKey(token);
redisTemplate.opsForValue().set(key, "BLACKLISTED");

Map<String, Object> valueMap = new HashMap<>();
valueMap.put("userId", loginUser.userId());
valueMap.put("withdrawalTime", withdrawalTime);
String valueJson = null;
try {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
valueJson = mapper.writeValueAsString(valueMap);
} catch (JsonProcessingException e) {
throw new ExternalApiException(JSON_PROCESSING_ERROR);
}
redisTemplate.opsForValue().set(key, valueJson);
log.info("블랙리스트에 탈퇴한 회원 토큰 및 관련 정보 추가 - userId: {}, withdrawalTime: {}, expiration: {}",
loginUser.userId(),
withdrawalTime,
expiration
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

seongjunnoh
seongjunnoh previously approved these changes Sep 8, 2025
Copy link
Copy Markdown
Collaborator

@seongjunnoh seongjunnoh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정해주신 코드 확인했습니다!

Comment on lines -88 to +80
case FEED -> feedJpaRepository.findById(postId)
case FEED -> feedJpaRepository.findByPostId(postId)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!! 이 부분 챙겨주셨네요! 좋습니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[THIP2025-232] [feat] 회원 탈퇴 api 개발

3 participants