Home File Descriptor란 무엇일까?
Post
Cancel

File Descriptor란 무엇일까?

글을 작성하게 된 계기


멀티 플렉싱(Multiplexing)을 학습하며 파일 디스크립터(File Descriptor)를 한 번 더 정리하고 싶었고, 이를 정리하기 위해 글을 작성하게 되었습니다.





1. 파일 디스크립터


파일 디스크립터(File Descriptor, FD)는 유닉스 및 유닉스 계열 운영체제에서 프로세스가 파일 또는 기타 입출력 리소스를 식별하는 정수형 핸들 입니다. 모든 프로세스는 파일 디스크립터 테이블을 가지고 있으며, 파일이나 소켓을 열면 여기에 FD가 할당됩니다. 즉, 프로그램이 어떤 파일을 열고 닫거나, 네트워크 소켓을 생성할 때, 커널은 파일 디스크립터를 반환하여 이를 관리하는 것입니다.

In Unix and Unix-like computer operating systems, a file descriptor (FD, less frequently fildes) is a process-unique identifier (handle) for a file or other input/output resource, such as a pipe or network socket.





리눅스에서 모든 프로세스는 기본적으로 표준 입력(0, stdin), 표준 출력(1, stdout), 표준 에러(2, stderr) 세 개의 파일 디스크립터를 가집니다. 예를 들어, 현재 실행 중인 셸($$)의 파일 디스크립터들이 모두 현재 터미널(/dev/pts/0)에 바인딩 돼 있습니다. 즉, 0, 1, 2 와 같이 정수로 표현된 파일 디스크립터가 어떤 파일 또는 리소스를 가리키는지 확인 할 수 있는 것입니다.

1
2
3
4
5
6
$ ls -l /proc/$$/fd
total 0
lrwx------. 1 ec2-user ec2-user 64 Feb 22 05:57 0 -> /dev/pts/0
lrwx------. 1 ec2-user ec2-user 64 Feb 22 05:57 1 -> /dev/pts/0
lrwx------. 1 ec2-user ec2-user 64 Feb 22 05:57 2 -> /dev/pts/0
lrwx------. 1 ec2-user ec2-user 64 Feb 22 05:57 255 -> /dev/pts/0

255라는 추가적인 FD가 존재하는데, 이는 Bash가 내부적으로 사용하는 FD로, exec 명령을 사용할 때 주로 활용됩니다.







2. 왜 이를 사용할까?


파일 디스크립터를 사용하는 이유는 다양하겠지만 크게 리눅스의 설계 철학 과 더불어 보안성과 안정성 을 확보하기 위함입니다. 이에 대해 좀 더 자세히 살펴보겠습니다.

  1. Everything is a file
  2. 보안과 안정성



2-1. Everything is a file

리눅스는 Everything is a file 이라는 철학을 따릅니다. 즉, 리눅스에서는 일반적인 파일뿐만 아니라 파이프, 소켓, 터미널, 장치 파일 등 모든 입출력 리소스를 하나의 공통된 추상화인 파일로 접근 합니다. 이는 프로세스가 입출력 자원을 식별하고 관리할 때 파일 디스크립터(File Descriptor, FD)라는 정수형 핸들을 통해 일관되게 처리하도록 설계되었기 때문입니다.

“Everything is a file” is an approach to interface design in Unix derivatives. While this turn of phrase does not as such figure as a Unix design principle or philosophy, it is a common way to analyse designs, and informs the design of new interfaces in a way that prefers, in rough order of import:





파일 디스크립터는 운영체제가 파일이나 기타 입출력 리소스를 효율적이고 간결한 방식으로 관리하도록 돕습니다. 예를 들어, 파일을 열 때 뿐만 아니라 네트워크 소켓을 열 때도 동일한 방식으로 FD가 할당되며, 이를 통해 read(), write()와 같은 공통된 시스템 호출로 데이터를 주고받을 수 있습니다. 아래 코드는 네트워크 소켓을 열어 FD를 할당하고, 이후 read( )와 write( )를 통해 데이터를 주고받습니다.

  1. File I/O
  2. Network Sockets





2-1-1. File I/O

리눅스에서 파일을 읽고 쓰는 것은 단순히 FD를 사용한 read/write 작업입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

#define FILE_PATH "example.txt"

int main() {
    int fd = open(FILE_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    const char *message = "Hello, File!";
    write(fd, message, 12);
    close(fd);

    // 읽기
    fd = open(FILE_PATH, O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    char buffer[128];
    int bytes_read = read(fd, buffer, sizeof(buffer) - 1);
    if (bytes_read > 0) {
        buffer[bytes_read] = '\0';
        printf("파일에서 읽음: %s\n", buffer);
    }

    close(fd);
    return 0;
}





2-1-2. Network Sockets

리눅스에서 소켓도 파일 디스크립터(FD)로 관리되며, 파일 읽기/쓰기와 동일한 방식으로 데이터를 송수신할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

#define PORT 8080
#define BUFFER_SIZE 1024

int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    char buffer[BUFFER_SIZE];
    socklen_t client_len = sizeof(client_addr);

    // 소켓 생성
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 주소 설정
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    // 바인딩
    if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 리스닝
    if (listen(server_fd, 5) == -1) {
        perror("listen");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    printf("서버 대기 중...\n");

    // 클라이언트 연결 대기
    client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
    if (client_fd == -1) {
        perror("accept");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 데이터 수신 및 응답
    read(client_fd, buffer, BUFFER_SIZE);
    printf("클라이언트로부터 수신: %s\n", buffer);
    write(client_fd, "Hello, Client!", 14);

    close(client_fd);
    close(server_fd);
    return 0;
}





2-1-3. IPC

리눅스에서 프로세스 간 통신(IPC)도 FD를 사용하여 처리됩니다. 아래는 부모-자식 간 파이프(Pipe)를 활용해 메시지를 전송하는 예제입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#define BUFFER_SIZE 1024

int main() {
    int fd[2];  // fd[0] = read end, fd[1] = write end
    pid_t pid;
    char buffer[BUFFER_SIZE];

    if (pipe(fd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }

    pid = fork();
    if (pid < 0) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {  // 자식 프로세스
        close(fd[1]);  // 쓰기 닫기
        read(fd[0], buffer, BUFFER_SIZE);
        printf("자식 프로세스가 읽음: %s\n", buffer);
        close(fd[0]);
    } else {  // 부모 프로세스
        close(fd[0]);  // 읽기 닫기
        const char *message = "Hello from parent!";
        write(fd[1], message, strlen(message));
        close(fd[1]);
        wait(NULL);  // 자식 종료 대기
    }

    return 0;
}





이처럼 리눅스는 입출력 처리 방식을 단순화하고, 다양한 리소스를 하나의 통합된 접근법으로 관리할 수 있게 하여 시스템 설계를 유연하고 효율적으로 만듭니다. 파일, 소켓, 파이프 등 다양한 리소스가 모두 동일한 인터페이스로 제공되기 때문에 개발자는 각각의 리소스 특성을 깊게 알지 않고도 효과적으로 프로그램을 작성할 수 있습니다.

  • 파일, 소켓, 파이프 등 모든 I/O 객체를 FD로 접근
  • open( ), socket( ), pipe( ) 등을 호출하면 새로운 FD를 반환
  • 프로세스 종료 시 모든 FD가 자동 해제
  • FD는 프로세스의 파일 디스크립터 테이블에서 관리됨





2-2. 보안과 안정성

파일 디스크립터는 사용자 프로세스가 직접 커널 내부 구조체를 다루지 않고, FD를 통해 간접적으로 접근 하도록 설계되었습니다. 운영체제는 사용자 공간(User Space)과 커널 공간(Kernel Space)을 분리 해서 안정성과 보안을 보장하는데요, 사용자 프로세스는 직접 커널 내부 데이터를 수정할 수 없으며, 대신 파일 디스크립터(FD)를 통해 커널이 관리하는 파일 시스템 자원에 간접적으로 접근합니다. 프로세스가 직접 커널의 데이터를 수정할 수 있으면 시스템 안정성이 깨지고 보안 문제가 발생할 수 있기 때문입니다.

image





모든 파일이나 장치 등의 입출력 리소스는 커널 내부에서 struct file이라는 구조체를 통해 관리되지만, 사용자 프로세스는 이를 직접 접근하거나 수정할 수 없습니다. 대신 프로세스는 커널이 제공하는 시스템 호출( open(), read(), write(), close())을 통해서만 리소스를 조작할 수 있습니다.

1
2
3
4
5
6
struct file {
    struct inode *f_inode;    // 파일 I-node 정보
    loff_t f_pos;             // 파일 오프셋(읽기/쓰기 위치)
    int f_flags;              // 파일 열기 플래그(O_RDONLY, O_WRONLY, O_RDWR)
    struct file_operations *f_op;  // 파일에 대한 연산 함수(read, write, open, close 등)
};





리눅스에서 FD를 활용한 보안 및 안정성 메커니즘은 다음과 같습니다. 이와 같은 설계를 통해 리눅스는 사용자 프로세스와 커널 간 명확한 경계를 유지하며, 보안적 위험을 최소화하고 시스템 전체의 안정성을 보장할 수 있습니다.

  • 사용자 프로세스는 FD라는 정수형 핸들만을 가지고 입출력 리소스를 제어
  • FD는 커널 내부의 파일 디스크립터 테이블(Descriptor Table)을 통해 관리됨
  • 파일 디스크립터 테이블은 각 FD가 접근 가능한 커널 내의 파일 엔트리(File Entry)를 가리킴
  • 실제 파일, 장치, 소켓, 파이프 등 모든 입출력 자원이 FD를 통해 접근됨
  • 프로세스 종료 시, 열려 있는 모든 FD는 자동으로 정리되어 자원 유출 방지





3. 파일 디스크립터 구조


리눅스/유닉스에서 프로세스가 파일을 열 때의 동작 과정은 파일 디스크립터(File Descriptor), 오픈 파일 테이블(Open File Table), I-node 테이블(I-node Table) 간의 관계로 설명할 수 있습니다. 프로세스는 파일에 접근하기 위해 시스템 호출을 사용하며, 운영체제는 이를 효과적으로 관리하기 위해 내부적으로 여러 단계의 테이블을 이용합니다.

image





3-1. 프로세스의 파일 디스크립터 테이블

각 프로세스는 자신만의 파일 디스크립터 테이블(File Descriptor Table)을 가지고 있습니다. 이 테이블은 프로세스가 접근할 수 있는 모든 입출력 리소스에 대한 핸들인 파일 디스크립터(FD)를 저장합니다. FD는 정수 형태로 표현되며, 테이블의 각 항목은 FD 플래그(읽기/쓰기 모드 등)와 오픈 파일 테이블을 가리키는 파일 포인터(file ptr)를 포함하고 있습니다.





3-2. 오픈 파일 테이블

오픈 파일 테이블(Open File Table)은 시스템 전체에서 관리하는 열린 파일 목록으로, 모든 프로세스가 공유하는 구조입니다. 같은 파일을 여러 프로세스가 열 경우, 해당 파일에 대한 오픈 파일 테이블의 엔트리를 공유할 수 있습니다. 각 오픈 파일 테이블 엔트리는 파일 오프셋(file offset), 상태 플래그(status flags), 그리고 실제 파일의 메타데이터를 포함한 I-node 포인터(inode ptr) 를 가지고 있습니다.

파일 오프셋은 read() 또는 write() 시스템 호출이 발생할 때마다 변경됩니다. 부모와 자식 프로세스가 fork()로 분기된 후 같은 FD를 공유할 경우 같은 오프셋을 사용하게 됩니다. 상태 플래그는 파일이 읽기 전용(O_RDONLY), 쓰기 전용(O_WRONLY), 또는 읽기 쓰기 가능(O_RDWR)인지 나타냅니다.





3-3. I-node 테이블

I-node 테이블(I-node Table)은 시스템 전체에서 파일의 메타데이터를 저장하고 관리하는 테이블입니다. 여기에는 파일 타입(file type), 파일 크기, 권한, 소유자 정보, 마지막 접근 시간 등의 정보가 포함됩니다. 모든 오픈 파일 테이블 엔트리는 I-node 테이블의 특정 I-node를 참조하여 해당 파일의 메타데이터를 얻습니다.

1
2
3
4
5
6
7
struct inode {
    mode_t i_mode;   // 파일 타입 및 권한
    uid_t i_uid;     // 소유자 ID
    gid_t i_gid;     // 그룹 ID
    off_t i_size;    // 파일 크기
    time_t i_atime;  // 마지막 접근 시간
};





3-4. 프로세스 간 FD 공유와 영향

두 프로세스가 같은 파일을 열었을 때, 예를 들어 프로세스 A의 fd 2와 fd 20이 같은 오픈 파일 테이블 엔트리를 가리킬 경우 이들은 같은 파일 오프셋(예: 23)을 공유합니다. 그러나 다른 프로세스 B의 fd 3이 같은 파일을 열었다 하더라도 별도의 오프셋(예: 73)을 가질 수 있습니다. 이처럼 파일의 메타데이터는 동일한 I-node에서 공유하지만, 각 프로세스의 FD는 독립적으로 관리됩니다.

fork()를 통해 부모와 자식 프로세스가 생성되면 같은 파일 오프셋을 공유하므로, 한 프로세스에서 파일의 오프셋을 변경하면 다른 프로세스에도 영향을 줍니다. 또한 dup(fd1)을 통해 같은 오픈 파일 테이블 엔트리를 공유하는 새 FD(fd2)를 만들면 fd1에서 데이터를 쓴 후 fd2로 바로 읽을 수 있습니다.

예를 들어, 다음과 같은 C 코드는 같은 파일 오프셋을 공유하는 두 FD를 사용하여 데이터를 쓰고 읽는 과정을 보여줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main() {
    int fd1 = open("test.txt", O_RDWR | O_CREAT, 0644);
    int fd2 = dup(fd1);

    write(fd1, "Hello", 5);
    lseek(fd2, 0, SEEK_SET);

    char buffer[6] = {0};
    read(fd2, buffer, 5);

    printf("Read from fd2: %s\n", buffer); // 출력: "Hello"

    close(fd1);
    close(fd2);
    return 0;
}





또한, 부모와 자식 프로세스가 fork( ) 후 같은 FD를 사용할 때의 영향을 보여주는 코드 예시는 다음과 같습니다. 이는 파일 오프셋 공유로 인해 무작위 결과가 나타날 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main() {
    int fd = open("test.txt", O_RDWR | O_CREAT, 0644);

    if (fork() == 0) {  // 자식 프로세스
        write(fd, "Child", 5);
    } else {  // 부모 프로세스
        write(fd, "Parent", 6);
    }

    close(fd);
    return 0;
}







4. 정리


파일 디스크립터와 동작 과정을 간단하게 살펴보았습니다. 공부하는 내용들이 점점 묵직해지는데요, 하나의 개념을 이해하기 위해 추가로 알아야 할 지식들이 많아져서 재미있으면서도 어렵네요.


This post is licensed under CC BY 4.0 by the author.