사전 파일 디스크립터
개념

파일 디스크립터

gabury1고친 사람 github-actions[bot]

파일 디스크립터는 프로그램이 열어 둔 파일을 번호 하나로 부르게 해 주는 표시입니다. 파일을 열면 운영체제가 작은 정수를 하나 돌려줍니다. 그 뒤로는 읽든 쓰든 닫든 그 번호만 건넵니다. 파일뿐 아니라 네트워크 연결과 파이프도 같은 번호로 다룹니다.

쉽고 빠른 이해

파일 디스크립터는 열린 파일을 가리키는 번호표입니다. 파일을 열면 3 같은 번호를 받습니다. 읽을 때는 그 번호를 건넵니다.

번호표가 없으면 프로그램이 파일을 부를 때마다 경로를 다시 찾고 권한을 다시 따져야 합니다. 번호 하나로 부르면 운영체제가 처음 연 상태를 기억해 두고 이어서 일합니다.

어떻게 도는가:

  1. 파일을 열면 운영체제가 빈 번호 중 가장 작은 것을 줍니다
  2. 읽기와 쓰기는 그 번호를 건네서 부탁합니다
  3. 다 쓰면 닫아서 번호를 돌려줍니다

대가가 있습니다. 프로그램 하나가 들 수 있는 번호 수에 한도가 있어서, 닫는 것을 잊으면 새 파일도 새 연결도 못 엽니다.

상세

외투 보관소를 떠올려 보겠습니다. 외투를 맡기면 번호표를 받습니다. 찾을 때는 번호표만 내밉니다. 외투가 어느 옷걸이에 걸렸는지는 보관소 직원만 압니다.

파일 디스크립터(file descriptor, 흔히 fd)는 이 번호표에 해당합니다. 프로그램이 운영체제에 파일을 열어 달라고 하면 운영체제는 0 이상의 정수를 돌려줍니다. 프로그램은 그 뒤 파일의 경로를 다시 말하지 않습니다. 읽기, 쓰기, 닫기를 부탁할 때마다 이 번호만 건넵니다.

번호를 쓰는 까닭은 파일을 여는 일이 비싸기 때문입니다. 열 때 운영체제는 경로를 따라 폴더를 하나씩 찾습니다. 그리고 이 프로그램이 그 파일을 만질 권한이 있는지 따집니다. 한 번 열어서 확인을 끝내 두면, 그 뒤 읽기 천 번은 번호 하나로 곧바로 이어집니다.

이 절은 먼저 번호가 무엇을 가리키는지를 표 세 장으로 봅니다. 뒤의 소절들은 그 표를 바탕으로 번호를 열고 쓰고 물려주는 일을 하나씩 다룹니다.

번호가 가리키는 표 세 장

번호 자체에는 아무 정보가 없습니다. 3 이라는 수가 뜻을 갖는 것은 커널 안에 그 번호를 풀어 주는 표가 있어서입니다. 커널은 운영체제의 핵심부로, 파일과 메모리처럼 여러 프로그램이 나눠 쓰는 자원을 관리합니다.

첫째 표는 프로세스마다 하나씩 있는 파일 디스크립터 표입니다. 프로세스는 실행 중인 프로그램 하나를 가리킵니다. 이 표의 칸 번호가 곧 파일 디스크립터입니다. 칸에는 둘째 표의 한 줄을 가리키는 값이 들어 있습니다. 그래서 프로세스 A 의 3번과 프로세스 B 의 3번은 서로 다른 파일일 수 있습니다.

둘째 표는 커널 전체에 하나 있는 열린 파일 표입니다. 파일을 한 번 열 때마다 이 표에 한 줄이 생깁니다. 그 줄에는 지금 파일의 몇 번째 바이트까지 읽었는지를 뜻하는 오프셋과, 읽기 전용인지 쓰기도 되는지 같은 열기 방식이 적힙니다.

셋째 표는 파일 자체의 정보입니다. 파일 크기, 소유자, 데이터가 디스크 어디에 있는지 같은 것이 여기 있습니다. 유닉스 계열에서는 이것을 아이노드라고 부릅니다. 같은 파일을 두 번 열면 둘째 표에는 줄이 둘 생기지만 셋째 표의 항목은 하나를 함께 가리킵니다.

아래 그림은 프로세스 둘이 같은 파일 하나를 각자 연 모습입니다. 번호가 다르고 열린 파일 표의 줄도 다릅니다. 그래도 끝에 가서는 같은 파일로 모입니다.

flowchart TD
    subgraph PA["프로세스 A 의 파일 디스크립터 표"]
        A3["3번"]
    end
    subgraph PB["프로세스 B 의 파일 디스크립터 표"]
        B5["5번"]
    end
    subgraph K["커널의 열린 파일 표"]
        O1["줄 1 · 오프셋 0 · 읽기 전용"]
        O2["줄 2 · 오프셋 800 · 읽기 쓰기"]
    end
    subgraph I["아이노드"]
        F["log.txt · 크기 · 소유자 · 디스크 위치"]
    end
    A3 --> O1
    B5 --> O2
    O1 --> F
    O2 --> F

오프셋이 둘째 표에 있으니 A 가 읽어 나가도 B 의 읽는 위치는 그대로입니다.

번호 둘이 둘째 표의 한 줄을 함께 가리키는 경우도 있습니다. 번호를 복사하거나(dup2) 자식 프로세스를 만들면(fork) 그렇게 됩니다. 이때는 한쪽이 읽은 만큼 다른 쪽의 위치도 움직입니다. 두 경우는 뒤 소절에서 다룹니다.

한 번의 열기에서 닫기까지

파일 하나를 다루는 흐름은 세 번의 부탁으로 이루어집니다. 프로그램은 이 부탁을 시스템 콜로 보냅니다. 시스템 콜은 프로그램이 커널에 일을 맡기는 정해진 호출 창구입니다.

sequenceDiagram
    participant 앱
    participant 커널
    앱->>커널: open("log.txt")
    Note over 커널: 권한 확인 · 표 세 장에 칸을 채운다
    커널-->>앱: 3
    앱->>커널: read(3, 버퍼, 100)
    커널-->>앱: 읽은 바이트 수
    앱->>커널: close(3)
    Note over 커널: 3번 칸을 비운다

open 만 경로를 받습니다. 권한 확인과 경로 찾기는 여기서 한 번 끝납니다. read 와 close 는 번호만 받습니다. 커널은 표를 따라가 이미 확인해 둔 파일로 곧장 갑니다.

그림의 read(3, 버퍼, 100) 은 3번에서 최대 100 바이트를 읽어 버퍼에 담으라는 부탁입니다. 버퍼는 읽은 내용을 받아 둘 메모리 공간입니다.

close 를 부르면 첫째 표의 칸이 비고 그 번호는 다시 쓸 수 있게 됩니다. 둘째 표의 줄은 그 줄을 가리키는 번호가 하나도 안 남았을 때 사라집니다.

번호를 매기는 규칙

새 파일을 열면 커널은 그 프로세스에서 비어 있는 번호 가운데 가장 작은 것을 줍니다. 유닉스 계열 운영체제가 공통으로 따르는 규칙이라 번호를 미리 짐작할 수 있습니다.

아래 코드는 0·1·2번만 열린 상태에서 시작한다고 가정합니다. 각 줄의 오른쪽 주석이 돌려받는 번호입니다. O_RDONLY 는 읽기 전용으로 연다는 표시입니다.

C
int a = open("a.txt", O_RDONLY);  // 3
int b = open("b.txt", O_RDONLY);  // 4
close(a);
int c = open("c.txt", O_RDONLY);  // 3

처음 두 번은 3 과 4 를 받습니다. a 를 닫으면 3번이 빕니다. 그다음 열기는 4 보다 작은 빈 번호인 3 을 다시 받습니다.

이 재사용이 버그의 원인이 되기도 합니다. a 를 닫은 뒤에도 코드 어딘가에 3 이라는 값이 남아 있으면, 그 코드는 이제 c.txt 를 만지게 됩니다. 오류는 나지 않고 엉뚱한 파일에 쓰기만 일어나서 찾기가 어렵습니다. 같은 번호를 두 번 닫는 실수도 같은 이유로 남의 파일을 닫아 버립니다.

0번 · 1번 · 2번

프로세스가 시작될 때 세 번호는 이미 열려 있습니다. 이 셋에 뜻이 정해져 있어서 프로그램은 따로 열지 않고 바로 씁니다.

번호 이름 보통 연결된 곳
0 표준 입력 키보드 · 앞 명령의 출력
1 표준 출력 터미널 화면
2 표준 오류 터미널 화면

printf 나 자바의 System.out.println 은 결국 1번에 씁니다. 오류 메시지를 2번으로 따로 두는 까닭은 정상 출력을 파일로 보낼 때 오류만은 화면에 남기기 위해서입니다.

셸의 리다이렉션은 이 번호를 바꿔 끼우는 일입니다. 아래 명령은 1번을 out.log 로 돌린 다음 2번이 1번과 같은 곳을 가리키게 합니다.

터미널
./server > out.log 2>&1

2>&1 의 &1 은 「1번 파일 디스크립터」라는 뜻입니다. 앞에 & 가 없으면 셸은 1 이라는 이름의 파일을 만듭니다. 셸은 이 일을 시스템 콜 dup2 로 처리합니다.

dup2 는 2번 칸에 1번 칸과 같은 값을 넣습니다. 그러면 두 번호가 열린 파일 표의 같은 줄을 가리킵니다. 앞에서 말한 「같은 곳」이 바로 이 줄입니다.

파일이 아닌 것도 번호로 다룬다

이름은 「파일」 디스크립터지만 번호가 가리키는 대상은 디스크 파일에 그치지 않습니다. 유닉스 계열 운영체제는 입출력 대상을 되도록 같은 모양으로 다룹니다. 흔히 「모든 것은 파일이다」라고 부르는 설계입니다.

대상 번호를 얻는 호출 읽고 쓰는 방법
디스크 파일 open read · write
네트워크 연결 socket · accept read · write
파이프 pipe read · write
터미널 · 장치 open read · write

표의 오른쪽 열은 모두 같습니다. 로그를 파일에 쓰던 코드에 번호만 바꿔 주면 네트워크 연결로 보낼 수 있습니다. 명령 둘을 | 로 잇는 파이프라인도, 앞 명령의 1번과 뒤 명령의 0번을 파이프 하나의 양 끝에 꽂아 만듭니다.

백엔드 서버에서 이 성질이 특히 잘 보입니다. 클라이언트 연결 하나가 소켓 하나입니다. 소켓 하나는 파일 디스크립터 하나입니다. 동시 접속이 만 개면 그 프로세스는 번호를 만 개 넘게 들고 있습니다.

여러 번호를 한꺼번에 지켜보기

연결이 만 개면 어느 연결에 데이터가 왔는지 알아내는 일이 문제가 됩니다. 번호마다 read 를 불러 기다리면 데이터가 안 온 연결에서 프로그램이 멈춰 버립니다.

그래서 번호 여러 개를 커널에 한꺼번에 맡기는 호출이 있습니다. 프로그램은 「읽을 준비가 된 번호를 알려 달라」고 부탁합니다. select · poll · epoll 이 그런 호출입니다.

이 호출과 짝을 이루는 것이 논블로킹 입출력입니다. 데이터가 없으면 기다리지 않고 바로 돌아오는 읽기와 쓰기를 말합니다. 준비된 번호만 골라 읽으니 어느 연결에서도 멈추지 않습니다.

이벤트 루프는 이 둘을 반복문 하나로 묶은 구조입니다. 준비된 번호를 받아 차례로 처리하는 일을 끝없이 되풀이합니다. 스레드 몇 개로 연결 수천 개를 받는 서버가 이 구조로 돕니다.

프로세스 사이에서 옮겨 가는 번호

fork 로 자식 프로세스를 만들면 자식은 부모의 파일 디스크립터 표를 복사해 받습니다. 복사되는 것은 첫째 표뿐입니다. 부모의 3번과 자식의 3번은 열린 파일 표의 같은 줄을 함께 가리킵니다.

그래서 부모와 자식이 같은 번호로 같은 파일에 쓰면 오프셋을 함께 씁니다. 한쪽이 쓴 뒤를 다른 쪽이 이어 씁니다. 서로 덮어쓰지 않는 대신 두 쪽이 쓴 내용이 뒤섞여 나올 수 있습니다.

exec 로 다른 프로그램을 실행해도 열린 번호는 기본으로 남습니다. 그래서 셸이 리다이렉션을 걸어 두고 명령을 실행하면, 그 명령은 바뀐 1번을 물려받아 씁니다. 새 프로그램에 넘기고 싶지 않은 번호에는 close-on-exec 표시를 붙여 실행 순간 닫히게 합니다.

번호에는 한도가 있다

프로세스 하나가 동시에 들 수 있는 번호 수에는 한도가 있습니다. 커널 메모리를 한 프로세스가 다 쓰지 못하게 막으려는 장치입니다. 한도는 셸의 ulimit -n 으로 보고 바꿉니다.

한도에 닿으면 open 이나 accept 가 실패하고 오류 코드로 EMFILE 을 남깁니다. 오류 메시지로는 「Too many open files」가 찍힙니다. 부하 테스트나 접속이 몰리는 순간에 서버가 새 연결을 못 받는 흔한 원인입니다.

한도에 닿는 까닭은 둘로 갈립니다. 하나는 연결 수에 비해 한도가 낮은 경우입니다. 이때는 한도를 올립니다. 다른 하나는 닫는 것을 잊어 번호가 새는 리소스 누수입니다. 이때 한도를 올리면 터지는 시점만 늦춰집니다.

누수는 예외가 난 경로에서 자주 생깁니다. 파일을 연 뒤 중간에 예외가 나면 close 줄까지 안 가기 때문입니다. 자바의 try-with-resources 나 파이썬의 with 는 어떤 경로로 빠져나가도 닫기를 보장하려고 만든 문법입니다.

관련 항목

파일 디스크립터를 풀어 주는 커널 안의 구조

커널 · 프로세스 · 아이노드 · 오프셋 · 열린 파일 표 · 가상 파일 시스템

파일 디스크립터를 주고받는 시스템 콜

시스템 콜 · open · read · write · close · dup2 · fcntl · ioctl · mmap

파일 디스크립터로 다루는 입출력 대상

파일 · 소켓 · 파이프 · 유닉스 도메인 소켓 · 터미널 · 장치 파일 · 표준 입력 · 표준 출력 · 표준 오류

여러 파일 디스크립터를 지켜보는 방식

select · poll · epoll · kqueue · 이벤트 루프 · 논블로킹 IO · 에지 트리거 · 레벨 트리거

파일 디스크립터를 물려주고 넘기는 동작

fork · exec · close-on-exec · 리다이렉션 · 파이프라인 · 셸

파일 디스크립터에서 자주 나는 오류와 장애

EMFILE · EBADF · EAGAIN · 리소스 누수 · ulimit · lsof · errno

파일 디스크립터를 품는 상위 분류

운영체제 · 시스템 프로그래밍 · POSIX · 유닉스 · Linux · 입출력

다른 환경에서 파일 디스크립터를 대신하는 수단

핸들 · FileChannel · 스트림

다른 이름: fd · file descriptor · 파일 서술자 · 파일 기술자