Jetpack Compose UI 조합(Composition)하기 심화
Jetpack Compose는 비교적 새로운 선언형 UI 프레임워크로서 컴포넌트 조합에 대한 구체적인 설계 패턴 연구가 많지 않은 편이다. 이에 반해 역사가 깊은 React 생태계에서는 UI 컴포넌트를 설계하고 조합하는 다양한 패턴이 연구되어 왔다. React에서 사용되는 UI 조합(Composition) 패턴들은 Compose에도 적용할 수 있으며, 프레임워크의 성격에 맞게 응용할 수 있다. (물론 패턴 자체를 적용하는 것보다, 왜 적용하는지 이해하는 것이 훨씬 중요하다)
언급하는 주요 패턴은 다음과 같다:
- Slot 패턴: UI를 유연하게 구성하기 위해 슬롯을 통해 다양한 컴포넌트를 조합하는 패턴으로, UI 요소의 자유로운 배치와 재구성을 가능하게 한다.
- Compound Component 패턴: 부모 컴포넌트의 상태를 자식 컴포넌트들과 공유하여 컴포넌트 간 결합도를 낮추고, UI와 비즈니스 로직을 분리하는 데 유용한 패턴이다.
이러한 패턴을 실제로 실무에서 마주할 법한 요구 사항과 함께 살펴보며, 복잡한 UI를 효과적으로 관리하고 변경에 유연하게 대처할 수 있는 방법을 제시한다. 궁극적으로 컴포넌트의 재사용성을 극대화하고 Jetpack Compose에서의 UI 조합을 위한 가이드라인을 제공하는 것을 목표로 한다.
용어 정리
이 글의 중요한 주제 중 하나인 조합 즉, ‘Composition’은 Compose가 UI를 그리는 과정 중 하나가 아닌, 객체지향 프로그래밍에서 객체들을 조합하여 복잡한 기능을 구현하는 Composition을 의미한다. 상속을 통한 재사용과는 달리, 객체를 조합하여 구조를 설계하는 방식으로 유연성과 재사용성을 높일 수 있다. (처음 들어본다면 Effective Kotlin Item 36: Prefer composition over inheritance 을 읽어보고 오자)
또한, 본문에서 언급하는 ‘컴포넌트’는 Jetpack Compose의 UI 컴포넌트를 가리키며, 이는 하나의 컴포저블(Composable) 함수 단위로 정의된다.
중복을 제거한 컴포넌트 분리
긴 말 할 것 없이 사례를 가정해 보자. 여러분은 SNS 프로필 카드를 개발하고 있는 개발자다. 단, 다른 사람 프로필과 내 프로필의 요구사항이 일부 다르다. 내 프로필은 다른 사람 프로필과 달리 수정과 공유가 가능해야 하므로 하단에 수정과 공유 버튼이 추가된다.
여러분은 이러한 상황에서 UI의 중복을 어떻게 효율적으로 제거하고 관리할 수 있을지 고민한다. 두 UI를 완전히 분리된 컴포넌트로 만드는 방법도 있지만, 공통 요소를 최대한 재사용하는 것이 효율적이라고 판단한다. UI 레이아웃, 로직, 디자인 스타일 등 공유되는 부분을 재사용한다면 코드의 간결성과 유지보수성을 향상할 수 있다.
“Don’t repeat yourself” (DRY), Every piece of knowledge must have a single, unambiguous, authoritative representation within a system. — The Pragmatic Programmer.
가장 직관적인 해결책은 if 조건문을 사용하는 것이다. 별도의 컴포넌트 분리 없이 UI 요소의 표시 여부만 조건에 따라 동적으로 제어할 수 있다.
@Composable
fun UserProfile(
user: User,
isSelf: Boolean,
) {
Column(...) {
Row(...) {
ProfileImage(...)
Name(...)
}
Bio(...)
if (isSelf) {
Toolbox(...)
}
}
}
무분별한 조건문은 컴포넌트의 복잡도 증가로 이어진다
조건문을 통한 UI 구성 방식은 초기에는 간단하고 실용적으로 보이지만, 사용자가 요구하는 UI 변경 사항이 늘어날수록 컴포넌트가 점차 복잡해지고 다른 조건들도 추가되기 쉽다. 조건문이 하나 추가된다는 것은, 컴포넌트가 커질수록 수많은 조건문이 추가될 가능성이 생긴다는 뜻이기 때문이다.
예를 들어, 프리미엄 사용자에게만 특정 UI 요소를 표시하거나, 이메일 주소가 있는 경우에만 노출해야 하는 경우 등 조건이 추가될 때마다 UserProfile
컴포넌트 내부의 조건문은 더욱 복잡해진다. 컴포넌트가 점점 조건문에 의존하게 되어, 유지보수가 어려워질 뿐만 아니라 가독성도 떨어진다. 이를 소위 ‘조건문 지옥’이라고 부르며, 컴포넌트를 서로 강하게 결합하게 만든다.
@Composable
fun UserProfile(...) {
Column(...) {
...
// 컴포넌트끼리 강하게 결합하게 만드는 조건문 지옥
if (isSelf) { ... }
if (isPremiumMember) { ... }
if (shouldShowEmail) { ... }
else {...}
...
}
}
이는 DRY(Don’t repeat yourself) 원칙의 함정이다. DRY 원칙에서 중복은 코드의 겉모습이 아닌, 코드 수정의 이유가 같은지에 따라 판단된다. 만약 두 코드가 동일한 이유로 수정되어야 한다면 중복이지만, 수정되는 이유가 다르면 중복이 아니다. 컴포넌트 내 조건문 추가는 서로 다른 수정 이유를 갖는 컴포넌트들이 묶였다는 뜻이며 이 로직은 재사용에 적합하지 않다. 또한 객체지향 설계 원칙, 특히 단일 책임 원칙(Single Responsibility Principle)에 위배되기도 한다. UserProfile
컴포넌트가 프로필 정보 표시라는 기본적인 역할 외에도 다양한 조건에 따른 UI 변화 로직까지 담당하게 되면서 컴포넌트의 책임이 과도하게 커지기 때문이다.
장기적인 관점에서 확장성과 유지보수성을 확보하기 위해서는 단순한 조건문 사용을 지양하고, 컴포넌트 간의 결합도를 낮추고, 각 컴포넌트의 역할과 책임을 명확하게 분리하여 변경에 유연하게 대응할 수 있는 UI 구조를 설계해야 한다.
컴포넌트 조합 아이디어 1 — Slot 패턴
컴포넌트의 강한 결합을 해소하고 유연한 UI 컴포넌트 구성을 가능하게 하는 방법 중 하나는 Slot 패턴이다. Slot 패턴은 컴포넌트의 특정 영역을 외부에서 자유롭게 구성할 수 있도록 하는 디자인 패턴이다. 컴포즈에서는 컴포저블 람다(@Composable () -> Unit
와 같은 형태)를 함수 파라미터로 활용하여 부모 컴포넌트는 자식 컴포넌트의 구체적인 구현에 대한 의존성 없이 UI 구조를 정의하는 식으로 구현할 수 있다.
다음은 Slot 패턴을 적용한 UserProfile
컴포넌트 예시다.
@Composable
fun UserProfile(
user: User,
// 컴포저블 람다를 통해 UI 자체를 파라미터로 받음 (Slot)
bottomContent: @Composable () -> Unit,
) {
Column(...) {
...
// 어떤 UI 컴포넌트가 오더라도 UserProfile 컴포넌트는 알지 못함 (== 결합되지 않음)
bottomContent(...)
}
}
이렇게 구현하면 UI의 공통된 요소는 UserProfile
컴포넌트에 캡슐화하여 중복을 제거하고, bottomContent
Slot을 통해 '내 프로필'과 '다른 사람 프로필'의 차이점을 구성할 수 있다. 즉, UserProfile
컴포넌트는 Slot에 어떤 콘텐츠가 들어올지 알 필요 없이 유연하게 UI를 구성할 수 있는 것이다.
내 프로필과 다른 사람 프로필 컴포넌트(SelfProfile
, PublicProfile
) 또한 캡슐화를 통해 UI 구현의 세부 사항을 외부에 노출하지 않고, 각 프로필 유형에 특화된 로직에만 집중할 수 있도록 한다.
@Composable
fun SelfProfile(
user: User
) {
UserProfile(user) {
Toolbox(...)
}
}
@Composable
fun PublicProfile(
user: User
) {
UserProfile(user)
}
이제 내 프로필 UI 노출 요구사항이 조금 바뀌더라도 (예: 수정 버튼 아이콘 변경, 하단에 새로운 UI 추가 등) 공통된 컴포넌트에 변경 사항이 생기지 않고, Slot으로 전달되는 컴포넌트만 수정하면 된다. (컴포넌트 간 결합 제거)
Slot 패턴 활용 사례— Compose Material Design Components
Compose의 Material Design Components는 대부분 Slot 패턴을 활용한다. 예를 들어 TopAppBar
의 경우 현재 화면과 관련된 타이틀 및 액션이 포함되어야 하기에 UI 레이아웃 요소에 맞게 유연한 구성을 할 수 있도록 @Composable () -> Unit
타입의 파라미터로 Slot을 구성했다.
@Composable
fun CenterAlignedTopAppBar(
title: @Composable () -> Unit,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
...
만약 TopAppBar
컴포넌트의 title
파라미터가 Slot 패턴 대신 String
타입을 받도록 설계되었다고 가정해 보자. 이 경우, 개발자는 title
영역에 텍스트만 표시할 수 있으며, Icon
이나 Image
와 같은 다른 UI 요소를 추가할 수 없게 된다. 다른 UI 요소를 추가하기 위해서는 요구 사항을 만족하는 컴포넌트를 새로 만들어야 한다. 즉, 컴포넌트의 유연성이 제한되고 다양한 UI 요구사항에 대응하기 어려워진다.
이처럼 Slot 패턴은 컴포넌트의 레이아웃 구조는 유지하면서 특정 영역의 콘텐츠만 변경해야 하는 경우에 유용하게 활용될 수 있다. 하지만, Slot 패턴은 모든 UI 조합 문제에 대한 완벽한 해결책은 아니다.
Slot 패턴의 한계
내 프로필과 다른 사람 프로필 예시를 다시 살펴보자. 이번에는 새로운 요구 사항이 생겼다고 가정해 본다. 다른 사람의 프로필의 경우 현재 내 위치에서 얼마나 떨어졌는지 알 수 있도록 UI 요구사항이 추가된 것이다.
(주니어 개발자 속마음: 이럴 줄 알았으면 중복 제거 안 했지 =_=;;)
앞선 예시와 다르게 UI의 차이점이 여러 곳에서 발생했다. Slot 패턴을 활용한다면 각각의 차이점에 대해 Slot을 추가해야 한다. 예를 들어, 프로필 이미지 영역이나 이름 영역에도 조건부 UI가 필요하다면, UserProfile
컴포넌트는 다음처럼 더 많은 Slot을 포함해야 할 것이다.
@Composable
fun UserProfile(
user: User,
centerContent: @Composable () -> Unit,
bottomContent: @Composable () -> Unit,
// UI 변경 사항이 발생할 때마다 Slot을 추가해야 하는가?
) { ... }
이처럼 UI의 변화에 따라 Slot을 계속 추가해야 한다면, 컴포넌트의 API가 오히려 복잡해지는 경우가 생길 수 있다. Slot을 통해 전달되는 콘텐츠의 의미가 모호해지고, 원래의 사용 의도를 해칠 수 있다. 특히, 부모 컴포넌트의 상태를 자식 컴포넌트에서 활용해야 하거나, 자식 컴포넌트들이 서로 연동되어 동작해야 하는 복잡한 UI 로직을 구현하는 경우에는 Slot 패턴만으로는 한계가 있다.
컴포넌트 조합 아이디어 2 — Compound Component 패턴
React 디자인 패턴 중 하나인 Compound Component 패턴은 이 패턴은 하나의 컴포넌트를 여러 조각으로 나눈 후, 외부에서 이들을 조합해 사용하는 방식이다. 부모 컴포넌트가 상태를 관리하고, 자식 컴포넌트는 이 상태를 전달받아 UI를 렌더링한다. 이를 통해 자식 컴포넌트들은 상태 관리 로직에서 분리되어 UI 표현에만 집중할 수 있게 된다. React에서는 Context API를 통해 구현하는 것이 일반적이지만, Compose에서는 Lambda Receiver를 활용하여 구현할 수 있다. (처음 들어본다면 Kotlin 공식 문서 — Function literals with receiver를 읽어보고 오자)
먼저, 컴포넌트의 상태를 관리하기 위한 Scope 인터페이스를 정의한다. 이 인터페이스는 자식 컴포넌트들이 접근할 수 있는 상태와 함수를 정의한다.
@Stable
interface UserProfileScope {
val user: User
}
UserProfile
컴포넌트의 컴포저블 람다에 리시버를 UserProfileScope
로 지정한다.
@Composable
fun UserProfile(
user: User,
// UserProfileScope 리시버 지정
content: @Composable UserProfileScope.() -> Unit,
) {
val scope = remember { DefaultUserProfileScope(user) }
Column(...) {
Row(...) {
ProfileImage(...)
Name(...)
}
// 어떤 UI 컴포넌트가 오더라도 UserProfile 컴포넌트는 알지 못함 (== 결합되지 않음)
// UserProfile이 들고 있는 User 데이터를 자식 컴포넌트에서 렌더링할 수 있음
scope.content()
}
}
이제 자식 컴포넌트들은 UserProfileScope
를 통해 user
상태에 접근할 수 있지만, 직접적으로 UserProfile
컴포넌트에 의존하지 않아도 된다.
@Composable
fun UserProfileScope.Bio(...) {
Text(text = user.bio, ...)
}
이렇게 Scope를 지정하여 선언한 컴포넌트는 해당 Scope 범위 내에서만 사용할 수 있다. UserProfileScope
을 리시버로 지정하지 않은 컴포저블 람다는 Scope 지정 컴포넌트를 전달받을 수 없다.
@Composable
fun OtherComponent(
content: @Composable () -> Unit,
) {
content()
}
@Composable
fun OtherComponentPreview() {
// content에 UserProfileScope을 지정하지 않았기에 컴파일 에러
OtherComponent {
Bio(...)
}
}
이제 내 프로필과 다른 사람 프로필 컴포넌트(SelfProfile
, PublicProfile
)는 UI 변경사항이 생겼을 때 훨씬 유연하게 대처할 수 있다.
@Composable
fun SelfProfile(
user: User
) {
UserProfile(user) {
Bio(...)
Toolbox(...)
}
}
@Composable
fun PublicProfile(
user: User
) {
UserProfile(user) {
Location(...)
Bio(...)
}
}
새로운 요구 사항이 생겨도 자식 컴포넌트를 만들어서 필요에 따라 조합하여 사용하면 된다. 자식 컴포넌트들은 부모 컴포넌트의 상태를 공유하면서도, 부모 컴포넌트에 직접적으로 의존하지 않고 독립적으로 구현될 수 있다.
Compound Component 패턴은 강력하지만 모든 상황에 적합한 것은 아니다. 특히 비즈니스 로직과 밀접하게 연결된 UI에 이 패턴을 적용할 경우, 비즈니스 로직 복잡도가 증가함에 따라 Scope 관리가 어려워질 수 있다. 예를 들어 UI는 동일하지만 UI에서 참조하는 클래스가 변경되는 경우 Scope 인터페이스를 수정해야 하고, 관련된 모든 자식 컴포넌트에 영향을 미칠 수 있다. 또 변경될 수 있는 상태를 들고 있는 Scope interface를 선언해야 하는 경우, 불안정한(Unstable) 상태로 인한 리컴포지션 트리거에 주의해야 한다.
Compound Component 패턴 활용 사례
앞서 살펴본 SNS 프로필 예시는 기본적인 개념 이해를 위한 것이었고, 이 패턴을 더욱 효과적으로 적용할 수 있는 사례들을 살펴보자.
디자인 시스템
Compound Component 패턴의 진가는 여러 하위 컴포넌트를 부모 컴포넌트와 조합해 UI를 구성할 때 때 드러난다. 특히 공통된 디자인 시스템을 구축하거나 재사용 가능한 UI 컴포넌트를 만들 때 매우 유용하다. 단순한 상태 변경뿐 아니라, 컴포넌트 간의 상호작용 및 이벤트 처리 로직까지 캡슐화하여 외부에서 사용하기 편리하고 일관된 API를 제공할 수 있기 때문이다.
예를 들어 아래와 같은 Flyout 메뉴 컴포넌트를 구현해야 한다고 가정해 보자.
Flyout 메뉴는 버튼 클릭과 같은 특정 이벤트에 의해 표시되는 일시적인 UI 요소로, 메뉴의 표시 여부, 위치, 애니메이션, 그리고 내부 항목들과의 상호작용 등 고려해야 할 요소가 많다. 이러한 복잡성을 효과적으로 관리하기 위해 Compound Component 패턴을 적용할 수 있다.
@Stable
interface FlyoutScope {
val isOpen: Boolean
fun toggleFlyout()
}
@Composable
fun Flyout(
content: @Composable FlyoutScope.() -> Unit,
) {
var isOpen by remember { mutableStateOf(false) }
val scope = remember {
object : FlyoutScope {
override val isOpen: Boolean = isOpen
override fun toggleFlyout() { isOpen = !isOpen }
}
}
IconButton(onClick = { scope.toggleFlyout() }) { ...}
DropdownMenu(
expanded = isOpen,
onDismissRequest = { scope.toggleFlyout() },
) {
scope.content()
}
}
@Composable
fun FlyoutScope.MenuItem(text: String, onClick: () -> Unit) {
DropdownMenuItem(
onClick = { onClick(); toggleFlyout() },
text = { Text(text) }
)
}
이제 Flyout
컴포넌트를 사용하여 메뉴 아이템을 손쉽게 구성할 수 있다. 개발자는 Flyout
컴포넌트의 내부 구현에 대해 자세히 알 필요 없이, 제공되는 API를 통해 원하는 기능을 쉽게 구현할 수 있는 것이다.
Flyout {
MenuItem("수정", onClick = { /* ... */ })
MenuItem("삭제", onClick = { /* ... */ })
MenuItem("공유", onClick = { /* ... */ })
}
Compose 기본 제공 컴포넌트
위의 Flyout 예시를 보고 왜인지 LazyColumn
이 생각났다면… 축하합니다. 당신은 컴포즈 고인물!
Compose는 기본적으로 레이아웃 Scope를 지정한 다양한 컴포넌트를 제공한다. 그 중 대표적인 예가 LazyColumn
과 LazyRow
와 같은 Lazy 컴포넌트다. 이들은 LazyListScope
를 활용하여 리스트 항목을 효율적으로 관리하고 구성한다.
LazyListScope
와 LazyColumn
의 선언부를 보자.
/**
* Receiver scope which is used by [LazyColumn] and [LazyRow].
*/
@LazyScopeMarker
@JvmDefaultWithCompatibility
interface LazyListScope {
fun item(...)
fun items(...)
fun stickyHeader(...)
}
/**
* The vertically scrolling list that only composes and lays out the currently visible items.
* The [content] block defines a DSL which allows you to emit items of different types. For
* example you can use [LazyListScope.item] to add a single item and [LazyListScope.items] to add
* a list of items.
*/
@Composable
fun LazyColumn(
...
content: LazyListScope.() -> Unit
) {
LazyListScope
는 item
, items
, stickyHeader
등 리스트를 구성하는 데 필요한 함수들을 제공한다. LazyColumn
을 사용하는 개발자는 리스트의 세부적인 상태 관리 로직에 대해 알지 못해도, 각 항목의 UI 구성에만 집중할 수 있다.
@Composable
fun UserProfiles(users: List<User>) {
LazyColumn {
stickyHeader {
HeaderTitle()
}
items(users) { user ->
PublicProfile(user = user)
}
item {
Footer()
}
}
}
만약 LazyListScope
없이 리스트를 구현해야 한다면, 개발자는 리스트의 상태 관리 및 항목 표시 로직을 직접 짜야 하므로 코드가 다소 복잡해졌을 것이다. 실제 Compose UI를 설계한 개발자들이 이 구조를 Compound Component 패턴이라고 명명하실진 모르지만, 아이디어는 비슷하다고 생각한다.
컴포넌트 조합 아이디어 정리
지금까지 살펴본 세 가지 UI 구현 방식(조건문, Slot 패턴, Compound Component 패턴)은 컴포넌트 간의 결합도와 상태 관리 측면에서 차이를 보인다. 조건문은 단일 컴포넌트 내에서 조건부 렌더링을 수행하므로 컴포넌트 간의 결합도가 높고 상태 관리가 복잡해질 수 있다. Slot 패턴을 활용하면 부모 컴포넌트는 Slot에 들어가는 콘텐츠의 세부적인 구현이나 상태에는 관여하지 않아 유연한 UI 구성을 가능하게 하지만, 복잡한 UI를 구성할 때에는 한계가 있다. 반면, Compound Component 패턴은 UI 조합이 훨씬 유연하며 Scope를 통해 상태를 암묵적으로 전달할 수 있다.
디자인 컴포넌트를 제공하는 입장에서는 Slot 패턴을 통해 컴포넌트의 유연성과 확장성을 보장해야 한다. 반대로, 제공된 컴포넌트를 조합하여 사용하는 입장에서는 Compound Component 패턴을 통해 컴포넌트 간의 상호작용과 상태 관리를 효율적으로 처리할 수 있다.
이 두 패턴은 서로 배타적인 관계가 아니라 상호 보완적인 관계이다. Slot 패턴은 컴포넌트의 재사용성을 위한 기반을 마련하고, Compound Component 패턴은 이를 바탕으로 더욱 복잡하고 정교한 UI를 구축할 수 있도록 지원한다. Stateful과 Stateless 컴포넌트의 적절한 분리와 조화 또한 중요하다. Stateless 컴포넌트는 UI 표현에만 집중하고, Stateful 컴포넌트는 상태 관리 로직을 캡슐화하여 코드의 복잡성을 줄이는 데 기여한다. Compound Component 패턴은 이러한 Stateful 컴포넌트를 효과적으로 관리하고 활용할 수 있는 구조를 제공한다.
궁극적으로는 컴포넌트 간의 결합도를 적절하게 조절하고, 코드의 재사용성과 유지보수성을 높이는 방향으로 UI 구조를 설계해야 한다.
되돌아보기: 조건문이 정말 나쁜가?
DRY 원칙에서 중복은 코드의 겉모습이 아닌, 코드 수정의 이유가 같은지에 따라 판단된다. 만약 두 코드가 동일한 이유로 수정되어야 한다면 중복이지만, 수정되는 이유가 다르면 중복이 아니다.
앞서 조건문의 과도한 사용이 컴포넌트의 복잡성을 증가시키고 유지보수를 어렵게 만드는 ‘조건문 지옥’을 초래할 수 있다고 설명했다. 그러나 조건문 자체가 나쁜 것은 아니다.
@Composable
fun UserProfile(
user: User,
isSelf: Boolean,
) {
Column(...) {
...
if (isSelf) {
Toolbox(...)
}
}
}
어떤 개발자들은 앞서 디스했던 이 코드 블럭이 대체 뭐가 나쁜지 모르겠다고 생각할 수도 있을 것이다. 비즈니스 요구사항을 구현할 수 있는 가장 단순한 방법인 것을 고려하면 충분히 이해되기도 한다. 핵심은 조건문 자체를 피해야 하는 것이 아니라, 조건문의 남용으로 인해 발생하는 복잡성 증가를 경계해야 한다는 것이다. 만약 조건문이 너무 많아지거나 복잡해진다면 컴포넌트 조합 패턴을 적용하여 코드를 리팩토링하고 복잡성을 줄이는 것을 고려해야 한다. 또한 UI behavior logic 과 같은 경우, 특히 애니메이션 상태를 관리할 때에는 굳이 복잡한 패턴을 적용하는 것보다 단순한 조건문을 사용하는 것이 더 효율적일 수 있다.
되돌아보기: 중복이 정말 나쁜가?
실무에서 이 주제로 대립하는 상황을 종종 목격했다.
- 주니어: 일단 재사용하고 생각해 봅시다
- 시니어: 비슷하게 생겼지만 달라요
예를 들어 나의 프로필과 다른 사람의 프로필 UI가 현재는 유사하지만, 미래의 비즈니스 요구사항 변경에 따라 두 UI가 완전히 다른 형태로 진화할 가능성도 있다. UI 개발에서 지나치게 DRY 원칙에 집착하면 오히려 코드의 복잡성을 높이고 유연성을 떨어뜨릴 수 있다. 이 경우 초기 단계에서 DRY 원칙을 맹목적으로 적용하여 공통 컴포넌트를 추출하고 복잡한 로직으로 중복을 제거하는 것보다, 일정 부분 중복을 허용하면서 각 프로필 UI를 독립적인 컴포넌트로 구현하는 것이 장기적인 관점에서 더 유리할 수 있다.
UI 요소의 배치나 스타일이 유사하더라도, 각 UI 컴포넌트가 담당하는 역할이나 상태 관리 로직이 다르다면, 중복을 감수하고 별도의 컴포넌트로 구현하는 것이 더 나은 선택일 수 있는 것이다. 만약 두 컴포넌트에서 공통으로 사용되는 작은 단위의 UI 요소가 있다면, 그 부분만 별도의 컴포넌트로 추출하여 재사용하는 것이 합리적일 것이다. 컴포넌트의 역할과 책임, 그리고 변경 가능성을 중심으로 중복 제거 여부를 판단해야 한다. 단순히 코드의 양을 줄이는 것보다 훨씬 중요하다.
결론
Simple is the Best.
컴포넌트 조합에 대한 여러 가지 아이디어를 살펴봤지만, Slot 패턴과 Compound Component 패턴은 모든 상황에 적합한 해결책은 아니다. 때로는 단순한 조건문이나 중복을 허용하는 것이 더 나은 선택일 수도 있다. 각 패턴의 장단점을 정확히 이해하고, 컴포넌트의 역할과 책임, 그리고 예상되는 변경 사항을 고려하여 적절한 패턴을 선택해야 한다.
개인적으로는 “우리 이런 패턴을 적용해보자”보다는, “구현하고 보니 이런 패턴이었다”를 지향한다. 즉, 패턴을 구현하기 위한 구현이 아닌, 정말 필요한 상황에서 적절한 도구로 활용했으면 한다 :)