F2FS 파일시스템 압축 적용에 대한 고찰

F2FS가 파일시스템 레벨 압축을 지원한다는 사실 자체는 잘 알려져 있지만, “얼마나 절약되는가”는 막상 찾아보면 명확한 답이 잘 안 나온다. 압축률은 데이터 종류에 따라 천차만별이고, F2FS는 일반적인 압축 유틸리티와 달리 클러스터 단위로만 절약분을 인정하기 때문에 실제 효과는 생각보다 계단식이다. 이 글에서는 F2FS가 지원하는 압축 알고리즘, 압축이 실제로 동작하는 방식(클러스터·임계값), 이 구조 때문에 블록 사용량이 어떤 패턴으로 줄어드는지, 그리고 이를 개선할 수 있는 실전 아이템을 정리한다.

지원하는 압축 알고리즘

커널 문서(Documentation/filesystems/f2fs.rst) 기준으로 F2FS는 lzo, lzo-rle, lz4, zstd 네 가지 알고리즘을 지원한다. LZ4는 커널 5.6, zstd는 5.7에 병합됐다(Chao Yu·Jaegeuk Kim 작업). 압축 레벨을 지정할 수 있는 건 lz4(3~16)와 zstd(1~22)뿐이고, lzo·lzo-rle는 레벨 개념이 없다. 일반적으로 lzo-rle/lz4는 속도가 빠른 대신 압축률이 낮고, zstd는 CPU를 더 쓰는 대신 압축률이 높은 트레이드오프를 갖는다 — 이 트레이드오프가 실제로 왜 중요한지는 아래 “임계값” 절에서 다시 다룬다.

1GB 분량의 혼합 데이터셋을 구성해 QEMU 테스트 환경에서 직접 재봤다. 한 가지 데이터 종류만 쓰면 압축률이 그 종류의 특성에 치우치므로, 압축 특성이 서로 다른 네 카테고리를 각각 약 200~300MB씩 섞었다.

카테고리구성크기압축 특성
source커널 소스 트리 일부(C 코드·텍스트)312 MB매우 잘 압축됨
binary/usr/bin ELF 실행 파일200 MB어느 정도 압축됨
compressed.deb 패키지(이미 gzip/xz 압축됨)200 MB거의 압축 안 됨
random/dev/urandom301 MB전혀 압축 안 됨

알고리즘별로 2GB 루프백 이미지를 새로 포맷하고, 같은 데이터셋을 복사한 뒤 압축 블록을 실제로 반환(release_cblocks)해 du -sh로 측정했다 — release를 안 하면 아래 “여기서 중요한 함정” 절에서 설명하듯 압축 효과가 du/df에 안 잡힌다.

for algo in lzo lzo-rle lz4 zstd; do
  truncate -s 2G f2fs_img_$algo.bin
  mkfs.f2fs -f -O extra_attr,compression f2fs_img_$algo.bin
  mount -o loop,compress_algorithm=$algo f2fs_img_$algo.bin f2fs_mnt
  chattr +c f2fs_mnt
  cp -a testdata/. f2fs_mnt/
  sync
  find f2fs_mnt -type f -exec f2fs_io release_cblocks {} \;
  sync
  du -sh f2fs_mnt
  umount f2fs_mnt
done
알고리즘전체(1010MB 원본 기준)sourcebinarycompressedrandom
없음(비압축 기준)998 MB300 MB200 MB200 MB301 MB
lzo827 MB (-17.1%)195 MB (-35.0%)133 MB (-33.5%)200 MB (0%)301 MB (0%)
lzo-rle828 MB (-17.0%)196 MB (-34.7%)133 MB (-33.5%)200 MB (0%)301 MB (0%)
lz4836 MB (-16.2%)201 MB (-33.0%)136 MB (-32.0%)200 MB (0%)301 MB (0%)
zstd790 MB (-20.8%)182 MB (-39.3%)109 MB (-45.5%)200 MB (0%)301 MB (0%)

네 알고리즘 모두에서 compressed·random 카테고리는 단 1바이트도 줄지 않았다 — 이미 압축됐거나 애초에 압축 불가능한 데이터는 클러스터 임계값(아래 절 참고)을 넘기지 못해 압축을 포기하고 원본 그대로 저장된다는 걸 그대로 보여준다. 반면 source(텍스트/코드)와 binary(실행 파일)는 알고리즘 간 차이가 뚜렷했다. 압축률은 zstd > lzo ≈ lzo-rle > lz4 순이었는데, lzo와 lzo-rle는 이 데이터셋에서 거의 차이가 없었다 — lzo-rle는 반복되는 동일 바이트 구간(run-length)에 특화된 최적화라, 그런 패턴이 두드러지지 않는 일반 텍스트/바이너리에서는 이득이 크지 않기 때문으로 보인다.

release 전에는 네 알고리즘 모두 du -sh가 원본과 동일한 1010MB를 그대로 보고했다는 점도 확인했다 — 압축은 실제로 일어났지만(release 시도 시 15,401개 파일 중 5,856~5,859개가 실제로 압축 블록을 갖고 있어 반환에 성공했고, 나머지는 “Operation not permitted”로 반환할 게 없었다), 반환 전까지는 예약 상태로 남아 있어 du에 잡히지 않는다.

압축이 실제로 동작하는 방식 — 클러스터와 임계값

F2FS는 파일을 클러스터(cluster) 단위로 나눠 압축한다. 클러스터 하나는 기본 4블록(4 << 0 = 16KB)이며, compress_log_size 마운트 옵션으로 4KB × (1 << n) 크기까지 키울 수 있다. 압축 여부 판단은 클러스터 단위로만 이뤄지고, 커널 소스(fs/f2fs/compress.c)의 cluster_may_compress()는 다음 조건을 전부 만족해야 압축을 시도한다.

static bool cluster_may_compress(struct compress_ctx *cc)
{
	if (!f2fs_need_compress_data(cc->inode))
		return false;
	if (f2fs_is_atomic_file(cc->inode))
		return false;
	if (!f2fs_cluster_is_full(cc))          // 클러스터가 꽉 차 있어야 함
		return false;
	if (unlikely(f2fs_cp_error(F2FS_I_SB(cc->inode))))
		return false;
	return !cluster_has_invalid_data(cc);   // 클러스터 안에 무효 블록이 없어야 함
}

즉 클러스터의 일부만 유효 데이터로 채워져 있으면(파일 끝부분처럼) 압축을 아예 시도하지 않는다. 조건을 통과해도 실제로 압축된 형태로 저장할지는 또 다른 기준을 통과해야 하는데, 압축 후 크기가 PAGE_SIZE × (cluster_size - 1) - COMPRESS_HEADER_SIZE보다 작아야 한다.

풀어서 말하면 클러스터 안에서 블록을 최소 1개(4KB) 이상 절약하지 못하면 압축을 포기하고 원본 그대로 저장한다는 뜻이다. 기본 4블록 클러스터라면 압축 후 크기가 12KB(3블록) 미만이어야 의미가 있고, 12KB~16KB 사이로만 줄어드는 경우는 압축 시도 자체가 무효화돼 그냥 4블록 그대로 저장된다. 압축에 성공하면 남는 블록은 물리적으로 쓰지 않고 COMPRESS_ADDR이라는 특수 블록 주소로만 표시해둔다(이 메커니즘은 F2FS 메타데이터 오버헤드 글에서 다룬 SIT/NAT 구조와는 무관하게, 메인 영역의 실사용 블록 수만 줄이는 방식이다).

활성화 방법

# mkfs 시점에 압축 기능 포함
mkfs.f2fs -O extra_attr,compression /dev/sdX

# 파일/디렉터리 단위 활성화 (디렉터리에 걸면 이후 생성 파일에 상속)
chattr +c /mnt/bigfile.dat

# 마운트 시점에 알고리즘·확장자 지정
mount -o compress_algorithm=zstd,compress_extension=* /dev/sdX /mnt

실제로 블록 사용량이 얼마나 줄어드는가

클러스터 단위로만 절약이 인정되기 때문에, 실제 절감 효과는 연속적인 비율이 아니라 계단식이다. 기본 4블록 클러스터에서는 압축 후 크기에 따라 절약되는 블록 수가 0개, 1개, 2개, 3개 중 하나로만 떨어진다 — 즉 클러스터 하나의 실효 압축률은 100%(절약 없음), 75%, 50%, 25% 중 하나로 양자화된다. 압축률이 예를 들어 60%라 해도 클러스터 크기가 작으면 그 중간값을 표현할 해상도 자체가 없다.

실제 절약량을 확인한 사례로는 Arch Linux 포럼에서 보고된 극단적 케이스가 있다. 0으로 채운 10GB 파일을 만든 뒤 df로 보면 처음엔 10GB가 그대로 사용량에 잡히는데, 이는 F2FS가 압축 여부와 무관하게 블록을 우선 예약해두기 때문이다. 이후 f2fs_io release_cblocks로 예약분을 명시적으로 반환하면 그제서야 약 1,966,047블록(약 8GB)이 회수됐다고 보고됐다 — 압축이 잘 되는 극단적 케이스(전부 0)에서 클러스터당 4블록 중 3블록을 절약(75%)한 셈이다. 일반적인 텍스트·로그 데이터는 이 정도로 압축되지 않으므로 실제 절감률은 데이터 특성에 따라 훨씬 낮을 수 있다.

여기서 중요한 함정이 하나 있다. 압축 자체는 compress_mode=fs(기본값)에서 파일을 쓸 때 자동으로 일어나지만, 그렇게 절약된 블록이 곧바로 여유 공간으로 잡히지 않을 수 있다는 점이다. 위 사례처럼 예약된(reserved) 블록을 실제 가용 공간으로 되돌리려면 f2fs_io release_cblocks(또는 동일한 동작을 하는 F2FS_IOC_RELEASE_COMPRESS_BLOCKS ioctl)를 명시적으로 호출해야 하는 경우가 있다. 압축을 켰는데 df 상 용량이 기대만큼 줄지 않는다면, 압축이 안 된 게 아니라 예약분 반환을 안 한 것일 수 있다.

개선할 수 있는 아이템

  • 예약 블록 명시적 반환: f2fs_io release_cblocks <file>로 압축 후 남는 예약 블록을 실제 가용 공간으로 되돌린다. 반대로 reserve_cblocks는 나중에 파일을 다시 압축 해제할 상황을 대비해 블록을 미리 확보해두는 용도이고, get_cblocks로 현재 압축 블록 수를 조회할 수 있다.
  • 클러스터 크기(compress_log_size) 조정: 클러스터를 키우면(예: 8블록=32KB) 절약분을 더 세밀한 단위로 인정받을 수 있지만, 그만큼 “클러스터 전체가 유효 데이터로 가득 차 있어야 압축을 시도한다”는 조건을 만족하기 어려워져 압축이 아예 시도되지 않는 파일이 늘어날 수 있다. 워크로드의 평균 파일 크기와 함께 조정해야 한다.
  • 알고리즘·레벨 선택: 클러스터 단위 임계값(최소 1블록 절약) 때문에 압축률이 조금만 높아도 “절약 인정” 여부가 갈릴 수 있다. lzo-rle/lz4로 속도를 우선할지, zstd로 압축률을 높여 임계값을 더 쉽게 넘길지는 CPU 여유와 데이터 특성에 맞춰 정해야 한다.
  • compress_mode=user + ioctl 수동 제어: 자동(fs) 모드 대신 F2FS_IOC_COMPRESS_FILE/F2FS_IOC_DECOMPRESS_FILE로 압축 시점을 직접 제어할 수 있다. 자주 갱신되는 파일은 압축 오버헤드를 피하고, 콜드 데이터만 배치로 압축하는 식의 정책을 세울 수 있다.
  • compress_cache 마운트 옵션: 압축 해제된 블록을 페이지 캐시에 남겨둬 반복 읽기 시 압축 해제 오버헤드를 상쇄한다. 자주 읽는 압축 파일이 많다면 켜두는 게 유리하다.

메타데이터 오버헤드까지 합쳐서 본 1GB 볼륨의 실사용 가능 크기

지금까지는 “압축이 데이터를 얼마나 줄이는가”만 봤다. 하지만 볼륨에는 구조적 메타데이터와 오버프로비저닝(GC 예약)이라는, 압축과 무관하게 이미 깎여 있는 몫도 있다. 1GB 볼륨 기준으로 두 요인을 합쳐서 실제로 얼마나 담을 수 있는지 직접 확인했다.

항목출처
명목 용량1,024 MB
구조적 메타데이터(SIT+NAT+SSA)-20 MB (1.95%)3520편 실측
오버프로비저닝(GC 예약, auto 5.37%)-54 MB (5.27%)3520편 실측
물리적으로 쓸 수 있는 공간950 MB (92.77%)3520편 실측

이 950MB 한도 안에서 압축이 실제로 얼마나 여유를 더 만들어주는지, 이 글의 1010MB 혼합 데이터셋을 같은 1GB 볼륨에 직접 복사해보며 세 가지 방식으로 실측했다.

방식결과비고
압축 없음, 한 번에 복사898MB에서 No space left on device3520편이 예측한 950MB 한도 근방에서 실패
zstd 압축, 한 번에 복사 (release 없이)동일하게 898MB에서 실패압축해도 쓰기 시점엔 최악의 경우로 블록을 예약해두므로 무의미했다
zstd 압축, 카테고리별로 복사 직후 release_cblocks1010MB 전량 성공, 최종 물리 사용량 790MB (101MB 여유)압축 절감분을 쓰는 도중에 계속 회수해야 그 여유를 다음 쓰기에 쓸 수 있다

메타데이터·오버프로비저닝을 감안하면 이 1GB 볼륨의 물리적 한도는 950MB인데, zstd 압축을 쓰는 도중에 주기적으로 release하면서 채웠더니 그 한도를 넘어 원본 데이터 1010MB 전량을 담고도 101MB가 남았다. 반대로 말하면 나머지 101MB 여유분(압축률을 고려하면 논리 데이터 기준 약 100~130MB 더)까지 활용 가능하다는 뜻이다. 다만 이건 이 글의 데이터 구성(텍스트·바이너리 60%, 이미 압축됐거나 무작위인 데이터 40%) 기준이라, compressed·random 비중이 더 높은 워크로드라면 여유분은 그만큼 줄어든다.

주의사항

  • 커널이 CONFIG_F2FS_FS_COMPRESSION 없이 빌드됐다면 chattr +c나 마운트 옵션이 에러 없이 성공한 것처럼 보여도 압축은 조용히 무시된다. 실제 적용 여부는 f2fs_io get_cblocks로 확인하는 게 안전하다.
  • 이미 압축된 데이터(미디어 파일, 이미 gzip된 아카이브)나 암호화된 데이터는 애초에 압축 여지가 거의 없어 클러스터 임계값을 넘기지 못하고 그대로 저장되는 경우가 대부분이다.
  • F2FS 압축은 “write-once” 파일을 전제로 설계됐다. 클러스터 일부만 덮어쓰면 재압축이 필요해지는데, 이 과정에서 클러스터 전체를 다시 읽고 압축하는 오버헤드가 생길 수 있다.
  • 압축이 예약 블록을 곧바로 반환하지 않는다는 점은 용량 산정에도 그대로 영향을 준다. 1GB 볼륨에 1010MB 데이터를 압축해서 담아본 테스트에서, release_cblocks 없이 한 번에 복사하면 압축을 켜도 안 켠 것과 똑같이 898MB 지점에서 공간 부족으로 실패했다. 나눠 쓰면서 그때그때 반환해야만 압축의 용량 이득을 실제로 활용할 수 있다.

마무리

F2FS 압축은 일반적인 압축 유틸리티처럼 임의의 비율로 공간을 줄여주는 게 아니라, 클러스터 단위로 최소 1블록 이상을 절약해야만 효과가 인정되는 계단식 구조다. 이 때문에 실제 절감 효과는 데이터의 압축률뿐 아니라 클러스터 크기, 그리고 예약 블록을 실제로 반환했는지에 따라 크게 달라진다. 압축을 켰는데 기대만큼 용량이 안 줄었다면 알고리즘 문제보다 클러스터 임계값을 못 넘겼거나 release_cblocks를 안 한 경우를 먼저 의심해보는 편이 낫다.

참고

답글 남기기