diff --git a/build.gradle b/build.gradle index 5ce1504..2ef00ae 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,13 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + + //jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/java/com/example/book_your_seat/BookYourSeatApplication.java b/src/main/java/com/example/book_your_seat/BookYourSeatApplication.java index b54c97e..86e73f7 100644 --- a/src/main/java/com/example/book_your_seat/BookYourSeatApplication.java +++ b/src/main/java/com/example/book_your_seat/BookYourSeatApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; -@SpringBootApplication +@EnableScheduling @EnableJpaAuditing +@SpringBootApplication public class BookYourSeatApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/book_your_seat/common/util/JwtConst.java b/src/main/java/com/example/book_your_seat/common/util/JwtConst.java new file mode 100644 index 0000000..d15f418 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/common/util/JwtConst.java @@ -0,0 +1,9 @@ +package com.example.book_your_seat.common.util; + +public final class JwtConst { + private JwtConst() {} + public static final String INVALID_JWT = "유효하지 않은 JWT 토큰입니다."; + public static final String EXPIRED_JWT = "만료된 JWT 토큰입니다."; + public static final String UNSUPPORTED_JWT = "지원하지 않는 JWT 토큰 형식입니다."; + public static final String EMPTY_JWT = "JWT 토큰이 없습니다."; +} diff --git a/src/main/java/com/example/book_your_seat/common/util/JwtUtil.java b/src/main/java/com/example/book_your_seat/common/util/JwtUtil.java new file mode 100644 index 0000000..c294642 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/common/util/JwtUtil.java @@ -0,0 +1,69 @@ +package com.example.book_your_seat.common.util; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +import static com.example.book_your_seat.common.util.JwtConst.*; + +@Slf4j +@Component +public class JwtUtil { + private final SecretKey secretKey; + private final Integer expirationTime; + private static final String SIGNATURE_ALGORITHM = Jwts.SIG.HS256.key().build().getAlgorithm(); + + JwtUtil(@Value("${jwt.secret}") String secretKey, @Value("${jwt.expiration_time}") Integer expirationTime) { + this.secretKey = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), SIGNATURE_ALGORITHM); + this.expirationTime = expirationTime; + } + + public String createJwt(Long userId) { + final Instant now = Instant.now(); + final Instant expiredAt = now.plusSeconds(expirationTime); + + return Jwts.builder() + .claim("userId", userId.toString()) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiredAt)) + .signWith(secretKey) + .compact(); + } + + public boolean validateToken(String token) { + try { + Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); + return true; + } catch (MalformedJwtException e) { + throw new IllegalStateException(INVALID_JWT); + } catch (ExpiredJwtException e) { + throw new IllegalStateException(EXPIRED_JWT); + } catch (UnsupportedJwtException | SignatureException e) { + throw new IllegalStateException(UNSUPPORTED_JWT); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(EMPTY_JWT); + } + } + + public Long getUserIdByToken(String token) { + String userId = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .get("userId", String.class); + + return Long.parseLong(userId); + } +} diff --git a/src/main/java/com/example/book_your_seat/config/RedisConfig.java b/src/main/java/com/example/book_your_seat/config/RedisConfig.java index 1edf58d..2caca31 100644 --- a/src/main/java/com/example/book_your_seat/config/RedisConfig.java +++ b/src/main/java/com/example/book_your_seat/config/RedisConfig.java @@ -36,7 +36,7 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); return template; } } diff --git a/src/main/java/com/example/book_your_seat/queue/QueueConst.java b/src/main/java/com/example/book_your_seat/queue/QueueConst.java new file mode 100644 index 0000000..17b5a41 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/QueueConst.java @@ -0,0 +1,16 @@ +package com.example.book_your_seat.queue; + +public final class QueueConst { + public static final String PROCESSING = "PROCESSING"; + public static final String WAITING = "WAITING"; + public static final String NOT_IN_QUEUE = "NOT_IN_QUEUE"; + + public static final Integer PROCESSING_QUEUE_SIZE = 1000; + public static final Integer PROCESSING_TOKEN_EXPIRATION_TIME = 30 * 60 * 1000; //30분 + public static final Integer WAITING_TOKEN_EXPIRATION_TIME = 60 * 60 * 1000; //1시간 + + public static final String PROCESSING_QUEUE_KEY = "processing_queue_key"; + public static final String WAITING_QUEUE_KEY = "waiting_queue_key"; + public static final String ALREADY_ISSUED_USER = "이미 토큰을 발급받은 유저입니다."; + +} diff --git a/src/main/java/com/example/book_your_seat/queue/controller/QueueController.java b/src/main/java/com/example/book_your_seat/queue/controller/QueueController.java new file mode 100644 index 0000000..6caadfa --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/controller/QueueController.java @@ -0,0 +1,43 @@ +package com.example.book_your_seat.queue.controller; + +import com.example.book_your_seat.queue.controller.dto.QueueResponse; +import com.example.book_your_seat.queue.controller.dto.TokenResponse; +import com.example.book_your_seat.queue.service.QueueCommandService; +import com.example.book_your_seat.queue.service.QueueQueryService; +import com.example.book_your_seat.queue.service.facade.QueueService; +import com.example.book_your_seat.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import static com.example.book_your_seat.common.SessionConst.LOGIN_USER; + +@RestController +@RequestMapping("/api/v1/queues") +@RequiredArgsConstructor +public class QueueController { + + private final QueueService queueService; + + @PostMapping("/token") + public ResponseEntity issueTokenAndEnqueue(@SessionAttribute(LOGIN_USER) User user) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(queueService.issueTokenAndEnqueue(user.getId())); + } + + @GetMapping("/queue") + public ResponseEntity getQueueInfoWithToken(@SessionAttribute(LOGIN_USER) User user, + @RequestParam("token") String token) { + return ResponseEntity.ok() + .body(queueService.findQueueStatus(user.getId(), token)); + } + + @PostMapping("/quit") + public ResponseEntity dequeueWaitingQueue(@SessionAttribute(LOGIN_USER) User user, + @RequestParam("token") String token) { + queueService.dequeueWaitingQueue(user.getId(), token); + return ResponseEntity.ok(null); + } +} diff --git a/src/main/java/com/example/book_your_seat/queue/controller/dto/QueueResponse.java b/src/main/java/com/example/book_your_seat/queue/controller/dto/QueueResponse.java new file mode 100644 index 0000000..73773ed --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/controller/dto/QueueResponse.java @@ -0,0 +1,7 @@ +package com.example.book_your_seat.queue.controller.dto; + +public record QueueResponse( + String status, + Integer waitingQueueCount +) { +} diff --git a/src/main/java/com/example/book_your_seat/queue/controller/dto/TokenResponse.java b/src/main/java/com/example/book_your_seat/queue/controller/dto/TokenResponse.java new file mode 100644 index 0000000..9f9f2ad --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/controller/dto/TokenResponse.java @@ -0,0 +1,6 @@ +package com.example.book_your_seat.queue.controller.dto; + +public record TokenResponse( + String token +) { +} diff --git a/src/main/java/com/example/book_your_seat/queue/repository/QueueRedisRepository.java b/src/main/java/com/example/book_your_seat/queue/repository/QueueRedisRepository.java new file mode 100644 index 0000000..e463142 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/repository/QueueRedisRepository.java @@ -0,0 +1,123 @@ +package com.example.book_your_seat.queue.repository; + +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Repository; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static com.example.book_your_seat.queue.QueueConst.*; + +@Repository +@RequiredArgsConstructor +public class QueueRedisRepository implements Serializable { + + @Resource(name = "redisTemplate") + private ZSetOperations zSet; + + + public void enqueueProcessingQueue(Long userId, String token) { + String value = generateValue(userId, token); + zSet.add(PROCESSING_QUEUE_KEY, value, System.currentTimeMillis() + PROCESSING_TOKEN_EXPIRATION_TIME); + } + + public void enqueueWaitingQueue(Long userId, String token) { + String value = generateValue(userId, token); + zSet.add(WAITING_QUEUE_KEY, value, System.currentTimeMillis() + WAITING_TOKEN_EXPIRATION_TIME); + } + + /* + 바로 Processing 가능한지 여부 반환 + */ + public boolean isProcessableNow() { + Long pqSize = zSet.zCard(PROCESSING_QUEUE_KEY); + Long wqSize = zSet.zCard(WAITING_QUEUE_KEY); + if (pqSize == null) pqSize = 0L; + if (wqSize == null) wqSize = 0L; + + return pqSize < PROCESSING_QUEUE_SIZE && wqSize == 0; + } + + public boolean isInProcessingQueue(Long userId) { + Set values = zSet.range(PROCESSING_QUEUE_KEY, 0, -1); + return values.stream().anyMatch( + value -> value.matches(userId + ":.*") + ); + } + + public boolean isInWaitingQueue(Long userId) { + Set values = zSet.range(WAITING_QUEUE_KEY, 0, -1); + return values.stream().anyMatch( + value -> value.matches(userId + ":.*") + ); + } + + /* + 몇번째로 대기하고 있는지를 반환 + */ + public Integer getWaitingQueuePosition(Long userId, String token) { + String value = generateValue(userId, token); + Long rank = zSet.rank(WAITING_QUEUE_KEY, value); + return (rank == null) ? null : rank.intValue() + 1; + } + + /* + 대기열에 존재 인원 수 반환 + */ + public Integer getProcessingQueueCount() { + Long count = zSet.zCard(PROCESSING_QUEUE_KEY); + return (count == null) ? 0 : count.intValue(); + } + + /* + 업데이트 되어야 하는 인원을 반환 + */ + public List getFrontTokensFromWaitingQueue(int count) { + Set tokens = zSet.range(WAITING_QUEUE_KEY, 0, count - 1); + return new ArrayList<>(tokens); + } + + /* + 대기열에서 처리열로 이동 + */ + public void updateWaitingToProcessing(String value) { + zSet.remove(WAITING_QUEUE_KEY, value); + zSet.add(PROCESSING_QUEUE_KEY, value, System.currentTimeMillis() + PROCESSING_TOKEN_EXPIRATION_TIME); + } + + /* + 웨이팅 취소 + */ + public void removeTokenInWaitingQueue(Long userId, String token) { + String value = generateValue(userId, token); + zSet.remove(WAITING_QUEUE_KEY, value); + } + + /* + 유효시간 만료 토큰 삭제 + */ + public void removeExpiredToken(Long currentTime) { + zSet.removeRangeByScore(PROCESSING_QUEUE_KEY, Double.NEGATIVE_INFINITY, (double) currentTime); + zSet.removeRangeByScore(WAITING_QUEUE_KEY, Double.NEGATIVE_INFINITY, (double) currentTime); + } + + /* + 처리 완료된 토큰 삭제 + */ + public void removeProcessingToken(Long userId) { + Set values = zSet.range(PROCESSING_QUEUE_KEY, 0, -1); + + values.stream() + .filter(value -> value.matches(userId + ":.*")) + .findFirst() + .ifPresent(value -> zSet.remove(PROCESSING_QUEUE_KEY, value)); + } + + private String generateValue(Long userId, String token) { + return userId.toString() + ":" + token; + } +} diff --git a/src/main/java/com/example/book_your_seat/queue/scheduler/QueueScheduler.java b/src/main/java/com/example/book_your_seat/queue/scheduler/QueueScheduler.java new file mode 100644 index 0000000..05d5cac --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/scheduler/QueueScheduler.java @@ -0,0 +1,21 @@ +package com.example.book_your_seat.queue.scheduler; + +import com.example.book_your_seat.queue.service.QueueCommandService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class QueueScheduler { + private final QueueCommandService queueCommandService; + + @Scheduled(fixedRate = 30 * 1000) //30초마다 + public void updateWaitingToProcessing() { + queueCommandService.removeExpiredToken(); + queueCommandService.updateWaitingToProcessing(); + log.info("processing queue 가 update 되었습니다."); + } +} diff --git a/src/main/java/com/example/book_your_seat/queue/service/QueueCommandService.java b/src/main/java/com/example/book_your_seat/queue/service/QueueCommandService.java new file mode 100644 index 0000000..c22b219 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/service/QueueCommandService.java @@ -0,0 +1,13 @@ +package com.example.book_your_seat.queue.service; + +public interface QueueCommandService { + String issueTokenAndEnqueue(Long userId); + + void updateWaitingToProcessing(); + + void removeExpiredToken(); + + void removeTokenInWaitingQueue(Long userId, String token); + + void completeProcessingToken(Long userId); +} diff --git a/src/main/java/com/example/book_your_seat/queue/service/QueueCommandServiceImpl.java b/src/main/java/com/example/book_your_seat/queue/service/QueueCommandServiceImpl.java new file mode 100644 index 0000000..9d9c4af --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/service/QueueCommandServiceImpl.java @@ -0,0 +1,86 @@ +package com.example.book_your_seat.queue.service; + +import com.example.book_your_seat.common.util.JwtUtil; +import com.example.book_your_seat.queue.repository.QueueRedisRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static com.example.book_your_seat.queue.QueueConst.ALREADY_ISSUED_USER; +import static com.example.book_your_seat.queue.QueueConst.PROCESSING_QUEUE_SIZE; + +@Service +@RequiredArgsConstructor +public class QueueCommandServiceImpl implements QueueCommandService { + private final QueueRedisRepository queueRedisRepository; + public final JwtUtil jwtUtil; + + /* + 토큰을 발급하고, Waiting Queue에 등록 + */ + public String issueTokenAndEnqueue(Long userId) { + String token = jwtUtil.createJwt(userId); + + checkAlreadyIssuedUser(userId); + + //waiting queue가 비어있고, processing queue가 다 차지 않았으면 바로 processing queue에 넣어주기 + if (queueRedisRepository.isProcessableNow()) { + queueRedisRepository.enqueueProcessingQueue(userId, token); + } else { + queueRedisRepository.enqueueWaitingQueue(userId, token); + } + + return token; + } + + /* + Waiting Queue -> Processing Queue 로 이동시키기 + */ + public void updateWaitingToProcessing() { + int count = calculateAvailableProcessingCount(); + if (count == 0) return; + + List values = queueRedisRepository.getFrontTokensFromWaitingQueue(count); + values.forEach(queueRedisRepository::updateWaitingToProcessing); + } + + /* + 스케줄러 실행 시 만료토큰 제거 + */ + public void removeExpiredToken() { + queueRedisRepository.removeExpiredToken(System.currentTimeMillis()); + } + + /* + 웨이팅 큐에서 삭제 + */ + public void removeTokenInWaitingQueue(Long userId, String token) { + queueRedisRepository.removeTokenInWaitingQueue(userId, token); + } + + /* + 진행 완료된 토큰 제거 + */ + public void completeProcessingToken(Long userId) { + queueRedisRepository.removeProcessingToken(userId); + } + + /* + 이미 토큰이 발급된 유저인지 확인 + */ + private void checkAlreadyIssuedUser(Long userId) { + if (queueRedisRepository.isInWaitingQueue(userId) + || queueRedisRepository.isInProcessingQueue(userId)) { + throw new IllegalArgumentException(ALREADY_ISSUED_USER); + } + } + + /* + update 가능한 queue Size 계산 + */ + private int calculateAvailableProcessingCount() { + int size = queueRedisRepository.getProcessingQueueCount(); + return PROCESSING_QUEUE_SIZE - size; + } +} diff --git a/src/main/java/com/example/book_your_seat/queue/service/QueueQueryService.java b/src/main/java/com/example/book_your_seat/queue/service/QueueQueryService.java new file mode 100644 index 0000000..af06829 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/service/QueueQueryService.java @@ -0,0 +1,8 @@ +package com.example.book_your_seat.queue.service; + +import com.example.book_your_seat.queue.controller.dto.QueueResponse; + +public interface QueueQueryService { + QueueResponse findQueueStatus(Long userId, String token); + +} diff --git a/src/main/java/com/example/book_your_seat/queue/service/QueueQueryServiceImpl.java b/src/main/java/com/example/book_your_seat/queue/service/QueueQueryServiceImpl.java new file mode 100644 index 0000000..4ba0901 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/service/QueueQueryServiceImpl.java @@ -0,0 +1,32 @@ +package com.example.book_your_seat.queue.service; + +import com.example.book_your_seat.queue.controller.dto.QueueResponse; +import com.example.book_your_seat.queue.repository.QueueRedisRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.example.book_your_seat.queue.QueueConst.*; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class QueueQueryServiceImpl implements QueueQueryService { + public final QueueRedisRepository queueRedisRepository; + + /* + 유저의 현재 큐 상태 확인 + */ + public QueueResponse findQueueStatus(Long userId, String token) { + if (queueRedisRepository.isInProcessingQueue(userId)) { + return new QueueResponse(PROCESSING, 0); + } + + Integer position = queueRedisRepository.getWaitingQueuePosition(userId, token); + if (position != null) { + return new QueueResponse(WAITING, position); + } else { + return new QueueResponse(NOT_IN_QUEUE, 0); + } + } +} diff --git a/src/main/java/com/example/book_your_seat/queue/service/facade/QueueService.java b/src/main/java/com/example/book_your_seat/queue/service/facade/QueueService.java new file mode 100644 index 0000000..c274828 --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/service/facade/QueueService.java @@ -0,0 +1,12 @@ +package com.example.book_your_seat.queue.service.facade; + +import com.example.book_your_seat.queue.controller.dto.QueueResponse; +import com.example.book_your_seat.queue.controller.dto.TokenResponse; + + +public interface QueueService { + TokenResponse issueTokenAndEnqueue(Long userId); + void dequeueWaitingQueue(Long userId, String token); + QueueResponse findQueueStatus(Long userId, String token); + void updateWaitingToProcessing(); +} diff --git a/src/main/java/com/example/book_your_seat/queue/service/facade/QueueServiceImpl.java b/src/main/java/com/example/book_your_seat/queue/service/facade/QueueServiceImpl.java new file mode 100644 index 0000000..982f1fb --- /dev/null +++ b/src/main/java/com/example/book_your_seat/queue/service/facade/QueueServiceImpl.java @@ -0,0 +1,38 @@ +package com.example.book_your_seat.queue.service.facade; + +import com.example.book_your_seat.queue.controller.dto.QueueResponse; +import com.example.book_your_seat.queue.controller.dto.TokenResponse; +import com.example.book_your_seat.queue.service.QueueCommandService; +import com.example.book_your_seat.queue.service.QueueQueryService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class QueueServiceImpl implements QueueService { + private final QueueCommandService queueCommandService; + private final QueueQueryService queueQueryService; + + @Override + public TokenResponse issueTokenAndEnqueue(Long userId) { + return new TokenResponse(queueCommandService.issueTokenAndEnqueue(userId)); + } + + @Override + public void dequeueWaitingQueue(Long userId, String token) { + queueCommandService.removeTokenInWaitingQueue(userId, token); + } + + @Override + @Transactional(readOnly = true) + public QueueResponse findQueueStatus(Long userId, String token) { + return queueQueryService.findQueueStatus(userId, token); + } + + @Override + public void updateWaitingToProcessing() { + queueCommandService.updateWaitingToProcessing(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index dbc6d74..040ec24 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,3 +17,7 @@ logging: level: ROOT: INFO org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG + +jwt: + secret: secretsecretsecretkeykeykeykeykeykey + expiration_time: 3600 # 1시간 \ No newline at end of file diff --git a/src/test/java/com/example/book_your_seat/IntegerTestSupport.java b/src/test/java/com/example/book_your_seat/IntegralTestSupport.java similarity index 73% rename from src/test/java/com/example/book_your_seat/IntegerTestSupport.java rename to src/test/java/com/example/book_your_seat/IntegralTestSupport.java index 6069fed..f3245e7 100644 --- a/src/test/java/com/example/book_your_seat/IntegerTestSupport.java +++ b/src/test/java/com/example/book_your_seat/IntegralTestSupport.java @@ -3,7 +3,7 @@ import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -public abstract class IntegerTestSupport { +public abstract class IntegralTestSupport { } diff --git a/src/test/java/com/example/book_your_seat/concurrent/PessimisticTest.java b/src/test/java/com/example/book_your_seat/concurrent/PessimisticTest.java index f20d2b8..4cb1d04 100644 --- a/src/test/java/com/example/book_your_seat/concurrent/PessimisticTest.java +++ b/src/test/java/com/example/book_your_seat/concurrent/PessimisticTest.java @@ -3,7 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import com.example.book_your_seat.IntegerTestSupport; +import com.example.book_your_seat.IntegralTestSupport; import com.example.book_your_seat.coupon.domain.Coupon; import com.example.book_your_seat.coupon.domain.DiscountRate; import com.example.book_your_seat.coupon.facade.CouponCommandFacade; @@ -23,7 +23,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -public class PessimisticTest extends IntegerTestSupport { +public class PessimisticTest extends IntegralTestSupport { @Autowired private CouponCommandFacade couponCommandServiceImpl; diff --git a/src/test/java/com/example/book_your_seat/service/concert/ConcertServiceTest.java b/src/test/java/com/example/book_your_seat/service/concert/ConcertServiceTest.java index 4c3ac9b..8f3c851 100644 --- a/src/test/java/com/example/book_your_seat/service/concert/ConcertServiceTest.java +++ b/src/test/java/com/example/book_your_seat/service/concert/ConcertServiceTest.java @@ -1,6 +1,6 @@ package com.example.book_your_seat.service.concert; -import com.example.book_your_seat.IntegerTestSupport; +import com.example.book_your_seat.IntegralTestSupport; import com.example.book_your_seat.concert.controller.dto.AddConcertRequest; import com.example.book_your_seat.concert.controller.dto.ConcertResponse; import com.example.book_your_seat.concert.service.ConcertCommandService; @@ -14,7 +14,7 @@ import java.time.LocalDateTime; import java.util.List; -class ConcertServiceTest extends IntegerTestSupport { +class ConcertServiceTest extends IntegralTestSupport { @Autowired private ConcertCommandService concertCommandService; diff --git a/src/test/java/com/example/book_your_seat/service/concert/ConcertTest.java b/src/test/java/com/example/book_your_seat/service/concert/ConcertTest.java index 6d55c94..cc0b738 100644 --- a/src/test/java/com/example/book_your_seat/service/concert/ConcertTest.java +++ b/src/test/java/com/example/book_your_seat/service/concert/ConcertTest.java @@ -1,6 +1,6 @@ package com.example.book_your_seat.service.concert; -import com.example.book_your_seat.IntegerTestSupport; +import com.example.book_your_seat.IntegralTestSupport; import com.example.book_your_seat.concert.domain.Concert; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -9,7 +9,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; -class ConcertTest extends IntegerTestSupport { +class ConcertTest extends IntegralTestSupport { // 예매 시작 시간 검증필요.. diff --git a/src/test/java/com/example/book_your_seat/service/coupon/CouponServiceTest.java b/src/test/java/com/example/book_your_seat/service/coupon/CouponServiceTest.java index d926318..65e3a05 100644 --- a/src/test/java/com/example/book_your_seat/service/coupon/CouponServiceTest.java +++ b/src/test/java/com/example/book_your_seat/service/coupon/CouponServiceTest.java @@ -3,7 +3,7 @@ import static com.example.book_your_seat.coupon.CouponConst.ALREADY_ISSUED_USER; import static org.assertj.core.api.Assertions.*; -import com.example.book_your_seat.IntegerTestSupport; +import com.example.book_your_seat.IntegralTestSupport; import com.example.book_your_seat.coupon.controller.dto.CouponCreateRequest; import com.example.book_your_seat.coupon.controller.dto.CouponDetailResponse; import com.example.book_your_seat.coupon.controller.dto.CouponResponse; @@ -25,7 +25,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -public class CouponServiceTest extends IntegerTestSupport { +public class CouponServiceTest extends IntegralTestSupport { @Autowired private CouponCommandService couponCommandService; diff --git a/src/test/java/com/example/book_your_seat/service/queue/QueueServiceTest.java b/src/test/java/com/example/book_your_seat/service/queue/QueueServiceTest.java new file mode 100644 index 0000000..55561a9 --- /dev/null +++ b/src/test/java/com/example/book_your_seat/service/queue/QueueServiceTest.java @@ -0,0 +1,192 @@ +package com.example.book_your_seat.service.queue; + +import com.example.book_your_seat.IntegralTestSupport; +import com.example.book_your_seat.queue.controller.dto.QueueResponse; +import com.example.book_your_seat.queue.service.QueueCommandService; +import com.example.book_your_seat.queue.service.QueueQueryService; +import com.example.book_your_seat.queue.service.facade.QueueService; +import com.example.book_your_seat.user.domain.User; +import com.example.book_your_seat.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import static com.example.book_your_seat.queue.QueueConst.*; +import static org.junit.jupiter.api.Assertions.*; + +public class QueueServiceTest extends IntegralTestSupport { + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private QueueService queueService; + + @Autowired + private QueueCommandService queueCommandService; + + @Autowired + private QueueQueryService queueQueryService; + + private ZSetOperations zSet; + + private User testUser; + + @BeforeEach + void beforeEach() { + User user = new User("nickname", "username", "email@email.com", "password123456789"); + testUser = userRepository.save(user); + zSet = redisTemplate.opsForZSet(); + } + + @AfterEach + void afterEach() { + redisTemplate.getConnectionFactory().getConnection().flushAll(); + } + + @Test + @DisplayName("user는 대기열에 중복 등록할 수 없다.") + void issueTokenAndDuplicateEnqueueTest() { + //given 한 번 등록 + queueCommandService.issueTokenAndEnqueue(testUser.getId()); + + //when 한번 더 등록 + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + queueCommandService.issueTokenAndEnqueue(testUser.getId()); + }); + + //then + assertEquals(ALREADY_ISSUED_USER, exception.getMessage()); + } + + @Test + @DisplayName("토큰을 발급하고 진행열이 다 차지 않은 경우 진행열에 넣는다.") + void issueTokenAndEnqueueProcessingQueueTest() { + //when + String token = queueService.issueTokenAndEnqueue(testUser.getId()).token(); + + //then + String value = testUser.getId() + ":" + token; + assertNotNull(zSet.score(PROCESSING_QUEUE_KEY, value)); + assertEquals(1L, zSet.zCard(PROCESSING_QUEUE_KEY)); + } + + @Test + @DisplayName("토큰을 발급하고 진행열이 다 찬 경우 대기열에 넣는다.") + void issueTokenAndEnqueueWaitingQueueTest() { + //given + for (int i = 1000; i < 2000; i++) { + queueCommandService.issueTokenAndEnqueue((long) i); + } + + //when + String token = queueService.issueTokenAndEnqueue(testUser.getId()).token(); + + //then + String value = testUser.getId() + ":" + token; + assertNotNull(zSet.score(WAITING_QUEUE_KEY, value)); + assertEquals(1, zSet.zCard(WAITING_QUEUE_KEY)); + } + + @Test + @DisplayName("대기열을 탈출한다.") + void dequeueWaitingQueueTest() { + //given + for (int i = 1000; i < 2100; i++) { + queueCommandService.issueTokenAndEnqueue((long) i); + } + + String token = queueService.issueTokenAndEnqueue(testUser.getId()).token(); + + //when + queueService.dequeueWaitingQueue(testUser.getId(), token); + + //then + String value = testUser.getId().toString() + ":" + token; + assertNull(zSet.score(WAITING_QUEUE_KEY, value)); + assertEquals(100, zSet.zCard(WAITING_QUEUE_KEY)); + + } + + @Test + @DisplayName("현재 나의 대기열 상태를 조회한다.(진행열에 들어온 상황)") + void findQueueStatusTest1() { + //given + String token = queueService.issueTokenAndEnqueue(testUser.getId()).token(); + + //when + QueueResponse queueResponse = queueService.findQueueStatus(testUser.getId(), token); + + //then + assertEquals(PROCESSING, queueResponse.status()); + assertEquals(0, queueResponse.waitingQueueCount()); + } + + @Test + @DisplayName("현재 나의 대기열 상태를 조회한다.(대기열에 있는 상황)") + void findQueueStatusTest2() { + //given + for (int i = 1000; i < 2100; i++) { + queueCommandService.issueTokenAndEnqueue((long) i); + } + + String token = queueService.issueTokenAndEnqueue(testUser.getId()).token(); + + //when + QueueResponse queueResponse = queueQueryService.findQueueStatus(testUser.getId(), token); + + //then + assertEquals(WAITING, queueResponse.status()); + assertEquals(101, queueResponse.waitingQueueCount()); + } + + @Test + @DisplayName("API 수행 완료 시 진행열에서 탈출한다.") + void completeProcessingTokenTest() { + //given + for (int i = 1000; i < 1500; i++) { + queueCommandService.issueTokenAndEnqueue((long) i); + } + + //when + queueCommandService.issueTokenAndEnqueue(testUser.getId()); + + //then + assertEquals(501, zSet.zCard(PROCESSING_QUEUE_KEY)); + + //when + queueCommandService.completeProcessingToken(testUser.getId()); + + //then + assertEquals(500, zSet.zCard(PROCESSING_QUEUE_KEY)); + } + + @Test + @DisplayName("스케줄러 실행 시 대기열 -> 진행열로 변환된다.") + void updateWaitingToProcessingTest() { + //given + for (int i = 1000; i <= 2500; i++) { + queueCommandService.issueTokenAndEnqueue((long) i); + } + + queueCommandService.issueTokenAndEnqueue(testUser.getId()); + + //when 500개 완료했을 때 스케줄러를 실행하면 + for (int i = 1000; i <= 1500; i++) { + queueCommandService.completeProcessingToken((long) i); + } + + queueCommandService.updateWaitingToProcessing(); + + //then 대기열에서 진행열으로 500개 당겨져와야한다. + assertEquals(zSet.zCard(PROCESSING_QUEUE_KEY),1000); + assertEquals(zSet.zCard(WAITING_QUEUE_KEY),1); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 151fcd7..3f09d0d 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,7 +1,12 @@ spring: datasource: url: jdbc:h2:mem:testdb;MODE=MYSQL;NON_KEYWORDS=USER + redis: host: localhost port: 6379 - password: \ No newline at end of file + password: + +jwt: + secret: secretsecretsecretkeykeykeykeykeykey + expiration_time: 3600 # 1시간 \ No newline at end of file