diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5132f4d..314d383 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,17 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - kotlin("plugin.serialization") + alias(libs.plugins.kotlin.serialization) +} + +val properties = Properties().apply { + load(project.rootProject.file("local.properties").inputStream()) } + android { namespace = "com.sopt.dive" compileSdk = 36 @@ -17,6 +24,8 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "BASE_URL", "\"${properties["base.url"]}\"") } buildTypes { @@ -37,6 +46,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } @@ -53,5 +63,8 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation(libs.kotlinx.serialization.json) + implementation(libs.retrofit.core) + implementation(libs.retrofit.kotlin.serialization) + implementation(libs.okhttp.logging) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4dd3d7b..81a8835 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Dive" - tools:targetApi="31"> + tools:targetApi="31" + android:usesCleartextTraffic="true"> create(): T = retrofit.create(T::class.java) +} + +object ServicePool { + val authService: AuthService by lazy { + ApiFactory.create() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/core/data/datasource/AuthRemoteDataSource.kt b/app/src/main/java/com/sopt/dive/core/data/datasource/AuthRemoteDataSource.kt new file mode 100644 index 0000000..629eb6c --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/datasource/AuthRemoteDataSource.kt @@ -0,0 +1,256 @@ +package com.sopt.dive.core.data.datasource + +import com.sopt.dive.core.data.dto.RequestLoginDto +import com.sopt.dive.core.data.dto.ResponseLoginDto +import com.sopt.dive.core.data.dto.ResponseUserDto +import retrofit2.HttpException +import java.io.IOException + +/** + * AuthRemoteDataSource - 실제 API를 호출하는 데이터 소스 + * + * RemoteDataSource의 역할: + * - Retrofit Service를 래핑 + * - API 호출 로직 캡슐화 + * - 에러를 DTO로 반환 (Exception 던지지 않음) + * + * 왜 Service를 직접 사용하지 않나? + * + * ❌ Repository에서 Service 직접 사용: + * class AuthRepositoryImpl(private val authService: AuthService) { + * override suspend fun login(...) { + * try { + * val response = authService.login(...) // Service 직접 호출 + * // ... 변환 로직 + * } catch (...) { + * // ... 에러 처리 + * } + * } + * } + * + * 문제점: + * - Repository가 Retrofit 의존성을 알게 됨 + * - 테스트 시 Retrofit Mock이 필요함 + * - API 변경 시 Repository도 수정 필요 + * + * ✅ DataSource를 통한 간접 호출: + * class AuthRepositoryImpl(private val dataSource: AuthRemoteDataSource) { + * override suspend fun login(...) { + * val response = dataSource.login(...) // DataSource 호출 + * // ... 변환 로직만 + * } + * } + * + * 장점: + * - Repository가 Retrofit을 모름 + * - 테스트 시 FakeDataSource로 교체 가능 + * - API 변경 시 DataSource만 수정 + * + * DataSource 계층의 이점: + * 1. 추상화: Repository가 API 세부사항을 모름 + * 2. 테스트 용이: Fake로 교체 가능 + * 3. 유연성: 여러 API나 DB를 투명하게 전환 + */ +class AuthRemoteDataSource( + /** + * Retrofit Service 인스턴스 + * - Retrofit이 자동 생성한 구현체 + * - suspend 함수로 API 호출 + */ + private val authService: AuthService +) { + + /** + * 로그인 API 호출 + * + * 반환 타입: + * - Result + * - Success: API 응답 DTO + * - Failure: 에러 정보 + * + * 에러 처리: + * - HttpException: HTTP 에러 (401, 404, 500 등) + * - IOException: 네트워크 에러 (연결 끊김, 타임아웃) + * - Exception: 기타 에러 (파싱 에러 등) + * + * Result vs throw: + * + * ❌ throw 방식: + * suspend fun login(): ResponseLoginDto { + * return authService.login(...) // 에러 시 Exception throw + * } + * + * 호출자가 try-catch 필요: + * try { + * val response = dataSource.login() + * } catch (e: HttpException) { ... } + * + * ✅ Result 방식: + * suspend fun login(): Result { + * return try { + * Result.success(authService.login(...)) + * } catch (e: Exception) { + * Result.failure(e) + * } + * } + * + * 호출자가 when으로 처리: + * when (val result = dataSource.login()) { + * is Result.Success -> result.getOrNull() + * is Result.Failure -> result.exceptionOrNull() + * } + */ + suspend fun login(username: String, password: String): Result { + return try { + // API 요청 객체 생성 + val request = RequestLoginDto( + username = username, + password = password + ) + + /** + * API 호출: + * - authService.login()은 suspend 함수 + * - 네트워크 통신 중 코루틴이 일시 중단 + * - 성공 시 ResponseLoginDto 반환 + * - 실패 시 Exception throw + */ + val response = authService.login(request) + + // 성공 Result 반환 + Result.success(response) + + } catch (e: HttpException) { + /** + * HttpException: + * - HTTP 에러 응답 (401, 403, 404, 500 등) + * - 서버는 응답했지만 에러 코드 반환 + * + * 에러 코드별 의미: + * - 401 Unauthorized: 인증 실패 (비밀번호 틀림) + * - 403 Forbidden: 권한 없음 (계정 비활성화) + * - 404 Not Found: 사용자 없음 + * - 500 Internal Server Error: 서버 오류 + */ + Result.failure(e) + + } catch (e: IOException) { + /** + * IOException: + * - 네트워크 연결 문제 + * - 서버에 도달하지 못함 + * + * 원인: + * - 인터넷 연결 끊김 + * - 서버 다운 + * - 타임아웃 + * - DNS 해석 실패 + */ + Result.failure(e) + + } catch (e: Exception) { + /** + * 기타 예외: + * - JSON 파싱 에러 + * - Serialization 에러 + * - 예상치 못한 에러 + */ + Result.failure(e) + } + } + + /** + * 사용자 정보 조회 API 호출 + * + * GET /api/users/{id} + */ + suspend fun getUserById(userId: Int): Result { + return try { + val response = authService.getUserById(userId) + Result.success(response) + } catch (e: Exception) { + Result.failure(e) + } + } +} + +/** + * ======================================== + * DataSource 패턴 개념 정리 + * ======================================== + * + * 1. DataSource의 종류 + * + * RemoteDataSource: + * - API 호출 + * - 네트워크 통신 + * - 최신 데이터 + * + * LocalDataSource: + * - Room DB, SharedPreferences + * - 오프라인 사용 가능 + * - 빠른 응답 + * + * FakeDataSource: + * - 테스트 및 개발용 + * - 네트워크 불필요 + * - 즉시 응답 + * + * 2. Repository의 DataSource 사용 + * + * 단일 DataSource: + * class Repository( + * private val remoteDataSource: RemoteDataSource + * ) { + * suspend fun getData() = remoteDataSource.getData() + * } + * + * 캐시 전략 (Cache + Remote): + * class Repository( + * private val remoteDataSource: RemoteDataSource, + * private val localDataSource: LocalDataSource + * ) { + * suspend fun getData() { + * // 1. 캐시 확인 + * val cached = localDataSource.getData() + * if (cached.isValid()) return cached + * + * // 2. API 호출 + * val remote = remoteDataSource.getData() + * + * // 3. 캐시 저장 + * localDataSource.saveData(remote) + * + * return remote + * } + * } + * + * 3. Result vs Exception + * + * Result 사용 (권장): + * - 에러를 값으로 표현 + * - 컴파일러가 처리 강제 + * - when으로 간결하게 처리 + * + * Exception 사용: + * - 에러를 예외로 표현 + * - try-catch 필요 + * - 놓치기 쉬움 + * + * 4. 테스트 전략 + * + * Unit Test: + * - FakeDataSource 사용 + * - Repository 로직만 테스트 + * - 빠르고 안정적 + * + * Integration Test: + * - MockWebServer 사용 + * - 실제 HTTP 통신 시뮬레이션 + * - API 계약 검증 + * + * E2E Test: + * - 실제 API 호출 + * - 전체 흐름 검증 + * - 느리고 불안정할 수 있음 + */ diff --git a/app/src/main/java/com/sopt/dive/core/data/datasource/AuthService.kt b/app/src/main/java/com/sopt/dive/core/data/datasource/AuthService.kt new file mode 100644 index 0000000..ad3b750 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/datasource/AuthService.kt @@ -0,0 +1,100 @@ +package com.sopt.dive.core.data.datasource + +import com.sopt.dive.core.data.dto.RequestLoginDto +import com.sopt.dive.core.data.dto.RequestSignUpDto +import com.sopt.dive.core.data.dto.ResponseLoginDto +import com.sopt.dive.core.data.dto.ResponseSignUpDto +import com.sopt.dive.core.data.dto.ResponseUserDto +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * AuthService - 코루틴 기반 API 서비스 + * + * Callback 방식 vs 코루틴 방식 비교: + * + * ❌ Callback 방식 (구식): + * fun login(@Body request: RequestLoginDto): Call + * + * ✅ 코루틴 방식 (최신): + * suspend fun login(@Body request: RequestLoginDto): ResponseLoginDto + * + * 차이점: + * 1. suspend 키워드 추가 + * 2. Call → T 직접 반환 + * 3. 에러 처리가 try-catch로 간단해짐 + */ +interface AuthService { + + /** + * 회원가입 API + * + * suspend 키워드: + * - 이 함수는 "일시 중단 가능한 함수"임을 의미 + * - 네트워크 통신 중에는 스레드를 블로킹하지 않고 다른 작업 가능 + * - 오직 코루틴 내부나 다른 suspend 함수에서만 호출 가능 + * + * 반환 타입: + * - Call → ResponseSignUpDto로 단순화 + * - Retrofit이 자동으로 응답을 파싱하여 반환 + * - 에러 발생 시 예외를 throw함 + */ + @POST("/api/v1/users") + suspend fun signUp( + @Body request: RequestSignUpDto + ): ResponseSignUpDto + + /** + * 로그인 API + * + * 사용 예시: + * viewModelScope.launch { // 코루틴 시작 + * try { + * val response = authService.login(request) // 네트워크 호출 (일시 중단) + * // 성공 처리 + * } catch (e: Exception) { + * // 에러 처리 + * } + * } + */ + @POST("/api/v1/auth/login") + suspend fun login( + @Body request: RequestLoginDto + ): ResponseLoginDto + + /** + * 사용자 정보 조회 API + */ + @GET("/api/v1/users/{id}") + suspend fun getUserById( + @Path("id") userId: Int + ): ResponseUserDto +} + +/** + * ======================================== + * suspend 함수 개념 정리 + * ======================================== + * + * 1. suspend 키워드의 의미 + * - "일시 중단 가능한 함수" + * - 코루틴 내에서만 호출 가능 + * - 네트워크/DB 작업처럼 시간이 걸리는 작업에 사용 + * + * 2. 장점 + * - 콜백 지옥 탈출 + * - 순차적으로 읽기 쉬운 코드 + * - 자동 취소 처리 + * + * 3. 일시 중단의 의미 + * - 함수 실행 중 다른 작업을 할 수 있음 + * - 메인 스레드를 블로킹하지 않음 + * - 네트워크 응답이 오면 자동으로 재개됨 + * + * 4. 에러 처리 + * - HTTP 에러 → HttpException 발생 + * - 네트워크 에러 → IOException 발생 + * - try-catch로 간단히 처리 가능 + */ diff --git a/app/src/main/java/com/sopt/dive/core/data/datasource/FakeAuthDataSource.kt b/app/src/main/java/com/sopt/dive/core/data/datasource/FakeAuthDataSource.kt new file mode 100644 index 0000000..5d16e6b --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/datasource/FakeAuthDataSource.kt @@ -0,0 +1,248 @@ +package com.sopt.dive.core.data.datasource + +import com.sopt.dive.core.domain.entity.LoginResult +import com.sopt.dive.core.domain.entity.User + +/** + * FakeAuthDataSource - 테스트 및 개발용 가짜 데이터 소스 + * + * DataSource Pattern이란? + * - 데이터의 출처(Source)를 추상화하는 패턴 + * - Remote (API), Local (DB), Fake (테스트용) 등 다양한 출처 가능 + * + * FakeDataSource의 목적: + * 1. 빠른 개발: API 없이도 UI 개발 가능 + * 2. 안정적인 테스트: 네트워크에 의존하지 않음 + * 3. 오프라인 개발: 인터넷 없이도 작업 가능 + * 4. 엣지 케이스 테스트: 성공/실패 시나리오 쉽게 재현 + * + * 실제 개발 시나리오: + * + * 1단계: FakeDataSource로 UI 개발 + * - 실제 API가 준비 안 됨 + * - FakeDataSource로 즉시 개발 시작 + * - UI 로직에 집중 + * + * 2단계: RealDataSource 구현 + * - API가 준비되면 구현 + * - Repository에서 주입만 바꾸면 됨 + * - UI 코드는 변경 불필요 + * + * 3단계: 테스트 + * - Unit Test: FakeDataSource 사용 + * - Integration Test: RealDataSource 사용 + * + * DataSource 교체 방법: + * + * 개발 환경: + * val repository = AuthRepositoryImpl(FakeAuthDataSource()) + * + * 프로덕션 환경: + * val repository = AuthRepositoryImpl(AuthRemoteDataSource(api)) + * + * Repository는 둘 중 어떤 DataSource를 받는지 모름! + */ +class FakeAuthDataSource { + + /** + * 테스트용 사용자 계정 목록 + * + * 실제 앱이라면: + * - 이 데이터는 서버 DB에 저장됨 + * - FakeDataSource는 이를 시뮬레이션 + */ + private val fakeUsers = mutableListOf( + User( + id = 1, + username = "test", + password = "password", + displayName = "테스트 사용자", + email = "test@example.com" + ), + User( + id = 2, + username = "admin", + password = "admin123", + displayName = "관리자", + email = "admin@example.com" + ), + User( + id = 3, + username = "user", + password = "user123", + displayName = "일반 사용자", + email = "user@example.com" + ) + ) + + /** + * 다음 사용자 ID (회원가입 시 사용) + * + * 실제 앱이라면: + * - DB가 자동으로 ID 생성 (Auto Increment) + * - FakeDataSource는 이를 시뮬레이션 + */ + private var nextUserId = 4 + + /** + * 가짜 로그인 함수 + * + * 동작: + * 1. fakeUsers에서 username으로 사용자 찾기 + * 2. 비밀번호 일치 확인 + * 3. 일치하면 Success, 아니면 Error 반환 + * + * 실제 API라면: + * - 서버가 DB를 조회 + * - 비밀번호 해시 비교 + * - JWT 토큰 생성 및 반환 + * + * Fake는 즉시 응답 (네트워크 지연 없음) + * 필요하다면 delay()로 네트워크 지연 시뮬레이션 가능 + */ + suspend fun login(username: String, password: String): LoginResult { + // 실제 API 지연 시뮬레이션 (선택사항) + // delay(1000) // 1초 지연 + + // 사용자 찾기 + val user = fakeUsers.find { it.username == username } + + // 사용자가 없는 경우 + if (user == null) { + return LoginResult.Error.InvalidCredentials( + message = "존재하지 않는 사용자입니다" + ) + } + + // 비밀번호 확인 + if (user.password != password) { + return LoginResult.Error.InvalidCredentials( + message = "비밀번호가 틀렸습니다" + ) + } + + // 로그인 성공 + return LoginResult.Success( + user = user, + token = "fake_jwt_token_${user.id}" // 가짜 토큰 + ) + } + + /** + * 가짜 회원가입 함수 + * + * 동작: + * 1. 중복 사용자명 확인 + * 2. 새 사용자 생성 (ID 자동 할당) + * 3. fakeUsers에 추가 + * 4. 자동 로그인 (Success 반환) + */ + suspend fun signUp(user: User): LoginResult { + // 중복 확인 + val existingUser = fakeUsers.find { it.username == user.username } + if (existingUser != null) { + return LoginResult.Error.InvalidCredentials( + message = "이미 존재하는 사용자입니다" + ) + } + + // 새 사용자 생성 (ID 할당) + val newUser = user.copy(id = nextUserId++) + + // 저장 + fakeUsers.add(newUser) + + // 자동 로그인 + return LoginResult.Success( + user = newUser, + token = "fake_jwt_token_${newUser.id}" + ) + } + + /** + * ID로 사용자 조회 + * + * 실제 API라면: + * - GET /api/users/{id} + * - 서버가 DB 조회 + */ + suspend fun getUserById(userId: Int): User? { + return fakeUsers.find { it.id == userId } + } + + /** + * 모든 사용자 조회 (개발/테스트용) + */ + fun getAllUsers(): List { + return fakeUsers.toList() // 복사본 반환 (불변성) + } + + /** + * 데이터 초기화 (테스트용) + * + * 사용 예시: + * @Before + * fun setUp() { + * fakeDataSource.reset() + * } + */ + fun reset() { + fakeUsers.clear() + fakeUsers.addAll( + listOf( + User(1, "test", "password", "테스트 사용자", "test@example.com"), + User(2, "admin", "admin123", "관리자", "admin@example.com"), + User(3, "user", "user123", "일반 사용자", "user@example.com") + ) + ) + nextUserId = 4 + } +} + +/** + * ======================================== + * FakeDataSource 사용 예시 + * ======================================== + * + * 1. Repository에서 사용: + * class AuthRepositoryImpl( + * private val dataSource: FakeAuthDataSource + * ) : AuthRepository { + * override suspend fun login(...): LoginResult { + * return dataSource.login(...) + * } + * } + * + * 2. ViewModel 테스트: + * @Test + * fun `로그인 성공 시 UI 상태 변경`() = runTest { + * // Given + * val fakeDataSource = FakeAuthDataSource() + * val repository = AuthRepositoryImpl(fakeDataSource) + * val useCase = LoginUseCase(repository) + * val viewModel = LoginViewModel(useCase) + * + * // When + * viewModel.login("test", "password") + * + * // Then + * assertTrue(viewModel.uiState.value.loginSuccess) + * } + * + * 3. UI 개발: + * - API가 준비 안 됐을 때 + * - FakeDataSource로 먼저 UI 개발 + * - 나중에 RealDataSource로 교체 + * + * 4. 엣지 케이스 테스트: + * val fakeDataSource = FakeAuthDataSource() + * + * // 성공 시나리오 + * val success = fakeDataSource.login("test", "password") + * + * // 실패 시나리오 + * val failure = fakeDataSource.login("wrong", "wrong") + * + * // 네트워크 에러 시뮬레이션 (직접 Result 반환) + * val networkError = LoginResult.Error.NetworkError() + */ diff --git a/app/src/main/java/com/sopt/dive/core/data/datasource/ReqresApiFactory.kt b/app/src/main/java/com/sopt/dive/core/data/datasource/ReqresApiFactory.kt new file mode 100644 index 0000000..11377b1 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/datasource/ReqresApiFactory.kt @@ -0,0 +1,41 @@ +package com.sopt.dive.core.data.datasource + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit + +object ReqresApiFactory { + private const val BASE_URL = "https://reqres.in/api/" + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + private val client = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + val retrofit: Retrofit by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + } + + inline fun create(): T = retrofit.create(T::class.java) +} + +object ReqresServicePool { + val reqresService: ReqresService by lazy { + ReqresApiFactory.create() + } +} diff --git a/app/src/main/java/com/sopt/dive/core/data/datasource/ReqresService.kt b/app/src/main/java/com/sopt/dive/core/data/datasource/ReqresService.kt new file mode 100644 index 0000000..9e103d2 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/datasource/ReqresService.kt @@ -0,0 +1,39 @@ +package com.sopt.dive.core.data.datasource + +import com.sopt.dive.core.data.dto.ResponseReqresUsersDto +import retrofit2.http.Header +import retrofit2.http.Query +import retrofit2.http.GET + +/** + * ReqresService - 코루틴 기반 API 서비스 + * + * suspend 함수로 구현하여 코루틴 내에서 간단하게 호출 가능 + */ +interface ReqresService { + + /** + * 사용자 리스트 조회 API + * + * suspend 키워드: + * - 네트워크 호출이 완료될 때까지 코루틴을 일시 중단 + * - 메인 스레드를 블로킹하지 않음 + * - try-catch로 간단한 에러 처리 + * + * 사용 예시: + * viewModelScope.launch { + * try { + * val response = reqresService.getUsers() + * // 성공 처리 + * } catch (e: Exception) { + * // 에러 처리 + * } + * } + */ + @GET("users") + suspend fun getUsers( + @Header("x-api-key") apiKey: String = "reqres-free-v1", + @Query("page") page: Int = 1, + @Query("per_page") perPage: Int = 10 + ): ResponseReqresUsersDto +} diff --git a/app/src/main/java/com/sopt/dive/core/data/dto/RequestLoginDto.kt b/app/src/main/java/com/sopt/dive/core/data/dto/RequestLoginDto.kt new file mode 100644 index 0000000..4d616d9 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/dto/RequestLoginDto.kt @@ -0,0 +1,12 @@ +package com.sopt.dive.core.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RequestLoginDto( + @SerialName("username") + val username: String, + @SerialName("password") + val password: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/core/data/dto/RequestSignUpDto.kt b/app/src/main/java/com/sopt/dive/core/data/dto/RequestSignUpDto.kt new file mode 100644 index 0000000..8b84863 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/dto/RequestSignUpDto.kt @@ -0,0 +1,18 @@ +package com.sopt.dive.core.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RequestSignUpDto( + @SerialName("username") + val username: String, + @SerialName("password") + val password: String, + @SerialName("name") + val name: String, + @SerialName("email") + val email: String, + @SerialName("age") + val age: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/core/data/dto/ResponseErrorDto.kt b/app/src/main/java/com/sopt/dive/core/data/dto/ResponseErrorDto.kt new file mode 100644 index 0000000..5912704 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/dto/ResponseErrorDto.kt @@ -0,0 +1,24 @@ +package com.sopt.dive.core.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseErrorDto( + @SerialName("success") + val success: Boolean, + @SerialName("code") + val code: String, + @SerialName("message") + val message: String, + @SerialName("data") + val data: ResponseErrorDataDto +) + +@Serializable +data class ResponseErrorDataDto( + @SerialName("code") + val code: String, + @SerialName("message") + val message: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/core/data/dto/ResponseLoginDto.kt b/app/src/main/java/com/sopt/dive/core/data/dto/ResponseLoginDto.kt new file mode 100644 index 0000000..3888371 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/dto/ResponseLoginDto.kt @@ -0,0 +1,24 @@ +package com.sopt.dive.core.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseLoginDto( + @SerialName("success") + val success: Boolean, + @SerialName("code") + val code: String, + @SerialName("message") + val message: String, + @SerialName("data") + val data: ResponseLoginDataDto +) + +@Serializable +data class ResponseLoginDataDto( + @SerialName("userId") + val userId: Int, + @SerialName("message") + val message: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/core/data/dto/ResponseReqresUsersDto.kt b/app/src/main/java/com/sopt/dive/core/data/dto/ResponseReqresUsersDto.kt new file mode 100644 index 0000000..fc7eec5 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/dto/ResponseReqresUsersDto.kt @@ -0,0 +1,32 @@ +package com.sopt.dive.core.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseReqresUsersDto( + @SerialName("page") + val page: Int, + @SerialName("per_page") + val perPage: Int, + @SerialName("total") + val total: Int, + @SerialName("total_pages") + val totalPages: Int, + @SerialName("data") + val data: List +) + +@Serializable +data class ReqresUser( + @SerialName("id") + val id: Int, + @SerialName("email") + val email: String, + @SerialName("first_name") + val firstName: String, + @SerialName("last_name") + val lastName: String, + @SerialName("avatar") + val avatar: String +) diff --git a/app/src/main/java/com/sopt/dive/core/data/dto/ResponseSignUpDto.kt b/app/src/main/java/com/sopt/dive/core/data/dto/ResponseSignUpDto.kt new file mode 100644 index 0000000..7c8259d --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/dto/ResponseSignUpDto.kt @@ -0,0 +1,36 @@ +package com.sopt.dive.core.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// 서버가 보내는 에러 메시지: "이미 존재하는 사용자명입니다" +// 이런 상세 메시지를 받으려면 DTO 필요 + +@Serializable +data class ResponseSignUpDto( + @SerialName("success") + val success: Boolean, + @SerialName("code") + val code: String, + @SerialName("message") + val message: String, + @SerialName("data") + val data: ResponseUserDataDto +) + +@Serializable +data class ResponseUserDataDto( + @SerialName("id") + val id: Int, + @SerialName("username") + val username: String, + @SerialName("name") + val name: String, + @SerialName("email") + val email: String, + @SerialName("age") + val age: Int, + @SerialName("status") + val status: String +) + diff --git a/app/src/main/java/com/sopt/dive/core/data/dto/ResponseUserDto.kt b/app/src/main/java/com/sopt/dive/core/data/dto/ResponseUserDto.kt new file mode 100644 index 0000000..a3bbc36 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/dto/ResponseUserDto.kt @@ -0,0 +1,32 @@ +package com.sopt.dive.core.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseUserDto( + @SerialName("success") + val success: Boolean, + @SerialName("code") + val code: String, + @SerialName("message") + val message: String, + @SerialName("data") + val data: UserDataDto +) + +@Serializable +data class UserDataDto( + @SerialName("id") + val id: Int, + @SerialName("username") + val username: String, + @SerialName("name") + val name: String, + @SerialName("email") + val email: String, + @SerialName("age") + val age: Int, + @SerialName("status") + val status: String +) \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/core/data/mapper/UserMapper.kt b/app/src/main/java/com/sopt/dive/core/data/mapper/UserMapper.kt new file mode 100644 index 0000000..8cfe3e6 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/mapper/UserMapper.kt @@ -0,0 +1,124 @@ +package com.sopt.dive.core.data.mapper + +import com.sopt.dive.core.data.dto.ResponseLoginDto +import com.sopt.dive.core.data.dto.ResponseUserDto +import com.sopt.dive.core.domain.entity.User + +/** + * Mapper - DTO와 Entity 간의 변환을 담당 + * + * Mapper란? + * - 한 데이터 형식을 다른 형식으로 변환하는 함수들의 모음 + * - DTO (Data Transfer Object) ↔ Entity 변환 + * + * 왜 Mapper가 필요한가? + * + * DTO vs Entity: + * + * DTO (ResponseLoginDto): + * { + * "success": true, + * "code": "SUCCESS", + * "message": "로그인 성공", + * "data": { + * "userId": 123, + * "message": "환영합니다" + * } + * } + * + * Entity (User): + * User( + * id = 123, + * username = "john", + * password = "******" + * ) + * + * 차이점: + * - DTO: API 응답 형식 그대로 (중첩된 구조, 불필요한 필드) + * - Entity: 앱 내부에서 사용하기 쉬운 형식 (평평한 구조, 필요한 필드만) + * + * Mapper의 역할: + * 1. DTO → Entity 변환 (API 응답을 앱 내부 모델로) + * 2. Entity → DTO 변환 (앱 내부 모델을 API 요청으로) + * 3. 필드명 매핑 (API와 앱의 필드명이 다를 때) + * 4. 타입 변환 (String → Int, Date 등) + * 5. 기본값 설정 (null → 기본값) + * + * Extension Function을 사용하는 이유: + * + * ❌ 일반 함수 (불편함): + * object UserMapper { + * fun mapToEntity(dto: ResponseLoginDto, username: String): User { + * return User(...) + * } + * } + * + * 사용: + * val user = UserMapper.mapToEntity(dto, username) // 길고 불편 + * + * ✅ Extension Function (간편함): + * fun ResponseLoginDto.toEntity(username: String): User { + * return User(...) + * } + * + * 사용: + * val user = dto.toEntity(username) // 간결하고 직관적 + * + * 장점: + * - 코드가 간결함 + * - 메서드 체이닝 가능 + * - IDE 자동완성 지원 + */ + +/** + * ResponseLoginDto → User 변환 + * + * API 응답에서 사용자 엔티티 생성: + * - API는 userId만 제공 + * - username과 password는 로그인 시 입력한 값 사용 + * + * @param username 로그인 시 입력한 사용자명 + * @param password 로그인 시 입력한 비밀번호 + * @return User 엔티티 + */ +fun ResponseLoginDto.toEntity(username: String, password: String): User { + return User( + id = this.data.userId, // DTO의 중첩된 필드 접근 + username = username, // 파라미터로 전달받은 값 + password = password, // 파라미터로 전달받은 값 + displayName = username, // 기본값으로 username 사용 + email = null // API가 제공하지 않으면 null + ) +} + +/** + * ResponseUserDto → User 변환 + * + * 사용자 정보 조회 API 응답 변환: + * - API가 사용자 상세 정보 제공 + * - password는 보안상 API에서 제공하지 않음 + * + * 실제 API 응답 구조: + * { + * "success": true, + * "data": { + * "id": 1, + * "username": "john", + * "name": "John Doe", // displayName이 아닌 name + * "email": "john@example.com", + * "age": 25, + * "status": "active" + * } + * } + * + * @return User 엔티티 (password는 빈 문자열) + */ +fun ResponseUserDto.toEntity(): User { + return User( + id = this.data.id, + username = this.data.username, + password = "", // 보안상 API에서 제공하지 않음 + displayName = this.data.name, // API의 name → Entity의 displayName + email = this.data.email + ) +} diff --git a/app/src/main/java/com/sopt/dive/core/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/sopt/dive/core/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..62ea78d --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,92 @@ +package com.sopt.dive.core.data.repository + +import com.sopt.dive.core.data.datasource.FakeAuthDataSource +import com.sopt.dive.core.domain.entity.LoginResult +import com.sopt.dive.core.domain.entity.User +import com.sopt.dive.core.domain.repository.AuthRepository + +/** + * AuthRepositoryImpl - Repository 인터페이스의 실제 구현 + * + * Repository Implementation의 역할: + * 1. DataSource 호출 + * 2. DTO → Entity 변환 (Mapper 사용) + * 3. 여러 DataSource 조합 (필요시) + * 4. 캐싱 로직 (필요시) + * 5. 에러 처리 및 변환 + */ +class AuthRepositoryImpl( + /** + * DataSource 주입 + * + * 현재는 Fake만 사용: + * - API 준비 전에도 개발 가능 + * - 테스트 용이 + * - 빠른 프로토타이핑 + * + * 나중에 Real로 교체: + * - 생성자 파라미터만 변경 + * - Repository 코드는 변경 불필요 + */ + private val dataSource: FakeAuthDataSource +) : AuthRepository { + + /** + * 로그인 구현 + * + * 로직: + * 1. DataSource 호출 + * 2. 결과 반환 (Fake는 이미 LoginResult 반환) + */ + override suspend fun login( + username: String, + password: String + ): LoginResult { + return dataSource.login(username, password) + } + + /** + * 회원가입 구현 + */ + override suspend fun signUp(user: User): LoginResult { + return dataSource.signUp(user) + } + + /** + * 사용자 조회 구현 + */ + override suspend fun getUserById(userId: Int): Result { + val user = dataSource.getUserById(userId) + return if (user != null) { + Result.success(user) + } else { + Result.failure(Exception("사용자를 찾을 수 없습니다")) + } + } + + /** + * 로그인 상태 확인 + */ + override suspend fun isLoggedIn(): Boolean { + // Fake 구현: 항상 false + // 실제: SharedPreferences에서 확인 + return false + } + + /** + * 로그아웃 + */ + override suspend fun logout() { + // Fake 구현: 아무것도 안 함 + // 실제: SharedPreferences.clear() + } + + /** + * 저장된 사용자 정보 가져오기 + */ + override suspend fun getSavedUser(): User? { + // Fake 구현: null 반환 + // 실제: SharedPreferences에서 조회 + return null + } +} diff --git a/app/src/main/java/com/sopt/dive/core/domain/entity/LoginResult.kt b/app/src/main/java/com/sopt/dive/core/domain/entity/LoginResult.kt new file mode 100644 index 0000000..e19c407 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/domain/entity/LoginResult.kt @@ -0,0 +1,141 @@ +package com.sopt.dive.core.domain.entity + +/** + * LoginResult - 로그인 결과를 나타내는 Sealed Class + * + * Sealed Class란? + * - "봉인된 클래스" (제한된 상속) + * - 특정 타입들만 상속할 수 있도록 제한 + * - when 표현식에서 모든 경우를 강제로 처리하게 만듦 + * + * 왜 Sealed Class를 사용하나? + * + * ❌ Boolean/Int로 상태 표현 (나쁜 방법): + * val success: Boolean // true/false만 표현 가능 + * val errorCode: Int? // 에러 코드 저장 + * + * 문제점: + * - 성공 시 에러 코드는? → null로 처리 (혼란스러움) + * - 에러 타입이 여러 개면? → errorCode로 구분 (가독성 떨어짐) + * - when으로 처리할 때 컴파일러가 체크 안 함 (버그 발생 가능) + * + * ✅ Sealed Class (좋은 방법): + * sealed class Result { + * data class Success(...) : Result() + * data class Error(...) : Result() + * } + * + * 장점: + * - 명확한 타입 표현 (Success, Error가 별도 타입) + * - when 표현식에서 컴파일러가 모든 경우를 체크 + * - 각 타입별로 다른 데이터를 안전하게 저장 + * + * 실제 사용 예시: + * when (result) { + * is LoginResult.Success -> { + * // result.user 사용 가능 (타입 안전) + * navigateToHome(result.user) + * } + * is LoginResult.Error -> { + * // result.message 사용 가능 (타입 안전) + * showError(result.message) + * } + * } + * // 컴파일러가 모든 경우를 체크했으므로 else 불필요! + */ +sealed class LoginResult { + + /** + * 로그인 성공 + * + * data class: + * - equals(), hashCode(), toString(), copy() 자동 생성 + * - 불변 객체로 안전함 + * + * 포함 정보: + * - user: 로그인한 사용자 정보 + * - token: 인증 토큰 (선택사항) + */ + data class Success( + val user: User, + val token: String? = null + ) : LoginResult() { + /** + * 성공 메시지 생성 + * - UI에서 토스트나 스낵바에 사용 + */ + fun getWelcomeMessage(): String { + return "${user.username}님 환영합니다!" + } + } + + /** + * 로그인 실패 + * + * 실패 타입: + * - InvalidCredentials: 잘못된 인증 정보 + * - NetworkError: 네트워크 에러 + * - ServerError: 서버 에러 + * - Unknown: 알 수 없는 에러 + */ + sealed class Error : LoginResult() { + /** + * 모든 에러의 공통 속성 + */ + abstract val message: String + + /** + * 잘못된 인증 정보 (401) + * - 아이디/비밀번호가 틀림 + */ + data class InvalidCredentials( + override val message: String = "아이디 또는 비밀번호가 틀렸습니다" + ) : Error() + + /** + * 네트워크 에러 + * - 인터넷 연결 끊김 + * - 타임아웃 + */ + data class NetworkError( + override val message: String = "네트워크 연결을 확인해주세요", + val cause: Throwable? = null + ) : Error() + + /** + * 서버 에러 (500) + */ + data class ServerError( + override val message: String = "서버 오류가 발생했습니다", + val code: Int? = null + ) : Error() + + /** + * 알 수 없는 에러 + */ + data class Unknown( + override val message: String = "알 수 없는 오류가 발생했습니다", + val cause: Throwable? = null + ) : Error() + } +} + +/** + * Sealed Class vs Enum 비교 + * + * Enum (제한적): + * enum class LoginState { + * SUCCESS, // 데이터를 담을 수 없음 + * ERROR // 에러 메시지를 어떻게 전달? + * } + * + * Sealed Class (유연함): + * sealed class LoginResult { + * data class Success(val user: User) : LoginResult() + * data class Error(val message: String) : LoginResult() + * } + * + * 차이점: + * - Enum: 값만 표현 (상태만 저장) + * - Sealed Class: 값 + 데이터 표현 (상태 + 관련 데이터 저장) + */ diff --git a/app/src/main/java/com/sopt/dive/core/domain/entity/User.kt b/app/src/main/java/com/sopt/dive/core/domain/entity/User.kt new file mode 100644 index 0000000..4ddeb7a --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/domain/entity/User.kt @@ -0,0 +1,85 @@ +package com.sopt.dive.core.domain.entity + +/** + * User Entity (Domain Layer의 핵심 모델) + * + * Entity란? + * - 비즈니스 로직의 핵심 데이터 모델 + * - 앱의 "진짜 데이터"를 표현 + * - DTO(Data Transfer Object)와 달리 API 응답 형식에 독립적 + * - 불변(immutable) 객체로 설계하여 안전성 보장 + * + * DTO vs Entity 차이: + * + * DTO (Data Transfer Object): + * - API 응답/요청 형식 그대로 표현 + * - JSON 필드명과 일치 (@SerialName 사용) + * - 네트워크 계층에 종속적 + * - 예: ResponseLoginDto, RequestLoginDto + * + * Entity: + * - 앱 내부에서 사용하는 깔끔한 모델 + * - 비즈니스 로직에 필요한 필드만 포함 + * - 네트워크, DB에 독립적 + * - 예: User, LoginResult + * + * 왜 분리하나? + * 1. API 변경에 유연함 (API가 바뀌어도 Entity는 안 바꿔도 됨) + * 2. 테스트 용이함 (네트워크 없이 테스트 가능) + * 3. 코드 가독성 향상 (불필요한 API 필드 제거) + */ +data class User( + /** + * 사용자 고유 ID + * - DB/API에서 관리하는 식별자 + * - nullable: 회원가입 전에는 ID가 없을 수 있음 + */ + val id: Int? = null, + + /** + * 사용자 이름 (로그인용) + * - 3~10자의 영문/숫자 + */ + val username: String, + + /** + * 비밀번호 + * - 실제로는 해시된 값을 저장하는 것이 안전 + * - 여기서는 학습용으로 평문 사용 + */ + val password: String, + + /** + * 표시 이름 (선택사항) + * - 화면에 보여줄 이름 + * - username과 다를 수 있음 + */ + val displayName: String? = null, + + /** + * 이메일 (선택사항) + */ + val email: String? = null +) { + /** + * Entity의 유효성 검증 로직 + * + * 장점: + * - 비즈니스 규칙을 Entity 내부에 캡슐화 + * - ViewModel이나 UseCase에서 재사용 가능 + * - 테스트가 쉬움 + */ + fun isValid(): Boolean { + return username.length in 3..10 && password.length >= 6 + } + + /** + * 비밀번호 유효성 검증 + */ + fun isPasswordValid(): Boolean = password.length >= 6 + + /** + * 사용자명 유효성 검증 + */ + fun isUsernameValid(): Boolean = username.length in 3..10 +} diff --git a/app/src/main/java/com/sopt/dive/core/domain/repository/AuthRepository.kt b/app/src/main/java/com/sopt/dive/core/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..4b019b6 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/domain/repository/AuthRepository.kt @@ -0,0 +1,196 @@ +package com.sopt.dive.core.domain.repository + +import com.sopt.dive.core.domain.entity.LoginResult +import com.sopt.dive.core.domain.entity.User + +/** + * AuthRepository Interface (Domain Layer) + * + * Repository Pattern이란? + * - 데이터 소스를 추상화하는 디자인 패턴 + * - 데이터가 어디서 오는지 숨기고 일관된 인터페이스 제공 + * + * 왜 Interface로 정의하나? + * + * 의존성 역전 원칙 (Dependency Inversion Principle): + * + * ❌ 잘못된 방법 (구체적인 클래스에 의존): + * class LoginUseCase( + * private val repository: AuthRepositoryImpl // 구현체에 직접 의존 + * ) + * + * 문제점: + * - AuthRepositoryImpl을 변경하면 UseCase도 수정해야 함 + * - 테스트 시 실제 API를 호출해야 함 (느리고 불안정) + * - FakeRepository로 교체가 어려움 + * + * ✅ 올바른 방법 (추상화에 의존): + * class LoginUseCase( + * private val repository: AuthRepository // 인터페이스에 의존 + * ) + * + * 장점: + * - 구현체를 자유롭게 교체 가능 (Real → Fake) + * - 테스트가 쉬움 (FakeRepository 사용) + * - 여러 데이터 소스를 투명하게 사용 (API, Cache, LocalDB) + * + * Repository의 역할: + * 1. 데이터 소스 추상화 (API, DB, Cache 등) + * 2. 데이터 접근 로직 캡슐화 + * 3. DTO → Entity 변환 + * 4. 에러 처리 및 변환 + * + * 예시: + * + * 실제 구현 (Production): + * class AuthRepositoryImpl : AuthRepository { + * override suspend fun login(username: String, password: String): LoginResult { + * return try { + * val response = api.login(...) // 실제 API 호출 + * LoginResult.Success(...) + * } catch (e: Exception) { + * LoginResult.Error.NetworkError() + * } + * } + * } + * + * 가짜 구현 (Test): + * class FakeAuthRepository : AuthRepository { + * override suspend fun login(username: String, password: String): LoginResult { + * return if (username == "test" && password == "password") { + * LoginResult.Success(User(...)) // 즉시 성공 반환 + * } else { + * LoginResult.Error.InvalidCredentials() + * } + * } + * } + * + * 캐시 + API 구현 (Advanced): + * class CachedAuthRepository : AuthRepository { + * override suspend fun login(username: String, password: String): LoginResult { + * // 1. 캐시 확인 + * val cached = cache.get(username) + * if (cached != null) return LoginResult.Success(cached) + * + * // 2. API 호출 + * val result = api.login(...) + * + * // 3. 캐시 저장 + * if (result is LoginResult.Success) { + * cache.save(result.user) + * } + * + * return result + * } + * } + */ +interface AuthRepository { + + /** + * 로그인 + * + * suspend 키워드: + * - 코루틴 내에서 비동기 작업 가능 + * - 네트워크 호출, DB 조회 등 시간이 걸리는 작업 처리 + * + * 반환 타입 LoginResult: + * - sealed class로 성공/실패를 명확히 구분 + * - Result나 User?보다 명확하고 안전함 + * + * 파라미터: + * - DTO가 아닌 원시 타입 사용 (String) + * - Domain Layer는 Data Layer의 DTO를 모름 + * + * 예외 처리: + * - 예외를 throw하지 않음 + * - 모든 에러를 LoginResult.Error로 변환하여 반환 + * - 호출자가 예외 처리를 신경쓰지 않아도 됨 + */ + suspend fun login( + username: String, + password: String + ): LoginResult + + /** + * 회원가입 + * + * User를 파라미터로 받음: + * - 회원가입 정보가 여러 개일 수 있으므로 객체로 전달 + * - username, password, email 등을 개별 파라미터로 받으면 복잡함 + */ + suspend fun signUp(user: User): LoginResult + + /** + * 사용자 정보 조회 + * + * Result: + * - 성공: User 객체 반환 + * - 실패: null 또는 에러 정보 반환 + */ + suspend fun getUserById(userId: Int): Result + + /** + * 로그인 상태 확인 + * + * 로컬 저장소에서 확인: + * - SharedPreferences나 DataStore에서 토큰 확인 + * - 빠른 응답 (suspend 없어도 됨, 하지만 일관성을 위해 suspend 유지) + */ + suspend fun isLoggedIn(): Boolean + + /** + * 로그아웃 + * + * 로컬 저장소 삭제: + * - 토큰 삭제 + * - 사용자 정보 삭제 + */ + suspend fun logout() + + /** + * 저장된 사용자 정보 가져오기 + * + * 로컬 저장소에서 조회: + * - SharedPreferences나 DataStore + * - 없으면 null 반환 + */ + suspend fun getSavedUser(): User? +} + +/** + * ======================================== + * Repository Pattern 개념 정리 + * ======================================== + * + * 1. Repository의 책임 + * - 데이터 소스 추상화 (어디서 데이터를 가져오는지 숨김) + * - 데이터 접근 로직 캡슐화 (API 호출, DB 조회 등) + * - DTO ↔ Entity 변환 (Data Layer ↔ Domain Layer) + * - 에러 처리 및 변환 (Exception → Result) + * + * 2. Repository의 구현체 종류 + * - AuthRepositoryImpl: 실제 API 호출 + * - FakeAuthRepository: 테스트용 가짜 구현 + * - MockAuthRepository: 모킹 라이브러리 사용 + * - CachedAuthRepository: 캐시 + API + * + * 3. 왜 Interface로 분리하나? + * - 구현체 교체 용이 (의존성 역전) + * - 테스트 용이 (Fake 구현 사용) + * - 여러 데이터 소스 투명하게 사용 + * + * 4. Domain Layer의 역할 + * - 비즈니스 로직 (Entity, UseCase) + * - Repository Interface (Data Layer 추상화) + * - Data Layer나 Presentation Layer에 독립적 + * + * 5. 데이터 흐름 + * UI → ViewModel → UseCase → Repository → DataSource → API + * ↑ ↓ + * Entity ← Entity ← Entity ← Entity ← DTO ← JSON + * + * 6. 에러 처리 전략 + * - Repository에서 모든 예외를 Result로 변환 + * - UseCase와 ViewModel은 예외 처리 불필요 + * - when으로 Result 타입만 체크하면 됨 + */ diff --git a/app/src/main/java/com/sopt/dive/core/domain/usecase/LoginUseCase.kt b/app/src/main/java/com/sopt/dive/core/domain/usecase/LoginUseCase.kt new file mode 100644 index 0000000..0564e38 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/core/domain/usecase/LoginUseCase.kt @@ -0,0 +1,261 @@ +package com.sopt.dive.core.domain.usecase + +import com.sopt.dive.core.domain.entity.LoginResult +import com.sopt.dive.core.domain.repository.AuthRepository + +/** + * LoginUseCase - 로그인 비즈니스 로직을 담당하는 UseCase + * + * UseCase란? + * - "사용 사례"를 표현하는 클래스 + * - 하나의 비즈니스 기능을 수행 + * - ViewModel과 Repository 사이의 중간 계층 + * + * UseCase의 역할: + * 1. 비즈니스 로직 실행 (검증, 변환, 조합 등) + * 2. Repository 호출 및 결과 처리 + * 3. 여러 Repository를 조합 (필요시) + * 4. 재사용 가능한 비즈니스 로직 제공 + * + * UseCase가 필요한 이유: + * + * ❌ UseCase 없이 (ViewModel이 Repository 직접 호출): + * class LoginViewModel(private val repository: AuthRepository) { + * fun login(username: String, password: String) { + * viewModelScope.launch { + * // 검증 로직 + * if (username.isBlank()) { + * _uiState.value = LoginUiState(error = "ID를 입력하세요") + * return@launch + * } + * if (password.isBlank()) { + * _uiState.value = LoginUiState(error = "비밀번호를 입력하세요") + * return@launch + * } + * + * // API 호출 + * val result = repository.login(username, password) + * + * // 결과 처리 + * when (result) { + * is LoginResult.Success -> { /* ... */ } + * is LoginResult.Error -> { /* ... */ } + * } + * } + * } + * } + * + * 문제점: + * - ViewModel이 비즈니스 로직과 UI 로직을 모두 처리 (책임 과다) + * - 로그인 기능이 여러 화면에서 필요하면 코드 중복 + * - 테스트 시 ViewModel 전체를 테스트해야 함 (복잡함) + * + * ✅ UseCase 사용 (권장): + * class LoginViewModel(private val loginUseCase: LoginUseCase) { + * fun login(username: String, password: String) { + * viewModelScope.launch { + * val result = loginUseCase(username, password) // 간결! + * + * when (result) { + * is LoginResult.Success -> { /* UI 업데이트만 */ } + * is LoginResult.Error -> { /* UI 업데이트만 */ } + * } + * } + * } + * } + * + * 장점: + * - ViewModel은 UI 로직에만 집중 + * - UseCase는 비즈니스 로직에만 집중 (단일 책임 원칙) + * - UseCase를 여러 ViewModel에서 재사용 가능 + * - UseCase만 따로 테스트 가능 (테스트 용이) + * + * UseCase 네이밍 규칙: + * - 동사 + 명사 형태: LoginUseCase, SignUpUseCase + * - 또는: DoSomethingUseCase 형태 + * + * UseCase의 특징: + * 1. 하나의 기능만 수행 (Single Responsibility) + * 2. suspend 함수로 구현 (코루틴 사용) + * 3. operator fun invoke() 사용 (호출을 간결하게) + */ +class LoginUseCase( + /** + * Repository Interface에 의존 + * - 구현체를 모름 (의존성 역전) + * - 테스트 시 FakeRepository 주입 가능 + */ + private val authRepository: AuthRepository +) { + + /** + * operator fun invoke() + * + * invoke()의 특별한 점: + * - 객체를 함수처럼 호출 가능 + * + * 일반 함수 호출: + * val result = loginUseCase.execute(username, password) + * + * invoke 사용 시: + * val result = loginUseCase(username, password) // 간결! + * + * 장점: + * - 코드가 간결해짐 + * - UseCase를 함수형 프로그래밍처럼 사용 가능 + * - 가독성 향상 + */ + suspend operator fun invoke( + username: String, + password: String + ): LoginResult { + /** + * 비즈니스 로직 실행 순서: + * 1. 입력 검증 (빠른 실패) + * 2. Repository 호출 + * 3. 결과 후처리 (필요시) + */ + + // ======================================== + // 1. 입력 검증 (빠른 실패 전략) + // ======================================== + + /** + * 빠른 실패 (Fail Fast) 전략: + * - 문제가 있으면 즉시 에러 반환 + * - 불필요한 네트워크 호출 방지 + * - 사용자에게 빠른 피드백 제공 + */ + + // 빈 값 검증 + if (username.isBlank()) { + return LoginResult.Error.InvalidCredentials( + message = "아이디를 입력해주세요" + ) + } + + if (password.isBlank()) { + return LoginResult.Error.InvalidCredentials( + message = "비밀번호를 입력해주세요" + ) + } + + // 길이 검증 + if (username.length < 3 || username.length > 10) { + return LoginResult.Error.InvalidCredentials( + message = "아이디는 3~10자여야 합니다" + ) + } + + if (password.length < 6) { + return LoginResult.Error.InvalidCredentials( + message = "비밀번호는 6자 이상이어야 합니다" + ) + } + + // ======================================== + // 2. Repository를 통한 로그인 수행 + // ======================================== + + /** + * Repository 호출: + * - suspend 함수이므로 네트워크 호출 가능 + * - Repository가 모든 에러를 Result로 변환 + * - UseCase는 예외 처리 불필요 + */ + val result = authRepository.login( + username = username.trim(), // 공백 제거 + password = password + ) + + // ======================================== + // 3. 결과 후처리 (필요한 경우) + // ======================================== + + /** + * 여기서 추가 비즈니스 로직 수행 가능: + * - 로그인 성공 시 분석 이벤트 전송 + * - 실패 횟수 카운트 + * - 로그 기록 + * - 캐시 업데이트 등 + */ + + // 예시: 로그인 성공 시 로그 기록 + if (result is LoginResult.Success) { + // Analytics.logEvent("login_success") + // LogManager.log("User ${result.user.username} logged in") + } + + return result + } +} + +/** + * ======================================== + * UseCase 패턴 개념 정리 + * ======================================== + * + * 1. UseCase의 책임 + * - 비즈니스 로직 실행 (검증, 변환, 조합) + * - Repository 호출 및 조합 + * - 재사용 가능한 기능 제공 + * + * 2. UseCase vs ViewModel + * + * UseCase: + * - 비즈니스 로직 (플랫폼 독립적) + * - 재사용 가능 + * - 쉬운 테스트 + * - Android 의존성 없음 + * + * ViewModel: + * - UI 로직 (UI 상태 관리) + * - 화면 특화 + * - LifecycleOwner 의존 + * - Android 의존성 있음 + * + * 3. UseCase의 구조 + * - Repository를 주입받음 (의존성 주입) + * - operator fun invoke() 사용 (간결한 호출) + * - suspend 함수로 구현 (비동기 작업) + * + * 4. 언제 UseCase를 만드나? + * + * UseCase 필요: + * - 복잡한 비즈니스 로직 + * - 여러 Repository 조합 + * - 재사용 가능한 로직 + * - 테스트가 중요한 로직 + * + * UseCase 불필요: + * - 단순 CRUD (Repository 직접 호출) + * - 한 번만 사용되는 로직 + * - 비즈니스 로직 없음 + * + * 5. UseCase 테스트 예시 + * @Test + * fun `빈 아이디로 로그인 시 에러 반환`() = runTest { + * // Given + * val useCase = LoginUseCase(fakeRepository) + * + * // When + * val result = useCase("", "password") + * + * // Then + * assertTrue(result is LoginResult.Error.InvalidCredentials) + * } + * + * 6. 여러 Repository 조합 예시 + * class ComplexUseCase( + * private val authRepository: AuthRepository, + * private val userRepository: UserRepository, + * private val analyticsRepository: AnalyticsRepository + * ) { + * suspend operator fun invoke() { + * val user = authRepository.getCurrentUser() + * val profile = userRepository.getProfile(user.id) + * analyticsRepository.logEvent("profile_viewed") + * return profile + * } + * } + */ diff --git a/app/src/main/java/com/sopt/dive/core/ui/navigation/NavigationRoute.kt b/app/src/main/java/com/sopt/dive/core/ui/navigation/NavigationRoute.kt index 28b4faa..21ac37a 100644 --- a/app/src/main/java/com/sopt/dive/core/ui/navigation/NavigationRoute.kt +++ b/app/src/main/java/com/sopt/dive/core/ui/navigation/NavigationRoute.kt @@ -20,7 +20,8 @@ sealed interface NavigationRoute { data class MainContainer( val userId: String, val userNickname: String, - val userExtra: String, + val userEmail: String, // userExtra → userEmail + val userAge: Int, // age 추가 val userPw: String ) : NavigationRoute diff --git a/app/src/main/java/com/sopt/dive/feature/home/HomeItem.kt b/app/src/main/java/com/sopt/dive/feature/home/HomeItem.kt index b031ae7..9b79e65 100644 --- a/app/src/main/java/com/sopt/dive/feature/home/HomeItem.kt +++ b/app/src/main/java/com/sopt/dive/feature/home/HomeItem.kt @@ -27,4 +27,13 @@ sealed interface HomeItem { val hasProfileMusic: Boolean ) : HomeItem + // 4. Reqres API에서 가져온 유저 프로필 + data class UserProfile( + val id: Int, + val email: String, + val firstName: String, + val lastName: String, + val avatar: String + ) : HomeItem + } \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/feature/home/HomeScreen.kt b/app/src/main/java/com/sopt/dive/feature/home/HomeScreen.kt index d59f49c..9fcd887 100644 --- a/app/src/main/java/com/sopt/dive/feature/home/HomeScreen.kt +++ b/app/src/main/java/com/sopt/dive/feature/home/HomeScreen.kt @@ -9,13 +9,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.sopt.dive.feature.home.components.profile.BirthdayItemComponent import com.sopt.dive.feature.home.components.profile.FriendProfileComponent import com.sopt.dive.feature.home.components.profile.MyProfileComponent +import com.sopt.dive.feature.home.components.profile.UserProfileComponent /** @@ -49,6 +49,7 @@ fun HomeScreen( is HomeItem.MyProfile -> "my_profile_${item.userId}" is HomeItem.BirthdayItem -> "birthday_${item.name}" is HomeItem.FriendProfile -> "friend_${item.name}" + is HomeItem.UserProfile -> "user_${item.id}" } } ) { item -> @@ -69,17 +70,28 @@ fun HomeScreen( hasMusic = item.hasProfileMusic ) } + is HomeItem.UserProfile -> { + UserProfileComponent( + id = item.id, + email = item.email, + firstName = item.firstName, + lastName = item.lastName, + avatar = item.avatar + ) + } } } } } -@Preview(showBackground = true, name = "뮤직 있음") +@Preview(showBackground = true, name = "유저 프로필") @Composable -private fun FriendProfileWithMusicPreview() { - FriendProfileComponent( - name = "ㅇㅇㅇㅇ", - statusMessage = "간바레", - hasMusic = true +private fun UserProfilePreview() { + UserProfileComponent( + id = 1, + email = "george.bluth@reqres.in", + firstName = "George", + lastName = "Bluth", + avatar = "https://reqres.in/img/faces/1-image.jpg" ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sopt/dive/feature/home/HomeViewModel.kt b/app/src/main/java/com/sopt/dive/feature/home/HomeViewModel.kt index 52c4b49..1c12954 100644 --- a/app/src/main/java/com/sopt/dive/feature/home/HomeViewModel.kt +++ b/app/src/main/java/com/sopt/dive/feature/home/HomeViewModel.kt @@ -1,27 +1,184 @@ package com.sopt.dive.feature.home +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sopt.dive.core.data.datasource.ReqresServicePool import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import retrofit2.HttpException +import java.io.IOException +/** + * HomeViewModel - 코루틴 기반 API 호출 + * + * 이 ViewModel에서 배울 수 있는 코루틴 개념: + * 1. viewModelScope.launch: 코루틴 시작 + * 2. suspend 함수 호출: 네트워크 통신 + * 3. try-catch: 예외 처리 + * 4. StateFlow: 상태 관리 + */ class HomeViewModel : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) val uiState: StateFlow = _uiState + private val reqresService = ReqresServicePool.reqresService + + /** + * 홈 화면 데이터 로드 + * + * 코루틴 실행 흐름: + * 1. viewModelScope.launch 시작 + * 2. try 블록 진입 + * 3. reqresService.getUsers() 호출 (일시 중단) + * 4. 네트워크 통신 중 다른 작업 가능 + * 5. 응답 도착 시 자동 재개 + * 6. 성공/실패 처리 + */ fun loadHomeItems(userId: String, userNickname: String) { + /** + * viewModelScope.launch: + * - ViewModel의 생명주기에 맞춰 자동 관리 + * - ViewModel이 파괴되면 자동으로 취소됨 + * - 메모리 누수 방지 + */ viewModelScope.launch { - val list = listOf( - HomeItem.MyProfile(userId = userId, userNickname = userNickname), - HomeItem.BirthdayItem(name = "안두콩", message = "오늘 생일입니다!"), - HomeItem.FriendProfile(name = "성규현", statusMessage = "졸려요", hasProfileMusic = true), - HomeItem.FriendProfile(name = "엄준식", statusMessage = "SOPT 37기 화이팅!", hasProfileMusic = false), - HomeItem.FriendProfile(name = "이름생각안나서", statusMessage = "", hasProfileMusic = false), - HomeItem.FriendProfile(name = "추워요", statusMessage = "여행 중", hasProfileMusic = true) - ) - _uiState.value = HomeUiState(items = list) + try { + /** + * suspend 함수 호출: + * - reqresService.getUsers()는 suspend 함수 + * - 네트워크 통신 중 코루틴이 일시 중단됨 + * - 메인 스레드는 블로킹되지 않고 UI 반응 가능 + * - 응답이 오면 자동으로 다음 줄부터 재개 + */ + val response = reqresService.getUsers(page = 1, perPage = 10) + + /** + * 여기 도달 = 네트워크 통신 성공 + * response 객체에 API 응답 데이터가 담겨있음 + */ + + // API 응답을 HomeItem.UserProfile로 변환 + val userProfiles = response.data.map { user -> + HomeItem.UserProfile( + id = user.id, + email = user.email, + firstName = user.firstName, + lastName = user.lastName, + avatar = user.avatar + ) + } + + // 내 프로필 + 유저 프로필 리스트 조합 + val list = buildList { + add(HomeItem.MyProfile(userId = userId, userNickname = userNickname)) + addAll(userProfiles) + } + + // StateFlow 업데이트 → UI 자동 리컴포지션 + _uiState.value = HomeUiState(items = list) + Log.d("HomeViewModel", "성공: ${userProfiles.size}명의 사용자 로드") + + } catch (e: HttpException) { + /** + * HttpException: + * - HTTP 에러 코드 (400, 401, 404, 500 등) + * - 서버가 응답은 했지만 에러 코드를 반환 + * + * 예시: + * - 401: 인증 실패 + * - 404: 리소스 없음 + * - 500: 서버 에러 + */ + Log.e("HomeViewModel", "HTTP 에러 ${e.code()}: ${e.message()}") + loadFallbackData(userId, userNickname) + + } catch (e: IOException) { + /** + * IOException: + * - 네트워크 연결 문제 + * - 서버에 도달하지 못함 + * + * 예시: + * - 인터넷 연결 끊김 + * - 타임아웃 + * - 서버 다운 + */ + Log.e("HomeViewModel", "네트워크 에러: ${e.message}") + loadFallbackData(userId, userNickname) + + } catch (e: Exception) { + /** + * Exception: + * - 그 외 모든 예외 + * - 파싱 에러, 예상치 못한 에러 등 + */ + Log.e("HomeViewModel", "알 수 없는 에러: ${e.message}") + loadFallbackData(userId, userNickname) + } } } + + /** + * 에러 발생 시 폴백 데이터 로드 + * + * 네트워크 에러가 발생해도 기본 데이터를 표시하여 + * 사용자 경험을 유지합니다. + */ + private fun loadFallbackData(userId: String, userNickname: String) { + val list = listOf( + HomeItem.MyProfile(userId = userId, userNickname = userNickname), + HomeItem.BirthdayItem(name = "안두콩", message = "오늘 생일입니다!"), + HomeItem.FriendProfile(name = "성규현", statusMessage = "졸려요", hasProfileMusic = true), + HomeItem.FriendProfile(name = "엄준식", statusMessage = "SOPT 37기 화이팅!", hasProfileMusic = false), + HomeItem.FriendProfile(name = "이름생각안나서", statusMessage = "", hasProfileMusic = false), + HomeItem.FriendProfile(name = "추워요", statusMessage = "여행 중", hasProfileMusic = true) + ) + _uiState.value = HomeUiState(items = list) + Log.d("HomeViewModel", "폴백 데이터 로드 완료") + } } + +/** + * ======================================== + * 코루틴 vs Callback 비교 (HomeViewModel 예시) + * ======================================== + * + * ❌ Callback 방식 (복잡함, 이전 코드): + * + * authService.getUsers().enqueue(object : Callback { + * override fun onResponse(call: Call, response: Response) { + * if (response.isSuccessful) { + * val data = response.body() + * // 데이터 처리 + * } else { + * // 에러 처리 + * } + * } + * override fun onFailure(call: Call, t: Throwable) { + * // 네트워크 에러 처리 + * } + * }) + * + * ✅ 코루틴 방식 (간결함, 현재 코드): + * + * viewModelScope.launch { + * try { + * val response = reqresService.getUsers() + * // 데이터 처리 + * } catch (e: HttpException) { + * // HTTP 에러 처리 + * } catch (e: IOException) { + * // 네트워크 에러 처리 + * } + * } + * + * 장점: + * 1. 코드가 순차적으로 읽힘 (위에서 아래로) + * 2. 중첩된 콜백 없음 (콜백 지옥 탈출) + * 3. 에러 처리가 try-catch로 간단함 + * 4. 자동 취소 (메모리 누수 방지) + * 5. 테스트하기 쉬움 + */ diff --git a/app/src/main/java/com/sopt/dive/feature/home/components/profile/UserProfileComponent.kt b/app/src/main/java/com/sopt/dive/feature/home/components/profile/UserProfileComponent.kt new file mode 100644 index 0000000..03c0316 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/feature/home/components/profile/UserProfileComponent.kt @@ -0,0 +1,101 @@ +package com.sopt.dive.feature.home.components.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage + +@Composable +fun UserProfileComponent( + id: Int, + email: String, + firstName: String, + lastName: String, + avatar: String, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 프로필 이미지 + AsyncImage( + model = avatar, + contentDescription = "Profile image of $firstName $lastName", + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color.LightGray), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.width(16.dp)) + + // 유저 정보 + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "$firstName $lastName", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = Color.Black + ) + Text( + text = email, + fontSize = 14.sp, + color = Color.Gray, + modifier = Modifier.padding(top = 4.dp) + ) + Text( + text = "ID: $id", + fontSize = 12.sp, + color = Color.Gray, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun UserProfileComponentPreview() { + UserProfileComponent( + id = 1, + email = "george.bluth@reqres.in", + firstName = "George", + lastName = "Bluth", + avatar = "https://reqres.in/img/faces/1-image.jpg" + ) +} diff --git a/app/src/main/java/com/sopt/dive/feature/login/LoginScreen.kt b/app/src/main/java/com/sopt/dive/feature/login/LoginScreen.kt index 5f21353..2e7e3ef 100644 --- a/app/src/main/java/com/sopt/dive/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/sopt/dive/feature/login/LoginScreen.kt @@ -21,9 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -39,14 +37,16 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.sopt.dive.core.data.UserPreferences import com.sopt.dive.core.ui.noRippleClickable /** - * 로그인 화면 - * - * 사용자로부터 ID와 비밀번호를 입력받아 SharedPreferences에 저장된 정보와 비교 - * 일치하면 로그인 성공, 일치하지 않으면 에러 메시지를 표시 + * 로그인 화면 - Flow 기반 실시간 검증 + * + * Flow 동작 확인: + * 1. TextField에 입력 → updateUserId/updatePassword 호출 + * 2. ViewModel의 Flow가 자동으로 검증 + * 3. uiState.collectAsState()로 상태 구독 + * 4. 상태가 변경되면 자동 리컴포지션 */ @Composable fun LoginScreen( @@ -58,7 +58,23 @@ fun LoginScreen( ) ) { val context = LocalContext.current + + /** + * collectAsState(): StateFlow를 Compose State로 변환 + * + * 작동 방식: + * 1. viewModel.uiState는 StateFlow + * 2. collectAsState()가 이를 State로 변환 + * 3. uiState 값이 변경되면 자동으로 리컴포지션 + * + * 예시: + * - 사용자가 "abc" 입력 + * - ViewModel의 combine Flow가 새 UiState 생성 + * - collectAsState()가 변경을 감지 + * - 이 Composable이 자동으로 리컴포지션됨 + */ val uiState by viewModel.uiState.collectAsState() + val focusRequester = remember { FocusRequester() } val scrollState = rememberScrollState() @@ -79,6 +95,9 @@ fun LoginScreen( fontWeight = FontWeight.Bold, ) + // ======================================== + // ID 입력 필드 + // ======================================== Text(text = "ID", fontSize = 20.sp) val brush = remember { @@ -87,23 +106,63 @@ fun LoginScreen( ) } + /** + * ID TextField - Flow와 연동 + * + * 동작 순서: + * 1. value = uiState.userId + * - StateFlow에서 현재 값을 읽어와 표시 + * + * 2. onValueChange = { viewModel.updateUserId(it) } + * - 사용자가 입력할 때마다 호출됨 + * - ViewModel의 _userId Flow에 새 값 방출 + * - Flow 체인이 자동으로 실행됨 (debounce → map → combine) + * + * 3. isError = uiState.idErrorMessage != null + * - Flow에서 계산된 에러 메시지가 있으면 에러 상태 표시 + * + * 4. supportingText + * - 에러 메시지를 TextField 아래에 표시 + */ OutlinedTextField( - value = uiState.userId, - onValueChange = { viewModel.updateUserId(it) }, + value = uiState.userId, // Flow에서 오는 값 + onValueChange = { viewModel.updateUserId(it) }, // Flow로 값 전달 modifier = Modifier.fillMaxWidth(), textStyle = TextStyle(brush = brush), label = { Text("아이디를 입력하세요") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions(onNext = { focusRequester.requestFocus() }), - singleLine = true + singleLine = true, + // Flow로 계산된 에러 상태 적용 + isError = uiState.idErrorMessage != null, + supportingText = { + // 에러 메시지가 있으면 빨간색으로 표시 + uiState.idErrorMessage?.let { errorMsg -> + Text( + text = errorMsg, + color = Color.Red, + fontSize = 12.sp + ) + } + } ) Spacer(modifier = Modifier.height(24.dp)) + + // ======================================== + // 비밀번호 입력 필드 + // ======================================== Text(text = "PW", fontSize = 20.sp) + /** + * 비밀번호 TextField - Flow와 연동 + * + * ID TextField와 동일한 방식으로 작동 + * 추가로 Go 액션으로 로그인 시도 가능 + */ OutlinedTextField( - value = uiState.password, - onValueChange = { viewModel.updatePassword(it) }, + value = uiState.password, // Flow에서 오는 값 + onValueChange = { viewModel.updatePassword(it) }, // Flow로 값 전달 modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester), @@ -114,32 +173,74 @@ fun LoginScreen( keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), keyboardActions = KeyboardActions( onGo = { - viewModel.validateLogin( - onSuccess = { id, pw -> onLoginClick(id, pw) }, - onFailure = { msg -> - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } + // Go 액션: 버튼이 활성화되어 있을 때만 로그인 시도 + if (uiState.isLoginButtonEnabled) { + viewModel.loginWithApi( + onSuccess = { id, pw -> onLoginClick(id, pw) }, + onFailure = { msg -> + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + ) + } + } + ), + // Flow로 계산된 에러 상태 적용 + isError = uiState.passwordErrorMessage != null, + supportingText = { + uiState.passwordErrorMessage?.let { errorMsg -> + Text( + text = errorMsg, + color = Color.Red, + fontSize = 12.sp ) } - ) + } ) Spacer(modifier = Modifier.weight(1f)) + // ======================================== + // 로그인 버튼 - Flow로 활성화 제어 + // ======================================== + + /** + * enabled 속성이 Flow로 제어됨 + * + * 버튼 활성화 조건 (ViewModel의 combine에서 계산): + * 1. ID가 비어있지 않음 + * 2. PW가 비어있지 않음 + * 3. ID가 유효함 (3~10자) + * 4. PW가 유효함 (6자 이상) + * 5. 로딩 중이 아님 + * + * 실시간 동작: + * - 사용자가 "ab" 입력 → 버튼 비활성화 (ID 너무 짧음) + * - "abc" 입력 → 버튼 여전히 비활성화 (PW 없음) + * - PW "pass123" 입력 → 버튼 활성화! + * - ID를 "ab"로 수정 → 버튼 즉시 비활성화 + */ Button( onClick = { - viewModel.validateLogin( + viewModel.loginWithApi( onSuccess = { id, pw -> onLoginClick(id, pw) }, onFailure = { msg -> Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() } ) }, + // Flow에서 계산된 버튼 활성화 상태 + 로딩 상태 + enabled = uiState.isLoginButtonEnabled && !uiState.isLoading, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = Color.Black), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Black, + disabledContainerColor = Color.Gray // 비활성화 시 회색 + ), shape = RoundedCornerShape(8.dp) ) { - Text("로그인", color = Color.White) + Text( + text = if (uiState.isLoading) "로그인 중..." else "로그인", + color = Color.White + ) } Spacer(modifier = Modifier.height(16.dp)) @@ -157,3 +258,33 @@ fun LoginScreen( Spacer(modifier = Modifier.height(24.dp)) } } + +/** + * ======================================== + * Flow 기반 UI 업데이트 흐름 정리 + * ======================================== + * + * 사용자 액션: + * TextField에 "a" 입력 + * ↓ + * ViewModel: + * 1. updateUserId("a") 호출 + * 2. _userId.value = "a" (Flow에 값 방출) + * 3. debounce 300ms 대기 + * 4. map { "a".length in 3..10 } → false + * 5. combine이 새 UiState 생성: + * - userId = "a" + * - isIdValid = false + * - isLoginButtonEnabled = false + * - idErrorMessage = "ID는 3자 이상이어야 합니다" + * 6. _uiState.value = 새로운 UiState + * ↓ + * UI (Screen): + * 1. collectAsState()가 변경 감지 + * 2. 리컴포지션 발생 + * 3. TextField에 에러 메시지 표시 + * 4. 로그인 버튼 비활성화 + * + * 이 모든 과정이 자동으로 실행됨! + * Flow가 알아서 값을 전파하고 UI를 업데이트함 + */ diff --git a/app/src/main/java/com/sopt/dive/feature/login/LoginUiState.kt b/app/src/main/java/com/sopt/dive/feature/login/LoginUiState.kt index 4e0bc69..e1707e7 100644 --- a/app/src/main/java/com/sopt/dive/feature/login/LoginUiState.kt +++ b/app/src/main/java/com/sopt/dive/feature/login/LoginUiState.kt @@ -1,8 +1,25 @@ package com.sopt.dive.feature.login +/** + * 로그인 화면의 UI 상태를 관리하는 데이터 클래스 + * + * Flow를 통해 실시간으로 업데이트되는 상태들을 포함합니다. + */ data class LoginUiState( + // === 입력 값 === val userId: String = "", val password: String = "", + + // === 검증 상태 (Flow로 자동 계산) === + val isIdValid: Boolean = false, // ID가 유효한가? + val isPasswordValid: Boolean = false, // 비밀번호가 유효한가? + val isLoginButtonEnabled: Boolean = false, // 로그인 버튼 활성화 여부 (ID + PW 모두 유효할 때) + + // === 에러 메시지 (Flow로 자동 계산) === + val idErrorMessage: String? = null, // ID 입력 에러 메시지 + val passwordErrorMessage: String? = null, // 비밀번호 입력 에러 메시지 + + // === API 통신 상태 === val isLoading: Boolean = false, val loginSuccess: Boolean = false, val errorMessage: String? = null diff --git a/app/src/main/java/com/sopt/dive/feature/login/LoginViewModel.kt b/app/src/main/java/com/sopt/dive/feature/login/LoginViewModel.kt index f777c69..4541386 100644 --- a/app/src/main/java/com/sopt/dive/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/sopt/dive/feature/login/LoginViewModel.kt @@ -1,48 +1,525 @@ package com.sopt.dive.feature.login import android.content.Context +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sopt.dive.core.data.UserPreferences +import com.sopt.dive.core.data.AuthPreferences +import com.sopt.dive.core.data.datasource.ServicePool +import com.sopt.dive.core.data.dto.RequestLoginDto import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import retrofit2.HttpException +import java.io.IOException +/** + * LoginViewModel with Flow 기반 입력 검증 + 코루틴 기반 API 호출 + * + * Flow의 핵심 개념: + * 1. MutableStateFlow: 값을 emit(방출)할 수 있는 Flow + * 2. StateFlow: 값을 읽기만 할 수 있는 Flow (UI에서 collect) + * 3. map: Flow의 값을 변환 (예: String → Boolean) + * 4. combine: 여러 Flow를 하나로 합치기 + * 5. debounce: 일정 시간 동안 입력이 없을 때만 처리 + * + * 코루틴의 핵심 개념: + * 1. viewModelScope: ViewModel 생명주기에 맞춰 자동으로 취소되는 코루틴 스코프 + * 2. launch: 새로운 코루틴을 시작 (Job 반환) + * 3. suspend: 일시 중단 가능한 함수 + * 4. try-catch: 코루틴 내 예외 처리 + */ class LoginViewModel( - private val context: Context // ViewModelFactory를 따로 만들어서 DI 형태로 추후 리팩토링 + private val context: Context ) : ViewModel() { + // ======================================== + // 1. 기본 StateFlow 정의 + // ======================================== + + /** + * MutableStateFlow: 값을 변경할 수 있는 Flow + * - emit()이나 value로 새 값을 방출할 수 있음 + * - ViewModel 내부에서만 사용 (private) + */ + private val _userId = MutableStateFlow("") + private val _password = MutableStateFlow("") + + /** + * StateFlow: 읽기 전용 Flow + * - UI(Screen)에서 collectAsState()로 구독 + * - 값이 변경되면 자동으로 UI가 리컴포지션됨 + */ + private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState - private val userPreferences = UserPreferences.getInstance(context) + private val authPreferences = AuthPreferences.getInstance(context) + private val authService = ServicePool.authService + // ======================================== + // 2. Flow 변환 - map 연산자 + // ======================================== + + /** + * map 연산자: Flow의 값을 변환 + * + * 작동 방식: + * _userId Flow에서 값이 방출될 때마다 (예: "abc" 입력) + * ↓ + * map { } 블록이 실행됨 + * ↓ + * 변환된 값을 새로운 Flow로 방출 + * + * 예시: + * _userId = "a" → isIdValidFlow = false (3자 미만) + * _userId = "abc" → isIdValidFlow = true (3자 이상) + * _userId = "abcdefghijk" → isIdValidFlow = false (10자 초과) + */ + private val isIdValidFlow = _userId + .debounce(300L) // 300ms 동안 입력이 없을 때까지 대기 (타이핑 중에는 검증 안 함) + .map { id -> // map: String → Boolean 변환 + // 검증 로직: 3자 이상 10자 이하 + id.length in 3..10 + } + + /** + * 비밀번호 검증 Flow + * + * debounce(300L): + * - 사용자가 타이핑을 멈춘 후 300ms 대기 + * - 불필요한 검증을 방지하여 성능 향상 + * - 예: "p" → "pa" → "pas" → "pass" 입력 시 + * "pass" 입력 후 300ms 대기 후에만 검증 실행 + */ + private val isPasswordValidFlow = _password + .debounce(300L) + .map { pw -> + // 검증 로직: 6자 이상 + pw.length >= 6 + } + + // ======================================== + // 3. Flow 변환 - map으로 에러 메시지 생성 + // ======================================== + + /** + * ID 에러 메시지 Flow + * + * map의 활용: + * - ID 값에 따라 적절한 에러 메시지를 반환 + * - null을 반환하면 에러 없음을 의미 + */ + private val idErrorMessageFlow = _userId + .debounce(300L) + .map { id -> + when { + id.isEmpty() -> null // 비어있으면 에러 메시지 없음 + id.length < 3 -> "ID는 3자 이상이어야 합니다" + id.length > 10 -> "ID는 10자 이하여야 합니다" + else -> null // 유효하면 null + } + } + + /** + * 비밀번호 에러 메시지 Flow + */ + private val passwordErrorMessageFlow = _password + .debounce(300L) + .map { pw -> + when { + pw.isEmpty() -> null + pw.length < 6 -> "비밀번호는 6자 이상이어야 합니다" + else -> null + } + } + + // ======================================== + // 4. Flow 결합 - combine 연산자 + // ======================================== + + /** + * combine 연산자: 여러 Flow를 하나로 합치기 + * + * 작동 방식: + * Flow1 ─┐ + * ├─→ combine { a, b, c → 결과 } ─→ 새로운 Flow + * Flow2 ─┤ + * Flow3 ─┘ + * + * 특징: + * - 모든 Flow가 최소 한 번씩 값을 방출해야 combine이 실행됨 + * - 어느 하나의 Flow가 새 값을 방출하면 combine이 다시 실행됨 + * + * 실행 예시: + * 1. _userId = "abc" 입력 + * → isIdValidFlow = true + * → (아직 password가 없으므로 combine 실행 안 됨) + * + * 2. _password = "pass123" 입력 + * → isPasswordValidFlow = true + * → combine 실행: true && true && true → isLoginEnabled = true + * + * 3. _userId = "ab" 입력 (너무 짧음) + * → isIdValidFlow = false + * → combine 실행: false && true && true → isLoginEnabled = false + */ + init { + // ViewModel이 생성될 때 combine Flow 설정 + viewModelScope.launch { + // 5개의 Flow를 combine으로 결합 + combine( + _userId, // Flow 1: ID 입력값 + _password, // Flow 2: PW 입력값 + isIdValidFlow, // Flow 3: ID 검증 결과 + isPasswordValidFlow, // Flow 4: PW 검증 결과 + idErrorMessageFlow // Flow 5: ID 에러 메시지 + ) { id, pw, isIdValid, isPwValid, idError -> + // combine 블록: 5개의 값을 받아서 새로운 UiState 생성 + + // 로그인 버튼 활성화 조건: + // 1. ID가 비어있지 않음 + // 2. PW가 비어있지 않음 + // 3. ID가 유효함 + // 4. PW가 유효함 + val isButtonEnabled = id.isNotEmpty() && + pw.isNotEmpty() && + isIdValid && + isPwValid + + // 새로운 UiState 생성 (불변성 유지) + LoginUiState( + userId = id, + password = pw, + isIdValid = isIdValid, + isPasswordValid = isPwValid, + isLoginButtonEnabled = isButtonEnabled, + idErrorMessage = idError, + passwordErrorMessage = null // 비밀번호 에러는 별도로 처리 가능 + ) + }.collect { newState -> + // collect: Flow에서 방출된 값을 소비(사용) + // 새로운 UiState를 _uiState에 방출 + _uiState.value = newState + } + } + } + + // ======================================== + // 5. 입력값 업데이트 함수 + // ======================================== + + /** + * ID 입력값 업데이트 + * + * Flow의 동작: + * 1. updateUserId("a") 호출 + * ↓ + * 2. _userId.value = "a" (새 값 방출) + * ↓ + * 3. _userId를 구독하는 모든 Flow가 반응 + * - isIdValidFlow의 debounce가 300ms 대기 시작 + * - combine의 _userId 파라미터가 "a"로 업데이트 + * ↓ + * 4. 300ms 후 debounce 통과 + * ↓ + * 5. map { id -> id.length in 3..10 } 실행 + * ↓ + * 6. combine이 다시 실행되어 새로운 UiState 생성 + * ↓ + * 7. UI가 자동으로 리컴포지션 + */ fun updateUserId(id: String) { - _uiState.value = _uiState.value.copy(userId = id) + _userId.value = id } + /** + * 비밀번호 입력값 업데이트 + * + * 동작은 updateUserId와 동일 + */ fun updatePassword(pw: String) { - _uiState.value = _uiState.value.copy(password = pw) + _password.value = pw } - fun validateLogin(onSuccess: (String, String) -> Unit, onFailure: (String) -> Unit) { - val id = _uiState.value.userId - val pw = _uiState.value.password + // ======================================== + // 6. 코루틴 기반 API 로그인 + // ======================================== + + /** + * 코루틴 기반 API 로그인 함수 + * + * 코루틴의 핵심 개념: + * + * 1. viewModelScope: + * - ViewModel의 생명주기에 맞춰 자동으로 관리되는 코루틴 스코프 + * - ViewModel이 onCleared()될 때 자동으로 모든 코루틴 취소 + * - 메모리 누수 방지 + * + * 2. launch: + * - 새로운 코루틴을 시작하는 빌더 함수 + * - Job을 반환 (필요시 수동으로 취소 가능) + * - 블록 내부에서 suspend 함수 호출 가능 + * + * 3. suspend 함수: + * - authService.login()은 suspend 함수 + * - 네트워크 호출 중 스레드를 블로킹하지 않고 일시 중단 + * - 응답이 오면 자동으로 재개됨 + * + * 4. try-catch: + * - suspend 함수에서 발생한 예외를 잡음 + * - HttpException: HTTP 에러 (401, 404 등) + * - IOException: 네트워크 연결 에러 + */ + fun loginWithApi(onSuccess: (String, String) -> Unit, onFailure: (String) -> Unit) { + // 현재 입력된 ID, PW 가져오기 + val id = _userId.value + val pw = _password.value + + // 입력값 검증 (combine으로 이미 검증되었지만 한번 더 체크) + if (id.isBlank() || pw.isBlank()) { + onFailure("ID와 비밀번호를 입력해주세요.") + return + } + /** + * viewModelScope.launch { }: + * - 새로운 코루틴 시작 + * - 이 블록 내부에서 suspend 함수 호출 가능 + * - ViewModel이 파괴되면 자동으로 취소됨 + * + * 실행 흐름: + * 1. launch { } 블록 시작 + * 2. 로딩 상태 true + * 3. authService.login() 호출 (일시 중단) + * 4. 네트워크 통신 중 다른 작업 가능 (UI 반응 가능) + * 5. 응답 도착 시 자동 재개 + * 6. 성공/실패 처리 + * 7. 로딩 상태 false + */ viewModelScope.launch { - if (userPreferences.validateLogin(id, pw)) { + // 로딩 시작 (기존 uiState 유지하면서 isLoading만 업데이트) + _uiState.value = _uiState.value.copy(isLoading = true) + + /** + * try-catch 블록: + * - suspend 함수에서 발생하는 예외를 처리 + * - Callback 방식의 onResponse/onFailure를 대체 + * + * Callback 방식 (복잡함, 15줄): + * authService.login(request).enqueue(object : Callback { + * override fun onResponse(...) { } + * override fun onFailure(...) { } + * }) + * + * 코루틴 방식 (간결함, 5줄): + * try { + * val response = authService.login(request) + * } catch (e: Exception) { } + */ + try { + // API 요청 객체 생성 + val request = RequestLoginDto(username = id, password = pw) + + /** + * authService.login(request): + * - suspend 함수 호출 + * - 네트워크 통신 중 코루틴이 일시 중단됨 + * - 메인 스레드는 블로킹되지 않고 다른 작업 가능 + * - 응답이 오면 다음 줄부터 자동으로 재개 + * + * 일시 중단의 의미: + * - 코루틴이 잠깐 멈추고 스레드를 양보 + * - 다른 코루틴이나 UI 작업이 실행될 수 있음 + * - 네트워크 응답이 오면 자동으로 재개됨 + */ + val response = authService.login(request) + + // 여기 도달 = 네트워크 통신 성공 + + // 로딩 종료 + _uiState.value = _uiState.value.copy(isLoading = false) + + // API 응답 처리 + if (response.success) { + // 로그인 성공 + val realUserId = response.data.userId + Log.d("Login", "실제 userId: $realUserId") + + // SharedPreferences에 로그인 정보 저장 + authPreferences.saveLoginInfo( + userId = realUserId, + username = id, + password = pw + ) + + // UiState 업데이트 + _uiState.value = _uiState.value.copy( + loginSuccess = true, + errorMessage = null + ) + + // 성공 콜백 호출 + onSuccess(realUserId.toString(), pw) + } else { + // API에서 success = false 응답 + val errorMsg = response.message ?: "로그인 실패" + _uiState.value = _uiState.value.copy( + loginSuccess = false, + errorMessage = errorMsg + ) + onFailure(errorMsg) + } + + } catch (e: HttpException) { + /** + * HttpException: + * - HTTP 에러 코드가 있는 경우 (400, 401, 404, 500 등) + * - 서버가 응답은 했지만 에러 코드를 반환한 경우 + * + * 예시: + * - 401: 인증 실패 (비밀번호 틀림) + * - 403: 권한 없음 (계정 비활성화) + * - 404: 사용자 없음 + * - 500: 서버 에러 + */ + _uiState.value = _uiState.value.copy(isLoading = false) + + val errorMsg = when (e.code()) { + 401 -> "아이디 또는 비밀번호가 틀렸습니다" + 403 -> "비활성화된 사용자입니다" + 404 -> "존재하지 않는 사용자입니다" + 500 -> "서버 오류가 발생했습니다" + else -> "로그인 실패: ${e.message()}" + } + + _uiState.value = _uiState.value.copy( + loginSuccess = false, + errorMessage = errorMsg + ) + onFailure(errorMsg) + + Log.e("Login", "HTTP 에러: ${e.code()} - ${e.message()}") + + } catch (e: IOException) { + /** + * IOException: + * - 네트워크 연결 문제 (인터넷 끊김, 타임아웃 등) + * - 서버에 도달하지 못한 경우 + * + * 예시: + * - 와이파이/데이터 꺼짐 + * - 서버 다운 + * - 타임아웃 + */ + _uiState.value = _uiState.value.copy(isLoading = false) + + val errorMsg = "네트워크 연결을 확인해주세요" _uiState.value = _uiState.value.copy( - loginSuccess = true, - errorMessage = null + loginSuccess = false, + errorMessage = errorMsg ) - onSuccess(id, pw) - } else { + onFailure(errorMsg) + + Log.e("Login", "네트워크 오류: ${e.message}") + + } catch (e: Exception) { + /** + * Exception: + * - 그 외 모든 예외 처리 + * - 파싱 에러, 예상치 못한 에러 등 + */ + _uiState.value = _uiState.value.copy(isLoading = false) + + val errorMsg = "알 수 없는 오류: ${e.message}" _uiState.value = _uiState.value.copy( loginSuccess = false, - errorMessage = "ID 또는 비밀번호가 일치하지 않습니다." + errorMessage = errorMsg ) - onFailure("ID 또는 비밀번호가 일치하지 않습니다.") + onFailure(errorMsg) + + Log.e("Login", "예외 발생: ${e.message}") } } } } + +/** + * ======================================== + * 코루틴 개념 정리 + * ======================================== + * + * 1. viewModelScope vs GlobalScope + * - viewModelScope: ViewModel에 종속, 자동 취소 (권장) + * - GlobalScope: 앱 전체에 종속, 수동 취소 필요 (비권장) + * + * 2. launch vs async + * - launch: 결과를 반환하지 않음, Job 반환 + * - async: 결과를 반환, Deferred 반환 + * + * 3. suspend 함수 + * - 일시 중단 가능한 함수 + * - 코루틴 내부나 다른 suspend 함수에서만 호출 가능 + * - 스레드를 블로킹하지 않음 + * + * 4. 예외 처리 + * - try-catch로 간단히 처리 + * - HttpException: HTTP 에러 + * - IOException: 네트워크 에러 + * - Exception: 기타 에러 + * + * 5. Callback vs 코루틴 비교 + * + * ❌ Callback (복잡함): + * authService.login(request).enqueue(object : Callback { + * override fun onResponse(call: Call, response: Response) { + * if (response.isSuccessful) { + * val data = response.body() + * // 성공 처리 + * } else { + * // 에러 처리 + * } + * } + * override fun onFailure(call: Call, t: Throwable) { + * // 네트워크 에러 처리 + * } + * }) + * + * ✅ 코루틴 (간결함): + * viewModelScope.launch { + * try { + * val response = authService.login(request) + * // 성공 처리 + * } catch (e: HttpException) { + * // HTTP 에러 처리 + * } catch (e: IOException) { + * // 네트워크 에러 처리 + * } + * } + * + * 6. 실행 흐름 예시 + * 버튼 클릭 + * ↓ + * loginWithApi() 호출 + * ↓ + * viewModelScope.launch { } 시작 + * ↓ + * isLoading = true (UI 업데이트) + * ↓ + * authService.login() 호출 (일시 중단) + * ↓ + * [네트워크 통신 중 - 다른 작업 가능] + * ↓ + * 응답 도착 → 코루틴 재개 + * ↓ + * try 블록: 성공 처리 + * or + * catch 블록: 에러 처리 + * ↓ + * isLoading = false (UI 업데이트) + * ↓ + * onSuccess/onFailure 콜백 호출 + */ diff --git a/app/src/main/java/com/sopt/dive/feature/main/MainActivity.kt b/app/src/main/java/com/sopt/dive/feature/main/MainActivity.kt index 87f6bde..05d6195 100644 --- a/app/src/main/java/com/sopt/dive/feature/main/MainActivity.kt +++ b/app/src/main/java/com/sopt/dive/feature/main/MainActivity.kt @@ -11,7 +11,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute -import com.sopt.dive.core.data.UserPreferences +import com.sopt.dive.core.data.AuthPreferences // ✅ UserPreferences → AuthPreferences import com.sopt.dive.core.ui.navigation.NavigationRoute import com.sopt.dive.core.ui.theme.DiveTheme import com.sopt.dive.feature.card.FlippingCardScreen @@ -34,23 +34,23 @@ class MainActivity : ComponentActivity() { fun AppNavigation() { val navController = rememberNavController() val context = LocalContext.current - val userPreferences = UserPreferences.getInstance(context) + val authPreferences = AuthPreferences.getInstance(context) // 변경 - // 로그인 상태를 확인하여 자동 로그인을 처리 + // 자동 로그인 처리 LaunchedEffect(Unit) { - if (userPreferences.isLoggedIn()) { - // 이미 로그인되어 있다면 저장된 사용자 정보를 불러와서 메인 화면으로 이동 - val userData = userPreferences.getUserData() - if (userData != null) { + if (authPreferences.isLoggedIn()) { + val loginInfo = authPreferences.getLoginInfo() + if (loginInfo != null) { + // 저장된 userId로 바로 이동 (사용자 정보는 MyScreen에서 API로 가져옴) navController.navigate( - NavigationRoute.MainContainer( // Main → MainContainer - userId = userData.userId, - userNickname = userData.userNickname, - userExtra = userData.userExtra, - userPw = userData.userPw + NavigationRoute.MainContainer( + userId = loginInfo.userId.toString(), // 실제 userId + userNickname = "로딩중...", // 임시값 (API에서 갱신됨) + userEmail = "로딩중...", + userAge = 0, + userPw = loginInfo.password ) ) { - // 로그인 화면을 백스택에서 제거하여 뒤로가기로 돌아갈 수 없도록 함 popUpTo(NavigationRoute.Login) { inclusive = true } } } @@ -63,28 +63,25 @@ fun AppNavigation() { ) { composable { LoginScreen( - onLoginClick = { id, pw -> - // 입력한 ID와 비밀번호가 저장된 정보와 일치하는지 확인 - if (userPreferences.validateLogin(id, pw)) { - // 로그인 성공: 로그인 상태를 저장하고 메인 화면으로 이동 - userPreferences.setLoggedIn(true) + onLoginClick = { userId, password -> + //API 로그인 성공 시 AuthPreferences에 저장 + val userIdInt = userId.toIntOrNull() ?: 0 + authPreferences.saveLoginInfo( + userId = userIdInt, // 실제 userId (359) + username = "unknown", // username을 모르므로 임시값 + password = password + ) - val userData = userPreferences.getUserData() - if (userData != null) { - navController.navigate( - NavigationRoute.MainContainer( // Main → MainContainer - userId = userData.userId, - userNickname = userData.userNickname, - userExtra = userData.userExtra, - userPw = userData.userPw - ) - ) { - popUpTo { inclusive = true } - } - } - } else { - // 로그인 실패: 사용자에게 에러 메시지 표시 - android.util.Log.e("AppNavigation", "로그인 실패: ID 또는 비밀번호가 일치하지 않습니다") + navController.navigate( + NavigationRoute.MainContainer( + userId = userId, // 실제 userId (359) + userNickname = "로딩중...", + userEmail = "로딩중...", + userAge = 0, + userPw = password + ) + ) { + popUpTo { inclusive = true } } }, onSignUpClick = { @@ -96,15 +93,7 @@ fun AppNavigation() { composable { SignUpScreen( onSignUpClick = { signUpData -> - // 회원가입 정보를 SharedPreferences에 저장 - userPreferences.saveUser( - signUpData.userId, - signUpData.password, - signUpData.nickname, - signUpData.extra - ) - - // 회원가입 성공 후 로그인 화면 + // 로컬 저장 제거 navController.navigate(NavigationRoute.Login) { popUpTo { inclusive = true } } @@ -112,17 +101,18 @@ fun AppNavigation() { ) } - composable { backStackEntry -> // Main → MainContainer + composable { backStackEntry -> val mainArgs = backStackEntry.toRoute() MainContainerScreen( mainRoute = mainArgs ) } + composable { FlippingCardScreen( onNavigateBack = { navController.popBackStack() } ) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/feature/main/MainContainerScreen.kt b/app/src/main/java/com/sopt/dive/feature/main/MainContainerScreen.kt index c929ddd..f3608cd 100644 --- a/app/src/main/java/com/sopt/dive/feature/main/MainContainerScreen.kt +++ b/app/src/main/java/com/sopt/dive/feature/main/MainContainerScreen.kt @@ -28,7 +28,7 @@ fun MainContainerScreen( mainRoute: NavigationRoute.MainContainer ) { // 바텀 네비게이션 탭 간 이동을 위한 별도의 NavController를 생성 - // 이것은 앱 전체 네비게이션과는 독립적으로 동작하는 내부 네비게이션입 + // 이것은 앱 전체 네비게이션과는 독립적으로 동작하는 내부 네비게이션입니다 val bottomNavController = rememberNavController() Scaffold( @@ -41,12 +41,12 @@ fun MainContainerScreen( ) { innerPadding -> NavHost( navController = bottomNavController, - startDestination = NavigationRoute.Home, // 파라미터 제거 + startDestination = NavigationRoute.Home, modifier = Modifier.padding(innerPadding) ) { composable { HomeScreen( - userId = mainRoute.userId, // mainRoute에서 직접 전달 + userId = mainRoute.userId, userNickname = mainRoute.userNickname ) } @@ -57,28 +57,26 @@ fun MainContainerScreen( composable { MyScreen( - userId = mainRoute.userId, // mainRoute에서 직접 전달 + userId = mainRoute.userId, userNickname = mainRoute.userNickname, - userExtra = mainRoute.userExtra, + userEmail = mainRoute.userEmail, // userExtra → userEmail + userAge = mainRoute.userAge, // age 추가 userPw = mainRoute.userPw, onNavigateToCard = { - // NavigationRoute.Card 사용 bottomNavController.navigate(NavigationRoute.Card) } ) } - // 수정 (리뷰 댓글 반영) + composable { FlippingCardScreen( onNavigateBack = { bottomNavController.popBackStack() } ) } - } } } - // Preview 함수는 UI 확인용으로만 사용되므로 private으로 선언 @Preview(showBackground = true, name = "Main Container Screen") @Composable @@ -87,7 +85,8 @@ private fun MainContainerScreenPreview() { val dummyMainRoute = NavigationRoute.MainContainer( userId = "previewUser", userNickname = "미리보기", - userExtra = "가위", + userEmail = "test@example.com", // ✅ userExtra → userEmail + userAge = 25, // ✅ age 추가 userPw = "preview123" ) diff --git a/app/src/main/java/com/sopt/dive/feature/my/MyScreen.kt b/app/src/main/java/com/sopt/dive/feature/my/MyScreen.kt index f556367..c660dcc 100644 --- a/app/src/main/java/com/sopt/dive/feature/my/MyScreen.kt +++ b/app/src/main/java/com/sopt/dive/feature/my/MyScreen.kt @@ -1,13 +1,16 @@ package com.sopt.dive.feature.my - +import android.util.Log import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text @@ -30,18 +33,12 @@ import coil.ImageLoader import coil.compose.AsyncImage import coil.decode.GifDecoder -/** - * My 탭 화면 - * 1주차에서 만든 메인페이지를 여기로 옮겼습니다 - */ - -// 메인 화면 컴포저블 -// 로그인한 사용자의 정보를 표시 @Composable fun MyScreen( userId: String, userNickname: String, - userExtra: String, + userEmail: String, + userAge: Int, userPw: String, onNavigateToCard: () -> Unit, modifier: Modifier = Modifier, @@ -51,7 +48,22 @@ fun MyScreen( // ViewModel에 유저 정보 전달 LaunchedEffect(Unit) { - viewModel.setUserInfo(userId, userPw, userNickname, userExtra) + Log.d("MyScreen", "LaunchedEffect 시작 - userId: $userId") + + // 로컬 정보 먼저 설정 (빠른 표시) + viewModel.setUserInfo(userId, userPw, userNickname, userEmail, userAge) + Log.d("MyScreen", "로컬 정보 설정 완료") + + // 서버에서 최신 정보 가져오기 (userId를 Int로 변환) + val userIdInt = userId.toIntOrNull() + Log.d("MyScreen", "userId 변환 시도: $userId -> $userIdInt") + + if (userIdInt != null) { + Log.d("MyScreen", "API 호출 시작: userId = $userIdInt") + viewModel.fetchUserFromServer(userIdInt) + } else { + Log.e("MyScreen", "userId를 Int로 변환 실패: $userId") + } } val context = LocalContext.current @@ -76,6 +88,34 @@ fun MyScreen( fontWeight = FontWeight.Bold, ) + // 로딩 상태 표시 + if (uiState.isLoading) { + Text( + text = "📡 사용자 정보 불러오는 중...", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + textAlign = TextAlign.Center, + color = Color.Blue, + fontWeight = FontWeight.Bold + ) + } + + // 에러 상태 표시 + if (uiState.errorMessage != null) { + Text( + text = "⚠️ ${uiState.errorMessage}", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .background(Color.Red.copy(alpha = 0.1f)) + .padding(8.dp), + textAlign = TextAlign.Center, + color = Color.Red, + fontWeight = FontWeight.Bold + ) + } + InfoBlock(label = "ID", value = uiState.userId) Spacer(modifier = Modifier.height(24.dp)) @@ -85,26 +125,46 @@ fun MyScreen( InfoBlock(label = "NICKNAME", value = uiState.userNickname) Spacer(modifier = Modifier.height(24.dp)) - InfoBlock(label = "승자는 ?", value = uiState.userExtra) + InfoBlock(label = "EMAIL", value = uiState.userEmail) + Spacer(modifier = Modifier.height(24.dp)) + + InfoBlock(label = "AGE", value = uiState.userAge.toString()) - Button( - onClick = { onNavigateToCard() }, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Yellow, - contentColor = Color.Black - ), - modifier = Modifier.align(Alignment.CenterHorizontally) + Spacer(modifier = Modifier.height(32.dp)) + + // ✅ 버튼들을 가로로 배치 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, // 균등 배치 + verticalAlignment = Alignment.CenterVertically ) { - Text("GO to Card") - } + // 새로고침 버튼 + Button( + onClick = { + val userIdInt = userId.toIntOrNull() + if (userIdInt != null) { + viewModel.fetchUserFromServer(userIdInt) + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Blue, + contentColor = Color.White + ) + ) { + Text("새로고침") + } - AsyncImage( - model = "https://github.com/dmp100/dmp100/raw/main/gifs/gif1.gif", - contentDescription = "GIF", - imageLoader = imageLoader, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) + // 카드 이동 버튼 + Button( + onClick = { onNavigateToCard() }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Yellow, + contentColor = Color.Black + ) + ) { + Text("GO to Card") + } + } } } @@ -121,15 +181,15 @@ private fun InfoBlock(label: String, value: String) { } } -// Preview 함수는 UI 확인용으로만 사용되므로 private으로 선언 @Preview(showBackground = true, name = "Main Screen") @Composable private fun MainScreenPreview() { MyScreen( userId = "1234", userNickname = "555", - userExtra = "421412", + userEmail = "test@example.com", + userAge = 25, userPw = "4444", - onNavigateToCard = {} // 더미는 이렇게 + onNavigateToCard = {} ) } \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/feature/my/MyUiState.kt b/app/src/main/java/com/sopt/dive/feature/my/MyUiState.kt index 582f0ee..339088b 100644 --- a/app/src/main/java/com/sopt/dive/feature/my/MyUiState.kt +++ b/app/src/main/java/com/sopt/dive/feature/my/MyUiState.kt @@ -4,5 +4,8 @@ data class MyUiState( val userId: String = "", val userPw: String = "", val userNickname: String = "", - val userExtra: String = "" -) + val userEmail: String = "", // userExtra → userEmail + val userAge: Int = 0, // age 추가 + val isLoading: Boolean = false, // 로딩 상태 추가 - 사용자 정보 조회 API 추가 + val errorMessage: String? = null // 에러 메시지 추가 - 사용자 정보 조회 API 추가 +) \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/feature/my/MyViewModel.kt b/app/src/main/java/com/sopt/dive/feature/my/MyViewModel.kt index f74a548..69ba660 100644 --- a/app/src/main/java/com/sopt/dive/feature/my/MyViewModel.kt +++ b/app/src/main/java/com/sopt/dive/feature/my/MyViewModel.kt @@ -1,19 +1,287 @@ package com.sopt.dive.feature.my +import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sopt.dive.core.data.datasource.ServicePool import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import retrofit2.HttpException +import java.io.IOException +/** + * MyViewModel - 코루틴 기반 API 호출 + * + * Callback → 코루틴 변환 예시 + * 이 ViewModel은 사용자 정보를 서버에서 조회하는 기능을 제공합니다. + */ class MyViewModel : ViewModel() { + private val _uiState = MutableStateFlow(MyUiState()) val uiState: StateFlow = _uiState - fun setUserInfo(id: String, pw: String, nickname: String, extra: String) { + private val authService = ServicePool.authService + + /** + * 기존 로컬 정보 설정 (호환성 유지) + * 네비게이션에서 전달받은 사용자 정보를 즉시 표시 + */ + fun setUserInfo(userId: String, userPw: String, userNickname: String, userEmail: String, userAge: Int) { _uiState.value = MyUiState( - userId = id, - userPw = pw, - userNickname = nickname, - userExtra = extra + userId = userId, + userPw = userPw, + userNickname = userNickname, + userEmail = userEmail, + userAge = userAge ) } + + /** + * 서버에서 사용자 정보 가져오기 (코루틴 버전) + * + * 실시간으로 최신 정보를 조회하여 UI를 업데이트합니다. + * + * ❌ 이전 Callback 방식 (15줄): + * authService.getUserById(userId).enqueue(object : Callback { + * override fun onResponse(...) { } + * override fun onFailure(...) { } + * }) + * + * ✅ 현재 코루틴 방식 (5줄): + * viewModelScope.launch { + * try { + * val response = authService.getUserById(userId) + * } catch (e: Exception) { } + * } + */ + fun fetchUserFromServer(userId: Int) { + /** + * viewModelScope.launch: + * - ViewModel 생명주기에 맞춰 자동 관리되는 코루틴 스코프 + * - ViewModel이 onCleared()되면 자동으로 취소 + * - 메모리 누수 방지 + */ + viewModelScope.launch { + // 로딩 시작 + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + /** + * try-catch 블록: + * - suspend 함수에서 발생하는 예외를 처리 + * - Callback의 onResponse/onFailure를 대체 + * + * 장점: + * 1. 코드가 순차적으로 읽힘 + * 2. 중첩된 콜백 없음 + * 3. 에러 처리가 직관적 + */ + try { + /** + * authService.getUserById(userId): + * - suspend 함수 호출 + * - 네트워크 통신 중 코루틴이 일시 중단됨 + * - 메인 스레드는 블로킹되지 않음 (UI 반응 가능) + * - 응답이 오면 자동으로 재개 + * + * 일시 중단의 의미: + * - "잠깐 멈추고 다른 작업 하세요~" + * - 스레드를 양보하여 UI가 끊기지 않음 + * - 네트워크 응답이 오면 자동으로 돌아옴 + */ + val userData = authService.getUserById(userId) + + /** + * 여기 도달 = 네트워크 통신 성공 + * userData 객체에 API 응답이 담겨있음 + */ + + // 로딩 종료 + _uiState.value = _uiState.value.copy(isLoading = false) + + // API 응답 처리 + if (userData.success) { + // 서버에서 받은 최신 정보로 업데이트 + Log.d("MyViewModel", "사용자 정보 조회 성공: ${userData.data.name}") + + _uiState.value = _uiState.value.copy( + userId = userData.data.username, + userNickname = userData.data.name, + userEmail = userData.data.email, + userAge = userData.data.age, + errorMessage = null + ) + } else { + // API에서 success = false 응답 + _uiState.value = _uiState.value.copy( + errorMessage = userData.message ?: "사용자 정보를 불러올 수 없습니다" + ) + } + + } catch (e: HttpException) { + /** + * HttpException: + * - HTTP 에러 코드가 있는 경우 (400, 401, 404, 500 등) + * - 서버가 응답은 했지만 에러 코드를 반환 + * + * 예시: + * - 404: 사용자를 찾을 수 없음 + * - 401: 인증 실패 + * - 500: 서버 내부 오류 + */ + _uiState.value = _uiState.value.copy(isLoading = false) + + val errorMsg = when (e.code()) { + 404 -> "사용자를 찾을 수 없습니다" + 401 -> "인증이 필요합니다" + 500 -> "서버 오류가 발생했습니다" + else -> "서버 오류: ${e.message()}" + } + + _uiState.value = _uiState.value.copy(errorMessage = errorMsg) + Log.e("MyViewModel", "HTTP 에러: ${e.code()} - ${e.message()}") + + } catch (e: IOException) { + /** + * IOException: + * - 네트워크 연결 문제 + * - 서버에 도달하지 못함 + * + * 예시: + * - 인터넷 연결 끊김 + * - 타임아웃 + * - DNS 실패 + */ + _uiState.value = _uiState.value.copy(isLoading = false) + + val errorMsg = "네트워크 연결을 확인해주세요" + _uiState.value = _uiState.value.copy(errorMessage = errorMsg) + Log.e("MyViewModel", "네트워크 오류: ${e.message}") + + } catch (e: Exception) { + /** + * Exception: + * - 그 외 모든 예외 + * - 파싱 에러, 예상치 못한 에러 등 + */ + _uiState.value = _uiState.value.copy(isLoading = false) + + val errorMsg = "알 수 없는 오류: ${e.message}" + _uiState.value = _uiState.value.copy(errorMessage = errorMsg) + Log.e("MyViewModel", "예외 발생: ${e.message}") + } + } + } } + +/** + * ======================================== + * 코루틴 변환 전후 비교 (실제 코드) + * ======================================== + * + * ❌ 이전 Callback 방식 (복잡함, 25줄): + * + * fun fetchUserFromServer(userId: Int) { + * viewModelScope.launch { + * _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + * + * authService.getUserById(userId).enqueue(object : Callback { + * override fun onResponse(call: Call, response: Response) { + * _uiState.value = _uiState.value.copy(isLoading = false) + * + * if (response.isSuccessful) { + * val userData = response.body() + * if (userData?.success == true) { + * _uiState.value = _uiState.value.copy( + * userId = userData.data.username, + * userNickname = userData.data.name, + * // ... + * ) + * } + * } else { + * val errorMsg = when (response.code()) { + * 404 -> "사용자를 찾을 수 없습니다" + * // ... + * } + * } + * } + * + * override fun onFailure(call: Call, t: Throwable) { + * _uiState.value = _uiState.value.copy( + * isLoading = false, + * errorMessage = "네트워크 오류: ${t.message}" + * ) + * } + * }) + * } + * } + * + * 문제점: + * 1. 중첩이 깊음 (콜백 안에 또 콜백) + * 2. 에러 처리가 분산됨 (onResponse, onFailure) + * 3. 코드 흐름이 복잡함 + * 4. response.isSuccessful 체크 필요 + * + * + * ✅ 현재 코루틴 방식 (간결함, 15줄): + * + * fun fetchUserFromServer(userId: Int) { + * viewModelScope.launch { + * _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + * + * try { + * val userData = authService.getUserById(userId) + * _uiState.value = _uiState.value.copy(isLoading = false) + * + * if (userData.success) { + * _uiState.value = _uiState.value.copy( + * userId = userData.data.username, + * // ... + * ) + * } + * } catch (e: HttpException) { + * // HTTP 에러 + * } catch (e: IOException) { + * // 네트워크 에러 + * } + * } + * } + * + * 장점: + * 1. 순차적으로 읽힘 (위에서 아래로) + * 2. 에러 처리가 한곳에 모임 (try-catch) + * 3. 중첩이 적음 + * 4. response.isSuccessful 체크 불필요 (자동) + * 5. 코드가 40% 더 짧음 + * + * + * ======================================== + * 코루틴 실행 흐름 + * ======================================== + * + * 사용자가 "새로고침" 버튼 클릭 + * ↓ + * fetchUserFromServer(userId) 호출 + * ↓ + * viewModelScope.launch { } 시작 + * ↓ + * isLoading = true + * ↓ (UI 업데이트: 로딩 표시) + * + * authService.getUserById(userId) 호출 + * ↓ (코루틴 일시 중단) + * + * [네트워크 통신 중] + * - 메인 스레드는 블로킹되지 않음 + * - 사용자는 UI와 상호작용 가능 + * - 다른 코루틴도 실행 가능 + * ↓ + * + * 응답 도착 → 코루틴 자동 재개 + * ↓ + * isLoading = false + * ↓ + * 데이터 처리 및 UI 업데이트 + * ↓ + * 완료! + */ diff --git a/app/src/main/java/com/sopt/dive/feature/signup/SignUpDataModel.kt b/app/src/main/java/com/sopt/dive/feature/signup/SignUpDataModel.kt index c11f760..37a5464 100644 --- a/app/src/main/java/com/sopt/dive/feature/signup/SignUpDataModel.kt +++ b/app/src/main/java/com/sopt/dive/feature/signup/SignUpDataModel.kt @@ -4,5 +4,6 @@ data class SignUpData( val userId: String, val password: String, val nickname: String, - val extra: String + val email: String, // 이메일 추가 + val age: Int // 나이 추가 (Int로 변경) ) \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/feature/signup/SignUpScreen.kt b/app/src/main/java/com/sopt/dive/feature/signup/SignUpScreen.kt index 62dbcb7..355fb7c 100644 --- a/app/src/main/java/com/sopt/dive/feature/signup/SignUpScreen.kt +++ b/app/src/main/java/com/sopt/dive/feature/signup/SignUpScreen.kt @@ -12,10 +12,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -25,14 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.sopt.dive.feature.signup.components.SignUpTextFieldComponent - -/** - * 회원가입 화면 컴포저블 - * - * @param modifier 외부에서 전달받는 Modifier (재사용성을 위해) - * @param onSignUpClick 회원가입 버튼 클릭 시 실행될 콜백 함수 - */ - +import com.sopt.dive.feature.signup.viewmodel.SignUpViewModel @Composable fun SignUpScreen( @@ -45,7 +34,8 @@ fun SignUpScreen( val idText = viewModel.id.value val pwText = viewModel.password.value val nicknameText = viewModel.nickname.value - val numberText = viewModel.extra.value + val emailText = viewModel.email.value // 이메일 추가 + val ageText = viewModel.age.value // 나이 추가 Column( modifier = modifier @@ -82,19 +72,30 @@ fun SignUpScreen( Spacer(modifier = Modifier.height(24.dp)) SignUpTextFieldComponent( - label = "NICKNAME", + label = "NAME", value = nicknameText, onValueChange = { viewModel.onNicknameChange(it) }, - placeholder = "닉네임을 입력해주세요." + placeholder = "이름을 입력해주세요." + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 이메일 필드 추가 + SignUpTextFieldComponent( + label = "EMAIL", + value = emailText, + onValueChange = { viewModel.onEmailChange(it) }, + placeholder = "이메일을 입력해주세요." ) Spacer(modifier = Modifier.height(24.dp)) + // 나이 필드 추가 SignUpTextFieldComponent( - label = "가위바위보", - value = numberText, - onValueChange = { viewModel.onExtraChange(it) }, - placeholder = "1은 가위, 2는 바위, 3은 보" + label = "AGE", + value = ageText, + onValueChange = { viewModel.onAgeChange(it) }, + placeholder = "나이를 입력해주세요." ) Spacer(modifier = Modifier.weight(1f)) @@ -105,15 +106,24 @@ fun SignUpScreen( if (errorMessage != null) { Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show() } else { - onSignUpClick(viewModel.getSignUpData()) + viewModel.signUpWithApi( + onSuccess = { + Toast.makeText(context, "회원가입 성공!", Toast.LENGTH_SHORT).show() + onSignUpClick(viewModel.getSignUpData()) + }, + onError = { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + ) } }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = Color.Black), - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), + enabled = !viewModel.isLoading.value ) { Text( - text = "회원가입", + text = if (viewModel.isLoading.value) "처리 중..." else "회원가입", color = Color.White, modifier = Modifier.padding(vertical = 8.dp) ) diff --git a/app/src/main/java/com/sopt/dive/feature/signup/SignUpViewModel.kt b/app/src/main/java/com/sopt/dive/feature/signup/SignUpViewModel.kt deleted file mode 100644 index f8cf04c..0000000 --- a/app/src/main/java/com/sopt/dive/feature/signup/SignUpViewModel.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.sopt.dive.feature.signup - -import androidx.lifecycle.ViewModel -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.State - -class SignUpViewModel : ViewModel() { - - private val _id = mutableStateOf("") - val id: State = _id - - private val _password = mutableStateOf("") - val password: State = _password - - private val _nickname = mutableStateOf("") - val nickname: State = _nickname - - private val _extra = mutableStateOf("") - val extra: State = _extra - - fun onIdChange(newId: String) { - _id.value = newId - } - - fun onPasswordChange(newPassword: String) { - _password.value = newPassword - } - - fun onNicknameChange(newNickname: String) { - _nickname.value = newNickname - } - - fun onExtraChange(newExtra: String) { - _extra.value = newExtra - } - - fun validateInput(): String? { - return when { - _id.value.length !in 6..10 -> "ID는 6~10글자여야 합니다" - _password.value.length !in 8..12 -> "PW는 8~12글자여야 합니다" - _nickname.value.isBlank() -> "닉네임을 입력해주세요" - _extra.value.isBlank() -> "추가 정보를 입력해주세요" - else -> null - } - } - - fun getSignUpData(): SignUpData { - return SignUpData( - userId = _id.value, - password = _password.value, - nickname = _nickname.value, - extra = _extra.value - ) - } -} diff --git a/app/src/main/java/com/sopt/dive/feature/signup/viewmodel/SignUpViewModel.kt b/app/src/main/java/com/sopt/dive/feature/signup/viewmodel/SignUpViewModel.kt new file mode 100644 index 0000000..82d5b37 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/feature/signup/viewmodel/SignUpViewModel.kt @@ -0,0 +1,268 @@ +package com.sopt.dive.feature.signup.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.State +import androidx.lifecycle.viewModelScope +import com.sopt.dive.core.data.datasource.ServicePool +import com.sopt.dive.core.data.dto.RequestSignUpDto +import com.sopt.dive.feature.signup.SignUpData +import kotlinx.coroutines.launch +import retrofit2.HttpException +import java.io.IOException + +/** + * SignUpViewModel - 코루틴 기반 회원가입 + * + * Callback → 코루틴 변환 완료 + * 더 간결하고 읽기 쉬운 코드로 개선되었습니다. + */ +class SignUpViewModel : ViewModel() { + + private val authService = ServicePool.authService + + private val _id = mutableStateOf("") + val id: State = _id + + private val _password = mutableStateOf("") + val password: State = _password + + private val _nickname = mutableStateOf("") + val nickname: State = _nickname + + private val _email = mutableStateOf("") + val email: State = _email + + private val _age = mutableStateOf("") + val age: State = _age + + private val _isLoading = mutableStateOf(false) + val isLoading: State = _isLoading + + fun onIdChange(newId: String) { + _id.value = newId + } + + fun onPasswordChange(newPassword: String) { + _password.value = newPassword + } + + fun onNicknameChange(newNickname: String) { + _nickname.value = newNickname + } + + fun onEmailChange(newEmail: String) { + _email.value = newEmail + } + + fun onAgeChange(newAge: String) { + _age.value = newAge + } + + /** + * 입력값 검증 + * + * 회원가입 전 모든 입력값이 유효한지 확인합니다. + * null 반환 = 모든 검증 통과 + */ + fun validateInput(): String? { + return when { + _id.value.length !in 6..10 -> "ID는 6~10글자여야 합니다" + _password.value.length !in 8..12 -> "PW는 8~12글자여야 합니다" + _nickname.value.isBlank() -> "이름을 입력해주세요" + _email.value.isBlank() -> "이메일을 입력해주세요" + !_email.value.contains("@") -> "올바른 이메일 형식이 아닙니다" + _age.value.isBlank() -> "나이를 입력해주세요" + _age.value.toIntOrNull() == null -> "나이는 숫자여야 합니다" + (_age.value.toIntOrNull() ?: 0) !in 14..100 -> "나이는 14~100 사이여야 합니다" + else -> null + } + } + + fun getSignUpData(): SignUpData { + return SignUpData( + userId = _id.value, + password = _password.value, + nickname = _nickname.value, + email = _email.value, + age = _age.value.toIntOrNull() ?: 0 + ) + } + + /** + * 코루틴 기반 회원가입 API 호출 + * + * ❌ 이전 Callback 방식 (25줄): + * authService.signUp(request).enqueue(object : Callback { + * override fun onResponse(...) { } + * override fun onFailure(...) { } + * }) + * + * ✅ 현재 코루틴 방식 (10줄): + * viewModelScope.launch { + * try { + * val response = authService.signUp(request) + * } catch (e: Exception) { } + * } + */ + fun signUpWithApi(onSuccess: () -> Unit, onError: (String) -> Unit) { + /** + * viewModelScope.launch: + * - ViewModel 생명주기에 맞춰 자동 관리 + * - 화면이 파괴되면 자동으로 취소됨 + * - 메모리 누수 방지 + */ + viewModelScope.launch { + // 로딩 시작 + _isLoading.value = true + + /** + * try-catch 블록: + * - Callback의 onResponse/onFailure를 대체 + * - 더 직관적인 에러 처리 + */ + try { + // API 요청 객체 생성 + val request = RequestSignUpDto( + username = _id.value, + password = _password.value, + name = _nickname.value, + email = _email.value, + age = _age.value.toIntOrNull() ?: 25 + ) + + /** + * authService.signUp(request): + * - suspend 함수 호출 + * - 네트워크 통신 중 일시 중단 + * - 응답이 오면 자동으로 재개 + */ + val userData = authService.signUp(request) + + // 여기 도달 = 네트워크 통신 성공 + + // 로딩 종료 + _isLoading.value = false + + // API 응답 처리 + if (userData.success) { + // 회원가입 성공 + Log.d("SignUp", "회원가입 성공: ${userData.data.name}") + onSuccess() + } else { + // API에서 success = false 응답 + Log.e("SignUp", "서버 에러: ${userData.message}") + onError(userData.message ?: "알 수 없는 오류") + } + + } catch (e: HttpException) { + /** + * HttpException: + * - HTTP 에러 코드 (400, 401, 409, 500 등) + * - 서버가 응답은 했지만 에러 코드를 반환 + * + * 예시: + * - 409: 중복된 사용자명 + * - 400: 잘못된 요청 + * - 500: 서버 내부 오류 + */ + _isLoading.value = false + + val errorMsg = when (e.code()) { + 409 -> "이미 존재하는 사용자명입니다" + 400 -> "입력 정보를 확인해주세요" + 500 -> "서버 오류가 발생했습니다" + else -> "회원가입 실패: ${e.message()}" + } + + Log.e("SignUp", "HTTP 에러 ${e.code()}: ${e.message()}") + onError(errorMsg) + + } catch (e: IOException) { + /** + * IOException: + * - 네트워크 연결 문제 + * - 서버에 도달하지 못함 + * + * 예시: + * - 인터넷 연결 끊김 + * - 타임아웃 + * - DNS 실패 + */ + _isLoading.value = false + + val errorMsg = "네트워크 연결을 확인해주세요" + Log.e("SignUp", "네트워크 오류: ${e.message}") + onError(errorMsg) + + } catch (e: Exception) { + /** + * Exception: + * - 그 외 모든 예외 + * - 파싱 에러, 예상치 못한 에러 등 + */ + _isLoading.value = false + + val errorMsg = "알 수 없는 오류: ${e.message}" + Log.e("SignUp", "예외 발생: ${e.message}") + onError(errorMsg) + } + } + } +} + +/** + * ======================================== + * 코루틴 변환의 장점 (SignUpViewModel 예시) + * ======================================== + * + * 코드 라인 수 비교: + * - Callback 방식: 약 25줄 + * - 코루틴 방식: 약 15줄 + * - 40% 코드 감소! + * + * 가독성: + * - Callback: 중첩된 구조로 흐름 파악 어려움 + * - 코루틴: 순차적으로 읽힘, 흐름이 명확함 + * + * 에러 처리: + * - Callback: onResponse와 onFailure로 분산 + * - 코루틴: try-catch로 한곳에 집중 + * + * 유지보수: + * - Callback: 수정 시 여러 곳 변경 필요 + * - 코루틴: 한곳만 수정하면 됨 + * + * + * ======================================== + * 실행 흐름 + * ======================================== + * + * 사용자가 "회원가입" 버튼 클릭 + * ↓ + * signUpWithApi() 호출 + * ↓ + * viewModelScope.launch { } 시작 + * ↓ + * isLoading = true + * ↓ (UI: 로딩 표시) + * + * authService.signUp(request) 호출 + * ↓ (코루틴 일시 중단) + * + * [네트워크 통신 중] + * - 메인 스레드 블로킹 없음 + * - UI 반응 유지 + * ↓ + * + * 응답 도착 → 코루틴 재개 + * ↓ + * isLoading = false + * ↓ + * 성공/실패 처리 + * ↓ + * onSuccess() 또는 onError() 콜백 호출 + * ↓ + * 완료! + */ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c56b219..bddfc64 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,13 @@ composeBom = "2024.09.00" navigation = "2.9.5" coil = "2.5.0" + +# server +retrofit = "2.11.0" +retrofit-kotlinx-serialization-json = "1.0.0" +okhttp = "4.12.0" +kotlinx-serialization-json = "1.6.3" # 1.9.0 버젼은 코틀린 2.2.0 이랑 호환됨,, 버젼 항상 신경쓰기 (컴파일오류남) + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -35,10 +42,19 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-gif = { group = "io.coil-kt", name = "coil-gif", version.ref = "coil" } + +# server +retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofit-kotlinx-serialization-json" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } + + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } [bundles] androidx = [