Development/Infrastructure

[Redis] Node.js를 사용한 Redis 캐시 구현

Danny Seo 2023. 5. 26. 18:17

Node.js & Redis Cache

이 글에서는 Redis에 대한 이해와 Redis를 이용한 Cache의 적용, 간단한 설치법 및 명령어를 포함한 사용 방법을 알아보겠습니다. 더 나아가, NodeJs에서 Redis를 프로그래밍적으로 사용하는 방법에 대해서도 코드와 또한 함께 설명하도록 하겠습니다.

 

Redis에 대해서

Redis란 무엇인가요?

Redis는 Remote Dictionary Server의 약자로, 디스크가 아닌 주 메모리(RAM)에 모든 데이터를 보유하고 있는 데이터베이스 (In Memory Database) 라고 할 수 있습니다. 컴퓨터의 주 메모리(RAM)에서 실행되므로 디스크 검색이 필요한 다른 DBMS보다 자료 접근이 훨씬 빠른 것이 가장 큰 장점입니다. Redis는 NoSQL 데이터베이스와 마찬가지로 키-값 쌍을 가진 JSON 객체를 저장하는 스키마 없는 데이터베이스입니다. Redis는 스키마를 구축하고 데이터베이스를 초기화하는 시간이 필요하지 않기 때문에 애플리케이션 테스트를 빠르게 수행할 수 있고 이는 개발 생산성을 향상할 수 있습니다.

 

하지만 Redis는 휘발성입니다. 시스템이 갑자기 중단되면 Redis 내의 모든 것이 손실될 수 있다는 점을 고려해야 합니다. 그러나 Redis를 복제하여 데이터 백업을 생성할 수 있으므로 Redis 마스터 인스턴스가 다운되더라도 복제본은 계속 작동하고 데이터를 유지할 수 있습니다. Redis는 애플리케이션의 성능을 향상하기 위해 캐시로 자주 사용됩니다. 자주 접근하는 데이터나 계산에 많은 시간이 소요되는 데이터를 저장하여 이에 대한 빠른 접근을 제공할 수 있습니다.

 

Redis는 왜 빠를까요?

Redis가 단일 스레드로 설계되어 있지만 높은 성능을 발휘하는지 궁금하지 않으신가요?

왜 단일 스레드 디자인이 높은 성능을 낼까요? 병렬로 요청을 처리하기 위해 여러 스레드를 사용하는 것이 더 좋지 않을까요? 

 

Redis를 빠르고 효율적인 데이터 저장소로 만드는 설계 요소들에 대해 알아보겠습니다.

 

첫 번째 이유는 Redis는 인메모리 키-값 저장소입니다. 순수한 메모리 읽기는 빠른 읽기/쓰기 속도와 빠른 응답 시간을 제공합니다. (다만 저장하는 데이터가 메모리보다 크지 않아야 합니다.)

 

두 번째 이유는 IO 멀티플렉싱(다중화)의 사용입니다. Redis는 주로 단일 스레드를 사용합니다, 그러나  IO 멀티플렉싱 덕분에 운영 체제는 단일 스레드가 여러 개의 열린 소켓 연결에서 동시에 대기할 수 있습니다. 현재 하드웨어에서 단일 스레드 설계는 모든 CPU 코어를 사용하지 않을 수 있습니다. 따라서 일부 작업 부하에서는 하나의 서버에서 여러 개의 Redis 인스턴스를 실행하여 더 많은 CPU 코어를 활용하는 것이 일반적인 방법입니다.

 

세 번째 이유는 효율적인 저수준 데이터 구조의 채택입니다. Redis는 인메모리 데이터베이스이기 때문에 LinkedList, SkipList, HashTable, ZipList, SDS, IntSet 등 효율적인 저수준 데이터 구조를 많이 사용할 수 있습니다. 이러한 데이터 구조들을 디스크에 효율적으로 저장하는 방법을 고민할 필요가 없습니다.

 

레디스가 빠른 이유

Redis 캐시는 어떻게 작동하나요?

클라이언트가 데이터를 요청하면 서버는 먼저 Redis Cache에서 해당 키를 찾습니다. 만약 Redis Cache에 키가 있다면 Cache Hit가 발생하고 사용자는 캐시된 데이터를 받게 됩니다. 만약 Redis Cache에 키가 없다면 Cache Miss입니다. 서버는 데이터베이스나 REST API를 통해 가장 최신 정보를 가져올 것입니다.

레디스 캐시의 작동 방법

 

Redis 사용 방법

Redis 설치하기

[Mac 에서 설치하기]

mac은 brew를 이용하여 쉽게 설치할 수 있습니다. (Mac homebrew 설치법)

$ brew install redis

위의 명령어를 통해 Redis를 설치합니다. (시간이 다소 소요될 수 있습니다.)

$ brew services start redis
$ brew services stop redis
$ brew services restart redis

brew services start 명령어를 통해 Redis를 실행시켜 줍니다.

$ redis-cli

위의 명령어를 통해 CLI를 사용할 수 있습니다.


[Windows에서 설치하기]

링크 : https://kitty-geno.tistory.com/133

Redis 명령어

Redis 키(Keys) 명령어

Redis는 키에 대한 다양한 작업을 수행하기 위해 다음과 같은 기본 명령어를 제공합니다.

  • SET key value: 키-값 쌍을 설정합니다.
  • GET key: 주어진 키에 대한 값을 가져옵니다.
  • DEL key: 주어진 키를 삭제합니다.
  • EXISTS key: 키가 존재하는지 여부를 확인합니다.
  • KEYS pattern: 특정 패턴과 일치하는 모든 키를 찾습니다.
  • FLUSHALL: Redis 내부의 모든 데이터를 삭제합니다.
  • SETEX key seconds value: 주어진 시간(seconds) 후에 만료되는 키-값을 설정합니다.
  • TTL key: 키의 만료까지 남은 시간을 반환합니다.
(base) devloo@devlooui-MacBookPro ~ % redis-cli
127.0.0.1:6379> set key1 value1
OK
127.0.0.1:6379> set key2 value2
OK
127.0.0.1:6379> set key3 value3
OK
127.0.0.1:6379> get key1
"value1"
127.0.0.1:6379> get key2
"value2"
127.0.0.1:6379> get key3
"value3"
127.0.0.1:6379> keys *
1) "key3"
2) "key2"
3) "key1"
127.0.0.1:6379> exists key3
(integer) 1
127.0.0.1:6379> del key3
(integer) 1
127.0.0.1:6379> exists key3
(integer) 0
127.0.0.1:6379> keys *
1) "key2"
2) "key1"
127.0.0.1:6379> setex key3 10 value3
OK
127.0.0.1:6379> keys *
1) "key3"
2) "key2"
3) "key1"
127.0.0.1:6379> ttl key3
(integer) 5
127.0.0.1:6379> ttl key3
(integer) 4
127.0.0.1:6379> ttl key3
(integer) 3
127.0.0.1:6379> ttl key3
(integer) 2
127.0.0.1:6379> ttl key3
(integer) 1
127.0.0.1:6379> ttl key3
(integer) -2
127.0.0.1:6379> ttl key3
(integer) -2
127.0.0.1:6379> keys *
1) "key2"
2) "key1"
127.0.0.1:6379> flushall
OK
127.0.0.1:6379> keys *
(empty array)
127.0.0.1:6379>

 

Redis 리스트(List) 명령어

Redis 리스트는 간단한 문자열 목록입니다. Redis 리스트에서는 목록의 처음이나 끝에 요소를 추가할 수 있습니다.

  • LPUSH key value: 배열의 가장 왼쪽 끝에 요소를 추가합니다.
  • RPUSH key value: 배열의 가장 오른쪽 끝에 요소를 추가합니다.
  • LRANGE key startIndex stopIndex: 시작 인덱스와 종료 인덱스 사이의 요소 목록을 표시합니다.
  • LPOP key: 배열의 가장 왼쪽 요소를 제거하고 반환합니다.
  • RPOP key: 배열의 가장 오른쪽 요소를 제거하고 반환합니다.
(base) devloo@devlooui-MacBookPro ~ % redis-cli
127.0.0.1:6379> lpush subjects math
(integer) 1
127.0.0.1:6379> lrange subjects 0 1
1) "math"
127.0.0.1:6379> lpush subjects astronomy
(integer) 2
127.0.0.1:6379> lrange subjects 0 2
1) "astronomy"
2) "math"
127.0.0.1:6379> rpush subjects humanity
(integer) 3
127.0.0.1:6379> lrange subjects 0 3
1) "astronomy"
2) "math"
3) "humanity"
127.0.0.1:6379> lpop subjects
"astronomy"
127.0.0.1:6379> lrange subjects 0 2
1) "math"
2) "humanity"
127.0.0.1:6379> rpop subjects
"humanity"
127.0.0.1:6379> lrange subjects 0 2
1) "math"
127.0.0.1:6379>

 

Redis 셋(Set) Commands

Set은 배열과 달리 모든 요소가 고유합니다. 배열에서는 인덱스를 사용하여 요소를 검색할 수 있지만, Set에서는 키가 필요하므로 인덱스로 검색하는 것은 허용되지 않습니다. 배열은 삽입 순서를 유지하지만, Set은 순서가 없습니다. 즉, Set 내의 항목이 나타나는 순서를 예측할 수 없습니다. Set 명령어는 아래와 같습니다.

  • SADD key member: 집합에 멤버를 추가합니다.
  • SMEMBERS key: 주어진 집합의 멤버를 표시합니다.
  • SREM key member: 주어진 멤버를 집합에서 제거합니다.
(base) devloo@devloo-MacBookPro ~ % redis-cli
127.0.0.1:6379> sadd subjects math humanity astronomy chemistry
(integer) 4
127.0.0.1:6379> smembers subjects
1) "astronomy"
2) "humanity"
3) "chemistry"
4) "math"
127.0.0.1:6379> srem subjects chemistry
(integer) 1
127.0.0.1:6379> smembers subjects
1) "astronomy"
2) "humanity"
3) "math"
127.0.0.1:6379>

 

Redis 해쉬(Hash) 명령어

해싱은 단일 키 내에 키-값 쌍을 저장할 수 있게 합니다. 해시 명령어는 다음과 같습니다.

  • HSET key field value: 해시에 키-값 쌍을 설정합니다.
  • HGET key field: 해시 필드의 값을 가져옵니다.
  • HGETALL key: 해시의 모든 키-값 쌍을 가져옵니다.
  • HEDEL key field: 해시에서 주어진 필드를 삭제합니다.
  • HEXISTS key field: 해시 내에서 필드의 존재 여부를 확인합니다.
(base) devloo@devlooui-MacBookPro ~ % redis-cli
127.0.0.1:6379> hset address city Seattle
(integer) 1
127.0.0.1:6379> hset address state Washington
(integer) 1
127.0.0.1:6379> hset address country US
(integer) 1
127.0.0.1:6379> hget address state
"Washington"
127.0.0.1:6379> hgetall address
1) "city"
2) "Seattle"
3) "state"
4) "Washington"
5) "country"
6) "US"
127.0.0.1:6379> hexists address city
(integer) 1
127.0.0.1:6379> hdel address city
(integer) 1
127.0.0.1:6379> hexists address city
(integer) 0
127.0.0.1:6379> hgetall address
1) "state"
2) "Washington"
3) "country"
4) "US"
127.0.0.1:6379>

Node.js를 사용한 Redis 캐싱

GET 및 POST 엔드포인트가 있는 Express 애플리케이션을 생성합니다. 실습 예제에서는 외부의 가짜 REST API(jsonplaceholder.typicode.com/posts)를 사용하여 게시물 목록을 가져오고 새로운 게시물을 생성합니다.

 

아래 단계를 따라 애플리케이션을 설정하세요.

1. npm init -y 명령어로 package.json 파일을 생성합니다.

2. Express 앱에 필요한 모든 종속성을 다음과 같이 설치하세요:
    npm i express dotenv axios body-parser

3. npm i ioredis를 사용하여 Redis 종속성을 설치하세요.

4. package.json 파일은 다음과 같아야 합니다.

{
  "name": "redis-setup",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^1.1.3",
    "body-parser": "^1.20.1",
    "dotenv": "^16.0.3",
    "express": "^4.18.2",
    "ioredis": "^5.2.3"
  }
}

5. .env 파일에 Redis 호스트, 포트, TTL (Time-to-Live) 및 Timeout을 추가하세요. .env 파일에는 Redis 예제를 위한 기본 REST API URL도 포함됩니다. .env 파일의 내용은 아래와 같습니다.

REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_TTL=30
REDIS_TIMEOUT=5000
BASE_URL=https://jsonplaceholder.typicode.com/posts

6. index.js 파일 내에서 서버를 시작하세요.

require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const app = express();

app.use(bodyParser.json());

app.listen(8000, () => {
    console.log('server started!');
});

7. 다음 내용을 포함하는 caching.js 파일을 생성하세요.

  • host, portcommandTimeout으로 Redis 인스턴스를 생성합니다. 지정된 시간(밀리초) 내에 응답이 없으면 "command timed out" 오류가 발생합니다.
  • set() 메서드는 캐시 키-값 쌍을 설정하고 만료를 적용합니다. Redis 값은 항상 문자열이므로 데이터를 설정하기 전에 문자열로 변환해야 합니다.
  • get() 메서드는 키-값 쌍을 검색합니다.
  • del() 메서드는 캐시 키를 제거합니다.

 

아래는 caching.js의 예제 코드입니다.

 

const Redis = require("ioredis");
const { REDIS_HOST, REDIS_PORT, REDIS_TTL, REDIS_TIMEOUT } = process.env;

let redis;

// Redis 인스턴스 생성
(async () => {
    redis = new Redis({
        host: REDIS_HOST,
        port: REDIS_PORT,
        commandTimeout: REDIS_TIMEOUT
    });
    redis.on("error", (err) => {
        console.log(err);
    });
})();

// Redis 캐시에서 키 데이터 가져오기
async function getCache(key) {
    try {
        const cacheData = await redis.get(key);
        return cacheData;
    } catch (err) {
        return null;
    }
}

// 지정된 만료 시간으로 Redis 캐시 키 설정
function setCache(key, data, ttl = REDIS_TTL) {
    try {
        redis.set(key, JSON.stringify(data), "EX", ttl);
    } catch (err) {
        return null;
    }
}

// 주어진 Redis 캐시 키 제거
function removeCache(key) {
    try {
        redis.del(key);
    } catch (err) {
        return null;
    }
}

module.exports = { getCache, setCache, removeCache };

 

캐싱된 GET 메서드

외부 API에서 모든 게시물을 가져오기 위한 GET 엔드포인트인 /getAll을 생성합니다. 고유한 캐시 키를 선택하여 값을 저장해야 합니다. getCache(key) 메서드를 호출하여 캐시 키에 이미 값이 할당되어 있는지 확인합니다. 캐시에서 키를 찾으면 캐시 된 데이터를 사용자에게 제공하며, 이를 Cache hit라고 합니다. 캐시에서 키를 찾을 수 없으면 Cache miss로 간주하여 데이터를 REST API에서 가져오고 setCache(key, data) 메서드를 호출하여 값을 설정합니다. 아래 코드를 참조하여 GET 엔드포인트를 생성하세요.

require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios');
const { getCache, setCache } = require('./caching');
const app = express();

const { BASE_URL } = process.env;
const cacheKey = `getAll/posts`;

// 미들웨어
app.use(bodyParser.json());

// 게시물 가져오기
app.get('/getAll', async (req, res, next) => {
    try {
        const response = {};
        const cacheData = await getCache(cacheKey);
        if (cacheData) {
            response['message'] = 'cache hit';
            response['posts'] = JSON.parse(cacheData);
        } else {
            const result = await axios.get(BASE_URL);
            const { data } = result;
            response['message'] = 'cache miss';
            response['posts'] = data;
            setCache(cacheKey, data);
        }
        res.status(200).send(response);
    } catch (err) {
        res.status(400).send(err);
    }
});

app.listen(8000, () => {
    console.log('server started!');
});

 

캐싱되기 전 GET 메서드의 결과

GET 엔드포인트를 테스트하기 위해 Postman 도구를 사용합니다. 결과를 가져오기 위해 GET 요청을 보냅니다. 아래 이미지는 캐싱하기 이전의 GET 게시물의 결과를 보여줍니다.

캐싱되기 전 GET 메서드 테스트 결과

Redis 캐시에 새로운 키가 추가될 것입니다. 우리는 Redis에서 새로 추가된 키를 확인하기 위해 다음과 같이 키를 검사할 수 있습니다.

127.0.0.1:6379> keys *
1)"getAll/posts"
127.0.0.1:6379


캐싱 후 GET 메서드 결과

Redis에 캐시 키가 설정된 후 다시 GET 요청을 보내서 결과를 가져옵니다. 데이터는 Redis 캐시에서 가져올 것입니다. 아래 이미지는 캐싱 후 GET posts의 Postman 결과를 보여줍니다.

캐싱 후 GET 메서드 테스트 결과

 

Postman 테스트 결과를 주의 깊게 관찰하면 성능 차이를 확인할 수 있습니다. Redis 캐싱을 사용한 후 응답 시간이 크게 줄어들게 됩니다.

 

캐싱된 POST 메서드

새로운 게시물을 외부 API에 추가하기 위한 POST 엔드포인트인 /create을 생성합니다. 데이터는 req.body 내에 전달됩니다. 새로운 게시물이 추가될 때 이전에 설정된 Redis 키가 삭제되어야 합니다. 이때 removeCache() 메서드가 이를 수행합니다. 이렇게 함으로써 데이터 일관성을 유지할 수 있습니다. 아래 코드를 참조하여 POST 엔드포인트를 생성하세요.

require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios');
const { removeCache } = require('./caching');
const app = express();

const { BASE_URL } = process.env;
const cacheKey = `getAll/posts`;

// 미들웨어
app.use(bodyParser.json());

// 새로운 게시물 생성
app.post('/create', async (req, res, next) => {
    try {
        const response = await axios.post(BASE_URL, req.body);
        if (response) {
            const { data: posts } = response;
            removeCache(cacheKey);
            res.status(201).send(posts);
        }
    } catch (err) {
        res.status(400).send(err);
    }
});

app.listen(8000, () => {
    console.log('server started!');
});

 

POST 메서드의 결과

Postman 도구에서 POST 요청을 보냅니다. POST 요청의 본문은 JSON으로 전송됩니다. 아래 이미지는 게시물 생성 API의 결과를 보여줍니다.

POST 메서드의 테스트 결과

새로운 게시물을 생성하면 Redis 키가 삭제됩니다.

 

요약

지금까지 Node.js에서 Redis 캐싱을 활용하는 방법에 대해 알아보았습니다. Redis를 캐시로 사용하면 느린 하부 저장소 계층에 접근하는 필요성을 최소화하여 데이터 검색 성능을 개선할 수 있습니다. Redis는 단일 스레드에서 작동하지만 여전히 빠른 인메모리 데이터베이스입니다.