AOP가 무엇인지 궁금하다면 아래 포스팅을 참고하자.
https://persi0815.tistory.com/117
Spring Boot에서 AOP를 사용하여 Log를 파일에 찍는 로직을 구현해보고, 트리거를 통해 해당 파일을 S3에 업로드 시켜보자!!
* 로그가 발생할 때마다 바로 s3에 올리면 부하가 생길 수 있다고 판단해, 로컬 파일을 만들어서 해당 파일에 저장 후, 특정 트리거가 발생시 로컬 파일을 s3에 업로드 하도록 했다.
Spring Boot가 지원하는 Logging 방식은 3가지가 있다. 로깅이란 무엇인지에 대한 구체적인 내용은 아래 포스팅과 Spring 공식 문서를 참고 바란다.
https://persi0815.tistory.com/88
https://docs.spring.io/spring-boot/reference/features/logging.html
1️⃣ 의존성 추가
: Spring Boot에서 AOP를 사용하려면 Maven이나 Gradle에서 의존성을 추가해야한다.
implementation 'org.springframework.boot:spring-boot-starter-aop'
gradle에서 위 의존성을 통해 자동으로 Spring AOP 관련 설정을 활성화할 수 있다.
이 과정에서 @EnableAspectJAutoProxy가 내부적으로 이미 활성화되어 AOP를 사용하기 위해 별도로 추가 설정을 할 필요가 없다!!
만일, 명시적으로 설정하고 싶다면, 다음과 같이 ' @EnableAspectJAutoProxy'를 추가할 수 있다
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@Configuration
@EnableAspectJAutoProxy
public class AopConfig {
}
참고
@Enable로 시작하는 애노테이션은 관련 기능을 적용하는데 필요한 다양한 스프링 설정을 대신 처리
언제 @EnableAspectJAutoProxy를 추가해야 할까?
1. 프록시 설정을 세부적으로 조정해야 할 때
@EnableAspectJAutoProxy(proxyTargetClass = true) // CGLIB 프록시 사용 강제
@EnableAspectJAutoProxy(proxyTargetClass = false) // JDK 동적 프록시 사용 강제
Spring AOP에서 프록시는 크게 JDK Dynamic proxy 또는 CGLIB로 작동하는데, Spring Boot 1.4부터 CGLIB 프록시가 기본적으로 활성화되어있다.
proxyTargetClass = true를 설정하면, 모든 곳에서 CGLIB 사용을 강제하여 인터페이스 여부와 상관없이 동적으로 자바 '클래스' 상속을 통해 프록시를 생성한다. 그래서 상속이 불가능한 final 메소드나 private 메소드는 프록시로 만들어지지 않는다.
proxyTargetClass = false를 설정하면, 모든 곳에서 JDK 동적 프록시 사용을 강제하는데, 인터페이스를 구현한 클래스만 프록시로 생성할 수 있다. JDK 동적 프록시는 인터페이스의 public 메서드만 가로챌 수 있다. protected/private 메서드는 인터페이스에 선언될 수 없으므로, JDK 동적 프록시의 가로채기 대상에서 제외되는 것이다.
추가) 스프링의 트랜잭션 적용 기준
스프링은 프록시 생성 방식에 따라 AOP 적용 여부가 달라지는 문제를 방지하기 위해 일관된 동작 규칙을 적용한다. 그래서 protected 메서드는 CGLIB 프록시를 사용하더라도 트랜잭션이 적용되지 않는다. 이는 프로그래머가 메서드 접근 제어자에 따라 트랜잭션 적용 여부가 달라지는 예측 불가능한 상황을 방지하기 위함이다.
2. Spring Boot가 아닌 순수 Spring Framework를 사용할 때
Spring Boot에서는 AOP 설정이 기본적으로 활성화되어 있어 @EnableAspectJAutoProxy를 추가하지 않아도 동작한다.
그러나 순수 Spring Framework 프로젝트에서는 AOP 설정을 활성화하기 위해 @EnableAspectJAutoProxy가 반드시 필요하다.
2️⃣ Aspect 클래스 정의
: 로깅 Aspect를 정의하여, 컨트롤러나 서비스 계층에서 메서드 실행 시점의 정보를 로깅한다.
package LuckyVicky.backend.global.aop;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class ErrorLoggingAspect {
private static final Logger logger = LoggerFactory.getLogger(ErrorLoggingAspect.class);
@Pointcut("execution(* LuckyVicky.backend..*.controller.*.*(..)) || "
+ "execution(* LuckyVicky.backend..*.service.*.*(..)) ||"
+ " execution(* LuckyVicky.backend..*.repository.*.*(..))")
public void applicationLayerPointcut() {
}
@AfterThrowing(pointcut = "applicationLayerPointcut()", throwing = "exception")
public void logError(JoinPoint joinPoint, Throwable exception) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName = signature.getMethod().getName();
String className = joinPoint.getTarget().getClass().getSimpleName();
Object[] args = joinPoint.getArgs();
logger.error("!! [ERROR] Exception in {}.{}() with arguments: {}", className, methodName, args);
logger.error("Exception: {}", exception.getMessage(), exception);
}
}
각자의 요구사항에 맞게 작성하면 된다.
3️⃣ Logback 설정으로 로그 파일 저장
Logback은 파일로 로그를 기록하고, 파일 관리 정책(롤오버, 삭제 등)을 통해 로깅 데이터를 효율적으로 유지하도록 도와주어 사용을 하게 되었다. 이렇게 파일 기반 로그를 사용하게 되면, 로직은 복잡할진 몰라도 보다 적은 부하로 로그를 안정적으로 저장하고, 필요시 중앙 저장소로 이동시켜 쉽게 트러블 슈팅이 가능하다.
* 나는 S3로 파일 업로드하는 로직을 만들었지만, 사실 진행중인 프로젝트의 경우 영구적으로 로깅을 저장할 필요는 없으니 해당 로직은 굳이.. 이기도 하다. 파일 관리 정책만 적절히 잘 설정해도 정해진 기간 동안에는 삭제되지 않고 저장되니 logback을 잘 이용하길 바란다!
Logback은 아래와 같이 xml 파일로 설정할 수 있다.
Logback 설정 파일(logback-spring.xml)은 pring Boot 프로젝트의 resources 폴더에 넣는 것이 표준이라고 하는데, 이유는 다음과 같다.
1. Spring Boot 및 Logback의 공식 문서에서 설정 파일은 resources 폴더에 두는 것을 권장한다.
https://docs.spring.io/spring-framework/reference/core/resources.html
2. Spring Boot 애플리케이션을 JAR 또는 WAR 파일로 패키징할 때, resources 폴더에 있는 파일은 자동으로 패키징되어 설정 파일들을 따로 관리하거나 서버에 수동 복사할 필요가 없다. Logback이 애플리케이션 실행 시 해당 설정 파일을 자동으로 감지하고 로드할 수 있다고 한다.
3. Spring Boot는 실행 시 resources 폴더를 클래스패스(classpath)에 자동으로 포함시켜 getClass().getClassLoader().getResource("logback-spring.xml")와 같은 방식으로 쉽게 접근할 수 있다. (클래스 패스로 접근한 코드는 후에 사용된다)
https://docs.spring.io/spring-framework/reference/core/resources.html
Logback전체 코드
<configuration>
<property name="LOG_HOME" value="/var/app/current/logs"/>
<appender name="ROLLING_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/error-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>10</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="ROLLING_FILE"/>
</root>
</configuration>
1. 로그 파일이 저장될 디렉터리 경로를 나타내는 LOG_HOME이라는 변수를 설정했다. 변수의 value에는 배포 환경의 디렉토리 구조에 맞게 명시적으로 지정해주었다.
<property name="LOG_HOME" value="/var/app/current/logs"/>
2. ROLLING_FILE이라는 이름의 파일 기반 로그 저장소를 정의했는데, RollingFileAppender는 로그 파일이 일정 크기나 시간이 지나면 새로운 파일로 롤오버(교체)되는 기능을 제공한다.
<appender name="ROLLING_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
3. 현재 활성 로그를 저장할 파일 경로를 환경변수를 통해 지정해줬다. 이를 통해 로컬 환경과 배포 환경 각각의 경로를 구분하여 설정할 수 있었다.
<file>${LOG_HOME}/error.log</file>
4. 로그 파일이 특정 시간 단위(예: 하루)가 지나면 새 로그 파일로 교체되도록 설정하는 부분인데, 새로운 파일이 생성될때 <fileNamePattern> 패턴으로 이름이 생성되고, <maxHistory> 개수 만큼의 로그 파일만 유지한다.
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/error-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>10</maxHistory>
5. 로그 메시지의 형식도 정의해주었다.
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
6. 전체 애플리케이션의 기본 로그 레벨을 아래와 같이 INFO로 지정해주었었는데, ERROR로 바꿀 에정이다.
<appender-ref>을 통해 ROLLING_FILE 설정을 사용하겠다는 선언을 해줬다.
<root level="INFO">
<appender-ref ref="ROLLING_FILE"/>
</root>
이렇게 xml파일로 설정해도 되지만, 아래와 같이 yml(properties)로도 기본 로깅 설정과 rollingpolicy 설정이 가능하다.
4️⃣ 로그 디렉토리 생성 및 권한 설정 스크립트
1. 디렉토리 생성: mkdir -p 명령어를 사용해 로그 디렉토리를 생성한다.
2. 소유권 변경: 디렉토리와 하위 파일의 소유권을 webapp 사용자 및 그룹으로 변경한다.
3. 접근 권한 설정: 디렉토리와 파일에 대해 읽기, 쓰기, 실행 권한(755)을 부여한다.
files:
"/sbin/appstart":
mode: "000755"
owner: webapp
group: webapp
content: |
#!/usr/bin/env bash
JAR_PATH=/var/app/current/application.jar
# 로그 디렉토리 지정
LOG_DIR=/var/app/current/logs
sudo mkdir -p "$LOG_DIR" # 경로 없으면 생성
sudo chown -R webapp:webapp "$LOG_DIR" # 디렉토리 및 하위 파일의 소유권을 webapp 사용자 및 그룹으로 변경.
sudo chmod -R 755 "$LOG_DIR" # 디렉토리 및 파일 접근 권한 설정
# run app
killalljava
java \
-Dfile.encoding=UTF-8 \
-Dspring.profiles.active=develop \
-jar "$JAR_PATH"
5️⃣s3에 파일 업로드 하는 AmazonS3Manager 구현 (AmazonS3 이용)
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
package LuckyVicky.backend.global.s3;
import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.DeleteObjectRequest;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.services.s3.model.S3Object;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
@Slf4j
@Component
@RequiredArgsConstructor
public class AmazonS3Manager {
private final AmazonS3 amazonS3;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
public InputStream getFcmSecretKeyFromS3() {
S3Object s3Object = amazonS3.getObject(bucket, "secret/serviceAccountKey.json");
return s3Object.getObjectContent();
}
public Optional<File> convert(MultipartFile file) throws IOException { // 파일로 변환
File convertedFile = new File(System.getProperty("java.io.tmpdir") + System.getProperty("file.separator")
+ file.getOriginalFilename());
file.transferTo(convertedFile);
return Optional.of(convertedFile);
}
public String putS3(File uploadFile, String fileName) { // S3로 업로드
amazonS3.putObject(
new PutObjectRequest(bucket, fileName, uploadFile).withCannedAcl(CannedAccessControlList.PublicRead));
return amazonS3.getUrl(bucket, fileName).toString();
}
public void delete(String filePath) {
try {
DeleteObjectRequest deleteObjectRequest = new DeleteObjectRequest(bucket, filePath);
log.info(String.valueOf(deleteObjectRequest));
amazonS3.deleteObject(deleteObjectRequest);
log.info("Deleted object from S3 with key: {}", filePath);
} catch (SdkClientException e) {
log.error("Error occurred while deleting object from S3", e);
throw new RuntimeException("Failed to delete object from S3", e);
}
}
public MediaType contentType(String fileName) {
String[] arr = fileName.split("\\.");
String type = arr[arr.length - 1];
return switch (type) {
case "txt" -> MediaType.TEXT_PLAIN;
case "png" -> MediaType.IMAGE_PNG;
case "jpg" -> MediaType.IMAGE_JPEG;
default -> MediaType.APPLICATION_OCTET_STREAM;
};
}
public static String generateFileName(MultipartFile file) { // 파일명 생성
return UUID.randomUUID().toString() + "-" + file.getOriginalFilename();
}
}
6️⃣ 로그 관련 비즈니스 로직 (LogSerivce) 구현
전체코드
package LuckyVicky.backend.global.s3;
import static LuckyVicky.backend.global.util.Constant.LOG_DATE_FORMAT;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.joran.JoranConfigurator;
import ch.qos.logback.core.joran.spi.JoranException;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.GetObjectRequest;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.services.s3.model.S3Object;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.time.LocalDate;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class S3LogService {
private final AmazonS3 amazonS3;
private final Environment environment;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
public void uploadDailyLog(String day) {
log.info("Starting uploadDailyLog for day: {}", day);
File localFile = getLocalLogFile(day);
ensureLogFileExists(localFile); // 로그 파일이 없으면 생성
String s3Key = buildS3Key(day);
File tempFile = new File("temp-" + localFile.getName());
try {
// S3에 기존 로그 파일이 있는 경우 다운로드하여 병합
if (amazonS3.doesObjectExist(bucket, s3Key)) {
log.info("Existing log found in S3. Merging logs for key: {}", s3Key);
mergeLogsFromS3(localFile, tempFile, s3Key);
} else {
log.info("No existing log found in S3 for key: {}. Copying local file.", s3Key);
Files.copy(localFile.toPath(), tempFile.toPath());
}
// 병합된 로그 파일을 S3에 업로드
amazonS3.putObject(new PutObjectRequest(bucket, s3Key, tempFile));
log.info("Uploaded merged log to S3: {} -> s3://{}/{}", tempFile.getName(), bucket, s3Key);
// 업로드 후 로컬 로그 파일 삭제 및 Logback 초기화
Files.deleteIfExists(localFile.toPath());
log.info("Deleted local log file: {}", localFile.getName());
resetLogbackContext();
} catch (Exception e) {
log.error("Failed to upload or merge log file to S3. day={}, file={}, key={}", day, localFile, s3Key, e);
} finally {
if (tempFile.delete()) {
log.info("Temporary file deleted: {}", tempFile.getName());
} else {
log.warn("Failed to delete temporary file: {}", tempFile.getName());
}
}
}
private void resetLogbackContext() {
log.info("Resetting Logback context to create a new log file.");
LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
loggerContext.stop(); // 기존 컨텍스트 정지
try {
loggerContext.reset(); // 컨텍스트 초기화
JoranConfigurator configurator = new JoranConfigurator();
configurator.setContext(loggerContext);
configurator.doConfigure( // 클래스패스를 통한 접근
Objects.requireNonNull(getClass().getClassLoader().getResource("logback-spring.xml")));
loggerContext.start(); // 컨텍스트 다시 시작
log.info("Logback context has been reset. New log file will be created.");
} catch (JoranException e) {
log.error("Failed to reset Logback context", e);
}
ensureLogFileExists(getLocalLogFile("today"));
log.info("Dummy log written to trigger new file creation.");
}
private void mergeLogsFromS3(File localFile, File tempFile, String s3Key) throws IOException {
log.info("Merging S3 log and local log for key: {}", s3Key);
try (S3Object s3Object = amazonS3.getObject(new GetObjectRequest(bucket, s3Key));
InputStream s3InputStream = s3Object.getObjectContent();
OutputStream tempOutputStream = new FileOutputStream(tempFile, true);
InputStream localInputStream = new FileInputStream(localFile)) {
// S3 로그 내용을 임시 파일에 복사
log.info("Copying S3 log content to temp file.");
copyContent(s3InputStream, tempOutputStream);
// 로컬 로그 내용을 임시 파일에 이어서 복사
log.info("Appending local log content to temp file.");
copyContent(localInputStream, tempOutputStream);
}
}
private void copyContent(InputStream input, OutputStream output) throws IOException {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = input.read(buffer)) > 0) {
output.write(buffer, 0, bytesRead);
}
}
private void ensureLogFileExists(File logFile) {
if (!logFile.exists()) {
try {
Files.createFile(logFile.toPath());
log.info("Log file manually created: {}", logFile.getAbsolutePath());
} catch (IOException e) {
log.error("Failed to create log file manually: {}", logFile.getAbsolutePath(), e);
}
} else {
log.info("Log file already exists: {}", logFile.getAbsolutePath());
}
}
private File getLocalLogFile(String day) {
String logHome = System.getProperty("LOG_HOME", "./logs");
LocalDate targetDate = "yesterday".equals(day) ? LocalDate.now().minusDays(1) : LocalDate.now();
String dateStr = targetDate.format(LOG_DATE_FORMAT);
if ("yesterday".equals(day)) {
return new File(logHome, "error-" + dateStr + ".log");
} else {
return new File(logHome, "error.log");
}
}
private String buildS3Key(String day) {
LocalDate targetDate = "yesterday".equals(day) ? LocalDate.now().minusDays(1) : LocalDate.now();
String dateStr = targetDate.format(LOG_DATE_FORMAT);
String activeProfile = environment.getProperty("spring.profiles.active", "default");
return String.format("logs/%s/%s-error-%s.log", activeProfile, activeProfile, dateStr);
}
}
메서드 하나씩 뜯어보자!
1. 트리거를 통해 uploadDailyLog가 호출된다.
로컬의 파일을 가져오고 기존 s3 파일이 있다면 병합한 후, s3로 업로드하고, 기존 로그 파일을 삭제 후, 새로운 파일을 생성한다.
public void uploadDailyLog(String day) {
log.info("Starting uploadDailyLog for day: {}", day);
File localFile = getLocalLogFile(day);
ensureLogFileExists(localFile); // 로그 파일이 없으면 생성
String s3Key = buildS3Key(day);
File tempFile = new File("temp-" + localFile.getName());
try {
// S3에 기존 로그 파일이 있는 경우 다운로드하여 병합
if (amazonS3.doesObjectExist(bucket, s3Key)) {
log.info("Existing log found in S3. Merging logs for key: {}", s3Key);
mergeLogsFromS3(localFile, tempFile, s3Key);
} else {
log.info("No existing log found in S3 for key: {}. Copying local file.", s3Key);
Files.copy(localFile.toPath(), tempFile.toPath());
}
// 병합된 로그 파일을 S3에 업로드
amazonS3.putObject(new PutObjectRequest(bucket, s3Key, tempFile));
log.info("Uploaded merged log to S3: {} -> s3://{}/{}", tempFile.getName(), bucket, s3Key);
// 업로드 후 로컬 로그 파일 삭제 및 Logback 초기화
Files.deleteIfExists(localFile.toPath());
log.info("Deleted local log file: {}", localFile.getName());
resetLogbackContext();
} catch (Exception e) {
log.error("Failed to upload or merge log file to S3. day={}, file={}, key={}", day, localFile, s3Key, e);
} finally {
if (tempFile.delete()) {
log.info("Temporary file deleted: {}", tempFile.getName());
} else {
log.warn("Failed to delete temporary file: {}", tempFile.getName());
}
}
}
2. 트리거에 따라 업로드 해야 하는 파일이 달라져서 해당 메소드로 알맞는 파일을 반환한다.
private File getLocalLogFile(String day) {
String logHome = System.getProperty("LOG_HOME", "./logs");
LocalDate targetDate = "yesterday".equals(day) ? LocalDate.now().minusDays(1) : LocalDate.now();
String dateStr = targetDate.format(LOG_DATE_FORMAT);
if ("yesterday".equals(day)) {
return new File(logHome, "error-" + dateStr + ".log");
} else {
return new File(logHome, "error.log");
}
}
3. 만일 파일이 없다면, 만들어낸다. (파일이 혹여나 생성이 되지 않았을 경우를 대비)
private void ensureLogFileExists(File logFile) {
if (!logFile.exists()) {
try {
Files.createFile(logFile.toPath());
log.info("Log file manually created: {}", logFile.getAbsolutePath());
} catch (IOException e) {
log.error("Failed to create log file manually: {}", logFile.getAbsolutePath(), e);
}
} else {
log.info("Log file already exists: {}", logFile.getAbsolutePath());
}
}
4. buildS3Key를 통해 profile에 맞는 s3 파일 저장소의 디렉토리와 파일명을 가져온다.
private String buildS3Key(String day) {
LocalDate targetDate = "yesterday".equals(day) ? LocalDate.now().minusDays(1) : LocalDate.now();
String dateStr = targetDate.format(LOG_DATE_FORMAT);
String activeProfile = environment.getProperty("spring.profiles.active", "default");
return String.format("logs/%s/%s-error-%s.log", activeProfile, activeProfile, dateStr);
}
5. s3에 업로드할 파일과 동일한 날의 파일이 있는 경우, 기존 파일을 다운로드하여 병합해야 한다.
temp 파일에 s3 파일 병합 후, 로컬 파일을 이어서 병합한다. 이렇게 만들어진 tempFile은 후에 병합되고 s3에 업로드 된다.
private void mergeLogsFromS3(File localFile, File tempFile, String s3Key) throws IOException {
log.info("Merging S3 log and local log for key: {}", s3Key);
try (S3Object s3Object = amazonS3.getObject(new GetObjectRequest(bucket, s3Key));
InputStream s3InputStream = s3Object.getObjectContent();
OutputStream tempOutputStream = new FileOutputStream(tempFile, true);
InputStream localInputStream = new FileInputStream(localFile)) {
// S3 로그 내용을 임시 파일에 복사
log.info("Copying S3 log content to temp file.");
copyContent(s3InputStream, tempOutputStream);
// 로컬 로그 내용을 임시 파일에 이어서 복사
log.info("Appending local log content to temp file.");
copyContent(localInputStream, tempOutputStream);
}
}
private void copyContent(InputStream input, OutputStream output) throws IOException {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = input.read(buffer)) > 0) {
output.write(buffer, 0, bytesRead);
}
}
6. S3 업로드 후 Logback 컨텍스트 재설정으로 로그 파일 자동 생성 문제 해결했다.
S3로 로그 파일을 업로드한 후, 로컬 로그 파일을 삭제하고 새 파일을 생성하는 과정에서 문제가 발생했다. logback-spring.xml에 설정된 RollingFileAppender가 로그 파일이 없을 때 자동으로 새 파일을 생성해야 하지만, 기존 컨텍스트가 파일 핸들러를 유지하고 있어 파일이 생성되지 않는 문제가 있었다. 이를 해결하기 위해 Logback 컨텍스트 재설정 로직을 추가했다.
Logback 컨텍스트 재설정은 LoggerContext를 가져와 기존 로그 컨텍스트를 중지(stop())하고 초기화(reset())한 후, 새로운 설정 파일(logback-spring.xml)을 적용(doConfigure())하고 로깅을 재시작(start())하는 방식으로 이루어진다. 이를 통해 파일 핸들러가 초기화되어 새 로그 파일이 정상적으로 생성되었다.
private void resetLogbackContext() {
log.info("Resetting Logback context to create a new log file.");
LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
loggerContext.stop(); // 기존 컨텍스트 정지
try {
loggerContext.reset(); // 컨텍스트 초기화
JoranConfigurator configurator = new JoranConfigurator();
configurator.setContext(loggerContext);
configurator.doConfigure( // 클래스패스를 통한 접근
Objects.requireNonNull(getClass().getClassLoader().getResource("logback-spring.xml")));
loggerContext.start(); // 컨텍스트 다시 시작
log.info("Logback context has been reset. New log file will be created.");
} catch (JoranException e) {
log.error("Failed to reset Logback context", e);
}
ensureLogFileExists(getLocalLogFile("today"));
log.info("Dummy log written to trigger new file creation.");
}
Logback 설정을 재적용할 때 loggerContext.doConfigure(...) 메서드에 전달하는 파일 경로는 애플리케이션이 접근 가능한 경로여야 한다. 예를 들어, src/main/resources/logback-spring.xml은 소스 코드 경로로, 빌드된 애플리케이션에서는 이 경로를 사용할 수 없다. 따라서 클래스패스를 활용하거나 절대 경로(예: /var/app/...)를 사용하는 방식 중 하나를 선택해야 한다.
클래스패스를 사용하는 방식은 소스 코드 관리와 배포 과정에서의 간소화라는 장점이 있어 채택했다. src/main/resources에 위치한 파일은 애플리케이션이 빌드될 때 자동으로 클래스패스에 포함되며, JAR 파일 내부에서도 동일한 방식으로 접근 가능하다. 이를 통해 로컬 개발 환경과 배포 환경 간의 차이를 없앨 수 있다. 반면, 절대 경로를 사용하는 방식은 특정 환경에 종속적이어서 다른 환경으로 애플리케이션을 이전하거나 배포할 때 문제가 발생할 가능성이 크다. 따라서 클래스패스를 활용하는 방식이 더 유연하고 유지보수에 유리하다.
7️⃣각 트리거에서 호출하는 코드 작성
1. 매일 자정에 해당 날에 모인 파일 s3에 업로드 하고, 새로운 파일 생성하도록 스케쥴링했다.
package LuckyVicky.backend.global.scheduler;
import LuckyVicky.backend.global.s3.S3LogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class LogUploadScheduler {
private final S3LogService logService;
@Scheduled(cron = "0 0 0 * * ?", zone = "Asia/Seoul")
public void uploadDailyLogToS3() {
log.info("Uploading Log to S3 with Scheduler");
logService.uploadDailyLog("yesterday");
log.info("Uploaded Log to S3 with Scheduler");
}
}
2. 무중단 cicd를 구축해놓은 상태라, 인스턴스가 계속 바뀐다. 그래서, 인스턴스 죽기 전에 지금까지 모인 로그들 s3로 업로드하도록 했다.
package LuckyVicky.backend.global.lifecycle;
import LuckyVicky.backend.global.s3.S3LogService;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class InstanceShutdownHandler {
private final S3LogService logService;
@PreDestroy
public void onShutdown() {
log.info("Application is shutting down. Uploading today's log to S3...");
logService.uploadDailyLog("today");
log.info("Uploaded Log to S3 with PreDestroy");
}
}
3. 자정 혹은 인스턴스 죽기전 말고 원할때 바로 s3로 업로드하고 싶기도 해서 api를 만들었다.
package LuckyVicky.backend.global.controller;
import LuckyVicky.backend.global.api_payload.ApiResponse;
import LuckyVicky.backend.global.api_payload.SuccessCode;
import LuckyVicky.backend.global.s3.S3LogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "로그", description = "로그 관련 API입니다.")
@RestController
@RequiredArgsConstructor
@RequestMapping("/logs")
public class LogController {
private final S3LogService logService;
@Operation(summary = "Error Log", description = "로컬 서버에 있는 에러 로그를 S3에 업로드 합니다.")
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "LOG_2001", description = "에러 로그 S3 업로드 완료")
})
@PostMapping("/error-upload")
public ApiResponse<String> uploadLogFile() {
logService.uploadDailyLog("today");
return ApiResponse.onSuccess(SuccessCode.ERROR_LOG_S3_UPLOADED, "Log file uploaded to S3.");
}
}
로그 파일 업로드 성공!!
참고 자료
https://dzone.com/articles/configuring-logback-with-spring-boot
https://docs.spring.io/spring-boot/reference/features/logging.html?utm_source=chatgpt.com
https://tecoble.techcourse.co.kr/post/2022-11-07-transaction-aop-fact-and-misconception/
'Backend > Spring AOP' 카테고리의 다른 글
[Spring] AOP(Aspect-Oriented Programming)란? (0) | 2024.12.14 |
---|