WSAAsyncSelect 모델

bolee·2022년 5월 3일
0

WSAAsyncSelect 모델은 WSAAsyncSelect() 함수가 핵심 역할을 한다는 뜻에서 붙인 이름이다.

WSAAsyncSelect 모델을 사용하면 소켓과 관련된 네트워크 이벤트를 윈도우 메세지 형태로 받게 된다. 아래 그림 같이 모든 소켓과 관련된 메세지가 한 윈도우, 즉, 한 윈도우 프로시저에 전달되므로 멀티스레드를 사용하지 않고도 여러 소켓을 처리할 수 있다.

WSAAsyncSelect 모델의 동작 원리

WSAAsyncSelect 모델을 사용하면 소켓 함수 호출이 성공할 수 있는 시점을 윈도우 메세지 수신으로 알 수 있다. 따라서 소켓 함수 호출 시 조건이 만족되지 않아 생기는 문제를 해결할 수 있다.

WSAAsyncSelect 모델을 이용한 소켓 입출력 절차는 다음과 같다.

  1. WSAAsyncSelect() 함수를 호출하여 소켓 이벤트를 알려줄 윈도우 메세지와 관심있는 네트워크 이벤트를 등록한다. 예를 들면, 소켓으로 데이터를 받을 수 있는 상황이 되면 (WM_USER + 1)로 정의된 윈도우 메세지로 알려달라고 등록한다.
  2. 등록한 네트워크 이벤트가 발생하면 윈도우 메세지가 발생하여 윈도우 프로시저가 호출된다.
  3. 윈도우 프로시저에서는 받은 메세지의 종류에 따라 적절한 소켓 함수를 호출해 처리한다.

WSAAsyncSelect() 함수의 원형은 다음과 같다.

// 성공: 0, 실패: SOCKET_ERROR
int WSAAsyncSelect(
	SOCKET	s,
    HWND	hWnd,
    unsigned int	wMsg,
    long	lEvent
);
  • s: 네트워크 이벤트를 처리하고자 하는 소켓
  • hWnd: 네트워크 이벤트가 발생하면 메세지를 받을 윈도우의 핸들이다.
  • wMsg: 네트워크 이벤트가 발생하면 윈도우가 받은 메세지다. 소켓을 위한 윈도우 메세지는 따로 정의되지 있지 않으므로 WM_USER + x형태의 사용자 정의 메세지를 이용하면 된다.
  • lEvent: 관심 있는 네트워크 이벤트를 아래 표의 비트 마스크 조합으로 나타낸다.
네트워크 이벤트의미
FD_ACCEPT접속한 클라이언트가 있다.
FD_READ데이터 수신이 가능하다.
FD_WRITE데이터 송신이 가능하다.
FD_CLOSE상대가 접속을 종료했다.
FD_CONNECT통신을 위한 연결 절차가 끝났다.
FD_OOBOOB(Out-Of-Band) 데이터가 도착했다.

다음 코드는 소켓 s에 대해 FD_READFD_WRITE 이벤트를 등록하는 예를 보여준다.

#define WM_SOCKET (WM_USER+1)	// 사용자 정의 윈도우 메세지
...
WSAAsyncSelect(s, hWnd, WM_SOCKET, FD_READ|FD_WRITE);

WSAAsyncSelect() 함수 사용 시 유의할 점은 다음과 같다.

  • WSAAsyncSelect() 함수를 호출하면 해당 소켓은 자동으로 넌블로킹 모드로 전환된다. 블로킹 소켓은 윈도우 메세지 루프를 정지시킬 가능성이 있기 때문에 WSAAsyncSelect 모델에서는 넌블로킹 소켓만 사용하게 되어 있다.
  • accept() 함수가 리턴하는 소켓은 연결 대기 소켓과 동일한 속성을 갖게 된다. 연결 대기 소켓은 직접 데이터 송수신을 하지 않으므로 FD_READ, FD_WRITE 이벤트를 처리하지 않는다. 반면 accept() 함수가 리턴하는 소켓은 FD_READ, FD_WRITE 이벤트를 처리해야 하므로, 다시 WSAAsyncSelect() 함수를 호출하여 관심있는 이벤트를 등록해야 한다.
  • 윈도우 메세지에 대응하여 소켓 함수를 호출하면 대부분 성공하지만, WSAEWOULDBLOCK 오류 코드가 발생하는 경우가 드물게 있다.
  • 윈도우 메세지를 받았을 때 적절한 소켓 함수를 호출하지 않으면, 다음 번에는 같은 윈도우 메세지가 발생하지 않는다. 예를 들어, FD_READ 이벤트에 대응하여 recv() 함수를 호출하지 않으면, 동일 소켓에 대한 FD_READ 이벤트는 다시 발생하지 않는다. 따라서 윈도우 메세지가 발생하면 아래 표에 표시한 대응 함수를 호출해야 하며, 그렇지 않을 경우 응용 프로그램이 나중에 직접 메세지를 발생시켜야 한다.

응용 프로그램이 직접 메세지를 발생시킨다는 것은 PostMessage() API 함수를 사용해 자신의 윈도우 메세지 큐에 직접 메세지를 넣는다는 뜻이다.

네트워크 이벤트대응 함수
FD_ACCEPTaccept()
FD_READrecv(), recvfrom()
FD_WRITEsend(), sendto()
FD_CLOSE없음
FD_CONNECT없음
FD_OOBrecv(), recvfrom()

네트워크 이벤트 발생 시 윈도우 프로시저에는 다음과 같이 총 4개의 인자를 통해 데이터를 전달받는다.

LPESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
	...
}
  • hWnd: 메세지가 발생한 윈도우의 핸들
  • uMsg: WSAAsyncSelect()함수 호출 시 등록했던 사용자 정의 메세지
  • wParam: 네트워크 이벤트가 발생한 소켓이다. 이 값을 SOCKET으로 형변환하여 소켓 함수 호출에 그대로 사용하면 된다.
  • lParam: 하위 16비트는 발생한 네트워크 이벤트를, 상위 16비트는 오류코드를 담고 있다. 항상 오류 코드를 먼저 확인한 후 네트워크 이벤트를 처리해야 한다. 이 때 기존의 WSAGetLastError() 함수로 오류 코드를 알아낼 수 없다. 이식성(portability)을 위해 다음과 같이 정의된 매크로를 사용하는 것이 좋다.
#define WSAGETSELECTERROR(lParam) HIWORD(lParam)
#define WSAGETSELECTEVENT(lParam) LOWORD(lParam)

WSAAsyncSelect 모델 서버 적성

아래 링크는 WSAAsyncSelect 모델을 이용한 TCP 서버이다.

https://github.com/LEEBONGHAK/TCP-IP_window_socket/tree/main/Chapter10/WSAAsyncSelect

실행 결과는 논블로킹 소켓과 Select 모델을 적용시킨 결과와 같다.
스레드를 한 개만 사용한다는 점과 넌블로킹 소켓을 사용하지만 CPU 사용률이 매우 낮다는 점은 동일하다. 다만 차이점은 WSAAsyncSelect 모델 적용 서버는 윈도우가 존재한다는 점이다.

WSAAsyncSelect 모델 코드 분석

WSAAsyncSelect 모델도 Select 모델처럼 소켓과 관련된 이벤트를 알려줄 뿐 소켓 정보를 관리해주지는 않는다. 따라서 각 소켓에 필요한 정보(응용 프로그램 버퍼, 송수신 바이트 정보 등)를 관리하는 기능은 응용 프로그램이 구현해야 한다.

Select 모델에서는 최대 FD_SETSIZE(64)개의 소켓을 처리할 수 있으므로 배열을 사용했지만, WSAAsyncSelect 모델은 이런 제약이 없으므로 작성된 서버는 연결 리스트(linked list)를 사용하였다.

위 그림은 소켓 정보를 관리하기 위한 구조다. SocketInfoList 변수는 연결 리스트의 시작점을 나타내는 SOCKETINFO형 포인터다. 새로운 소켓이 생성되면 아래 그림처럼 SOCKETINFO 구조체를 동적으로 할당하고, 연결 리스트 맨 앞에 삽입한다.

소켓 정보를 삭제할 때는 아래 그림처럼 앞쪽에 있는 포인터를 조작해 연결 리스트를 유지한다.

이제 핵심 코드를 분석할 것이다.

소켓 정보 저장을 위한 구조체와 변수는 다음과 같다.

008	#define WM_SOCKET (WM_USER+1)
009
010	// 소켓 정보 저장을 위한 구조체와 변수
011	typedef struct socketinfo
012	{
013		SOCKET sock;
014		char buf[BUFSIZE + 1];
015		int recvbytes;
016		int sendbytes;
017		BOOL recvdelayed;
018		SOCKETINFO *next;
019	} SOCKETINFO;
020
021	SOCKETINFO *SocketInfoList;
  • 8: 네트워크 이벤트를 전달할 사용자 정의 윈도우 메세지
  • 11-19: 소켓 정보 저장을 위한 SOCKETINFO 구조체다. 데이터를 받은 후 끝에 널 문자('\0')를 추가하기 위해 BUFSIZE + 1 길이인 바이트 배열을 선언했다. recvbytes, sendbytes는 각각 받은 바이트 수와 보낸 바이트 수를 유지하기 위한 변수다. recvdelayed 변수는 FD_READ 메세지를 받았지만 대응 함수인 recv() 함수를 호출하지 않은 경우 TRUE로 설정할 것이다. next 변수는 SOCKETINFO 구조체를 단일 연결 리스트(singly-linked list)로 관리하기 위해 필요하다.
  • 21: SocketInfoList 는 단일 연결 리스트의 시작점이다.
037	int main(int argc, char **argv)
038	{
040		int retval;
041
042		// 윈도우 클랙스 등록
043		WNDCLASS wndclas;
044		wndclass.style = CS_HREDRAW | CS_VREDRAW;
045		wndclass.lpfnWndProc = WndProc;
046		wndclass.cbClsExtra = 0;
047		wndclass.cbWndExtra = 0;
048		wndclass.hInstance = NULL;
049		wndclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
050		wndclass.hCursor = LoadCursor(NULL, IDC_ARROW);
051		wndclass.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
052		wndclass.lpszMenuName = NULL;
053		wndclass.lpszClassName = "MyWndClass";
054		if (!RegisterClass(&wndclass))
055			return 1;
056
057		// 윈도우 생성
058		WHND hWnd = CreateWindow("MyWndClass", "TCP 서버", WS_OVERLAPPEDWINDOW, 0, 0, 600, 200, NULL, NULL, NULL, NULL);
059		if (hWnd == NULL)
060			return 1;
061		ShowWindow(hWnd, SW_SHOWNORMAL);
062		UpdateWindow(hWnd);
063
064		// 윈속 초기화
065		WSADATA wsa;
066		if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
067			return 1;
068	
069		// socket()
070		SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, 0);
071		if (listen_sock == INVALID_SOCKET)
072			err_quit("socket()");
073
074		// bind()
075		SOCKADDR_IN serveraddr;
076		ZeroMemory(&serveraddr, sizeof(serveraddr));
077		serveraddr.sin_family = AF_INET;
078		serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
079		serveraddr.sin_port = htons(SERVERPORT);
080		retval = bind(listen_sock, (SOCKADDR *)&serveraddr, sizeof(serveraddr));
081		if (retval == SOCKET_ERROR)
082			err_quit("bind()");
083
084		// listen()
085		retval = listen(listen_sock, SOMAXCONN);
086		if (retval == SOCKET_ERROR)
087			err_quit("listen()");
088
089		// WSAAsyncSelect()
090		retval = WSAAsyncSelect(listen_sock, hWnd, WM_SOCKET, FD_ACCEPT|FD_CLOSE);
091		if (retval == SOCKET_ERROR)
092			err_quit("WSAAsyncSelect()");
093	
094		// 메세지 루프
095		MSG msg;
096		// GetMessage()는 메세지 큐에 메세지가 존재한다면 가져와서 MSG 구조체에 그 값을 저장하고 TRUE를 반환한다. 그런데 만약 읽은 메세지가 WM_QUIT이면 FALSE를 리턴한다.
097		while (GetMessage(&msg, 0, 0) > 0)
098		{
099			// TranslateMessage()은 GetMessage()으로부터 전달받은 메시지가 WM_KEYDOWN 인지, 눌려진 키가 문자키인지 검사해보고 조건이 맞을 경우 WM_CHAR 메시지를 만들어 message queue에 덧붙이는 역할을 한다. 문자 입력이 아닐 경우는 아무 일도 하지 않는다.
100			// 키보드 드라이버에 의해 ASCII 문자로 매핑된 키에 대해서만 WM_CHAR 메시지를 생성한다.
101			TranslateMessage(&msg);
102			// message queue에 덧붙여진 메시지는 DispatchMessage()에 의해 WndProc()으로 전달된다.
103			DispatchMessage(&msg);
104		}
105
106		// 윈속 종료
107		WSACleanup();
108		return msg.wParam;
109	}
  • 43-62: 윈도우를 생성하고 화면에 보이게 한다.
  • 90-92: 연결 대기 소켓에 대한 이벤트를 윈도우 메세지 형식으로 받도록 등록한다. 연결 대기 소켓은 데이터 전송 목적으로는 사용하지 않으므로 FD_ACCEPTFD_CLOSE 이벤트만 등록한다.
110	// 윈도우 메세지 처리
111	LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
112	{
113		switch (uMsg)
114		{
115		case WM_SOCKET:	// 소켓 관련 윈도우 메세지
116			ProcessSocketMessage(hWnd, uMsg, wParam, lParam);
117			return 0;
118		case WM_DESTROY:
119			PostQuitMessage(0);
120			return 0;
121		}
122		return DefWindowProc(hWnd, uMsg, wParam, lParam);
123	}
  • 115-117: 사용자 정의 WM_SOCKET 메세지가 발생하면 ProcessSocketMessage() 함수에 전달하여 처리한다. 윈도우 메세지를 처리하는 코드가 길어질 경우에는 이와 같이 별도의 사용자 정의 함수에서 처리하는 것이 좋다.
125	// 소켓 관련 윈도우 메세지 처리
126	void ProcessSocketMessage(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
127	{
128		// 데이터 통신에 사용할 변수
129		SOCKETINFO *ptr;
130		SOCKET client_sock;
131		SOCKADDR_IN clientaddr;
132		int addrlen, retval;
133
134		// 오류 발생 여부 확인
135		if (WSAGETSELECTERROR(lParam))
136		{
137			err_display(WSAGETSELECTERROR(lParam));
138			RemoveSocketInfo(wParam);
139			return ;
140		}
  • 135-140: 소켓 관련 윈도우 메세지를 받으면 먼저 오류 발생 여부를 확인해야 한다. WSAGETSELECTERROR() 매크로 함수로 lParam 변수의 상위 16비트를 검사한다. 오류가 발생했다면 err_display() 함수로 오류 코드ㅡ레 대응하는 문자열을 출력한 후, RemoveSocketInfo() 함수로 소켓을 닫고 소켓 정보를 제거한다.

오류가 발생하지 않았다면 WSAGETSELECTEVENT() 매크로 함수로 네트워크 이벤트를 조사해야v한다.

142		// 메세지 처리
143		switch (WSAGETSELECTEVENT(lParam))
144		{
145		case FD_ACCEPT:
146			addrlen = sizeof(clientaddr);
147			client_sock = accept(wParam, (SOCKADDR *) &clientaddr, &addlen);
148			if (client_sock == INVALID_SOCKET)
149			{
150				err_display("accept()");
151				return ;
152			}
153			printf("\n[TCP 서버] 클라이언트 접속: IP 주소=%s, 포트 번호=%d\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientadd.sin_port));
154			AddSocketInfo(client_sock);
155			retval = WSAAsyncSelect(client_sock, hWnd, WM_SOCKET, FD_READ|FD_WRITE|FD_CLOSE);
156			if (retval == SOCKET_ERROR)
157			{
158				err_display("WSAAsyncSelect()");
159				RemoveSocketInfo(client_sock);
160			}
161			break;
  • 145-152: FD_ACCEPT 이벤트가 발생하면 accept() 함수를 호출하고 리턴 값을 확인하여 오류를 처리한다.
  • 153: 접속한 클라이언트 정보를 화면에 출력한다.
  • 154: AddSocketInfo() 함수를 호출해 소켓 정보를 추가한다.
  • 155-160: accept() 함수가 리턴한 소켓에 대해 WSAAsyncSelect() 함수를 호출함으로써 관심이 있는 이벤트를 다시 등록한다. 이 소켓은 데이터 전송에 사용할 것이므로 FD_READ, FD_WRITE, FD_CLOSE 이벤트를 등록한다. 이 과정에서 오류가 발생하면 RemoveSocketInfo() 함수를 호출해 소켓을 닫고 소켓 정보를 제거한다.
162		case FD_READ:
163			ptr = GetSocketInfo(wParam);
164			if (ptr->recvbytes > 0)
165			{
166				ptr->recvdelayed = TRUE;
167				return ;
168			}
169			// 데이터 받기
170			retval = recv(ptr->sock, ptr->buf, BUFSIZE, 0);
171			if (retval == SOCKET_ERROR)
172			{
173				err_display("recv()");
174				RemoveSocketInfo(wParam);
175				return ;
176			}
177			ptr->recvbytes = retval;
178			// 받은 데이터 출력
179			ptr->buf[retval] = '\0';
180			addrlen = sizeof(clientaddr);
181			getpeername(wParam, (SOCKADDR *) &clientaddr, &addrlen);
182			printf("[TCP/%s: %d] %s\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port), ptr->buf);
  • 162-163: FD_READ 이벤트가 발생하면 해당 소켓(wParam에 저장되어 있다.)에 대응ㅇ하는 소켓 정보 구조체를 GetSocketInfo() 함수로 얻는다.
  • 164-168: 이전에 받았지만 아직 보내지 않은 데이터가 있다면 대응 함수인 recv()를 호출하지 않고 곧바로 리턴한다. 단, recvdelayed 변수를 TRUE로 설정하여 이 사실을 기록해둔다. 대응함수를 호출하지 않았으므로 받은 데이터가 있더라도 FD_READ 이벤트는 다시 발생하지 않는다. 따라서 나중에 PostMessage() API 함수를 사용하여 응용 프로그램이 직접 FD_READ 이벤트를 발생시켜야 한다.(203행)
  • 170-176: recv() 함수를 호출해 데이터를 읽는다. 오류가 발생했다면 RemoveSocketInfo() 함수를 호출해 소켓을 닫고 소켓 정보를 제거한다.
  • 177: recv() 함수 호출이 성공했다면, 받은 바이트 수(recvbytes)을 갱신한다.
  • 179-182: 받은 데이터를 출력한다. FD_READ 이벤트를 처리한 후 곧바로 FD_WRITE 이벤트를 처리한다.(break 문이 없다는 것을 유의한다.)
183		case FD_WRITE:
184			ptr = GetSocketInfo(wParam);
185			if (ptr->recvbytes <= ptr->sendbytes)
186				return ;
187			// 데이터 보내기
188			retval = send(ptr->sock, ptr->buf + ptr->sendbytes, ptr->recvbytes - ptr->sendbytes, 0);
189			if (retval == SOCKET_ERROR)
190			{
191				err_display("send()");
192				RemoveSocketInfo(wParam);
193				return ;
194			}
195			ptr->sendbytes += retval;
196			// 받은 데이터를 모두 보냈는지 체크
197			if (recv->recvbytes == ptr->sendbytes)
198			{
199				ptr->recvbytes = ptr->sendbytes = 0;
200				if (ptr->recvdelayed)
201				{
202					ptr->recvdelayed = FALSE;
203					PostMessage(hWnd, WM_SOCKET, wParam, FD_READ);
204				}
205			}
206			break;
207		case FD_CLOSE:
208			RemoveSocketInfo(wParam);
209			break ;
210		}
211	}
  • 183-184: FD_WRITE 이벤트가 발생하면 해당 소켓(wParams에 저장되어 있다.)에 대응하는 소켓 정보 구조체를 GetSocketInfo() 함수로 얻는다.
  • 185-186: 받은 바이트 수가 보낸 바이트 수보다 크지 않다면 보낼 데이터가 없으므로 곧바로 리턴한다.
  • 188-194: send() 함수를 호출하여 데이터를 보낸다. 오류가 발생했다면 RemoveSocketInfo() 함수를 호출해 소켓을 닫고 소켓 정보를 제거한다.
  • 195: send() 함수 호출이 성공했으면, 보낸 바이트 수(sendbytes)를 갱신한다.
  • 197-205: 받은 데이터를 모두 보냈다면 받은 바이트 수와 보낸 바이트 수를 0으로 초기화 한다. 이전에 FD_READ 이벤트에 대한 대응함수를 호출하지 않았다면 PostMessage() API 함수를 호출해 강제로 발생시킨다. 이렇게 하면 다음에 FD_READ 이벤트가 발생하므로 이전에 도착했지만 처리하지 못한 데이터를 읽을 수 있다.
  • 207-208: FD_CLOSE 이벤트가 발생하면 정상 종료를 의미하므로 RemoveSocketInfo() 함수를 호출해 소켓을 닫고 소켓 정보를 제거한다.

참고 자료
김성우 저, "TCP/IP 윈도우 소켓 프로그래밍", 한빛아카데미, 2018

0개의 댓글