C++ SSL/TLS 보안 통신 | OpenSSL과 Asio 연동 완벽 가이드 [#30-2]

C++ SSL/TLS 보안 통신 | OpenSSL과 Asio 연동 완벽 가이드 [#30-2]

이 글의 핵심

HTTP는 안전하지 않아요. TLS 핸드셰이크, OpenSSL·Asio·순수 OpenSSL 예제, 인증서·mTLS, 자주 발생하는 SSL 에러, 모범 사례, 프로덕션 패턴, Let's Encrypt 배포까지.

들어가며: “HTTP는 안전하지 않아요, HTTPS가 필요해요”

문제 시나리오

채팅 서버나 API 서버를 평문 TCP로 만들었는데, 보안 담당자가 이렇게 말합니다:

“로그인 비밀번호가 네트워크에서 그대로 노출돼요. 와이파이 공유 환경에서 누구나 패킷 캡처로 볼 수 있어요.”

// ❌ 평문 HTTP - 위험
// 클라이언트 → 서버: "POST /login HTTP/1.1\r\n...\r\npassword=secret123"
// 와이파이 중간에서 패킷 캡처 시 비밀번호가 그대로 보임!
tcp::socket socket(io);
boost::asio::write(socket, boost::asio::buffer(request));

왜 이런 일이 발생할까요?

HTTP평문(plaintext) 프로토콜입니다. TCP 위에서 데이터가 암호화 없이 전송되므로, 같은 네트워크에 있는 공격자가 패킷 스니핑(Wireshark 등)으로 요청·응답 내용을 그대로 볼 수 있습니다. 비밀번호, 세션 쿠키, API 키가 모두 노출됩니다.

결과:

  • 도청: 중간자(MITM)가 데이터를 가로챔
  • 변조: 요청/응답 내용을 중간에서 수정
  • 위장: 가짜 서버로 연결 유도

해결책: TLS(Transport Layer Security)를 TCP 위에 올려 암호화하고 서버 인증을 합니다. HTTPS, WSS(WebSocket Secure)가 모두 이 방식입니다.

추가 문제 시나리오

시나리오 2: IoT 기기 ↔ 클라우드 API 통신
센서 데이터를 HTTP로 전송하는데, 공장 내부 네트워크가 침해되면 제어 명령이 위조될 수 있습니다. mTLS(상호 인증)로 기기와 서버를 모두 검증해야 합니다.

시나리오 3: 마이크로서비스 간 내부 API
서비스 A가 서비스 B를 호출할 때, 평문 gRPC/HTTP는 같은 Kubernetes 클러스터 내에서도 스니핑 가능합니다. 내부 통신도 TLS로 암호화하고, 클라이언트 인증서로 호출자 신원을 확인하는 패턴이 권장됩니다.

시나리오 4: WebSocket 실시간 채팅
WSS 없이 WS만 쓰면 채팅 메시지가 평문으로 전송됩니다. 공용 와이파이에서 wscat 등으로 쉽게 도청할 수 있어, 실시간 서비스는 반드시 WSS를 사용해야 합니다.

목표:

  • TLS 역할 (암호화, 서버/클라이언트 인증)
  • SSL/TLS 핸드셰이크 시각화
  • OpenSSL + Asio 완전 통합 (서버/클라이언트)
  • 인증서 생성·관리 (자체 서명, CA)
  • 클라이언트 인증서 검증
  • 자주 발생하는 SSL 에러와 해결법
  • 성능 영향 비교
  • 프로덕션 배포 (Let’s Encrypt)

요구 환경: C++17 이상, Boost.Asio, OpenSSL 1.1+

개념을 잡는 비유

소켓과 비동기 I/O는 우편함 주소와 배달 경로로 이해하면 편합니다. 주소(IP·포트)만 맞으면 데이터가 들어오고, Asio는 한 우체국에서 여러 배달부(스레드·핸들러)가 일을 나누는 구조로 보시면 됩니다.


목차

  1. TLS 개요
  2. SSL/TLS 핸드셰이크 다이어그램
  3. OpenSSL과 Asio 완전 통합
  4. 순수 OpenSSL 예제
  5. 인증서 생성과 관리
  6. 클라이언트 인증서 검증 (mTLS)
  7. 자주 발생하는 SSL 에러
  8. 성능 영향 비교
  9. 프로덕션 배포 (Let’s Encrypt)
  10. 모범 사례와 프로덕션 패턴
  11. 실무 주의사항

1. TLS 개요

TLS가 하는 일

기능설명
암호화전송 데이터를 대칭키로 암호화 (AES 등)
서버 인증클라이언트가 서버 인증서로 신원 확인
클라이언트 인증 (선택)서버가 클라이언트 인증서 요구 (mTLS)
무결성메시지 인증 코드(MAC)로 변조 탐지

SSL vs TLS

  • SSL (Secure Sockets Layer): 구버전, 취약점 다수 → 사용 금지
  • TLS (Transport Layer Security): SSL의 후속, TLS 1.2/1.3 권장

2. SSL/TLS 핸드셰이크 다이어그램

TLS 1.2 핸드셰이크 흐름

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버

    Note over C,S: 1. Client Hello
    C->>S: ClientHello (지원 TLS 버전, cipher suites, random)

    Note over C,S: 2. Server Hello
    S->>C: ServerHello (선택된 버전, cipher, random)
    S->>C: Certificate (서버 인증서)
    S->>C: ServerKeyExchange (선택)
    S->>C: ServerHelloDone

    Note over C,S: 3. 클라이언트 검증
    C->>C: 인증서 검증 (CA, 만료, 호스트명)
    C->>S: ClientKeyExchange (premaster secret 암호화)
    C->>S: ChangeCipherSpec
    C->>S: Finished (암호화됨)

    Note over C,S: 4. 서버 완료
    S->>C: ChangeCipherSpec
    S->>C: Finished (암호화됨)

    Note over C,S: 5. 암호화 통신 시작
    C->>S: Application Data (암호화)
    S->>C: Application Data (암호화)

핸드셰이크 단계 요약

flowchart LR
    subgraph Phase1["1단계: 협상"]
        A[Client Hello]
        B[Server Hello]
        C[Certificate]
    end

    subgraph Phase2["2단계: 키 교환"]
        D[ClientKeyExchange]
        E[ChangeCipherSpec]
    end

    subgraph Phase3["3단계: 암호화"]
        F[Finished]
        G[Application Data]
    end

    A --> B --> C --> D --> E --> F --> G

핵심: 핸드셰이크가 끝나면 대칭키가 협상되고, 이후 모든 Application Data는 이 키로 암호화됩니다. Asio의 async_handshake가 이 전체 과정을 처리합니다.


3. OpenSSL과 Asio 완전 통합

아키텍처

flowchart TB
    subgraph App["애플리케이션"]
        Read[async_read_some]
        Write[async_write]
    end

    subgraph Asio["Boost.Asio"]
        SSL["ssl stream"]
    end

    subgraph OpenSSL["OpenSSL"]
        BIO[BIO]
        SSL_CTX[SSL_CTX]
    end

    subgraph TCP["TCP"]
        Socket["tcp socket"]
    end

    App --> SSL
    SSL --> BIO
    BIO --> Socket
    SSL --> SSL_CTX

서버: TLS 에코 서버 완전 구현

#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <iostream>
#include <memory>

namespace ssl = boost::asio::ssl;
using tcp = boost::asio::ip::tcp;

class SslSession : public std::enable_shared_from_this<SslSession> {
    ssl::stream<tcp::socket> stream_;
    std::array<char, 1024> buffer_;

public:
    explicit SslSession(ssl::stream<tcp::socket> stream)
        : stream_(std::move(stream)) {}

    void start() {
        // 1. TLS 핸드셰이크 (서버 역할)
        stream_.async_handshake(
            ssl::stream_base::server,
            [self = shared_from_this()](boost::system::error_code ec) {
                if (!ec) {
                    self->do_read();
                } else {
                    std::cerr << "Handshake failed: " << ec.message() << "\n";
                }
            }
        );
    }

private:
    void do_read() {
        auto self = shared_from_this();
        stream_.async_read_some(
            boost::asio::buffer(buffer_),
            [this, self](boost::system::error_code ec, std::size_t length) {
                if (!ec) {
                    do_write(length);
                }
            }
        );
    }

    void do_write(std::size_t length) {
        auto self = shared_from_this();
        boost::asio::async_write(
            stream_,
            boost::asio::buffer(buffer_, length),
            [this, self](boost::system::error_code ec, std::size_t /*written*/) {
                if (!ec) {
                    do_read();  // 다음 읽기
                }
            }
        );
    }
};

class SslServer {
    tcp::acceptor acceptor_;
    ssl::context ctx_;

public:
    SslServer(boost::asio::io_context& io, uint16_t port)
        : acceptor_(io, tcp::endpoint(tcp::v4(), port)),
          ctx_(ssl::context::tls_server) {
        // 2. 인증서와 비밀키 로드
        ctx_.use_certificate_chain_file("server.crt");
        ctx_.use_private_key_file("server.key", ssl::context::pem);

        // 3. 보안 옵션
        ctx_.set_options(
            ssl::context::default_workarounds |
            ssl::context::no_sslv2 |
            ssl::context::no_sslv3
        );

        do_accept();
    }

private:
    void do_accept() {
        acceptor_.async_accept([this](boost::system::error_code ec, tcp::socket socket) {
            if (!ec) {
                auto ssl_stream = ssl::stream<tcp::socket>(std::move(socket), ctx_);
                std::make_shared<SslSession>(std::move(ssl_stream))->start();
            }
            do_accept();
        });
    }
};

int main() {
    boost::asio::io_context io;
    SslServer server(io, 8443);
    io.run();
    return 0;
}

클라이언트: TLS 클라이언트 완전 구현

#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <openssl/ssl.h>
#include <iostream>

namespace ssl = boost::asio::ssl;
using tcp = boost::asio::ip::tcp;

class SslClient {
    tcp::resolver resolver_;
    ssl::stream<tcp::socket> stream_;
    std::string host_;
    std::string port_;

public:
    SslClient(boost::asio::io_context& io, ssl::context& ctx,
              const std::string& host, const std::string& port)
        : resolver_(io),
          stream_(io, ctx),
          host_(host),
          port_(port) {}

    void connect() {
        resolver_.async_resolve(
            host_, port_,
            [this](boost::system::error_code ec, tcp::resolver::results_type results) {
                if (!ec) {
                    boost::asio::async_connect(
                        stream_.lowest_layer(),
                        results,
                        [this](boost::system::error_code ec, const tcp::endpoint&) {
                            if (!ec) {
                                do_handshake();
                            }
                        }
                    );
                }
            }
        );
    }

private:
    void do_handshake() {
        // 4. SNI(Server Name Indication) 설정 - 호스트명 검증에 필요
        SSL_set_tlsext_host_name(stream_.native_handle(), host_.c_str());

        stream_.async_handshake(
            ssl::stream_base::client,
            [this](boost::system::error_code ec) {
                if (!ec) {
                    do_write("Hello, TLS!");
                } else {
                    std::cerr << "Handshake failed: " << ec.message() << "\n";
                }
            }
        );
    }

    void do_write(const std::string& msg) {
        std::cout << "Sending: " << msg << "\n";
        boost::asio::async_write(
            stream_,
            boost::asio::buffer(msg),
            [this](boost::system::error_code ec, std::size_t) {
                if (!ec) {
                    do_read();
                }
            }
        );
    }

    void do_read() {
        auto buffer = std::make_shared<std::array<char, 1024>>();
        stream_.async_read_some(
            boost::asio::buffer(*buffer),
            [this, buffer](boost::system::error_code ec, std::size_t length) {
                if (!ec) {
                    std::cout << "Received: " << std::string(buffer->data(), length) << "\n";
                }
            }
        );
    }
};

int main() {
    boost::asio::io_context io;
    ssl::context ctx(ssl::context::tls_client);

    // 5. 인증서 검증 활성화 (중요!)
    ctx.set_default_verify_paths();
    ctx.set_verify_mode(ssl::verify_peer);

    SslClient client(io, ctx, "localhost", "8443");
    client.connect();
    io.run();
    return 0;
}

핵심 API 정리

API용도
ssl::context::tls_server / tls_client서버/클라이언트 컨텍스트
ctx.use_certificate_chain_file()인증서 체인 로드
ctx.use_private_key_file()비밀키 로드
ctx.set_verify_mode(verify_peer)인증서 검증 활성화
ctx.set_default_verify_paths()시스템 CA 인증서 사용
stream.async_handshake()TLS 핸드셰이크
stream.async_read_some() / async_write()암호화된 송수신

4. 순수 OpenSSL 예제 (Boost 없이)

Boost.Asio를 쓰지 않고 순수 OpenSSL API만으로 TLS 서버/클라이언트를 구현하는 방법입니다. 임베디드, 레거시 프로젝트, 또는 Asio 의존성을 줄이고 싶을 때 유용합니다.

순수 OpenSSL TLS 서버

// g++ -o ssl_server ssl_server.cpp -lssl -lcrypto
#include <openssl/ssl.h>
#include <openssl/err.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <iostream>

int main() {
    SSL_library_init();
    SSL_load_error_strings();
    OpenSSL_add_all_algorithms();

    SSL_CTX* ctx = SSL_CTX_new(TLS_server_method());
    if (!ctx) {
        ERR_print_errors_fp(stderr);
        return 1;
    }

    // 인증서·비밀키 로드
    if (SSL_CTX_use_certificate_file(ctx, "server.crt", SSL_FILETYPE_PEM) <= 0 ||
        SSL_CTX_use_PrivateKey_file(ctx, "server.key", SSL_FILETYPE_PEM) <= 0) {
        ERR_print_errors_fp(stderr);
        SSL_CTX_free(ctx);
        return 1;
    }

    // SSLv2/3 비활성화
    SSL_CTX_set_options(ctx, SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3);

    int sock = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8443);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(sock, (sockaddr*)&addr, sizeof(addr));
    listen(sock, 5);

    while (true) {
        int client = accept(sock, nullptr, nullptr);
        if (client < 0) continue;

        SSL* ssl = SSL_new(ctx);
        SSL_set_fd(ssl, client);

        if (SSL_accept(ssl) <= 0) {
            ERR_print_errors_fp(stderr);
            SSL_shutdown(ssl);
            SSL_free(ssl);
            close(client);
            continue;
        }

        char buf[1024];
        int n = SSL_read(ssl, buf, sizeof(buf) - 1);
        if (n > 0) {
            buf[n] = '\0';
            SSL_write(ssl, buf, n);  // 에코
        }

        SSL_shutdown(ssl);
        SSL_free(ssl);
        close(client);
    }

    SSL_CTX_free(ctx);
    close(sock);
    return 0;
}

순수 OpenSSL TLS 클라이언트

// g++ -o ssl_client ssl_client.cpp -lssl -lcrypto
#include <openssl/ssl.h>
#include <openssl/err.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <cstring>
#include <iostream>

int main() {
    SSL_library_init();
    SSL_load_error_strings();

    SSL_CTX* ctx = SSL_CTX_new(TLS_client_method());
    SSL_CTX_set_default_verify_paths(ctx);
    SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, nullptr);

    int sock = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8443);
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
    connect(sock, (sockaddr*)&addr, sizeof(addr));

    SSL* ssl = SSL_new(ctx);
    SSL_set_fd(ssl, sock);
    SSL_set_tlsext_host_name(ssl, "localhost");  // SNI

    if (SSL_connect(ssl) <= 0) {
        ERR_print_errors_fp(stderr);
        SSL_free(ssl);
        close(sock);
        return 1;
    }

    const char* msg = "Hello, OpenSSL!";
    SSL_write(ssl, msg, strlen(msg));

    char buf[1024];
    int n = SSL_read(ssl, buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = '\0';
        std::cout << "Received: " << buf << "\n";
    }

    SSL_shutdown(ssl);
    SSL_free(ssl);
    close(sock);
    SSL_CTX_free(ctx);
    return 0;
}

주의: 순수 OpenSSL은 동기(sync) API입니다. 고성능 비동기 서버가 필요하면 Boost.Asio SSL을 사용하세요.


5. 인증서 생성과 관리

자체 서명 인증서 (개발용)

# 1. 비밀키 생성 (2048비트 RSA)
openssl genrsa -out server.key 2048

# 2. 인증서 서명 요청(CSR) 생성
openssl req -new -key server.key -out server.csr

# 3. 자체 서명 인증서 생성 (유효기간 365일)
openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt

# 4. 한 줄로 통합
openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt -days 365 -nodes \
  -subj "/CN=localhost"

주의: 자체 서명 인증서는 클라이언트에서 verify_none으로 건너뛰거나, set_verify_callback에서 수동 허용해야 합니다. 운영 환경에서는 사용 금지.

CA 서명 인증서 (개발/테스트용)

# 1. CA 비밀키와 인증서 생성
openssl genrsa -out ca.key 2048
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt \
  -subj "/CN=MyCA"

# 2. 서버 키 생성
openssl genrsa -out server.key 2048

# 3. CSR 생성 (CN=서버 도메인 중요!)
openssl req -new -key server.key -out server.csr

# 4. CA로 서버 인증서 서명
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out server.crt -days 365 -sha256

# 5. 클라이언트는 ca.crt를 load_verify_file로 로드

인증서 파일 형식

파일형식용도
server.keyPEM서버 비밀키 (절대 노출 금지)
server.crtPEM서버 인증서 (공개)
ca.crtPEMCA 인증서 (클라이언트 검증용)
server.pemPEM인증서+키 합친 파일 (일부 사용)

인증서 검사

# 인증서 내용 확인
openssl x509 -in server.crt -text -noout

# 만료일 확인
openssl x509 -in server.crt -enddate -noout

# 연결 테스트
openssl s_client -connect localhost:8443 -showcerts

5. 클라이언트 인증서 검증

클라이언트 인증(mTLS)이란?

서버가 클라이언트의 인증서를 요구해, “이 클라이언트는 신뢰할 수 있다”고 확인하는 방식입니다. API 서버, IoT 기기, 내부 서비스 간 통신에 사용합니다.

서버 설정: 클라이언트 인증서 요구

ssl::context ctx(ssl::context::tls_server);
ctx.use_certificate_chain_file("server.crt");
ctx.use_private_key_file("server.key", ssl::context::pem);

// 클라이언트 인증서 요구 (필수)
ctx.set_verify_mode(ssl::verify_peer | ssl::verify_fail_if_no_peer_cert);

// 클라이언트 인증서를 검증할 CA 인증서
ctx.load_verify_file("ca.crt");

// 클라이언트 인증서에서 CN 추출 (선택)
ctx.set_verify_callback(
     {
        if (!preverified) return false;
        // 추가 검증: CN, OU 등 확인
        return true;
    }
);

클라이언트: 인증서 전송

// 클라이언트 측: 인증서와 키 로드
ctx.use_certificate_chain_file("client.crt");
ctx.use_private_key_file("client.key", ssl::context::pem);
ctx.load_verify_file("ca.crt");  // 서버 인증서 검증용
ctx.set_verify_mode(ssl::verify_peer);

클라이언트 인증서 생성

# CA로 클라이언트 인증서 서명
openssl genrsa -out client.key 2048
openssl req -new -key client.key -out client.csr -subj "/CN=client1"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out client.crt -days 365 -sha256

6. 자주 발생하는 SSL 에러

에러 1: 인증서 만료 (Certificate Expired)

증상:

handshake failed: certificate verify failed

원인: 서버 인증서의 notAfter 날짜가 지남.

해결:

# 만료일 확인
openssl x509 -in server.crt -enddate -noout
# notAfter=Mar  9 12:00:00 2026 GMT

# 새 인증서 발급 (Let's Encrypt는 certbot renew)
// 에러 코드로 구분 (openssl/err.h, openssl/x509.h 필요)
if (ec.category() == boost::asio::error::get_ssl_category()) {
    auto err = ERR_get_error();
    if (ERR_GET_REASON(err) == X509_V_ERR_CERT_HAS_EXPIRED) {
        spdlog::error("Certificate expired - renew required");
    }
}

에러 2: 호스트명 불일치 (Hostname Mismatch)

증상:

handshake failed: certificate verify failed

원인: 인증서의 CN/Subject Alternative Name과 연결한 호스트명이 다름. 예: localhost로 연결했는데 인증서는 example.com.

해결:

// 1. SNI 설정 (필수!)
SSL_set_tlsext_host_name(stream.native_handle(), "example.com");

// 2. 호스트명 검증 콜백 (OpenSSL 기본은 CN만 검사)
ctx.set_verify_callback(
    ssl::rfc2818_verification("example.com")
);

// 또는 수동 검증
ctx.set_verify_callback(
    [host = std::string("example.com")](bool preverified, ssl::verify_context& ctx) {
        if (!preverified) return false;
        X509* cert = X509_STORE_CTX_get_current_cert(ctx.native_handle());
        return ssl::rfc2818_verification(host)(preverified, ctx);
    }
);

에러 3: 자체 서명 인증서 (Self-Signed Certificate)

증상: 클라이언트에서 verify_peer 시 검증 실패.

해결 (개발 환경만):

// ❌ 운영에서는 절대 사용 금지!
ctx.set_verify_mode(ssl::verify_none);

// ✅ 개발: CA 인증서를 load_verify_file로 지정
ctx.load_verify_file("ca.crt");  // 자체 서명 인증서를 서명한 CA
ctx.set_verify_mode(ssl::verify_peer);

에러 4: 프로토콜 버전 불일치

증상:

handshake failed: wrong version number

원인: 클라이언트/서버가 지원하는 TLS 버전이 맞지 않음 (예: SSLv3만 지원하는 구형 서버).

해결:

// TLS 1.2 이상만 허용
ctx.set_options(ssl::context::no_sslv2 | ssl::context::no_sslv3);

// TLS 1.3 명시 (OpenSSL 1.1.1+)
ctx.set_options(ssl::context::no_sslv2 | ssl::context::no_sslv3);
// 기본이 TLS 1.2/1.3이면 추가 설정 불필요

에러 5: 연결 끊김 (Safari WSS 등)

원인: ssl::streamwebsocket::streamthread-safe하지 않음. 멀티스레드에서 동시 접근 시 연결 불안정.

해결: strand로 직렬화

auto ws_strand = boost::asio::make_strand(ioc);
websocket::stream<ssl::stream<tcp::socket>> ws(ws_strand, ssl_ctx);

ws.async_handshake(host, "/",
    boost::asio::bind_executor(ws_strand,  { /* ... */ }));
ws.async_read(buffer,
    boost::asio::bind_executor(ws_strand,  { /* ... */ }));

에러 6: 인증서 체인 불완전 (Certificate Chain Incomplete)

증상:

unable to get local issuer certificate

원인: 서버가 server.crt만 전송하고 중간 CA 인증서를 포함하지 않음. 클라이언트가 루트 CA까지 체인을 검증하지 못함.

해결:

# fullchain.pem = 서버 인증서 + 중간 CA (체인)
cat server.crt intermediate.crt > fullchain.pem
// 서버: 체인 전체 로드
ctx.use_certificate_chain_file("fullchain.pem");  // ✅ 체인 포함
// ctx.use_certificate_file("server.crt");       // ❌ 단일 인증서만

에러 7: 비밀키 불일치 (Key/Certificate Mismatch)

증상:

key values mismatch

원인: server.crtserver.key가 서로 다른 키 쌍에 속함. 인증서 재발급 후 키를 바꾸지 않았거나, 잘못된 파일을 로드한 경우.

해결:

# 인증서와 키가 쌍인지 확인
openssl x509 -noout -modulus -in server.crt | openssl md5
openssl rsa -noout -modulus -in server.key | openssl md5
# 두 해시가 같아야 함

에러 8: SSL_shutdown 실패 (Broken Pipe)

증상: SSL_shutdown 호출 시 SSL_ERROR_SYSCALL 또는 BROKEN PIPE.

원인: 상대가 이미 연결을 끊었을 때 정상적인 shutdown을 시도하면 실패할 수 있음.

해결:

// Graceful shutdown: 실패해도 무시하고 정리
void close_connection() {
    boost::system::error_code ec;
    stream_.shutdown(ec);  // ec 무시 가능
    stream_.lowest_layer().close(ec);
}

에러 코드 참조

OpenSSL 에러의미
X509_V_ERR_CERT_HAS_EXPIRED인증서 만료
X509_V_ERR_CERT_NOT_YET_VALID인증서 아직 유효하지 않음
X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT자체 서명 인증서
X509_V_ERR_HOSTNAME_MISMATCH호스트명 불일치
SSL_R_UNKNOWN_PROTOCOL프로토콜 버전 불일치
X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT인증서 체인 불완전
SSL_R_SSLV3_ALERT_HANDSHAKE_FAILURE핸드셰이크 실패 (키 불일치 등)

8. 성능 영향 비교

TLS 오버헤드 요약

항목영향
핸드셰이크최초 1회, RTT 1~2회 추가 (지연)
암호화/복호화CPU 사용량 증가 (AES-NI 있으면 미미)
메모리세션당 수 KB 추가
지연핸드셰이크 후에는 평문과 유사

벤치마크 (참고치)

통신 방식초당 요청 수 (단일 연결)연결당 지연
평문 TCP~50,0000.02ms
TLS 1.2~45,0000.025ms
TLS 1.3~48,0000.022ms

결론: 현대 CPU(AES-NI)에서는 TLS 오버헤드가 5~10% 수준. 보안 이득에 비해 수용 가능합니다.

최적화 팁

// 1. 세션 재사용 (Session Resumption) - 핸드셰이크 생략
// OpenSSL 기본 활성화됨

// 2. TLS 1.3 사용 (1-RTT 핸드셰이크)
// OpenSSL 1.1.1+ 기본

// 3. 적절한 cipher suite

ctx.set_options(ssl::context::default_workarounds);
// AES-GCM 사용 cipher 우선 (하드웨어 가속)

9. 프로덕션 배포 (Let’s Encrypt)

Let’s Encrypt 개요

  • 무료 공인 인증서
  • 90일 유효기간 (자동 갱신 권장)
  • certbot으로 발급·갱신 자동화

certbot으로 인증서 발급

# 1. certbot 설치 (Ubuntu/Debian)
sudo apt install certbot

# 2. HTTP-01 챌린지 (웹 서버가 80 포트에서 동작 필요)
sudo certbot certonly --standalone -d example.com

# 3. 인증서 위치
# /etc/letsencrypt/live/example.com/fullchain.pem  (인증서 체인)
# /etc/letsencrypt/live/example.com/privkey.pem   (비밀키)

C++ 서버에서 Let’s Encrypt 인증서 사용

ssl::context ctx(ssl::context::tls_server);

// fullchain.pem = 서버 인증서 + 중간 CA (체인)
ctx.use_certificate_chain_file("/etc/letsencrypt/live/example.com/fullchain.pem");
ctx.use_private_key_file("/etc/letsencrypt/live/example.com/privkey.pem", ssl::context::pem);

자동 갱신 (cron)

# 매일 새벽 2시 갱신 시도
0 2 * * * certbot renew --quiet --deploy-hook "systemctl reload myapp"

갱신 후 서버 재시작

// inotify 또는 systemd socket activation으로 인증서 변경 감지
// 또는 주기적으로 certbot renew 후 프로세스 재시작

프로덕션 체크리스트

  • Let’s Encrypt 또는 유료 CA 인증서 사용
  • TLS 1.2 이상만 허용 (SSLv2/3 비활성화)
  • verify_peer 활성화 (클라이언트)
  • SNI 설정 (가상 호스트)
  • 인증서 만료 모니터링 (90일 주기)
  • 비밀키 권한 600, root만 읽기

10. 모범 사례와 프로덕션 패턴

모범 사례 (Best Practices)

항목권장비권장
TLS 버전TLS 1.2, TLS 1.3SSLv2, SSLv3
인증서 검증verify_peer (운영)verify_none (운영)
Cipher SuiteAES-GCM, ChaCha20-Poly1305RC4, 3DES, NULL
키 길이RSA 2048+, ECDSA P-256+RSA 1024
인증서fullchain (체인 포함)단일 인증서만
비밀키파일 권한 600, root만world-readable

프로덕션 패턴 1: 인증서 핫 리로드

Let’s Encrypt 갱신 후 서버 재시작 없이 인증서를 다시 로드하는 패턴입니다.

// inotify로 fullchain.pem/privkey.pem 변경 감지 → reload_ssl_context() 호출
void reload_ssl_context() {
    ssl::context new_ctx(ssl::context::tls_server);
    new_ctx.use_certificate_chain_file("fullchain.pem");
    new_ctx.use_private_key_file("privkey.pem", ssl::context::pem);
    ctx_.swap(new_ctx);  // atomic 교체 (기존 연결은 이전 context, 새 연결만 새 인증서)
}

프로덕션 패턴 2: TLS 종료 프록시 (Reverse Proxy)

C++ 애플리케이션 앞단에 Nginx/HAProxy가 TLS를 처리하고, 백엔드는 평문으로 받는 패턴입니다.

flowchart LR
    Client[클라이언트] -->|HTTPS| Proxy[Nginx/HAProxy]
    Proxy -->|HTTP 평문| App[C++ 앱]
# Nginx 예시: TLS 종료 후 localhost:8080으로 전달
server {
    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

장점: 인증서 갱신을 Nginx만 재시작하면 됨. C++ 앱은 TLS 코드 불필요.
단점: Nginx ↔ 앱 구간이 평문이므로, 같은 호스트 내부에서만 사용해야 함.

프로덕션 패턴 3: mTLS + RBAC

클라이언트 인증서의 CN/OU로 역할을 판별해 RBAC를 적용합니다. SSL_get_peer_certificateX509_NAME_get_text_by_NID(NID_organizationalUnitName)로 OU 추출 후, /admin/* 등 경로 접근을 제한하세요.

프로덕션 패턴 4: 연결 풀 + TLS 세션 재사용

다운스트림 연결 시 핸드셰이크 완료된 스트림을 풀에 보관해 재사용하면 핸드셰이크 비용을 줄일 수 있습니다.

프로덕션 체크리스트 (확장)

항목확인
HSTS 헤더Strict-Transport-Security (프록시에서)
OCSP Stapling인증서 폐기 상태 실시간 확인 (Nginx 등)
로깅handshake 실패 시 ERR_get_error() 로그
모니터링인증서 만료 30일 전 알림
비밀키HSM 또는 시크릿 매니저 사용 (고보안)

11. 실무 주의사항

버전 및 보안

  • TLS 1.2 이상 권장
  • TLS 1.3 지원 시 1-RTT 핸드셰이크로 지연 감소
  • SSLv2/SSLv3 비활성화

에러 처리

  • handshake/read/write 실패 시 error_code 확인
  • SSL 에러는 ec.category() == get_ssl_category()로 구분
  • 로그에 ERR_get_error() 상세 메시지 기록

리소스 관리

  • ssl::context는 서버당 1개, 재사용
  • ssl::stream은 연결당 1개
  • 연결 종료 시 stream.shutdown() 호출

실무 사례: Safari WSS 연결 끊김

멀티스레드 환경에서 ssl::streamwebsocket::stream에 동시 접근하면 Safari에서 연결이 끊깁니다. strand로 모든 비동기 작업을 직렬화하면 해결됩니다.


체크리스트

구현 체크리스트

  • ssl::context 서버/클라이언트 구분
  • 인증서·비밀키 파일 로드
  • set_verify_mode(verify_peer) (운영)
  • SNI 설정 (클라이언트)
  • 에러 처리 (handshake 실패)
  • strand 사용 (WSS 멀티스레드)

프로덕션 체크리스트

  • Let’s Encrypt 또는 공인 CA 인증서
  • TLS 1.2 이상
  • 인증서 갱신 자동화
  • 비밀키 권한 600

정리

항목내용
ssl::streamTCP 소켓 위 TLS 레이어
handshake클라이언트/서버 각각 async_handshake
서버use_certificate_chain_file, use_private_key_file
클라이언트set_verify_mode(verify_peer), set_default_verify_paths
인증서자체 서명(개발), Let’s Encrypt(운영)
에러만료, 호스트명, 자체 서명, strand

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. HTTPS 서버, WSS(WebSocket Secure), API 서버, IoT 보안 통신 등 TLS 암호화가 필요한 모든 C++ 네트워크 애플리케이션에서 사용합니다.

Q. 자체 서명 인증서를 운영에서 써도 되나요?

A. 안 됩니다. 브라우저·클라이언트에서 경고가 뜨고, 중간자 공격에 취약합니다. Let’s Encrypt(무료) 또는 유료 CA를 사용하세요.

Q. TLS 성능이 걱정돼요.

A. AES-NI를 지원하는 현대 CPU에서는 오버헤드가 5~10% 수준입니다. TLS 1.3은 1-RTT 핸드셰이크로 지연도 줄었습니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. OpenSSL 문서, RFC 8446 (TLS 1.3), Boost.Asio SSL을 참고하세요.

한 줄 요약: OpenSSL·Asio로 SSL/TLS 암호화 통신을 구성할 수 있습니다. 인증서 검증을 켜고, 운영에서는 Let’s Encrypt를 사용하세요.

이전 글: C++ 실전 가이드 #30-1: WebSocket

다음 글: C++ 실전 가이드 #30-3: 프로토콜 설계와 직렬화


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ WebSocket 완벽 가이드 | Beast 핸드셰이크·프레임·Ping/Pong [#30-1]
  • C++ 프로토콜 설계와 직렬화 | TCP 메시지 경계·길이 프리픽스·바이너리 포맷 완벽 가이드 [#30-3]
  • C++ 비동기 I/O 이벤트 루프 완벽 가이드 | Asio run·post

이 글에서 다루는 키워드 (관련 검색어)

C++, SSL, TLS, OpenSSL, Asio, 보안, HTTPS, 인증서, LetsEncrypt 등으로 검색하시면 이 글이 도움이 됩니다.


관련 글

  • C++ HTTP 기초 완벽 가이드 | 요청/응답 파싱·헤더·청크 인코딩·Beast 실전 [#30-1]
  • C++ WebSocket 완벽 가이드 | Beast 핸드셰이크·프레임·Ping/Pong [#30-1]
  • C++ WebSocket 심화 가이드 | 핸드셰이크·프레임·Ping/Pong·에러·프로덕션 패턴
  • C++ 프로토콜 설계와 직렬화 | TCP 메시지 경계·길이 프리픽스·바이너리 포맷 완벽 가이드 [#30-3]
  • C++ Boost.Asio 입문 | io_context·async_read