파이썬 소켓 연결 사용법

네트워크 프로그래밍 분야에서 소켓은 연결된 네트워크의 양 끝단을 추상화 시킨 개념이며, 컴퓨터의 관점에서는 네트워크로 통하는 컴퓨터의 외부와 컴퓨터 내부의 프로그램을 이어주는 인터페이스이다. 소켓의 개념에 대해서 이 글에서 모두 소상히 설명할 수는 없고, 네트워크를 통해서 바이트스트림을 주고 받을 수 있는 창구라 보면 된다. 다만 단순히 프로그램의 내부와 외부를 잇는 표준 입출력과는 달리 소켓은 네트워크의 반대편이 어디인지에 대한 정보를 가지고 있다. 즉 우리가 택배를 보낼 때 박스에 물건을 넣고 받는 사람 주소를 쓰는 것과 비슷하게 소켓은 어디로 보내지는 창구라는 것이 명시된 택배 상자 같은 것이다.

파이썬의 socket 모듈은 소켓 프로그래밍에 필요한 시스템 콜을 래핑하는 API를 제공하는 모듈이다.  소켓 통신을 위해서 물론 소켓을 생성해서 사용하는데, 서버와 클라이언트일 때가 조금 다르다.

공통

소켓을 생성하기

socket.socket() 함수를 이용해서 소켓 객체를 생성할 수 있다. 서버든 클라이언트등 동일하게 소켓을 이용한 네트워킹을 하기 위해서는 소켓을 먼저 생성할 필요가 있다. 이 함수는 두 가지 인자를 받는데, 하나는 패밀리이고 다른 하나는 타입이다.

  1. 패밀리: 첫번째 인자는 패밀리이다. 소켓의 패밀리란, “택배상자에 쓰는 주소 체계가 어떻게 되어 있느나”에 관한 것으로 흔히 AF_INET, AF_INET6를 많이 쓴다. 전자는 IP4v에 후자는 IP6v에 사용된다. 각각 socket.AF_INET, socket.AF_INET6로 정의되어 있다.
  2. 타입: 소켓 타입이다. raw 소켓, 스트림소켓, 데이터그램 소켓등이 있는데, 보통 많이 쓰는 것은 socket.SOCK_STREAM 혹은 socket.SOCK_DGRAM이다.

가장 흔히 쓰이는 socket.AF_INET, socket.SOCK_STREAM 조합은 socket.socket()의 인자 중에서 family=, type=에 대한 기본 인자값이다. 따라서 이 타입의 소켓을 생성하고자 하는 경우에는 인자 없이 socket.socket()만 써도 무방하다.

서버

바인드 – 서버쪽의 맵핑 방식

서버가 특정한 포트를 열고 입력을 기다리기 위해서는 소켓을 포트에 바인드하는 과정이 선행되어야 한다.  이는 생성된 소켓 객체에 대해서 sock.bind() 메소드를 이용해서 실행한다. bind() 호출 시에는 호스트이름과 포트번호가 필요한데, 이들을 각각의 인자로 넘기는 것이 아니라 튜플로 감싸서 전달한다.

바인드는 서버사이드에서만 필요하다. 이 작업은 프로그램 인터페이스인 소켓과 네트워크 시스템이 자원을 구분하는 IP와 포트번호를 연결한다. 즉 프로그램/프로그래머는 자신이 사용하는 포트가 명시적으로 몇 번인지, 자신의 IP가 무엇인지 알고 있어야 한다. (알고 있다는 말은 즉 자신이 능동적으로 정해준다는 말이다.) 그래야 이 정보를 교신상대, 클라이언트에게 알려 클라이언트가 접속할 수 있게한다.

포트 듣기(Listen)와 열기

바인드가 완료되면 포트를 듣는다. sock.listen() 메소드를 사용한다. 이 메소드는 호출되면 클라이언트가 해당 포트에 접속하는 것을 기다린다. 접속이 들어오면 (그것이 원하는 클라이언트인지, 임의의 접속 요청인지는 알 수 없다.) 리턴된다. 이는 ‘듣기’만 하는 것이다. 실제로 접속을 수락하는 것은 다음 차례이다.  listen()으로 접속 시도를 알아챘다면 이쪽(서버)에서도 그 요청을 받아서 접속을 시작한다. 접속의 개시는 sock.accept()를 사용한다.

이 메소드는 (소켓, 주소정보)로 구성되는 튜플을 리턴한다. 여기서 소켓은 실제 클라이언트와 접속이 이루어져 교신가능한 소켓이다. 서버는 최초 생성되어 듣는 소켓이 아닌 accept()의 리턴으로 제공되는 소켓을 사용해서 클라이언트와 정보를 주고 받을 수 있다. (왜냐하면 소켓이라는 모델 자체가 1:N 통신을 상정하고 있기 때문이다.)

다시 공통

정보 주고 받기

소켓으로부터 데이터를 읽을 때는 sock.recv()를, 정보를 보낼 때는 sock.sendall()1을 사용한다. sock.recv(bufsize)는 읽어들일 데이터의 크기를 정해서 그만큼을 읽어온다. 단 파일을 읽을 때 처럼 반복해서 읽을 수는 없다. 소켓은 한 번은 읽고 한 번은 보내는 턴 바이 턴식으로 통신하기 때문이다.  sock.sendall(data)은 주어진 데이터를 보낸다.

닫기

소켓 역시 외부 리소스를 열어서 사용하는 것이므로 닫는 것이 매우 중요하다. 연결을 종료할 때에는 서버와 클라이언트 모두 소켓을 닫아야 하며, 이미 닫혀있는 소켓에서 데이터를 받으려하거나 데이터를 보내려하는 동작은 모두 에러가 된다. 소켓을 닫을 때에는 sock.close() 메소드를 사용한다. 단, 소켓 객체는 컨텍스트매니저 프로토콜을 지원하기 때문에 with 문으로 사용하면 명시적으로 닫을 필요가 없다.2

클라이언트

연결하기 – connect

클라이언트가 소켓을 생성하는 방법은 서버측과 동일하다. 서버는 생성->바인드->듣기->수락->읽기->쓰기->닫기의 사이클 대로 동작한다면, 클라이언트는 조금 단순하다. 바인드 과정이 없으며, 그 자신이 접속을 능동적으로 수행하기 때문에 생성->연결->쓰기->읽기의 사이클이 적용된다.

연결은 sock.connect()를 사용하며 이 때 사용하는 인자는 bind()와 동일하다.

예제

간단한 에코서버

다음은 간단한 에코서버를 소켓으로 구현했다. 파이썬 레퍼런스에 소개되는 예제이다. 바인드 > 듣기 > 수락 > 읽고 보내기 > 닫기의 순서대로 코드를 작성했다.

## server.py

import socket

def run_server(port=4000):
  host = ''
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((host, port))
    s.listen(1)
    conn, addr = s.accept()
    msg = conn.recv(1024)
    print(f'{msg.decode()}')
    conn.sendall(msg)
    conn.close()

if __name__ == '__main__':
  run_server()

이어서 클라이언트 코드. 클라이언트는 connect 하나면 되기 때문에 간단하다.

import socket

def run():
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect(('127.0.0.1', 4000))
    line = input(':')
    s.sendall(line.encode())
    resp = s.recv(1024)
    print(f'>{resp.decode()}')

if __name__ == '__main__':
  run()

서버를 실행해놓고 다른 터미널창에서 클라이언트를 실행한 후, 클라이언트 쪽에서 키보드로 문자열을 입력하면 다시 출력되고 양쪽 프로그램이 종료된다.

변형 : 에코서버2

처음의 코드를 변형해보자. accept() 이후에 한 번만 교신하는 것이 아니라 recv/sendall을 번갈아 가며 반복하다가 연결을 종료하도록 하여 한 번의 연결에서 여러차례 데이터를 교환할 수 있다. 이는 특정한 조건 (흔히 수신데이터가 없는 상태)까지의 while 루프를 accept() 아래에 추가한다.

...
conn, addr = sock.accept()
while True
  data = conn.recv(1024)
  if not data: break
  print(data.encode())
  conn.sendall(data)
conn.close()
....

위 코드는 받아온 데이터가 존재하면 반송하고 다시 읽는 과정을 반복하도록 변경했다. 그렇다면 클라이언트도 같은 식으로 고칠 수 있겠다.

sock.connect((host,port))
while True:
  line = input()
  sock.sendall(line.encode()) ## 서버가 빈 데이터를 받고 연결을 종료할 수 있도록
  if not line: break          ## 빈 데이터를 먼저 보낸 후 루프를 탈출
  data = sock.recv(1024)
  print(data.decode())
sock.close()

변형 : 다중 접속 에코 서버

서버에서 소켓을 포트에 바인드하고 난 이후에는 소켓을 닫기 전까지 여러 개의 접속을 받아 들일 수 있다. 접속을 받을 때마다 해당 접속의 통신을 위한 소켓이 다시 생성된다. 위의 예제들에서 conn은 이렇게 생성된 접속당 통신에 사용되는 소켓 객체이다. 그렇다면 포트에 바인드된 소켓은 하나의 접속을 수락한 후에 다른 접속을 위한 추가적인 conn을 생성할 수 있을까? 물론이다. 소켓이 이런 방식으로 동작하는 것은 1:N 접속을 서버에서 구현할 수 있다는 이야기이다. 단, 서버에서 접속을 생성하기 위해서는 포트를 듣는 작업과 맺어진 연결을 관리하는 작업이 병렬적으로 이루어져야 한다. 따라서 이 두 작업은 개별 스레드로 분리해야 한다.

from threading import Thread
import socket

def echo(sock):
  while True:
    data = sock.recv(1024)
    if not data: break
    sock.sendall(data)
  sock.close()

def run_server(port=4000):
  host = ''
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((host, port))
    while True:
      s.listen(1)
      conn. addr = s.accept()
      t = Thread(target=echo, args=(conn,))
      t.start

if __name__ == '__main__':
  run_server()

물론 이 코드는 그저 컨셉을 구현한 수준이며, 접속이 많아지는대로 스레드를 늘릴 수는 없으니 별도의 관리가 필요할 것이다. 대략 기본적인 소켓 사용은 이렇게 하는 것이라하는 정도만 알면 되겠다.


  1. sendall()이 아닌 send() 메소드도 있다. 이 메소드는 sendall()과는 달리 리턴값이 있는데, 바로 실제 전송된 바이트 수를 리턴한다. 따라서 보내려는 데이터와 실제 보내진 데이터가 다를 수 있다는 의미이며, 보내지 못한 데이터를 보낼 책임은 프로그래머에게 있다. (https://docs.python.org/3/library/socket.html#socket.socket.send
  2. 단, accept()로 생성된 개별 연결에 할당된 소켓은 그냥 생성된 채로 던져지는 것이기 때문에 명시적으로 닫아주어야 한다. 이 과정 자체를 아예 데코레이팅하는 방법이 있지 않을까?