Effective Kotlin 읽고 Compose에 적용하기
Effective Kotlin 책을 읽고 지식을 공유하는 스터디가 2024/11/07 에 종료된 기념으로, 이 책에서 다룬 주제들을 더 확장해서 Jetpack Compose에서 어떻게 활용할 수 더 생각해보고 정리해보았다.
Item 1. 가변성을 제한하라 - 안정성
코틀린은 안정성을 추구하고 불변성을 지향하는 언어이다. 불변성을 유지하면 코드의 예측 가능성이 높아지고, 개발자가 코드의 동작을 쉽게 추론할 수 있다.
이러한 불변성을 Kotlin에서 가장 쉽게 구현할 수 있는 방법에 대해서 흔히 val
을 떠올린다. Skydoves님이 진행한 투표의 결과를 보면, 얼마나 많은 개발자들이 Kotlin의 val
키워드를 Immutable로 다루고 있는지 확인할 수 있다.
하지만 이건 반만 맞은 생각이다. Kotiln에서 val
은 불변성을 완벽히 보장하지 않으며, 실제로는 참조의 불변성만 제공한다. 가장 쉬운 예시로 val
로 선언된 List
가 MutableList
를 가리킬 경우 리스트 내 요소는 추가되거나 삭제될 수 있는 예시가 있다.
이런 경우를 대비하기 위해 책에서는 가변 컬렉션(MutableList
, MutableSet
등)과 불변 컬렉션(List
, Set
등)을 구분거나, 방어적 복사를 사용해 원본 객체의 상태가 외부에 의해 변경되지 않도록 하거나, data class
의 불변성을 사용하는 것을 권장하고 있다.
이러한 방법들을 사용해서 가변성을 제한하면 다음과 같은 이점이 있다.
안정성 향상: 상태 변화가 없으므로 코드의 예측 가능성이 높아진다.
스레드 안전성 확보: 불변 객체는 스레드로부터 안전하므로 동기화 없이도 안전하게 사용할 수 있다.
함수형 프로그래밍 장점 활용: 불변성을 통해 순수 함수(pure function)를 작성할 수 있어 코드의 재사용성과 테스트 용이성이 높아진다.
하지만 가변성을 제한하기 위해 불변 객체를 생성하면 메모리 할당 및 GC 오버헤드가 발생할 수 있다. 그러나 현대의 JVM과 안드로이드 런타임은 객체 할당과 가비지 컬렉션을 효율적으로 처리하므로, 대부분의 경우 성능에 큰 영향을 주지 않는다. (이 부분은 아직 직접 검증하지 못하였다.)
또한, 불변 객체를 사용함으로써 얻는 코드의 안정성과 유지보수성은 이러한 오버헤드보다 더 큰 이점을 제공한다고 생각한다. (이 또한 개인적인 생각이다.)
이제 이런 불변성을 활용하면 함수형 프로그래밍에서 데이터가 변경되지 않는다는 가정을 통해 순수 함수와 SideEffect가 없는 코드를 작성할 수 있다. 이러한 성격을 가진 함수형 프로그래밍은 Jetpack Compose에 아주 큰 영향을 준다.
Compose는 선언형 프로그래밍의 방식을 따르며, 무엇을 그릴지에 집중하고 어떻게 업데이트할지는 Compose 프레임워크가 알아서 처리하도록 한다. 이 선언형 접근 덕분에, 개발자는 가변성 관리에 집중하기보다는 필요한 상태를 선언하고 그에 따른 UI를 작성하는 데 집중할 수 있다.
반면, 선언형 UI 구조에서 가변 상태를 다루는 것은 매우 신중하게 접근해야 할 문제이다. 가변 상태가 예측하지 못한 타이밍에 변경되면 UI의 일관성이 깨지거나, 상태가 멀티 스레드 환경에서 올바르게 동기화되지 않아 동시성 문제가 발생할 수 있기 때문이다.
따라서, Compose는 내부적으로 Snapshot 시스템이라는 고유한 상태 관리 메커니즘을 제공하여 가변성을 효과적으로 제어하고 관리한다. Snapshot 시스템은 상태 변화를 트랜잭션 기반으로 관리하여 상태의 변경 사항을 자동으로 추적하고, 변경이 발생할 때만 Recomposition을 유도하며 UI가 불변성에 가까운 상태를 유지할 수 있도록 한다.
Snapshot 상태는 변경 사항을 기억하고 관리할 수 있는 분리된 상태를 의미한다. Snapshot 상태 는 mutableStateOf
, derivedStateOf
, produceState
, collectAsState
와 같은 함수를 호출할 때 얻게 된다.
위 문서에서 알 수 있듯이 mutableStateOf
함수는 Compose의 Snapshot 상태를 생성하여 상태 변화에 대한 자동 추적을 시작한다. 이를 통해 상태가 변경될 때마다 Compose가 해당 상태를 사용하는 모든 Composable을 자동으로 Recomposition되도록 한다.
Compose의 Snapshot 시스템에서 상태 변경은 트랜잭션처럼 처리된다. 상태 변경이 발생할 때마다 Snapshot 시스템은 트랜잭션 단위로 상태 변경을 기록하고, 최종적으로 apply 메서드를 호출하여 변경사항을 확정한다. 이러한 구조 덕분에 Compose는 상태 변경을 안전하게 관리하며, 변경 사항이 확정되기 전까지는 UI에 반영되지 않도록 격리할 수 있다.
여기서 takeMutableSnapshot
메서드는 새로운 가변 스냅샷을 생성하고, apply
메서드를 호출하여 상태의 최종 확정이 이루어진다. enter
블록 내에서 상태 값을 변경하더라도, apply
가 호출되기 전까지는 변경 사항이 전역 상태에 반영되지 않으며 독립적인 스냅샷 상태로 유지된다.
더 나아가 Compose의 Snapshot 시스템은 다중 버전 동시성 제어(MVCC)를 통해 다중 스레드 환경에서도 안정성을 유지한다. MVCC란 ACID(Atomicity, Consistency, Isolation, Durability)의 I를 구현하는 방법인 동시성 제어(Concurrency Control) 중 하나이다. 이를 통해 각 스레드는 독립적인 상태의 스냅샷을 생성하여 상태를 읽거나 수정할 수 있으며, 최종적으로 apply
가 호출될 때만 전역 상태에 반영된다.
이 코드에서 takeMutableSnapshot
을 통해 가변적인 스냅샷을 생성하고 enter
블록 내에서 상태 변경을 수행한다. 이때 apply
호출 전까지 변경 사항은 격리된 상태로 유지된다. 이는 여러 스레드에서 동시에 작업할 때도 안전하게 상태를 관리할 수 있도록 한다. (이와 관련한 개념으로는 StateObjcet, StateRecord가 존재한다.)
그리고 이렇게 스냅샷으로 찍어낸 값들은 snapshot.dispose()
가 호출될 때까지 보존된다. 스냅샷에서도 라이프사이클이 존재하기에, 스냅샷 사용을 마쳤을 때 반드시 이를 폐기해야 한다.
snapshot.dispose()
를 호출하지 않으면 스냅샷과 관련된 모든 리소스와 해당 스냅샷이 유지하는 상태에 대해 메모리 누수가 발생한다.
또한 스냅샷이 찍힐 때, ID가 주어져서 다른 스냅샷이 유지하는 잠재적으로 동일한 상태의 다른 버전들과 쉽게 구별될 수 있도록 한다. 이를 통해 프로그램 상태를 버전화 할 수 있기 때문에, 프로그램 상태를 버전에 따라 일관되게 유지 할 수 있게 한다.(MVCC)
정리
Kotlin에서는?
가변성을 제한하기 위해 불변의 객체나 val
키워드를 사용하지만, 이 방식만으로는 모든 상황에서 완전한 불변성을 보장할 수 없기에 data calss
의 copy
매서드나 방어적 복사 기법을 사용하여 상태가 외부에서는 반경되지 않도록 보호한다.
Compose에서는?
Compose는 Snapshot 시스템을 통해 불변성 제약을 강화한다. Snapshot 시스템은 특정 시점의 상태를 독립적으로 보관하여 이후 상태 변경이 발생해도 해당 시점의 상태를 참조할 수 있게 한다. 이는 가변 상태가 다른 컴포저블에 예기치 않게 영향을 미치지 않도록 격리된 상태로 관리하며, MVCC 개념을 활용해 Snapshot 간의 변경 충돌을 방지하여 안정적이고 일관된 상태 관리를 지원한다.
Item 19. 지식을 반복하지 마라 - 재사용성
프로젝트에서 복사-붙여넣기를 사용한다면, 뭔가 잘못하고 있을 가능성이 크다.
이 주제에서는 다루는 내용을 책에서는 위 문장으로 정리했다.
Kotlin에서 DRY 원칙(Don't Repeat Yourself)는 유지보수를 쉽게 하고, 코드 변경 시 오류를 방지하기 위해 중요하게 고려된다. 반복되는 로직이나 UI는 독립적인 함수나 클래스로 추출하여 Single Source of Truth (SSOT) 원칙을 따르도록 설계하는 것이 좋다. 예를 들어, 여러 화면에서 동일한 데이터를 가져오는 비즈니스 로직이 필요하다면, 이를 별도의 리포지토리나 유틸리티 함수로 분리하여 한 곳에서 관리할 수 있다.
이 주제에서 지식이라는 단어를 의도된 정보의 모든 조각이라고 풀어서 설명하고, 코드나 데이터로 표현될 수 있다고 한다. 개발자는 지식의 유형 중 비즈니스 로직이나 일반적인 알고리즘을 주로 다룬다. 알고리즘은 안정적이고 크게 변하지 않지만, 로직은 시간이 지나면서 자주 바뀔 수 있다.
UI 디자인의 표준과 기술은 훨씬 더 빠르게 변화한다. 책에서는 필연적인 변화에 대해 애자일 방식을 채택하는 것을 권하면서, 변화의 가장 큰 적을 지식의 반복이라고 설명한다. 여러 곳에서 반복되는 곳을 변경해야 한다면, 모든 곳을 수동으로 바꿔주는 과정에서 리소스의 낭비와 휴면 에러가 발생할 가능성이 있고, 이를 변화의 가장 큰 적이라고 말하는 것 같다.
그렇다면 반복을 허용할 때는 언제인가?
두 개의 코드가 비슷해 보이더라도, 실제로는 하나로 추출되지 말아야 할 때가 있다. 이는 그들이 단지 비슷하게 보일 뿐, 다른 지식을 나타낼 때 발생한다.
추상화를 시도하면 자체적인 API를 설계하게 되며, 이 API를 사용하는 개발자들은 이를 새로 배워야 한다. 추상화가 실제로 동일한 지식이 아닌 경우, 추출은 문제를 일으킬 가능성이 높다.
우리가 두 개의 코드가 유사한 지식을 나타내는지 결정할 때 가장 중요한 질문은, 그들이 함께 변경될 가능성이 더 높은가, 아니면 별도로 변경될 가능성이 더 높은가?이다. 이 질문이 가장 중요한 이유는 공통 부분을 추출하면 두 개를 함께 변경하는 것은 쉬워지지만, 하나만 변경하는 것은 더 어려워지기 때문이다.
하나의 유용한 경험 법칙은, 비즈니스 규칙이 다른 출처에서 나오는 경우, 그들은 독립적으로 변경될 가능성이 크다고 가정해야 한다는 것이다. 이러한 경우, 우리는 의도치 않은 코드 추출을 방지하는 규칙을 가지고 있다. 이 규칙은 단일 책임 원칙(Single Responsibility Principle)이다.
Single responsibility principle
코드를 공통으로 추출해서는 안 되는 상황을 알려주는 매우 중요한 규칙이 SOLID의 단일 책임 원칙이다. 이 원칙은 하나의 클래스는 변경할 이유가 하나만 있어야 한다는 것을 의미한다. 이 규칙은 두 명의 행위자(actor)가 같은 클래스를 변경해야 하는 상황이 없어야 한다는 것으로 간단히 설명할 수 있다.
여기서 행위자란 변경의 출처를 의미하며, 종종 서로의 업무와 도메인에 대해 잘 모르는 다른 부서의 개발자들로 의인화된다. 비록 프로젝트에 한 명의 개발자만 있더라도, 여러 명의 관리자들이 있다면, 그들 역시 행위자로 취급해야 한다. 이는 서로의 도메인에 대해 잘 모르는 두 개의 변경 출처를 의미한다. 두 행위자가 같은 코드를 편집해야 하는 상황은 특히 위험하다.
Compose에서도 마찬가지로 이러한 위험에 많이 노출되어 있다. 각각 다른 목적의 상태나 UI 변화를 하나의 컴포저블에 결합할 경우, 예상치 못한 변경이 일어날 수 있다.
위와 같은 TopAppBar가 있다고 치자. Compose에 익숙하지 않은 사람이 위 TopAppBar를 구현한다고 가정하면 아래와 같이 구현할 수 있을 것 같다. 아래 TopAppBar에 기본으로 제공된 폰트 크기 및 배경색 등등 디자인을 커스텀했다고 가정하자. (코드 생략을 위해)
이렇게 구현한 TopAppBar를 두 화면에서 사용하고 있었는데, 한 화면에서 아이콘이 바뀌어야 되는 경우가 생겼다. 이런 경우에서 현재 상황에서의 최선은 복사 붙여넣기로 두 가지의 TopAppBar를 만드는 것이라고 생각할 수 있다.
하지만 Compose에서는 Slot Api가 존재하기에 SRP 원칙을 준수하기 꽤 수월하다. Slot Api를 통해 Composable을 유연하게 설계하여 변경사항에 대해 쉽게 대처할 수 있다.
위와 같이 설계하면 각 요소를 독립적인 Slot으로 받아 처리하므로, 개별 요소를 유연하게 교체할 수 있다. 이를 통해 재사용성을 높이고, 유지보수를 용이하게 할 수 있다.
No Silver Bullet
Compose에서 Slot Api나 비즈니스 로직의 모델을 적절하게 SRP을 준수하게 분리했다고 치자. 과연 그 분리한 녀석들도 분리가 되어야 할 경우가 생기지 않을 것이라고 확신하는가?
필자는 최근에 앱 대규모 리뉴얼 작업을 수행하면서 공통적으로 전임자가 추출한 Model에 대한 구조가 바뀐 경험이 있다. 이에 따라 해당 Model을 참조하는 모든 곳에서 개별적인 로직 수정이 필요했고, 이는 재사용성과 유지보수성을 높이기 위한 추상화가 오히려 재사용성과 유지보수성을 낮추게 되었다. 하지만 이런 상황에서 화면마다 Model을 구축하는 것은 비효율적일 수도 있다.
극단적인 접근은 건강하지 않으며, 우리는 항상 균형을 찾아야 한다. 무언가를 추출할지 말지는 때로 어려운 결정이며, 이는 정보 시스템을 잘 설계하는 것이 하나의 예술인 이유다. 이는 시간이 필요하고 많은 연습을 요한다.
책에서는 Item 19의 결론을 위와 같이 묘사하였다. 결국 미래에 대해 완벽하게 알 수 없기 때문에, 균형을 잘 찾아야 하며 이는 트러블 슈팅 경험으로 어느정도 극복이 가능하다고 생각한다. 저자가 예술이라는 단어를 쓴 것처럼, 이상적인 설계란 아주 어려운 작업이라고 생각한다.
Item 27. 추상화를 사용하여 코드 변경으로부터 보호하기
"물 위를 걷거나 명세서에서 소프트웨어를 개발하는 일은 둘 다 고정되어 있으면 쉽다." – 에드워드 V.베라드
위에서 다루었던 Item 19와 유사한 내용이다. 책에서는 상수, 함수, 클래스, 인터페이스 등으로 분리하는 것을 예시로 들며 추상화의 장단점을 설명하고 있다. 결론 부분은 마찬가지로 균형을 잘 지키는 것을 강조했다.
Compose에서는 상태 관리가 핵심이다. UI와 상태가 강하게 결합되면, 단순한 변경이라도 전체 UI 구조에 큰 영향을 미칠 수 있다. 이런 경우에는 상태 관리와 UI로직을 분리하고, 서로 추상화된 인터페이스를 통해 상호작용하도록 설계하는 것이 좋다.
StateFlow와 mutableStateOf
Compose에서는 State
및 **MutableState
**가 UI의 재구성을 자동으로 트리거하여 반응형 상태 관리를 지원한다. 하지만 프로젝트가 복잡해지면 UI가 아닌 계층에서도 상태 변경을 다뤄야 할 필요성이 커진다. 이때는 추상화된 인터페이스를 활용할 수 있다.
가장 흔한 예시로 StateFlow
가 존재한다. StateFlow
는 상태 변화를 관찰 가능한 데이터 흐름으로 제공하며, Compose가 아닌 계층에서도 쉽게 통합할 수 있다. 이를 통해 비즈니스 로직에 따른 상태 변경을 UI에 쉽게 반영할 수 있고, 상태 관리의 일관성을 유지할 수 있다.
StateFlow
의 collectAsState
확장 함수는 Compose와 StateFlow
를 연동해 StateFlow
의 상태 변화를 Compose 컴포저블에서 쉽게 관찰할 수 있도록 설계된 함수이다. 이 확장 함수는 StateFlow
의 최신 값을 State
로 변환하여, Composable
함수에서 직접 StateFlow
를 관찰하는 데 사용하는 대표적인 방법이다.
collectAsState
함수는 StateFlow
를 State
로 변환하여, Compose의 Composable
함수가 리컴포지션이 필요할 때 자동으로 반응하도록 한다. 이 코드에서 StateFlow<T>.collectAsState
함수가 호출되면, 현재 StateFlow
의 값을 State
로 변환해주는 역할을 한다. 이 함수는 기본값으로 StateFlow
의 value
프로퍼티를 전달해 초기 State
값으로 설정하고 있다.
총평
Effective Kotlin 책은 스터디가 없었다면 읽지 못했을 것 같다. 책의 분량이 상당했으며, 어려운 내용이 생각보다 많았다. (특히 공변성, 반공변성 내용은 3번 읽어도 이해가 안됐다..) 하지만 스터디의 강제성 덕분에 11주의 기간 동안 완독을 했으며 평소 궁금했던 부분들을 어느정도 해소하여 매우 만족스럽다.
좀 더 많은 내용을 이 글에서 다루고 싶었으나, Compose도 결국 Kotlin 코드로 작성하기에 거의 대부분의 주제가 그대로 적용되어 특별하게 다룰만한 것이 떠오르지 않았다. 함수형 프로그래밍과 관련된 불변성, 컴포넌트를 어디까지 분리해야되는가에 대한 재사용성 및 추상화가 Compose에 적용할 수 있는 가장 중요한 내용이기에 다뤄보았다. 이후 Snapshot System과 같은 내용들은 JetpackCompose Internals를 통해 추가 학습을 진행해보려고 한다.
어떤 분야든 기본기가 가장 중요하고 차이를 만들어 내는 부분인 것 같다. 주기적으로 책의 정리본을 다시 읽어보며 내용을 상기시킬 필요가 있다고 생각이 든다. 끝으로 이런 개발 서적을 낮은 수준이라도 직접 집필해보면 기본기가 굉장히 튼튼해질 것 같다는 생각이 들어서 언젠가 나도 저자가 되는 꿈을 꿔보려고 한다.