-
Notifications
You must be signed in to change notification settings - Fork 36
[volume-9] Product Ranking with Redis #228
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
6ead99e
8e06929
5569bf6
ee02ce7
e1d3f05
b399fde
a143f91
0096dae
7f05b54
ca1fed1
d5280ea
05b7b2a
227d932
003b543
00498cd
1ad67da
4a13366
bc962b2
0605dc4
f1d94ed
a51021c
a946ac0
33c0ab8
b73c2c6
8d5e90a
d6711cf
7a56684
ded9e38
5b83c03
3158c2b
e287600
9c5e6ea
3774002
8e5643a
ef3eebd
d738654
b06e034
6c48755
d9ae7ad
5bb8d33
d505115
624b780
a97d77b
55f8c8b
592a4e5
4faa67e
86a8205
d06a0d0
5042c22
4fda674
25b423e
04ff345
7e0ac82
1087ea2
3e2e0f4
5db5c36
aa374c3
c74e6ad
c80ed47
c5754ff
deda1e2
617746d
be18c88
0074ea9
b84525a
52b62bd
34df8d5
4ca321e
339132b
d321b49
caec6fa
72517a8
1cfeaa3
e102c1d
cc0c139
a75c7ef
b397ce4
ccc015e
dcab5ea
d2586f9
82284a4
dc745c9
da195a3
6c87e71
4071e77
acc30d7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| import com.loopers.domain.product.Product; | ||
|
|
||
| import java.math.BigDecimal; | ||
|
|
||
| public record ProductRankingInfo(Long id, Long brandId, String name, BigDecimal price, int stock, int likeCount, int rank, double score) { | ||
| public static ProductRankingInfo from(Product product, RankingInfo ranking) { | ||
| return new ProductRankingInfo( | ||
| product.getId(), | ||
| product.getBrandId(), | ||
| product.getName(), | ||
| product.getPrice(), | ||
| product.getStock(), | ||
| product.getLikeCount(), | ||
| ranking.rank(), | ||
| ranking.score() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| public record RankingInfo( | ||
| String date, | ||
| double score, | ||
| int rank, | ||
| Long total | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,40 @@ | ||||||
| package com.loopers.application.product; | ||||||
|
|
||||||
| import lombok.RequiredArgsConstructor; | ||||||
| import org.springframework.data.redis.core.RedisCallback; | ||||||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||||||
| import org.springframework.data.redis.serializer.RedisSerializer; | ||||||
| import org.springframework.stereotype.Component; | ||||||
|
|
||||||
| import java.util.List; | ||||||
|
|
||||||
| @Component | ||||||
| @RequiredArgsConstructor | ||||||
| public class RankingRedisReader { | ||||||
| private final StringRedisTemplate redisTemplate; | ||||||
|
|
||||||
| public RankingInfo getDailyRanking(String date, Long productId) { | ||||||
| String key = "ranking:all:" + date; | ||||||
| String member = String.valueOf(productId); | ||||||
|
|
||||||
| RedisSerializer<String> serializer = redisTemplate.getStringSerializer(); | ||||||
| byte[] keyBytes = serializer.serialize(key); | ||||||
| byte[] memberBytes = serializer.serialize(member); | ||||||
|
|
||||||
| @SuppressWarnings("unchecked") | ||||||
| List<Object> results = (List<Object>) redisTemplate.executePipelined((RedisCallback<Object>) connection -> { | ||||||
| connection.zScore(keyBytes, memberBytes); // -> Double (or null) | ||||||
| connection.zRevRank(keyBytes, memberBytes); // -> Long (or null) 0-base | ||||||
| connection.zCard(keyBytes); // -> Long | ||||||
| return null; | ||||||
| }); | ||||||
|
|
||||||
| Double score = (Double) results.get(0); | ||||||
| Long revRank0 = (Long) results.get(1); | ||||||
| Long total = (Long) results.get(2); | ||||||
|
|
||||||
| Integer rank = (revRank0 == null) ? null : Math.toIntExact(revRank0 + 1); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
π μμ ν λ³ν μ μ-Integer rank = (revRank0 == null) ? null : Math.toIntExact(revRank0 + 1);
+Integer rank = (revRank0 == null) ? null : (int) Math.min(revRank0 + 1, Integer.MAX_VALUE);π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||
|
|
||||||
| return new RankingInfo(date, score, rank, total); | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package com.loopers.application; | ||
|
|
||
| import com.loopers.interfaces.api.ranking.RankingV1Dto; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.data.redis.core.ZSetOperations; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.ZoneId; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class RankingFacade { | ||
| private final StringRedisTemplate redisTemplate; | ||
| private static final ZoneId KST = ZoneId.of("Asia/Seoul"); | ||
| private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.BASIC_ISO_DATE; | ||
|
|
||
| public RankingV1Dto.ProductRankingPageResponse getDailyProductRanking(int page, int size) { | ||
| if (page < 1) page = 1; | ||
| if (size < 1) size = 20; | ||
|
|
||
| String date = LocalDate.now(KST).format(YYYYMMDD); | ||
|
|
||
| String key = "ranking:all:" + date; | ||
| ZSetOperations<String, String> zset = redisTemplate.opsForZSet(); | ||
|
|
||
| Long total = zset.size(key); | ||
| long totalElements = (total == null) ? 0 : total; | ||
|
|
||
| if (totalElements == 0) { | ||
| return new RankingV1Dto.ProductRankingPageResponse(date, page, size, 0, 0, List.of()); | ||
| } | ||
|
|
||
| long start = (long) (page - 1) * size; | ||
| long end = start + size - 1; | ||
|
|
||
| if (start >= totalElements) { | ||
| int totalPages = (int) Math.ceil((double) totalElements / size); | ||
| return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, List.of()); | ||
| } | ||
|
|
||
| Set<ZSetOperations.TypedTuple<String>> tuples = | ||
| zset.reverseRangeWithScores(key, start, end); | ||
|
|
||
| List<RankingV1Dto.ProductRankingResponse> items = new ArrayList<>(); | ||
| if (tuples != null) { | ||
| long rank = start + 1; | ||
| for (var t : tuples) { | ||
| String member = t.getValue(); | ||
| Double score = t.getScore(); | ||
| if (member == null || score == null) continue; | ||
|
|
||
| items.add(new RankingV1Dto.ProductRankingResponse( | ||
| rank++, | ||
| Long.parseLong(member), | ||
| score | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| int totalPages = (int) Math.ceil((double) totalElements / size); | ||
| return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, items); | ||
| } | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| package com.loopers.interfaces.api; | ||
|
|
||
| import com.fasterxml.jackson.databind.JsonMappingException; | ||
| import com.fasterxml.jackson.databind.exc.InvalidFormatException; | ||
| import com.fasterxml.jackson.databind.exc.MismatchedInputException; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.http.converter.HttpMessageNotReadableException; | ||
| import org.springframework.web.bind.MissingServletRequestParameterException; | ||
| import org.springframework.web.bind.annotation.ExceptionHandler; | ||
| import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
| import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; | ||
| import org.springframework.web.server.ServerWebInputException; | ||
| import org.springframework.web.servlet.resource.NoResourceFoundException; | ||
|
|
||
| import java.util.Arrays; | ||
| import java.util.regex.Matcher; | ||
| import java.util.regex.Pattern; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @RestControllerAdvice | ||
| @Slf4j | ||
| public class ApiControllerAdvice { | ||
| @ExceptionHandler | ||
| public ResponseEntity<ApiResponse<?>> handle(CoreException e) { | ||
| log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); | ||
| return failureResponse(e.getErrorType(), e.getCustomMessage()); | ||
| } | ||
|
|
||
| @ExceptionHandler | ||
| public ResponseEntity<ApiResponse<?>> handleBadRequest(MethodArgumentTypeMismatchException e) { | ||
| String name = e.getName(); | ||
| String type = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "unknown"; | ||
| String value = e.getValue() != null ? e.getValue().toString() : "null"; | ||
| String message = String.format("μμ² νλΌλ―Έν° '%s' (νμ : %s)μ κ° '%s'μ΄(κ°) μλͺ»λμμ΅λλ€.", name, type, value); | ||
| return failureResponse(ErrorType.BAD_REQUEST, message); | ||
| } | ||
|
|
||
| @ExceptionHandler | ||
| public ResponseEntity<ApiResponse<?>> handleBadRequest(MissingServletRequestParameterException e) { | ||
| String name = e.getParameterName(); | ||
| String type = e.getParameterType(); | ||
| String message = String.format("νμ μμ² νλΌλ―Έν° '%s' (νμ : %s)κ° λλ½λμμ΅λλ€.", name, type); | ||
| return failureResponse(ErrorType.BAD_REQUEST, message); | ||
| } | ||
|
|
||
| @ExceptionHandler | ||
| public ResponseEntity<ApiResponse<?>> handleBadRequest(HttpMessageNotReadableException e) { | ||
| String errorMessage; | ||
| Throwable rootCause = e.getRootCause(); | ||
|
|
||
| if (rootCause instanceof InvalidFormatException invalidFormat) { | ||
| String fieldName = invalidFormat.getPath().stream() | ||
| .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") | ||
| .collect(Collectors.joining(".")); | ||
|
|
||
| String valueIndicationMessage = ""; | ||
| if (invalidFormat.getTargetType().isEnum()) { | ||
| Class<?> enumClass = invalidFormat.getTargetType(); | ||
| String enumValues = Arrays.stream(enumClass.getEnumConstants()) | ||
| .map(Object::toString) | ||
| .collect(Collectors.joining(", ")); | ||
| valueIndicationMessage = "μ¬μ© κ°λ₯ν κ° : [" + enumValues + "]"; | ||
| } | ||
|
|
||
| String expectedType = invalidFormat.getTargetType().getSimpleName(); | ||
| Object value = invalidFormat.getValue(); | ||
|
|
||
| errorMessage = String.format("νλ '%s'μ κ° '%s'μ΄(κ°) μμ νμ (%s)κ³Ό μΌμΉνμ§ μμ΅λλ€. %s", | ||
| fieldName, value, expectedType, valueIndicationMessage); | ||
|
|
||
| } else if (rootCause instanceof MismatchedInputException mismatchedInput) { | ||
| String fieldPath = mismatchedInput.getPath().stream() | ||
| .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") | ||
| .collect(Collectors.joining(".")); | ||
| errorMessage = String.format("νμ νλ '%s'μ΄(κ°) λλ½λμμ΅λλ€.", fieldPath); | ||
|
|
||
| } else if (rootCause instanceof JsonMappingException jsonMapping) { | ||
| String fieldPath = jsonMapping.getPath().stream() | ||
| .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") | ||
| .collect(Collectors.joining(".")); | ||
| errorMessage = String.format("νλ '%s'μμ JSON λ§€ν μ€λ₯κ° λ°μνμ΅λλ€: %s", | ||
| fieldPath, jsonMapping.getOriginalMessage()); | ||
|
|
||
| } else { | ||
| errorMessage = "μμ² λ³Έλ¬Έμ μ²λ¦¬νλ μ€ μ€λ₯κ° λ°μνμ΅λλ€. JSON λ©μΈμ§ κ·κ²©μ νμΈν΄μ£ΌμΈμ."; | ||
| } | ||
|
|
||
| return failureResponse(ErrorType.BAD_REQUEST, errorMessage); | ||
| } | ||
|
|
||
| @ExceptionHandler | ||
| public ResponseEntity<ApiResponse<?>> handleBadRequest(ServerWebInputException e) { | ||
| String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : ""); | ||
| if (!missingParams.isEmpty()) { | ||
| String message = String.format("νμ μμ² κ° '%s'κ° λλ½λμμ΅λλ€.", missingParams); | ||
| return failureResponse(ErrorType.BAD_REQUEST, message); | ||
| } else { | ||
| return failureResponse(ErrorType.BAD_REQUEST, null); | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π§© Analysis chainπ Script executed: #!/bin/bash
echo "=== Build Configuration Files ==="
fd -e gradle -e xml -e properties | head -20
echo ""
echo "=== Spring Dependencies in build.gradle ==="
fd build.gradle | xargs cat 2>/dev/null
echo ""
echo "=== Checking pom.xml ==="
fd pom.xml | xargs cat 2>/dev/null | head -100Repository: Loopers-dev-lab/loopers-spring-java-template Length of output: 9932 π Script executed: #!/bin/bash
echo "=== Checking ApiControllerAdvice.java imports and class definition ==="
cat apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java | head -20
echo ""
echo "=== Checking if ServerWebInputException is used ==="
rg "ServerWebInputException" apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.javaRepository: Loopers-dev-lab/loopers-spring-java-template Length of output: 1387
μ΄ νλ‘μ νΈλ Servlet MVCμμ μμ² λ§€κ°λ³μ μ€λ₯λ₯Ό μ²λ¦¬νλ €λ©΄ π€ Prompt for AI Agents |
||
|
|
||
| @ExceptionHandler | ||
| public ResponseEntity<ApiResponse<?>> handleNotFound(NoResourceFoundException e) { | ||
| return failureResponse(ErrorType.NOT_FOUND, null); | ||
| } | ||
|
|
||
| @ExceptionHandler | ||
| public ResponseEntity<ApiResponse<?>> handle(Throwable e) { | ||
| log.error("Exception : {}", e.getMessage(), e); | ||
| return failureResponse(ErrorType.INTERNAL_ERROR, null); | ||
| } | ||
|
|
||
| private String extractMissingParameter(String message) { | ||
| Pattern pattern = Pattern.compile("'(.+?)'"); | ||
| Matcher matcher = pattern.matcher(message); | ||
| return matcher.find() ? matcher.group(1) : ""; | ||
| } | ||
|
|
||
| private ResponseEntity<ApiResponse<?>> failureResponse(ErrorType errorType, String errorMessage) { | ||
| return ResponseEntity.status(errorType.getStatus()) | ||
| .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
μμΈλ₯Ό 무μνλ
catch (Exception ignored)ν¨ν΄ κ°μ νμλͺ¨λ μμΈλ₯Ό μ‘°μ©ν μΌν€λ©΄ Redis μ°κ²° μ€ν¨, μ§λ ¬ν μ€λ₯ λ± μ€μν λ¬Έμ λ₯Ό λλ²κΉ νκΈ° μ΄λ ΅μ΅λλ€. μ΅μν λ‘κΉ μ μΆκ°νμΈμ.
π λ‘κΉ μΆκ° μμ
try { String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); ranking = rankingRedisReader.getDailyRanking(date, product.getId()); -} catch (Exception ignored) {} +} catch (Exception e) { + log.warn("Failed to fetch ranking for product {}: {}", product.getId(), e.getMessage()); +}π€ Prompt for AI Agents