개요
이번에는 Write Back 혹은 Write Behind 라고도 불리는 지연 쓰기 전략에 대해 적어보겠습니다.
Write Back은 Write Through 처럼 데이터의 변경(수정, 삭제 등)이 일어날 때 캐시를 최신화, 일관성을 유지하는 전략입니다.
다만 다른 점은 Write Through는 캐시 데이터를 변경하면서 동시에 DB에 변경 사항을 반영하는데 Write Back 전략은 변경 사항을 모았다가 한 번에 DB에 반영합니다.
이 방법은 높은 트래픽 상황에서 응답 시간을 최적화 하는데 유용하지만 데이터 손실 위험이 있습니다.
장점과 단점
장점
- 빠른 응답 시간: 쓰기 요청에 대한 응답은 캐시 업데이트 후 즉시 반환되므로, 사용자에게는 빠른 응답 시간이 제공됩니다.
- 배치 처리의 효율성: 여러 쓰기 작업을 배치로 묶어서 한 번에 처리하므로, I/O 작업의 오버헤드가 줄어듭니다.
단점
- 데이터 일관성의 위험: 캐시와 백엔드 저장소 사이에 일시적인 데이터 불일치가 발생할 수 있습니다. 이는 배치 업데이트가 이루어질 때까지 지속됩니다.
- 데이터 손실의 위험: 시스템 장애가 발생하면, 아직 백엔드 저장소에 기록되지 않은 캐시의 데이터가 손실될 수 있습니다.
하지만 단점을 잘 매꾸면 높은 트래픽 상황에서도 응답 시간을 최적화하는 성능 향상을 이룰 수 있습니다.
이 Write Back 전략은 주로 바로 반영되서 보여주지 않아도 되는 좋아요수, 조회수 같은 서비스에 주로 사용된다고 합니다.
(Youtube의 조회수는 들어갈 때 마다 바로 반영되는게 아니라 일정 시간 이후 반영됩니다.)
실행 흐름
- 데이터 변경 요청(수정, 삭제)이 들어온다.
- Redis에 Caching된 데이터만 변경하고 DB에 반영하지 않는다. 대신 지연 정보를 큐에 담아 Redis에 저장한다.
- 원하는 시간대에 Redis에 저장된 큐에서 하나씩 꺼내면 작업을 처리한다.
코드
build.gradle에 의존성 추가
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// 직렬화, 역직렬화
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
// cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
application.yml에 Redis 연결 정보 추가
spring:
cache:
type: redis
redis:
host: localhost
port: 6379
password: ssafy
3. Service 파일 생성
Operation 클래스 생성
// Redis에 지연 정보를 저장할 Operation 클래스
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Operation {
private String type;
private JsonNode data;
}
Redis에 지연 정보를 저장할 형태의 Operation 클래스 입니다.
Redis에 이 Operation의 List를 넣어 변경 사항 정보, 즉 지연 정보들을 저장합니다.
JsonNode
이 때 data로 JsonNode를 사용했습니다.
Jackson 라이브러리는 Java 객체와 JSON 간의 직렬화, 역직렬화를 지원하는 라이브러리 입니다.
JsonNode는 Jackson 라이브러리에서 제공하는 JSON 트리 모델의 핵심 클래스 입니다.
저의 로직은 수정, 삭제일 때 data에 들어가는 자료형이 다릅니다.
수정일 떄는 Product, 삭제일 때는 Integer id가 들어갑니다.
JsonNode를 사용함으로 객체의 트리 구조로 변환해 쉽게 다룰 수 있고 직렬화, 역직렬화를 쉽게 할 수 있었습니다.
Operation 직렬화, 역직렬화
// Operation 직렬화
public String operationSerialize(Operation operation) {
try {
return objectMapper.writeValueAsString(operation);
} catch (Exception e) {
throw new RuntimeException("직렬화 실패", e);
}
}
// Operation 역직렬화
public Operation operationDeserialize(String serializedData) {
try {
return objectMapper.readValue(serializedData, Operation.class);
} catch (Exception e) {
throw new RuntimeException("역직렬화 실패", e);
}
}
Operation의 직렬화를 통해 Redis 에 저장하고 역직렬화를 통해 Redis에 가져온 값을 다시 Java의 객체로 돌립니다.
Product 수정 메소드
// 상품 수정
public ProductResponseDto updateProductWriteBack(ProductUpdateDto dto) {
// MySQL Product 조회
Product product = productRepository.findById(dto.getId()).orElseThrow(
()-> new RuntimeException("Product 조회 실패")
);
// Product 수정 전 detach -> DB에 반영 안됨
entityManager.detach(product);
// Product 수정
product.setName(dto.getName());
product.setDescription(dto.getDescription());
product.setPrice(dto.getPrice());
// 캐시 수정
redisTemplate.opsForValue().set("product" + product.getId(), product);
// 지연 정보 추가
JsonNode productJsonNode = objectMapper.valueToTree(product);
Operation operation = new Operation("UPDATE", productJsonNode);
String serializedData = operationSerialize(operation);
stringRedisTemplate.opsForList().rightPush("productOperationList", serializedData);
// 응답
ProductResponseDto result = productMapper.productToProductResponseDto(product);
return result;
}
entitiyManager.detach(product);
수정 요청이 들어오면 product 객체를 불러와서 수정 사항들을 반영한 후 Redis에 저장합니다.
이 때, 저는 JPA를 사용하고 있기에 set 메소드로 객체의 필드를 수정하면 자동으로 DB에 반영됩니다.
이것을 방지하고자 entityManager.detach(); 를 사용해서 객체의 영속성을 포기하여 자동 반영을 막았습니다.
변경된 product를 redis에 업데이트하고 productOperationList에 지연 정보를 넣었습니다.
Product 삭제
// 상품 삭제
public void deleteProductWriteBack(Integer id) {
// Redis에서 Product 삭제
redisTemplate.delete("product" + id);
// 지연 정보 추가
JsonNode idJsonNode = objectMapper.valueToTree(id);
Operation operation = new Operation("DELETE", idJsonNode);
String serializedData = operationSerialize(operation);
stringRedisTemplate.opsForList().rightPush("productOperationList", serializedData);
}
redis에 캐싱된 데이터를 삭제하고 productOperationList에 삭제 지연 정보를 넣었습니다.
productOperationList 실행
@EnableScheduling
변경된 정보들을 저장한 productOperationList 5분 마다 실행하게 했습니다.
이를 위해 Spring Boot Application의 main 메소드에 @EnableScheduling 어노테이션을 추가했습니다.
@SpringBootApplication
@EnableScheduling
public class ProductApplication {
public static void main(String[] args) {
SpringApplication.run(ProductApplication.class, args);
}
}
productOperationList 안에 있는 Operation을 실행시키는 메소드
// 지연 정보 처리 메소드 - 5분 마다 실행
@Scheduled(fixedRate = 300000)
public void processOperationList() throws JsonProcessingException {
while (true) {
String serializedData = stringRedisTemplate.opsForList().leftPop("productOperationList");
if(serializedData == null) {
break;
}
Operation operation = operationDeserialize(serializedData);
if (operation.getType().equals("UPDATE")) {
Product data = objectMapper.treeToValue(operation.getData(), Product.class);
Product product = productRepository.findById(data.getId()).orElse(null);
product.setName(data.getName());
product.setDescription(data.getDescription());
product.setPrice(data.getPrice());
productRepository.save(product);
}
else if (operation.getType().equals("DELETE")) {
Integer data = operation.getData().asInt();
productRepository.deleteById(data);
}
}
}
@Scheduled(fixedRate = 300000)
이 어노테이션을 통해 메소드의 스케줄링을 추가할 수 있습니다.
시간은 5 minutes * 60 seconds * 1000 milliseconds 로 계산합니다.
이 메소드는 productOperationList에서 Operation을 하나씩 가져와서 DB에 반영합니다.
이 때 Operation이 null이면 비어있다는 뜻이므로 동작을 멈추게 했습니다.
실행 결과
RedisInsight 를 통해 데이터 변경과 삭제 지연 정보가 저장된 것을 확인할 수 있습니다.
5분 후
마무리
이번에는 wrtie back 을 구현해 보았습니다.
이번 Redis를 하면서 직렬화, 역직렬화에 애를 먹는 거 같습니다.
또한 Operation의 data가 수정, 삭제일 때가 다르기 때문에 이 부분을 같은 List에 넣기 위해 고민을 많이 했습니다.
다행히 동작이 되게 만들었지만 보수해야할 부분이 많습니다.
Operation을 실행하면서 도중에 실패하면 처리할 코드, Redis가 내려가서 List가 사라졌을 때 복구하는 방법 등 안정성을 더 높여야 합니다.
이 부분도 학습하여 적용해보도록 하겠습니다.
감사합니다.