[Plus] Let’s Encrypt 인증서를 적용하면 HTTPS는 어떻게 동작할까?
1. HTTPS를 적용하며 생긴 궁금증
프로젝트에서 프론트엔드는 Vercel에 배포하고, Spring Boot 백엔드는 AWS EC2에 배포했다. AWS 서버에는 Nginx를 설치하고 Let’s Encrypt에서 인증서를 발급받아 HTTPS를 적용했다. HTTPS의 동작 과정을 공부하다 보니 다음과 같은 궁금증이 생겼다.
TLS Handshake 과정에서 클라이언트가 지원하는 TLS 버전과 암호화 알고리즘을 서버에 전달한다는데, 프론트엔드에서는 이런 정보를 설정한 적이 없다. 그렇다면 클라이언트는 이를 어떻게 알고 전달하는 것일까?
결론부터 말하면 프론트엔드 코드가 TLS 버전과 암호화 알고리즘을 직접 설정하는 것이 아니다.
https:// 주소로 요청하면 브라우저와 Nginx가 애플리케이션 코드 아래의 네트워크 계층에서 TLS Handshake를 자동으로 수행한다.
2. SSL과 TLS
1. SSL이란?
SSL(Secure Sockets Layer)은 네트워크에서 데이터를 안전하게 전송하기 위해 만들어진 암호화 프로토콜이다.
하지만 SSL에는 여러 보안 취약점이 발견되어 현재는 더 이상 사용하지 않는다. SSL을 발전시킨 프로토콜이 TLS(Transport Layer Security)다.
현재 HTTPS 통신에서는 TLS를 사용하지만, 관습적으로 다음과 같은 표현이 여전히 사용된다.
- SSL 인증서
- SSL 적용
- SSL 연결
정확하게 표현하면 SSL 인증서보다는 TLS 인증서 또는 SSL/TLS 인증서라고 하는 것이 적절하다.
2. HTTPS와 TLS의 관계
HTTP는 클라이언트와 서버가 요청과 응답을 주고받기 위한 프로토콜이다. 그러나 HTTP 자체에는 데이터를 암호화하는 기능이 없다.
HTTPS는 HTTP 통신에 TLS를 적용한 방식이다.
HTTPS = HTTP + TLS
3. Let’s Encrypt란?
Let’s Encrypt는 웹사이트에 HTTPS를 적용할 수 있도록 무료로 인증서를 발급하는 인증기관(CA, Certificate Authority)이다.
Let’s Encrypt에서 인증서를 발급받으면 일반적으로 다음과 같은 파일이 생성된다.
/etc/letsencrypt/live/api.example.com/fullchain.pem
/etc/letsencrypt/live/api.example.com/privkey.pem
fullchain.pem
서버 인증서와 이를 검증하는 데 필요한 중간 인증서 체인이 포함된 파일이다.
서버는 TLS Handshake 과정에서 이 인증서 체인을 클라이언트에게 전달한다.
privkey.pem
인증서의 공개키와 한 쌍을 이루는 서버의 개인키다.
개인키는 서버가 인증서의 실제 소유자임을 증명할 때 사용된다. 외부에 공개되면 안 되며 서버에서 안전하게 보관해야 한다.
4. 인증서 발급과 TLS 설정의 차이
Let’s Encrypt에서 인증서를 발급받는 것과 실제로 TLS 통신을 제공하는 것은 완전히 같은 작업은 아니다.
인증서 발급
서버가 자신의 신원을 증명할 수 있는 인증서와 개인키를 준비하는 과정이다.
TLS 설정
Nginx와 같은 웹 서버가 발급받은 인증서와 개인키를 사용하여 443 포트에서 HTTPS 연결을 처리하도록 설정하는 과정이다.
예를 들어 다음과 같이 Nginx에 인증서를 연결해야 한다.
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate
/etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key
/etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://spring-app:8080;
}
}
따라서 다음 과정까지 완료했다면 TLS를 설정했다고 볼 수 있다.
Let’s Encrypt 인증서 발급
↓
Nginx에 인증서와 개인키 연결
↓
443 포트에서 HTTPS 요청 수신
↓
TLS Handshake 및 암호화 통신 수행
즉, 프로젝트에서 Let’s Encrypt 인증서를 발급받아 Nginx에 적용했다면 HTTPS를 위한 TLS 설정을 한 것이다.
5. 프로젝트의 HTTPS 통신 구조
프로젝트의 배포 구조는 다음과 같다.

사용자가 Vercel에 접속하면 브라우저는 HTML, CSS, JavaScript 등의 프론트엔드 파일을 내려받는다.
브라우저 ← HTTPS → Vercel
브라우저에서 실행된 JavaScript가 AWS 백엔드 API를 호출하면 새로운 HTTPS 연결이 만들어진다.
브라우저 ← HTTPS → AWS Nginx
따라서 Vercel과 연결된 HTTPS 통신과 AWS 백엔드로 향하는 HTTPS 통신은 서로 다른 연결이다.
각 서버는 자신의 도메인에 해당하는 인증서를 사용한다.
6. 실제 요청을 보내는 주체
React에서 다음과 같이 API 요청을 작성했다고 가정해보자.
fetch("https://api.example.com/api/orders");
코드는 React에 작성되어 있지만, Vercel에 저장된 React 코드가 직접 요청을 보내는 것은 아니다.
일반적인 클라이언트 사이드 렌더링 구조에서는 다음 과정으로 실행된다.
- 브라우저가 Vercel에서 JavaScript 파일을 내려받는다.
- 내려받은 JavaScript가 사용자의 브라우저에서 실행된다.
- 브라우저가 AWS 백엔드 API에 요청을 보낸다.
- 브라우저와 AWS의 Nginx가 TLS Handshake를 수행한다.
따라서 이 경우 TLS 연결의 클라이언트는 Vercel이 아니라 사용자의 브라우저다.
Vercel
│ JavaScript 파일 전달
▼
사용자 브라우저
│ fetch() 실행
▼
AWS Nginx
다만 Vercel의 서버리스 함수나 서버 사이드 렌더링 코드에서 AWS API를 호출한다면 요청 주체는 브라우저가 아니라 Vercel 서버가 된다.
API 호출 위치TLS 연결의 클라이언트
| API 호출 위치 | TLS 연결의 클라이언트 |
| 브라우저에서 실행되는 React 코드 | 사용자 브라우저 |
| Vercel 서버리스 함수 | Vercel 서버 |
| 서버 사이드 렌더링 코드 | Vercel 서버 |
| 모바일 애플리케이션 | 모바일 애플리케이션의 네트워크 라이브러리 |
누가 클라이언트가 되더라도 애플리케이션 코드가 직접 TLS Handshake를 구현하지는 않는다. 브라우저, 운영체제 또는 네트워크 라이브러리가 이를 처리한다.
7. 브라우저는 TLS 정보를 어떻게 아는가?
Chrome, Edge, Safari 등의 브라우저에는 TLS 프로토콜이 이미 구현되어 있다.
따라서 개발자가 React 코드에 TLS 버전이나 암호화 알고리즘을 작성하지 않아도 브라우저는 자신이 지원하는 정보를 알고 있다.
브라우저가 다음 주소로 요청을 보낸다고 가정해보자.
https://api.example.com/api/orders
URL이 https://로 시작하기 때문에 브라우저는 HTTP 요청을 바로 보내지 않고 먼저 해당 서버와 TLS 연결을 수립한다.
브라우저는 ClientHello 메시지를 통해 자신이 지원하는 정보를 전달한다.
브라우저 → Nginx
- 지원하는 TLS 버전
- 지원하는 암호화 알고리즘
- 키 합의에 필요한 공개 정보
- 임의의 값
- 접속하려는 도메인 등의 확장 정보
Nginx도 자신이 지원하도록 설정된 TLS 버전과 암호화 알고리즘을 알고 있다.
ssl_protocols TLSv1.2 TLSv1.3;
서버는 클라이언트와 자신이 공통으로 지원하는 방식 중 하나를 선택하여 응답한다.
브라우저가 지원하는 버전: TLS 1.2, TLS 1.3
서버가 지원하는 버전: TLS 1.2, TLS 1.3
→ TLS 1.3 선택
클라이언트와 서버가 공통으로 지원하는 TLS 버전이나 알고리즘이 없다면 연결은 성립하지 않는다.
8. TLS Handshake 과정
TLS Handshake는 클라이언트와 서버가 실제 데이터를 암호화하기 전에 다음 내용을 결정하는 과정이다.
- 어떤 TLS 버전을 사용할 것인가?
- 어떤 암호화 방식을 사용할 것인가?
- 접속한 서버를 신뢰할 수 있는가?
- 실제 데이터를 암호화할 대칭키를 어떻게 만들 것인가?
전체 과정은 다음과 같다.
1단계: 클라이언트가 ClientHello를 보낸다
브라우저가 지원하는 TLS 버전과 암호화 알고리즘 등의 정보를 전달한다.
브라우저 → Nginx
ClientHello
- 지원 TLS 버전
- 암호화 알고리즘 목록
- 키 합의에 필요한 정보
2단계: 서버가 사용할 방식을 선택한다
Nginx는 브라우저와 공통으로 지원하는 TLS 버전 및 암호화 방식을 선택한다.
그리고 선택 결과와 함께 서버 인증서를 전달한다.
Nginx → 브라우저
ServerHello
- 선택한 TLS 버전
- 선택한 암호화 알고리즘
- Let’s Encrypt 서버 인증서
- 키 합의에 필요한 정보
3단계: 브라우저가 인증서를 검증한다
브라우저는 서버로부터 받은 인증서를 확인한다.
주요 검증 항목은 다음과 같다.
- 신뢰할 수 있는 인증기관이 발급했는가?
- 인증서의 전자서명이 올바른가?
- 접속한 도메인과 인증서의 도메인이 일치하는가?
- 인증서의 유효 기간이 지나지 않았는가?
브라우저와 운영체제에는 신뢰할 수 있는 최상위 인증기관의 인증서 목록이 저장되어 있다.
브라우저는 Let’s Encrypt 인증서의 서명과 인증서 체인을 검증하여 서버의 신원을 확인한다.
4단계: 서버가 개인키의 소유를 증명한다
서버는 인증서만 전달하는 것이 아니라 핸드셰이크 정보에 전자서명을 생성한다.
브라우저는 인증서에 포함된 공개키로 서명을 검증한다. 이를 통해 서버가 인증서와 대응되는 개인키를 실제로 보유하고 있다는 것을 확인한다.
서버의 개인키가 브라우저로 전송되는 것은 아니다.
5단계: 세션 키를 생성한다
현대 TLS에서는 주로 ECDHE와 같은 키 합의 방식을 사용한다.
클라이언트와 서버는 각자의 비밀값을 직접 전송하지 않고 공개 정보만 교환한다. 이후 양쪽이 각자의 비밀값과 상대방의 공개 정보를 이용하여 동일한 공유 비밀을 계산한다.
이 공유 비밀로부터 실제 통신에 사용할 대칭키를 생성한다.
브라우저의 비밀값 + 서버의 공개 정보
↓
공유 비밀
서버의 비밀값 + 브라우저의 공개 정보
↓
동일한 공유 비밀
6단계: 암호화된 HTTP 데이터를 전송한다
TLS Handshake가 완료되면 브라우저와 Nginx는 생성한 대칭키를 사용하여 실제 HTTP 요청과 응답을 암호화한다.
HTTP 요청
↓ 대칭키로 암호화
암호화된 데이터
↓ 네트워크 전송
대칭키로 복호화
↓
HTTP 요청 처리
9. 대칭키와 비대칭키의 역할
TLS에서는 대칭키와 비대칭키 기술을 함께 사용한다.
비대칭키
공개키와 개인키라는 서로 다른 키를 사용한다.
TLS에서는 주로 다음 역할을 수행한다.
- 서버의 신원 인증
- 전자서명 생성 및 검증
- 안전한 키 합의
비대칭키 연산은 대칭키 연산보다 상대적으로 비용이 크기 때문에 모든 HTTP 데이터를 비대칭키로 암호화하지 않는다.
대칭키
암호화와 복호화에 같은 키를 사용한다.
TLS Handshake를 통해 안전하게 대칭키를 생성한 이후 실제 HTTP 데이터는 대칭키로 암호화한다.
대칭키 암호화는 처리 속도가 빠르기 때문에 많은 데이터를 주고받는 데 적합하다.
| 방식 | TLS에서의 주요 역할 |
| 비대칭키 기술 | 서버 인증, 전자서명, 키 합의 |
| 대칭키 | 실제 HTTP 요청과 응답 암호화 |
따라서 HTTPS의 동작을 단순하게 정리하면 다음과 같다.
비대칭키 기술을 이용해 서버의 신원을 확인하고 안전하게 키를 합의한 뒤, 실제 데이터는 빠른 대칭키로 암호화하여 전송한다.
10. Nginx의 TLS Termination
프로젝트에서는 Nginx가 외부 HTTPS 요청을 받고 내부 Spring Boot 애플리케이션으로 요청을 전달한다.
브라우저
│ 암호화된 HTTPS 요청
▼
Nginx
│ TLS 복호화
│ HTTP 요청으로 프록시
▼
Spring Boot
이처럼 Nginx가 TLS 연결과 암·복호화를 담당하고 애플리케이션에는 복호화된 요청을 전달하는 구조를 TLS Termination이라고 한다.
Nginx는 다음 작업을 담당한다.
- Let’s Encrypt 인증서 전달
- TLS Handshake 수행
- 서버 개인키를 이용한 전자서명
- 암호화된 요청 복호화
- 응답 암호화
- Spring Boot로 요청 전달
Spring Boot는 TLS 처리에 직접 관여하지 않고 일반 HTTP 요청을 처리할 수 있다.
다만 Nginx와 Spring Boot 사이가 HTTP라면 해당 내부 구간은 암호화되지 않는다. 두 애플리케이션이 같은 서버나 신뢰할 수 있는 사설 네트워크에 있을 때 자주 사용하는 구조다.
내부 구간까지 보호해야 하는 환경이라면 Nginx와 Spring Boot 사이에도 HTTPS 또는 mTLS를 적용할 수 있다.
11. HTTPS와 CORS는 다른 문제다
Vercel 프론트엔드에서 AWS 백엔드를 호출할 때 CORS 설정이 필요할 수 있다.
프론트엔드: https://frontend.vercel.app
백엔드: https://api.example.com
두 주소는 출처가 다르기 때문에 브라우저의 동일 출처 정책이 적용된다. 이 경우 백엔드에서 프론트엔드 출처를 허용해야 한다.
configuration.setAllowedOrigins(
List.of("https://frontend.vercel.app")
);
하지만 CORS와 TLS는 서로 다른 목적을 가진다.
| 구분 | 목적 |
| TLS | 통신 암호화, 무결성 확인, 서버 인증 |
| CORS | 다른 출처의 웹페이지가 API에 접근할 수 있는지 제어 |
12. 전체 과정 정리
사용자가 Vercel에 배포된 프론트엔드를 통해 AWS 백엔드 API를 호출하는 과정은 다음과 같다.
1. 브라우저가 Vercel에 HTTPS로 접속
2. Vercel에서 HTML, CSS, JavaScript 다운로드
3. 브라우저에서 JavaScript 실행
4. fetch()를 통해 AWS의 HTTPS API 호출
5. 브라우저가 Nginx에 ClientHello 전달
6. Nginx가 TLS 버전과 암호화 방식 선택
7. Nginx가 Let’s Encrypt 인증서 전달
8. 브라우저가 인증서와 서버의 서명 검증
9. 브라우저와 Nginx가 세션 키 생성
10. 세션 키로 HTTP 요청 암호화
11. Nginx가 요청을 복호화
12. Nginx가 Spring Boot로 요청 전달
13. 응답도 반대 방향으로 암호화하여 전달