TCP 서버-클라이언트
서버가 연결을 기다리고 클라이언트가 접속한 뒤, TCP 연결로 데이터를 주고받는 과정을 IPv4와 IPv6 예제로 읽습니다.
2-2장의 Winsock 초기화·오류 처리와 3장의 주소 구조체·바이트 순서를 먼저 확인하세요. 이 장의 다운로드는 Windows Winsock용 C++ 소스입니다.
학습목표
- TCP 서버-클라이언트의 기본 구조와 동작 원리를 이해합니다.
- TCP 응용프로그램 작성에 필요한 핵심 소켓 함수를 익힙니다.
- IPv4와 IPv6 기반 TCP 서버-클라이언트를 작성할 수 있습니다.
연결을 기다리는 소켓과 통신하는 소켓
웹 클라이언트가 요청하고 웹 서버가 응답하듯, TCP 프로그램은 먼저 연결을 만든 다음 데이터를 교환합니다. 이 장의 에코 서버는 받은 바이트를 그대로 되돌려 줍니다. HTTP 요청을 해석하는 웹 서버와는 응용 규약이 다릅니다.
서버
WSAStartup → socketbind → listenaccept → client_sockrecv → sendclosesocket(client_sock)accept …
클라이언트
WSAStartup → socketconnectfgets → sendrecv → printfclosesocket → WSACleanup
listen_sock은 새 연결을 기다리고, accept가 반환한 client_sock은 특정 클라이언트와 통신합니다. 연결 하나를 닫아도 listen_sock이 남아 있으면 다음 클라이언트를 받을 수 있습니다.
보충 · 제공된 서버는 바깥 반복문에서 accept하고 안쪽 반복문에서 한 클라이언트의 recv/send를 반복합니다. 첫 클라이언트가 끝나야 다음 accept로 갑니다. 두 번째 연결이 운영체제 대기 큐에 들어가더라도 이 코드가 두 클라이언트를 동시에 처리하는 것은 아닙니다.
통신 상대는 프로토콜과 양쪽 IP·포트로 구분합니다. 서버의 지역 포트는 9000이고 클라이언트의 지역 포트는 보통 운영체제가 고릅니다. 서버에서 보는 원격 주소가 클라이언트의 지역 주소입니다.
socket → bind → listen → accept
먼저 WSAStartup(MAKEWORD(2, 2), &wsa)로 Winsock을 준비합니다. 성공은 0이고 실패는 반환된 오류 코드로 판단합니다. 이 함수의 실패 코드는 WSAGetLastError가 아니라 반환값 자체로 확인합니다. 성공한 초기화에 대응해 마지막에 WSACleanup을 호출합니다.
| 함수 | 인수와 역할 | Winsock 반환값 |
|---|---|---|
| socket(af, type, protocol) | AF_INET은 IPv4, SOCK_STREAM은 바이트 스트림, protocol=0은 이 조합의 기본 TCP를 선택합니다. | 성공: SOCKET / 실패: INVALID_SOCKET |
| bind(sock, addr, addrlen) | 소켓에 지역 IP·포트를 연결합니다. 주소 구조체 포인터와 실제 sizeof를 전달합니다. | 성공: 0 / 실패: SOCKET_ERROR |
| listen(sock, backlog) | bind한 소켓을 연결 대기 상태로 바꿉니다. backlog는 아직 accept하지 않은 연결의 대기 큐와 관련된 값입니다. | 성공: 0 / 실패: SOCKET_ERROR |
| accept(sock, addr, addrlen) | 대기 소켓, 상대 주소를 쓸 공간, 그 공간 크기를 담은 변수의 주소를 받습니다. addrlen은 실제 주소 길이로 갱신됩니다. | 성공: 새 SOCKET / 실패: INVALID_SOCKET |
IPv4 서버는 sockaddr_in을 0으로 초기화한 뒤 sin_family=AF_INET, sin_port=htons(9000), sin_addr.s_addr=htonl(INADDR_ANY)를 넣습니다. INADDR_ANY는 이 컴퓨터의 사용 가능한 IPv4 인터페이스에서 접속을 받겠다는 뜻입니다. 클라이언트가 접속할 목적지 주소로 쓰는 값은 아닙니다.
각 accept 직전에 addrlen=sizeof(clientaddr)를 다시 넣습니다. 성공하면 clientaddr에서 상대 IP·포트를 읽고 inet_ntop과 ntohs로 표시합니다. 연결 요청이 없으면 기본 블로킹 accept는 기다리므로 화면에 아무것도 나오지 않을 수 있습니다.
예제는 listen의 backlog에 SOMAXCONN을 전달해 서비스 제공자가 정하는 합리적인 최대 대기 길이를 요청합니다. 이것은 동시에 처리할 클라이언트 수나 이미 연결된 소켓의 개수를 지정하는 값이 아닙니다.
원본 구분 · PDF 11쪽의 별도 bind 예시는 포트 8080을 사용하지만, 제공된 네 개의 전체 예제는 9000을 사용합니다. 원본의 POSIX int 소켓·실패 -1과 Winsock SOCKET·실패 INVALID_SOCKET을 섞지 않습니다. Winsock accept의 길이 인수는 int*이며 시스템 헤더 WS2tcpip.h가 int로 정의한 socklen_t를 사용합니다.
connect에 서버 주소를 전달하기
클라이언트도 WSAStartup과 socket을 먼저 호출합니다. sockaddr_in에 서버의 IP와 포트 9000을 채우고 connect(sock, (sockaddr *)&serveraddr, sizeof(serveraddr))를 호출합니다. 성공은 0, 실패는 SOCKET_ERROR입니다. 성공한 소켓으로 send와 recv를 호출합니다.
connect의 첫 인수는 클라이언트 소켓, 두 번째는 원격 서버 주소, 세 번째는 주소 구조체의 바이트 크기입니다. 명시적인 bind가 없는 이 클라이언트는 연결할 때 운영체제가 지역 IP와 임시 포트를 선택합니다. 서버가 클라이언트의 임시 포트에도 bind해야 하는 것은 아닙니다.
보충 · 연결 성공은 상대 응용프로그램이 데이터를 이미 읽었다는 뜻이 아닙니다. 제공된 inet_pton 호출은 반환값을 검사하지 않으므로 유효한 숫자 주소로 실행하세요. 새 코드에서는 변환 결과가 1인지 검사한 뒤 connect하고, 0인 잘못된 문자열과 SOCKET_ERROR인 오류를 구분해야 합니다.
바이트 수로 송수신하고 종료를 확인하기
원본은 TCP의 기본 함수 send/recv 외에 UDP에서 상대 주소를 지정·확인하는 sendto/recvfrom, 여러 버퍼·비동기 입출력을 위한 Windows WSASend 계열과 WSARecv 계열, Linux 소켓에도 쓸 수 있는 write/read를 함께 소개합니다. 아래 네 전체 예제는 Winsock의 send/recv를 사용합니다.
| 호출 | 인수 | 반환값과 처리 |
|---|---|---|
| send(sock, buf, len, flags) | 연결 소켓, 보낼 메모리 주소, 바이트 수, 옵션입니다. 예제의 flags는 0입니다. | 실제 보낸 바이트 수 / SOCKET_ERROR. 요청 길이보다 작을 수 있으므로 남은 부분을 반복 전송해야 합니다. |
| recv(sock, buf, len, flags) | 연결 소켓, 받을 메모리 주소, 최대 바이트 수, 옵션입니다. 서버의 최대 수신 크기는 512입니다. | 양수: 받은 바이트 / 0: 상대의 정상 송신 종료 / SOCKET_ERROR: 실패. 반환값을 먼저 검사합니다. |
| closesocket(sock) | 개별 SOCKET을 닫습니다. 수신 버퍼 메모리를 해제하는 함수가 아닙니다. | 성공: 0 / 실패: SOCKET_ERROR. Winsock에는 POSIX close 대신 사용합니다. |
서버의 buf는 513바이트입니다. 최대 512바이트를 받은 뒤 buf[retval]='\0'으로 문자열 끝을 표시할 한 칸을 남깁니다. 이 NUL은 출력용으로 덧붙이며 서버는 받은 retval바이트만 돌려보냅니다.
보충 · TCP는 메시지 경계를 보존하지 않는 바이트 스트림입니다. 한 send의 내용이 여러 recv로 나뉘거나 여러 send의 내용이 한 recv로 합쳐질 수 있습니다. 길이 헤더·구분자·고정 길이처럼 응용 규약으로 끝을 정해야 합니다.
제공된 클라이언트는 send가 반환한 바이트 수만큼 MSG_WAITALL로 수신합니다. 이 옵션은 요청 길이를 기다리지만 종료·오류·취소 등에 의해 짧게 끝날 수 있으며 메시지 경계를 만들지 않습니다. 서버의 send도 전체 길이를 보장하지 않으므로 이 예제는 임의 길이 데이터의 완전한 전송을 보장하는 구현이 아닙니다.
보충 · 클라이언트는 fgets로 한 줄을 읽고 끝의 개행을 제거합니다. 512바이트를 넘는 줄은 다음 입력에 나뉘어 남을 수 있습니다. 빈 줄이나 표준입력 EOF에서 클라이언트가 소켓을 닫으면 서버 recv가 0을 반환하고, 서버는 client_sock을 닫은 뒤 다시 accept합니다.
오류 처리 · socket과 accept는 INVALID_SOCKET, 나머지 소켓 호출은 SOCKET_ERROR를 검사합니다. Common2.h의 err_display는 WSAGetLastError를 출력하고 돌아오며, err_quit은 출력 후 exit(1)로 끝냅니다. err_quit 경로는 명시적인 closesocket·WSACleanup에 도달하지 않습니다. 자원 정리를 항상 수행하는 예제로 이해하지 않습니다.
서버에서 accept 오류로 바깥 반복문을 벗어나면 closesocket(listen_sock) 뒤 WSACleanup을 호출합니다. WSACleanup은 인수가 없고 성공 시 0, 실패 시 SOCKET_ERROR를 반환합니다. 제공 소스는 닫기·정리의 반환값은 검사하지 않습니다. Ctrl+C는 프로세스를 끝내므로 이 명시적인 정리 경로가 실행된다고 보장할 수 없습니다.
원본 구분 · PDF 17쪽은 연결 완료를 가정한 발췌로, 서버가 Hello Client를 응답합니다. 아래 전체 예제는 받은 데이터를 그대로 돌려주는 에코이므로 Hello를 보내면 Hello가 돌아옵니다.
주소 계열을 IPv6로 맞추기
원본 규칙 · 필요하면 새 헤더를 포함하고, AF_INET을 AF_INET6로, sockaddr_in을 sockaddr_in6로 바꿉니다. send와 recv는 그대로 사용합니다. 이 규칙으로 바꾼 코드는 IPv6만 지원합니다.
| 항목 | IPv4 | IPv6 |
|---|---|---|
| 소켓 계열 | AF_INET | AF_INET6 |
| 주소 구조체 | sockaddr_in · sin_family/addr/port | sockaddr_in6 · sin6_family/addr/port |
| 서버 대기 주소 | htonl(INADDR_ANY) | in6addr_any |
| 클라이언트 루프백 | 127.0.0.1 | ::1 |
| 문자열 변환·출력 버퍼 | AF_INET · INET_ADDRSTRLEN | AF_INET6 · INET6_ADDRSTRLEN |
| 포트·송수신 | htons(9000) · send/recv | htons(9000) · send/recv |
TCPServer6.cpp와 TCPClient6.cpp도 동일한 연결·에코 흐름을 사용합니다. 주소 계열, 구조체와 필드, inet_pton/inet_ntop의 계열을 함께 바꿉니다. IPv6 주소 전체를 htonl에 넣지 않고, 포트에는 여전히 htons를 사용합니다.
보충 · 표의 서버 대기 주소·클라이언트 루프백·주소 문자열 출력 버퍼·포트 값은 제공 예제 기준입니다. 원본 표의 주소 계열·주소 구조체·주소 변환 함수·send/recv 규칙에 실제 예제 설정을 덧붙였습니다.
보충 · 제공된 Common2.h는 Windows의 WinSock2.h와 WS2tcpip.h를 포함하므로 IPv6 주소와 변환 함수 선언을 사용할 수 있습니다. Windows Vista 이후 AF_INET6 소켓의 IPV6_V6ONLY 기본값은 0이 아니므로 IPv4 연결을 받지 않습니다. 이중 스택을 사용하려면 bind 전에 setsockopt로 이 옵션을 0으로 설정해야 합니다. 제공 예제는 이 옵션을 바꾸지 않습니다.
Microsoft Learn · Dual-stack sockets
이 실습에서는 IPv6 클라이언트를 ::1에 연결하며 IPv4·IPv6 서버를 한 번에 하나씩 실행합니다.
서버부터 실행하고 에코 확인하기
Windows PowerShell에서 4장 폴더를 작업 위치로 사용합니다. 아래 명령은 이미 설치된 MinGW-w64 g++가 PATH에 있을 때의 C++11 빌드입니다. -lws2_32는 Winsock 라이브러리를 링크합니다. 네 파일은 각각 main이 있으므로 하나씩 별도 실행 파일로 만듭니다.
Dev-C++가 기본 사용자 경로에 설치되어 있고 g++가 PATH에 없다면, 현재 PowerShell에서 아래 첫 줄로 이 터미널의 PATH에 컴파일러 폴더를 추가하세요.
$env:Path = "$env:LOCALAPPDATA\Programs\Dev-Cpp\MinGW64\bin;$env:Path"
g++ -std=c++11 -D_WIN32_WINNT=0x0600 -Wall -Wextra -finput-charset=UTF-8 -fexec-charset=UTF-8 examples/TCPServer.cpp -o TCPServer.exe -lws2_32
g++ -std=c++11 -D_WIN32_WINNT=0x0600 -Wall -Wextra -finput-charset=UTF-8 -fexec-charset=UTF-8 examples/TCPClient.cpp -o TCPClient.exe -lws2_32
g++ -std=c++11 -D_WIN32_WINNT=0x0600 -Wall -Wextra -finput-charset=UTF-8 -fexec-charset=UTF-8 examples/TCPServer6.cpp -o TCPServer6.exe -lws2_32
g++ -std=c++11 -D_WIN32_WINNT=0x0600 -Wall -Wextra -finput-charset=UTF-8 -fexec-charset=UTF-8 examples/TCPClient6.cpp -o TCPClient6.exe -lws2_32-D_WIN32_WINNT=0x0600은 Windows Vista 이상 API 선언을 요청합니다. 오래된 MinGW 헤더에서 inet_pton과 inet_ntop이 선언되지 않는 오류를 피하기 위한 빌드 설정이며 운영체제를 바꾸는 명령은 아닙니다.
보충 · Visual Studio Developer PowerShell에서도 cl /nologo /EHsc /std:c++14 /utf-8 examples/TCPServer.cpp /Fe:TCPServer.exe ws2_32.lib처럼 각 소스를 별도로 빌드할 수 있습니다. Common2.h의 #pragma warning은 MSVC용이며 GCC에서는 알 수 없는 pragma 경고가 날 수 있습니다. 서버 두 개에는 사용하지 않는 argc/argv 경고도 날 수 있습니다. Linux/WSL용 소스로 이름만 바꾸어 빌드할 수는 없습니다.
- 터미널 A에서 .\TCPServer.exe를 실행합니다. 접속 전에는 아무 출력 없이 accept에서 기다릴 수 있습니다.
- 터미널 B에서 .\TCPClient.exe 127.0.0.1을 실행합니다. 기본 포트는 두 프로그램 모두 9000입니다.
- Hello를 입력하고 Enter를 누릅니다. 보낸 길이·받은 길이 5와 돌아온 Hello를 확인합니다.
- 빈 줄에서 Enter를 눌러 클라이언트를 종료합니다. 서버는 접속 종료를 출력하고 다음 연결을 기다립니다. 서버 터미널에서 Ctrl+C로 종료합니다.
- IPv4 서버를 끝낸 뒤 터미널 A에서 .\TCPServer6.exe, B에서 .\TCPClient6.exe ::1을 실행해 같은 입력을 확인합니다.
예상 출력 · IPv4 클라이언트
[보낼 데이터] Hello [TCP 클라이언트] 5바이트를 보냈습니다. [TCP 클라이언트] 5바이트를 받았습니다. [받은 데이터] Hello
서버에는 클라이언트 접속 IP 127.0.0.1과 임시 포트, 수신한 Hello, 클라이언트 종료가 표시됩니다. 임시 포트 값은 실행마다 달라집니다. IPv6에서는 접속 IP가 ::1입니다. 한글 입력의 바이트 수는 글자 수와 다르므로 첫 확인에는 ASCII를 사용하세요.
netstat로 연결 상태 읽기
netstat -a -n -p tcp | findstr 9000IPv6 연결만 조회하려면 프로토콜 이름을 tcpv6로 바꿔 netstat -a -n -p tcpv6 | findstr 9000을 실행합니다.
연결 전에는 서버 포트 9000의 LISTENING, 연결 뒤에는 양쪽 끝점의 ESTABLISHED를 볼 수 있습니다. 클라이언트 종료 뒤 TIME_WAIT가 남아도 서버의 대기 소켓과는 별개입니다. 원본은 5분 뒤 다시 netstat를 실행하면 연결 대기만 남은 2단계 상태로 돌아온다고 설명합니다. TIME_WAIT의 실제 지속 시간과 행 개수는 운영체제·종료 순서·관찰 시점에 따라 달라집니다.
| 증상 | 확인할 조건 |
|---|---|
| bind 실패 · 10048 | 9000번을 다른 프로세스가 쓰는지 netstat로 확인합니다. 기존 서버를 종료하고 다시 실행합니다. |
| connect 실패 · 10061 | 서버가 먼저 실행되어 있는지, 주소 계열·IP·포트가 일치하는지 확인합니다. |
| connect 실패 · 시간 초과 | 다른 컴퓨터 주소라면 경로·서버 대기 주소·방화벽을 확인합니다. 먼저 루프백으로 확인합니다. |
| recv에서 대기 | 서버·클라이언트 모두 수신만 기다리거나 MSG_WAITALL이 요구한 길이가 오지 않으면 대기합니다. 송신 순서와 실제 길이를 확인합니다. |
| IPv6 접속 실패 | IPv6 스택과 ::1 사용 가능 여부, TCPServer6 실행 여부를 확인합니다. IPv4 서버만으로 IPv6 실습 성공을 판단하지 않습니다. |
전체 예제와 공통 헤더
예제의 #include "../Common2.h"를 유지하려면 examples 폴더의 한 단계 위에 Common2.h를 둡니다. 아래 파일을 받을 때 4장/Common2.h와 4장/examples/*.cpp 구조를 유지하세요. 헤더는 오류 출력 함수를 정의하므로 각 예제를 별도 실행 파일로 빌드합니다.
Common2.h
#ifndef __COMMON2_H__
#define __COMMON2_H__
#pragma warning (disable : 4996)
#include <sys/types.h> // basic type definitions
#include <fcntl.h> // fcntl(), ...
//#include <pthread.h> // pthread_create(), ...
//#include <poll.h> // poll()
//#include <sys/epoll.h> // epoll()
//#include <sys/socket.h> // socket(), AF_INET, ...
//#include <arpa/inet.h> // htons(), htonl(), ...
//#include <netdb.h> // gethostbyname(), ...
//#include <unistd.h> // close(), ...
#include <stdio.h> // printf(), ...
#include <stdlib.h> // exit(), ...
#include <string.h> // strerror(), ...
#include <errno.h> // errno
#include <WinSock2.h> // socket(), SOCKET, INVALID_SOCKET, SOCKET_ERROR, ...
#include <WS2tcpip.h> // inet_pton(), inet_ntop(), socklen_t, ...
// SOCKET_ERROR, INVALID_SOCKET은 WinSock2.h가 이미 정의하므로 다시 정의하지 않는다.
// (SOCKET은 UINT_PTR이고, INVALID_SOCKET은 (SOCKET)(~0)이다.)
// Winsock 오류 코드를 시스템 메시지로 바꿔 출력
// (Winsock은 errno를 설정하지 않으므로 WSAGetLastError()의 값을 사용한다.)
static void print_wsa_error(const char *msg, int errcode)
{
wchar_t *wbuf = NULL;
FormatMessageW(
FORMAT_MESSAGE_ALLOCATE_BUFFER |
FORMAT_MESSAGE_FROM_SYSTEM |
FORMAT_MESSAGE_IGNORE_INSERTS,
NULL, errcode,
MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
(wchar_t *)&wbuf, 0, NULL);
// /utf-8로 컴파일하므로 콘솔 출력도 UTF-8로 맞춘다.
char msgbuf[512] = "알 수 없는 오류";
if (wbuf != NULL) {
WideCharToMultiByte(CP_UTF8, 0, wbuf, -1, msgbuf, sizeof(msgbuf), NULL, NULL);
LocalFree(wbuf);
}
// 시스템 메시지 끝의 "\r\n" 제거
size_t len = strlen(msgbuf);
while (len > 0 && (msgbuf[len - 1] == '\r' || msgbuf[len - 1] == '\n'))
msgbuf[--len] = '\0';
printf("[%s] (%d) %s\n", msg, errcode, msgbuf);
}
// 소켓 함수 오류 출력 후 종료
void err_quit(const char *msg)
{
print_wsa_error(msg, WSAGetLastError());
exit(1);
}
// 소켓 함수 오류 출력
void err_display(const char *msg)
{
print_wsa_error(msg, WSAGetLastError());
}
// 소켓 함수 오류 출력
void err_display(int errcode)
{
print_wsa_error("오류", errcode);
}
#endif // !__COMMON2_H__
TCPServer.cpp
#include "../Common2.h"
#define SERVERPORT 9000
#define BUFSIZE 512
int main(int argc, char *argv[])
{
int retval;
// 콘솔 입출력을 UTF-8로 설정 (/utf-8로 컴파일한 한글 출력용)
SetConsoleOutputCP(CP_UTF8);
SetConsoleCP(CP_UTF8);
// 윈속 초기화
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
return 1;
// 소켓 생성
SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock == INVALID_SOCKET) err_quit("socket()");
// bind()
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons(SERVERPORT);
retval = bind(listen_sock, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (retval == SOCKET_ERROR) err_quit("bind()");
// listen()
retval = listen(listen_sock, SOMAXCONN);
if (retval == SOCKET_ERROR) err_quit("listen()");
// 데이터 통신에 사용할 변수
SOCKET client_sock;
struct sockaddr_in clientaddr;
socklen_t addrlen;
char buf[BUFSIZE + 1];
while (1) {
// accept()
addrlen = sizeof(clientaddr);
client_sock = accept(listen_sock, (struct sockaddr *)&clientaddr, &addrlen);
if (client_sock == INVALID_SOCKET) {
err_display("accept()");
break;
}
// 접속한 클라이언트 정보 출력
char addr[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));
printf("\n[TCP 서버] 클라이언트 접속: IP 주소=%s, 포트 번호=%d\n",
addr, ntohs(clientaddr.sin_port));
// 클라이언트와 데이터 통신
while (1) {
// 데이터 받기
retval = recv(client_sock, buf, BUFSIZE, 0);
if (retval == SOCKET_ERROR) {
err_display("recv()");
break;
}
else if (retval == 0)
break;
// 받은 데이터 출력
buf[retval] = '\0';
printf("[TCP/%s:%d] %s\n", addr, ntohs(clientaddr.sin_port), buf);
// 데이터 보내기
retval = send(client_sock, buf, retval, 0);
if (retval == SOCKET_ERROR) {
err_display("send()");
break;
}
}
// 소켓 닫기
closesocket(client_sock);
printf("[TCP 서버] 클라이언트 종료: IP 주소=%s, 포트 번호=%d\n",
addr, ntohs(clientaddr.sin_port));
}
// 소켓 닫기
closesocket(listen_sock);
// 윈속 종료
WSACleanup();
return 0;
}
TCPClient.cpp
#include "../Common2.h"
char *SERVERIP = (char *)"127.0.0.1";
#define SERVERPORT 9000
#define BUFSIZE 512
int main(int argc, char *argv[])
{
int retval;
// 명령행 인수가 있으면 IP 주소로 사용
if (argc > 1) SERVERIP = argv[1];
// 콘솔 입출력을 UTF-8로 설정 (/utf-8로 컴파일한 한글 출력용)
SetConsoleOutputCP(CP_UTF8);
SetConsoleCP(CP_UTF8);
// 윈속 초기화
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
return 1;
// 소켓 생성
SOCKET sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == INVALID_SOCKET) err_quit("socket()");
// connect()
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET, SERVERIP, &serveraddr.sin_addr);
serveraddr.sin_port = htons(SERVERPORT);
retval = connect(sock, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (retval == SOCKET_ERROR) err_quit("connect()");
// 데이터 통신에 사용할 변수
char buf[BUFSIZE + 1];
int len;
// 서버와 데이터 통신
while (1) {
// 데이터 입력
printf("\n[보낼 데이터] ");
if (fgets(buf, BUFSIZE + 1, stdin) == NULL)
break;
// '\n' 문자 제거
len = (int)strlen(buf);
if (buf[len - 1] == '\n')
buf[len - 1] = '\0';
if (strlen(buf) == 0)
break;
// 데이터 보내기
retval = send(sock, buf, (int)strlen(buf), 0);
if (retval == SOCKET_ERROR) {
err_display("send()");
break;
}
printf("[TCP 클라이언트] %d바이트를 보냈습니다.\n", retval);
// 데이터 받기
retval = recv(sock, buf, retval, MSG_WAITALL);
if (retval == SOCKET_ERROR) {
err_display("recv()");
break;
}
else if (retval == 0)
break;
// 받은 데이터 출력
buf[retval] = '\0';
printf("[TCP 클라이언트] %d바이트를 받았습니다.\n", retval);
printf("[받은 데이터] %s\n", buf);
}
// 소켓 닫기
closesocket(sock);
// 윈속 종료
WSACleanup();
return 0;
}
TCPServer6.cpp
#include "../Common2.h"
#define SERVERPORT 9000
#define BUFSIZE 512
int main(int argc, char *argv[])
{
int retval;
// 콘솔 입출력을 UTF-8로 설정 (/utf-8로 컴파일한 한글 출력용)
SetConsoleOutputCP(CP_UTF8);
SetConsoleCP(CP_UTF8);
// 윈속 초기화
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
return 1;
// 소켓 생성
SOCKET listen_sock = socket(AF_INET6, SOCK_STREAM, 0);
if (listen_sock == INVALID_SOCKET) err_quit("socket()");
// bind()
struct sockaddr_in6 serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin6_family = AF_INET6;
serveraddr.sin6_addr = in6addr_any;
serveraddr.sin6_port = htons(SERVERPORT);
retval = bind(listen_sock, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (retval == SOCKET_ERROR) err_quit("bind()");
// listen()
retval = listen(listen_sock, SOMAXCONN);
if (retval == SOCKET_ERROR) err_quit("listen()");
// 데이터 통신에 사용할 변수
SOCKET client_sock;
struct sockaddr_in6 clientaddr;
socklen_t addrlen;
char buf[BUFSIZE + 1];
while (1) {
// accept()
addrlen = sizeof(clientaddr);
client_sock = accept(listen_sock, (struct sockaddr *)&clientaddr, &addrlen);
if (client_sock == INVALID_SOCKET) {
err_display("accept()");
break;
}
// 접속한 클라이언트 정보 출력
char addr[INET6_ADDRSTRLEN];
inet_ntop(AF_INET6, &clientaddr.sin6_addr, addr, sizeof(addr));
printf("\n[TCP 서버] 클라이언트 접속: IP 주소=%s, 포트 번호=%d\n",
addr, ntohs(clientaddr.sin6_port));
// 클라이언트와 데이터 통신
while (1) {
// 데이터 받기
retval = recv(client_sock, buf, BUFSIZE, 0);
if (retval == SOCKET_ERROR) {
err_display("recv()");
break;
}
else if (retval == 0)
break;
// 받은 데이터 출력
buf[retval] = '\0';
printf("[TCP/%s:%d] %s\n", addr, ntohs(clientaddr.sin6_port), buf);
// 데이터 보내기
retval = send(client_sock, buf, retval, 0);
if (retval == SOCKET_ERROR) {
err_display("send()");
break;
}
}
// 소켓 닫기
closesocket(client_sock);
printf("[TCP 서버] 클라이언트 종료: IP 주소=%s, 포트 번호=%d\n",
addr, ntohs(clientaddr.sin6_port));
}
// 소켓 닫기
closesocket(listen_sock);
// 윈속 종료
WSACleanup();
return 0;
}
TCPClient6.cpp
#include "../Common2.h"
char *SERVERIP = (char *)"::1";
#define SERVERPORT 9000
#define BUFSIZE 512
int main(int argc, char *argv[])
{
int retval;
// 명령행 인수가 있으면 IP 주소로 사용
if (argc > 1) SERVERIP = argv[1];
// 콘솔 입출력을 UTF-8로 설정 (/utf-8로 컴파일한 한글 출력용)
SetConsoleOutputCP(CP_UTF8);
SetConsoleCP(CP_UTF8);
// 윈속 초기화
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
return 1;
// 소켓 생성
SOCKET sock = socket(AF_INET6, SOCK_STREAM, 0);
if (sock == INVALID_SOCKET) err_quit("socket()");
// connect()
struct sockaddr_in6 serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin6_family = AF_INET6;
inet_pton(AF_INET6, SERVERIP, &serveraddr.sin6_addr);
serveraddr.sin6_port = htons(SERVERPORT);
retval = connect(sock, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (retval == SOCKET_ERROR) err_quit("connect()");
// 데이터 통신에 사용할 변수
char buf[BUFSIZE + 1];
int len;
// 서버와 데이터 통신
while (1) {
// 데이터 입력
printf("\n[보낼 데이터] ");
if (fgets(buf, BUFSIZE + 1, stdin) == NULL)
break;
// '\n' 문자 제거
len = (int)strlen(buf);
if (buf[len - 1] == '\n')
buf[len - 1] = '\0';
if (strlen(buf) == 0)
break;
// 데이터 보내기
retval = send(sock, buf, (int)strlen(buf), 0);
if (retval == SOCKET_ERROR) {
err_display("send()");
break;
}
printf("[TCP 클라이언트] %d바이트를 보냈습니다.\n", retval);
// 데이터 받기
retval = recv(sock, buf, retval, MSG_WAITALL);
if (retval == SOCKET_ERROR) {
err_display("recv()");
break;
}
else if (retval == 0)
break;
// 받은 데이터 출력
buf[retval] = '\0';
printf("[TCP 클라이언트] %d바이트를 받았습니다.\n", retval);
printf("[받은 데이터] %s\n", buf);
}
// 소켓 닫기
closesocket(sock);
// 윈속 종료
WSACleanup();
return 0;
}
이해도 체크
accept가 반환한 소켓과 listen_sock은 왜 둘 다 필요한가요?
대기 소켓은 다음 접속을 받고, 반환된 소켓은 연결 하나의 송수신을 맡습니다. 한 클라이언트를 닫아도 다음 연결을 받을 수 있도록 역할을 분리합니다.
클라이언트에는 왜 bind가 없나요?
이 예제는 운영체제가 connect 때 지역 주소와 임시 포트를 선택합니다. connect에 전달하는 것은 원격 서버의 주소입니다.
send가 5를 반환하면 상대가 5바이트를 읽었다는 뜻인가요?
아닙니다. 송신 측이 처리한 바이트 수이며 상대 응용프로그램의 수신 완료를 보장하지 않습니다.
recv가 0을 반환하면 빈 문자열 메시지를 받은 것인가요?
양수 길이로 요청한 이 TCP 예제에서는 상대가 정상적으로 송신을 끝냈다는 뜻입니다. 버퍼를 메시지로 출력하지 않고 수신 반복을 끝냅니다.
IPv6로 바꿀 때 send와 recv도 바꾸나요?
아닙니다. 주소 계열·구조체·필드·주소 변환 인수를 맞추고 send/recv는 그대로 사용합니다.
첫 클라이언트가 연결을 유지하면 두 번째 클라이언트를 이 서버가 바로 처리하나요?
아닙니다. 현재 연결의 안쪽 반복문을 빠져나와야 다음 accept를 실행합니다. 연결 대기 큐와 동시 응용 처리는 다릅니다.
출처와 원본 범위
ch04_TCP 서버-클라이언트1.pdf
PDF 한 쪽에는 슬라이드 두 장이 있습니다. PDF 쪽은 파일 순서이며 아래 슬라이드 번호는 화면 아래에 인쇄된 번호입니다. 표지·목차·구분·마지막 인사 화면에는 번호가 인쇄되어 있지 않습니다.
| PDF 쪽 | 인쇄 슬라이드 | 내용과 본문 연결 |
|---|---|---|
| 1–2 | 2 / 나머지 번호 없음 | 표지·목표·목차·구조 구분 → 학습 순서 |
| 3–5 | 5–10 | 웹 요청, 연결 대기·통신, 에코 흐름 → TCP 통신 흐름 |
| 6–8 | 11–16 | 실행·netstat·빈 줄 종료·TIME_WAIT → 함께 실행 |
| 9 | 18 / 구분 화면 번호 없음 | 지역·원격 주소 → TCP 통신 흐름 |
| 10–13 | 19–26 | socket/bind/listen/accept → 서버·클라이언트 |
| 14 | 27–28 | connect → 클라이언트 연결 |
| 15–17 | 29–34 | 송수신 API·버퍼·연결 가정 코드 → 송수신과 종료 |
| 18–19 | 36–37 / 구분·인사 번호 없음 | IPv6 변환 표·코드 비교 → IPv6로 바꾸기 |
보충 범위: Winsock의 반환값·부분 송수신·MSG_WAITALL의 한계, 제공 소스의 순차 처리·입력·오류 종료 동작, Windows 빌드·루프백 실행 안내와 이해도 질문입니다. 제공된 전체 소스는 PDF 17쪽의 짧은 예제와 구분합니다.