고수들이 절대 가르쳐 주지 않는 C/C++ 프로그래밍 팁 #8 - 로깅 라이브러리 API 설계

Posted at 2007. 8. 14. 09:55 // in S/W개발/고절가주팁 // by 김윤수


고절가주팁 여덟번 째 글입니다. 고절가주팁도 어느덧 이만큼 왔네요. 이번 글에서는 LogPublisher 와 LogListener 인터페이스를 구현하는 구체 클래스로 LogSource 와 SimpleLogSink 및 이 둘 간의 주고 받을 데이터에 해당하는 LoggingEvent 의 프로그래밍 인터페이스를 설계해 보도록 하겠습니다(로깅 라이브러리 관련된 글을 처음 보시는 분들은 본문에 나와 있는 로깅 라이브러리를 클릭해 보시면 이전 글들을 모두 보실 수 있습니다).

원래 제가 생각했던 로깅 라이브러리의 기본 개념은 다음과 같이 비교적 복잡한 과정을 거치는 것이었지만

로깅 라이브러리 기본 개념

로깅 라이브러리 기본 개념


다음과 같이 간략하게 작동하는 것도 가능할 것입니다.

로깅 라이브러리 연결 예1

로깅 라이브러리 연결 예1


그렇다면 복잡한 과정을 다 거치는 모델을 처음부터 무리하게 다 구현하기 보다는 기능이 풍부하진 않더라도 일단 간단하게나마 동작하는 것부터 구현해 보고, 잘 작동하는 지 확인해 본 후에 대상이 되는 사용자들에게 빠르게 릴리즈해서 피드백 받고... 이렇게 빠르게 개발 싸이클을 돌리다 보면 로깅 라이브러리가 빠르게 발전할 수 있을 것입니다.

그래서 먼저 LogPublisher 인터페이스를 구현하는 구체 클래스 LogSource 와 LogListener 인터페이스를 구현한 구체 클래스 SimpleLogSink를 설계해 보고, 이 둘 간에 주고 받는 로그 데이터를 의미하는 LoggingEvent 클래스를 설계하고자 합니다.

자~ 그럼 어디서부터 설계를 시작할까요 ? 설계의 시작점은 다양할 수 있겠지만 라이브러리를 설계한다면 프로그래밍 인터페이스부터 설계를 시작하는 것이 바람직하다고 생각합니다. 왜냐면 라이브러리란 그 자체로서 유용하기 보다는 응용 프로그래머가 사용했을 경우에 의미가 있을 것이므로 프로그래머가 사용할 인터페이스가 매우 중요한 역할을 하게 될 것이기 때문입니다.

여러분은 로깅 라이브러리프로그래밍 인터페이스를 어떻게 설계하시겠습니까 ?

우선 LogSource 부터 생각해 보지요. LogSource 를 생성할 수 있는 방법이 있어야 할 텐데... LogSource 는 어떤 인터페이스로 생성해야 할까요 ? 고절가주팁 #6 에서 LogSource 는 모듈과 일치하는 개념으로 하자고 밝혔던 걸 기억하실 겁니다. 그렇다면 LogSource 의 가장 중요한 속성은 모듈 이름이겠지요.

거기에 덧붙여 이번 설계에서는 가장 간단한 작동 모델만을 지원한다 하더라도 기본적인 필터링 기능-활성화 여부 및 로깅 수준-은 포함시켜야할 것 같습니다. 이 기본적인 필터링 기능은 LogSource 에서 직접 수행하는 것이 성능상 이로울 것으로 판단됩니다. 그렇지 않으면 SimpleLogSink 에서 필터링 기능을 수행해야 하는데... 그럴 경우 결국에는 SimpleLogSink에서 필터링될 로깅 이벤트가 불필요하게 전달될 것이기 때문입니다.

이상을 종합해 볼 때, LogSource 인스턴스 생성시에 다음과 같이 모듈명, 로깅 활성화 여부, 로깅 수준 등을 명시해야 할 것입니다.

LogSource source("Main", true, WARN);

그 다음에는 실제로 로깅하는 인터페이스를 생각해 볼까요 ? 로깅하는 인터페이스는 상당히 여러가지 대안이 나올 수 있을 것입니다. 가장 쉽게 떠오르는 것이

source.log(level, func, file, line, msg);

이런 형식이네요. 고절가주팁 #7 에서 정리한 요구사항 중 첫번째로
1. 어떤 모듈, 어떤 소스 파일의 어느 함수, 어느 위치에서 발생한 정보인지 출력할 것. 주로 디버깅 시에 유용함
가 있었다는 걸 기억하실 겁니다. 그러므로 func, file, line 등의 정보를 함께 로깅해야하는 것은 자명해 보입니다.

자~ 그런데, 조금 찬찬히 생각해 보면 source 내부적으로 결국 필터링될 로깅 데이터를 굳이 log() 로 넘겨줄 필요가 있을까라는 생각이 듭니다. 그냥 log() 를 호출하기 전에 현재 로깅 데이터가 필터링될지를 미리 체크하면 불필요한 log() 호출을 막을 수 있겠다는 생각이 들지 않으세요 ? 그렇게 하려면 다음과 같은 멤버 함수가 필요할 것으로 생각됩니다.

source.isEnabled(level);

이런 멤버 함수가 있다면, 프로그래머들이 다음과 같이 로깅하겠지요.

if (source.isEnabled(level)) {
  source.log(level, func, file, line, msg);
}


또 한 번 찬찬히 이런 인터페이스를 들여다 보시기 바랍니다. 뭔가 좀 싫지 않나요 ? 여러분이 이런 인터페이스를 써서 로깅을 한다고 생각해 보시죠. 보통은 로깅하는 코드가 상당히 여기 저기서 자주 사용될텐데... 그 때마다 source.isEnabled(level) 이런 식으로 로깅 활성화 여부를 체크한다는 게 너무 싫지 않나요 ?

라이브러리를 사용하는 프로그래머 입장에서 보면 매번 로깅하려고 할 때마다 if 문으로 체크한다는 것도 참 곤역인 것 같습니다. 로깅 라이브러리 안쓰면 그냥 printf() 로 출력하면 그만인데 말입니다. 아무래도 printf() 보다 사용성이 떨어질 것 같죠 ? 그럼 이 문제를 어떻게 해결해야 할까요 ? 저라면 다음과 같은 매크로 함수를 제공해서 그렇게 할 필요가 없도록 하겠습니다.

#define YSLOG_LOG(logSource, lvl, func, file, line, msg) \
  { \
    if ((logSource).isEnabled((lvl))) { \
      (logSource).log((lvl), (func), (file), (line), (msg)); \
    } \
  }

근데 또 고절가주팁 #4에서 알려 드렸던 __FUNCTION__, __FILE__, __LINE__ 등이 생각나네요. 이걸 굳이 func, file, line 등을 매크로 함수의 인자로 하는 것이 아니라 매크로 함수 내부에서 자동 생성하도록 바꾸는 게 여러모로 편할 것 같습니다.

#define YSLOG_LOG(logSource, lvl, msg) \
  { \
    if ((logSource).isEnabled((lvl))) { \
      (logSource).log((lvl), __FUNCTION__, __FILE__, __LINE__, \
        (msg)); \
    } \
  }

그리고, 굳이 lvl 도 직접 입력하게 하기 보다는 각 로깅 수준별 매크로 함수는 따로 두면 더 편할 것입니다.

YSLOG_DEBUG(logSource, "checkpoint #1");
YSLOG_TRACE(logSource, "entering state XXXX");
YSLOG_INFO(logSource, "consult manual for additional information");
YSLOG_WARN(logSource, "XXX should be YYY");
YSLOG_ERROR(logSource, "Error occurred");
YSLOG_FATAL(logSource, "Can't recover from XXX. Exiting...");


이 정도면 얼추 LogSource 를 위한 프로그래밍 인터페이스 설계가 마무리 된 듯했지만... 제 생각에 이런 로깅 인터페이스는 한 가지 불편한 점이 있습니다.

YSLOG_XXX() 와 같은 매크로 함수를 호출하는 시점에 msg 가 다 구성되어 있어야 한다는 점입니다. 무슨 말인고 하니 예를 들어 다음과 같이 어떤 시스템 호출을 하고 나서 에러가 났는데, 로깅 메시지를 구성하려면 몇 가지 복잡한 과정을 거쳐야 하는 경우도 있습니다.

if (some_system_call() < 0) {
  sprintf(buf, "Error occurred during some_system_call() - %s\n",
    strerror(errno));
  strcat(buf, ": calling another function");
  YSLOG_ERROR(logSource, buf);
}


이렇게 메시지를 준비하는 준비작업들이 로깅 라이브러리에 의해 지원된다면 훨씬 쉽게 로깅 라이브러리를 사용할 수 있을 것입니다. 예를 들어, 다음과 같은 프로그래밍이 가능하다면 어떨까요 ?

if (some_system_call() < 0) {
  YSLOG_STARTLOG(logSource, ERROR, "");
  YSLOG_APPENDLOG("Error occurred during some_system_call() - " <<
    strerror(errno));
  YSLOG_ENDLOG(logSource);
}

이런식으로 로깅 메시지를 점진적으로 구성할 수 있도록 하기 위해서는 ostringstream 클래스를 쓰는 것이 좋을 것 같습니다. ostringstream 은 마치 string 을 output stream 처럼 쓸 수 있게 해주는 클래스이기 때문입니다. ostringstream 을 이용하면 다음과 같은 프로그래밍이 가능합니다.

#include <iostream>
#include <sstream>

using namespace std;
...
ostringstream oss;

oss << "Error occurred - " << strerror(errno) << endl;
oss << "Please, try to use another function" << endl;
cout << oss.str();


이와 비슷하게 LoggingEvent 도 다음과 같이 ostringstream 처럼 작동하게 한다면 어떨까요 ?

LoggingEvent logEv;

logEv << "Error occurred - " << strerror(errno) << endl;
logEv << "Please, try to use another function" << endl;
cout << logEv.str();


그리고, LogSource 의 log() 멤버 함수로 LoggingEvent 포인터를 받는 것도 있으면 좋을 것 같습니다.

LogSource root("Main");

LoggingEvent *pLogEv = new LoggingEvent(root.module(), ERROR,
  __FILE__, __FUNCTION__, __LINE__, "Error occurred - ");
*pLogEv << strerror(errno) << endl;
*pLogEv << "Please, try to use another function" << endl;
source.log(pLogEv);


로깅의 정확한 위치를 알 수 있으려면 당연히 LoggingEvent 생성자에 모듈명, 파일명, 함수명, 라인수 등의 인자가 주어져야 할 것이고, 덧붙여 로깅 메시지의 수준과 메시지도 함께 인자로 주어져야 할 것입니다. 모듈명의 경우 LogSource에 module() 이라는 멤버 함수를 둬서 알아낼 수 있도록 하면 될 것 같습니다. 위 코드를 아까 정의했던 매크로 함수로 바꾸면

LogSource root("Main");

YSLOG_STARTLOG(root, "Error occurred - ");
YSLOG_APPENDLOG(strerror(errno) << endl);
YSLOG_APPENDLOG("Please, try not to ...");
YSLOG_ENDLOG(root);


위와 같이 작성할 수 있을 것입니다. LogSource 에 LoggingEvent 를 넘겨 주면 넘겨 받은 쪽으로 소유권이 넘어 가는 방식으로 작동하게 하겠습니다. LoggingEvent 가 LogSource, LogFilter, LogFormatter, LogSink 등을 거치면서 계속해서 흘러가는 개념을 표현하기에는 소유권 이전 개념이 적절해 보이기 때문입니다. 이걸 달리 표현하면 log() 멤버 함수를 호출하는 쪽에서는 pLogEv 를 위해 할당된 메모리를 릴리즈해서는 안된다는 얘기입니다. 더 이상 자기 데이터가 아니기 때문이죠.

마지막으로 LogSource 에 로깅 메시지를 log() 하면 출력될 목적지가 있어야 할텐데요. 이건 지난 글에서 설명했던 것처럼 LogListener 인터페이스를 구현하는 구체 클래스 SimpleLogSink 가 그 역할을 담당하도록 하겠습니다.

SimpleLogSink 클래스는 어떤 식으로 인터페이스를 설계해야 할까요 ? 가장 간단한 경우로 생각할 수 있는 것은 프로그래머가 미리 생성해 놓은 output stream 을 생성시에 인자로 주면 계속해서 그 output stream 으로 출력하는 식으로 작동한다면 좋을 것 같습니다. 이걸 프로그래밍으로 표현한다면,

#include <fstream>

using namespace std;

// log.txt 라는 파일로 로깅 메시지 출력
SimpleLogSink sls(new ofstream("log.txt",
  ios_base::out | ios_base::app));


이렇게 되겠지요.

그리고, 이 SimpleLogSink 를 이용해서 실제 메시지를 출력할 때는 고절가주팁 #7에서 설계했던 대로 publish() 라는 멤버 함수를 통해 LoggingEvent 를 넘겨 주면 될 것입니다.

LoggingEvent *pLogEv = ...
sls.publish(pLogEv);


LogSource 의 log() 멤버 함수와 비슷하게 publish() 멤버 함수도 LoggingEvent 의 소유권 이전 개념을 적용하도록 해야 겠습니다.

마지막으로 LogSource 는 고절가주팁 #7에서 설계했던 대로 LogPublisher 인터페이스의 subscribe() 및 unsubscribe() 멤버 함수를 구현해야 할 것입니다. subscribe() 와 unsubscribe() 에는 당연히 LogPublisher 구체 클래스 인스턴스가 인자로 주어져야 할 것입니다.

이상을 다 합쳐 보면 다음과 같은 방법으로 이번 글에서 설계한 프로그래밍 인터페이스를 사용할 수 있습니다.

// 로깅 라이브러리 구성
LogSource root("Root", true, DEBUG);
SimpleLogSink filesink(new ofstream("log.txt",
  ios_base::out | ios_base::app));
root.subscribe(&filesink);

// 멤버 함수 직접 호출
root.log(WARN, "afunction", __FILE__, __LINE__, "Can it work ?");
LoggingEvent* pLogEv = new LoggingEvent(root.module(),ERROR,
  __FILE__, __FUNCTION__, __LINE__, "Error occurred");
root.log(pLogEv);

// 매크로 함수 사용
YSLOG_TRACE(root, "being executed");
YSLOG_FATAL(root, "Can't recover from the error. Exiting...");

YSLOG_STARTLOG(root, ERROR, "An error occurred - ");
YSLOG_APPENDLOG(strerror(errno));
YSLOG_ENDLOG(root);

이런 걸 좀 더 거창하게 말하면 프로그래밍 모델이라고 합니다. 어떤 식으로 프로그래머가 라이브러리를 사용할 수 있도록 할 것이냐 ? 하는 거죠. 어떠세요 ? 로깅 라이브러리 사용자 입장에서 볼 때 쉽게 그리고 안전하게 사용할 수 있도록 프로그래밍 인터페이스가 설계되었나요 ? 여러분의 의견을 듣고 싶네요. 제가 설계한 인터페이스를 어떻게 생각하시는지 댓글 남겨 주시면 감사하겠습니다.

다음 글에서는 LogSource, SimpleLogSink, LoggingEvent 를 깊이 들여다 보면서 상세하게 설계해 보도록 하겠습니다.

그럼 지금까지 제 글을 읽어 주셔서 감사드리고, 이 글이 맘에 드신다면 살짝 여기 저기 추천 부탁드립니다. 고절가주팁이 쭉~ 갈 수 있도록 많은 관심 부탁드립니다.

제 글이 유익하셨다면 오른쪽 버튼을 눌러 제 블로그를 구독하세요. ->
블로그를 구독하는 방법을 잘 모르시는 분은 2. RSS 활용을 클릭하세요.
RSS에 대해 잘 모르시는 분은 1. RSS란 무엇인가를 클릭하세요.