메모리 최적화

메모리 최적화 (Memory Optimization)

Redis에서 메모리를 최적화하는 전략을 살펴볼게요.

출처: 공식문서

작은 집계 데이터 타입의 특수 인코딩

Redis 2.2부터 많은 데이터 타입이 특정 크기까지 더 적은 공간을 쓰도록 최적화됐어요. Hashes, Lists, 정수로만 구성된 Sets, Sorted Sets는 요소 수가 주어진 개수보다 작고 요소 크기가 최대 이하일 때, 메모리를 최대 10배 덜 쓰는(평균 절약은 5배) 매우 메모리 효율적인 방식으로 인코딩돼요.

이것은 사용자와 API 관점에서 완전히 투명해요. CPU/메모리 트레이드오프이므로, 특수 인코딩 타입의 최대 요소 수와 최대 요소 크기는 다음 redis.conf 지시어로 조정할 수 있어요(기본값 표시).

Redis <= 6.2

hash-max-ziplist-entries 512
hash-max-ziplist-value 64
zset-max-ziplist-entries 128 
zset-max-ziplist-value 64
set-max-intset-entries 512

Redis >= 7.0

hash-max-listpack-entries 512
hash-max-listpack-value 64
zset-max-listpack-entries 128
zset-max-listpack-value 64
set-max-intset-entries 512

Redis >= 7.2

다음 지시어도 사용 가능해요:

set-max-listpack-entries 128
set-max-listpack-value 64

특수 인코딩된 값이 설정된 최대 크기를 넘으면 Redis는 자동으로 일반 인코딩으로 변환해요. 이 연산은 작은 값에선 매우 빠르지만, 훨씬 더 큰 집계 타입에 특수 인코딩을 쓰기 위해 설정을 바꿨다면 변환 시간을 확인하기 위해 몇 가지 벤치마크·테스트를 실행하는 걸 권장해요.

32비트 인스턴스 사용

Redis를 32비트 타깃으로 컴파일하면 포인터가 작아서 키당 메모리를 훨씬 덜 쓰지만, 인스턴스는 최대 4GB 메모리 사용으로 제한돼요. Redis를 32비트 바이너리로 컴파일하려면 make 32bit를 쓰세요. RDB와 AOF 파일은 32비트·64비트 인스턴스 간(그리고 little/big endian 간에도) 호환되므로 문제없이 32→64비트로, 또는 그 반대로 전환할 수 있어요.

비트·바이트 레벨 연산

Redis 2.2는 새 비트·바이트 레벨 연산을 도입했어요: GETRANGE, SETRANGE, GETBIT, SETBIT. 이 명령들로 Redis string 타입을 임의 접근 배열처럼 다룰 수 있어요. 예를 들어 사용자가 고유한 연속 정수 번호로 식별되는 애플리케이션이 있다면, 비트맵으로 메일링 리스트의 사용자 구독 정보를 저장할 수 있어요(구독이면 비트 설정, 구독 해지면 해제). 1억 명의 사용자면 이 데이터는 Redis 인스턴스에서 단 12MB RAM만 차지해요. GETRANGESETRANGE로 사용자당 1바이트 정보를 저장하는 것도 가능해요. 이건 예시일 뿐이지만, 이런 새 프리미티브들로 아주 적은 공간에 많은 문제를 모델링할 수 있어요.

가능하면 hashes 사용

작은 hashes는 매우 작은 공간으로 인코딩되므로, 가능하면 데이터를 hashes로 표현하는 걸 시도해야 해요. 예를 들어 웹 애플리케이션의 사용자를 나타내는 객체가 있다면, name, surname, email, password에 각각 다른 키를 쓰는 대신 필요한 모든 필드를 가진 단일 hash를 쓰세요. 더 알고 싶다면 다음 섹션을 읽어보세요.

hashes로 Redis 위에 매우 메모리 효율적인 평범한 키-값 저장소 추상화하기

제목이 좀 무섭게 들리지만, 이게 무엇에 관한 건지 자세히 설명할게요. 기본적으로 가능한 것은 값을 그저 문자열로만 둘 수 있는 평범한 키-값 저장소를 Redis로 모델링하는 건데, 이는 Redis 일반 키보다 더 메모리 효율적일 뿐 아니라 memcached보다도 훨씬 메모리 효율적이에요.

몇 가지 사실부터 시작할게요: 몇 개의 키는 몇 개의 필드를 가진 단일 hash를 담은 키 하나보다 훨씬 많은 메모리를 사용해요. 어떻게 가능할까요? 트릭을 써요. 이론적으로 상수 시간(O(1)) 조회를 보장하려면 평균 상수 시간 복잡도를 가진 데이터 구조(해시 테이블 같은)를 써야 해요.

하지만 많은 경우 hashes는 몇 개의 필드만 담아요. hash가 작을 때는 O(N) 데이터 구조, 즉 길이-접두사 키-값 쌍의 선형 배열로 인코딩할 수 있어요. N이 작을 때만 이렇게 하므로, HGETHSET 명령의 분할상환(amortized) 시간은 여전히 O(1)이에요: 포함 요소 수가 너무 커지면 hash는 진짜 해시 테이블로 변환되거든요(한도는 redis.conf에서 설정 가능).

이것은 시간 복잡도 관점에서만 잘 작동하는 게 아니라, 상수 시간 관점에서도 잘 작동해요. 키-값 쌍의 선형 배열은 CPU 캐시와 잘 어울리기 때문이에요(해시 테이블보다 캐시 지역성이 좋아요).

다만 hash 필드와 값이 (항상) 완전한 기능의 Redis 객체로 표현되지는 않으므로, hash 필드는 진짜 키처럼 TTL(만료)을 가질 수 없고 문자열만 담을 수 있어요. 그래도 괜찮아요. hash 데이터 타입 API를 설계할 때 의도한 바이니까요(중첩 데이터 구조와 단일 필드 만료가 허용되지 않는 것처럼, 기능보다 단순함을 신뢰해요).

그래서 hashes는 메모리 효율적이에요. 객체를 표현하거나 관련 필드 그룹이 있는 다른 문제를 모델링할 때 유용하죠. 그런데 평범한 키-값 비즈니스라면 어떨까요?

Redis를 JSON 인코딩 객체, 작은 HTML 조각, 단순 key -> boolean 값 등 많은 작은 객체의 캐시로 쓴다고 상상해 보세요. 기본적으로 뭐든 작은 키와 값을 가진 string -> string 맵이에요.

캐시할 객체가 번호가 매겨져 있다고 가정해요:

  • object:102393
  • object:1234
  • object:5

우리가 할 수 있는 일은 이래요. 새 값을 설정하는 SET 연산을 할 때마다 실제로 키를 두 부분으로 나눠 한 부분은 키로, 다른 부분은 hash의 필드 이름으로 써요. 예를 들어 "object:1234"라는 객체는 실제로 이렇게 나뉘어요:

  • object:12라는 키
  • 34라는 필드

즉 마지막 두 글자를 제외한 모든 문자를 키로, 마지막 두 글자를 hash 필드 이름으로 써요. 키를 설정하려면 다음 명령을 써요:

HSET object:12 34 somevalue

보시다시피 모든 hash는 결국 100개의 필드를 담게 되는데, 이는 CPU와 메모리 절약 사이의 최적의 절충이에요.

이 스키마에서 캐시한 객체 수와 무관하게 모든 hash가 대략 100개의 필드를 가진다는 점도 중요해요. 객체가 항상 숫자로 끝나고 랜덤 문자열이 아니기 때문이죠. 어떤 면에서 마지막 숫자는 암묵적 사전 샤딩(pre-sharding) 의 한 형태로 볼 수 있어요.

작은 숫자는 어떨까요? object:2 같은 경우? 이 경우 "object:"만 키 이름으로 쓰고, 전체 숫자를 hash 필드 이름으로 써서 처리해요. 그래서 object:2와 object:10은 둘 다 "object:" 키 안에 들어가는데, 하나는 필드 이름 "2", 다른 하나는 "10"이 돼요.

이렇게 하면 메모리를 얼마나 아낄까요? 다음 Ruby 프로그램으로 어떻게 작동하는지 테스트했어요:

require 'rubygems'
require 'redis'

USE_OPTIMIZATION = true

def hash_get_key_field(key)
  s = key.split(':')
  if s[1].length > 2
    { key: s[0] + ':' + s[1][0..-3], field: s[1][-2..-1] }
  else
    { key: s[0] + ':', field: s[1] }
  end
end

def hash_set(r, key, value)
  kf = hash_get_key_field(key)
  r.hset(kf[:key], kf[:field], value)
end

def hash_get(r, key, value)
  kf = hash_get_key_field(key)
  r.hget(kf[:key], kf[:field], value)
end

r = Redis.new
(0..100_000).each do |id|
  key = "object:#{id}"
  if USE_OPTIMIZATION
    hash_set(r, key, 'val')
  else
    r.set(key, 'val')
  end
end

Redis 2.2의 64비트 인스턴스에서의 결과예요:

  • USE_OPTIMIZATION을 true로 설정: 사용 메모리 1.7 MB
  • USE_OPTIMIZATION을 false로 설정: 사용 메모리 11 MB

한 자릿수 차이예요. 이로써 Redis가 세상에서 가장 메모리 효율적인 평범한 키-값 저장소 중 하나가 됐다고 생각해요.

경고: 이게 작동하려면 redis.conf에 이렇게 설정돼 있는지 확인하세요:

hash-max-zipmap-entries 256

그리고 키와 값의 최대 크기에 맞춰 다음 필드도 설정해야 해요:

hash-max-zipmap-value 1024

hash가 지정된 요소 수나 요소 크기를 초과할 때마다 진짜 해시 테이블로 변환되고, 메모리 절약이 사라져요.

"왜 일반 키 공간에서 암묵적으로 이렇게 하지 않아서 신경 쓸 필요가 없게 하지 않나요?"라고 물을 수 있어요. 두 가지 이유가 있어요. 하나는 트레이드오프를 명시적으로 만들려 하기 때문이고, 이건 CPU·메모리·최대 요소 크기 사이의 분명한 트레이드오프예요. 둘째, 최상위 키 공간은 만료, LRU 데이터 등 흥미로운 것들을 많이 지원해야 하므로 일반적인 방법으로는 실용적이지 않아요.

하지만 Redis 방식은 사용자가 시스템이 정확히 어떻게 동작할지 이해할 수 있도록 작동 원리를 이해해야 한다는 거예요. 그래야 최적의 절충을 고를 수 있으니까요.

메모리 할당

사용자 키를 저장하기 위해 Redis는 maxmemory 설정이 허용하는 만큼 최대 메모리를 할당해요(작은 추가 할당은 가능하지만요). 정확한 값은 설정 파일이나 나중에 CONFIG SET으로 설정할 수 있어요(자세한 내용은 Using memory as an LRU cache 참고). Redis가 메모리를 관리하는 방식에 대해 몇 가지 알아둬야 할 점이 있어요:

  • Redis는 키를 제거할 때 항상 메모리를 OS에 해제(반환)하지 않아요. 이건 Redis만의 특별한 점이 아니라 대부분 malloc() 구현의 방식이에요. 예를 들어 인스턴스에 5GB 데이터를 채운 뒤 2GB 상당을 제거하면, Resident Set Size(RSS, 프로세스가 소비하는 메모리 페이지 수)는 Redis가 사용자 메모리가 약 3GB라고 말해도 여전히 5GB 전후일 거예요. 기본 할당자가 메모리를 쉽게 해제할 수 없기 때문이에요. 예를 들어 제거된 키 대부분이 여전히 존재하는 다른 키와 같은 페이지에 할당된 경우가 자주 있죠.
  • 위의 점은 피크 메모리 사용량(peak memory usage) 기준으로 메모리를 프로비저닝해야 한다는 뜻이에요. 워크로드가 가끔 10GB를 필요로 한다면, 대부분은 5GB로 충분해도 10GB로 프로비저닝해야 해요.
  • 하지만 할당자는 똑똑해서 자유 메모리 청크를 재사용할 수 있어요. 5GB 데이터셋에서 2GB를 해제한 뒤 다시 키를 추가하기 시작하면, 최대 2GB의 추가 키를 추가하는 동안 RSS(Resident Set Size)가 일정하게 유지되고 더 늘어나지 않는 걸 볼 수 있어요. 할당자가 이전에 (논리적으로) 해제된 2GB를 재사용하려 하기 때문이에요.
  • 이 모든 것 때문에, 피크 시 메모리 사용량이 현재 사용 메모리보다 훨씬 컸다면 단편화 비율(fragmentation ratio)은 신뢰할 수 없어요. 단편화는 실제 사용 물리 메모리(RSS 값)를 현재 사용 메모리(Redis가 수행한 모든 할당의 합)로 나눠 계산돼요. RSS가 피크 메모리를 반영하므로, 많은 키/값이 해제돼 (가상으로) 사용 메모리는 낮은데 RSS가 높으면 RSS / mem_used 비율이 매우 높아져요.

maxmemory를 설정하지 않으면 Redis는 적절하다고 판단되는 대로 계속 메모리를 할당해 결국 모든 자유 메모리를 (점진적으로) 먹어버릴 수 있어요. 따라서 일반적으로 몇 가지 한계를 설정하는 게 좋아요. maxmemory-policynoeviction으로 설정하고 싶을 수도 있어요(이건 일부 구버전 Redis에서 기본값이 아님).

이렇게 하면 한도에 도달하면 쓰기 명령에 대해 out-of-memory 오류를 반환해, 애플리케이션에 오류가 생길 수는 있지만 메모리 고갈로 전체 머신이 죽지는 않게 돼요.

더 알아보기 (Learn more)