Spring

Spring Cache 구현 원리

AlwaysPr 2019. 3. 24. 14:40

Spring Cache 구현방식에 대해서 살펴보자. 최근 회사에서 spring-cache의 @Cacheable을 사용하게 되었다. 사용하다 보니 토비의 스프링에서 본 관심사의 분리와 AOP와 겹쳐서 머릿속의 생각을 코드와 글로 써보려 한다.

글로 다 표현하기에는 한계가 있는 듯하니 Github source와 함께 보면 좀 더 도움이 될 것 같다. 그리고 Test code도 작성하였으니, 이걸 통해서 Test를 하면 될 것 같다. (단, cache의 유무만 중요하기에 assert문은 작성하지 않고, log만 찍었다.)

 

요구사항은 다음과 같다. 나는 글로벌 게임을 만들고 있고, 유저들에게 웹사이트에서 랭킹을 보여주어야 한다. 단, 이용자는 100만 명을 넘기 때문에 모든 이용자를 Scan 해서 순위를 실시간으로 보여주기에는 한계가 있기에 Cache를 이용해서 랭킹 서비스를 제공하려 한다.

사전준비

의존성을 먼저 설정한다. 사용하는 의존성은 다음과 같다.

  • Spring boot
  • Spring cache
  • Spring data jpa
  • Spring data redis
  • Spring boot web
  • Embedded Redis
  • H2

pom.xml에 추가할 요소들은 위에서 언급한 Github source를 참고하자.

랭킹을 보여주기 위해서는 일단 사용자가 있어야 한다. Spring boot 프로젝트 생성 시 만들어주는 *Application에 다음처럼 사용자 몇 명을 추가하자.

@SpringBootApplication
public class CacheDemoApplication implements CommandLineRunner {
    private final MemberRepository memberRepository;

    public CacheDemoApplication(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public static void main(String[] args) {
        SpringApplication.run(CacheDemoApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        this.memberRepository.saveAll(asList(new Member("minsoo"), new Member("wonwoo"), new Member("sonsang"), new Member("toby"), new Member("manbok")));
    }
}

 

랭킹의 종류가 주, 월, 년 단위도 있기 때문에 enum을 추가한다.

public enum RankingType {ALL, YEAR, MONTH, WEEK;}

 

랭킹 Business를 제공해 주는 interface도 하나 만들자.

public interface RankingService {

    //Redis에 저장할 prefix
    String RANKING_GETTING_KEY = "ranking:get";

    List<Member> getRanking(RankingType type);
    
}

 

Redis가 이미 local에 깔려있으면 상관없겠지만, 그렇지 않으면 Embedded로 사용할 수 있게 아래의 config도 추가하자.

@Configuration
public class EmbeddedRedisConfig {
    private RedisServer redisServer;

    @PostConstruct
    public void redisServer() {
        redisServer = new RedisServer(6379);
        redisServer.start();
    }

    @PreDestroy
    public void stopRedis() {
        if (redisServer != null) {
            redisServer.stop();
        }
    }
}

Business 구현

입문자 방식

@Service
public class BeginnerRankingService implements RankingService {
    private final Logger log = LoggerFactory.getLogger(getClass());
    private final MemberRepository memberRepository;
    private final ValueOperations<String, List<Member>> operations;

    public BeginnerRankingService(MemberRepository memberRepository, RedisTemplate redisTemplate) {
        this.memberRepository = memberRepository;
        this.operations = redisTemplate.opsForValue();
    }

    @Override
    public List<Member> getRanking(RankingType type) {
        final String key = format("%s:%s", RANKING_GETTING_KEY, type.name().toLowerCase());
        final List<Member> cachedRankingList = this.operations.get(key);
        if (CollectionUtils.isEmpty(cachedRankingList)) {
            log.info("business logic execution");
            final List<Member> rankingList = this.memberRepository.findAll().stream()
                    //.sorted() 랭킹을 정하는 로직이 있다고 가정        
                    .collect(Collectors.toList());
            this.operations.set(key, rankingList, 30L, TimeUnit.SECONDS);
            return rankingList;
        } else {
            return cachedRankingList;
        }
    }
}

getRanking()을 보자. 로직은 간단하다. Redis에 data를 간편하게 넣고 가져올 수 있는 ValueOperations를 통해서 캐시 데이터를 가져온 다음 존재하지 않으면 로직을 수행하고, 존재하면 cache 된 데이터를 반환한다.

그러나 이 코드의 문제점은 SRP를 위반한다. 자세히 말하자면 두 가지 관심사(책임)를 가지고 있다.

  1. 랭킹순위를 매기는 로직
  2. Cache 하는 로직

즉 하나의 관심사 로직이 변하게 되면 직접적으로 타 로직에게 영향을 준다. 적합한 예는 아니지만 해당 게임의 랭킹 로직이 유명해지면서 N사와 K사가 구입했다고 가정하자. 그런데 N사는 Cache 용도로 Redis를 사용하지만, K사는 Hazelcast를 사용한다. 이렇게 될 경우 두 가지 버전의 랭킹서비스를 만들어서 각각 제공해줘야 한다.

 

IoC

위의 문제를 IoC를 이용해서 개선해 보자. 코드는 다음과 같다.

Ranking business

@Service
public class BasicRankingService implements RankingService {
    private final Logger log = LoggerFactory.getLogger(getClass());
    private final MemberRepository memberRepository;

    public BasicRankingService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public List<Member> getRanking(RankingType type) {
        log.info("business logic execution");
        return this.memberRepository.findAll().stream()
//              .sorted() 랭킹을 정하는 로직이 있다고 가정                 
                .collect(Collectors.toList());
    }
}

 

Cache

@Service
public class CacheRankingService implements RankingService {
    private final ValueOperations<String, List<Member>> operations;
    private final RankingService rankingService;

    public CacheRankingService(RedisTemplate redisTemplate, RankingService basicRankingService) {
        this.operations = redisTemplate.opsForValue();
        this.rankingService = basicRankingService;
    }

    @Override
    public List<Member> getRanking(RankingType type) {
        final String key = format("%s:%s", RANKING_GETTING_KEY, type.name().toLowerCase());
        final List<Member> cachedRankingList = this.operations.get(key);
        if (CollectionUtils.isEmpty(cachedRankingList)) {
            final List<Member> rankingList = this.rankingService.getRanking(type);
            this.operations.set(key, rankingList, 30L, TimeUnit.SECONDS);
            return rankingList;
        } else {
            return cachedRankingList;
        }
    }
}

 

BasicRankingServiceCacheRankingService 두 개의 클래스로 나뉘게 되었다. 그래서 각 클래스에 맞는 책임만을 가지고 있다. 이렇게 될 경우 Cache의 방식이 변하게 되더라도 랭킹 business에는 영향이 없다.

책임은 잘 분리했으나 후에 랭킹 말고도 Cache를 하려면 모든 class에 위와 같은 행위를 해줘야 한다. 즉 엄청 귀찮아진다. 혹자는 위의 이미지를 보고 Proxy라는 단어를 떠올렸을 것이다.

 

AOP

Proxy를 하는 방법은 다양하게 있겠지만, 스프링에서는 횡단의 관심사를 AOP를 통하여 분리할 수 있다.

Annotation을 통해서 AOP를 사용하겠다. Annotation을 먼저 만들자.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Inherited
@Documented
public @interface Cacheable {

    @AliasFor("cacheName") String value() default "";
    @AliasFor("value") String cacheName() default "";
    
}

 

그리고 Aspect를 만들자.

@Component
@Aspect
public class CacheAspect {
    private final ValueOperations<String, Object> operations;

    public CacheAspect(RedisTemplate redisTemplate) {
        this.operations = redisTemplate.opsForValue();
    }

    @Around("@annotation(Cacheable)")
    public Object cache(ProceedingJoinPoint joinPoint) throws Throwable {
        final String prefix = getCacheName(joinPoint);
        final String key = generateKey(joinPoint);
        final String cacheKey = String.format("%s:%s", prefix, key);
        final Object cachedValue = this.operations.get(cacheKey);
        if (isNull(cachedValue)) {
            final Object result = joinPoint.proceed();
            this.operations.set(cacheKey, result);
            return result;
        } else {
            return cachedValue;
        }
    }

    private String getCacheName(ProceedingJoinPoint joinPoint) {
        final MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        final Method method = signature.getMethod();
        final Cacheable cacheable = AnnotationUtils.getAnnotation(method, Cacheable.class);
        return cacheable.cacheName();
    }

    private String generateKey(ProceedingJoinPoint joinPoint) {
        return Arrays.stream(joinPoint.getArgs()).map(args -> Integer.toString(args.hashCode())).collect(joining(":"));
    }
}

 

@Service
public class AopRankingService implements RankingService {
    private final Logger log = LoggerFactory.getLogger(getClass());
    private final MemberRepository memberRepository;

    public AopRankingService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    @Cacheable(RANKING_GETTING_KEY)
    public List<Member> getRanking(RankingType type) {
        log.info("business logic execution");
        return this.memberRepository.findAll().stream()
                //.sorted() 랭킹을 정하는 로직이 있다고 가정              
                .collect(Collectors.toList());
    }
}

Cache를 하기 위해 일일이 class를 만들 필요가 없어졌으며, 간단하게 annotation으로 해결이 가능하다.

이전에 작성한 코드와 비슷하다. 단, method의 정보들을 가져오는 부분과 파라미터들을 key로 만드는 로직이 추가되었을 뿐이다. key생성은 임의로 만들었으며 실제 Spring에서는 기본적으로 SimpleKeyGeneretor를 통해 생성한다.

 

Spring

당연히 Spring에서도 위와 같은 AOP를 이용한 Cache 방식을 제공한다. @EnableCaching를 추가하고 몇몇 필요한 설정 후 @Cacheable을 이용하면 간단하게 Cache를 할 수 있다.
이 부분에 대한 자세한 내용은 이미 다른 곳에 많이 있으므로 생략하겠다.

 

우리가 당연하게 사용하고 있는 것들이 Framework를 만든 사람들 입장에서는 당연한 게 아닐 수도 있다. Spring에서 제공해 주는 이런 @Cacheable이나 @Transactional은 단순히 우리를 편하게 만들어주려는 의도뿐만은 아닌 것 같다. 그 내면에는 이런 책임을 분리하고 만약 변경이 일어나더라도 자기에게만 영향이 끼치게 하는 등의 다양한 Framework 및 그들의 철학이 반영된 걸로 보인다.