Network Socket통신이란? (+HTTP통신이란?)

|

개인적인 연습 내용을 정리한 글입니다.
더 좋은 방법이 있거나, 잘못된 부분이 있으면 편하게 의견 주세요. :)


네트워크를 통해 서버로부터 데이터를 가져오기 위한 통신을 구현하기 위해서 크게 Http 프로그래밍과 Socket 프로그래밍 두가지가 있습니다. 각각의 특징에 대해 정리해보겠습니다.

Socket 프로그래밍

서버와 클라이언트가 특정 포트를 통해 실시간으로 양방향 통신 을 하는 방식

소켓 연결은 TCP/CP 프로토콜을 기반으로 맺어진 네트워크 연결 방식을 의미합니다. 이러한 소켓 연결방식으로 프로그래밍 하는 것을 소켓 프로그래밍이라고 하는데, 소켓 프로그래밍은 서버와 클라이언트가 특정 포트를 통해 연결을 유지하고 있고, 실시간으로 양방향 통신을 할 수 있는 방식을 의미합니다. 필요한 경우 클라이언트만 요청을 보낼 수 있는 HTTP 프로그래밍과 달리 소켓 프로그램이은 서버 역시 클라이언트에게 요청을 보낼 수 있는 것이 가장 큰 특징입니다.

뿐만 아니라, 계속 연결을 유지하는 연결지향형 방식을 지니고 있기 때문에 실시간 통신이 필요한 경우 자주 사용됩니다.

예로들어, 실시간 스트리밍 중계나 실시간 채팅과 같이 즉각적으로 정보를 주고받는 경우 사용하는 것이 특징입니다. 만약 실시간 동영상 스트리밍 방식을 HTTP 프로그래밍으로 구현했다고 가정하게 된다면, 사용자가 서버로 동영상을 ㅛ청하기 위해 동영상이 종료되는 순간까지 계속해서 Request를 보내야하고, 이러한 구조는 연결을 계속적으로 요청하기 때문에 부하가 걸리게 됩니다. 따라서 이러한 경우에는 소켓 프로그래밍을 통해 구현하는 것이 적합합니다.

  • 서버와 클라이언트가 계속 연결을 유지하는 양방향 프로그래밍 방식
  • 서버와 클라이언트가 실시간으로 데이터를 주고받는 상황이 플요한 경우 사용
  • 실시간 동영상 스트리밍이나 온라인 게임 등에 자주 사용

Http 프로그래밍

클라이언트의 요청이 있을때만 서버가 응답하여 해당 정보를 전송하고 곧바로 연결을 종료하는 방식

HTTP 프로그래밍의 가장 큰 특징은 클라이언트의 요청이 있을 때만 서버가 응답하여 처리한 후 바로 연결을 끊는 것입니다. 이러한 통신방식은 클라이언트가 요청을 보내는 경우에만 서버가 응답하는 단방향적 통신 으로 서버가 클라이언트로는 요청을 보낼 수 없는것이 큰 특징입니다.

클라이언트가 웹에서 어떤 링크를 클린한 순간에 클라이언트는 서버로 링크에 포함된 내용을 보내달라고 요청을 보내게 됩니다. 그리고 그 요청에 대한 응답을 받은 즉시 바로 둘 사이의 연결은 끊기게 됩니다. 이러한 Http 프로그래밍 방식은 실시간 연결이 아닌, 필요한 경우에만 서버로 접근하는 콘텐츠 위주의 데이터를 사용할 때 용이합니다. 만약 게시물에 대한 내용을 요청하기 위해 계속해서 실시간으로 연결을 유지하는 소켓 프로그래밍을 사용하게 된다면, 게시물을 받은 후에도 계속 통신을 위한 연결이 성립되어 부하가 걸리게 될 것입니다.

일반적으로 모바일 어플리케이션은 필요한 경우에만 서버로 정보를 요청하는 경우가 많아 서버로 Http 요청을 통해 필요한 경우에 짧게 연결을 유지함으로써 비용 및 유지 보수등 대부분의 방면에서 많은 장점을 얻을 수 있게 될 것입니다.

  • 클라이언트가 요청을 보내는 경우에만 서버가 응답하는 단방향 프로그래밍 방식
  • 서버로부터 소켓 연결을 하고 응답을 받은 후에 연결이 바로 종료
  • 실시간 연결이 아닌 응답이 필요한 경우에만 서버와 연결을 맺어 요청을 보내는 상황에 유용

Socket

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

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

덤으로 케이블 분리로 인한 네트워크 단절, 트래픽 증가에 따른 데이터 전송 지연, 시스템 리소스 관리 문제로 인한 에러 등 네트워크 환경에서 발생할 수 있는 다양한 예외사항에 대해서도 처리가 필요하기 때문에 소켓 프로그래밍은 더욱 어렵게 느껴질 수도 있습니다.

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

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

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

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

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

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

클라이언트 소켓(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])
  }
}
  • socket.IO에서는 소켓을 룸으로 나누어 소켓을 룸단위로 구분
    • 클라이언트가 /test 룸에 속한 소켓이라면 서버에서도 /test룸으로 설정하고 처리해줘야 통신 가능

서버 소켓 프로그래밍(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);
  });
});