diff --git a/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java b/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java new file mode 100644 index 00000000..1a282f21 --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/config/AsyncConfig.java @@ -0,0 +1,33 @@ +package com.jishop.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@EnableAsync +@Configuration +@EnableScheduling +public class AsyncConfig { + + @Bean + public Executor taskExecutor() { + // Spring 제공 스레드 풀 정의 + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + // 기본 스레드 수(유지되는 최소 스레드 수) + executor.setCorePoolSize(5); + // 최대 스레드 수(동시 실행 가능한 스레드 수) + executor.setMaxPoolSize(10); + // 작업을 큐에 최대로 대기시킬수 있는 허용량 + executor.setQueueCapacity(100); + // 생성된 스레드 이름 + executor.setThreadNamePrefix("TaskExecutor-"); + // 쓰레드 풀 초기화 후 리턴 + executor.initialize(); + return executor; + } +} + diff --git a/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java b/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java index 1b7c5b52..0a8d16c8 100644 --- a/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java +++ b/backend/JiShop/src/main/java/com/jishop/config/RedisConfig.java @@ -44,14 +44,6 @@ public ObjectMapper redisObjectMapper() { return mapper; } - @Bean - public FilterRegistrationBean> redisSessionFilterRegistration(SessionRepositoryFilter sessionRepositoryFilter) { - FilterRegistrationBean> registrationBean = new FilterRegistrationBean<>(sessionRepositoryFilter); - // /auth/* 패턴에만 Redis 세션 저장소 적용 (즉, 로그인 관련 요청에만) - registrationBean.setUrlPatterns(Arrays.asList("/auth/*")); - return registrationBean; - } - // 인기 검색어, RedisTemplate 빈 등록 @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { diff --git a/backend/JiShop/src/main/java/com/jishop/config/WebConfig.java b/backend/JiShop/src/main/java/com/jishop/config/WebConfig.java index da886379..083d941b 100644 --- a/backend/JiShop/src/main/java/com/jishop/config/WebConfig.java +++ b/backend/JiShop/src/main/java/com/jishop/config/WebConfig.java @@ -64,23 +64,13 @@ public CookieSerializer cookieSerializer() { return serializer; } - // todo: 추후 반영 결정해야할 사항 (3/23) - /* @Bean - public FilterRegistrationBean securityHeadersFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new SecurityHeadersFilter()); - registrationBean.addUrlPatterns("/*"); - registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); - return registrationBean; - }*/ - - // todo: 추후 반영 결정해야할 사항 (3/23) - /*@Bean - public FilterRegistrationBean csrfFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new CsrfFilter()); - registrationBean.addUrlPatterns("/*"); - registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 1); - return registrationBean; - }*/ - } \ No newline at end of file + // todo: 추후 반영 결정해야할 사항 (3/23) + /*@Bean + public FilterRegistrationBean csrfFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new CsrfFilter()); + registrationBean.addUrlPatterns("/*"); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 1); + return registrationBean; + }*/ +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/member/controller/AuthController.java b/backend/JiShop/src/main/java/com/jishop/member/controller/AuthController.java index f2257290..d7f4644d 100644 --- a/backend/JiShop/src/main/java/com/jishop/member/controller/AuthController.java +++ b/backend/JiShop/src/main/java/com/jishop/member/controller/AuthController.java @@ -10,11 +10,13 @@ import jakarta.servlet.http.HttpSession; import org.springframework.http.ResponseEntity; +import java.util.concurrent.ExecutionException; + @Tag(name = "로컬 로그인 API") public interface AuthController { @Operation(summary = "로컬 회원 로그인") - ResponseEntity signIn(SignInFormRequest request, HttpServletRequest httpRequest, - HttpServletResponse response); + ResponseEntity signIn(SignInFormRequest request, HttpServletRequest httpRequest, + HttpServletResponse response) throws ExecutionException, InterruptedException; @Operation(summary = "회원 로그아웃") ResponseEntity logout(User user,HttpServletRequest request); @Operation(summary = "로그인 상태 체크") diff --git a/backend/JiShop/src/main/java/com/jishop/member/controller/impl/AuthControllerImpl.java b/backend/JiShop/src/main/java/com/jishop/member/controller/impl/AuthControllerImpl.java index 00bf828b..3439ff67 100644 --- a/backend/JiShop/src/main/java/com/jishop/member/controller/impl/AuthControllerImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/member/controller/impl/AuthControllerImpl.java @@ -5,16 +5,18 @@ import com.jishop.member.domain.User; import com.jishop.member.dto.request.SignInFormRequest; import com.jishop.member.service.AuthService; +import com.jishop.queue.service.QueueService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.UUID; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; @RestController @RequiredArgsConstructor @@ -22,26 +24,36 @@ public class AuthControllerImpl implements AuthController { private final AuthService service; + private final QueueService queueService; @PostMapping("/signin") - public ResponseEntity signIn(@RequestBody @Valid SignInFormRequest request, + public ResponseEntity signIn(@RequestBody @Valid SignInFormRequest request, HttpServletRequest httpRequest, - HttpServletResponse httpServletResponse) { - // todo: IP 주소 로깅을 해야할까?? - // String clientIp = getClientIp(httpRequest); + HttpServletResponse httpServletResponse) throws ExecutionException, InterruptedException { + // todo: IP 주소 로깅을 해야할까?? -> 해결책 다른 나라, 일단 다른 ip인 경우 해당 하면 알림 보내기?(4/17) - HttpSession session = httpRequest.getSession(); - service.signIn(request, session); + try { + // 요청 처리 시작 시 카운터 증가 + queueService.incrementActiveRequests(); - Long userId = (Long) session.getAttribute("userId"); - String welcomeMessage = service.loginStr(userId); + HttpSession session = httpRequest.getSession(); + CompletableFuture future = service.signInType(request, session); + String taskId = future.get(); - // CSRF 토큰 생성 및 응답 헤더에 포함 - String csrfToken = UUID.randomUUID().toString(); - session.setAttribute("CSRF_TOKEN", csrfToken); - httpServletResponse.setHeader("X-CSRF-TOKEN", csrfToken); + if(taskId.startsWith("immediate-login-")){ + Long userId =(Long) session.getAttribute("userId"); + String welcome = service.loginStr(userId); + return ResponseEntity.ok(welcome); + } - return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(welcomeMessage); + return ResponseEntity.accepted().body(Map.of( + "taskId", taskId, + "message", "로그인 요청이 대기열에 등록되었습니다." + )); + } finally { + // 요청 처리 완료 시 카운터 감소 (예외 발생해도 실행됨) + queueService.decrementActiveRequests(); + } } @GetMapping("/logout") @@ -58,7 +70,7 @@ public ResponseEntity logout(@CurrentUser User user, HttpServletRequest re @GetMapping() public ResponseEntity checkLogin(@CurrentUser User user) { Long id = service.checkLogin(user); - if(id == null) return ResponseEntity.badRequest().body("로그인 타임 종료!"); + if(id == null) return ResponseEntity.badRequest().body("로그아웃!"); return ResponseEntity.ok("로그인 중!"); } } \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/member/filter/SecurityHeadersFilter.java b/backend/JiShop/src/main/java/com/jishop/member/filter/SecurityHeadersFilter.java deleted file mode 100644 index a143a1b6..00000000 --- a/backend/JiShop/src/main/java/com/jishop/member/filter/SecurityHeadersFilter.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.jishop.member.filter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -public class SecurityHeadersFilter extends OncePerRequestFilter { - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - response.setHeader("X-Content-Type-Options", "nosniff"); - response.setHeader("X-Frame-Options", "DENY"); - response.setHeader("X-XSS-Protection", "1; mode=block"); - response.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); - - // 캐시 제어 - API 응답은 캐싱하지 않음 - response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); - response.setHeader("Pragma", "no-cache"); - response.setHeader("Expires", "0"); - - // 소셜 로그인 페이지인 경우 CSP를 완화하거나 적용하지 않음 - String requestURI = request.getRequestURI(); - if (requestURI.contains("/oauth/login") || requestURI.contains("/oauth2/authorization") || - requestURI.contains("/login") || requestURI.endsWith(".html")) { - // 소셜 로그인 관련 페이지에는 CSP 적용 안함 - filterChain.doFilter(request, response); - return; - } - - String csp = "default-src 'self' *; " + - "script-src 'self' 'unsafe-inline' 'unsafe-eval' *; " + - "style-src 'self' 'unsafe-inline' *; " + - "img-src 'self' data: *; " + - "connect-src 'self' *; " + - "frame-src 'self' *; " + - "font-src 'self' *; " + - "object-src 'none'; " + - "base-uri 'self'; " + - "form-action 'self' *;"; - - response.setHeader("Content-Security-Policy", csp); - response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); - - filterChain.doFilter(request, response); - } -} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/member/service/AuthService.java b/backend/JiShop/src/main/java/com/jishop/member/service/AuthService.java index 3bb8b501..11a17597 100644 --- a/backend/JiShop/src/main/java/com/jishop/member/service/AuthService.java +++ b/backend/JiShop/src/main/java/com/jishop/member/service/AuthService.java @@ -5,6 +5,8 @@ import com.jishop.member.dto.response.UserResponse; import jakarta.servlet.http.HttpSession; +import java.util.concurrent.CompletableFuture; + public interface AuthService { void signIn(SignInFormRequest form, HttpSession session); @@ -20,4 +22,6 @@ public interface AuthService { void updateAdSMSAgree(User user, UserAdSMSRequest request); void updateAdEmailAgree(User user, UserAdEmailRequest request); void logout(User user); + CompletableFuture signInType(SignInFormRequest request, HttpSession session); + User attemptLogin(SignInFormRequest form); } diff --git a/backend/JiShop/src/main/java/com/jishop/member/service/impl/AuthServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/member/service/impl/AuthServiceImpl.java index 3d5d01dc..06955289 100644 --- a/backend/JiShop/src/main/java/com/jishop/member/service/impl/AuthServiceImpl.java +++ b/backend/JiShop/src/main/java/com/jishop/member/service/impl/AuthServiceImpl.java @@ -7,6 +7,9 @@ import com.jishop.member.dto.response.UserResponse; import com.jishop.member.repository.UserRepository; import com.jishop.member.service.AuthService; +import com.jishop.queue.domain.TaskType; +import com.jishop.queue.service.QueueService; +import com.jishop.queue.service.TaskProducer; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; @@ -14,6 +17,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + import java.time.Duration; import java.util.function.Consumer; @@ -22,8 +29,10 @@ @RequiredArgsConstructor public class AuthServiceImpl implements AuthService { - private final UserRepository userRepository; + private final QueueService queueService; private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; + private final TaskProducer taskProducer; private final RedisTemplate redisTemplate; @Override @@ -134,5 +143,33 @@ public void logout(User user) { String cacheKey = "user::" + user.getId(); redisTemplate.delete(cacheKey); } + + public CompletableFuture signInType(SignInFormRequest request, HttpSession session) { + if (queueService.useQueue()) { + Map payload = Map.of( + "loginId", request.loginId(), + "password", request.password(), + "sessionId", session.getId() // "sessionId"로 키 이름 통일 + ); + return taskProducer.submitTask(TaskType.LOGIN, payload); + } else { + // 대기열 사용하지 않을 때는 즉시 로그인 처리 + this.signIn(request, session); + return CompletableFuture.completedFuture("immediate-login-" + UUID.randomUUID()); + } + } + + // 대기열을 위한 메서드 추가 + public User attemptLogin(SignInFormRequest form) { + User user = userRepository.findByLoginId(form.loginId()) + .orElseThrow(() -> new DomainException(ErrorType.USER_NOT_FOUND)); + + if(!passwordEncoder.matches(form.password(), user.getPassword())) { + throw new DomainException(ErrorType.USER_NOT_FOUND); + } + if(user.isDeleteStatus()) throw new DomainException(ErrorType.USER_NOT_FOUND); + + return user; + } } diff --git a/backend/JiShop/src/main/java/com/jishop/queue/controller/TaskController.java b/backend/JiShop/src/main/java/com/jishop/queue/controller/TaskController.java new file mode 100644 index 00000000..a57411a2 --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/controller/TaskController.java @@ -0,0 +1,11 @@ +package com.jishop.queue.controller; + +import com.jishop.queue.dto.TaskRequest; +import org.springframework.http.ResponseEntity; + +public interface TaskController { + + ResponseEntity addTask(TaskRequest request); + ResponseEntity getTaskStatus(String taskId); + ResponseEntity getQueueStatus(); +} diff --git a/backend/JiShop/src/main/java/com/jishop/queue/controller/TaskControllerImpl.java b/backend/JiShop/src/main/java/com/jishop/queue/controller/TaskControllerImpl.java new file mode 100644 index 00000000..2b22c42b --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/controller/TaskControllerImpl.java @@ -0,0 +1,61 @@ +package com.jishop.queue.controller; + +import com.jishop.queue.domain.Task; +import com.jishop.queue.dto.TaskRequest; +import com.jishop.queue.service.QueueService; +import com.jishop.queue.service.TaskProducer; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/pend") +public class TaskControllerImpl { + + private final TaskProducer taskProducer; + private final QueueService queueService; + + // 작업 추가 + @PostMapping("/tasks") + public ResponseEntity addTask(@RequestBody TaskRequest request) { + try{ + CompletableFuture future = taskProducer.submitTask( + request.type(), request.payload()); + + if(queueService.getQueueSize()> 300) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("taskId", future.get(), "status", "queued", + "message", "잠시 후 다시 시도해주세요!")); + } + return ResponseEntity.accepted() + .body(Map.of("taskId", future.get(), "status", "대기열 등록 완료")); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", e.getMessage())); + } + } + + // 작업 상태 조회 + @GetMapping("/tasks/{taskId}/status") + public ResponseEntity getTaskStatus(@PathVariable String taskId) { + Task task = queueService.getTaskById(taskId); + if(task == null){ + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("message", "해당 작업을 찾을 수 없습니다!")); + } + return ResponseEntity.ok(Map.of("taskId", taskId, "status", task.getStatus())); + } + + // 큐 상태 조회 + @GetMapping("/queue/status") + public ResponseEntity getQueueStatus() { + return ResponseEntity.ok(Map.of("queueSize", queueService.getQueueSize(), + "timestamp", LocalDateTime.now())); + } +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/queue/domain/Task.java b/backend/JiShop/src/main/java/com/jishop/queue/domain/Task.java new file mode 100644 index 00000000..3c4ac42a --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/domain/Task.java @@ -0,0 +1,53 @@ +package com.jishop.queue.domain; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Task implements Serializable { + + private String id; + // 작업 유형 + private TaskType type; + // 작업에 필요한 데이터 + private Map payload; + // 작업 생성된 시간 + private LocalDateTime createdAt; + // 작업 실패 시 재시도 횟수 카운트 + private int retryCount; + // 작업 상태 (PENDING, RETRY, FAILED, DONE) + private String status; + + private Task(TaskType type, Map payload) { + this.id = UUID.randomUUID().toString(); + this.type = type; + this.payload = payload; + this.createdAt = LocalDateTime.now(); + this.retryCount = 0; + this.status = "PENDING"; + } + + public void markAsRetry() { + this.retryCount++; + this.status = "RETRY"; + } + + public void markAsFailed() { + this.status = "FAILED"; + } + + public void markAsDone() { + this.status = "DONE"; + } + + public static Task of(TaskType type, Map payload) { + return new Task(type, payload); + } +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/queue/domain/TaskType.java b/backend/JiShop/src/main/java/com/jishop/queue/domain/TaskType.java new file mode 100644 index 00000000..b04b1167 --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/domain/TaskType.java @@ -0,0 +1,5 @@ +package com.jishop.queue.domain; + +public enum TaskType { + LOGIN, PAYMENTS +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/queue/dto/TaskRequest.java b/backend/JiShop/src/main/java/com/jishop/queue/dto/TaskRequest.java new file mode 100644 index 00000000..03b4ab6a --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/dto/TaskRequest.java @@ -0,0 +1,11 @@ +package com.jishop.queue.dto; + +import com.jishop.queue.domain.TaskType; + +import java.util.Map; + +public record TaskRequest( + TaskType type, + Map payload +) { +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/queue/service/QueueService.java b/backend/JiShop/src/main/java/com/jishop/queue/service/QueueService.java new file mode 100644 index 00000000..683c8129 --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/service/QueueService.java @@ -0,0 +1,19 @@ +package com.jishop.queue.service; + +import com.jishop.queue.domain.Task; + +import java.util.List; + +public interface QueueService { + + String enqueueTask(Task task); + Task dequeueTask(); + void completeTask(Task task); + void failTask(Task task); + Long getQueueSize(); + Task getTaskById(String taskId); + void incrementActiveRequests(); + void decrementActiveRequests(); + int getCurrentActiveRequests(); + boolean useQueue(); +} diff --git a/backend/JiShop/src/main/java/com/jishop/queue/service/QueueServiceImpl.java b/backend/JiShop/src/main/java/com/jishop/queue/service/QueueServiceImpl.java new file mode 100644 index 00000000..7fdf2a2f --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/service/QueueServiceImpl.java @@ -0,0 +1,113 @@ +package com.jishop.queue.service; + +import com.jishop.queue.domain.Task; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +@Service +@RequiredArgsConstructor +public class QueueServiceImpl implements QueueService { + + private static final String TASK_QUEUE = "taskQueue"; + private static final String PROCESSING_SET = "taskProcessing"; + private static final String DEAD_LETTER_QUEUE = "taskDeadLetter"; + + private final RedisTemplate redisTemplate; + + // 동시 접속자수 카운트 모니터링을 위한 추가 + private final AtomicInteger activeRequests = new AtomicInteger(0); + + + // 작업을 우선순위 큐에 추가 + public String enqueueTask(Task task) { + try { + redisTemplate.opsForList().rightPush(TASK_QUEUE, task); // 맨 뒤 삽입 + return task.getId(); + } catch (Exception e) { + throw new RuntimeException("큐 추가 실패!", e); + } + } + + // 가장 오래된 작업부터 추가 진행 + public Task dequeueTask(){ + Object obj = redisTemplate.opsForList().leftPop(TASK_QUEUE); + if (obj != null) { + Task task = (Task) obj; + redisTemplate.opsForSet().add(PROCESSING_SET, task); + return task; + } + + return null; + } + + // 작업이 완료 + public void completeTask(Task task){ + redisTemplate.opsForSet().remove(PROCESSING_SET, task); + task.markAsDone(); + } + + // 작업 실패 + public void failTask(Task task){ + redisTemplate.opsForSet().remove(PROCESSING_SET, task); + if(task.getRetryCount()<3) { + task.markAsRetry(); + redisTemplate.opsForList().rightPush(TASK_QUEUE, task); + } else { + task.markAsFailed(); + redisTemplate.opsForList().rightPush(DEAD_LETTER_QUEUE, task); + } + } + + // 현재 대기 중인 작업 수 check + public Long getQueueSize(){ + return redisTemplate.opsForList().size(TASK_QUEUE); + } + + + public Task getTaskById(String taskId){ + // 1. 처리 중 작업 조회 + Set processingTasks = redisTemplate.opsForSet().members(PROCESSING_SET); + if (processingTasks != null) { + for (Object obj : processingTasks) { + Task task = (Task) obj; + if (task.getId().equals(taskId)) return task; + } + } + + // 2. 대기 중 작업 조회 (List) + List queuedTasks = redisTemplate.opsForList().range(TASK_QUEUE, 0, -1); + if (queuedTasks != null) { + for (Object obj : queuedTasks) { + Task task = (Task) obj; + if (task.getId().equals(taskId)) return task; + } + } + + return null; + } + + // 동시 요청 수 증가 메서드 + public void incrementActiveRequests() { + activeRequests.incrementAndGet(); + } + + // 동시 요청 수 감소 메서드 + public void decrementActiveRequests() { + activeRequests.decrementAndGet(); + } + + // 현재 활성 요청 수 조회 메서드 + public int getCurrentActiveRequests() { + return activeRequests.get(); + } + + // 부하에 따른 로직 결정 + public boolean useQueue(){ + return getCurrentActiveRequests() > 50; + } +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/queue/service/TaskConsumer.java b/backend/JiShop/src/main/java/com/jishop/queue/service/TaskConsumer.java new file mode 100644 index 00000000..cdf4ba3b --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/service/TaskConsumer.java @@ -0,0 +1,8 @@ +package com.jishop.queue.service; + +import com.jishop.queue.domain.Task; + +public interface TaskConsumer { + + void processNextTask(); +} diff --git a/backend/JiShop/src/main/java/com/jishop/queue/service/TaskConsumerImpl.java b/backend/JiShop/src/main/java/com/jishop/queue/service/TaskConsumerImpl.java new file mode 100644 index 00000000..3530ce69 --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/service/TaskConsumerImpl.java @@ -0,0 +1,80 @@ +package com.jishop.queue.service; + +import com.jishop.member.domain.User; +import com.jishop.member.dto.request.SignInFormRequest; +import com.jishop.member.service.AuthService; +import com.jishop.queue.domain.Task; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskConsumerImpl implements TaskConsumer { + + private final AuthService authService; + private final QueueService queueService; + private final RedisTemplate redisTemplate; + + // 작업 유형별 실제 처리 + private void processTaskByType(Task task) { + switch (task.getType()) { + case LOGIN -> handleLogin(task); + case PAYMENTS -> handlePayment(task); + default -> throw new UnsupportedOperationException("지원하지 않는 작업 유형: " + task.getType()); + } + } + + // 로그인 처리 + private void handleLogin(Task task) { + String loginId = (String) task.getPayload().get("loginId"); + String password = (String) task.getPayload().get("password"); + String sessionId = (String) task.getPayload().get("sessionId"); + + try { + // AuthService에 새 메서드 추가 필요 + User user = authService.attemptLogin(new SignInFormRequest(loginId, password)); + + // Redis에 직접 세션 데이터 저장 + String sessionKey = "spring:session:sessions:" + sessionId; + redisTemplate.opsForHash().put(sessionKey, "sessionAttr:userId", user.getId()); + + // 세션 만료 시간 설정 + redisTemplate.expire(sessionKey, 3600, TimeUnit.SECONDS); + + log.info("사용자 {} 로그인 성공", user.getId()); + } catch (Exception e) { + log.error("로그인 실패: {}", e.getMessage(), e); + throw new RuntimeException("로그인 처리 중 오류 발생", e); + } + } + + // todo: 결제 처리 추가 예정 (4/17) + private void handlePayment(Task task) { + // todo: 결제 로직 대기열 붙이기 + String email = (String) task.getPayload().get("email"); + } + + // 작업 하나를 처리 + @Scheduled(fixedRate = 1000) + public void processNextTask() { + Task task = queueService.dequeueTask(); + + if(task == null) return; + + try { + log.info("작업 처리 중: {}", task.getId()); + processTaskByType(task); + queueService.completeTask(task); + log.info("작업 완료: {}", task.getId()); + } catch (Exception e) { + log.error("작업 실패: {}", task.getId(), e); + queueService.failTask(task); + } + } +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/queue/service/TaskProducer.java b/backend/JiShop/src/main/java/com/jishop/queue/service/TaskProducer.java new file mode 100644 index 00000000..03ff0d55 --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/service/TaskProducer.java @@ -0,0 +1,11 @@ +package com.jishop.queue.service; + +import com.jishop.queue.domain.TaskType; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public interface TaskProducer { + + CompletableFuture submitTask(TaskType type, Map payload/*, int priority*/); +} \ No newline at end of file diff --git a/backend/JiShop/src/main/java/com/jishop/queue/service/TaskProducerImpl.java b/backend/JiShop/src/main/java/com/jishop/queue/service/TaskProducerImpl.java new file mode 100644 index 00000000..8ff525fe --- /dev/null +++ b/backend/JiShop/src/main/java/com/jishop/queue/service/TaskProducerImpl.java @@ -0,0 +1,25 @@ +package com.jishop.queue.service; + +import com.jishop.queue.domain.Task; +import com.jishop.queue.domain.TaskType; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +public class TaskProducerImpl implements TaskProducer { + + private final QueueService queueService; + + // 생산자 -> 외부 작업 요청 들어올시 Task 객체 만들고 Redis 큐에 등록 + @Async + public CompletableFuture submitTask(TaskType type, Map payload/*, int priority*/){ + Task task = Task.of(type, payload/*, priority*/); + String taskId = queueService.enqueueTask(task); + return CompletableFuture.completedFuture(taskId); + } +} \ No newline at end of file diff --git a/backend/JiShop/src/main/resources/config b/backend/JiShop/src/main/resources/config index 04f54a4a..021596b8 160000 --- a/backend/JiShop/src/main/resources/config +++ b/backend/JiShop/src/main/resources/config @@ -1 +1 @@ -Subproject commit 04f54a4aa1734aeeab6c21bb705b1568e2c39788 +Subproject commit 021596b831341ef8d31bd9fb8d084af15609392b diff --git a/backend/JiShop/src/main/resources/login-test.js b/backend/JiShop/src/main/resources/login-test.js new file mode 100644 index 00000000..408d7f43 --- /dev/null +++ b/backend/JiShop/src/main/resources/login-test.js @@ -0,0 +1,92 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.1/index.js'; +import { Counter } from 'k6/metrics'; + +// 각 응답 유형별 카운터 (모든 VU의 결과가 집계됨) +export let immediateLogins = new Counter('immediate_logins'); +export let queueLogins = new Counter('queue_logins'); +export let failures = new Counter('failures'); + +export let options = { + stages: [ + { duration: '30s', target: 10 }, // 초기 낮은 부하 + { duration: '1m', target: 1000 }, // 부하 증가 (대기열 사용 기대) + { duration: '30s', target: 10 }, // 부하 감소 (쿨다운) + ], +}; + +export default function () { + let userNumber = Math.floor(Math.random() * 100) + 1; + let loginId = `qeqe${userNumber}@qeqe.com`; + + let payload = JSON.stringify({ + loginId: loginId, + password: "aaaa1111₩" + }); + + let params = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + // 로그인 API 요청 전송 + let res = http.post('http://localhost:8080/auth/signin', payload, params); + + // 응답 상태에 따라 카운터 업데이트 및 로그 출력 + if (res.status === 200) { + immediateLogins.add(1); + console.log(`[VU: ${__VU}, ITER: ${__ITER}] ✅ [${res.status}] 즉시 로그인 성공 | user: ${loginId}`); + } else if (res.status === 202) { + queueLogins.add(1); + console.log(`[VU: ${__VU}, ITER: ${__ITER}] 🚧 [${res.status}] 대기열 진입 | user: ${loginId}`); + } else { + failures.add(1); + console.log(`[VU: ${__VU}, ITER: ${__ITER}] ❌ [${res.status}] 알 수 없는 응답 | user: ${loginId}`); + } + + // 응답이 200 또는 202인지 검사 + check(res, { + 'status is 200 or 202': (r) => r.status === 200 || r.status === 202, + }); + + sleep(1); +} + +// 테스트 종료 후 커스텀 요약 리포트를 생성 +export function handleSummary(data) { + // bracket 표기법으로 내장 메트릭 값을 안전하게 읽음 + let totalRequests = data.metrics["http_reqs"] ? data.metrics["http_reqs"].count : 0; + let immediateLoginCount = data.metrics["immediate_logins"] ? data.metrics["immediate_logins"].count : 0; + let queueLoginCount = data.metrics["queue_logins"] ? data.metrics["queue_logins"].count : 0; + let failureCount = data.metrics["failures"] ? data.metrics["failures"].count : 0; + let p50 = data.metrics["http_req_duration"] ? data.metrics["http_req_duration"]["p(50)"] : 'undefined'; + let p95 = data.metrics["http_req_duration"] ? data.metrics["http_req_duration"]["p(95)"] : 'undefined'; + let p99 = data.metrics["http_req_duration"] ? data.metrics["http_req_duration"]["p(99)"] : 'undefined'; + + let defaultSummary = textSummary(data, { indent: ' ', enableColors: true }); + + let custom = ` +=========================== + 💻 TEST SUMMARY +=========================== +총 요청 수: ${totalRequests} + - 즉시 로그인(200): ${immediateLoginCount} + - 대기열 진입(202): ${queueLoginCount} + - 기타 실패: ${failureCount} + +** 응답 시간 분포 ** + - p(50): ${p50} ms + - p(95): ${p95} ms + - p(99): ${p99} ms + +기본 K6 요약: +${defaultSummary} +`; + + return { + stdout: custom, + // 필요하다면 파일로도 저장 가능: 'result.json': JSON.stringify(data), + }; +} \ No newline at end of file diff --git a/backend/JiShop/src/main/resources/summary.json b/backend/JiShop/src/main/resources/summary.json new file mode 100644 index 00000000..1e9cfbfb --- /dev/null +++ b/backend/JiShop/src/main/resources/summary.json @@ -0,0 +1,164 @@ +{ + "root_group": { + "name": "", + "path": "", + "id": "d41d8cd98f00b204e9800998ecf8427e", + "groups": {}, + "checks": { + "status is 200 or 202": { + "name": "status is 200 or 202", + "path": "::status is 200 or 202", + "id": "7f512a616e0b85e628ce0b92fc0914ad", + "passes": 10166, + "fails": 108 + }, + "logged in successfully": { + "path": "::logged in successfully", + "id": "136f596a51a4e9d3ea26d90b7909acc4", + "passes": 10166, + "fails": 0, + "name": "logged in successfully" + }, + "logout successful": { + "name": "logout successful", + "path": "::logout successful", + "id": "fcd37ea5fe87ad8dc41f51c3d4ba88b4", + "passes": 10166, + "fails": 0 + } + } + }, + "metrics": { + "http_req_tls_handshaking": { + "med": 0, + "max": 0, + "p(90)": 0, + "p(95)": 0, + "avg": 0, + "min": 0 + }, + "iterations": { + "count": 10274, + "rate": 33.86392239293432 + }, + "http_req_receiving": { + "max": 13.419, + "p(90)": 0.068, + "p(95)": 0.089, + "avg": 0.044133111154674214, + "min": 0.006, + "med": 0.033 + }, + "iteration_duration": { + "avg": 4059.7614662656074, + "min": 2002.260416, + "med": 4074.0037085, + "max": 4286.133208, + "p(90)": 4103.5783749, + "p(95)": 4119.133629049999 + }, + "http_req_failed": { + "passes": 108, + "fails": 30498, + "thresholds": { + "rate<0.1": false + }, + "value": 0.0035287198588512055 + }, + "http_req_sending": { + "p(95)": 0.044, + "avg": 0.022694014245571242, + "min": 0.002, + "med": 0.016, + "max": 9.582, + "p(90)": 0.032 + }, + "successful_logins": { + "count": 10166, + "rate": 33.50794579001074 + }, + "failed_logins": { + "count": 108, + "rate": 0.35597660292358446 + }, + "http_req_duration": { + "avg": 26.41542187806299, + "min": 0.438, + "med": 1.655, + "max": 281.924, + "p(90)": 71.3735, + "p(95)": 81.00099999999999, + "thresholds": { + "p(95)<5000": false + } + }, + "checks": { + "passes": 30498, + "fails": 108, + "value": 0.9964712801411488 + }, + "data_received": { + "count": 8134623, + "rate": 26812.365385222754 + }, + "dropped_iterations": { + "count": 9, + "rate": 0.029664716910298705 + }, + "http_req_blocked": { + "min": 0, + "med": 0.004, + "max": 9.277, + "p(90)": 0.009, + "p(95)": 0.013, + "avg": 0.012438410769126192 + }, + "http_reqs": { + "count": 30606, + "rate": 100.87981397295579 + }, + "vus": { + "value": 1, + "min": 1, + "max": 304 + }, + "login_response_time": { + "passes": 10274, + "fails": 0, + "value": 1 + }, + "data_sent": { + "count": 5215167, + "rate": 17189.605854992416 + }, + "http_req_duration{expected_response:true}": { + "p(95)": 81.02939999999998, + "avg": 26.482400550855658, + "min": 0.438, + "med": 1.649, + "max": 281.924, + "p(90)": 71.3899 + }, + "http_req_waiting": { + "avg": 26.348594752662937, + "min": 0.415, + "med": 1.594, + "max": 281.87, + "p(90)": 71.2685, + "p(95)": 80.94199999999998 + }, + "http_req_connecting": { + "avg": 0.004677122132915115, + "min": 0, + "med": 0, + "max": 7.439, + "p(90)": 0, + "p(95)": 0 + }, + "vus_max": { + "value": 309, + "min": 300, + "max": 309 + } + } +} \ No newline at end of file diff --git a/backend/log/src/main/resources/config b/backend/log/src/main/resources/config index 255adddc..b8c5de61 160000 --- a/backend/log/src/main/resources/config +++ b/backend/log/src/main/resources/config @@ -1 +1 @@ -Subproject commit 255adddcea7cdcb4712f05f14edfdf73f9741d74 +Subproject commit b8c5de613548264e77327c908b1337919454525f