SSL·TLS — 보안 통신
데이터가 네트워크를 타고 이동할 때, 그 데이터를 중간에서 누군가 들여다보고 있다면? TCP는 데이터를 안정적으로 전달해 주지만, 그 내용을 보호해 주지는 않는다. 평문으로 보낸 패스워드, API 키, 개인정보가 도청되는 걸 어떻게 막을 수 있을까?
이번 글에서는 Netty에서 SSL/TLS를 적용하여 보안 통신을 구현하는 방법 을 다룹니다. SslContext 생성부터 SslHandler 배치, 자체 서명 인증서, mTLS, StartTLS 패턴까지 정리합니다.
왜 SSL/TLS가 필요한가
TCP 통신은 기본적으로 평문(plaintext) 입니다. 데이터가 클라이언트에서 서버로 가는 동안, 그 경로에 있는 누구든 내용을 볼 수 있습니다.
평문 통신의 세 가지 위험:
- 도청(Eavesdropping) — 네트워크 중간에서 패킷을 캡처하면 내용이 그대로 노출됩니다. Wireshark 하나면 충분합니다
- ** 변조(Tampering)** — 전송 중인 데이터를 중간에서 바꿔치기할 수 있습니다. 금액을 조작하거나, 응답을 위조하거나
- ** 위장(Spoofing)** — 상대방이 진짜 그 서버인지 확인할 수 없습니다. 가짜 서버에 연결해도 클라이언트는 모릅니다
SSL/TLS는 이 세 가지를 한꺼번에 해결합니다.
| 위협 | TLS의 대응 |
|---|---|
| 도청 | ** 암호화** — 데이터를 암호화하여 중간에서 읽을 수 없게 만든다 |
| 변조 | ** 무결성 검증** — MAC(Message Authentication Code)으로 변조를 감지한다 |
| 위장 | ** 인증서 기반 인증** — 서버(또는 양측)의 신원을 인증서로 검증한다 |
TLS(Transport Layer Security)는 SSL(Secure Sockets Layer)의 후속 프로토콜이다. SSL 3.0 이후로 TLS 1.0, 1.1, 1.2, 1.3으로 진화했으며, 현재 SSL은 공식적으로 폐기되었다. 하지만 관습적으로 "SSL"이라는 용어가 여전히 혼용되고 있다.
SslContext — 보안 컨텍스트 생성
Netty에서 SSL/TLS를 사용하려면 먼저 SslContext를 만들어야 합니다. SslContext는 인증서, 개인 키, 프로토콜 버전 등 TLS 설정을 담고 있는 팩토리 객체 입니다. 이걸 통해 SslHandler를 생성합니다.
서버 측 SslContext
// 서버: 인증서와 개인 키로 SslContext 생성
SslContext sslCtx = SslContextBuilder
.forServer(certFile, keyFile) // 서버 인증서 + 개인 키
.protocols("TLSv1.3", "TLSv1.2") // 허용할 TLS 버전
.build();
클라이언트 측 SslContext
// 클라이언트: 서버 인증서를 검증할 신뢰 저장소 설정
SslContext sslCtx = SslContextBuilder
.forClient()
.trustManager(caCertFile) // CA 인증서 (서버 검증용)
.build();
JDK vs OpenSSL 프로바이더
Netty는 두 가지 SSL 프로바이더를 지원합니다.
// JDK 기본 SSL 프로바이더
SslContext sslCtx = SslContextBuilder
.forServer(certFile, keyFile)
.sslProvider(SslProvider.JDK)
.build();
// OpenSSL 기반 프로바이더 (netty-tcnative 의존성 필요)
SslContext sslCtx = SslContextBuilder
.forServer(certFile, keyFile)
.sslProvider(SslProvider.OPENSSL)
.build();
| 프로바이더 | 장점 | 단점 |
|---|---|---|
| JDK | 추가 의존성 없음, 어디서나 동작 | 상대적으로 느림 |
| OpenSSL | 성능이 훨씬 빠름 (특히 대량 연결) | netty-tcnative 네이티브 라이브러리 필요 |
프로덕션에서 대량의 TLS 연결을 처리한다면 OpenSSL 프로바이더를 사용하는 게 성능상 유리하다. TLS 핸드셰이크와 암복호화 모두 OpenSSL이 JDK보다 빠르다.
SslHandler — 파이프라인 맨 앞에 배치하는 이유
SslHandler는 ChannelPipeline에서 반드시 첫 번째 핸들러 로 배치해야 합니다. 이유는 데이터 흐름을 생각하면 명확합니다.
인바운드 (네트워크 → 애플리케이션)
[암호화된 바이트] → SslHandler(복호화) → 디코더 → 비즈니스 핸들러
네트워크에서 들어온 데이터는 암호화되어 있습니다. SslHandler가 먼저 복호화해야 뒤의 디코더와 비즈니스 핸들러가 평문 데이터를 처리할 수 있습니다.
아웃바운드 (애플리케이션 → 네트워크)
비즈니스 핸들러 → 인코더 → SslHandler(암호화) → [암호화된 바이트]
아웃바운드는 파이프라인을 역순으로 타므로, SslHandler가 마지막에 데이터를 암호화하여 전송합니다.
파이프라인 구성 예시
// 서버 부트스트랩에서 파이프라인 구성
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
// 1. SslHandler — 반드시 맨 앞
SSLEngine engine = sslCtx.newEngine(ch.alloc());
p.addLast("ssl", new SslHandler(engine));
// 2. 프레임 디코더
p.addLast("frameDecoder", new LengthFieldBasedFrameDecoder(
1024, 0, 4, 0, 4));
// 3. 비즈니스 핸들러
p.addLast("handler", new MyServerHandler());
}
});
핸드셰이크 이벤트 처리
SslHandler는 연결이 맺어지면 자동으로 TLS 핸드셰이크를 시작합니다. 핸드셰이크 완료 여부를 감지하려면 SslHandshakeCompletionEvent를 활용합니다.
public class SslEventHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt instanceof SslHandshakeCompletionEvent) {
SslHandshakeCompletionEvent handshakeEvent =
(SslHandshakeCompletionEvent) evt;
if (handshakeEvent.isSuccess()) {
// 핸드셰이크 성공 — 보안 통신 시작
SSLSession session = ctx.pipeline()
.get(SslHandler.class)
.engine()
.getSession();
System.out.println("프로토콜: " + session.getProtocol());
System.out.println("암호 스위트: " + session.getCipherSuite());
} else {
// 핸드셰이크 실패 — 연결 종료
System.err.println("TLS 핸드셰이크 실패: "
+ handshakeEvent.cause().getMessage());
ctx.close();
}
}
ctx.fireUserEventTriggered(evt);
}
}
SslHandler를 파이프라인 맨 앞에 두는 것은 "반드시"이다. 만약 디코더 뒤에 넣으면, 디코더가 암호화된 바이트를 평문으로 착각하고 파싱하다 실패한다.
자체 서명 인증서 — 개발/테스트 환경
개발이나 테스트 환경에서 매번 CA 인증서를 발급받기는 번거롭습니다. Netty는 SelfSignedCertificate를 제공하여 코드 한 줄로 자체 서명 인증서를 생성 할 수 있습니다.
// 자체 서명 인증서 생성 (개발/테스트 전용)
SelfSignedCertificate ssc = new SelfSignedCertificate();
// 서버 SslContext
SslContext serverSslCtx = SslContextBuilder
.forServer(ssc.certificate(), ssc.privateKey())
.build();
// 클라이언트 SslContext (인증서 검증 비활성화 — 개발용!)
SslContext clientSslCtx = SslContextBuilder
.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE) // 모든 인증서 신뢰
.build();
주의사항
InsecureTrustManagerFactory는 어떤 인증서든 무조건 신뢰 합니다. 프로덕션에서 절대 사용하면 안 됩니다.
// ❌ 프로덕션에서 이렇게 하면 중간자 공격에 무방비
SslContextBuilder.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE)
.build();
// ✅ 프로덕션에서는 CA 인증서로 검증
SslContextBuilder.forClient()
.trustManager(caCertFile) // 신뢰할 수 있는 CA 인증서 지정
.build();
자체 서명 인증서가 유용한 상황:
- 단위 테스트 —
EmbeddedChannel로 TLS 핸들러를 테스트할 때 - ** 로컬 개발** — 로컬에서 TLS 통신을 시뮬레이션할 때
- ** 통합 테스트** — CI/CD 파이프라인에서 TLS 연결을 검증할 때
상호 TLS(mTLS) — 클라이언트도 인증서를 제시한다
일반 TLS는 ** 클라이언트만 서버를 검증 **합니다. 하지만 마이크로서비스 간 통신처럼 ** 양쪽 모두 신원 확인이 필요한 경우 **, 서버도 클라이언트의 인증서를 요구해야 합니다. 이것이 mTLS(Mutual TLS) 입니다.
일반 TLS vs mTLS
[일반 TLS]
클라이언트 ----(서버 인증서 검증)---→ 서버
←---(데이터 암호화)------
[mTLS]
클라이언트 ----(서버 인증서 검증)---→ 서버
←---(클라이언트 인증서 검증)--
←---(데이터 암호화)------→
서버 측 — 클라이언트 인증서 요구
// 서버: 클라이언트 인증서를 반드시 요구
SslContext serverSslCtx = SslContextBuilder
.forServer(serverCert, serverKey)
.trustManager(caCert) // 클라이언트 인증서를 검증할 CA
.clientAuth(ClientAuth.REQUIRE) // 클라이언트 인증서 필수
.build();
ClientAuth 옵션:
| 옵션 | 설명 |
|---|---|
NONE | 클라이언트 인증서를 요구하지 않음 (기본값) |
OPTIONAL | 클라이언트가 인증서를 제시하면 검증, 없어도 허용 |
REQUIRE | 클라이언트가 반드시 인증서를 제시해야 함 |
클라이언트 측 — 인증서 제시
// 클라이언트: 자신의 인증서를 제시
SslContext clientSslCtx = SslContextBuilder
.forClient()
.keyManager(clientCert, clientKey) // 클라이언트 인증서 + 개인 키
.trustManager(caCert) // 서버 인증서를 검증할 CA
.build();
mTLS 핸드셰이크 과정
1. ClientHello → 클라이언트가 지원하는 TLS 버전, 암호 스위트 전송
2. ServerHello ← 서버가 선택한 TLS 버전, 암호 스위트 응답
3. Server Certificate ← 서버 인증서 전송
4. CertificateRequest ← 서버가 클라이언트 인증서 요청 ★ mTLS 핵심
5. Client Certificate → 클라이언트 인증서 전송 ★
6. Key Exchange → 양쪽이 세션 키 교환
7. Finished ↔ 핸드셰이크 완료, 암호화 통신 시작
mTLS는 마이크로서비스 아키텍처에서 서비스 간 통신을 보호하는 표준적인 방법이다. Kubernetes의 서비스 메시(Istio, Linkerd)도 내부적으로 mTLS를 사용한다.
StartTLS 패턴 — 평문에서 TLS로 업그레이드
StartTLS는 처음에 평문으로 연결 한 뒤, 프로토콜 협상을 통해 중간에 TLS로 업그레이드 하는 패턴입니다. SMTP, IMAP, LDAP 등에서 사용됩니다.
일반 TLS vs StartTLS
[일반 TLS]
연결 시작 → 즉시 TLS 핸드셰이크 → 암호화 통신
[StartTLS]
연결 시작 → 평문 통신 → "STARTTLS" 명령 → TLS 핸드셰이크 → 암호화 통신
구현 — 동적 파이프라인 수정
StartTLS의 핵심은 파이프라인을 동적으로 수정 하는 것입니다. 처음에는 SslHandler 없이 시작하고, 양쪽이 TLS 업그레이드에 합의하면 그때 추가합니다.
public class StartTlsServerHandler extends ChannelInboundHandlerAdapter {
private final SslContext sslCtx;
public StartTlsServerHandler(SslContext sslCtx) {
this.sslCtx = sslCtx;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
String command = (String) msg;
if ("STARTTLS".equalsIgnoreCase(command.trim())) {
// 1. 클라이언트에게 TLS 준비 완료 응답
ctx.writeAndFlush("READY\r\n").addListener(future -> {
if (future.isSuccess()) {
// 2. 파이프라인 맨 앞에 SslHandler 동적 추가
SslHandler sslHandler = sslCtx.newHandler(ctx.alloc());
ctx.pipeline().addFirst("ssl", sslHandler);
// 3. 핸드셰이크 완료 대기
sslHandler.handshakeFuture().addListener(hsFuture -> {
if (hsFuture.isSuccess()) {
System.out.println("StartTLS 업그레이드 성공");
} else {
System.err.println("StartTLS 핸드셰이크 실패");
ctx.close();
}
});
}
});
} else {
// TLS 업그레이드 전 — 평문 처리
ctx.fireChannelRead(msg);
}
}
}
클라이언트 측 StartTLS
public class StartTlsClientHandler extends ChannelInboundHandlerAdapter {
private final SslContext sslCtx;
public StartTlsClientHandler(SslContext sslCtx) {
this.sslCtx = sslCtx;
}
@Override
public void channelActive(ChannelHandlerContext ctx) {
// 서버에 STARTTLS 요청
ctx.writeAndFlush("STARTTLS\r\n");
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
String response = (String) msg;
if ("READY".equalsIgnoreCase(response.trim())) {
// 서버가 준비되면 SslHandler 추가
SslHandler sslHandler = sslCtx.newHandler(
ctx.alloc(),
"server-host", 8443 // SNI를 위한 호스트/포트
);
ctx.pipeline().addFirst("ssl", sslHandler);
sslHandler.handshakeFuture().addListener(future -> {
if (future.isSuccess()) {
// TLS 업그레이드 완료 — 이후 암호화 통신
ctx.writeAndFlush("이제 암호화된 메시지입니다\r\n");
}
});
}
}
}
StartTLS의 약점은 "STARTTLS" 명령 자체가 평문이라는 것이다. 중간자가 이 명령을 제거하면 TLS 업그레이드가 일어나지 않아 계속 평문으로 통신하게 된다(STRIPTLS 공격). 가능하면 처음부터 TLS를 사용하는 것이 더 안전하다.
주의사항 — 성능, 호환성, 운영
SSL/TLS를 적용하면 보안은 강화되지만, 알아두어야 할 트레이드오프가 있습니다.
FileRegion(Zero-Copy) 사용 불가
SslHandler가 파이프라인에 있으면 FileRegion을 사용할 수 없습니다.
// ❌ SSL 환경에서는 FileRegion 사용 불가
ctx.writeAndFlush(new DefaultFileRegion(fileChannel, 0, fileLength));
// ✅ 대신 ChunkedFile 사용
ctx.writeAndFlush(new ChunkedFile(file));
FileRegion은 sendfile() 시스템 콜로 커널 공간에서 직접 전송하는 Zero-Copy 기법인데, SSL/TLS는 데이터를 사용자 공간에서 암호화해야 하므로 양립할 수 없습니다. ChunkedFile은 데이터를 사용자 공간으로 읽어온 뒤 SslHandler가 암호화하여 전송합니다.
성능 오버헤드
TLS는 CPU와 메모리를 추가로 소모합니다.
- 핸드셰이크 비용 — 연결마다 비대칭 키 교환이 발생합니다. 특히 RSA 키 교환은 CPU 집약적입니다
- ** 암복호화 비용** — 모든 데이터가 대칭 키로 암복호화됩니다. 데이터량이 많으면 CPU 부담이 커집니다
- ** 메모리 사용량** — 각 연결마다 SSL 세션 상태를 유지해야 합니다
성능 최적화 팁:
// 1. OpenSSL 프로바이더 사용 — JDK보다 훨씬 빠름
SslContextBuilder.forServer(cert, key)
.sslProvider(SslProvider.OPENSSL)
.build();
// 2. TLS 세션 재개(Session Resumption) 활용
// SslContext가 내부적으로 세션 캐시를 관리
// 동일 클라이언트의 재연결 시 풀 핸드셰이크를 생략
// 3. 적절한 암호 스위트 선택
SslContextBuilder.forServer(cert, key)
.ciphers(Arrays.asList(
"TLS_AES_256_GCM_SHA384", // TLS 1.3
"TLS_AES_128_GCM_SHA256" // TLS 1.3
))
.build();
인증서 갱신
프로덕션 환경에서 ** 인증서 만료는 장애로 직결 **됩니다.
- Let's Encrypt 인증서는 90일마다 갱신 이 필요합니다
- 인증서가 만료되면 새로운 TLS 연결이 모두 실패 합니다
- 기존 연결은 유지되지만, 재연결 시 핸드셰이크가 실패합니다
// 인증서 갱신 시 SslContext를 재생성하는 패턴
public class ReloadableSslContext {
private volatile SslContext sslContext;
// 주기적으로 호출하여 인증서를 갱신
public void reload(File certFile, File keyFile) throws Exception {
SslContext newCtx = SslContextBuilder
.forServer(certFile, keyFile)
.sslProvider(SslProvider.OPENSSL)
.build();
SslContext oldCtx = this.sslContext;
this.sslContext = newCtx;
// 기존 연결은 이전 컨텍스트를 계속 사용
// 새 연결부터 갱신된 인증서 적용
// oldCtx는 모든 연결이 종료된 후 GC됨
}
public SslContext get() {
return sslContext;
}
}
인증서 만료로 인한 장애는 실무에서 생각보다 자주 발생한다. 자동 갱신 스크립트와 만료 알림 모니터링은 필수이다.
전체 예제 — SSL/TLS 서버 & 클라이언트
지금까지 배운 내용을 조합한 전체 예제입니다.
서버
public class SslServer {
public static void main(String[] args) throws Exception {
// 개발용 자체 서명 인증서
SelfSignedCertificate ssc = new SelfSignedCertificate();
SslContext sslCtx = SslContextBuilder
.forServer(ssc.certificate(), ssc.privateKey())
.build();
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
// SslHandler를 맨 앞에 배치
ch.pipeline().addLast(sslCtx.newHandler(ch.alloc()));
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StringEncoder());
ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {
@Override
protected void channelRead0(
ChannelHandlerContext ctx, String msg) {
System.out.println("수신 (암호화 해제됨): " + msg);
ctx.writeAndFlush("에코: " + msg);
}
});
}
});
ChannelFuture f = b.bind(8443).sync();
System.out.println("SSL 서버 시작: 포트 8443");
f.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
클라이언트
public class SslClient {
public static void main(String[] args) throws Exception {
// 개발용 — 모든 인증서 신뢰 (프로덕션 금지!)
SslContext sslCtx = SslContextBuilder
.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE)
.build();
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap b = new Bootstrap();
b.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
// SslHandler를 맨 앞에 배치
ch.pipeline().addLast(
sslCtx.newHandler(ch.alloc(), "localhost", 8443));
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StringEncoder());
ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {
@Override
protected void channelRead0(
ChannelHandlerContext ctx, String msg) {
System.out.println("서버 응답: " + msg);
}
@Override
public void channelActive(ChannelHandlerContext ctx) {
ctx.writeAndFlush("안녕하세요, 보안 채널입니다!");
}
});
}
});
ChannelFuture f = b.connect("localhost", 8443).sync();
f.channel().closeFuture().sync();
} finally {
group.shutdownGracefully();
}
}
}
정리
| 개념 | 핵심 |
|---|---|
| SslContext | TLS 설정(인증서, 키, 프로토콜)을 담은 팩토리. 서버/클라이언트 각각 생성 |
| SslHandler | 파이프라인 ** 맨 앞 **에 배치. 인바운드 복호화, 아웃바운드 암호화 담당 |
| SelfSignedCertificate | 개발/테스트용 자체 서명 인증서. 프로덕션 사용 금지 |
| mTLS | 서버와 클라이언트 양쪽이 인증서를 제시하여 상호 인증 |
| StartTLS | 평문으로 시작 후 동적으로 TLS 업그레이드. STRIPTLS 공격에 주의 |
| FileRegion | SSL 환경에서 사용 불가. ChunkedFile로 대체 |
| ** 인증서 갱신** | 만료 시 장애 직결. 자동 갱신 + 모니터링 필수 |