로깅에서 slack 메세지까지

경쓱타드·2024년 8월 16일
0

구현

목록 보기
1/4

에러, 로그, 문의사항에 대한 로그가 생성되면 파일로 관리해서 필요한 로그는 slack 메세지를 통해서 바로 확인할 수 있도록 만들고 싶었다. 문의사항이 들어오면 이메일로 오도록 했는데, 잘 확인하지 않아서 모든 확인을 slack에서 하도록 만들어야겠다고 느꼈다.

개발 과정은 크게 보면 spring에서 로그 파일 생성, ec2 배포 환경에서 slack 메세지 보내는 스크립트 작성이다. 과정은 결과만 간략하게 정리해두었다.

1. 로그 파일 생성하기

1-1 로그 파일 생성하기

먼저 스프링에서 로그 파일, 에러 로그 파일, 문의사항 API 관련 로그 파일을 생성하는 xml 파일을 생성한다.

  • logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 로그 색상변경 참고 사이트 : https://breakcoding.tistory.com/400 -->

    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter"/>

    <!--    <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS, Asia/Seoul} %-5level %logger{36} - %msg%n"/>-->
    <property name="LOG_FILE_NAME" value="%d{yyyy-MM-dd, Asia/Seoul}" />
    <property name="FILE_NAME" value="%d{yyyy-MM-dd_HH-mm, Asia/Seoul}" />
    <property name="CONSOLE_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS}  %clr(%-5level) --- [%thread] %cyan(%logger{36}) : %msg%n"/>
    <property name="FILE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS}  %-5level --- [%thread] %logger{36} : %msg%n"/>

    <!-- 콘솔 로그 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_PATTERN}</pattern>
        </encoder>
    </appender>

    <!--  파일 로그  -->
    <appender name="LOG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 로그 파일 이름 형식 -->
            <fileNamePattern>logs/${LOG_FILE_NAME}.%i.log</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <!-- 최대 보관 기간 (로그 파일을 10일간 보관) -->
            <maxHistory>10</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>${FILE_PATTERN}</pattern>
        </encoder>
    </appender>

    <!-- 문의사항 로그 -->
    <appender name="QUESTION_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/questions/question-${FILE_NAME}.log</fileNamePattern>
            <maxHistory>10</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%msg%n</pattern>
        </encoder>
    </appender>

    <!-- 시간에 따른 에러 로그 -->
    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 로그 파일 이름 형식 -->
            <fileNamePattern>logs/errors/errors-${FILE_NAME}.log</fileNamePattern>
            <!-- 최대 보관 기간 (로그 파일을 10일간 보관) -->
            <maxHistory>10</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>${FILE_PATTERN}</pattern>
        </encoder>
        <!-- 필터 추가 -->
        <filter class="ga.backend.log.ExceptionFilter"/>
    </appender>

    <!-- Spring Security Debugger 로그를 별도 파일에 기록 -->
    <logger name="Spring Security Debugger" level="INFO">
        <appender-ref ref="ERROR_FILE"/>
    </logger>

    <!-- Logger for BusinessLogicException -->
    <logger name="ga.backend.exception.BusinessLogicException" level="ERROR" additivity="false">
        <appender-ref ref="ERROR_FILE"/>
    </logger>

    <!-- Logger for GlobalExceptionHandler -->
    <logger name="ga.backend.exception.GlobalExceptionHandler" level="ERROR">
        <appender-ref ref="ERROR_FILE"/>
    </logger>

    <logger name="ga.backend.question.controller.QuestionController" level="INFO" additivity="false">
        <appender-ref ref="QUESTION_FILE"/>
    </logger>

    <!-- Root logger -->
    <root level="INFO">
        <appender-ref ref="LOG_FILE"/>
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

위와 같은 xml 파일(logback-spring.xml)을 resources 폴더에 생성한다. spring에서는 logback이라는 xml을 생성하면 자동으로 인식해서 사용하게 된다. 다른 이름으로 logback 파일을 사용하려면 yml 파일 설정이 필요하다.

위의 xml 파일을 통해서 콘솔 로그, 로그 파일, 에러 로그 파일, 문의사항 API 로그 파일이 생성된다.

1-2. 필터링 파일 생성하기

에러 발생했을 때, 'Spring Security Debugger' 내용을 같이 출력하고 싶어서 로그 관련 필터 파일을 생성했다.
'Spring Security Debugger'는 Spring Security 애플리케이션에서 발생하는 문제를 진단하고 디버깅하는 데 도움을 주는 도구이다. 주요 기능은 요청과 응답 디버깅, 필터 체인 로깅이다. 이 내용을 에러 발생할 때마다 로그 파일에 남기고 싶어서 아래의 파일을 만들었다.
에러가 발생했을 때 request 내용을 확인하고 싶었기 때문이다.

  • ExceptionFilter.java
package ga.backend.log;

import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.StackTraceElementProxy;
import ch.qos.logback.core.spi.FilterReply;
import ch.qos.logback.core.filter.Filter;

import java.io.FileWriter;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.TimeZone;

public class ExceptionFilter extends Filter<ILoggingEvent> {
    private ILoggingEvent securityLogEvent;
    private final String fileNamePattern = "logs/errors/errors-%s.log"; // 파일명 패턴 설정

    @Override
    public FilterReply decide(ILoggingEvent event) {
        String logger = event.getLoggerName();

        // "Spring Security Debugger" 로그를 저장
        if (logger.contains("Spring Security Debugger")) {
            securityLogEvent = event;
        }

        // BusinessLogicException일 경우 -> Requset 내용 추가
        if (logger.contains("BusinessLogicException")) {
            // 저장된 "Spring Security Debugger" 로그 출력
            if (securityLogEvent != null) {
                String message = securityLogEvent.getMessage();
                message = message.split("Security filter chain")[0];
                message += "************************************************************";

                // 파일에 로그 메시지를 기록
                writeLogToFile(message);

                // securityLogEvent를 null로 설정해 중복 기록 방지
                securityLogEvent = null;
            }

            return FilterReply.ACCEPT;
        }

        // GlobalExceptionHandler일 경우 -> 내용 요약하기
        if (logger.contains("GlobalExceptionHandler")) {
            // 전체 메시지 가져오기
            String message = event.getMessage();

            // Throwable이 있는 경우, 스택 트레이스를 가져와서 메시지 추가
            if (event.getThrowableProxy() != null) {
                StringBuilder sb = new StringBuilder();
                sb.append(message).append(System.lineSeparator());

                // 예외 스택 트레이스 추가
                for (StackTraceElementProxy line : event.getThrowableProxy().getStackTraceElementProxyArray()) {
                    sb.append(line).append(System.lineSeparator());
                    // 메시지 길이를 제한하여 생략 처리
                    if (sb.length() > 1000) {
                        sb.append("... [truncated]");
                        break;
                    }
                }

                message = sb.toString();
            }

            // 파일에 로그 메시지를 기록
            writeLogToFile(formatLogMessage(event, message));
        }

        return FilterReply.DENY;
    }

    // 파일에 로그 남기기
    private void writeLogToFile(String logMessage) {
        String formattedFileName = getFormattedFileName();
        try (FileWriter fileWriter = new FileWriter(formattedFileName, true)) {
            fileWriter.write(logMessage + System.lineSeparator());
        } catch (IOException e) {
            System.err.println("Failed to write log to file: " + e.getMessage());
        }
    }

    // 로그 메시지 포맷팅
    private String formatLogMessage(ILoggingEvent event, String message) {
        // 로그 타임스탬프
        String timestamp = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(new Date(event.getTimeStamp()));

        // 로그 레벨
        String level = event.getLevel().toString();

        // 스레드 이름
        String threadName = event.getThreadName();

        // 로거 이름
        String loggerName = event.getLoggerName();

        // 포맷된 로그 메시지
        return String.format("%s  %-5s --- [%s] %s : %s", timestamp, level, threadName, loggerName, message);
    }


    // 파일명 설정
    private String getFormattedFileName() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm");
        sdf.setTimeZone(TimeZone.getTimeZone("Asia/Seoul"));  // 시간대를 Asia/Seoul로 설정
        String dateTime = sdf.format(new Date());
        return String.format(fileNamePattern, dateTime);
    }
}

이 과정을 통해서 로그 파일 생성하고, 원하는 내용을 로그 파일에 남길 수 있다. 다음 과정은 이렇게 생성한 로그 파일을 slack을 메세지를 보내는 과정이다.

2. slack 메세지 보내기

2-1. java 어플리케이션 백그라운드 동작시키기

먼저 구현한 java 어플리케이션을 AWS EC2에 백그라운드 동작을 시킨다. 환경변수 및 백그라운드에 동작시키는 과정을 간편화하기 위해서 스크립트를 이용한다.

환경변수 파일을 따로 만들어서 환경변수 관리를 원활하게 진행하도록 한다.

  • env.sh
#!/bin/bash

export DB_USER=root
export DB_PW=password123#

# JAVA_OPTS에 환경변수 추가
export JAVA_OPTS="$JAVA_OPTS \
    -DDB_USER=$DB_USER \
    -DDB_PW=$DB_PW \

그리고 파일을 동작시키는 스크립트를 구현한다. git pull한 후 현재 구동 중인 어플리케이션 종료 후 재가동하는 동작을 한다.

  • deploy.sh
#!/bin/bash

REPOSITORY=/home/ubuntu/InsurePro_Backend

# 환경변수 파일 불러오기
source /home/ubuntu/env.sh

# 프로젝트 디렉토리로 이동
cd $REPOSITORY/

# 최신 코드 가져오기
echo "> Git Pull"
git stash
git pull

# 프로젝트 빌드
echo "> 프로젝트 Build 시작"
sudo chmod +777 gradlew
./gradlew build

# 최신 JAR 파일을 구동 디렉토리에 복사
echo "> Build 파일 복사"
cp ./build/libs/*.jar $REPOSITORY/

# 현재 구동 중인 애플리케이션 종료
echo "> 현재 구동중인 애플리케이션 pid 확인"
CURRENT_PID=$(pgrep -f backend)
echo "$CURRENT_PID"

if [ -z $CURRENT_PID ]; then
    echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
else
    echo "> kill -2 $CURRENT_PID"
    sudo kill -9 $CURRENT_PID
sleep 5
fi

# 포트 강제 종료 (필요 시 사용)
sudo fuser -k 8080/tcp

# 새 애플리케이션 배포
echo "> 새 어플리케이션 배포"
JAR_NAME=$(ls $REPOSITORY/ |grep 'backend' | tail -n 1)
echo "> JAR Name: $JAR_NAME"

nohup sudo java $JAVA_OPTS -jar $REPOSITORY/$JAR_NAME > /dev/null 2>&1 &

2-2. 로그 파일 생성 시 slack 메세지 보내기

자세한 과정은 아래의 내용을 참고하길 바란다.
https://velog.io/@kung036/Spring-%EC%84%9C%EB%B2%84-%EC%97%90%EB%9F%AC-%EB%B0%9C%EC%83%9D-%EC%8B%9C-Slack-%EB%A9%94%EC%84%B8%EC%A7%80-%EB%B3%B4%EB%82%B4%EA%B8%B0

slack 채널에 메세지를 보내는 스크립트이다. 두 개의 채널에 로그 파일 생성 시 에러 메세지 및 문의사항 메세지를 보낸다.

  • error-slack.sh
#!/bin/bash

# 모니터링할 디렉토리 경로
ERROR_MONITORED_DIR="/home/ubuntu/InsurePro_Backend/logs/errors"
QUESTION_MONITORED_DIR="/home/ubuntu/InsurePro_Backend/logs/questions"

# Slack Webhook URL
ERROR_SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T07"
QUESTION_SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T0"

# 로그 파일 경로
LOG_FILE="/home/ubuntu/slack-msg-error/error-slack.log"

# 파일 생성 이벤트를 감지하고 처리하는 함수
process_file() {
    local file_path=$1
    local webhook_url=$2
    local message_prefix=$3

    # 파일이 텍스트 파일일 경우에만 처리
    if [[ -f "${file_path}" ]]; then
        # 파일 내용을 읽어오기
        FILE_CONTENT=$(cat "${file_path}")

        # Slack 메시지 전송
        curl -X POST -H 'Content-type: application/json' --data "{
            \"text\": \"${message_prefix}: ${file_path}\n내용:\n${FILE_CONTENT}\"
        }" "${webhook_url}"

        # 로그 파일에 기록
        echo "$(date): 파일 생성 감지 - ${file_path}" >> "${LOG_FILE}"
    fi
}

# inotifywait를 사용하여 파일 생성 이벤트 모니터링
inotifywait -m -e create --format '%w%f' "${ERROR_MONITORED_DIR}" "${QUESTION_MONITORED_DIR}" | while read NEW_FILE
do
    if [[ "${NEW_FILE}" =~ ^${ERROR_MONITORED_DIR} ]]; then
        # 에러 디렉토리의 파일 처리
        process_file "${NEW_FILE}" "${ERROR_SLACK_WEBHOOK_URL}" "에러"
    elif [[ "${NEW_FILE}" =~ ^${QUESTION_MONITORED_DIR} ]]; then
        # 질문 디렉토리의 파일 처리
        process_file "${NEW_FILE}" "${QUESTION_SLACK_WEBHOOK_URL}" "질문"
    fi
done

그 후 slack 메세지를 보내는 스크립트를 동작시키는 스크립트를 생성한다. 말이 조금 복잡하긴 한데, 결국 이 스크립트만 동작시키면 백그라운드에서 로그 파일 생성을 확인하다가 로그 파일 생성되면 slack에 메세지를 보낸다.

  • deploy-error-slack.sh
#!/bin/bash

# 로그 파일 경로
LOG_FILE="/home/ubuntu/slack-msg-error/error-slack.log"

# 모니터링 실행 코드 경로
EXE_FILE="/home/ubuntu/slack-msg-error/error-slack.sh"

# 현재 실행 중인 PID들을 배열로 저장
echo "> 현재 구동중인 애플리케이션 pid 확인"

CURRENT_PIDS=($(pgrep -f error-slack.sh))

# 배열 내 PID들을 출력
echo "${CURRENT_PIDS[@]}"

# 배열에 있는 각 PID에 대해 처리
for PID in "${CURRENT_PIDS[@]}"; do
    echo "> kill -9 $PID &"
    kill -9 $PID &
done

# 스크립트를 백그라운드에서 실행
echo "> 백그라운드에서 error-slack 실행 중"
nohup ${EXE_FILE} > ${LOG_FILE} 2>&1 &
echo "($(pgrep -f error-slack.sh))"
profile
백엔드 개발자를 시작으로 도메인 이해도까지 풍부한 개발자가 목표입니다!

0개의 댓글