1. 개요
기존에 웹사이트를 WebView 기반 iOS 앱으로 사용 중인 프로젝트에서
다음 요구사항이 추가되었다.
웹에서 버튼을 누르면
→ iOS 네이티브에서 음성 녹음
→ 녹음 파일(m4a) 생성
→ 서버로 업로드
→ 업로드 결과를 다시 웹으로 전달
이 글은 처음 프로젝트를 받은 상태에서,
실제 구현 과정에서 겪은 문제들과 해결 방법을 정리한 것이다.
2. 전체 구조 요약
전체 흐름
Web(JavaScript)
→ WKWebView messageHandler 호출
iOS (WKWebView)
→ 메시지 수신
→ VoiceRecording 녹음 start/stop
→ 파일 생성
→ 서버로 multipart 업로드
→ 결과를 JavaScript 콜백으로 전달
3. Web → iOS 브릿지 구성
웹(JavaScript)에서 호출
// 녹음 시작
webkit.messageHandlers.voiceRecordStart.postMessage(data);
// 녹음 종료
webkit.messageHandlers.voiceRecordStop.postMessage(data);
메시지 수신
if message.name == "voiceRecord" {
if let command = message.body as? String {
guard let data = message.body as? [String: Any],
let paramAA = data["paramAA"] as? String
else {
return
}
handleVoiceRecordCommand(paramAA, data: data)
}
}
4. VoiceRecordingClass – 음성 녹음 구현
음성 녹음 클래스
final class VoiceRecordingClass {
private var recorder: AVAudioRecorder?
private(set) var audioFileURL: URL?
func start() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
AVAudioSessionCategoryPlayAndRecord,
with: [.defaultToSpeaker]
)
try session.setMode(AVAudioSessionModeDefault)
try session.setActive(true)
let fileName = "voice_\(Int(Date().timeIntervalSince1970)).m4a"
let documents = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
)[0]
let fileURL = documents.appendingPathComponent(fileName)
audioFileURL = fileURL
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
]
recorder = try AVAudioRecorder(url: fileURL, settings: settings)
recorder?.prepareToRecord()
recorder?.record()
}
func stop() -> URL? {
recorder?.stop()
recorder = nil
try? AVAudioSession.sharedInstance().setActive(false)
return audioFileURL
}
}
stop() 시점에 녹음 파일 URL을 반환하도록 설계
외부에서는 URL을 직접 수정할 수 없도록 private(set) 사용
5. stop 시 서버 업로드 연결
명령 처리 로직
func handleVoiceRecordCommand(_ command: String, data: [String: Any]) {
switch command {
case "voiceRecordstart":
try? voiceRecorder.start()
case "voiceRecordstop":
guard let fileURL = voiceRecorder.stop() else { return }
guard
let paramA = data["paramA"] as? String,
let paramB = data["paramB"] as? String
else {
return
}
voiceRecorder.uploadVoiceFile(
fileURL: fileURL,
paramA: paramA,
paramB: paramB
) { success, message in
self.sendUploadResultToWeb(success: success, message: message)
}
default:
break
}
}
6. 서버 업로드 (multipart/form-data)
extension VoiceRecordingClass {
func uploadVoiceFile(
fileURL: URL,
paramA: String,
paramB: String,
completion: @escaping (Bool, String) -> Void
) {
let serverURL = URL(string: AppConfig.voiceUploadURL)!
var request = URLRequest(url: serverURL)
request.httpMethod = "POST"
let boundary = UUID().uuidString
request.setValue(
"multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type"
)
var body = Data()
func appendField(_ name: String, _ value: String) {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append(
"Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n"
.data(using: .utf8)!
)
body.append("\(value)\r\n".data(using: .utf8)!)
}
appendField("paramA", paramA)
appendField("paramB", paramB)
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append(
"Content-Disposition: form-data; name=\"file\"; filename=\"\(fileURL.lastPathComponent)\"\r\n"
.data(using: .utf8)!
)
body.append("Content-Type: audio/m4a\r\n\r\n".data(using: .utf8)!)
body.append(try! Data(contentsOf: fileURL))
body.append("\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
URLSession.shared.dataTask(with: request) { _, _, error in
DispatchQueue.main.async {
if let error = error {
completion(false, error.localizedDescription)
} else {
completion(true, "ok")
}
}
}.resume()
}
}
7. 업로드 결과를 웹으로 전달
func sendUploadResultToWeb(success: Bool, message: String) {
let js = """
window.onVoiceUploadResult &&
window.onVoiceUploadResult({
success: \(success ? "true" : "false"),
message: "\(message)"
});
"""
webView.evaluateJavaScript(js, completionHandler: nil)
}
웹(JavaScript)에서 수신
window.onVoiceUploadResult = function (result) {
if (result.success) {
alert("음성 업로드 완료");
} else {
alert("업로드 실패");
}
};
8. 작업 중 발생한 오류와 주의점
- as! 강제 캐스팅 사용
let value = data["key"] as! String
WebView / 서버 데이터는 항상 nil 또는 타입 변경 가능
as! 사용 시 런타임 크래시 발생
- 안전한 파싱 방식
guard let value = data["key"] as? String else { return }
정리
이 방식의 장점
✔ 기존 WebView 구조 유지
✔ 네이티브 기능 최소 침투
✔ 확장성 높은 설계
✔ 실기기 테스트 안정적
WebView 기반 iOS 앱에서 네이티브 기능을 연동할 때 사용
'Programmer > 모바일' 카테고리의 다른 글
| 모바일 담당자 없는 프로젝트에서 GPT로 FFmpegKit 크래시 해결하기 (0) | 2026.03.19 |
|---|---|
| Android NDK libc++_shared.so 16KB 페이지 사이즈 경고 해결 정리 (0) | 2026.02.03 |