지난 포스팅에서는 **동시성 제어(Redis 분산 락)**를 통해 데이터의 정합성을 지키는 데 집중했습니다. 하지만 실제 대용량 트래픽 환경에서는 정합성만큼이나 중요한 것이 바로 **"성능(Performance)"**과 **"가용성(Availability)"**입니다.
이번에는 사용자가 가장 많이 접속하는 **메인 페이지(주식 목록 조회)**의 부하를 줄이기 위해 **Redis 캐싱(Caching)**을 도입한 과정을 정리합니다.
1. 문제 상황: "DB가 너무 힘들어해요" 💦
거래소 서비스의 특성상, 유저들은 메인 페이지에 들어와서 "지금 비트코인 얼마지?" 하고 수시로 새로고침(F5)을 누릅니다.
- 현재 구조: 유저 요청 1번 → DB 조회 1번
- 시나리오: 만약 10만 명의 유저가 동시에 접속한다면?
- 결과: DB에 순간적으로 10만 개의 조회 쿼리가 날아갑니다. DB CPU가 치솟고, 결국 **서버가 다운(504 Gateway Timeout)**되는 사태가 발생합니다.
우리는 이 문제를 해결하기 위해 **DB 앞단에 "방파제"**를 세우기로 했습니다. 그게 바로 Redis입니다.
2. 캐싱(Caching)이란 무엇인가?
캐싱을 쉽게 비유하자면 "컨닝 페이퍼" 또는 **"책상 위 메모지"**와 같습니다.
- 데이터베이스(Disk): 도서관 구석에 있는 두꺼운 백과사전입니다. 정보는 정확하지만, 찾으러 갔다 오는 데 시간이 걸립니다.
- 캐시(Memory): 내 책상 바로 위에 붙여둔 포스트잇입니다. 고개만 들면 바로 보일 정도로 압도적으로 빠릅니다.
🛠️ Look-Aside 전략 (우리가 사용한 방식)
대부분의 웹 서비스에서 사용하는 표준적인 전략입니다.
- Look Cache: 요청이 오면 먼저 캐시(Redis)를 봅니다.
- Cache Hit: 데이터가 있으면? DB 안 가고 바로 반환합니다. (Fast!) 🚀
- Cache Miss: 데이터가 없으면? 그제야 DB에서 조회해오고, 다음에 또 쓸 수 있게 캐시에 저장(Write Back)합니다.
3. 기술적 고민: 실시간성 vs 성능 (Trade-off) 🤔
여기서 중요한 엔지니어링적 고민이 발생합니다.
"주식 가격은 1초마다 변하는데, 캐싱을 해도 될까?"
캐시에 저장하는 순간, 그 데이터는 **"과거의 데이터"**가 됩니다. 만약 TTL(유효기간)을 1분으로 설정하면, 유저는 1분 전의 가격을 보고 매수 버튼을 누를 수도 있습니다. 이는 치명적일 수 있습니다.
✅ 나의 결론
- 전략: 캐시의 유효기간(TTL)을 매우 짧게(3~5초) 가져간다.
- 이유:
- 사용자에게 3초 전 데이터를 보여주는 것보다, 트래픽 폭주로 서버가 죽어서 아예 아무것도 못 보여주는 것이 더 최악이다.
- 따라서 "완벽한 실시간성"을 아주 살짝 포기하고, "서비스 생존(가용성)"을 선택했다.
4. 구현 과정 💻
1) CacheConfig 설정 (CacheManager 등록)
Redis를 캐시 저장소로 사용하기 위해 설정 파일을 작성합니다. 여기서 핵심은 TTL(Time To Live)을 설정하여, 오래된 데이터가 자동으로 삭제되도록 하는 것입니다.
@Configuration
@EnableCaching // 캐시 기능 활성화
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(30)) // ★ 30초가 지나면 데이터 자동 삭제 (Refresh)
.disableCachingNullValues()
.serializeValuesWith( // 데이터를 JSON 형태로 저장 (가독성 UP)
RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
이 부분에서, 테스트를 위해서 잠시 30초로 하고, 기본적으론 3초로 진행했습니다.
2) Service 계층 적용 (@Cacheable)
이제 복잡한 로직 없이, 어노테이션 하나만 붙이면 스프링 AOP가 알아서 캐싱을 처리해줍니다.
@Service
@Slf4j
public class StockService {
@Transactional(readOnly = true)
// value: 저장소 이름, key: 데이터 식별자
@Cacheable(value = "stocks", key = "'allStocks'")
public List<Stock> getStocks() {
// 이 로그는 캐시가 없을 때(Cache Miss)만 찍힘!
log.info("📢 [DB 조회] 캐시가 없어서 DB에서 주식 목록을 가져옵니다...");
return stockRepository.findAll();
}
}
5. 결과 검증 (로그 확인) 🧪
실제로 성능이 개선되었는지 확인하기 위해 새로고침(F5)을 연타하며 로그를 관찰했습니다.
✅ 1. 첫 번째 요청 (Cache Miss)
Redis가 비어있으므로 DB를 조회합니다. 로그가 출력됩니다.
📢 [DB 조회] 캐시가 없어서 DB에서 주식 목록을 가져옵니다...
✅ 2. 두 번째 ~ N번째 요청 (Cache Hit) 🔥
데이터가 캐시에 저장되었습니다. 이제 아무리 새로고침을 해도 DB 조회 로그가 뜨지 않습니다. 즉, DB 부하가 '0'이 되었습니다.
✅ 3. TTL 만료 후 (Refresh)
설정해둔 시간(30초)이 지나자, Redis가 데이터를 삭제했습니다. 다시 요청을 보내니 로그가 뜹니다.

6. 마무리 및 회고
이번 프로젝트를 통해 백엔드 개발의 핵심인 **"안정성"**과 **"성능"**을 모두 잡는 경험을 했습니다.
- 동시성 제어: JPA 비관적 락 → Redis 분산 락으로 고도화하여 데이터 정합성 확보.
- 성능 최적화: Redis 캐싱(Look-Aside)을 도입하여 조회 성능 개선 및 DB 보호.
단순히 "기능이 돌아간다"에서 멈추지 않고, **"대용량 트래픽이 들어왔을 때 내 서버는 버틸 수 있는가?"**를 끊임없이 고민하며 아키텍처를 개선해 나간 값진 과정이었습니다.
7. 아쉬운 점
실제 캐시를 통해 데이터를 가져올 때와, db에서 데이터를 가져올 때의 시간을 비교하는 사진을 가져와서 차이가 어느정도 나는지 보여주고 싶었으나, 애초에 너무 작은 프로젝트라 데이터의 양이 적어, 실제 차이나는 시간이 10ms 정도밖에 안나 차이를 제대로 보여주지 못한 점이 아쉽습니다.