바이너리의 단점

마지막 업데이트: 2022년 2월 10일 | 0개 댓글
  • 네이버 블로그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 트위터 공유하기
  • 카카오스토리 공유하기
Big O time complexity 시간 복잡도

바이너리 코드의 차이점 분석은 보안 패치와 같은 매우 유사한 두 프로그램 사이의 차이점을 구별해 주는 방법이다. 이전의 연구에서는 분석을 위하여 프로그램의 구조 또는 명령어의 세부 사항만을 각각 이용하였다. 프로그램의 구조를 이용하는 차이점 분석 방법은 제어 흐름의 변화는 잘 탐지해 낼 수 있지만, 버퍼 크기 변화와 같은 상수 값의 변화는 잘 찾아낼 수 없다. 명령어 기반의 차이점 분석 방법은 세부적인 값의 변화는 발견할 수 있으나 명령어 재배치와 같은 컴파일러에 의해 생성되는 불필요한 차이점을 결과로 낸다는 단점이 있다. 이 연구에서는 프로그램 구조를 이용한 비교 분석 방법에 상수 값의 변화를 함께 추적할 수 있는 방법을 제안하고 바이너리 차이점 분석 도구를 구현하였다. 구현된 도구는 윈도 보안 업데이트를 이용하여 평가하였다. 실험 결과 제안된 방법은 구조적인 차이점 분석과 같이 빠른 속도로 구조적인 변화를 찾아낼 뿐 아니라 상수 값의 변화까지 추적할 수 있다는 것을 보였다.

Binary diffing is a method to find differences in similar binary executables such as two different versions of security patches. Previous diffing methods using flow information can detect control flow changes, but they cannot track constant value changes. Diffing methods using assembly instructions can detect constant value changes, but they give false positives which are due to compiling methods such as instruction reordering. We present a binary diffing method and its implementation named SCV which utilizes both structure and value information. SCV summarizes structure and constant value information from disassembled code, and matches 바이너리의 단점 the summaries to find differences. By analyzing a Microsoft Windows security patches, we showed that SCV found necessary differences caused by constant value changes which the state-of-the-art binary diffing 바이너리의 단점 tool BinDiff failed to find.

바이너리의 단점

데이터 파일의 형식 - 바이너리 파일, 텍스트 파일

XML은 데이터를 기술하고 구조화하는 것과 관련한 기술이다. 따라서 XML에 대한 개념을 철저히 이해하기에 앞서 컴퓨터가 어떤 방식으로 데이터를 저장하고 접근하는지에 대한 이해가 필요하다. 컴퓨터가 이해할 수 있는 데이터 파일의 종류는 두 가지가 있다. 바로 바이너리(binary) 파일과 텍스트(text) 파일이다.

아주 단순한 의미로 바이너리 파일은 비트(1과0)의 나열이라고 할 수 있다. 전체 비트들이 무엇을 의미하는지는 그 바이너리 파일을 생성한 프로그램에 달려있다. 이것은 바이너리 파일을 이해할 수 있도록 작성된 특정 프로그램에 의해서만 그것이 생성되고 읽혀지기 때문이다.

바이너리 파일 형식은 컴퓨터가 이들 바이너리 코드를 쉽게 이해할 수 있기 때문에 코드를 더욱 빨리 처리할 수 있으며 메타 데이터를 저장하는 데 매우 효율적이라는 장점을 가지고 있다. 그러나 바이너리 파일 형태는 단점도 지니고 있다. 어떤 프로그램에서 작성된 파일을 다른 프로그램에서 열 수 없을 수도 있으며, 심지어 다른 운영 시스템에서 동작하는 프로그램에서도 열리지 않을 수 있다.

바이너리 파일과 마찬가지로 텍스트 파일 또한 비트(0또는1)들의 나열이다. 그러나 텍스트 파일에서 이러한 비트들은 일관된 방법으로 서로 그룹 지어져 일정한 숫자를 형성한다. 이러한 숫자들은 그 후 바이너리의 단점 문자로 표현된다.

텍스트 파일은 이런 표준 덕에 많은 프로그램들이 쉽게 인식할 수 있고, 사람도 텍스트 편집기를 통해 쉽게 읽을 수 있다. 일단 텍스트 파일이 만들어지면 전 세계의 누구나 그들이 원하는 텍스트 편집기로 그 파일들을 읽을 수 있다는 것이다. 물론 서로 다른 운영체제에서는 서로 다른 라인-인코딩 방법을 가지고 있긴 하지만, 텍스트 파일을 이용한다면 바이너리 파일에 있는 정보보다 훨씬 쉽게 정보를 공유할 수 있다는 것은 분명하다.

텍스트 파일의 단점은 텍스트에 메타 데이터 같은 정보를 첨가하는 것이 쉽지 않을 뿐 아니라 그 파일 크기도 크게 늘어난다는 데 있다.

[데이터 구조] Binary Search Tree

이렇게 세 개의 값이 하나의 노드에 지정되고 새로 생성된 노드들이 각각의 위치를 찾아 계속해서 연결된다.

Big O time complexity 시간 복잡도

Big O / 평균적인 상황
Insertion : O(log n)
Deletion : O(log n)
Search : O(log n)

root 에서 왼쪽이나 오른쪽, 한쪽으로만 쭉 이어져있는 최악의 상황에서는 O(n)의 시간 복잡도가 걸린다.
이 부분을 보완한 것이 Red Black Tree. 입력되는 값에 따라 트리 구조가 균형을 잡을 수 있도록 위치를 계속 변경할 수 있다.

O(n) 은 문제를 해결하기 위한 단계의 수와 입력값 n 바이너리의 단점 이 1:1 관계를 가지는 것을 의미하고
O(log n) 은 문제를 해결하는데 필요한 단계들이 연산마다 특정 요인에 의해 입력값 n 줄어드는 것을 의미한다.

임의로 index 를 찾는 Hash table 이나 새로운 데이터를 넣는 순서대로 tail 로 엮는 Linked list 와 달리 Binary Search Tree는 값에 따라 위치가 정해지기 때문에 각각의 노드들이 모두 유기적으로 연결되어 BTS 전체를 하나의 노드로 여길 수도 있다는 것이다.

바이너리 역어셈블의 의미와 원리 파헤쳐보기

소프트웨어 보안 논문을 읽다 보면, 역어셈블(Disassemble)은 비결정(undecidable)문제라는 이야기를 자주 접하게 됩니다. 하지만 그 의미를 정확히 바이너리의 단점 아는 사람은 드뭅니다. 국내외를 막론하고 역어셈블의 의미를 깊이 있게 고찰하는 글을 찾아보기는 매우 어렵기 때문입니다. 이번 시간에는 많은 사람들이 궁금해하는 바이너리 역어셈블의 의미와 원리를 한 번 파헤쳐보겠습니다.

역어셈블의 뜻은 바이너리의 단점 그 대상에 따라 달라진다.

역어셈블(Disassemble)은 말 그대로 어셈블(Assemble)을 거꾸로 하는 것을 의미합니다. 어셈블은 컴파일러가 어셈블리코드를 바이너리코드로 전환하는 과정을 의미하니, 역어셈블은 바이너리코드를 어셈블리코드로 바꾸는 과정이 됩니다.

그런데 컴파일러의 동작 원리를 아는 사람이라면 누구나 어셈블 과정이 “단순 변환”과정이라는 사실을 잘 압니다. 예를 들어 인텔 명령어 집합에서 add rax, rbx 라는 어셈블리코드는 항상 4803c3 이라는 바이너리코드(16진수)로 변환되는데, 여기에는 어떤 복잡한 논리적 전개가 필요한 것이 아닙니다. 단순히 각 어셈블리 명령어마다 대응되는 바이너리코드가 정해져있는 것뿐입니다.

결국, 어셈블리 명령어와 바이너리 명령어는 1:1 대응 관계가 있으며, 이러한 관점에서 본다면 어셈블이나 역어셈블 과정 모두 단순 변환 과정에 불과합니다. 예컨대 4803c3 이라는 바이너리 명령어는 add rax, rbx 라는 어셈블리 명령어로 단순 변환될 수 있죠. 같은 논리로, “임의의 바이너리 명령어의 리스트를 어셈블리 명령어의 리스트로 변환하는 문제” 또한 단순합니다. 주어진 명령어를 순차적으로 1:1 변환해준다면, 아무 문제 없이 명령어 나열을 “역어셈블”할 수 있기 때문입니다.

아래는 역어셈블된 바이너리코드의 일부를 나타냅니다. 8354번지에 있는 ret 명령어는 함수의 끝을 나타내며, 8360번지에 있는 push 명령어는 새로운 함수의 시작이 됩니다. 하지만 두 함수 사이에는 우리가 패딩(padding)이라 부르는, 11바이트의 쓰레기값이 들어있습니다. 다행히 이 경우에는 11바이트 영역이 모두 no-op 명령어로 해석되기 때문에( xchg ax, ax 또한 2바이트 no-op에 해당함) 코드로 해석한다 해도 큰 문제는 없지만, 해당 영역이 임의의 다른 값으로 채워져 있다면, 데이터 값이 엉뚱한 명령어로 잘못 해석될 수 있을 것입니다.

물론 패딩 데이터에 해당하는 영역은 일반적으로 no-op으로 해석되는 명령어가 삽입되지만, 바이너리코드에는 패딩 뿐 아니라 다양한 형태의 “데이터”가 존재합니다. 그 대표적인 예가 스위치(switch)문이 점프 테이블 형태로 변환되는 경우인데, 특히 ARM 바이너리의 경우 점프테이블 자체가 명령어와 명령어 사이에 삽입되는 형태를 보이곤 합니다. 이렇게 코드와 데이터가 혼재할 수 있는 근본 원인은 우리가 사용하는 대부분의 CPU가 폰 노이만 구조 [1] 를 따르기 때문입니다. 즉, 근원적으로 코드와 데이터를 구분을 짓지 않기 때문이죠.

이렇듯, 우리가 바이너리라고 부르는 대상은 단순 코드의 나열이 아니기 때문에 역어셈블은 단순 변환과정이 아니게 됩니다. 즉, 대상에 따라 역어셈블은 쉬운 문제가 될수도 있고, 풀기 어려운 문제가 될 수도 있습니다.

역어셈블 기법과 바이너리의 단점 그 의미

역어셈블의 방법은 크게 두 가지로 나뉩니다. 하나는 선형쓸기(Linear Sweep)방식이고, 또 하나는 재귀(Recursive)방식 입니다. 각각의 특징과 장단점에 대해 알아봅시다.

선형쓸기 방식

선형쓸기는 단순 무식한 방법입니다. 위에 말했던 코드와 데이터의 구분을 신경 쓰지 않고, 단순히 바이너리를 명령어의 나열로 간주합니다. 그리고 코드의 시작점부터 일자로(선형으로) 단순 역어셈블 변환을 시행합니다. 우리가 사용하는 도구 중에서는 GNU objdump가 이에 해당하는데, 앞에서 말했듯이 이러한 변환 방식은 코드와 데이터를 구분짓지 못하는 치명적인 단점을 갖고 있습니다.

하지만 놀랍게도 이러한 선형쓸기 방식은 바이너리 명령어를 복원해낸다는 측면에서는 탁월한 성능을 보입니다. 흔히 말하는 명령어 커버리지(coverage)가 높다는 것인데, 그 이유는 데이터를 설사 명령어로 간주한다고 하더라도, 결국에는 해당 데이터에 이어지는 명령어를 다시금 역어셈블해낼 것이기 때문입니다. 이러한 사실은 2016년 USENIX Security에 발표된 논문 [2]에서도 조명된 바 있습니다.

물론 데이터를 역어셈블하다보면 역어셈블된 명령어의 길이가 실제 데이터 값보다 길어지는 경우가 발생합니다. 특히 인텔 명령어처럼 가변길이의 명령어집합을 갖는 경우에는 명령어 하나의 길이가 1바이트에서 길게는 15바이트까지 가능합니다. 따라서, 데이터의 크기가 4바이트였는데, 해당되는 역어셈블된 명령어의 길이는 8바이트였다면, 4바이트 만큼의 명령어를 잃어버릴 수도 있습니다. 아래의 예제를 봅시다.

원본 어셈블리 코드(test.s)는 “hello”라는 데이터를 내재하고 있습니다. 그런데 objdump를 통해 역어셈블된 바이너리 코드에서는 해당 데이터가 push 0x6f6c6c65 라는 명령어로 해석되었을 뿐 아니라, 이어지는 원본 명령어인 push rax 와 push rbx 는 기존 코드에 없던 add 명령어로 대체되었습니다. 즉, 데이터의 잘못된 해석으로 인해 원본에 있던 유효한 명령어마저 잃게 된 것입니다.

그런데 여기에서 주목할 것은, 잘못된 해석이 지속되지 않고 바로 그 다음 명령어인 add rax, rbx 에서 해소되었다는 점입니다. 만약 우리가 운이 정말 없었다면 다음에 이어지는 명령어, 그리고 그 이후에 이어지는 모든 명령어가 다 영향을 받았을 수도 있습니다만, 실제 선형 역어셈블을 수행해보면 대개 명령어 한두 개 이후로는 원본 어셈블리코드와 동일한 형태의 코드를 복원하게 됩니다. 이러한 현상을 우리는 자가복원현상(Self-repairing Disassembly)이라 부릅니다 [3]. 그리고 이것이 선형쓸기 방식의 커버리지가 높을 수밖에 없는 핵심 이유입니다.

재귀방식의 역어셈블은 선형쓸기의 치명적인 단점인 코드와 데이터의 구분이 어렵다는 점을 해소하기 위해 개발되었습니다. 가장 큰 차이점은 명령어의 제어흐름을 하나하나 따라가면서 역어셈블을 진행한다는 점인데, 예를 들어 순차적으로 역어셈블을 진행하다가 jmp 와 같은 브랜치 명령어를 만나면, 브랜치의 대상주소로 가서 역어셈블을 다시(재귀적으로) 진행하는 방식입니다. 이렇게 제어흐름을 고려한 재귀형 역어셈블 방식은 코드만을 따라가기 때문에 데이터를 잘못 해석하는 일이 없지만, 선형쓸기에서는 볼 수 없었던 새로운 문제를 갖게 됩니다.

바로 브랜치의 대상 주소를 모르는 경우가 발생한다는 것입니다. 예를 들어 jmp rax 와 같은 명령어는 레지스터 rax 의 값이 프로그램 실행 중에 변화할 수 있기 때문에, 여러 대상주소로 점프가 가능합니다. 하지만, 정적으로 역어셈블을 진행하는 중에는 rax 의 값이 어떤 값이 올 수 있는지를 알 수 없기 때문에 제어흐름을 모두 따라가기가 어려운 것입니다. 만약 우리가 놓치는 대상주소가 있다면, 그만큼 코드를 복원하지 못하게 되는 것이고, 선형 역어셈블방식에서와는 달리 낮은 커버리지를 달성할 수밖에 없습니다.

따라서 많은 연구자가 재귀방식 역어셈블의 단점을 극복하기 위해 간접분기문의 점프 대상을 복원해내는 연구를 진행하고 있으며, 소프트웨어 보안 연구실[4]에서도 해당 연구를 활발히 진행하고 있습니다. 이에 대해서는 다음 기회에 좀 더 자세히 알아보기로 하겠습니다.

이번 포스팅에서는 역어셈블이 왜 어려운 문제인지에 대해서 간단히 짚어보았습니다. 또한 역어셈블의 대표기술인 선형쓸기와 재귀형 역어셈블에 대해 알아보고, 각각의 장단점이 무엇인지도 살펴보았습니다. 다음 시간에는 B2R2의 구조와 리프팅된 언어의 의미에 대해서 설명하는 시간을 갖도록 하겠습니다.

차상길 교수는 카네기멜론 대학교에서 2015년에 박사학위를 취득하였으며, 2020년 3월부터 사이버보안연구센터장으로 역임중이다. 현재 주 연구 분야는 소프트웨어 보안 및 프로그램 분석이며, 최근에는 차세대 바이너리 플랫폼을 만드는 연구에 매진하고 있다.

Lossless Data Compression

데이터 압축의 가장 큰 장점은 뭐니뭐니해도, 사용하는 공간의 크기를 줄일 수 있다는 점입니다. 저장장치의 크기는 정해져 있는데, 이 곳에 데이터를 압축하여 저장한다면 같은 비용으로 더 많은 데이터를 저장할 수 있게 됩니다.

데이터 압축의 또 다른 장점으로는, Bandwidth를 높여주는 효과를 가져온다는 점입니다. 예를 들어서 통신을 할 때, 통신 속도가 충분히 빠르지 않다면 이 과정이 병목이 될 가능성이 높습니다.

이 때 데이터를 압축하고 보내고, 다시 압축해제 한다면 이러한 병목을 완화할 수 있습니다.

이러한 데이터 압축은 크게 lossless data compression과, lossy data compression으로 나눌 수 있습니다. 이름에서 알 수 있듯이 전자는 압축한 데이터를 다시 원본과 똑같이 복원해낼 수 있고, 후자는 데이터 손실이 발생합니다.

Lossy data compression은 이미지와 같은, 어느 정도 손실이 발생하여도 크게 지장을 바이너리의 단점 미치지 않는 곳에 보통 사용됩니다. jpg 파일이 대표적인 lossy data compression입니다. 하지만, 대부분의 데이터는 손실이 일어나면 곤란한 경우가 많으므로 더 일반적으로 사용가능한 것은 lossless data compression입니다.

이번 글에서는 lossless 바이너리의 단점 data compression 중, data entropy를 기반으로 작동하는 압축 알고리즘들을 살펴보도록 하겠습니다. Data entropy를 기반으로 작동한다는 것은, 압축률이 데이터 내에서 각 소단위(예를 들어 byte)들이 출현하는 빈도와 관련된다는 바이너리의 단점 것입니다.

Huffman Coding

Huffman coding은 가장 대표적이고, 간단하지만 좋은 성능을 가지는 데이터 압축 기법입니다.

Huffman coding의 아이디어는 출현 빈도가 높은 데이터를 짧은 binary code로 나타내서, 최종적으로 bit 수를 줄이겠다는 아이디어입니다.

예를 들어서, 아래와 같은 데이터를 압축하는 상황을 가정해보도록 하겠습니다. 아래의 모든 예시에서, 빈도의 측정은 byte 단위로 진행된다고 가정하겠습니다.

각 문자별로 빈도수를 세어보면, 아래와 같습니다.

문자 빈도 비율
A 7 0.4375
B 2 0.125
C 1 0.0625
D 6 0.375

출현 빈도수가 높은 A, D에 더 짧은 binary code를 할당하는 것이 합리적입니다. 반면, B와 C는 출현 횟수가 많지 않으므로 긴 binary code를 할당하여도 큰 부담이 없습니다. 이제 binary code를 할당하는 법을 살펴보겠습니다.

각 문자별로 binary code를 할당하는 것은, heap을 이용한 greedy algorithm을 사용하여 가능합니다.

이 때, 모든 binary code들은 “한 binary code가 다른 binary code의 접두사가 되지 않아야 한다“는 조건을 만족해야 합니다. 하나가 다른 하나의 접두사가 되면, decode를 진행할 때 동일한 접두사를 가진 둘을 구분할 수 없게 되기 때문입니다.

이 때 만약 모든 binary code를 이진 트리로 나타내보면 어떻게 될까요? head부터 시작하여, 왼쪽 child로 이동하는 것을 0, 오른쪽 child로 이동하는 것을 1로 나타내봅시다. 트리를 따라 내려가 leaf에 도착하면 현재까지 경로가 나타내는 binary code가 해당 leaf가 가진 문자를 나타내게 됩니다.

만약 한 code가 다른 code의 접두사가 된다면, 접두사가 되는 code의 child를 따라 내려가면 다른 code를 발견할 수 있게 됩니다. 모든 leaf는 다 하나의 문자를 나타내고 있으므로, 결국 위 조건을 만족하려면 모든 code는 leaf에서 끝나야 합니다.

마지막으로, child를 하나 가지고 있는 node가 있을 경우 해당 노드를 제거하는 것이 더 짧은 binary code를 만들어낼 수 있으므로, 바이너리의 단점 만들어진 tree는 항상 완전 이진 트리가 된다고 가정할 수 있습니다.

이를 만족하는 트리의 예시는 아래 그림 1과 같습니다. 이를 Huffman tree라고 합니다.

그림 1. Huffman tree의 예시

각 문자의 binary code의 길이는, 해당 문자가 위치한 node의 depth와 간다는 사실도 금방 유추해낼 수 있습니다. 그럼 이러한 tree 중, 전체 길이가 최소가 되는 tree는 어떻게 만들 수 있을까요?

이를 만드는 방법은 매우 간단한데, 바로 출현 빈도가 가장 작은 두 문자열부터 차례로 합쳐주는 방법입니다. 한 번 합칠 때, 합쳐지는 노드의 subtree에 위치하는 문자의 출현 빈도의 합만큼 전체 길이가 길어지기 때문에, 가장 작은 두 node부터 합쳐주는 greedy한 방법이 쉽게 성립할 수 있습니다.

아래 그림 2는 Huffman tree를 만드는 과정을 나타낸 그림입니다.

그림 2. Building Huffman tree

실제 Huffman tree를 만드는 과정은, 최소 힙을 사용하여 쉽게 가능합니다. 아래는 Huffman tree를 만드는 code입니다.

Huffman tree가 만들어졌으면, encoding과 decoding은 해당 tree를 이용하여 쉽게 진행할 수 있습니다. 단, encode의 경우 문자에서 tree를 거슬러 올라가는 것은 비효율적이기 때문에 tree를 순회하며 미리 binary code를 모두 저장해놓는 것이 구현을 하는 데에 효율적입니다.

코드는 아래와 같습니다. 아래 구현에서는 편의성을 위해 0과 1로 구성된 문자열을 생성하는데, 이 경우 한 bit당 1바이트씩 차지하므로 실제로는 각각의 bit로 저장해야 합니다. 문자열을 생성하는 것은 bit를 생성하도록 바꾸는 것은 크게 어렵지 않으므로, 해당 구현은 생략하겠습니다.

bit 단위로 저장할 때 발생하는 문제점 중 하나로, padding된 문자열이 실제 encode된 것인지, 단순 padding인지 구분할 수 없다는 점이 있습니다.

예를 들어, 최종적으로 길이 9의 binary code가 생성되었다면 이를 byte단위로 저장할 경우 뒤에 7bit만큼의 공간이 남는데, decode를 진행할 때 이 부분이 실제 encode된 것인지, 단순 padding인지 구분할 수 없습니다.

이를 해결하기 위해서는 추가적으로 전체 bit의 길이를 저장하거나, EOF문자를 포함시켜 Huffman tree를 만드는 방법을 사용할 수 있습니다.

Huffman tree의 저장

데이터를 decode하기 위해서 Huffman tree가 필요하므로, huffman tree 또한 압축을 할 때 함께 저장을 해야 합니다. Huffman tree는 어떻게 저장을 해야 효율적일까요?

가장 쉬운 방법은, tree를 순회하며 leaf node가 아닐 경우 0을 적고, leaf node일 경우 1을 적고 뒤 8 bit에 leaf가 나타내는 문자를 적어주는 방법을 사용하면 간편합니다.

예를 들어, 위 그림 1에 있는 tree는 아래와 같이 저장되게 됩니다. 괄호는 구분을 위해 추가하였습니다.

모든 문자는 리프 노드에 하나씩 놓이게 되며, tree를 만드는 과정을 생각해보면 한 번의 merge가 일어날 때 노드가 하나씩 추가되므로, Huffman tree에는 총 $2t - 1$개의 노드가 놓이게 됩니다. 여기서 t는 압축하려는 데이터에 포함된 문자의 종류입니다.

Huffman tree를 표현하는 데에 하나의 노드 당 1 bit만큼 필요하고, $t$개의 데이터를 저장하는 데 $8t$ bit이 필요하므로 추가적으로 tree를 저장하는 데에 소모되는 비용은 $10t - 1$이 됨을 확인할 수 있습니다. 일반적인 256종류의 문자를 가지는 데이터라고 가정하였을 때, 약 320byte 정도가 필요합니다.

Arithmetic coding

데이터를 압축하는 또 다른 방법으로, Arithmetic coding 방법이 있습니다.

Arithmetic coding은 개념 자체를 매우 간단하지만, 구현 난이도가 높고 계산 시간이 오래 걸린다는 단점이 있습니다.

Arithmetic coding은 전체 데이터를 0과 1 사이에 있는 단 하나의 실수로 mapping해줍니다. 이 실수가 속한 범위에 따라서, 원래 데이터를 복원해낼 수 있습니다.

간단한 예시를 확인해보겠습니다. 전체 데이터에서 출현 빈도가 아래 표와 같이 측정되었다고 가정해봅시다.

문자 비율 구간
A 0.6 [0, 0.6)
B 0.2 [0.6, 0.8)
C 0.1 [0.8, 0.9)
[종료] 0.1 [0.9, 1.0)

새롭게 구간이라는 것이 추가되었는데, 이는 각 비율의 누적합이 속하는 구간을 의미합니다.

이 때, AACB라는 문자열을 어떻게 압축할까요? 앞에서부터 구간을 줄여가는 식으로 계산하게 됩니다.

AACB: [0.3096, 0.3168)

AACB[종료]: [0.31608, 0.3168)

최종적으로 AACB의 구간이 확정되었습니다. 이를 이진법으로 나타내면 아래와 같습니다.

따라서, 이진법으로 0.01010001로 데이터를 표현할 경우, 데이터 압축을 완료할 수 있습니다.

모든 구간의 크기를 균등하게 하지 않고 비율에 따라 구간의 길이를 비례해서 정해주는 이유는, 데이터 압축이 완료되었을 때 최종 구간의 길이가 최대한 크도록 만들어주기 위함입니다. 이는 압축 효율의 향상을 가져올 수 있습니다.

또, [종료]에 해당하는 문자열을 지정해주는 것이 반드시 필요한데, 만약 [종료]에 해당하는 문자열이 존재하지 바이너리의 단점 않는다면 구간을 계속 세분해서 나누어갈 수 있기 때문에 데이터의 종료를 알 수 없기 때문입니다.

Data entropy

위에서 살펴본 것과 같은 압축 기법들은 모두 “어떤 데이터가 몇 번 출현하는지“에 깊게 관련되어 있습니다.

특정 데이터가 많이 출현한다면 좋은 압축 효율을 기대할 수 있고, 반대로 다양한 데이터가 비교적 균등한 횟수로 출현한다면 압축 효율이 좋을 것이라고 기대하기 힘들게 됩니다.

이를 나타낼 수 있는 척도 중 하나가 바로 Shannon Entropy입니다. Shannon Entropy는 데이터에 포함된 정보들의 크기를 나타내는 것으로, entropy가 작으면 포함된 정보가 많지 않으므로 좋은 압축 효율을 기대할 수 있고, 반대로 바이너리의 단점 entropy가 크다면 많은 정보가 포함되어 있기 때문에, 어떤 방법을 사용해도 압축된 데이터의 크기를 크게 줄이기 힘들다는 것을 의미합니다.

흔히 압축된 파일을 한 번 더 압축해보면 압축 효율이 좋지 않은 것을 확인할 수 있는데, 이는 한 번 압축을 진행하면 entropy가 굉장히 커져서, 더 이상 좋은 압축 효율을 보일 수 없기 때문입니다.

Shannon entropy는 다음과 같은 식으로 계산할 수 있습니다.

수식에서 각각의 항에 대해 조금 더 자세히 살펴보도록 하겠습니다.

먼저 $P(x_i)$는 각 데이터 별 출현 빈도를 나타냅니다. 여기에 로그를 붙여, $\log$를 만들면 어떤 의미를 가질까요? 이는 해당 데이터를 나타내는 데에 필요한 정보의 양을 나타내게 됩니다.

예를 들어서, 데이터가 1/4 확률로 출현한다면, 각각의 데이터들을 표현하는 데에 2 bit가 필요할 것이라고 기대할 수 있습니다. 반면 데이터가 1/8 확률로 출현한다면, 각각의 데이터들을 표현하는 데에 3 bit가 필요할 것이라고 기대해야 할 것입니다. 이런 식으로, 데이터의 출현 빈도가 낮아질수록 해당 데이터를 표현하는 데에 더 많은 양의 bit가 필요해질 것임을 알 수 있으며, 이는 확률의 로그에 비례할 것입니다.

이렇게 구한 정보의 양에 각각의 확률을 곱해 더해주게 되면, 전체 데이터에 대한 평균을 구할 수 있습니다. 다시 말해 전체 데이터를 표현하는 데에 필요한 정보의 양을 나타내게 되는 것입니다.

Entropy를 기반으로 동작하는 많은 압축 알고리즘은 결과적으로 해당 데이터가 얼마나 많은 정보를 가지고 있었는지에 따라 그 압축 효율이 결정되게 됩니다.


0 개 댓글

답장을 남겨주세요