[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());
}
}
분명 두 번 조회를 했으나 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를 사용하지 않는 업데이트 로직 바로 뒤에 올 때, 실시간 데이터 기반 로직이 아닐 수 있음을 인지하고 있어야 하는 것이죠.
당연해보이기도 하고 사소해보이지만, 이를 인지하지 못한 채 로직을 설계하다보면 예외나 에러가 발생했을 때 디버깅하기가 쉽지 않습니다.
따라서, 캐시를 사용할 땐 항상 이런 부분을 의심하면 좋을 것 같습니다.
감사합니다.
'Spring & Springboot' 카테고리의 다른 글
단위 테스트에서 @InjectMocks, @Mock VS @MockBean 이해하기 (0) | 2023.04.15 |
---|---|
JPA 연관관계 편의 메서드의 위치에 대한 내 생각 (0) | 2023.03.15 |
@DataJpaTest의 동작 방식과 몇 가지 주의사항 (0) | 2023.02.12 |
Spring Bean VS StaticClass (0) | 2022.12.10 |
Spring AOP 스터디 - (3) 프록시 객체의 내부 메서드 호출 문제 (0) | 2022.11.20 |