[Swift/iOS] 네트워크 감지 방법에 관한 고찰

Inwoo Hwang·2022년 7월 1일
4
post-thumbnail

마주쳤던 문제:

프로젝트 진행 중 네트워크 감지와 관련된 로직을 프로젝트에 추가해야 할 일이 생겼었습니다.

관련된 문제에 대한 여러 사람의 다양한 시각이 궁금하였고 그래서 iOS Developer KR에 자문을 구해봤습니다:

다음과 같은 글을 남겼습니다.

"안녕하세요, 선배님들. 현재 인터넷 연결을 감지하여 인터넷 연결이 없는 경우 안내 뷰를 화면에 띄우는 작업을 하고 있습니다. 관련돼서 질문이 있어서 글 남깁니다. 1. 네트워크 연결을 판단하는 방법은 공부하였는데요 특정 뷰컨이 아닌 모든 뷰컨에서 인터넷 연결이 끊겼을 때 뷰를 화면에 띄우고 싶은데요. 혹시 참고할만한 자료나 키워드를 알려주시면 감사하겠습니다."

받은 조언

다행히도 많은 분들이 조언을 주셨고 받은 답변을 간추려보면 다음과 같습니다:

  • "1번에서 가장 간단하게 떠오른 방법은 viewDidLoad에서 네트워크 상태 확인하는 로직 추가하고 그 vc를 상속받는 방법이 하나 생각나네요" - from one of 멋진 단톡방 멤버

  • "윈도우 이용해서 띄우는 방법이 있을거에요. 오픈 소스들에 notifier 구현체를 보시면 vc상관 없이 띄우는데 윈도우 이용해요. 기본적으로 제공하는 것도 있고 오픈소스도 있는데 Reachability라는걸 이용하시면 쉽게 구현가능해요." - from one of 멋진 단톡방 멤버

다음과 같은 조언을 받았고 조언들 속에 있는 키워드를 활용하여 문제를 해결 해 보자

문제 해결 흐름

1. NWPathMonitor vs Reachability

네트워크 유무를 감지하는 데 있어서 Network 프레임워크를 활용할 지 단톡방에서 알려주신 Reachability 에 대해 알아보니 외부라이브러리라서 추후 업데이트가 안될 가능성이 있지만 그래도 낮은 버전타겟(iOS 8)까지 지원을 해준다는 장점이 있었다.

하지만 아무리 봐도 요즘 거의 모든 기업들 중에서 최소타겟이 iOS12보다 낮은 곳이 없기도 하고 애플에서 제공하는 프레임워크를 사용하는 게 추후 유지보수할 때도 문제가 적을 것이라 생각을 하기 때문에 Network 프레임워크에서 제공하는 NWPathMonitor를 사용하기로 하였습니다.

NWPathMonitor 는 다음과 같이 만들어 보았습니다:

import Foundation
import Network

final class NetworkMonitor {
    private let queue = DispatchQueue.global(qos: .background)
    private let monitor: NWPathMonitor
    
    init() {
        monitor = NWPathMonitor()
        dump(monitor)
        print("------------")
    }
    
    func startMonitoring(statusUpdateHandler: @escaping (NWPath.Status) -> Void) {
        monitor.pathUpdateHandler = { path in
            DispatchQueue.main.async {
                statusUpdateHandler(path.status)
            }
        }
        monitor.start(queue: queue)
    }
    
    func stopMonitoring() {
        monitor.cancel()
    }
}

NWPathMonitor 객체의 .pathUpdateHandler 를 통해 현 네트워크 상태를 전달 받은 뒤 이 상태를 탈출시켜서 다른 곳에서 호출할 수 있도록 설계 해 보았습니다. 그리고 이 작업은 메인쓰레드로 보내서 추후 UI를 상황에 따라 업데이트 해야할 때 쓰레드문제가 생기지 않게 하였구요

2. BaseViewController를 활용 해 볼까요?

다음으로 얘기가 나왔던 BaseViewController를 한번 만들어 봤습니다.

import UIKit

class BaseViewController: UIViewController {
    
    private let networkMonitor = NetworkMonitor()

    private var noNetworkView: NoNetworkView = NoNetworkView(frame: CGRect(x: UIScreen.main.bounds.minX, y: UIScreen.main.bounds.minY, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
    )
    
    deinit {
        networkMonitor.stopMonitoring()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        networkMonitor.startMonitoring(statusUpdateHandler: { [weak self] connectionStatus in
            switch connectionStatus {
            case .satisfied:
                self?.dismissNetworkErrorView()
                print("dismiss networkError View if is present")
            case .unsatisfied:
                self?.showNetworkErrorView()
                print("No Internet!! show network Error View")
            default:
                break
            }
        })
    }
    
    private func showNoNetworkView() {
        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
           let keyWindow = windowScene.keyWindow {

            keyWindow.addSubview(noNetworkView)
            keyWindow.bringSubviewToFront(noNetworkView)
        } else {
            print("either no connectedScene or no keyWindow available")
        }
    }
    
    private func dismissNoNetworkView() {
        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
           let keyWindow = windowScene.keyWindow {
            guard let noNetworkView = keyWindow.subviews.last as? NoNetworkView else {
                print("the presenting view is not noNetworkView")
                return }
            noNetworkView.removeFromSuperview()
        }
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
    }
}

조언 받은대로 다음과 같이 viewDidLoad에서 네트워크 감지를 시작한 뒤 switch문을 활용하여 각 상황에 따른 분기처리를 통해 상태에 맞는 UI처리를 해 주었습니다.

deinit 시점에서 networkMonitor를 cancel 해 주어서 해당 뷰컨트롤러를 상속받은 모든 뷰컨트롤러들이 해제되는 시점에 각 뷰컨트롤러에서 네트워크감지를 중지하도록 하였습니다. 추가적인 리소스 낭비를 방지하기 위해서요!

override func viewDidLoad() {
        super.viewDidLoad()
        networkMonitor.startMonitoring(statusUpdateHandler: { [weak self] connectionStatus in
            switch connectionStatus {
            case .satisfied:
                self?.dismissNoNetworkView()
                print("dismiss networkError View if is present")
            case .unsatisfied:
                self?.showNoNetworkView()
                print("No Internet!! show network Error View")
            default:
                break
            }
        })
    }

위 코드는 네트워크 연결이 끊길 경우 보여질 화면을 띄우는 로직을 담은 메서드입니다:

위에 보이는 noNetworkView를 적절한 타이밍에 띄워주기 위해서 keyWindow에 접근한 뒤 해당 window에 noNetworkViewaddSubview 하고 .bringSubviewToFront 를 하여 그 어떤 상황이라도 해당 뷰가 최상단에 위치하여 화면을 덮도록 로직을 짜봤습니다.

private func showNoNetworkView() {
    // MARK: - baseViewController에서 window를 접근하여 window의 subview에서 noNetworkView에 접근하는 방법
        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
           let keyWindow = windowScene.keyWindow {

            keyWindow.addSubview(noNetworkView)
            keyWindow.bringSubviewToFront(noNetworkView)
        } else {
            print("either no connectedScene or no keyWindow available")
        }
    }
    
    private func dismissNoNetworkView() {
    // MARK: - baseViewController에서 window를 접근하여 window의 subview에서 noNetworkView에 접근하는 방법
        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
           let keyWindow = windowScene.keyWindow {
            guard let networkErrorView = keyWindow.subviews.last as? networkErrorView else {
                print("the presenting view is not noNetworkView")
                return }
            noNetworkView.removeFromSuperview()
        }
    }

noNetworkView 를 제거하는 방식도 고민해 봤는데요.

keyWindow의 최상단 뷰가 noNetworkView 인지 아닌지 판단한 뒤 해당 뷰가 noNetworkView인 경우 window로부터 제거하는 방향으로 접근 해 보았습니다.

3. BaseViewController는 방법이 아닌것 같다!

단톡을 보던 중 다음과 같은 조언을 확인하였습니다:

"vc 기반으로 하게 되면 위에 이야기 한대로 baseVC등을 생각해야 하거나 해당 vc를 벗어나게 되면 hiding time이 안되었는데도 사라지거나 하는 문제등등 꾸준히 다른 문제가 발생하더라구요.
윈도우도 몇몇가지 문제가 발생하긴 하는데 전부 해결가능한 수준이고 오히려 vc로 만드는 것보다 훨 낫다라고 결론 내린적이 있어요". - from one of 멋진 단톡방 멤버들

어떤 문제가 생길 수 있을까 군데 군데 dumpprint 문을 활용하여 로그를 찍어놓았는데요. 당연한 얘기겠지만 다음과 같이 BaseViewController를 상속받은 모든 뷰컨트롤러가 화면에 띄워질 때 마다 새로운 NWPathMonitor가 생성되는 것을 확인할 수 있었습니다.

▿ Network.NWPathMonitor #0
  ▿ currentPath: none
    - status: Network.NWPath.Status.unsatisfied
    - availableInterfaces: 0 elements
    - isExpensive: false
    - supportsIPv4: false
    - supportsIPv6: false
    - supportsDNS: false
    - internalGateways: 0 elements
    - localEndpoint: nil
    - remoteEndpoint: nil
    - nw: nil
  - nw: Default evaluator #1
    - super: NSObject
  - _pathUpdateHandler: nil
  - _queue: nil
<NetworkErrorExample.InitialViewController: 0x12f5065d0>
---------------
▿ Network.NWPathMonitor #0
  ▿ currentPath: none
    - status: Network.NWPath.Status.unsatisfied
    - availableInterfaces: 0 elements
    - isExpensive: false
    - supportsIPv4: false
    - supportsIPv6: false
    - supportsDNS: false
    - internalGateways: 0 elements
    - localEndpoint: nil
    - remoteEndpoint: nil
    - nw: nil
  - nw: Default evaluator #1
    - super: NSObject
  - _pathUpdateHandler: nil
  - _queue: nil
  <NetworkErrorExample.SecondViewController: 0x157e08230>
  ---------------

매번 새로운 NWPathMonitor 객체를 생성하게된다면 리소스 낭비를 야기한다고 판단하였습니다. 가령 100개가 넘는 뷰컨트롤러가 startMonitoring을 통해서 각 각NoNetworkView 를 만들게 된다면 100개나 해당되는 NoNetworkView를 생성하고 지우는 작업을 하게되는데 이는 더더욱 비효율적일것이라 생각이 들었습니다. 그래서 BaseViewController 방식이 답이 아니라고 생각이 들었습니다.

4. InitialViewController에서 NetworkMonitor를 활용 해 볼까?

물론 keyWindow의 최상단에 NoNetworkView 를 올려놓는 로직을 그래도 가져와 InitialViewController, 제일 첫 화면에 띄워질 ViewController에 NetworkMonitor객체를 생성하여 다음과 같이 InitialViewController를 구현해 볼 수도 있습니다.

class InitialViewController: UIViewController {
    private let networkMonitor = NetworkMonitor()

    private var noNetworkView: NoNetworkView = NoNetworkView(
      frame: CGRect(
        x: UIScreen.main.bounds.minX, 
        y: UIScreen.main.bounds.minY, 
        width: UIScreen.main.bounds.width, 
        height: UIScreen.main.bounds.height)
    )
  
    deinit {
        print("stoped monitoring network")
        networkMonitor.stopMonitoring()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        print(self)
        print("---------------")
        
        networkMonitor.startMonitoring(statusUpdateHandler: { [weak self] connectionStatus in
            // 네트워크상태 감지 후 분기 처리
        })
    }
    
    private func showNetworkErrorView() {
        // NoNetworkView 상단에 띄우기
    }
    
    private func dismissNetworkErrorView() {
        // 네트워크에 연결될 경우 NoNetworkView dismiss
}

이렇게 첫 뷰컨트롤러에 네트워크 감지를 설정 해 놓는 경우 불필요한 네트워크 모니터를 중복으로 생성하는 것을 방지할 수 있습니다. 또한 statusUpdateHandler 또한 여러 번 호출되는 것을 방지할 수도 있어서 BaseViewController 에서 생기는 리소스 낭비 문제를 해결할 수 있었습니다.

5. 그럼에도 하나의 window를 활용할 경우 생길 수 있는 문제점

이렇게 문제를 해결했음에도 불구하고 단톡방에서 나온 의견 중에서 VC방식을 활용하게 되면 View를 화면에 띄우고 제거하는 과정에서 문제가 생길 수도 있다는 의견이 보였습니다. 그래서 곰곰히 생각 해 봤습니다.

여러가지 상황을 고려해 봤을 때 NoNetworkView가 화면에 제대로 띄워지지 않을 수 있는 경우는 크게 두 가지 경우가 될 수 있겠다 생각이 들었습니다.

1. keyWindow의 최상단 뷰가 NoNetworkView가 아닌 경우 생길 수 있는 문제

현재 인터넷 연결이 끊겼다 재연결된 후 NoNetworkView 를 지우는 로직은 아래와 같이 구현되어 있습니다:

private func dismissNoNetworkView() {
        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
           let keyWindow = windowScene.keyWindow {
            guard let noNetworkView = keyWindow.subviews.last as? NoNetworkView else {
                print("the presenting view is not noNetworkView")
                return }
            noNetworkView.removeFromSuperview()
        }
    }

keyWindow의 최상단 뷰가 NoNetworkView인 경우 해당 뷰를 제거하는 로직의 메서드인걸 확인할 수 있는데요.

'만약 최상단 뷰가 NoNetworkView가 아니면 어떡하지?' 라는 생각이 들기 시작했습니다. 🤔

예를 들면 기획서에서 "배터리가 20% 아래일 경우 배터리를 충전하세요 View를 최상단에 띄워주세요" 라는 내용이 포함되어있다고 가정해 봅시다.

위 가정에서 네트워크가 연결되어있지 않는 상황에서 배터리가 20% 아래로 떨어질 경우

최상단 뷰가 NoNetworkView가 아닌 LowBatteryView가 되게 됩니다.

이런 경우에서는 최상단뷰가 NoNetworkView가 아니기 때문에 NoNetworkView를 화면에서 제거해야함에도 불구하고 정상적으로 NoNetworkView를 제거할 수 없는 문제가 생길 수 있다는 생각이 들었습니다..

2. 휴먼에러 때문에 생길 수 있는 문제

NoNetworkView 그리고 NetworkErrorView 와 같이 매우 유사한 이름의 뷰가 존재한다면. 최상단 뷰가 noNetworkView인지 판단하는 과정을 수정하다가 다음과 같은 휴먼에러를 발생시킬 수 있다는 생각이 들었습니다.

guard let noNetworkView = keyWindow.subviews.last as? NetworkErrorView else {
                print("the presenting view is not noNetworkView")
                return }
            noNetworkView.removeFromSuperview()

이럴 경우 최상단뷰를 검증하는 로직에서 타입이 틀리기 때문에 (NoNetworkView != NetworkErrorView)

NoNetworkView를 최상단에서 제거할 수 없는 문제가 생길 수도 있다는 생각이 들었습니다.

위에 언급한 두 이유 때문에 InitialViewController에서 네트워크 감지를 하는 것도 좋지 못한 방법인것 같다고 판단하게 되었습니다.

6. SceneDelegate에서 별도의 window를 만들어서 문제를 해결 해 보자

위에서 언급한 문제 때문에 딜레마에 빠진 와중에 다음과 같은 답변이 눈에 들어오게되었습니다.

"아니면 sceneDelegate에 networkMonitor를 하나 만들어서 할 수 있는 방법도 있지 않을까 생각해봅니다."

"keywindow 사용하시면 최상위 뷰컨 찾을 필요도 없을거에요. 윈도우도 뷰중에 하나인데...현재 앱의 모든 뷰를 보여주기 위한 제일 최상위 뷰라고 생각하셔야 하고 이거말고 다른 윈도우를 하나 더 위에 덮어서 띄운다라고 보심되요"

그래서 한 번 구현 해 보았습니다.

다음과 같은 자료를 참고 했습니다 :)

UIWindow | Apple Developer Document

windowLevel | Apple Developer Document

makeKeyAndVisible() | Apple Developer Document

  1. 먼저 errorWindow라는 이름의 window 변수를 선언 해 보았습니다.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

	var window: UIWindow?
  var errorWindow: UIWindow?
  
  var networkMonitor: NetworkMonitor = NetworkMonitor()
}

물론 네트워크 감지에 필요한 NetworkMonitor 인스턴스 또한 생성해 두었구요

  1. Scene WillConnectTo
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // MARK: - network monitor logic
        networkMonitor.startMonitoring(statusUpdateHandler: { [weak self] connectionStatus in
            switch connectionStatus {
            case .satisfied:
                self?.removeNetworkErrorWindow()
                print("dismiss networkError View if is present")
            case .unsatisfied:
                self?.loadNetworkErrorWindow()
                print("No Internet!! show network Error View")
            default:
                break
            }
        })
	}

scene이 연결되는 시점에 네트워크 모니터링을 시작하였습니다.

  1. loadNetworkErrorWindow & removeErrorWindow

그다음으로는 네트워크 연결이 끊어질 경우 errorWindowmakeKeyAndVisible 설정하여 네트워크 연결이 끊어질 경우 NoNetworkErrorView 가 더해진 errorWindow 를 기존 윈도우 위에 띄워주도록 설정하였습니다.

*makeKeyAndVisible메서드를 실행하지 않으면 해당 윈도우는 화면에 보이지 않습니다!!

private func loadNetworkErrorWindow(on scene: UIScene) {
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.windowLevel = .statusBar
            window.makeKeyAndVisible()
            
            let noNetworkView = NoNetworkView(frame: window.bounds)
            window.addSubview(noNetworkView)
            self.errorWindow = window
        }
    }

여기서 가장 신경 쓴 부분은 window Level입니다. errorWindowwindowLevel.statusBar 로 설정하여서 메인 윈도우보다 더 높은 스텍에 쌓은 뒤 화면에 보여질 수 있도록 하였습니다.

그리고 인터넷에 다시 연결될 경우 해당 window를 keyWindow로부터 해지하는 것 + hidden + nil로 설정하여 화면에서 제거하고 리소스를 제거하는 것을 확실히 하였습니다 😆 이제 메인 윈도우 스텍 위에 쌓여있던 errorWindow는 화면에서 사라지고 유저는 자기가 기존에 자신이 보고있던 화면으로 다시 돌아가게 됩니다. 😁

private func removeNetworkErrorWindow() {
        errorWindow?.resignKey()
        errorWindow?.isHidden = true
        errorWindow = nil
    }
  1. sceneDidDisconnect

마지막으로 scene이 연결해지될 때 필요한 리소스를 해지해야 하니 networkMonitor를 정지하는 것도 이 시점에서 하는게 맞다 판단되어서 sceneDidDisconnect에서 monitoring을 멈추게 하여 앱이 종료된 후 불필요한 리소스를 낭비하는 것을 방지하였습니다.

func sceneDidDisconnect(_ scene: UIScene) {
        // Called as the scene is being released by the system.
        // This occurs shortly after the scene enters the background, or when its session is discarded.
        // Release any resources associated with this scene that can be re-created the next time the scene connects.
        // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
        networkMonitor.stopMonitoring()
    }

7. SceneDelegate에서 여러개 window를 활용하는 방법의 장점:

물론 공식문서에는 window관련되어서 주의해야할 점에 대해서 명시하고 있습니다. windowLevel을 활용할 경우 "The ordering of windows within a given window level is not guaranteed." 라고 합니다.

즉 동일한 window level을 가진 window가 쌓이는 순서는 보장되지 않기 때문에 동일한 level의 window 여러개가 떠 있는 경우 주의가 필요하다는 거입니다.

그럼에도 불구하고 sceneDelegate에서 별도의 window를 만들어서 경우에 따라 최상단에 보여져야하는 뷰들을 관리하는게 좋다고 생각하였습니다.

가장 큰 이유는 뷰를 띄우는 과정에서 keyWindow의 최상단뷰가 무엇인지 판단하는 로직이 필요 없게됩니다. 기존에 문제가 될거라고 생각했던 타입캐스팅문제, 휴먼에러 문제에서 벗어나 sceneDelegate에 선언된 윈도우 객체 이름으로 접근한 뒤 뷰를 더하든 빼든 되니까 특정뷰가 화면에 뜨지 않을 것이라는 불확실성에서 벗어날 수 있게 되었습니다.

또한 동일한 windowLevel 문제 또한 인지만하고 있다면 충분히 그리 어렵지 않은 로직으로 풀어낼 수 있다고 생각되었습니다.

결론

위에서 언급된 생각의 흐름의 끝에서 결국 SceneDelegate에서 별도의 window를 생성하여 네트워크 연결이 끊겼을 때 관련된 뷰를 화면에 띄우는 것으로 결론을 내리게 되었습니다.

이렇게 다양한 방면으로 고려할 수 있게 키워드를 제공 해 주신 iOS Developer KR 멤버분들에게 감사를 표하면 이만 글 줄이도록 하겠습니다. 다시 한 번 감사합니다.🥹

(Ps: 혹시나 잘못된 내용이 발견되면 언제든 알려주시면 최대한 빠른 시일 안에 수정하도록 하겠습니다~~🙇‍♂️)

profile
james, the enthusiastic developer

1개의 댓글

comment-user-thumbnail
2024년 1월 15일

잘 읽었습니다. 이걸 보고 제가 학습하는 깊이에 대해 반성하게 되었습니다

답글 달기