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
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ WORKDIR /app

COPY build/libs/*.jar app.jar

ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "app.jar"]
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.example.Piroin.project.domain.user.entity.User;
import com.example.Piroin.project.domain.user.enums.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

Expand All @@ -18,4 +20,10 @@ public interface QuestionAnonymousIdentityRepository extends JpaRepository<Quest
// 용도: 새 익명 번호 발급 시 역할별로 따로 카운트
// MEMBER → 익명1, 익명2... / ADMIN → 운영진1, 운영진2...
int countByQuestionAndUser_Role(Question question, Role role);

@Query("SELECT COALESCE(MAX(a.anonymousNo), 0) FROM QuestionAnonymousIdentity a " + "WHERE a.question = :question AND a.user.role = :role")
int findMaxAnonymousNoByQuestionAndRole(
@Param("question") Question question,
@Param("role") Role role
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<SseEmitter> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ private String assignAnonymousIdentity(Question question, User commenter) {
.orElseGet(() -> {
// 처음 댓글 다는 유저 → 역할별 카운트 기반으로 새 번호 부여
int nextNo = anonymousIdentityRepository
.countByQuestionAndUser_Role(question, commenter.getRole()) + 1;
.findMaxAnonymousNoByQuestionAndRole(question, commenter.getRole()) + 1;

anonymousIdentityRepository.save(QuestionAnonymousIdentity.builder()
.question(question)
Expand Down Expand Up @@ -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);
}

// 좋아요 토글
Expand Down Expand Up @@ -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()
);
Expand All @@ -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;
}

// 공통 헬퍼 메서드
Expand Down Expand Up @@ -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()) {
Expand Down
13 changes: 13 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ spring:
hibernate:
format_sql: true
packagesToScan: com.example.Piroin.project.domain
jdbc:
time_zone: Asia/Seoul

jackson:
time-zone: Asia/Seoul

jwt:
secret: ${JWT_SECRET}
Expand All @@ -32,3 +37,11 @@ management:

file:
upload-dir: uploads/

# SSE 연결은 HTTP 커넥션을 끊지 않고 유지
# 연결당 Tomcat 스레드 하나를 점유하므로, 동시 접속자 수를 고려해 스레드 수를 넉넉하게 설정
server:
tomcat:
threads:
max: 200 # 기본값 200 (명시적으로 설정)
min-spare: 20 # 최소 대기 스레드 수
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE question_anonymous_identity
ADD CONSTRAINT uq_question_anonymous_identity_question_user
UNIQUE (question_id, user_id);
Loading
Loading