used-market-server Project
대용량 트래픽 환경에서 게시글 검색시 캐싱 적용하기
즐기는플머
2020. 9. 9. 04:34

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

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

- 캐싱 적용을 위한 @EnableCaching 적용
- Spring boot를 실행하는 상위 클래스에 @EnableCaching 어노테이션을 적용해야 합니다.
해당 어노테이션을 적용하면 Spring에서 Cache에 관련한 어노테이션을 스캔하여 캐싱 적용을 진행합니다.
- Spring boot를 실행하는 상위 클래스에 @EnableCaching 어노테이션을 적용해야 합니다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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로 사용합니다.
- redisTemplate을 json 형식으로 데이터를 받을 때 값이 일정하도록 직렬화 합니다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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초로 설정하였습니다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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에 등록합니다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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; | |
} | |
...생략... | |
} |
- ProductService 에선 물품 등록 시에 ProductDao에서 정의한 물품 등록 시 RedisTemplate에 Key와 value를 저장합니다.
- 물품 삭제 시에는 현재 물품이 레디스 values 중 몇 번째 인덱스인지 찾은 후 RedisTempalte에서 삭제되게 ProductDao에서 정의한 deleteByProductIdAndIndex 메서드를 호출합니다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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 메서드를 호출합니다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Service | |
@Log4j2 | |
public class ProductSearchServiceImpl implements ProductSearchService { | |
...생략... | |
public List<ProductDTO> findAllProductsByCacheId(String useId) { | |
return productDao.findAllProductsByCacheId(useId); | |
} | |
...생략... | |
} |
- ProductSearchController 에선 중고물품 검색시 캐싱되어있는 값을 가져올 수 있게 ProductSearchService에서findAllProductsByCacheId 메서드를 호출합니다.
만약 캐싱 데이터가 없다면 DB에서 직접 조회하게끔 하였습니다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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가 들고 데이터를 처리하므로 조회 성능이 향상되었습니다.
- used-market-server 중고거래 프로젝트 코드는 아래에서 확인하실 수 있습니다.
junshock5/used-market-server
중고거래 프로젝트. Contribute to junshock5/used-market-server development by creating an account on GitHub.
github.com
- [ 참고 ]
- cache 사용법: coding-start.tistory.com/271
- spring-boot에서 cache 사용: yonguri.tistory.com/82
- 캐시 사용법: jeong-pro.tistory.com/170
- Caching: http://dveamer.github.io/backend/SpringCacheable.html
- ApectJ: http://dveamer.github.io/java/SpringAsyncAspectJ.html