C · 심화
포인터·메모리 안전·시스템 프로그래밍
파일 디스크립터와 시스템 호출 - stdio 아래층 보기
open/read/write/close, 짧은 읽기·쓰기 반복 처리, stdio 버퍼링과 섞어 쓸 때 순서 문제, strace 로 호출 보기, 원자적 파일 교체(rename)
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 우리는 오류를 일관되게 처리하는 규약을 만들고 반환값과 전역 변수 errno를 다루는 방법을 익혔다. 이번 장에서는 운영체제와 직접 소통하는 입출력 방식을 배운다. 우리는 지금까지 파일을 읽고 쓸 때 C 표준 라이브러리가 제공하는 FILE 구조체와 관련 함수들을 사용했다. 이 함수들은 프로그래머의 편의를 위해 복잡한 세부 사항을 감추고 메모리에 데이터를 모아서 처리해 준다. 하지만 프로그램의 신뢰성을 높이고 예외적인 입출력 상황에 대처하려면, 그 아래층에서 무슨 일이 일어나는지 알아야 한다.
- 운영체제가 파일을 관리하는 파일 디스크립터 구조를 이해한다.
- open, read, write, close 시스템 호출을 직접 사용해 파일을 제어한다.
- 짧은 읽기와 쓰기 상황을 반복문으로 안전하게 처리하는 방법을 익힌다.
- 파일 버퍼링의 원리를 이해하고 시스템 호출과 섞어 쓸 때의 문제를 방지한다.
- 임시 파일과 원자적 교체를 이용해 데이터를 안전하게 저장하는 패턴을 적용한다.
문제 상황
우리는 도서관 대출 관리 프로그램을 운영하고 있다. 새로운 도서가 대출될 때마다 회원 이름과 책 제목을 텍스트 파일 데이터베이스에 한 줄씩 기록한다. 어느 날 대출 기록이 한창 쌓이고 있던 중 서버 컴퓨터의 전원이 갑자기 차단되었다. 시스템이 다시 켜진 후 대출 프로그램을 실행하자 파일을 제대로 읽어 들이지 못하고 프로그램이 비정상 종료되었다. 데이터 파일을 열어 확인해 보니, 마지막 대출 기록이 "User: user1, Book: C "까지만 쓰인 채 끊어져 있었다. 데이터를 파일에 쓰는 도중에 프로그램이 죽어버린 것이다.
또한 디버깅을 위해 로그를 출력하는 코드에서도 이상한 점을 발견했다. 프로그램의 흐름을 확인하려고 화면에 메시지를 출력할 때, 표준 I/O 함수와 저수준 파일 입출력 함수를 섞어서 호출했다. 소스 코드 상으로는 분명히 "작업 시작"을 먼저 기록하고 "데이터 저장"을 나중에 기록하도록 작성했는데, 터미널이나 파일에 남은 로그는 "데이터 저장"이 먼저 나오고 "작업 시작"이 나중에 나오는 기묘한 순서 역전 현상이 일어났다.
이 두 가지 문제는 모두 우리가 파일 입출력의 가장 밑바탕을 지탱하는 운영체제의 메커니즘을 제대로 제어하지 못해서 발생한다. 이 장에서는 C 라이브러리가 숨겨둔 아래층으로 내려가 파일 디스크립터와 시스템 호출을 직접 조작해 문제를 해결해 본다.
파일 디스크립터와 커널의 파일 테이블
운영체제 커널은 실행 중인 각 프로세스가 어떤 파일을 열고 있는지 추적한다. 프로세스가 커널에게 새로운 파일을 열어 달라고 요청하면, 커널은 자신의 메모리 공간에 열린 파일의 상태와 오프셋을 기록하는 테이블을 만들고 프로세스에게 양의 정수 하나를 반환한다. 이 정수표를 파일 디스크립터(File Descriptor)라고 부른다. 앞으로 프로세스는 커널에 데이터를 읽거나 쓸 때 이 정수표만 제시하면 된다.
유닉스와 리눅스 환경에서는 프로그램이 실행될 때 기본적으로 세 개의 파일 디스크립터가 열린다. 0번은 입력을 받는 표준 입력, 1번은 정상적인 결과를 내보내는 표준 출력, 2번은 오류 메시지를 내보내는 표준 에러다. 화면에 글자를 찍는 표준 라이브러리 함수들도 최종적으로는 이 1번 디스크립터를 향해 데이터를 기록해 달라고 커널에 요청하는 구조다.
표준 I/O 라이브러리는 이 파일 디스크립터를 내부에 감싸고 메모리 버퍼를 더한 FILE 구조체를 제공한다. 반면 시스템 호출 함수들은 파일 디스크립터 정수를 직접 넘겨받아 커널로 진입한다.
| 기능 | 표준 I/O (C 라이브러리) | 시스템 호출 (POSIX) |
|---|---|---|
| 파일 열기 | fopen() | open() |
| 데이터 읽기 | fread(), fgets() | read() |
| 데이터 쓰기 | fwrite(), fprintf() | write() |
| 파일 닫기 | fclose() | close() |
시스템 호출로 직접 파일 제어하기
시스템 호출인 open 함수를 사용하려면 fcntl.h 헤더를 포함해야 한다. open 함수는 첫 번째 인자로 파일 경로를 받고, 두 번째 인자로 파일을 어떤 목적과 모드로 열지 지시하는 플래그를 비트 단위 연산자(OR)로 묶어서 받는다. 만약 파일을 새로 생성하는 O_CREAT 플래그를 사용했다면, 세 번째 인자로 파일의 접근 권한을 지정해야 한다. 권한은 주로 0644(소유자 읽기/쓰기, 나머지 읽기) 같은 8진수로 지정한다.
| 플래그 기호 | 설명 |
|---|---|
| O_RDONLY | 파일을 읽기 전용으로 연다. |
| O_WRONLY | 파일을 쓰기 전용으로 연다. |
| O_RDWR | 파일을 읽기와 쓰기 모두 가능하도록 연다. |
| O_CREAT | 파일이 존재하지 않으면 새로 만든다. |
| O_TRUNC | 파일이 이미 존재하면 기존 내용을 모두 지우고 길이를 0으로 만든다. |
| O_APPEND | 파일의 끝부분에 데이터를 이어 쓴다. |
파일을 연 후에는 반환된 정수값이 0보다 크거나 같은지 반드시 검사해야 한다. 파일이 없거나 권한이 부족하면 커널은 -1을 반환하고 실패 원인을 전역 변수 errno에 기록한다.
짧은 읽기(Short Read)와 쓰기(Short Write) 방어
파일 디스크립터로 통신할 때 가장 주의해야 할 점은 짧은 읽기와 짧은 쓰기 현상이다. 프로그래머가 커널에 1,024바이트를 써 달라고 write 함수를 호출해도, 커널은 네트워크 혼잡, 파이프 용량 제한, 디스크 공간 부족, 또는 운영체제 시그널 인터럽트 발생 등의 이유로 일부인 500바이트만 처리하고 반환할 수 있다. 이때 write 함수는 자신이 성공적으로 쓴 바이트 수인 500을 반환한다.
이를 무시하고 다음 코드로 넘어가면 남은 524바이트는 기록되지 않고 유실된다. 따라서 read나 write 시스템 호출은 항상 반환값을 검사하여 남은 바이트를 다시 요청하는 루프를 만들어야 한다. 또한 호출 도중 운영체제의 시그널을 받아 작업이 중단된 경우, 반환값은 -1이 되고 errno는 EINTR로 설정된다. 이 오류는 심각한 문제가 아니라 일시적인 인터럽트이므로 반복문 안에서 다시 시도하도록 예외 처리해야 한다.
표준 버퍼링과 시스템 호출 혼용의 함정
표준 I/O 라이브러리는 디스크 접근 횟수를 줄여 성능을 높이기 위해 내부에 메모리 버퍼를 둔다. 반면 시스템 호출인 write는 이 버퍼를 무시하고 즉시 커널로 데이터를 보낸다. 만약 하나의 프로그램에서 fprintf와 write를 섞어서 쓰면 어떻게 될까. fprintf로 넘긴 데이터는 사용자 메모리 공간의 버퍼에 쌓여서 아직 디스크로 가지 않은 상태인데, write로 넘긴 데이터는 즉시 커널 공간으로 직행하여 파일에 기록된다. 결국 소스 코드에서 나중에 작성된 write 데이터가 먼저 출력되고, 프로그램이 종료되거나 명시적으로 fflush를 호출할 때 버퍼가 비워지면서 fprintf 데이터가 뒤늦게 출력되는 역전 현상이 일어난다.
이 문제를 막으려면 두 가지 방식을 섞어 쓰지 않거나, 시스템 호출을 하기 직전에 fflush 함수를 명시적으로 호출해 버퍼에 고인 데이터를 먼저 커널로 내려보내야 한다.
데이터 손상을 막는 원자적 파일 교체
문제 상황에서 언급했듯, 파일을 열어 직접 덮어쓰는 도중 프로그램이 죽으면 데이터베이스가 반 토막 나는 심각한 손상이 발생한다. 이를 방지하려면 운영체제 파일 시스템의 원자적(atomic) 속성을 활용해야 한다. 원자적 연산이란 연산이 완전히 수행되거나 아예 수행되지 않은 두 가지 상태만 존재하며, 중간 상태가 외부에 관찰되지 않음을 뜻한다.
데이터를 갱신할 때는 먼저 임시 파일(예: db.txt.tmp)을 생성하고 새로운 데이터를 모두 쓴다. 파일 닫기까지 성공적으로 완료되면, 그제야 rename 시스템 호출을 사용해 임시 파일의 이름을 원본 파일(db.txt)로 덮어씌운다. POSIX 호환 시스템에서 rename은 원자적 교체를 보장한다. 교체 도중 정전이 일어나도 기존 원본 파일이 그대로 남아 있거나 완전히 새로운 파일로 대체되어 있을 뿐, 데이터가 반쯤 섞인 상태는 만들어지지 않는다.
시스템 호출을 엿보는 strace 도구
프로그램이 커널과 어떻게 대화하는지 눈으로 직접 확인하고 싶다면 strace(리눅스)나 dtruss(macOS) 같은 추적 도구를 사용할 수 있다. 이 도구들은 프로그램이 실행되는 동안 발생시키는 모든 시스템 호출의 이름, 전달된 인자, 그리고 반환값을 화면에 실시간으로 나열한다. 버그의 원인이 내 코드에 있는지, 파일 권한 문제인지, 아니면 커널이 요청을 거부한 것인지 파악할 때 매우 강력한 디버깅 수단이 된다.
완성 코드
library_loan.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
void show_buffering_issue(void) {
/* stdio 라이브러리의 버퍼에 기록된다. */
fprintf(stdout, "1. stdio print (buffered) - ");
/* 커널에 직접 전달되므로 버퍼를 우회하여 즉시 출력된다. */
const char *msg = "2. syscall write (unbuffered)\n";
write(STDOUT_FILENO, msg, strlen(msg));
/* 개행 문자를 만나거나 명시적으로 비워질 때 앞선 버퍼 내용이 출력된다. */
fprintf(stdout, "3. end of stdio print\n");
}
int write_loan_atomic(const char *target_file, const char *data) {
char tmp_file[256];
snprintf(tmp_file, sizeof(tmp_file), "%s.tmp", target_file);
/* 임시 파일을 생성하고 쓰기 전용으로 연다. 기존 내용이 있다면 비운다. */
int fd = open(tmp_file, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("임시 파일 열기 실패");
return -1;
}
size_t left = strlen(data);
const char *ptr = data;
/* 짧은 쓰기(Short Write)를 방어하는 반복문 */
while (left > 0) {
ssize_t written = write(fd, ptr, left);
if (written < 0) {
/* 시그널로 인한 인터럽트는 다시 시도한다. */
if (errno == EINTR) {
continue;
}
perror("파일 쓰기 실패");
close(fd);
return -1;
}
ptr += written;
left -= written;
}
/* 모든 데이터를 쓴 후 파일을 닫는다. */
if (close(fd) < 0) {
perror("파일 닫기 실패");
return -1;
}
/* 임시 파일을 원본 파일 이름으로 원자적 교체한다. */
if (rename(tmp_file, target_file) < 0) {
perror("파일 이름 변경 실패");
return -1;
}
return 0;
}
int read_loan_file(const char *target_file) {
/* 읽기 전용으로 파일을 연다. */
int fd = open(target_file, O_RDONLY);
if (fd < 0) {
perror("파일 열기 실패");
return -1;
}
char buf[128];
size_t total_read = 0;
/* 짧은 읽기(Short Read)를 방어하는 반복문 */
while (1) {
ssize_t n = read(fd, buf + total_read, sizeof(buf) - 1 - total_read);
if (n < 0) {
if (errno == EINTR) {
continue;
}
perror("파일 읽기 실패");
close(fd);
return -1;
}
if (n == 0) {
break; /* 파일 끝(EOF) 도달 */
}
total_read += n;
if (total_read >= sizeof(buf) - 1) {
break; /* 버퍼 가득 참 */
}
}
buf[total_read] = '\0'; /* 문자열의 끝을 표시한다. */
close(fd);
const char *msg = "\n[파일 읽기 결과]\n";
write(STDOUT_FILENO, msg, strlen(msg));
write(STDOUT_FILENO, buf, total_read);
return 0;
}
int main(void) {
show_buffering_issue();
const char *db_file = "loan_db.txt";
const char *record = "User: Kim, Book: The C Programming Language\n";
if (write_loan_atomic(db_file, record) == 0) {
read_loan_file(db_file);
}
return 0;
}
줄별 해설
10~19줄: 버퍼링의 차이를 보여주는 함수다. fprintf는 데이터를 프로세스의 힙 영역에 있는 FILE 구조체 버퍼에 보관하지만, 뒤따라오는 write 시스템 호출은 커널 영역으로 데이터를 즉시 쏴버린다. 그 결과 2번 문자열이 1번 문자열보다 화면에 먼저 인쇄되는 것을 볼 수 있다.
22~26줄: 원본 파일 이름에 ".tmp"를 붙여 임시 파일을 만든다. O_WRONLY(쓰기 전용), O_CREAT(없으면 생성), O_TRUNC(있으면 비우기) 플래그를 결합해 파일을 연다. 권한은 0644로 지정해 소유자만 쓸 수 있게 설정한다.
36~49줄: 짧은 쓰기에 대비하는 핵심 루프다. write가 반환한 실제 기록 바이트 수만큼 포인터 ptr을 전진시키고 남은 바이트 left를 줄인다. 도중에 시그널을 받아 EINTR 오류가 발생하면 루프의 맨 처음으로 돌아가 재시도한다.
52~55줄: 모든 데이터를 쓴 후 파일을 닫아 커널의 자원을 반환한다. 닫기에 실패하는 경우도 있으므로 반환값을 확인한다.
58~61줄: 작성이 끝난 임시 파일의 이름을 원본 데이터베이스 파일 이름으로 변경한다. 이 rename 호출은 원자적이므로 시스템이 중간에 멈춰도 파일이 반만 덮어씌워지는 사태를 방지한다.
76~92줄: 파일을 읽을 때도 마찬가지로 짧은 읽기를 방어한다. 남은 버퍼 공간을 계산하여 read 함수에 넘기고 반환된 바이트 수를 total_read에 누적한다. 반환값이 0이면 더 이상 읽을 데이터가 없는 끝(EOF)에 도달한 것이다.
실행 결과
$ cc -std=c17 -Wall -Wextra library_loan.c
$ ./a.out
2. syscall write (unbuffered)
1. stdio print (buffered) - 3. end of stdio print
[파일 읽기 결과]
User: Kim, Book: The C Programming Language
실무에서 자주 틀리는 것
반환값 검사 없이 덮어쓰기 시도
파일 열기에 실패했을 때 반환값을 확인하지 않고 파일 디스크립터를 그대로 write 함수에 넘기는 경우가 많다. 이 경우 디스크립터 값으로 -1이 넘어가게 되고 호출은 실패한다.
// 틀린 코드
int fd = open("db.txt", O_WRONLY);
write(fd, data, strlen(data)); // fd가 -1이면 오류 발생
// 고친 코드
int fd = open("db.txt", O_WRONLY);
if (fd < 0) {
perror("open error");
return -1;
}
// 안전하게 write 진행
짧은 I/O를 고려하지 않은 단일 호출
데이터 크기가 작을 것이라 지레짐작하고 한 번의 호출로 끝내려 하면, 예기치 않은 인터럽트나 네트워크 지연이 발생할 때 데이터가 소실된다.
// 틀린 코드
write(fd, data, total_length); // 일부만 쓰이고 끝날 수 있다.
// 고친 코드
size_t left = total_length;
const char *p = data;
while (left > 0) {
ssize_t n = write(fd, p, left);
if (n < 0 && errno == EINTR) continue;
if (n < 0) break;
p += n; left -= n;
}
임시 파일 없이 직접 덮어쓰기
설정 파일이나 중요한 데이터베이스를 덮어쓸 때 O_TRUNC 플래그로 기존 파일을 열면 내용이 즉시 지워진다. 이후 쓰는 도중에 오류가 나거나 전원이 나가면 과거 데이터까지 모두 잃게 된다.
// 틀린 코드
int fd = open("config.txt", O_WRONLY | O_TRUNC);
// 이 시점에 이미 파일 내용은 증발했다. 쓰기 실패 시 복구 불가.
// 고친 코드
int fd = open("config.tmp", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// 임시 파일에 모두 쓴 뒤 닫는다.
rename("config.tmp", "config.txt"); // 안전하게 교체
한눈에 보기
| 함수 | 성공 시 반환 | 실패 시 반환 | 주요 오류 (errno) |
|---|---|---|---|
| open() | 파일 디스크립터 정수 (0 이상) | -1 | EACCES (권한 없음), ENOENT (파일 없음) |
| read() | 실제로 읽은 바이트 수 (0은 EOF) | -1 | EINTR (시그널 중단), EBADF (잘못된 FD) |
| write() | 실제로 기록한 바이트 수 | -1 | EINTR (시그널 중단), ENOSPC (용량 부족) |
| close() | 0 | -1 | EBADF (이미 닫힘 또는 잘못된 FD) |
연습 문제
- 운영체제가 프로세스에 할당하는 0번, 1번, 2번 파일 디스크립터의 역할은 각각 무엇인가?
- write 함수 호출 시 남은 바이트 수를 관리하며 반복문을 도는 이유는 무엇인가?
- 설정 파일을 저장할 때 원자적 교체(Atomic Rename) 패턴을 사용하는 이유는 무엇인가?
- 표준 I/O 함수인 fprintf와 시스템 호출인 write를 섞어서 사용할 때 발생할 수 있는 부작용과 그 해결책을 설명하라.
정답과 해설
1번 정답: 0번은 입력을 받아들이는 표준 입력, 1번은 정상적인 결과를 출력하는 표준 출력, 2번은 오류와 경고 메시지를 내보내는 표준 에러의 역할을 담당한다.
2번 정답: 시스템 호출을 통해 커널에 기록을 요청해도 가용한 버퍼나 디스크 상황, 또는 시그널 인터럽트 발생에 따라 요청한 바이트보다 적은 양만 기록되는 짧은 쓰기 현상이 발생할 수 있다. 누락된 나머지 데이터를 끝까지 전송하기 위해 포인터와 남은 바이트 크기를 갱신하며 반복해야 한다.
3번 정답: 덮어쓰기 모드로 원본 파일을 직접 열면 기존 데이터가 즉시 지워진다. 이때 새로운 데이터를 쓰는 도중 프로세스가 비정상 종료되거나 전원이 차단되면 파일 내용이 불완전해져 데이터베이스가 손상된다. 임시 파일에 전체 데이터를 쓰고 닫은 뒤 rename 시스템 호출을 이용하면 원본 파일이 중간 상태 없이 원자적으로 교체되어 데이터 무결성을 지킬 수 있다.
4번 정답: fprintf는 메모리 공간의 내부 버퍼에 데이터를 보관하지만, write는 버퍼를 거치지 않고 운영체제 커널로 데이터를 즉시 보낸다. 따라서 소스 코드의 순서와 관계없이 write의 결과가 먼저 출력되는 역전 현상이 나타날 수 있다. 이를 방지하려면 두 함수를 섞어 쓰지 않거나, write 호출 직전에 fflush를 호출해 보류된 버퍼의 내용을 커널로 모두 밀어내야 한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.