새소식

Welcome to the tech blog of Junior Backend Developer Seulbin Kang!

Study

(CS Study) Redis String 타입 완전 정리(Binary Safe부터 내부 인코딩까지)

  • -

👀 들어가기 전에

Redis를 처음 접하면 “String 타입이 뭐 별거 있겠어?”라고 생각하기 쉽습니다.
하지만 실제로는 Redis 성능과 메모리 효율의 핵심이 이 String 내부 구조에 숨어 있습니다.

특히 백엔드에서 Redis를 사용할 때

  • 왜 빠른지
  • 왜 작은 단위로 쪼개야 하는지
  • 왜 특정 상황에서 메모리가 터지는지

이걸 이해하려면 String 타입을 제대로 알아야 한다고 생각하여 기본부터 다시 파보려합니다.


👀본론

1. Redis의 String Type

Redis의 String 타입은 "문자열을 저장하는 자료형"처럼 보이지만 실제로는 훨씬 더 범용적인 데이터 타입입니다.

Redis에서 String은 단순 text 저장용 타입이 아니라 바이트 배열 기반의 가장 기본적인 데이터 저장 단위입니다.

 

즉, Redis String은 문자열 뿐만 아니라 숫자, 이진데이터, 직렬화된 객체까지 저장할 수 있습니다.

실제로 Redis의 모든 Key는 기본적으로 String 타입이며 Value 역시 String으로 저장할 수 있습니다.

 

Redis에서는 저장 가능한 최대크기가 512MB입니다.

그냥 단순 숫자로 보게 되어 그렇지, 문자 기준 한 글자 1바이트니까 대략 5억 3천만 글자가 저장 가능한 것입니다.

일반적으로 많이 쓰는 JSON자료형 하나, 이 자료형이 150Byte라고 가정한다면, 약 3.5백만개 저장이 가능한 것입니다.

 

그렇다면 그냥 용량 큰 자료형을 그대로 저장해도 괜찮을까요?

 

실제로는 작은 값으로 쪼개어서 관리하는게 효율이 더 좋습니다.

레디스는 메모리에 모든 데이터를 저장합니다.

하나의 키에 100MB 저장하면, 조회시마다 100MB의 왕복 비용이 발생하게 됩니다.

 

이를 네트워크 관점에서 보게되면 대역폭을 많이 소모하고, 다른 요청 처리를 지연시킬 수 있기에 크게 저장하는 것은 효율적으로 좋지 않습니다.

 

큰파일을 한 번에 메모리에 로드해서 처리하지 않고 chunk형태로 로드시키는 경우들이 많은데, 이 관점 그대로 들어간것이라 생각하면 쉬울 것 같습니다. 그래서 String Byte는 작은수 byte에서 kbyte단위로만 다루는게 적합합니다.

저장 데이터가 너무 크다면 선택을 해야하겠죠.

이때는, 여러 조각으로 데이터를 나눌 수 있는지까지 생각을 해보고 나눌 수 있다면 나누는 것이 적합합니다. 

못나누더라도 나눌 수 있는 방법을 생각해볼 수 있을 것 같습니다.

 


2. Redis의 String과 Binary Safe

Redis의 String을 설명하게 되며, Binary Safe를 알게될 수 밖에 없습니다.

C언어의 기본 문자열은 문자열의 끝을 null byte (\0)로 판단합니다.

즉, 문자열 중간에 null byte가 들어있으면 그 지점에서 문자열이 끝난 것으로 해석됩니다.

C문자열 관점에서는 중간에 null byte가 들어간 데이터는 일반 문자열처럼 다루기 어렵습니다.

그래서 이미지 파일, 바이너리 파일, 직렬화 데이터처럼 내부에 null byte가 포함될 수 있는 데이터는 일반적인 C 문자열 구조로는 처리에 제약이 생겨버립니다.

 

Redis는 왜 Binary Safe인가

Redis는 문자열 처리를 위해 C 문자열 대신 Simple Dynamic String 구조를 사용합니다.

이 SDS는 아래와 같이 구성되어 있습니다.

SDS의 구조

 

len : int (문자열의 실제 길이)

free : int (여유 공간)

buf : char (문자 데이터)

 

즉 SDS는 문자 배열 뿐만이 아니라 문자열 길이와 남는 공간까지 함께 관리합니다.

이는, 아래와 같은 장점이 있습니다.

  1. 문자열 길이 조회가 빠릅니다.
    일반 C 문자열에서는 길이를 알기 위해 끝까지 순회해야만 알 수 있지만,
    SDS는 len 필드가 있으므로 문자열 길이를 바로 알 수 있습니다.
    시간복잡도가 굉장히 낮을 것이라 생각됩니다. 즉, 길이 조회가 매우 효율적입니다.

  2. 버퍼 오버플로우를 방지하기 쉽습니다.
    C 문자열은 잘못 다루면 버퍼 오버플로우 위험이 있습니다.
    반면, SDS는 len 기반으로 관리되므로 상대적으로 안전합니다.
  3. append 연산에 유리합니다.
    SDS는 free 공간을 따로 관리하므로 뒤에 문자열을 붙일 때 남는 공간이 있다면 바로 append가 가능한 것입니다.
    즉, 매번 새 메모리를 할당하지 않아도 가능한 것입니다.

  4. 재할당이 필요해도 이를 효율적으로 처리가 가능합니다.
    문자열이 커져서 추가 공간이 필요해지더라도, 
    SDS는 이런 확장 처리를 일반 C 문자열보다 훨씬 효율적으로 다룰 수 있습니다.

정리하면, SDS를 통해 Redis String은 길이 조회, 안정성, 확장성 측면에서 유리한 구조인 것입니다.


3. Redis String의 내부 인코딩 방식

Redis String은 내부적으로 상황에 따라 서로 다른 인코딩 방식을 사용합니다.

대표적으로 아래 세가지를 많이 사용합니다.

Redis String의 인코딩

 

1) int 인코딩

2) embstr 인코딩

3) raw 인코딩

 

위 세가지를 아래에서 더 자세히 알아보고 왜 이렇게 세가지로 나누어지게 되는 것인지를 생각해보도록 하겠습니다.


3-1. int 인코딩

먼저 int 인코딩은 주로 정수형 값을 저장할 때 사용되는 인코딩입니다.

정수값이고 long타입 범위 내에 있으면 문자열이 아닌 정수로 저장하게 됩니다.

SET count 123

 

예를 들어 위와 같은 명령어를 실행했다고 생각해보겠습니다.

위 값은 정수형이고, long 타입 범위 내에 있으므로 int 인코딩을 따르게 됩니다.

그래서 내부적으로 “123”이 아니라 정수 123이 저장되게 되는 것입니다.

 

이를 통해 메모리의 크기를 절약할 수 있습니다.

문자열 데이터의 경우 "123"은 3바이트에 플러스 알파로 메타데이터가 들어가게 됩니다.

하지만, 정수는 무조건 8바이트입니다.

 

즉, 숫자 값들은 메모리와 연산의 측면에서 더 효율적인 방식으로 저장이 가능합니다.


3-2. embstr 인코딩

embstr은 44바이트 이하 짧은 문자열을 인코딩할 때 사용됩니다.

 

embstr은 Redis의 객체 헤더와 SDS 구조를 연속된 메모리 공간에 한 번에 할당하는 방식입니다.

원래대로라면 아래처럼 두 번의 메모리 할당이 일어나게 됩니다.

1. Redis 객체 헤더

2. 실제 문자열 데이터 구조 (SDS)

 

그런데 embstr은 이를 한번의 메모리 할당으로 연속된 메모리 공간에 생성합니다.

이로 인해 결과적으로 메모리 단편화 문제가 줄고,

관련 데이터가 메모리에 연속적으로 배치되어 있기에, CPU상 캐시 히트율이 높아지게 됩니다.

 

주의점 : 읽기 전용 !!!

다만, embstr은 짧은 문자열을 빠르고 효율적으로 저장하기 위한 구조가 발생하기에

수정이 발생하면 보통 raw 인코딩으로 전환됩니다.

근데 왜 하필 44 바이트 일까요?

 

Redis 객체 헤더가 16바이트입니다.

거기에 SDS 헤더가 추가로 필요하기 때문에 전체가 jemalloc의 64바이트 안에 들어올 수 있도록 44바이트로 설정을 해놓은 것입니다.

redisObject + SDS + 문자열 데이터 ≪ 64 bytes 

 

이는 단순히 객체 헤더 크기 합산으로 고정된 값이라기보다는, 메모리 할당 효율을 높이기 위한 설계이며 Redis 버전이나 내부 구조, jemalloc 정책에 따라 달라질 수 있습니다.


3-3. raw 인코딩

raw 인코딩은 일반적인 문자열을 처리하는 방식입니다.

대체로 44바이트를 초과하는 문자열, 혹은 수정 가능성이 있는 문자열이 raw 인코딩으로 관리됩니다.

 

raw인코딩에서는 Redis 객체와 SDS가 별도의 메모리 영역으로 관리됩니다.

즉, Redis 객체는 포인터로 SDS를 가리키고, SDS는 실제 문자열 데이터를 저장하고 있는 것입니다.

 

그렇기 때문에 더 긴 문자열 저장이 가능하고, 문자열 수정에 유리하며 append 연산에 적합합니다.

문자열 수정할 때, 필요에 따라 메모리 재할당과 확장도 가능합니다. 

즉, 메모리 사용량 측면에서는 조금 불리할 수 있지만 대신 수정과 확장에 유리한 일반 목적 구조라고 생각하면 될 것 같습니다.


3-4 왜?

왜 int, embstr, raw로 이렇게 인코딩 방식을 나눌까요?

 

Redis가 String 내부 인코딩을 여러 개로 나눠놓은 이유는 상황별로 메모리 사용량과 성능을 최적화하기 위해서입니다.

 

예를 들어 보겠습니다.

hello를 저장해야한다고 생각해보겠습니다.

만약 “hello”로 저장을 하게 된다면, 5바이트에 저장됩니다.

반면에, embstr 인코딩을 한다면 약 25바이트를 생성하게 됩니다.

raw 로 저장하게된다면 30바이트를 생성하게 됩니다.

물론 지금 보았을 때는 끽해야 5바이트 차이인 것이지만, 천만개 저장하면 이 차이는 기하 급수적으로 커질 수 밖에 없습니다.

이에 작은 부분 부터 최적화 되도록 만든 것입니다.

 


4. 기본적인 String 명령어

이제 기본적인 String 명령어들을 하나하나 다뤄보도록 하겠습니다.

Redis String을 다룰 때 가장 기본이 되는 명령어인 SET과 GET부터 다뤄보도록 하겠습니다.


4-1. SET

SET은 키와 값을 저장하는 가장 기본적인 명령어입니다.

SET users:1000:name "김철수"

 

위와 같이 명령어를 사용하게 됩니다.

SET (키이름) (저장값) 순서로 배치하여 저장하는 것입니다.

이때 너무 긴 키는 메모리 낭비를 유발하므로 키이름을 어떻게 설계하느냐가 중요할 것 같습니다. 

그래야 나중에 시각화 도구에서 폴더구조로 파악을 할 때도 보기 좋게 판단이 가능한 것입니다.

 

이 명령어의 의미는 매우 단순합니다.

  • 키에 값을 저장한다
  • 기존 값이 있으면 덮어쓴다

중요한 것은 바로 아래에 적은 기존 값이 있으면 덮어쓴다는 것입니다.

또한 기존 값의 형태와는 무관하게 동작합니다. 

해당 키가 다른 값으로 사용되고 있었더라도 새로운 값으로 변경 가능한 것입니다.


4-2. GET

GET은 저장된 값을 조회할 때 사용하는 명령어입니다.

 

GET users:1000:name

 

예를 들어 적은 위 코드의 동작은 아래와 같습니다.

  • 키가 존재하면 값을 반환합니다.
  • 키가 존재하지 않는다면 nil을 반환합니다.

여기서 nil은 해당 키 값이 존재하지 않는다는 의미를 나타내는 특별한 반환값입니다.

 

즉, 어플리케이션은 GET 결과가 nil인지 확인함으로써 해당 키가 없는 상태인지를 판별할 수 있습니다.

또한, GET은 기본적으로 String값 조회 명령어이므로 String 타입으로 저장된 값을 읽는데 사용한다고 이해하시면 됩니다.

 

보통 SET으로 문자열이나 직렬화 데이터를 넣고, GET으로 꺼낸뒤, 어플리케이션에서 역직렬화하는 흐름을 사용한다는 생각이 듭니다.


4-3. INCR , DECR

위 두 명령어는 원자적 연산을 사용하는 명령어입니다.

 

왜 원자적 연산이 필요할까?

 

예를 들어 아래와 같은 상황을 생각해보겠습니다.

Race Condition 발생 (동시성 문제 발생)

(상황 : 게시물 1의 조회수가 100인 상황)

1. A가 현재 게시물 1을 읽음 → 조회수 101
2. B도 동일한 시점에 게시물 1을 읽음 → 조회수 101

 

실제로는 두 명이 읽었기에 102가 되어야하지만 결과는 101이 되는 것입니다.

이런 문제가 바로 Race Condition입니다.

그리고 위와 같은 문제들을 해결하기 위해 Redis의 원자적 연산이 필요한 것입니다.

원자적 연산이란 한번 실행되는 연산을 의미하는 것이 아닙니다.

원자적 연산이란, 한번에 완료되는 연산을 의미합니다.

Redis의 Atomic Operation


INCR users:1000:visits

 

INCR 연산은 읽고, 1을 증가시켜서 다시 저장하는 과정을 하나의 원자적 명령으로 수행합니다.

3단계로 이루어지는게 1번에 진행되기에 중간에 다른 클라이언트가 끼어들 수 없습니다.

그래서 동시성 문제 없이 안전하게 카운터를 증가시킬 수 있는 것입니다.

 

A가 INCR 실행하면 100이 101이 되는 것이고, B가 INCR로 실행하면 101이 102가 되는 것입니다.

만약 기존에 키가 없다면? 레디스는 자동으로 0으로 초기화해서 바로 1증가 시킵니다.

단, 값이 정수가 아니라 "hello"와 같은 문자열에 INCR를 한다면 동작하지 않고 에러가 발생합니다.


DECR stock:product:1

 

DECR연산도 동일하게 원자적 감소 연산입니다.

값이 없다면 동일하게 0으로 간주하고 1을 감소시킵니다. 

즉, -1로 저장되는 것입니다.

INCR와 동일한 성질을 가지되 방향만 반대라고 생각하면 편할 것 같습니다.


4-4 INCRBY, DECRBY

증가 또는 감소 폭을 1이 아니라 원하는 수치로 조정하고 싶을 때는 INCRBY, DECRBY를 사용합니다.

INCRBY users:1000:visits 5
DECRBY stock:product:1 3

 

이 명령어들 역시 원자적으로 동작합니다.

둘다 지정한 값만큼 증가 혹은 감소 시키며 동시성 문제 없이 안전하게 사용 가능합니다.

그렇기에 조회 수 증가, 재고 감소, 포인트 적립, 다운로드 횟수 제한, 사용량 카운팅 (대시보드)와 같은 곳에 잘 어울릴 것 같습니다. 


4-5. MSET, MGET : 여러 키를 한 번에 처리하기

마지막으로 말씀드릴 명령어는 MSET과 MGET입니다.

여러 개의 키를 한 번에 처리할 때 주로 사용합니다.

 

RTT의 관점에서 생각해보겠습니다.

예를 들어 RTT가 1ms라고 가정해보겠습니다.

  • SET이 1번에 1ms의 왕복비용이 들게 됩니다.
  • 이런 SET을 10번만 하더라도 10ms의 왕복비용이 들게 되는 것입니다.

즉, 같은 양의 작업이라도 요청을 잘게 나눠서 보내면, 네트워크 왕복 비용이 커질 수 있습니다.

이럴때 MSET과 MGET을 사용하게 됩니다.


MSET을 사용하면 여러 개의 키-값을 한 번에 저장할 수 있습니다.

MSET users:1000:name "강슬빈" users:1000:email "xeulbn@test.com"

 

키와 값을 번갈아 나열하면서 여러 값을 한 번에 넣는 구조입니다.

이로 인해, 여러 키를 한 번에 저장가능하고 네트워크의 RTT를 감소시킬 수 있으며 단일 명령으로 처리되어 일관성 관점에서도 유리합니다.


MGET도 동일하게 여러개의 키-값을 한 번에 조회합니다.

MGET users:1000:name users:1000:email users:1000:age

 

대시보드에 여러 통계 수치를 한 화면에 보여줘야하거나 사용자 프로필 정보를 한 번에 가져와야 할 때, 여러 캐시 값을 한 번에 읽어오고 싶을 때 사용하면 좋을 것 같습니다.

 

각각 GET으로 호출하면 키 수 만큼의 왕복 비용이 발생하지만, MGET은 이를 한 번의 호출로 처리할 수 있습니다.

그렇다면 얼마까지 키를 넣어야 할까?

 

한 번에 너무 많은 키를 넣게 되면 문제가 발생합니다.

Redis는 기본적으로 단일 스레드 기반으로 명령을 처리합니다.

하나의 무거운 명령이 오래 걸리면 그동안 다른 명령들이 기다리게 되고, 병목이 발생합니다.

 

즉, 한 번에 적절한 개수로 나누어 처리하는 습관이 중요한 것 같습니다.

(이는 모든 기술, 모든 프로젝트에 적용되는 방법인 것 같습니다.)

예를 들어 수십 개에서 많아야 수백 개 수준 정도로 관리하는 식의 설계를 먼저 고민해보는게 좋을 것 같습니다.

 

Pipelining과 MSET/MGET의 차이??

 

Pipeline과 MSET/MGET의 차이가 헷갈릴 수 있다고 생각합니다.

Pipeline은 여러 명령어를 네트워크 레벨에서 한 번에 몰아서 보내는 방식입니다. 즉, 통신 효율은 좋아질 수 있습니다.

하지만 Pipeline은 각 명령어가 독립적으로 실행되는 구조이므로, MSET처럼 하나의 명령으로 원자적으로 처리되는 것은 아닙니다.

둘은 비슷해보여도 목적과 성질이 다르다는 것을 알아두면 좋을 것 같습니다.


5. TTL과 SET 옵션 (SETEX, SETNX, EX, PX, NX, XX)

SET 명령은 다양한 옵션과 결합되어 캐시, 세션, 중복 방지, 분산 락과 같은 패턴을 만들 수 있습니다.

이에 대해 알아보기 위해 TTL에 대해서 자세히 알아보고 옵션들을 알아보겠습니다.


5-1. TTL이 왜 필요한가

Redis는 메모리를 사용하는 저장소이기 때문에 오래된 데이터를 무한정 유지하면 결국 메모리 낭비가 됩니다.

또한 캐시 데이터는 일정 시간이 지나면 더 이상 유효하지 않을 수 있습니다.

예를 들어보겠습니다. 

날씨 정보는 결국 10분 정도 지나면 다시 갱신해야 합니다.

세션데이터는 일정 시간뒤 만료가 필요하고 인증 코드는 짧은 시간 뒤에 자동으로 삭제될 필요가 있습니다.

즉, Redis에서는 언제까지 이 데이터를  유지할 것인가를 함께 설계하는 것이 매우 중요하고 이게 바로 TTL인 것입니다.


5-2. SETEX

SETEX는 값을 저장하면서 동시에 만료 시간을 설정하는 명령어입니다.

SETEX session:abcd1235 3600 "session_data"

 

의미는 아래와 같습니다.

  • session : abcd1235 키에 "session_data" 저장
  • TTL은 3600초
  • 시간이 지나면 만료

즉, SET + EXPIRE를 한 번에 수행하는 개념으로 이해해주시면 됩니다.


5-3. SETNX

Set if Not Exist라고 생각해주시면 됩니다.

키가 존재하지 않을 때만 저장하는 것입니다.

SETNX lock:coupon:1001 "server-1"

 

이 명령의 의미는 키가 없으면 저장하고, 키가 없으면 저장하지 않습니다.

일반 SET이 무조건 덮어쓰기였다면, SETNX는 없을 때만 저장하는 명령어 입니다.

 

생각해보면 분산락을 구현할 때도 유리합니다.

여러 서버가 동시에 같은 작업을 수행하려 할 때, 각 서버가 같은 lock key에 대해 SETNX를 시도하도록 만들면,

Redis는 한 번에 하나만 성공 시키고 나머지는 실패하게 만들 수 있습니다.

 

그러면 성공한 서버만 작업을 진행하고 나머지는 대기하거나 포기하게 만드는 구조를 만들 수 있습니다.

이 때문에 Redis 기반 분산 락을 설명할 때 자주 등장하는 것 같습니다.


5-4. 요즘은... SET+옵션

최근에는 SETEX 자체보다는 SET 명령어에 옵션을 붙이는 방식도 많이 사용되는 것 같습니다.

SET session:abcd1235 "session_data" EX 3600

 

즉, SET 명령어 자체에 TTL 관련 옵션을 붙여서 사용하는 것입니다.


 

👀 마무리하며..

확실히 Redis를 공부하며 가장 기본이 되는 String이 중요하다는 것을 다시 느끼게 되었습니다.

무엇보다 신기했던건 Redis가 String 타입을 사용한다라는 것을 들었을 때는 단순히 String! 문자열!인줄만 알고 있었는데,

내부 구조를 파악해보니, Binary Safe 특성 덕분에 텍스트뿐 아니라 임의의 바이트 데이터를 저장할 수 있으며, SDS 구조 덕분에 문자열 길이 조회, append, 안정성 측면에서 일반 C 문자열보다 훨씬 효율적으로 동작한다는 것까지 알 수 있었습니다.

 

오랜만에 기본 개념을 다시 정리해보았습니다. Redis를 어느정도 사용해왔다고 생각했지만

막상 돌아보니 놓치고 있던 기초와 애매하게 알고 있던 부분들이 꽤 많았습니다.

역시 익숙하다고 생각하는 기술일수록 한 번쯤 다시 기본으로 돌아가 정리해보는 과정이 중요하다는 것을 느꼈습니다.

결국 탄탄한 기본기가 있어야 실무에서 마주치는 문제도 더 정확하게 이해하고 다룰 수 있는 것 같습니다.

 

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.