| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- kubernetes
- Effective Java
- 스프링부트
- 알고리즘
- 자바스크립트
- 이펙티브자바
- 김영한
- 스프링 핵심원리
- Spring
- Sort
- 자바
- java
- 이펙티브 자바
- Effective Java 3
- 스프링
- 예제로 배우는 스프링 입문
- 클린아키텍처
- JavaScript
- ElasticSearch
- effectivejava
- 티스토리챌린지
- k8s
- Kotlin
- springboot
- 알고리즘정렬
- 카카오
- 함수형프로그래밍
- 스프링핵심원리
- 엘라스틱서치
- 오블완
- Today
- Total
Kim-Baek 개발자 이야기
파일 시스템 - 데이터는 디스크에 어떻게 저장될까 본문
100만 개 파일을 저장했더니 서버가 느려졌다
Court Alarm이 1년쯤 운영되면서 사용자들의 프로필 사진이 쌓이기 시작했다. 처음엔 몇 백 개였는데, 어느새 10만 개, 50만 개, 그리고 100만 개를 넘어섰다.
그런데 이상한 일이 생겼다. 파일을 저장하거나 읽는 속도가 점점 느려지는 것이었다.
// 프로필 사진 저장
val file = File("/data/profile-images/${userId}.jpg")
file.writeBytes(imageData) // 처음엔 10ms → 지금은 500ms?!
왜 똑같은 작업이 50배나 느려진 걸까?
서버 스펙은 그대로였다. CPU도, 메모리도, 디스크도 여유가 있었다. 문제는 파일 시스템에 있었다. 하나의 디렉토리에 100만 개 파일을 저장하면서 파일 시스템의 성능이 급격히 떨어진 것이다.
5년 차가 되어서도 파일 시스템이 이렇게 중요한 줄 몰랐다. 이 문제를 해결하면서 inode, 디렉토리 구조, 파일 할당 방식을 제대로 이해하게 됐다.
파일 시스템이란 무엇인가
기본 개념
파일 시스템은 디스크에 데이터를 저장하고 관리하는 방법이다.
사용자 관점:
/home/user/document.txt
파일 시스템 관점:
디스크 블록 1024번~2048번에 저장
inode 4567번에 메타데이터
파일 시스템이 하는 일:
- 파일과 디렉토리 관리
- 디스크 공간 할당
- 메타데이터 관리 (이름, 크기, 권한, 날짜)
- 빠른 검색
- 안정성과 복구
주요 파일 시스템
Linux:
- ext4: 가장 널리 사용, 안정적
- XFS: 대용량 파일, 병렬 I/O에 강함
- Btrfs: 스냅샷, CoW, 압축 지원
Windows:
- NTFS: 저널링, 압축, 암호화 지원
- FAT32: 호환성 좋지만 4GB 제한
macOS:
- APFS: SSD 최적화, 암호화, 스냅샷
분산 파일 시스템:
- NFS: Network File System
- Ceph: 분산 객체 스토리지
- GlusterFS: 확장 가능한 네트워크 파일 시스템
디스크 구조
물리적 구조 (HDD)
┌─────────────────────────────┐
│ 플래터 (Platter) │ 회전하는 원판
│ 트랙 (Track) ──→ │ 동심원
│ ├─ 섹터 (Sector) │ 최소 단위 (512B)
│ └─ 블록 (Block) │ 여러 섹터 (4KB)
└─────────────────────────────┘
↑
읽기/쓰기 헤드
섹터 (Sector):
- 물리적 최소 단위
- 보통 512 바이트
- 최근 HDD는 4KB (Advanced Format)
블록 (Block):
- 파일 시스템의 논리적 단위
- 보통 4KB (섹터 8개)
- 파일 시스템이 한 번에 읽고 쓰는 단위
SSD는 다르다
┌─────────────────────────────┐
│ 페이지 (Page): 4KB~16KB │ 읽기/쓰기 단위
├─────────────────────────────┤
│ 블록 (Erase Block): 256KB~│ 삭제 단위
│ 여러 페이지 │ (HDD의 블록과 다름!)
└─────────────────────────────┘
SSD 특징:
- 읽기: 빠름 (100μs)
- 쓰기: 중간 (수백 μs)
- 삭제: 느림 (수 ms), 큰 블록 단위로만 가능
- Write Amplification: 작은 데이터 써도 큰 블록 삭제 필요
inode: 파일의 메타데이터
inode란
inode(index node)는 파일의 메타데이터를 저장하는 자료구조다. 파일 이름을 제외한 모든 정보를 담고 있다.
inode 구조 (간략화)
┌─────────────────────────────┐
│ inode 번호: 4567 │
├─────────────────────────────┤
│ 파일 타입: 일반 파일 │
│ 권한: -rw-r--r-- │
│ 소유자: user (UID 1000) │
│ 그룹: users (GID 100) │
│ 크기: 10,485,760 bytes │
│ 링크 수: 1 │
│ 생성 시간: 2024-01-05 10:23 │
│ 수정 시간: 2024-01-05 14:30 │
│ 접근 시간: 2024-01-08 09:15 │
├─────────────────────────────┤
│ 데이터 블록 포인터: │
│ - Direct: [1024, 1025, ...] │ 직접 포인터 (12개)
│ - Indirect: 5000 │ 간접 포인터 (1개)
│ - Double Indirect: 5001 │ 이중 간접 (1개)
│ - Triple Indirect: 5002 │ 삼중 간접 (1개)
└─────────────────────────────┘
주의: 파일 이름은 inode가 아닌 디렉토리에 저장된다!
Direct vs Indirect Pointer
작은 파일과 큰 파일을 효율적으로 저장하기 위한 구조:
Direct Pointer (12개):
블록 크기 4KB × 12 = 48KB까지 직접 접근
├→ 블록 1024
├→ 블록 1025
├→ 블록 1026
...
└→ 블록 1035
Indirect Pointer (1개):
블록 하나를 포인터 테이블로 사용
4KB / 4 bytes = 1024개 포인터
1024 × 4KB = 4MB까지
Double Indirect (1개):
포인터 → 포인터 테이블 → 데이터
1024 × 1024 × 4KB = 4GB까지
Triple Indirect (1개):
포인터 → 포인터 → 포인터 테이블 → 데이터
1024 × 1024 × 1024 × 4KB = 4TB까지
성능 영향:
10KB 파일 (Direct):
inode → 블록 (1회 접근)
10MB 파일 (Indirect):
inode → 포인터 블록 → 데이터 블록 (2회 접근)
5GB 파일 (Double Indirect):
inode → 포인터 블록 → 포인터 블록 → 데이터 블록 (3회 접근)
큰 파일일수록 접근이 느리다!
inode 확인
# 파일의 inode 번호 확인
ls -i myfile.txt
# 4567 myfile.txt
# inode 상세 정보
stat myfile.txt
출력:
File: myfile.txt
Size: 10485760 Blocks: 20480 IO Block: 4096
Device: 803h/2051d Inode: 4567 Links: 1
Access: (0644/-rw-r--r--) Uid: (1000/ user) Gid: ( 100/ users)
Access: 2024-01-08 09:15:30.123456789 +0900
Modify: 2024-01-05 14:30:45.987654321 +0900
Change: 2024-01-05 14:30:45.987654321 +0900
Hard Link vs Soft Link
Hard Link:
ln original.txt hardlink.txt
디렉토리:
original.txt → inode 4567
hardlink.txt → inode 4567 (같은 inode!)
inode 4567:
Links: 2 ← 링크 수 증가
- 같은 inode를 가리킴
- 원본 삭제해도 데이터 유지
- 디렉토리나 다른 파일 시스템 불가
Soft Link (Symbolic Link):
ln -s original.txt softlink.txt
디렉토리:
original.txt → inode 4567
softlink.txt → inode 8888 (새 inode)
inode 8888:
Type: Symbolic Link
Data: "original.txt" (경로 문자열)
- 별도 inode, 경로만 저장
- 원본 삭제하면 링크 깨짐 (Dangling)
- 디렉토리, 다른 파일 시스템 가능
디렉토리 구조
디렉토리는 특별한 파일이다
디렉토리는 "파일 이름 → inode 번호" 매핑 테이블을 저장하는 파일이다.
디렉토리 /home/user/docs:
┌─────────────────────────────┐
│ 파일 이름 │ inode 번호 │
├─────────────────────────────┤
│ . │ 12345 │ 자기 자신
│ .. │ 12000 │ 부모 디렉토리
│ file1.txt │ 4567 │
│ file2.txt │ 4568 │
│ subdir │ 12400 │ 하위 디렉토리
└─────────────────────────────┘
파일 경로 해석 과정
/home/user/docs/file.txt를 여는 과정:
1. 루트 디렉토리 (/) inode: 2번 (고정)
↓
2. 루트 디렉토리 읽기
"home" → inode 100
↓
3. /home 디렉토리 (inode 100) 읽기
"user" → inode 1000
↓
4. /home/user 디렉토리 (inode 1000) 읽기
"docs" → inode 12345
↓
5. /home/user/docs 디렉토리 (inode 12345) 읽기
"file.txt" → inode 4567
↓
6. file.txt의 inode 4567 읽기
데이터 블록 위치 확인
↓
7. 데이터 블록 읽기
총 7번의 디스크 접근!
경로가 깊을수록 느리다.
100만 개 파일 문제의 원인
// ❌ 하나의 디렉토리에 100만 개 파일
/data/profile-images/
├── user1.jpg
├── user2.jpg
├── user3.jpg
...
└── user1000000.jpg
문제:
- 디렉토리 파일이 거대해짐 (수십 MB)
- 파일 찾으려면 전체 디렉토리 스캔 (선형 검색)
- 100만 개 중 하나 찾기 = 평균 50만 번 비교
- 디스크 I/O 폭증
ext4의 경우:
- 작은 디렉토리: 선형 검색
- 큰 디렉토리: HTree (해시 트리) 자동 생성
- 하지만 100만 개는 여전히 느림
해결: 계층적 디렉토리 구조
// ✅ 계층 구조로 분산
fun getProfileImagePath(userId: Long): String {
val hash = userId.toString().padStart(10, '0')
// user_id 1234567 → "0001234567"
val level1 = hash.substring(0, 2) // "00"
val level2 = hash.substring(2, 4) // "01"
val level3 = hash.substring(4, 6) // "23"
// /data/profile-images/00/01/23/0001234567.jpg
return "/data/profile-images/$level1/$level2/$level3/$hash.jpg"
}
// 디렉토리 생성
fun saveProfileImage(userId: Long, imageData: ByteArray) {
val path = getProfileImagePath(userId)
val file = File(path)
// 부모 디렉토리 자동 생성
file.parentFile.mkdirs()
file.writeBytes(imageData)
}
구조:
/data/profile-images/
├── 00/
│ ├── 00/
│ │ ├── 00/
│ │ │ ├── 0000000001.jpg
│ │ │ ├── 0000000002.jpg
│ │ │ └── ... (최대 100개)
│ │ └── 01/
│ └── 01/
└── 99/
개선:
- 디렉토리당 최대 100개 파일
- 3단계 계층 = 100 × 100 × 100 = 100만 개 지원
- 각 디렉토리는 작아서 빠름
성능 측정:
100만 개 파일, 랜덤 읽기 10,000회
단일 디렉토리:
- 평균 시간: 450ms
- 총 시간: 75분
계층 구조 (3단계):
- 평균 시간: 8ms
- 총 시간: 80초 (56배 빠름!)
파일 할당 방식
연속 할당 (Contiguous Allocation)
파일을 연속된 블록에 저장:
디스크:
┌────┬────┬────┬────┬────┬────┬────┐
│File│File│File│ │File│File│ │
│ A │ A │ A │Free│ B │ B │Free│
│ 1 │ 2 │ 3 │ │ 1 │ 2 │ │
└────┴────┴────┴────┴────┴────┴────┘
0 1 2 3 4 5 6
File A: 시작 블록 0, 길이 3
File B: 시작 블록 4, 길이 2
장점:
- 순차 읽기 매우 빠름
- 랜덤 접근도 빠름 (시작 + 오프셋)
- 구현 간단
단점:
- 외부 단편화 (External Fragmentation)
- 파일 크기 변경 어려움
- 미리 최대 크기 예약 필요
사용: CD-ROM, DVD (쓰기 한 번만)
연결 할당 (Linked Allocation)
각 블록이 다음 블록을 가리킴:
디스크:
┌────┬────┬────┬────┬────┬────┐
│File│ │File│File│ │File│
│ A │Free│ B │ A │Free│ A │
│→3 │ │→5 │→5 │ │→end│
└────┴────┴────┴────┴────┴────┘
0 1 2 3 4 5
File A: 0 → 3 → 5 → end
File B: 2 → end
장점:
- 외부 단편화 없음
- 파일 크기 변경 쉬움
단점:
- 순차 접근만 빠름
- 랜덤 접근 느림 (연결 리스트 순회)
- 포인터 저장 공간 필요
- 블록 하나 손상되면 뒤가 전부 손실
사용: 거의 안 씀 (너무 느림)
인덱스 할당 (Indexed Allocation)
인덱스 블록에 모든 데이터 블록 포인터 저장:
인덱스 블록 (블록 10):
┌────┬────┬────┬────┐
│ 0 │ 3 │ 7 │ 9 │
└────┴────┴────┴────┘
디스크:
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│File│ │ │File│ │ │ │File│ │File│
│ A │Free│Free│ A │Free│Free│Free│ A │Free│ A │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
0 1 2 3 4 5 6 7 8 9
File A의 inode: 인덱스 블록 10
장점:
- 랜덤 접근 빠름
- 외부 단편화 없음
단점:
- 인덱스 블록 공간 필요
- 작은 파일에는 낭비
사용: Unix/Linux (inode의 Direct/Indirect Pointer)
FAT (File Allocation Table)
전체 디스크의 연결 정보를 테이블로:
FAT (메모리에 상주):
┌─────┬──────┬─────┐
│블록 │다음 │파일 │
├─────┼──────┼─────┤
│ 0 │ 3 │ A │
│ 1 │ FREE │ │
│ 2 │ EOF │ B │
│ 3 │ 5 │ A │
│ 4 │ FREE │ │
│ 5 │ EOF │ A │
└─────┴──────┴─────┘
File A: 0 → 3 → 5 (EOF)
File B: 2 (EOF)
장점:
- 랜덤 접근 빠름 (FAT는 메모리에)
- 구현 간단
단점:
- FAT 크기 제한 (FAT32는 4GB 파일 제한)
- FAT 손상 시 전체 파일 시스템 손상
사용: USB, SD 카드 (호환성)
실전: 대용량 로그 파일 처리
문제 상황
Court Alarm은 모든 API 요청을 로그로 남긴다. 하루 로그 파일이 10GB를 넘어가면서 문제가 생겼다.
로그 조회 API:
// ❌ 전체 파일을 메모리로 읽음
@GetMapping("/logs/today")
fun getTodayLogs(): List<String> {
val file = File("/var/log/api.log")
return file.readLines() // OutOfMemoryError!
}
문제:
- 10GB 파일 전체를 메모리로
- JVM Heap: 4GB
- OutOfMemoryError 발생
해결 1: 스트리밍 읽기
// ✅ 버퍼로 조금씩 읽기
@GetMapping("/logs/today")
fun getTodayLogs(): ResponseEntity<StreamingResponseBody> {
val file = File("/var/log/api.log")
val stream = StreamingResponseBody { outputStream ->
file.inputStream().use { input ->
val buffer = ByteArray(8192) // 8KB 버퍼
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
}
}
return ResponseEntity.ok()
.header("Content-Type", "text/plain")
.body(stream)
}
개선:
- 메모리 사용: 10GB → 8KB
- OutOfMemoryError 해결
- 클라이언트에 스트리밍 전송
해결 2: 파일 분할
// ✅ 날짜별로 파일 분할
class RotatingFileAppender {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd")
fun log(message: String) {
val today = dateFormat.format(Date())
val file = File("/var/log/api-$today.log")
// 파일 끝에 추가
file.appendText("$message\n")
}
@Scheduled(cron = "0 0 0 * * *") // 매일 자정
fun rotate() {
// 7일 이전 로그 삭제
val sevenDaysAgo = Calendar.getInstance().apply {
add(Calendar.DAY_OF_MONTH, -7)
}.time
val oldDate = dateFormat.format(sevenDaysAgo)
val oldFile = File("/var/log/api-$oldDate.log")
if (oldFile.exists()) {
// S3에 업로드 후 삭제
s3Client.upload(oldFile)
oldFile.delete()
logger.info("로그 파일 아카이빙: $oldDate")
}
}
}
// 특정 날짜 로그 조회
@GetMapping("/logs/{date}")
fun getLogsByDate(@PathVariable date: String): List<String> {
val file = File("/var/log/api-$date.log")
if (!file.exists()) {
throw NotFoundException("로그 파일 없음: $date")
}
// 날짜별 파일은 크기가 작아서 안전
return file.readLines()
}
개선:
- 파일당 1GB 이하 유지
- 빠른 검색 (날짜로 바로 찾기)
- 자동 정리
해결 3: mmap (Memory-Mapped File)
큰 파일을 가상 메모리에 매핑:
import java.nio.MappedByteBuffer
import java.nio.channels.FileChannel
import java.io.RandomAccessFile
// ✅ mmap으로 대용량 파일 효율적 읽기
class MmapLogReader(private val filePath: String) {
private val file = RandomAccessFile(filePath, "r")
private val channel = file.channel
private val buffer: MappedByteBuffer = channel.map(
FileChannel.MapMode.READ_ONLY,
0,
channel.size()
)
fun readLines(startLine: Int, count: Int): List<String> {
val lines = mutableListOf<String>()
var currentLine = 0
var position = 0
// 시작 라인까지 스킵
while (currentLine < startLine && position < buffer.limit()) {
if (buffer.get(position++) == '\n'.code.toByte()) {
currentLine++
}
}
// count개 라인 읽기
val lineBuffer = StringBuilder()
while (lines.size < count && position < buffer.limit()) {
val byte = buffer.get(position++)
if (byte == '\n'.code.toByte()) {
lines.add(lineBuffer.toString())
lineBuffer.clear()
} else {
lineBuffer.append(byte.toInt().toChar())
}
}
return lines
}
fun search(keyword: String): List<String> {
val results = mutableListOf<String>()
val lineBuffer = StringBuilder()
for (i in 0 until buffer.limit()) {
val byte = buffer.get(i)
if (byte == '\n'.code.toByte()) {
val line = lineBuffer.toString()
if (line.contains(keyword)) {
results.add(line)
}
lineBuffer.clear()
} else {
lineBuffer.append(byte.toInt().toChar())
}
}
return results
}
fun close() {
channel.close()
file.close()
}
}
// 사용
@GetMapping("/logs/search")
fun searchLogs(@RequestParam keyword: String): List<String> {
val reader = MmapLogReader("/var/log/api.log")
try {
return reader.search(keyword)
} finally {
reader.close()
}
}
mmap의 장점:
- 파일을 메모리처럼 접근
- OS가 페이지 단위로 자동 로드
- 필요한 부분만 메모리에 (Lazy Loading)
- 여러 프로세스가 공유 가능
성능 비교:
10GB 파일에서 키워드 검색
readLines():
- 메모리: OutOfMemoryError
- 불가능
버퍼 읽기:
- 시간: 45초
- 메모리: 8KB
mmap:
- 시간: 12초 (3.7배 빠름)
- 메모리: 실제 사용량만 (수십 MB)
저널링 (Journaling)
파일 시스템 일관성 문제
파일 생성 중 전원이 나가면?
1. inode 할당
2. 데이터 블록 할당
3. 디렉토리 엔트리 추가
4. inode 정보 업데이트
전원 차단!
→ 어느 단계까지 완료됐나?
→ 파일 시스템 손상 가능
저널링 파일 시스템
모든 변경을 먼저 저널에 기록:
1. 저널에 "파일 생성 시작" 기록
2. 저널에 "inode 할당" 기록
3. 저널에 "데이터 블록 할당" 기록
4. 저널에 "디렉토리 추가" 기록
5. 저널에 "완료" 기록
6. 실제 파일 시스템에 적용
7. 저널 항목 삭제
복구 과정:
재부팅 시:
저널 확인
→ "완료" 표시 없는 항목 발견
→ 해당 작업 롤백 또는 재실행
→ 파일 시스템 일관성 보장
저널링 모드
1. Journal (데이터 + 메타데이터):
저널:
- 파일 데이터: "Hello World"
- inode 정보
- 디렉토리 엔트리
→ 가장 안전, 가장 느림
2. Ordered (메타데이터만, 순서 보장):
1. 파일 데이터 쓰기 (저널 없이)
2. 저널에 메타데이터 기록
3. 메타데이터 적용
→ 균형잡힌 선택 (ext4 기본)
3. Writeback (메타데이터만, 순서 무관):
저널: 메타데이터만
데이터: 언제든지 쓰기
→ 가장 빠름, 데이터 손실 가능
ext4 설정:
# 현재 모드 확인
tune2fs -l /dev/sda1 | grep "mount options"
# Journal 모드로 변경
tune2fs -o journal_data /dev/sda1
# Ordered 모드로 변경 (기본)
tune2fs -o journal_data_ordered /dev/sda1
Copy-on-Write (CoW)
기본 개념
데이터를 수정할 때 복사본에 쓰기:
Before:
File A
├→ Block 1: "Hello"
└→ Block 2: "World"
수정 요청: Block 1을 "Hi"로 변경
After:
File A (새 버전)
├→ Block 3: "Hi" (새로 할당)
└→ Block 2: "World" (공유)
File A (이전 버전)
├→ Block 1: "Hello" (유지)
└→ Block 2: "World" (공유)
장점:
- 원본 데이터 손상 없음
- 스냅샷이 거의 무료
- 롤백 쉬움
단점:
- 쓰기 성능 저하 (복사 필요)
- 단편화 증가
사용: Btrfs, ZFS, APFS
실무 적용: 데이터베이스 백업
// Btrfs 스냅샷으로 백업
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시
fun backupDatabase() {
val today = LocalDate.now().toString()
// Btrfs 스냅샷 생성 (거의 즉시)
ProcessBuilder(
"btrfs", "subvolume", "snapshot",
"/data/mysql",
"/backup/mysql-$today"
).start().waitFor()
logger.info("DB 백업 완료: $today")
// 7일 이전 스냅샷 삭제
val sevenDaysAgo = LocalDate.now().minusDays(7).toString()
ProcessBuilder(
"btrfs", "subvolume", "delete",
"/backup/mysql-$sevenDaysAgo"
).start().waitFor()
}
기존 방식 (mysqldump):
- 시간: 30분
- 디스크 사용: 전체 복사 (100GB × 7일 = 700GB)
CoW 스냅샷:
- 시간: 1초
- 디스크 사용: 변경된 블록만 (일 5GB × 7일 = 35GB)
20배 빠르고 공간도 절약!
파일 시스템 성능 최적화
1. 블록 크기 선택
# 파일 시스템 생성 시 블록 크기 지정
mkfs.ext4 -b 4096 /dev/sda1 # 4KB (기본)
작은 블록 (1KB~2KB):
- 장점: 공간 효율적 (내부 단편화 적음)
- 단점: 많은 메타데이터, 느림
- 사용: 작은 파일 많은 경우
큰 블록 (8KB~64KB):
- 장점: 빠름 (I/O 횟수 감소)
- 단점: 공간 낭비 (내부 단편화)
- 사용: 큰 파일 많은 경우
Court Alarm 선택:
- 4KB 블록 (기본값)
- 프로필 사진 평균 크기: 100KB
- 적절한 균형
2. 파일 시스템 캐시
Linux는 Page Cache로 파일을 캐싱:
RAM
┌─────────────────────────────┐
│ 애플리케이션 메모리 │
├─────────────────────────────┤
│ Page Cache (파일 캐시) │ ← 파일 시스템 캐시
│ - /var/log/api.log (10MB) │
│ - /data/images/user1.jpg │
│ - ... │
└─────────────────────────────┘
자동 캐싱:
// 첫 번째 읽기: 디스크에서 (느림)
val data1 = File("large.dat").readBytes() // 100ms
// 두 번째 읽기: 캐시에서 (빠름)
val data2 = File("large.dat").readBytes() // 1ms
캐시 확인:
free -h
출력:
total used free shared buff/cache
Mem: 15Gi 2.0Gi 8.0Gi 100Mi 5.0Gi
Swap: 2.0Gi 0B 2.0Gi
buff/cache: 5GB가 파일 캐시로 사용 중
캐시 지우기 (테스트용):
# 경고: 운영 서버에서 하지 말 것!
sync # 변경사항 디스크에 쓰기
echo 3 > /proc/sys/vm/drop_caches # 캐시 비우기
3. Read-Ahead
파일을 읽을 때 뒤의 데이터도 미리 읽기:
파일 읽기 요청: 0~4KB
실제 읽기:
0~4KB (요청)
4KB~8KB (Read-Ahead)
8KB~12KB (Read-Ahead)
...
0~128KB (총 128KB)
다음 읽기 요청이 오면 캐시에서!
설정:
# 현재 Read-Ahead 크기 확인 (KB)
blockdev --getra /dev/sda1
# 256 (128KB)
# Read-Ahead 크기 변경
blockdev --setra 512 /dev/sda1 # 256KB
최적값:
- HDD: 256KB~1MB (회전 지연 숨기기)
- SSD: 128KB~256KB (너무 크면 낭비)
- 순차 읽기 많으면: 크게
- 랜덤 읽기 많으면: 작게
4. Direct I/O
Page Cache를 우회하고 직접 디스크에:
import java.nio.channels.FileChannel
import java.nio.file.StandardOpenOption
// ✅ Direct I/O (캐시 우회)
val channel = FileChannel.open(
Paths.get("large.dat"),
StandardOpenOption.READ,
StandardOpenOption.WRITE,
StandardOpenOption.DSYNC // Direct I/O
)
언제 사용:
- 데이터베이스 (자체 캐시 있음)
- 대용량 스트리밍 (한 번만 읽음)
- Page Cache 오염 방지
주의:
- 정렬 필요 (512 바이트 경계)
- 일반적으로 느림 (캐시 효과 없음)
- 꼭 필요할 때만
5. I/O 스케줄러
커널이 디스크 I/O 요청 순서 최적화:
noop (No Operation):
- FIFO, 재정렬 없음
- SSD에 적합 (탐색 시간 없음)
deadline:
- 읽기/쓰기 기한 보장
- 데이터베이스에 적합
cfq (Completely Fair Queuing):
- 프로세스별 공평 할당
- 일반 서버 기본값
현재 스케줄러 확인:
cat /sys/block/sda/queue/scheduler
# [noop] deadline cfq
# 변경
echo deadline > /sys/block/sda/queue/scheduler
Court Alarm 설정:
- SSD: noop
- HDD: deadline (DB 서버)
면접에서 이렇게 답하자
1단계: 기본 개념 (30초)
"파일 시스템은 디스크에 데이터를 저장하고 관리하는 방법입니다. inode는 파일의 메타데이터를 저장하고, 디렉토리는 파일 이름과 inode 번호를 매핑하는 특별한 파일입니다. 파일 할당 방식은 연속, 연결, 인덱스 방식이 있으며, 현대 파일 시스템은 대부분 인덱스 방식을 사용합니다."
2단계: inode와 디렉토리 (1분)
"inode는 파일 이름을 제외한 모든 메타데이터(권한, 크기, 시간, 블록 포인터)를 저장합니다. Direct Pointer로 48KB까지, Indirect로 4MB, Double Indirect로 4GB까지 파일을 지원합니다. 디렉토리는 파일 이름에서 inode 번호로의 매핑 테이블이며, 파일 경로 해석 시 각 디렉토리를 순차적으로 읽어야 해서 경로가 깊을수록 느립니다."
3단계: 실무 경험 (2분)
"Court Alarm에서 100만 개 프로필 사진을 하나의 디렉토리에 저장했더니 파일 저장이 10ms에서 500ms로 느려졌습니다. 디렉토리 파일이 수십 MB로 커지면서 선형 검색 때문에 성능이 급격히 떨어진 것입니다."
"3단계 계층 구조로 변경했습니다. user_id를 해시해서 /data/00/01/23/0001234567.jpg 형태로 저장했고, 각 디렉토리는 최대 100개 파일만 가지도록 했습니다. 결과적으로 파일 접근 시간이 평균 450ms에서 8ms로 56배 개선됐습니다."
4단계: 고급 최적화 (1분 30초)
"대용량 로그 파일 처리는 mmap으로 해결했습니다. 10GB 파일을 가상 메모리에 매핑하고 필요한 부분만 페이지 단위로 로드해서, 키워드 검색 시간을 45초에서 12초로 단축했습니다."
"파일 시스템 선택도 중요했습니다. 데이터베이스 백업은 Btrfs의 CoW 스냅샷을 사용해서 mysqldump 30분을 1초로 줄였고, 디스크 사용량도 700GB에서 35GB로 20배 절약했습니다."
핵심 팁
구체적인 숫자:
- ❌ "파일이 많아서 느려졌어요"
- ✅ "100만 개 파일, 접근 시간 10ms→500ms, 계층 구조로 8ms까지 개선 (56배)"
문제 원인 명확히:
- ❌ "디렉토리가 문제였어요"
- ✅ "디렉토리 파일이 수십 MB로 커지면서 선형 검색 때문에 성능 저하"
해결 방법 구체적으로:
- 단순히 "최적화했다"가 아니라
- "3단계 계층, 디렉토리당 100개 제한, user_id 해싱"
정리
파일 시스템은 디스크 관리의 핵심이다.
주요 개념:
inode:
- 파일 메타데이터 저장
- Direct/Indirect/Double/Triple Pointer
- 큰 파일일수록 접근 느림
디렉토리:
- 파일 이름 → inode 매핑
- 특별한 파일
- 큰 디렉토리는 느림 (선형 검색)
파일 할당:
- 연속: 빠르지만 단편화
- 연결: 순차만 빠름
- 인덱스: 랜덤 접근 빠름 (현대 방식)
성능 최적화:
- 디렉토리 구조
- 계층적 분산
- 디렉토리당 100~1,000개
- 대용량 파일
- 스트리밍 읽기
- mmap (Memory-Mapped File)
- 파일 분할
- 파일 시스템 선택
- ext4: 범용
- XFS: 대용량
- Btrfs: 스냅샷, CoW
- 캐싱
- Page Cache 활용
- Read-Ahead 조정
- Direct I/O (필요시)
- I/O 스케줄러
- SSD: noop
- DB: deadline
- 일반: cfq
실무 체크리스트:
- ✅ 디렉토리당 파일 수 제한
- ✅ 대용량 파일은 스트리밍
- ✅ 로그는 날짜별 분할
- ✅ 백업은 스냅샷 활용
- ✅ 캐시와 버퍼 모니터링
다음 글에서는 "I/O - 블로킹, 논블로킹, 동기, 비동기"를 다루면서, I/O 모델의 차이와 각각의 장단점, 그리고 실제 성능에 미치는 영향을 이야기해보겠다.
'컴퓨터 공학 > 운영체제' 카테고리의 다른 글
| I/O - 블로킹, 논블로킹, 동기, 비동기의 진실 (0) | 2026.01.08 |
|---|---|
| 네트워킹 - 운영체제가 네트워크를 다루는 방법 (0) | 2026.01.08 |
| 동기화와 통신 - 멀티스레드의 가장 어려운 문제 (0) | 2026.01.07 |
| 메모리 관리 - 5년차 개발자가 겪은 메모리 누수와의 전쟁 (0) | 2026.01.06 |
| 프로세스와 스레드 - 경력 5년차 개발자의 실전 경험 (1) | 2026.01.05 |
