해당 기능을 개발한 이유
위와 같이 몇몇 증권사들은 각 금융상품에 대해서 청약 경쟁률을 보여주나, 대부분은 제공하고 있지 않고 있었습니다.
청약 경쟁률 조회 API를 증권사에서 제공해서 사용하면 좋겠지만,
사업자 등록증이나 별도의 승인이 필요하거나 API를 제공하지 않았습니다...ㅠㅠ
투자에 참고하기 이러한 좋은 기능을 저희 서비스에 녹여내고 싶었고,
실제 투자자분들의 의견을 수집하고, 타 서비스를 조사하면서
결과적으로 저희 서비스만의 기능으로 일일 HOT 상품 목록 조회 기능을 개발하고자 하였습니다.
일일 HOT 상품 판별 요소 선정과 비율 반영
그렇다면, 일일 HOT 상품들을 사용자에게 보여주기 위해서
어떤 요소들로 어떤 기준을 가지고 보여줘야 할지 고민이 되었습니다.
저희 서비스 테두리 안에서 직관적으로 생각이 났던 방법은
각 상품들에 대한 사용자의 일일 조회수(중복x)와 일일 좋아요 증감 수를 모두 더한 스코어를 내림차순 정렬하여
API로 넘겨주는 방법이였으나, 이렇게 단순히 더하기만하는 과정은 옳은 방식은 아닌 것 같았습니다.
그래서 소마에서 팀프로젝트를 할때 진행하는 정규멘토링시간에
개발 진행 상황 및 고민을 담당 멘토님께 말씀드리다가 멘토님으로부터 조언을 얻을 수 있었습니다.
아래와 같은 상황에서 어떤게 인기 상품에 더 가까운가?라는 물음에 고민해보면
일일 조회수 | 일일 좋아요 증감 수 | |
상품 A | 10000 | +500 |
상품 B | 5000 | +2000 |
상품 C | 10000 | -300 |
조회수와 좋아요 증감 수를 단순히 더하면 A 상품이 가장 스코어가 높지만,
A 상품은 조회수 대비 좋아요 증감 수가 B 상품에 비해 낮기 때문에
B 상품이 더 인기 상품에 가깝다고 말할 수 있기 때문입니다.
결과적으로 일일 좋아요 증감 수에 가중치를 부여해서
모두 더한 스코어를 가지고 내림차순으로 인기 상품을 선별하였습니다.
예) 일일 좋아요 증감수 : 일일 조회수 = 7 : 3
추가적으로 일일 HOT 상품으로 조회되는 데 있어서
현재 청약 중인 상품뿐만 아니라 청약 종료된 상품들도 반영하였습니다.
그 이유는 대부분의 상품들이 만기 기간이 3년 정도이고, 조기 상환이 6개월마다 존재하는
비교적 긴 주기를 가지고 있는 ELS 상품의 특징을 반영하고자 하였기 때문입니다.
어떻게 구현해야 할까
우선 HOT 상품 기능을 구현하기 전에 요소로 들어갈 일일 상품 조회수와 좋아요 관련 정보들은
빠른 읽기/쓰기와 데이터베이스의 부담을 덜기 위해 레디스를 활용하여 캐싱되도록 구현해놨었습니다.
그리고 메시지 브로커 역할을 하는 카프카를 통해 사용자가 좋아요를 누른 상품 id는 유저 DB에서 관리됩니다.
일일 HOT 상품 기능은 위 사진과 같이 전체적으로 구성해보았습니다.
우선 일일 상품별 좋아요 증감 수와 조회수는 각각
레디스의 ZSet이라고 불리는 Sorted Set 자료구조를 사용하여 점수를 기준으로 자동으로 정렬되도록 구성하였습니다.
아래와 같이 일일 상품별 조회수가 저장된 ZSet을 reverseRangeWithScores를 사용하여,
score(조회수)를 기준으로 내림차순 정렬된 형태로 가져올 수 있습니다.
@Repository
@RequiredArgsConstructor
public class ViewRedisRepository implements ViewMemoryRepository {
private final StringRedisTemplate redisTemplate;
...
/**
* 캐싱되어 있는 일일 상품에 대한 조회수를 내림차순으로 가져온다.
*/
@Override
public Set<ZSetOperations.TypedTuple<String>> getCachedViewCountDesc() {
String key = combine(RedisKeys.VIEW_COUNT_KEY, ViewTarget.Product, LocalDate.now());
return redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, -1);
}
}
또한 아래와 같이 score 명령어를 사용하여 ZSet 내 특정 요소의 점수를 가져올 수 있으므로,
HOT 상품 기능에만 특정된게 아니라 다른 기능에서도 상황에 맞게 사용할 수 있다는 확장성도 가지고 있다고 생각하여
해당 자료구조를 선택하였습니다.
Double score = redisTemplate.opsForZSet().score("zsetKey", "value");
각각의 값들을 받은 후, 비율 반영 및 순위 계산이 완료된 이후에는
스코어 TOP 5 상품에 대해서 AI 상품 위험도 분석(저위험 / 중위험 / 고위험 / 초고위험)에 대한 값을 가져와야했습니다.
그래서 마이크로서비스인 상품 서비스와 분석 서비스 간에 FeignClient 방식의 통신을 하고,
이 과정에서 분석 서비스가 비정상 상태에 있을 때, 상품 서비스로의 장애 전파를 방지하기 위해서
CircuitBreaker를 적용하였습니다.
일일 조회수 구현
인터페이스
public interface ViewMemoryRepository {
/**
* 조회수 추가
*
* @param productId 상품 ID
* @param userId 사용자 ID
*/
void view(Long productId, Long userId);
/**
* 메모리에서 사용자가 조회한적이 있는지 확인한다.
* 캐싱되어있는 경우에는 true/false로 반환하지만, 캐싱되어있지 않다면 null을
* 반환한다.
*
* @param productId 상품 ID
* @param userId 사용자 ID
* @return 사용자가 조회한적이 있는지 반환. 캐싱된 데이터가 없다면 null반환.
*/
Boolean isViewed(Long productId, Long userId);
/**
* 조회 여부를 메모리에 캐싱한다.
*
* @param productId 상품 ID
* @param userId 사용자 ID
*/
void setIsViewed(Long productId, Long userId);
/**
* 메모리에 캐싱된 조회수 확인.
*
* @param productId 상품 ID
* @return 캐싱된 조회수. 없으면 -1리턴.
*/
int getCachedViewCount(Long productId);
/**
* 조회수 생성
*
* @param productId 상품 ID
*/
void setViewCount(Long productId, Duration expiresAfter);
/**
* 조회수 1 증가
*
* @param productId 요소 ID
*/
void increaseViewCount(Long productId);
/**
* 캐싱되어 있는 상품에 대한 조회수를 내림차순으로 가져온다.
*
* @return 캐싱된 조회수를 내림차순으로 반환
*/
Set<ZSetOperations.TypedTuple<String>> getCachedViewCountDesc();
/**
* 캐싱된 특정 날짜의 모든 일일 조회수 데이터중에서 특정 유저의 것들을 모두 삭제한다.
*/
void deleteAllDailyViews(LocalDate localDate, Long userId);
/**
* 메모리에 저장된 특정 날짜의 모든 일일 조회수 데이터를 삭제한다.
*/
void deleteAllDailyViews(LocalDate localDate);
}
구현체
@Repository
@RequiredArgsConstructor
public class ViewRedisRepository implements ViewMemoryRepository {
private final StringRedisTemplate redisTemplate;
@Override
public void view(Long productId, Long userId) {
String key = combine(RedisKeys.VIEW_KEY, ViewTarget.Product, userId, LocalDate.now());
redisTemplate.opsForHash().put(key, productId.toString(), ViewState.VIEWED.toString());
key = combine(RedisKeys.VIEW_USERS_KEY, LikeTarget.Product, LocalDate.now());
redisTemplate.opsForSet().add(key, userId.toString());
setIsViewed(productId, userId);
}
@Override
public Boolean isViewed(Long productId, Long userId) {
String key = combine(RedisKeys.VIEW_PRODUCTS_KEY, ViewTarget.Product, userId, LocalDate.now());
Object value = redisTemplate.opsForHash().get(key, productId.toString());
if (value == null) {
return null;
}
return value.equals(ViewState.VIEWED.name());
}
@Override
public void setIsViewed(Long productId, Long userId) {
String key = combine(RedisKeys.VIEW_PRODUCTS_KEY, ViewTarget.Product, userId, LocalDate.now());
String value = ViewState.VIEWED.toString();
redisTemplate.opsForHash().put(key, productId.toString(), value);
}
@Override
public int getCachedViewCount(Long productId) {
String key = combine(RedisKeys.VIEW_COUNT_KEY, ViewTarget.Product, LocalDate.now());
Double value = redisTemplate.opsForZSet().score(key, String.valueOf(productId));
if (value == null) {
return -1;
}
return value.intValue();
}
@Override
public void setViewCount(Long productId, Duration expiresAfter) {
String key = combine(RedisKeys.VIEW_COUNT_KEY, ViewTarget.Product, LocalDate.now());
redisTemplate.opsForZSet().addIfAbsent(key, String.valueOf(productId), 0);
redisTemplate.expire(key, expiresAfter);
}
@Override
public void increaseViewCount(Long productId) {
String key = combine(RedisKeys.VIEW_COUNT_KEY, ViewTarget.Product, LocalDate.now());
redisTemplate.opsForZSet().incrementScore(key, String.valueOf(productId), 1);
}
@Override
public Set<ZSetOperations.TypedTuple<String>> getCachedViewCountDesc() {
String key = combine(RedisKeys.VIEW_COUNT_KEY, ViewTarget.Product, LocalDate.now());
return redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, -1);
}
@Override
public void deleteAllDailyViews(LocalDate localDate, Long userId) {
String key = combine(RedisKeys.VIEW_KEY, LikeTarget.Product, userId, localDate);
redisTemplate.delete(key);
key = combine(RedisKeys.VIEW_USERS_KEY, LikeTarget.Product, localDate);
redisTemplate.opsForSet().remove(key, userId.toString());
key = combine(RedisKeys.VIEW_PRODUCTS_KEY, ViewTarget.Product, userId, localDate);
redisTemplate.delete(key);
}
@Override
public void deleteAllDailyViews(LocalDate localDate) {
String key = combine(RedisKeys.VIEW_COUNT_KEY, ViewTarget.Product, localDate);
redisTemplate.delete(key);
key = combine(RedisKeys.VIEW_USERS_KEY, LikeTarget.Product, localDate);
Set<String> members = redisTemplate.opsForSet().members(key);
if (members != null) {
members.stream()
.map(Long::parseLong)
.forEach(memberId -> deleteAllDailyViews(localDate, memberId));
}
}
}
일일 좋아요 증감 수 구현
인터페이스
public interface LikeMemoryRepository {
...
/**
* 좋아요 증감 수 생성
*
* @param productId 상품 ID
*/
void setLikeCountDelta(Long productId, Duration expiresAfter);
/**
* 좋아요 증감 수 1 증가 반영
*
* @param productId 상품 ID
*/
void increaseLikeCountDelta(Long productId);
/**
* 좋아요 증감 수 1 감소 반영
*
* @param productId 상품 ID
*/
void decreaseLikeCountDelta(Long productId);
/**
* 캐싱되어 있는 상품에 대한 좋아요 증감수를 내림차순으로 가져온다.
*
* @return 캐싱된 좋아요 수를 내림차순으로 반환
*/
Set<ZSetOperations.TypedTuple<String>> getCachedLikeCountDeltaDesc();
/**
* 메모리에 저장된 모든 '좋아요' 데이터를 가져오고, 모두 삭제한다.
*
* @return '좋아요' entities
*/
Map<Long, List<LikeEntry>> getAllLikesAndClear();
}
구현체
@Repository
@RequiredArgsConstructor
public class LikeRedisRepository implements LikeMemoryRepository {
private final StringRedisTemplate redisTemplate;
...
@Override
public void setLikeCountDelta(Long productId, Duration expiresAfter) {
String key = combine(RedisKeys.LIKE_COUNT_DELTA_KEY, LikeTarget.Product, LocalDate.now());
redisTemplate.opsForZSet().addIfAbsent(key, String.valueOf(productId), 0);
redisTemplate.expire(key, expiresAfter);
}
@Override
public void increaseLikeCountDelta(Long productId) {
String key = combine(RedisKeys.LIKE_COUNT_DELTA_KEY, LikeTarget.Product, LocalDate.now());
redisTemplate.opsForZSet().incrementScore(key, String.valueOf(productId), 1);
}
@Override
public void decreaseLikeCountDelta(Long productId) {
String key = combine(RedisKeys.LIKE_COUNT_DELTA_KEY, LikeTarget.Product, LocalDate.now());
redisTemplate.opsForZSet().incrementScore(key, String.valueOf(productId), -1);
}
@Override
public Set<ZSetOperations.TypedTuple<String>> getCachedLikeCountDeltaDesc() {
String key = combine(RedisKeys.LIKE_COUNT_DELTA_KEY, LikeTarget.Product, LocalDate.now());
return redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, -1);
}
@Override
public List<LikeEntry> getAllLikesAndClear(Long userId) {
String key = combine(RedisKeys.LIKE_KEY, LikeTarget.Product, userId);
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
redisTemplate.delete(key);
key = combine(RedisKeys.LIKE_USERS_KEY, LikeTarget.Product);
redisTemplate.opsForSet().remove(key, userId.toString());
return entries.entrySet().stream()
.map(entry -> {
Long productId = Long.valueOf((String) entry.getKey());
LikeState state = LikeState.of((String) entry.getValue());
return new LikeEntry(productId, state);
})
.collect(Collectors.toList());
}
@Override
public Map<Long, List<LikeEntry>> getAllLikesAndClear() {
String key = combine(RedisKeys.LIKE_USERS_KEY, LikeTarget.Product);
Set<String> members = redisTemplate.opsForSet().members(key);
if (members == null) {
return new HashMap<>();
}
return members.stream()
.map(Long::valueOf)
.collect(Collectors.toMap(
userId -> userId,
userId -> getAllLikesAndClear(userId)
));
}
}
요소들 비율 반영 구현
@Service
@RequiredArgsConstructor
@Slf4j
public class DailyHotProductService {
private final ViewMemoryRepository viewMemoryRepository;
private final LikeMemoryRepository likeMemoryRepository;
private final ProductRepository productRepository;
@Value("${app.product.hot.like-ratio}")
private Double likeRatio;
@Value("${app.product.hot.view-ratio}")
private Double viewRatio;
public List<Long> getDailyTop5Products() {
Set<ZSetOperations.TypedTuple<String>> views = viewMemoryRepository.getCachedViewCountDesc();
Set<ZSetOperations.TypedTuple<String>> likes = likeMemoryRepository.getCachedLikeCountDeltaDesc();
// 가중치를 부여한 두 값을 합산하여 정렬
Map<String, Double> productScores = new HashMap<>();
for (ZSetOperations.TypedTuple<String> view : views) {
productScores.put(view.getValue(), view.getScore() * viewRatio);
}
for (ZSetOperations.TypedTuple<String> like : likes) {
// 좋아요 증감이 음수가 아닌 값들로만
if (like.getScore() != null && like.getScore() > 0) {
productScores.merge(like.getValue(), like.getScore() * likeRatio, Double::sum);
}
}
return productScores.entrySet().stream()
.filter(entry -> {
Long productId = Long.parseLong(entry.getKey());
return productRepository.isOnSaleProduct(productId);
})
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(5)
.map(entry -> parseLong(entry.getKey()))
.collect(Collectors.toList());
}
}
하루 동안 인기 있는 상위 5개 상품을 계산하는 로직을 구현한 클래스입니다.
해당 클래스에서 상품의 조회 수(view)와 좋아요(like) 데이터를 Redis를 통해 가져와
이를 기반으로 가중치를 계산하고, 최종적으로 상위 5개의 상품 ID를 반환합니다.
일일 인기 TOP5 상품 리스트 조회 서비스 로직 구현
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
...
/**
* 일일 인기 TOP5 상품 리스트 조회
*
* @return 좋아요 증감 + 조회수가 높은 상품 정보 리스트 반환
*/
public List<SummarizedProductDto> getDailyTop5Products() {
List<Long> productIdList = dailyHotProductService.getDailyTop5Products();
List<Product> productList = productRepository.listByIds(productIdList);
// List<Product>를 productIdList의 순서대로 다시 정렬
Map<Long, Product> productMap = productList.stream()
.collect(Collectors.toMap(Product::getId, product -> product));
List<Product> sortedProductList = productIdList.stream()
.map(productMap::get)
.toList();
List<Long> stepDownProductIds = productList.stream()
.filter(product -> ((product.getType() == ProductType.STEP_DOWN) &&
(!product.getSubscriptionEndDate().isBefore(LocalDate.now()))
)
) // STEP_DOWN 타입 및 청약 중인 상품 필터링
.map(Product::getId)
.toList();
List<ResponseAIResultDto> responseStepDownAIResultDtos = listStepDownAIResult(stepDownProductIds);
// AI 결과 리스트에서 productId를 key로 하는 Map으로 변환
Map<Long, BigDecimal> productSafetyScoreMap = responseStepDownAIResultDtos.stream()
.collect(Collectors.toMap(
ResponseAIResultDto::getProductId,
ResponseAIResultDto::getSafetyScore,
(existing, replacement) -> existing // 중복된 경우 기존 값 사용
));
// 정렬된 Product 리스트를 SummarizedProductDto로 변환 후 반환
return sortedProductList.stream()
.map(product -> {
BigDecimal safetyScore = productSafetyScoreMap.getOrDefault(product.getId(), null);
return new SummarizedProductDto(product, safetyScore);
})
.collect(Collectors.toList());
}
private List<ResponseAIResultDto> listStepDownAIResult(List<Long> stepDownProductIds) {
CircuitBreaker circuitBreaker = circuitBreakerFactory.create("aiResultListCircuitBreaker");
return circuitBreaker.run(() -> analysisServiceClient.getAIResultList(new RequestProductIdListDto(stepDownProductIds)),
throwable -> new ArrayList<>());
}
}
nGrinder를 사용한 일일 HOT 상품 성능 테스트
구현한 기능에 대해서 성능 테스트를 통한 검증을 진행하고 싶어서
아래와 같은 script를 작성하여 nGrinder에서 성능 테스트를 진행하였습니다.
성능 테스트는 개발 서버 환경에서 진행하였으며,
각 마이크로서비스는 AWS t3.small(2CPU, MEM 2GB) 인스턴스를 사용하였습니다.
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
@RunWith(GrinderRunner)
class DailyHotProductUsingRedis {
public static GTest test;
public static HTTPRequest request;
public static Map<String, String> headers = [:];
public static Map<String, Object> params = [:];
public static String accessToken;
public static String serverBaseUrl;
@BeforeProcess
public static void beforeProcess() {
HTTPRequestControl.setConnectionTimeout(300000);
test = new GTest(1, "test");
request = new HTTPRequest();
grinder.logger.info("before process.");
}
@BeforeThread
public void beforeThread() {
test.record(this, "test");
grinder.statistics.delayReports = true;
grinder.logger.info("before thread.");
}
@Before
public void before() {
accessToken = System.getenv("ACCESS_TOKEN");
serverBaseUrl = System.getenv("SERVER_BASE_URL");
headers = ["Authorization": "Bearer " + accessToken];
request.setHeaders(headers);
grinder.logger.info("before. init headers");
}
@Test
public void test() {
HTTPResponse response = request.GET(serverBaseUrl + "/api/product-service/v1/product/hot/daily");
if (response.statusCode == 301 || response.statusCode == 302) {
grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode);
} else {
assertThat(response.statusCode, is(200));
}
}
}
Test Configuration
- Vusers : 500
- Agent : 1
- Duration : 3 minute
- Enable Ramp-Up
- Initial Count : 2
- Incremental Step : 2
위와 같이 설정한 조건에 대해서, TPS 258 정도가 나오고 다행히 에러는 발견되지 않았습니다...!
마무리
결과적으로 위와 같이 서비스에 추가할 수 있었습니다!
개발이 좀 지난 시점에서 글을 쓰면서 다시 돌아봤을 때, 좀 더 개선할 방법이 있지 않을까? 라는 생각이 들기도합니다..
그래도 당시에 처음 MSA 환경에서 기능을 개발하면서
늘 부족했지만 어떻게 하면 더 나은 방식으로 구현하고 안정적으로 운영할 수 있을지에 대해서
계속 고민하며 성장하는 발판을 마련할 수 있었다고 생각합니다
'SpringBoot' 카테고리의 다른 글
비효율적인 상품 상세 검색 코드 수정과 nGrinder를 활용한 성능 테스트 (0) | 2024.11.12 |
---|---|
Spring Boot와 Kafka 테스트: Embedded Kafka로 상품 좋아요 메시지 검증하기 (1) | 2024.10.13 |
MSA) 서비스 별 각 인스턴스에서 애플리케이션을 Docker 컨테이너화 후, 발생한 Eureka Client 간의 통신 문제 (0) | 2024.07.09 |
MSA) Spring Cloud 기반의 MSA 구조에서 Swagger 통합하기 + FastAPI의 Swagger까지 (0) | 2024.06.17 |
MSA) Spring Cloud Eureka에 FastAPI 서버를 Client로 등록하기 (0) | 2024.06.01 |