JAVA/JAVA | Spring 학습기록

[Spring] logback 로깅 전략 및 민감정보 마스킹 로그 처리를 하자

kth990303 2023. 5. 12. 21:45
반응형

애플리케이션에서 로그는 굉장히 중요한 역할을 한다. 특히, 실제 배포된 애플리케이션에서 로그 없이 nohup.jar 파일만 참고한다면 에러 로그나 sql문 정도만 확인할 수 있을 것이다. 사용자 요청이 언제 어떻게 들어왔는지 로그를 남겨줌으로써 디버깅의 용이성 및 애플리케이션 관리를 편리하게 해보도록 하자.

 

코드는 여기에서도 볼 수 있다.

https://github.com/mocacong/Mocacong-Backend

 

GitHub - mocacong/Mocacong-Backend: 모카콩 백엔드

모카콩 백엔드. Contribute to mocacong/Mocacong-Backend development by creating an account on GitHub.

github.com


log4j2 vs logback

이전에 나는 log4j2를 사용하고 관련 포스팅을 남긴 적이 있다.

https://kth990303.tistory.com/369

 

[Spring] log4j2를 활용한 로깅 전략을 다룬 yml 파일을 생성하자

배포 환경에서, 그리고 개발하면서 api를 맞춰보면서 특정 문제가 발생했을 때 그에 대한 기록을 남겨두면 이후에 그 기록을 바탕으로 대처를 할 수 있다. Spring에서는 다양한 logging configurations들

kth990303.tistory.com

그런데 왜 이번에는 log4j2가 아닌 logback을 썼느냐고 묻는다면... 큰 이유는 없다.

log4j2와 logback 둘 다 log4j의 보안 취약점 및 성능을 개선한 좋은 라이브러리이다.  (또, 공식문서에서 둘 다 자기네가 다른 라이브러리보다 빠르다고 주장하고 있다.)

이 때문에 뭐가 더 좋느니 논의하기는 힘들 듯하다.

그래서 이럴 바에 log4j2, logback 둘 다 써보자! 하는 마인드로 logback을 고른 것도 있다.

 

그리고 가장 큰 이유는 스프링에서 default 로깅 라이브러리로 logback을 선택했기 때문이다. logback이 아닌 log4j2를 사용하려면 build.gradle에서 logback 관련 설정을 exclude하고 사용해야 한다. 하지만 logback을 사용할 경우, 별도의 의존성 추가가 필요 없다. 스프링이 기본 라이브러리를 logback으로 설정한 이유가 있지 않을까? 하는 마인드로 logback을 사용해보고 싶어졌다. 

다만, logback 관련 환경설정 파일은 xml만 지원되는 점은 log4j2에 비해 아쉬웠다. log4j2는 yml(yaml), xml 등 다양한 형식을 지원한다. 근데 그러한 부분을 제외하곤 문법이 사실상 거의 똑같다는 점 때문에 log4j2, logback 둘 중 하나만 사용해도 다른 라이브러리로 옮기는 데엔 큰 무리가 없을 듯하다.


로그 형태

로그로 남길 항목은 아래와 같다.

  • 어떤 스레드에서 받아온 요청인지 (비동기 처리 및 멀티스레드 환경일 경우 로그 구별의 편리성을 위함)
  • 어떤 메서드를 수행중인지
  • 응답 시간이 어떤지
  • 어떠한 요청 및 응답값을 보내주는지

 

따라서 아래와 같이 로그를 작성해주었다.

 

LoggingTracer.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void begin(final String message, final Object[] args) {
    loggingStatusManager.syncStatus();
    if (log.isInfoEnabled()) {
        log.info(
                "[{}] {}-> {} args={}",
                loggingStatusManager.getTaskId(),
                getDepthSpace(loggingStatusManager.getDepthLevel()),
                message,
                args
        );
    }
}
 
private String getDepthSpace(int depthLevel) {
    return TRACE_DEPTH_SPACE.repeat(depthLevel);
}
cs

요청이 들어올 때 로그를 남기는 메서드이다. 응답을 받을 때 로그를 남기는 end() 메서드, 에러가 발생했을 때 로그를 남기는 exception() 메서드도 존재한다. 그리고 이러한 로그들은 ThreadLocal 타입에서 관리되고 있다.

 

Java에서 문자열을 넣어줄 때 + 키워드를 이용할 수 있다. 그런데 @Slf4j 라이브러리로 로그를 찍을 때는 + 보다 {}를 이용하는 것을 적극 권장한다. 그 이유는 + 는 해당 로그가 찍히지 않는 상황에서도 문자열 조합 연산이 들어가기 때문이다. {}로 이용하면 해당 문자열 조합 연산이 필요한 상황에서만 조합 연산이 발생하여 시간을 훨씬 줄일 수 있다

 

또, log.isInfoEnabled() 옵션을 통해 INFO 레벨을 로그로 남기는 전략에서만 log.info() 메서드를 호출해주도록 하였다. logback 공식문서에서도 이러한 방법을 권장하고 있다.

 

로그 출력 화면

이제 애플리케이션에 로그를 남겨줄 부분에 Spring AOP의 Pointcut으로 적용해주면 된다.

Spring AOP에 대해 설명하는 글은 아니기 때문에, 해당 내용은 생략하겠다.


개인정보(민감정보) 마스킹 처리

요청 및 응답으로 DTO 정보들을 로그에 남기다보면 개인정보까지 로그에 남는 상황이 존재하게 된다. 이러한 정보들은 *** 로 뜨도록 마스킹할 필요가 있다. 

 

이를 위해서는 logback의 PatternLayout을 상속받아 구현하면 된다. 

PatternLayout은 로그 패턴을 어떻게 나타낼지 지정해주는 클래스이다. 우리가 만들 MaskingPatternLayout 클래스로logback-spring.xml의 layout 필드를 커스텀한다고 생각하면 된다.

 

MaskingPatternLayout.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class MaskingPatternLayout extends PatternLayout {
 
    private final List<String> maskPatterns = new ArrayList<>();
    private Pattern multilinePattern;
 
    public void addMaskPattern(String maskPattern) {
        maskPatterns.add(maskPattern);
        multilinePattern = Pattern.compile(String.join("|", maskPatterns), Pattern.MULTILINE);
    }
 
    @Override
    public String doLayout(ILoggingEvent event) {
        return maskMessage(super.doLayout(event));
    }
 
    private String maskMessage(String message) {
        if (multilinePattern == null) {
            return message;
        }
        StringBuilder sb = new StringBuilder(message);
        Matcher matcher = multilinePattern.matcher(sb);
        while (matcher.find()) {
            IntStream.rangeClosed(1, matcher.groupCount()).forEach(
                    group -> {
                        if (matcher.group(group) != null) {
                            IntStream.range(matcher.start(group), matcher.end(group))
                                    .forEach(i -> sb.setCharAt(i, '*'));
                        }
                    });
        }
        return sb.toString();
    }
}
cs

코드는 https://www.baeldung.com/logback-mask-sensitive-data 를 참고했다.

 

* 가 아닌 다른 값으로 마스킹하고 싶다면 27번째 줄 부분을 수정해주면 된다.

참고로 해당 클래스는 별도의 빈 등록이 필요없다

 

위 설정만 한다고 해서 마스킹이 되는 것은 아니고, logback-spring.xml을 건드려야 한다.


logback-spring.xml

이제 logback의 핵심, 로깅 전략을 수립하는 logback-spring.xml 코드를 살펴보자.

 

logback-spring.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <timestamp key="BY_DATE" datePattern="yyyy-MM-dd"/>
    <property name="LOG_PATH" value="./logs"/>
    <property name="ARCHIVE_PATH" value="./archive"/>
    <property name="LOG_PATTERN"
              value="[%d{yyyy-MM-dd HH:mm:ss}:%-4relative] %green([%thread]) %highlight(%-5level) [%C.%M:%line] - %msg%n"/>
 
    <springProfile name="!prod">
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
                <layout class="mocacong.server.support.logging.MaskingPatternLayout">
                    <maskPattern>email\s*=\s*([^,)\s]+)</maskPattern> <!-- Email Arg pattern -->
                    <maskPattern>password\s*=\s*([^,)\s]+)</maskPattern> <!-- Password Arg pattern -->
                    <maskPattern>phone\s*=\s*([^,)\s]+)</maskPattern> <!-- Phone Arg pattern -->
                    <maskPattern>platformId\s*=\s*([^,)\s]+)</maskPattern> <!-- PlatformId Arg pattern -->
                    <maskPattern>(\d+\.\d+\.\d+\.\d+)</maskPattern> <!-- Ip address IPv4 pattern -->
                    <maskPattern>(\w+@\w+\.\w+)</maskPattern> <!-- Email pattern -->
                    <pattern>${LOG_PATTERN}</pattern>
                </layout>
            </encoder>
        </appender>
 
        <!-- 로컬 환경에서는 기본 로그레벨 INFO -->
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>
 
    <springProfile name="prod">
        <!-- 운영 환경에서는 로그를 파일로 저장 -->
        <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>${LOG_PATH}/mocacong-${BY_DATE}.log</file>
            <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
                <layout class="mocacong.server.support.logging.MaskingPatternLayout">
                    <maskPattern>email\s*=\s*([^,)\s]+)</maskPattern> <!-- Email Arg pattern -->
                    <maskPattern>password\s*=\s*([^,)\s]+)</maskPattern> <!-- Password Arg pattern -->
                    <maskPattern>phone\s*=\s*([^,)\s]+)</maskPattern> <!-- Phone Arg pattern -->
                    <maskPattern>platformId\s*=\s*([^,)\s]+)</maskPattern> <!-- PlatformId Arg pattern -->
                    <maskPattern>(\d+\.\d+\.\d+\.\d+)</maskPattern> <!-- Ip address IPv4 pattern -->
                    <maskPattern>(\w+@\w+\.\w+)</maskPattern> <!-- Email pattern -->
                    <pattern>${LOG_PATTERN}</pattern>
                </layout>
            </encoder>
            <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
                <!-- rolling(백업) 파일 저장 위치 -->
                <fileNamePattern>${ARCHIVE_PATH}/mocacong-%d{yyyy-MM-dd}-%i.log</fileNamePattern>
                <!-- 로그 파일이 10MB 넘으면 백업하고 새로운 파일 생성 -->
                <maxFileSize>10MB</maxFileSize>
                <!-- 백업 로그 파일은 7일 이후 삭제 -->
                <maxHistory>7</maxHistory>
                <!-- 로그 파일과 백업 로그 파일의 최대 크기는 100MB -->
                <!-- 초과 시 가장 오래된 백업 파일부터 삭제 -->
                <totalSizeCap>100MB</totalSizeCap>
            </rollingPolicy>
        </appender>
 
        <appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>${LOG_PATH}/error/mocacong-error-${BY_DATE}.log</file>
            <!-- ERROR 로깅 레벨만 필터링 -->
            <filter class="ch.qos.logback.classic.filter.LevelFilter">
                <level>ERROR</level>
                <onMatch>ACCEPT</onMatch>
                <onMismatch>DENY</onMismatch>
            </filter>
            <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
                <layout class="mocacong.server.support.logging.MaskingPatternLayout">
                    <maskPattern>email\s*=\s*([^,)\s]+)</maskPattern> <!-- Email Arg pattern -->
                    <maskPattern>password\s*=\s*([^,)\s]+)</maskPattern> <!-- Password Arg pattern -->
                    <maskPattern>phone\s*=\s*([^,)\s]+)</maskPattern> <!-- Phone Arg pattern -->
                    <maskPattern>platformId\s*=\s*([^,)\s]+)</maskPattern> <!-- PlatformId Arg pattern -->
                    <maskPattern>(\d+\.\d+\.\d+\.\d+)</maskPattern> <!-- Ip address IPv4 pattern -->
                    <maskPattern>(\w+@\w+\.\w+)</maskPattern> <!-- Email pattern -->
                    <pattern>${LOG_PATTERN}</pattern>
                </layout>
            </encoder>
            <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
                <!-- 에러 rolling(백업) 파일 저장 위치 -->
                <fileNamePattern>${ARCHIVE_PATH}/error/mocacong-error-%d{yyyy-MM-dd}-%i.log</fileNamePattern>
                <!-- 에러 로그 파일이 10MB 넘으면 백업하고 새로운 파일 생성 -->
                <maxFileSize>10MB</maxFileSize>
                <!-- 에러 백업 로그 파일은 20일 이후 삭제 -->
                <maxHistory>20</maxHistory>
                <!-- 에러 관련 로그 파일과 백업 로그 파일의 최대 크기는 500MB -->
                <!-- 초과 시 가장 오래된 백업 파일부터 삭제 -->
                <totalSizeCap>500MB</totalSizeCap>
            </rollingPolicy>
        </appender>
 
        <!-- 운영 환경에서는 기본 로그레벨 INFO -->
        <root level="INFO">
            <appender-ref ref="FILE"/>
            <appender-ref ref="FILE_ERROR"/>
        </root>
    </springProfile>
</configuration>
 
cs

맨 위쪽 코드를 보면 알 수 있듯이, property나 timestamp를 이용하여 변수로 따로 빼두는 작업을 할 수 있다.

나의 경우는 어떠한 패턴으로 로그를 나타낼지에 대한 LOG_PATTERN, 파일 저장 경로에 해당되는 LOG_PATH 정도를 property로 분리해두었다. 위와 같이 빼둔 변수를 ${LOG_PATTERN} 과 같이 이용할 수 있다.

 

작성된 전략들을 바탕으로 로그를 쓰는 주체가 appender이다.

appender의 class에 Console로 지정하여 콘솔에 로그를 남길 것인지, fileAppender로 지정하여 파일로 남길 것인지, rollingFileAppender로 지정하여 파일로 남기고 일정 트리거 시점에 백업 파일로 이동시킬 것인지 등을 지정해줄 수 있다.

 

자세한 appender에 대한 내용은 공식문서를 참고하자.

https://logback.qos.ch/manual/appenders.html

 

Chapter 4: Appenders

There is so much to tell about the Western country in that day that it is hard to know where to start. One thing sets off a hundred others. The problem is to decide which one to tell first. —JOHN STEINBECK, East of Eden Chapter 4: Appenders What is an Ap

logback.qos.ch

 

Console Appender

우선 간단하게 알아볼 수 있는 Console appender 부터 보도록 하자.

Console에서는 아래 코드만 주의하면 된다.

1
2
3
4
5
6
7
8
9
10
11
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
    <layout class="mocacong.server.support.logging.MaskingPatternLayout">
        <maskPattern>email\s*=\s*([^,)\s]+)</maskPattern> <!-- Email Arg pattern -->
        <maskPattern>password\s*=\s*([^,)\s]+)</maskPattern> <!-- Password Arg pattern -->
        <maskPattern>phone\s*=\s*([^,)\s]+)</maskPattern> <!-- Phone Arg pattern -->
        <maskPattern>platformId\s*=\s*([^,)\s]+)</maskPattern> <!-- PlatformId Arg pattern -->
        <maskPattern>(\d+\.\d+\.\d+\.\d+)</maskPattern> <!-- Ip address IPv4 pattern -->
        <maskPattern>(\w+@\w+\.\w+)</maskPattern> <!-- Email pattern -->
        <pattern>${LOG_PATTERN}</pattern>
    </layout>
</encoder>
cs

앞에서 만들어주었던 MaskingPatternLayout.class 의 마스킹 패턴을 적용하는 부분이다.

 

email, password, phone, platformId, IP 정보는 유저의 개인정보에 해당되는 부분이라 판단했다.

우리 애플리케이션의 로그 형태대로라면 로그는 [(email=kth990303@naver.com, password=kth990303, ...)] 꼴로 남는다.

그렇기 때문에 maskPattern에 `email={어떤 문자열 + ,나 )로 끝나는 경우}`의 정규표현식을 지정해주었다. 참고로 maskPattern에는 반드시 정규표현식 형태를 넣어주어야 한다. 위 정규표현식대로 작성하면 email= 뒤에 오는 문자열은 ***로 마스킹을 해준다.

개인정보는 모두 마스킹되어 넘어온다.

RollingFileAppender

앞에서도 언급했듯, RollingFileAppender는 파일로 로그를 남기지만 일정 트리거 시점에는 백업을 수행하는 전략의 appender이다. 운영 서버에서의 로그는 애플리케이션 모니터링하는 데에 필수적이지만, 용량이 커질 때의 비용 또한 무시할 수 없기 때문에 백업 및 삭제 전략을 세울 필요가 있었다.

 

RollingPolicy가 백업 및 삭제 전략을 담당하는 정책이다. 아래 코드를 중점적으로 보자.

1
2
3
4
5
6
7
8
9
10
11
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <!-- rolling(백업) 파일 저장 위치 -->
    <fileNamePattern>${ARCHIVE_PATH}/mocacong-%d{yyyy-MM-dd}-%i.log</fileNamePattern>
    <!-- 로그 파일이 10MB 넘으면 백업하고 새로운 파일 생성 -->
    <maxFileSize>10MB</maxFileSize>
    <!-- 백업 로그 파일은 7일 이후 삭제 -->
    <maxHistory>7</maxHistory>
    <!-- 로그 파일과 백업 로그 파일의 최대 크기는 100MB -->
    <!-- 초과 시 가장 오래된 백업 파일부터 삭제 -->
    <totalSizeCap>100MB</totalSizeCap>
</rollingPolicy>
cs

SizeAndTimeBased 의 전략을 세웠기 때문에 용량, 시간에 따라 전략을 세울 수 있다.

 

우리 프로젝트는 EC2 프리티어이자 30GB 볼륨 용량을 가지기 때문에, 안전하게 하나의 파일 당 10MB, 총 100MB 정도만 저장하도록 했다. 만약 사용자가 정말 많아진다면 파일 용량을 늘릴 수도 있을 듯하다. 7일이 지나면 백업 파일도 삭제되도록 했다.

 

1
2
3
4
5
6
7
<file>${LOG_PATH}/error/mocacong-error-${BY_DATE}.log</file>
<!-- ERROR 로깅 레벨만 필터링 -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>ERROR</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
</filter>
cs

그리고 에러 로그는 따로 모아서 보고 싶었기 때문에 error 관련 appender도 커스텀하여 만들어주었다.

에러 로그만 따로 보는 방법은 위처럼 filter를 이용하면 된다. ERROR < WARN < INFO < DEBUG < TRACE 중에서 발생할 확률이 낮지만 가장 심각한 로그만 따로 모아서 볼 필요가 있다고 판단했기 때문에, 위 작업을 수행해주었다.

 

 

또, RollingFileAppender에도 마스킹은 필수이기 때문에 Console Appender에 존재하던 masking encoder이 존재한다. 중복코드이기 때문에 appender-ref처럼 별도의 encoder-ref라든지 property로 뺄 수 있는지 알아보았지만, 아쉽게도 아직 encoder를 별도로 분리할 수 있는 방법은 없는 듯하다.


결과

mocacong-2023-05-12.log가 있는 걸 확인할 수 있다.

민감정보 마스킹, 로그 파일까지 잘 남는다.

위처럼 로그가 올바르게 파일로 남는 것을 확인할 수 있다~

 

단, 도커 이미지로 배포하는 환경이라면 컨테이너에 로그가 남게 될 것이다.

 

  1. `docker exec -it {컨테이너명} sh` 명령어로 컨테이너 접속 (alpine 이미지가 아닐 경우 bash로 접속 가능)
  2. `cd logs` 명령어로 logs 디렉토리 이동
  3. `ls` 명령어로 로그 파일이 존재하는지 확인
  4. `cat {로그 이름}`로 로그 확인

 

위 명령어로 로그를 확인할 수 있지만, 재배포가 이루어지면 컨테이너가 삭제되고 새로운 컨테이너로 생기므로 로그가 같이 사라지는 이슈가 존재한다. 따라서 볼륨을 생성하는 방식으로 컨테이너와 호스트를 연결하여 ec2에 로그를 저장하길 매우 권장한다. 그리고 ec2의 용량은 한정돼있으니 특정 시점이 되면 s3나 s3 glacier로 로그를 이동시키길 권장한다.

반응형