Skip to content
목록으로

테이블 설계 : 분산 키, 정렬 키, 인코딩

Amazon Redshift2025년 7월 27일
테이블 설계 : 분산 키, 정렬 키, 인코딩

저번 포스팅에서는, Redshift 의 구조와 컬럼 지향 저장을 공부했습니다. 이번에는 테이블 설계입니다. 인덱스가 없는 Redshift 에서 성능을 만드는 것은 결국 세 가지입니다. 데이터를 어떻게 나눠 둘 것인가(분산 키), 어떤 순서로 쌓을 것인가(정렬 키), 어떻게 압축할 것인가(인코딩).

Redshift 에서 가장 자주 묻고 가장 자주 잘못 정하는 부분이기도 합니다.


1. 분산 키


1.1. 왜 중요한가

1편에서 본 대로 데이터는 여러 노드의 슬라이스에 나뉘어 저장됩니다. 어느 행이 어느 슬라이스로 갈지를 정하는 것이 분산 키입니다. 이것이 왜 성능을 좌우하는지는 조인을 생각하면 분명해집니다. 주문 테이블과 고객 테이블을 customer_id 로 조인한다고 합시다. 어떤 주문 행과 그에 맞는 고객 행이 서로 다른 노드에 있으면, 조인을 하기 위해 네트워크로 데이터를 옮겨야 합니다. 이것을 재분배(redistribution)라고 합니다.

반대로 두 테이블이 모두 customer_id 로 분산되어 있으면 같은 customer_id 는 항상 같은 슬라이스에 있습니다. 그러면 각 슬라이스가 자기 안에서 조인을 끝냅니다. 네트워크 이동이 없습니다. 1편에서 말한 "데이터 이동을 얼마나 줄이느냐" 가 여기서 결정됩니다.

1.2. 네 가지 방식

  1. KEY : 지정한 컬럼의 해시로 슬라이스를 정합니다. 같은 값은 항상 같은 슬라이스로 갑니다. 조인 키에 걸어 재분배를 없애는 것이 목적입니다.
  2. ALL : 모든 노드에 전체 복사본을 둡니다. 어떤 조인이든 상대가 항상 같은 노드에 있으므로 이동이 없습니다. 대신 저장 공간이 노드 수만큼 들고 적재도 그만큼 느려집니다.
  3. EVEN : 라운드로빈으로 고르게 뿌립니다. 조인에 참여하지 않거나 마땅한 키가 없을 때 씁니다.
  4. AUTO (기본값) : Redshift 가 알아서 정합니다. 처음에는 작은 테이블을 ALL 로 두었다가, 커지면 EVEN 으로 바꾸는 식으로 데이터 양에 따라 스스로 전환합니다.

1.3. 어떻게 고르는가

  1. 가장 자주, 가장 크게 조인하는 컬럼을 KEY 로 둡니다. 팩트 테이블과 큰 디멘션 테이블을 잇는 컬럼입니다.
  2. 작은 디멘션 테이블은 ALL 로 둡니다. 코드 테이블처럼 몇천 행짜리라면 복사해두는 비용이 재분배 비용보다 쌉니다.
  3. GROUP BY 에 자주 쓰는 컬럼도 후보입니다. 집계도 같은 값이 한 슬라이스에 모여 있으면 유리합니다.
  4. 조인이 없거나 기준을 못 정하겠으면 EVEN 또는 AUTO 로 둡니다.

1.4. 가장 흔한 실수

카디널리티가 낮은 컬럼을 KEY 로 두는 것입니다. 성별이나 상태 컬럼을 분산 키로 잡으면 어떻게 될까요. 값이 몇 가지뿐이니 몇 개의 슬라이스에만 데이터가 몰립니다. 슬라이스가 32개인데 값이 3종류면 29개가 놀고 3개가 전부를 처리합니다.

이것을 분산 스큐(skew) 라고 합니다. 병렬 처리의 이점이 통째로 사라지고 쿼리 시간은 가장 오래 걸리는 슬라이스에 맞춰집니다. SVV_TABLE_INFO 의 skew_rows 값을 보면 확인할 수 있습니다. 값이 1에 가까울수록 고르게 퍼진 것이고, 크면 한쪽에 몰린 것입니다.

NULL 이 많은 컬럼도 같은 문제를 일으킵니다. NULL 끼리는 같은 슬라이스로 모이기 때문입니다.


2. 정렬 키


2.1. 존 맵과의 관계

1편에서 본 존 맵을 다시 떠올려봅시다. Redshift 는 1MB 블록마다 최솟값과 최댓값을 기록해두고 조건에 맞지 않는 블록은 읽지 않습니다. 이 건너뛰기가 실제로 효과를 보려면 데이터가 그 컬럼 기준으로 정렬되어 있어야 합니다.

날짜 컬럼으로 정렬되어 있다면 1월 데이터는 앞쪽 블록에 모여 있습니다. "1월만 보자" 는 쿼리는 뒤쪽 블록을 전부 건너뜁니다. 정렬되어 있지 않다면 1월 데이터가 모든 블록에 흩어져 있습니다. 어느 블록도 건너뛸 수 없어 전체를 다 읽습니다.

정렬 키는 인덱스가 아니라, 인덱스가 없는 상황에서 읽는 양을 줄이는 장치입니다.

2.2. 무엇을 고르는가

  1. WHERE 절에 가장 자주 쓰는 컬럼. 특히 날짜와 시간 컬럼이 1순위입니다. 분석 쿼리는 대부분 기간으로 자릅니다.
  2. 조인 키. 정렬되어 있으면 병합 조인이 가능해질 수 있습니다.
  3. 카디널리티가 적당히 높은 컬럼. 값이 몇 개뿐이면 블록을 가르는 효과가 적습니다.

2.3. 복합 정렬 키의 순서가 중요하다

여러 컬럼을 정렬 키로 지정할 수 있는데 앞에 오는 컬럼이 압도적으로 중요합니다. SORTKEY(date, region) 으로 두면 먼저 날짜로 정렬하고 같은 날짜 안에서 지역으로 정렬합니다.

  1. WHERE date = ... → 잘 걸립니다.
  2. WHERE date = ... AND region = ... → 잘 걸립니다.
  3. WHERE region = ... → 거의 효과가 없습니다. 지역 값이 모든 날짜 블록에 흩어져 있기 때문입니다.

복합 인덱스의 선두 컬럼 규칙과 같은 이야기입니다. 가장 자주 걸리는 조건을 맨 앞에 두어야 합니다.

2.4. 인터리브드 정렬 키

INTERLEAVED 로 선언하면 여러 컬럼에 동등한 가중치를 줍니다. 어느 컬럼으로 걸러도 비슷한 효과를 냅니다. 편해 보이지만 대가가 큽니다. 적재할 때 비용이 더 들고 VACUUM REINDEX 라는 무거운 작업을 주기적으로 해줘야 합니다. 실무에서는 대체로 복합 정렬 키(compound) 를 씁니다. 조회 패턴이 정말로 예측 불가능하게 여러 컬럼으로 흩어지는 경우에만 고려합니다.

2.5. AUTO 정렬 키

분산 키와 마찬가지로 정렬 키에도 AUTO 가 있습니다. Redshift 가 쿼리 패턴을 관찰해 정렬 키를 정하고 바꿉니다. 처음 설계할 때 확신이 없다면 AUTO 로 두고 쿼리 패턴이 자리를 잡은 뒤 명시적으로 지정하는 방식이 무난합니다.


3. 압축 인코딩


3.1. 왜 신경 써야 하는가

1편에서 본 대로 분석 워크로드의 병목은 대체로 I/O 입니다. 압축이 잘 되면 읽어야 할 바이트가 줄어들고 그것이 그대로 속도가 됩니다. 그리고 Redshift 는 압축된 상태로 저장할 뿐 아니라, 일부 인코딩은 압축을 풀지 않고도 연산할 수 있습니다.

3.2. 주요 인코딩

  1. AZ64 : AWS 가 만든 인코딩입니다. 숫자, 날짜, 시간 타입에 권장됩니다. 압축률과 처리 속도의 균형이 좋습니다.
  2. ZSTD : 범용으로 잘 듣습니다. 문자열 컬럼에 주로 씁니다. 어느 타입에나 쓸 수 있습니다.
  3. BYTEDICT : 값의 종류가 256개 이하인 컬럼에 맞습니다. 사전을 두고 값 대신 번호를 저장합니다.
  4. RUNLENGTH : 같은 값이 길게 반복될 때 "이 값이 N번" 으로 저장합니다. 정렬 키 컬럼처럼 같은 값이 연속되는 경우에 효과적입니다.
  5. DELTA : 이전 값과의 차이만 저장합니다. 증가하는 ID 나 타임스탬프에 맞습니다.
  6. RAW : 압축하지 않습니다.

3.3. 정렬 키 컬럼은 압축하지 않는 편이 낫다

자주 놓치는 부분입니다. 정렬 키의 첫 번째 컬럼은 RAW 로 두는 것이 권장됩니다. 이유는 존 맵과 관련이 있습니다. 그 컬럼을 강하게 압축하면 한 블록에 더 많은 행이 들어갑니다. 블록이 담는 값의 범위가 넓어지고 그러면 건너뛸 수 있는 블록이 줄어듭니다.

압축을 포기하고 블록당 행 수를 적게 유지하는 편이, 존 맵의 효과를 살리는 데는 유리합니다.

3.4. 직접 정하지 않아도 된다

  1. COPY 로 빈 테이블에 처음 적재하면 Redshift 가 데이터를 보고 인코딩을 자동으로 정합니다.
  2. ANALYZE COMPRESSION 명령으로 기존 테이블에 대한 추천을 받을 수 있습니다.
  3. 최근에는 자동 테이블 최적화(ATO) 가 인코딩과 키까지 스스로 조정합니다.

다만 자동에 맡기더라도 왜 그렇게 정해졌는지는 이해하고 있어야 이상한 결과를 알아챌 수 있습니다.


4. 스키마 설계


4.1. 정규화하지 않는다

OLTP 에서는 중복을 없애기 위해 정규화합니다. 데이터 웨어하우스에서는 반대로 갑니다. 조인이 비싸기 때문에 어느 정도 중복을 허용합니다. 전형적인 형태가 스타 스키마입니다.

  1. 팩트 테이블 : 사건을 기록합니다. 주문, 클릭, 결제 같은 것입니다. 행이 아주 많습니다.
  2. 디멘션 테이블 : 사건을 설명하는 속성입니다. 고객, 상품, 지역, 날짜 같은 것입니다. 상대적으로 작습니다.

팩트 하나를 가운데 두고 디멘션들이 둘러싼 모양이라 스타 스키마라고 부릅니다. 여기에 1장의 분산 전략을 얹으면 자연스럽게 정리됩니다. 팩트 테이블은 가장 큰 디멘션과의 조인 키로 KEY 분산하고 작은 디멘션들은 ALL 로 둡니다.

4.2. 데이터 타입

  1. 문자열 길이를 넉넉하게 잡지 않습니다. VARCHAR(10) 이면 되는데 VARCHAR(65535) 로 두는 경우가 있습니다. 저장은 실제 길이만큼만 하지만, 쿼리 처리 중 메모리는 선언된 길이 기준으로 잡습니다. 그래서 불필요하게 큰 선언이 디스크로 흘러넘치는 원인이 됩니다.
  2. 날짜는 문자열이 아니라 DATE, TIMESTAMP 로 둡니다. 존 맵과 압축 모두에 유리합니다.
  3. 적절한 숫자 타입을 씁니다. 소수점이 필요 없는데 DECIMAL 을 쓰면 연산이 느려집니다.

4.3. 제약 조건은 검사되지 않는다

1편에서 언급한 부분입니다. Redshift 에서 기본 키, 고유 키, 외래 키는 선언은 되지만 강제되지 않습니다. 중복된 기본 키를 넣어도 오류가 나지 않습니다. 그럼 왜 선언할까요. 옵티마이저가 실행 계획을 세울 때 참고하기 때문입니다. "이 컬럼은 유일하다" 는 정보가 있으면 더 나은 계획을 세울 수 있습니다.

따라서 선언은 하되, 무결성은 적재 과정에서 직접 보장해야 합니다. 이것을 모르면 중복 데이터가 조용히 쌓입니다.


5. 설계를 검증하는 방법


설계가 잘 되었는지는 짐작하지 말고 확인해야 합니다.

5.1. 실행 계획 읽기

EXPLAIN 으로 계획을 보면 조인 앞에 붙는 접두사에서 데이터 이동이 드러납니다.

  1. DS_DIST_NONE : 이동이 없습니다. 가장 좋은 상태입니다. 두 테이블이 조인 키로 함께 분산되어 있거나 한쪽이 ALL 인 경우입니다.
  2. DS_DIST_ALL_NONE : 한쪽이 ALL 분산이라 이동이 필요 없습니다. 역시 좋습니다.
  3. DS_DIST_INNER / DS_DIST_OUTER : 한쪽 테이블을 재분배합니다. 네트워크 비용이 발생합니다.
  4. DS_BCAST_INNER : 한쪽 테이블을 모든 노드에 방송합니다. 그 테이블이 크면 아주 비쌉니다. 큰 테이블에서 이것이 보이면 분산 키를 다시 봐야 한다는 신호입니다.

5.2. 시스템 테이블

  1. SVV_TABLE_INFO : 테이블별로 분산 키, 정렬 키, 스큐 정도, 정렬되지 않은 비율(unsorted), 통계 갱신 여부(stats_off)를 한눈에 보여줍니다. 가장 먼저 봐야 할 뷰입니다.
  2. STL_ALERT_EVENT_LOG : 옵티마이저가 남긴 경고입니다. "통계가 없다", "방송이 일어났다", "디스크로 흘러넘쳤다" 같은 내용이 기록됩니다.
  3. SVL_QUERY_SUMMARY : 쿼리의 단계별 상세입니다. 어느 단계에서 디스크를 썼는지 확인할 수 있습니다.

5.3. 통계

옵티마이저는 통계를 보고 계획을 세웁니다. 통계가 낡으면 잘못된 조인 순서를 고르거나 엉뚱한 분산 방식을 선택합니다. ANALYZE 로 갱신하며 요즘은 자동 통계 수집이 동작합니다. 다만 대량 적재 직후에는 자동 수집을 기다리지 말고 직접 실행하는 편이 안전합니다.


6. 정리하면


정하는 것 목적 기준
분산 키 노드 간 데이터 이동 줄이기 가장 크게 조인하는 컬럼, 카디널리티 높게
정렬 키 읽는 블록 수 줄이기 WHERE 에 자주 쓰는 컬럼, 날짜 우선
인코딩 읽는 바이트 줄이기 숫자는 AZ64, 문자는 ZSTD, 정렬 키 선두는 RAW
스키마 조인 줄이기 스타 스키마, 과한 정규화 지양

7. 조금 더 들어가보기 : 옵티마이저는 무엇을 보고 고르는가


분산 키와 정렬 키를 고르는 기준을 봤는데 옵티마이저가 그 선택을 어떻게 활용하는지까지 내려가면 왜 그런 기준이 나왔는지가 분명해집니다.

7.1. 조인은 어떻게 실행되는가

관계형 엔진이 두 테이블을 잇는 방법은 몇 가지뿐입니다.

  1. 해시 조인 : 한쪽을 읽어 조인 키로 해시 테이블을 만들고(빌드), 다른 쪽을 훑으며 그 테이블을 조회한다(프로브). 빌드하는 쪽이 메모리에 들어가야 합니다.
  2. 머지 조인 : 양쪽이 이미 조인 키로 정렬되어 있으면 두 목록을 나란히 훑으며 맞춰갑니다. 해시 테이블을 만들 필요가 없어 메모리를 거의 쓰지 않습니다.
  3. 중첩 루프 : 한쪽의 각 행마다 다른 쪽을 전부 훑습니다. 아주 느립니다.

여기서 정렬 키가 조인 키와 같으면 좋은 이유가 나옵니다. 정렬되어 있으면 머지 조인이 가능해지고 그러면 빌드 단계와 메모리 부담이 사라집니다. 그리고 해시 조인에서 빌드 쪽이 메모리에 안 들어가면 디스크로 넘치며 급격히 느려집니다. 앞서 본 디스크 스필의 흔한 원인입니다.

7.2. 그런데 그 전에 데이터가 같은 자리에 있어야 한다

MPP 에서는 조인 알고리즘보다 앞선 단계가 있습니다. 짝이 되는 행이 같은 슬라이스에 있는가입니다. 없으면 옮겨야 하고 그 방식이 실행 계획에 드러납니다.

  1. 양쪽이 조인 키로 함께 분산되어 있으면 → 옮기지 않습니다. 각 슬라이스가 자기 안에서 끝냅니다.
  2. 한쪽이 모든 노드에 복제되어 있으면 → 역시 옮기지 않습니다.
  3. 한쪽만 조인 키로 분산되어 있으면 → 다른 쪽을 재분배합니다.
  4. 둘 다 아니면 → 한쪽을 모든 노드에 방송하거나 양쪽을 재분배합니다.

분산 키를 고르는 일은 4번을 1번으로 바꾸는 작업입니다. 조인 알고리즘을 아무리 잘 골라도 그 앞의 이동 비용이 크면 소용이 없습니다.

7.3. 옵티마이저는 통계를 보고 고른다

어느 쪽을 빌드로 삼을지, 방송할지 재분배할지는 행 수와 값 분포 추정치로 정합니다. 통계가 낡으면 이 추정이 틀립니다. 틀렸을 때 벌어지는 일이 고약합니다.

  1. 작은 줄 알고 방송했는데 실제로는 큰 테이블이면 모든 노드에 거대한 데이터가 복사됩니다.
  2. 메모리에 들어갈 줄 알고 해시 테이블을 만들었는데 넘쳐서 디스크로 갑니다.
  3. 조인 순서를 잘못 잡아 중간 결과가 폭발합니다.

그래서 대량 적재 직후에 통계를 갱신하는 것이 중요합니다. 적재로 행 수가 열 배 늘었는데 옵티마이저가 옛 통계를 보고 있으면 계획이 처음부터 어긋납니다. 경고 로그에 "통계가 없다" 가 자주 찍힌다면 이 문제입니다.

7.4. 카디널리티 추정이 어긋나는 지점

통계가 최신이어도 추정이 틀리는 경우가 있습니다.

  1. 컬럼 사이에 상관관계가 있을 때. 도시와 우편번호처럼 사실상 같은 정보를 담은 두 컬럼에 조건을 각각 걸면 옵티마이저는 독립이라고 보고 선택도를 곱합니다. 실제보다 훨씬 적게 나올 것으로 추정합니다.
  2. 값 분포가 치우쳐 있을 때. 앞서 본 멱법칙 분포입니다. 평균으로 추정하면 인기 키가 걸린 쿼리에서 크게 빗나갑니다.
  3. 함수를 씌운 조건. 컬럼에 함수를 적용하면 통계를 쓸 수 없어 기본 추정치로 떨어집니다.

세 번째는 앞서 정리한 존 맵 무력화와 같은 이야기입니다. 날짜에 함수를 씌우면 블록 건너뛰기도 못 하고 추정도 못 합니다. 한 번의 표현 차이로 두 가지를 함께 잃습니다.

7.5. 대규모에서 설계가 달라지는 지점

  1. 테이블이 커질수록 방송 조인의 위험이 커집니다. 개발 데이터에서는 잘 돌던 쿼리가 운영에서 터지는 전형적인 경로입니다.
  2. 분산 키를 바꾸려면 테이블을 다시 써야 합니다. 수 TB 테이블이라면 큰 작업이라, 처음 설계가 오래 갑니다.
  3. 조인 패턴이 여러 가지면 하나의 분산 키로 다 만족시킬 수 없습니다. 가장 크고 자주 도는 조인을 기준으로 잡고 나머지는 재분배 비용을 감수하거나 별도 테이블을 둡니다.
  4. 작은 디멘션을 복제해두는 전략은 테이블이 커지면 무너집니다. 복제본이 노드 수만큼 저장되므로, 기준을 넘으면 재분배가 더 쌉니다.

결국 분산 키는 특정 조인을 위한 최적화이고 그 조인이 바뀌면 설계도 다시 봐야 합니다. "한 번 잘 잡아두면 끝" 이 아니라 워크로드와 함께 재검토해야 하는 값이라고 이해하는 편이 맞습니다.


결론


테이블 설계를 정리하면서 가장 크게 느낀 것은, 세 가지 키가 전부 "줄이기" 를 향하고 있다는 점이었습니다. 분산 키는 네트워크로 옮기는 양을, 정렬 키는 디스크에서 읽는 블록을, 인코딩은 그 블록의 바이트를 줄입니다. 인덱스처럼 "빨리 찾는" 장치가 아니라 처음부터 덜 만지도록 배치하는 방식이었습니다.

두 번째로 인상 깊었던 것은 정렬 키 컬럼을 압축하지 않는 편이 낫다는 부분이었습니다. 압축은 무조건 좋은 것이라고 생각했는데 여기서는 압축을 하면 블록당 행이 늘어 존 맵의 효과가 떨어진다는 상충이 있었습니다. 두 최적화가 서로를 갉아먹는 지점을 이해해야 올바른 선택을 할 수 있다는 것을 배웠습니다.

세 번째는 제약 조건이 검사되지 않는다는 사실이었습니다. 선언은 받아주는데 지키지는 않고 대신 옵티마이저의 힌트로만 쓰입니다. 처음 들었을 때는 위험해 보였는데 수억 행을 적재하면서 매번 유일성을 검사하는 비용을 생각하면 이해가 되었습니다. 성능을 위해 무결성 보장을 애플리케이션으로 넘긴 결정인 셈입니다.


공부 참고 자료


📑 1. Redshift - 테이블 설계 모범 사례

📑 2. Redshift - 데이터 배포 스타일 선택

📑 3. Redshift - 정렬 키 선택

📑 4. Redshift - 압축 인코딩

📑 5. Redshift - 쿼리 계획 및 실행 워크플로

📑 6. Redshift - 쿼리 계획 분석

📑 7. Redshift - 자동 테이블 최적화

📑 8. Redshift - SVV_TABLE_INFO

Comments