이번 글은 효율적인 백엔드 시스템을 구현하기 위해 필수적으로 고려해야 하는 캐싱 전략과 분산 캐시에 대한 내용을 다루려고 한다. 캐싱의 기본 원리부터 다양한 캐싱 기법 및 Redis 도입까지 알아보도록 하자.
캐싱(Caching) 이란, 자주 사용하는 데이터나 결과 값을 미리 저장해 두고 필요할 때 빠르게 꺼내 쓰는 기법을 의미한다.
예를 들어, 매번 DB를 조회해야 하는 경우가 있다고 해보자. 동일한 데이터를 계속 DB에서 읽어오면 트래픽 부담이 커지고 응답 속도가 느려진다. 이런 상황에서 캐시에 저장해 두면, DB를 거치지 않고 즉시 데이터를 반환할 수 있으므로 성능과 응답 속도가 크게 향상된다.
📌 캐싱의 주요 장점
1. 성능 개선 : 데이터를 빠르게 접근함으로써 응답 시간을 단축할 수 있음
2. 부하 감소 : DB나 외부 API 서버에 대한 요청 횟수를 줄여, 인프라 비용이나 스케일링 부담을 줄여줌
3. 시스템 안정성 개선 : 한꺼번에 많은 사용자가 몰리는 트래픽 폭주 상황에서도, 캐시에 데이터가 축적되어 있다면 임계 부하를 해소하기가 훨씬 수월하다.
캐싱 전략은 크게 "어떻게 쓰고(Write)", "어떻게 읽고(Read)", "언제 무효화(Invalidate)할 것인가"에 따라 구분할 수 있다.
캐시에 저장된 데이터가 변경되거나 더 이상 유효하지 않을 때, 제때 무효화하지 않으면 오래된 데이터가 사용자에게 반환될 수 있다. 이를 방지하기 위한 기법이 캐시 무효화이다.
1. TTL(만료 시간) 설정
2. 수동 무효화(Explicit Invalidation)
3. LRU/LFU 정책
Redis를 알아보기 전에 인메모리(In-memory)캐시와 분산 캐시에 대해 알아보자. Redis는 기본적으로 인메모리 데이터 저장소이며, 동시에 네트워크(분산) 환경에서 동작할 수 있기 때문에 두 가지 측면을 모두 갖고 있다.
인메모리 캐싱 서비스는 데이터나 연산 결과를 메모리에 저장하여 빠르게 검색하고 액세스할 수 있게 하는 서비스이다.
이 서비스는 데이터베이스 또는 다른 백엔드 서버에서 데이터를 가져오거나 계산하는 시간을 줄여주어 애플리케이션의 성능을 향상시킨다.
인메모리 캐싱 서비스는 주로 다음과 같은 방식으로 작동한다.
[데이터 저장]
인메모리 캐싱 서비스는 데이터를 메모리에 저장한다. (그래서 In Memory!)
메모리는 디스크보다 빠르고 접근하기 쉬우므로 데이터 검색 및 액세스가 빠르다.
[데이터 검색]
애플리케이션이 필요한 데이터를 요청하면 인메모리 캐싱 서비스는 메모리에서 해당 데이터를 검색한다.
이렇게 하면 데이터베이스나 다른 백엔드 서비스로부터 데이터를 가져오는 데 필요한 시간이 단축된다.
[데이터 갱신]
데이터가 변경되면 캐시도 갱신되어 최신 데이터를 유지한다.
일부 캐싱 서비스는 데이터의 유효기간을 설정하여 일정 시간 동안 데이터를 캐시에 저장한다.
여러 대의 서버(노드)에 캐시 데이터를 분산 저장하고, 각 노드가 협력하여 캐시 데이터를 관리하는 방식이다. 하나의 거대한 캐시 서버가 아니라 여러 서버가 동시에 캐시 역할을 분담함으로써 확장성과 가용성을 높일 수 있다. 대규모 트래픽을 처리하거나 장애 상황에 대응하려면 분산 캐시가 유용하다.
분산 캐싱 서비스는 주로 다음과 같은 방식으로 작동한다.
[데이터 분산]
여러 노드에 데이터를 나누어 저장(샤딩)하여 각 노드가 담당하는 데이터 범위를 구분한다.
특정 키(key)의 데이터가 어느 노드에 할당되는지 규칙(해싱 등)을 통해 빠르게 찾을 수 있다.
노드를 추가·제거하면 자동으로 데이터를 재분배하여 확장(스케일 아웃)이 가능하다.
[데이터 검색]
애플리케이션이 특정 키를 요청하면, 분산 캐시 시스템은 어떤 노드에 해당 키가 있는지 계산하거나 라우팅 테이블을 참조한다.
해당 노드에 요청을 전달하여 데이터를 가져온다.
단일 인메모리 캐시와 달리 네트워크 hop이 늘어날 수 있지만, 노드 간 통신을 효율화해 지연(latency)을 최소화하도록 구현되어 있다.
[데이터 동기화]
분산 환경에서 여러 노드가 동일 데이터를 관리할 수도 있으므로, 변경 사항을 노드들 간에 공유하거나 복제(Replication)한다.
장애 발생 시에도 데이터 유실을 막거나 빠르게 복구할 수 있도록 복제본(Replica)을 운용하기도 한다.
[유효 기간 & 갱신]
분산 캐시에서도 키마다 TTL을 설정할 수 있어, 일정 시간이 지나면 데이터를 자동으로 제거한다.
데이터베이스(또는 원본 시스템)의 변경 사항이 생길 경우 해당 키를 무효화(invalidate)하거나 즉시 갱신하여 최신 상태를 유지한다.
페일오버(Failover) 는 장애 조치 기능으로 시스템 장애 이벤트 발생 시 하나 이상의 예비 백업 시스템(노드) 로 자동 전환되는 것을 말한다. 즉, Fail(실패)를 Over(끝낸다) 는 의미로, 시스템 장애 시 준비되어있는 다른 시스템으로 대체되어 운영되는 것을 말한다.
GET /user/123)를 요청 SET "user:123" "{"name":"Joonil","age":31}" EX 60HSET "user:123" "name" "Joonil" "age" "31" → 특정 필드만 업데이트 가능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");
}
}