diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java index b407419..0c7fef7 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceCodeRepository.java @@ -5,14 +5,13 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; import java.time.LocalDate; import java.util.List; import java.util.Optional; -public interface AttendanceCodeRepository extends JpaRepository { +public interface AttendanceCodeRepository extends JpaRepository { // [추가] 모든 활성화된 코드를 한 번에 만료 처리 (벌크 연산) @Modifying @@ -34,4 +33,3 @@ public interface AttendanceCodeRepository extends JpaRepository findByAttendanceDate(LocalDate attendanceDate); } - diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java index 86aeb01..ccd4e54 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/repository/AttendanceRepository.java @@ -1,27 +1,20 @@ package com.example.Piroin.project.domain.attendance.repository; -import com.example.Piroin.project.domain.attendance.entity.AttendanceCode; -import com.example.Piroin.project.domain.curriculum.entity.StudySession; import com.example.Piroin.project.domain.user.entity.User; import com.example.Piroin.project.domain.attendance.entity.Attendance; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; import java.time.LocalDate; import java.util.List; import java.util.Optional; -public interface AttendanceRepository extends JpaRepository { +public interface AttendanceRepository extends JpaRepository { Optional findById(Integer id); - // 연관관계 필드명이 attendanceCode 라면 내부 ID인 Id를 조합하여 명명 - Optional findByUserIdAndAttendanceCodeId(Long userId, Long attendanceCodeId); - - int countByUserAndStatusFalse(User user); // 1. 특정 출석 코드 ID에 해당하는 결석 데이터 조회 diff --git a/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java b/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java index 2395098..8d46c95 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/attendance/service/AttendanceService.java @@ -165,7 +165,7 @@ public AttendanceMarkResponse markAttendance(Long userId, String inputCode) { // 혹은 조회된 code의 날짜/차수 정보를 기반으로 기존 출석 기록을 찾아야 합니다. // (여기서는 이전 답변 시나리오 1인 'attendanceCodeId'로 매핑했다고 가정했을 때의 예시입니다.) Attendance attendance = attendanceRepository - .findByUserIdAndAttendanceCodeId(userId, Long.valueOf(code.getId())) + .findByUserIdAndAttendanceCodeId(userId, code.getId()) .orElse(null); // 해당 사용자와 출석 코드에 대한 출석 기록이 존재하지 않는 경우 @@ -323,4 +323,3 @@ public List findByUserId(Integer userId) { } - diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java index ed5f1d5..3138079 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/repository/DepositRepository.java @@ -7,7 +7,7 @@ import java.util.List; import java.util.Optional; -public interface DepositRepository extends JpaRepository { +public interface DepositRepository extends JpaRepository { Optional findByUser(User user); Optional findByUserId(Long userId); diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java index 94a617e..3759ddf 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionAnonymousIdentity.java @@ -1,6 +1,7 @@ package com.example.Piroin.project.domain.question.entity; import com.example.Piroin.project.domain.user.entity.User; +import com.example.Piroin.project.domain.user.enums.Role; import jakarta.persistence.*; import lombok.*; @@ -11,12 +12,12 @@ name = "question_anonymous_identity", uniqueConstraints = { @UniqueConstraint( - name = "uq_question_anon_question_user", + name = "uq_question_anonymous_identity_question_user", columnNames = {"question_id", "user_id"} ), @UniqueConstraint( - name = "uq_question_anon_question_no", - columnNames = {"question_id", "anonymous_no"} + name = "uq_question_anonymous_identity_question_role_no", + columnNames = {"question_id", "role", "anonymous_no"} ) } ) @@ -41,7 +42,10 @@ public class QuestionAnonymousIdentity { @Column(name = "anonymous_no", nullable = false) private Integer anonymousNo; + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + private Role role; + @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; } - diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java index 53448d9..a2d1e22 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionAnonymousIdentityRepository.java @@ -8,7 +8,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; +import java.util.Set; public interface QuestionAnonymousIdentityRepository extends JpaRepository { @@ -16,14 +18,27 @@ public interface QuestionAnonymousIdentityRepository extends JpaRepository findByQuestionAndUser(Question question, User user); - // 해당 질문에서 특정 역할(MEMBER/ADMIN)의 익명 번호 수 조회 - // 용도: 새 익명 번호 발급 시 역할별로 따로 카운트 - // MEMBER → 익명1, 익명2... / ADMIN → 운영진1, 운영진2... - int countByQuestionAndUser_Role(Question question, Role role); + // 질문 상세 조회용: 댓글 작성자들의 익명 번호를 한 번에 조회 + @Query(""" + SELECT identity + FROM QuestionAnonymousIdentity identity + JOIN FETCH identity.user + WHERE identity.question = :question + AND identity.user.id IN :userIds + """) + List findByQuestionAndUserIds( + @Param("question") Question question, + @Param("userIds") Set userIds + ); - @Query("SELECT COALESCE(MAX(a.anonymousNo), 0) FROM QuestionAnonymousIdentity a " + "WHERE a.question = :question AND a.user.role = :role") + @Query(""" + SELECT COALESCE(MAX(identity.anonymousNo), 0) + FROM QuestionAnonymousIdentity identity + WHERE identity.question = :question + AND identity.role = :role + """) int findMaxAnonymousNoByQuestionAndRole( @Param("question") Question question, @Param("role") Role role ); -} \ No newline at end of file +} 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 95da5bf..521b5bb 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 @@ -11,11 +11,19 @@ public interface QuestionCommentRepository extends JpaRepository { /* - 특정 질문의 삭제되지 않은 최상위 댓글 목록(등록순) - parentComment가 null인 것 = 대댓글이 아닌 최상위 댓글 - 용도: 질문 상세 페이지에서 댓글 목록 표시 시 + 질문 상세 조회용 댓글 목록을 한 번에 가져온다. + 댓글 작성자와 부모 댓글을 함께 로딩해 댓글/대댓글 DTO 조립 중 N+1 조회를 피한다. */ - List findByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtAsc(Question question); + @Query(""" + SELECT comment + FROM QuestionComment comment + JOIN FETCH comment.user + LEFT JOIN FETCH comment.parentComment + WHERE comment.question = :question + AND comment.deletedAt IS NULL + ORDER BY comment.createdAt ASC, comment.id ASC + """) + List findByQuestionWithUserAndParentComment(@Param("question") Question question); /* 질문 목록 미리보기용 최상위 댓글 3개를 질문별로 한 번에 조회한다. @@ -25,7 +33,7 @@ public interface QuestionCommentRepository extends JpaRepository countByQuestionIds(@Param("questionIds") List questionIds); - /* - 특정 댓글의 대댓글 목록(등록순) - 용도: 댓글 아래 대댓글을 가져올 때 - */ - List findByParentCommentAndDeletedAtIsNullOrderByCreatedAtAsc(QuestionComment parentComment); - interface PreviewCommentRow { Long getQuestionId(); diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java index 9f5a941..b664c31 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/repository/QuestionRepository.java @@ -2,7 +2,11 @@ import com.example.Piroin.project.domain.curriculum.entity.StudySession; import com.example.Piroin.project.domain.question.entity.Question; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -19,4 +23,28 @@ public interface QuestionRepository extends JpaRepository { 용도: 질문 상세 조회, 수정, 삭제, 좋아요 처리 시 */ Optional findByIdAndDeletedAtIsNull(Long id); -} \ No newline at end of file + + /* + 질문 상세 조회용: 질문 작성자를 함께 가져와 상세 DTO 조립 중 추가 조회를 피한다. + */ + @Query(""" + SELECT question + FROM Question question + JOIN FETCH question.user + WHERE question.id = :id + AND question.deletedAt IS NULL + """) + Optional findDetailByIdAndDeletedAtIsNull(@Param("id") Long id); + + /* + 좋아요 카운트 갱신용: 같은 질문에 대한 동시 토글 요청을 직렬화해 likeCount lost update를 방지한다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT question + FROM Question question + WHERE question.id = :id + AND question.deletedAt IS NULL + """) + Optional findByIdAndDeletedAtIsNullForUpdate(@Param("id") Long id); +} 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 008597d..1c6e6b3 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 @@ -75,7 +75,7 @@ public SseEmitter subscribeQuestionEvents(Long sessionId) { @Transactional(readOnly = true) public QuestionResDTO.QuestionDetailResponse getQuestionDetail(Long questionId, Long userId) { User loginUser = findLoginUser(userId); - Question question = findQuestion(questionId); + Question question = findQuestionDetail(questionId); return toDetailResponse(question, loginUser); } @@ -84,11 +84,11 @@ private QuestionResDTO.QuestionDetailResponse toDetailResponse(Question question boolean isMine = question.getUser().getId().equals(loginUser.getId()); boolean isPopular = !question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD; - List topComments = - questionCommentRepository.findByQuestionAndParentCommentIsNullAndDeletedAtIsNullOrderByCreatedAtAsc(question); + List comments = questionCommentRepository.findByQuestionWithUserAndParentComment(question); + DetailCommentContext commentContext = getDetailCommentContext(question, comments); - List commentResponses = topComments.stream() - .map(comment -> toCommentResponse(question, comment, loginUser)) + List commentResponses = commentContext.topComments().stream() + .map(comment -> toTopLevelCommentResponse(question, comment, loginUser, commentContext)) .toList(); return new QuestionResDTO.QuestionDetailResponse( @@ -99,25 +99,72 @@ private QuestionResDTO.QuestionDetailResponse toDetailResponse(Question question ); } - private QuestionResDTO.CommentResponse toCommentResponse(Question question, QuestionComment comment, User loginUser) { - List replies = - questionCommentRepository.findByParentCommentAndDeletedAtIsNullOrderByCreatedAtAsc(comment); + private DetailCommentContext getDetailCommentContext(Question question, List comments) { + List topComments = new ArrayList<>(); + Map> repliesByParentId = new HashMap<>(); + Set commenterIds = new HashSet<>(); + + for (QuestionComment comment : comments) { + commenterIds.add(comment.getUser().getId()); + + QuestionComment parentComment = comment.getParentComment(); + if (parentComment == null) { + topComments.add(comment); + continue; + } + repliesByParentId.computeIfAbsent(parentComment.getId(), key -> new ArrayList<>()) + .add(comment); + } + + Long questionAuthorId = question.getUser().getId(); + Set anonymousUserIds = commenterIds.stream() + .filter(commenterId -> !commenterId.equals(questionAuthorId)) + .collect(Collectors.toSet()); + + Map anonymousIdentitiesByUserId = new HashMap<>(); + if (!anonymousUserIds.isEmpty()) { + anonymousIdentityRepository.findByQuestionAndUserIds(question, anonymousUserIds) + .forEach(identity -> anonymousIdentitiesByUserId.put( + identity.getUser().getId(), + new AnonymousIdentityDisplay(identity.getRole(), identity.getAnonymousNo()) + )); + } - List replyResponses = replies.stream() - .map(reply -> new QuestionResDTO.CommentResponse( - reply.getId(), getDisplayName(question, reply.getUser()), - reply.getContent(), reply.getImageUrls(), isCommentMine(reply, loginUser), - reply.getCreatedAt(), List.of() - )) + return new DetailCommentContext(topComments, repliesByParentId, anonymousIdentitiesByUserId); + } + + private QuestionResDTO.CommentResponse toTopLevelCommentResponse( + Question question, + QuestionComment comment, + User loginUser, + DetailCommentContext commentContext + ) { + List replyResponses = commentContext.repliesByParentId() + .getOrDefault(comment.getId(), List.of()) + .stream() + .map(reply -> toReplyCommentResponse(question, reply, loginUser, commentContext)) .toList(); return new QuestionResDTO.CommentResponse( - comment.getId(), getDisplayName(question, comment.getUser()), + comment.getId(), getDisplayName(question, comment.getUser(), commentContext.anonymousIdentitiesByUserId()), comment.getContent(), comment.getImageUrls(), isCommentMine(comment, loginUser), comment.getCreatedAt(), replyResponses ); } + private QuestionResDTO.CommentResponse toReplyCommentResponse( + Question question, + QuestionComment reply, + User loginUser, + DetailCommentContext commentContext + ) { + return new QuestionResDTO.CommentResponse( + reply.getId(), getDisplayName(question, reply.getUser(), commentContext.anonymousIdentitiesByUserId()), + reply.getContent(), reply.getImageUrls(), isCommentMine(reply, loginUser), + reply.getCreatedAt(), List.of() + ); + } + private boolean isCommentMine(QuestionComment comment, User loginUser) { return comment.getUser().getId().equals(loginUser.getId()); } @@ -131,7 +178,7 @@ public QuestionResDTO.CommentCreateRes createComment( Long userId ) { User loginUser = findLoginUser(userId); - Question question = findQuestion(questionId); + Question question = findQuestionForUpdate(questionId); // 1. 대댓글 여부 확인: parentCommentId가 있으면 부모 댓글 조회 QuestionComment parentComment = resolveParentComment(request.getParentCommentId(), question); @@ -212,7 +259,7 @@ private String assignAnonymousIdentity(Question question, User commenter) { // 이미 이 질문에서 익명 번호가 있는지 확인 return anonymousIdentityRepository .findByQuestionAndUser(question, commenter) - .map(identity -> buildDisplayName(commenter.getRole(), identity.getAnonymousNo())) + .map(identity -> buildDisplayName(identity.getRole(), identity.getAnonymousNo())) .orElseGet(() -> { // 처음 댓글 다는 유저 → 역할별 카운트 기반으로 새 번호 부여 int nextNo = anonymousIdentityRepository @@ -222,6 +269,7 @@ private String assignAnonymousIdentity(Question question, User commenter) { .question(question) .user(commenter) .anonymousNo(nextNo) + .role(commenter.getRole()) .createdAt(LocalDateTime.now()) .build()); @@ -234,15 +282,20 @@ private String buildDisplayName(Role role, int anonymousNo) { return role == Role.ADMIN ? "운영진" + anonymousNo : "익명" + anonymousNo; } - // getDisplayName: 상세 조회 시 기존 익명 번호 읽기 (번호 부여 없음) - private String getDisplayName(Question question, User commenter) { + private String getDisplayName( + Question question, + User commenter, + Map anonymousIdentitiesByUserId + ) { if (commenter.getId().equals(question.getUser().getId())) { return "작성자"; } - return anonymousIdentityRepository - .findByQuestionAndUser(question, commenter) - .map(identity -> buildDisplayName(commenter.getRole(), identity.getAnonymousNo())) - .orElse(commenter.getRole() == Role.ADMIN ? "운영진" : "익명"); + + AnonymousIdentityDisplay identity = anonymousIdentitiesByUserId.get(commenter.getId()); + if (identity == null) { + return commenter.getRole() == Role.ADMIN ? "운영진" : "익명"; + } + return buildDisplayName(identity.role(), identity.anonymousNo()); } // 질문 등록 @@ -278,7 +331,7 @@ public QuestionResDTO.CreateRes createQuestion(Long sessionId, QuestionReqDTO.Cr @Transactional public QuestionResDTO.LikeRes toggleLike(Long questionId, Long userId) { User loginUser = findLoginUser(userId); - Question question = findQuestion(questionId); + Question question = findQuestionForUpdate(questionId); // 이미 좋아요를 눌렀는지 확인 QuestionResDTO.LikeRes result = questionLikeRepository.findByQuestionAndUser(question, loginUser) @@ -476,6 +529,16 @@ private Question findQuestion(Long questionId) { .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); } + private Question findQuestionDetail(Long questionId) { + return questionRepository.findDetailByIdAndDeletedAtIsNull(questionId) + .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); + } + + private Question findQuestionForUpdate(Long questionId) { + return questionRepository.findByIdAndDeletedAtIsNullForUpdate(questionId) + .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); + } + private StudySession findSession(Long sessionId) { return curriculumRepository.findById(sessionId) .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다.")); @@ -871,6 +934,19 @@ private record QuestionSummaryContext( ) { } + private record DetailCommentContext( + List topComments, + Map> repliesByParentId, + Map anonymousIdentitiesByUserId + ) { + } + + private record AnonymousIdentityDisplay( + Role role, + Integer anonymousNo + ) { + } + // 질문은 내용 또는 이미지 중 하나는 반드시 있어야 함 private void validateQuestionContent(String content, List imageUrls) { boolean hasContent = content != null && !content.isBlank(); diff --git a/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java b/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java index 28ed7a3..dfd4873 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/user/service/AdminUserService.java @@ -101,8 +101,8 @@ public UpdateStudentStatusResponse updateStudentWeekStatus( } if (request.getAttendances() != null) { - List attendanceIds = request.getAttendances().stream() - .map(dto -> dto.getAttendanceId().longValue()) + List attendanceIds = request.getAttendances().stream() + .map(UpdateStudentStatusRequest.AttendanceStatusRequest::getAttendanceId) .toList(); Map attendanceMap = attendanceRepository.findAllById(attendanceIds).stream() @@ -176,4 +176,4 @@ private int calculateAssignmentPenalty(AssignmentStatus status) { // 6. 출석에 대한 보증금 계산 로직 // (AttendanceService에 있음!!) -} \ No newline at end of file +} diff --git a/backend/src/main/resources/db/migration/V8__drop_question_anonymous_no_unique_constraint.sql b/backend/src/main/resources/db/migration/V8__drop_question_anonymous_no_unique_constraint.sql new file mode 100644 index 0000000..0b8117e --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__drop_question_anonymous_no_unique_constraint.sql @@ -0,0 +1,4 @@ +-- Anonymous numbers are scoped by role, so member #1 and admin #1 can coexist in the same question. +-- Drop the legacy question_id + anonymous_no constraint if it exists. +ALTER TABLE question_anonymous_identity + DROP CONSTRAINT IF EXISTS uq_question_anon_question_no; diff --git a/backend/src/main/resources/db/migration/V9__add_role_to_question_anonymous_identity.sql b/backend/src/main/resources/db/migration/V9__add_role_to_question_anonymous_identity.sql new file mode 100644 index 0000000..e79e6fd --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__add_role_to_question_anonymous_identity.sql @@ -0,0 +1,33 @@ +ALTER TABLE question_anonymous_identity + ADD COLUMN role VARCHAR(20); + +UPDATE question_anonymous_identity identity +SET role = users.role +FROM users +WHERE identity.user_id = users.id + AND identity.role IS NULL; + +-- Normalize any duplicate numbers that may have been created while the role-scoped constraint was absent. +WITH ranked_identity AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY question_id, role + ORDER BY anonymous_no ASC, created_at ASC, id ASC + ) AS next_anonymous_no + FROM question_anonymous_identity +) +UPDATE question_anonymous_identity identity +SET anonymous_no = ranked_identity.next_anonymous_no +FROM ranked_identity +WHERE identity.id = ranked_identity.id; + +ALTER TABLE question_anonymous_identity + ALTER COLUMN role SET NOT NULL; + +ALTER TABLE question_anonymous_identity + ADD CONSTRAINT chk_question_anonymous_identity_role + CHECK (role IN ('ADMIN', 'MEMBER')); + +ALTER TABLE question_anonymous_identity + ADD CONSTRAINT uq_question_anonymous_identity_question_role_no + UNIQUE (question_id, role, anonymous_no);