YunDaeHyeon
← Lab

Swift NWPathMonitor

스터디2025.07.09
  • SwiftUI
  • Swift
  • Combine
  • DispatchQueue

NWPathMonitor

NWPathMonitor는 네트워크 변경 사항을 모니터링하고 대응할 수 있도록 해주는 관찰자

import Network

let monitor = NWPathMonitor() // 네트워크 상태 모니터링을 위한 모니터를 인스턴스화
// 위처럼 단순하게 해도 되지만 특정 상황을 모니터링 하고 싶다면 생성자에 타입 집어넣기
let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
let monitor = NWPathMonitor(prohibitedInterfaceTypes: [.wifi, .cellular])

monitor.start(queue: .global()) // 백그라운드 큐에서 모니터링 후 업데이트 사항 전달받기

// pathUpdateHandler는 네트워크 연결 상태에 따라 핸들링해줌
self.monitor.pathUpdateHandler = { path in
	if path.status == .satisfied {
		print("인터넷 연결 OK")
	} else {
		print("인터넷 연결 끊김")
	}

// 한 번 pathUpdateHandler를 통해 상태 업데이트를 받게 되면 모니터 인스턴스를 통해 현재 연결상태 확인 가능
if monitor.currentPath.isExpensive {
    // 셀룰러, 핫스팟으로 연결될 경우
} 

if monitor.currentPath.isConstrained {
    // 데이터 전송속도 400kb이하 (저전송 데이터 모드)
}

// 간단하게 해주고 싶다면 아래처럼 활용하면 가능. onAppear 부분에서 모니터를 start 해주고 
// onDisappear 부분에서 모니터 cencel을 통해 모니터 종료

import SwiftUI
import Network
import Combine

// MARK: - NetworkMonitor
/// 네트워크 연결 상태를 실시간으로 모니터링하는 ObservableObject
/// NWPathMonitor를 사용하여 네트워크 상태 변화를 감지하고 UI에 반영
final class NetworkMonitor: ObservableObject {
    /// 현재 네트워크 연결 상태 (true: 연결됨, false: 연결 안됨)
    /// @Published로 선언하여 값이 변경되면 자동으로 View 업데이트
    @Published var isConnected: Bool = true
    
    /// Apple의 Network 프레임워크에서 제공하는 네트워크 경로 모니터
    /// 네트워크 인터페이스(WiFi, Cellular 등)의 상태 변화를 감지
    private let monitor = NWPathMonitor()
    
    /// 네트워크 모니터링을 실행할 별도의 백그라운드 큐
    /// 메인 스레드를 블로킹하지 않기 위해 별도 큐 사용
    private let queue = DispatchQueue(label: "NetworkMonitor")
    
    init() {
        startMonitoring()
    }
    
    /// 네트워크 모니터링 시작
    private func startMonitoring() {
        // 네트워크 경로 상태가 변경될 때마다 호출되는 핸들러 설정
        monitor.pathUpdateHandler = { [weak self] path in
            // UI 업데이트는 반드시 메인 스레드에서 실행
            DispatchQueue.main.async {
                // path.status가 .satisfied면 네트워크 연결됨
                // .unsatisfied면 연결 안됨, .requiresConnection은 연결 시도 중
                self?.isConnected = path.status == .satisfied
            }
        }
        
        // 지정한 큐에서 모니터링 시작
        monitor.start(queue: queue)
    }
    
    /// 네트워크 모니터링 중지
    /// 메모리 누수 방지를 위해 객체 해제 시 반드시 호출 필요
    func stopMonitoring() {
        monitor.cancel()
    }
    
    deinit {
        stopMonitoring()
    }
}

// MARK: - NetworkStatusBorderModifier
/// 네트워크 연결 상태에 따라 화면 테두리 색상을 변경하는 ViewModifier
/// 연결됨: 초록색, 연결 안됨: 빨간색
struct NetworkStatusBorderModifier: ViewModifier {
    /// App에서 주입한 NetworkMonitor를 EnvironmentObject로 받음
    /// 모든 하위 View에서 동일한 NetworkMonitor 인스턴스 공유
    @EnvironmentObject var networkMonitor: NetworkMonitor
    
    func body(content: Content) -> some View {
        content
            // overlay를 사용하여 기존 View 위에 테두리 추가
            .overlay(
                // RoundedRectangle로 테두리 모양 정의
                RoundedRectangle(cornerRadius: 0)
                    // stroke로 테두리만 그리기 (내부는 투명)
                    // lineWidth: 테두리 두께
                    .stroke(
                        // 네트워크 상태에 따라 색상 변경
                        networkMonitor.isConnected ? Color.green : Color.red,
                        lineWidth: 4
                    )
            )
            // 애니메이션 효과 추가: 색상 변경 시 부드럽게 전환
            .animation(.easeInOut(duration: 0.3), value: networkMonitor.isConnected)
    }
}

// MARK: - View Extension
/// View에 쉽게 적용할 수 있도록 편의 메서드 제공
extension View {
    /// 네트워크 상태 테두리를 View에 적용
    /// 사용법: SomeView().networkStatusBorder()
    func networkStatusBorder() -> some View {
        self.modifier(NetworkStatusBorderModifier())
    }
}

// MARK: - 적용된 IssueTrackerApp
@main
struct IssueTrackerApp: App {
    private let container = DIContainer.shared
    
    /// 인증 상태를 관리하는 Provider
    @StateObject private var authProvider: AuthProvider
    
    /// 화면 전환을 관리하는 Coordinator
    @StateObject private var loginCoordinator = LoginCoordinator()
    
    /// 네트워크 상태를 모니터링하는 Monitor
    /// App 생명주기와 함께 생성되고 소멸됨
    @StateObject private var networkMonitor = NetworkMonitor()

    init() {
        // 임시: 직접 생성하여 주입 (추후 Assembler 패턴으로 개선 예정)
        let keychainManager = KeychainManager()
        let authProvider = AuthProvider(keychainManager: keychainManager)
        self._authProvider = StateObject(wrappedValue: authProvider)

        // 의존성 등록
        registerAuthDependencies(authProvider: authProvider)
        registerLoginDependencies()
        registerIssueListDependencies()
    }

    var body: some Scene {
        WindowGroup {
            // Group으로 감싸서 if-else 전체에 modifier 적용 가능하게 함
            Group {
                if authProvider.isLoggedIn {
                    // 로그인된 상태: IssueList 화면 표시
                    NavigationStack(path: $loginCoordinator.path) {
                        loginCoordinator.build(.issueListView)
                            .navigationDestination(
                                for: NavigationRoute.self,
                                destination: loginCoordinator.build(_:))
                    }
                } else {
                    // 로그인 안 된 상태: Login 화면 표시
                    NavigationStack(path: $loginCoordinator.path) {
                        loginCoordinator.build(.loginView)
                            .navigationDestination(
                                for: NavigationRoute.self,
                                destination: loginCoordinator.build(_:))
                    }
                }
            }
            // AuthProvider를 환경에 주입 (하위 모든 View에서 접근 가능)
            .environmentObject(authProvider)
            // NetworkMonitor를 환경에 주입 (Modifier에서 사용)
            .environmentObject(networkMonitor)
            // 네트워크 상태 테두리 적용 (전체 화면에 적용됨)
            .networkStatusBorder()
        }
    }

    // MARK: - 인증 의존성
    private func registerAuthDependencies(authProvider: AuthProvider) {
        container.register(AuthProviderProtocol.self, lifetime: .singleton) { authProvider }
    }

    // MARK: - LoginView 의존성
    private func registerLoginDependencies() {
        container.register(LoginRepositoryProtocol.self, lifetime: .singleton) {
            LoginRepository(
                requester: container.resolve(RequesterProtocol.self),
                keychainManager: container.resolve(KeychainManagerProtocol.self)
            )
        }

        container.register(LoginUseCaseProtocol.self, lifetime: .singleton) {
            LoginUseCase(repository: container.resolve(LoginRepositoryProtocol.self))
        }

        container.register(LoginViewModel.self, lifetime: .transient) {
            LoginViewModel(
                loginUseCase: container.resolve(LoginUseCaseProtocol.self),
                authProvider: container.resolve(AuthProviderProtocol.self)
            )
        }
    }

    // MARK: - IssueListView 의존성
    private func registerIssueListDependencies() {
        container.register(IssueListViewModel.self, lifetime: .transient) {
            IssueListViewModel(authProvider: container.resolve(AuthProviderProtocol.self))
        }
    }
}

// MARK: - 테스트용 Preview
#Preview {
    ContentView()
        .environmentObject(NetworkMonitor())
        .networkStatusBorder()
}

struct ContentView: View {
    var body: some View {
        VStack {
            Text("네트워크 상태 테스트")
                .font(.largeTitle)
            Text("WiFi나 비행기 모드를 켜고 끄면")
            Text("화면 테두리 색상이 변경됩니다")
        }
        .padding()
    }
}
import SwiftUI
import Network
import Combine

// MARK: - Notification Names
extension Notification.Name {
    /// 네트워크 연결 상태가 변경되었을 때 발송되는 Notification
    static let networkStatusDidChange = Notification.Name("networkStatusDidChange")
}

// MARK: - NetworkMonitor
/// 네트워크 연결 상태를 실시간으로 모니터링하고 NotificationCenter를 통해 알림
/// NWPathMonitor를 사용하여 네트워크 상태 변화를 감지하고 Notification 발송
final class NetworkMonitor {
    /// 싱글톤 인스턴스
    static let shared = NetworkMonitor()
    
    /// 현재 네트워크 연결 상태 (true: 연결됨, false: 연결 안됨)
    private(set) var isConnected: Bool = true
    
    /// Apple의 Network 프레임워크에서 제공하는 네트워크 경로 모니터
    private let monitor = NWPathMonitor()
    
    /// 네트워크 모니터링을 실행할 별도의 백그라운드 큐
    private let queue = DispatchQueue(label: "NetworkMonitor")
    
    private init() {
        startMonitoring()
    }
    
    /// 네트워크 모니터링 시작
    private func startMonitoring() {
        monitor.pathUpdateHandler = { [weak self] path in
            guard let self = self else { return }
            
            let newStatus = path.status == .satisfied
            
            // 상태가 변경된 경우에만 Notification 발송
            if self.isConnected != newStatus {
                self.isConnected = newStatus
                
                // NotificationCenter를 통해 상태 변경 알림
                DispatchQueue.main.async {
                    NotificationCenter.default.post(
                        name: .networkStatusDidChange,
                        object: nil,
                        userInfo: ["isConnected": newStatus]
                    )
                }
            }
        }
        
        monitor.start(queue: queue)
    }
    
    /// 네트워크 모니터링 중지
    func stopMonitoring() {
        monitor.cancel()
    }
    
    deinit {
        stopMonitoring()
    }
}

// MARK: - NetworkStatusBorderModifier
/// 네트워크 연결 상태에 따라 화면 테두리 색상을 변경하는 ViewModifier
/// NotificationCenter를 통해 상태 변화를 감지
struct NetworkStatusBorderModifier: ViewModifier {
    /// 현재 네트워크 연결 상태
    @State private var isConnected: Bool = NetworkMonitor.shared.isConnected
    
    func body(content: Content) -> some View {
        content
            .overlay(
                RoundedRectangle(cornerRadius: 0)
                    .stroke(
                        isConnected ? Color.green : Color.red,
                        lineWidth: 4
                    )
            )
            .animation(.easeInOut(duration: 0.3), value: isConnected)
            // View가 나타날 때 Notification 구독
            .onAppear {
                subscribeToNetworkChanges()
            }
    }
    
    /// NotificationCenter를 통해 네트워크 상태 변화 구독
    private func subscribeToNetworkChanges() {
        NotificationCenter.default.addObserver(
            forName: .networkStatusDidChange,
            object: nil,
            queue: .main
        ) { notification in
            if let isConnected = notification.userInfo?["isConnected"] as? Bool {
                self.isConnected = isConnected
            }
        }
    }
}

// MARK: - View Extension
extension View {
    /// 네트워크 상태 테두리를 View에 적용
    func networkStatusBorder() -> some View {
        self.modifier(NetworkStatusBorderModifier())
    }
}

// MARK: - 적용된 IssueTrackerApp
@main
struct IssueTrackerApp: App {
    private let container = DIContainer.shared
    
    @StateObject private var authProvider: AuthProvider
    @StateObject private var loginCoordinator = LoginCoordinator()

    init() {
        // NetworkMonitor 초기화 (싱글톤이지만 명시적으로 시작)
        _ = NetworkMonitor.shared
        
        // 임시: 직접 생성하여 주입
        let keychainManager = KeychainManager()
        let authProvider = AuthProvider(keychainManager: keychainManager)
        self._authProvider = StateObject(wrappedValue: authProvider)

        // 의존성 등록
        registerAuthDependencies(authProvider: authProvider)
        registerLoginDependencies()
        registerIssueListDependencies()
    }

    var body: some Scene {
        WindowGroup {
            Group {
                if authProvider.isLoggedIn {
                    NavigationStack(path: $loginCoordinator.path) {
                        loginCoordinator.build(.issueListView)
                            .navigationDestination(
                                for: NavigationRoute.self,
                                destination: loginCoordinator.build(_:))
                    }
                } else {
                    NavigationStack(path: $loginCoordinator.path) {
                        loginCoordinator.build(.loginView)
                            .navigationDestination(
                                for: NavigationRoute.self,
                                destination: loginCoordinator.build(_:))
                    }
                }
            }
            .environmentObject(authProvider)
            // 네트워크 상태 테두리 적용
            .networkStatusBorder()
        }
    }

    // MARK: - 의존성 등록
    private func registerAuthDependencies(authProvider: AuthProvider) {
        container.register(AuthProviderProtocol.self, lifetime: .singleton) { authProvider }
    }

    private func registerLoginDependencies() {
        container.register(LoginRepositoryProtocol.self, lifetime: .singleton) {
            LoginRepository(
                requester: container.resolve(RequesterProtocol.self),
                keychainManager: container.resolve(KeychainManagerProtocol.self)
            )
        }

        container.register(LoginUseCaseProtocol.self, lifetime: .singleton) {
            LoginUseCase(repository: container.resolve(LoginRepositoryProtocol.self))
        }

        container.register(LoginViewModel.self, lifetime: .transient) {
            LoginViewModel(
                loginUseCase: container.resolve(LoginUseCaseProtocol.self),
                authProvider: container.resolve(AuthProviderProtocol.self)
            )
        }
    }

    private func registerIssueListDependencies() {
        container.register(IssueListViewModel.self, lifetime: .transient) {
            IssueListViewModel(authProvider: container.resolve(AuthProviderProtocol.self))
        }
    }
}

// MARK: - 테스트용 Preview
#Preview {
    ContentView()
        .networkStatusBorder()
}

struct ContentView: View {
    var body: some View {
        VStack {
            Text("네트워크 상태 테스트")
                .font(.largeTitle)
            Text("WiFi나 비행기 모드를 켜고 끄면")
            Text("화면 테두리 색상이 변경됩니다")
        }
        .padding()
    }
}

// MARK: - 다른 곳에서 네트워크 상태를 구독하는 예제
class SomeViewModel: ObservableObject {
    @Published var isNetworkConnected: Bool = NetworkMonitor.shared.isConnected
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // NotificationCenter를 Combine Publisher로 변환하여 구독
        NotificationCenter.default.publisher(for: .networkStatusDidChange)
            .compactMap { $0.userInfo?["isConnected"] as? Bool }
            .assign(to: \.isNetworkConnected, on: self)
            .store(in: &cancellables)
    }
}