diff --git a/build.gradle b/build.gradle index 7a4b184..eee1ee8 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,9 @@ dependencies { // Swagger (SpringDoc OpenAPI) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' + // Logging (JSON 파일 로그) + implementation 'net.logstash.logback:logstash-logback-encoder:8.0' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/com/linclean/global/log/DiscordWebhookAppender.java b/src/main/java/com/linclean/global/log/DiscordWebhookAppender.java new file mode 100644 index 0000000..dcfd82b --- /dev/null +++ b/src/main/java/com/linclean/global/log/DiscordWebhookAppender.java @@ -0,0 +1,123 @@ +package com.linclean.global.log; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.AppenderBase; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * ERROR 로그를 디스코드 웹훅으로 실시간 전송하는 logback appender. + * + *

logback-spring.xml 에서 {@code ${DISCORD_WEBHOOK_URL:-}} 로 주입한다. + * URL이 비어 있으면 아무 동작도 하지 않으므로(로컬/미설정 환경) 안전하다. + * Spring 컨텍스트보다 먼저 동작하므로 빈 주입 대신 표준 {@link HttpClient}를 사용한다. + * + *

전송은 {@link HttpClient#sendAsync} 로 비동기 처리하며, 디스코드 분당 30회 한도를 + * 넘기지 않도록 분당 {@code maxPerMinute}건으로 제한(초과분은 드롭)한다. + */ +public class DiscordWebhookAppender extends AppenderBase { + + /** 디스코드 content 최대 길이 2000자. 코드블록/여유분 고려해 보수적으로 자른다. */ + private static final int MAX_CONTENT = 1900; + private static final DateTimeFormatter TS_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private String webhookUrl; + private int maxPerMinute = 20; + + private HttpClient httpClient; + + // 분당 호출 제한 상태 + private long windowStartMs = 0; + private int sentInWindow = 0; + + public void setWebhookUrl(String webhookUrl) { + this.webhookUrl = webhookUrl; + } + + public void setMaxPerMinute(int maxPerMinute) { + this.maxPerMinute = maxPerMinute; + } + + @Override + public void start() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build(); + super.start(); + } + + @Override + protected void append(ILoggingEvent event) { + if (webhookUrl == null || webhookUrl.isBlank()) { + return; // 미설정 환경에서는 no-op + } + if (!allow()) { + return; // 분당 제한 초과 → 드롭 + } + try { + String payload = buildPayload(event); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(webhookUrl)) + .timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + // 비동기 전송 - 실패해도 애플리케이션 로깅 흐름에 영향 주지 않음 + httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .exceptionally(ex -> null); + } catch (Exception e) { + // appender 는 절대 예외를 던지지 않는다 + addError("디스코드 웹훅 전송 실패", e); + } + } + + private synchronized boolean allow() { + long now = System.currentTimeMillis(); + if (now - windowStartMs >= 60_000) { + windowStartMs = now; + sentInWindow = 0; + } + if (sentInWindow < maxPerMinute) { + sentInWindow++; + return true; + } + return false; + } + + private String buildPayload(ILoggingEvent event) { + StringBuilder sb = new StringBuilder(); + sb.append("🚨 **[ERROR]** ").append(TS_FORMAT.format(Instant.ofEpochMilli(event.getTimeStamp()))) + .append('\n') + .append("**logger**: ").append(event.getLoggerName()).append('\n') + .append("**message**: ").append(event.getFormattedMessage()); + + IThrowableProxy throwableProxy = event.getThrowableProxy(); + if (throwableProxy != null) { + sb.append("\n```\n") + .append(ThrowableProxyUtil.asString(throwableProxy)) + .append("\n```"); + } + + String content = sb.toString(); + if (content.length() > MAX_CONTENT) { + content = content.substring(0, MAX_CONTENT) + "\n...(생략)"; + } + + ObjectNode node = OBJECT_MAPPER.createObjectNode(); + node.put("content", content); + return node.toString(); + } +} diff --git a/src/main/java/com/linclean/global/log/LogViewController.java b/src/main/java/com/linclean/global/log/LogViewController.java new file mode 100644 index 0000000..3a31134 --- /dev/null +++ b/src/main/java/com/linclean/global/log/LogViewController.java @@ -0,0 +1,118 @@ +package com.linclean.global.log; + +import com.linclean.global.web.ApiResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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 org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * 서버 로그 파일 조회용 내부 엔드포인트. + * + *

{@code /internal/**} 경로이므로 {@code InternalApiKeyFilter}가 자동 적용된다. + * 호출 시 {@code X-Internal-Api-Key} 헤더에 {@code internal.api-key} 값이 필요하다. + * SSH 없이 IntelliJ HTTP Client / 브라우저에서 로그를 확인하기 위한 용도. + */ +@RestController +@RequestMapping("/internal/logs") +public class LogViewController { + + private static final Pattern DATE_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}"); + private static final String ACTIVE_FILE = "app.json"; + + @Value("${LOG_DIR:logs}") + private String logDir; + + /** 사용 가능한 로그 파일 목록(파일명/크기). */ + @GetMapping + public ResponseEntity>> listLogFiles() throws IOException { + Path dir = Paths.get(logDir); + if (!Files.isDirectory(dir)) { + return ResponseEntity.ok(ApiResponse.of(List.of())); + } + try (Stream files = Files.list(dir)) { + List result = files + .filter(LogViewController::isLogFile) + .sorted(Comparator.comparing((Path p) -> p.getFileName().toString()).reversed()) + .map(LogViewController::toInfo) + .toList(); + return ResponseEntity.ok(ApiResponse.of(result)); + } + } + + /** + * 특정 날짜의 로그 조회. 오늘 날짜는 롤링 전이므로 활성 파일(app.json)을 가리킨다. + * + * @param date yyyy-MM-dd + * @param level (선택) 해당 레벨 라인만 필터 (예: ERROR) + * @param lines (선택) 마지막 N줄만 반환 + */ + @GetMapping("/{date}") + public ResponseEntity getLogByDate( + @PathVariable String date, + @RequestParam(required = false) String level, + @RequestParam(required = false) Integer lines + ) throws IOException { + if (!DATE_PATTERN.matcher(date).matches()) { + return ResponseEntity.badRequest().body("date 형식은 yyyy-MM-dd 여야 합니다."); + } + + String fileName = date.equals(LocalDate.now().toString()) + ? ACTIVE_FILE + : "app." + date + ".json"; + + Path dir = Paths.get(logDir).toAbsolutePath().normalize(); + Path file = dir.resolve(fileName).normalize(); + if (!file.startsWith(dir) || !Files.isRegularFile(file)) { + return ResponseEntity.notFound().build(); + } + + List all = Files.readAllLines(file, StandardCharsets.UTF_8); + Stream stream = all.stream(); + if (level != null && !level.isBlank()) { + String needle = "\"level\":\"" + level.toUpperCase() + "\""; + stream = stream.filter(line -> line.contains(needle)); + } + List filtered = stream.toList(); + if (lines != null && lines > 0 && filtered.size() > lines) { + filtered = filtered.subList(filtered.size() - lines, filtered.size()); + } + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/x-ndjson; charset=UTF-8")) + .body(String.join("\n", filtered)); + } + + private static boolean isLogFile(Path p) { + String name = p.getFileName().toString(); + return name.startsWith("app") && name.endsWith(".json"); + } + + private static LogFileInfo toInfo(Path p) { + long size; + try { + size = Files.size(p); + } catch (IOException e) { + size = -1; + } + return new LogFileInfo(p.getFileName().toString(), size); + } + + public record LogFileInfo(String fileName, long sizeBytes) { + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..e1183fc --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + ${LOG_DIR}/app.json + + ${LOG_DIR}/app.%d{yyyy-MM-dd}.json + 7 + + + + + + + ${DISCORD_WEBHOOK_URL:-} + + + + + ERROR + + + 0 + 512 + + + + + + + +