From 980fb818428c3e30448fe293360b594a6cd34a7f Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 24 May 2026 16:20:21 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Feat]=20=EB=8C=93=EA=B8=80=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=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 | 11 +++++++++++ .../repository/QuestionCommentRepository.java | 8 +++++++- .../domain/question/service/QuestionService.java | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) 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..1646775 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,6 +145,17 @@ public record QuestionSummaryResponse( Boolean isPopular, Integer likeCount, Integer commentCount, + // previewComment == NULL일 시 프론트에서 렌더링 x + List previewComments, + LocalDateTime createdAt + ) { + } + + // 질문 목록용 댓글 미리보기 응답 + public record PreviewCommentResponse( + Long commentId, + String displayName, + String content, LocalDateTime createdAt ) { } 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..26fca41 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 @@ -14,6 +14,12 @@ public interface QuestionCommentRepository extends JpaRepository findByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtAsc(Question question); + /* + 질문 목록 미리보기용 최신 최상위 댓글 3개 + 조회는 최신순으로 가져오고, 서비스에서 오래된 순으로 다시 정렬해 내려준다. + */ + List findTop3ByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtDesc(Question question); + /* 특정 댓글의 대댓글 목록(등록순) 용도: 댓글 아래 대댓글을 가져올 때 @@ -25,4 +31,4 @@ public interface QuestionCommentRepository extends JpaRepository= POPULAR_LIKE_THRESHOLD, question.getLikeCount(), questionCommentRepository.countByQuestionAndDeletedAtIsNull(question), + getPreviewComments(question), question.getCreatedAt() ); } + + private List getPreviewComments(Question question) { + return questionCommentRepository + .findTop3ByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtDesc(question) + .stream() + .sorted(Comparator.comparing(QuestionComment::getCreatedAt)) + .map(comment -> new QuestionResDTO.PreviewCommentResponse( + comment.getId(), + getDisplayName(question, comment.getUser()), + comment.getContent(), + comment.getCreatedAt() + )) + .toList(); + } } From 9ee8bfa927f4cc87065c873d6e3f83114d31136e Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 24 May 2026 16:30:09 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[Feat]=20=EC=A7=88=EB=AC=B8=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EA=B5=AC=EB=8F=85=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/QuestionController.java | 9 ++++ .../service/QuestionEventService.java | 49 +++++++++++++++++++ .../question/service/QuestionService.java | 8 +++ 3 files changed, 66 insertions(+) create mode 100644 backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java 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..7371f8d 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,13 @@ public ResponseEntity> getQuest questionService.getQuestionRoom(sessionId, understandingIndex)); } + // 질문 목록 실시간 이벤트 구독 + // GET /api/sessions/{sessionId}/questions/events + @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/service/QuestionEventService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java new file mode 100644 index 0000000..b640f01 --- /dev/null +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java @@ -0,0 +1,49 @@ +package com.example.Piroin.project.domain.question.service; + +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; + + private final Map> sessionEmitters = new ConcurrentHashMap<>(); + + 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; + } + + 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 67d6aaa..bb18211 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,6 +17,7 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.time.LocalDateTime; import java.util.Comparator; @@ -36,6 +37,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 +53,12 @@ public QuestionResDTO.QuestionRoomResponse getQuestionRoom(Long sessionId, int u ); } + @Transactional(readOnly = true) + public SseEmitter subscribeQuestionEvents(Long sessionId) { + findSession(sessionId); + return questionEventService.subscribe(sessionId); + } + // 질문 상세 조회 @Transactional(readOnly = true) public QuestionResDTO.QuestionDetailResponse getQuestionDetail(Long questionId, Long userId) { From a3c3c0e1f0db84ae20b223e97124c7544badd670 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 24 May 2026 16:36:21 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Feat]=20=EB=8C=93=EA=B8=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/dto/QuestionResDTO.java | 10 ++++++ .../service/QuestionEventService.java | 16 +++++++++ .../question/service/QuestionService.java | 34 ++++++++++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) 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 1646775..47047fe 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 @@ -160,6 +160,16 @@ public record PreviewCommentResponse( ) { } + // 질문 목록 실시간 댓글 생성 이벤트 응답 + 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/service/QuestionEventService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionEventService.java index b640f01..ff40ac9 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 @@ -1,5 +1,6 @@ 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; @@ -35,6 +36,21 @@ public SseEmitter subscribe(Long sessionId) { 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) { 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 bb18211..33ede45 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,6 +17,8 @@ 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; @@ -136,9 +138,12 @@ 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() ); + publishCommentCreatedEventAfterCommit(question); + + return response; } // parentCommentId가 있으면 해당 댓글 조회, 없으면 null 반환 @@ -499,4 +504,31 @@ private List getPreviewComments(Question )) .toList(); } + + private void publishCommentCreatedEventAfterCommit(Question question) { + Long sessionId = question.getSession().getId(); + QuestionResDTO.CommentCreatedEvent event = new QuestionResDTO.CommentCreatedEvent( + "COMMENT_CREATED", + sessionId, + question.getId(), + questionCommentRepository.countByQuestionAndDeletedAtIsNull(question), + getPreviewComments(question) + ); + + publishAfterCommit(() -> questionEventService.publishCommentCreated(sessionId, event)); + } + + private void publishAfterCommit(Runnable action) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action.run(); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + } } From 339819cebecc190ac1ef751ac2298dc5c11afcfa Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 24 May 2026 16:42:18 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[Docs]=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/controller/QuestionController.java | 1 + .../project/domain/question/dto/QuestionResDTO.java | 6 +++--- .../domain/question/service/QuestionEventService.java | 6 ++++++ .../project/domain/question/service/QuestionService.java | 8 ++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) 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 7371f8d..679a026 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 @@ -31,6 +31,7 @@ public ResponseEntity> getQuest // 질문 목록 실시간 이벤트 구독 // GET /api/sessions/{sessionId}/questions/events + // text/event-stream으로 연결을 유지하며, 댓글 생성 같은 목록 갱신 이벤트를 받는다. @GetMapping(value = "/api/sessions/{sessionId}/questions/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribeQuestionEvents(@PathVariable Long sessionId) { return 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 47047fe..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,13 +145,13 @@ public record QuestionSummaryResponse( Boolean isPopular, Integer likeCount, Integer commentCount, - // previewComment == NULL일 시 프론트에서 렌더링 x + // 댓글이 없으면 빈 배열로 내려가며, 프론트는 빈 배열일 때 미리보기 영역을 숨긴다. List previewComments, LocalDateTime createdAt ) { } - // 질문 목록용 댓글 미리보기 응답 + // 질문 목록용 댓글 미리보기 응답. 메인 목록에서는 대댓글 없이 최상위 댓글만 보여준다. public record PreviewCommentResponse( Long commentId, String displayName, @@ -160,7 +160,7 @@ public record PreviewCommentResponse( ) { } - // 질문 목록 실시간 댓글 생성 이벤트 응답 + // 댓글 생성 시 SSE로 내려가는 목록 갱신 이벤트 응답 public record CommentCreatedEvent( String type, Long sessionId, 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 ff40ac9..bec66f4 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 @@ -14,17 +14,21 @@ 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")); @@ -36,6 +40,7 @@ public SseEmitter subscribe(Long sessionId) { return emitter; } + // 댓글 생성 이벤트를 같은 세션 질문방을 구독 중인 모든 클라이언트에게 전파한다. public void publishCommentCreated(Long sessionId, QuestionResDTO.CommentCreatedEvent event) { List emitters = sessionEmitters.getOrDefault(sessionId, List.of()); @@ -51,6 +56,7 @@ public void publishCommentCreated(Long sessionId, QuestionResDTO.CommentCreatedE } } + // 더 이상 사용하지 않는 연결을 제거하고, 세션에 남은 연결이 없으면 세션 키도 정리한다. private void removeEmitter(Long sessionId, SseEmitter emitter) { List emitters = sessionEmitters.get(sessionId); if (emitters == null) { 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 33ede45..71fd0c8 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 @@ -57,6 +57,7 @@ public QuestionResDTO.QuestionRoomResponse getQuestionRoom(Long sessionId, int u @Transactional(readOnly = true) public SseEmitter subscribeQuestionEvents(Long sessionId) { + // 존재하는 세션에 대해서만 SSE 연결을 허용한다. findSession(sessionId); return questionEventService.subscribe(sessionId); } @@ -141,6 +142,8 @@ public QuestionResDTO.CommentCreateRes createComment( QuestionResDTO.CommentCreateRes response = new QuestionResDTO.CommentCreateRes( comment.getId(), question.getId(), displayName, comment.getContent(), comment.getCreatedAt() ); + + // DB 반영이 끝난 뒤 같은 질문방 구독자들이 목록 댓글 미리보기를 갱신하도록 알린다. publishCommentCreatedEventAfterCommit(question); return response; @@ -486,12 +489,14 @@ private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse(Questio !question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD, question.getLikeCount(), questionCommentRepository.countByQuestionAndDeletedAtIsNull(question), + // 목록 화면에서 바로 렌더링할 댓글 미리보기 3개를 함께 내려준다. getPreviewComments(question), question.getCreatedAt() ); } private List getPreviewComments(Question question) { + // 최신 댓글 3개를 미리보기 대상으로 삼되, 화면 표시는 댓글이 달린 순서대로 보이게 오래된 순으로 정렬한다. return questionCommentRepository .findTop3ByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtDesc(question) .stream() @@ -507,6 +512,8 @@ private List getPreviewComments(Question private void publishCommentCreatedEventAfterCommit(Question question) { Long sessionId = question.getSession().getId(); + + // 프론트가 전체 목록을 다시 조회하지 않고 해당 질문만 갱신할 수 있는 최소 데이터만 보낸다. QuestionResDTO.CommentCreatedEvent event = new QuestionResDTO.CommentCreatedEvent( "COMMENT_CREATED", sessionId, @@ -518,6 +525,7 @@ private void publishCommentCreatedEventAfterCommit(Question question) { publishAfterCommit(() -> questionEventService.publishCommentCreated(sessionId, event)); } + // 롤백된 댓글이 실시간 화면에 먼저 보이지 않도록 트랜잭션 커밋 이후에만 이벤트를 발행한다. private void publishAfterCommit(Runnable action) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { action.run(); From 116d511364f98043e4768872b7228ca5d42c5e94 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 24 May 2026 17:13:04 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[Refactor]=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/QuestionController.java | 1 + .../repository/QuestionCommentRepository.java | 75 +++++++++++-- .../question/service/QuestionService.java | 105 +++++++++++++----- 3 files changed, 147 insertions(+), 34 deletions(-) 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 679a026..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 @@ -32,6 +32,7 @@ public ResponseEntity> getQuest // 질문 목록 실시간 이벤트 구독 // 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); 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 26fca41..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 { @@ -15,10 +18,50 @@ public interface QuestionCommentRepository extends JpaRepository findByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtAsc(Question question); /* - 질문 목록 미리보기용 최신 최상위 댓글 3개 - 조회는 최신순으로 가져오고, 서비스에서 오래된 순으로 다시 정렬해 내려준다. + 질문 목록 미리보기용 최상위 댓글 3개를 질문별로 한 번에 조회한다. + row_number()로 각 질문의 오래된 댓글 3개만 남겨 N+1 조회를 피한다. */ - List findTop3ByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtDesc(Question question); + @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); /* 특정 댓글의 대댓글 목록(등록순) @@ -26,9 +69,25 @@ public interface QuestionCommentRepository extends JpaRepository findByParentCommentAndDeletedAtIsNullOrderByCreatedAtAsc(QuestionComment parentComment); - /* - 특정 질문의 삭제되지 않은 댓글 수(대댓글 포함) - 용도: 질문 목록에서 "댓글 N개" 표시 시 - */ - int countByQuestionAndDeletedAtIsNull(Question question); + 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/QuestionService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java index 71fd0c8..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 @@ -22,8 +22,12 @@ 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 @@ -462,74 +466,117 @@ 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), - // 목록 화면에서 바로 렌더링할 댓글 미리보기 3개를 함께 내려준다. - getPreviewComments(question), + summaryContext.commentCounts().getOrDefault(questionId, 0), + // 목록 화면은 최상위 댓글 중 먼저 달린 3개만 미리보기로 보여준다. + summaryContext.previewComments().getOrDefault(questionId, List.of()), question.getCreatedAt() ); } - private List getPreviewComments(Question question) { - // 최신 댓글 3개를 미리보기 대상으로 삼되, 화면 표시는 댓글이 달린 순서대로 보이게 오래된 순으로 정렬한다. - return questionCommentRepository - .findTop3ByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtDesc(question) - .stream() - .sorted(Comparator.comparing(QuestionComment::getCreatedAt)) - .map(comment -> new QuestionResDTO.PreviewCommentResponse( - comment.getId(), - getDisplayName(question, comment.getUser()), - comment.getContent(), - comment.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, - question.getId(), - questionCommentRepository.countByQuestionAndDeletedAtIsNull(question), - getPreviewComments(question) + 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()) { - action.run(); - return; + throw new IllegalStateException("publishAfterCommit must be called within an active transaction synchronization"); } TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @@ -539,4 +586,10 @@ public void afterCommit() { } }); } + + private record QuestionSummaryContext( + Map commentCounts, + Map> previewComments + ) { + } }