[Spring] @CacheEvict 없이 @Cacheable을 쓸 때 생길 수 있는 문제

kindof

·

2023. 2. 22. 18:56

1. 문제

캐시의 종류에는 크게 두 가지가 있는데요. 바로 로컬 캐시와 글로벌 캐시입니다. 로컬 캐시는 각 서버마다 존재하는 캐시이고, 글로벌 캐시는 여러 서버가 공유할 수 있는 캐시입니다.

 

부하 분산을 위해 여러 서버를 가지고 서비스를 운영하고 있다면 아마 글로벌 캐시 전략을 가져가고 있을 것입니다.

 

글로벌 캐시

이번 글에서는 Springboot + JPA + Redis를 활용해서 기본적인 캐시 사용 전략을 실습해보고, 반드시 주의해야 할 내용 한 가지를 짚어보려고 합니다.

 

2. 테스트 환경 구축

먼저 캐시를 사용할 때 기본적으로 알아야 할 개념인 @Cacheable과 @CacheEvict가 무엇인지 알아보기 위해 간단한 테스트 환경을 구축해보겠습니다.

 

Redis를 설치하지 않으셨다면 여기에서 설치하실 수 있습니다. 설치 이후에는 redis-server 명령어를 통해 실행시켜주세요.

 

 

[build.gradle]

h2 데이터베이스, JPA, Redis를 기반으로 실습합니다.

dependencies {
	runtimeOnly 'com.h2database:h2'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

	...

	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	testCompileOnly 'org.projectlombok:lombok:'
	testAnnotationProcessor 'org.projectlombok:lombok'
}

 

[application.yml]

H2, Redis, JPA 관련 설정을 입력합니다. 어플리케이션을 실행한 상태에서 테스트코드를 실행할 예정이므로 H2는 tcp를 사용해야 합니다.

spring:
  h2:
    console:
      enabled: true
      path: /h2-console

  datasource:
    hikari:
      jdbc-url: jdbc:h2:tcp://localhost/~/test
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    properties:
      hibernate:
        format_sql: true
        show_sql: true
        dialect: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create

  cache:
    redis:
      host: 127.0.0.1
      port: 6379

 

 

[RedisConfig.java]

Redis 캐시를 사용하기 위한 설정 파일입니다. cacheManager를 Bean으로 생성하고 TTL(Time-to-live)을 1분으로 설정했습니다.

TTL이 1분이면 캐시에 데이터가 살아있는 시간이 1분이라는 뜻입니다.

@Configuration
@Getter
@RequiredArgsConstructor
@EnableCaching
public class RedisConfig {

    @Value("${spring.cache.redis.host}")
    private String host;

    @Value("${spring.cache.redis.port}")
    private int port;

    @Bean
    public CacheManager cacheManager() {
        RedisCacheManager.RedisCacheManagerBuilder builder = RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) // Value Serializer 변경
                .entryTtl(Duration.ofMinutes(1));
        builder.cacheDefaults(configuration);
        return builder.build();
    }
}

 

[Book.java]

실습에 사용할 간단한 엔티티입니다. 편의를 위해 여러 어노테이션들을 사용합니다.

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Data
@Entity
public class Book implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String title;
    private String content;

}

 

[BookService.java]

간단한 생성, 조회, 업데이트 로직만 작성했습니다.

@Service
@RequiredArgsConstructor
public class BookService {
    private final BookRepository bookRepository;

    public Book create(String title, String content) {
        return bookRepository.save(Book.builder().title(title).content(content).build());
    }

    public Book findByBookTitle(String title) {
        return bookRepository.findByTitle(title);
    }

    public void updateContent(String title, String content) {
        Book book = bookRepository.findByTitle(title);
        book.setContent(content);
        bookRepository.save(book);
    }
}

 

[BookRepository.java]

JpaRepository를 사용합니다. Title을 통해 조회하기 위해 아래와 같이 JPA에서 기본으로 생성해주는 메서드를 만들었습니다.

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
    Book findByTitle(String title);
}

 

[BookController.java]

가장 중요한 Controller입니다. 지금은 @Cacheable, @CacheEvict를 모든 메서드에 적용해서 조회 뿐만 아니라 업데이트, 생성 시에도 캐시와 DB의 Sync를 맞추고 있습니다.

@RestController
@RequiredArgsConstructor
@Slf4j
public class BookController {

    private final BookService bookService;
    private final BookRepository bookRepository;

    @PostMapping("/create")
    @ResponseBody
    @CacheEvict(value = "book", key = "#title")
    public Book create(@RequestParam String title, @RequestParam String content) {
        log.info("create method call");
        return bookService.create(title, content);
    }

    @PutMapping("/update")
    @CacheEvict(value = "book", key = "#title")
    public void update(@RequestParam String title, @RequestParam String content) {
        log.info("update method call");
        bookService.updateContent(title, content);
    }

    @GetMapping("/get")
    @Cacheable(value = "book", key = "#title")
    public Book getBook(@RequestParam String title) {
        log.info("get method call");
        return bookService.findByBookTitle(title);
    }
}

 

3. @Cacheable, @CacheEvict 테스트

이제 아래와 같이 테스트 코드를 작성해서 캐시를 잘 사용하고 있는지 쿼리를 확인해보겠습니다.

@SpringBootTest
@ExtendWith(SpringExtension.class)
@AutoConfigureMockMvc
@Transactional
class BookControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName("Cacheable 테스트")
    public void cacheableTest() throws Exception {
        mockMvc.perform(post("/create").param("title", "test").param("content", "before"))
                .andExpect(status().isOk());

        mockMvc.perform(get("/get").param("title", "test"))
                .andExpect(status().isOk());

        mockMvc.perform(get("/get").param("title", "test")).andExpect(status().isOk());
    }
}

@Cacheable 테스트

분명 두 번 조회를 했으나 DB에는 한 번만 쿼리가 나갔습니다. 실제로 @Cacheable 어노테이션을 빼고 실행하면 2번의 Select 쿼리가 나가는 것을 확인하실 수 있습니다.

 

다음으로 @CacheEvict 사용이 잘 되는지 살펴보겠습니다.

    @Test
    @DisplayName("CacheEvict 테스트")
    public void cacheEvictTest() throws Exception {
        mockMvc.perform(post("/create")
                        .param("title", "test")
                        .param("content", "before"))
                .andExpect(status().isOk());

        mockMvc.perform(put("/update")
                        .param("title", "test")
                        .param("content", "after"))
                .andExpect(status().isOk());

        mockMvc.perform(get("/get")
                        .param("title", "test"))
                .andExpect(jsonPath("$.content").value("after"))
                .andExpect(status().isOk());

    }

update를 했을 때 content 내용이 "before" → "after"로 변경되었습니다. 그리고 이 때 @CacheEvict 설정을 걸었기 때문에 캐시의 데이터는 최신 데이터로 갱신되었고 변경된 값을 조회하는 테스트가 성공합니다.

 

4. 항상 @CacheEvict를 쓸 수 있을까

위 테스트를 보면 Create, Update 등의 이벤트에 대해서는 @CacheEvict를 사용했고, 조회 할 때에는 @Cacheable을 사용했습니다.

 

하지만 너무 잦은 @CacheEvict 사용은 매 번 캐시에서 데이터를 제거하므로 데이터를 재계산하거나 DB에서 검색해야 하므로 캐시 누락과 지연 시간 증가를 초래할 수 있습니다.

 

또한 캐시가 지속적으로 제거되는 경우 캐시에 데이터를 다시 채워야 하므로 메모리 관련 성능 이슈나 잦은 GC를 일으킬 수 있죠.

 

이러한 이유로 폭팔적인 TPS가 발생하는 메서드에 대해서는 @CacheEvict를 사용하지 않고 적절한 TTL값을 설정하여 합의를 보기도 하는데요.

 

예를 들어, 월드컵 경기의 중계 댓글은 수 십만개가 짧은 시간 안에 생성되고 여기서 '가장 최신 댓글'을 조회하기 위해서는 수 십만개의 댓글에 대해 생성 시간을 업데이트해야 합니다. 위에서 언급한 문제가 발생할 수 있죠.

 

이런 상황에서 비즈니스 로직을 설계할 때 주의해야 합니다.

 

@Cacheable을 통해 조회하는 로직이 @CacheEvict를 사용하지 않는 업데이트 로직 바로 뒤에 올 때, 실시간 데이터 기반 로직이 아닐 수 있음을 인지하고 있어야 하는 것이죠.

 

당연해보이기도 하고 사소해보이지만, 이를 인지하지 못한 채 로직을 설계하다보면 예외나 에러가 발생했을 때 디버깅하기가 쉽지 않습니다.

 

따라서, 캐시를 사용할 땐 항상 이런 부분을 의심하면 좋을 것 같습니다.

 

감사합니다.