android multi-module, 처음이라면 circular dependency 를 조심하세요!

Evergreen
Duckie Tech
Published in
10 min readJun 18, 2023

--

안녕하세요. duckie 에서 안드로이드 개발을 담당하고 있는 Evergreen 입니다.

Duckie 안드로이드 제품은 확장성, 가독성을 고려한 multi-module project 입니다. 처음 설계를 시작할 때부터 모듈화를 적용하였고, 여러 시행 착오를 겪으며 지속적으로 개선해 나가고 있습니다.

결론을 먼저 말씀드리면, 현재에는 아래와 같은 구조를 가지게 되었습니다.

Project Dependencies Graph

간략하게 어떤 모듈들이 어떤 역할을 하는지 설명하자면, 아래와 같습니다.

  • app : Application에 대해 정의하는 클래스 입니다. Hilt를 사용하여 의존성을 제공 해야하는 모듈이 존재한다면, app layer가 의존성을 갖게 됩니다.
  • feature : presentation layer에서 정의할 수 있는 View, View Data, Logic 등을 기능에 따라 나눈 모듈입니다. feature-home, feature-setting 등을 예로 들 수 있습니다.
  • build-logic, buildSrc : 빌드 로직 및 라이브러리 의존성을 정의한 모듈입니다. build-logic 모듈은 공통적으로 선언하는 plugin, dependency 등을 미리 정의하여 새로운 모듈을 생성했을 때 발생하는 보일러 플레이트 코드를 줄이는 역할을 합니다.
  • data : domain layer에서 정의한 인터페이스를 기반으로 local 및 network api 을 통해 데이터를 가져오는 모듈입니다.
  • di : repository, datasource에 대한 구현체를 제공해주는 Hilt Module을 선언하는 모듈입니다. 의존성 주입을 따로 관리하기 위한 목적으로 만들게 되었습니다.
  • domain : 비즈니스 로직 및 모델에 대해 정의하고 있는 모듈입니다. Repository, Usecase 등을 가지며, data layer가 구현체를 제공하고, presentation layer에서 usecase를 통해 결과를 받아옵니다.
  • navigator : feature 모듈 간 navigate 하는 interface를 정의하는 모듈입니다.
  • common : android, kotlin, compose 각각에서 공통으로 사용하는 로직을 정의한 모듈입니다. compose 모듈은 ui, util로 나뉘어져 공통으로 사용하는 컴포넌트와, 로직을 관리하고 있습니다.

Duckie 안드로이드 팀은 모듈화의 이점을 최대한 살려서 모듈 간의 필요한 의존성만을 남기고 제거하였고, 다음 장점을 얻을 수 있었습니다.

관심사의 분리

변경이 일어날 때 필요한 작업을 최소화하고, 그 변경이 다른 곳에 영향을 미치지 않도록 해야 한다.

관심사의 분리를 만족할 수 있게 되었습니다.
안을 들여다 보면 Clean Architecture 를 기반으로
Presentation —> domain< — Data 의 개념이 모듈로 분리되어 있습니다.
단일 app 모듈에서 package 를 나누게 되면, 각 Layer 간 알지 않아도 될 부분까지 알게 되어 휴먼 에러를 발생시킬 수 있고, 이는 곧 Presentation Layer 의 변경이 Data Layer 에 크게 작용하게 되는 문제가 발생하기도 합니다.
모듈화 기반의 Clean Architecture 적용으로 Presentation Layer와 Data Layer의 의존 관계를 끊어낼 수 있게 되었습니다.

The Clean Architecture

Feature Module 간 분리
Presentation Layer 내에 있는 각 feature들을 모듈로 분리함으로써, 알 필요가 없는 feature들 간의 의존성을 느슨하게 할 수 있었습니다.

  • feature:onboard
  • feature:search

이 두 개의 feature는 서로 다른 성격의 도메인을 포함하고 있고, 각각의 다른 Activity에서 View와 Logic 을 구현하기 때문에 분리가 필요했습니다.
두 모듈, 혹은 다른 feature들 간의 공통적으로 사용해야 하는 로직에 대해서는 common 이라는 모듈을 두어 재 사용이 가능하도록 설계하였습니다.

모듈화, 처음이라면 이것만은 알아두세요!

처음 duckie 의 프로젝트 구조를 설계할 때 모듈화에 대한 지식이 부족한 채로 진행하다 보니, 여러가지 시행착오를 겪어야만 했습니다. 이 중 손꼽아 보았을 때 가장 크게 발생했던 문제는 다음과 같았습니다.

  • Circular Dependency(순환 참조)문제

조금만 더 알아보고 설계했다면 피해갈 수 있던 문제였기에, 모듈화에 익숙치 않은 분께 도움이 되었으면 합니다.

우리는 몰랐다. “Circular Dependency”를 직접 만나기 전까지는..

Android 팀은 duckie MVP 의 주요 feature 인 덕퀴즈를 만들고 있었습니다.

문제 상세 정보 → 필적 확인 문구 입력 → 퀴즈 풀기 → 결과 확인 까지, 팀원들은 각각의 feature 모듈을 생성하고, UI 및 로직을 작성하였습니다. 이후 각각의 feature들을 연결하는 작업을 진행할 때, 문제를 맞닥뜨리게 되었습니다.

덕퀴즈 flow

Activity 간 intent 를 위해, 한 Activity 가 다른 Activity 를 알아야 했고, 저희는 추후 어떤 일이 일어날지 감히 상상도 하지 못한 채, feature 모듈 간 의존성을 가지는 방향으로 진행하였습니다.

문제는 결과 확인 → 문제 상세 정보에서 발생하였습니다. 시험 끝내기를 누른 후, 다시 상세 화면으로 돌아오기 위해서는 ResultActivity — DetailActivity 간 참조가 필요했고, 현재 구조에서는 각 feature 모듈에서 Activity를 가지기에 구조 변경이 불가피한 상황이었습니다.

feature 모듈 간 의존성을 없애보자

결국 기존의 구조는 다음 두 가지 큰 문제가 있었습니다.

  • 알지 않아도 될 모듈에 대해 의존성을 가진다.
  • 순환 참조가 발생한다.

duckie 안드로이드 팀원들은 이를 해결하기 위해 머리를 맞대었고, 카카오 페이 앱 리빌딩 스토리 에서 관련 내용 참고하여 새로운 구조를 계획했습니다.

  1. Navigator의 정의
activity.startActivity<DetailActivity>

각 feature 모듈에서 유일하게 참조하는 부분은, 액티비티 간 이동하는 위 코드에서 유일했기에, 아래와 같은 형식으로 추상화 할 수 있었습니다.

interface Navigator {
fun navigateFrom(
activity: Activity,
intentBuilder: Intent.() -> Intent = { this },
withFinish: Boolean = false,
)
}
interface ResultNavigator : Navigator
interface DetailNavigator : Navigator
...

해당 interface를 navigator 라는 모듈을 하나 두어, 모든 feature에서 접근할 수 있도록 정의해 줍니다.

2. 의존성 끊어내기 및 구현체 제공

각 feature 들이 Navigator 인터페이스를 바라보도록 하고, 각 feature들 간의 의존성을 끊어냅니다.

이제 각 feature 들은 Navigator 인터페이스에 대한 구현체를 정의해야 합니다. Hilt의 도움을 받아 Application Container가 Navigator의 구현체를 관리하고, 주입할 수 있습니다.

internal class ExamResultNavigatorImpl @Inject constructor() : ExamResultNavigator {
override fun navigateFrom(
activity: Activity,
intentBuilder: Intent.() -> Intent,
withFinish: Boolean,
) {
activity.startActivityWithAnimation<ExamResultActivity>(
intentBuilder = intentBuilder,
withFinish = withFinish,
)
}
}

@Module
@InstallIn(SingletonComponent::class)
internal abstract class ExamResultNavigatorModule {
@Singleton
@Binds
abstract fun bindExamResultNavigator(navigator: ExamResultNavigatorImpl): ExamResultNavigator
}

주의 : Application이 존재하는 모듈에서, 각 feature에 대한 의존성을 가지고 있어야 합니다.

3. 주입하기

@AndroidEntryPoint
class ExamResultActivity : BaseActivity() {
@Inject
lateinit var detailExamNavigator: DetailNavigator
...
private fun handleSideEffect(sideEffect: ExamResultSideEffect) {
when (sideEffect) {
...
is ExamResultSideEffect.NavigateToStartExam -> {
detailExamNavigator.navigateFrom(
activity = this,
intentBuilder = {
...
},
withFinish = true,
)
}
}
}
}

위와 같이 시험 결과 → 시험 상세 정보로 이동해야 한다면, 목적지에 대한 Navigator를 주입하여 사용할 수 있습니다. 이로써 각 feature들 간의 의존성을 제거하고, 순환 참조 문제를 해결 할 수 있게 되었습니다.

장점

  • feature 모듈 간 의존성 분리- 이는 곧 빌드 시간의 축소로 이어짐
  • 순환 참조 문제를 해결할 수 있음

단점

  • 각 feature 모듈마다 bind 모듈, 구현체를 생성 해줘야 함

마치며

이외에도 모듈화로 인해 신경써야 할 부분들이 몇몇 있었습니다.
모듈이 많아지면서 패키지로 모듈을 묶기도 했고, strings.xml의 중복 key 문제가 발생하여 모듈의 이름을 딴 prefix 를 strings의 key에 붙이기도 하였습니다.

앞으로도 duckie android 팀이 개선해가야 할 문제들이 많습니다. 함께 문제들을 해결해 나갈 분을 모집하고 있으니 많은 관심 부탁드립니다!

긴 글 읽어주셔서 감사합니다 개발자 evergreen 였습니다.

--

--