컴포넌트를 나누는 기준 with android jetpack compose

MUNGI JO·2024년 9월 5일

Android Jetpack Compose

목록 보기
2/7

컴포넌트를 나누는 기준

DroiKnights 2024 - 김수현님의 발표 및 프론트엔드 아키텍처에 - 이문기 님의 글을 참고하였습니다.

컴포넌트(component) - 소프트웨어 디자인 수준에서 나눌 수 있는 가장 작은 단위를 의미로 이 의미를 프론트엔드 라이브러리 또는 프레임워크에 적용하면, 웹 앱을 구성 하는 데 있어 가장 작은 단위가 된다.

컴포넌트를 나누는 데 기준이되는 몇가지 유명한 방법으로 Presentational Components와 Container Components라는 가장 유명한 기준이 있고 Atomic Design Pattern또한 많이 사용되는 기준이다.

Flutter, React, React Native등 찍먹만 해보고 제대로 구성하기 전 다른 언어로 넘어가서 컴포넌트를 나누는 기준에 대해서 아직 이해하지 못한 상태였는데 짚고 넘어가고자 한다.

왜 component를 나누어야 할까

Android에서 Xml단위로 화면을 구현하게 되면 그만큼 많은 리소스가 XML내부에 생기게 되고 하나의 화면에 여러가지 책임 부여된다. 따라서 하나의 기능을 테스트하기 위해 다른 여러 컴포넌트를 함께 사용해야 하며 필연적으로 테스트 시간또한 길어질 수 밖에 없기 때문이다.

1. 재사용 가능성 (Reusability): 재사용 가능한 컴포넌트는 특정한 상황에 국한되지 않고, 여러 상황에서 활용될 수 있도록 일반적으로 설계된다. 재사용 가능성이 높은 컴포넌트는 다양한 입력을 받아도 제대로 동작하며, 여러 상황에서 반복적으로 사용될 수 있다. 예를 들어, 하나의 Button 컴포넌트가 다양한 화면이나 기능에서 사용될 수 있도록 설계하는 것이다. 즉, 범용성에 대한 얘기다.

흔히 객체지향 프로그래밍(OOP)을 설명할 때 자동차와 부품의 비유를 많이 쓰지만, 여기서는 조금 다른 비유를 사용해 보겠다. 자동차와 비행기를 예로 들어보면, 둘 다 서로 다르지만 모두 탈것이다. 더 넓게 보면 기차도 탈것에 속한다. 이들은 세세한 조작 방식이나 움직임에는 차이가 있지만, 공통적으로 이동하고, 정지하고, 사람을 태우는 기능을 수행한다.

여기서 중요한 점은, 탈것이라는 상위 개념을 공통적으로 가지면서도, 각 탈것이 고유한 방식으로 구현된다는 점이다. 자동차는 땅 위에서 바퀴를 굴리며 이동하고, 비행기는 하늘을 날지만, 기본적인 이동성이라는 기능은 공통으로 제공한다. 즉, 자동차와 비행기는 서로 다른 환경에서 사용되지만, 이동이라는 기본 기능은 탈것이라는 범주에 속하는 모든 것에 적용되는 범용적인 개념이다.

예를 들어 커스텀 입력 필드 컴포넌트를 사용한다면 각 화면에서 동일한 스타일과 동작을 가진 입력 필드가 필요할 때, 아래와 같은 재사용 가능한 컴포넌트를 만들 수 있다.

// 탈것에 대한 기본 인터페이스 (추상화)
interface Vehicle {
    fun move()
    fun stop()
    fun boardPassenger(passenger: String)
}

// 자동차 클래스는 Vehicle의 기능을 재사용한다.
class Car : Vehicle {
    override fun move() {
        println("The car is moving on the road.")
    }

    override fun stop() {
        println("The car has stopped.")
    }

    override fun boardPassenger(passenger: String) {
        println("$passenger has boarded the car.")
    }
}

// 비행기 클래스는 Vehicle의 기능을 재사용하면서 고유 기능도 가진다.
class Airplane : Vehicle {
    override fun move() {
        println("The airplane is flying in the sky.")
    }

    override fun stop() {
        println("The airplane has landed.")
    }

    override fun boardPassenger(passenger: String) {
        println("$passenger has boarded the airplane.")
    }

    fun takeOff() {
        println("The airplane is taking off.")
    }
}

2. 테스트 가능성 (Testability): 테스트 가능성이 높은 컴포넌트는 외부 의존성이 적고, 입력에 따라 예측 가능한 결과를 반환해야 한다. 즉, 외부 의존성을 줄이고, 예측 가능한 동작에 대한 얘기로 이런 예시를 하나 들 수 있다. 사용자 인증을 처리하는 로직이 포함된 뷰 모델(ViewModel)이다. 아래 예시는 사용자의 로그인 상태를 처리하는 LoginViewModel을 보여준다.

// 사용자의 로그인 입력에 따라 상태를 관리
class LoginViewModel : ViewModel() {
    var username by mutableStateOf("")
    var password by mutableStateOf("")
    var loginState by mutableStateOf<LoginState>(LoginState.Idle)
        private set

    fun login() {
        if (username.isBlank() || password.isBlank()) {
            loginState = LoginState.Error("Username or Password cannot be empty")
        } else {
            // Simulating a login call
            loginState = LoginState.Loading
            viewModelScope.launch {
                delay(1000) // Simulate network delay
                if (username == "user" && password == "password") {
                    loginState = LoginState.Success
                } else {
                    loginState = LoginState.Error("Invalid credentials")
                }
            }
        }
    }
}

sealed class LoginState {
    object Idle : LoginState()
    object Loading : LoginState()
    object Success : LoginState()
    data class Error(val message: String) : LoginState()
}

ViewModel은 다음과 같은 이유로 테스트하기 쉽다:

독립성: 이 뷰 모델은 네트워크 호출 등을 직접 다루지 않으며, 실제로는 테스트 시 모킹할 수 있는 간단한 상태 관리만 담당한다.
예측 가능성: 특정 입력을 주었을 때, 예측 가능한 상태 변화(예: 성공, 실패 등)가 발생한다.

아래처럼 ViewModel의 동작을 검증하며, 외부 의존성(API, 데이터베이스 등)이 적기 때문에 쉽게 외부 리소스를 모킹할 필요 없이 유닛 테스트를 진행할 수 있다. 따라서 유지보수향상 및 코드 품질을 높일 수 있게 된다.

class LoginViewModelTest {

    @Test
    fun `login with correct credentials should succeed`() {
        val viewModel = LoginViewModel()
        viewModel.username = "user"
        viewModel.password = "password"
        
        viewModel.login()
        
        assertEquals(LoginState.Success, viewModel.loginState)
    }

    @Test
    fun `login with incorrect credentials should fail`() {
        val viewModel = LoginViewModel()
        viewModel.username = "user"
        viewModel.password = "wrongpassword"
        
        viewModel.login()
        
        assertTrue(viewModel.loginState is LoginState.Error)
    }
}

3. 하나의 역할/책임 (Single Responsibility): 하나의 역할/책임 원칙은 컴포넌트가 하나의 기능 또는 역할만을 수행하도록 설계해야 한다는 원칙이다. 이 원칙을 따르면 컴포넌트가 간결해지고, 이해하기 쉬워진다. 따라서 독립적인 테스트가 될 수 있도록 설계하는 것인데 이것을 모듈화 및 간결성을 유지하여 구현할 수 있다.

예를 들어, 앱에서 사용자 프로필 화면을 구성한다고 가정해 보자. 이 화면에서는 사용자 아바타, 이름, 이메일을 표시하고, 사용자 정보를 편집할 수 있는 버튼을 제공한다고 할 때, 각 기능을 하나의 역할로 분리할 수 있다.

// 사용자의 아바타 이미지를 화면에 표시하는 역할
@Composable
fun UserProfileAvatar(avatarUrl: String) {
    Image(
        painter = rememberImagePainter(avatarUrl),
        contentDescription = null,
        modifier = Modifier.size(80.dp).clip(CircleShape)
    )
}

// 사용자의 이름과 이메일 정보를 표시하는 역할
@Composable
fun UserProfileInfo(name: String, email: String) {
    Column {
        Text(text = name, style = MaterialTheme.typography.h6)
        Text(text = email, style = MaterialTheme.typography.body2)
    }
}

// 사용자가 프로필을 편집할 수 있는 버튼을 표시하고, 클릭 이벤트를 처리하는 역할
@Composable
fun UserProfileEditButton(onEditClick: () -> Unit) {
    Button(onClick = onEditClick) {
        Text(text = "Edit Profile")
    }
}

이렇게 각 책임을 분리하고 결합을 통해 더 큰 컴포넌트나 하나의 화면을 구현할 수 있다.

// 사용자 프로필 화면을 구성
@Composable
fun UserProfileScreen(
    avatarUrl: String,
    name: String,
    email: String,
    onEditClick: () -> Unit
) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = Modifier.padding(16.dp)
    ) {
        UserProfileAvatar(avatarUrl = avatarUrl)
        UserProfileInfo(name = name, email = email)
        UserProfileEditButton(onEditClick = onEditClick)
    }
}

4. 변경에 유연 (Flexibility to Change): 변경에 유연한 컴포넌트는 내부 구현을 변경하더라도 외부에 미치는 영향을 최소화할 수 있어야 한다. 이는 컴포넌트의 내부 로직이 외부와 강하게 결합되지 않도록 설계해야 한다는 의미다. 이렇게 설계된 컴포넌트는 유지보수 시 변경이 쉽고, 다른 컴포넌트에 미치는 영향이 적어 유연하게 대응할 수 있다.

@Composable
fun CustomTextField(
    value: String,
    onValueChange: (String) -> Unit,
    label: String = "Enter text"
) {
    TextField(
        value = value,
        onValueChange = onValueChange,
        label = { Text(label) }
    )
}

5. 코드의 복잡성 (Complexity): 컴포넌트의 코드 복잡성을 낮추는 것은 재사용성을 높이기 위해 매우 중요하다. 코드가 복잡할수록 이해하기 어려워지고, 유지보수나 재사용이 힘들어지기 때문이다. 이를 해결하기 위해서 각 단위가 명확한 역할을 수행하도록 설계해야 한다. 내용을 보면 결국 이 때문에 재사용성이나 테스트, 역할 책임을 분담한다고도 볼 수 있다. 따라서 매우 중요한 개념이며 여기선 불필요한 복잡성을 줄이는 것이 목표다.

  • 예시: 게시물 목록을 표시하는 UI 컴포넌트
    예를 들어, 소셜 미디어 앱에서 사용자 게시물 목록을 표시하는 화면을 구성할 때, 이를 단일 컴포넌트로 작성하면 코드가 매우 복잡해질 수 있다. 따라서, 복잡성을 줄이기 위해 이를 작은 단위의 컴포넌트로 나누고, 각각의 컴포넌트가 명확한 역할을 수행하도록 설계할 수 있다.

1. 게시물 항목을 나타내는 컴포넌트 (PostItem)
2. 게시물 목록을 나타내는 컴포넌트 (PostList)
3. 게시물 목록 화면을 나타내는 컴포넌트 (PostScreen)

// 게시물 항목을 나타내는 컴포넌트
@Composable
fun PostItem(post: Post) {
    Column(modifier = Modifier.padding(8.dp)) {
        Text(text = post.author, style = MaterialTheme.typography.subtitle2)
        Spacer(modifier = Modifier.height(4.dp))
        Text(text = post.content, style = MaterialTheme.typography.body1)
        Spacer(modifier = Modifier.height(8.dp))
        Text(text = post.timestamp, style = MaterialTheme.typography.caption)
    }
}

// 게시물 목록을 나타내는 컴포넌트
@Composable
fun PostList(posts: List<Post>) {
    LazyColumn {
        items(posts) { post ->
            PostItem(post = post)
            Divider()
        }
    }
}

// 게시물 목록 화면을 나타내는 컴포넌트
@Composable
fun PostScreen(posts: List<Post>) {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Posts") }) },
        content = { PostList(posts = posts) }
    )
}

결국 이 모든 원칙이 얘기하는 것은 더 단순하고 이해하기 쉬운 코드를 작성하는 것, 모든 개발자가 가져야 하는 기본적인 역량에 대해서 얘기하고 있는 것이다. 따라서 모든 코드를 작성할 때 컴포넌트 기반이 아니더라도 가독성 좋은 코드를 작성하는 것은 필수불가결한 일이다.

컴포넌트를 만들 때 주의해야 하는 것

위에서 설명한 내용들을 반대로 생각하면 된다. 이들을 다시 언급하는 이유는, 컴포넌트를 만들기 전에 항상 구조와 설계를 신중하게 고려해야 하기 때문이다. 아래에서 언급한 내용들은 컴포넌트를 설계할 때 피해야 할 개념들이다.

1. 복잡한 컴포넌트: 복잡한 컴포넌트는 유지보수가 어렵고 이해하기 힘들어진다. 복잡도가 높아질수록 오류가 발생할 가능성도 커지고, 협업 시 의사소통 문제가 발생할 수 있다. 컴포넌트가 너무 복잡해지면 테스트와 디버깅이 힘들어지므로, 최대한 간결하고 명확하게 설계하는 것이 중요하다.

2. 하나의 컴포넌트에 여러 책임을 추가: 하나의 컴포넌트가 여러 책임을 가지게 되면, 유지보수가 어렵고 재사용이 힘들어진다. 컴포넌트는 단일 책임 원칙(Single Responsibility Principle)을 따르는 것이 좋다. 하나의 역할만을 수행하는 컴포넌트로 설계하면, 코드가 더 간결해지고, 나중에 수정할 때도 더 유연하게 대응할 수 있다.

3. 몇몇 동작하는 부분을 결합하여 컴포넌트 생성: 컴포넌트 내부에서 여러 동작을 결합하면, 해당 컴포넌트는 재사용성이 떨어지고 테스트하기 어려워진다. 각각의 동작을 독립된 작은 컴포넌트로 나누고, 필요한 경우에만 결합하는 것이 좋다. 이를 통해 동작을 테스트하기 쉽고, 유지보수나 변경 시에도 영향을 최소화할 수 있다.

4. 비즈니스 로직을 컴포넌트에 추가: 비즈니스 로직을 UI 컴포넌트에 포함하면, 코드가 복잡해지고 테스트가 어려워진다. 비즈니스 로직은 ViewModel이나 다른 비즈니스 로직 전용 클래스에 두는 것이 좋다. 컴포넌트는 화면에 대한 표현과 간단한 사용자 상호작용만 담당하고, 복잡한 로직은 별도의 계층에서 처리하는 것이 이상적이다.

참고자료

DroidKnights 2024
프론트엔드 아키텍처: 컴포넌트를 분리하는 기준과 방법

profile
안녕하세요. 개발에 이제 막 뛰어든 신입 개발자 입니다.

0개의 댓글