From 59a909961f8d0aae27805785c0afa72a3fb92de8 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Thu, 8 Jan 2026 20:00:55 -0800 Subject: [PATCH 01/12] =?UTF-8?q?chore:=20redis=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 1 + 1 file changed, 1 insertion(+) 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' From 0a5420f51e0c636d105924f59192c49d2900e39d Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Thu, 8 Jan 2026 20:01:16 -0800 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20redis=20pub/sub=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/sse/dto/ApplicantSseDto.java | 11 +++++++++++ .../java/moadong/sse/enums/ApplicantEventType.java | 6 ++++++ 2 files changed, 17 insertions(+) create mode 100644 backend/src/main/java/moadong/sse/dto/ApplicantSseDto.java create mode 100644 backend/src/main/java/moadong/sse/enums/ApplicantEventType.java 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, +} From 5e8d4eeda237c12b09afe022b404968f1ab87dba Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 14 Jan 2026 17:09:11 -0800 Subject: [PATCH 03/12] refactor: sperate semester to year and semesterTerm --- backend/src/main/java/moadong/club/entity/ClubAward.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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; } From 65f8d62bd355d2799ead009a4367bfd84b031d5a Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 14 Jan 2026 17:11:33 -0800 Subject: [PATCH 04/12] refactor: fixing compiler error --- .../java/moadong/club/payload/dto/ClubAwardDto.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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..cf8838216 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,25 @@ import jakarta.validation.constraints.Size; import moadong.club.entity.ClubAward; +import moadong.club.enums.SemesterTerm; import java.util.List; public record ClubAwardDto( - @Size(max = 50) - String semester, + 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(); } From 76e4eef49fab3f135d0c7aadf6b19f53dd295510 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 14 Jan 2026 17:16:14 -0800 Subject: [PATCH 05/12] =?UTF-8?q?feature:=20year=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - year는 1900~2050년 사이만 가능하다 --- .../src/main/java/moadong/club/payload/dto/ClubAwardDto.java | 2 ++ 1 file changed, 2 insertions(+) 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 cf8838216..b49ff64e9 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java @@ -3,10 +3,12 @@ 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( + @Range(min = 1900, max = 2050) int year, SemesterTerm semesterTerm, From 2d48099fd2f3bcbbdf939f115f08f01e4c8eb7dc Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Thu, 15 Jan 2026 17:47:47 -0800 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EB=B6=84=EC=82=B0=EB=9D=BD?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=A0=88=EB=94=94=EC=8A=A4=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moadong/global/config/RedisConfig.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 backend/src/main/java/moadong/global/config/RedisConfig.java 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..90f0ec54a --- /dev/null +++ b/backend/src/main/java/moadong/global/config/RedisConfig.java @@ -0,0 +1,25 @@ +package moadong.global.config; + +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.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} From 1be72af28e3edc1286c82c4e8ef9e6c3161de002 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Thu, 15 Jan 2026 19:24:12 -0800 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20redis=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=EB=84=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/moadong/global/config/RedisConfig.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/moadong/global/config/RedisConfig.java b/backend/src/main/java/moadong/global/config/RedisConfig.java index 90f0ec54a..7409c546a 100644 --- a/backend/src/main/java/moadong/global/config/RedisConfig.java +++ b/backend/src/main/java/moadong/global/config/RedisConfig.java @@ -4,6 +4,8 @@ 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 @@ -15,11 +17,18 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec redisTemplate.setConnectionFactory(connectionFactory); redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); - redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); return redisTemplate; } + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + return container; + } } From 12a0e493ed0ec10af66b2378e4e562ddc29586db Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Thu, 15 Jan 2026 19:25:37 -0800 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=9E=90=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B3=B5=EC=9C=A0=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20SSE=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sse/service/ApplicantsStatusShareSse.java | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 backend/src/main/java/moadong/sse/service/ApplicantsStatusShareSse.java 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..1757d3a5e --- /dev/null +++ b/backend/src/main/java/moadong/sse/service/ApplicantsStatusShareSse.java @@ -0,0 +1,175 @@ +package moadong.sse.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.ChannelTopic; +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 = 60000L; + 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 ChannelTopic(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; + try { + String message = objectMapper.writeValueAsString(event); + redisTemplate.convertAndSend(channel, message); + log.debug("Published SSE event to Redis channel: {}", channel); + } catch (JsonProcessingException e) { + log.error("Failed to serialize SSE event: {}", e.getMessage()); + } + } + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + String channel = new String(message.getChannel(), StandardCharsets.UTF_8); + String body = new String(message.getBody(), 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(body, 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); + } + }); + } +} From 434f05f6c0977ba969d5355d9e1e42788eca16f6 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Thu, 15 Jan 2026 19:25:43 -0800 Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20SSE=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EA=B8=B0=EC=A1=B4=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ClubApplyAdminController.java | 8 +- .../club/service/ClubApplyAdminService.java | 101 +++--------------- 2 files changed, 19 insertions(+), 90 deletions(-) diff --git a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java index d3784936d..da6dd08c2 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 = "클럽 지원서 양식을 생성합니다") @@ -116,17 +118,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/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java index b4e485562..86cd0fc6f 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java @@ -14,19 +14,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; @@ -38,12 +36,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) { } @@ -187,27 +180,29 @@ 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( + 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() { - CompletableFuture.runAsync(() -> sendStatusChangeEvent(clubId, applicationFormId, event)); - } - }); + )); + }); + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + events.forEach(event -> + applicantsStatusShareSse.publishStatusChangeEvent(clubId, applicationFormId, event)); + } }); clubApplicantsRepository.saveAll(application); @@ -297,72 +292,4 @@ private List buildClubFormQuestions(List 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 From cd02d82e97595e31ba2a286fc10586f07e8ffe28 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 17 Jan 2026 23:06:39 -0800 Subject: [PATCH 10/12] =?UTF-8?q?fix:=20serializer=20LocalDateTime=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=20=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/global/config/RedisConfig.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/moadong/global/config/RedisConfig.java b/backend/src/main/java/moadong/global/config/RedisConfig.java index 7409c546a..1cd07b909 100644 --- a/backend/src/main/java/moadong/global/config/RedisConfig.java +++ b/backend/src/main/java/moadong/global/config/RedisConfig.java @@ -1,5 +1,7 @@ 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; @@ -16,11 +18,16 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec 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(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setValueSerializer(jsonSerializer); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); - redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setHashValueSerializer(jsonSerializer); return redisTemplate; } @@ -32,3 +39,4 @@ public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnecti return container; } } + From 37f69bfeae65e7833ba25dd981df2bd37f1593a8 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 17 Jan 2026 23:07:18 -0800 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20TIMEOUT=20=EA=B8=B8=EC=9D=B4=20?= =?UTF-8?q?=EC=A6=9D=EA=B0=80=20=EB=B0=8F=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?redis=20=EC=B1=84=EB=84=90=20=EA=B5=AC=EB=8F=85=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sse/service/ApplicantsStatusShareSse.java | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/backend/src/main/java/moadong/sse/service/ApplicantsStatusShareSse.java b/backend/src/main/java/moadong/sse/service/ApplicantsStatusShareSse.java index 1757d3a5e..4d4a77f02 100644 --- a/backend/src/main/java/moadong/sse/service/ApplicantsStatusShareSse.java +++ b/backend/src/main/java/moadong/sse/service/ApplicantsStatusShareSse.java @@ -1,6 +1,5 @@ package moadong.sse.service; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; @@ -13,7 +12,7 @@ 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.ChannelTopic; +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; @@ -36,13 +35,13 @@ public class ApplicantsStatusShareSse implements MessageListener { private final Map> sseConnections = new ConcurrentHashMap<>(); - private static final long SSE_EMITTER_TIME_OUT = 60000L; + 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 ChannelTopic(CHANNEL_PREFIX + "*")); + redisMessageListenerContainer.addMessageListener(this, new PatternTopic(CHANNEL_PREFIX + "*")); } public SseEmitter createSseSession(String applicationFormId, CustomUserDetails user) { @@ -98,20 +97,13 @@ public SseEmitter createSseSession(String applicationFormId, CustomUserDetails u public void publishStatusChangeEvent(String clubId, String applicationFormId, ApplicantStatusEvent event) { String channel = CHANNEL_PREFIX + clubId + ":" + applicationFormId; - try { - String message = objectMapper.writeValueAsString(event); - redisTemplate.convertAndSend(channel, message); - log.debug("Published SSE event to Redis channel: {}", channel); - } catch (JsonProcessingException e) { - log.error("Failed to serialize SSE event: {}", e.getMessage()); - } + redisTemplate.convertAndSend(channel, event); } @Override public void onMessage(Message message, byte[] pattern) { try { String channel = new String(message.getChannel(), StandardCharsets.UTF_8); - String body = new String(message.getBody(), StandardCharsets.UTF_8); String channelSuffix = channel.substring(CHANNEL_PREFIX.length()); String[] parts = channelSuffix.split(":", 2); @@ -123,7 +115,7 @@ public void onMessage(Message message, byte[] pattern) { String clubId = parts[0]; String applicationFormId = parts[1]; - ApplicantStatusEvent event = objectMapper.readValue(body, ApplicantStatusEvent.class); + ApplicantStatusEvent event = objectMapper.readValue(message.getBody(), ApplicantStatusEvent.class); broadcastToLocalConnections(clubId, applicationFormId, event); } catch (Exception e) { From 55536a614cc945d1b8f73b1b77e1916673760479 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sun, 18 Jan 2026 20:48:19 -0800 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20redis=20publishing=20try-catch=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/service/ClubApplyAdminService.java | 97 ++++++------------- 1 file changed, 29 insertions(+), 68 deletions(-) diff --git a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java index 86cd0fc6f..0e0d594bb 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java @@ -66,8 +66,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); } @@ -77,19 +76,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)); @@ -97,8 +91,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(); @@ -107,23 +100,19 @@ 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); } 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); @@ -143,23 +132,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; @@ -169,9 +148,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); @@ -187,21 +164,19 @@ public void editApplicantDetail(String applicationFormId, List - applicantsStatusShareSse.publishStatusChangeEvent(clubId, applicationFormId, event)); + 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); + } + }); } }); @@ -236,14 +211,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()); @@ -266,26 +237,16 @@ 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(); - - ClubApplicationFormQuestion clubApplicationFormQuestion = ClubApplicationFormQuestion.builder() - .id(question.id()) - .title(question.title()) - .description(question.description()) - .type(question.type()) - .options(options) - .items(items) - .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(); formQuestions.add(clubApplicationFormQuestion); }