[Android] HapticFeedback 시스템 설계

Daemon·2025년 11월 2일

Android

목록 보기
3/13
post-thumbnail

들어가며

2025년 4월부터 iPhone에 입문하게 되면서 기존까지 사용했던 Android 기기와는 많이 다르다는 것을 느꼈다.

애플의 여러 가지 섬세한 설계 중에서 진동 효과가 인상 깊었는데, Android에서는 근본적으로 iOS를 따라가는 것이 불가능할까라는 생각에서 출발해서 공부하게 되었다.

더 나아가서 앱 아키텍쳐에서 HapticFeedback 시스템을 어떻게 설계해야 되는지 고민해보았던 여정을 공유하겠다.

쪼금 더 나아가서 최근 맥북 프로 M4를 결제했다.


1. iOS Taptic Engine vs Android Vibration: 철학의 차이

1.1 iOS의 Taptic Engine: 정교함의 극치

Apple은 iPhone 6s부터 Taptic Engine을 도입했다.

이는 단순한 진동 모터가 아닌, 선형 액추에이터(Linear Actuator)를 사용하여 정밀한 촉각 피드백을 제공한다.

2014년까지는 애플도 Nidec나 AAC Technologies 같은 진동 모터 제조 업체한테 공급받았었지만 팀 내에서 직접 설계하지 않으면 원하는 경험을 제공할 수 없겠다고 판단했다.

이 또한 스티브 잡스의 정신이 깃들어있다고도 볼 수 있다.

그래서 알고리즘을 자체 제작하고 업체로부터 전용 생산 라인을 얻어내고 특허 기술까지 냈을 정도로 R&D 사업에도 굉장한 자금을 쏟아냈던 것으로 알고 있다.

// iOS - UIKit
import UIKit

// 사전 정의된 피드백 타입
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
impactFeedback.impactOccurred()

let selectionFeedback = UISelectionFeedbackGenerator()
selectionFeedback.selectionChanged()

let notificationFeedback = UINotificationFeedbackGenerator()
notificationFeedback.notificationOccurred(.success)

iOS의 특징:

  • 하드웨어 수준의 일관성: 모든 최신 iPhone이 동일한 Taptic Engine 사용
  • 사전 정의된 패턴: .light, .medium, .heavy, .soft, .rigid 등
  • 시스템 설정 연동: 사용자가 시스템 설정에서 햅틱을 껐다면 자동으로 비활성화
// iOS 13+ CoreHaptics - 완전한 커스터마이징
import CoreHaptics

let engine = try CHHapticEngine()
try engine.start()

let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5)
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0)
let event = CHHapticEvent(eventType: .hapticTransient, parameters: [sharpness, intensity], relativeTime: 0)

let pattern = try CHHapticPattern(events: [event], parameters: [])
let player = try engine.makePlayer(with: pattern)
try player.start(atTime: 0)

1.2 Android의 Vibrator/VibrationEffect: 파편화

iOS와는 다르게 제품 생산 라인이 수직 통합되어있는 구조가 아니다보니까 기기별 파편화를 대응해야한다는 운명을 갖고 있는 것 같다.

iOS는 선형 액추에이터(Linear Actuator) 기반이라서 질량체가 직선으로 움직이면서 정밀한 진동을 생성하고, 요약하자면 커스텀할 수 있는 진동의 스펙트럼이 넓다고 볼 수 있다.

하지만 Android에서는 ERM (Eccentric Rotating Mass), LRA (Linear Resonant Actuator), Piezo Actuator 등등 다양한 하드웨어가 혼재되어있고 그마저도 iOS에 비하면 퀄리티가 그리 높지 않다는 것이 함정이다.

전통적인 ERM (Eccentric Rotating Mass) 모터는 편심 추를 회전시키는 것이 기본 골자이기에 응답시간이 길고 정밀도가 낮았지만,

Apple Taptic Engine은 전자기력을 바탕으로 코일 내에 네오디뮴 자석 질량체를 움직여서 조절하고 스프링 시스템까지 적용해서 복원력까지 세밀하게 커버한다.

그러다보니 응답시간도 짧아지고 더 세밀하게 제어할 수 있게 되다보니 지금과 같은 퀄리티를 가졌다.

// Android - 세대별 API 진화

// API 1-25: 단순 진동 시간만 제어
vibrator.vibrate(100) // 100ms 진동

// API 26+ (Oreo): VibrationEffect 도입
val effect = VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)
vibrator.vibrate(effect)

// API 26+ (Oreo): 진동 강도 제어
val effect = VibrationEffect.createOneShot(100, 50) // 100ms, amplitude 50 (1-255)

// API 31+ (Android 12): VibrationEffect Composition
val effect = VibrationEffect.startComposition()
    .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.5f)
    .addPrimitive(VibrationEffect.Composition.PRIMITIVE_TICK, 1.0f)
    .compose()

// API 31+ (Android 12S): VibratorManager
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
val vibrator = vibratorManager.defaultVibrator

Android의 특징:

  • 하드웨어 파편화: Galaxy는 강력한 진동, Pixel은 정교한 진동, 저가형은 기본 모터
  • API 파편화: API 26 이전/이후, API 31 이전/이후가 완전히 다름
  • 제조사 커스터마이징: Samsung, Xiaomi 등이 자체 진동 시스템 추가

2. 아키텍처 관점: HapticFeedback

2.1 첫 번째 고민: "이게 비즈니스 로직인가?"

Domain Layer는 순수 Kotlin을 보장해야하기 때문에 Presentation이 맞다고 판단했다.

또한, Toast, Snackbar, 애니메이션, 음향효과와 성격이 유사하기 때문에 Domain은 고려 대상이 전혀 아니었다.

2.2 두 번째 고민: "Android API 파편화를 어떻게 처리할 것인가?"

각자 프로젝트에서 앱의 최소 SDK는 API 28 정도를 사용할 수도 있고 아닐 수도 있지만 다양한 버전을 지원해야한다.

core 모듈에 있는 것이 바람직하고 API 버전 체크는 중앙에서 처리를 하는 것이 맞기 때문에 Haptic을 담당하는 manager 클래스를 생성해서 관리한다.

중앙화된 Manager 패턴

@Singleton
class HapticFeedbackManager @Inject constructor(
    @param:ApplicationContext private val context: Context
) {
    private val vibrator: Vibrator? by lazy {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
            vibratorManager?.defaultVibrator
        } else {
            @Suppress("DEPRECATION")
            context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
        }
    }

    fun perform(type: HapticFeedbackType) {
        try {
            if (vibrator?.hasVibrator() != true) {
                Timber.w("Vibrator not available")
                return
            }

            vibrator?.vibrate(type.toVibrationEffect())
        } catch (e: Exception) {
            Timber.e(e, "Failed to perform haptic feedback")
        }
    }

    fun cancel() {
        try {
            vibrator?.cancel()
        } catch (e: Exception) {
            Timber.e(e, "Failed to cancel haptic feedback")
        }
    }
}

2.3 세 번째 고민: "어떤 햅틱 패턴을 정의할 것인가?"

소프트웨어 레벨에서 iOS에서의 API는 의도 중심으로 설계가 되어있어서 개발자 친화적인, 다시 말해서 DX가 좋다.

UIImpactFeedbackGenerator의 style 속성을 light, heavy, soft 이런 방식으로 정의할 수 있는데, Android는 진동 강도, 진동 시간과 같은 파라미터 중심으로 설계가 되어있어서 직접 정의내려야한다.

최종 결정:

sealed interface HapticFeedbackType {
    fun getLegacyDuration(): Long
    fun toVibrationEffect(): VibrationEffect

    data object Light : HapticFeedbackType {
        override fun getLegacyDuration(): Long = 5L
        override fun toVibrationEffect(): VibrationEffect =
            VibrationEffect.createOneShot(5, 50)
    }

    data object Tab : HapticFeedbackType {
        override fun getLegacyDuration(): Long = 10L
        override fun toVibrationEffect(): VibrationEffect =
            VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE)
    }

    data object Medium : HapticFeedbackType {
        override fun getLegacyDuration(): Long = 30L
        override fun toVibrationEffect(): VibrationEffect =
            VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE)
    }

    data object Success : HapticFeedbackType {
        override fun getLegacyDuration(): Long = 100L

        override fun toVibrationEffect(): VibrationEffect =
            VibrationEffect.createWaveform(
                longArrayOf(0, 20, 50, 20),
                intArrayOf(0, 150, 0, 200),
                -1
            )
    }

    data object Heavy : HapticFeedbackType {
        override fun getLegacyDuration(): Long = 50L

        override fun toVibrationEffect(): VibrationEffect =
            VibrationEffect.createOneShot(50, 255)
    }
}

설계 원칙:

  1. Sealed Interface: 타입 안전성 + 확장 가능
  2. iOS 스타일 차용: Light, Medium, Heavy로 직관적 명명

2.4 네 번째 고민: "Compose에서 어떻게 깔끔하게 사용할 것인가?"

HapticFeedback이 적용되는 모든 Composable에 매니저를 주입할 수는 없으므로 Props Drilling을 방지하기 위해서 CompositionLocal을 사용했다.

CompositionLocal + Hilt EntryPoint

val LocalHapticFeedback = staticCompositionLocalOf<HapticFeedbackManager> {
    error("HapticFeedbackManager not provided")
}

@EntryPoint
@InstallIn(SingletonComponent::class)
interface HapticFeedbackManagerEntryPoint {
    fun hapticFeedbackManager(): HapticFeedbackManager
}

@Composable
fun ProvideHapticFeedback(
    content: @Composable () -> Unit
) {
    val context = LocalContext.current
    val hapticManager = remember {
        EntryPointAccessors.fromApplication(
            context.applicationContext,
            HapticFeedbackManagerEntryPoint::class.java
        ).hapticFeedbackManager()
    }

    CompositionLocalProvider(
        LocalHapticFeedback provides hapticManager,
        content = content
    )
}

@Composable
fun rememberHapticFeedback(): HapticFeedbackManager {
    return LocalHapticFeedback.current
}

3. 성능 고려사항

3.1 LaunchedEffect vs SideEffect

// SideEffect는 매 Recomposition마다 실행
SideEffect {
    if (selectedIndex != null) {
        hapticFeedback.perform(HapticFeedbackType.Light) // 너무 자주 실행됨
    }
}

// LaunchedEffect는 key가 변경될 때만 실행
LaunchedEffect(selectedIndex) {
    if (selectedIndex != null) {
        hapticFeedback.perform(HapticFeedbackType.Light) // selectedIndex가 바뀔 때만
    }
}

3.2 ApplicationContext 사용 이유

@Singleton
class HapticFeedbackManager @Inject constructor(
    @param:ApplicationContext private val context: Context // ← Application Context!
) {}

왜 ApplicationContext인가?

Activity Context를 Singleton 클래스가 주입받고 있으면 GC되지 않는 문제가 있기 때문에 ApplicationContext를 사용했다.

// Activity Context + Singleton = 메모리 누수
@Singleton
class HapticFeedbackManager(
    @ActivityContext private val context: Context // Activity가 계속 메모리에 남음
)

// Application Context + Singleton = 안전
@Singleton
class HapticFeedbackManager(
    @ApplicationContext private val context: Context // 앱과 생명주기 일치
)

마치며

재미로 개발을 했을 때는 좋은 것이 좋은 것이다라는 막연한 생각을 주로 했었는데, Android Haptic은 기기별 파편화가 심하기 때문에 OS 레벨에서도 통합을 시켜주지 않은 것을 효율성을 따지는 기업에서 관리를 한다는 것은 어려운 결정이라는 생각을 했다.

거대한 모바일 시장에서 애플이 차지하는 파이가 큰 이유도 이러한 디테일에 투자하는 것이 곧 미래에 투자하는 것이라는 판단으로 세밀하게 설계하는 것도 똑똑한 결정인 것 같다.

요 근래 들어서 시간이 참 소중하다는 것을 느끼는 것 같다. 그렇다해서 이제 벌써 몇 살이네와 같은 애늙은이 같은 말로 치환되지는 않지만, 주어진 것에 만족하기보다는 감사함을 느낄 필요가 있는 것 같다. 어느 하나를 취하면 어느 하나를 잃고 어떤 문이 닫히면 다른 문이 열리듯이, 모든 것을 취할 수 없는 불완전한 존재라는 사실을 받아들이고 있다. 그래서 누가 삶은 선택의 연속이라고 했던 것 같다.

그래서! 각설하고 내 다짐이다. 블로그 글은 남들이 보기 때문에 메모장, 일기장이 아닌 시간을 들여 읽어볼 가치가 있어야한다고 누가 그랬다.

이걸 시작한 이유는 순전히 기록하는 습관을 들이기 위함이었지만 이제는 독자까지 고려해서 작성하는 것이 맞다.


참고 자료:

2개의 댓글

comment-user-thumbnail
2025년 11월 3일

오호 HapticManager CompositionLocal 쓰는 아이디어 좋네요
TimePicker 컴포넌트 휠 돌리는 때 햅틱 피드백 줄 때
휠 돌려서 값이 바뀔때마다 ViewModel에 Intent 날리고 그에 따른 갱신을 했던 적이 있어서 ViewModel 단에서 hapticManager 쓴 적도 있었는데... 지금 생각해보면 비즈니스 로직은 아니여서 UI 단에 두는게 더 나았을 거 같네요

1개의 답글