Spring에서 제공하는 추상화된 캐싱 기능입니다. 캐시 저장소(Local Map, Caffeine, Redis, EhCache 등)와 무관하게 동일한 어노테이션으로 캐싱 동작을 선언할 수 있게 해줍니다.
@SpringBootApplication
@EnableCaching
public class Application { ... }
@EnableCaching을 선언하면 Spring이 캐시 어노테이션을 AOP 프록시로 처리합니다. 메서드 호출이 프록시를 거쳐 캐시를 확인하고, 필요할 때만 실제 메서드를 실행하는 구조입니다.
가장 일반적인 캐시 어노테이션입니다. 메서드 호출 결과를 캐시하고, 같은 키로 다시 호출되면 메서드를 실행하지 않고 캐시 값을 반환합니다.
@Service
public class MemberService {
@Cacheable(value = "members", key = "#id")
public MemberDto getMember(Long id) {
// 캐시에 없을 때만 실행됨
return memberRepository.findById(id)
.map(MemberDto::from)
.orElseThrow();
}
}
1. getMember(1L) 호출
2. 캐시 "members"에서 key=1 조회
├─ 캐시 히트 → 캐시 값 반환 (메서드 실행 X)
└─ 캐시 미스 → 메서드 실행 → 결과를 캐시에 저장 → 반환
@Cacheable(
value = "members",
key = "#id",
condition = "#id > 0",
unless = "#result == null",
sync = true
)
public MemberDto getMember(Long id) { ... }
캐시 존재 여부와 무관하게 메서드를 항상 실행하고, 그 결과를 캐시에 저장합니다. 캐시를 새 값으로 덮어쓰는 용도입니다.
@CachePut(value = "members", key = "#member.id")
public MemberDto updateMember(MemberDto member) {
Member updated = memberRepository.save(member.toEntity());
return MemberDto.from(updated);
}
1. updateMember(...) 호출
2. 메서드 항상 실행 (캐시 확인 안 함)
3. 결과를 캐시 "members"의 key=member.id에 저장(덮어쓰기)
4. 결과 반환
@CachePut은 메서드 반환값을 캐시에 저장합니다. 그래서 반환 타입이 void이거나 캐시되어야 할 객체가 아니라면 의미가 없습니다. 보통 save()나 update() 같은 메서드와 함께 씁니다.
지정한 키의 캐시를 제거합니다. 데이터를 삭제하거나 캐시를 무효화해야 할 때 사용합니다.
@CacheEvict(value = "members", key = "#id")
public void deleteMember(Long id) {
memberRepository.deleteById(id);
}
전체 캐시를 비우는 것도 가능합니다.
@CacheEvict(value = "members", allEntries = true)
public void clearAllMembers() {
// 캐시 "members" 전체 제거
}
beforeInvocation 동작 차이
@CacheEvict(value = "members", key = "#id", beforeInvocation = false) // 기본값
public void deleteMember(Long id) {
memberRepository.deleteById(id);
// 1. 메서드 실행
// 2. 정상 종료 시 캐시 제거
// 3. 예외 발생 시 캐시 제거 안 됨 (DB 삭제 실패 시 캐시 유지)
}
@CacheEvict(value = "members", key = "#id", beforeInvocation = true)
public void deleteMember(Long id) {
// 1. 캐시 먼저 제거
// 2. 메서드 실행
// 3. 예외 발생해도 캐시는 이미 제거됨
}
@Service
public class MemberService {
// 조회: 캐시에서 먼저 찾고, 없으면 DB 조회 후 캐싱
@Cacheable(value = "members", key = "#id")
public MemberDto getMember(Long id) {
return memberRepository.findById(id)
.map(MemberDto::from)
.orElseThrow();
}
// 수정: DB와 캐시를 함께 갱신
@CachePut(value = "members", key = "#dto.id")
public MemberDto updateMember(MemberDto dto) {
Member updated = memberRepository.save(dto.toEntity());
return MemberDto.from(updated);
}
// 삭제: DB 삭제 후 캐시 제거
@CacheEvict(value = "members", key = "#id")
public void deleteMember(Long id) {
memberRepository.deleteById(id);
}
}
@Caching으로 묶을 수 있습니다.
@Caching(
put = {
@CachePut(value = "members", key = "#dto.id")
},
evict = {
@CacheEvict(value = "memberList", allEntries = true),
@CacheEvict(value = "memberCount", allEntries = true)
}
)
public MemberDto updateMember(MemberDto dto) {
// 단건 캐시는 갱신, 목록/카운트 캐시는 전체 제거
}
@Transactional과 동일한 문제입니다. 같은 클래스 내부 메서드를 호출하면 프록시를 거치지 않아 캐시가 동작하지 않습니다.
@Service
public class MemberService {
public MemberDto findAndProcess(Long id) {
return getMember(id); // 캐시 적용 안 됨
}
@Cacheable(value = "members", key = "#id")
public MemberDto getMember(Long id) { ... }
}
해결: 메서드를 별도 빈으로 분리하거나, 자기 자신을 주입(Self-Injection)합니다.
캐시 미스 상황에서 여러 스레드가 동시에 같은 메서드를 호출하면, 모두 DB를 조회해서 캐시를 채웁니다(Thundering Herd). sync = true로 막을 수 있습니다.
@Cacheable(value = "members", key = "#id", sync = true)
public MemberDto getMember(Long id) { ... }
다만 sync는 모든 캐시 매니저가 지원하지 않으며, 분산 환경에서는 한계가 있습니다.
키를 객체로 지정할 때 equals()와 hashCode()가 제대로 구현되어 있어야 합니다. 또한 SpEL에서 null 처리에 유의해야 합니다.
// nullable 파라미터에 안전한 키
@Cacheable(
value = "members",
key = "#id ?: 'none'",
condition = "#id != null"
)
public MemberDto getMember(Long id) { ... }
같은 캐시 이름을 다른 메서드와 공유할 때 키 충돌이 날 수 있습니다. 메서드별로 캐시 이름을 분리하거나, 키에 prefix를 두는 것이 안전합니다.
@Cacheable(value = "members", key = "'detail:' + #id")
public MemberDetailDto getMemberDetail(Long id) { ... }
@Cacheable(value = "members", key = "'summary:' + #id")
public MemberSummaryDto getMemberSummary(Long id) { ... }
@CacheEvict나 @CachePut은 메서드가 끝나는 시점에 동작하는데, 이것이 트랜잭션 commit 이전입니다. DB 변경이 롤백되어도 캐시는 이미 갱신/제거된 상태가 될 수 있습니다.
@Transactional
@CacheEvict(value = "members", key = "#id")
public void deleteMember(Long id) {
memberRepository.deleteById(id);
throw new RuntimeException(); // DB 롤백되지만 캐시는 제거된 상태
}
해결책
TransactionalEventListener로 commit 후 캐시 처리