최근 널널한 개발자님의 소켓의 본질에 대한 이해을 보게됐습니다. 이 영상을 보던 중, 널널한 개발자님이 "소켓도 결국 파일이다. 즉, 특정 파일에 대해 찾아가는 과정에서 TCP를 처리하는 추상화된 인터페이스를 만나게되면 이 파일을 TCP 소켓이라고 부른다." 말씀을 듣고, 문득 "리눅스의 파일 시스템이 어떻게 동작하면 이런게 가능하지?"라는 생각이 들었습니다. 그래서 리눅스의 파일 시스템에 대해서 간단히 정리해보려 합니다.
파일 시스템은 스토리지 디바이스에 저장된 파일들을 관리합니다. 즉, 파일을 관리하는 방법이 다르다면 파일 시스템도 다르다고 할 수 있습니다..
리눅스의 파일 시스템에 대해 논의하기 전에 알아야 할 핵심 개념은 "리눅스에서 모든건 파일입니다" 라는 점 입니다. 리눅스에서는 일반적인 파일뿐만 아니라 블럭 디바이스(Block Device), 소켓 ,파이프(Pipe)등 모든 것이 통합 파일 시스템을 통해 관리됩니다.
우선 리눅스에서 파일이 어떻게 저장되고, 관리되는지 살펴보겠습니다.
리눅스에서는 파일의 메타데이터와 디렉터리 구조를 저장하기 위해 inode와 directory entry라는 두 가지 자료구조를 제공합니다.
inode와 directory entry에 대해 좀 더 구체적으로 설명하면 다음과 같습니다.

inode는 inode number, 파일 크기, 접근 권한, 수정 일자, 데이터 위치등과 같은 파일의 메타데이터를 저장합니다. 각 파일은 고유한 inode를 가지며, inode 역시 디스크에 저장되기 때문에 디스크 공간을 차지합니다.directory entry(dentry라고도 불림)는 파일의 이름과 inode에 대한 주소를 저장합니다. 또한 연관된 다른 directory entry 정보도 저장하며, 여러 directory entry들이 연결되어 파일 시스템의 디렉터리 구조를 형성합니다. directory entry는 inode와는 달리 파일을 탐색할 때 커널에 의해 생성되며, 메모리에 저장하여 관리합니다.이를 통해 directory entry와 inode는 다대일(Many-to-One) 관계를 가짐을 알 수 있습니다. 즉, 하나의 파일을 hard link로 여러 개 생성하더라도, 각 directory entry는 동일한 inode의 주소를 가지고 있게 됩니다.
inode와 directory entry는 각각 파일 메타데이터와 디렉터리간 관계를 저장합니다. 그렇다면, 파일 데이터들은 디스크에 어떻게 저장될까요?
저장 장치에 따라 다르지만 HDD를 예로 설명해보겠습니다.
HDD의 경우 한 섹터당 512바이트의 공간을 제공하고 있습니다. 한 섹터는 데이터를 저장하기에는 너무 작은 공간이기 때문에 리눅스에서는 연속된 8개의 섹터를 묶어 4KB의 공간을 논리적 블록(Logical Block)으로 사용합니다. 이 논리적 블록을 통해 데이터들을 저장하고 관리합니다.
마지막으로, 주의해야할 두 가지가 있습니다.
첫 번째로, directory entry는 메모리에서 관리되고 inode는 디스크에 관리되고 있음을 설명했습니다. 하지만 리눅스에서는 파일을 빠르게 접근하기 위해 페이지 캐시(page cache)를 사용하며, inode도 메모리에 캐싱합니다.
두 번째로, 파일 시스템에 의해 디스크가 구성될 때, 디스크는 superblock, inode table, data block area로 불리는 세 영역으로 분리됩니다.
inode들을 저장합니다.앞선 내용을 통해 directory entry, inode, superblock, 논리적 블록이 리눅스 파일 시스템을 구성함을 알 수 있었습니다. 그러나, 리눅스 커널은 다양한 파일 시스템들을 지원하기 위해 유저 프로세스와 파일 시스템 사이에 가상 파일 시스템(Virtual File System, VFS)이라 부르는 추상화된 계층을 추가했습니다.
VFS는 모든 파일 시스템이 지원하는 표준 인터페이스와 자료구조를 정의하고 있습니다. 즉, VFS를 통해 유저 프로세스는 파일 시스템과 상호작용할 때, 대상 파일 시스템의 구현체를 신경쓰지 않고 커널에서 제공하는 통합 인터페이스와만 상호작용하면 됩니다.
아래 이미지는 리눅스의 파일 시스템 아키텍처를 간단히 도식화한 이미지입니다.

위 이미지를 보면 VFS 아래 리눅스는 Ex4, XFS, ZFS와 같은 다양한 파일 시스템을 지원함을 알 수 있습니다. 또한 스토리지 종류에 따라 세 가지의 카테고리로 나눌 수 있습니다.
/proc 파일 시스템의 경우 가상 파일 시스템을 사용합니다.추가로 글의 시작에서 "소켓도 결국 파일이다" 라고 말했던걸 기억하시나요? 이에 대해서 설명해보려 합니다.
리눅스에서는 네트워크 소켓을 파일 디스크립터로 추상화하여 파일처럼 다룰 수 있게 합니다. 즉, 소켓을 통해 데이터를 주고받는 방식도 일반적인 파일을 다룰 때 사용하는 read(), write()와 유사하게 처리됩니다. 따라서 소켓은 VFS 계층에 의해 추상화되어 관리되기 때문에, 소켓을 파일처럼 사용할 수 있는 것 입니다.
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
write(sockfd, buffer, length);
read(sockfd, buffer, length);
파일 디스크립터란?
파일 디스크립터는 파일이나 입출력 자원을 식별하는 데 사용되는 추상적인 개념입니다.
파일 디스크립터는 정수 값을 가지며, 프로세스가 파일이나 소켓과 같은 자원을 다룰 때 이 디스크립터를 통해 접근하게 됩니다.
파일 시스템이 마운트 포인트(mount point)에 마운트 되면, 해당 마운트 포인트를 통해 파일에 접근하고 관리할 수 있습니다. 이때 VFS는 파일 접근에 필요한 인터페이스를 제공하며, 애플리케이션들은 시스템 콜을 통해 해당 인터페이스를 사용합니다.
마운트 포인트란?
마운트 포인트는 특정 디렉터리 경로에 새로운 저장장치가 마운트될 때, 해당 경로를 마운트 포인트라고 불립니다. '/sub' 경로에 저장장치가 마운트되어 있다면, 해당 경로가 마운트 포인트가 됩니다.
cat 커맨드를 예로들어보겠습니다. 우선 cat 커맨드를 호출하면 파일에 접근하는 open()이 호출됩니다. 이후 read() 호출을 통해 파일의 내용을 읽어옵니다. 마지막으로 write() 을 통해 콘솔에 파일의 내용을 출력합니다.
int open(const char *pathname, int flags, mode_t mode);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
파일 I/O는 다양한 종류의 I/O 작업이 존재하며, 나눠보면 다음과 같이 나눠볼 수 있습니다.
Buffered I/O의 경우 파일을 더 빠르게 접근하기 위해 표준 라이브러리 캐싱을 사용합니다. 이때, 표준 라이브러리가 직접 시스템 콜을 통해 파일에 접근합니다.
Unbuffered I/O의 경우 표준 라이브러리를 사용하지 않고 직접 시스템 콜을 통해 파일에 접근합니다.
즉, buffering은 유저 영역에서의 캐싱 메커니즘을 의미합니다. 또한, Buffered, Unbuffered I/O는 유저 영역에서의 캐싱을 사용하기 때문에 파일에 접근할 때 시스템 콜을 사용합니다.
페이지 캐시를 사용 유무에 따라 Direct I/O인지, Non-Direct I/O인지 구분할 수 있습니다.
우선 Direct I/O의 경우 파일 시스템을 통해 파일에 접근할 때, 페이지 캐시를 활용하지 않습니다.
반대로 Non-Direct I/O는 파일을 읽거나 쓸 때, 페이지 캐시를 활용하여 커널이나 추가적인 시스템 콜을 통해 디스크에 데이터를 반영합니다.
Direct I/O를 사용하기 위해서는 시스템 콜을 할 때 O_DIRECT 플래그를 사용해야 합니다. 만약 해당 플래그를 설정하지 않으면 Non-Direct I/O가 사용됩니다.
추가로 MySQL과 같은 데이터베이스에서 "페이지 캐시를 활용하지 않고 버퍼 풀을 통해 변경된 데이터를 직접 디스크에 반영할 수 있다." 를 들어본적이 있으실 겁니다. 이는 MySQL이 O_DIRECT 플래그를 통해 페이지 캐시를 사용하지 않고, 메모리에 저장된 데이터를 DMA를 통해 직접 디스크에 반영하고 있는겁니다. 그렇다면 왜? O_DIRECT를 사용할까요?
OS 캐시인 페이지 캐시에 대해 다시 정리해보면 다음과 같습니다.
페이지 캐시는 디스크로부터 데이터를 읽어올 때, 해당 파일이 캐시에 존재하면 해당 파일을 바로 반환합니다. 반대로 데이터를 쓸 때, 페이지 캐시에 임시 저장한 다음 OS에 의해 디스크에 반영합니다.
즉, 페이지 캐시는 디스크에서의 저장 속도와 애플리케이션 처리 속도 사이에서 완충 역할을 수행합니다. 그렇다면 O_DIRECT를 사용하게 되면 쓰기 과정에서의 완충역할을 해주는 캐시가 없어 오히려 성능이 더 안좋아지지 않을까요?
사실 I/O 성능은 안좋아지는게 맞습니다. 그러면 왜 사용할까요? 메모리 최적화를 위해 사용합니다.
즉, 더블버퍼링(유저 영역과 커널 영역에서 모두 저장)을 막아 메모리를 효율적으로 사용하기 위함입니다.
InnoDB의 경우 이미 버퍼 풀(Buffer Pool)이라는 고도화된 메모리 관리 영역을 가지고 있으며, 이전에 작성한 스왑 메모리를 보면 OS는 최대한 데이터를 캐시합니다. 또한 swappiness를 설정하지 않는다면 메모리 사용량이 일정 기준을 넘어가게 되면 버퍼 풀의 데이터를 스왑하려합니다.
결론적으로 O_DIRECT를 사용하는 이유는 메모리 사용량이 많은 환경에서 더블버퍼링으로 인한 중복 저장과 OS에서의 캐시 동작으로 인한 불필요한 디스크 I/O가 발생하는 것을 막기 위함임을 유추할 수 있습니다.
디스크 I/O 작업에 대해 block 되는지 여부에 따라 Blocking I/O와 Non-Blocking I/O로 나눠볼 수 있습니다.
Blocking I/O는 I/O 작업을 할 때 응답이 올 때까지 해당 스레드가 Blocking되어 다른 작업을 수행할 수 없는 것을 의미합니다.
Non-Blocking I/O는 I/O 작업에 대해 스레드가 Blocking 되지 않고, 다른 작업을 수행할 수 있습니다. 이후 작업이 완료되면 polling 또는 이벤트를 통해 결괏값을 조회할 수 있습니다. 예를들면 네트워크 소켓에 접근할 때, O_NONBLOCK 플래스를 지정하면 Non-Blocking하게 작업이 처리됩니다.
Synchronous I/O 즉, 동기 작업은 I/O에 대해 응답이 올 때까지 대기하는 작업을 의미합니다.
Asynchronous I/O 즉, 비동기 작업은 I/O에 대해 응답이 올 때까지 대기하지 않고, 다른 작업은 처리합니다. 또한 I/O가 완료되면 이벤트를 통해 해당 응답을 조회할 수 있습니다.
동기 작업을 처리하기 위해서는 O_SYNC 또는 O_DSYNC 플래그를 지정하면 됩니다. 만약 O_DSYNC를 사용하면 디스크에 쓰기 작업이 완료될때까지 대기합니다. O_SYNC를 사용하면 O_DSYNC 설정과 추가로 파일 메타데이터가 저장될때까지 대기합니다.
위 작업에서 알 수 있듯이 네트워크, 디스크 접근 모두 파일 시스템을 통해 이뤄집니다. 즉, 리눅스의 모든 것이 파일입니다.
inode와 directory entry를 통해 접근할 수 있으며, 해당 자료구조들은 메모리에 캐싱되어 관리됩니다.참고
https://medium.com/codex/how-does-the-linux-file-system-work-180bfcb09a5f
https://blog.naver.com/eldkrpdla121/220706916315
https://www.redhat.com/ko/topics/data-storage/network-attached-storage
https://systemv.tistory.com/49
https://youtu.be/3jQ2dBpiqPo?si=7tGhya12hq79GawR