Oct 25, 2021, TIL (Today I Learned) - 소켓, Stream 그리고 iOS앱 적용

Inwoo Hwang·2021년 10월 25일
0

TIL

목록 보기
8/8
post-thumbnail

소켓에 대한 공부 및 iOS앱에 적용


소켓(Socket)이란?

소켓의 정의

소켓은 소프트웨어로 작성된 추상적인 개념의 통신 접속점이라고 할 수 있습니다. 네트워크 응용 프로그램은 소켓을 통해 통신망으로 데이터를 송수신하게 됩니다.

소켓은 다음과 같은 한 문장으로 정의할 수 있습니다

정규 유닉스 파일 기술자를 이용하여 다른 프로그램과 정보를 교환하는 방법

용어 설명

유닉스는 운영체제 중 하나입니다. 유닉스에서는 모든 것이 파일로 존재하게 됩니다.

그리고 소켓도 유닉스에서 파일 로 취급받습니다.

모든 유닉스 프로그램은 파일 기술자(File Descriptor) 라는 것을 통해서 입출력(read, write)을 실행합니다.

파일 기술자는 열린 파일을 의미하는 인덱스 번호입니다.

비슷한 맥락으로 소켓 기술자(Socket Descriptor)는 소켓을 만들고 얻은 파일 기술자를 뜻하는 것이죠

종합

서버와 소켓 통신을 하기 위해서는 소켓을 생성하는 작업이 필요합니다.

socket()이라는 함수를 통해 내부적으로 소켓에 사용할 파일을 하나 열고

해당 파일의 인덱스 번호를 소켓함수가 return하게 됩니다.

이때 이 인텍스 번호가 소켓 기술자(Socket Descriptor)가 되는 것이고 해당 소켓 기술자를 활용하여 send(), recv() 하여 통신을 하는 것입니다.

따라서 소켓 통신을 소켓 기술자를 이용하여 send, recv를 하는 통신이라고 이해해 볼 수 있습니다.

소켓의 종류

TCP(Transmission Control Protocol)

Stream Socket

데이터를 메세지 형태로 보내기 위해 IP와 함께 사용되는 프로토콜이 바로 TCP입니다.

  • IP가 데이터 배달을 처리하고,

  • TCP는 데이트 패킷을 추적 & 관리하게 됩니다.

    *데이터를 보낼 때 한 번에 온전한 데이터를 보내는 것 보다 효율성을 위해 데이터를 조각 으로 나누어서 보내게 됩니다. 이 과정에서 나뉜 데이터 조각을 패킷 이라 부르는 것입니다.

    TCP는 각 데이터 패킷에 번호를 부여하여 패킷이 중간에 손실된 부분이 없는지 검증을하고, 목적지에서 나누어진 패킷을 온전한 데이터가 될 수 있도록 재조립을 하게됩니다.

    이렇게 패킷에 번호를 부여하고 재조립하는 과정을 추적관리 라고 볼 수 있습니다.

UDP(User Datagram Protocol)

Datagram Socket

UDP에서 데이터를 Datagram 단위로 처리합니다.

*Datagram이란 독립적인 단계를 지니는 패킷을 뜻합니다.

소켓 통신을 구현할 때 소켓을 만들어 서로 연결을 맺는 구조가 아닌 소켓을 만들어 UDP 서버의 IP, Port로 데이터를 보내버리는 개념입니다.

서버또한 클라이언트의 IP, Port로 데이터를 데이터그램 단위로 보내게 됩니다.

iOS에서 소켓 통신

C언어와 같은 low-level인 경우 경우 소켓기술자를 직접 이용해서 소켓을 구현하게 됩니다. 주소와 포트번호를 설정하는 과정에허 리틀앤디안, 빅앤디안을 바꿔주는 작업이 필요로 하고

connect() 호출 후 성공할 때 send(), recv()과 같은 메서드를 호출해야 합니다.

서버 사이드에서 보자면, bind(), listen(), accep()와 같은 작업이 추가될 뿐 아니라 데이터가 block되지 않게 좀 더 복잡한 작업을 추가해야 합니다.

Stream

이러한 복잡한 작업이 iOS에서는 추상화가 되어있습니다. iOS에서는 socket 연결을 설정하기 위해서 Stream 을 사용하게 됩니다.

Stream 은 말그대로 Stream을 나타내는 추상클래스이고 자식클래스로 InputStreamOutputStream 을 갖고 있습니다.

InputStream 을 활용하여 입력되는 데이터를 읽어올 수 있고

OutputStream 을 통해 데이터를 써서 결과물을 서버로 보낼 수 가 있습니다.

Socket 생성 및 연결

소켓을 연결하는 작업을 3가지 정도로 시도해 봤습니다.

Stream을 활용한 소켓 연결

getStreamsTo(_:port:inputStream:outputStream:)

해당 메서드를 활용하여 NSInputStream객체와 NSOutputStream객체를 생성한 뒤 소켓 연결을 위해 사용할 수 있습니다. 물론 이 과정에서 호스트와 포트에 대한 정보가 필요합니다.

다음과 같이 소켓 연결이 이루어질 수 있습니다.

final class Network: NSObject {
  private var inputStream: InputStream?
  private var outputStream: OutputStream?
  
  func connect(hostName: String, portNumber: Int) {
    Stream.getStreamsToHost(withName: hostName, port: portNumber, inputStream: &inputStream, outputStream: &outputStream)
    
    if inputStream != nil && outputStream != nil { 
      
      // Set Delegate
      inputStream!.delegate = self
      outputStream!.delegate = self
      
      // Schedule Connection
      inputStream!.schedule(in: .main, forMode: .default)
      outputStream!.schedule(in: .main, forMode: .default)
      
      // open
      inputStream!.open()
      outputStream!.open()
    }
  }
}

Stream의 타입 메서드를 활용하여 Host로 연결하는 작업을 진행하게 됩니다. 이 과정에서 호스트이름, 포트 그리고 inputStream과 outputStream의 주소값에 포인터를 넣어주는 작업이 필요합니다.

 Stream.getStreamsToHost(withName: hostName, port: portNumber, inputStream: &inputStream, outputStream: &outputStream)

각 stream객체가 nil이 아니라는 검증과정을 if문을 통해 진행한 다음

inputStream, outputStream의 대리자 설정을 하는 작업을 진행하였습니다. 이 경우 Network가 stream의 대리자가 되겠네요. 이렇게 위임 작업을 하는 이유는 StreamDelgate 를 활용하여 주어진 Stream이 받는 이벤트에 대한 처리를 할 수 있게 됩니다. 이런식으로요

extension Network: StreamDelegate {
  func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
    switch eventCode {
    case .hasBytesAvailable:
      print("new message received")
    case .endEncountered:
      // close socket here
      print("end Message received")
    case .errorOccurred:
      print("error occurred")
    case .hasSpaceAvailable:
      print("has space available")
    default:
      print("some other event...")
    }
  }

이렇게 delegate에게 Stream의 이벤트처리를 하기 위해서는 delegate작업이 필요하고 delegate가 메세지를 받기 위해서는 Stream이 runloop에 스케줄되어 있어야 합니다. 이 경우 기본상태로 mainRunLoop로 inputStream과 outputStream을 스케줄하게되죠.

inputStream!.schedule(in: .main, forMode: .default)
outputStream!.schedule(in: .main, forMode: .default)

스케줄 준비가 되면 stream을 open() 하여 소켓을 생성하고 읽고 쓰는 작업이 가능하게 설정을 하게 됩니다. open하게 되면 클라이언트가 직접적으로 사용이 가능해집니다. 단 이경우에는 runloop로 스케줄을 한 상태이니 Delegate인 Network가 쓰고 읽는 작업을 관리할 수 있게 됩니다.

++애플이 제공하는 아카이브 문서에 의하면 "The NSStream class does not support connecting to a remote host on iOS" 라고 하더라구요. 즉 Stream 클래스를 사용하게 되면 원격 호스트에 접근할 수 없으니 원격 서버로 접근하는 것도 지원하지 않는다고 하는데...주변 개발자분들 얘기 들어보니 Stream을 활용하여 원격호스트 접근에 성공하신 분도 계셔서 이 부분은 조금 더 확인이 필요한 것 같습니다.

물론 아카이브된 문서는 애플이 내용을 보장하지 않는다는 의미이기 때문에 해당 내용이 outdated된걸 수도 있구요.

무튼 아카이브 문서에 따르면 CFStream이 원격호스트 연결을 지원하기 때문에 CFStream을 사용하라고 적혀있더라구요!!

그래서 또 CFStream에 대해서 공부 해 봤죠!!! 🤩

CFStream을 활용한 소켓 연결

func CFStreamCreatePairWithSocketToHost(_ alloc: CFAllocator!, 
                                      _ host: CFString!, 
                                      _ port: UInt32, 
                                      _ readStream: UnsafeMutablePointer<Unmanaged<CFReadStream>?>!, 
                                      _ writeStream: UnsafeMutablePointer<Unmanaged<CFWriteStream>?>!)

메서드를 활용하여 inputStream과 outputStream 객체를 생성하여 소켓을 연결할 수 있습니다.

CFStream또한 Stream의 구현방식과 유사합니다

class Network: NSObject {
  var inputStream: InputStream?
  var outputStream: OutputStream?
  weak var delegate: ChatRoomDelegate?
  let maxReadLength = 4096
  
  func connect(hostName: String, portNumber: Int) {
    var readStream: Unmanaged<CFReadStream>?
    var writeStream: Unmanaged<CFWriteStream>?
    
    CFStreamCreatePairWithSocketToHost(kCFAllocatorDefault, hostName as CFString, portNumber, &readStream, &writeStream)
    guard let readStreamRetainedValue = readStream?.takeRetainedValue(),
          let writeStreamRetainedValue = writeStream?.takeRetainedValue() else {
      return
    }
    
    inputStream = readStreamRetainedValue
    outputStream = writeStreamRetainedValue
    
    inputStream?.delegate = self
    
    inputStream?.schedule(in: .current, forMode: .common)
    outputStream?.schedule(in: .current, forMode: .common)
    
    inputStream?.open()
    outputStream?.open()
  }
}
CFStreamCreatePairWithSocketToHost(kCFAllocatorDefault, hostName as CFString, portNumber, &readStream, &writeStream)

inputStream과 outputStream에 메모리를 할당하기 위한 할당기(allocator)가 필요하고 이 경우 그냥 디폴트 allocator인 kcFAllocatorDefault를 전달인자로 넣어주었습니다.

hostName 또한 CoreFoundation 클래스에서 사용하기 위해 CFString으로 타입캐스팅을 해 주었구요.

var readStream: Unmanaged<CFReadStream>?
var writeStream: Unmanaged<CFWriteStream>?

Core Foundation 클래스를 활용하여 inputStream과 outputStream을 구현하기위해서 readStream과 writeStream을 선언한 뒤

각 Stream의 주소값에 포인터를 넣어주는 작업을 위해 &readStream 그리고 &writeStreamCFStreamCreatePairWithSocketToHost 의 전달인자로 넣어주었습니다.

guard let readStreamRetainedValue = readStream?.takeRetainedValue(),
          let writeStreamRetainedValue = writeStream?.takeRetainedValue() else {
      return
    }

이제 객체로 사용되기 적합해진 readStream과 writeStream의 takeRetainedValue() 메서드를 통해 readStream과 writeStream의 주소로부터 값을 가져온 뒤

inputStream = readStreamRetainedValue
outputStream = writeStreamRetainedValue

사용할 inputStream과 outputStream에 할당해 주는 작업을 해주었습니다. CFStream은 CocoaFoundation의 Stream과 toll-free briedged 관계..즉 교환해서 사용가능한 관계이기 때문에 할당 또한 가능합니다.

URLSessionStreamTask를 활용한 소켓 연결

마지막으로 URLSessionStreamTask 또한 소켓연결로서 활용될 수 있지 않을까 생각 해보았습니다. 위 두 방법 같은 경우 소켓 생성과정에서 활용되는 메서드들이 deprecated 되어있기 때문에 추후에 사용이 제한될 수 있기 때문에 시도해 볼만한 가치가 있다 생각한거죠!!

func streamTask(withHostName hostname: String, 
           port: Int) -> URLSessionStreamTask
final class Network: NSObject  {
    
//    weak var delegate: MessageTransferable?
    var urlSession: URLSessionProtocol
    var streamTask: URLSessionStreamTaskProtocol?
    private var inputStream: InputStream?
    private var outputStream: OutputStream?
    
    init(urlSession: URLSessionProtocol = URLSession.shared) {
        self.urlSession = urlSession
    }
    
     func connect(hostName: String, portNumber: Int) {
        
        let configuration = URLSessionConfiguration.default
        configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
        urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
       
        self.streamTask = urlSession.streamTask(withHostName: hostName, port: portNumber)
        streamTask?.captureStreams()
        streamTask?.resume()
        
        
    }
}

URLSessionstreamTask 를 통해 URLSessionStreamTask 객체를 생성할 수 있습니다.

URLSessionStreamTask객체는 비동기적으로 읽고 쓰는 작업을 실행할 수 있고 모든 작업은 queue에 저장되어있다가 serial하게 실행되게 됩니다.

StreamTask를 생성한 뒤 inputStream과 outputStream을 생성하기 위해서는 captureStreams() 메서드가 필요합니다.

streamTask?.captureStreams()

해당 메서드가 호출이 되면 URLSessionStreamDelegate

func urlSession(_ session: URLSession, streamTask: URLSessionStreamTask, didBecome inputStream: InputStream, outputStream: OutputStream)

메서드가 호출이 되면서 inputStream과 outputStream의 세팅작업이 가능 해 집니다.

func urlSession(_ session: URLSession, streamTask: URLSessionStreamTask, didBecome inputStream: InputStream, outputStream: OutputStream) {
        self.inputStream = inputStream
        self.outputStream = outputStream
        
        self.inputStream?.delegate = self

        self.inputStream?.schedule(in: .current, forMode: .common)
        self.outputStream?.schedule(in: .current, forMode: .common)

        self.inputStream?.open()
        self.outputStream?.open()

    }

그렇게 되면 해당 메서드 내부에서 inputStream과 outputStream의 생성이 가능해지고 위임작업, 스케줄링과 open() 작업이 가능해 져서 소켓이 정상적으로 사용될 수 있게 생성이 되는 것이라 생각했지만...

Trouble: streamTask야 왜 읽지를 못하니...

URLSessionStreamTask의 문제점

왠걸...streamTask의 .capture() 메서드를 통해 inputStream과 outputStream 새팅을 해주니까 서버로 write하는데 까지는 문제가 없었지만...서버로 부터 데이터를 읽어오는데 핵심 역할을 하는 StreamDelegate 가 정상적으로 data를 받아서 처리를하지 못하는 문제를 직면했습니다.

self.inputStream?.delegate = self 를 통해 Network가 stream의 대리자가 되도록 설정을 하면 받아온 데이터가 StreamDelegate의 stream(_:handle) 메서드를 통해 받아올 수 있는 데이터를 감지할 수 있습니다.

이 delegate의 메서드 덕분에 지속적인 polling이 아니라 필요할 때만 데이터를 받아올 수 있게 됩니다.

아래와 같은 방식으로 만약 가져올 수 있는 데이터가 있는 상황이다(.hasBytesAvailable)를 Stream의 Event에서 알려주면 그 때 데이터를 읽도록(readAvailableBytes(stream: InputStream)) 메서드를 호출하면 되는 것이지요

extension Network: StreamDelegate {
    func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
        switch eventCode {
        case .hasBytesAvailable:
            guard let inputStream = aStream as? InputStream else { return }
            readAvailableBytes(stream: inputStream)
            NSLog("new message received")
        case .endEncountered:
            disconnect()
            NSLog("socketIsClosed")
        case .errorOccurred:
            NSLog("error occurred")
        case .hasSpaceAvailable:
            NSLog("has space available")
        default:
            NSLog("some other event...")
        }
    }
}

그런데....URLSessionStreamTask로 inputStream과 outputStream 세팅을 하니 데이터를 작성할 수는 있지만 무슨일인지 delegate의 stream(_:handle) 메서드가 호출되지 않는 문제를 맞닥드렸습니다. 꽤 오래 고민하며 문제를 해결하려 하였으나 streamTask를 활용해서는 문제를 해결하지 못하였고..

TroubleShooting: CFStream을 사용하자!

두 번째 방법인 CFStream을 활용하여 소켓 연결을 구현하였습니다. 현재는 CFStreamCreatePairWithSocketToHost(_:_:_:_:_:) 메서드를 사용할 수 있지만 언제 deprecated된 메서드 이기에 언제 사용이 불가능해질지 모르지만 애플이 대체할 수 있는 메서드를 들고 오겠지요😁

소켓 연결 방식에 대한 소결론

  • Stream → getStreamsTo(_:port:inputStream:outputStream:) 통한 소켓 연결
    • 문제점: 아카이브 문서에 따르면 Stream Class는 원격 호스트에 접근하는 것을 지원하지 않는다고 합니다. 물론 아카이브 문서가 오래된 문서이고 애플이 더 이상 보증하지 않는다는 점 그리고 주변 분들 중 Stream을 사용하여 원격 호스트 접근에 성공하신 분들이 계시기 때문에 사용하면 안된다 라고 할 수는 없지만 그래도...베스트 방법은 아닌 것 같다는 생각이 들었습니다. getStreamsTo 타입 메서드가 deprecated된 이상 미래지향적이 아닌 방법인 건 자명한듯 싶구요.
  • CFStream → CFStreamCreatePairWithSocketToHost(_:_:_:_:_:) 통한 소켓 연결
    • 아카이브 문서 에서는 CFStream을 통해서 원격 호스트에 접근할 수 있다고 하니 조금 더 믿음직스러운 접근방법이 아닌가 싶습니다. 다만 연결을 위해 필요한 CFStreamCreatePairWithSocketToHost 메서드가 deprecated 된게 흠이긴 하지만요.
  • URLSessionStreamTask → streamTask(withHostName:port:) 통한 소켓 연결
    • readwrite 를 좀 더 high-level에서 사용하기도 하고 위 메서드들 처럼 deprecated된 메서드를 사용하지 않아도 되니 처음에는 더 나은 접근이지 않나 생각을 했지만....제일 중요한 Stream 이벤트를 감지하고 처리해주는 작업이 되지 않으니 반쪽짜리 기능이어서 사용하기 적절하지 않다고 생각이 되었습니다.

(혹시나 URLStremTask를 활용하여 소켓연결에 성공하신 분 계시면 코멘트 남겨주시거나 연락 남겨주시면 감사하겠습니다 😂)

데이터를 어떻게 읽지?

소켓 통신을 위에서 세팅을 해 보았는데요. 이제 데이터를 읽어야겠죠?

데이터는 다음과 같은 메서드를 통해 읽어볼 수 있습니다.

func readAvailableBytes(stream: InputStream) {
        let availableBytes = UnsafeMutablePointer<UInt8>.allocate(capacity: ConnectionConfiguration.maximumReadLength)
        
        while stream.hasBytesAvailable {
            guard let numberOfBytesRead = inputStream?.read(availableBytes, maxLength: ConnectionConfiguration.maximumReadLength) else { return }
            
            if numberOfBytesRead < 0,
               let error = stream.streamError {
                NSLog(error.localizedDescription)
                break
            }
            try? delegate?.convertDataToTexts(buffer: availableBytes, length: numberOfBytesRead)
        }
    }
let availableBytes = UnsafeMutablePointer<UInt8>.allocate(capacity: ConnectionConfiguration.maximumReadLength)

먼저 웹소켓에서 사용되는 데이터는 UTF8로 인코딩 되어있으니 UnsafeMutablePointer 를 활용하여 UInt8 타입의 초기화 되지 않는 메모리를 할당합니다.

guard let numberOfBytesRead = inputStream?.read(availableBytes, maxLength: ConnectionConfiguration.maximumReadLength) else { return }

그리고 InputStream이 데이터를 받아 올 수 있는 상황을 조건일 때 inputStream의 read메서드를 통해 bytes들을 buffer에 저장합니다. 해당 메서드를 통해 반환되는 값을 통해 bytes를 제대로 읽었는지 확인할 수 있고 필요에 따라 화면에 보여지도록 사용할 수도 있습니다.

String(bytesNoCopy: availableBytes, length: length, encoding: .utf8, freeWhenDone: true)

예를들면 이와 같은 String의 이니셜라이저를 통해 읽어온 데이터를 String 객체로 초기화 하여 화면에 문자로서 보여지도록 활용할 수 있습니다.

이렇게 구현한 readAvailableBytes(stream:) 메서드를 StreamDelegate의 이벤트처리에 맞게 실행을 시켜주면 데이터를 읽어올 준비가 끝납니다.

extension Network: StreamDelegate {
    func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
        switch eventCode {
        case .hasBytesAvailable:
            guard let inputStream = aStream as? InputStream else { return }
          	// 데이터를 읽어오자!!!
            readAvailableBytes(stream: inputStream)
            NSLog("new message received")
        case .endEncountered:
            disconnect()
            NSLog("socketIsClosed")
        case .errorOccurred:
            NSLog("error occurred")
        case .hasSpaceAvailable:
            NSLog("has space available")
        default:
            NSLog("some other event...")
        }
    }
}

데이터를 어떻게 전달하지?

사용자가 입력한 문자열을 Data타입으로 변환한 뒤 다음과 같은 방법으로 수신자에게 데이터의 컨텐츠를 OutputStream을 활용하여 전달할 수 있습니다.

func writeWithUnsafeBytes(using data: Data) {
        data.withUnsafeBytes { unsafeBufferPointer in
            guard let buffer = unsafeBufferPointer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
                NSLog("error while writing chat")
                return
            }
            outputStream?.write(buffer, maxLength: data.count)
        }
    }
data.withUnsafeBytes { unsafeBufferPointer in
            guard let buffer = unsafeBufferPointer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
                NSLog("error while writing chat")
                return
            }

OutputStream으로 write하기 위해서는 dataBuffer로 접근을 해야 합니다. withUnsafeBytes 라는 메서드를 통해 주어진 data의 buffer로 접근을 할 수 있습니다.

outputStream?.write(buffer, maxLength: data.count) 

bufferpointer를 통해 buffer에 접근을 할 수 있고. 접근한 버퍼와 데이터의 갯수를 write의 전달인자에 할당을 하게되면 outputStream을 활용하여 데이터를 수신자에게 전달할 수 있게 됩니다😊

[참고]:

iOS) Socket 통신 (1) Socket 기초, TCP, UDP

Apple Developer Document | CFStream

Apple Developer Document | Stream

profile
james, the enthusiastic developer

0개의 댓글