From c8991ff0e34b5ecad847eab80ec4faa87d4c0fa4 Mon Sep 17 00:00:00 2001 From: EpicFn Date: Fri, 10 Oct 2025 14:09:29 +0900 Subject: [PATCH 1/6] =?UTF-8?q?new=20:=20emitter=20=EA=B4=80=EB=A6=AC=20se?= =?UTF-8?q?rvice,=20controller=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApiV1NotificationController.java | 34 +++++++++++ .../domain/SSE/service/EmitterService.java | 56 +++++++++++++++++++ .../controller/ApiV1SpaceController.java | 4 +- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/SSE/controller/ApiV1NotificationController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/SSE/service/EmitterService.java 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..461a8b15 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/service/EmitterService.java @@ -0,0 +1,56 @@ +package org.tuna.zoopzoop.backend.domain.SSE.service; + +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); + } + } + } +} 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); From 6900a6e79819f022c19e391e4ed4d9970a39a118 Mon Sep 17 00:00:00 2001 From: EpicFn Date: Fri, 10 Oct 2025 14:32:37 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat=20:=20=ED=95=98=ED=8A=B8=EB=B9=84?= =?UTF-8?q?=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zoopzoop/backend/BackendApplication.java | 2 ++ .../domain/SSE/service/EmitterService.java | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) 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/service/EmitterService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/service/EmitterService.java index 461a8b15..a5ef4f2c 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -53,4 +54,22 @@ public void sendNotification(Long memberId, String eventName, Object data) { } } } + + /** + * 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); + } + }); + } } From 39c606b958a2cd00ba2df3ad159da05d03890677 Mon Sep 17 00:00:00 2001 From: EpicFn Date: Fri, 10 Oct 2025 14:52:06 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix=20:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../membership/service/MembershipService.java | 24 ++++++++++++++++++- .../service/NotificationService.java | 17 +++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/NotificationService.java 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); + } +} From 40e5e67575a03dfaa0d3079c41defc212cd2ebdd Mon Sep 17 00:00:00 2001 From: EpicFn Date: Fri, 10 Oct 2025 15:07:59 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat=20:=20=EA=B4=80=EB=A0=A8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationServiceTest.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/NotificationServiceTest.java 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..be14d52d --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/NotificationServiceTest.java @@ -0,0 +1,45 @@ +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.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ActiveProfiles("test") +@SpringBootTest +@Transactional +@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 From 2c3898e26170a1f3a8024c03fe1dfac8f824b681 Mon Sep 17 00:00:00 2001 From: EpicFn Date: Fri, 10 Oct 2025 15:11:56 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../space/membership/service/NotificationServiceTest.java | 4 ---- 1 file changed, 4 deletions(-) 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 index be14d52d..28630c9a 100644 --- 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 @@ -6,17 +6,13 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @ActiveProfiles("test") -@SpringBootTest -@Transactional @ExtendWith(MockitoExtension.class) class NotificationServiceTest { @Mock From cb563b5954f967c92931000aece4d665f65e0e4f Mon Sep 17 00:00:00 2001 From: EpicFn Date: Fri, 10 Oct 2025 15:13:34 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix=20:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../space/membership/service/NotificationServiceTest.java | 2 -- 1 file changed, 2 deletions(-) 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 index 28630c9a..81e796d7 100644 --- 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 @@ -6,13 +6,11 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.context.ActiveProfiles; import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -@ActiveProfiles("test") @ExtendWith(MockitoExtension.class) class NotificationServiceTest { @Mock