Spring Boot 3 핵심 정리 시리즈 14편. @Cacheable로 DB 조회를 메모리 캐시로 바꿔 응답 속도를 수백~수천 배 끌어올리는 법, Caffeine과 Redis 캐시 구현체 차이, 그리고 Spring Application Events로 핵심 비즈니스 로직과 감사 로그·이메일·외부 연동을 분리하는 패턴까지 책상 옆 사물함 비유로 친절하게 풀어쓴 14편.
이 글은 Spring Boot 3 핵심 정리 시리즈의 14편입니다. 13편까지 따라오셨다면 이미 Actuator로 애플리케이션의 건강 상태를 들여다볼 수 있는 단계예요. 이번 14편에서는 그 위에 한 층을 더 얹습니다 — @Cacheable 어노테이션 한 줄로 데이터베이스 조회를 메모리 캐시로 바꿔 응답 속도를 수백 배에서 수천 배까지 끌어올리는 법, 그리고 Spring Application Events로 핵심 비즈니스 로직과 감사 로그·이메일·외부 연동 같은 부가 기능을 깔끔하게 분리하는 패턴이에요.
캐싱과 이벤트는 얼핏 보면 별개 주제 같지만 결이 같습니다. 둘 다 "핵심 처리 흐름은 그대로 두고, 옆에서 살짝 거들어 주는 도우미" 같은 역할이에요. 그래서 한 편으로 묶어 풀어 가기 좋습니다.
왜 캐싱·이벤트가 처음엔 어렵게 느껴질까요
이유는 세 가지예요.
첫째, @Cacheable이 마법처럼 동작합니다. 어노테이션 한 줄을 붙였더니 두 번째 호출부터 메서드 본문이 실행되지 않아요. "이게 어디서, 어떻게 가로채진 거지?" 라는 의문이 들 수밖에 없습니다. AOP 프록시가 바깥에서 메서드 호출을 가로채 캐시를 먼저 들여다본다는 흐름을 모르고 보면 동작이 안 보입니다.
둘째, 캐시 구현체가 너무 많아요. ConcurrentHashMap, Caffeine, Redis, EhCache, Hazelcast — 처음엔 어떤 걸 골라야 하는지 감이 안 옵니다. "단일 서버냐 다중 서버냐" 한 가지 기준만 잡으면 단순해지는데, 그 기준이 안 보이는 거죠.
셋째, 이벤트 리스너의 트랜잭션 경계가 헷갈려요. @EventListener로 감사 로그를 남겼는데 메인 트랜잭션이 롤백되면 감사 로그도 함께 롤백되는지, @TransactionalEventListener는 뭐가 다른지, @Async까지 붙으면 트랜잭션이 어떻게 되는지 — 이 조합이 한 번에 안 잡힙니다.
해결법은 두 가지 비유예요. 캐시는 "자주 쓰는 자료를 책상 옆 사물함에 보관" 하는 것이고, 이벤트는 "회사 게시판에 공지를 붙이면 관심 있는 부서가 알아서 가져가는" 식이에요. 이 두 비유만 잡고 가면 어노테이션 8개가 한 번에 정리됩니다.
왜 캐싱이 필요한가요 — 책상 옆 사물함 비유
회사에서 매번 자주 들춰 보는 서류가 있다고 해 봅시다. 그 서류가 7층 자료실에 있다면 한 번 가져오는 데 5분이 걸려요. 같은 서류를 하루에 50번 본다면 250분, 4시간을 자료실 왕복에만 쓰는 셈이에요. 그래서 책상 옆에 사물함을 두고 자주 보는 서류만 거기 보관하면, 한 번 가져온 뒤로는 1초면 꺼낼 수 있습니다.
@Cacheable이 정확히 이 역할을 합니다. 첫 호출 때 데이터베이스에서 조회한 결과를 JVM 메모리(또는 외부 캐시 서버) 에 저장해 두고, 같은 인자로 두 번째 호출이 오면 메서드 본문은 실행하지 않고 캐시에서 바로 반환해요. 디스크 I/O와 네트워크 왕복이 빠지니 응답 속도가 수십 밀리초에서 마이크로초 단위로 떨어집니다.
특히 효과가 극적인 경우는 읽기가 쓰기보다 훨씬 많은 영역이에요. 상품 카탈로그, 공통 코드, 카테고리 목록 같은 데이터는 하루에 수백만 번 읽혀도 갱신은 한두 번뿐이죠. 이런 데이터를 캐싱하지 않으면 그 자체로 시스템 병목이 됩니다.
Spring Cache 추상화 — @EnableCaching부터
Spring Cache의 가장 큰 강점은 추상화예요. 코드에는 @Cacheable·@CachePut·@CacheEvict 같은 표준 어노테이션만 붙이고, 실제 저장소는 ConcurrentHashMap이든 Caffeine이든 Redis든 자유롭게 교체할 수 있습니다. 회사로 치면 "사물함 브랜드는 나중에 바꿔도 되니 일단 사물함 이용 절차부터 표준화하자"는 거예요.
시작점은 단 한 줄 — @EnableCaching을 메인 클래스 또는 설정 클래스에 추가하는 것입니다.
@SpringBootApplication
@EnableCaching // 캐시 기능 활성화 — 이게 없으면 @Cacheable이 동작하지 않습니다
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
여기서 시험 함정이 하나 있어요. @EnableCaching을 빼먹으면 @Cacheable이 단순한 코멘트처럼 무시돼요. 동작 안 하는 게 아니라 "그냥 평범한 메서드 호출"로 처리됩니다. 캐시가 안 듣는 것 같으면 가장 먼저 이 어노테이션부터 확인하세요.
@Cacheable — 읽기에서 가장 많이 쓰는 어노테이션
@Cacheable은 메서드 결과를 캐시에 저장하고, 같은 키로 다시 호출되면 캐시에서 바로 반환합니다. 읽기 작업의 90% 이상이 이 어노테이션이에요.
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
/**
* productId를 key로 사용해 캐시 "productCache"에 저장
* 같은 productId로 두 번째 호출부터는 DB 조회 없이 캐시에서 반환
*/
@Cacheable(cacheNames = "productCache", key = "#productId")
public ProductDTO getProductById(UUID productId) {
// 이 코드는 캐시 미스(cache miss) 때만 실행됩니다
return productRepository.findById(productId)
.map(productMapper::productToDto)
.orElseThrow(() -> new NotFoundException("Product not found: " + productId));
}
/**
* 조건부 캐싱 — pageNumber가 0일 때만 캐시 적용
* 첫 페이지는 자주 요청되니 캐싱이 효과적
*/
@Cacheable(cacheNames = "productListCache", condition = "#pageNumber == 0")
public Page<ProductDTO> listProducts(String name, ProductCategory category,
Boolean showInventory, Integer pageNumber, Integer pageSize) {
return productRepository.findAll(buildSpec(name, category),
buildPageRequest(pageNumber, pageSize))
.map(productMapper::productToDto);
}
}
condition 속성은 시험에 자주 나옵니다. 모든 호출을 캐시할 필요는 없을 때 쓰는 안전판이에요. 위 예시처럼 첫 페이지만 캐싱하면 메모리는 아끼면서 효과는 거의 그대로 거둘 수 있습니다.
@CachePut — 데이터 수정 후 캐시 갱신
@CachePut은 메서드를 항상 실행하고 그 반환값으로 캐시를 갱신합니다. 데이터를 수정한 뒤 캐시를 최신 상태로 유지하고 싶을 때 쓰는 어노테이션이에요.
@CachePut(cacheNames = "productCache", key = "#productId")
public ProductDTO updateProduct(UUID productId, ProductDTO productDTO) {
Product product = productRepository.findById(productId)
.orElseThrow(NotFoundException::new);
// 업데이트 로직...
Product savedProduct = productRepository.save(product);
return productMapper.productToDto(savedProduct);
// 반환값이 자동으로 캐시에 저장됨
}
여기서 정말 중요한 시험 함정 — @Cacheable과 @CachePut을 헷갈리지 마세요. @Cacheable은 "캐시에 있으면 메서드 실행을 건너뛴다", @CachePut은 "메서드는 항상 실행하고 결과로 캐시를 덮어쓴다"입니다. 수정 메서드에 @Cacheable을 잘못 붙이면 두 번째 수정부터는 DB 업데이트 자체가 일어나지 않아요.
@CacheEvict — 캐시에서 항목 삭제
데이터를 삭제하거나 캐시 전체를 무효화해야 할 때 사용합니다.
/**
* 특정 상품 삭제 시 해당 캐시 항목 제거
*/
@CacheEvict(cacheNames = "productCache", key = "#productId")
public void deleteById(UUID productId) {
productRepository.deleteById(productId);
}
/**
* allEntries = true → 캐시의 모든 항목 제거
* beforeInvocation = true → 메서드 실행 전에 캐시 삭제 (기본값 false: 메서드 실행 후 삭제)
*/
@CacheEvict(cacheNames = "productListCache", allEntries = true)
public ProductDTO saveNewProduct(ProductDTO productDTO) {
Product savedProduct = productRepository.save(productMapper.dtoToProduct(productDTO));
return productMapper.productToDto(savedProduct);
}
@Caching — 복합 어노테이션
여러 캐시 어노테이션을 한 메서드에 붙여야 할 때 씁니다.
@Caching(evict = {
@CacheEvict(cacheNames = "productCache", key = "#productId"),
@CacheEvict(cacheNames = "productListCache", allEntries = true)
})
public void deleteProduct(UUID productId) {
productRepository.deleteById(productId);
}
CacheManager — 사물함 브랜드 고르기
@Cacheable만 붙이면 Spring Boot가 기본으로 ConcurrentHashMap 기반의 단순한 메모리 캐시를 자동으로 만들어 줍니다. 단일 서버 개발·테스트에는 충분하지만, TTL(만료 시간)이나 최대 크기 같은 정책을 제어할 수 없어요. 사물함은 만들어졌는데 청소 규칙이 없는 상태예요.
프로덕션에서는 두 갈래로 나뉩니다.
Caffeine — 단일 서버용 카페인 효과 캐시
Caffeine은 고성능 JVM 캐시 라이브러리예요. 이름처럼 카페인 효과처럼 빠르고, TTL·최대 크기·만료 전략을 세밀하게 제어할 수 있습니다. 서버가 한 대뿐인 환경에서 가장 권장되는 선택이에요.
<!-- pom.xml -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
// 모든 캐시에 기본 설정 적용
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(500) // 최대 500개 항목
.expireAfterWrite(10, TimeUnit.MINUTES) // 쓰기 후 10분 뒤 만료
.recordStats()); // 캐시 히트율 등 통계 수집
return cacheManager;
}
}
# application.properties로도 기본 설정 가능
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m
recordStats()는 캐시 히트율을 추적할 수 있게 해 줘요. Actuator의 /actuator/metrics로 노출하면 운영 중 캐시가 얼마나 효과적인지 한눈에 볼 수 있습니다.
Redis — 사옥 공용 캐시 창고
Redis는 다른 차원이에요. 같은 서비스가 여러 서버에서 동시에 돌아가는 환경에선 각 서버 메모리에 따로 캐시를 두면 일관성이 깨집니다. 한 서버에서 데이터를 갱신해도 다른 서버 캐시는 옛 데이터를 그대로 가지고 있죠.
이때 Redis는 사옥 전체가 공유하는 외부 캐시 창고 역할을 합니다. 모든 서버가 같은 Redis 인스턴스를 보니 일관성이 자연스럽게 유지돼요.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // 기본 TTL 10분
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer())); // JSON 직렬화
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
// 특정 캐시에 다른 TTL 적용
cacheConfigs.put("productCache", defaultConfig.entryTtl(Duration.ofHours(1)));
cacheConfigs.put("productListCache", defaultConfig.entryTtl(Duration.ofMinutes(5)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.cache.type=redis
Redis 공식 문서는 redis.io/docs/에서 자세히 볼 수 있어요. 분산 캐시 외에도 세션 저장소·메시지 브로커 등 활용처가 다양합니다.
캐시 구현체 비교표
| 구현체 | 저장 위치 | 분산 지원 | TTL 설정 | 통계 | 적합한 환경 |
|---|---|---|---|---|---|
| ConcurrentHashMap (기본) | JVM 힙 | 불가 | 불가 | 불가 | 단순 개발/테스트 |
| Caffeine | JVM 힙 | 불가 | 가능 | 가능 | 단일 서버 프로덕션 |
| Redis | 외부 서버 | 가능 | 가능 | 가능 | 다중 서버, 클러스터 |
| EhCache | JVM 힙/디스크 | 제한적 | 가능 | 가능 | 엔터프라이즈 온프레미스 |
| Hazelcast | 분산 메모리 | 가능 | 가능 | 가능 | 대규모 분산 시스템 |
판단 기준은 한 줄이에요 — 서버 한 대면 Caffeine, 여러 대면 Redis.
캐시 Key 전략 — SpEL로 유연하게
Spring은 기본적으로 메서드 파라미터를 조합해 키를 만들지만, SpEL(Spring Expression Language) 로 커스텀 키를 정의할 수 있어요.
// 단순 파라미터 key
@Cacheable(cacheNames = "productCache", key = "#productId")
// 복합 key — 여러 파라미터 조합
@Cacheable(cacheNames = "productListCache",
key = "#name + '_' + #category + '_' + #pageNumber")
// 객체의 특정 필드를 key로
@Cacheable(cacheNames = "productCache", key = "#productDTO.id")
// 커스텀 KeyGenerator 등록
@Bean
public KeyGenerator customKeyGenerator() {
return (target, method, params) -> {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName());
sb.append("_").append(method.getName());
for (Object param : params) {
sb.append("_").append(param);
}
return sb.toString();
};
}
@Cacheable(cacheNames = "productCache", keyGenerator = "customKeyGenerator")
public ProductDTO getProduct(UUID productId) { ... }
캐시 키 설계는 의외로 중요한 영역이에요. 키가 너무 거칠면 다른 호출이 같은 캐시 항목을 덮어쓰고, 너무 세밀하면 같은 데이터인데 캐시 미스가 자주 발생합니다.
왜 애플리케이션 이벤트가 필요한가요 — 사내 게시판 비유
비즈니스 로직이 복잡해질수록 핵심 서비스에 감사 로그·알림·외부 시스템 연동 같은 부가 기능이 얽히기 시작해요. 주문 처리 메서드 한 개에 "주문 저장 → 감사 로그 → 이메일 발송 → 재고 차감 → 분석 시스템 통보" 다섯 가지 책임이 다 들어가는 식이죠. 한 곳을 고치면 다른 곳이 깨지는 악순환에 빠집니다.
이럴 때 이벤트 기반 프로그래밍이 빛을 발해요. 회사 비유로 풀면 — "사내 게시판에 공지를 붙여 두면 관심 있는 부서가 알아서 가져간다" 는 식이에요. 주문 처리 서비스는 "주문이 발생했어요"라는 이벤트만 발행하면, 감사 로그·이메일·재고 부서가 각자 알아서 듣고 처리합니다.
핵심 원칙은 한 줄 — 발행자(publisher)는 수신자(listener)가 누구인지 알 필요가 없다는 것이에요. 결합도가 극도로 낮아지고, 새 리스너를 추가하려고 발행자 코드를 수정할 필요가 없어집니다.
커스텀 이벤트 클래스 만들기
이벤트는 그저 평범한 POJO 클래스예요. Spring 4.2 이전에는 ApplicationEvent를 상속해야 했지만, 4.2부터는 아무 클래스나 이벤트로 쓸 수 있습니다.
/**
* 전통적 방식 — ApplicationEvent 상속
*/
public class OrderCreatedEvent extends ApplicationEvent {
private final OrderDTO order;
public OrderCreatedEvent(Object source, OrderDTO order) {
super(source);
this.order = order;
}
public OrderDTO getOrder() {
return order;
}
}
/**
* 현대적 방식 — POJO 이벤트 (Spring 4.2+)
*/
@Getter
@AllArgsConstructor
public class OrderPlacedEvent {
private final OrderDTO order;
private final LocalDateTime placedAt;
}
POJO 방식이 훨씬 단순해서 실무에서는 거의 이쪽을 씁니다.
이벤트 발행 — ApplicationEventPublisher
이벤트를 발행하려면 ApplicationEventPublisher를 주입받아 publishEvent()를 호출하면 끝이에요. Spring이 자동으로 주입해 줍니다.
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher; // 자동 주입
@Override
@Transactional
public OrderDTO updateOrder(UUID orderId, OrderUpdateDTO updateDTO) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new NotFoundException("Order Not Found: " + orderId));
order.setCustomerRef(updateDTO.getCustomerRef());
// 결제 금액이 설정된 경우 이벤트 발행
if (updateDTO.getPaymentAmount() != null) {
order.setPaymentAmount(updateDTO.getPaymentAmount());
Order savedOrder = orderRepository.save(order);
// 이벤트 발행 — 리스너가 누구인지, 얼마나 많은지 알 필요 없음
eventPublisher.publishEvent(new OrderPlacedEvent(
orderMapper.orderToDto(savedOrder),
LocalDateTime.now()
));
return orderMapper.orderToDto(savedOrder);
}
return orderMapper.orderToDto(orderRepository.save(order));
}
}
발행자는 그저 게시판에 공지만 붙입니다. 누가 읽고 어떻게 처리하는지는 상관하지 않아요.
이벤트 리스너 3가지 — 동기·트랜잭션·비동기
리스너는 세 가지 방식이 있고, 각자의 자리가 다릅니다.
@EventListener — 동기 처리
기본적으로 이벤트 발행 스레드와 동일한 스레드에서 동기로 실행돼요. 발행자의 트랜잭션 안에서 함께 처리되니, 같은 트랜잭션 내에서 마무리해야 하는 감사 로그 같은 작업에 어울립니다.
@Component
@Slf4j
public class OrderAuditListener {
private final OrderAuditRepository auditRepository;
public OrderAuditListener(OrderAuditRepository auditRepository) {
this.auditRepository = auditRepository;
}
/**
* OrderPlacedEvent를 수신해 감사 로그 기록
* 이벤트 타입을 파라미터 타입으로 자동 매핑
*/
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
log.debug("Audit: Order Placed - {}", event.getOrder().getId());
OrderAudit audit = OrderAudit.builder()
.orderId(event.getOrder().getId())
.customerRef(event.getOrder().getCustomerRef())
.paymentAmount(event.getOrder().getPaymentAmount())
.auditEventType("ORDER_PLACED")
.createdDate(LocalDateTime.now())
.build();
auditRepository.save(audit);
}
/**
* 조건부 이벤트 처리 — SpEL 조건식
* 결제 금액이 100 이상일 때만 처리
*/
@EventListener(condition = "#event.order.paymentAmount >= 100")
public void onLargeOrderPlaced(OrderPlacedEvent event) {
log.info("Large order detected: {}", event.getOrder().getPaymentAmount());
// 대용량 주문 알림 처리...
}
}
@TransactionalEventListener — 트랜잭션 단계 후 처리
여기서 시험 함정이 하나 있어요. @EventListener는 발행자의 트랜잭션 안에서 함께 돌기 때문에, 리스너가 실패하면 발행자의 트랜잭션도 같이 롤백될 수 있습니다. 감사 로그는 그래도 괜찮은데, 이메일은 어떨까요? 트랜잭션이 롤백됐는데 이메일은 이미 나갔다면 사용자에게 잘못된 정보가 전달돼요.
이럴 때 @TransactionalEventListener가 자리를 잡습니다. 트랜잭션이 성공적으로 커밋된 후에만 실행되거나, 롤백된 후에만 실행되도록 제어할 수 있어요.
@Component
@Slf4j
public class EmailNotificationListener {
private final EmailService emailService;
public EmailNotificationListener(EmailService emailService) {
this.emailService = emailService;
}
/**
* AFTER_COMMIT — DB 트랜잭션 커밋 성공 후에만 이메일 발송
* 트랜잭션이 롤백되면 이 메서드는 실행되지 않음
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendOrderConfirmationEmail(OrderPlacedEvent event) {
log.info("Sending order confirmation email for order: {}", event.getOrder().getId());
emailService.sendOrderConfirmation(event.getOrder());
}
/**
* AFTER_ROLLBACK — 트랜잭션 롤백 시 보상 처리
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleOrderFailure(OrderPlacedEvent event) {
log.error("Order transaction rolled back: {}", event.getOrder().getId());
}
}
@Async — 비동기 처리
발행자가 리스너 처리를 기다리지 않아도 될 때 사용해요. 별도 스레드에서 돌아가니 응답 시간이 단축됩니다. 외부 API 호출·파일 저장 같은 느린 작업에 어울려요.
@Configuration
@EnableAsync // 비동기 기능 활성화 필수!
public class AsyncConfig {
@Bean
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(10);
executor.setThreadNamePrefix("EventAsync-");
executor.initialize();
return executor;
}
}
@Component
public class AsyncAuditListener {
@Async // 별도 스레드에서 실행
@EventListener
public void onOrderPlacedAsync(OrderPlacedEvent event) {
// 느린 작업도 메인 스레드를 차단하지 않음
performSlowAuditOperation(event);
}
}
여기서 정말 중요한 시험 함정 — @EnableAsync를 빼먹으면 @Async가 그냥 동기로 실행돼요. @EnableCaching을 빼먹은 경우와 똑같은 함정입니다. 비동기로 돌고 있는 줄 알았는데 사실 응답 시간이 그대로면 가장 먼저 이 어노테이션을 확인하세요.
이벤트 리스너 방식 비교표
| 방식 | 실행 타이밍 | 트랜잭션 참여 | 스레드 | 주요 용도 |
|---|---|---|---|---|
@EventListener | 즉시 (동기) | 발행자 트랜잭션 내 | 동일 | 감사 로그, 빠른 처리 |
@TransactionalEventListener | 트랜잭션 단계 후 | 별도 트랜잭션 | 동일 | 이메일, 외부 연동 |
@Async + @EventListener | 즉시 (비동기) | 독립적 | 별도 | 느린 작업, 알림 |
판단 기준 — 즉시 처리 + 같은 트랜잭션 = @EventListener, 커밋 후 외부 호출 = @TransactionalEventListener, 응답 시간이 중요 = @Async.
Spring 내장 이벤트 활용
Spring 프레임워크 자체도 다양한 이벤트를 발행해요. 애플리케이션 시작·종료 시점에 추가 처리가 필요할 때 활용합니다.
@Component
@Slf4j
public class ApplicationLifecycleListener {
/**
* ApplicationContext 초기화 완료 후 실행
* 초기 데이터 로드, 캐시 워밍업 등에 활용
*/
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
log.info("Application is ready - warming up caches...");
// 자주 쓰는 데이터를 미리 캐시에 로드
}
/**
* 컨텍스트 종료 전 실행 — 정리 작업
*/
@EventListener(ContextClosedEvent.class)
public void onContextClosed() {
log.info("Application context is closing...");
}
}
ApplicationReadyEvent는 캐시 워밍업의 정석 자리예요. 사용자가 첫 요청을 보내기 전에 자주 쓰이는 데이터를 미리 캐시에 채워 두면, 첫 응답부터 빠릅니다.
감사 테이블 + SecurityContext 연동
감사 로그를 남길 때 누가 그 작업을 했는지도 함께 기록하면 가치가 훨씬 높아져요. Spring Security 컨텍스트에서 현재 사용자를 꺼내 감사 엔티티에 넣는 패턴이 자주 등장합니다.
@Entity
@Table(name = "order_audit")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderAudit {
@Id
@GeneratedValue(generator = "UUID")
@GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
@Column(length = 36, columnDefinition = "varchar(36)", updatable = false, nullable = false)
private UUID id;
private UUID orderId;
private String customerRef;
private BigDecimal paymentAmount;
private String auditEventType; // "ORDER_PLACED", "ORDER_SHIPPED" 등
@CreationTimestamp
private LocalDateTime createdDate;
private String auditUser; // 작업을 수행한 사용자
private String principalName; // Spring Security 인증 주체
}
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
// Spring Security 컨텍스트에서 현재 사용자 정보 추출
String principalName = null;
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
principalName = auth.getName();
}
OrderAudit audit = OrderAudit.builder()
.orderId(event.getOrder().getId())
.auditEventType("ORDER_PLACED")
.principalName(principalName)
.createdDate(LocalDateTime.now())
.build();
auditRepository.save(audit);
}
캐시 어노테이션 비교표
| 어노테이션 | 메서드 실행 | 캐시 동작 | 주요 용도 |
|---|---|---|---|
@Cacheable | 캐시 미스 때만 실행 | 결과를 캐시에 저장 후 반환 | 읽기(GET) |
@CachePut | 항상 실행 | 결과로 캐시 갱신 | 수정 후 캐시 최신화 |
@CacheEvict | 항상 실행 | 캐시에서 항목 삭제 | 삭제(DELETE) |
@Caching | 항상 실행 | 복수 어노테이션 조합 | 복합 캐시 작업 |
이벤트 vs 직접 메서드 호출
| 항목 | 이벤트 방식 | 직접 호출 방식 |
|---|---|---|
| 결합도 | 낮음 (발행자가 수신자 모름) | 높음 (발행자가 수신자 알아야 함) |
| 확장성 | 리스너만 추가하면 됨 | 발행자 코드 수정 필요 |
| 테스트 | 각각 독립적으로 가능 | 의존관계로 인해 복잡 |
| 디버깅 | 흐름 추적이 다소 복잡 | 직관적이고 명확 |
| 비동기 처리 | @Async로 쉽게 구현 | 별도 스레드 관리 필요 |
자주 만나는 함정 5가지
1. @EnableCaching·@EnableAsync 누락
이미 두 번 언급했지만 한 번 더 — 이 어노테이션이 없으면 @Cacheable·@Async는 그냥 무시됩니다. 동작 안 할 때 가장 먼저 확인할 자리예요.
2. 같은 클래스 내부 호출 시 캐시 동작 안 함
Spring 캐시는 AOP 프록시를 통해 동작해요. 같은 클래스의 다른 메서드를 this.method()로 직접 호출하면 프록시를 거치지 않아 캐시가 적용되지 않습니다.
// 잘못된 예 — this.를 통한 내부 호출은 캐시 미적용
@Service
public class ProductService {
public void someMethod() {
this.getProductById(id); // 캐시 동작 안 함!
}
@Cacheable("productCache")
public ProductDTO getProductById(UUID id) { ... }
}
// 해결 — 별도 서비스 클래스로 분리
@Service
public class ProductReadService {
@Cacheable("productCache")
public ProductDTO getProductById(UUID id) { ... }
}
같은 함정이 @Transactional에도 있어요. 17편(시리즈 마지막)에서 다시 자세히 풀어 갑니다.
3. 캐시 무효화 잊기
쓰기 작업 후 캐시를 갱신하지 않으면 오래된 데이터(stale data) 가 반환됩니다.
// 잘못된 예 — 업데이트 후 캐시 갱신 없음
public void updateProduct(UUID productId, ProductDTO dto) {
// DB 업데이트는 됐지만 "productCache"에는 여전히 이전 데이터
productRepository.save(mapper.dtoToProduct(dto));
}
// 올바른 예
@CachePut(cacheNames = "productCache", key = "#productId")
public ProductDTO updateProduct(UUID productId, ProductDTO dto) {
Product saved = productRepository.save(mapper.dtoToProduct(dto));
return mapper.productToDto(saved);
}
4. null 캐싱 문제
기본적으로 null 값도 캐시에 저장돼요. 한 번 null이 캐시되면 데이터가 없는데도 계속 null만 반환됩니다.
// unless 속성으로 null 캐싱 방지
@Cacheable(cacheNames = "productCache", key = "#id", unless = "#result == null")
public ProductDTO findProduct(UUID id) {
return productRepository.findById(id)
.map(mapper::productToDto)
.orElse(null); // null은 캐시하지 않음
}
5. 리스너 예외가 발행자 트랜잭션을 롤백시킴
// 주의 — 리스너의 예외가 발행자 트랜잭션에 영향을 줌
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
// 여기서 RuntimeException 발생 시 주문 저장 트랜잭션도 롤백됨
auditRepository.save(buildAudit(event));
}
// 안전한 방법 — try-catch로 격리
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
try {
auditRepository.save(buildAudit(event));
} catch (Exception e) {
log.error("Audit logging failed (non-critical): {}", e.getMessage());
// 감사 로그 실패가 주문 처리를 막아서는 안 됨
}
}
또는 @TransactionalEventListener로 트랜잭션 경계 자체를 분리하는 방법도 있어요.
추가 — 순환 이벤트 발행
리스너가 같은 타입의 이벤트를 다시 발행하면 무한 루프가 발생합니다.
// 위험한 코드 — StackOverflowError
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
// 처리 후 다시 동일한 이벤트 발행
eventPublisher.publishEvent(new OrderPlacedEvent(event.getOrder()));
}
리스너 안에서는 다른 타입의 이벤트만 발행하거나, 같은 타입을 발행해야 한다면 조건을 걸어 무한 루프를 막아야 합니다.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 14편 캐싱·이벤트의 핵심이에요. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
@EnableCaching누락 =@Cacheable이 그냥 코멘트 처리 — 동작 안 하면 가장 먼저 확인@Cacheable은 캐시 미스 때만 실행,@CachePut은 항상 실행하고 결과로 캐시 갱신@CacheEvict(allEntries = true)= 해당 캐시의 모든 항목 제거@Caching은 여러 캐시 어노테이션을 한 메서드에 묶을 때- 캐시 구현체 — 단일 서버 = Caffeine, 다중 서버 = Redis, 기본 ConcurrentHashMap은 TTL 제어 불가
- Caffeine = "카페인 효과처럼 빠른 단일 서버 캐시", Redis = "사옥 공용 캐시 창고"
- Spring 캐시는 AOP 프록시 기반 — 같은 클래스 내부 호출은 캐시 미적용
- 쓰기 후 반드시 캐시 무효화(
@CacheEvict) 또는 갱신(@CachePut) 필요 - null 캐싱 방지 —
unless = "#result == null" - SpEL key —
key = "#productId"또는 복합key = "#name + '_' + #pageNumber" - 이벤트 = "사내 게시판" 비유, 발행자는 수신자가 누구인지 모름 (낮은 결합도)
- POJO 이벤트 가능 (Spring 4.2+) —
ApplicationEvent상속 불필요 ApplicationEventPublisher주입받아publishEvent()호출@EventListener= 동기, 발행자 트랜잭션 내 → 감사 로그@TransactionalEventListener(phase = AFTER_COMMIT)= 커밋 후 → 이메일·외부 연동@Async + @EventListener= 비동기 → 느린 작업@EnableAsync누락 =@Async가 동기로 실행 — 응답 시간 안 줄면 확인- 리스너 예외가 발행자 트랜잭션을 롤백시킬 수 있음 — try-catch 또는
@TransactionalEventListener - 순환 이벤트 발행 = StackOverflowError — 리스너에서 같은 타입 재발행 금지
ApplicationReadyEvent= 캐시 워밍업의 정석 자리- 감사 로그에
SecurityContextHolder로 현재 사용자 추가 → 가치 ↑
캐시 추상화의 자세한 사양은 Spring Cache 공식 문서에서, Redis의 깊은 활용은 redis.io/docs/에서 확인할 수 있어요.
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Spring Boot 입문
- 2편 — Spring MVC REST · MockMVC
- 3편 — Spring Data JPA · 검증
- 4편 — MySQL · Flyway · TestContainers
- 5편 — CSV 업로드 · 페이징 · 동적 쿼리
- 6편 — JPA 관계 매핑 심화
- 7편 — Spring Security · OAuth 2.0 · JWT
- 8편 — RestTemplate · RestClient
- 9편 — Reactive Programming · WebFlux 입문
- 10편 — WebFlux 심화 · MongoDB · WebClient
- 11편 — Cloud Gateway · Maven/Gradle · Buildpack
- 12편 — OpenAPI · Spring AI
- 13편 — Actuator · 관측성
- 14편 — Spring Cache · 이벤트 (현재 글)
- 15편 — Docker · Compose · Kubernetes
- 16편 — 마이크로서비스 · Apache Kafka
- 17편 — Spring Professional · 베스트 프랙티스 (완)