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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
123 changes: 123 additions & 0 deletions src/main/java/com/linclean/global/log/DiscordWebhookAppender.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>logback-spring.xml 에서 {@code <webhookUrl>${DISCORD_WEBHOOK_URL:-}</webhookUrl>} 로 주입한다.
* URL이 비어 있으면 아무 동작도 하지 않으므로(로컬/미설정 환경) 안전하다.
* Spring 컨텍스트보다 먼저 동작하므로 빈 주입 대신 표준 {@link HttpClient}를 사용한다.
*
* <p>전송은 {@link HttpClient#sendAsync} 로 비동기 처리하며, 디스코드 분당 30회 한도를
* 넘기지 않도록 분당 {@code maxPerMinute}건으로 제한(초과분은 드롭)한다.
*/
public class DiscordWebhookAppender extends AppenderBase<ILoggingEvent> {

/** 디스코드 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();
}
}
118 changes: 118 additions & 0 deletions src/main/java/com/linclean/global/log/LogViewController.java
Original file line number Diff line number Diff line change
@@ -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;

/**
* 서버 로그 파일 조회용 내부 엔드포인트.
*
* <p>{@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<ApiResponse<List<LogFileInfo>>> listLogFiles() throws IOException {
Path dir = Paths.get(logDir);
if (!Files.isDirectory(dir)) {
return ResponseEntity.ok(ApiResponse.of(List.of()));
}
try (Stream<Path> files = Files.list(dir)) {
List<LogFileInfo> 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<String> 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<String> all = Files.readAllLines(file, StandardCharsets.UTF_8);
Stream<String> stream = all.stream();
if (level != null && !level.isBlank()) {
String needle = "\"level\":\"" + level.toUpperCase() + "\"";
stream = stream.filter(line -> line.contains(needle));
}
List<String> 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) {
}
}
Loading
Loading