카테고리 없음

[Spring_4기 본캠프] Spring 플러스 - Redis | Day 65

austindynasty 2025. 2. 3. 23:26

1. Redis

1) Redis의 데이터 저장 방식

- Redis : 키-값(key-value)저장소로, 데이터를 메모리(RAM)에 저장하는 NoSQL 기반의 데이터베이스

- 데이터를 key-value 형태로 저장하며, key는 문자열이며 value는 다양한 데이터 타입을 가질 수 있음

- 데이터를 빠르게 읽고 쓰기 위해 메모리 기반으로 동작하지만 영속성을 지원해 데이터 유실을 방지할 수 있음

 

2) Redis의 데이터 저장 흐름

◆ 기본 흐름 : 캐시 조회 → 없으면 DB 조회 후 저장

⑴ 사용자가 조회 API 를 호출 ex. "GET api/stores/{id}" 호출 

⑵ Redis에서 해당 ID가 캐싱되어 있는지 확인 

- 캐시 HIT : 데이터 존재, Redis에서 데이터를 가져와 반환

- 캐시 MISS : 데이터 없음, DB에서 조회 후 Redis에 저장

// 데이터 조회 흐름 예시 코드
@Cacheable(value = "store", key = "#id") // 캐시 적용
public StoreResponseDto getStoreById(Long id) {
    return storeRepository.findById(id)
        .map(StoreResponseDto::fromEntity)
        .orElseThrow(() -> new StoreNotFoundException(id));
}
Redis 데이터 저장 과정
1. 클라이언트 -> API 서버: "GET /stores/1"
2. API 서버 -> Redis: "store:1" 키로 데이터 조회
   └ 만약 데이터가 있으면 바로 반환 (캐시 HIT ✅)
3. Redis에서 데이터가 없으면 (캐시 MISS ❌)
   └ API 서버 -> MySQL: "SELECT * FROM store WHERE id=1" 실행
4. DB에서 가져온 데이터를 Redis에 저장 (예: TTL 10분 설정)
   └ Redis에 저장: "store:1" -> "{id:1, name:'가게1', location:'서울'}"
5. 클라이언트에게 응답 반환

 

3) Redis 데이터 저장 방식 (Persistence)

- Redis는 기본적으로 메모리에 데이터를 저장하지만, 데이터 유실 방지를 위해 디스크에도 저장할 수 있음

 

2. @Cacheable 애너테이션을 사용하는 여러 가지 형식

- Spring의 @Cacheable 애너테이션은 프록시 기반 AOP 방식으로 동작하기 때문에 내부 메서드 호출 시에는 캐싱이 적용되지 않는 문제가 발생

@Service
public class StoreService {

    @Cacheable(value = "store", key = "#id")
    public StoreResponseDto getStoreById(Long id) {
        System.out.println("Fetching store from DB...");
        return storeRepository.findById(id)
            .map(StoreResponseDto::fromEntity)
            .orElseThrow(() -> new StoreNotFoundException(id));
    }

    public StoreResponseDto getStoreInfo(Long id) {
        // 내부 메서드 호출 (캐시 적용되지 않음)
        return getStoreById(id);
    }
}
// 실행 결과 캐싱이 적용되지 않음 -> 내부 호출은 프록시를 거치지 않기 때문

 

▶ 해결법

⑴ 자기 자신을 주입하기 

- Spring의 빈을 자기 자신에게 주입해 내부 호출 문제를 해결할 수 있음

@Service
public class StoreService {
    
    private final StoreRepository storeRepository;
    private StoreService self; // 자기 자신을 주입할 필드

    @Autowired
    public StoreService(StoreRepository storeRepository) {
        this.storeRepository = storeRepository;
    }

    @Autowired
    public void setSelf(StoreService self) {
        this.self = self;
    }

    @Cacheable(value = "store", key = "#id")
    public StoreResponseDto getStoreById(Long id) {
        System.out.println("Fetching store from DB...");
        return storeRepository.findById(id)
            .map(StoreResponseDto::fromEntity)
            .orElseThrow(() -> new StoreNotFoundException(id));
    }

    public StoreResponseDto getStoreInfo(Long id) {
        return self.getStoreById(id); // 자기 자신을 통해 호출
    }
}

- 첫 번째 호출 : getStoreById(id)가 실행되고, DB에서 조회 후 캐시에 저장

- 두 번째 호출 : 캐시에서 데이터를 가져옴

- self.getStoreId(id)를 호출하면 Spring 프록시를 통해 Cacheable이 정상적으로 동작

- @Autowired를 이용해 자기 자신을 주입하는 방식

- 자기 자신 주입은 생성자 주입이 되지 않아 본인을 생성하면서 주입해주어야 함 → 순환 참조 발생, setter주입이나 필드 주입이 필요함 

 

⑵ 서비스 클래스로 분리하기

- @Cacheable이 적용된 메서드를 다른 서비스 클래스로 분리하면 외부에서 호출하는 방식이 되므로 캐싱이 정상적으로 동작

// CacheStoreService (캐싱 전용 서비스 클래스)
@Service
public class CachedStoreService {

    private final StoreRepository storeRepository;

    public CachedStoreService(StoreRepository storeRepository) {
        this.storeRepository = storeRepository;
    }

    @Cacheable(value = "store", key = "#id")
    public StoreResponseDto getStoreById(Long id) {
        System.out.println("Fetching store from DB...");
        return storeRepository.findById(id)
            .map(StoreResponseDto::fromEntity)
            .orElseThrow(() -> new StoreNotFoundException(id));
    }
}
// StoreService (캐싱을 사용하는 서비스 클래스)
@Service
public class StoreService {

    private final CachedStoreService cachedStoreService;

    public StoreService(CachedStoreService cachedStoreService) {
        this.cachedStoreService = cachedStoreService;
    }

    public StoreResponseDto getStoreInfo(Long id) {
        return cachedStoreService.getStoreById(id); // 외부 호출
    }
}

- 첫 번째 호출 : cachedStoreService.getStoreById(id) 실행 → 캐싱됨

- 두 번째 호출 : 캐시에서 데이터를 가져옴

- Cacheable이 적용된 CacheStoreService는 별도 서비스로 분리

- StoreService에서 외부 서비스 호출 방식으로 캐싱을 적용

- 서비스 클래스를 추가로 만들어 주어야 하며, 유지보수를 위해서가 아닌 기술 사용을 위한 클래스 분리로 주객전도가 되는 모양이 될 수 있음 

 

⑶ CacheManager를 직접 불러 조회하기

- Spring의 CacheManager를 이용해 캐시를 직접 조회하고 저장할 수 있음

import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;

@Service
public class StoreService {

    private final StoreRepository storeRepository;
    private final CacheManager cacheManager;

    public StoreService(StoreRepository storeRepository, CacheManager cacheManager) {
        this.storeRepository = storeRepository;
        this.cacheManager = cacheManager;
    }

    public StoreResponseDto getStoreById(Long id) {
        Cache cache = cacheManager.getCache("store"); // 캐시 조회
        StoreResponseDto cachedStore = cache != null ? cache.get(id, StoreResponseDto.class) : null;

        if (cachedStore != null) {
            return cachedStore; // 캐시 데이터 반환
        }

        System.out.println("Fetching store from DB...");
        StoreResponseDto store = storeRepository.findById(id)
            .map(StoreResponseDto::fromEntity)
            .orElseThrow(() -> new StoreNotFoundException(id));

        if (cache != null) {
            cache.put(id, store); // 캐시에 저장
        }

        return store;
    }
}

- 첫 번째 호출 : 캐시에 데이터가 없으므로 DB에서 조회 후 캐싱

- 두 번째 호출 : 캐시에서 데이터를 가져옴

- CacheManager.getCache("store")를 사용해 캐시 직접 조회 및 저장

- CacheManager를 활용하면 동적 캐싱이 가능하고, 특정 조건에 맞게 동작을 커스텀할 수 있음

- 캐시를 직접 제어 하거나 커스텀 로직 작성 시 자유도가 높음 

 

▶ 정리

해결 방법 장점 단점
자기 자신 주입 코드 변경이 적고 간단 setSelf() 방식이 번거로울 수 있음
서비스 클래스로 분리 명확한 책임 분리, 확장성 향상 서비스 클래스를 추가로 만들어야 함
CacheManager 사용 캐시를 직접 제어, 커스텀 로직 가능 코드가 다소 길어짐

 

▶ 비교

▷ Cacheable + CacheManager 방식

: Spring의 @Cacheable 애너테이션을 사용하면 특정 메서드의 실행 결과를 캐싱할 수 있음. 이 때 CacheManager를 통해 캐시 저장소(Redis, Caffeine, EhCache)를 설정할 수 있음.

 

<장점>

- 간결한 코드 

: @Cacheable, @CachePut, @CacheEvict 등의 애너테이션을 활용하면 캐싱 로직을 간편하게 적용 가능

- Spring Cache 추상화 지원

: Redis 뿐만 아니라 Caffeine, EhCache 등 다양한 캐시 저장소를 쉽게 교체할 수 있음

- 자동 직렬화 및 역직렬화 지원

: Spring Boot에서 Redis를 캐시 저장소로 사용할 경우, Jackson을 활용한 JSON 직렬화를 기본 지원

- AOP 기반 캐싱

: 캐시가 있으면 메서드를 실행하지 않고 바로 캐싱된 값을 반환하므로 성능 최적화에 유리

 

<단점>

- 세부적인 캐시 조작이 어려움

: 특정 조건에 따라 동적으로 캐시 키를 설정하거나 TTL(Time To Live)를 제어하기 어려움

- 복잡한 캐시 로직에 부적합

: 단순한 메서드 캐싱에는 유용하지만, 캐시 데이터를 직접 다루어야 하는 경우(ex.캐시를 수정하거나 삭제할 때) 유연성 떨어짐

- 캐시 조회 시 조회 쿼리가 숨겨짐

: @Cacheable을 적용하면 캐시가 있는지 여부를 로그에서 바로 확인하기 어렵고, 디버깅이 어려울 수 있음

 

▷ RedisTemplate 방식

: Spring에서 제공하는 RedisTemplate을 사용하면 Redis에 데이터를 저장, 조회, 삭제하는 작업을 직접 수행할 수 있음

 

<장점>

- 세밀한 캐싱 제어 가능

: 데이터를 캐시에 저장할 때 TTL 설정, 만료 정책, 키 패턴 관리 등을 자유롭게 설정할 수 있음

- 다양한 데이터 구조 지원

: Redis의 String, Hash, List, Set, Sorted Set 등 다양한 자료구조를 활용할 수 있음

- 캐시 동기화 로직 구현 가능

: 캐시 데이터와 DB 데이터의 일관성을 유지하기 위해 Pub/Sub을 활용한 동기화 호직을 직접 구현할 수 있음

- 디버깅 및 모니터링 용이

: Redis CLI, Redis Insight 등의 도구를 활용하면 저장된 데이터를 직접 확인하고 관리할 수 있음

 

<단점>

- 코드 복잡도 증가

: 캐싱 로직을 직접 작성해야 하므로 코드가 길어질 수 있음

- Spring Cache 추상화 미적용

: 특정 캐시 저장소(Redis)에 종속되므로, 다른 캐시 저장소로 변경할 때 추가적인 작업 필요

- 직렬화/역직렬화 설정 필요

: 저장하는 객체를 직렬화 할 때 별도의 설정을 해야 하며, Jackson, Kryo, ProtoBuf 등의 라이브러리를 활용해야 할 수 있음

 

▷ 단순한 메서드 캐싱 → @Cacheable + CacheManager

- API 응답 데이터를 캐싱하는 경우

- 코드 변경 없이 간단하게 캐싱을 적용하고 싶을 때

 

▷ 세밀한 캐시 제어 필요 → RedisTemplate

- 특정 키의 TTL을 다르게 설정해야 하는 경우

- Redis의 다양한 자료구조(Hash, Set, List 등)를 활용해야 하는 경우

- 캐시 데이터를 직접 수정하거나 삭제하는 경우

 

 

Opinion

이번 프로젝트 필수 기능 구현 중 캐싱을 활용하라는 비즈니스 요구사항이 있어서 코드를 작성하려고 하니 전혀 감이 잡히질 않아 튜터님께 방향을 여쭤보았다. 정확한 비즈니스 요구사항이 무엇인지 파악하고, 내가 구현해야 하는 기능 중 캐싱을 적용할만한 기능이 있는지, 그리고 레디스가 어떤 방식으로 어떻게 데이터를 저장하고 관리하는지, 캐싱을 적용하기 위한 다양한 방식에 대해 먼저 공부해보라는 조언을 해주셨다.

자주 변경되는 데이터에는 캐싱을 적용하는 것이 적절하지 않고, 검색 및 필터링이 포함된 경우(인기 검색어)에는 캐싱이 가능하고, 다양한 검색 조건이 포함되었다면 캐싱이 불가능하다는 생각을 했다. 하지만 이렇게 말하니까 어느 정도 아는 것처럼 보이는데 사실상 아직도 감을 못잡고 있어서 굉장히 골치가 아프다... Spring 에 처음 입문했을 때 딱 이런 느낌이었던 것 같은데, 그 때도 넘지 못할 것 같던 스프링의 벽이 뚫렸듯 이번에도 그럴 것이라고 믿고 일단 삽질부터 하고 있다. 내일 다시 볼 때는 더 이해가 되어있길 빌며! 오늘도 화이팅 했다...