이 글은 내가 tistory에서 활동할 때 적었던 글이다.
블로그 이전하면서 버리기 아까운 내용이여서 가져왔다.
시작에 앞서 로그가 무엇이며, 로깅을 하는 이유에 대해서 알아본다.
네이버 사전에 log를 검색하면 다음과 같이 검색된다.
log(logging) 뜻
1. 통나무
2. (특히 항해운항비행 등의) 일지
3. logarithm(?)
개발자들이 말하는 Log라는 것은 네이버 사전의 두 번째 뜻(=일지)과 느낌이 비슷하다.
흔히 컴퓨터 분야에서 말하는 Log(로그)는 애플리케이션(또는 운영체제)이 실행 중 발생하는 다양한 이벤트에 대한 기록을 뜻한다. 그리고 기록하는 행위를 Logging(로깅)이라고 한다.
Logging의 대상은 콘솔, 파일, 데이터베이스 등이 있다.
자바에서는 로깅을 위한 다양한 로깅 프레임워크가 존재한다.
아래에 작성한 것들이 대표적인 프레임워크들이다.
logback
: Log4j를 개발한 Ceki Gulcu
가 기존에 사용되던 Log4j를 더 발전시킨 것 java.util.logging
: JDK 1.4부터 포함된 로깅 APIlog4j2
: 아파치 재단에서 제공하는 로깅 APIApache Commons logging
참고로 우리가 자주 사용하는 spring boot에서는 기본으로 logback 프레임워크를 사용한다.
그래서 이번 게시물도 logback 프레임워크에 대한 사용법을 익힐 예정이다.
그런데 무작정 logback 프레임워크 관련 코드를 애플리케이션 코드에 직접적으로 사용해도 괜찮을까?
만약에 수백개의 코드에 logback 관련 코드를 작성했다고 가정해보자.
그런데 갑자기 클라이언트에 의해서 Log4j2 로 로깅 프레임워크를 바꿔야 한다면?
방금 말한 수백개의 코드를 모두 바꿔야 한다.
IDE의 도움을 받아서 고쳤다고 해도, SVN이나 Git으로 Commit을 하면 무수한 충돌이 날 것이다.
그렇다면 어떡할까? 이때 필요한 것이 바로 SLF4J(Simple Logging Facade For Java)이다.
참고:
SLF4J 는 위에서 말한 로깅 프레임워크가 아니다.
단지 복잡한 로깅 프레임워크들을 쉽게 사용할 수 있도록 도와주는 퍼사드(facade)에 불과하다.
퍼사드는 GoF 디자인 패턴 중 하나로서 복잡한 서브 시스템을 쉽게 사용할 수 있도록 간단하고 통일된 인터페이스를 제공한다.따라서 퍼사드를 이용하면 복잡한 로깅 프레임워크의 구조는 몰라도 쉽게 사용할 수 있으며, 프레임워크의 의존성이 낮아지기 때문에 쉽게 교체할 수 있다.
지금부터 SLF4J를 좀 더 심도 있게 알아보겠다.
참고로 이후 내용 중 일부를 이 블로그에서 참조했다. 그리고 해당 블로그의 글은 slf4j 공식 사이트 설명을 직역한 보인다. 만약 지금부터 하는 설명이 이해가 안되면 해당 블로그 혹은 slf4j 사이트에 가서 부족한 부분을 참고하길 바란다.
SLF4J는 세가지 구성요소를 갖는다.
SLF4J Binding은 SLF4J 인터페이스( = SLF4J API )를 로깅 구현체와 연결하는 어댑터 역할의 라이브러리다.
Slf4j Binding과 그 구현체는 반드시 classpath에 동시에 있어야 하며,
SLF4J Binding 라이브러리는 반드시 하나만 classpath에 존재해야 한다.
로깅을 하는 구현체를 하나로 통일하려고 쓰는 Slf4j인데, 구현체를 연결해주는 Binding을 여러개 둔다는 건 말이 안된다.
만약에 하나도 class path에 없다면, no operation으로 설정된다. 즉 아무것도 출력이 안된다는 의미다.
SLF4J binding의 종류는 대략 다음과 같다.
slf4j-log4j12-{version}.jar
: log4j 버전 1.2에 대한 바인딩
slf4j-jdk14-{version}.jar
: java.util.logging(JDK1.4 로깅)에 대한 바인딩
slf4j-nop-{version}.jar
: NOP에 대한 바인딩. 모든 로깅을 자동 삭제
slf4j-simple-{version}.jar
: 모든 이벤트를 System.err에 출력하는 단순 구현에 바인딩
slf4j-jcl-{version}.jar
: JCL(Jakarata Commons Logging)에 대한 바인딩. 모든 SLF4J 로깅을 JCL에 위임
logback-classic-{version}.jar
:
SLF4J 인터페이스를 직접 구현한 것. 참고로 ch.qos.logback.classic.Logger 클래스는 SLF4J의 org.slf4j.Logger 인터페이스를 직접 구현한 클래스다.
SLF4J 이외의 다른 로깅 API에서의 Logger 호출을 SLF4J 인터페이스로 연결(redirect) 하여 SLF4J API가 대신 처리할 수 있도록 하는 일종의 어댑터 역할을 하는 라이브러리다.
프로젝트에는 다양한 Component들이 있을 수 있고, 일부는 SLF4J 이외의 로깅 API에 의존할 수 있다. 이러한 상황을 처리하기 위해서 SLF4J에는 여러 Bridging Module이 제공된다.
SLF4J 가 제공하는 Bridge는 다음과 같다.
jcl-over-slf4j.jar
: JCL API 에 의존하는 클래스들을 수정하지 않고, JCL로 들어오는 요청을 jcl-over-slf4j
를 이용해서 SLF4J API가 처리하도록 한다.
log4j-over-slf4j.jar
: log4j 호출을 slf4j api 가 처리하도록 한다.
jul-to-slf4j.jar
: java.util.logging
호출을 slf4j api 가 처리하도록 한다.
log4j-over-slf4j, log4j-to-slf4j
? 아래 스택 오버플로우 글을 참조한다.
이런 많은 브릿지 라이브러리가 정확히 어떻게 기존 로깅 라이브러리의 호출을 redirect 시키는지, 그리고 중간중간 보이는 over
와 to
가 붙는데, 이 둘간의 차이가 뭔지를 한방에 설명하는 이 스택오버플로우 글을 한번 읽어 보고 오자.
스택 오버플로우의 글에 따르면 to
나 over
모두 SLF4J API로 Logging 작업을 redirect 시키는 기능은 같다. redirect을 구현하기 위한 방식이 다를 뿐이다.
over
의 경우에는 치환하려는 로깅 프레임워크의 핵심 클래스들을 똑같이 만들어서 대신 쓰이도록 하는 것이며, 내부적으로 slf4j-api이 호출되도록 한다.
to
의 경우는 Handler 클래스를 사용해서 redirect를 수행한다.
브릿지와 바인딩 모듈은 같은 로깅 프레임워크와 관련된 jar를 사용하지 않도록 한다.
(log4j 관련 bridge : log4j-over-slf4j / log4j 관련 binding : slf4j-log4j)
아래처럼 하는 방식을 생각해두자.
slf4j-api
logback-classic
, slf4j-log4j12
등logback-core
, log4j-core
등log4j-over-slf4j
, jcl-over-slf4j
, jul-to-slf4j
등중요! Logging Framework를 변경하고 싶으면 2,3번을 동시에 교체한다.
사실상SLF4J Binding
과Logging Framework
은 한 몸이다.
이론적인 것을 어느정도 알았으니, 실전이다.
gradle 빌드툴을 사용해서 의존성을 추가하고, 간단하게 테스트 코드를 작성하겠다.
일단 아래와 같이 gradle.build 파일을 작성하겠다.
plugins {
id 'java'
}
group 'org.example'
version '1.0-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
dependencies {
implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.30'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}
test {
useJUnitPlatform()
}
참고
앞으로 로그를 찍을 때 log.trace, log.debug, log.info, log.warn, log.error 를 쓴다.
이렇게 나뉘는 이유는 로그의 중요도를 다르게 하기 위함이다.trace ==> debug ==> info ==> warn ==> error 순으로 중요도가 높아진다.
정말 치명적인 에러의 경우에는 log.error 를 하고
단순히 애플리케이션 사용 통계를 위한 거면 log.info 를 사용하면 된다.
테스트 코드
package me.sickbbang.logging_test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LogTest {
private final static Logger log = LoggerFactory.getLogger(LogTest.class);
public void run() {
log.trace("trace!!!!!");
log.debug("debug!!!!");
log.info("info!!!!!");
log.warn("warn!!!!!");
log.error("error!!!!");
}
public static void main(String[] args) {
new LogTest().run();
}
}
실행 결과
에러의 내용을 보면 알겠지만, LoggerBinder 가 없어서 나는 에러다.
즉 slf4j-api에 사용될 실제 구현체(로깅 프레임워크)와 바인딩을 하는 Binder가 없다는 뜻이다.
build.gradle 에서 dependencies{ ~ } 을 다음과 같이 수정한다.
dependencies {
implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.30'
implementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}
사실은 logback-classic 의존성만 추가해도 slf4j-api, logback-core 의존성이 자동 추가된다. 하지만 빌드툴의 의존성 전이 메커니즘에 의해 버전이 잘못 들어가는 것을 방지하기 위해서 이렇게 했다.
의존성 현황
테스트 코드 재실행 결과
테스트 코드를 실행하면 정상적으로 log가 찍히는 것을 확인할 수 있다.
그런데 log.trace가 출력이 안된다. trace가 나오게 하는 것은 뒤에서 xml을 통한 로그 레벨 설정에서 알아보겠다.
build.gradle 에서 dependencies{ ~ }을 다음과 같이 수정한다.
dependencies {
implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.30'
implementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3'
implementation group: 'org.apache.logging.log4j', name: 'log4j-to-slf4j', version: '2.13.3'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}
테스트 코드
package me.sickbbang.logging_adapter_test;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class LogAdapterTest {
private final static Logger log = LogManager.getLogger(LogAdapterTest.class);
public static void main(String[] args) {
log.info("안녕");
}
}
테스트 코드에서는 Log4j 프레임워크의 api를 사용했다.
하지만 브릿지를 사용하고 있으므로 실제 Logger 동작은 SLF4J API
==>
logback-classic
을 사용하게 된다.
실행 결과
성공 😊
logback의 동작 및 로그 패턴을 설정하고 싶다면 logback.xml 파일을 작성해야 한다.
실습을 위해 src/main/resources/logback.xml
경로에 파일을 생성해준다.
logback.xml
을 다음과 같이 작성한다.
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="consoleAppender" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
<encoder>
<pattern>%d{yyyy/MM/dd HH:mm:ss.SSS} %-5level --- [%thread] %logger[%method:%line] - %msg %n</pattern>
</encoder>
</appender>
<!-- me.sickbbang 패키지와 서브 패키지 모두 이 로거가 Logging 처리를 할 것이다. -->
<!-- additivity 는 아래 보이는 <root> (Root Logger)의 Appender에 대한 상속 유무를 뜻한다. -->
<!-- 자세한 내용은 이후에 설명하겠다. -->
<logger name="me.sickbbang.logging_test" level="DEBUG" additivity="false">
<appender-ref ref="consoleAppender" />
</logger>
<!-- 기본 로거 ( Root Logger ) -->
<!-- 앞서 logger는 특정 패키지 (혹은 클래스) 를 위한 거였다면, root 는 모든 패키지를 의미한다. -->
<!-- level 미지정시 debug 기본 값사용 -->
<root level="warn">
<appender-ref ref="consoleAppender" />
</root>
</configuration>
작성 후, 테스트를 위해서 다음과 같이 패키지 및 Class를 구성해준다.
3개의 클래스에 대해서 작성한다.
package me.sickbbang.logging_test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LogTest {
private final static Logger log = LoggerFactory.getLogger(LogTest.class);
public void run() {
log.trace("trace!!!!!");
log.debug("debug!!!!");
log.info("info!!!!!");
log.warn("warn!!!!!");
log.error("error!!!!");
}
}
package me.sickbbang.bravo_log;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class BravoLogTest {
private static final Logger log = LoggerFactory.getLogger(BravoLogTest.class);
public void run() {
log.trace("trace!!!!!");
log.debug("debug!!!!");
log.info("info!!!!!");
log.warn("warn!!!!!");
log.error("error!!!!");
}
}
package me.sickbbang;
import me.sickbbang.bravo_log.BravoLogTest;
import me.sickbbang.logging_test.LogTest;
public class MainRunner {
public static void main(String[] args) {
LogTest logTest = new LogTest();
BravoLogTest bravoLogTest = new BravoLogTest();
System.out.println("logTest.....");
logTest.run();
System.out.println("bravoLogTest.....");
bravoLogTest.run();
}
}
다 작성하고 나서 MainRunner의 main 메소드를 실행하면 다음과 같은 결과가 나온다.
실행 결과
실행 결과를 보면 <logger>
의 name
속성으로 준 me.sickbbang.logging_test
패키지는 logger level
에서 지정한 DEBUG
이상의 레벨만 출력하는 것을 볼 수 있다.
반면에 logger
로 지정하지 않은 me.sickbbang.bravo_log
패키지는 <root>
(= root logger
)에서 지정한 기본값이 적용된다. 현재는 root logger
의 level
이 warn
이므로 위 그림처럼 warn
, error
로그가 출력된다.
다음으로는 logback.xml
에서 작성했던 <root>
와 <logger>
태그 간의 상속 관계를 알아보겠다.
logback.xml
의 <logger>
태그는 이름 규칙에 의한 부모, 자식 상속 구조를 갖는다.
아래 그림을 보자.
그림을 보면 root logger
가 항상 최상단의 부모 logger이고,
그 이후로는 로거의 이름에 .
으로 부모 자식이 나뉘는 것을 확인할 수 있다.
이름에 의해서 부모 자식이 구별되고 나면 자식 logger
는 부모 logger
의 level
를 기본으로 상속 받는다. 하지만 logger
스스로가 level
을 명시했다면 부모의 level을 무시한다.
위 말이 이해가 안된다면 다음 예제를 보자.
(예제 참고: https://logback.qos.ch/manual/architecture.html#effectiveLevel)
- Example 1
Logger name | Assigned level | Effective level |
---|---|---|
root | DEBUG | DEBUG |
X | none | DEBUG |
X.Y | none | DEBUG |
X.Y.Z | none | DEBUG |
In example 1 above, only the root logger is assigned a level. This level value, DEBUG, is inherited by the other loggers X, X.Y and X.Y.Z
- Example 2
Logger name | Assigned level | Effective level |
---|---|---|
root | ERROR | ERROR |
X | INFO | INFO |
X.Y | DEBUG | DEBUG |
X.Y.Z | WARN | WARN |
In example 2 above, all loggers have an assigned level value. Level inheritance does not come into play.
- Example 3
Logger name | Assigned level | Effective level |
---|---|---|
root | DEBUG | DEBUG |
X | INFO | INFO |
X.Y | none | INFO |
X.Y.Z | ERROR | ERROR |
In example 3 above, the loggers root, X and X.Y.Z are assigned the levels DEBUG, INFO and ERROR respectively. Logger X.Y inherits its level value from its parent X.
- Example 4
Logger name | Assigned level | Effective level |
---|---|---|
root | DEBUG | DEBUG |
X | INFO | INFO |
X.Y | none | INFO |
X.Y.Z | none | INFO |
In example 4 above, the loggers root and X and are assigned the levels DEBUG and INFO respectively. The loggers X.Y and X.Y.Z inherit their level value from their nearest parent X, which has an assigned level.
표만 봐도 뭔 내용인지 감이 올 것이다.
다음은 <logger>
태그와 별개로 만들었던 <appender>
태그에 대해서 알아보겠다.
위에서 Logger를 열심히 설명했지만, 정작 <logger>
스스로는 아무것도 못한다.
<logger>
는 단순히 로그를 출력하고자 하는 패키지 위치와 level을 지정해주는 게 전부이다.
<logger>
가 로그를 기록하는 대상(파일, 콘솔, DB 등등)를 지정하는 것은 <appender>
이다.
하나의 <logger>
에는 자식 태그인 <appender-ref>
태그를 통해서 appender를 포함시킨다,
이렇게 함으로써 다양한 위치(콘솔, 파일, DB 등)에 로그를 동시에 기록할 수 있다.
그런데 위에서 작성한 logback.xml
에서 아래와 같은 스크립트를 봤을 것이다.
<logger name="me.sickbbang.logging_test" level="info" additivity="false">
<appender-ref ref="consoleAppender" />
</logger>
여기서 봐야될 것은 additivity이다. 이 additivity의 true/false 값에 따라서
부모 logger의 Appender들을 상속 받을지 안 받을지를 결정하게 된다.
참고로 additivity는 설정을 안하면 기본으로 true 가 지정된다.
위의 말이 이해가 안된다면 logback 공식 사이트에서 제공하는 예제를 보자(아래 사진).
예제를 보면 대충 알겠지만, 번역하면 이런 내용이다.
root logger에는 additivity 속성을 부여할 수 없다.
additivity="true"(기본값) 이면 모든 부모 logger로부터 Appender를 상속 받는다.
상속을 받은 logger는 자신이 갖고 있던 ( Appender + 상속받은 Appender )를 로깅 대상으로 간주한다.
additivity="false" 를 지정한 logger는 자신이 갖고 있는 Appender만을 사용해서 로깅을 한다.
additivity="false" 를 지정한 logger의 자식 logger는 additivity="false"인 부모까지만 Appender를 상속받는다.
당장 이해가 안되도, 천천히 다시 보고 이해하려고 노력하자.
우리가 기존에 작성했던 테스트 logback.xml
을 살짝 수정하면 위의 내용이 사실임을 알 수 있다. logback.xml 일부를 다음과 같이 수정한다.
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="consoleAppender" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy/MM/dd HH:mm:ss.SSS} %-5level --- [%thread] %logger[%method:%line] - %msg %n</pattern>
</encoder>
</appender>
<!-- level="info" , additivity="true" 로 변경 -->
<logger name="me.sickbbang.logging_test" level="info" additivity="true">
<appender-ref ref="consoleAppender" />
</logger>
<!-- level="error" 로 변경 -->
<root level="error">
<appender-ref ref="consoleAppender" />
</root>
</configuration>
이렇게 작성하고 <logger name="me.sickbbang.logging_test">
의 출력 결과를 예측해보자. 어떻게 될까?
어렵다면 위에서 설명한 level 상속과 Appender 상속을 차례대로 생각하면 된다.
일단 level
상속부터 생각하자.
logger
자신이 level
을 지정하면 부모 logger
가 어떤 level
을 하든 무시한다.
그러므로 logger의 level은 info 가 된다.
다음으로 Appender
상속을 생각하자.
현재 logger는 additivity=true 인 상태다. 그러므로 부모 logger의 Appender를 상속 받는다. 그런데 같은 appender인데도 상속을 받을까?
그렇다. 중복이 되더라도 additivity=true 이면 상속을 받는다.
테스트를 해서 위의 예측이 맞는지 알아보자.
테스트 코드
package me.sickbbang.logging_test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LogTest {
private final static Logger log = LoggerFactory.getLogger(LogTest.class);
public void run() {
log.trace("trace!!!!!");
log.debug("debug!!!!");
log.info("info!!!!!");
log.warn("warn!!!!!");
log.error("error!!!!");
}
public static void main(String[] args) {
new LogTest().run();
}
}
실행 결과
1. level = "info"
2. consoleAppender 2개 사용, 하나는 자신의 것, 다른 하나는 부모의 것
1,2 번을 생각하면 위의 결과가 맞다.
그렇다면 위처럼 중복되는 결과를 원하지 않는다면 어떡할까?
간단하다. logger 속성에서 additivity="false"
를 해주면 끝이다.
logback.xml 일부 수정
<logger name="me.sickbbang.logging_test" level="info" additivity="false">
<appender-ref ref="consoleAppender" />
</logger>
실행결과
root와 logger를 나누면 전체와 부분을 나누어서 로깅을 할 수 있다.
root logger 에는 전체적으로 해야되는 logging을 맡기고
logger에는 특정 패키지에서 root와는 다른 방식으로 로깅을 해야하면 그것을 지정해주면 된다.