diff --git a/backend/build.gradle b/backend/build.gradle index d07cb9834..724b0111a 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-amqp' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.retry:spring-retry' implementation 'com.google.cloud:spring-cloud-gcp-storage:5.8.0' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' diff --git a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java index 7d9bb8930..472879bbe 100644 --- a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java +++ b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java @@ -13,6 +13,7 @@ import moadong.club.payload.request.ClubApplicationFormEditRequest; import moadong.club.service.ClubApplyAdminService; import moadong.global.payload.Response; +import moadong.sse.service.ApplicantsStatusShareSse; import moadong.user.annotation.CurrentUser; import moadong.user.payload.CustomUserDetails; import org.springframework.http.ResponseEntity; @@ -31,6 +32,7 @@ public class ClubApplyAdminController { private final ClubApplyAdminService clubApplyAdminService; + private final ApplicantsStatusShareSse sse; @PostMapping("/application") @Operation(summary = "클럽 지원서 양식 생성", description = "클럽 지원서 양식을 생성합니다") @@ -126,17 +128,17 @@ public ResponseEntity removeApplicant(@PathVariable String applicationFormId, return Response.ok("success delete applicant"); } - @GetMapping(value = "/applicant/{applicationFormId}/events", produces = "text/event-stream") + @GetMapping(value = "/applicant/{applicationFormId}/sse", produces = "text/event-stream") @Operation(summary = "지원자 상태 변경 실시간 이벤트", description = "지원자의 상태 변경을 실시간으로 받아볼 수 있는 SSE 엔드포인트입니다.") @PreAuthorize("isAuthenticated()") @SecurityRequirement(name = "BearerAuth") public SseEmitter getApplicantStatusEvents(HttpServletResponse response, - @PathVariable String applicationFormId, + @PathVariable String applicationFormId, @CurrentUser CustomUserDetails user) { response.addHeader("X-Accel-Buffering", "no"); response.addHeader("Cache-Control", "no-cache"); - return clubApplyAdminService.createSseConnection(applicationFormId, user); + return sse.createSseSession(applicationFormId, user); } } diff --git a/backend/src/main/java/moadong/club/entity/ClubAward.java b/backend/src/main/java/moadong/club/entity/ClubAward.java index 99a8c2893..9888aa33c 100644 --- a/backend/src/main/java/moadong/club/entity/ClubAward.java +++ b/backend/src/main/java/moadong/club/entity/ClubAward.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import moadong.club.enums.SemesterTerm; import java.util.List; @@ -13,7 +14,9 @@ @NoArgsConstructor public class ClubAward { - private String semester; + private int year; + + private SemesterTerm semesterTerm; private List achievements; } diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java b/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java index ef793e595..b49ff64e9 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java @@ -2,23 +2,27 @@ import jakarta.validation.constraints.Size; import moadong.club.entity.ClubAward; +import moadong.club.enums.SemesterTerm; +import org.hibernate.validator.constraints.Range; import java.util.List; public record ClubAwardDto( - @Size(max = 50) - String semester, + @Range(min = 1900, max = 2050) + int year, + SemesterTerm semesterTerm, List<@Size(max = 100) String> achievements ) { public static ClubAwardDto from(ClubAward clubAward) { if (clubAward == null) return null; - return new ClubAwardDto(clubAward.getSemester(), clubAward.getAchievements()); + return new ClubAwardDto(clubAward.getYear(), clubAward.getSemesterTerm(), clubAward.getAchievements()); } public ClubAward toEntity() { return ClubAward.builder() - .semester(semester) + .year(year) + .semesterTerm(semesterTerm) .achievements(achievements) .build(); } diff --git a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java index 3fbaff95f..f9835d494 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java @@ -15,19 +15,17 @@ import moadong.global.exception.ErrorCode; import moadong.global.exception.RestApiException; import moadong.global.util.AESCipher; +import moadong.sse.service.ApplicantsStatusShareSse; import moadong.user.payload.CustomUserDetails; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.stream.Collectors; @@ -39,12 +37,7 @@ public class ClubApplyAdminService { private final ClubApplicantsRepository clubApplicantsRepository; private final AESCipher cipher; private final ClubApplicationFormsRepositoryCustom clubApplicationFormsRepositoryCustom; - - // SSE 연결 관리 - private final Map sseConnections = new ConcurrentHashMap<>(); - - // SSE Emitter 타임아웃 (5분) - private static final long SSE_EMITTER_TIME_OUT = 300000L; + private final ApplicantsStatusShareSse applicantsStatusShareSse; private record OptionItem(int year, SemesterTerm term) { } @@ -74,8 +67,7 @@ private List buildOptionItems(LocalDate baseDate, int count) { private void validateSemester(Integer semesterYear, SemesterTerm semesterTerm) { LocalDate baseDate = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); List items = buildOptionItems(baseDate, 3); - boolean allowed = items.stream() - .anyMatch(it -> it.year() == semesterYear && it.term() == semesterTerm); + boolean allowed = items.stream().anyMatch(it -> it.year() == semesterYear && it.term() == semesterTerm); if (!allowed) { throw new RestApiException(ErrorCode.APPLICATION_SEMESTER_INVALID); } @@ -85,19 +77,14 @@ private void validateSemester(Integer semesterYear, SemesterTerm semesterTerm) { public void createClubApplicationForm(CustomUserDetails user, ClubApplicationFormCreateRequest request) { validateSemester(request.semesterYear(), request.semesterTerm()); - ClubApplicationForm clubApplicationForm = createApplicationForm( - ClubApplicationForm.builder() - .clubId(user.getClubId()) - .build(), - request); + ClubApplicationForm clubApplicationForm = createApplicationForm(ClubApplicationForm.builder().clubId(user.getClubId()).build(), request); clubApplicationFormsRepository.save(clubApplicationForm); } @Transactional public void editClubApplication(String applicationFormId, CustomUserDetails user, ClubApplicationFormEditRequest request) { - ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId) - .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); clubApplicationForm.updateEditedAt(); clubApplicationFormsRepository.save(updateApplicationForm(clubApplicationForm, request)); @@ -105,8 +92,7 @@ public void editClubApplication(String applicationFormId, CustomUserDetails user @Transactional //test 사용 public void editClubApplicationQuestion(String applicationFormId, CustomUserDetails user, ClubApplicationFormEditRequest request) { - ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findById(applicationFormId) - .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findById(applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); updateApplicationForm(clubApplicationForm, request); clubApplicationForm.updateEditedAt(); @@ -115,15 +101,12 @@ public void editClubApplicationQuestion(String applicationFormId, CustomUserDeta } public ClubApplicationFormsResponse getClubApplicationForms(CustomUserDetails user) { - return ClubApplicationFormsResponse.builder() - .forms(clubApplicationFormsRepositoryCustom.findClubApplicationFormsByClubId(user.getClubId())) - .build(); + return ClubApplicationFormsResponse.builder().forms(clubApplicationFormsRepositoryCustom.findClubApplicationFormsByClubId(user.getClubId())).build(); } @Transactional public void deleteClubApplicationForm(String applicationFormId, CustomUserDetails user) { - ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId) - .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); clubApplicantsRepository.deleteAllByFormId(applicationForm.getId()); clubApplicationFormsRepository.delete(applicationForm); @@ -147,8 +130,7 @@ public void duplicateClubApplicationForm(String applicationFormId, CustomUserDet } public ClubApplyInfoResponse getClubApplyInfo(String applicationFormId, CustomUserDetails user) { - ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId) - .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); List submittedApplications = clubApplicantsRepository.findAllByFormId(applicationFormId); @@ -168,23 +150,13 @@ public ClubApplyInfoResponse getClubApplyInfo(String applicationFormId, CustomUs } } - return ClubApplyInfoResponse.builder() - .total(applications.size()) - .reviewRequired(reviewRequired) - .scheduledInterview(scheduledInterview) - .accepted(accepted) - .applicants(applications) - .build(); + return ClubApplyInfoResponse.builder().total(applications.size()).reviewRequired(reviewRequired).scheduledInterview(scheduledInterview).accepted(accepted).applicants(applications).build(); } private ClubApplicant sortApplicationAnswers(ClubApplicationForm application, ClubApplicant app) { - Map answerMap = app.getAnswers().stream() - .collect(Collectors.toMap(ClubQuestionAnswer::getId, answer -> answer)); + Map answerMap = app.getAnswers().stream().collect(Collectors.toMap(ClubQuestionAnswer::getId, answer -> answer)); - List sortedAnswers = application.getQuestions().stream() - .map(question -> answerMap.get(question.getId())) - .filter(Objects::nonNull) - .collect(Collectors.toList()); + List sortedAnswers = application.getQuestions().stream().map(question -> answerMap.get(question.getId())).filter(Objects::nonNull).collect(Collectors.toList()); app.updateAnswers(sortedAnswers); return app; @@ -194,9 +166,7 @@ private ClubApplicant sortApplicationAnswers(ClubApplicationForm application, Cl public void editApplicantDetail(String applicationFormId, List request, CustomUserDetails user) { String clubId = user.getClubId(); - Map requestMap = request.stream() - .collect(Collectors.toMap(ClubApplicantEditRequest::applicantId, - Function.identity(), (prev, next) -> next)); + Map requestMap = request.stream().collect(Collectors.toMap(ClubApplicantEditRequest::applicantId, Function.identity(), (prev, next) -> next)); List applicationIds = new ArrayList<>(requestMap.keySet()); List application = clubApplicantsRepository.findAllByIdInAndFormId(applicationIds, applicationFormId); @@ -205,27 +175,27 @@ public void editApplicantDetail(String applicationFormId, List events = new ArrayList<>(); + application.forEach(app -> { ClubApplicantEditRequest editRequest = requestMap.get(app.getId()); app.updateMemo(editRequest.memo()); app.updateStatus(editRequest.status()); - // SSE 이벤트 발송 - ApplicantStatusEvent event = new ApplicantStatusEvent( - app.getId(), - editRequest.status(), - editRequest.memo(), - ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(), - clubId, - applicationFormId - ); - - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - CompletableFuture.runAsync(() -> sendStatusChangeEvent(clubId, applicationFormId, event)); - } - }); + events.add(new ApplicantStatusEvent(app.getId(), editRequest.status(), editRequest.memo(), ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(), clubId, applicationFormId)); + }); + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + events.forEach(event -> { + try { + applicantsStatusShareSse.publishStatusChangeEvent(clubId, applicationFormId, event); + } catch (Exception e) { + log.error("SSE publish failed. clubId={}, formId={}, applicantId={}", clubId, applicationFormId, event.applicantId(), e); + } + }); + } }); clubApplicantsRepository.saveAll(application); @@ -259,14 +229,10 @@ private ClubApplicationForm createApplicationForm(ClubApplicationForm clubApplic private ClubApplicationForm updateApplicationForm(ClubApplicationForm clubApplicationForm, ClubApplicationFormEditRequest request) { if (request.questions() != null) clubApplicationForm.updateQuestions(buildClubFormQuestions(request.questions())); - if (request.title() != null) - clubApplicationForm.updateFormTitle(request.title()); - if (request.description() != null) - clubApplicationForm.updateFormDescription(request.description()); - if (request.active() != null) - clubApplicationForm.updateFormStatus(request.active()); - if (request.formMode() != null) - clubApplicationForm.updateFormMode(request.formMode()); + if (request.title() != null) clubApplicationForm.updateFormTitle(request.title()); + if (request.description() != null) clubApplicationForm.updateFormDescription(request.description()); + if (request.active() != null) clubApplicationForm.updateFormStatus(request.active()); + if (request.formMode() != null) clubApplicationForm.updateFormMode(request.formMode()); if (request.externalApplicationUrl() != null) clubApplicationForm.updateExternalApplicationUrl(request.externalApplicationUrl()); @@ -289,98 +255,20 @@ private List buildClubFormQuestions(List items = new ArrayList<>(); Set distinctQuestionItemList = new HashSet<>(question.items()); - if (distinctQuestionItemList.size() != question.items().size()) throw new RestApiException(ErrorCode.DUPLICATE_QUESTIONS_ITEMS); + if (distinctQuestionItemList.size() != question.items().size()) + throw new RestApiException(ErrorCode.DUPLICATE_QUESTIONS_ITEMS); for (var item : question.items()) { - items.add(ClubQuestionItem.builder() - .value(item.value()) - .build()); + items.add(ClubQuestionItem.builder().value(item.value()).build()); } - ClubQuestionOption options = ClubQuestionOption.builder() - .required(question.options().required()) - .build(); + ClubQuestionOption options = ClubQuestionOption.builder().required(question.options().required()).build(); - ClubApplicationFormQuestion clubApplicationFormQuestion = ClubApplicationFormQuestion.builder() - .id(question.id()) - .title(question.title()) - .description(question.description()) - .type(question.type()) - .options(options) - .items(items) - .build(); + ClubApplicationFormQuestion clubApplicationFormQuestion = ClubApplicationFormQuestion.builder().id(question.id()).title(question.title()).description(question.description()).type(question.type()).options(options).items(items).build(); formQuestions.add(clubApplicationFormQuestion); } return formQuestions; } - - // SSE 연결 생성 - public SseEmitter createSseConnection(String applicationFormId, CustomUserDetails user) { - String clubId = user.getClubId(); - - clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId) - .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); - - - String connectionKey = clubId + "_" + applicationFormId + "_" + user.getId(); - SseEmitter emitter = new SseEmitter(SSE_EMITTER_TIME_OUT); - - // 기존 연결이 있으면 먼저 맵에서 제거한 뒤 정리하여 race condition 방지 - SseEmitter prev = sseConnections.remove(connectionKey); - if (prev != null) { - try { - prev.complete(); - } catch (Exception ignored) { - } - } - - sseConnections.put(connectionKey, emitter); - - emitter.onCompletion(() -> sseConnections.remove(connectionKey, emitter)); - emitter.onTimeout(() -> sseConnections.remove(connectionKey, emitter)); - emitter.onError((ex) -> sseConnections.remove(connectionKey, emitter)); - - // 초기 핸드셰이크 이벤트 전송 (프록시/버퍼로 인한 지연 감소) - try { - emitter.send(SseEmitter.event().name("connected").data("ok")); - } catch (Exception e) { - sseConnections.remove(connectionKey, emitter); - emitter.completeWithError(e); - } - - return emitter; - } - - // 이벤트 발송 - private void sendStatusChangeEvent(String clubId, String applicationFormId, ApplicantStatusEvent event) { - // 안전한 prefix (뒤에 "_" 추가) - String connectionKeyPrefix = clubId + "_" + applicationFormId + "_"; - - // 동시성 문제 방지: 스냅샷을 만들어서 순회 - List> entries = sseConnections.entrySet().stream() - .filter(entry -> entry.getKey().startsWith(connectionKeyPrefix)) - .collect(Collectors.toList()); - - entries.forEach(entry -> { - String key = entry.getKey(); - SseEmitter emitter = entry.getValue(); - - try { - emitter.send(SseEmitter.event() - .name("applicant-status-changed") // 이벤트 이름 지정 - .data(event)); // 실제 데이터 - } catch (Exception e) { - log.warn("SSE 이벤트 발송 실패: {}", e.getMessage()); - // 동일 인스턴스일 때만 제거하여 race condition 방지 - sseConnections.remove(key, emitter); - try { - emitter.completeWithError(e); // emitter 쪽도 정상 종료 - } catch (Exception ignore) { - } - } - }); - } - } \ No newline at end of file diff --git a/backend/src/main/java/moadong/global/config/RedisConfig.java b/backend/src/main/java/moadong/global/config/RedisConfig.java new file mode 100644 index 000000000..1cd07b909 --- /dev/null +++ b/backend/src/main/java/moadong/global/config/RedisConfig.java @@ -0,0 +1,42 @@ +package moadong.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; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(jsonSerializer); + + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(jsonSerializer); + + return redisTemplate; + } + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + return container; + } +} + diff --git a/backend/src/main/java/moadong/sse/dto/ApplicantSseDto.java b/backend/src/main/java/moadong/sse/dto/ApplicantSseDto.java new file mode 100644 index 000000000..5749eedcc --- /dev/null +++ b/backend/src/main/java/moadong/sse/dto/ApplicantSseDto.java @@ -0,0 +1,11 @@ +package moadong.sse.dto; + +import lombok.Data; +import moadong.sse.enums.ApplicantEventType; + +@Data +public class ApplicantSseDto { + private String clubId; + private ApplicantEventType event; + private Object data; +} diff --git a/backend/src/main/java/moadong/sse/enums/ApplicantEventType.java b/backend/src/main/java/moadong/sse/enums/ApplicantEventType.java new file mode 100644 index 000000000..da471d3b8 --- /dev/null +++ b/backend/src/main/java/moadong/sse/enums/ApplicantEventType.java @@ -0,0 +1,6 @@ +package moadong.sse.enums; + +public enum ApplicantEventType { + APPLICANT_STATUS_UPDATE, + ADDED_NEW_APPLICANT, +} diff --git a/backend/src/main/java/moadong/sse/service/ApplicantsStatusShareSse.java b/backend/src/main/java/moadong/sse/service/ApplicantsStatusShareSse.java new file mode 100644 index 000000000..4d4a77f02 --- /dev/null +++ b/backend/src/main/java/moadong/sse/service/ApplicantsStatusShareSse.java @@ -0,0 +1,167 @@ +package moadong.sse.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import moadong.club.payload.dto.ApplicantStatusEvent; +import moadong.club.repository.ClubApplicationFormsRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import moadong.user.payload.CustomUserDetails; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ApplicantsStatusShareSse implements MessageListener { + + private final ClubApplicationFormsRepository clubApplicationFormsRepository; + private final RedisTemplate redisTemplate; + private final RedisMessageListenerContainer redisMessageListenerContainer; + private final ObjectMapper objectMapper; + + private final Map> sseConnections = new ConcurrentHashMap<>(); + + private static final long SSE_EMITTER_TIME_OUT = 60 * 60 * 1000L; + private static final int MAX_SESSIONS_PER_CLUB = 20; + private static final String CHANNEL_PREFIX = "sse:applicant-status:"; + + @PostConstruct + public void init() { + redisMessageListenerContainer.addMessageListener(this, new PatternTopic(CHANNEL_PREFIX + "*")); + } + + public SseEmitter createSseSession(String applicationFormId, CustomUserDetails user) { + String clubId = user.getClubId(); + + clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId) + .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + + String connectionKey = applicationFormId + "_" + UUID.randomUUID(); + + Map clubEmitters = sseConnections.computeIfAbsent(clubId, k -> new ConcurrentHashMap<>()); + + if (clubEmitters.size() >= MAX_SESSIONS_PER_CLUB) { + String keyToRemove = clubEmitters.keySet().iterator().next(); + SseEmitter oldEmitter = clubEmitters.get(keyToRemove); + + if (oldEmitter != null) { + oldEmitter.complete(); + clubEmitters.remove(keyToRemove); + } + } + + SseEmitter emitter = new SseEmitter(SSE_EMITTER_TIME_OUT); + clubEmitters.put(connectionKey, emitter); + + Runnable removeCallback = () -> { + sseConnections.computeIfPresent(clubId, (key, innerMap) -> { + innerMap.remove(connectionKey); + return innerMap.isEmpty() ? null : innerMap; + }); + }; + + emitter.onCompletion(removeCallback); + emitter.onTimeout(removeCallback); + emitter.onError((ex) -> { + if (ex.getMessage() != null && ex.getMessage().contains("Broken pipe")) { + log.info("SSE Client Disconnected [Club: {}, Key: {}]", clubId, connectionKey); + } else { + log.error("SSE Error [Club: {}, Key: {}]", clubId, connectionKey, ex); + } + removeCallback.run(); + }); + + try { + emitter.send(SseEmitter.event().name("connected").data("ok")); + } catch (Exception e) { + removeCallback.run(); + emitter.completeWithError(e); + } + + return emitter; + } + + public void publishStatusChangeEvent(String clubId, String applicationFormId, ApplicantStatusEvent event) { + String channel = CHANNEL_PREFIX + clubId + ":" + applicationFormId; + redisTemplate.convertAndSend(channel, event); + } + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + String channel = new String(message.getChannel(), StandardCharsets.UTF_8); + + String channelSuffix = channel.substring(CHANNEL_PREFIX.length()); + String[] parts = channelSuffix.split(":", 2); + if (parts.length < 2) { + log.warn("Invalid channel format: {}", channel); + return; + } + + String clubId = parts[0]; + String applicationFormId = parts[1]; + + ApplicantStatusEvent event = objectMapper.readValue(message.getBody(), ApplicantStatusEvent.class); + broadcastToLocalConnections(clubId, applicationFormId, event); + + } catch (Exception e) { + log.error("Failed to process Redis message: {}", e.getMessage(), e); + } + } + + private void broadcastToLocalConnections(String clubId, String applicationFormId, ApplicantStatusEvent event) { + Map clubEmitters = sseConnections.get(clubId); + if (clubEmitters == null || clubEmitters.isEmpty()) { + return; + } + + String connectionKeyPrefix = applicationFormId + "_"; + + clubEmitters.entrySet().stream() + .filter(entry -> entry.getKey().startsWith(connectionKeyPrefix)) + .forEach(entry -> { + String key = entry.getKey(); + SseEmitter emitter = entry.getValue(); + + try { + emitter.send(SseEmitter.event() + .name("applicant-status-changed") + .data(event)); + } catch (Exception e) { + log.warn("SSE 이벤트 발송 실패: {}", e.getMessage()); + clubEmitters.remove(key); + try { + emitter.completeWithError(e); + } catch (Exception ignore) { + } + } + }); + } + + @Scheduled(fixedRate = 45000L) + public void sendHeartBeat() { + sseConnections.values() + .stream().flatMap(innerMap -> innerMap.values().stream()) + .forEach(emitter -> { + try { + emitter.send(SseEmitter.event().name("ping").data("")); + } catch (Exception e) { + emitter.completeWithError(e); + } + }); + } +}