개요
MSA 프로젝트에 사용할 Redis를 활용한 캐시 기능에 대해 배워볼 것이다.
캐시(Cache)?
데이터 임시 저장소로, 자주 사용하는 데이터나 계산 결과를 저장하여 데이터 접근 속도를 높이고 시스템 부하를 줄이는 기술, 주로 메모리(RAM)에 저장되며, 디스크 I/O 또는 네트워크 요청을 줄여 성능을 개선함.
캐싱(Caching)
- 데이터를 캐시에 저장하고 활용하는 프로세스.
- 주요 목적은 반복적인 데이터 요청을 줄이고 응답 시간을 단축하는 것.
- 캐싱은 시스템 성능 최적화, 비용 절감 , 네트워크 부하 감소에 기여.
캐싱 전략
캐시를 효율적으로 사용하기 위해 데이터를 어떻게 캐시에 저장하고 관리할지를 정의하는 방법.
1. 캐시 적중 정책
- Write-Through: 데이터를 캐시와 저장소(원본 데이터베이스)에 동시에 기록.
- 장점: 데이터 일관성 보장.
- 단점: 쓰기 속도가 느릴 수 있음.
- Write-Behind (Write-Back): 데이터를 캐시에만 저장한 후 일정 시점에 원본에 반영.
- 장점: 빠른 쓰기 속도.
- 단점: 데이터 손실 가능성 존재.
- Read-Through: 데이터 요청 시 캐시에 없으면 원본에서 가져와 캐시에 저장.
2. 캐시 무효화 정책
- Time-To-Live (TTL): 데이터의 유효 기간을 설정해 오래된 데이터를 제거.
- Explicit Invalidation: 애플리케이션에서 명시적으로 캐시를 삭제.
- Least Recently Used (LRU): 가장 오랫동안 사용되지 않은 데이터를 제거.
- Least Frequently Used (LFU): 사용 빈도가 가장 낮은 데이터를 제거.
3. 캐싱 위치
- 클라이언트 측 캐싱: 웹 브라우저의 쿠키, 로컬 스토리지.
- 서버 측 캐싱: 서버 메모리, Redis, Memcached.
- CDN 캐싱: 콘텐츠 배포 네트워크를 활용해 정적 콘텐츠를 사용자와 가까운 위치에 저장.
4. 분산 캐싱
- 여러 서버에서 동일한 데이터를 캐싱해 확장성과 가용성을 높이는 방법.
- 주로 Redis, Memcached 같은 툴 사용.
사용 사례
- API 응답 캐싱: 자주 호출되는 API 결과를 캐시.
- DB 조회 캐싱: 반복적인 데이터베이스 쿼리 결과를 캐시.
- 정적 콘텐츠 캐싱: 이미지, CSS, JavaScript 파일을 캐싱해 웹 성능 개선.
실습
Spring Boot 캐싱 적용
1. 프로젝트 구성
- build.gradle dependency
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
- DB와 Cache를 사용 위해 Redis 추가
- application.yml
spring:
data:
redis:
host: localhost
port: 6379
username: default
password: systempass
datasource:
url: jdbc:mysql://localhost:3306/balance
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 'zaq123'
jpa:
hibernate:
ddl-auto: create
defer-datasource-initialization: true
show-sql: true
sql:
init:
mode: always
- DB와 Redis 설정 등록
- CacheConfig
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
/* 설정 구성을 먼저 진행한다.
* Redis를 이용해서 Spring Cache를 사용할 때
* Redis 관련 설정을 모아두는 클래스*/
RedisCacheConfiguration configuration = RedisCacheConfiguration
.defaultCacheConfig()
// null을 캐싱하는지
.disableCachingNullValues()
// 기본 캐시 유지 시간 (Time to Live)
.entryTtl(Duration.ofSeconds(10))
// 캐시를 구분하는 접두사 설정
.computePrefixWith(CacheKeyPrefix.simple())
// 캐시에 저장할 값을 어떻게 직렬화 / 역직렬화 할 것인지
.serializeValuesWith(SerializationPair.fromSerializer(RedisSerializer.java()));
return RedisCacheManager
.builder(connectionFactory)
.cacheDefaults(configuration)
.build();
}
}
- Spring Cache를 사용 Redis 캐시 저장소를 구현하기 위한 설정
2. Test용 코드 작성
Test 편의성을 위해 어노테이션 활용
- ItemEntity, ItemDto
@Getter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Item {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
private String name;
@Setter
private String description;
@Setter
private Integer price;
}
@Getter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ItemDto implements Serializable {
private Long id;
private String name;
private String description;
private Integer price;
public static ItemDto fromEntity(Item item) {
return ItemDto.builder()
.id(item.getId())
.name(item.getName())
.description(item.getDescription())
.price(item.getPrice())
.build();
}
}
- ItemRepository
public interface ItemRepository extends JpaRepository<Item, Long> {
Page<Item> findAllByNameContains(String name, Pageable pageable);
}
- ItemController
@RestController
@RequestMapping("items")
@RequiredArgsConstructor
public class ItemController {
private final ItemService itemService;
@PostMapping
public ItemDto create(@RequestBody ItemDto itemDto) {
return itemService.create(itemDto);
}
@GetMapping
public List<ItemDto> readAll() {
return itemService.readAll();
}
@GetMapping("{id}")
public ItemDto readOne(@PathVariable("id") Long id) {
return itemService.readOne(id);
}
@PutMapping("{id}")
public ItemDto update(@PathVariable("id") Long id, @RequestBody ItemDto dto) {
return itemService.update(id, dto);
}
@DeleteMapping("{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
itemService.delete(id);
}
@GetMapping("search")
public Page<ItemDto> search(
@RequestParam(name = "q")
String query,
Pageable pageable
) {
return itemService.searchByName(query, pageable);
}
}
- Item에 대한 CRUD 작성
- ItemService
@Slf4j
@Service
public class ItemService {
private final ItemRepository itemRepository;
public ItemService(ItemRepository itemRepository) {
this.itemRepository = itemRepository;
}
@CachePut(cacheNames = "itemCache", key = "#result.id")
public ItemDto create(ItemDto dto) {
return ItemDto.fromEntity(itemRepository.save(Item.builder()
.name(dto.getName())
.description(dto.getDescription())
.price(dto.getPrice())
.build()));
}
@Cacheable(cacheNames = "itemAllCache", key = "getMethodName()")
public List<ItemDto> readAll() {
return itemRepository.findAll()
.stream()
.map(ItemDto::fromEntity)
.toList();
}
// 이 메서드의 결과는 캐싱이 가능하다.
// cacheNames: 메서드로 인해서 만들어질 캐시를 지칭하는 이름
// key: 캐시 데이터를 구분하기 위해 활용하는 값
@Cacheable(cacheNames = "itemCache", key = "args[0]")
public ItemDto readOne(Long id) {
log.info("Read One: {}", id);
return itemRepository.findById(id)
.map(ItemDto::fromEntity)
.orElseThrow(() ->
new ResponseStatusException(HttpStatus.NOT_FOUND));
}
@CachePut(cacheNames = "itemCache", key = "args[0]")
// Evict에서 allEntries 대신 key로 지정 가능(예: readAll)
@CacheEvict(cacheNames = "itemAllCache", allEntries = true)
public ItemDto update(Long id, ItemDto dto) {
Item item = itemRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
item.setName(dto.getName());
item.setDescription(dto.getDescription());
item.setPrice(dto.getPrice());
return ItemDto.fromEntity(itemRepository.save(item));
}
@CacheEvict(cacheNames = "itemCache", key = "args[0]")
public void delete(Long id) {
itemRepository.deleteById(id);
}
@Cacheable(
cacheNames = "itemSearchCache",
key = "{ args[0], args[1].pageNumber, args[1].pageSize }"
)
public Page<ItemDto> searchByName(String query, Pageable pageable) {
return itemRepository.findAllByNameContains(query, pageable)
.map(ItemDto::fromEntity);
}
}
- 각 메서드 별로 Caching 기능 사용
각 메서드 별 캐싱 설정
1. Create
@CachePut(cacheNames = "itemCache", key = "#result.id")
- 캐싱 어노테이션: @CahcePut
- 메서드의 실행 결과를 캐시에 저장: 새로운 데이터 생성 후, 결과 객체의 id 값을 키로 사용해 캐시에 저장
- 특징
- 항상 메서드를 실행하며, 결과를 캐시에 저장. Cacheable과 달리 캐시를 조회하지 않고 결과를 갱신.
2. Read
// readOne
@Cacheable(cacheNames = "itemCache", key = "args[0]")
// readAll
@Cacheable(cacheNames = "itemAllCache", key = "getMethodName()")
- 캐싱 어노테이션: @Cacheable
- One: 주어진 ID(args[0])을 키로 사용해 캐시에 데이터가 있는지 확인
- 캐시 히트: 캐시에서 데이터를 반환, 캐시 미스: 메서드 실행 후 결과를 캐시에 저장
- All: 캐시에 해당 키(getMethodName())가 존재하면 캐시에서 데이터를 반환, 없으면 메서드를 실행해 결과를 캐시에 저장
- One: 주어진 ID(args[0])을 키로 사용해 캐시에 데이터가 있는지 확인
- 특징:
- One: 캐시에 저장된 데이터가 있을 경우 로그 호출 없이 캐시에서 반환
- All: 메서드 결과가 항상 동일할 것으로 예상되는 경우 유용
3. Update
@CachePut(cacheNames = "itemCache", key = "args[0]")
@CacheEvict(cacheNames = "itemAllCache", allEntries = true)
- 캐싱 어노테이션
- @CachePut: 업데이트된 데이터를 캐시에 저장. 키는 args[0](id)
- @CacheEvict: itemAllCache에 저장된 모든 엔트리를 삭제
- 특징
- 캐시와 데이터베이스의 상태를 일치시킴
- itemAllCache는 readAll에서 사용되므로 데이터 변경 시 무효화 필요
5. Delete
@CacheEvict(cacheNames = "itemCache", key = "args[0]")
- 캐싱 어노테이션: CacheEvict
- ItemCache의 특정 항목(args[0]) 제거
6. Search
@Cacheable(
cacheNames = "itemSearchCache",
key = "{ args[0], args[1].pageNumber, args[1].pageSize }"
)
- 캐싱 애노테이션: @Cacheable
- 검색 쿼리와 페이지 정보(페이지 번호, 크기)를 키로 사용해 캐시에 저장된 검색 결과를 조회.
- 캐시 히트: 캐시된 검색 결과 반환, 캐시 미스: 검색 실행 후 결과를 캐시에 저장.
- 검색 쿼리와 페이지 정보(페이지 번호, 크기)를 키로 사용해 캐시에 저장된 검색 결과를 조회.
- 특징:
- 복합 키(query, pageNumber, pageSize)로 저장.
- 동일한 검색어와 페이징 조건으로 호출 시 캐시된 데이터를 재사용.
3. 테스트 및 성능 비교
조회와 검색 기능만 비교
- 단건 조회: 캐시가 없을 때
- 단건 조회: 캐시로 조회
- 단 건 조회이기 때문에 속도 상으로 큰 차이가 나지 않지만 캐시가 있을 경우 SQL 쿼리 구문 없이 데이터를 반환해 준다.
- 페이징: 캐시 없을 때
- 페이징: 캐시 조회
- 페이징 처리 시 캐시가 없을 때에 비해 약 90% 정도 속도가 빨라진 것을 확인할 수 있다.
- 검색: 캐시 없을 때
- 검색: 캐시 조회
- 캐시가 없을 때 보다 93% 가량 조회 속도가 빠른 것을 확인할 수 있다.
정리
- 캐싱을 통해 데이터 접근 속도 증가와 DB 부하 감소, 응답 시간 단축 등 많은 이점을 가져올 수 있다는 사실을 배웠다.
- 캐싱 시 일관성 및 캐시 만료 정책 등을 설계할 때 문제가 발생하지 않도록 잘 고려해야 될 것 같다.
'자바 심화 > TIL' 카테고리의 다른 글
Rabbit MQ (1) | 2024.12.05 |
---|---|
대규모 스트림 처리 (2) | 2024.12.04 |
Redis - Redis Template (0) | 2024.11.29 |
Docker - 기본 사용 및 Cl / CD (5) | 2024.11.28 |
MSA - 기초 5 (0) | 2024.11.27 |