slf4j 구현체를 만들며 배우는 로깅
부끄러운 말이지만 SLF4J lombok 애노테이션을 습관처럼 쓰면서도 구체적인 내용에 대해 별로 고민해본 적이 없다. 서비스 모니터링과 알람을 고민하면서 grafana와 loki 세팅을 하며 로깅 라이브러리들을 살펴보는 와중에 SLF4J가 인터페이스라는 것을 알았다. 자주 사용하는 Logback은 SLF4J를 구현한 구현체였던 것이다.
이건 달리 보면 내가 SLF4J 의 인터페이스 형식을 이해하면 원하는 형태의 로그 적재나, 로깅 설정하면서 겪는 다양한 문제를 해결할 수 있는 실마리를 얻는 것이구나 하는 생각이 들었다. 또 grafana를 더 효과적으로 사용하기 위한 로그 라이브러리가 무엇인지 판단할 더 확실한 근거도 생길 것 같았다. 그래서 커스텀한 로깅 클래스를 만들면서 SLF4J의 구조적 이해와 직접 구현을 통해, 로깅 프레임워크의 교체 및 확장에 대한 아이디어를 얻어보기로 했다.
SLF4J 인터페이스 구현하며 이해하기
핵심 인터페이스 Logger 구현
SLF4J의 핵심은 org.slf4j.Logger
인터페이스다. 보통 우리가 코드에서 흔히 사용하는 방식은 이렇다:
import org.slf4j.Logger;import org.slf4j.LoggerFactory;
@Slf4j // Lombok annotationpublic class UserService { // @Slf4j가 아래 코드를 자동으로 생성한다 // private static final Logger log = LoggerFactory.getLogger(UserService.class);
public void createUser(String username) { log.info("Creating user: {}", username); if (username == null) { log.error("Username cannot be null"); throw new IllegalArgumentException("Username cannot be null"); } }}
이때 이 Logger 인터페이스를 구현하면, 나만의 로깅 라이브러리가 생기는 것이다. 이 인터페이스가 이미 trace, debug, info, warn, error 라는 메서드 구현을 강제하고 있다. 우리가 흔하게 접하는 로깅 레벨이 바로 여기서 구분되게 되는 것이다.
이번 구현에서는 CustomLogger
라는 인터페이스를 구현하여 로그를 System.out
으로 출력하는 형태를 만들어 보았다. 세부적으로 살펴보면, 먼저 Logger
인터페이스를 구현한 CustomLoggerAdapter
를 만들었고, 이 클래스는 CustomLogger
인터페이스를 의존한다. 그리고 CustomLoggerImpl
은 이를 구현한 클래스로, 최종적으로 System.out.printf
를 통해 로그 형식을 출력하고 있다.
// CustomLogger : Logger 인터페이스를 구현한 커스텀 로거public class CustomLoggerAdapter implements Logger { private final CustomLogger logger; private final String name;
public CustomLoggerAdapter(String name) { this.name = name; this.logger = new CustomLoggerImpl(name); }
@Override public String getName() { return this.name; }
// Trace Level @Override public boolean isTraceEnabled() { return true; }
@Override public void trace(String msg) { logger.debug(msg); // trace를 debug로 매핑 }
// ...중략}
// CustomLoggerImpl : CustomLogger 인터페이스 구현체public class CustomLoggerImpl implements CustomLogger { private final String name;
public CustomLoggerImpl(String name) { this.name = name; }
/// ... 중략
@Override public void error(String message) { log("ERROR", message); }
private void log(String level, String message) { System.out.printf("[%s] %s - %s: %s%n", Thread.currentThread().getName(), level, name, message ); }}
SPI(Service Provider Interface)를 통한 연동 추가
위의 구현만 있으면 로그가 동작할까? 아직은 아니다. SLF4J는 SPI(Service, Provider Interface)1를 통한 로깅 라이브러리 연동을 지원한다. 이를 위해 resource/META-INF/services
디렉토리에 org.slf4j.spi.SLF4JServiceProvider
파일을 만들어야 한다. 파일 안에는 SLF4JServiceProvider
의 구현체 경로를 적는다. 이 프로젝트에서는 org.cbl.clog.CustomLoggerServiceProvider
로 기입했다.
SLF4JServiceProvider
은 SLF4J가 로깅 구현체를 동적으로 탐색하고 로깅 기능을 위임할 수 있도록 지원한다. 이를 통해 SLF4J는 특정 로깅 구현체에 의존하지 않고 유연한 방식으로 다양한 로깅 프레임워크와 연동할 수 있다.
SLF4JServiceProvider
는 5개의 메서드를 구현하도록 하고 있다.
public interface SLF4JServiceProvider { ILoggerFactory getLoggerFactory(); IMarkerFactory getMarkerFactory(); MDCAdapter getMDCAdapter(); String getRequestedApiVersion(); void initialize();}
ILoggerFactory
타입을 반환하는 getLoggerFactory()
를 구현하면 이 구현체는 애플리케이션에서 로그를 남길 수 있도록 로거(Logger) 객체를 생성하고 제공하는 역할을 한다. 아래와 같은 팩토리를 반환하도록 했다.
// Logger logger = LoggerFactory.getLogger("MyLogger"); 할 떄 호출되는 것
public class CustomLoggerFactory implements ILoggerFactory { private final ConcurrentMap<String, Logger> loggerMap;
public CustomLoggerFactory() { this.loggerMap = new ConcurrentHashMap<>(); }
@Override public Logger getLogger(String name) { // computeIfAbsent를 사용하여 스레드 안전하게 로거 생성 return loggerMap.computeIfAbsent(name, CustomLoggerAdapter::new); }}
나머지는 특정 구현체를 만들지는 않았다. 특정 로깅 프레임워크가 로드되지 않으면, SLF4J는 org.slf4j.helpers
패키지에 있는 기본 구현체(예: BasicMarkerFactory
, BasicMDCAdapter
)를 자동으로 사용한다. IMarkerFactory
타입을 반환하는 getMarkerFactory()
를 통해서는 로그에 태그를 추가하는 기능을 제공하며, MDCAdapter
타입을 반환하는 getMDCAdapter()
는 로그에 컨텍스트(Context) 정보 추가하는 역할을 하는데 아직 많이 사용해보지 못했다.
결국 핵심은 추상화
이 짧은 분석을 통해 로깅 라이브러리를 만들어 보면서, 거꾸로 SLF4J 스펙을 제공하는 라이브러리라면 그 구현체를 바꾸기 위해 코드 수정이 거의 필요하지 않음을 알았다. 물론 로깅 I/O 성능, 로그 보관 정책과 같은 실무적인 요소를 고려해야 하지만, 기본적인 구조 변경에는 큰 리스크가 없다고 판단이 된다. 그러므로 로그 모니터링을 위해 JSON 형식의 로깅을 보다 쉽게 지원하는 라이브러리가 있다면, 교체를 고려해도 될 것이다.
SLF4J가 그럼 표준으로 쓰이느냐? 하는 궁금증도 있는데, 마치 자바의 스프링처럼 사실상의 표준으로 쓰이고 있는 것 같다. 스프링 또한 인터페이스를 통한 추상화와 구현체 분리로 원하는 부분을 직접 구현할 수 있도록 되어 있으니 그 점에서 SLF4J 다르지 않은 것 같다. 아래는 SLF4J가 제공하는 다양한 로깅 구현체들과 그 연동 구조를 표현한 도식이다. 이미 다양한 구현체들에 대한 연동 방식을 고민하고 있는 것 같다.
출처: SLF4J 공식 문서
마치며
별 생각없이 로그를 사용하고 있었는데 로그 인터페이스의 세계에도 흥미로운 내용이 많았다. SPI라는 방식 자체도 네이밍은 굉장히 생소했고, 이 인터페이스를 가지고 시스템 콘솔 뿐 아니라 파일 입출력에 기타 등등 원하는 기능을 넣으려면 한 세월이 걸리겠다는 결론도 얻었다. 일단 잘 만들어진 친구들을 써보도록 하자.
로그에서 또 하나 어려운 부분은 어디서 어디까지 로그를 쌓을 것인가
하는 것이다. 모 기업에서는 주 단위에 페타바이트 단위 이상의 로그가 쌓인다고 한다. 문제는 이 정도 트래픽을 가진 기업이라면 당연히 이를 받쳐주는 시스템도 있겠지만, 그렇지 않은 기업에서는 어떻게 해야할까? 디스크도 무한하지 않고, 무한정 늘리면 성능에 영향도 있다. 더욱이 로그에 대한 추적 문제도 있을 것이다. 서비스 운영하면서 '이런 로그 찍어둘껄' 하는 생각이 들었다면, 아직도 '적정 로그 적재 문제'에 봉착해 있다고 봐야할 것이다.
이번에 SLF4J에 대해 알아보며 로그 인터페이스에 대한 이해를 높였으니, 다음 번에는 좀 덜 기술적이면서 문제 해결의 관점에 가까운 '적정 로그 적재 문제'를 어떻게 나름대로 해결해보고 있는지 적어보고 싶다.
Footnotes
-
Service Provider Interface. 자바에서 제공하는 인터페이스로, 서비스 제공자가 구현해야 하는 인터페이스를 정의한다. 이를 통해 서비스 제공자는 서비스 사용자에게 서비스를 제공할 수 있다. 자바에서는 JDBC, JPA, JAX-RS 등 다양한 API에서 이를 사용한다. ↩