Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +32,7 @@
public class ClubApplyAdminController {

private final ClubApplyAdminService clubApplyAdminService;
private final ApplicantsStatusShareSse sse;

@PostMapping("/application")
@Operation(summary = "클럽 지원서 양식 생성", description = "클럽 지원서 양식을 생성합니다")
Expand Down Expand Up @@ -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);
}

}
5 changes: 4 additions & 1 deletion backend/src/main/java/moadong/club/entity/ClubAward.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import moadong.club.enums.SemesterTerm;

import java.util.List;

Expand All @@ -13,7 +14,9 @@
@NoArgsConstructor
public class ClubAward {

private String semester;
private int year;

private SemesterTerm semesterTerm;

private List<String> achievements;
}
12 changes: 8 additions & 4 deletions backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
188 changes: 38 additions & 150 deletions backend/src/main/java/moadong/club/service/ClubApplyAdminService.java

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions backend/src/main/java/moadong/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> 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;
}
}

11 changes: 11 additions & 0 deletions backend/src/main/java/moadong/sse/dto/ApplicantSseDto.java
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package moadong.sse.enums;

public enum ApplicantEventType {
APPLICANT_STATUS_UPDATE,
ADDED_NEW_APPLICANT,
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> redisTemplate;
private final RedisMessageListenerContainer redisMessageListenerContainer;
private final ObjectMapper objectMapper;

private final Map<String, Map<String, SseEmitter>> 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<String, SseEmitter> 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<String, SseEmitter> 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);
}
});
}
}
Loading