Select 모델은 select()
함수가 핵심 역할을 한다는 뜻에서 붙인 이름이다. Select 모델을 사용하면 소켓 모드(블로킹, 넌블로킹)에 관계없이 여러 소켓을 한 스레드로 처리할 수 있다.
Select 모델을 사용하면 소켓 함수 호출이 성공할 수 있는 시점을 미리 알 수 있다. 따라서 소켓 함수 호출 시 조건이 만족되지 않아 생기는 문제를 해결할 수 있다. 소켓 모드에 따른 Select 모델의 사용 효과는 다음과 같다.
아래 그림은 Select 모델의 동작 원리를 보여준다. Select 모델을 사용하려면 소켓 셋(socket set) 3개를 준비해야 한다. 소켓 셋은 소켓 디스크립터의 집합을 의미하며, 호출한 함수의 종류에 따라 소켓을 적당한 셋에 넣어두어야 한다. 예를 들면 어떤 소켓에 대해 recv()
함수를 호출하고 싶다면 읽기 셋에 넣고, send()
함수를 호출하고 싶다면 쓰기 셋에 넣으면 된다.
소켓 셋을 3개 준비해 select()
함수를 호출하면, select()
함수는 소켓 셋에 포함된 소켓이 입출력을 위한 준비가 될 때까지 대기한다. 적어도 한 소켓이 준비되면 select()
함수는 리턴한다. 이때 소켓 셋에는 입출력이 가능한 소켓만 남고 나머지는 모두 제거된다.
응용 프로그램은 소켓 셋을 통해 소켓 함수를 성공적으로 호출할 수 있는 시점을 알아낼 수 있고, 드물지만 소켓 함수의 호출 결과를 확인할 수도 있다. 다음은 소켓 셋의 역할을 정리한 표이다.
소켓 셋 | 함수 호출 시점 | 함수 호출 결과 |
---|---|---|
읽기 셋(read set) | - 접속한 클라이언트가 있으므로 accept() 함수를 호출할 수 있다.- 소켓 수신 버퍼에 도착한 데이터가 있으므로 recv() , recvfrom() 등의 함수를 호출해 데이터를 읽을 수 있다.- TCP 연결이 종료되었으므로 recv() , recvfrom() 등의 함수를 호출해 연결 종료를 감지할 수 있다. | x |
쓰기 셋(write set) | - 소켓 송신 버퍼의 여유 공간이 충분하므로 send() , sendto() 등의 함수를 호출하여 데이터를 보낼 수 있다. | - 넌블로킹 소켓을 활용한 connect() 함수 호출이 성공 했다. |
예외 셋(exception set) | - OOB(Out-Of-Band) 데이터가 도착했으므로 recv() , recvfrom() 등의 함수를 호출하여 OOB데이터를 받을 수 있다. | - 넌블로킹 소켓을 사용한 connect() 함수 호출이 실패했다. |
select()
함수 원형은 다음과 같다.
// 성공: 조건을 만족하는 소켓의 개수 또는 0(타임 아웃), 실패: SOCKET_ERROR
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
const struct timeval *timeout
);
NULL
값이 될 수 있다.select()
함수는 무조건 리턴한다.timeval
구조체 형태는 다음과 같다.
typedef struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
} TIMEVAL;
타임아웃 값에 따른 select()
함수의 동작은 다음과 같다.
NULL
: 적어도 한 소켓이 조건을 만족할 때가지 무한히 기다린다. 리턴 값은 조건을 만족하는 소켓의 개수가 된다.{0, 0}
: 소켓 셋에 포함된 모든 소켓을 검사한 후 곧바로 리턴한다. 리턴 값은 조건을 만족하는 소켓의 개수 또는 0(타임아웃)이 된다.select()
함수를 이용한 소켓 입출력 절차는 다음과 같다. 단, 타임아웃은 NULL
값을 가정한다.
FD_SETSIZE(64)
로 정의되어 있다.select()
함수를 호출한다. 타임아웃이 NULL
이면 select()
함수는 조건을 만족하는 소켓이 있을 때까지 리턴하지 않는다.select()
함수가 리턴하면 소켓 셋에 남아 있는 모든 소켓에 대해 적절한 소켓 함수를 호출해 처리한다.소켓 셋을 편하게 다룰 수 있도록 다음과 같은 매크로 함수가 제공된다.
매크로 함수 | 기능 |
---|---|
FD_ZERO(fd_set *set) | 셋을 비운다(초기화) |
FD_SET(SOCKET s, fd_set *set) | 셋에 소켓 s를 넣는다. |
FD_CLR(SOCKET s, fd_set *set) | 셋에 소켓 s를 제거한다. |
FD_ISSET(SOCKET s, fd_set *set) | 소켓 s가 셋에 들어 있으면 0이 아닌 값을 리턴한다. 그렇지 않으면 0을 리턴한다. |
아래 링크는 넌블로킹 소켓을 사용하고 Select 모텔로 작성된 TCP 서버이다.
https://github.com/LEEBONGHAK/TCP-IP_window_socket/blob/main/Chapter10/Select/SelectTCPServer.cpp
실행 결과는 멀티스레드를 사용한 블로킹 TCP 서버를 사용할 때와 같다. 차이가 있다면 Select 모델 서버는 멀티스레드를 사용하지 않고도 여러 소켓을 처리한다는 점이다. 또한 모든 소켓은 넌블로킹 소켓이지만 Windows 작업 관리자로 보면 CPU 사용률이 매우 낮음을 알 수 있다.
Select 모델은 여러 소켓에 대해 함수 호출 시점(또는 호출 결과)을 알려주는 역할을 할 뿐 소켓 정보를 관리해주지는 않는다. 따라서 각 소켓에 필요한 정보(응용 프로그램 버퍼, 송/수신 바이트 정보 등)을 관리하는 기능은 응용 프로그램이 구현해야 한다.
위 그림은 소켓 정보를 관리하기 위한 구조다. Select 모델에서 처리할 수 있는 소켓의 최대 개수는 FD_SETSIZE(64)
로 정해져 있으므로, 이 개수 만큼 사용자 정의 SOCKETINFO
구조체의 주소를 저장할 수 있도록 배열을 선언한다.
새로운 소켓이 생성되면 위 그림처럼 SOCKETINFO*
구조체를 동적으로 할당하고, 배열에 주소값을 저장해둔다.
소켓 정보를 삭제할 때는 위 그림처럼 포인터 배열 중간에 빈 곳이 없도록 배열의 맨 끝에 있는 유효 원소를 삭제한 위치로 옮긴다.
이제 핵심 코드를 행 단위로 분석해 볼 것이다. 소켓 정보 저장을 위한 구조체와 변수는 다음과 같다.
009 // 소켓 정보 저장을 위한 구조체와 변수
010 typedef struct socket_info
011 {
012 SOCKET sock;
013 char buf[BUFSIZE + 1];
014 int recvbytes;
015 int sendbytes;
016 } SOCKETINFO;
017
018 int nTotalSockets = 0;
019 SOCKETINFO *SocketInfoArray[FD_SETSIZE];
SOCKETINFO
구조체이다. 데이터를 받은 후 끝에 널 문자('\0')를 추가하기 위해 BUFSIZE + 1
길이인 바이트 배열을 선언했다. recvbytes
, sendbytes
는 각각 받은 바이트 수와 보낸 바이트 수를 유지하기 위한 변수이다.SOCKETINFO
구조체의 개수다. 소켓을 생성할 때마다 1씩 증가하고 소켓을 닫을 때마다 1씩 감소한다.SOCKETINFO
형 포인터를 저장할 배열이다. 원소개수는 Select 모델에서 처리할 수 있는 소켓의 최대 개수(FD_SETSIZE(64))로 정의하고 있다.윈속 초기화, socket()
, bind()
, listen()
함수 호출 부분은 일반적인 TCP 서버와 같으므로 설명을 생략하고 그 다음 부분부터 살펴보자.
056 // 넌블러킹 소켓으로 전환
057 u_long on = 1;
058 retval = ioctlsocket(listen_sock, FIONBIO, &on);
059 if (retval == SOCKET_ERROR)
060 err_quit("ioctlsocket()");
send()
함수 호출 시 지정한 값보다 작은 값이 send()
함수의 리턴 값으로 나올 수 있으므로 주의해야 한다.068 while (1)
069 {
070 // 소켓 셋 초기화
071 FD_ZERO(&reset);
072 FD_ZERO(&wset);
073 FD_SET(listen_sock, &rset);
074 for (i = 0; i < nTotalSockets; i++)
075 {
076 if (SocketInfoArray[i]->recvbytes > SocketInfoArray[i]->sendbytes)
077 FD_SET(SocketInfoArray[i]->sock, &wset);
078 else
079 FD_SET(SocketInfoArray[i]->sock, &rset);
080 }
082 // select()
083 retval = select(0, &rset, &wset, NULL, NULL);
084 if (retval == SOCKET_ERROR)
085 err_quit("select()");
select()
함수를 호출한다. 예외 셋은 사용하지 않으므로 NULL 값을 넣는다. 타임아웃 역시 NULL 값을 사용함으로써 조건이 만족될 때까지 무한히 대기하게 된다.087 // 소켓 셋 검사(1): 클라이언트 접속 수용
088 if (FD_ISSET(listen_sock, &rset))
089 {
090 addrlen = sizeof(clientaddr);
091 client_sock = accept(listen_sock, (SOCKADDR *) &clientaddr, &addrlen);
092 if (client_sock == SOCKET_ERROR)
093 err_display("accept()");
094
095 // 접속한 클라이언트 정보 출력
096 printf("\n[TCP 서버] 클라이언트 접속: IP 주소=%s, 포트 번호=%d\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));
097 // 소켓 정보 추가
098 AddSocketInfo(client_sock);
099 }
select()
함수가 리턴하면 먼저 읽기 셋을 검사하여 접속한 클라이언트가 있는지 확인한다. 연결 대기 소켓이 읽기 셋에 있다면 접속한 클라이언트가 있다는 뜻이다.accept()
함수를 호출한 후 리턴 값을 확인하여 오류를 처리한다. 윈도우 운영체제에서는 listen_sock
이 넌블로킹 소켓이면 client_sock
도 자동으로 넌블로킹 소켓이 된다. 따라서 client_sock
에 대해 56-60행과 비슷한 코드를 작성할 필요는 없다.AddSocketInfo()
함수를 호출해 소켓 정보를 추가한다.101 // 소켓 셋 검사(2): 데이터 통신
102 for (i = 0; i < nTotalSockets; i++)
103 {
104 SOCKETINFO *ptr = SocketInfoArray[i];
105 if (FD_ISSET(ptr->sock, &rset))
106 {
107 // 데이터 받기
108 retval = recv(ptr->sock, ptr->buf, BUFSIZE, 0);
109 if (retval == SOCKET_ERROR)
110 {
111 err_display("recv()");
112 RemoveSocketInfo(i);
113 continue;
114 }
115 else if (retval == 0)
116 {
117 RemoveSocketInfo(i);
118 continue;
119 }
120 ptr->recvbytes = retval;
121
122 // 받은 데이터 출력
123 addrlen = sizeof(clientaddr);
124 getpeername(ptr->sock, (SOCKADDR *) &clientaddr, &addrlen);
125 ptr->buf[retval] = '\0';
126 printf("[TCP/%s: %d] %s\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port), ptr->buf);
127 }
128
129 if (FD_ISSET(ptr->sock, &wset))
130 {
131 // 데이터 보내기
132 retval = send(ptr->sock, ptr->buf + ptr->sendbytes, ptr->recvbytes - ptr->sendbytes, 0);
133 if (retval == SOCKET_ERROR)
134 {
135 err_display("send()");
136 RemoveSocketInfo(i);
137 continue;
138 }
139
140 ptr->sendbytes += retval;
141 if (ptr->recvbytes == ptr->sendbytes)
142 ptr->recvbytes = ptr->sendbytes = 0;
143 }
144 }
145 }
select()
함수는 조건을 만족하는 소켓의 개수를 리턴하지만 구체적으로 어떤 소켓인지는 알려주지 않는다. 따라서 응용 프로그램이 관리하고 있는 모든 소켓에 대해 소켓 셋에 들어 있는지 여부를 일일이 확인해야 한다.recv()
함수를 호출한 후 리턴 값을 확인하여 오류를 처리한다. 오류 발생(SOCKET_ERROR
) 또는 정상 종료(0) 모두 RemoveSocketInfo()
함수를 호출해 소켓을 닫고 소켓 정보을 제거한다.recv()
함수 호출이 성공했으면, 받은 바이트 수(recvbytes
)를 갱신한 후 화면에 출력한다.send()
함수를 호출한 후 리턴 값을 확인하여 오류를 처리한다. 오류가 발생했다면 RemoveSocketInfo()
함수를 호출하여 소켓을 닫고 소켓 정보를 제거한다.send()
함수 호출이 성공했으면, 보낸 바이트 수(sendbytes
)를 갱신한다.참고 자료
김성우 저, "TCP/IP 윈도우 소켓 프로그래밍", 한빛아카데미, 2018
감사합니다. 잘 읽었습니다.
오타가 있으니 수정하시면 좋을듯 합니당.
내용 : 90-83: accept() 함수를 호출한 후 리턴 값을 확인하여 오류를 처리한다.
83 -> 93