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+
목차
- TLS 개요
- SSL/TLS 핸드셰이크 다이어그램
- OpenSSL과 Asio 완전 통합
- 순수 OpenSSL 예제
- 인증서 생성과 관리
- 클라이언트 인증서 검증 (mTLS)
- 자주 발생하는 SSL 에러
- 성능 영향 비교
- 프로덕션 배포 (Let’s Encrypt)
- 모범 사례와 프로덕션 패턴
- 실무 주의사항
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.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
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::stream과 websocket::stream은 thread-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.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 | 인증서 체인 불완전 |
SSL_R_SSLV3_ALERT_HANDSHAKE_FAILURE | 핸드셰이크 실패 (키 불일치 등) |
8. 성능 영향 비교
TLS 오버헤드 요약
| 항목 | 영향 |
|---|---|
| 핸드셰이크 | 최초 1회, RTT 1~2회 추가 (지연) |
| 암호화/복호화 | CPU 사용량 증가 (AES-NI 있으면 미미) |
| 메모리 | 세션당 수 KB 추가 |
| 지연 | 핸드셰이크 후에는 평문과 유사 |
벤치마크 (참고치)
| 통신 방식 | 초당 요청 수 (단일 연결) | 연결당 지연 |
|---|---|---|
| 평문 TCP | ~50,000 | 0.02ms |
| TLS 1.2 | ~45,000 | 0.025ms |
| TLS 1.3 | ~48,000 | 0.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.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: 인증서 핫 리로드
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_certificate → X509_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::stream과 websocket::stream에 동시 접근하면 Safari에서 연결이 끊깁니다. strand로 모든 비동기 작업을 직렬화하면 해결됩니다.
체크리스트
구현 체크리스트
- ssl::context 서버/클라이언트 구분
- 인증서·비밀키 파일 로드
- set_verify_mode(verify_peer) (운영)
- SNI 설정 (클라이언트)
- 에러 처리 (handshake 실패)
- strand 사용 (WSS 멀티스레드)
프로덕션 체크리스트
- Let’s Encrypt 또는 공인 CA 인증서
- TLS 1.2 이상
- 인증서 갱신 자동화
- 비밀키 권한 600
정리
| 항목 | 내용 |
|---|---|
| ssl::stream | TCP 소켓 위 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