2025년 4월부터 iPhone에 입문하게 되면서 기존까지 사용했던 Android 기기와는 많이 다르다는 것을 느꼈다.
애플의 여러 가지 섬세한 설계 중에서 진동 효과가 인상 깊었는데, Android에서는 근본적으로 iOS를 따라가는 것이 불가능할까라는 생각에서 출발해서 공부하게 되었다.
더 나아가서 앱 아키텍쳐에서 HapticFeedback 시스템을 어떻게 설계해야 되는지 고민해보았던 여정을 공유하겠다.
쪼금 더 나아가서 최근 맥북 프로 M4를 결제했다.

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의 특징:
// 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)
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의 특징:
Domain Layer는 순수 Kotlin을 보장해야하기 때문에 Presentation이 맞다고 판단했다.
또한, Toast, Snackbar, 애니메이션, 음향효과와 성격이 유사하기 때문에 Domain은 고려 대상이 전혀 아니었다.
각자 프로젝트에서 앱의 최소 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")
}
}
}
소프트웨어 레벨에서 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)
}
}
설계 원칙:
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
}
// SideEffect는 매 Recomposition마다 실행
SideEffect {
if (selectedIndex != null) {
hapticFeedback.perform(HapticFeedbackType.Light) // 너무 자주 실행됨
}
}
// LaunchedEffect는 key가 변경될 때만 실행
LaunchedEffect(selectedIndex) {
if (selectedIndex != null) {
hapticFeedback.perform(HapticFeedbackType.Light) // selectedIndex가 바뀔 때만
}
}
@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 레벨에서도 통합을 시켜주지 않은 것을 효율성을 따지는 기업에서 관리를 한다는 것은 어려운 결정이라는 생각을 했다.
거대한 모바일 시장에서 애플이 차지하는 파이가 큰 이유도 이러한 디테일에 투자하는 것이 곧 미래에 투자하는 것이라는 판단으로 세밀하게 설계하는 것도 똑똑한 결정인 것 같다.
요 근래 들어서 시간이 참 소중하다는 것을 느끼는 것 같다. 그렇다해서 이제 벌써 몇 살이네와 같은 애늙은이 같은 말로 치환되지는 않지만, 주어진 것에 만족하기보다는 감사함을 느낄 필요가 있는 것 같다. 어느 하나를 취하면 어느 하나를 잃고 어떤 문이 닫히면 다른 문이 열리듯이, 모든 것을 취할 수 없는 불완전한 존재라는 사실을 받아들이고 있다. 그래서 누가 삶은 선택의 연속이라고 했던 것 같다.
그래서! 각설하고 내 다짐이다. 블로그 글은 남들이 보기 때문에 메모장, 일기장이 아닌 시간을 들여 읽어볼 가치가 있어야한다고 누가 그랬다.
이걸 시작한 이유는 순전히 기록하는 습관을 들이기 위함이었지만 이제는 독자까지 고려해서 작성하는 것이 맞다.
참고 자료:
오호 HapticManager CompositionLocal 쓰는 아이디어 좋네요
TimePicker 컴포넌트 휠 돌리는 때 햅틱 피드백 줄 때
휠 돌려서 값이 바뀔때마다 ViewModel에 Intent 날리고 그에 따른 갱신을 했던 적이 있어서 ViewModel 단에서 hapticManager 쓴 적도 있었는데... 지금 생각해보면 비즈니스 로직은 아니여서 UI 단에 두는게 더 나았을 거 같네요