Skip to content

Commit 149e690

Browse files
Merge pull request #330 from prgrms-web-devcourse-final-project/refactor/timevote
refactor : 최적 시간 투표 API를 비트마스크 포맷으로 리팩터링
2 parents 82283e5 + 0a6ffba commit 149e690

File tree

11 files changed

+189
-43
lines changed

11 files changed

+189
-43
lines changed

src/main/java/grep/neogulcoder/domain/timevote/dto/request/TimeVoteCreateRequest.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,30 @@
77
import lombok.Builder;
88
import lombok.Getter;
99

10+
import static grep.neogulcoder.domain.timevote.provider.TimeSlotBitmaskConverter.expand;
11+
1012
@Getter
1113
@Schema(description = "스터디 모임 일정 조율 - 가능 시간 제출 요청 DTO")
1214
public class TimeVoteCreateRequest {
1315

14-
@NotEmpty(message = "시간대를 1개 이상 선택해주세요.")
16+
@NotEmpty(message = "시간대는 최소 한 개 이상의 일자 정보를 포함해야 합니다.")
1517
@Schema(
16-
description = "시간대 리스트",
17-
example = "[\"2025-07-25T10:00:00\", \"2025-07-26T11:00:00\"]"
18+
description = "일자별 시간 bitmask 리스트 (LSB=0시, 1시간 단위)",
19+
example = "[" +
20+
"{\"date\": \"2025-08-11\", \"timeMask\": 10}," +
21+
"{\"date\": \"2025-08-12\", \"timeMask\": 384}" +
22+
"]"
1823
)
19-
private List<LocalDateTime> timeSlots;
24+
private List<TimeVoteDateMaskRequest> timeMasks;
2025

2126
private TimeVoteCreateRequest() {}
2227

2328
@Builder
24-
private TimeVoteCreateRequest(List<LocalDateTime> timeSlots) {
25-
this.timeSlots = timeSlots;
29+
private TimeVoteCreateRequest(List<TimeVoteDateMaskRequest> timeMasks) {
30+
this.timeMasks = timeMasks;
31+
}
32+
33+
public List<LocalDateTime> toTimeSlots() {
34+
return expand(timeMasks);
2635
}
2736
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package grep.neogulcoder.domain.timevote.dto.request;
2+
3+
import java.time.LocalDate;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import jakarta.validation.constraints.NotNull;
6+
import lombok.AccessLevel;
7+
import lombok.Builder;
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
11+
@Getter
12+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
13+
@Schema(description = "단일 일자에 대한 시간대 비트마스크 DTO")
14+
public class TimeVoteDateMaskRequest {
15+
16+
@NotNull(message = "날짜는 비어 있을 수 없습니다.")
17+
@Schema(description = "투표 일자", example = "2025-08-11")
18+
private LocalDate date;
19+
20+
@NotNull(message = "시간 비트마스크는 비어 있을 수 없습니다.")
21+
@Schema(
22+
description = "시간 비트마스크 (LSB=0시, 1시간 단위, 10진수 표현)",
23+
example = "1024"
24+
)
25+
private Long timeMask;
26+
27+
@Builder
28+
private TimeVoteDateMaskRequest(LocalDate date, Long timeMask) {
29+
this.date = date;
30+
this.timeMask = timeMask;
31+
}
32+
}

src/main/java/grep/neogulcoder/domain/timevote/dto/request/TimeVoteUpdateRequest.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,30 @@
77
import lombok.Builder;
88
import lombok.Getter;
99

10+
import static grep.neogulcoder.domain.timevote.provider.TimeSlotBitmaskConverter.expand;
11+
1012
@Getter
1113
@Schema(description = "스터디 모임 일정 조율 - 가능 시간 수정 요청 DTO")
1214
public class TimeVoteUpdateRequest {
1315

14-
@NotEmpty(message = "시간대를 1개 이상 선택해주세요.")
16+
@NotEmpty(message = "시간대는 최소 한 개 이상의 일자 정보를 포함해야 합니다.")
1517
@Schema(
16-
description = "시간대 리스트",
17-
example = "[\"2025-07-27T10:00:00\", \"2025-07-28T11:00:00\"]"
18+
description = "일자별 시간 비트마스크 리스트 (LSB=0시, 1시간 단위)",
19+
example = "[" +
20+
"{\"date\": \"2025-08-11\", \"timeMask\": 10}," +
21+
"{\"date\": \"2025-08-12\", \"timeMask\": 384}" +
22+
"]"
1823
)
19-
private List<LocalDateTime> timeSlots;
24+
private List<TimeVoteDateMaskRequest> timeMasks;
2025

2126
private TimeVoteUpdateRequest() {}
2227

2328
@Builder
24-
private TimeVoteUpdateRequest( List<LocalDateTime> timeSlots) {
25-
this.timeSlots = timeSlots;
29+
private TimeVoteUpdateRequest(List<TimeVoteDateMaskRequest> timeMasks) {
30+
this.timeMasks = timeMasks;
31+
}
32+
33+
public List<LocalDateTime> toTimeSlots() {
34+
return expand(timeMasks);
2635
}
2736
}

src/main/java/grep/neogulcoder/domain/timevote/dto/response/TimeVoteResponse.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package grep.neogulcoder.domain.timevote.dto.response;
22

3+
import static grep.neogulcoder.domain.timevote.provider.TimeSlotBitmaskConverter.compress;
4+
35
import grep.neogulcoder.domain.timevote.TimeVote;
46
import io.swagger.v3.oas.annotations.media.Schema;
7+
import java.time.LocalDate;
58
import java.time.LocalDateTime;
69
import java.util.List;
10+
import java.util.Map;
711
import java.util.stream.Collectors;
812
import lombok.Builder;
913
import lombok.Getter;
@@ -16,25 +20,35 @@ public class TimeVoteResponse {
1620
private Long studyMemberId;
1721

1822
@Schema(
19-
description = "시간대 리스트",
20-
example = "[\"2025-07-26T10:00:00\", \"2025-07-26T11:00:00\", \"2025-07-26T13:00:00\", \"2025-07-28T11:00:00\"]"
23+
description = "사용자가 제출한 개별 시간대 리스트",
24+
example = "[\"2025-07-26T10:00:00\", \"2025-07-26T11:00:00\"]"
2125
)
2226
private List<LocalDateTime> timeSlots;
2327

28+
@Schema(
29+
description = "일자별 시간 비트마스크 (LSB=0시, 1시간 단위)",
30+
example = "{\"2025-07-26\": 3, \"2025-07-27\": 384}"
31+
)
32+
private Map<LocalDate, Long> timeMasks;
33+
2434
@Builder
25-
private TimeVoteResponse(Long studyMemberId, List<LocalDateTime> timeSlots) {
35+
private TimeVoteResponse(Long studyMemberId, List<LocalDateTime> timeSlots, Map<LocalDate, Long> timeMasks) {
2636
this.studyMemberId = studyMemberId;
2737
this.timeSlots = timeSlots;
38+
this.timeMasks = timeMasks;
2839
}
2940

3041
public static TimeVoteResponse from(Long studyMemberId, List<TimeVote> votes) {
3142
List<LocalDateTime> timeSlots = votes.stream()
3243
.map(TimeVote::getTimeSlot)
3344
.collect(Collectors.toList());
3445

46+
Map<LocalDate, Long> timeMasks = compress(votes);
47+
3548
return TimeVoteResponse.builder()
3649
.studyMemberId(studyMemberId)
3750
.timeSlots(timeSlots)
51+
.timeMasks(timeMasks)
3852
.build();
3953
}
4054
}

src/main/java/grep/neogulcoder/domain/timevote/exception/code/TimeVoteErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public enum TimeVoteErrorCode implements ErrorCode {
2828
TIME_VOTE_DUPLICATED_TIME_SLOT("TV_003", HttpStatus.BAD_REQUEST, "중복된 시간이 포함되어 있습니다."),
2929
TIME_VOTE_PERIOD_EXPIRED("TV_004", HttpStatus.BAD_REQUEST, "투표 기간이 만료되었습니다."),
3030
TIME_VOTE_EMPTY("TV_005", HttpStatus.BAD_REQUEST, "한 개 이상의 시간을 선택해주세요."),
31+
TIME_VOTE_INVALID_TIME_MASK("TV_006", HttpStatus.BAD_REQUEST, "시간 비트마스크 값이 유효하지 않습니다."),
3132

3233
// Time Vote Stats (e.g. 통계 충돌 등)
3334
TIME_VOTE_STAT_CONFLICT("TVS_001", HttpStatus.CONFLICT, "투표 통계 저장 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."),
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package grep.neogulcoder.domain.timevote.provider;
2+
3+
import static grep.neogulcoder.domain.timevote.exception.code.TimeVoteErrorCode.TIME_VOTE_INVALID_TIME_MASK;
4+
5+
import grep.neogulcoder.domain.timevote.TimeVote;
6+
import grep.neogulcoder.domain.timevote.dto.request.TimeVoteDateMaskRequest;
7+
import grep.neogulcoder.global.exception.business.BusinessException;
8+
import java.time.LocalDate;
9+
import java.time.LocalDateTime;
10+
import java.util.ArrayList;
11+
import java.util.Collections;
12+
import java.util.List;
13+
import java.util.Map;
14+
import java.util.TreeMap;
15+
16+
public final class TimeSlotBitmaskConverter {
17+
18+
private static final int HOURS_PER_DAY = 24;
19+
private static final long VALID_MASK_RANGE = (1L << HOURS_PER_DAY) - 1;
20+
21+
private TimeSlotBitmaskConverter() {}
22+
23+
// 비트마스크 형식의 시간 정보를 개별 LocalDateTime 리스트로 확장
24+
public static List<LocalDateTime> expand(List<TimeVoteDateMaskRequest> masks) {
25+
if (masks == null || masks.isEmpty()) {
26+
return Collections.emptyList();
27+
}
28+
29+
List<LocalDateTime> timeSlots = new ArrayList<>();
30+
31+
for (TimeVoteDateMaskRequest request : masks) {
32+
if (request.getDate() == null || request.getTimeMask() == null) {
33+
throw new BusinessException(TIME_VOTE_INVALID_TIME_MASK);
34+
}
35+
36+
long mask = request.getTimeMask();
37+
validateMask(mask);
38+
39+
LocalDate date = request.getDate();
40+
for (int hour = 0; hour < HOURS_PER_DAY; hour++) {
41+
if ((mask & (1L << hour)) != 0) {
42+
timeSlots.add(date.atTime(hour, 0));
43+
}
44+
}
45+
}
46+
47+
Collections.sort(timeSlots);
48+
return timeSlots;
49+
}
50+
51+
// 개별 TimeVote 객체 리스트를 날짜별 비트마스크로 압축
52+
public static Map<LocalDate, Long> compress(List<TimeVote> votes) {
53+
if (votes == null || votes.isEmpty()) {
54+
return Collections.emptyMap();
55+
}
56+
57+
Map<LocalDate, Long> compressed = new TreeMap<>();
58+
59+
for (TimeVote vote : votes) {
60+
LocalDateTime timeSlot = vote.getTimeSlot();
61+
LocalDate date = timeSlot.toLocalDate();
62+
int hour = timeSlot.getHour();
63+
64+
if (hour < 0 || hour >= HOURS_PER_DAY) {
65+
throw new BusinessException(TIME_VOTE_INVALID_TIME_MASK);
66+
}
67+
68+
long bit = 1L << hour;
69+
compressed.merge(date, bit, (prev, curr) -> prev | curr);
70+
}
71+
72+
return compressed;
73+
}
74+
75+
private static void validateMask(long mask) {
76+
if (mask < 0 || (mask & ~VALID_MASK_RANGE) != 0) {
77+
throw new BusinessException(TIME_VOTE_INVALID_TIME_MASK);
78+
}
79+
}
80+
}

src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteRepository.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111
public interface TimeVoteRepository extends JpaRepository<TimeVote, Long> {
1212

1313
@Modifying(clearAutomatically = true)
14-
@Query("UPDATE TimeVote v SET v.activated = false WHERE v.period.studyId = :studyId")
14+
@Query(
15+
value =
16+
"UPDATE time_vote tv "
17+
+ "JOIN time_vote_period p ON p.period_id = tv.period_id "
18+
+ "SET tv.activated = false "
19+
+ "WHERE p.study_id = :studyId",
20+
nativeQuery = true)
1521
void deactivateAllByPeriod_StudyId(@Param("studyId") Long studyId);
1622

1723
List<TimeVote> findByPeriodAndStudyMemberIdAndActivatedTrue(TimeVotePeriod period, Long studyMemberId);

src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatRepository.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
public interface TimeVoteStatRepository extends JpaRepository<TimeVoteStat, Long> {
1313

1414
@Modifying
15-
@Query("UPDATE TimeVoteStat s SET s.activated = false WHERE s.period.studyId = :studyId")
15+
@Query(
16+
value =
17+
"UPDATE time_vote_stat tvs "
18+
+ "JOIN time_vote_period p ON p.period_id = tvs.period_id "
19+
+ "SET tvs.activated = false "
20+
+ "WHERE p.study_id = :studyId",
21+
nativeQuery = true)
1622
void deactivateAllByPeriod_StudyId(@Param("studyId") Long studyId);
1723

1824
@Modifying

src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteMapper.java

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package grep.neogulcoder.domain.timevote.service;
22

3-
import grep.neogulcoder.domain.timevote.dto.request.TimeVoteCreateRequest;
43
import grep.neogulcoder.domain.timevote.dto.request.TimeVotePeriodCreateRequest;
5-
import grep.neogulcoder.domain.timevote.dto.request.TimeVoteUpdateRequest;
64
import grep.neogulcoder.domain.timevote.TimeVote;
75
import grep.neogulcoder.domain.timevote.TimeVotePeriod;
86
import java.time.LocalDateTime;
@@ -23,26 +21,6 @@ public TimeVotePeriod toEntity(TimeVotePeriodCreateRequest request, Long studyId
2321
.build();
2422
}
2523

26-
public List<TimeVote> toEntities(TimeVoteCreateRequest request, TimeVotePeriod period, Long studyMemberId) {
27-
return request.getTimeSlots().stream()
28-
.map(slot -> TimeVote.builder()
29-
.period(period)
30-
.studyMemberId(studyMemberId)
31-
.timeSlot(slot)
32-
.build())
33-
.collect(Collectors.toList());
34-
}
35-
36-
public List<TimeVote> toEntities(TimeVoteUpdateRequest request, TimeVotePeriod period, Long studyMemberId) {
37-
return request.getTimeSlots().stream()
38-
.map(slot -> TimeVote.builder()
39-
.period(period)
40-
.studyMemberId(studyMemberId)
41-
.timeSlot(slot)
42-
.build())
43-
.collect(Collectors.toList());
44-
}
45-
4624
public List<TimeVote> toEntities(List<LocalDateTime> timeSlots, TimeVotePeriod period, Long studyMemberId) {
4725
return timeSlots.stream()
4826
.map(slot -> TimeVote.builder()

src/main/java/grep/neogulcoder/domain/timevote/service/vote/TimeVoteService.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,15 @@ public TimeVoteResponse getMyVotes(Long studyId, Long userId) {
4545
}
4646

4747
public TimeVoteResponse submitVotes(TimeVoteCreateRequest request, Long studyId, Long userId) {
48-
TimeVoteContext context = timeVoteValidator.getSubmitContext(studyId, userId, request.getTimeSlots());
49-
return executeVoteSubmission(context, request.getTimeSlots());
48+
List<LocalDateTime> timeSlots = request.toTimeSlots();
49+
TimeVoteContext context = timeVoteValidator.getSubmitContext(studyId, userId, timeSlots);
50+
return executeVoteSubmission(context, timeSlots);
5051
}
5152

5253
public TimeVoteResponse updateVotes(TimeVoteUpdateRequest request, Long studyId, Long userId) {
53-
TimeVoteContext context = timeVoteValidator.getUpdateContext(studyId, userId, request.getTimeSlots());
54-
return executeVoteSubmission(context, request.getTimeSlots());
54+
List<LocalDateTime> timeSlots = request.toTimeSlots();
55+
TimeVoteContext context = timeVoteValidator.getUpdateContext(studyId, userId, timeSlots);
56+
return executeVoteSubmission(context, timeSlots);
5557
}
5658

5759
public void deleteAllVotes(Long studyId, Long userId) {

0 commit comments

Comments
 (0)