이번 포스팅은 브라우저에 URL을 입력했을 때, 일어나는 일 중, HTTP로 요청을 보내고, 그 결과를 서버로부터 받는 http 프로토콜의 작동을 프로토타입으로 구현해보는 것에 대한 내용입니다. 그 전에 앞서 네트워크에 대한 간단한 내용을 소개하고, HTTP란 무엇인지에 대해 살펴보겠습니다.
먼저, HTTP가 무엇인지에 대해 알아보기에 앞서, HTTP를 '왜' 알아야하는지 알아야겠죠? HTTP가 네트워크에서 어느 부분에 사용되고, 실생활과는 어떤 연관성이 있는지 궁금할 것입니다. 우리 근처에서는 바로 주소창에 URL을 입력할 때가 있겠습니다. (이는 논외로 인터뷰 단골 질문이라고 합니다..)
주소창에 URL을 검색하면 일어나는 일
웹 브라우저의 주소창에 URL(Uniform Resource Locator)을 입력하면, 웹 브라우저는 해당 URL을 해석하고 웹 페이지를 표시하기 위해 다음과 같은 과정을 거칩니다.
- URL 파싱: 웹 브라우저는 입력된 URL을 해석하고, 프로토콜, 호스트, 포트, 경로 등으로 URL을 구성합니다.
- DNS 조회: 호스트 이름을 IP 주소로 변환하기 위해 DNS(Domain Name System) 조회를 수행합니다. 예를 들면, 도메인 주소인 porolog.tistory.com 을 IP 주소인 110.421.674.103으로 변환하는 것이죠.
- TCP 연결: 웹 브라우저는 서버와의 TCP(Transmission Control Protocol) 연결을 설정합니다. TCP는 신뢰성 있는 데이터 전송을 보장하는 프로토콜입니다.
- HTTP 요청: 웹 브라우저는 서버에 HTTP 요청을 보냅니다. HTTP 요청은 GET, POST, PUT, DELETE 등의 요청 메소드와 요청 헤더, 요청 본문으로 구성됩니다.
- 서버 응답: 서버는 HTTP 요청에 대한 응답을 보냅니다. HTTP 응답은 상태 코드, 응답 헤더, 응답 본문으로 구성됩니다.
- 렌더링: 웹 브라우저는 서버에서 받은 HTML, CSS, JavaScript 등의 문서를 해석하여 웹 페이지를 렌더링합니다. 이때 브라우저는 DOM(Document Object Model)을 구축하고, CSS 스타일과 JS로 렌더트리를 동적으로 구성하며 화면에 웹 페이지를 출력합니다.
URL을 입력하면 웹 브라우저는 이러한 과정을 통해 서버와 통신하고, 웹 페이지를 표시합니다. 이러한 과정은 웹 애플리케이션의 동작 원리를 이해하는 데 매우 중요합니다. 엔터 한 번으로 정말 많은 일이 보이지 않는 곳에서 일어남을 알 수 있습니다. 이번 포스팅에서 다룰 주제는 그 중에서, HTTP 요청에 대한 부분으로 실제로 서버와 어떤 식으로 요청과 응답을 주고받느냐에 대한 부분이라고 생각하시면 좋을 것 같습니다.
HTTP란?
다시, HTTP가 무엇인지 정확히 알아보겠습니다. HTTP는 Hyper Text Transfer Protocol의 약자로, 인터넷에서 데이터를 주고받는 데 가장 많이 사용되는 프로토콜 중 하나입니다. HTTP를 사용하여 클라이언트와 서버 간에 통신을 수행하고, 요청과 응답 메시지를 전송합니다. HTTP는 웹 브라우저와 웹 서버 간의 통신에 사용되며, 이를 통해 사용자는 웹 사이트에서 정보를 가져올 수 있습니다. HTTP는 TCP/UDP(전송 프로토콜)로 서버 80번 포트에 요청을 보내고, 요청을 받은 서버는 정보를 찾아서 클라이언트에게 다시 보내줍니다. 이러한 정보의 전송에 필요한 프로토콜이 TCP/UDP가 있습니다. HTTP는 단독으로 전송을 수행할 수 있는 것이아닌, 이처럼 프로토콜의 계층으로 정보를 전송하게 됩니다. (자세한 내용인 OSI 7계층에 대해 포스팅 예정입니다.) 즉, 요약하면 아래 문장과 같습니다.
HTTP 특징
- 무상태(Stateless)
- 요청-응답 모델(Request-Response)
- 비연결성(Connectionless)
HTTP의 특징에 대해 서술해놓은 여러 글에서 특징을 다르게 분류하기도 하는데, 구분되는 명확한 특징을 위의 세가지에 대해서만 설명하겠습니다.
비연결성(Connectionless)
HTTP는 기본적으로 클라이언트와 서버 간에 연결을 유지하지 않습니다. 즉, 클라이언트가 요청을 보내고 서버가 응답을 보낸 후에는 연결을 끊습니다. 이러한 방식은 매 요청마다 연결을 새로 맺어야 하기 때문에 오버헤드가 발생할 수 있지만, 동시에 많은 클라이언트와 연결을 유지하지 않아도 되기 때문에 서버 부하를 낮출 수 있습니다.
무상태(Stateless)
HTTP는 상태 정보를 유지하지 않습니다. 이는 클라이언트와 서버 간에 이전에 주고받은 요청이나 응답에 대한 상태 정보를 저장하지 않는다는 것을 의미합니다. 상태 정보를 유지하지 않는 것은 HTTP의 비연결성을 가능하게 하며, 덕분에 서버에서는 클라이언트 간의 구분을 하지 않아도 됩니다.
그러나 클라이언트와 서버 간의 상태 정보를 유지해야 할 때도 있습니다. 예를 들면, 회원 로그인을 한 후 물건을 장바구니에 담거나 메시지를 보내거나하는 행위들이죠. 서버는 클라이언트의 상태에 대해서 기억하거나, 혹은 클라이언트가 스스로의 상태를 기억해야 위의 동작이 수행가능할겁니다. 때문에 HTTP에서는 쿠키(cookie), 세션(Session) 등의 기술을 사용하여 상태 정보를 유지합니다. 위의 예에서,쇼핑몰 웹 사이트에서는 사용자가 로그인을 하면 서버 측에서 세션을 생성하고, 이후에 사용자가 다른 페이지를 요청할 때마다 세션 ID를 함께 전송하여 사용자의 정보를 유지합니다. 이처럼, 무상태를 기본으로 하되, 상태가 있는 프로토콜을 구현 가능합니다. 쿠키는 클라이언트 측에서 상태 정보를 유지하고, 세션은 서버 측에서 상태 정보를 유지합니다. 이러한 기술을 이용하여 HTTP를 상태(Stateful) 프로토콜처럼 사용할 수 있습니다.
요청-응답(Request-Response) 모델
HTTP는 요청-응답 모델을 따릅니다. 클라이언트는 HTTP 요청을 보내고, 서버는 HTTP 응답을 보냅니다. 이러한 요청-응답 모델은 클라이언트와 서버 간의 통신을 간단하게 만들어주며, 여러 클라이언트가 동시에 서버에 접속할 수 있는 웹 서비스를 구현할 수 있도록 합니다. 즉, 웹 브라우저에서 웹 페이지를 요청할 때, 브라우저가 HTTP 요청 메시지를 서버로 보내면 서버는 이에 대한 HTTP 응답 메시지를 다시 브라우저에게 보내게 되는 것이 요청-응답 모델입니다. 당연해 보이지만, 이러한 모델을 따르지 않는 경우도 있습니다. 바로, 서버 푸시 모델입니다. 서버가 클라이언트의 요청 없이 데이터를 보내는 방식으로, 이 방식은 클라이언트가 서버에게 요청을 보내기 전에 필요한 데이터를 미리 보내주는 것이 가능합니다. 예를 들어, 웹 사이트에서 사용자가 클릭한 링크에 대한 새로운 페이지를 미리 로드하거나, 실시간으로 변경되는 데이터를 주기적으로 클라이언트에게 전송하는 경우에 사용됩니다. HTTP/2부터 도입된 기능인데 사용에 주의를 요한다고 합니다.
HTTP 메시지 구조
이제, HTTP가 어디에 쓰이는지, 왜 필요한지, 어떤 특징을 가지는지 알게 되셨을 겁니다. 이제, HTTP가 실제로 어떤 메시지 구조로 전송되는지 알아보겠습니다. 클라이언트가 서버로 요청을 할 때는 요청 메시지가 전송되고, 서버는 클라이언트에게 응답 메시지를 보냅니다. 각각에 대해서 알아보겠습니다.
HTTP 요청 메시지는 다음과 같은 요소로 구성됩니다.
- 요청 라인(Request Line): 요청 메소드, 요청 URL, HTTP 버전으로 구성됩니다.
- 요청 헤더(Request Header): 클라이언트가 서버에게 전달하는 부가적인 정보로 구성됩니다.
- 요청 본문(Request Body): 요청 메시지와 함께 서버에 전달되는 데이터로, 모든 HTTP 요청에서 선택적으로 사용됩니다.
HTTP 응답 메시지는 서버에서 클라이언트로 보내는 메시지입니다. 응답 메시지는 다음과 같은 요소로 구성됩니다.
- 상태 라인(Status Line): 상태 코드, 상태 메시지, HTTP 버전으로 구성됩니다.
- 응답 헤더(Response Header): 서버가 클라이언트에게 전달하는 부가적인 정보로 구성됩니다.
- 응답 본문(Response Body): 서버가 클라이언트에게 반환하는 데이터로, 모든 HTTP 응답에서 선택적으로 사용됩니다.
HTTP 요청 및 응답 메시지는 이해하기 쉽고 분석하기 쉽습니다. HTTP 요청을 분석하여 서버에 전송되는 정보를 확인하고, HTTP 응답을 분석하여 서버가 반환하는 데이터를 확인할 수 있습니다.
리소스 포맷 - 미디어 타입 MIME
HTTP 메시지 구조가 상태 라인, 헤더, 본문으로 이루어진다는 것을 알게 되셨을 겁니다. 마치 곤충의 머리, 가슴, 배처럼 딱 구분되죠.. 메시지가 어떤 구조로 이루어져있는지 파고 들어가면 끝이 없기 때문에, 그 중에서 HTTP 헤더와 리소스 타입에 대해서 알아보겠습니다.
HTTP 헤더는 클라이언트와 서버가 통신하는 과정에서 전송되는 메타데이터입니다. HTTP 요청 헤더와 응답 헤더가 있으며, 이 헤더에는 다양한 정보가 포함됩니다. MIME라는 것이 있는데요, 이 MIME(Multipurpose Internet Mail Extensions) 타입은 HTTP 헤더에 Content-Type 필드를 이용하여 데이터의 형식을 나타냅니다. MIME 타입은 전송되는 데이터의 형식이나 종류를 알려주기 때문에, 클라이언트는 이 정보를 이용하여 데이터를 올바르게 해석하고 처리할 수 있습니다.
예를 들어, 웹 브라우저가 서버에게 이미지 파일을 요청했다면, 서버는 HTTP 응답 헤더에 Content-Type 필드에 "image/jpeg"나 "image/png"와 같은 MIME 타입을 포함시킵니다. 이렇게 하면 브라우저는 이미지 데이터를 적절한 방식으로 해석하고 렌더링할 수 있습니다.
따라서 HTTP 헤더와 MIME 타입은 클라이언트와 서버 간의 통신에서 중요한 역할을 합니다. 만약, HTTP 응답으로 어떤 파일을 받았는데, 어떤 파일인지 모르면 브라우저가 어떤 방식으로 렌더링해야할지 모르겠죠? 이처럼 브라우저들은 리소스를 내려받았을 때 해야 할 기본 동작이 무엇인지를 결정하기 위해 대게 MIME 타입을 사용합니다. 이를 이용하여 클라이언트는 서버로부터 전송된 데이터를 올바르게 해석하고 처리할 수 있게 되며, 서버는 클라이언트에게 올바른 데이터를 전송할 수 있습니다.
HTTP 분석기
이제, HTTP에 대한 어느정도 지식을 쌓았으니 직접 서버에 요청을 보내서 응답을 받아보겠습니다. 요청을 보내는 상세한 과정은 아래와 같습니다.
1. 서버에 URL의 정보 GET 요청 보내기(HttpRequest)
2. 서버의 응답 수신
3. 응답받은 URL의 HTML 파싱(Jsoup)
4. 파싱한 결과를 바탕으로 이미지, CSS, JS 등 렌더링을 위해 추가로 필요한 파일들 재요청
5. 위 데이터를 종합하여 브라우저는 파스 트리를 구성하며 최종 렌더링됩니다.
제일 먼저, HttpRequest 또는 HttpUrlConnection 라이브러리를 통해서 요청을 보낼 수 있습니다. 아래에서 자세히 설명하겠지만 두 라이브러리는 요청을 송/수신하기 위한 라이브러리입니다.
HttpRequest, HttpUrlConnection
Java에서 HTTP 요청을 보내는 데 사용할 수 있는 다양한 라이브러리가 있지만, 가장 대표적인 라이브러리 중 하나가 Java에서 기본으로 제공되는 HttpURLConnection 클래스입니다.
HttpURLConnection 클래스는 Java에서 URL을 통해 HTTP 요청을 보내고 응답을 받는 기본적인 기능을 제공합니다. 이 클래스를 사용하여 GET, POST, PUT, DELETE 등의 HTTP 요청을 보낼 수 있으며, 요청 헤더, 요청 바디, 응답 헤더, 응답 바디 등의 정보를 다룰 수 있습니다.
예를 들어, 아래는 HttpURLConnection 클래스를 사용하여 GET 요청을 보내고 응답을 출력하는 코드입니다.
URL url = new URL("http://example.com");
HttpURLConnection con = (HttpURLConnection) url.openConnection();
con.setRequestMethod("GET");
int responseCode = con.getResponseCode();
System.out.println("Response code: " + responseCode);
BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
String inputLine;
StringBuilder response = new StringBuilder();
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
System.out.println("Response body: " + response.toString());
이 코드는 http://example.com으로 GET 요청을 보내고, 응답 코드와 응답 본문을 출력합니다.
그렇지만 저는 HttpRequest를 사용해서 요청을 보냈습니다. 그 이유는, HttpRequest 및 HttpClient 클래스는 기존의 HttpURLConnection보다 더 유연하고 쉬운 사용성을 제공하며, 비동기적인 요청 처리를 지원한다는 장점이 있기 때문입니다. 실제 네트워크 상황에서는 비동기적이기 때문에 Java 11부터는 이 클래스를 사용하여 HTTP 요청을 처리하는 것이 권장된다고 합니다.
HttpRequest 클래스는 요청 메서드, URL, 헤더, 바디 등의 정보를 설정할 수 있습니다. 예를 들어, 아래 코드는 GET 요청을 보내기 위해 HttpRequest 객체를 생성하고, 설정된 URL과 요청 메서드를 출력하는 코드입니다.
/*
* Request 생성 - URL, Method 등을 가진 Request 객체 생성
*/
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class HttpRequestExample {
public static void main(String[] args) throws Exception {
String url = "https://example.com";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
System.out.println("URL: " + request.uri());
System.out.println("Method: " + request.method());
}
}
HttpRequest와 함께 요청을 송신,수신하기 위한 클래스인 HttpClient가 같이 사용되어야합니다. HttpClient 클래스는 HttpRequest 객체를 사용하여 HTTP 요청을 보내고, 응답을 받을 수 있습니다. 아래 코드는 GET 요청을 보내고, 응답 본문을 출력하는 코드입니다.
/*
* Http 요청 송신 및 수신
*/
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class HttpClientExample {
public static void main(String[] args) throws Exception {
String url = "https://example.com";
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("Response body: " + response.body());
}
}
HttpAnalyzer 외부 라이브러리(JSoup)
위 라이브러리로 HTTP의 송,수신 요청이 가능해졌습니다. 수신 받은 HTML을 파싱해서 재요청들을 보내야겠죠. 파싱을 위한 라이브러리는 JSoup으로, Java에서 HTML을 파싱하고 조작하기 위한 라이브러리입니다. jsoup을 사용하여 HTML을 파싱하는 방법은 다음과 같습니다.
1. HTML 문서 파싱하기
Document doc = Jsoup.connect("http://naver.com/").get();
String title = doc.title();
위 코드에서, Jsoup.connect(url)은 URL에서 HTML을 가져오기 위한 Connection 객체를 생성합니다. Connection 객체를 통해 get() 메서드를 호출하여 HTML 문서를 가져옵니다. 파싱된 결과는 Document 타입 객체로 저장됩니다.
2. HTML 요소 선택하기
jsoup은 CSS 선택자를 사용하여 HTML 문서에서 특정 요소를 선택할 수 있습니다. 선택한 요소를 조작하거나 값을 가져올 수 있습니다. 다음은 추가로 요청이 필요한 url을 추출하기 위한 선택자의 예제입니다. script인 모든 요소를 선택하여, src 속성 값을 가져옵니다. 그 결과, js, css 등 파일의 url들을 추출할 수 있습니다. 비슷하게, img에 대해서 모든 요소를 선택해서 image url들을 추출하였습니다.
// class가 "script"인 모든 요소 선택
Elements scripts = dom.select("script");
// script 요소의 src 속성 값 가져오기
String src = script.attr("src");
위 코드에서, select() 메서드를 사용하여 CSS 선택자를 지정합니다. 선택된 요소는 Element 객체 또는 Elements 객체로 반환됩니다. Element 객체는 선택된 요소 중 첫 번째 요소를 반환하며, Elements 객체는 선택된 모든 요소를 반환합니다. 선택된 요소의 속성 값은 attr() 메서드로, 텍스트 값은 text() 메서드로 가져올 수 있습니다. jsoup은 간결하고 직관적인 API를 제공하여 HTML 문서의 파싱을 쉽게 할 수 있습니다.
이렇게, 브라우저 렌더링 전에 HTTP의 요청에 대해서 배워봤습니다. 위의 내용을 바탕으로 HTTP 분석기의 간략한 시퀀스 다이어그램을 그려보았습니다. 서버와 HTTP 분석기 사이에 캐시라는 클래스가 추가되었는데요, 캐시를 통해서 한 번 전송한 요청을 계속해서 전송하며 트래픽을 낭비하는 것을 방지할 수 있습니다. 간단하게 아래에서 캐시의 개념을 다시 정리해봤습니다.
Cache
캐시란?
한 번 접속한 웹사이트는 다시 요청을 보내지 않도록 내용을 저장해놓는 것으로, Private Cache와 Shared Cache로 분류됩니다. Private Cache는 한 사용자에 의해서만 재활용되는 캐시로 브라우저 캐시 등이 있으며, Shared Cache는 여러 사용자에 의해 재활용되는 캐시로, 프록시 캐시(ISP 측에서 로컬 네트워크 인프라 일부로 구축, 트래픽 완화)등이 있습니다. 캐시의 개념은 비교적 단순하지만 중요한 것은 캐시를 어떻게 사용할지입니다. 캐시를 언제까지 사용할지, 어떤 정보를 저장할지, 그리고 모든 정보를 계속 저장할 수는 없으니 언제 내보낼지를 결정해야합니다. 캐시의 유효기간을 정하고 언제까지 사용할지 정하는 것이 캐시의 유효성 검증입니다. 그리고, 어떤 캐시를 저장하고 저장 공간이 다 찼을 때 어떤 캐시를 내보내는 것에 대한 것이 캐시 알고리즘입니다.
유효성 검증
캐시의 수명을 결정하는 로직(Lifetime)입니다. 규칙은 아래와 같습니다.
- HTTP 프로토콜의 cache-control 헤더에서 캐시 동작과 관련된 메타 데이터를 저장합니다.
- Cache-Control 헤더의 max-age=N 디렉티브가 존재하면, 수명은 N과 같습니다.
- Expires 해더가 존재하면, 수명은 Expires 헤더에서 Date 값을 뺀 것과 같습니다.
- Last-Modified 헤더가 존재하면, 수명은 Date 헤더 값에서 Last-Modified 헤더 값을 뺀 것을 10으로 나눈 것과 같습니다.
- 수명이 남은 경우를 Fresh라고하며, 다한 경우는 Stale이라고 합니다. Fresh한 경우는 요청을 보내지 않고 캐시 사용합니다.
- 수명이 다한 경우 브라우저에 유효성을 검증하기 위해 HTTP 요청 전달하는데, 이를 Validation이라고 합니다.
- 유효한 경우 304(Not Modified)를 반환하고, 유효하지 않으면 새로운 자원을 Body에 담아 200을 반환합니다. 유효한 경우 기존 캐싱의 Age를 초기화합니다.
캐시 알고리즘
- LRU 알고리즘(Least Recently Used)
참조된 시간을 기준으로 교체될 페이지를 선정하며, 가장 오래동안 참조되지 않은 페이지를 교체하는 방식입니다. 주 기억장치에 접근할 때마다 참조 페이지에 대한 시간을 기록해야한다는 특징이 있습니다.
- LFU 알고리즘 (Least Frequently Used)
참조된 횟수를 기준으로 교체될 페이지 선정하며, 가장 적게 참조된 페이지를 교체하는 방식입니다. 만약 참조 횟수가 동일하면 시간이 오래된 것을 교체합니다. 단점으로는, 최근 사용 시작한 프로그램을 교체시킬 수도 있다는 점이 있습니다. 알고리즘이 늘 그렇듯 정답은 없으니 상황에 맞게 사용하는 것이 중요하겠죠. 저는 구현에서는 LFU 알고리즘으로 캐시를 적용했습니다.
구현 결과
아래의 결과와 같이 구현을 완료했습니다. 결과에서 보이듯 헤더 이름에 따라서 결과가 제대로 조회되지 않는 현상도 있어서 추가로 보완이 필요할 것 같습니다. 그래도 도메인에 요청을 전송하고, 요청 결과로 받은 html을 파싱하여 추가로 JS나 JPG 파일을 요청하는 것이 잘 수행이 됩니다. 그리고 요청, 응답 메시지의 헤더를 분석해서 파일의 정보를 확인할 수도 있었습니다. 사실 이런 기능들은 이미 브라우저의 개발자 도구에서 더 잘 제공하고있지만, 직접 구현해봄으로써 실제로 HTTP 요청이 어떤 방식으로 이루어지는지 확인할 수 있었습니다.
> m.naver.com
도메인 m.naver.com
스킴 https
경로
종류 html; charset=UTF-8
용량 KB
다운로드 시간 446ms
>> gfp-core.js
도메인 ssl.pstatic.net
스킴 https
경로 /tveta/libs/glad/prod/gfp-core.js
종류 javascript
용량 49.63KB
다운로드 시간 90ms
>> index_head.9ac14169.js
도메인 mm.pstatic.net
스킴 https
경로 /js/build/index_head.9ac14169.js
종류 javascript
용량 66.33KB
다운로드 시간 110ms
>> upload_1674188500084tM9vG.jpg
도메인 s.pstatic.net
스킴 https
경로 /static/www/mobile/edit/20230120_1095/upload_1674188500084tM9vG.jpg
종류 jpeg
용량 1.76KB
>> 캐시됨
=====
도메인 개수 : 4개
요청 개수 : 49개
이미지(png, gif, jpg) 개수 : 26개
코드(css, js) 개수 : 0개
전송 용량 : 2.9246MB
캐시 데이터 개수 : 35개
전체 로딩 시간 : 2692ms
가장 큰 용량 : 748.5KB
가장 오랜 대기 시간 : 446ms
'Computer Science > 네트워크' 카테고리의 다른 글
네트워크 계층 - IP, CIDR, NAT (0) | 2023.06.09 |
---|---|
MAC 주소와 ARP 프로토콜 (0) | 2023.06.01 |
SOP, CORS - CORS 이슈 해결하기 (0) | 2023.05.29 |
Socket의 연결 종료와 Timeout 설정하기 - setSoTimeOut (0) | 2023.04.06 |
HTTP 요청 전송 및 분석 - 소켓 프로그래밍, TCP 전송의 원리 (Java) (0) | 2023.02.20 |