금융서비스 Redis 적용기- Read, Write, Transaction 과 Event Strategy

김형래
23 min readJul 10, 2024

--

안녕하세요, FINDA 현금흐름 PG 백엔드 개발자 김형래 입니다.

이번 글에서는 현금흐름 PG 의 금융서비스 안에서 Redis 를 이용해 Read, Write, Transaction 과 Event Strategy 가 무엇인지를 살펴보고 실무에서 어떻게 적용했는지 알아보도록 할게요!

Redis Cache Strategy 란 ?

Redis Cache Strategy 는 웹 서비스 환경에서 시스템의 성능을 향상시킬 수 있는 중요한 기술입니다. Cache 는 RAM 을 사용하기 때문에 DB(Database) 보다 더욱 빠르게 데이터를 응답할 수 있어 사용자에게 빠른 서비스를 제공합니다. Cache 를 이용할 경우, 반드시 데이터 정합성이라는 문제를 만나죠!

데이터 정합성이란, 데이터가 Cache 와 DB 사이에서 같은 데이터라도 정보값이 서로 다른 현상을 말합니다. 쉽게 말해, 대출 정보가 Cache 에는 3개인데, DB 에서는 5개로 저장된 경우에 데이터 불일치가 발생합니다. 따라서 적절한 Read / Write Cache Strategy 를 통해 데이터 Cache 와 DB 간의 데이터 불일치를 해소하고, 빠른 응답 속도로 사용자에게 서비스를 제공해야 합니다.

이제 아래와 같이 Redis Cache Strategy 종류에 대해서 상세히 알아보고자 합니다. 실무에서는 Look Aside 와 Write Back Pattern 을 사용했습니다.

Read Cache Strategy

Look Aside Pattern

  • 가장 일반적으로 사용되는 Pattern
  • Cache Aside 패턴으로 불림
  • 데이터 조회 시 먼저 Cache 에 저장된 데이터를 찾는 전략으로 Cache 에 데이터가 없다면 DB 조회
  • 반복 조회가 많은 호출용에 적합, 단일 건 호출 빈도 높은 서비스에 부적합
  • Cache 와 DB 가 분리되어 있어 원하는 데이터만 별도로 구성하여 Cache 에 저장(장애 대비 구성)
  • Redis Down 시, DB 에서 데이터를 조회하므로 서비스 안정성 유지(장애 대응)
  • Cache 를 사용하는 connection 이 많았다면, Redis Down 시, 일시적 DB 부하 발생
Look Aside Pattern

아래는 Look Aside Pattern 를 적용한 코드입니다.

public ResponseDto getList(Long userId, String startDate, String endDate) {

ResponseDto responseDto = ResponseDto.builder().build();

// 1.cache 값이 존재하는지 확인
boolean isCacheExists = cacheService.isExistInfo(userId, startDate,
endDate);

if (isCacheExists) {
// 2. cache 값이 존재한다면, cache 정보로 응답값 저장
responseDto = cacheService.getInfo(userId, startDate, endDate);
}

if (!isCacheExists || ObjectUtils.isEmpty(responseDto)) {
// 3. cache 또는 응답값이 없는 경우, DB 정보로 응답값 저장
responseDto = getInfoByDb(userId, startDate, endDate);
...
}

return responseDto;
}

반복 조회가 많은 API 에서 사용하는 Method 로 간단한 구현으로 빠른 응답처리가 장점인 Look Aside Pattern을 사용했습니다. 먼저 cache 값을 확인하고, 존재한다면 cache 값으로 응답값을 저장합니다. 만약 cache 값 또는 응답값이 없을 경우, DB 정보로 응답값을 저장하도록 처리합니다.

Read Through Pattern

  • Cache 에서만 데이터를 가져오는 Pattern
  • Look Aside 와 유사하나, 데이터 동기화를 라이브러리 또는 Cache 제공자에게 위임하는 방식
  • 데이터 조회 속도 느림, 조회가 많은 호출용에 적합
  • 데이터 조회를 Cache 로만 처리하고 Redis Down 시, 서비스 장애 발생
  • Cache 와 DB 간 데이터 동기화가 항상 이루어져 데이터 정합성 문제 없음
  • DB Connection & Read 자원 최소화
  • Replication 또는 Cluster 구성으로 가용성 증가 필요
Read Through Pattern

Write Cache Strategy

Write Back(Behind) Pattern

  • Cache 와 DB 동기화를 비동기처리로 동기화 과정 생략
  • 데이터를 저장할때 DB 에 바로 조회하지않고, Cache 에 저장해서 일정 주기 배치 작업을 통해 DB 에 반영
  • Write 횟수 비용과 부하 감소, 일정 시간 이후, 데이터 정합성 확보
  • Write 가 자주 일어나고 Read 를 하는데 대량의 Resource 가 필요한 서비스에 적합
  • Cache 에서 오류가 발생하면 데이터를 영구 소실함
  • DB 장애 시, 지속적인 서비스 제공 가능
Write Back Pattern

아래는 Write Back Pattern 를 실무에 적용한 코드입니다.

// 1.Queue 에 저장한 cache 값이 조회
private <T> List<T> getCacheDataByQueue(String queueName, Class<T> clazz) {
// redisson distributed lock 잠금 처리
if (!redisCommandHelper.checkQueueAndTryLock(queueName, 3, 2)) {
return Collections.emptyList();
}

List<T> entityList = new ArrayList<>();

try {
// Queue 에서 JDBC Bulk Size 만큼 cache 값 조회
int cacheDataSize = redisCommandHelper.getQueueSize(queueName);
if (cacheDataSize >= jdbcCardRefreshBulkSize) {
entityList = getCacheDataListByQueue(queueName, jdbcCardRefreshBulkSize, clazz);
} else {
entityList = getCacheDataListByQueue(queueName, cacheDataSize, clazz);
}
} finally {
// redisson distributed lock 잠금 해제
redisCommandHelper.unLock(queueName);
}

return entityList;
}

private <T> List<T> getCacheDataListByQueue(String key, int bulkSize, Class<T> clazz) {
List<Object> cacheDataList = redisCommandHelper.getCacheDataListByQueue(key, bulkSize);

// cache 값 파싱 후 리턴
if (cacheDataList != null) {
return cacheDataList.stream()
.map(entity -> cmsDataParser.parse(entity.toString(), clazz))
.toList();
}

return Collections.emptyList();
}


// 2. 매 100ms 마다 Scheduler 로 cache 값을 DB 에 저장
@Transactional
@Scheduled(fixedDelay = 100)
public void checkCacheDataAndInsertForPaymentInfo() {
String queueName = redisKeyProperties.getQueue().getCms().getCardPaymentInfoInsert()
.getPrefix();

List<CmsCardPaymentInfoEntity> cmsCardPaymentInfoEntityList = getCacheDataByQueue(queueName,
CmsCardPaymentInfoEntity.class);

if (!ObjectUtils.isEmpty(cmsCardPaymentInfoEntityList)) {
try {
bulkInsertCardPaymentInfo(cmsCardPaymentInfoEntityList);
} catch (Exception e) {
LogUtil.writeErrorLog(new ErrorLog(e));
}
}
}

public void bulkInsertCardPaymentInfo(
List<CmsCardPaymentInfoEntity> cmsCardPaymentInfoEntityList) {
// cache 값 JDBC 로 bulk insert 처리
this.jdbcTemplate.batchUpdate(CMS_CARD_PAYMENT_INFO_BULK_INSERT_SQL,
cmsCardPaymentInfoEntityList, jdbcCardRefreshBulkSize,
(ps, arg) -> {
ps.setString(1, arg.getOrgCode());
...
});
}

MYDATA Refresh API 응답은 Write 가 빈번하여 Write 비용을 줄이고 데이터 불일치가 일어나도 API 재전송을 통해 업데이트를 하면 되는 구조라서 Write Back Pattern 을 적용했습니다. 위에 코드를 보면 MYDATA Portal 에서 조회한 API 응답을 Client 에 먼저 전송합니다. 그리고 Queue 에 cache 값으로 저장할 데이터(API 응답)를 저장합니다. 이후, 저장한 cache 값을 조회해서 Scheduler 에서 cache 값을 파싱해서 entity 를 JDBC 로 Bulk Insert 처리합니다. Redisson distributed locks 을 사용해서 Write 시 lock 을 걸어서 처리 했는데 이 부분에 대한 상세한 내용은 아래 github 링크에서 확인이 가능합니다.

Write Through Pattern

  • DB 와 Cache 동시에 데이터를 저장하는 전략
  • 데이터를 저장할 때 먼저 Cache 에 저장 후, DB 에 저장
  • Read Through 와 마찬가지로 DB 동기화 작업을 Cache 에게 위임
  • DB 와 Cache 가 항상 동기화 되어 있어, Cache 데이터는 항상 최신 상태
  • DB 와 Cache 에 동시에 업데이트하여 데이터 일관성 유지로 안정적
  • 데이터 유실이 발생하면 안 되는 상황에 적합
  • 자주 사용되지 않는 불필요한 리소스 저장
  • 매 요청마다 DB 와 Cache 에 동시에 업데이트되므로 빈번한 생성/수정이 발생하는 서비스에서는 성능 저하
  • 기억장치 속도가 느릴 경우, 데이터를 기록할 때 CPU가 대기하는 시간이 필요하기 때문에 성능 감소
Write Through Pattern

Write Around Pattern

  • Write Through 보다 훨씬 빠름
  • 모든 데이터는 DB 에 저장 (Cache 비갱신)
  • Cache miss 가 발생 시, DB 와 Cache 에 데이터 저장
  • Cache 와 DB 내의 데이터 불일치 발생
  • 데이터 변경 시(수정, 삭제) Cache 를 evict 또는 rewrite 하며, ttl 을 짧게 조정 필요
Write Around Pattern

Declarative Annotation-based Caching

Spring 의 Annotation 기반으로 Cache 데이터를 관리하는 방법도 존재합니다. 이 방식은 Spring 공식사이트에 자세히 기술되어 있어서 개발 시 참고하시면 좋습니다.

위에 Redis Cache 의 Read/Write Pattern 을 표로 정리하면 아래와 같습니다.

Pattern’s Description

Redis Transaction 란 ?

Redis 에 저장된 Cache 값에 대한 동시성 처리를 위해 Transaction 을 사용합니다. 그리고 아래와 같은 장단점이 존재합니다.

장점

  • Thread 생성, Context Switching 으로 인한 오버헤드 감소
  • 동시성 문제로 인한 복잡성, 오버헤드 감소(Transaction 기능으로 Atomic 보장)

단점

  • 특정 연산이 지연되면, 해당 로직에서 장애 및 병목 발생 가능 높음
  • CPU 멀티코어를 활용하기 어려움(Multi Core → Multi Process 방식 처리, Clustering)

아래 코드는 Redis Transaction 을 적용한 코드입니다.

@Component
public class RedisCommandHelper {

// RedissonClient 의 Transaction 조회
public RTransaction getTx() {
return redissonClient.createTransaction(TransactionOptions.defaults());
}
...
}


@Service
@RequiredArgsConstructor
public class CardCacheRequestService {

public void setRedisCompleteCardBillRefreshInfo(Long userId, String redisKey,
List<RedisCardBillInfoDto> cardOrgInfoList) {

// RedissonClient 의 Transaction 조회
RTransaction tx = redisCommandHelper.getTx();
RMap<Object, Object> cardBillInfoMap = tx.getMap(redisKey);

// Cache 데이터 저장
cardBillInfoMap.put(CARD_REFRESH_CARD_ORG_INFO_LIST_FIELD, cmsDataParser.parseToString(cardOrgInfoList));

// Cache 데이터 저장
if (isCardBillInfoRefreshComplete(cardOrgInfoList, userId)) {
cardBillInfoMap.put(CARD_REFRESH_STATUS_FIELD,
CardRefreshStatus.DONE.toString());
}

// RedissonClient 의 Transaction Commit
tx.commit();
}
...
}

MYDATA Portal 에서 조회한 API 응답을 Cache 로 저장한 뒤에 Cache 값의 동시성을 보장하면서 수정하는 로직이 필요했습니다. 그래서 RedissonClient 의 Transaction 을 가져와서 Cache 값을 저장하고 Commit 하는 과정을 보여주고 있습니다. 해당 과정에서는 Cache 값을 Atomic 하게 처리하므로 동시성을 보장합니다.

Redis Event Strategy 란 ?

Redis 는 Redis 들 간의 메세지 통신을 위해 Publish/Subscribe 기능을 제공하고 있습니다. Redis 2.8.0 부터는 Key 에서 발생하는 Event 에 대해서 알림을 설정할 수 있습니다. redis.conf 설정 파일을 살펴보면 notify-keyspace-events 옵션을 통해 redis 에서 발생하는 이벤트에 대한 알림 설정이 가능합니다. 기본적으로 Redis Event 알림은 성능적 오버헤드가 발생할 수 있어서 비활성화(“”)되어 있습니다.

이벤트 종류는 아래와 같습니다(관계형 DB의 Trigger 기능과 유사).

  • 키(데이터) 입력/변경 : set 같은 명령으로 키/값이 새로 입력되거나 수정될 때
  • 키 삭제 : del 같은 명령으로 키가 삭제될 때
  • 키 만료 시간 설정으로 키가 삭제될 때
  • Max Memory 정책으로 키가 퇴출(eviction)될 때

이벤트 발생 시, Channel 유형에는 2가지가 있습니다.

  • 키 중심 : __keyspace@<dbid>__:key command
  • 명령 중심 : __keyevent@<dbid>__:command key

실무에서는 명령 중심으로 구현했습니다. notify-keyspace-events 의 종류는 아래와 같습니다.

  • K : Keyspace events, publish prefix “__keyspace@<dbid>__:key command”
  • E : Keyevent events, publish prefix “__keyevent@<dbid>__:command key”
  • g : Common Command(del, expire, rename …)
  • $ : String Command
  • l : List Command
  • s : Set Command
  • h: Hash Command
  • z: Sorted set Command
  • x: Expired Event (키가 만료 시, 생성되는 이벤트)
  • e: Evicted Event(Maxmemory 정책으로 키 삭제 시, 생성되는 이벤트)
  • A: All Event(g$lshzxe), “AKE”로 지정하면 모든 이벤트 수신

기본값 K 또는 E 중 하나를 설정해야 알림이 발송됩니다. Local 에서 Docker 로 테스트하려면 redis.config 파일을 수정해야 하고, 현재는 AWS Infra 를 사용하고 있어서 ‘Ex’ 옵션을 추가해서 redis 옵션을 활성화한 상태입니다. 아래에 적용한 사례는 ‘Ex’ 로 적용한 코드입니다. 여기서 Key 가 만료된 경우, 비즈니스 로직 처리가 필요해서 Key 만료 이벤트를 받아 로직을 처리한 뒤에 Client 로 응답을 전송했습니다.

@Component
public class CmsRedisExpirationListener extends KeyExpirationEventMessageListener {

private final CmsCacheService cmsCacheService;
private final RedisKeyProperties redisKeyProperties;
private final RedisMessageListenerContainer listenerContainer;
private final SseEmitterService sseEmitterService;

@Value("${sse.emitter.cms.card-refresh-async.event-name}")
private String cardRefreshEmitterEventName;

public CmsRedisExpirationListener(RedisMessageListenerContainer listenerContainer,
CmsCacheService cmsCacheService, RedisKeyProperties redisKeyProperties, SseEmitterService sseEmitterService) {
super(listenerContainer);
this.cmsCacheService = cmsCacheService;
this.redisKeyProperties = redisKeyProperties;
this.listenerContainer = listenerContainer;
this.sseEmitterService = sseEmitterService;
}

@Override
public void init() {
// Restricted Redis commands Override 처리
super.doRegister(listenerContainer);
}

@Override
public void onMessage(Message message, @Nullable byte[] pattern) {

// Key 가 만료된 경우, 로직 처리
String expiredKey = message.toString();
if (expiredKey.contains(
redisKeyProperties.getKey().getCms().getCardInfoRefreshTtl().getPrefix())) {
String[] cardInfoRefreshTtlKeyArray = expiredKey.split(":");
long userId = Long.parseLong(cardInfoRefreshTtlKeyArray[1]);

cmsCacheService.hexistAndHset(userId,
redisKeyProperties.getKey().getCms().getCardInfoRefresh().getPrefix(),
cmsCacheService.CARD_REFRESH_STATUS_FIELD, CardRefreshStatusType.DONE.name());

if (sseEmitterService.hasEventEmitter(userId, cardRefreshEmitterEventName)) {
sseEmitterService.completeSseEvent(userId, cardRefreshEmitterEventName);
}
}
}
}

위와 같이 AWS ElastiCache Restricted Commands 에서 config 를 사용할 수 없다고 나오는데, 이부분은 CmsRedisExpirationListener.class 가 상속받은 KeyspaceEventMessageListener.class 에서 init() Method 를 Override 처리해서 Restricted Command 를 사용하지 않는 방법을 선택해야 정상적으로 동작합니다. 아래의 코드는 KeyspaceEventMessageListener.class 에서 init() Method 입니다.

public void init() {
if (StringUtils.hasText(keyspaceNotificationsConfigParameter)) {
RedisConnection connection = listenerContainer.getConnectionFactory().getConnection();
try {
Properties config = connection.getConfig("notify-keyspace-events");
if (!StringUtils.hasText(config.getProperty("notify-keyspace-events"))) {
connection.setConfig("notify-keyspace-events", keyspaceNotificationsConfigParameter);
}
} finally {
connection.close();
}
}

doRegister(listenerContainer);
}

성능 테스트

해당 설정을 Infra 팀에 요청하고 서비스를 배포하기에 앞서 성능을 테스트해봤습니다.

테스트 조건

  • Redis Spec. : 운영 Redis 와 동일 설정
  • 카드업권 refresh 요청 포함해서 총 cache item 수 : 233,000 건

진행 순서

  • Redis Key Event Notification 테스트 코드 작성
  • 스테이지 Redis 를 운영 Redis 사양으로 변경해서 부하테스트 진행

테스트 결과 및 분석

Stage Redis Test Result
Production Redis Result 30Days
  • 테스트 결과 Redis CPU 8% 정도 사용
  • CPU 사용률 확인 후, 서비스에 영향도가 크지 않다는 Infra 팀의 판단으로 기존 운영 Redis 를 사용해서 해당 로직 적용
  • 실제 운영Redis CPU 5% 미만 사용

참고자료

실제 운영에 적용해 보니, 잘 모르고 사용한 부분과 적용을 올바르게 하지 못한 부분들이 있었습니다. Redis 의 다양한 기능을 실무에 적용하면서 팀원들과 많이 고민하고 적용했던 부분이라 고생했던 기억이 많네요. 추후 좀 더 재미난 글로 만나뵙겠습니다. 여기까지 긴 글을 읽어주셔서 감사합니다.

--

--