운영체제 아주 쉬운 세가지 이야기 15주차(크래시 일관성: FSCK와 저널링)

2025. 12. 16. 19:20개념 공부

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개의 구조를 업데이트해야 함: 

  1. inode I[v2]: size = 2, 두 번째 포인터가 새 블록(5)을 가리킴
  2. data bitmap B[v2]: 블록 5번이 할당됨으로 표시
  3. 데이터 블록 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가 하는 일 (단계 요약)

  1. 슈퍼블록 검사: 크기, 블록 수, 할당 수 등 sanity check. 문제가 있으면 백업 슈퍼블록 사용.
  2. Free blocks 재계산: inode/indirect 블록들을 싹 스캔해서 “실제로 사용 중인 블록”을 다시 계산해 bitmap 재구성.
  3. Inode 상태 검사: 타입(파일/디렉토리/링크 등)이 말이 되는지, 필드가 이상하지 않은지 확인.
  4. 링크 카운트 검증:
    • 루트부터 디렉터리 트리를 전부 스캔해서 각 inode의 실제 링크 수 계산.
    • inode 내의 link count와 다르면 수정.
    • 어느 디렉터리에서도 참조되지 않는 inode는 lost+found로 이동.
  5. 중복 블록 포인터: 서로 다른 inode가 같은 블록을 가리키면, 복사해서 각자 가지게 하거나 한 쪽을 비우는 등 조치.
  6. 잘못된 블록 포인터: 파티션 범위 밖을 가리키면 포인터 제거.
  7. 디렉터리 검사: ., .. 유효성, 디렉터리 엔트리가 유효 inode를 가리키는지, 디렉터리가 2번 이상 링크되지 않는지 등.

2-2. FSCK의 한계

  • 느리다: 디스크 전체를 스캔해야 함 → 수백 GB, 수 TB 디스크에서 수십 분~수 시간.
  • 메타데이터 일관성만 맞추는 수준이고, 실제 데이터 내용(Db가 쓰레기인가?)까지는 복구 불가.
  • RAID/대용량 환경에서 점점 비현실적인 방식이 됨.
FSCK는 “이미 부서진 상태를 전수조사해서 맞추는” 방식이라,
오늘날 대용량 서비스(수십 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(체크포인트)한다:

  1. 저널 write: TxB + I[v2] + B[v2] + Db + TxE 를 저널에 씀.
  2. 체크포인트: 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 등은 저널에 쓸 때 다음 순서를 강제한다: 

  1. Journal write: TxB, I[v2], B[v2], Db 를 먼저 모두 저널에 씀 (TxE 제외).
  2. Journal commit: 앞의 블록들이 디스크에 안전히 기록된 후, TxE(512B) 하나만 따로 씀.
  3. 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 도중에 크래시가 나도, 재부팅 시 다시 한번 덮어쓰면 되므로 안전하다.

→ 복구 시간은 “디스크 전체 크기”가 아닌 “저널 크기”에 비례.

Journaling은 “업데이트 계획을 노트(저널)에 써두고, 이 노트를 기준으로 복구”하는 구조입니다.
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])를 나중에 쓰자”.

  1. Data write: Db를 최종 위치에 씀.
  2. Journal metadata write: TxB, I[v2], B[v2]를 저널에 씀.
  3. Journal commit: TxE를 저널에 씀.
  4. Checkpoint metadata: I[v2], B[v2]를 실제 위치에 씀.
  5. Free: 나중에 journal superblock 업데이트로 해당 트랜잭션 공간을 비움.

이 순서를 지키면, 크래시 후 복구를 해도 “inode가 쓰레기를 가리키는 상황”을 피할 수 있다. (항상 데이터가 먼저 쓰였다는 전제)

이 규칙은 파일시스템뿐 아니라, 모든 영속성 시스템에서 거의 만능 법칙입니다.
“항상 타겟(데이터) 먼저, 그걸 가리키는 포인터/메타데이터는 나중에.”

7. Block Reuse & Revoke Record (조금 더 까다로운 케이스)

journaling에서도 “블록 재사용” 때문에 미묘한 버그/문제가 나온다.

7-1. 문제 시나리오

  1. 디렉터리 foo의 데이터 블록이 1000번이라고 하자.
  2. foo에 엔트리를 추가해서, 디렉터리 데이터(블록 1000)를 저널에 기록 (metadata journaling이므로 디렉터리는 저널에 기록됨).
  3. 나중에 foo를 싹 비우고 삭제 → 블록 1000이 free 상태.
  4. 새 파일 bar를 생성했는데, 우연히 블록 1000이 bar의 데이터 블록으로 재사용됨.
  5. 크래시 발생, 저널에는 예전 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에서 꼭 가져가야 할 것

  1. Crash-consistency 문제: 여러 블록을 수정하는 도중 언제든 크래시가 날 수 있고, 그때 디스크가 “반쯤만 업데이트된 상태”가 될 수 있다.
  2. FSCK: 디스크 전체를 스캔해 메타데이터 불일치를 고치는 방식. 동작은 하지만 대용량 시대엔 너무 느림.
  3. Journaling: 업데이트하기 전에 저널(로그)에 “무엇을 쓸지”를 기록하고, 크래시 후 이 로그를 재생해서 빠르게 복구. 
  4. Data journaling vs Metadata journaling: - Data journaling: 데이터 + 메타데이터 모두 저널에 기록 → 안전하지만 I/O 2배.
    - Metadata journaling(ordered): 메타데이터만 저널, 데이터는 본 위치에만 기록. 대부분의 실무 FS가 선택.
  5. 쓰기 순서 규칙: “포인터가 가리키는 대상(데이터)을 먼저 쓰고, 포인터(메타데이터)는 나중에 쓴다”는 규칙이 핵심. 
  6. 저널은 유한 → Circular log: checkpoint가 끝난 트랜잭션은 journal superblock에서 free로 표시하고 공간 재사용.
  7. Revoke record: 블록 재사용 시, 예전 로그가 현재 데이터를 덮어쓰지 않도록 “revoke” 정보를 기록.
  8. 다른 방법들: Soft Updates(순서 관리), COW(ZFS), BBC(Backpointer), Optimistic CC 등 다양한 연구가 존재. 
실제 서비스 개발자로서 이 챕터에서 건질 수 있는 핵심은 크게 세 가지입니다.
  1. 데이터/메타데이터 쓰기 순서 규칙을 항상 의식할 것 (포인터 대상 먼저).
  2. DB/FS/자기 서비스의 트랜잭션/로그 설계가 결국은 같은 문제를 푼다는 걸 이해할 것.
  3. “복구 시간 = 로그 크기”로 만들 수 있는 구조(저널/이벤트 로그 기반)를 항상 염두에 둘 것.