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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe cast from int to long. The getId() method returns an int, but you're casting it to long which could cause issues. Consider using Long.valueOf() for safer conversion or ensure the method signature matches the expected type.

Suggested change
Long memberId = (long) userDetails.getMember().getId();
Long memberId = Long.valueOf(userDetails.getMember().getId());

Copilot uses AI. Check for mistakes.

// 2. EmitterService를 통해 Emitter를 생성하고 반환
return emitterService.addEmitter(memberId);
}
}
Original file line number Diff line number Diff line change
@@ -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<Long, SseEmitter> 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) {
// 예외 처리
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty catch block with only a comment is not a good practice. Consider logging the exception or handling it appropriately, as connection failures during initial SSE setup could indicate important issues.

Copilot uses AI. Check for mistakes.
}

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);
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -26,6 +29,7 @@
public class MembershipService {
private final MembershipRepository membershipRepository;
private final MemberService memberService;
private final NotificationService notificationService;

// ======================== 멤버십 조회 ======================== //

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

return membershipRepository.saveAll(invitedMemberships);
// 4. 멤버십 저장
List<Membership> savedMemberships = membershipRepository.saveAll(invitedMemberships);

// 5. 알림 전송 호출
savedMemberships.forEach(membership -> {
notificationService.sendSpaceInvitation(
(long) membership.getMember().getId(),
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe cast from int to long. The getId() method returns an int, but you're casting it to long which could cause issues. Consider using Long.valueOf() for safer conversion or ensure the method signature matches the expected type.

Suggested change
(long) membership.getMember().getId(),
Long.valueOf(membership.getMember().getId()),

Copilot uses AI. Check for mistakes.
new SpaceInvitationInfo(
space.getId(),
space.getName(),
space.getThumbnailUrl(),
membership.getId()
)
);
});

// 6. 저장된 멤버십 반환
return savedMemberships;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,8 @@ public RsData<ResBodyForSpaceInfo> getSpace(
@Operation(summary = "Liveblocks 접속 토큰 발급")
public ResponseEntity<RsData<ResBodyForAuthToken>> getAuthToken(
@PathVariable Integer spaceId,
@AuthenticationPrincipal CustomUserDetails userDetails) throws AccessDeniedException {

@AuthenticationPrincipal CustomUserDetails userDetails
) throws AccessDeniedException {
Member member = userDetails.getMember();
String token = dashboardService.getAuthTokenForSpace(spaceId, member);

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

}