프로젝트를 진행하면서 서버를 띄워야 하는 순간이 왔습니다.

이런저런 방법을 알아본 결과, aws의 ecs라는 서비스를 선택해서 진행했습니다.

서버를 띄우는데까지 성공했고 이제 서버에 로그를 남기려고 확인해보니, 로컬에서 프로젝트를 실행했을 때 나오는 포맷 로그가 그대로 cloudwatch에 표출되고 있었습니다.

물론 로컬에서 개발 할 당시에는 기본 제공되는 로그 뿐만 아니라, db 쿼리, 네트워크 통신 내용 등 여러가지를 볼 일이 많기 때문에 포맷에 크게 손을 대지는 않았습니다.

 

하지만 서버의 로그를 확인하기에는 불편한점이 한 둘이 아니었습니다.

예를들면 놓치고 예외처리를 못한 Exception이 발생했을 때, stackTrace가 출력되는 경우가 있는데, 이런 경우에는 라인 수 가 너무 길어서 확인하기 어려울 정도입니다.

 

그래서 편하게 볼 수 있는 방법이 없을 까 고민하던 찰나에, Log4j2 라이브러리를 이용하면 로그를 json 형식으로 남길 수 있다는 사실을 알게되었습니다.

프로젝트는 적용 완료해서 잘 사용하고 있고, 까먹을 것 같아 정리해둡니다.

 

Log4j2를 선택한 이유

  1. Json format의 Log
  2. Json 속성 커스터마이징

cloudwatch에서 로그를 편하게 보기 위해서 제가 가장 중요하다고 생각했던 두 가지 입니다.

저는 Spring boot를 사용하기 때문에, spring-boot-starter 의존성을 추가하면 그 안에는 기본적으로 logback이 포함되어있습니다.

logback도 로그를 json 포맷으로 설정할 수 있게 지원하고있지만, 제가 필요로 하는 속성들 외에 서버를 운영하는데 크게 필요하지 않다고 느껴지는 속성들이 많이 있었습니다. (mdc, label, tags 등 …)

 

하지만 이 부분들을 제외하는 방법을 찾지 못했습니다.

Log4j2는 이런 부분들을 제거할 수 있고, 내가 원하는 속성들로만 구성할 수 있기 때문에 Log4j2를 사용하게 되었습니다.



Build.gradle 의존성 수정

 

우선, spring에서 기본으로 제공하는 로깅 프레임워크를 사용하지 않을 것이기 때문에 의존성을 제외해주어야 합니다.

제외하지 않으면 어떻게 되나 궁금해서 그냥 해봤는데, 오류가 발생하면서 프로젝트가 종료되더라구요.

둘 다 @Slf4j의 구현체들 이기 때문에 스프링이 올라오면서 충돌이 일어나는 것 같습니다.

 

Spring 기본 제공 로깅 프레임워크 exclude

configurations {
    ...
    all {
        exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
    }
}

이제 Log4j2 의존성을 추가해줍니다.

그리고 log4j2 설정을 yml로 작성할 것이기 때문에, yml을 json형태로 읽을 수 있도록 도와주는 라이브러리도 추가했습니다.

 

log4j2 의존성 추가

dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-log4j2' // Log4j2
    implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.14.2' // log4j2.yml을 json 형태로 읽을 수 있게 도와주는 라이브러리
    ...
}



Log4j2 설정

 

저는 local, dev, prod라는 세가지 환경을 구성할 것인데, local은 제 컴퓨터에서 작업하는 것이기 때문에 dev와 prod에만 json 설정을 진행하겠습니다.



Log4j2에서 기본적으로 지원하는 JSON 포맷 - JSON_LAYOUT_APPENDER

 

우선, Log4j2에서 기본으로 지원하는 JSON포맷을 한번 살펴보겠습니다.

log4j2.yml에 JSON_LAYOUT_APPENDER를 적용하면 Log가 Json 포맷으로 만들어지는데, JsonTemplateLayout이라는 plugin 클래스와 Jackson이 사용됩니다.

만약 Jackson이 없다면 추가해주어야 합니다.



log4j2.yml

Configuration:
  name: DefaultConfiguration
  status: INFO
    Properties:
    Property:
      - name: pattern
        value: "%msg%n"

  Appenders:
    Console:
      - name: JSON_LAYOUT_APPENDER
        target: SYSTEM_OUT
        JSONLayout:
          objectMessageAsJsonObject: true
          compact: true
          eventEol: true

      - name: CONSOLE_ROOT
        target: SYSTEM_OUT
        PatternLayout:
          alwaysWriteExceptions: true
          pattern: ${pattern}

    Root:
      level: INFO
      AppenderRef:
        ref: CONSOLE_ROOT

위는 전체 코드이고, 여기에서 Appenders.Console 부분을 살펴보겠습니다.



Appenders.Console

...
  Appenders:
    Console:
      - name: JSON_LAYOUT_APPENDER
        target: SYSTEM_OUT
          JSONLayout:
            objectMessageAsJsonObject: true
            compact: true
            eventEol: true
...

Console 속성은 말 그대로 로그가 출력될 때의 설정을 해주는 곳이고, 여기에 해당하는 여러가지 옵션들이 있지만, 저는 name과 target만 설정해주겠습니다.

JsonLayout 하위에 여러가지 옵션을 적용할 수 있는데, 이 부분은 공식문서 링크를 남겨놓겠습니다.

여기까지 설정을 했으면, 이제 아래와 같은 Json 포맷으로 만들어진 로그를 볼 수 있습니다.

{ "field" : "value" }



실제 프로젝트에 적용해보면 아래와 같은 형태입니다.

{"instant":{"epochSecond":1698385086,"nanoOfSecond":838851300},"thread":"main","level":"INFO","loggerName":"org.springframework.boot.web.embedded.tomcat.TomcatWebServer","message":"Tomcat started on port(s): 8080 (http) with context path ''","endOfBatch":false,"loggerFqcn":"org.apache.commons.logging.LogAdapter$Log4jLog","threadId":1,"threadPriority":5}

 


 

한계

 

Log4j2에서 기본으로 제공하는 JsonLayout을 이용해도 로그를 Json 형태로 얻을 수는 있지만, 실제로 사용하기에는 무리가 있다고 생각합니다.

시스템에서 발생하는 로그의 경우엔, 기본 제공 형태를 사용해도 무리는 없을것 같은데, 제가 보고싶었던 로그는 사용자의 요청 정보를 한 눈에 볼 수 있는 형태였습니다. (페이지 하단으로 가시면 제가 보고싶었던 로그의 형태를 볼 수 있습니다.)

 

그래서 이런저런 설정을 시도해보았지만, 실제 프로젝트에 적용된 로그처럼 시스템 정보를 제외하는 방법을 찾지 못했습니다.

그래서 아래에서는 커스텀 플러그인을 추가해서 제가 원하는 속성 (ex - url, http method, user-agent ..)들만 Json에 담길 수 있도록 설정을 해보겠습니다.



CustomLayout 적용



CustomLayout Plugin class 생성

  • 공식 홈페이지를 참고하면 CustomLayout Plugin을 손쉽게 만들 수 있습니다.
@Plugin(name = "CustomLayout", category = Node.CATEGORY, elementType = Layout.ELEMENT_TYPE, printObject = true)
public class CustomLayout extends AbstractStringLayout {

    private static final String DEFAULT_EOL = "\r\n";

    protected CustomLayout(Charset charset) {
        super(charset);
    }

    @PluginFactory
    public static CustomLayout createLayout(@PluginAttribute(value = "charset", defaultString = "UTF-8") Charset charset) {
        return new CustomLayout(charset);
    }

    @Override
    public String toSerializable(LogEvent event) {
        return event.getMessage().getFormattedMessage() + DEFAULT_EOL;
    }
}
  • @Plugin
    • CustomLayout이라는 클래스를 Log4j2 플러그인으로 등록합니다.
  • @PluginFactory
    • Log4j2에게 이 메서드가 플러그인을 생성하기 위한 팩토리 메서드임을 알려줍니다.
    • 팩토리 메서드이기 때문에 메서드명은 마음대로 지정해도 됩니다.
      동작 과정
      1. yml 파일에 미리 설정한 정보를 읽습니다.(Appenders.Console.name.CustomLayout: 이 부분)
      2. Log4j2는 위 파일을 읽어서 @PluginFactory 어노테이션이 붙은 createLayout 메서드를 호출하고, yml파일에 저는 주석을 해놓았지만 만약 charset에 값을 설정해주었다면, 그 부분을 파라미터로 전달합니다.
      3. createLayout 메서드는 전달된 charset을 이용하여 CustomLayout 인스턴스를 생성하고 반환하면서 사용할 수 있도록 만들어줍니다.

 

CustomLayout - yml 파일에 추가

  • 위에서 만든 Layout을 사용하기 위해서 yml에 파일에 추가를 해줍니다.
  ...    

  Appenders:
    Console:
      # 추가
      - name: CUSTOM_LAYOUT_APPENDER # 위에서 추가한 plunin
        target: SYSTEM_OUT
        CustomLayout: {}

      - name: JSON_LAYOUT_APPENDER
                ... 

  Loggers:
    logger:
      # 추가
      - name: CUSTOM_JSON_LAYOUT_LOGGER # 실제로 사용할 것
        level: info
        additivity: false
        AppenderRef:
          ref: CUSTOM_LAYOUT_APPENDER

      - name: CONSOLE_ROOT
        ...

    ...
  • Appenders에는 새롭게 만든 layout plugin을 추가해주었습니다.
  • 해당 layout을 자바 클래스에서 사용하기 위해 logger에 이름을 부여해서 추가해줍니다.

 

logger 사용

  • 로그를 남길 클래스에서 CUSTOM_JSON_LAYOUT_LOGGER를 사용하기 위해 LogManager에 넣어줍니다.
  • 저의 경우에는 로그를 남기는 곳이 filter이기 때문에 filter에 적용했습니다.
public class Filter {
        private static final String CUSTOM_JSON_LAYOUT_LOGGER = "CUSTOM_JSON_LAYOUT_LOGGER";
        private Logger logger = LogManager.getLogger(CUSTOM_JSON_LAYOUT_LOGGER);

        ...
}

여기까지 됐으면 비어있는 layout으로 된 json log 포맷을 사용할 준비가 됐습니다.

이대로 로그를 찍으면 {} 이렇게만 나올것입니다.

 

Json 속성 만들기

  • 이제 위 layout에 채워줄 속성을 만들어줄 차례인데, 크게 Type과 Body로 나눌것이고, Body에 들어가는 속성들은 고정하지 않고, 상황에 맞게 설정할 수 있도록 Map을 사용하겠습니다.
public class CustomMessage implements Message {

    private static final String TYPE = "type";
    private static final String BODY = "body";

    private final Map<String, Object> log;

    public CustomMessage(Map<String, Object> log) {
        this.log = log;
    }

    @Override
    public String getFormattedMessage() {

        JSONObject realLog = new JSONObject(log);
        JSONObject createdLog = new JSONObject(new HashMap<String, Object>() {{
            put(TYPE, "Request Log");
            put(BODY, realLog);
        }});
        return createdLog.toString();
    }

    @Override
    public String getFormat() {
        return log.toString();
    }

    @Override
    public Object[] getParameters() {
        return new Object[0];
    }

    @Override
    public Throwable getThrowable() {
        return null;
    }
}

 


 

실제 요청 적용

 

Controller

@RestController
public class Controller {
    private static final String CUSTOM_JSON_LAYOUT_LOGGER = "CUSTOM_JSON_LAYOUT_LOGGER";
    private Logger logger = LogManager.getLogger(CUSTOM_JSON_LAYOUT_LOGGER);

    @GetMapping("/test")
    public void test(@RequestParam String name, @RequestParam String mail) {
        Map<String, String> map = new HashMap<>();
        map.put("name", name);
        map.put("mail", mail);
        CustomMessage message = new CustomMessage(map);
        logger.info(message);
    }
}

 

Custom Layout이 적용된 Log

{
    "type": "Request Log",
    "body": {
        "name": "test", 
        "mail": "test@google.com"
    }
}

사용자마다 사용하는 방식은 다르겠지만 저는 실제로 사용할 떄 CustomMessage 클래스의 log변수의 타입인 Map으로는 아쉬워서 별도의 속성을 담을 클래스를 만들어서 사용했습니다.

이 부분은 원하시는 방법을 선택하셔서 유연하게 사용하시면 좋을 것 같습니다.

+ Recent posts