C++ 멀티채널 알림 서비스: 이메일·SMS·FCM 푸시·WebSocket·Webhook 통합과 재시도·DLQ
알림 시스템은 단순히 “메시지 보내기”처럼 보이지만, 채널마다 성질이 크게 다릅니다. 이메일은 늦게 도착해도 되지만 스팸함에 들어가면 안 되고, SMS는 빠르지만 건당 비용이 들며, 푸시는 기기 토큰이 수시로 바뀌고, Webhook은 상대 서버가 언제든 실패할 수 있습니다. 이 글은 WebSocket, FCM 푸시, SMTP 이메일, SMS, Webhook을 하나의 알림 서비스 뒤에 묶고, 채널별 재시도와 실패 처리를 어떻게 다르게 가져가야 하는지를 C++ 코드와 함께 살펴봅니다.
예제는 C++17, Boost.Asio/Beast, nlohmann/json을 기준으로 합니다. 코드는 각 채널이 어떤 프로토콜 흐름으로 동작하는지 보여 주기 위한 뼈대입니다. 일부 HTTP 전송 부분은 생략했고, 운영 코드로 옮길 때 반드시 고쳐야 하는 부분(TLS 검증, 쓰기 직렬화, 멱등성 등)은 각 절의 설명에 표시해 두었습니다.
알림 지연·중복·토큰 만료: 알림 시스템이 필요한 상황
주문 API 안에서 이메일을 동기로 보내면, SMTP 서버와 TLS 핸드셰이크를 하고 명령을 주고받는 동안 API 응답이 수 초씩 늦어지고, SMTP 서버가 느려지면 주문 API까지 함께 느려집니다. 알림 작업은 메시지 큐에 넣고 백그라운드 워커가 보내게 하면 사용자는 바로 응답을 받습니다.
채널마다 맡는 역할도 다릅니다. WebSocket은 앱이 포그라운드에 있을 때만 연결이 살아 있으므로, 앱이 백그라운드이거나 꺼져 있을 때는 OS가 대신 표시해 주는 FCM(Firebase Cloud Messaging) 푸시로 넘어가야 합니다. SMS는 건당 과금이라 인증 코드나 보안 알림처럼 급한 것에만 쓰고, 나머지는 이메일과 푸시로 보냅니다. 채널마다 발송 코드를 따로 두면 “주문 완료” 같은 알림 하나를 보내는 코드가 채널 수만큼 중복되고 사용자의 수신 설정을 반영하기도 어려우므로, 통합 서비스가 템플릿과 사용자별 채널 선호도를 보고 한 번에 라우팅합니다.
실패의 성격도 채널마다 다릅니다. ERP나 재고 시스템으로 가는 Webhook은 상대 서버 장애로 실패할 수 있어 재시도와 백오프가 필요하고, 끝내 실패한 이벤트는 DLQ(Dead Letter Queue)에 보관했다가 사람이 처리해야 합니다. FCM 토큰은 앱 재설치나 데이터 삭제로 무효가 되는데, 이때 받는 UNREGISTERED(레거시 API의 NotRegistered·InvalidRegistration)는 재시도해도 소용이 없으므로 토큰을 지우고 앱이 새 토큰을 등록하게 해야 합니다.
이 모든 경우에 공통으로 깔린 원칙은 알림 발송을 요청 처리 경로에서 떼어 내는 것입니다. 주문 API는 “알림을 보내야 한다”는 사실만 큐에 기록하고 바로 응답하며, 실제 발송과 재시도, 채널 선택은 별도 워커가 맡습니다. 이렇게 나누면 외부 서비스(SMTP 서버, FCM, SMS 게이트웨이)가 느려지거나 장애가 나도 핵심 기능은 영향을 받지 않고, 알림은 늦어질 뿐 사라지지 않습니다. 대신 “보냈는가”를 즉시 알 수 없으므로 발송 이력과 실패 이유를 따로 저장해 조회할 수 있어야 합니다.
멀티채널 알림 아키텍처
전체 구조
flowchart TB
subgraph App[Application]
A1[Order Complete]
A2[Auth Request]
A3[Event Trigger]
A1 --> NS
A2 --> NS
A3 --> NS
end
subgraph NS[Notification Service]
Router[Router]
Template[Template Engine]
Router --> Template
end
subgraph Channels[Channels]
WS[WebSocket]
Email[SMTP Email]
SMS[SMS API]
Push[FCM Push]
WH[Webhook]
end
NS --> WS
NS --> Email
NS --> SMS
NS --> Push
NS --> WH
subgraph Queue[Message Queue]
MQ[RabbitMQ/Kafka]
end
NS --> MQ
MQ --> NS
채널별 특성 비교
| 채널 | 지연 | 비용 | 적합 용도 | 오프라인 수신 |
|---|---|---|---|---|
| WebSocket | 즉시 | 낮음 | 실시간 대시보드, 채팅 | 불가 (연결 필요) |
| 이메일 | 수 초~수 분 | 낮음 | 상세 내용, 영수증 | 가능 |
| SMS | 수 초 | 높음 (건당 과금) | 인증 코드, 긴급 알림 | 가능 |
| 푸시 | 수 초 (지연 가능) | 낮음 | 앱 알림 | 가능 |
| Webhook | 수 초 | 발송 측 인프라 비용 | 외부 시스템 연동 | 해당 없음 |
시퀀스 다이어그램: 멀티채널 알림 흐름
sequenceDiagram
participant App as Application
participant NS as Notification Service
participant MQ as Message Queue
participant WS as WebSocket
participant Email as SMTP
participant Push as FCM
App->>NS: notify(user_id, order_complete, data)
NS->>NS: Render Template
NS->>MQ: Publish Notification Task
par WebSocket (Online Users)
NS->>WS: Send Realtime
WS->>App: Display Immediately
and Email
MQ->>Email: Send via SMTP
and Push
MQ->>Push: Call FCM API
end
핵심 클래스 구조
// 알림 타입 열거
enum class NotificationChannel {
WebSocket,
Email,
SMS,
Push,
Webhook
};
// 알림 요청
struct NotificationRequest {
std::string user_id;
std::string template_id; // "order_complete", "auth_code" 등
nlohmann::json data; // 템플릿 변수
std::vector<NotificationChannel> channels;
std::string priority = "normal"; // "high", "normal", "low"
};
// 채널별 발송 인터페이스
class INotificationSender {
public:
virtual ~INotificationSender() = default;
virtual bool send(const std::string& user_id,
const std::string& rendered_content,
const nlohmann::json& metadata) = 0;
};
이메일 알림 (SMTP)
SMTP 클라이언트 기본 구현
// email_sender.cpp
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <iostream>
#include <sstream>
#include <string>
namespace asio = boost::asio;
using ssl_socket = asio::ssl::stream<asio::ip::tcp::socket>;
class SmtpSender {
asio::io_context& io_context_;
ssl_socket socket_;
std::string host_;
std::string port_;
std::string username_;
std::string password_;
void send_command(const std::string& cmd) {
std::string data = cmd + "\r\n";
asio::write(socket_, asio::buffer(data));
}
std::string read_response() {
asio::streambuf buffer;
asio::read_until(socket_, buffer, "\r\n");
std::istream is(&buffer);
std::string line;
std::getline(is, line);
return line;
}
public:
// ssl::stream은 io_context와 함께 ssl::context를 받아야 생성됨
SmtpSender(asio::io_context& ctx, asio::ssl::context& ssl_ctx,
const std::string& host, const std::string& port,
const std::string& user, const std::string& pass)
: io_context_(ctx), socket_(ctx, ssl_ctx), host_(host), port_(port),
username_(user), password_(pass) {}
bool send_email(const std::string& to,
const std::string& subject,
const std::string& body) {
try {
asio::ip::tcp::resolver resolver(io_context_);
auto endpoints = resolver.resolve(host_, port_);
asio::connect(socket_.lowest_layer(), endpoints);
socket_.lowest_layer().set_option(asio::socket_base::keep_alive(true));
socket_.set_verify_mode(asio::ssl::verify_peer);
// 인증서가 host_용인지 검증 (항상 true를 돌려주는 콜백은 검증을 꺼 버림)
socket_.set_verify_callback(asio::ssl::host_name_verification(host_));
SSL_set_tlsext_host_name(socket_.native_handle(), host_.c_str()); // SNI
socket_.handshake(ssl_socket::client);
read_response(); // 220
send_command("EHLO localhost");
read_response();
send_command("AUTH LOGIN");
read_response();
send_command(base64_encode(username_));
read_response();
send_command(base64_encode(password_));
read_response();
send_command("MAIL FROM:<" + username_ + ">");
read_response();
send_command("RCPT TO:<" + to + ">");
read_response();
send_command("DATA");
read_response();
std::string msg = "From: " + username_ + "\r\n"
+ "To: " + to + "\r\n"
+ "Subject: " + subject + "\r\n"
+ "Content-Type: text/html; charset=utf-8\r\n"
+ "\r\n" + body + "\r\n.\r\n";
asio::write(socket_, asio::buffer(msg));
read_response();
send_command("QUIT");
read_response();
return true;
} catch (const std::exception& e) {
std::cerr << "SMTP error: " << e.what() << "\n";
return false;
}
}
};
이 클라이언트는 SMTP 대화의 순서(EHLO → AUTH → MAIL FROM → RCPT TO → DATA)를 보여 주기 위한 최소 구현이라, 그대로 운영에 쓰기에는 빠진 것이 많습니다. 가장 먼저 짚을 것은 TLS 검증입니다. 흔히 보이는 예제처럼 검증 콜백이 무조건 true를 돌려주면, 공격자가 중간에서 가짜 인증서를 내밀어도 통과해 SMTP 계정 비밀번호가 그대로 노출됩니다. 위처럼 host_name_verification으로 호스트 이름을 확인하고, ssl::context에 set_default_verify_paths()로 시스템 루트 인증서를 불러와야 검증이 실제로 동작합니다. 또 이 코드는 처음부터 TLS로 연결하는 465 포트(SMTPS) 방식이고, 587 포트는 평문으로 연결한 뒤 STARTTLS 명령으로 승격하는 흐름이 따로 필요합니다. ssl::stream은 한 번 연결하고 종료한 뒤 다시 쓸 수 없으므로, send_email을 두 번 호출하면 두 번째 연결은 실패합니다. 메일마다 SmtpSender를 새로 만들거나, 연결 하나로 여러 메일을 보내도록(RSET 후 다음 MAIL FROM) 구조를 바꿔야 합니다.
프로토콜 처리에서도 함정이 있습니다. read_response()는 한 줄만 읽는데, EHLO에 대한 응답은 250-SIZE ..., 250-AUTH LOGIN PLAIN, 250 HELP처럼 여러 줄로 옵니다. 첫 줄만 읽고 넘어가면 남은 줄이 다음 명령의 응답으로 읽혀 대화가 한 칸씩 어긋나고, 결국 AUTH에 대한 334 대신 250을 받아 비밀번호를 엉뚱한 시점에 보내게 됩니다. 응답 코드 네 번째 글자가 -이면 계속 읽고, 공백이면 멈추는 루프가 필요하며, 각 단계에서 기대한 코드(220, 250, 334, 235, 354)인지 확인해야 합니다. 본문에 .로 시작하는 줄이 있으면 서버가 그것을 메시지 끝으로 오해하므로 앞에 .을 하나 더 붙이는 dot-stuffing이 필요하고, 한글 제목은 =?UTF-8?B?...?= 형태로 인코딩하지 않으면 수신 클라이언트에서 깨집니다. 이런 세부 사항 때문에 실무에서는 libcurl의 SMTP 지원을 쓰거나, 아예 SendGrid·Amazon SES 같은 발송 서비스의 HTTP API를 호출하는 경우가 많습니다.
이메일 템플릿 예시
// 주문 완료 이메일 템플릿
std::string render_order_email(const nlohmann::json& data) {
std::ostringstream html;
html << "<html><body>";
html << "<h1>주문이 완료되었습니다</h1>";
html << "<p>주문번호: " << data["order_id"].get<std::string>() << "</p>";
html << "<p>결제금액: " << data["amount"].get<std::string>() << "원</p>";
html << "<p>예상 배송일: " << data["delivery_date"].get<std::string>() << "</p>";
html << "</body></html>";
return html.str();
}
템플릿에 데이터를 그대로 끼워 넣는 방식은 HTML 이스케이프가 없다는 점을 기억해야 합니다. 주문번호나 금액처럼 서버가 만든 값은 괜찮지만, 사용자가 입력한 이름이나 배송 메모가 들어가면 <a href="피싱 URL"> 같은 태그가 그대로 메일 본문이 되어 우리 도메인 이름으로 피싱 메일을 보내는 셈이 됩니다. 사용자 입력은 &, <, >, "를 엔티티로 바꾼 뒤 넣고, 템플릿이 늘어나면 inja처럼 자동 이스케이프를 지원하는 템플릿 엔진을 쓰는 편이 안전합니다. 또 data["amount"].get<std::string>()은 JSON에 숫자 50000이 들어 있으면 type_error.302: type must be string, but is number 예외를 던지므로, 필드 타입을 스키마로 정해 두어야 합니다.
Base64 인코딩 (AUTH용)
#include <boost/beast/core/detail/base64.hpp>
std::string base64_encode(const std::string& input) {
std::string output;
output.resize(boost::beast::detail::base64::encoded_size(input.size()));
auto len = boost::beast::detail::base64::encode(
output.data(), input.data(), input.size());
output.resize(len);
return output;
}
boost::beast::detail은 이름 그대로 Beast 내부 구현이라 버전이 바뀌면 사라지거나 시그니처가 바뀔 수 있습니다. 이미 OpenSSL을 링크하고 있다면 EVP_EncodeBlock을 쓰는 편이 안정적입니다.
SMS 알림
SMS API 클라이언트 (REST 기반)
대부분의 SMS 서비스(NHN Cloud, Twilio, AWS SNS 등)는 REST API를 제공합니다.
// sms_sender.cpp
#include <boost/asio.hpp>
#include <boost/beast.hpp>
#include <nlohmann/json.hpp>
#include <string>
namespace beast = boost::beast;
namespace http = beast::http;
namespace asio = boost::asio;
class SmsSender {
asio::io_context& io_context_;
std::string api_url_;
std::string api_key_;
public:
SmsSender(asio::io_context& ctx,
const std::string& url, const std::string& api_key)
: io_context_(ctx), api_url_(url), api_key_(api_key) {}
bool send_sms(const std::string& to_phone,
const std::string& message) {
nlohmann::json body = {
{"to", to_phone},
{"from", "01012345678"}, // 발신번호 (사전 등록 필요)
{"text", message}
};
beast::tcp_stream stream(io_context_);
asio::ip::tcp::resolver resolver(io_context_);
auto const results = resolver.resolve(
extract_host(api_url_), extract_port(api_url_));
stream.connect(results);
http::request<http::string_body> req{http::verb::post, api_url_, 11};
req.set(http::field::host, extract_host(api_url_));
req.set(http::field::content_type, "application/json");
req.set("Authorization", "Bearer " + api_key_);
req.body() = body.dump();
req.prepare_payload();
http::write(stream, req);
beast::flat_buffer buffer;
http::response<http::dynamic_body> res;
http::read(stream, buffer, res);
beast::error_code ec;
stream.socket().shutdown(asio::ip::tcp::socket::shutdown_both, ec);
int status = res.result_int();
return status >= 200 && status < 300;
}
};
이 코드는 흐름을 보여 주기 위해 두 가지를 단순화했습니다. 첫째, 실제 SMS API는 모두 HTTPS이므로 beast::tcp_stream 대신 beast::ssl_stream<beast::tcp_stream>으로 TLS 핸드셰이크를 해야 합니다. 평문 소켓으로 443 포트에 붙으면 서버가 연결을 끊거나 400 The plain HTTP request was sent to HTTPS port 응답을 줍니다. 둘째, http::request의 두 번째 인자는 전체 URL이 아니라 경로(/v1/messages)여야 합니다. 전체 URL을 넣으면 요청 줄이 POST https://api.example.com/v1/messages HTTP/1.1이 되어 많은 서버가 404나 400으로 거부합니다.
국내 SMS는 발신번호 사전 등록제가 적용되어 있어서, 등록하지 않은 번호를 from에 넣으면 API 호출은 성공해도 메시지가 차단됩니다. 인증 코드처럼 중요한 메시지가 이런 이유로 조용히 사라지지 않도록, 발송 API의 응답 코드뿐 아니라 게이트웨이가 제공하는 결과 조회(전달 리포트)를 나중에 확인하는 절차를 두는 것이 좋습니다.
인증 코드 SMS 예시
std::string render_auth_code_sms(const nlohmann::json& data) {
return "[서비스명] 인증번호: " + data["code"].get<std::string>() +
"\n3분 내 입력해 주세요.";
}
푸시 알림 (FCM)
FCM HTTP v1 API 클라이언트
// push_sender.cpp
#include <boost/asio.hpp>
#include <boost/beast.hpp>
#include <nlohmann/json.hpp>
#include <string>
namespace beast = boost::beast;
namespace http = beast::http;
namespace asio = boost::asio;
// FCM v1 API: https://fcm.googleapis.com/v1/projects/{project_id}/messages:send
class FcmPushSender {
asio::io_context& io_context_;
std::string project_id_;
std::string access_token_; // OAuth2 토큰 (서비스 계정)
public:
FcmPushSender(asio::io_context& ctx,
const std::string& project_id,
const std::string& token)
: io_context_(ctx), project_id_(project_id), access_token_(token) {}
bool send_push(const std::string& fcm_token,
const std::string& title,
const std::string& body,
const nlohmann::json& data = {}) {
nlohmann::json message = {
{"token", fcm_token},
{"notification", {
{"title", title},
{"body", body}
}},
{"data", data},
{"android", {
{"priority", "high"},
{"notification", {{"channel_id", "default"}}}
}},
{"apns", {
{"payload", {
{"aps", {
{"alert", {{"title", title}, {"body", body}}},
{"sound", "default"}
}}
}}
}}
};
std::string url = "https://fcm.googleapis.com/v1/projects/" +
project_id_ + "/messages:send";
nlohmann::json body_json = {{"message", message}};
// HTTP POST with Bearer token
http::request<http::string_body> req{http::verb::post, url, 11};
req.set(http::field::host, "fcm.googleapis.com");
req.set(http::field::content_type, "application/json");
req.set(http::field::authorization, "Bearer " + access_token_);
req.body() = body_json.dump();
req.prepare_payload();
// ... HTTP 클라이언트로 전송 (위 SMS와 유사)
return true;
}
};
FCM HTTP v1 API에서 실제로 막히는 지점은 전송 코드보다 인증 토큰과 페이로드 형식입니다. access_token_은 서비스 계정 키로 서명한 JWT를 Google OAuth 엔드포인트에 보내 받은 토큰인데, 유효 시간이 1시간이라 생성자에서 한 번 받아 두면 한 시간 뒤부터 모든 발송이 401 UNAUTHENTICATED로 실패합니다. 만료 전에 갱신하는 로직이 반드시 필요합니다. 페이로드에서는 data 필드의 모든 값이 문자열이어야 한다는 규칙이 흔한 함정입니다. {"order_id": 12345}처럼 숫자를 넣으면 400 INVALID_ARGUMENT가 나므로 값을 문자열로 바꿔 넣어야 하고, 기본 인자 data = {}는 nlohmann/json에서 null이 되므로 데이터가 없으면 data 필드를 아예 빼는 편이 안전합니다. 마지막으로 위 요청 객체의 target도 전체 URL이 아니라 /v1/projects/{id}/messages:send 경로여야 합니다.
notification 필드가 있는 메시지는 앱이 백그라운드일 때 OS가 직접 알림을 표시하고 앱 코드는 사용자가 알림을 누를 때까지 실행되지 않습니다. 앱이 받은 즉시 로컬 DB를 갱신하는 등 처리를 해야 한다면 data만 담은 메시지를 보내야 하는데, 이 경우 iOS에서는 전달이 보장되지 않고 안드로이드에서는 절전 모드에 따라 지연될 수 있다는 트레이드오프가 있습니다.
푸시 토큰 관리
// 사용자별 FCM 토큰 저장 및 갱신
class PushTokenStore {
std::unordered_map<std::string, std::string> user_to_token_;
std::mutex mutex_;
public:
void register_token(const std::string& user_id,
const std::string& fcm_token) {
std::lock_guard lock(mutex_);
user_to_token_[user_id] = fcm_token;
}
void remove_token(const std::string& user_id) {
std::lock_guard lock(mutex_);
user_to_token_.erase(user_id);
}
std::optional<std::string> get_token(const std::string& user_id) {
std::lock_guard lock(mutex_);
auto it = user_to_token_.find(user_id);
if (it != user_to_token_.end()) return it->second;
return std::nullopt;
}
};
이 저장소는 사용자당 토큰을 하나만 보관하므로, 휴대폰과 태블릿을 함께 쓰는 사용자는 마지막에 로그인한 기기에서만 알림을 받습니다. 실제로는 user_id → {토큰, 기기 정보, 마지막 갱신 시각} 목록으로 두고, 발송 실패로 무효가 된 토큰만 골라 지워야 합니다. remove_token(user_id)처럼 사용자 단위로 지우면 한 기기의 토큰이 무효가 됐다는 이유로 다른 기기의 정상 토큰까지 사라집니다. 앱이 실행될 때마다 토큰을 서버에 다시 보내 갱신 시각을 기록하고, 오랫동안 갱신되지 않은 토큰을 주기적으로 정리하는 것도 Firebase 문서가 권하는 운영 방식입니다.
WebSocket 실시간 알림
WebSocket 연결 관리 및 브로드캐스트
// websocket_notifier.cpp
#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <memory>
#include <mutex>
#include <unordered_map>
#include <vector>
namespace beast = boost::beast;
namespace asio = boost::asio;
namespace websocket = beast::websocket;
class WebSocketNotifier {
// user_id -> WebSocket 세션 목록 (한 사용자가 여러 디바이스)
std::unordered_map<std::string, std::vector<std::weak_ptr<websocket::stream<beast::tcp_stream>>>> sessions_;
std::mutex mutex_;
public:
void register_session(const std::string& user_id,
std::shared_ptr<websocket::stream<beast::tcp_stream>> ws) {
std::lock_guard lock(mutex_);
sessions_[user_id].push_back(ws);
}
void unregister_session(const std::string& user_id,
std::shared_ptr<websocket::stream<beast::tcp_stream>> ws) {
std::lock_guard lock(mutex_);
auto it = sessions_.find(user_id);
if (it != sessions_.end()) {
auto& vec = it->second;
vec.erase(std::remove_if(vec.begin(), vec.end(),
[&ws](const auto& w) {
auto p = w.lock();
return !p || p.get() == ws.get();
}), vec.end());
if (vec.empty()) sessions_.erase(it);
}
}
void notify_user(const std::string& user_id,
const nlohmann::json& message) {
std::lock_guard lock(mutex_);
auto it = sessions_.find(user_id);
if (it == sessions_.end()) return;
// async_write가 끝날 때까지 버퍼가 살아 있도록 shared_ptr로 소유
auto data = std::make_shared<std::string>(message.dump());
for (auto& w : it->second) {
auto ws = w.lock();
if (ws && ws->is_open()) {
ws->async_write(asio::buffer(*data),
[data, ws](beast::error_code, std::size_t) {});
}
}
}
};
흔히 보는 형태처럼 std::string data를 지역 변수로 두고 async_write(asio::buffer(data), ...)를 호출하면, 함수가 끝나는 순간 data가 파괴되지만 비동기 쓰기는 아직 진행 중이라 해제된 메모리를 소켓으로 보냅니다. 테스트에서는 메시지가 짧아 대부분 우연히 잘 가다가 부하가 걸리면 깨진 프레임이 가는 전형적인 버그라, 위처럼 핸들러가 버퍼를 붙잡고 있게 해야 합니다.
또 하나의 규칙은 Beast WebSocket 스트림에 동시에 두 개의 async_write를 걸 수 없다는 것입니다. 알림 두 개가 짧은 간격으로 같은 사용자에게 가면 첫 쓰기가 끝나기 전에 두 번째 async_write가 호출되고, Beast는 디버그 빌드에서 soft_mutex 단언으로 멈추거나 릴리스 빌드에서 프레임이 섞입니다. 세션마다 보낼 메시지 큐를 두고 앞 쓰기가 끝난 핸들러에서 다음 메시지를 꺼내 보내는 방식, 그리고 그 작업을 세션의 strand에서 실행하는 방식이 표준적인 해결책입니다. notify_user가 워커 스레드에서 호출된다면 asio::post(ws->get_executor(), ...)로 소켓의 실행기로 넘긴 뒤 큐에 넣어야 합니다.
실시간 알림 메시지 형식
{
"type": "notification",
"id": "notif-12345",
"template_id": "order_complete",
"title": "주문 완료",
"body": "주문번호 #12345가 완료되었습니다.",
"data": {
"order_id": "12345",
"amount": 50000
},
"timestamp": 1709876543
}
Webhook 연동
Webhook 발송기 (재시도·백오프 포함)
// webhook_sender.cpp
#include <boost/asio.hpp>
#include <boost/beast.hpp>
#include <nlohmann/json.hpp>
#include <chrono>
#include <cmath>
class WebhookSender {
asio::io_context& io_context_;
static constexpr int MAX_RETRIES = 5;
static constexpr int BASE_DELAY_MS = 1000;
public:
WebhookSender(asio::io_context& ctx) : io_context_(ctx) {}
void send_webhook(const std::string& url,
const nlohmann::json& payload,
std::function<void(bool)> callback) {
send_with_retry(url, payload, 0, callback);
}
private:
void send_with_retry(const std::string& url,
const nlohmann::json& payload,
int attempt,
std::function<void(bool)> callback) {
// HTTP POST
bool success = do_http_post(url, payload);
if (success) {
callback(true);
return;
}
if (attempt >= MAX_RETRIES) {
callback(false);
return;
}
// Exponential backoff
int delay_ms = BASE_DELAY_MS * static_cast<int>(std::pow(2, attempt));
auto timer = std::make_shared<asio::steady_timer>(io_context_);
timer->expires_after(std::chrono::milliseconds(delay_ms));
timer->async_wait([this, url, payload, attempt, callback, timer]
(boost::system::error_code ec) {
if (!ec) {
send_with_retry(url, payload, attempt + 1, callback);
} else {
callback(false);
}
});
}
bool do_http_post(const std::string& url,
const nlohmann::json& payload) {
// ... Beast HTTP POST 구현
return true;
}
};
지수 백오프 자체는 맞는 방향이지만, 운영에서는 세 가지를 더 챙겨야 합니다. 첫째, 모든 실패를 재시도하면 안 됩니다. 5xx와 타임아웃, 연결 실패는 일시적일 수 있어 재시도할 가치가 있지만, 400이나 401, 404는 몇 번을 보내도 결과가 같으므로 바로 DLQ로 보내는 편이 낫습니다. 429(요청 과다)라면 Retry-After 헤더를 따르는 것이 예의입니다. 둘째, 지연 시간에 무작위 지터를 섞어야 합니다. 수신 서버가 잠깐 죽었다 살아나면 그동안 실패한 수백 개의 Webhook이 정확히 같은 1초, 2초, 4초 뒤에 한꺼번에 몰려 서버를 다시 쓰러뜨립니다. 셋째, do_http_post가 동기 호출이라면 io_context 스레드를 막아 타이머와 다른 재시도가 모두 멈추므로, 실제로는 Beast의 비동기 클라이언트로 바꾸고 요청마다 타임아웃을 걸어야 합니다.
수신 측 입장에서 Webhook이 믿을 만하려면 서명과 이벤트 ID가 필요합니다. 페이로드를 공유 비밀키로 HMAC-SHA256 서명해 X-Signature 같은 헤더에 넣으면, 수신 측은 요청이 정말 우리 서버에서 왔는지 확인할 수 있습니다. 재시도 때문에 같은 이벤트가 두 번 도착할 수 있으므로 페이로드에 고유 event_id를 넣어 수신 측이 중복을 걸러 낼 수 있게 하는 것도 사실상 필수입니다. 아래 페이로드 예시에는 이 필드가 없으니 실제로는 추가해야 합니다.
Webhook 페이로드 예시
{
"event": "order.completed",
"timestamp": "2026-04-01T12:00:00Z",
"data": {
"order_id": "ORD-12345",
"user_id": "user-abc",
"amount": 50000,
"items": [
{"sku": "ITEM-001", "qty": 2}
]
}
}
라우터·템플릿·메시지 큐로 묶은 통합 알림 서비스
라우터 및 템플릿 엔진
// notification_service.cpp
#include <memory>
#include <unordered_map>
#include <functional>
class NotificationService {
std::unordered_map<std::string, std::unique_ptr<INotificationSender>> senders_;
std::unordered_map<std::string, std::function<std::string(const nlohmann::json&)>> templates_;
WebSocketNotifier* ws_notifier_;
UserChannelPreference* channel_prefs_;
public:
void register_sender(NotificationChannel ch,
std::unique_ptr<INotificationSender> sender) {
senders_[channel_to_string(ch)] = std::move(sender);
}
void register_template(const std::string& id,
std::function<std::string(const nlohmann::json&)> fn) {
templates_[id] = std::move(fn);
}
void notify(const NotificationRequest& req) {
std::string content = render_template(req.template_id, req.data);
auto prefs = channel_prefs_->get(req.user_id); // 사용자 설정은 한 번만 조회
for (auto ch : req.channels) {
if (!should_send(ch, prefs)) continue;
switch (ch) {
case NotificationChannel::WebSocket:
ws_notifier_->notify_user(req.user_id,
{{"type", "notification"}, {"body", content}, {"data", req.data}});
break;
case NotificationChannel::Email:
senders_["email"]->send(req.user_id, content, req.data);
break;
case NotificationChannel::SMS:
senders_["sms"]->send(req.user_id, content, req.data);
break;
case NotificationChannel::Push:
senders_["push"]->send(req.user_id, content, req.data);
break;
case NotificationChannel::Webhook:
senders_["webhook"]->send(req.user_id, content, req.data);
break;
}
}
}
private:
std::string render_template(const std::string& id,
const nlohmann::json& data) {
auto it = templates_.find(id);
if (it != templates_.end()) return it->second(data);
return data.dump();
}
bool should_send(NotificationChannel ch,
const ChannelPreference& prefs) {
if (ch == NotificationChannel::WebSocket) return true;
if (ch == NotificationChannel::Email) return prefs.email_enabled;
if (ch == NotificationChannel::SMS) return prefs.sms_enabled;
if (ch == NotificationChannel::Push) return prefs.push_enabled;
return true;
}
};
템플릿 하나를 모든 채널에 같은 문자열로 렌더링하는 것은 단순화입니다. 실제로는 이메일 제목·HTML 본문, SMS의 짧은 문구, 푸시의 제목·본문처럼 채널마다 형식과 길이 제한이 달라서, 아래 “템플릿 중앙 관리”처럼 템플릿을 채널별로 나눠 두고 채널에 맞는 것을 고릅니다.
메시지 큐 연동 (비동기 발송)
// 알림을 큐에 넣고 워커가 비동기 처리
void NotificationService::notify_async(const NotificationRequest& req) {
nlohmann::json msg = {
{"user_id", req.user_id},
{"template_id", req.template_id},
{"data", req.data},
{"channels", std::vector<std::string>()}
};
for (auto ch : req.channels) {
msg["channels"].push_back(channel_to_string(ch));
}
message_queue_.publish("notifications", msg.dump());
}
// 워커: 큐에서 메시지 소비 후 발송
void notification_worker() {
while (true) {
auto msg = message_queue_.consume("notifications");
if (!msg) break;
auto j = nlohmann::json::parse(*msg);
NotificationRequest req;
req.user_id = j["user_id"];
req.template_id = j["template_id"];
req.data = j["data"];
for (const auto& c : j["channels"]) {
req.channels.push_back(string_to_channel(c));
}
notification_service_.notify(req);
}
}
큐를 쓰면 메시지를 언제 확인(ack)하는가가 전달 보장을 결정합니다. 꺼내자마자 확인하고 발송 중에 워커가 죽으면 알림이 사라지고(최대 한 번), 발송이 끝난 뒤 확인하면 발송은 됐는데 확인 직전에 죽은 경우 같은 알림이 다시 처리됩니다(최소 한 번). 알림 시스템은 대개 “사라지는 것보다 두 번 가는 것이 낫다”는 쪽을 택해 최소 한 번 전달을 쓰고, 그 대신 아래의 중복 방지 캐시나 알림 ID 기록으로 두 번째 발송을 걸러 냅니다. 또 위 워커는 notify가 채널별 실패를 알려 주지 않아 실패해도 메시지를 확인해 버리므로, 채널별 결과를 돌려받아 실패한 채널만 재시도 큐에 다시 넣는 구조가 필요합니다. nlohmann::json::parse는 형식이 잘못된 메시지에 예외를 던지는데, 이를 잡지 않으면 워커가 죽고 같은 메시지가 계속 재전달되어 워커가 반복해서 죽는 “독이 든 메시지(poison message)” 상황이 되므로, 파싱 실패 메시지는 곧바로 DLQ로 보내야 합니다.
SMTP 타임아웃, FCM UNREGISTERED, 스팸 차단: 채널별 에러 해결
SMTP “Connection timed out”
이메일 발송이 한참 기다리다 타임아웃으로 끝난다면, 방화벽이 SMTP 포트를 막고 있거나 호스트·포트가 잘못된 경우가 대부분입니다. 연결 단계에 직접 타임아웃을 걸어 두면 원인을 빨리 드러낼 수 있습니다.
// 연결 타임아웃: 타이머가 먼저 끝나면 소켓을 닫아 연결 시도를 취소
auto timer = std::make_shared<asio::steady_timer>(io_context_);
timer->expires_after(std::chrono::seconds(10));
timer->async_wait([this](boost::system::error_code ec) {
if (!ec) socket_.lowest_layer().close(); // 대기 중인 async_connect가 operation_aborted로 끝남
});
asio::async_connect(socket_.lowest_layer(), endpoints,
[this, timer](boost::system::error_code ec, const asio::ip::tcp::endpoint&) {
timer->cancel();
if (!ec) {
socket_.lowest_layer().set_option(asio::socket_base::keep_alive(true));
}
});
Asio의 동기 connect에는 타임아웃 인자가 없어서, 방화벽이 패킷을 조용히 버리는 환경에서는 OS의 TCP 재전송 한도(리눅스 기본 약 2분)까지 기다립니다. 위처럼 타이머와 비동기 연결을 경쟁시키거나, Beast를 쓴다면 beast::tcp_stream::expires_after()가 이 패턴을 내장하고 있습니다. 클라우드 환경에서는 스팸 방지 때문에 아웃바운드 25번 포트가 기본적으로 막혀 있는 경우가 많으므로, 연결 타임아웃이 난다면 먼저 465나 587 포트를 쓰고 있는지 확인해야 합니다.
FCM “UNREGISTERED” (레거시: “NotRegistered”)
푸시 발송 시 HTTP v1 API가 404를 돌려주고 메시지가 전달되지 않는다면, 앱 재설치나 데이터 삭제로 토큰이 무효가 된 경우입니다. 오류 응답의 details에서 오류 코드를 꺼내 무효 토큰을 지웁니다.
// FCM HTTP v1 오류 응답 파싱 후 토큰 무효화 처리
// 응답 예: {"error": {"code": 404, "status": "NOT_FOUND",
// "details": [{"@type": "...FcmError", "errorCode": "UNREGISTERED"}]}}
void handle_fcm_error(int http_status, const nlohmann::json& response,
const std::string& user_id, const std::string& fcm_token) {
if (!response.contains("error")) return;
for (const auto& d : response["error"].value("details", nlohmann::json::array())) {
std::string code = d.value("errorCode", "");
if (code == "UNREGISTERED" ||
(http_status == 400 && code == "INVALID_ARGUMENT")) {
push_token_store_.remove_token(user_id); // 실제로는 해당 fcm_token만 제거
spdlog::warn("Removed invalid FCM token for user {}", user_id);
}
}
}
레거시 FCM API는 여러 토큰에 한 번에 보내고 HTTP 200 안의 results 배열로 토큰별 오류(NotRegistered, InvalidRegistration)를 알려 줬지만, 이 API는 2024년에 종료되었습니다. HTTP v1은 토큰 하나당 요청 하나이고 오류를 HTTP 상태 코드와 details[].errorCode로 알려 주므로, 예전 형식의 results를 찾는 파싱 코드를 옮겨 오면 오류를 하나도 감지하지 못합니다. INVALID_ARGUMENT는 토큰 형식 오류일 수도 있지만 페이로드 문제(예: data에 숫자 값)일 수도 있으므로, 토큰을 지우기 전에 오류 메시지의 대상 필드를 확인하는 편이 안전합니다. UNAVAILABLE(503)이나 INTERNAL(500)은 일시적인 오류라 백오프 후 재시도 대상입니다.
Webhook “Connection refused” / 5xx
수신 서버가 내려가 있거나 과부하 상태라 재시도해도 계속 실패하는 경우입니다. 재시도 한도를 넘긴 이벤트는 버리지 말고 DLQ에 넣어 두었다가, 수신 측이 복구된 뒤 다시 보내거나 사람이 확인합니다.
// 재시도 + DLQ (Dead Letter Queue)
void send_webhook_with_dlq(const std::string& url,
const nlohmann::json& payload) {
send_webhook(url, payload, [this, url, payload](bool success) {
if (!success) {
dlq_.push({{"url", url}, {"payload", payload}});
spdlog::error("Webhook failed, moved to DLQ: {}", url);
}
});
}
”Too many emails” / 스팸 필터 차단
이메일이 스팸함으로 가거나 수신 서버에서 거부된다면 SPF/DKIM/DMARC 미설정, 갑작스러운 발송량 증가, 스팸성 내용이 주된 원인입니다. 발송량은 코드에서 속도를 제한해 고르게 펼칩니다.
// 발송 속도 제한 (rate limiting) 예시
class RateLimitedEmailSender {
SmtpSender sender_;
std::atomic<int> emails_sent_this_minute_{0};
std::chrono::steady_clock::time_point minute_start_;
public:
bool send_email(const std::string& to,
const std::string& subject,
const std::string& body) {
reset_if_new_minute();
if (emails_sent_this_minute_++ >= 10) {
// 1분당 10통 제한
return false;
}
return sender_.send_email(to, subject, body);
}
};
스팸함으로 가는 문제의 대부분은 코드가 아니라 발신 도메인 인증에서 결정됩니다. SPF는 “이 도메인의 메일을 보낼 수 있는 서버 목록”, DKIM은 “메일 내용에 대한 도메인 서명”, DMARC는 “둘이 실패했을 때 수신 서버가 어떻게 처리할지”를 DNS에 선언하는 것으로, 2024년부터 Gmail과 Yahoo는 대량 발송자에게 이 세 가지를 요구하고 있습니다. 위 rate limiter는 동작은 하지만 minute_start_ 갱신이 원자적이지 않아 여러 스레드가 동시에 호출하면 한도가 어긋날 수 있고, 한도를 넘은 메일을 false로 버리기만 하므로 실제로는 큐에 다시 넣어 나중에 보내야 합니다. 새로 만든 발신 도메인이라면 첫날부터 대량으로 보내지 말고 발송량을 며칠에 걸쳐 늘리는(IP·도메인 워밍업) 것이 차단을 피하는 방법입니다.
SMS “Invalid phone number”
SMS API가 400을 돌려준다면 하이픈이 섞였거나 국가 코드가 빠진 전화번호인 경우가 많습니다. 게이트웨이가 요구하는 형식(국제 형식 E.164 등)으로 정규화한 뒤 보냅니다.
#include <cctype>
// 국제 형식(국가 코드 포함, + 없이)으로 정규화하는 예
std::string normalize_phone(const std::string& phone) {
std::string result;
for (unsigned char c : phone) { // char를 그대로 넘기면 음수 값에서 미정의 동작
if (std::isdigit(c)) result += static_cast<char>(c);
}
if (result.size() == 10 && result[0] == '0') {
result = "82" + result.substr(1); // 한국: 010 -> 8210
} else if (result.size() == 11 && result.substr(0, 2) == "01") {
result = "82" + result.substr(1);
}
return result;
}
국내 번호만 받는다고 가정한 단순한 예라, 해외 번호까지 받는다면 libphonenumber 같은 라이브러리로 검증하는 편이 안전합니다. 게이트웨이마다 국내 형식(01012345678)을 요구하는지 국제 형식(821012345678)을 요구하는지가 다르므로 문서를 먼저 확인합니다.
WebSocket “메시지가 전달되지 않습니다”
온라인 사용자에게 알림이 가지 않는다면 세션 등록이 빠졌거나, 등록할 때와 알림을 보낼 때의 user_id가 다르거나, 연결이 이미 끊어진 경우입니다. 등록과 해제에 같은 user_id와 같은 스트림 포인터를 써야 합니다.
// Session이 websocket 스트림을 shared_ptr(ws_)로 소유한다고 가정
void Session::on_connect() {
user_id_ = get_user_id_from_token(token_); // 해제 때 쓸 수 있도록 멤버에 저장
ws_notifier_->register_session(user_id_, ws_);
}
void Session::on_disconnect() {
ws_notifier_->unregister_session(user_id_, ws_);
}
서버를 여러 대 띄웠다면 알림을 처리한 워커와 사용자의 WebSocket 세션이 다른 서버에 있을 수 있으므로, Redis Pub/Sub처럼 모든 WebSocket 서버에 메시지를 뿌리는 경로가 필요합니다.
채널 우선순위·템플릿 관리·중복 방지
채널별 우선순위
// 긴급도에 따른 채널 선택
std::vector<NotificationChannel> select_channels(
const std::string& priority,
const ChannelPreference& prefs) {
std::vector<NotificationChannel> channels;
if (priority == "high") {
// 긴급: SMS + 푸시 + WebSocket
if (prefs.sms_enabled) channels.push_back(NotificationChannel::SMS);
if (prefs.push_enabled) channels.push_back(NotificationChannel::Push);
channels.push_back(NotificationChannel::WebSocket);
} else if (priority == "normal") {
// 일반: 푸시 + 이메일
if (prefs.push_enabled) channels.push_back(NotificationChannel::Push);
if (prefs.email_enabled) channels.push_back(NotificationChannel::Email);
channels.push_back(NotificationChannel::WebSocket);
} else {
// 낮음: 이메일만
if (prefs.email_enabled) channels.push_back(NotificationChannel::Email);
}
return channels;
}
템플릿 중앙 관리
채널별 템플릿을 코드가 아니라 JSON이나 DB에 두면, 문구를 고칠 때 재배포가 필요 없습니다.
{
"email": {"subject": "주문 완료 - {{order_id}}", "body": "<h1>주문 완료</h1><p>{{order_id}}</p>"},
"sms": "주문번호 {{order_id}} 완료. {{amount}}원 결제됩니다.",
"push": {"title": "주문 완료", "body": "주문번호 #{{order_id}}가 완료되었습니다."}
}
알림 중복 방지
class DeduplicationCache {
std::unordered_map<std::string, int64_t> cache_;
std::mutex mutex_;
static constexpr int TTL_SECONDS = 60;
public:
bool should_send(const std::string& user_id,
const std::string& template_id,
const std::string& dedup_key) {
std::string key = user_id + ":" + template_id + ":" + dedup_key;
// count()를 그대로 쓰면 초가 아니라 클록 틱(보통 ns) 단위라 TTL 비교가 무의미해짐
auto now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
std::lock_guard lock(mutex_);
auto it = cache_.find(key);
if (it != cache_.end() && (now - it->second) < TTL_SECONDS) return false;
cache_[key] = now;
return true;
}
};
이런 캐시에서 흔히 생기는 버그가 시간 단위입니다. system_clock::now().time_since_epoch().count()는 초가 아니라 구현이 정한 틱 수(libstdc++는 나노초, MSVC는 100나노초)를 돌려주므로, TTL_SECONDS = 60과 그대로 비교하면 60나노초 안의 중복만 막는 셈이 되어 사실상 아무것도 걸러 내지 못합니다. 위처럼 duration_cast로 단위를 명시해야 하고, 시스템 시계가 NTP로 조정될 때 흔들리지 않도록 경과 시간 측정에는 steady_clock을 씁니다. 이 캐시는 항목을 지우지 않아 사용자 수 × 템플릿 수만큼 계속 커지고, 서버를 여러 대 띄우면 각자 따로 기억하므로 실제로는 Redis의 SET key 1 NX EX 60 같은 공유 저장소로 옮기는 것이 일반적입니다.
로깅과 메트릭
발송 결과는 사용자·채널·템플릿·결과 코드를 키로 구조화해서 남기고, 채널별 발송 수, 실패 수, 큐 대기 시간, DLQ 크기를 메트릭으로 수집합니다. “알림이 안 왔다”는 문의가 들어왔을 때 어느 채널에서 어떤 응답으로 실패했는지 바로 찾을 수 있어야 하고, DLQ가 쌓이기 시작하면 경보가 울려야 합니다.
멀티 워커·셧다운·알림 히스토리 운영 패턴
부하 분산 (멀티 워커)
// 알림을 여러 워커가 소비
void start_notification_workers(size_t num_workers) {
for (size_t i = 0; i < num_workers; ++i) {
workers_.emplace_back([this]() {
notification_worker();
});
}
}
그레이스풀 셧다운
void NotificationService::shutdown() {
message_queue_.stop_consuming();
for (auto& w : workers_) {
if (w.joinable()) w.join();
}
}
stop_consuming()은 새 메시지를 더 꺼내지 않게 할 뿐이므로, 워커가 지금 보내고 있는 알림은 끝까지 처리하고 확인(ack)한 뒤 종료하게 해야 합니다. 그래야 종료 중에 처리하던 메시지가 확인되지 않은 채 남아 재시작 후 한 번 더 발송되는 일이 줄어듭니다.
설정과 헬스 체크
SMTP 계정, SMS API 키, FCM 서비스 계정 키 같은 비밀값은 코드나 이미지에 넣지 말고 환경 변수나 비밀 관리 시스템에서 읽습니다. 헬스 체크에서 SMTP 서버나 FCM에 실제로 접속해 보는 것은 조심해야 합니다. 외부 서비스가 잠깐 느려졌다는 이유로 모든 알림 워커가 비정상으로 판정되어 재시작되면, 큐에 쌓인 알림까지 처리가 멈춥니다. 프로세스 자체의 상태(큐 연결, 워커 스레드 생존)는 헬스 체크로, 외부 채널의 상태는 실패율 메트릭과 경보로 나눠서 봅니다.
알림 히스토리 저장
void save_notification_log(const std::string& user_id,
NotificationChannel ch,
const std::string& status) {
db_.execute("INSERT INTO notification_log (user_id, channel, status, created_at) "
"VALUES (?, ?, ?, ?)", user_id, channel_to_string(ch), status, now());
}
히스토리에는 상태뿐 아니라 실패 이유(SMTP 응답 코드, FCM errorCode, Webhook HTTP 상태)와 알림 ID를 함께 남겨 두어야, 중복 발송 여부를 확인하거나 고객 문의에 답할 수 있습니다. 발송량이 많다면 이 테이블이 빠르게 커지므로 보존 기간을 정해 오래된 기록을 정리합니다.