Skip to content

[feat/#14] google login setting#17

Open
SijongKim93 wants to merge 126 commits intodevelopfrom
feat/#14-GoogleLogin-Setting
Open

[feat/#14] google login setting#17
SijongKim93 wants to merge 126 commits intodevelopfrom
feat/#14-GoogleLogin-Setting

Conversation

@SijongKim93
Copy link
Collaborator

@SijongKim93 SijongKim93 commented Jun 12, 2025

🔗 연결된 이슈

💻 주요 코드 설명

1. Entity (Domain Layer)

swift
public struct LoginUser {
    public let email: String
    public let accessToken: String?
    public let isExistingUser: Bool?
    public let isOnboardingCompleted: Bool

    public init(email: String, accessToken: String?, isExistingUser: Bool?, isOnboardingCompleted: Bool = false) {
        self.email = email
        self.accessToken = accessToken
        self.isExistingUser = isExistingUser
        self.isOnboardingCompleted = isOnboardingCompleted
    }
}

2. Interface Layer

swift
// Interface/AuthRepositoryInterface.swift
public protocol AuthRepositoryInterface {
    func loginWithGoogle(accessToken: String, name: String?) async throws -> LoginUser
}

// Interface/GoogleAuthServiceInterface.swift
public protocol GoogleAuthServiceInterface {
    func signIn() async throws -> (accessToken: String, name: String?, email: String?)
    func signOut()
}

// Interface/LoginWithGoogleUseCaseInterface.swift
public protocol LoginWithGoogleUseCaseInterface {
    func execute(accessToken: String, name: String?) async throws -> LoginUser
}

Repository와 외부 서비스들을 인터페이스로 추상화 구현했습니다

3. UseCase

public final class LoginWithGoogleUseCase: LoginWithGoogleUseCaseInterface {
    private let authRepository: AuthRepositoryInterface

    public func execute(accessToken: String, name: String?) async throws -> LoginUser {
// 현재는 단순 Repository 호출이지만, 향후 로그인 관련 비즈니스 룰 추가 예정// 예: 로그인 시도 횟수 제한, 분석 이벤트 전송, 사용자 활동 로깅 등
        return try await authRepository.loginWithGoogle(accessToken: accessToken, name: name)
    }
}

로그인 관련 비즈니스 로직을 담당합니다.

4. Repository

public final class AuthRepositoryImpl: AuthRepositoryInterface {
    private let networkService: NetworkServiceInterface

    public func loginWithGoogle(accessToken: String, name: String?) async throws -> LoginUser {
        var parameters: [String: Any] = ["accessToken": accessToken]
        if let name = name {
            parameters["name"] = name
        }

        let endpoint = "/api/v1/login/google"
        let apiDTO: ApiResponseGoogleLoginDTO = try await networkService.post(
            endpoint: endpoint,
            parameters: parameters
        )

        guard apiDTO.result == "SUCCESS" else {
            let errorMessage = apiDTO.error?.message ?? "Server Error"
            throw NetworkError.serverError(errorMessage)
        }

        return apiDTO.data.toDomain()
    }
}

네트워크 통신을 통한 서버 API 호출과 DTO → Domain Entity 변환을 담당합니다.

5. DTO (Data Transfer Object) - 서버 통신 데이터 구조

swift
// Request DTO
public struct LoginRequestDTO: Encodable {
    public let accessToken: String
    public let name: String?

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(accessToken, forKey: .accessToken)
        if let name = name {
            try container.encode(name, forKey: .name)
        }
    }
}

// Response DTO
public struct GoogleLoginResponseDTO: Decodable {
    let email: String
    let exists: Bool
    let accessToken: String
}

public struct ApiResponseGoogleLoginDTO: Decodable {
    let result: String
    let data: GoogleLoginResponseDTO
    let error: ErrorMessageDTO?
}

// DTO → Domain 변환
extension GoogleLoginResponseDTO {
    func toDomain() -> LoginUser {
        return LoginUser(
            email: self.email,
            accessToken: self.accessToken,
            isExistingUser: self.exists
        )
    }
}

전달 받은 Swagger를 바탕으로 서버 통신 데이터 구조를 정의하고, 조건부 인코딩으로 기존/신규 사용자를 구분 처리하도록 구현하였습니다.

6. GoogleAuthService

public final class GoogleAuthService: GoogleAuthServiceInterface {
    public func signIn() async throws -> (accessToken: String, name: String?, email: String?) {
        guard let scene = await UIApplication.shared.connectedScenes.first as? UIWindowScene,
              let root = await scene.windows.first?.rootViewController else {
            throw NSError(domain: "GoogleAuthService", code: -1,
                         userInfo: [NSLocalizedDescriptionKey: "RootViewController를 찾을 수 없습니다."])
        }

        let result = try await GIDSignIn.sharedInstance.signIn(
            withPresenting: root,
            hint: nil,
            additionalScopes: ["profile", "email"]
        )

        let user = result.user
        let accessToken = user.accessToken.tokenString
        await verifyGoogleToken(accessToken)

        return (accessToken: accessToken, name: user.profile?.name, email: user.profile?.email)
    }
}

7. NetworkService (Infrastructure Layer) - HTTP 통신

swift
public class NetworkService: NetworkServiceInterface {
    private let baseURL: String
    private let headers: HTTPHeaders

    public func post<T: Decodable>(endpoint: String, parameters: [String: Any]?) async throws -> T {
        let url = baseURL + endpoint

        return try await withCheckedThrowingContinuation { continuation in
            AF.request(url, method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers)
                .validate()
                .responseDecodable(of: T.self) { response in
                    switch response.result {
                    case .success(let value):
                        continuation.resume(returning: value)
                    case .failure(let error):
                        continuation.resume(throwing: NetworkError.serverError(error.localizedDescription))
                    }
                }
        }
    }
}

Alamofire를 활용한 HTTP 통신을 추상화하여 구현하였습니다.

8. View Layer (Presentation) - 사용자 인터랙션

Button(action: {
    store.send(.signInWithGoogle)// Intent 전달
}) {
    HStack {
        if store.state.isLoading {
            ProgressView().scaleEffect(0.8)
        } else {
            Image(systemName: "globe")
        }
        Text("Google 계정으로 시작")
    }
}
.disabled(store.state.isLoading)// 중복 요청 방지

사용자 터치 이벤트를 Intent로 변환하여 Store에 전달하며, 로딩 상태에 따른 UI 업데이트를 처리합니다.

9. Store Layer (Presentation) - 상태 관리 및 플로우 제어

swift
@MainActor
public final class LoginStore: ObservableObject {
    @Published private(set) var state = LoginState()
    let effect = PassthroughSubject<LoginEffect, Never>()

    public func send(_ intent: LoginIntent) {
        switch intent {
        case .signInWithGoogle:
            handleGoogleSignIn()// 복잡한 로그인 플로우 시작
        case .signInSucceeded(let user):
            state.isSignedIn = true
            state.userEmail = user.email
            state.isLoading = false
            effect.send(.navigateToHome)// 사이드 이펙트 발생
        case .signInFailed(let error):
            state.errorMessage = error.localizedDescription
            state.isLoading = false
            effect.send(.showError(message: error.localizedDescription))
        }
    }
}

TCA 패턴의 Intent-State-Effect 구조로 단방향 데이터 플로우를 구현하고, 사이드 이펙트를 분리하여 관리합니다.

10. 복합 로그인 플로우 - 기존/신규 사용자 자동 구분

swift
private func handleGoogleSignIn() {
    state.errorMessage = ""
    state.isLoading = true

    Task {
        do {
// 1단계: Google에서 토큰 획득
            let signInResult = try await googleAuthService.signIn()

            do {
// 2단계: 기존 사용자로 로그인 시도 (name 없이)
                let user = try await loginUseCase.execute(
                    accessToken: signInResult.accessToken,
                    name: nil
                )
                send(.signInSucceeded(user: user))

            } catch {
// 3단계: 401 에러 시 신규 사용자로 회원가입
                if let networkError = error as? NetworkError,
                   case .serverError(let message) = networkError,
                   message.contains("401") || message.contains("Unauthorized") {

                    guard let profileName = signInResult.name, !profileName.isEmpty else {
                        throw NSError(domain: "", code: -1,
                                    userInfo: [NSLocalizedDescriptionKey: "회원가입을 위한 이름을 가져올 수 없습니다."])
                    }

                    let user = try await loginUseCase.execute(
                        accessToken: signInResult.accessToken,
                        name: profileName
                    )
                    send(.signInSucceeded(user: user))
                } else {
                    send(.signInFailed(error: error))
                }
            }
        } catch {
            send(.signInFailed(error: error))
        }
    }
}

2단계 로그인 시도를 통해 기존 사용자는 즉시 로그인하고, 신규 사용자는 자동으로 회원가입 처리하여 방식으로 전달받아 임시적으로 이런 플로우로 개발!

11. 앱 설정 및 초기화

swift
// LoginConfiguration.swift
public final class LoginConfiguration {
    public static func configure() {
        let config = GIDConfiguration(
            clientID: GoogleConfig.clientID,
            serverClientID: GoogleConfig.serverClientID
        )
        GIDSignIn.sharedInstance.configuration = config
    }

    public static func handleOpenURL(_ url: URL) -> Bool {
        return GIDSignIn.sharedInstance.handle(url)
    }
}

// AppDelegate.swift
class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        LoginConfiguration.configure()
        return true
    }

    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool {
        return LoginConfiguration.handleOpenURL(url)
    }
}

Google SDK 초기화와 URL 스킴 처리를 Login 모듈 내부로 캡슐화하여 관심사를 분리했습니다.

로그인 로직 데이터 플로우

    User->>View: "Google 로그인" 버튼 클릭
    View->>Store: send(.signInWithGoogle)

    Store->>Google: signIn() 호출
    Google->>User: Google 로그인 팝업 표시
    User->>Google: 구글 계정으로 로그인
    Google->>Store: (accessToken, name, email) 반환

    Note over Store: 1차 시도: 기존 사용자 로그인
    Store->>UseCase: execute(accessToken, name: nil)
    UseCase->>Repo: loginWithGoogle(accessToken, nil)
    Repo->>Network: POST /api/v1/login/google
    Network->>Server: HTTP Request (accessToken만)

    기존 사용자인 경우
        Server->>Network: 200 OK + LoginUser 데이터
        Network->>Repo: GoogleLoginResponseDTO
        Repo->>UseCase: LoginUser (exists: true)
        UseCase->>Store: LoginUser 반환
        Store->>View: 로그인 성공 상태 업데이트
        View->>User: 홈 화면으로 이동
    신규 사용자인 경우 (401 에러)
        Server->>Network: 401 Unauthorized
        Network->>Repo: NetworkError
        Repo->>UseCase: Error 반환
        UseCase->>Store: Error 반환

        Note over Store: 2차 시도: 신규 사용자 회원가입
        Store->>UseCase: execute(accessToken, name: "김시종")
        UseCase->>Repo: loginWithGoogle(accessToken, "김시종")
        Repo->>Network: POST /api/v1/login/google
        Network->>Server: HTTP Request (accessToken + name)
        Server->>Network: 200 OK + 새 LoginUser 데이터
        Network->>Repo: GoogleLoginResponseDTO
        Repo->>UseCase: LoginUser (exists: false)
        UseCase->>Store: LoginUser 반환
        Store->>View: 로그인 성공 상태 업데이트
        View->>User: 온보딩 화면으로 이동

이전에 구현해왔던 내용을 현재 구조에 맞게 재구현하여 넣다보니 양이 너무 많은 것 같아요 ㅠㅠ 죄송합니다.
이후로는 좀 쪼개서 진행하도록 하겠습니다.

해당 구글 로그인 로직은 서버와 연동 테스트를 마무리한 코드입니다. 13일 변경된 서버 API 명세서를 확인 한 뒤 로직 변경있을 예정입니다.

✔️ CI Completed

  • ✅ Lint: Completed
  • ✅ Build: Completed

✔️ CI Completed

  • ✅ Lint: Completed
  • ✅ Build: Completed

✔️ CI Completed

  • ✅ Lint: Completed
  • ✅ Build: Completed

SijongKim93 and others added 30 commits May 15, 2025 23:39
Rename .github/.github/PULL_REQUEST_TEMPLATE.md to .github/PULL_REQUE…
탭뷰 영역에 속하지 않기때문에 따로 빼기
@SijongKim93 SijongKim93 requested review from hooni0918 and k-nh June 12, 2025 09:29
@SijongKim93 SijongKim93 added feature sijong 나는 시종 labels Jun 12, 2025
import UIKit
import Login

class AppDelegate: NSObject, UIApplicationDelegate {
Copy link
Member

Choose a reason for hiding this comment

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

onOpenURL 을 사용하지 않고 Appdelegate를 따로 선언하신 이유가 궁금합니다!

Copy link
Member

Choose a reason for hiding this comment

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

FCM추가를 고려하신걸까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

onOpenURL을 활용해 구현했다면 보다 간결하게

struct LoginView: View {
    var body: some View {
        VStack {
            GoogleSignInButton()
        }
        .onOpenURL { url in
            LoginConfiguration.handleOpenURL(url)
        }
    }
}

과 같이 구현이 가능했을 것 같습니다. 하지만 View 메모리가 활성화 된 이후 시점에서 사용 가능하며, 가능성이 적을 순 있지만 AppDelegate로 구현하는 방식보다 안정성이 다소 떨어진다고 생각했습니다~
또한, 추후 FCM을 활용할 예정이기 때문에 확장성 측면에서 현재 시점부터 AppDelegate로 구현하는 방식으로 선택했습니다.

public init() {}

public func signIn() async throws -> (accessToken: String, name: String?, email: String?) {
guard let scene = await UIApplication.shared.connectedScenes.first as? UIWindowScene,
Copy link
Member

Choose a reason for hiding this comment

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

UIApplication 는 메인스레드에서만 접근 가능한데 async 컨텍스트에서 직접 사용이 되고있어요

Suggested change
guard let scene = await UIApplication.shared.connectedScenes.first as? UIWindowScene,
guard let scene = await MainActor.run { UIApplication.shared.connectedScenes.first } as? UIWindowScene,
let root = await MainActor.run { scene.windows.first?.rootViewController } else {

이런식으로 MainActor로 돌리는것이 좋을것같아요

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

감사합니다 :)
전달 주신 수정사항에 대해서 살짝 수정해 이런 방식으로 수정하는 건 어떻게 생각하시나요?

let rootViewController: UIViewController? = await MainActor.run {
    guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
          let root = scene.windows.first?.rootViewController else {
        return nil
    }
    return root
}

guard let root = rootViewController else {
    throw NSError(
        domain: "GoogleAuthService",
        code: -1,
        userInfo: [NSLocalizedDescriptionKey: "RootViewController를 찾을 수 없습니다."]
    )
}

Copy link
Member

Choose a reason for hiding this comment

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

앗 이방법이 더 나은방식인것같네요 제가말한방식은 scene 변수가 첫 번째 MainActor.run 블록 밖에서 정의되지 않아서 두 번째 블록에서 사용할수가 없을거라 이방식이좀더 맞는방법인것같네요!

Copy link

Choose a reason for hiding this comment

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

더 근본적인 질문이 있어요..!
제가 코드를 다 본 것은 아니지만 GoogleAuthService에서 rootViewController를 찾는 이유가 뭘까요~?
UI 관련 객체가 아닌 것 같은데 UI에 접근하고 있어서 import UIKit 의존성도 생기고 mainActor 이슈도 생기는 것 같아서 코멘트 남깁니당

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

말씀 해주신 내용을 바탕으로 다시 고민해보니 Service에서 UI에 접근하기 때문에 추가 의존성과 MainActor 이슈가 발생하는 것 같습니다. Service에서는 비즈니스 로직/API통신만 담당 하도록 그 책임을 명확하게 할 필요가 있을 것 같아요.

LoginWithGoogleUseCaseInterface를 구성해서 UI에 접근하는 로직을 별도 구성하고 현재 Store에서 추가적으로 담당하고 있는 handleGoogleSignIn로 해당 UseCase에서 구현하여 Store가 불필요한 로직을 가지고 있지 않도록 하는 방식으로 시도해보겠습니다.

let root = await scene.windows.first?.rootViewController else {
throw NSError(
domain: "GoogleAuthService",
code: -1,
Copy link
Member

Choose a reason for hiding this comment

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

아직 에러처리가 미정이라 하드코딩으로 넣은것으로 생각하겟습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

넵넵!! 맞습니다 그렇게 생각해주십셔!!

import ServiceInterface


public final class GoogleAuthService: GoogleAuthServiceInterface {
Copy link
Member

Choose a reason for hiding this comment

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

(개인의견)
SOLID 원칙에 의거하여 코드를 구현하는거에 대해서 논의를 하지 않았지만 해당 클래스가 인증 + 네트워크 + 로깅 이 한번에 있어서 SRP를 준수하지 못하고 있는것 같습니다.
해당 클래스는 인증만을 가지고 토큰 관련 부분을 다른 클래스로 빼는것도 방법이 될수 있을것같아요!!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

좋은 말씀이신 것 같습니다. 제가 생각했던건 톡방에서 여쭤봤던 것 처럼 네트워크 관련 로직들 ( + 로직 세분화 ) 별개로 처리할지 통합해 처리할지 관련해서 나왔던 말이 따로 하지 않고 플랫폼 Service에 한번에 구현하는 방식으로 하자고 이해했었는데 한번 더 논의해보는 것으로 하면 좋을 것 같습니다.

개인적으로는 의견 주신 내용처럼 단일 책임을 가진 형태로 구현하는 방식이 좋을 것 같다고 생각합니다 :)

let parameters: [String: String] = ["access_token": accessToken]

do {
let response = try await AF.request(
Copy link
Member

Choose a reason for hiding this comment

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

alarmofire에 직접 의존을 하면 추후 테스트 코드 넣을때 테스트가 어려울것이라 생각합니다.
DI로 추상화 해서

protocol NetworkService {
    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}

이런식으로 의존성을 주입하는 형식으로 대체 가능해보일듯 합니다

Copy link
Collaborator Author

@SijongKim93 SijongKim93 Jun 13, 2025

Choose a reason for hiding this comment

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

말씀하신 것 처럼 추후 애플 로그인도 같은 요청을 서버에 줄 수 있을 것 같아서 각각 직접 Alamofire에 의존해서 구현하게 되는 문제도 발생할 수 있겠네요!

의견 주신 것처럼 LoginNetworkServiceProtocol 을 만들어 요청 로직을 추상화하고 각각 적용하는 방식으로 수정하도록 하겠습니다.

Comment on lines +92 to +94
.project(target: "UseCase", path: "../Core"),
.project(target: "Repository", path: "../Core"),
.project(target: "Network", path: "../Core")
Copy link
Member

Choose a reason for hiding this comment

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

스크린샷 2025-06-13 오전 12 21 50

해당 Core타겟들을 직접 의존하면 순환참조 문제가 발생할것같아요
코어와 피쳐는 서로를 모르는 상태에서 각각의 인터페이스를 참조하는 방식으로 참조해야만 지금 구조에 더 맞다고 생각합니다

Copy link

Choose a reason for hiding this comment

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

Core에서 피쳐를 의존하지 않으면 순환 참조가 생기진 않아요~!
코어는 피쳐를 당연히 몰라야 하지만 피쳐도 코어를 몰라야 하는가? 하면 그건 고민해보시면 좋겠네요 ㅎㅎ

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

이 부분도 저희가 회의했던 것 처럼 모듈 구조 픽스 되면 참고해서 수정 진행하도록 하겠습니다 :)

let accessToken = user.accessToken.tokenString

// 토큰 검증 (원래 코드와 동일)
await verifyGoogleToken(accessToken)
Copy link
Member

Choose a reason for hiding this comment

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

이부분도 위에서 말햇듯 Google SDK로 받은 토큰을 Google API로 다시 검증하고 있어서 의미없는 코드라고 생각됩니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

위에 답변 드린 내용과 같이 동일하게 수정하도록 하겠습니다 :0

import Common
import UIKit

public final class DefaultGoogleAuthService: GoogleAuthServiceInterface {
Copy link
Member

Choose a reason for hiding this comment

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

Projects/Core/Network/Sources/GoogleAuthService 의 코드랑 같은 코드인거같은데 어떤 차이점이 있나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

기존에 ThirdParty 로 구현했던 GoogleAuthService 내용을 삭제하고 Network 모듈로 변경했는데 generate시 서드파티가 확인되지 않아 삭제가 누락 됐던 것 같습니다 :)

해당 내용 삭제 하였습니다!


public struct LoginUser {
public let email: String
public let accessToken: String?
Copy link
Member

Choose a reason for hiding this comment

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

로그인을 성공했는데 엑세스토큰이 nil로 올수 있나요?
로그인이 고장난상태에서 개발중 UI작업을 할때를 상정한 코드인가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

이 부분도 서버 API 명세서가 나온다면 수정될 수 있는 부분이라고 생각됩니다. 해당 내용 구현 시 생각했던 내용은 accessToken이 nil인 상황에 대해 대비하기 위해 옵셔널로 구성하였습니다.

Comment on lines +7 to +11
@StateObject private var store: LoginStore

public init(store: LoginStore) {
self._store = StateObject(wrappedValue: store)
}
Copy link
Member

Choose a reason for hiding this comment

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

해당부분을 StateObject로 작업하신 이유가 궁금합니다

Copy link
Member

Choose a reason for hiding this comment

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

@StateObject가 "이 View가 객체의 소유자"라는 의미인데, 외부에서 이미 생성된 store를 받아서 다시 StateObject로 감싸고있는것같아요

// 지금 일어나는 일
struct ParentView: View {
    @StateObject var loginStore = LoginStore() // 첫 번째 참조
    
    var body: some View {
        LoginView(store: loginStore) // 전달
    }
}

struct LoginView: View {
    @StateObject private var store: LoginStore
    
    init(store: LoginStore) {
        // 같은 객체에 대해 두 번째 참조 발생
        self._store = StateObject(wrappedValue: store)
    }
}

이렇게 되면 같은 LoginStore 객체에 대해 두 개의 서로 다른 참조가 생기고 있어요
SwiftUI가 그래서 객체 담당을 누가 해야되는데?!?!?!?!?!?!?!?!? 하면서 혼란스러워지면서 메모리 누수가 발생되지 않을까 싶네요

public struct LoginView: View {
    @ObservedObject private var store: LoginStore  // StateObject → ObservedObject
    
    public init(store: LoginStore) {
        self.store = store // 단순 할당, 추가 래핑 없음
    }
}

@ObservedObject는 "나는 이 객체를 관찰만 할 뿐, 소유하지는 않는다"는 라고 가져가기에 소유권은 외부에서 관리하고, View는 단순히 관찰만 하므로 생명주기가 좀더 이뻐지고 추후 저희의 MVI에 좀더 맞는 방식이라 생각이 되어요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

구글 로그인 로직을 테스트하면서 단일 프로젝트에서 크게 신경쓰지 않고 StateObject를 사용해 구현했던 것 같습니다.

말씀해주신 내용과 같이 현재 StateObject를 사용하면서

@StateObject private var store: LoginStore

public init(store: LoginStore) {
    self._store = StateObject(wrappedValue: store) // ❌
}

객체의 소유자인 상태에서 불필요한 생성자 활용으로 인해 잘못 구성되어있습니다.

현재와 같은 상태에서 외부에서 생성된 store 객체를 전달 받는 상태에서는 ObservedObject를 활용해

@ObservedObject private var store: LoginStore

public init(store: LoginStore) {
    self.store = store // ✅ 전달받은 인스턴스 사용
}

이와 같은 방법으로 수정하도록 하겠습니다 :)

@hooni0918
Copy link
Member

PR 중간의 스웨거 링크는 지우는게 좋을것같아요!

Copy link

@lsj8706 lsj8706 left a comment

Choose a reason for hiding this comment

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

다들 열심히 하는 모습 멋지네요!!
슬쩍 보고 눈에 보이는 것들 위주로 코멘트 남겼어요. 제가 드린 의견/질문은 다 반영하려고 하지 마시고 팀원분들이랑 고민해보면 좋을 것 같아요.

그리고 커밋 내역을 보면 아마 작업하면서 깃이 좀 꼬이신 것 같아요.
완전 동일한 커밋들이 여러개 있고 컨플릭트 상태였던 커밋도 있어 보여요.
큰 작업일수록 커밋 단위로 리뷰하는 것이 리뷰어에게 편하기 때문에 이 부분도 신경써서 맥락단위로 나누는 연습 해보시면 정말 도움 많이 될 것 같아요!! 👍

Comment on lines +101 to +104
.project(target: "Domain", path: "../Interface"),
.project(target: "DesignSystem", path: "../Shared"),
.project(target: "LoginInterface", path: "../Interface"),
.project(target: "Common", path: "../Shared")
Copy link

Choose a reason for hiding this comment

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

이렇게 문자열을 직접 기입해서 의존성을 관리하면 오타 등의 실수가 발생하기 쉬울 것 같아요.
모듈을 모델링해서 다음과 같이 사용하면 더 좋을 것 같네요.

짧게 생각해본 예시 코드
.feature(interface: .logIn)
.shared(module: .designSystem)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

최대한 휴먼 에러를 줄일 수 있도록 ProjectDescriptionHelper를 도입하는 방식도 고려해 보도록 하겠습니다 :)

Comment on lines +10 to +13
public struct UserEntity {
public let email: String
public let isExistingUser: Bool
public let accessToken: String
Copy link

Choose a reason for hiding this comment

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

UserEntity 도메인 모델에 대한 설계를 더 엄격하게 하면 좋을 것 같아요..!

  • email -> Ok
  • isExistingUser -> 이 값이 왜 필요한지 잘 모르겠어요.
  • accessToken -> 인증 정보에 가깝고 유저 도메인 모듈에 있으면 안 될 것 같음

정말 유저 정보에 해당하는 필드들만 들어가는게 더 좋은 모델 설계일 것 같아요~!

Copy link

Choose a reason for hiding this comment

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

여기 말고도 전반적으로 모델링/인터페이스를 디자인할 때 다음과 같이 기준을 세우고 시작하면 좋을 것 같아요!

  1. 네이밍이 의도를 잘 드러내는가
  2. 이 속성/메서드가 의도에 부합한가 (객체의 역할에 속하는가)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

기존에는 도메인 모델/인터페이스 작성 시 의도를 깊게 생각하지 않았던 것 같아요.

의견 주신 내용과 같이 도메인 역할에 맞는 필드들만 들어갈 수 있도록 수정하도록 하겠습니다. 그리고 추후에도 네이밍 의도가 잘 드러나는지, 역할에 맞는지 등 그 의도에 대해서 다시 생각해보고 설계할 수 있도록 노력하겠습니다!

Comment on lines +4 to +11
public protocol LoginInterfaceProtocol: ObservableObject {
var events: [Event] { get }
var selectedDate: Date { get set }
var isLoading: Bool { get }

func loadEvents(for date: Date) async
func selectDate(_ date: Date) async
}
Copy link

@lsj8706 lsj8706 Jun 15, 2025

Choose a reason for hiding this comment

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

인터페이스를 만들 때는 그 의도가 명확하게 들어나는 것이 좋아요!

  • LoginInterfaceProtocol -> 이걸 봤을 때는 로그인과 관련된 일을 수행하는 친구일 것이라고 생각했어요.
    그런데 이 인터페이스가 명시하는 속성, 메서드는 로그인과 무관해 보이는 이름들이에요. (실제로 내부 구현은 로그인과 관련이 있긴 하겠지만)

직관적으로 느낀 바를 적어보면

  • event는 어떤 이벤트인지 살짝 감이 오지 않아요.
  • selectedDate -> 날짜와 로그인이 어떤 관계가 있는지 잘 모르겠어요.
  • 다른 것들도 비슷한 느낌이에요.

제가 배경 맥락을 모르고 없이 그냥 적어보자면 LoginInterfaceProtocol은 다음과 같은 구성일 것이라고 예상해요.

public protocol LoginInterfaceProtocol {
    func logIn(id: String, token: String) async throws
}


public struct LoginRequestDTO: Encodable {
public let accessToken: String
public let name: String?
Copy link

Choose a reason for hiding this comment

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

이름이 없는 케이스도 있나보네요? 👀

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

현재 단계에서 서버와 테스트를 위해 신규회원 가입과 그냥 로그인을 나눠 테스트 하느라 그 차이를 name으로 나눠 진행하고 있어요 :)
추후 완성된 API 명세를 받으면 수정 될 예정입니다~~!

신경써서 리뷰 달아줘서 고마워용~~

import Common

@MainActor
public final class LoginStore: ObservableObject {
Copy link

Choose a reason for hiding this comment

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

질문
타 모듈에서 사용되는 타입일까요? 아니라면 internal로 지정해도 괜찮을 것 같아요.
다른 타입들도 마찬가지에요!

Comment on lines +53 to +54
state.errorMessage = ""
state.isLoading = true
Copy link

Choose a reason for hiding this comment

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

참고차 코멘트 남겨보자면, 현재 아키텍처 상으로는 state를 변경하는 곳이 store 내부라면 어디든 가능하기 때문에 안정적인 단방향 플로우는 아닌 것 같아요!
-> 데이터 레이스 발생 가능 + 상태 업데이트 순서 보장이 어려움

나중에 고도화 하게 되면 팀원분들이랑 개선 포인트 찾아보면 좋을 것 같아요~!

public init() {}

public func signIn() async throws -> (accessToken: String, name: String?, email: String?) {
guard let scene = await UIApplication.shared.connectedScenes.first as? UIWindowScene,
Copy link

Choose a reason for hiding this comment

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

더 근본적인 질문이 있어요..!
제가 코드를 다 본 것은 아니지만 GoogleAuthService에서 rootViewController를 찾는 이유가 뭘까요~?
UI 관련 객체가 아닌 것 같은데 UI에 접근하고 있어서 import UIKit 의존성도 생기고 mainActor 이슈도 생기는 것 같아서 코멘트 남깁니당

Comment on lines +92 to +94
.project(target: "UseCase", path: "../Core"),
.project(target: "Repository", path: "../Core"),
.project(target: "Network", path: "../Core")
Copy link

Choose a reason for hiding this comment

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

Core에서 피쳐를 의존하지 않으면 순환 참조가 생기진 않아요~!
코어는 피쳐를 당연히 몰라야 하지만 피쳐도 코어를 몰라야 하는가? 하면 그건 고민해보시면 좋겠네요 ㅎㅎ

- Service 계층에서 UI 로직 제거 및 책임 분리
- UseCase 계층 UI 의존성 제거 및 ViewControllerProvider 도입
- Store 계층 단순화 및 AuthUseCase 분리
- Repository 구현체 및 DTO 매핑 로직 추가
- Apple 로그인 기능 추가 (Service, UseCase, DTO)
- 전체 계층 인터페이스 추상화로 테스트 가능성 개선
Copy link
Member

@hooni0918 hooni0918 left a comment

Choose a reason for hiding this comment

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

고생하셧습니다

public init() {}

public func signIn() async throws -> (accessToken: String, name: String?, email: String?) {
guard let scene = await UIApplication.shared.connectedScenes.first as? UIWindowScene,
Copy link
Member

Choose a reason for hiding this comment

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

앗 이방법이 더 나은방식인것같네요 제가말한방식은 scene 변수가 첫 번째 MainActor.run 블록 밖에서 정의되지 않아서 두 번째 블록에서 사용할수가 없을거라 이방식이좀더 맞는방법인것같네요!

@SijongKim93 SijongKim93 changed the title Feat/#14 google login setting [feat/#14] google login setting Jun 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature sijong 나는 시종

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants