Skip to content

Commit 76d795e

Browse files
EpicFnEpicFn
andauthored
[Feat/OPS-389] 스페이스 초대 알림 구현 (#136)
* new : emitter 관리 service, controller 생성 * feat : 하트비트 로직 구현 * fix : 비동기 처리 하도록 변경 * feat : 관련 테스트 케이스 추가 * fix : 테스트 수정 * fix : 불필요한 어노테이션 삭제 --------- Co-authored-by: EpicFn <[email protected]>
1 parent d539dde commit 76d795e

File tree

7 files changed

+192
-3
lines changed

7 files changed

+192
-3
lines changed

src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
6+
import org.springframework.scheduling.annotation.EnableScheduling;
67

78
@SpringBootApplication
89
@EnableJpaAuditing
10+
@EnableScheduling
911
public class BackendApplication {
1012
public static void main(String[] args) {
1113
SpringApplication.run(BackendApplication.class, args);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package org.tuna.zoopzoop.backend.domain.SSE.controller;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.security.core.Authentication;
5+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
6+
import org.springframework.web.bind.annotation.GetMapping;
7+
import org.springframework.web.bind.annotation.RequestMapping;
8+
import org.springframework.web.bind.annotation.RestController;
9+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
10+
import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService;
11+
import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails;
12+
13+
@RestController
14+
@RequestMapping("/api/v1/notifications")
15+
@RequiredArgsConstructor
16+
public class ApiV1NotificationController {
17+
private final EmitterService emitterService;
18+
19+
/**
20+
* SSE 구독 엔드포인트
21+
* @param userDetails - 현재 인증된 사용자 정보
22+
* @return SseEmitter - 클라이언트와의 SSE 연결을 관리하는 객체
23+
*/
24+
@GetMapping(value = "/subscribe", produces = "text/event-stream")
25+
public SseEmitter subscribe(
26+
@AuthenticationPrincipal CustomUserDetails userDetails
27+
) {
28+
// 1. 현재 로그인한 사용자의 ID를 가져옴
29+
Long memberId = (long) userDetails.getMember().getId();
30+
31+
// 2. EmitterService를 통해 Emitter를 생성하고 반환
32+
return emitterService.addEmitter(memberId);
33+
}
34+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package org.tuna.zoopzoop.backend.domain.SSE.service;
2+
3+
import org.springframework.scheduling.annotation.Scheduled;
4+
import org.springframework.stereotype.Service;
5+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
6+
7+
import java.io.IOException;
8+
import java.util.Map;
9+
import java.util.concurrent.ConcurrentHashMap;
10+
11+
@Service
12+
public class EmitterService {
13+
// 1. 모든 Emitter를 저장하는 ConcurrentHashMap
14+
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
15+
16+
/**
17+
* 새로운 Emitter 생성 및 저장
18+
* @param memberId - 사용자 ID
19+
* @return SseEmitter - 생성된 Emitter 객체
20+
*/
21+
public SseEmitter addEmitter(Long memberId) {
22+
// 1시간 타임아웃 설정
23+
SseEmitter emitter = new SseEmitter(3600L * 1000);
24+
this.emitters.put(memberId, emitter);
25+
26+
// Emitter 완료 또는 타임아웃 시 Map에서 삭제
27+
emitter.onCompletion(() -> this.emitters.remove(memberId));
28+
emitter.onTimeout(() -> this.emitters.remove(memberId));
29+
30+
// 503 에러 방지를 위한 더미 이벤트 전송
31+
try {
32+
emitter.send(SseEmitter.event().name("connect").data("SSE connected!"));
33+
} catch (IOException e) {
34+
// 예외 처리
35+
}
36+
37+
return emitter;
38+
}
39+
40+
/**
41+
* 특정 사용자에게 이벤트 전송
42+
* @param memberId - 사용자 ID
43+
* @param eventName - 이벤트 이름
44+
* @param data - 전송할 데이터 객체
45+
*/
46+
public void sendNotification(Long memberId, String eventName, Object data) {
47+
SseEmitter emitter = this.emitters.get(memberId);
48+
if (emitter != null) {
49+
try {
50+
// data 객체를 JSON 문자열로 변환하여 전송해야 함 (Controller에서는 자동 변환)
51+
emitter.send(SseEmitter.event().name(eventName).data(data));
52+
} catch (IOException e) {
53+
this.emitters.remove(memberId);
54+
}
55+
}
56+
}
57+
58+
/**
59+
* 20초마다 모든 Emitter에 하트비트 전송
60+
* 클라이언트와의 연결 유지를 위해 주기적으로 빈 이벤트를 전송
61+
*/
62+
@Scheduled(fixedRate = 20000)
63+
public void sendHeartbeat() {
64+
// 모든 Emitter에 하트비트 전송
65+
emitters.forEach((userId, emitter) -> {
66+
try {
67+
// SSE 주석(comment)을 사용하여 클라이언트에서 별도 이벤트를 발생시키지 않음
68+
emitter.send(SseEmitter.event().comment("keep-alive"));
69+
} catch (IOException e) {
70+
// 전송 실패 시, 클라이언트 연결이 끊어진 것으로 간주하고 Map에서 제거
71+
emitters.remove(userId);
72+
}
73+
});
74+
}
75+
}

src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
import org.springframework.data.domain.Page;
99
import org.springframework.data.domain.Pageable;
1010
import org.springframework.stereotype.Service;
11+
import org.springframework.transaction.annotation.Transactional;
12+
import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService;
1113
import org.tuna.zoopzoop.backend.domain.member.entity.Member;
1214
import org.tuna.zoopzoop.backend.domain.member.service.MemberService;
1315
import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership;
1416
import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority;
1517
import org.tuna.zoopzoop.backend.domain.space.membership.enums.JoinState;
1618
import org.tuna.zoopzoop.backend.domain.space.membership.repository.MembershipRepository;
19+
import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceInvitationInfo;
1720
import org.tuna.zoopzoop.backend.domain.space.space.entity.Space;
1821
import org.tuna.zoopzoop.backend.global.rsData.RsData;
1922

@@ -26,6 +29,7 @@
2629
public class MembershipService {
2730
private final MembershipRepository membershipRepository;
2831
private final MemberService memberService;
32+
private final NotificationService notificationService;
2933

3034
// ======================== 멤버십 조회 ======================== //
3135

@@ -205,6 +209,7 @@ public Membership addMemberToSpace(Member member, Space space, Authority authori
205209
* @param invitedName 초대할 멤버 이름 목록
206210
* @return 생성된 Membership 엔티티 목록
207211
*/
212+
@Transactional
208213
public List<Membership> inviteMembersToSpace(Space space, List<String> invitedName) {
209214
// 1. 이름 중복 제거
210215
List<String> uniqueNames = invitedName.stream().distinct().toList();
@@ -228,7 +233,24 @@ public List<Membership> inviteMembersToSpace(Space space, List<String> invitedNa
228233
})
229234
.toList();
230235

231-
return membershipRepository.saveAll(invitedMemberships);
236+
// 4. 멤버십 저장
237+
List<Membership> savedMemberships = membershipRepository.saveAll(invitedMemberships);
238+
239+
// 5. 알림 전송 호출
240+
savedMemberships.forEach(membership -> {
241+
notificationService.sendSpaceInvitation(
242+
(long) membership.getMember().getId(),
243+
new SpaceInvitationInfo(
244+
space.getId(),
245+
space.getName(),
246+
space.getThumbnailUrl(),
247+
membership.getId()
248+
)
249+
);
250+
});
251+
252+
// 6. 저장된 멤버십 반환
253+
return savedMemberships;
232254
}
233255

234256
/**
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.tuna.zoopzoop.backend.domain.space.membership.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.scheduling.annotation.Async;
5+
import org.springframework.stereotype.Service;
6+
import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService;
7+
8+
@Service
9+
@RequiredArgsConstructor
10+
public class NotificationService {
11+
private final EmitterService emitterService;
12+
13+
@Async // 별도 스레드에서 비동기 실행
14+
public void sendSpaceInvitation(Long memberId, Object invitationData) {
15+
emitterService.sendNotification(memberId, "space-invitation", invitationData);
16+
}
17+
}

src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,8 @@ public RsData<ResBodyForSpaceInfo> getSpace(
220220
@Operation(summary = "Liveblocks 접속 토큰 발급")
221221
public ResponseEntity<RsData<ResBodyForAuthToken>> getAuthToken(
222222
@PathVariable Integer spaceId,
223-
@AuthenticationPrincipal CustomUserDetails userDetails) throws AccessDeniedException {
224-
223+
@AuthenticationPrincipal CustomUserDetails userDetails
224+
) throws AccessDeniedException {
225225
Member member = userDetails.getMember();
226226
String token = dashboardService.getAuthTokenForSpace(spaceId, member);
227227

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package org.tuna.zoopzoop.backend.domain.space.membership.service;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
import org.junit.jupiter.api.extension.ExtendWith;
6+
import org.mockito.InjectMocks;
7+
import org.mockito.Mock;
8+
import org.mockito.junit.jupiter.MockitoExtension;
9+
import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService;
10+
11+
import static org.mockito.Mockito.times;
12+
import static org.mockito.Mockito.verify;
13+
14+
@ExtendWith(MockitoExtension.class)
15+
class NotificationServiceTest {
16+
@Mock
17+
private EmitterService emitterService;
18+
19+
@InjectMocks
20+
private NotificationService notificationService;
21+
22+
@Test
23+
@DisplayName("초대 알림 전송 시 EmitterService의 sendNotification이 올바르게 호출됨")
24+
void sendSpaceInvitation_CallsEmitterService() {
25+
// given (준비)
26+
Long memberId = 1L;
27+
String testData = "test data";
28+
29+
// when (실행)
30+
notificationService.sendSpaceInvitation(memberId, testData);
31+
32+
// then (검증)
33+
// EmitterService의 sendNotification 메소드가
34+
// memberId, "space-invitation", testData 파라미터로
35+
// 1번 호출되었는지 검증
36+
verify(emitterService, times(1)).sendNotification(memberId, "space-invitation", testData);
37+
}
38+
39+
}

0 commit comments

Comments
 (0)