[Swift 입문] 35편 — AVFoundation과 사운드 재생

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [34편] LaunchAgent와 백그라운드 데몬

AVAudioPlayer로 소리 재생하기

macOS에서 짧은 효과음이나 알림음을 재생하는 가장 간단한 방법은 AVFoundation의 AVAudioPlayer입니다. 스트리밍이 필요 없는 로컬 파일 재생에 적합합니다.

import AVFoundation

final class SoundPlayer {
    private var player: AVAudioPlayer?

    func play(fileURL: URL, volume: Float = 1.0) {
        do {
            let player = try AVAudioPlayer(contentsOf: fileURL)
            player.volume = volume
            player.prepareToPlay()
            player.play()
            self.player = player  // 재생이 끝나기 전에 해제되지 않도록 참조 유지
        } catch {
            print("사운드 재생 실패: \(error)")
        }
    }
}

여기서 흔히 놓치는 부분이 self.player = player입니다. AVAudioPlayer 인스턴스가 로컬 변수로만 존재하면, 재생이 끝나기도 전에 ARC가 이를 해제해버려 소리가 갑자기 끊깁니다. 재생이 끝날 때까지 어딘가 강한 참조를 들고 있어야 합니다.


여러 사운드를 동시에 재생하기

플레이어 하나로는 겹치는 소리를 재생할 수 없습니다(새 재생이 이전 것을 끊어버립니다). 이벤트가 빠르게 연달아 발생하는 앱이라면 플레이어를 여러 개 풀(pool)로 관리하는 편이 낫습니다.

final class SoundPool {
    private var activePlayers: Set = []
    private let queue = DispatchQueue(label: "soundpool.sync")

    func play(fileURL: URL, volume: Float = 1.0) {
        guard let player = try? AVAudioPlayer(contentsOf: fileURL) else { return }
        player.volume = volume
        player.prepareToPlay()

        let delegate = CompletionDelegate { [weak self] finishedPlayer in
            self?.queue.sync {
                self?.activePlayers.remove(finishedPlayer)
            }
        }
        player.delegate = delegate
        objc_setAssociatedObject(player, &delegateKey, delegate, .OBJC_ASSOCIATION_RETAIN)

        queue.sync { activePlayers.insert(player) }
        player.play()
    }
}

AVAudioPlayerHashable이 아니므로 실제 코드에서는 배열이나 딕셔너리로 관리하는 경우가 더 흔하지만, 핵심 아이디어는 같습니다. 재생이 끝난 플레이어를 델리게이트 콜백에서 정리해서 무한정 쌓이지 않게 합니다.


사운드팩 구조 설계하기

이벤트별로 다른 소리를 매핑하는 “사운드팩” 기능을 만든다고 생각해봅시다. 성공, 실패, 알림 같은 카테고리에 각각 오디오 파일을 매핑합니다.

enum SoundEvent: String {
    case success
    case failure
    case notification
}

struct SoundPack {
    let name: String
    let mapping: [SoundEvent: URL]

    func url(for event: SoundEvent) -> URL? {
        mapping[event]
    }
}

final class SoundEngine {
    private let pool = SoundPool()
    private var currentPack: SoundPack?
    private var isMuted = false

    func loadPack(_ pack: SoundPack) {
        currentPack = pack
    }

    func trigger(_ event: SoundEvent, volume: Float = 1.0) {
        guard !isMuted, let pack = currentPack, let url = pack.url(for: event) else { return }
        pool.play(fileURL: url, volume: volume)
    }
}

이렇게 이벤트와 파일을 분리해두면, 사용자가 사운드팩을 통째로 교체하거나 특정 이벤트만 음소거하는 기능을 붙이기 쉬워집니다.


디바운스 — 짧은 시간에 같은 소리가 몰릴 때

같은 이벤트가 짧은 시간 안에 여러 번 발생하면(예: 빠르게 연속된 알림), 매번 소리를 재생하는 대신 일정 시간 안의 중복은 걸러주는 것이 사용자 경험에 낫습니다.

final class DebouncedSoundEngine {
    private let engine = SoundEngine()
    private var lastTriggered: [SoundEvent: Date] = [:]
    private let minInterval: TimeInterval = 0.3

    func trigger(_ event: SoundEvent) {
        let now = Date()
        if let last = lastTriggered[event], now.timeIntervalSince(last) < minInterval {
            return  // 너무 최근에 울렸으므로 무시
        }
        lastTriggered[event] = now
        engine.trigger(event)
    }
}

디바운스 간격은 너무 짧으면 효과가 없고, 너무 길면 정말 연속으로 알려야 할 이벤트까지 묻어버립니다. 실제 이벤트 발생 패턴을 관찰하며 값을 조정하세요.


비동기 로딩과 캐싱

사운드팩의 오디오 파일이 많아지면, 재생 시점마다 AVAudioPlayer(contentsOf:)로 매번 디스크에서 읽는 대신 미리 로드해두는 편이 지연을 줄입니다.

final class PreloadedSoundCache {
    private var cache: [URL: Data] = [:]

    func preload(_ urls: [URL]) {
        for url in urls {
            guard cache[url] == nil, let data = try? Data(contentsOf: url) else { continue }
            cache[url] = data
        }
    }

    func player(for url: URL) -> AVAudioPlayer? {
        guard let data = cache[url] else {
            return try? AVAudioPlayer(contentsOf: url)  // 캐시 미스 시 폴백
        }
        return try? AVAudioPlayer(data: data)
    }
}

AVAudioPlayer(data:)는 이미 메모리에 올라온 Data로 플레이어를 만듭니다. 사운드팩을 앱 시작 시 한 번 preload해두면, 실제 이벤트가 발생했을 때는 디스크 접근 없이 바로 재생할 수 있습니다.


핵심 요약

  • AVAudioPlayer로 로컬 오디오 파일 재생 — 재생 중 참조를 반드시 유지해야 함
  • 여러 소리를 동시에 재생하려면 플레이어를 로 관리하고, 델리게이트에서 종료된 플레이어를 정리
  • 이벤트 → 파일 매핑을 분리한 사운드팩 구조로 확장성 확보
  • 짧은 시간 내 중복 재생은 디바운스로 걸러 사용자 경험 개선
  • 자주 쓰는 사운드는 미리 로드(preload)해서 재생 지연 최소화

이것으로 7부 시스템 프로그래밍이 완결됩니다(Process, PTY, LaunchAgent, AVFoundation). 다음 편부터는 8부 AI CLI 훅 시스템으로, hook 이벤트 수신, 정책 평가, 승인 큐, IPC 프레이밍을 실제로 조합해 하나의 시스템을 완성해봅니다.

🤖 Generated with Claude Code

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다