diff --git a/build.gradle b/build.gradle index 88bd9a49..9da18342 100644 --- a/build.gradle +++ b/build.gradle @@ -40,7 +40,6 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' //security - implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'io.jsonwebtoken:jjwt-api:0.11.2' @@ -49,8 +48,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'io.github.cdimascio:dotenv-java:3.0.0' - - // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' @@ -59,8 +56,16 @@ dependencies { implementation 'mysql:mysql-connector-java:8.0.30' implementation 'com.h2database:h2:2.2.220' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.22.0' implementation 'org.springframework.boot:spring-boot-starter-jdbc' - + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' } @@ -71,4 +76,8 @@ tasks.named('test') { test { // 모든 테스트를 스킵하도록 설정 (임시) enabled = false +} + +clean { + delete file('src/main/generated') } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c82..b82aa23a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f5feea6d..f3b75f3b 100755 --- a/gradlew +++ b/gradlew @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/vote/domain/Vote.java b/src/main/java/com/umc/yeogi_gal_lae/api/vote/domain/Vote.java index a6ca68d0..fdf5f501 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/vote/domain/Vote.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/vote/domain/Vote.java @@ -1,11 +1,15 @@ package com.umc.yeogi_gal_lae.api.vote.domain; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.umc.yeogi_gal_lae.api.tripPlan.domain.TripPlan; import com.umc.yeogi_gal_lae.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + @Builder @Getter @Setter @NoArgsConstructor @@ -19,17 +23,19 @@ public class Vote extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "trip_plan_id", nullable = false) + @JsonIgnore private TripPlan tripPlan; - // 특정 사용자의 투표 결과 조회 빈번하게 일어날 것으로 예상되기에, VoteRoom 을 통하지 않고 바로 Use 와 매핑 - // VoteRoom 은 투표 방 자체를 관리하고, Vote 는 사용자들의 실제 투표 데이터를 관리하므로 역할 분리 - // 투표 데이터를 추가하거나 변경할 때 VoteRoom 에 불필요한 데이터가 섞이지 않기 위함 - @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "vote_room_id", nullable = false) + @JsonIgnore private VoteRoom voteRoom; @Enumerated(EnumType.STRING) @Column(nullable = false) private VoteType type; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + @Override + public LocalDateTime getCreatedAt() { return super.getCreatedAt(); } } diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/vote/dto/request/VoteRequest.java b/src/main/java/com/umc/yeogi_gal_lae/api/vote/dto/request/VoteRequest.java index 4b73da5d..120f73f6 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/vote/dto/request/VoteRequest.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/vote/dto/request/VoteRequest.java @@ -6,6 +6,7 @@ import net.minidev.json.annotate.JsonIgnore; +@NoArgsConstructor public class VoteRequest { diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/vote/dto/request/VoteRoomRequest.java b/src/main/java/com/umc/yeogi_gal_lae/api/vote/dto/request/VoteRoomRequest.java index c1ec268a..b215e86b 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/vote/dto/request/VoteRoomRequest.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/vote/dto/request/VoteRoomRequest.java @@ -1,12 +1,16 @@ package com.umc.yeogi_gal_lae.api.vote.dto.request; import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; -@Builder @Getter +@NoArgsConstructor +@Builder // 추가 +@AllArgsConstructor // 필요 시 추가 public class VoteRoomRequest { @NotNull @@ -17,5 +21,4 @@ public class VoteRoomRequest { @NotNull private Long voteRoomId; - } \ No newline at end of file diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/vote/service/ValidVoteResultService.java b/src/main/java/com/umc/yeogi_gal_lae/api/vote/service/ValidVoteResultService.java index 7e44ad7e..b0e2fb12 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/vote/service/ValidVoteResultService.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/vote/service/ValidVoteResultService.java @@ -14,7 +14,6 @@ import com.umc.yeogi_gal_lae.api.vote.repository.VoteRoomRepository; import com.umc.yeogi_gal_lae.global.error.BusinessException; import com.umc.yeogi_gal_lae.global.error.ErrorCode; -import jakarta.persistence.EntityNotFoundException; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -41,64 +40,52 @@ public class ValidVoteResultService { public boolean validResult(VoteRoomRequest voteRoomRequest) { // 투표 완료 여부 확인 - if (!checkVoteCompleted(voteRoomRequest)) { throw new BusinessException(ErrorCode.VOTE_NOT_COMPLETED_YET); } + if (!checkVoteCompleted(voteRoomRequest)) { throw new BusinessException(ErrorCode.VOTE_NOT_COMPLETED_YET);} VoteRoom voteRoom = findVoteRoomById(voteRoomRequest.getVoteRoomId()); TripPlan tripPlan = voteRoom.getTripPlan(); + VoteCounts voteCounts = countVotes(tripPlan.getId()); - if (isVoteTimeExpired(voteRoom, tripPlan)) { - voteRoomRepository.delete(voteRoom); - return true; // 재투표 - } - - // 찬성/반대 투표 집계 - List votes = voteRepository.findAllVotesByTripPlanId(tripPlan.getId()); - long goodVotes = votes.stream().filter(v -> v.getType() == VoteType.GOOD).count(); - long badVotes = votes.stream().filter(v -> v.getType() == VoteType.BAD).count(); - - - // 반대표가 더 많을 시, 재투표를 위해 투표 방 삭제 - if (goodVotes < badVotes) { + if (voteCounts.goodVotes > voteCounts.badVotes) { + tripPlan.setStatus(Status.COMPLETED); + tripPlanRepository.save(tripPlan); + } else { voteRoomRepository.delete(voteRoom); - - // roomId를 통해 roomName 가져오기 - Room room = findRoomById(voteRoomRequest.getRoomId()); - String roomName = room.getName(); - - // 투표 완료 알림 생성 - notificationService.createEndNotification(roomName, tripPlan.getUser().getEmail(), NotificationType.VOTE_COMPLETE, tripPlan.getId(), tripPlan.getTripPlanType()); - return true; } - else{ - tripPlan.setStatus(Status.COMPLETED); // 여행 계획 '완료'로 상태 변경 - tripPlanRepository.save(tripPlan); - // roomId를 통해 roomName 가져오기 - Room room = findRoomById(voteRoomRequest.getRoomId()); - String roomName = room.getName(); - - // 투표 완료 알림 생성 - notificationService.createEndNotification(roomName, tripPlan.getUser().getEmail(), NotificationType.VOTE_COMPLETE, tripPlan.getId(), tripPlan.getTripPlanType()); - return false; - } + Room room = findRoomById(voteRoomRequest.getRoomId()); + notificationService.createEndNotification( + room.getName(), tripPlan.getUser().getEmail(), NotificationType.VOTE_COMPLETE, tripPlan.getId(), tripPlan.getTripPlanType() + ); + return voteCounts.goodVotes <= voteCounts.badVotes; } @Transactional(readOnly = true) public boolean checkVoteCompleted(VoteRoomRequest voteRoomRequest) { + try { + VoteRoom voteRoom = findVoteRoomById(voteRoomRequest.getVoteRoomId()); + TripPlan tripPlan = findTripPlanById(voteRoomRequest.getTripId()); + VoteCounts voteCounts = countVotes(tripPlan.getId()); - // 반복되는 로직 헬퍼 클래스로 분리 - VoteRoom voteRoom = findVoteRoomById(voteRoomRequest.getVoteRoomId()); - TripPlan tripPlan = findTripPlanById(voteRoomRequest.getTripId()); + // 조건 1. 모든 멤버가 투표했는지 확인 + boolean allMembersVoted = isAllMembersVoted(voteRoomRequest.getRoomId(), voteCounts.totalVotes); - // 조건 1. 모든 멤버가 투표 했는지 - List votes = voteRepository.findAllVotesByTripPlanId(tripPlan.getId()); // 여행에 해당하는 모든 투표 리스트 - boolean allMembersVoted = isAllMembersVoted(voteRoomRequest.getRoomId(), votes); + // 조건 2. 투표 제한 시간 초과 확인 + boolean isTimeExpired = isVoteTimeExpired(voteRoom, tripPlan); - // 조건 2. 투표 제한 시간 초과 - boolean isTimeExpired = isVoteTimeExpired(voteRoom, tripPlan); + return (isTimeExpired || allMembersVoted) && (voteCounts.goodVotes != voteCounts.badVotes); + } catch (Exception e) { + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } - return isTimeExpired || allMembersVoted; + private VoteCounts countVotes(Long tripPlanId) { + List votes = voteRepository.findAllVotesByTripPlanId(tripPlanId); + long goodVotes = votes.stream().filter(v -> v.getType() == VoteType.GOOD).count(); + long badVotes = votes.stream().filter(v -> v.getType() == VoteType.BAD).count(); + + return new VoteCounts(goodVotes, badVotes, votes.size()); } private TripPlan findTripPlanById(Long tripId) { @@ -116,9 +103,9 @@ private VoteRoom findVoteRoomById(Long voteRoomId) { .orElseThrow(() -> new BusinessException(ErrorCode.VOTE_ROOM_NOT_FOUND)); } - private boolean isAllMembersVoted(Long roomId, List votes) { + private boolean isAllMembersVoted(Long roomId, long totalVotes) { Room room = findRoomById(roomId); - return room.getRoomMembers().size() == votes.size(); + return room.getRoomMembers().size() == totalVotes; } private boolean isVoteTimeExpired(VoteRoom voteRoom, TripPlan tripPlan) { @@ -127,4 +114,16 @@ private boolean isVoteTimeExpired(VoteRoom voteRoom, TripPlan tripPlan) { voteRoom.getCreatedAt().plusSeconds(tripPlan.getVoteLimitTime().getSeconds()) ); } + + private static class VoteCounts { + final long goodVotes; + final long badVotes; + final long totalVotes; + + public VoteCounts(long goodVotes, long badVotes, long totalVotes) { + this.goodVotes = goodVotes; + this.badVotes = badVotes; + this.totalVotes = totalVotes; + } + } } diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/vote/service/VoteService.java b/src/main/java/com/umc/yeogi_gal_lae/api/vote/service/VoteService.java index 65082ae8..2442c593 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/vote/service/VoteService.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/vote/service/VoteService.java @@ -1,5 +1,7 @@ package com.umc.yeogi_gal_lae.api.vote.service; +import com.querydsl.core.Tuple; +import com.querydsl.jpa.impl.JPAQueryFactory; import com.umc.yeogi_gal_lae.api.notification.domain.NotificationType; import com.umc.yeogi_gal_lae.api.room.domain.Room; import com.umc.yeogi_gal_lae.api.room.repository.RoomMemberRepository; @@ -9,11 +11,14 @@ import com.umc.yeogi_gal_lae.api.user.domain.User; import com.umc.yeogi_gal_lae.api.user.repository.UserRepository; import com.umc.yeogi_gal_lae.api.vote.converter.VoteConverter; +import com.umc.yeogi_gal_lae.api.vote.domain.QVote; import com.umc.yeogi_gal_lae.api.vote.domain.Vote; import com.umc.yeogi_gal_lae.api.vote.domain.VoteRoom; import com.umc.yeogi_gal_lae.api.vote.domain.VoteType; import com.umc.yeogi_gal_lae.api.notification.service.NotificationService; +import org.redisson.api.RedissonClient; +import com.umc.yeogi_gal_lae.api.user.domain.QUser; import com.umc.yeogi_gal_lae.api.vote.dto.request.VoteRequest; import com.umc.yeogi_gal_lae.api.vote.dto.VoteResponse; @@ -21,13 +26,16 @@ import com.umc.yeogi_gal_lae.api.vote.repository.VoteRoomRepository; import com.umc.yeogi_gal_lae.global.error.BusinessException; import com.umc.yeogi_gal_lae.global.error.ErrorCode; -import jakarta.persistence.EntityNotFoundException; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import static com.umc.yeogi_gal_lae.global.error.ErrorCode.*; @@ -45,6 +53,10 @@ public class VoteService { private final NotificationService notificationService; private final RoomMemberRepository roomMemberRepository; + private RedissonClient redissonClient; + private CacheManager cacheManager; + private final JPAQueryFactory queryFactory; + @Transactional(readOnly = true) public VoteResponse.VoteInfoDTO getTripPlanInfoForVote(Long tripId, Long roomId , String userEmail){ @@ -61,67 +73,115 @@ public VoteResponse.VoteInfoDTO getTripPlanInfoForVote(Long tripId, Long roomId @Transactional public void createVote(VoteRequest.createVoteReq request, String userEmail){ - // 유저 이메일로 검증 User user = userRepository.findByEmail(userEmail).orElseThrow(()-> new BusinessException(ErrorCode.USER_NOT_FOUND)); TripPlan tripPlan = tripPlanRepository.findById(request.getTripId()).orElseThrow(()-> new BusinessException(ErrorCode.TRIP_PLAN_NOT_FOUND)); VoteRoom voteRoom = voteRoomRepository.findByTripPlanId(tripPlan.getId()).orElseThrow(() -> new BusinessException(VOTE_ROOM_NOT_FOUND)); - - - Vote vote = voteRepository.findByTripPlanId(tripPlan.getId()) // DB 에 Vote 객체가 있다면, - .orElseGet(() -> voteRepository.save(Vote.builder() // 없다면, Vote 객체 생성 + VoteType voteType = VoteType.valueOf(request.getType().trim().toUpperCase()); + + // Key 기반의 분산 락을 적용하므로써, 사용자의 동시 투표 방지 + RLock lock = redissonClient.getLock("voteLock:" + userEmail); + boolean isLocked = false; + try{ + isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS); + log.info("락 획득 여부: {}, 현재 스레드가 락을 보유하고 있는가? {}", isLocked, lock.isHeldByCurrentThread()); + if (!isLocked) { throw new BusinessException(ErrorCode.VOTE_CONCURRENT_UPDATE);} + + // 락 획득 성공 시, 레디스에 저장된 투표 데이터를 확인하여 캐싱된 데이터가 있을 시, DB 조회 x + Vote vote = getCachedVoteByTripPlan(tripPlan.getId()); + if (vote == null) { + vote = voteRepository.save(Vote.builder() .tripPlan(tripPlan) .voteRoom(voteRoom) - .type(VoteType.valueOf(request.getType().trim().toUpperCase())) // 초기 타입 설정 - .build())); - // 투표 시작 알림 생성 - notificationService.createStartNotification(tripPlan.getRoom().getName(), user.getUsername(), user.getEmail(),NotificationType.VOTE_START, tripPlan.getId(), tripPlan.getTripPlanType()); - - // 기존 투표 이력 확인 - Vote currentVote = user.getVote(); - - if (currentVote == null) { user.setVote(vote); } - else if (currentVote.getTripPlan().getId().equals(tripPlan.getId())) { - VoteType requestedType = VoteType.valueOf(request.getType().trim().toUpperCase()); - - if (currentVote.getType().equals(requestedType)) { throw new IllegalArgumentException("같은 타입으로 중복 투표는 불가능합니다.");} - - currentVote.setType(requestedType); - voteRepository.save(currentVote); - } else { - user.setVote(vote); + .type(voteType) + .build()); + + // 새로 생성된 경우 캐싱 + Vote finalVote = vote; + Optional.ofNullable(cacheManager.getCache("votes")) + .ifPresent(cache -> cache.put(tripPlan.getId(), finalVote)); + } + + // 투표 시작 알림 생성 + notificationService.createStartNotification(tripPlan.getRoom().getName(), user.getUsername(), user.getEmail(),NotificationType.VOTE_START, tripPlan.getId(), tripPlan.getTripPlanType()); + + // 기존 투표 이력 확인 + Vote currentVote = user.getVote(); + if (currentVote == null) { + user.setVote(vote); + } + else if (currentVote.getTripPlan().getId().equals(tripPlan.getId())) { + if (Thread.currentThread().isInterrupted()) { + log.warn("BusinessException 발생 후 스레드가 인터럽트된 상태 - 사용자: {}", userEmail); + Thread.interrupted(); // 인터럽트 상태 초기화 + } + if (currentVote.getType().equals(voteType)) { throw new BusinessException(ErrorCode.DUPLICATE_VOTE_NOT_ALLOWED);} + + currentVote.setType(voteType); + voteRepository.save(currentVote); + + // 사용자가 이미 투표한 내역을 변경할 경우, 레디스에 저장된 데이터를 삭제하여 최신화 + Optional.ofNullable(cacheManager.getCache("votes")) + .ifPresent(cache -> cache.evict(tripPlan.getId())); + } else { + user.setVote(vote); + } + userRepository.save(user); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BusinessException(ErrorCode.VOTE_CONCURRENT_UPDATE); + } catch (Exception e) { + log.error("예외 발생 함. 사용자: {} - 예외 타입: {}", userEmail, e.getClass().getName(), e); + throw e; + }finally { + log.info("현재 스레드 사용자 {} 에 대한 락을 해제합니다.", userEmail); + if (isLocked && lock.isHeldByCurrentThread()) { lock.unlock(); } } - - userRepository.save(user); } public List getVoteResults(String userEmail, Long tripId){ - Long userId = userRepository.findByEmail(userEmail) - .orElseThrow(()-> new BusinessException.UserNotFoundException("요청하신 이메일과 일치하는 유저가 존재하지 않습니다.")) - .getId(); - - if (!tripPlanRepository.existsById(tripId)) { throw new EntityNotFoundException("여행 계획을 찾을 수 없습니다.");} - - - List users = userRepository.findUsersByVoteTripPlanId(tripId); - - // 현재 접속한 사용자에 대한 투표 데이터 - Optional userVote = users.stream() - .filter(user -> user.getId().equals(userId)) + Long userId = userRepository.findByEmail(userEmail).orElseThrow(()-> new BusinessException(ErrorCode.USER_NOT_FOUND)).getId(); + if (!tripPlanRepository.existsById(tripId)) { throw new BusinessException(TRIP_PLAN_NOT_FOUND);} + + QUser qUser = QUser.user; + QVote qVote = QVote.vote; + List voteData = queryFactory + .select(qUser.id, qVote.type) + .from(qUser) + .leftJoin(qVote).on(qUser.vote.eq(qVote)) + .where(qVote.tripPlan.id.eq(tripId)) + .fetch(); + + // 사용자 투표 확인 + Optional userVote = voteData.stream() + .filter(tuple -> tuple.get(qUser.id).equals(userId)) .findFirst(); - // 투표 데이터를 type 이름 기준('GOOD ','BAD')으로 그룹화, 타입 당 투표 수 계산 // {"GOOD": 3, "BAD": 2} - Map groupedVotes = users.stream() - .filter(user -> user.getVote() != null && user.getVote().getType() != null) // 투표한 데이터만 카운팅 - .map(user -> user.getVote().getType().name()) + Map groupedVotes = voteData.stream() + .map(tuple -> tuple.get(qVote.type)) // type 추출 + .filter(Objects::nonNull) + .map(Enum::name) // Enum -> String 변환 .collect(Collectors.groupingBy( typeName -> typeName, Collectors.counting()) ); - VoteResponse.ResultDTO goodResponse = VoteConverter.convert("GOOD", userVote.orElse(null), groupedVotes); - VoteResponse.ResultDTO badResponse = VoteConverter.convert("BAD", userVote.orElse(null), groupedVotes); + // 사용자 투표 확인 (Tuple -> User) + User userVoteData = userVote + .map(tuple -> userRepository.findById(tuple.get(qUser.id)).orElse(null)) + .orElse(null); + VoteResponse.ResultDTO goodResponse = VoteConverter.convert("GOOD", userVoteData, groupedVotes); + VoteResponse.ResultDTO badResponse = VoteConverter.convert("BAD", userVoteData, groupedVotes); + return List.of(goodResponse, badResponse); } + + // 캐싱 공간 및 키 할당 + @Cacheable(value = "votes", key = "#tripPlanId") + public Vote getCachedVoteByTripPlan(Long tripPlanId) { + return voteRepository.findByTripPlanId(tripPlanId).orElse(null); + } + } \ No newline at end of file diff --git a/src/main/java/com/umc/yeogi_gal_lae/global/config/ModuleConfig.java b/src/main/java/com/umc/yeogi_gal_lae/global/config/ModuleConfig.java new file mode 100644 index 00000000..1dda43c3 --- /dev/null +++ b/src/main/java/com/umc/yeogi_gal_lae/global/config/ModuleConfig.java @@ -0,0 +1,17 @@ +package com.umc.yeogi_gal_lae.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ModuleConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} \ No newline at end of file diff --git a/src/main/java/com/umc/yeogi_gal_lae/global/config/QueryDslConfig.java b/src/main/java/com/umc/yeogi_gal_lae/global/config/QueryDslConfig.java new file mode 100644 index 00000000..273fab99 --- /dev/null +++ b/src/main/java/com/umc/yeogi_gal_lae/global/config/QueryDslConfig.java @@ -0,0 +1,21 @@ +package com.umc.yeogi_gal_lae.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +@RequiredArgsConstructor +public class QueryDslConfig { + + private final EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} + diff --git a/src/main/java/com/umc/yeogi_gal_lae/global/config/RedisConfig.java b/src/main/java/com/umc/yeogi_gal_lae/global/config/RedisConfig.java new file mode 100644 index 00000000..cfd3f82e --- /dev/null +++ b/src/main/java/com/umc/yeogi_gal_lae/global/config/RedisConfig.java @@ -0,0 +1,100 @@ +package com.umc.yeogi_gal_lae.global.config; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.interceptor.SimpleKeyGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.cache.RedisCacheWriter; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.cache.annotation.EnableCaching; + + + +@Configuration +@EnableCaching +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://" + redisHost + ":" + redisPort) + .setConnectionPoolSize(35) + .setConnectionMinimumIdleSize(35) + .setRetryAttempts(3) + .setRetryInterval(2000) + .setConnectTimeout(30000); // 락 자동 연장 시간 + + return Redisson.create(config); + } + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory, ObjectMapper objectMapper) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + // Redis에서 LocalDateTime 지원하는 ObjectMapper 사용 + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(serializer); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(serializer); + + return redisTemplate; + } + + // CacheManager에서도 ObjectMapper 적용 + @Bean + public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory, ObjectMapper objectMapper) { + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(10)) + .disableCachingNullValues() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))); + + return RedisCacheManager.builder(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory)) + .cacheDefaults(cacheConfig) + .build(); + } + + @Bean + public SimpleKeyGenerator keyGenerator() { + return new SimpleKeyGenerator(); + } +} diff --git a/src/main/java/com/umc/yeogi_gal_lae/global/error/ErrorCode.java b/src/main/java/com/umc/yeogi_gal_lae/global/error/ErrorCode.java index 337913ab..0ad234ac 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/global/error/ErrorCode.java +++ b/src/main/java/com/umc/yeogi_gal_lae/global/error/ErrorCode.java @@ -42,6 +42,8 @@ public enum ErrorCode implements BaseStatus { VOTE_NOT_COMPLETED_YET(HttpStatus.BAD_REQUEST, "VOTE_400", "아직 투표가 종료되지 않았습니다."), VOTE_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "VOTE_401", "요청 하신 투표 방을 찾을 수 없습니다."), VOTE_RESULT_FAILED(HttpStatus.BAD_REQUEST, "VOTE_403", "여행 확정에 실패하셨습니다. 이 방은 사라집니다."), + DUPLICATE_VOTE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "VOTE_404", "중복 투표는 불가능합니다."), + VOTE_CONCURRENT_UPDATE(HttpStatus.BAD_REQUEST, "VOTE_404", "동시 투표는 이용이 제한 됩니다."), VOTE_NOT_ALLOWED_FOR_COURSE(HttpStatus.BAD_REQUEST, "VOTE_404", "코스는 투표가 허용되지 않습니다."), // Room Member Error diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 590ee8c2..86d81de8 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -21,7 +21,7 @@ spring: enabled: false data: redis: - host: redis + host: redis1 port: 6379 mvc: static-path-pattern: /static/** diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 00000000..5757a24b --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,18 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb # 메모리 DB 사용 (테스트 환경에서 적합) + driver-class-name: org.h2.Driver + username: sa + password: + h2: + console: + enabled: true + path: /h2-console + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop # 테스트 시 자동으로 테이블 생성 후 삭제 (create-drop) + properties: + hibernate: + format_sql: true + show_sql: true diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2471fbc5..6f3cae12 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,6 +18,11 @@ spring: show_sql: true flyway: enabled: false + data: + redis: + host: localhost + port: 6379 + timeout: 5000ms springdoc: api-docs: enabled: true