[Spring] Cache

dustle·2026년 6월 14일

Spring Cache란

Spring에서 제공하는 추상화된 캐싱 기능입니다. 캐시 저장소(Local Map, Caffeine, Redis, EhCache 등)와 무관하게 동일한 어노테이션으로 캐싱 동작을 선언할 수 있게 해줍니다.

@SpringBootApplication
@EnableCaching
public class Application { ... }

@EnableCaching을 선언하면 Spring이 캐시 어노테이션을 AOP 프록시로 처리합니다. 메서드 호출이 프록시를 거쳐 캐시를 확인하고, 필요할 때만 실제 메서드를 실행하는 구조입니다.


1. @Cacheable - 조회 결과 캐싱

가장 일반적인 캐시 어노테이션입니다. 메서드 호출 결과를 캐시하고, 같은 키로 다시 호출되면 메서드를 실행하지 않고 캐시 값을 반환합니다.

@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)
   └─ 캐시 미스 → 메서드 실행 → 결과를 캐시에 저장 → 반환

옵션

  • value (또는 cacheNames): 캐시 이름. 여러 개 지정 가능합니다
  • key: 캐시 키. SpEL(Spring Expression Language)로 작성합니다
  • condition: 캐시할지 말지 결정하는 조건
  • unless: 결과에 따라 캐시에서 제외할 조건 (메서드 실행 후 평가)
  • sync: 동시에 같은 키로 요청이 들어왔을 때 중복 실행 방지
@Cacheable(
    value = "members",
    key = "#id",
    condition = "#id > 0",
    unless = "#result == null",
    sync = true
)
public MemberDto getMember(Long id) { ... }

사용 용도

  • 조회 빈도는 높지만 변경 빈도는 낮은 데이터
  • 카테고리 목록, 설정값, 코드 테이블, 인기 게시글 등
  • DB 부하가 큰 복잡한 집계 쿼리 결과

2. @CachePut - 캐시 강제 갱신

캐시 존재 여부와 무관하게 메서드를 항상 실행하고, 그 결과를 캐시에 저장합니다. 캐시를 새 값으로 덮어쓰는 용도입니다.

@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. 결과 반환

사용 용도

  • 데이터 수정 메서드에서 캐시를 함께 갱신할 때
  • DB와 캐시가 분리되어 있어 한쪽만 변경하면 데이터 불일치가 생기는 경우
  • 수정 직후 조회 시 최신 값을 즉시 반환하고 싶을 때

주의할 점

@CachePut은 메서드 반환값을 캐시에 저장합니다. 그래서 반환 타입이 void이거나 캐시되어야 할 객체가 아니라면 의미가 없습니다. 보통 save()update() 같은 메서드와 함께 씁니다.


3. @CacheEvict - 캐시 제거

지정한 키의 캐시를 제거합니다. 데이터를 삭제하거나 캐시를 무효화해야 할 때 사용합니다.

@CacheEvict(value = "members", key = "#id")
public void deleteMember(Long id) {
    memberRepository.deleteById(id);
}

전체 캐시를 비우는 것도 가능합니다.

@CacheEvict(value = "members", allEntries = true)
public void clearAllMembers() {
    // 캐시 "members" 전체 제거
}

옵션

  • key: 제거할 캐시 키
  • allEntries: true면 해당 캐시의 모든 항목 제거
  • beforeInvocation: 메서드 실행 전에 캐시를 제거할지 결정 (기본 false)

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) {
    // 단건 캐시는 갱신, 목록/카운트 캐시는 전체 제거
}

자주 발생하는 문제와 주의점

1. 자기 자신 호출 시 동작 안 함

@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)합니다.

2. 동시성 - sync 옵션

캐시 미스 상황에서 여러 스레드가 동시에 같은 메서드를 호출하면, 모두 DB를 조회해서 캐시를 채웁니다(Thundering Herd). sync = true로 막을 수 있습니다.

@Cacheable(value = "members", key = "#id", sync = true)
public MemberDto getMember(Long id) { ... }

다만 sync는 모든 캐시 매니저가 지원하지 않으며, 분산 환경에서는 한계가 있습니다.

3. SpEL 키 작성 주의

키를 객체로 지정할 때 equals()hashCode()가 제대로 구현되어 있어야 합니다. 또한 SpEL에서 null 처리에 유의해야 합니다.

// nullable 파라미터에 안전한 키
@Cacheable(
    value = "members",
    key = "#id ?: 'none'",
    condition = "#id != null"
)
public MemberDto getMember(Long id) { ... }

4. 캐시 키 충돌

같은 캐시 이름을 다른 메서드와 공유할 때 키 충돌이 날 수 있습니다. 메서드별로 캐시 이름을 분리하거나, 키에 prefix를 두는 것이 안전합니다.

@Cacheable(value = "members", key = "'detail:' + #id")
public MemberDetailDto getMemberDetail(Long id) { ... }

@Cacheable(value = "members", key = "'summary:' + #id")
public MemberSummaryDto getMemberSummary(Long id) { ... }

5. 트랜잭션과 캐시 타이밍

@CacheEvict@CachePut은 메서드가 끝나는 시점에 동작하는데, 이것이 트랜잭션 commit 이전입니다. DB 변경이 롤백되어도 캐시는 이미 갱신/제거된 상태가 될 수 있습니다.

@Transactional
@CacheEvict(value = "members", key = "#id")
public void deleteMember(Long id) {
    memberRepository.deleteById(id);
    throw new RuntimeException(); // DB 롤백되지만 캐시는 제거된 상태
}

해결책

  • TransactionalEventListener로 commit 후 캐시 처리
  • 또는 트랜잭션 동기화 기반 캐시 매니저 사용

0개의 댓글