[Android] 실용적인 멀티모듈 아키텍처 설계 실전기 (Domain 레이어와 build-logic)

2025. 8. 10. 01:13·Android

안녕하세요! AI가 영어 일기를 첨삭해주는 앱, 'Hilingual' 프로젝트를 진행하고 있는 안드로이드 개발자 한민재입니다.

"AI가 영어 일기를 첨삭해주는 앱"이라는 아이디어로 시작된 Hilingual 프로젝트는 초기 단계부터 명확한 목표를 가지고 있었어요. 바로 지속 가능한 확장성 확보와 팀 단위 개발 생산성 극대화인데요. 저희는 팀 리더의 명확한 비전과 릴리즈에 대한 강한 의지를 바탕으로, 단순한 사이드 프로젝트를 넘어 '실무에 준하는 프로덕트', '수익을 창출해도 부끄럽지 않을 프로덕트'를 만들자는 높은 목표를 세웠어요. 향후 기능 확장의 가능성이 무궁무진하다고 판단했기에, 프로젝트 규모가 커짐에 따라 발생할 수 있는 코드의 강한 결합, 긴 빌드 시간, 명확한 의존성 분리, 그리고 개발자 간의 코드 충돌 문제를 선제적으로 방지하기 위해, 저희는 많은 논의를 거쳐 멀티모듈 아키텍처를 선택했어요.

 

직접 구현한 프로젝트 깃허브를 첨부해요. 참고가 되었으면 합니다 :)

https://github.com/Hi-lingual/Hilingual-Android

 

GitHub - Hi-lingual/Hilingual-Android: 하이링구얼 안드로이드 레포지토리입니다.

하이링구얼 안드로이드 레포지토리입니다. Contribute to Hi-lingual/Hilingual-Android development by creating an account on GitHub.

github.com

 

고민의 흔적들..

클린 아키텍처, 맹목적으로 따라야 할까?

프로젝트 아키텍처를 구상할 때, 많은 개발자들이 '클린 아키텍처(Clean Architecture)'를 일종의 정답처럼 여기는 경향이 있어요. 계층을 엄격하게 분리하고 의존성 규칙을 통해 유연하고 테스트하기 쉬운 구조를 만드는 클린 아키텍처의 이상은 매우 매력적임에 동의해요.

하지만 저희는 한 걸음 물러서서 근본적인 질문을 던졌어요. "클린 아키텍처를 교과서처럼 무조건 따라야만 하는가?" 그리고 더 나아가, "과연 나는 테스트를 잘 활용하고, 클린 아키텍처를 통해 유지보수성이 향상된다는 이점을 실제로 체감한 적이 있는가?" 라는 현실적인 고민을 시작했습니다. 개인적으로 저는 보일러플레이트 코드를 선호하지 않고, 직접 그 효과나 이점을 느껴보지 못한 기술을 맹목적으로 도입하는 것을 지양해요.

이러한 고민의 과정에서 저희는 Google의 권장 아키텍처(UI - Domain(optional) - Data)가 클린 아키텍처의 철학을 안드로이드 환경에 맞게 잘 녹여낸 훌륭한 가이드라인이라는 점에는 동의했어요. 하지만 동시에, 모든 프로젝트에 이 구조가 최선은 아닐 수 있다는 어찌보면 당연한 결론에 다다랐어요. 특히 "현재 우리 프로젝트의 복잡도에 Domain 레이어가 정말로 필요한가?" 이 질문이 저희 아키텍처 설계의 출발점이었어요.

Domain 레이어의 비용과 현실적인 의문

Domain 레이어는 UseCase를 통해 애플리케이션의 핵심 비즈니스 로직을 캡슐화하고, Entity를 통해 데이터 소스에 종속되지 않는 순수한 데이터 모델을 정의해요. 이는 분명 이론적으로 강력한 장점이지만, 저희는 다음과 같은 현실적인 비용과 의문을 제기했어요.

  • 보일러플레이트 코드 증가: 간단한 CRUD 기능 하나를 추가하더라도 UseCase, Repository 인터페이스, Entity 및 DTO와 Entity 간의 매퍼(Mapper) 등 수많은 파일을 생성하고 관리해야 해요.
  • 개발 속도 저하: 초기 단계의 프로젝트나 비즈니스 로직이 복잡하지 않은 기능의 경우, Domain 레이어를 거치는 과정이 오히려 개발의 흐름을 끊고 불필요한 복잡성을 더할 수 있다고 판단했어요.
  • 클라이언트의 역할 변화: 현대적인 개발 패러다임은 Server-Driven 방식이 주를 이뤄요. 대부분의 클라이언트 앱은 서버가 제공하는 데이터를 화면에 잘 보여주는 '읽기 중심(Read-Heavy)'의 역할을 수행해요. 클라이언트 내에서 도메인 객체 간의 복잡한 상호작용이 일어나는 경우는 드물어요. 예를 들어, '계좌 잔고를 기반으로 이자를 지급한다'와 같은 핵심 비즈니스 로직(도메인 규약)은 클라이언트에서 트리거하거나 보장할 수 없어요.
  • Domain의 순수성, 지킬 수 있는가?: 현실적인 안드로이드 개발 환경에서 Domain 레이어의 순수성을 지키는 것은 어려워요. 단순히 날짜 포맷팅을 위해 java.util에 의존하거나, '결제'라는 핵심 도메인 기능을 구현하기 위해 외부 결제 SDK에 의존하게 되는 순간, Domain의 순수성은 깨지기 시작해요.

위와 같은 이유들로 인해, 저희는 Domain 레이어가 가져다주는 이점보다 관리 비용이 더 크다고 결론을 내렸어요.

과감한 Domain 레이어 생략

저희는 실용주의적 관점에서 접근하기로 결정했어요. 지금 당장 얻는 이점보다 유지보수 비용이 더 크다고 판단하여, Domain 레이어를 의도적으로 생략하기로 했어요.

  • Data Layer → Presentation Layer로 데이터 흐름을 단순화하여 코드 추적을 용이하게 하고, 신규 팀원의 온보딩 비용을 줄일 수 있다고 봤어요.
  • 데이터 가공과 관련된 간단한 로직은 Data 레이어의 Repository 구현체에서, UI 상태 계산과 관련된 로직은 Presentation 레이어의 ViewModel에서 처리하기로 결정했어요. 이는 각 레이어의 책임과 어느 정도 부합하며, 과도한 추상화 없이도 충분히 역할을 분리할 수 있다고 판단했어요.

이 결정은 "좋은 아키텍처는 규칙을 맹목적으로 따르는 것이 아니라, 주어진 상황 속에서 최적의 트레이드오프를 찾는 과정"이라는 저희 팀의 철학을 반영한 결과였어요.

물론 추후에 앱의 확장이 마무리가 되어가면서 중복되는 로직이 많아질 경우 Domain이 선택적이라는 구글 권장 앱 아키텍쳐에 맞게 Usecase만 추출해서 사용할 수 있어요 :)

의존성 규칙 = 안으로, 한 방향으로

레이어 분리의 핵심은 엄격한 의존성 규칙에 있어요. 저희는 의존성이 항상 바깥쪽 레이어에서 안쪽 레이어로, 즉 app → presentation → data → core 방향으로만 흐르도록 설계했어요.

app (Application Module)
 ↑
presentation (Feature Modules)
 ↑
data (Data Source Modules)
 ↑
core (Core Utility Modules)
  • presentation 모듈은 data와 core 모듈에 의존할 수 있지만, 그 역은 성립하지 않아요.
  • data 모듈은 오직 core 모듈에만 의존해요.
  • core 모듈은 어떤 상위 모듈에도 의존하지 않는, 가장 독립적인 최하위 레이어에요.

이러한 단방향 의존성(Unidirectional Dependency) 규칙은 순환 참조를 원천적으로 방지하고, 특정 기능의 수정이 다른 기능에 미치는 영향을 최소화하여 코드베이스를 안정적으로 유지할수 있게 해요.

어떤 기준으로 모듈을 나눌 것인가?

모듈화의 방향을 정한 뒤, '어떻게 나눌 것인가'라는 구체적인 문제에 봉착했어요. 크게 레이어 기반과 기능 기반, 두 가지 방식을 두고 고민했어요.

  • 레이어 기반 모듈화 (presentation, data, core)
    • 장점: 구조가 단순하고 이해하기 쉽다.
    • 단점: 프로젝트가 커질수록 각 레이어 모듈이 비대해져 또 다른 모놀리식(Monolithic)이 될 수 있다. 특정 기능을 수정하려면 여러 모듈을 넘나들어야 해서 응집도가 떨어진다.
  • 기능 기반 모듈화 (feature-login, feature-diary)
    • 장점: 기능별로 코드가 캡슐화되어 응집도가 높고, 다른 기능에 미치는 영향이 적다. 기능 단위의 빌드가 가능해 개발 속도가 향상된다.
    • 단점: 공통 로직이나 데이터 처리에 대한 중복 코드가 발생할 수 있고, 모듈 간 의존성 관리가 복잡해질 수 있다.

하이브리드 방식의 도입

두 방식 모두 장단점이 명확했기에, 어느 하나를 선택하기보다는 두 방식의 장점을 결합한 하이브리드(Hybrid) 방식을 채택하기로 했어요.

core 모듈 그룹 (레이어 기반) - 네트워크, 디자인 시스템, 공통 유틸리티 등 앱 전반에서 사용되는 기반 기술은 특정 기능에 종속되지 않아요. 따라서 이들은 레이어 기반으로 분리하여 어떤 모듈에서든 재사용할 수 있도록 설계했어요.

data, presentation 모듈 그룹 (기능 기반) - UI와 데이터 처리는 특정 기능과 강하게 결합되어 있어요. 따라서 auth, diary, user 등 기능 단위로 모듈을 분리하여 기능의 독립성과 응집도를 높였어요.

가장 큰 고민의 흔적..

Domain 레이어가 없는 상황에서 Data 모듈을 기능 단위로 나누기로 결정했지만 스스로 고민하면서 많은 질문을 던졌어요.

혼자 고뇌한 흔적들..

 

Q. data 모듈을 화면 단위(data:home, data:mypage)로 나누면 안 될까?

  • home 화면과 mypage 화면 모두 '사용자 정보'를 필요로 할 수 있어요. 만약 화면 단위로 나눈다면, data:home과 data:mypage 양쪽에 사용자 정보를 가져오는 코드가 중복될 수 있고, 데이터의 소유권과 책임 소재가 불분명해져요.따라서 data:user라는 모듈을 만들고, 사용자 정보가 필요한 모든 presentation 모듈이 data:user를 의존하는 것이 훨씬 합리적이라고 생각했어요.

Q. Repository는 무엇을 반환해야 하는가? DTO?

  • DTO(Data Transfer Object)는 서버 API의 응답 구조와 1:1로 매핑되는 클래스에요. 만약 Repository가 DTO를 그대로 ViewModel에 반환한다면, ViewModel은 서버의 세부적인 필드명이나 구조에 직접적으로 의존하게 돼요. 이는 API 명세가 변경될 때마다 ViewModel과 UI 코드까지 연쇄적으로 수정해야 하기에 결합도가 강해져요.

그래서 Model 클래스를 도입했어요. Data 모듈 내부에 UI 레이어에서 필요한 데이터만 담은 순수한 코틀린 데이터 클래스, 즉 Model을 정의해요. 그리고 Repository 내부에서 DTO를 Model로 변환(Mapper)하여 반환하는 책임을 지게 했어요. 이렇게 함으로써 Presentation 레이어는 DTO의 존재를 전혀 알 필요가 없게 되고, Data 와의 강한 결합도를 낮췄어요.

이러한 고민 끝에, 각 data 모듈이 자신의 datasource, repository, dto, service, 그리고 model까지 모두 책임지는 현재의 구조가 완성되었어요 👏👏

왜 build-logic과 Convention Plugin인가?

무한 Gradle 열차

모듈이 수십 개로 늘어나자, 새로운 문제가 우리를 기다리고 있었어요. 바로 무한 Gradle 설정이에요. 모든 build.gradle.kts 파일에 minSdk, compileSdk, 공통 플러그인, 기본 의존성 등을 반복적으로 설정해줘야 했어요. 의존성 버전 하나를 올리려면 수십 개의 파일을 수정해야 하는, 개발자가 정말 싫어하는 반복작업이에요.

그럼 어떻게 해결할까?

이 문제를 해결하기 위해 저는 빌드 로직을 중앙에서 관리할 방법을 찾아 나섰고, buildSrc와 build-logic이라는 두 가지 선택지를 두고 비교 분석했어요

  • buildSrc 방식
    • 가장 널리 알려져 있고 구현이 비교적 간단했어요. 하지만 buildSrc는 그 자체로 하나의 모듈처럼 동작하지만, 내부 코드가 아주 조금만 변경되어도 전체 프로젝트의 빌드 캐시를 무효화시켜요. 주석 한 줄을 추가해도 전체 캐시가 날아가고, 이는 빌드 시간의 급격한 증가로 이어져 개발 생산성을 심각하게 저하시켜요. 잦은 빌드 시간 저하로 인한 팀의 생산성 저하를 감수할 수 없었기에, buildSrc는 최종적으로 반려했어요.
  • build-logic과 Convention Plugin 방식
    • build-logic은 포함 빌드방식으로 동작해요. 이는 build-logic 모듈이 메인 프로젝트와는 독립적인 자체 빌드 생명주기와 캐시를 가진다는 의미에요. 때문에 build-logic 내부의 코드를 수정해도, 해당 로직을 사용하는 모듈에만 영향을 미칠 뿐 전체 프로젝트의 캐시를 무효화하지 않아요. 이 독립적인 캐시라는 특성이 빌드 성능에 결정적인 이점을 제공한다고 판단하여 최종적으로 채택했어요.

build-logic 상세 구현 과정

build-logic과 Convention Plugin을 도입하는 과정은 다음과 같았어요.

  1. 모듈 생성 및 설정
    • java-or-kotlin-library 타입으로 build-logic 모듈을 생성
    • 루트 settings.gradle.kts에 includeBuild("build-logic")을 추가하여 빌드에 포함
    • build-logic의 build.gradle.kts에 kotlin-dsl 플러그인을 적용하여 코틀린으로 커스텀 플러그인을 작성할 준비
  2. Convention Plugin 작성
    build-logic/src/main/java/ 경로에 .gradle.kts 형태의 Precompiled Script Plugin들을 작성했어요. 예를 들어, presentation 레이어의 기능 모듈들이 공통으로 사용하는 설정을 hilingual.android.feature.gradle.kts라는 플러그인으로 만들었어요.
// build-logic/src/main/kotlin/hilingual.android.feature.gradle.kts
plugins {
    id("hilingual.android.library") // 모든 안드로이드 라이브러리 모듈의 기본 설정을 담은 플러그인
    id("hilingual.android.compose") // Compose 관련 설정을 모아둔 플러그인
    id("com.google.dagger.hilt.android")
}

dependencies {
    // 기능 모듈에 공통적으로 필요한 core 모듈 의존성을 자동으로 추가
    implementation(project(":core:designsystem"))
    implementation(project(":core:common"))
    implementation(project(":core:navigation"))

    // Hilt 의존성 추가
    implementation(libs.findLibrary("hilt.android").get())
    kapt(libs.findLibrary("hilt.compiler").get())
}

 

버전 카탈로그(libs.versions.toml)와 타입세이프 적용
작성한 Convention Plugin들을 gradle/libs.versions.toml에 별칭(alias)으로 등록해요.

// presentation:home/build.gradle.kts (수정 전)
plugins {
    id("com.android.library")
    id("org.jetbrains.kotlin.android")
    id("com.google.dagger.hilt.android")
    // ...
}
// ... 수많은 중복 설정과 의존성 ...

이제 각 모듈의 build.gradel.kts 에서는 이 별칭을 사용하여 타입세이프하게 플러그인을 적용할 수 있게 되었어요.

 

Before build.gradle.kts

// presentation:home/build.gradle.kts (수정 전)
plugins {
    id("com.android.library")
    id("org.jetbrains.kotlin.android")
    id("com.google.dagger.hilt.android")
    // ...
}
// ... 수많은 중복 설정과 의존성 ...

 

After build.gradle.kts

// presentation:home/build.gradle.kts (수정 후)
plugins {
    alias(libs.plugins.hilingual.feature) // 단 한 줄로 모든 공통 설정 적용!
}

android {
     setNamespace("presentation.home")
}

dependencies {
		// 이 모듈에 필요한 의존성만 추가
    implementation(libs.compose.calendar)
    implementation(projects.data.user)
    implementation(projects.data.calendar)
}

 

 

우리가 얻은 것

이러한 아키텍처 설계와 빌드 시스템 구축을 통해 저희는 다음과 같은 실질적인 효과를 얻을 수 있었어요.

  • 각 모듈의 역할과 책임이 명확해져 코드를 이해하고 수정하기 쉬워졌어요. 특히 팀원들이 복잡한 Gradle 설정에 대한 부담을 덜고 각자의 기능 개발에 집중할 수 있는 환경이 만들어졌어요
  • internal 키워드를 적극적으로 사용하여 모듈 외부로 노출할 필요가 없는 구현 세부사항을 숨겼어요. 모듈 간의 결합도를 낮추고, 의도하지 않은 의존성을 사전에 방지하여 아키텍처의 흐름을 강력하게 제어할 수 있어요.
  • 신규 모듈 추가 시 alias 한 줄만 추가하면 모든 기본 설정이 완료되므로, 개발자는 비즈니스 로직 구현이라는 본질에만 집중할 수 있게 되었어요.
  • 공통 라이브러리 버전을 올리거나 컴파일 옵션을 변경할 때, build-logic의 파일 하나만 수정하면 모든 모듈에 일괄적으로 안전하게 적용할 수 있게 되었어요.

살아있는 아키텍처를 향하여

Hilingual 프로젝트의 멀티모듈 아키텍처 설계 과정은 '정답'을 찾는 과정이 아니라, 주어진 상황 속에서 최적의 트레이드오프를 찾아 나가는 과정이었어요. 이 과정을 통해 저희는 아키텍처는 그 자체가 목표가 아니라, 좋은 프로덕트를 만들기 위한 수단이라는 점을 다시 한번 상기할 수 있었어요.

기술적인 선택의 순간마다 '왜 이 기술을 선택해야 하는가?'에 대한 명확한 근거를 세우려 노력했고, 그 고민의 과정이 현재의 아키텍처를 만들었다고 생각해요.

물론 지금의 구조가 최종 버전은 아니에요. 프로젝트가 성장하고 새로운 요구사항이 생기면서 현재의 구조는 언제든 변경될 수 있어요. 중요한 것은 변화를 두려워하지 않고, 더 나은 구조를 위해 끊임없이 고민하고 개선해나가는 자세라고 생각해요.

긴 글 읽어주셔서 감사합니다. 이 글이 멀티모듈 아키텍처를 고민하는 다른 개발자분들께 조금이나마 도움이 되었으면 좋겠습니다. 또한 구글 권장 앱 아키텍쳐의 좋은 예시가 되길 바랍니다!

'Android' 카테고리의 다른 글

[Android] 앱을 배포해 봅시다.  (0) 2025.10.02
[Android] 컨벤션 플러그인 뜯어고치기  (0) 2025.09.25
[Android] Baseline Profile 적용기 feat. Fake 주입 실패  (0) 2025.09.23
'Android' 카테고리의 다른 글
  • [Android] 앱을 배포해 봅시다.
  • [Android] 컨벤션 플러그인 뜯어고치기
  • [Android] Baseline Profile 적용기 feat. Fake 주입 실패
한민돌
한민돌
Android 개발자가 되기까지.
  • 한민돌
    미래 가젯 연구소
    한민돌
  • 전체
    오늘
    어제
    • 분류 전체보기 (20)
      • Android (4)
        • Compose (10)
        • Jetpack (2)
      • Kotlin (2)
        • Kotlin In Action (0)
      • 외부 활동 (2)
        • 우아한테크코스 8기 (2)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

    • GitHub
  • 인기 글

  • 태그

    custom plugin
    Multi-module
    OssLicensePlugin
    sideeffect
    Baseline Profile
    Google Recommend Architecture
    coroutine
    compose
    API35
    derivedstateof
    AboutLibraries
    LaunchedEffect
    우테코
    backing property
    Android
    jetpack
    rememberupdatedstate
    build-logic
    producestate
    runcatching
  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
한민돌
[Android] 실용적인 멀티모듈 아키텍처 설계 실전기 (Domain 레이어와 build-logic)
상단으로

티스토리툴바