클린아키텍쳐 기반으로 안드로이드 앱 프로젝트 시작하기

안드로이드 앱을 처음 개발하고, 시간이 흐르면 좀 더 잘 정리된 구조로 리펙토링을 하고 싶거나, 해야하는 상황이 오기 마련입니다. 어떤 이는 가독성과 유지보수성을 좋게 하기 위해서일 수도 있고, 어떤 이는 협업을 위해서일 수도 있습니다. 그리고 여러 잘 짜여진 구조들을 공부하면서 리펙토링을 마치고, 추후에 시작될 신규 개발 프로젝트는 잘짜여진 구조로 처음부터 시작을 하게 되고, 우리의 실력은 조금더 올라가게 됩니다.

앱의 구조를 체계적으로 변경하기 위해 클린아키텍쳐 기반의 구조설계 방법을 공유하고자 합니다. 클린아키텍쳐에 대한 설명은 링크에 잘 나와있습니다. 그리고 안드로이드 공식 문서에서 말하는 아키텍쳐는 클린아키텍쳐와는 조금 다릅니다. 역시 링크를 통해서 공부해보시고, 내 프로젝트에 맞는 구조가 무엇인지 판단하여 사용하면 될 것 같습니다.

프로젝트 생성

  1. Android Studio 에서 [File] -> [New Project] -> [Phone and Tablet] -> [Empty Activity] 선택
  2. [Name] 에 “AddressBook” 입력
  3. [Package name] 에 “com.sample.addressbook” 입력
  4. [Save location] 에 원하는 경로 입력
  5. [Minimum SDK] 는 기본값 (예 : API 28)
  6. [Build configuration language] 는 “Kotlin DSL” 선택
  7. [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 모듈 생성

  1. [File] -> [New] -> [New Module] 선택
  2. [Templates] -> [Android Library] 선택
  3. [Module name] 에 “data” 입력
  4. [Package name] 에 “com.sample.addressbook.data” 입력
  5. 나머지는 기본값으로 두고 [Finish] 선택

domain 모듈 생성

  1. [File] -> [New] -> [New Module] 선택
  2. [Templates] -> [Java or Kotlin Library] 선택
  3. [Module name] 에 “domain” 입력
  4. [Package name] 에 “com.sample.addressbook.domain” 입력
  5. 나머지는 기본값으로 두고 [Finish] 선택

presentation 모듈 생성

  1. [File] -> [New] -> [New Module] 선택
  2. [Templates] -> [Android Library] 선택
  3. [Module name] 에 “presentation” 입력
  4. [Package name] 에 “com.sample.addressbook.presentation” 입력
  5. 나머지는 기본값으로 두고 [Finish] 선택

여기까지 하셨으면 일단 [Build] 메뉴의 [Make Project]를 실행하여 빌드가 잘 되는지 확인을 합니다.

빌드가 잘 된다면 안드로이드 에뮬레이터로 실행을 해보고 실행이 잘 된다면 다음 단계로 넘어 갑니다.

UI 코드 presentation 모듈로 옮기기

  1. Android Studio 좌측의 Project Tool 창에서 상단 우측의 Options -> Appearance -> Compact Middle Packages 선택을 해제합니다. 그러면 패키지 폴더가 계층구조로 풀립니다.
  2. app 모듈의 테마파일이 있는 com.sample.addressbook의 ui 패키지 전체를 presentation 모듈의 “AdressBook/presentation/src/main/com.sample.addressbook.presentation/” 위치로 이동합니다.
  3. app 모듈의 MainActivity.kt 파일을 presentation 모듈로 이동합니다. 해당 경로는 “AdressBook/presentation/src/main/com.sample.addressbook.presentation/” 위치입니다.
  4. 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}")
    }
}
0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x