C++ 실시간 알림 시스템 완벽 가이드 | 이메일·SMS·푸시·Webhook 멀티채널 [#50-12]
이 글의 핵심
멀티채널 알림: WebSocket 실시간 알림, FCM 푸시, SMTP 이메일, SMS, Webhook. 템플릿 엔진, 재시도, 프로덕션 패턴까지 실전 구현. 이커머스에서 주문을 완료했는데 이메일이 30분 뒤에 도착하거나, 앱 푸시가 아예 오지 않는 경험을 겪어 보셨나요? 알림 시스템은 단순히
들어가며: “주문 완료 알림이 30분 뒤에 와요”
실무에서 겪는 알림 시스템 문제
이커머스에서 주문을 완료했는데 이메일이 30분 뒤에 도착하거나, 앱 푸시가 아예 오지 않는 경험을 겪어 보셨나요? 알림 시스템은 단순히 “메시지 보내기”가 아니라, 채널별 특성(이메일 지연, SMS 비용, 푸시 토큰 관리, Webhook 재시도)을 이해하고 멀티채널로 일관된 경험을 제공해야 합니다. 잘못 구현하면 사용자는 중요한 알림을 놓치고, 비즈니스 기회를 잃습니다.
이 글에서 다루는 것:
- WebSocket 실시간 알림 (온라인 사용자 즉시 전달)
- FCM 푸시 알림 (모바일 앱)
- SMTP 이메일 (상세 내용, 첨부파일)
- SMS (긴급 알림, 인증 코드)
- Webhook (외부 시스템 연동)
- 템플릿 엔진, 재시도, 프로덕션 패턴
요구 환경: C++17 이상, Boost.Asio, nlohmann/json
이 글을 읽으면:
- 멀티채널 알림 시스템 아키텍처를 설계할 수 있습니다.
- 각 채널별 완전한 구현 예제를 적용할 수 있습니다.
- 실전 에러와 프로덕션 패턴을 활용할 수 있습니다.
문제 시나리오: 알림 시스템이 필요한 상황
시나리오 1: “주문 완료 알림이 30분 뒤에 와요”
동기로 이메일을 발송하면 SMTP 서버 응답을 기다리는 동안 API 응답이 5~10초 걸립니다. 사용자는 “주문 완료” 화면에서 멈춰 있고, 이메일은 나중에 도착합니다. 해결: 메시지 큐에 알림 작업을 넣고, 백그라운드 워커가 비동기로 발송합니다. 사용자는 즉시 “주문 완료” 응답을 받고, 이메일은 수 초 내 도착합니다.
시나리오 2: “앱을 켜야만 알림을 받아요”
앱이 백그라운드일 때 WebSocket 연결이 끊어지면 실시간 알림을 받을 수 없습니다. 해결: FCM(Firebase Cloud Messaging) 푸시를 사용해, 앱이 꺼져 있어도 OS가 알림을 표시합니다. WebSocket은 앱이 포그라운드일 때만 사용하고, 백그라운드 시에는 푸시로 전환합니다.
시나리오 3: “같은 알림이 이메일·SMS·푸시에 세 번 와요”
채널별로 따로 구현하면 “주문 완료” 알림을 이메일·SMS·푸시에 각각 보내는 코드가 중복됩니다. 사용자 설정(이메일만 받기, 푸시만 받기)을 반영하기도 어렵습니다. 해결: 통합 알림 서비스에서 템플릿과 사용자 채널 선호도를 기반으로 한 번에 라우팅합니다.
시나리오 4: “외부 시스템에 이벤트를 알려야 해요”
결제 완료 시 ERP·재고 시스템·마케팅 툴에 Webhook으로 이벤트를 전달해야 합니다. HTTP POST가 실패하면 재시도하고, 최종 실패 시 DLQ(Dead Letter Queue)에 넣어 수동 처리해야 합니다. 해결: Webhook 발송기에 재시도·타임아웃·백오프를 적용하고, 실패 시 큐에 보관합니다.
시나리오 5: “SMS 비용이 폭발해요”
모든 알림을 SMS로 보내면 건당 10~20원으로 월 수백만 원이 나옵니다. 해결: 긴급(인증 코드, 보안 알림)만 SMS, 나머지는 이메일·푸시로 전달합니다. 사용자별 채널 선호도와 비용 정책을 적용합니다.
시나리오 6: “푸시 토큰이 만료돼서 알림이 안 가요”
FCM 토큰은 앱 재설치·로그아웃 시 변경됩니다. 서버에 저장된 토큰이 오래되면 푸시 발송이 실패합니다. 해결: 푸시 실패 시 InvalidRegistration·NotRegistered 응답을 받으면 DB에서 토큰을 삭제하고, 클라이언트가 새 토큰을 등록하도록 합니다.
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
목차
- 시스템 아키텍처
- 이메일 알림 (SMTP)
- SMS 알림
- 푸시 알림 (FCM)
- WebSocket 실시간 알림
- Webhook 연동
- 통합 알림 서비스
- 자주 발생하는 에러와 해결법
- 베스트 프랙티스
- 프로덕션 패턴
- 구현 체크리스트
1. 시스템 아키텍처
전체 구조
flowchart TB
subgraph App["애플리케이션"]
A1[주문 완료]
A2[인증 요청]
A3[이벤트 발생]
A1 --> NS
A2 --> NS
A3 --> NS
end
subgraph NS["알림 서비스"]
Router[라우터]
Template[템플릿 엔진]
Router --> Template
end
subgraph Channels["채널"]
WS[WebSocket]
Email[SMTP 이메일]
SMS[SMS API]
Push[FCM 푸시]
WH[Webhook]
end
NS --> WS
NS --> Email
NS --> SMS
NS --> Push
NS --> WH
subgraph Queue["메시지 큐"]
MQ[RabbitMQ/Kafka]
end
NS --> MQ
MQ --> NS
채널별 특성 비교
| 채널 | 지연 | 비용 | 적합 용도 | 오프라인 수신 |
|---|---|---|---|---|
| WebSocket | 즉시 | 낮음 | 실시간 대시보드, 채팅 | ✗ (연결 필요) |
| 이메일 | 수 초~수 분 | 낮음 | 상세 내용, 영수증 | ✓ |
| SMS | 수 초 | 높음 | 인증 코드, 긴급 알림 | ✓ |
| 푸시 | 수 초 | 낮음 | 앱 알림 | ✓ |
| Webhook | 수 초 | 없음 | 외부 시스템 연동 | - |
시퀀스 다이어그램: 멀티채널 알림 흐름
sequenceDiagram
participant App as 애플리케이션
participant NS as 알림 서비스
participant MQ as 메시지 큐
participant WS as WebSocket
participant Email as SMTP
participant Push as FCM
App->>NS: notify(user_id, "order_complete", data)
NS->>NS: 템플릿 렌더링
NS->>MQ: 알림 작업 발행 (비동기)
par WebSocket (온라인 사용자)
NS->>WS: 실시간 전송
WS->>App: 즉시 표시
and 이메일
MQ->>Email: SMTP 발송
and 푸시
MQ->>Push: 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;
};
2. 이메일 알림 (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:
SmtpSender(asio::io_context& ctx,
const std::string& host, const std::string& port,
const std::string& user, const std::string& pass)
: io_context_(ctx), socket_(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);
socket_.set_verify_callback( { return true; });
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;
}
}
};
이메일 템플릿 예시
// 주문 완료 이메일 템플릿
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();
}
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;
}
3. 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 예시
std::string render_auth_code_sms(const nlohmann::json& data) {
return "[서비스명] 인증번호: " + data["code"].get<std::string>() +
"\n3분 내 입력해 주세요.";
}
4. 푸시 알림 (FCM)
FCM HTTP v1 API 클라이언트
// push_sender.cpp
#include <boost/asio.hpp>
#include <boost/beast.hpp>
#include <nlohmann/json.hpp>
#include <string>
// 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 토큰 저장 및 갱신
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;
}
};
5. WebSocket 실시간 알림
WebSocket 연결 관리 및 브로드캐스트
// websocket_notifier.cpp
#include <boost/beast.hpp>
#include <unordered_map>
#include <mutex>
#include <memory>
namespace beast = boost::beast;
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;
std::string data = message.dump();
for (auto& w : it->second) {
auto ws = w.lock();
if (ws && ws->is_open()) {
ws->async_write(asio::buffer(data),
{});
}
}
}
};
실시간 알림 메시지 형식
{
"type": "notification",
"id": "notif-12345",
"template_id": "order_complete",
"title": "주문 완료",
"body": "주문번호 #12345가 완료되었습니다.",
"data": {
"order_id": "12345",
"amount": 50000
},
"timestamp": 1709876543
}
6. 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;
}
};
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}
]
}
}
7. 통합 알림 서비스
라우터 및 템플릿 엔진
// 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);
for (auto ch : req.channels) {
auto prefs = channel_prefs_->get(req.user_id);
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;
}
};
메시지 큐 연동 (비동기 발송)
// 알림을 큐에 넣고 워커가 비동기 처리
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);
}
}
8. 자주 발생하는 에러와 해결법
에러 1: SMTP “Connection timed out”
증상: 이메일 발송 시 30초 후 타임아웃이 발생합니다.
원인: 방화벽에서 SMTP 포트(465, 587) 차단, 잘못된 호스트/포트, DNS 해석 실패.
해결법:
// ✅ 연결 타임아웃 + keep_alive 설정
acceptor_.async_connect(endpoints, [this](error_code ec, auto ep) {
if (!ec) {
socket_.lowest_layer().set_option(asio::socket_base::keep_alive(true));
}
});
asio::steady_timer timer(io_context_);
timer.expires_after(std::chrono::seconds(10));
에러 2: FCM “InvalidRegistration” / “NotRegistered”
증상: 푸시 발송 시 200 OK이지만 메시지가 전달되지 않습니다.
원인: FCM 토큰이 만료되었거나 앱 재설치로 무효화됨.
해결법:
// ✅ FCM 응답 파싱 후 토큰 무효화 처리
void handle_fcm_response(const nlohmann::json& response,
const std::string& user_id) {
auto it = response.find("results");
if (it == response.end()) return;
for (size_t i = 0; i < it->size(); ++i) {
auto& r = (*it)[i];
std::string error = r.value("error", "");
if (error == "InvalidRegistration" || error == "NotRegistered") {
push_token_store_.remove_token(user_id);
spdlog::warn("Removed invalid FCM token for user {}", user_id);
}
}
}
에러 3: Webhook “Connection refused” / 5xx
증상: Webhook 호출이 실패하고 재시도해도 계속 실패합니다.
원인: 수신 서버 다운, 네트워크 불안정, 수신 서버 과부하.
해결법:
// ✅ 재시도 + 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);
}
});
}
에러 4: “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);
}
};
에러 5: SMS “Invalid phone number”
증상: SMS API가 400 Bad Request를 반환합니다.
원인: 전화번호 형식 오류(국가코드 누락, 하이픈 포함).
해결법:
// ✅ 전화번호 정규화
std::string normalize_phone(const std::string& phone) {
std::string result;
for (char c : phone) {
if (std::isdigit(c)) result += 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;
}
에러 6: WebSocket “메시지가 전달되지 않습니다”
증상: 온라인 사용자에게 WebSocket 알림이 가지 않습니다.
원인: 세션 등록 누락, user_id 불일치, 연결이 끊어진 상태.
해결법:
// ✅ 연결 시 세션 등록, 끊김 시 해제
void Session::on_connect() {
auto user_id = get_user_id_from_token(token_);
ws_notifier_->register_session(user_id, shared_from_this());
}
void Session::on_disconnect() {
ws_notifier_->unregister_session(user_id_, shared_from_this());
}
9. 베스트 프랙티스
1. 채널별 우선순위
// 긴급도에 따른 채널 선택
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;
}
2. 템플릿 중앙 관리
{
"email": {"subject": "주문 완료 - {{order_id}}", "body": "<h1>주문 완료</h1><p>{{order_id}}</p>"},
"sms": "주문번호 {{order_id}} 완료. {{amount}}원 결제됨.",
"push": {"title": "주문 완료", "body": "주문번호 #{{order_id}}가 완료되었습니다."}
}
3. 알림 중복 방지
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;
auto now = std::chrono::system_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;
}
};
4. 로깅 및 메트릭
spdlog::info("notification_sent user={} channel={} template={}",
user_id, channel_to_string(ch), template_id);
metrics::counter notifications_sent_total{};
metrics::histogram notification_latency_seconds{};
10. 프로덕션 패턴
패턴 1: 부하 분산 (멀티 워커)
// 알림을 여러 워커가 소비
void start_notification_workers(size_t num_workers) {
for (size_t i = 0; i < num_workers; ++i) {
workers_.emplace_back([this]() {
notification_worker();
});
}
}
패턴 2: 그레이스풀 셧다운
void NotificationService::shutdown() {
message_queue_.stop_consuming();
for (auto& w : workers_) {
if (w.joinable()) w.join();
}
}
패턴 3: 설정 외부화
struct NotificationConfig {
std::string smtp_host, smtp_user, smtp_pass;
std::string fcm_project_id, fcm_token_path;
std::string sms_api_url, sms_api_key;
int max_retries = 5;
};
NotificationConfig load_config() {
NotificationConfig cfg;
if (auto v = std::getenv("SMTP_HOST")) cfg.smtp_host = v;
if (auto v = std::getenv("FCM_PROJECT_ID")) cfg.fcm_project_id = v;
return cfg;
}
패턴 4: 헬스 체크
bool NotificationService::health_check() {
bool ok = true;
ok &= smtp_sender_.test_connection();
ok &= push_sender_.validate_token();
return ok;
}
패턴 5: 알림 히스토리 저장
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());
}
11. 구현 체크리스트
| 항목 | 확인 |
|---|---|
| SMTP SSL/TLS 적용 (포트 465/587) | ☐ |
| 이메일 발송 비동기화 (메시지 큐) | ☐ |
| FCM 토큰 만료 시 DB 삭제 | ☐ |
| Webhook 재시도 + exponential backoff | ☐ |
| Webhook 실패 시 DLQ 저장 | ☐ |
| 사용자별 채널 선호도 적용 | ☐ |
| 알림 중복 방지 (Deduplication) | ☐ |
| 템플릿 외부화 (JSON/DB) | ☐ |
| 로깅 및 메트릭 수집 | ☐ |
| Rate limiting (이메일/SMS) | ☐ |
| SPF/DKIM/DMARC 설정 (이메일) | ☐ |
| 전화번호 정규화 (SMS) | ☐ |
정리
| 채널 | 구현 요약 |
|---|---|
| 이메일 | SMTP + SSL, 템플릿, 비동기 큐 |
| SMS | REST API, 전화번호 정규화, Rate limit |
| 푸시 | FCM v1 API, 토큰 관리, InvalidRegistration 처리 |
| WebSocket | 세션 등록/해제, 실시간 브로드캐스트 |
| Webhook | 재시도, 백오프, DLQ |
핵심 원칙:
- 비동기 발송으로 API 응답 지연 방지
- 채널별 특성에 맞는 우선순위 적용
- 토큰/연결 상태 관리로 실패 최소화
- 재시도와 DLQ로 안정성 확보
- 사용자 선호도와 비용 정책 반영
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 실시간 알림, 푸시 메시지, 이메일 발송, SMS 전송, 외부 시스템 연동 등 사용자 engagement에 활용합니다. 실무에서는 위 본문의 예제와 채널 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. FCM 문서, Boost.Beast도 활용하면 좋습니다.
한 줄 요약: 이메일·SMS·푸시·WebSocket·Webhook 멀티채널 알림을 통합 서비스로 구현하고, 프로덕션 패턴을 적용할 수 있습니다.
다음 글: [C++ 실전 가이드 #51-1] 프로파일링 도구 마스터
이전 글: [C++ 실전 가이드 #50-11] 파일 스토리지 시스템
관련 글
- C++ 시리즈 전체 보기
- C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
- C++ ADL |
- C++ Aggregate Initialization |