면접에서 이 질문을 받으면 대부분 “DNS 조회하고, TCP 연결하고, HTTP 요청 보내요”로 끝낸다.
틀린 말은 아니다. 하지만 그건 목차일 뿐이다.
프로덕션에서 간헐적 지연을 디버깅할 때, CDN이 API 응답을 캐시해서 다른 사용자 정보가 노출됐을 때, SSE 연결이 60초마다 끊길 때 — 이 흐름의 각 단계를 모르면 원인을 찾을 수 없다.
처음부터 끝까지 뜯어보자. 각 단계가 왜 그렇게 설계됐는지까지.
브라우저가 www.naver.com을 치면 제일 먼저 뭘 하나?
네트워크 패킷을 던지지 않는다.
IP 주소를 모르면 아무것도 할 수 없다. www.naver.com은 사람이 읽는 이름이고, 컴퓨터가 통신하려면 숫자 주소인 IP가 필요하다. 그래서 브라우저는 먼저 IP를 찾는다.
찾는 순서가 있다.
1단계 — 브라우저 자체 DNS 캐시
Chrome은 chrome://net-internals/#dns에서 확인할 수 있다. 이전에 방문했고 TTL이 살아있으면 여기서 끝난다. OS에 묻지도 않는다.
2단계 — HSTS 프리로드 확인
브라우저는 Strict-Transport-Security 헤더를 받은 도메인 목록을 내부적으로 관리한다. naver.com이 이 목록에 있으면 사용자가 http://로 쳐도 브라우저가 스스로 https://로 바꾼다. 서버에 302 리다이렉트 왕복이 없다. Chrome 소스코드에는 수백만 개 도메인이 하드코딩된 프리로드 목록으로 내장되어 있다.
3단계 — OS의 hosts 파일
Linux는 /etc/nsswitch.conf의 hosts: files dns 순서에 따라 /etc/hosts를 먼저 확인한다. 개발할 때 127.0.0.1 my-service.local 같이 쓰는 게 이 파일이다.
4단계 — OS DNS 캐시 → DNS 쿼리
위 모두 미스면 OS가 /etc/resolv.conf의 리졸버(보통 ISP 제공 또는 8.8.8.8)에 DNS 쿼리를 날린다.
이 모든 과정이 엔터를 치는 순간 수 밀리초 안에 일어난다.
DNS가 없었다면 어떻게 됐을까?
1970년대 ARPANET 시절, 호스트 이름은 HOSTS.TXT 파일 하나로 관리했다.
Stanford Research Institute(SRI)가 이 파일을 중앙에서 관리했다. 모든 기관이 FTP로 최신 버전을 받아 썼다.
# 초기 HOSTS.TXT 예시
14.0.0.1 MIT-AI
14.0.0.2 MIT-ML
10.1.0.6 SRI-NIC
10.2.0.8 RAND-UNIX
호스트가 수백 개일 때는 괜찮았다.
1980년대 초, 호스트가 수천 개로 늘어나자 문제가 한꺼번에 터졌다.
- 파일이 수 메가바이트가 됐다. 매일 받아도 이미 낡은 정보였다
- SRI 서버 하나에 수천 기관이 FTP 요청을 보냈다. 서버가 병목이었다
- 기관마다 맘대로 이름을 짓다 보니 같은 이름이 여러 곳에 생겼다
- 새 호스트가 추가되면 전파에 며칠이 걸렸다
지금으로 치면 수십억 개 도메인을 텍스트 파일 하나로 관리하는 것이다. 불가능하다.
그래서 1983년 DNS가 나왔다.
Paul Mockapetris가 설계했다. 핵심 아이디어는 두 가지였다.
분산 데이터베이스 — 계층 구조로 나눠서 각 부분을 해당 조직이 직접 관리한다. naver.com의 IP는 Naver가 관리한다. SRI가 전 세계 레코드를 들고 있지 않는다.
캐싱 — TTL로 유효 기간을 제어하고, 자주 조회되는 결과는 가까운 서버에 캐시한다. 모든 쿼리가 Naver 서버까지 갈 필요가 없다.
지금도 /etc/hosts는 모든 OS에 살아있다. DNS보다 먼저 확인된다. HOSTS.TXT의 흔적이 50년 뒤에도 남아있는 것이다.
DNS 쿼리는 어떻게 흘러가나? 계층 구조가 왜 필요했나?
전 세계 수십억 개 도메인을 한 서버에 담을 수 없다. 계층으로 나눠서 책임을 분산했다.
클라이언트 → 재귀 리졸버 (ISP 제공, 8.8.8.8, 또는 1.1.1.1)
재귀 리졸버가 캐시 미스면:
→ 루트 네임서버 (.)
"com 담당 서버가 어딘지 알아?"
↓
→ .com TLD 네임서버 (Verisign이 운영)
"naver.com 담당 서버가 어딘지 알아?"
↓
→ naver.com 권위 네임서버
"www.naver.com = 223.130.200.107"
↓
재귀 리졸버가 결과를 캐시하고 클라이언트에 응답
각 단계는 UDP 포트 53으로 통신한다. 응답이 512바이트를 초과하거나 DNSSEC이 붙으면 TCP로 전환된다.
루트 네임서버는 전 세계에 13개 클러스터(a.root-servers.net ~ m.root-servers.net)다. “13개밖에 안 되면 하나 죽으면 큰일나는 거 아냐?”라는 걱정은 불필요하다. Anycast로 수백 대 서버가 운영된다. 가장 가까운 물리 서버가 응답한다.
TTL과 배포 전략
naver.com A 레코드 TTL은 보통 60~300초다.
배포할 때 이 TTL이 발목을 잡는다. 서버 IP를 바꾸면 TTL이 살아있는 동안 전 세계 리졸버들이 구버전 IP를 캐시하고 있다. TTL 300초면 최대 5분간 구버전 서버로 트래픽이 간다.
실무 패턴: 배포 1시간 전에 TTL을 60초로 낮춘다. 배포 완료 후 다시 300초로 올린다. 이걸 빠뜨리면 배포 후 30분간 일부 사용자가 구버전 서버에 붙는다.
IP를 얻었다. 이제 연결하면 되는 거 아닌가? TCP 3-way Handshake는 왜 필요한가?
IP는 주소다. 연결이 아니다.
IP 패킷을 상대방에게 보낼 수는 있다. 하지만 상대방이 받았는지 모른다. 네트워크에서 패킷은 유실될 수 있고, 순서가 바뀔 수 있다.
“그냥 보내면 안 되나?”
된다. UDP가 그 방식이다. DNS도 UDP를 쓴다. 단발성 조회라 유실되면 다시 보내면 그만이다.
하지만 HTTP는 다르다. 요청이 갔는지, 응답이 왔는지, 순서가 맞는지를 보장해야 한다. 그래서 연결을 먼저 맺는다.
TCP 3-way Handshake:
Client → Server : SYN (seq=x)
"연결하고 싶어. 내 시작 번호는 x야."
Server → Client : SYN-ACK (seq=y, ack=x+1)
"알았어. 내 시작 번호는 y야. 너의 x는 잘 받았어."
Client → Server : ACK (ack=y+1)
"너의 y도 잘 받았어. 이제 연결됐다."
3번 왕복하는 이유: 서로 “나 준비됐어”를 확인하는 것이다. 2번으로는 서버 준비 상태를 클라이언트가 확인할 수 없다.
이 왕복에 1 RTT가 소모된다. 서울 IDC 간 RTT는 15ms. 미국 서버라면 150200ms다. SYN을 보내고 SYN-ACK를 기다리는 동안 아무것도 못 한다.
SYN 패킷에는 협상 정보가 담긴다.
- MSS(Maximum Segment Size): 한 번에 보낼 수 있는 최대 데이터 크기. MTU(1500바이트)에서 헤더(40바이트)를 뺀 1460바이트가 기본
- 윈도우 스케일: 흐름 제어를 위한 수신 버퍼 크기
- SACK(Selective Acknowledgment): 유실된 패킷만 선택적 재전송 허용
이 협상이 없으면 한쪽이 너무 큰 패킷을 보내다가 단편화(fragmentation)가 발생한다.
연결된 다음엔? TCP가 흐름·혼잡을 어떻게 제어하고, 어떻게 끊나
핸드쉐이크는 시작일 뿐이다. 연결된 뒤 TCP는 두 가지를 동시에 관리한다.
흐름 제어(Flow Control) — 수신자를 배려한다:
수신자의 처리 속도보다 빠르게 보내면 수신 버퍼가 넘쳐 패킷이 버려진다. TCP는 슬라이딩 윈도우로 이를 막는다. 수신자는 ACK마다 “지금 내 수신 버퍼에 이만큼 더 받을 수 있다”는 윈도우 크기(receive window)를 알려주고, 송신자는 그 범위 안에서만 ACK를 기다리지 않고 연속 전송한다. 윈도우가 0이면 송신자는 멈춘다. 흐름 제어는 수신자 능력에 맞추는 것이다.
혼잡 제어(Congestion Control) — 네트워크를 배려한다:
흐름 제어가 수신자를 본다면, 혼잡 제어는 그 사이의 네트워크가 막히지 않게 한다. 송신자는 별도로 혼잡 윈도우(cwnd)를 유지한다.
- Slow Start: 연결 초기엔 cwnd를 작게 시작해 ACK가 올 때마다 지수적으로 키운다. 처음부터 대역폭을 추측하지 않고 더듬어 올라간다.
- AIMD(Additive Increase, Multiplicative Decrease): 임계치(ssthresh)를 넘으면 선형으로 천천히 늘린다. 그러다 패킷 손실(혼잡 신호)이 감지되면 윈도우를 절반 등으로 급격히 줄인다. 천천히 올리고 빠르게 내리는 비대칭이 안정성을 만든다.
실제 송신량은 min(수신 윈도우, 혼잡 윈도우)로 결정된다. 둘 중 작은 쪽이 병목이다.
연결 종료 — 4-way Handshake:
연결은 양방향이라 끊을 때도 양쪽이 각각 닫는다.
Client → Server : FIN "나는 더 보낼 게 없어"
Server → Client : ACK "알았어"
Server → Client : FIN "나도 더 보낼 게 없어" (남은 데이터 다 보낸 뒤)
Client → Server : ACK "알았어, 끝"
서버 쪽 ACK와 FIN이 따로 나뉘는 이유는, ACK를 보낸 시점에도 서버가 아직 보낼 데이터가 남아 있을 수 있기 때문이다(half-close). 그래서 3-way가 아니라 4-way다.
TIME_WAIT는 왜 생기고 왜 필요한가:
마지막 ACK를 보낸 쪽(능동적으로 닫은 쪽)은 곧장 사라지지 않고 TIME_WAIT 상태로 보통 2×MSL(Maximum Segment Lifetime, 통상 수십 초~수 분)을 기다린다. 이유는 두 가지다. 첫째, 마지막 ACK가 유실되면 상대가 FIN을 재전송하는데, 그때 응답해 줄 주체가 남아 있어야 한다. 둘째, 같은 IP·포트 조합으로 새 연결이 곧바로 열렸을 때 이전 연결의 지연 패킷이 섞여 들어오는 것을 막는다. 서버에서 짧은 연결이 폭증하면 TIME_WAIT 소켓이 쌓여 포트가 고갈되기도 하는데, 이것이 Keep-Alive로 연결을 재사용해야 하는 또 다른 이유다.
HTTPS는 어떻게 동작하나? TLS가 TCP 위에서 어떻게 작동하나?
TCP 연결이 완료된 뒤에 TLS 핸드쉐이크가 시작된다.
순서가 중요하다. TCP가 없으면 TLS도 없다. TLS는 신뢰할 수 있는 전송 채널(TCP) 위에서 암호화 채널을 추가로 만든다.
TLS가 없으면 어떤 일이 생기나?
HTTP 요청이 평문으로 날아간다. 카페 와이파이에서 로그인하면 아이디와 패스워드가 그대로 보인다. 중간자(Man-in-the-Middle)가 내용을 바꿀 수도 있다. HTTPS가 없던 시절에는 이런 공격이 실제로 있었다.
TLS 1.2 — 2 RTT:
[TCP 연결 완료 (1 RTT)]
Client → Server : ClientHello (지원 암호 목록, 클라이언트 랜덤값)
Server → Client : ServerHello + Certificate + ServerHelloDone
Client → Server : ClientKeyExchange + ChangeCipherSpec + Finished
Server → Client : ChangeCipherSpec + Finished
총 2 RTT 추가 → TCP 1 RTT + TLS 2 RTT = 3 RTT 뒤에야 데이터 전송 가능
미국 서버 기준 RTT 150ms라면, 연결 수립에만 450ms를 쓴다.
TLS 1.3 (2018) — 1 RTT:
[TCP 연결 완료 (1 RTT)]
Client → Server : ClientHello + Key Share (ECDH 공개키 선제 전송)
Server → Client : ServerHello + Key Share + {Certificate + Finished} (암호화 즉시 시작)
Client → Server : {Finished}
→ 바로 데이터 전송 가능
클라이언트가 첫 메시지에 ECDH 공개키를 미리 실어 보낸다. 서버도 자기 공개키를 응답에 담아 보내면, 두 공개키만으로 같은 비밀키를 도출한다. Diffie-Hellman 키 교환이다. 공개키만 교환했는데 같은 비밀키가 나온다. 수학의 마법이다.
0-RTT — 이전 방문 서버라면:
이전 세션 키(PSK)를 첫 메시지에 담아 보낼 수 있다. 서버가 인식하면 핸드쉐이크 없이 바로 데이터를 수락한다. 클라이언트도 첫 메시지에 HTTP 요청을 실어 보낸다.
단, 0-RTT 데이터에는 치명적 약점이 있다 — 재전송 공격(Replay Attack).
공격자가 0-RTT 패킷을 가로채서 서버에 다시 보낼 수 있다. GET /home은 괜찮다. POST /api/payment는 재앙이다. 0-RTT는 멱등 요청(GET, HEAD)에만 써야 한다.
TLS 1.3에서 사라진 것들:
- RSA 키 교환 (서버 개인키 탈취 시 과거 세션 전부 복호화 가능)
- SHA-1, MD5 기반 암호 스위트
- TLS 1.0, 1.1 (이미 폐기됨)
서비스에 TLS 1.2 설정이 있다면 그대로 두되, TLS 1.0/1.1은 반드시 비활성화해야 한다.
HTTP/2가 HTTP/1.1보다 왜 빠른가? 브라우저가 연결을 6개씩 열었던 이유
TLS 핸드쉐이크에서 ALPN(Application-Layer Protocol Negotiation) 확장으로 h2에 합의되면 HTTP/2가 시작된다.
HTTP/1.1의 근본적인 문제 — 직렬 요청:
HTTP/1.1은 요청이 직렬이다. 하나를 보내고 응답이 와야 다음을 보낸다.
웹 페이지 하나에 CSS 10개, JS 5개, 이미지 30개 — 45개 리소스가 필요하다면, 순서대로 받으면 수 초가 걸린다.
브라우저의 편법:
같은 도메인에 TCP 연결을 최대 6개 병렬로 열었다. 6개씩 나눠 받는다. 45개를 6개씩 나누면 8라운드다. 여전히 느리다.
더 나쁜 건 CDN들이 이를 우회하려고 static1.example.com, static2.example.com 같은 도메인 샤딩을 했다는 것이다. 도메인마다 6개 연결 제한이 있으니 도메인을 여러 개 쓰면 12개, 18개를 열 수 있었다. 눈속임이었다.
HTTP/2: 멀티플렉싱
하나의 TCP 연결 위에 여러 스트림을 동시에 운반한다. 각 스트림은 독립적인 요청-응답 쌍이고, 프레임 단위로 쪼개져 인터리빙된다.
TCP 연결 하나
├── Stream 1: GET / (HTML — 우선순위 HIGH)
├── Stream 3: GET /css/main.css (HIGH)
├── Stream 5: GET /js/bundle.js (MEDIUM)
└── Stream 7: GET /images/logo.png (LOW)
Stream 1이 느려도 3, 5, 7은 독립적으로 처리된다.
[WHY] 왜 멀티플렉싱은 “바이너리 프레이밍”이라야 가능한가:
HTTP/1.1은 텍스트 기반 프로토콜이다. 메시지의 끝을 \r\n 빈 줄이나 Content-Length, chunked 인코딩으로 추정하는데, 이 경계가 본질적으로 모호하다. 그래서 한 연결에 여러 요청을 미리 끼워 넣는 파이프라이닝을 시도하면, 응답들이 반드시 보낸 순서대로 와야 하고(HOL 블로킹) 어디서 한 응답이 끝나고 다음이 시작되는지 파싱이 흔들린다. 결국 대부분의 브라우저가 파이프라이닝을 끈 이유다.
HTTP/2는 모든 것을 바이너리 프레임으로 쪼갠다. 각 프레임은 길이·타입·스트림 ID를 헤더에 명시한다. 스트림 ID라는 꼬리표 덕분에, 서로 다른 스트림의 프레임을 하나의 TCP 연결에 마음대로 뒤섞어(인터리빙) 보내도 수신 측이 ID로 다시 분류해 올바른 요청-응답에 재조립할 수 있다. 즉 경계가 명시적이라 섞어도 안전하다 — 이것이 멀티플렉싱의 근본 전제다. 텍스트 경계의 모호함을 길이가 정해진 바이너리 프레임으로 없앤 것이 핵심 변화다.
HPACK 헤더 압축:
HTTP/1.1은 매 요청마다 헤더를 전부 평문으로 보낸다. User-Agent, Cookie, Accept-Encoding — 요청마다 수백 바이트. 100개 요청이면 수십 KB가 헤더로만 날아간다.
HPACK은 자주 쓰는 헤더(61개 정적 테이블)를 인덱스 번호 하나로 대체한다. 동적 테이블로 세션 중에 추가된 헤더도 캐시한다. 반복 요청에서 헤더 오버헤드가 90% 줄기도 한다.
HTTP/3 (QUIC) — HTTP/2도 해결 못 한 문제:
HTTP/2도 TCP 위에 있다. TCP에는 HoL Blocking이 있다. 패킷 하나가 유실되면 그 뒤의 모든 스트림이 기다린다. 멀티플렉싱을 해도 TCP 레벨에서 막힌다.
HTTP/3는 UDP 기반 QUIC 프로토콜을 쓴다. 스트림별로 독립 재전송을 구현해서 진정한 HoL Blocking 해소를 이뤘다.
서버에서는 어떻게 처리되나? L4/L7 LB → WAS까지의 흐름
클라이언트 요청이 AWS 인프라에 닿으면 여러 레이어를 통과한다.
인터넷
↓
NLB (L4)
TCP/IP 레벨만 본다. 패킷 내용은 모른다.
처리량 엄청남, 레이턴시 마이크로초 단위.
↓
ALB (L7)
HTTP 헤더, URI, 쿠키까지 파싱한다.
TLS 종료. Spring Boot로는 HTTP만 보낸다.
/api/* → WAS 서버
/admin/* → 관리 서버
↓
Spring Boot WAS (Private Subnet)
↓
MySQL / Redis / Kafka / 외부 API
NLB는 언제 필요한가?
HTTP를 파싱할 필요가 없는 상황 — TCP 레벨 로드 밸런싱, 고성능이 필요한 게임 서버, gRPC. 패킷 내용을 열어보지 않으니 빠르다. 하지만 경로 기반 라우팅, 쿠키 세션 고정은 불가능하다.
ALB에서 TLS를 종료하면:
Spring Boot는 HTTP만 받으면 된다. SSL 설정, 인증서 관리가 Spring Boot에서 사라진다. 인증서 갱신도 ALB에서만 한다.
Spring Boot가 반드시 확인해야 할 헤더들:
# application.yml
server:
forward-headers-strategy: framework
이 설정 없이 IP 기반 Rate Limiting을 하면 — ALB IP를 기준으로 제한한다. 모든 클라이언트가 같은 IP로 보이는 것이다. Rate Limiting이 전혀 작동하지 않는다.
X-Forwarded-For: 클라이언트 실제 IP (ALB가 추가)
X-Forwarded-Proto: https (원래 프로토콜)
X-Forwarded-For는 프록시를 거칠 때마다 “직전에 연결해 온 IP”가 뒤로 append되는 헤더다. 형식은 client, proxy1, proxy2…로, 가장 왼쪽이 원 클라이언트라고 주장되는 값이지만 이 값은 클라이언트가 위조할 수 있다. 그래서 실제 클라이언트 IP가 목록의 어디에 있는지는 신뢰 경계에 달려 있다 — 내가 신뢰하는 가장 바깥쪽 프록시가 어디까지냐를 기준으로, 그 프록시가 본 IP를 클라이언트로 삼는다. ALB만 거치는 단순 구조라면 ALB가 마지막에 붙인 값이 실제 클라이언트 IP지만, 앞단에 다른 프록시가 더 있으면 위치가 달라진다(아래 다단 프록시 참고).
정적 파일은 서버까지 안 간다는데, CDN이 어떻게 동작하나?
CDN 없이 서비스를 운영하면 어떻게 될까?
모든 요청이 Origin 서버로 온다. 서울 사용자는 RTT가 낮으니 괜찮다. 하지만 미국 사용자가 서울 서버에서 이미지를 받으면 RTT가 150ms다. 이미지 하나 받는 데 왕복이 여러 번 필요하다. 체감 로딩이 수 초가 된다.
게다가 모든 정적 파일 트래픽이 Origin 서버로 쏟아진다. 서버 비용이 폭발한다.
CDN 동작 원리:
브라우저 → DNS: ssl.pstatic.net이 어디야?
Anycast로 가장 가까운 CDN 엣지 노드로 라우팅
엣지 노드:
캐시 HIT → 즉시 응답. Origin 서버는 모른다
캐시 MISS → Origin에서 가져와 캐시 저장 → 응답
서울 엣지가 서울 사용자에게 응답하면 RTT 5ms 이하. 미국 엣지가 미국 사용자에게 응답하면 RTT 10ms 이하. Origin까지 왕복할 필요가 없다.
[WHY] 왜 BGP(IP 라우팅) 대신 주로 DNS와 Anycast로 가까운 엣지에 보내나:
사용자를 가장 가까운 엣지로 보내는 두 축은 DNS 기반 지리적 라우팅과 Anycast다. 순수 IP 라우팅(BGP)만으로는 “이 사용자가 지금 지구상 어디에 있는지”를 세밀하게 보고 특정 엣지로 보내기 어렵다. BGP는 AS(자율 시스템) 단위의 경로 정책으로 도달성을 정할 뿐, 사용자 위치·서버 부하·헬스 상태 같은 정책을 태우기 까다롭다. 그래서 CDN은 DNS 응답 단계에서 결정한다 — 리졸버의 위치(또는 EDNS Client Subnet)를 보고 사용자에 가까운 엣지의 IP를 골라 돌려준다. 이 방식은 서버 부하·장애에 따라 응답 IP를 즉시 바꿀 수 있어 유연하다. 여기에 Anycast를 더한다. 같은 IP를 여러 지역의 엣지가 동시에 광고(BGP)하면, 라우팅이 자연스럽게 네트워크상 가장 가까운 엣지로 패킷을 보낸다. 결국 BGP를 안 쓰는 게 아니라, 지리적 정밀도와 정책 제어는 DNS가, 네트워크 근접성과 장애 흡수는 Anycast가 담당하도록 둘을 조합한다.
Cache Busting — 파일 이름에 해시를 넣는다:
main.a3b9c2d1.js 처럼 파일명에 콘텐츠 해시를 넣으면, 파일 내용이 바뀔 때 URL이 바뀐다. 이전 URL의 캐시는 무효화할 필요가 없다. 새 URL이니까. Cache-Control: max-age=31536000, immutable로 1년 캐시를 설정해도 된다.
운영 주의 — CDN이 API 응답을 캐시하는 사고:
Cache-Control 헤더를 명시하지 않으면 CDN이 기본 정책으로 캐시할 수 있다. /api/me 응답이 CDN에 캐시되면 다른 사용자에게 다른 사람의 정보가 반환된다. 실제로 발생하는 사고다.
API 응답엔 반드시 Cache-Control: no-store 또는 Cache-Control: private을 붙여라.
Cache Stampede — 캐시가 동시에 만료되면:
캐시가 만료되는 순간 수천 개 요청이 Origin으로 몰린다. 대형 서비스는 배포 전에 CDN에 파일을 미리 올려두거나(Pre-warming), stale-while-revalidate로 대응한다.
브라우저가 HTML을 받으면 화면을 어떻게 그리나?
HTML이 도착하기 시작하면 브라우저는 다 받을 때까지 기다리지 않는다. 스트리밍으로 파싱한다.
HTML 파싱 → DOM 트리
CSS 파싱 → CSSOM 트리
↓ 합쳐서
Render Tree (보이는 노드만)
↓
Layout / Reflow (위치·크기 계산)
↓
Paint (픽셀 그리기)
↓
Composite (레이어 합성, GPU 가속)
<script> 태그의 저주 — 파싱이 멈춘다:
HTML 파서가 <script> 태그를 만나면 파싱을 중단한다. JS가 document.write()로 DOM을 바꿀 수 있기 때문이다. 파서 입장에서 “이 스크립트가 실행되면 지금까지 파싱한 게 바뀔 수 있다” — 그래서 기다린다.
해결 방법:
defer: 파싱 완료 후 실행. 선언 순서 보장async: 다운로드 완료 즉시 실행. 순서 보장 없음<script>태그를</body>바로 앞에 배치
Reflow 비용 — 성능 킬러:
JS에서 element.offsetWidth를 읽으면 브라우저가 강제로 Layout을 재계산한다. 루프 안에서 DOM 읽기-쓰기를 번갈아 하면 매 반복마다 Reflow가 발생해 성능이 폭락한다.
읽기를 먼저 모두 하고, 쓰기를 한 번에 처리하는 패턴이 필요하다.
Core Web Vitals — Google 검색 순위에 반영된다:
- LCP(Largest Contentful Paint): 가장 큰 콘텐츠가 그려지는 시점. 2.5초 이내 목표
- INP(Interaction to Next Paint): 사용자 상호작용 반응 속도
- CLS(Cumulative Layout Shift): 레이아웃이 얼마나 흔들렸는가
백엔드 개발자도 알아야 하는 지표가 됐다.
이 전체 흐름에서 내 Spring Boot 서비스는 어디에 위치하는가?
WAS 자리다.
그 앞에: DNS → TCP → TLS → CDN → LB 그 뒤에: DB → Cache → 메시지 브로커 → 외부 API
내가 컨트롤할 수 있는 건 WAS뿐이다. 하지만 장애는 어느 레이어에서나 온다. 이 흐름을 이해하지 못하면 장애 원인을 찾을 수 없다.
실제로 만난 시나리오들:
DNS TTL을 낮추지 않고 배포했다
배포 후 30분간 일부 사용자가 구버전 서버에 연결됐다. DNS TTL 300초가 남아있었다. 배포 전 TTL 단축이 절차에 없었던 것이다.
TLS 인증서 만료 알림을 놓쳤다
Let’s Encrypt 자동 갱신이 실패했는데 아무도 몰랐다. 만료 30일 전 알람을 CloudWatch Alarms에 등록해야 한다.
# 인증서 만료일 확인
echo | openssl s_client -connect api.example.com:443 2>/dev/null \
| openssl x509 -noout -dates
X-Forwarded-For 처리를 안 했더니 Rate Limiting이 ALB IP 기준으로 동작했다
server.forward-headers-strategy: framework 미설정 상태. IP 기반 제한이 전혀 작동하지 않았다. 같은 ALB에서 오는 모든 요청이 같은 IP로 보였다.
SSE 연결이 60초마다 끊겼다
ALB Idle Timeout 기본값이 60초다. SSE는 최소 75초, 실제로는 3600초로 늘려야 한다.
CDN이 /api/user 응답을 캐시했다
Cache-Control 헤더를 붙이지 않았다. CDN 기본 정책으로 캐시되어 다른 사용자에게 다른 사람의 정보가 반환됐다.
최적화
| 레이어 | 포인트 | 효과 |
|---|---|---|
| DNS | 배포 전 TTL 60초로 단축, DoH 도입 여부 검토 | 빠른 IP 변경 전파 |
| TCP | TCP Fast Open, Keep-Alive 연결 재사용 | 1 RTT 절감 |
| TLS | TLS 1.3 전환, OCSP Stapling, Session Resumption, 0-RTT (GET 한정) | 핸드쉐이크 비용 절감 |
| HTTP | HTTP/2 멀티플렉싱, HPACK 압축, HTTP/3 검토 | HoL Blocking 제거, 헤더 오버헤드 절감 |
| CDN | Cache Busting, immutable 캐시, API는 no-store | Origin 트래픽 절감, 캐시 오염 방지 |
| WAS | Graceful Shutdown, 커넥션 풀 튜닝, Health Check 경량화 | 배포 중 요청 손실 방지 |
튜닝
ALB Idle Timeout — SSE 서비스:
// SseEmitter timeout = ALB timeout보다 짧게
// ALB를 3600초로 설정했다면 SseEmitter는 3000초
SseEmitter emitter = new SseEmitter(3_000_000L); // 50분
ALB 콘솔 → Load Balancers → Attributes → Idle timeout을 3600초로 변경. Spring Boot SseEmitter timeout은 ALB보다 짧게.
Connection Draining — 배포 중 요청 손실 방지:
# application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
ALB Deregistration delay (기본 300초)와 함께 설정해야 배포 중 진행 중인 요청이 끊기지 않는다.
X-Forwarded-For 신뢰 범위:
WAF → CloudFront → ALB → Spring Boot 구조에서는 각 단을 지날 때마다 X-Forwarded-For에 직전 홉의 IP가 append되어 값이 여러 개 쌓인다. 이때 “맨 마지막 값”이 무조건 실제 클라이언트 IP인 것은 아니다 — 마지막 값은 ALB가 본 직전 홉(CloudFront)일 수 있다. 정답은 신뢰 경계에서 역산하는 것이다. 내가 신뢰하는 프록시 체인(CloudFront·ALB)을 목록 오른쪽에서부터 제거하고, 그 바로 앞에 남는 값을 클라이언트 IP로 본다. 클라이언트가 임의로 채워 넣을 수 있는 가장 왼쪽 값을 그대로 신뢰하면 안 된다. (단일 ALB 구조라면 마지막 값이 곧 클라이언트 IP다.)
운영상 주의사항
TLS 인증서 만료 모니터링
자동 갱신이 있어도 갱신 실패 케이스가 있다. 만료 30일 전에 알람을 걸어두고, 실제로 갱신됐는지 주기적으로 확인한다.
DNS 전파 지연 대응
DNS 변경 후 전파 완료 전에 새 IP와 구 IP가 혼재하는 기간이 있다. 이 기간 동안 구버전 서버도 살아있어야 한다. Blue-Green 배포의 DNS 전환은 TTL이 충분히 짧아진 후에 시작해야 한다.
CDN 캐시 오염 방지
Vary: Accept-Encoding 헤더를 빠뜨리면 CDN이 gzip 응답과 비압축 응답을 같은 캐시로 혼동한다. gzip 미지원 클라이언트에게 gzip 응답이 반환되는 사고가 생긴다.
HTTP/2 Server Push 비활성화
Server Push는 브라우저 캐시를 확인하지 않고 리소스를 밀어 넣는다. 이미 캐시된 파일을 중복 전송해 오히려 대역폭을 낭비한다. 대신 Link: </css/main.css>; rel=preload 힌트를 주거나 HTTP/103 Early Hints를 검토한다.
Health Check 경량화
ALB가 /actuator/health를 1초마다 호출하면 WAS에 부담이 생긴다. Health Check 전용 lightweight 엔드포인트를 분리하거나, 간격을 늘리는 것을 검토한다.