[Redis에 대하여] 3. 캐싱 읽기와 쓰기 로직 구현(+ 스케줄링)

아양시·2023년 1월 19일
3

Redis에 대하여

목록 보기
3/3
post-thumbnail

이제 구현을 한번 해보았다.

기존 코드(리팩토링 전)

우선 좋아요 데이터를 저장하는 기존 로직은 아래와 같았다.

...
public class ExpressionService {

    ...
    private static final long duration = 3600*24*100;

    @Transactional
    public void save(CreateExpressionForm form, Long profileId) {
        String key = keyPrefix + form.getCommunityId();
        if(!redis.exists(key)) DBtoCache(form.getCommunityId());

        if(redis.sIsMember(key, profileId)) {
            redis.sRem(key, profileId);
            Expression findEx = expressionRepository.findByBoardIdnProfileId(form.getCommunityId(), profileId)
                    .orElseThrow(() -> new IllegalStateException("비정상적인 데이터"));
            expressionRepository.delete(findEx);
        } else {
            redis.sAdd(key, profileId);
            redis.expire(key, duration);
            Community community = communityRepository.findById(form.getCommunityId())
                    .orElseThrow(() -> new NotFoundException("글이 존재하지 않습니다."));
            Profile profile = accountRepository.findProfileById(profileId)
                    .orElseThrow(() -> new NotFoundException("존재하지 않는 프로필입니다."));
            Expression expression = Expression.builder()
                    .board(community)
                    .profile(profile)
                    .build();
            expressionRepository.save(expression);
        }
    }
}

  캐시를 사용해서 읽고 쓰기로 했었고, 캐시에서 디비로 데이터를 동기화하는 스케줄링 로직은 빼고 구현했기 때문에 캐시에 쓰고 디비에도 함께 썼었다.

  우선, 캐시에는 게시물 ID를 key로 하고, 해당 게시물에 좋아요를 누른 사람의 프로필 ID가 그에 대한 Set 구조로 저장한다.
위의 코드 로직은 다음과 같다.

  1. 누가 어떤 글에 좋아요를 눌렀는지를 파라미터로 받아온다.
  2. 캐시에 해당 게시물에 대한 데이터가 없으면 디비에서 가져온다.
  3. 해당 게시물에 대해서 해당 프로필의 사용자가
    • 원래 눌렀었으면 캐시에서 삭제 후 디비에서 삭제
    • 처음 누르면 캐시에 추가 후 디비에 추가

문제

  하지만 위와 같은 로직은 문제가 있다. 우선, 캐시에 저장한 후 디비에 저장하기 때문에, 캐시에는 정상적으로 저장되었는데 디비 트랜잭션이 롤백되거나 예외가 발생하면 캐시와 디비 간 데이터 모순이 생긴다. 또한, 한 트랜잭션 내에 두 가지 종류의 저장소가 정상적으로 함께 동작하고 정합성까지 보장해주길 바라는 것은 너무 큰 오산이다. 캐시에서 데이터가 소멸되는 주기 또한 우선 기능 동작을 초점으로 구현한거라 계산없이 길게 설정해두었다.

Redis를 이용해보자

  우선, Redis는 빠르다. 서비스 처리 속도는 사용자에게 매우 중요하다. In-memory 저장소인 Redis를 사용함으로써 좋아요 기능에 대해서 만큼은 빠르게 처리할 수 있다. 위의 로직에서 MySQL 디비만 사용했다면, 저장하기 기능 하나에 게시물 조회, 해당 게시물-계정 데이터 존재 확인, 좋아요 또는 프로필 정보 조회, 좋아요 데이터 삭제 혹은 조회 이렇게 네 가지 쿼리가 수행된다. 좋아요 기능이 너무 자주, 쉽게 발생하는 요청이라고 생각했다.

  또한, 무엇보다 구현 로직이 간단하다. 게시물에 대한 좋아요 수만 필요한 것이 아니라, 내가 눌렀는지 여부도 알아야 하기 때문에 이 게시물에 누가 눌렀는지에 대한 정보를 저장해야 한다. MySQL만 사용했다면 각 게시물ID-프로필ID의 쌍을 모두 저장하고 이를 사용하지만, Redis를 사용해서 게시물ID에 대해서 프로필ID의 Set을 저장하면 된다. Set이므로 한 데이터가 해당 Set의 원소인지, Set 내의 원소가 몇 개인지 확인하기도 간단하다.

  이러한 이유로 Redis를 선택했고, 제대로 활용해보고자 아키텍처를 설계하고 코드를 수정했다.

수정한 코드(리팩토링 후)

  먼저, 좋아요를 눌렀을 때, 데이터를 저장하는 구조이다.

...
public class ExpressionService {
	...
    private static final long duration = 3600*2;   // 2시간
    
	public void save(CreateExpressionForm form, Long profileId) {
        String key = String.format(keyPrefix + "%s", form.getCommunityId());
        if(!redis.exists(key)) DBtoCache(form.getCommunityId());
        if(redis.sIsMember(key, profileId)) redis.sRem(key, profileId);
        else {
            redis.sAdd(key, profileId);
            redis.expire(key, duration);
        }
        redis.sAdd(keyUpdate, form.getCommunityId());
        redis.expire(key, duration);
    }
}

  두 가지 종류의 저장소에 대해 한 트랜잭션이 보장되어야 했던 기존 코드에 반해, 이제 Redis에만 저장하는 로직으로 수정했다.

  1. 캐시에 해당 게시물 Id에 대한 데이터가 있는지 확인 후 없다면 디비에서 가져온다.
  2. 해당 프로필 Id가 해당 게시물에 좋아요를
    1. 누른 적이 있다면 지우고
    2. 처음 눌렀다면 새로 추가한다.
  3. 변경사항이 생긴 게시물 Id를 keyUpdate에 기록한다.

  keyUpdate라는 set을 새로 추가해서 디비 동기화 시에 사용하도록 했다. 변경사항이 생긴 것을 표시해두지 않으면 모든 캐시를 탐색해야 하기 때문에 dirty bit의 원리를 이용해서 동기화를 해야하는 데이터를 선별했다.

  그리고 expire는 2시간으로 설정했다. 이는 스케줄링 주기를 고려하여 캐시 데이터가 소멸되기 전에 디비에 옮길 기회를 최소 두 번 부여하기 위해서이다.

디비 동기화 스케줄링

  이제 읽기와 쓰기는 모두 캐시를 통해서 이루어지기 때문에 캐시 데이터를 주기적으로 디비에 동기화하는 작업이 필요했다.

@Service
@RequiredArgsConstructor
public class ScheduleService {

    ...
    private static final long duration = 3600*2;   // 2시간

    @Scheduled(fixedDelay = 3000000L) // 50분 마다
    public void cacheToDB() {
        Set<Long> updatedIds = redis.sMembers(keyUpdate).stream().map(i -> (Long) i)).collect(Collectors.toSet());
        // 이 시점에 다른 게시물에 대한 좋아요 변경사항이 생길 경우 expire하기 전에 또 다른 변경사항이 생기지 않으면 누락됨, expire 하기 전에 화면에는 보임
        redis.del(keyUpdate);

        for(Long boardId : updatedIds) {
            Set<Long> updates = redis.sMembers(String.format(keyPrefix + "%s", boardId)).stream().map(i -> (Long) i).collect(Collectors.toSet());
            try {
                expressionService.store(boardId, updates);
            } catch (Exception e) {
                redis.sAdd(keyUpdate, boardId);
            }
        }
    }
}
  1. 캐시에서 업데이트된 게시물Id의 목록을 가져온다.
  2. 캐시에서 업데이트 대기열을 삭제한다.
  3. 캐시에서 각 게시물Id 에 좋아요를 누른 프로필 Id의 목록을 가져와서 디비에 반영한다.
    • 실패할 경우 다음 스케줄링 때 동기화될 수 있도록 업데이트 대기열에 다시 추가한다.

  우선, 스케줄링 메서드를 내에 디비 트랜잭션과 같은 다른 AOP 메서드가 포함될 경우 클래스를 분리해야 한다. @Scheduled 메서드에서 같은 클래스 내의 @Transaction이 붙은 메서드를 호출할 경우, Transaction이 적용된 프록시 객체의 메서드가 아닌 적용되지 않은 본래의 메서드를 호출하기 때문이다.

  본 스케줄링을 정상적으로 구현하기 위해 다음과 같은 문제들을 고려해야 했다.

첫번째 문제

캐시에서 이번 스케줄링에서 디비에 반영할 데이터셋을 가져오고 난 후 캐시에서 대기열을 삭제하기 전에 새로운 게시물에 대한 변경사항이 생길 경우, 데이터가 영속화될 수 없을 가능성이 생긴다.

  이 게시물에 누가 좋아요를 눌렀는지에 대한 정보(정보A)가 디비에 반영되기 전에 이 게시물에 변경사항이 생겼다는 사실 자체가 사라지기 때문에 정보A가 소멸되기 전에 다시 해당 게시물에 변경사항이 생기지 않으면 정보A는 완전히 누락된다. 그래서 사실 위 로직의 1번과 2번은 트랜잭션이 보장되어야 한다. 하지만 Redis는 트랜잭션 내에서 get 명령어가 무효화되기 때문에 불가능한 것 같다. 그래서 최대한 누락되는 데이터를 줄이기 위해 누락가능성이 생기는 기간을 줄여야 했고, 1번 로직 후에 바로 삭제하도록 했다.

두번째 문제

게시물-프로필의 정보를 반영하는 디비 트랜잭션이 롤백될 경우 데이터가 누락된다.

  첫번째 문제에 대한 방안으로 디비 트랜잭션 전에 캐시의 대기열을 삭제하기 때문에 디비 트랜잭션이 롤백되면 데이터가 그냥 사라지게 된다. 그래서 try-catch문을 사용해서 디비 트랜잭션을 수행하는 store라는 함수에서 예외가 발생하면 대기열에 해당 게시물 Id를 다시 넣어서 다음 스케줄링 때 반영될 수 있도록 했다.

세번째 문제

좋아요 기록이 캐시에서 소멸되기 전에 디비에 반영될 수 있도록 주기 조정하기

  앞서 언급했듯이 한 번의 스케줄링에서 완벽한 디비 반영이 실패할 경우를 대비해서 두 번정도 스케줄링 되도록 각 주기를 설계했다. 결과적으로 캐시 데이터 소멸 주기는 2시간, 스케줄링 주기는 50분으로 설정했다. 데이터 소멸 주기는 캐시 메모리 용량을 위해 너무 길지 않아야 하고, 스케줄링 주기는 캐시 의존도가 높은 본 아키텍처에서 캐시가 종료되었을 경우를 대비하여 길지 않아야 하는 것을 고려했다.

  사실상 좋아요 기능에서는 읽기가 반드시 쓰기에 수반되고, 읽기 시 데이터 소멸 기간이 갱신되기 때문에 데이터의 생존기간은 충분히 길고, 그 안에 디비에 제대로 반영될 수 있는 가능성이 높다고 할 수 있다.

여전히 완벽하지는 않다.

  첫번째 문제에서 언급된 방안은 여전히 약간의 데이터 누락 가능성을 남겨둔다. 이것을 보완하기 위해 대기열을 여러 개 두기, 대기열의 데이터 타입을 set 대신 string value로 사용해서 getAndDel 사용하기 등의 방안을 고려해봤지만 .. 쉽지가 않았다. 고민하던 중 우아콘의 이벤트 기반 아키텍처 구축하기 영상을 보게 되었고, 다중 데이터베이스의 분산 트랜잭션을 구현하는 것은 거의 불가능하다는 것을 알게 되었다. 이에 본 서비스에서는 유실이 발생할 수 없도록 데이터베이스를 공유하는 대신, 낮은 확률로 유실이 발생할 수 있는 다중 데이터베이스 시스템 유지하기를 선택했다. 최대한 유실이 발생할 수 있는 확률을 낮추는 것에 집중해봤다.

디비 동기화 로직

  스케줄링에서 사용되는 실제로 디비에 반영되는 트랜잭션 함수는 다음과 같이 구현했다.

...
public class ExpressionService {
	...
    
	@Transactional
    public void store(Long updatedBoardId, Set<Long> updatedProfileIds) throws Exception {
        Community board = communityRepository.findById(updatedBoardId).orElse(null);
        if(board == null) return;   // 게시물에 대한 좋아요 데이터를 DB에 동기화하는 사이에 게시물에 삭제됨 => 정상

        Set<Long> history = new HashSet<>(expressionRepository.findProfileIdsByBoardId(updatedBoardId));

        Set<Long> intersection = new HashSet<>(history);
        intersection.retainAll(updatedProfileIds);

        // 캐시에는 존재, 디비에는 부재 -> 새로 저장
        updatedProfileIds.removeAll(intersection);
        for(Long profileId : updatedProfileIds) {
            Profile profile = accountRepository.findProfileById(profileId).orElse(null);
            if(profile == null) continue;     // 좋아요 데이터를 DB에 동기화하는 사이에 누른 사람이 탈퇴함 => 정상
            Expression expression = Expression.builder()
                    .board(board)
                    .profile(profile)
                    .build();
            try {
                expressionRepository.save(expression);
            } catch (Exception e) {
                throw new Exception();
            }
        }

        // 캐시에는 부재, 디비에는 존재 -> 삭제
        history.removeAll(intersection);
        for(Long profileId : history) {
            Expression expression = expressionRepository.findByBoardIdnProfileId(
                    updatedBoardId,
                    profileId).orElseThrow(() -> new IllegalStateException("한 트랜잭션 내에서 데이터 모순 발생")
            );
            try {
                expressionRepository.delete(expression);
            } catch (Exception e) {
                throw new Exception();
            }
        }
    }
}

  아주 오래 걸릴 이 로직을 최대한 짧게 끝내기 위해 노력해봤다. 좋아요 특성상, 해당 게시물에 대해서 특정 프로필이 좋아한다는 데이터가 디비에는 없는데 새로 생겼으면 디비에 추가해야 하고, 디비에는 있는데 캐시에는 없으면 좋아요를 취소한 것이기 때문에 디비에서 삭제해야 한다.

그래서 위 그림처럼 집합 연산을 통해 각 연산이 필요한 데이터만 탐색해서 처리하도록 해봤다. 그리고 각 과정에서 예외가 발생하면 throw해서 스케줄링 메서드에서 처리할 수 있도록 했다.

profile
BE Developer

0개의 댓글