C++ 멀티채널 알림 서비스: 이메일·SMS·FCM 푸시·WebSocket·Webhook 통합과 재시도·DLQ

들어가며: “주문 완료 알림이 30분 뒤에 와요”

실무에서 겪는 알림 시스템 문제

이커머스에서 주문을 완료했는데 이메일이 30분 뒤에 도착하거나, 앱 푸시가 아예 오지 않는 경험을 겪어 보셨나요? 알림 시스템은 단순히 “메시지 보내기”가 아니라, 채널별 특성(이메일 지연, SMS 비용, 푸시 토큰 관리, Webhook 재시도)을 이해하고 멀티채널로 일관된 경험을 제공해야 합니다. 잘못 구현하면 사용자는 중요한 알림을 놓치며, 비즈니스 기회를 잃습니다.

이 글에서 다루는 것:

  • WebSocket 실시간 알림 (온라인 사용자 즉시 전달)
  • FCM 푸시 알림 (모바일 앱)
  • SMTP 이메일 (상세 내용, 첨부파일)
  • SMS (긴급 알림, 인증 코드)
  • Webhook (외부 시스템 연동)
  • 템플릿 엔진, 재시도, 프로덕션 패턴 요구 환경: C++17 이상, Boost.Asio, nlohmann/json

이 글의 코드는 각 채널이 어떤 프로토콜 흐름으로 동작하는지 보여 주기 위한 뼈대입니다. 일부 HTTP 전송 부분은 생략했고, 운영 코드로 옮길 때 반드시 고쳐야 하는 부분(TLS 검증, 쓰기 직렬화, 멱등성 등)은 각 절의 설명에 표시해 두었습니다.


알림 지연·중복·토큰 만료: 알림 시스템이 필요한 상황

동기로 이메일을 발송하면 SMTP 서버 응답을 기다리는 동안 API 응답이 5~10초 걸립니다. 사용자는 “주문 완료” 화면에서 멈춰 있으며, 이메일은 나중에 도착합니다. 해결: 메시지 큐에 알림 작업을 넣으며, 백그라운드 워커가 비동기로 발송합니다. 사용자는 즉시 “주문 완료” 응답을 받으며, 이메일은 수 초 내 도착합니다. 앱이 백그라운드일 때 WebSocket 연결이 끊어지면 실시간 알림을 받을 수 없습니다. 해결: FCM(Firebase Cloud Messaging) 푸시를 사용해, 앱이 꺼져 있어도 OS가 알림을 표시합니다. WebSocket은 앱이 포그라운드일 때만 사용하며, 백그라운드 시에는 푸시로 전환합니다. 채널별로 따로 구현하면 “주문 완료” 알림을 이메일·SMS·푸시에 각각 보내는 코드가 중복됩니다. 사용자 설정(이메일만 받기, 푸시만 받기)을 반영하기도 어렵습니다. 해결: 통합 알림 서비스에서 템플릿과 사용자 채널 선호도를 기반으로 한 번에 라우팅합니다. 결제 완료 시 ERP·재고 시스템·마케팅 툴에 Webhook으로 이벤트를 전달해야 합니다. HTTP POST가 실패하면 재시도하며, 최종 실패 시 DLQ(Dead Letter Queue)에 넣어 수동 처리해야 합니다. 해결: Webhook 발송기에 재시도·타임아웃·백오프를 적용하며, 실패 시 큐에 보관합니다. SMS는 건당 과금이라 모든 알림을 SMS로 보내면 발송량에 비례해 비용이 빠르게 불어납니다. 해결: 긴급(인증 코드, 보안 알림)만 SMS, 나머지는 이메일·푸시로 전달합니다. 사용자별 채널 선호도와 비용 정책을 적용합니다. FCM 토큰은 앱 재설치·로그아웃 시 변경됩니다. 서버에 저장된 토큰이 오래되면 푸시 발송이 실패합니다. 해결: 푸시 실패 시 토큰 무효 응답(HTTP v1의 UNREGISTERED, 레거시 API의 NotRegistered·InvalidRegistration)을 받으면 DB에서 토큰을 삭제하며, 클라이언트가 새 토큰을 등록하도록 합니다.

여섯 시나리오에 공통으로 깔린 원칙은 알림 발송을 요청 처리 경로에서 떼어 내는 것입니다. 주문 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 명령으로 승격하는 흐름이 따로 필요합니다.

프로토콜 처리에서도 함정이 있습니다. 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;
}

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)처럼 사용자 단위로 지우면 한 기기의 토큰이 무효가 됐다는 이유로 다른 기기의 정상 토큰까지 사라집니다. 오랫동안 갱신되지 않은 토큰(예: 두 달 이상)을 주기적으로 정리하는 것도 FCM 문서가 권하는 운영 방식입니다.


WebSocket 실시간 알림

WebSocket 연결 관리 및 브로드캐스트

// websocket_notifier.cpp
#include <boost/beast.hpp>
#include <boost/asio.hpp>
#include <unordered_map>
#include <mutex>
#include <memory>
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);
        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);
    }
}

큐를 쓰면 메시지를 언제 확인(ack)하는가가 전달 보장을 결정합니다. 꺼내자마자 확인하고 발송 중에 워커가 죽으면 알림이 사라지고(최대 한 번), 발송이 끝난 뒤 확인하면 발송은 됐는데 확인 직전에 죽은 경우 같은 알림이 다시 처리됩니다(최소 한 번). 알림 시스템은 대개 “사라지는 것보다 두 번 가는 것이 낫다”는 쪽을 택해 최소 한 번 전달을 쓰고, 그 대신 아래의 중복 방지 캐시나 알림 ID 기록으로 두 번째 발송을 걸러 냅니다. 또 위 워커는 notify가 채널별 실패를 알려 주지 않아 실패해도 메시지를 확인해 버리므로, 채널별 결과를 돌려받아 실패한 채널만 재시도 큐에 다시 넣는 구조가 필요합니다. nlohmann::json::parse는 형식이 잘못된 메시지에 예외를 던지는데, 이를 잡지 않으면 워커가 죽고 같은 메시지가 계속 재전달되어 워커가 반복해서 죽는 “독이 든 메시지(poison message)” 상황이 되므로, 파싱 실패 메시지는 곧바로 DLQ로 보내야 합니다.


SMTP 타임아웃, FCM UNREGISTERED, 스팸 차단: 채널별 에러 해결

SMTP “Connection timed out”

증상: 이메일 발송 시 30초 후 타임아웃이 발생합니다. 원인: 방화벽에서 SMTP 포트(465, 587) 차단, 잘못된 호스트/포트, DNS 해석 실패. 해결법:

// ✅ 연결 타임아웃: 타이머가 먼저 끝나면 소켓을 닫아 연결 시도를 취소
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를 돌려주고 메시지가 전달되지 않습니다. 원인: FCM 토큰이 만료되었거나 앱 재설치로 무효화됩니다. 해결법:

// ✅ 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

증상: 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);
        }
    });
}

“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 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;
}

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());
}

채널 우선순위·템플릿 관리·중복 방지

채널별 우선순위

// 긴급도에 따른 채널 선택
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;
}

템플릿 중앙 관리

{
  "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 같은 공유 저장소로 옮기는 것이 일반적입니다.

로깅 및 메트릭

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{};

멀티 워커·셧다운·알림 히스토리 운영 패턴

부하 분산 (멀티 워커)

// 알림을 여러 워커가 소비
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();
    }
}

설정 외부화

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;
}

헬스 체크

bool NotificationService::health_check() {
    bool ok = true;
    ok &= smtp_sender_.test_connection();
    ok &= push_sender_.validate_token();
    return ok;
}

알림 히스토리 저장

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 SSL/TLS 적용 (포트 465/587)☐
이메일 발송 비동기화 (메시지 큐)☐
FCM 토큰 만료 시 DB 삭제☐
Webhook 재시도 + exponential backoff☐
Webhook 실패 시 DLQ 저장☐
사용자별 채널 선호도 적용☐
알림 중복 방지 (Deduplication)☐
템플릿 외부화 (JSON/DB)☐
로깅 및 메트릭 수집☐
Rate limiting (이메일/SMS)☐
SPF/DKIM/DMARC 설정 (이메일)☐
전화번호 정규화 (SMS)☐

채널별 구현 요약

채널구현 요약
이메일SMTP + SSL, 템플릿, 비동기 큐
SMSREST API, 전화번호 정규화, Rate limit
푸시FCM v1 API, 토큰 관리, UNREGISTERED 처리
WebSocket세션 등록/해제, 실시간 브로드캐스트
Webhook재시도, 백오프, DLQ

핵심 원칙:

  1. 비동기 발송으로 API 응답 지연 방지
  2. 채널별 특성에 맞는 우선순위 적용
  3. 토큰/연결 상태 관리로 실패 최소화
  4. 재시도와 DLQ로 안정성 확보
  5. 사용자 선호도와 비용 정책 반영

같이 보면 좋은 글