
동기
Jetpack Compose 에서는 툴팁을 사용해본 적이 없었는데요. 진행 중인 사이드 프로젝트에서 구현할 일이 생겨 사용해보게 되었습니다. UX 요구 사항은 해당 툴팁이 자연히 사라져서도 안 되고, 툴팁이 떠 있다고 해서 다른 컴포넌트와 상호 작용이 불가해서도 안 됩니다.
모바일 환경에는 Hover 기능이 없다보니, 대부분 버튼을 짧게 터치하거나 길게 터치했을 때 툴팁이 표시 되었다가 이후 다시 툴팁을 터치하거나 손을 떼었을 때 사라지게 되는 형태가 대부분인데요. 제가 구현해야 했던 것은 특정 상황에서 자동으로 표시되어야 했습니다. 사라지는 것도 해당 툴팁 내의 버튼을 터치했을 때에만 사라져야 하고요. 이와 같이, 보통의 툴팁과는 조금 다른 방식으로의 구현이 필요해 보였습니다.
Jetpack Compose 의 툴팁과 UI / UX 요구 사항의 괴리
먼저, UI / UX 요구 사항은 다음과 같습니다.
- 정해진 위치에 표시되어야 한다.
- 툴팁의 닫기 버튼을 누르기 전까지 사라져서는 안 된다.
- 툴팁이 표시되고 있어도 다른 컴포넌트와 상호 작용할 수 있어야 한다.
Jetpack Compose 의 TooltipBox 로 구현하기에는 2번과 3번이 조금 애매했습니다. TooltipBox 는 툴팁을 포함한 어디를 터치하더라도 툴팁이 사라지도록 설계되어 있기 때문입니다. 즉, 툴팁의 닫기 버튼이 아닌 다른 곳을 누르면 사라지기 때문에, 그대로 사용하면 2번과 3번을 만족시키기 어렵게 됩니다.
그래서 일단 다른 방법을 물색해봤는데요. 그냥 Box 나 Row 등을 이용해서 나이브하게 구현하는 방법이 있었고, 이와 같은 상황에 적용하기 좋은 Popup 으로 구현하는 방법이 있었습니다.
하지만 근본적인 문제가 있었는데요. 두 방법 모두 요구 사항 1번을 만족시키기 애매하다는 것이었습니다. 해당 화면의 UI 를 구성하기 전부터 툴팁이 있었다면 그냥 Box 나 Row 로 구현했을 것 같은데, 추후에 생긴 기능이라 바로 구현하기 애매했거든요. 그렇다고 Offset 을 사용하자니, 화면을 벗어날 우려가 있어 이 역시 문제가 있었습니다. Popup 을 사용하면 더 복잡해지는데, 기존에 구현했던 코드에 Box 를 하나 더 추가하여 Depth 가 증가하는 문제가 있었습니다.
즉, 다른 Composable 의 영역과의 경합이 없도록 고유한 영역을 가지지 않되, 화면 밖으로 벗어나지 않아야 하며, 또 다른 화면에서 역시 툴팁 구현이 요구될 수 있으므로 특정 Composable 과 우아하게 커플링되어야 하고, 코드 Depth 가 크게 증가하지 않아야 하는, 아주 복잡한 Composable 이 필요하게 되었습니다.
그래서 내린 결론이, 'TooltipBox 에 적용되는 State 를 고쳐서 사라지지 않게 만들어보자' 였습니다. 다만, 그 전에 'TooltipBox 에 선언한 Tooltip 은 크기에 따라 화면을 벗어나는가?' 를 테스트하여야 합니다.
상단의 빨간 사각형이 Anchor, 하단의 검은 사각형이 Tooltip 입니다. LiveEdit 을 활성화하고 Tooltip Composable 의 크기를 점진적으로 증가시켜봤고, 화면 밖으로 넘어가지 않는 것을 확인할 수 있습니다. (물론 화면 전체 가로 길이보다 크면 넘어갑니다.)
커스텀 TooltipState 구현하여 문제 해결하기
일단, 기본 TooltipState 를 그대로 적용하면 아래 영상과 같습니다. isPersistent 파라미터를 true 로 줘서 시간이 지나도 사라지지 않게는 할 수 있는데, 다른 곳을 터치하면 사라집니다.
UX 요구 사항은 화면의 다른 곳을 터치해도 해당 툴팁이 사라져서는 안 되기 때문에, 이를 위해 사라지는 메서드를 수정해야 한다고 생각했습니다.
override fun dismiss() {
transition.targetState = false
if (isPersistent) {
job?.cancel()
}
}
그런데 웬 걸, dismiss() 메서드의 코드에는 타이머 역할을 수행하는 객체나 로직이 없었습니다. 즉, 툴팁의 제거가 아니라 생성에 문제 해결의 열쇠가 있음을 의미합니다. 다만 재미있는 점은, 단순히 targetState 만 변경하는 것이 아니라 Coroutines Job 을 취소하고 있다는 것입니다. 이로부터 우리는 툴팁을 표시하는 프로세스가 Coroutines 내에서 진행되며, 화면에 툴팁을 유지하기 위해 해당 Coroutine 이 일시정지 되어 있음을 유추할 수 있습니다.
override suspend fun show(mutatePriority: MutatePriority) {
val cancellableShow: suspend () -> Unit = {
suspendCancellableCoroutine { continuation ->
transition.targetState = true
job = continuation
}
}
// Show associated tooltip for [TooltipDuration] amount of time
// or until tooltip is explicitly dismissed depending on [isPersistent].
mutatorMutex.mutate(mutatePriority) {
try {
if (isPersistent) {
cancellableShow()
} else {
withTimeout(BasicTooltipDefaults.TooltipDuration) { cancellableShow() }
}
} finally {
if (mutatePriority != MutatePriority.PreventUserInput) {
// timeout or cancellation has occurred and we close out the current tooltip.
dismiss()
}
}
}
}
툴팁을 화면에 표시하는 show() 메서드 본문입니다. 아주 흥미로운 부분이 있는데, 툴팁의 표시와 해제에 Mutex 가 사용되고 있습니다. 단순히 UI 상태를 제어하는 프로세스인데 왜 Mutex 가 필요할까요? 이는 Material Design Guide 를 보면 알 수 있는데, 툴팁은 언제나 단 하나만 표시되어야 하기 때문입니다.

이에 대한 구현을 위해, Jetpack Compose 에서 기본적으로 제공하는 TooltipStateImpl 의 경우, object 내 선언된 전역 Mutex 를 기본 인자로 넘겨주고 있습니다.
/*
TooltipStateImpl
*/
@Composable
@ExperimentalMaterial3Api
fun rememberTooltipState(
initialIsVisible: Boolean = false,
isPersistent: Boolean = false,
mutatorMutex: MutatorMutex = BasicTooltipDefaults.GlobalMutatorMutex,
): TooltipState =
remember(isPersistent, mutatorMutex) {
TooltipStateImpl(
initialIsVisible = initialIsVisible,
isPersistent = isPersistent,
mutatorMutex = mutatorMutex,
)
}
/*
GlobalMutatorMutex
*/
internal object BasicTooltipDefaults {
/** The global/default [MutatorMutex] used to sync Tooltips. */
val GlobalMutatorMutex: MutatorMutex = MutatorMutex()
/**
* The default duration, in milliseconds, that non-persistent tooltips will show on the screen
* before dismissing.
*/
const val TooltipDuration = 1500L
}
이 전역 MutatorMutex 를 할당함으로써, 모든 툴팁을 하나의 Mutex 로 관리할 수 있도록 합니다. Mutex 는 단 하나만이 공유 자원을 점유할 수 있음을 보장하기 때문에, Material Design Guideline 이 제시하는 'Only display one tooltip at a time' 을 아주 쉽게 달성할 수 있습니다.
툴팁은 우선권이 매우 높은 UI 컴포넌트 중 하나입니다. 그래서 suspendCancellableCoroutine() 메서드로 툴팁을 표시하는 작업이 완료되면 Coroutine 을 대기시키고, 시간이 지나거나 유저 입력이 발생하면 Coroutine 을 취소시켜 이를 해제하는 것입니다. 이러한 과정에서 중복 생성이나 중복 제거를 통한 상태 데이터 꼬임과 오류를 방지하기 위해 Mutex 를 사용하고 있습니다. Snackbar, Toast 에서도 역시 이와 같은 구조가 적용돼 있고요.
하지만 저에게 중요한 것은 공유 자원에 대한 관리가 아니라, Tooltip 을 표시하는 과정을 행위가 아니라 상태로 제어하는 것입니다. 그러므로 Mutex 와 Coroutines 등의 코드를 완전히 제거하고, transition 내부의 targetState 만을 제어하는 방식으로 구현합니다. 코드는 다음과 같습니다.
@OptIn(ExperimentalMaterial3Api::class)
@Stable
class PersistentTooltipStateImpl(
initialIsVisible: Boolean = false,
) : TooltipState {
override val transition = MutableTransitionState(initialIsVisible)
override val isPersistent: Boolean = true
override val isVisible: Boolean
get() = transition.currentState || transition.targetState
@Deprecated(
level = DeprecationLevel.WARNING,
message = "This method will not work properly. use forceDismiss() instead.",
replaceWith = ReplaceWith("forceDismiss()")
)
override fun dismiss() = Unit
@Deprecated(
level = DeprecationLevel.WARNING,
message = "This method will not do anything."
)
override fun onDispose() = Unit
override suspend fun show(mutatePriority: MutatePriority) {
transition.targetState = true
}
suspend fun forceShow() = show(MutatePriority.Default)
fun forceDismiss() {
transition.targetState = false
}
}
show() 메서드는 애초에 suspend 키워드가 붙었기 때문에, 어쩔 수 없이 forceShow() 메서드에도 suspend 가 붙습니다. dismiss() 메서드는 외부 터치 시 자동으로 호출되기 때문에, 내용을 비워두고 @Deprecated 어노테이션을 붙여 호출하지 않도록 알렸습니다.
결과
결과적으로, 이 코드는 잘 동작합니다. 화면 밖으로 툴팁이 잘리지도 않고, Anchor 와 잘 붙어있어 다른 화면에서도 추가로 툴팁을 구현해야 할 때 그대로 사용할 수 있습니다. 다른 Composable 과 상호 작용 하여도 그대로 화면에 표시되며, 닫기 버튼을 눌러야만 툴팁을 제거할 수 있고, 전역 MutatorMutex 를 사용하지 않기에, 한 화면에 여러 개의 툴팁을 구현할 수도 있겠네요.
하지만 코드를 다시 보면 마음 한구석이 찝찝합니다. 동시성 제어를 위한 Coroutines 와 Mutex 는 걷어냈고, 인터페이스의 몇 메서드는 작동하지 않는, ISP 와 LSP 를 정면으로 위반하는 코드를 만들어냈으니까요.
애초부터 단순한 말풍선 UI 를 구현해야 한다는 사실만 있었는데, 그걸 억지로 툴팁에 끼워맞춘 제 무지의 소산일 수도 있겠습니다.
여기서 쉽사리 대답하기 힘든 난제가 다시 한 번 등장합니다. 문제를 해결하기 위한 원칙을 고수하며 복잡도를 감수하는 게 맞는 선택이었을까요, 아니면 요구 사항을 보다 빠르고 심플하게 해결하는 지금의 원칙 위반 커스텀 구현이 맞는 선택이었을까요? 어쩌면 이 문제를 완벽하게 해결할 수 있는 또 다른 대안이 있었는지도 모를 일입니다.
'좋은 코드'란 과연 원칙을 지키는 코드일까요, 아니면 비즈니스의 문제를 군더더기 없이 해결해 주는 코드일까요?
이 질문은 '코드'를 '개발자'로 바꿀 수 있는 질문이기도 합니다.
결국, 둘 다 할 줄 알되, 개인이면 개인, 조직이면 조직 단위에서 우선시하는 방향에 가중치를 두는 게 맞는 방법이지 않나 싶습니다.
긴 글 읽어주셔서 감사합니다.
'Android > Trouble Shoot' 카테고리의 다른 글
| [Jetpack Compose] 동적 스켈레톤 적용기 (0) | 2025.12.27 |
|---|---|
| [Jetpack Compose] SubcomposeLayout 으로 UI 배치 문제 해결하기 (0) | 2025.11.28 |
| [Jetpack Compose] NestedScroll 로 UX 개선하기 (0) | 2025.08.10 |
| [Jetpack Compose] onGloballyPositioned 를 onLayoutRectChanged 로 대체하기 (0) | 2025.07.16 |
| Appium + UiAutomator2 로 Android-Web 통합 테스트 자동화하기 (0) | 2025.06.05 |