Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +29,15 @@ public ResponseEntity<ApiResponse<QuestionResDTO.QuestionRoomResponse>> 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);
}
Comment on lines +36 to +39

// 질문 상세 조회
// GET /api/questions/{questionId}
@GetMapping("/api/questions/{questionId}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,31 @@ public record QuestionSummaryResponse(
Boolean isPopular,
Integer likeCount,
Integer commentCount,
// 댓글이 없으면 빈 배열로 내려가며, 프론트는 빈 배열일 때 미리보기 영역을 숨긴다.
List<PreviewCommentResponse> 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<PreviewCommentResponse> previewComments
) {
}

public record UnderstandingResponseResult(
Long checkId,
UnderstandResChoice selectedChoice,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<QuestionComment, Long> {
Expand All @@ -14,15 +17,77 @@ public interface QuestionCommentRepository extends JpaRepository<QuestionComment
*/
List<QuestionComment> 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<PreviewCommentRow> findPreviewCommentsByQuestionIds(@Param("questionIds") List<Long> 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<CommentCountRow> countByQuestionIds(@Param("questionIds") List<Long> questionIds);

/*
특정 댓글의 대댓글 목록(등록순)
용도: 댓글 아래 대댓글을 가져올 때
*/
List<QuestionComment> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<Long, List<SseEmitter>> 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<SseEmitter> 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<SseEmitter> emitters = sessionEmitters.get(sessionId);
if (emitters == null) {
return;
}

emitters.remove(emitter);
if (emitters.isEmpty()) {
sessionEmitters.remove(sessionId);
}
}
}
Loading
Loading