altair의 프로젝트 일기

러스트로 tar 직접 구현하기 (1) 본문

IT/러스트

러스트로 tar 직접 구현하기 (1)

altair823 2022. 12. 14. 06:50

개요

 예전에 여러 글로 따로 썼듯이 난 라즈베리파이로 만든 개인 나스를 아직도 적극적으로 사용중이다. 거의 컴퓨터 D드라이브처럼 많은 파일을 보관하는 용도로 사용 중이다. 오죽하면 데스크탑의 용량은 빠르게 작업하기 위한 캐시가 아닌가 하는 정도로 말이다. 게다가 맥북과 윈도우 데스크탑을 자주 번갈아 쓰는 난 양쪽 모두에서 접근할 수 있는 개인 공유 드라이브가 있다는 점에서 나스가 참 매력적이다.  

 

ImageCompressor2 개발기

가끔 아카이빙 했던 사진들을 둘러보곤 한다. 그리운 옛날 생각도 나고 그때 기분도 다시 느낄 수 있었다. 그러다 어느 날 모든 사진들의 용량을 살펴봤다. 80기가가 넘는 용량이었다. 평소라면

altair823.tistory.com

 

 그러던 어느 날, 다시 한 번 사진들이 날 불편하게 했다. 전체 폴더를 복사하는데 너무 오랜 시간이 걸리는 것이었다. 몇 달전에 만든 프로그램으로 80기가에 육박하는 4000여 개의 PNG 파일들을 4기가로 압축할 수 있었다. 하지만 파일의 개수는 변한 것이 없다. 이게 뭐가 문제냐고 할 수도 있겠다. 

 조금 러프하게 설명하자면, 파일의 개수가 많아질수록(그리고 만약 하드디스크라면 남은 용량이 적을수록) 파일이 디스크 여러 곳에 분산되어 저장된다. 요새 많이 쓰는 SSD라면 파일이 어디에 있던 접근 속도가 똑같이 빠르겠지만 아쉽게도 내가 쓰는 나스는 하드디스크를 사용 중이기 때문에 파일들이 디스크에 저장된 위치를 찾는데에도 오랜 시간이 필요하다. 때문에 그 모두를 복사하는데에도 많은 시간이 걸린다. 

 "만약 이 파일들을 하나의 파일로 묶어 저장할 수 있다면 더 빠른 복사가 가능하지 않을까?" 하는 생각이 들었다. 가장 대표적인 해결책은 폴더를 압축하는 것일테다. 일반적으로 말하자면 7z나 zip과 같은 것 말이다. 

 또 하나 이런 목적으로 사용할 수 있는 도구가 있는데 바로 tarball(한글)이다. tape archive의 준말로, 단순히 여러 파일들을 하나의 큰 파일에 묶어 테이프에 저장하기 위해 만들어졌다. 지금에야 기업이 아닌 이상 테이프를 사용하진 않지만 이렇게 여러 파일들을 하나로 묶는데 사용하는 대표적인 도구이다. 

 7z과 zip, xz와 같은 압축 파일과 다른 점은 tar가 무손실 무압축 프로그램이란 것이다. 한 마디로 압축을 전혀 하지 않는다. 오히려 메타데이터를 저장하기 때문에 용량이 소폭 증가한다. 또한 압축을 하지 않기 때문에 속도가 매우 빠르다. 압축이 필요하다면 결과물로 나온 .tar 파일을 추후에 압축하면 된다. 

 이미 많은 tar 구현체가 있다. 리눅스와 맥은 물론이고 윈도우도 WSL을 탑재하면서 tar을 사용할 수 있게 되었다(반 쯤은 리눅스를 이용하긴 하지만). 이번엔 직접 나만의 tarball을 만들어보고 싶었다. 추후에 여러 기능들도 추가하고 싶었기 때문이다. 예를 들면 암호화나 압축, 멀티스레딩 같은 기능 말이다. 

 

Rust Programming Language

A language empowering everyone to build reliable and efficient software.

www.rust-lang.org

 사용할 언어는 저번에도 썼던 Rust로 정했다.

 

프로그램이 할 일

 프로그램이 해야 할 일은 크게 두 가지로 나눌 수 있다. 

  1. 여러 파일들을 읽고 그 데이터들을 한 파일로 합치는 것
  2. 1에서 합친 파일을 읽어 파일들을 차례대로 복원하는 것

1을 직렬화(Serialize), 2를 역직렬화(Deserialize)라고 이름 붙이고 이 둘을 올바르게 구현하는 것을 목표로 했다.

 

직렬화

구조

직렬화된 파일의 구조

직렬화된 파일은 위와 같은 구조로 저장될 것이다. 이텔릭체로 쓰여진 부분은 계속 반복되는 부분이다. 

 

메타데이터

 프로그램이 파일 데이터를 저장하면서 가장 먼저 다루어야 할 것은 바로 메타데이터다. 메타데이터는 데이터에 대한 데이터란 뜻인데 이 경우엔 파일의 경로, 크기, 생성날짜 등을 말한다. 내가 이번에 사용한 파일의 메타데이터는 다음과 같다. 

  • 경로 - 파일의 이름을 포함한다. 타겟이 되는 루트 폴더로부터의 상대 경로.
  • 크기 - 파일의 바이트 크기. 
  • 파일, 디렉토리, 심볼릭 링크인지 여부
  • MD5 체크섬

메타데이터 구조체

 여기서 눈여겨 보아야 할 것은 체크섬이다. 보통 내려받은 파일의 무결성을 검증하기 위해 체크섬을 활용한다. 파일을 복원했을 때 그 파일의 데이터 무결성을 체크하기 위해 원본 파일의 체크섬을 같이 저장하기로 했다. 해시 알고리즘은 MD5를 사용했다. 

 보안 측면에서 MD5는 피해야 할 알고리즘이지만 파일 경로를 제외하면 체크섬이 메타데이터에서 가장 큰 데이터 크기를 사용하므로 SHA보다 짧은 해시값, 빠른 속도 때문에 사용하기로 했다. 어차피 파일 데이터 무결성 검증에만 사용할 것이기 때문이다. 

 파일 경로와 파일 크기를 제외한 나머지 값들은 모두 크기가 정해져 있다. 특히 파일 크기는 64비트 정수이기 때문에 그대로 저장하기보다 이를 little-endian으로 변환하고, 이 중 의미를 갖는 바이트의 수를 1바이트에 따로 저장하는 방식으로 크기를 줄일 수 있었다. 파일 경로는 경로 문자열 바이트 수를 위와 같은 방법으로 저장하고 그 뒤로 경로 문자열을 저장했다. 파일 앞 쪽에 저장하는 파일 카운트 또한 이런 방식으로 저장하였다. 

플래그들과 체크섬은 크기가 정해져 있으므로 순서만 맞춰서 쓰고 읽으면 된다. 

 

파일 데이터

 메타데이터를 올바르게 썼다면 이제 파일 데이터를 쓸 차례다. 파일의 데이터는 메타데이터에 비해 매우 크다. 기본적인 파일 포인터로 직렬화 파일에 쓸 경우 파일 쓰기 시스템 콜을 매번 호출해 극단적으로 성능이 느려질 것이다. 따라서 러스트 표준 라이브러리의 BufWriter를 사용했다. 파일 읽기 역시 BufReader를 사용했다. 

let mut buffer_reader = BufReader::new(File::open(original_file)?);
loop {
    let length = {
        let buffer = buffer_reader.fill_buf()?;

        self.result.write(buffer)?;
        buffer.len()
    };
    if length == 0 {
        break;
    }
    buffer_reader.consume(length);
}
self.result.flush()?;

BufReader로 파일을 읽고 그 데이터를 BufWriter로 쓰는 코드. self.result는 미리 정의된 BufWriter이다.

더보기

 러스트 표준 라이브러리는 BufReader, BufWriter의 내부 버퍼 크기를 8KB로 정의하고 있다. (직접 따로 설정하지 않는다면) 이는 8KB씩 읽고 쓴다는 의미이다. 하지만 시스템 콜을 최소한으로 호출하려는 특성상 flush를 호출하기 전까진 파일에 쓰여졌는지 확실하지 않다. 한 번에 모아서 쓰는 경우도 많기 때문이다. 

 위의 코드는 원본 파일을 내부 버퍼 크기 만큼 읽어 그 데이터를 다시 결과 파일에 쓰고 이를 파일 끝까지 반복하는 코드이다. 만약 읽어온 크기가 0이라면 파일을 모두 읽은 것이다. 

 가장 기본적인 직렬화는 위 과정을 반복하는 것으로 이루어져 있다. 다음은 역직렬화를 구현해보자. 

 

역직렬화

파일 읽기

 직렬화된 파일의 역직렬화를 위해서는 먼저 그 파일을 읽어야 한다. 파일을 읽을 때는 필요할 때마다 일정 크기만큼 읽기보다는 어느 정도 큰 청크를 읽고 그것을 필요한 만큼 나눠 역직렬화를 하는 것이 더 효율적이다. 결과적으로 파일을 더 적은 횟수로 읽기 때문이다(시스템과 저장 매체마다 다른 크기의 최적화된 청크 크기가 있을 것이다). 

fn fill_buf(&mut self) -> io::Result<usize> {
        self.buffer.append(&mut VecDeque::from_iter(
            self.serialized_file.fill_buf()?.to_vec(),
        ));
        self.serialized_file.consume(self.buffer.len());
        Ok(self.buffer.len())
    }

    fn fill_buf_with_len(&mut self, length: usize) -> io::Result<Vec<u8>> {
        while self.buffer.len() < length {
            let previous_buf_len = self.buffer.len();
            self.fill_buf()?;
            if self.buffer.len() == previous_buf_len {
                return Ok(self.buffer.drain(..self.buffer.len()).collect());
            }
        }
        Ok(self.buffer.drain(..length).collect())
    }

 위 코드의 fill_buf 함수는 파일을 BufReader 내부 버퍼의 크기만큼 읽고 구조체 내부 버퍼(self.buffer는 VecDeque로 구현한 버퍼다)에 저장하는 함수다. fill_buf_with_len 함수는 fill_buf로 파일을 읽고 함수에 요청한 크기만큼 버퍼에 저장한 데이터를 전달하는 함수다. 이 두 함수를 사용하면 다음과 같은 일을 할 수 있다. 

  1. 사용자가 파일을 20 바이트 만큼 읽고 싶어한다. 
  2. self.fill_buf_with_len(20)을 호출한다. 
  3. 함수는 내부 버퍼에 이미 20 바이트 이상의 데이터가 존재하는지 확인한다.
  4. 존재하지 않는다면 fill_buf 함수를 호출해 내부 버퍼를 채운다. 내부 버퍼의 크기가 20 바이트 이상이 될 때까지 반복한다.
  5. 내부 버퍼에 20 바이트 이상의 데이터가 존재한다면, 내부 버퍼의 앞에서부터 20 바이트 만큼의 데이터를 반환하고 제거한다. 

 사용자는 파일 읽기 함수를 호출하거나 버퍼를 따로 다루지 않더라도 필요할 때 필요한 만큼 데이터를 받을 수 있다. 

 

데이터 역직렬화

 직렬화된 파일에 있는 데이터는 다음과 같은 구조로 되어 있다. 

직렬화된 파일의 구조

 파일 태그는 직렬화된 파일이 맞는지, 파일 카운트는 역직렬화가 올바르게 완료되었는지 확인하는 용도이다. 그 뒤로 이텔릭체로 쓴 메타데이터와 파일 데이터는 파일 끝까지 계속 반복되는 데이터다. 파일 태그는 크기가 정해져 있으므로 그대로 읽고, 파일 카운트는 little-endian으로 저장된 정수를 다시 읽으면 된다.

 메타데이터는 각 하위 데이터의 크기가 고정되어 있거나 가변적일 경우 데이터보다 앞에 명시되어 있다. 파일 데이터의 크기는 메타데이터의 한 하위 항목인 파일 크기에 나와 있으므로 그만큼 읽으면 된다. 

 이런 방법으로 파일 전체를 읽고 복원하면 역직렬화를 구현할 수 있다. 

 

이제 아주 기본적인 직렬화와 역직렬화를 구현했다. 다음 글에선 이 데이터를 암호화하고 압축하는 방법에 대해 알아보자. 

Comments