SOPT 30기 Android 파트 세미나 과제입니다.
github: https://github.com/KINGSAMJO/iOS_Seunghyeon
해당 주차 브랜치(ex. seminar/4)에서 각 세미나별 과제 코드를 확인할 수 있습니다.
github를 통해 코드를 보시는 것을 추천드립니다.
필수과제 1은 SharedPreferences를 활용해 기초적인 영속성 데이터를 사용해 자동 로그인을 구현하는 것입니다. 이 과제의 요구사항은 다음과 같습니다.
SharedPreferences는 SOPT Android 파트 7차 세미나에서 사용법을 다룹니다. 그래서 세미나에서 알려준 방법대로만 하면 Android 파트원 모두가 필수과제를 구현할 수 있습니다.
object SOPTSharedPreferences {
private const val STORAGE_KEY = "USER_AUTH"
private const val AUTO_LOGIN = "AUTO_LOGIN"
private lateinit var preferences: SharedPreferences
fun init(context: Context) {
preferences = context.getSharedPreferences(STORAGE_KEY, Context.MODE_PRIVATE)
}
fun getAutoLogin(): Boolean {
return preferences.getBoolean(AUTO_LOGIN, false)
}
fun setAutoLogin(value: Boolean) {
preferences.edit()
.putBoolean(AUTO_LOGIN, value)
.apply()
}
fun setLogout(context: Context) {
preferences = context.getSharedPreferences(STORAGE_KEY, Context.MODE_PRIVATE)
preferences.edit()
.remove(AUTO_LOGIN)
.clear()
.apply()
}
}
제가 가졌던 의문점은 2가지입니다.
제 의문점에 대해서 조금 더 자세하게 설명해보겠습니다.
SharedPreferences 인스턴스를 만들 이유가 있을까?
제가 이런 의문을 갖게 된 이유는 이렇습니다.
어차피 context.getSharedPreferences() 메서드를 통해 SharedPreferences를 가져온다면, context가 있다면 언제든 getSharedPreferences로 접근할 수 있는 것 아닐까?
이런 관점에서 보면 object로 만들어 앱 전역에서 사용할 이유는 없는 것 같습니다. 필요한 곳에서 context만 적절하게 가져다 getSharedPreferences() 메서드를 호출하면 되니까요. 실제로 그렇게 구현해도 됩니다. 하지만 저 역시도 SharedPreferences를 관리하는 별도의 클래스를 구현했습니다. 그 이유는 다음과 같습니다.
SharedPreferences를 관리하는 클래스가 별도로 존재하지 않고 각각의 코드에서 독립적으로 context.getSharedPreferences()를 활용해 코드를 작성할 경우 개발자의 실수에 의해 에러가 너무 많이 발생할 것 같다.
가령 자동 로그인만 해도 그렇습니다. AUTO_LOGIN
이라는 KEY를 사용해야 하는데 오타로 Auto_Login
이라고 작성할 경우, 개발자의 실수로 인해 개발자가 예측하지 못한 엉뚱한 결과를 초래할 수 있다는 이야기입니다. 저는 똑똑한 천재 개발자가 아니므로, 저 스스로 클래스
라는 하나의 규칙을 만들어 적용하면 제 실수로 인한 에러를 줄일 수 있을 것 같다고 생각했습니다.
매번 저렇게 긴 코드로 조작할 필요가 있을까?
이 의문은 이번 SOPT 30기에서 Fragment Transaction에 대해 공부하면서 생각할 수 있게 된 점입니다. Fragment KTX를 활용해 기존의 beginTransaction()으로 시작하는 복잡한 Fragment Transaction 코드를 commit { } 메서드로 대체할 수 있다는 것을 깨닫고 난 뒤, Android KTX를 활용해 코드를 더 효율적이고 깔끔하게 작성할 수 있음을 알게 되었습니다. 그래서 SharedPreferences에도 적용할 수 있는지 찾아볼 생각을 하게 되었습니다. 실제로 이번 과제에서는 Android KTX를 사용해 SharedPreferences를 더 간단하게 구현했습니다. 먼저 제가 짠 SharedPreferences를 관리하는 클래스인 SopthubDataStore
의 코드를 보겠습니다.
const val FILE_NAME = "SOPTHUB_DATA_STORE"
class SopthubDataStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private val dataStore =
if (BuildConfig.DEBUG) {
context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
} else {
EncryptedSharedPreferences.create(
FILE_NAME,
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
var userId: String
set(value) = dataStore.edit { putString("USER_ID", value) }
get() = dataStore.getString("USER_ID", "") ?: ""
var autoLogin: Boolean
set(value) = dataStore.edit { putBoolean("AUTO_LOGIN", value) }
get() = dataStore.getBoolean("AUTO_LOGIN", false)
var onBoardingEnabled: Boolean
set(value) = dataStore.edit { putBoolean("ON_BOARDING_ENABLED", value) }
get() = dataStore.getBoolean("ON_BOARDING_ENABLED", false)
}
기존 세미나의 SharedPreferences object와 달라진 것은 2가지입니다.
1번의 내용을 이해하기 위해서는 의존성 주입에 대한 개념을 알아야 합니다. 의존성 주입이란, 클래스끼리 갖는 의존 관계를 줄여나가기 위한 방법 중 하나이며 클래스 외부에서 클래스가 필요로 하는 의존성을 주입해주는 개념입니다. SharedPreferences에 접근하기 위해서는 context가 필요합니다. 이를 위해서 저는 Module을 이렇게 구현했습니다.
@Module
@InstallIn(SingletonComponent::class)
object SingletonModule {
@Singleton
@Provides
@ApplicationContext
fun provideApplication(application: Application) = application
}
2번의 내용은 Android보다는 조금 더 Kotlin 언어 자체에 대한 이야기로 들어갑니다. Kotlin 언어 자체를 논하다보면 절대 빠지지 않는 언어가 하나 있습니다. 바로 Java입니다. Java에서는 getter와 setter를 직접 구현해야 합니다. 가령, 아래와 같은 식으로 말입니다.
class User {
private final String name;
private String age;
public User(String name, String age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public String getAge() {
return age;
}
public void setAge(String age) {
this.age = age;
}
}
위의 코드에서 데이터가 저장되는 곳을 필드(field) 라고 부릅니다. name과 age가 이에 해당합니다. 그리고 각 데이터마다 적용되는 getter와 setter를 접근자 라고 부릅니다. 접근자를 통해 우리는 User 클래스 외부에서도 private한 필드에 접근할 수 있습니다. 하지만 이런 Java 코드의 단점은, 필드에 들어가는 데이터가 점점 많아지게 되면 그만큼의 더 많은 getter와 setter를 작성해야 하고, 이는 결국 코드의 가시성을 떨어뜨릴 수 있습니다. Kotlin에서는 위의 User 클래스를 이렇게 바꿀 수 있습니다.
class User {
val name: String,
var age: String
}
위의 긴 Java 코드와 아래 네 줄짜리 Kotlin 코드는 동일한 코드입니다. 불변 변수 name에 대해서는 val로 선언함으로써 get만 가능하고 set은 불가능하게, 가변 변수 age에 대해서는 var로 선언함으로써 get과 set 모두 가능하게 이루어져 있습니다. 이를 조금 더 펼쳐보면, 이런 코드가 됩니다.
class User {
val name: String
get() { return this.name }
var age: String
get() { return this.age }
set(age: String) { this.age = age }
}
즉, 이게 무슨 의미냐 하면 Kotlin에서는 Java의 필드와 달리, 변수를 프로퍼티(property) 로 취급합니다. 프로퍼티는 필드 + 접근자 입니다. 따라서 프로퍼티를 생성하면 getter와 setter가 자동으로 생성됩니다. 또한 필요 시 getter와 setter를 직접 정의할 수 있습니다.
이런 Kotlin의 특성을 활용해 SharedPreferences가 관리할 데이터에 대해 getter와 setter를 만들어 주었습니다. SharedPreferences 내의 데이터에 접근할 때는 Android KTX를 활용했는데, 이 부분은 공식문서에 잘 나와 있습니다.
https://developer.android.com/kotlin/ktx
Android KTX를 사용해 코드를 작성할 경우 edit 메서드의 중괄호 안에 우리가 수행하고 싶은 작업을 나열함으로써 기존의 여러 줄짜리 edit(), apply() 과정을 간소화할 수 있습니다. 이렇게 만든 SharedPreferences 클래스의 인스턴스를 활용해 아래와 같이 값을 참조할 수 있습니다.
class OnBoardingActivity : BaseActivity<ActivityOnBoardingBinding>() {
....
@Inject
lateinit var dataStore: SopthubDataStore
....
private fun checkAutoLoginEnabled() {
if (dataStore.autoLogin) {
// 자동 로그인이 설정되어 있을 때 수행할 로직
}
}
....
}
성장과제 1은 Navigation Component를 활용해 온보딩 화면을 만드는 것입니다. Navigation Component 또한 세미나에서 다뤘는데, 저는 이번에 살짝만 다르게 구성을 바꿔봤습니다.
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph_on_boarding"
app:startDestination="@id/fragment_on_boarding_first">
<fragment
android:id="@+id/fragment_on_boarding_first"
android:name="co.kr.sopt_seminar_30th.presentation.ui.onboarding.OnBoardingFirstFragment"
android:label="OnBoardingFirstFragment"
tools:layout="@layout/fragment_on_boarding_first">
<action
android:id="@+id/action_fragment_on_boarding_first_to_fragment_on_boarding_second"
app:destination="@id/fragment_on_boarding_second" />
</fragment>
<fragment
android:id="@+id/fragment_on_boarding_second"
android:name="co.kr.sopt_seminar_30th.presentation.ui.onboarding.OnBoardingSecondFragment"
android:label="OnBoardingFirstFragment"
tools:layout="@layout/fragment_on_boarding_second">
<action
android:id="@+id/action_fragment_on_boarding_second_to_fragment_on_boarding_third"
app:destination="@id/fragment_on_boarding_third" />
</fragment>
<fragment
android:id="@+id/fragment_on_boarding_third"
android:name="co.kr.sopt_seminar_30th.presentation.ui.onboarding.OnBoardingThirdFragment"
android:label="OnBoardingFirstFragment"
tools:layout="@layout/fragment_on_boarding_third">
<action
android:id="@+id/action_fragment_on_boarding_third_to_activity_sign_in"
app:destination="@id/activity_sign_in" />
</fragment>
<activity
android:id="@+id/activity_sign_in"
android:name="co.kr.sopt_seminar_30th.presentation.ui.auth.SignInActivity"
android:label="SignInActivity"
tools:layout="@layout/activity_sign_in" />
</navigation>
위 코드는 제 nav_graph_on_boarding.xml
의 코드입니다. 세미나에서 다룬 것과 같으나, 딱 한 가지 다른 점은 마지막 세 번째 프래그먼트의 action을 다음 프래그먼트가 아닌, 로그인 액티비티로 연결했다는 점입니다.
Navigation Component는 이런 일련의 프래그먼트 순서를 나열하는데 사용하는 것 외에도 다양한 활용이 가능합니다. 일례로 저의 경우에는 이전에 BottomNavigationView를 만들 때 세미나에서 알려준 ViewPager2 방식 대신에 Navigation Component를 활용해 구현한 적이 있습니다. 아래 코드는 BottomNavigationView와 연결되는 Navigation Component입니다.
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph_home"
app:startDestination="@id/fragment_profile">
<fragment
android:id="@+id/fragment_profile"
android:name="co.kr.sopt_seminar_30th.presentation.ui.home.profile.ProfileFragment"
android:label="profile_fragment"
tools:layout="@layout/fragment_profile" />
<fragment
android:id="@+id/fragment_home"
android:name="co.kr.sopt_seminar_30th.presentation.ui.home.home.HomeFragment"
android:label="home_fragment"
tools:layout="@layout/fragment_home" />
<fragment
android:id="@+id/fragment_more"
android:name="co.kr.sopt_seminar_30th.presentation.ui.home.more.MoreFragment"
android:label="more_fragment"
tools:layout="@layout/fragment_more" />
</navigation>
이 Navigation이 적용될 HomeActivity의 FragmentContainerView 또한 마찬가지로 이렇게 구현합니다.
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fcv_home"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/bnv_home"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph_home" />
그리고 HomeActivity에서는 BottomNavigationView에 대해 이렇게 설정해줍니다.
private fun initBottomNavigationView() {
val navHostFragment = supportFragmentManager.findFragmentById(R.id.fcv_home) as NavHostFragment
val navController = navHostFragment.findNavController()
binding.bnvHome.setupWithNavController(navController)
}
Navigation Component의 활용법은 무궁무진합니다. 하지만 이를 하나하나 다 포스트로 작성하기엔 무리가 있어 혹여나 이 글을 보며 공부할 SOPT 파트원 분들을 위해 이런 활용도 있다는 것을 알려드리고자 추가했습니다.
도전과제 1은 필수과제에서 SharedPreferences를 활용해 구현한 자동 로그인 로직을 Room으로 구현해보는 과제입니다. Room에 대해 기술하면 한없이 길어지기 때문에, 간단하게 제 구현 방식을 설명해보는 식으로 서술해보겠습니다.
먼저 데이터베이스에 해당하는 RoomDatabase.kt
입니다.
package co.kr.sopt_seminar_30th.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import co.kr.sopt_seminar_30th.data.datasource.local.AuthorizationDao
import co.kr.sopt_seminar_30th.data.model.dto.AuthorizationDto
@Database(entities = [UserDto::class, FollowerDto::class, RepositoryDto::class, AuthorizationDto::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
....
abstract fun authorizationDao(): AuthorizationDao
companion object {
fun getInstance(context: Context): AppDatabase = Room
.databaseBuilder(context, AppDatabase::class.java, "seminar.db")
.build()
}
다음은 DAO인 AuthorizationDao.kt
입니다.
package co.kr.sopt_seminar_30th.data.datasource.local
import androidx.room.*
import co.kr.sopt_seminar_30th.data.model.dto.AuthorizationDto
@Dao
interface AuthorizationDao {
@Insert
suspend fun insertAuthorization(authorizationDto: AuthorizationDto)
@Delete
suspend fun deleteAuthorization(authorizationDto: AuthorizationDto)
@Query("SELECT * FROM Authorization WHERE userId = :id")
suspend fun getAuthorization(id: String): AuthorizationDto
}
다음은 DTO인 AuthorizationDto.kt
입니다.
package co.kr.sopt_seminar_30th.data.model.dto
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "Authorization")
data class AuthorizationDto(
@PrimaryKey val userId: String,
@ColumnInfo(name = "autoLogin") val autoLogin: Boolean
)
사실 여기까지만 있어도 과제에 대한 구현은 가능하지만, 저는 AuthorizationRepository
, AuthorizationRepositoryImpl
도 추가로 구현했습니다.
다음은 AuthorizationRepository
입니다.
package co.kr.sopt_seminar_30th.domain.repository.local
interface AuthorizationRepository {
suspend fun insertAuthorization(userId: String, autoLogin: Boolean)
suspend fun deleteAuthorization(userId: String, autoLogin: Boolean)
suspend fun getAuthorization(userId: String): Boolean
}
다음은 AuthorizationRepositoryImpl
입니다.
package co.kr.sopt_seminar_30th.data.repositoryimpl.local
import co.kr.sopt_seminar_30th.data.datasource.local.AuthorizationDao
import co.kr.sopt_seminar_30th.data.model.dto.AuthorizationDto
import co.kr.sopt_seminar_30th.domain.repository.local.AuthorizationRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
class AuthorizationRepositoryImpl @Inject constructor(
private val dao: AuthorizationDao,
private val coroutineDispatcher: CoroutineDispatcher
) : AuthorizationRepository {
override suspend fun insertAuthorization(userId: String, autoLogin: Boolean) =
withContext(coroutineDispatcher) {
dao.insertAuthorization(AuthorizationDto(userId, autoLogin))
}
override suspend fun deleteAuthorization(userId: String, autoLogin: Boolean) =
withContext(coroutineDispatcher) {
dao.deleteAuthorization(AuthorizationDto(userId, autoLogin))
}
override suspend fun getAuthorization(userId: String): Boolean =
withContext(coroutineDispatcher) {
dao.getAuthorization(userId).autoLogin
}
}
저는 크게 3가지의 메서드를 구현했습니다. 첫째로는 자동 로그인 여부 Boolean값을 삽입하는 insert 메서드, 둘째로는 자동 로그인 여부가 포함된 하나의 tuple을 삭제하는 delete 메서드, 마지막 하나는 자동 로그인 여부 Boolean 값을 가져오는 query 메서드입니다. 이 3가지를 아래와 같이 사용했습니다.