개발/칼럼 - Deep Dive

[네트워크] 소켓 통신의 기본

조금씩 차근차근 2024. 11. 13. 21:09

글의 목적

개발자가 일반적으로 사용하는 모든 TCP 네트워크 통신의 "API"인 소켓 통신의 기본 동작 원리를 이해함으로써, 추상화되어 잊기 쉬운 전송계층의 동작 원리를 "알아야 하는 부분까지" low Level 로 이해하기 위함이다.

Top-Down 으로 접근하기에, 추후 전송 계층의 TCP 와 네트워크 계층의 IP까지 다룰 예정이다.

서버(Server) & 클라이언트(Client) 모델

우리가 컴퓨터에서 흔히 접하는 "클라이언트 & 서버" 라는 단어는 엄청 있어보이지만, 사실 실세계에서 매우 흔히 볼 수 있는 형태의 모델을 추상화해둔 패턴이다.

출처: 김영한 자바 고급 2편

클라이언트(Client)

클라이언트는 서비스를 요청하는 쪽이다. 마치 식당에서 음식을 주문하는 손님처럼, 클라이언트는 어떤 정보를 얻거나 작업을 처리해달라고 요청하는 역할을 한다.

예를 들어, 변호사에게 법률 자문을 구하는 의뢰인, 은행에서 계좌를 개설하는 고객, 미용실에서 머리를 자르는 손님과 같은 예시를 들 수 있다.

여기서 재미있게 볼 특징은, "클라이언트"는 서버가 원하는 "요청" 을 만들어주는 "생산자" 의 역할을 한다고 볼 수 있는 것이다.

서버(Server)

서버는 서비스를 Serving 하는 쪽이다. 요청을 받아 그 서비스를 제공하는 쪽으로, 서버는 클라이언트의 요청을 받아들이고, 그 요청에 맞게 서비스를 제공한다. 식당에서 음식을 준비해서 손님에게 가져다주는 주방이나 웨이터가 서버의 역할을 한다.

예를 들어, 위의 예시와 대응시키면 법률 서비스를 제공하는 변호사, 은행에서 계좌를 관리하는 은행, 미용 서비스를 제공하는 미용사라고 할 수 있다.

여기서 한번 꼬아서 생각하면, "서버"는 클라이언트의 "요청" 을 읽고 사용하는 "소비자" 임과 동시에, 클라이언트가 원하는 "응답" 을 생성하는 "생산자" 이기도 하다는 것이며, 이 패턴에서 클라이언트는 서버의 "응답"을 소비하는 "소비자" 라고 볼 수 있다는 것이다.

 

위와 같은 모델을 "클라이언트 - 서버" 모델이라고 부르며, 위와 비슷한 다양한 문제를 해당 패턴의 해결책에 접목시켜 해결책을 찾는게 클라이언트 - 서버 모델의 존재 이유라고 할 수 있다.

참고로, 클라이언트 - 서버 모델은 "두 객체 간의 관계성" 에서 발생하는 것으로, 누군가에겐 "클라이언트" 인 객체가 누군가에겐 "서버"가 될 수 있다.

인터넷의 기본 원리

인터넷은 "클라이언트"의 요청을 "서버"가 응답하는 "클라이언트 - 서버" 모델의 대표적인 형태이다. 그 과정에서, 클라이언트와 서버 간, "만났다" 라는 상태를 정의하기 위해, 우리는 프로토콜을 사용하여, 매번 새로운 서버를 만나고, 새로운 클라이언트에게 데이터를 서빙하며, 통신이라는 이름의 만남과 헤어짐을 반복한다고 볼 수 있다.

하지만 "만났다" 라는 상태의 정의는 사실 굉장히 까다로운데, 상대가 진짜 내가 원하는 상대인지 알 검증 방법이 필요하고, 상대가 진짜 내 앞에 존재하는지 확인하는 과정이 필요하기도 하는 등, 다양한 과정들을 일일히 수행해야 한다.

하지만 이런 귀찮은 반복작업을 간소화하고 추상화하여, 개발자들이 편하게 "통신"할 수 있도록, 간편한 새로운 프로토콜 - 인터페이스를 만들었는데, 이를 소켓 인터페이스라고 한다.

소켓 인터페이스

소켓 인터페이스(Socket Interface)란, 개발자가 복잡한 통신 규약 및 통신 과정을 간소화하여 사용할 수 있도록, 내부 기능을 자동화한 인터페이스이다. POSIX 에서 정의했고, 대부분의 언어가 유사한 인터페이스를 사용하여, 한번 이해하면 대부분의 언어에서 자연스럽게 사용할 수 있게 만들어 두었다. 

출처: 김영한 자바 고급 2편

"소켓" 은 객체, 구조체 등 다양한 언어에서 하나의 객체로 간주되며, 시스템 내부에 socket 이라는 저장공간을 메모리에 하나 만들고, 해당 저장공간의 "입력받는 곳(inputStream)" 과 "출력하는 곳(outputStream)" 을 이용해 반대편 소켓과 통신한다.

예를 들어, 소켓 A는 소켓 B의 "입력받는 곳" 에 쓰기 위해, 자신의 "출력하는 곳" 에 데이터를 쓰면, A의 운영체제가 이 변화를 감지하고 소켓 B의 운영체제에 데이터를 전송하여, B의 운영체제가 이를 작성함으로써 데이터의 통신이 마무리되는 것이다.

99%이상의 TCP 통신은 이와 같은 방식으로 작동한다.

소켓 인터페이스의 구조 - 소켓 통신 연결 과정

우리가 일상생활에서 흔히 잊고 있지만, 사실은 "동시에 만났다" 라는 것은 매우 만족하기 힘들다.
결국 누군가는 상대방보다 "먼저 도착" 하고, "대기" 해야 하는 상황이 발생하는 것이다.

우리는 이를 위에서 "클라이언트 - 서버" 모델을 정의했다.
"만남의 순간" 을 처리하기 위해, 클라이언트 - 서버 모델을 사용하는 것이다.
서버는 클라이언트의 "요청"을 "대기"하고, 클라이언트는 서버가 준비되었을 때 자신의 "요청" 을 보내는 것이다.

소켓도 누군가는 서버 역할을 하고, 누군가는 클라이언트 역할을 해야, 서로 간 통신을 수행할 수 있는 것이다.

출처: https://song-yoshiii.tistory.com/3, 화이트 모드로 보길 권장한다.

socket()

소켓 객체를 생성한다. 클라이언트와 서버 모두 한번은 호출해야 하며, 자신의 존재를 생성한다.

bind()

서버가 호출해야 하는 메소드로, "자신의 위치" 를 정의한다.
자신의 포트 번호를 통해, 자신의 위치를 내 컴퓨터에 알린다.
만약, 해당 주소를 DNS 서버라고 하는 전 세계의 지도에 알리면, 어느곳에서든 내 주소를 보고, 내 위치로 찾아올 수 있게 되는 것이다.

listen()

자신을 "서버" 로 지정하고, 이를 운영체제(OS)에게 알린다.
"서버" 가 된다는 것은, 다른 소켓과 연결 상태를 계속 유지하는 것이 아닌, "다른 소켓의 요청을 받기 위한 소켓" 으로 완벽히 전직한다는 것이다.
이때, "연결 요청"을 관리하기 위한 관리 큐를 지정하는데, 이를 백로그 큐 라고 한다.
이에 대한 설명은 TCP 에 대해 가볍게 훑은 뒤 진행하도록 하겠다. 

connect()

"클라이언트" 로 정의된 일반 소켓이 호출하는 메소드로, "가고싶은 위치" 를 지정한다.
이 과정에서 직접 해당 위치의 서버 소켓과 연결을 시도(요청)하며, 응답이 올 때까지 대기 상태를 유지하게 된다. 

accept()

서버 소켓이 호출하는 메소드로, 들어온 클라이언트의 요청을 받는다.
만약 메소드를 호출했는데, 실제로 요청이 안들어 왔으면, 클라이언트의 요청이 들어올때까지 무한정 대기하며, 이 클라이언트와 서버 간의 관계는 생산자-소비자 문제로 해석할 수 있다. (추후 생산자-소비자 문제 작성 예정)

accept() 메소드는 실제로 새로운 소켓 객체를 생성해서, 클라이언트의 소켓과 연결을 맺어주고, 그 새로운 소켓이 클라이언트와 통신할 수 있도록 만든 뒤, 자신은 다시 다음 일(예: 다시 새로운 클라이언트를 대기 or 종료) 을 하러 간다. 

 

위의 과정을 통해, 두 "일반 소켓" 들은 read()/write() 메소드를 이용하여 서로 통신을 주고받을 수 있게 되는 것이다.
(서버 소켓과 클라이언트(일반) 소켓이 직접 통신하는게 아니다!)

TCP

TCP란 Transmission Control Protocol의 약자로, 혼잡 제어가 가능한 Transport 계층의 프로토콜 중 하나이다.
왜 3번 handshake 를 하여 연결을 맺는지, 왜 4번 handshake 하여 연결을 끊는지, 그 유명한 혼잡제어는 뭔지와 같은
TCP에 대한 자세한 설명은 다른 글에서 다루기로 하고, 현재 간단하게 알고 있어야 하는 연결의 상태에 대해 알아보자.

출처: https://dontbesatisfied.tistory.com/10

서버는 클라이언트의 연결 시도가 들어왔을 때, 클라이언트와의 연결 상태를 크게 3가지 단계로 나눈다.

  1. NONE
    아무런 연결이 이루어지지 않은 상태로, 아직 클라이언트의 요청이 서버에 들어오지 않은 상태이다.
  2. SYN_RCVD
    서버가 클라이언트의 요청을 확인한 상태로, 아직 3-way handshake 가 완료되진 않았지만, 연결을 시도중인 상태이다.
  3. ESTABLISHED
    서버와 클라이언트가 3-way handshake 를 완료한 상태로, 이 상태에선 서버와 클라이언트 간의 연결 정보가 완성이 되고, 클라이언트와 서버 간에 정보를 주고받기 위한 모든 준비가 완료된 정보가 "어딘가"에 담겨있다.

잠깐 참고 - TCP/IP

나와 같이 TCP/IP 와 TCP, IP를 헷갈리는 사람이 없길 바라며, 가볍게 용어 정리를 하고 넘어가려고 한다.

TCP/IP란?

TCP/IP 란, 인터넷에서 쓰이는 프로토콜의 집합으로, The Protocol Suite 라고도 불린다.
TCP/IP 에는 TCP, IP 말고, HTTP, UDP 등 다양한 프로토콜이 있는데, 이들 모두를 TCP/IP 에 속한다고 볼 수 있는 것이다.

그럼 왜 TCP/IP 인가? 궁금할 수 있는데, 다양한 프로토콜을 만들고 프로토콜의 집합을 정의한 1970년대 미국 국방부 DARPA 프로젝트에서 이 두 프로토콜이 가장 중요한 구성요소였기 때문에, 전체 프로토콜 스위트를 TCP/IP라고 부르게 되었다고 한다.

 

Connect() - Accept() 사이 중간 과정

1. 소켓의 connect() 요청

출처:김영한 자바 고급 2편

클라이언트가 해당 서버 소켓에 connect 요청을 보내게 되면, OS가 먼저 OS의 백로그 큐에 연결을 맺어두고, "연결 정보"를 보관한다. 서버 소켓이 먼저 OS에 소켓의 생성을 알려뒀어야 (listen() 했어야) 가능하며, 실제 연결을 맺는 것은 소켓의 로직이 아닌, 운영체제가 관리함을 의미한다.

이는 백로그 큐와 TCP 의 구조와도 관련이 있는데, 실제 백로그 큐는 다음과 같은 구조를 가진다.

  1. SYN Queue
    3-way handshake 중인 연결을 관리하며, SYN_RCVD 상태의 연결들을 보관한다.
  2. Accept Queue
    3-way handshake 가 완료된 연결을 보관한다. ESTABLISHED 상태이지만 아직 accept() 되지 않은 연결들을 보관하는 것이다.

해당 백로그 큐는 "생산자 - 소비자 문제" 가 적용된다.

더보기

백로그 큐가 가득 찬 경우, 운영체제는 일반적으로 더 들어오는 요청을 그냥 Drop 하며, 클라이언트는 일반적으로 연결이 맺어졌다는 응답이 돌아올 때까지 (혹은 타임아웃시까지) 재요청을 보내게 된다.

백로그 큐가 비어있는 경우, 서버는 accept 메소드 호출 시, 클라이언트의 요청이 들어올때까지 무한정 대기하는 방식으로 accept의 동작을 처리하게 된다.

출처: 김영한 자바 고급 2편

이래서 발생하는 재밌는 상황이 하나 있다.
만약 서버 소켓이 accept() 처리를 하지 않았는데, 클라이언트가 패킷을 보내게 되면, OS 레벨에선 네트워크 카드를 통해 클라이언트가 보낸 해당 데이터를 받을 수 있다.
클라이언트는 따라서 자신의 데이터를 성공적으로 보냈다고 판단하고, 응답을 대기하게 된다.
하지만, 실제 소켓과 연결이 되어있진 않기 때문에, 애플리케이션은 OS TCP 수신 버퍼에 있는 데이터를 확인할 방법이 없고, 따라서 해당 데이터는 서버 OS의 TCP 수신 버퍼에서 대기하게 되는 것이다.

2. 서버의 accept() 처리

출처: 김영한 자바 고급 2편

이 그림에서 보면 알 수 있지만, 서버 소켓은 단지 클라이언트와 서버의 TCP 연결만 지원하는 특별한 소켓이다. 물론 TCP 연결을 맺는 것 마저도 OS가 하는데, 그럼 서버 소켓은 하는게 뭐냐? 싶기에, 정확한 서버의 accept() 처리에 대해 알아보도록 하겠다.

서버 소켓은 accept() 호출 시, OS의 백로그 큐 (그중 특별히 accept Queue) 에 데이터(연결 정보)가 올 때까지 대기하다가, 만약 큐에 데이터가 존재하는 상태가 되면, 큐에서 데이터를 하나 pop하고, 새 소켓을 생성하여, 해당 소켓을 OS가 보관한 "연결 정보" 에 따라 클라이언트의 소켓과 연결한다.

정확히 말하자면, 서버 소켓은 하나의 "소켓 팩토리" 이고, OS 백로그 큐의 데이터를 받아 새로운 소켓을 생성하여, 서버 애플리케이션과 OS의 TCP연결을 묶는 책임을 지고 있는 통신 목적이 아닌 독특한 객체 라고 보는 것이다.

3. accept() 처리 완료 후

출처: 김영한 자바 고급 2편

이렇게 accept() 가 완료된 후, 클라이언트와 서버의 새로 생성된 Socket 객체가 TCP로 연결되고, 스트림을 통해 메시지를 주고받을 수 있게 되는 것이다.

만약 OS 백로그 큐가 아직 데이터가 남아있고 accept() 를 다시 호출했다면, 서버 소켓은 다시 새로운 연결 정보를 받아 새 소켓을 생성하고, 해당 소켓을 다른 클라이언트 소켓과 연결하는 것이다.

만약 서버 소켓이 새 소켓을 생성하고 해당 연결 정보에 따라 클라이언트의 소켓과 연결하는 속도보다, OS 백로그 큐에 연결 정보가 쌓이는 속도가 빠르다면, 벡로그 큐가 가득 차고, 연결 정보들이 Drop 되는 사태가 발생하는 것이다.

자바에서의 소켓 인터페이스의 사용 - Socket 객체

이제 본격적으로 소켓 인터페이스의 사용 방법에 대해 제대로 알아보자.

새 일반 소켓의 생성

일반 소켓 생성시에는 생성자의 매개변수에 목적지 IP 주소 혹은 DNS 주소와 포트 번호를 남겨야 하는데, 소켓은 생성시 해당 정보를 통해 해당 서버에 connect() 요청까지 수행하게 된다.

이때, IP 및 포트번호를 찾는 과정에서, 위와 같은 IP 주소가 아닌 DNS 주소가 들어올 경우, 먼저 시스템의 호스트 파일을 먼저 확인한 후, 호스트 파일에 정의되어 있지 않다면, DNS 서버에 요청해서 해당 DNS의 IP 주소를 얻는다.

시스템의 호스트 파일은 다음과 같은 위치에서 확인해볼 수 있다.

  • 맥/리눅스: /etc/hosts
  • 윈도우: C:\Windows\System32\drivers\etc\hosts

실제 필자의 시스템 호스트 파일. 도커와 쿠버네티스 관련 기본 설정들이 잡혀있다.

자바에서의 소켓 인터페이스의 사용 - ServerSocket 객체

위에서 짐작했겠지만, ServerSocket 은 사실 애초에 설계부터 동시에 여러 요청을 받고 처리하기 위한 "멀티스레드 서버"를 상정한 설계이다.

서버 소켓 생성. 자신의 IP는 이미 OS레벨에서 정해져 있고, 포트 번호을 알려줌으로써 내 위치를 명확히 한다.

다음과 같이 서버 소켓 객체를 생성하며, 해당 과정을 통해 listen() 까지의 모든 과정을 추상화한다.

.

또한, serverSocket.accept()를 통해 accept()가 하는 모든 작업들을 수행한 뒤, 생성된 새 소켓을 반환한다.

따라서, 위 serverSocket.accept() 로 생성한 소켓은, 새 스레드를 생성하여, 해당 스레드에서 서빙을 수행하도록 하는 것이 바람직하다.

출처: 김영한 자바 고급 2편

 

내용 출처: 컴퓨터 네트워킹 - Top Down Approach, 김영한 자바 고급 2편 - I/O, 네트워크, 리플렉션