Skip to content
60 changes: 60 additions & 0 deletions src/main/java/com/allclearwas/config/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,70 @@
package com.allclearwas.config;

import java.util.concurrent.ThreadPoolExecutor;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Configuration
@EnableAsync
public class AsyncConfig {

private static final int CORE_POOL_SIZE = 16;
private static final int MAX_POOL_SIZE = 64;
private static final int QUEUE_CAPACITY = 800;
private static final String THREAD_NAME_PREFIX = "SSE-Async-";

@Bean(name = "sseAsyncExecutor")
public ThreadPoolTaskExecutor sseAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(CORE_POOL_SIZE);
executor.setMaxPoolSize(MAX_POOL_SIZE);
executor.setQueueCapacity(QUEUE_CAPACITY);
executor.setThreadNamePrefix(THREAD_NAME_PREFIX);

executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
log.warn("""
❌ SSE 비동기 스레드풀 요청 초과!
├─ Active Threads : {}
├─ Pool Size : {}
├─ Maximum Pool Size : {}
├─ Queue Size : {}
├─ Remaining Capacity : {}
└─ Task Count : {}
""",
e.getActiveCount(),
e.getPoolSize(),
e.getMaximumPoolSize(),
e.getQueue().size(),
e.getQueue().remainingCapacity(),
e.getTaskCount()
);
super.rejectedExecution(r, e);
}
});

executor.initialize();

log.info("""
✅ 비동기 스레드풀 초기화 완료
├─ Core Pool Size : {}
├─ Max Pool Size : {}
├─ Queue Capacity : {}
└─ Thread Name Prefix: {}
""",
CORE_POOL_SIZE,
MAX_POOL_SIZE,
QUEUE_CAPACITY,
THREAD_NAME_PREFIX
);
return executor;
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.allclearwas.domains.enrollment.service;

import java.util.List;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -13,12 +12,12 @@
import com.allclearwas.domains.course.implement.CourseReader;
import com.allclearwas.domains.course.implement.CourseUpdater;
import com.allclearwas.domains.enrollment.domain.Enrollment;
import com.allclearwas.domains.enrollment.dto.response.CourseEnrollmentCountRes;
import com.allclearwas.domains.enrollment.dto.response.EnrollmentRes;
import com.allclearwas.domains.enrollment.implement.EnrollmentAppender;
import com.allclearwas.domains.enrollment.implement.EnrollmentDeleter;
import com.allclearwas.domains.enrollment.implement.EnrollmentReader;
import com.allclearwas.domains.enrollment.implement.EnrollmentValidator;
import com.allclearwas.domains.seat.service.SseSeatService;
import com.allclearwas.domains.student.domain.Student;
import com.allclearwas.domains.student.domain.StudentPolicy;
import com.allclearwas.domains.student.implement.StudentPolicyReader;
Expand All @@ -43,11 +42,8 @@ public class EnrollmentService {
private final EnrollmentValidator enrollmentValidator;
private final CourseReader courseReader;
private final CourseUpdater courseUpdater;

public List<CourseEnrollmentCountRes> getEnrolledCount(List<Long> courseIds) {
List<CourseEnrollmentCountRes> countList = courseReader.getEnrollmentCount(courseIds);
return countList;
}
private final RedisTemplate<String, String> redisTemplate;
private final SseSeatService sseSeatService;

@Transactional
public EnrollmentRes enrollCourse(Long courseId, Long studentId) {
Expand Down Expand Up @@ -97,6 +93,12 @@ public EnrollmentRes enrollCourse(Long courseId, Long studentId) {
// Enrollment 저장
Enrollment enrollment = enrollmentAppender.save(student, course);

// Redis 여석 감소 + SSE 알림 전송
String redisKey = "course:" + courseId + ":remaining";
redisTemplate.opsForValue().decrement(redisKey);
String remaining = redisTemplate.opsForValue().get(redisKey);
sseSeatService.notifyRemainingChanged(courseId, remaining);

// 학생 학점 증가
studentPolicyUpdater.increaseStudentCredits(policy, course.getCredit());

Expand All @@ -109,8 +111,7 @@ public void deleteEnrollment(Long enrollmentId, Long studentId) {

// 수강신청 정보 조회
Enrollment enrollment = enrollmentReader.read(enrollmentId)
.orElseThrow(() -> new EnrollmentException(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND));

.orElseThrow(() -> new EnrollmentException(EnrollmentErrorCode.ENROLLMENT_NOT_FOUND));

// 수강신청 정보 소유자 검증
if (!enrollmentReader.isOwnedByStudent(enrollment.getId(), studentId)) {
Expand Down Expand Up @@ -138,5 +139,11 @@ public void deleteEnrollment(Long enrollmentId, Long studentId) {

// 수강 신청 정보 삭제
enrollmentDeleter.delete(enrollment);

// Redis 여석 감소 + SSE 알림 전송
String redisKey = "course:" + courseId + ":remaining";
redisTemplate.opsForValue().increment(redisKey);
String remaining = redisTemplate.opsForValue().get(redisKey);
sseSeatService.notifyRemainingChanged(courseId, remaining);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.allclearwas.domains.seat.controller;

import java.util.List;

import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import com.allclearwas.common.security.authentication.SecurityUserDetails;
import com.allclearwas.domains.seat.service.SseSeatService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/seats")
public class SseSeatController {

private final SseSeatService sseSeatService;

@PreAuthorize("isAuthenticated()")
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@RequestParam List<Long> courseIds,
@AuthenticationPrincipal SecurityUserDetails userDetails) {
Long studentId = userDetails.getStudentId();
return sseSeatService.subscribe(studentId, courseIds);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.allclearwas.domains.seat.implement;

import java.io.IOException;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Component
@RequiredArgsConstructor
@Slf4j
public class SseAsyncSender {

@Async("sseAsyncExecutor")
public void send(SseEmitter emitter, String event, Object data) {
try {
emitter.send(SseEmitter.event()
.name(event)
.data(data));
log.info("✅ send 성공!!");
} catch (IOException e) {
log.warn("🚨 SSE 전송 실패: event={}, message={}", event, e.getMessage());
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.allclearwas.domains.seat.implement;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import lombok.Getter;

@Getter
@Component
public class SseEmitterManager {
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
private final Map<Long, List<Long>> subscribedCourses = new ConcurrentHashMap<>();

public void addEmitter(Long studentId, SseEmitter emitter, List<Long> courseIds) {
emitters.put(studentId, emitter);
subscribedCourses.put(studentId, courseIds);
}

public void removeEmitter(Long studentId) {
emitters.remove(studentId);
subscribedCourses.remove(studentId);
}

public SseEmitter getEmitter(Long studentId) {
return emitters.get(studentId);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.allclearwas.domains.seat.service;

import java.util.List;
import java.util.Map;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import com.allclearwas.domains.seat.implement.SseAsyncSender;
import com.allclearwas.domains.seat.implement.SseEmitterManager;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class SseSeatService {

private final SseEmitterManager emitterManager;
private final SseAsyncSender asyncSender;
private final RedisTemplate<String, String> redisTemplate;

public SseEmitter subscribe(Long studentId, List<Long> courseIds) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
emitterManager.addEmitter(studentId, emitter, courseIds);

for (Long courseId : courseIds) {
String remaining = redisTemplate.opsForValue().get("course:" + courseId + ":remaining");
asyncSender.send(emitter, "seat", Map.of(
"courseId", courseId,
"remaining", remaining
));
}

emitter.onCompletion(() -> emitterManager.removeEmitter(studentId));
emitter.onTimeout(() -> emitterManager.removeEmitter(studentId));
emitter.onError((e) -> emitterManager.removeEmitter(studentId));

return emitter;
}

public void notifyRemainingChanged(Long courseId, String remaining) {
for (Map.Entry<Long, List<Long>> entry : emitterManager.getSubscribedCourses().entrySet()) {
Long studentId = entry.getKey();
List<Long> subscribed = entry.getValue();

if (subscribed.contains(courseId)) {
SseEmitter emitter = emitterManager.getEmitter(studentId);
if (emitter != null) {
asyncSender.send(emitter, "seat", Map.of(
"courseId", courseId,
"remaining", remaining
));
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.allclearwas.domains.enrollment;

/*
import static org.junit.jupiter.api.Assertions.*;

import java.time.DayOfWeek;
Expand Down Expand Up @@ -146,3 +147,4 @@ void setup() {
assertEquals(40, course.getParticipant(), "Course의 participant 필드도 40이어야 함");
}
}
*/