본문 바로가기
백엔드 개발

로컬 캐시(Local Cache), 언제 쓰고 어떻게 써야 할까? (Spring + Caffeine 정리)

by collenkim 2026. 4. 1.
반응형

서비스를 운영하다 보면 조회 성능 때문에 캐시를 고민하게 됩니다. 보통은 Redis를 먼저 떠올리지만, 모든 환경에서 Redis를 쓸 수 있는 건 아닙니다. 망 분리, 비용, 네트워크 레이턴시 같은 현실적인 이유로 외부 캐시가 어려운 경우도 많습니다.

이럴 때 생각보다 잘 먹히는 선택이 로컬 캐시(Local Cache) 입니다.
조건만 맞으면, DB 부하를 크게 줄이면서 응답 속도도 확실히 개선할 수 있습니다.


로컬 캐시는 언제 쓰는 게 맞을까?

아무 데나 쓰는 건 아니고, 아래 조건이면 효과가 확실합니다.

1. Redis 같은 외부 캐시를 쓰기 어려운 경우

  • 폐쇄망 / 온프레미스 환경
  • 네트워크 호출 자체가 부담인 구조
  • 인프라 비용 최소화가 필요한 상황

이럴 때는 애플리케이션 메모리를 그대로 쓰는 게 가장 단순하고 빠릅니다.


2. 조회 비중이 높은 데이터

  • 공통 코드 (국가 코드, 상태 코드 등)
  • 설정값
  • 거의 변경되지 않는 데이터

읽기가 대부분이면 캐시 효율이 잘 나옵니다.


3. 응답 속도가 중요한 API

로컬 캐시는 네트워크를 타지 않습니다.
메모리 접근이라서 Redis보다도 빠릅니다.


로 컬 캐시를 쓰면 얻는 이점

실제로 체감되는 건 아래 3가지입니다.

  • 응답 속도 개선 → DB 접근 감소
  • DB 부하 감소 → 트래픽 몰릴 때 안정적
  • 구조 단순화 → 외부 캐시 없이 운영 가능

특히 조회 트래픽이 많은 서비스에서 효과가 큽니다.


Spring에서 적용하는 방법 (Caffeine 기준)

실무에서는 단순 Map 대신 Caffeine을 많이 씁니다.

 

1. 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-cache' 
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'

 

2. 기본 설정

@Configuration 
@EnableCaching 
public class CacheConfig { 
	
    @Bean 
    public CacheManager cacheManager() { 
    	CaffeineCacheManager cacheManager = new CaffeineCacheManager("codeCache"); 
        
        cacheManager
        	.setCaffeine(Caffeine.newBuilder() 
        	.maximumSize(10_000) // 메모리 보호 
        	.expireAfterWrite(10, TimeUnit.MINUTES) // TTL 
        ); 
        
        return cacheManager; 
    } 
}

 

여기서 핵심은 두 가지입니다.

maximumSize → 메모리 제한 (필수)
expireAfterWrite → 데이터 갱신 전략

 

 

3. 사용 예시 (Cache-Aside 패턴)

@Service 
public class CodeService { 
	
    @Cacheable(value = "codeCache", key = "#key") 
	public Code getCode(String key) { 
    	return codeRepository.findByKey(key); 
    } 
}

 

흐름은 단순합니다.

  1. 캐시 확인
  2. 없으면 DB 조회
  3. 결과를 캐시에 저장

Init 시 캐시 미리 올릴까?

자주 조회되는 데이터라면 초기 로딩을 해두는 게 낫습니다.

 

@Component 
@RequiredArgsConstructor 
public class CacheWarmUp { 
	
    private final CodeRepository codeRepository; 
    private final CacheManager cacheManager; 
    
    @PostConstruct 
    public void init() { 
    	Cache cache = cacheManager.getCache("codeCache"); 
		codeRepository.findAll() 
        .forEach(code -> cache.put(code.getKey(), code)); 
    } 
}

 

이렇게 하면 첫 요청이 느려지는 문제를 피할 수 있습니다.


TTL은 꼭 설정해야 할까?

케이스에 따라 다릅니다.

TTL이 필요한 경우

  • 데이터가 변경될 수 있음
  • 정합성이 중요한 경우

없어도 되는 경우

  • 코드 테이블처럼 거의 변하지 않는 데이터

애매하면 TTL을 넣는 쪽이 안전합니다.


메모리 관리 (이거 안 하면 장애 납니다)

로컬 캐시는 JVM 메모리를 직접 쓰기 때문에 관리가 필수입니다.

1. 최대 크기 제한

.maximumSize(10_000)

 

이거 없으면 계속 쌓입니다.

 

2. 큰 객체 그대로 넣지 않기

필요한 데이터만 캐싱하는 게 좋습니다.

 

3. TTL 또는 eviction 전략 필수

오래된 데이터는 정리되어야 합니다.

 

4. GC 영향 고려

캐시가 커질수록 GC 부담이 커집니다.
잘못하면 응답 지연으로 이어집니다.


다중 서버 환경에서 반드시 고려할 점

여기서 많이 놓칩니다.

로컬 캐시는 공유되지 않는다

  • 서버 A 캐시 ≠ 서버 B 캐시
  • 서로 동기화되지 않음

그래서 데이터 불일치가 생깁니다.


해결 방법은 3가지

1. TTL로 자연스럽게 맞추기

.expireAfterWrite(5, TimeUnit.MINUTES)
  • 일정 시간 후 자동 갱신
  • 간단하지만 완벽하진 않음

2. 이벤트 기반 캐시 무효화

데이터 변경 시 모든 서버에 알림을 보내 캐시를 제거하는 방식입니다.

// 데이터 변경 후 이벤트 발행
eventPublisher.publishEvent(new CodeUpdatedEvent(key));

// 이벤트 수신 후 캐시 제거
@EventListener
public void handle(CodeUpdatedEvent event) {
    cacheManager.getCache("codeCache")
        .evict(event.getKey());
}

 

정합성이 중요한 경우 이 방식이 필요합니다.


3. 로컬 캐시 + Redis 같이 사용

Local Cache → Redis → DB

 

속도와 정합성을 동시에 가져가는 구조입니다.


정리

로컬 캐시는 다음 상황에서 효과적입니다.

  • Redis를 사용하기 어려운 환경
  • 조회 중심 서비스
  • 빠른 응답이 중요한 API
  • 데이터 변경이 적은 경우

대신 반드시 고려해야 합니다.

  • 메모리 제한 설정
  • TTL 또는 eviction 전략
  • 캐시 미스 처리
  • 다중 서버 환경에서의 동기화 전략
반응형