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

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

이 글의 핵심

C++ 리눅스 시스템 프로그래밍에 대한 실전 가이드입니다. 시스템 콜 호출과 커널 인터페이스 이해 [#42-3] 등을 예제와 함께 상세히 설명합니다.

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

시스템 콜이란?

30번에서 소켓·네트워크를 다뤘다면, 그 아래에는 커널이 있고, 파일·프로세스·네트워크 제어는 시스템 콜(syscall—사용자 프로그램이 커널에 서비스를 요청하는 인터페이스)을 통해 커널(OS의 핵심. 하드웨어와 프로세스를 관리)에 요청합니다. 리눅스에서는 syscall(2) 또는 glibc 래퍼(open, read, write, socket 등)가 그 인터페이스입니다.
C++에서 시스템 프로그래밍을 할 때는: 에러 처리(errno)·시그널·블로킹/논블로킹을 이해하고, 가능하면 RAII로 리소스(파일 디스크립터, mmap 영역)를 감싸면 안전합니다.

이 글에서 다루는 것:

  • 문제 시나리오: 실제 겪는 “파일 열기 실패”, “EINTR 재시도”, “fd 누수” 상황
  • 시스템 콜과 래퍼: syscall 번호·glibc 래퍼·errno
  • 자주 쓰는 콜: open/read/write, mmap, poll/epoll
  • 완전한 syscall 예제: 복사 후 바로 실행 가능한 코드
  • 자주 발생하는 에러와 해결법
  • 성능 최적화: 버퍼 크기, O_DIRECT, mmap vs read
  • 프로덕션 패턴: RAII·에러 코드 반환·예외(또는 expected) 변환

목차

  1. 문제 시나리오: 왜 시스템 콜을 제대로 알아야 하나
  2. 시스템 콜과 glibc 래퍼
  3. 자주 쓰는 시스템 콜
  4. 완전한 syscall 예제
  5. C++에서 RAII·에러 처리
  6. 자주 발생하는 에러와 해결법
  7. 성능 최적화
  8. 프로덕션 패턴
  9. 정리

개념을 잡는 비유

C++에서 자주 쓰는 비유로, 템플릿은 붕어빵 틀, 스마트 포인터는 자동 청소 로봇, RAII는 자동문처럼 스코프를 나가며 자원을 정리한다고 기억해 두면 다른 글과도 연결하기 쉽습니다.


1. 문제 시나리오: 왜 시스템 콜을 제대로 알아야 하나

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

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

원인: open() 실패 시 errnoENOENT(파일 없음), EACCES(권한 없음), EMFILE(프로세스 파일 디스크립터 한도 초과), EINTR(시그널에 의해 중단) 등이 설정됩니다. errno를 확인하지 않으면 실패 원인을 알 수 없고, EINTR은 재시도하지 않으면 일시적 실패로 처리됩니다.

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

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

원인: open()·socket()close()를 호출하지 않거나, 예외 발생 시 경로를 빠져나가면서 close가 실행되지 않으면 fd 누수가 발생합니다. RAII로 fd를 감싸면 소멸자에서 자동으로 close가 호출됩니다.

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

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

원인: read()0 = EOF(정상 종료), -1 = 에러(이때 errno 확인), 양수 = 읽은 바이트 수입니다. 0을 에러로 처리하면 정상 종료를 놓치고, -1을 무시하면 에러가 전파됩니다.

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

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

원인: read()·write()·accept()블로킹 시스템 콜시그널에 의해 중단될 수 있습니다. 이때 errno == EINTR이 설정되고, 재시도하면 됩니다. SA_RESTART를 사용하면 자동 재시도되지만, 모든 콜은 지원하지 않습니다.

시나리오 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

2. 시스템 콜과 glibc 래퍼

사용자 → 커널 경계

  • 시스템 콜: CPU가 커널 모드로 전환하고, 커널이 요청을 처리한 뒤 반환합니다. 시그니처·번호는 아키텍처별로 정의되어 있고, syscall(SYS_xxx, …) 로 직접 호출할 수 있지만, 대부분 glibc가 제공하는 open, read, write, socket래퍼를 씁니다.
  • 에러: 대부분의 래퍼는 실패 시 -1(또는 NULL 등)을 반환하고 errno를 설정합니다. C++에서는 errno를 바로 쓰기보다 한 번 확인 후 std::error_code예외로 변환하는 레이어를 두면 일관된 오류 처리가 됩니다.
  • 시그널: 일부 시스템 콜은 시그널에 의해 중단될 수 있습니다(EINTR). 루프로 재시도하거나 SA_RESTART 등으로 처리하는 것이 좋습니다.

시스템 콜 흐름 개요

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: syscall(SYS_open, ...)
    Kernel->>Kernel: 파일 열기 처리
    Kernel->>Glibc: fd 또는 -errno
    Glibc->>Glibc: errno 설정
    Glibc->>App: fd 또는 -1

glibc 래퍼를 쓰는 이유:

  • 시그널 재시도: 일부 래퍼는 EINTR 시 자동 재시도
  • ABI 안정성: syscall 번호는 아키텍처별로 다를 수 있음
  • 가독성: open(path, flags)syscall(SYS_open, path, flags)보다 명확

syscall(2) 직접 사용하는 경우:

  • 새로운 syscall이 glibc에 래퍼가 없을 때
  • 최소 의존성 (예: musl, 임베디드)

3. 자주 쓰는 시스템 콜

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

  • open / read / write / close: 파일·디바이스 접근. O_NONBLOCK으로 논블로킹 모드.
  • mmap / munmap: 파일·anon을 메모리에 매핑. 대용량 파일 접근·공유 메모리에 사용.
  • poll / epoll (epoll은 리눅스 전용): 여러 fd의 이벤트 대기. 29번·30번의 비동기 I/O 기반이 됩니다.
  • clone / fork / exec: 프로세스 생성. C++에서는 forkexec 전에 가상 메모리·스레드 관련 주의가 필요합니다(멀티스레드 프로그램에서 fork는 위험할 수 있음).

fork/exec 주의사항

// ❌ 위험: 멀티스레드에서 fork 후 exec
// fork 시 자식은 호출 스레드만 복사, 다른 스레드의 뮤텍스·락 상태가 불일치
// ✅ 안전: fork 직후 exec만 호출 (중간에 로직 없음)
pid_t pid = fork();
if (pid == 0) {
    execl("/bin/ls", "ls", "/tmp", nullptr);
    _exit(127);
}

주의: fork()exec() 전에 할당된 메모리·파일·스레드를 사용하면 안 됩니다. O_CLOEXEC로 fd를 열면 exec 시 자동 close됩니다.


4. 완전한 syscall 예제

예제 1: EINTR 안전 read 래퍼

#include <cerrno>
#include <cstring>
#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);
    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) {
        throw std::system_error(errno, std::system_category(), "read failed");
    }
    close(fd);
    return result;
}

포인트: read()가 EINTR로 중단되면 재시도하면 됩니다. SA_RESTART를 쓰지 않아도 이 래퍼로 안전합니다.

예제 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) {
        close(src_fd);
        throw std::runtime_error("open dst: " + std::string(strerror(errno)));
    }
    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) {
                close(src_fd);
                close(dst_fd);
                throw std::runtime_error("write: " + std::string(strerror(errno)));
            }
            written += w;  // w > 0: 쓴 바이트 수
        }
    }
    if (n == -1) {
        close(src_fd);
        close(dst_fd);
        throw std::runtime_error("read: " + std::string(strerror(errno)));
    }
    close(src_fd);
    close(dst_fd);
}

주의: read() 실패(n == -1)는 루프 종료 후 확인합니다. write()한 번에 전체를 쓰지 않을 수 있음. written < n 루프로 전부 쓸 때까지 반복해야 합니다. write()EINTR 가능성이 있으므로 재시도가 필요합니다.

예제 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) {
        close(fd);
        throw std::runtime_error("fstat: " + std::string(strerror(errno)));
    }
    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);
    close(fd);  // mmap 후 fd는 닫아도 매핑 유지
    if (addr == MAP_FAILED) {
        throw std::runtime_error("mmap: " + std::string(strerror(errno)));
    }
    return {addr, size};
}

// 사용 후 반드시 munmap 호출
void unmap_file(void* addr, size_t size) {
    if (addr && size > 0) {
        munmap(addr, size);
    }
}

포인트: mmapfd는 닫아도 매핑은 유지됩니다. 사용 후 munmap을 반드시 호출해야 합니다.

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

#include <sys/syscall.h>
#include <unistd.h>

// glibc 래퍼 없이 open 직접 호출 (교육용)
int open_syscall(const char* path, int flags, int mode) {
    return static_cast<int>(syscall(SYS_open, path, flags, mode));
}

// 사용: 대부분 open(2) 래퍼를 쓰는 것이 권장됨

참고: syscall(2)SYS_open 등 아키텍처별 syscall 번호를 사용합니다. 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;
}

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

#include <sys/epoll.h>
#include <unistd.h>
#include <cerrno>
#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;
    }
};

epoll 사용 시점: 동시 연결 수가 1000개 이상일 때 poll보다 효율적입니다.

빌드 및 실행

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

# 실행
./syscall_demo

필요 헤더: <unistd.h>, <fcntl.h>, <sys/mman.h>, <sys/epoll.h>, <poll.h> 등은 POSIX 환경에서 제공됩니다. Windows에서는 #ifdef __linux__ 등으로 분기하거나 WSL을 사용하세요.

자주 쓰는 errno 값

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

5. C++에서 RAII·에러 처리

fd·메모리 매핑을 객체로

  • 파일 디스크립터RAII 클래스로 감싸면, 소멸자에서 close를 호출해 누수를 방지할 수 있습니다. 이동을 지원하고 복사는 막는 식으로 설계하면 안전합니다.
  • 에러: open 등이 실패하면 -1errno를 받으므로, optional<File> 또는 expected<File, std::error_code>를 반환하는 open_file 같은 래퍼를 두면 예외 없이(42-1 스타일) 쓸 수 있습니다.
  • mmap 영역도 munmap을 소멸자에서 호출하는 MmapRegion 같은 클래스로 감싸면 됩니다.

UniqueFd: RAII 파일 디스크립터

UniqueFd는 파일 디스크립터를 소유하고, 소멸자에서 fd_ >= 0 일 때만 close(fd_) 를 호출해 누수를 막습니다. 이동 생성자에서 std::exchange(o.fd_, -1) 로 원본의 fd 를 가져오고 원본은 -1 로 두어, 소멸 시 close 가 한 번만 호출되게 합니다. 복사는 막고(선언하지 않음) get() 으로 fd 만 노출하면 open 이 반환한 fd 를 이렇게 감싸서 사용할 수 있습니다.

#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_; }
};

6. 자주 발생하는 에러와 해결법

에러 1: EINTR 무시

증상: read()·write()·accept() 등이 가끔 -1을 반환하고 errno == EINTR입니다.

원인: 시그널 핸들러가 등록된 상태에서 블로킹 시스템 콜이 중단되면 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 호출
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;
}

에러 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()으로 매핑한 영역을 munmap()으로 해제하지 않았습니다.

해결법:

// ❌ 잘못된 예: munmap 누락
void* addr = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
use(addr);
// munmap 없음!

// ✅ 올바른 예: RAII로 감싸기
MmapRegion region(mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0), size);
if (region.data() == MAP_FAILED) return -1;
use(region.data());
// 소멸자에서 munmap 자동 호출

에러 5: errno 덮어쓰기

증상: open() 실패 후 strerror(errno)를 호출했는데 잘못된 메시지가 나옵니다.

원인: 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 상속

증상: 자식 프로세스에서 fd가 열려 있어서 부모가 close한 fd를 자식이 사용합니다.

원인: fork() 시 fd가 자식에게 복사됩니다. exec 전에 close하지 않으면 fd가 유지됩니다.

해결법:

// ✅ exec 전에 fd_CLOEXEC 설정 또는 close
int fd = open(path, O_RDONLY | O_CLOEXEC);  // exec 시 자동 close
// 또는 fork 후 exec 전에 close

7. 성능 최적화

버퍼 크기 선택

// 작은 버퍼: 시스템 콜 횟수 증가
char buf[256];   // read/write 호출 4배
char buf[4096];  // 일반적인 권장
char buf[65536]; // 64KB: 대용량 파일에 적합

권장: 대용량 파일 복사·스트리밍에는 64KB~1MB 버퍼가 적당합니다. 너무 크면 캐시 미스가 증가합니다.

mmap vs read

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

mmap이 유리한 경우: 랜덤 접근, 대용량 파일, 공유 메모리
read가 유리한 경우: 순차 스트리밍, 작은 파일

O_DIRECT (직접 I/O)

// O_DIRECT: 페이지 캐시 우회, DMA 직접 전송
int fd = open(path, O_RDONLY | O_DIRECT);
// 주의: 버퍼 정렬 필요 (보통 512바이트 또는 4096바이트)

사용 시점: 대용량 순차 I/O, DB·스토리지 엔진

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과 함께 사용. 블로킹을 피하고 이벤트 루프에서 처리할 때 사용합니다.

시스템 콜 비용 이해

시스템 콜은 사용자 모드 → 커널 모드 전환이 발생하므로, 호출 자체에 수백 나노초~수 마이크로초의 오버헤드가 있습니다. 따라서:

  • 작은 read/write 반복보다 큰 버퍼로 한 번에 읽고 쓰는 것이 효율적입니다.
  • mmap은 매핑 시 한 번의 syscall, 이후 메모리 접근은 페이지 폴트 시에만 커널 진입이므로, 랜덤 접근이 많을 때 유리합니다.
  • io_uring(리눅스 5.1+)은 배치 제출·완료로 syscall 횟수를 줄이는 최신 인터페이스입니다.

poll vs epoll

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

epoll 권장: 동시 연결 수가 많을 때 (예: 1000 이상)

벤치마크 참고 (대략적)

작업버퍼 4KB버퍼 64KBmmap
100MB 순차 읽기~25ms~8ms~5ms
100MB 순차 쓰기~30ms~10ms~6ms
랜덤 접근 1만 회N/AN/A~2ms

실제 수치는 디스크·SSD·캐시 상태에 따라 다릅니다.


8. 프로덕션 패턴

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

#include <expected>
#include <system_error>

std::expected<UniqueFd, std::error_code> open_file(const char* path) {
    int fd = open(path, O_RDONLY);
    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(std::forward<Args>(args)...)) result;
    do {
        result = f(std::forward<Args>(args)...);
    } while (result == -1 && errno == EINTR);
    return result;
}

// 사용
ssize_t n = retry_on_eintr(read, fd, buf, size);

패턴 3: ScopedFd + scope guard

// 범위를 벗어나면 자동 close
void process_file(const char* path) {
    int fd = open(path, O_RDONLY);
    if (fd == -1) return;
    auto guard = [fd]() { close(fd); };
    // ... 또는 UniqueFd 사용
}

패턴 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());
    }
    return {};
}

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

# 프로세스 실행 시 시스템 콜 추적
strace -e trace=open,read,write,close ./my_program

# 파일 관련만 추적
strace -e trace=file ./my_program

# 실행 중인 프로세스에 attach
strace -p <pid> -e trace=read,write

활용: open() 실패 시 errno 확인, read()/write() 호출 횟수·크기 분석, fd 누수 추적에 유용합니다.

프로덕션 체크리스트

  • 모든 open/socket 후 RAII 또는 명시적 close
  • read/write/accept 등 EINTR 재시도
  • write 부분 쓰기 루프
  • mmapmunmap (RAII 권장)
  • errno 확인 전 다른 함수 호출 금지
  • fork 후 fd 정리 또는 O_CLOEXEC
  • 버퍼 크기 4KB 이상 (대용량 I/O)
  • 에러 로깅 시 errno 즉시 저장

9. 정리

항목요약
시스템 콜glibc 래퍼·errno·EINTR 재시도
자주 쓰는 콜open/read/write, mmap, poll/epoll
C++RAII로 fd·mmap 관리, error_code·expected로 오류 전달
자주 발생하는 에러EINTR 무시, write 부분 쓰기, fd 누수, munmap 누락
성능버퍼 크기, mmap vs read, O_DIRECT, epoll
프로덕션expected 반환, EINTR 래퍼, 로깅

42번 시리즈는 제약된 환경(예외·RTTI 없음)하드웨어 제어(volatile·MMIO·ISR)리눅스 시스템 콜로 임베디드·시스템 프로그래밍의 기초를 마쳤습니다.

실전 적용 순서

  1. UniqueFd·MmapRegion 같은 RAII 래퍼를 먼저 도입해 fd·mmap 누수를 방지합니다.
  2. read·write·accept 등에 EINTR 재시도를 적용합니다.
  3. write 부분 쓰기 루프를 모든 쓰기 경로에 추가합니다.
  4. errno 확인 시 즉시 변수에 저장한 뒤 사용합니다.
  5. 필요 시 expected·error_code로 에러 전파 체계를 정리합니다.

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • Rust vs C++ 메모리 안전성 | 컴파일러 오류 차이 [#47-3]
  • C++와 Rust: 두 언어의 상호 운용성과 Memory Safety 논쟁의 실체 [#44-2]
  • C++ Valgrind 완벽 가이드 | Memcheck·누수 탐지

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


이 글에서 다루는 키워드 (관련 검색어)

Linux 시스템 콜, syscall, C++ 시스템 프로그래밍, errno, EINTR, RAII 파일 디스크립터, mmap 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++에서 리눅스 시스템 콜을 어떻게 호출하고, errno·래퍼·커널 인터페이스와의 관계를 이해하는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다. 파일 I/O, 네트워크 서버, 임베디드 리눅스 등에서 사용됩니다.

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

A. 시그널 핸들러가 등록된 상태에서 read()·write() 등 블로킹 시스템 콜이 중단되면 errno == EINTR이 설정됩니다. 이는 “에러”가 아니라 “일시적 중단”이므로, 같은 인자로 다시 호출하면 됩니다. 재시도하지 않으면 일시적 실패로 처리되어 데이터 손실이 발생할 수 있습니다.

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

A. 랜덤 접근이 많거나 대용량 파일을 한 번에 매핑하면 mmap이 유리합니다. 순차 스트리밍이나 작은 파일에서는 read가 단순하고 효율적입니다. 공유 메모리가 필요하면 mmap의 MAP_SHARED를 사용합니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

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

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

Q. 더 깊이 공부하려면?

A. man7.org Linux man-pages에서 open(2), read(2), mmap(2) 등을 참고하세요. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

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

A. ulimit -n으로 프로세스당 열 수 있는 fd 수를 확인·설정합니다. 서버는 수천 개 연결을 처리할 수 있도록 ulimit -n 65535 등으로 늘리는 경우가 많습니다. /etc/security/limits.conf에서 영구 설정이 가능합니다.

참고 자료

한 줄 요약: 시스템 콜·커널 인터페이스를 이해하면 리눅스 환경 C++ 디버깅이 수월해집니다. 다음으로 gRPC·Protobuf(#43-1)를 읽어보면 좋습니다.

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

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


관련 글

  • C++ 제약된 환경에서의 C++: Exception과 RTTI 없이 안전한 코드 짜기 [#42-1]
  • C++ noexcept 완벽 가이드 | 예외 계약·이동 최적화·프로덕션 패턴 [#42-1]
  • C++ 직접적인 하드웨어 제어: volatile, 메모리 맵 I/O, 인터럽트 서비스 루틴 [#42-2]
  • C++ 정적 분석 도구 통합: Clang-Tidy와 Cppcheck로 코드 퀄리티 강제하기 [#41-1]
  • C++ volatile 완벽 가이드 | MMIO·ISR·메모리 맵 레지스터·atomic과의 차이 [실전]