네트워크 오류 TOP 7 — ECONNREFUSED, ECONNRESET, EPIPE 원인과 해결

소켓 프로그래밍을 하다 보면 ECONNREFUSED, ECONNRESET, EPIPE 같은 네트워크 오류를 자주 만난다. 에러 코드만 보고는 원인을 바로 알기 어렵고, OS마다 처리 방법도 다르다. 이 글에서는 실무에서 자주 마주치는 네트워크 오류 7가지의 원인과 해결 방법을 정리했다.

1. Connection Timeout

서버나 상대 피어에 연결을 시도했는데 일정 시간 내에 응답이 없을 때 발생한다. TCP 연결 시 SYN 패킷을 보냈지만 SYN-ACK를 받지 못하는 경우가 대표적이다.

주요 원인

방화벽이나 라우터에서 패킷을 차단하고 있거나, 상대방 서버가 다운된 경우, 네트워크 경로 상에 문제가 있는 경우에 발생한다. 간혹 상대방의 대역폭이 포화 상태여서 응답을 못 주는 경우도 있다.

해결 방법

타임아웃 값을 적절히 조정하는 것이 첫 번째다. 기본값이 너무 짧으면 정상적인 상황에서도 오류가 날 수 있다. 재시도 로직을 구현할 때는 exponential backoff 방식을 추천한다. 1초, 2초, 4초 이런 식으로 간격을 늘려가면서 재시도하면 네트워크 부하도 줄이고 성공 확률도 높아진다.

int timeout_ms = 5000; // 5초로 시작
int max_retries = 3;
int retry_count = 0;

while (retry_count < max_retries) {
    if (connect_with_timeout(socket, timeout_ms)) {
        break;
    }
    retry_count++;
    timeout_ms *= 2; // 타임아웃 시간을 2배로 증가
}

ping이나 traceroute로 네트워크 경로를 확인해보는 것도 도움이 된다. 어디서 패킷이 막히는지 알 수 있다.

2. Connection Refused (ECONNREFUSED)

연결을 시도했지만 상대방이 명시적으로 거부한 경우다. 클라이언트가 서버에 접속하려 했는데 해당 포트에서 listen하는 프로세스가 없을 때 발생한다.

주요 원인

서버 프로세스가 실행되지 않았거나, 잘못된 포트로 connect를 시도한 경우가 대부분이다. 서버가 특정 인터페이스(예: 127.0.0.1)에서만 listen하고 있는데 외부 IP로 connect하는 경우에도 발생한다.

해결 방법

먼저 서버가 정상적으로 실행 중인지 확인한다.

Windows:

netstat -ano | findstr :포트번호
# 또는 PowerShell
Get-NetTCPConnection -LocalPort 포트번호

macOS:

lsof -i :포트번호
netstat -an | grep 포트번호

Linux:

ss -tlnp | grep :포트번호
# 또는
netstat -tlnp | grep :포트번호

서버 코드에서 bind 시 주소를 확인해본다. INADDR_ANY(0.0.0.0)로 bind하면 모든 인터페이스에서 accept할 수 있다.

struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY; // 모든 인터페이스에서 listen
addr.sin_port = htons(8080);

방화벽 설정도 체크해야 한다.

Windows: Windows Defender 방화벽 → 고급 설정 → 인바운드 규칙에서 해당 포트 허용 macOS: 시스템 설정 → 네트워크 → 방화벽에서 애플리케이션 허용 Linux: sudo ufw allow 포트번호 또는 sudo firewall-cmd --add-port=포트번호/tcp

3. Connection Reset (ECONNRESET)

이미 established된 connection에서 상대방이 예기치 않게 연결을 끊은 경우다. TCP RST 패킷을 수신했을 때 발생한다.

주요 원인

상대방 프로세스가 crash되거나, 네트워크 장비가 idle connection을 강제로 끊은 경우, recv buffer overflow로 상대방이 RST를 보낸 경우가 있다. P2P 환경에서는 NAT timeout으로 인해 발생하기도 한다.

해결 방법

TCP keep-alive를 설정해서 주기적으로 probe 패킷을 보내는 방법이 효과적이다. 이렇게 하면 NAT 테이블이 유지되고 idle timeout도 방지할 수 있다.

Linux / macOS:

int keepalive = 1;
setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));

int keepidle = 60;  // 60초 유휴 후 시작
int keepintvl = 10; // 10초 간격
int keepcnt = 3;    // 3번 시도

// Linux
setsockopt(sock, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
setsockopt(sock, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
setsockopt(sock, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));

// macOS는 TCP_KEEPALIVE 사용 (TCP_KEEPIDLE 대신)
setsockopt(sock, IPPROTO_TCP, TCP_KEEPALIVE, &keepidle, sizeof(keepidle));

Windows:

#include <mstcpip.h>

struct tcp_keepalive keepalive_vals;
keepalive_vals.onoff = 1;
keepalive_vals.keepalivetime = 60000;     // 60초 (밀리초)
keepalive_vals.keepaliveinterval = 10000; // 10초 (밀리초)

DWORD bytes_returned;
WSAIoctl(sock, SIO_KEEPALIVE_VALS, &keepalive_vals, sizeof(keepalive_vals),
         NULL, 0, &bytes_returned, NULL, NULL);

recv 측에서 데이터를 제때 read하지 않으면 send buffer가 차서 RST가 발생할 수 있다. non-blocking I/O나 별도 thread로 recv 처리를 하는 것이 좋다.

4. Broken Pipe (EPIPE / SIGPIPE)

이미 close된 socket에 write하려고 할 때 발생한다. peer가 connection을 끊었는데 그 사실을 모르고 send()를 호출하면 발생한다.

주요 원인

클라이언트가 disconnect했는데 서버가 그 사실을 모르고 계속 send하려는 경우가 흔하다. 특히 async I/O 환경에서 event 처리 타이밍 이슈로 자주 발생한다.

해결 방법

Linux / macOS:

SIGPIPE signal을 ignore하도록 설정한다. 그렇지 않으면 프로세스가 terminate될 수 있다.

signal(SIGPIPE, SIG_IGN);

send()나 write() 호출 시 MSG_NOSIGNAL flag를 사용하면 SIGPIPE 대신 EPIPE error code를 받을 수 있다.

// Linux
ssize_t sent = send(sock, buffer, size, MSG_NOSIGNAL);

// macOS (MSG_NOSIGNAL 없음, SO_NOSIGPIPE 소켓 옵션 사용)
int set = 1;
setsockopt(sock, SOL_SOCKET, SO_NOSIGPIPE, &set, sizeof(set));
ssize_t sent = send(sock, buffer, size, 0);

if (sent < 0) {
    if (errno == EPIPE) {
        // 연결이 끊어진 것으로 처리
        close_connection(sock);
    }
}

Windows:

Windows에서는 SIGPIPE가 없다. 대신 send 실패 시 WSAECONNRESET 에러를 확인한다.

int sent = send(sock, buffer, size, 0);
if (sent == SOCKET_ERROR) {
    int err = WSAGetLastError();
    if (err == WSAECONNRESET || err == WSAECONNABORTED) {
        // 연결이 끊어진 것으로 처리
        closesocket(sock);
    }
}

write()나 send() 전에 socket이 여전히 valid한지 확인하는 것도 방법이다. select()나 poll()로 socket 상태를 체크할 수 있다.

5. Network Unreachable (ENETUNREACH)

destination network에 도달할 수 없을 때 발생한다. routing table에 해당 네트워크로 가는 route가 없거나, network interface가 down된 경우다.

주요 원인

WiFi나 Ethernet 연결이 끊어진 상태에서 네트워크 요청을 했거나, VPN 연결이 필요한데 연결되지 않은 경우, invalid IP로 connect를 시도한 경우에 발생한다.

해결 방법

네트워크 연결 상태를 모니터링하는 로직을 추가한다.

Windows:

#include <iphlpapi.h>
#pragma comment(lib, "iphlpapi.lib")

bool is_network_available() {
    DWORD flags;
    return InternetGetConnectedState(&flags, 0) == TRUE;
}

macOS:

#include <SystemConfiguration/SystemConfiguration.h>

bool is_network_available() {
    SCNetworkReachabilityRef reachability = 
        SCNetworkReachabilityCreateWithName(NULL, "www.google.com");
    
    SCNetworkReachabilityFlags flags;
    bool success = SCNetworkReachabilityGetFlags(reachability, &flags);
    CFRelease(reachability);
    
    return success && (flags & kSCNetworkReachabilityFlagsReachable);
}

Linux:

#include <ifaddrs.h>
#include <net/if.h>

bool is_network_available() {
    struct ifaddrs *ifaddr;
    if (getifaddrs(&ifaddr) == -1) return false;
    
    bool available = false;
    for (struct ifaddrs *ifa = ifaddr; ifa != NULL; ifa = ifa->ifa_next) {
        if (ifa->ifa_addr == NULL) continue;
        // 루프백이 아니고 UP 상태인 인터페이스 확인
        if (!(ifa->ifa_flags & IFF_LOOPBACK) && (ifa->ifa_flags & IFF_UP)) {
            available = true;
            break;
        }
    }
    freeifaddrs(ifaddr);
    return available;
}

네트워크가 복구될 때까지 request를 queue에 쌓아뒀다가 나중에 처리하는 방식도 고려해볼 만하다. UX 측면에서 좋다.

6. Too Many Open Files (EMFILE / ENFILE)

프로세스나 시스템 전체에서 열 수 있는 file descriptor 수를 초과했을 때 발생한다. socket도 fd를 사용하기 때문에 concurrent connection이 많으면 이 오류를 만난다.

주요 원인

socket을 제대로 close하지 않아서 resource leak이 발생한 경우, concurrent connection이 예상보다 많은 경우, 시스템 기본 limit이 너무 낮게 설정된 경우가 있다.

해결 방법

RAII 패턴을 활용해서 socket이 자동으로 close되게 만든다. smart pointer나 wrapper class를 사용하면 실수로 close()를 빼먹는 일이 없다.

class Socket {
    int fd_;
public:
    Socket(int fd) : fd_(fd) {}
    ~Socket() { 
        if (fd_ >= 0) {
            close(fd_); 
        }
    }
    int get() const { return fd_; }
    // 복사 방지
    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;
};

시스템 제한을 늘리는 것도 필요하다.

Windows:

# 레지스트리에서 MaxUserPort 확인/수정
# HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters
# MaxUserPort (DWORD) = 65534

Windows에서는 handle limit이 기본적으로 높아서 문제가 적지만, socket handle leak은 여전히 확인해야 한다.

macOS:

# 현재 제한 확인
ulimit -n

# 제한 증가 (일시적)
ulimit -n 4096

# 영구 적용: /Library/LaunchDaemons/limit.maxfiles.plist 생성
sudo launchctl limit maxfiles 65536 200000

Linux:

# 현재 제한 확인
ulimit -n

# 제한 증가 (일시적)
ulimit -n 4096

# 영구 적용: /etc/security/limits.conf 수정
# * soft nofile 65536
# * hard nofile 65536

# 또는 /etc/sysctl.conf 수정
# fs.file-max = 65536
sudo sysctl -p

코드에서도 설정할 수 있다 (Linux/macOS).

struct rlimit limit;
limit.rlim_cur = 4096;
limit.rlim_max = 4096;
setrlimit(RLIMIT_NOFILE, &limit);

7. Address Already in Use (EADDRINUSE)

이미 사용 중인 포트를 bind하려고 할 때 발생한다. 서버를 재시작했는데 이전 프로세스가 사용하던 포트가 아직 TIME_WAIT 상태로 남아있는 경우가 흔하다.

주요 원인

서버를 kill했을 때 포트가 즉시 release되지 않고 TIME_WAIT 상태로 일정 시간 유지된다. 여러 프로세스가 같은 포트에 bind하려고 하는 경우에도 발생한다.

해결 방법

SO_REUSEADDR 옵션을 설정하면 TIME_WAIT 상태의 포트를 재사용할 수 있다.

int reuse = 1;
if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {
    perror("setsockopt(SO_REUSEADDR) failed");
}

Linux와 macOS에서는 SO_REUSEPORT도 유용하다. 여러 프로세스가 같은 포트를 공유할 수 있게 해준다.

// Linux / macOS
int reuse_port = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &reuse_port, sizeof(reuse_port));

어떤 프로세스가 해당 포트를 사용 중인지 확인하려면 다음 명령어를 쓴다.

Windows:

netstat -ano | findstr :포트번호
# PID 확인 후
tasklist /FI "PID eq PID번호"
# 종료
taskkill /PID PID번호 /F

macOS:

lsof -i :포트번호
# 또는
netstat -vanp tcp | grep 포트번호
# 종료
kill -9 PID

Linux:

ss -tlnp | grep :포트번호
# 또는
netstat -tlnp | grep :포트번호
# 종료
kill -9 PID

TIME_WAIT가 계속 쌓이는 경우

서버에서 TIME_WAIT 상태의 socket이 수천~수만 개씩 쌓이는 경우가 있다. netstat -an | grep TIME_WAIT | wc -l로 확인해보면 놀랄 때가 많다.

왜 TIME_WAIT가 발생하는가?

TCP connection을 먼저 close()한 쪽에서 TIME_WAIT 상태가 된다. 이건 TCP 프로토콜의 정상적인 동작이다. TIME_WAIT는 2MSL(Maximum Segment Lifetime, 보통 60초) 동안 유지되며, 이 시간 동안 지연된 패킷이 새 connection에 영향을 주지 않도록 보호한다.

서버에 TIME_WAIT가 쌓이는 이유

  1. 서버가 먼저 close()를 호출하는 구조: HTTP/1.0처럼 서버가 response 후 connection을 끊는 경우
  2. 짧은 connection이 대량으로 발생: API 서버에서 request마다 새 connection을 맺고 끊는 경우
  3. 클라이언트가 close()를 안 하고 서버가 timeout으로 끊는 경우

해결 방법

  1. 클라이언트가 먼저 close()하도록 프로토콜 설계

가능하다면 클라이언트가 먼저 connection을 끊도록 설계한다. 그러면 TIME_WAIT가 클라이언트에 쌓인다.

  1. Connection pooling / Keep-alive 사용

connection을 재사용하면 TIME_WAIT 자체가 줄어든다.

// HTTP Keep-Alive 헤더 사용
// Connection: keep-alive
  1. tcp_tw_reuse 활성화 (Linux)

outbound connection에서 TIME_WAIT 상태의 socket을 재사용할 수 있게 한다.

# /etc/sysctl.conf
net.ipv4.tcp_tw_reuse = 1
sudo sysctl -p

주의: tcp_tw_recycle은 NAT 환경에서 문제를 일으키므로 사용하지 않는다. Linux 4.12부터 제거됨.

  1. TIME_WAIT 시간 단축 (권장하지 않음)

일부 OS에서 TIME_WAIT 시간을 줄일 수 있지만, 패킷 충돌 위험이 있어 권장하지 않는다.

  1. ephemeral port range 확장

TIME_WAIT가 많아도 사용 가능한 port가 충분하면 문제없다.

Linux:

# 기본값: 32768-60999
cat /proc/sys/net/ipv4/ip_local_port_range

# 확장
echo "10000 65535" > /proc/sys/net/ipv4/ip_local_port_range

TIME_WAIT 모니터링

# TIME_WAIT 개수 확인
netstat -an | grep TIME_WAIT | wc -l

# 상태별 socket 개수 확인
ss -s
# 또는
netstat -an | awk '/tcp/ {print $6}' | sort | uniq -c | sort -rn

TIME_WAIT는 TCP의 정상적인 동작이므로 무조건 없애려고 하기보다는, connection 재사용이나 프로토콜 설계로 근본적인 원인을 해결하는 것이 좋다.

결국은 경험이다

솔직히 네트워크 오류는 처음 겪으면 뭐가 뭔지 모르겠다. 에러 메시지만 보고 “아 이거구나” 하고 바로 해결되는 경우는 거의 없다. ECONNRESET 처음 봤을 때 한참 헤맸다.

그래도 몇 번 겪다 보면 패턴이 보인다. “아 이건 방화벽 문제겠네”, “TIME_WAIT 쌓였나 보네” 이런 감이 생긴다. 결국 경험이다.

디버깅할 때 Wireshark 켜놓고 패킷 흐름 보는 게 제일 확실하다. 로그도 아끼지 말고 남겨두자. 새벽에 장애 터졌을 때 로그 없으면 진짜 막막하다.