Jetpack Compose 초심자 가이드 5: 이벤트 처리와 UI 상호작용 🚀

윤성현·2025년 5월 25일
post-thumbnail

📌 개요

앞선 글에서는 상태를 정의하고 관리하는 방법, 그리고 상태 변화에 따라 UI가 자동으로 갱신되는 Recomposition 개념을 배웠습니다. 이제 상태 기반 UI의 흐름을 이해했으니, 다음으로는 사용자와 UI가 어떻게 상호작용하는지를 알아볼 차례입니다.

이번 글에서는 Compose에서 가장 기본적인 사용자 입력 이벤트인 클릭과 글자 입력 이벤트를 어떻게 다루는지 배우고, 사용자의 제스처(드래그, 스와이프 등)를 감지하고 반응하는 방법까지 단계적으로 살펴보겠습니다.


1. 클릭 이벤트 처리

1-1. 기본 버튼 클릭 처리: onClick

가장 대표적인 사용자 상호작용은 버튼 클릭입니다. Compose에서는 Button 컴포저블의 onClick 파라미터를 통해 클릭 이벤트를 처리합니다.

@Composable
fun ClickableButton() {
    var count by remember { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("클릭 횟수: $count")
    }
}
  • 사용자가 버튼을 클릭하면 onClick 블록이 실행되어 상태 count가 증가합니다.
  • 상태가 바뀌면 컴포저블 함수가 자동으로 UI를 재구성(Recomposition)하여 새로운 텍스트를 보여줍니다.

이런 구조 덕분에 버튼과 상태 관리가 직관적으로 연결되며, 버튼의 동작을 View나 Fragment에서 따로 처리할 필요가 없습니다.

1-2. 커스텀 뷰 클릭 처리: Modifier.clickable

버튼이 아닌 다른 요소도 클릭 가능하게 만들 수 있습니다. 이때는 Modifier.clickable을 사용합니다.

@Composable
fun ClickableText() {
    var clicked by remember { mutableStateOf(false) }

    Text(
        text = if (clicked) "클릭됨!" else "클릭해보세요",
        modifier = Modifier.clickable { clicked = !clicked }
    )
}

💡 clickable은 UI 요소에 클릭 동작을 부여해주는 Modifier입니다. Text, Box, Image 등 어떤 컴포저블에도 적용할 수 있으며, 기존 View 시스템의 setOnClickListener와 같은 역할을 합니다.

1-3. Button vs Modifier.clickable: 언제 어떤 걸 쓸까?

  • Button은 Material 디자인 컴포넌트로 시각적 스타일, 그림자, 클릭 피드백, 접근성 처리까지 포함된 완성된 UI 요소입니다.
  • 반면 Modifier.clickable은 다양한 컴포저블(Text, Box 등)에 행동만 추가할 수 있게 해주는 유연한 도구입니다.

✅ UI 가이드라인을 따르면서 사용자에게 명확한 피드백을 주고 싶다면 Button,
커스텀한 UI나 Box, 이미지 등에 동작을 입히고 싶다면 Modifier.clickable을 사용하는 것이 좋습니다.


2. 텍스트 입력 처리

2-1. TextField: 사용자 입력을 받는 컴포저블

앞선 글에서는 Text, Button, Column 등의 기본 컴포저블을 사용해 정적인 화면을 구성해봤습니다. 하지만 앱에서는 사용자로부터 정보를 입력받는 UI가 필요해지는 경우가 많습니다. 이때 주로 사용하는 것이 바로 TextField입니다.

TextField는 기존 View시스템의 EditText보다 더 확장된 역할을 합니다. 기존 View시스템의 TextInputLayoutTextInputEditText를 함께 사용하는 구조와 유사하게, 레이블(label), 에러 상태, 힌트 처리 등의 기능이 하나로 통합되어 있습니다. 사용자의 입력을 받아 상태에 저장하고, 그 상태를 기반으로 UI를 자동으로 갱신하는 구조를 가지고 있어, Compose의 선언형 UI 철학을 잘 반영하고 있습니다.

Compose에서 TextField는 보통 다음 두 가지 파라미터로 사용됩니다.

  • value: 입력 필드에 표시할 현재 상태값
  • onValueChange: 사용자가 입력을 변경할 때 호출되는 콜백
@Composable
fun InputGreeting() {
    var name by remember { mutableStateOf("") }

    Column {
        TextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("이름을 입력하세요") }
        )
        Text("안녕하세요, $name 님!")
    }
}

이 예제에서,

  • 사용자가 TextField에 글자를 입력하면 onValueChange가 호출됩니다.
  • 입력된 값은 name 상태에 저장됩니다.
  • name 상태는 다시 Text에 반영되어, 입력과 동시에 인사 메시지도 함께 바뀌게 됩니다.

✅ Compose에서는 이렇게 상태와 UI가 직접 연결되어 있기 때문에, 별도의 리스너나 설정 없이도 입력값을 그대로 화면에 반영할 수 있습니다.

2-2. 입력값 유효성 검사

앞서 살펴본 TextField는 사용자 입력을 상태에 연결해 화면에 반영하는 기본 흐름을 보여주었습니다. 이제는 여기에 한 걸음 더 나아가, 입력된 값이 유효한지 검사하고, 그 결과를 UI에 반영하는 방법을 알아보겠습니다.

예를 들어 사용자가 입력한 이름이 2자 이상일 때만 "유효한 이름입니다"라는 메시지를 띄우고, 그렇지 않으면 경고 메시지를 보여주는 방식입니다.

@Composable
fun InputGreeting() {
    var name by remember { mutableStateOf("") }
    val isValid = name.length >= 2

    Column {
        TextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("이름을 입력하세요") },
        )
        Text("안녕하세요, $name 님!")
        Text(
            text = if (isValid) "유효한 이름입니다" else "2글자 이상 입력해주세요",
            color = if (isValid) Color.Green else Color.Red
        )
    }
}

✅ 상태 기반 UI의 장점은 입력값이 바뀌면 관련된 모든 컴포저블이 자동으로 다시 실행된다는 점입니다. 따라서 조건문을 넣기만 하면 유효성 처리도 함께 따라옵니다.


3. 실습 예제: 입력값 검증과 UI 반영

앞에서 우리는 TextField를 통해 사용자 입력을 상태에 반영하고, 그 상태로 UI가 갱신되는 기본 흐름을 살펴봤습니다. 이제 이 개념과 지금까지 학습한 내용을 바탕으로 실제 회원가입 입력 폼을 만들어보는 실습을 해보겠습니다.

이번 예제에서는 다음과 같은 기능을 구현해볼 예정입니다.

  • 사용자가 이메일과 비밀번호를 입력하고,
  • 회원가입 버튼을 클릭했을 때만 입력값을 검증합니다.
  • 이메일 형식이 아니거나 비밀번호가 4자 미만이면 오류 메시지를 빨간색으로 표시하고,
  • 둘 다 유효할 경우 "회원가입 성공!"이라는 메시지를 출력합니다.

이를 통해 상태(state), 입력 이벤트(onValueChange), 클릭 이벤트(onClick), 조건부 UI 출력(if 블록)을 모두 실전에 가깝게 경험해볼 수 있습니다.

3-1. 예제 화면

3-2. 전체 코드 예제

@Composable
fun SignUpForm() {
		// 이메일, 비밀번호 값을 저장하기 위한 변수
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }

		// 제출하기 버튼 클릭 여부와 유효한 입력 상태를 관리하는 변수
    var isSubmitted by remember { mutableStateOf(false) }
    var isSuccess by remember { mutableStateOf(false) }

    // 유효성 검사 조건
    val isEmailValid = email.contains("@") && email.contains(".")
    val isPasswordValid = password.length >= 4

    Column(modifier = Modifier.padding(16.dp)) {
				// 이메일 입력란
        TextField(
            value = email,
            onValueChange = {
                email = it
                isSuccess = false // 입력 중에는 성공 메시지 숨기기
            },
            label = { Text("이메일") },
            isError = isSubmitted && !isEmailValid,
	          supportingText = {
			        if (isSubmitted && !isEmailValid) {
		            Text(
	                text = "유효한 이메일 형식을 입력해주세요.",
	                color = Color.Red,
	                fontSize = 12.sp
		            )
			        }
		        },
		         modifier = Modifier.fillMaxWidth(),
        )

        Spacer(modifier = Modifier.height(12.dp))

				// 비밀번호 입력란
        TextField(
            value = password,
            onValueChange = {
                password = it
                isSuccess = false // 입력 변경 시 성공 상태 초기화
            },
            label = { Text("비밀번호") },
            isError = isSubmitted && !isPasswordValid,
            visualTransformation = PasswordVisualTransformation(),
            supportingText = {
                if (isSubmitted && !isPasswordValid) {
                    Text(
                        text = "비밀번호는 4글자 이상이어야 합니다.",
                        color = Color.Red,
                        fontSize = 12.sp,
                    )
                }
            },
            modifier = Modifier.fillMaxWidth(),
        )
        
        Spacer(modifier = Modifier.height(20.dp))

        Button(
            onClick = {
                isSubmitted = true
                isSuccess = isEmailValid && isPasswordValid
            },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("회원가입")
        }

        if (isSuccess) {
            Spacer(modifier = Modifier.height(16.dp))
            Text(text = "회원가입 성공!", color = Color.Green)
        }
    }
}

3-3. 코드 설명

  • email, password: 사용자의 입력을 저장하는 상태
  • isSubmitted: 회원가입 버튼을 누른 후에만 에러 메시지를 보여주기 위한 플래그
  • isSuccess: 유효성 검사를 통과했는지 여부를 저장하는 상태
  • isError: TextField의 시각적 상태를 표시하기 위한 조건
  • supportingText: TextField 하단에 에러 메시지를 표시하는 용도로 사용. 조건문과 함께 사용하면 특정 상황에서만 메시지를 보여줄 수 있어 유효성 검사 UI에 적합합니다.

중요한 점은, 에러 메시지와 성공 메시지는 버튼을 누른 이후에만 나타나도록 isSubmitted상태로 제어한 것입니다. 이렇게 하면 사용자에게 불필요한 경고 없이, 의도된 타이밍에만 메시지를 보여줄 수 있습니다.

3-4. 실습 포인트

  • 입력값이 바뀌면 곧바로 상태를 업데이트하고, 조건을 만족하는지 실시간으로 계산합니다.
  • 단, 실제 메시지는 버튼을 눌러야 보여지므로 isSubmitted로 제어합니다.
  • 이처럼 상태를 중심으로 UI 흐름을 설계하는 것이 Compose의 가장 큰 특징이자 장점입니다.

4. 제스처 인식: 드래그, 스와이프, 탭 등

앱에서 사용자의 상호작용은 단순 클릭이나 입력을 넘어서, 드래그, 스와이프, 탭 등 다양한 제스처로 확장됩니다. Jetpack Compose는 이러한 제스처 이벤트를 Modifier 기반의 선언형 방식으로 매우 직관적으로 처리할 수 있습니다.

4-1. Modifier.pointerInput 사용하기

가장 기본적인 제스처 처리 방법은 Modifier.pointerInput을 사용하는 것입니다. 아래 예제는 사용자가 드래그하면 박스가 마우스나 손가락의 움직임에 따라 이동하도록 구현한 코드입니다.

@Composable
fun DraggableBox() {
    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }

    Box(
      modifier = Modifier
          .offset { IntOffset(offsetX.toInt(), offsetY.toInt()) }
          .size(100.dp)
          .background(Color.Blue)
          .pointerInput(Unit) {
              detectDragGestures { change, dragAmount ->
                  offsetX += dragAmount.x
                  offsetY += dragAmount.y
              }
          }
    )
}
  • 사용자가 박스를 드래그하면 dragAmount 만큼 상태 값이 갱신됩니다.
  • 이 값은 상태로 저장되어 박스 위치도 즉시 변경되며 UI가 자동으로 갱신됩니다.

📌 드래그 이벤트 처리 중 change.consume()을 호출하지 않으면, 나중에 다른 제스처(예: 스크롤)와 충돌할 수 있으니 상황에 따라 제어가 필요합니다.

4-2. 기타 제스처 감지 도구

Jetpack Compose에서는 드래그 외에도 다양한 제스처를 처리할 수 있는 도구를 제공합니다.

제스처 종류사용 도구설명
탭, 더블탭, 롱프레스detectTapGestures다양한 탭 동작을 감지할 수 있는 고수준 제스처 처리
스와이프Modifier.swipeable좌우로 슬라이드 가능한 UI에 활용 (토글, 카드 슬라이드 등)
확대/축소, 회전TransformableState두 손가락으로 확대/회전하는 인터랙션 구현
복합 스크롤NestedScrollConnection내부 스크롤과 외부 스크롤이 함께 작동해야 할 때 사용

✅ Compose는 물리적인 입력 흐름을 상태와 Modifier 조합으로 깔끔하게 처리할 수 있어, 더 유연하게 UI 반응을 구현할 수 있습니다.

4-3. Compose의 이벤트 흐름 요약

Jetpack Compose의 이벤트 처리 흐름은 매우 명확합니다:

  1. 사용자가 입력(클릭, 입력, 드래그 등)을 하면
  2. onClick, onValueChange, pointerInput 등 이벤트 핸들러가 실행되고
  3. 관련 상태 값이 변경되며
  4. 이 상태를 참조하던 컴포저블이 자동으로 Recomposition되고
  5. 최신 상태에 맞는 UI로 화면이 갱신됩니다.

💡 이 구조는 “입력 → 상태 변경 → UI 갱신”이라는 단방향 데이터 흐름에 기반합니다.


5. 정리

이번 글에서는 Jetpack Compose에서 사용자 입력을 처리하는 방법을 다양하게 실습해보았습니다. 클릭, 입력, 제스처 등 모든 상호작용은 상태와 연결되고, 상태 변화에 따라 UI가 자동으로 반응하는 구조는 Compose의 핵심 철학인 선언형 UI를 실감나게 보여줍니다.

상호작용 종류처리 방식
버튼 클릭onClick, Modifier.clickable
텍스트 입력TextField + onValueChange
드래그Modifier.pointerInput + detectDragGestures
탭/스와이프detectTapGestures, swipeable

Compose의 이벤트 처리 방식은 View 시스템보다 구조적이며, 유지보수도 쉽습니다. 앞으로 앱을 만들면서 다양한 입력 흐름을 설계할 때 큰 도움이 될 것입니다.


🎯 다음 글 예고: Scaffold로 시작하는 Compose 화면 구성

이번 글에서는 버튼 클릭, 입력 처리, 제스처 인식 등 다양한 사용자 입력 방식과 이를 상태와 연결하는 흐름을 배웠습니다. 이제 개별 요소를 넘어서 앱의 화면 전체를 구성하는 방법으로 넘어가보려 합니다.

다음 글에서는 Jetpack Compose에서 화면을 구성할 때 가장 기본이 되는 Scaffold 컴포저블을 중심으로, 앱의 기본 골격을 만드는 방법을 알아봅니다. TopAppBar, BottomNavigation, FAB 등 자주 쓰이는 UI 구성 요소들을 어떻게 배치하고 하나의 완성된 화면을 선언적으로 그려내는지 함께 살펴보겠습니다.

UI 구성의 구조적 접근을 시작하고 싶은 분들께 다음 글이 좋은 전환점이 될 것이라고 생각합니다. 기대해주세요! 🚀

1개의 댓글

comment-user-thumbnail
2025년 5월 30일

TextField의 유효성 검사의 경우 isError와 supportingText를 이용할 수도 있습니다. 그러나 TextField는 100% 커스터마이징이 어려워서 개인적으로는 BasicTextField를 사용하네요.

답글 달기