안드로이드 앱을 처음 개발하고, 시간이 흐르면 좀 더 잘 정리된 구조로 리펙토링을 하고 싶거나, 해야하는 상황이 오기 마련입니다. 어떤 이는 가독성과 유지보수성을 좋게 하기 위해서일 수도 있고, 어떤 이는 협업을 위해서일 수도 있습니다. 그리고 여러 잘 짜여진 구조들을 공부하면서 리펙토링을 마치고, 추후에 시작될 신규 개발 프로젝트는 잘짜여진 구조로 처음부터 시작을 하게 되고, 우리의 실력은 조금더 올라가게 됩니다.
앱의 구조를 체계적으로 변경하기 위해 클린아키텍쳐 기반의 구조설계 방법을 공유하고자 합니다. 클린아키텍쳐에 대한 설명은 링크에 잘 나와있습니다. 그리고 안드로이드 공식 문서에서 말하는 아키텍쳐는 클린아키텍쳐와는 조금 다릅니다. 역시 링크를 통해서 공부해보시고, 내 프로젝트에 맞는 구조가 무엇인지 판단하여 사용하면 될 것 같습니다.
프로젝트 생성
- Android Studio 에서 [File] -> [New Project] -> [Phone and Tablet] -> [Empty Activity] 선택
- [Name] 에 “AddressBook” 입력
- [Package name] 에 “com.sample.addressbook” 입력
- [Save location] 에 원하는 경로 입력
- [Minimum SDK] 는 기본값 (예 : API 28)
- [Build configuration language] 는 “Kotlin DSL” 선택
- [Finish] 클릭
Hilt, KSP 라이브러리 추가 및 설정
libs.versions.toml에 라이브러리를 추가하기 위해 아래 코드를 작성합니다.
[versions] hilt = "2.51.1" devtools = "2.0.0-1.0.21" [libraries] hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt"} hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt"} [plugins] google-devtools = {id = "com.google.devtools.ksp", version.ref = "devtools"} hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt"}
Project 단위 build.gradle.kts를 아래와 같이 수정합니다.
plugins { alias(libs.plugins.google.devtools) apply false alias(libs.plugins.hilt) apply false }
App 단위 build.gradle.kts를 아래와 같이 수정합니다.
plugins { alias(libs.plugins.google.devtools) alias(libs.plugins.hilt) } dependencies { implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) }
[Sync now] 선택하여 빌드가 잘 되는지 확인합니다.
data 모듈 생성
- [File] -> [New] -> [New Module] 선택
- [Templates] -> [Android Library] 선택
- [Module name] 에 “data” 입력
- [Package name] 에 “com.sample.addressbook.data” 입력
- 나머지는 기본값으로 두고 [Finish] 선택
domain 모듈 생성
- [File] -> [New] -> [New Module] 선택
- [Templates] -> [Java or Kotlin Library] 선택
- [Module name] 에 “domain” 입력
- [Package name] 에 “com.sample.addressbook.domain” 입력
- 나머지는 기본값으로 두고 [Finish] 선택
presentation 모듈 생성
- [File] -> [New] -> [New Module] 선택
- [Templates] -> [Android Library] 선택
- [Module name] 에 “presentation” 입력
- [Package name] 에 “com.sample.addressbook.presentation” 입력
- 나머지는 기본값으로 두고 [Finish] 선택
여기까지 하셨으면 일단 [Build] 메뉴의 [Make Project]를 실행하여 빌드가 잘 되는지 확인을 합니다.
빌드가 잘 된다면 안드로이드 에뮬레이터로 실행을 해보고 실행이 잘 된다면 다음 단계로 넘어 갑니다.
UI 코드 presentation 모듈로 옮기기
- Android Studio 좌측의 Project Tool 창에서 상단 우측의 Options -> Appearance -> Compact Middle Packages 선택을 해제합니다. 그러면 패키지 폴더가 계층구조로 풀립니다.
- app 모듈의 테마파일이 있는 com.sample.addressbook의 ui 패키지 전체를 presentation 모듈의 “AdressBook/presentation/src/main/com.sample.addressbook.presentation/” 위치로 이동합니다.
- app 모듈의 MainActivity.kt 파일을 presentation 모듈로 이동합니다. 해당 경로는 “AdressBook/presentation/src/main/com.sample.addressbook.presentation/” 위치입니다.
- presentation 모듈의 build.gradle.kts 파일에 아래 코드를 추가합니다.
plugins { alias(libs.plugins.google.devtools) alias(libs.plugins.hilt) } dependencies { implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) }
MainActivity.kt 수정
MainActivity.kt 파일로 가서 import가 변경되어 빨갛게 표시된 부분들을 수정해줍니다. 보통은 import 부분을 날리고 Alt+Enter로 다시 지정해주면 됩니다. 안드로이드 스튜디오의 변화 속도가 워낙 빨라 이 부분은 하나하나 문제를 해결해가면서 고쳐야 합니다.
테스트 빌드
이제 빌드를 하시고 다시 에뮬레이터로 실행을 해보시면 Hello Android! 표기가 제대로 뜨는 것을 확인할 수 있습니다.
코드 구현
클린아키텍쳐 구조를 잡는 작업이 끝났으니 실제로 샘플코드를 구현해 보겠습니다.
Domain Layer
먼저 Domain Layer를 구현해 줍니다. domain 모듈에 다음과 같은 클래스를 생성하고 구현합니다.
Person Entity (Domain Model)
package com.sample.addressbook.domain.model data class Person ( val id: Int = 0, val name: String, val phoneNumber: String, val address: String )
PersonRepository (Interface)
package com.sample.addressbook.domain.repository import com.sample.addressbook.domain.model.Person interface PersonRepository { suspend fun addPerson(person: Person) suspend fun getPerson(id: Int): Person? suspend fun updatePerson(person: Person) suspend fun deletePerson(id: Int) suspend fun getAllPersonList(): List<Person> }
Data Layer
Data 레이어에서는 RoomDB를 통해서 로컬데이터베이스로 데이터를 관리하는 코드를 구현합니다.
PersonEntity (DataBaseModel)
package com.sample.addressbook.data.entity import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "person") data class PersonEntity( @PrimaryKey(autoGenerate = true) val id: Int = 0, val name: String, val phoneNumber: String, val address: String )
DAO (Data Access Object)
package com.sample.addressbook.data.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import com.sample.addressbook.data.entity.PersonEntity @Dao interface PersonDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun addPerson(person: PersonEntity) @Query("SELECT * FROM person WHERE id = :id") suspend fun getPerson(id: Int): PersonEntity? @Update suspend fun updatePerson(person: PersonEntity) @Delete suspend fun deletePerson(person: PersonEntity) @Query("SELECT * FROM person") suspend fun getAllPersonList(): List<PersonEntity> }
RoomDatabase
package com.sample.addressbook.data.database import androidx.room.Database import androidx.room.RoomDatabase import com.sample.addressbook.data.dao.PersonDao import com.sample.addressbook.data.entity.PersonEntity @Database(entities = [PersonEntity::class], version = 1) abstract class PersonDatabase :RoomDatabase() { abstract fun personDao(): PersonDao }
Person Repository Implementation
package com.sample.addressbook.data.repository import com.sample.addressbook.data.dao.PersonDao import com.sample.addressbook.data.entity.PersonEntity import com.sample.addressbook.domain.model.Person import com.sample.addressbook.domain.repository.PersonRepository class PersonRepositoryImpl(private val personDao: PersonDao): PersonRepository { override suspend fun addPerson(person: Person) { personDao.addPerson(person.toEntity()) } override suspend fun getPerson(id: Int): Person? { return personDao.getPerson(id)?.toDomain() } override suspend fun getAllPersonList(): List<Person> { return personDao.getAllPersonList().map { it.toDomain() } } override suspend fun updatePerson(person: Person) { personDao.updatePerson(person.toEntity()) } override suspend fun deletePerson(id: Int) { val personEntity = personDao.getPerson(id) if(personEntity != null){ personDao.deletePerson(personEntity) } } private fun Person.toEntity(): PersonEntity { return PersonEntity(id, name, phoneNumber, address) } private fun PersonEntity.toDomain(): Person { return Person(id, name, phoneNumber, address) } }
AppModule
package com.sample.addressbook.data.di import android.content.Context import androidx.room.Room import com.sample.addressbook.data.dao.PersonDao import com.sample.addressbook.data.database.PersonDatabase import com.sample.addressbook.data.repository.PersonRepositoryImpl import com.sample.addressbook.domain.repository.PersonRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object AppMoule { @Provides @Singleton fun providePersonDatabase(@ApplicationContext context: Context): PersonDatabase { return Room.databaseBuilder( context, PersonDatabase::class.java, "person_database" ).build() } @Provides @Singleton fun providePersonDao(db: PersonDatabase) = db.personDao() @Provides @Singleton fun providePersonRepository(personDao: PersonDao): PersonRepository { return PersonRepositoryImpl(personDao) } }
App Layer
Application 클래스 생성
package com.sample.addressbook import android.app.Application import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class AddressBookApplication: Application() { }
Manifest 수정
Application 클래스를 생성하고, 매니페스트에 연결해줍니다.
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <application android:name=".AddressBookApplication" android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.AddressBook" tools:targetApi="31"> <activity android:name=".presentation.MainActivity" android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.AddressBook"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
UI 작업
PersonViewModel 구현
package com.sample.addressbook.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sample.addressbook.domain.model.Person import com.sample.addressbook.domain.repository.PersonRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class PersonViewModel @Inject constructor( private val repository: PersonRepository ): ViewModel() { private val _personListStateFlow = MutableStateFlow<List<Person>>(emptyList()) val personList = _personListStateFlow.asStateFlow() fun addPerson(person: Person){ viewModelScope.launch(Dispatchers.IO) { repository.addPerson(person) loadAllPersonList() } } fun getPerson(id: Int, onResult: (Person?) -> Unit){ viewModelScope.launch(Dispatchers.IO) { val person = repository.getPerson(id) onResult(person) } } fun updatePerson(person: Person){ viewModelScope.launch(Dispatchers.IO) { repository.updatePerson(person) } } fun deletePerson(id: Int){ viewModelScope.launch(Dispatchers.IO) { repository.deletePerson(id) loadAllPersonList() } } fun loadAllPersonList() { viewModelScope.launch(Dispatchers.IO) { _personListStateFlow.value = repository.getAllPersonList() } } }
MainActivity.kt 수정
package com.sample.addressbook.presentation import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface 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.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.sample.addressbook.domain.model.Person import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { private val personViewModel: PersonViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { MaterialTheme { Surface { AddPersonScreen(viewModel = personViewModel) } } } } } @Composable fun AddPersonScreen(viewModel: PersonViewModel) { // State variables for input fields var name by remember { mutableStateOf(TextFieldValue("")) } var phoneNumber by remember { mutableStateOf(TextFieldValue("")) } var address by remember { mutableStateOf(TextFieldValue("")) } val personList by viewModel.personList.collectAsState() Column( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { // Name input field OutlinedTextField( value = name, onValueChange = { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) // Phone number input field OutlinedTextField( value = phoneNumber, onValueChange = { phoneNumber = it }, label = { Text("Phone Number") }, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) // Address input field OutlinedTextField( value = address, onValueChange = { address = it }, label = { Text("Address") }, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(16.dp)) // Save button Button( onClick = { // Create a Person object and add it using the ViewModel val person = Person( name = name.text, phoneNumber = phoneNumber.text, address = address.text ) viewModel.addPerson(person) // Clear the input fields after adding name = TextFieldValue("") phoneNumber = TextFieldValue("") address = TextFieldValue("") }, modifier = Modifier.fillMaxWidth() ) { Text(text = "Save") } Spacer(modifier = Modifier.height(32.dp)) // Section for displaying the list of persons Text(text = "Person List", modifier = Modifier.padding(8.dp)) LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(vertical = 8.dp) ) { items(personList) { person -> PersonItem(person = person) } } } } @Composable fun PersonItem(person: Person) { Column( modifier = Modifier .fillMaxWidth() .padding(8.dp) .then(Modifier.padding(8.dp)) ) { Text(text = "Name: ${person.name}") Text(text = "Phone: ${person.phoneNumber}") Text(text = "Address: ${person.address}") } }