교재 실습/자바 웹 프로그래밍 Next Step

4.2.1.1 요구사항 1 - index.html 응답하기

Jint 2025. 2. 10. 23:01
요구사항 http://localhost:8080/index.html로 접속했을 때 webapp 디렉토리의 index.html 파일을 읽어 클라이언트에 응답한다.

 

이 요구사항을 구현하려면 먼저 클라이언트에서 서버로 전송하는 데이터가 어떻게 구성되었는지 확인해야 한다. 1단계 힌트를 참고해 요청 데이터를 출력한다.

 

- RequestHandler.java

package webserver;

import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.Socket;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class RequestHandler extends Thread {
    private static final Logger log = LoggerFactory.getLogger(RequestHandler.class);

    private Socket connection;

    public RequestHandler(Socket connectionSocket) {
        this.connection = connectionSocket;
    }

    public void run() {
        log.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(), connection.getPort());

        try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {
            // TODO 사용자 요청에 대한 처리는 이 곳에 구현하면 된다.
            BufferedReader br = new BufferedReader(new InputStreamReader(in, "UTF-8"));
            String line = br.readLine();
            log.debug("request line : {}", line);

            if (line == null) {
                return;
            }

            while (!line.equals("")) {
                line = br.readLine();
                log.debug("header : {}", line);
            }

            DataOutputStream dos = new DataOutputStream(out);
            byte[] body = "Hello World".getBytes();
            response200Header(dos, body.length);
            responseBody(dos, body);
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }

    private void response200Header(DataOutputStream dos, int lengthOfBodyContent) {
        try {
            dos.writeBytes("HTTP/1.1 200 OK \r\n");
            dos.writeBytes("Content-Type: text/html;charset=utf-8\r\n");
            dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n");
            dos.writeBytes("\r\n");
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }

    private void responseBody(DataOutputStream dos, byte[] body) {
        try {
            dos.write(body, 0, body.length);
            dos.flush();
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }
}


위와 같이 구현하고 웹 서버를 재시작한 후 브라우저에서 http://localhost:8080/index.html로 요청을 보내면 이클립스 콘솔 화면에 다음과 비슷한 결과가 나타나는 것을 확인할 수 있다.

 

16:10:12.836 [DEBUG] [Thread-0] [webserver.RequestHandler] - New Client Connect! Connected IP : /0:0:0:0:0:0:0:1, Port : 6217
16:10:12.836 [DEBUG] [Thread-1] [webserver.RequestHandler] - New Client Connect! Connected IP : /0:0:0:0:0:0:0:1, Port : 6218
16:10:12.839 [DEBUG] [Thread-0] [webserver.RequestHandler] - request line : GET /index.html HTTP/1.1
16:10:12.839 [DEBUG] [Thread-0] [webserver.RequestHandler] - header : Host: localhost:8080
16:10:12.839 [DEBUG] [Thread-0] [webserver.RequestHandler] - header : Connection: keep-alive
16:10:12.839 [DEBUG] [Thread-0] [webserver.RequestHandler] - header : Cache-Control: max-age=0
16:10:12.839 [DEBUG] [Thread-0] [webserver.RequestHandler] - header : Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
16:10:12.840 [DEBUG] [Thread-0] [webserver.RequestHandler] - header : Upgrade-Insecure-Requests: 1
16:10:12.840 [DEBUG] [Thread-0] [webserver.RequestHandler] - header : User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.109 Safari/537.36
16:10:12.840 [DEBUG] [Thread-0] [webserver.RequestHandler] - header : Accept-Encoding: gzip, deflate, sdch
16:10:12.840 [DEBUG] [Thread-0] [webserver.RequestHandler] - header : Accept-Language: ko-KR,ko;q=0.8,en-US;q=0.6,en;q=0.4
16:10:12.840 [DEBUG] [Thread-0] [webserver.RequestHandler] - header : 
16:10:12.876 [DEBUG] [Thread-1] [webserver.RequestHandler] - request line : GET /favicon.ico HTTP/1.1
16:10:12.876 [DEBUG] [Thread-1] [webserver.RequestHandler] - header : Host: localhost:8080
16:10:12.876 [DEBUG] [Thread-1] [webserver.RequestHandler] - header : Connection: keep-alive
16:10:12.876 [DEBUG] [Thread-1] [webserver.RequestHandler] - header : Pragma: no-cache
16:10:12.876 [DEBUG] [Thread-1] [webserver.RequestHandler] - header : Cache-Control: no-cache
16:10:12.876 [DEBUG] [Thread-1] [webserver.RequestHandler] - header : User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.109 Safari/537.36
16:10:12.876 [DEBUG] [Thread-1] [webserver.RequestHandler] - header : Accept: */*
16:10:12.877 [DEBUG] [Thread-1] [webserver.RequestHandler] - header : Referer: http://localhost:8080/index.html
16:10:12.877 [DEBUG] [Thread-1] [webserver.RequestHandler] - header : Accept-Encoding: gzip, deflate, sdch
16:10:12.877 [DEBUG] [Thread-1] [webserver.RequestHandler] - header : Accept-Language: ko-KR,ko;q=0.8,en-US;q=0.6,en;q=0.4
16:10:12.877 [DEBUG] [Thread-1] [webserver.RequestHandler] - header :


위와 같이 콘솔에 출력되는 결과를 통해 확인할 수 있는 내용은 다음과 같다.

 

· 첫 번째와 두 번째 라인을 확인해보니 클라이언트로부터 2개의 요청이 발생했으며, 각 요청마다 클라이언트의 포트는 서로 다른 포트(port)로 연결한다. 서버는 각 요청에 대해 순차적으로 실행하는 것이 아니라 동시에 각 요청에 대응하는 스레드(위 콘솔 결과를 보면 Thread-0, Thread-1)를 생성해 동시에 실행한다.

· 각 요청에 대한 첫 번째 라인은 "GET /index.html HTTP/1.1"과 같은 형태로 구성되어 있다. 두 번째 요청의 경우에도 "favicon.ico"만 다르고 다른 부분은 같다. 브라우저에 따라 "favicon.ico" 요청이 없을 수도 있다. 위 테스트는 크롬 브라우저에서 했을 때의 콘솔 화면의 로그이다.

· 첫 번째 라인을 제외한 나머지 요청 데이터는 "<필드이름>: <필드 값>" 형태로 구성되어 있다.

· 각 요청의 마지막은 빈 문자열("")로 구성되어 있다.

 

웹 클라이언트(대부분 웹 브라우저)는 웹 서버와 데이터를 주고 받기 위해 HTTP라는 서로 간에 약속된 규약을 따른다. 웹 클라이언트가 웹 서버에 요청을 보내기 위한 규약은 다음과 같다.

 

POST /user/create HTTP/1.1 ------------------------> 요청라인
HOST: localhost:8080                               ┐
Connection-Length: 59                              │ 요청헤더
Content-Type: application/x-wwww-form-urlencoded   │
Accept: */*                                        ┘
---------------------------------------------------> 헤더와 본문 사이의 빈 공백 라인
userId=javajigi&password=password -----------------> 요청 본문


요청 데이터의 첫 번째 라인은 요청 라인(Request Line), 두 번째 라인부터 빈 공백 문자열 라인까지 요청 헤더(header), 빈 공백 문자열 다음부터 본문(body) 데이터가 전송된다. 모든 HTTP 요청에 대해 요청 라인, 요청 헤더, 빈 공백 문자열은 필수이고, 요청 본문은 필수가 아니다. 각 요소에 대해 더 자세하게 살펴본다.

 

1) 요청 라인(Request Line)

요청 데이터의 첫 번째 라인은 요청 라인(Request Line) 이라고 부른다.

요청 라인은 "HTTP-메서드 URI HTTP-버전"으로 구성되어 있다. HTTP 메서드는 요청의 종류를 나타낸다. URI는 클라이언트가 서버에 유일하게 식별할 수 있는 요청 자원의 경로를 의미한다. 일반적으로 URI와 URL이 혼용되어 사용되는데 거의 같은 의미라고 생각하면 된다.

HTTP-버전은 현재 요청의 HTTP 버전으로 현재 HTTP/1.1이 주로 사용된다. 2015년에 HTTP 2.0 버전에 대한 최종 스펙도 확정되어 앞으로 2.0 버전도 사용될 것이다.

 

2) 요청 헤더(Request Headers)

요청 헤더는 <필드 이름> : <필드 값> 쌍으로 이루어져 있다. 만약 필드 이름 하나에 여러 개의 필드 값을 전달하고 싶다면 쉼표(,)를 구분자로 전달할 수 있다.

 

<필드 이름>: <필드 값1>, <필드 값2>

 

예를 들어 앞의 콘솔 출력 결과 중 "Accept-Encoding: gzip, deflate, sdch"가 Accept-Encoding 필드 이름 하나에 여러 개의 값을 전달하고 있다.

이와 같이 클라이언트에서 요청을 받으면 서버는 클라이언트 요청에 대한 응답을 한다. HTTP 응답 또한 요청과 같이 헤더와 본문으로 구성되어 있다. 서버에서 클라이언트로 보내는 응답 메시지를 분석해 보면 다음과 같다.

 

HTTP/1.1 200 OK ------------------------> 상태 라인
Content-Type: text-html;charset=utf-8   ┐ 응답 헤더
Content-Length: 20                      ┘
----------------------------------------> 헤더와 본문 사이의 빈 공백 라인
<h1>Hello World</h1> -------------------> 응답 본문

 

응답 메시지의 첫 번째 라인은 상태 라인(Status), 두 번째 라인부터 빈 공백 문자열 라인까지 응답 헤더(header)이고, 빈 공백 문자열 다음부터 응답으로 보낼 본문(body) 데이터이다. 응답 메시지의 문법 또한 요청 메시지와 같다. 단지 다른 점이라면 첫 번째 라인의 형식이 다르다는 것이다.

 

3) 상태 라인(Status Line)
응답 헤더의 첫 번째 라인은 상태 라인이라고 부른다.

응답 라인은 "HTTP-버전 상태코드 응답구문"으로 구성되어 있다. HTTP-버전은 HTTP 요청 라인(Request Line)의 HTTP-버전과 같은 의미이다. 상태코드는 응답에 대한 상태를 의미하는 코드 값으로 200은 성공을 의미한다. 응답 구문은 응답 상태에 대한 설명이다. 상태코드는 200 이외에도 다양한 상태코드가 있다.

지금까지 HTTP 요청과 응답 메시지의 기본 구조에 대해 살펴봤다. 요청 메시지의 형태를 살펴봤으니 다음 단계는 요청 라인에서 클라이언트가 요청하는 자원이 무엇인지 분리할 필요가 있다. 즉, "GET /index.html HTTP/1.1"에서 필요한 값은 "index.html"이다. 이와 같이 분리한 "/index.html"에 해당하는 자원을 웹 서버에서 읽은 후 브라우저에 응답으로 보내면 된다. 이 과정을 구현하면 다음과 같다.

 

- RequestHandler.java

package webserver;

import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.File;
import java.nio.file.Files;
import java.net.Socket;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class RequestHandler extends Thread {
    private static final Logger log = LoggerFactory.getLogger(RequestHandler.class);

    private Socket connection;

    public RequestHandler(Socket connectionSocket) {
        this.connection = connectionSocket;
    }

    public void run() {
        log.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(), connection.getPort());

        try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {
            // TODO 사용자 요청에 대한 처리는 이 곳에 구현하면 된다.
            BufferedReader br = new BufferedReader(new InputStreamReader(in, "UTF-8"));
            String line = br.readLine();
            log.debug("request line : {}", line);

            if (line == null) {
                return;
            }

            String[] tokens = line.split(" ");

            while (!line.equals("")) {
                line = br.readLine();
                log.debug("header : {}", line);
            }

            DataOutputStream dos = new DataOutputStream(out);
            // byte[] body = "Hello World".getBytes();
            byte[] body = Files.readAllBytes(new File("./webapp" + tokens[1]).toPath());
            response200Header(dos, body.length);
            responseBody(dos, body);
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }

    private void response200Header(DataOutputStream dos, int lengthOfBodyContent) {
        try {
            dos.writeBytes("HTTP/1.1 200 OK \r\n");
            dos.writeBytes("Content-Type: text/html;charset=utf-8\r\n");
            dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n");
            dos.writeBytes("\r\n");
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }

    private void responseBody(DataOutputStream dos, byte[] body) {
        try {
            dos.write(body, 0, body.length);
            dos.flush();
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }
}

 

클라이언트 요청 메시지 구조 분석이 끝나면 이후 구현은 의외로 쉽다. 클라이언트의 요청 URI에 해당하는 자원을 읽어 응답으로 보내면 끝이다. 위와 같이 구현을 끝내고 서버를 재시작한 후 http://localhost:8080/index.html로 요청을 보내면 index.html의 내용을 응답으로 받을 수 있다.

위와 같이 요청을 보낸 후 이클립스 콘솔 결과를 확인한다. 분명히 index.html로 요청을 한 번 보냈을 뿐인데 한 번의 요청이 아닌 여러 번의 요청이 발생하는 것을 확인할 수 있다. 앞에서도 /favicon.ico 요청이 있었지만 그보다 훨씬 더 많은 추가 요청이 발생했다.

이를 확인하기 위해 각 요청에 대한 요청 라인(첫 번째 라인)만 분리해서 확인한다.

 

[DEBUG] [Thread-0] [webserver.RequestHandler] - request line : GET /index.html HTTP/1.1
[DEBUG] [Thread-0] [webserver.RequestHandler] - request line : GET /css/bootstrap.min.css HTTP/1.1
[DEBUG] [Thread-0] [webserver.RequestHandler] - request line : GET /css/styles.css HTTP/1.1
[DEBUG] [Thread-0] [webserver.RequestHandler] - request line : GET /js/jquery-2.2.0.min.js HTTP/1.1
[DEBUG] [Thread-0] [webserver.RequestHandler] - request line : GET /js/scripts.js HTTP/1.1
[DEBUG] [Thread-0] [webserver.RequestHandler] - request line : GET /js/bootstrap.min.js HTTP/1.1
[DEBUG] [Thread-0] [webserver.RequestHandler] - request line : GET /favicon.ico HTTP/1.1

 

분명히 /index.html 요청을 한 번 보냈는데 위와 같이 여러 개의 추가 요청이 발생했다. 이 같이 많은 요청이 발생한 이유는 서버가 웹 페이지를 구성하는 모든 자원(HTML, CSS, 자바스크립트, 이미지 등)을 한 번에 응답으로 보내지 않기 때문이다. 웹 서버는 첫 번째로 /index.html 요청에 대한 응답에 HTML만 보낸다. 응답을 받은 브라우저는 HTML 내용을 분석해 CSS, 자바스크립트, 이미지 등의 자원이 포함되어 있으면 서버에 해당 자원을 다시 요청하게 된다. 따라서 하나의 웹 페이지를 사용자에게 정상적으로 서비스하려면 클라이언트와 서버 간에 한 번의 요청이 아닌 여러 번의 요청과 응답을 주고 받게 된다. 웹 클라이언트와 웹 서버 간에 주고 받는 이 같은 구조를 이해하고 있어야 추후 성능을 개선할 때 방법을 찾을 수 있다.



참고도서 : https://roadbook.co.kr/169

 

[신간안내] 자바 웹 프로그래밍 Next Step

● 저자: 박재성 ● 페이지: 480 ● 판형: 사륙배변형(172*225) ● 도수: 1도 ● 정가: 30,000원 ● 발행일: 2016년 9월 19일 ● ISBN: 978-89-97924-24-0 93000 [강컴] [교보] [반디] [알라딘] [예스24] [인터파크] [샘

roadbook.co.kr