본문 바로가기
카테고리 없음

안드로이드 UI - 1

by 화릿불 2025. 9. 21.

 

오늘은 안드로이드의 UI에 대해 알아볼 것이다. 워낙에 양이 많이 부분이니 조금씩 나누어서 보게 될 것 같다.

UI, 오늘날의 사용자 인터페이스는 GUI 형태로 제공되는 경우가 많다. 이에 사용되는 컴포넌트에 대해서 알아보자.

크게 보면 안드로이드의 UI는 "뷰"와 "레이아웃"으로 정의된다.

뷰는 TextView, ImageView 등 다양하게 존재할 수 있는 그래픽의 요소, 컴포넌트들이고 레이아웃은 이 뷰들을 화면에 어떻게 배치할지 결정하는 역할을 한다.

 

뷰 (View)

안드로이드의 모든 UI 컴포넌트들은 View 클래스를 근본으로 한다.

즉, 뷰 클래스는 모든 UI 컴포넌트의 부모 클래스이며 모든 UI 컴포넌트들은 이 뷰가 확장된 형태이다. 따라서 기본적인 속성들을 모두 공유하고 있으며 이 속성은 화면에 어떻게 보일지 크기나 색깔을 정할 수도 있다.

 

뷰의 컴포넌트들에 대해 본격적으로 알아보기 전에 근본적인 의문이 든다.

안드로이드에서 뷰가 화면에 그려지는 과정은 어떻게 진행되는 걸까?


보통 이 과정은 크게 3단계로 나누어지며 렌더링 파이프라인(Rendering Pipeline)이라고 불린다.

1단계 : 측정 (Measure)

각 뷰의 크기를 결정하는 단계이다. 부모 뷰는 자식 뷰에게 '너는 어떤 크기를 원하니?'라고 물어보고, 자식 뷰는 자신의 콘텐츠 크기나 레이아웃 매개변수(match_parent, wrap_content, 고정 크기 등)에 따라 원하는 크기를 계산하여 부모에게 알려준다. 부모 뷰는 이 정보를 바탕으로 자식 뷰들이 차지할 영역을 조절한다. 이 과정은 부모 뷰에서 시작하여 모든 자식 뷰에게 전달되는, 하향식(top-down) 방식으로 진행된다.

2단계 : 배치 (Layout)

각 뷰가 화면의 어느 위치에 놓일지 결정하는 단계이다. 측정 단계에서 결정된 크기를 바탕으로, 부모 뷰는 자식 뷰에게 '너는 (x, y) 좌표부터 시작해서 너의 크기만큼 그려져라'라고 명령한다. 이 단계에서 뷰의 정확한 위치가 계산되는데 이 역시 하향식으로 진행된다.

3단계 : 그리기 (Draw)

실제로 뷰를 화면에 그리는(렌더링하는) 단계이다. 배치 단계에서 확정된 위치와 크기 정보를 바탕으로 각 뷰는 자신을 캔버스(Canvas)에 그린다. 이 과정은 부모 뷰부터 자식 뷰의 순서로 진행된다. 예를 들어, 부모 뷰인 ConstraintLayout이 먼저 배경을 그리고, 그 위에 자식 뷰인 TextView와 Button이 텍스트와 버튼 이미지를 그리는 방식이다.

 

결국, 모든 화면을 그리는 과정은 하향식 방식으로 크기를 계산하고 크기에 맞춰 레이아웃에 어떻게 보일지 배치하고 최종적으로 렌더링 하는 과정을 거치는 것이다.


 

XML과 Compose

현대의 Compose 이전 안드로이드에서는 Java와 XML을 이용해서 화면을 구성했다.

예를 들어, android:layout_width="wrap_content"와 같은 속성들은 뷰의 너비를 내용물의 크기에 맞추라는 의미였다. 이 방식은 레이아웃과 로직이 분리되어 있어 구조를 파악하기는 쉽지만, 복잡한 UI를 구성할 때 코드가 매우 길어지고 유지보수가 어렵다는 단점이 있다.

 

반면, Jetpack Compose는 안드로이드 UI를 구축하는 선언형(declarative) 방식의 새로운 프레임워크이다. 더 이상 XML 파일을 사용하지 않고, Kotlin 코드만으로 UI를 직접 구성할 수 있다. 여기서 View의 역할을 하는 것이 바로 Composable 함수이다.

Composable 함수는 UI 요소를 화면에 그리는 역할을 한다. TextView는 Text()로 ImageView는 Image()라는 Composable함수로 대체된다. 

// XML
<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="안녕하세요!"
    android:padding="16dp" />
// Compose
@Composable
fun MyTextComponent() {
    Text(
        text = "안녕하세요!",
        modifier = Modifier
            .wrapContentWidth()
            .padding(16.dp)
    )
}

 

위 두 코드를 보면 알겠지만 첫 번째 코드가 XML 방식이고 두 번째 코드가 Composable 방식이다.

앞서 설명했듯이 Compose는 Text() 함수를 사용해 UI를 직접 코드로 선언하고, Modifier를 체인 형태로 연결하여 다양한 속성을 적용했다. 이 점이 기존 XML 방식과 가장 큰 차이이자 기본이다.

현재 두 코드는 모두 같은 UI를 그리고 있는데 아직은 요소가 많지 않아 그렇게 복잡해보이지 않는다. 

하지만, 우리가 보는 화면의 UI는 저렇게 단순하지 않기 때문에 요소가 많으면 많을수록 Jetpack의 Compose 방식이 훨씬 간결하게 작성되며 코틀린 코드로 작성되기 때문에 개발자가 보기에 아주 직관적이다.

 

특히 Modifier는 Composable 함수의 외형, 레이아웃, 그리고 동작을 정의하는 객체인데 기존 XML 방식에서 android:layout_width, android:padding, android:onClick 등 흩어져 있던 여러 속성들을 하나의 체인(Chain) 형태로 묶어주는 역할을 합니다.

이 체인 형태는 디자인 패턴 중 "빌더" 패턴이 적용된 대표적인 예이다. UI를 형성하는데 필요한 요소를 개발자가 선택적으로 만들 수 있는데 그렇다 보니 Modifier에 속성을 적는 순서가 매우 중요하다.

@Composable
fun ModifierExample() {
    Text(
        text = "Hello, Compose!",
        modifier = Modifier
            .padding(16.dp)  // 1. 패딩을 먼저 적용
            .background(Color.Yellow) // 2. 그 다음 배경색 적용
    )
}

@Composable
fun ModifierExampleReversed() {
    Text(
        text = "Hello, Compose!",
        modifier = Modifier
            .background(Color.Yellow) // 1. 배경색을 먼저 적용
            .padding(16.dp)  // 2. 그 다음 패딩을 적용
    )
}

 

이렇게 순서가 다른 Modifier는 다음과 같은 결과를 만들어낸다.

 

인간의 심미적 관점에서는 당연하다면 당연하다는 생각이 드는데 컴퓨터는 결국 객체 생성을 위해 모든 정보를 한번에 받아들이고 결정, 생성할 거라고 생각했는데 이상하다. 이유가 뭘까?

 

각 Modifier는 이전 Modifier가 반환하는 결과를 감싸는(wrapping) 형태로 적용된다. 따라서 padding이 먼저 적용되면 padding이 적용된 영역에 배경색이 칠해지고, background가 먼저 적용되면 배경색이 칠해진 영역에 padding이 적용됩니다. 이는 개발자가 Modifier를 조합하는 순서에 따라 뷰의 최종 모습이 달라지는 이유이다.

이렇게 Modifier의 적용 순서에 따라 보이는 UI가 완전히 달라질 수 있으니 주의하자.

 

Text(TextView) & TextField(EditText)

가장 먼저 볼 것은 가장 기본 요소인 텍스트 관련이다.

Text는 화면에 텍스트를 표시하기 위한 컴포넌트이다. 사용자 입력은 받지 않는다. 단순히 정보를 보여주기 위한 용도로 사용된다.

 

TextField는 Text와 달리 사용자로부터 텍스트를 입력받는 컴포넌트이다.

시시각각 변화하는 사용자의 입력을 어떻게 동적으로 기억하고 보여주는 걸까? 이러한 동적 동작을 위해서 TextField는 State를 저장하고 기억해야 한다.

@Composable
fun MyTextField() {
    var text by remember { mutableStateOf("") } 

    TextField(
        value = text,
        onValueChange = { newText -> text = newText },
        label = { Text("이름을 입력하세요") }
    )
}

Compose에서는 이를 상태 호이스팅(State Hoisting)이라고 부른다. 상태 호이스팅은 상태와 UI를 분리하여 컴포저블의 재사용성 테스트 용이성을 높이는 Jetpack Compose의 핵심 디자인 패턴이다. 현재 MyTextField 예시처럼 TextField 내부에 상태를 두는 대신, 상태를 부모 컴포저블로 끌어올려 상태와 이벤트를 분리하는 것이 이상적인 방식이다.

이처럼 사용자가 실시간으로 컴포넌트에 입력값을 넣으면 onValueChange라는 콜백 함수가 실행되어 실시간으로 text 변수에 저장되는 형태이다.

 

Button, onClick & Image(ImageView)

다음은 사용자에게 시각적으로 보이고 상호작용할 수 있는 컴포넌트들이다.

기본적으로 Button은 사용자가 눌렀을 때 특정 동작이 실행되는 우리가 익히 알고 있는 컴포넌트이다.

Jetpack Compose는 OutlineButton(테두리만 있음), TextButton(텍스트만 있음), IconButton(아이콘만 있음)처럼 다양한 버튼을 제공하니 본인이 필요한 버튼을 잘 찾아서 이용하면 되겠다.

 

기존 시스템에서 버튼은 리스너를 통해 제어되었다. 이 방식은 콜백 기반으로 사용자의 특정 행동이 발생하면 미리 정해둔 함수, 로직을 호출하는 방식이다. 순서는 다음과 같다.

 

  1. 사용자가 화면의 버튼을 터치하면, 안드로이드 시스템은 터치 이벤트를 감지한다.
  2. 이 이벤트는 터치된 좌표에 있는 뷰(Button)에게 전달된다.
  3. Button 뷰는 내부적으로 터치 이벤트를 처리하여 onClick과 같은 리스너가 등록되어 있는지 확인한다.
  4. 개발자가 미리 setOnClickListener를 통해 리스너를 등록해 두었다면, 해당 리스너의 onClick() 메서드 콜백으로 호출된다.
  5. 이 콜백 함수 내부의 로직(예: 다른 화면으로 이동, 데이터 저장 등)이 실행된다.

 

이 과정은 명령형이다. 개발자가 button.setOnClickListener {... }와 같이 "무엇을 할지"를 직접 명령한다. 이 방식은 UI와 로직이 분리되어 있어 구조 파악은 쉽지만, 복잡한 상태에 따라 버튼의 동작을 변경하려면 setEnabled(false)와 같은 추가적인 명령을 사용해야 한다.

 

반면 Compose에서 onClick은 단방향 데이터 흐름에 기반한 선언적 방식인 함수형 매개변수로 전달된다.

  1. 사용자가 버튼을 클릭하면, Button 컴포저블의 onClick 람다 함수가 실행된다.
  2. 이 람다 함수는 이벤트를 발생시켜 상태를 업데이트한다.
  3. 예를 들어, var count by remember { mutableStateOf(0) }라는 상태가 있고, 버튼의 onClick에서 count++를 수행하면, 상태가 변경되는 것이다.
  4. Compose 프레임워크는 상태 변경을 감지하여 count를 사용하는 모든 UI를 자동으로 재구성(Recomposition)한다.

이 방식은 선언형이다. 개발자는 "어떤 상태일 때 어떤 UI를 그릴지"만 선언한다. 예를 들어, Button(enabled = count < 5)와 같이 count가 5보다 작은 상태처럼 상태에 따라 버튼의 활성화 여부를 선언할 수 있는 것이다.

 

 

Image는 화면에 이미지를 표시하는 컴포넌트이다.

painter라는 개념을 사용해 이미지를 그리는데 painterResource라는 속성을 활용해 안드로이드의 res폴더에 있는 drawable 이미지를 손쉽게 이용할 수 있다.

painter를 이용해 "손쉽게 이미지를 그린다고 했는데 그 과정은 어떻게 이루어지는 걸까?" 하는 의문이 생겼다.

 


조사해 보니 전통적인 안드로이드 View 시스템에서 이미지를 그리는 과정은 다음과 같았다.

  1. 비트맵(Bitmap) 객체 로딩: 먼저, JPG, PNG와 같은 이미지 파일을 메모리에 Bitmap 객체로 로딩한다. Bitmap은 픽셀 단위의 이미지 데이터를 메모리에 저장하는 클래스이다.
  2. 메모리 관리: 이미지 파일은 용량이 클 수 있어서 OOM(Out Of Memory) 오류를 방지하기 위해 여러 가지 최적화 기법을 사용해야 한다.
    • 샘플링: 이미지를 화면에 필요한 크기만큼만 로딩하기 위해 inSampleSize 옵션을 사용.
    • 캐싱: 자주 사용하는 이미지는 메모리나 디스크에 캐싱하여 재사용.
  3. ImageView에 설정: 로딩된 Bitmap을 ImageView의 setImageBitmap() 메서드를 통해 설정한다.
  4. 그리기(Draw): ImageView는 내부적으로 onDraw() 메서드에서 Bitmap을 캔버스에 그린다.

이 과정은 개발자가 직접 메모리 관리, 비동기 로딩, 캐싱 등을 신경 써야 하는 단점이 있다. 복잡한 이미지 로딩 로직을 직접 구현해야 하므로 코드가 길어지고 오류가 발생하기 쉽다. 하지만, Jetpack Compose는 이러한 복잡함을 Painter라는 개념으로 해결했다.

 

Painter는 화면에 이미지를 그리는(Paint) 역할을 하는 추상화된 클래스이다. 개발자는 더 이상 복잡한 Bitmap객체를 직접 다루거나 메모리 관리에 신경 쓸 필요가 없다. 대신, Image() 컴포저블 함수에 painter 객체를 전달하여 이미지를 그리게 되었다.

예를 들어, res/drawable 폴더에 있는 이미지를 그릴 때 painterResource() 함수를 사용하는데, 이 함수는 내부적으로 다음과 같은 역할을 수행한다다.

  1. 리소스 ID 전달: painterResource(id = R.drawable.my_image)를 호출하면, 리소스 ID만 Painter에게 전달된다.
  2. Painter 객체 생성: painterResource() 함수는 해당 리소스 ID를 가진 Painter 객체를 반환한다. 이 객체는 실제 이미지 데이터를 메모리에 올릴 필요 없이 그리기 방법을 알고 있는 상태다.
  3. 그리기 위임: Image() 컴포저블은 전달받은 Painter 객체의 draw() 메서드를 호출하여 이미지를 캔버스에 그리는 것을 위임합니다.

이 방식의 가장 큰 장점은 관심사가 분리된다. 아래를 보자.

  • 개발자: 이미지를 '어떻게' 그릴지에 대해 고민하지 않고, '무엇을' 그릴지에만 집중할 수 있게 된다.
  • 시스템: Painter가 실제 픽셀 로딩, 메모리 관리, 캐싱 등 복잡한 내부 로직을 처리한다.

이렇게 Painter를 사용하면 개발자는 더 이상 Bitmap을 직접 다루지 않고도 다양한 소스(drawable, 네트워크 URL, 로컬 파일 등)의 이미지를 손쉽게 그릴 수 있게 되는 것이다. AsyncImage 라이브러리가 대표적인 예로, 네트워크 URL에 있는 이미지를 알아서 로딩하고 캐싱하여 Painter로 변환해 준다. 결론적으로, painter는 안드로이드의 복잡한 이미지 렌더링 파이프라인을 간소화하여, 개발자가 UI 로직에 더 집중할 수 있도록 돕는 추상화 개념이다.


 

Dialog & Toast, SnackBar

다음 컴포넌트들은 팝업이나 알림과 관련된 컴포넌트들이다.

먼저, Dialog는 사용자에게 정보를 알리거나 결정을 유도하는 모달(Modal) 형태의 팝업이다. 모달이란 팝업이 떠 있는 동안 사용자가 다른 화면을 터치할 수 없기 때문에 사용자의 특정 상호작용을 강제하는 것을 의미한다.

 

사용자에게 무조건적인 상호작용을 강제하는 것이 어떻게 가능한 걸까? 화면에 보이는 z 축을 이용해서 무조건적으로 눌릴 수밖에 없게 만들었을까? 다른 액티비티들이 어떻게 무시되는 걸까?


var isDialogOpen by remember { mutableStateOf(false) }

Button(onClick = { isDialogOpen = true }) { Text("알림 띄우기") }

if (isDialogOpen) {
    AlertDialog(
        onDismissRequest = { isDialogOpen = false },
        title = { Text("알림") },
        text = { Text("이것은 경고 메시지입니다.") },
        confirmButton = {
            Button(onClick = { isDialogOpen = false }) { Text("확인") }
        }
    )
}

 

기존 View 시스템에서 Dialog는 AlertDialog.Builder를 사용해 복잡한 콜백과 상태 관리가 필요했다. 과정은 다음과 같다.

Activity Context를 기반으로 Dialog 객체를 생성하고 show() 메서드를 호출한다. Dialog는 자체적인 Window를 가지며, 이 윈도우가 현재 액티비티 윈도우 위에 겹쳐서 나타난다. 액티비티의 뷰 계층과 독립적으로 별도의 윈도우에 그려지기 때문에 다른 뷰보다 항상 위에 표시되는 것이다.

 

반면 Jetpack Compose의 Dialog는 위 코드처럼 isDialogOpen과 같은 상태(State) 변수를 사용하여 팝업의 표시 여부를 선언적으로 관리한다. 상태에 따라 컴포넌트의 표시 여부를 결정할 수 있는 것이다. Compose에서도 기존과 동일하게 Dialog를 생성한다. 하지만 기존 뷰 시스템과 다르게 프래그먼트나 액티비티 생명주기를 직접 관리할 필요가 없고 상태를 통해 손쉽게 제어가 가능하다.

즉 기존 시스템과 Compose의 차이는 제어 방식에 있다. 기존의 명령형과 현대의 선언형의 차이가 있으며 특히 상태를 통해 관리될 경우, 'mutableStateOf' 같은 상태 변수를 저장하는 것과 함께 쓰이는데 바로 이 것이 상태가 변경될 때 자동으로 재구성(Recomposition)해주는 트리거이자 핵심 요소라는 것을 알아두자.

 

Compose 코드를 조금 더 살펴보면 마치 일반적인 코드 로직처럼 UI가 사용되는 모습을 볼 수 있다.

이처럼 개발자는 if문으로 상태만 변경하면 되고, 팝업이 화면에 나타나고 사라지는 복잡한 과정은 Compose 프레임워크가 알아서 처리하게 된다. 이는 모두 UI 상태를 코드로 명시하는 선언형 UI의 강력한 장점이자 특징이다.

 


 

Toast는 사용자에게 짧은 메시지를 띄우는 가벼운 알림이다. 일정 시간 후 자동으로 사라지며 사용자의 상호작용을 요구하지 않는다. Snackbar는 Toast와 유사하지만 화면 하단에 나타나며 액션(Action) 버튼을 추가할 수 있어 사용자와의 상호작용이 가능하다.

둘 모두 사용자에게 짧은 피드백 메시지를 표시하는 컴포넌트이다.

 

Toast는 기존 뷰 시스템과 Compose에서 변함이 없다. Toast는 안드로이드의 시스템 기능이기 때문이다.

Toast 역시 Dialog처럼 액티비티의 뷰 계층에 속하지 않고, WindowManager를 통해 시스템 윈도우 위에 직접 그려진다. 이 때문에 앱이 백그라운드로 전환되거나 액티비티가 종료되어도 메시지가 잠시 표시될 수 있다. 가령 예를 들면 분명 앱을 종료했음에도 카카오톡 알림이나 송금 완료 알림이 오는 것이 이에 해당한다.

 

Snackbar는 지금까지와는 다소 다르다.

기존 시스템에서 스낵바는 CoordinatorLayout라는 특정 레이아웃에 포함되어 표시된다. 앞선 컴포넌트들과 달리 현재 액티비티의 뷰 계층에 포함되어 있는 것이다. 즉, Dialog나 Toast는 화면에 '떠 있는 팝업'이지만 Snackbar는 화면의 일부이다.

반면, Compose에서는 SnackbarHostState를 생성하고 이벤트 발생(상태 변경) 시 코루틴을 활용해 스낵바를 표시한다. Compose의 스낵바는 기본적인 머티리얼 디자인 레이아웃 구조(앱바, 플로팅 액션 버튼, 스낵바 등)를 쉽게 구성할 수 있도록 돕는 컴포저블인 Scaffold 컴포넌트의 일부로 정의되어 있는데 기존 시스템의 CoordinatorLayout과 유사한 역할을 Scaffold가 호스팅 하며 상태 변경 시 재구성이 발생해 스낵바가 화면에 나타나는 방식이다. 이는 기존 시스템이 양방향으로 데이터 바인딩에서 단방향 데이터 흐름으로 상태를 안전하게 관리하도록 해주는 차이가 있다.