본문 바로가기
TYLER SONGBlog
← Blog index
CS6 min read

File Descriptor: 프로세스가 커널 자원을 가리키는 정수 핸들

파일 디스크립터(FD)는 프로세스가 열어놓은 파일, 소켓, 파이프 등의 커널 자원을 식별하는 정수 값이다. 프로세스별 FD 테이블과 커널의 열린 파일 테이블, inode 테이블이라는 세 단계 구조로 동작하며, 이 구조 덕분에 dup2, fork 이후 fd 공유 같은 동작이 가능해진다. FD 개수 제한, FD 누수(leak) 문제는 실무에서 "Too many

By 송민성

1. 개념

파일 디스크립터(File Descriptor, FD)는 프로세스가 커널에 열어달라고 요청한 자원(파일, 소켓, 파이프, 디바이스 등)을 가리키는 음이 아닌 정수다. 리눅스/유닉스 계열에서 "모든 것은 파일이다"라는 철학에 따라, 일반 파일뿐 아니라 소켓, 파이프, 심지어 표준입출력까지 전부 FD로 다뤄진다.

프로세스가 시작되면 기본적으로 세 개의 FD가 열려 있다.

  • 0: 표준 입력(stdin)
  • 1: 표준 출력(stdout)
  • 2: 표준 에러(stderr)

이후 open() 같은 시스템 콜을 호출할 때마다 커널은 그 다음으로 사용 가능한 가장 작은 정수를 FD로 할당한다.

2. 왜 사용하는가

사용자 공간 프로세스는 커널 내부의 파일 시스템 구조체(inode, 파일 오프셋, 접근 모드 등)에 직접 접근할 수 없다. 이를 그대로 노출하면 메모리 주소나 포인터를 다루는 것과 같아서 보안 문제와 구현 복잡도가 커진다.

FD는 이 문제를 추상화된 정수 핸들로 해결한다. 프로세스는 "3번 자원에 읽기 요청을 보낸다"처럼 단순한 정수만 알면 되고, 실제 자원 관리(오프셋 추적, 참조 카운트, 락)는 커널이 담당한다. 이 덕분에 파일, 소켓, 파이프를 동일한 read()/write() 인터페이스로 다룰 수 있어 I/O 코드의 일관성이 생긴다.

3. 동작 원리

FD 시스템은 세 단계 테이블로 구성된다.

1) 프로세스별 FD 테이블 (Per-process file descriptor table) 각 프로세스마다 독립적으로 존재하며, FD 정수를 인덱스로 사용해 "열린 파일 테이블"의 엔트리를 가리키는 포인터 배열이다.

2) 열린 파일 테이블 (Open File Table / File Table) 커널 전역에 존재하며, 파일이 열릴 때마다 하나씩 생성된다. 여기에는 파일 오프셋(현재 읽기/쓰기 위치), 접근 모드(읽기/쓰기/추가), 참조 카운트가 저장된다. fork()나 dup()으로 FD가 복제되면 이 엔트리를 공유한다.

3) inode 테이블 (Inode Table) 실제 파일의 메타데이터(권한, 크기, 디스크 상 위치 등)를 담는다. 여러 열린 파일 테이블 엔트리가 같은 inode를 가리킬 수 있다.

이 3단계 구조 때문에 다음과 같은 차이가 생긴다.

  • open()을 두 번 호출하면 → 열린 파일 테이블 엔트리가 두 개 생성되며, 각각 독립적인 오프셋을 가진다.
  • dup() / dup2() / fork()로 FD를 복제하면 → 같은 열린 파일 테이블 엔트리를 공유하므로, 오프셋도 공유된다.
text
프로세스 A의 FD 테이블        열린 파일 테이블           inode 테이블
  0 -> stdin        \
  1 -> stdout        \___________ [엔트리 #1] ------> [inode: /dev/tty]
  2 -> stderr        /            offset, mode
  3 -> ------------->             refcount=1
                                 [엔트리 #2] ------> [inode: /data/log.txt]
                                  offset=128, mode=RDWR
                                  refcount=2  <----------\
프로세스 B의 FD 테이블                                    \
  3 -> ------------------------------------------------- (fork로 공유)

4. 코드 예제

Python의 os 모듈로 저수준 시스템 콜을 직접 다뤄보면 FD의 정수 특성과 공유 특성이 드러난다.

python
import os # 파일을 저수준으로 연다 -> 정수 FD를 반환 fd = os.open("example.txt", os.O_RDWR | os.O_CREAT, 0o644) print(f"할당된 FD: {fd}") # 보통 3 (0,1,2는 이미 사용 중) os.write(fd, b"hello file descriptor\n") os.lseek(fd, 0, os.SEEK_SET) # 오프셋을 처음으로 되돌림 print(os.read(fd, 100)) # dup으로 FD를 복제 -> 같은 열린 파일 테이블 엔트리를 공유 fd2 = os.dup(fd) print(f"복제된 FD: {fd2}") # fd로 오프셋을 이동시키면 fd2의 오프셋도 함께 변한다 (엔트리 공유) os.lseek(fd, 5, os.SEEK_SET) print(f"fd2로 읽은 위치: {os.lseek(fd2, 0, os.SEEK_CUR)}") # 5가 출력됨 os.close(fd) os.close(fd2)

FD 누수(leak)를 재현하는 예제. 파일을 닫지 않고 반복하면 한계에 도달한다.

python
import os import resource # 현재 프로세스가 열 수 있는 최대 FD 개수 확인 soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) print(f"soft limit: {soft}, hard limit: {hard}") fds = [] try: for i in range(soft + 10): fd = os.open("/tmp/test.txt", os.O_RDONLY) fds.append(fd) # close를 호출하지 않아 계속 누적됨 except OSError as e: print(f"에러 발생: {e}") # Too many open files (EMFILE) finally: for fd in fds: os.close(fd)

셸에서 특정 프로세스가 열고 있는 FD 목록을 확인하는 방법.

shell
# 프로세스 PID의 FD 상태를 /proc 파일시스템으로 확인 (리눅스) ls -l /proc/$$/fd # lsof로 특정 프로세스가 연 파일과 FD 번호 확인 lsof -p $$ # 시스템 전체 및 프로세스별 FD 제한 확인 ulimit -n

5. 시간 복잡도 또는 성능 특성

  • FD를 인덱스로 사용하는 프로세스별 FD 테이블 조회는 O(1)이다. 배열 기반 구조이기 때문에 read(fd, ...) 호출 시 커널이 해당 엔트리를 찾는 비용은 상수 시간이다.
  • select()는 감시 대상 FD 집합 전체를 매번 순회하므로 FD 개수에 비례해 O(n)이 걸리고, 리눅스 기본 구현에서 FD_SETSIZE(보통 1024)라는 상한이 있다.
  • epoll(리눅스)이나 kqueue(BSD/macOS)는 커널 내부에서 이벤트 발생 FD만 추려서 반환하므로, 대기 중인 FD 수가 많아도 실제 이벤트 처리 비용은 발생한 이벤트 수에 비례한다. 이 때문에 수만 개의 동시 연결을 다루는 서버에서 select 대신 epoll 계열이 선택된다.

6. 실무 사용 사례

  • Node.js / 웹 서버의 소켓 관리: 클라이언트 연결마다 소켓 FD가 하나씩 생성된다. Node.js는 내부적으로 리눅스에서 epoll을 사용해 다수의 소켓 FD를 논블로킹으로 감시한다.
  • 로그 파일 회전(log rotation): logrotate 같은 도구가 파일을 이동시켜도, 이미 열어놓은 프로세스는 기존 inode를 가리키는 FD를 그대로 들고 있어 로그가 유실되지 않는다. 프로세스가 새 로그 파일을 열려면 SIGHUP 등으로 FD를 재오픈해야 한다.
  • 컨테이너 환경의 ulimit 설정: Docker나 Kubernetes에서 --ulimit nofile=65536:65536 같은 옵션으로 컨테이너 내 프로세스의 최대 FD 개수를 조정한다. 기본값이 너무 낮으면 고성능 DB나 프록시 서버에서 EMFILE 에러가 발생한다.
  • 커넥션 풀(connection pool): DB 커넥션도 결국 소켓 FD이므로, 풀 크기를 시스템 FD 한도보다 크게 잡으면 장애로 이어진다.

7. 주의할 점

  • FD 누수: open() 후 close()를 호출하지 않으면 프로세스가 종료될 때까지 FD가 반환되지 않는다. 예외 처리 경로에서 close()가 누락되는 경우가 흔한 원인이다. Python에서는 with open(...) 같은 컨텍스트 매니저를 쓰는 것이 안전하다.
  • fork 이후 FD 공유: fork() 직후 자식 프로세스는 부모의 FD 테이블을 복사하지만, 열린 파일 테이블 엔트리는 공유한다. 부모나 자식 어느 한쪽에서 lseek으로 오프셋을 옮기면 다른 쪽에도 영향을 준다.
  • FD 재사용: 커널은 닫힌 FD 번호를 즉시 재사용할 수 있다. 비동기 콜백에서 이미 닫힌 FD 번호를 참조하는 코드를 남겨두면, 그사이 다른 자원이 같은 번호를 할당받아 엉뚱한 자원에 접근하는 버그가 생길 수 있다.
  • soft/hard limit 혼동: ulimit -n으로 보이는 값은 soft limit이며, 일반 사용자는 hard limit을 넘어설 수 없다. 컨테이너나 systemd 서비스에서는 별도의 설정 파일로 상한을 조정해야 한다.

8. 핵심 정리

파일 디스크립터는 커널 자원을 정수 하나로 추상화한 핸들이며, 프로세스별 FD 테이블 → 열린 파일 테이블 → inode 테이블이라는 3단계 구조로 동작한다. 이 구조 덕분에 dup/fork로 FD를 복제해도 오프셋 같은 상태를 공유할 수 있다. 실무에서는 FD 누수와 시스템 한도(ulimit) 초과가 장애의 주요 원인이므로, 자원을 명시적으로 닫는 습관과 epoll 같은 확장 가능한 I/O 감시 메커니즘 선택이 중요하다.