
300만 줄의 코드로 배운 것들
개인 광고 영역
+270만, -240만, 290개
이건 내가 처음부터 끝까지 서비스 하나를 만들고 약 4년간 작성해본 코드 양이며, 모듈 숫자이다.
혼자 서비스를 하면서 선택한 아키텍처 덕분에 안정적인 서비스를 해볼 수 있었다. 정승욱님이 발표한 코드 제로 관련에 나온 코드를 기반하여 수정하고 개선한 프로젝트를 사용했다.
최근엔 iOS 개발도 약 10개월 진행하였는데 iOS TCA를 이해하면서 작성한 코드 역시 +46만 줄, -32만 줄 수정하였다.
이 글에서는
- 4년간 서비스를 만들며 다듬어온 실용적인 모듈화 전략
- 인터페이스 분리 원칙(ISP)을 적용해 빌드 속도를 개선한 경험을 소개
- 시나리오 기반의 유닛 테스트 방법
모듈화
모듈화의 목적은 무엇일까? 클린 아키텍처를 사용하기 위해서는 모듈화를 해야 한다는 이야기가 있다.
그렇지 않다. 필자의 모듈은 크게 4가지로 나뉜다.
Core
: UI를 제외한 기본 동작을 처리하는 모듈, 여기에는 테스트용 모듈도 포함된다.Core-App
: 앱 내에서 사용하기 위한 디자인 시스템, UI를 포함하는 모듈이다. 이 역시 테스트 용도 모듈이 포함된다.Data
: 일반적으로 클린 아키텍처의 data, domain, data store 등을 포함한다.Feature
: 화면을 기준으로 정리한 모듈
2개의 Core를 나눈 이유는 완전 기본 동작을 위한 코드와 앱 내에서 UI를 포함하는 부분을 나누기 위함이다. 그 말은 core는 어디서든 사용할 수 있음을 뜻하는 완전 코어한 모듈이다.
ISP(인터페이스 분리 원칙)
모든 코드는 인터페이스 분리 원칙을 기반한 모듈화 구성이다.
다음 구성에 대한 참고 : 드로이드 나이츠 코드를 참고하면 이를 볼 수 있다.
- interface를 정의하는 모듈 하나
- 구현체를 정의하는 모듈
- 테스트 시에 가져다 사용할 Fake 모듈
Feature에는 interface를 기반하여 모든 코드를 구성하고, 테스트를 위한 fake 모듈을 기반하여 테스트를 진행할 수 있다.
구현체 모듈을 수정하더라도 interface를 바라보는 모듈은 재빌드 대상에서 제외되기 때문에 더 빠른 개발이 가능한데, 일반적으로 1분에서 최대 3분 이내 빌드 가능해 빠른 개발이 가능했었다.
이건 단순 모듈의 분리가 아닌 interface를 바라보도록 만든 구조 덕분에 가능했다고 생각한다.
다만, core를 수정한다면 implementation이 feature의 구현체들이 참조하기 때문에 오랜 시간 걸릴 수 있다.
서버에서 내려주는 오류에 따른 응답 처리
서버 응답에 대한 처리는 서버에서 Alert/Toast 등의 노출도 제어할 수 있도록 개발하였다.
이에 따라 Error 발생 시 모든 기본 응답은 서버에서 내려주는 대로 노출할 수 있게 되었다. 화면 별로 처음 로드하는 기준을 정하고, 재시도 가능한 형태를 구분하여 개발하였다.
데이터
네트워크 모듈의 interface를 주입받아 사용한다. 로컬 데이터는 앱의 성격상 정말 필요한 경우가 아니라면 사용할 일은 없다. 보통 금융 관련은 로컬에 데이터를 저장하는 선택보단 실시간 데이터가 더 중요하다.
금융 앱의 특성상 데이터를 저장할 때는 기본 Android Keystore를 활용하여 저장하고 값을 꺼내 사용한다.
도메인은 사용했나?
도메인은 이전 글에서 작성했지만 클라이언트 아키텍처에 대한 단상 - ‘서버’가 진짜 ‘도메인’ 아닐까? 정말 필요하지 않은 이상 사용하지 않았다.
1개 이상의 repository에 접근해서 서로 다른 데이터의 합이 필요한 경우 사용하였다. 지금 공부하는 개발자들은 안드로이드 아키텍처에 가깝다고 생각하겠지만, 지금까지 꾸준히 MVP > MVVM > 클린 아키텍처 적용을 해본 개발자들이라면 원래 그런 것 아니야? 라는 생각을 하는 것은 당연하다. 필자도 오랜 기간 단순 포워딩하는 것이 필요하지 않음을 알고 있다.
View
뷰와 뷰모델의 사용은 그냥 흔한 방식이다. UiState를 활용해 데이터를 처리하도록 만들었고, 이때 @Immutable
, @Stable
을 붙여 사용하였다.
@Immutable
data class SomeUiState(
val items: ImmutableList<String>,
val showProgress: Boolean,
val showPlaceholder: Boolean,
) {
companion object {
val Default = SomeUiState(
items = persistentListOf(),
showProgress = false,
showPlaceholder = false,
)
val Placeholder = Default.copy(
showPlaceholder = true,
items = persistentListOf("a", "b"),
)
}
}
위와 같은 형태이다. 리컴포지션을 중요하게 생각하니 ImmutableList
역시 사용한다.
Compose는 일반적인 방식이며, Stateful, Stateless를 구분하여 사용하며
- SomeRoute: Navigation에서 불러지는 화면
- SomeScreen: Route에서 불러다 사용하는 내부의 internal fun SomeScreen()
- SomeComponent: Screen을 조합하는 모든 구성 요소는 Component를 활용한다.
- 당연하지만 Component는 Stateless 형태로 작성해야 한다. 다만 모듈의 위치에 따라 data class를 전달받기도 한다.
시나리오 기반 테스트
모든 ViewModel의 로직은 UnitTest를 포함하는데, 2023 드로이드 나이츠 발표자료에서 일부 소개한 방식을 사용한다.
아래와 같은 시나리오 기반의 코드가 가능하다.
- 사용자가 버튼을 누른다.
- 입력값이 있다면 flow를 진행하고, 그렇지 않다면 filter에 걸린다.
- UiState 상 프로그레스를 실행한다.
- 동의 화면이(Alert/BottomSheet 등)이 뜨면 응답에 따라 동작이 달라진다.
- Yes 진행하고 네트워크 통신을 시작한다.
- No 진행을 중단하고 프로그레스를 원래 상태로 되돌린다.
- 네트워크 응답에 따라
- 실패하면 서버에서 내려주는 Alert/Toast 등을 노출하고 프로그레스를 되돌린다.
- 성공하면 완료 처리한다.
위의 모든 단계를 UnitTest에서 테스트하고 버튼의 복구까지 이어지는 UiState의 상태를 확인하는 식으로 개발하였다. 모두 완벽하진 않다. 다만 UiTest를 하지 않아도 UnitTest 만으로 어느 정도 범위에서 안정적인 서비스가 가능하다는 장점을 가진다.
이런 테스트는 1,000개가 넘게 포함되어 있었다.
KSP를 활용한 Hilt Module 정의
메모리상 빠르게 사라질 수 있도록 하려고 Data 모듈에 대한 정의는 Activity 라이프사이클에 따르도록 만들었고, 정말 필요한 경우가 아니라면 싱글턴은 최소한으로 사용하였다.
이런 부분도 자동화를 하고 싶었고, KSP를 활용해 자동화했다. 기존에는 Dagger를 활용하였는데, Hilt를 사용함으로써 더 간결해졌다.
KSP를 활용해 Hilt 모듈을 자동으로 생성할 수 있도록 만들어 사용하였는데, Kilt(Kotlin + Hilt)를 별도로 만들어 두었다.
@KiltGenerateModule
을 추가하면 자동으로 Hilt의 @Module
을 만들어주는 역할을 한다.
참고 : Dagger를 사용하면 사실상 빌드 시간이 더 단축된다. Hilt는 수동으로 구성하던 많은 코드를 자동으로 만드는데 이 역할은 Android Studio의 플러그인이 해주고 있다. 다만 빌드 시간이 늘어날 수밖에 없다.(사실 Dagger일 때 1-2분이었지만 3분 이상으로 늘기도 했다)
수많은 모듈
수많은 모듈에서는 당연하지만 결국 모듈의 관리가 가장 중요하다.
Android에서 효율적인 모듈 디펜던시 관리하기, build-logic으로 전환하기?
build-logic을 사용하는 것은 어찌 보면 필수이다. 최소한의 코드를 사용하여 build.gradle.kts를 관리하여야 한다.
그리고 필자는 사실상 App 모듈을 수정하지 않는 것을 선호한다. 사실 만들고 나면 고칠 일은 거의 없다. 모두 피처 모듈의 형태를 띠고 있으니 더더욱 수정할 일은 없다.
그럼 implementation은 어떻게 하는가? 위 글에도 나오긴 하지만 이제 저 방식으로도 힘들다.
그래서 수정한 코드 방식은 아래와 같다.
dependencies {
rootProject.subprojects.filterProject {
implementation(it)
}
}
필터는 왜 필요한가? 모든 모듈이 하위에 바로 위치하는 것은 아니다. :폴더:폴더:모듈 의 형태를 가지는 경우가 더 흔하다. 그래서 filter를 통해 구분한다.
fun Set<Project>.filterProject(
body: (target: Project) -> Unit,
) {
forEach { project ->
if (project.name != "app" && project.buildFile.isFile) {
body(project)
}
}
}
사실상 위에서부터 순서대로 settings.gradle.kts
에 정의한 모든 모듈을 참조하기에 app 모듈이 아닌 경우를 구분해야 한다.
어찌 보면 단점이다. 기존엔 그냥 특정 모듈을 바로 공통으로 접근할 수 있었는데, 그렇지 않아서 수정한 상태다.
레거시 ViewModel에서 AAC-ViewModel로
AAC-ViewModel을 사용하지 않았다. 이유는 굳이 써야 할 이유가 없었다.
하지만 안드로이드 16이 나오면서 이제 AAC-ViewModel을 꼭 사용하긴 해야 한다. 그렇지 않다면 데이터 savedstate를 처리해 주어야 한다. 안드로이드 16부터는 폴더블 기기 600dp 부터 화면 회전이 강제화된다. 회전을 막는다고 하더라도 무시되고 동작한다.
예외를 하면 회전했을 때 좌우에 여백을 공백으로 만들 뿐 회전을 막는 것 역시 아니다. 근데 최근에 이거 안 쓰는 개발자가 있을까 싶다.
iOS TCA
TCA를 처음부터 한 것은 아니고 중간부터 투입되어 개발하게 되었다.
TCA는 리덕스로 작성되어 있다. 다행히도 안드로이드 개발에서 사용하던 플로우 이벤트 처리 방식을 그대로 이어가면서 개발이 가능하였다는 점이다. 적응하는 시간은 오래 걸렸다. 일반적으로 UiState를 리턴하는 리액트 방식과는 다르게 다음 액션을 전달하는 방식의 차이가 있다. 그래서 이후 안드로이드 아키텍처를 만들면서 소개한 내용과 이어진다.(블로그 글 참고)
10개월이라는 짧은 기간 동안 TCA를 사용해보면서 느낀 점은 딱 하나. 오류 위치를 알려주지 않는다. 의외로 해결법은 간단한데, 코드를 분리해야 한다. 코드를 분리한다고 하여도 오류를 잘 알려주는 것은 아니다.
그냥 어딘가 문법 오류가 있다. 끝. 일일이 주석 처리해 가면서 찾아야 한다. 다행히 런타임 오류가 아닌 것이 어디인가 싶다.
그리고 또 하나 경험한 내용은 앱의 이름 덕분에 앱이 그냥 종료되어지는 오류도 만나보았다. 특정 버전의 OS에서만 오류가 발생하였다. 의심 가는 걸 Gemini와 GPT 둘 다에게 질문했지만 찾지 못했다. 결국 감으로 찾은 이유는 앱 이름이 겹쳐서였다는 사실이다.
앱이 그냥 아무 이유 없이 꺼지는데 분석을 자세히 해보면 더 빨리 찾았겠지만 iOS 경험이 많지 않은 상태에서 AI가 없었다면 며칠 걸렸을지도 모르지만 다행히 하루 만에 이유를 찾고 해결해보았다.
결국
결국 요구사항을 잘 반영하는 코드를 만들고 싶은 욕심과 AI를 더 잘 활용하는 방법 등이 있다.
TCA를 하면서 한 가지 느낀 점은 굳이 Data 모듈도 나누어야 할까?
라는 의문이다. 자동화를 통해 Data 모듈도 KSP로 JSON 통신하는 것을 만들고, 이것을 API 정의한 것에 연동하는 과정을 만들면 더 쉬운 개발도 가능하다는 점이다.
그런 관점에서 이전에 작성한 도메인 관련 글의 인사이트를 다시 얻게 되었다.
마무리
약 4년 동안 입력한 코드 양만 합산 316만 줄이다. 여기에는 자동으로 생성되어지는 json이나 파일들이 있을 거니 이런 걸 제외하더라도 300만 줄의 코드를 쳤다는 것이긴 하다. 단순히 파일 이동도 있으니 더 제외하면 더 줄어들겠지만.
코드양이 많다고 많은 걸 해본 것도 아니다. 그냥 즐겁게 코드를 입력하고, 이해하기 위해서 기존의 코드를 가져와 그대로 사용하지 않고 직접 입력하면서 이해하는 것을 우선하려고 노력하였다. 다행히 이를 통해 더 많은 코드를 수정하고 반영할 수 있었다.
다만 누구나 이 코드를 수정할 수 있는가? 뷰는 가능하지만 core는 어렵다. 기본적인 이해를 바탕으로 하기에는 프레임워크를 이해해야 하는 것과 같다.
Comments