스프링을 사용하지 않고 순수 자바로 HTTP 서버를 구현해 보자
📌 서론
개발을 하다 보면 필수적으로 http 프로토콜을 사용하게 된다. 스프링 부트를 사용해서 백엔드 개발을 한다면 @Controller와 @RequestMapping 조합을 통해 http 요청을 간편하게 처리할 수 있다. 이것은 스프링이 http 요청 처리를 추상화해서 지원하기 때문인데 스프링은 이 기능을 dispatcherServlet를 사용해서 지원한다. (고수준 처리)
dispatcherServlet은 http요청을 직접적으로 받는 것이 아니라 tomcat으로부터 HttpServletRequest객체를 받은 다음 HandlerMapping을 통해 요청에 적절한 컨트롤러를 찾아서 매핑하고 처리하도록 구성되어 있다.
내가 궁금했던 것은 스프링의 http요청 처리방식(dispatcherServlet의 동작방식)이 아니라 tomcat의 동작이었다. 톰캣은 내가 보내는 http요청을 가장 먼저 받아서 1차적으로 처리를 한 다음 그 데이터를 HttpServletRequest로 만들어서 spring의 dispatcherServlet에서 이것을 사용할 수 있도록 매게 변수로 넣어주는 역할을 한다.
즉, tomcat은 클라이언트의 http 요청을 가장먼저 받아서 처리한다. 이때 tomcat은 BufferedReader, BufferedWriter와 같은 저수준의 자바 I/O 클래스들을 사용하여 HTTP 요청과 응답을 처리한다. (저수준 처리)
나는 이 궁금증을 해소하기 위해 직접 http요청 처리 서버를 구현해 보기로 마음먹었고 간단하게 코드를 작성했다. 이번 포스트에서는 이 코드를 소개함과 동시에 내가 만든 http서버에 요청을 보냈을 때 만난 오류와 해결방법을 함께 소개한다.
작성한 코드는 아래의 github에서 코드를 다운로드한 후 [http] 패키지를 확인해주시면 됩니다.👇🏻👇🏻
1. 자바 코드만으로 서버 구현하고 http 요청 보내기
자바를 사용하여 서버 클래스 구현하기
- 내가 구현한 SimpleHttpServer는 Java의 ServerSocket을 사용하여 기본적인 HTTP 서버를 구현하는 예시로, 스프링과 같은 프레임워크가 제공하는 추상화 없이 HTTP 통신을 어떻게 처리할 수 있는지 보여준다.
package com.study.blog.http;
import java.io.*;
import java.net.*;
/**
* SimpleHttpServer (서버 측)
* * 이 코드는 자바의 ServerSocket을 사용하여 간단한 HTTP 서버를 구현한다.
* 서버는 8080 포트에서 클라이언트의 연결을 기다리고, 요청을 받으면 이를 처리한 후 "Hello World!"라는 응답을 보낸다.
* 이 서버는 HTTP 프로토콜의 기본적인 개념을 이해하고 있어야 하며, 클라이언트의 요청을 직접 파싱하고 응답을 구성해야 한다.
*/
public class SimpleHttpServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080); // 8080 포트에서 서버 시작
System.out.println("Listening for connection on port 8080 ....");
while (true) {
try (Socket clientSocket = serverSocket.accept()) {
InputStreamReader isr = new InputStreamReader(clientSocket.getInputStream());
BufferedReader reader = new BufferedReader(isr);
String line = reader.readLine();
while (!line.isEmpty()) {
System.out.println(line);
line = reader.readLine();
}
// HTTP 응답 생성
String httpResponse = "HTTP/1.1 200 OK\r\n\r\n" + "Hello World!";
clientSocket.getOutputStream().write(httpResponse.getBytes("UTF-8"));
}
}
}
}
코드를 실행한 뒤 postman에서 localhost:8080에 get으로 http요청을 보냈다.
- 근데 갑자기 에러가 발생했는데 공포의 NPE였다..
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.isEmpty()" because "line" is null
at com.study.blog.http.SimpleHttpServer.main(SimpleHttpServer.java:24)
FAILURE: Build failed with an exception.
내가 요청을 잘못 보낸 건가 싶어 요청 url을 변경해서 다시 시도했다. (에러 분석은 아래에 있다.)
- localhost = 127.0.0.1이라는 것을 알고 있었기에 이번에는 127.0.0.1로 postman에 요청을 보냈다.
그랬더니 아래와 같이 성공했다
GET / HTTP/1.1
User-Agent: PostmanRuntime/7.35.0
Accept: */*
Postman-Token: 5ffbd8bd-10c7-434f-87e4-86f12b158995
Host: 127.0.0.1:8080
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
- 200 Ok가 나오면서 "Hello World!"라는 응답을 받은 것을 확인할 수 있다.
📌 궁금증
2. localhost를 사용한 http 요청에서 발생한 NPE 에러 파악하기
가장 먼저 시스템의 호스트 파일을 확인했다.
- localhost가 127.0.0.1로 올바르게 매핑되어 있는지 확인을 해봤다. 호스트 파일의 위치는 운영 체제에 따라 다르지만, 일반적으로 다음 위치에 있다. (macOS/Linux기준)
/etc/hosts
터미널(iterm)에서 hosts 파일 편집모드로 열기
sudo vim /etc/hosts
hosts 파일 내용 살펴보기
- /etc/hosts 파일 내용을 살펴보면, localhost는 올바르게 127.0.0.1과 ::1 (IPv6 주소)에 매핑되어 있다. 파일 내용을 확인해본 결과 지금 발생한 localhost와 127.0.0.1 주소 간의 문제는 hosts 파일의 설정 관련 문제는 아닌 것 같다.
##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting. Do not change this entry.
##
127.0.0.1 localhost
255.255.255.255 broadcasthost
::1 localhost
hosts에는 문제가 없었으니 다시 한번 로그를 확인해 보자
- 로그를 보면, SimpleHttpServer는 localhost:8080으로 요청을 받았을 때 NullPointerException 오류를 발생시키고 있다.
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.isEmpty()" because "line" is null
at com.study.blog.http.SimpleHttpServer.main(SimpleHttpServer.java:24)
FAILURE: Build failed with an exception.
NullPointerException 오류 분석하기
- 이 오류 메시지는 line 변수가 null이기 때문에 isEmpty() 메서드를 호출할 수 없다는 것을 의미한다. 이는 클라이언트로부터의 연결이 종료되었거나 요청이 제대로 완료되지 않았음을 나타내는 것이다.
while (!line.isEmpty()) {
System.out.println(line);
line = reader.readLine();
}
해결 방법
- SimpleHttpServer에서 발생한 NullPointerException 오류는 클라이언트로부터의 연결이 예상치 못하게 끊겼을 때 line 변수가 null이 되면서 발생한 것이라고 판단했다. 지금 같은 경우에는 line 변수가 null인지 여부를 먼저 확인한 후에 isEmpty() 메서드를 호출해야 한다. 이렇게 하면 NullPointerException 오류를 방지할 수 있다.
3. 이제 NPE 에러를 해결해 보자
NPE가 발생한 부분의 코드 수정하기
- 아래와 같이 코드를 수정해서 line이 null인 경우 isEmpty() 메서드를 호출하지 않도록 해서, NullPointerException 오류를 방지할 수 있었다. 서버가 클라이언트로부터 정상적인 HTTP 요청을 받지 못하고 연결이 끊기더라도 이제 오류 없이 다음 연결을 기다릴 수 있게 되었다. (기존 코드에서 while문 코드만 아래의 코드로 수정했다.)
while (line != null && !line.isEmpty()) {
System.out.println(line);
line = reader.readLine();
}
postman을 통해 localhost:8080으로 다시 요청한 결과 (성공)
GET / HTTP/1.1
User-Agent: PostmanRuntime/7.35.0
Accept: */*
Postman-Token: 996e42cb-1704-42c6-a221-63db4315447d
Host: localhost:8080
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
- 아래와 같이 localhost로 보낸 요청도 성공하는 것을 확인할 수 있었다.
📌 궁금증
localhost와 127.0.0.1은 같은 것이 아닌가? 근데 왜 localhost로 요청 보내면 실패하고 127.0.0.1로 보내면 성공하는 건지 이해가 가지 않았다. 그래서 postman 대신 자바를 이용하여 http 요청을 보내는 클라이언트를 코드를 만들어서 실행해 봤다.
4. 자바로 http 요청 클라이언트 만들어서 테스트하기
클라이언트 호출 코드를 작성했다. (localhost:8080으로 요청을 보냄)
package com.study.blog.http;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
/**
* HttpClientExample (클라이언트 측)
* * 이 코드는 자바 11에서 도입된 HttpClient를 사용하여 HTTP 요청을 보내는 클라이언트를 구현해.
* 클라이언트는 특정 URL(http://example.com)로 GET 요청을 보내고, 서버로부터 받은 응답을 처리해.
* 이 클라이언트는 서버와의 통신을 위해 HTTP 요청을 구성하고, 응답을 받아 그 내용을 출력해.
*/public class HttpClientExample {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8080"))
.GET() // GET 요청
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body());
}
}
서버 측 코드를 NPE 발생 이전으로 되돌린다.
- NPE발생 이전 코드에서는 localhost가 아닌 127.0.0.1로 요청을 보냈을 때 NPE가 발생하지 않고 제대로 응답을 줬기 때문에 이번 테스트에서도 동일한 환경으로 만들어 준다. (NPE를 해결했던 코드를 테스트를 위해 다시 복구한다.)
while (!line.isEmpty()) {
System.out.println(line);
line = reader.readLine();
}
자바로 만든 클라이언트 코드를 실행
- 자바 클라이언트를 실행해서 localhost:8080로 요청을 보냈더니 다음과 같은 결과가 나왔다.(성공)
GET / HTTP/1.1
Connection: Upgrade, HTTP2-Settings
Content-Length: 0
Host: localhost:8080
HTTP2-Settings: AAEAAEAAAAIAAAABAAMAAABkAAQBAAAAAAUAAEAA
Upgrade: h2c
User-Agent: Java-http-client/17.0.9
📌 궁금증
굉장히 이상하고 이해가 가지 않는 상황이었다. 자바로 요청 클라이언트를 만들고 실행하면 null처리하는 코드를 추가하지 않아도 localhost:8080 호출에 대해서 문제가 발생하지 않고 응답한다. 대체 뭐가 다른 걸까? 내가 CS 지식이 부족해서 http 요청에 대한 이해가 부족한 걸까? 요청 헤더를 비교해 보면서 다시 생각해 보기로 했다.
5. Postman과 Java 클라이언트 요청의 차이점
HTTP 프로토콜 버전
Postman 요청: HTTP/1.1을 사용. 이는 표준적인 웹 통신 프로토콜이며, 대부분의 웹 서비스에서 널리 사용됨.
Java 요청: HTTP/1.1 기반으로 시작하지만, Upgrade: h2c 헤더를 통해 HTTP/2로 업그레이드를 시도함. h2c는 비암호화된 HTTP/2 연결을 의미함.
업그레이드 시도
Postman: HTTP/2 업그레이드를 시도하지 않음.
Java: HTTP/2 업그레이드를 시도하는데, 이는 Java HttpClient가 기본적으로 HTTP/2 지원을 시도하기 때문
서버에서의 처리 방식
- 내가 구현한 SimpleHttpServer는 매우 기본적인 HTTP 서버로 HTTP/1.1 프로토콜만을 지원하므로, 클라이언트가 HTTP/2로의 업그레이드를 시도하더라도 이를 무시하고 HTTP/1.1로 응답한다.
- 이러한 서버의 동작 방식은 클라이언트가 HTTP/2를 요청하더라도 실제 통신은 HTTP/1.1을 사용하여 이루어짐을 의미한다.
결론적인 이해
Postman: HTTP/1.1 요청을 그대로 전송하며, 이는 내가 만든 서버가 처리할 수 있는 표준 프로토콜이다.
Java: HTTP/1.1을 사용하지만, HTTP/2로의 업그레이드를 시도한다. 하지만 내가 만든 서버가 HTTP/2를 지원하지 않기 때문에, 실제 통신은 HTTP/1.1을 사용해 이루어진다.
내가 만든 서버: HTTP/2 특징이나 복잡한 처리를 지원하지 않으므로, 모든 요청을 HTTP/1.1로 처리한다. 그러므로 실제로 클라이언트가 어떤 HTTP 버전을 요청하든 간에 서버의 응답은 HTTP/1.1 기반으로 이루어진다.
📌 궁금증
Postman 요청이 localhost일 때만 오류가 발생하고 127.0.0.1에서는 성공하는 것은 로컬 네트워크 설정이나 DNS 해석의 차이 때문일 수 있다. 이는 서버 코드의 문제가 아니라, 네트워크 환경 또는 Postman의 내부 구성에 의한 것일 수 있다는 의미다.
6. 서버 코드를 수정해서 헤더를 좀 더 자세히 살펴보자
기존 서버(SimpleHttpServer)에 아래의 코드를 추가했다.
// 요청 헤더 출력
System.out.println("Request Header: \n" + requestBuilder.toString());
이번에도 postman, 자바 클라이언트 요청 둘 다 NPE 처리 이전 코드를 사용했다.
while (!line.isEmpty()) {
requestBuilder.append(line).append("\n");
line = reader.readLine();
}
자바 클라이언트로 요청을 보냈을 때 결과는 다음과 같다. (여전히 성공)
Listening for connection on port 8080 ....
Connection accepted: Socket[addr=/127.0.0.1,port=53643,localport=8080]
Request Header:
GET / HTTP/1.1
Connection: Upgrade, HTTP2-Settings
Content-Length: 0
Host: localhost:8080
HTTP2-Settings: AAEAAEAAAAIAAAABAAMAAABkAAQBAAAAAAUAAEAA
Upgrade: h2c
User-Agent: Java-http-client/17.0.9
postman으로 요청을 보냈을 때는 다음과 같다. (또 오류 발생)
Listening for connection on port 8080 ....
Connection accepted: Socket[addr=/127.0.0.1,port=53643,localport=8080]
Request Header:
GET / HTTP/1.1
Connection: Upgrade, HTTP2-Settings
Content-Length: 0
Host: localhost:8080
HTTP2-Settings: AAEAAEAAAAIAAAABAAMAAABkAAQBAAAAAAUAAEAA
Upgrade: h2c
User-Agent: Java-http-client/17.0.9
Connection accepted: Socket[addr=/0:0:0:0:0:0:0:1,port=53692,localport=8080]
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.isEmpty()" because "line" is null
이번에는 지난번 실패했을 때보다 좀 더 자세한 출력 결과가 있으니 다시 분석해 봤다.
Spring 요청 (성공)
- 요청은 127.0.0.1 (IPv4 주소)에서 수신된다.
- HTTP/1.1 요청으로, HTTP/2로의 업그레이드를 시도하는 헤더가 포함되어 있다.
- 서버 코드가 이 요청을 정상적으로 처리한다.
Postman 요청 (오류 발생)
- 첫 번째 요청은 127.0.0.1 (IPv4 주소)에서 수신되며, 동일한 HTTP/1.1 요청과 헤더로 처리된다. 이어서 두 번째 요청이 0:0:0:0:0:0:0:1 (IPv6 주소)에서 수신된다. 이때 NullPointerException 오류가 발생했다.
원인 분석
- Postman이 요청을 두 번 보내는 것은 예상치 못한 행동으로, 첫 번째 요청은 성공적으로 처리되지만, 두 번째 요청에서 line 변수가 null이 되면서 오류가 발생한다.
- Postman 요청의 두 번째 수신이 왜 발생하는지는 명확하지 않지만, 이는 Postman의 네트워크 처리 방식, 특히 localhost를 IPv6 주소로 해석하는 방식과 관련이 있을 수 있다.
해결 방향 및 디버깅
- 서버 코드의 while 루프를 수정하여 line이 null인 경우를 처리해야 한다 (while (line != null && !line.isEmpty())). 그리고 Postman 설정을 검토하고, 왜 두 번째 요청이 발생하는지 파악해야 할 필요가 있다.
📌 결론
일반적으로, 클라이언트가 하나의 요청을 두 번 보내는 것은 비정상적이므로, Postman 설정이나 네트워크 환경에 대한 추가적인 조사가 필요하다고 판단했고 커뮤니티를 찾아봤다.
7. postman의 비정상적인 localhost 호출 이유
커뮤니티를 확인한 결과
- 이 현상은 Postman의 네트워크 처리 방식과 관련이 있는 것 같다. 조금 검색해 보니 일부 사용자들에게 같은 현상이 발생했는데 Postman을 사용할 때 localhost 주소로 요청을 보내면, Postman이 먼저 IPv6 주소 (::1)로 연결을 시도한 다음, IPv4 주소 (127.0.0.1)로 연결을 시도하는 경우가 있다는 것이었다.
이는 RFC 3484에 따라 시스템이 IPv6 연결을 우선시하고, 실패하면 IPv4 연결을 시도하는 것과 관련이 있을 수 있다고 한다.
- 이 문제에 대한 해결책으로는, 몇몇 사용자들이 localhost 대신 명시적으로 127.0.0.1 주소를 사용하는 것이 효과적이었다고 한다. 이렇게 하면 Postman이 IPv4 주소로 직접 요청을 보내게 되어, IPv6 관련 문제를 우회할 수 있다고 적혀있었다. 따라서 내가 겪고 있는 문제를 해결하기 위해서는 Postman에서 요청을 보낼 때 localhost 대신 127.0.0.1 주소를 사용해서 요청을 하거나 while문 조건에 NPE 예외처리를 하는 방식을 사용해야만 할 것 같다.
📌 결론
postman에서 내부적으로 고쳐주지 않으면 어쩔 수 없는 문제인 것 같다. 하지만 해결 방법이 존재하거나 내가 잘못 알아본 것일 수도 있으니 이 글을 읽는 누군가 원인과 해결방법을 알고 있다면 공유해 주셨으면 좋겠다.
참조한 커뮤니티 링크
이 글이 재밌었다면 다음 포스트를 통해 GET/POST/PUT 요청별 분기처리를 하여 서버 코드를 고도화시킨 내용을 확인해보자
'JAVA' 카테고리의 다른 글
[JMeter] MacOS(M1)에서 JMeter를 이용한 부하 테스트 (26) | 2024.01.01 |
---|---|
[Java] HTTP 서버 만들기: GET, POST, PUT 요청별 처리 (28) | 2023.12.30 |
[Java] Optional로 Null 처리하기 (28) | 2023.12.28 |
[Java] 자바 리플렉션(Reflection) 실습하기 (1) | 2023.11.18 |
[Java] 자바 리플렉션(reflection)이란? (1) | 2023.11.15 |