From 7e6bc95a2eb995492256bc8b0d197dd60b9a5832 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 16 Jun 2026 20:45:12 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Fix]=20=EC=B6=9C=EC=84=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B3=B4=EC=A6=9D=EA=B8=88=20ID=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/repository/AttendanceCodeRepository.java | 4 +--- .../attendance/repository/AttendanceRepository.java | 9 +-------- .../domain/attendance/service/AttendanceService.java | 3 +-- .../domain/deposit/repository/DepositRepository.java | 2 +- .../project/domain/user/service/AdminUserService.java | 6 +++--- 5 files changed, 7 insertions(+), 17 deletions(-) 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/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 +} From 0aff7b19a4cb751d159975a90d1ae525d97f4747 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 16 Jun 2026 20:50:49 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[Fix]=20=EC=A7=88=EB=AC=B8=20=EC=9D=B5?= =?UTF-8?q?=EB=AA=85=20=EB=B2=88=ED=98=B8=20=EC=9C=A0=EB=8B=88=ED=81=AC=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/entity/QuestionAnonymousIdentity.java | 7 +------ .../V8__drop_question_anonymous_no_unique_constraint.sql | 4 ++++ 2 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V8__drop_question_anonymous_no_unique_constraint.sql 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..c6ea157 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 @@ -11,12 +11,8 @@ 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"} ) } ) @@ -44,4 +40,3 @@ public class QuestionAnonymousIdentity { @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; } - 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; From 5179e64300bea22cca1c7f62c4e8e604dbcaee3c Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 16 Jun 2026 21:15:48 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Fix]=20=EC=A7=88=EB=AC=B8=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20N+1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../QuestionAnonymousIdentityRepository.java | 17 ++- .../repository/QuestionCommentRepository.java | 22 ++-- .../repository/QuestionRepository.java | 16 ++- .../question/service/QuestionService.java | 101 ++++++++++++++---- 4 files changed, 123 insertions(+), 33 deletions(-) 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..458dc03 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,6 +18,19 @@ public interface QuestionAnonymousIdentityRepository extends JpaRepository findByQuestionAndUser(Question question, User user); + // 질문 상세 조회용: 댓글 작성자들의 익명 번호를 한 번에 조회 + @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 + ); + // 해당 질문에서 특정 역할(MEMBER/ADMIN)의 익명 번호 수 조회 // 용도: 새 익명 번호 발급 시 역할별로 따로 카운트 // MEMBER → 익명1, 익명2... / ADMIN → 운영진1, 운영진2... @@ -26,4 +41,4 @@ 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..7d3bc11 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개를 질문별로 한 번에 조회한다. @@ -64,12 +72,6 @@ WHERE qc.question_id IN (:questionIds) """) List 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..4b6db37 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 @@ -3,6 +3,8 @@ import com.example.Piroin.project.domain.curriculum.entity.StudySession; import com.example.Piroin.project.domain.question.entity.Question; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -19,4 +21,16 @@ 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); +} 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..1ad6930 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,71 @@ 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 anonymousNumbersByUserId = new HashMap<>(); + if (!anonymousUserIds.isEmpty()) { + anonymousIdentityRepository.findByQuestionAndUserIds(question, anonymousUserIds) + .forEach(identity -> anonymousNumbersByUserId.put( + identity.getUser().getId(), identity.getAnonymousNo() + )); + } + + return new DetailCommentContext(topComments, repliesByParentId, anonymousNumbersByUserId); + } - 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() - )) + 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.anonymousNumbersByUserId()), 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.anonymousNumbersByUserId()), + 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()); } @@ -234,15 +280,16 @@ 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 anonymousNumbersByUserId) { 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 ? "운영진" : "익명"); + + Integer anonymousNo = anonymousNumbersByUserId.get(commenter.getId()); + if (anonymousNo == null) { + return commenter.getRole() == Role.ADMIN ? "운영진" : "익명"; + } + return buildDisplayName(commenter.getRole(), anonymousNo); } // 질문 등록 @@ -476,6 +523,11 @@ 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 StudySession findSession(Long sessionId) { return curriculumRepository.findById(sessionId) .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다.")); @@ -871,6 +923,13 @@ private record QuestionSummaryContext( ) { } + private record DetailCommentContext( + List topComments, + Map> repliesByParentId, + Map anonymousNumbersByUserId + ) { + } + // 질문은 내용 또는 이미지 중 하나는 반드시 있어야 함 private void validateQuestionContent(String content, List imageUrls) { boolean hasContent = content != null && !content.isBlank(); From ef728376d52f3b8ba4f4d7c634057b94e8be6413 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 16 Jun 2026 21:42:43 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[Fix]=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/repository/QuestionRepository.java | 14 ++++++++++++++ .../domain/question/service/QuestionService.java | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) 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 4b6db37..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,9 @@ 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; @@ -33,4 +35,16 @@ public interface QuestionRepository extends JpaRepository { 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 1ad6930..0edd8cc 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 @@ -325,7 +325,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 = findQuestionForLikeUpdate(questionId); // 이미 좋아요를 눌렀는지 확인 QuestionResDTO.LikeRes result = questionLikeRepository.findByQuestionAndUser(question, loginUser) @@ -528,6 +528,11 @@ private Question findQuestionDetail(Long questionId) { .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); } + private Question findQuestionForLikeUpdate(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, "세션을 찾을 수 없습니다.")); From ebd8030985e97c6c55f0bf3c9f0e68a03f3eda3e Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 16 Jun 2026 22:14:01 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[Fix]=20=EC=9D=B5=EB=AA=85=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=97=AD=ED=95=A0=EB=B3=84=20=EC=9C=A0=EB=8B=88?= =?UTF-8?q?=ED=81=AC=20=EC=A0=9C=EC=95=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/QuestionAnonymousIdentity.java | 9 ++++ .../QuestionAnonymousIdentityRepository.java | 12 +++--- .../repository/QuestionCommentRepository.java | 2 +- .../question/service/QuestionService.java | 42 ++++++++++++------- ...dd_role_to_question_anonymous_identity.sql | 33 +++++++++++++++ 5 files changed, 76 insertions(+), 22 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V9__add_role_to_question_anonymous_identity.sql 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 c6ea157..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.*; @@ -13,6 +14,10 @@ @UniqueConstraint( name = "uq_question_anonymous_identity_question_user", columnNames = {"question_id", "user_id"} + ), + @UniqueConstraint( + name = "uq_question_anonymous_identity_question_role_no", + columnNames = {"question_id", "role", "anonymous_no"} ) } ) @@ -37,6 +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 458dc03..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 @@ -31,12 +31,12 @@ List findByQuestionAndUserIds( @Param("userIds") Set userIds ); - // 해당 질문에서 특정 역할(MEMBER/ADMIN)의 익명 번호 수 조회 - // 용도: 새 익명 번호 발급 시 역할별로 따로 카운트 - // 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") + @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 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 7d3bc11..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 @@ -33,7 +33,7 @@ public interface QuestionCommentRepository extends JpaRepository !commenterId.equals(questionAuthorId)) .collect(Collectors.toSet()); - Map anonymousNumbersByUserId = new HashMap<>(); + Map anonymousIdentitiesByUserId = new HashMap<>(); if (!anonymousUserIds.isEmpty()) { anonymousIdentityRepository.findByQuestionAndUserIds(question, anonymousUserIds) - .forEach(identity -> anonymousNumbersByUserId.put( - identity.getUser().getId(), identity.getAnonymousNo() + .forEach(identity -> anonymousIdentitiesByUserId.put( + identity.getUser().getId(), + new AnonymousIdentityDisplay(identity.getRole(), identity.getAnonymousNo()) )); } - return new DetailCommentContext(topComments, repliesByParentId, anonymousNumbersByUserId); + return new DetailCommentContext(topComments, repliesByParentId, anonymousIdentitiesByUserId); } private QuestionResDTO.CommentResponse toTopLevelCommentResponse( @@ -145,7 +146,7 @@ private QuestionResDTO.CommentResponse toTopLevelCommentResponse( .toList(); return new QuestionResDTO.CommentResponse( - comment.getId(), getDisplayName(question, comment.getUser(), commentContext.anonymousNumbersByUserId()), + comment.getId(), getDisplayName(question, comment.getUser(), commentContext.anonymousIdentitiesByUserId()), comment.getContent(), comment.getImageUrls(), isCommentMine(comment, loginUser), comment.getCreatedAt(), replyResponses ); @@ -158,7 +159,7 @@ private QuestionResDTO.CommentResponse toReplyCommentResponse( DetailCommentContext commentContext ) { return new QuestionResDTO.CommentResponse( - reply.getId(), getDisplayName(question, reply.getUser(), commentContext.anonymousNumbersByUserId()), + reply.getId(), getDisplayName(question, reply.getUser(), commentContext.anonymousIdentitiesByUserId()), reply.getContent(), reply.getImageUrls(), isCommentMine(reply, loginUser), reply.getCreatedAt(), List.of() ); @@ -177,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); @@ -258,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 @@ -268,6 +269,7 @@ private String assignAnonymousIdentity(Question question, User commenter) { .question(question) .user(commenter) .anonymousNo(nextNo) + .role(commenter.getRole()) .createdAt(LocalDateTime.now()) .build()); @@ -280,16 +282,20 @@ private String buildDisplayName(Role role, int anonymousNo) { return role == Role.ADMIN ? "운영진" + anonymousNo : "익명" + anonymousNo; } - private String getDisplayName(Question question, User commenter, Map anonymousNumbersByUserId) { + private String getDisplayName( + Question question, + User commenter, + Map anonymousIdentitiesByUserId + ) { if (commenter.getId().equals(question.getUser().getId())) { return "작성자"; } - Integer anonymousNo = anonymousNumbersByUserId.get(commenter.getId()); - if (anonymousNo == null) { + AnonymousIdentityDisplay identity = anonymousIdentitiesByUserId.get(commenter.getId()); + if (identity == null) { return commenter.getRole() == Role.ADMIN ? "운영진" : "익명"; } - return buildDisplayName(commenter.getRole(), anonymousNo); + return buildDisplayName(identity.role(), identity.anonymousNo()); } // 질문 등록 @@ -325,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 = findQuestionForLikeUpdate(questionId); + Question question = findQuestionForUpdate(questionId); // 이미 좋아요를 눌렀는지 확인 QuestionResDTO.LikeRes result = questionLikeRepository.findByQuestionAndUser(question, loginUser) @@ -528,7 +534,7 @@ private Question findQuestionDetail(Long questionId) { .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); } - private Question findQuestionForLikeUpdate(Long questionId) { + private Question findQuestionForUpdate(Long questionId) { return questionRepository.findByIdAndDeletedAtIsNullForUpdate(questionId) .orElseThrow(() -> new QuestionException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다.")); } @@ -931,7 +937,13 @@ private record QuestionSummaryContext( private record DetailCommentContext( List topComments, Map> repliesByParentId, - Map anonymousNumbersByUserId + Map anonymousIdentitiesByUserId + ) { + } + + private record AnonymousIdentityDisplay( + Role role, + Integer anonymousNo ) { } 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);