From 8c04a37e40efafe2f6607a2adabbf03abed12cb9 Mon Sep 17 00:00:00 2001 From: kkw610 Date: Sun, 31 May 2026 20:07:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SSE=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=99=95=EC=9E=A5=20-=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EB=93=B1=EB=A1=9D,=20=EC=9D=B4=ED=95=B4?= =?UTF-8?q?=EB=8F=84=20=EC=B2=B4=ED=81=AC=20=EC=83=9D=EC=84=B1/=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/dto/QuestionResDTO.java | 51 ++++++++++++++ .../service/QuestionEventService.java | 25 ++++++- .../question/service/QuestionService.java | 70 ++++++++++++++++++- backend/src/main/resources/application.yml | 8 +++ 4 files changed, 150 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java index 0347795..37f18ba 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java @@ -225,4 +225,55 @@ public record UnderstandingCheckCreateResponse( LocalDateTime createdAt ) { } + + // 질문 등록 시 SSE로 내려가는 이벤트. 같은 세션 질문방을 보고 있는 모든 클라이언트에게 전파된다. + public record QuestionCreatedEvent( + String type, + Long sessionId, + Long questionId, + String content, + String imageUrl, + // 좋아요 수 (생성 직후에는 0) + Integer likeCount, + // 댓글 수 (생성 직후에는 0) + Integer commentCount, + LocalDateTime createdAt + ) { + } + + // 운영진이 이해도 체크를 생성했을 때 SSE로 내려가는 이벤트. + // 같은 세션 질문방을 보고 있는 모든 클라이언트에게 전파된다. + public record UnderstandingCheckCreatedEvent( + String type, + Long sessionId, + Long checkId, + String content, + // 생성 직후에는 0 + Integer respondedCount, + // 해당 세션의 출석 인원 (이해도 분모) + Integer attendanceCount, + // 생성 직후에는 0 + Integer understoodCount, + // 생성 직후에는 0 + Integer notUnderstoodCount, + LocalDateTime createdAt + ) { + } + + // 누군가 이해도 O/X를 누를 때 SSE로 내려가는 이벤트. + // 같은 세션 질문방을 보고 있는 모든 클라이언트의 이해도 카운트를 실시간으로 갱신한다. + public record UnderstandingResponseUpdatedEvent( + String type, + Long sessionId, + Long checkId, + // 화면의 \"13/29\" 중 13: O 응답 수 + X 응답 수 + Integer respondedCount, + // 화면의 \"13/29\" 중 29: 해당 세션에 대응되는 출석 회차의 출석 인원 + Integer attendanceCount, + // 오른쪽 O 뱃지 숫자 + Integer understoodCount, + // 오른쪽 X 뱃지 숫자 + Integer notUnderstoodCount + ) { + } } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java index bec66f4..087eac4 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java @@ -42,13 +42,34 @@ public SseEmitter subscribe(Long sessionId) { // 댓글 생성 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. public void publishCommentCreated(Long sessionId, QuestionResDTO.CommentCreatedEvent event) { + broadcast(sessionId, "comment-created", event); + } + + // 질문 등록 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. + public void publishQuestionCreated(Long sessionId, QuestionResDTO.QuestionCreatedEvent event) { + broadcast(sessionId, "question-created", event); + } + + // 이해도 체크 생성 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. + public void publishUnderstandingCheckCreated(Long sessionId, QuestionResDTO.UnderstandingCheckCreatedEvent event) { + broadcast(sessionId, "understanding-check-created", event); + } + + // 이해도 응답(O/X) 업데이트 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. + public void publishUnderstandingResponseUpdated(Long sessionId, QuestionResDTO.UnderstandingResponseUpdatedEvent event) { + broadcast(sessionId, "understanding-response-updated", event); + } + + // 지정한 이벤트 이름과 데이터를 해당 세션의 모든 구독자에게 전송한다. + // 전송 실패한 연결은 즉시 제거한다. + private void broadcast(Long sessionId, String eventName, Object data) { List emitters = sessionEmitters.getOrDefault(sessionId, List.of()); for (SseEmitter emitter : emitters) { try { emitter.send(SseEmitter.event() - .name("comment-created") - .data(event)); + .name(eventName) + .data(data)); } catch (IOException | IllegalStateException e) { removeEmitter(sessionId, emitter); emitter.completeWithError(e); diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java index 0d47992..3291b25 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java @@ -242,7 +242,12 @@ public QuestionResDTO.CreateRes createQuestion(Long sessionId, QuestionReqDTO.Cr .updatedAt(LocalDateTime.now()) .build(); - return QuestionResDTO.CreateRes.from(questionRepository.save(question)); + Question saved = questionRepository.save(question); + + // DB 반영 후 같은 세션을 보고 있는 모든 클라이언트에게 새 질문을 알림 + publishQuestionCreatedEventAfterCommit(saved); + + return QuestionResDTO.CreateRes.from(saved); } // 좋아요 토글 @@ -375,6 +380,11 @@ public QuestionResDTO.UnderstandingCheckCreateResponse createUnderstandingCheck( .updatedAt(now) .build()); + int attendanceCount = attendanceService.countAttendedBySession(session); + + // DB 반영 후 같은 세션을 보고 있는 모든 클라이언트에게 새 이해도 체크를 알림 + publishUnderstandingCheckCreatedEventAfterCommit(session.getId(), check, attendanceCount); + return new QuestionResDTO.UnderstandingCheckCreateResponse( check.getId(), check.getTitle(), 0, null, 0, 0, check.getCreatedAt() ); @@ -396,7 +406,12 @@ public QuestionResDTO.UnderstandingResponseResult respondUnderstandingCheck( UnderstandResChoice selectedChoice = applyUnderstandingResponse(check, loginUser, request.getChoice()); // O/X 클릭 직후 프론트가 13/29와 O/X 뱃지를 바로 갱신할 수 있도록 최신 분모도 함께 내려준다. int attendanceCount = attendanceService.countAttendedBySession(session); - return toUnderstandingResponseResult(check, selectedChoice, attendanceCount); + QuestionResDTO.UnderstandingResponseResult result = toUnderstandingResponseResult(check, selectedChoice, attendanceCount); + + // DB 반영 후 같은 세션을 보고 있는 모든 클라이언트의 이해도 카운트를 갱신 + publishUnderstandingResponseUpdatedEventAfterCommit(sessionId, result); + + return result; } // 공통 헬퍼 메서드 @@ -677,6 +692,57 @@ private void publishCommentCreatedEventAfterCommit(Question question) { publishAfterCommit(() -> questionEventService.publishCommentCreated(sessionId, event)); } + private void publishQuestionCreatedEventAfterCommit(Question question) { + Long sessionId = question.getSession().getId(); + + QuestionResDTO.QuestionCreatedEvent event = new QuestionResDTO.QuestionCreatedEvent( + "QUESTION_CREATED", + sessionId, + question.getId(), + question.getContent(), + question.getImageUrl(), + question.getLikeCount(), + 0, // 방금 만들어진 질문이므로 댓글 수는 0 + question.getCreatedAt() + ); + + publishAfterCommit(() -> questionEventService.publishQuestionCreated(sessionId, event)); + } + + private void publishUnderstandingCheckCreatedEventAfterCommit( + Long sessionId, UnderstandingCheck check, int attendanceCount + ) { + QuestionResDTO.UnderstandingCheckCreatedEvent event = new QuestionResDTO.UnderstandingCheckCreatedEvent( + "UNDERSTANDING_CHECK_CREATED", + sessionId, + check.getId(), + check.getTitle(), + 0, // 생성 직후 응답 수 0 + attendanceCount, + 0, // 생성 직후 O 0 + 0, // 생성 직후 X 0 + check.getCreatedAt() + ); + + publishAfterCommit(() -> questionEventService.publishUnderstandingCheckCreated(sessionId, event)); + } + + private void publishUnderstandingResponseUpdatedEventAfterCommit( + Long sessionId, QuestionResDTO.UnderstandingResponseResult result + ) { + QuestionResDTO.UnderstandingResponseUpdatedEvent event = new QuestionResDTO.UnderstandingResponseUpdatedEvent( + "UNDERSTANDING_RESPONSE_UPDATED", + sessionId, + result.checkId(), + result.respondedCount(), + result.attendanceCount(), + result.understoodCount(), + result.notUnderstoodCount() + ); + + publishAfterCommit(() -> questionEventService.publishUnderstandingResponseUpdated(sessionId, event)); + } + // 롤백된 댓글이 실시간 화면에 먼저 보이지 않도록, 활성화된 트랜잭션 동기화 안에서만 커밋 이후 이벤트를 발행한다. private void publishAfterCommit(Runnable action) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 11c99f9..cd715f1 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -32,3 +32,11 @@ management: file: upload-dir: uploads/ + +# SSE 연결은 HTTP 커넥션을 끊지 않고 유지 +# 연결당 Tomcat 스레드 하나를 점유하므로, 동시 접속자 수를 고려해 스레드 수를 넉넉하게 설정 +server: + tomcat: + threads: + max: 200 # 기본값 200 (명시적으로 설정) + min-spare: 20 # 최소 대기 스레드 수