[Cache] 캐시란?-캐싱 전략과 Redis

문준일·2025년 2월 28일
post-thumbnail

이번 글은 효율적인 백엔드 시스템을 구현하기 위해 필수적으로 고려해야 하는 캐싱 전략과 분산 캐시에 대한 내용을 다루려고 한다. 캐싱의 기본 원리부터 다양한 캐싱 기법 및 Redis 도입까지 알아보도록 하자.

1. 캐싱이란?

캐싱(Caching) 이란, 자주 사용하는 데이터나 결과 값을 미리 저장해 두고 필요할 때 빠르게 꺼내 쓰는 기법을 의미한다.
예를 들어, 매번 DB를 조회해야 하는 경우가 있다고 해보자. 동일한 데이터를 계속 DB에서 읽어오면 트래픽 부담이 커지고 응답 속도가 느려진다. 이런 상황에서 캐시에 저장해 두면, DB를 거치지 않고 즉시 데이터를 반환할 수 있으므로 성능과 응답 속도가 크게 향상된다.

📌 캐싱의 주요 장점
1. 성능 개선 : 데이터를 빠르게 접근함으로써 응답 시간을 단축할 수 있음
2. 부하 감소 : DB나 외부 API 서버에 대한 요청 횟수를 줄여, 인프라 비용이나 스케일링 부담을 줄여줌
3. 시스템 안정성 개선 : 한꺼번에 많은 사용자가 몰리는 트래픽 폭주 상황에서도, 캐시에 데이터가 축적되어 있다면 임계 부하를 해소하기가 훨씬 수월하다.

2. 캐싱 전략의 분류

캐싱 전략은 크게 "어떻게 쓰고(Write)", "어떻게 읽고(Read)", "언제 무효화(Invalidate)할 것인가"에 따라 구분할 수 있다.

1️⃣ 읽기 관점: Lazy Loading vs Read-Through

  1. Lazy Loading
  • 캐시에 데이터가 없으면 그때 DB에서 조회하고 캐시에 저장한다.
  • 장점: 사용되지 않는 데이터를 굳이 캐시에 넣지 않으므로, 메모리를 효율적으로 쓸 수 있다.
  • 단점: 최초 요청 시에는 DB 조회가 발생해 응답 시간이 길어질 수 있다.
  1. Read-Through
  • 애플리케이션이 데이터를 읽을 때, 캐시에 먼저 접근하고 업승면 캐시에 데이터를 채워둔 뒤 반환한다.
  • 장점: 요청마다 캐시가 투명하게 작동하여, 개발자가 직접 캐시 로직을 작성하는 비용이 줄어든다.
  • 단점: 캐싱 로직이 프록시 수준에서 처리되므로, 세부적인 제어가 어려울 수 있다.

2️⃣ 쓰기 관점: Write-Through vs Write-Behind (Write-Back)

  1. Write-Through
  • DB에 쓰는 순간 동시에 캐시에 쓰는 방식
  • 장점: DB와 캐시의 일관성을 유지하기 쉽다.
  • 단점: 쓰기 연산 시 캐시도 업데이트해야 하므로 쓰기 부하가 증가할 수 있다.
  1. Write-Behind (Write-Back)
  • 캐시에 먼저 쓰고, 일정 시점(또는 주기)에 배치 형태로 DB를 갱신함.
  • 장점: 쓰기 지연으로 애플리케이션 쓰기 성능이 좋아진다.
  • 단점: 캐시와 DB가 즉시 동기화되지 않기 때문에, 일관성 문제가 발생할 수 있다.

3️⃣ 캐시 무효화(Invalidation) 전략

캐시에 저장된 데이터가 변경되거나 더 이상 유효하지 않을 때, 제때 무효화하지 않으면 오래된 데이터가 사용자에게 반환될 수 있다. 이를 방지하기 위한 기법이 캐시 무효화이다.

1. TTL(만료 시간) 설정

  • 특정 시간(초, 분, 시간 등)이 지나면 캐시를 자동으로 제거한다.
  • 변경이 잦은 데이터는 짧은 TTL, 상대적으로 안정적인 데이터는 긴 TTL을 부여하는 식으로 조정할 수 있다.

2. 수동 무효화(Explicit Invalidation)

  • 데이터 변경 이벤트(예: 업데이트, 삭제) 발생 시, 관련 캐시 키를 명시적으로 삭제한다.

3. LRU/LFU 정책

  • LRU(Least Recently Used): 최근에 사용되지 않은 캐시부터 제거
  • LFU(Least Frequently Used): 사용 빈도가 낮은 캐시부터 제거
  • 캐시 서버의 메모리가 제한된 상황에서, 자동으로 덜 중요한 데이터를 정리해주는 방식이다.

3. 인메모리(In-memory)캐시와 분산 캐시

Redis를 알아보기 전에 인메모리(In-memory)캐시와 분산 캐시에 대해 알아보자. Redis는 기본적으로 인메모리 데이터 저장소이며, 동시에 네트워크(분산) 환경에서 동작할 수 있기 때문에 두 가지 측면을 모두 갖고 있다.

1️⃣ 인메모리(In-memory) 캐시란?

인메모리 캐싱 서비스는 데이터나 연산 결과를 메모리에 저장하여 빠르게 검색하고 액세스할 수 있게 하는 서비스이다.
이 서비스는 데이터베이스 또는 다른 백엔드 서버에서 데이터를 가져오거나 계산하는 시간을 줄여주어 애플리케이션의 성능을 향상시킨다.
인메모리 캐싱 서비스는 주로 다음과 같은 방식으로 작동한다.

[데이터 저장]
인메모리 캐싱 서비스는 데이터를 메모리에 저장한다. (그래서 In Memory!)
메모리는 디스크보다 빠르고 접근하기 쉬우므로 데이터 검색 및 액세스가 빠르다.

[데이터 검색]
애플리케이션이 필요한 데이터를 요청하면 인메모리 캐싱 서비스는 메모리에서 해당 데이터를 검색한다.
이렇게 하면 데이터베이스나 다른 백엔드 서비스로부터 데이터를 가져오는 데 필요한 시간이 단축된다.

[데이터 갱신]
데이터가 변경되면 캐시도 갱신되어 최신 데이터를 유지한다.
일부 캐싱 서비스는 데이터의 유효기간을 설정하여 일정 시간 동안 데이터를 캐시에 저장한다.

2️⃣ 분산 캐시(Distributed Cache)란?

여러 대의 서버(노드)에 캐시 데이터를 분산 저장하고, 각 노드가 협력하여 캐시 데이터를 관리하는 방식이다. 하나의 거대한 캐시 서버가 아니라 여러 서버가 동시에 캐시 역할을 분담함으로써 확장성과 가용성을 높일 수 있다. 대규모 트래픽을 처리하거나 장애 상황에 대응하려면 분산 캐시가 유용하다.
분산 캐싱 서비스는 주로 다음과 같은 방식으로 작동한다.

[데이터 분산]
여러 노드에 데이터를 나누어 저장(샤딩)하여 각 노드가 담당하는 데이터 범위를 구분한다.
특정 키(key)의 데이터가 어느 노드에 할당되는지 규칙(해싱 등)을 통해 빠르게 찾을 수 있다.
노드를 추가·제거하면 자동으로 데이터를 재분배하여 확장(스케일 아웃)이 가능하다.

[데이터 검색]
애플리케이션이 특정 키를 요청하면, 분산 캐시 시스템은 어떤 노드에 해당 키가 있는지 계산하거나 라우팅 테이블을 참조한다.
해당 노드에 요청을 전달하여 데이터를 가져온다.
단일 인메모리 캐시와 달리 네트워크 hop이 늘어날 수 있지만, 노드 간 통신을 효율화해 지연(latency)을 최소화하도록 구현되어 있다.

[데이터 동기화]
분산 환경에서 여러 노드가 동일 데이터를 관리할 수도 있으므로, 변경 사항을 노드들 간에 공유하거나 복제(Replication)한다.
장애 발생 시에도 데이터 유실을 막거나 빠르게 복구할 수 있도록 복제본(Replica)을 운용하기도 한다.

[유효 기간 & 갱신]
분산 캐시에서도 키마다 TTL을 설정할 수 있어, 일정 시간이 지나면 데이터를 자동으로 제거한다.
데이터베이스(또는 원본 시스템)의 변경 사항이 생길 경우 해당 키를 무효화(invalidate)하거나 즉시 갱신하여 최신 상태를 유지한다.

4. 왜 Redis인가?

Redis의 특징

  • 인메모리(In-memory): 데이터를 메모리에 저장하여 읽고 쓰는 속도가 매우 빠르다.
  • 다양한 데이터 구조: String, Hash, List, Set, Sorted Set 등 여러 자료구조를 제공해 활용 범위가 넓다.
  • 분산 환경 지원: Redis Cluster를 구성하면 여러 노드에 데이터를 분산 저장해 대용량 처리를 할 수 있다.
  • 고가용성(HA) 지원: Redis Sentinel, Redis Cluster를 통해 Failover가 가능해, 장애 상황에서도 서비스를 지속할 수 있다.

페일오버(Failover) 는 장애 조치 기능으로 시스템 장애 이벤트 발생 시 하나 이상의 예비 백업 시스템(노드) 로 자동 전환되는 것을 말한다. 즉, Fail(실패)를 Over(끝낸다) 는 의미로, 시스템 장애 시 준비되어있는 다른 시스템으로 대체되어 운영되는 것을 말한다.

5. Redis를 이용한 캐시 설계

1️⃣ 기본 구조

  1. 클라이언트가 특정 리소스(예: GET /user/123)를 요청
  2. 애플리케이션 서버에서 Redis 캐시를 먼저 확인
  3. 만료 시간이 지나거나 무효화가 일어나면 캐시에서 제거됨

2️⃣ Redis 데이터 타입 활용 예

  1. String
  • 가장 간단한 형태의 데이터. 대부분의 캐싱 시나리오에 널리 사용
  • 예: SET "user:123" "{"name":"Joonil","age":31}" EX 60
  1. Hash
  • Key에 해당하는 Value를 필드-값 쌍으로 관리할 수 있음
  • 예: HSET "user:123" "name" "Joonil" "age" "31" → 특정 필드만 업데이트 가능
  1. List, Set, Sorted Set
  • 뉴스 피드, 랭킹, 태그 목록 등 다양한 방식으로 사용 가능
  • 예: “인기 게시물 10개”를 Sorted Set에 점수(조회수, 좋아요 수 등) 기준으로 정렬해 캐싱

6. 구현 예시(Spirng Boot + Redis)

Java, Spring Boot, Gradle 애플리케이션 환경에서 Spring Data Redis를 사용해 Redis 캐싱을 구현하는 예시이다.

1️⃣ 의존성 추가

// build.gradle에 의존성 추가
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

2️⃣ Redis 설정

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        // 로컬 환경 기준: host=localhost, port=6379
        // 실무 환경: 레디스 서버 정보, 보안 설정 등을 반영
        return new LettuceConnectionFactory("localhost", 6379);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        // 직렬화 설정
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

3️⃣ 데이터 예시

// 간단한 User 엔티티 가정
public class User {
    private String id;
    private String name;
    private int age;

    // getters, setters, constructor
}

// 애플리케이션 구동 시점에 임시 DB 역할을 하는 Map
@Component
public class MockUserDB {
    private final Map<String, User> userMap = new ConcurrentHashMap<>();

    public MockUserDB() {
        userMap.put("123", new User("123", "joonil", 31));
        userMap.put("456", new User("456", "joon2", 30));
    }

    public User findById(String id) {
        return userMap.get(id);
    }
}

4️⃣ Service

@Service
public class UserService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final MockUserDB mockUserDB;

    public UserService(RedisTemplate<String, Object> redisTemplate, MockUserDB mockUserDB) {
        this.redisTemplate = redisTemplate;
        this.mockUserDB = mockUserDB;
    }

    public User getUserById(String userId) {
        String cacheKey = "user:" + userId;

        // 1) Redis에서 조회
        User cachedUser = (User) redisTemplate.opsForValue().get(cacheKey);
        if (cachedUser != null) {
            System.out.println("Cache Hit!");
            return cachedUser;
        }

        // 2) 캐시에 없으면 DB에서 조회
        User userFromDB = mockUserDB.findById(userId);
        if (userFromDB == null) {
            return null; // 혹은 예외 처리
        }

        System.out.println("Cache Miss. DB에서 가져온 데이터 저장...");
        // 3) Redis에 저장 + 만료시간 설정(예: 60초)
        redisTemplate.opsForValue().set(cacheKey, userFromDB, 60, TimeUnit.SECONDS);

        return userFromDB;
    }

    public void updateUserName(String userId, String newName) {
        // 1) DB 업데이트
        User user = mockUserDB.findById(userId);
        if (user == null) return;
        user.setName(newName);

        // 2) 캐시 무효화(Explicit Invalidation)
        String cacheKey = "user:" + userId;
        redisTemplate.delete(cacheKey);
    }
}

5️⃣ Controller

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{userId}")
    public ResponseEntity<?> getUser(@PathVariable String userId) {
        User user = userService.getUserById(userId);
        if (user == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(user);
    }

    @PutMapping("/{userId}")
    public ResponseEntity<?> updateUserName(@PathVariable String userId, @RequestParam String name) {
        userService.updateUserName(userId, name);
        return ResponseEntity.ok("User name updated");
    }
}

🔥 결론 및 요약

  • 캐싱 전략은 크게 “읽기 방식을 어떻게 할 것인가(Lazy vs. Read-Through)”, “쓰기 시점을 어떻게 처리할 것인가(Write-Through vs. Write-Behind)”, 그리고 “어떻게 무효화할 것인가(TTL, LRU, 수동 삭제)”로 구분할 수 있다.
  • Redis는 인메모리 기반으로 빠른 성능과 다양한 자료구조, 분산 환경을 지원해 사실상 표준 분산 캐시 솔루션으로 널리 쓰인다.
  • 실무에서는 TTL 설정, 캐시 용량 계획, 무효화 정책, 모니터링, Failover 등을 종합적으로 고려해야 하며, 서비스 특성(데이터 갱신 빈도, 필요한 일관성 수준 등)에 맞춰 적절한 캐싱 전략을 적용해야 한다.
profile
하나씩 실천하는 개발자

0개의 댓글