Computer >> 컴퓨터 >  >> 프로그래밍 >> Redis

Redis로 웹 API 성능 극대화하기: 검증된 캐싱 최적화 가이드

글쓴이: Tarique Ejaz

소프트웨어를 설계할 때 성능은 반드시 고려해야 할 핵심 요소입니다. 특히 사용자 눈에 직접 보이지 않는 백엔드 영역일수록 그 중요성은 더욱 커집니다.

개발자들은 여러 가지 기법과 구현 방식을 통해 성능을 개선하곤 하는데, 그중에서도 가장 강력한 무기 중 하나가 바로 캐싱(Caching)입니다.

캐싱이란 데이터나 파일을 임시 저장 공간에 미리 보관해 두고, 필요할 때 즉시 꺼내 쓸 수 있도록 하는 메커니즘을 말합니다. 오늘날 캐싱은 웹 애플리케이션의 필수 요소가 되었으며, Node.js와 MongoDB로 구축한 웹 API 역시 Redis를 활용하면 놀라운 수준으로 성능을 끌어올릴 수 있습니다.

"캐싱은 100년, 200년 후에도 여전히 매우 중요한 역할을 하게 될 것입니다."

Redis란 무엇인가? 초보자를 위한 개요

공식 문서에 따르면 Redis는 인메모리(in-memory) 데이터 구조 저장소로, 데이터베이스, 메시지 브로커 또는 캐시 스토리지로 활용됩니다. 문자열(strings), 해시(hashes), 리스트(lists), 셋(sets), 범위 조회가 가능한 정렬 셋(sorted sets), 비트맵(bitmaps), hyperloglogs, 반경 쿼리가 가능한 지리 공간 인덱스, 스트림(streams) 등 다양한 자료구조를 지원합니다.

지원하는 자료구조가 꽤 많아 보이지만, 사실 대부분은 결국 문자열 형태로 환원됩니다. 실제 구현 과정을 진행하다 보면 이해가 한결 명확해질 것입니다.

분명한 사실 하나는, Redis는 강력한 도구이며 올바르게 활용하면 애플리케이션을 더 빠르게 만들 뿐만 아니라驚くほど 효율적으로 동작하게 만들 수 있다는 점입니다. 이론은 이쯤 하고, 이제 본격적으로 코드를 살펴보겠습니다.

본격적으로 코드 살펴보기

시작하기에 앞서 로컬 시스템에 Redis를 설치해야 합니다. 간단한 설치 가이드를 따라 Redis를 먼저 실행해 주세요.

설치가 완료되었다면 시작해 보겠습니다. 예제는 Express로 만든 간단한 애플리케이션으로, MongoDB Atlas 인스턴스에 데이터를 읽고 씁니다.

/blogs 라우트 파일에는 두 개의 주요 API가 정의되어 있습니다.

...
// GET - 해당 유저의 모든 블로그 포스트 조회
blogsRouter.route('/:user')
 .get(async (req, res, next) => {
 const blogs = await Blog.find({ user: req.params.user });
 res.status(200).json({
 blogs,
 });
 });
// POST - 새 블로그 포스트 생성
blogsRouter.route('/')
 .post(async (req, res, next) => {
 const existingBlog = await Blog.findOne({ title: req.body.title });
 if (!existingBlog) {
 let newBlog = new Blog(req.body);
 const result = await newBlog.save();
 return res.status(200).json({
 message: `Blog ${result.id} is successfully created`,
 result,
 });
 }
 res.status(200).json({
 message: 'Blog with same title exists',
 });
 });
...

Redis 연결 준비하기

먼저 로컬 Redis 서버에 연결하기 위해 npm 패키지 redis를 설치합니다.

const mongoose = require('mongoose');
const redis = require('redis');
const util = require('util');
const redisUrl = 'redis://127.0.0.1:6379';
const client = redis.createClient(redisUrl);
client.hget = util.promisify(client.hget);
...

utils.promisify 함수를 활용해 client.hget 함수가 콜백 대신 프로미스(promise)를 반환하도록 변환했습니다. 프로미스화(promisification)에 대해 더 알고 싶다면 관련 문서를 참고하세요.

Redis 연결이 준비되었습니다. 본격적으로 캐싱 코드를 작성하기 전에, 우리가 충족해야 할 요구사항과 마주칠 가능성이 있는 문제들을 먼저 정리해 보겠습니다.

캐싱 전략은 다음 사항을 처리할 수 있어야 합니다.

  • 특정 유저의 모든 블로그 포스트 요청을 캐시에 저장
  • 새 블로그 포스트가 생성될 때마다 캐시 초기화

전략을 세우는 과정에서 주의해야 할 잠재적 과제는 다음과 같습니다.

  • 캐시 데이터를 저장하기 위한 키(key) 생성의 적절한 방법
  • 캐시 신선도 유지를 위한 만료(expiration) 로직과 강제 만료 처리
  • 재사용 가능한 캐싱 로직 구현

요구사항과 과제를 정리했고 Redis도 연결했습니다. 다음 단계로 넘어가겠습니다.

Mongoose 기본 exec 함수 오버라이딩

캐싱 로직은 재사용 가능해야 합니다. 나아가 데이터베이스에 어떤 쿼리를 날리기 전에 항상 거치는 첫 번째 체크포인트 역할도 하면 좋습니다. 이는 Mongoose의 exec 함수에 얹혀 가는(override) 간단한 트릭으로 손쉽게 구현할 수 있습니다.

...
const exec = mongoose.Query.prototype.exec;
...
mongoose.Query.prototype.exec = async function() {
 ...
 const result = await exec.apply(this, arguments);
 console.log('Data Source: Database');
 return result;
}
...

Mongoose의 프로토타입 객체를 활용해 쿼리 실행 시 가장 먼저 캐싱 로직이 동작하도록 만든 것입니다.

캐시를 쿼리 메서드로 추가하기

어떤 쿼리를 캐싱 대상으로 삼을지 표시하기 위해 Mongoose 쿼리 메서드를 하나 만듭니다. options 객체를 통해 해시 키(hash-key)로 사용할 user 값을 넘길 수 있게 합니다.

참고: 해시 키는 해시 자료구조를 식별하는 식별자입니다. 쉽게 말해 여러 개의 키-값 쌍을 담는 부모 키라고 생각하면 됩니다. 덕분에 더 많은 쿼리-값 집합을 효율적으로 캐싱할 수 있습니다. Redis의 해시에 대한 자세한 내용은 공식 문서를 참고하세요.

...
mongoose.Query.prototype.cache = function(options = {}) {
 this.enableCache = true;
 this.hashKey = JSON.stringify(options.key || 'default');
 return this;
};
...

이렇게 해두면 캐싱하고 싶은 쿼리에 cache(<options>)를 다음과 같이 간단히 붙여 사용할 수 있습니다.

...
const blogs = await Blog
 .find({ user: req.params.user })
 .cache({ key: req.params.user });
...

핵심 캐싱 로직 작성하기

어떤 쿼리를 캐싱할지 지정하는 공통 메서드를 만들었으니, 이제 중추적인 캐싱 로직을 작성해 보겠습니다.

...
mongoose.Query.prototype.exec = async function() {
 if (!this.enableCache) {
 console.log('Data Source: Database');
 return exec.apply(this, arguments);
 }
 const key = JSON.stringify(Object.assign({}, this.getQuery(), {
 collection: this.mongooseCollection.name,
 }));
 const cachedValue = await client.hget(this.hashKey, key);
 if (cachedValue) {
 const parsedCache = JSON.parse(cachedValue);
 console.log('Data Source: Cache');
 return Array.isArray(parsedCache) 
 ? parsedCache.map(doc => new this.model(doc)) 
 : new this.model(parsedCache);
 }
 const result = await exec.apply(this, arguments);
 client.hmset(this.hashKey, key, JSON.stringify(result), 'EX', 300);
 console.log('Data Source: Database');
 return result;
};
...

메인 쿼리에 cache()를 함께 사용하면 enableCache 값이 true로 설정됩니다.

이 값이 false이면 기본 exec 쿼리를 그대로 반환합니다. true라면 먼저 캐시 데이터를 가져오거나 갱신하는 데 사용할 키를 만듭니다.

키 이름의 고유성을 위해 쿼리 조건과 함께 collection 이름을 조합해서 사용합니다. 해시 키는 앞서 cache() 함수 정의에서 이미 설정한 user 이름입니다.

캐시된 데이터는 client.hget() 함수로 가져오며, 이 함수는 해시 키와 그에 해당하는 키를 파라미터로 받습니다.

참고: Redis에서 데이터를 가져올 때는 항상 JSON.parse()를 사용하고, 반대로 Redis에 무언가를 저장하기 전에는 키와 데이터 모두에 JSON.stringify()를 적용해야 합니다. Redis는 JSON 자료구조를 직접 지원하지 않기 때문입니다.

캐시 데이터를 확보했다면, 각 객체를 Mongoose 모델로 변환해야 합니다. 이는 new this.model(<object>)로 간단히 처리할 수 있습니다.

캐시에 필요한 데이터가 없다면 데이터베이스에 쿼리를 실행합니다. 그런 다음 API로 데이터를 반환하면서 client.hmset()으로 캐시를 갱신합니다. 이때 기본 캐시 만료 시간을 300초로 설정했는데, 이 값은 여러분의 캐싱 전략에 맞게 자유롭게 조정할 수 있습니다.

이것으로 캐싱 로직이 완성되었고 기본 만료 시간도 설정했습니다. 다음은 새 블로그 포스트가 생성될 때 캐시를 강제로 만료시키는 방법을 살펴보겠습니다.

강제 캐시 만료 처리

유저가 새 블로그 포스트를 작성하는 경우를 생각해 봅시다. 유저는 새 글이 곧바로 전체 포스트 목록에 나타나기를 기대합니다.

그러려면 해당 유저와 관련된 캐시를 삭제하고 새 데이터로 갱신해야 합니다. 즉, 강제 만료(forced expiration)가 필요합니다. 이는 Redis가 제공하는 del() 함수로 구현할 수 있습니다.

...
module.exports = {
 clearCache(hashKey) {
 console.log('Cache cleaned');
 client.del(JSON.stringify(hashKey));
 }
}
...

또한 여러 라우트에서 강제 만료를 호출해야 한다는 점도 염두에 둬야 합니다. 확장 가능한 방법 중 하나는 이 clearCache()를 미들웨어로 만들어, 해당 라우트의 쿼리 실행이 끝나면 호출되도록 하는 것입니다.

const { clearCache } = require('../services/cache');
module.exports = async (req, res, next) => {
 // 라우트 핸들러의 실행이 끝나기를 기다린 뒤
 await next(); 
 clearCache(req.body.user);
}

이 미들웨어는 특정 라우트에 다음과 같이 간단히 등록할 수 있습니다.

...
blogsRouter.route('/')
 .post(cleanCache, async (req, res, next) => {
 ...
 }
...

이것으로 완성입니다. 코드 분량이 상당했지만, 마지막 단계까지 마치면서 애플리케이션에 Redis를 통합했고 발생 가능한 거의 모든 과제도 해결했습니다. 이제 캐싱 전략이 실제로 어떻게 동작하는지 확인해 볼 차례입니다.

실제 동작 결과 확인하기

API 클라이언트로 Postman을 사용해 캐싱 전략의 실제 동작을 살펴보겠습니다. API 작업을 하나씩 순서대로 진행해 보겠습니다.

  1. /blogs 라우트로 새 블로그 포스트를 생성합니다.

새 블로그 포스트 생성 화면

  1. 유저 tejaz와 관련된 모든 블로그 포스트를 조회합니다.

유저 tejaz의 전체 블로그 포스트 조회 화면

  1. 유저 tejaz의 블로그 포스트를 한 번 더 조회합니다.

유저 tejaz의 블로그 포스트 재조회 화면

결과를 보면 캐시에서 데이터를 가져올 때 소요 시간이 409ms에서 24ms로 크게 줄어든 것을 확인할 수 있습니다. 이는 응답 시간을 약 95%나 단축해 API 성능을 폭발적으로 끌어올려 줍니다.

게다가 캐시 만료 및 갱신 작업도 의도한 대로 정확히 동작하는 것을 확인할 수 있습니다.

완성된 전체 소스 코드는 redis-express 폴더에서 확인할 수 있습니다.

마무리하며

캐싱은 성능에 민감하고 데이터 처리량이 많은 모든 애플리케이션에서 반드시 거쳐야 할 단계입니다. Redis를 활용하면 웹 애플리케이션에서 이를 손쉽게 달성할 수 있습니다. Redis는 매우 강력한 도구이며, 제대로만 활용한다면 전 세계 개발자와 사용자 모두에게 훌륭한 경험을 선사할 수 있습니다.

Redis 전체 명령어 목록은 공식 문서에서 확인할 수 있으며, redis-cli와 함께 사용하면 캐시 데이터와 애플리케이션 프로세스를 모니터링할 수 있습니다.

특정 기술이 제공하는 가능성은 정말 무궁무진합니다. 궁금한 점이 있다면 LinkedIn을 통해 저에게 연락해 주세요.

그동안 즐거운 코딩 되세요!

무료로 코딩을 배워보세요. freeCodeCamp의 오픈소스 커리큘럼은 4만 명 이상의 사람들이 개발자로 취업하는 데 도움을 주었습니다. 지금 바로 시작해 보세요.