
안녕하세요 주코입니다.
저는 8월 초 "Pool" 이라는 사이드 프로젝트 팀의 Android 개발 파트로 합류하게 되었습니다.
오늘 포스팅에선 팀에 합류 하면서 멀티 모듈을 도입하게 된 계기와 약간의 팁(?) 그리고 저의 이야기들을 적어 보려 합니다.
우선 멀티 모듈을 도입하기전, 제가 이해하고 있는 멀티 모듈의 개념과 장단점을 말씀드리고자 합니다.
멀티모듈 (Multi-Module)
모듈(Module) 이란?
특정 기능을 수행하는 독립적인 단위로, 복잡한 시스템을 작은 부분으로 분할하여 관리하고 재사용하기 위한 개념이다.
멀티모듈(Multi-Module) 이란?
하나의 프로젝트를 여러 개의 독립적인 모듈로 나누어 관리하는 프로젝트 구조를 말한다.
멀티모듈의 장점
1. 협업 시 충돌 가능성이 낮다.
단일 모듈에서는 여러 개발자가 하나의 모듈 안에서 동시에 작업하기 때문에 충돌이 자주 발생할 수 있습니다.
하지만 멀티 모듈 구조에서는 각 모듈이 독립된 환경으로 분리되어 있기 때문에, 내가 개발하는 화면(feature)이나 계층(layer)에 다른 사람이 영향을 주기 어려워 충돌 가능성이 낮습니다.
2. 빌드 속도가 빠르다.
Android Studio에서 빌드는 항상 기다림의 미학.. 이지만
멀티모듈 구조에서는 Gradle은 변경된 모듈만 재빌드되기 때문에 체감 속도가 꽤 빨라집니다.
단일 모듈은 코드 한 줄만 수정해도 전체 프로젝트를 다시 빌드해야 하지만,
멀티모듈은 관련된 부분만 다시 빌드하기 때문에 개발 흐름이 덜 끊기게 됩니다.
3. 재사용성이 뛰어나다.
기능별로 잘 분리된 모듈이라면 다른 프로젝트에서도 바로 사용할 수 있습니다.
예를 들어 "designsystem` `core` 같은 공통 모듈은 그대로 프로젝트에 붙여 넣기만 하면 됩니다.
코드 복사 붙여넣기 없이 레고처럼 조립 가능한 구조를 만들 수 있어 장기적으로도 유지보수 효율이 높습니다.
4. 의존성 구조가 명확해진다.
단일 모듈은 모든 코드가 뒤섞일 수 있어, 어떤 코드가 어떤 계층에 의존하는지 파악하기 어렵습니다.
하지만 멀티 모듈은 `data`, `domain` `feature` 처럼 레이어 단위로 책임을 쉽게 분리하고 파악할 수 있어서
의존 관계가 명확하고 아키텍처 설계가 깔끔해 집니다.
그럼에도 단점
1. 초기 설정 비용이 크다.
단일 모듈은 `app` 모듈 하나로 시작해 개발을 시작할 수 있지만,
멀티 모듈은 시작부터 `:core`, `:data`, :feature:home` 등 이런식으로 구조를 잡고 Gradle 세팅을 맞추는데 시간이 꽤 걸립니다.
2. 러닝 커브가 높다.
멀티모듈 구조를 제대로 활용하려면 단순히 모듈만 나누는 게 아니라 `build-logic`, `version catalog`, `convention plugin` 등
Gradle 생태계 전반을 이해 해야합니다.
특히, 모듈 간 의존 관계를 적절히 관리 해야해서 초기 세팅과 구조 설계의 러닝 커브가 꽤 높습니다.
-> "모듈 분리"보다 "모듈 관리"가 더 어렵다는 걸 체감하게 된다.
멀티모듈 도입 계기
간단하게 멀티모듈의 개념과 제가 느끼고 경험한 것을 토대로 장점과 단점을 나누어 보았습니다.
결론 부터 말하면 단점보다 장점, 장점중에 특히 협업 시 충돌 가능성이 낮고, 의존성 구조가 명확해지기에 멀티모듈을 도입하게 되었습니다.
처음 팀에 합류 했을때 Android 파트는 아래 사진과 같이 단일 모듈(app)로 구성되어 있었습니다.
위에 설명한 장점들로 저는 멀티모듈 구조가 협업에도 안정성도 보다 낫고 협업의 효율성도 높아진다고 생각합니다.
하지만 처음 멀티모듈 구조에 대해 제안을 꺼냈을 때, 팀원분의 반응은 다소 미적지근했습니다.
팀원분은 멀티모듈을 직접 다뤄본 경험이 없었고, 다른 프로젝트에서 잠깐 본 적만 있었기 때문이었습니다.
여기서 문제는 결국 "러닝커브"에 대한 문제였습니다.
설득과정
저는 멀티구조를 도입하기위해 설득이 필요했습니다.
처음엔 이론적인 근거도 고민했습니다.
예를 들어 아래 영상
https://www.youtube.com/watch?v=uvG-amw2u2s 의 5분 50초쯤 에서 말하는
SRP(단일책임원칙) 즉, "변경의 이유가 하나여야한다."는 개념을 멀티모듈로 잘 실현할 수 있다는점을 어필하고 싶었습니다.
하지만 이런 내용만으로는 다소 딱딱하고, 실제 와닿기 어렵다고 느꼈습니다.
그래서 저는 팀의 현재 상황과 목표에 맞춰 이렇게 접근했습니다:
“저희 사이드 프로젝트를 단순히 완성하는 게 아니라, 새로운 걸 배우고 성장하기 위한 기회로 보면 좋을것 같습니다. 멀티모듈은 그런 성장에 꼭 필요한 구조입니다!”
실제로 이렇게 딱딱하게 말은 하지 않았지만, 사이드 프로젝트에 대한 의의와 저희 목표를 다시 잡기위해 말씀드리려 노력했습니다.
결국 팀원분들도 공감해주셨고, 학습을 위해서라도 도전해보자는 방향으로 합의해 멀티 모듈 구조를 도입하게 되었습니다.
그리고 이건 팁인데, 예전에 Naver Dan24 컴퍼런스에 참여 했을때 eastar Jeong 님께서 설득 하는법을 잠시 말씀 하신적이 있습니다.
내용은 "모두를 설득하기 위해서 회의나 공적인 자리가 아닌, 개개인에게 한명씩 설득 하는것이 더욱 설득력이 있다" 라고 말씀하신적이 있는데
협업자가 많다면 설득을 할때 이러한 방식도 괜찮아 보입니다. ㅋㅅㅋ
멀티 모듈 도입기
저는 단일 Acitivty와 단방향 데이터 흐름을 가지는 멀티모듈 구조를 설계하고자 합니다.
위 사진처럼 저희는 단일 모듈(app)로 되어 있었습니다.
멀티모듈로 도입 결정을 하면서 리팩터링은 제가 맡게 되었는데 다행히도 아직 개발 초기단계라 구조가 단순했었습니다.
크게 패키지가 이렇게 4가지로 분류 되어있었습니다.
(패키지 명칭) - (설명)
base - Hilt를 설정하기위해 Application()을 상속받은 MyApp이 있는 패키지
event - button 클릭 이벤트가 정의되어 있는 패키지
ui - 화면이 있는 Composable 함수가 모여있고 theme, navigation 설정등이 있는 패키지
utils - timber 및 designsystem의 일부가 있는 패키지
패키지 구조를 봤을때 아직 network 연동은 하지 않은것으로 보이고,
현재는 UI(feature)단위와 공통으로 사용되는 Common 그리고 Designsystem 정도만 구분해서 리팩터링하면 되는 단순한 구조입니다.
저는 MVVM + CleanArchitecture 조합의 구조를 선호합니다.
따라서, 아래와 같이 모듈을 구성하였습니다.
📦 app
┣ 📂 core
┃ ┣ 📂 common
┃ ┗ 📂 designsystem
┣ 📂 data
┣ 📂 domain
┗ 📂 feature
┣ 📂 add
┣ 📂 home
┣ 📂 auth
┣ 📂 main
┣ 📂 my
┣ 📂 pool
┗ 📂 shorts
data 모듈은 Network, localDB, DataSource, RepositoryImpl 등이 위치하게 됩니다.
domain 모듈은 Usecase 또는 Repository를 위치 시킬 예정입니다.
auth 모듈은 유저의 로그인 / 회원가입을 담당하는 모듈입니다.
로그인 / 회원가입에는 "로그인화면" "회원가입 아이디 중복확인 화면" "회원가입 비밀번호 등록화면" "비밀번호 찾기 화면" 등이 있을 수 있습니다.
저는 해당 화면들까지 보통 모듈을 나누지않고 패키지로 구분합니다.
main 모듈은 앱 전체의 화면 전환을 제어하는 Navigation과 단방향 흐름을 제어하는 모듈입니다.
각 feature 모듈이 독립적으로 화면을 구성하더라도, 사용자는 결국 단일 액티비티(MainActivity)에서 이 모든 화면을 이동하게 됩니다.
MainActivity는 main 모듈에 위치하게 됩니다.
이때 중요한건 MainNavigator와 MainNavHost 입니다.
아래와 같이 MainNavigator로 각 feature 단위 화면을 연결하고, MainNavHost에서 단방향 데이터 흐름을 잡아줍니다.
아래는 일부 코드입니다.
class MainNavigator(
val navController: NavHostController
) {
val startDestination = RouteModel.Login
val currentDestination: NavDestination?
@Composable get() = navController.currentBackStackEntryAsState().value?.destination
val currentMenu: MainMenu?
@Composable get() = MainMenu.find { menu ->
currentDestination?.hasRoute(menu::class) == true
}
private val singleTopOptions = navOptions {
launchSingleTop = true
restoreState = true
}
fun navigate(menu: MainMenu) {
val navOptions = navOptions {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = menu == MainMenu.Home
}
launchSingleTop = true
}
when(menu) {
MainMenu.Home -> navController.navigateHome(navOptions)
MainMenu.Shorts -> navController.navigateShorts(navOptions)
MainMenu.Demand -> navController.navigateDemand(navOptions)
MainMenu.Pool -> navController.navigatePool(navOptions)
MainMenu.My -> navController.navigateMy(navOptions)
}
}
fun navigateToLogin() = navController.navigateToLogin(navOptions = singleTopOptions)
// ...중략...
fun popBackStack() = navController.popBackStack()
fun popAllBackStack(destination: RouteModel) {
navController.popBackStack(route = destination, inclusive = false)
}
}
@Composable
fun rememberMainNavigator(
navController: NavHostController = rememberNavController(),
): MainNavigator = remember(navController) {
MainNavigator(navController)
}
@Composable
fun MainNavHost(
navigator: MainNavigator,
padding: PaddingValues,
onShowSnackBar: (String) -> Unit
) {
NavHost(
navController = navigator.navController,
startDestination = navigator.startDestination
) {
homeNavGraph(
padding = padding
)
// ...중략...
loginNavGraph(
padding = padding,
onNavigateToEmailPasswordInput = navigator::navigateToEmailPasswordInput,
onNavigateToFindPassword = navigator::navigateToFindPassword,
onNavigateToFindEmail = navigator::navigateToFindEmail,
)
}
}
core의 common 모듈은 공통으로 사용하는 기능 정의 및 Navigation 처리할 RouteModel 등을 정의합니다.
designsystem모듈은 theme, Color, Dimens, 각Component 들을 정의합니다.
특히 Tpyography와 Color는 변경을 하지 않거나 다크모드/라이트모드 일때만 변경이 일어나기에 @Stable 과 @Immutable 어노테이션을 적극 활용하고
theme값은 기존의 primary, onPrimary 대신 커스텀 하여 사용하였습니다.
아래는 일부 코드입니다.
Color.kt
// ...(중략)...
/** Colors */
val BrandLight = Color(0xFF41CEF5)
val BrandDark = Color(0xFF4CDCFF)
val RedLight = Color(0xFFFF3B30)
val RedDark = Color(0xFFFF453A)
val OrangeLight = Color(0xFFFF9500)
val OrangeDark = Color(0xFFFF9F0A)
val YellowLight = Color(0xFFFFCC00)
val YellowDark = Color(0xFFFFD60A)
val GreenLight = Color(0xFF34C759)
val GreenDark = Color(0xFF30D158)
val BlueLight = Color(0xFF007AFF)
val BlueDark = Color(0xFF0A84FF)
@Stable
class PoolColors(
// ...(중략)...
brand: Color,
red: Color,
orange: Color,
yellow: Color,
green: Color,
blue: Color,
isDark: Boolean
) {
// ...(중략)...
var brand by mutableStateOf(brand)
private set
var red by mutableStateOf(red)
private set
var orange by mutableStateOf(orange)
private set
var yellow by mutableStateOf(yellow)
private set
var green by mutableStateOf(green)
private set
var blue by mutableStateOf(blue)
private set
var isDark by mutableStateOf(isDark)
fun copy(): PoolColors = PoolColors(
// ...(중략)...
)
fun update(other: PoolColors) {
// ...(중략)...
}
}
fun PoolLightColors(
// ...(중략)...
) = PoolColors(
// ...(중략)...
isDark = false
)
fun PoolDarkColors(
// ...(중략)...
) = PoolColors(
// ...(중략)...
isDark = true
)
변경이 많이 없는 PoolColors Class 이기에 @Stable 처리하고 각 상태에만 변경을 시키기 위해서 색상마다 mutableStateOf 처리를 해줍니다.
Type.kt
// ...(중략)...
@Immutable
data class PoolTypography(
val headXXLBold: TextStyle,
val headXLBold: TextStyle,
val headLBold: TextStyle,
val headMBold: TextStyle,
val headSBold: TextStyle,
val headXSBold: TextStyle,
// ...(중략)...
)
private fun headBoldTextStyle(
// fontFamily: FontFamily,
fontSize: TextUnit,
fontFamily: FontFamily = PretendardBold,
lineHeight: TextUnit = 1.35.em,
letterSpacing: TextUnit = (-0.012).em,
): TextStyle = TextStyle(
fontSize = fontSize,
fontFamily = fontFamily,
lineHeight = lineHeight,
letterSpacing = letterSpacing,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.None
)
)
// ...(중략)...
Text의 Style은 변경이 될 가능성이 없기에 @Immutable 처리를 해줍니다.
이후에 Figma상 네이밍이 변경되었기에 headXXLBold 같은 명칭은 h1, b1 같은 네이밍으로 변경시킬 예정입니다.
Theme.kt
private val LocalPoolColors = staticCompositionLocalOf<PoolColors> {
error("No PoolColors provided")
}
private val LocalPoolTypography = staticCompositionLocalOf<PoolTypography> {
error("No PoolTypography provided")
}
object PoolAosTheme {
val colors: PoolColors
@Composable
@ReadOnlyComposable
get() = LocalPoolColors.current
val typography: PoolTypography
@Composable
@ReadOnlyComposable
get() = LocalPoolTypography.current
}
@Composable
fun ProvidePoolColorsAndTypography(
colors: PoolColors,
typography: PoolTypography,
content: @Composable () -> Unit
) {
val provideColors = remember { colors.copy() }.apply { update(colors) }
CompositionLocalProvider(
LocalPoolColors provides provideColors,
LocalPoolTypography provides typography,
content = content
)
}
@Composable
fun PoolAosTheme(
content: @Composable () -> Unit,
) {
val colors = PoolDarkColors()
val typography = typography()
ProvidePoolColorsAndTypography(
colors = colors,
typography = typography
) {
MaterialTheme(
content = content,
)
}
}
PoolAosTheme을 생성자로 만들거나 참조하기 위해 PooAosTheme을 같은 네이밍으로 함수와 object 설정을 동시에 해줍니다.
해당 코드로 저희 프로젝트에선 Dark, Light 모드에 따라 원하는 Color를 원하는 네이밍으로 사용할 수 있습니다.
다만, 저희 앱은 단일 색상으로 가기로 해서 PoolDarkColors()를 사용합니다.
이렇게 저는 아래와 같이 최종적으로 멀티 모듈 설정을 완료했습니다.
이후 data와 domain 역시 폴더 형태로두고 각 기능에 따라 모듈로 나눌 예정입니다.
포스팅 마무리
이번 포스팅은 단일모듈에서 멀티 모듈로 리팩터링을 진행하며, 제 게시글을 읽는 분들에게 약간의 도움을 드리고자 작성을 해보았는데요.
저희 프로젝트는 다행히 제가 합류한 시점에는 개발 진행이 많이 되어 있지 않아서 리팩터링에는 수월했습니다.
다만, 포스팅엔 기재하지않은 문제들이 약간 있었는데요.
예를들어 아직, 단일모듈 상태라고 했을때 demand와 home 화면의 패키지들에서 공통으로 사용하는 data class 같은 모델이 공통으로 사용하는 패키지등이 아닌, demand 에서 사용하는 문제점이 있었습니다.
멀티 모듈로 리팩터링하면서 demand와 home은 서로 의존 관계를 가지고 있지 않기 때문에 서로 공통된 모델을 따로 빼야하는 작업을 추가로 해야 했었는데요.
이런 문제가 발생했을때 많은 고민을 했습니다.
제가 이 팀에 합류하는 순간부터 안드로이드 개발은 팀으로 활동을 하기 때문에 다 같이 성장을 해야할 필요성이 있습니다.
물론 제가 아예 작업 하는것이 편하고 빠르겠지만, 간단하면서 새로 도입한 멀티모듈과 저의 단방향 데이터 흐름 방식을 이해 시키기위해선 약간의 Task도 주는것이 좋다고 생각했습니다.
따라서 화면간 Navigation 설정에 대한 것과 공통 으로 사용되는 로직 분리 정도를 한번 해보시라고 Task를 드렸었는데요.
실제로 한번 해보시고 PR에 대해 Review를 드리면서 지금은 완벽 적응을 한 상태 입니다.
이런식으로 프로젝트를 해 나가는것도 괜찮다고 생각합니다!
멀티모듈 + 단방향 데이터 흐름에 대한 저희 프로젝트 구조에 대해 궁금하시다면, 언제든지 댓글 달아주세요 :D
'Android Studio > - Project' 카테고리의 다른 글
Endless Scroll(무한 스크롤) 구현 중, query 호출 빈도 개선 일지 (0) | 2024.06.24 |
---|
주코딩의 개발 노트!
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!