Intro
프로젝트를 거의 완성한 시점에서 적어 보는 주요 키워드 복기 포스팅이다.
현재 프로젝트에 어떤 기능을 왜 넣었는지, 이로써 어떤 효과가 있었는지, 아쉬웠던 점은 무엇이었는지 적어 보겠다.
Redis로 관리했던 데이터들은 다음과 같다.
- Refresh Token, Blacklist
- 장바구니
- 상품의 재고
사용자 경험이 중요한 이커머스에서 대표적인 해당 데이터들은 자주 변경되고 빠른 접근이 필요한 것이다.
따라서 Redis로 관리하기에 적합하다 판단하였다.
인메모리 방식의 데이터베이스이므로 대량의 데이터 및 장기 보관에는 적합하지 않다는 점을 신경썼다.
🎈 가독성을 위해 일부 코드를 생략하였습니다.
Refresh Token, Blacklist
- 로그인해서 리프레시 토큰이 생성될 때 TTL 설정
- 로그아웃해서 블랙리스트에 리프레시 토큰을 등록할 때, 블랙리스트의 TTL을 7일로 설정
public LoginResponseDto login(LoginRequestDto loginRequestDto, HttpServletResponse response) {
try {
String refreshToken = jwtUtil.generateRefreshToken(username, role);
refreshTokenRepository.save(new RefreshToken(username, refreshToken));
Cookie accessTokenCookie = new Cookie("AccessToken", accessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(60 * 60 * 1000); // 1시간
response.addCookie(accessTokenCookie);
return new LoginResponseDto(username, accessToken);
} catch (Exception e) {
log.error("Error authenticating user", e);
throw new CommonException(ErrorCode.NOT_AUTHORIZED);
}
}
public void logout(String refreshToken) {
if (jwtUtil.validateToken(refreshToken)) {
refreshTokenRepository.deleteByToken(refreshToken);
// 만료 시간을 7일로 설정
BlackList blackList = new BlackList(refreshToken, LocalDateTime.now());
redisTemplate.opsForValue().set("refreshtoken:" + refreshToken, blackList, 7, TimeUnit.DAYS);
} else {
throw new CommonException(ErrorCode.NOT_AUTHORIZED);
}
}
장바구니
- 사용자의 경험을 고려하여 장바구니에 상품 추가, 수량 업데이트 때마다 TTL을 갱신하도록 함
private static final long CART_TTL = 30; // 30일
// 장바구니 조회
public List<CartItemResponseDto> getCart(String username) {
redisUtils.setExpire(username, CART_TTL, TimeUnit.DAYS);
}
// 장바구니에 상품 추가
public CartResponseDto addCart(String username, AddItemCartRequest addItemCartRequest) {
// 상품 정보를 상품 서비스에서 조회
ProductDto productDto = productAdapter.getItem(addItemCartRequest.getItemId());
redisUtils.put(username, cart);
redisUtils.setExpire(username, CART_TTL, TimeUnit.DAYS); // TTL 설정
return new CartResponseDto(cart);
}
// 장바구니에서 특정 상품 제거
public CartResponseDto removeItem(String username, Long itemId) {
Cart cart = redisUtils.get(username, Cart.class);
if (cart != null) {
cart.getItems().removeIf(item -> item.getItemId().equals(itemId));
redisUtils.put(username, cart);
redisUtils.setExpire(username, CART_TTL, TimeUnit.DAYS); // TTL 설정
}
return new CartResponseDto(cart);
}
// 장바구니 상품 수량 업데이트
public CartResponseDto updateCartItem(String username, UpdateCartItemRequest updateCartItemRequest) {
if (cart != null) {
//...
if (itemOptional.isPresent()) {
CartItem existingItem = itemOptional.get();
existingItem.setCount(updateCartItemRequest.getQuantity());
redisUtils.put(username, cart);
redisUtils.setExpire(username, CART_TTL, TimeUnit.DAYS); // TTL 설정
//...
}
재고
- 상품 생성, 상품 조회, 재고 업데이트할 때마다 TTL을 갱신하도록 함
private static final long STOCK_EXPIRATION = 30; // 30일
private final RedisScript<Boolean> updateInventoryScript = RedisScript.of(
"local current = redis.call('get', KEYS[1]) " +
"if current and tonumber(current) + tonumber(ARGV[1]) >= 0 then " +
" redis.call('incrby', KEYS[1], ARGV[1]) " +
" redis.call('expire', KEYS[1], ARGV[2]) " + // TTL 설정 추가
" return true " +
"else " +
" return false " +
"end",
Boolean.class
);
@Transactional
public boolean updateInventory(Long itemId, int quantityChange) {
String key = INVENTORY_KEY_PREFIX + itemId;
Boolean result = redisTemplate.execute(updateInventoryScript,
Collections.singletonList(key),
String.valueOf(quantityChange),
String,valueOf(STOCK_EXPIRATION);
//...
log.info("재고 및 판매량 업데이트 성공: 상품 ID {}, 변경량 {}", itemId, quantityChange);
return true;
} else {
log.warn("재고 업데이트 실패: 상품 ID {}, 변경량 {}", itemId, quantityChange);
return false;
}
}
@Transactional
public int getStock(Long itemId) {
//...
// 만료 시간 재설정
redisTemplate.expire(key, STOCK_EXPIRATION, TimeUnit.DAYS);
log.info("재고 조회: 상품 ID {}, 수량 {}", itemId, stock);
return stock;
}
@Transactional
public void setStock(Long itemId, Integer quantity) {
String key = INVENTORY_KEY_PREFIX + itemId;
redisTemplate.opsForValue().set(key, quantity, Duration.ofDays(STOCK_EXPIRATION));
log.info("재고 설정: 상품 ID {}, 수량 {}", itemId, quantity);
}
@Transactional
public void updateStock(Long itemId, int quantity) {
//...
redisTemplate.opsForValue().set(key, newQuantity, Duration.ofDays(STOCK_EXPIRATION));
log.info("재고 업데이트: 상품 ID {}, 새 수량 {}", itemId, newQuantity);
}
😂 더 고려했으면 좋았을 것
- 장애 대비
➡ Redis 서버에서 문제가 발생하여 다운 되었다면? 만약 Redis DB 하나만 운용하고 있었다면?
정말 아찔한 상황이 펼쳐질 것 같다..
➡ Redis 하나만 운용하는 것이 아닌 다른 데이터베이스와 함께 사용하면서, 주기적으로 Redis의 데이터를 주 DB에 동기화하는 작업이 있었으면 더 좋았을 것 같다.
➡ 특히 재고 관리 상황에서, 만약 인기 없는 상품의 경우 한 달 동안 어떤 재고도 감소되지 않았다면? 주 DB와 동기화하여 항상 최신 재고를 유지하도록 했으면 더 관리하기에 용이했을 것 같다.
➡ 비즈니스 상황에 의해 장기보관의 정도가 짧다면 다른 데이터베이스와 함께 관리하는 건 필수적일 듯 하다. - 보안
➡ 보안 정책이 더 엄격하다면 리프레시 토큰과 같은 민감한 정보는 저장할 때 암호화를 한다면 더 좋을 듯
프로젝트를 진행하다 위와 같은 결함이 있을 수도 있다는 걸 너무 늦게 알아서 시간상 적용하진 못 했지만..
다음부터는 꼭 Redis와 주 DB를 동기화하는 식으로 사용해 봐야 겠다.