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 7823111..ea15efc 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 @@ -7,9 +7,11 @@ import com.example.Piroin.project.global.response.ApiResponse; import com.example.Piroin.project.global.response.ResponseUtil; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @RequiredArgsConstructor @@ -27,6 +29,15 @@ public ResponseEntity> getQuest questionService.getQuestionRoom(sessionId, understandingIndex)); } + // 질문 목록 실시간 이벤트 구독 + // GET /api/sessions/{sessionId}/questions/events + // 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); + } + // 질문 상세 조회 // GET /api/questions/{questionId} @GetMapping("/api/questions/{questionId}") 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 104f79b..0e006dc 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 @@ -145,10 +145,31 @@ public record QuestionSummaryResponse( Boolean isPopular, Integer likeCount, Integer commentCount, + // 댓글이 없으면 빈 배열로 내려가며, 프론트는 빈 배열일 때 미리보기 영역을 숨긴다. + List previewComments, LocalDateTime createdAt ) { } + // 질문 목록용 댓글 미리보기 응답. 메인 목록에서는 대댓글 없이 최상위 댓글만 보여준다. + public record PreviewCommentResponse( + Long commentId, + String displayName, + String content, + LocalDateTime createdAt + ) { + } + + // 댓글 생성 시 SSE로 내려가는 목록 갱신 이벤트 응답 + public record CommentCreatedEvent( + String type, + Long sessionId, + Long questionId, + Integer commentCount, + List previewComments + ) { + } + public record UnderstandingResponseResult( Long checkId, UnderstandResChoice selectedChoice, diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java index 065398e..87c2d5a 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionCommentRepository.java @@ -3,7 +3,10 @@ import com.example.Piroin.project.domain.question.entity.Question; import com.example.Piroin.project.domain.question.entity.QuestionComment; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; public interface QuestionCommentRepository extends JpaRepository { @@ -14,15 +17,77 @@ public interface QuestionCommentRepository extends JpaRepository findByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtAsc(Question question); + /* + 질문 목록 미리보기용 최상위 댓글 3개를 질문별로 한 번에 조회한다. + row_number()로 각 질문의 오래된 댓글 3개만 남겨 N+1 조회를 피한다. + */ + @Query(value = """ + SELECT ranked.question_id AS "questionId", + ranked.id AS "commentId", + ranked.user_id AS "userId", + u.role AS "userRole", + ranked.content AS "content", + ranked.created_at AS "createdAt", + qai.anonymous_no AS "anonymousNo" + FROM ( + SELECT qc.*, + ROW_NUMBER() OVER ( + PARTITION BY qc.question_id + ORDER BY qc.created_at ASC, qc.id ASC + ) AS rn + FROM question_comment qc + WHERE qc.question_id IN (:questionIds) + AND qc.parent_comment_id IS NULL + AND qc.deleted_at IS NULL + ) ranked + JOIN users u ON u.id = ranked.user_id + LEFT JOIN question_anonymous_identity qai + ON qai.question_id = ranked.question_id + AND qai.user_id = ranked.user_id + WHERE ranked.rn <= 3 + ORDER BY ranked.question_id ASC, ranked.created_at ASC, ranked.id ASC + """, nativeQuery = true) + List findPreviewCommentsByQuestionIds(@Param("questionIds") List questionIds); + + /* + 질문 목록의 댓글 수를 질문별로 한 번에 조회한다. + 대댓글도 댓글 수에 포함한다. + */ + @Query(""" + SELECT comment.question.id AS questionId, + COUNT(comment.id) AS commentCount + FROM QuestionComment comment + WHERE comment.question.id IN :questionIds + AND comment.deletedAt IS NULL + GROUP BY comment.question.id + """) + List countByQuestionIds(@Param("questionIds") List questionIds); + /* 특정 댓글의 대댓글 목록(등록순) 용도: 댓글 아래 대댓글을 가져올 때 */ List findByParentCommentAndDeletedAtIsNullOrderByCreatedAtAsc(QuestionComment parentComment); - /* - 특정 질문의 삭제되지 않은 댓글 수(대댓글 포함) - 용도: 질문 목록에서 "댓글 N개" 표시 시 - */ - int countByQuestionAndDeletedAtIsNull(Question question); -} \ No newline at end of file + interface PreviewCommentRow { + Long getQuestionId(); + + Long getCommentId(); + + Long getUserId(); + + String getUserRole(); + + String getContent(); + + LocalDateTime getCreatedAt(); + + Integer getAnonymousNo(); + } + + interface CommentCountRow { + Long getQuestionId(); + + Long getCommentCount(); + } +} 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 new file mode 100644 index 0000000..bec66f4 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java @@ -0,0 +1,71 @@ +package com.example.Piroin.project.domain.question.service; + +import com.example.Piroin.project.domain.question.dto.QuestionResDTO; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +@Service +public class QuestionEventService { + private static final long SSE_TIMEOUT_MILLIS = 60L * 60L * 1000L; + + // sessionId별로 현재 질문방을 보고 있는 SSE 연결들을 보관한다. + private final Map> sessionEmitters = new ConcurrentHashMap<>(); + + // 클라이언트가 질문방에 들어오면 SSE 연결을 열고 해당 세션 구독자로 등록한다. + public SseEmitter subscribe(Long sessionId) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS); + sessionEmitters.computeIfAbsent(sessionId, key -> new CopyOnWriteArrayList<>()).add(emitter); + + // 페이지 이탈, 타임아웃, 네트워크 오류 시 죽은 연결이 남지 않도록 제거한다. + emitter.onCompletion(() -> removeEmitter(sessionId, emitter)); + emitter.onTimeout(() -> removeEmitter(sessionId, emitter)); + emitter.onError(error -> removeEmitter(sessionId, emitter)); + + try { + // 최초 연결 확인용 이벤트. 프론트는 이 이벤트로 구독 성공을 확인할 수 있다. + emitter.send(SseEmitter.event() + .name("connected") + .data("connected")); + } catch (IOException | IllegalStateException e) { + removeEmitter(sessionId, emitter); + emitter.completeWithError(e); + } + + return emitter; + } + + // 댓글 생성 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. + public void publishCommentCreated(Long sessionId, QuestionResDTO.CommentCreatedEvent event) { + List emitters = sessionEmitters.getOrDefault(sessionId, List.of()); + + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event() + .name("comment-created") + .data(event)); + } catch (IOException | IllegalStateException e) { + removeEmitter(sessionId, emitter); + emitter.completeWithError(e); + } + } + } + + // 더 이상 사용하지 않는 연결을 제거하고, 세션에 남은 연결이 없으면 세션 키도 정리한다. + private void removeEmitter(Long sessionId, SseEmitter emitter) { + List emitters = sessionEmitters.get(sessionId); + if (emitters == null) { + return; + } + + emitters.remove(emitter); + if (emitters.isEmpty()) { + sessionEmitters.remove(sessionId); + } + } +} 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 1543a26..4b22adc 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 @@ -17,10 +17,17 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -36,6 +43,7 @@ public class QuestionService { private final UnderstandingResponseRepository understandingResponseRepository; private final CurriculumRepository curriculumRepository; private final UserRepository userRepository; + private final QuestionEventService questionEventService; // 질문 방 조회 @Transactional(readOnly = true) @@ -51,6 +59,13 @@ public QuestionResDTO.QuestionRoomResponse getQuestionRoom(Long sessionId, int u ); } + @Transactional(readOnly = true) + public SseEmitter subscribeQuestionEvents(Long sessionId) { + // 존재하는 세션에 대해서만 SSE 연결을 허용한다. + findSession(sessionId); + return questionEventService.subscribe(sessionId); + } + // 질문 상세 조회 @Transactional(readOnly = true) public QuestionResDTO.QuestionDetailResponse getQuestionDetail(Long questionId, Long userId) { @@ -128,9 +143,14 @@ public QuestionResDTO.CommentCreateRes createComment( // 4. 표시명 결정 (질문 작성자가 아닌 경우 익명 번호 부여) String displayName = assignAnonymousIdentity(question, loginUser); - return new QuestionResDTO.CommentCreateRes( + QuestionResDTO.CommentCreateRes response = new QuestionResDTO.CommentCreateRes( comment.getId(), question.getId(), displayName, comment.getContent(), comment.getCreatedAt() ); + + // DB 반영이 끝난 뒤 같은 질문방 구독자들이 목록 댓글 미리보기를 갱신하도록 알린다. + publishCommentCreatedEventAfterCommit(question); + + return response; } // parentCommentId가 있으면 해당 댓글 조회, 없으면 null 반환 @@ -446,34 +466,130 @@ private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse(U private QuestionResDTO.QuestionGroupsResponse getQuestionGroups(StudySession session) { List questions = questionRepository.findBySessionAndDeletedAtIsNull(session); + QuestionSummaryContext summaryContext = getQuestionSummaryContext(questions); List popularQuestions = questions.stream() .filter(q -> !q.getIsResolved() && q.getLikeCount() >= POPULAR_LIKE_THRESHOLD) .sorted(Comparator.comparing(Question::getLikeCount, Comparator.reverseOrder()) .thenComparing(Question::getCreatedAt, Comparator.reverseOrder())) - .map(this::toQuestionSummaryResponse).toList(); + .map(question -> toQuestionSummaryResponse(question, summaryContext)).toList(); List unresolvedQuestions = questions.stream() .filter(q -> !q.getIsResolved() && q.getLikeCount() < POPULAR_LIKE_THRESHOLD) .sorted(Comparator.comparing(Question::getCreatedAt, Comparator.reverseOrder())) - .map(this::toQuestionSummaryResponse).toList(); + .map(question -> toQuestionSummaryResponse(question, summaryContext)).toList(); List resolvedQuestions = questions.stream() .filter(Question::getIsResolved) .sorted(Comparator.comparing(Question::getCreatedAt, Comparator.reverseOrder())) - .map(this::toQuestionSummaryResponse).toList(); + .map(question -> toQuestionSummaryResponse(question, summaryContext)).toList(); return new QuestionResDTO.QuestionGroupsResponse(popularQuestions, unresolvedQuestions, resolvedQuestions); } - private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse(Question question) { + private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse( + Question question, + QuestionSummaryContext summaryContext + ) { + Long questionId = question.getId(); return new QuestionResDTO.QuestionSummaryResponse( - question.getId(), question.getContent(), question.getImageUrl(), + questionId, question.getContent(), question.getImageUrl(), question.getIsResolved(), !question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD, question.getLikeCount(), - questionCommentRepository.countByQuestionAndDeletedAtIsNull(question), + summaryContext.commentCounts().getOrDefault(questionId, 0), + // 목록 화면은 최상위 댓글 중 먼저 달린 3개만 미리보기로 보여준다. + summaryContext.previewComments().getOrDefault(questionId, List.of()), question.getCreatedAt() ); } + + private QuestionSummaryContext getQuestionSummaryContext(List questions) { + if (questions.isEmpty()) { + return new QuestionSummaryContext(Map.of(), Map.of()); + } + + List questionIds = questions.stream() + .map(Question::getId) + .toList(); + Map questionsById = questions.stream() + .collect(Collectors.toMap(Question::getId, question -> question)); + + 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 -> { + Question question = questionsById.get(row.getQuestionId()); + if (question == null) { + return; + } + previewComments.computeIfAbsent(row.getQuestionId(), key -> new ArrayList<>()) + .add(toPreviewCommentResponse(question, row)); + }); + + return new QuestionSummaryContext(commentCounts, previewComments); + } + + private QuestionResDTO.PreviewCommentResponse toPreviewCommentResponse( + Question question, + QuestionCommentRepository.PreviewCommentRow row + ) { + return new QuestionResDTO.PreviewCommentResponse( + row.getCommentId(), + getPreviewDisplayName(question, row), + row.getContent(), + row.getCreatedAt() + ); + } + + private String getPreviewDisplayName(Question question, QuestionCommentRepository.PreviewCommentRow row) { + if (row.getUserId().equals(question.getUser().getId())) { + return "작성자"; + } + if (row.getAnonymousNo() == null) { + Role role = Role.valueOf(row.getUserRole()); + return role == Role.ADMIN ? "운영진" : "익명"; + } + return buildDisplayName(Role.valueOf(row.getUserRole()), row.getAnonymousNo()); + } + + private void publishCommentCreatedEventAfterCommit(Question question) { + Long sessionId = question.getSession().getId(); + Long questionId = question.getId(); + QuestionSummaryContext summaryContext = getQuestionSummaryContext(List.of(question)); + + // 프론트가 전체 목록을 다시 조회하지 않고 해당 질문만 갱신할 수 있는 최소 데이터만 보낸다. + QuestionResDTO.CommentCreatedEvent event = new QuestionResDTO.CommentCreatedEvent( + "COMMENT_CREATED", + sessionId, + questionId, + summaryContext.commentCounts().getOrDefault(questionId, 0), + summaryContext.previewComments().getOrDefault(questionId, List.of()) + ); + + publishAfterCommit(() -> questionEventService.publishCommentCreated(sessionId, event)); + } + + // 롤백된 댓글이 실시간 화면에 먼저 보이지 않도록, 활성화된 트랜잭션 동기화 안에서만 커밋 이후 이벤트를 발행한다. + private void publishAfterCommit(Runnable action) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new IllegalStateException("publishAfterCommit must be called within an active transaction synchronization"); + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + } + + private record QuestionSummaryContext( + Map commentCounts, + Map> previewComments + ) { + } }