작성된 글의 프로젝트
https://github.com/Fingoo-org/Fingoo
들어가기 전
외부 API에서 주식, 금융 등의 관련 지표를 불러오는 로직이 프로젝트에 많은 부분을 차지하게 되었습니다. 이렇게 외부 API를 사용하게 되면 해당 기능을 직접 프로젝트에 구현하지 않아도 된다는 장점이 있지만, 그만큼 해당 API 서비스에 의존하게 되기 때문에 최소한으로 사용하고자 했습니다.
그럼에도 사용하게 될 API 발생했고, 이번에는 기왕 사용할 API의 데이터를 Redis를 활용해서 캐싱함으로써, expire되지 않은 동안의 안정성을 부여하고, 응답속도를 획기적으로 높일 수 있도록 해보았습니다.
💡 해당 글에서는 Redis의 사용방법보다는 프로젝트에 적용한 사례와 그 결과를 중심으로 서술하고 있습니다.! 참고해주세요 💡
Look Aside Cache 패턴
Look Asdie Cache 패턴을 통해 첫 데이터 요청 후 해당 데이터를 redis에 캐싱하고 데이터의 만료 시점 전까지 해당 요청을 redis에서 가져옵니다.
기존 redis를 통해 cache hit 시에 빠르게 요청을 수행하고 cache miss 시에만 동작시간이 오래걸리는 외부 API에 요청하기 위합니다.
주가 정보는 많은 사용자가 반복적으로 요청할 것으로 예상되고, 외부 API에 문제가 발생할 경우 서비스 문제에 대비할 수 있으므로 Look Asdie Cache 패턴을 적용했습니다.
예상하는 결과
- 기존보다 빠른 요청 속도를 예상하며, 외부 API에 대한 서비스 장애 의존도를 낮출 수 있습니다.
단점 및 리스크
- Look Asdie Cache 패턴의 고질적인 문제로 정합성 유지 문제가 발생할 수 있으며, 첫 요청 시에 꼭 외부 API를 요청해야합니다.
- 단건 호출 빈도가 높은 주가의 경우 적합하지 않을 수 있습니다.
- 해당 문제를 아래에서 추가적으로 다룰 예정이며, interval에 따라 expire 값을 다르게 주는 것으로 해결해보았습니다.
적용
구조도
- Redis에 검색하는 데이터가 있는지 확인 (cache hit)
- Redis에 없을 경우 외부 API에 데이터 요청 (cache miss)
- 외부 API에서 요청한 데이터를 Redis에 업데이트
Redis 적용 결과
Redis 사용 전
size: 200.06.KB 일 때, 응답시간 4.06s(4060ms)
Redis 사용 후
size: 200.06.KB 일 때, 응답시간 13ms
해당 작업으로 약 310배 (4060ms → 13ms) 작업이 빨라졌습니다.
외부 api가 아니고 db에 redis의 캐시 전략을 적용할 수 있다.
또한 이렇게만 보면 왜 안 쓰지? 라는 생각을 할 수 있지만, Redis는 In-Memory Data Store이므로 메모리 관리를 정말 잘해야한다.
항상 프로그램을 최적의 환경에서 기획할 수는 없기 때문..입니다.
이제 redis의 추가로 개선점을 확인했으니, application 레벨에서 더 개선할 점을 찾아보았습니다.
어떻게 하면 redis의 효용성을 극대화할 수 있을지에 대한 고민
우선 저의 서비스는 day, week, month, year 의 interval로 지표를 확인할 수 있도록 되어있습니다.
이때 지표의 종류만 약 10만개 이상이고, 각 지표의 interval마다 지표를 불러오는 외부 API콜이 있다면 사용자가 많아질 수록 부담이 될 수 밖에 없는 구조라는 생각이 들었습니다.
이에 최대한 필요없는 외부 API 콜을 줄이는 것이 application 레벨에서 할 수 있는 최적화라고 생각이 들었습니다.
redis key, redis expire 값 개선 계획
- 변경할 수 없는 조건: startDate를 기준으로 지표를 불러오기 때문에(startDate 기준으로 값이 있는 날부터), 요구사항이 변경되지 않는 이상 startDate에 따라서는 redis에 캐싱되는 key와 value가 변경됩니다.
- 단, 고객 수준의 요구사항이 아닌 클라이언트(프론트) 수준의 요구사항이고, startDate를 매번 바꾸며 요청하는 경우보다는 고정된 startDate를 요청하는 경우가 많기 때문에 해당 값에 종속된 key를 가진다고 하더라도 문제가 없다고 생각했습니다.
- 활용할 수 있는 조건: endDate(현재 날짜)
- interval은 day, week, month, year가 있고, 각 값들은 어떤 기준에 대해서 항상 같은 값을 가집니다.
- 예를들면 2024년 6월 데이터는 endDate가 6월 10일이든 6월 20일이든 바뀌지 않는다는 것입니다.
- 또한, 2024년 6월 1주차, 2주차가 달라지는 경우에만 지표값이 변경되고, 같은 주차의 경우에는 지표값이 변경되지 않게 됩니다.
- 이렇게 되면 각 day, week, month, year에 대한 구분값을 가지게 되는 경우 redis에서 불러오면 됩니다.
기존의 redis key
${indicatorDto.indicatorType}/live${indicatorDto.symbol}${interval}${startDate}${endDate}
- 입력된 startDate와 endDate(오늘 날짜)를 기준으로 해당 키를 탐색해서 일치하는 key값이 없다면, 새로 redis에 저장하는 방식이었습니다.
새로운 redis key
${indicatorDto.indicatorType}/live-${indicatorDto.symbol}-interval:${keyInterval}-startDate:${startDate}-redisExpiredKey:${redisExpiredKey}
- `redisExpiredKey` 를 추가하면 endDate를 기준으로 같은 날, 주차, 월별, 년별 값은 각각 24시간, 1주, 1달, 1년간은 새로 외부 api를 거치지 않아도 됩니다.
- 간단히, 같은 날인지, 같은 주차인지, 같은 달인지, 같은 해인지를 계산해서 `redisExpiredKey` 으로 설정하고, interval에 맞는 redis expire 값을 설정해주었습니다.
redis에 저장하게 되는 기준이 되는 key를 `redisExpiredKey` 라고 정의했고, 아래는 endDate(=currentDate, 오늘 날짜)를 기준으로 interval에 따라 불러오는 로직입니다.
private getRedisExpiredKey(currentDate: Date, interval: string): string {
switch (interval) {
case 'day':
return this.formatDayToString(currentDate);
case 'week':
return this.formatWeekToString(currentDate);
case 'month':
return this.formatMonthToString(currentDate);
case 'year':
return this.formatYearToString(currentDate);
default:
return this.formatDayToString(currentDate);
}
}
여기서 week의 경우 명확한 기준이 필요하기 때문에 ISO 주차를 구하는 함수를 추가하여 구현했습니다.
private formatDayToString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
private formatWeekToString(date: Date): string {
const year = date.getFullYear();
const week = this.getISOWeekNumber(date);
return `${year}-W${week}`;
}
private formatMonthToString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${year}-${month}`;
}
private formatYearToString(date: Date): string {
return String(date.getFullYear());
}
private getISOWeekNumber(date: Date): number {
const tempDate = new Date(date.getTime());
tempDate.setDate(tempDate.getDate() + THURSDAY_OFFSET - (tempDate.getDay() || DAYS_IN_A_WEEK));
const yearStart = new Date(tempDate.getFullYear(), 0, 1);
return Math.ceil(((tempDate.getTime() - yearStart.getTime()) / MILLISECONDS_IN_A_DAY + 1) / DAYS_IN_A_WEEK);
}
redis caching 로직
caching의 만료 시간 역시 각 날짜에 맞게 동적으로 변경해주었습니다.
만약 1주일, 1달, 1년을 각각의 시간대로 1주, 1달, 1년씩 expire값을 정해주게 되면 2024년 12월 31일에 캐싱 된 값은 무의미하게 2025년에도 약 1년간 메모리를 잡고 있어야하기 때문에 바로 메모리를 신경써야하는 redis의 특성상 이는 필수적인 로직입니다.
때문에 caching되는 순간에 맞도록 캐싱 만료 시간을 동적으로 지정해주는 로직을 추가해주었습니다.
만약, week를 기준으로 caching하게 되면 토요일일 경우 1일 + 여유시간 1일(이는 뚝떨어지게 caching했을 때의 사이드 이펙트를 막고자 했습니다.) 로 지정하게 됩니다.
- redis caching 관련전체 로직
async cachingLiveIndicator(key: string, indicatorDto: LiveIndicatorDtoType): Promise<void> {
const value: string = JSON.stringify(indicatorDto);
const expireTime = this.calculateExpireTime(key);
await this.redis.set(key, value);
await this.redis.expire(key, expireTime);
}
private calculateExpireTime(key: string): number {
const currentDate = new Date();
const intervalType = this.extractIntervalType(key);
return this.calculateRemainingSeconds(currentDate, intervalType);
}
private extractIntervalType(key: string): Interval {
if (key.includes('-interval:day-')) {
return 'day';
} else if (key.includes('-interval:week-')) {
return 'week';
} else if (key.includes('-interval:month-')) {
return 'month';
} else if (key.includes('-interval:year-')) {
return 'year';
}
}
private calculateRemainingSeconds(currentDate: Date, intervalType: string): number {
switch (intervalType) {
case 'day':
return SECONDS_IN_DAY;
case 'week':
return this.calculateWeekRemainingSeconds(currentDate);
case 'month':
return this.calculateMonthRemainingSeconds(currentDate);
case 'year':
return this.calculateYearRemainingSeconds(currentDate);
default:
return SECONDS_IN_DAY;
}
}
private calculateWeekRemainingSeconds(currentDate: Date): number {
const dayOfWeek = currentDate.getDay(); // 0 (일요일) ~ 6 (토요일)
let remainingDays = ((DAYS_IN_WEEK + JS_MONTH_CAL_NUM - dayOfWeek) % CALCULATING_WEEK_NUM) + EXTRA_DAY; // 주말까지 남은 일수 계산
if (remainingDays == 1) remainingDays += EXTRA_DAY;
return remainingDays * SECONDS_IN_DAY;
}
private calculateMonthRemainingSeconds(currentDate: Date): number {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const lastDayOfMonth = new Date(year, month + JS_MONTH_CAL_NUM, 0).getDate();
const remainingDays = lastDayOfMonth - currentDate.getDate() + EXTRA_DAY;
return remainingDays * SECONDS_IN_DAY;
}
private calculateYearRemainingSeconds(currentDate: Date): number {
const year = currentDate.getFullYear();
const lastDayOfYear = new Date(year, DECEMBER, LAST_DAY_OF_DECEMBER).getDate();
const remainingDays =
lastDayOfYear -
currentDate.getDate() +
(new Date(year, DECEMBER, LAST_DAY_OF_DECEMBER).getMonth() - currentDate.getMonth()) * DAYS_IN_MONTH +
EXTRA_DAY;
return remainingDays * SECONDS_IN_DAY;
}
caching 만료 시간 계산 방식은 아래와 같이 계획했습니다.
- Day는 하루
- Week는 아래와 같은 방식으로 해당 주에서 남은 날 계산
1 2 3 4 5 6 0
월 화 수 목 금 토 일
- 8 (남은 시간 계산용)
월 화 수 목 금 토 일
7 6 5 4 3 2 8
에서 8로 나눈 나머지
월 화 수 목 금 토 일
7 6 5 4 3 2 0
- Month는 해당 달의 마지막 날에서 남은 날 계산
- Year은 해당 년도의 마지막 날에서 남은 날 계산
결론
코드 비교 및 분석
- 키 생성 방식 변경
- 이전 코드에서는 키를 생성할 때 간단한 문자열 조합만 사용했으나, 개선된 코드에서는 interval과 endDate에 따라 동적으로 생성되는 `redisExpiredKey` 키를 사용했습니다.
- 이로 인해 데이터의 정확성과 유효기간 관리가 향상되었습니다.
- Redis Expire 설정
- 개선된 코드에서는 interval에 따라 Redis에 저장된 데이터의 유효 기간을 설정하는 방식을 도입했습니다.
- 이전 코드에서는 단순히 현재 날짜를 기반으로만 expire를 설정했으나, 새로운 방식에서는 interval에 따라 적절한 expire 기간을 설정합니다.
위와 같은 로직을 추가 구현하면서 시스템의 성능을 최적화하고, 외부 API 호출을 최소화하여 시스템 부하를 줄이는 데 기여하고자 했습니다.
생각 자체는 단순할 수 있지만 redis의 장점과 단점을 미리 생각해보고 직접 구현 방식을 설계하면서, 실제 프로덕트에서 어떤 변화를 가져올지 기대되는 작업이었기에 재밌다고 느껴진 작업이었습니다.
메모리야 버텨줘..!