포스트

HP Trivia (2)

Audio

이제 이전 글에서 구현한 transition animation 효과에 맞춰 사운드를 재생시켜, 유저에게 시각뿐 아니라 청각적으로도 몰입감을 줄 수 있도록 해보려 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import AVKit

@State private var audioPlayer: AVAudioPlayer!

.onAppear {
    animateViewsIn = true
    playAudio()
}

private func playAudio() {
    let sound = Bundle.main.path(forResource: "magic-in-the-air", ofType: "mp3")
    audioPlayer = try! AVAudioPlayer(contentsOf: URL(filePath: sound!))
    audioPlayer.numberOfLoops = -1
    audioPlayer.play()
}

이 코드가 새롭게 추가되었다.
AVKit 프레임워크를 import해야 오디오 기능을 사용할 수 있다.

핵심은 playAudio() 함수이다.
여기서 오디오 파일 경로를 찾고, AVAudioPlayer 인스턴스를 생성하여 반복 재생 설정 후 재생을 시작한다.

numberOfLoops Docs에 따르면:

  • 0: 사운드를 한 번만 재생 (기본값)
  • 양수: 해당 값만큼 반복 재생 (예: 1 → 총 2번 재생)
  • 음수: 무한 반복 재생 (stop() 호출 시까지 반복)

AVAudioPlayer! 왜 !를 썼을까?

AVAudioPlayer는 클래스이므로 참조 타입이며, 선언 시점에 인스턴스를 생성하지 않아도 된다.
하지만 여기서는 !를 붙여 IUO(Implicitly Unwrapped Optional)로 선언했다.

이유를 정리하면 다음과 같다:

  • AVAudioPlayer()와 같은 빈 생성자는 존재하지 않음
  • 생성 시 반드시 오디오 파일 경로가 필요
  • 따라서 선언만 먼저 하고, 나중에 초기화하는 방식이 필요함
  • 일반 옵셔널(?)을 사용하면 이후 코드에서 바인딩 또는 언래핑 코드가 필요해짐
  • !을 사용하면 마치 일반 변수처럼 접근할 수 있어 코드가 간결해짐

즉, 초기에는 nil이지만 이후 반드시 값이 들어온다는 전제를 둘 수 있을 때 IUO 방식이 간결하고 실용적일 수 있다.

IUO (Implicitly Unwrapped Optional)

String!처럼 !가 붙은 타입은 초기에는 nil일 수 있지만, 나중에는 반드시 값이 있다고 가정하고 사용하는 타입이다.
옵셔널 바인딩 없이도 일반 변수처럼 사용할 수 있는 것이 특징이다.

예시:

1
2
3
4
5
6
var name: String! // IUO 선언

name = "Potter"

print("Welcome, \(name)")   // Welcome, Optional("Potter")
print("Welcome, \(name!)")  // Welcome, Potter

문자열 보간 시에는 여전히 옵셔널로 처리되기 때문에 .name!처럼 명시적으로 언래핑해야 Optional(…) 없이 출력된다.

만약 값을 할당하지 않으면?

1
2
3
4
var name: String!

print("Welcome, \(name)")   // Welcome, nil
print("Welcome, \(name!)")  // 런타임 에러: Unexpectedly found nil while unwrapping an Optional value

즉, 언래핑 시점에 값이 없다면 크래시 발생 가능성 있음.

일반 옵셔널(?)과 비교

1
2
3
4
5
6
7
8
9
var nameOptional: String? // 일반 옵셔널

nameOptional = "Harry"

print("Welcome, \(nameOptional)")  // Welcome, Optional("Harry")

if let unwrapped = nameOptional {
    print("Welcome, \(unwrapped)") // Welcome, Harry
}

일반 옵셔널은 안전성은 높지만 바인딩 코드가 필요하고, IUO는 간결하지만 안전하지 않을 수 있음

AVAudioPlayer 인스턴스 주입 확인

1
2
3
4
5
6
7
8
9
10
11
private func playAudio() {
    let sound = Bundle.main.path(forResource: "magic-in-the-air", ofType: "mp3")
    print("before: \(audioPlayer)")
    print("before Type: \(type(of: audioPlayer))")
    print("---")
    audioPlayer = try! AVAudioPlayer(contentsOf: URL(filePath: sound!))
    print("after: \(audioPlayer)")
    print("after Type: \(type(of: audioPlayer))")
    audioPlayer.numberOfLoops = -1
    audioPlayer.play()
}

이때 콘솔 출력 결과는 다음과 같다:

1
2
3
4
5
before: nil
before Type: Optional<AVAudioPlayer>
---
after: Optional(<AVAudioPlayer: 0x6000002393a0>)
after Type: Optional<AVAudioPlayer>

즉, audioPlayer는 초기에는 nil이었지만, playAudio() 호출 이후에는 인스턴스가 할당되어 정상 작동한다.

이럴때 path 설정이 잘못된다면?

1
2
3
4
5
6
7
8
9
10
11
private func playAudio() {
    let sound = Bundle.main.path(forResource: "magic-in-the-air", ofType: "mp4") // mp3 -> mp4로 변경
        print("before: \(audioPlayer)")
        print("before Type: \(type(of: audioPlayer))")
        print("---")
        audioPlayer = try? AVAudioPlayer(contentsOf: URL(filePath: sound ?? "/dev/null"))
        print("after: \(audioPlayer)")
        print("after Type: \(type(of: audioPlayer))")
        //audioPlayer.numberOfLoops = -1
        //audioPlayer.play()
}

이렇게 일부러 잘못된 값을 넣어준다. (?? 뒤에도 잘못된 경로를 일부러 넣어준다.)

1
2
3
4
5
before: nil
before Type: Optional<AVAudioPlayer>
---
after: nil
after Type: Optional<AVAudioPlayer>

하지만

1
2
audioPlayer.numberOfLoops = -1
audioPlayer.play()

둘중 하나라도 주석을 풀게되면

Image

에러가 발생!

즉 !를 쓸때는 이렇게 오타같은 실수를 하게되면 바로 앱이 충돌이 발생하니 주의하자.

번외: 옵셔널 바인딩으로 앱 안정성 높이기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Exceptional Case
private func playAudio() {
    let sound = Bundle.main.path(forResource: "magic-in-the-air", ofType: "mp4")
    audioPlayer = try? AVAudioPlayer(contentsOf: URL(filePath: sound ?? "/dev/null"))
    guard let audioPlayer = audioPlayer else {
        print("audioPlayer가 nil입니다. 파일 경로를 확인하세요.")
        return
    }
    audioPlayer.numberOfLoops = -1
    audioPlayer.play()
}

// 출력
audioPlayer가 nil입니다. 파일 경로를 확인하세요. // 하지만 앱은 정상실행 (단지 소리만 나오지 않음.)

// Normal Case
private func playAudio() {
    let sound = Bundle.main.path(forResource: "magic-in-the-air", ofType: "mp3")
    audioPlayer = try? AVAudioPlayer(contentsOf: URL(filePath: sound ?? "/dev/null"))
    guard let audioPlayer = audioPlayer else {
        print("audioPlayer가 nil입니다. 파일 경로를 확인하세요.")
        return
    }
    audioPlayer.numberOfLoops = -1
    audioPlayer.play()
}

이렇게 해주면 조금 앱 개발에 있어 안정적인 처리가 가능하다. 즉,

  • 실전에서는 아래처럼 guard let으로 옵셔널 바인딩 처리 시
    • 경로 오류 발생 시 앱이 crash 나지 않고, 사용자에게 안내 메시지를 출력하거나 fallback 처리 가능

결론

  • @State private var audioPlayer: AVAudioPlayer!는 초기에는 nil
  • playAudio() 호출 시 정상적인 AVAudioPlayer 인스턴스로 초기화됨
  • 이후에는 일반 변수처럼 audioPlayer.play() 등으로 직접 사용 가능
  • SwiftUI의 @State와 IUO의 결합은, 초기값 없이도 상태값을 나중에 안전하게 주입하는 패턴으로 자주 사용됨
  • 단, 타입은 여전히 Optional<AVAudioPlayer> 형태로 유지되며,

    IUO는 내부적으로 옵셔널 타입으로 존재하고, 단지 문법상 편의를 제공할 뿐 실제로는 옵셔널 바인딩된 값이다

예를 들어, 콘솔에서 type(of: audioPlayer)를 출력하면
Optional<AVAudioPlayer>로 나오는 이유가 이 때문이다.

Image

이미지는 참고(audioPlayer를 Option 누른상태에서 클릭했을때)

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.