데이터가 네트워크를 타고 이동할 때, 그 데이터를 중간에서 누군가 들여다보고 있다면? 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

JAVA
// 서버: 인증서와 개인 키로 SslContext 생성
SslContext sslCtx = SslContextBuilder
    .forServer(certFile, keyFile)      // 서버 인증서 + 개인 키
    .protocols("TLSv1.3", "TLSv1.2")  // 허용할 TLS 버전
    .build();

클라이언트 측 SslContext

JAVA
// 클라이언트: 서버 인증서를 검증할 신뢰 저장소 설정
SslContext sslCtx = SslContextBuilder
    .forClient()
    .trustManager(caCertFile)          // CA 인증서 (서버 검증용)
    .build();

JDK vs OpenSSL 프로바이더

Netty는 두 가지 SSL 프로바이더를 지원합니다.

JAVA
// 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 — 파이프라인 맨 앞에 배치하는 이유

SslHandlerChannelPipeline에서 반드시 첫 번째 핸들러 로 배치해야 합니다. 이유는 데이터 흐름을 생각하면 명확합니다.

인바운드 (네트워크 → 애플리케이션)

PLAINTEXT
[암호화된 바이트] → SslHandler(복호화) → 디코더 → 비즈니스 핸들러

네트워크에서 들어온 데이터는 암호화되어 있습니다. SslHandler가 먼저 복호화해야 뒤의 디코더와 비즈니스 핸들러가 평문 데이터를 처리할 수 있습니다.

아웃바운드 (애플리케이션 → 네트워크)

PLAINTEXT
비즈니스 핸들러 → 인코더 → SslHandler(암호화) → [암호화된 바이트]

아웃바운드는 파이프라인을 역순으로 타므로, SslHandler가 마지막에 데이터를 암호화하여 전송합니다.

파이프라인 구성 예시

JAVA
// 서버 부트스트랩에서 파이프라인 구성
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를 활용합니다.

JAVA
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를 제공하여 코드 한 줄로 자체 서명 인증서를 생성 할 수 있습니다.

JAVA
// 자체 서명 인증서 생성 (개발/테스트 전용)
SelfSignedCertificate ssc = new SelfSignedCertificate();

// 서버 SslContext
SslContext serverSslCtx = SslContextBuilder
    .forServer(ssc.certificate(), ssc.privateKey())
    .build();

// 클라이언트 SslContext (인증서 검증 비활성화 — 개발용!)
SslContext clientSslCtx = SslContextBuilder
    .forClient()
    .trustManager(InsecureTrustManagerFactory.INSTANCE)  // 모든 인증서 신뢰
    .build();

주의사항

InsecureTrustManagerFactory어떤 인증서든 무조건 신뢰 합니다. 프로덕션에서 절대 사용하면 안 됩니다.

JAVA
// ❌ 프로덕션에서 이렇게 하면 중간자 공격에 무방비
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

PLAINTEXT
[일반 TLS]
클라이언트 ----(서버 인증서 검증)---→ 서버
           ←---(데이터 암호화)------

[mTLS]
클라이언트 ----(서버 인증서 검증)---→ 서버
           ←---(클라이언트 인증서 검증)--
           ←---(데이터 암호화)------→

서버 측 — 클라이언트 인증서 요구

JAVA
// 서버: 클라이언트 인증서를 반드시 요구
SslContext serverSslCtx = SslContextBuilder
    .forServer(serverCert, serverKey)
    .trustManager(caCert)             // 클라이언트 인증서를 검증할 CA
    .clientAuth(ClientAuth.REQUIRE)   // 클라이언트 인증서 필수
    .build();

ClientAuth 옵션:

옵션설명
NONE클라이언트 인증서를 요구하지 않음 (기본값)
OPTIONAL클라이언트가 인증서를 제시하면 검증, 없어도 허용
REQUIRE클라이언트가 반드시 인증서를 제시해야 함

클라이언트 측 — 인증서 제시

JAVA
// 클라이언트: 자신의 인증서를 제시
SslContext clientSslCtx = SslContextBuilder
    .forClient()
    .keyManager(clientCert, clientKey) // 클라이언트 인증서 + 개인 키
    .trustManager(caCert)              // 서버 인증서를 검증할 CA
    .build();

mTLS 핸드셰이크 과정

PLAINTEXT
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

PLAINTEXT
[일반 TLS]
연결 시작 → 즉시 TLS 핸드셰이크 → 암호화 통신

[StartTLS]
연결 시작 → 평문 통신 → "STARTTLS" 명령 → TLS 핸드셰이크 → 암호화 통신

구현 — 동적 파이프라인 수정

StartTLS의 핵심은 파이프라인을 동적으로 수정 하는 것입니다. 처음에는 SslHandler 없이 시작하고, 양쪽이 TLS 업그레이드에 합의하면 그때 추가합니다.

JAVA
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

JAVA
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을 사용할 수 없습니다.

JAVA
// ❌ SSL 환경에서는 FileRegion 사용 불가
ctx.writeAndFlush(new DefaultFileRegion(fileChannel, 0, fileLength));

// ✅ 대신 ChunkedFile 사용
ctx.writeAndFlush(new ChunkedFile(file));

FileRegionsendfile() 시스템 콜로 커널 공간에서 직접 전송하는 Zero-Copy 기법인데, SSL/TLS는 데이터를 사용자 공간에서 암호화해야 하므로 양립할 수 없습니다. ChunkedFile은 데이터를 사용자 공간으로 읽어온 뒤 SslHandler가 암호화하여 전송합니다.

성능 오버헤드

TLS는 CPU와 메모리를 추가로 소모합니다.

  • 핸드셰이크 비용 — 연결마다 비대칭 키 교환이 발생합니다. 특히 RSA 키 교환은 CPU 집약적입니다
  • ** 암복호화 비용** — 모든 데이터가 대칭 키로 암복호화됩니다. 데이터량이 많으면 CPU 부담이 커집니다
  • ** 메모리 사용량** — 각 연결마다 SSL 세션 상태를 유지해야 합니다

성능 최적화 팁:

JAVA
// 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 연결이 모두 실패 합니다
  • 기존 연결은 유지되지만, 재연결 시 핸드셰이크가 실패합니다
JAVA
// 인증서 갱신 시 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 서버 & 클라이언트

지금까지 배운 내용을 조합한 전체 예제입니다.

서버

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

클라이언트

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

정리

개념핵심
SslContextTLS 설정(인증서, 키, 프로토콜)을 담은 팩토리. 서버/클라이언트 각각 생성
SslHandler파이프라인 ** 맨 앞 **에 배치. 인바운드 복호화, 아웃바운드 암호화 담당
SelfSignedCertificate개발/테스트용 자체 서명 인증서. 프로덕션 사용 금지
mTLS서버와 클라이언트 양쪽이 인증서를 제시하여 상호 인증
StartTLS평문으로 시작 후 동적으로 TLS 업그레이드. STRIPTLS 공격에 주의
FileRegionSSL 환경에서 사용 불가. ChunkedFile로 대체
** 인증서 갱신**만료 시 장애 직결. 자동 갱신 + 모니터링 필수
댓글 로딩 중...