
이번 포스트는 Redis의 Cache-Aside(Lazy Loading) 패턴을 적용했던 사례에 대하여 게시판을 예제로 정리해 봤습니다.
글을 들어가기 전에 DB에 100만 건짜리 게시판 데이터를 넣어두고, 매번 리스트를 조회하면 어떤 일이 벌어질까요?
간단히 생각해보면
1. 페이지네이션 + 정렬까지 들어가면 DB 부하가 꽤 세게 온다
2. 동시에 여러 사용자가 몰리면, 같은 쿼리를 반복해서 날리게 된다
간단히 정리하면 2가지 정도 될꺼 같습니다. 아마 이렇게되면 부하가 많이 발생하고, 서비스 품질(속도)가 저하 될 것 입니다.
운이 나쁘면 DB 락이 걸리거나 서버가 다운되는 등 서비스에 치명적인 문제가 발생할 수 있습니다.
사실 대부분의 사용자는 “최신 1~2페이지”만 반복해서 보는 경우가 보통입니다. 물론 10페이지, 20페이지 까지 보는 경우도 있지만
순간적으로 부하를 분산시키고 서비스 품질을 유지 시키고 싶을때 딱 맞는게 Redis를 사용한 캐싱입니다.
이번 글에서는 아래 구조로 간단한 게시판 서비스에 Redis 캐시를 적용한 내용을 정리해 보내겠습니다.
- Spring Boot 3.5.5 / Java 17
- JPA + MariaDB (게시판 데이터 100만 건)
- Redis (포트: 6379, 캐시 TTL 1분)
- @Cacheable / @CacheEvict 기반 캐시 적용
- Cache-Aside(Lazy Loading) 패턴 적용
Docker로 Redis + MariaDB 환경 세팅하기
docker-compose.yml 작성
version: "3.9"
services:
mariadb:
image: mariadb:11
container_name: mariadb-redis-toy
restart: unless-stopped
environment:
MARIADB_DATABASE: search # 예제에서 사용한 DB 이름
MARIADB_USER: 아이디
MARIADB_PASSWORD: 패스워드
MARIADB_ROOT_PASSWORD: 패스워드확인
ports:
- "3306:3306"
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- ./mysql/data:/var/lib/mysql
- ./mysql/init:/docker-entrypoint-initdb.d
redis:
image: redis:7
container_name: redis-redis-toy
restart: unless-stopped
# 패스워드 설정
command: ["redis-server", "--requirepass", "패스워드"]
ports:
- "6379:6379"
volumes:
- ./redis/data:/data
실행
docker-compose up -d
MariaDB에 테스트 데이터 입력 : 100만건
캐싱되었을 때와 캐싱이 안되었을때의 속도 차이를 확인 하기 위하여, DB에는 100만 건더미 데이터를 넣었습니다.
SET SESSION max_recursive_iterations = 1000000;
INSERT INTO board (title, content, created_at, writer)
WITH RECURSIVE cte (n) AS
(
SELECT 1
UNION ALL
SELECT n + 1 FROM cte WHERE n < 1000000
)
SELECT
CONCAT('Title', LPAD(n, 7, '0')) AS title,
CONCAT('Content', LPAD(n, 7, '0')) AS content,
TIMESTAMP(
DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650 + 1) DAY)
+ INTERVAL FLOOR(RAND() * 86400) SECOND
) AS created_at,
CONCAT('Writer', LPAD(n, 7, '0')) AS writer
FROM cte;
프로젝트 구조 & 핵심 기능
게시판 API는 아주 심플하게 구성해 보았습니다.
- POST /api/boards : 글 생성
- GET /api/boards/{id} : 단건 조회
- GET /api/boards?page=1&size=10 : 리스트 조회 (여기에 Redis 캐시 적용)
- PUT /api/boards/{id} : 수정
- DELETE /api/boards/{id} : 삭제
Redis & Spring Boot 연동
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.5'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.lucky'
version = '0.0.1-SNAPSHOT'
description = 'redis-toy-service'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
application.yml 설정
spring:
datasource:
url: jdbc:mariadb://localhost:3306/search
driver-class-name: org.mariadb.jdbc.Driver
username: 아이디
password: 패스워드
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
open-in-view: false
data:
redis:
host: localhost
port: 6379
username: 아이디
password: 패스워드
database: 0
logging:
level:
org.springframework.cache: trace
Redis 커넥션 설정 (Lettuce)
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.username}")
private String username;
@Value("${spring.data.redis.password}")
private String password;
@Value("${spring.data.redis.database:0}")
private int database;
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration conf = new RedisStandaloneConfiguration(host, port);
conf.setDatabase(database);
if (org.springframework.util.StringUtils.hasText(username)) {
conf.setUsername(username);
}
if (org.springframework.util.StringUtils.hasText(password)) {
conf.setPassword(
org.springframework.data.redis.connection.RedisPassword.of(password)
);
}
return new LettuceConnectionFactory(conf);
}
}
Redis CacheManager 설정
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public CacheManager boardCacheManager(RedisConnectionFactory redisConnectionFactory,
ObjectMapper springObjectMapper) {
ObjectMapper om = springObjectMapper.copy()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
GenericJackson2JsonRedisSerializer valueSerializer =
new GenericJackson2JsonRedisSerializer(om);
RedisCacheConfiguration config = RedisCacheConfiguration
.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()
)
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
valueSerializer
)
)
.entryTtl(Duration.ofMinutes(1)); // 캐시 TTL 1분
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(config)
.build();
}
}
메인 클래스에서 @EnableCaching으로 캐시 활성화
@SpringBootApplication
@EnableCaching // 캐시 활성화
public class RedisToyServiceApplication {
public static void main(String[] args) {
SpringApplication.run(RedisToyServiceApplication.class, args);
}
}
Entity & DTO 생성
Board : Entity
@Entity
@Table(name = "board")
@Setter
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@ToString
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String content;
@Column(nullable = false, length = 100)
private String writer;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}
BoardRequestDTO : 요청 DTO
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BoardRequestDTO {
@NotBlank
@Size(max = 200)
private String title;
private String content;
@NotBlank
@Size(max = 100)
private String writer;
}
BoardResponseDTO : 응답 DTO
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BoardResponseDTO {
private Long id;
private String title;
private String content;
private String writer;
private LocalDateTime createdAt;
}
Repository : 최신순 목록 조회
public interface BoardRepository extends JpaRepository<Board, Long> {
Page<Board> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
Controller : API 레벨
@RestController
@RequestMapping("/api/boards")
@RequiredArgsConstructor
@Slf4j
public class BoardController {
private final BoardService boardService;
@PostMapping
public ResponseEntity<BoardResponseDTO> create(
@Valid @RequestBody BoardRequestDTO boardRequestDTO) {
return ResponseEntity.ok(boardService.create(boardRequestDTO));
}
@GetMapping("/{id}")
public ResponseEntity<BoardResponseDTO> get(@PathVariable Long id) {
return ResponseEntity.ok(boardService.get(id));
}
@GetMapping
public List<Board> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return boardService.list(page, size); // ← 여기서 Redis 캐시 적용
}
@PutMapping("/{id}")
public ResponseEntity<BoardResponseDTO> update(
@PathVariable Long id,
@Valid @RequestBody BoardRequestDTO request) {
return ResponseEntity.ok(boardService.update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
boardService.delete(id);
return ResponseEntity.noContent().build();
}
}
게시판 리스트에 Redis 캐시 적용하기
목록 조회 : @Cacheable
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BoardService {
private final BoardRepository boardRepository;
@Transactional
@CacheEvict(cacheNames = "boards:list", allEntries = true, cacheManager = "boardCacheManager") // 캐시 무요화
public BoardResponseDTO create(BoardRequestDTO dto) {
Board entity = Board.builder()
.title(dto.getTitle())
.content(dto.getContent())
.writer(dto.getWriter())
.createdAt(LocalDateTime.now())
.build();
Board saved = boardRepository.save(entity);
return toResponse(saved);
}
public BoardResponseDTO get(Long id) {
Board board = boardRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Board not found: " + id));
return toResponse(board);
}
@Cacheable(
cacheNames = "boards:list",
key = "'page:' + #page + ':size:' + #size",
cacheManager = "boardCacheManager",
condition = "#page >= 1 && #size <= 100", // 과도한 요청은 캐시 안 함
unless = "#result == null || #result.isEmpty()" // 빈 결과는 캐시 안 함
)
public List<Board> list(int page, int size) {
Pageable pageable = PageRequest.of(page - 1, size);
Page<Board> pageOfBoards = boardRepository.findAllByOrderByCreatedAtDesc(pageable);
return pageOfBoards.getContent();
}
@Transactional
@CacheEvict(cacheNames = "boards:list", allEntries = true, cacheManager = "boardCacheManager") // 캐시 무요화
public BoardResponseDTO update(Long id, BoardRequestDTO dto) {
Board board = boardRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Board not found: " + id));
board.setTitle(dto.getTitle());
board.setContent(dto.getContent());
board.setWriter(dto.getWriter());
Board updated = boardRepository.save(board);
return toResponse(updated);
}
@Transactional
@CacheEvict(cacheNames = "boards:list", allEntries = true, cacheManager = "boardCacheManager") // 캐시 무요화
public void delete(Long id) {
if (!boardRepository.existsById(id)) {
throw new IllegalArgumentException("Board not found: " + id);
}
boardRepository.deleteById(id);
}
private BoardResponseDTO toResponse(Board board) {
return BoardResponseDTO.builder()
.id(board.getId())
.title(board.getTitle())
.content(board.getContent())
.writer(board.getWriter())
.createdAt(board.getCreatedAt())
.build();
}
}
해당 코드에 대하여 간단히 설명 하면, 캐시 이름은 boards:list이며, 키 패턴은 "page:1:size:10" 이런 식으로 페이지/사이즈별로 분리 했습니다. 또한 Redis 서버의 부하를 막기 위해 페이지 1 이상, size 100 이하에서만 캐시만 캐싱(condition = "#page >= 1 && #size <= 100")이 되게 하여 무분별하게 캐싱이 되지 않게 하였습니다. 또한, null 같은 무의미한 값의 캐싱을 피하여 자원 낭비를 막기 위해 unless = "#result == null || #result.isEmpty()" 설정 하였습니다.
소스를 예시로 참고 하여, Cache-Aside 패턴의 전형적인 흐름을 정리하면
"/api/boards?page=1&size=10" 요청이 들어왔을때, 아래와 같은 순서로 처리 합니다.
- Redis(boards:list::page:1:size:10)에 값이 있는지 확인
- 없으면 DB에서 조회 후 Redis에 저장
- 다음부터 1분 동안은 Redis에서 바로 꺼내와서 응답

데이터 생성/수정/삭제시 @CacheEvict로 캐시 무효화 시킵니다. 여기 예제에서는 글이 하나라도 추가/수정/삭제되면, 리스트의 순서, 개수가 변할 수 있기 때문에 boards:list 캐시에 들어있는 모든 페이지 캐시를 날리도록 세팅했습니다. (allEntries = true)
하지만 실무에서는 상황에 따라 특정 페이지(예: page=1)만 지우거나, 더 정교하게 invalidation 전략을 가져갈 수도 있습니다.
실행 결과 확인 & 정리


결론 부터 이야기하면, 캐싱 적용 후 "page : 1, size : 100"을 기준으로 했을때 캐싱 적용 전이 1.84s에서 캐싱 적용 후 6ms로 상당히 성능이 올라 간거로 확인 됩니다. 이렇게 하면 일시적으로 부하가 몰렸을때 MariaDB와 Redis를 활용하여 트래픽을 분산 시켜 서버 부하를 낮출 뿐만 아니라 데이터를 빠르게 응답 하여 서비스 품질 또한 향상 시킬 수 있습니다. 그리고 만약 DB에서 장애가 발생 하더라고 Reids에 캐싱이 되어있다면 TTL이 설정되어 있는 시간동안은 서비스는 동작 시킬 수 있어 장애 대응 시간을 벌 수 있습니다.
하지만, Cache-Aside 패턴은 쓰기 빈도가 높으면 캐시가 자주 날아가서, 효과가 줄어들 수 있다. 그럼으로 Cache-Aside 패턴 전용하기전 패턴 적용이 시스템에 유리한지 불리한지 여부를 합리적으로 판단 한는 것이 중요 하다라고 생각합니다.
마지막으로 Cache-Aside 외에 Read-Through / Write-Through, Write-Behind (Write-Back), Refresh-Ahead 등 다양한 패턴 들이 존재 하는데 상황에 따라서 응용을 잘 한다면, 서비스 품질을 높이는데 굉장히 도움이 된다고 생각합니다.
'DB' 카테고리의 다른 글
| [Elasticsearch] 인덱스(Index) 생성 및 도큐먼트(Document) CURD (0) | 2025.12.14 |
|---|---|
| [Elasticsearch] 기본 개념 및 조작하기 (1) | 2025.12.12 |
| [Elasticsearch] Elasticsearch Highlight 검색 적용(With SpringBoot) (0) | 2025.11.12 |
| [Redis] Redis 계정 생성 및 systemd 등록 (0) | 2025.05.19 |
| [Redis] Redis 도입을 위한 설치 및 세팅 (1) | 2025.05.18 |
| [Oracle] EXISTS VS COUNT 성능 차이 (0) | 2025.04.05 |
| [MariaDB/MySQL] general_log 설정 및 확인하기 (0) | 2024.08.26 |
| [MariaDB/MySQL] DB 환경 세팅(2) - MariaDB Server 외부 접속을 위한 방화벽(Firewall) 세팅 (0) | 2024.07.31 |