[Java] HTTP 서버 만들기: GET, POST, PUT 요청별 처리
자바로 GET, POST, PUT 요청에 대해 각각 처리가 가능한 HTTP 서버를 만들어 보자
📌 서론
저번 포스트에서 Java만을 사용하여 간단하게 HTTP 서버를 구축해 봤는데 이번에는 그 서버 코드를 고도화시켜서 GET, POST, PUT 요청에 따라 유형별로 각각 처리하도록 만들어 봤다.
이전에 작성한 HTTP 서버를 확인하려면 아래의 포스트를 보고 오는 것을 추천한다.👇🏻👇🏻
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 등)로 이어질 수 있기 때문이다.