소켓 프로그래밍을 배우면서 가장 기본이 되는 Echo 서버를 만들어보았습니다. 단순한 버전부터 시작해서 실전에서 쓸 수 있는 멀티클라이언트 서버까지 단계적으로 발전시켜보겠습니다.
Echo 서버란?
Echo 서버는 클라이언트가 보낸 메시지를 그대로 돌려보내는 서버입니다. 네트워킹의 "Hello World" 같은 존재로, TCP/IP 통신의 기본 원리를 이해하기 좋은 예제입니다.
클라이언트: "안녕하세요"
서버: "안녕하세요" (그대로 돌려보냄)
1단계: 기본 Echo 서버
첫 번째 버전은 한 번에 하나의 클라이언트만 처리할 수 있는 기본적인 서버입니다.
핵심 개념들
소켓 생성과 바인딩
int server_fd = socket(AF_INET, SOCK_STREAM, 0); // TCP 소켓
bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
listen(server_fd, 128); // 연결 대기 큐 크기
클라이언트 연결 처리
while (1) {
int client_fd = accept(server_fd, ...); // 새 연결 수락
// Echo 루프
while ((bytes = recv(client_fd, buffer, ...)) > 0) {
send(client_fd, buffer, bytes, 0); // 받은 데이터 그대로 전송
}
close(client_fd);
}
첫 번째 개선: 안정성 강화
기본 버전에서 발견한 문제점들을 하나씩 해결했습니다:
1. SIGPIPE 처리
signal(SIGPIPE, SIG_IGN); // 클라이언트 연결 해제시 프로세스 종료 방지
2. send() 부분 전송 문제
static int send_all(int fd, const char *buf, size_t len) {
while (len) {
ssize_t n = send(fd, buf, len, 0);
if (n < 0) return -1;
buf += n;
len -= n; // 남은 데이터만큼 다시 전송
}
return 0;
}
3. 에러 처리 개선
if (errno == EINTR) continue; // 시그널 인터럽트시 재시도
4. accept() 매번 초기화
socklen_t client_len = sizeof(client_addr); // 루프마다 초기화 필수
2단계: 멀티클라이언트 서버 (select)
기본 서버의 가장 큰 문제는 한 번에 하나의 클라이언트만 처리한다는 점이었습니다. A 클라이언트가 연결 중이면 B는 아예 접속도 못하는 상황이죠.
I/O 멀티플렉싱: select() 활용
select()의 작동 원리:
fd_set read_fds, master_fds;
FD_SET(server_fd, &master_fds); // 서버 소켓 등록
FD_SET(client_fd, &master_fds); // 클라이언트 소켓들 등록
select(max_fd + 1, &read_fds, NULL, NULL, &timeout); // 준비된 소켓 대기
if (FD_ISSET(server_fd, &read_fds)) {
// 새 연결 요청 처리
}
if (FD_ISSET(client_fd, &read_fds)) {
// 기존 클라이언트 데이터 처리
}
클라이언트 관리 구조체
typedef struct {
int fd;
struct sockaddr_in addr;
char ip[INET_ADDRSTRLEN];
} client_t;
client_t clients[MAX_CLIENTS]; // 클라이언트 배열
동시 처리 로직
while (1) {
select(...); // 모든 소켓 동시 감시
// 1. 새 연결 처리
if (FD_ISSET(server_fd, &read_fds)) {
// accept & 빈 슬롯 찾아서 등록
}
// 2. 기존 클라이언트들 데이터 처리
for (int i = 0; i < MAX_CLIENTS; i++) {
if (clients[i].fd != -1 && FD_ISSET(clients[i].fd, &read_fds)) {
// Echo 처리 또는 연결 해제
}
}
}
핵심 개선 포인트들
1. 이식성 고려한 SIGPIPE 처리
// 처음엔 이중 처리 (불필요)
signal(SIGPIPE, SIG_IGN);
send(fd, buf, len, MSG_NOSIGNAL); // Linux 전용
// 개선: 하나만 사용 (이식성 ↑)
signal(SIGPIPE, SIG_IGN); // 모든 OS에서 동작
send(fd, buf, len, 0);
2. accept4 + CLOEXEC (보안 강화)
#ifdef __linux__
int new_client = accept4(server_fd, addr, len, SOCK_CLOEXEC);
#else
int new_client = accept(server_fd, addr, len);
fcntl(new_client, F_SETFD, FD_CLOEXEC);
#endif
3. 타임아웃과 Keepalive (세밀한 튜닝)
struct timeval timeout = { .tv_sec = 60 };
select(..., &timeout); // 60초마다 상태 체크
// 기본 Keepalive
int ka = 1;
setsockopt(client_fd, SOL_SOCKET, SO_KEEPALIVE, &ka, sizeof(ka));
#ifdef __linux__
// Linux 전용 세밀한 Keep-Alive 설정
int idle = 60, intvl = 10, cnt = 5; // 60초 유휴 → 10초마다 5번 체크
setsockopt(client_fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
setsockopt(client_fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl));
setsockopt(client_fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));
#endif
4. 즉시 출력으로 디버깅 향상
setvbuf(stdout, NULL, _IONBF, 0); // 버퍼링 없이 즉시 출력
보안과 안정성 강화
실전에서 사용하려면 악의적인 클라이언트나 네트워크 문제에 대비해야 합니다. 다음과 같은 방어 코드들을 추가했습니다.
1. FD_SETSIZE 가드
if (new_client >= FD_SETSIZE) {
printf("[!] fd %d >= FD_SETSIZE(%d) - 연결 거부\n", new_client, FD_SETSIZE);
close(new_client);
continue;
}
select()에서 FD_SET 사용시 fd가 FD_SETSIZE(보통 1024)를 초과하면 Undefined Behavior가 발생할 수 있습니다. 이를 미리 체크해서 안전하게 거부합니다.
2. Send 타임아웃 (느린 클라이언트 방어)
struct timeval snd_to = { .tv_sec = 2, .tv_usec = 0 };
setsockopt(new_client, SOL_SOCKET, SO_SNDTIMEO, &snd_to, sizeof(snd_to));
네트워크가 느리거나 악의적으로 수신을 지연시키는 클라이언트 때문에 send_all()이 오래 블로킹되는 것을 방지합니다. 2초 안에 전송이 안되면 해당 클라이언트를 끊어버립니다.
3. Recv 타임아웃 (유령 클라이언트 정리)
struct timeval rcv_to = { .tv_sec = 120, .tv_usec = 0 };
setsockopt(new_client, SOL_SOCKET, SO_RCVTIMEO, &rcv_to, sizeof(rcv_to));
연결만 하고 데이터는 전혀 보내지 않는 "유령 클라이언트"들을 120초 후에 자동으로 정리합니다. 서버 리소스 낭비를 방지할 수 있습니다.
4. 이식성 개선
#include <sys/time.h> // struct timeval (BSD/macOS 호환)
#ifdef __linux__
#include <netinet/tcp.h> // TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT
#endif
Linux와 BSD/macOS 간의 헤더 차이를 고려한 이식성 있는 코드로 작성했습니다.
성능과 확장성 고려사항
select() 한계점
- FD_SETSIZE 제한: 기본 1024개 파일 디스크립터 (가드 코드로 안전하게 처리)
- O(n) 복잡도: 모든 fd를 순회해야 함
- send_all 블로킹: 느린 클라이언트가 전체 서버를 지연시킬 수 있음 (타임아웃으로 완화)
실전 운영 시 고려사항
악성 클라이언트 대응
- Slowloris 공격: Send 타임아웃으로 방어
- Connection 고갈: FD_SETSIZE 가드 + 최대 클라이언트 수 제한
- 유령 연결: Recv 타임아웃으로 정리
리소스 관리
// 현재 구현된 방어 메커니즘들
SO_KEEPALIVE // 죽은 연결 감지
TCP_KEEPIDLE // Linux: 60초 유휴 후 체크 시작
TCP_KEEPINTVL // Linux: 10초 간격으로 체크
TCP_KEEPCNT // Linux: 5번 실패시 연결 끊기
SO_SNDTIMEO // 전송 지연 방지 (2초)
SO_RCVTIMEO // 유령 클라 정리 (120초)
FD_SETSIZE 가드 // 메모리 오버플로우 방지
SOCK_CLOEXEC // 자식 프로세스 fd 상속 방지
에러 처리 강화
// setsockopt 에러도 적절히 로깅
if (setsockopt(...) < 0) perror("SO_KEEPALIVE");
// EINTR 시그널 인터럽트 처리
if (errno == EINTR) continue;
다음 단계 발전 방향
1. 논블로킹 + 출력 큐
// 클라이언트별 출력 버퍼 관리
typedef struct {
int fd;
char outbuf[OUTBUF_SIZE];
size_t out_len, out_off;
} client_t;
// writefds도 select에 등록하여 단계적 전송
select(max_fd + 1, &readfds, &writefds, NULL, &timeout);
2. epoll (Linux) / kqueue (BSD)
// 더 효율적인 이벤트 기반 처리
epoll_wait(epfd, events, MAX_EVENTS, timeout);
실전 팁들
개발 워크플로우:
# 1. 개발 중에는 debug 모드
make debug && make run
# 2. 메모리 오류 의심시 asan 모드
make asan && make run
# 3. 성능 테스트는 기본 모드
make && make run
# 4. 다중 클라이언트 테스트
make run-multi &
for i in {1..5}; do nc localhost 8080 & done
디버깅과 모니터링
# 패킷 캡처로 확인
tcpdump -i lo -nn port 8080
# 연결 상태 확인
ss -tulnp | grep 8080
netstat -an | grep 8080
# 메모리 사용량 모니터링 (asan 빌드시)
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 ./multi_echo_server
# 포트 변경하여 테스트
make PORT=9090 && ./multi_echo_server
컴파일 옵션
gcc -Wall -Wextra -Wpedantic -O2 -g -std=c99 server.c -o server
# 런타임 디버깅
gcc -fsanitize=address,undefined server.c -o server
프로젝트 구조와 빌드 시스템
실제 개발에서는 여러 버전을 동시에 관리하고 다양한 빌드 옵션을 쉽게 사용할 수 있어야 합니다. 프로젝트 구조를 다음과 같이 정리했습니다:
suminworld-system-lab/network/echo_server/
├── echo_server.c # 기본 단일 클라이언트 버전
├── multi_echo_server.c # 멀티클라이언트 select() 버전
├── multi_echo_server_select.c # select() 고급 버전
└── Makefile # 통합 빌드 시스템
Makefile 구성
# 컴파일러와 플래그 설정
CC ?= gcc
CSTD ?= -std=c11
WARN ?= -Wall -Wextra -Wshadow -Wformat=2 -Wcast-qual -Wpointer-arith
OPT ?= -O2
DBG ?= -g3
CFLAGS ?= $(CSTD) $(WARN) $(OPT) $(DBG)
# 빌드 타겟들
all: echo_server multi_echo_server multi_echo_server_select
# 특수 빌드 모드
debug: CFLAGS := $(CSTD) $(WARN) -O0 -g3
debug: clean all
asan: CFLAGS := $(CSTD) $(WARN) -O1 -g3 -fsanitize=address,undefined
asan: clean all
빌드 방법
# 기본 빌드 (최적화 + 디버그 심볼)
make
# 디버깅용 빌드 (최적화 끄고 디버그 심볼 최대)
make debug
# 메모리 오류 검출 빌드
make asan
# 개별 타겟 빌드
make echo_server
make multi_echo_server
# 실행
make run # 단일 클라이언트 버전
make run-multi # 멀티 클라이언트 버전
make run-select # select() 고급 버전
빌드 옵션 설명
기본 컴파일 플래그:
- -std=c11: C11 표준 사용
- -Wall -Wextra: 기본적인 경고들
- -Wshadow: 변수 이름 중복 경고
- -Wformat=2: printf 계열 함수 안전성 체크
- -Wcast-qual: 포인터 캐스팅 경고
- -Wpointer-arith: 포인터 연산 경고
디버그 모드:
- -O0: 최적화 완전 해제 (디버깅 편의)
- -g3: 최대 디버그 정보 포함
Address Sanitizer 모드:
- -fsanitize=address,undefined: 메모리 오류 + 정의되지 않은 동작 검출
- -fno-omit-frame-pointer: 스택 트레이스 정확도 향상
- -O1: 최소 최적화 (ASan과 호환)
배운 점들
- 소켓 프로그래밍의 기본 패턴: socket → bind → listen → accept 플로우
- I/O 멀티플렉싱의 필요성: 동시 처리를 위한 select() 활용
- 에러 처리의 중요성: EINTR, SIGPIPE 등 실전에서 마주할 문제들
- 이식성 고려: OS별 차이점과 호환성 유지 방법
- 보안과 방어코딩: 악성 클라이언트 대응과 리소스 보호
- 성능 최적화 포인트: 블로킹 vs 논블로킹, 타임아웃 설정 등
특히 방어적 프로그래밍의 중요성을 많이 느꼈습니다. 기본 기능만 구현해서는 실전에서 쓸 수 없고, 다양한 예외 상황과 악용 시나리오를 고려해야 견고한 서버가 된다는 걸 배웠습니다.
마무리
Echo 서버 하나를 만들면서 소켓 프로그래밍의 핵심 개념들을 모두 경험할 수 있었습니다. 특히 기본 버전에서 멀티클라이언트 버전으로 발전시키면서 동시성 처리의 어려움을 해결해나가면서 생각보다 쉽지 않다는 것도 깨달은 듯...
다음엔 이 기반 위에서 브로드캐스트 채팅 서버나 간단한 HTTP 서버를 만들어볼 예정입니다.
GitHub: 전체 소스코드 보기
'network' 카테고리의 다른 글
| ALFA AWUS036ACM(MT7612U) 모니터모드 캡처 설정 (0) | 2025.10.01 |
|---|---|
| [네트워크] UTM VM에서 USB 무선랜카드 연결하는 법 (macOS) (0) | 2025.09.08 |
| [네트워크] C 네트워크 프로그래밍과 패킷 분석 실습 (0) | 2025.09.08 |