HTTP 응답 받기 - CURL 구현하기

김태훈·2023년 6월 29일
0

들어가기에 앞서

해당 단계에서 서버가 보내주는 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는 소켓을 사용해서 응답을 받아오는데, 헤더가 끝이 났다는 걸 알려주기 위해 공백라인이 필수적으로 들어가야 합니다.

HTTP 데이터에 Body가 있을 경우, Content-Length 헤더가 필수인 이유

앞서 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 방식에서 Body를 받아오는 법

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 처리를 해줬습니다.

To Be...

이렇게 간단한 cUrl을 만들어보았습니다. 다음 글에서는 배운 점을 간략하게 정리하고, 구현에 있어 아쉬운 부분들을 정리하면서 마무리해보겠습니다.

profile
작은 지식 모아모아

0개의 댓글