C++ TLS 통신: OpenSSL과 Asio 연동, 인증서 관리, mTLS, 흔한 SSL 에러
들어가며: “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 위에서 데이터가 암호화 없이 전송되므로, 같은 네트워크에 있는 공격자가 패킷 캡처 도구로 요청과 응답을 그대로 볼 수 있고, 비밀번호·세션 쿠키·API 키가 모두 노출됩니다. 경로 중간에 있는 공격자(MITM)는 내용을 엿보는 데서 그치지 않고 요청이나 응답을 바꿔치기하거나, 가짜 서버로 연결을 유도할 수도 있습니다.
TLS(Transport Layer Security)는 TCP 위에서 데이터를 암호화하고, 인증서로 서버(필요하면 클라이언트까지)의 신원을 확인해 이 세 가지 위협을 막습니다. HTTPS와 WSS(WebSocket Secure)가 모두 이 방식입니다.
추가 문제 시나리오
IoT 기기 ↔ 클라우드 API 통신
센서 데이터를 HTTP로 전송하는데 공장 내부 네트워크가 침해되면 제어 명령이 위조될 수 있습니다. mTLS(상호 인증)로 기기와 서버를 모두 검증해야 합니다.
마이크로서비스 간 내부 API
서비스 A가 서비스 B를 호출할 때, 평문 gRPC/HTTP는 같은 Kubernetes 클러스터 안에서도 스니핑될 수 있습니다. 내부 통신도 TLS로 암호화하고, 클라이언트 인증서로 호출자 신원을 확인하는 패턴을 권장합니다.
WebSocket 실시간 채팅
WSS 없이 WS만 쓰면 채팅 메시지가 평문으로 전송됩니다. 공용 와이파이에서 wscat 등으로 쉽게 도청할 수 있으므로, 실시간 서비스는 반드시 WSS를 사용해야 합니다.
이 글은 TLS가 암호화와 인증을 어떻게 제공하는지, 핸드셰이크가 어떤 순서로 진행되는지부터 설명합니다. 이어서 Boost.Asio와 순수 OpenSSL로 TLS 서버·클라이언트를 구현하고, 자체 서명·CA 인증서 생성, 클라이언트 인증서(mTLS) 검증, 자주 만나는 SSL 에러, Let’s Encrypt를 이용한 운영 배포를 차례로 다룹니다.
요구 환경: C++17 이상, Boost.Asio, OpenSSL 1.1+
TLS 개요
TLS가 하는 일
| 기능 | 설명 |
|---|---|
| 암호화 | 전송 데이터를 대칭키로 암호화 (AES 등) |
| 서버 인증 | 클라이언트가 서버 인증서로 신원 확인 |
| 클라이언트 인증 (선택) | 서버가 클라이언트 인증서 요구 (mTLS) |
| 무결성 | 메시지 인증 코드(MAC)로 변조 탐지 |
SSL vs TLS
- SSL (Secure Sockets Layer): 구버전, 취약점 다수 → 사용 금지
- TLS (Transport Layer Security): SSL의 후속, TLS 1.2/1.3 권장
SSL/TLS 핸드셰이크 다이어그램
TLS 1.2 핸드셰이크 흐름
sequenceDiagram
participant C as 클라이언트
participant S as 서버
C->>S: ClientHello (지원 버전, cipher suites, random, SNI)
S->>C: ServerHello (선택된 버전, cipher, random)
S->>C: Certificate (서버 인증서 체인)
S->>C: ServerKeyExchange (ECDHE 공개값과 서명)
S->>C: ServerHelloDone
C->>C: 인증서 검증 (CA 체인, 유효기간, 호스트명)
C->>S: ClientKeyExchange (클라이언트 ECDHE 공개값)
C->>S: ChangeCipherSpec
C->>S: Finished (암호화됨)
S->>C: ChangeCipherSpec
S->>C: Finished (암호화됨)
C->>S: Application Data (암호화)
S->>C: Application Data (암호화)
ECDHE 키 교환에서는 양쪽이 교환한 공개값으로 같은 비밀값을 계산하므로 키 자체는 네트워크를 지나가지 않습니다. 예전의 RSA 키 교환처럼 클라이언트가 premaster secret을 서버 공개키로 암호화해 보내는 방식은 서버 비밀키가 유출되면 과거 트래픽까지 복호화되기 때문에(순방향 비밀성 없음) TLS 1.3에서 제거되었습니다. TLS 1.3은 ClientHello에 키 공유 값을 미리 실어 보내 왕복 한 번(1-RTT)으로 핸드셰이크를 끝내고, 서버 인증서도 암호화된 상태로 보냅니다.
핸드셰이크 단계 요약
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가 이 전체 과정을 처리합니다.
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 <array>
#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);
}
// ec가 ssl::error::stream_truncated면 상대가 close_notify 없이 TCP를 끊은 것
}
);
}
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. 보안 옵션: SSLv2/v3, TLS 1.0/1.1 비활성화
ctx_.set_options(
ssl::context::default_workarounds |
ssl::context::no_sslv2 |
ssl::context::no_sslv3 |
ssl::context::no_tlsv1 |
ssl::context::no_tlsv1_1
);
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: 서버가 여러 도메인을 호스팅할 때 어떤 인증서를 줄지 고르게 함
SSL_set_tlsext_host_name(stream_.native_handle(), host_.c_str());
// 5. 호스트명 검증: verify_peer만으로는 인증서의 이름을 확인하지 않음
stream_.set_verify_callback(ssl::host_name_verification(host_));
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);
// 인증서 체인 검증 활성화
ctx.set_default_verify_paths(); // 시스템 CA 저장소
// ctx.load_verify_file("ca.crt"); // 자체 CA로 서명한 테스트 인증서라면
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) | 인증서 체인 검증 활성화 (호스트명은 별도) |
stream.set_verify_callback(host_name_verification(host)) | 호스트명 검증 (Boost 1.73+, 이전 버전은 rfc2818_verification) |
ctx.set_default_verify_paths() | 시스템 CA 인증서 사용 |
stream.async_handshake() | TLS 핸드셰이크 |
stream.async_read_some() / async_write() | 암호화된 송수신 |
순수 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() {
// OpenSSL 1.1.0부터 라이브러리 초기화는 자동이라 SSL_library_init() 등은 필요 없음
SSL_CTX* ctx = SSL_CTX_new(TLS_server_method());
if (!ctx) {
ERR_print_errors_fp(stderr);
return 1;
}
// 인증서 체인·비밀키 로드
if (SSL_CTX_use_certificate_chain_file(ctx, "server.crt") <= 0 ||
SSL_CTX_use_PrivateKey_file(ctx, "server.key", SSL_FILETYPE_PEM) <= 0) {
ERR_print_errors_fp(stderr);
SSL_CTX_free(ctx);
return 1;
}
// TLS 1.2 미만 비활성화
SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION);
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_free(ssl); // 핸드셰이크 실패 시 SSL_shutdown은 호출하지 않음
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_CTX* ctx = SSL_CTX_new(TLS_client_method());
SSL_CTX_set_default_verify_paths(ctx);
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, nullptr);
SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION);
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
SSL_set1_host(ssl, "localhost"); // 호스트명 검증 (OpenSSL 1.1.0+)
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도 논블로킹 소켓과 SSL_ERROR_WANT_READ/SSL_ERROR_WANT_WRITE 처리로 비동기 서버를 만들 수 있지만 상태 관리가 복잡하므로, 많은 연결을 다룬다면 이를 감싸 주는 Boost.Asio SSL 같은 라이브러리를 쓰는 편이 현실적입니다.
인증서 생성과 관리
자체 서명 인증서 (개발용)
# 한 줄로: 비밀키 + 자체 서명 인증서 (OpenSSL 1.1.1+)
openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt -days 365 -nodes \
-subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
요즘 클라이언트는 호스트명을 인증서의 CN이 아니라 SAN(Subject Alternative Name)에서 확인하므로 -addext로 SAN을 넣어야 합니다. 자체 서명 인증서를 신뢰시키려면 검증을 끄지 말고, 클라이언트에서 load_verify_file("server.crt")로 그 인증서 자체를 신뢰 앵커로 등록합니다. 운영 환경에서는 공인 CA 인증서를 씁니다.
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 생성
openssl req -new -key server.key -out server.csr -subj "/CN=localhost"
# 4. CA로 서버 인증서 서명 (SAN은 확장 파일로 지정)
printf "subjectAltName=DNS:localhost,IP:127.0.0.1\n" > san.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days 365 -sha256 -extfile san.ext
# 5. 클라이언트는 ca.crt를 load_verify_file로 로드
인증서 파일 형식
| 파일 | 형식 | 용도 |
|---|---|---|
server.key | PEM | 서버 비밀키 (절대 노출 금지) |
server.crt | PEM | 서버 인증서 (공개) |
ca.crt | PEM | CA 인증서 (클라이언트 검증용) |
server.pem | PEM | 인증서+키 합친 파일 (일부 사용) |
인증서 검사
# 인증서 내용 확인
openssl x509 -in server.crt -text -noout
# 만료일 확인
openssl x509 -in server.crt -enddate -noout
# 연결 테스트
openssl s_client -connect localhost:8443 -showcerts
클라이언트 인증서 검증
클라이언트 인증(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");
// 체인 검증 결과에 추가 조건을 걸 때 (선택)
ctx.set_verify_callback(
[](bool preverified, ssl::verify_context& vctx) {
if (!preverified) return false;
// 체인의 각 인증서마다 호출되므로, leaf(깊이 0)에서만 CN/OU 등을 확인
int depth = X509_STORE_CTX_get_error_depth(vctx.native_handle());
if (depth == 0) {
X509* cert = X509_STORE_CTX_get_current_cert(vctx.native_handle());
(void)cert; // X509_get_subject_name(cert) 등으로 허용 목록과 비교
}
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
자주 발생하는 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/x509.h)
long vr = SSL_get_verify_result(stream.native_handle());
if (vr == X509_V_ERR_CERT_HAS_EXPIRED) {
std::cerr << "Certificate expired - renew required\n";
} else if (vr != X509_V_OK) {
std::cerr << "verify error: " << X509_verify_cert_error_string(vr) << "\n";
}
ERR_get_error()가 돌려주는 것은 OpenSSL 에러 큐의 코드이고, 인증서 검증 결과(X509_V_ERR_*)는 SSL_get_verify_result()로 따로 얻어야 합니다.
에러 2: 호스트명 불일치 (Hostname Mismatch)
증상:
handshake failed: certificate verify failed
원인: 인증서의 SAN(Subject Alternative Name)에 연결한 호스트명이 없기 때문입니다. 예를 들어 localhost로 연결했는데 인증서는 example.com용인 경우입니다. OpenSSL은 기본적으로 호스트명을 검사하지 않으므로, 클라이언트 코드에서 명시적으로 켜 두어야 이 에러가 납니다. 켜지 않으면 다른 도메인의 유효한 인증서로도 연결이 성공해 버려 중간자 공격에 노출됩니다.
해결:
// 1. SNI 설정: 서버가 올바른 인증서를 고르도록
SSL_set_tlsext_host_name(stream.native_handle(), "example.com");
// 2. 호스트명 검증 (Boost 1.73+)
stream.set_verify_callback(ssl::host_name_verification("example.com"));
에러 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가 아닌 데이터를 받았을 때 가장 흔히 납니다. TLS 클라이언트로 평문 HTTP 포트(예: 80, 8080)에 연결했거나, 프록시가 평문 응답을 돌려준 경우입니다. 양쪽이 공통으로 지원하는 TLS 버전이 없을 때는 보통 unsupported protocol이나 handshake failure 경고가 납니다.
해결: 먼저 포트와 프로토콜이 맞는지 openssl s_client -connect host:port로 확인합니다. 버전 하한은 이렇게 지정합니다.
// TLS 1.2 이상만 허용 (OpenSSL 1.1.0+)
SSL_CTX_set_min_proto_version(ctx.native_handle(), TLS1_2_VERSION);
에러 5: 여러 스레드에서 같은 스트림에 접근
ssl::stream과 websocket::stream은 스레드 안전하지 않습니다. 여러 스레드가 io_context::run()을 돌리는 서버에서 같은 스트림에 대해 비동기 작업을 동시에 시작하거나, 쓰기가 끝나기 전에 두 번째 async_write를 시작하면 TLS 레코드가 섞여 연결이 깨지거나 크래시합니다. 스트림을 strand에 묶어 모든 핸들러가 직렬로 실행되게 하고, 쓰기는 큐에 쌓아 하나씩 보냅니다.
auto ws_strand = boost::asio::make_strand(ioc);
websocket::stream<ssl::stream<tcp::socket>> ws(ws_strand, ssl_ctx); // 스트림의 executor가 strand
// 이 스트림의 비동기 작업 완료 핸들러는 모두 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.crt와 server.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_LOCALLY | ”unable to get local issuer certificate”, 체인 불완전 또는 신뢰할 CA 없음 |
SSL_R_SSLV3_ALERT_HANDSHAKE_FAILURE | 상대가 handshake_failure 경고를 보냄 (공통 cipher·버전 없음, 클라이언트 인증서 거부 등) |
성능 영향 비교
TLS 오버헤드 요약
| 항목 | 영향 |
|---|---|
| 핸드셰이크 | 최초 1회, RTT 1~2회 추가 (지연) |
| 암호화/복호화 | CPU 사용량 증가 (AES-NI 있으면 미미) |
| 메모리 | 세션당 수 KB 추가 |
| 지연 | 핸드셰이크 후에는 평문과 유사 |
오버헤드를 어떻게 봐야 하나
비용의 대부분은 연결을 맺을 때의 핸드셰이크(왕복 지연과 공개키 연산)에서 발생합니다. 핸드셰이크가 끝난 뒤의 대칭키 암호화는 AES-NI를 지원하는 CPU에서 부담이 작은 편이라, 연결을 오래 유지하거나 세션을 재사용하는 서비스에서는 체감 차이가 크지 않습니다. 반대로 짧은 연결을 자주 맺는 구조라면 핸드셰이크 비용이 두드러지므로, 실제 수치는 자신의 워크로드로 직접 측정해 보는 것이 정확합니다.
연결을 맺는 비용을 줄이는 가장 효과적인 방법은 연결을 오래 유지하고(keep-alive, 연결 풀) 세션을 재사용하는 것입니다. OpenSSL 서버는 기본으로 세션 캐시와 TLS 1.3 세션 티켓을 지원하지만, 클라이언트가 재사용하려면 이전 연결의 세션(SSL_get1_session)을 저장했다가 다음 연결에 SSL_set_session으로 넘겨야 합니다. cipher는 OpenSSL 기본값이 AES-GCM과 ChaCha20-Poly1305를 우선하므로 대부분 따로 조정할 필요가 없습니다.
프로덕션 배포 (Let’s Encrypt)
Let’s Encrypt 개요
Let’s Encrypt는 무료 공인 인증서를 발급하는 CA로, 인증서 유효기간이 짧기 때문에(현재 90일이며 더 짧아질 예정) certbot 같은 ACME 클라이언트로 발급과 갱신을 자동화하는 것을 전제로 합니다.
certbot으로 인증서 발급
# 1. certbot 설치 (Ubuntu/Debian)
sudo apt install certbot
# 2. HTTP-01 챌린지: --standalone은 certbot이 직접 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);
/etc/letsencrypt/live 아래 키는 root만 읽을 수 있으므로, 서버를 일반 계정으로 실행한다면 deploy hook에서 서비스 계정이 읽을 수 있는 위치로 복사하고 권한을 600으로 맞춥니다.
자동 갱신
배포판 패키지로 설치한 certbot은 보통 systemd 타이머나 cron 작업을 함께 설치해 하루 두 번 갱신을 시도합니다. 갱신된 인증서를 서버가 다시 읽게 하려면 deploy hook을 지정합니다.
sudo certbot renew --deploy-hook "systemctl reload myapp"
모범 사례와 프로덕션 패턴
모범 사례 (Best Practices)
| 항목 | 권장 | 비권장 |
|---|---|---|
| TLS 버전 | TLS 1.2, TLS 1.3 | SSLv2, SSLv3 |
| 인증서 검증 | verify_peer (운영) | verify_none (운영) |
| Cipher Suite | AES-GCM, ChaCha20-Poly1305 | RC4, 3DES, NULL |
| 키 길이 | RSA 2048+, ECDSA P-256+ | RSA 1024 |
| 인증서 | fullchain (체인 포함) | 단일 인증서만 |
| 비밀키 | 파일 권한 600, root만 | world-readable |
프로덕션 패턴 1: 인증서 핫 리로드
갱신된 인증서를 재시작 없이 반영하려면 새 ssl::context를 만들어 새 연결부터 쓰게 합니다. 이미 맺은 연결의 SSL 객체는 자기를 만든 SSL_CTX의 참조를 갖고 있으므로 기존 연결은 영향을 받지 않습니다.
// 서버가 std::shared_ptr<ssl::context>를 들고 있고, accept 시점에 복사해 사용
std::shared_ptr<ssl::context> ctx_; // 여러 스레드에서 접근한다면 std::atomic_load/store 또는 mutex로 보호
void reload_ssl_context() {
auto new_ctx = std::make_shared<ssl::context>(ssl::context::tls_server);
new_ctx->use_certificate_chain_file("fullchain.pem");
new_ctx->use_private_key_file("privkey.pem", ssl::context::pem);
std::atomic_store(&ctx_, new_ctx); // 새 연결부터 새 인증서
}
// accept 핸들러: auto ctx = std::atomic_load(&ctx_);
// 세션 객체가 ctx(shared_ptr)를 함께 들고 있어 연결이 끝날 때까지 컨텍스트가 유지됨
프로덕션 패턴 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로 역할을 판별해 경로별 접근을 제한할 수 있습니다. 핸드셰이크 후 SSL_get1_peer_certificate(OpenSSL 3.0, 이전 버전은 SSL_get_peer_certificate)로 인증서를 얻고 X509_NAME_get_text_by_NID(X509_get_subject_name(cert), NID_organizationalUnitName, ...)로 OU를 꺼낸 뒤 X509_free로 해제합니다. 인증서의 이름만 믿으려면 그 CA가 발급 대상을 엄격히 관리한다는 전제가 있어야 합니다.
프로덕션 패턴 4: 연결 풀 + TLS 세션 재사용
다운스트림 연결 시 핸드셰이크 완료된 스트림을 풀에 보관해 재사용하면 핸드셰이크 비용을 줄일 수 있습니다.
운영 환경에서는 프록시에서 HSTS(Strict-Transport-Security) 헤더와 OCSP 스테이플링을 설정하고, 핸드셰이크 실패 시 ERR_get_error()와 검증 결과를 로그로 남기며, 인증서 만료 30일 전쯤 알림이 오도록 모니터링을 걸어 둡니다. ssl::context는 서버 전체에서 하나를 재사용하고 ssl::stream은 연결마다 하나씩 만들며, 연결을 끝낼 때는 shutdown()으로 close_notify를 보낸 뒤 소켓을 닫습니다.
자주 묻는 질문 (FAQ)
Q. 자체 서명 인증서를 운영에서 써도 되나요?
A. 안 됩니다. 브라우저·클라이언트에서 경고가 뜨고, 중간자 공격에 취약합니다. Let’s Encrypt(무료) 또는 유료 CA를 사용하세요. OpenSSL·Asio로 SSL/TLS 암호화 통신을 구성할 수 있습니다. 인증서 검증을 켜고, 운영에서는 Let’s Encrypt를 사용하세요. 이전 글: C++ 실전 가이드 #30-1: WebSocket 다음 글: C++ 실전 가이드 #30-3: 프로토콜 설계와 직렬화