
이 글은 야구보구 프로젝트에서 서버 헬스체크 알림을 Discord와 연동해 운영한 경험과 시행착오를 작성한 글입니다.
📌 왜 Discord를 선택했는가?
많은 팀이 알림 툴로 Slack을 사용합니다. 하지만 Slack 무료 요금제는 90일 이전 메세지를 못 보는 큰 단점이 있었습니다. 반면 Discord는 메세지 보존 기간에 제약이 없고, 메시지 형식도 Slack보다 깔끔하다고 판단했습니다.
그래서 저희는 알림 채널을 Discord로 통일하기로 결정했습니다.
🏗️ 전체 구조
저희는 AWS EC2 서버를 사용하고 있었기 때문에, AWS의 CloudWatch + SNS + Lambda 조합을 이용했습니다.
전체 흐름은 다음과 같습니다.
- CloudWatch Alarm이 서버 상태 임계치를 감지한다.
- 감지된 이벤트를 SNS Topic으로 발행한다.
- SNS가 구독자(Lambda)에게 메시지를 전달한다.
- Lambda 함수가 Discord Webhook API를 호출한다.
- Discord 채널에 알림 메시지가 표시된다.
하나씩 알아보도록 하겠습니다.
1. CloudWatch Alarm
CloudWatch Alarm은 사용자가 정의한 임계치를 기준으로 서버의 상태 변화를 감시하는 기능을 제공합니다
저희가 설정한 서버 헬스체크 Alarm은 yagu-bogu-health-check-alert라는 이름을 가지고 있습니다.
- 조건: 5분 동안 수집한 데이터 포인트 중 1개라도 < 1이면 Alarm 상태로 전환
- Data Point: 서버에서 1분마다 crontab으로 health-check 스크립트 실행 → 정상이면 1, 비정상이면 0값을 전송
- 판단 방식: CloudWatch는 최근 5개의 데이터 포인트를 기준으로 Alarm 여부를 결정
CloudWatch에는 OK / ALARM / INSUFFICIENT_DATA 세 가지 상태가 있습니다.
저희는 OK와 ALARM 사이의 상태 전환이 있을 때만 알림을 받도록 설정했습니다.
- OK → ALARM 전환 시: 서버 문제 발생
- ALARM → OK 전환 시: 서버 정상 복구
서버가 문제 발생했다는 알림도 받아야 하지만, 복구했다는 알림도 받아야한다고 생각했습니다.
만약 복구 알림이 안 온다면 개발자가 직접 서버 상태를 재확인해야 하고 불필요하게 모니터링 화면을 붙잡고 있어야 하기 때문입니다.
이렇게 설정함으로써 저희는 복구 알림을 통해 서버가 다시 살아났는지 자동으로 확인할 수 있습니다.
2. SNS (Simple Notification Service)
SNS는 단순히 메세지를 받아서 여러 구독자에게 뿌려주는 역할입니다. CloudWatch가 발행한 이벤트를 여러 구독자에게 동시에 전달할 수 있습니다.
구독자는 이메일, Lambda, SQS 등 다양하게 설정할 수 있습니다.
SNS는 Discord Webhook을 직접 호출할 수 없기 때문에 Lambda를 구독자로 설정했습니다.
3. Lambda
Lambda는 SNS 이벤트를 받아 간단한 코드를 실행하고, 그 결과를 Discord Webhook으로 전달하는 중간 처리자 역할을 합니다.
SNS에서 전달받은 메시지를 이쁘게 가공한 뒤, Discord Webhook API로 알림을 보냅니다.
다음은 CloudWatch 알람 이벤트를 Discord Webhook 형식의 메시지(JSON)로 변환하는 함수입니다.
즉, AWS 알람을 사람이 보기 좋은 디스코드 알림 카드로 만들어 주는 함수입니다.
원래는 Java로 작성해보려고 했는데, AWS Lambda에서 코드 편집기는 Java 런타임을 지원하지 않는 것 같습니다. 😅
import https from 'https';
import { URL } from 'url';
const ENV = process.env
if (!ENV.webhook) throw new Error('Missing environment variable: webhook')
const webhook = ENV.webhook;
const statusColorsAndMessage = {
ALARM: { color: 15158332, message: "위험" },
INSUFFICIENT_DATA: { color: 15844367, message: "데이터 부족" },
OK: { color: 3066993, message: "정상" }
}
const comparisonOperator = {
"GreaterThanOrEqualToThreshold": ">=",
"GreaterThanThreshold": ">",
"LowerThanOrEqualToThreshold": "<=",
"LessThanThreshold": "<",
}
// `exports.handler` 대신 `export const handler` 사용
export const handler = async (event) => {
await processEvent(event);
}
// `exports.processEvent` 대신 `export const processEvent` 사용
export const processEvent = async (event) => {
console.log('Event:', JSON.stringify(event))
const snsMessage = event.Records[0].Sns.Message;
console.log('SNS Message:', snsMessage);
const postData = buildDiscordMessage(JSON.parse(snsMessage))
await postDiscord(postData, webhook);
}
// `exports.buildDiscordMessage` 대신 `export const buildDiscordMessage` 사용
export const buildDiscordMessage = (data) => {
const newState = statusColorsAndMessage[data.NewStateValue];
const oldState = statusColorsAndMessage[data.OldStateValue];
const executeTime = toYyyymmddhhmmss(data.StateChangeTime);
const description = data.AlarmDescription || '설명 없음';
const cause = getCause(data);
const alarmLink = createLink(data);
return {
username: "CloudWatch Alarm",
embeds: [
{
title: `[${data.AlarmName}]`,
color: newState.color,
fields: [
{ name: "언제", value: executeTime, inline: false },
{ name: "설명", value: description, inline: false },
{ name: "원인", value: cause, inline: false },
{ name: "이전 상태", value: oldState.message, inline: true },
{ name: "현재 상태", value: `**${newState.message}**`, inline: true },
{ name: "바로가기", value: `[CloudWatch로 이동](${alarmLink})`, inline: false }
]
}
]
}
}
// `exports.createLink` 대신 `export const createLink` 사용
export const createLink = (data) => {
return `https://console.aws.amazon.com/cloudwatch/home?region=${exportRegionCode(data.AlarmArn)}#alarm:alarmFilter=ANY;name=${encodeURIComponent(data.AlarmName)}`;
}
// `exports.exportRegionCode` 대신 `export const exportRegionCode` 사용
export const exportRegionCode = (arn) => {
return arn.replace("arn:aws:cloudwatch:", "").split(":")[0];
}
// `exports.getCause` 대신 `export const getCause` 사용
export const getCause = (data) => {
const trigger = data.Trigger;
const evaluationPeriods = trigger.EvaluationPeriods;
const minutes = Math.floor(trigger.Period / 60);
if (data.Trigger.Metrics) {
return buildAnomalyDetectionBand(data, evaluationPeriods, minutes);
}
return buildThresholdMessage(data, evaluationPeriods, minutes);
}
// `exports.buildAnomalyDetectionBand` 대신 `export const buildAnomalyDetectionBand` 사용
export const buildAnomalyDetectionBand = (data, evaluationPeriods, minutes) => {
const metrics = data.Trigger.Metrics;
const metric = metrics.find(metric => metric.Id === 'm1').MetricStat.Metric.MetricName;
const expression = metrics.find(metric => metric.Id === 'ad1').Expression;
const width = expression.split(',')[1].replace(')', '').trim();
return `${evaluationPeriods * minutes} 분 동안 ${evaluationPeriods} 회 ${metric} 지표가 범위(약 ${width}배)를 벗어났습니다.`;
}
// `exports.buildThresholdMessage` 대신 `export const buildThresholdMessage` 사용
export const buildThresholdMessage = (data, evaluationPeriods, minutes) => {
const trigger = data.Trigger;
const threshold = trigger.Threshold;
const metric = trigger.MetricName;
const operator = comparisonOperator[trigger.ComparisonOperator];
return `${evaluationPeriods * minutes} 분 동안 ${evaluationPeriods} 회 ${metric} ${operator} ${threshold}`;
}
// `exports.toYyyymmddhhmmss` 대신 `export const toYyyymmddhhmmss` 사용
export const toYyyymmddhhmmss = (timeString) => {
if (!timeString) return '';
const kstDate = new Date(new Date(timeString).getTime() + 32400000);
function pad2(n) { return n < 10 ? '0' + n : n }
return kstDate.getFullYear().toString()
+ '-' + pad2(kstDate.getMonth() + 1)
+ '-' + pad2(kstDate.getDate())
+ ' ' + pad2(kstDate.getHours())
+ ':' + pad2(kstDate.getMinutes())
+ ':' + pad2(kstDate.getSeconds());
}
// `exports.postDiscord` 대신 `export const postDiscord` 사용
export const postDiscord = async (message, discordUrl) => {
return await request(options(discordUrl), message);
}
// `exports.options` 대신 `export const options` 사용
export const options = (discordUrl) => {
const { host, pathname } = new URL(discordUrl);
return {
hostname: host,
path: pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
};
}
function request(options, data) {
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
res.setEncoding('utf8');
let responseBody = '';
res.on('data', (chunk) => { responseBody += chunk; });
res.on('end', () => { resolve(responseBody); });
});
req.on('error', (err) => {
console.error(err);
reject(err);
});
req.write(JSON.stringify(data));
req.end();
});
}

이 방식의 장점은 다음과 같습니다.
- 별도의 서버나 배포 없이 코드 몇 줄만으로 알림 처리 가능
- 다른 알림 채널(Slack, 이메일)로 확장이 쉬움
⚠️ 헬스체크 알람이 안 잡히던 이유
운영 중에 잠시 모니터링을 확인해보니 서버 헬스체크가 계속 ALARM 상태로 찍히고 있었습니다.
그러나 실제 prod 서버는 멀쩡하게 돌아가고 있었습니다.

이상해서 서버에 직접 접속해 health-check datapoint를 수동으로 보내봤습니다. 정상이라면 곧바로 CloudWatch 지표에 반영되어야 했는데, 아무 반응이 없었습니다.
1시간 넘게 삽질한 끝에 알아낸 문제는 metric_name과 namespace 불일치였습니다.
#!/bin/bash
set -euo pipefail
# 구성
URL="http://localhost:80/actuator/health"
NAMESPACE="EC2/yagu-bogu/prod"
METRIC_NAME="AppHealth"
AWS_REGION="ap-northeast-2"
# 헬스 체크 조회 (실패 시 UNKNOWN 반환)
STATUS=$(curl -fsS "$URL" | jq -r '.status' 2>/dev/null || echo "UNKNOWN")
# 상태에 따라 VALUE 설정
if [ "$STATUS" = "UP" ]; then
VALUE=1
else
VALUE=0
fi
# 인스턴스 ID 안전하게 가져오기 (실패 시 'unknown-instance' 사용)
INSTANCE_ID=$(curl -fsS "$META_URL" 2>/dev/null || echo "unknown-instance")
# CloudWatch에 전송 (VALUE 사용)
aws cloudwatch put-metric-data \
--metric-name "$METRIC_NAME" \
--namespace "$NAMESPACE" \
--value "$VALUE" \
--dimensions Name=InstanceId,Value="$INSTANCE_ID" \
--region "$AWS_REGION"
heal-check.sh

기존에 모니터링하던 지표
- 헬스체크 스크립트(health-check.sh)에서 보낸 datapoint의 metric 정보 (namespace=EC2/yagubogu/prod, metric_name=AppHealth)
- CloudWatch 대시보드에서 모니터링하던 지표 (namespace=EC2/yagubogu, metric_name=yagu-bogu-health-check)
이 둘이 달라서, 데이터를 보내도 CloudWatch가 전혀 다른 지표를 바라보고 있었던 것이었습니다.
대시보드 설정을 수정해, health-check 스크립트와 동일한 metric을 바라보도록 바꾸었더니 문제가 바로 해결되었습니다.
💡 배운 점
- 알람 시스템은 설정 실수 하나에도 무용지물이 될 수 있다.
- 관찰 중인 지표(namespace, metric_name)가 실제 수집하는 지표와 정확히 일치하는지 확인하는 것이 중요하다.
- 빠르게 문제를 찾으려면 실제로 datapoint를 강제로 넣어보고 대시보드에서 반응을 확인하는 것이 가장 확실하다.
'개발괴발 > Devops' 카테고리의 다른 글
| [Prometheus, Grafana] 야구보구 부하테스트 모니터링 구축기 (k6 → Prometheus → Grafana) (0) | 2025.11.04 |
|---|---|
| [Infra] Nginx와 Docker로 구현하는 단일 서버 Blue-Green 무중단 배포 (0) | 2025.08.29 |
| [GitHub Actions] EC2에서 막힌 CI/CD, Self-hosted Runner로 자동화하기 (5) | 2025.08.01 |
| [Github Actions] CI/CD, 배포 자동화하기 (3) | 2025.07.22 |
| [Jenkins] ERROR: Error fetching remote repo 'origin' (0) | 2024.11.29 |