Spring Boot + Ehcache: 캐싱 적용

jkky98·2025년 2월 6일
0

ProjectSpring

목록 보기
10/20
post-thumbnail

캐시 도입에 대한 고민

"해당 로직에서 데이터 갱신보다 참조가 매우 많이 일어날 경우 고민가능하다."
📖 참고: 《가상 면접 사례로 배우는 대규모 시스템 설계 기초》 1장

현재 개인 프로젝트에서 이에 가장 적절한 부분이 태그를 랜더링하는 부분이다.

우선적으로 Ehcache를 태그 기능에 발라보도록 하자.

설정

gradle

implementation 'org.springframework.boot:spring-boot-starter-cache' # Spring Cache 추상화 (캐싱 기능 활성화)
implementation 'org.ehcache:ehcache:3.10.8' # Ehcache 라이브러리 (JCache 구현체)
implementation 'javax.cache:cache-api:1.1.1' # JCache(JSR-107) API 사용 (캐시 표준 인터페이스 제공)

Spring Boot의 자동 설정 (Spring Boot Auto-Configuration)

Spring Boot는 JCache API (JSR-107)를 지원하며, @EnableCaching을 사용하면 자동으로 캐시 관리자를 결정한다.

JCache를 자동으로 활성화하는 원리

  • spring-boot-autoconfigure에서 JCacheManagerCustomizer 빈이 등록되면, Spring Boot는 JCache 구현체가 classpath에 있는지 확인한다.
  • Ehcachejavax.cache.spi.CachingProvider 인터페이스를 구현하므로, Spring Boot는 이를 JCache의 구현체로 자동 등록한다.
  • cacheManager.createCache(...)를 호출할 때, Ehcache가 내부적으로 사용된다.

위의 원리로 직접 CacheConfig를 구성해보자.

CacheConfig


@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public JCacheManagerCustomizer cacheManagerCustomizer() {
        return cacheManager -> {
            cacheManager.createCache("tagCache", new MutableConfiguration<Long, List>()
                    .setTypes(Long.class, List.class)
                    .setStoreByValue(false)
                    .setExpiryPolicyFactory(ModifiedExpiryPolicy.factoryOf(new Duration(TimeUnit.HOURS, 6))));
        };
    }
}

CacheConfig 클래스는 Spring Boot에서 캐싱 기능을 활성화하고, JCache(JSR-107)를 활용하여 특정 캐시를 설정하는 역할을 한다.
이 설정을 통해 tagCache라는 캐시를 생성하고, 해당 캐시에 대한 만료 정책과 저장 방식 등을 정의한다.

1. 캐싱 기능 활성화

클래스에 @EnableCaching 애너테이션을 적용하여 Spring의 캐싱 기능을 활성화한다.
이를 통해 애플리케이션에서 @Cacheable, @CachePut, @CacheEvict 등의 캐싱 관련 애너테이션을 사용할 수 있다.

2. 캐시 설정을 위한 JCacheManagerCustomizer 등록

Spring에서 JCache(JSR-107) 기반의 캐시를 설정할 수 있도록 JCacheManagerCustomizer를 빈으로 등록한다.
이 인터페이스를 활용하면 애플리케이션 실행 시점에 캐시 구성을 쉽게 추가할 수 있다.

3. tagCache 캐시 생성 및 설정

  • tagCache라는 이름의 캐시를 생성한다.
  • 캐시의 키 타입은 Long, 값 타입은 List로 지정하여, Long 값을 키로 하여 List 데이터를 저장할 수 있도록 설정한다.
  • setStoreByValue(false) 설정을 통해 캐시 데이터를 참조(Reference) 로 저장하여, 동일 객체를 공유할 수 있도록 한다.
  • 만료 정책으로 6시간 동안 유지되는 Expiry Policy 를 적용하여, 데이터가 6시간 후 자동으로 삭제되도록 설정한다.

4. 디폴트 설정과 변경 가능 옵션

JCache를 사용하는 경우, 구현체(Ehcache 등)의 기본 설정이 적용된다.
특정 설정을 명시하지 않으면 기본적으로 다음과 같은 값이 적용된다.

캐시 저장 방식

  • 기본값: setStoreByValue(true) (객체를 직렬화하여 저장)
  • 변경: setStoreByValue(false) 로 설정하면 객체 참조 방식으로 저장됨 (Ehcache는 기본적으로 false).

만료 정책 (Expiry Policy)

  • 기본값: AccessedExpiryPolicy.factoryOf(ExpiryPolicy.INFINITE) (데이터가 만료되지 않음)
  • 변경: ModifiedExpiryPolicy.factoryOf(new Duration(TimeUnit.HOURS, 6)) 등을 설정하여 특정 시간 후 만료되도록 할 수 있음.

캐시 정리 정책 (Eviction Policy)

  • 기본값: LRU (Least Recently Used)
    • 가장 오래 사용되지 않은 항목부터 제거됨.
    • Ehcache의 경우 기본적으로 LRU 정책을 사용하며, CacheConfiguration을 통해 다른 정책으로 변경할 수 있음.
  • 변경 가능 옵션:
    • LFU (Least Frequently Used) : 가장 적게 사용된 항목을 제거함.
    • FIFO (First-In First-Out) : 먼저 추가된 항목부터 제거함.

Persistence (영속성)

  • 기본값: 비활성화 (캐시가 휘발성 메모리에 저장됨)
  • 변경: DiskStoreConfiguration을 설정하면 디스크에 캐시 데이터를 저장할 수도 있음.

실제 로직에 적용(JPA)

	@Transactional(readOnly = true)
    @Cacheable(value = "tagCache", key = "#userId")
    public List<TagCountDto> getAllTag(Long userId) {
        User userFind = userService.findUserById(userId);
        return userRepository.findTagsByUser(userFind.getId());
    }
  1. getAllTag()처음 호출 시에는 영속성 컨텍스트(1차 캐시)를 조회한다. 그 후 1차 캐시에 태그 엔티티들이 없다면 DB에서 이를 영속성 컨텍스트에 올리고 이를 ehcache 캐시에 저장하고 반환한다.

  2. getAllTag() 두번째 호출부터는 동일한 userId 인자에 대해서는 getAllTag()자체가 호출되지 않는다. 대신 캐시에서 key인 userId로 하여금 처음 호출시 캐싱된 getAllTag()의 결과를 가져온다.

  3. 세번째 호출또한 동일하다.

  4. 동일하다.

캐시 일관성 유지

@Cachable에 의한 1~4 과정을 이해하면 캐시를 업데이트 해야겠다는 어느 시점들에 갱신해주어야 할 지 고민된다.

  1. 캐시 만료정책에 의해 최대 6시간 후에는 자동 삭제되므로 다시 1~4 과정이 일어날 것이니 동기화 가능.
    2. 해당 User가 가진 Tag엔티티의 등록, 수정, 삭제가 일어날 경우 동기화 시켜야한다.
	@Transactional
    public void removePost(Long postId) {
        Post post = findById(postId);

        Long userIdPost = post.getUser().getId();

        // Post에 연결된 PostTag의 Tag delete 처리
        List<Tag> tagsRemoved = post.getPostTags().stream()
                .map(
                        postTag -> postTag.getTag()
                ).toList();
        tagRepository.deleteAll(tagsRemoved);

        // Post 삭제
        postRepository.delete(post);

        // tag 캐시 삭제
        evictTagCache(userIdPost);
    }

    @CacheEvict(value = "tagCache", key = "#userId")
    public void evictTagCache(Long userId) {
        // 캐시만 삭제하는 목적이므로 내부 로직은 비워둠
    }

위의 로직은 Post 게시글 엔티티를 삭제하는 로직이다. 게시글을 삭제하며 그에 딸린 Tag 또한 삭제처리 해주어야 할 것이다. 아마 Tag 몇 개가 게시글 삭제로 인해 삭제될 것이다.

더 이상 getAllTag()는 이전 캐시에 저장된 결과를 내어주면 안된다. 그러므로 해당 userId key의 캐시를 지워주기 위해 @CacheEvict를 사용한다.

removePost()userId를 인자로 받지 않으므로 @CacheEvict만을 위한 메서드를 만들어 이를 타도록 하여 캐시를 지운다.

다만 아쉬운 지점은 AOP가 private 호출을 감지하지 못해서 클래스 내부 로직임에도 public으로 사용해야한다는 점이 짜증난다.

@Transactional
@CacheEvict(value = "tagCache", key = "#post.user.id", beforeInvocation = false)
public void removePost(Long postId) {
    Post post = findById(postId);

    // Post에 연결된 Tag 삭제
    List<Tag> tagsRemoved = post.getPostTags().stream()
            .map(PostTag::getTag)
            .toList();
    tagRepository.deleteAll(tagsRemoved);

    // Post 삭제
    postRepository.delete(post);
}
  • beforeInvocation = false (기본값) 이므로 메서드 실행 후 캐시를 삭제함.
  • post.getUser().getId()를 키로 사용하여 연결된 tagCache를 자동 삭제.

이렇게 사용도 가능하다.

두 경우를 모두 보여주기 위해 약간의 스토리를 부여했다..

이런 방식으로 태그가 추가되는 곳에, 태그가 수정되는 곳에, 태그가 삭제되는 곳에서 해당 유저Id에 대한 캐시를 지워 일관성을 유지할 수 있다.

profile
자바집사의 거북이 수련법

0개의 댓글

관련 채용 정보