이전에 진행한 내잔고를 부탁해 프로젝트에서는 Redis 를 활용한 캐싱 처리를 제대로 사용하지 못한 느낌이 있었다. 그때는 어떤 데이터를 캐싱해야할지 잘 감이 안와서 그냥 남들 하는대로 Refresh 토큰을 저장해두는 정도로만 활용하였다. 대신 채팅 기능을 구현할 때 사용자의 구독 정보 관리와 서버간 메세지 브로커로 활용한 바 있다. 물론 대규모의 트래픽 처리를 하는 경우에는 메세지 브로커로 Kafka 를 쓰겠지만, 그래도 알차게 Redis 를 활용했다고 생각한다.
이번 프로젝트에서는 Redis 의 캐싱 기능을 더 야무지게 활용하여 api 응답 시간을 줄이고자 하였다. 사실 기술적으로 별 내용은 아닌데 나름 참고할만한 유즈케이스 같아서 기록해 둔다.
1. 캐싱 데이터
캐싱 처리는 데이터를 가지고 올때 DB 의 IO 작업이 없어서(또는 외부 api 요청) 데이터를 서빙하는 시간을 대폭 줄여준다. 그러니까 기본적으로 여유가 된다면 모든 데이터에 대해서 캐싱 처리를 하는 게 좋다.(당연히 그럴 여유는 없지만) 그중에서 꼭 필요하고, 가장 가성비가 좋은 데이터로 추리자는 식으로 접근하는게 좋은 것 같다.
추리는 기준은 "데이터 읽기의 빈도", "IO 에 걸리는 소요 시간", "업데이트 주기" 를 생각하면 된다. 자주 읽히는 데이터가 캐싱되어야 응답 시간을 줄이는데 가장 많이 기여할 것이다. 또한 IO 에 오래 걸리는 데이터가 캐싱되면 빈도가 적더라도 많은 응답 시간을 단축시킬 수 있다. 그리고 업데이트가 빈번하면 캐시도 계속 업데이트를 해줘야 하기 때문에 가능한 업데이트가 적은 데이터를 캐싱해두는 편이 좋을 것이다.
2. 프로젝트 적용
이번 프로젝트에서 나는 인증/인가, 회원, 팔로우 의 도메인과 식물 도감 쪽 개발을 맡았다. ( + 인프라) 이 중에서는 회원 정보와 식물 정보가 가장 빈번하게 요청이 들어올 것 같았다. 참고로 회원 정보는 다른 마이크로 서비스에서도 요청이 들어온다. 회원 정보의 경우 모든 회원 데이터를 다 집어 넣어도 좋겠지만, 나는 우선 가장 데이터 읽기 빈도가 많은 id, 닉네임, 썸네일 이미지 url 만 캐싱을 해두었다.
회원의 간략 정보를 반환하는 메서드
@Override
public MemberBriefInfoResponse getMemberBriefInfo(List<Long> memberIds) {
List<MemberBrief> memberBriefList = new ArrayList<>();
List<Long> notCached = new ArrayList<>();
for(Long memberId : memberIds){
MemberBrief briefById = memberQuery.findBriefById(memberId);
if(briefById == null){
notCached.add(memberId);
} else {
memberBriefList.add(briefById);
}
}
memberBriefList.addAll(memberQuery.findAllBriefById(notCached));
return MemberBriefInfoResponse.of(memberBriefList);
}
화면 구성시에 여러 회원의 정보가 한번에 필요한 경우가 많아서 배열로 받아서 처리하게 해두었는데, 배열을 순회하면서 캐싱처리가 되어있는 경우엔 Redis 에서 불러오고, 없는 경우엔 별도의 배열에 담아서 IO 작업을 수행한다. IO 작업이 일어난 경우에는 캐싱 처리가 된다.
참고로 null 처리를 하는 방식이 마음에 안들수도 있을 텐데(Optional 을 쓰지 않아서) 이번 프로젝트에서는 널 처리를 위해 Optional 과 IntelliJ 에서 정적 분석 도구가 활용하는 @Nullable 어노테이션을 적절하게 혼용했다. Optional 이 많은 경우 유용하지만 오히려 가독성을 떨어트리는 경우도 있는 것 같았기 때문이었다. 그리고 Optional 은 객체 생성에 따른 비용도 있다고도 한다. (얼마나 차이가 나는지는 모르겠음 주된 이유는 가독성이다.)
식물 간략 정보 요청 메서드
@Override
@Cacheable(cacheManager = "redisCacheManager", cacheNames = "plant_brief", key = "#id")
public PlantBrief getBriefById(Long id) {
return encyclopediaRepository.findById(id)
.map(PlantSpeciesEntity::toBrief)
.orElse(new PlantBrief(id, null, null, null));
}
이건 서비스 레이어가 아니라 어뎁터 레이어의 메서드다. 리포지토리에 직접 요청하는 메서드인데, 여기서는 @Cacheable 을 활용했다. 간단하게 어노테이션으로 레디스에서 캐시 관리를 해준다. 회원 정보쪽에서는 특별히 이유가 있어서 @Cacheable 을 쓰지 않았는데 그런 경우에는 RedisTemplate 으로 직접 관리해줄 수 있다. 참고로 Optional 도 객체 그대로 캐싱 해버릴 수 있다. 여기서는 @Notull 을 안붙이는데 왜냐하면 그게 디폴트기 때문.
MemberEntity 의 엔티티 리스너 어노테이션
@EntityListeners(MemberEntityListener.class)
public class MemberEntity extends DeletedAtAbstractEntity
엔티티 리스너
@Component
@RequiredArgsConstructor
public class MemberEntityListener {
private final MemberCacheRepository memberCacheRepository;
@PostPersist
public void postPersist(MemberEntity member) {
memberCacheRepository.saveMemberBrief(MemberEntity.toBrief(member));
}
@PostUpdate
public void postUpdate(MemberEntity member) {
memberCacheRepository.saveMemberBrief(MemberEntity.toBrief(member));
}
@PostRemove
public void postRemove(MemberEntity member) {
memberCacheRepository.deleteMemberBrief(member.getId());
}
}
캐시 업데이트는 간단하게 엔티티 리스너로 구현했다. 원래 여기서 이벤트를 발행해서 핸들링 하려고 했는데, 괜히 거창한 것 같다. 꼼삐의 버전업이 되면 다른 서비스와 회원 서비스가 분산 트랜잭션이 필요할 때가 있을 텐데 그때 그런식으로 진행해보려고 함.
3. 추후 숙제
카카오 같은 곳에서는 레디스를 정말 과감하게 쓴다고 한다. 회원 정보를 아예 통으로 집어넣기도 한다고 함.
근데 분명 캐싱은 비용이 상당하기 때문에 캐싱 대상이 되는 데이터를 매우 신중하게 선택할텐데 그때 어떤 정량적 판단 기준을 가지고 가게 될까? 또, 카카오 같은 대규모 서비스에서는 레디스 또한 HA 및 수평 확장을 위해서 클러스터링과 샤딩을 할텐데, 이런게 어떤식으로 이루어지는 지도 궁금하다. 입사시켜줘.