클라이언트와 서버의 동작에 대해서 코드 분석을 통해 알아보자.
0. 핵심 요약
핵심 키워드: Winsock 초기화, socket 생성, 바이트 정렬, Listen socket과 Accept socket의 차이, 3-Way handshake
1. 서버 측 주요 함수 분석
// 1. initialize Winsock
WSADATA wsaData; // WSADATA: struct with info on socket initialization
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) return 1; // initiate Winsock DLL from process. return 0 on success
// MAKEWORD(2, 2) for Winsock 2.2
// &wsaData: a pointer to the WSADATA structure
WSADATA는 socket 구현에 대한 정보를 담는 구조체다.
WSAStartup 함수는 winsock dll을 실행하도록 한다.
이때 버전은 MAKEWORD(2, 2)를 통해 winsock 2.2를 사용하도록 하고, wsaData에 socket 구현에 대한 정보를 저장한다.
성공한 경우 0을 리턴한다.
// 2. create listening socket
SOCKET listenSock = socket(AF_INET, SOCK_STREAM, 0); // socket(addr family, socket type, protocol). return socket handle(SOCKET) on success
// AF_INET for IPv4
// SOCK_STREAM for connection based stream(ex. TCP)
// 0 for not specifying a protocol. service provider chooses(AF_INET & SOCK_STREAM -> TCP)
socket 함수는 주소 패밀리와 socket type, 프로토콜을 설정한 socket을 생성해 리턴한다.
- 주소 패밀리 AF_INET은 IPv4를 의미한다.
- socket type SOCK_STREAM은 해당 socket이 연결지향적임을 의미한다.
- 프로토콜은 구체적인 프로토콜을 지정하는데, 0인 경우 서비스 공급자가 결정한다. 주소 패밀리가 AF_INET(IPv4)이고 socket type이 SOCK_STREAM(연결지향)이라면 TCP가 된다.
// 3. Set server addr(IP, Port)
sockaddr_in serverAddr = {}; // struct for addr, port. (for server)
serverAddr.sin_family = AF_INET; // IPv4. Fixed.
serverAddr.sin_port = htons(9000); // htons(short): little-endian -> big-endian. 2byte
serverAddr.sin_addr.s_addr = htonl(INADDR_ANY); // IPv4(type: u_long). htonl(long): little-endian -> big-endian. 4byte
// INADDR_ANY: 0x00000000(allow connection toward any IP)
sockaddr_in은 주소 패밀리와 주소, 포트를 저장하는 구조체이다.
이때 데이터를 저장할 때에는 엔디안을 고려해야 한다.
network 표준은 상위 바이트를 메모리의 앞 주소에 저장하는 빅 엔디안 방식을 사용한다.
host인 대부분의 개인용 PC(x86/x86_64 아키텍처)는 하위 바이트를 메모리의 앞 주소에 저장하는 리틀 엔디안 방식을 사용한다.
따라서 시스템 간 데이터를 주고받을 때, 엔디안을 변환하지 않으면 데이터가 뒤집혀서 오작동할 수 있다.
이를 막기 위해, 저장할 때에는 htons(short, 2byte), htonl(long, 4byte)를 활용해 빅 엔디안 방식으로 바꾸어 저장한다.
엔디안 변환 – htons와 htonl 정리!
🌐 엔디안 변환 – htons와 htonl 완벽 정리!네트워크 프로그래밍이나 시스템 간 데이터 전송을 다루다 보면 "엔디안(Endian)"이라는 개념을 자주 접하게 됩니다. 특히 C/C++에서 htons, htonl 같은 함수가
drdbg.com
참고로 INADDR_ANY는 0x00000000로, 주소를 INADDR_ANY로 설정하면 목적지 IP 주소와 상관없이 포트번호만 맞다면 연결을 허용한다는 의미이다.
// 4. bind - connect socket to addr
bind(listenSock, (sockaddr*)&serverAddr, sizeof(serverAddr));
// 5. listen - change state to "Listening". return 0 on success
listen(listenSock, SOMAXCONN); // backlog(backlog queue): max size of the queue with connections waiting. --> SOMAXCONN: set backlog to maximum
cout << "Server started. Waiting connection...\n";
bind 함수는 socket에 주소와 포트번호를 할당한다.
listen 함수는 socket의 상태를 Listening(수신 대기)으로 설정한다.
클라이언트의 연결 요청은 backlog queue에서 대기하는데, SOMAXCONN은 backlog queue의 크기를 최대로 설정한다.
// 6. accept - allow client connection
sockaddr_in clientAddr = {}; // struct for addr, port. (for client)
int clientAddrLen = sizeof(clientAddr);
SOCKET clientSock = accept(listenSock, (sockaddr*)&clientAddr, &clientAddrLen); // accept the first incoming connection attempt in listenSock's backlog queue. return new socket with actual connection on success
cout << "client connected!\n";
클라이언트의 연결 요청을 허용하는 부분이다.
accept 함수는 서버 listen socket의 backlog queue에 들어있는 클라이언트의 연결 요청 중 첫번째 연결을 추출하고, 해당 클라이언트의 주소와 포트번호를 clientAddr에 저장하며, 이를 바탕으로 socket을 만들어 리턴한다. 해당 socket이 실제 연결을 처리하는 socket이다.
하나의 socket으로는 Listening과 연결 처리 모두를 담당할 수 없다. 즉, 서버는 Listening(수신 대기)을 담당하는 listenSock과 연결 처리를 담당하는 clientSock이 각각 필요하다.
// 7. data sending and receiving (Echo)
char buf[1024];
while (true) {
int recvLen = recv(clientSock, buf, sizeof(buf) - 1, 0); // receive data from socket. blocking(blocking socket + flag=0). return size of bytes received on success.
if (recvLen <= 0) break;
buf[recvLen] = '\0';
cout << "Received msg: " << buf << "\n";
send(clientSock, buf, recvLen, 0); // send data to client
}
recv 함수는 연결된 socket에서 데이터를 수신한다. 성공 시 수신한 데이터의 크기를 리턴한다.
이때 recv 함수가 0을 리턴하면 클라이언트가 연결을 정상 종료했음을 의미한다.
참고로 length의 경우 sizeof(buf) - 1만큼 가져오게 해서 buf[recvLen]에서의 오버플로우를 방지한다.
send 함수는 연결된 socket으로 데이터를 송신한다. 성공 시 송신한 데이터의 크기를 리턴한다.
socket은 blocking socket이고, flag=0으로 동작에 영향을 미치지 않으므로 수신 버퍼가 비어있을 때 recv를 호출하거나 송신 버퍼가 꽉 찬 경우에 send를 호출하면 blocking이 발생한다.
Blocking socket VS Non-blocking socket 및 버퍼 동작 원리
1. 기본 개념Blocking: 제어권이 해당 처리에 묶여서, 프로그램이 다음 줄로 넘어가지 못하고 대기 상태가 되는 것을 의미한다.Non-blocking: 프로그램이 대기 상태가 되지 않아 다른 작업을 동시에 할
study-programming-firecat.tistory.com
// 8. close connection
closesocket(clientSock); // close individual client socket
closesocket(listenSock); // close listening(at the end of total service)
WSACleanup(); // end usage of winsock2 dll
return 0;
closesocket(clientSock)은 개별 client socket을 닫는다.
listen socket은 전체 서비스가 닫힐 때에나 닫는다.
WSACleanup 함수는 winsock2 dll의 사용을 종료한다. 관련 모든 자원을 해제한다.
2. 클라이언트 측 주요 함수 분석
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
SOCKET sock = socket(AF_INET, SOCK_STREAM, 0); // IPv4, TCP
WSAStartup 함수를 통해 winsock dll을 실행하고, socket 함수를 통해 TCP socket을 생성한다.
sockaddr_in serverAddr = {};
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(9000);
inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr); // local address. convert IPv4 address into binary type
주소패밀리를 IPv4로 설정하고, htons 함수로 엔디안을 고려해서(리틀엔디안 -> 빅엔디안) 포트를 설정한다.
텍스트 기반의 IP주소를 저장할 때는, 이를 이진형태로 변환해주는 inet_pton 함수를 사용해야 한다.
inet_pton 함수는 IPv4와 IPv6를 모두 지원하며, 이를 사용하려면 ws2tcpip.h 헤더파일을 include해야 한다.
if (connect(sock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
cout << "연결 실패\n";
return 1;
}
connect 함수를 이용해 socket에 대한 연결을 설정한다. 오류가 없다면 0을, 오류가 있다면 SOCKET_ERROR를 리턴한다.
char msg[1024];
while (true) {
cout << "보낼 메시지 입력: ";
cin.getline(msg, 1024);
if (strcmp(msg, "exit") == 0) break; // send 'exit' to end
send(sock, msg, (int)strlen(msg), 0);
char buf[1024];
int recvLen = recv(sock, buf, sizeof(buf) - 1, 0);
buf[recvLen] = '\0';
cout << "서버로부터 응답: " << buf << "\n";
}
closesocket(sock);
WSACleanup();
return 0;
메시지를 보내고(send 함수), 받고(recv 함수), socket을 닫고(closesocket 함수), winsock2.dll 사용을 종료하는(WSACleanup 함수) 부분이다.
3. Deep Dive: TCP 3-Way handshake
| 단계 | 동작 | 소켓 함수와의 연관성 | 상태(State) | 참고 |
| SYN | 클라이언트->서버 연결 요청 | 클라이언트 connect() 호출 | SYN_SENT | SYN 패킷 전송 |
| SYN/ACK | 서버->클라이언트 응답 및 요청(클라이언트 연결 정보를 Incomplete | SYN_RCVD | 클라이언트 연결 정보를 Incomplete Connection Queue(SYN Queue)에 넣음 | |
| ACK | 클라이언트->서버 최종 응답 | connect() 리턴 | ESTABLISHED | 응답을 받은 서버는 클라이언트 연결 정보를 Completed Connection Queue(Backlog queue)로 옮김. |
참고로, 서버의 accept 호출은 그 이후의 일이다. 이때 헷갈리지 말아야 할 것은 accept는 socket을 생성하는 것이 아니라, 커널이 ESTABLISHED 상태가 된 연결을 토대로 이미 만들어둔 socket의 핸들을 Backlog queue에서 꺼내오는 것이라는 점이다.
4. 참고자료
- https://edu-coding.tistory.com/138
- https://learn.microsoft.com/ko-kr/windows/win32/api/winsock2/
https://drdbg.com/272
https://learn.microsoft.com/ko-kr/windows/win32/api/ws2def/ns-ws2def-sockaddr_in
https://stackoverflow.com/questions/24430564/meaning-of-flag-in-socket-send-and-recv#:~:text=if%20int%20flags%20are%20equal%20to%200%2C,flags%20%2D%20it%20is%20a%20mechanism%20that
https://learn.microsoft.com/ko-kr/windows/win32/api/ws2tcpip/nf-ws2tcpip-inet_pton
https://mindnet.tistory.com/entry/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-22%ED%8E%B8-TCP-3-WayHandshake-4-WayHandshake