[ Android Essential ] 파일을 믿었던 아이는 어떻게 자라는가

malcongmalcom·2025년 7월 4일

Android Essential

목록 보기
2/5
post-thumbnail

거시적 해석과 미시적 해석

한 현상은 크게 보면 거시적 관점에서, 작게 보면 미시적 관점에서 바라볼 수 있다. 예를 들어 아래의 간단한 파일 내용을 불러와 콘솔에 출력하는 3줄짜리 코드를 보자.

fun main() {
    val file = File("malcongmalcom.txt")
    val contents = file.readText()
    println(contents)
}

대부분 사람들은 그저 "간단한 파일 입출력 코드"라고 생각할 것이다. 나 역시 마찬가지다. 하지만 이 3줄의 코드는 관점에 따라 완전히 다르게 해석될 수 있다. 단순한 코드 실행 절차일 수도 있고, 더 나아가 사회 문제를 은유하는 상징일 수도 있다.

거시적으로 본다는 것은 문제의 맥락과 배경을 넓게 살펴본다는 의미다. 예를 들어, 갑자기 이 코드가 이해되지 않는다면, 그 문제를 자신의 컨디션이나 환경과 연결 지어 생각해볼 수도 있다. "왜 이 코드를 꼭 이해해야 하지? 꼭 그래야 하나?"라고 자문하면서, 사회가 지나치게 경쟁과 효율성에 집착해 쓸데없는 부분까지 의미를 부여하고 있다고 느낄 수도 있다. "그래, 이런 과열된 경쟁 사회에서 나만의 길을 찾자. 나는 사회운동가가 되어야겠다." 이런 식으로 문제를 넓은 시각에서 바라보는 것이 바로 거시적 사고이다.

반면, 미시적으로 본다는 것은 문제를 점점 더 작고 구체적인 단위로 분해해서 들여다보는 것이다. "이 3줄 코드는 결국 컴파일러라는 프로그램에 들어가는 텍스트일 뿐이지.", "컴파일러는 결국 운영체제 위에서 동작하는 프로그램이니까, 운영체제의 관점에서 해석해야 해.", "운영체제도 결국 소프트웨어인데, 그 소프트웨어가 실행되려면 하드웨어가 필요하지. 결국 CPU가 계산하는 과정으로 봐야 해.", "아니 잠깐, CPU가 저 코드를 직접 계산하진 않잖아. 디스크에 저장된 데이터를 가져오는 데도 시간이 걸릴 테니까, 디스크 컨트롤러가 실제 작업을 수행하는 거지.", "디스크 컨트롤러도 전자회로일 뿐이고, 결국 자기 배열이 바뀌면서 데이터가 저장되는 거야. 결국 가장 미시적인 수준에서는 자기 배열의 변화라고 할 수 있겠네."

결국, 하나의 현상도 어떻게 바라보느냐에 따라 전혀 다른 해석이 나온다. 문제를 문제로 삼느냐, 아니면 단순한 현상으로 보느냐에 따라 시야가 달라지는 것이다.

오늘은 이 3줄짜리 코드가 실행될 때, 컴퓨터 내부에서 도대체 어떤 일이 벌어지는지, 미시적인 관점에서 그 과정을 차근차근 살펴보려 한다.

OS라는 산타, 파일이라는 선물

어렸을 때, 집으로 찾아오는 산타클로스를 보고 기뻐했던 기억이 있는가? 필자에게도 그런 기억이 있다. 어릴 적, 산타클로스는 가끔 게임기를 가져다주곤 했다. 물론 지금은 안다. 그 산타클로스가 사실은 엄마의 부탁을 받은 삼촌이었다는 걸. 하지만 그건 중요하지 않았다. 다섯 살 아이의 눈에 그는 분명 산타클로스였고, 나는 선물을 받은 행복한 아이였다. 그리고 시간이 흘러 진실을 알게 된 지금도, 엄마와 삼촌의 사랑은 여전히 내 마음 깊은 곳에 남아 있다.

아이가 자라며 산타클로스의 정체를 이해하고 그 안의 진심과 사랑을 깨닫듯, 우리 개발자들도 코드 한 줄 한 줄 속에 담긴 본질, 보이지 않는 수많은 개발자의 헌신, 그리고 소프트웨어에 대한 깊은 사랑을 언젠가는 발견해야 한다고 믿는다.

분명, 산타클로스는 환상이다. 하지만 의미는 있다. 그 안엔 진심이 있고, 따뜻함이 있고, 누군가의 노력이 담겨 있다. 파일도 마찬가지다. 우리가 당연하게 다루는 이 ‘파일’이라는 개념 역시 하나의 환상일 뿐이다. 하지만 그 속에는 운영체제라는 삼촌의 손길이, CPU라는 어머니의 마음이, 디스크라는 어른 모두의 헌신이 고스란히 녹아 있다.

어린아이는 그 사랑의 무게나 헌신의 깊이를 온전히 알 수 없다. 기쁨은 있었지만, 그 기쁨 뒤에 있던 마음들은 미처 보지 못했다. 이제는 그 시선을 벗고, 산타클로스의 모자를 벗길 시간이다. 함께, 그 환상의 이면으로 한 걸음 들어가 보자.

산타의 모자를 쓴 삼촌의 손길

파일은 결국 본질적으로 비트의 나열일 뿐이다. 그 안에 텍스트든, 이미지든, 실행 파일이든 모두 비트로 표현된다. 예를 들어 “Hello”라는 문자열은 ASCII 또는 UTF-8로 인코딩 된 비트 시퀀스이고, jpeg 이미지는 픽셀 정보와 압축 메타데이터가 비트 시퀀스로 표현된다.

이 비트들이 저장되는 장소가 바로 저장장치이며, 여기에는 물리적 실체가 있다. 하드디스크는 자성을 띄는 디스크 플래터 위에 NS극으로 자화된 상태로, SSD는 플래시 메모리 셀에 전자가 존재하느냐 아니냐로, RAM은 전기적으로 충전되어 있느냐 아니냐로 0과 1을 표현한다. 결국 비트라는 것도 추상적인 개념일 뿐, 실제로는 전기적·자기적인 상태로 존재할 뿐이다.

그런데 파일이라는 건 의도적으로 변하지 않는 비트를 사용한다. 변하지 않는다는 것은 전원이 꺼져도 상태가 유지된다는 의미이며, 그래서 파일은 비휘발성 메모리에 저장된다. 파일 시스템이란 결국 이 비트들을 추적하고 관리하기 위한 프로그램이며, 운영체제의 핵심 기능 중 하나이다. 이 말은 파일 시스템 자체도 소프트웨어이고, 비트로 구성된 실행 코드라는 의미다.

파일 시스템은 “어느 위치부터 어느 위치까지가 하나의 파일이다”, “이 파일의 이름은 무엇이고, 수정된 시각은 언제다”라는 정보를 특정한 규칙에 따라 관리한다. 즉, 비트를 어떻게 저장하고, 이름 붙이고, 찾을 수 있게 만들지에 대한 약속이자 코드다. 운영체제는 하드웨어 자원을 효율적으로 관리하기 위한 시스템 소프트웨어이며, 그 안에는 프로세스, 메모리, 입출력 장치, 그리고 파일 시스템 관리가 포함된다. 파일 시스템은 OS의 일부 기능이며, 저장장치와 프로그램 사이를 연결해주는 추상화 계층이다.

실제로 ext4, NTFS, APFS, FAT32 같은 파일 시스템은 C 언어로 구현되어 있으며, 리눅스 커널의 fs/ext4 같은 디렉토리에 그 코드가 들어 있다. 우리가 사용하는 open(), read(), write() 같은 시스템 콜이 호출되면 내부적으로 이 파일 시스템 코드가 실행되어 물리적인 저장장치와 통신한다.

즉, 우리가 파일이라 부르는 개념은 결국 OS가 저장장치 위에 올려놓은 하나의 환상에 불과하다. 현실은 단순한 전자기적 비트의 나열이며, OS가 “이건 텍스트야!”, “이건 이미지야!”라고 해석해주기 때문에 의미를 갖는 것이다.

이에 대한 또 하나의 증거는, ‘파일’이라는 개념이 꼭 불변 저장장치 위에만 존재하는 것은 아니라는 점이다. 이렇게 생각해보자. 우리는 보통 파일이라고 하면 디스크에 영구히 저장된 어떤 정적인 대상을 떠올린다. 하지만 정말 그럴까? 정답은, 아니다. 현실의 물리적 조건이 어떠하든, 운영체제가 그것을 파일이라고 생각하는 순간, 그것은 곧 파일이 된다. 마치 우리가 어릴 적, 진짜 산타가 있다고 믿었던 것처럼 말이다.

RAM은 휘발성이라 파일이 아니라고 생각할 수도 있지만, /dev/shm이나 tmpfs 같은 구조를 통해 RAM도 파일처럼 사용할 수 있다. 이것은 운영체제가 RAM을 파일 시스템처럼 해석했기 때문에 가능한 것이다. 따라서 파일이라는 개념 자체는 해석의 결과이며, 물리적 실체 그 자체는 아니다.

산타가 삼촌임을 알게 된 그 밤

어떤가? 조금 충격적일 수도 있다. 그렇다, 파일 시스템이 없으면 ‘파일’이라는 것도 존재하지 않는다. USB의 파일 시스템이 손상되면 실제 데이터 비트는 그대로 남아 있어도, 그 비트가 어떤 파일에 속하는지 알 수 없게 된다. 즉, 파일이라는 개념은 해석과 메타데이터 없이는 성립하지 않는다. 이 메타데이터는 저장장치 내 파일 시스템에 포함되어 있으며, ext4는 inode를, FAT32는 FAT 테이블을 통해 각 파일의 저장 위치를 추적한다. 따라서 파일은 단순한 비트의 집합이 아니라, 데이터와 메타데이터가 합쳐진 존재이며, 파일 시스템은 이 메타데이터를 해석하는 일종의 프로토콜이라 할 수 있다. 부디 이 글을 읽는 독자 중 누구도 운영체제에게 배신감을 느끼지 않길 간절히 바란다.

엄마와 삼촌의 비밀 회의실

하나 더 충격적인 사실을 말하자면, 파일의 확장자와 경로 역시 운영체제와 사용자 사이의 해석에 불과하다는 점이다. 예를 들어, .jpg, .txt, .exe 같은 확장자는 OS에 “이 비트를 이렇게 해석해라”라고 알려주는 일종의 신호 역할을 하지만, 이는 실제 데이터와는 별개다. 즉, .txt 파일을 .jpg로 확장자만 바꿔도 내부 비트는 전혀 변하지 않는다. Windows는 주로 확장자에 의존해 파일 타입을 구분하지만, Linux나 macOS는 파일 내용을 직접 살펴 매직 넘버 같은 시그니처를 통해 파일 타입을 추론하기도 한다. 여기서 매직 넘버란 파일 데이터 내부에 포함된 고유한 식별자이며, 파일의 권한(퍼미션) 정보는 파일 시스템의 메타데이터에 저장되어 있다.

파일의 경로는 파일 시스템에서 파일을 찾는 위치 정보를 나타내는데, 크게 절대 경로와 상대 경로로 구분할 수 있다. 절대 경로는 루트 디렉터리(/ 또는 드라이브 문자)부터 시작해 파일이 저장된 정확한 위치까지 모든 경로를 명시하는 반면, 상대 경로는 현재 작업 중인 디렉터리를 기준으로 파일 위치를 지정한다. 예를 들어, /home/user/documents/file.txt는 절대 경로이고, ../pictures/image.jpg는 현재 디렉터리의 상위 폴더에 있는 pictures 폴더 내 파일을 가리키는 상대 경로다.

위 코드를 다시 살펴보자.

fun main() {
    val file = File("malcongmalcom.txt")
    val contents = file.readText()
    println(contents)
}

이 간단한 코드에서 File("malcongmalcom.txt")는 운영체제에 상대 경로(현재 작업 디렉터리를 기준으로 파일 위치)를 전달하는 역할을 한다. readText() 함수는 내부적으로 open(), read(), close() 같은 시스템 콜을 호출하면서 파일을 읽기 모드로 연다. 이때 운영체제는 malcongmalcom.txt라는 이름과 경로 정보를 받아 해당 파일이 저장된 위치를 파일 시스템 메타데이터(예: inode 또는 FAT 테이블)를 통해 찾아낸다.

운영체제는 파일의 권한과 상태를 확인하고, 실제 저장장치에 저장된 물리적 블록 위치를 파악한 후, 디스크 드라이버에 데이터를 읽도록 요청한다. CPU는 이 과정에서 사용자 모드에서 커널 모드로 전환되어, 입출력 요청이 안전하고 효율적으로 처리되도록 한다. 파일의 내용이 메모리로 복사되고, 이 데이터가 다시 readText() 함수의 결과로 반환되어 contents 변수에 저장된다. 마지막으로 println(contents)가 실행되면서 읽어온 파일 내용이 콘솔에 출력된다.

이처럼 애플리케이션이 전달하는 경로와 파일 열기 모드 같은 단순한 인자들은 운영체제 내부에서 복잡한 경로 해석, 메타데이터 조회, 권한 검사, 물리적 저장장치 입출력 등 일련의 과정을 거치게 된다. 결국, 우리가 코드를 통해 ‘파일을 읽는다’고 할 때 그 뒤에서는 파일 시스템과 저장장치 드라이버, 커널 모드 전환, CPU 스케줄러 등이 유기적으로 협력하여 작업을 수행하는 것이다.

어떤가? 부디 이 글을 읽고 충격받는 독자가 없길 간절히 바란다. 우리 모두 이제 5살짜리 어린아이의 환상에서 벗어날 때다. 어른들은 아이들에게 “산타 같은 건 없어!”라고 말하기를 꺼려하지만, 그 역시 성장의 한 과정임을 이해해야 한다. 이제부터는 잔혹한 현실이 어떻게 돌아가는지, 산타 같은 존재가 왜 환상인지, 그 ‘그날 밤’의 진실을 좀 더 자세히 들여다보자. 바로 산타가 삼촌이라는 사실을 알게 된 그 밤, 그 밤으로 함께 떠나보자.

산타, 삼촌 그리고 엄마

OS 삼촌은 혼자서 일을 처리할 수 없다. 그 뒤엔 언제나 CPU라는 엄마가 있다. 엄마는 눈에 띄지 않게 뒤에서 모든 걸 조율하지만, 겉으로 보기엔 마치 OS 삼촌이 진짜 산타처럼 보인다. 그래서 우린 속았다. 철석같이 믿었다. 만약 엄마가 없었더라면, 그렇게까지 속진 않았을 것이다. "엄마도 아니고, 아빠도 아니고… 산타는 분명 북극에서 온 진짜 사람이야!" 어렸을 땐 그렇게 믿었고, 의심조차 하지 않았다. 이제는 알게 된 진실이 있다. OS 삼촌도, 산타도, 혼자선 아무것도 아니었다. 그 배후엔 언제나 조용히 모든 걸 움직이는 엄마가 있었다. 지금부터는, 그 진짜 주인공—CPU 엄마에 대해 더 깊이 들여다보자.

선물 사러 출발!

fun main() {
    val file = File("malcongmalcom.txt")
    val contents = file.readText()
    println(contents)
}

위처럼 파일을 읽으려 할 때, 애플리케이션은 내부적으로 open(), read(), close() 같은 시스템 콜을 운영체제에 전달한다. 이때 시스템 콜은 CPU가 사용자 모드(Ring 3)에서 커널 모드(Ring 0)로 전환하는 신호 역할을 하며, 특권 권한이 높은 커널 모드에서만 가능한 파일 입출력 처리가 시작된다. CPU는 현재 실행 중인 사용자 프로세스의 상태를 커널용 스택에 저장하고, 커널 내부의 파일 시스템 관리 코드로 제어를 넘긴다.

여기서 “CPU가 현재 실행 중인 사용자 프로세스의 상태를 커널용 스택에 저장한다”는 말은, 단순히 상태를 어디에 메모해두는 정도가 아니다. 정확히는, 현재 실행 중인 스레드의 레지스터 상태(프로그램 카운터, 스택 포인터, 플래그 등)와 같은 정보들을 커널 전용 메모리 영역의 스택 공간에 저장하는 것이다. 커널 스택은 일반 유저 공간이 아니라, 커널이 직접 관리하는 메모리 영역(Ring 0)에 위치해 있으며, 스레드마다 독립적으로 하나씩 존재한다. 이렇게 별도로 존재하는 이유는 보안과 안정성 때문이다. 사용자 공간의 메모리는 악의적인 프로그램이 조작할 수 있는 여지가 있지만, 커널 공간은 일반적으로 접근이 차단되어 있고, 중요한 시스템 정보들을 안전하게 보관해야 하기 때문이다.

이제 “커널 내부의 파일 시스템 관리 코드로 제어를 넘긴다”는 말의 의미를 살펴보자. 운영체제 커널은 부팅 시 이미 메모리 상에 로딩되어 있는 하나의 프로그램이다. 커널은 메모리의 특정 주소 범위에 로딩되어 있고, 평소에는 CPU에 의해 직접 실행되지 않는다. 말하자면, 그냥 ‘존재’만 하고 있을 뿐이다. 하지만 시스템 콜이 발생하면, CPU는 이 커널 코드의 특정 진입 지점으로 제어 흐름을 “점프(jump)”한다. 이 jump는 단순히 CPU의 프로그램 카운터(PC) 레지스터 값을 바꿔서 다음 명령어를 커널 쪽 주소에서부터 가져오도록 만든다는 뜻이다. CPU는 본질적으로 메모리에서 명령어를 한 줄씩 순차적으로 가져와 실행하는 단순한 장치이고, 프로그램이라는 건 결국 이 명령어들의 집합일 뿐이다. 우리가 “점프한다”고 말하는 것은, CPU가 다음에 실행할 명령어가 위치한 주소를 기존 사용자 코드 주소가 아닌, 커널 코드의 특정 위치로 바꾼다는 것이다.

결국 CPU는 명령어를 순차적으로 계산하는 계산기이자 실행 엔진이다. 그리고 그 명령어들의 흐름을 우리는 ‘스레드(Thread)’라고 부른다. 시스템 콜도 결국 스레드가 실행하는 명령어 중 하나다. 중요한 건 시스템 콜은 프로세스 전체가 아니라, 하나의 스레드 단위로 발생한다는 점이다. 예를 들어 스레드 A가 시스템 콜을 호출하면, 이 스레드 A만 커널 모드로 진입하는 것이다. 같은 프로세스에 속한 다른 스레드들은 영향을 받지 않으며, 계속 사용자 모드에서 실행된다. 다시 말해, 시스템 콜은 단일 스레드의 커널 진입이며, 프로세스 전체가 멈추는 건 아니다.

커널은 앞서 말했듯, 스레드마다 고유한 커널 스택을 갖는다. 시스템 콜을 호출한 스레드는 그 순간, 자신의 유저 스택이 아닌 커널 스택으로 스택 포인터를 전환하게 된다. 유저 스택은 사용자 영역 메모리에 존재하고, 커널 스택은 커널 영역에 존재한다. 이 전환은 CPU가 Ring 3 → Ring 0으로 진입할 때 자동으로 발생하며, 이때 CPU는 커널 스택 포인터로 스택 레지스터 값을 바꾼다. 이 커널 스택은 작고 고정된 크기(보통 8KB)로, 커널 내부 함수 호출, 인터럽트 처리, 시스템 콜 로직 수행 등에 사용된다.

그럼 여기서 “스택”이라는 건 뭘까? CPU가 무슨 사람도 아닌데 스택을 쓴다는 건 무슨 말일까? 스택은 단순한 추상 개념이 아니라 실제 메모리의 한 영역을 의미한다. 함수 호출 시마다 해당 함수의 지역 변수, 복귀 주소 등을 저장해두기 위해 사용되는 공간이다. 그리고 이 스택은 후입선출(LIFO: Last In, First Out) 구조로 동작한다. 즉, 가장 마지막에 들어온 데이터가 가장 먼저 나간다.

fun santa() {
    val a = 42      // 스택에 저장
    val b = a + 1   // b도 스택에 저장
    println(b)      // 함수 종료 시 a, b 제거됨
}

우리가 fun santa()에서 val a = 42 같은 코드를 쓰면, 이 a 값은 스택에 저장되고, foo()가 끝날 때 스택에서 제거된다. 이런 저장과 제거는 전부 CPU의 스택 포인터(stack pointer)라는 레지스터를 통해 제어된다.

CPU는 각 스레드마다 자신이 사용할 스택 포인터 값을 유지하고 있고, 이 레지스터는 현재 스택의 꼭대기를 가리킨다. CPU는 함수를 호출하거나 복귀할 때, 이 포인터 값을 기준으로 스택에 데이터를 push/pop 한다. 이 스택은 단순한 메모리 블록이지만, CPU가 다음 데이터를 어디에 넣고 꺼낼지 알기 위해 반드시 포인터가 필요하다. 예를 들어 x86에서는 ESP, x86_64에서는 RSP, ARM에서는 SP 같은 레지스터가 이 역할을 한다.

아래는 이 내용을 이해하는 데 도움이 되는 메모리 구조 표이다:

사용자 공간 (Ring 3)코틀린에서의 예시 및 설명
.text (코드 영역)컴파일된 함수와 메서드 바이트코드가 위치. 예: fun main() { ... } 코드 부분
.data, .bss전역 객체, 상수 등. 코틀린에서는 const val 같은 상수가 여기에 대응할 수 있음
Heap객체와 배열이 동적으로 할당되는 영역. 예: val list = mutableListOf<Int>() 이 리스트 객체는 힙에 저장됨
Stack (User Stack)함수 호출 시 지역 변수, 매개변수, 리턴 주소 등이 저장되는 영역. 예:

커널 공간 (Ring 0)설명 및 역할
커널 코드운영체제의 핵심 로직이 위치한 영역으로, 시스템 콜 처리, 하드웨어 드라이버 등이 포함됨
커널 데이터커널 전역 변수, 각종 상태 정보, 락(lock) 같은 동기화 자료구조들이 저장되는 영역
커널 힙 (kmalloc 등)커널 내에서 동적으로 메모리를 할당할 때 사용하는 영역 (예: 장치 드라이버가 임시 버퍼 필요할 때)
커널 스택각 스레드마다 별도로 존재하며, 시스템 콜이나 인터럽트 처리 시 호출 정보, 지역 변수 등을 저장하는 스택 공간
페이지 캐시, 슬랩 캐시 등디스크 I/O 효율화를 위해 데이터를 메모리에 임시 저장하는 캐시 메모리 영역, 파일 데이터나 메타데이터가 이곳에 저장됨

결론적으로, 시스템 콜은 하나의 스레드가 실행 중 커널로 진입하는 사건이며, CPU는 이 시점에 사용자 스택에서 커널 스택으로 전환하고, 커널 메모리에 적재된 코드로 점프하여 실행을 계속한다. 이 모든 흐름은 CPU의 레지스터, 스택 포인터, 그리고 메모리 주소 체계와 밀접하게 맞물려 있으며, 결국 단순한 파일 읽기 동작조차도 이처럼 복잡하고 정교한 구조 위에서 작동하고 있다.

장난감 가게 찾기

지금부터는 시스템 콜이 발생했을 때, 즉 특정 명령어가 CPU에 들어와 스택 포인터가 커널 스택을 가리키는 순간부터 어떤 일이 벌어지는지 살펴보자.

커널에 진입한 첫 작업은 파일 경로 문자열을 해석하는 것이다. 프로세스의 현재 작업 디렉터리(CWD)를 기준으로 경로를 정규화하고, ext4 파일 시스템이라면 디렉터리별로 inode를, FAT32 계열이라면 디렉터리 엔트리를 순차적으로 탐색한다. 최종적으로 "malcongmalcom.txt"에 해당하는 inode 또는 엔트리를 찾으면, 파일 크기, 권한, 논리 블록 목록 등 메타데이터를 메모리에 적재한다.

여기서 첫 번째 의문점이 생길 수 있다. ‘프로세스의 현재 작업 디렉터리를 기준으로 경로를 정규화할 수 있는 이유는 무엇일까?’ 앞서 내용을 잘 이해했다면 쉽게 답할 수 있다. 시스템 콜이 발생할 때 CPU가 해당 프로세스의 상태를 커널용 스택에 저장하기 때문이다.

이렇게 상태가 저장된 후에 실행되는 명령어는 커널 내부의 시스템 콜 처리 함수, 즉 커널 코드의 특정 부분이다. 이 커널 코드는 메모리 내 특정 영역에 상주하고 있다.

다음으로, inode는 어디에 저장되어 있고 어떤 정보를 담고 있을까? inode는 디스크 상에 존재하는 데이터 구조로, 각 파일마다 고유하게 할당되어 있다. 이 inode 관리 역시 운영체제와 파일 시스템의 역할이다. 파일 생성 시 inode를 할당하고 관리하는 것, 그리고 삭제, 메타데이터 갱신, 디스크 공간 할당 같은 작업이 모두 OS의 핵심 기능이다.

비록 inode는 디스크에 저장되지만, 파일 시스템은 디스크에 매번 접근하는 데 너무 오랜 시간이 걸리므로, 운영체제 커널이 inode 정보를 커널 데이터 영역에 캐시 형태로 적재해둔다. 덕분에 같은 inode를 다시 조회할 때는 디스크 접근 없이 메모리 상의 캐시에서 빠르게 확인할 수 있다. 따라서 커널 내부의 파일 시스템 함수가 inode를 찾는다는 것은, 우선 커널 데이터 영역에 해당 inode 정보가 있는지 확인하고, 없다면 디스크에서 inode 블록을 읽어 메모리에 올려서 사용하는 과정을 뜻한다.

결국, inode는 디스크에 저장된 파일 메타데이터 구조체이자, 동시에 커널 데이터 영역에서 효율적으로 관리되는 메모리 캐시 상태로 존재한다는 것이다. 이처럼 여러 단계를 거쳐 inode가 메모리에 적재되고 활용되는 과정을 이해할 수 있다.

그 장난감 있어요?

커널은 파일에 접근할 때, 해당 파일의 메타데이터를 기반으로 접근 권한(rwx), 소유자(UID/GID), 잠금 상태 등을 검사한다. 여기서 메타데이터는 커널의 데이터 영역에 존재하며, 커널 스택에 있지 않은 이유는 여러 프로세스의 정보가 함께 관리되어야 하기 때문이다. 이 메타데이터에는 파일의 권한 정보, 소유자, 타임스탬프, 크기, 저장된 블록 위치 정보 등이 포함되어 있다. 쉽게 말해, 이 정보는 ‘선물을 어느 가게에서 사야 하는지’, ‘이미 예약된 상태인지’ 등을 알려주는 역할을 한다. 만약 다른 프로세스가 쓰기 잠금을 걸지 않았고 현재 사용자에게 읽기 권한이 있다면, 커널은 새로운 파일 객체(struct file*)를 생성하여 이를 해당 프로세스의 파일 디스크립터 테이블에 등록하고, open() 시스템 콜의 반환값으로 파일 디스크립터 번호를 돌려준다.

먼저 짚고 넘어가야 할 개념은 ‘프로세스 테이블’인데, 여기서 말하는 ‘프로세스 테이블’은 정확히는 각 프로세스마다 독립적으로 존재하는 파일 디스크립터 테이블을 의미한다. 이 테이블은 정수 인덱스(파일 디스크립터 번호)와 커널 내부의 struct file* 포인터를 매핑한다. 즉, 사용자 프로그램 입장에서는 단순히 숫자만 알고 있으면 되고, 커널은 이 숫자를 기반으로 실제 파일 정보를 찾아 처리한다. 따라서 open() 시스템 콜이 반환하는 값은 바로 이 프로세스의 파일 디스크립터 테이블에 등록된 정수 인덱스다.

여기서 중요한 점은, 이러한 비교와 관리는 ‘다른 프로세스’ 단위로 이루어진다는 것이다. 같은 프로세스 내에서는 스레드들이 파일 디스크립터 테이블을 공유한다. 왜냐하면, 스레드는 하나의 프로세스 내에서 병렬로 작업을 처리하기 위한 실행 단위일 뿐이고, 파일 디스크립터 테이블을 스레드마다 따로 가진다면 같은 파일을 여러 번 열어야 하는 불합리한 상황이 발생하기 때문이다.

그렇다면 프로세스마다 독립적인 파일 디스크립터 테이블을 가져야 하는 이유를 살펴보자. 첫 번째 이유는 같은 파일이라도 각 프로세스가 다르게 다룰 수 있어야 하기 때문이다. 예를 들어 cat file.txt와 vim file.txt를 동시에 실행하면, cat은 읽기 모드로 파일을 열고 vim은 읽기와 쓰기 모드로 연다. 같은 파일이라도 서로 다른 접근 모드, 다른 읽기/쓰기 위치(offset), 그리고 서로 다른 파일 디스크립터 번호와 독립적인 파일 처리 상태를 갖는다. 따라서 각 프로세스는 자신만의 파일 디스크립터 테이블을 가져야 한다.

두 번째 이유는 파일을 닫는 책임이 프로세스 단위에 있기 때문이다. 만약 파일 디스크립터 테이블이 여러 프로세스 사이에서 공유된다면, 한 프로세스가 close(fd)를 호출했을 때 다른 프로세스가 그 파일 디스크립터를 계속 사용하는 상황이 문제가 된다. 따라서 독립적인 테이블이 필요하다.

세 번째 이유는 자식 프로세스에게 선택적으로 파일 디스크립터를 전달하기 위함이다. fork() 시스템 콜을 실행하면 부모 프로세스의 파일 디스크립터 테이블이 복사되어 자식 프로세스에게 전달된다. 이렇게 함으로써 파이프, 소켓, 파일 등의 자원을 자식 프로세스와 공유할 수 있지만, 복사된 후에는 독립적으로 관리된다.

이런 구조 덕분에 같은 파일을 여러 프로세스가 열더라도 중복되어 열리지 않으며, 각 프로세스는 자신만의 접근 설정과 읽기/쓰기 위치 등을 가진 새로운 파일 객체를 생성하여 관리한다. 예를 들어 아래와 같은 상황이 발생할 수 있다.

프로세스FD 번호파일 경로접근 모드읽기 위치
A3malcongmalcom.txt읽기100바이트
B3malcongmalcom.txt쓰기0바이트

두 프로세스 모두 우연히 파일 디스크립터 번호 3을 사용했지만, 각자 독립적인 파일 디스크립터 테이블에서 서로 다른 struct file*를 참조한다.

요약하자면, 아래와 같다.

개념설명
파일 디스크립터 (FD)정수(int)로 표현되는 파일을 다루는 핸들
파일 디스크립터 테이블프로세스마다 독립적으로 존재하며 FD와 struct file*을 매핑
struct file커널 내부에서 파일을 관리하는 실제 파일 객체
프로세스별 FD 테이블 존재 이유접근 권한, 상태(offset), 닫기 책임 등을 프로세스별로 분리하기 위해

장난감 예약하고 번호표 받기

read() 단계에서 커널은 먼저 페이지 캐시에 해당 파일 데이터가 존재하는지 확인한다. 만약 데이터가 없다면, inode에 등록된 논리 블록 번호를 LBA(논리 블록 주소)로 변환한 후, 디스크 드라이버를 통해 실제 물리 블록을 요청한다. 이 요청은 I/O 스케줄러 큐에 적재되고, 이후 디스크 컨트롤러(NVMe, SATA 등)로 전송된다.

inode는 리눅스 파일 시스템에서 파일에 대한 메타데이터를 저장하는 구조체로, 파일 소유자, 권한, 크기, 그리고 데이터가 저장된 위치 정보 등을 포함한다. 파일 데이터는 디스크에 블록 단위로 저장되며, 파일 시스템은 실제 물리 디스크 블록 대신 논리 블록 번호로 이를 관리한다. 즉, OS 관점에서 중요한 것은 논리 블록 번호이며, inode에는 ‘파일 데이터가 디스크의 어느 물리적 위치에 저장되어 있는가’가 아니라 ‘파일의 n번째 논리 블록이 어디에 있는가’에 관한 정보가 담겨 있다.

하지만 inode가 모든 논리 블록 주소를 직접 담고 있지는 않다. 작은 파일은 일부 논리 블록 번호를 inode에 직접 저장하지만, 파일 크기가 커지면 inode에 저장할 수 있는 논리 블록 번호가 부족해진다. 따라서 확장 파일 시스템(ext2/3/4)에서는 간접 블록 구조를 사용한다. 즉, inode는 일부 논리 블록 주소만 직접 담고, 나머지는 간접 블록에 저장된 논리 블록 번호 목록을 통해 계층적으로 참조한다.

여기서 ‘논리 블록 번호를 논리 블록 주소(LBA)로 변환한다’는 것은 무엇일까? 논리 블록 주소는 하드디스크 접근 시 사용하는 논리적 디스크 주소로, 파일 시스템 메타데이터를 참고해 ‘파일 내 블록 번호’를 ‘디스크에서 실제 데이터를 읽어올 물리적 위치’로 변환하는 과정을 뜻한다.

정리하면, 논리 블록 번호와 논리 블록 주소 모두 OS가 다루는 자료구조지만, 전자는 OS 내부에서만 사용되는 개념이고, 후자는 디스크와 통신하기 위한 인터페이스 역할을 한다.

이 변환 과정을 거친 뒤, 디스크 드라이버에 물리 블록 요청이 전달되고, LBA 주소가 드라이버 함수로 넘어간다. 디스크 드라이버는 NVMe, SATA, SCSI 등 하드웨어 인터페이스를 통해 디스크 컨트롤러에 실제 요청을 전달한다.

참고로, 디스크 드라이버도 소프트웨어이지만, 일반 사용자 영역 코드와는 달리 커널 내부에서 하드웨어를 직접 제어하는 특수한 소프트웨어다. 즉, 디스크 드라이버는 운영체제 커널의 일부라고 할 수 있다.

LBA가 디스크 드라이버에 전달된 이후, 요청은 I/O 스케줄러 큐에 차례로 쌓인다. ‘I/O 스케줄러 큐에 쌓인다’는 것은 여러 프로세스가 동시에 디스크 접근을 요청할 수 있기 때문에, 이를 효율적으로 정렬·관리할 필요가 있기 때문이다. 예를 들어, 디스크 헤드가 불필요하게 많이 움직이지 않도록 요청 순서를 재배치하거나, 우선순위를 조정하는 작업이 포함된다. 따라서 디스크 요청은 즉시 처리되지 않고, 이 큐에서 대기하며 순차적으로 실행된다.

배송 트럭에 실어 보내기

여기서 아주 중요한 포인트를 짚어볼 수 있다. 삼촌이 장난감 가게에서 예약표를 받은 순간, 이제 어떻게 할까? 삼촌은 아주 바쁘다. 선물을 직접 받으러 가는 대신, 다른 일을 하러 떠난다. 이게 핵심이다.

요청 객체가 I/O 스케줄러 큐에 적재되면, 커널은 해당 프로세스를 수면 상태로 전환하고, 커널 모드에서 사용자 모드로 빠져나간다. 이후 디스크 컨트롤러가 예약된 요청을 처리한다. 즉, 예약표를 받은 삼촌은 선물 배송을 장난감 가게의 배송 시스템에 완전히 맡기고, 자기 할 일을 계속하는 것이다.

삼촌이 장난감 가게에서 배송을 기다리는 건 멍청한 짓이다. 장난감 가게에도 창고가 있어 선물을 받으려면 한참 기다려야 하기 때문이다.

디스크 드라이버는 저장장치의 플래시 셀(SSD)이나 플래터(HDD)에서 데이터를 읽기 위해 DMA(Direct Memory Access)를 사용한다. 데이터 전송이 완료되면 저장장치는 CPU에 인터럽트를 발생시켜 ‘읽기 완료’를 알린다. CPU는 IRQ 핸들러를 실행해 해당 이벤트를 처리하고, 파일 시스템 콜백으로 제어를 넘겨 블록 입출력을 완료한다.

이 과정에서 DMA를 이용해 데이터를 전송한 뒤, 커널 모드에서 사용자 모드로 전환되는 부분이 핵심이다. 이 부분을 좀 더 자세히 살펴보자.

디스크 드라이버는 CPU가 직접 메모리를 복사하지 않도록 DMA를 활용한다. 구체적으로 드라이버는 먼저 DMA 전송 버퍼의 물리 주소를 확보한다. 이때 가상 주소는 물리 주소로 변환된다. 다음으로 DMA 전송 크기(예: 4KB 블록)를 지정하고, 하드웨어 레지스터에 DMA 시작 주소, 전송 크기, 방향(읽기/쓰기)을 설정한다. 이 설정은 메모리 매핑된 I/O 방식을 통해 디스크 컨트롤러에 전달된다.

디스크 컨트롤러는 드라이버의 설정을 받아 SSD나 HDD 저장장치에서 데이터를 읽어온다. SSD는 플래시 셀에서, HDD는 플래터에서 헤드를 이동시켜 데이터를 읽는다. 컨트롤러는 읽은 데이터를 DMA 엔진을 통해 메인 메모리의 지정 버퍼로 직접 전송한다. 이 과정에서 CPU는 데이터 전송에 개입하지 않으므로 다른 작업에 집중할 수 있다.

데이터 전송이 완료되면 디스크 컨트롤러는 CPU의 IRQ 라인에 인터럽트를 발생시켜 완료 신호를 보낸다. CPU는 실행 중이던 작업을 잠시 멈추고 커널 모드로 전환해 IRQ 핸들러를 실행한다. 마치 엄마가 택배 아저씨의 ‘띵동’ 소리를 듣고 삼촌에게 “너가 대신 받아라”라고 명령하는 것과 같다.

디스크 드라이버 내부의 인터럽트 서비스 루틴(ISR)은 인터럽트 원인을 확인하고, 전송 상태를 점검하며 에러가 있으면 처리한다. 완료된 I/O 요청 정보를 내부 데이터 구조에 기록한다.

이후 ISR은 커널 내에서 대기 중인 사용자 프로세스(실제로는 스레드)를 깨운다. 사용자 프로세스는 깨어나 DMA를 통해 메인 메모리에 복사된 데이터를 읽을 수 있게 된다. 즉, 삼촌이 “선물이 도착했다”고 알려주는 것이다.

이 과정이 다소 추상적으로 느껴질 수 있다. ‘스레드를 깨운다’는 건, 스레드가 “데이터가 메모리에 들어와 있으니 이제 읽어도 된다”는 신호를 받는다는 의미다. I/O 요청 시점에 커널은 DMA 전송 버퍼를 예약해 두었고, 디스크에서 읽은 데이터는 이 버퍼에 복사된다. 이 버퍼는 커널 공간에 할당된 메모리 영역으로, 사용자 프로세스가 직접 접근할 수 없다. 따라서 대부분의 경우 커널은 이 데이터를 사용자 프로세스가 제공한 사용자 공간 버퍼로 복사한다. 선물은 이런 과정을 거쳐 아이에게 전달되는 셈이다.

포장지 뜯어 아이 손에 쥐어주기

요청된 모든 블록의 데이터가 커널 페이지 캐시에 올라오면, 커널은 copy_to_user()와 같은 메커니즘을 통해 데이터를 사용자 영역으로 복사한다. JVM 기반 앱의 경우, 이 데이터는 JVM 힙의 ByteArray로 복사되고, 이후 Kotlin 코드에서 문자열로 디코딩되어 최종적으로 readText()가 완성된 문자열을 반환한다.

"커널 페이지 캐시에 올라온다"는 말은 곧 디스크에서 읽어온 데이터가 커널 메모리에 저장되었다는 뜻이다. 커널은 디스크 I/O 성능 향상을 위해, 최근에 읽거나 쓴 파일 데이터를 RAM에 저장해 두는데, 이를 페이지 캐시라 부른다. 페이지 캐시는 특별한 메모리 공간이 아니라, 일반 RAM 중 커널이 파일 데이터 캐시 용도로 관리하는 영역이다. 읽기뿐 아니라 쓰기의 경우도 데이터를 디스크에 바로 쓰지 않고 캐시에 저장한 뒤 일정 시점에 디스크로 플러시한다.

커널은 페이지 캐시에 해당 파일 블록이 있는지 확인한 후, copy_to_user()를 통해 데이터를 사용자 공간으로 복사한다. JVM 기반 앱이라면 이 복사 대상은 JVM 힙의 ByteArray다. 이후 Kotlin 코드가 ByteArray를 문자열로 디코딩하는데, 이는 JVM 내부에서 java.nio.charset.CharsetDecoder 같은 클래스를 이용해 수행된다.

val bytes = file.readBytes()          // JVM 힙의 ByteArray
val text = String(bytes, Charsets.UTF_8)  // 디코딩 → Kotlin String

앞선 흐름에 자주 생략되는 단계가 하나 더 있다. 바로 DMA 버퍼에서 커널 페이지 캐시로 데이터가 복사되는 과정이다. 이 과정은 디스크 I/O 완료 시 발생하는 인터럽트(IRQ)에 의해 트리거되며, 커널 내부의 IRQ 핸들러가 처리한다.

즉, 디스크에서 읽은 데이터는 먼저 DMA 버퍼(임시 저장 공간)로 전송되고, 이후 커널 페이지 캐시(파일 데이터 캐시 공간)로 복사된다. 그 다음에야 사용자 공간(JVM 힙)으로 전달된다.

요약하면 다음과 같다:

  1. read() 호출 → 커널이 디스크에 데이터 요청
  2. DMA를 통해 데이터가 DMA 버퍼로 복사
  3. 인터럽트 발생 → IRQ 핸들러가 DMA 버퍼에서 페이지 캐시로 데이터 복사
  4. 커널이 페이지 캐시에서 사용자 공간으로 copy_to_user() 수행
  5. JVM 힙의 ByteArray를 Kotlin 문자열로 디코딩

비유하자면,

  • DMA 버퍼는 현관 앞에 놓인 택배 상자
  • 커널 페이지 캐시는 집 안의 창고
  • 사용자 공간(JVM 힙)은 크리스마스 트리 밑 선물 위치

택배가 도착하면, 엄마가 현관의 상자를 집 안 창고로 옮기고, 아이가 깨어나면 선물을 크리스마스 트리 밑으로 옮겨주는 과정과 같다.

동시에 옆집 선물도 챙기는 산타 팀플레이

앞서 설명했듯, 모든 작업이 이렇게 끊기지 않고 유기적으로 진행될 수 있는 비밀은 커널 스케줄러가 이 모든 과정을 꼼꼼히 관리하기 때문이다. 디스크 컨트롤러에 작업을 맡기면, 해당 스레드는 TASK_INTERRUPTIBLE 상태로 잠시 멈추는데, 이는 디스크 I/O가 상대적으로 느린 작업이기 때문이다. 이 순간, 커널의 CFS(Completely Fair Scheduler)는 즉시 CPU를 다른 프로세스에 할당한다. 덕분에 한 스레드가 I/O를 기다리는 동안에도 시스템 전체가 멈추지 않고 여러 작업이 동시에 진행되는 것처럼 보이는 병렬성을 자연스럽게 유지할 수 있다.

들키지 않게, 산타 작전 완료!

지금까지 코틀린 코드 세 줄을 찬찬히 뜯어보며 그 안에 숨겨진 수많은 손길과 복잡한 과정을 마주했다. 겉보기엔 단순한 코드 한 줄이지만, 그 뒤에는 보이지 않는 수많은 이들의 노력과 정교한 연결이 촘촘히 엮여 있었다. 어릴 적 무심코 지나쳤던 세상의 숨은 이야기들처럼, 프로그래밍도 깊이 파고들수록 그 신비로움이 더 선명하게 다가온다.

File("malcongmalcom.txt").readText()라는 짧은 한 줄 속에는 시스템 콜부터 디스크 컨트롤러, 커널과 드라이버, 저장장치, 스케줄러, CPU 하드웨어까지 수많은 존재가 한 마음으로 움직이는 오케스트라가 있다. 평범한 순간 속에 숨어 있던 이들의 노고와 기술이 이제야 선명하게 느껴진다.

이제는 단지 코드를 실행하는 행위를 넘어서, 그 뒤에 숨은 거대한 세상과 연결된다는 사실에 마음이 벅차다. 우리 앞에 펼쳐진 이 신비로운 협력과 조화 속에서, 프로그래밍은 더 이상 혼자가 아니며, 그 작은 한 줄이 전하는 이야기는 결코 작지 않다는 걸 알게 된다. 그렇게 오늘도 우리는 보이지 않는 산타와 함께 또 한 걸음 나아간다.

profile
안녕하세요. 날씨가 참 덥네요.

0개의 댓글