문제 상황
리플 프로젝트에서는 백엔드 서버를 EC2에 Docker를 사용하여 Spring Boot 애플리케이션을 컨테이너 형태로 배포해 운영하고 있다. 컨테이너는 정상적으로 작동하고 있었고 애플리케이션에서 발생하는 로그는 log.info, log.error 등을 통해 출력되도록 구현되어 있다.
에러 로그를 확인할 때는 다음 명령어를 통해 로그를 추적했다
docker logs ripple(컨테이너명)
그러나 이 명령어로는 다음과 같은 문제점이 있었다.
- 표준 출력(stdout), 표준 에러(stderr)가 섞여서 출력된다.
- 쿼리 로그, 일반 정보 로그, 에러 로그가 뒤섞여 있어 실제 에러 원인 파악이 어려웠다.
- 컨테이너가 오래 실행될수록 로그가 길어지고, 문제 발생 시점을 추적하기 어렵게 되었다.
- 실시간 대응이 불가능하며 문제 발생 후 수동으로 로그를 확인해야만 했다.
이러한 문제를 해결하기 위해 다음과 같은 두가지 방식을 도입하여 에러 및 문제 상황에 대응하고자 한다.
- Spring 에러 로그를 파일로 분리하여 저장한다.
- 에러 발생 시 디스코드 알림 전송을 통해 실시간으로 상황을 파악한다.
로그 파일 분리 저장
로그 파일을 생성하기 위해 Logback을 활용해보고자 한다. 우선 Logback이 무엇인지 알아보자.
Logback
Logback은 log4j의 후속으로 설계된 고성능 로깅 프레임워크이며 SLF4J(Simple Logging Facade for Java)를 기본적으로 구현하고 있다.
공식 설명에 따르면:
Logback is intended as a successor to the popular log4j project, picking up where log4j 1.x leaves off.
Logback’s architecture is quite generic so as to apply under different circumstances. It consists of three main modules:
- logback-core: 다른 모듈들의 기반이 되는 공통 기능 제공
- logback-classic: log4j 1.x의 향상된 버전이며 SLF4J API를 구현
- logback-access: Tomcat, Jetty 같은 Servlet 컨테이너에 통합되어 HTTP 접근 로그 지원
logback-spring.xml 구성
Spring Boot에서는 간단한 로깅 설정을 application.yml 파일을 통해 구성할 수 있다. 하지만 로그를 파일로 저장하거나, 로그 레벨별로 분리하는 등 보다 세부적인 설정이 필요한 경우에는 logback-spring.xml
파일을 직접 구성해야 한다. 따라서 이번 프로젝트에서는 복잡한 로깅 설정을 적용하기 위해 logback-spring.xml
을 사용하고자 한다.
logback-spring.xml을 설정하기 위해서는 appender, logger, root 등을 설정해줘야 한다. 각 구성 요소가 무엇인지 알아보자.
Appender
로그를 출력할 대상(예: 콘솔, 파일, 소켓 등)을 정의한다. 출력하는 유형은 주로 다음과 같다.
ConsoleAppender
: 콘솔 출력RollingFileAppender
: 파일 출력 및 날짜/크기 기반 분할SocketAppender
,SMTPAppender
등
각 Appender는 내부에 <encoder>
를 포함하며, <pattern>
을 통해 로그 메시지 포맷을 지정할 수 있다. 파일 기반 Appender의 경우, RollingPolicy
나 TriggeringPolicy
를 통해 로그 파일 분할 및 보관 정책을 설정할 수 있다.
Logger
특정 패키지나 클래스에 대해 로깅 레벨과 Appender를 지정한다. 주요 속성은 주로 다음과 같다.
name
: 로거가 적용될 패키지 또는 클래스명level
: 로그 출력 레벨 (DEBUG, INFO, WARN, ERROR 등)additivity
: 상위 로거로 로그 전파 여부 (true면 부모 로거에도 전파됨)
Root
애플리케이션 전체에 기본으로 적용되는 로깅 설정이다. name 속성 없이 level만 지정하며, 하나 이상의 Appender를 <appender-ref>
로 연결할 수 있다.
모든 로거의 최상위에 위치하며, 별도의 Logger 설정이 없는 경우 이 설정이 적용된다.
Error 로그를 서버 파일에 저장하도록 logback-spring.xml 구성하기
앞서 logback-spring.xml의 주요 구성 요소인 appender, logger, root에 대해 살펴보았다. 이제 실제로 log.error()가 실행될 때, 해당 로그가 별도의 파일(error.log) 로 저장되도록 설정 파일을 작성해보자.
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">
<!-- 환경별 로그 경로 분기 처리 -->
<springProfile name="local">
<property name="LOG_PATH" value="./logs"/>
</springProfile>
<springProfile name="prod">
<property name="LOG_PATH" value="/app/logs"/>
</springProfile>
<!-- 에러 로그만 저장하는 파일 Appender -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/error.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>
<!-- com.ripple 패키지의 ERROR 로그는 파일에만 저장 -->
<logger name="com.ripple" level="ERROR" additivity="false">
<appender-ref ref="ERROR_FILE"/>
</logger>
<!-- 기본 로그는 콘솔 출력 -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
<configuration>
: 로그백 설정의 루트 요소이다. scan="true"와 scanPeriod="30 seconds"를 설정하면 설정 파일이 변경되었을 때 자동으로 갱신된다.<property name="LOG_PATH">
: 로그 파일 저장 경로를 변수로 지정한다. 이후 ${LOG_PATH}로 참조할 수 있으며, 여기서는 기본값으로 ./logs를 사용한다.<appender>
: 로그를 출력할 대상을 정의하는 요소이다. 위 설정에서는 ERROR_FILE이라는 이름으로 에러 로그만 별도 파일(error.log)에 저장되도록 구성했다.<rollingPolicy>
: 로그 파일을 날짜 기준으로 분리 저장하도록 설정한다. 예를 들어 error.2025-06-17.log처럼 파일이 생성되며, 최대 30일까지 로그를 보관한다.<encoder>
: 로그 메시지의 출력 포맷을 지정하는 영역이다. 로그의 날짜, 스레드, 로그 레벨, 클래스명, 메시지 등을 포맷팅하여 가독성 높은 형태로 출력할 수 있다.<filter>
: 로그 레벨을 기준으로 필터링하는 설정이다. ThresholdFilter를 통해 ERROR 이상만 이 Appender로 전달되도록 제한한다.<logger>
: 특정 패키지나 클래스에 대한 로깅 규칙을 지정한다. 여기서는 com.ripple 패키지의 ERROR 로그만 ERROR_FILE에 기록되도록 설정했다. 또한 additivity="false"로 설정하여 루트 로거로 로그가 중복 전파되지 않도록 차단한다.<root>
: 애플리케이션 전체에 적용되는 기본 로깅 설정이다. 기본 로그 레벨은 INFO이며, 지정된 Appender(CONSOLE)를 통해 콘솔에 출력된다.
Error Log 저장 확인
@SpringBootTest
public class ErrorLogTest {
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ErrorLogTest.class);
@Test
void logbackErrorLogFile_shouldBeCreated_whenLogErrorOccurs() throws Exception {
// given
String errorMessage = "🚨 테스트 에러 로그입니다.";
// when
log.error(errorMessage);
// then - 잠시 대기 후 파일 존재 여부 확인
Thread.sleep(500); // 로그 flush 기다림
Path logPath = Path.of("./logs/error.log");
assertThat(Files.exists(logPath)).isTrue();
// 그리고 파일 안에 해당 로그가 있는지 확인
String content = Files.readString(logPath);
assertThat(content).contains(errorMessage);
}
}
이 테스트 코드를 실행하고 난 후에는 ./logs/error.log가 생성되고 메시지가 포함되어 있음을 확인할 수 있다.
Docker 로그 파일 외부 저장
나 같은 경우는 도커를 통해 서버를 운영하므로 추가적인 설정이 필요하다. 도커 환경에서는 로그 파일이 컨테이너 내부에 존재하므로 이를 EC2와 공유하려면 볼륨 마운트가 필요하다.
docker-compose.yml 예시
version: '3.8'
services:
ripple-backend:
image: ripple-backend:latest
container_name: ripple
volumes:
- ./logs:/app/logs
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- /app/logs는 컨테이너 내부 경로, ./logs는 EC2 호스트의 실제 경로이다.
- logback-spring.xml에서 ${LOG_PATH}가 /app/logs로 설정되면, 실제 파일은 EC2의 ./logs에 저장된다.
디스코드로 실시간 에러 알림 전송하기
에러 발생 시 디스코드 Webhook을 이용해 자동으로 알림을 전송할 수 있다. 이를 통해 서버에서 발생한 에러를 실시간으로 감지하고 대응할 수 있다.
웹훅에 대해 다음과 같이 설명하고 있다.
웹훅이란 데이터가 변경되었을 때 실시간으로 알림을 받을 수 있는 기능입니다. 웹 서비스의 이벤트 데이터를 전달하는 HTTP 기반 콜백 함수입니다. 특정 이벤트가 발생하면 웹훅이 클라이언트에게 이벤트 데이터를 보내요. 웹훅이라는 단어는 2007년에 Jeff Lindsay에 의해 처음 사용되었어요. HTTP 기반의 웹 특징과 훅(Hook) 기능을 합친 용어죠.
웹훅 전달 과정을 더 자세히 알아볼게요. 클라이언트가 서버에게 웹훅을 받을 유니크한 URL을 제공하고, 받고 싶은 이벤트를 등록해요. 등록한 이벤트가 발생하면 클라이언트는 제공한 URL로 이벤트 데이터를 받을 수 있어요.
이러한 웹훅 방식을 활용하면, Spring Boot 서버에서 발생한 에러를 Discord 서버에 실시간 알림 형태로 전달할 수 있다. 이 경우 웹훅 서버는 Spring Boot 애플리케이션, 웹훅 수신자는 Discord 서버가 된다.
디스코드 WebHook Url 복사
의존성 추가
logback-discord-appender 는 GitHub에서 배포되므로 JitPack을 통해 받아야 한다.
repositories {
mavenCentral()
maven { url '<https://jitpack.io>' } // JitPack 저장소 추가
}
dependencies {
implementation 'com.github.napstr:logback-discord-appender:1.0.0'
}
jitpack.io는 GitHub에 존재하는 라이브러리를 빌드해서 제공해주는 저장소다. logback-discord-appender는 JitPack을 통해 받을 수 있다.
logback-spring.xml 수정하기
이제 기존의 콘솔 및 에러 로그 파일 출력 설정에 더해 디스코드로도 에러 로그가 전송되도록 Appender를 추가한다.
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">
<!-- 공통 로그 패턴 -->
<property name="LOG_PATTERN"
value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] %-5level [%logger{36}] - %msg%n"/>
<!-- 로컬 환경 설정 -->
<springProfile name="local">
<property name="LOG_PATH" value="./logs" />
</springProfile>
<!-- 운영 환경 설정 -->
<springProfile name="prod">
<property name="LOG_PATH" value="/app/logs" />
<springProperty name="DISCORD_ERROR_WEBHOOK_URL"
source="logging.discord-error.webhook-url"/>
</springProfile>
<!-- 콘솔 출력 공통 Appender -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<!-- ✅ 운영 환경 전용: 에러 로그 파일 Appender -->
<springProfile name="prod">
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/error.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>
</springProfile>
<!-- ✅ 운영 환경 전용: Discord Webhook Appender -->
<springProfile name="prod">
<appender name="DISCORD" class="com.github.napstr.logback.DiscordAppender">
<webhookUri>${DISCORD_ERROR_WEBHOOK_URL}</webhookUri>
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>
\\n[🚨 ERROR LOG] ========================================
\\n%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%logger{2}.%M:%L] - %ex{short}%n
</pattern>
</layout>
<username>🚨 Ripple 서버 에러 알림</username>
<tts>false</tts>
</appender>
<appender name="ASYNC_DISCORD" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="DISCORD" />
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>
</springProfile>
<!-- com.ripple 로그 설정: 운영 환경일 경우만 파일/디스코드 추가 -->
<logger name="com.ripple" level="ERROR" additivity="false">
<springProfile name="prod">
<appender-ref ref="ERROR_FILE" />
<appender-ref ref="ASYNC_DISCORD" />
</springProfile>
</logger>
<!-- 전체 로그 콘솔 출력 (공통) -->
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
추가된 설정 사항들을 자세히 알아보자.
먼저 디스코드로 로그를 보내기 위한 Appender부터 살펴보면 다음과 같다.
<appender name="DISCORD" class="com.github.napstr.logback.DiscordAppender">
<webhookUri>${DISCORD_ERROR_WEBHOOK_URL}</webhookUri>
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>
\\n[🚨 ERROR LOG] ========================================
\\n%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%logger{2}.%M:%L] - %ex{short}%n
</pattern>
</layout>
<username>🚨 Ripple 서버 에러 알림</username>
<tts>false</tts>
</appender>
webhookUri
: 디스코드 웹훅 URL을 환경 변수에서 받아서 사용한다. 운영 환경에서는 application-prod.yml에서 해당 값을 불러온다.layout
: 실제 메시지 형식이다. 날짜, 스레드, 로그 레벨, 호출 클래스/메서드/라인 정보, 예외 메시지 등이 포함되도록 구성했다.username
: 디스코드에서 메시지가 올라올 때 보일 이름이다. 🚨 Ripple 서버 에러 알림 처럼 시각적으로 눈에 띄게 설정해둔다.tts
: 텍스트 음성 출력 여부인데, 일반 알림 메시지이므로 false로 둔다.
디스코드 알림을 애플리케이션의 주요 흐름과 분리해서 처리하기 위해 비동기 Appender도 같이 등록해두었다.
<appender name="ASYNC_DISCORD" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="DISCORD" />
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>
AsyncAppender
는 로그 전송을 별도의 스레드에서 처리하기 때문에, 메시지를 디스코드로 보내는 과정에서 애플리케이션이 느려지는 걸 방지할 수 있다.ThresholdFilter
를 통해 ERROR 이상인 로그만 디스코드로 전송되도록 필터링하고 있다.
그다음은 com.ripple 패키지에 대해, 운영 환경일 때만 파일 저장과 디스코드 알림이 동작하도록 logger 설정을 적용한다.
<logger name="com.ripple" level="ERROR" additivity="false">
<springProfile name="prod">
<appender-ref ref="ERROR_FILE" />
<appender-ref ref="ASYNC_DISCORD" />
</springProfile>
</logger>
level="ERROR"
: 에러 로그만 대상으로 한다.additivity="false"
: 로그가 루트 로거로 중복 전파되지 않게 막는다. 콘솔에는 출력되지 않고 지정된 Appender에만 전달된다.springProfile name="prod"
: 이 설정은 운영 환경에서만 적용되도록 제한했다.
마지막으로 전체 애플리케이션의 루트 로그는 콘솔로 출력되도록 설정해둔다.
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
- 별도로 정의되지 않은 대부분의 로그는 이 설정을 타게 된다.
- 운영/로컬 공통으로 콘솔 출력은 기본으로 유지하되 에러 로그는 별도 파일과 디스코드로 분리하는 구조다.
실행 결과
디스코드에 다음과 같이 에러 알림이 오는 것을 확인할 수 있다.
마무리
이렇게 설정을 마무리하면 운영 환경에서는 다음과 같은 흐름이 만들어진다
- com.ripple 패키지에서 log.error()가 발생하면,
- error.log 파일로 저장되고,
- 동시에 디스코드에 실시간 알림이 전송된다.
- 이 모든 과정은 AsyncAppender를 통해 비동기로 처리되어 성능에도 영향을 주지 않는다.
이제 굳이 docker logs ripple로 길게 뒤섞인 로그를 찾지 않아도 되고 에러가 발생하면 바로 디스코드에서 알림을 받을 수 있어서 대응이 훨씬 빨라지게 할 수 있게 되었다.
'트러블슈팅' 카테고리의 다른 글
@EventListener와 @TransactionalEventListener (3) | 2025.06.15 |
---|---|
No EntityManager with actual transaction 예외 (0) | 2025.06.11 |
MySQL Full Text Search가 원하는 결과를 반환하지 않는 오류 (3) | 2025.06.10 |
MySQL Full Text Search를 활용한 검색 성능 개선 (1) | 2025.06.09 |
Querydsl에서 DTO로 결과 받기 (0) | 2025.06.07 |