개발자는 기록이 답이다
Redis의 Increment는 왜 원자적으로 동작하는 것일까? 본문
저는 Redis가 싱글 스레드로 동작하는데도 불구하고, 특정 명령어를 사용할때 동시성 제어가 필요있고 없고의 차이점에 명확하게 아하! 모먼트를 가질 수는 없었습니다. 이러한 고민을 해결하기 위해 Redis 명령어들이 어떻게 동작하는지 살펴보았습니다. 이번 포스팅에서는 이 주제에 대해 알아보고자 합니다.
📌 왜 GET과 SET을 같이 사용하는 것은 동시성 이슈가 발생할까?
Spring Boot 프로젝트에서 Redis를 사용하다 보면, GET과 SET 명령어를 함께 사용할 때 동시성 이슈가 발생하는 경우가 있습니다. 그 이유는 Redis는 멀티플렉싱 I/O를 활용하여 여러 요청을 받으면서, 실제 요청들은 싱글 스레드로 순차적으로 처리하기 때문입니다.
참고: 아래 예시에서는 GET 대신 SCARD를, SET 대신 SADD를 사용하는 예시로 설명하겠습니다.
예를 들어, Redis에서 Set 타입으로 저장한 특정 키의 데이터 개수가 10개 이상이 되기 전까지 데이터를 추가하는 시나리오를 생각해보겠습니다.
if (redisTemplate.sCard("포인트ID:1") >= 10) {
throw new Exception;
}
redisTemplate.sAdd("포인트ID:1",임의의 객체);
여기서 Redis에서 사용하는 커맨드는 2개 입니다.
- SCARD
- SADD

여러 스레드가 동시에 Redis에 접근하여 Set의 크기를 조회하는 과정에서, 각 스레드는 redisTemplate.sCard("포인트ID:1") 명령어를 통해 현재 Set의 크기를 Redis로부터 반환받습니다. 이때 Redis 내부적으로는 아직 Set의 크기가 9개이므로 각 스레드에 대해 동일하게 9라는 값을 반환하게 됩니다.
하지만 이 반환 과정에서 각 스레드는 서로 독립적으로 동작하며, Redis에서 Set 크기를 반환받을 때는 다른 스레드가 데이터를 추가했는지 여부를 인지하지 못합니다. 즉, 모든 스레드는 조건문을 통과할 때 동일한 값(9)을 보고 있으며, 그에 따라 "포인트ID:1"이라는 키를 가진 Set에 새로운 데이터를 추가하는 sAdd 명령어를 실행하게 됩니다.
이 과정에서 동시성 이슈가 발생하게 되고 여러 스레드가 동시에 Set에 데이터를 추가함으로써, 결과적으로 Set의 크기가 예상보다 더 많이 증가하여 10개를 초과하는 상황이 발생할 수 있습니다.
그렇다면 여기서 "Redis가 싱글 스레드로 동작한다"라는 개념을 다시 한번 리마인드하면서 Increment 명령어에 대해 알아보겠습니다.
📌 Redis는 싱글 스레드로 동작한다
Redis가 싱글 스레드로 동작한다는 것은 한 번에 하나의 명령어만 처리한다는 뜻입니다. 즉, 모든 명령어는 순차적으로 실행되며, 그 명령어가 완료될 때까지 다른 명령어는 대기하게 됩니다. 이 때문에 INCR 같은 명령어는 원자적으로 처리됩니다. INCR 명령어는 하나의 연산으로 값을 가져오고, 증가시키고, 다시 저장하는 과정을 거치는데, 이 모든 과정이 하나의 연산으로 이루어지기 때문에 다른 명령어가 끼어들 틈이 없기 때문이죠.
📌 Redis의 INCR 내부 동작
그렇다면 INCR명령어의 내부 동작이 어떻게 구현되어있는지 Redis의 공식 Github 소스코드를 살펴보겠습니다.

incrDecrCommand() 함수는 아래와 같은 단계로 동작합니다.
- 키의 존재 여부 확인
- 먼저, lookupKeyWriteDictEntry() 함수를 사용하여 해당 키가 Redis 데이터베이스에 존재하는지 확인합니다.
- 만약 키가 존재하지 않으면, 그 키를 새로 생성하고 기본 값을 0으로 설정합니다.
- 값을 정수로 변환
- 키가 존재하고 값이 있을 경우, 그 값을 정수 값으로 변환합니다. 이때 변환이 실패하면 에러를 반환합니다.
- 변환된 값이 정수여야만 INCR 또는 DECR 작업을 수행할 수 있습니다.
- 증가 또는 감소 연산 수행
- 변환된 정수 값을 증가 또는 감소시킵니다. 여기서 주의할 점은 정수 오버플로우를 체크하는 것입니다.
- 오버플로우가 발생할 경우에도 에러를 반환합니다.
- 결과를 다시 저장
- 연산이 완료된 새로운 값을 Redis 데이터베이스에 저장합니다.
- 클라이언트에 결과 반환
- 마지막으로, 증가 또는 감소된 값을 클라이언트에 반환합니다.
Redis에서 INCR와 DECR 명령어는 내부적으로 incrDecrCommand() 함수를 호출합니다. 이 함수는 숫자 값을 증가시키거나 감소시키는 역할을 하며, 중요한 것은 이 과정이 싱글 스레드에서 함수 호출 한번으로 원자적으로 이루어진다는 점입니다. 즉, 여러 클라이언트가 동시에 값을 변경하려고 시도해도 동시성 문제가 발생하지 않도록 설계되어 있습니다.
📌 Cluster 환경에서도 Increment는 동시성 제어가 가능할까?
Redis를 사용하면서 가용성과 대용량 데이터 처리가 중요한 상황에서는 클러스터 환경을 운영하게 될 때가 있습니다. 이렇게 Redis 클러스터를 구성하면, 마스터 노드가 2개 이상이 되는 상황이 발생합니다.
그렇다면, 여러 마스터 노드가 존재하는 클러스터 환경에서도 INCR와 같은 명령어가 동시성 제어를 제대로 할 수 있을까요?
Redis 클러스터 환경에서는 해시 슬롯(Hash Slot)을 기반으로 키를 여러 마스터 노드에 분산합니다. 클러스터에는 총 16384개의 해시 슬롯이 존재하며, 각 마스터 노드는 이 슬롯들 중 일부를 담당합니다.

- 첫 번째 마스터 노드는 0부터 5460까지의 해시슬롯을 포함
- 두 번째 마스터 노드는 5461부터 10922까지의 해시슬롯을 포함
- 세 번째 마스터 노드는 10923부터 16383까지의 해시슬롯을 포함
레디스에 입력된 모든 키는 클러스터에 저장될 때, 하나의 특정 해시 슬롯에 매핑되며, 이때 해시함수는 아래와 같습니다.
아래 해시 함수를 활용해 그 슬롯을 담당하는 하나의 마스터 노드로만 라우팅됩니다.
HASH_SLOT = CRC16(key) mod 16384

즉, 특정 키는 항상 같은 마스터 노드에 저장되고 그 노드에서만 처리됩니다. 다른 마스터 노드는 그 키에 접근하지 않습니다. 이렇게 하면 동일한 키에 대한 요청이 항상 같은 마스터 노드에서만 처리되므로, 동시성 문제 없이 안전하게 키의 상태를 관리할 수 있습니다.
Redis 클러스터에서는 클라이언트가 처음 접속한 노드가 특정 키를 담당하는 노드가 아닐 경우, 해당 노드는 MOVE 리디렉션을 통해 클라이언트를 올바른 노드로 안내합니다.
(이렇게 클러스터 내 노드 간 설정 정보를 서로 공유하고 인지할 수 있는 이유는 Raft라는 분산 합의 알고리즘 때문입니다. 실전 레디스 p.627)

실제로 마스터 3개, 각 마스터에 레플리카 1개씩 구성된 Redis 클러스터를 구성하고 테스트한 결과는 다음과 같습니다.

쓰기 작업을 위해 각 마스터 노드에서 counter라는 키에 대해 INCR 명령어를 실행했더니, 하나의 노드를 제외하고는 MOVED 메시지가 발생하는 것을 확인할 수 있었습니다.
$ docker exec -it redis-node-1 redis-cli -p 6379 incr counter
$ docker exec -it redis-node-2 redis-cli -p 6379 incr counter
$ docker exec -it redis-node-3 redis-cli -p 6379 incr counter

현재 상황에서는 counter라는 키가 172.21.0.3:6379 노드에 할당되어 있기 때문에, 다른 노드에서 counter 키에 접근하려고 할 때 MOVED 메시지가 발생합니다. MOVED 메시지는 클러스터에서 키가 잘못된 노드에 요청되었을 때, 해당 키를 처리하는 올바른 노드의 정보를 제공하는 리디렉션 메시지입니다. 클라이언트는 이 메시지에 명시된 IP 주소와 포트 번호로 요청을 다시 보내야 작업이 정상적으로 완료됩니다.
현재 테스트에서는 수동으로 해당 IP와 포트로 다시 접근해야 하기 때문에, MOVED 메시지가 나온 후 직접 해당 노드에 INCR 명령어를 보내야 합니다. 하지만 이는 수동 리디렉션이고, 클러스터 모드에서 자동으로 리디렉션을 처리하는 방법도 있습니다.
일반적인 Redis 명령어(예: INCR, GET)를 클러스터에서 사용할 때 -c (클러스터 모드) 플래그를 사용하면, Redis 클라이언트가 자동으로 리디렉션을 처리합니다. -c 플래그를 사용하면 Redis 클러스터 내에서 MOVED 메시지를 자동으로 처리하여 올바른 노드로 요청을 보냅니다.
docker exec -it redis-node-1 redis-cli -c -p 6379 incr counter

이처럼 해시 슬롯을 기반으로 키를 분산 저장하기 때문에, 클러스터에서 여러 마스터 노드가 있는 상황에서도 INCR 명령어는 동시성 문제 없이 안전하게 처리됩니다.
📌 정리
"싱글 스레드로 동작한다"라는 개념은 알고 있었지만, 기존에 내부 구현 동작을 살펴보지 않아서 면접에서 애매한 답변만 했던 것 같습니다. 저는 추상적인것보다 구체적인 것을 선호하기 때문에 명확하게 와닿지 않으면 우물쭈물하게 됩니다. 면접 볼 당시에 Cluster환경에서 Increment가 동시성제어가 가능한 이유가 "정족수의 방식으로 각 노드에 쓰기 작업이 들어가게 되면 여러 노드간에 다수결로 OK한 것들만 처리된다"라는 이상한 추측성 답변을 내놨을때, 그건 낙관적락에 대한 내용인것 같은데 싱글 스레드와 관련해서 다시 생각해보시겠어요? 라는 피드백을 받았는데, 진작에 구현부를 살펴볼걸 후회되네요. 😅
이번 기회로 한 단계 더 자세히 배울 수 있어서 재미있습니다. 역시 모를땐 내부 구조 뜯어보는게 BEST이고, 이론을 같이 곁들여서 여러 가지 상황들을 가정하면서 생각해봐야 한다는 것을 깨닫게 되었습니다.
참고 :