개발 공부/CS

TLS handshake 동작 원리 정리

gmelon 2026. 7. 26. 20:25

이전 편(HTTP 버전별 동시성 정리)이 전송 계층의 동시성을 다뤘다면, 이번 편은 그 위에서 통신을 보호하는 TLS 를 다룬다. QUIC 이 handshake 를 1-RTT 로 줄일 수 있었던 배경이기도 하다.

1. TLS 가 방어하는 것

네트워크 경로 위의 공격자(공용 와이파이 운영자, ISP, 라우터를 장악한 해커)는 세 가지를 할 수 있다.

공격 내용 TLS 의 대응
도청 지나가는 패킷을 복사해서 읽기 기밀성 (암호화)
변조 패킷 내용을 바꿔치기 (예: 다운로드 파일에 악성코드 삽입) 무결성 (변조 감지)
위장 "내가 example.com 이다"라고 속이기 인증 (신원 증명)

셋 중 하나라도 빠지면 나머지 보장도 의미가 없어진다. 예를 들어 인증 없이 암호화만 하면, 공격자와 "안전하게" 통신하는 무의미한 상황이 된다. 이후 등장하는 모든 설계 요소는 이 세 가지 보장 중 하나를 담당한다.

2. 대칭키와 비대칭키

대칭키: 빠르지만 전달할 방법이 없다

대칭키는 하나의 키로 암호화하고 같은 키로 복호화하는 방식이다. 대표 알고리즘이 AES 이고, CPU 에 전용 명령어(AES-NI)가 있을 만큼 최적화되어 있어 대용량 트래픽도 실시간으로 처리할 수 있다.

문제는 전제 조건에 있다. 양쪽이 이미 같은 키를 갖고 있어야 한다. 처음 접속하는 서버와 어떻게 키를 나눠 가질까? 키를 그대로 보내면 도청자도 얻는다. 암호화해서 보내려면 그 암호화에 쓸 키가 또 필요하다.

[대칭키의 키 배송 문제]

키를 그냥 보낸다         →   도청자도 키를 얻음
키를 암호화해서 보낸다    →   그 암호화 키는 어떻게 전달?
그 키도 암호화한다       →   같은 질문의 무한 반복

이걸 키 배송 문제라고 한다. 이 문제를 풀기 위해 비대칭키가 필요하다.

비대칭키: 짝으로 생성되는 키 두 개

비대칭키는 키를 만들면 수학적으로 연결된 키 쌍이 함께 생성된다. 규칙은 두 가지다.

  • 한쪽 키로 수행한 연산은 짝꿍 키로만 되돌릴 수 있다. 자기 자신으로는 되돌릴 수 없다.
  • 한쪽 키에서 다른 쪽 키를 계산해낼 수 없다.

그래서 하나는 전 세계에 공개하고(공개키), 하나는 본인만 보관한다(비밀키). 이 구조에서 방향이 반대인 두 가지 용법이 나온다.

[비대칭키의 두 용법]

      암호화 용법                      서명 용법
┌─────────────────────┐    ┌─────────────────────┐
│ 아무나                │    │ 주인만                │
│ 공개키로 암호화         │    │ 비밀키로 서명 생성      │
│          ▼          │    │          ▼          │
│ 주인만                │    │ 아무나                │
│ 비밀키로 복호화         │    │ 공개키로 서명 검증      │
│                     │    │                     │
│ 의미: "너만 읽어라"     │    │ 의미: "내가 작성했다"   │
└─────────────────────┘    └─────────────────────┘
              두 용법은 방향이 서로 반대다

서명 용법에서 중요한 성질 하나. 서명은 문서 내용과 결합되어 있어서, 문서가 한 글자라도 바뀌면 서명 검증이 실패한다. 즉 서명은 "이 문서를, 내가, 이 내용 그대로 승인했다"는 증거가 된다. 뒤에서 이 성질이 계속 쓰인다.

최신 TLS(1.3)에서는 서명 용법이 중심이고, 암호화 용법은 거의 쓰이지 않는다. 왜 그렇게 됐는지가 3장과 4장의 내용이다.

두 방식의 분업

비대칭 암호는 대칭 암호보다 수백~수천 배 느리다. 그래서 TLS 는 두 방식을 분업시킨다.

  • handshake: 비대칭키를 동원해서 대칭키를 안전하게 합의한다. 연결당 한 번.
  • 이후 모든 실데이터: 합의된 대칭키로 암호화한다.

비싼 연산은 키 합의에 한 번만 쓰고, 이후는 빠른 대칭 암호로 처리하는 구조다.

3. 키 배송 방식과 한계 (TLS 1.2)

공개키로 암호화해서 보내기

TLS 1.2 까지 널리 쓰인 방식은 암호화 용법을 그대로 적용한 것이다.

  • 클라이언트가 premaster secret 이라는 48바이트 비밀값을 생성한다
  • 서버의 공개키로 암호화해서 전송한다
  • 서버가 자기 비밀키로 복호화한다
  • 이제 양쪽이 같은 비밀값을 가졌다

실제 대칭키는 premaster 를 그대로 쓰지 않고 한 단계 더 거친다.

[TLS 1.2 의 키 유도]

마스터 비밀 = 유도함수( premaster,  클라 난수,  서버 난수 )
                       (암호문)     (평문)      (평문)
      │
      ▼
실제 사용할 키 묶음: 클라→서버 암호화 키, 서버→클라 암호화 키, …

난수가 평문이어도 되는 이유

난수가 평문으로 전달되는 게 이상해 보일 수 있는데, 재료마다 담당하는 역할이 다르다.

  • premaster: 비밀 담당. 유도 함수는 입력이 전부 있어야 출력이 나오는데, premaster 는 48바이트짜리 난수라 추측이 사실상 불가능하다. 이 하나가 비밀인 것으로 최종 키의 안전성은 확보된다.
  • 난수 2개: 신선도 담당. 연결마다 새로 뽑혀서 최종 키가 연결마다 달라지게 만든다. 이 임무는 값이 공개되어도 수행된다.

난수가 없으면 어떤 사고가 나는지 예를 하나 들면, 공격자가 어제의 연결 전체(handshake + 암호화된 "10만원 이체" 요청)를 녹화해뒀다가 오늘 서버에 그대로 재생하는 공격이 가능해진다. 공격자는 내용을 읽지도 못하고 키도 모르지만, 키가 같은 재료에서 같은 값으로 재현되므로 서버가 재생된 요청을 정상 처리해버린다. 서버 난수가 있으면 오늘의 서버는 새 난수를 뽑으므로 키가 달라지고, 재생된 어제의 암호문은 복호화에 실패한다.

그리고 현실적인 제약도 있다. 난수는 대화의 첫 메시지에 실리는데, 이 시점에는 대칭키는 물론이고 서버의 공개키조차 없다. 공개키가 담긴 인증서는 서버의 응답에 실려 오기 때문이다. 즉 암호화하고 싶어도 쓸 수 있는 키가 하나도 없는 시점이다. 그래서 평문이어도 안전한 재료(난수)를 먼저 교환하고, 비밀이 필요한 재료(premaster)는 보호 수단이 생긴 뒤(서버 공개키를 받은 뒤)에 보내는 순서가 된 것이다.

녹화 공격과 forward secrecy

이 방식은 잘 동작했지만 구조적인 약점이 있다. 서버의 비밀키 하나로 모든 연결의 premaster 를 복호화할 수 있다는 점이다.

[녹화 공격 타임라인]

2023년   공격자가 암호화된 트래픽을 통째로 녹화 (지금은 못 엶)
   │
2026년   서버 해킹으로 비밀키 유출
   │
   ▼     녹화해둔 3년치 트래픽의 premaster 를 전부 복호화
         → 난수는 원래 평문이라 키 유도 재료 완성
         → 과거의 비밀번호·메시지 전부 복호화됨

미래의 유출이 과거를 소급해서 뚫는 구조다. 난수 2개는 여기서 아무 방어가 되지 못한다. 평문이라 녹화본에 이미 들어 있기 때문이다. 난수의 역할은 비밀 유지가 아니라 신선도이고, 비밀 유지는 premaster 하나가 전담한다. 그래서 그 premaster 를 복호화할 수 있는 비밀키가 서버에 영구 보관된다는 점이 구조 전체의 약점이 된다. 이런 약점이 없는 성질, 즉 "장기 비밀키가 유출되어도 과거 트래픽은 안전하다"를 forward secrecy 라고 하는데, 키 배송 방식에는 이게 없다. 이 때문에 TLS 1.3 에서 키 배송 방식은 완전히 제거됐다.

4. 키 합의 방식 (ECDHE)

키를 보내지 않고 만든다

대체한 방식이 Diffie-Hellman(DH) 키 교환이다. 키를 한쪽이 만들어 배송하는 게 아니라, 양쪽이 공개된 값만 주고받고 각자 자기 자리에서 같은 값을 계산해낸다.

[DH 키 교환]                              (물감 섞기에 자주 비유된다)

① 공통 파라미터를 공개적으로 합의             공통 물감 "노랑"
② 클라: 비밀값 a 생성 (외부에 안 보냄)        비밀 물감 "빨강"
   서버: 비밀값 b 생성 (외부에 안 보냄)        비밀 물감 "파랑"
③ 클라 → 서버: 공개값 A (a 에서 계산)         노랑+빨강 = "주황" 전달
   서버 → 클라: 공개값 B (b 에서 계산)         노랑+파랑 = "초록" 전달
④ 클라: B 와 a 를 결합 → 공유 비밀 S          초록+빨강 = "갈색"
   서버: A 와 b 를 결합 → 같은 S              주황+파랑 = 같은 "갈색"
⑤ 도청자: A 와 B 는 봤지만 a, b 가 없어       섞인 물감에서 원색을
   S 를 계산할 수 없음                        분리할 수 없음

수학적으로 "섞기"는 거듭제곱한 뒤 큰 소수로 나눈 나머지를 취하는 연산(모듈러 거듭제곱)이다. 정방향 계산은 반복 제곱이라는 지름길이 있어 큰 수에서도 빠르게 끝난다. 반대로 공개값에서 비밀값(지수)을 찾아내는 역방향이 이산로그 문제인데, 나머지 연산이 값의 크기 순서를 지워버려서 "결과가 너무 크니 지수를 줄여보자" 같은 좁혀가기가 통하지 않는다. 지름길 없이 2^256 규모의 후보를 시험하는 문제가 되고, 알려진 최선의 공격들도 키 길이를 조금 늘리면 무력화되는 수준이라, 현재 컴퓨터로는 사실상 풀 수 없다.

실전에서 쓰는 변형이 ECDHE 다. 이름을 분해하면 위 구조에 두 가지가 더해진 것이다.

  • DH: 위에서 본 키 합의 구조 그대로
  • EC(Elliptic Curve): "섞기" 연산을 모듈러 거듭제곱 대신 타원곡선 위의 연산으로 바꾼 것. 역산이 어렵다는 성질은 같은데, 훨씬 짧은 키로 같은 안전성이 나와서(3072비트 급 ≈ 256비트) 계산이 빠르고 주고받는 데이터도 작다
  • E(Ephemeral): 키쌍을 연결마다 새로 만들고 폐기한다는 뜻. 바로 다음 절의 내용이다

결과적으로 대칭키의 재료(공유 비밀 S)가 네트워크를 한 번도 지나가지 않는다. 오간 것은 공개값 A, B 뿐이고, 이것은 도청당해도 무해하다.

임시 키쌍과 forward secrecy

②의 비밀값과 ③의 공개값, 이 구조는 비대칭 키쌍 그 자체다. 다만 용도가 다르다. 서버가 수년간 보유하는 신원용 키쌍이 아니라, 이 연결 하나만을 위해 즉석에서 만들고 연결이 끝나면 폐기하는 일회용 키쌍이다.

일회용이라는 점이 3장의 녹화 공격을 무력화한다. 3년 뒤 서버가 통째로 해킹당해도, 그때 그 연결의 비밀값 a, b 는 이미 폐기되어 어디에도 존재하지 않는다. 녹화해둔 트래픽은 영원히 암호문으로 남는다. 재료별로 비교하면 이렇다.

재료 연결마다 새로 생성 비밀 사후 복원 가능성
난수 (1.2/1.3 공통) O X (평문) 녹화본에 그대로 있음
premaster (1.2 키 배송) O O 가능 (서버 비밀키 유출 시)
DH 비밀값 (1.3) O O 불가능 (폐기됨, 복원 경로 없음)

forward secrecy 에 필요한 것은 "임시 + 비밀 + 폐기" 세 가지의 조합이고, DH 의 비밀값만 셋을 모두 충족한다.

남은 문제: 상대가 누구인지 모른다

DH 는 "누군가와" 도청 불가능한 비밀 합의를 하게 해준다. 그런데 그 누군가가 진짜 서버라는 보장이 없다. DH 공개값에는 신원 정보가 전혀 없기 때문이다. 이 신원 문제를 해결하는 장치가 인증서다.

5. 인증서와 PKI

중간자 공격 (MITM)

공격자가 중간에서 양쪽과 각각 DH 합의를 하면 이렇게 된다.

[중간자 공격]

클라이언트 ◀── DH 합의 ──▶ 공격자 ◀── DH 합의 ──▶ 진짜 서버
        (비밀 S1 공유)          (비밀 S2 공유)

클라이언트: "키 합의 완벽하게 성공" (상대가 공격자인 줄 모름)
공격자: S1 로 복호화해 읽고 → 조작하고 → S2 로 재암호화해 릴레이

암호 수학은 한 치의 오류 없이 동작했는데도 뚫린다. "누구와 합의했는가"는 수학 바깥의 문제이고, 별도의 신뢰 장치가 필요하다.

신뢰의 출발점: trust store

비대칭키의 서명 용법을 쓰면 될 것 같다. 서버가 자기 DH 공개값에 장기 비밀키로 서명하고, 클라이언트가 서버의 공개키로 검증하면 된다. 그런데 여기서 같은 문제가 반복된다. 클라이언트는 검증에 쓸 "서버의 공개키"를 어디서 얻는가? 네트워크로 받으면 공격자가 자기 공개키를 대신 줄 수 있다.

이 무한 반복을 끊으려면 네트워크로 받지 않은, 원래부터 갖고 있던 신뢰가 필요하다. 그것이 OS 와 브라우저에 미리 내장된 루트 인증기관(CA)들의 공개키 목록(trust store)이다. 네트워크는 못 믿어도 내 기기에 처음부터 들어 있던 것은 믿는다는 전제에서 모든 신뢰가 출발한다.

인증서와 신뢰 사슬

인증서는 "도메인 example.com 의 공개키는 X 다. 유효기간은 언제까지"라는 문서에 CA 의 서명이 붙은 것이다. CA 는 아무에게나 서명해주지 않는다. 발급 전에 "이 도메인의 웹서버나 DNS 에 이 토큰을 올려보라"는 방식으로 도메인 소유를 확인한다. 공격자는 example.com 의 서버를 통제하지 못하므로 이 확인을 통과할 수 없고, 따라서 example.com 명의의 인증서를 발급받을 수 없다. Let's Encrypt 가 이 절차(ACME)를 자동화하면서 인증서 발급이 무료·자동이 됐다.

실제 서명은 계단식이다. 루트 CA 의 비밀키는 이 체계 전체의 최상위 자산이라 오프라인 금고에 보관하고, 일상적인 발급은 중간 CA 가 담당한다.

[신뢰 사슬]

내 기기에 내장 (네트워크로 받지 않음)
┌──────────────┐
│ 루트 CA 공개키 │──서명 검증──▶ 중간 CA 인증서 ──서명 검증──▶ example.com 인증서
└──────────────┘                                            (장기 공개키 포함)
                                                                   │
                                                          비밀키 보유 증명 서명
                                                                   ▼
                                                       이번 연결의 임시 DH 공개값

클라이언트는 접속 시 이 사슬을 검증한다. 서명이 trust store 의 루트까지 이어지는지, 인증서의 도메인이 지금 접속하려는 도메인과 일치하는지, 유효기간이 지나지 않았는지.

비밀키 보유 증명

인증서에는 비밀이 없다. 공개키와 서명뿐인 공개 문서라서, 공격자도 example.com 의 진짜 인증서를 내밀 수는 있다. 그래서 인증서 제시만으로는 부족하고, "그 인증서에 적힌 공개키의 짝꿍 비밀키를 실제로 보유하고 있다"는 증명이 추가로 필요하다. 방법은 지금까지의 handshake 대화 전체에 그 비밀키로 서명해 보이는 것이다. 공격자는 인증서는 복사해도 비밀키가 없으므로 이 서명을 만들 수 없다.

이 서명의 대상에 서버의 임시 DH 공개값이 포함되므로, 여기서 "이 DH 값은 진짜 example.com 이 만든 것"이 증명된다. 정리하면 키쌍이 두 개 등장한 것이다.

  • 임시 DH 키쌍: 연결마다 만들고 버리는 합의용. 신원 정보 없음.
  • 장기 서명 키쌍: 서버가 수년 보유하는 신원용. 인증서가 공증하는 대상.

장기 키의 서명이 임시 키를 보증하는 구조로, trust store 에서 출발한 신뢰가 방금 만든 일회용 DH 값까지 단계적으로 전달된다.

실무 참고로, 인증서 유효기간은 계속 짧아지는 추세다. 2026년 3월부터 최대 200일로 줄었고, 2027년 100일, 2029년 47일까지 단계적 단축이 예정되어 있다(CA/Browser Forum 결정). 유출된 비밀키가 유효한 기간을 줄이려는 것으로, ACME 기반 자동 갱신이 사실상 필수가 됐다.

6. TLS 1.3 handshake 전체 흐름

대칭키 합의(4장), 신원 증명(5장), 난수(3장)를 최소한의 왕복으로 배치한 것이 TLS 1.3 handshake 다.

[TLS 1.3 handshake]

클라이언트                                              서버
   │                                                    │
   │──① ClientHello ─────────────────────────────────▶│
   │     · 클라 난수, 지원하는 암호 방식 목록                │
   │     · key_share: 임시 DH 공개값 (선발송)              │
   │                                                    │
   │       (서버: 임시 DH 키쌍 생성 → 공유 비밀 계산         │
   │        → 대칭키 유도)                                │
   │                                                    │
   │◀─② ServerHello: 서버 난수, 선택 결과, DH 공개값 ──────│
   │◀─② {인증서 사슬} ─────────────────── 여기부터 암호화 ──│
   │◀─② {CertificateVerify: 대화록 전체에 서명} ───────────│
   │◀─② {Finished: 대화록 전체의 MAC} ────────────────────│
   │                                                    │
   │       (클라: 공유 비밀 계산 → 복호화                   │
   │        → 사슬 검증, 도메인 확인, 서명 검증, MAC 검증)    │
   │                                                    │
   │──③ {Finished} + {첫 HTTP 요청} ───────────────────▶│
   │◀────{첫 HTTP 응답} ─────────────────────────────────│
   │                                                    │
   │                합계: 1 RTT                          │

※ 번호는 전송 묶음(flight). 같은 번호의 메시지들은 한 번에 이어서 전송된다.

도식의 1 RTT 는 TLS handshake 만의 왕복 수다. TCP 위에서 돌 때는 이 앞에 TCP handshake 1 왕복이 따로 붙는다. 8장에서 전송 계층까지 합산해 비교한다.

①의 선발송 이 왕복을 줄인 지점이다. 클라이언트는 서버가 어떤 암호 방식을 고를지 예측해서 임시 DH 공개값을 첫 메시지에 실어 보낸다. 예측이 맞으면(대부분 맞는다) 서버의 첫 응답으로 키 합의가 끝난다. 빗나가면 서버가 다시 요청해서 한 왕복이 추가되지만 드문 경우다.

TLS 1.2 는 "암호 방식 목록 교환 → 선택 → 그 방식대로 키 교환"의 직렬 구조라 2 RTT 가 필요했다. 1.3 은 협상과 키 교환을 한 왕복에 겹친 것이다.

비교를 위해 TLS 1.2 의 전체 흐름도 같은 형식으로 그리면 다음과 같다.

[TLS 1.2 handshake (키 배송 방식)]

클라이언트                                              서버
   │                                                    │
   │──① ClientHello ─────────────────────────────────▶│
   │     · 클라 난수, 지원하는 암호 방식 목록                │
   │     · 키 재료 없음 (방식이 아직 안 정해졌으므로)          │
   │                                                    │
   │◀─② ServerHello: 서버 난수, 선택한 방식 ──────────────│
   │◀─② Certificate: 인증서 사슬 (평문!) ─────────────────│
   │◀─② ServerHelloDone: "내 차례 끝" ───────────────────│
   │                                                    │
   │──③ ClientKeyExchange ───────────────────────────▶│
   │     · premaster 생성, 인증서의 공개키로 암호화          │
   │──③ ChangeCipherSpec: "지금부터 암호화" ────────────▶│
   │──③ {Finished} ───────────────────────────────────▶│
   │                                                    │
   │◀─④ ChangeCipherSpec + {Finished} ───────────────────│
   │                                                    │
   │──⑤ {첫 HTTP 요청} ────────────────────────────────▶│
   │                                                    │
   │                합계: 2 RTT                          │

1.3 흐름과 나란히 놓으면 차이가 세 곳에서 보인다.

  • 키 재료의 시점: 1.2 의 ①에는 키 재료가 없다. 어떤 방식을 쓸지 ②에서 정해진 뒤에야 ③에서 premaster 를 보낼 수 있어서, 협상과 키 교환이 직렬이 되고 한 왕복이 더 든다. 1.3 은 ①에서 DH 공개값을 선발송해 둘을 겹쳤다.
  • 키 재료의 성격: 계산이 아니라 배송이다. 3장에서 정리한 그 방식이고, forward secrecy 가 없는 이유다.
  • 인증서 노출: 1.2 는 암호화가 ③ 이후에나 시작되므로 인증서가 평문으로 노출된다. 1.3 은 서버가 첫 응답 시점에 이미 대칭키를 가지므로 인증서부터 암호화해서 보낸다.

참고로 1.2 의 이 흐름에는 1.3 의 CertificateVerify 같은 별도의 비밀키 보유 증명이 없다. 공개키로 암호화된 premaster 를 열 수 있다는 것 자체가 보유 증명을 겸하기 때문이다. 1.3 에서 배송이 사라지면서 이 암묵적 증명도 함께 사라졌고, 그래서 명시적인 서명 메시지가 필요해진 것이다.

대화록 기반 검증

②에 등장한 대화록(transcript) 은 handshake 시작부터 지금까지 주고받은 모든 메시지의 바이트를 순서대로 이어붙인 것이다. 양쪽이 각자 자기가 보내고 받은 그대로를 기억해둔다. 조작이 없었다면 두 기록은 바이트 단위로 동일하다.

여기 쓰이는 MAC(Message Authentication Code)은 메시지의 지문(해시)을 계산할 때 비밀 키를 섞은 것이다. 키가 없으면 유효한 값을 만들 수 없으므로, "내용이 그대로다"에 더해 "키를 가진 쪽이 만들었다"까지 증명한다.

②의 마지막 두 메시지는 둘 다 대상이 대화록 전체인데, 증명하는 것이 다르다.

  • CertificateVerify: 대화록 전체에 장기 비밀키로 서명한 것. 신원을 증명한다. 인증서의 공개키로 검증되므로, "진짜 example.com 이 지금까지의 대화를 이 내용 그대로 인지하고 승인했다"가 확인된다(비밀키 보유 증명, 5장).
  • Finished: 대화록 전체에 대한 MAC. 키를 공유 비밀에서 유도하므로 신원과는 무관하다. 대신 상대가 나와 같은 공유 비밀을 유도했다는 것(키 합의 확인)과, 그 상대가 인지한 대화록이 내 것과 같다는 것이 증명된다. 클라이언트는 보통 인증서가 없으므로, 클라 쪽 확인은 ③의 Finished 가 전담한다.

클라이언트에게는 비교할 원본이 없으므로 "대화록이 조작됐는지" 직접 알 방법이 없다. 대신 "내가 기록한 대화록에 대해, 유효한 서명과 MAC 을 상대가 제시할 수 있는가" 를 확인한다. 공격자가 중간에서 무엇을 바꿨든, 클라이언트의 대화록에는 조작본이 들어가고, 조작된 대화록에 맞는 서명과 MAC 을 새로 만들려면 장기 비밀키 또는 공유 비밀이 필요하다. 공격자에게는 둘 다 없다.

대화록을 통째로 검증하는 이유는 handshake 초반이 평문 구간이기 때문이다. 예를 들어 공격자가 ClientHello 의 암호 방식 목록에서 강한 방식들을 지워 약한 방식이 선택되게 유도할 수 있다(downgrade 공격). 조작 시점에는 막을 방법이 없지만, 마지막에 대화록을 검증하면 "클라이언트가 보낸 목록"과 "서버가 받은 목록"이 달랐다는 것이 드러나 연결이 끊어진다. 소급해서 적발하는 구조다.

공격자의 선택지를 하나씩 확인해보면 전부 막혀 있다.

공격 시도 차단 지점
자기가 CA 인 척 서명 그 CA 는 trust store 에 없음. 사슬 검증 실패
진짜 CA 의 서명 위조 CA 비밀키가 없어 불가능
example.com 명의로 정식 발급 시도 CA 의 도메인 소유 확인을 통과 못 함
진짜 인증서를 복사해서 제시 짝꿍 비밀키가 없어 CertificateVerify 실패
자기 도메인의 정식 인증서 제시 도메인 불일치로 실패
handshake 메시지 일부 조작 대화록이 달라져 서명/MAC 검증 실패

조작을 "발견"하는 게 아니라, 조작이 있으면 상대가 통과할 수 없는 시험을 내는 구조다.

이 서명·MAC 검증은 handshake 를 마칠 때 한 번만 하고, 이후의 요청/응답에 매번 반복하지는 않는다. 대신 모든 실데이터가 record 단위로 봉인되면서 변조 감지가 이어진다. 그 방법이 다음 장의 내용이다. 정리하면 신원과 합의 과정에 대한 검증은 연결당 한 번, 데이터에 대한 변조 감지는 매 record 다.

세션 재개와 PSK

매번 이 전체 과정을 반복하는 것은 아니다. handshake 를 마친 서버는 session ticket 이라는 토큰을 클라이언트에게 발급해줄 수 있다. 재개용 비밀을 서버만 아는 키로 암호화한 것이라, 클라이언트는 내용을 모른 채 보관만 한다. 재방문 때 클라이언트가 ClientHello 에 이 티켓을 실어 보내면, 양쪽은 이전 세션에서 유도해둔 공유 비밀(PSK, Pre-Shared Key)을 사용해 인증서 사슬 검증과 서명 연산을 통째로 생략한다. 이때도 새 ECDHE 합의를 함께 섞는 것이 기본이라 forward secrecy 는 유지된다. 8장의 "재방문 0-RTT" 가 이 PSK 위에서 동작한다.

7. 실데이터 암호화 (record 와 AEAD)

handshake 가 끝나 양쪽이 같은 대칭키를 가졌다. 이번 장은 그 키로 실데이터를 보호하는 방법이다. 2장에서 "이후 모든 실데이터는 합의된 대칭키로 암호화한다"고 했는데, 실제로는 암호화만 하는 게 아니다.

record: 잘라서 봉인하는 단위

record 는 TLS 가 정의하는 봉인 단위다. TCP 는 경계 없는 바이트 스트림만 제공하는데, 봉인과 검증은 완결된 덩어리 단위로만 가능하다. 그래서 TLS 는 위 계층이 내려보낸 바이트를 앞에서부터 최대 16KB(스펙상 2^14 바이트)씩 잘라 조각마다 봉인하고, 타입과 길이가 적힌 작은 헤더를 붙여 TCP 로 내려보낸다. 수신 측은 헤더에 적힌 길이만큼 바이트가 전부 도착해야 record 하나를 복호화·검증할 수 있다. 절반만 도착한 record 는 열 수 없다.

HTTP/2 가 바이트 스트림 위에 frame 이라는 자기 단위를 정의했듯(이전 편 4장), TLS 는 record 라는 자기 단위를 정의한 것이다. handshake 메시지도 실데이터도 전부 이 봉투에 실려 가고, 위에 올라가는 HTTP 버전이 무엇이든(1.0/1.1/2) record 층은 동일하다. TLS 에게 위 계층의 바이트는 불투명해서, 버전에 따라 달라지는 건 봉투가 아니라 봉투에 담기는 내용물뿐이다.

[계층별 단위 (TCP + TLS 스택)]

HTTP/2    frame     [A1][B1][A2][C1][B2]…       여러 stream 의 frame 이 섞인 순서
              │  하나의 바이트 스트림으로 직렬화
              ▼
TLS       record    [    봉인    ][    봉인    ]   최대 16KB 씩 잘라 각각 봉인
              │  봉인된 record 들이 다시 바이트 스트림으로
              ▼
TCP       segment   [--][--][--][--][--][--]     MSS(약 1460B) 단위로 잘라 운반

암호화만으로는 부족하다

복호화는 "실패"하지 않는다는 사실부터. 복호화는 키로 수행하는 역변환 계산이고, "유효하지 않은 암호문"이라는 개념이 알고리즘 안에 없다. 그래서 중간에서 조작된 암호문을 넣어도 에러가 나는 게 아니라 엉뚱한 평문이 조용히 나온다. 공격자의 복호화를 막지 못한다는 뜻이 아니다. 키가 없는 공격자는 여전히 내용을 읽을 수 없다. 문제는 수신자 쪽으로, 키로 열어도 그 결과가 조작의 산물인지 알 수 없다는 것이다.

표적 조작도 가능하다. 널리 쓰이는 스트림 계열 암호는 암호문의 i번째 비트를 뒤집으면 평문의 정확히 i번째 비트가 뒤집힌다. 공격자가 키는 몰라도 "이 위치에 금액이 온다"는 것만 알면, 암호문 비트를 뒤집어 amount=100amount=900 으로 바꿀 수 있다. 키 없이, 복호화 실패도 없이. 그래서 암호화(기밀성)에는 변조 감지(무결성)가 반드시 따라붙어야 하고, TLS 는 처음부터 record 마다 둘을 함께 적용해왔다. 그 방법이 버전에 따라 달라졌다.

TLS 1.2 까지: 암호화 따로, 변조 감지 따로

1.2 까지는 두 연산을 각각 수행했다. record 마다 평문에 MAC(6장)을 계산해 붙이고, 그 전체를 블록 암호(AES-CBC 등)로 암호화했다. 3장 키 유도의 "키 묶음"에 이 구조가 반영되어 있다. 방향별 암호화 키에 더해 방향별 MAC 키가 따로 유도됐다. 9장 실측에서 키 배송을 강제할 때 쓰는 cipher 이름 AES128-SHA 도 여기서 나온 것이다. AES128(-CBC)이 암호화, SHA 가 MAC(HMAC-SHA1) 담당이라는 뜻이다.

동작은 했지만 조합 지점이 계속 공격면이 됐다. 붙이는 순서(MAC 먼저 vs 암호화 먼저), 블록 크기를 맞추는 패딩의 처리 같은 미묘한 부분에서, 패딩 오류와 MAC 오류의 응답 차이(에러 종류, 미세한 시간 차)를 힌트 삼아 암호문을 한 바이트씩 복원하는 공격이 반복해서 나왔다(패딩 오라클 계열). 방식 자체보다 "따로 만든 두 부품의 조립"이 문제였던 셈이다.

TLS 1.3: AEAD 하나로 봉인

그래서 1.3 은 조립을 구현자에게 맡기지 않는다. 암호화와 변조 감지를 처음부터 한 연산으로 설계한 AEAD(Authenticated Encryption with Associated Data)만 허용한다. 대표 구현이 AES-GCM, ChaCha20-Poly1305 다. 등장하는 재료는 네 가지다.

  • : handshake 에서 합의한 공유 비밀로부터 유도한 대칭키. 방향별로 하나씩(클라→서버, 서버→클라).
  • 일련번호: 양쪽이 각자 세는 record 순번(0, 1, 2, …). 양쪽 다 알고 있으므로 네트워크로 전송하지 않고 카운트만 한다. TCP 가 순서 보장과 유실 복구를 해주기 때문에, 정상 상황에서 양쪽의 카운트는 어긋나지 않는다.
  • nonce(1회용 번호): 같은 키로 절대 재사용하면 안 되는 번호. handshake 에서 키와 함께 IV 라는 12바이트 값을 유도해두고, record 마다 "IV XOR 일련번호"로 만든다(8바이트 일련번호 앞에 0을 채워 길이를 맞춘 뒤). 일련번호를 그대로 안 쓰는 이유는 두 가지다. AEAD 가 요구하는 12바이트 형태로 맞추기 위해서, 그리고 모든 연결의 nonce 가 똑같이 0, 1, 2, … 로 예측되지 않도록 연결마다 다른 IV 를 섞기 위해서다. IV 는 유도로, 일련번호는 카운트로 양쪽이 이미 알고 있어 이것도 전송이 필요 없다.
  • 태그: 봉인할 때 암호문 전체에 대해 계산되어 뒤에 붙는 16바이트 검증값. MAC 과 같은 성질이라, 키가 없으면 유효한 값을 만들 수 없다.
[AEAD 봉인과 개봉]

봉인 (보내는 쪽)
  입력   키, nonce(일련번호 기반), 평문, 부가 데이터(record 헤더)
  출력   암호문 + 태그(16바이트)

개봉 (받는 쪽)
  입력   키, nonce(자기가 센 일련번호 기반), 암호문 + 태그, 부가 데이터
  검사   암호문·부가 데이터 전체로 태그를 재계산 → 받은 태그와 비교
         일치    → 평문 반환
         불일치  → 평문을 아예 반환하지 않고 연결 종료

이 구조가 막는 것을 정리하면 이렇다.

  • 내용 조작: 암호문이 한 비트라도 바뀌면 태그가 불일치한다. 위의 비트 뒤집기가 즉시 걸린다.
  • 헤더 조작: 이름의 Associated Data(부가 데이터)가 이 몫이다. 평문으로 다녀야 하지만 바뀌면 안 되는 값(record 헤더)을 암호화 없이 태그 계산에만 포함시킨다.
  • 재전송·순서 바꾸기: 내용을 안 건드리고 record 를 다시 보내거나 순서를 바꾸면, 수신 측이 센 일련번호와 어긋나 nonce 가 달라지고 태그 검증이 실패한다. 카운트가 어긋나는 상황을 복구 대상이 아니라 공격 신호로 취급하고 연결을 끊는 것이다.

복호화하면서 검증까지 끝나고, 검증에 실패하면 평문 자체를 얻을 수 없다. "엉뚱한 평문이 조용히 나오는" 일이 구조적으로 사라진 것이다.

8. QUIC 과의 통합 (HTTP/3)

handshake 엔진만 이식한다

TLS 1.3 은 두 부분으로 나눌 수 있다.

  • handshake 부분: 키 교환, 인증서 검증, 키 유도. 6장까지의 내용 전부.
  • record 부분: 7장의 실데이터 봉인 계층.

QUIC 은 이 중 handshake 부분만 가져온다. 검증된 보안 로직을 새로 만들지 않고 재사용하되, 운반과 암호화는 자기 방식으로 처리하는 구조다.

[TCP 스택과 QUIC 스택의 TLS]

  TCP + TLS                       QUIC (HTTP/3)
  ┌───────────────┐              ┌─────────────────────────────┐
  │ HTTP/2        │              │ HTTP/3                      │
  ├───────────────┤              ├─────────────────────────────┤
  │ TLS record 층  │ ← 봉인 담당   │ QUIC                        │
  │ TLS handshake │              │  · TLS handshake 엔진 (이식)  │ ← 키 합의만 담당
  ├───────────────┤              │  · packet protection        │ ← 봉인은 QUIC 이 직접
  │ TCP (평문)     │              ├─────────────────────────────┤
  └───────────────┘              │ UDP                         │
                                 └─────────────────────────────┘

handshake 메시지(ClientHello 등)의 내용은 동일하고, 운반 방법만 바뀐다. TLS record 대신 QUIC 의 CRYPTO frame 에 실려 간다.

record 층을 버린 이유

record 봉인에 쓰이는 AEAD(7장)는 태그가 record 전체에 대해 계산되므로, 마지막 바이트까지 모여야 복호화·검증이 가능하다. 그리고 크기 관계상 record 하나는 여러 패킷에 나뉘어 실린다. record 최대 크기는 16KB 인데 패킷 크기는 경로 MTU(이더넷 기준 약 1500 바이트)에 묶여 있어서, 최대 크기 record 하나가 패킷 11개 이상에 걸치는 것이다. 여기에 HTTP/2 는 모든 stream 의 frame 을 하나의 바이트 스트림으로 직렬화하고 record 층은 frame 경계를 모른 채 바이트 위치로만 자르기 때문에, record 하나에는 여러 stream 의 데이터가 섞인다.

TCP 위에서는 이 구조가 추가 비용이 아니었다. 패킷이 유실되면 어차피 TCP 레벨 HOL blocking 이 모든 데이터를 같은 자리에서 막았기 때문이다(h1.1 은 애초에 응답이 한 줄로 흐르고, h2 는 TCP 가 이미 전체를 막는다). 그런데 stream 단위 복구를 갖춘 QUIC 위에 record 층을 그대로 얹으면, 전송 계층이 만들어낸 격리가 암호화 계층에서 도로 깨진다. 패킷 하나가 유실되면 그 record 전체를 열 수 없고, record 안에 섞여 있던 무관한 stream 까지 함께 막히기 때문이다.

그래서 QUIC 은 봉인 단위를 record 에서 패킷으로 바꿨다. 패킷 하나가 그 자체로 완결된 봉인 덩어리라서, 도착 즉시 다른 패킷 없이 단독으로 복호화·검증된다. 이것이 packet protection 이다.

7장에서 nonce 는 record 일련번호로 만든다고 했다. 다만 "각자 세기"는 TCP 의 순서 보장 위에서만 성립하는 방식이다. 순서가 뒤섞이는 UDP 위에서는 카운트가 어긋난다. QUIC 에는 이미 완벽한 후보가 있다. 패킷에 명시적으로 실려 다니고, 재전송에도 재사용되지 않고 단조 증가하는 패킷 번호다. 유실 감지를 위한 설계가 암호화의 요구사항을 그대로 충족한다.

왕복 수의 단축

TCP 스택에서 전송 합의(TCP handshake)와 암호 합의(TLS handshake)는 직렬로 진행된다. 계층 관계 때문이다. TLS 메시지는 TCP 의 페이로드라서, TCP handshake 가 끝나 연결이 성립하기 전에는 ClientHello 를 실어 보낼 방법이 없다. SYN 에 데이터를 실어 이 순서를 깨려던 시도(TCP Fast Open)도 있었지만, 낯선 형태의 패킷을 버리는 중간 장비들 때문에 널리 쓰이지 못했다. 이전 편에서 다룬 ossification(중간 장비들이 기존 프로토콜 형태를 검사하고 간섭해서, 프로토콜이 새로운 형태로 진화하지 못하게 굳는 현상)과 같은 원인이다.

QUIC 은 전송과 암호가 한 프로토콜이라 이 전제가 없다. 첫 패킷(Initial)이 그 자체로 연결 수립의 시작이면서, 그 안의 CRYPTO frame 에 ClientHello 가 실려 간다. TCP 라면 SYN 옵션으로 교환했을 전송 설정(흐름 제어 한도 등)도 TLS 확장(quic_transport_parameters)에 담아 암호 합의와 같은 메시지에서 교환한다. 전송 합의가 암호 합의에 실려 가는 구조라 왕복 하나에 둘 다 끝나고, 전송 설정까지 인증·암호화의 보호를 받는다.

[첫 응답까지의 왕복 수]

TCP + TLS 1.2    [TCP 1] + [TLS 2]                 = 3 RTT
TCP + TLS 1.3    [TCP 1] + [TLS 1]                 = 2 RTT
QUIC             [전송 합의 + 암호 합의 동시]         = 1 RTT
QUIC 재방문       [PSK 재사용, 첫 패킷에 요청 동봉]    = 0-RTT

TCP + TLS 1.3 줄의 [TLS 1] 이 6장 도식에서 센 그 1 RTT 다.

0-RTT 는 6장의 세션 재개(PSK)를 이용한다. 재방문 클라이언트가 보관해둔 PSK 로 첫 패킷에 암호화된 요청까지 동봉하는 것인데, 실무 제약이 있다. 이 시점의 데이터는 공격자가 복사해서 재전송(replay)할 수 있어서, 멱등 요청(GET)만 실어야 한다. POST 같은 요청은 handshake 완료 후에 보내야 하고, 실제로 서버/CDN 들이 0-RTT 데이터에서 비멱등 요청을 거부하도록 구현되어 있다.

보호 범위의 확장

TCP 스택에서 TLS 가 보호하는 것은 TCP 페이로드뿐이다. TCP 헤더(시퀀스 번호, 플래그)는 평문에 무결성 보호도 없어서, 도청자가 유효한 시퀀스 번호를 단 위조 RST 패킷으로 연결을 강제 종료시킬 수 있다. 내용은 못 읽어도 통신은 끊을 수 있는 것이고, 실제 검열 시스템들이 쓰는 방식이다. 중간 장비가 TCP 헤더에 개입할 수 있다는 사실 자체가 이전 편에서 다룬 ossification 의 원인이기도 했다.

QUIC 은 전송 메타데이터(패킷 번호, ACK, 연결 종료 신호)까지 전부 암호화한다. 유효한 종료 신호를 위조하려면 키가 필요하므로 RST 류의 공격이 차단되고, 중간 장비가 프로토콜 내부를 볼 수 없으니 ossification 도 원천적으로 방지된다. QUIC 이 TLS 1.3 미만을 허용하지 않고 암호화 없는 모드 자체가 없는 것도 같은 맥락이다.

9. handshake 직접 관찰

여기까지의 내용이 실제로 그렇게 오가는지 로컬에서 확인해본다. 확인할 것은 크게 세 가지다. 메시지가 6장의 도식대로 오가는지, 1.2 와 1.3 의 차이(인증서 노출, 왕복 수)가 실제로 관측되는지, QUIC 이 정말 첫 패킷에 handshake 를 싣는지.

메시지 순서: openssl s_client

openssl s_client 는 TLS 클라이언트를 직접 열어주는 도구다. -msg 옵션을 주면 오가는 handshake 메시지를 순서대로 출력한다.

# TLS 1.3 (기본 협상)
openssl s_client -connect cloudflare.com:443 -msg

# TLS 1.2 강제
openssl s_client -connect cloudflare.com:443 -tls1_2 -msg

1.3 출력에서 핵심 줄만 남기면 다음과 같다.

>>> TLS 1.3, Handshake [length 0604], ClientHello          ← ① 클라 → 서버 (평문)
<<< TLS 1.3, Handshake [length 04ba], ServerHello          ← ② 서버 → 클라 (평문)
<<< TLS 1.3, Handshake [length 000a], EncryptedExtensions  ← ② 여기부터 암호화 구간
<<< TLS 1.3, Handshake [length 0a0d], Certificate          ← ②
<<< TLS 1.3, Handshake [length 004f], CertificateVerify    ← ②
<<< TLS 1.3, Handshake [length 0034], Finished             ← ②
>>> TLS 1.3, Handshake [length 0034], Finished             ← ③ 클라 → 서버

6장 도식의 ①②③ 순서 그대로다. EncryptedExtensions 는 도식에서 생략한 부가 확장 메시지다. -trace 옵션으로 열어보면 ClientHello 안에 key_share(51) 확장, 즉 6장의 선발송이 실제로 실려 있는 것도 확인된다.

1.2 를 강제하면 이렇게 바뀐다.

>>> TLS 1.2, Handshake [length 00cf], ClientHello
<<< TLS 1.2, Handshake [length 0043], ServerHello
<<< TLS 1.2, Handshake [length 0a06], Certificate          ← 인증서, 아직 평문 구간
<<< TLS 1.2, Handshake [length 0073], ServerKeyExchange    ← 서버의 임시 DH 공개값
<<< TLS 1.2, Handshake [length 0004], ServerHelloDone
>>> TLS 1.2, Handshake [length 0025], ClientKeyExchange    ← 클라의 DH 공개값 (37바이트)
>>> TLS 1.2, ChangeCipherSpec [length 0001]                ← 여기부터 암호화
>>> TLS 1.2, Handshake [length 0010], Finished
<<< TLS 1.2, Handshake [length 00ca], NewSessionTicket     ← 세션 재개용 티켓 (6장)
<<< TLS 1.2, Handshake [length 0010], Finished

Certificate 와 ServerHelloDone 이 평문 구간에 오고, ClientKeyExchange 뒤에야 암호화가 시작(ChangeCipherSpec)된다. 협상이 끝나야 키 재료를 보낼 수 있는 직렬 구조가 그대로 보인다.

그런데 6장 도식에 없는 ServerKeyExchange 가 등장한다. 서버의 임시 DH 공개값, 즉 기본 설정으로 협상하면 1.2 에서도 키 배송이 아니라 ECDHE 가 쓰인다는 뜻이다. 3장의 키 배송(premaster 를 공개키로 암호화)을 재현하려면 RSA 배송 계열 cipher 를 강제해야 한다.

openssl s_client -connect cloudflare.com:443 -tls1_2 -cipher AES128-SHA -msg
>>> TLS 1.2, Handshake [length 0087], ClientHello
<<< TLS 1.2, Handshake [length 003d], ServerHello
<<< TLS 1.2, Handshake [length 0fe7], Certificate
<<< TLS 1.2, Handshake [length 0004], ServerHelloDone      ← ServerKeyExchange 가 사라짐
>>> TLS 1.2, Handshake [length 0106], ClientKeyExchange    ← 암호화된 premaster (262바이트)
>>> TLS 1.2, ChangeCipherSpec [length 0001]
>>> TLS 1.2, Handshake [length 0010], Finished

ClientKeyExchange 길이가 0x0025(37바이트, DH 공개값)에서 0x0106(262바이트)으로 커졌다. RSA-2048 로 암호화된 premaster(256바이트) + 길이 프리픽스, 3장의 키 배송이 이 262바이트 안에 들어 있다. 공개 서버들이 기본 협상에서 이 방식을 버렸다는 사실 자체가, forward secrecy 부재라는 3장의 결론이 실무에 반영된 결과다.

도청자 관점: 패킷 캡처

s_client 는 통신 당사자라서 복호화된 결과를 보여준다. 경로 위의 제3자에게 무엇이 보이는지는 패킷 캡처로 확인해야 한다. 6장에서 정리한 차이, 즉 1.2 는 인증서가 평문으로 노출되고 1.3 은 인증서부터 암호화된다는 것이 실제로 관측되는지 본다.

맥 호스트는 패킷 캡처에 별도 권한(BPF)이 필요해서, 리눅스 컨테이너 안에서 tcpdump 로 캡처하고 tshark 로 열었다. 같은 사이트에 1.2 연결과 1.3 연결을 하나씩 만들어 담았다.

# 캡처된 각 패킷의 요약(Info 컬럼) 출력
tshark -r tls12.pcap -Y tls.handshake -T fields -e _ws.col.Info
tshark -r tls13.pcap -Y tls -T fields -e _ws.col.Info

아래는 두 캡처의 출력을 [1.2]/[1.3] 라벨을 붙여 합친 것이다.

[1.2] Client Hello (SNI=cloudflare.com)
[1.2] Server Hello, Certificate, Server Key Exchange, Server Hello Done    ← 인증서가 평문으로 노출
[1.2] Client Key Exchange, Change Cipher Spec, Encrypted Handshake Message ← 여기부터 암호화
[1.2] New Session Ticket, Change Cipher Spec, Encrypted Handshake Message

[1.3] Client Hello (SNI=cloudflare.com)
[1.3] Server Hello, Change Cipher Spec, Application Data   ← ServerHello 이후 곧장 암호문
[1.3] Change Cipher Spec, Application Data                 ← 인증서·검증 메시지가 이 안에 있음
[1.3] Application Data

1.2 는 Certificate 가 패킷 요약에 이름 그대로 식별된다. 평문이라 인증서 안의 문자열을 뽑아낼 수도 있다.

tshark -r tls12.pcap -Y "tls.handshake.type==11" -T fields -e x509sat.uTF8String -e x509sat.printableString
Google Trust Services,WE1,cloudflare.com,Google Trust Services LLC,GTS Root R4,GlobalSign nv-sa,Root CA,GlobalSign Root CA,...

도메인(cloudflare.com)과 발급 체인(Google Trust Services, GlobalSign)이 도청자에게 그대로 읽힌다. 반면 1.3 은 ServerHello 다음부터 전부 Application Data 로만 보이고, Certificate 로 파싱되는 패킷이 0건이다. tshark 가 못 여는 게 아니라 대칭키 없이는 누구도 열 수 없는 상태다. 6장에서 정리한 "1.3 은 인증서부터 암호화"가 네트워크에서 그대로 관측된 것이다.

왕복 수: curl 타이밍

curl -w 로 연결 단계별 소요 시간을 잴 수 있다. time_connect 가 TCP handshake 완료 시점, time_appconnect 가 TLS 완료 시점이라 두 값의 차이가 TLS handshake 비용이다. 8장의 왕복 수 비교(1.2 는 2 RTT, 1.3 은 1 RTT, QUIC 은 전송 합의와 통합)가 수치로 나타나는지 확인한다.

FMT='TCP: %{time_connect}s  TLS까지: %{time_appconnect}s\n'

curl -so /dev/null -w "$FMT" --tlsv1.2 --tls-max 1.2 https://cloudflare.com
curl -so /dev/null -w "$FMT" --tlsv1.3 https://cloudflare.com
curl -so /dev/null -w "$FMT" --http3-only https://cloudflare.com
# --http3-only 는 h3 지원 빌드 필요 (macOS 기본 curl 은 미지원, brew curl 등으로 실행)

cloudflare.com 에 15회씩 반복한 평균이다. TCP connect 완료가 곧 한 왕복이므로 기준 RTT 는 약 9.5ms.

구성 connect appconnect (모든 합의 완료) 해석
TCP + TLS 1.2 9.8ms 29.5ms ≈ 3 RTT (TCP 1 + TLS 2)
TCP + TLS 1.3 9.2ms 21.2ms ≈ 2 RTT (TCP 1 + TLS 1)
QUIC (h3) - 14.5ms ≈ 1 RTT + 암호 연산

8장의 왕복 수 비교(3/2/1)가 그대로 재현된다. 구성이 하나씩 바뀔 때마다 appconnect 가 대략 한 왕복씩 줄어든다. h3 의 connect 값은 UDP 라서 TCP 의 연결 수립 같은 비교 지점이 없어 제외했고, 모든 준비가 끝나는 appconnect 로만 비교했다. h3 가 1 RTT 이론값(약 9.5ms)보다 조금 큰 것은 QUIC 의 암호 연산이 유저스페이스에서 이뤄지는 비용과 측정 지터로 보인다.

QUIC Initial 안의 ClientHello

8장에서 QUIC 은 첫 패킷에 ClientHello 를 싣는다고 했다. 캡처로 직접 열어볼 수 있다. h3 요청 한 번을 캡처해서 첫 패킷을 tshark 로 연 결과다.

$ tshark -r quic.pcap -Y "quic && tls.handshake.type==1" -T fields -e _ws.col.Info
Initial, DCID=ad67c14365e42d9cf0b7a6c52ed0f5a7, SCID=..., PKN: 0, CRYPTO
(상세 트리 발췌)
QUIC IETF
    Packet Type: Initial (0)                               ← 연결의 첫 패킷
    CRYPTO                                                 ← handshake 메시지를 나르는 frame
        Handshake Protocol: Client Hello                   ← 그 안의 TLS ClientHello
            Extension: server_name (len=19) name=cloudflare.com
            Extension: key_share (len=38) x25519           ← 선발송된 DH 공개값
            Extension: quic_transport_parameters (len=72)  ← 전송 설정 (TCP 라면 SYN 옵션 자리)
                Parameter: max_udp_payload_size (len=4) 65527
                Parameter: initial_max_data (len=4) 1048576
                Parameter: initial_max_streams_bidi (len=2) 100

PKN(패킷 번호)까지 보인다는 건 tshark 가 Initial 의 보호를 벗겨냈다는 뜻이다. 어떻게 도청자가 열 수 있을까? 이 시점에는 아직 합의된 비밀이 없어서 진짜 암호화가 불가능하다. QUIC 은 대신 패킷에 그대로 노출되어 있는 연결 ID(DCID)에서 유도한 키로 Initial 을 감싼다. 키 재료가 패킷 안에 있으니, 도청을 막는 잠금이 아니라 뜯는 방법이 정해져 있는 포장에 가깝다.

포장의 효과는 중간 장비 쪽에 있다. 내용을 읽으려면 QUIC 스펙의 키 유도 절차를 그대로 구현해야 하고, 이 유도에 쓰는 상수는 QUIC 버전마다 바뀐다. 옛 버전 기준으로 구현된 장비는 새 버전의 패킷을 아예 읽지 못하므로, 패킷 내용을 들여다보며 동작하는 장비가 애초에 만들어지기 어렵다. 중간 장비가 프로토콜 내부 형태에 의존하면서 생기는 문제(ossification)를 구조적으로 차단하는 장치다.

열린 ClientHello 안에는 key_share(선발송)와 quic_transport_parameters 확장이 보인다. 초기 흐름 제어 한도(initial_max_data 1MB, 동시 stream 100개)처럼 TCP 라면 SYN 옵션에 있었을 값들이 TLS 확장에 실려 가고 있다. 8장의 "전송 합의가 암호 합의에 실려 간다"를 여기서 직접 확인할 수 있다.

Java 클라이언트 로그

JVM 은 -Djavax.net.debug=ssl:handshake 플래그로 handshake 전 과정을 로그로 출력한다. 스프링 서버가 외부 API 를 호출할 때 이 과정이 실제로 일어나는 것을 서버 로그에서 확인하는 용도로 유용하다.

호출 코드는 JDK 기본 HttpClient 한 파일이면 된다.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class TlsProbe {
    public static void main(String[] args) throws Exception {
        HttpResponse<Void> res = HttpClient.newHttpClient().send(
                HttpRequest.newBuilder(URI.create("https://cloudflare.com/")).GET().build(),
                HttpResponse.BodyHandlers.discarding());
        System.out.println("status=" + res.statusCode());
    }
}
java -Djavax.net.debug=ssl:handshake TlsProbe.java 2>&1 | grep -oE '(Produced|Consuming).*handshake message'
Produced ClientHello handshake message           ← ①
Consuming ServerHello handshake message          ← ②
Consuming EncryptedExtensions handshake message  ← ②
Consuming server Certificate handshake message   ← ②
Consuming CertificateVerify handshake message    ← ②
Consuming server Finished handshake message      ← ②
Produced client Finished handshake message       ← ③

openssl 로 본 것과 같은 흐름이 JVM 로그로 찍힌다(로그 상단에 Negotiated protocol version: TLSv1.3 도 보인다). RestTemplate 이든 WebClient 든 결국 이 handshake 위에서 동작하므로, 외부 API 호출 지연을 조사할 때 이 플래그로 handshake 단계를 직접 확인할 수 있다.

'개발 공부 > CS' 카테고리의 다른 글

HTTP 버전별 동시성 정리 (HOL blocking)  (0) 2026.07.12