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
+
+
+
+
+
+
+
+