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개의 댓글

관련 채용 정보