From fb15568979a41d7228ae116f7facf8e673ca130f Mon Sep 17 00:00:00 2001 From: "rui.bao" Date: Sun, 17 May 2026 21:56:27 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[FEAT]:=20OAuth=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAuthRepository에 withdrawKakao/Google/Apple 메서드 추가 - Repository 레이어에서 KakaoSDK 호출하도록 변경 - WithdrawCredential을 password/oauth로 분리 - UserProfile에 AuthProvider 추가 - Data 모듈에 KakaoSDKUser 의존성 추가 Co-Authored-By: Claude Opus 4.5 --- Projects/App/Sources/AppContainer.swift | 3 +- .../Core/Utils/ServiceConfiguration.swift | 9 +++ .../DTO/MyPage/UserProfileResponseDTO.swift | 4 +- .../DTO/OAuth/OAuthWithdrawRequestDTO.swift | 17 +++++ .../Data/DataMapper/MyPageDataMapper.swift | 8 ++- Projects/Data/Project.swift | 3 +- .../OAuthRepositoryImpl/OAuthAPI.swift | 30 +++++++++ .../OAuthRepositoryImpl.swift | 66 +++++++++++++++++++ Projects/Domain/Entity/UserProfile.swift | 19 +++++- .../Interfaces/OAuthRepository.swift | 9 +++ .../ProfileEidtUseCaseImpl.swift | 30 +++++++-- .../Interfaces/ProfileEditUseCase.swift | 2 +- 12 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 Projects/Data/DTO/OAuth/OAuthWithdrawRequestDTO.swift diff --git a/Projects/App/Sources/AppContainer.swift b/Projects/App/Sources/AppContainer.swift index 3ea01323..cd3683e9 100644 --- a/Projects/App/Sources/AppContainer.swift +++ b/Projects/App/Sources/AppContainer.swift @@ -123,7 +123,8 @@ final class AppContainer: authRepository: authRepository, loginRepository: logInRepository, imageUploader: imageUploader, - myPageRepository: myPageRepository + myPageRepository: myPageRepository, + oauthRepository: oauthRepository ) }() diff --git a/Projects/Cores/Core/Utils/ServiceConfiguration.swift b/Projects/Cores/Core/Utils/ServiceConfiguration.swift index a6adb11e..35b1b786 100644 --- a/Projects/Cores/Core/Utils/ServiceConfiguration.swift +++ b/Projects/Cores/Core/Utils/ServiceConfiguration.swift @@ -40,6 +40,15 @@ public final class ServiceConfiguration { public func setUserName(_ name: String) { UserDefaults.standard.set(name, forKey: "userName") } + + /// 사용자의 `authProvider`를 리턴합니다. + public var authProvider: String { + return UserDefaults.standard.string(forKey: "authProvider") ?? "normal" + } + + public func setAuthProvider(_ provider: String) { + UserDefaults.standard.set(provider, forKey: "authProvider") + } // 개인정보 처리방침 public var privacyUrl: URL { diff --git a/Projects/Data/DTO/MyPage/UserProfileResponseDTO.swift b/Projects/Data/DTO/MyPage/UserProfileResponseDTO.swift index 019ced03..7a1f4864 100644 --- a/Projects/Data/DTO/MyPage/UserProfileResponseDTO.swift +++ b/Projects/Data/DTO/MyPage/UserProfileResponseDTO.swift @@ -10,6 +10,7 @@ public struct UserProfileResponseDTO: Decodable { public let imageUrl: String? public let username: String public let email: String + public let provider: String? } public extension UserProfileResponseDTO { @@ -20,7 +21,8 @@ public extension UserProfileResponseDTO { "data": { "imageUrl": "https://url.kr/5MhHhD", "username": "photi", - "email": "photi@photi.com" + "email": "photi@photi.com", + "provider": null } } """ diff --git a/Projects/Data/DTO/OAuth/OAuthWithdrawRequestDTO.swift b/Projects/Data/DTO/OAuth/OAuthWithdrawRequestDTO.swift new file mode 100644 index 00000000..6645bd1c --- /dev/null +++ b/Projects/Data/DTO/OAuth/OAuthWithdrawRequestDTO.swift @@ -0,0 +1,17 @@ +// +// OAuthWithdrawRequestDTO.swift +// Data +// +// Created by Claude on 5/10/26. +// Copyright © 2026 com.photi. All rights reserved. +// + +public struct OAuthWithdrawRequestDTO: Encodable { + public let provider: String + public let sub: String + + public init(provider: String, sub: String) { + self.provider = provider + self.sub = sub + } +} diff --git a/Projects/Data/DataMapper/MyPageDataMapper.swift b/Projects/Data/DataMapper/MyPageDataMapper.swift index 49db96d5..af8449f6 100644 --- a/Projects/Data/DataMapper/MyPageDataMapper.swift +++ b/Projects/Data/DataMapper/MyPageDataMapper.swift @@ -89,7 +89,8 @@ public extension MyPageDataMapperImpl { return .init( imageUrl: imageURL(from: dto.imageUrl), name: dto.username, - email: dto.email + email: dto.email, + provider: authProvider(from: dto.provider) ) } @@ -108,4 +109,9 @@ private extension MyPageDataMapperImpl { guard let strURL else { return nil } return URL(string: strURL) } + + func authProvider(from provider: String?) -> AuthProvider { + guard let provider else { return .normal } + return AuthProvider(rawValue: provider) ?? .normal + } } diff --git a/Projects/Data/Project.swift b/Projects/Data/Project.swift index 27d4d319..0290d0c8 100644 --- a/Projects/Data/Project.swift +++ b/Projects/Data/Project.swift @@ -37,7 +37,8 @@ let project = Project.make( .Project.Data.DTO, .Project.Data.DataMapper, .Project.Data.PhotiNetwork, - .Project.Domain.Repository + .Project.Domain.Repository, + .SPM.KakaoSDKUser ] ), .make( diff --git a/Projects/Data/Repository/Implementations/OAuthRepositoryImpl/OAuthAPI.swift b/Projects/Data/Repository/Implementations/OAuthRepositoryImpl/OAuthAPI.swift index 9f8d2000..e82c9545 100644 --- a/Projects/Data/Repository/Implementations/OAuthRepositoryImpl/OAuthAPI.swift +++ b/Projects/Data/Repository/Implementations/OAuthRepositoryImpl/OAuthAPI.swift @@ -14,6 +14,10 @@ import PhotiNetwork public enum OAuthAPI { case login(provider: String, idToken: String) case setUsername(dto: OAuthUsernameRequestDTO) + /// 카카오/구글 탈퇴 - 클라이언트에서 unlink 후 호출 + case withdrawKakaoGoogle(dto: OAuthWithdrawRequestDTO) + /// 애플 탈퇴 - 서버에서 revoke 처리 + case withdrawApple(accessToken: String) } extension OAuthAPI: TargetType { @@ -27,6 +31,10 @@ extension OAuthAPI: TargetType { return "oauth/\(provider)/login" case .setUsername: return "oauth/username" + case .withdrawKakaoGoogle: + return "oauth/withdraw" + case .withdrawApple: + return "oauth/apple/withdraw" } } @@ -36,6 +44,8 @@ extension OAuthAPI: TargetType { return .get case .setUsername: return .post + case .withdrawKakaoGoogle, .withdrawApple: + return .patch } } @@ -47,6 +57,13 @@ extension OAuthAPI: TargetType { case let .setUsername(dto): return .requestJSONEncodable(dto) + + case let .withdrawKakaoGoogle(dto): + return .requestJSONEncodable(dto) + + case let .withdrawApple(accessToken): + let parameters = ["access_token": accessToken] + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) } } @@ -75,6 +92,19 @@ extension OAuthAPI: TargetType { """ let jsonData = data.data(using: .utf8) return .networkResponse(200, jsonData ?? Data(), "", "") + + case .withdrawKakaoGoogle, .withdrawApple: + let data = """ + { + "code": "200 OK", + "message": "성공", + "data": { + "successMessage": "string" + } + } + """ + let jsonData = data.data(using: .utf8) + return .networkResponse(200, jsonData ?? Data(), "", "") } } } diff --git a/Projects/Data/Repository/Implementations/OAuthRepositoryImpl/OAuthRepositoryImpl.swift b/Projects/Data/Repository/Implementations/OAuthRepositoryImpl/OAuthRepositoryImpl.swift index 2029d915..ae01d3a3 100644 --- a/Projects/Data/Repository/Implementations/OAuthRepositoryImpl/OAuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Implementations/OAuthRepositoryImpl/OAuthRepositoryImpl.swift @@ -11,6 +11,7 @@ import DTO import Entity import Repository import PhotiNetwork +import KakaoSDKUser public struct OAuthRepositoryImpl: OAuthRepository { public init() { } @@ -33,6 +34,71 @@ public extension OAuthRepositoryImpl { responseType: SuccessResponseDTO.self ) } + + func withdrawKakao() async throws { + let sub = try await fetchKakaoUserId() + try await unlinkKakao() + try await requestWithdraw(provider: "KAKAO", sub: sub) + } + + func withdrawGoogle() async throws { + // TODO: 구글 SDK 연동 후 구현 + // 1. GIDSignIn.sharedInstance.currentUser?.userID로 sub 획득 + // 2. GIDSignIn.sharedInstance.disconnect()로 연결 해제 + // 3. requestWithdraw(provider: "GOOGLE", sub: sub) 호출 + throw APIError.serverError + } + + func withdrawApple() async throws { + // TODO: 애플 재인증 후 authorization code 획득하여 서버 전달 + // ASAuthorizationAppleIDProvider를 사용하여 재인증 + throw APIError.serverError + } +} + +// MARK: - Kakao SDK Methods +private extension OAuthRepositoryImpl { + func fetchKakaoUserId() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + UserApi.shared.me { user, error in + if let error = error { + continuation.resume(throwing: error) + return + } + + guard let userId = user?.id else { + continuation.resume(throwing: APIError.serverError) + return + } + + continuation.resume(returning: String(userId)) + } + } + } + + func unlinkKakao() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + UserApi.shared.unlink { error in + if let error = error { + continuation.resume(throwing: error) + return + } + continuation.resume() + } + } + } +} + +// MARK: - Server API Methods +private extension OAuthRepositoryImpl { + func requestWithdraw(provider: String, sub: String) async throws { + let requestDTO = OAuthWithdrawRequestDTO(provider: provider, sub: sub) + + try await requestAuthorizableAPI( + api: OAuthAPI.withdrawKakaoGoogle(dto: requestDTO), + responseType: SuccessResponseDTO.self + ) + } } // MARK: - Private Methods diff --git a/Projects/Domain/Entity/UserProfile.swift b/Projects/Domain/Entity/UserProfile.swift index 65a40433..836f4942 100644 --- a/Projects/Domain/Entity/UserProfile.swift +++ b/Projects/Domain/Entity/UserProfile.swift @@ -8,18 +8,33 @@ import Foundation +public enum AuthProvider: String { + case normal + case kakao = "KAKAO" + case google = "GOOGLE" + case apple = "APPLE" +} + +public enum WithdrawCredential { + case password(String) + case oauth(provider: AuthProvider) +} + public struct UserProfile { public let imageUrl: URL? public let name: String public let email: String - + public let provider: AuthProvider + public init( imageUrl: URL?, name: String, - email: String + email: String, + provider: AuthProvider ) { self.imageUrl = imageUrl self.name = name self.email = email + self.provider = provider } } diff --git a/Projects/Domain/Repository/Interfaces/OAuthRepository.swift b/Projects/Domain/Repository/Interfaces/OAuthRepository.swift index e4ce61b2..38363f36 100644 --- a/Projects/Domain/Repository/Interfaces/OAuthRepository.swift +++ b/Projects/Domain/Repository/Interfaces/OAuthRepository.swift @@ -12,4 +12,13 @@ public protocol OAuthRepository { /// OAuth 신규 사용자 username 설정 func setUsername(_ username: String) async throws + + /// 카카오 회원 탈퇴 - SDK unlink + 서버 API 호출 + func withdrawKakao() async throws + + /// 구글 회원 탈퇴 - SDK disconnect + 서버 API 호출 + func withdrawGoogle() async throws + + /// 애플 회원 탈퇴 - 서버에서 revoke 처리 + func withdrawApple() async throws } diff --git a/Projects/Domain/UseCase/Implementations/ProfileEidtUseCaseImpl.swift b/Projects/Domain/UseCase/Implementations/ProfileEidtUseCaseImpl.swift index a5895d8e..848d776a 100644 --- a/Projects/Domain/UseCase/Implementations/ProfileEidtUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Implementations/ProfileEidtUseCaseImpl.swift @@ -7,6 +7,7 @@ // import Foundation +import Core import Entity import UseCase import Repository @@ -16,23 +17,28 @@ public final class ProfileEditUseCaseImpl: ProfileEditUseCase { private let loginRepository: LogInRepository private let imageUploader: PresignedImageUploader private let myPageRepository: MyPageRepository - + private let oauthRepository: OAuthRepository + public init( authRepository: AuthRepository, loginRepository: LogInRepository, imageUploader: PresignedImageUploader, - myPageRepository: MyPageRepository + myPageRepository: MyPageRepository, + oauthRepository: OAuthRepository ) { self.authRepository = authRepository self.loginRepository = loginRepository self.imageUploader = imageUploader self.myPageRepository = myPageRepository + self.oauthRepository = oauthRepository } } public extension ProfileEditUseCaseImpl { func loadUserProfile() async throws -> UserProfile { - return try await myPageRepository.fetchUserProfile() + let profile = try await myPageRepository.fetchUserProfile() + ServiceConfiguration.shared.setAuthProvider(profile.provider.rawValue) + return profile } func updateProfileImage(_ imageData: Data, type: String) async throws -> URL? { @@ -42,8 +48,22 @@ public extension ProfileEditUseCaseImpl { return try await myPageRepository.uploadProfileImage(path: url) } - func withdraw(with password: String) async throws { - try await myPageRepository.deleteUserAccount(password: password) + func withdraw(with credential: WithdrawCredential) async throws { + switch credential { + case let .password(password): + try await myPageRepository.deleteUserAccount(password: password) + case let .oauth(provider): + switch provider { + case .kakao: + try await oauthRepository.withdrawKakao() + case .google: + try await oauthRepository.withdrawGoogle() + case .apple: + try await oauthRepository.withdrawApple() + case .normal: + break + } + } authRepository.removeToken() } diff --git a/Projects/Domain/UseCase/Interfaces/ProfileEditUseCase.swift b/Projects/Domain/UseCase/Interfaces/ProfileEditUseCase.swift index c792b6f4..027d5986 100644 --- a/Projects/Domain/UseCase/Interfaces/ProfileEditUseCase.swift +++ b/Projects/Domain/UseCase/Interfaces/ProfileEditUseCase.swift @@ -12,7 +12,7 @@ import Entity public protocol ProfileEditUseCase { func loadUserProfile() async throws -> UserProfile func updateProfileImage(_ imageData: Data, type: String) async throws -> URL? - func withdraw(with password: String) async throws + func withdraw(with credential: WithdrawCredential) async throws func changePassword(from password: String, to newPassword: String) async throws func sendTemporaryPassword(to email: String, userName: String) async throws } From 30eeb68b17620bbbeb1732b5f4f006e083eb971a Mon Sep 17 00:00:00 2001 From: "rui.bao" Date: Sun, 17 May 2026 21:56:39 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[REFACT]:=20Withdraw=20OAuth=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=84=20Alert=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20Combine=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WithdrawOAuth 화면 제거, Alert으로 대체 - WithdrawViewModel/ViewController Rx → Combine 마이그레이션 - WithdrawCoordinator에서 OAuth 관련 코드 제거 - ViewController에서 Entity 의존성 제거 Co-Authored-By: Claude Opus 4.5 --- .../WithdrawAuth/WithdrawAuthViewModel.swift | 2 +- .../Withdraw/WithdrawContainer.swift | 10 +- .../Withdraw/WithdrawCoordinator.swift | 30 ++-- .../Withdraw/WithdrawViewController.swift | 116 +++++++++++---- .../Withdraw/WithdrawViewModel.swift | 139 ++++++++++++++---- 5 files changed, 218 insertions(+), 79 deletions(-) diff --git a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawAuth/WithdrawAuthViewModel.swift b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawAuth/WithdrawAuthViewModel.swift index 12c60369..c80aac51 100644 --- a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawAuth/WithdrawAuthViewModel.swift +++ b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawAuth/WithdrawAuthViewModel.swift @@ -83,7 +83,7 @@ final class WithdrawAuthViewModel: WithdrawAuthViewModelType { private extension WithdrawAuthViewModel { @MainActor func withdraw(password: String) async { do { - try await useCase.withdraw(with: password) + try await useCase.withdraw(with: .password(password)) coordinator?.withdrawalSucceed() } catch { requestFailed(with: error) diff --git a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawContainer.swift b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawContainer.swift index 8e5db6f4..d28ab56d 100644 --- a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawContainer.swift +++ b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawContainer.swift @@ -22,20 +22,20 @@ final class WithdrawContainer: WithdrawContainable, WithdrawAuthDependency { var profileEditUsecase: ProfileEditUseCase { dependency.profileEditUseCase } - + func coordinator(listener: WithdrawListener) -> ViewableCoordinating { - let viewModel = WithdrawViewModel() + let viewModel = WithdrawViewModel(useCase: dependency.profileEditUseCase) let viewControllerable = WithdrawViewController(viewModel: viewModel) - + let withdrawAuth = WithdrawAuthContainer(dependency: self) let coordinator = WithdrawCoordinator( viewControllerable: viewControllerable, viewModel: viewModel, withdrawAuthContainable: withdrawAuth ) - + coordinator.listener = listener - + return coordinator } } diff --git a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawCoordinator.swift b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawCoordinator.swift index 097ef5bf..d6ca463f 100644 --- a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawCoordinator.swift +++ b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawCoordinator.swift @@ -20,10 +20,10 @@ final class WithdrawCoordinator: ViewableCoordinator { weak var listener: WithdrawListener? private let viewModel: WithdrawViewModel - + private let withdrawAuthContainable: WithdrawAuthContainable private var withdrawAuthCoordinator: ViewableCoordinating? - + init( viewControllerable: ViewControllerable, viewModel: WithdrawViewModel, @@ -32,7 +32,7 @@ final class WithdrawCoordinator: ViewableCoordinator { self.viewModel = viewModel self.withdrawAuthContainable = withdrawAuthContainable super.init(viewControllerable) - viewModel.coodinator = self + viewModel.coordinator = self } } @@ -40,16 +40,16 @@ final class WithdrawCoordinator: ViewableCoordinator { @MainActor extension WithdrawCoordinator { func attachWithdrawAuth() { guard withdrawAuthCoordinator == nil else { return } - + let coordinator = withdrawAuthContainable.coordinator(listener: self) addChild(coordinator) viewControllerable.pushViewController(coordinator.viewControllerable, animated: true) self.withdrawAuthCoordinator = coordinator } - - func withdrawAuthPassword() { + + func detachWithdrawAuth() { guard let coordinator = withdrawAuthCoordinator else { return } - + removeChild(coordinator) viewControllerable.popViewController(animated: true) self.withdrawAuthCoordinator = nil @@ -61,22 +61,30 @@ extension WithdrawCoordinator: WithdrawCoordinatable { func didTapBackButton() { listener?.didTapBackButtonAtWithdraw() } - + func didTapCancelButton() { listener?.didTapBackButtonAtWithdraw() } + + func withdrawalSucceed() { + listener?.didFinishWithdrawal() + } + + func authenticatedFailed() { + listener?.authenticatedFailedAtWithdraw() + } } // MARK: - WithdrawAuthListener extension WithdrawCoordinator: WithdrawAuthListener { func didTapBackButtonAtWithdrawAuth() { - Task { await withdrawAuthPassword() } + Task { await detachWithdrawAuth() } } - + func didFinishWithdrawal() { listener?.didFinishWithdrawal() } - + func authenticatedFailedAtWithdrawAuth() { listener?.authenticatedFailedAtWithdraw() } diff --git a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewController.swift b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewController.swift index b99fbf1b..93e1dc91 100644 --- a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewController.swift +++ b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewController.swift @@ -7,46 +7,53 @@ // import UIKit +import Combine import Coordinator -import RxSwift -import RxCocoa import SnapKit import CoreUI import DesignSystem final class WithdrawViewController: UIViewController, ViewControllerable { private let viewModel: WithdrawViewModel - - private let disposeBag = DisposeBag() + + private var cancellables = Set() + private let didConfirmOAuthWithdrawSubject = PassthroughSubject() + // MARK: - UIComponents private let navigationBar = PhotiNavigationBar(leftView: .backButton, displayMode: .dark) - + private let titleLabel = { let label = UILabel() label.attributedText = "탈퇴 후 계정 복구는 불가해요 \n정말 탈퇴하시겠어요?".attributedString(font: .heading2, color: .gray900) label.numberOfLines = 2 label.textAlignment = .center - + return label }() private let withdrawButton = LineRoundButton(text: "탈퇴 계속하기", type: .primary, size: .xLarge) private let cancelButton = LineRoundButton(text: "취소하기", type: .quaternary, size: .xLarge) - + + private let loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .large) + indicator.hidesWhenStopped = true + return indicator + }() + // MARK: - Initializers init(viewModel: WithdrawViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } - + @available(*, deprecated) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + // MARK: - View LifeCycle override func viewDidLoad() { super.viewDidLoad() - + setupUI() bind() } @@ -59,64 +66,109 @@ private extension WithdrawViewController { setViewHierarchy() setConstraints() } - + func setViewHierarchy() { self.view.addSubviews( navigationBar, titleLabel, withdrawButton, - cancelButton + cancelButton, + loadingIndicator ) } - + func setConstraints() { navigationBar.snp.makeConstraints { $0.leading.trailing.equalToSuperview() $0.top.equalToSuperview().offset(44) $0.height.equalTo(56) } - + titleLabel.snp.makeConstraints { $0.top.equalTo(navigationBar.snp.bottom).offset(54) $0.centerX.equalToSuperview() } - + cancelButton.snp.makeConstraints { $0.leading.trailing.equalToSuperview().inset(24) $0.bottom.equalToSuperview().inset(56) } - + withdrawButton.snp.makeConstraints { $0.leading.trailing.equalToSuperview().inset(24) $0.bottom.equalTo(cancelButton.snp.top).offset(-16) } + + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } } } // MARK: - Bind Methods private extension WithdrawViewController { func bind() { - let backButtonEvent: ControlEvent = { - let events = Observable.create { [weak navigationBar] observer in - guard let bar = navigationBar else { return Disposables.create() } - let cancellable = bar.didTapBackButton - .sink { observer.onNext(()) } - return Disposables.create { cancellable.cancel() } - } - return ControlEvent(events: events) - }() - let input = WithdrawViewModel.Input( - didTapBackButton: backButtonEvent, - didTapWithdrawButton: withdrawButton.rx.tap, - didTapCancelButton: cancelButton.rx.tap + didTapBackButton: navigationBar.didTapBackButton, + didTapWithdrawButton: withdrawButton.tapPublisher, + didTapCancelButton: cancelButton.tapPublisher, + didConfirmOAuthWithdraw: didConfirmOAuthWithdrawSubject.eraseToAnyPublisher() ) - + let output = viewModel.transform(input: input) bind(for: output) } - - func bind(for output: WithdrawViewModel.Output) { } + + func bind(for output: WithdrawViewModel.Output) { + output.showOAuthAlert + .receive(on: DispatchQueue.main) + .sink { [weak self] providerName in + self?.showOAuthWithdrawAlert(providerName: providerName) + } + .store(in: &cancellables) + + output.isLoading + .receive(on: DispatchQueue.main) + .sink { [weak self] isLoading in + guard let self else { return } + if isLoading { + loadingIndicator.startAnimating() + withdrawButton.isEnabled = false + cancelButton.isEnabled = false + } else { + loadingIndicator.stopAnimating() + withdrawButton.isEnabled = true + cancelButton.isEnabled = true + } + } + .store(in: &cancellables) + + output.networkUnstable + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.presentNetworkUnstableAlert() + } + .store(in: &cancellables) + } + + func showOAuthWithdrawAlert(providerName: String) { + let alert = AlertViewController( + alertType: .canCancel, + title: "\(providerName) 계정 연결을 해제하고\n탈퇴하시겠습니까?", + subTitle: "탈퇴 시 모든 데이터가 삭제됩니다.", + shouldDismissOnConfirm: false + ) + alert.setConfirmButtonText("탈퇴하기") + + alert.didTapConfirmButton + .sink { [weak self, weak alert] in + alert?.dismiss(animated: true) + self?.didConfirmOAuthWithdrawSubject.send(()) + } + .store(in: &cancellables) + + alert.present(to: self, animted: true) + } } // MARK: - ResignPresentable diff --git a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewModel.swift b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewModel.swift index 999eee91..66151199 100644 --- a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewModel.swift +++ b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewModel.swift @@ -6,57 +6,136 @@ // Copyright © 2024 com.photi. All rights reserved. // -import RxCocoa -import RxSwift +import Combine +import Core +import Entity +import UseCase protocol WithdrawCoordinatable: AnyObject { @MainActor func attachWithdrawAuth() func didTapBackButton() func didTapCancelButton() + func withdrawalSucceed() + func authenticatedFailed() } protocol WithdrawViewModelType: AnyObject { associatedtype Input associatedtype Output - - var disposeBag: DisposeBag { get } - var coodinator: WithdrawCoordinatable? { get set } + + var cancellables: Set { get } + var coordinator: WithdrawCoordinatable? { get set } } final class WithdrawViewModel: WithdrawViewModelType { - let disposeBag = DisposeBag() - - weak var coodinator: WithdrawCoordinatable? - + var cancellables = Set() + + weak var coordinator: WithdrawCoordinatable? + + private let useCase: ProfileEditUseCase + private let showOAuthAlertSubject = PassthroughSubject() + private let isLoadingSubject = CurrentValueSubject(false) + private let networkUnstableSubject = PassthroughSubject() + // MARK: - Input struct Input { - let didTapBackButton: ControlEvent - let didTapWithdrawButton: ControlEvent - let didTapCancelButton: ControlEvent + let didTapBackButton: AnyPublisher + let didTapWithdrawButton: AnyPublisher + let didTapCancelButton: AnyPublisher + let didConfirmOAuthWithdraw: AnyPublisher } - + // MARK: - Output - struct Output { } - + struct Output { + let showOAuthAlert: AnyPublisher + let isLoading: AnyPublisher + let networkUnstable: AnyPublisher + } + // MARK: - Initializers - init() { } - + init(useCase: ProfileEditUseCase) { + self.useCase = useCase + } + func transform(input: Input) -> Output { input.didTapBackButton - .bind(with: self) { onwer, _ in - onwer.coodinator?.didTapBackButton() - }.disposed(by: disposeBag) - + .sink { [weak self] in + self?.coordinator?.didTapBackButton() + } + .store(in: &cancellables) + input.didTapWithdrawButton - .bind(with: self) { onwer, _ in - Task { await onwer.coodinator?.attachWithdrawAuth() } - }.disposed(by: disposeBag) - + .sink { [weak self] in + guard let self else { return } + let providerString = ServiceConfiguration.shared.authProvider + let provider = AuthProvider(rawValue: providerString) ?? .normal + Task { + if provider == .normal { + await self.coordinator?.attachWithdrawAuth() + } else { + self.showOAuthAlertSubject.send(self.providerDisplayName(provider)) + } + } + } + .store(in: &cancellables) + input.didTapCancelButton - .bind(with: self) { onwer, _ in - onwer.coodinator?.didTapCancelButton() - }.disposed(by: disposeBag) - - return Output() + .sink { [weak self] in + self?.coordinator?.didTapCancelButton() + } + .store(in: &cancellables) + + input.didConfirmOAuthWithdraw + .sink { [weak self] in + guard let self else { return } + let providerString = ServiceConfiguration.shared.authProvider + let provider = AuthProvider(rawValue: providerString) ?? .normal + Task { await self.withdrawOAuth(provider: provider) } + } + .store(in: &cancellables) + + return Output( + showOAuthAlert: showOAuthAlertSubject.eraseToAnyPublisher(), + isLoading: isLoadingSubject.eraseToAnyPublisher(), + networkUnstable: networkUnstableSubject.eraseToAnyPublisher() + ) + } +} + +// MARK: - Private Methods +private extension WithdrawViewModel { + func providerDisplayName(_ provider: AuthProvider) -> String { + switch provider { + case .kakao: return "카카오" + case .google: return "구글" + case .apple: return "애플" + case .normal: return "" + } + } + + @MainActor + func withdrawOAuth(provider: AuthProvider) async { + isLoadingSubject.send(true) + + do { + try await useCase.withdraw(with: .oauth(provider: provider)) + coordinator?.withdrawalSucceed() + } catch { + isLoadingSubject.send(false) + handleError(error) + } + } + + func handleError(_ error: Error) { + guard let error = error as? APIError else { + return networkUnstableSubject.send(()) + } + + switch error { + case .authenticationFailed, .userNotFound: + coordinator?.authenticatedFailed() + default: + networkUnstableSubject.send(()) + } } } From d8e8a4d63c889853c0393c9101453108e0c12c8d Mon Sep 17 00:00:00 2001 From: "rui.bao" Date: Mon, 18 May 2026 18:21:57 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[REFACT]:=20withdrawOAuth=EC=97=90=20defer?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=95=88=EC=A0=84=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .../Setting/ProfileEdit/Withdraw/WithdrawViewModel.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewModel.swift b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewModel.swift index 66151199..e71d933d 100644 --- a/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewModel.swift +++ b/Projects/Presentation/MyPage/Implementations/Setting/ProfileEdit/Withdraw/WithdrawViewModel.swift @@ -115,6 +115,8 @@ private extension WithdrawViewModel { @MainActor func withdrawOAuth(provider: AuthProvider) async { + defer { isLoadingSubject.send(false) } + isLoadingSubject.send(true) do {