-
Notifications
You must be signed in to change notification settings - Fork 0
[WTH-409] 위드 탈퇴 구현 #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "feat/WTH-409-\uC704\uB4DC-\uD0C8\uD1F4-\uAD6C\uD604"
Changes from all commits
c21e03b
39940b9
7f273bf
b14dddd
1611011
6b1ef09
dfc08b0
f3ba9b2
55d0b28
7ff3c83
43c2971
cbb90b6
6ff7f0e
aa65387
fb7ba56
a931703
8c4eda0
c1bf2c7
2d0253d
fe21d82
541d06f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.weeth.domain.user.application.exception | ||
|
|
||
| import com.weeth.global.common.exception.BaseException | ||
|
|
||
| class UserHasLeadClubException : BaseException(UserErrorCode.USER_HAS_LEAD_CLUB) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| package com.weeth.domain.user.application.usecase.command | ||
|
|
||
| import com.weeth.domain.club.domain.enums.MemberRole | ||
| import com.weeth.domain.club.domain.repository.ClubMemberRepository | ||
| import com.weeth.domain.club.domain.service.ClubActivityDeletionPolicy | ||
| import com.weeth.domain.user.application.exception.UserHasLeadClubException | ||
| import com.weeth.domain.user.domain.repository.UserReader | ||
| import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase | ||
| import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort | ||
| import io.micrometer.core.instrument.MeterRegistry | ||
| import org.slf4j.LoggerFactory | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
| import org.springframework.transaction.support.TransactionSynchronization | ||
| import org.springframework.transaction.support.TransactionSynchronizationManager | ||
| import java.time.Clock | ||
| import java.time.LocalDateTime | ||
|
|
||
| @Service | ||
| class LeaveUserUseCase( | ||
| private val userReader: UserReader, | ||
| private val clubMemberRepository: ClubMemberRepository, | ||
| private val clubActivityDeletionPolicy: ClubActivityDeletionPolicy, | ||
| private val jwtManageUseCase: JwtManageUseCase, | ||
| private val accessTokenBlacklistStore: AccessTokenBlacklistStorePort, | ||
| private val meterRegistry: MeterRegistry, | ||
| private val clock: Clock, | ||
| ) { | ||
| private val log = LoggerFactory.getLogger(javaClass) | ||
|
|
||
| @Transactional | ||
| fun execute(userId: Long) { | ||
| val now = LocalDateTime.now(clock) | ||
| val user = userReader.getByIdWithLock(userId) | ||
| val activeMembers = clubMemberRepository.findAllActiveByUserIdWithLock(userId) | ||
|
|
||
| if (activeMembers.any { it.memberRole == MemberRole.LEAD }) { | ||
| throw UserHasLeadClubException() | ||
| } | ||
|
|
||
| activeMembers.forEach { member -> | ||
| clubActivityDeletionPolicy.markMemberActivitiesDeleted(member, now) | ||
| member.leave(now) | ||
| } | ||
|
|
||
| user.leave(now) | ||
| revokeTokensAfterCommit(userId) | ||
| } | ||
|
|
||
| private fun revokeTokensAfterCommit(userId: Long) { | ||
| TransactionSynchronizationManager.registerSynchronization( | ||
| object : TransactionSynchronization { | ||
| override fun afterCommit() { | ||
| retryTokenRevoke("refresh token 삭제", "refresh_token_delete", userId) { | ||
| jwtManageUseCase.deleteRefreshToken(userId) | ||
| } | ||
| retryTokenRevoke("access token blacklist 등록", "access_token_blacklist", userId) { | ||
| accessTokenBlacklistStore.blacklist(userId) | ||
| } | ||
| } | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| private fun retryTokenRevoke( | ||
| actionName: String, | ||
| metricAction: String, | ||
| userId: Long, | ||
| action: () -> Unit, | ||
| ) { | ||
| for (attempt in 1..TOKEN_REVOKE_ATTEMPTS) { | ||
| val result = runCatching(action) | ||
| if (result.isSuccess) break | ||
|
|
||
| result.onFailure { exception -> | ||
| if (attempt == TOKEN_REVOKE_ATTEMPTS) { | ||
| log.error( | ||
| "탈퇴 후 {} 최종 실패. userId={}, attempts={}", | ||
| actionName, | ||
| userId, | ||
| TOKEN_REVOKE_ATTEMPTS, | ||
| exception, | ||
| ) | ||
| meterRegistry | ||
| .counter(TOKEN_REVOKE_FAILURE_METRIC, "action", metricAction) | ||
| .increment() | ||
| } else { | ||
| log.warn( | ||
| "탈퇴 후 {} 실패. userId={}, attempt={}/{}", | ||
| actionName, | ||
| userId, | ||
| attempt, | ||
| TOKEN_REVOKE_ATTEMPTS, | ||
| exception, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| companion object { | ||
| private const val TOKEN_REVOKE_ATTEMPTS = 3 | ||
| private const val TOKEN_REVOKE_FAILURE_METRIC = "user.leave.token_revoke.failure" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+68
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 새 컬럼 매핑은 이번 PR에 마이그레이션이 같이 들어와야 합니다.
|
||
|
|
||
| @Column(nullable = false) | ||
| var termsAgreed: Boolean = false | ||
| private set | ||
|
|
@@ -78,8 +87,11 @@ class User( | |
| val telValue: String? | ||
| get() = tel?.value | ||
|
|
||
| fun leave() { | ||
| fun leave(now: LocalDateTime) { | ||
| check(status != Status.LEFT) { "이미 탈퇴한 사용자입니다." } | ||
| status = Status.LEFT | ||
| leftAt = now | ||
| hardDeleteAfter = now.plusDays(RETENTION_DAYS) | ||
| } | ||
|
|
||
| fun isActive(): Boolean = status == Status.ACTIVE | ||
|
|
@@ -146,6 +158,8 @@ class User( | |
| } | ||
|
|
||
| companion object { | ||
| private const val RETENTION_DAYS = 30L | ||
|
|
||
| fun create( | ||
| name: String, | ||
| email: String, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.weeth.global.auth.jwt.domain.port | ||
|
|
||
| interface AccessTokenBlacklistStorePort { | ||
| fun blacklist(userId: Long) | ||
|
|
||
| fun isBlacklisted(userId: Long): Boolean | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,10 @@ | ||
| package com.weeth.global.auth.jwt.filter | ||
|
|
||
| import com.weeth.domain.user.application.exception.UserInActiveException | ||
| import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException | ||
| import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor | ||
| import com.weeth.global.auth.jwt.domain.enums.TokenType | ||
| import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort | ||
| import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider | ||
| import com.weeth.global.auth.model.AuthenticatedUser | ||
| import jakarta.servlet.FilterChain | ||
|
|
@@ -18,6 +20,7 @@ import org.springframework.web.filter.OncePerRequestFilter | |
| class JwtAuthenticationProcessingFilter( | ||
| private val jwtTokenProvider: JwtTokenProvider, | ||
| private val jwtTokenExtractor: JwtTokenExtractor, | ||
| private val accessTokenBlacklistStore: AccessTokenBlacklistStorePort, | ||
| ) : OncePerRequestFilter() { | ||
| private val log = LoggerFactory.getLogger(javaClass) | ||
|
|
||
|
|
@@ -41,6 +44,7 @@ class JwtAuthenticationProcessingFilter( | |
|
|
||
| private fun saveAuthentication(accessToken: String) { | ||
| val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() | ||
| validateAccessTokenBlacklist(claims.id) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 해당 방식에 대한 성능 측정 / 부하 테스트도 한 번 진행되면 좋을 것 같아요! 저도 측정해보진 못했지만 도입 전/후로 측정 데이터가 나오면 조금 더 명확하게 블랙리스트 필터링을 할지 액세스 토큰을 극단적으로 줄일지 결정이 가능할 것 같아욥 또 만약에 Redis에 장애가 발생한 경우에 대한 처리 방안도 필요할 것 같아용 try - catch 라거나
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1차로 /api/v4/clubs로 로컬 부하 테스트를 진행해봤는데 p95 차이는 VU 10/50/100 기준 최대 1.79ms였습니당 장애가 발생한 경우에 대한 처리 방안도 추가해두겠습니닷!! 👍
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 위에 부하테스트 결과 스크린샷도 추가해두었습니당 |
||
| val principal = AuthenticatedUser(claims.id, claims.email) | ||
|
|
||
| val role = | ||
|
|
@@ -59,4 +63,16 @@ class JwtAuthenticationProcessingFilter( | |
| SecurityContextHolder.getContext().authentication = authentication | ||
| MDC.put("userId", claims.id.toString()) | ||
| } | ||
|
|
||
| private fun validateAccessTokenBlacklist(userId: Long) { | ||
| val isBlacklisted = | ||
| try { | ||
| accessTokenBlacklistStore.isBlacklisted(userId) | ||
| } catch (e: RuntimeException) { | ||
| log.error("Access token blacklist lookup failed. userId={}", userId, e) | ||
| throw e | ||
| } | ||
|
|
||
| if (isBlacklisted) throw UserInActiveException() | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.