해당 단계에서 서버가 보내주는 Http Response Message를 직접 받아보는 부분을 구현합니다.
해당 구현을 통해
1. 왜 헤더와 바디 사이에는 공백이 필요한지
2. HTTP 데이터에 Body가 있을 경우, 왜 Content-Length 헤더가 필수인 이유
를 알 수 있습니다.
또, Http Message의 바디를 받는 방식은 Content-Length를 적어주는 방식 외에도 chunked 방식이 존재하는데 두 방식 모두 직접 구현해보겠습니다
package org.kimtaehoondev.domain;
import java.util.List;
import java.util.StringJoiner;
import java.util.stream.Collectors;
public class HttpResponse {
private final String httpVersion;
private final Integer httpStatus;
private final Headers headers;
private String body;
protected HttpResponse(String httpVersion, Integer httpStatus, Headers headers, String body) {
this.httpVersion = httpVersion;
this.httpStatus = httpStatus;
this.headers = headers;
this.body = body;
}
public static Builder builder() {
return Builder.builder();
}
public String getBody() {
return body;
}
public String serialize() {
StringJoiner stringJoiner = new StringJoiner("\n");
stringJoiner.add(httpVersion + " " + httpStatus);
List<String> total = headers.getAll().stream()
.map(Header::getPrettier)
.collect(Collectors.toList());
for (String each : total) {
stringJoiner.add(each);
}
stringJoiner.add("");
stringJoiner.add(body);
return stringJoiner.toString();
}
public static class Builder {
private String httpVersion;
private Integer httpStatus;
private boolean isChunked = false;
private Integer contentLength;
private String body;
private final Headers headers;
private Builder() {
headers = new Headers();
}
public static Builder builder() {
return new Builder();
}
public HttpResponse build() {
if (httpVersion == null || httpStatus == null || headers == null) {
throw new RuntimeException("초기화가 제대로 이뤄지지 않았습니다");
}
return new HttpResponse(httpVersion, httpStatus, headers, body);
}
public Builder setStartLine(String line) {
String[] values = line.split(" ");
httpVersion = values[0];
httpStatus = Integer.parseInt(values[1]);
return this;
}
public Builder setHeaders(List<Header> headers) {
for (Header header : headers) {
addHeader(header);
}
return this;
}
private void addHeader(Header header) {
headers.put(header);
if (header.isKeyEquals("Transfer-Encoding") && header.isValueEqual("chunked")) {
isChunked = true;
}
if (header.isKeyEquals("Content-Length")) {
contentLength = Integer.parseInt(header.getValue());
}
}
public Builder setBody(String body) {
this.body = body;
return this;
}
public Integer getContentLength() {
return contentLength;
}
public boolean isChunked() {
return isChunked;
}
}
}
package org.kimtaehoondev;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.Socket;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.StringJoiner;
import org.kimtaehoondev.domain.Header;
import org.kimtaehoondev.domain.HttpRequest;
import org.kimtaehoondev.domain.HttpResponse;
import org.kimtaehoondev.factory.HttpRequestFactory;
import org.kimtaehoondev.utils.UrlParser;
public class Curl {
public static final int LINE_BREAK_LENGTH = 1;
private final HttpRequestFactory httpRequestFactory;
public Curl(HttpRequestFactory httpRequestFactory) {
this.httpRequestFactory = httpRequestFactory;
}
public void run(String[] args) {
URL url = UrlParser.parse(args[args.length - 1]);
String[] argsExceptUrl = Arrays.copyOfRange(args, 0, args.length - 1);
HttpRequest request = httpRequestFactory.make(url, argsExceptUrl);
try (Socket socket = new Socket(url.getHost(), url.getPort())) {
BufferedReader readerFromServer =
new BufferedReader(new InputStreamReader(socket.getInputStream()));
BufferedWriter writerToServer =
new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
sendRequestToServer(request, writerToServer);
// 해당 줄부터 추가된 부분입니다
HttpResponse httpResponse = receiveResponseFromServer(readerFromServer);
System.out.println(httpResponse.serialize());
} catch (UnknownHostException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void sendRequestToServer(HttpRequest request, BufferedWriter writerToServer)
throws IOException {
List<String> lines = request.serialize();
for (String line : lines) {
writerToServer.write(line + HttpRequest.CRLF);
}
writerToServer.flush();
}
/**
* 서버에서 응답을 받아온다.
* 바디를 파싱하기 위한 정보를 얻기 위해 헤더를 따로 얻어낸다
*/
private HttpResponse receiveResponseFromServer(BufferedReader readerFromServer) throws IOException {
HttpResponse.Builder builder = HttpResponse.builder();
builder.setStartLine(readerFromServer.readLine());
List<Header> headers = receiveResponseHeaderFromServer(readerFromServer);
builder.setHeaders(headers);
String body = receiveResponseBodyFromServer(builder, readerFromServer);
builder.setBody(body);
return builder.build();
}
private List<Header> receiveResponseHeaderFromServer(BufferedReader readerFromServer)
throws IOException {
List<Header> headers = new ArrayList<>();
String line;
while ((line = readerFromServer.readLine()) != null && !line.isEmpty()) {
headers.add(new Header(line));
}
return headers;
}
private String receiveResponseBodyFromServer(HttpResponse.Builder httpResponseBuilder,
BufferedReader readerFromServer) throws IOException {
if (httpResponseBuilder.isChunked()) {
StringJoiner stringJoiner = new StringJoiner("\n");
String line;
Integer chunkedSize;
while (true) {
chunkedSize = Integer.parseInt(readerFromServer.readLine(), 16); // 16진수
line = readerFromServer.readLine();
if (chunkedSize == 0) {
break;
}
stringJoiner.add(line);
}
return stringJoiner.toString();
}
int totalLength = 0;
int character;
StringBuilder sb = new StringBuilder();
while (totalLength != httpResponseBuilder.getContentLength() && ((character = readerFromServer.read()) != -1)) {
char c = (char) character;
totalLength += 1;
sb.append(c);
}
return sb.toString();
}
}
로직은 다음과 같은 순서로 진행됩니다.
1. 서버에서 보내준 텍스트들을 받아서 HttpResponse 객체를 만든다
2. 만들어진 HttpResponse 객체를 직렬화해 화면에 출력한다
먼저 Http Message의 스펙을 살펴보겠습니다.
BufferedReader 객체는 가장 먼저 start-line에 대한 텍스트를 받습니다. Http Response Message는 start-line에 Http Version과 상태코드에 대한 정보를 들고 있습니다. 그리고 스펙 순서대로 헤더와 바디에 대한 정보를 받아옵니다. receiveResponseFromServer
를 보시면 해당 부분을 구현한 걸 확인할 수 있습니다.
우리는 Socket을 사용해 서버와 통신을 하고 있다고 이야기했습니다. Socket은 readline 메서드를 사용해서 데이터를 받아옵니다.
어플리케이션 입장에서 받아온 데이터가 Start-Line에 대한 정보인지 구별하는 건 쉽습니다. 가장 먼저 받아온 첫 번째 줄이 Start-Line이기 때문입니다. 그 이후 헤더에 대한 정보들을 받아오는데 여기에서 문제가 생깁니다. 과연 어디까지가 헤더고, 어디부터가 바디일까요?
우리는 사람이니까 어디부터 Body인지 한눈에 구별할 수 있지만 컴퓨터 입장에서는 헤더와 바디를 구별해주는 무언가가 필요합니다. CRLF
가 그 역할을 수행합니다. 다시 한 번 로직을 살펴보겠습니다.
// CURL 객체의 receiveResponseHeaderFromServer 메서드입니다.
private List<Header> receiveResponseHeaderFromServer(BufferedReader readerFromServer)
throws IOException {
List<Header> headers = new ArrayList<>();
String line;
while ((line = readerFromServer.readLine()) != null && !line.isEmpty()) {
headers.add(new Header(line));
}
return headers;
}
가운데 While 문을 보시면 !line.isEmpty()
라는 메서드가 존재합니다. CURL 객체는 while문을 돌리면서 계속해서 헤더를 받다가, CRLF를 만나면, !line.isEmpty() 가 false
가 되면서 반복문을 탈출합니다.
정리하면 HTTP는 소켓을 사용해서 응답을 받아오는데, 헤더가 끝이 났다는 걸 알려주기 위해 공백라인이 필수적으로 들어가야 합니다.
앞서 Header와 Body를 구분하기 위해 CRLF를 사용한다 했습니다. 그러면 Body가 끝났으니 소켓을 닫아야 한다는 걸 어떻게 알 수 있을까요?
앞서 헤더는 key: value
와 같이 정해진 양식이 존재했습니다. CRLF(공백)과 같은 데이터는 헤더에 존재할 수 없기 때문에, CRLF로 헤더가 끝났다는 걸 가르쳐줄 수 있었습니다. 반면, Body에는 들어갈 수 있는 형태의 제한이 없습니다.
끝났다는 걸 가르쳐줄 수 있는 수단이 없기 때문에 서버는 "Body의 크기는 얼마다~"라면서 Content-Length를 사용해 보내줍니다.
클라이언트는 Body를 한 글자씩 받으면서, 지금까지 받아온 메세지들의 총 크기를 계산합니다. 받아온 데이터의 크기가 Content-Length와 일치한다면 연결을 종료합니다.
private String receiveResponseBodyFromServer(HttpResponse.Builder httpResponseBuilder,
BufferedReader readerFromServer) throws IOException {
StringJoiner stringJoiner = new StringJoiner("\n");
String line;
//... 가운데 생략
int totalLength = 0;
int character;
StringBuilder sb = new StringBuilder();
while (totalLength != httpResponseBuilder.getContentLength() && ((character = readerFromServer.read()) != -1)) {
char c = (char) character;
totalLength += 1;
sb.append(c);
}
return sb.toString();
}
Content-Length는 바이트크기를 사용해 계산이 이뤄집니다. 따라서 받아온 데이터의 인코딩 방식에 따라 계산방식이 달라지는데, 이해하기 쉽도록 예시에서는 UTF-8 방식으로 String의 크기를 계산하고 있습니다.
또, LINE_BREAK_LENGTH(크기 1)를 더해주면서 계산하고 있는데 readline 메서드는 줄바꿈문자를 기준으로 문자를 나누어 보냅니다.
따라서 연산할 때 받아온 line마다 줄바꿈문자의 크기를 더해줘야 올바른 연산이 가능합니다.
chunked 방식은 말 그대로 Chunk 단위로 쪼개서 보내는 방식입니다. Content-Length에 대한 헤더를 사용하는 대신, 다른 방식을 사용해 메세지가 끝났다는 걸 가르쳐줍니다.
Transfer-Encoding 헤더에 Chunked라는 데이터를 넣고, Body에는 chunk size+내용+chunk size+내용 ... 방식으로 데이터를 전달합니다.
모든 데이터를 보낸 뒤에는 chunk size로 0을 보내 메세지가 끝났다는 걸 가르쳐줍니다. 아래는 chunked 방식일 때 바디를 받아오는 걸 보여줍니다.
if (httpResponseBuilder.isChunked()) {
Integer chunkedSize;
while (true) {
chunkedSize = Integer.parseInt(readerFromServer.readLine(), 16); // 16진수
line = readerFromServer.readLine();
if (chunkedSize == 0) {
break;
}
stringJoiner.add(line);
}
return stringJoiner.toString();
}
chunkedSize는 16진수로 표현됩니다!
이제 결과를 확인해볼까요? 아무 값도 넣지 않는 스프링 웹 어플리케이션을 실행시킨 뒤 -request GET -H accept:*/* -H User-Agent:curl/7.79.1 localhost:8080
요청을 보내보도록 하겠습니다.
Postman을 통해 응답이 일치하는지 확인해보겠습니다.
동일한 Http Response인걸 확인할 수 있습니다. 한 번 테스트에 사용한 웹 어플리케이션에 Index를 넣어두고 확인을 해보겠습니다.
한 번 -d 플래그가 잘 동작하는지도 테스트해보겠습니다.
-request POST -H accept:*/* -H User-Agent:curl/7.79.1 -d name=taehoon&pwd=12345 localhost:8080/hello
localhost:8080/hello에 RestController 처리를 해줬습니다.
이렇게 간단한 cUrl을 만들어보았습니다. 다음 글에서는 배운 점을 간략하게 정리하고, 구현에 있어 아쉬운 부분들을 정리하면서 마무리해보겠습니다.