[UIKit] 녹음 기능 리뷰 (Feat. 나만의 Todo)

2025. 5. 12. 16:19·iOS 개발/UIKit

안녕하세요, 오늘은 제가 직접 만든 나만의 Todo 앱에 녹음 기능에 대해 리뷰해보려 합니다.

 

우리는 흔히 할 일을 텍스트로 적지만, 어떤 순간엔 글보다 말이 더 빠르고 편할 때가 있죠. 특히 이동 중이거나 급하게 아이디어가 떠올랐을 때, 간단히 마이크 버튼을 눌러 말로 기록할 수 있다면 어떨까요?

 

이번에 구현한 기능은 다음과 같은 흐름을 가집니다:

  1. 📂 녹음 불러오기: 앱 실행 시 저장된 녹음 파일들을 자동으로 불러와 목록에 보여줍니다.
  2. 🎙️ 녹음하기: 버튼을 눌러 음성을 녹음하고, 제목과 저장할 수 있도록 구성했습니다.
  3. ▶️ 녹음본 재생: 녹음한 음성을 목록에서 선택하여 재생할 수 있습니다.
  4. 🗑️ 녹음 삭제: 필요 없는 녹음은 리스트에서 손쉽게 삭제할 수 있도록 했습니다.

이 중 녹음 불러오기 기능은 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를 통해 재생이 시작됩니다. 재생 중이면 일시정지, 일시정지 상태라면 재생 시작으로 분기합니다.

    1. 재생 시작 시:
      1. 스피커로 출력 (overrideOutputAudioPort)
      2. 타이머 작동 시작
      3. 버튼 이미지를 "pause.fill"로 변경
    2. 일시정지 시:
      1. .pause()
      2. 타이머 중단
      3. 버튼 이미지를 "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 저장소 바로가기

 

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
'iOS 개발/UIKit' 카테고리의 다른 글
  • [UIKit] 설정 탭 리뷰 (Feat. 나만의 Todo)
  • [UIKit] Timer 기능 리뷰 (Feat. 나만의 Todo)
  • [UIKit] 메모 기능 구현 리뷰 (Feat. 나만의 Todo)
  • [UIKit] Todo 기능 구현 리뷰 (Feat. 나만의 Todo)
Riu
Riu
안녕하세요 iOS 개발자를 꿈꾸는 Riu입니다. Github: woolnd
  • Riu
    Riu 개발노트
    Riu
  • 전체
    오늘
    어제
    • 분류 전체보기 (27)
      • 티스토리 (2)
      • iOS 개발 (21)
        • SwiftUI (9)
        • UIKit (6)
        • Combine (5)
        • Architecture (1)
      • 알고리즘 (1)
      • 회고록 (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    SwiftUI
    SWIFT
    회고록
    figma
    막자알림서비스
    Combine
    SWIF
    티스토리
    UIKit
    ios개발
    Architecture
    안드로이드
    앗차!
    시작
    cleanArchitecture
    구름톤유니브
    ios
    알고리즘
    나만의todo
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
Riu
[UIKit] 녹음 기능 리뷰 (Feat. 나만의 Todo)
상단으로

티스토리툴바