안녕하세요, 오늘은 제가 직접 만든 나만의 Todo 앱에 녹음 기능에 대해 리뷰해보려 합니다.
우리는 흔히 할 일을 텍스트로 적지만, 어떤 순간엔 글보다 말이 더 빠르고 편할 때가 있죠. 특히 이동 중이거나 급하게 아이디어가 떠올랐을 때, 간단히 마이크 버튼을 눌러 말로 기록할 수 있다면 어떨까요?
이번에 구현한 기능은 다음과 같은 흐름을 가집니다:
- 📂 녹음 불러오기: 앱 실행 시 저장된 녹음 파일들을 자동으로 불러와 목록에 보여줍니다.
- 🎙️ 녹음하기: 버튼을 눌러 음성을 녹음하고, 제목과 저장할 수 있도록 구성했습니다.
- ▶️ 녹음본 재생: 녹음한 음성을 목록에서 선택하여 재생할 수 있습니다.
- 🗑️ 녹음 삭제: 필요 없는 녹음은 리스트에서 손쉽게 삭제할 수 있도록 했습니다.
이 중 녹음 불러오기 기능은 Todo 불러오기 로직과 거의 동일하게 구성되어 있어, 별도로 복잡한 설명 없이 재사용이 가능했습니다.
혹시 이전 내용을 확인하고 싶다면 아래 링크를 참고해 주세요👇
[UIKit] Todo 기능 구현 리뷰 (Feat. 나만의 Todo)
이번 프로젝트는 ‘나만의 Todo’라는 이름으로 진행해봤어요.앱의 로고도 직접 디자인해서 적용해보았고, UI 구성부터 기능 구현까지 차근차근 UIKit으로 만들어봤습니다. 오늘 블로그에서는 이
riu-dev.tistory.com
UI는 사용자에게 직관적으로 다가갈 수 있도록 단순하고 명확하게 구성했고, iOS의 AVFoundation을 활용해 안정적인 오디오 녹음 및 재생 기능을 구현했습니다.
🎙️ 녹음하기
✅ 주요 기능 흐름
- 🎙️ 녹음 시작
- ⏹ 녹음 종료 및 상태 전환
- ▶️ 녹음 파일 재생
- 🗂 제목/시간/날짜 저장
- 🔒 Firebase와 로컬 파일 연결
- 🔁 초기화 및 재녹음 처리
1. 고유한 녹음 ID 생성 및 파일 경로 설정
녹음이 시작되기 전, UUID()로 고유한 ID를 먼저 생성합니다. 이 ID는 중요한 역할을 합니다:
- 녹음 파일의 이름으로 사용됨
- Firebase에 저장할 때 Key값(ID) 으로 활용됨
- 녹음 목록을 불러올 때 연결 포인트로 사용됨
let id = UUID().uuidString
currentRecordingID = id
let url = getAudioFileURL(using: id)
recordedFileURL = url
로컬에 저장할 파일 경로는 아래 함수로 지정합니다:
func getAudioFileURL(using id: String) -> URL {
let fileName = "\(id).m4a"
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return directory.appendingPathComponent(fileName)
}
2. AVFoundation을 통한 녹음 시작
AVFoundation은 iOS에서 오디오 녹음과 재생을 위한 핵심 프레임워크입니다.
- AVFormatIDKey: 오디오 포맷 지정 (MPEG4 AAC)
- AVSampleRateKey: 샘플링 속도
- AVNumberOfChannelsKey: 오디오 채널 수 (1 = 모노)
- AVEncoderAudioQualityKey: 인코딩 품질
try session.setCategory(.playAndRecord, mode: .default)
try session.setActive(true)
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
audioRecorder?.record()
녹음이 시작되면:
- UI 상태가 "녹음 중"으로 전환
- 타이머를 시작하여 녹음 시간을 실시간 표시
updateRecordingUI(state: "recording")
startTimer()
private func updateRecordingUI(state: String? = nil) {
switch state {
case "recording":
recordingButton.setImage(UIImage.voiceOn, for: .normal)
recordingButton.setTitle("녹음중", for: .normal)
recordingTimeLabel.text = "00:00"
resetButton.alpha = 0
case "done":
recordingButton.setImage(UIImage.voiceOff, for: .normal)
recordingButton.setTitle("재생하기", for: .normal)
resetButton.alpha = 1
default:
recordingButton.setImage(UIImage.voiceOff, for: .normal)
recordingButton.setTitle("녹음하기", for: .normal)
recordingTimeLabel.text = "00:00"
resetButton.alpha = 0
}
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
guard let start = self.recordingStartTime else { return }
let elapsed = Int(Date().timeIntervalSince(start))
let minutes = String(format: "%02d", elapsed / 60)
let seconds = String(format: "%02d", elapsed % 60)
self.recordingTimeLabel.text = "\(minutes):\(seconds)"
}
}
3. 녹음 종료 → 재생 가능한 상태로 전환
다시 버튼을 누르면 녹음이 종료됩니다. 녹음 중이었는지 판단하는 플래그는 isRecording입니다.
audioRecorder?.stop()
isRecording = false
recordingStartTime = nil
stopTimer()
updateRecordingUI(state: "done")
private func stopTimer() {
timer?.invalidate()
timer = nil
}
4. 녹음 재생 처리
녹음본은 스피커로 출력되도록 설정합니다. AVAudioPlayer를 통해 재생 기능을 구현합니다.
private func playRecording(url: URL) {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default)
try session.overrideOutputAudioPort(.speaker)
try session.setActive(true)
audioPlayer = try AVAudioPlayer(contentsOf: url)
audioPlayer?.prepareToPlay()
audioPlayer?.play()
} catch {
print("재생 실패: \(error)")
}
}
5. 녹음 시간 & 날짜 자동 기록
녹음 시작 시 현재 시간을 Date()로 기록합니다.
이후 타이머를 통해 초마다 경과 시간을 계산해 "00:00" 형식으로 업데이트합니다.
let elapsed = Int(Date().timeIntervalSince(start))
let minutes = String(format: "%02d", elapsed / 60)
let seconds = String(format: "%02d", elapsed % 60)
recordingTimeLabel.text = "\(minutes):\(seconds)"
dateFormatter.dateFormat = "yyyy년 M월 d일 (E) - a h시 mm분"
dateLabel.text = dateFormatter.string(from: date)
6. 초기화 & 재녹음 처리
사용자가 실수로 잘못 녹음했거나 다시 녹음을 원하는 경우를 대비해
초기화 버튼(resetButtonTapped)을 구현했습니다:
- 녹음 중이라면 audioRecorder?.stop()
- 재생 중이라면 audioPlayer?.stop()
- 타이머 초기화 및 UI 복원
@IBAction func resetButtonTapped(_ sender: Any) {
audioRecorder?.stop()
audioPlayer?.stop()
isRecording = false
recordedFileURL = nil
recordingStartTime = nil
stopTimer()
updateRecordingUI() // 초기 상태로 복원
}
7. 제목 입력 + 데이터 저장
사용자는 제목을 입력할 수 있고, Create 버튼을 누르면 다음 데이터가 저장됩니다:
- id (UUID 기반)
- title (텍스트 입력)
- time (녹음 길이)
- date (자동 입력 날짜)
let model = recordingCreateModel(id: id, title: title, time: time, date: date)
이 모델은 ViewModel에 전달되어 Firebase에 저장됩니다:
▶️ 녹음본 재생
이번엔 저장된 녹음 파일을 재생하는 기능을 구현해보았습니다.
👉 이 기능은 Firebase에서 받은 id를 기반으로
로컬 파일 시스템에서 녹음본을 찾아 재생하는 구조로 설계했습니다.
✅ 기능 흐름 요약
- 서버에서 받은 id → 로컬 파일 경로 탐색
- 재생 버튼 클릭 → AVAudioPlayer로 재생
- ⏱ 재생 시간 업데이트 (타이머)
- 📊 ProgressView 이동
- ⏮ 처음으로 되돌리기 버튼
1. 로컬 파일에서 녹음본 찾기
서버(Firebase)에서 받은 녹음 ID를 기반으로 로컬 Document 디렉토리에서 .m4a 파일을 찾습니다.
이 로직은 녹음 저장 때와 동일하게 유지하여, ID만 있으면 언제든 해당 녹음 파일을 찾을 수 있도록 했습니다.
private func getAudioFileURL(using id: String) -> URL? {
let fileName = "\(id).m4a"
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return directory.appendingPathComponent(fileName)
}
2. 녹음 재생 시작 / 일시정지 처리
플레이 버튼을 누르면 AVAudioPlayer를 통해 재생이 시작됩니다. 재생 중이면 일시정지, 일시정지 상태라면 재생 시작으로 분기합니다.
- 재생 시작 시:
- 스피커로 출력 (overrideOutputAudioPort)
- 타이머 작동 시작
- 버튼 이미지를 "pause.fill"로 변경
- 일시정지 시:
- .pause()
- 타이머 중단
- 버튼 이미지를 "play.fill"로 변경
@IBAction func playButtonTapped(_ sender: Any) {
guard let id = id,
let url = getAudioFileURL(using: id) else {
print("❌ 파일 경로를 찾을 수 없습니다.")
return
}
if isPlaying {
audioPlayer?.pause()
stopTimer()
buttonImageResize("play.fill")
isPlaying = false
print("⏸ 녹음 재생 일시정지")
} else {
do {
if audioPlayer == nil {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default)
try session.overrideOutputAudioPort(.speaker)
try session.setActive(true)
audioPlayer = try AVAudioPlayer(contentsOf: url)
audioPlayer?.delegate = self
audioPlayer?.prepareToPlay()
}
audioPlayer?.play()
playbackStartTime = Date()
startTimer()
buttonImageResize("pause.fill")
isPlaying = true
print("▶️ 녹음 재생 시작")
} catch {
print("❌ 녹음 재생 실패: \(error)")
}
}
}
3. 프로그레스바 & 시간 타이머 동기화
- 1초마다 타이머가 실행되어 현재 재생 시간을 계산합니다.
- currentTime / duration으로 프로그레스바를 업데이트합니다.
private func startTimer() {
playbackTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self,
let player = self.audioPlayer else { return }
// ⏱ 타이머 레이블 업데이트
let elapsed = Int(player.currentTime)
let minutes = String(format: "%02d", elapsed / 60)
let seconds = String(format: "%02d", elapsed % 60)
self.timerLabel.text = "\(minutes):\(seconds)"
// 📊 프로그레스바 업데이트
let progress = Float(player.currentTime / player.duration)
self.progressView.progress = progress
}
}
4. ⏮ 처음으로 되돌리기 버튼
- 현재 재생 중인 위치를 0초로 돌리고 play()를 호출해 처음부터 재생되도록 합니다.
- 타이머도 재시작되며, 버튼 이미지는 "pause.fill"로 전환됩니다.
@IBAction func forwardButtonTapped(_ sender: Any) {
guard let player = audioPlayer else { return }
player.currentTime = 0 // ⏪ 재생 위치를 처음으로
player.play() // ▶️ 재생 시작
isPlaying = true
buttonImageResize("pause.fill")
startTimer() // 타이머도 다시 시작
}
5. 재생 완료 후 상태 초기화
- 녹음이 끝까지 재생되면 자동으로 호출되는 델리게이트 메서드입니다.
- 여기서 UI와 타이머, 프로그레스바 모두 초기화합니다.
extension RecordingDetailViewController: AVAudioPlayerDelegate {
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
isPlaying = false
stopTimer()
timerLabel.text = "00:00"
buttonImageResize("play.fill")
print("⏹ 재생 완료")
progressView.progress = 0.0 // 🔁 초기화
}
}
🗑️ 녹음 삭제
Todo 메모 삭제 기능과 매우 유사하지만,
녹음은 로컬에 저장된 .m4a 파일도 함께 삭제해야 하기 때문에 그에 맞는 처리를 추가했습니다.
1. 파일 경로 찾기: ID → FileURL 매핑
- 앞서 녹음 저장 시 사용했던 UUID 기반 ID와 동일하게 녹음 파일명도 id.m4a 형식으로 저장되어 있습니다.
- 따라서 삭제할 파일 경로도 같은 방식으로 구성하면 됩니다.
let fileName = "\(id).m4a"
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let fileURL = directory.appendingPathComponent(fileName)
2. 파일 존재 여부 확인 및 삭제 실행
- fileExists(atPath:)를 통해 해당 경로에 실제 파일이 존재하는지 확인합니다.
- 파일이 있을 경우 removeItem을 통해 삭제가 수행됩니다.
- 삭제 성공 시 로그를 남기고, 실패하면 에러를 출력합니다.
- 파일이 존재하지 않는 경우에는 사용자나 개발자가 혼동하지 않도록 로그를 남깁니다.
if FileManager.default.fileExists(atPath: fileURL.path) {
do {
try FileManager.default.removeItem(at: fileURL)
print("🗑️ 파일 삭제 완료: \(fileName)")
} catch {
print("❌ 파일 삭제 실패: \(error)")
}
} else {
print("⚠️ 파일 없음: \(fileName)")
}
🎬 Recording 기능 구현 영상
구현 영상을 보시면, 녹화를 직접 제 휴대폰으로 진행하다 보니 녹음 당시 어떤 노래를 사용했는지는
영상에 나타나지 않습니다.
하지만 재생 버튼을 누르면 영상처럼 정상적으로 녹음이 잘 되었음을 확인하실 수 있습니다.
참고로 녹음에 사용한 곡은 제가 좋아하는 유다빈밴드의 ‘불’입니다! 😊
🧩 마무리
이번 포스팅에서는 나만의 Todo 앱에서 녹음 기능 구현에 초점을 맞춰
🎙️ 녹음하기 → ▶️ 재생하기 → 🗑️ 삭제하기의 전체 흐름을 상세히 소개해보았습니다.
- UUID 기반의 녹음 ID 구조
- AVFoundation을 활용한 녹음/재생 처리
- 타이머 & 프로그레스바를 통한 재생 시각화
- Firebase 연동 및 로컬 파일 관리 전략까지 실제 구현에 적용된 구조와 흐름을 중점적으로 다뤘습니다.
📌 녹음 기능의 전체 코드가 궁금하신 분들은 아래 GitHub 링크를 참고해주세요:
GitHub - woolnd/UIKit_MyTodo
Contribute to woolnd/UIKit_MyTodo development by creating an account on GitHub.
github.com
다음 포스팅에서는 앱 내 또 다른 기능인 ⏱ 타이머 기능 구현를 소개드릴 예정이니 기대해주세요!
꾸준히 기록하며 성장하는 개발자가 되기 위해 계속 달려보겠습니다! 🚀
읽어주셔서 감사합니다 😊
'iOS 개발 > UIKit' 카테고리의 다른 글
[UIKit] 설정 탭 리뷰 (Feat. 나만의 Todo) (1) | 2025.05.20 |
---|---|
[UIKit] Timer 기능 리뷰 (Feat. 나만의 Todo) (0) | 2025.05.16 |
[UIKit] 메모 기능 구현 리뷰 (Feat. 나만의 Todo) (1) | 2025.05.09 |
[UIKit] Todo 기능 구현 리뷰 (Feat. 나만의 Todo) (0) | 2025.05.07 |
[UIKit] Launch & Onboarding 화면 구현 리뷰 (Feat. 음성메모앱) (2) | 2025.05.05 |