-
Notifications
You must be signed in to change notification settings - Fork 0
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
[feat] 추첨 로직 구현(#40) #43
Changes from all commits
b5bc84a
6788bb6
6811ca0
e9955a4
1e6bf5c
377554b
341fd65
b619c00
ecbc562
686994e
dacefdc
3e2d7a7
4007e44
58a17f6
9e8bfa5
26c2ae9
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,11 @@ | ||
services: | ||
mysql: | ||
image: mysql | ||
ports: | ||
- 3306:3306 | ||
environment: | ||
MYSQL_ROOT_PASSWORD: test | ||
redis: | ||
image: redis | ||
ports: | ||
- 6379:6379 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package hyundai.softeer.orange.comment.dto; | ||
|
||
public interface WriteCommentCountDto { | ||
Long getEventUserId(); | ||
Long getCount(); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
package hyundai.softeer.orange.comment.repository; | ||
|
||
import hyundai.softeer.orange.comment.dto.WriteCommentCountDto; | ||
import hyundai.softeer.orange.comment.entity.Comment; | ||
import org.springframework.data.domain.Page; | ||
import org.springframework.data.domain.Pageable; | ||
|
@@ -26,4 +27,13 @@ public interface CommentRepository extends JpaRepository<Comment, Long> { | |
countProjection = "c.id", // 어떤 값으로 count 셀건지 지정. 지정 안하면 count(c.*)가 되어 문제 발생. | ||
nativeQuery = true) | ||
Page<Comment> findAllByEventId(@Param("eventId") String eventId, Pageable pageable); | ||
|
||
// 이름 매핑 필요. 필드 이름과 직접 매핑. | ||
@Query(value = "SELECT c.event_user_id as eventUserId, COUNT(c.event_user_id) as count " + | ||
"FROM comment c " + | ||
"JOIN event_frame ef ON c.event_frame_id = ef.id " + | ||
"JOIN event_metadata e ON ef.id = e.event_frame_id " + | ||
"WHERE e.id = :eventRawId " + | ||
"GROUP BY c.event_user_id " , nativeQuery = true) | ||
Comment on lines
+32
to
+37
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. 유저와 기대평 개수를 카운트해서 Dto로 바로 가져오는 로직이군요! |
||
List<WriteCommentCountDto> countPerEventUserByEventId(@Param("eventRawId") Long eventRawId); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,20 +8,25 @@ | |
import hyundai.softeer.orange.event.url.exception.UrlException; | ||
import hyundai.softeer.orange.eventuser.exception.EventUserException; | ||
|
||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.context.MessageSource; | ||
import org.springframework.context.i18n.LocaleContextHolder; | ||
import org.springframework.http.HttpStatus; | ||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.http.converter.HttpMessageNotReadableException; | ||
import org.springframework.web.bind.MethodArgumentNotValidException; | ||
import org.springframework.web.bind.annotation.ExceptionHandler; | ||
import org.springframework.web.bind.annotation.ResponseStatus; | ||
import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; | ||
|
||
import java.util.HashMap; | ||
import java.util.Locale; | ||
import java.util.Map; | ||
|
||
@RestControllerAdvice | ||
public class GlobalExceptionHandler { | ||
@Autowired | ||
private MessageSource messageSource; | ||
|
||
@ExceptionHandler(MethodArgumentNotValidException.class) // 요청의 유효성 검사 실패 시 | ||
@ResponseStatus(HttpStatus.BAD_REQUEST) // 400 Bad Request로 응답 반환 | ||
|
@@ -39,10 +44,15 @@ public ResponseEntity<Map<String, String>> handleInValidRequestException(MethodA | |
// TODO: messages.properties에 예외 메시지 커스터마이징할 수 있게 방법 찾아보기 | ||
@ExceptionHandler(MethodArgumentTypeMismatchException.class) | ||
@ResponseStatus(HttpStatus.BAD_REQUEST) | ||
public ResponseEntity<Map<String, String>> handleInValidRequestException(MethodArgumentTypeMismatchException e) { | ||
Map<String, String> errors = new HashMap<>(); | ||
errors.put(e.getName(), e.getLocalizedMessage()); | ||
return ResponseEntity.badRequest().body(errors); | ||
public Map<String,String> handleInValidRequestException(MethodArgumentTypeMismatchException e) { | ||
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. Spring MVC 중 View를 사용하는 경우 메세지 소스로부터 메시지 코드를 치환해주지만, API 서버로 사용할 때는 예외 메시지에 대한 메시지를 치환해주지 않습니다. 따라서, 예외에 대한 메시징 기능은 MessageSource를 받아 직접 수정해야 합니다. MethodArgumentTypeMismatchException은 query parameter / path variable에 대한 타입이 일치하지 않을 때 발생하는 예외입니다. 한국 이외의 국가에서도 현재 시스템을 이용할 수 있는 상황을 염두에 두고, Spring Message 으로 예외 메시지를 관리합니다. |
||
String code = e.getErrorCode(); | ||
String fieldName = e.getName(); | ||
Locale locale = LocaleContextHolder.getLocale(); // 현재 스레드의 로케일 정보를 가져온다. | ||
String errorMessage = messageSource.getMessage(code, null, locale); // 국제화 된 메시지를 가져온다. | ||
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. 국제화까지 신경써주셔서 감사합니다! |
||
|
||
Map<String, String> error = new HashMap<>(); | ||
error.put(fieldName, errorMessage); | ||
return error; | ||
} | ||
|
||
@ExceptionHandler({CommentException.class, AdminException.class, EventUserException.class, FcfsEventException.class, UrlException.class, InternalServerException.class}) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
package hyundai.softeer.orange.event.draw; | ||
|
||
import hyundai.softeer.orange.event.draw.component.score.actionHandler.ActionHandler; | ||
import hyundai.softeer.orange.event.draw.enums.DrawEventAction; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
|
||
import java.util.Collections; | ||
import java.util.HashMap; | ||
import java.util.Map; | ||
import java.util.regex.Matcher; | ||
import java.util.regex.Pattern; | ||
|
||
@Configuration | ||
public class DrawEventConfig { | ||
private static final String NamePattern = "(.+?)_ActionHandler"; | ||
|
||
// actionHandler을 Action 타입으로 가져올 수 있도록 설정 | ||
@Bean(name = "actionHandlerMap") | ||
public Map<DrawEventAction, ActionHandler> actionHandlerMap(Map<String, ActionHandler> handlers) { | ||
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. 현재 추첨 이벤트의 점수 계산 로직은 하드코딩 된 로직 대신 확장 가능한 액션 ( 댓글 작성 등 ) 및 점수를 포함한 정책을 기반으로 유연하게 변경할 수 있는 구조로 설계되고 있습니다. 액션은 enum 타입으로 관리되고 있는데, 각 Action Handler을 문자열 대신 enum 타입으로 접근할 수 있도록 설정을 추가했습니다. |
||
Map<DrawEventAction, ActionHandler> actionHandlerMap = new HashMap<>(); | ||
Pattern pattern = Pattern.compile(NamePattern); | ||
|
||
for(var entry: handlers.entrySet()) { | ||
Matcher matcher = pattern.matcher(entry.getKey()); | ||
if (!matcher.find()) throw new RuntimeException("no matched action"); | ||
|
||
String key = matcher.group(1); | ||
ActionHandler actionHandler = entry.getValue(); | ||
|
||
actionHandlerMap.put(DrawEventAction.valueOf(key), actionHandler); | ||
} | ||
// 런타임에 변경 못하게 제한 | ||
return Collections.unmodifiableMap(actionHandlerMap); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
package hyundai.softeer.orange.event.draw.component.picker; | ||
|
||
import org.springframework.context.annotation.Primary; | ||
import org.springframework.stereotype.Component; | ||
|
||
import java.util.*; | ||
|
||
@Primary | ||
@Component | ||
public class AccSumBasedWinnerPicker implements WinnerPicker { | ||
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. 추첨 로직은 다양할 수 있습니다. 현재는 누적 합 기반의 로직을 작성했지만, 추후 더 좋은 방법이 발견되는 경우 쉽게 전환할 수 있도록 WinnerPicker 명세와 누적합 방식의 구현 형태로 분리했습니다. |
||
|
||
@Override | ||
public List<PickTarget> pick(List<PickTarget> items, long count) { | ||
long maxPickCount = Math.min(items.size(), count); | ||
// TODO: 둘 중 하나를 선택하는 최적 조건 분석하기. | ||
if (count - maxPickCount <= 10 || // 두 값 차이가 작을 때 | ||
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. 조건에 따라 누적합 테이블을 매번 생성하거나, 한번 테이블을 만들고 추첨하는 방식 중에서 선택할 수 있게 구성했습니다. 구체적인 수치는 테스트를 통해 변경할 예정입니다. |
||
((double) count / maxPickCount) <= 1.1 ) { | ||
return pickMany(items, maxPickCount); | ||
} else { | ||
return pickManyUsingSet(items, maxPickCount); | ||
} | ||
Comment on lines
+16
to
+21
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. 사전조사에서 설계하셨던 내용을 여기에 담으신거군요~! 좋습니다 |
||
} | ||
|
||
protected List<PickTarget> pickMany(List<PickTarget> targets, long count) { | ||
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. 매번 누적합 테이블을 구축하여 인원을 뽑는 방식입니다. 추첨 인원이 적다면 큰 문제가 없지만, 추첨 인원 풀과 당첨자 수가 많아지면 (50만 명 이상 + 뽑는 사람 수 많음) 속도가 느려지는 문제가 있습니다. 그럼에도, Set 기반 방식이 가진 문제를 보완할 수 있어 두 로직을 함께 사용할 예정입니다. |
||
List<PickTarget> pickedTargets = new ArrayList<>(); | ||
// 추첨에 참여하는 객체들이 존재하는 set | ||
Set<PickTarget> targetSet = new HashSet<>(targets); | ||
|
||
for(int i = 0; i < count; i++) { | ||
RandomItem[] items = getAccumulatedItems(targetSet); | ||
long bound = items[items.length - 1].score; | ||
long targetScore = new Random().nextLong(1, bound + 1); | ||
int pickedIdx = binarySearch(items, targetScore); | ||
PickTarget pickedTarget = items[pickedIdx].target; | ||
|
||
targetSet.remove(pickedTarget); | ||
pickedTargets.add(pickedTarget); | ||
} | ||
|
||
return pickedTargets; | ||
} | ||
|
||
/** | ||
* set 자료구조를 이용하여 가중합을 1번만 계산하는 타겟 선택 방식 | ||
* @param targets 선택 대상이 되는 배열 | ||
* @param count | ||
* @return | ||
*/ | ||
protected List<PickTarget> pickManyUsingSet(List<PickTarget> targets, long count) { | ||
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. 가중 합 배열을 한번 생성하는 대신 set 자료구조를 이용하여 이미 당첨된 인원을 무시하는 로직입니다. 가중 합 배열을 단 한번만 생성하기 때문에 속도가 상당히 빠르다는 장점이 있으나, 이벤트 참여 인원수와 총 추첨 인원 수 차이가 작을 때는 중복된 인원을 탐색하는 비율이 높아지므로 실행 시간이 무한대에 가깝게 길어집니다. 위 문제를 대비하기 위해 매번 가중 합 배열을 생성하는 방식을 함께 사용합니다. |
||
// 가중합 배열 | ||
RandomItem[] items = getAccumulatedItems(targets); | ||
List<PickTarget> pickedTargets = new ArrayList<>(); | ||
long bound = items[items.length - 1].score; | ||
// 이미 선택된 대상이 존재하는 공간 | ||
Set<Integer> pickedIdxSet = new HashSet<>(); | ||
|
||
for(int i = 0; i < count; i++) { | ||
int pickedIdx; | ||
do { | ||
long targetScore = new Random().nextLong(1, bound + 1); | ||
pickedIdx = binarySearch(items, targetScore); | ||
}while(pickedIdxSet.contains(pickedIdx)); | ||
// 방문했다고 마킹 | ||
pickedIdxSet.add(pickedIdx); | ||
pickedTargets.add(targets.get(pickedIdx)); | ||
} | ||
|
||
return pickedTargets; | ||
} | ||
|
||
|
||
|
||
/** | ||
* 누적합 배열을 반환한다 | ||
* @param targets 추첨 대상들 | ||
* @return 누적합 형태로 표현된 RandomItem 배열 | ||
*/ | ||
protected RandomItem[] getAccumulatedItems(Collection<PickTarget> targets) { | ||
RandomItem[] items = new RandomItem[targets.size()]; | ||
long score = 0; | ||
|
||
int idx = 0; | ||
for (PickTarget target : targets) { | ||
score += target.score(); | ||
items[idx++] = new RandomItem(target, score); | ||
} | ||
|
||
return items; | ||
} | ||
|
||
/** | ||
* 이진 탐색을 이용하여 누적합 배열에서 대상 인덱스를 얻는다. | ||
* @param items 누적합 배열 | ||
* @param target 대상 점수 | ||
* @return 선택된 인덱스 값 | ||
*/ | ||
protected int binarySearch(RandomItem[] items, long target) { | ||
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. 가중합 배열인 RandomItem[ ] 은 만들 때 정렬된 상태를 가지므로 정렬이 필요하지 않습니다. |
||
int low = 0; | ||
int high = items.length - 1; | ||
|
||
while(low <= high) { | ||
// >>>은 비트열 오른쪽으로 이동, 빈 공간은 0으로 채움 | ||
// 2로 나누는 로직을 >>>으로 처리 | ||
int mid = (low + high) >>> 1; | ||
|
||
if(items[mid].score > target) { | ||
high = mid - 1; | ||
} else if(items[mid].score < target) { | ||
low = mid + 1; | ||
} else { | ||
break; | ||
} | ||
} | ||
|
||
return low; | ||
} | ||
|
||
/** | ||
* 가중합을 기록해두는 객체 | ||
* @param target 추첨 대상 | ||
* @param score 누적합 점수 | ||
*/ | ||
protected record RandomItem(PickTarget target, long score) {} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
package hyundai.softeer.orange.event.draw.component.picker; | ||
|
||
public record PickTarget(Long key, long score) {} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package hyundai.softeer.orange.event.draw.component.picker; | ||
|
||
import java.util.List; | ||
|
||
/** | ||
* "추첨" 작업을 진행하는 서비스. 여러 추첨 방식이 존재할 수 있으므로, 추첨 과정은 별도 인터페이스로 분리. | ||
*/ | ||
public interface WinnerPicker { | ||
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. 추첨 로직이 다양할 수 있다고 생각하여 인터페이스 - 구현으로 분리합니다. |
||
List<PickTarget> pick(List<PickTarget> items,long count); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
package hyundai.softeer.orange.event.draw.component.score; | ||
|
||
import hyundai.softeer.orange.event.draw.component.score.actionHandler.ActionHandler; | ||
import hyundai.softeer.orange.event.draw.entity.DrawEventScorePolicy; | ||
import hyundai.softeer.orange.event.draw.enums.DrawEventAction; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Component; | ||
|
||
import java.util.HashMap; | ||
import java.util.List; | ||
import java.util.Map; | ||
|
||
/** | ||
* 특정 추첨 이벤트에 대한 점수를 계산하는 클래스. 저장은 안한다. | ||
*/ | ||
@RequiredArgsConstructor | ||
@Component | ||
public class ScoreCalculator { | ||
private final Map<DrawEventAction, ActionHandler> handlerMap; | ||
|
||
public Map<Long, Long> calculate(long eventId, List<DrawEventScorePolicy> policies) { | ||
Map<Long, Long> scoreMap = new HashMap<>(); | ||
for (var policy : policies) { | ||
ActionHandler handler = handlerMap.get(policy.getAction()); | ||
handler.handle(scoreMap, eventId, policy.getScore()); | ||
} | ||
return scoreMap; | ||
} | ||
Comment on lines
+21
to
+28
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. 점수 계산을 별도로 분리해버리는 것은 좋은 전략인 것 같습니다. |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
package hyundai.softeer.orange.event.draw.component.score.actionHandler; | ||
|
||
import java.util.Map; | ||
|
||
/** | ||
* 채점 정책에 대한 동작을 처리한다. | ||
* <p>component 이름은 action 이름 + ActionHandler 형식을 띄어야 한다.</p> | ||
* <p>액션 = {@link hyundai.softeer.orange.event.draw.enums.DrawEventAction}</p> | ||
*/ | ||
public interface ActionHandler { | ||
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. 각 액션 / 정책에 대한 점수 채점을 수행하는 객체에 대한 명세입니다. |
||
void handle(Map<Long, Long> scoreMap, long eventRawId, long score); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
package hyundai.softeer.orange.event.draw.component.score.actionHandler; | ||
|
||
import hyundai.softeer.orange.event.draw.repository.EventParticipationInfoRepository; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Component; | ||
|
||
import java.util.Map; | ||
|
||
@RequiredArgsConstructor | ||
@Component("ParticipateEvent_ActionHandler") | ||
public class ParticipateEventActionHandler implements ActionHandler { | ||
private final EventParticipationInfoRepository repo; | ||
|
||
@Override | ||
public void handle(Map<Long, Long> scoreMap, long eventRawId, long score) { | ||
var participateCounts = repo.countPerEventUserByEventId(eventRawId); | ||
for (var p : participateCounts) { | ||
long beforeScore = scoreMap.getOrDefault(p.getEventUserId(),0L); | ||
scoreMap.put(p.getEventUserId(), beforeScore + p.getCount() * score); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
package hyundai.softeer.orange.event.draw.component.score.actionHandler; | ||
|
||
import hyundai.softeer.orange.comment.repository.CommentRepository; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Component; | ||
|
||
import java.util.Map; | ||
|
||
@RequiredArgsConstructor | ||
@Component("WriteComment_ActionHandler") | ||
public class WriteCommentActionHandler implements ActionHandler { | ||
private final CommentRepository repo; | ||
|
||
@Override | ||
public void handle(Map<Long, Long> scoreMap, long eventRawId, long score) { | ||
var commentCounts = repo.countPerEventUserByEventId(eventRawId); | ||
for (var c : commentCounts) { | ||
long beforeScore = scoreMap.getOrDefault(c.getEventUserId(), 0L); | ||
scoreMap.put(c.getEventUserId(), beforeScore + c.getCount() * score); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package hyundai.softeer.orange.event.draw.dto; | ||
|
||
import lombok.Getter; | ||
|
||
@Getter | ||
public class DrawEventWinningInfoBulkInsertDto { | ||
private long eventUserId; | ||
private long ranking; | ||
private long drawEventId; | ||
|
||
public static DrawEventWinningInfoBulkInsertDto of(long eventUserId, long ranking, long drawEventId) { | ||
var dto = new DrawEventWinningInfoBulkInsertDto(); | ||
dto.eventUserId = eventUserId; | ||
dto.ranking = ranking; | ||
dto.drawEventId = drawEventId; | ||
return dto; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package hyundai.softeer.orange.event.draw.dto; | ||
|
||
public interface EventParticipateCountDto { | ||
long getEventUserId(); | ||
long getCount(); | ||
} |
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.
초기 의도는 mysql AUTO_INCREMENT Primary Key를 이용하는 방식이었지만, 실제로는 다른 전략을 사용하고 있어 수정했습니다.