Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a047a58
feat: Retrofit, API 통신 초기 설정
1hyok Nov 6, 2025
c62340d
Merge upstream/develop into JeongIlhyuk/week6
1hyok Nov 6, 2025
7b4f7b3
feat: Snackbar 기능 추가
1hyok Nov 7, 2025
4d8a1f5
refactor: DI 적용 및 Repository 패턴 도입
1hyok Nov 12, 2025
fb67908
refactor: ViewModel 분리 및 Repository 패턴 적용
1hyok Nov 12, 2025
b801059
refactor: ViewModel 분리 및 UI 상태 관리 개선
1hyok Nov 13, 2025
ba74537
refactor: ViewModel 팩토리 및 게시글 목록 새로고침 로직 개선
1hyok Nov 13, 2025
cb50212
refactor: ViewModel 생성 로직 변경
1hyok Nov 13, 2025
179fd7d
refactor: [미션] ViewModel 분리 및 Repository 패턴 적용 시작
1hyok Nov 12, 2025
aca9015
refactor: ViewModel 분리 및 UI 상태 관리 개선
1hyok Nov 13, 2025
fd56889
refactor: ViewModel 팩토리 및 게시글 목록 새로고침 로직 개선
1hyok Nov 13, 2025
460b888
refactor: ViewModel 생성 로직 변경
1hyok Nov 13, 2025
da6b8bb
chore: Gradle 플러그인 버전 업데이트
1hyok Nov 13, 2025
04f83ac
refactor: PostRepository를 인터페이스로 변경 및 PostRepositoryImpl에 모든 메서드 구현
1hyok Nov 13, 2025
4e4cb53
Merge branch 'JeongIlhyuk/week7' of https://github.com/JeongIlhyuk/KU…
1hyok Nov 13, 2025
924862d
docs: ViewModel 수동 주입 관련 주석 추가
1hyok Nov 13, 2025
f817333
refactor: ViewModel 분리 및 Repository 패턴 적용
1hyok Nov 13, 2025
25834c3
refactor: StateFlow 적용 및 UI 상태 관리 개선
1hyok Nov 13, 2025
e64229a
refactor: AppContainer 패키지 경로 변경 및 의존성 주입 구조 개선
1hyok Nov 13, 2025
874812c
docs: PostListScreen에 주석 추가
1hyok Nov 13, 2025
f2e0e7d
refactor: PostDetailViewModel의 UiState를 MutableStateFlow로 변경
1hyok Nov 14, 2025
039f752
Merge remote-tracking branch 'upstream/develop' into JeongIlhyuk/week8
1hyok Nov 14, 2025
555e5ed
refactor: ViewModel의 UI 상태 관리를 StateFlow로 변경
1hyok Nov 14, 2025
6a335ab
feat: 로그인 화면 및 기능 추가
1hyok Nov 14, 2025
29306ae
refactor: ViewModel UI 상태 관리 구조 개선
1hyok Nov 14, 2025
e2cd3ad
feat: 로그인 및 회원가입 기능 추가
1hyok Nov 14, 2025
2704669
feat: 로그인, 회원가입 기능 및 토큰 저장 기능 추가
1hyok Nov 14, 2025
f331764
feat: (실습2 완료)회원가입 및 로그인 시 토큰 저장 기능 추가
1hyok Nov 14, 2025
f68a3cb
feat: (실습3 완료)자동 로그인 기능 구현 준비
1hyok Nov 14, 2025
dd8492d
feat: 자동 로그인 및 토큰 기반 인증 기능 구현
1hyok Nov 14, 2025
99572ac
feat: (실습3 진행)토큰 검증 기능 및 UI 상태 연동
1hyok Nov 14, 2025
9064fcb
refactor: 토큰 검증 API 엔드포인트 및 로직 수정
1hyok Nov 15, 2025
dc63e43
chore: AuthInterceptor 코드 주석 추가
1hyok Nov 15, 2025
543fd9b
refactor: 주석 추가
1hyok Nov 22, 2025
57b0e7e
refactor: 게시물 목록 변수 추출을 통해 가독성 향상
1hyok Nov 22, 2025
7d4df19
feat: Hilt 의존성 주입 라이브러리 추가
1hyok Nov 22, 2025
1de778d
feat: Hilt를 사용한 의존성 주입(DI) 전환 및 토큰 검증 로직 리팩토링
1hyok Nov 22, 2025
a5991f4
feat: 토큰 검증 로직 분리 및 Hilt 적용
1hyok Nov 22, 2025
b94897f
feat: 토큰 검증 실패 시 자동 로그인 해제 및 토큰 삭제 기능 추가
1hyok Nov 22, 2025
9327b07
refactor: 토큰 검증 로직 Repository 분리 및 책임 명확화
1hyok Nov 22, 2025
9904e2f
refactor: Hilt를 사용한 의존성 주입으로 전환 및 AppContainer 제거
1hyok Nov 22, 2025
d1ef930
refactor: `TokenRepository` 의존성 분리 및 이름 변경
1hyok Nov 22, 2025
ba13d06
feat: Hilt를 사용한 의존성 주입(DI) 전환
1hyok Nov 22, 2025
c9a755d
refactor: 주석 수정
1hyok Nov 22, 2025
fae2449
refactor: Hilt를 사용하여 ViewModel 의존성 주입 적용
1hyok Nov 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .idea/deploymentTargetSelector.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/markdown.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions .kotlin/errors/errors-1762482068299.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

4 changes: 4 additions & 0 deletions .kotlin/errors/errors-1763117299345.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "automatic"
}
13 changes: 13 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}

android {
Expand Down Expand Up @@ -79,6 +81,9 @@ dependencies {
// Coroutines
implementation(libs.kotlinx.coroutines.android)

// DataStore Preferences
implementation(libs.androidx.datastore.preferences)

testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
Expand All @@ -90,4 +95,12 @@ dependencies {
implementation(libs.retrofit.converter.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)

// hilt
implementation(libs.hilt.android)
implementation(libs.hilt.core)
implementation(libs.hilt.navigation.compose)
ksp(libs.hilt.android.compiler)
ksp(libs.hilt.compiler)
ksp(libs.hilt.manager)
}
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
android:maxSdkVersion="32" />

<application
android:name=".App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/java/com/example/kuit6_android_api/App.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.kuit6_android_api

import android.app.Application
import androidx.lifecycle.ViewModelProvider.NewInstanceFactory.Companion.instance
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

사용되지 않는 잘못된 import를 제거해주세요.

ViewModelProvider.NewInstanceFactory.Companion.instance import는 이 파일에서 사용되지 않으며, 실제로는 companion object의 instance 프로퍼티를 사용하고 있습니다. 이 import는 혼란을 야기할 수 있으므로 제거해야 합니다.

다음과 같이 import를 제거하세요:

 package com.example.kuit6_android_api
 
 import android.app.Application
-import androidx.lifecycle.ViewModelProvider.NewInstanceFactory.Companion.instance
 import dagger.hilt.android.HiltAndroidApp
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import androidx.lifecycle.ViewModelProvider.NewInstanceFactory.Companion.instance
import androidx.lifecycle.ViewModelProvider.NewInstanceFactory.Companion.instance
🤖 Prompt for AI Agents
In app/src/main/java/com/example/kuit6_android_api/App.kt around line 4, remove
the unused and incorrect import
'androidx.lifecycle.ViewModelProvider.NewInstanceFactory.Companion.instance' —
delete this import line so the file relies on the companion object's instance
property directly and does not import a non-used symbol that causes confusion.

import dagger.hilt.android.HiltAndroidApp


@HiltAndroidApp
class App: Application() {
//Application은 Context의 하위 클래스

override fun onCreate(){
super.onCreate()
instance = this
//Application Context 전달
}

companion object{
lateinit var instance: App
private set
}
}
26 changes: 21 additions & 5 deletions app/src/main/java/com/example/kuit6_android_api/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,23 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController
import com.example.kuit6_android_api.ui.navigation.NavGraph
import com.example.kuit6_android_api.ui.navigation.PostListRoute
import com.example.kuit6_android_api.ui.theme.KUIT6_Android_APITheme
import dagger.hilt.android.AndroidEntryPoint


@AndroidEntryPoint
class MainActivity : ComponentActivity() {

// 권한 요청 런처
Expand All @@ -40,15 +46,23 @@ class MainActivity : ComponentActivity() {

setContent {
KUIT6_Android_APITheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
val snackBarState = remember { SnackbarHostState() }
Scaffold(
modifier = Modifier
.fillMaxSize()
.background(
MaterialTheme.colorScheme.background
),
snackbarHost = {
SnackbarHost(hostState = snackBarState)
}
) {
val navController = rememberNavController()

NavGraph(
navController = navController,
startDestination = PostListRoute
startDestination = PostListRoute,
snackBarState = snackBarState
)
}
}
Expand All @@ -71,6 +85,7 @@ class MainActivity : ComponentActivity() {
) == PackageManager.PERMISSION_GRANTED -> {
// 이미 권한이 있음
}

shouldShowRequestPermissionRationale(permission) -> {
// 권한 거부 이력이 있음 - 설명 표시 후 재요청
Toast.makeText(
Expand All @@ -80,6 +95,7 @@ class MainActivity : ComponentActivity() {
).show()
requestPermissionLauncher.launch(permission)
}

else -> {
// 권한 요청
requestPermissionLauncher.launch(permission)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.example.kuit6_android_api.data.api

import com.example.kuit6_android_api.data.model.request.LoginRequest
import com.example.kuit6_android_api.data.model.request.PostCreateRequest
import com.example.kuit6_android_api.data.model.response.BaseResponse
import com.example.kuit6_android_api.data.model.response.LoginResponse
import com.example.kuit6_android_api.data.model.response.PostResponse
import okhttp3.MultipartBody
import retrofit2.http.Body
Expand Down Expand Up @@ -51,4 +53,19 @@ interface ApiService {
suspend fun uploadImage(
@Part file: MultipartBody.Part
): BaseResponse<Map<String, String>>

@POST("/api/auth/signup")
suspend fun signup(
@Body request: LoginRequest
): BaseResponse<LoginResponse>

@POST("/api/auth/login")
suspend fun login(
@Body request: LoginRequest
): BaseResponse<LoginResponse>

// 토큰 인증 관련 엔드포인트 호출 함수(API 함수) 추가
@GET("/api/auth/validate")
suspend fun validateToken(): BaseResponse<Boolean>
// 서버로 요청이 나가기 전에 실행되는 authInterceptor가 헤더에 토큰을 포함하기 때문에 토큰을 전달하는 바디 불필요
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.example.kuit6_android_api.data.api

import android.content.Context
import com.example.kuit6_android_api.data.repository.TokenRepository
import com.example.kuit6_android_api.data.repository.TokenRepositoryImpl
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class AuthInterceptor @Inject constructor(
private val tokenRepository: TokenRepository // Hilt가 TokenRepositoryImpl을 생성해서 주입
) : Interceptor {
//발생한 요청을 가로채 수정

override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking {
//블록이 완료될 때까지 intercept를 호출한 스레드의 실행 멈춤
tokenRepository.getToken()
}

val request = chain.request().newBuilder()
if(!token.isNullOrEmpty()){
request.addHeader("Authorization", "Bearer $token")
}
return chain.proceed(request.build())
}
Comment on lines +18 to +29
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: 요청 스레드에서 runBlocking 사용으로 인한 성능 문제

Interceptor의 intercept 메서드는 네트워크 요청 스레드에서 실행되며, 여기서 runBlocking을 사용하면 해당 스레드가 코루틴 완료까지 차단됩니다. 이는 다음과 같은 심각한 문제를 발생시킬 수 있습니다:

  1. 성능 저하: 네트워크 스레드 풀의 스레드가 블로킹되어 동시 요청 처리 능력 감소
  2. ANR 위험: 메인 스레드에서 호출 시 ANR 발생 가능
  3. 데드락 위험: DataStore I/O가 지연되면 요청이 장시간 대기

다음 중 하나의 해결 방법을 고려하세요:

해결 방법 1 (권장): 동기 토큰 캐시 사용

TokenRepository에 메모리 캐싱을 추가하여 동기적으로 토큰을 반환:

@Singleton
class AuthInterceptor @Inject constructor(
    private val tokenRepository: TokenRepository
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenRepository.getTokenSync() // 동기 메서드 추가
        
        val request = chain.request().newBuilder()
        if (!token.isNullOrEmpty()) {
            request.addHeader("Authorization", "Bearer $token")
        }
        return chain.proceed(request.build())
    }
}

해결 방법 2: Authenticator 사용

OkHttp의 Authenticator를 사용하여 401 응답 시에만 토큰 재인증:

class TokenAuthenticator @Inject constructor(
    private val tokenRepository: TokenRepository
) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        val token = runBlocking { tokenRepository.getToken() }
        return response.request.newBuilder()
            .header("Authorization", "Bearer $token")
            .build()
    }
}
🤖 Prompt for AI Agents
In app/src/main/java/com/example/kuit6_android_api/data/api/AuthInterceptor.kt
around lines 18-29, the interceptor currently uses runBlocking to fetch the
token which blocks the network thread; replace this with a non-blocking
synchronous token access by adding a synchronous cache accessor to
TokenRepository (e.g., getTokenSync() that reads an in-memory cached token
updated on login/refresh) and call that from intercept(), or alternatively
remove token fetch from intercept and implement an OkHttp Authenticator that
performs suspended token refresh logic (runBlocking only inside the
Authenticator if unavoidable) and attaches the Authorization header in intercept
using the synchronous cached token; ensure TokenRepository is updated to
maintain the in-memory cache and getTokenSync() is thread-safe.

}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.example.kuit6_android_api.data.di

import com.example.kuit6_android_api.BuildConfig
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

BODY 레벨 HttpLoggingInterceptor로 인한 토큰/민감 정보 노출 위험

현재 HttpLoggingInterceptor의 레벨을 항상 Level.BODY로 두고 있어, 요청/응답 바디와 헤더(Authorization 포함)가 모두 로그로 남습니다.
특히 로그인/토큰 API를 다루는 앱에서 릴리즈 빌드까지 BODY 로그가 켜져 있으면 보안·프라이버시 이슈가 될 수 있습니다.

빌드 타입에 따라 로그 레벨을 분기하는 것을 강하게 추천드립니다. 예:

-    fun provideLoginInterceptor(): HttpLoggingInterceptor =
-        HttpLoggingInterceptor().apply {
-            level = HttpLoggingInterceptor.Level.BODY
-        }
+    fun provideLoginInterceptor(): HttpLoggingInterceptor =
+        HttpLoggingInterceptor().apply {
+            level = if (BuildConfig.DEBUG) {
+                HttpLoggingInterceptor.Level.BODY
+            } else {
+                HttpLoggingInterceptor.Level.NONE
+            }
+        }

이렇게 하면 개발 환경에서는 상세 로그를 유지하면서, 운영 환경에서는 민감 정보가 로그에 남지 않도록 할 수 있습니다.

Also applies to: 22-27

🤖 Prompt for AI Agents
In app/src/main/java/com/example/kuit6_android_api/data/di/NetworkModule.kt
around lines 3 and 22-27, the HttpLoggingInterceptor is currently set to
Level.BODY which logs headers and bodies (including Authorization tokens) and
can leak sensitive data; change it to choose the logging level based on the
build type (e.g., if BuildConfig.DEBUG use Level.BODY or Level.HEADERS for
development, otherwise use Level.NONE or Level.BASIC for release), and ensure
headers containing sensitive info are not logged in release builds by
conditionally configuring the interceptor accordingly.

import com.example.kuit6_android_api.data.api.ApiService
import com.example.kuit6_android_api.data.api.AuthInterceptor
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Singleton


@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideLoginInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}

@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor, // Hilt가 인터셉터를 자동으로 주입하므로써 인터셉터를 매번 생성할 필요가 없게 됨
loggingInterceptor: HttpLoggingInterceptor
): okhttp3.OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.build()

@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}

@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient,
json: Json
): Retrofit =
Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()

@Provides
@Singleton
fun provideApiService(retrofit: Retrofit) : ApiService =
retrofit.create(ApiService::class.java)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.example.kuit6_android_api.data.di

import com.example.kuit6_android_api.data.repository.LoginRepository
import com.example.kuit6_android_api.data.repository.LoginRepositoryImpl
import com.example.kuit6_android_api.data.repository.PostRepository
import com.example.kuit6_android_api.data.repository.PostRepositoryImpl
import com.example.kuit6_android_api.data.repository.TokenApiRepository
import com.example.kuit6_android_api.data.repository.TokenApiRepositoryImpl
import com.example.kuit6_android_api.data.repository.TokenRepository
import com.example.kuit6_android_api.data.repository.TokenRepositoryImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

@Binds
@Singleton
abstract fun bindLoginRepository(impl: LoginRepositoryImpl) : LoginRepository

@Binds
@Singleton
abstract fun bindTokenRepository(impl: TokenRepositoryImpl) : TokenRepository

@Binds
@Singleton
abstract fun bindPostRepository(impl: PostRepositoryImpl) : PostRepository

@Binds
@Singleton
abstract fun bindTokenApiRepository(impl: TokenApiRepositoryImpl) : TokenApiRepository

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.kuit6_android_api.data.model.request

import kotlinx.serialization.Serializable

@Serializable
data class LoginRequest(
val username:String,
val password:String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.kuit6_android_api.data.model.response

import kotlinx.serialization.Serializable

@Serializable
data class LoginResponse(
val token:String,
val userId:Long,
val username:String
)
Loading