diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java
new file mode 100644
index 00000000..f021a716
--- /dev/null
+++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java
@@ -0,0 +1,52 @@
+package com.aisip.OnO.backend.admin.controller;
+
+import com.aisip.OnO.backend.feedback.dto.FeedbackResponseDto;
+import com.aisip.OnO.backend.feedback.service.FeedbackService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+@Controller
+@RequestMapping("/admin/feedbacks")
+@RequiredArgsConstructor
+public class AdminFeedbackController {
+
+ private final FeedbackService feedbackService;
+
+ @GetMapping
+ public String feedbackList(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size,
+ Model model
+ ) {
+ Page pageResult = feedbackService.findAll(page, size);
+
+ model.addAttribute("feedbacks", pageResult.getContent());
+ model.addAttribute("totalCount", feedbackService.count());
+ model.addAttribute("averageNps", feedbackService.averageNps());
+ model.addAttribute("currentPage", page);
+ model.addAttribute("totalPages", pageResult.getTotalPages());
+ model.addAttribute("size", size);
+
+ int blockSize = 10;
+ int blockStart = (page / blockSize) * blockSize;
+ int blockEnd = Math.min(blockStart + blockSize - 1, pageResult.getTotalPages() - 1);
+ model.addAttribute("pageBlockStart", blockStart);
+ model.addAttribute("pageBlockEnd", Math.max(blockEnd, blockStart));
+ model.addAttribute("hasPreviousBlock", blockStart > 0);
+ model.addAttribute("hasNextBlock", blockEnd < pageResult.getTotalPages() - 1);
+
+ return "admin-feedback";
+ }
+
+ @GetMapping("/{id}")
+ public String feedbackDetail(@PathVariable Long id, Model model) {
+ model.addAttribute("feedback", feedbackService.findById(id));
+ return "admin-feedback-detail";
+ }
+}
diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java
new file mode 100644
index 00000000..5b7795f5
--- /dev/null
+++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java
@@ -0,0 +1,90 @@
+package com.aisip.OnO.backend.admin.controller;
+
+import com.aisip.OnO.backend.admin.dto.AdminStudyRoomDetailDto;
+import com.aisip.OnO.backend.admin.dto.AdminStudyRoomSummaryDto;
+import com.aisip.OnO.backend.studyroom.entity.StudyRoom;
+import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallenge;
+import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember;
+import com.aisip.OnO.backend.studyroom.repository.StudyRoomChallengeRepository;
+import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository;
+import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository;
+import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Controller
+@RequestMapping("/admin/study-rooms")
+@RequiredArgsConstructor
+public class AdminStudyRoomController {
+
+ private final StudyRoomRepository studyRoomRepository;
+ private final StudyRoomMemberRepository studyRoomMemberRepository;
+ private final StudyRoomChallengeRepository studyRoomChallengeRepository;
+ private final StudyRoomSharedProblemRepository studyRoomSharedProblemRepository;
+
+ @GetMapping
+ public String list(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size,
+ Model model
+ ) {
+ Page pageResult = studyRoomRepository.findAll(
+ PageRequest.of(page, size, Sort.by("createdAt").descending()));
+
+ List roomIds = pageResult.stream().map(StudyRoom::getId).toList();
+
+ Map memberCountMap = studyRoomMemberRepository.countMembersByRoomIds(roomIds)
+ .stream().collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1]));
+
+ Map sharedProblemCountMap = studyRoomSharedProblemRepository.countSharedProblemsByRoomIds(roomIds)
+ .stream().collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1]));
+
+ List rooms = pageResult.stream()
+ .map(r -> AdminStudyRoomSummaryDto.from(
+ r,
+ memberCountMap.getOrDefault(r.getId(), 0L),
+ sharedProblemCountMap.getOrDefault(r.getId(), 0L)))
+ .toList();
+
+ model.addAttribute("rooms", rooms);
+ model.addAttribute("totalCount", pageResult.getTotalElements());
+ model.addAttribute("currentPage", page);
+ model.addAttribute("totalPages", pageResult.getTotalPages());
+ model.addAttribute("size", size);
+
+ int blockSize = 10;
+ int blockStart = (page / blockSize) * blockSize;
+ int blockEnd = Math.min(blockStart + blockSize - 1, pageResult.getTotalPages() - 1);
+ model.addAttribute("pageBlockStart", blockStart);
+ model.addAttribute("pageBlockEnd", Math.max(blockEnd, blockStart));
+ model.addAttribute("hasPreviousBlock", blockStart > 0);
+ model.addAttribute("hasNextBlock", blockEnd < pageResult.getTotalPages() - 1);
+
+ return "admin-study-rooms";
+ }
+
+ @GetMapping("/{id}")
+ public String detail(@PathVariable Long id, Model model) {
+ StudyRoom room = studyRoomRepository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("스터디룸을 찾을 수 없습니다: " + id));
+
+ List members = studyRoomMemberRepository.findAllWithUserByRoomId(id);
+ List challenges = studyRoomChallengeRepository.findAllByRoomIdOrderByEndAtAsc(id);
+ long sharedProblemCount = studyRoomSharedProblemRepository.countByRoomId(id);
+
+ model.addAttribute("room", AdminStudyRoomDetailDto.from(room, members, challenges, sharedProblemCount));
+ return "admin-study-room-detail";
+ }
+}
diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java
new file mode 100644
index 00000000..98027eb9
--- /dev/null
+++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomDetailDto.java
@@ -0,0 +1,100 @@
+package com.aisip.OnO.backend.admin.dto;
+
+import com.aisip.OnO.backend.studyroom.entity.StudyRoom;
+import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallenge;
+import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallengeStatus;
+import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Getter
+@Builder
+public class AdminStudyRoomDetailDto {
+
+ private Long id;
+ private String name;
+ private Long hostUserId;
+ private LocalDateTime createdAt;
+ private long sharedProblemCount;
+ private List members;
+ private List challenges;
+
+ @Getter
+ @Builder
+ public static class MemberInfo {
+ private Long userId;
+ private String userName;
+ private String role; // 방장 / 멤버
+ private LocalDateTime joinedAt;
+ }
+
+ @Getter
+ @Builder
+ public static class ChallengeInfo {
+ private Long id;
+ private String title;
+ private String type; // 개인 / 그룹 / 연속
+ private String metric;
+ private String status; // 진행 중 / 완료 / 실패 / 만료
+ private Integer targetValue;
+ private LocalDateTime startAt;
+ private LocalDateTime endAt;
+ private LocalDateTime completedAt;
+ private boolean isActive;
+ }
+
+ public static AdminStudyRoomDetailDto from(StudyRoom room,
+ List members,
+ List challenges,
+ long sharedProblemCount) {
+ return AdminStudyRoomDetailDto.builder()
+ .id(room.getId())
+ .name(room.getName())
+ .hostUserId(room.getHostUserId())
+ .createdAt(room.getCreatedAt())
+ .sharedProblemCount(sharedProblemCount)
+ .members(members.stream()
+ .map(m -> MemberInfo.builder()
+ .userId(m.getUser().getId())
+ .userName(m.getUser().getName())
+ .role(m.getRole().name().equals("HOST") ? "방장" : "멤버")
+ .joinedAt(m.getCreatedAt())
+ .build())
+ .toList())
+ .challenges(challenges.stream()
+ .map(c -> ChallengeInfo.builder()
+ .id(c.getId())
+ .title(c.getTitle())
+ .type(translateType(c))
+ .metric(c.getMetric().name())
+ .status(translateStatus(c.getStatus()))
+ .targetValue(c.getTargetValue())
+ .startAt(c.getStartAt())
+ .endAt(c.getEndAt())
+ .completedAt(c.getCompletedAt())
+ .isActive(c.getStatus() == StudyRoomChallengeStatus.IN_PROGRESS)
+ .build())
+ .toList())
+ .build();
+ }
+
+ private static String translateType(StudyRoomChallenge c) {
+ return switch (c.getType()) {
+ case INDIVIDUAL -> "개인";
+ case GROUP -> "그룹";
+ case STREAK -> "연속";
+ };
+ }
+
+ private static String translateStatus(StudyRoomChallengeStatus s) {
+ return switch (s) {
+ case IN_PROGRESS -> "진행 중";
+ case COMPLETED -> "완료";
+ case FAILED -> "실패";
+ case EXPIRED -> "만료";
+ };
+ }
+}
diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java
new file mode 100644
index 00000000..61fdc99e
--- /dev/null
+++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminStudyRoomSummaryDto.java
@@ -0,0 +1,28 @@
+package com.aisip.OnO.backend.admin.dto;
+
+import com.aisip.OnO.backend.studyroom.entity.StudyRoom;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Builder
+public class AdminStudyRoomSummaryDto {
+
+ private Long id;
+ private String name;
+ private long memberCount;
+ private long sharedProblemCount;
+ private LocalDateTime createdAt;
+
+ public static AdminStudyRoomSummaryDto from(StudyRoom room, long memberCount, long sharedProblemCount) {
+ return AdminStudyRoomSummaryDto.builder()
+ .id(room.getId())
+ .name(room.getName())
+ .memberCount(memberCount)
+ .sharedProblemCount(sharedProblemCount)
+ .createdAt(room.getCreatedAt())
+ .build();
+ }
+}
diff --git a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java
index d162df1f..7eb840a2 100644
--- a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java
+++ b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java
@@ -95,7 +95,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/grafana",
"/grafana/**",
"/prometheus",
- "/prometheus/**"
+ "/prometheus/**",
+ "/feedback",
+ "/feedback/**"
).permitAll()
.requestMatchers("/api/auth/logout").hasAnyRole("GUEST", "MEMBER", "ADMIN")
.requestMatchers("/api/auth/**").permitAll()
diff --git a/src/main/java/com/aisip/OnO/backend/feedback/controller/FeedbackController.java b/src/main/java/com/aisip/OnO/backend/feedback/controller/FeedbackController.java
new file mode 100644
index 00000000..ae678c6d
--- /dev/null
+++ b/src/main/java/com/aisip/OnO/backend/feedback/controller/FeedbackController.java
@@ -0,0 +1,44 @@
+package com.aisip.OnO.backend.feedback.controller;
+
+import com.aisip.OnO.backend.feedback.dto.FeedbackRequestDto;
+import com.aisip.OnO.backend.feedback.service.FeedbackService;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping("/feedback")
+@RequiredArgsConstructor
+public class FeedbackController {
+
+ private final FeedbackService feedbackService;
+
+ @GetMapping
+ public String feedbackForm() {
+ return "feedback";
+ }
+
+ @PostMapping
+ public String submit(@ModelAttribute FeedbackRequestDto dto, HttpServletRequest request) {
+ String ip = resolveClientIp(request);
+ feedbackService.save(dto, ip);
+ return "redirect:/feedback/complete";
+ }
+
+ @GetMapping("/complete")
+ public String complete() {
+ return "feedback-complete";
+ }
+
+ private String resolveClientIp(HttpServletRequest request) {
+ String xff = request.getHeader("X-Forwarded-For");
+ if (xff != null && !xff.isBlank()) {
+ return xff.split(",")[0].trim();
+ }
+ return request.getRemoteAddr();
+ }
+}
diff --git a/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackRequestDto.java b/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackRequestDto.java
new file mode 100644
index 00000000..391c953f
--- /dev/null
+++ b/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackRequestDto.java
@@ -0,0 +1,64 @@
+package com.aisip.OnO.backend.feedback.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+@Getter
+@Setter
+public class FeedbackRequestDto {
+
+ // Q1: 사용 목적 (다중 선택 + 기타 텍스트)
+ private List usagePurpose;
+ private String usagePurposeOther;
+
+ // Q2: 사용 빈도
+ private String usageFrequency;
+
+ // Q3: NPS
+ private Integer npsScore;
+
+ // Q4: 등록·조회 불편 (다중 선택 + 기타 텍스트)
+ private List registrationPainPoints;
+ private String registrationPainPointsOther;
+
+ // Q5: 분류 방식
+ private String classificationMethod;
+
+ // Q6: 템플릿 적합도
+ private Integer templateSatisfaction;
+
+ // Q_복습세트 사용 여부 (true=사용 중, false=사용 안 함)
+ private Boolean practiceNoteUsed;
+
+ // 복습 세트 사용 중일 때 → Q7: 알림 효과
+ private String notificationEffectiveness;
+
+ // 복습 세트 미사용 이유 (복수 선택 + 기타 텍스트)
+ private List reviewSetNonUsageReason;
+ private String reviewSetNonUsageReasonOther;
+
+ // Q10: 스터디룸 사용 여부 ("사용 중" / "사용하지 않음")
+ private String studyRoomUsage;
+
+ // 스터디룸 미사용 이유 (복수 선택 + 기타 텍스트)
+ private List studyRoomNonUsageReason;
+ private String studyRoomNonUsageReasonOther;
+
+ // 스터디룸 사용 중일 때
+ // Q11: 챌린지 동기 부여
+ private Integer challengeMotivation;
+
+ // Q12: 오답 공유 유용도
+ private Integer problemSharingUsefulness;
+
+ // Q13: 자주 쓰는 기능
+ private String mostUsedFeature;
+
+ // Q14: 불편한 점
+ private String painPoints;
+
+ // Q15: 원하는 기능
+ private String desiredFeatures;
+}
diff --git a/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackResponseDto.java b/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackResponseDto.java
new file mode 100644
index 00000000..cf61f75d
--- /dev/null
+++ b/src/main/java/com/aisip/OnO/backend/feedback/dto/FeedbackResponseDto.java
@@ -0,0 +1,56 @@
+package com.aisip.OnO.backend.feedback.dto;
+
+import com.aisip.OnO.backend.feedback.entity.UserFeedback;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Builder
+public class FeedbackResponseDto {
+
+ private Long id;
+ private String usagePurpose;
+ private String usageFrequency;
+ private Integer npsScore;
+ private String registrationPainPoints;
+ private String classificationMethod;
+ private Integer templateSatisfaction;
+ private Boolean practiceNoteUsed;
+ private String notificationEffectiveness;
+ private String reviewSetNonUsageReason;
+ private String studyRoomUsage;
+ private String studyRoomNonUsageReason;
+ private Integer challengeMotivation;
+ private Integer problemSharingUsefulness;
+ private String mostUsedFeature;
+ private String painPoints;
+ private String desiredFeatures;
+ private String ipAddress;
+ private LocalDateTime submittedAt;
+
+ public static FeedbackResponseDto from(UserFeedback f) {
+ return FeedbackResponseDto.builder()
+ .id(f.getId())
+ .usagePurpose(f.getUsagePurpose())
+ .usageFrequency(f.getUsageFrequency())
+ .npsScore(f.getNpsScore())
+ .registrationPainPoints(f.getRegistrationPainPoints())
+ .classificationMethod(f.getClassificationMethod())
+ .templateSatisfaction(f.getTemplateSatisfaction())
+ .practiceNoteUsed(f.getPracticeNoteUsed())
+ .notificationEffectiveness(f.getNotificationEffectiveness())
+ .reviewSetNonUsageReason(f.getReviewSetNonUsageReason())
+ .studyRoomUsage(f.getStudyRoomUsage())
+ .studyRoomNonUsageReason(f.getStudyRoomNonUsageReason())
+ .challengeMotivation(f.getChallengeMotivation())
+ .problemSharingUsefulness(f.getProblemSharingUsefulness())
+ .mostUsedFeature(f.getMostUsedFeature())
+ .painPoints(f.getPainPoints())
+ .desiredFeatures(f.getDesiredFeatures())
+ .ipAddress(f.getIpAddress())
+ .submittedAt(f.getSubmittedAt())
+ .build();
+ }
+}
diff --git a/src/main/java/com/aisip/OnO/backend/feedback/entity/UserFeedback.java b/src/main/java/com/aisip/OnO/backend/feedback/entity/UserFeedback.java
new file mode 100644
index 00000000..f416abff
--- /dev/null
+++ b/src/main/java/com/aisip/OnO/backend/feedback/entity/UserFeedback.java
@@ -0,0 +1,79 @@
+package com.aisip.OnO.backend.feedback.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "user_feedback")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class UserFeedback {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "usage_purpose", length = 500)
+ private String usagePurpose;
+
+ @Column(name = "usage_frequency", length = 50)
+ private String usageFrequency;
+
+ @Column(name = "nps_score")
+ private Integer npsScore;
+
+ @Column(name = "registration_pain_points", length = 500)
+ private String registrationPainPoints;
+
+ @Column(name = "classification_method", length = 50)
+ private String classificationMethod;
+
+ @Column(name = "template_satisfaction")
+ private Integer templateSatisfaction;
+
+ @Column(name = "notification_effectiveness", length = 50)
+ private String notificationEffectiveness;
+
+ @Column(name = "review_interval_satisfaction")
+ private Integer reviewIntervalSatisfaction;
+
+ @Column(name = "practice_note_used")
+ private Boolean practiceNoteUsed;
+
+ @Column(name = "practice_note_usefulness")
+ private Integer practiceNoteUsefulness;
+
+ @Column(name = "review_set_non_usage_reason", length = 300)
+ private String reviewSetNonUsageReason;
+
+ @Column(name = "study_room_usage", length = 50)
+ private String studyRoomUsage;
+
+ @Column(name = "study_room_non_usage_reason", length = 300)
+ private String studyRoomNonUsageReason;
+
+ @Column(name = "challenge_motivation")
+ private Integer challengeMotivation;
+
+ @Column(name = "problem_sharing_usefulness")
+ private Integer problemSharingUsefulness;
+
+ @Column(name = "most_used_feature", length = 100)
+ private String mostUsedFeature;
+
+ @Column(name = "pain_points", columnDefinition = "TEXT")
+ private String painPoints;
+
+ @Column(name = "desired_features", columnDefinition = "TEXT")
+ private String desiredFeatures;
+
+ @Column(name = "ip_address", length = 50)
+ private String ipAddress;
+
+ @Column(name = "submitted_at")
+ private LocalDateTime submittedAt;
+}
diff --git a/src/main/java/com/aisip/OnO/backend/feedback/repository/UserFeedbackRepository.java b/src/main/java/com/aisip/OnO/backend/feedback/repository/UserFeedbackRepository.java
new file mode 100644
index 00000000..69df1132
--- /dev/null
+++ b/src/main/java/com/aisip/OnO/backend/feedback/repository/UserFeedbackRepository.java
@@ -0,0 +1,17 @@
+package com.aisip.OnO.backend.feedback.repository;
+
+import com.aisip.OnO.backend.feedback.entity.UserFeedback;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.OptionalDouble;
+
+public interface UserFeedbackRepository extends JpaRepository {
+
+ Page findAllByOrderBySubmittedAtDesc(Pageable pageable);
+
+ @Query("SELECT AVG(f.npsScore) FROM UserFeedback f WHERE f.npsScore IS NOT NULL")
+ Double findAverageNpsScore();
+}
diff --git a/src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java b/src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java
new file mode 100644
index 00000000..63924edb
--- /dev/null
+++ b/src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java
@@ -0,0 +1,119 @@
+package com.aisip.OnO.backend.feedback.service;
+
+import com.aisip.OnO.backend.feedback.dto.FeedbackRequestDto;
+import com.aisip.OnO.backend.feedback.dto.FeedbackResponseDto;
+import com.aisip.OnO.backend.feedback.entity.UserFeedback;
+import com.aisip.OnO.backend.feedback.repository.UserFeedbackRepository;
+import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class FeedbackService {
+
+ private final UserFeedbackRepository feedbackRepository;
+ private final DiscordWebhookNotificationService discordWebhookNotificationService;
+
+ @Transactional
+ public void save(FeedbackRequestDto dto, String ipAddress) {
+ boolean reviewSetUsed = Boolean.TRUE.equals(dto.getPracticeNoteUsed());
+ boolean studyRoomUsed = "사용 중".equals(dto.getStudyRoomUsage());
+
+ UserFeedback feedback = UserFeedback.builder()
+ .usagePurpose(multiSelectWithOther(dto.getUsagePurpose(), dto.getUsagePurposeOther()))
+ .usageFrequency(dto.getUsageFrequency())
+ .npsScore(dto.getNpsScore())
+ .registrationPainPoints(multiSelectWithOther(dto.getRegistrationPainPoints(), dto.getRegistrationPainPointsOther()))
+ .classificationMethod(dto.getClassificationMethod())
+ .templateSatisfaction(dto.getTemplateSatisfaction())
+ .practiceNoteUsed(dto.getPracticeNoteUsed())
+ .notificationEffectiveness(reviewSetUsed ? dto.getNotificationEffectiveness() : null)
+ .reviewSetNonUsageReason(!reviewSetUsed ? multiSelectWithOther(dto.getReviewSetNonUsageReason(), dto.getReviewSetNonUsageReasonOther()) : null)
+ .studyRoomUsage(dto.getStudyRoomUsage())
+ .studyRoomNonUsageReason(!studyRoomUsed ? multiSelectWithOther(dto.getStudyRoomNonUsageReason(), dto.getStudyRoomNonUsageReasonOther()) : null)
+ .challengeMotivation(studyRoomUsed ? dto.getChallengeMotivation() : null)
+ .problemSharingUsefulness(studyRoomUsed ? dto.getProblemSharingUsefulness() : null)
+ .mostUsedFeature(dto.getMostUsedFeature())
+ .painPoints(nullIfBlank(dto.getPainPoints()))
+ .desiredFeatures(nullIfBlank(dto.getDesiredFeatures()))
+ .ipAddress(ipAddress)
+ .submittedAt(LocalDateTime.now())
+ .build();
+
+ feedbackRepository.save(feedback);
+ notifyDiscord(feedback);
+ }
+
+ public Page findAll(int page, int size) {
+ return feedbackRepository
+ .findAllByOrderBySubmittedAtDesc(PageRequest.of(page, size))
+ .map(FeedbackResponseDto::from);
+ }
+
+ public FeedbackResponseDto findById(Long id) {
+ return feedbackRepository.findById(id)
+ .map(FeedbackResponseDto::from)
+ .orElseThrow(() -> new IllegalArgumentException("피드백을 찾을 수 없습니다: " + id));
+ }
+
+ public long count() {
+ return feedbackRepository.count();
+ }
+
+ public Double averageNps() {
+ return feedbackRepository.findAverageNpsScore();
+ }
+
+ private void notifyDiscord(UserFeedback f) {
+ try {
+ StringBuilder sb = new StringBuilder();
+ appendField(sb, "NPS", f.getNpsScore() != null ? f.getNpsScore() + " / 10" : null);
+ appendField(sb, "사용 목적", f.getUsagePurpose());
+ appendField(sb, "사용 빈도", f.getUsageFrequency());
+ appendField(sb, "복습 세트", f.getPracticeNoteUsed() == null ? null
+ : (f.getPracticeNoteUsed() ? "사용 중" : "미사용"));
+ appendField(sb, "스터디룸", f.getStudyRoomUsage());
+ appendField(sb, "불편한 점", f.getPainPoints());
+ appendField(sb, "원하는 기능", f.getDesiredFeatures());
+ discordWebhookNotificationService.sendMessage("📋 새 유저 피드백 도착", sb.toString().trim());
+ } catch (Exception e) {
+ // 알림 실패가 저장 트랜잭션에 영향을 주지 않도록 로그만 남김
+ log.warn("Discord 피드백 알림 전송 실패: {}", e.getMessage());
+ }
+ }
+
+ private void appendField(StringBuilder sb, String label, String value) {
+ if (value != null && !value.isBlank()) {
+ sb.append("**").append(label).append("**: ").append(value).append("\n");
+ }
+ }
+
+ private String multiSelectWithOther(List items, String otherText) {
+ boolean hasOther = otherText != null && !otherText.isBlank();
+ if (CollectionUtils.isEmpty(items)) {
+ return hasOther ? "기타: " + otherText.trim() : null;
+ }
+ List result = new ArrayList<>(items);
+ if (hasOther) {
+ result.remove("기타");
+ result.add("기타: " + otherText.trim());
+ }
+ return String.join(",", result);
+ }
+
+ private String nullIfBlank(String s) {
+ return (s == null || s.isBlank()) ? null : s.trim();
+ }
+}
diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java
index 06587ad4..eaf1726a 100644
--- a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java
+++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemRepository.java
@@ -7,11 +7,17 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface StudyRoomSharedProblemRepository extends JpaRepository {
+ long countByRoomId(Long roomId);
+
+ @Query("select sp.room.id, count(sp) from StudyRoomSharedProblem sp where sp.room.id in :roomIds group by sp.room.id")
+ List
+
+
방장 ID
+
+
+
+
생성일
+
+
+
+
전체 멤버
+
+
+
+
공유 문제 수
+
+
+
+
전체 챌린지
+
+
+
+
+
+
+
+
+
+
멤버 목록
+
+
+
+
+
+ | 유저 ID |
+ 이름 |
+ 역할 |
+ 참여일 |
+
+
+
+
+ |
+ |
+
+ 방장
+ 멤버
+ |
+ |
+
+
+ | 멤버가 없습니다. |
+
+
+
+
+
+
+
+
+
+
챌린지 목록
+
+
+
+
+
+
+ | ID |
+ 제목 |
+ 유형 |
+ 지표 |
+ 목표 |
+ 상태 |
+ 기간 |
+
+
+
+
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+
+
+ ~
+
+ |
+
+
+ | 챌린지가 없습니다. |
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/admin-study-rooms.html b/src/main/resources/templates/admin-study-rooms.html
new file mode 100644
index 00000000..e614db6a
--- /dev/null
+++ b/src/main/resources/templates/admin-study-rooms.html
@@ -0,0 +1,88 @@
+
+
+
+
+
+ 스터디룸 관리 — OnO Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 룸 이름 |
+ 멤버 수 |
+ 공유 문제 수 |
+ 생성일 |
+ 상세 |
+
+
+
+
+ |
+ |
+
+
+
+ 명
+
+ |
+ |
+ |
+
+ 상세 보기 →
+ |
+
+
+ | 스터디룸이 없습니다. |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html
index d9381100..5e2b098d 100644
--- a/src/main/resources/templates/admin.html
+++ b/src/main/resources/templates/admin.html
@@ -95,6 +95,24 @@ 복습노트 관리
+
+
+
+
+
스터디룸 관리
+
스터디룸 목록 조회
+
+
+
@@ -112,25 +130,24 @@
통계 분석
시스템 통계 및 인사이트 보기
-
-
-
+
+
+
감사합니다!
+
+ 소중한 피드백을 보내주셨습니다.
+ 더 나은 OnO를 만드는 데 큰 도움이 됩니다.
+
+
+
diff --git a/src/main/resources/templates/feedback-complete.html b/src/main/resources/templates/feedback-complete.html
new file mode 100644
index 00000000..c0403d46
--- /dev/null
+++ b/src/main/resources/templates/feedback-complete.html
@@ -0,0 +1,40 @@
+
+
+