파이썬 소켓 프로그래밍 - 1. 간단한 채팅 프로그램 구현(1)

2018. 11. 20. 00:52Programming/Python

작년에 컴퓨터 네트워크 강의시간에 파이썬 소켓 프로그래밍 강의를 받은 적이 있습니다. 간단한 웹 서버 구축과 채팅 프로그램 구축 둘 중 하나를 택하는 거였는데, 네트워크 개념을 다시 복습하는 겸해서 한번 글을 써보기로 했습니다. 정확히 아는 것은 아니라 틀린 정보가 있을 수도 있으니 감안해 주시고, 혹시라도 제가 잘못 알고 있는 사실이 있다면 지적해주세요.

이 포스트에선 소켓에 대한 간단한 설명만 하고 넘어가도록 하겠습니다.

1. 소켓(Socket)

OSI 7계층, 혹은 5계층에 대해서 들어보신 적이 있을 겁니다. 제가 배울 때에는 7계층까지 언급은 했지만 결국 강의는 5계층에 맞춰서 진행했으므로 여기서도 5계층에 맞춰서 이야기 하겠습니다.

우리가 흔히 "인터넷"을 언급할 때에는 주로 트랜스포트 계층을 이야기하게 됩니다. TCP/UDP의 프로토콜로 구현되며, 인터넷 상의 모든 정보가 이 계층을 반드시 이용하여 출입을 하므로, 인터넷을 사용하는 프로그램을 만들기 위해선 트랜스포트 계층에 접근하지 않을 수가 없습니다.

우리가 프로그램 개발을 할 때에 보통 손댈 수 있는 범위는 애플리케이션 계층에 한정되어 있습니다. 앞서 인터넷을 사용하는 프로그램은 트랜스포트를 쓸 수밖에 없다고 했는데, 어떻게 하면 애플리케이션 계층에서 트랜스포트 계층을 조작할 수 있을까요. 이를 해결하기 위한 방법이 소켓(Socket)이라 불리는 인터페이스입니다.

소켓에 대한 자세한 설명은 검색만 해봐도 많이 나오므로 간단하게 정리만 하겠습니다.

소켓이란 인터페이스는 UNIX(더 엄밀하게 말하자면 BSD)에서 등장했으며, OSI상에서 위치를 그려보자면 트랜스포트와 애플리케이션 사이에 존재합니다. UNIX에서 등장했단 말에서 알 수 있듯이, OS에서 제공하는 인터페이스며, 어떤 종류의 프로그램이라 하더라도 이 소켓에 접근하여 외부 네트워크와 통신을 할 수 있습니다. 지금은 당연히 윈도우 시리즈에도 있으며, 자바를 쓰든, 파이썬을 쓰든, C++를 쓰든간에 소켓의 사용방법은 크게 다르지 않습니다. 마치 어떤 언어로 프로그램을 만들어도 파일을 읽거나 쓰는 파일시스템(fs)에 관련된 모듈 사용방법이 크게 다르지 않듯이요.

2. 소켓의 작동방식

소켓은 OS에서 제공하는 인터페이스라고 설명했습니다. 실제로 프로그램에서 소켓에 관련된 작업을 수행하면, OS는 그 요청을 받아들여서 새로운 소켓을 만들어줍니다. 우리는 오로지 이 소켓으로만 외부 네트워크와 통신을 할 수 있습니다.

흔히들 소켓을 설명할 때 "창구"에 비유를 많이 합니다. 저도 적절한 비유라고 생각하고, 특히 "우체통"에도 비유하곤 합니다. 제가 작년에 과제로 만들었던 채팅 프로그램 "코코아톡"을 가지고 설명을 해보겠습니다. 아래의 그림처럼 저의 코코아톡에 소켓이 있고, 상대방의 코코아톡에도 소켓이 있습니다. 실제로 통신을 하고 있는 것은 소켓들이고, 코코아톡 자체는 외부 네트워크와 아무런 정보도 주고 받지 않습니다. 이 부분이 중요한 부분입니다. 애플리케이션 입장에선 소켓을 통해서만 정보를 보내거나 받을 수 있고, 실제 통신 과정에 대해서는 아무런 힘이 없다는 부분이죠.

그림을 좀 더 쉽게 설명해보자면, 제 코코아톡이 소켓A에게 "안녕이라고 메시지 좀 보내줘"하고 부탁을 합니다. 부탁받은 소켓A는 상대방 코코아톡에 달린 소켓B에게 이를 전달해 주겠죠. 그리고 상대방 코코아톡이 자기 소켓한테 "지금 무슨 메시지가 왔는지 좀 알려줄래?" 하고 물어봐서 제가 보냈던 메시지를 가져옵니다. 이제 좀 왜 창구라고 하는지, 우체통이라고 하는지 이해가 가실 것 같습니다.

3. 소켓의 사용방법

OS의 인터페이스이므로 어떤 언어든 소켓의 사용방법은 똑같습니다. 일단 파이썬으로 작성한 소켓 세팅 과정은 아래와 같습니다.

3.1 서버 소켓 세팅

from socket import *

serverSock = socket(AF_INET, SOCK_STREAM)
serverSock.bind(('', 8080))
serverSock.listen(1)

clientSock, addr = serverSock.accept()

차례대로 설명하겠습니다.

먼저 socket 객체를 생성할 수 있습니다. 이 때, 인자로 두 가지를 입력해야 하는데, 첫번째는 어드레스 패밀리(AF: Address Family)고, 두번째는 소켓 타입입니다.

글을 쓰면서 이 두 인자가 도대체 뭔지 검색을 좀 해봤는데, 어드레스 패밀리는 주소 체계에 해당한다고 합니다. 소켓이 워낙에 옛날에 만들어진 물건이라 인터넷이 아닌 통신도 고려해서 만들어졌다고 하는데, 지금 시대에 인터넷 외의 외부 네트워크를 이용할 일은 거의 없으므로 인터넷에 사용되는 IP로 구성된 어드레스 패밀리 한가지만이 사용된다고 봐도 될 것 같습니다. 이를 AF_INET이라고 부르는데, 그냥 AF_INET은 IPv4를, AF_INET6는 IPv6를 의미한다고 합니다. 아직 IPv6가 보편화되지 않았으므로 어지간하면 거의 다 AF_INET을 사용할 것입니다.

소켓 타입은 여러가지가 있긴 한데, 구체적으로 어떤 차이가 있는지는 잘 모르겠습니다. 다만 문서를 검색해보면 SOCK_STREAM과 SOCK_DGRAM만이 주로 사용된다고 언급이 되어있네요. 저는 SOCK_STREAM을 사용할 것입니다.

파이썬에서 소켓 객체를 생성하는 방법은 다음과 같습니다.

from socket import *

serverSock = socket(AF_INET, SOCK_STREAM)

기본 모듈 socket을 불러온 다음 socket()을 실행하면 소켓이 하나 생성됩니다. 당연히 변수 이름은 사용자 마음이겠죠.

그 다음은 생성한 소켓을 bind를 해주어야 합니다. 이는 클라이언트를 만들 때에는 불필요하며, 서버를 운용할 때에는 반드시 필요하다고 합니다. 이 작업이 의미하는 바는 생성된 소켓의 번호와 실제 어드레스 패밀리를 연결해주는 것이라고 합니다. 일단 다음 코드를 보도록 합시다.

serverSock.bind(('', 8080))

bind 함수 내에 튜플을 입력했다는 점을 유의하셔야 합니다. bind('',8080)가 아니라 bind(('',8080))입니다. 앞서 말한대로 bind는 소켓과 AF를 연결하는 과정이라 하였으므로, 이 인자는 어드레스 패밀리가 됩니다. 앞부분은 ip, 뒷부분은 포트로 (ip, port) 형식으로 한 쌍으로 구성된 튜플이 곧 어드레스 패밀리인 것이죠.

그런데 가만 보아하니 이상합니다. 주소에 해당하는 부분에 빈 문자열만 들어가 있습니다. 파이썬 공식 문서를 참조해보니, AF_INET에서 ''는 INADDR_ANY를 의미한다고 합니다. 즉, 모든 인터페이스와 연결하고 싶다면 빈 문자열을 넣으면 된다는군요. 반대로 브로드캐스트를 하고 싶다면 ''를 입력하면 된다고 합니다.

즉, 위의 bind는 8080번 포트에서 모든 인터페이스에게 연결하도록 한다라는 정도의 의미로 받아들이면 될 것 같습니다.

bind가 끝나고 나면 listen하는 단계가 필요합니다. 이는 상대방의 접속을 기다리는 단계로 넘어가겠단 의미입니다.

serverSock.listen(1)

앞서 bind가 서버소켓에서 필수로 사용된다고 말했듯이, 이 listen도 서버소켓에서밖에 쓰일 일이 없습니다. listen()안에 인자로 숫자 1이 입력되어 있는데, 이는 해당 소켓이 총 몇개의 동시접속까지를 허용할 것이냐는 이야기입니다. 1을 입력하면 단 한 개의 접속만을 허용할 것이고, 인자를 입력하지 않으면 파이썬이 자의적으로 판단해서 임의의 숫자로 listen한다고 합니다.

이로서 서버 소켓은 상대방의 접속이 올 때까지 계속 대기하는 상태가 됩니다. 그럼 접속을 수락하고, 그 후에 통신을 하기 위해선 어떻게 해야할까요? 이 경우엔 accept를 사용하게 됩니다.

connectionSock, addr = serverSock.accept()

accept()는 소켓에 누군가가 접속하여 연결되었을 때에 비로소 결과값이 return되는 함수입니다. 즉, 소스코드 내에 serverSock.accept()가 있더라도, 누군가가 접속할 때까지 프로그램은 바로 이 부분에서 계속 멈춰있게 된단 이야기죠. 상대방이 접속함으로써 accept()가 실행되면, return 값으로 새로운 소켓과, 상대방의 AF를 전달해주게 됩니다.

이 후에, 서버에 접속한 상대방과 데이터를 주고받기 위해서는 accept()를 통해 생성된 connectionSock이라는 소켓을 이용하면 됩니다. 이제부터 serverSock 소켓을 이용할 일은 거의 없으며, connectionSock을 주로 이용합니다.

3.2 클라이언트 소켓 세팅

클라이언트 소켓은 어떻게 사용할까요? 서버 소켓보다 훨씬 간단합니다.

from socket import *

clientSock = socket(AF_INET, SOCK_STREAM)
clientSock.connect(('127.0.0.1', 8080))

bind와 listen, accept 과정이 빠지고 대신 connect가 추가되었습니다. 클라이언트에서 서버에 접속하기 위해선 connect()만 실행해주면 됩니다. 여기에도 어드레스 패밀리가 인자로 들어가고, 호스트 주소와 포트번호로 구성된 튜플이 요구됩니다. 127.0.0.1은 자기 자신을 의미하므로, 위의 어드레스 패밀리는 자기 자신에게 8080번 포트로 연결하란 소리가 되겠네요.

3.3 소켓 송수신

서버 소켓 세팅에서는 connectionSock을 이용해서 데이터를 주고 받는다고 하였습니다. 클라이언트는 어차피 사용하는 소켓이 clientSock이므로 헷갈릴 일이 없겠지만, 서버는 두 개의 소켓이 있으므로 이를 유의해서 사용해주세요. bind()로 서버를 구성한 소켓으로는 데이터를 주고 받지 않습니다.

그렇다면 소켓으로 어떻게 데이터를 주고 받을 수 있을까요? 메소드 이름이 아주 간단합니다. send()recv()를 이용하면 됩니다. 직관적인 네이밍으로 알 수 있듯이, send()는 보내는 메소드이고 recv()는 받는 메소드입니다.

소켓을 통해 "안녕"이라는 메시지를 보내고 싶다면 다음과 같이 사용하면 됩니다.

msg = '안녕'
connectionSock.send(msg.encode('utf-8'))

여기서 문자열을 전송할 때 encode()가 들어간다는 점을 유의해야합니다. 파이썬 문자열의 encode() 메소드는 문자열을 byte로 변환해주는 메소드입니다. 파이썬 내부에서 다뤄지는 문자열은 파이썬에서 생성된 객체이고, 이를 바로 트랜스포트에 그대로 싣는 것은 불가능합니다. 그러므로 적절한 인코딩을 하여 보내야만 합니다. 인코딩을 하지 않고 보내면 에러가 뜨니까 주의하셔야 합니다.

이제 상대방에게서 온 메시지를 확인하는 방법을 보겠습니다.

msg = connectionSock.recv(1024)
print(msg.decode('utf-8'))

recv()를 실행하면 소켓에 메시지가 실제로 수신될 때까지 파이썬 코드는 대기하게 됩니다. 위에 언급한 accept()처럼 말이죠. 인자로는 수신할 바이트의 크기를 지정할 수 있습니다. recv(1024)는 소켓에서 1024바이트만큼을 가져오겠단 소리입니다. 만일 소켓에 도착한 데이터가 1024바이트보다 많다면, 다시 recv(1024)를 실행할 때 전에 미처 가져오지 못했던 것을 끌어오게 됩니다.

아까 send()를 실행할 때는 문자열을 인코딩해서 보냈었는데요, recv()를 할 때는 데이터를 바이트로 수신하므로, 문자열로서 활용하기 위해선 디코딩을 해야합니다. 이 또한 decode()를 이용하여 적절하게 문자열로 디코딩할 수 있습니다.

소켓에서 주고받는 데이터는 바이트이므로, 굳이 문자열을 주고받지 않아도 됩니다. 예를 들어 이미지 파일이나 동영상 파일을 읽어서 1024바이트 단위로 전송을 해도 실제로 상대방 컴퓨터에 파일을 전송할 수 있습니다.

4. 샘플 코드

이로서 소켓의 기초 사용법을 한번 확인했습니다. 아래는 서버와 클라이언트가 서로 한번씩 문자열 데이터를 주고 받는 코드입니다.

서버를 먼저 실행하고, 클라이언트를 그 다음으로 실행해야 합니다. 연결이 되고 나면, 서버는 자신에게 접속한 사람의 정보를 표시하고, 클라이언트로부터 데이터 수신을 대기합니다. 클라이언트는 서버에게 메시지를 하나 보내고, 서버는 메시지를 확인 해 출력하고, 다시 클라이언트에게 답례 메시지를 하나 보냅니다. 클라이언트까지 메시지 수신 확인이 끝나면 두 프로그램이 종료됩니다.

실행 결과는 아래와 같습니다.

다음 포스트에선 이를 응용하여 서버와 클라이언트간 간단한 1:1 채팅을 할 수 있는 프로그램을 만들어 보겠습니다.