📚FOS Study
홈카테고리
홈카테고리

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agents 페이지로 이동
    • langgraph 페이지로 이동
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
  • architecture 페이지로 이동
    • 캐시 설계 전략 총정리
    • 디자인 패턴
    • 분산 트랜잭션
  • css 페이지로 이동
    • FlexBox 페이지로 이동
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • 커넥션 풀 크기는 얼마나 조정해야할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • monitoring 페이지로 이동
    • Envoy Proxy
    • Graceful Shutdown
  • go 페이지로 이동
    • Go 언어 기본 학습
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • 210812 페이지로 이동
    • 뱅크샐러드 AI Native Server Engineer
    • CJ 올리브영 커머스플랫폼유닛 Back-End 개발 지원 자료
    • 마이리얼트립 - Platform Solutions실 회원주문개발 Product Engineer
    • NHN 서비스개발센터 AI서비스개발팀
    • nhn gameenvil console backend 직무 인터뷰 준비
    • 면접을 대비해봅시다
    • 토스증권 Server Developer (Platform) 지원 자료
    • 토스증권 Server Developer (Product) 지원 자료
    • 토스뱅크 Server Developer (Product) 지원 자료
    • Tossplace Node.js Developer
    • 토스플레이스 Node.js 백엔드 컬처핏
  • java 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • OpenTelemetry 란 무엇인가?
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • Data_Structures_and_Algorithms 페이지로 이동
    • Heap 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node.js
    • npm vs pnpm 선택기준은 무엇인가요?
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 기본
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • 메시지 전송 신뢰성
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • react 페이지로 이동
    • JSX 페이지로 이동
    • VirtualDOM 페이지로 이동
    • v16 페이지로 이동
  • resume 페이지로 이동
    • CJ 올리브영 지원 문항
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
📚FOS Study

개발 학습 기록을 정리하는 블로그입니다.

바로가기

  • 홈
  • 카테고리

소셜

  • GitHub
  • Source Repository

© 2025 FOS Study. Built with Next.js & Tailwind CSS

목록으로 돌아가기
📁task/ sb-dev-team

Spring Boot 인메모리 캐시 구조

약 4분
2026년 3월 29일
GitHub에서 보기

Spring Boot 인메모리 캐시 구조

진행 기간: 2023.03 ~ 2024.02

스포츠 베팅 백엔드의 인메모리 캐시 전반을 구성했다. 다중 서버 환경에서 캐시 정합성을 어떻게 유지하는지가 핵심이었다.


캐시 두 종류

캐시는 성격에 따라 두 가지로 나뉜다.

1. Ehcache (JSR-107, @Cacheable)

ehcache.xml에 선언하고 @Cacheable로 사용하는 방식이다. TTL 기반 자동 만료가 필요한 데이터에 쓴다. 주로 DB에서 조회한 결과를 메서드 단위로 캐싱할 때 사용한다.

<cache-template name="default">
    <expiry>
        <ttl unit="seconds">60</ttl>
    </expiry>
    <listeners>
        <listener>
            <class>...CacheEventLogger</class>
            <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
            <events-to-fire-on>CREATED</events-to-fire-on>
            <events-to-fire-on>EXPIRED</events-to-fire-on>
        </listener>
    </listeners>
    <heap>10000</heap>
</cache-template>

<!-- 데이터 성격에 따라 TTL을 다르게 설정 -->
<cache alias="WHITE_LIST" uses-template="default">
    <expiry><ttl unit="minutes">10</ttl></expiry>
</cache>

<cache alias="static_banners" uses-template="default">
    <expiry><ttl unit="days">1</ttl></expiry>
</cache>

<cache alias="betradar_status" uses-template="default">
    <expiry><ttl>10</ttl></expiry>   <!-- 10초: 실시간성 필요 -->
</cache>

CacheEventLogger를 달아 캐시 생성/만료 이벤트를 비동기로 로깅한다. 운영 중에 캐시가 언제 갱신되는지 추적하는 데 유용하다.

2. 인메모리 Map 캐시 (AbstractStaticReloadable)

JVM 내부 ConcurrentMap으로 직접 관리하는 방식이다. 이벤트/설정 데이터처럼 명시적으로 리로드 제어가 필요한 경우에 사용한다.

// 추상 기반 클래스
public abstract class AbstractStaticReloadable<T, Key> {
    protected final ConcurrentMap<Key, T> configMap = Maps.newConcurrentMap();
    final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    @PostConstruct
    public abstract void reload();         // 서버 기동 시 자동 로드

    public abstract DataTableName tableName(); // 어떤 테이블 변경에 반응할지
}
// AbstractStaticKeyReloadable: Key→T 맵 + List 형태
public void reload() {
    writeLockJob(() -> {
        configList.clear();
        configMap.clear();
        List<T> loaded = loadFromRepo();    // DB에서 새로 조회
        configList.addAll(loaded);
        loaded.forEach(a -> configMap.put(key(a), a));
        return true;
    });
}

각 캐시는 이 클래스를 상속해 loadFromRepo()와 tableName()만 구현하면 된다.

// 추천 프로그램 목록 캐시
@Component
public class RecommendProgramCache extends AbstractStaticKeyReloadable<Event.RecommendProgramEvent, Long> {

    @Override
    protected List<Event.RecommendProgramEvent> loadFromRepo() {
        return repository.findAllByTypeAndActiveOrderByEndDateDesc(
                EventSchema.EventType.RECOMMENDER_BONUS_PROGRAM, ACTIVE)
                .stream().map(Event::toRecommendProgramEvent).collect(Collectors.toList());
    }

    @Override
    public DataTableName tableName() {
        return DataTableName.EventRecommendProgram;  // 이 테이블이 변경되면 reload
    }
}

캐시 정합성: MQ Fanout 구조

백엔드 서버가 여러 대 뜨는 환경에서 어드민이 데이터를 변경하면 모든 서버의 캐시를 동시에 갱신해야 한다. 이를 MQ Fanout으로 해결한다.

어드민 백엔드
POST /api/v2/admin/service/refresh
        │
        ▼
ServiceService.reloadMemory(tableName)
        │
        ▼
DataPublisher.reloadMemory(tableName)
        │
        ▼
   MQ Fanout 발행
(FANOUT_STATIC_DATA 토픽)
        │
   ┌────┴────┐
   ▼         ▼
백엔드 서버1  백엔드 서버2  ... (모두 동시 수신)
        │
        ▼
MqDataListener.onReloadStaticData()
        │
        ▼
해당 DataTableName의 캐시 reload()

Fanout 방식이라 모든 서버 인스턴스가 동시에 같은 메시지를 받는다. 특정 서버만 갱신되는 상황이 발생하지 않는다.


메시지 발행: 어드민 백엔드

어드민 백엔드에 캐시 갱신 전용 엔드포인트가 있다.

@RestController
@RequestMapping("/api/v2/admin/service/refresh")
class RefreshResource(private val service: ServiceService) {

    @PostMapping
    fun refreshCache(@RequestBody dto: ServiceDto.RefreshCache.Request): BaseResponseDto<*> {
        when (dto.type) {
            2 -> dto.tableNames?.forEach { service.reloadMemory(it) }   // 인메모리 캐시
            1 -> dto.cacheNames?.forEach { service.reloadCache(it) }    // Ehcache
        }
        return BaseResponseDto(data = null)
    }
}
  • type=2 + tableName: AbstractStaticReloadable 기반 인메모리 캐시를 특정 테이블 이름으로 리로드
  • type=1 + cacheName: Ehcache의 특정 캐시를 clear

어드민 프론트엔드에서 화이트리스트/이벤트 등을 수정한 후 이 API를 호출해 즉시 반영한다.


MQ 이중화: RabbitMQ / Azure Service Bus

MQ 구현체가 환경에 따라 달라진다. @DataPublisher.toast와 @DataPublisher.azure 프로필 애너테이션으로 구분한다.

// NHN Cloud 환경: RabbitMQ
@Configuration
@DataPublisher.toast   // Profile: !azure
public static class RabbitMqDataPublisher extends DataPublisher {
    public void reloadMemory(DataTableName tableName) {
        template.convertAndSend(ExchangeNames.FANOUT_STATIC_DATA, "",
            ReloadCommand.createToJson(ReloadCommandType.Memory, tableName));
    }
}

// Azure 환경: Azure Service Bus
@Configuration
@DataPublisher.azure   // Profile: azure
public static class ServiceBusDataPublisher extends DataPublisher {
    public void reloadMemory(DataTableName tableName) {
        template.convertAndSend(ExchangeNames.FANOUT_STATIC_DATA,
            ReloadCommand.createToJson(ReloadCommandType.Memory, tableName));
    }
}

인터페이스(DataPublisher)가 동일해서 나머지 코드는 MQ 종류에 관계없이 그대로 동작한다. 환경 프로필만 바꾸면 된다.

수신 측 MqDataListener도 같은 방식으로 분리되어 있다.

@Component
@DataPublisher.toast
static class RabbitMqDataListener extends MqDataListener {
    @RabbitCommonConfig.FanoutListenBindingStaticData
    public void onRabbitMqReloadStaticData(String json) {
        onReloadStaticData(json);
    }
}

@Component
@DataPublisher.azure
static class ServiceBusDataListener extends MqDataListener {
    @PostConstruct
    public void init() {
        config.subscribe(ExchangeNames.FANOUT_STATIC_DATA, ..., (j) -> {
            onReloadStaticData((String) j);
        });
    }
}

수신 처리: MqDataListener

메시지를 받은 백엔드에서는 ReloadCommand의 타입에 따라 처리한다.

public void onReloadStaticData(String json) {
    ReloadCommand command = ReloadCommand.jsonToObject(json);

    switch (command.getType()) {
        case AllEhcache:
            // 모든 Ehcache clear
            cacheManager.getCacheNames().forEach(name -> cacheManager.getCache(name).clear());
            break;
        case Ehcahe:
            // 특정 Ehcache clear
            cacheManager.getCache(command.getEhcacheName().ehcacheName).clear();
            break;
        case AllMemory:
            // 모든 AbstractStaticReloadable bean의 reload() 호출
            applicationContext.getBeansOfType(AbstractStaticReloadable.class)
                .values().forEach(r -> r.reload());
            break;
        case Memory:
            // tableName이 일치하는 캐시만 reload()
            applicationContext.getBeansOfType(AbstractStaticReloadable.class)
                .values().stream()
                .filter(r -> command.getTableName() == r.tableName())
                .forEach(r -> r.reload());
            break;
    }
}

동시성: ReentrantReadWriteLock

reload()는 writeLock을 잡고 실행하고, 조회(list(), one())는 readLock을 잡는다. 리로드 중에 다른 스레드가 불완전한 데이터를 읽는 상황을 방지한다.

public void reload() {
    writeLockJob(() -> {     // 쓰기 락: 리로드 중 읽기 차단
        configList.clear();
        configMap.clear();
        List<T> loaded = loadFromRepo();
        configList.addAll(loaded);
        loaded.forEach(a -> configMap.put(key(a), a));
        return true;
    });
}

public List<T> list() {
    return readLockJob(() -> new ArrayList<>(configList));  // 읽기 락
}

서버 기동 시 자동 로드

AbstractStaticReloadable의 reload()에 @PostConstruct가 붙어있어서 서버가 뜰 때 자동으로 DB에서 캐시를 채운다. 콜드 스타트 문제가 없다.

@PostConstruct
public void reload() {
    writeLockJob(() -> {
        // 서버 기동 시 DB에서 전체 로드
        ...
    });
}

관련 문서

  • 캐시 설계 전략
  • 추천 프로그램 시스템
task 카테고리의 다른 글 보기수정 제안하기

댓글

댓글을 불러오는 중...
  • Spring Boot 인메모리 캐시 구조
  • 캐시 두 종류
  • 1. Ehcache (JSR-107, `@Cacheable`)
  • 2. 인메모리 Map 캐시 (`AbstractStaticReloadable`)
  • 캐시 정합성: MQ Fanout 구조
  • 메시지 발행: 어드민 백엔드
  • MQ 이중화: RabbitMQ / Azure Service Bus
  • 수신 처리: MqDataListener
  • 동시성: ReentrantReadWriteLock
  • 서버 기동 시 자동 로드
  • 관련 문서