본문 바로가기

Programmer/모바일

iOS WebView 앱에서 음성 녹음 → 파일 생성 → 서버 업로드 구현 정리

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 앱에서 네이티브 기능을 연동할 때 사용