크롤링으로 보는 Http

mongBrown·2026년 5월 9일

Socket으로 웹 페이지를 크롤링하는 HTTP 클라이언트를 직접 구현한다면?

OkHttp.get("https://example.com") 한 줄이면 끝난다. 근데 이 한 줄 안에서 실제로 무슨 일이 일어나는 걸까.

HTTP 라이브러리들은 결국 소켓을 대신 열어주는 것이다. 소켓으로 직접 구현한다는 건, 그 라이브러리가 숨겨둔 과정을 손으로 짠다는 뜻이다.


소켓 연결 후 서버에 뭘 보내야 할까?

TCP 소켓으로 www.google.com:80에 연결했다. 연결은 됐는데 서버는 내가 뭘 원하는지 모른다. HTTP는 이 시점에 보내야 할 텍스트 형식을 정해둔 프로토콜이다.

GET / HTTP/1.1\r\n
Host: www.google.com\r\n
Connection: close\r\n
\r\n

첫 줄에 메서드와 경로, HTTP 버전을 쓰고, 헤더를 줄줄이 붙인 뒤, 빈 줄(\r\n\r\n)로 요청이 끝났다고 알린다. 서버는 이 빈 줄을 보고 응답을 돌려준다.

서버 응답도 같은 구조다.

HTTP/1.1 200 OK\r\n
Content-Type: text/html\r\n
Content-Length: 1523\r\n
\r\n
<html>...</html>

상태줄 + 헤더 + 빈 줄 + body. 크롤러는 이 body에서 원하는 데이터를 뽑으면 끝이다.

브라우저였다면 여기서 끝나지 않는다. HTML을 파싱해서 CSS, JS, 이미지 링크를 찾고, 각각에 대해 다시 요청을 보내 화면을 그린다. 크롤러는 그 과정이 필요 없다. HTML 텍스트만 있으면 원하는 데이터를 파싱할 수 있고, 추가 리소스는 아예 요청하지 않는다.


응답이 끝났다는 걸 어떻게 알 수 있을까?

Content-Length: 1523이 헤더에 있으면 간단하다. 1523바이트를 읽으면 응답이 끝난 것이다. TCP 연결이 끊길 때까지 기다릴 필요가 없어서 빠르다.

문제는 서버가 응답 크기를 미리 모를 때다. AI 응답처럼 생성하면서 동시에 내려주거나, 대용량 파일을 압축하면서 전송하는 경우가 여기에 해당한다. 이때는 Chunked Transfer Encoding을 쓴다.

Transfer-Encoding: chunked

7\r\n
Hello, \r\n
6\r\n
world!\r\n
0\r\n          ← 마지막 청크, 크기 0 = 끝
\r\n

청크 크기(16진수)를 먼저 보내고, 데이터를 보내는 방식을 반복한다. 크기가 0인 청크가 오면 응답이 끝난 것이다.

두 헤더도 없다면 TCP 연결 자체가 끊길 때를 응답 종료로 판단한다. 연결 종료는 4-way handshake로 이루어진다.

클라이언트 → FIN   (나 보낼 거 다 보냈어)
서버       → ACK   (알겠어)
서버       → FIN   (나도 보낼 거 다 보냈어)
클라이언트 → ACK   (알겠어)

클라이언트는 마지막 ACK를 보낸 뒤 바로 닫지 않고 TIME_WAIT 상태로 잠시 기다린다. 마지막 ACK가 유실됐을 때 서버가 FIN을 재전송할 수 있도록 여유를 두는 것이다. TIME_WAIT가 끝나면 연결이 완전히 종료된다.


HTTPS 사이트는 어떻게 처리하나?

HTTP는 포트 80으로 연결하면 그냥 텍스트를 주고받으면 됐다. HTTPS는 포트 443으로 연결하고, 데이터를 주고받기 전에 TLS 핸드셰이크를 먼저 해야 한다.

1. 서버가 인증서(공개키 포함)를 보내온다
2. CA 서명을 검증해서 인증서를 신뢰할 수 있는지 확인한다
3. 클라이언트가 대칭키 재료를 서버의 공개키로 암호화해서 보낸다
4. 양쪽이 각자 대칭키를 도출한다
5. 이후 HTTP 요청/응답은 이 대칭키로 암호화된다

소켓으로 직접 구현한다면 Socket 대신 SSLSocket을 쓰면 된다. TLS 핸드셰이크는 자동으로 처리해주고, 이후 write/read는 일반 소켓과 동일하다.

SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
SSLSocket socket = (SSLSocket) factory.createSocket("www.google.com", 443);

매 요청마다 소켓을 새로 열어야 할까?

크롤러는 대부분 한 페이지로 끝나지 않는다. HTML에서 링크를 뽑고, 그 링크를 따라가고, 다시 링크를 뽑는다. 같은 도메인에 수백 개 요청을 보내는 게 일반적이다.

Keep-Alive 없이 구현하면 요청마다 소켓을 새로 열어야 한다. TCP 3-way handshake에 HTTPS면 TLS 핸드셰이크까지, 매번 반복된다. 요청 수백 개면 이 비용이 쌓인다.

Connection: keep-alive
Keep-Alive: timeout=5, max=100

이 헤더를 요청에 포함하면 서버가 연결을 유지해준다. 첫 요청에서 한 번 handshake를 하고, 이후 요청들은 같은 소켓을 재사용한다. timeout 시간 안에, max 횟수 안에서는 소켓을 닫지 않는다.


크롤링 전에 확인해야 할 게 있다 — robots.txt

크롤러가 사이트를 긁기 전에 /robots.txt를 먼저 요청하는 이유가 있다. 사이트 운영자가 크롤링 허용 범위를 이 파일에 선언해두기 때문이다.

User-agent: *
Disallow: /admin
Disallow: /private
Allow: /public

Disallow에 적힌 경로는 크롤링하지 말라는 뜻이다. 기술적으로 막혀있지는 않다 — 무시하고 요청을 보내도 서버는 응답한다. 하지만 이를 어기면 법적 문제로 이어지거나 IP가 차단될 수 있어서, 정상적인 크롤러는 반드시 확인하고 준수한다.

구글봇, 네이버봇 같은 검색엔진 크롤러들이 사이트에 접근할 때마다 /robots.txt를 먼저 가져오는 이유가 여기에 있다.

profile
화이팅!

0개의 댓글