Discord 로 서버 error 메시지를 받아보자! (with. pm2-discord) (1)

이수인·2025년 9월 7일
1

inhu

목록 보기
3/3
post-thumbnail

안녕하세요 백엔드 개발자 이수인입니다! 오늘은 discord 로 서버 error 알림을 받아보는 방법에 대해 소개해보려 합니다. 먼저 어떻게 메시지가 오는지 궁금하신 분들을 위해 사진부터 첨부하고 포스팅 시작하겠습니다.

저희 서비스는 현재 pm2로 서버를 실행하고 있습니다. 그래서 docker로 배포하신 분들에겐 큰 도움이 안 될 수 있습니다. 하지만 docker로 배포하는 것도 고려하고 있기 때문에 docker로도 알림을 보낼 수 있게 구현할 예정입니다.

구현 계기

에러가 발생하게 되면 서버에 접속을 해서 에러 메시지를 확인해야 하죠. 하지만 이 방식은 서버에 접속해야 하는 불편함도 있지만, 에러가 발생했을 때 바로 알 수 없다는 단점이 있습니다. 이 문제를 해결하기 위해 고민을 하던 중, 저희 팀원 중 한 명이 디스코드로 에러 메시지를 받을 수 있다고 했습니다. 그러면 알림도 오고, 메시지도 확인할 수 있어서 대응할 수 있게 되죠. 저희 팀원은 '이런 방법이 있다~' 라고만 했기 때문에 어떻게 구현해야 할지 고민할 필요가 있었죠.

discord 로 어떻게 에러 메시지가 가지?

discord로 에러 메시지를 보내는 방법은 생각보다 간단합니다.

  1. 에러 메시지를 받을 채널 생성하기
  2. 서버 설정 들어가기
  3. 앱 > 연동 클릭
  4. '새 웹후크' 클릭하여 생성하기
  5. 사진, 이름, 채널 원하는 대로 수정하기
  6. '웹후크 URL 복사' 를 클릭해서 웹후크 url 복사하기 (민감한 정보이니 꼭 환경변수로 관리해야 합니다!)
  7. 해당 url로 메시지와 함께 post 요청 보내기

생각보다 원리는 간단하죠? discord 는 그냥 메시지 받고 출력하는 용도입니다.

방법1) service 함수로 구현하기?

가장 쉽게 생각해볼 수 있는 방법입니다. 위에서 설명했던 것처럼 discord 에 에러 메시지를 보내는 방법은 정말 간단합니다. 그래서 service 함수로 처리해버릴 수 있죠. 코드로 봐볼까요?

public async sendErrorMessage(
    title: string,
    message: string,
    error: Error,
    context: DiscordWebhookContext,
  ) {
    await this.httpService.axiosRef.post(
      this.ERROR_WEBHOOK_URL,
      {
        content: `# **🚨 에러 로그 알림**`,
        embeds: [
          {
            title,
            description: `${message}
  
  Error Object
  \`\`\`
  ${this.getErrorStr(error)}
  \`\`\`
                `, // Description 추가
            color: 16711680, // 빨간색 (16진수)
            author: {
              name: context,
            },
            footer: {
              text: `시간: ${new Date().toLocaleString()}`,
            },
            timestamp: new Date().toISOString(),
          },
        ],
      },
      {
        headers: {
          'Content-Type': 'application/json',
        },
      },
    );
  }

이제 에러가 발생하면 catch 해서 sendErrorMessage 함수를 호출하면 끝입니다. 어려운 방법이 아니죠.

문제

그런데 문제가 있습니다. 이 함수 호출은 NestFactory 가 create 되어야만 호출할 수 있습니다. 만약에 create 되기 전에 호출되면 에러 메시지를 보낼 수 없습니다. ioredis 설정 실패나 env read 실패 같은 것들 말이죠. 그래서 nestjs 의 create 와 상관없이 동작할 수 있는 다른 방법을 고민해볼 필요가 있었습니다.

방법2) pm2-discord 라이브러리

저희가 pm2로 서버를 돌리고 있으니 pm2 에서 해결할 수 없을까? 고민을 하고 구글링 결과 pm2-discord 라는 라이브러리를 찾을 수 있었습니다. 이 라이브러리는 nestjs 의 생성과 create와 상관없이, pm2 에서 발생하는 event 를 감지하기 때문에 제가 원했던 요구사항을 만족시켜줬습니다.
https://github.com/FranciscoG/pm2-discord

사용 방법은 정말 간단합니다. readme 를 읽어보면 알겠지만,

pm2 install pm2-discord
pm2 set pm2-discord:discord_url https://discord_url

이전에 복사해둔 웹후크 url을 설정을 하고,

  • log
  • error
  • kill
  • exception
  • restart
  • delete
  • stop
  • restart overlimit
  • exit
  • start
  • online

위 옵션 중 필요한 것들을 true/false 로 설정하면 됩니다. 간단하죠? 에러 메시지 관련을 다루고 있으니 error 옵션을 true 로 설정하면 되겠네요.

잘 되는지 테스트가 필요합니다. 저는 대충 에러 발생시키는 용도 api를 만들어봤습니다.

@Get('test-error-log')
  testErrorLog() {
    console.error(
      '서버 중단 없는 에러 로그 테스트입니다. 이 메시지가 디스코드에 보여야 합니다.',
    );

    return {
      status: 'OK',
      message:
        'Error log has been sent, but the server is still running perfectly!',
    };
  }

내용은 어떻게 작성해도 상관 없지만, console.error 는 필요합니다. 이제 이 api 를 호출하면,

이렇게 알림이 오게 됩니다.

문제) 가독성 떨어짐

그런데 이런 메시지 형태 괜찮으신가요? 지금 예시는 한 줄이라서 크게 신경 쓰이지 않을 수 있습니다. 그러면 다른 예시를 보여드릴게요.

어떤가요? 잘 읽히나요? 일단 저는 안 읽힙니다. 심지어 위 사진은 메시지가 2개 온 겁니다. 따로 감싸져서 오는 게 아니라 그냥 텍스트가 오니까 잘 읽히지 않습니다. 그리고 언제 발생한 에러인지, 어떤 process에서 발생한 에러인지 등의 필요한 정보들을 판별하기 쉽지 않습니다.

맨 처음에 보여준 사진 기억하시나요? 기억 못 하시는 분들을 위해 다시 첨부해보면,

이렇습니다. 어떤가요? 잘 읽히지 않나요?

이렇게 여러 개가 와도 구분이 잘 됩니다. 이런 형식으로 보내려면 embed 로 감싸서 보내고, 필요한 정보들을 담아서 보내야 합니다. 이렇게 보내는 게 저희의 목표입니다.

그래서 어떻게 하는 건데?

경험이 부족한 저한테 제일 쉽지 않은 문제였습니다. 위에 옵션을 보면 알겠지만, 에러 메시지를 커스텀하는 옵션은 없습니다. 그래서 처음에는 방법이 없다고 생각하고 에러 메시지를 커스텀할 수 있는 다른 라이브러리를 찾았습니다. 그런데 아무리 찾아도 없고, 된다고 하는 것들은 안 되더라고요... 결국에 고민을 하다가 pm2-discord 코드를 내가 수정하면 안 되나? 라는 생각이 들더라고요. 그래서 어떻게 수정을 했는지 공유해보려 합니다.

원본 코드 커스텀하기

원본 코드를 읽어보면 알겠지만 생각했던 것보다 코드가 간단합니다. 총 5개의 함수 discord에 메시지 보내기, buffer 관리, message queue 관리, 메시지 생성하기, 이벤트 등록하기 가 있습니다. 코드를 설명하는 글이 아니기 때문에 설명은 생략하고, 결론적으로 수정해야 할 함수는 당연하게도 discord 에 메시지 보내기, 메시지 생성하기 입니다. 원본 코드 보겠습니다.

function sendToDiscord(message) {

  var description = message.description;

  if (!conf.discord_url) {
    return console.error("There is no Discord URL set, please set the Discord URL: 'pm2 set pm2-discord:discord_url https://[discord_url]'");
  }

  var payload = {
    "content" : description
  };

  var options = {
    method: 'post',
    body: payload,
    json: true,
    url: conf.discord_url
  };

  request(options, function(err, res, body) {
    if (err) {
      return console.error(err);
    }

    if (res.statusCode !== 204) {
      console.error("Error occured during the request to the Discord webhook");
    }
  });
}

function createMessage(data, eventName, altDescription) {
  if (data.process.name === 'pm2-discord') {
    return;
  }

  if (conf.process_name !== null && data.process.name !== conf.process_name) {
    return;
  }

  var msg = altDescription || data.data;
  if (typeof msg === "object") {
    msg = JSON.stringify(msg);
  } 

  messages.push({
    name: data.process.name,
    event: eventName,
    description: stripAnsi(msg),
    timestamp: Math.floor(Date.now() / 1000),
  });
}

여기서 각각 수정할 내용은 아래와 같습니다.

sendToDiscord

  • embed 로 감싸서 보내기

createMessage

  • 원하는 메시지 형태로 커스텀하기

그렇게 어렵지 않으니 바로 코드로 보겠습니다. 수정한 부분은 주석으로 명시할게요.

function sendToDiscord(message) {
  if (!discordUrl) {
    return console.error(
      "There is no Discord URL set, please set the Discord URL: 'pm2 set pm2-discord:discord_url https://[discord_url]'"
    );
  }

  var payload = {
    content: message.content,
    embeds: [message.embed], // embed 로 감싸서 보내기
  };

  var options = {
    method: "post",
    body: payload,
    json: true,
    url: discordUrl,
  };

  request(options, function (err, res, body) {
    if (err) {
      return console.error(err);
    }

    if (res.statusCode !== 204) {
      console.error("Error occured during the request to the Discord webhook");
    }
  });
}

// 반복되는 메시지 포멧팅으로 함수 생성
function makeEmbedFormat(content, color, msgName, msg, processName) {
  return {
    content: content,
    embed: {
      color: color,
      fields: [
        {
          // 메시지 소제목
          name: msgName,
          value: `\`\`\`\n${msg}\n\`\`\``,
          inline: false,
        },
        {
          // 언제 발생한 메시지인지
          name: "Time", 
          value: new Date().toISOString().replace("T", " ").slice(0, 19),
          inline: true,
        },
        {
          // 어떤 process 에서 발생한 건지
          name: "Process",
          value: processName,
          inline: true,
        },
      ],
    },
  };
}

function createMessage(data, eventName, altDescription) {
  const processName = data.process.name;

  if (processName === "pm2-discord") {
    return;
  }

  if (conf.process_name !== null && processName !== conf.process_name) {
    return;
  }

  var msg = altDescription || data.data;
  if (typeof msg === "object") {
    msg = JSON.stringify(msg);
  }

  // 이벤트마다 원하는 메시지 형태로 만들기
  const embedFormatters = {
    log: () =>
      makeEmbedFormat(
        "# **📜 LOG **",
        5763719,
        "Log Message",
        msg,
        processName
      ),
    error: () =>
      makeEmbedFormat(
        "# **🚨 ERROR **",
        15158332,
        "Error Message",
        msg,
        processName
      ),
    exception: () =>
      makeEmbedFormat(
        "# **🚨 EXCEPTION **",
        15158332,
        "Exception Message",
        msg,
        processName
      ),
    restart: () =>
      makeEmbedFormat(
        "# **🔄 PROCESS RESTARTED **",
        16776960,
        "Restart Message",
        msg,
        processName
      ),
    delete: () =>
      makeEmbedFormat(
        "# **❌ PROCESS DELETED **",
        15158332,
        "Delete Message",
        msg,
        processName
      ),
    stop: () =>
      makeEmbedFormat(
        "# **🔴 PROCESS STOPPED **",
        15158332,
        "Stop Message",
        msg,
        processName
      ),
    exit: () =>
      makeEmbedFormat(
        "# **🔴 PROCESS EXITED **",
        15158332,
        "Exit Message",
        msg,
        processName
      ),
    start: () =>
      makeEmbedFormat(
        "# **🟢 PROCESS STARTED **",
        5763719,
        "Start Message",
        msg,
        processName
      ),
    online: () =>
      makeEmbedFormat(
        "# **🟢 PROCESS ONLINE **",
        5763719,
        "Online Message",
        msg,
        processName
      ),
  };

  const formatter = embedFormatters[eventName];
  const result = formatter();

  messages.push({
    name: processName,
    event: eventName,
    content: result.content,
    embed: result.embed,
    timestamp: Math.floor(Date.now() / 1000),
  });
}

코드가 길 뿐이지 어렵지 않을 거라고 생각합니다.

환경변수 추가하기

현재 설정 값에는 discord_url 이거 하나밖에 없습니다. 그런데 채널 여러 개에서 알림을 받고 싶을 경우에는 한 개만으론 부족하죠. 그래서 package.json 에서 config field 에 원하는 값을 추가하면 됩니다. 아래처럼 말이죠

"config": {
    "discord_url": null,
    "discord_error_url": null, // 추가한 설정 값
    "process_name": null,
    "log": true,
    "error": false,
    "kill": true,
    "exception": true,
    "restart": false,
    "delete": false,
    "stop": true,
    "restart overlimit": true,
    "exit": false,
    "start": false,
    "online": false,
    "buffer": true,
    "buffer_seconds": 1,
    "queue_max": 100
  }

적용하기

그럼 이 코드를 실제로 적용해봐야겠죠? 원본 코드에서는 pm2-install pm2-discord 이렇게 했지만, 제가 fork 따서 개인 레포에서 수정했기 때문에 좀 다르게 해야 됩니다. pm2 install https://github.com/사용자이름/레포지토리이름 이렇게 하면 됩니다. 저는 pm2 install https://github.com/suleeesulee/pm2-discord 이렇게 했습니다. 설치 후 pm2 list 를 하면

이렇게 설치가 잘 된 것을 확인 할 수 있습니다.

설정 값 확인하기

현재 설정된 값들을 확인하고 싶으면 pm2 conf pm2-discord 혹은 더 자세히 보고 싶으면 pm2 describe pm2-discord 로 확인할 수 있습니다.

개선해야 할 부분

현재 저희 프로젝트에 적용을 하고 잘 에러를 반환하지만 개선해야 할 부분들이 많습니다.

  1. 현재 js 로 작성했지만, 안정적인 타입 지원을 위해 ts 로 수정해야 함
  2. 여러 discord 웹후크 url을 설정했을 때 조건문 남발하지 않도록 객체지향적으로 잘 설계해야 함
  3. pm2 만 적용할 수 있지만 docker로 배포하는 경우에도 가능하게 해야 함

1, 2번은 어느정도 쉽게 해결이 될 거 같은데 솔직히 3번 문제는 어떻게 해야 할지 고민을 계속하고 있습니다. 3번 문제가 해결되면 여러 프로젝트에서 활용할 수 있을 것 같고 좋은 글 소재가 될 것 같습니다.

마무리

아직 수정 중인 코드이기 때문에 조금 부족해 보일 수 있습니다. 피드백 적극 환영합니다. 꼭 좋은 코드로 완성할 테니, 이용해주시면 감사하겠습니다. 그럼 글 마치겠습니다!

0개의 댓글