used-market-server Project

대용량 트래픽 환경에서 게시글 검색시 캐싱 적용하기

즐기는플머 2020. 9. 9. 04:34


  • [ 배경 ]
    • 중고거래 사이트 프로젝트를 진행하면서 멀티 서버 환경에서 대용량 트래픽을 처리하기 위해
      캐싱에 대해 고민해보려고 합니다.

  • [ 과정 ]
    • 캐시는 언제 써야 할까?
      • 반복적으로 동일한 결과를 리턴해야 하는 작업
      • 서버 자원을 많이 사용하는 작업 또는 시간이 오래 걸리는 작업 (API 호출, 데이터베이스 조회 쿼리,...)
      • 자주 조회되는 데이터
      • 입력값과 출력 값이 일정한 데이터
        • 캐싱된 데이터는 데이터 갱신으로 인해 DB와 불일치가 발생할 수 있습니다.
          Update가 잦거나 데이터 불일치 시 비즈니스 로직 상 문제가 발생할 수 있는 경우에는 캐싱 대상으로 부적합합니다.
        • Used-Market-Server에서 캐싱 대상은 '게시글 리스트와 게시글'입니다.
          자주 조회되는 데이터로 DB서버에 부하를 줄일 수 있습니다.
          중고거래 서비스 특징상 최신 게시글이 5초 정도 늦게 올라온다고
          서비스에 큰 무리가 가지 않는다고 판단하여서 중고물품 캐싱 만료시간을 5초로 설정하였습니다.
          (만약 실시간성 중요 데이터를 업데이트할 경우 별도로 처리할 예정입니다.)

  • Scale-Up vs Scale-Out 환경별 캐싱 전략 특징
    • Scale-Up 환경에선 Local Cache 적용
      • Scale-Up 환경에선 서버가 1개이어서 Local Cache로 데이터 처리가 가능합니다.
      • 캐시 된 데이터가 서버 사이에 일관되지 않아도 됩니다.
        즉, 서버 간 캐싱 데이터를 참조할 필요가 없습니다.
    • Scale-Out 환경에선 Global Cache 적용
      • Scale-Out 환경에선 여러 서버에서 캐시 서버를 참조해야 합니다.
      • 서버 간 데이터를 공유해야 하는 환경입니다.
        즉, 캐싱된 데이터가 일관되려면 서버 간 캐싱 데이터를 참조해야 합니다.
  • Used-Market-Server는 Scale-Out 환경이어서 Global Cache 전략을 선택하였습니다.
  • 세션 저장을 위한 Redis 서버를 사용하고 있어서 글로벌 캐싱도 Redis를 사용하여 자원을 효율적으로 사용할 수 있다고 생각하였습니다.

 

Global Cache 적용 구상도

  • 캐싱 적용을 위한 @EnableCaching 적용
    • Spring boot를 실행하는 상위 클래스에 @EnableCaching 어노테이션을 적용해야 합니다.
      해당 어노테이션을 적용하면 Spring에서 Cache에 관련한 어노테이션을 스캔하여 캐싱 적용을 진행합니다.
@EnableCaching // Spring에서 Caching을 사용하겠다고 선언한다.
public class UsedMarketServerApplication {
public static void main(String[] args) {
SpringApplication.run(UsedMarketServerApplication.class, args);
}
}
  • SpringCacheManager에 RedisCacheManager 주입
    • redisTemplate을 json 형식으로 데이터를 받을 때 값이 일정하도록 직렬화 합니다.
      저장할 클래스가 여러 개일 경우 범용적으로 저장할 수 있게 GenericJackson2JsonRedisSerializer를 이용합니다.
    • RedisCacheManager는 SpringFramework의 CacheManager를 구현합니다.
      Spring에서는 RedisCacheManager를 Bean으로 등록하면 기본 CacheManager를 RedisCacheManager로 사용합니다.
@Bean
public RedisTemplate<String, Object> redisTemplate(ObjectMapper objectMapper) {
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
// json 형식으로 데이터를 받을 때
// 값이 깨지지 않도록 직렬화한다.
// 저장할 클래스가 여러개일 경우 범용 JacksonSerializer인 GenericJackson2JsonRedisSerializer를 이용한다
// 참고 https://somoly.tistory.com/134
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(serializer);
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(serializer);
redisTemplate.setEnableTransactionSupport(true); // transaction 허용
return redisTemplate;
}
/**
* Redis Cache를 사용하기 위한 cache manager 등록.
* 커스텀 설정을 적용하기 위해 RedisCacheConfiguration을 먼저 생성한다.
* 이후 RadisCacheManager를 생성할 때 cacheDefaults의 인자로 configuration을 주면 해당 설정이 적용된다.
* RedisCacheConfiguration 설정
* disableCachingNullValues - null값이 캐싱될 수 없도록 설정한다. null값 캐싱이 시도될 경우 에러를 발생시킨다.
* entryTtl - 캐시의 TTL(Time To Live)를 설정한다. Duraction class로 설정할 수 있다.
* serializeKeysWith - 캐시 Key를 직렬화-역직렬화 하는데 사용하는 Pair를 지정한다.
* serializeValuesWith - 캐시 Value를 직렬화-역직렬화 하는데 사용하는 Pair를 지정한다.
* Value는 다양한 자료구조가 올 수 있기 때문에 GenericJackson2JsonRedisSerializer를 사용한다.
*
* @author junshock5
* @param redisConnectionFactory Redis와의 연결을 담당한다.
* @return
*/
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory,
ObjectMapper objectMapper) {
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues()
.entryTtl(Duration.ofSeconds(defaultExpireSecond))
.serializeKeysWith(RedisSerializationContext
.SerializationPair
.fromSerializer(new StringRedisSerializer())).serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)));
return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(configuration).build();
}
  • Redis를 캐시 저장소로 사용하기 위한 설정입니다. 중고 물품 만료시간을 5초로 설정하였습니다.
# redis
spring.cache.type=redis
spring.data.redis.repositories.enabled=true
expire.products=5000
market.server.redis.port=6379
  • ProductDao 클래스에선 RedisTemplate을 이용하여 자주 사용되는 중고물품을 @PostConstruct 가 있는 메서드에서 bean 등록 시에 DEFAULT_PRODUCT_SEARCH_CACHE_KEY 에 최대 2000개 저장합니다.
    • 물품 삭제시 물품 Key값을 이용해  RedisTemplate에 등록되어있는 value의 index를 찾아 삭제합니다.
    • 물품 조회시 DEFAULT_PRODUCT_SEARCH_CACHE_KEY에 있는 values를 조회합니다.
    • 물품 등록 시 Product의 insert 시의 auto-Increment id를 가지고  RedisTemplate의 value에 등록합니다.
@Repository
public class ProductDao {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductSearchMapper productSearchMapper;
@Autowired
private ObjectMapper objectMapper;
@Value("${expire.products}")
private long productsExpireSecond;
/**
* 최상단 중고 물품들을 캐싱된 레디스 에서 조회 한다.
*
* @param productId 물품 아이디
* @return
* @author topojs8
*/
public List<ProductDTO> findAllProductsByCacheId(String productId) {
List<ProductDTO> items = redisTemplate.opsForList()
.range(RedisKeyFactory.generateProductKey(productId), 0, -1)
.stream()
.map(item -> objectMapper.convertValue(item, ProductDTO.class))
.collect(Collectors.toList());
return items;
}
/**
* redis list에 해당 물품을 추가한다.
* RedisKeyFactory로 물품 아이디, 내부 키를 이용해 키를 생산한 후 물품을 저장시킨다.
*
* @param productDTO 레디스 리스트에 추가할 중고 물품
* @param productId 물품 아이디
* @return
* @author topojs8
*/
public void addProduct(ProductDTO productDTO, String productId) {
final String key = RedisKeyFactory.generateProductKey(productId);
redisTemplate.watch(key); // 해당 키를 감시한다. 변경되면 로직 취소.
try {
if (redisTemplate.opsForList().size(key) > 2000) {
throw new IndexOutOfBoundsException("최상단 중고 물품 2O00개 이상 담을 수 없습니다.");
}
redisTemplate.multi();
redisTemplate.opsForList().rightPush(key, productDTO);
redisTemplate.expire(key, productsExpireSecond, TimeUnit.SECONDS);
redisTemplate.exec();
} catch (Exception e) {
redisTemplate.discard(); // 트랜잭션 종료시 unwatch()가 호출된다
throw e;
}
}
/**
* 물품 레디스 리스트 에서 해당 인덱스에 해당하는 물품을 삭제한다.
*
* @param productId 물품 아이디
* @param index 삭제할 메뉴 인덱스
* @return 삭제에 성공할 시 true
* @author topojs8
*/
public boolean deleteByProductIdAndIndex(String productId, long index) {
/*
* opsForList().remove(key, count, value)
* key : list를 조회할 수 있는 key
* count > 0이면 head부터 순차적으로 조회하며 count의 절대값에 해당하는 개수만큼 제거
* count < 0이면 tail부터 순차적으로 조회하며 count의 절대값에 해당하는 개수만큼 제거
* count = 0이면 모두 조회한 후 value에 해당하는 값 모두 제거
* value : 주어진 값과 같은 value를 가지는 대상이 삭제 대상이 된다
* return값으로는 삭제한 인자의 개수를 리턴한다.
*
* 해당 리스트에서 인덱스에 해당하는 값을 조회한 후, remove의 value값 인자로 넘겨준다.
* 그 후 count에 1 값을 주면 head부터 순차적으로 조회하며 index에 해당하는 값을 제거할것이다.
* return값이 1이면 1개를 삭제한 것이니 성공, 1이 아니라면 잘 삭제된것이 아니니 실패이다.
*/
Long remove = redisTemplate.opsForList().remove(RedisKeyFactory.generateProductKey(productId), 1,
redisTemplate.opsForList().index(RedisKeyFactory.generateProductKey(productId), index));
return remove == 1;
}
...생략...
}
view raw ProductDao.java hosted with ❤ by GitHub
  • ProductService 에선 물품 등록 시에 ProductDao에서 정의한 물품 등록 시 RedisTemplate에 Key와 value를 저장합니다.
  • 물품 삭제 시에는 현재 물품이 레디스 values 중 몇 번째 인덱스인지 찾은 후 RedisTempalte에서 삭제되게 ProductDao에서 정의한 deleteByProductIdAndIndex 메서드를 호출합니다.
@Service
@Log4j2
public class ProductServiceImpl implements ProductService {
@Autowired
private ProductMapper productMapper;
@Autowired
private UserProfileMapper userProfileMapper;
@Autowired
private ProductDao productDao;
@Override
public void register(String id, ProductDTO productDTO) {
...생략...
if (memberInfo != null) {
productMapper.register(productDTO);
productDTO.setId(productMapper.getLastProductId()); // insert 시 auto-Increment 입력 하므로 redis key 에 정확한 값을 넣기 위해 설정
productDao.addProduct(productDTO, ProductDTO.DEFAULT_PRODUCT_SEARCH_CACHE_KEY);
} else {
log.error("register ERROR! {}", productDTO);
throw new RuntimeException("register ERROR! 상품 등록 메서드를 확인해주세요\n" + "Params : " + productDTO);
}
}
@Override
public void deleteProduct(int accountId, int productId) {
if (accountId != 0 && productId != 0) {
productMapper.deleteProduct(accountId, productId);
productId = productDao.selectProductsIndex(productId);
if (productDao.deleteByProductIdAndIndex(ProductDTO.DEFAULT_PRODUCT_SEARCH_CACHE_KEY, productId) == false) {
throw new RuntimeException("물품 레디스 리스트에서 삭제 실패!");
}
} else {
log.error("deleteProudct ERROR! {}", productId);
throw new RuntimeException("updateProducts ERROR! 물품 삭제 메서드를 확인해주세요\n" + "Params : " + productId);
}
}
  • ProductSearchService 에선 검색 시 캐시 key값을 이용해 게시글을 검색하게끔 ProductDao에서 정의한 findAllProductsByCacheId 메서드를 호출합니다.
@Service
@Log4j2
public class ProductSearchServiceImpl implements ProductSearchService {
...생략...
public List<ProductDTO> findAllProductsByCacheId(String useId) {
return productDao.findAllProductsByCacheId(useId);
}
...생략...
}
  • ProductSearchController 에선 중고물품 검색시 캐싱되어있는 값을 가져올 수 있게 ProductSearchService에서findAllProductsByCacheId 메서드를 호출합니다.
    만약 캐싱 데이터가 없다면 DB에서 직접 조회하게끔 하였습니다.
@RestController
@RequestMapping("/search")
@Log4j2
@RequiredArgsConstructor
public class ProductSearchController {
private final ProductSearchServiceImpl productSearchService;
/**
* 중고물품 검색 메서드.
* 초기 bean 등록시 2000개의 최신 중고물품 캐싱해온 데이터를 검색
* @params date 물품 생성 날짜,
* price 물품 가격,
* accountId 계정 번호,
* title 물품 제목,
* status 상품 상태,
* categoryId 물품 카테고리 번호
* ORDER BY 카테고리별
* 판매순
* 최신순
* 낮은가격순
* 높은 가격순
* 평점순
* @return ProductSearchResponse
*
* @author topojs8
*/
@GetMapping
public ProductSearchResponse search(ProductDTO productDTO,CategoryDTO categoryDTO) {
String accountId = ProductDTO.DEFAULT_PRODUCT_SEARCH_CACHE_KEY;
List<ProductDTO> productDTOList = productSearchService.findAllProductsByCacheId(accountId);
if(productDTOList.size() == 0)
productDTOList = productSearchService.getProducts(productDTO,categoryDTO);
return new ProductSearchResponse(productDTOList);
}
// -------------- response 객체 --------------
@Getter
@AllArgsConstructor
private static class ProductSearchResponse {
private List<ProductDTO> productDTO;
}
}
  • Redis Cli 키 결과

  • Swagger API 화면


  • [ 결과 ]
    • 자주 조회되는 중고물품을 만료시간 5초를 기준으로 캐싱하여 조회 시 DB서버에 부하를 줄였습니다.
    • 캐싱된 데이터가 업데이트되었을 시 캐시 데이터를 삭제해주지 않으면 데이터의 정합성이 없어지기 때문에 게시글 중 새로운 글을 등록, 수정할 때 캐시 정보를 갱신했습니다.

  • [ 성과 ]
    • 자주 조회되며 서버 자원을 많이 사용하는 데이터인 중고 물품 게시글들을 WAS가 들고 데이터를 처리하므로 조회 성능이 향상되었습니다.

 

junshock5/used-market-server

중고거래 프로젝트. Contribute to junshock5/used-market-server development by creating an account on GitHub.

github.com