본문 바로가기

Network

Socket Programming - Socket

# Index

1. Socket이란

2. Socket과 TCP, Application의 관계

3. Socker 구현

4. Socket API

5. Socket Programming Sequence

6. Socket Close

 

1. Socket이란

1-1. Socket의 정의

 Socket은 파일 디스크립터(fd)를 통해 서로 다른 프로그램간에 정보 교환을 가능하게 해주는 방법이다. 따라서 소켓을 소프트웨어로 작성된 프로그램의 통신 접속점이라고 할 수 있으며, 응용 프로그램 (Application)은 소켓을 통해여 네트워크에 데이터를 송수신 하게 된다.

 

Socket

쉽게 말해 Socket이란, 응용 프로그램 (Application)에서 네트워크에 데이터를 전송하기 위해 사용하는 콘센트(?) 라고 생각할 수 있다.

 

1-2. Socket 번호 (Socket Discriptot)

 Socket은 필요에 따라서 한개 또는 여러개가 만들어 질 수 있는데, 이를 구분하기 위해 각각의 Socket마다 Socket번호가 부여된다.

Socket번호는 실제 Socket 구조체를 가리티는 주소값을 담고있는 기술자 테이블(Discriptor Table)의 Index이며, 기술자 테이블에는 fd(File discriptor)와 sd(socket discriptor)이 함께 담겨있다.

 

Discriptor Table

fd와 sd는 중복되지 않으며, 위 그림은 응용프로그램에서 2개의 파일과, 1개의 소켓을 생성했을 때, 기술자 테이블의 상태를 나타낸다.

fd 0,1,2는 기본적으로 할당되어 있다.
- fd0 : 표준 입력
- fd1 : 표준 출력
- fd2 : 표준 에러

2.  Socket과 TCP, Application간의 관계

2-1. Socket Layer

 Socket과 TCP, Application의 관계를 알아보기 위해서는, Socket Layer에 대한 지식이 필요하다. 앞서 Socket은 응용프로그램이 네트워크에 데이서를 송수신 하기 위해 필요한 접속점이라고 했다. 즉 Socket은 실제 네트워크에 접속하기 위한 어떠한 방법이라고 한다면, Socket Layer는 Socket이 위치하는 계층이라고 생각하면 된다.

 

Socket Layer

위 두 그림의 공통점은 Applicatoin(응용계층)과 TCP,UDP(전송계층)를 무언가가 이어주고 있다는 것이다. 실제 Application과 TCP를 이어주는 역할을 하는 것이 Socket이고, 이 계층을 Socket Layer라고 한다.

 

2-2 Socket Layer가 필요한 이유

 Socket Layer가 필요한 이유는 한마디로 TCP, UDP와 같은 전송계층의 복잡한 로직을 추상화하여, 응용 계층에서 쉽게 사용하기 위해서 이다. Socket Layer가 존재하기 때문에, 우리는 Application을 개발하면서 아래와 같은 복잡한 로직을 신경쓰지 않아도 된다.

 

- TCP/UDP헤더는 어떻게 만들어야 하는지

- 헤더 구조는 어떻게 되는지

- 커널에 어떻게 전달해야 하는지

 

 즉, 복잡하게 TCP/UDP를 직접 제어할 필요 없이, 단지 Socket API를 사용하여 Socket을 제어하기만 하면 Socket이 알아서 전송계층으로 데이터를 전송해 주기 때문이다.

 

3. Socket 구현

예제는 C언어의 Socket API

Socket을 사용하여 다른 프로그램과 데이터를 주고받이 위해서는 다음 5개의 정보가 필요하다.

 

1. 통신에 사용할 프로토콜 (TCP or UDP)

2. 자신의 IP Address

3. 자신의 Port 번호

4. 상대방의 IP Address

5. 상대방의 Port 번호

 

위 5개의 정보를 이용하여, Socket과, Socket주소 구조체를 생성할 수 있다.

 

3-1. Socket 생성

socket() 함수를 이용하여 socket을 생성하고, sd를 리턴받을 수 있다.

 

#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

/*
    domain   : 프로토콜 체계
    type     : 서비스 타입
    protocol : 소켓에 사용될 프로토콜
*/

 

Socket은 본래 TCP/IP, 즉 인터넷 만을 위하여 정의된 것이 아니며 UNIX 네트워크, XEROX 네트워크 등에서도 사용할 수 있도록 일반적으로 정의된 것이다. 우리는 인터넷 소켓을 사용하기 때문에 domainPF_INET으로 설정해야 한다.

 

type은 연결형(TCP), 비연결형(UDP)중 하나를 선택할 수 있으며, 옵션은 다음과 같다.

 

SOCK_STREAM (스트림 방식 , TCP)
SOCK_DGRAM  (데이터 그램 방식, UDP)

마지막으로 protocol은 소켓을 지원하는 프로토콜을 지정하는데, 일반적으로 0을 쓰면 시스템이 자동으로 설정해 준다.


Socket Parameter 종류

1. domain
- PF_INET : 인터넷 프로토콜 (TCP/IP를 사용하기 위해 기본적으로 인터넷 프로토콜 지정)
- PF_INET6 : IENT IPv6 프로토콜
- PF_UNIX : 유닉스 방식 프로토콜
- PF_NS : 제록스 네트워크 시스템의 프로토콜
- PF_PACKET : 리눅스에서 패킷 캡쳐를 위해 사용

2. type
- SOCK_STREAM (스트림) : TCP 방식
- SOCK_DGRAM (데이터그램) : UDP 방식
- SOCK_RAW : Raw 방식(TCP나 UDP를 거치지 않고 바로 IP 계층 사용시)

3. protocol
- IPPROTO_TCP : TCP 방식
- IPPROTO_UDP : UDP 방식
- 0 : type에서 미리 정해진 경우

 

3-2. Socket주소 구조체

 Socket통신을 하기 위해 필요한 5가지 정보중 1번 (통신에 사용할 프로토콜)을 이용하여, Socket을 생성하였다. 이젠 소켓을 이용할 객체(클라이언트 or 서버)의 구체적인 주소를 표현하기 위해 주소 체계, IP Address, Port번호 세 가지가 지정되어야 하며, 이 세가지 주소 정보를 소켓 주소 (Socket Address)라고 부른다.

 

 Socket프로그래밍에서 소켓 주소를 담을 구조체(sockaddr)를 아래와 같이 정의하였으며, 2Byte의 Address family와 14Byte의 주소 (IP + port)로 구성되어 있다.

 

struct sockaddr {
    u_short sa_family; /* address family */
    char sa_data[14];  /* 주소 */
}

 

하지만 sockaddr에 IP주소, 포트번호를 직접 쓰거나 읽기가 불편하므로, 인터넷 프로그래밍에서는 sockaddr 구조체를 사용하는 대신 4Byte의 IP주소와, 2Byte의 포트번호를 구분하여 지정할 수 있는 인터넷 전용 소켓주소 구조체인 sockaddr_in을 주로 사용한다.

 

struct in_addr {
    u_long s_addr; /* 32비트의 IP주소를 저장할 구조체 */
};

struct sockaddr_in {
    shot sin_family; /* 주소 체계 */
    u_short sin_port /* 16비트 포트번호 */
    struct in_addr sin_addr; /* 32비트 IP주소 */
    char sin_zero[8]; /* 16바이트 크기를 맞추기 위한 dummy field */
};

 

 sockaddr_in에서는 4Byte의 IP주소를 저장하는 구조체 in_addr과 2Byte의 포트번호를 저장하는 sin_port변수를 사용하고 있으며, sockaddr과 호환성을 위해 두 구조체의 크기를 16Byte로 같게 만들었다.

 소켓 주소의 주요 내용은 IP주소와 포트번호인데, 소켓 주소는 응용프로그램이 자신의 소켓주소를 표현하는것과, 통신할 상대방 프로세스의 소켓주소를 표현할 때 사용된다. 즉 5개의 정보중 나머지 1,2,3,4를 표현하는데 사용된다.


sin_family로 사용될 수 있는 주소체계

AF_INET (인터넷 주소 체계)
AF_UNIX (UNIX 파일 주소 체계)
AF_NS (XEROX 주소 체계)

4. Socket API

 만들어진 소켓을 통해 사용할 수 있는 기본적인 Socket API에 대해 알아보자.

 

bind()

socket()함수를 통해 만들어진 소켓에 소켓의 특성(주소)를 부여(bind)한다. 쉽게말해 따로 생성한 소켓과 소켓 주소를 묶어준다.

 

int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen);

 

인자로 주어진 sd(socket discriptor)에 socketaddr을 바인딩해준다. bind()함수를 통해 socket이 사용할 포트번호와 IP를 설정할 수 있다.

bind() 함수가 필요한 이유는, 소켓번호는 응용 프로그램(Application)이 알고 있는 통신을 위한 접속점이고, 소켓주소(IP + port)는 TCP와 같은 전송계층이 알고 있는 통신 접속점이므로, 이 둘의 관계를 묶어(bind)두어야 응용 프로그램과 네트워크 시스템간에 데이터(패킷) 전달이 가능하기 때문이다.

bind() 함수는 주로 서버에서 사용된다. 서버는 여러 클라이언트의 요청에 대해 요청을 받을 IP와 Port를 통해 서비스 되기 때문이다.
반면 클라이언트의 경우 커널에서 할당한 임의의 포트번호를 사용하여 서버와 연결하기 때문에 bind()를 사용할 필요가 없다.

connect()

주로 클라이언트측 에서 사용되며, sockaddr구조체에 설정된 주소+포트번호로 서버에 연결한다.

 

int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);

 

listen()

서버측 에서 사용되며, socket()함수를 이용해서 만들어진 Socket에 대해서 들어오는 연결을 기다린다.

 

int listen(int sockfd, int backlog);

 

backlog는 아직 완전히 연결되지 않은 연결들이 대기할 queue의 길이를 설정하기 위해서 사용된다.

 

accept()

listen()을 통해 만들어진 미연결상태의 연결들 중, 가장 앞의 연결을 가져와서 새로운 연결 소켓을 만들어준다.

 

int accept(int s, struct sockaddr *addr, socklen_t *addrlen);

 

새로 만들어진 연결소켓은 소켓 번호(socket discriptor)를 할당하여 리턴해주며, 새롭게 생성된 소켓을 통해 클라이언트와 통신할 수 있게 된다. (UDP의 경우는 연결 소켓을 만들지 않는다.)

 

recv() / recvfrom()

socket으로 부터 데이터를 읽는 기능을 하는 함수이다.

 

ssize_t recv(int socket, void *buffer, size_t length, int flags);

ssize_t recvfrom(int s, void *buf, size_t len, int flags, struct sock-addr *from, socklen_t *fromlen);

 

둘다 TCP, UDP소켓에 모두 사용할 수 있다. socket s로부터 len만큼 데이터를 읽어와서 buf에 저장한다.

 

recvfrom()은 5번째 인자인 from을 통해서 데이터를 보낸 호스트의 인터넷 정보를 얻어올 수 있다

 

recv()의 4번째 파라미터인 flag로 사용할 수 있는 값은 다음과 같다.

- MSG_OOB : out of band 데이터를 읽는다.

- MSG_PEEK : 버퍼로부터 데이터를 읽지만 제거하진 않는다.

- MSG_WAITALL : 요청한 데이터의 크기가 모두 차야지만 함수를 반환한다. ( 이식성의 문제로 사용이 드물다, 반복해서 데이터를 읽는 방법 권장)

 

send() / sendto()

socket으로 데이터를 전송하는 기능을 하는 함수이다. recv계열 함수와 비슷한 방법으로 사용이 가능하다.

 

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

ssize_t sendto(int s, const void *buf, size_t len, int flags, const struct sockaddr *to, socklen_t tolen);

 

socket sbuf로 부터 len길이만큼 데이터를 읽어와서 소켓으로 데이터를 전송한다.

 

sendto()는 5번째 인자인 to 소켓주소 구조체에 설정된 주소로 데이터를 전송한다.

 

send()의  4번째 파라미터인  flag로 사용할 수 있는 값은 다음과 같다.

- MSG_DONTROUTE : gateway를 통하지 않고 직접 상대시스템으로 전송

- MSG_DONTWAIT : non blocking에서 사용하는 옵션, 전송이 block되면 EAGIN, EWOULDBLOCK 오류를 바로 return

- MSG_MORE : 더 전송할 데이터가 있음을 설정함

- MSG_OOB : out of band 데이터를 읽는다.

 

유닉스에서 소켓은 파일과 동일하게 취급 되기 때문에 read(), write()와같은 시스템 함수를 이용해도 대부분의 입출력을 다룰 수 있다. 그러나 이들 시스템 함수들은 네트워크의 특성을 고려하지 않고 만들었기 때문에 네트워크 정보를 필요로 하는 작업을 하기에는 적당하지 않은 점이 있다.

예를들어 UDP를 이용해서 통신을 할경우 읽기는 문제없지만 쓰기에는 문제가 생길 수 있다. UDP는 연결 소켓을 만들지 않기 때문에 쓸때 연결된 호스트의 정보를 알 수가 없기 때문에 write()함수로는 데이터를 전송할 수 없게 된다. 이럴경우에는 소켓 API를 사용해서 통신을 해주어야 한다.

5. Socket Programming Sequence

5-1. TCP Socket Sequence

 앞서 배운 socket API만을 활용하여, TCP 프로토콜을 사용하는 Socket 프로그래밍을 작성할 수 있다.

TCP socket programming sequence

Server

  1. Server는 socket() 함수를 호출하여, 통신에 사용할 소켓 하나를 개설한다.
  2. Server는 생성한 소켓을 자신이 사용할 소켓 주소(sockaddr_in)를 bind() 함수를 통해 바인딩 한다.
  3. Server는 listen() 함수를 호출하여, Client로부터의 연결 요청을 기다리는 대기모드로 들어간다.
  4. Server는 accept() 시스템 콜에서 기다리고 있다가, client가 connect()를 호출하여 접속을 요청하면 이를 처리한다.
  5. 연결이 성공적으로 이루어지면, Server와 Client간에 데이터를 송수신 할 수 있게 된다.

Client

  1. Client는 socket() 함수를 호출하여, 통신에 사용할 소켓 하나를 개설한다.
  2. Client는 접속할 Server의 소켓주소 구조체(sockaddr_in)을 만들어 connect()함수를 호출한다.
  3. Server와의 연결이 이루어지면 (connect()가 성공적으로 리턴되면) 서버와 데이터를 송수신 할 수 있다.

Tip!

  • 클라이언트에서 bind()를 호출할 필요가 없는 이유는, 클라이언트 프로그램은 서버 프로그램과 달리 자신이 사용하는 IP 주소나 포트번호를 다른 클라이언트 또는 서버가 미리 알고 있을 필요가 없기 때문이다.
  • 서버의 응용 프로그램은 자신이 사용하고 있는 포트번호를 통하여 클라이언트들의 서비스를 처리해야 하므로, 응용 프로그램이 소켓번호와 소켓주소를 bind()하는 것이 필수적이다.
  • 클라이언트는 포트번호를 임의로 사용해도 되기 때문에 포트번호를 특정한 값으로 bind()시켜 두는 것이 필요 없다.
  • 클라이언트는 오히려 bind()를 사용하는 것이 클라이언트 프로그램의 범용성을 떨어뜨리게 된다. 왜냐하면 같은 포트번호를 사용하는 클라이언트 프로그램들이 하나의 컴퓨터에서 두 개 이상 실행되면 에러가 발생하기 때문이다.
  • 클라이언트에서 connect()호출 시 TCP가 서버의 소켓주소 구조체 *addr에 지정된 목적지 IP 주소와 포트번호로 연결을 시도하여 연결이 성공하면 0을 리턴한다. 실패하면 -1을 리턴하며 전역변수 errno에 에러코드가 들어 있게 된다.
  • connect() 시스템 콜 호출 중에 에러가 발생하였을 때는 바로 다시 connect()를 호출하지 말고 해당 소켓을 close()로 닫고, 새로운 소켓을 socket()으로 만든 후 사용하는 것이 안전하다.
  • 스트림 소켓에서는 IP 패킷이 한 번에 전송할 수 있는 최대 데이터 크기보다 큰 데이터를 송신 버퍼에 저장하고 write()나 send()를 호출할 수 있다. 이때에는 전체 데이터가 IP 패킷 단위로 분할되어 전송되며 수신측에서는 패킷의 순서와 분실을 검사하고 필요하면 재전송을 요구하는데 이것이 바로 스트림(stream) 소켓을 사용하는 장점이기도 하다.

 

5-1 UDP Socket Sequence

UDP socket(비연결형, 데이터그램)도 앞서 설명한 TCP sockst과 유사한 절차로 구현할 수 있다. 다만 비연결형 서비스이므로 connect() 함수 호출이 필요 없고, Client는 소켓 개설 후 바로 데이터를 송수신 할 수 있다.

 

UDP socket programming sequence

 

 비연결형 통신에서는 연결형 소켓 프로그래밍과 달리, 소켓이 목적지별로 개설되어 있는 것이 아니므로 하나의 소켓을 통하여 임의의 목적지를 향하여 IP 패킷을 보낼 수 있는데 이것이 바로 UDP를 사용하는 최대의 장점이기도 하다. 그러나 비연결형으로 만든 소켓으로 패킷을 전송할 때에는 각 패킷 전송시마다 목적지의 IP 주소와 포트번호(즉 소켓주소)를 항상 함수 인자로 주어야 한다.


Tip!

  • UDP 소켓에서는 사용자가 전송을 요구한 데이터의 크기가 데이터그램 크기보다 크면 에러가 발생하거나 데이터그램 크기만큼만 한 번 전송되고 만다.

6. Socket Close

 Socket연결을 종료하기 위한 방법으로는 shoutdown()과  close() 두 가지 방법이 존재한다.

shutdown()

#include <sys/socket.h>

int shutdown (int socket, int how);

 

SHUT_RD (0) - 입력 스트림 종료
- 데이터가 입력 버퍼에 전달되어도 지워버린다.
- 입력 관련 함수의 호출도 허용하지 않는다.
SHUT_WR (1) - 출력 스트림 종료
- 출력버퍼에 남아있는 데이터는 모두 전송한뒤 종료한다.
- 상대에게 FIN 패킷 전송
SHUT_RDWR(2) - 입출력 스트림 종료
- 입력, 출력 순으로 종료한다.
- 상대에게 FIN 패킷 전송

한번  shutdown된 소켓의 입출력 스트림은 재활용이 불가능 하다. shutdown함수를 통해 출력 스트림을 종료하면 상태에게 FIN패킷을 전송하여 TCP의 종료절차인 4-way-hand shake를 수행하도록 한다.

Close()

#include <unistd.h>

int close(int fd);

 

close()는 default옵션이 shoutdown과 마찬가지로 입출력 스트림을 종료하여 FIN패킷을 전송하지만, 해당 함수의 호출이 끝나는 시점에서 소켓의 리소스를 반납하는 동작을 수행한다.

 

소켓이 종료될 때, 출력 버퍼에 쌓인 데이터는 소켓의 옵션에 따라 다르게 동작하며 이는  Linger옵션을 통해 설정이 가능하다.

 

Linger옵션에 대해 알아보기 전에 소켓의 입출력 스트림(버퍼)에 대해 알아보자.

출처 : https://kuaaan.tistory.com/118

  1. send()함수는 인자로 주어진 Buf의 데이터를 해당 Socket에 할당된 출력스트림(버퍼)로 복사하고 Return한다.
    • send()가 리턴되었다고 해서, 인자로 넘긴 데이터가 상대방에 전송된 것은 아니다.
  2. OS는 출력스트림(버퍼)에 들어온 데이터를 상대방에게 전송한다.
    • 데이터가 전송되기 위해서는 상대방의 입력스트림에 공간이 남아있어야 하는데, 만약 상대방이 입력을 처리할 리소스 부족등으로 입력을 처리하지 못할 경우 상대방의 입력스트림에 공간이 부족해서 송신자의 출력스트림의 데이터가 바로 전송되지 못하고 지연될 수 있다.

그렇다면 송신자는 수신자의 입력스트림에 충분한 공간이 있는지 어떻게 알 수 있을 까?

-> TCP는 Ack패킷을 이용하여 송신자와 수신자 사이에 입력스트림에 공간이 충분한지 체크하는 메커니즘인 Sliding Window가 존재한다. (나중에 알아보자)

 

출처 : https://kuaaan.tistory.com/118

만약 위 그림과 같이, 출력스트림에 데이터가 남아있는 상황에서 송신자가 close()를 호출하는 상황에서 어떻게 처리해야 할지 결정하는 옵션이 Linger옵션이다. 즉 Linger 옵션이란 close()를 호출했을 때, 아직 송신되지 않고 버퍼에 남아있는 데이터를 처리하는 방식을 OS에 알려주는 옵션이다.

Linger

struct linger {
  u_short l_onoff;
  u_short l_linger;
};
l_onoff linger 옵션 사용 여부
l_linger linger timeout 설정

 

Linger를 통해 설정할 수 있는 옵션은 총 3가지가 있다.

 

 

 l_onoff = 0 Default

close()는 즉시 return하고 Graceful shutdown을 백그라운드에서 진행한다.

종료작업이 언제 완료되는지는 알 수 없다.
 l_onoff = 1
 l_linger = 0
close()는 즉시 return하고, 출력스트림에 남아있는 데이터는 버린다.

비정상 종료 (Abortive Shutdown)
 l_onoff = 1
 l_linger = 0이아닌 정수값
정상적으로 종료가 될 때까지 close()를 return하지 않는다.

l_linger에 명시된 timeout 시간이 지나도 정상적인 종료가 되지 않는다면 비정상종료 (Abortive Shutdown)을 진행한다.

 

 


 Socket을 닫는 경우, UDP와 같은 비연결형 서비스는 단순히 소켓을 닫는 작업만 하지만, TCP와 같은 연결형 서비스의 경우에는 설정할 수 있는 옵션이 많다. 그 이유는 TCP는 기본적으로 두 종단간에 연결을 맺고 사용하는 방식이며, 연결을 맺고 끊을 때 사용하는 handshake때문에 여러 옵션이 있는 듯 하다.

'Network' 카테고리의 다른 글

Socket Programming - TCP 세션 수립 및 종료  (0) 2020.11.30
Socket Programming 실습  (0) 2020.11.18