suminworld

Make the world a better place using technology

자세히보기

network

[네트워크] C 소켓 프로그래밍 Echo 서버 구현 - 기본부터 멀티클라이언트까지

숨usm 2025. 9. 8. 20:47

소켓 프로그래밍을 배우면서 가장 기본이 되는 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과 호환)

배운 점들

  1. 소켓 프로그래밍의 기본 패턴: socket → bind → listen → accept 플로우
  2. I/O 멀티플렉싱의 필요성: 동시 처리를 위한 select() 활용
  3. 에러 처리의 중요성: EINTR, SIGPIPE 등 실전에서 마주할 문제들
  4. 이식성 고려: OS별 차이점과 호환성 유지 방법
  5. 보안과 방어코딩: 악성 클라이언트 대응과 리소스 보호
  6. 성능 최적화 포인트: 블로킹 vs 논블로킹, 타임아웃 설정 등

특히 방어적 프로그래밍의 중요성을 많이 느꼈습니다. 기본 기능만 구현해서는 실전에서 쓸 수 없고, 다양한 예외 상황과 악용 시나리오를 고려해야 견고한 서버가 된다는 걸 배웠습니다.

마무리

Echo 서버 하나를 만들면서 소켓 프로그래밍의 핵심 개념들을 모두 경험할 수 있었습니다. 특히 기본 버전에서 멀티클라이언트 버전으로 발전시키면서 동시성 처리의 어려움을 해결해나가면서 생각보다 쉽지 않다는 것도 깨달은 듯...

다음엔 이 기반 위에서 브로드캐스트 채팅 서버간단한 HTTP 서버를 만들어볼 예정입니다.


GitHub: 전체 소스코드 보기