From 4df88aee1b554d0e130e6ea8eedc4af6ead2b90a Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 22 Sep 2026 21:15:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=B1=84=EB=84=90=20(SSE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /notifications/stream 으로 새 알림을 즉시 받는다. - WebSocket 대신 SSE: 알림은 한 방향이라, 기존 JWT 필터·인가·CORS 를 그대로 쓰는 쪽을 골랐다. - 알림 엔티티 @PostPersist 에서 이벤트를 올리고 커밋 뒤에 보낸다. 알림을 만드는 경로가 여럿이라 (서비스·관리자 발송·빈자리 알림 …) 각각에 발송 코드를 넣지 않는다. 롤백된 알림은 나가지 않는다. - connected 이벤트, 알림 id 를 이벤트 id 로, 25초 heartbeat, 30분 타임아웃, 사용자당 연결 5개 상한. - X-Accel-Buffering: no, 연결 수 게이지(carecode.notification.stream.connections). - 연결 종료 시 ASYNC 디스패치는 인가를 다시 하지 않는다 (JWT 필터가 돌지 않아 커밋된 응답에 401 을 쓰려던 문제). - docs/features/realtime-notifications.md --- docs/features/realtime-notifications.md | 61 +++++++ .../core/security/SecurityConfig.java | 4 + .../controller/NotificationController.java | 17 ++ .../notification/entity/Notification.java | 1 + .../realtime/NotificationCreatedEvent.java | 7 + .../realtime/NotificationPersistListener.java | 44 +++++ .../realtime/NotificationStreamService.java | 121 +++++++++++++ .../NotificationStreamContractTest.java | 168 ++++++++++++++++++ 8 files changed, 423 insertions(+) create mode 100644 docs/features/realtime-notifications.md create mode 100644 src/main/java/com/carecode/domain/notification/realtime/NotificationCreatedEvent.java create mode 100644 src/main/java/com/carecode/domain/notification/realtime/NotificationPersistListener.java create mode 100644 src/main/java/com/carecode/domain/notification/realtime/NotificationStreamService.java create mode 100644 src/test/java/com/carecode/integration/NotificationStreamContractTest.java diff --git a/docs/features/realtime-notifications.md b/docs/features/realtime-notifications.md new file mode 100644 index 0000000..f669496 --- /dev/null +++ b/docs/features/realtime-notifications.md @@ -0,0 +1,61 @@ +# 실시간 알림 (SSE) + +> 관련 이슈: #45 + +## 결정 — WebSocket 이 아니라 SSE + +| | SSE | WebSocket | +|---|---|---| +| 방향 | 서버 → 클라이언트 | 양방향 | +| 인증 | 기존 JWT 필터 그대로 | 핸드셰이크 인증을 따로 구현 | +| 인가·CORS·레이트 리밋 | 기존 규칙 그대로 (`/notifications/**` 인증) | 별도 | +| 프록시 | 평범한 HTTP. 버퍼링만 끄면 된다 | Upgrade 설정 필요 | + +알림은 한 방향이다. 양방향 채널이 주는 이점이 없고, 인증·인가를 두 벌 만드는 비용만 생긴다. + +## 흐름 + +``` +알림 저장 (어느 경로든: 서비스, 관리자 발송, 빈자리 알림 …) + → @PostPersist (NotificationPersistListener) → NotificationCreatedEvent + → 커밋 후 (@TransactionalEventListener AFTER_COMMIT) + → NotificationStreamService → 받는 사람의 열린 연결마다 전송 +``` + +- 알림을 만드는 곳마다 발송 코드를 넣지 않는다. 엔티티 저장에 걸어서 새 생성 경로가 생겨도 빠지지 않는다. +- **커밋 뒤에** 보낸다. 롤백된 알림이 화면에 떴다가 목록에서 사라지는 일이 없다. + +## 계약 + +`GET /notifications/stream` — `Authorization: Bearer `, 응답 `text/event-stream` + +| 이벤트 | 언제 | data | +|--------|------|------| +| `connected` | 연결 직후 | `ok` | +| `notification` | 새 알림 | 알림 JSON (목록 API 의 항목과 같은 모양). `id:` 는 알림 id | +| `: ping` (주석) | 25초마다 | — | + +브라우저 `EventSource` 는 헤더를 못 붙이므로 `fetch` 스트림으로 읽는다 (CareCode_FE `useNotificationStream`). + +## 끊김·재연결·중복 + +- 연결은 30분 뒤 서버가 닫는다. 클라이언트는 다시 연결한다(지수 백오프). +- 끊긴 사이의 알림은 다시 보내 주지 않는다. 대신 **`connected` 를 받으면 목록을 새로 불러온다.** + 목록이 진실의 원천이고 SSE 는 "새로 불러올 때가 됐다" 는 신호다. +- 같은 알림을 두 번 받아도 목록을 다시 불러올 뿐이라 화면에 두 번 나오지 않는다. +- 사용자당 연결은 5개까지. 넘치면 가장 오래된 것을 닫는다 (재연결을 반복하는 클라이언트가 연결을 쌓지 못하게). + +## 운영 + +| 항목 | 내용 | +|------|------| +| 지표 | `carecode.notification.stream.connections` — 열린 연결 수 (`/actuator/prometheus`) | +| 프록시 | 응답에 `X-Accel-Buffering: no`. Nginx 를 앞에 두면 `proxy_read_timeout` 을 heartbeat(25초)보다 길게 | +| 설정 | `app.notification.stream.timeout-millis`(30분), `heartbeat-millis`(25초), `max-connections-per-user`(5) | +| 보안 | 연결 종료 시 컨테이너의 ASYNC 디스패치는 인가를 다시 하지 않는다 (원 요청에서 이미 통과) | + +## 한계 + +연결을 인스턴스 메모리에 둔다. 지금 운영은 단일 인스턴스라 충분하다. +**여러 대로 늘리면** 알림이 저장된 인스턴스와 사용자가 연결된 인스턴스가 다를 수 있으므로, +`NotificationCreatedEvent` 를 Redis pub/sub 로 모든 인스턴스에 퍼뜨려야 한다. diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index 31a23dd..838d9a0 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -78,6 +78,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { }) ) .authorizeHttpRequests(authz -> { + // 비동기 응답(알림 SSE)이 끝날 때 서블릿 컨테이너가 ASYNC 디스패치를 한 번 더 한다. + // JWT 필터는 그 디스패치에서 돌지 않아 인증 정보가 비어 있고, 여기서 막으면 이미 커밋된 + // 응답에 401 을 쓰려다 오류 로그만 남는다. 원래 요청에서 이미 인가를 통과했으므로 연다. + authz.dispatcherTypeMatchers(jakarta.servlet.DispatcherType.ASYNC).permitAll(); if (!environment.matchesProfiles("prod")) { authz.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/api-docs/**", "/v3/api-docs/**").permitAll(); } diff --git a/src/main/java/com/carecode/domain/notification/controller/NotificationController.java b/src/main/java/com/carecode/domain/notification/controller/NotificationController.java index d8b8686..63d351a 100644 --- a/src/main/java/com/carecode/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/carecode/domain/notification/controller/NotificationController.java @@ -17,6 +17,10 @@ import com.carecode.domain.notification.dto.response.NotificationDeliveryStatusResponse; import com.carecode.domain.notification.app.NotificationFacade; import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.realtime.NotificationStreamService; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -43,6 +47,19 @@ public class NotificationController extends BaseController { private final NotificationFacade notificationFacade; private final CurrentUserFacade currentUserFacade; + private final NotificationStreamService notificationStreamService; + + // 실시간 수신 (SSE) + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation(summary = "알림 실시간 수신 (SSE)", + description = "연결 직후 connected 이벤트, 새 알림마다 notification 이벤트(JSON, id=알림 id)가 온다. " + + "25초마다 heartbeat 주석. 끊기면 다시 연결하고 목록을 새로 불러온다.") + public SseEmitter stream(HttpServletResponse response) { + // Nginx 가 이벤트를 모아 두지 않고 바로 흘려보내게 한다. + response.setHeader("X-Accel-Buffering", "no"); + response.setHeader("Cache-Control", "no-cache"); + return notificationStreamService.connect(currentUserFacade.requireCurrentUserDbId()); + } // 알림 목록 조회 @GetMapping diff --git a/src/main/java/com/carecode/domain/notification/entity/Notification.java b/src/main/java/com/carecode/domain/notification/entity/Notification.java index 2abf1d8..74dac13 100644 --- a/src/main/java/com/carecode/domain/notification/entity/Notification.java +++ b/src/main/java/com/carecode/domain/notification/entity/Notification.java @@ -13,6 +13,7 @@ /** 알림 엔티티 사용자에게 전송되는 알림을 관리합니다. 단순한 구조로 필수 기능만 포함합니다. */ @Entity @Table(name = "TBL_NOTIFICATION") +@EntityListeners(com.carecode.domain.notification.realtime.NotificationPersistListener.class) @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/realtime/NotificationCreatedEvent.java b/src/main/java/com/carecode/domain/notification/realtime/NotificationCreatedEvent.java new file mode 100644 index 0000000..40e5263 --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/realtime/NotificationCreatedEvent.java @@ -0,0 +1,7 @@ +package com.carecode.domain.notification.realtime; + +import com.carecode.domain.notification.dto.response.NotificationInfoResponse; + +/** 알림 한 건이 저장됐다. 커밋 후 실시간 채널로 내보낸다. */ +public record NotificationCreatedEvent(Long userDbId, NotificationInfoResponse payload) { +} diff --git a/src/main/java/com/carecode/domain/notification/realtime/NotificationPersistListener.java b/src/main/java/com/carecode/domain/notification/realtime/NotificationPersistListener.java new file mode 100644 index 0000000..baaa7dd --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/realtime/NotificationPersistListener.java @@ -0,0 +1,44 @@ +package com.carecode.domain.notification.realtime; + +import com.carecode.domain.notification.dto.response.NotificationInfoResponse; +import com.carecode.domain.notification.entity.Notification; +import jakarta.persistence.PostPersist; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * 알림이 저장되는 순간을 잡는다. + * + *

알림을 만드는 곳이 여럿이다 (서비스, 관리자 발송, 빈자리 알림, 초기 데이터 …). 각각에 발송 코드를 + * 넣으면 새 경로가 생길 때 빠뜨린다. 엔티티 저장에 걸면 어느 경로로 만들어도 실시간으로 나간다. + * + *

여기서는 이벤트만 올린다. 실제 전송은 커밋 뒤({@link NotificationStreamService})에 한다 — + * 롤백된 알림이 화면에 떴다가 목록에서 사라지는 일을 막는다. + * 사용자 식별은 DB id 만 쓴다. 지연 로딩 프록시라도 id 는 초기화 없이 읽힌다. + */ +@Component +@RequiredArgsConstructor +public class NotificationPersistListener { + + private final ApplicationEventPublisher publisher; + + @PostPersist + public void onPersist(Notification notification) { + if (notification.getUser() == null || notification.getUser().getId() == null) { + return; + } + publisher.publishEvent(new NotificationCreatedEvent( + notification.getUser().getId(), + NotificationInfoResponse.builder() + .id(notification.getId()) + .notificationType(notification.getNotificationType() != null + ? notification.getNotificationType().name() : null) + .title(notification.getTitle()) + .message(notification.getMessage()) + .isRead(Boolean.TRUE.equals(notification.getIsRead())) + .createdAt(notification.getCreatedAt()) + .sentAt(notification.getCreatedAt()) + .build())); + } +} diff --git a/src/main/java/com/carecode/domain/notification/realtime/NotificationStreamService.java b/src/main/java/com/carecode/domain/notification/realtime/NotificationStreamService.java new file mode 100644 index 0000000..65b07f0 --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/realtime/NotificationStreamService.java @@ -0,0 +1,121 @@ +package com.carecode.domain.notification.realtime; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; + +/** + * 알림 실시간 채널 (SSE). + * + *

WebSocket 이 아니라 SSE 를 쓰는 이유: 알림은 서버에서 클라이언트로 가는 한 방향이다. SSE 는 평범한 HTTP 라 + * 기존 JWT 필터·CORS·접근제어가 그대로 적용되고, 프록시·로드밸런서 설정도 따로 필요 없다. + * + *

한계: 연결을 이 인스턴스 메모리에 둔다. 지금 운영은 단일 인스턴스라 충분하다. 여러 대로 늘리면 + * Redis pub/sub 로 이벤트를 모든 인스턴스에 퍼뜨려야 한다 ({@code docs/features/realtime-notifications.md}). + */ +@Slf4j +@Service +public class NotificationStreamService { + + private final Map> emitters = new ConcurrentHashMap<>(); + private final long timeoutMillis; + private final int maxConnectionsPerUser; + + public NotificationStreamService( + @Value("${app.notification.stream.timeout-millis:1800000}") long timeoutMillis, + @Value("${app.notification.stream.max-connections-per-user:5}") int maxConnectionsPerUser, + MeterRegistry meterRegistry) { + this.timeoutMillis = timeoutMillis; + this.maxConnectionsPerUser = maxConnectionsPerUser; + Gauge.builder("carecode.notification.stream.connections", this, NotificationStreamService::connectionCount) + .description("열려 있는 알림 실시간(SSE) 연결 수") + .register(meterRegistry); + } + + /** + * 연결을 연다. 탭을 여러 개 열 수 있으므로 사용자당 여러 연결을 허용하되 상한을 둔다 + * (끊긴 줄 모르고 재연결을 반복하는 클라이언트가 연결을 무한히 쌓지 못하게). 넘치면 가장 오래된 것을 닫는다. + */ + public SseEmitter connect(Long userDbId) { + SseEmitter emitter = new SseEmitter(timeoutMillis); + Deque userEmitters = emitters.computeIfAbsent(userDbId, id -> new ConcurrentLinkedDeque<>()); + userEmitters.addLast(emitter); + while (userEmitters.size() > maxConnectionsPerUser) { + SseEmitter oldest = userEmitters.pollFirst(); + if (oldest != null) { + oldest.complete(); + } + } + + Runnable remove = () -> remove(userDbId, emitter); + emitter.onCompletion(remove); + emitter.onTimeout(remove); + emitter.onError(e -> remove.run()); + + // 첫 이벤트를 바로 보내야 프록시가 응답 헤더를 흘려보내고, 클라이언트도 연결됐음을 안다. + // 재연결 뒤에는 이 이벤트를 신호로 목록을 다시 불러 끊긴 사이의 알림을 채운다. + send(userDbId, emitter, SseEmitter.event().name("connected").data("ok")); + return emitter; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onNotificationCreated(NotificationCreatedEvent event) { + Deque userEmitters = emitters.get(event.userDbId()); + if (userEmitters == null || userEmitters.isEmpty()) { + return; + } + for (SseEmitter emitter : List.copyOf(userEmitters)) { + // id 는 알림 id. 클라이언트는 같은 id 를 두 번 받아도 한 번만 반영한다. + send(event.userDbId(), emitter, SseEmitter.event() + .id(String.valueOf(event.payload().getId())) + .name("notification") + .data(event.payload(), MediaType.APPLICATION_JSON)); + } + } + + /** 유휴 연결을 프록시가 끊지 않게 주석 한 줄을 보낸다. 끊긴 연결도 여기서 정리된다. */ + @Scheduled(fixedDelayString = "${app.notification.stream.heartbeat-millis:25000}") + public void heartbeat() { + emitters.forEach((userDbId, userEmitters) -> { + for (SseEmitter emitter : List.copyOf(userEmitters)) { + send(userDbId, emitter, SseEmitter.event().comment("ping")); + } + }); + } + + public int connectionCount() { + return emitters.values().stream().mapToInt(Deque::size).sum(); + } + + private void send(Long userDbId, SseEmitter emitter, SseEmitter.SseEventBuilder event) { + try { + emitter.send(event); + } catch (IOException | IllegalStateException e) { + // 클라이언트가 이미 떠났다. 정상 상황이라 경고로 남기지 않는다. + log.debug("SSE 전송 실패로 연결을 정리합니다 - userDbId={}", userDbId); + remove(userDbId, emitter); + emitter.completeWithError(e); + } + } + + private void remove(Long userDbId, SseEmitter emitter) { + emitters.computeIfPresent(userDbId, (id, userEmitters) -> { + userEmitters.remove(emitter); + return userEmitters.isEmpty() ? null : userEmitters; + }); + } +} diff --git a/src/test/java/com/carecode/integration/NotificationStreamContractTest.java b/src/test/java/com/carecode/integration/NotificationStreamContractTest.java new file mode 100644 index 0000000..439c8cd --- /dev/null +++ b/src/test/java/com/carecode/integration/NotificationStreamContractTest.java @@ -0,0 +1,168 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.realtime.NotificationStreamService; +import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.user.service.JwtService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * 알림 실시간 채널을 실제 필터 체인(JWT)과 실제 트랜잭션으로 확인한다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:carecode_notification_stream;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("알림 실시간 채널 (SSE)") +class NotificationStreamContractTest { + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + @Autowired NotificationRepository notificationRepository; + @Autowired NotificationStreamService streamService; + @Autowired TransactionTemplate transactionTemplate; + + private User receiver; + private User other; + + @BeforeEach + void setUp() { + receiver = saveUser(); + other = saveUser(); + } + + @Test + @DisplayName("로그인 없이는 연결할 수 없다") + void requiresLogin() throws Exception { + assertThat(mockMvc.perform(get("/notifications/stream")).andReturn().getResponse().getStatus()) + .isEqualTo(401); + } + + @Test + @DisplayName("커밋된 알림은 받는 사람의 연결로만 간다") + void deliversCommittedNotificationToOwnerOnly() throws Exception { + MvcResult mine = connect(receiver); + MvcResult others = connect(other); + assertThat(body(mine)).contains("event:connected"); + + Long id = transactionTemplate.execute(status -> notificationRepository.save(notification(receiver, "빈자리 알림")).getId()); + + assertThat(body(mine)) + .contains("event:notification") + .contains("id:" + id) + .contains("\"title\":\"빈자리 알림\"") + .contains("\"isRead\":false"); + assertThat(body(others)).doesNotContain("빈자리 알림"); + } + + @Test + @DisplayName("롤백된 알림은 보내지 않는다") + void rolledBackNotificationIsNotSent() throws Exception { + MvcResult mine = connect(receiver); + + transactionTemplate.executeWithoutResult(status -> { + notificationRepository.saveAndFlush(notification(receiver, "취소될 알림")); + status.setRollbackOnly(); + }); + + assertThat(body(mine)).doesNotContain("취소될 알림"); + } + + @Test + @DisplayName("사용자당 연결 수에 상한이 있다") + void connectionCapPerUser() throws Exception { + int before = streamService.connectionCount(); + for (int i = 0; i < 8; i++) { + connect(receiver); + } + assertThat(streamService.connectionCount() - before).isLessThanOrEqualTo(5); + } + + private MvcResult connect(User user) throws Exception { + String token = jwtService.generateAccessToken(user.getUserId(), user.getEmail(), user.getRole().name()); + MvcResult result = mockMvc.perform(get("/notifications/stream").header("Authorization", "Bearer " + token)) + .andReturn(); + assertThat(result.getRequest().isAsyncStarted()).as("SSE 는 비동기 응답이다").isTrue(); + assertThat(result.getResponse().getHeader("X-Accel-Buffering")).isEqualTo("no"); + return result; + } + + private static String body(MvcResult result) throws Exception { + return result.getResponse().getContentAsString(StandardCharsets.UTF_8).replace(": ", ":"); + } + + private static Notification notification(User user, String title) { + return Notification.builder() + .user(user) + .notificationType(Notification.NotificationType.FACILITY) + .title(title) + .message("자리가 났어요") + .build(); + } + + private User saveUser() { + String id = UUID.randomUUID().toString().substring(0, 8); + return userRepository.save(User.builder() + .userId("user_" + id) + .email(id + "@example.com") + .password("{noop}unused") + .name("사용자" + id) + .role(UserRole.PARENT) + .isActive(true) + .emailVerified(true) + .registrationCompleted(true) + .createdAt(LocalDateTime.now()) + .build()); + } +}