Socket 통신이란?
Writer: 박지혜

Socket

소켓의 사전적 의미로는 ‘구멍’, ‘연결’, ‘콘센트’의 의미를 가집니다.

주로 전기 부품을 규격에 따라 연결할 수 있게 만들어진 ‘구멍 형태의 연결부’를 일컫는 단어인데, 가정에서 흔히 볼 수 있는 콘센트 구멍을 떠올리면 쉽게 이해할 수 있습니다. 네트워크 프로그래밍에서 소켓에 대한 의미도 사전적의미를 크게 벗어나지 않습니다. 프로그램이 네트워크에서 송수신할 수 있도록, 네트워크 환경에 연결할 수 있게 만들어진 연결부 를 바로 소켓이라고 합니다.

이러한 네트워크에 연결하기 위한 소켓은 통신을 위한 프로토콜에 맞게 만들어져야 합니다.

소켓으로 네트워크 통신 기능을 구현하기 위해서는 소켓을 만드는 것과, 만들어진 소켓을 통해 데이터를 주고 받는 절차에 대한 이해가 필요하고, 운영체제 및 프로그래밍 언어에 종속적으로 제공되는 소켓 API 사용법을 숙지해야합니다.


클라이언트 소켓(Client Socket)과 서버 소켓(Server Socket)

두 개의 시스템(또는 프로세스)가 소켓을 통해 네트워크 연결을 만들어내기 위해서는 최초 어느 한곳에서 그 대상이 되는 곳으로 연결을 요청해야합니다. IP 주소와 포트 번호 를 통해 식별되는 대상에게 자신의 데이터 송수신을 위한 네트워크 연결을 수립할 의사가 있음을 알려야하죠. 그런데, 최초 한 곳에서 무작정 연결을 시도한다고 해서, 그 요청이 무조건 받아들여지고 연결이 되어 데이터를 주고받을 수 있는 것은 아닙니다. 한곳에서 요청을 보낸다고 하더라고 그 대상 시스템이 요청을 받아들일 준비가 되어있지 않다면 해당 요청은 무시되고 연결은 이루어지지 않습니다.

그렇기 때문에 요청을 받아들이는 곳에서도 어떤 연결 요청을 받아들일 것인지를 미리 시스템에 등록하는 절차가 필요하고, 그 이후 요청이 수신되었을 때 해당 요청을 처리할 수 있도록 준비해야 합니다.

따라서 두개의 시스템(또는 프로세스)이 소켓을 통해 데이터 통신을 위한 연결을 만들기 위해서는 연결 요청을 보내는지 또는 요청을 받아들이는 지에 따라 소켓의 역할이 나뉘게 되는데, 전자에 사용되는 소켓을 클라이언트 소켓이라 하고 후자에 사용되는 소켓을 서버 소켓이라고 합니다.

그런데 여기서 중요한 점은 앞서 클라이언트 소켓과 서버소켓이 전혀 별개의 소켓이라고 생각하면 안되는 것 입니다.

소켓의 역할과 구현 절차 구분을 위해 다르게 부르는 것일 뿐 전혀 다른 형태의 소켓이 아니고 단지 역할에 따라 호출되는 API 함수 종류와 순서만 다를뿐 동일한 소켓임 을 알아야합니다. 그리고 이 두 소켓은 절대로 직접 데이터를 주고 받지 않습니다. 서버 소켓은 클라이언트 소켓의 연결 요청을 받아들이는 역할만 수행할 뿐, 직접적인 데이터 송수신은 서버 소켓의 연결, 요청, 수락의 결과로 만들어지는 새로운 소켓을 통해 처리 됩니다.


소켓 API(Socket API)의 실행흐름

Socket


클라이언트 소켓(Client Socket)

클라이언트 소켓은 처음 소켓을 생성(create) 한 다음, 서버 측에 연결(connect) 을 요청합니다. 그리고 서버 소켓에서의 연결이 받아들여지면 데이터를 송수신(send/recv) 하고, 모든 처리가 완료되면 소켓을 닫습니다(close).


서버 소켓(server socket)

서버 소켓은 클라이언트보다는 조금 복잡한 과정을 거칩니다. 우선 클라이언트와 마찬가지로 처음 소켓을 생성(create) 합니다. 그리고 서버가 사용할 IP주소와 포트 번호를 생성해 소켓에 결합(bind) 합니다. 이후 클라이언트로부터 연결 요청이 수신되는 지 주시(listen) 하고, 요청이 수신되면 요청을 받아들어(accept) 데이터 통신을 위한 새로운 소켓을 생성합니다. 그렇게 새로운 소켓을 통해 연결이 수립(ESTABLISH)되면, 클라이언트와 마찬가지로 데이터를 송수신(send/recv) 할 수 있게 되고 데이터 송수신이 완료되면 소켓을 닫습니다(close).


클라이언트 소켓 프로그래밍(Client Socket Programming)

클라이언트 소켓생성(socket())

소켓 통신을 위해 가장 먼저 해야할 일은 소켓을 생성하는 것입니다. 이때 소켓의 종류를 지정할 수 있는데, TCP 소켓을 위해서는 스트림(stream)타입, UDP 소켓을 위해서는 데이터그램(Datagram) 타입을 지정할 수 있습니다.

이 최초 소켓이 만들어지는 시점에는 어떠한 연결 대상 에 대한 정보는 들어있지 않습니다. 그저 껍데기 뿐인 소켓 하나가 만들어진 것 뿐입니다.


연결 요청(connect())

connect() API는 IP주소포트번호 로 식별되는 대상에 연결 요청을 보냅니다.

이 API는 블럭방식으로 동작하는데, 블럭 방식이라는 것은 연결 요청에 대한 결과(성공, 거절 등)가 결정되기 전에는 connect()의 실행이 끝나지 않는 것을 의미합니다. 그렇기 때문에 connect API가 실행되지 마자 실행결과와 관계없이 무조건 결과가 리턴될 것이라고 가정해서는 안됩니다. 이 connect API 호출이 성공하면 이제부터 데이터의 송수신(send/recv) API를 통해 데이터를 주고받을 수 있게 됩니다.


데이터 송수신(send()/recv())

연결된 소켓을 통해 데이터를 보낼때는 send(), 데이터를 받을 때는 recv API를 사용합니다. 이 두 API 또한 블럭 방식으로 동작됩니다. 즉, 두 API 모두 실행결과가 결정되기 전까지는 API에 대한 결과가 리턴되지 않는 것을 의미하죠. 그중에서도 특히 recv() API는 데이터가 수신되지 않거나 에러가 발생하기 전에는 실행이 종료되지 않기 땜누에 데이터 수신 작업은 단순하게 처리하기가 쉽지는 않습니다.

send()의 경우 데이터를 보내는 주체가 자기 자신이기 때문에 얼마만큼의 데이터를 보낼지, 언제 보낼지를 알수 있지만, recv()의 경우에는 통신 대상이 언제, 언떤 데이터를 보낼 지 특정할 수 없기 때문에 해당 API는 한번 실행되면 언제 끝날지 모르는 상태가 됩니다.

따라서 데이터 수신을 위한 recv() API는 별도의 스레드에서 작업이 이루어집니다. 소켓의 생성과 연결이 완료된 후 새로운 스레드를 하나 더 만들어 그곳에서 recv()를 실행하고 데이터가 수신되길 기다리는 것이죠.


소켓닫기(close())

더 이상 데이터 송수신이 필요없게 되면 소켓을 닫기 위해 close() API를 호출합니다. 이렇게 close()에 의해 닫힌 소켓은 더 이상 유효한 소켓이 아니기 때문에 해당 소켓을 사용해 데이터를 송수신할 수 없게 됩니다. 만약 소켓 연결이 종료된 후 다시 데이터를 주고받고자 한다면 다시 한번 소켓의 생성, 연결 과정을 통해 소켓이 데이터를 송수신할 수 있는 상태가 되어야 합니다.


예시

import UIKit
import SocketIO

class SocketIOManager: NSObject {
  static let shared = SocketIOManager()
  var manager = SocketManager(socketURL: URL(string: "http://localhost:9000")!, config: [.log(true), .compress])
  var socket: SocketIOClient!

  override init() {
    super.init()
    socket = self.manager.socket(forNamespace: "/test")
    socket.on("test") { dataArray, ack in   // test로 송신된 이벤트 수신
      print(dataArray)
    }
  }

  func establishConnection() {
    socket.connect()  // 설정한 주소와 포트로 소켓 연결 시도
  }

  func closeConnection() {
    socket.disconnect()  // 소켓 연결 종료
  }

  func sendMessage(message: String, nickname: String) {
    socket.emit("event", ["message" : "This is a test message"])  // event라는 이름으로 뒤 데이터 송신
    socket.emit("event1", [["name" : "ns"], ["email" : "@naver.com"]])
    socket.emit("event2", ["name" : "ns", "email" : "@naver.com"])
    socket.emit("msg", ["nick": nickname, "msg" : message])
  }
}


서버 소켓 프로그래밍(Server Socket Programming)

클라이언트 소켓을 처리하는 과정의 API는 비교적 간단하지만 서버 소켓의 경우 그 처리 과정이 조금 복잡합니다.


서버 소켓 생성(socket())

클라이언트 소켓과 마찬가지로 서버 소켓을 사용하려면 최초에 소켓을 생성해야 합니다.


서버 소켓 바인딩(bind())

bind의 사전적의미로 ‘결합하다’, ‘구속하다’, ‘묶다’등의 의미를 가지고 있습니다.

bind() API에서 사용되는 인자는 두가지 소켓포트번호(또는 IP+포트번호) 입니다. 즉 사전적 의미로 바라보면 소켓과 포트번호를 결합한다는 의미입니다.

보통 시스템에는 많은 수의 프로세스가 동작합니다. 만약 어떤 프로세스가 TCP 또는 UDP 프로토콜을 사용한다면 각 표준에 따라 소켓은 시스템이 관리하는 포트 중 하나의 포트 번호를 사용하게 됩니다. 그런데 만약 소켓이 사용하는 포트 번호가 다른 소켓의 포트 번호와 중복된다면 어떻게 될까요?

모든 소켓이 1000이라는 동일한 포트번호를 사용하게 된다면, 네트워크를 통해 1000번 포트로 어떤 데이터가 수신될 때 어떤 소켓으로 이를 처리해야할 지 결정할 수 없는 문제가 발생하게 될 것 입니다.

그렇기 때문에 운영체제에서는 소켓들이 중복된 포트번호를 사용하지 않게 하기 위해 내부적으로 포트번호화 소켓 연결정보를 관리합니다.

그리고 bind()API에서는 해당 소켓이 지정된 포트 번호를 사용할 것이라는 것을 운영체제에 요청하는 것이 바로 해당 API의 역할입니다. 만약 지정된 포트 번호를 다른 소켓이 사용하고 있다면 bind() API는 에러를 리턴합니다. 즉 일반적으로 서버 소켓은 고정된 포트번호를 사용합니다. 그리고 그 포트 번호를 통해 클라이언트의 연결 요청을 받아들입니다. 그리고 운영체제가 특정 포트 번호를 서버 소켓이 사용하도록 만들기 위해 소켓과 포트 번호를 결합하는데 이를 결합하기 위해 사용하는 API 가 바로 bind()인 것입니다.

이를 소켓바인드, 소켓 바인딩이라고도 부릅니다.


클라이언트 연결 요청 대기(listen())

서버 소켓에 포트번호를 결합하고 나면 서버 소켓을 통해 클라이언트 연결 요청을 받아들일 준비가 되고, 이제는 클라이언트에 의한 연결요청이 수신될 때까지 기다리게 됩니다. listen()API가 그 역할을 수행합니다.

서버 소켓에 바인딩된 포트 번호를 통해 클라이언트의 연결 요청이 있는지 확인하며 대기상태에 머물게 되고, 클라이언트에서 호출된 connect() API에의해 연결요청이 수신되는지 귀 기울이고 있다가 요청이 수신되면 그 때 대기 상태를 종료하고 결과를 리턴합니다. 이렇게 listen() API가 대기 상태에서 빠져나오는 경우는 크게 두가지 입니다.

  1. 클라이언트 요청이 수신되는 경우
  2. 에러가 발생하는 경우

그런데 listen()API가 성공한 경우라도 리턴 값에는 클라이언트 요청에 대한 정보는 들어있지 않는 것이 특징입니다. 이때 반환되는 리턴값에서 판단할 수 있는 것은 단 두가지로 연결 요청이 수신되었는지(success), 그렇지 않고 에러가 발생했는지(fail) 뿐입니다.

그리고 이 클라이언트 연결 요청에 대한 정보는 시스템 내부적으로 관리되는 큐(queue)에 쌓이게 되는데, 이 시점은 클라이언트와의 연결은 아직 완전히 연결된 상태라고는 할 수없는 여전한 대기상태임을 놓치지 말아야 합니다. 이렇게 대기 중이 연결 요청을 큐로부터 꺼내와서 연결을 완료하기 위해서는 accept()API를 호출해야합니다.


클라이언트 연결 수립(accept())

다시 한번, listen() API가 클라이언트 연결 요청을 확인하고 문제없이 리턴(success)한다고 해서, 클라이언트와의 연결 과정이 모두 완료된 것은 아닙니다. 아직 실질적인 소켓 연결(connection)을 수립하는 절차가 남아있습니다. 즉 최종적으로 연결 요청을 받아들이는 역할을 수행하는 것은 accept() API입니다.

연결 요청을 받아들여 소켓 간 연결을 수립하는 것이 바로 이 API의 역할입니다.

그런데 여기서 가장 중요한 점은 최종적으로 데이터 통신을 위해 연결되는 이 소켓은 앞서 bind(), listen() API에서 사용한 서버 소켓이 아니라는 점입니다. 즉, 클라이언트 소켓과 연결이 만들어지는 소켓은 앞서 사용했던 서버 소켓이 아닌 accept()API 내부에서 만들어진 새로운 소켓이라는 점 입니다.

서버 소켓의 핵심역할은 클라이언트의 연결 요청을 수신!하는 것입니다.

이를 위해 bind() 및 listen()을 통해 소켓에 포트번호를 바인딩하고 요청 대기 큐를 생성해 클라이언트의 요청을 대기하였죠. 그리고 이후 accept() API에서 데이터 송수신을 위한 새로운 소켓을 만들고 서버 소켓의 대기 큐에 쌓여있는 첫번째 연결요청을 매핑 시킵니다. 이렇게 하나의 연결 요청을 처리하기 위한 서버 소켓의 역할은 끝나게 됩니다.


데이터 송수신(send()/recv())

이제 실질적인 데이터 송수신은 accept()API에서 생성된 연결이 수립된(Establiched)된 소켓을 통해 처리 됩니다. 데이터를 송수신하는 과정은 클라이언트 소켓 처리 과정 내용과 동일합니다.


소켓 연결 종료(close())

클라이언트 소켓 처리 과정과 마찬가지로 소켓을 닫기 위해 close() API를 호출합니다.

그런데 서버 소켓에서는 close()의 대상이 하나만 있는것이 아니란 것이 중요합니다.
최초 socket() API를 통해 생성한 서커 소켓에 더해 accept() API 호출에 의해 생성된 소켓 또한 관리해야하기 때문이죠.


예시

아래는 Socket.IO 문서에 나오는 Server를 구현하는 예시 코드입니다.

var app = require('http').createServer(handler)  // http 서버를 생성
var io = require('socket.io')(app);  // 소켓 생성
var fs = require('fs');

app.listen(80);  // 80번 포트를 연결해 클라이언트 요청을 대기

// 이제 클라이언트 소켓은 localhost:80으로 연결을 요청

io.on('connection', function (socket) {  // connection되면
  socket.emit('news', { hello: 'world' });  // 클라이언트로 news라는 키로 뒤 객체를 보냄
  socket.on('my other event', function (data) {  
    // 클라이언트에서 서버로 보낸 데이터중 'my other event'라는 키로 들어오는 값을 받아 console.log 출력
    console.log(data);
  });
});


읽어주셔서 감사합니다 🙇‍♀️