From c21e03b4b87e3688b7c4f887499e4144c601ea5a Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 11 Jun 2026 21:40:42 +0900 Subject: [PATCH 01/20] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weeth/domain/user/domain/entity/User.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index 8bec40f2..d883032c 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -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") @@ -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 + @Column(nullable = false) var termsAgreed: Boolean = false private set @@ -78,8 +87,15 @@ 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 leave() { + leave(LocalDateTime.now()) } fun isActive(): Boolean = status == Status.ACTIVE @@ -146,6 +162,8 @@ class User( } companion object { + private const val RETENTION_DAYS = 30L + fun create( name: String, email: String, From 39940b9fe0138630f7c4735e46cb75782d92f047 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 11 Jun 2026 21:45:18 +0900 Subject: [PATCH 02/20] =?UTF-8?q?feat:=20=ED=83=88=ED=87=B4=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/repository/ClubMemberRepository.kt | 17 +++++++++++++++++ .../application/service/TokenCookieProvider.kt | 16 ++++++++++++++++ .../jwt/application/usecase/JwtManageUseCase.kt | 4 ++++ 3 files changed, 37 insertions(+) diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index 9b0e810a..ae07779b 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -28,6 +28,23 @@ interface ClubMemberRepository : @Param("ids") ids: List, ): List + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.club + 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 + override fun findAllByClubIdAndMemberStatus( clubId: Long, memberStatus: MemberStatus, diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt index 20ed71a1..a2410e31 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt @@ -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, diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt index e8439ebb..d35dfccf 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -38,4 +38,8 @@ class JwtManageUseCase( return create(userId, email, tokenType) } + + fun deleteRefreshToken(userId: Long) { + refreshTokenStore.delete(userId) + } } From 7f273bfef02d5574635bb9febea37ef4f5397b48 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 11 Jun 2026 21:49:17 +0900 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20LEAD=20=EB=8F=99=EC=95=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EC=9C=A0=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=97=90=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weeth/domain/user/application/exception/UserErrorCode.kt | 3 +++ .../user/application/exception/UserHasLeadClubException.kt | 5 +++++ 2 files changed, 8 insertions(+) create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/UserHasLeadClubException.kt diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt index 80291859..f8fb7c12 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt @@ -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인 동아리가 있어 탈퇴할 수 없습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserHasLeadClubException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserHasLeadClubException.kt new file mode 100644 index 00000000..0e1a82aa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserHasLeadClubException.kt @@ -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) From b14dddde90726a1c15b2b1800cdf32be41eac132 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Thu, 11 Jun 2026 21:50:08 +0900 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20=EC=9C=84=EB=93=9C=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=9C=A0=EC=8A=A4=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/WithdrawUserUseCase.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt new file mode 100644 index 00000000..214eee87 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt @@ -0,0 +1,40 @@ +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 org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDateTime + +@Service +class WithdrawUserUseCase( + private val userReader: UserReader, + private val clubMemberRepository: ClubMemberRepository, + private val clubActivityDeletionPolicy: ClubActivityDeletionPolicy, + private val jwtManageUseCase: JwtManageUseCase, + private val clock: Clock, +) { + @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) + jwtManageUseCase.deleteRefreshToken(userId) + } +} From 16110110616ef77671239c3980544c9224aaa97d Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 12 Jun 2026 15:05:49 +0900 Subject: [PATCH 05/20] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=ED=9B=84=20refresh=20token=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/WithdrawUserUseCase.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt index 214eee87..19ac8fd1 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt @@ -6,8 +6,11 @@ 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 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 @@ -19,6 +22,8 @@ class WithdrawUserUseCase( private val jwtManageUseCase: JwtManageUseCase, private val clock: Clock, ) { + private val log = LoggerFactory.getLogger(javaClass) + @Transactional fun execute(userId: Long) { val now = LocalDateTime.now(clock) @@ -35,6 +40,20 @@ class WithdrawUserUseCase( } user.leave(now) - jwtManageUseCase.deleteRefreshToken(userId) + deleteRefreshTokenAfterCommit(userId) + } + + private fun deleteRefreshTokenAfterCommit(userId: Long) { + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + runCatching { + jwtManageUseCase.deleteRefreshToken(userId) + }.onFailure { e -> + log.warn("탈퇴 후 refresh token 삭제 실패. userId={}", userId, e) + } + } + }, + ) } } From 6b1ef09680c7a243a8dff2a1851f032bb639021a Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 12 Jun 2026 15:16:05 +0900 Subject: [PATCH 06/20] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85/=EC=83=9D=EC=84=B1=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/application/usecase/command/ManageClubMemberUsecase.kt | 2 ++ .../club/application/usecase/command/ManageClubUseCase.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index cf5daadc..e6e5f98c 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -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 @@ -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() diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 886a263a..9c6ace04 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -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 @@ -66,6 +67,7 @@ class ManageClubUseCase( val user = userReader.getByIdWithLock(userId) + if (!user.isRegistered()) throw UserInActiveException() clubJoinPolicy.validateCreateLimit(userId) val code = ClubCodePolicy.generateCode() From dfc08b0bb8ec430eb9934ecea84d3207453cebb0 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 12 Jun 2026 15:29:43 +0900 Subject: [PATCH 07/20] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=86=A0=ED=81=B0=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/command/AuthUserUseCase.kt | 5 +++++ .../jwt/filter/JwtAuthenticationProcessingFilter.kt | 9 +++++++++ .../kotlin/com/weeth/global/config/SecurityConfig.kt | 12 ++++++++---- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt index 2936caf0..43993c2c 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt @@ -1,7 +1,9 @@ package com.weeth.domain.user.application.usecase.command +import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase import jakarta.servlet.http.HttpServletRequest @@ -22,6 +24,9 @@ class AuthUserUseCase( fun refreshToken(httpServletRequest: HttpServletRequest): JwtDto { val refreshToken = jwtTokenExtractor.extractRefreshToken(httpServletRequest) + val userId = jwtTokenExtractor.extractId(refreshToken) ?: throw InvalidTokenException() + val user = userReader.getById(userId) + if (user.isBannedOrLeft()) throw UserInActiveException() return jwtManageUseCase.reIssueToken(refreshToken) } } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt index 9cb494ce..1816b788 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -1,5 +1,7 @@ package com.weeth.global.auth.jwt.filter +import com.weeth.domain.user.application.exception.UserInActiveException +import com.weeth.domain.user.domain.repository.UserReader 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 @@ -18,6 +20,7 @@ import org.springframework.web.filter.OncePerRequestFilter class JwtAuthenticationProcessingFilter( private val jwtTokenProvider: JwtTokenProvider, private val jwtTokenExtractor: JwtTokenExtractor, + private val userReader: UserReader, ) : OncePerRequestFilter() { private val log = LoggerFactory.getLogger(javaClass) @@ -41,6 +44,7 @@ class JwtAuthenticationProcessingFilter( private fun saveAuthentication(accessToken: String) { val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() + validateUserStatus(claims.id) val principal = AuthenticatedUser(claims.id, claims.email) val role = @@ -59,4 +63,9 @@ class JwtAuthenticationProcessingFilter( SecurityContextHolder.getContext().authentication = authentication MDC.put("userId", claims.id.toString()) } + + private fun validateUserStatus(userId: Long) { + val user = userReader.getById(userId) + if (user.isBannedOrLeft()) throw UserInActiveException() + } } diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 4bd4f248..507c89fa 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -1,5 +1,6 @@ package com.weeth.global.config +import com.weeth.domain.user.domain.repository.UserReader import com.weeth.global.auth.authentication.CustomAccessDeniedHandler import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor @@ -30,7 +31,10 @@ class SecurityConfig( private val customAccessDeniedHandler: CustomAccessDeniedHandler, ) { @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain = + fun filterChain( + http: HttpSecurity, + jwtAuthenticationProcessingFilter: JwtAuthenticationProcessingFilter, + ): SecurityFilterChain = http .formLogin { it.disable() } .httpBasic { it.disable() } @@ -75,7 +79,7 @@ class SecurityConfig( exceptionHandling .authenticationEntryPoint(customAuthenticationEntryPoint) .accessDeniedHandler(customAccessDeniedHandler) - }.addFilterBefore(jwtAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter::class.java) + }.addFilterBefore(jwtAuthenticationProcessingFilter, UsernamePasswordAuthenticationFilter::class.java) .build() @Bean @@ -105,6 +109,6 @@ class SecurityConfig( } @Bean - fun jwtAuthenticationProcessingFilter(): JwtAuthenticationProcessingFilter = - JwtAuthenticationProcessingFilter(jwtTokenProvider, jwtTokenExtractor) + fun jwtAuthenticationProcessingFilter(userReader: UserReader): JwtAuthenticationProcessingFilter = + JwtAuthenticationProcessingFilter(jwtTokenProvider, jwtTokenExtractor, userReader) } From f3ba9b2cd3d6e11fa124a383af641fdcd453bf9f Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 12 Jun 2026 15:36:24 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20=EC=9C=84=EB=93=9C=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/presentation/UserController.kt | 19 +++++++++++++++++++ .../user/presentation/UserResponseCode.kt | 1 + 2 files changed, 20 insertions(+) diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index 94f56fe6..e241469e 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -11,6 +11,7 @@ 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.SocialLoginUseCase import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase +import com.weeth.domain.user.application.usecase.command.WithdrawUserUseCase import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.exception.JwtErrorCode @@ -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 @@ -41,6 +43,7 @@ class UserController( private val updateUserProfileUseCase: UpdateUserProfileUseCase, private val agreeTermsUseCase: AgreeTermsUseCase, private val createInquiryUseCase: CreateInquiryUseCase, + private val withdrawUserUseCase: WithdrawUserUseCase, private val tokenCookieProvider: TokenCookieProvider, ) { @PostMapping("/social/kakao") @@ -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> { + withdrawUserUseCase.execute(userId) + return buildExpiredTokenResponse(CommonResponse.success(UserResponseCode.USER_LEFT_SUCCESS)) + } + @PostMapping("/inquiries") @Operation(summary = "문의하기") @SecurityRequirements @@ -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): ResponseEntity> = + ResponseEntity + .ok() + .header(HttpHeaders.SET_COOKIE, tokenCookieProvider.expireAccessTokenCookie().toString()) + .header(HttpHeaders.SET_COOKIE, tokenCookieProvider.expireRefreshTokenCookie().toString()) + .body(body) } diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt index c1776a65..58179616 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt @@ -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, "위드 탈퇴가 완료되었습니다."), } From 55d0b28837b574318db3c8db82390ddb1b59fd72 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 12 Jun 2026 16:06:59 +0900 Subject: [PATCH 09/20] =?UTF-8?q?test:=20=EC=9C=84=EB=93=9C=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManageClubMemberUseCaseTest.kt | 30 +++- .../usecase/command/ManageClubUseCaseTest.kt | 32 ++++- .../usecase/command/AuthUserUseCaseTest.kt | 28 ++++ .../command/WithdrawUserUseCaseTest.kt | 130 ++++++++++++++++++ .../domain/user/domain/entity/UserTest.kt | 22 +++ .../user/presentation/UserControllerTest.kt | 87 ++++++++++++ .../service/TokenCookieProviderTest.kt | 38 +++++ .../JwtAuthenticationProcessingFilterTest.kt | 30 +++- 8 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index 4104fe28..1f168a13 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -27,6 +27,7 @@ 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.file.fixture.FileTestFixture +import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -380,7 +381,7 @@ class ManageClubMemberUseCaseTest : context("이미 USER로 1개 동아리에 가입한 사용자가 가입 시도하는 경우") { it("ClubJoinLimitExceededException이 발생한다") { val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") - val user = UserTestFixture.createActiveUser1() + val user = UserTestFixture.createRegisteredUser() every { clubRepository.getClubById(1L) } returns targetClub every { userReader.getByIdWithLock(10L) } returns user @@ -402,7 +403,7 @@ class ManageClubMemberUseCaseTest : context("LEAD로 1개 동아리를 생성한 사용자가 USER로 가입 시도하는 경우") { it("역할이 다르므로 가입에 성공한다") { val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") - val user = UserTestFixture.createActiveUser1() + val user = UserTestFixture.createRegisteredUser() every { clubRepository.getClubById(1L) } returns targetClub every { userReader.getByIdWithLock(10L) } returns user @@ -418,5 +419,30 @@ class ManageClubMemberUseCaseTest : verify(exactly = 1) { clubMemberRepository.save(any()) } } } + + context("탈퇴 사용자가 가입 시도하는 경우") { + it("UserInActiveException이 발생하고 가입 처리를 진행하지 않는다") { + val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") + val user = + UserTestFixture + .createRegisteredUser() + .apply { leave(LocalDateTime.of(2026, 6, 12, 12, 0)) } + + every { clubRepository.getClubById(1L) } returns targetClub + every { userReader.getByIdWithLock(10L) } returns user + + shouldThrow { + useCase.join( + clubId = 1L, + userId = 10L, + request = ClubJoinRequest(code = "JOIN-CODE"), + ) + } + + verify(exactly = 0) { clubMemberRepository.findByClubIdAndUserId(any(), any()) } + verify(exactly = 0) { clubJoinPolicy.validateJoinLimit(any()) } + verify(exactly = 0) { clubMemberRepository.save(any()) } + } + } } }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index 3fb76e1b..d2bd81c5 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -25,6 +25,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 com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -37,6 +38,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import java.time.LocalDateTime class ManageClubUseCaseTest : DescribeSpec({ @@ -95,7 +97,7 @@ class ManageClubUseCaseTest : } describe("create") { - val user = UserTestFixture.createActiveUser1() + val user = UserTestFixture.createRegisteredUser() context("N기 동아리를 개설하는 경우") { it("1기부터 N기까지 Cardinal이 생성되며, 마지막 기수만 IN_PROGRESS이다") { @@ -291,6 +293,34 @@ class ManageClubUseCaseTest : verify(exactly = 0) { cardinalRepository.saveAll(any>()) } } } + + context("탈퇴 사용자가 동아리 생성을 시도하는 경우") { + it("UserInActiveException이 발생하고 생성 처리를 진행하지 않는다") { + val leftUser = + UserTestFixture + .createRegisteredUser() + .apply { leave(LocalDateTime.of(2026, 6, 12, 12, 0)) } + every { userReader.getByIdWithLock(10L) } returns leftUser + + shouldThrow { + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + } + + verify(exactly = 0) { clubJoinPolicy.validateCreateLimit(any()) } + verify(exactly = 0) { clubRepository.save(any()) } + verify(exactly = 0) { clubMemberRepository.save(any()) } + verify(exactly = 0) { cardinalRepository.saveAll(any>()) } + } + } } describe("update") { diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt index ca822e46..b1647cd2 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt @@ -1,16 +1,21 @@ package com.weeth.domain.user.application.usecase.command +import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture 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 io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk +import io.mockk.verify import jakarta.servlet.http.HttpServletRequest +import java.time.LocalDateTime class AuthUserUseCaseTest : DescribeSpec({ @@ -25,6 +30,10 @@ class AuthUserUseCaseTest : jwtTokenExtractor, ) + beforeTest { + clearMocks(userReader, jwtManageUseCase, jwtTokenExtractor) + } + describe("leave") { it("회원 탈퇴 시 상태를 LEFT로 변경한다") { val user = UserTestFixture.createActiveUser1(1L) @@ -40,6 +49,8 @@ class AuthUserUseCaseTest : it("요청에서 refresh token을 추출해 재발급한다") { val servletRequest = mockk() every { jwtTokenExtractor.extractRefreshToken(servletRequest) } returns "refresh-token" + every { jwtTokenExtractor.extractId("refresh-token") } returns 1L + every { userReader.getById(1L) } returns UserTestFixture.createActiveUser1(1L) every { jwtManageUseCase.reIssueToken("refresh-token") } returns JwtDto("new-access", "new-refresh") val result = useCase.refreshToken(servletRequest) @@ -47,5 +58,22 @@ class AuthUserUseCaseTest : result.accessToken shouldBe "new-access" result.refreshToken shouldBe "new-refresh" } + + it("LEFT 사용자의 refresh token이면 재발급하지 않는다") { + val servletRequest = mockk() + val user = + UserTestFixture + .createActiveUser1(1L) + .apply { leave(LocalDateTime.of(2026, 6, 12, 12, 0)) } + every { jwtTokenExtractor.extractRefreshToken(servletRequest) } returns "refresh-token" + every { jwtTokenExtractor.extractId("refresh-token") } returns 1L + every { userReader.getById(1L) } returns user + + shouldThrow { + useCase.refreshToken(servletRequest) + } + + verify(exactly = 0) { jwtManageUseCase.reIssueToken(any()) } + } } }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt new file mode 100644 index 00000000..218c21bd --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt @@ -0,0 +1,130 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.service.ClubActivityDeletionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.user.application.exception.UserHasLeadClubException +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.springframework.transaction.support.TransactionSynchronizationManager +import java.time.Clock +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +class WithdrawUserUseCaseTest : + DescribeSpec({ + val userReader = mockk() + val clubMemberRepository = mockk() + val clubActivityDeletionPolicy = mockk() + val jwtManageUseCase = mockk() + val clock = Clock.fixed(Instant.parse("2026-06-12T03:00:00Z"), ZoneId.of("Asia/Seoul")) + val useCase = + WithdrawUserUseCase( + userReader = userReader, + clubMemberRepository = clubMemberRepository, + clubActivityDeletionPolicy = clubActivityDeletionPolicy, + jwtManageUseCase = jwtManageUseCase, + clock = clock, + ) + + beforeTest { + clearMocks(userReader, clubMemberRepository, clubActivityDeletionPolicy, jwtManageUseCase) + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization() + } + } + + afterTest { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization() + } + } + + describe("execute") { + it("ACTIVE 멤버십이 없으면 사용자만 탈퇴하고 커밋 후 refresh token을 삭제한다") { + val user = UserTestFixture.createRegisteredUser(1L) + val now = LocalDateTime.now(clock) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + justRun { jwtManageUseCase.deleteRefreshToken(1L) } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + user.status shouldBe Status.LEFT + user.leftAt shouldBe now + user.hardDeleteAfter shouldBe now.plusDays(30) + verify(exactly = 0) { jwtManageUseCase.deleteRefreshToken(any()) } + + TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } + + verify(exactly = 1) { jwtManageUseCase.deleteRefreshToken(1L) } + } + + it("USER와 ADMIN ACTIVE 멤버십을 모두 탈퇴 처리한다") { + val user = UserTestFixture.createRegisteredUser(1L) + val userMember = + ClubMemberTestFixture.createActiveMember( + id = 10L, + user = user, + memberRole = MemberRole.USER, + ) + val adminMember = + ClubMemberTestFixture.createActiveMember( + id = 11L, + user = user, + memberRole = MemberRole.ADMIN, + ) + val now = LocalDateTime.now(clock) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns listOf(userMember, adminMember) + justRun { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + justRun { jwtManageUseCase.deleteRefreshToken(1L) } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + userMember.memberStatus shouldBe MemberStatus.LEFT + userMember.leftAt shouldBe now + adminMember.memberStatus shouldBe MemberStatus.LEFT + adminMember.leftAt shouldBe now + user.status shouldBe Status.LEFT + verify(exactly = 1) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(userMember, now) } + verify(exactly = 1) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(adminMember, now) } + } + + it("ACTIVE LEAD 멤버십이 있으면 탈퇴를 차단하고 상태를 변경하지 않는다") { + val user = UserTestFixture.createRegisteredUser(1L) + val leadMember = + ClubMemberTestFixture.createActiveMember( + id = 10L, + user = user, + memberRole = MemberRole.LEAD, + ) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns listOf(leadMember) + + shouldThrow { + useCase.execute(1L) + } + + user.status shouldBe Status.ACTIVE + leadMember.memberStatus shouldBe MemberStatus.ACTIVE + verify(exactly = 0) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + verify(exactly = 0) { jwtManageUseCase.deleteRefreshToken(any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt index 1f6a84de..ceae17dc 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -8,6 +8,7 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.shouldBe +import java.time.LocalDateTime class UserTest : StringSpec({ @@ -24,6 +25,27 @@ class UserTest : user.status shouldBe Status.LEFT } + "leave(now)는 탈퇴 상태와 삭제 예정일을 기록한다" { + val user = User.create(name = "test", email = "test@test.com", status = Status.ACTIVE) + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + + user.leave(now) + + user.status shouldBe Status.LEFT + user.leftAt shouldBe now + user.hardDeleteAfter shouldBe now.plusDays(30) + } + + "이미 LEFT 상태인 사용자가 leave(now)를 호출하면 예외가 발생한다" { + val user = User.create(name = "test", email = "test@test.com", status = Status.ACTIVE) + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + user.leave(now) + + shouldThrow { + user.leave(now.plusDays(1)) + } + } + "User.create 기본 status는 WAITING이다" { val user = User.create(name = "test", email = "test@test.com") diff --git a/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt b/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt new file mode 100644 index 00000000..2cde34cc --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt @@ -0,0 +1,87 @@ +package com.weeth.domain.user.presentation + +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.SocialLoginUseCase +import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase +import com.weeth.domain.user.application.usecase.command.WithdrawUserUseCase +import com.weeth.global.auth.jwt.application.service.TokenCookieProvider +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.mockk.clearMocks +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseCookie + +class UserControllerTest : + DescribeSpec({ + val authUserUseCase = mockk(relaxed = true) + val socialLoginUseCase = mockk(relaxed = true) + val updateUserProfileUseCase = mockk(relaxed = true) + val agreeTermsUseCase = mockk(relaxed = true) + val createInquiryUseCase = mockk(relaxed = true) + val withdrawUserUseCase = mockk() + val tokenCookieProvider = mockk() + val controller = + UserController( + authUserUseCase = authUserUseCase, + socialLoginUseCase = socialLoginUseCase, + updateUserProfileUseCase = updateUserProfileUseCase, + agreeTermsUseCase = agreeTermsUseCase, + createInquiryUseCase = createInquiryUseCase, + withdrawUserUseCase = withdrawUserUseCase, + tokenCookieProvider = tokenCookieProvider, + ) + + beforeTest { + clearMocks( + authUserUseCase, + socialLoginUseCase, + updateUserProfileUseCase, + agreeTermsUseCase, + createInquiryUseCase, + withdrawUserUseCase, + tokenCookieProvider, + ) + } + + fun everyExpireCookies() { + io.mockk.every { tokenCookieProvider.expireAccessTokenCookie() } returns + ResponseCookie + .from("access_token", "") + .path("/") + .maxAge(0) + .build() + io.mockk.every { tokenCookieProvider.expireRefreshTokenCookie() } returns + ResponseCookie + .from("refresh_token", "") + .path("/api/v4/users/social/refresh") + .maxAge(0) + .build() + } + + describe("leave") { + it("위드 탈퇴를 수행하고 access/refresh token 쿠키를 만료한다") { + justRun { withdrawUserUseCase.execute(1L) } + everyExpireCookies() + + val response = controller.leave(1L) + + response.statusCode.is2xxSuccessful shouldBe true + response.body?.code shouldBe UserResponseCode.USER_LEFT_SUCCESS.code + response.body?.message shouldBe UserResponseCode.USER_LEFT_SUCCESS.message + val cookies = response.headers[HttpHeaders.SET_COOKIE].orEmpty() + cookies shouldHaveSize 2 + cookies[0] shouldContain "access_token=" + cookies[0] shouldContain "Max-Age=0" + cookies[1] shouldContain "refresh_token=" + cookies[1] shouldContain "Max-Age=0" + verify(exactly = 1) { withdrawUserUseCase.execute(1L) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt index 459ab50b..a0ba00fa 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt @@ -83,4 +83,42 @@ class TokenCookieProviderTest : cookie.sameSite shouldBe "None" } } + + describe("expireAccessTokenCookie") { + it("access token 쿠키를 같은 이름과 path로 만료한다") { + val cookieProperties = + CookieProperties( + accessTokenName = "access_token", + refreshTokenName = "refresh_token", + path = "/", + ) + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.expireAccessTokenCookie() + + cookie.name shouldBe "access_token" + cookie.value shouldBe "" + cookie.maxAge.seconds shouldBe 0L + cookie.path shouldBe "/" + } + } + + describe("expireRefreshTokenCookie") { + it("refresh token 쿠키를 같은 이름과 refresh path로 만료한다") { + val cookieProperties = + CookieProperties( + accessTokenName = "access_token", + refreshTokenName = "refresh_token", + refreshPath = "/api/v4/users/social/refresh", + ) + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.expireRefreshTokenCookie() + + cookie.name shouldBe "refresh_token" + cookie.value shouldBe "" + cookie.maxAge.seconds shouldBe 0L + cookie.path shouldBe "/api/v4/users/social/refresh" + } + } }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt index 4278102a..8f94db78 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -1,5 +1,7 @@ package com.weeth.global.auth.jwt.filter +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture 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.service.JwtTokenProvider @@ -16,16 +18,18 @@ import org.springframework.mock.web.MockFilterChain import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse import org.springframework.security.core.context.SecurityContextHolder +import java.time.LocalDateTime class JwtAuthenticationProcessingFilterTest : DescribeSpec({ val jwtProvider = mockk() val jwtService = mockk() - val filter = JwtAuthenticationProcessingFilter(jwtProvider, jwtService) + val userReader = mockk() + val filter = JwtAuthenticationProcessingFilter(jwtProvider, jwtService, userReader) beforeTest { SecurityContextHolder.clearContext() - clearMocks(jwtProvider, jwtService) + clearMocks(jwtProvider, jwtService, userReader) } afterTest { @@ -42,6 +46,7 @@ class JwtAuthenticationProcessingFilterTest : every { jwtProvider.validate("access-token") } just runs every { jwtService.extractClaims("access-token") } returns JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", TokenType.ACCESS) + every { userReader.getById(1L) } returns UserTestFixture.createActiveUser1(1L) filter.doFilter(request, response, chain) @@ -63,6 +68,7 @@ class JwtAuthenticationProcessingFilterTest : every { jwtProvider.validate("temp-token") } just runs every { jwtService.extractClaims("temp-token") } returns JwtTokenExtractor.TokenClaims(2L, "new@weeth.com", TokenType.TEMPORARY) + every { userReader.getById(2L) } returns UserTestFixture.createWaitingUser1(2L) filter.doFilter(request, response, chain) @@ -98,5 +104,25 @@ class JwtAuthenticationProcessingFilterTest : SecurityContextHolder.getContext().authentication shouldBe null } + + it("LEFT 사용자의 토큰이면 인증을 저장하지 않는다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v4/users/me" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + val user = + UserTestFixture + .createActiveUser1(1L) + .apply { leave(LocalDateTime.of(2026, 6, 12, 12, 0)) } + + every { jwtService.extractAccessToken(request) } returns "access-token" + every { jwtProvider.validate("access-token") } just runs + every { jwtService.extractClaims("access-token") } returns + JwtTokenExtractor.TokenClaims(1L, "left@weeth.com", TokenType.ACCESS) + every { userReader.getById(1L) } returns user + + filter.doFilter(request, response, chain) + + SecurityContextHolder.getContext().authentication shouldBe null + } } }) From 7ff3c83e1903a2dd4c0822b4eb5b7da82005e9ae Mon Sep 17 00:00:00 2001 From: soo0711 Date: Fri, 12 Jun 2026 16:12:22 +0900 Subject: [PATCH 10/20] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=83=88=ED=87=B4=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/command/AuthUserUseCase.kt | 7 ------- .../com/weeth/domain/user/domain/entity/User.kt | 4 ---- .../usecase/command/AuthUserUseCaseTest.kt | 12 ------------ .../com/weeth/domain/user/domain/entity/UserTest.kt | 4 ++-- 4 files changed, 2 insertions(+), 25 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt index 43993c2c..6acf8213 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt @@ -8,7 +8,6 @@ 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( @@ -16,12 +15,6 @@ class AuthUserUseCase( 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) val userId = jwtTokenExtractor.extractId(refreshToken) ?: throw InvalidTokenException() diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index d883032c..f6640f2d 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -94,10 +94,6 @@ class User( hardDeleteAfter = now.plusDays(RETENTION_DAYS) } - fun leave() { - leave(LocalDateTime.now()) - } - fun isActive(): Boolean = status == Status.ACTIVE fun isInactive(): Boolean = !isActive() diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt index b1647cd2..b37c52ca 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt @@ -1,7 +1,6 @@ package com.weeth.domain.user.application.usecase.command import com.weeth.domain.user.application.exception.UserInActiveException -import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import com.weeth.global.auth.jwt.application.dto.JwtDto @@ -34,17 +33,6 @@ class AuthUserUseCaseTest : clearMocks(userReader, jwtManageUseCase, jwtTokenExtractor) } - describe("leave") { - it("회원 탈퇴 시 상태를 LEFT로 변경한다") { - val user = UserTestFixture.createActiveUser1(1L) - every { userReader.getById(1L) } returns user - - useCase.leave(1L) - - user.status shouldBe Status.LEFT - } - } - describe("refreshToken") { it("요청에서 refresh token을 추출해 재발급한다") { val servletRequest = mockk() diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt index ceae17dc..8b04dc87 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -21,7 +21,7 @@ class UserTest : user.ban() user.status shouldBe Status.BANNED - user.leave() + user.leave(LocalDateTime.of(2026, 6, 12, 12, 0)) user.status shouldBe Status.LEFT } @@ -150,7 +150,7 @@ class UserTest : user.isBannedOrLeft() shouldBe true user.accept() - user.leave() + user.leave(LocalDateTime.of(2026, 6, 12, 12, 0)) user.isBannedOrLeft() shouldBe true } From 43c297110baae270e6385494edf294264ffd4258 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Sun, 14 Jun 2026 17:20:44 +0900 Subject: [PATCH 11/20] =?UTF-8?q?refactor:=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=20=EB=A9=A4=EB=B2=84=20=EB=9D=BD=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weeth/domain/club/domain/repository/ClubMemberRepository.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index ae07779b..63d7a82e 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -34,7 +34,6 @@ interface ClubMemberRepository : """ SELECT cm FROM ClubMember cm - JOIN FETCH cm.club JOIN FETCH cm.user WHERE cm.user.id = :userId AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE From cbb90b6fd479f39f391b24a66833c6e3b64afd22 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Sun, 14 Jun 2026 17:40:19 +0900 Subject: [PATCH 12/20] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=20=ED=9B=84=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EC=82=AD=EC=A0=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/WithdrawUserUseCase.kt | 23 +++++++++++++++---- .../command/WithdrawUserUseCaseTest.kt | 19 +++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt index 19ac8fd1..cb95a007 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt @@ -47,13 +47,28 @@ class WithdrawUserUseCase( TransactionSynchronizationManager.registerSynchronization( object : TransactionSynchronization { override fun afterCommit() { - runCatching { - jwtManageUseCase.deleteRefreshToken(userId) - }.onFailure { e -> - log.warn("탈퇴 후 refresh token 삭제 실패. userId={}", userId, e) + repeat(REFRESH_TOKEN_DELETE_ATTEMPTS) { index -> + runCatching { + jwtManageUseCase.deleteRefreshToken(userId) + }.onSuccess { + return + }.onFailure { e -> + val attempt = index + 1 + log.warn( + "탈퇴 후 refresh token 삭제 실패. userId={}, attempt={}/{}", + userId, + attempt, + REFRESH_TOKEN_DELETE_ATTEMPTS, + e, + ) + } } } }, ) } + + companion object { + private const val REFRESH_TOKEN_DELETE_ATTEMPTS = 3 + } } diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt index 218c21bd..670c04fb 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt @@ -74,6 +74,25 @@ class WithdrawUserUseCaseTest : verify(exactly = 1) { jwtManageUseCase.deleteRefreshToken(1L) } } + it("커밋 후 refresh token 삭제가 일시 실패하면 재시도한다") { + val user = UserTestFixture.createRegisteredUser(1L) + var attempts = 0 + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + every { jwtManageUseCase.deleteRefreshToken(1L) } answers { + attempts++ + if (attempts < 3) throw RuntimeException("temporary redis failure") + } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } + + attempts shouldBe 3 + verify(exactly = 3) { jwtManageUseCase.deleteRefreshToken(1L) } + } + it("USER와 ADMIN ACTIVE 멤버십을 모두 탈퇴 처리한다") { val user = UserTestFixture.createRegisteredUser(1L) val userMember = From 6ff7f0efbcf5cd443c32e6ecb868173394113915 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Sun, 14 Jun 2026 18:03:55 +0900 Subject: [PATCH 13/20] =?UTF-8?q?refactor:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/AuthUserUseCase.kt | 7 ---- .../usecase/command/AuthUserUseCaseTest.kt | 33 +++---------------- 2 files changed, 4 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt index 6acf8213..c49998c2 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt @@ -1,9 +1,6 @@ package com.weeth.domain.user.application.usecase.command -import com.weeth.domain.user.application.exception.UserInActiveException -import com.weeth.domain.user.domain.repository.UserReader import com.weeth.global.auth.jwt.application.dto.JwtDto -import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase import jakarta.servlet.http.HttpServletRequest @@ -11,15 +8,11 @@ import org.springframework.stereotype.Service @Service class AuthUserUseCase( - private val userReader: UserReader, private val jwtManageUseCase: JwtManageUseCase, private val jwtTokenExtractor: JwtTokenExtractor, ) { fun refreshToken(httpServletRequest: HttpServletRequest): JwtDto { val refreshToken = jwtTokenExtractor.extractRefreshToken(httpServletRequest) - val userId = jwtTokenExtractor.extractId(refreshToken) ?: throw InvalidTokenException() - val user = userReader.getById(userId) - if (user.isBannedOrLeft()) throw UserInActiveException() return jwtManageUseCase.reIssueToken(refreshToken) } } diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt index b37c52ca..2621ba5b 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt @@ -1,12 +1,8 @@ package com.weeth.domain.user.application.usecase.command -import com.weeth.domain.user.application.exception.UserInActiveException -import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.fixture.UserTestFixture 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 io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.clearMocks @@ -14,54 +10,33 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import jakarta.servlet.http.HttpServletRequest -import java.time.LocalDateTime class AuthUserUseCaseTest : DescribeSpec({ - val userReader = mockk() val jwtManageUseCase = mockk() val jwtTokenExtractor = mockk() val useCase = AuthUserUseCase( - userReader, - jwtManageUseCase, - jwtTokenExtractor, + jwtManageUseCase = jwtManageUseCase, + jwtTokenExtractor = jwtTokenExtractor, ) beforeTest { - clearMocks(userReader, jwtManageUseCase, jwtTokenExtractor) + clearMocks(jwtManageUseCase, jwtTokenExtractor) } describe("refreshToken") { it("요청에서 refresh token을 추출해 재발급한다") { val servletRequest = mockk() every { jwtTokenExtractor.extractRefreshToken(servletRequest) } returns "refresh-token" - every { jwtTokenExtractor.extractId("refresh-token") } returns 1L - every { userReader.getById(1L) } returns UserTestFixture.createActiveUser1(1L) every { jwtManageUseCase.reIssueToken("refresh-token") } returns JwtDto("new-access", "new-refresh") val result = useCase.refreshToken(servletRequest) result.accessToken shouldBe "new-access" result.refreshToken shouldBe "new-refresh" - } - - it("LEFT 사용자의 refresh token이면 재발급하지 않는다") { - val servletRequest = mockk() - val user = - UserTestFixture - .createActiveUser1(1L) - .apply { leave(LocalDateTime.of(2026, 6, 12, 12, 0)) } - every { jwtTokenExtractor.extractRefreshToken(servletRequest) } returns "refresh-token" - every { jwtTokenExtractor.extractId("refresh-token") } returns 1L - every { userReader.getById(1L) } returns user - - shouldThrow { - useCase.refreshToken(servletRequest) - } - - verify(exactly = 0) { jwtManageUseCase.reIssueToken(any()) } + verify(exactly = 1) { jwtManageUseCase.reIssueToken("refresh-token") } } } }) From aa65387fd728af82046e43514abc97a72bdbdf76 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Sun, 14 Jun 2026 18:06:26 +0900 Subject: [PATCH 14/20] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EC=9C=A0=EC=8A=A4=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{WithdrawUserUseCase.kt => LeaveUserUseCase.kt} | 2 +- .../weeth/domain/user/presentation/UserController.kt | 6 +++--- .../usecase/command/WithdrawUserUseCaseTest.kt | 2 +- .../domain/user/presentation/UserControllerTest.kt | 12 ++++++------ 4 files changed, 11 insertions(+), 11 deletions(-) rename src/main/kotlin/com/weeth/domain/user/application/usecase/command/{WithdrawUserUseCase.kt => LeaveUserUseCase.kt} (99%) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt similarity index 99% rename from src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt rename to src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt index cb95a007..26d12cc8 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt @@ -15,7 +15,7 @@ import java.time.Clock import java.time.LocalDateTime @Service -class WithdrawUserUseCase( +class LeaveUserUseCase( private val userReader: UserReader, private val clubMemberRepository: ClubMemberRepository, private val clubActivityDeletionPolicy: ClubActivityDeletionPolicy, diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index e241469e..645b6cdb 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -11,7 +11,7 @@ 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.SocialLoginUseCase import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase -import com.weeth.domain.user.application.usecase.command.WithdrawUserUseCase +import com.weeth.domain.user.application.usecase.command.LeaveUserUseCase import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.exception.JwtErrorCode @@ -43,7 +43,7 @@ class UserController( private val updateUserProfileUseCase: UpdateUserProfileUseCase, private val agreeTermsUseCase: AgreeTermsUseCase, private val createInquiryUseCase: CreateInquiryUseCase, - private val withdrawUserUseCase: WithdrawUserUseCase, + private val leaveUserUseCase: LeaveUserUseCase, private val tokenCookieProvider: TokenCookieProvider, ) { @PostMapping("/social/kakao") @@ -115,7 +115,7 @@ class UserController( fun leave( @Parameter(hidden = true) @CurrentUser userId: Long, ): ResponseEntity> { - withdrawUserUseCase.execute(userId) + leaveUserUseCase.execute(userId) return buildExpiredTokenResponse(CommonResponse.success(UserResponseCode.USER_LEFT_SUCCESS)) } diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt index 670c04fb..ecbc5b03 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt @@ -32,7 +32,7 @@ class WithdrawUserUseCaseTest : val jwtManageUseCase = mockk() val clock = Clock.fixed(Instant.parse("2026-06-12T03:00:00Z"), ZoneId.of("Asia/Seoul")) val useCase = - WithdrawUserUseCase( + LeaveUserUseCase( userReader = userReader, clubMemberRepository = clubMemberRepository, clubActivityDeletionPolicy = clubActivityDeletionPolicy, diff --git a/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt b/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt index 2cde34cc..9e271b13 100644 --- a/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt @@ -5,7 +5,7 @@ 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.SocialLoginUseCase import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase -import com.weeth.domain.user.application.usecase.command.WithdrawUserUseCase +import com.weeth.domain.user.application.usecase.command.LeaveUserUseCase import com.weeth.global.auth.jwt.application.service.TokenCookieProvider import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize @@ -25,7 +25,7 @@ class UserControllerTest : val updateUserProfileUseCase = mockk(relaxed = true) val agreeTermsUseCase = mockk(relaxed = true) val createInquiryUseCase = mockk(relaxed = true) - val withdrawUserUseCase = mockk() + val leaveUserUseCase = mockk() val tokenCookieProvider = mockk() val controller = UserController( @@ -34,7 +34,7 @@ class UserControllerTest : updateUserProfileUseCase = updateUserProfileUseCase, agreeTermsUseCase = agreeTermsUseCase, createInquiryUseCase = createInquiryUseCase, - withdrawUserUseCase = withdrawUserUseCase, + leaveUserUseCase = leaveUserUseCase, tokenCookieProvider = tokenCookieProvider, ) @@ -45,7 +45,7 @@ class UserControllerTest : updateUserProfileUseCase, agreeTermsUseCase, createInquiryUseCase, - withdrawUserUseCase, + leaveUserUseCase, tokenCookieProvider, ) } @@ -67,7 +67,7 @@ class UserControllerTest : describe("leave") { it("위드 탈퇴를 수행하고 access/refresh token 쿠키를 만료한다") { - justRun { withdrawUserUseCase.execute(1L) } + justRun { leaveUserUseCase.execute(1L) } everyExpireCookies() val response = controller.leave(1L) @@ -81,7 +81,7 @@ class UserControllerTest : cookies[0] shouldContain "Max-Age=0" cookies[1] shouldContain "refresh_token=" cookies[1] shouldContain "Max-Age=0" - verify(exactly = 1) { withdrawUserUseCase.execute(1L) } + verify(exactly = 1) { leaveUserUseCase.execute(1L) } } } }) From fb7ba5692c9068a447f8792411cdc45b270cc120 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Sun, 14 Jun 2026 18:21:23 +0900 Subject: [PATCH 15/20] =?UTF-8?q?style:=20=EB=A6=B0=ED=8A=B8=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/weeth/domain/user/presentation/UserController.kt | 2 +- .../com/weeth/domain/user/presentation/UserControllerTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index 645b6cdb..7ff98d9d 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -9,9 +9,9 @@ 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.domain.user.application.usecase.command.LeaveUserUseCase import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.exception.JwtErrorCode diff --git a/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt b/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt index 9e271b13..5de34484 100644 --- a/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt @@ -3,9 +3,9 @@ package com.weeth.domain.user.presentation 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.domain.user.application.usecase.command.LeaveUserUseCase import com.weeth.global.auth.jwt.application.service.TokenCookieProvider import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize From a931703ad6a54ddc3e9b9b00d4e7e70e0c1170d7 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Sun, 14 Jun 2026 18:35:23 +0900 Subject: [PATCH 16/20] =?UTF-8?q?refactor:=20=EC=95=A1=EC=84=B8=EC=8A=A4?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/LeaveUserUseCase.kt | 50 ++++++++++++------- .../port/AccessTokenBlacklistStorePort.kt | 7 +++ .../JwtAuthenticationProcessingFilter.kt | 11 ++-- .../RedisAccessTokenBlacklistStoreAdapter.kt | 34 +++++++++++++ .../com/weeth/global/config/SecurityConfig.kt | 8 +-- .../command/WithdrawUserUseCaseTest.kt | 36 ++++++++++++- .../JwtAuthenticationProcessingFilterTest.kt | 22 +++----- ...disAccessTokenBlacklistStoreAdapterTest.kt | 39 +++++++++++++++ 8 files changed, 165 insertions(+), 42 deletions(-) create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/domain/port/AccessTokenBlacklistStorePort.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisAccessTokenBlacklistStoreAdapterTest.kt diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt index 26d12cc8..36a72e79 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt @@ -6,6 +6,7 @@ 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 org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -20,6 +21,7 @@ class LeaveUserUseCase( private val clubMemberRepository: ClubMemberRepository, private val clubActivityDeletionPolicy: ClubActivityDeletionPolicy, private val jwtManageUseCase: JwtManageUseCase, + private val accessTokenBlacklistStore: AccessTokenBlacklistStorePort, private val clock: Clock, ) { private val log = LoggerFactory.getLogger(javaClass) @@ -40,35 +42,47 @@ class LeaveUserUseCase( } user.leave(now) - deleteRefreshTokenAfterCommit(userId) + revokeTokensAfterCommit(userId) } - private fun deleteRefreshTokenAfterCommit(userId: Long) { + private fun revokeTokensAfterCommit(userId: Long) { TransactionSynchronizationManager.registerSynchronization( object : TransactionSynchronization { override fun afterCommit() { - repeat(REFRESH_TOKEN_DELETE_ATTEMPTS) { index -> - runCatching { - jwtManageUseCase.deleteRefreshToken(userId) - }.onSuccess { - return - }.onFailure { e -> - val attempt = index + 1 - log.warn( - "탈퇴 후 refresh token 삭제 실패. userId={}, attempt={}/{}", - userId, - attempt, - REFRESH_TOKEN_DELETE_ATTEMPTS, - e, - ) - } + retryTokenRevoke("refresh token 삭제", userId) { + jwtManageUseCase.deleteRefreshToken(userId) + } + retryTokenRevoke("access token blacklist 등록", userId) { + accessTokenBlacklistStore.blacklist(userId) } } }, ) } + private fun retryTokenRevoke( + actionName: String, + userId: Long, + action: () -> Unit, + ) { + for (attempt in 1..TOKEN_REVOKE_ATTEMPTS) { + val result = runCatching(action) + if (result.isSuccess) break + + result.onFailure { e -> + log.warn( + "탈퇴 후 {} 실패. userId={}, attempt={}/{}", + actionName, + userId, + attempt, + TOKEN_REVOKE_ATTEMPTS, + e, + ) + } + } + } + companion object { - private const val REFRESH_TOKEN_DELETE_ATTEMPTS = 3 + private const val TOKEN_REVOKE_ATTEMPTS = 3 } } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/AccessTokenBlacklistStorePort.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/AccessTokenBlacklistStorePort.kt new file mode 100644 index 00000000..b775874a --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/AccessTokenBlacklistStorePort.kt @@ -0,0 +1,7 @@ +package com.weeth.global.auth.jwt.domain.port + +interface AccessTokenBlacklistStorePort { + fun blacklist(userId: Long) + + fun isBlacklisted(userId: Long): Boolean +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt index 1816b788..24df807b 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -1,10 +1,10 @@ package com.weeth.global.auth.jwt.filter import com.weeth.domain.user.application.exception.UserInActiveException -import com.weeth.domain.user.domain.repository.UserReader 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 @@ -20,7 +20,7 @@ import org.springframework.web.filter.OncePerRequestFilter class JwtAuthenticationProcessingFilter( private val jwtTokenProvider: JwtTokenProvider, private val jwtTokenExtractor: JwtTokenExtractor, - private val userReader: UserReader, + private val accessTokenBlacklistStore: AccessTokenBlacklistStorePort, ) : OncePerRequestFilter() { private val log = LoggerFactory.getLogger(javaClass) @@ -44,7 +44,7 @@ class JwtAuthenticationProcessingFilter( private fun saveAuthentication(accessToken: String) { val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() - validateUserStatus(claims.id) + validateAccessTokenBlacklist(claims.id) val principal = AuthenticatedUser(claims.id, claims.email) val role = @@ -64,8 +64,7 @@ class JwtAuthenticationProcessingFilter( MDC.put("userId", claims.id.toString()) } - private fun validateUserStatus(userId: Long) { - val user = userReader.getById(userId) - if (user.isBannedOrLeft()) throw UserInActiveException() + private fun validateAccessTokenBlacklist(userId: Long) { + if (accessTokenBlacklistStore.isBlacklisted(userId)) throw UserInActiveException() } } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt new file mode 100644 index 00000000..5651a231 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt @@ -0,0 +1,34 @@ +package com.weeth.global.auth.jwt.infrastructure + +import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort +import com.weeth.global.config.properties.JwtProperties +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class RedisAccessTokenBlacklistStoreAdapter( + private val jwtProperties: JwtProperties, + private val redisTemplate: RedisTemplate, +) : AccessTokenBlacklistStorePort { + override fun blacklist(userId: Long) { + redisTemplate + .opsForValue() + .set( + getKey(userId), + BLACKLISTED, + jwtProperties.access.expiration + TTL_BUFFER_MILLIS, + TimeUnit.MILLISECONDS, + ) + } + + override fun isBlacklisted(userId: Long): Boolean = redisTemplate.hasKey(getKey(userId)) == true + + private fun getKey(userId: Long): String = "$PREFIX$userId" + + companion object { + private const val PREFIX = "accessTokenBlacklist:" + private const val BLACKLISTED = "true" + private const val TTL_BUFFER_MILLIS = 60_000L + } +} diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 507c89fa..187d35a2 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -1,9 +1,9 @@ package com.weeth.global.config -import com.weeth.domain.user.domain.repository.UserReader import com.weeth.global.auth.authentication.CustomAccessDeniedHandler import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.auth.jwt.filter.JwtAuthenticationProcessingFilter import org.springframework.context.annotation.Bean @@ -109,6 +109,8 @@ class SecurityConfig( } @Bean - fun jwtAuthenticationProcessingFilter(userReader: UserReader): JwtAuthenticationProcessingFilter = - JwtAuthenticationProcessingFilter(jwtTokenProvider, jwtTokenExtractor, userReader) + fun jwtAuthenticationProcessingFilter( + accessTokenBlacklistStore: AccessTokenBlacklistStorePort, + ): JwtAuthenticationProcessingFilter = + JwtAuthenticationProcessingFilter(jwtTokenProvider, jwtTokenExtractor, accessTokenBlacklistStore) } diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt index ecbc5b03..20dcef5b 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt @@ -10,6 +10,7 @@ import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -30,6 +31,7 @@ class WithdrawUserUseCaseTest : val clubMemberRepository = mockk() val clubActivityDeletionPolicy = mockk() val jwtManageUseCase = mockk() + val accessTokenBlacklistStore = mockk() val clock = Clock.fixed(Instant.parse("2026-06-12T03:00:00Z"), ZoneId.of("Asia/Seoul")) val useCase = LeaveUserUseCase( @@ -37,11 +39,18 @@ class WithdrawUserUseCaseTest : clubMemberRepository = clubMemberRepository, clubActivityDeletionPolicy = clubActivityDeletionPolicy, jwtManageUseCase = jwtManageUseCase, + accessTokenBlacklistStore = accessTokenBlacklistStore, clock = clock, ) beforeTest { - clearMocks(userReader, clubMemberRepository, clubActivityDeletionPolicy, jwtManageUseCase) + clearMocks( + userReader, + clubMemberRepository, + clubActivityDeletionPolicy, + jwtManageUseCase, + accessTokenBlacklistStore, + ) if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.clearSynchronization() } @@ -60,6 +69,7 @@ class WithdrawUserUseCaseTest : every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() justRun { jwtManageUseCase.deleteRefreshToken(1L) } + justRun { accessTokenBlacklistStore.blacklist(1L) } TransactionSynchronizationManager.initSynchronization() useCase.execute(1L) @@ -68,10 +78,12 @@ class WithdrawUserUseCaseTest : user.leftAt shouldBe now user.hardDeleteAfter shouldBe now.plusDays(30) verify(exactly = 0) { jwtManageUseCase.deleteRefreshToken(any()) } + verify(exactly = 0) { accessTokenBlacklistStore.blacklist(any()) } TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } verify(exactly = 1) { jwtManageUseCase.deleteRefreshToken(1L) } + verify(exactly = 1) { accessTokenBlacklistStore.blacklist(1L) } } it("커밋 후 refresh token 삭제가 일시 실패하면 재시도한다") { @@ -83,6 +95,7 @@ class WithdrawUserUseCaseTest : attempts++ if (attempts < 3) throw RuntimeException("temporary redis failure") } + justRun { accessTokenBlacklistStore.blacklist(1L) } TransactionSynchronizationManager.initSynchronization() useCase.execute(1L) @@ -93,6 +106,26 @@ class WithdrawUserUseCaseTest : verify(exactly = 3) { jwtManageUseCase.deleteRefreshToken(1L) } } + it("커밋 후 access token blacklist 등록이 일시 실패하면 재시도한다") { + val user = UserTestFixture.createRegisteredUser(1L) + var attempts = 0 + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + justRun { jwtManageUseCase.deleteRefreshToken(1L) } + every { accessTokenBlacklistStore.blacklist(1L) } answers { + attempts++ + if (attempts < 3) throw RuntimeException("temporary redis failure") + } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } + + attempts shouldBe 3 + verify(exactly = 3) { accessTokenBlacklistStore.blacklist(1L) } + } + it("USER와 ADMIN ACTIVE 멤버십을 모두 탈퇴 처리한다") { val user = UserTestFixture.createRegisteredUser(1L) val userMember = @@ -112,6 +145,7 @@ class WithdrawUserUseCaseTest : every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns listOf(userMember, adminMember) justRun { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } justRun { jwtManageUseCase.deleteRefreshToken(1L) } + justRun { accessTokenBlacklistStore.blacklist(1L) } TransactionSynchronizationManager.initSynchronization() useCase.execute(1L) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt index 8f94db78..17e6e897 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -1,9 +1,8 @@ package com.weeth.global.auth.jwt.filter -import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.fixture.UserTestFixture 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 io.kotest.core.spec.style.DescribeSpec @@ -18,18 +17,17 @@ import org.springframework.mock.web.MockFilterChain import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse import org.springframework.security.core.context.SecurityContextHolder -import java.time.LocalDateTime class JwtAuthenticationProcessingFilterTest : DescribeSpec({ val jwtProvider = mockk() val jwtService = mockk() - val userReader = mockk() - val filter = JwtAuthenticationProcessingFilter(jwtProvider, jwtService, userReader) + val accessTokenBlacklistStore = mockk() + val filter = JwtAuthenticationProcessingFilter(jwtProvider, jwtService, accessTokenBlacklistStore) beforeTest { SecurityContextHolder.clearContext() - clearMocks(jwtProvider, jwtService, userReader) + clearMocks(jwtProvider, jwtService, accessTokenBlacklistStore) } afterTest { @@ -46,7 +44,7 @@ class JwtAuthenticationProcessingFilterTest : every { jwtProvider.validate("access-token") } just runs every { jwtService.extractClaims("access-token") } returns JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", TokenType.ACCESS) - every { userReader.getById(1L) } returns UserTestFixture.createActiveUser1(1L) + every { accessTokenBlacklistStore.isBlacklisted(1L) } returns false filter.doFilter(request, response, chain) @@ -68,7 +66,7 @@ class JwtAuthenticationProcessingFilterTest : every { jwtProvider.validate("temp-token") } just runs every { jwtService.extractClaims("temp-token") } returns JwtTokenExtractor.TokenClaims(2L, "new@weeth.com", TokenType.TEMPORARY) - every { userReader.getById(2L) } returns UserTestFixture.createWaitingUser1(2L) + every { accessTokenBlacklistStore.isBlacklisted(2L) } returns false filter.doFilter(request, response, chain) @@ -105,20 +103,16 @@ class JwtAuthenticationProcessingFilterTest : SecurityContextHolder.getContext().authentication shouldBe null } - it("LEFT 사용자의 토큰이면 인증을 저장하지 않는다") { + it("blacklist에 등록된 사용자의 토큰이면 인증을 저장하지 않는다") { val request = MockHttpServletRequest().apply { requestURI = "/api/v4/users/me" } val response = MockHttpServletResponse() val chain = MockFilterChain() - val user = - UserTestFixture - .createActiveUser1(1L) - .apply { leave(LocalDateTime.of(2026, 6, 12, 12, 0)) } every { jwtService.extractAccessToken(request) } returns "access-token" every { jwtProvider.validate("access-token") } just runs every { jwtService.extractClaims("access-token") } returns JwtTokenExtractor.TokenClaims(1L, "left@weeth.com", TokenType.ACCESS) - every { userReader.getById(1L) } returns user + every { accessTokenBlacklistStore.isBlacklisted(1L) } returns true filter.doFilter(request, response, chain) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisAccessTokenBlacklistStoreAdapterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisAccessTokenBlacklistStoreAdapterTest.kt new file mode 100644 index 00000000..3c9f122f --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisAccessTokenBlacklistStoreAdapterTest.kt @@ -0,0 +1,39 @@ +package com.weeth.global.auth.jwt.infrastructure.store + +import com.weeth.config.TestContainersConfig +import com.weeth.global.auth.jwt.infrastructure.RedisAccessTokenBlacklistStoreAdapter +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.test.context.ActiveProfiles + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class) +class RedisAccessTokenBlacklistStoreAdapterTest( + private val redisAccessTokenBlacklistStoreAdapter: RedisAccessTokenBlacklistStoreAdapter, + private val redisTemplate: RedisTemplate, +) : DescribeSpec({ + beforeTest { + val keys = redisTemplate.keys("$PREFIX*") + if (!keys.isNullOrEmpty()) { + redisTemplate.delete(keys) + } + } + + describe("blacklist") { + it("사용자를 blacklist에 등록하고 TTL을 설정한다") { + redisAccessTokenBlacklistStoreAdapter.blacklist(1L) + + redisAccessTokenBlacklistStoreAdapter.isBlacklisted(1L) shouldBe true + redisTemplate.getExpire("${PREFIX}1") shouldBeGreaterThan 0L + } + } + }) { + companion object { + private const val PREFIX = "accessTokenBlacklist:" + } +} From 8c4eda0838f2e5ff0b6fa245188fe895f688dfbe Mon Sep 17 00:00:00 2001 From: soo0711 Date: Sun, 14 Jun 2026 18:36:14 +0900 Subject: [PATCH 17/20] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EC=9C=A0=EC=8A=A4=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{WithdrawUserUseCaseTest.kt => LeaveUserUseCaseTest.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/test/kotlin/com/weeth/domain/user/application/usecase/command/{WithdrawUserUseCaseTest.kt => LeaveUserUseCaseTest.kt} (99%) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt similarity index 99% rename from src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt rename to src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt index 20dcef5b..54af23c8 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/WithdrawUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt @@ -25,7 +25,7 @@ import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId -class WithdrawUserUseCaseTest : +class LeaveUserUseCaseTest : DescribeSpec({ val userReader = mockk() val clubMemberRepository = mockk() From c1bf2c76dcbfac067abe9890d7d760f2e45cbb90 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Sun, 14 Jun 2026 18:51:33 +0900 Subject: [PATCH 18/20] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=8B=A4=ED=8C=A8=EC=8B=9C=20metric=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/LeaveUserUseCase.kt | 39 +++++++++++++------ .../usecase/command/LeaveUserUseCaseTest.kt | 30 ++++++++++++++ 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt index 36a72e79..898fc3cc 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt @@ -7,6 +7,7 @@ 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 @@ -22,6 +23,7 @@ class LeaveUserUseCase( 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) @@ -49,10 +51,10 @@ class LeaveUserUseCase( TransactionSynchronizationManager.registerSynchronization( object : TransactionSynchronization { override fun afterCommit() { - retryTokenRevoke("refresh token 삭제", userId) { + retryTokenRevoke("refresh token 삭제", "refresh_token_delete", userId) { jwtManageUseCase.deleteRefreshToken(userId) } - retryTokenRevoke("access token blacklist 등록", userId) { + retryTokenRevoke("access token blacklist 등록", "access_token_blacklist", userId) { accessTokenBlacklistStore.blacklist(userId) } } @@ -62,6 +64,7 @@ class LeaveUserUseCase( private fun retryTokenRevoke( actionName: String, + metricAction: String, userId: Long, action: () -> Unit, ) { @@ -69,20 +72,34 @@ class LeaveUserUseCase( val result = runCatching(action) if (result.isSuccess) break - result.onFailure { e -> - log.warn( - "탈퇴 후 {} 실패. userId={}, attempt={}/{}", - actionName, - userId, - attempt, - TOKEN_REVOKE_ATTEMPTS, - e, - ) + 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, + ) + } } } } companion object { private const val TOKEN_REVOKE_ATTEMPTS = 3 + private const val TOKEN_REVOKE_FAILURE_METRIC = "user.leave.token_revoke.failure" } } diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt index 54af23c8..c96783c5 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt @@ -14,6 +14,7 @@ import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.micrometer.core.instrument.simple.SimpleMeterRegistry import io.mockk.clearMocks import io.mockk.every import io.mockk.justRun @@ -32,6 +33,7 @@ class LeaveUserUseCaseTest : val clubActivityDeletionPolicy = mockk() val jwtManageUseCase = mockk() val accessTokenBlacklistStore = mockk() + val meterRegistry = SimpleMeterRegistry() val clock = Clock.fixed(Instant.parse("2026-06-12T03:00:00Z"), ZoneId.of("Asia/Seoul")) val useCase = LeaveUserUseCase( @@ -40,6 +42,7 @@ class LeaveUserUseCaseTest : clubActivityDeletionPolicy = clubActivityDeletionPolicy, jwtManageUseCase = jwtManageUseCase, accessTokenBlacklistStore = accessTokenBlacklistStore, + meterRegistry = meterRegistry, clock = clock, ) @@ -54,6 +57,7 @@ class LeaveUserUseCaseTest : if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.clearSynchronization() } + meterRegistry.clear() } afterTest { @@ -126,6 +130,32 @@ class LeaveUserUseCaseTest : verify(exactly = 3) { accessTokenBlacklistStore.blacklist(1L) } } + it("커밋 후 토큰 폐기가 모두 실패하면 실패 metric을 기록한다") { + val user = UserTestFixture.createRegisteredUser(1L) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + every { jwtManageUseCase.deleteRefreshToken(1L) } throws RuntimeException("redis down") + every { accessTokenBlacklistStore.blacklist(1L) } throws RuntimeException("redis down") + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } + + meterRegistry + .counter( + "user.leave.token_revoke.failure", + "action", + "refresh_token_delete", + ).count() shouldBe 1.0 + meterRegistry + .counter( + "user.leave.token_revoke.failure", + "action", + "access_token_blacklist", + ).count() shouldBe 1.0 + } + it("USER와 ADMIN ACTIVE 멤버십을 모두 탈퇴 처리한다") { val user = UserTestFixture.createRegisteredUser(1L) val userMember = From 2d0253d26108463f9b40e1de273c4f74af24ec6e Mon Sep 17 00:00:00 2001 From: soo0711 Date: Mon, 15 Jun 2026 16:45:12 +0900 Subject: [PATCH 19/20] =?UTF-8?q?refactor:=20=EB=B8=94=EB=9E=99=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jwt/filter/JwtAuthenticationProcessingFilter.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt index 24df807b..2b7656a5 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -65,6 +65,14 @@ class JwtAuthenticationProcessingFilter( } private fun validateAccessTokenBlacklist(userId: Long) { - if (accessTokenBlacklistStore.isBlacklisted(userId)) throw UserInActiveException() + 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() } } From 541d06f559fc682abfc05aebcfca4053b71a2d47 Mon Sep 17 00:00:00 2001 From: soo0711 Date: Mon, 15 Jun 2026 20:00:52 +0900 Subject: [PATCH 20/20] =?UTF-8?q?refactor:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V4__add_user_leave_metadata.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/main/resources/db/migration/V4__add_user_leave_metadata.sql diff --git a/src/main/resources/db/migration/V4__add_user_leave_metadata.sql b/src/main/resources/db/migration/V4__add_user_leave_metadata.sql new file mode 100644 index 00000000..77a18898 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_user_leave_metadata.sql @@ -0,0 +1,8 @@ +-- [위드 탈퇴] 사용자 탈퇴 메타데이터 추가 + +ALTER TABLE users + ADD COLUMN left_at DATETIME(6) NULL, + ADD COLUMN hard_delete_after DATETIME(6) NULL; + +CREATE INDEX idx_users_status_hard_delete_after + ON users (status, hard_delete_after);