Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
HELP.md
docs/
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public String getAllAnalysis(
long periodProcessingAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.PROCESSING, 0L);
long periodNotStartedAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.NOT_STARTED, 0L);
long periodNoImageAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.NO_IMAGE, 0L);
long periodRateLimitExceededAnalysisCount = periodAnalysisStatusCounts.getOrDefault(AnalysisStatus.RATE_LIMIT_EXCEEDED, 0L);
long periodFinishedAnalysisCount = periodCompletedAnalysisCount + periodFailedAnalysisCount;
double periodAnalysisFailureRate = periodFinishedAnalysisCount == 0
? 0.0
Expand All @@ -112,6 +113,7 @@ public String getAllAnalysis(
model.addAttribute("allProcessingAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.PROCESSING, 0L));
model.addAttribute("allNotStartedAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.NOT_STARTED, 0L));
model.addAttribute("allNoImageAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.NO_IMAGE, 0L));
model.addAttribute("allRateLimitExceededAnalysisCount", allAnalysisStatusCounts.getOrDefault(AnalysisStatus.RATE_LIMIT_EXCEEDED, 0L));
model.addAttribute("dailyActiveUsers", dailyActiveUsers);
model.addAttribute("dailyVisits", dailyVisits);
model.addAttribute("dailyNewUsers", dailyNewUsers);
Expand All @@ -130,6 +132,7 @@ public String getAllAnalysis(
model.addAttribute("periodProcessingAnalysisCount", periodProcessingAnalysisCount);
model.addAttribute("periodNotStartedAnalysisCount", periodNotStartedAnalysisCount);
model.addAttribute("periodNoImageAnalysisCount", periodNoImageAnalysisCount);
model.addAttribute("periodRateLimitExceededAnalysisCount", periodRateLimitExceededAnalysisCount);
model.addAttribute("periodAnalysisFailureRate", periodAnalysisFailureRate);
model.addAttribute("averageDailyVisitors", averageDailyVisitors);
model.addAttribute("startDate", selectedStartDate);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,23 @@
import com.aisip.OnO.backend.common.exception.ApplicationException;
import com.aisip.OnO.backend.problem.exception.ProblemErrorCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import java.time.Duration;

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RateLimitAspect {

private static final String KEY_PREFIX = "rate_limit:";

private final RedisTemplate<String, Object> redisTemplate;
private final RateLimitService rateLimitService;

@Before("@annotation(rateLimit)")
public void checkRateLimit(RateLimit rateLimit) {
Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
String key = KEY_PREFIX + rateLimit.key() + ":" + userId;

try {
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
redisTemplate.expire(key, Duration.ofDays(1));
}
if (count != null && count > rateLimit.limitPerDay()) {
log.warn("Rate limit exceeded - userId: {}, key: {}, count: {}/{}", userId, rateLimit.key(), count, rateLimit.limitPerDay());
throw new ApplicationException(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED);
}
} catch (ApplicationException e) {
throw e;
} catch (Exception e) {
// Redis 장애 시 서비스 중단 방지를 위해 통과
log.warn("Rate limit check failed for key: {}, failing open - reason: {}", key, e.getMessage());
if (!rateLimitService.tryConsume(rateLimit.key(), userId, rateLimit.limitPerDay())) {
throw new ApplicationException(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.aisip.OnO.backend.common.ratelimit;

import java.time.Duration;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class RateLimitService {
private static final String KEY_PREFIX = "rate_limit:";

private final RedisTemplate<String, Object> redisTemplate;

public boolean tryConsume(String keyName, Long userId, int limitPerDay) {
String key = KEY_PREFIX + keyName + ":" + userId;

try {
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
redisTemplate.expire(key, Duration.ofDays(1));
}
if (count != null && count > limitPerDay) {
log.warn("Rate limit exceeded - userId: {}, key: {}, count: {}/{}",
userId, keyName, count, limitPerDay);
return false;
}
return true;
} catch (Exception e) {
// Redis 장애 시 서비스 중단 방지를 위해 통과
log.warn("Rate limit check failed for key: {}, failing open - reason: {}", key, e.getMessage());
return true;
}
}
}
33 changes: 0 additions & 33 deletions src/main/java/com/aisip/OnO/backend/config/P6SpyConfig.java

This file was deleted.

140 changes: 10 additions & 130 deletions src/main/java/com/aisip/OnO/backend/config/P6SpyExplainAppender.java
Original file line number Diff line number Diff line change
@@ -1,152 +1,32 @@
package com.aisip.OnO.backend.config;

import com.p6spy.engine.spy.appender.MessageFormattingStrategy;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.engine.jdbc.internal.FormatStyle;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Locale;

@Slf4j
public class P6SpyExplainAppender implements MessageFormattingStrategy {

// DataSource를 static으로 저장
private static DataSource dataSource;

// P6SpyConfig에서 호출
public static void setDataSource(DataSource ds) {
dataSource = ds;
}

// 시스템 프로퍼티로 제어 (P6SpyConfig에서 설정)
private static boolean isExplainEnabled() {
return Boolean.parseBoolean(System.getProperty("p6spy.enable.explain", "false"));
}

@Override
public String formatMessage(int connectionId, String now, long elapsed,
String category, String prepared, String sql, String url) {

// Quartz 관련 쿼리 제외
if (sql == null || sql.trim().isEmpty() || sql.contains("QRTZ_")) {
if (sql == null || sql.trim().isEmpty() || containsQuartzTable(sql)) {
return "";
}

StringBuilder result = new StringBuilder();

// 기본 로그 출력
String formattedSql = formatSql(category, sql);
result.append(String.format("[P6Spy][SLOW_QUERY] | %s | took %dms | %s | connection %d\n%s\n",
now, elapsed, category, connectionId, formattedSql));

// EXPLAIN 실행 조건 체크
boolean explainEnabled = isExplainEnabled();
boolean isSelect = sql.trim().toLowerCase(Locale.ROOT).startsWith("select");

boolean shouldExplain = explainEnabled && isSelect;

if (shouldExplain) {
try {
String explainResult = executeExplain(sql, url);
result.append("\n");
result.append("╔════════════════════════════════════════════════════════════════════════════════╗\n");
result.append("║ EXPLAIN RESULT (took " + elapsed + "ms) ║\n");
result.append("╠════════════════════════════════════════════════════════════════════════════════╣\n");
result.append("║ Query: ").append(String.format("%-70s", summarizeSql(sql))).append(" ║\n");
result.append("╠════════════════════════════════════════════════════════════════════════════════╣\n");
result.append(explainResult);
result.append("╚════════════════════════════════════════════════════════════════════════════════╝\n");
} catch (Exception e) {
log.warn("Failed to execute EXPLAIN for query: {}", e.getMessage());
}
}

return result.toString();
String explainSql = toExplainSql(sql);
return String.format("[P6Spy][SLOW_QUERY] took=%dms category=%s connection=%d sql=%s",
elapsed, category, connectionId, explainSql);
}

private String formatSql(String category, String sql) {
if (sql == null || sql.trim().isEmpty()) {
return "";
}

if (category.equals("statement") && sql.trim().toLowerCase(Locale.ROOT).startsWith("select")) {
return FormatStyle.BASIC.getFormatter().format(sql);
}

return sql;
private boolean containsQuartzTable(String sql) {
return sql.toUpperCase(Locale.ROOT).contains("QRTZ_");
}

private String summarizeSql(String sql) {
private String toExplainSql(String sql) {
String normalizedSql = sql.replaceAll("\\s+", " ").trim();
return normalizedSql.substring(0, Math.min(70, normalizedSql.length()));
}

private String executeExplain(String sql, String jdbcUrl) throws Exception {
if (dataSource == null) {
return "DataSource not available";
if (normalizedSql.endsWith(";")) {
normalizedSql = normalizedSql.substring(0, normalizedSql.length() - 1).trim();
}

StringBuilder result = new StringBuilder();

try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("EXPLAIN " + sql)) {

// 컬럼별 너비 설정 (보기 좋게 조정)
String[] columnNames = new String[rs.getMetaData().getColumnCount()];
int[] columnWidths = new int[columnNames.length];

for (int i = 0; i < columnNames.length; i++) {
columnNames[i] = rs.getMetaData().getColumnName(i + 1);
// 컬럼별 최적 너비
columnWidths[i] = getOptimalWidth(columnNames[i]);
}

// 헤더 출력 (세로 형식)
result.append("\n");

// 데이터 읽기
java.util.List<String[]> rows = new java.util.ArrayList<>();
while (rs.next()) {
String[] row = new String[columnNames.length];
for (int i = 0; i < columnNames.length; i++) {
row[i] = rs.getString(i + 1);
if (row[i] == null) row[i] = "NULL";
}
rows.add(row);
}

// 가독성 좋은 세로 형식으로 출력
for (String[] row : rows) {
for (int i = 0; i < columnNames.length; i++) {
result.append(String.format(" %-20s: %s\n", columnNames[i], row[i]));
}
result.append("\n");
}
}

return result.toString();
}

private int getOptimalWidth(String columnName) {
// 컬럼별 최적 너비 설정
return switch (columnName.toLowerCase()) {
case "id" -> 5;
case "select_type" -> 12;
case "table" -> 15;
case "partitions" -> 12;
case "type" -> 10;
case "possible_keys" -> 25;
case "key" -> 25;
case "key_len" -> 10;
case "ref" -> 15;
case "rows" -> 10;
case "filtered" -> 10;
case "extra" -> 30;
default -> 15;
};
return "EXPLAIN " + normalizedSql + ";";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ public enum FolderErrorCase implements ErrorCase {

ROOT_FOLDER_NOT_EXIST(404, 5003, "루트 폴더가 존재하지 않습니다."),

ROOT_FOLDER_CANNOT_REMOVE(400, 5004, "루트 폴더는 삭제할 수 없습니다.");
ROOT_FOLDER_CANNOT_REMOVE(400, 5004, "루트 폴더는 삭제할 수 없습니다."),

ROOT_FOLDER_CANNOT_UPDATE(400, 5005, "루트 폴더는 수정할 수 없습니다.");

private final Integer httpStatusCode;
private final Integer errorCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ public Long createFolder(FolderRegisterDto folderRegisterDto, Long userId) {

public void updateFolder(FolderRegisterDto folderRegisterDto, Long userId) {
Folder folder = findFolderEntity(folderRegisterDto.folderId(), userId);

if (folder.getParentFolder() == null) {
throw new ApplicationException(FolderErrorCase.ROOT_FOLDER_CANNOT_UPDATE);
}

folder.updateFolderInfo(folderRegisterDto);

if (folderRegisterDto.parentFolderId() != null && folder.getParentFolder() != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.aisip.OnO.backend.learningcalendar.controller;

import com.aisip.OnO.backend.common.response.CommonResponse;
import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarResponseDto;
import com.aisip.OnO.backend.learningcalendar.service.LearningCalendarService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/learning-calendar")
public class LearningCalendarController {

private final LearningCalendarService learningCalendarService;

@GetMapping("")
public CommonResponse<LearningCalendarResponseDto> getLearningCalendar(
@RequestParam("year") int year,
@RequestParam("month") int month
) {
validateYearMonth(year, month);
Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return CommonResponse.success(learningCalendarService.getLearningCalendar(userId, year, month));
}

private void validateYearMonth(int year, int month) {
if (year < 1 || month < 1 || month > 12) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 요청입니다.");
}
}
}
Loading
Loading