본문으로 이동
  • 현재 모든 문서는 작성 중입니다. 보시는 데 불편함이 있더라도 양해 부탁드립니다.

운영체제 기본/파일과 입출력: 두 판 사이의 차이

~ldmsys
출처·문체 정리: 커널 원문은 git.kernel.org, em dash는 콜론으로 변경
총론 재작성: 이름·객체·열린 상태 분리와 버퍼링·비동기·영속성의 trade-off
1번째 줄: 1번째 줄:
[[운영체제 기본|목차]] · 이전: [[운영체제 기본/가상 메모리|가상 메모리]]
[[운영체제 기본|목차]] · 이전: [[운영체제 기본/가상 메모리|가상 메모리]]


C에서 파일을 다룰 때에는 파일 이름, 파일 디스크립터, 열린 파일의 상태를 구분해 두면 좋다. 셸 리다이렉션이나 파일을 여러 실행 흐름에서 함께 쓰는 코드를 읽을 때 이 차이가 드러난다.
메모리의 데이터는 프로세스가 종료되거나 전원이 꺼진 뒤에도 필요한 경우가 많다. 파일 시스템은 데이터를 지속적으로 보관하고 다시 찾을 수 있게 하며, 입출력 계층은 속도와 동작 방식이 다른 장치 사이에서 데이터를 전달한다. 이때 프로그램이 사용하는 이름, 현재 접근 중인 대상, 실제 저장장치의 상태를 하나로 취급하면 동시 접근과 장애를 다루기 어렵다.


== 파일 디스크립터와 열린 상태 ==
== 저장 위치 대신 이름과 바이트를 제공하기 ==
<code>open()</code>이 성공하면 음이 아닌 정수인 파일 디스크립터(fd)를 돌려준다. 이 값은 프로세스의 fd 테이블에서 열린 대상을 찾는 데 사용한다.
프로그램이 저장장치의 어느 구역에 데이터가 있는지 직접 관리한다면, 파일이 커지거나 다른 프로그램이 공간을 사용했을 때 배치를 함께 조정해야 한다. 파일 시스템은 저장 공간의 할당과 회수를 맡고, 프로그램에는 이름으로 찾을 수 있는 파일을 제공한다. 디렉터리는 이름을 파일 객체에 연결하며, 파일 객체는 내용과 크기, 접근 권한 같은 정보를 가진다. 이름을 찾는 문제와 내용을 어디에 놓을지는 분리된다.


그 아래에는 커널이 관리하는 '''open file description'''이 있다. 여기에는 현재 파일 오프셋과 파일 상태 플래그 등이 들어 있다. fd와 열린 상태를 구별해야 하는 이유는 여러 fd가 같은 상태를 가리킬 수 있기 때문이다.
일반 파일을 바이트의 나열로 제공하면 프로그램은 저장장치의 세부 구조에 맞춰 매번 코드를 바꾸지 않아도 된다. Unix는 파일뿐 아니라 파이프와 터미널에도 읽기와 쓰기라는 공통 인터페이스를 제공한다. 덕분에 출력 대상을 바꾸어 프로그램을 조합하기 쉽다. 다만 인터페이스가 비슷해도 모든 대상의 성질이 같지는 않다. 일반 파일은 위치를 바꾸어 읽을 수 있지만 파이프는 그렇게 할 수 없으며, 바이트 스트림의 데이터에 붙일 레코드 구조는 프로그램이 정해야 한다. <ref name="source-1">[https://man7.org/linux/man-pages/man2/read.2.html Linux man-pages: read(2)].</ref>


<code>dup()</code>으로 fd를 복제하면 두 fd는 같은 open file description을 참조한다. 일반 파일에서 한쪽으로 읽어 오프셋이 이동하면 다른 쪽에도 그 위치가 적용된다. 반면 같은 경로를 <code>open()</code>으로 두 번 열면 각각 새로운 open file description이 만들어져 오프셋이 별도로 유지된다. [https://man7.org/linux/man-pages/man2/open.2.html open(2)] · [https://man7.org/linux/man-pages/man2/dup.2.html dup(2)]
== 이름, 객체, 열린 상태의 수명을 나누기 ==
파일을 열 때마다 경로를 다시 검색하는 대신, 운영체제는 찾은 객체를 계속 참조할 수 있는 핸들을 돌려준다. Unix의 파일 디스크립터가 그 예다. 핸들이 가리키는 열린 상태에는 읽고 쓰는 현재 위치 등이 들어 있다. 같은 파일을 따로 열면 위치를 독립적으로 유지할 수 있고, 열린 상태를 공유하는 핸들은 위치도 함께 사용한다. 파일 내용의 공유와 읽기 위치의 공유는 다른 선택이다. <ref name="source-2">[https://man7.org/linux/man-pages/man2/open.2.html Linux man-pages: open(2)].</ref>


파일 상태 플래그를 공유한다는 말과 fd 자체의 플래그까지 공유한다는 말도 구별해야 한다. 예를 들어 close-on-exec 같은 파일 디스크립터 플래그는 fd별로 관리된다.
이 분리는 이름이 바뀌어도 진행 중인 작업을 유지하게 한다. Unix에서 로그 파일의 마지막 이름을 삭제해도 프로세스가 파일을 열어 두었다면 계속 쓸 수 있다. 같은 이름으로 새 파일을 만들어도 기존 핸들이 새 파일로 옮겨 가지는 않는다. 이름이 사라진 시점과 저장 공간을 회수할 수 있는 시점이 달라지는 대신, 경로의 변경 때문에 이미 연 대상이 갑자기 바뀌지 않는다. <ref name="source-3">[https://man7.org/linux/man-pages/man2/unlink.2.html Linux man-pages: unlink(2)].</ref>


== 이름이 사라지는 것과 파일이 사라지는 것은 다르다 ==
== 버퍼링과 비동기로 기다리는 비용 줄이기 ==
경로는 디렉터리를 따라 대상을 찾는 방법이고, 열린 파일 디스크립터는 이미 찾은 대상에 접근하는 참조다. 일반적인 Unix 파일 시스템에서 이름은 inode로 표현되는 파일 객체에 연결된다. 같은 객체에 여러 이름이 붙을 수도 있다. 이름을 지우는 일과 객체를 더는 아무도 사용하지 않는 일은 구분해야 한다.
CPU가 작은 입출력 요청마다 느린 장치의 완료를 기다리면 계산할 시간이 낭비된다. 버퍼는 속도가 다른 두 쪽 사이에서 데이터를 잠시 보관한다. 작은 쓰기를 모아 보내면 요청 횟수를 줄이고, 읽은 내용을 캐시에 남기면 같은 데이터를 다시 가져오는 비용을 줄인다. 대신 버퍼가 RAM을 차지하며, 모아서 보내기를 기다리는 동안 개별 데이터의 전달은 늦어질 수 있다. 처리량의 개선과 한 요청의 지연 시간 단축은 항상 같은 방향이 아니다.


로그 이름을 삭제해도 프로세스가 기존 파일을 열어 두었다면 계속 쓸 수 있다. 경로 검색으로 이름을 찾지 못해도 열린 참조가 남기 때문이다. 따라서 파일이 디렉터리에서 사라졌다고 저장 공간까지 즉시 회수되었다고 결론 내릴 수 없다. 같은 이름으로 새 파일을 만들어도 기존 디스크립터가 새 파일로 옮겨 가지는 않는다.
비동기 입출력은 요청과 완료 확인을 분리해, 장치가 일하는 동안 프로그램이 다른 일을 하게 한다. 버퍼링과 함께 사용할 수 있지만 서로 같은 개념은 아니다. 비동기로 실행한 작업도 완료 결과를 확인해야 하며, 완료 전까지 사용 중인 버퍼의 수명을 보장해야 한다. 동시에 너무 많은 요청을 쌓으면 대기열과 메모리 사용량이 늘어난다. 읽기 캐시 역시 원본이 다른 경로로 바뀔 수 있다면 갱신을 어떻게 알아낼지 정해야 한다. 재사용의 이득에는 최신성 관리 비용이 따른다. <ref name="source-4">[https://docs.kernel.org/admin-guide/mm/concepts.html 커널 문서: 페이지 캐시].</ref><ref name="source-5">[https://man7.org/linux/man-pages/man7/aio.7.html Linux man-pages: 비동기 입출력].</ref>


설정 파일을 교체할 때도 이 구분이 중요하다. 같은 파일 시스템에 새 파일을 완성한 뒤 기존 이름으로 바꾸면, 새로 그 경로를 여는 쪽에는 새 대상을 제시하면서 이미 옛 파일을 연 쪽의 참조는 유지한다. 다만 이름 교체의 원자성은 여러 파일을 함께 바꾸는 트랜잭션이나 전원 장애 뒤에도 변경이 남는 내구성을 뜻하지 않는다.
== 요청한 양과 완료된 양을 구분하기 ==
입출력은 요청 전체의 성공과 실패 두 상태만으로 표현하기 어렵다. 읽기는 파일 끝에 도달하거나 현재 도착한 데이터가 적으면 요청보다 적은 바이트를 돌려줄 수 있다. 쓰기도 공간 부족이나 실행 중단 등의 이유로 일부만 끝날 수 있다. 따라서 반환된 양은 실제로 어디까지 진행했는지를 나타내는 정보다. 남은 부분을 재시도하려면 이미 처리한 위치를 보존해야 한다. 처음부터 다시 보내면 중복된 데이터가 생길 수 있다.


경로가 가리키는 대상, fd가 참조하는 객체, 데이터의 저장 상태는 각각 이름 공간, 객체 수명, 영속성의 문제다.
양수를 반환한 부분 완료, 입력의 끝, 오류는 각각 다르게 처리해야 한다. 특히 바이트 스트림에서는 송신 측이 한 번에 쓴 범위와 수신 측이 한 번에 읽는 범위가 일치하지 않는다. 메시지의 길이나 구분자는 상위 프로토콜이 정해야 한다. 또한 버퍼에 쓰기를 받아들인 뒤 실제 저장 과정에서 오류가 드러날 수 있으므로, 최초 쓰기의 성공만으로 이후 실패 가능성까지 사라지지는 않는다. <ref name="source-6">[https://man7.org/linux/man-pages/man2/write.2.html Linux man-pages: write(2)].</ref>


참고: [https://man7.org/linux/man-pages/man2/open.2.html open(2)], [https://man7.org/linux/man-pages/man2/unlink.2.html unlink(2)], [https://man7.org/linux/man-pages/man2/rename.2.html rename(2)].
== 보이는 변경과 남는 변경을 구분하기 ==
버퍼드 쓰기의 성공은 데이터가 운영체제의 캐시에 전달되었다는 뜻일 수 있다. 다른 읽기에서 변경을 관찰하는 것과 전원 장애 후에도 변경이 남는 것은 별개다. 내구성이 필요하면 데이터를 저장장치까지 반영하는 동기화 절차와 그 실패를 처리해야 한다. 매번 동기화하면 대기 비용이 커지고, 여러 변경을 묶으면 비용을 나누는 대신 마지막 묶음이 보장되기까지의 시간이 길어진다.


== 표준 입출력도 fd다 ==
이름 변경의 원자성도 내구성과 구별한다. 같은 파일 시스템에서 완성한 새 파일로 기존 이름을 원자적으로 교체하면, 새로 여는 쪽이 교체 도중의 빈 이름을 보는 문제를 피할 수 있다. 이미 열린 참조는 이전 객체를 유지한다. 그러나 이것만으로 여러 파일의 변경이 하나의 트랜잭션이 되거나 장애 후 복구 결과가 보장되지는 않는다. 파일 내용과 그 이름을 담은 디렉터리는 각각 영속화가 필요한 상태다. Linux에서 파일에 대한 동기화만으로 디렉터리 항목까지 저장되었다고 볼 수 없는 이유도 여기에 있다. <ref name="source-7">[https://man7.org/linux/man-pages/man2/rename.2.html Linux man-pages: rename(2)].</ref><ref name="source-8">[https://man7.org/linux/man-pages/man2/fsync.2.html fsync(2)].</ref>
보통 fd 0, 1, 2는 각각 표준 입력, 표준 출력, 표준 오류에 쓰인다. Bash 터미널에서 다음 명령으로 현재 셸의 연결 대상을 볼 수 있다.
 
<pre>
ls -l /proc/$$/fd
</pre>
 
터미널에서 실행했다면 세 번호가 같은 터미널 장치를 가리킬 수 있다. 그러나 프로그램의 표준 출력이 반드시 화면인 것은 아니다. 셸이 리다이렉션이나 파이프를 구성하면 그 대상은 파일이나 파이프일 수 있다. 프로그램은 표준 출력에 쓰는 인터페이스를 그대로 사용하고 셸이 연결을 바꾼다.
 
<code>FILE *</code>와 fd도 같은 것이 아니다. <code>fopen()</code> 등으로 얻는 C 표준 I/O 스트림은 사용자 공간의 버퍼링을 포함하며, <code>open()</code>으로 얻는 정수 fd와 구분해야 한다.
 
== 읽기와 쓰기는 일부만 완료될 수 있다 ==
<code>read(fd, buf, count)</code>의 성공 반환값은 실제 읽은 바이트 수다. 요청한 <code>count</code>보다 작을 수 있으므로 고정 길이 데이터를 읽는 코드는 반환값을 누적하며 필요한 만큼 처리해야 한다. 오류와 EOF도 구분한다.
 
<code>write()</code>도 전달한 데이터를 한 번에 모두 기록했다고 가정할 수 없다. 반환값과 오류를 확인해야 한다. 특히 파이프나 TCP 같은 스트림 소켓을 다룰 때 “한 번 보낸 크기”와 “상대가 한 번 읽는 크기”가 같다고 가정하면 프로토콜 처리가 깨질 수 있다. [https://man7.org/linux/man-pages/man2/read.2.html read(2)] · [https://man7.org/linux/man-pages/man2/write.2.html write(2)]
 
== 저장 완료는 어느 시점인가 ==
일반적인 버퍼드 파일 I/O에서는 <code>write()</code>가 성공해도 데이터가 페이지 캐시에만 반영되어 있을 수 있다. 이후 커널이 저장장치에 기록한다. 따라서 전원 차단이나 시스템 장애에도 데이터가 남아야 하는 프로그램은 쓰기의 반환값만으로 작업을 끝내면 안 된다.
 
<code>fsync()</code>는 파일의 데이터와 필요한 메타데이터를 저장장치에 동기화하는 인터페이스다. 이 호출의 실패도 처리해야 한다. 새 파일을 만들거나 이름을 바꾼 경우에는 파일 내용 외에 디렉터리 항목의 영속성도 고려해야 하며, 파일 하나에 대한 <code>fsync()</code>만으로 그 부분까지 보장되지는 않는다.
 
출력 스트림에 대한 C 표준 I/O의 <code>fflush()</code>는 사용자 공간의 버퍼에 쌓인 데이터를 하위 쓰기 함수로 전달한다. 이것만으로 저장장치까지 동기화되었다고 볼 수 없다. '''사용자 공간 버퍼 → 커널의 캐시 → 저장장치'''를 구분하면 두 함수의 역할이 왜 다른지 이해할 수 있다.


== 관련 각론 ==
== 관련 각론 ==
* [[운영체제 기본/Linux의 파일 읽기]]: fd 테이블, struct file, file_operations의 연결.
* [[운영체제 기본/Linux의 파일 읽기]]: 파일 읽기 인터페이스가 Linux 내부 구현으로 이어지는 과정.
 
== 참고 문헌 ==
== 참고 문헌 ==
* [https://man7.org/linux/man-pages/man2/open.2.html open(2)] · [https://man7.org/linux/man-pages/man2/dup.2.html dup(2)]
<references />
* [https://man7.org/linux/man-pages/man2/read.2.html read(2)] · [https://man7.org/linux/man-pages/man2/write.2.html write(2)]
* [https://man7.org/linux/man-pages/man3/fflush.3.html fflush(3)] · [https://man7.org/linux/man-pages/man2/fsync.2.html fsync(2)]
* [https://man7.org/linux/man-pages/man5/proc_pid_fd.5.html proc_pid_fd(5)]

2026년 10월 31일 (토) 07:02 판

목차 · 이전: 가상 메모리

메모리의 데이터는 프로세스가 종료되거나 전원이 꺼진 뒤에도 필요한 경우가 많다. 파일 시스템은 데이터를 지속적으로 보관하고 다시 찾을 수 있게 하며, 입출력 계층은 속도와 동작 방식이 다른 장치 사이에서 데이터를 전달한다. 이때 프로그램이 사용하는 이름, 현재 접근 중인 대상, 실제 저장장치의 상태를 하나로 취급하면 동시 접근과 장애를 다루기 어렵다.

저장 위치 대신 이름과 바이트를 제공하기

프로그램이 저장장치의 어느 구역에 데이터가 있는지 직접 관리한다면, 파일이 커지거나 다른 프로그램이 공간을 사용했을 때 배치를 함께 조정해야 한다. 파일 시스템은 저장 공간의 할당과 회수를 맡고, 프로그램에는 이름으로 찾을 수 있는 파일을 제공한다. 디렉터리는 이름을 파일 객체에 연결하며, 파일 객체는 내용과 크기, 접근 권한 같은 정보를 가진다. 이름을 찾는 문제와 내용을 어디에 놓을지는 분리된다.

일반 파일을 바이트의 나열로 제공하면 프로그램은 저장장치의 세부 구조에 맞춰 매번 코드를 바꾸지 않아도 된다. Unix는 파일뿐 아니라 파이프와 터미널에도 읽기와 쓰기라는 공통 인터페이스를 제공한다. 덕분에 출력 대상을 바꾸어 프로그램을 조합하기 쉽다. 다만 인터페이스가 비슷해도 모든 대상의 성질이 같지는 않다. 일반 파일은 위치를 바꾸어 읽을 수 있지만 파이프는 그렇게 할 수 없으며, 바이트 스트림의 데이터에 붙일 레코드 구조는 프로그램이 정해야 한다. [1]

이름, 객체, 열린 상태의 수명을 나누기

파일을 열 때마다 경로를 다시 검색하는 대신, 운영체제는 찾은 객체를 계속 참조할 수 있는 핸들을 돌려준다. Unix의 파일 디스크립터가 그 예다. 핸들이 가리키는 열린 상태에는 읽고 쓰는 현재 위치 등이 들어 있다. 같은 파일을 따로 열면 위치를 독립적으로 유지할 수 있고, 열린 상태를 공유하는 핸들은 위치도 함께 사용한다. 파일 내용의 공유와 읽기 위치의 공유는 다른 선택이다. [2]

이 분리는 이름이 바뀌어도 진행 중인 작업을 유지하게 한다. Unix에서 로그 파일의 마지막 이름을 삭제해도 프로세스가 파일을 열어 두었다면 계속 쓸 수 있다. 같은 이름으로 새 파일을 만들어도 기존 핸들이 새 파일로 옮겨 가지는 않는다. 이름이 사라진 시점과 저장 공간을 회수할 수 있는 시점이 달라지는 대신, 경로의 변경 때문에 이미 연 대상이 갑자기 바뀌지 않는다. [3]

버퍼링과 비동기로 기다리는 비용 줄이기

CPU가 작은 입출력 요청마다 느린 장치의 완료를 기다리면 계산할 시간이 낭비된다. 버퍼는 속도가 다른 두 쪽 사이에서 데이터를 잠시 보관한다. 작은 쓰기를 모아 보내면 요청 횟수를 줄이고, 읽은 내용을 캐시에 남기면 같은 데이터를 다시 가져오는 비용을 줄인다. 대신 버퍼가 RAM을 차지하며, 모아서 보내기를 기다리는 동안 개별 데이터의 전달은 늦어질 수 있다. 처리량의 개선과 한 요청의 지연 시간 단축은 항상 같은 방향이 아니다.

비동기 입출력은 요청과 완료 확인을 분리해, 장치가 일하는 동안 프로그램이 다른 일을 하게 한다. 버퍼링과 함께 사용할 수 있지만 서로 같은 개념은 아니다. 비동기로 실행한 작업도 완료 결과를 확인해야 하며, 완료 전까지 사용 중인 버퍼의 수명을 보장해야 한다. 동시에 너무 많은 요청을 쌓으면 대기열과 메모리 사용량이 늘어난다. 읽기 캐시 역시 원본이 다른 경로로 바뀔 수 있다면 갱신을 어떻게 알아낼지 정해야 한다. 재사용의 이득에는 최신성 관리 비용이 따른다. [4][5]

요청한 양과 완료된 양을 구분하기

입출력은 요청 전체의 성공과 실패 두 상태만으로 표현하기 어렵다. 읽기는 파일 끝에 도달하거나 현재 도착한 데이터가 적으면 요청보다 적은 바이트를 돌려줄 수 있다. 쓰기도 공간 부족이나 실행 중단 등의 이유로 일부만 끝날 수 있다. 따라서 반환된 양은 실제로 어디까지 진행했는지를 나타내는 정보다. 남은 부분을 재시도하려면 이미 처리한 위치를 보존해야 한다. 처음부터 다시 보내면 중복된 데이터가 생길 수 있다.

양수를 반환한 부분 완료, 입력의 끝, 오류는 각각 다르게 처리해야 한다. 특히 바이트 스트림에서는 송신 측이 한 번에 쓴 범위와 수신 측이 한 번에 읽는 범위가 일치하지 않는다. 메시지의 길이나 구분자는 상위 프로토콜이 정해야 한다. 또한 버퍼에 쓰기를 받아들인 뒤 실제 저장 과정에서 오류가 드러날 수 있으므로, 최초 쓰기의 성공만으로 이후 실패 가능성까지 사라지지는 않는다. [6]

보이는 변경과 남는 변경을 구분하기

버퍼드 쓰기의 성공은 데이터가 운영체제의 캐시에 전달되었다는 뜻일 수 있다. 다른 읽기에서 변경을 관찰하는 것과 전원 장애 후에도 변경이 남는 것은 별개다. 내구성이 필요하면 데이터를 저장장치까지 반영하는 동기화 절차와 그 실패를 처리해야 한다. 매번 동기화하면 대기 비용이 커지고, 여러 변경을 묶으면 비용을 나누는 대신 마지막 묶음이 보장되기까지의 시간이 길어진다.

이름 변경의 원자성도 내구성과 구별한다. 같은 파일 시스템에서 완성한 새 파일로 기존 이름을 원자적으로 교체하면, 새로 여는 쪽이 교체 도중의 빈 이름을 보는 문제를 피할 수 있다. 이미 열린 참조는 이전 객체를 유지한다. 그러나 이것만으로 여러 파일의 변경이 하나의 트랜잭션이 되거나 장애 후 복구 결과가 보장되지는 않는다. 파일 내용과 그 이름을 담은 디렉터리는 각각 영속화가 필요한 상태다. Linux에서 파일에 대한 동기화만으로 디렉터리 항목까지 저장되었다고 볼 수 없는 이유도 여기에 있다. [7][8]

관련 각론

참고 문헌