diff --git a/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java b/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java index a9ecb6f6..20a4813c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java +++ b/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class BackendApplication { public static void main(String[] args) { SpringApplication.run(BackendApplication.class, args); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/controller/ApiV1NotificationController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/controller/ApiV1NotificationController.java new file mode 100644 index 00000000..06528c19 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/controller/ApiV1NotificationController.java @@ -0,0 +1,34 @@ +package org.tuna.zoopzoop.backend.domain.SSE.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +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.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +@RestController +@RequestMapping("/api/v1/notifications") +@RequiredArgsConstructor +public class ApiV1NotificationController { + private final EmitterService emitterService; + + /** + * SSE 구독 엔드포인트 + * @param userDetails - 현재 인증된 사용자 정보 + * @return SseEmitter - 클라이언트와의 SSE 연결을 관리하는 객체 + */ + @GetMapping(value = "/subscribe", produces = "text/event-stream") + public SseEmitter subscribe( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + // 1. 현재 로그인한 사용자의 ID를 가져옴 + Long memberId = (long) userDetails.getMember().getId(); + + // 2. EmitterService를 통해 Emitter를 생성하고 반환 + return emitterService.addEmitter(memberId); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/service/EmitterService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/service/EmitterService.java new file mode 100644 index 00000000..a5ef4f2c --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/service/EmitterService.java @@ -0,0 +1,75 @@ +package org.tuna.zoopzoop.backend.domain.SSE.service; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class EmitterService { + // 1. 모든 Emitter를 저장하는 ConcurrentHashMap + private final Map emitters = new ConcurrentHashMap<>(); + + /** + * 새로운 Emitter 생성 및 저장 + * @param memberId - 사용자 ID + * @return SseEmitter - 생성된 Emitter 객체 + */ + public SseEmitter addEmitter(Long memberId) { + // 1시간 타임아웃 설정 + SseEmitter emitter = new SseEmitter(3600L * 1000); + this.emitters.put(memberId, emitter); + + // Emitter 완료 또는 타임아웃 시 Map에서 삭제 + emitter.onCompletion(() -> this.emitters.remove(memberId)); + emitter.onTimeout(() -> this.emitters.remove(memberId)); + + // 503 에러 방지를 위한 더미 이벤트 전송 + try { + emitter.send(SseEmitter.event().name("connect").data("SSE connected!")); + } catch (IOException e) { + // 예외 처리 + } + + return emitter; + } + + /** + * 특정 사용자에게 이벤트 전송 + * @param memberId - 사용자 ID + * @param eventName - 이벤트 이름 + * @param data - 전송할 데이터 객체 + */ + public void sendNotification(Long memberId, String eventName, Object data) { + SseEmitter emitter = this.emitters.get(memberId); + if (emitter != null) { + try { + // data 객체를 JSON 문자열로 변환하여 전송해야 함 (Controller에서는 자동 변환) + emitter.send(SseEmitter.event().name(eventName).data(data)); + } catch (IOException e) { + this.emitters.remove(memberId); + } + } + } + + /** + * 20초마다 모든 Emitter에 하트비트 전송 + * 클라이언트와의 연결 유지를 위해 주기적으로 빈 이벤트를 전송 + */ + @Scheduled(fixedRate = 20000) + public void sendHeartbeat() { + // 모든 Emitter에 하트비트 전송 + emitters.forEach((userId, emitter) -> { + try { + // SSE 주석(comment)을 사용하여 클라이언트에서 별도 이벤트를 발생시키지 않음 + emitter.send(SseEmitter.event().comment("keep-alive")); + } catch (IOException e) { + // 전송 실패 시, 클라이언트 연결이 끊어진 것으로 간주하고 Map에서 제거 + emitters.remove(userId); + } + }); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java index f30a8dbb..cece27cc 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java @@ -8,12 +8,15 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; import org.tuna.zoopzoop.backend.domain.space.membership.enums.JoinState; import org.tuna.zoopzoop.backend.domain.space.membership.repository.MembershipRepository; +import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceInvitationInfo; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.global.rsData.RsData; @@ -26,6 +29,7 @@ public class MembershipService { private final MembershipRepository membershipRepository; private final MemberService memberService; + private final NotificationService notificationService; // ======================== 멤버십 조회 ======================== // @@ -205,6 +209,7 @@ public Membership addMemberToSpace(Member member, Space space, Authority authori * @param invitedName 초대할 멤버 이름 목록 * @return 생성된 Membership 엔티티 목록 */ + @Transactional public List inviteMembersToSpace(Space space, List invitedName) { // 1. 이름 중복 제거 List uniqueNames = invitedName.stream().distinct().toList(); @@ -228,7 +233,24 @@ public List inviteMembersToSpace(Space space, List invitedNa }) .toList(); - return membershipRepository.saveAll(invitedMemberships); + // 4. 멤버십 저장 + List savedMemberships = membershipRepository.saveAll(invitedMemberships); + + // 5. 알림 전송 호출 + savedMemberships.forEach(membership -> { + notificationService.sendSpaceInvitation( + (long) membership.getMember().getId(), + new SpaceInvitationInfo( + space.getId(), + space.getName(), + space.getThumbnailUrl(), + membership.getId() + ) + ); + }); + + // 6. 저장된 멤버십 반환 + return savedMemberships; } /** diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/NotificationService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/NotificationService.java new file mode 100644 index 00000000..0e61a33d --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/NotificationService.java @@ -0,0 +1,17 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService; + +@Service +@RequiredArgsConstructor +public class NotificationService { + private final EmitterService emitterService; + + @Async // 별도 스레드에서 비동기 실행 + public void sendSpaceInvitation(Long memberId, Object invitationData) { + emitterService.sendNotification(memberId, "space-invitation", invitationData); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java index fdb4e5e8..ea8b286c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java @@ -220,8 +220,8 @@ public RsData getSpace( @Operation(summary = "Liveblocks 접속 토큰 발급") public ResponseEntity> getAuthToken( @PathVariable Integer spaceId, - @AuthenticationPrincipal CustomUserDetails userDetails) throws AccessDeniedException { - + @AuthenticationPrincipal CustomUserDetails userDetails + ) throws AccessDeniedException { Member member = userDetails.getMember(); String token = dashboardService.getAuthTokenForSpace(spaceId, member); diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/NotificationServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/NotificationServiceTest.java new file mode 100644 index 00000000..81e796d7 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/NotificationServiceTest.java @@ -0,0 +1,39 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + @Mock + private EmitterService emitterService; + + @InjectMocks + private NotificationService notificationService; + + @Test + @DisplayName("초대 알림 전송 시 EmitterService의 sendNotification이 올바르게 호출됨") + void sendSpaceInvitation_CallsEmitterService() { + // given (준비) + Long memberId = 1L; + String testData = "test data"; + + // when (실행) + notificationService.sendSpaceInvitation(memberId, testData); + + // then (검증) + // EmitterService의 sendNotification 메소드가 + // memberId, "space-invitation", testData 파라미터로 + // 1번 호출되었는지 검증 + verify(emitterService, times(1)).sendNotification(memberId, "space-invitation", testData); + } + +} \ No newline at end of file