Ver3.5 Swift 기반 음악 스트리밍 앱 개발 가이드

Swift 기반 음악 스트리밍 앱 개발 가이드
🎧 AVFoundation부터 실시간 스트리밍까지, 나만의 음악 앱을 만들어보자!
야, 솔직히 말해봐. 🎵 Spotify나 Apple Music 쓰면서 한 번쯤은 "나도 이런 거 만들 수 있을까?" 생각해본 적 있지 않아?
오늘은 그 꿈을 현실로 만들어줄 Swift 기반 음악 스트리밍 앱 개발 가이드를 완전 친절하게 풀어볼게.
겁먹지 마. 차근차근 따라오면 진짜로 동작하는 음악 앱을 만들 수 있어! 🚀
이 가이드는 iOS 개발 기초는 어느 정도 알고 있는 분들을 대상으로 해.
Swift 문법, Xcode 사용법 정도는 알고 있다는 전제 하에 진행할 거야.
그래도 최대한 쉽게 설명할 테니까 걱정 말고 따라와! 😎
🎯 1. 프로젝트 설계 — 뭘 만들 건지 먼저 정하자
코드부터 막 짜기 시작하면 나중에 진짜 후회해. 😅
음악 스트리밍 앱은 생각보다 컴포넌트가 많거든. 먼저 우리가 만들 앱의 핵심 기능을 정리해보자.
이 기능들을 구현하기 위해 우리가 사용할 기술 스택을 먼저 확인해보자.
| 레이어 | 기술/프레임워크 | 역할 |
|---|---|---|
| UI | SwiftUI | 화면 구성, 애니메이션 |
| 오디오 엔진 | AVFoundation | 스트리밍 재생, 오디오 세션 |
| 네트워킹 | URLSession + async/await | API 통신, 스트림 다운로드 |
| 상태 관리 | Combine + ObservableObject | 재생 상태, 플레이리스트 관리 |
| 시스템 연동 | MediaPlayer Framework | 잠금화면, 컨트롤센터 |
| 로컬 저장 | CoreData / FileManager | 캐싱, 즐겨찾기 저장 |
🏗️ 2. 프로젝트 구조 설계 — 폴더 구조부터 잡자
좋은 앱은 좋은 구조에서 나와. 🏛️
음악 앱처럼 복잡한 프로젝트는 처음부터 폴더 구조를 잘 잡아야 나중에 유지보수가 편해.
MusicStreamingApp/
├── App/
│ ├── MusicStreamingApp.swift // 앱 진입점
│ └── AppDelegate.swift
├── Models/
│ ├── Track.swift // 트랙 모델
│ ├── Album.swift // 앨범 모델
│ ├── Artist.swift // 아티스트 모델
│ └── Playlist.swift // 플레이리스트 모델
├── ViewModels/
│ ├── PlayerViewModel.swift // 재생 상태 관리
│ ├── LibraryViewModel.swift // 라이브러리 관리
│ └── SearchViewModel.swift // 검색 관리
├── Views/
│ ├── Player/
│ │ ├── PlayerView.swift // 메인 플레이어 화면
│ │ ├── MiniPlayerView.swift // 미니 플레이어
│ │ └── PlayerControlsView.swift // 재생 컨트롤
│ ├── Library/
│ │ ├── LibraryView.swift
│ │ └── PlaylistDetailView.swift
│ └── Search/
│ └── SearchView.swift
├── Services/
│ ├── AudioPlayerService.swift // AVFoundation 래퍼
│ ├── NetworkService.swift // API 통신
│ └── CacheService.swift // 캐싱 서비스
└── Utilities/
├── Extensions/
└── Constants.swift
이 구조는 MVVM(Model-View-ViewModel) 패턴을 기반으로 해.
SwiftUI와 Combine이랑 찰떡궁합이라서 음악 앱에 딱 맞는 패턴이야. 👌
🎵 3. 데이터 모델 정의 — Track, Album, Playlist
앱의 뼈대가 되는 데이터 모델부터 만들어보자. 깔끔하게 Codable을 채택해서 JSON 파싱도 쉽게 할 수 있게 설계할 거야.
import Foundation
struct Track: Identifiable, Codable, Equatable {
let id: String
let title: String
let artistName: String
let albumTitle: String
let albumArtURL: URL?
let streamURL: URL
let duration: TimeInterval
let genre: String?
var isLiked: Bool = false
// 재생 시간을 "3:45" 형식으로 변환
var formattedDuration: String {
let minutes = Int(duration) / 60
let seconds = Int(duration) % 60
return String(format: "%d:%02d", minutes, seconds)
}
enum CodingKeys: String, CodingKey {
case id, title, duration, genre
case artistName = "artist_name"
case albumTitle = "album_title"
case albumArtURL = "album_art_url"
case streamURL = "stream_url"
}
}
struct Playlist: Identifiable, Codable {
let id: String
var name: String
var description: String?
var coverImageURL: URL?
var tracks: [Track]
let createdAt: Date
var updatedAt: Date
// 전체 재생 시간 계산
var totalDuration: TimeInterval {
tracks.reduce(0) { $0 + $1.duration }
}
var trackCount: Int {
tracks.count
}
// 플레이리스트에 트랙 추가
mutating func addTrack(_ track: Track) {
guard !tracks.contains(track) else { return }
tracks.append(track)
updatedAt = Date()
}
// 트랙 제거
mutating func removeTrack(at index: Int) {
tracks.remove(at: index)
updatedAt = Date()
}
}
Identifiable을 채택하면 SwiftUI의 ForEach에서 id 파라미터 없이 바로 사용할 수 있어. 코드가 훨씬 깔끔해지지!
🔊 4. AVFoundation으로 오디오 엔진 구축하기
이제 진짜 핵심이야! 🎯
AVFoundation은 Apple이 제공하는 강력한 미디어 프레임워크야.
음악 스트리밍에서는 주로 AVPlayer와 AVPlayerItem을 사용해.
AVPlayer를 직접 ViewModel에서 다루면 코드가 지저분해져.
Service 레이어로 분리해서 깔끔하게 관리하자!
import AVFoundation
import Combine
import MediaPlayer
class AudioPlayerService: NSObject, ObservableObject {
// MARK: - Published Properties
@Published var isPlaying: Bool = false
@Published var currentTime: TimeInterval = 0
@Published var duration: TimeInterval = 0
@Published var bufferedProgress: Double = 0
@Published var playerState: PlayerState = .idle
// MARK: - Private Properties
private var player: AVPlayer?
private var playerItem: AVPlayerItem?
private var timeObserver: Any?
private var cancellables = Set<AnyCancellable>()
// 재생 상태 열거형
enum PlayerState {
case idle // 초기 상태
case loading // 로딩 중
case playing // 재생 중
case paused // 일시정지
case buffering // 버퍼링 중
case error(Error) // 에러
}
override init() {
super.init()
setupAudioSession()
setupRemoteCommandCenter()
}
// MARK: - 오디오 세션 설정
private func setupAudioSession() {
do {
let session = AVAudioSession.sharedInstance()
// .playback 카테고리: 백그라운드 재생 허용
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
} catch {
print("오디오 세션 설정 실패: \(error)")
}
}
// MARK: - 트랙 로드 및 재생
func loadAndPlay(track: Track) {
// 기존 옵저버 제거
removeTimeObserver()
playerState = .loading
// AVPlayerItem 생성
let item = AVPlayerItem(url: track.streamURL)
self.playerItem = item
// 플레이어 설정
if player == nil {
player = AVPlayer(playerItem: item)
} else {
player?.replaceCurrentItem(with: item)
}
// 상태 옵저버 등록
observePlayerItem(item)
setupTimeObserver()
player?.play()
isPlaying = true
playerState = .playing
// 잠금화면 정보 업데이트
updateNowPlayingInfo(track: track)
}
// MARK: - 재생 제어
func play() {
player?.play()
isPlaying = true
playerState = .playing
}
func pause() {
player?.pause()
isPlaying = false
playerState = .paused
}
func togglePlayPause() {
isPlaying ? pause() : play()
}
// 특정 시간으로 이동 (Seek)
func seek(to time: TimeInterval) {
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
player?.seek(to: cmTime, toleranceBefore: .zero, toleranceAfter: .zero)
}
// 볼륨 설정 (0.0 ~ 1.0)
func setVolume(_ volume: Float) {
player?.volume = volume
}
// MARK: - 시간 옵저버 설정
private func setupTimeObserver() {
// 0.5초마다 현재 재생 시간 업데이트
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = player?.addPeriodicTimeObserver(
forInterval: interval,
queue: .main
) { [weak self] time in
self?.currentTime = time.seconds
// 버퍼링 진행률 계산
if let item = self?.playerItem {
self?.updateBufferedProgress(item: item)
}
}
}
private func removeTimeObserver() {
if let observer = timeObserver {
player?.removeTimeObserver(observer)
timeObserver = nil
}
}
// MARK: - 버퍼링 진행률 계산
private func updateBufferedProgress(item: AVPlayerItem) {
guard let timeRange = item.loadedTimeRanges.first?.timeRangeValue else { return }
let bufferedEnd = timeRange.start.seconds + timeRange.duration.seconds
let total = item.duration.seconds
guard total > 0 else { return }
bufferedProgress = bufferedEnd / total
}
// MARK: - PlayerItem 상태 감지
private func observePlayerItem(_ item: AVPlayerItem) {
// 재생 완료 감지
NotificationCenter.default.publisher(
for: .AVPlayerItemDidPlayToEndTime,
object: item
)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.handlePlaybackEnded()
}
.store(in: &cancellables)
// 버퍼링 상태 감지
item.publisher(for: \.isPlaybackLikelyToKeepUp)
.receive(on: DispatchQueue.main)
.sink { [weak self] isReady in
if isReady {
self?.playerState = .playing
} else {
self?.playerState = .buffering
}
}
.store(in: &cancellables)
// 재생 시간 감지
item.publisher(for: \.duration)
.receive(on: DispatchQueue.main)
.compactMap { $0.seconds.isNaN ? nil : $0.seconds }
.assign(to: &$duration)
}
private func handlePlaybackEnded() {
isPlaying = false
playerState = .idle
currentTime = 0
}
}
Info.plist에 UIBackgroundModes에 audio를 반드시 추가해야 해. 이거 빠뜨리면 앱이 백그라운드로 가는 순간 음악이 뚝 끊겨버려!
🔒 5. 잠금화면 & 컨트롤센터 연동 — MediaPlayer Framework
음악 앱에서 잠금화면 컨트롤은 필수야! 🎛️
MediaPlayer Framework의 MPNowPlayingInfoCenter와 MPRemoteCommandCenter를 사용하면 구현할 수 있어.
import MediaPlayer
extension AudioPlayerService {
// MARK: - 잠금화면 정보 업데이트
func updateNowPlayingInfo(track: Track) {
var nowPlayingInfo = [String: Any]()
// 기본 정보 설정
nowPlayingInfo[MPMediaItemPropertyTitle] = track.title
nowPlayingInfo[MPMediaItemPropertyArtist] = track.artistName
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = track.albumTitle
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = track.duration
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
// 앨범 아트 설정 (비동기 로딩)
if let artURL = track.albumArtURL {
loadArtwork(from: artURL) { image in
if let image = image {
let artwork = MPMediaItemArtwork(
boundsSize: image.size
) { _ in image }
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
} else {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
}
// MARK: - 리모트 커맨드 설정 (잠금화면 버튼)
func setupRemoteCommandCenter() {
let commandCenter = MPRemoteCommandCenter.shared()
// 재생 버튼
commandCenter.playCommand.addTarget { [weak self] _ in
self?.play()
return .success
}
// 일시정지 버튼
commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.pause()
return .success
}
// 다음 곡 버튼
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
self?.skipToNext()
return .success
}
// 이전 곡 버튼
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
self?.skipToPrevious()
return .success
}
// 탐색 바 (스크러버)
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
self?.seek(to: event.positionTime)
return .success
}
}
// 앨범 아트 비동기 로딩
private func loadArtwork(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url) { data, _, _ in
DispatchQueue.main.async {
if let data = data, let image = UIImage(data: data) {
completion(image)
} else {
completion(nil)
}
}
}.resume()
}
// 다음/이전 곡 (PlayerViewModel에서 처리)
func skipToNext() {
NotificationCenter.default.post(name: .skipToNext, object: nil)
}
func skipToPrevious() {
NotificationCenter.default.post(name: .skipToPrevious, object: nil)
}
}
// Notification 이름 확장
extension Notification.Name {
static let skipToNext = Notification.Name("skipToNext")
static let skipToPrevious = Notification.Name("skipToPrevious")
}
🎮 6. PlayerViewModel — 재생 상태 중앙 관리
이제 UI와 서비스를 연결해주는 ViewModel을 만들 차례야.
PlayerViewModel은 앱 전체에서 공유되는 재생 상태를 관리하는 핵심 클래스야. 🧠
import SwiftUI
import Combine
class PlayerViewModel: ObservableObject {
// MARK: - Published Properties
@Published var currentTrack: Track?
@Published var queue: [Track] = []
@Published var currentIndex: Int = 0
@Published var isShuffleEnabled: Bool = false
@Published var repeatMode: RepeatMode = .none
@Published var showFullPlayer: Bool = false
// 오디오 서비스 (의존성 주입)
let audioService: AudioPlayerService
private var cancellables = Set<AnyCancellable>()
// 반복 모드 열거형
enum RepeatMode {
case none // 반복 없음
case one // 현재 곡 반복
case all // 전체 반복
}
// 셔플된 인덱스 배열
private var shuffledIndices: [Int] = []
init(audioService: AudioPlayerService = AudioPlayerService()) {
self.audioService = audioService
setupBindings()
setupNotifications()
}
// MARK: - 바인딩 설정
private func setupBindings() {
// 재생 완료 시 다음 곡으로
audioService.$playerState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
if case .idle = state {
self?.handleTrackEnded()
}
}
.store(in: &cancellables)
}
private func setupNotifications() {
NotificationCenter.default.publisher(for: .skipToNext)
.sink { [weak self] _ in self?.skipToNext() }
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .skipToPrevious)
.sink { [weak self] _ in self?.skipToPrevious() }
.store(in: &cancellables)
}
// MARK: - 재생 제어
func play(track: Track, in queue: [Track] = []) {
self.queue = queue.isEmpty ? [track] : queue
if let index = self.queue.firstIndex(of: track) {
currentIndex = index
}
currentTrack = track
audioService.loadAndPlay(track: track)
// 셔플 인덱스 초기화
if isShuffleEnabled {
generateShuffledIndices()
}
}
func skipToNext() {
let nextIndex = getNextIndex()
guard nextIndex < queue.count else { return }
currentIndex = nextIndex
currentTrack = queue[currentIndex]
audioService.loadAndPlay(track: queue[currentIndex])
}
func skipToPrevious() {
// 3초 이상 재생됐으면 처음으로, 아니면 이전 곡
if audioService.currentTime > 3 {
audioService.seek(to: 0)
} else {
let prevIndex = getPreviousIndex()
currentIndex = prevIndex
currentTrack = queue[currentIndex]
audioService.loadAndPlay(track: queue[currentIndex])
}
}
// MARK: - 셔플 & 반복 모드
func toggleShuffle() {
isShuffleEnabled.toggle()
if isShuffleEnabled {
generateShuffledIndices()
}
}
func toggleRepeatMode() {
switch repeatMode {
case .none: repeatMode = .all
case .all: repeatMode = .one
case .one: repeatMode = .none
}
}
private func generateShuffledIndices() {
shuffledIndices = Array(0..<queue.count).shuffled()
// 현재 곡을 첫 번째로
if let currentPos = shuffledIndices.firstIndex(of: currentIndex) {
shuffledIndices.swapAt(0, currentPos)
}
}
private func getNextIndex() -> Int {
if isShuffleEnabled && !shuffledIndices.isEmpty {
let currentShufflePos = shuffledIndices.firstIndex(of: currentIndex) ?? 0
let nextShufflePos = (currentShufflePos + 1) % shuffledIndices.count
return shuffledIndices[nextShufflePos]
}
return (currentIndex + 1) % queue.count
}
private func getPreviousIndex() -> Int {
if isShuffleEnabled && !shuffledIndices.isEmpty {
let currentShufflePos = shuffledIndices.firstIndex(of: currentIndex) ?? 0
let prevShufflePos = currentShufflePos == 0
? shuffledIndices.count - 1
: currentShufflePos - 1
return shuffledIndices[prevShufflePos]
}
return currentIndex == 0 ? queue.count - 1 : currentIndex - 1
}
private func handleTrackEnded() {
switch repeatMode {
case .one:
// 현재 곡 다시 재생
if let track = currentTrack {
audioService.loadAndPlay(track: track)
}
case .all:
skipToNext()
case .none:
if currentIndex < queue.count - 1 {
skipToNext()
}
}
}
// MARK: - 플레이리스트에 추가
func addToQueue(_ track: Track) {
queue.append(track)
}
func removeFromQueue(at index: Int) {
queue.remove(at: index)
if currentIndex >= queue.count {
currentIndex = max(0, queue.count - 1)
}
}
}
🎨 7. SwiftUI로 플레이어 UI 만들기
이제 눈에 보이는 부분을 만들 차례야! 🎨
Spotify 스타일의 풀스크린 플레이어를 SwiftUI로 구현해보자.
import SwiftUI
struct PlayerView: View {
@EnvironmentObject var playerVM: PlayerViewModel
@Environment(\.dismiss) var dismiss
// 앨범 아트 드래그 제스처용
@State private var dragOffset: CGFloat = 0
var body: some View {
ZStack {
// 배경 그라디언트 (앨범 컬러 기반)
LinearGradient(
colors: [Color(hex: "#1a1a2e"), Color(hex: "#16213e")],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 0) {
// 상단 핸들 바
headerBar
Spacer()
// 앨범 아트
albumArtView
.padding(.horizontal, 32)
Spacer()
// 트랙 정보
trackInfoView
.padding(.horizontal, 24)
// 진행 바
progressBar
.padding(.horizontal, 24)
.padding(.top, 16)
// 재생 컨트롤
controlButtons
.padding(.horizontal, 24)
.padding(.top, 20)
// 추가 컨트롤 (셔플, 반복, 좋아요)
extraControls
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 32)
}
}
.gesture(
DragGesture()
.onChanged { value in
if value.translation.height > 0 {
dragOffset = value.translation.height
}
}
.onEnded { value in
if value.translation.height > 100 {
dismiss()
}
dragOffset = 0
}
)
.offset(y: dragOffset)
.animation(.spring(response: 0.3), value: dragOffset)
}
// MARK: - 서브뷰들
private var headerBar: some View {
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.down")
.font(.title2)
.foregroundColor(.white.opacity(0.8))
}
Spacer()
Text("지금 재생 중")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.white.opacity(0.7))
Spacer()
Button(action: {}) {
Image(systemName: "ellipsis")
.font(.title2)
.foregroundColor(.white.opacity(0.8))
}
}
.padding(.horizontal, 24)
.padding(.top, 16)
}
private var albumArtView: some View {
AsyncImage(url: playerVM.currentTrack?.albumArtURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 16)
.fill(Color.purple.opacity(0.3))
.overlay(
Image(systemName: "music.note")
.font(.system(size: 60))
.foregroundColor(.white.opacity(0.5))
)
}
.frame(width: 280, height: 280)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10)
// 재생 중일 때 살짝 커지는 애니메이션
.scaleEffect(playerVM.audioService.isPlaying ? 1.0 : 0.92)
.animation(.spring(response: 0.4), value: playerVM.audioService.isPlaying)
}
private var trackInfoView: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(playerVM.currentTrack?.title ?? "재생 중인 곡 없음")
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.white)
.lineLimit(1)
Text(playerVM.currentTrack?.artistName ?? "")
.font(.subheadline)
.foregroundColor(.white.opacity(0.7))
}
Spacer()
// 좋아요 버튼
Button(action: { toggleLike() }) {
Image(systemName: playerVM.currentTrack?.isLiked == true
? "heart.fill" : "heart")
.font(.title2)
.foregroundColor(playerVM.currentTrack?.isLiked == true
? .pink : .white.opacity(0.7))
}
}
}
private var progressBar: some View {
VStack(spacing: 6) {
// 슬라이더
Slider(
value: Binding(
get: { playerVM.audioService.currentTime },
set: { playerVM.audioService.seek(to: $0) }
),
in: 0...max(playerVM.audioService.duration, 1)
)
.tint(.white)
// 시간 표시
HStack {
Text(formatTime(playerVM.audioService.currentTime))
.font(.caption)
.foregroundColor(.white.opacity(0.6))
Spacer()
Text(formatTime(playerVM.audioService.duration))
.font(.caption)
.foregroundColor(.white.opacity(0.6))
}
}
}
private var controlButtons: some View {
HStack(spacing: 0) {
// 이전 곡
Button(action: { playerVM.skipToPrevious() }) {
Image(systemName: "backward.fill")
.font(.title)
.foregroundColor(.white)
}
Spacer()
// 재생/일시정지
Button(action: { playerVM.audioService.togglePlayPause() }) {
ZStack {
Circle()
.fill(.white)
.frame(width: 72, height: 72)
Image(systemName: playerVM.audioService.isPlaying
? "pause.fill" : "play.fill")
.font(.title)
.foregroundColor(.black)
}
}
Spacer()
// 다음 곡
Button(action: { playerVM.skipToNext() }) {
Image(systemName: "forward.fill")
.font(.title)
.foregroundColor(.white)
}
}
}
private var extraControls: some View {
HStack {
// 셔플
Button(action: { playerVM.toggleShuffle() }) {
Image(systemName: "shuffle")
.font(.title3)
.foregroundColor(playerVM.isShuffleEnabled ? .purple : .white.opacity(0.5))
}
Spacer()
// 반복 모드
Button(action: { playerVM.toggleRepeatMode() }) {
Image(systemName: repeatModeIcon)
.font(.title3)
.foregroundColor(playerVM.repeatMode == .none ? .white.opacity(0.5) : .purple)
}
}
}
private var repeatModeIcon: String {
switch playerVM.repeatMode {
case .none: return "repeat"
case .all: return "repeat"
case .one: return "repeat.1"
}
}
private func formatTime(_ time: TimeInterval) -> String {
let minutes = Int(time) / 60
let seconds = Int(time) % 60
return String(format: "%d:%02d", minutes, seconds)
}
private func toggleLike() {
// 좋아요 토글 로직
}
}
🌐 8. 네트워크 서비스 — API 통신 & 스트리밍
실제 음악 스트리밍 앱은 서버에서 트랙 정보를 받아와야 해.
async/await를 활용한 깔끔한 네트워크 레이어를 만들어보자! 🌐
import Foundation
// MARK: - 네트워크 에러 정의
enum NetworkError: LocalizedError {
case invalidURL
case noData
case decodingError(Error)
case serverError(Int)
case networkUnavailable
var errorDescription: String? {
switch self {
case .invalidURL: return "잘못된 URL입니다"
case .noData: return "데이터가 없습니다"
case .decodingError(let e): return "디코딩 오류: \(e.localizedDescription)"
case .serverError(let code): return "서버 오류: \(code)"
case .networkUnavailable: return "네트워크 연결을 확인해주세요"
}
}
}
// MARK: - API 엔드포인트 정의
enum APIEndpoint {
case tracks(page: Int, limit: Int)
case search(query: String)
case trackDetail(id: String)
case playlist(id: String)
case recommendations(trackId: String)
var path: String {
switch self {
case .tracks(let page, let limit):
return "/tracks?page=\(page)&limit=\(limit)"
case .search(let query):
return "/search?q=\(query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
case .trackDetail(let id):
return "/tracks/\(id)"
case .playlist(let id):
return "/playlists/\(id)"
case .recommendations(let trackId):
return "/tracks/\(trackId)/recommendations"
}
}
}
// MARK: - 네트워크 서비스
class NetworkService {
static let shared = NetworkService()
private let baseURL = "https://api.yourmusic.com/v1"
private let session: URLSession
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
// 캐시 정책 설정
config.requestCachePolicy = .returnCacheDataElseLoad
self.session = URLSession(configuration: config)
}
// MARK: - 제네릭 요청 메서드
func request<T: Decodable>(
endpoint: APIEndpoint,
type: T.Type
) async throws -> T {
guard let url = URL(string: baseURL + endpoint.path) else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(getAuthToken())", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.noData
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.serverError(httpResponse.statusCode)
}
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkError.decodingError(error)
}
}
// MARK: - 트랙 목록 가져오기
func fetchTracks(page: Int = 1, limit: Int = 20) async throws -> [Track] {
return try await request(
endpoint: .tracks(page: page, limit: limit),
type: TracksResponse.self
).tracks
}
// MARK: - 검색
func searchTracks(query: String) async throws -> [Track] {
return try await request(
endpoint: .search(query: query),
type: SearchResponse.self
).results
}
// MARK: - 추천 트랙
func fetchRecommendations(for trackId: String) async throws -> [Track] {
return try await request(
endpoint: .recommendations(trackId: trackId),
type: TracksResponse.self
).tracks
}
private func getAuthToken() -> String {
// 실제 구현에서는 Keychain에서 토큰 가져오기
return UserDefaults.standard.string(forKey: "authToken") ?? ""
}
}
// MARK: - 응답 모델
struct TracksResponse: Codable {
let tracks: [Track]
let total: Int
let page: Int
}
struct SearchResponse: Codable {
let results: [Track]
let query: String
let total: Int
}
💾 9. 캐싱 전략 — 오프라인 재생 구현
좋은 음악 앱은 오프라인에서도 동작해야 해! 📶
FileManager를 활용한 오디오 파일 캐싱 시스템을 구현해보자.
import Foundation
class CacheService {
static let shared = CacheService()
// 메모리 캐시 (이미지용)
private let memoryCache = NSCache<NSString, NSData>()
// 캐시 디렉토리 경로
private var cacheDirectory: URL {
let paths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
let cacheDir = paths[0].appendingPathComponent("MusicCache")
// 디렉토리 없으면 생성
if !FileManager.default.fileExists(atPath: cacheDir.path) {
try? FileManager.default.createDirectory(
at: cacheDir,
withIntermediateDirectories: true
)
}
return cacheDir
}
// 다운로드 디렉토리 (오프라인 재생용)
private var downloadDirectory: URL {
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
let downloadDir = paths[0].appendingPathComponent("Downloads")
if !FileManager.default.fileExists(atPath: downloadDir.path) {
try? FileManager.default.createDirectory(
at: downloadDir,
withIntermediateDirectories: true
)
}
return downloadDir
}
// MARK: - 오디오 파일 다운로드 & 캐싱
func downloadTrack(_ track: Track, progress: @escaping (Double) -> Void) async throws -> URL {
let localURL = downloadDirectory.appendingPathComponent("\(track.id).mp3")
// 이미 다운로드된 파일이 있으면 바로 반환
if FileManager.default.fileExists(atPath: localURL.path) {
return localURL
}
// URLSession으로 다운로드
let (tempURL, _) = try await URLSession.shared.download(from: track.streamURL)
// 임시 파일을 영구 위치로 이동
try FileManager.default.moveItem(at: tempURL, to: localURL)
return localURL
}
// MARK: - 캐시된 트랙 URL 반환 (없으면 스트리밍 URL)
func resolveURL(for track: Track) -> URL {
let localURL = downloadDirectory.appendingPathComponent("\(track.id).mp3")
if FileManager.default.fileExists(atPath: localURL.path) {
return localURL // 로컬 파일 사용
}
return track.streamURL // 스트리밍 URL 사용
}
// MARK: - 캐시 크기 계산
func calculateCacheSize() -> Int64 {
var totalSize: Int64 = 0
let enumerator = FileManager.default.enumerator(
at: downloadDirectory,
includingPropertiesForKeys: [.fileSizeKey]
)
while let fileURL = enumerator?.nextObject() as? URL {
let fileSize = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
totalSize += Int64(fileSize)
}
return totalSize
}
// MARK: - 캐시 삭제
func clearCache() throws {
let contents = try FileManager.default.contentsOfDirectory(
at: downloadDirectory,
includingPropertiesForKeys: nil
)
for file in contents {
try FileManager.default.removeItem(at: file)
}
}
// 특정 트랙 캐시 삭제
func removeCache(for track: Track) {
let localURL = downloadDirectory.appendingPathComponent("\(track.id).mp3")
try? FileManager.default.removeItem(at: localURL)
}
// 다운로드 여부 확인
func isDownloaded(_ track: Track) -> Bool {
let localURL = downloadDirectory.appendingPathComponent("\(track.id).mp3")
return FileManager.default.fileExists(atPath: localURL.path)
}
}
🔍 10. 검색 기능 구현 — SearchViewModel
검색 기능은 사용자 경험에서 정말 중요해. 🔍
Combine의 debounce를 활용해서 타이핑할 때마다 API를 호출하지 않고 일정 시간 후에만 검색하는 스마트한 검색을 구현해보자.
import SwiftUI
import Combine
class SearchViewModel: ObservableObject {
@Published var searchText: String = ""
@Published var searchResults: [Track] = []
@Published var isSearching: Bool = false
@Published var recentSearches: [String] = []
@Published var errorMessage: String?
private var cancellables = Set<AnyCancellable>()
private let networkService = NetworkService.shared
init() {
setupSearchBinding()
loadRecentSearches()
}
private func setupSearchBinding() {
// 검색어 변경 감지 + debounce (0.5초 후 검색)
$searchText
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.sink { [weak self] query in
Task {
await self?.performSearch(query: query)
}
}
.store(in: &cancellables)
// 검색어가 비면 결과 초기화
$searchText
.filter { $0.isEmpty }
.sink { [weak self] _ in
self?.searchResults = []
self?.isSearching = false
}
.store(in: &cancellables)
}
@MainActor
func performSearch(query: String) async {
isSearching = true
errorMessage = nil
do {
searchResults = try await networkService.searchTracks(query: query)
saveRecentSearch(query)
} catch {
errorMessage = error.localizedDescription
searchResults = []
}
isSearching = false
}
// MARK: - 최근 검색어 관리
private func saveRecentSearch(_ query: String) {
var recent = recentSearches
recent.removeAll { $0 == query }
recent.insert(query, at: 0)
recentSearches = Array(recent.prefix(10)) // 최대 10개
UserDefaults.standard.set(recentSearches, forKey: "recentSearches")
}
private func loadRecentSearches() {
recentSearches = UserDefaults.standard.stringArray(forKey: "recentSearches") ?? []
}
func clearRecentSearches() {
recentSearches = []
UserDefaults.standard.removeObject(forKey: "recentSearches")
}
}
// MARK: - SearchView
struct SearchView: View {
@StateObject private var viewModel = SearchViewModel()
@EnvironmentObject var playerVM: PlayerViewModel
var body: some View {
NavigationView {
VStack(spacing: 0) {
// 검색 바
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("아티스트, 곡, 앨범 검색", text: $viewModel.searchText)
.textFieldStyle(.plain)
if !viewModel.searchText.isEmpty {
Button(action: { viewModel.searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(12)
.padding(.horizontal, 16)
.padding(.vertical, 12)
if viewModel.isSearching {
ProgressView()
.padding()
} else if viewModel.searchText.isEmpty {
// 최근 검색어
recentSearchesView
} else {
// 검색 결과
searchResultsView
}
}
.navigationTitle("검색")
}
}
private var recentSearchesView: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("최근 검색")
.font(.headline)
Spacer()
Button("모두 지우기") {
viewModel.clearRecentSearches()
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
ForEach(viewModel.recentSearches, id: \.self) { query in
Button(action: { viewModel.searchText = query }) {
HStack {
Image(systemName: "clock")
.foregroundColor(.secondary)
Text(query)
.foregroundColor(.primary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
Spacer()
}
.padding(.top, 8)
}
private var searchResultsView: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(viewModel.searchResults) { track in
TrackRowView(track: track)
.onTapGesture {
playerVM.play(
track: track,
in: viewModel.searchResults
)
}
}
}
}
}
}
debounce(for: .milliseconds(500))는 사용자가 타이핑을 멈춘 후 0.5초가 지나야 검색을 실행해. 이렇게 하면 불필요한 API 호출을 크게 줄일 수 있어. 서버 비용도 절약되고 UX도 좋아지는 일석이조! 🎯
⚡ 11. 성능 최적화 — 부드러운 스크롤과 빠른 로딩
음악 앱에서 성능은 정말 중요해. 트랙 리스트가 수천 개라도 부드럽게 스크롤되어야 하거든. 🚀
// MARK: - 최적화된 트랙 행 뷰
struct TrackRowView: View, Equatable {
let track: Track
// Equatable 구현으로 불필요한 리렌더링 방지
static func == (lhs: TrackRowView, rhs: TrackRowView) -> Bool {
lhs.track.id == rhs.track.id && lhs.track.isLiked == rhs.track.isLiked
}
var body: some View {
HStack(spacing: 12) {
// 앨범 아트 (AsyncImage로 비동기 로딩)
AsyncImage(url: track.albumArtURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
Color.gray.opacity(0.3)
.overlay(Image(systemName: "music.note"))
case .empty:
Color.gray.opacity(0.2)
.overlay(ProgressView().scaleEffect(0.5))
@unknown default:
EmptyView()
}
}
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 8))
// 트랙 정보
VStack(alignment: .leading, spacing: 3) {
Text(track.title)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
Text(track.artistName)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
// 재생 시간
Text(track.formattedDuration)
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
}
// MARK: - 무한 스크롤 구현
class LibraryViewModel: ObservableObject {
@Published var tracks: [Track] = []
@Published var isLoading: Bool = false
@Published var hasMoreData: Bool = true
private var currentPage = 1
private let pageSize = 20
private var loadTask: Task<Void, Never>?
func loadMoreIfNeeded(currentTrack: Track) {
guard let index = tracks.firstIndex(of: currentTrack) else { return }
// 마지막에서 5개 전에 미리 로딩
let thresholdIndex = tracks.index(tracks.endIndex, offsetBy: -5)
if index >= thresholdIndex {
loadNextPage()
}
}
func loadNextPage() {
guard !isLoading && hasMoreData else { return }
// 이전 태스크 취소
loadTask?.cancel()
loadTask = Task { @MainActor in
isLoading = true
do {
let newTracks = try await NetworkService.shared.fetchTracks(
page: currentPage,
limit: pageSize
)
// Task가 취소됐으면 중단
guard !Task.isCancelled else { return }
tracks.append(contentsOf: newTracks)
currentPage += 1
hasMoreData = newTracks.count == pageSize
} catch {
print("로딩 실패: \(error)")
}
isLoading = false
}
}
}
🎤 12. 오디오 이퀄라이저 — AVAudioEngine 활용
좀 더 고급 기능을 원한다면? 🎛️
AVAudioEngine을 사용하면 이퀄라이저, 리버브, 피치 조절 같은 오디오 이펙트를 구현할 수 있어!
import AVFoundation
class AudioEngineService {
private let engine = AVAudioEngine()
private let playerNode = AVAudioPlayerNode()
private let equalizer = AVAudioUnitEQ(numberOfBands: 10)
private let reverb = AVAudioUnitReverb()
private let timePitch = AVAudioUnitTimePitch()
init() {
setupAudioEngine()
}
private func setupAudioEngine() {
// 노드 연결
engine.attach(playerNode)
engine.attach(equalizer)
engine.attach(reverb)
engine.attach(timePitch)
// 체인 연결: playerNode → equalizer → reverb → timePitch → output
engine.connect(playerNode, to: equalizer, format: nil)
engine.connect(equalizer, to: reverb, format: nil)
engine.connect(reverb, to: timePitch, format: nil)
engine.connect(timePitch, to: engine.mainMixerNode, format: nil)
// 이퀄라이저 기본 설정
setupEqualizer()
// 엔진 시작
do {
try engine.start()
} catch {
print("오디오 엔진 시작 실패: \(error)")
}
}
private func setupEqualizer() {
// 10밴드 이퀄라이저 설정 (Hz)
let frequencies: [Float] = [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
for (index, frequency) in frequencies.enumerated() {
let band = equalizer.bands[index]
band.filterType = .parametric
band.frequency = frequency
band.bandwidth = 1.0
band.gain = 0.0 // 기본값: 0dB
band.bypass = false
}
}
// MARK: - 이퀄라이저 프리셋
enum EQPreset {
case flat, bass, treble, vocal, electronic
var gains: [Float] {
switch self {
case .flat:
return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
case .bass:
return [6, 5, 4, 2, 0, 0, 0, 0, 0, 0]
case .treble:
return [0, 0, 0, 0, 0, 0, 2, 4, 5, 6]
case .vocal:
return [-2, -1, 0, 2, 4, 4, 3, 2, 1, 0]
case .electronic:
return [4, 3, 0, -2, -1, 0, 2, 3, 4, 4]
}
}
}
func applyPreset(_ preset: EQPreset) {
for (index, gain) in preset.gains.enumerated() {
equalizer.bands[index].gain = gain
}
}
// MARK: - 재생 속도 조절 (0.5x ~ 2.0x)
func setPlaybackRate(_ rate: Float) {
timePitch.rate = rate
}
// MARK: - 피치 조절 (-12 ~ +12 반음)
func setPitch(_ semitones: Float) {
timePitch.pitch = semitones * 100 // cents 단위
}
// MARK: - 리버브 설정
func setReverb(wetDryMix: Float) {
reverb.wetDryMix = wetDryMix // 0 ~ 100
}
}
🧪 13. 테스트 작성 — 안정적인 앱을 위해
코드를 다 짰다고 끝이 아니야! 😤
XCTest로 핵심 로직을 테스트해서 버그를 미리 잡아보자.
import XCTest
@testable import MusicStreamingApp
class PlayerViewModelTests: XCTestCase {
var viewModel: PlayerViewModel!
var mockAudioService: MockAudioPlayerService!
override func setUp() {
super.setUp()
mockAudioService = MockAudioPlayerService()
viewModel = PlayerViewModel(audioService: mockAudioService)
}
override func tearDown() {
viewModel = nil
mockAudioService = nil
super.tearDown()
}
// MARK: - 재생 테스트
func testPlayTrack() {
// Given
let track = Track.mock()
// When
viewModel.play(track: track)
// Then
XCTAssertEqual(viewModel.currentTrack, track)
XCTAssertTrue(mockAudioService.loadAndPlayCalled)
}
// MARK: - 다음 곡 테스트
func testSkipToNext() {
// Given
let tracks = Track.mockArray(count: 3)
viewModel.play(track: tracks[0], in: tracks)
// When
viewModel.skipToNext()
// Then
XCTAssertEqual(viewModel.currentTrack, tracks[1])
XCTAssertEqual(viewModel.currentIndex, 1)
}
// MARK: - 셔플 테스트
func testShuffleMode() {
// Given
let tracks = Track.mockArray(count: 10)
viewModel.play(track: tracks[0], in: tracks)
// When
viewModel.toggleShuffle()
// Then
XCTAssertTrue(viewModel.isShuffleEnabled)
}
// MARK: - 반복 모드 테스트
func testRepeatModeToggle() {
// 처음: none
XCTAssertEqual(viewModel.repeatMode, .none)
// 첫 번째 토글: all
viewModel.toggleRepeatMode()
XCTAssertEqual(viewModel.repeatMode, .all)
// 두 번째 토글: one
viewModel.toggleRepeatMode()
XCTAssertEqual(viewModel.repeatMode, .one)
// 세 번째 토글: none
viewModel.toggleRepeatMode()
XCTAssertEqual(viewModel.repeatMode, .none)
}
}
// MARK: - Mock 클래스
class MockAudioPlayerService: AudioPlayerService {
var loadAndPlayCalled = false
var lastPlayedTrack: Track?
override func loadAndPlay(track: Track) {
loadAndPlayCalled = true
lastPlayedTrack = track
}
}
// MARK: - 테스트용 Mock 데이터
extension Track {
static func mock(id: String = UUID().uuidString) -> Track {
Track(
id: id,
title: "테스트 곡",
artistName: "테스트 아티스트",
albumTitle: "테스트 앨범",
albumArtURL: nil,
streamURL: URL(string: "https://example.com/test.mp3")!,
duration: 180
)
}
static func mockArray(count: Int) -> [Track] {
(0..<count).map { Track.mock(id: "\($0)") }
}
}
🚀 14. App Store 출시 전 체크리스트
앱을 다 만들었다면 이제 출시 준비를 해야지! 🎉
App Store 심사에서 자주 걸리는 포인트들을 미리 체크해보자.
💡 15. 실전 팁 & 자주 하는 실수들
마지막으로 실제 개발하면서 자주 마주치는 문제들과 해결책을 정리해줄게! 🛠️
AVAudioSession.sharedInstance().setCategory(.playback)를 빠뜨리면 백그라운드에서 음악이 멈춰. 반드시 앱 시작 시 설정!
네트워크 콜백에서 직접 Published 프로퍼티를 업데이트하면 크래시.
@MainActor나 DispatchQueue.main.async 필수!
클로저에서
self를 캡처할 때 [weak self]를 빠뜨리면 메모리 누수 발생. Instruments로 꼭 확인!
트랙을 바꿀 때마다 새 AVPlayer를 만들면 메모리 낭비.
replaceCurrentItem(with:)을 사용해서 재사용하자!
cancellables Set에 저장하지 않으면 구독이 즉시 해제돼. 반드시 .store(in: &cancellables)!
이런 기능들을 하나씩 추가하다 보면 어느새 진짜 상용 앱 수준이 될 거야! 💪
그리고 만약 네가 만든 앱을 다른 사람들에게 알리고 싶다면, 재능넷(https://www.jaenung.net) 같은 재능 공유 플랫폼에서 iOS 개발 서비스를 제공하거나, 앱 개발 노하우를 공유하는 것도 좋은 방법이야! 🎯
📚 마무리 — 전체 아키텍처 정리
자, 지금까지 Swift로 음악 스트리밍 앱을 만드는 전 과정을 살펴봤어! 🎉
마지막으로 전체 아키텍처를 한 번 더 정리해보자.
음악 스트리밍 앱 개발, 생각보다 할 만하지? 😄
처음엔 복잡해 보여도 하나씩 레이어를 쌓아가다 보면 어느새 완성된 앱이 눈앞에 있을 거야.
개발하다가 막히는 부분이 생기면 Apple Developer Documentation, WWDC 세션, 그리고 재능넷에서 Swift 전문가를 찾아 도움을 받는 것도 좋은 방법이야! 🤝
자, 이제 Xcode 열고 시작해보자! 🚀🎵
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.