안드로이드

[안드로이드] 멀티모듈, 컴포즈UI, Hilt, Navigation 사용해서 간단한 게시판 프로젝트 만들기

이손안나 2025. 6. 12. 11:37

멀티 모듈을 사용하는 이유는 다음과 같다.

 

1. 빌드 속도 개선

- Gradle은 변경된 모듈만 다시 빌드

- 앱이 커질수록 전체를 빌드하는데 시간이 오래걸리기 때문에 기능 단위로 모듈을 나누면 변경된 부분만 빠르게 빌드 

 

2. 관심사 분리

- 각 모듈은 기능 또는 역할 단위로 나뉘며 독립적으로 개발되고 테스트 됨

- 유지보수가 쉬워지고 코드의 가독성도 향상

 

3. 재사용성 증가 

- 공통 로직을 별도 모듈로 만들어 쉽게 재사용 

 

 

이러한 이유로 안드로이드 멀티모듈 작업을 하기로 마음먹었다!

 

1. 모듈 생성

 

file -> new -> new module 선택

 

 

android library 선택 후 모듈 이름 작성 

나는 간단한 게시판 앱이라서 core, feature 모듈을 만들어 주었다.

 

 

또한, 안에는 각 기능별로 모듈을 생성해 주었다. (같은 방식으로 생성!)

 

게시판은 게시글 리스트 페이지, 게시글 상세 페이지, 게시글 작성페이지가 있다.

 

2. Post 구현

package com.example.model

data class Post(
    val id:Int,
    val title:String,
    val content:String,
    val author: String,
    val createdAt:String
)

 

나는 :core:model 모듈에 Post 클래스를 작성했다.

그리고 바로 api 작성하기

 

3. API 클래스 작성

package com.example.network

import com.example.model.Post
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path

interface PostApiService {

    @GET("/posts")
    suspend fun getPosts():List<Post>

    @GET("/posts/{id}")
    suspend fun getPostDetail(@Path("id") id:Int):Post

    @POST("/posts")
    suspend fun createPost(@Body post:Post):Response<Unit>

    @PUT("/posts/{id}")
    suspend fun updatePost(
        @Path("id") id:Int,
        @Body post: Post
    ):Response<Unit>
}

 

전체 리스트를 가져오기, 게시글 상세보기, 게시글 작성하기, 게시글 수정하기가 있다. 

 

이 코드는 :core:network 에 작성했다.

 

4. Repository

interface PostRepository {

    val posts: StateFlow<List<Post>>

    suspend fun getPosts(): List<Post>
    suspend fun getPostDetail(id:Int) :Post
    suspend fun createPost(post:Post)
    suspend fun updatePost(post: Post)
}

repository 는 :core:domain 에 작성

 

5. Repository  구현체 

@Singleton
class PostRepositoryImpl @Inject constructor(private val api:PostApiService):PostRepository {

    private val _posts = MutableStateFlow<List<Post>>(
        mutableListOf( Post(
            id = 1,
            title = "Compose로 만드는 투두 앱",
            content = "Jetpack Compose와 MVVM 아키텍처를 활용한 투두 앱 예제입니다.",
            author = "안드로이드 개발자",
            createdAt = "2024-06-01T09:00:00"
        ),
        Post(
            id = 2,
            title = "DI와 Hilt 완전정복",
            content = "Hilt를 이용해 의존성 주입을 간편하게 관리하는 방법을 소개합니다.",
            author = "DI 마스터",
            createdAt = "2024-06-03T14:30:00"
        ),
        Post(
            id = 3,
            title = "Compose Navigation 기초",
            content = "컴포즈에서 화면 전환을 처리하는 Navigation 기본기를 배웁니다.",
            author = "Jetpack 고수",
            createdAt = "2024-06-05T18:45:00"
        )
        )
    )

    override val posts: StateFlow<List<Post>> = _posts

    override suspend fun getPosts(): List<Post> {
        return posts.value
    }

    override suspend fun getPostDetail(id: Int): Post {
        return  posts.value.find { it.id == id }!!
    }

    override suspend fun createPost(post: Post) {
        val newPost = post.copy(
            id = (posts.value.maxOfOrNull { it.id } ?: 0) + 1,
            createdAt = "20250610"
        )
        _posts.value = posts.value + newPost
    }

    override suspend fun updatePost(post: Post) {
        _posts.value = posts.value.map {
            if (it.id == post.id) post else it
        }
    }
}

 

서버가 없기 때문에 임시로 게시글 리스트를 생성해서 작업했다. 왜냐하면 멀티모듈+ 클린아키텍처 공부를 위한거니까~

 

stateFlow를 사용해서 viewModel에서 상태를 계속 관찰하고 UI에 반응형으로 전달했다.

 

6. ViewModel

package com.example.postlist

import androidx.lifecycle.ViewModel
import com.example.domain.PostRepository
import com.example.model.Post
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject

@HiltViewModel
class PostListViewModel @Inject constructor(
    private val repository:PostRepository
) :ViewModel(){

    val posts: StateFlow<List<Post>> = repository.posts

}

 

viewModel은 간단하게 작성. :feature:postlist 안에다가 만들었다.

 

7. UI

컴포즈를 사용해 본 적이 없어서 이번에 사용해 보기로 했다.

package com.example.ui

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Divider
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.postlist.PostListViewModel

@Composable
fun PostListScreen (
    viewModel: PostListViewModel,
    onPostClick:(Int)->Unit,
    onWriteClick:()->Unit,
    onEditClick:(Int)->Unit
){

    val posts by viewModel.posts.collectAsState()

    Scaffold(
        floatingActionButton = {
            FloatingActionButton(
                onClick = onWriteClick
            ) {
                Icon(Icons.Default.Add, contentDescription = "글 작성")
            }
        }
    ) { padding ->
        LazyColumn(contentPadding = padding) {
            items(posts) { post ->
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp),
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    Column(
                        modifier = Modifier
                            .weight(1f)
                            .clickable { onPostClick(post.id) }
                    ) {
                        Text(text = post.title, style = MaterialTheme.typography.titleMedium)
                        Text(text = "by ${post.author}", style = MaterialTheme.typography.bodySmall)
                    }

                    IconButton(onClick = { onEditClick(post.id) }) {
                        Icon(Icons.Default.Edit, contentDescription = "수정")
                    }
                }

                Divider()
            }
        }
    }


}

 

게다가 xml로 개발 했을 때 navigation을 사용했었는데 컴포즈에서는 어떻게 구성하는지 궁금해서 그것도 사용해 보기로...ㅎㅎ

 

8. Navigation

Jetpack Navigation-Compose  라이브러리 의존성을  build.gradle에 추가. 

(저는 :core:navigation 모듈을 만들었습니다. 이 모듈 안의 build.gradle에 추가)

 

dependencies {
    implementation "androidx.navigation:navigation-compose:2.7.7" // 또는 최신 버전
}

 

먼저 각 화면 단위로 sealed class를 정의해 준다.

 

package com.example.navigation

sealed class Screen(val route: String) {

    object PostList: Screen("post_list")
    object PostDetail: Screen("post_detail/{postId}"){
        fun routeWithArgs(postId:Int) = "post_detail/$postId"
    }
    object PostEdit :Screen("post_edit?postId={postId}") {
        fun routeWithArgs(postId:Int?):String {
            return if (postId != null) "post_edit?postId=$postId" else "post_edit"
        }
    }

}

 

그다음 각 화면으로 이동하는 동작들을 구현해 준다.

package com.example.navigation

import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.example.ui.PostDetailScreen
import com.example.ui.PostEditScreen
import com.example.ui.PostListScreen

@Composable
fun  AppNavHost(navController: NavHostController = rememberNavController()) {

    NavHost(navController, startDestination = Screen.PostList.route) {
        composable(Screen.PostList.route) {
            PostListScreen(
                onPostClick = { postId -> navController.navigate(Screen.PostDetail.routeWithArgs(postId)) },
                onWriteClick = { navController.navigate(Screen.PostEdit.route) },
                onEditClick = {postId -> navController.navigate("post_edit?postId=$postId")},
                viewModel = hiltViewModel()
            )
        }

        composable(Screen.PostDetail.route , arguments = listOf(
                navArgument("postId") { type = NavType.IntType }
                )
        ) { backStackEntry ->
            PostDetailScreen()
        }

        composable(Screen.PostEdit.route,
            listOf(navArgument("postId") {
                type = NavType.IntType
                defaultValue= -1
            })
        ) { backStackEntry ->

            val postIdArg = backStackEntry.arguments?.getInt("postId")?: -1
            val postId = if (postIdArg == -1) null else postIdArg

            PostEditScreen(
                postId = postId,
                onPostSaved = {navController.popBackStack()}
            )
        }
    }
}

 

여기서 봐야 할 부분은 PostDetail로 이동하는 코드다. 

arguments = listOf(navArgument("postId") { type = NavType.IntType })

 

이렇게 코드를 작성하면 @HiltViewModel 에 주입되는 SavedStateHandle은 Navigation Compse에서 넘긴 navArgument를 자동으로 받아온다

 

- PostDetailViewModel 작성

package com.example.postdetail

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.domain.PostRepository
import com.example.model.Post
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class PostDetailViewModel @Inject constructor(
    private val repository: PostRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _post = MutableStateFlow<Post?>(null)
    val post: StateFlow<Post?> = _post

    init {
        val postId = savedStateHandle.get<Int>("postId") ?: -1
        viewModelScope.launch {
            _post.value = repository.getPostDetail(postId)
        }
    }
}

 

- PostDetailScreen

 

package com.example.ui

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.postdetail.PostDetailViewModel

@Composable
fun PostDetailScreen(
    viewModel: PostDetailViewModel = hiltViewModel()
) {
    val uiState by viewModel.post.collectAsState()

    if (uiState != null) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(text = uiState!!.title, style = MaterialTheme.typography.headlineMedium)
            Text(text = "by ${uiState!!.author}", style = MaterialTheme.typography.bodySmall)
            Text(text = "작성일: ${uiState!!.createdAt}", style = MaterialTheme.typography.bodySmall)
        }
    } else {
        CircularProgressIndicator()
    }
}

 

이렇게 상세 페이지 까지 했고 그 다음 등록/수정 페이지 ( 같은 페이지를 재사용 한다.)

 

 

- PostEditViewModel

 

@HiltViewModel
class PostEditViewModel @Inject constructor(
    private val postRepository: PostRepository
) :ViewModel(){

    private val _title = MutableStateFlow("")
    val title :StateFlow<String> = _title

    private val _content = MutableStateFlow("")
    val content : StateFlow<String> = _content

    private val _isSaving = MutableStateFlow(false)
    val isSaving:StateFlow<Boolean> = _isSaving

    fun onTitleChanged(newTitle:String) {
        _title.value = newTitle
    }

    fun onContentChanged(newContent:String) {
        _content.value = newContent
    }

    fun loadPost(postId:Int) {
        viewModelScope.launch {
            try {
                val post = postRepository.getPostDetail(postId)
                _title.value = post.title
                _content.value = post.content
            } catch (e:Exception) {
                Log.e("TAG", "loadPost: ${e.message}" )
            }
        }

    }

    fun savePost(postId:Int?,onSaved: ()->Unit) {

        viewModelScope.launch {
            _isSaving.value = true

            val post = Post(
                id = postId ?: 0,
                title = _title.value,
                content = _content.value,
                author = "ㅇㄴㅇ",
                createdAt = "20250611"
            )

            if (postId == null) {
                postRepository.createPost(post)
            } else {
                postRepository.updatePost(post)
            }

            _isSaving.value = false
            onSaved()
        }
    }
}

 

- PostEditScreen

package com.example.ui

import androidx.compose.foundation.layout.Column
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.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel


@Composable
fun PostEditScreen(
    postId: Int? = null,
    viewModel: com.example.postedit.PostEditViewModel = hiltViewModel(),
    onPostSaved: ()->Unit
) {

    val title by viewModel.title.collectAsState()
    val content by viewModel.content.collectAsState()
    val isSaving by viewModel.isSaving.collectAsState()

    LaunchedEffect(postId) {
        postId?.let {
            viewModel.loadPost(it)
        }
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {

        Text(text = if (postId == null) "새 글 작성" else "글 수정", style = MaterialTheme.typography.titleMedium)

        Spacer(modifier = Modifier.height(16.dp))

        OutlinedTextField(
            value = title,
            onValueChange ={viewModel.onTitleChanged(it)},
            label = { Text("제목") },
            modifier = Modifier.fillMaxWidth()
        )


        Spacer(modifier = Modifier.height(24.dp))

        OutlinedTextField(
            value = content,
            onValueChange = {viewModel.onContentChanged(it)}
                ,
            label = { Text("내용") },
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp)
                ,
            maxLines = Int.MAX_VALUE
        )

        Button(
            onClick = {
                viewModel.savePost(postId,onPostSaved)
            },
            enabled = !isSaving,
            modifier = Modifier.align(Alignment.End)
        ) {
            Text(text = if (postId == null) "저장하기" else "수정 완료")
        }

    }

}

 

screen 클래스들은 전부 :core:ui 모듈에 작성했다.

 

 

이렇게만 해줘도 일단은 간단한 게시글 앱이 완성이 된다.

코드를 보면 di hilt도 사용을 했는데 처음에는 어디에다가 패키지를 만들어서 사용해야하는지 감이 안 왔다. 

그리고 각 모듈마다 중복되는 dependencies가 많아서 관리하는게 힘들었는데 찾아보니까

의존성을 공통으로 관리해주는 build-logic 구조가 있다고 해서 공부중이다.

 

아직 컴포즈 ui 가 와닿지가 않아서 더 꾸준히 연습하고 새로운 최신 신기술을 더 많이 익혀야겠다.