diff --git a/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java b/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java index 73fe0c5..54505dd 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/assignment/service/AssignmentService.java @@ -34,7 +34,6 @@ @Service @RequiredArgsConstructor -@Transactional public class AssignmentService { private final AssignmentRepository assignmentRepository; @@ -163,6 +162,7 @@ public ModifyAssignmentResponse modifyAssignment( } // 3. 과제 삭제 + @Transactional public DeleteAssignmentResponse deleteAssignment(Integer assignmentId) { Assignment assignment = assignmentRepository.findById(assignmentId) @@ -182,6 +182,7 @@ public DeleteAssignmentResponse deleteAssignment(Integer assignmentId) { } // 4-1. 나의 과제 조회 (부원) + @Transactional(readOnly = true) public GetMyAssignmentsResponse getMyAssignments( Long userId, String week 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 5839457..86aeb01 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 @@ -55,6 +55,9 @@ Optional findByUserIdAndAttendanceCodeId( List findByAttendanceCodeId(Integer id); + @Query("SELECT a.user.id, COUNT(a) FROM Attendance a WHERE a.user.id IN :userIds AND a.status = false GROUP BY a.user.id") + List countFailedAttendanceByUserIds(@Param("userIds") List userIds); + // 특정 날짜에 발급된 출석 코드의 개수를 세는 메서드 //long countByAttendanceDate(String attendanceDate); 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 2840c00..8150bc5 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 @@ -64,22 +64,18 @@ public AttendanceCode generateCodeAndCreateAttendances(LocalDate date) { // [수 throw new IllegalStateException("하루에 최대 3회까지만 출석 코드를 생성할 수 있습니다."); } - // 1-3) 기존 활성화된 코드들 만료 처리 + // 1-3) 기존 활성화된 코드들 만료 처리 + 보증금 일괄 재계산 List activeCodes = attendanceCodeRepository.findByIsExpiredFalse(); for (AttendanceCode activeCode : activeCodes) { activeCode.expire(); } - for (AttendanceCode activeCode : activeCodes) { - activeCode.expire(); - - List attendances = - attendanceRepository.findByAttendanceCodeId(activeCode.getId()); - - for (Attendance attendance : attendances) { - depositService.recalculateDeposit(attendance.getUser().getId()); - } - } + List userIdsToRecalculate = activeCodes.stream() + .flatMap(activeCode -> attendanceRepository.findByAttendanceCodeId(activeCode.getId()).stream()) + .map(attendance -> attendance.getUser().getId()) + .distinct() + .toList(); + depositService.recalculateDepositBatch(userIdsToRecalculate); // 1-4) 4자리 랜덤 코드 생성 및 차수(Order) 계산 @@ -208,10 +204,12 @@ public String expireActiveAttendanceCode() { List absents = attendanceRepository.findByAttendanceCodeIdAndStatusFalse(attendanceCodeId); - // 4. 결석자 대상 보증금 재계산 (User ID 타입 Integer 반영) - for (Attendance attendance : absents) { - depositService.recalculateDeposit(attendance.getUser().getId()); - } + // 4. 결석자 대상 보증금 일괄 재계산 + List absentUserIds = absents.stream() + .map(attendance -> attendance.getUser().getId()) + .distinct() + .toList(); + depositService.recalculateDepositBatch(absentUserIds); return "출석 코드가 성공적으로 만료되었습니다."; } 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 f3836bc..ed5f1d5 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 @@ -4,6 +4,7 @@ import com.example.Piroin.project.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface DepositRepository extends JpaRepository { @@ -11,4 +12,5 @@ public interface DepositRepository extends JpaRepository { Optional findByUserId(Long userId); + List findByUserIdIn(List userIds); } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/deposit/service/DepositService.java b/backend/src/main/java/com/example/Piroin/project/domain/deposit/service/DepositService.java index de461cd..47e2e09 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/deposit/service/DepositService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/deposit/service/DepositService.java @@ -15,6 +15,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + @Service @RequiredArgsConstructor public class DepositService { @@ -40,6 +44,25 @@ public void recalculateDeposit(Long userId) { deposit.updateAttendanceAmount(descentAttendance); } + // 1-1. 여러 유저 보증금 일괄 재계산 (N*3 쿼리 → 3 쿼리) + @Transactional + public void recalculateDepositBatch(List userIds) { + if (userIds.isEmpty()) return; + + List deposits = depositRepository.findByUserIdIn(userIds); + Map failCountMap = attendanceRepository.countFailedAttendanceByUserIds(userIds) + .stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> ((Long) row[1]).intValue() + )); + + for (Deposit deposit : deposits) { + int failCount = failCountMap.getOrDefault(deposit.getUser().getId(), 0); + deposit.updateAttendanceAmount(failCount * ATTENDANCE_PENALTY); + } + } + // 2. 보증금 조회 로직 public DepositResponse getMyDeposit(Long userId) { 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 2ba72d2..e304fd3 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 @@ -12,7 +12,7 @@ @Service public class QuestionEventService { - private static final long SSE_TIMEOUT_MILLIS = 60L * 60L * 1000L; + private static final long SSE_TIMEOUT_MILLIS = 3L * 60L * 1000L; // sessionId별로 현재 질문방을 보고 있는 SSE 연결들을 보관한다. private final Map> sessionEmitters = new ConcurrentHashMap<>(); 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 4e923a8..28ed7a3 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 @@ -23,6 +23,9 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -76,17 +79,17 @@ public UpdateStudentStatusResponse updateStudentWeekStatus( .orElseThrow(() -> new RuntimeException("사용자가 존재하지 않습니다.")); if (request.getAssignments() != null) { - for (UpdateStudentStatusRequest.AssignmentStatusRequest dto - : request.getAssignments()) { + List itemIds = request.getAssignments().stream() + .map(UpdateStudentStatusRequest.AssignmentStatusRequest::getAssignmentItemId) + .toList(); - // 해당 assignmentItem 존재하지 않을 때 - AssignmentItem assignmentItem = - assignmentItemRepository.findById(dto.getAssignmentItemId()) - .orElseThrow(() -> - new RuntimeException("과제 정보가 존재하지 않습니다.") - ); + Map itemMap = assignmentItemRepository.findAllById(itemIds).stream() + .collect(Collectors.toMap(AssignmentItem::getId, item -> item)); + + for (UpdateStudentStatusRequest.AssignmentStatusRequest dto : request.getAssignments()) { + AssignmentItem assignmentItem = Optional.ofNullable(itemMap.get(dto.getAssignmentItemId())) + .orElseThrow(() -> new RuntimeException("과제 정보가 존재하지 않습니다.")); - // assignmentItem가 userId의 과제가 아닐 경우 if (!assignmentItem.getUser().getId().equals(userId)) { throw new RuntimeException("해당 유저의 과제가 아닙니다."); } @@ -98,16 +101,17 @@ public UpdateStudentStatusResponse updateStudentWeekStatus( } if (request.getAttendances() != null) { - for (UpdateStudentStatusRequest.AttendanceStatusRequest dto - : request.getAttendances()) { + List attendanceIds = request.getAttendances().stream() + .map(dto -> dto.getAttendanceId().longValue()) + .toList(); + + Map attendanceMap = attendanceRepository.findAllById(attendanceIds).stream() + .collect(Collectors.toMap(Attendance::getId, a -> a)); - Attendance attendance = - attendanceRepository.findById(dto.getAttendanceId()) - .orElseThrow(() -> - new RuntimeException("출석 정보가 존재하지 않습니다.") - ); + for (UpdateStudentStatusRequest.AttendanceStatusRequest dto : request.getAttendances()) { + Attendance attendance = Optional.ofNullable(attendanceMap.get(dto.getAttendanceId())) + .orElseThrow(() -> new RuntimeException("출석 정보가 존재하지 않습니다.")); - // assignmentId가 userId의 것이 아닐 때 if (!attendance.getUser().getId().equals(userId)) { throw new RuntimeException("해당 유저의 출석이 아닙니다."); } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index a83439d..b0e4826 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -6,6 +6,12 @@ spring: username: ${RDS_USERNAME} password: ${RDS_PASSWORD} driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + leak-detection-threshold: 5000 flyway: # repair-on-migrate: true @@ -15,6 +21,7 @@ spring: hibernate: ddl-auto: validate show-sql: true + open-in-view: false properties: hibernate: format_sql: true diff --git a/frontend/src/pages/curriculum/CurriculumPage.js b/frontend/src/pages/curriculum/CurriculumPage.js index a1a7e8c..79a15a1 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.js +++ b/frontend/src/pages/curriculum/CurriculumPage.js @@ -318,17 +318,12 @@ function SessionForm({ day, week, onClose, onSave }) { // ── 메인 컴포넌트 ───────────────────────────────────── function CurriculumPage() { - const [role, setRole] = useState(null); + const role = localStorage.getItem('role') || 'MEMBER'; const [days, setDays] = useState([]); const [showForm, setShowForm] = useState(false); const [editDay, setEditDay] = useState(null); const [createWeek, setCreateWeek] = useState(null); - useEffect(() => { - setRole(localStorage.getItem('role') || 'MEMBER'); - }, []); - - // if (role === null) return null; const fetchDays = async () => { try {