Crash Consistency와 Journaling 정리 (OSTEP 기반)
0. 전체 그림 한 줄 요약
“디스크에 여러 블록을 업데이트하다가 중간에 크래시 나도,
파일시스템을 일관된 상태로 다시 복구할 수 있어야 한다.”
- 파일시스템 메타데이터(슈퍼블록, 비트맵, inode, 디렉터리 등)는 영속성이 필요함.
- 여러 블록(A, B, …)을 순서대로 쓰다가 중간에 크래시 → 절반만 업데이트된 상태가 될 수 있음.
- 이 문제를 crash-consistency problem이라고 부름.
트랜잭션 고민하는 것과 완전히 똑같은 문제입니다.“여러 개를 같이 쓰다가 중간에 죽으면 어떻게 하지?”를 OS/FS 레벨에서 다루는 것뿐이에요.
1. 예제로 보는 Crash Consistency 문제
1-1. 파일 하나에 블록 추가(append) 예제
간단한 파일시스템을 가정:
- inode bitmap: 8개
- data bitmap: 8개
- inode 8개 (0~7)
- data block 8개 (0~7)
현재 inode 2번(I[v1])이 하나의 데이터 블록(4번)을 가리키는 파일이라고 하자.
I[v1]:
owner : remzi
permissions : read-write
size : 1 (블록 1개)
ptr0 : 4 (Da)
ptr1 : null
ptr2 : null
ptr3 : null
이제 이 파일 끝에 4KB 블록 하나(Db)를 append 하면, 3개의 구조를 업데이트해야 함:
- inode I[v2]: size = 2, 두 번째 포인터가 새 블록(5)을 가리킴
- data bitmap B[v2]: 블록 5번이 할당됨으로 표시
- 데이터 블록 Db: 실제 사용자 데이터
1-2. 크래시 시나리오
이상적인 최종 상태는:
- inode I[v2] (size=2, ptr0=4, ptr1=5)
- data bitmap B[v2] (…00011100 같은 형태)
- 데이터 블록 Da, Db 가 정상 저장
그런데 I[v2], B[v2], Db를 디스크에 쓰는 도중 크래시가 나면 여러 이상한 조합이 생길 수 있음.
(1) 세 개 중 딱 하나만 성공한 경우
- Db만 씀: 데이터만 있고 inode/bitmap은 모름 → “안 쓴 것과 같음” (공간은 새어나가지만 consistency는 그나마 OK)
- I[v2]만 씀: inode는 블록 5를 가리키는데, 거긴 아직 쓰레기 데이터 + bitmap은 여전히 free로 표기 → 메타데이터 불일치
- B[v2]만 씀: bitmap은 블록 5가 사용 중이라고 하는데, inode는 모름 → space leak (영원히 안 쓰이는 블록)
(2) 세 개 중 두 개만 성공한 경우
- I[v2] + B[v2]: 메타데이터는 일관적이지만, Db가 실제로 안 써졌으면 inode가 쓰레기 블록 가리키는 상태.
- I[v2] + Db: 데이터는 있지만 bitmap은 여전히 free → inode vs bitmap 불일치.
- B[v2] + Db: 블록은 쓰였고 bitmap도 사용 중인데 inode가 모르고 있음 → 이 블록이 어느 파일 소속인지 모름.
이 모든 경우가 crash-consistency 문제의 구체적인 예시다.
이건 DB 트랜잭션 중간에 죽는 것과 완전히 동일한 문제고, 해결책도 대부분 비슷해요.
2. 해결책 #1 – FSCK (File System Checker)
옛날 파일시스템은 “일단 망가져도 나중에 스캔해서 고치자”라는 전략을 썼음. 그 대표 도구가 fsck.
2-1. FSCK가 하는 일 (단계 요약)
- 슈퍼블록 검사: 크기, 블록 수, 할당 수 등 sanity check. 문제가 있으면 백업 슈퍼블록 사용.
- Free blocks 재계산: inode/indirect 블록들을 싹 스캔해서 “실제로 사용 중인 블록”을 다시 계산해 bitmap 재구성.
- Inode 상태 검사: 타입(파일/디렉토리/링크 등)이 말이 되는지, 필드가 이상하지 않은지 확인.
- 링크 카운트 검증:
- 루트부터 디렉터리 트리를 전부 스캔해서 각 inode의 실제 링크 수 계산.
- inode 내의 link count와 다르면 수정.
- 어느 디렉터리에서도 참조되지 않는 inode는
lost+found로 이동.
- 중복 블록 포인터: 서로 다른 inode가 같은 블록을 가리키면, 복사해서 각자 가지게 하거나 한 쪽을 비우는 등 조치.
- 잘못된 블록 포인터: 파티션 범위 밖을 가리키면 포인터 제거.
- 디렉터리 검사:
.,..유효성, 디렉터리 엔트리가 유효 inode를 가리키는지, 디렉터리가 2번 이상 링크되지 않는지 등.
2-2. FSCK의 한계
- 느리다: 디스크 전체를 스캔해야 함 → 수백 GB, 수 TB 디스크에서 수십 분~수 시간.
- 메타데이터 일관성만 맞추는 수준이고, 실제 데이터 내용(Db가 쓰레기인가?)까지는 복구 불가.
- RAID/대용량 환경에서 점점 비현실적인 방식이 됨.
오늘날 대용량 서비스(수십 TB·수백 TB)에서는 다운타임 관점에서 거의 불가능에 가깝습니다.
3. 해결책 #2 – Journaling (Write-Ahead Logging)
현대 파일시스템(ext3, ext4, XFS, JFS, NTFS 등)이 주로 사용하는 방식이 journaling이다.
- DB에서 온 아이디어: write-ahead logging.
- “디스크를 덮어쓰기 전에, 무엇을 할 건지 로그(저널)에 먼저 써 두자.”
- 크래시 나면 이 로그를 보고 어디를 어떻게 다시 써야 할지 정확히 알 수 있다.
3-1. ext3 레이아웃
ext2(비저널링) 구조와 비교:
ext2:
Super | Group0 | Group1 | ... | GroupN
ext3:
Super | Journal | Group0 | Group1 | ... | GroupN
저널은 파티션 내부의 한 영역이거나, 별도 디스크/파일로도 둘 수 있다.
4. Data Journaling (데이터 + 메타데이터 저널링)
4-1. 기본 아이디어
아까 append 예제(I[v2], B[v2], Db)를 journaling으로 처리하면, 먼저 저널에 이렇게 기록한다:
Journal:
TxB I[v2] B[v2] Db TxE
- TxB (Transaction Begin): 어떤 블록들을, 어느 최종 주소에 쓸 건지 + TID 등 정보.
- I[v2], B[v2], Db: 실제로 쓸 블록 내용(physical logging).
- TxE (Transaction End): 트랜잭션 끝, TID 포함.
그리고 나서 파일시스템 실제 위치에 overwrite(체크포인트)한다:
- 저널 write: TxB + I[v2] + B[v2] + Db + TxE 를 저널에 씀.
- 체크포인트: I[v2], B[v2], Db를 각자의 최종 위치에 씀.
4-2. 저널에 쓸 때의 크래시 & 쓰기 순서 문제
저널에 5블록(TxB, I[v2], B[v2], Db, TxE)을 한 번에 순차 write 하고 싶지만, 디스크 내부 스케줄링 때문에 내부 순서가 뒤섞일 수 있음.
문제 시나리오:
- 디스크가 TxB, I[v2], B[v2], TxE까지 먼저 쓰고, Db는 나중에 쓰려다 크래시.
- 저널에는 TxB, I[v2], B[v2], 쓰레기, TxE 가 남음.
- 겉으로 보면 TxB·TxE 다 있고 TID도 맞아서 “완전한 트랜잭션”처럼 보임 → 재생 시 쓰레기 블록을 Db 위치에 복사해버림.
4-3. 안전한 프로토콜 (2단계 저널 접근)
그래서 ext3 등은 저널에 쓸 때 다음 순서를 강제한다:
- Journal write: TxB, I[v2], B[v2], Db 를 먼저 모두 저널에 씀 (TxE 제외).
- Journal commit: 앞의 블록들이 디스크에 안전히 기록된 후, TxE(512B) 하나만 따로 씀.
- Checkpoint: 그 다음에 I[v2], B[v2], Db를 실제 위치에 쓰기.
디스크가 512B write에 대해 “all-or-nothing” 보장을 하므로, TxE는 반드시 온전한 한 블록 단위로만 존재한다는 가정.
4-4. Recovery (복구)
- 부팅 후, 저널을 스캔해서 commit(TxE)이 완료된 트랜잭션만 골라낸다.
- 그 트랜잭션 안의 블록들(I[v2], B[v2], Db)을 다시 최종 위치에 덮어쓰기(redo logging).
- checkpoint 도중에 크래시가 나도, 재부팅 시 다시 한번 덮어쓰면 되므로 안전하다.
→ 복구 시간은 “디스크 전체 크기”가 아닌 “저널 크기”에 비례.
DB 로그랑 거의 똑같고, 실무에서도 DB/FS 모두 이 패턴을 쓰는 경우가 많아요.
5. 최적화: Batching, Circular Log, Checksum
5-1. 여러 업데이트를 한 번에 – 글로벌 트랜잭션
파일 두 개를 연속으로 만들면, inode bitmap, 디렉터리 블록, parent inode 등 겹치는 블록이 많다. 이걸 각각 트랜잭션으로 기록하면 같은 블록을 여러 번 저널에 쓰게 된다.
그래서 보통:
- 메모리에서 여러 변경을 모아서 하나의 큰 트랜잭션으로 기록 (타임아웃 5초 등).
- 저널 write/commit를 줄여서 디스크 I/O를 줄인다.
5-2. 저널은 유한하다 → Circular Log
저널이 무한히 크지 않으니, 일정 크기까지만 쓰고 나면 다시 앞에서부터 재사용해야 한다.
- 트랜잭션들이 쌓이면 로그가 가득 참.
- 이미 checkpoint가 끝난 트랜잭션 공간은 재사용 가능.
- 이를 위해 journal superblock에 “어디까지 checkpoint 됐는지”, “어디까지가 유효한 트랜잭션인지” 정보 기록.
5-3. 쓰기 순서 최적화 – Checksum 활용
기본 프로토콜은 “TxB + 내용들 먼저, TxE 나중”이라 디스크 회전이 한 바퀴 더 필요한 비효율이 있다.
이를 개선하기 위해:
- TxB와 TxE에 트랜잭션 내용의 checksum을 포함.
- 트랜잭션 전체(TxB, 내용, TxE)를 한 번에 써도 됨.
- 복구 시 checksum을 검증해서, 깨진 트랜잭션이면 무시.
이 아이디어는 ext4에 실제로 들어갔고, 많은 리눅스/안드로이드 장비에서 사용된다.
6. Metadata Journaling (Ordered Journaling)
6-1. 왜 데이터까지 두 번 쓰면 문제가 될까?
Data journaling은 데이터 블록도 저널에 쓰고, 다시 본 위치에도 쓰므로 I/O가 거의 2배. 순차 쓰기 성능이 반으로 줄어들 수 있다.
그래서 많은 파일시스템(ext3 ordered mode, XFS, NTFS 등)은 metadata journaling만 한다.
- 저널에는 메타데이터(inode, 비트맵, 디렉터리)만 기록.
- 데이터 블록(Db)은 저널에 쓰지 않고, 바로 파일시스템 본 위치에만 쓴다.
6-2. 프로토콜 (ordered / metadata journaling)
append 예제(I[v2], B[v2], Db)를 metadata journaling으로 하면 저널에는 이렇게만 쓴다:
Journal:
TxB I[v2] B[v2] TxE
핵심 규칙: “포인터가 가리키는 데이터(=Db)를 먼저 쓰고, 그걸 가리키는 메타데이터(I[v2])를 나중에 쓰자”.
- Data write: Db를 최종 위치에 씀.
- Journal metadata write: TxB, I[v2], B[v2]를 저널에 씀.
- Journal commit: TxE를 저널에 씀.
- Checkpoint metadata: I[v2], B[v2]를 실제 위치에 씀.
- Free: 나중에 journal superblock 업데이트로 해당 트랜잭션 공간을 비움.
이 순서를 지키면, 크래시 후 복구를 해도 “inode가 쓰레기를 가리키는 상황”을 피할 수 있다. (항상 데이터가 먼저 쓰였다는 전제)
“항상 타겟(데이터) 먼저, 그걸 가리키는 포인터/메타데이터는 나중에.”
7. Block Reuse & Revoke Record (조금 더 까다로운 케이스)
journaling에서도 “블록 재사용” 때문에 미묘한 버그/문제가 나온다.
7-1. 문제 시나리오
- 디렉터리
foo의 데이터 블록이 1000번이라고 하자. foo에 엔트리를 추가해서, 디렉터리 데이터(블록 1000)를 저널에 기록 (metadata journaling이므로 디렉터리는 저널에 기록됨).- 나중에 foo를 싹 비우고 삭제 → 블록 1000이 free 상태.
- 새 파일
bar를 생성했는데, 우연히 블록 1000이bar의 데이터 블록으로 재사용됨. - 크래시 발생, 저널에는 예전 foo 디렉터리 데이터(블록 1000에 쓸 내용)와 bar inode 정보 등이 남아 있음.
복구 시, 저널을 순서대로 재생하면:
- foo 시절의 디렉터리 데이터가 다시 블록 1000에 덮어써짐.
- 결과: bar 파일의 사용자 데이터가 옛날 foo 디렉터리 내용으로 덮어씌워짐.
7-2. 해결: Revoke Record
ext3는 이 문제를 해결하기 위해 revoke record 라는 타입을 저널에 추가한다.
- 블록이 free 되거나 재사용될 때, 해당 블록에 대해 “revoke” 로그를 남김.
- 복구 시, 먼저 저널 전체를 스캔하면서 “revoke된 블록”들을 기억한다.
- revoke된 블록에 대해서는 이전 트랜잭션 내용이라도 replay하지 않음.
8. Journaling 말고 다른 접근들
8-1. Soft Updates
Ganger & Patt의 아이디어: 모든 쓰기 순서를 철저하게 관리해서, 디스크 상에서 절대 불일치가 생기지 않게 하자.
- 항상 “포인터로 가리키는 대상 먼저, 포인터 나중” 등 규칙을 엄격히 적용.
- 저널 없이도 crash-consistency를 달성할 수 있음.
- 하지만 구현 난이도가 매우 높고, 파일시스템 구조 세부를 모두 알아야 함.
8-2. Copy-On-Write (COW, 예: ZFS)
기존 데이터/메타데이터를 절대 제자리(overwrite)에서 수정하지 않고, 항상 새 위치에 쓰는 기법.
- 여러 블록을 새로 써 놓고, 마지막에 “루트 포인터”만 새 버전을 가리키게 swap.
- 이 과정에서 어느 시점이든 “옛 버전”은 온전하게 존재.
- Log-structured file system(LFS), ZFS 등에서 활용.
8-3. BBC (Backpointer-based Consistency)
위스콘신 그룹이 제안한 방식으로, 각 블록에 back pointer를 넣는 아이디어.
- 데이터 블록 안에 “이 블록을 가리키는 inode/포인터의 정보”를 같이 저장.
- inode의 forward pointer와 블록의 back pointer를 서로 비교해서 일치하면 “일관된 상태”.
- 불일치하면 crash 중간 상태로 판단하고 에러를 리턴하거나 무시.
8-4. Optimistic Crash Consistency
journaling의 write-wait를 최소화하려는 시도. 가능한 많은 쓰기를 한꺼번에 날려서 성능을 올리고, 나중에 체크섬 등으로 불일치 검출.
9. 요약: Crash Consistency & Journaling에서 꼭 가져가야 할 것
- Crash-consistency 문제: 여러 블록을 수정하는 도중 언제든 크래시가 날 수 있고, 그때 디스크가 “반쯤만 업데이트된 상태”가 될 수 있다.
- FSCK: 디스크 전체를 스캔해 메타데이터 불일치를 고치는 방식. 동작은 하지만 대용량 시대엔 너무 느림.
- Journaling: 업데이트하기 전에 저널(로그)에 “무엇을 쓸지”를 기록하고, 크래시 후 이 로그를 재생해서 빠르게 복구.
- Data journaling vs Metadata journaling: - Data journaling: 데이터 + 메타데이터 모두 저널에 기록 → 안전하지만 I/O 2배.
- Metadata journaling(ordered): 메타데이터만 저널, 데이터는 본 위치에만 기록. 대부분의 실무 FS가 선택. - 쓰기 순서 규칙: “포인터가 가리키는 대상(데이터)을 먼저 쓰고, 포인터(메타데이터)는 나중에 쓴다”는 규칙이 핵심.
- 저널은 유한 → Circular log: checkpoint가 끝난 트랜잭션은 journal superblock에서 free로 표시하고 공간 재사용.
- Revoke record: 블록 재사용 시, 예전 로그가 현재 데이터를 덮어쓰지 않도록 “revoke” 정보를 기록.
- 다른 방법들: Soft Updates(순서 관리), COW(ZFS), BBC(Backpointer), Optimistic CC 등 다양한 연구가 존재.
- 데이터/메타데이터 쓰기 순서 규칙을 항상 의식할 것 (포인터 대상 먼저).
- DB/FS/자기 서비스의 트랜잭션/로그 설계가 결국은 같은 문제를 푼다는 걸 이해할 것.
- “복구 시간 = 로그 크기”로 만들 수 있는 구조(저널/이벤트 로그 기반)를 항상 염두에 둘 것.