From edc1264c5255edd1e33230e7e1171d76e10af1fd Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 12:17:40 +0900 Subject: [PATCH 01/18] =?UTF-8?q?[Fix]=20role=EC=9D=84=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=95=A8=EC=88=98=20=EB=82=B4=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=9D=BD=EA=B8=B0=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EC=8B=9C=20=EA=B6=8C=ED=95=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/curriculum/CurriculumPage.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 { From 98a547c3b1cf231d4230bf567822b9b7c001bf48 Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 12:21:33 +0900 Subject: [PATCH 02/18] =?UTF-8?q?=20[Revert]=20role=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EC=9B=90=EC=83=81=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/curriculum/CurriculumPage.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/curriculum/CurriculumPage.js b/frontend/src/pages/curriculum/CurriculumPage.js index 79a15a1..14782a4 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.js +++ b/frontend/src/pages/curriculum/CurriculumPage.js @@ -6,6 +6,8 @@ import AmImg from '../../assets/images/am.png'; import PmImg from '../../assets/images/pm.png'; import Toggle1 from '../../assets/images/icon_togle1.svg'; +const role = localStorage.getItem('role') || 'MEMBER'; + const DAY_LABEL = { SUNDAY: '일요일', MONDAY: '월요일', TUESDAY: '화요일', WEDNESDAY: '수요일', THURSDAY: '목요일', FRIDAY: '금요일', SATURDAY: '토요일' }; const STATUS_OPTIONS = ['BEFORE_SESSION', 'IN_SESSION', 'AFTER_SESSION']; const STATUS_LABEL = { BEFORE_SESSION: '세션 전', IN_SESSION: '세션 중', AFTER_SESSION: '세션 후' }; @@ -318,7 +320,6 @@ function SessionForm({ day, week, onClose, onSave }) { // ── 메인 컴포넌트 ───────────────────────────────────── function CurriculumPage() { - const role = localStorage.getItem('role') || 'MEMBER'; const [days, setDays] = useState([]); const [showForm, setShowForm] = useState(false); const [editDay, setEditDay] = useState(null); From fe26a8a287c081be006f7a65b031e334d51454cf Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 12:24:16 +0900 Subject: [PATCH 03/18] =?UTF-8?q?[Fix]=20role=EC=9D=84=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=95=A8=EC=88=98=20=EB=82=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=BD=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=98=EC=97=AC=20=EC=B2=AB=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EC=8B=9C=20=EA=B6=8C=ED=95=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/curriculum/CurriculumPage.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/pages/curriculum/CurriculumPage.js b/frontend/src/pages/curriculum/CurriculumPage.js index 14782a4..79a15a1 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.js +++ b/frontend/src/pages/curriculum/CurriculumPage.js @@ -6,8 +6,6 @@ import AmImg from '../../assets/images/am.png'; import PmImg from '../../assets/images/pm.png'; import Toggle1 from '../../assets/images/icon_togle1.svg'; -const role = localStorage.getItem('role') || 'MEMBER'; - const DAY_LABEL = { SUNDAY: '일요일', MONDAY: '월요일', TUESDAY: '화요일', WEDNESDAY: '수요일', THURSDAY: '목요일', FRIDAY: '금요일', SATURDAY: '토요일' }; const STATUS_OPTIONS = ['BEFORE_SESSION', 'IN_SESSION', 'AFTER_SESSION']; const STATUS_LABEL = { BEFORE_SESSION: '세션 전', IN_SESSION: '세션 중', AFTER_SESSION: '세션 후' }; @@ -320,6 +318,7 @@ function SessionForm({ day, week, onClose, onSave }) { // ── 메인 컴포넌트 ───────────────────────────────────── function CurriculumPage() { + const role = localStorage.getItem('role') || 'MEMBER'; const [days, setDays] = useState([]); const [showForm, setShowForm] = useState(false); const [editDay, setEditDay] = useState(null); From cb01b76abd4c2ff23dfa3750d98c363d678c913c Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 18:49:59 +0900 Subject: [PATCH 04/18] =?UTF-8?q?[Fix]=20HikariCP=20=EC=BB=A4=EB=84=A5?= =?UTF-8?q?=EC=85=98=20=ED=92=80=20=EA=B3=A0=EA=B0=88=20=EB=B0=A9=EC=A7=80?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/resources/application.yml | 7 +++++++ 1 file changed, 7 insertions(+) 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 From bd832d468d21c386b05d48941844095c16336a5e Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 18:54:04 +0900 Subject: [PATCH 05/18] =?UTF-8?q?[Fix]=20=EC=B6=9C=EC=84=9D=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=20=EB=B3=B4=EC=A6=9D?= =?UTF-8?q?=EA=B8=88=20=EC=9E=AC=EA=B3=84=EC=82=B0=20N*3=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=E2=86=92=20=EB=B0=B0=EC=B9=98=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/AttendanceRepository.java | 3 ++ .../attendance/service/AttendanceService.java | 28 +++++++++---------- .../deposit/repository/DepositRepository.java | 2 ++ .../deposit/service/DepositService.java | 23 +++++++++++++++ 4 files changed, 41 insertions(+), 15 deletions(-) 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) { From be6e638943e7c62be5edd3284785f5445b805a59 Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 18:58:35 +0900 Subject: [PATCH 06/18] =?UTF-8?q?[Fix]=20AssignmentService=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EB=A0=88=EB=B2=A8=20@Transactional=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=B3=84=20readOnly=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/domain/assignment/service/AssignmentService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 7509dd61e738472c351d121ccc009b1324aa6410 Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 19:01:07 +0900 Subject: [PATCH 07/18] =?UTF-8?q?[Fix]=20SSE=20=ED=83=80=EC=9E=84=EC=95=84?= =?UTF-8?q?=EC=9B=83=201=EC=8B=9C=EA=B0=84=20=E2=86=92=203=EB=B6=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B6=95=EC=86=8C=ED=95=98=EC=97=AC=20Tom?= =?UTF-8?q?cat=20=EC=8A=A4=EB=A0=88=EB=93=9C=20=EB=82=AD=EB=B9=84=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/domain/question/service/QuestionEventService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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<>(); From 4b2e2d924ddeef9ed6b170ab35a4cca1756c1746 Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 19:03:51 +0900 Subject: [PATCH 08/18] =?UTF-8?q?[Fix]=20AdminUserService=20=EA=B3=BC?= =?UTF-8?q?=EC=A0=9C/=EC=B6=9C=EC=84=9D=20=EC=88=98=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=20=EB=A3=A8=ED=94=84=20=EB=82=B4=20=EA=B0=9C=EB=B3=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=E2=86=92=20=EB=B0=B0=EC=B9=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/service/AdminUserService.java | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) 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..5450cf1 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().longValue())) + .orElseThrow(() -> new RuntimeException("출석 정보가 존재하지 않습니다.")); - // assignmentId가 userId의 것이 아닐 때 if (!attendance.getUser().getId().equals(userId)) { throw new RuntimeException("해당 유저의 출석이 아닙니다."); } From 1a487e645e4f40f1d8509bec716511dffd9f77d6 Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 19:32:20 +0900 Subject: [PATCH 09/18] =?UTF-8?q?[Fix]=20AdminUserService=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20ID=20=ED=83=80=EC=9E=85=20Long=20=E2=86=92=20Intege?= =?UTF-8?q?r=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/domain/user/service/AdminUserService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 5450cf1..9d23d27 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,15 +101,15 @@ 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() + Map attendanceMap = attendanceRepository.findAllById(attendanceIds).stream() .collect(Collectors.toMap(Attendance::getId, a -> a)); for (UpdateStudentStatusRequest.AttendanceStatusRequest dto : request.getAttendances()) { - Attendance attendance = Optional.ofNullable(attendanceMap.get(dto.getAttendanceId().longValue())) + Attendance attendance = Optional.ofNullable(attendanceMap.get(dto.getAttendanceId())) .orElseThrow(() -> new RuntimeException("출석 정보가 존재하지 않습니다.")); if (!attendance.getUser().getId().equals(userId)) { From c6c348886fcbc532ea75d3ba6474b845d9333f1b Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 19:39:45 +0900 Subject: [PATCH 10/18] =?UTF-8?q?[Fix]=20AdminUserService=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20ID=20=ED=83=80=EC=9E=85=20=EB=B6=88=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=EC=88=98=EC=A0=95=20-=20findAllById=20Long,=20map?= =?UTF-8?q?=20=ED=82=A4=20Integer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Piroin/project/domain/user/service/AdminUserService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 9d23d27..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 @@ -101,8 +101,8 @@ public UpdateStudentStatusResponse updateStudentWeekStatus( } if (request.getAttendances() != null) { - List attendanceIds = request.getAttendances().stream() - .map(UpdateStudentStatusRequest.AttendanceStatusRequest::getAttendanceId) + List attendanceIds = request.getAttendances().stream() + .map(dto -> dto.getAttendanceId().longValue()) .toList(); Map attendanceMap = attendanceRepository.findAllById(attendanceIds).stream() From 8a3d35488cd308d44acebe043ef73d7d3931541f Mon Sep 17 00:00:00 2001 From: xihxxn Date: Sun, 7 Jun 2026 20:51:48 +0900 Subject: [PATCH 11/18] =?UTF-8?q?[Fix]=20AttendanceService=20findByUserId/?= =?UTF-8?q?findByUserIdAndDate=20@Transactional(readOnly)=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=EC=9C=BC=EB=A1=9C=20LazyInitializationException=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/domain/attendance/service/AttendanceService.java | 2 ++ 1 file changed, 2 insertions(+) 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 8150bc5..2395098 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 @@ -216,6 +216,7 @@ public String expireActiveAttendanceCode() { // 5. 유저의 특정 날짜의 출석 현황을 조회하는 함수 + @Transactional(readOnly = true) public List findByUserIdAndDate(Integer userId, LocalDate date) { // Long -> Integer // DB의 VARCHAR(255) 날짜 포맷과 맞추기 위해 String으로 변환 (예: "2026-05-17") String dateStr = date.toString(); @@ -234,6 +235,7 @@ public List findByUserIdAndDate(Integer userId, LocalDate dat } // 6. 나의 전체 출석 현황 조회 서비스 + @Transactional(readOnly = true) public List findByUserId(Integer userId) { List attendances = From 33db1e7b27f7ffe1ac52f5477edb3ab440891b25 Mon Sep 17 00:00:00 2001 From: xihxxn Date: Mon, 8 Jun 2026 02:45:58 +0900 Subject: [PATCH 12/18] =?UTF-8?q?[Fix]=20HikariCP=20=EC=BB=A4=EB=84=A5?= =?UTF-8?q?=EC=85=98=20=ED=92=80=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=AC=EB=A1=9C=EC=9A=B0=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/k6/config.js | 81 ++++++ backend/k6/k6-result.md | 144 +++++++++++ backend/k6/k6-test-scenario.md | 271 +++++++++++++++++++++ backend/k6/load-test.js | 95 ++++++++ backend/k6/smoke-test.js | 71 ++++++ backend/k6/soak-test.js | 86 +++++++ backend/k6/sse-test.js | 130 ++++++++++ backend/k6/stress-test.js | 71 ++++++ backend/src/main/resources/application.yml | 27 ++ 9 files changed, 976 insertions(+) create mode 100644 backend/k6/config.js create mode 100644 backend/k6/k6-result.md create mode 100644 backend/k6/k6-test-scenario.md create mode 100644 backend/k6/load-test.js create mode 100644 backend/k6/smoke-test.js create mode 100644 backend/k6/soak-test.js create mode 100644 backend/k6/sse-test.js create mode 100644 backend/k6/stress-test.js diff --git a/backend/k6/config.js b/backend/k6/config.js new file mode 100644 index 0000000..517e2c4 --- /dev/null +++ b/backend/k6/config.js @@ -0,0 +1,81 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, *Controller.java +// SSE 구현: SseEmitter (Spring WebMVC, Tomcat 스레드 점유 방식) +// 대상 유저 수: 약 40명 + +import http from 'k6/http'; +import { check } from 'k6'; +import encoding from 'k6/encoding'; + +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +export const SESSION_ID = __ENV.SESSION_ID || '1'; + +export const CREDENTIALS = { + name: __ENV.USER_NAME || 'test_user', + password: __ENV.USER_PASSWORD || 'test_password', +}; + +export const AUTH = { + type: 'bearer', + token: __ENV.API_TOKEN || '', +}; + +export function getHeaders(withAuth = true) { + const headers = { 'Content-Type': 'application/json' }; + if (withAuth && AUTH.token) { + headers['Authorization'] = `Bearer ${AUTH.token}`; + } + return headers; +} + +export function getHeadersWithToken(token) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; +} + +export function login() { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ name: CREDENTIALS.name, password: CREDENTIALS.password }), + { headers: { 'Content-Type': 'application/json' } } + ); + check(res, { '로그인 성공': (r) => r.status === 200 }); + try { + return res.json('token') || ''; + } catch { + return ''; + } +} + +// 테스트용 1x1 JPEG (base64 인라인 — 외부 파일 불필요) +const TEST_IMAGE_B64 = + '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkS' + + 'Ew8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAAR' + + 'CAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAA' + + 'AAAAAAAAAAAAAP/EABQBAQAAAAAAAAAAAAAAAAAAAAD/xAAUEQEAAAAAAAAAAAAA' + + 'AAAAAAAA/9oADAMBAAIRAxEAPwCwAB//2Q=='; + +// QnA 이미지 업로드: POST /api/images (multipart/form-data) +// Authorization 헤더만 포함 — Content-Type은 k6가 multipart로 자동 설정 +export function uploadTestImage(token) { + const imageBytes = encoding.b64decode(TEST_IMAGE_B64, 'std', 'b'); + const data = { + file: http.file(imageBytes, 'test.jpg', 'image/jpeg'), + }; + return http.post(`${BASE_URL}/api/images`, data, { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +// ⚠️ HikariCP pool-size=10, Tomcat threads.max=200 +// 40명 운영에는 pool-size=10 적절 +// Stress(120 VU) 시 DB 커넥션 병목 관측 예상 — 의도된 설정 +export const THRESHOLDS = { + smoke: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<500'] }, + load: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000', 'p(99)<2000'] }, + stress: { http_req_failed: ['rate<0.05'], http_req_duration: ['p(95)<3000'] }, + soak: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000'] }, + sse: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<2000'] }, +}; diff --git a/backend/k6/k6-result.md b/backend/k6/k6-result.md new file mode 100644 index 0000000..680288b --- /dev/null +++ b/backend/k6/k6-result.md @@ -0,0 +1,144 @@ +# k6 부하 테스트 결과 + +> 테스트 일시: 2026-06-07 +> 대상 서버: https://api.piroin.com +> 기준 유저 수: 40명 +> 계정: test / 12345678 (MEMBER), SESSION_ID=23 (IN_SESSION) + +--- + +## 결과 요약 + +| 테스트 | 결과 | 에러율 | p(95) | 비고 | +|--------|------|--------|-------|------| +| Smoke | ✅ PASS | 0.00% | 123ms | 전 항목 정상 | +| Load | ✅ PASS | 0.00% | 96ms | 40 VU 전 항목 정상 | +| SSE | ⚠️ 부분 해결 | - | 110ms (TTFB) / 116ms (일반 API) | sse_connect_time 해결, sse_error k6 한계 | +| Stress | ⚠️ FAIL | 0.00% | 614ms | 3s 초과 응답 300건 | +| Soak | ⚠️ FAIL | 0.07% | 130ms | 간헐적 DB 오류 98건, 최대 응답 24분 | + +--- + +## 1. Smoke Test ✅ PASS + +| 항목 | 값 | 기준 | +|------|-----|------| +| 에러율 | 0.00% | < 1% | +| p(95) | 123ms | < 500ms | +| checks | 100% (280/280) | | +| 총 요청 | 187건 | | + +**체크 항목 전부 통과** +- 헬스 체크, 커리큘럼, 세션 목록, 질문 목록, 출석, 이미지 업로드 모두 정상 + +--- + +## 2. Load Test ✅ PASS + +| 항목 | 값 | 기준 | +|------|-----|------| +| 에러율 | 0.00% | < 1% | +| p(95) | 96ms | < 1000ms | +| p(99) | 115ms | < 2000ms | +| 평균 응답 | 29ms | | +| 최대 응답 | 3.41s | | +| 총 요청 | 46,461건 | | +| checks | 100% (47,981/47,981) | | + +**40 VU 전체 구간에서 에러 0건.** 이미지 업로드(20% 확률) 포함 모두 정상. +최대 응답 3.41s는 이미지 업로드 요청으로 추정. + +--- + +## 3. SSE Test (재테스트) + +| 항목 | 1차 | 재테스트 | 기준 | 판정 | +|------|-----|----------|------|------| +| `sse_connect_time` p(95) | 35003ms | **110ms** | < 2000ms | ✅ 해결 | +| `sse_error` count | 510 | 596 | < 10 | ✗ | +| `normal_api` p(95) | 86.9ms | 116.92ms | < 1000ms | ✅ | +| SSE connected 이벤트 수신 | - | ✅ 100% | | | + +**sse_connect_time 완전 해결**: TTFB 측정으로 전환 후 35003ms → 110ms. + +**sse_error 596 원인 (k6 한계)**: +timeout=10s 설정 시, k6가 연결을 강제 종료하는 시점에 body 버퍼링이 완료되지 않아 +`res.body`에 `connected` 문자열이 없는 경우 에러로 카운트됨. +`SSE connected 이벤트 수신 ✓`와 모순되는 것처럼 보이지만, +체크는 버퍼링 성공한 연결에서만 실행되기 때문. 서버 문제 아님. + +**normal_api 4% 실패 (130건)**: +SSE 30개 연결 중 일반 API 배치 요청 4% 실패. HikariCP 간헐적 병목으로 추정. +p(95)=116ms로 대부분 정상이며 스레드 고갈은 아님. + +**핵심 확인 사항 ✅**: +- SSE 30개 연결 중에도 일반 API p(95)=116ms → **스레드 고갈 없음** +- connected 이벤트 정상 수신 확인 +- `sse_error` 임계값은 k6의 body 버퍼링 한계로 인한 것 — 추가 도구로 검증 필요 + +--- + +## 4. Stress Test ⚠️ FAIL + +| 항목 | 값 | 기준 | +|------|-----|------| +| 에러율 (`http_req_failed`) | **0.00%** | < 5% ✅ | +| p(95) | 614ms | < 3000ms ✅ | +| 평균 응답 | 191ms | | +| 최대 응답 | 40.53s | | +| 3s 초과 응답 | **300건** | 0건 기대 | +| `custom_errors` | 300 | < 100 ✗ | +| 총 요청 | 173,458건 | | + +**에러율 0%, p(95) 614ms로 기준치는 통과했으나**, 3초를 초과한 응답이 300건 발생해 `custom_errors` 임계값(100건) 초과. +최대 응답 40.53s는 120 VU 구간(한계 탐색)에서 HikariCP 커넥션 대기가 쌓인 것으로 추정. + +**주요 발견**: +- 40 VU(정상) → 80 VU(2배) 구간까지는 안정적 +- 120 VU(3배) 구간에서 일부 요청이 3s 초과 — **병목 지점 확인** +- 회복 구간(마지막 5분)에서 에러율 0% 복귀 → 서버 자가 회복 능력 정상 + +**개선 방향**: HikariCP `maximum-pool-size` 상향 또는 쿼리 최적화로 커넥션 점유 시간 단축. + +--- + +## 5. Soak Test ⚠️ FAIL + +| 항목 | 값 | 기준 | +|------|-----|------| +| 에러율 (`http_req_failed`) | 0.07% | < 1% ✅ | +| p(95) | 130ms | < 1000ms ✅ | +| `db_error_count` | **98** | < 20 ✗ | +| 최대 응답 | **24분 2초** | | +| slow_response p(95) | 814s | | +| 총 요청 | 84,593건 / 2시간 | | + +**p(95) 130ms로 대부분 요청은 정상**이나, 간헐적으로 수 분~24분짜리 극단적 지연이 발생. + +**실패 항목별 건수** +- 커리큘럼 조회 실패: 15건 +- 1s 초과 응답: 71건 +- 출석 조회 실패: 18건 +- 질문 목록 실패: 23건 +- 이미지 업로드 실패: 4건 + +**분석**: +- 2시간 중 99% 구간은 정상 동작 (p(95)=130ms) +- 극단적 지연(max=24분)은 HikariCP 커넥션 대기가 누적되어 일부 요청이 timeout까지 대기한 것으로 추정 +- `db_error_count` 98건은 연속 실패가 아닌 간헐적 발생 → 서버가 완전히 멈추진 않음 +- 실제 40명 환경(20 VU)에서도 장시간 운영 시 간헐적 DB 병목 발생 확인 + +**개선 방향**: +- 장시간 운영 중 커넥션 점유 시간이 긴 쿼리 식별 (슬로우 쿼리 로그 확인) +- HikariCP `idle-timeout` 설정 검토 + +--- + +## 전체 총평 + +| 구분 | 내용 | +|------|------| +| **안전 운영 범위** | 40 VU (실제 40명 동시 사용) — 에러 0%, p(95) 96ms | +| **병목 시작 지점** | 80~120 VU — 3s 초과 응답 발생, HikariCP 커넥션 대기 | +| **SSE 영향도** | 없음 — SSE 30개 연결 중에도 일반 API p(95)=86ms 유지 | +| **주요 개선 과제** | Soak 간헐적 DB 오류 원인 파악 (최대 응답 24분 발생) | diff --git a/backend/k6/k6-test-scenario.md b/backend/k6/k6-test-scenario.md new file mode 100644 index 0000000..77cd2e6 --- /dev/null +++ b/backend/k6/k6-test-scenario.md @@ -0,0 +1,271 @@ +# k6 부하 테스트 시나리오 + +> **Claude에게**: 이 문서를 읽고 아래 지시사항에 따라 k6 테스트 스크립트를 직접 작성해줘. +> 프로젝트 디렉토리를 먼저 탐색하고, 실제 API 엔드포인트·인증 방식·환경변수를 확인한 뒤 스크립트를 생성해. + +--- + +## 0. 전제 조건 + +- **대상 유저 수**: 약 40명 (동아리 실사용 규모 — 회원 30명 + 운영진 5~7명) +- **접근 제어**: 로그인된 회원만 접근 가능 → 최대 동시 접속자 고정 +- **SSE 구현**: SseEmitter (Spring WebMVC) — Tomcat 스레드 1개 = SSE 연결 1개 점유 +- **이미지 업로드**: QnA 질문/댓글 작성 시 `POST /api/images` (multipart/form-data) 동시 발생 +- **HikariCP pool-size**: 10 (운영 기준 적절, Stress 시 병목 관측 목적) +- **Tomcat threads.max**: 200 + +--- + +## 1. 공통 설정 (`config.js`) + +```javascript +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +// 인증: JWT Bearer Token +// k6 실행 시 -e USER_NAME=xxx -e USER_PASSWORD=xxx 로 주입 +// 또는 -e API_TOKEN=xxx 로 직접 주입 + +// 이미지 업로드: POST /api/images (multipart/form-data, file 파라미터) +// 테스트용 1x1 JPEG를 base64로 인라인 포함 (외부 파일 불필요) +export function uploadTestImage(token) { ... } + +export const THRESHOLDS = { + smoke: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<500'] }, + load: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000', 'p(99)<2000'] }, + stress: { http_req_failed: ['rate<0.05'], http_req_duration: ['p(95)<3000'] }, + soak: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000'] }, + sse: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<2000'] }, +}; +``` + +--- + +## 2. 테스트별 시나리오 정의 + +### 2-1. Smoke Test (`smoke-test.js`) — 기본 동작 확인 + +| 항목 | 값 | +|------|-----| +| 목적 | 배포 후 API 정상 동작 확인 | +| VU | 1명 | +| 시간 | 2분 | +| 통과 기준 | 에러 0%, p(95) < 500ms | +| 실행 시점 | 배포 직후, 매 PR 머지 후 | + +**테스트 엔드포인트** + +``` +GET /actuator/health — 헬스 체크 (public) +GET /api/curriculums — 커리큘럼 목록 +GET /api/sessions — QnA 세션 목록 +GET /api/sessions/{sessionId}/questions?understandingIndex=0 — 질문 목록 +GET /api/attendance/user — 나의 출석 현황 +POST /api/images — 이미지 업로드 (multipart) +``` + +--- + +### 2-2. Load Test (`load-test.js`) — 정상 부하 시뮬레이션 + +| 항목 | 값 | +|------|-----| +| 목적 | 40명 동시 사용 처리 능력 검증 | +| VU | 최대 40명 | +| 시간 | 약 16분 | +| 통과 기준 | 에러율 < 1%, p(95) < 1s, p(99) < 2s | + +**stages** + +``` +2m → 15 VU (워밍업) +5m → 15 VU (유지) +2m → 40 VU (전체 유저 시뮬레이션) +5m → 40 VU (유지) +2m → 0 VU (종료) +``` + +**시나리오 흐름** + +``` +그룹1 — 읽기 (병렬 GET) + GET /api/curriculums + GET /api/sessions + GET /api/attendance/user + GET /api/deposit/me + +그룹2 — 질문 조회 + 단건 조회 + +그룹3 — 과제 조회 (주차 랜덤) + +그룹4 — 이미지 업로드 (POST /api/images, multipart) + → VU 중 20%만 이미지 업로드 수행 (Math.random() < 0.2) + → QnA 진행 시 사진 올리는 상황 시뮬레이션 +``` + +--- + +### 2-3. Stress Test (`stress-test.js`) — 한계 부하 탐색 + +| 항목 | 값 | +|------|-----| +| 목적 | 40명 기준의 3배 부하로 병목 지점 파악 | +| VU | 최대 120명 | +| 시간 | 약 35분 | +| 통과 기준 | 에러율 < 5%, 회복 구간 에러 0% 복귀 | + +**stages** + +``` +2m → 20 VU +5m → 20 VU +2m → 40 VU (정상 부하) +5m → 40 VU +2m → 80 VU (2배) +5m → 80 VU +2m → 120 VU (3배, 한계 탐색) +5m → 120 VU +5m → 0 VU (회복 확인) +``` + +--- + +### 2-4. SSE Test (`sse-test.js`) — 스트리밍 연결 부하 검증 + +| 항목 | 값 | +|------|-----| +| 목적 | SSE 동시 연결 + 일반 API 영향도 측정 | +| VU | SSE 30명 + 일반 API 10명 (총 40명) | +| 시간 | 약 11분 | +| 통과 기준 | 연결 수립 p(95) < 2s, 일반 API p(95) < 1s | + +**시나리오** + +``` +시나리오 A (sse_connections, 30 VU) + GET /api/sessions/{sessionId}/questions/events + Accept: text/event-stream, timeout: 10s + → sse_connect_time: res.timings.waiting (TTFB) 측정 + → timeout이어도 body에 'connected' 포함 시 성공으로 처리 + → 재연결 반복 (시나리오 C) + +시나리오 B (normal_api, 10 VU, startTime: 30s) + GET /api/curriculums + GET /api/attendance/user + GET /api/sessions/{sessionId}/questions + POST /api/images (30% 확률 — 이미지 업로드 병행) + → SSE 중 일반 API 응답시간이 올라가면 스레드 고갈 신호 +``` + +**k6 SSE 측정 방식** + +``` +sse_connect_time: res.timings.waiting (TTFB) — 실제 첫 이벤트 수신 시간만 측정 +sse_error: timeout이어도 body에 'connected' 있으면 성공, 4xx/5xx만 진짜 오류로 카운트 +``` + +**⚠️ SseEmitter 주의** + +``` +Tomcat threads.max=200 +SSE 30개 + 일반 10개 = 40 스레드 → 안전 범위 +``` + +--- + +### 2-5. Soak Test (`soak-test.js`) — 장시간 안정성 검증 + +| 항목 | 값 | +|------|-----| +| 목적 | 메모리 누수, 커넥션 풀 고갈, SSE emitter 미정리 감지 | +| VU | 20명 지속 | +| 시간 | 120분 | +| 통과 기준 | 에러율 < 1%, 30분 경과 후에도 p(95) 유지 | + +**stages** + +``` +5m → 20 VU (워밍업) +110m → 20 VU (장시간 유지) +5m → 0 VU (종료) +``` + +**감지 항목** + +``` +- 연속 실패 5회 → HikariCP 고갈 경고 +- 30분 경과 후 응답 > 1s → 메모리 누수 또는 DB 부하 의심 +- 이미지 업로드 (20% 확률) — 파일 핸들 누수 감지 +``` + +--- + +## 3. 이미지 업로드 시나리오 + +``` +엔드포인트: POST /api/images +Content-Type: multipart/form-data +파라미터: file (이미지 파일) +응답: { "imageUrl": "/api/images/{filename}" } +인증: Bearer JWT 필요 + +테스트 전략: +- load-test: VU 중 20% (랜덤)가 이미지 업로드 수행 +- sse-test: normal_api VU 중 30%가 이미지 업로드 수행 +- soak-test: 20% 확률로 이미지 업로드 — 파일 핸들 누수 장시간 감지 +- 테스트용 이미지: 1x1 JPEG (base64 인라인, 외부 파일 불필요) +``` + +--- + +## 4. 실행 방법 + +```bash +# Smoke +k6 run -e USER_NAME=이름 -e USER_PASSWORD=비번 -e SESSION_ID=1 k6/smoke-test.js + +# Load +k6 run -e USER_NAME=이름 -e USER_PASSWORD=비번 -e SESSION_ID=1 k6/load-test.js + +# Stress +k6 run -e USER_NAME=이름 -e USER_PASSWORD=비번 -e SESSION_ID=1 k6/stress-test.js + +# SSE +k6 run -e USER_NAME=이름 -e USER_PASSWORD=비번 -e SESSION_ID=1 k6/sse-test.js + +# Soak (백그라운드 권장) +k6 run -e USER_NAME=이름 -e USER_PASSWORD=비번 -e SESSION_ID=1 k6/soak-test.js & +``` + +--- + +## 5. 테스트 순서 체크리스트 + +``` +[ ] 1. Smoke → 에러 0%, 이미지 업로드 포함 기본 동작 확인 +[ ] 2. Load → 40명 p(95) < 1s, 이미지 업로드 20% 혼합 +[ ] 3. SSE → SSE 30개 + 일반 API 10개, 스레드 고갈 없음 확인 +[ ] 4. Stress → 120명, 한계 VU 기록 +[ ] 5. Soak → 2시간, 이미지 업로드 포함 메모리 누수 없음 확인 +``` + +--- + +## 6. Spring Boot 설정 현황 + +```yaml +# 현재 설정 (application.yml 기준) +spring: + datasource: + hikari: + maximum-pool-size: 10 # 40명 운영에는 적절 + connection-timeout: 30000 # Stress 120명 시 병목 관측 포인트 + +server: + tomcat: + threads: + max: 200 + min-spare: 20 + +# ⚠️ Stress(120 VU) 시 HikariCP 병목 예상 → 관측 목적으로 설정 그대로 유지 +# ⚠️ SSE 30개 연결 시 스레드 30개 점유 → threads.max=200 기준 안전 +``` diff --git a/backend/k6/load-test.js b/backend/k6/load-test.js new file mode 100644 index 0000000..f856027 --- /dev/null +++ b/backend/k6/load-test.js @@ -0,0 +1,95 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, *Controller.java +// SSE 구현: SseEmitter +// 대상: 최대 40 VU, 약 16분, 정상 부하 시뮬레이션 +// 이미지 업로드: VU 중 20% 확률로 POST /api/images 수행 + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { BASE_URL, THRESHOLDS, login, getHeadersWithToken, SESSION_ID, uploadTestImage } from './config.js'; + +export const options = { + stages: [ + { duration: '2m', target: 15 }, + { duration: '5m', target: 15 }, + { duration: '2m', target: 40 }, + { duration: '5m', target: 40 }, + { duration: '2m', target: 0 }, + ], + thresholds: THRESHOLDS.load, +}; + +export function setup() { + return { token: login() }; +} + +export default function (data) { + const headers = getHeadersWithToken(data.token); + + group('읽기 — 병렬 GET', () => { + const responses = http.batch([ + ['GET', `${BASE_URL}/api/curriculums`, null, { headers }], + ['GET', `${BASE_URL}/api/sessions`, null, { headers }], + ['GET', `${BASE_URL}/api/attendance/user`, null, { headers }], + ['GET', `${BASE_URL}/api/deposit/me`, null, { headers }], + ]); + + check(responses[0], { '커리큘럼 200': (r) => r.status === 200 }); + check(responses[1], { '세션 목록 200': (r) => r.status === 200 }); + check(responses[2], { '출석 200': (r) => r.status === 200 }); + check(responses[3], { '보증금 200': (r) => r.status === 200 }); + + responses.forEach((r, i) => { + if (r.status !== 200) { + console.log(`batch[${i}] 실패: status=${r.status} body=${r.body?.slice(0, 200)}`); + } + }); + }); + + sleep(Math.random() * 0.5 + 0.5); + + group('질문 조회', () => { + const questions = http.get( + `${BASE_URL}/api/sessions/${SESSION_ID}/questions?understandingIndex=0`, + { headers } + ); + check(questions, { '질문 목록 200': (r) => r.status === 200 }); + + try { + const list = questions.json(); + if (Array.isArray(list) && list.length > 0) { + const qId = list[0].id || list[0].questionId; + if (qId) { + const detail = http.get(`${BASE_URL}/api/questions/${qId}`, { headers }); + check(detail, { '질문 상세 200': (r) => r.status === 200 }); + } + } + } catch { /* 파싱 실패 무시 */ } + }); + + sleep(Math.random() * 0.5 + 0.5); + + group('과제 조회', () => { + const week = Math.floor(Math.random() * 8) + 1; + const assignment = http.get(`${BASE_URL}/api/assignments/me/${week}`, { headers }); + check(assignment, { '과제 조회 200': (r) => r.status === 200 }); + }); + + sleep(Math.random() * 0.5 + 0.5); + + // QnA 사진 첨부 시뮬레이션 — 20% 확률 (40명 중 약 8명이 동시 업로드) + if (Math.random() < 0.2) { + group('이미지 업로드', () => { + const imgRes = uploadTestImage(data.token); + check(imgRes, { + '이미지 업로드 200': (r) => r.status === 200, + 'imageUrl 포함': (r) => (r.json('imageUrl') || '').length > 0, + }); + if (imgRes.status !== 200) { + console.log(`이미지 업로드 실패: status=${imgRes.status} body=${imgRes.body}`); + } + }); + } + + sleep(Math.random() * 0.5 + 0.5); +} diff --git a/backend/k6/smoke-test.js b/backend/k6/smoke-test.js new file mode 100644 index 0000000..7e3964e --- /dev/null +++ b/backend/k6/smoke-test.js @@ -0,0 +1,71 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, *Controller.java +// SSE 구현: SseEmitter +// 대상: 1 VU, 2분, 배포 직후 기본 동작 확인 + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { BASE_URL, THRESHOLDS, login, getHeadersWithToken, SESSION_ID, uploadTestImage } from './config.js'; + +export const options = { + vus: 1, + duration: '2m', + thresholds: THRESHOLDS.smoke, +}; + +export function setup() { + return { token: login() }; +} + +export default function (data) { + const headers = getHeadersWithToken(data.token); + + // 1. 헬스 체크 (public) + const health = http.get(`${BASE_URL}/actuator/health`); + check(health, { + '헬스 체크 200': (r) => r.status === 200, + '헬스 UP': (r) => r.json('status') === 'UP', + }); + sleep(0.5); + + // 2. 커리큘럼 목록 + const curriculum = http.get(`${BASE_URL}/api/curriculums`, { headers }); + check(curriculum, { + '커리큘럼 200': (r) => r.status === 200, + '커리큘럼 배열': (r) => Array.isArray(r.json()), + }); + if (curriculum.status !== 200) { + console.log(`커리큘럼 실패: status=${curriculum.status} body=${curriculum.body}`); + } + sleep(0.5); + + // 3. QnA 세션 목록 + const sessions = http.get(`${BASE_URL}/api/sessions`, { headers }); + check(sessions, { '세션 목록 200': (r) => r.status === 200 }); + sleep(0.5); + + // 4. 질문 목록 + const questions = http.get( + `${BASE_URL}/api/sessions/${SESSION_ID}/questions?understandingIndex=0`, + { headers } + ); + check(questions, { '질문 목록 200': (r) => r.status === 200 }); + sleep(0.5); + + // 5. 나의 출석 현황 + const attendance = http.get(`${BASE_URL}/api/attendance/user`, { headers }); + check(attendance, { '출석 200': (r) => r.status === 200 }); + sleep(0.5); + + // 6. 이미지 업로드 (QnA 사진 첨부 시뮬레이션) + const imgRes = uploadTestImage(data.token); + check(imgRes, { + '이미지 업로드 200': (r) => r.status === 200, + 'imageUrl 포함': (r) => (r.json('imageUrl') || '').length > 0, + }); + if (imgRes.status !== 200) { + console.log(`이미지 업로드 실패: status=${imgRes.status} body=${imgRes.body}`); + } + + sleep(1); +} diff --git a/backend/k6/soak-test.js b/backend/k6/soak-test.js new file mode 100644 index 0000000..9733b73 --- /dev/null +++ b/backend/k6/soak-test.js @@ -0,0 +1,86 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, *Controller.java +// SSE 구현: SseEmitter +// 대상: 20 VU, 120분, 메모리 누수·HikariCP 고갈·파일 핸들 누수 감지 + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; +import { BASE_URL, THRESHOLDS, login, getHeadersWithToken, SESSION_ID, uploadTestImage } from './config.js'; + +const slowResponses = new Trend('slow_response_trend'); +const dbErrorCount = new Counter('db_error_count'); + +let consecutiveFails = 0; + +export const options = { + stages: [ + { duration: '5m', target: 20 }, + { duration: '110m', target: 20 }, + { duration: '5m', target: 0 }, + ], + thresholds: { + ...THRESHOLDS.soak, + db_error_count: ['count<20'], + }, +}; + +export function setup() { + return { token: login(), startTime: Date.now() }; +} + +export default function (data) { + const headers = getHeadersWithToken(data.token); + const elapsed = (Date.now() - data.startTime) / 1000 / 60; + + const cur = http.get(`${BASE_URL}/api/curriculums`, { headers }); + const curOk = check(cur, { + '커리큘럼 200': (r) => r.status === 200, + '1s 이내': (r) => r.timings.duration < 1000, + }); + + if (!curOk) { + consecutiveFails++; + dbErrorCount.add(1); + if (consecutiveFails >= 5) { + console.warn(`⚠️ DB 커넥션 고갈 의심 — 연속 실패 ${consecutiveFails}회 (${elapsed.toFixed(1)}분 경과)`); + } + } else { + consecutiveFails = 0; + } + + if (cur.timings.duration > 1000) { + slowResponses.add(cur.timings.duration); + console.log(`응답 지연: ${cur.timings.duration}ms (${elapsed.toFixed(1)}분 경과)`); + } + + sleep(1); + + const att = http.get(`${BASE_URL}/api/attendance/user`, { headers }); + check(att, { '출석 200': (r) => r.status === 200 }); + if (att.status !== 200) dbErrorCount.add(1); + + sleep(1); + + const q = http.get( + `${BASE_URL}/api/sessions/${SESSION_ID}/questions?understandingIndex=0`, + { headers } + ); + check(q, { '질문 목록 200': (r) => r.status === 200 }); + if (elapsed >= 30 && q.timings.duration > 1000) { + console.warn(`⚠️ 30분 경과 후 응답 저하: ${q.timings.duration}ms — 메모리 누수 또는 DB 부하 의심`); + } + + sleep(1); + + // 이미지 업로드 20% 확률 — 파일 핸들 누수 장시간 감지 + if (Math.random() < 0.2) { + const imgRes = uploadTestImage(data.token); + check(imgRes, { '이미지 업로드 200': (r) => r.status === 200 }); + if (imgRes.status !== 200) { + console.log(`이미지 업로드 실패 (${elapsed.toFixed(1)}분 경과): status=${imgRes.status}`); + } + } + + sleep(2); +} diff --git a/backend/k6/sse-test.js b/backend/k6/sse-test.js new file mode 100644 index 0000000..3b9e4a9 --- /dev/null +++ b/backend/k6/sse-test.js @@ -0,0 +1,130 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, QuestionController.java, QuestionEventService.java +// SSE 구현: SseEmitter (Spring WebMVC) +// 대상: SSE 30 VU + 일반 API 10 VU = 총 40명 +// +// ⚠️ k6 SSE 측정 방식 +// http.get()은 연결이 닫혀야 status를 확정함 +// SSE는 서버가 3분간 연결 유지 → k6 timeout(10s) 후 status=0으로 기록됨 +// 따라서: +// - sse_connect_time: res.timings.waiting (TTFB, 첫 바이트 수신 시간) 사용 +// - sse_error: timeout이어도 body에 'connected' 포함 시 성공으로 처리 +// +// ⚠️ SseEmitter 주의사항 +// Tomcat threads.max=200, SSE 30개 + 일반 10개 = 40 스레드 → 안전 범위 +// SSE timeout=3분 (QuestionEventService.SSE_TIMEOUT_MILLIS) 자동 해제 + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; +import { BASE_URL, THRESHOLDS, login, getHeadersWithToken, SESSION_ID, uploadTestImage } from './config.js'; + +const sseConnectTime = new Trend('sse_connect_time'); // TTFB (첫 바이트 수신 시간) +const sseEventCount = new Counter('sse_event_received'); +const sseErrorCount = new Counter('sse_error'); // 진짜 서버 오류만 카운트 + +export const options = { + scenarios: { + sse_connections: { + executor: 'constant-vus', + vus: 30, + duration: '10m', + exec: 'sseScenario', + }, + normal_api: { + executor: 'constant-vus', + vus: 10, + duration: '10m', + exec: 'apiScenario', + startTime: '30s', + }, + }, + thresholds: { + // TTFB 기준 — 연결 수립 + 첫 이벤트 수신 시간 + sse_connect_time: ['p(95)<2000'], + // 진짜 서버 오류만 카운트 (timeout 제외) + sse_error: ['count<10'], + 'http_req_duration{scenario:normal_api}': ['p(95)<1000'], + }, +}; + +export function setup() { + return { token: login() }; +} + +export function sseScenario(data) { + const params = { + headers: { + ...getHeadersWithToken(data.token), + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + timeout: '10s', // connected 이벤트 수신에 충분한 시간, 전체 연결 유지 불필요 + }; + + const res = http.get( + `${BASE_URL}/api/sessions/${SESSION_ID}/questions/events`, + params + ); + + const body = res.body || ''; + const receivedConnected = body.includes('connected'); + const isTimeout = res.status === 0; + + // TTFB — 실제 서버가 첫 바이트를 보낼 때까지 걸린 시간 + if (res.timings.waiting > 0) { + sseConnectTime.add(res.timings.waiting); + } + + if (res.status === 200 || (isTimeout && receivedConnected)) { + // 정상 연결: 200 응답 또는 timeout이어도 connected 이벤트 수신한 경우 + const events = body.split('\n').filter(l => l.startsWith('data:')); + sseEventCount.add(events.length); + + check(res, { + 'SSE connected 이벤트 수신': () => receivedConnected, + 'Content-Type text/event-stream': (r) => + r.status === 200 + ? (r.headers['Content-Type'] || '').includes('text/event-stream') + : true, // timeout 시 헤더 미수신 허용 + }); + } else { + // 진짜 서버 오류 (4xx, 5xx, 네트워크 오류) + sseErrorCount.add(1); + console.log(`SSE 연결 실패: status=${res.status} body=${body.slice(0, 200)}`); + } + + sleep(1); +} + +export function apiScenario(data) { + const headers = getHeadersWithToken(data.token); + + const responses = http.batch([ + ['GET', `${BASE_URL}/api/curriculums`, null, { headers }], + ['GET', `${BASE_URL}/api/attendance/user`, null, { headers }], + ['GET', `${BASE_URL}/api/sessions/${SESSION_ID}/questions?understandingIndex=0`, null, { headers }], + ]); + + check(responses[0], { '[SSE중] 커리큘럼 200': (r) => r.status === 200 }); + check(responses[1], { '[SSE중] 출석 200': (r) => r.status === 200 }); + check(responses[2], { '[SSE중] 질문 목록 200': (r) => r.status === 200 }); + + responses.forEach((r, i) => { + if (r.status !== 200) { + console.log(`⚠️ SSE 중 일반 API 실패[${i}]: status=${r.status} — 스레드 고갈 의심`); + } + }); + + sleep(0.5); + + if (Math.random() < 0.3) { + const imgRes = uploadTestImage(data.token); + check(imgRes, { '[SSE중] 이미지 업로드 200': (r) => r.status === 200 }); + if (imgRes.status !== 200) { + console.log(`⚠️ SSE 중 이미지 업로드 실패: status=${imgRes.status}`); + } + } + + sleep(1); +} diff --git a/backend/k6/stress-test.js b/backend/k6/stress-test.js new file mode 100644 index 0000000..e7cb615 --- /dev/null +++ b/backend/k6/stress-test.js @@ -0,0 +1,71 @@ +// 테스트 환경: http://localhost:8080 (dev) / https://api.piroin.com (prod) +// 작성 기준: application.yml, SecurityConfig.java, *Controller.java +// SSE 구현: SseEmitter +// 대상: 최대 120 VU (40명 기준 3배), 약 35분, 한계 부하 탐색 +// ⚠️ HikariCP pool-size=10 → 120 VU 시 DB 커넥션 병목 관측 예상 (의도된 관측) + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter } from 'k6/metrics'; +import { BASE_URL, THRESHOLDS, login, getHeadersWithToken, SESSION_ID } from './config.js'; + +const errorCount = new Counter('custom_errors'); + +export const options = { + stages: [ + { duration: '2m', target: 20 }, + { duration: '5m', target: 20 }, + { duration: '2m', target: 40 }, // 정상 부하 + { duration: '5m', target: 40 }, + { duration: '2m', target: 80 }, // 2배 + { duration: '5m', target: 80 }, + { duration: '2m', target: 120 }, // 3배, 한계 탐색 + { duration: '5m', target: 120 }, + { duration: '5m', target: 0 }, // 회복 확인 + ], + thresholds: { + ...THRESHOLDS.stress, + custom_errors: ['count<100'], + }, +}; + +export function setup() { + return { token: login() }; +} + +export default function (data) { + const headers = getHeadersWithToken(data.token); + + const cur = http.get(`${BASE_URL}/api/curriculums`, { headers }); + const ok = check(cur, { + '커리큘럼 200': (r) => r.status === 200, + '응답 3s 이내': (r) => r.timings.duration < 3000, + }); + if (!ok || cur.status !== 200) { + errorCount.add(1); + if (cur.timings.duration >= 3000) { + console.log(`응답 지연: ${cur.timings.duration}ms status=${cur.status}`); + } + } + + sleep(0.5); + + const att = http.get(`${BASE_URL}/api/attendance/user`, { headers }); + check(att, { '출석 200': (r) => r.status === 200 }); + if (att.status !== 200) errorCount.add(1); + + sleep(0.5); + + const q = http.get( + `${BASE_URL}/api/sessions/${SESSION_ID}/questions?understandingIndex=0`, + { headers } + ); + check(q, { '질문 목록 200': (r) => r.status === 200 }); + if (q.status !== 200) errorCount.add(1); + + sleep(0.5); +} + +export function teardown() { + console.log('Stress test 완료 — 회복 구간(마지막 5분) 에러율을 결과에서 확인할 것'); +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b0e4826..5063915 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -13,6 +13,9 @@ spring: max-lifetime: 1800000 leak-detection-threshold: 5000 + transaction: + default-timeout: 30 # 트랜잭션 전체 30s 초과 시 롤백 + flyway: # repair-on-migrate: true enabled: true @@ -28,6 +31,17 @@ spring: packagesToScan: com.example.Piroin.project.domain jdbc: time_zone: Asia/Seoul + # 슬로우 쿼리 로그 — 1초 이상 걸리는 쿼리를 WARN 레벨로 출력 + generate_statistics: true + session: + events: + log: + LOG_QUERIES_SLOWER_THAN_MS: 1000 + # 쿼리 실행 자체 타임아웃 — 커넥션 획득 후 쿼리가 느린 경우 대응 + javax: + persistence: + query: + timeout: 30000 # 30s 초과 쿼리 예외 발생 jackson: time-zone: Asia/Seoul @@ -36,6 +50,14 @@ jwt: secret: ${JWT_SECRET} expiration: ${JWT_EXPIRATION} +logging: + level: + # 슬로우 쿼리 로그 (1초 이상 쿼리 감지) + org.hibernate.SQL_SLOW: WARN + # HikariCP 커넥션 풀 상태 로그 + com.zaxxer.hikari: DEBUG + com.zaxxer.hikari.HikariConfig: DEBUG + management: endpoints: web: @@ -52,3 +74,8 @@ server: threads: max: 200 # 기본값 200 (명시적으로 설정) min-spare: 20 # 최소 대기 스레드 수 + # 요청 레벨 타임아웃 — Soak에서 최대 24분 응답 방지 + connection-timeout: 20000 # 커넥션 수립 타임아웃 20s + servlet: + session: + timeout: 30m From 4f3b857b50cca765cffbffa9d453026835e344b7 Mon Sep 17 00:00:00 2001 From: plumbestie Date: Mon, 8 Jun 2026 14:23:07 +0900 Subject: [PATCH 13/18] =?UTF-8?q?[Fix]=20Header=20=ED=88=AC=EB=AA=85?= =?UTF-8?q?=EB=8F=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Header.module.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/components/Header.module.css b/frontend/src/components/Header.module.css index 9b3c98e..aebec1b 100644 --- a/frontend/src/components/Header.module.css +++ b/frontend/src/components/Header.module.css @@ -3,6 +3,7 @@ --header-bg: var(--white); --header-color: var(--gray600); --logo-color: var(--dark); + --drawer-bg: var(--white); } /* 다크 헤더 */ @@ -11,6 +12,7 @@ --header-color: var(--white); --logo-color: var(--main); border-bottom: none; + --drawer-bg: var(--black); } .header { @@ -151,6 +153,7 @@ transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.35s ease; box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); + background: var(--drawer-bg); } .drawerOpen { From 693fb2efe5b7e8ef781e2dbbcd2b0b8e67f8c3dd Mon Sep 17 00:00:00 2001 From: plumbestie Date: Mon, 8 Jun 2026 15:00:16 +0900 Subject: [PATCH 14/18] =?UTF-8?q?[Fix]=20=EC=BB=A4=EB=A6=AC=ED=81=98?= =?UTF-8?q?=EB=9F=BC=20CSS=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../curriculum/CurriculumPage.module.css | 59 ++++--------------- 1 file changed, 10 insertions(+), 49 deletions(-) diff --git a/frontend/src/pages/curriculum/CurriculumPage.module.css b/frontend/src/pages/curriculum/CurriculumPage.module.css index de1c60c..18208af 100644 --- a/frontend/src/pages/curriculum/CurriculumPage.module.css +++ b/frontend/src/pages/curriculum/CurriculumPage.module.css @@ -73,14 +73,11 @@ border-radius: 20px; padding: 30px 30px 24px 30px; min-width: 200px; - width: calc(28% - 14px); - max-height: 380px; + width: calc(33.333% - 14px); display: flex; flex-direction: column; box-shadow: 0 1px 4px rgba(0,0,0,0.06); box-sizing: border-box; - overflow: hidden; - transition: max-height 0.3s ease; } .cardHeader { @@ -512,8 +509,6 @@ width: 100%; min-width: unset; padding: 20px; - height: auto; - overflow: visible; } .cardBody { @@ -521,47 +516,13 @@ flex: none; } - .cardTitle { - font-size: 1.1rem; - } - - .cardDate { - margin-left: 0; - } - - /* 폼 */ - .formOverlay { - padding: 0; - align-items: flex-start; - } - - .formCard { - width: 100%; - min-height: 100vh; - border-radius: 0; - padding: 28px 20px; - } - - .formGrid { - grid-template-columns: 1fr; - } - - .formRow2 { - grid-template-columns: 1fr; - } - - .saveFormBtn { - width: 100%; - } - - .statusBtns { - margin-left: 0; - margin-top: 6px; - width: 100%; - } - - .formSectionTitle { - flex-direction: column; - align-items: flex-start; - } + .cardTitle { font-size: 1.1rem; } + .cardDate { margin-left: 0; } + .formOverlay { padding: 0; align-items: flex-start; } + .formCard { width: 100%; min-height: 100vh; border-radius: 0; padding: 28px 20px; } + .formGrid { grid-template-columns: 1fr; } + .formRow2 { grid-template-columns: 1fr; } + .saveFormBtn { width: 100%; } + .statusBtns { margin-left: 0; margin-top: 6px; width: 100%; } + .formSectionTitle { flex-direction: column; align-items: flex-start; } } \ No newline at end of file From a8b84cf597a72202cdda5758b478281299ba23a1 Mon Sep 17 00:00:00 2001 From: kkw610 Date: Mon, 8 Jun 2026 17:22:19 +0900 Subject: [PATCH 15/18] =?UTF-8?q?fix:=20=EC=9E=AC=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=9C=A0=EC=8B=A4=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=9C=20Docker=20nam?= =?UTF-8?q?ed=20volume=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 1 + backend/Dockerfile | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2d5b3cd..21f1f7b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -58,6 +58,7 @@ jobs: --name piroin-backend \ --restart unless-stopped \ -p 8080:8080 \ + -v piroin-uploads:/app/uploads \ --health-cmd="curl -f http://localhost:8080/actuator/health || exit 1" \ --health-interval=30s \ --health-timeout=10s \ diff --git a/backend/Dockerfile b/backend/Dockerfile index f7d2e4a..c549523 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,4 +4,6 @@ WORKDIR /app COPY build/libs/*.jar app.jar +VOLUME /app/uploads + ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "app.jar"] \ No newline at end of file From 30beff28f577d70b2dc97429d7bf7fb66b4b688a Mon Sep 17 00:00:00 2001 From: kkw610 Date: Mon, 8 Jun 2026 17:50:36 +0900 Subject: [PATCH 16/18] =?UTF-8?q?feat:=20=EC=A7=88=EB=AC=B8/=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=9E=A5=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A7=80=EC=9B=90(?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=205=EC=9E=A5)=20BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/controller/ImageController.java | 70 ++++++++++++++----- .../domain/question/dto/QuestionReqDTO.java | 9 ++- .../domain/question/dto/QuestionResDTO.java | 12 ++-- .../domain/question/entity/Question.java | 55 +++++++++++++++ .../question/entity/QuestionComment.java | 11 +++ .../question/service/QuestionService.java | 27 +++---- 6 files changed, 146 insertions(+), 38 deletions(-) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java index 1fdac22..ee8db0b 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java @@ -11,6 +11,8 @@ import java.io.File; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -21,8 +23,7 @@ public class ImageController { @Value("${file.upload-dir}") private String uploadDir; - // consumes 제거 - // Swagger용 어노테이션 추가 (파일 선택 버튼 표시용) + // 단일 이미지 업로드 @PostMapping @Operation( requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( @@ -33,33 +34,44 @@ public class ImageController { public ResponseEntity> uploadImage( @RequestParam("file") MultipartFile file ) throws IOException { + String savedUrl = saveFile(file); + return ResponseEntity.ok(Map.of("imageUrl", savedUrl)); + } - // 절대 경로로 변환 - File dir = new File(uploadDir).getAbsoluteFile(); - if (!dir.exists()) { - dir.mkdirs(); + // 다중 이미지 업로드 (최대 5장) + // POST /api/images/multi + @PostMapping("/multi") + @Operation( + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(type = "object", requiredProperties = {"files"})) + ) + ) + public ResponseEntity>> uploadImages( + @RequestParam("files") List files + ) throws IOException { + if (files == null || files.isEmpty()) { + return ResponseEntity.badRequest().build(); } - - // 파일명 중복 방지: UUID + 원본 확장자 - String originalName = file.getOriginalFilename(); - String extension = ""; - if (originalName != null && originalName.contains(".")) { - extension = originalName.substring(originalName.lastIndexOf(".")); + // 최대 5장 제한 (서버 부하 방지) + if (files.size() > 5) { + return ResponseEntity.badRequest().build(); } - String savedName = UUID.randomUUID() + extension; - - // 절대 경로로 파일 저장 - File targetFile = new File(dir, savedName); - file.transferTo(targetFile); - return ResponseEntity.ok(Map.of("imageUrl", "/api/images/" + savedName)); + List imageUrls = new ArrayList<>(); + for (MultipartFile file : files) { + if (!file.isEmpty()) { + imageUrls.add(saveFile(file)); + } + } + return ResponseEntity.ok(Map.of("imageUrls", imageUrls)); } // 이미지 조회 // GET /api/images/{filename} @GetMapping("/{filename}") public ResponseEntity getImage(@PathVariable String filename) throws IOException { - File file = new File(new File(uploadDir).getAbsoluteFile(), filename); // ← 절대 경로 + File file = new File(new File(uploadDir).getAbsoluteFile(), filename); if (!file.exists()) { return ResponseEntity.notFound().build(); @@ -71,4 +83,24 @@ public ResponseEntity getImage(@PathVariable String filename) throws IOE .header("Content-Type", contentType) .body(java.nio.file.Files.readAllBytes(file.toPath())); } + + // 파일 저장 공통 로직 + private String saveFile(MultipartFile file) throws IOException { + File dir = new File(uploadDir).getAbsoluteFile(); + if (!dir.exists()) { + dir.mkdirs(); + } + + String originalName = file.getOriginalFilename(); + String extension = ""; + if (originalName != null && originalName.contains(".")) { + extension = originalName.substring(originalName.lastIndexOf(".")); + } + String savedName = UUID.randomUUID() + extension; + + File targetFile = new File(dir, savedName); + file.transferTo(targetFile); + + return "/api/images/" + savedName; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java index c49ab0c..4d04cc7 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java @@ -4,6 +4,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.List; + public class QuestionReqDTO { // 질문 등록 요청 @@ -11,7 +13,9 @@ public class QuestionReqDTO { @NoArgsConstructor public static class CreateReq { private String content; - private String imageUrl; + // 이미지 여러 장 지원: URL 목록으로 수신 + // 프론트에서 /api/images/multi로 업로드 후 반환된 URL 목록을 넘겨줌 + private List imageUrls; } // 질문 수정 요청 @@ -27,7 +31,8 @@ public static class UpdateReq { @NoArgsConstructor public static class CommentReq { private String content; - private String imageUrl; + // 이미지 여러 장 지원 + private List imageUrls; private Long parentCommentId; // 대댓글일 때만 값이 있음, 일반 댓글이면 null } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java index fd59783..7c516fd 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java @@ -75,7 +75,8 @@ public record QuestionDetailResponse( Long questionId, String displayName, String content, - String imageUrl, + // 이미지 여러 장 지원: URL 목록 + List imageUrls, Boolean isResolved, Boolean isPopular, Integer likeCount, @@ -91,7 +92,8 @@ public record CommentResponse( Long commentId, String displayName, String content, - String imageUrl, + // 이미지 여러 장 지원 + List imageUrls, Boolean isMine, LocalDateTime createdAt, List replies @@ -161,7 +163,8 @@ public record QuestionGroupsResponse( public record QuestionSummaryResponse( Long questionId, String content, - String imageUrl, + // 이미지 여러 장 지원 + List imageUrls, Boolean isResolved, Boolean isPopular, Boolean isLiked, @@ -245,7 +248,8 @@ public record QuestionCreatedEvent( Long sessionId, Long questionId, String content, - String imageUrl, + // 이미지 여러 장 지원 + List imageUrls, // 좋아요 수 (생성 직후에는 0) Integer likeCount, // 댓글 수 (생성 직후에는 0) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java index 195f34c..24e3197 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java @@ -6,6 +6,10 @@ import lombok.*; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; @Entity @Table(name = "question") @@ -30,6 +34,12 @@ public class Question { @Column(columnDefinition = "TEXT") private String content; + /* + 이미지 URL 목록을 JSON 배열 문자열로 저장 + 예시: ["\/api\/images\/uuid1.png","\/api\/images\/uuid2.jpg"] + 기존 단일 URL(하위 호환): 기존 데이터에 imageUrl이 JSON 배열이 아닌 단일 URL 문자열로 저장된 경우 + getImageUrls()에서 정상적으로 파싱하여 1개짜리 리스트로 반환 + */ @Column(name = "image_url", columnDefinition = "TEXT") private String imageUrl; @@ -48,6 +58,18 @@ public class Question { @Column(name = "deleted_at") private LocalDateTime deletedAt; + // 이미지 URL 목록 조회 (JSON 배열 → List 변환) + @Transient + public List getImageUrls() { + return parseImageUrls(this.imageUrl); + } + + // 이미지 URL 목록 저장 (List → JSON 배열 문자열 변환) + public void setImageUrls(List imageUrls) { + this.imageUrl = serializeImageUrls(imageUrls); + this.updatedAt = LocalDateTime.now(); + } + // 댓글이 새로 달리면 미해결로 되돌리도록 public void markUnresolved() { this.isResolved = false; @@ -85,4 +107,37 @@ public void markResolved() { this.isResolved = true; this.updatedAt = LocalDateTime.now(); } + + // JSON 배열 문자열 파싱 유틸 (하위 호환: 기존 단일 URL도 1개짜리 리스트로 반환) + public static List parseImageUrls(String raw) { + if (raw == null || raw.isBlank()) { + return new ArrayList<>(); + } + String trimmed = raw.trim(); + // JSON 배열 형태인 경우 + if (trimmed.startsWith("[")) { + // 간단한 JSON 배열 파싱 (외부 라이브러리 없이) + String inner = trimmed.substring(1, trimmed.length() - 1).trim(); + if (inner.isEmpty()) return new ArrayList<>(); + return Arrays.stream(inner.split(",")) + .map(s -> s.trim().replaceAll("^\"|\"$", "")) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + // 기존 단일 URL (하위 호환) + List list = new ArrayList<>(); + list.add(trimmed); + return list; + } + + // List → JSON 배열 문자열 직렬화 유틸 + public static String serializeImageUrls(List urls) { + if (urls == null || urls.isEmpty()) { + return null; + } + String joined = urls.stream() + .map(url -> "\"" + url.replace("\"", "\\\"") + "\"") + .collect(Collectors.joining(",")); + return "[" + joined + "]"; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java index d91606c..b68b716 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java @@ -5,6 +5,7 @@ import lombok.*; import java.time.LocalDateTime; +import java.util.List; @Entity @Table(name = "question_comment") @@ -39,6 +40,10 @@ public class QuestionComment { @Column(columnDefinition = "TEXT") private String content; + /* + 이미지 URL 목록을 JSON 배열 문자열로 저장 + Question 엔티티와 동일한 방식 사용 + */ @Column(name = "image_url", columnDefinition = "TEXT") private String imageUrl; @@ -51,6 +56,12 @@ public class QuestionComment { @Column(name = "deleted_at") private LocalDateTime deletedAt; + // 이미지 URL 목록 조회 + @Transient + public List getImageUrls() { + return Question.parseImageUrls(this.imageUrl); + } + // 댓글 내용 수정 public void updateContent(String content) { this.content = content; 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 74552f7..008597d 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 @@ -92,7 +92,7 @@ private QuestionResDTO.QuestionDetailResponse toDetailResponse(Question question .toList(); return new QuestionResDTO.QuestionDetailResponse( - question.getId(), "작성자", question.getContent(), question.getImageUrl(), + question.getId(), "작성자", question.getContent(), question.getImageUrls(), question.getIsResolved(), isPopular, question.getLikeCount(), isLiked, isMine, question.getCreatedAt(), commentResponses @@ -106,14 +106,14 @@ private QuestionResDTO.CommentResponse toCommentResponse(Question question, Ques List replyResponses = replies.stream() .map(reply -> new QuestionResDTO.CommentResponse( reply.getId(), getDisplayName(question, reply.getUser()), - reply.getContent(), reply.getImageUrl(), isCommentMine(reply, loginUser), + reply.getContent(), reply.getImageUrls(), isCommentMine(reply, loginUser), reply.getCreatedAt(), List.of() )) .toList(); return new QuestionResDTO.CommentResponse( comment.getId(), getDisplayName(question, comment.getUser()), - comment.getContent(), comment.getImageUrl(), isCommentMine(comment, loginUser), + comment.getContent(), comment.getImageUrls(), isCommentMine(comment, loginUser), comment.getCreatedAt(), replyResponses ); } @@ -137,7 +137,7 @@ public QuestionResDTO.CommentCreateRes createComment( QuestionComment parentComment = resolveParentComment(request.getParentCommentId(), question); // builder 전에 검증 추가 - validateCommentContent(request.getContent(), request.getImageUrl()); + validateCommentContent(request.getContent(), request.getImageUrls()); // 2. 댓글 엔티티 생성 및 저장 LocalDateTime now = LocalDateTime.now(); @@ -146,7 +146,7 @@ public QuestionResDTO.CommentCreateRes createComment( .user(loginUser) .parentComment(parentComment) // 일반 댓글이면 null, 대댓글이면 부모 댓글 .content(request.getContent()) - .imageUrl(request.getImageUrl()) + .imageUrl(Question.serializeImageUrls(request.getImageUrls())) .createdAt(now) .updatedAt(now) .build(); @@ -252,13 +252,13 @@ public QuestionResDTO.CreateRes createQuestion(Long sessionId, QuestionReqDTO.Cr StudySession session = findSession(sessionId); // builder 전에 검증 추가 - validateQuestionContent(request.getContent(), request.getImageUrl()); + validateQuestionContent(request.getContent(), request.getImageUrls()); Question question = Question.builder() .session(session) .user(loginUser) .content(request.getContent()) - .imageUrl(request.getImageUrl()) + .imageUrl(Question.serializeImageUrls(request.getImageUrls())) .isResolved(false) .likeCount(0) .createdAt(LocalDateTime.now()) @@ -654,7 +654,7 @@ private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse ( boolean isLiked = summaryContext.likedQuestionIds().contains(questionId); boolean isMine = question.getUser().getId().equals(loginUser.getId()); return new QuestionResDTO.QuestionSummaryResponse( - questionId, question.getContent(), question.getImageUrl(), + questionId, question.getContent(), question.getImageUrls(), question.getIsResolved(), !question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD, isLiked, @@ -713,6 +713,7 @@ private QuestionResDTO.PreviewCommentResponse toPreviewCommentResponse( } private boolean hasPreviewImage(QuestionCommentRepository.PreviewCommentRow row) { + // image_url 컬럼에 값이 있으면 이미지 있는 것으로 처리 (JSON 배열 또는 단일 URL 모두 포함) return row.getImageUrl() != null && !row.getImageUrl().isBlank(); } @@ -789,7 +790,7 @@ private void publishQuestionCreatedEventAfterCommit(Question question) { sessionId, question.getId(), question.getContent(), - question.getImageUrl(), + question.getImageUrls(), question.getLikeCount(), 0, // 방금 만들어진 질문이므로 댓글 수는 0 question.getCreatedAt() @@ -871,18 +872,18 @@ private record QuestionSummaryContext( } // 질문은 내용 또는 이미지 중 하나는 반드시 있어야 함 - private void validateQuestionContent(String content, String imageUrl) { + private void validateQuestionContent(String content, List imageUrls) { boolean hasContent = content != null && !content.isBlank(); - boolean hasImage = imageUrl != null && !imageUrl.isBlank(); + boolean hasImage = imageUrls != null && !imageUrls.isEmpty(); if (!hasContent && !hasImage) { throw new QuestionException(HttpStatus.BAD_REQUEST, "질문 내용 또는 이미지 중 하나는 필수입니다."); } } // 댓글은 내용 또는 이미지 중 하나는 반드시 있어야 함 - private void validateCommentContent(String content, String imageUrl) { + private void validateCommentContent(String content, List imageUrls) { boolean hasContent = content != null && !content.isBlank(); - boolean hasImage = imageUrl != null && !imageUrl.isBlank(); + boolean hasImage = imageUrls != null && !imageUrls.isEmpty(); if (!hasContent && !hasImage) { throw new QuestionException(HttpStatus.BAD_REQUEST, "댓글 내용 또는 이미지 중 하나는 필수입니다."); } From fb0958ea1975e3c8b9c1d5648495293839df44d5 Mon Sep 17 00:00:00 2001 From: kkw610 Date: Mon, 8 Jun 2026 17:57:41 +0900 Subject: [PATCH 17/18] =?UTF-8?q?feat:=20=EC=A7=88=EB=AC=B8/=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=9E=A5=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A7=80=EC=9B=90(?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=205=EC=9E=A5)=20FE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnADetailPage.js | 112 ++++++++------ frontend/src/pages/qna/QnAListPage.js | 189 ++++++++++++++---------- frontend/src/utils/qnaUtils.js | 18 ++- 3 files changed, 197 insertions(+), 122 deletions(-) diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index 9ca6e44..e965efb 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -8,7 +8,7 @@ import { MeCuriousToo, StaffCheck, SumitBtn, - uploadImage, + uploadImages, } from '../../utils/qnaUtils'; import profileImg from '../../assets/images/profile.png'; import { authFetch } from '../../utils/Api'; @@ -39,10 +39,16 @@ const createBlobImageUrl = async (imageUrl) => { } }; +// imageUrls 배열을 blob URL 배열로 변환 +const createBlobImageUrls = async (imageUrls) => { + if (!imageUrls || imageUrls.length === 0) return []; + return Promise.all(imageUrls.map(url => createBlobImageUrl(url))); +}; + const attachCommentBlobImages = async (comments = []) => Promise.all( comments.map(async (comment) => ({ ...comment, - imageUrl: await createBlobImageUrl(comment.imageUrl), + imageUrls: await createBlobImageUrls(comment.imageUrls ?? []), replies: await attachCommentBlobImages(comment.replies ?? []), })) ); @@ -82,8 +88,8 @@ function QnADetailPage() { // ── 댓글 입력 상태 ─────────────────────────────── const [commentText, setCommentText] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - const [selectedImage, setSelectedImage] = useState(null); - const [imagePreview, setImagePreview] = useState(null); + const [selectedImages, setSelectedImages] = useState([]); // 여러 장 + const [imagePreviews, setImagePreviews] = useState([]); // 여러 장 미리보기 const fileInputRef = useRef(null); // ── 댓글 수정 상태 ─────────────────────────────── @@ -104,8 +110,8 @@ function QnADetailPage() { const result = json.result; - // 질문 이미지 blob 변환 - result.imageUrl = await createBlobImageUrl(result.imageUrl); + // 질문 이미지 blob 변환 (여러 장) + result.imageUrls = await createBlobImageUrls(result.imageUrls ?? []); // 댓글과 대댓글 이미지 blob 변환 if (result.comments) { @@ -259,10 +265,14 @@ function QnADetailPage() { // ── 댓글 이미지 선택 / 붙여넣기 ───────────────── const handleImageSelect = (e) => { - const file = e.target.files[0]; - if (!file) return; - setSelectedImage(file); - setImagePreview(URL.createObjectURL(file)); + const files = Array.from(e.target.files); + if (!files.length) return; + // 최대 5장 제한 + const merged = [...selectedImages, ...files].slice(0, 5); + setSelectedImages(merged); + setImagePreviews(merged.map(f => URL.createObjectURL(f))); + // 같은 파일 재선택 허용 + e.target.value = ''; }; const handlePaste = (e) => { @@ -272,49 +282,44 @@ function QnADetailPage() { if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { - setSelectedImage(file); - setImagePreview(URL.createObjectURL(file)); + const merged = [...selectedImages, file].slice(0, 5); + setSelectedImages(merged); + setImagePreviews(merged.map(f => URL.createObjectURL(f))); } break; } } }; + const handleRemoveImage = (idx) => { + const next = selectedImages.filter((_, i) => i !== idx); + setSelectedImages(next); + setImagePreviews(next.map(f => URL.createObjectURL(f))); + }; + // ── 댓글 등록 ──────────────────────────────────── const handleCommentSubmit = async () => { const text = commentText.trim(); - if (!text && !selectedImage) return; + if (!text && selectedImages.length === 0) return; setIsSubmitting(true); try { - let imageUrl = null; - if (selectedImage) { - imageUrl = await uploadImage(selectedImage); + let imageUrls = []; + if (selectedImages.length > 0) { + imageUrls = await uploadImages(selectedImages); } const res = await authFetch(`/api/questions/${questionId}/comments`, { method: 'POST', - body: JSON.stringify({ content: text, parentCommentId: null, imageUrl }), + body: JSON.stringify({ content: text, parentCommentId: null, imageUrls }), }); if (!res.ok) throw new Error(); const json = await res.json(); if (json.isSuccess) { - // 댓글 등록 응답값으로 해결 상태 반영 - setQuestion(prev => ({ ...prev, isResolved: json.result.isResolved })); - - const newComment = { - commentId: json.result.commentId, - displayName: json.result.displayName, - content: json.result.content, - createdAt: json.result.createdAt, - imageUrl: imagePreview, - isMine: true, - }; - setQuestion(prev => ({ - ...prev, - comments: [...(prev.comments ?? []), newComment], - })); setCommentText(''); - setSelectedImage(null); - setImagePreview(null); + setSelectedImages([]); + setImagePreviews([]); + // 로컬 상태에 blob URL을 직접 넣으면 새로고침 시 이미지가 깨지므로 + // 등록 직후 fetchQuestion으로 서버의 정식 URL을 받아온다. + await fetchQuestion(); } } catch (err) { console.error('댓글 등록 실패:', err); @@ -432,8 +437,12 @@ function QnADetailPage() { {comment.content} )} - {comment.imageUrl && ( - 댓글 첨부 이미지 + {comment.imageUrls?.length > 0 && ( +
+ {comment.imageUrls.map((url, idx) => ( + {`댓글 + ))} +
)}

{formatTime(comment.createdAt)}

@@ -516,9 +525,13 @@ function QnADetailPage() { )} - {/* ── 질문 첨부 이미지 ── */} - {question.imageUrl && ( - 첨부 이미지 + {/* ── 질문 첨부 이미지 (여러 장) ── */} + {question.imageUrls?.length > 0 && ( +
+ {question.imageUrls.map((url, idx) => ( + {`첨부 + ))} +
)} {/* ── 액션 버튼 (좋아요 / 댓글달기) ── */} @@ -548,13 +561,17 @@ function QnADetailPage() { {/* ── 하단 댓글 입력바 ── */}
- {imagePreview && ( -
- 미리보기 - + {imagePreviews.length > 0 && ( +
+ {imagePreviews.map((preview, idx) => ( +
+ {`미리보기 + +
+ ))}
)}
@@ -565,6 +582,7 @@ function QnADetailPage() { {isSubmitting ? '⏳' : } diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 211fcfd..a6f56a5 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -7,7 +7,7 @@ import { subscribeQuestionEvents } from '../../utils/sse'; import { CommentImoji, MeCuriousToo, SortBtn, OBtn, XBtn, CommentCommentArraw, SumitBtn, StaffCheck, ImgPreview, - DAY_PART_KO, DAY_OF_WEEK_KO, uploadImage, + DAY_PART_KO, DAY_OF_WEEK_KO, uploadImages, } from '../../utils/qnaUtils'; const MAX_VISIBLE_COMMENTS = 3; @@ -186,7 +186,9 @@ function QnAListPage() { // ── 댓글 입력 상태 ─────────────────────────────── const [commentOpenId, setCommentOpenId] = useState(null); const [commentInputs, setCommentInputs] = useState({}); + // 질문별 댓글 이미지 여러 장: { [questionId]: File[] } const [commentImages, setCommentImages] = useState({}); + // 질문별 댓글 이미지 미리보기: { [questionId]: string[] } const [commentImagePreviews, setCommentImagePreviews] = useState({}); const commentFileRefs = useRef({}); @@ -194,8 +196,9 @@ function QnAListPage() { const [newQuestion, setNewQuestion] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); - const [selectedImage, setSelectedImage] = useState(null); - const [imagePreview, setImagePreview] = useState(null); + // 질문 이미지 여러 장 + const [selectedImages, setSelectedImages] = useState([]); + const [imagePreviews, setImagePreviews] = useState([]); const fileInputRef = useRef(null); const applyQuestionGroups = useCallback((groups) => { @@ -243,20 +246,22 @@ function QnAListPage() { ...(questions.resolvedQuestions ?? []), ]; - // 질문 이미지 blob URL 변환 + // 질문 이미지 blob URL 변환 (여러 장: imageUrls 배열) const withBlob = await Promise.all( allQ.map(async (q) => { - let blobImageUrl = null; - if (q.imageUrl) { - try { - const imgRes = await authFetch(q.imageUrl); - const blob = await imgRes.blob(); - blobImageUrl = URL.createObjectURL(blob); - } catch { - blobImageUrl = null; - } - } - return { ...q, iLiked: q.isLiked, imageUrl: blobImageUrl }; + const rawUrls = q.imageUrls ?? []; + const blobUrls = await Promise.all( + rawUrls.map(async (url) => { + try { + const imgRes = await authFetch(url); + const blob = await imgRes.blob(); + return URL.createObjectURL(blob); + } catch { + return null; + } + }) + ); + return { ...q, iLiked: q.isLiked, imageUrls: blobUrls.filter(Boolean) }; }) ); @@ -288,21 +293,24 @@ function QnAListPage() { const buildQuestionFromCreatedEvent = useCallback(async (eventData) => { if (!eventData?.questionId) return null; - let blobImageUrl = null; - if (eventData.imageUrl) { - try { - const imgRes = await authFetch(eventData.imageUrl); - const blob = await imgRes.blob(); - blobImageUrl = URL.createObjectURL(blob); - } catch { - blobImageUrl = null; - } - } + // SSE 이벤트의 imageUrls 배열을 blob URL로 변환 + const rawUrls = eventData.imageUrls ?? []; + const blobUrls = await Promise.all( + rawUrls.map(async (url) => { + try { + const imgRes = await authFetch(url); + const blob = await imgRes.blob(); + return URL.createObjectURL(blob); + } catch { + return null; + } + }) + ); return { questionId: eventData.questionId, content: eventData.content, - imageUrl: blobImageUrl, + imageUrls: blobUrls.filter(Boolean), isResolved: false, isPopular: false, isLiked: false, @@ -506,15 +514,16 @@ function QnAListPage() { const handleCommentSubmit = async (e, questionId) => { e.stopPropagation(); const text = (commentInputs[questionId] || '').trim(); - if (!text && !commentImages[questionId]) return; + const images = commentImages[questionId] ?? []; + if (!text && images.length === 0) return; try { - let imageUrl = null; - if (commentImages[questionId]) { - imageUrl = await uploadImage(commentImages[questionId]); + let imageUrls = []; + if (images.length > 0) { + imageUrls = await uploadImages(images); } const res = await authFetch(`/api/questions/${questionId}/comments`, { method: 'POST', - body: JSON.stringify({ content: text, parentCommentId: null, imageUrl }), + body: JSON.stringify({ content: text, parentCommentId: null, imageUrls }), }); if (!res.ok) throw new Error(); const json = await res.json(); @@ -541,8 +550,8 @@ function QnAListPage() { setUnresolvedQuestions(update); setResolvedQuestions(update); setCommentInputs(prev => ({ ...prev, [questionId]: '' })); - setCommentImages(prev => ({ ...prev, [questionId]: null })); - setCommentImagePreviews(prev => ({ ...prev, [questionId]: null })); + setCommentImages(prev => ({ ...prev, [questionId]: [] })); + setCommentImagePreviews(prev => ({ ...prev, [questionId]: [] })); setCommentOpenId(null); } } catch (err) { @@ -552,10 +561,20 @@ function QnAListPage() { // ── 댓글 이미지 선택 / 붙여넣기 ───────────────── const handleCommentImageSelect = (e, questionId) => { - const file = e.target.files[0]; - if (!file) return; - setCommentImages(prev => ({ ...prev, [questionId]: file })); - setCommentImagePreviews(prev => ({ ...prev, [questionId]: URL.createObjectURL(file) })); + const files = Array.from(e.target.files); + if (!files.length) return; + const prev = commentImages[questionId] ?? []; + const merged = [...prev, ...files].slice(0, 5); + setCommentImages(p => ({ ...p, [questionId]: merged })); + setCommentImagePreviews(p => ({ ...p, [questionId]: merged.map(f => URL.createObjectURL(f)) })); + e.target.value = ''; + }; + + const handleCommentRemoveImage = (questionId, idx) => { + const prev = commentImages[questionId] ?? []; + const next = prev.filter((_, i) => i !== idx); + setCommentImages(p => ({ ...p, [questionId]: next })); + setCommentImagePreviews(p => ({ ...p, [questionId]: next.map(f => URL.createObjectURL(f)) })); }; const handleCommentPaste = (e, questionId) => { @@ -565,43 +584,53 @@ function QnAListPage() { if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { - setCommentImages(prev => ({ ...prev, [questionId]: file })); - setCommentImagePreviews(prev => ({ ...prev, [questionId]: URL.createObjectURL(file) })); + const prev = commentImages[questionId] ?? []; + const merged = [...prev, file].slice(0, 5); + setCommentImages(p => ({ ...p, [questionId]: merged })); + setCommentImagePreviews(p => ({ ...p, [questionId]: merged.map(f => URL.createObjectURL(f)) })); } break; } } }; - // ── 질문 이미지 선택 ───────────────────────────── + // ── 질문 이미지 선택 (여러 장) ─────────────────── const handleImageSelect = (e) => { - const file = e.target.files[0]; - if (!file) return; - setSelectedImage(file); - setImagePreview(URL.createObjectURL(file)); + const files = Array.from(e.target.files); + if (!files.length) return; + const merged = [...selectedImages, ...files].slice(0, 5); + setSelectedImages(merged); + setImagePreviews(merged.map(f => URL.createObjectURL(f))); + e.target.value = ''; + }; + + const handleRemoveImage = (idx) => { + const next = selectedImages.filter((_, i) => i !== idx); + setSelectedImages(next); + setImagePreviews(next.map(f => URL.createObjectURL(f))); }; // ── 새 질문 등록 ───────────────────────────────── const handleNewQuestion = async () => { const text = newQuestion.trim(); - if (!text && !selectedImage) return; + if (!text && selectedImages.length === 0) return; setIsSubmitting(true); setSubmitError(null); try { - let imageUrl = null; - if (selectedImage) { - imageUrl = await uploadImage(selectedImage); + let imageUrls = []; + if (selectedImages.length > 0) { + imageUrls = await uploadImages(selectedImages); } const res = await authFetch(`/api/sessions/${sessionId}/questions`, { method: 'POST', - body: JSON.stringify({ content: text, imageUrl }), + body: JSON.stringify({ content: text, imageUrls }), }); if (!res.ok) throw new Error(); const json = await res.json(); if (json.isSuccess) { setNewQuestion(''); - setSelectedImage(null); - setImagePreview(null); + setSelectedImages([]); + setImagePreviews([]); fetchQuestions(understandingIndex); } } catch (err) { @@ -768,11 +797,14 @@ function QnAListPage() {
- {/* 질문 첨부 이미지 */} - {question.imageUrl && ( - 첨부 이미지 e.stopPropagation()} /> + {/* 질문 첨부 이미지 (여러 장) */} + {question.imageUrls?.length > 0 && ( +
e.stopPropagation()}> + {question.imageUrls.map((url, idx) => ( + {`첨부 + ))} +
)} {/* 댓글 미리보기 */} @@ -815,17 +847,20 @@ function QnAListPage() { {/* 댓글 입력창 */} {commentOpenId === question.questionId && (
e.stopPropagation()}> - {commentImagePreviews[question.questionId] && ( -
- 미리보기 - + {(commentImagePreviews[question.questionId] ?? []).length > 0 && ( +
+ {(commentImagePreviews[question.questionId] ?? []).map((preview, idx) => ( +
+ {`미리보기 + +
+ ))}
)}
@@ -837,6 +872,7 @@ function QnAListPage() { commentFileRefs.current[question.questionId] = document.createElement('input'); commentFileRefs.current[question.questionId].type = 'file'; commentFileRefs.current[question.questionId].accept = 'image/*'; + commentFileRefs.current[question.questionId].multiple = true; commentFileRefs.current[question.questionId].onchange = (ev) => handleCommentImageSelect(ev, question.questionId); } commentFileRefs.current[question.questionId].click(); @@ -868,13 +904,17 @@ function QnAListPage() { {!isPast && (
{submitError &&

{submitError}

} - {imagePreview && ( -
- 미리보기 - + {imagePreviews.length > 0 && ( +
+ {imagePreviews.map((preview, idx) => ( +
+ {`미리보기 + +
+ ))}
)}
@@ -888,6 +928,7 @@ function QnAListPage() { dayPart === 'AM' ? '10:00 ~ 13:00' : '14:00 // 운영진 여부 판단 (displayName이 '운영진'으로 시작하면 true) export const isStaffDisplay = (displayName) => displayName?.startsWith('운영진') ?? false; -// 이미지 업로드 후 서버 URL 반환 +// 이미지 단일 업로드 후 서버 URL 반환 export const uploadImage = async (file) => { const formData = new FormData(); formData.append('file', file); @@ -108,4 +108,20 @@ export const uploadImage = async (file) => { }); const json = await res.json(); return json.imageUrl; +}; + +// 이미지 여러 장 업로드 후 서버 URL 목록 반환 +// files: File[] (최대 5장) +export const uploadImages = async (files) => { + if (!files || files.length === 0) return []; + const formData = new FormData(); + files.forEach(file => formData.append('files', file)); + const token = localStorage.getItem('token'); + const res = await fetch('/api/images/multi', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData, + }); + const json = await res.json(); + return json.imageUrls ?? []; }; \ No newline at end of file From 349759c7248c991b2af1fe3c2e3737c8893154c2 Mon Sep 17 00:00:00 2001 From: lilyyang0077 Date: Fri, 12 Jun 2026 20:34:38 +0900 Subject: [PATCH 18/18] fix: update favicon and metadata --- frontend/public/index.html | 8 ++++---- frontend/public/manifest.json | 12 ++++++------ frontend/public/piroin_logo.svg | 8 ++++++++ 3 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 frontend/public/piroin_logo.svg diff --git a/frontend/public/index.html b/frontend/public/index.html index cf3e89a..f547ceb 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -2,14 +2,14 @@ - + - +