JAVA

[Java] HTTP 서버 만들기: GET, POST, PUT 요청별 처리

Stark97 2023. 12. 30. 23:23
반응형
 
 
 

자바로 GET, POST, PUT 요청에 대해 각각 처리가 가능한 HTTP 서버를 만들어 보자

📌 서론

저번 포스트에서 Java만을 사용하여 간단하게 HTTP 서버를 구축해 봤는데 이번에는 그 서버 코드를 고도화시켜서 GET, POST, PUT 요청에 따라 유형별로 각각 처리하도록 만들어 봤다.

이전에 작성한 HTTP 서버를 확인하려면 아래의 포스트를 보고 오는 것을 추천한다.👇🏻👇🏻

 

[Java] HTTP 서버 구현: postman과 자바 HttpClient를 사용한 요청

스프링을 사용하지 않고 순수 자바로 HTTP 서버를 구현해 보자 📌 서론 개발을 하다 보면 필수적으로 http 프로토콜을 사용하게 된다. 만약 스프링을 통해 백엔드 개발을 한다면 @Controller와 @Request

curiousjinan.tistory.com

 

1. GET, POST, PUT 요청을 유형별로 처리하는 HTTP 서버 코드 작성

코드에 대한 간략한 설명

  • 이 코드는 간단한 HTTP 서버를 Java로 구현한 것이다. 서버는 8080 포트에서 클라이언트의 연결을 기다리고, 연결이 성립되면 클라이언트로부터 HTTP 요청을 받는다. 요청은 `GET`, `POST`, `PUT` 메서드 중 하나일 수 있으며, 서버는 요청 유형에 따라 다르게 처리한다. 

  • `GET` 요청은 요청된 경로에 대한 응답을 반환하고, `POST`와 `PUT` 요청은 본문 데이터를 처리한다. 서버는 클라이언트에게 UTF-8 인코딩을 사용한 응답을 전송하여 다국어 지원을 보장한다.
package com.study.blog.http;  

import java.io.*;  
import java.net.*;  
import java.nio.charset.StandardCharsets;  
import java.util.HashMap;  
import java.util.Map;  

public class FullHttpServer {  
    public static void main(String[] args) throws IOException {  
        ServerSocket serverSocket = new ServerSocket(8080);  
        System.out.println("Listening for connection on port 8080 ....");  

        while (true) {  
            try (Socket clientSocket = serverSocket.accept()) {  
                BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));  
                BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()));  

                // 여기서 먼저 null 처리를 해줘야함  
                String line = reader.readLine();  
                if (line == null) {  
                    continue; // 요청이 비어 있으면 다음 연결을 기다림  
                }  

                String[] requestLineParts = line.split(" ");  
                String httpMethod = requestLineParts[0];  
                String requestPath = requestLineParts[1];  

                Map<String, String> headers = new HashMap<>();  
                while ((line = reader.readLine()) != null) {  
                    if (line.isEmpty()) break;  

                    String[] header = line.split(": ");  
                    headers.put(header[0], header[1]);  
                }  

                // 헤더 정보 출력  
                System.out.println("HTTP Headers:");  
                for (Map.Entry<String, String> header : headers.entrySet()) {  
                    System.out.println(header.getKey() + ": " + header.getValue());  
                }  

                String responseBody = "";  
                if ("GET".equals(httpMethod)) {  
                    responseBody = handleGetRequest(requestPath);  
                } else if ("POST".equals(httpMethod)) {  
                    responseBody = handlePostOrPutRequest(reader, headers);  
                } else if ("PUT".equals(httpMethod)) {  
                    responseBody = handlePostOrPutRequest(reader, headers);  
                }  

                // 인코딩을 추가해줘야 한국어가 잘 받아진다.  
                String httpResponse = "HTTP/1.1 200 OK\r\n" +  
                        "Content-Length: " + responseBody.getBytes(StandardCharsets.UTF_8).length + "\r\n" +  
                        "Content-Type: text/plain; charset=UTF-8\r\n" +  
                        "\r\n" +  
                        responseBody;  

                writer.write(httpResponse);  
                writer.flush();  

            } catch (IOException e) {  
                e.printStackTrace();  
            }  
        }  
    }  

    private static String handleGetRequest(String path) {  
        // GET 요청 처리 로직 (한국어를 넣으면 postman이 응답을 못함.. 이건 또 뭐야)  
        return "GET 요청에 대한 응답 : " + path;  
    }  

    private static String handlePostOrPutRequest(BufferedReader reader, Map<String, String> headers) throws IOException {  
        // POST와 PUT 요청 본문 처리 로직  
        int contentLength = Integer.parseInt(headers.getOrDefault("Content-Length", "0"));  
        StringBuilder requestBody = new StringBuilder();  
        for (int i = 0; i < contentLength; i++) {  
            requestBody.append((char) reader.read());  
        }  
        return "Received data: " + requestBody.toString();  
    }  
}

2. 서버 소켓 및 연결 처리

서버 시작

  • 여기서 ServerSocket은 서버 측 소켓으로, 특정 포트(8080)에서 클라이언트의 연결 요청을 기다린다. 이 객체는 서버가 클라이언트의 요청을 받을 준비가 되었음을 의미한다.
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("Listening for connection on port 8080 ....");

📌 ServerSocket 객체

ServerSocket은 서버 측에서 네트워크 연결을 수신하기 위한 소켓이다. 이 객체는 네트워크를 통해 들어오는 클라이언트의 연결 요청을 기다리는 데 사용된다. 내 코드에서 ServerSocket 객체는 특정 포트(예: 8080)에 바인딩되어, 해당 포트로 들어오는 연결 요청을 감지한다.

 

연결 수락 및 스트림 생성

  • serverSocket.accept()는 클라이언트의 연결 요청을 대기하다가 연결이 성립되면 Socket 객체를 반환한다. 그럼 생성된 Socket을 통해 서버와 클라이언트는 데이터를 주고받을 수 있게 된다.
  • BufferedReader와 BufferedWriter는 각각 클라이언트로부터의 입력 데이터를 읽고, 클라이언트에게 데이터를 쓴다.
while (true) {  
   try (Socket clientSocket = serverSocket.accept()) {  
       BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));  
       BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()));

📌 Socket 객체

Socket 객체는 서버와 클라이언트 간의 실제 연결을 나타낸다. ServerSocket이 연결 요청을 수락하면, 서버와 클라이언트 간의 통신을 가능하게 하는 Socket 객체가 생성된다.

📌 BufferedReader와 BufferedWriter란?

BufferedReader

InputStreamReader를 감싸고, 클라이언트로부터 들어오는 데이터를 읽는다. 이 클래스는 데이터를 버퍼링함으로써 네트워크에서 데이터를 읽는 것을 최적화한다. 버퍼링은 여러 번의 작은 읽기 연산을 하나의 큰 연산으로 결합시켜 성능을 향상시킨다.

 

BufferedWriter

OutputStreamWriter를 감싸고, 서버로부터 클라이언트에게 데이터를 쓴다. BufferedWriter 역시 버퍼링을 사용하여 데이터 쓰기 연산의 효율을 높인다.

 

3. HTTP 요청 처리

요청 분석

  • reader.readLine()은 클라이언트로부터 전송된 데이터의 첫 번째 줄을 읽는다. 이 줄에는 HTTP 메서드와 요청 경로가 포함되어 있다. 이후 line.split(" ")를 사용하여 메서드와 경로를 분리하는 작업을 진행한다.
String line = reader.readLine();  
if (line == null) {  
   continue; // 요청이 비어 있으면 다음 연결을 기다림  
}  
String[] requestLineParts = line.split(" ");  
String httpMethod = requestLineParts[0];  
String requestPath = requestLineParts[1];

HTTP 헤더 처리

  • 헤더의 정보를 Map에 저장한다. 각 헤더는 키와 값으로 분리되어 저장된다. line.isEmpty()는 헤더의 끝을 나타내는 빈 줄(EOL:End Of Line)을 확인한다.
Map<String, String> headers = new HashMap<>();  
while ((line = reader.readLine()) != null) {  
 if (line.isEmpty()) break;  
 String[] header = line.split(": ");  
 headers.put(header[0], header[1]);  
}

 

4. GET, POST, PUT 처리 및 응답 생성

요청 타입에 따른 처리

  • GET, POST, PUT 요청은 각각 다른 방식으로 처리되도록 작성했다. 예를 들어 GET 요청은 단순히 요청 경로에 대한 정보를 반환하고, POST와 PUT 요청은 요청의 본문을 처리하도록 했다.
String responseBody = "";  
if ("GET".equals(httpMethod)) {  
   responseBody = handleGetRequest(requestPath);  
} else if ("POST".equals(httpMethod)) {  
   responseBody = handlePostOrPutRequest(reader, headers);  
} else if ("PUT".equals(httpMethod)) {  
   responseBody = handlePostOrPutRequest(reader, headers);  
}

위의 분기처리별로 사용하는 extract Method 해석하기

handleGetRequest 메서드

  • 이 메서드는 GET 요청을 처리한다. 요청된 경로(path)에 대한 정보를 단순히 문자열 형태로 반환하도록 했다.
private static String handleGetRequest(String path) {  
    // GET 요청 처리 로직 (한국어를 넣으면 postman이 응답을 못함.. 이건 또 뭐야)  
    return "GET 요청에 대한 응답 : " + path;  
}

handlePostOrPutRequest 메서드

  • POST와 PUT 요청을 처리한다. 이 메서드는 요청 헤더에서 Content-Length를 읽어 요청 본문의 길이를 결정하고, 그 길이만큼 본문 데이터를 읽어 처리한다.
private static String handlePostOrPutRequest(BufferedReader reader, Map<String, String> headers) throws IOException {  
    // POST와 PUT 요청 본문 처리 로직  
    int contentLength = Integer.parseInt(headers.getOrDefault("Content-Length", "0"));  
    StringBuilder requestBody = new StringBuilder();  
    for (int i = 0; i < contentLength; i++) {  
        requestBody.append((char) reader.read());  
    }  
    return "Received data: " + requestBody.toString();  
}

HTTP 응답 전송

  • 이 부분에서는 서버가 클라이언트에 응답을 전송한다. Content-Type 헤더에 UTF-8 인코딩을 명시하여 클라이언트가 응답을 정확하게 해석할 수 있도록 한다.
String httpResponse = "HTTP/1.1 200 OK\r\n" +  
                     "Content-Length: " + responseBody.getBytes(StandardCharsets.UTF_8).length + "\r\n" +  
                     "Content-Type: text/plain; charset=UTF-8\r\n" +  
                     "\r\n" +  
                     responseBody;  
writer.write(httpResponse);  
writer.flush();

 

추가정보: UTF-8 인코딩을 명시해야 하는 이유

바로 위에서 봤던 코드이지만 한 번만 더 살펴보자

String httpResponse = "HTTP/1.1 200 OK\r\n" +  
                     "Content-Length: " + responseBody.getBytes(StandardCharsets.UTF_8).length + "\r\n" +  
                     "Content-Type: text/plain; charset=UTF-8\r\n" +  
                     "\r\n" +  
                     responseBody;  
writer.write(httpResponse);  
writer.flush();

Content-Type 헤더

  • 이 헤더는 클라이언트에게 응답의 내용 유형을 알려준다. 예를 들어, text/plain은 응답이 일반 텍스트임을 나타낸다. 여기서 charset=UTF-8은 해당 텍스트의 문자 인코딩이 UTF-8 임을 명시하는 것이다.

  • UTF-8 인코딩은 다국어 문자를 지원하고 널리 사용되는 인코딩 방식이다. 또한 한국어와 같은 멀티바이트 문자도 정확하게 표현할 수 있다는 장점을 가지고 있어서 주로 사용한다.

 

응답의 인코딩 방식

"Content-Length: " + responseBody.getBytes(StandardCharsets.UTF_8).length + "\r\n" +
  • responseBody.getBytes(StandardCharsets.UTF_8).length는 응답 본문의 길이를 바이트 단위로 계산한다. 이 길이는 Content-Length 헤더에 설정되며, 클라이언트가 얼마나 많은 데이터를 받을 것인지 알 수 있게 해주는 역할을 한다.

  • 이것을 UTF-8로 인코딩하지 않았더니 문자 데이터가 클라이언트에게 올바르게 전송되지 않는 문제가 발생했다. 영어를 사용해서 response응답을 받았을 때는 문제가 없었지만 한국어로 응답 메시지를 받았더니 비 ASCII 문자라서 그런지 올바른 인코딩이 적용되지 않아서 깨져서 표시되는 현상이 발생했다. 또한 postman으로 요청을 보냈을 때는 응답 자체를 하지 못하고 오류가 발생했다.

 

UTF-8 인코딩을 설정하지 않고 요청을 보내보자 (오류 상황 테스트)

  • 기존의 httpResponse 코드를 아래와 같이 바꿔준다. (위의 코드를 보면 기존에는 content-type 설정이 존재)
// response에 content-type을 제거한다.
String httpResponse = "HTTP/1.1 200 OK\r\nContent-Length: " + responseBody.length() + "\r\n\r\n" + responseBody;

postman에 요청을 보내면 아래와 같은 결과를 받게 된다. (실패)

요청 결과

기존에 작성했던 content-type=utf-8을 포함시키는 코드로 변경하고 요청을 보낸다. (성공)

  • 요청에 성공해서 body에 내가 작성했던 응답을 제대로 받은 것을 확인할 수 있었다.

요청 결과 성공
요청 결과 성공

📌 결론

content-type에 utf-8 인코딩 세팅을 하지 않으면 클라이언트(예: Postman)는 서버로부터 오는 응답을 제대로 해석하지 못할 수 있다는 것을 알 수 있었다. 결국 content-type 인코딩 설정이 매우 중요한 이유는 잘못된 인코딩이나 Content-Type 설정은 데이터의 손실이나 오류, 클라이언트의 예외 처리 문제(NPE 등)로 이어질 수 있기 때문이다.

 

반응형