프로젝트/쇼핑몰 프로젝트

[쇼핑몰 프로젝트] 분산 환경에서 데이터 캐싱 - Redis

Joo.v7 2025. 3. 24. 21:10

0. 개요

장바구니 로직을 설계하던 중, 사용자가 장바구니를 이용할 때마다 DB에서 매번 데이터를 넣고 가져오는 방식은 비효율적이라 생각했다. 그래서 장바구니 데이터를 세션으로 관리하고, 특정 시점(세션 종료, 로그아웃)에만 DB에 백업하여 저장하는 방식으로 설계를 진행했다.


1. 문제

  1. 서버 이중화 환경에서, 세션 통해 장바구니를 관리하면 세션 불일치 문제가 발생했다.
    • 서버가 이중화된 환경에서 사용자가 장바구니 세션이 없는 다른 서버로 요청이 가면 장바구니 기능이 제대로 동작하지 않는 문제가 발생했다.
  2. 사용자가 많을 경우, 서버 메모리의 부담이 커진다.
    • 장바구니는 사용자가 자주 또 많이 이용하는 기능이다. 이는 서버의 메모리 사용량을 크게 증가시켜, 서버의 성능 저하로 인해 서비스의 품질에 영향이 갈 것이라 생각했다.

2. 여러 해결 방법 검토

  1. Session Clustering: 서버들을 하나의 클러스터로 묶어서 관리하고, 클러스터 내의 서버들이 세션을 공유할 수 있도록 하는 방식.
    • 문제점
      • 구현이 복잡하다.
      • 장바구니는 많은 사람들이 자주 사용할텐데, 그럴때마다 생기는 세션 데이터가 모든 서버에 추가되어야 하기 때문에 서버의 부담이 커진다.
  2. 세션 데이터를 서버의 외부 캐시 시스템으로 관리: 세션 데이터를 애플리케이션 서버의 메모리가 아닌 외부 캐시 서버에 저장하는 방식.
    • Redis: 키-값(Key-Value) 구조의 NoSQL 데이터베이스로, 메모리 기반으로 동작하여 매우 빠른 속도를 제공하는 오픈 소스 인메모리 데이터 저장소.
      • 장점: 다양한 데이터 구조 지원, TTL 기능.
      • 단점: 메모리 기반 저장소라서 RAM 크기에 따라 저장할 수 있는 데이터가 제한됨, 단일 스레드 처리

3. 내가 선택한 방법: Redis

결론적으로, Redis를 사용하기로 결정했다.

Redis가 제공하는 다양한 데이터 구조는 장바구니의 상품을 효율적으로 관리하는 데 매우 적합하다고 판단했다.

또한, Redis는 데이터 캐싱 역할을 수행하며, 영구 저장을 위해 MySQL과 함께 사용할 계획이므로 메모리 기반 저장소의 한계도 효과적으로 보완할 수 있다.

 

(1) 장바구니 플로우

  • 장바구니는 비회원도 사용할 수 있다.
  • 회원은 로그인 시, DB에서 가져와서 Redis에 저장한다. 장바구니가 없을 경우 새로 만든다.
  • 장바구니의 영속성Spring Scheduler를 활용하여 특정 시점에 배치 처리(Batch Processing) 방식으로 대량의 데이터를 DB에 저장하는 방식으로 해결한다. -> 장바구니만을 위한 Redis DB를 따로 둔 이유다.

장바구니 로직

 


(2) 코드 - 프론트 서버

RedisConfig

  • Redis를 설정한다.
  • Lettuce 기반의 RedisConnectionFactory를 생성하고, 각 목적에 맞는 Redis DB를 관리한다.
    (Lettuce: 비동기 & Thread-safe한 방식으로 Spring에서 기본적으로 지원한다)
  • RedisTemplate를 통해 데이터 직렬화/역직렬화하여 저장하는 기능 제공한다.
더보기
@Slf4j
@RequiredArgsConstructor
@Configuration
public class RedisConfig2 {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Value("${spring.data.redis.password}")
    private String password;

//    @Value("${nhnKey.keyId}")
//    private String keyId;

    @Value("${spring.data.redis.default_db}")
    private int default_db;

    @Value("${spring.data.redis.cart_db}")
    private int cart_db;

    public LettuceConnectionFactory createConnectionFactory(int dbNo) {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();

        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(port);
        redisStandaloneConfiguration.setPassword(password);
        redisStandaloneConfiguration.setDatabase(dbNo);

        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    // default - 201
    @Bean
    @Primary
    public RedisConnectionFactory defaultRedisConnectionFactory() {
        log.info("default RedisConnectionFactory 등록");
        return createConnectionFactory(default_db);
    }

    // cart - 202
    @Bean
    public RedisConnectionFactory cartRedisConnectionFactory() {
        log.info("cart RedisConnectionFactory 등록");
        return createConnectionFactory(cart_db);
    }

    // default - 201: 두레이 인증, 등등...
    @Bean
    public RedisTemplate<String, Object> authRedisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

        redisTemplate.setConnectionFactory(defaultRedisConnectionFactory());

        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

        return redisTemplate;
    }

    // 장바구니용 - 202
    @Bean
    public RedisTemplate<Object, Object> cartRedisTemplate() {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();

        redisTemplate.setConnectionFactory(cartRedisConnectionFactory());

        redisTemplate.setKeySerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setHashKeySerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

        return redisTemplate;
    }

}

 

CartController

  • 장바구니 요청 처리하고 view 리턴.
더보기
@RequiredArgsConstructor
@Controller
@RequestMapping("/cart")
public class CartController {

    private final CartService cartService;

    // 장바구니 목록 페이지
    @GetMapping
    public String cartForm(HttpServletRequest request,
                           HttpServletResponse response,
                           Model model) throws IOException {

        String cartId = cartService.getCartIdFromCookie(request, response);
        List<CartItemViewResponse> cartItemViews =cartService.getCartItemsFromRedisById(cartId);
        model.addAttribute("cartItems", cartItemViews);

        return "cart/cartForm";
    }

    // 장바구니에 도서 담기 -> true, false로 리턴해야 자바스크립트에서 받고 "장바구니로 이동하기" 팝업을 보여준다.
    @PostMapping
    public ResponseEntity<Boolean> addItemToCart(HttpServletRequest request,
                                        HttpServletResponse response,
                                        @ModelAttribute CartRequest cartRequest) throws IOException {

        String cartId = cartService.getCartIdFromCookie(request, response);
        boolean result = cartService.addCartItem(cartId, cartRequest);

        return ResponseEntity.ok(result);
    }

    // 장바구니에서 도서 삭제
    @DeleteMapping
    public String deleteCartItem(HttpServletRequest request,
                               HttpServletResponse response,
                               @RequestParam Long bookId) throws IOException {

        String cartId = cartService.getCartIdFromCookie(request, response);
        cartService.removeCartItem(cartId, bookId);

        return "redirect:/cart";
    }

    // 장바구니 도서 수량 변경
    @PutMapping
    public String updateCartItem(HttpServletRequest request,
                                 HttpServletResponse response,
                                 @ModelAttribute CartRequest cartRequest) throws IOException {

        String cartId = cartService.getCartIdFromCookie(request, response);
        cartService.modifyCartItem(cartId, cartRequest);

        return "redirect:/cart";
    }

}

 

CartService

  • 장바구니 비즈니스 로직을 처리.
더보기
@RequiredArgsConstructor
@Service
public class CartService {
    private final CartClient cartClient;
    private final RedisTemplate<Object, Object> redisTemplate;
    public final static String CART_ID = "CART_ID";
    public final static String CART_PREFIX = "CART:";

    private final BookService bookService;
    private final ImageService imageService;
    private final StockService stockService;

    // CART_ID 쿠키에서 cartId 가져오기.
    public String getCartIdFromCookie (HttpServletRequest request, HttpServletResponse response) throws IOException {
        String result = null;

        Cookie[] cookies = request.getCookies();
        // 쿠키가 하나도 없으면 '홈'으로 리다이렉트 하게 함.
        if(Objects.isNull(cookies)) {
            response.sendRedirect("/");
        }

        // 장바구니 쿠키가 있으면 쿠키 안의 cartId 값 return.
        for(Cookie c : cookies) {
            if(c.getName().equals(CART_ID)) {
                result = c.getValue();
                return result;
            }
        }

        // cartId가 없으면 새로 생성.
        String cartId = UUID.randomUUID().toString(); // cartId 생성.

        // CART_ID cookie 생성.
        HttpSession session = request.getSession(true);
        session.setAttribute("cartId", cartId);

        Cookie cookie = new Cookie(CART_ID, session.getId());
        cookie.setHttpOnly(true);
        cookie.setMaxAge(30*24*60*60); // 장바구니 쿠키 유효 기간: 30일
        cookie.setPath("/");
        response.addCookie(cookie);

        result = cartId;

        return result;
    }

    // cartId로 redis에 저장된 cartItems 가져오기.
    public List<CartItemViewResponse> getCartItemsFromRedisById(String cartId) {
        Map<Object, Object> map = redisTemplate.opsForHash().entries(CART_PREFIX+cartId);
        List<CartItemResponse> cartItemResponses = CartItemResponse.fromMapToList(map);

        if(cartItemResponses.isEmpty()) {
           return null;
        }

        List<CartItemViewResponse> result = new ArrayList<>();
        // bookId -> book 가져와서 return.
        for (int i = 0; i < cartItemResponses.size(); i++) {
            try {
                CartItemResponse cartItemResponse = cartItemResponses.get(i);

                BookDTO book = bookService.getBook(cartItemResponse.bookId()); // book
                ImageDTO image = imageService.getImage(cartItemResponse.bookId()); // image
                StockDTO stock = stockService.getStock(cartItemResponse.bookId()); // book_stock

                CartItemViewResponse cartItemViewResponse = new CartItemViewResponse(
                        book.getBookId(),
                        book.getTitle(),
                        book.getPrice(),
                        book.getSalePrice(),
                        image.getUrl(),
                        cartItemResponse.quantity(),
                        stock.getStock()
                );

                result.add(cartItemViewResponse);
            }catch(FeignException e) {
                // 도서를 불러오는 중 에러(FeignException) 발생하면 해당 도서는 불러오지 않는다.
                continue;
            }

        }

        return result;
    }

    // 장바구니에 물건 담기: redis에 cartItemResponse 저장.
    public boolean addCartItem(String cartId, CartRequest cartRequest) {
        // redis에 bookId가 존재하면 수량이 덮어씌워짐, 없으면 추가됨.
        redisTemplate.opsForHash().put(CART_PREFIX+cartId, cartRequest.bookId(), cartRequest.quantity());
        redisTemplate.expire(CART_PREFIX + cartId, 30, TimeUnit.DAYS); // 유효기간 30일

        return true;
    }

    // 장바구니에서 특정 도서 삭제
    public void removeCartItem(String cartId, Long bookId) {
        redisTemplate.opsForHash().delete(CART_PREFIX + cartId, bookId);
    }

    // 장바구니에서 도서 수량 변경
    public void modifyCartItem(String cartId, CartRequest cartRequest) {
        redisTemplate.opsForHash().put(CART_PREFIX+cartId, cartRequest.bookId(), cartRequest.quantity());
    }

    // 장바구니 비우기 (redis에 key는 남아있음)
    public void clearCart(String cartId) {
        redisTemplate.opsForHash().delete(CART_PREFIX+cartId);
    }

    // task(DB)에서 장바구니 가져와서 redis에 저장. by cartId
    public void saveCartFromDbToRedisByCartId(String cartId) {
        CartFeignResponse cartFeignResponse = cartClient.getRequestById(cartId);
        CartResponse cartResponse = CartResponse.from(cartFeignResponse);

        for(CartItemResponse ci : cartResponse.cartItemResponses()) {
            redisTemplate.opsForHash().put(CART_PREFIX + cartId, ci.bookId(), ci.quantity());
        }

    }

    // task(DB)에서 장바구니 가져와서 redis에 저장. by loginId
    public void saveCartFromDbToRedisByLoginId(String loginId,
                                               HttpServletRequest request,
                                               HttpServletResponse response) {

        Cookie[] cookies = request.getCookies();
        // CART_ID 쿠키가 있다면
        for(Cookie c : cookies) {
            if(c.getName().equals(CART_ID)) {
                return;
            }
        }

        try {
            // CART_ID 쿠키가 없으면
            CartFeignResponse cartFeignResponse = cartClient.getRequestByLoginId(loginId);
            CartResponse cartResponse = CartResponse.from(cartFeignResponse);

            // redis에 db에서 가져온 cart 저장.
            if (Objects.nonNull(cartResponse)) {
                for (CartItemResponse ci : cartResponse.cartItemResponses()) {
                    redisTemplate.opsForHash().put(CART_PREFIX + cartResponse.cartId(), ci.bookId(), ci.quantity());
                    redisTemplate.expire(CART_ID + cartFeignResponse.cartId(), 30, TimeUnit.DAYS);
                }
            }

            // CART_ID 쿠키 재발급
            HttpSession session = request.getSession(true);
            session.setAttribute("cartId", cartResponse.cartId());

            Cookie cookie = new Cookie(CART_ID, session.getId());
            cookie.setHttpOnly(true);
            cookie.setMaxAge(30 * 24 * 60 * 60); // 장바구니 쿠키 유효 기간: 30일
            cookie.setPath("/");
            response.addCookie(cookie);
        }catch(FeignException e) {
            return;
        }
    }

}

 

Task 서버의 장바구니 코드

https://github.com/Joo-v7/OneBook/tree/main/OneBook-front/src/main/java/com/onebook/frontapi

 

OneBook/OneBook-front/src/main/java/com/onebook/frontapi at main · Joo-v7/OneBook

NHN Academy 백엔드 8기 인터넷 서점 쇼핑몰 프로젝트. Contribute to Joo-v7/OneBook development by creating an account on GitHub.

github.com


4. 정리 및 배운점

(1) 문제 해결 후 달라진 점

프론트 서버를 여러 개 띄운 상태에서 테스트를 진행해도 장바구니 기능이 정상적으로 동작했다. 기존의 세션 기반 방식 대신 Redis를 활용한 데이터 캐싱을 적용함으로써 확장성이 크게 개선되었다.

(2) 아쉬운 점

  • 장바구니 영속성 구현 실패: 일정한 시간 간격으로 Redis의 장바구니 데이터를 DB에 저장하는 기능을 Spring Scheduler와 Batch를 활용하여 구현하려 했으나, 시간 부족으로 완성하지 못했다.

5. 향후 개선할 부분

  • 미완성된 기능 구현: Spring Scheduler와 Batch를 활용하여 Redis의 장바구니 데이터를 주기적으로 DB에 저장하는 기능을 추가.

 

참고