중앙 세션 저장소, 왜 RDB가 아닌 Redis일까?
서론
안녕하세요!
혹시 분산 환경에서 sticky session이 불가한 상황에서 사용자의 상태를 관리하기위해 중앙 저장소 서버를 두신적이 있으신가요?
제가 참여한 프로젝트에서는 Redis를 중앙 세션 저장소 서버로 두었는데요. 저는 이런 의문이 들었습니다.
"이미 RDB가 있는데, 왜 굳이 Redis를 중앙 세션 저장소로 써야 할까?"
물론 단편적 지식으로 Redis가 메모리 기반이라 디스크 I/O 관점에서 봐도 빠를거 같아서 쓸거같기는하지만 이런 '~같다'가 아닌 직접 소스 코드를 보며 제 개발 내공을 쌓고 싶었습니다.
이 글에서 Redis를 중앙 세션 저장소로 쓰는 이유와 소스 코드까지 같이보며 의문을 해결해보려합니다.
RDB를 세션 저장소로 사용했을 때의 한계
먼저 RDB가 왜 세션 관리에 최적의 선택이 아닌지 알아보겠습니다.
먼저 세션 데이터의 특성을 정의해보자면
- 사용자 경험에 직접적으로 연관되기에 고속의 읽기, 쓰기가 요구됨
- 1:1 매핑되는 데이터(Key(sessionId) - Value(sessionData))
- HTTP의 무상태 특성 보완하기 위한 데이터라 상태 정보를 가짐
- 영구적으로 저장될 필요 없음
이렇습니다.
여기서 대부분 RDB로도 별 무리 없지만 1번 사항인 '고속의 읽기, 쓰기'가 걸리는군요.
만약 RDB에 Session Table을 생성하고 sessionId를 PK로 사용한다 가정해 봅시다. 이 테이블에 대규모 읽기, 쓰기를 한다면 어떻게 될까요?
InnoDB 엔진 기준으로, 쓰기 과정(세션 데이터 생성, 갱신)위해 레코드 락, 로그 기록, 인덱스 갱신, 락 해제 과정을 거칠 것입니다.
읽기 과정에서는 원하는 세션 데이터를 포함하는 페이지가 버퍼 풀에 없다면 디스크 I/O 가 발생할 수 있습니다.
'sessionData를 저장하고, 읽고싶다' 라는 단순한 요청 하나를 처리하기 위해 RDB 내부에서는 무거운 절차를 수행하게 되는 셈입니다.
세션은 영구적으로 저장이 필요없는 휘발성이 있어도 되는 데이터입니다. 만약 세션이 유실된다 해도, 사용자가 다시 로그인하여 새로 생성하면 그만입니다.
그런데 이런 데이터에 대해 락, 인덱스, 디스크 I/O 같은 비싼 리소스를 쓰는 건 너무 과해보입니다. '배보다 배꼽이 큰' 상황이라는 말이 어울립니다.
그러면 Redis라면 어떻게되는걸까요?
Redis는 어떻게 이 문제를 해결하는가? - 싱글 스레드, 이벤트 루프
주인공 Redis에 관해 드디어 알아보겠습니다. 깃헙에 공개된 Redis 소스코드와 함께 실제로 어떻게 구현됬는지 알아보겠습니다.
RDB가 많은 자원을 가지고 세션 관리를 했다면, Redis는 단순한 접근법을 선택했습니다.
바로 '싱글 스레드 이벤트 루프' 입니다.
이 지점에서 저는 "어?, 스레드가 하나인데 어떻게 RDB보다 많은 요청을 빨리 처리하지?"라는 의문이 생겼었는데요.
핵심은 '아예 원천적으로 경쟁을 없애고, 기다림도 없앤' 설계에 있습니다.
1. 경쟁 제거: 싱글 스레드와 원자적 명령어
Redis는 하나의 스레드에서 모든 명령을 처리합니다. 이게 왜 성능상 좋은걸까요?
'스레드가 하나뿐이기 때문에, 락이 필요 없습니다.'
스레드가 하나뿐이니, 특정 자원에 대해 A 요청이 GET을 하는 동시에 B 요청이 SET을 하려는 '경쟁 상태' 자체가 원천적으로 발생하지 않습니다. 모든 명령어는 큐에 들어온 순서대로, 하나씩, 원자적으로 실행됩니다.
그리고, 스레드가 하나뿐이니 스레드간 컨텍스트 스위칭이 없습니다. 이러면 시작된 명령은 집중적으로 실행될 수 밖에 없는것입니다.
RDB처럼 락을 걸고, 갱신하고, 해제하는 비용이 없어도 됩니다.👍
2. 기다림 제거: In-Memory와 I/O Multiplexing
하지만 싱글 스레드가 만능은 아닙니다. 만약 A의 요청을 처리하는 동안 디스크 I/O나 네트워크 I/O가 발생해서 스레드가 멈춘다면? 그 뒤의 모든 요청은 I/O가 끝날때 까지 대기해야합니다.
Redis는 이 문제를 두 가지 방법으로 해결합니다.
- In-Memory
- 모든 데이터를 메모리에 저장
- RDB의 가장 큰 병목이자 지연을 유발하는 디스크 I/O 자체가 없음
- I/O Multiplexing
- 네트워크 I/O는 이벤트 루프가 비동기(Non-Blocking)로 처리
- OS 커널의 이벤트 감시 기능(epoll, kqueue 등)을 활용
사실 전 I/O Multiplexing이 잘 이해되지 않았습니다. 혹시 저같은 분들이 계실까봐 제가 이해한바를 풀어 설명드리자면...
- 클라이언트 요청은 이미 네트워크로 들어(Input)와 커널 버퍼에 존재
- Redis는 소켓(fd) 읽기 가능 이벤트를 커널(epoll, kqueue 등)에 등록
- 커널이 '읽을 준비가 됐다'고 알리면, Redis 스레드는 해당 소켓에서 데이터를 읽어 명령어를 처리
- 명령어 처리 후 응답은 소켓 쓰기 가능 이벤트를 커널에 등록하고, 커널이 '쓸 수 있음' 신호를 줄 때만 실제로 출력(Output)
즉, Redis 스레드는 '데이터가 올 때까지', '응답을 보낼 수 있을 때까지' 멍하니 기다리지 않습니다. 이 덕분에 스레드는 대기 시간을 최소화하고, 연산에 집중이 가능한것입니다.
Redis 소스 코드 까봅시다
개념만 이렇게 하면 솔직히 블로그 가치도 없고 저도 재미가 없답니다.
이제 Redis 소스를 한번 다운받아 까보았습니다.
이벤트 루프 소스 코드(ae.c)를 직접 살펴보며, 위에서 설명한 내용이 어떻게 구현되어 있는지 확인해 보겠습니다.
ae.c의 acMain 함수
이 aeMain함수는 Redis 서버가 시작될 때 호출되는 메인 이벤트 루프입니다.
주석으로 흐름 추적을 작성해보았습니다.
void aeMain(aeEventLoop *eventLoop) {
// aeEventLoop 구조체의 stop 필드를 0으로 초기화함.
eventLoop->stop = 0;// 0(false)인 것임.
while (!eventLoop->stop) { // stop이 false이면 무한 루프!
// aeProcessEvents 이 함수가 계속 호출되는것을 확인할 수 있다.
aeProcessEvents(eventLoop, AE_ALL_EVENTS|
AE_CALL_BEFORE_SLEEP|
AE_CALL_AFTER_SLEEP);
}
}aeProcessEvents가 무한루프로 동작되는걸 확인했으니 다음엔 이 함수를 더 자세히 알아봅시다.
ae.c의 aeProcessEvents 함수
실질적인 이벤트 처리가 일어나는 곳입니다. 여기서 Redis는 '대기'과 '처리'를 반복합니다.
// 기존에 주석이 있는데 AE_ALL_EVENTS는 모든 종류의 이벤트 처리, AE_FILE_EVENTS는 파일 이벤트 처리 등 이런 내용이 존재했습니다.
// 지면상 삭제했으니 참고하시고 싶으신분들은 원본 소스를 보시길 추천드려요.
// 함수가 `aeMain`의 루프 안에서 계속 호출됨. 참고로 이 함수는 처리한 이벤트 수를 반환한다.
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
int processed = 0, numevents;
// AE_TIME_EVENTS 도 아니고 AE_FILE_EVENTS 아니면 빨리 끝낸다. 즉, 이벤트 종류는 파일, 시간 2개지인것.
if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
// maxfd 최대 파일디스크립터가 -1이 아닌것. 즉, 파일 이벤트가 존재하거나, AE_TIME_EVENTS AND AE_DONT_WAIT가 아닌 경우 관한 분기문
if (eventLoop->maxfd != -1 ||
((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
int j;
struct timeval tv, *tvp = NULL; /* NULL means infinite wait. */
int64_t usUntilTimer;
// sleep(대기) 전에 실행할 사용자가 정의한 콜백(beforesleep) 실행
if (eventLoop->beforesleep != NULL && (flags & AE_CALL_BEFORE_SLEEP))
eventLoop->beforesleep(eventLoop);
/* ... */
// 이 분기문에서는 얼마 동안 이벤트를 기다릴지(sleep) 결정한다.
if ((flags & AE_DONT_WAIT) || (eventLoop->flags & AE_DONT_WAIT)) {
// flags, eventLoop의 flags가 AE_DONT_WAIT인 경우.
// 즉시 리턴, 안기다린다.
tv.tv_sec = tv.tv_usec = 0; // tv 필드값(초, 마이크로초)들을 죄다 0으로 함.
tvp = &tv;
} else if (flags & AE_TIME_EVENTS) {
// 가장 가까운 시간 이벤트까지의 시간을 계산하여 타임아웃으로 설정함.
// 다음 타이머 이벤트가 발생하기 전까지만 잠들어라라는 의미!
usUntilTimer = usUntilEarliestTimer(eventLoop);//마이크로초 단위 리턴함.
if (usUntilTimer >= 0) {
tv.tv_sec = usUntilTimer / 1000000;
tv.tv_usec = usUntilTimer % 1000000;
tvp = &tv;
}
}
// ⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️
// 여기가 바로 이벤트 루프가 블로킹되는 지점. 이벤트 발생 대기!
// 타임아웃 발생 OR 이벤트 발생시 리턴이 발생한다!
// OS의 멀티플렉싱 함수(epoll_wait, kqueue, select)를 호출
// numevents는 발생한 I/O 이벤트의 개수
// ⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️
numevents = aeApiPoll(eventLoop, tvp);// 이벤트 루프랑 timeValue포인터를 파라미터로 가진다.
// aeApiPoll 함수에서 깨어난 직후, numevents에 담긴 이벤트 개수만큼
// 루프를 돌며 각 이벤트에 미리 등록된 콜백 함수(읽기/쓰기 핸들러)를 실행합니다.
// *지면 관계상 이후 코드는 생략하겠습니다. 코드를 통해 알 수 있듯이, Redis의 이벤트 루프는 aeApiPoll을 통해 I/O 이벤트를 기다리고, 이벤트가 발생하면 즉시 깨어나 등록된 콜백 함수들을 순차적으로 실행하는 심플한 구조로 구현되어있습니다.
정리 및 후기
Redis의 성능의 원천을 알고나니 왜 Redis를 세션저장소로 써야했는지 명확해졌습니다.
읽기, 쓰기에 드는 비용이 RDB와 비교해서 현저히 적고, 심플합니다
특히 흥미로웠던 점은, 저는 항상 멀티 스레드를 쓰는 것이 최고라고 생각했었는데, 이번 공부를 통해 OS 커널 기능과 싱글 스레드의 조합만으로도 효율적으로 설계될 수 있다는 것에 놀랐습니다. CPU를 어떻게든 놀게두지 않겠다는 의지가 느껴졌습니다.
이번 글을 작성하면서 많은 것을 배울 수 있었습니다. 막연하게 '빠르다'라고만 알고 있던 Redis의 이벤트 루프를 코드 레벨에서 직접 살펴보며, 유명한 도구의 내부 구현을 분석할 수 있는 자신감도 얻었습니다.
읽어주셔서 감사합니다. 이 글이 도움이 되었기를 바랍니다.🙇🏻