mTLS와 실제 서비스 적용 사례

서지호's avatar
Nov 28, 2025
mTLS와 실제 서비스 적용 사례

mTLS와 실제 서비스 적용 사례

오늘 포스트에서는 TLS와 mTLS의 개념적 차이를 먼저 살펴보고, 이어서 Kurento 기반 WebRTC 미디어 서버에 mTLS를 적용한 사례를 공유하겠습니다. 내부 서비스 통신 보안을 강화하려는 엔지니어링 팀이라면, mTLS가 무엇이고 어떻게 활용할 수 있는지 감을 잡을 수 있도록 최대한 이해하기 쉽게 설명하겠습니다.

TLS와 mTLS, 뭐가 다른가

일반적인 TLS(위)와 mTLS(아래) 핸드셰이크 방식 비교 개념도. TLS에서는 서버만 인증서를 통해 신원을 증명하지만, mTLS에서는 클라이언트도 별도 인증서를 제시하여 서버와 상호 인증을 수행한다.

우선 TLS(Transport Layer Security)는 서버와 클라이언트 사이의 통신을 암호화하여 데이터의 기밀성과 무결성을 보장하는 프로토콜입니다. 일반적인 TLS 연결에서 클라이언트는 서버의 인증서만 검증하며, 이를 통해 서버의 신원을 확인하고 안전한 연결을 수립합니다 . 예를 들어 웹 브라우저가 HTTPS 사이트에 접속할 때, 브라우저는 서버가 제시한 TLS 인증서가 신뢰할 수 있는 CA(Certificate Authority)에서 발급된 것인지 확인한 후 통신을 암호화합니다. 이 과정에서 클라이언트(브라우저)는 서버가 누구인지에만 집중하고, 자신을 따로 증명하지는 않습니다.

반면 mTLS(mutual TLS)는 TLS handshake 단계에 클라이언트 인증 과정이 추가된 형태입니다 . 즉 서버뿐 아니라 클라이언트도 자신의 신원을 증명하기 위해 클라이언트 인증서를 서버에 제시하고 검증을 받습니다. 서버는 클라이언트의 인증서를 신뢰할 수 있는 CA로부터 발급받은 것인지 확인한 뒤에만 연결을 이어갑니다 . 이렇게 양쪽 끝단의 주체가 서로를 인증함으로써 신뢰할 수 있는 통신 채널을 구축하게 됩니다. 요약하자면, TLS가 “서버 신원 확인 + 암호화”라면 mTLS는 여기에 “클라이언트 신원 확인”을 추가한 것입니다 .

mTLS의 이러한 상호 인증 덕분에, 인증되지 않은 클라이언트는 아예 서버와 통신할 수 없게 차단됩니다 . 이는 제로트러스트(Zero Trust) 보안 모델에서도 핵심적으로 사용되는 방식으로, 내부 서비스 간 통신이나 B2B API 통신 등에서 추가적인 보안 레이어로 활용됩니다 . 물론 mTLS를 적용하려면 각 클라이언트에 인증서 배포 및 관리가 필요하므로 구현과 운영의 복잡도가 늘어나는 부담이 있습니다. 따라서 외부 사용자가 접속하는 일반 웹 서비스에는 TLS로도 충분하지만, 내부 마이크로서비스 간 통신이나 중요 데이터가 오가는 시스템에는 mTLS를 도입해 한층 강화된 신뢰성과 보안성을 확보하곤 합니다 .

Kurento 기반 WebRTC 미디어 서버에 mTLS를 붙이기까지

두 번째로, 저희 팀이 Kurento 기반의 WebRTC 미디어 서버에 mTLS를 적용했던 실제 사례를 소개하겠습니다. Kurento Media Server(KMS)는 WebRTC 스트리밍을 처리하기 위해 사용되는데, 클라이언트 애플리케이션은 KMS와 WebSocket으로 신호 시그널링을 주고받습니다. 당사의 보안 정책상 내부 서비스 간 통신에도 모두 mTLS를 적용해야 했기 때문에, KMS와 이를 제어하는 애플리케이션 사이의 통신을 mTLS로 보호해야 하는 상황이었습니다. Kubernetes 환경에서 이를 구현하면서 몇 가지 도전 과제가 있었습니다.

전체적인 구성

  • 클라이언트 앱 (Spring Boot, nuvion-be)

    • Kurento Java Client로 KMS(WebSocket)에 붙음

    • Vault + Kubernetes에서 앱 시크릿 + TLS 키/인증서를 주입받음

  • Kurento Media Server (KMS)

    • Nginx/Reverse Proxy 뒤에서 wss://[대외비]/kurento 로 서비스

    • 서버 인증서는 사설 CA 기반

  • HashiCorp Vault

    • PKI 엔진으로 내부 TLS 인증서 발급/관리

    • KV 엔진으로 앱 시크릿 관리

  • Kubernetes

    • Vault Secrets Operator (VSO)로 앱 시크릿 자동 동기화

    • initContainer로 PEM → PKCS#12 변환 후, mTLS용 keystore/truststore 준비

    Overall Architecture

1) 사설 CA 인증서 활용 및 신뢰 구성: 당연히 외부 공개 CA가 아닌 사설 인증 기관(private CA)을 통해 내부 인증서를 발급받아야 했습니다. 사설 CA를 쓰면 우리의 서비스들만 해당 CA를 신뢰하도록 구성할 수 있어 보안상 유리하지만, 기본적으로 Java나 OS에 내장된 신뢰 저장소에는 사설 CA가 없으므로 별도로 신뢰 구성을 해줘야 했습니다. 또한 KMS 서버 인증서와 클라이언트 인증서 모두 사설 CA로부터 발급받았기 때문에, 서로의 인증서를 신뢰하도록 CA 증명서 설치가 필요했습니다. 한편으로 인증서 유효기간 및 회전(rotation) 관리도 고려해야 했습니다.

2) 인증서의 동적 배포와 관리 자동화: 여러 애플리케이션 인스턴스(Pod)에 일일이 인증서를 수동 배포하는 것은 비효율적이고 안전하지도 않으므로, HashiCorp Vault를 사용해 동적으로 인증서를 발급/배포하기로 했습니다. Vault의 PKI 시크릿 엔진을 통해 필요할 때마다 새로운 인증서와 키 쌍을 생성하고, 짧은 TTL로 자동 갱신하는 방식을 택했습니다. 이를 Kubernetes에 통합하기 위해 Init Container를 활용했는데, 애플리케이션 Pod가 뜰 때 Vault에 접속하여 새로운 클라이언트 인증서를 발급받고 저장하는 용도의 보조 컨테이너입니다 . Vault CLI나 Vault Agent를 사용해서 Init Container가 애플리케이션 시작 전에 미리 인증서와 키를 받아오도록 구성하였습니다. 이 과정은 내부적으로 스크립트화하여 자동화되었고, 개발자는 인증서 파일 경로만 알면 되도록 하였습니다.

3) PEM vs. PKCS#12 포맷 및 Java 호환성: Vault를 통해 얻은 인증서는 일반적으로 PEM 형식(.pem 파일 2개: 인증서와 개인키)으로 제공되는데, Java 애플리케이션에서는 하나의 PKCS#12 파일(.p12 또는 .pfx)로 관리하는 편이 다루기 수월했습니다 . 따라서 Init Container 단계에서 OpenSSL을 이용해 cert.pem과 key.pem을 묶어 .p12 파일로 변환하는 작업을 수행했습니다 . 이 .p12 파일에는 클라이언트 인증서와 개인키가 포함되며, 필요하다면 CA 체인도 함께 저장됩니다. 변환 시에 내부에서만 사용하는 임시 비밀번호를 걸어 두고, 애플리케이션이 시작할 때 그 비밀번호로 PKCS#12를 로드하게 했습니다. 최종적으로 이 PKCS#12 파일을 Kubernetes의 EmptyDir 볼륨 등을 통해 메인 애플리케이션 컨테이너에 마운트하여, 애플리케이션이 해당 경로에서 인증서를 읽을 수 있도록 준비했습니다.

{{- $img := .Values.image | default (dict) -}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "nuvion.fullname" . }}
spec:
  replicas: {{ .Values.replicaCount | default 1 }}
  selector:
    matchLabels:
      {{- include "nuvion.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "nuvion.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ .Values.serviceAccount.name | default "default" }}
      imagePullSecrets:
        - name: {{ .Values.imagePullSecret | default "regcred" }}

      # 1) initContainer: PEM → PKCS12
      initContainers:
        - name: init-kurento-tls
          image: alpine:3.20
          command:
            - sh
            - -c
            - |
              apk add --no-cache openssl

              CERT=/kurento-pem/certificate
              KEY=/kurento-pem/private_key
              CA=/kurento-pem/ca_chain

              OUT_DIR=/kurento-ks
              mkdir -p "${OUT_DIR}"

              openssl pkcs12 -export \
                -in "${CERT}" \
                -inkey "${KEY}" \
                -out "${OUT_DIR}/client.p12" \
                -name kurento-client \
                -CAfile "${CA}" \
                -caname root \
                -passout pass:changeit
          volumeMounts:
            - name: kurento-client-tls
              mountPath: /kurento-pem
              readOnly: true
            - name: kurento-ks
              mountPath: /kurento-ks

      containers:
        - name: app
          {{- if $img.digest }}
          image: "{{ $img.repository }}@{{ $img.digest }}"
          {{- else }}
          image: "{{ $img.repository }}:{{ $img.tag | default "latest" }}"
          {{- end }}
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort | default 8080 }}
            - name: metrics
              containerPort: 8081

          env:
            {{- range .Values.env }}
            - name: {{ .name }}
              {{- if .value }}
              value: {{ .value | quote }}
              {{- end }}
              {{- if .valueFrom }}
              valueFrom:
                {{- toYaml .valueFrom | nindent 16 }}
              {{- end }}
            {{- end }}

          volumeMounts:
            # initContainer가 만든 client.p12를 앱에서 /etc/ssl/kurento로 봄
            - name: kurento-ks
              mountPath: /etc/ssl/kurento
              readOnly: true

      volumes:
        - name: kurento-client-tls
          secret:
            secretName: kurento-client-tls
        - name: kurento-ks
          emptyDir: {}

InitContainer는:

  • kurento-client-tls Secret에서 PEM 파일 3개를 읽고

  • /kurento-ks/client.p12 를 생성합니다.

  • 그 emptyDir을 메인 컨테이너에서 /etc/ssl/kurento 로 마운트합니다.

결과적으로, 앱 컨테이너 입장에서는:4) Kurento Client의 mTLS 연결 구성: Kurento를 제어하는 클라이언트 애플리케이션(Java 기반)은 일반적으로 KurentoClient.create() 메서드를 통해 KMS의 WebSocket에 연결합니다.

그런데 기본 설정으로는 서버 인증서 검증만 수행하고 클라이언트 인증서를 제공하는 기능은 별도로 노출되어 있지 않았습니다.

이를 해결하기 위해 Kurento Java 클라이언트가 내부적으로 사용하는 JsonRpcClientWebSocket을 직접 생성하여 커스텀 SSL 컨텍스트를 주입했습니다 .

예를 들어 Kurento Java API의 가이드에 따라 SslContextFactory를 생성하고, 여기에 우리가 준비한 신뢰 저장소(truststore)키 저장소(keystore) 정보를 설정했습니다.

Truststore에는 우리 사설 CA의 인증서가 들어있어 서버(KMS)의 인증서를 검증하도록 했고, Keystore에는 앞서 준비한 .p12 파일(클라이언트 인증서+키)을 로드하여 클라이언트 자신의 자격 증명으로 사용하도록 했습니다.

이렇게 구성한 SslContextFactory로 JsonRpcClientWebSocket 인스턴스를 생성한 뒤, KurentoClient.createFromJsonRpcClient(...)로 KurentoClient를 초기화했습니다 .

이 과정으로 클라이언트 측에서 mTLS 핸드셰이크를 수행할 준비를 마쳤습니다.

(서버 측 KMS에는 별도로 kurento.conf.json에서 secure WebSocket포트와 서버 인증서 경로를 설정하여 TLS를 활성화했고, 필요 시 클라이언트 인증서 검증을 위해 프록시 계층에서 CA 검증을 추가로 구성했습니다.)

(https://github.com/Kurento/kurento/pull/139) → Kurento Client Java SDK에는 mTLS인증을 위한 기능 추가하여 풀리퀘스트를 넣어두었습니다.

위의 과정을 거쳐, 최종적으로 애플리케이션 <-> KMS 간 WebSocket 통신에 mTLS 적용을 완료할 수 있었습니다.

이를 통해 얻은 효과는 분명했습니다.

첫째, 보안 정책 준수: 서비스 간 트래픽에 대해 mTLS 요건을 만족시켰습니다.

둘째, 자동화와 관리 효율: 수작업 없이 Vault가 인증서를 관리하고 주기적으로 갱신해주므로 운영 부하가 줄었고, 유출 위험도 낮아졌습니다.

셋째, 프로덕션 레디: 초기 설정은 다소 복잡했지만 일단 패턴을 정립하자 새로운 서비스나 환경에 쉽게 적용할 수 있게 되었고, 장애 발생 시 인증서 이슈를 빠르게 파악할 수 있는 모니터링도 구축했습니다.

무엇보다 서비스 간 신뢰할 수 있는 암호화 채널이 확보되어 안심하고 WebRTC 미디어 데이터를 주고받을 수 있게 되었고, 이는 사용자 데이터 보호 측면에서도 큰 이점이었습니다.


이상으로 mTLS의 개념적인 면과, 실제 Kubernetes 환경에서 mTLS를 적용한 사례를 살펴보았습니다.

요약하자면, mTLS는 양단이 서로 신원을 확인하는 한층 강화된 TLS이며, 제대로 활용하면 내부 서비스 보안을 크게 높일 수 있습니다.

물론 구현에는 추가 노력이 들지만, Vault 등 자동화 도구와 적절한 아키텍처를 활용하면 충분히 효율적으로 운영 가능합니다.

점점 더 제로트러스트 보안이 강조되는 추세 속에서, mTLS는 선택이 아닌 필수가 되어가고 있습니다.

저희도 이번 경험을 바탕으로 다른 내부 서비스들에도 mTLS를 확대 적용해 나갈 계획입니다.

여러분의 환경에서도 보안 수준 제고가 필요하다면 한 번 검토해보시면 좋겠습니다.

Share article

플래드