C++ 리눅스 시스템 프로그래밍 | 시스템 콜 호출과 커널 인터페이스 이해

들어가며: 사용자 공간과 커널 사이

시스템 콜이란?

소켓·네트워크를 다뤄 봤다면 그 아래에는 커널이 있습니다. 파일, 프로세스, 네트워크를 다루는 일은 모두 시스템 콜, 즉 사용자 프로그램이 커널에 서비스를 요청하는 인터페이스를 거칩니다. 리눅스에서는 syscall(2)로 직접 부르거나, 보통은 glibc가 제공하는 open, read, write, socket 같은 래퍼 함수를 씁니다.

C++에서 시스템 프로그래밍을 할 때는 errno로 전달되는 에러, 시그널에 의한 중단, 블로킹과 논블로킹의 차이를 이해해야 합니다. 그리고 파일 디스크립터나 mmap 영역 같은 리소스는 가능하면 RAII로 감싸는 편이 안전합니다.

이 글에서는 open/read/write, mmap, poll/epoll 같은 자주 쓰는 시스템 콜과 glibc 래퍼·errno의 관계를 짚고, EINTR 재시도, 부분 쓰기, fd 누수 같은 흔한 실수, 버퍼 크기와 O_DIRECT·mmap의 선택, RAII로 fd를 감싸는 패턴을 다룹니다.

가끔 실패하는 open, 바닥난 fd, EINTR: 시스템 콜을 제대로 알아야 하는 이유

시나리오 1: “파일 열 때 가끔 실패해요”

"로컬에서는 잘 되는데, 프로덕션에서 open()이 -1을 반환해요."
"errno가 뭔지 모르겠고, 재시도하면 되기도 해요."

open()이 실패하면 errno에 원인이 들어갑니다. 파일이 없으면 ENOENT, 권한이 없으면 EACCES, 프로세스의 파일 디스크립터 한도를 넘었으면 EMFILE입니다. FIFO처럼 열기가 블로킹될 수 있는 파일은 시그널에 의해 EINTR로 중단되기도 합니다. errno를 확인하지 않으면 이 중 어떤 이유인지 알 수 없고, “재시도하면 되기도 한다”는 증상은 대개 EMFILE이나 EINTR처럼 일시적인 원인입니다.

시나리오 2: “서버가 파일 디스크립터를 다 써버려요”

"장시간 실행 중 'Too many open files' 에러가 나요."
"프로세스당 fd 한도가 1024인데, 어디서 누수가 나는지 모르겠습니다."

open()이나 socket() 후 close()를 빠뜨리거나, 예외나 조기 반환으로 함수를 빠져나가면서 close()가 실행되지 않으면 fd가 샙니다. 한 요청에 하나씩만 새도 장시간 실행되는 서버에서는 결국 한도에 닿습니다. fd를 RAII 객체로 감싸면 소멸자에서 자동으로 close가 호출되므로 이런 누수가 구조적으로 사라집니다. 누수 여부는 ls /proc/<pid>/fd | wc -l로 fd 수가 계속 늘어나는지 보면 쉽게 확인할 수 있습니다.

시나리오 3: “read()가 반환한 값이 0인데 에러를 안 봤어요”

"read()가 0을 반환했는데, EOF인지 에러인지 구분을 안 했습니다."
"네트워크 소켓에서 연결이 끊겼어도 계속 read()를 호출했습니다."

read()의 반환값은 세 가지 의미를 가집니다. 양수는 읽은 바이트 수, 0은 EOF(파일 끝이거나 상대가 연결을 정상 종료함), -1은 에러이고 이때 errno를 봐야 합니다. 0을 에러로 처리하면 정상 종료를 놓치고, 0을 무시하고 계속 read()를 부르면 끊긴 소켓에서 0만 받으며 CPU를 태우는 루프가 됩니다.

시나리오 4: “시그널 때문에 EINTR이 나와요”

"SIGCHLD 핸들러를 등록했는데, read()가 EINTR로 중단돼요."
"재시도 루프를 넣어야 한다고 하는데, 어디에 넣어야 할지 모르겠습니다."

read(), write(), accept() 같은 블로킹 시스템 콜은 기다리는 도중 시그널 핸들러가 실행되면 중단되고 errno == EINTR로 -1을 돌려줄 수 있습니다. 이것은 실패가 아니라 중단이므로 같은 호출을 다시 하면 됩니다. sigaction에 SA_RESTART를 주면 커널이 자동으로 재시작해 주지만, poll, epoll_wait, select, 타임아웃이 걸린 소켓 호출 등 일부는 SA_RESTART와 무관하게 EINTR을 돌려주므로 재시도 루프는 여전히 필요합니다.

시나리오 5: “mmap 후 munmap을 안 해서 메모리 누수”

"대용량 파일을 mmap으로 읽었는데, 사용 후 munmap을 안 했습니다."
"프로세스 메모리가 계속 늘어나요."

mmap()으로 만든 매핑은 munmap()으로 해제하기 전까지 프로세스 주소 공간에 남습니다. 예외나 조기 반환으로 해제 경로를 놓치면 가상 메모리가 계속 늘어납니다. 이것도 매핑 영역을 RAII로 감싸서 해결합니다.

시나리오 6: “write()가 전체를 안 쓰는데 무시했어요”

"네트워크로 전송할 때 write()가 반환한 값이 len보다 작은데 그냥 넘어갔습니다."
"대용량 전송 시 데이터가 잘려 나갔습니다."

write()는 요청한 바이트를 한 번에 다 쓴다는 보장이 없습니다. 소켓 송신 버퍼가 거의 찼거나 파이프 버퍼에 남은 공간이 적으면 일부만 쓰고 그 크기를 돌려줍니다. 반환값만큼 앞으로 옮겨 가며 전부 쓸 때까지 반복해야 합니다.

해결 방향

flowchart LR
  subgraph before["수동 관리 (Before)"]
    B1[open] --> B2[read/write]
    B2 --> B3[예외?]
    B3 -.->|close 누락| B4[fd 누수]
  end
  subgraph after["RAII + 에러 처리 (After)"]
    A1[UniqueFd] --> A2[read/write]
    A2 --> A3[에러 확인]
    A3 --> A4[EINTR 재시도]
    A4 --> A5[소멸자 close]
  end

시스템 콜과 glibc 래퍼

사용자 → 커널 경계

시스템 콜을 부르면 CPU가 커널 모드로 전환되고, 커널이 요청을 처리한 뒤 사용자 모드로 돌아옵니다. 시스템 콜 번호와 인자 전달 방식은 아키텍처마다 정해져 있어서, x86-64와 aarch64에서 같은 기능의 번호가 다르고 aarch64에는 open이라는 시스템 콜 자체가 없습니다(openat만 있습니다).

커널은 실패하면 음수 에러 코드(-ENOENT 등)를 돌려주고, glibc 래퍼가 이를 -1 반환과 errno 설정으로 바꿉니다. C++ 코드에서는 errno를 여기저기서 직접 읽기보다, 실패 직후 한 번 읽어서 std::error_code나 예외로 바꾸는 층을 두면 에러 처리가 일관됩니다.

시스템 콜 흐름 개요

flowchart TB
    subgraph userspace[사용자 공간]
        A[C++ 코드] --> B[glibc 래퍼]
        B --> C[syscall 인터페이스]
    end
    subgraph kernel[커널 공간]
        C --> D[시스템 콜 핸들러]
        D --> E[파일 시스템]
        D --> F[네트워크 스택]
        D --> G[메모리 관리]
    end
    E --> H[하드웨어]
    F --> H
    G --> H

syscall 직접 호출 vs glibc 래퍼

sequenceDiagram
    participant App as 사용자 프로그램
    participant Glibc as glibc 래퍼
    participant Kernel as 커널
    App->>Glibc: open("/etc/passwd", O_RDONLY)
    Glibc->>Kernel: openat(AT_FDCWD, ...)
    Kernel->>Kernel: 파일 열기 처리
    Kernel->>Glibc: fd 또는 -errno
    Glibc->>Glibc: errno 설정
    Glibc->>App: fd 또는 -1

위 그림처럼 최신 glibc의 open()은 내부에서 openat 시스템 콜을 부릅니다. 래퍼를 쓰면 이런 아키텍처별 차이와 커널 버전별 차이를 glibc가 흡수해 주고, open(path, flags)처럼 읽기 쉬운 코드가 됩니다. glibc 래퍼는 EINTR을 자동으로 재시도하지 않는다는 점은 알아 둬야 합니다. 재시작 여부는 커널과 SA_RESTART 설정이 정합니다.

syscall(2)로 직접 부르는 경우는 glibc에 아직 래퍼가 없는 시스템 콜을 쓸 때입니다. 예를 들어 glibc 2.30 이전에는 gettid() 래퍼가 없어서 syscall(SYS_gettid)로 불렀고, futex나 새로 추가된 시스템 콜도 이렇게 부릅니다.


자주 쓰는 시스템 콜

파일·메모리·I/O 멀티플렉싱

  • open / read / write / close: 파일과 디바이스 접근. O_NONBLOCK을 주면 논블로킹 모드가 됩니다.
  • mmap / munmap: 파일이나 익명 메모리를 주소 공간에 매핑합니다. 대용량 파일 접근과 프로세스 간 공유 메모리에 씁니다.
  • poll / epoll(리눅스 전용): 여러 fd의 이벤트를 한 번에 기다립니다. 비동기 I/O 라이브러리의 기반입니다.
  • clone / fork / exec: 프로세스를 만들고 다른 프로그램으로 바꿉니다. 멀티스레드 프로그램에서 fork는 특히 조심해야 합니다.

fork/exec 주의사항

// ❌ 위험: 멀티스레드에서 fork 후 exec 전에 다른 로직 실행
// fork 시 자식은 호출 스레드만 복사, 다른 스레드가 잡고 있던 뮤텍스는 잠긴 채로 남음
// ✅ 안전: fork 직후 exec만 호출 (중간에 로직 없음)
pid_t pid = fork();
if (pid == 0) {
    execl("/bin/ls", "ls", "/tmp", nullptr);
    _exit(127);
}

fork()는 호출한 스레드 하나만 자식에 복제합니다. 다른 스레드가 malloc 내부 락이나 로깅 뮤텍스를 잡고 있던 순간에 fork가 일어나면, 자식에서는 그 락을 풀어 줄 스레드가 없어 영원히 잠긴 상태가 됩니다. 그래서 멀티스레드 프로그램의 자식은 exec하기 전까지 async-signal-safe 함수만 호출해야 하고, malloc, printf, C++ 객체 생성 같은 일은 피해야 합니다. exec에 실패했을 때도 exit 대신 _exit를 써서 부모에게서 복사된 stdio 버퍼나 atexit 핸들러가 실행되지 않게 합니다. fd는 O_CLOEXEC로 열어 두면 exec 시점에 자동으로 닫힙니다. 단순히 다른 프로그램을 실행하는 것이 목적이라면 posix_spawn이 이 문제를 대신 처리해 줍니다.


EINTR 안전 read, mmap, poll, epoll 예제

예제 1: EINTR 안전 read 래퍼

#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <string>
#include <unistd.h>
#include <system_error>
// EINTR 시 자동 재시도하는 read 래퍼
ssize_t read_retry(int fd, void* buf, size_t count) {
    ssize_t n;
    do {
        n = read(fd, buf, count);
    } while (n == -1 && errno == EINTR);
    return n;
}
// 사용 예: 파일 전체 읽기
std::string read_file(const std::string& path) {
    int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC);
    if (fd == -1) {
        throw std::system_error(errno, std::system_category(), "open failed");
    }
    std::string result;
    char buf[4096];
    ssize_t n;
    while ((n = read_retry(fd, buf, sizeof(buf))) > 0) {
        result.append(buf, static_cast<size_t>(n));
    }
    if (n == -1) {
        int e = errno;
        close(fd);  // 예외를 던지기 전에 fd 정리
        throw std::system_error(e, std::system_category(), "read failed");
    }
    close(fd);
    return result;
}

read()가 EINTR로 중단되면 같은 인자로 다시 부르면 됩니다. 이 래퍼를 쓰면 SA_RESTART 설정과 관계없이 안전합니다. 에러 경로에서 close()를 먼저 부르면 errno가 바뀔 수 있으므로, errno를 먼저 저장해 두는 점도 눈여겨보세요. 이런 정리 코드를 경로마다 쓰는 번거로움이 뒤에서 RAII를 쓰는 이유입니다.

예제 2: open/read/write/close로 파일 복사

#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <stdexcept>
#include <string>
// EINTR 안전 read 래퍼 (내부 사용)
static ssize_t read_retry(int fd, void* buf, size_t count) {
    ssize_t n;
    do { n = read(fd, buf, count); } while (n == -1 && errno == EINTR);
    return n;
}
// 파일 복사: src -> dst
void copy_file(const std::string& src, const std::string& dst) {
    int src_fd = open(src.c_str(), O_RDONLY);
    if (src_fd == -1) {
        throw std::runtime_error("open src: " + std::string(strerror(errno)));
    }
    int dst_fd = open(dst.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (dst_fd == -1) {
        int e = errno;
        close(src_fd);
        throw std::runtime_error("open dst: " + std::string(strerror(e)));
    }
    char buf[65536];  // 64KB 버퍼
    ssize_t n;
    while ((n = read_retry(src_fd, buf, sizeof(buf))) > 0) {
        ssize_t written = 0;
        while (written < n) {
            ssize_t w;
            do {
                w = write(dst_fd, buf + written, static_cast<size_t>(n - written));
            } while (w == -1 && errno == EINTR);
            if (w == -1) {
                int e = errno;
                close(src_fd);
                close(dst_fd);
                throw std::runtime_error("write: " + std::string(strerror(e)));
            }
            written += w;  // w > 0: 쓴 바이트 수
        }
    }
    if (n == -1) {
        int e = errno;
        close(src_fd);
        close(dst_fd);
        throw std::runtime_error("read: " + std::string(strerror(e)));
    }
    close(src_fd);
    close(dst_fd);
}

read() 실패(n == -1)는 루프가 끝난 뒤에 확인합니다. write()는 한 번에 전체를 쓰지 않을 수 있으므로 written < n 루프로 전부 쓸 때까지 반복하고, write() 역시 EINTR로 중단될 수 있으므로 재시도합니다. 쓰기용 fd의 close()도 지연된 쓰기 에러를 돌려줄 수 있어서, 데이터가 중요한 경우에는 close()나 fsync()의 반환값까지 확인해야 합니다.

예제 3: mmap으로 파일 읽기

#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdexcept>
#include <string>
// mmap으로 파일 전체를 메모리에 매핑
std::pair<void*, size_t> map_file(const std::string& path) {
    int fd = open(path.c_str(), O_RDONLY);
    if (fd == -1) {
        throw std::runtime_error("open: " + std::string(strerror(errno)));
    }
    struct stat st;
    if (fstat(fd, &st) == -1) {
        int e = errno;
        close(fd);
        throw std::runtime_error("fstat: " + std::string(strerror(e)));
    }
    size_t size = static_cast<size_t>(st.st_size);
    if (size == 0) {
        close(fd);
        return {nullptr, 0};
    }
    void* addr = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
    int e = errno;
    close(fd);  // mmap 후 fd는 닫아도 매핑 유지
    if (addr == MAP_FAILED) {
        throw std::runtime_error("mmap: " + std::string(strerror(e)));
    }
    return {addr, size};
}
// 사용 후 반드시 munmap 호출
void unmap_file(void* addr, size_t size) {
    if (addr && size > 0) {
        munmap(addr, size);
    }
}

매핑이 만들어진 뒤에는 fd를 닫아도 매핑은 유지됩니다. 크기가 0인 파일은 mmap이 EINVAL로 실패하므로 따로 처리합니다. 매핑한 파일을 다른 프로세스가 도중에 잘라 내면(truncate) 사라진 영역에 접근할 때 SIGBUS가 발생한다는 점도 알아 두어야 합니다.

예제 4: syscall(2) 직접 호출

#include <fcntl.h>
#include <sys/syscall.h>
#include <unistd.h>
// glibc 래퍼 없이 openat 직접 호출 (교육용)
// SYS_open은 aarch64 등 일부 아키텍처에 없으므로 SYS_openat 사용
int open_syscall(const char* path, int flags, int mode) {
    return static_cast<int>(syscall(SYS_openat, AT_FDCWD, path, flags, mode));
}
// 사용: 대부분 open(2) 래퍼를 쓰는 것이 권장됨

syscall() 함수도 glibc가 제공하는 얇은 래퍼라서, 실패하면 -1을 돌려주고 errno를 설정하는 것은 같습니다. 차이는 인자 타입 검사와 아키텍처별 보정이 없다는 점입니다. 잘못된 인자를 넘겨도 컴파일러가 잡아 주지 않으므로 glibc 래퍼가 없는 경우에만 씁니다.

예제 5: poll로 여러 fd 대기

#include <poll.h>
#include <unistd.h>
#include <cerrno>
#include <vector>
// 여러 fd에서 읽기 가능할 때까지 대기
int poll_read(std::vector<int>& fds, int timeout_ms) {
    std::vector<struct pollfd> pfds;
    pfds.reserve(fds.size());
    for (int fd : fds) {
        pfds.push_back({fd, POLLIN, 0});
    }
    int ret;
    do {
        ret = poll(pfds.data(), pfds.size(), timeout_ms);
    } while (ret == -1 && errno == EINTR);
    if (ret == -1) return -1;
    if (ret == 0) return 0;  // 타임아웃
    int ready = 0;
    for (size_t i = 0; i < pfds.size(); ++i) {
        if (pfds[i].revents & POLLIN) {
            ready++;
        }
    }
    return ready;
}

EINTR로 재시도할 때 타임아웃이 처음부터 다시 시작된다는 점에 주의하세요. 시그널이 자주 오는 환경에서 정확한 타임아웃이 필요하면 남은 시간을 계산해서 넘겨야 합니다. revents에는 POLLIN 외에도 POLLHUP, POLLERR가 설정될 수 있으므로 실제 코드에서는 이 값들도 확인합니다.

예제 6: epoll로 고성능 I/O 멀티플렉싱

#include <sys/epoll.h>
#include <unistd.h>
#include <cerrno>
#include <stdexcept>
#include <vector>
class EpollLoop {
    int epfd_;
public:
    EpollLoop() : epfd_(epoll_create1(EPOLL_CLOEXEC)) {
        if (epfd_ == -1) throw std::runtime_error("epoll_create1 failed");
    }
    ~EpollLoop() { if (epfd_ >= 0) close(epfd_); }
    void add(int fd, uint32_t events) {
        struct epoll_event ev = {};
        ev.events = events;
        ev.data.fd = fd;
        if (epoll_ctl(epfd_, EPOLL_CTL_ADD, fd, &ev) == -1)
            throw std::runtime_error("epoll_ctl add failed");
    }
    int wait(struct epoll_event* events, int maxevents, int timeout_ms) {
        int n;
        do {
            n = epoll_wait(epfd_, events, maxevents, timeout_ms);
        } while (n == -1 && errno == EINTR);
        return n;
    }
};

poll은 호출할 때마다 감시할 fd 배열 전체를 커널에 넘기고 커널도 전체를 훑으므로, fd 수에 비례해 비용이 늘어납니다. epoll은 감시 목록을 커널에 등록해 두고 준비된 fd만 돌려받으므로 연결이 수천 개 이상인 서버에서 차이가 커집니다. 감시할 fd가 적다면 poll로도 충분합니다.

빌드 및 실행

# g++로 컴파일 (예: read_file 예제)
g++ -std=c++17 -o syscall_demo syscall_demo.cpp -pthread
# 실행
./syscall_demo

<unistd.h>, <fcntl.h>, <sys/mman.h>, <poll.h>는 POSIX 환경에서 제공되고, <sys/epoll.h>는 리눅스 전용입니다. Windows에서는 #ifdef __linux__ 등으로 분기하거나 WSL을 쓰세요.

자주 쓰는 errno 값

errno의미대응
EINTR시그널에 의해 중단재시도
EAGAIN/EWOULDBLOCK논블로킹에서 대기 필요나중에 다시 시도
ENOENT파일/디렉터리 없음경로 확인
EACCES권한 없음권한·퍼미션 확인
EMFILE프로세스 fd 한도 초과fd 누수 확인, ulimit
ENOMEM메모리 부족mmap/malloc 실패
EBADF잘못된 fdfd가 이미 close됨

C++에서 RAII·에러 처리

fd·메모리 매핑을 객체로

파일 디스크립터를 RAII 클래스로 감싸면 소멸자에서 close가 호출되어 누수가 사라집니다. 같은 fd를 두 번 닫는 사고를 막으려면 이동은 허용하고 복사는 금지하는 식으로 설계합니다.

open 같은 함수는 실패하면 -1과 errno를 주므로, std::optional<File>이나 std::expected<File, std::error_code>를 반환하는 open_file 래퍼를 두면 예외 없이도 에러를 전달할 수 있습니다(#42-1의 방식). mmap 영역도 소멸자에서 munmap을 부르는 MmapRegion 같은 클래스로 감쌀 수 있습니다.

UniqueFd: RAII 파일 디스크립터

UniqueFd는 fd 하나를 소유하고, 소멸자에서 유효한 fd(0 이상)일 때만 close를 부릅니다. 이동 생성자는 std::exchange(o.fd_, -1)로 원본의 fd를 가져오고 원본에는 -1을 남겨 두므로, 이동 후에도 close는 정확히 한 번만 호출됩니다. 복사 생성자와 복사 대입은 삭제합니다.

#include <utility>
#include <unistd.h>
class UniqueFd {
    int fd_ = -1;
public:
    explicit UniqueFd(int fd) : fd_(fd) {}
    ~UniqueFd() { if (fd_ >= 0) close(fd_); }
    UniqueFd(UniqueFd&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
    UniqueFd& operator=(UniqueFd&& o) noexcept {
        if (this != &o) {
            if (fd_ >= 0) close(fd_);
            fd_ = std::exchange(o.fd_, -1);
        }
        return *this;
    }
    UniqueFd(const UniqueFd&) = delete;
    UniqueFd& operator=(const UniqueFd&) = delete;
    int get() const { return fd_; }
    explicit operator bool() const { return fd_ >= 0; }
};

MmapRegion: RAII mmap

#include <sys/mman.h>
#include <utility>
#include <cstddef>
class MmapRegion {
    void* addr_ = nullptr;
    size_t size_ = 0;
public:
    MmapRegion() = default;
    MmapRegion(void* addr, size_t size) : addr_(addr), size_(size) {}
    ~MmapRegion() { reset(); }
    MmapRegion(MmapRegion&& o) noexcept
        : addr_(std::exchange(o.addr_, nullptr))
        , size_(std::exchange(o.size_, 0)) {}
    MmapRegion& operator=(MmapRegion&& o) noexcept {
        if (this != &o) {
            reset();
            addr_ = std::exchange(o.addr_, nullptr);
            size_ = std::exchange(o.size_, 0);
        }
        return *this;
    }
    MmapRegion(const MmapRegion&) = delete;
    MmapRegion& operator=(const MmapRegion&) = delete;
    void reset() {
        if (addr_ && size_ > 0) {
            munmap(addr_, size_);
            addr_ = nullptr;
            size_ = 0;
        }
    }
    void* data() const { return addr_; }
    size_t size() const { return size_; }
};

mmap은 실패하면 nullptr이 아니라 MAP_FAILED((void*)-1)를 돌려준다는 점이 함정입니다. 이 클래스는 MAP_FAILED를 받으면 소멸자에서 그 주소로 munmap을 부르게 되므로, 반드시 MAP_FAILED를 확인한 뒤에 객체를 만들어야 합니다.


EINTR 무시, 부분 쓰기, fd 누수, errno 덮어쓰기

에러 1: EINTR 무시

read(), write(), accept() 등이 가끔 -1을 반환하고 errno가 EINTR인 경우입니다. 시그널 핸들러가 등록된 상태에서 블로킹 시스템 콜이 중단되면 이렇게 됩니다.

// ❌ 잘못된 예: EINTR을 에러로 처리
ssize_t n = read(fd, buf, size);
if (n == -1) {
    return -1;  // EINTR도 에러로 처리됨
}
// ✅ 올바른 예: EINTR 시 재시도
ssize_t n;
do {
    n = read(fd, buf, size);
} while (n == -1 && errno == EINTR);
if (n == -1) {
    return -1;  // EINTR이 아닌 경우만 에러
}

에러 2: write() 부분 쓰기 무시

write()가 요청보다 적은 바이트를 썼다고 반환했는데 나머지를 버리고 다음 버퍼로 넘어가는 경우입니다. 소켓과 파이프에서는 버퍼 상태에 따라 부분 쓰기가 흔합니다.

// ❌ 잘못된 예: 한 번만 write 호출
write(fd, buf, len);  // len보다 적게 쓸 수 있음
// ✅ 올바른 예: 전부 쓸 때까지 루프
ssize_t written = 0;
while (written < static_cast<ssize_t>(len)) {
    ssize_t n = write(fd, buf + written, len - written);
    if (n == -1) {
        if (errno == EINTR) continue;
        return -1;
    }
    written += n;
}

논블로킹 소켓이라면 EAGAIN이 나왔을 때 루프를 돌며 재시도하면 CPU를 헛되이 씁니다. 남은 데이터를 보관해 두고 epoll에서 쓰기 가능 이벤트(EPOLLOUT)를 받은 뒤 이어서 씁니다.

에러 3: fd 누수 (close 미호출)

ulimit -n 한도에 도달해 “Too many open files”가 나는 경우입니다. open()이나 socket() 뒤에 예외가 발생해 close()까지 가지 못한 경로가 원인인 경우가 많습니다.

// ❌ 잘못된 예: 예외 시 close 누락
int fd = open(path, O_RDONLY);
process(fd);  // 예외 발생 시 close 안 됨
close(fd);
// ✅ 올바른 예: RAII로 감싸기
UniqueFd fd(open(path, O_RDONLY));
if (!fd) return -1;
process(fd.get());  // 예외 발생해도 소멸자에서 close

에러 4: mmap 후 munmap 누락

프로세스의 가상 메모리가 계속 늘어나는데 힙은 그대로라면, mmap()으로 만든 매핑을 해제하지 않은 경우를 의심합니다. /proc/<pid>/maps를 보면 같은 파일의 매핑이 여러 개 쌓여 있는 것으로 확인할 수 있습니다.

// ❌ 잘못된 예: munmap 누락
void* addr = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
use(addr);
// munmap 없음!
// ✅ 올바른 예: 실패 확인 후 RAII로 감싸기
void* addr = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (addr == MAP_FAILED) return -1;
MmapRegion region(addr, size);
use(region.data());
// 소멸자에서 munmap 자동 호출

에러 5: errno 덮어쓰기

open() 실패 후 strerror(errno)를 호출했는데 엉뚱한 메시지가 나오는 경우입니다. errno를 읽기 전에 부른 다른 함수(로깅, 메모리 할당, close 등)가 errno를 덮어썼기 때문입니다. 성공한 함수도 errno를 바꿀 수 있다는 점이 함정입니다.

// ❌ 잘못된 예: errno 덮어쓰기
int fd = open(path, O_RDONLY);
if (fd == -1) {
    log("open failed");  // log() 내부에서 errno를 덮을 수 있음
    return std::error_code(errno, std::system_category());  // 잘못된 errno
}
// ✅ 올바른 예: 즉시 errno 저장
int fd = open(path, O_RDONLY);
if (fd == -1) {
    int e = errno;
    return std::error_code(e, std::system_category());
}

에러 6: fork 후 fd 상속

부모가 소켓을 닫았는데도 연결이 끊기지 않거나 포트가 해제되지 않는 경우입니다. fork()하면 자식이 부모의 fd 테이블을 복제하고, close-on-exec 플래그가 없는 fd는 exec 후에도 새 프로그램에 그대로 남습니다. 같은 소켓을 가리키는 fd가 자식에게도 있으므로, 부모가 닫아도 커널 입장에서는 아직 열려 있는 소켓입니다.

// ✅ exec 시 자동으로 닫히도록 O_CLOEXEC 지정
int fd = open(path, O_RDONLY | O_CLOEXEC);
int sock = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
// 또는 fork 후 exec 전에 자식에서 필요 없는 fd를 close

O_CLOEXEC를 open에 바로 주는 것과 나중에 fcntl(fd, F_SETFD, FD_CLOEXEC)로 설정하는 것은 멀티스레드에서 차이가 납니다. 후자는 open과 fcntl 사이에 다른 스레드가 fork하면 플래그가 없는 fd가 새어 나갈 수 있으므로, 처음부터 플래그를 주는 편이 안전합니다.


버퍼 크기, mmap vs read, poll vs epoll

버퍼 크기 선택

// 작은 버퍼: 시스템 콜 횟수 증가
char buf[256];   // 4KB 버퍼 대비 read/write 호출 16배
char buf[4096];  // 페이지 크기, 일반적인 기본값
char buf[65536]; // 64KB: 대용량 파일에 적합

버퍼가 작으면 같은 양을 읽는 데 시스템 콜이 더 많이 필요합니다. 대용량 파일 복사나 스트리밍에는 수십 KB에서 1MB 정도의 버퍼가 흔히 쓰입니다. 그보다 키워도 시스템 콜 횟수가 줄어드는 효과는 작아지고, 버퍼가 CPU 캐시에서 밀려나거나 스택을 과하게 쓰는 문제가 생깁니다. 64KB를 넘는 버퍼는 스택 대신 힙에 두는 편이 안전합니다.

mmap vs read

구분read/writemmap
랜덤 접근seek + read 반복포인터로 직접 접근
순차 읽기버퍼 크기에 따라페이지 폴트 오버헤드
대용량 파일버퍼 관리 필요한 번 매핑 후 사용
공유 메모리불가MAP_SHARED 가능

mmap은 랜덤 접근이 많거나, 여러 프로세스가 같은 파일을 읽거나, 공유 메모리가 필요할 때 유리합니다. 순차 스트리밍이나 작은 파일은 read가 단순하고 충분히 빠릅니다. mmap은 처음 접근하는 페이지마다 페이지 폴트가 나므로, 순차로 한 번만 읽는 용도라면 read보다 오히려 느릴 수 있습니다. madvise(MADV_SEQUENTIAL)로 커널에 접근 패턴을 알려 주면 미리 읽기가 좋아집니다.

O_DIRECT (직접 I/O)

// O_DIRECT: 페이지 캐시를 거치지 않고 디바이스와 직접 전송
int fd = open(path, O_RDONLY | O_DIRECT);
// 주의: 버퍼 주소·오프셋·크기 정렬 필요 (보통 512바이트 또는 4096바이트)

O_DIRECT는 페이지 캐시를 우회하므로 자체 캐시를 가진 데이터베이스나 스토리지 엔진에서 이중 캐싱을 피하려고 씁니다. 버퍼 주소, 파일 오프셋, 전송 크기가 모두 블록 크기에 맞춰 정렬되어 있지 않으면 EINVAL로 실패하고, 일반 애플리케이션에서는 페이지 캐시의 이점을 잃어 오히려 느려지는 경우가 많습니다.

O_NONBLOCK (논블로킹 I/O)

#include <fcntl.h>
// O_NONBLOCK: 데이터 없으면 즉시 EAGAIN 반환
int fd = open(path, O_RDONLY | O_NONBLOCK);
// 또는 기존 fd에 적용
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

네트워크 소켓과 파이프에서 poll/epoll과 함께 씁니다. 일반 파일에는 O_NONBLOCK이 사실상 효과가 없어서, 디스크 읽기는 여전히 블로킹됩니다. 파일 I/O를 비동기로 처리하려면 스레드 풀이나 io_uring이 필요합니다.

시스템 콜 비용 이해

시스템 콜은 사용자 모드와 커널 모드 사이를 오가므로 일반 함수 호출보다 훨씬 비쌉니다. 비용은 CPU와 커널의 보안 완화 설정(Spectre/Meltdown 대응 등)에 따라 크게 달라집니다. 그래서 작은 read/write를 여러 번 하기보다 큰 버퍼로 한 번에 읽고 쓰는 것이 효율적입니다. mmap은 매핑할 때 시스템 콜 한 번이고 이후에는 페이지 폴트가 날 때만 커널에 들어가므로 랜덤 접근이 많을 때 유리합니다. 리눅스 5.1부터 들어온 io_uring은 요청과 완료를 공유 링 버퍼로 주고받아 시스템 콜 횟수 자체를 줄이는 인터페이스입니다.

실제 시스템 콜 횟수와 소요 시간은 strace -c ./my_program으로 요약해 볼 수 있으니, 추측하기보다 한 번 재 보는 편이 정확합니다.

poll vs epoll

구분pollepoll
fd 수적을 때 적합수천~수만 개
복사매번 전체 fd 배열 전달커널이 내부 상태 유지
이벤트level-triggeredlevel/edge 선택 가능
이식성POSIX리눅스 전용

epoll을 edge-triggered(EPOLLET)로 쓰면 이벤트가 상태 변화 시점에 한 번만 오므로, 알림을 받으면 EAGAIN이 나올 때까지 읽어야 합니다. 중간에 멈추면 남은 데이터에 대한 알림이 다시 오지 않아 연결이 멈춘 것처럼 보입니다.


expected 반환, EINTR 재시도 래퍼, strace 디버깅

패턴 1: expected<T, E> 반환

std::expected는 C++23 기능이므로 -std=c++23(또는 c++2b)과 이를 지원하는 표준 라이브러리가 필요합니다. 그 이전 표준에서는 tl::expected 같은 라이브러리를 씁니다.

#include <expected>
#include <system_error>
std::expected<UniqueFd, std::error_code> open_file(const char* path) {
    int fd = open(path, O_RDONLY | O_CLOEXEC);
    if (fd == -1) {
        return std::unexpected(std::error_code(errno, std::system_category()));
    }
    return UniqueFd(fd);
}
// 사용
auto result = open_file("/etc/passwd");
if (result) {
    UniqueFd& fd = *result;
    // ...
} else {
    std::error_code ec = result.error();
    // ...
}

패턴 2: EINTR 재시도 래퍼

template<typename Func, typename... Args>
auto retry_on_eintr(Func&& f, Args&&... args) {
    decltype(f(args...)) result;
    do {
        result = f(args...);  // 반복 호출하므로 forward하지 않음
    } while (result == -1 && errno == EINTR);
    return result;
}
// 사용
ssize_t n = retry_on_eintr(read, fd, buf, size);

같은 인자로 여러 번 호출하므로 std::forward로 인자를 넘기면 안 됩니다. 이동된 인자를 두 번째 호출에서 다시 쓰게 될 수 있기 때문입니다.

패턴 3: 간단한 scope guard

UniqueFd 같은 전용 클래스를 만들기 번거로운 곳에서는, 범위를 벗어날 때 정리 함수를 실행하는 작은 가드를 쓸 수 있습니다.

template <typename F>
struct ScopeExit {
    F f;
    ~ScopeExit() { f(); }
};
template <typename F> ScopeExit(F) -> ScopeExit<F>;

void process_file(const char* path) {
    int fd = open(path, O_RDONLY | O_CLOEXEC);
    if (fd == -1) return;
    ScopeExit guard{[fd] { close(fd); }};  // 함수를 빠져나가면 close
    // ... 예외나 조기 반환이 있어도 close 보장
}

람다를 변수에 담기만 하고 호출하지 않으면 아무 일도 일어나지 않습니다. 소멸자에서 호출되는 객체로 감싸야 가드 역할을 합니다.

패턴 4: 로깅 + 에러 전파

std::error_code open_with_log(const char* path) {
    int fd = open(path, O_RDONLY);
    if (fd == -1) {
        int e = errno;
        log_error("open failed: path=%s errno=%d %s", path, e, strerror(e));
        return std::error_code(e, std::system_category());
    }
    close(fd);
    return {};
}

패턴 5: strace로 시스템 콜 디버깅

# 프로세스 실행 시 시스템 콜 추적 (최신 glibc는 open 대신 openat 사용)
strace -e trace=openat,read,write,close ./my_program
# 파일 관련만 추적
strace -e trace=file ./my_program
# 실행 중인 프로세스에 attach
strace -p <pid> -e trace=read,write
# 시스템 콜별 호출 횟수·시간 요약
strace -c ./my_program

open()이 왜 실패하는지(어떤 경로를 어떤 errno로 실패했는지), read()와 write()가 몇 번, 어떤 크기로 호출되는지, 어디서 fd를 열고 닫지 않는지를 코드 수정 없이 확인할 수 있습니다. strace -e trace=openat만 걸어도 설정 파일을 엉뚱한 경로에서 찾고 있는 문제를 금방 발견하는 경우가 많습니다. 다만 strace는 대상 프로세스를 크게 느리게 만들므로 운영 환경에서는 짧게만 씁니다.


기존 코드에 적용하는 순서

  1. UniqueFd, MmapRegion 같은 RAII 래퍼를 먼저 도입해 fd와 mmap 누수를 막습니다.
  2. read, write, accept 등에 EINTR 재시도를 적용합니다.
  3. 모든 쓰기 경로에 부분 쓰기 루프를 넣습니다.
  4. errno는 실패 직후 변수에 저장한 뒤 사용합니다.
  5. 필요하면 expected나 error_code로 에러 전파 방식을 정리합니다.

순서가 중요한 이유는 뒤 단계일수록 앞 단계에 기대기 때문입니다. RAII 래퍼가 없는 상태에서 EINTR 재시도나 부분 쓰기 루프를 넣으면 조기 반환 경로마다 close를 빠뜨리기 쉽고, errno를 곧바로 저장하지 않은 채 expected로 감싸면 중간의 로깅 호출이 errno를 덮어써 엉뚱한 에러가 전파됩니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. fork 후 exec한 자식 프로세스가 부모의 소켓이나 파일을 계속 잡고 있는 이유는 무엇인가요?

fork는 부모의 파일 디스크립터 테이블을 그대로 복제하고, exec을 해도 close-on-exec 플래그가 없는 fd는 새 프로그램에 그대로 상속되기 때문입니다. 그래서 부모가 소켓을 닫아도 자식이 fd를 쥐고 있어 연결이 끊기지 않거나 포트가 해제되지 않는 문제가 생깁니다. open에 O_CLOEXEC, socket에 SOCK_CLOEXEC를 지정하는 습관을 들이면 exec 시점에 자동으로 닫히므로 이런 누수를 막을 수 있습니다.

Q. EINTR을 왜 재시도해야 하나요?

시그널 핸들러가 등록된 상태에서 read()·write() 등 블로킹 시스템 콜이 중단되면 errno == EINTR이 설정됩니다. 이는 에러가 아니라 일시적 중단이므로 같은 인자로 다시 호출하면 됩니다. 재시도하지 않으면 정상적인 상황을 실패로 처리해 연결을 끊거나 데이터를 버리게 됩니다.

Q. mmap과 read 중 뭘 써야 하나요?

랜덤 접근이 많거나 여러 프로세스가 같은 대용량 파일을 읽는다면 mmap이 유리합니다. 순차 스트리밍이나 작은 파일에서는 read가 단순하고 효율적입니다. 공유 메모리가 필요하면 mmap의 MAP_SHARED를 씁니다.

Q. read()가 0을 반환하면 EOF인가요, 에러인가요?

read()가 0을 반환하면 EOF입니다. 파일 끝에 도달했거나, 소켓에서 상대가 연결을 정상적으로 닫았을 때 0이 반환됩니다. 에러일 때는 -1이 반환되고 errno가 설정됩니다. 0과 -1을 구분해 처리해야 합니다.

Q. 프로덕션에서 fd 한도는 어떻게 설정하나요?

ulimit -n으로 현재 셸과 그 자식 프로세스의 fd 한도를 확인하고 바꿀 수 있습니다. 수천 개 연결을 처리하는 서버는 ulimit -n 65535처럼 늘리는 경우가 많습니다. 로그인 세션의 영구 설정은 /etc/security/limits.conf에서 하지만, systemd로 실행하는 서비스에는 이 파일이 적용되지 않으므로 유닛 파일의 LimitNOFILE=로 설정해야 합니다. 한도를 올리기 전에 fd 수가 계속 증가하는 누수가 아닌지 먼저 확인하세요.

참고 자료

이전 글: 실전 도메인 #42-2: volatile·메모리 맵 I/O·ISR

다음 글: [실전 도메인 #43-1] 고성능 RPC 시스템: gRPC와 Protocol Buffers를 이용한 마이크로서비스 구축