Skip to content
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

[Feat] 자동로그인 Reactorkit - pulse 도입 #409

Open
wants to merge 9 commits into
base: suyeon
Choose a base branch
from

Conversation

hooni0918
Copy link
Member

@hooni0918 hooni0918 commented Feb 25, 2025

🔗 연결된 이슈

📄 작업 내용

  • ReactorKit의 Pulse 구현
  • 로그인 중복 호출 문제 해결

기능 내용이기에 GIF는 생략합니다

💻 About Pulse

문제발생

현재 로그인 기준으로, 앱 첫 설치후 첫 로그인 / 회원가입 버튼을 클릭시 네트워크 response가 약간 딜레이되고있는 상황입니다. 현재 이 지연중 로그인 버튼을 여러번 누르게 되었을때 동일한 API가 두번 사용되는 문제가 있었습니다. (Error Alert도 중복으로 날아오는 문제 발생)

현재 옵저버블 객체로 사용하고있는 해당 문제 대신, Rxswift를 사용하여 해당 문제를 해결하려 했습니다.

RxSwift 적용 고민

초기에는 RxSwift를 활용하여 이 문제를 해결하고자 했습니다만

  1. PublishRelay는 지속적인 이벤트 스트림에 최적화되어 있어, 로그인과 같은 단일 이벤트 처리에는 적합하지 않았습니다.
  2. BehaviorSubjectReplaySubject는 이벤트 값을 캐싱할 수 있지만, 완료 후에는 새로운 구독이 불가능하다는 제약이 있었습니다. 특히 로그인 후 화면 전환 과정에서 새로운 컨트롤러가 이전 이벤트 결과에 접근해야 하는 상황에서 문제가 되었습니다.
  3. 로그인 컨텍스트(카카오 로그인 vs 애플 로그인)를 유지하기 위해서는 복잡한 타입 정의와 연산자 체인이 필요했습니다.
  4. 기존 프로젝트의 콜백 패턴을 크게 변경하고 싶지는 않았습니다.

Pulse 도입

이러한 제약을 해결하기 위해 ReactorKit의 Pulse 개념에서 영감을 얻은 커스텀 코드를 사용하게 되었습니다.

(공부하는 김에 도입한것도 물론 있습니다,,ㅎㅎ)

  1. 이벤트가 한 번만 발생하도록 보장하는 isConsumed 플래그를 통해 중복 API 호출 문제를 효과적으로 해결했습니다.
  2. 이벤트 발생 후에도 값을 유지하여 나중에 등록된 구독자에게 전달할 수 있어, 화면 전환 시 이전 결과에 접근이 가능했습니다.
  3. 복잡한 타입 정의 없이도 이벤트 컨텍스트 정보를 쉽게 포함할 수 있었습니다.
  4. 기존의 콜백 패턴과 자연스럽게 통합되어 코드 변경을 최소화할 수 있었습니다.

Pulse란?

지속적인 상태로 유지되어야 하는 데이터와 단 한 번만 발생해야 하는 이벤트를 구분하는 방법을 위해 사용됩니다. Pulse일회성 이벤트를 처리하기 위해 설계된 메커니즘입니다.

ReactorKit 프레임워크에서는 @Pulse 프로퍼티 래퍼로 구현되어 있으며, 이는 특정 상태 값이 변경될 때만 옵저버가 트리거되도록 합니다.

ReactorKit과 같은 구조화된 아키텍처에서는 일반적으로 상태(state)를 단일 진실의 원천(single source of truth)으로 관리하게 되는데, 모든 UI 관련 이벤트가 지속적인 상태로 취급되어야 하는 것은 아닙니다.
알림, 경고, 네비게이션 트리거, 오류 메시지와 같은 이벤트는 한 번만 발생해야 하며, 뷰가 다시 로드되거나 애플리케이션 상태가 변경될 때 반복되어서는 안 됩니다.

Pulse는 이러한 일회성 이벤트를 지속적인 상태 관리 시스템과 별도로 발행하고 소비하는 인스턴스를 제공해서 문제를 해결해버립니다람쥐

Pulse의 작동 방식

Pulse의 가장 기본적인 특징은 한 번만 소비(consume once) 동작입니다. Pulse를 통해 이벤트가 발행되면, 이 이벤트는 리스너에게 전달되지만 단 한 번만 발생합니다. 이는 isConsumed 플래그를 통해 구현되며, 이벤트가 한 번 발생한 후에는 추가 이벤트 발행이 차단됩니다.

또 다른 중요한 특징은 이벤트 캐싱입니다. Pulse는 발행된 이벤트 값을 저장하고, 나중에 리스너가 등록되면 이미 소비된 이벤트라도 해당 값을 전달합니다. 이는 화면 전환 후에도 이전 이벤트 결과에 접근해야 하는 경우에 특히 유용합니다.

ReactorKit에서의 @Pulse

(모두가 Rx를 알고있으니,,,자세한 코드 설명은 빼겟습니다)

ReactorKit 프레임워크에서 @Pulse는 프로퍼티 래퍼로 구현되어 있으며, 특정 상태 값이 변경될 때만 옵저버가 트리거되도록 합니다:

struct State {
    var value: Int
    var isLoading: Bool
    @Pulse var alertMessage: String?
}

일반 상태 변수와 달리, @Pulse로 표시된 변수는 이 값이 변경될 때만 구독자에게 알림을 보냅니다. 이는 불필요한 UI 업데이트를 방지하고, 일회성 이벤트 처리를 단순화합니다.

reactor.pulse(\.$alertMessage)
  .compactMap { $0 }
  .subscribe(onNext: { [weak self] message in
    // 알림 표시 로직
  })
  .disposed(by: disposeBag)

실사용하는 경우

Pulse는 다음과 같은 상황에서 특히 유용합니다:

  1. 네비게이션 이벤트: 특정 조건이 충족될 때 한 번만 다른 화면으로 이동
  2. 알림 메시지: 사용자에게 오류나 성공 메시지를 중복 없이 한 번만 표시
  3. 애니메이션 트리거: 특정 상태 변화에 의해 애니메이션이 한 번만 실행되도록 함
  4. 폼 검증 결과: 폼 제출 결과를 한 번만 표시
  5. 권한 요청: 앱에서 특정 권한을 요청한 후 결과를 사용자에게 알림

RxSwift vs Pulse

RxSwift와Pulse의 일회성 이벤트 처리에는 몇 가지 한계가 있습니다:

  1. PublishRelay/Subject: 이들은 지속적인 데이터 스트림을 처리하는 데 최적화되어 있어, 일회성 이벤트 처리와는 다른 다릅니다.
  2. BehaviorSubject/ReplaySubject: 값을 캐싱할 수 있지만, 완료된 후에는 새로운 구독이 불가능한 제약이 있습니다.

반면 Pulse는 일회성 이벤트 처리를 위해 특별히 설계되었으며, 이벤트가 한 번만 발생하도록 보장하면서도 필요한 경우 값을 캐싱하고 전달할 수 있습니다.

결론

Pulse는 일회성 이벤트와 지속적인 상태를 명확하게 구분하여 관리할 수 있는 강력한 도구이다!

코드설명

현재 리뷰하시면서 이해하기 편하도록 기본 주석과 describtion을 많이 작성해두었는데 이부분은 코드리뷰 이후 삭제하도록 하겟습니다.

1. Pulse 클래스

class Pulse<T> {
    typealias Listener = (T) -> Void

    private var value: T?
    private var listeners: [Listener] = []
    private var isConsumed = false

    // 이벤트 발행
    func emit(_ value: T) {
        guard !isConsumed else { return }

        self.value = value
        isConsumed = true

        listeners.forEach { $0(value) }
        listeners.removeAll()
    }

    // 구독 등록
    func subscribe(_ listener: @escaping Listener) {
        if let value = value, isConsumed {
            listener(value)
        } else {
            listeners.append(listener)
        }
    }

    // 약한 참조로 구독 등록 (DisPoseBag 사용 X)
    func subscribe<O: AnyObject>(with object: O, listener: @escaping (O, T) -> Void) {
        //....
    }

    // 상태 초기화
    func reset() {
        value = nil
        isConsumed = false
        listeners.removeAll()
    }
}
  1. 일회성 이벤트 보장: isConsumed 플래그를 통해 이벤트가 단 한 번만 발생하도록 보장합니다.
  2. 값 캐싱: 발생한 이벤트의 값을 저장하고, 새로운 구독자에게 자동으로 전달합니다.

2. LoginViewModel에서의 Pulse 활용

LoginViewModel.swift에서는 세 가지 Pulse 인스턴스를 사용하여 각기 다른 유형의 일회성 이벤트를 처리합니다

private(set) var loginResultPulse = Pulse<Result<SocialLoginResponseModel, Error>>()
private(set) var navigationPulse = Pulse<LoginNavigation>()
private(set) var errorPulse = Pulse<String>()
각 Pulse의 역할:
  • loginResultPulse: 로그인 API 호출 결과를 전달합니다. 성공 또는 실패 정보를 담고 있습니다.
  • navigationPulse: 로그인 결과에 따라 다음 화면으로의 네비게이션 이벤트를 전달합니다 (메인 화면, 온보딩 화면, 오류 알림).
  • errorPulse: 오류 메시지를 전달합니다.

이 인스턴스들을 통해

// 에러 발생 시 자동으로 내비게이션 pulse에 에러 메시지 전달
private func setupBindings() {
    errorPulse.subscribe { [weak self] errorMessage in
        if !errorMessage.isEmpty {
            self?.navigationPulse.emit(.showError(message: errorMessage))
        }
    }
}

errorPulse에서 에러 메시지가 발생하면 자동으로 navigationPulse를 통해 오류 알림 화면으로 네비게이션하도록 합니다. 즉, 두 이벤트 스트림을 연결해줍니다.

private func handleLoginResponse(_ response: ResponseBodyDTO<SocialLoginResponseModel>) {
    switch (response.success, response.data) {
    case (true, let data?):
        saveTokens(
            accessToken: data.jwtTokenDTO.accessToken,
            refreshToken: data.jwtTokenDTO.refreshToken
        )
        loginResultPulse.emit(.success(data))

        // Pulse 객체 재설정
        navigationPulse = Pulse<LoginNavigation>()

        switch data.name {
        case .some:
            loginState = .login
            navigationPulse.emit(.toMain)
            // 메인 화면으로 네비게이션
        case .none:
            loginState = .needOnboarding
            navigationPulse.emit(.toOnboarding)
            // 온보딩 화면으로 네비게이션
        }

    default:
        let errorMessage = response.error?.message ?? "Unknown error occurred"
        errorPulse.emit(errorMessage)
        loginState = .notLogin
    }
}

로그인 성공 후 navigationPulse = Pulse<LoginNavigation>()로 Pulse 객체를 재설정하는 부분입니다. 이는 이전의 네비게이션 이벤트를 초기화하고 새로운 이벤트만 처리하도록 보장합니다.

3. LoginViewController에서 Pulse 구독

LoginViewController.swift에서는 ViewModel의 Pulse 이벤트를 구독하여 UI 업데이트와 화면 전환을 처리합니다

private func setupBindings() {
    // 상태 변경 콜백
    viewModel.loginStateChanged = {state in
        print("Login state changed: \(state)")
    }

    // 로그인 결과 Pulse 구독
    viewModel.loginResultPulse.subscribe(with: self) { owner, result in
        switch result {
        case .success(let model):
            print("Login success with user: \(model.name ?? "no name")")
        case .failure(let error):
            print("Login failed with error: \(error)")
        }
    }

    // 네비게이션 Pulse 구독
    viewModel.navigationPulse.subscribe(with: self) { owner, navigation in
        // 중복 네비게이션 방지
        guard !owner.isNavigating else { return }
        owner.isNavigating = true

        switch navigation {
        case .toMain:
            owner.navigateToMainScreen()
        case .toOnboarding:
            owner.navigateToOnboardingScreen()
        case .showError(let message):
            owner.showErrorAlert(message: message)
        }
    }
}
  • 네비게이션 이벤트 처리 시 isNavigating 플래그를 사용하여 중복 네비게이션을 방지합니다.
  • 이벤트 유형에 따라 적절한 화면 전환 메서드를 호출합니다.

4. SceneDelegate에서의 Pulse 활용

SceneDelegate.swift에서도 LoginViewModel의 navigationPulse를 구독하여 화면 전환을 합니다.

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    //...

    print("Setting up navigation pulse subscription")
    loginViewModel.navigationPulse.subscribe(with: self) { [weak self] (owner, navigation) in
        print("🚀 Navigation pulse received: \(navigation)")
        guard let self = self else { return }

        DispatchQueue.main.async {
            switch navigation {
            case .toMain:
                self.showMainScreen()
                print("🏠 Navigating to main screen")
            case .toOnboarding:
                let nicknameViewModel = NicknameViewModel()
                let nicknameViewController = NicknameViewController(viewModel: nicknameViewModel)
                let navigationController = UINavigationController(
                    rootViewController: nicknameViewController,
                    isBorderNeeded: false
                )
                self.animateRootViewControllerChange(to: navigationController)
                print("👤 Navigating to onboarding")

            case .showError(let message):
                print("Login error: \(message)")
                self.showLoginScreen()
            }
        }
    }

    performAutoLogin()
}

자동로그인 코드를 여기서 실행합니다.

5. 버튼 중복 클릭 문제 해결

가장 큰 문제였던 중복 로그인호출 문제를 해결한 과정입니다.

  1. LoginViewModel에서:
    이 코드 자체에는 중복 클릭 방지 로직이 없지만, Pulse의 isConsumed 플래그를 통해 동일한 API 호출이 여러 번 발생하더라도 네비게이션 이벤트는 한 번만 처리됩니다.

    func performKakaoLogin() {
        if UserApi.isKakaoTalkLoginAvailable() {
            UserApi.shared.loginWithKakaoTalk { [weak self] (oauthToken, error) in
                self?.handleKakaoLoginResult(oauthToken: oauthToken, error: error)
            }
        } else {
            UserApi.shared.loginWithKakaoAccount { [weak self] (oauthToken, error) in
                self?.handleKakaoLoginResult(oauthToken: oauthToken, error: error)
            }
        }
    }
  2. LoginViewController에서:

여기서는 isNavigating 플래그를 추가로 사용하여 UI 레벨에서도 중복 네비게이션을 방지합니다.

    viewModel.navigationPulse.subscribe(with: self) { owner, navigation in
        // 중복 네비게이션 방지
        guard !owner.isNavigating else { return }
        owner.isNavigating = true
    
        // 네비게이션 처리...
    }

참고한곳

https://github.com/ReactorKit/ReactorKit/blob/master/Sources/ReactorKit/Pulse.swift

👀 기타 더 이야기해볼 점

ReactorKit의 정확한 이해와 Pulse의 정확한 이해가 저도 많이 부족합니다만 열심히 해보았습니다 매서운 코드리뷰 부탁드리고 이해가 안가거나 질문 있으시다면 남겨주세요 제가 모르는 부분일수 있는데 같이 공부해 보아요!

@hooni0918 hooni0918 self-assigned this Feb 25, 2025
@hooni0918 hooni0918 added ✨ feat 기능 구현시 사용 🧡 JiHoon 쌈뽕한 플러팅 꿀팁을 듣고 싶다면 labels Feb 25, 2025
Copy link
Contributor

@JinUng41 JinUng41 left a comment

Choose a reason for hiding this comment

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

좋은 코드 감사합니다.

다만 기존의 Rx 코드에서도 가능하지 않나 싶어서 코멘트를 남겨봅니다.
debounce로 요청을 무시하거나 혹은, removeDuplicates로 중복되는 이벤트는 무시하게 하거나, take(1)을 이용하여 1회만 이벤트를 수신하는 방법으로도 가능할 것 같습니다만 어떻게 생각하실까요?

@hooni0918
Copy link
Member Author

@JinUng41
사실 RxSwift의 다양한 Operator를 사용하는게 가장 베스트가 맞다고 생각합니다. 말씀주신 Operator를 사용하면 동일한 이슈를 해결할수 있긴합니다.

하지만 지금 구현은 현재 옵저버블 객체를 최대한 변경하지 않고 (Rx로 리팩토링하지 않고) 작업한 내용이기에 그부분을 중점적으로 봐주시면 좋을것 같습니다!

물론,, 커스텀이 너무 많아져서 Rx로의 전환이 좀더 올바른 방향이였을수도 있다는 생각이 드네요ㅋㅋㅋ

Copy link
Member

@youz2me youz2me left a comment

Choose a reason for hiding this comment

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

굿~ 친절한 PR 감사드립니다. 상태 관리를 조금 더 효율적으로 해볼 수 있을 것 같아요.
저도 조만간 로그인 로직 구현해야 하는데 코드 참고해서 열심히 해보겠습니다 ... 감사합니다!

Comment on lines -2231 to +2237
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = D2DRA3F792;
DEVELOPMENT_TEAM = D2DRA3F792;
Copy link
Member

Choose a reason for hiding this comment

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

어이구 이거 왜 이렇게 됐지요

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
✨ feat 기능 구현시 사용 🧡 JiHoon 쌈뽕한 플러팅 꿀팁을 듣고 싶다면
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[feat] Image Chaes 개선
3 participants