최종 업데이트: 2026년 9월 10일
SwiftUI에서 햅틱 피드백을 추가하는 가장 빠른 방법은 iOS 17에서 도입된 .sensoryFeedback(_:trigger:) 모디파이어를 사용하는 것이며, 커스텀 파형이나 오디오-햅틱 동기화가 필요하면 CoreHaptics의 CHHapticEngine과 AHAP(Apple Haptic and Audio Pattern) 파일로 확장합니다. 저는 지난 3년간 프로덕션 SwiftUI 앱에서 두 API를 모두 다뤄봤고, 이 글에서는 iOS 17~26 기준으로 언제 무엇을 써야 하는지, 어떤 인텐시티와 샤프니스 값이 실제로 자연스럽게 느껴지는지, 그리고 접근성과 배터리를 어떻게 고려해야 하는지 정리합니다.
sensoryFeedback 모디파이어 5분 만에 시작하기
iOS 17에서 SwiftUI가 자체 햅틱 API를 갖게 되면서, UIKit의 UIImpactFeedbackGenerator를 직접 임포트할 이유가 대부분 사라졌습니다. .sensoryFeedback 모디파이어는 트리거 값의 변화를 감지해 자동으로 햅틱을 재생하므로, 상태 관리와 부작용 발생 지점을 분리할 수 있습니다. iOS 26에서도 시그니처는 동일하지만 새 프리셋 몇 가지가 추가됐고, watchOS 10 이상, macOS는 트랙패드/포인터 기기에서 제한적으로 지원합니다.
가장 단순한 형태는 이렇습니다.
import SwiftUI
struct LikeButton: View {
@State private var likeCount = 0
var body: some View {
Button {
likeCount += 1
} label: {
Label("좋아요 \(likeCount)", systemImage: "heart.fill")
}
.sensoryFeedback(.impact(weight: .light, intensity: 0.7), trigger: likeCount)
}
}
여기서 핵심은 trigger가 Equatable이면 어떤 값이든 올 수 있다는 점입니다. Int 카운터, Bool 토글, 또는 커스텀 열거형까지 가능합니다. SwiftUI는 이전 값과 새 값이 다를 때만 피드백을 재생하므로 onChange 안에 수동으로 넣을 필요가 없습니다.
기본 제공되는 SensoryFeedback 케이스는 아래 표로 정리됩니다. 각 프리셋은 애플이 튜닝한 값이 들어 있으니 굳이 CoreHaptics로 재구현하지 마세요.
| 프리셋 | 용도 | 체감 강도 | 대체 UIKit API |
|---|---|---|---|
.success | 저장/완료/성공 알림 | 중간 (두 번 톡) | UINotificationFeedbackGenerator.success |
.warning | 주의가 필요한 상태 | 중간~강 (세 번 톡) | UINotificationFeedbackGenerator.warning |
.error | 실패/취소 | 강함 (긴 지연) | UINotificationFeedbackGenerator.error |
.impact(weight:intensity:) | 버튼 탭, 선택 확정 | weight × intensity로 조절 | UIImpactFeedbackGenerator |
.selection | Picker/스크롤 스냅 | 매우 약함 | UISelectionFeedbackGenerator |
.increase / .decrease | 스테퍼/슬라이더 방향성 | 약함, 방향감 있음 | iOS 17+ 신규 |
.pathComplete (iOS 17.2+) | 제스처 궤적 완료 | 중간 | iOS 17.2+ 신규 |
SwiftUI에서 햅틱 피드백을 어떻게 추가하나요?
단계별로 정리하면 다음과 같습니다. 이 워크플로는 새 앱에서든 기존 UIKit 앱에서 SwiftUI 화면을 추가하든 동일하게 적용됩니다.
- Info.plist는 필요 없습니다.
.sensoryFeedback은 시스템 API라 특별한 권한이 필요 없습니다. CoreHaptics도 마찬가지지만 진동 없는 기기(예: iPad, Mac)에서는 조용히 무시됩니다. - 트리거 값을 정의합니다.
@State,@Binding, 또는 @Observable 매크로 완벽 가이드에서 다룬 관찰 가능한 프로퍼티 모두 사용할 수 있습니다. - 모디파이어를 뷰에 붙입니다. 뷰 계층 어디에 붙여도 되지만, 관련 UI에 가장 가까운 뷰에 붙이는 편이 리팩터링에 강합니다.
- 조건부로 재생합니다.
trigger는Equatable만 요구하지만, 두 번째 오버로드는{ oldValue, newValue in Bool }클로저를 받아 조건 로직을 넣을 수 있습니다. - 기기에서 테스트합니다. 시뮬레이터는 햅틱을 재생하지 않습니다. 물리 기기에서만 느낌을 확인할 수 있습니다.
조건부 트리거의 실전 예시입니다.
@State private var scrollOffset: CGFloat = 0
private let snapThreshold: CGFloat = 80
var body: some View {
ScrollView(.horizontal) {
// ... 콘텐츠
}
.sensoryFeedback(.selection, trigger: scrollOffset) { oldValue, newValue in
// 임계값을 넘을 때만 발생 (계속 발생 방지)
let oldBucket = Int(oldValue / snapThreshold)
let newBucket = Int(newValue / snapThreshold)
return oldBucket != newBucket
}
}
이 패턴은 카루셀이나 커스텀 픽커에서 각 스냅 지점마다 .selection을 한 번씩만 재생하도록 보장합니다. 트리거 값이 매 프레임 갱신되더라도 클로저가 false를 리턴하면 무시됩니다.
sensoryFeedback와 CoreHaptics의 차이는 무엇인가요?
단순화하면 .sensoryFeedback은 프리셋 재생기, CoreHaptics는 파형 편집기입니다. 시스템 프리셋(누르기, 성공, 실패)은 애플이 iOS 팀 내부에서 튜닝한 값이 들어 있어 재현이 어렵고, 대부분의 UI 이벤트는 프리셋으로 충분합니다. 반면 게임의 총구 반동, 결제 완료 시 심장 박동 패턴, 텍스트 편집기의 커서 이동 클릭 같은 커스텀 파형은 CoreHaptics가 필요합니다.
결정 기준은 이렇게 정리할 수 있습니다.
| 기준 | .sensoryFeedback | CoreHaptics |
|---|---|---|
| 도입 iOS | 17 | 13 |
| API 스타일 | 선언형(모디파이어) | 명령형(엔진, 플레이어) |
| 커스텀 파형 | 불가 | 가능 (AHAP, 이벤트 배열) |
| 실시간 파라미터 변조 | 불가 | 가능 (sendParameters) |
| 오디오 동시 재생 | 불가 | 가능 (CHHapticAudioResource) |
| 백그라운드 라이프사이클 관리 | 자동 | 수동 (앱 상태 옵서버 필요) |
| 학습 곡선 | 낮음 | 중~높음 |
실무 팁 하나 더: 두 API는 공존할 수 있습니다. 앱 대부분은 .sensoryFeedback으로 처리하고, 특정 화면(게임 미니뷰, 시그니처 인터랙션)만 CoreHaptics로 만드는 것이 유지보수 부담이 가장 적었습니다. 프로젝트 초기부터 모든 곳에 CoreHaptics를 넣으면 엔진 재시작 처리, 스레딩, AHAP 파일 관리 오버헤드가 순식간에 커집니다.
CoreHaptics 엔진 설정과 라이프사이클
CHHapticEngine은 프로세스당 여러 개 만들 수 있지만, 대부분의 앱은 하나면 충분합니다. 문제는 엔진이 백그라운드 진입, 오디오 세션 인터럽션, 시스템 리소스 회수 등의 이유로 종종 정지된다는 점입니다. 정지 후 다시 재생하려고 하면 조용히 실패하므로 stoppedHandler와 resetHandler를 반드시 설정해야 합니다.
재사용 가능한 햅틱 매니저의 최소 구현은 다음과 같습니다.
import CoreHaptics
import Observation
@Observable
final class HapticsManager {
private var engine: CHHapticEngine?
private(set) var supportsHaptics: Bool = false
init() {
supportsHaptics = CHHapticEngine.capabilitiesForHardware().supportsHaptics
guard supportsHaptics else { return }
prepareEngine()
}
private func prepareEngine() {
do {
let engine = try CHHapticEngine()
engine.stoppedHandler = { reason in
// .audioSessionInterrupt, .applicationSuspended 등
print("Haptic engine stopped: \(reason)")
}
engine.resetHandler = { [weak self] in
// 서버 프로세스가 재시작된 경우, 엔진을 다시 start해야 함
try? self?.engine?.start()
}
try engine.start()
self.engine = engine
} catch {
print("Haptic engine init failed: \(error)")
}
}
func play(_ pattern: CHHapticPattern) {
guard supportsHaptics, let engine else { return }
do {
try engine.start() // 정지 상태면 다시 시작
let player = try engine.makePlayer(with: pattern)
try player.start(atTime: CHHapticTimeImmediate)
} catch {
print("Play failed: \(error)")
}
}
}
iOS 26에서는 CHHapticEngine.playsHapticsOnly가 기본적으로 true가 되어 오디오 세션을 점유하지 않도록 개선됐지만, 오디오와 햅틱을 함께 재생하려면 여전히 false로 명시해야 합니다.
커스텀 햅틱 패턴 만들기: transient와 continuous
CoreHaptics의 모든 파형은 두 가지 이벤트 타입의 조합으로 만들어집니다. Transient는 순간적인 톡(수 ms), Continuous는 지속적인 진동(밀리초~수 초)입니다. 각 이벤트는 intensity(강도)와 sharpness(선명도) 두 파라미터를 갖습니다. 인텐시티는 진폭, 샤프니스는 주파수 특성으로 이해하면 편합니다.
다음은 심장 박동 패턴(두 번 톡, 짧은 간격)입니다.
import CoreHaptics
func heartbeatPattern() throws -> CHHapticPattern {
let first = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.9),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3)
],
relativeTime: 0
)
let second = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.6),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3)
],
relativeTime: 0.18 // 180ms 간격 = 심박수 약 66bpm
)
return try CHHapticPattern(events: [first, second], parameters: [])
}
지속형 파형(예: 화면 밀어서 잠금 해제)의 인텐시티를 시간에 따라 변화시키려면 CHHapticParameterCurve를 사용합니다.
let continuous = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5)
],
relativeTime: 0,
duration: 1.5
)
// 인텐시티를 0 → 1 → 0으로 부드럽게 변화
let intensityCurve = CHHapticParameterCurve(
parameterID: .hapticIntensityControl,
controlPoints: [
.init(relativeTime: 0.0, value: 0.0),
.init(relativeTime: 0.75, value: 1.0),
.init(relativeTime: 1.5, value: 0.0)
],
relativeTime: 0
)
let pattern = try CHHapticPattern(events: [continuous], parameterCurves: [intensityCurve])
디자이너와 협업할 때는 인텐시티와 샤프니스 값을 말이 아니라 숫자로 공유하세요. "조금 더 부드럽게"는 재현이 불가능하지만 "샤프니스 0.3으로 낮춰보자"는 즉시 반영됩니다. 스프링 애니메이션에서 phaseAnimator와 keyframeAnimator를 다룰 때와 같은 원칙입니다.
AHAP 파일과 디자이너 협업 워크플로
AHAP(Apple Haptic and Audio Pattern)는 햅틱과 오디오를 함께 표현하는 JSON 포맷입니다. Xcode에는 별도 편집기가 없지만, 애플이 배포하는 공식 샘플 프로젝트와 함께 사용하는 것이 표준입니다. 디자이너가 .ahap 파일을 만들어 넘겨주면 개발자는 다음 한 줄로 로드합니다.
func loadPattern(named name: String) throws -> CHHapticPattern {
guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else {
throw NSError(domain: "Haptics", code: 404)
}
let data = try Data(contentsOf: url)
let dict = try JSONSerialization.jsonObject(with: data) as? [CHHapticPattern.Key.RawValue: Any] ?? [:]
return try CHHapticPattern(dictionary: dict)
}
AHAP 파일 예시(부드럽게 커지는 진동)입니다.
{
"Version": 1.0,
"Pattern": [
{
"Event": {
"Time": 0.0,
"EventType": "HapticContinuous",
"EventDuration": 2.0,
"EventParameters": [
{ "ParameterID": "HapticIntensity", "ParameterValue": 0.2 },
{ "ParameterID": "HapticSharpness", "ParameterValue": 0.5 }
]
}
},
{
"ParameterCurve": {
"ParameterID": "HapticIntensityControl",
"Time": 0.0,
"ParameterCurveControlPoints": [
{ "Time": 0.0, "ParameterValue": 0.0 },
{ "Time": 2.0, "ParameterValue": 1.0 }
]
}
}
]
}
이 방식의 장점은 명확합니다. 디자이너는 코드 빌드 없이 파형을 편집할 수 있고, 개발자는 파일 이름만 알면 됩니다. 저는 Haptics.xcassets 같은 별도 폴더에 .ahap 파일을 모아두고, enum HapticAsset: String { case checkoutSuccess = "checkout_success" } 형태로 타입 안전하게 관리합니다.
오디오와 햅틱 동기화하기
결제 완료음, 카메라 셔터, 게임 UI 등에서는 짧은 오디오와 햅틱을 정확히 같은 시점에 재생해야 합니다. AVAudioPlayer와 CHHapticEngine을 각각 트리거하면 스레딩과 오디오 세션 지연 때문에 20~50ms의 편차가 생기는데, 이 정도 차이도 사람은 "따로 재생됐다"고 인지합니다. 정확한 동기화는 CHHapticAudioResource로 오디오를 엔진에 등록하는 방식이 유일한 답입니다.
let audioURL = Bundle.main.url(forResource: "coin", withExtension: "wav")!
let audioResource = try engine.registerAudioResource(audioURL, options: [:])
let audioEvent = CHHapticEvent(
audioResourceID: audioResource,
parameters: [
CHHapticEventParameter(parameterID: .audioVolume, value: 0.8)
],
relativeTime: 0
)
let hapticEvent = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.9),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7)
],
relativeTime: 0
)
let pattern = try CHHapticPattern(events: [audioEvent, hapticEvent], parameters: [])
이 방식은 iPad처럼 햅틱이 없는 기기에서도 오디오는 정상 재생됩니다. 그리고 앱이 무음 모드 처리 정책을 결정할 수 있게 AVAudioSession을 .ambient 또는 .playback으로 설정해야 합니다. 자세한 세션 카테고리는 AVAudioSession 공식 문서를 참고하세요.
iPhone에서 햅틱이 안 될 때 디버깅
햅틱이 재생되지 않는 원인은 놀랍도록 다양합니다. 저희 팀에서 실제 겪은 사례를 빈도 순으로 정리했습니다.
- 시뮬레이터에서 테스트 중. 가장 흔한 원인입니다. iOS 시뮬레이터는 햅틱을 재생하지 않으니 물리 기기에 설치해야 합니다.
- 기기 설정에서 System Haptics OFF. 설정 → 사운드 및 햅틱 → 시스템 햅틱 토글이 꺼져 있으면
.sensoryFeedback은 무시됩니다. 반면CoreHaptics는 여전히 재생됩니다(이건 접근성 관점에서 논쟁의 여지가 있죠). - 진동 모드에서 무음. iPhone의 물리 링/무음 스위치가 무음이면 특정 프리셋(
.warning,.error)이 감쇠될 수 있습니다. - iPad에서 실행. iPad는 대부분 Taptic Engine이 없습니다.
CHHapticEngine.capabilitiesForHardware().supportsHaptics가false를 반환합니다. - 백그라운드 진입 후 엔진 재시작 누락.
resetHandler를 등록하지 않으면 백그라운드에서 복귀한 뒤player.start가 조용히 실패합니다. - 오디오 세션 인터럽션. 전화, 시리, 다른 앱의 오디오 재생으로 세션이 인터럽트되면 엔진이 정지합니다.
- 배터리 저전력 모드. 저전력 모드에서는 시스템이 햅틱을 감쇠하거나 억제할 수 있습니다.
접근성과 배터리 고려사항
햅틱은 시각/청각 정보를 보조하는 채널이지, 대체 채널이 아닙니다. 청각장애 사용자에게 알림 도착을 진동으로만 전달하는 것은 괜찮지만, 시각장애 사용자에게 "결제가 성공했음"을 진동만으로 알리면 안 됩니다. VoiceOver가 함께 읽어야 합니다. 애플의 Human Interface Guidelines의 햅틱 섹션은 이 원칙을 명시적으로 서술합니다.
실무 체크리스트입니다.
- 중복 채널 제공: 햅틱 + 시각적 상태 변화 + (필요 시) VoiceOver 안내. 세 채널 중 두 개는 반드시 있어야 합니다.
- 사용자 설정 존중: "시스템 햅틱" 토글이 꺼져 있으면
.sensoryFeedback은 자동으로 무시되지만, 커스텀CoreHaptics파형은 앱이 별도 설정 화면을 제공해야 합니다. iOS 17+에서는UIAccessibility.isReduceMotionEnabled가true일 때 햅틱도 함께 축소하는 것이 좋습니다. - 배터리: 지속형(
continuous) 파형을 1초 이상 매초 반복하면 명확히 배터리에 영향이 있습니다. 게임처럼 의도적으로 사용하는 경우가 아니라면 이벤트당 300ms 미만으로 유지하세요. - 스팸 방지: 스크롤 위치 변화마다
.selection을 재생하면 손목이 지칩니다. 위 예시처럼 임계값 기반 조건을 넣거나throttle로직을 추가하세요.
프로덕션 레시피: 스와이프, 롱프레스, 실시간 슬라이더
제가 실제 프로젝트에서 반복적으로 재사용하는 레시피 세 가지입니다. 각 예시의 파라미터 값은 여러 차례 사용자 테스트를 거쳐 정착된 값입니다.
1. Swipe-to-Delete 확인
@State private var swipeProgress: CGFloat = 0
@State private var didConfirm = false
var body: some View {
Rectangle()
.frame(height: 60)
.offset(x: -swipeProgress)
.gesture(
DragGesture()
.onChanged { value in
swipeProgress = min(abs(value.translation.width), 120)
if swipeProgress >= 120 && !didConfirm {
didConfirm = true
}
}
.onEnded { _ in
withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) {
swipeProgress = 0
}
didConfirm = false
}
)
.sensoryFeedback(.impact(weight: .medium, intensity: 0.8), trigger: didConfirm) { _, new in new }
}
여기서 스프링 값 response: 0.4, dampingFraction: 0.75는 UI 리바운드가 자연스럽게 느껴지는 조합입니다. response가 0.2 이하면 너무 빨라 튕겨나가는 느낌이 나고, 0.6 이상이면 굼떠 보입니다.
2. 롱프레스 게이지
@State private var pressProgress: CGFloat = 0
@State private var timerBucket: Int = 0
var body: some View {
Circle()
.trim(from: 0, to: pressProgress)
.stroke(lineWidth: 6)
.rotationEffect(.degrees(-90))
.frame(width: 80, height: 80)
.gesture(
LongPressGesture(minimumDuration: 1.2)
.updating($isPressed) { value, state, _ in state = value }
)
.onChange(of: pressProgress) { _, newValue in
timerBucket = Int(newValue * 4) // 4단계로 나눔
}
.sensoryFeedback(.selection, trigger: timerBucket)
.sensoryFeedback(.success, trigger: pressProgress) { _, new in new >= 1.0 }
}
같은 뷰에 .sensoryFeedback을 두 번 붙일 수 있다는 점이 핵심입니다. 진행 중에는 .selection으로 살짝살짝 톡톡 치고, 완료 시 .success로 마무리합니다.
3. 실시간 슬라이더의 값별 톡
@State private var volume: Double = 0.5
var body: some View {
Slider(value: $volume, in: 0...1, step: 0.1)
.sensoryFeedback(.increase, trigger: volume) { old, new in new > old }
.sensoryFeedback(.decrease, trigger: volume) { old, new in new < old }
}
.increase와 .decrease는 iOS 17에서 새로 추가된 방향성 있는 피드백입니다. Taptic Engine이 짧고 강한 톡(.increase) 또는 길고 무거운 톡(.decrease)으로 방향을 암시합니다. 슬라이더뿐 아니라 스테퍼, 세그먼트 컨트롤, 볼륨 조절 UI에서 모두 유용합니다.
더 깊이 있는 상태 관리와 결합하려면 NavigationStack 완벽 가이드에서 다룬 NavigationPath 변화에도 .sensoryFeedback을 붙일 수 있습니다. 화면 전환 시 미묘한 톡이 방향감을 크게 향상시킵니다.
자주 묻는 질문
iOS 16에서도 sensoryFeedback을 사용할 수 있나요?
아니요. .sensoryFeedback 모디파이어는 iOS 17에서 도입되었습니다. iOS 16 이하를 지원해야 한다면 UIImpactFeedbackGenerator, UISelectionFeedbackGenerator, UINotificationFeedbackGenerator를 SwiftUI에서 직접 호출해야 합니다. 조건부 분기는 if #available(iOS 17, *)로 처리하세요.
시뮬레이터에서 햅틱을 테스트할 수 있나요?
불가능합니다. iOS 시뮬레이터는 .sensoryFeedback과 CoreHaptics 모두 조용히 무시합니다. Xcode 콘솔에도 오류 메시지가 남지 않습니다. Taptic Engine이 탑재된 iPhone 실기(iPhone 7 이상)에서만 검증할 수 있습니다.
햅틱 패턴을 완전히 커스터마이징할 수 있나요?
네. CoreHaptics는 이벤트(CHHapticEvent) 배열과 파라미터 커브(CHHapticParameterCurve)를 조합해 임의의 파형을 만들 수 있습니다. AHAP JSON 파일로 저장해두면 디자이너와 협업하기 좋고, 코드 변경 없이 파형을 조정할 수 있습니다. 다만 프리셋 톡의 정확한 파형은 애플이 공개하지 않아서 완전히 동일하게 재현하기는 어렵습니다.
iPad에서 햅틱이 재생되지 않는 이유는 무엇인가요?
대부분의 iPad에는 Taptic Engine이 탑재되어 있지 않기 때문입니다. CHHapticEngine.capabilitiesForHardware().supportsHaptics가 false를 반환하므로 앱은 조용히 무시해야 합니다. 오디오 피드백이나 시각적 애니메이션으로 대체 채널을 제공하는 것이 UX 원칙입니다.
햅틱 사용이 배터리 소모에 얼마나 영향을 주나요?
단발 transient 이벤트는 배터리에 사실상 영향이 없습니다. 반면 continuous 이벤트를 초당 1회 이상 장시간 반복하면 측정 가능한 소모가 발생합니다. 게임처럼 의도적으로 지속 진동을 사용하는 경우가 아니라면, UI 피드백은 이벤트당 300ms 미만으로 유지하고 스크롤 이벤트마다 재생하지 않도록 임계값 로직을 넣으세요.
SwiftUI에서 UIKit 햅틱 API를 계속 사용해도 되나요?
iOS 17 이상만 지원한다면 .sensoryFeedback이 더 선언적이고 재사용하기 좋아 권장됩니다. 다만 UIImpactFeedbackGenerator는 prepare()로 엔진을 미리 워밍업할 수 있어 응답성 예측이 필요할 때는 여전히 유용합니다. 기존 UIKit 코드베이스에 SwiftUI를 점진적으로 도입하는 중이라면 두 API를 섞어 쓰는 것이 문제가 되지 않습니다.




