고수들이 절대 가르쳐 주지 않는 C/C++ 프로그래밍 팁 #12 - 로깅 라이브러리 SimpleLogSink&LoggingEvent 클래스 구현

Posted at 2007. 8. 27. 07:22 // in S/W개발/고절가주팁 // by 김윤수


고절가주팁 열두번째 시간입니다. (로깅 라이브러리 관련된 글을 처음 보시는 분들은 본문에 나와 있는 로깅 라이브러리를 클릭해 보시면 이전 글들을 모두 보실 수 있습니다). 고절가주팁 #11까지 LogSource 및 매크로 함수를 거의 다 구현하고 Code Review 까지도 마쳤습니다.

이번에는 SimpleLogSink 를 상세하게 설계해 보도록 하겠습니다. 그런데 SimpleLogSink 설계로 넘어가기 전에 매크로 함수 중에 몇 가지를 구현하지 않은 것이 있더군요. 바로

YSLOG_STARTLOG(source, msg);
YSLOG_APPENDLOG(msg);
YSLOG_ENDLOG(source);

이 세 가지입니다. 고절가주팁 #8에서 이 세 가지 매크로 사용 예로 다음과 같은 예를 제시했었습니다.

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

이런 사용 예가 가능하게 하려면 다음과 같이 구현할 수 있을 것입니다.

#define YSLOG_STARTLOG(logSource, lvl, msg) \
  { \
    YSLog::LoggingEvent* pLogEv = 0; \
    if ((logSource).isEnabled((lvl))) { \
      pLogEv = new YSLog::LoggingEvent( \
        (logSource).module().c_str(), (lvl), __FILE__, \
        __FUNCTION__, __LINE__, ""); \

        *pLogEv << msg; \
    } \
    (void)0

#define YSLOG_APPENDLOG(msg) \
    if (pLogEv) { \
      *pLogEv << msg; \
    } \
    (void)0

#define YSLOG_ENDLOG(logSource) \
    if (pLogEv) { \
      (logSource).log(pLogEv); \
      pLogEv = 0; \
    } \
  } \
  (void)0

위 매크로 함수 구현을 자세히 살펴 보시기 바랍니다. YSLOG_STARTLOG() 에서는 '{' 로 새로운 name scope 를 열고 YSLOG_ENDLOG() 에서는 '}' 로 그 name scope 를 닫고 있습니다. 이렇게 구현한다는 것은 YSLOG_STARTLOG() 가 나오면 반드시 같은 scope 안에 YSLOG_ENDLOG() 가 나와야 한다는 것입니다. 즉, 다음과 같은 예는 불가능합니다.

if (XXX) {
  YSLOG_STARTLOG(logSource, lvl, "error occurred");
  ......
}
YSLOG_ENDLOG(logSource);


이렇게 별도의 name scope 를 만드는 이유는 임시로 사용하는 변수인 pLogEv 에 의해 다른 코드가 영향받지 않도록 하기 위한 것입니다. 만약 별도의 name scope 를 사용하지 않을 경우, 다음과 같은 코드는 컴파일되지 않을 것입니다(왜 그럴까요 ?).

YSLOG_STARTLOG(root, YSLog::TRACE, "trace #1"); // 여기에서 pLogEv 정의됨
YSLOG_ENDLOG(root);
YSLOG_STARTLOG(root, YSLog::TRACE, "trace #2"); // 여기에서 또 정의됨
YSLOG_ENDLOG(root);


별도의 name scope 를 사용하지 않을 경우 첫번째 YSLOG_STARTLOG() 에서 pLogEv 변수가 정의되었는데, 두 번째 YSLOG_STARTLOG() 에서 pLogEv 가 또 정의되려고 할 것이므로 컴파일에러가 발생하게 됩니다.

그리고, 또 한가지 주의할 점은 YSLOG_STARTLOG() 와 YSLOG_APPENDLOG() 에서 msg 매크로 인자에 대해 괄호를 사용하지 않고 있습니다. 보통 매크로 함수의 인자를 사용할 때는 연산자 우선 순위 규칙을 override 하기 위해 항상 괄호로 둘러싸기 마련입니다. YSLOG_STARTLOG() 와 YSLOG_APPENDLOG() 에서 msg 에 괄호를 둘러싸지 않은 이유는 다음과 같은 호출이 가능하도록 하기 위한 것입니다.

YSLOG_STARTLOG(logSource, YSLog::TRACE,
  "multiple" << "arguments" << "string");
YSLOG_APPENDLOG("continued" << "string");


이런 호출에 대해 msg 를 괄호로 둘러 쌀 경우, 위와 같은 매크로 함수 호출은 다음과 같은 코드로 치환될 것입니다.

{
  YSLog::LoggingEvent* pLogEv = 0;
  if ((logSource).isEnabled((YSLog::TRACE))) {
    pLogEv = new YSLog::LoggingEvent(
      (logSource).module().c_str(), (YSLog::TRACE), __FILE__,
      __FUNCTION__, __LINE__, "");

      *pLogEv << ("multiple" << "arguments" << "string") ;
  }
  (void)0;
  if (pLogEv) {
    *pLogEv << ("continued" << "string"
);
  }
  (void)0;

진한 색깔로 표시한 부분을 보시면 '<<' 연산자(shift 연산자)가 문자열 리터럴에 사용되고 있는 코드가 되어 버립니다. shift 연산자는 두 파라미터 모두 정수형 타입이어야 하죠. 그러니 당연히 컴파일 에러가 나게 될 것입니다. 이런 문제를 피하기 위해 msg 파라미터를 괄호로 둘러 싸지 않은 것입니다. 그런데 이 매크로를 잘못 사용하게 되면 에러가 발생할 가능성이 높아집니다. 예를 들어, 프로그래머가 다음과 같은 코드를 작성했다고 해 보죠.

YSLOG_STARTLOG(logSource, YSLog::TRACE, "value = ");
YSLOG_APPENDLOG(1 << 2);
YSLOG_ENDLOG(logSource);


코드로 보건데 YSLOG_APPENDLOG() 주어진 인자는 1 << 2 인데, 프로그래머는 1 을 좌측으로 2비트만큼 shift 한 값을 출력하고 싶었을 것입니다. 그런데 실제로 생성되는 코드는 다음과 같겠지요.

if (pLogEv) {
  *pLogEv << 1 << 2;
}
(void)0;


위 코드의 의미는 1 이라는 정수를 출력하고, 다시 2라는 정수를 출력하라는 뜻이 됩니다. 프로그래머의 의도와는 전혀 다른 의미가 되어 버렸습니다.

여러분이라면 이 문제를 어떻게 해결하시겠어요 ? 그냥 에러가 잘 발생할 수 있는 가능성을 감수하고 프로그래머용 문서에 조심하라고 써 두는 정도로 하는 게 나을까요 ? 아니면 YSLOG_STARTLOG() 와 YSLOG_APPENDLOG() 에 msg 인자에 '<<' 을 쓰지 못하도록 해 버리는 게 나을까요 ? 사용성과 잘못된 사용 예방 두 가지 특성간의 충돌이네요.

unconditionalLog() 의 접근 권할을 결정할 때도 이와 마찬가지로 이 두 가지 특성이 충돌했었는데요... 그때 unconditionalLog() 를 public 으로 선언했습니다. 이 경우에도 에러가 발생할 수 있는 가능성을 감수하고, 그냥 현재의 인터페이스를 유지하도록 하겠습니다. 그리고, YSLOG_APPENDLOG() 에 대해서는 약간 gray box approach 를 사용해서 그 implementation 을 프로그래머용 문서에 공개하면서 잘못되는 경우가 발생할 수 있다는 것을 주지하도록 하겠습니다. 결국 이 경우에도 프로그래머에 대한 믿음을 유지한 것이죠.

YSLOG_STARTLOG(), YSLOG_APPENDLOG(), YSLOG_ENDLOG() 이 세 가지 구현에 대한 논의에 너무 많은 시간을 보냈네요. 그럼 이번 글의 본 주제인 SimpleLogSink 설계로 넘어가도록 하겠습니다.

고절가주팁 #8에서 SimpleLogSink 의 인터페이스는 다음과 같이 설계했었습니다.

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


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


위에 제시된 인터페이스 사용 예에서는 생성자의 인자로 file output stream 을 받았지만, 가장 일반적으로 사용될 수 있는 간단한 output stream 은 cout 과 cerr 일 것입니다. 따라서 file output stream 으로 한정하기 보다는 좀 더 일반적인 output stream 을 생성자의 인자로 받아들이는 것이 좋을 것 같습니다.

그리고, publish() 멤버 함수는 LogListener 에서 상속받아서 구현해야겠지요. 이런 고려사항을 반영해서 설계한 인터페이스를 UML 로 나타낸다면 다음과 같을 것입니다.

SimpleLogSink 인터페이스

SimpleLogSink 인터페이스


이번에는 SimpleLogSink 의 세부적인 구현을 생각해 보겠습니다. publish() 가 호출될 때마다 생성시에 인자로 주어진 output stream 에 계속해서 출력해야 할 것이므로 이걸 기억하고 있어야 할 것입니다. 이걸 위한 멤버 변수가 필요하겠네요. 이걸 고려해서 UML 로 다시 표현하면 다음과 같을 것입니다.

SimpleLogSink 설계 UML

SimpleLogSink 설계 UML

마지막으로 SimpleLogSink 의 publish() 멤버 함수가 해야할 일을 생각해 보면 결국 LoggingEvent 가 포함한 정보들을 적당한 형식으로 output stream 에 출력하는 일일 것입니다. LoggingEvent 가 포함하고 있는 정보는 모듈, 로깅 수준, 함수명, 파일, 라인번호, 부가 메시지입니다. 이런 정보들을 읽어서 적당히 출력해야 하는 것이지요. 여러분은 어떤 형식으로 출력하실 건가요 ? 저는 그냥 다음과 같은 형식으로 출력하려고 합니다. 기본적으로 디버깅 시에 유용한 형식입니다.

[DEBUG] module.func() at filename::lineno - msg\n

이런 내용을 실제로 구현하면 다음과 같이 할 수 있을 것입니다.

// LogListener.h
#ifndef LOGLISTENER_H
#define
LOGLISTENER_H

namespace YSLog {

class LoggingEvent;

struct LogListener {
  virtual bool publish(LoggingEvent* pLogEv) = 0;
};

}

#endif //
LOGLISTENER_H

// SimpleLogSink.h
#ifndef SIMPLELOGSINK_H
#define SIMPLELOGSINK_H

#include <iostream>
#include "LogListener.h"
#include "LoggingEvent.h"

namespace YSLog {

class SimpleLogSink : public LogListener {
private:
  ostream* _os;

public:
  SimpleLogSink(ostream* os) : _os(os)
  {
  }
  virtual bool publish(LoggingEvent* pLogEv);
};

}

#endif // SIMPLELOGSINK_H

// SimpleLogSink.cpp
#include <sstream>
#include "SimpleLogSink.h"

namespace YSLog {

// 로깅 수준을 나타내는 문자열 정의
static const char * pLevelStrList[6] = {
  "DEBUG",
  "TRACE",
  "INFO",
  "WARN",
  "ERROR",
  "FATAL"
};

bool SimpleLogSink::publish(LoggingEvent* pLogEv)
{
  ostringstream oss;

  // 적당한 형식에 따라 출력. LoggingEvent 에 module(), level(), file(),
  // func(), line() 등이 정의되어 있다고 가정
  oss << "[" << pLevelStrList[pLogEv->level()] << "] "
    << pLogEv->module() << "." << pLogEv->func()
    << "() at " << pLogEv->file() << "::" << pLogEv->line() << " - " <<
    pLogEv->str() << endl;
  *_os << oss.str();

  delete pLogEv;  // LoggingEvent 에 대한 소유권이 넘어 왔으므로
                  // 여기에서 메모리를 해제할 책임이 있음

  return true;
}

};

SimpleLogSink 는 워낙 하는 일이 간단한지라 위 코드에서 제시된 Comment 만으로 설명이 충분할 것 같습니다.

그럼 이제 LoggingEvent 를 설계하고 구현해 보도록 하지요.

LoggingEvent 는 고절가주팁 #8에서 ostringstream 처럼 작동하게 해보기로 했었습니다. ostringstream 처럼 작동하게 할 수 있는 가장 좋은 방법은 ostringstream 으로부터 상속을 받는 것이겠지요. 그리고, 생성자에는 모듈명, 로깅 수준, 함수명, 파일명, 라인번호, 메시지 등의 인자를 주기로 했었고, SimpleLogSink 를 구현하면서 module(), level(), file(), func(), line() 등의 멤버 함수를 정의하기로 했습니다. 이상을 종합해서 구현해 보면 다음과 같을 것입니다.

#ifndef LOGGINGEVENT_H
#define LOGGINGEVENT_H

#include <sstream>
#include <string>

using namespace std;

namespace YSLog {

// LoggingEvent.h 가 LogSource.cpp 및 SimpleLogSink.cpp 에서 모두
// 참조되므로 여기에 LogLevel 정의!
enum LogLevel {
  DEBUG = 0,
  TRACE = 1,
  INFO = 2,
  WARN = 3,
  ERROR = 4,
  FATAL = 5
};

class LoggingEvent : public ostringstream {
private:
  string _module;
  LogLevel _level;
  string _file;
  string _func;
  int _line;

  // Prohibit invocation of default constructor.
C++ 이야기 다섯번째 참조
  LoggingEvent();

public:
  LoggingEvent(
    const char * mod, LogLevel lvl, const char * file,
    const char * func, int line,
    const char * msg = ""
  ) :
    ostringstream(), _module(mod), _level(lvl), _file(file), _func(func), _line(line)
  {
    *this << msg;
  }

  string module() const
  {
    return _module;
  }

  LogLevel level() const
  {
    return _level;
  }

  string file() const
  {
    return _file;
  }

  string func() const
  {
    return _func;
  }

  int line() const
  {
    return _line;
  }

};

}

#endif // LOGGINGEVENT_H

이 정도로 해서 LogSource, LoggingEvent, SimpleLogSink 를 모두 구현했으니 간단하게 사용할 수 있는 로깅 라이브러리를 구현해 본 셈입니다. 이걸 테스트할 수 있는 코드를 간략하게 만들었습니다.

#include <iostream>
#include <fstream>
#include <sstream>
#include "LogSource.h"
#include "SimpleLogSink.h"

using namespace std;

int
main(void)
{
  // 로깅 라이브러리 setup
  YSLog::LogSource root("Root", true, YSLog::DEBUG);
  YSLog::SimpleLogSink sls(new ofstream("log.txt",
    ios_base::out | ios_base::app));

  // To the file only
  root.subscribe(&sls);

  YSLog::LoggingEvent* pLogEv = new YSLog::LoggingEvent(
    "Root", YSLog::DEBUG, __FILE__, __FUNCTION__, __LINE__,
    "Test Logging");
  *pLogEv << " ===> Wow! It's working";
  root.log(pLogEv);

  root.log(YSLog::INFO, __FILE__, __FUNCTION__, __LINE__,
    "Test Logging #2");

  YSLOG_DEBUG(root, "Error occurred, error code = 100");
  YSLOG_TRACE(root, "Error occurred, error code = 100");
  YSLOG_INFO(root, "Error occurred, error code = 100");
  YSLOG_WARN(root, "Error occurred, error code = 100");
  YSLOG_ERROR(root, "Error occurred, error code = 100");
  YSLOG_FATAL(root, "Error occurred, error code = 100");

  YSLog::SimpleLogSink coutSink(&cout);

  // To both the file and the standard output
  root.subscribe(&coutSink);

  YSLOG_DEBUG(root, "Error occurred, error code = 100");
  YSLOG_TRACE(root, "Error occurred, error code = 100");
  YSLOG_INFO(root, "Error occurred, error code = 100");
  YSLOG_WARN(root, "Error occurred, error code = 100");
  YSLOG_ERROR(root, "Error occurred, error code = 100");
  YSLOG_FATAL(root, "Error occurred, error code = 100");

  // to the standard output only
  root.unsubscribe(&sls);

  YSLOG_STARTLOG(root, YSLog::FATAL, "");
  YSLOG_APPENDLOG("Test Logging #3 - " << "Error code = " << 204);
  YSLOG_ENDLOG(root);

  YSLOG_STARTLOG(root, YSLog::INFO, "Starting -");
  YSLOG_APPENDLOG(endl << "step #1" << endl);
  YSLOG_APPENDLOG("step #2" << endl);
  YSLOG_APPENDLOG("step #3" << endl);
  YSLOG_ENDLOG(root);

  return 0;
}

한 번 지금까지 작성한 코드를 컴파일해서 잘 수행되는지 확인해 보시면 좋을 것 같습니다. 제가 중간에 일부러 심각한 문제를 하나 고치지 않고 넘어 간 게 있는데, 그걸 찾아 보시는 것도 좋을 것 같습니다.

다음 글에서는 CppUnit을 써서 로깅 라이브러리를 단위 테스트하는 걸 해 보도록 하겠습니다. 이걸 하다 보면 아마 앞에서 말한 문제점이 나타나지 않을까 싶습니다.

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

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