콘텐츠 대표 이미지 - Swift 기반 음악 스트리밍 앱 개발 가이드
🎵 Swift Development Guide

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

🎧 AVFoundation부터 실시간 스트리밍까지, 나만의 음악 앱을 만들어보자!

야, 솔직히 말해봐. 🎵 Spotify나 Apple Music 쓰면서 한 번쯤은 "나도 이런 거 만들 수 있을까?" 생각해본 적 있지 않아?
오늘은 그 꿈을 현실로 만들어줄 Swift 기반 음악 스트리밍 앱 개발 가이드를 완전 친절하게 풀어볼게.
겁먹지 마. 차근차근 따라오면 진짜로 동작하는 음악 앱을 만들 수 있어! 🚀

이 가이드는 iOS 개발 기초는 어느 정도 알고 있는 분들을 대상으로 해.
Swift 문법, Xcode 사용법 정도는 알고 있다는 전제 하에 진행할 거야.
그래도 최대한 쉽게 설명할 테니까 걱정 말고 따라와! 😎


음악 스트리밍 앱 아키텍처 한눈에 보기 Swift 음악 앱 AVFoundation 오디오 재생 엔진 URLSession 네트워크 스트리밍 SwiftUI UI 레이어 CoreData 로컬 캐싱 MediaPlayer Framework 잠금화면 / 컨트롤센터 연동 Combine / async-await 비동기 데이터 흐름

🎯 1. 프로젝트 설계 — 뭘 만들 건지 먼저 정하자

코드부터 막 짜기 시작하면 나중에 진짜 후회해. 😅
음악 스트리밍 앱은 생각보다 컴포넌트가 많거든. 먼저 우리가 만들 앱의 핵심 기능을 정리해보자.

🎵 우리가 만들 앱의 핵심 기능 목록 🎧 오디오 스트리밍 재생 ⏯️ 재생/일시정지/스킵 🔀 셔플 & 반복 📋 플레이리스트 관리 🔍 곡 검색 ❤️ 즐겨찾기 🔒 잠금화면 컨트롤 📶 오프라인 캐싱

이 기능들을 구현하기 위해 우리가 사용할 기술 스택을 먼저 확인해보자.

레이어 기술/프레임워크 역할
UI SwiftUI 화면 구성, 애니메이션
오디오 엔진 AVFoundation 스트리밍 재생, 오디오 세션
네트워킹 URLSession + async/await API 통신, 스트림 다운로드
상태 관리 Combine + ObservableObject 재생 상태, 플레이리스트 관리
시스템 연동 MediaPlayer Framework 잠금화면, 컨트롤센터
로컬 저장 CoreData / FileManager 캐싱, 즐겨찾기 저장
💡 팁! 처음부터 모든 기능을 다 구현하려 하지 마. MVP(Minimum Viable Product) 방식으로 핵심 기능부터 완성하고 점진적으로 추가하는 게 훨씬 효율적이야!

🏗️ 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 파싱도 쉽게 할 수 있게 설계할 거야.

📀 Track 모델
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"
    }
}
📋 Playlist 모델
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()
    }
}
💡 Swift 꿀팁! Identifiable을 채택하면 SwiftUI의 ForEach에서 id 파라미터 없이 바로 사용할 수 있어. 코드가 훨씬 깔끔해지지!

🔊 4. AVFoundation으로 오디오 엔진 구축하기

이제 진짜 핵심이야! 🎯
AVFoundation은 Apple이 제공하는 강력한 미디어 프레임워크야.
음악 스트리밍에서는 주로 AVPlayer와 AVPlayerItem을 사용해.

AVFoundation 오디오 파이프라인 스트리밍 서버 HLS / MP3 / AAC AVPlayerItem URL 로딩 버퍼링 관리 AVPlayer 재생 / 일시정지 탐색 (Seek) 볼륨 / 속도 조절 AVAudioSession 오디오 세션 설정 백그라운드 재생 🔊 출력 스피커 / 이어폰 KVO / Combine으로 상태 변화를 실시간 감지 addPeriodicTimeObserver → 재생 진행률 업데이트
🎛️ AudioPlayerService 구현

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로 구현해보자.

플레이어 UI 구조 🎵 ⏮ ⏸ ⏭ 🔀 🔁 ❤️ 풀스크린 플레이어 🎵 ⏸ ⏭ 미니 플레이어 + 트랙 리스트 🔍 팝 재즈 록 검색 화면
🎵 PlayerView (풀스크린 플레이어)
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를 활용한 오디오 파일 캐싱 시스템을 구현해보자.

🗂️ 캐싱 전략 3단계
1
메모리 캐시 — NSCache로 앨범 아트 이미지 빠르게 캐싱
2
디스크 캐시 — FileManager로 오디오 파일 로컬 저장
3
DB 캐시 — CoreData로 트랙 메타데이터 영구 저장
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
                            )
                        }
                }
            }
        }
    }
}
💡 Combine 꿀팁! debounce(for: .milliseconds(500))는 사용자가 타이핑을 멈춘 후 0.5초가 지나야 검색을 실행해. 이렇게 하면 불필요한 API 호출을 크게 줄일 수 있어. 서버 비용도 절약되고 UX도 좋아지는 일석이조! 🎯

⚡ 11. 성능 최적화 — 부드러운 스크롤과 빠른 로딩

음악 앱에서 성능은 정말 중요해. 트랙 리스트가 수천 개라도 부드럽게 스크롤되어야 하거든. 🚀

⚡ 성능 최적화 핵심 포인트 ✅ LazyVStack 사용 ✅ AsyncImage로 이미지 비동기 로딩 ✅ Equatable 채택으로 불필요한 리렌더링 방지 ✅ @MainActor로 UI 업데이트 보장 ✅ Task 취소 처리
// 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 심사에서 자주 걸리는 포인트들을 미리 체크해보자.

🚀 App Store 출시 전 체크리스트 ⚙️ 기술적 요구사항 ✓ Info.plist 오디오 백그라운드 모드 설정 ✓ NSMicrophoneUsageDescription (필요시) ✓ ATS (App Transport Security) 설정 ✓ 메모리 누수 없음 (Instruments 확인) ✓ 크래시 없음 (TestFlight 베타 테스트) ✓ 다크모드 지원 ✓ 다양한 기기 해상도 대응 ✓ 접근성 (VoiceOver) 지원 ✓ 앱 아이콘 모든 사이즈 준비 ✓ 스크린샷 (6.7", 6.5", 5.5" 등) 📋 법적/정책 요구사항 ✓ 음원 저작권 라이선스 확보 ✓ 개인정보 처리방침 URL 등록 ✓ 이용약관 작성 ✓ 인앱결제 구현 (구독 모델) ✓ 앱 설명 및 키워드 최적화 (ASO) ✓ 연령 등급 설정 ✓ GDPR / 개인정보 수집 동의 ✓ 앱 리뷰 노트 작성 ✓ 지원 URL 및 마케팅 URL ✓ 테스트 계정 정보 제공
⚠️ 저작권 주의! 음악 스트리밍 앱에서 가장 중요한 건 음원 저작권이야. 무단으로 음원을 사용하면 법적 문제가 생길 수 있어. 반드시 저작권 라이선스를 확보하거나, 저작권 없는 음원(CC 라이선스)을 사용해야 해. 개발 테스트용으로는 Free Music Archive 같은 곳의 무료 음원을 활용하자!

💡 15. 실전 팁 & 자주 하는 실수들

마지막으로 실제 개발하면서 자주 마주치는 문제들과 해결책을 정리해줄게! 🛠️

🔥 자주 하는 실수 TOP 5

1
오디오 세션 카테고리 미설정
AVAudioSession.sharedInstance().setCategory(.playback)
를 빠뜨리면 백그라운드에서 음악이 멈춰. 반드시 앱 시작 시 설정!
2
메인 스레드에서 UI 업데이트 안 함
네트워크 콜백에서 직접 Published 프로퍼티를 업데이트하면 크래시. @MainActor나 DispatchQueue.main.async 필수!
3
강한 참조 순환 (Retain Cycle)
클로저에서 self를 캡처할 때 [weak self]를 빠뜨리면 메모리 누수 발생. Instruments로 꼭 확인!
4
AVPlayer 중복 생성
트랙을 바꿀 때마다 새 AVPlayer를 만들면 메모리 낭비. replaceCurrentItem(with:)을 사용해서 재사용하자!
5
Combine 구독 해제 안 함
cancellables Set에 저장하지 않으면 구독이 즉시 해제돼. 반드시 .store(in: &cancellables)!
🌟 더 발전시키고 싶다면? 🎵 Apple Music API 연동 🎤 가사 동기화 (LRC 파싱) 📊 오디오 시각화 (FFT) 🤝 소셜 기능 (친구와 공유) 🧠 AI 추천 알고리즘 ⌚ Apple Watch 연동 🏠 HomePod 지원 🎙️ Siri 연동

이런 기능들을 하나씩 추가하다 보면 어느새 진짜 상용 앱 수준이 될 거야! 💪
그리고 만약 네가 만든 앱을 다른 사람들에게 알리고 싶다면, 재능넷(https://www.jaenung.net) 같은 재능 공유 플랫폼에서 iOS 개발 서비스를 제공하거나, 앱 개발 노하우를 공유하는 것도 좋은 방법이야! 🎯


📚 마무리 — 전체 아키텍처 정리

자, 지금까지 Swift로 음악 스트리밍 앱을 만드는 전 과정을 살펴봤어! 🎉
마지막으로 전체 아키텍처를 한 번 더 정리해보자.

전체 아키텍처 — MVVM + Service Layer 🎨 UI Layer (SwiftUI Views) PlayerView | MiniPlayerView | LibraryView | SearchView | PlaylistView @EnvironmentObject로 ViewModel 공유 / @State, @Binding으로 로컬 상태 관리 🧠 ViewModel Layer (ObservableObject) PlayerViewModel | LibraryViewModel | SearchViewModel @Published로 상태 발행 / Combine으로 데이터 흐름 관리 / async/await 비동기 처리 ⚙️ Service Layer AudioPlayerService | NetworkService | CacheService | AudioEngineService AVFoundation / URLSession / FileManager / AVAudioEngine 래핑 💾 Data Layer CoreData (로컬 DB) | FileManager (파일 캐시) | UserDefaults (설정) | Keychain (인증) 외부 API 서버 ↔ 로컬 저장소 동기화
🎯 핵심 요약
1
AVFoundation이 음악 재생의 핵심. AVPlayer + AVAudioSession 조합으로 백그라운드 재생까지 완벽 구현
2
MVVM 패턴으로 UI와 비즈니스 로직을 분리. SwiftUI + Combine의 반응형 프로그래밍이 찰떡
3
MediaPlayer Framework로 잠금화면/컨트롤센터 연동. 사용자 경험의 핵심!
4
async/await + Combine으로 비동기 처리를 깔끔하게. 콜백 지옥 탈출!
5
캐싱 전략으로 오프라인 재생 지원. 사용자 만족도 UP!

음악 스트리밍 앱 개발, 생각보다 할 만하지? 😄
처음엔 복잡해 보여도 하나씩 레이어를 쌓아가다 보면 어느새 완성된 앱이 눈앞에 있을 거야.

개발하다가 막히는 부분이 생기면 Apple Developer Documentation, WWDC 세션, 그리고 재능넷에서 Swift 전문가를 찾아 도움을 받는 것도 좋은 방법이야! 🤝

자, 이제 Xcode 열고 시작해보자! 🚀🎵

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.