Skip to content

Commit 0972fc3

Browse files
authored
feat: 애플 회원탈퇴 구현 완료 (#76)
* [BOOK-181] refactor: apis - dto 클래스 도메인 역할에 맞게 재배치 * [BOOK-181] refactor: apis - domain의 역할에 맞게 api 이동 * [BOOK-181] refactor: apis - 애플 및 카카오 로그인 전략 리팩토링 * [BOOK-181] feat: apis - 회원 탈퇴 전략을 위한 관련 클래스 추가 * [BOOK-181] fix: infra - RefreshTokenEntity 클래스 네이밍 수정 * [BOOK-181] refactor: apis - AuthTokenService의 메서드 네이밍 및 유효성 검사 어노테이션 수정 * [BOOK-181] feat: apis - 회원 탈퇴 요청을 위한 관련 클래스 추가 * [BOOK-181] fix: apis - valid 어노테이션 위치 수정 * [BOOK-181] chore: domain - 리프레쉬토큰 도메인 서비스 이름 변경 * [BOOK-181] fix: apis - 트랜잭션 경계 분리로 외부 API 호출 안전성 개선 - AuthUseCase.withdraw()에서 @transactional 제거하여 외부 API 호출을 트랜잭션 밖으로 분리 - UserWithdrawalService.processWithdrawal()에 @transactional 추가하여 DB 작업을 별도 트랜잭션으로 격리 - UserSignInService 추가로 로그인 시 사용자 생성 로직 분리 - 외부 API 호출 실패 시 DB 롤백 문제 해결 - 단방향 의존성 auth → user 유지 변경 사항: - AuthUseCase.withdraw(): 트랜잭션 제거, 외부 API 호출 후 UserWithdrawalService 호출 - UserWithdrawalService: 토큰 삭제와 사용자 삭제를 하나의 트랜잭션에서 처리 - UserSignInService: 로그인 시 사용자 생성 로직 분리 * [BOOK-181] fix: apis - 로그인 시 외부 호출로 인한 트랜잭션 문제 해결 - signIn 로직에서 외부 API 호출과 DB 트랜잭션의 경계를 분리했습니다. - AuthUseCase.signIn()은 트랜잭션 없이 외부 API 호출 및 흐름 제어만 담당하도록 수정했습니다. - 실제 DB 작업은 UserSignInService.processSignIn()이라는 별도의 트랜잭션 메서드로 격리하여 처리합니다. - 외부 API 지연이 DB 커넥션 풀에 영향을 주던 잠재적인 성능 문제를 해결하고, 로그인 프로세스의 안정성을 확보했습니다. * [BOOK-181] fix: apis - 먼저 사용자 탈퇴 처리 후, 성공 시에만 토큰 삭제하도록 수정 * [BOOK-181] fix: apis - 로그인 프로세스의 트랜잭션 경계 분리 및 안정성 강화
1 parent 5a7528d commit 0972fc3

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+629
-202
lines changed

apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ import org.springframework.http.ResponseEntity
55
import org.springframework.security.core.annotation.AuthenticationPrincipal
66
import org.springframework.web.bind.annotation.*
77
import org.yapp.apis.auth.dto.request.SocialLoginRequest
8-
import org.yapp.apis.auth.dto.request.TermsAgreementRequest
98
import org.yapp.apis.auth.dto.request.TokenRefreshRequest
9+
import org.yapp.apis.auth.dto.request.WithdrawRequest
1010
import org.yapp.apis.auth.dto.response.AuthResponse
11-
import org.yapp.apis.auth.dto.response.UserProfileResponse
1211
import org.yapp.apis.auth.usecase.AuthUseCase
1312
import java.util.*
1413

@@ -36,18 +35,12 @@ class AuthController(
3635
return ResponseEntity.noContent().build()
3736
}
3837

39-
@GetMapping("/me")
40-
override fun getUserProfile(@AuthenticationPrincipal userId: UUID): ResponseEntity<UserProfileResponse> {
41-
val userProfile = authUseCase.getUserProfile(userId)
42-
return ResponseEntity.ok(userProfile)
43-
}
44-
45-
@PutMapping("/terms-agreement")
46-
override fun updateTermsAgreement(
38+
@DeleteMapping("/withdraw")
39+
override fun withdraw(
4740
@AuthenticationPrincipal userId: UUID,
48-
@Valid @RequestBody request: TermsAgreementRequest
49-
): ResponseEntity<UserProfileResponse> {
50-
val userProfile = authUseCase.updateTermsAgreement(userId, request.validTermsAgreed())
51-
return ResponseEntity.ok(userProfile)
41+
@Valid @RequestBody request: WithdrawRequest
42+
): ResponseEntity<Unit> {
43+
authUseCase.withdraw(userId, request)
44+
return ResponseEntity.noContent().build()
5245
}
5346
}

apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,13 @@ import io.swagger.v3.oas.annotations.tags.Tag
99
import jakarta.validation.Valid
1010
import org.springframework.http.ResponseEntity
1111
import org.springframework.security.core.annotation.AuthenticationPrincipal
12-
import org.springframework.web.bind.annotation.GetMapping
12+
import org.springframework.web.bind.annotation.DeleteMapping
1313
import org.springframework.web.bind.annotation.PostMapping
14-
import org.springframework.web.bind.annotation.PutMapping
1514
import org.springframework.web.bind.annotation.RequestBody
1615
import org.yapp.apis.auth.dto.request.SocialLoginRequest
17-
import org.yapp.apis.auth.dto.request.TermsAgreementRequest
1816
import org.yapp.apis.auth.dto.request.TokenRefreshRequest
17+
import org.yapp.apis.auth.dto.request.WithdrawRequest
1918
import org.yapp.apis.auth.dto.response.AuthResponse
20-
import org.yapp.apis.auth.dto.response.UserProfileResponse
2119
import org.yapp.globalutils.exception.ErrorResponse
2220
import java.util.*
2321

@@ -90,42 +88,28 @@ interface AuthControllerApi {
9088
@PostMapping("/signout")
9189
fun signOut(@AuthenticationPrincipal userId: UUID): ResponseEntity<Unit>
9290

93-
@Operation(summary = "사용자 프로필 조회", description = "현재 로그인한 사용자의 프로필 정보를 조회합니다.")
94-
@ApiResponses(
95-
value = [
96-
ApiResponse(
97-
responseCode = "200",
98-
description = "사용자 프로필 조회 성공",
99-
content = [Content(schema = Schema(implementation = UserProfileResponse::class))]
100-
),
101-
ApiResponse(
102-
responseCode = "404",
103-
description = "사용자를 찾을 수 없음",
104-
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
105-
)
106-
]
91+
@Operation(
92+
summary = "회원 탈퇴",
93+
description = "사용자 계정을 탈퇴합니다."
10794
)
108-
@GetMapping("/me")
109-
fun getUserProfile(@AuthenticationPrincipal userId: UUID): ResponseEntity<UserProfileResponse>
110-
111-
@Operation(summary = "약관 동의 상태 수정", description = "사용자의 약관 동의 상태를 업데이트합니다")
11295
@ApiResponses(
11396
value = [
97+
ApiResponse(responseCode = "204", description = "회원 탈퇴 성공"),
11498
ApiResponse(
115-
responseCode = "200",
116-
description = "약관 동의 상태 업데이트 성공",
117-
content = [Content(schema = Schema(implementation = UserProfileResponse::class))]
99+
responseCode = "400",
100+
description = "잘못된 요청 또는 사용자를 찾을 수 없음",
101+
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
118102
),
119103
ApiResponse(
120-
responseCode = "404",
121-
description = "사용자를 찾을 수 없음",
104+
responseCode = "500",
105+
description = "Apple or Kakao 서버 연결 해제 실패",
122106
content = [Content(schema = Schema(implementation = ErrorResponse::class))]
123107
)
124108
]
125109
)
126-
@PutMapping("/terms-agreement")
127-
fun updateTermsAgreement(
110+
@DeleteMapping("/withdraw")
111+
fun withdraw(
128112
@AuthenticationPrincipal userId: UUID,
129-
@Valid @RequestBody request: TermsAgreementRequest
130-
): ResponseEntity<UserProfileResponse>
113+
@Valid @RequestBody request: WithdrawRequest
114+
): ResponseEntity<Unit>
131115
}

apis/src/main/kotlin/org/yapp/apis/auth/dto/request/GenerateTokenPairRequest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ package org.yapp.apis.auth.dto.request
22

33
import io.swagger.v3.oas.annotations.media.Schema
44
import jakarta.validation.constraints.NotNull
5-
import org.yapp.apis.auth.dto.response.CreateUserResponse
6-
import org.yapp.apis.auth.dto.response.UserAuthInfoResponse
5+
import org.yapp.apis.user.dto.response.CreateUserResponse
6+
import org.yapp.apis.user.dto.response.UserAuthInfoResponse
77
import org.yapp.globalutils.auth.Role
88
import java.util.UUID
99

apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SaveAppleRefreshTokenRequest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ package org.yapp.apis.auth.dto.request
33
import io.swagger.v3.oas.annotations.media.Schema
44
import jakarta.validation.constraints.NotBlank
55
import jakarta.validation.constraints.NotNull
6-
import org.yapp.apis.auth.dto.response.CreateUserResponse
7-
import org.yapp.apis.auth.strategy.AppleAuthCredentials
6+
import org.yapp.apis.user.dto.response.CreateUserResponse
7+
import org.yapp.apis.auth.strategy.signin.AppleAuthCredentials
88
import java.util.UUID
99

1010
@Schema(

apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ package org.yapp.apis.auth.dto.request
22

33
import io.swagger.v3.oas.annotations.media.Schema
44
import jakarta.validation.constraints.NotBlank
5-
import org.yapp.apis.auth.strategy.AppleAuthCredentials
6-
import org.yapp.apis.auth.strategy.AuthCredentials
7-
import org.yapp.apis.auth.strategy.KakaoAuthCredentials
85
import org.yapp.apis.auth.exception.AuthErrorCode
96
import org.yapp.apis.auth.exception.AuthException
7+
import org.yapp.apis.auth.strategy.signin.AppleAuthCredentials
8+
import org.yapp.apis.auth.strategy.signin.KakaoAuthCredentials
9+
import org.yapp.apis.auth.strategy.signin.SignInCredentials
1010
import org.yapp.domain.user.ProviderType
1111

1212
@Schema(
@@ -41,7 +41,7 @@ data class SocialLoginRequest private constructor(
4141
fun validOauthToken(): String = oauthToken!!
4242

4343
companion object {
44-
fun toCredentials(request: SocialLoginRequest): AuthCredentials {
44+
fun toCredentials(request: SocialLoginRequest): SignInCredentials {
4545
val provider = try {
4646
ProviderType.valueOf(request.validProviderType().uppercase())
4747
} catch (e: IllegalArgumentException) {
@@ -55,7 +55,10 @@ data class SocialLoginRequest private constructor(
5555
ProviderType.KAKAO -> KakaoAuthCredentials(request.validOauthToken())
5656
ProviderType.APPLE -> {
5757
val authCode = request.authorizationCode
58-
?: throw AuthException(AuthErrorCode.INVALID_REQUEST, "Apple login requires an authorization code.")
58+
?: throw AuthException(
59+
AuthErrorCode.INVALID_REQUEST,
60+
"Apple login requires an authorization code."
61+
)
5962
AppleAuthCredentials(request.validOauthToken(), authCode)
6063
}
6164
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package org.yapp.apis.auth.dto.request
2+
3+
import io.swagger.v3.oas.annotations.media.Schema
4+
import jakarta.validation.constraints.NotBlank
5+
import jakarta.validation.constraints.NotNull
6+
import org.yapp.domain.user.ProviderType
7+
8+
@Schema(
9+
name = "WithdrawRequest",
10+
description = "DTO for user withdrawal requests"
11+
)
12+
data class WithdrawRequest private constructor(
13+
@Schema(
14+
description = "Type of social login provider for withdrawal",
15+
example = "APPLE",
16+
required = true
17+
)
18+
@field:NotNull(message = "Provider type is not-null")
19+
val providerType: ProviderType? = null
20+
) {
21+
fun validProviderType(): ProviderType = providerType!!
22+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package org.yapp.apis.auth.dto.request
2+
3+
import io.swagger.v3.oas.annotations.media.Schema
4+
import org.yapp.apis.user.dto.response.WithdrawTargetUserResponse
5+
import org.yapp.domain.user.ProviderType
6+
import java.util.*
7+
8+
@Schema(description = "회원 탈퇴 처리 시 내부적으로 사용되는 요청 DTO")
9+
data class WithdrawStrategyRequest private constructor(
10+
@Schema(
11+
description = "사용자 고유 ID",
12+
example = "123e4567-e89b-12d3-a456-426614174000",
13+
)
14+
val userId: UUID,
15+
16+
@Schema(
17+
description = "소셜 로그인 제공자 타입",
18+
example = "KAKAO",
19+
)
20+
val providerType: ProviderType,
21+
22+
@Schema(
23+
description = "소셜 로그인 제공자로부터 발급받은 고유 ID",
24+
example = "21412412412",
25+
)
26+
val providerId: String,
27+
28+
@Schema(
29+
description = "Apple 로그인 시 발급받은 리프레시 토큰 (Apple 로그인 회원 탈퇴 시에만 필요)",
30+
example = "r_abc123def456ghi789jkl0mnopqrstu",
31+
required = false
32+
)
33+
val appleRefreshToken: String?
34+
) {
35+
companion object {
36+
fun from(response: WithdrawTargetUserResponse): WithdrawStrategyRequest {
37+
return WithdrawStrategyRequest(
38+
userId = response.id,
39+
providerType = response.providerType,
40+
providerId = response.providerId,
41+
appleRefreshToken = response.appleRefreshToken
42+
)
43+
}
44+
}
45+
}

apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ enum class AuthErrorCode(
2020
USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_400_08", "사용자를 찾을 수 없습니다."),
2121
EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_400_09", "이메일을 찾을 수 없습니다."),
2222
INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_400_10", "유효하지 않은 Apple ID 토큰입니다."),
23+
PROVIDER_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "AUTH_400_11", "요청된 공급자 타입이 실제 사용자의 공급자 타입과 일치하지 않습니다."),
24+
APPLE_REFRESH_TOKEN_MISSING(HttpStatus.BAD_REQUEST, "AUTH_400_12", "Apple 사용자 탈퇴 시 리프레시 토큰이 누락되었습니다."),
2325

2426
/* 401 UNAUTHORIZED */
2527
INVALID_OAUTH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_401_01", "잘못된 소셜 OAuth 토큰입니다."),

apis/src/main/kotlin/org/yapp/apis/auth/manager/AppleApiManager.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,23 @@ class AppleApiManager(
3535
)
3636
}
3737
}
38+
39+
fun revokeToken(appleRefreshToken: String) {
40+
val clientSecret = appleClientSecretGenerator.generateClientSecret()
41+
42+
appleApi.revokeAppleToken(
43+
clientId = properties.clientId,
44+
clientSecret = clientSecret,
45+
token = appleRefreshToken,
46+
tokenTypeHint = "refresh_token"
47+
).onSuccess {
48+
log.info { "Successfully revoked Apple token." }
49+
}.onFailure { originalError ->
50+
log.error(originalError) { "Failed to revoke Apple token." }
51+
throw AuthException(
52+
AuthErrorCode.OAUTH_SERVER_ERROR,
53+
"Failed to revoke Apple token: ${originalError.message}"
54+
)
55+
}
56+
}
3857
}
Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,23 @@
11
package org.yapp.apis.auth.service
22

3-
import jakarta.validation.Valid
43
import org.springframework.stereotype.Service
54
import org.springframework.validation.annotation.Validated
6-
import org.yapp.apis.auth.dto.request.SaveAppleRefreshTokenRequest
75
import org.yapp.apis.auth.exception.AuthErrorCode
86
import org.yapp.apis.auth.exception.AuthException
97
import org.yapp.apis.auth.manager.AppleApiManager
10-
import org.yapp.domain.user.UserDomainService
8+
import org.yapp.infra.external.oauth.apple.response.AppleTokenResponse
119

1210
@Service
1311
@Validated
1412
class AppleAuthService(
1513
private val appleApiManager: AppleApiManager,
16-
private val userDomainService: UserDomainService
1714
) {
18-
fun saveAppleRefreshTokenIfMissing(@Valid request: SaveAppleRefreshTokenRequest) {
19-
if (request.appleRefreshToken == null) {
20-
val tokenResponse = appleApiManager.fetchAppleOauthTokens(request.validAuthorizationCode())
15+
fun fetchAppleOauthTokens(authorizationCode: String): AppleTokenResponse {
16+
val tokenResponse = appleApiManager.fetchAppleOauthTokens(authorizationCode)
2117

22-
val refreshToken = tokenResponse.refreshToken
23-
?: throw AuthException(AuthErrorCode.MISSING_APPLE_REFRESH_TOKEN)
18+
tokenResponse.refreshToken
19+
?: throw AuthException(AuthErrorCode.MISSING_APPLE_REFRESH_TOKEN)
2420

25-
userDomainService.updateAppleRefreshToken(request.validUserId(), refreshToken)
26-
}
21+
return tokenResponse
2722
}
2823
}

0 commit comments

Comments
 (0)