Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c21e03b
refactor: 사용자 엔티티 수정
soo0711 Jun 11, 2026
39940b9
feat: 탈퇴 준비 기능 추가
soo0711 Jun 11, 2026
7f273bf
feat: LEAD 동아리 보유 사용자 탈퇴 에러 추가
soo0711 Jun 11, 2026
b14dddd
feat: 위드 탈퇴 유스케이스 추가
soo0711 Jun 11, 2026
1611011
refactor: 탈퇴 후 refresh token 삭제 시점 조정
soo0711 Jun 12, 2026
6b1ef09
refactor: 탈퇴 사용자 동아리 가입/생성 차단
soo0711 Jun 12, 2026
dfc08b0
refactor: 탈퇴 사용자 토큰 인증 차단
soo0711 Jun 12, 2026
f3ba9b2
feat: 위드 탈퇴 API 추가
soo0711 Jun 12, 2026
55d0b28
test: 위드 탈퇴 테스트 추가
soo0711 Jun 12, 2026
7ff3c83
refactor: 기존 사용자 탈퇴 경로 제거
soo0711 Jun 12, 2026
43c2971
refactor: 동아리 멤버 락 조회 범위 축소
soo0711 Jun 14, 2026
cbb90b6
refactor: 탈퇴 커밋 후 리프레시 토큰 삭제 재시도 추가
soo0711 Jun 14, 2026
6ff7f0e
refactor: 리프레시 토큰 재발급 시 사용자 조회 제거
soo0711 Jun 14, 2026
aa65387
refactor: 탈퇴 유스케이스 이름 변경
soo0711 Jun 14, 2026
fb7ba56
style: 린트 적용
soo0711 Jun 14, 2026
a931703
refactor: 액세스 토큰 블랙리스트 적용
soo0711 Jun 14, 2026
8c4eda0
refactor: 탈퇴 유스케이스 이름 변경
soo0711 Jun 14, 2026
c1bf2c7
refactor: 탈퇴 토큰 실패시 metric 기록
soo0711 Jun 14, 2026
2d0253d
refactor: 블랙리스트 조회 실패 로그 추가
soo0711 Jun 15, 2026
fe21d82
Merge remote-tracking branch 'origin/dev' into feat/WTH-409-위드-탈퇴-구현
soo0711 Jun 15, 2026
541d06f
refactor: 마이그레이션 쿼리 추가
soo0711 Jun 15, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.weeth.domain.file.domain.enums.FileOwnerType
import com.weeth.domain.file.domain.enums.FileStatus
import com.weeth.domain.file.domain.port.FileAccessUrlPort
import com.weeth.domain.file.domain.repository.FileRepository
import com.weeth.domain.user.application.exception.UserInActiveException
import com.weeth.domain.user.domain.repository.UserReader
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
Expand Down Expand Up @@ -63,6 +64,7 @@ class ManageClubMemberUsecase(
val club = clubRepository.getClubById(clubId)
val user =
userReader.getByIdWithLock(userId)
if (!user.isRegistered()) throw UserInActiveException()

clubMemberRepository.findByClubIdAndUserId(clubId, userId)?.let {
throw AlreadyJoinedException()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.weeth.domain.file.domain.entity.File
import com.weeth.domain.file.domain.enums.FileOwnerType
import com.weeth.domain.file.domain.enums.FileStatus
import com.weeth.domain.file.domain.repository.FileRepository
import com.weeth.domain.user.application.exception.UserInActiveException
import com.weeth.domain.user.domain.repository.UserReader
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
Expand Down Expand Up @@ -66,6 +67,7 @@ class ManageClubUseCase(

val user =
userReader.getByIdWithLock(userId)
if (!user.isRegistered()) throw UserInActiveException()
clubJoinPolicy.validateCreateLimit(userId)

val code = ClubCodePolicy.generateCode()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ interface ClubMemberRepository :
@Param("ids") ids: List<Long>,
): List<ClubMember>

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000"))
@Query(
"""
SELECT cm
FROM ClubMember cm
JOIN FETCH cm.user
WHERE cm.user.id = :userId
AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE
ORDER BY cm.id ASC
""",
)
fun findAllActiveByUserIdWithLock(
@Param("userId") userId: Long,
): List<ClubMember>

override fun findAllByClubIdAndMemberStatus(
clubId: Long,
memberStatus: MemberStatus,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,7 @@ enum class UserErrorCode(

@ExplainError("프로필 초기 설정 시 필수 필드가 누락되었을 때 발생합니다.")
PROFILE_REQUIRED_FIELDS_MISSING(20912, HttpStatus.BAD_REQUEST, "프로필 초기 설정 시 모든 필수 항목을 입력해야 합니다."),

@ExplainError("사용자가 LEAD인 활성 동아리를 보유한 상태로 위드 탈퇴를 시도할 때 발생합니다.")
USER_HAS_LEAD_CLUB(20913, HttpStatus.CONFLICT, "LEAD인 동아리가 있어 탈퇴할 수 없습니다."),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.weeth.domain.user.application.exception

import com.weeth.global.common.exception.BaseException

class UserHasLeadClubException : BaseException(UserErrorCode.USER_HAS_LEAD_CLUB)
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
package com.weeth.domain.user.application.usecase.command

import com.weeth.domain.user.domain.repository.UserReader
import com.weeth.global.auth.jwt.application.dto.JwtDto
import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor
import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase
import jakarta.servlet.http.HttpServletRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class AuthUserUseCase(
private val userReader: UserReader,
private val jwtManageUseCase: JwtManageUseCase,
private val jwtTokenExtractor: JwtTokenExtractor,
) {
@Transactional
fun leave(userId: Long) {
val user = userReader.getById(userId)
user.leave()
}

fun refreshToken(httpServletRequest: HttpServletRequest): JwtDto {
val refreshToken = jwtTokenExtractor.extractRefreshToken(httpServletRequest)
return jwtManageUseCase.reIssueToken(refreshToken)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.weeth.domain.user.application.usecase.command

import com.weeth.domain.club.domain.enums.MemberRole
import com.weeth.domain.club.domain.repository.ClubMemberRepository
import com.weeth.domain.club.domain.service.ClubActivityDeletionPolicy
import com.weeth.domain.user.application.exception.UserHasLeadClubException
import com.weeth.domain.user.domain.repository.UserReader
import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase
import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort
import io.micrometer.core.instrument.MeterRegistry
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.Clock
import java.time.LocalDateTime

@Service
class LeaveUserUseCase(
private val userReader: UserReader,
private val clubMemberRepository: ClubMemberRepository,
private val clubActivityDeletionPolicy: ClubActivityDeletionPolicy,
private val jwtManageUseCase: JwtManageUseCase,
private val accessTokenBlacklistStore: AccessTokenBlacklistStorePort,
private val meterRegistry: MeterRegistry,
private val clock: Clock,
) {
private val log = LoggerFactory.getLogger(javaClass)

@Transactional
fun execute(userId: Long) {
val now = LocalDateTime.now(clock)
val user = userReader.getByIdWithLock(userId)
val activeMembers = clubMemberRepository.findAllActiveByUserIdWithLock(userId)

if (activeMembers.any { it.memberRole == MemberRole.LEAD }) {
throw UserHasLeadClubException()
}

activeMembers.forEach { member ->
clubActivityDeletionPolicy.markMemberActivitiesDeleted(member, now)
member.leave(now)
}

user.leave(now)
revokeTokensAfterCommit(userId)
}

private fun revokeTokensAfterCommit(userId: Long) {
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() {
retryTokenRevoke("refresh token 삭제", "refresh_token_delete", userId) {
jwtManageUseCase.deleteRefreshToken(userId)
}
retryTokenRevoke("access token blacklist 등록", "access_token_blacklist", userId) {
accessTokenBlacklistStore.blacklist(userId)
}
}
},
)
}

private fun retryTokenRevoke(
actionName: String,
metricAction: String,
userId: Long,
action: () -> Unit,
) {
for (attempt in 1..TOKEN_REVOKE_ATTEMPTS) {
val result = runCatching(action)
if (result.isSuccess) break

result.onFailure { exception ->
if (attempt == TOKEN_REVOKE_ATTEMPTS) {
log.error(
"탈퇴 후 {} 최종 실패. userId={}, attempts={}",
actionName,
userId,
TOKEN_REVOKE_ATTEMPTS,
exception,
)
meterRegistry
.counter(TOKEN_REVOKE_FAILURE_METRIC, "action", metricAction)
.increment()
} else {
log.warn(
"탈퇴 후 {} 실패. userId={}, attempt={}/{}",
actionName,
userId,
attempt,
TOKEN_REVOKE_ATTEMPTS,
exception,
)
}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

companion object {
private const val TOKEN_REVOKE_ATTEMPTS = 3
private const val TOKEN_REVOKE_FAILURE_METRIC = "user.leave.token_revoke.failure"
}
}
16 changes: 15 additions & 1 deletion src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime

@Entity
@Table(name = "users")
Expand Down Expand Up @@ -64,6 +65,14 @@ class User(
var status: Status = status
private set

@Column(name = "left_at", nullable = true)
var leftAt: LocalDateTime? = null
private set

@Column(name = "hard_delete_after", nullable = true)
var hardDeleteAfter: LocalDateTime? = null
private set
Comment on lines +68 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

새 컬럼 매핑은 이번 PR에 마이그레이션이 같이 들어와야 합니다.

users.left_at, users.hard_delete_after를 엔티티에 바로 매핑했는데, PR 설명상 Flyway migration은 아직 없는 상태입니다. 이대로 배포되면 JPA가 users를 조회/저장할 때 존재하지 않는 컬럼을 참조해서 탈퇴 기능뿐 아니라 일반 사용자 로드도 깨질 수 있습니다.


@Column(nullable = false)
var termsAgreed: Boolean = false
private set
Expand All @@ -78,8 +87,11 @@ class User(
val telValue: String?
get() = tel?.value

fun leave() {
fun leave(now: LocalDateTime) {
check(status != Status.LEFT) { "이미 탈퇴한 사용자입니다." }
status = Status.LEFT
leftAt = now
hardDeleteAfter = now.plusDays(RETENTION_DAYS)
}

fun isActive(): Boolean = status == Status.ACTIVE
Expand Down Expand Up @@ -146,6 +158,8 @@ class User(
}

companion object {
private const val RETENTION_DAYS = 30L

fun create(
name: String,
email: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.weeth.domain.user.application.exception.UserErrorCode
import com.weeth.domain.user.application.usecase.command.AgreeTermsUseCase
import com.weeth.domain.user.application.usecase.command.AuthUserUseCase
import com.weeth.domain.user.application.usecase.command.CreateInquiryUseCase
import com.weeth.domain.user.application.usecase.command.LeaveUserUseCase
import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase
import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase
import com.weeth.global.auth.annotation.CurrentUser
Expand All @@ -25,6 +26,7 @@ import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
Expand All @@ -41,6 +43,7 @@ class UserController(
private val updateUserProfileUseCase: UpdateUserProfileUseCase,
private val agreeTermsUseCase: AgreeTermsUseCase,
private val createInquiryUseCase: CreateInquiryUseCase,
private val leaveUserUseCase: LeaveUserUseCase,
private val tokenCookieProvider: TokenCookieProvider,
) {
@PostMapping("/social/kakao")
Expand Down Expand Up @@ -107,6 +110,15 @@ class UserController(
return CommonResponse.success(UserResponseCode.USER_UPDATE_SUCCESS)
}

@DeleteMapping("/me")
@Operation(summary = "위드 탈퇴")
fun leave(
@Parameter(hidden = true) @CurrentUser userId: Long,
): ResponseEntity<CommonResponse<Void>> {
leaveUserUseCase.execute(userId)
return buildExpiredTokenResponse(CommonResponse.success(UserResponseCode.USER_LEFT_SUCCESS))
}

@PostMapping("/inquiries")
@Operation(summary = "문의하기")
@SecurityRequirements
Expand All @@ -127,4 +139,11 @@ class UserController(
.header(HttpHeaders.SET_COOKIE, tokenCookieProvider.createAccessTokenCookie(accessToken).toString())
.header(HttpHeaders.SET_COOKIE, tokenCookieProvider.createRefreshTokenCookie(refreshToken).toString())
.body(body)

private fun buildExpiredTokenResponse(body: CommonResponse<Void>): ResponseEntity<CommonResponse<Void>> =
ResponseEntity
.ok()
.header(HttpHeaders.SET_COOKIE, tokenCookieProvider.expireAccessTokenCookie().toString())
.header(HttpHeaders.SET_COOKIE, tokenCookieProvider.expireRefreshTokenCookie().toString())
.body(body)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ enum class UserResponseCode(
SOCIAL_LOGIN_SUCCESS(10903, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."),
USER_TERMS_AGREE_SUCCESS(10904, HttpStatus.OK, "약관 동의가 성공적으로 처리되었습니다."),
INQUIRY_SEND_SUCCESS(10905, HttpStatus.OK, "문의가 성공적으로 접수되었습니다."),
USER_LEFT_SUCCESS(10906, HttpStatus.OK, "위드 탈퇴가 완료되었습니다."),
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ class TokenCookieProvider(
path = cookieProperties.refreshPath,
)

fun expireAccessTokenCookie(): ResponseCookie =
buildCookie(
name = cookieProperties.accessTokenName,
value = "",
maxAge = Duration.ZERO,
path = cookieProperties.path,
)

fun expireRefreshTokenCookie(): ResponseCookie =
buildCookie(
name = cookieProperties.refreshTokenName,
value = "",
maxAge = Duration.ZERO,
path = cookieProperties.refreshPath,
)

private fun buildCookie(
name: String,
value: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,8 @@ class JwtManageUseCase(

return create(userId, email, tokenType)
}

fun deleteRefreshToken(userId: Long) {
refreshTokenStore.delete(userId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.weeth.global.auth.jwt.domain.port

interface AccessTokenBlacklistStorePort {
fun blacklist(userId: Long)

fun isBlacklisted(userId: Long): Boolean
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.weeth.global.auth.jwt.filter

import com.weeth.domain.user.application.exception.UserInActiveException
import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException
import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor
import com.weeth.global.auth.jwt.domain.enums.TokenType
import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort
import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider
import com.weeth.global.auth.model.AuthenticatedUser
import jakarta.servlet.FilterChain
Expand All @@ -18,6 +20,7 @@ import org.springframework.web.filter.OncePerRequestFilter
class JwtAuthenticationProcessingFilter(
private val jwtTokenProvider: JwtTokenProvider,
private val jwtTokenExtractor: JwtTokenExtractor,
private val accessTokenBlacklistStore: AccessTokenBlacklistStorePort,
) : OncePerRequestFilter() {
private val log = LoggerFactory.getLogger(javaClass)

Expand All @@ -41,6 +44,7 @@ class JwtAuthenticationProcessingFilter(

private fun saveAuthentication(accessToken: String) {
val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException()
validateAccessTokenBlacklist(claims.id)

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.

해당 방식에 대한 성능 측정 / 부하 테스트도 한 번 진행되면 좋을 것 같아요!

저도 측정해보진 못했지만 도입 전/후로 측정 데이터가 나오면 조금 더 명확하게 블랙리스트 필터링을 할지 액세스 토큰을 극단적으로 줄일지 결정이 가능할 것 같아욥
redis를 넣는 것도 부담이 될지, 혹은 큰 문제가 없을지

또 만약에 Redis에 장애가 발생한 경우에 대한 처리 방안도 필요할 것 같아용 try - catch 라거나

@soo0711 soo0711 Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

1차로 /api/v4/clubs로 로컬 부하 테스트를 진행해봤는데 p95 차이는 VU 10/50/100 기준 최대 1.79ms였습니당
현재 측정 범위에서는 Redis 조회가 큰 병목으로 보이지 않아 우선 블랙리스트 필터링은 유지해도 괜찮아 보입니다!
로컬 테스트라서 추후에 개발환경에서 반복 측정해 더 명확히 확인해보는 편이 좋을 것 같습니다용

장애가 발생한 경우에 대한 처리 방안도 추가해두겠습니닷!! 👍

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

위에 부하테스트 결과 스크린샷도 추가해두었습니당

val principal = AuthenticatedUser(claims.id, claims.email)

val role =
Expand All @@ -59,4 +63,16 @@ class JwtAuthenticationProcessingFilter(
SecurityContextHolder.getContext().authentication = authentication
MDC.put("userId", claims.id.toString())
}

private fun validateAccessTokenBlacklist(userId: Long) {
val isBlacklisted =
try {
accessTokenBlacklistStore.isBlacklisted(userId)
} catch (e: RuntimeException) {
log.error("Access token blacklist lookup failed. userId={}", userId, e)
throw e
}

if (isBlacklisted) throw UserInActiveException()
}
}
Loading