ANDROID 프로그래밍/NETWORK

소켓 프로그래밍. (Socket Programming)

뽀따 2019. 3. 5. 11:04


1. 소켓(Socket)

만약 네트워크와 관련된 프로젝트를 진행하면서, 사용자(User)의 관점이 아닌, 개발자(Developer)의 관점에서 네트워크를 다뤄본 경험이 있다면, "소켓(Socket)"이라는 용어가 아주 낯설게만 느껴지는 단어는 아닐 것입니다. 하지만 이제 막 정보통신학과 전공을 배우는 학생이거나 TCP/IP 통신 프로그래밍을 한번도 접해보지 못한 개발자라면, 소켓(Socket)이란 그저 벽에 뚫린 전원 케이블 연결 구멍 정도로 생각될지도 모르겠네요. (물론, 전혀 생뚱맞은 개념 이해는 아니지만...)


"소켓(Socket)"은 사전적으로 "구멍", "연결", "콘센트" 등의 의미를 가집니다. 주로 전기 부품을 규격에 따라 연결할 수 있게 만들어진 "구멍 형태의 연결부"를 일컫는 단어인데, 가정에서 흔히 볼 수 있는 콘센트 구멍을 떠올리면 쉽게 이해할 수 있을 것입니다. 다시 한번 풀어서 쓰자면, 전기를 필요로하는 디바이스 또는 부품들이 전기를 공급받을 수 있도록, 전기 공급 인프라 환경에 연결할 수 있게 만들어진 연결부가 "소켓(Socket)"인 것이죠.


네트워크 프로그래밍에서의 소켓(Socket)에 대한 의미도, 사전적 의미를 크게 벗어나지 않습니다. 프로그램이 네트워크에서 데이터를 송수신할 수 있도록, "네트워크 환경에 연결할 수 있게 만들어진 연결부"가 바로 "네트워크 소켓(Socket)"입니다.


하지만 엄밀히 따지자면, "네트워크 소켓"이라는 용어가 정확한 표현은 아닙니다. 전기 소켓이 전기를 공급받기 위해 정해진 규격(110V, 220V 등)에 맞게 만들어져야 하듯, 네트워크에 연결하기 위한 소켓 또한 정해진 규약, 즉, 통신을 위한 프로토콜(Protocol)에 맞게 만들어져야 합니다. 보통 OSI 7 Layer(Open System Interconnection 7 Layer)의 네 번째 계층인 TCP(Transport Control Protocol) 상에서 동작하는 소켓을 주로 사용하는데, 이를 "TCP 소켓" 또는 "TCP/IP 소켓"이라고 부릅니다. (UDP에서 동작하는 소켓은 "UDP 소켓"이라고 합니다.)

2. TCP/IP 소켓 프로그래밍(Socket Programming)

앞에서 비교적 단순한 문장으로 TCP/IP 소켓(Socket)에 대한 의미를 설명했지만, 소켓(Socket)을 사용하여 네트워크 통신 기능을 구현하는 과정, 즉, 소켓 프로그래밍(Socket Programming)은 그 개념만큼 아주 단순하지만은 않습니다. 그 이유는 바로, 소켓(Socket)으로 네트워크 통신 기능을 구현하기 위해서는, 소켓을 만드는 것과, 만들어진 소켓을 통해 데이터를 주고 받는 절차에 대한 이해가 필요하고, 운영체제 및 프로그래밍 언어에 종속적으로 제공되는 소켓 API 사용법을 숙지해야 하기 때문입니다.


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


하지만 다행히도, 소켓 프로그래밍(Socket Programming)은 그 역사가 꽤 긴 편입니다. 거의 모든 운영체제에서 지원되고, 대부분의 프로그래밍 언어와 개발 플랫폼에서 소켓(Socket) 관련 API가 제공되고 있죠. 그리고 많은 양의 문서와 예제를, 책과 인터넷을 통해 참고할 수 있습니다.


여기서는 일단, 소켓 프로그래밍 방법을 설명하기 위해, 클라이언트 소켓(Client Socket)과 서버 소켓(Server Socket)의 역할에 대한 내용부터 살펴볼까 합니다. 데이터를 주고받기 위해서는 먼저 소켓의 연결 과정이 선행되어야 하고, 그 과정에서의 연결 요청과 수신이 각각 클라이언트 소켓과 서버 소켓의 역할이기 때문입니다.

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

두 개의 시스템(또는 프로세스)이 소켓을 통해 네트워크 연결(Connection)을 만들기 위해서는, 최초 어느 한 곳에서 그 대상이 되는 곳으로 연결을 요청해야 합니다. IP 주소와 포트 번호로 식별되는 대상에게, 자신이 데이터 송수신을 위한 네트워크 연결을 수립할 의사가 있음을 알리는 것이죠.


그런데, 최초 한 곳에서 무작정 연결을 시도한다고 해서, 그 요청이 무조건 받아들여지고 연결이 만들어져 데이터를 주고 받을 수 있게 될까요? 아닙니다. 한 곳에서 연결 요청을 보낸다고 하더라도 그 대상 시스템이 그 요청을 받아들일 준비가 되어 있지 않다면, 해당 요청은 무시되고 연결은 만들어지지 않습니다.


그러므로 요청을 받아들이는 곳에서는 어떤 연결 요청(일반적으로 포트 번호로 식별)을 받아들일 것인지를 미리 시스템에 등록하여, 요청이 수신되었을 때 해당 요청을 처리할 수 있도록 준비해야 합니다.


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


그런데 여기서, 오해하기 쉬운 내용이 있습니다. 앞서 설명한 내용을 보면 마치 클라이언트 소켓(Client Socket)과 서버 소켓(Server Socket)이 태생적으로 구조가 다른, 전혀 별개의 소켓(Socket)인 것처럼 여겨질 수 있다는 것이죠. 하지만 두 소켓(Socket)은 동일합니다. 소켓의 역할과 구현 절차 구분을 위해 다르게 부르는 것일 뿐, 전혀 다른 형태의 소켓이 아니라는 것이죠. 단지 역할에 따라 처리되는 흐름, 즉, 호출되는 API 함수의 종류와 순서들이 다를 뿐입니다.


또한 위의 그림을 보고, 소켓 연결이 완료된 다음 클라이언트 소켓과 서버 소켓이 직접 데이터를 주고 받는다고 생각하지 마시기 바랍니다. 서버 소켓은 클라이언트 소켓의 연결 요청을 받아들이는 역할만 수행할 뿐, 직접적인 데이터 송수신은 서버 소켓의 연결 요청 수락의 결과로 만들어지는 새로운 소켓을 통해 처리됩니다. 이와 관련된 내용은 지금 당장 이해되지 않더라도 너무 걱정하지 마세요. 뒤에서 좀 더 자세하게 설명할 것입니다.

2.2 소켓 API(Socket API) 실행 흐름.

자 그럼 이제 클라이언트 소켓(Client Socket)과 서버 소켓(Server Socket)이 어떻게 다루어지는지, API 호출 흐름을 통해 조금 더 자세하게 살펴보도록 하겠습니다. 일단, 각 소켓(Socket)이 처리되는 흐름을 간단한 문장으로 표현해 볼까요?


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


서버 소켓(Server Socket)은 처리 과정이 조금 복잡합니다. 일단 클라이언트와 마찬가지로, 첫 번째 단계는 소켓(Socket)을 [1]생성(create)하는 것입니다. 그리고 서버 소켓이 해야 할 두 번째 작업은, 서버가 사용할 IP 주소와 포트 번호를 생성한 소켓에 [2]결합(bind)시키는 것입니다. 그런 다음 클라이언트로부터 연결 요청이 수신되는지 [3]주시(listen)하고, 요청이 수신되면 요청을 [4]받아들여(accept) 데이터 통신을 위한 소켓을 생성합니다. 일단 새로운 소켓을 통해 연결이 수립(ESTABLISHED)되면, 클라이언트와 마찬가지로 데이터를 [5]송수신(send/recv)할 수 있습니다. 마지막으로 데이터 송수신이 완료되면, 소켓(Socket)을 [6]닫습니다(close).


간략한 문장들로 각 소켓의 처리 흐름을 정리해봤는데요, 그 흐름이 머리 속에 잘 그려지나요? 이해가 잘 가지 않는다면 아래 그림을 통해 다시 한번 클라이언트 소켓(Client Socket)과 서버 소켓(Server Socket)의 실행 흐름을 확인하시기 바랍니다.


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

위의 그림으로 볼 수 있듯이 소켓(Socket) API의 실행 흐름은 꽤 직관적입니다. 특히 클라이언트 소켓(Client Socket)을 다루는 과정은 이해하기가 그리 어렵지 않죠.

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

소켓 통신을 위해 가장 먼저 해야 할 일은 소켓을 생성하는 것입니다. 이 때 소켓의 종류를 지정할 수 있는데, TCP 소켓을 위해서는 스트림(Stream) 타입, UDP 소켓을 위해서는 데이터그램(Datagram) 타입을 지정할 수 있습니다. (물론, 좀 더 다양한 소켓 옵션(family, raw)들이 존재하지만, 여기서는 따로 언급하지 않습니다.)


최초 소켓이 만들어지는 시점에는 어떠한 "연결 대상"에 대한 정보도 들어 있지 않습니다. 껍데기 뿐인 소켓이 하나 만들어진 것 뿐이죠. 연결 대상(IP:PORT)을 지정하고 연결 요청을 전달하기 위해서는, 여기서 생성한 소켓을 사용하여 connect() API를 호출해야 합니다.

3.2 연결 요청. (connect())

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


connect() API는 블럭(Block) 방식으로 동작합니다. 즉, 연결 요청에 대한 결과(성공, 거절, 시간 초과 등)가 결정되기 전에는 connect()의 실행이 끝나지 않는다는 것이죠. 그러므로 connect() API가 실행되지마자 실행 결과와 관계없이 무조건 바로 리턴될 것이라 가정해선 안됩니다.


connect() API 호출이 성공하면, 이제 send() / recv API를 통해 데이터를 주고 받을 수 있습니다.

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

연결된 소켓을 통해 데이터를 보낼 때는 send(), 데이터를 받을 때는 recv API를 사용합니다. 언뜻보면 API를 호출하는 방법이 매우 단순할 것 같지만, 한 가지 중요한 사실이 있습니다. send()recv() API가 모두 블럭(Block) 방식으로 동작한다는 것이죠. 즉, 두 API 모두 실행 결과(성공, 실패, 종료)가 결정되기 전까지는 API가 리턴되지 않습니다. 특히 recv()는 데이터가 수신되거나, 에러가 발생하기 전에는 실행이 종료되지 않기 때문에, 데이터 수신 작업을 생각만큼 단순하게 처리하기 쉽지 않습니다.


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


그래서 데이터 수신을 위한 recv() API는 별도의 스레드에서 실행합니다. 소켓의 생성과 연결이 완료된 후, 새로운 스레드를 하나 만든 다음 그곳에서 recv()를 실행하고 데이터가 수신되길 기다리는 것이죠.


음, 관련 내용을 모두 설명하려면 내용이 너무 길어지니, 나중에 예제를 통해 좀 더 자세히 설명하도록 하죠.


send() / recv API를 통해 데이터 송수신 과정이 완료되면, close() API를 사용하여 소켓을 닫습니다.

3.4 소켓 닫기. (close())

더 이상 데이터 송수신이 필요없게되면, 소켓을 닫기 위해 close() API를 호출합니다. close()에 의해 닫힌 소켓은 더 이상 유효한 소켓이 아니기 때문에, 해당 소켓을 사용하여 데이터를 송수신할 수 없습니다.


그리고 만약 소켓 연결이 종료된 후 또 다시 데이터를 주고 받고자 한다면, 또 한번의 소켓 생성(socket())과 연결(connect()) 과정을 통해, 소켓이 데이터를 송수신할 수 있는 상태가 되어야 합니다.

4. 서버 소켓 프로그래밍. (Server Socket Programmng)

클라이언트 소켓을 처리하는 과정에서 사용하는 API들은 비교적 그 의미가 쉽게 와닿는 편입니다. "만들고, 연결하고, 주고받고, 닫는다."...간단하죠?


반면, 서버 소켓(Server Socket)을 처리하는 과정은 조금 복잡하게 느껴질 수 있습니다. 그 이유는 아마도, 소켓(Socket)에 IP 주소와 포트 번호를 결합하는 bind() API와 클라이언트 요청이 있는지 확인하는 listen() API의 존재 때문일거라 생각합니다. "bind(결합하다, 묶다)"와 "listen(듣다, 경청하다)"이라는 단어의 사전적 의미가, 서버 소켓의 처리 과정에서 어떤 역할을 의미하는지 쉽게 이해가 되지 않죠.


하지만 너무 복잡하게 생각할 필요는 없습니다. 본문의 장황한 설명과는 달리 서버 소켓을 사용하는 소스 코드 자체는 간단한 편이고, 서버 소켓의 처리 로직이 어플리케이션의 성격에 큰 영향을 받지 않는 경우가 대부분이기 때문입니다.


그럼 이제, 서버 소켓을 다루는 과정을 살펴볼까요?

4.1 서버 소켓 생성. (socket())

클라이언트 소켓과 마찬가지로, 서버 소켓을 사용하려면 최초에 소켓을 생성해야 합니다. [3.1 클라이언트 소켓 생성] 단계에서 설명한 내용과 크게 다르지 않습니다.

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

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


bind() API에 사용되는 인자는 소켓(Socket)과 포트 번호(또는 IP 주소+포트 번호)입니다. 위의 사전적 의미대로라면, "소켓(Socket)과 포트 번호를 결합(bind)한다"는 것인데요. 이 "결합(bind)"이라는 동작이 구체적으로 무엇을 의미하는 걸까요?


보통 시스템에는 많은 수의 프로세스가 동작합니다. 그 중에는 네트워크 관련 기능을 수행하는 프로세스도 다수 포함되어 있죠. 만약 프로세스가 TCP 또는 UDP 프로토콜을 사용한다면, TCP(RFC-793) 또는 UDP(RFC-768) 표준에 따라, 각 소켓은 시스템이 관리하는 포트(0~65535) 중 하나의 포트 번호를 사용하게 됩니다. 그런데 만약 소켓이 사용하는 포트 번호가 다른 소켓의 포트 번호와 중복된다면 어떤 상황이 될까요? 모든 소켓이 10000 이라는 동일한 포트 번호를 사용하게 된다면, 네트워크를 통해 10000 포트로 데이터가 수신될 때 어떤 소켓이 처리해야 하는지 결정할 수 없는 문제가 발생할 것입니다.


이런 이유로, 운영체제에서는 소켓들이 중복된 포트 번호를 사용하지 않도록, 내부적으로 포트 번호와 소켓 연결 정보를 관리합니다. 그리고 bind() API는 해당 소켓이 지정된 포트 번호를 사용할 것이라는 것을 운영체제에 요청하는 API인 것이죠. 만약 지정된 포트 번호를 다른 소켓이 사용하고 있다면, bind() API는 에러를 리턴합니다.


정리하자면, 일반적으로 서버 소켓은 고정된 포트 번호를 사용합니다. 그리고 그 포트 번호로 클라이언트의 연결 요청을 받아들이죠. 그래서 운영체제가 특정 포트 번호를 서버 소켓이 사용하도록 만들기 위해 소켓과 포트 번호를 결합(bind)해야 하는데, 이 때 사용하는 API가 바로 bind인 것입니다.


참고로 여기서는 bind의 뜻을 설명하기 위해 "결합"이라는 용어를 사용하였지만, 보통 소켓에 bind() API를 호출하는 것을 "소켓 바인드" 또는 "소켓 바인딩"이라고 부릅니다.

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

서버 소켓(Server Socket)에 포트 번호(또는 IP 주소+포트 번호)를 결합(bind)하고 나면, 서버 소켓(Server Socket)을 통해 클라이언트의 연결 요청을 받아들일 준비가 되었습니다. 이제 할 일은, 클라이언트에 의한 연결 요청이 수신될 때까지 기다리는 것인데요, listen API 가 그 역할을 수행합니다.


"듣다" 또는 "귀를 기울이다"라는 뜻을 가진 listen() API는 서버 소켓(Server Socket)에 바인딩된 포트 번호로 클라이언트의 연결 요청이 있는지 확인하며 대기 상태에 머무릅니다. 클라이언트에서 호출된 connect() API에 의해 연결 요청이 수신되는지 귀 기울이고(?) 있다가, 요청이 수신되면, 그 때 대기 상태를 종료하고 리턴하는 것이죠.


listen() API가 대기 상태에서 빠져나오는 경우는 크게 두 가지입니다. 클라이언트 요청이 수신되는 경우와, 에러가 발생(소켓 close() 포함)하는 경우죠. 그런데 listen() API가 성공한 경우라도, 리턴 값에 클라이언트의 요청에 대한 정보는 들어 있지 않습니다. listen()의 리턴 값으로 판단할 수 있는 것은 클라이언트 연결 요청이 수신되었는지(SUCCESS), 그렇지 않고 에러가 발생했는지(FAIL) 뿐이죠.


대신 클라이언트 연결 요청에 대한 정보는 시스템 내부적으로 관리되는 큐(Queue)에서 쌓이게 되는데, 이 시점에서 클라이언트와의 연결은 아직 완전히 연결되지 않은(not ESTABLISHED state) 대기 상태입니다.


대기 중인 연결 요청을 큐(Queue)로부터 꺼내와서, 연결을 완료하기 위해서는 accept() API를 호출해야 합니다.

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

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


accept() API는 그 사전적 의미만큼 직관적인 역할을 수행합니다. 연결 요청을 받아들여(accept) 소켓 간 연결을 수립하는 것이죠. 그런데 주의할 점은 최종적으로 데이터 통신을 위해 연결되는 소켓이, 앞서 bind() 또는 listen() API에서 사용한 서버 소켓(Server Socket)이 아니라는 것입니다.


"응? 여태까지 서버 소켓과의 연결 요청이 어쩌구저쩌구 해놓고, 이제 와서 연결된 소켓이 서버 소켓이 아니라니.. 무슨 말이지?" 라고 당혹감을 느낄 수도 있을텐데요. 결론부터 말하자면, 최종적으로 클라이언트 소켓(Client Socket)과 연결(Connection)이 만들어지는 소켓(Socket)은 앞서 사용한 서버 소켓(Server Socket)이 아니라, accept API 내부에서 새로 만들어지는 소켓(Socket)입니다.


앞에서도 언급했지만, 서버 소켓(Server Socket)의 핵심 역할은 클라이언트의 연결 요청을 수신하는 것입니다. 이를 위해 bind()listen()을 통해 소켓에 포트 번호를 바인딩하고 요청 대기 큐를 생성하여 클라이언트의 요청을 대기하였죠. 그리고 accept() API에서, 데이터 송수신을 위한 새로운 소켓(Socket)을 만들고 서버 소켓의 대기 큐에 쌓여있는 첫 번째 연결 요청을 매핑시킵니다. 여기까지, 하나의 연결 요청을 처리하기 위한 서버 소켓의 역할은 끝났습니다. 서버 소켓의 입장에서 남은 일은, 또 다른 연결 요청을 처리하기 위해 다시 대기(listen)하거나, 서버 소켓(Socket)을 닫는(close) 것 뿐이죠.


실질적인 데이터 송수신은 accept API에서 생성된, 연결(Connection)이 수립(Established)된 소켓(Socket)을 통해 처리됩니다.

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

데이터를 송수신하는 과정은 클라이언트 소켓 처리 과정에서 설명했던 [3.3 데이터 송수신]의 내용과 동일합니다.

4.6 소켓 연결 종료. (close())

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


그런데 서버 소켓에서는 close()의 대상이 하나만 있는 것이 아니라는 것에 주의해야 합니다. 무슨 말이냐구요? 최초 socket() API를 통해 생성한 서버 소켓에 더해, accpet() API 호출에 의해 생성된 소켓도 관리해야한다는 의미입니다.

5. 참고.

.END.