[PostgreSQL] TOAST (The Oversized-Attribute Storage Technique)의 개념, PostgreSQL의 대용량 속성 저장 기법

1. TOAST (The Oversized-Attribute Storage Technique)란?

데이터베이스의 대용량 속성을 효율적으로 저장하고 관리하기 위한 기법으로, 데이터를 효율적으로 처리하고, 저장공간을 최적화하며 데이터 접근시간을 개선하기 위해 사용된다. PostgreSQL의 각 page영역은 일반적으로 8kb의 고정된 크기로 되어있고 각 tuple이 여러 페이지에 나뉘어 존재할 수 없다. (매우 큰 값을 바로 저장할 수 없다.) 이 한계를 극복하기 위해서, 큰 필드 값은 압축되어 저장되거나 여러 개의 물리적 ROWS로 분할되어 저장된다. 이 과정은 보통 개발자가 별도의 처리로직을 구현할 필요 없이 데이터베이스 백앤드에서 자동으로 이루어진다. 이 기법을 TOAST (The Oversized-Attribute Storage Technique)라고 하며 PostgreSQL에서 큰 데이터 값을 메모리 내에서 효율적으로 처리하는 데에 사용된다.

2. TOAST의 원리

TOAST는 특정 데이터 타입만 지원한다. 애초에 큰 데이터 필드값을 생성할 수 없는 고정된 타입에는 이러한 오버헤드를 부여할 필요가 없기 때문이다. 그러므로 TOAST 기능은 데이터 타입이 가변길이인 속성만을 지원한다. 일반적으로 저장된 값의 첫 번째 4바이트 단어가 전체 길이에 대한 정보를 포함한다. 예를 들어 총 100바이트의 공간을 차지하는 텍스트 데이터는 맨 앞 4바이트에 100이라는 값이 저장된다. TOAST 기능을 통해 처리되는 값들을 TOASTed 값이라고 불리며, 이 값들은 TOAST 헤더를 포함한 특별한 표현식으로 구성되어 있다. PostgreSQL은 TOAST 헤더 값을 수정하거나 재해석하여, 큰 데이터를 효율적으로 관리하고 접근할 수 있도록 해주고 데이터를 필요에 따라 압축하거나 필요한 부분을 선택적으로 로드하여 성능과 저장공간을 최적화할 수 있게 도와준다. TOAST 처리된 데이터를 가져오기 위해서는 DETOAST 과정으로 데이터를 복원해야 한다. 

DETOAST - 데이터를 원래의 비압축 상태로 복구하거나, 외부 저장소에서 읽어오는 과정이다, 데이터를 안전하게 읽고 처리할 수 있도록 데이터의 원본 구조와 내용을 복원하는 작업으로 'PG_DETOAST_DATUM' 함수로 명시적으로 실행 가능하다.

 

TOAST는 헤더의 맨 앞 4바이트 중 2비트를 특별한 용도로 할당하여 TOAST처리에 관련된 추가정보를 저장한다. TOAST 가능한 데이터 유형의 값은 1GB (2^30 -1 바이트)로 제한한다. 별도로 할당된 2비트가 모두 0일 때 해당 데이터는 일반적인 토스트 처리가 되지 않은 데이터를 의미하고, 나머지 비트들은 전체 데이터의 크기를 바이트단위로 나타낸다. 데이터가 TOAST 처리가 될 경우 압축여부를 함께 저장하며, 만약 데이터가 아주 작다면 일반적인 4바이트 헤더 대신 1 바이트 헤더만을 가지게 된다. 1바이트 헤더를 사용할 경우 데이터의 길이, 데이터가 압축 같은 특별한 처리를 거쳤는지를 포함한다. 4바이트 대신 1바이트를 사용하면 127바이트보다 작은 값들을 효율적으로 저장하게 해 준다.

1바이트 = 8비트로, TOAST처리 상태를 나타내기 위한 1비트를 할당 후, 나머지 비트는 데이터의 실제 길이를 나타내는 데 사용한다. 이 경우 최대 127바이트까지의 데이터 길이만을 표현할 수 있다. (나머지 7비트를 사용해서 표현할 수 있는 길이 2^7 -1 = 127)

 

1바이트 헤더의 경우 별도 정렬이 없지만, 4바이트 헤더를 가진 값들은 4바이트 경계에 맞춰 정렬된다. 

경계에 맞춘 정렬 - 데이터를 메모리상의 특정 위치에 저장할 때, 그 위치가 특정 크기의 배수가 되도록 정렬한다는 의미로 4바이트 경계에 정렬은 데이터가 4의 배수가 되는 메모리 주소에 저장함을 의미한다. CPU가 데이터에 더 효율적으로 접근 가능하게 하지만 지정된 위치에 데이터를 저장해야 하기에 공백 (패딩)이 생길 수 있어 데이터가 차지하지 않는 메모리 공간이 생길 수 있다. 

 

1바이트 헤더에는 데이터의 메타정보가 저장되어 있고, 특정 패턴을 보일 때 추가적인 정보를 제공한다. 예를 들어 헤더에서 나머지 비트가 모두 0일 경우(자신을 포함한 길이를 나타내는 영역이기에 이론상 0이 될 수 없다.), 이 데이터는 실제 값이 아닌 외부 데이터를 가리키는 포인터라는 특별한 상황을 의미한다. 또한 최상위, 최하위 비트가 설정되지 않았다면, 데이터는 압축된 상태이며, 사용되기 전에 압축해제가 되어야 한다. 이 경우 4바이트 헤더의 나머지 부분은 원데이터가 아니라 압축된 데이터의 총용량을 의미한다. 외부 저장 데이터에 대해서도 압축이 가능하지만, 헤더만으로는 압축여부를 판단할 수 없고, TOAST 포인터에서 확인가능하다. 

3. TOAST의 관리와 활용

PostgreSQL에서는 인 라인 또는 아웃라인 압축 데이터에 사용되는 압축 기법을 칼럼별로 선택할 수 있다. CREATE TABLE 혹은 ALTER TABLE 명령어를 COMPRESSION과 함께 사용하면 된다. 

CREATE TABLE example (
    column_name data_type COMPRESSION compression_method,
    ...
);

ALTER TABLE example
ALTER COLUMN column_name SET COMPRESSION compression_method;
  • PGLZ - 기본 알고리즘으로 일반적으로 안정적인 압축 비율 제공한다. 다양한 유형의 데이터에 대해 괜찮은 성능을 보이며, 매우 큰 데이터에 대해 상대적으로 다른 방식보다 느리다.
  • LZ4 - 높은 압축 속도를 제공하며, 13 버전 이후에서만 사용가능하다. 압축속도는 빠르지만 압축률은 낮기에 성능이 중요한 환경에서 큰 데이터를 다룰 때 유용하게 사용 가능하다.

옵션을 지정하지 않으면 default_toast_compression 옵션의 설정값을 사용한다. 해당 명령어를 통해 기본 설정을 확인/변경할 수 있다.

SHOW default_toast_compression;
SET default_toast_compression = '압축방식';

 

다만 SET을 통한 변경은 해당 세션에서만 적용되기에, 영구 수정은 설정파일을 직접 수정해야 한다. 

4. TOAST 포인터

TOAST 포인터 데이터는 메인 테이블에 저장되며, 실제 데이터가 TOAST 테이블에 저장된 위치를 가리키는 역할을 한다. TOAST 포인터 데이터에는 여러 가지 유형이 있으며, 보통은 메인테이블 외부의 TOAST 테이블을 가리킨다. 이 디스크상의 포인터 데이터는 TOAST management code (access/common/toast_internals.c 파일)에 의해 관리되며, tuple이 그 자체로 저장되기에 너무 큰 사이즈일 때 생성된다. 

 

TOAST 포인터는 메모리 내의 다른 위체 존재하는 외부 데이터를 가리키는 포인터를 포함할 수도 있다. 메모리상 저장된 외부데이터는 보통 휘발성 데이터이고 디스크에 저장되지 않는다. 이러한 데이터는 메모리에 중간연산 데이터를 저장하여 캐싱하여 사용하고, 데이터 접근시간이 대폭 감소하기에 큰 데이터 값을 복사하거나 불필요한 반복처리를 피할 때 매우 유용하다. 테이블의 컬럼중 하나라도 TOAST가 가능하다면, 테이블은 TOAST테이블과 연관되며 테이블의 OID는 TOAST테이블의 pg_class.reltoastrelid 항목에 저장된다. 디스크 상의 TOAST 된 값은 TOAST테이블에 유지된다.

 

외부 테이블에 저장되는 데이터는 TOAST_MAX_CHUNK_SIZE (byte) 단위로 분리되어 저장된다. 각 분리 단위를 chunk라고 하며, 한 page 에는 4개의 chunk가 들어갈 수 있다. 

1 page = 8KB (8,192 byte), 1 chunk = 2048 byte

각 chunk는 TOAST 테이블에 별도의 ROW로 저장된다. 모든 TOAST 테이블은 다음 항목들로 이루어진다.

  • chunk_id - TOAST된 특정 값을 식별하기 위한 OID
  • chunk_seq - 각 chunk의 순번
  • chunk_data - chunk의 실제 데이터

chunk_id와 chunk_seq에 UNIQUE 인덱스가 걸려있어 값을 빠르게 찾을 수 있고 기존 테이블에서 TOAST 된 데이터를 찾기 위해서는 TOAST 테이블의 OID와 chunk_id를 저장해야 한다.

 

좀 더 효율적으로 데이터를 찾기 위해서,  포인터 데이터는 데이터의 압축 전 원 데이터사이즈와 압축 후 데이터 사이즈, 압축 방식을 저장한다. 전체 구성을 보면 TOAST 포인터 데이터는 18바이트로 구성되며 다음 항목들을 저장한다.

  • Varlena header - 위에서 설명한 4바이트의 길이정보, 압축정보
  • TOAST 테이블 OID - 4바이트의 TOAST에 저장된 테이블을 식별하는 고유 식별자
  • chunk_id - 실제 데이터 chunk를 식별하는 고유 식별자 4바이트
  • 위치정보 - 데이터 청크의 실제 위치를 가리키는 추가정보가 필요하다면 사용, 나머지 바이트

TOAST는 TOAST_TUPLE_THRESHOLD bytes보다 큰 값이 저장될 때 실행된다. TOAST management code는 임계치보다 용량이 줄어들 때까지, 혹은 압축가능한 최대치로 압축을 하거나, 외부 테이블로 값을 옮긴다. 다만 UPDATE가 실행되는 동안, 변경이 없는 필드의 값은 그대로 유지된다. 그래서 TOAST 영역에 저장된 데이터의 변경이 이루어지지 않는 한 TOAST비용은 발생하지 않는다.

5. TOAST 저장 방식

TOAST관리 코드는 디스크에 TOAST 가능한 칼럼을 판단할 때 다음 4가지를 전략을 사용한다.

  • PLAIN - 압축/외부 저장소를 사용하지 않는다. TOAST 불가능한 칼럼 대상으로만 적용 가능하다.
  • EXTENDED - 압축/외부저장소를 둘 다 사용한다. TOAST가능한 데이터의 기본 옵션으로 압축이 먼저 시도되고 그래도 여전히 데이터가 너무 크다면 외부 저장소를 사용한다.
  • EXTERNAL - 압축은 허용하지 않고, 외부저장소를 허용한다. text, bytea 칼럼의 substring속도를 향상해 준다 (저장소 공간은 더 사용하지만, 압축을 해제할 필요 없이 필요한 부분만 바로 찾을 수 있다.)
  • MAIN - 압축은 허용하고, 외부저장소는 허용하지 않는다. (외부저장소를 허용하지 않는 옵션이지만, 압축 후에도 page에 사이즈를 맞출 수 있는 다른 방법이 없다면 최후의 수단으로 외부 저장소를 사용한다.)
  압축 외부저장소
PLAIN X X
EXTENDED O O
EXTERNAL X O
MAIN O X (선택적 사용)

 

각 TOAST 가능한 칼럼에 대해 TOAST 저장 전략을 각각 설정도 가능하다.

ALTER TABLE my_table
ALTER COLUMN my_column SET STORAGE EXTENDED;

6. TOAST 적용 시점

TOAST_TUPLE_TARGET으로 테이블이 TOAST를 고려하는 시점을 지정할 수 있다. 예를 들어 TOAST_TUPLE_TARGET이 2048로 설정되면 테이블의 행 크기가 2048byte에 도달했을 때 TOAST처리를 고려한다. 

ALTER TABLE ... SET (toast_tuple_target = N)

 

TOAST 시스템은 page사이즈보다 큰 데이터를 강제로 저장하는 방식에 비해 훨씬 효율적이다. 일반적으로 쿼리가 상대적으로 작은 키 값에 대한 비교를 할 때, 대부분의 작업은 메인 ROW값을 통해 실행된다. TOAST 된 큰 값은 연산 후 추출될 때만 꺼내질 것이다. 그래서 메인테이블은 훨씬 더 작아지고, 메인테이블의 행들이 공유 버퍼 캐시에 더 많이 저장될 수 있다.

7. 인메모리 토스트 저장소

TOAST포인터는 디스크상에 없는 현재 프로세스상의 데이터를 가리킬 수도 있다. 물론 해당 포인터 데이터는 휘발성이지만 효율적이다. 

7-1. 간접 데이터를 가리키는 포인터

메모리상에서 간접적으로 varlena포인터를 가리킨다. 초기에는 개념을 증명하기 위해서 만들어졌지만, 현재는 디코딩 간 모든 실제 데이터를 튜플에 포함시키지 않고 효율적으로 데이터를 처리하여 1GB를 초과하는 튜플을 생성하는 것을 방지하기 위해 사용된다. 포인터가 참조하는 데이터에 대한 관리를 유저가 직접(데이터가 변경될 경우 메모리상의 참조 데이터도 명시적으로 변경해주어야 함) 해주어야 하기에 사용이 제한적이다.

7-2. 확장된 데이터를 가리키는 포인터

확장된 데이터를 가리키는 포인터는 복잡한 데이터 유형에 유용하다. 예를 들어, 일반적이 varlena 표현식은 다음과 같은 값들을 포함한다.

  • 차원 정보 - 배열의 차원수와 각 차원의 크기정보
  • 데이터의 길이 - 데이터가 차지하는 바이트 수
  • NULL 비트맵 - 배열에 NULL이 포함되어 있는 경우, 어떤 요소가 null인지를 아려주는 비트맵

해당 해더들 뒤에 실제 데이터 요소들이 순차적으로 저장된다. 순차적으로 저장되기에, 요소 타입 자체가 가변길이인 경우 N번째 요소를 찾기 위해서는 모든 선행요소들을 순차적으로 스캔해야만 한다. 데이터를 가능한 적은 공간에 저장하기 위해 모든 요소를 연속적으로 배치하는 이 방식은,  저장공간을 효율적으로 사용하기에 디스크 저장소에는 적합하지만, 특정 요소에 접근하거나 배열을 활용한 계산을 수행할 때는 모든 선행 요소를 순차적으로 검사해야 하는 단점이 있다. 

 

배열의 각 요소가 어디에서 시작하는지를 미리 식별해서 "확장된 데이터"를 별도로 저장함으로써 특정요소에 직접, 더 빠르게 접근이 가능하다. 메모리상에서 이러한 별도의 확장된 저장영역을 TOAST 포인터가 가리킬 수 있게 함으로써 효율적인 데이터 처리를 가능하게 한다. 

 

확장 데이터를 가리키는 포인터는 read-write, read-only 포인터로 나뉜다.

  • read-write 포인터 -  함수가 참조하는 값을 메모리에서 직접 수정 가능하다. 원데이터에 변경이 필요한 경우 추가적인 복사 없이 해당 값을 바로 업데이트 가능
  • read-only 포인터 -  참조된 값을 변경할 수 없다. 값을 수정하려면 먼저 해당 값을 복사한 후 복사된 데이터에 대해 변경을 수행해야 한다. 원본데이터의 무결성을 보장하기 위한 조치이다. 

이렇게 구분함으로써 데이터를 읽기만 하는 경우에는 복사본을 생성하지 않고, 데이터를 수정하는 경우에만 복사본을 생성하기에 메모리 사용량이 줄어들고 성능향상에 도움이 된다.

 

메모리 상의 TOAST 포인터는 데이터가 메모리에 로드되어 있을 때 사용되며, 데이터 처리과정에서 임시적으로만 사용된다. TOAST management code는 이러한 메모리상의 임시 포인터 데이터가 그대로 디스크에 영구적으로 잘못 저장되지 않도록 보장하기 위해 다음과 같은 처리를 진행하며, 전처리를 완료한 후 적절한 형태로 디스크에 데이터를 저장한다.

  • 메모리 내 TOAST 포인터 확장 : 데이터가 디스크에 저장되기 전에 메모리 내에서 사용되던 toast 포인터는 일반 varlena 표현식으로 치환한다. 이 과정에서 실제 데이터가 인라인 형태로 확장되며 테이블의 일부로 저장될 수 있도록 준비된다.
  • 디스크상 TOAST 포인터로 변환 : 데이터를 테이블에 저장할 때 해당 튜플의 크기가 너무 크면 자동으로 데이터를 디스크에 별도로 저장하고, 원래 테이블에는 데이터를 가리키는 TOAST 포인터만을 저장한다.

 

 

 

 

 

 

참고

https://www.postgresql.org/docs/16/storage-toast.html#STORAGE-TOAST-INMEMORY