diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java index 3cd0b8a..97952d3 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/QuestionController.java @@ -35,8 +35,12 @@ public ResponseEntity> getQuest // text/event-stream으로 연결을 유지하며, 댓글 생성 같은 목록 갱신 이벤트를 받는다. // 인증 헤더가 필요하므로 프론트에서는 기본 EventSource 대신 fetch 기반 SSE 클라이언트로 구독한다. @GetMapping(value = "/api/sessions/{sessionId}/questions/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter subscribeQuestionEvents(@PathVariable Long sessionId) { - return questionService.subscribeQuestionEvents(sessionId); + public ResponseEntity subscribeQuestionEvents(@PathVariable Long sessionId) { + return ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .header("Cache-Control", "no-cache") + .header("X-Accel-Buffering", "no") + .body(questionService.subscribeQuestionEvents(sessionId)); } // 질문 상세 조회 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 ca5b1e8..fd59783 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 @@ -197,6 +197,17 @@ public record CommentCreatedEvent( ) { } + // 댓글 수정/삭제 시 SSE로 내려가는 목록 갱신 이벤트 응답 + public record CommentUpdatedEvent( + String type, + Long sessionId, + Long questionId, + Boolean isResolved, + Integer commentCount, + List previewComments + ) { + } + // O/X 클릭 직후 응답. selectedChoice가 null이면 같은 선택지를 다시 눌러 취소된 상태다. public record UnderstandingResponseResult( Long checkId, @@ -243,6 +254,19 @@ public record QuestionCreatedEvent( ) { } + // 좋아요, 해결 상태, 본문 수정, 삭제처럼 기존 질문의 상태가 바뀔 때 내려가는 SSE 이벤트 + public record QuestionUpdatedEvent( + String type, + Long sessionId, + Long questionId, + String content, + Boolean isResolved, + Integer likeCount, + Boolean isDeleted, + LocalDateTime updatedAt + ) { + } + // 운영진이 이해도 체크를 생성했을 때 SSE로 내려가는 이벤트. // 같은 세션 질문방을 보고 있는 모든 클라이언트에게 전파된다. public record UnderstandingCheckCreatedEvent( 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 087eac4..2ba72d2 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 @@ -45,11 +45,21 @@ public void publishCommentCreated(Long sessionId, QuestionResDTO.CommentCreatedE broadcast(sessionId, "comment-created", event); } + // 댓글 수정/삭제 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. + public void publishCommentUpdated(Long sessionId, QuestionResDTO.CommentUpdatedEvent event) { + broadcast(sessionId, "comment-updated", event); + } + // 질문 등록 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. public void publishQuestionCreated(Long sessionId, QuestionResDTO.QuestionCreatedEvent event) { broadcast(sessionId, "question-created", event); } + // 질문 상태 변경 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. + public void publishQuestionUpdated(Long sessionId, QuestionResDTO.QuestionUpdatedEvent event) { + broadcast(sessionId, "question-updated", event); + } + // 이해도 체크 생성 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. public void publishUnderstandingCheckCreated(Long sessionId, QuestionResDTO.UnderstandingCheckCreatedEvent event) { broadcast(sessionId, "understanding-check-created", event); 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 a3d0037..74552f7 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 @@ -281,7 +281,7 @@ public QuestionResDTO.LikeRes toggleLike(Long questionId, Long userId) { Question question = findQuestion(questionId); // 이미 좋아요를 눌렀는지 확인 - return questionLikeRepository.findByQuestionAndUser(question, loginUser) + QuestionResDTO.LikeRes result = questionLikeRepository.findByQuestionAndUser(question, loginUser) .map(existingLike -> { // 이미 눌렀으면 → 취소 (삭제 + likeCount -1) questionLikeRepository.delete(existingLike); @@ -298,6 +298,9 @@ public QuestionResDTO.LikeRes toggleLike(Long questionId, Long userId) { question.increaseLikeCount(); return new QuestionResDTO.LikeRes(question.getId(), question.getLikeCount(), true); }); + + publishQuestionUpdatedEventAfterCommit(question, false); + return result; } // 질문 수정 @@ -313,6 +316,8 @@ public QuestionResDTO.UpdateDeleteRes updateQuestion( question.updateContent(request.getContent()); + publishQuestionUpdatedEventAfterCommit(question, false); + return new QuestionResDTO.UpdateDeleteRes( question.getId(), question.getContent(), question.getUpdatedAt(), question.getDeletedAt() @@ -328,6 +333,8 @@ public QuestionResDTO.UpdateDeleteRes deleteQuestion(Long questionId, Long userI question.softDelete(); + publishQuestionUpdatedEventAfterCommit(question, true); + return new QuestionResDTO.UpdateDeleteRes( question.getId(), question.getContent(), question.getUpdatedAt(), question.getDeletedAt() @@ -347,6 +354,8 @@ public QuestionResDTO.CommentUpdateDeleteRes updateComment( comment.updateContent(request.getContent()); + publishCommentUpdatedEventAfterCommit(comment.getQuestion()); + return new QuestionResDTO.CommentUpdateDeleteRes( comment.getId(), comment.getContent(), comment.getUpdatedAt(), comment.getDeletedAt() @@ -362,6 +371,8 @@ public QuestionResDTO.CommentUpdateDeleteRes deleteComment(Long commentId, Long comment.softDelete(); + publishCommentUpdatedEventAfterCommit(comment.getQuestion()); + return new QuestionResDTO.CommentUpdateDeleteRes( comment.getId(), comment.getContent(), comment.getUpdatedAt(), comment.getDeletedAt() @@ -379,6 +390,8 @@ public QuestionResDTO.StatusUpdateRes updateQuestionStatus(Long questionId, Long Question question = findQuestion(questionId); question.markResolved(); + publishQuestionUpdatedEventAfterCommit(question, false); + return new QuestionResDTO.StatusUpdateRes( question.getId(), question.getIsResolved(), question.getUpdatedAt() ); @@ -742,6 +755,32 @@ private void publishCommentCreatedEventAfterCommit(Question question) { publishAfterCommit(() -> questionEventService.publishCommentCreated(sessionId, event)); } + private void publishCommentUpdatedEventAfterCommit(Question question) { + Long sessionId = question.getSession().getId(); + Long questionId = question.getId(); + List questionIds = List.of(questionId); + + Map commentCounts = new HashMap<>(); + questionCommentRepository.countByQuestionIds(questionIds) + .forEach(row -> commentCounts.put(row.getQuestionId(), Math.toIntExact(row.getCommentCount()))); + + Map> previewComments = new HashMap<>(); + questionCommentRepository.findPreviewCommentsByQuestionIds(questionIds) + .forEach(row -> previewComments.computeIfAbsent(row.getQuestionId(), key -> new ArrayList<>()) + .add(toPreviewCommentResponse(question, row))); + + QuestionResDTO.CommentUpdatedEvent event = new QuestionResDTO.CommentUpdatedEvent( + "COMMENT_UPDATED", + sessionId, + questionId, + question.getIsResolved(), + commentCounts.getOrDefault(questionId, 0), + previewComments.getOrDefault(questionId, List.of()) + ); + + publishAfterCommit(() -> questionEventService.publishCommentUpdated(sessionId, event)); + } + private void publishQuestionCreatedEventAfterCommit(Question question) { Long sessionId = question.getSession().getId(); @@ -759,6 +798,23 @@ private void publishQuestionCreatedEventAfterCommit(Question question) { publishAfterCommit(() -> questionEventService.publishQuestionCreated(sessionId, event)); } + private void publishQuestionUpdatedEventAfterCommit(Question question, boolean isDeleted) { + Long sessionId = question.getSession().getId(); + + QuestionResDTO.QuestionUpdatedEvent event = new QuestionResDTO.QuestionUpdatedEvent( + "QUESTION_UPDATED", + sessionId, + question.getId(), + question.getContent(), + question.getIsResolved(), + question.getLikeCount(), + isDeleted, + question.getUpdatedAt() + ); + + publishAfterCommit(() -> questionEventService.publishQuestionUpdated(sessionId, event)); + } + private void publishUnderstandingCheckCreatedEventAfterCommit( Long sessionId, UnderstandingCheck check, int attendanceCount ) { diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index b58cea4..9ca6e44 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -1,5 +1,5 @@ import '../../assets/styles/global.css'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import styles from './QnADetailPage.module.css'; import { FiMoreVertical, FiCornerDownRight } from 'react-icons/fi'; @@ -12,6 +12,9 @@ import { } from '../../utils/qnaUtils'; import profileImg from '../../assets/images/profile.png'; import { authFetch } from '../../utils/Api'; +import { subscribeQuestionEvents } from '../../utils/sse'; + +const POPULAR_LIKE_THRESHOLD = 5; // 시간만 표시하는 포맷 함수 (HH:MM) const formatTime = (dateStr) => { @@ -24,8 +27,46 @@ const formatTime = (dateStr) => { }); }; +const createBlobImageUrl = async (imageUrl) => { + if (!imageUrl) return null; + + try { + const imgRes = await authFetch(imageUrl); + const blob = await imgRes.blob(); + return URL.createObjectURL(blob); + } catch { + return null; + } +}; + +const attachCommentBlobImages = async (comments = []) => Promise.all( + comments.map(async (comment) => ({ + ...comment, + imageUrl: await createBlobImageUrl(comment.imageUrl), + replies: await attachCommentBlobImages(comment.replies ?? []), + })) +); + +const removeCommentFromTree = (comments = [], commentId) => comments + .filter(comment => comment.commentId !== commentId) + .map(comment => ({ + ...comment, + replies: removeCommentFromTree(comment.replies ?? [], commentId), + })); + +const updateCommentInTree = (comments = [], commentId, updater) => comments.map(comment => { + if (comment.commentId === commentId) { + return updater(comment); + } + + return { + ...comment, + replies: updateCommentInTree(comment.replies ?? [], commentId, updater), + }; +}); + function QnADetailPage() { - const { questionId } = useParams(); + const { sessionId, questionId } = useParams(); const navigate = useNavigate(); const isStaff = localStorage.getItem('role') === 'ADMIN'; @@ -50,58 +91,93 @@ function QnADetailPage() { const [editingCommentId, setEditingCommentId] = useState(null); const [editCommentText, setEditCommentText] = useState(''); - // ── 질문 불러오기 ──────────────────────────────── - useEffect(() => { - document.title = "Q&A | PIROIN"; - - const fetchQuestion = async () => { - try { + const fetchQuestion = useCallback(async ({ showLoading = false } = {}) => { + try { + if (showLoading) { setLoading(true); - const res = await authFetch(`/api/questions/${questionId}`); - if (!res.ok) throw new Error(`서버 오류: ${res.status}`); - const json = await res.json(); - if (!json.isSuccess) throw new Error(json.message); - - const result = json.result; - - // 질문 이미지 blob 변환 - if (result.imageUrl) { - try { - const imgRes = await authFetch(result.imageUrl); - const blob = await imgRes.blob(); - result.imageUrl = URL.createObjectURL(blob); - } catch { - result.imageUrl = null; - } - } + } - // 댓글 이미지 blob 변환 - if (result.comments) { - result.comments = await Promise.all( - result.comments.map(async (comment) => { - if (comment.imageUrl) { - try { - const imgRes = await authFetch(comment.imageUrl); - const blob = await imgRes.blob(); - return { ...comment, imageUrl: URL.createObjectURL(blob) }; - } catch { - return { ...comment, imageUrl: null }; - } - } - return comment; - }) - ); - } - setQuestion(result); - } catch (err) { - console.error('질문 불러오기 실패:', err); - } finally { + const res = await authFetch(`/api/questions/${questionId}`); + if (!res.ok) throw new Error(`서버 오류: ${res.status}`); + const json = await res.json(); + if (!json.isSuccess) throw new Error(json.message); + + const result = json.result; + + // 질문 이미지 blob 변환 + result.imageUrl = await createBlobImageUrl(result.imageUrl); + + // 댓글과 대댓글 이미지 blob 변환 + if (result.comments) { + result.comments = await attachCommentBlobImages(result.comments); + } + setQuestion(result); + } catch (err) { + console.error('질문 불러오기 실패:', err); + } finally { + if (showLoading) { setLoading(false); } - }; - if (questionId) fetchQuestion(); + } }, [questionId]); + // ── 질문 불러오기 ──────────────────────────────── + useEffect(() => { + document.title = "Q&A | PIROIN"; + + if (questionId) { + void fetchQuestion({ showLoading: true }); + } + }, [questionId, fetchQuestion]); + + const handleQuestionEvent = useCallback((message) => { + if (String(message.data?.questionId) !== String(questionId)) { + return; + } + + if (message.event === 'comment-created' || message.event === 'comment-updated') { + void fetchQuestion(); + return; + } + + if (message.event === 'question-updated') { + if (message.data?.isDeleted) { + navigate(-1); + return; + } + + setQuestion(prev => { + if (!prev) return prev; + + return { + ...prev, + content: message.data.content ?? prev.content, + isResolved: message.data.isResolved ?? prev.isResolved, + isPopular: message.data.isResolved === true + ? false + : (message.data.likeCount ?? prev.likeCount) >= POPULAR_LIKE_THRESHOLD, + likeCount: message.data.likeCount ?? prev.likeCount, + }; + }); + } + }, [fetchQuestion, navigate, questionId]); + + useEffect(() => { + if (!sessionId || !questionId) { + return undefined; + } + + return subscribeQuestionEvents(sessionId, { + onOpen: () => { + console.debug('질문 상세 SSE 연결 열림'); + }, + onEvent: handleQuestionEvent, + onError: (error) => { + console.error('질문 상세 SSE 연결 실패:', error); + }, + }); + }, [sessionId, questionId, handleQuestionEvent]); + // ── 메뉴 외부 클릭 시 닫기 ────────────────────── useEffect(() => { const handleClickOutside = () => { @@ -255,7 +331,7 @@ function QnADetailPage() { if (!res.ok) throw new Error(); setQuestion(prev => ({ ...prev, - comments: prev.comments.filter(c => c.commentId !== commentId), + comments: removeCommentFromTree(prev.comments ?? [], commentId), })); } catch (err) { console.error('댓글 삭제 실패:', err); @@ -283,8 +359,10 @@ function QnADetailPage() { if (json.isSuccess) { setQuestion(prev => ({ ...prev, - comments: prev.comments.map(c => - c.commentId === commentId ? { ...c, content: text } : c + comments: updateCommentInTree( + prev.comments ?? [], + commentId, + comment => ({ ...comment, content: text }) ), })); setEditingCommentId(null); @@ -298,6 +376,75 @@ function QnADetailPage() { if (!question) return
질문을 찾을 수 없어요
; const isMyQuestion = question.isMine; + const renderCommentBlock = (comment, isReply = false) => ( +
+ {/* 댓글 작성자 행 */} +
+
+ {comment.displayName} +
+ + {comment.displayName} + {comment.displayName?.startsWith('운영진') && ( + + )} + + {/* 본인 댓글만 수정/삭제 메뉴 표시 */} + {comment.isMine && ( +
+ + {commentMenuId === comment.commentId && ( +
+ + +
+ )} +
+ )} +
+ + {/* 댓글 말풍선 */} +
+ {editingCommentId === comment.commentId ? ( +
+