0. 목표
- 일주일간 인기 메뉴 순위 조회
- 기존 방식
- 순위 조회할 때마다 DB에 접근해서 순위 연산
- JPA의 네이티브 쿼리 이용
- 목표
- Redis로 캐싱하여 DB 접근 최소화 성능 향상
- Redis란
- 키-값(Key-Value) 구조의 비정형 데이터를 저장 및 관리하며 빠른 처리하는 비관계형 DBMS
- 다양한 데이터 구조 지원
- 데이터베이스, 캐시, 메세지 브로커 등의 용도로 사용
- Redis의 Sorted Sets을 사용하여 랭크 보드 구현
- 구조 설계
- Redis Sorted Sets 구조
- 기존 방식
-
-
- Spring 구조
-
Order
1. 최초 주문 시 DB에 저장
2. DB 저장 후 Redis에 Score 추가
PopularMenu
1. 인기 메뉴 조회 시 Redis 조회
2. 조회 데이터 반환
(3. Redis에 데이터가 없을 시 DB 조회)
(4. 조회 데이터 반환)
5. 클라이언트에 조회 데이터 반환
1. Redis - Spring Boot 설정
Redis를 설치하고 스프링 부트에 의존성을 설정한다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
- Redis 2.7.0
- Lettuce 6.1.8
application.properties 또는 application.yml에 Redis 호스트와 포트를 넣어준다.
나는 로컬 환경 테스트 용도이므로 이렇게 적어주었다. 6379는 Redis의 디폴트 포트다.
spring.redis.host=localhost
spring.redis.port=6379
설정 파일
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
2. 코드 수정
기존 코드
public interface PopularMenuRepository extends JpaRepository<OrderItem, Long> {
@Query(nativeQuery = true,
value = "select menu_id as menuId, totalCount, rownum as ranking " +
"from (select menu_id, sum(counts) as totalCount " +
" from order_item " +
" where order_item_date_time between :startDt and :endDt " +
" group by menu_id " +
" order by totalCount desc)" +
"where rownum <= :n")
List<PopularMenuDto> findPopularMenu(@Param("n") int topN,
@Param("startDt") LocalDateTime startDateTime,
@Param("endDt") LocalDateTime endDateTime);
}
- 순위(topN), 순위의 시간 범위를 위한 시작(startDateTime)과 끝(endDateTime) 시간을 파라미터로 받음
- 인기 메뉴 조회할 때마다 DB 조회
- 네이티브 쿼리로 조회
- JPQL은 from에서의 서브쿼리를 지원하지 않아 네이티브 쿼리 사용 (하이버네이트 6.1부터는 사용 가능)
- select와 from의 서브쿼리를 두 개의 메서드로 분리하여 JPQL로 사용하는 방법도 있음
수정 코드
주문 서비스
@Service
public class OrderService {
...
private final RedisTemplate<String, Object> redisTemplate;
public Long order(OrderDto orderDto) throws JsonProcessingException {
...
Order order = Order.createOrder(customer, orderItems);
// 주문 DB 저장
// DB 저장 후 메뉴 별 주문 수 Redis에 저장
putRedisPopularMenu(orderItems);
return order.getId();
}
private void putRedisPopularMenu(List<OrderItem> orderItems) {
String key = RedisUtil.generateKeyYYYYMMWEEK(LocalDateTime.now());
ObjectMapper ojm = new ObjectMapper();
orderItems.stream()
.forEach(oi -> {
Menu menu = oi.getMenu();
try {
// 주문 아이템마다 ZSet의 incrementScore 사용하여 score 증가
redisTemplate.opsForZSet().incrementScore(key, ojm.writeValueAsString(MenuDto.builder()
.id(menu.getId())
.name(menu.getName())
.price(menu.getPrice())
.build()), 1);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
});
}
}
인기 메뉴 조회 컨트롤러
@RequiredArgsConstructor
@RestController
public class PopularMenuController {
private final PopularMenuService popularMenuService;
@GetMapping("/popular/menu")
public ResponseEntity<List<PopularMenuDto>> findPopularMenu(@RequestParam(value = "topN") int topN,
@RequestParam(value = "endDateTime") String edt) throws JsonProcessingException {
DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS");
LocalDateTime endDateTime = LocalDateTime.parse(edt, format);
List<PopularMenuDto> menus = popularMenuService.findPopularMenu(topN, endDateTime);
return ResponseEntity.ok(menus);
}
}
인기 메뉴 조회 서비스
@Service
public class PopularMenuService {
private final RedisTemplate<String, Object> redisTemplate;
...
public List<PopularMenuDto> findPopularMenu(int topN, LocalDateTime ldt) throws JsonProcessingException {
// redis 템플릿으로 ZSet 구조 생성
ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();
// key 생성
String key = RedisUtil.generateKeyYYYYMMWEEK(ldt);
// 역직렬화 준비
ObjectMapper ojm = new ObjectMapper();
// Stream에서 조회된 순서대로 rank를 넣어주기 위해 AtomicInteger 사용
AtomicInteger rank = new AtomicInteger(1);
// 스코어가 많은 순으로 조회(reverse range)
List<PopularMenuDto> popularMenus = zSetOps.reverseRangeWithScores(key, 0, topN-1)
.stream()
.map(z -> {
// 메뉴 DTO -> 인기 메뉴 DTO로 변환하여 매핑
// 리팩토링 대상
MenuDto menu;
try {
// ZSet에 저장된 value(=MenuDto) 역직렬화
menu = ojm.readValue(z.getValue().toString(), MenuDto.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return PopularMenuDto.builder()
.menuId(menu.getId())
.menuName(menu.getName())
.totalCount(z.getScore().intValue())
.ranking(rank.incrementAndGet())
.build();
})
.collect(toList());
// Redis에서 조회된 게 없으면 DB 조회
if (popularMenus == null) {
popularMenus = popularMenuRepository.findPopularMenu(topN, ldt.minusDays(7), ldt)
.stream()
.map(PopularMenuDto::new)
.collect(toList());
}
return result;
}
...
}
Redis Util 클래스
public class RedisUtil {
public static String generateKeyYYYYMMWEEK(LocalDateTime ldt) {
Instant instant = ldt.atZone(ZoneId.systemDefault()).toInstant();
Date date = Date.from(instant);
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
return generateKeyYYYYMMWEEK(calendar);
}
public static String generateKeyYYYYMMWEEK(Calendar calendar) {
return "popular:menu:" + calendar.get(Calendar.YEAR) + "-" + (calendar.get(Calendar.MONTH)+1) + "-" + calendar.get(Calendar.WEEK_OF_MONTH);
}
}
- 주문 (OrderService)
- 주문할 때마다 Redis에 저장하고 score 1 추가
- putRedisPopularMenu(orderItems);
- 주문할 때마다 Redis에 저장하고 score 1 추가
- 인기 메뉴 조회(PopularMenuService)
- 인기 메뉴 조회할 때마다 Redis 조회
- key(년-월-주차)에서 Score가 높은 순으로 topN 개 조회
- reverseRangeWithScores()
- ZREVRANGE
이슈
- 일주일간 인기 메뉴 조회라는 기능에서 '일주일간'을 Redis로 어떻게 대응할 것인가
- 기존 DB에 쿼리로 날릴 때는 where 절로 필터링
- where order_item_date_time between :startDt and :endDt
- key, value, score로 어떻게 날짜를 표현하고 필터링을 해야 하나
- 오직 Redis에 넣을 용도로 메뉴 정보와 주문 날짜가 있는 새로운 객체를 만들기엔 이미 Menu(id, name, price)와 많이 겹침
- 그렇다고 Menu에 주문 날짜를 넣으면 Menu라는 객체의 목적과 어긋남
- 해결
- key에 날짜 정보를 추가
- 주문한 날짜의 주차를 '고정'한다 8월의 1주 차, 8월의 2주 차..
- ex.2023년 8월 5일에 주문 시, 8월 5일은 8월의 첫째주이므로
key는 popular:menu:2023-8-1 - 조회하는 날짜의 주차가 포함된 key를 조회하면 필터링이 되는 셈
- 기존 DB에 쿼리로 날릴 때는 where 절로 필터링
3. 성능 비교
조건
- DB 조회 -> Redis 조회 로직 변경 말고는 모두 동일한 환경
- 2만 건의 주문 아이템을 넣는다
- JMeter를 사용하여 호출할 API와 파라미터를 설정한다
- 한글이 깨지지만 결과는 잘 나온다
Case 1. Number of Threads 1 / Loop Count 10000
Summary Report
수정 전
수정 후
jp@gc - Transactions per Second
수정 전
수정 후
- max 시간 230 -> 57
- TPS 1900 -> 2400
Case 2. Number of Threads 1000 / Loop Count 10
Summary Report
수정 전
수정 후
jp@gc - Transactions per Second
수정 전
수정 후
- max 시간 1703 -> 697
- TPS 3000 -> 6000
'Project' 카테고리의 다른 글
3. JAVA로 아주 간단한 WAS와 Spring MVC Framework 만들기 (0) | 2023.01.10 |
---|---|
2. JAVA로 아주 간단한 WAS와 Spring MVC Framework 만들기 (0) | 2023.01.07 |
1. JAVA로 아주 간단한 WAS와 Spring MVC Framework 만들기 (0) | 2023.01.04 |
커피 주문 서비스를 객체 지향으로 설계해보기 with Java (0) | 2022.10.19 |
4. AWS + Spring Boot + React 프로젝트 근데 이제 배포 자동화를 곁들인 (0) | 2022.03.27 |