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) 변환
목차
- 문제 시나리오: 왜 시스템 콜을 제대로 알아야 하나
- 시스템 콜과 glibc 래퍼
- 자주 쓰는 시스템 콜
- 완전한 syscall 예제
- C++에서 RAII·에러 처리
- 자주 발생하는 에러와 해결법
- 성능 최적화
- 프로덕션 패턴
- 정리
개념을 잡는 비유
C++에서 자주 쓰는 비유로, 템플릿은 붕어빵 틀, 스마트 포인터는 자동 청소 로봇, RAII는 자동문처럼 스코프를 나가며 자원을 정리한다고 기억해 두면 다른 글과도 연결하기 쉽습니다.
1. 문제 시나리오: 왜 시스템 콜을 제대로 알아야 하나
시나리오 1: “파일 열 때 가끔 실패해요”
"로컬에서는 잘 되는데, 프로덕션에서 open()이 -1을 반환해요."
"errno가 뭔지 모르겠고, 재시도하면 되기도 해요."
원인: open() 실패 시 errno에 ENOENT(파일 없음), 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++에서는 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() 후 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);
}
}
포인트: mmap 후 fd는 닫아도 매핑은 유지됩니다. 사용 후 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 | 잘못된 fd | fd가 이미 close됨 |
5. C++에서 RAII·에러 처리
fd·메모리 매핑을 객체로
- 파일 디스크립터를 RAII 클래스로 감싸면, 소멸자에서 close를 호출해 누수를 방지할 수 있습니다. 이동을 지원하고 복사는 막는 식으로 설계하면 안전합니다.
- 에러: open 등이 실패하면 -1과 errno를 받으므로, 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/write | mmap |
|---|---|---|
| 랜덤 접근 | 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
| 구분 | poll | epoll |
|---|---|---|
| fd 수 | 수십 개 | 수천~수만 개 |
| 복사 | 매번 전체 fd 배열 전달 | 커널이 내부 상태 유지 |
| 이벤트 | level-triggered | level/edge 선택 가능 |
| 이식성 | POSIX | 리눅스 전용 |
epoll 권장: 동시 연결 수가 많을 때 (예: 1000 이상)
벤치마크 참고 (대략적)
| 작업 | 버퍼 4KB | 버퍼 64KB | mmap |
|---|---|---|---|
| 100MB 순차 읽기 | ~25ms | ~8ms | ~5ms |
| 100MB 순차 쓰기 | ~30ms | ~10ms | ~6ms |
| 랜덤 접근 1만 회 | N/A | N/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부분 쓰기 루프 -
mmap후munmap(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) → 리눅스 시스템 콜로 임베디드·시스템 프로그래밍의 기초를 마쳤습니다.
실전 적용 순서
- UniqueFd·MmapRegion 같은 RAII 래퍼를 먼저 도입해 fd·mmap 누수를 방지합니다.
- read·write·accept 등에 EINTR 재시도를 적용합니다.
- write 부분 쓰기 루프를 모든 쓰기 경로에 추가합니다.
- errno 확인 시 즉시 변수에 저장한 뒤 사용합니다.
- 필요 시 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에서 영구 설정이 가능합니다.
참고 자료
- Linux man-pages —
open(2),read(2),write(2),mmap(2),poll(2),epoll(7) - The Linux Programming Interface (Michael Kerrisk) — 시스템 콜 상세 설명
- syscall(2) man page — 직접 syscall 호출
한 줄 요약: 시스템 콜·커널 인터페이스를 이해하면 리눅스 환경 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과의 차이 [실전]