Skip to content
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

Merged
merged 16 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b5bc84a
[chore] 로컬 환경에서 일관된 db 사용을 위한 docker compose 파일 작성(#40)
blaxsior Aug 8, 2024
6788bb6
[feat] path variable / query parameter 타입 불일치 예외 메시징 기능 구현(#40)
blaxsior Aug 8, 2024
6811ca0
[feat] 추첨 이벤트 당첨 정보 bulk insert 기능 구현(#40)
blaxsior Aug 8, 2024
e9955a4
[feat] 추첨 이벤트 누적합 기반 추첨 로직 구현(#40)
blaxsior Aug 8, 2024
1e6bf5c
[feat] native query count가 제대로 동작하는지 확인(#40)
blaxsior Aug 8, 2024
377554b
[feat] 이벤트 참여 정보에 대한 native query count가 제대로 동작하는지 확인(#40)
blaxsior Aug 9, 2024
341fd65
[feat] 정책에 대한 점수를 채점하는 역할의 ActionHandler 인터페이스 정의(#40)
blaxsior Aug 9, 2024
b619c00
[fix, chore] 테스트에서 drawevent에 대한 외래키가 지정되지 않아 발생하던 문제 해결 / sql문을 별도의 …
blaxsior Aug 9, 2024
ecbc562
[test] Action Handler 구현체에 대한 테스트 수행(#40)
blaxsior Aug 9, 2024
686994e
[feat] 정책에 따른 점수 채점 객체 구현(#40)
blaxsior Aug 9, 2024
dacefdc
[chore] 이벤트 참여 repository 누락된 코드 추가(#40)
blaxsior Aug 9, 2024
3e2d7a7
[fix] Admin 엔티티의 primary key 전략이 auto increment가 아니던 부분 수정(#40)
blaxsior Aug 9, 2024
4007e44
[chore] 코드 레벨에서 ActionHandler을 DrawEventAction enum 타입 기반으로 접근할 수 있도록…
blaxsior Aug 9, 2024
58a17f6
[feat] 추첨 이벤트 점수 채점 + 추첨 서비스 구현(#40)
blaxsior Aug 9, 2024
9e8bfa5
[test] DrawEventService 테스트 및 수정(#40)
blaxsior Aug 9, 2024
26c2ae9
[chore] 이벤트 유저 점수 업로드를 위한 코드 작성(#40)
blaxsior Aug 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions infra/dev/db/docker-compose.yml
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
Expand Up @@ -14,7 +14,7 @@
@NoArgsConstructor
public class Admin {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@GeneratedValue(strategy = GenerationType.IDENTITY)
Copy link
Collaborator Author

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를 이용하는 방식이었지만, 실제로는 다른 전략을 사용하고 있어 수정했습니다.

private Long id;

/**
Expand Down
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;
Expand All @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

유저와 기대평 개수를 카운트해서 Dto로 바로 가져오는 로직이군요!

List<WriteCommentCountDto> countPerEventUserByEventId(@Param("eventRawId") Long eventRawId);
}
1 change: 1 addition & 0 deletions src/main/java/hyundai/softeer/orange/common/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public enum ErrorCode {
USER_NOT_FOUND(HttpStatus.NOT_FOUND, false, "사용자를 찾을 수 없습니다."),
SHORT_URL_NOT_FOUND(HttpStatus.NOT_FOUND, false, "단축 URL을 찾을 수 없습니다."),
FCFS_EVENT_NOT_FOUND(HttpStatus.NOT_FOUND, false, "선착순 이벤트를 찾을 수 없습니다."),
DRAW_EVENT_NOT_FOUND(HttpStatus.NOT_FOUND, false, "추첨 이벤트를 찾을 수 없습니다."),
EVENT_FRAME_NOT_FOUND(HttpStatus.NOT_FOUND, false, "이벤트 프레임을 찾을 수 없습니다."),
EVENT_USER_NOT_FOUND(HttpStatus.NOT_FOUND, false, "이벤트 사용자를 찾을 수 없습니다."),
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, false, "기대평을 찾을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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로 응답 반환
Expand All @@ -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) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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); // 국제화 된 메시지를 가져온다.
Copy link
Collaborator

Choose a reason for hiding this comment

The 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})
Expand Down
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) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 || // 두 값 차이가 작을 때
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사전조사에서 설계하셨던 내용을 여기에 담으신거군요~! 좋습니다

}

protected List<PickTarget> pickMany(List<PickTarget> targets, long count) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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();
}
Loading
Loading