일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 자바로 키오스크 만들기
- 자바 중급1편 - 날짜와 시간
- 자바의 정석 기초편 ch14
- 람다
- 자바의 정석 기초편 ch1
- 스프링 mvc2 - 로그인 처리
- 데이터 접근 기술
- 스프링 고급 - 스프링 aop
- 스프링 mvc2 - 타임리프
- 스프링 mvc2 - 검증
- 스프링 트랜잭션
- 자바의 정석 기초편 ch2
- 자바로 계산기 만들기
- 스프링 입문(무료)
- 자바의 정석 기초편 ch11
- 자바의 정석 기초편 ch6
- 자바의 정석 기초편 ch12
- 스프링 mvc1 - 스프링 mvc
- 자바 기초
- 자바의 정석 기초편 ch9
- @Aspect
- 자바 고급2편 - io
- 자바 중급2편 - 컬렉션 프레임워크
- 자바의 정석 기초편 ch5
- 자바 고급2편 - 네트워크 프로그램
- 자바의 정석 기초편 ch4
- 자바의 정석 기초편 ch7
- 자바의 정석 기초편 ch13
- 2024 정보처리기사 시나공 필기
- 2024 정보처리기사 수제비 실기
- Today
- Total
개발공부기록
HTTP 서버 만들기, HTTP 서버(시작, 동시 요청, 기능 추가, 요청, 응답, 커맨드 패턴), URL 인코딩, 웹 애플리케이션 서버의 역사 본문
HTTP 서버 만들기, HTTP 서버(시작, 동시 요청, 기능 추가, 요청, 응답, 커맨드 패턴), URL 인코딩, 웹 애플리케이션 서버의 역사
소소한나구리 2025. 3. 4. 15:47출처 : 인프런 - 김영한의 실전 자바 - 고급2편 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
HTTP 서버1
HTTP 서버 - 시작
<h1>Hello World</h1>을 응답해 주는 서버 만들기
웹 브라우저에서 직접 만든 서버에 접속하면 웹 브라우저에 Hello World가 크게 보일 것임
** 참고
- HTML은 <html>, <head>, <body>와 같은 기본 태그를 가지는데 원래는 이런 태그도 함께 포함해서 전달해야 하지만 예제를 단순하게 만들기 위해 최소한의 태그만 사용함
- HTML에 대한 자세한 내용은 강의에서 설명하지 않음
HttpServerV1
- HTTP 메시지의 주요 내용들을 문자로 읽고 쓰는 서버이므로 BufferedReader, BufferedWriter를 사용함
- Stream을 Reader, Writer로 변경할 때는 항상 인코딩을 확인해야 함
package was.v1;
public class HttpServerV1 {
private final int port;
public HttpServerV1(int port) {
this.port = port;
}
public void start() throws IOException {
ServerSocket serverSocket = new ServerSocket(port);
log("서버 시작 port: " + port);
while (true) {
Socket socket = serverSocket.accept();
process(socket);
}
}
private void process(Socket socket) throws IOException {
try (socket;
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {
String requestString = requestToString(reader);
if (requestString.contains("/favicon.ico")) {
log("favicon 요청");
return;
}
log("HTTP 요청 정보 출력");
System.out.println(requestString);
log("HTTP 응답 생성중...");
sleep(5000);
responseToClient(writer);
log("HTTP 응답 전달 완료");
}
}
private void sleep(int millis) {
try {
Thread.sleep(millis); // 서버 처리 시간
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
private String requestToString(BufferedReader reader) throws IOException {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
break;
}
sb.append(line).append("\n");
}
return sb.toString();
}
private void responseToClient(PrintWriter writer) {
// 웹 브라우저에 전달하는 내용
String body = "<h1>Hello World</h1>";
int length = body.getBytes(UTF_8).length;
StringBuilder sb = new StringBuilder();
sb.append("HTTP/1.1 200 OK\r\n");
sb.append("Content-Type: text/html\r\n");
sb.append("Content-Length: ").append(length).append("\r\n");
sb.append("\r\n"); // header, body 구분
sb.append(body);
log("HTTP 응답 정보 출력");
System.out.println(sb);
writer.println(sb);
writer.flush();
}
}
AutoFlush
new PrintWriter(socket.getOutputStream(), false, UTF_8)
- PrintWriter의 두 번째 인자는 autoFlush 여부인데, 이 값을 true로 설정하면 println()으로 출력할 때마다 자동으로 플러시가 되어 첫 내용을 빠르게 전송할 수 있지만 네트워크 전송이 자주 발생함
- 이 값을 false로 설정하면 flush()를 직접 호출해 주어야 데이터를 전송함
- 오토플러시가 false이면 데이터를 버퍼에 모아서 전송하므로 네트워크 전송 횟수를 효과적으로 줄일 수 있어 한 패킷에 많은 양의 데이터를 담아서 전송할 수 있음
- 오토플러시를 false로 설정하면 마지막에 writer.flush()를 호출하여 수동으로 플러시를 해주어야 데이터 전송이 완료됨
requestToString()
HTTP 요청을 읽어서 String으로 반환하며 HTTP 요청의 시작 라인에서 헤더까지 읽음
- line.isEmpty()이면 HTTP 메시지 헤더의 마지막으로 인식하고 메시지 읽기를 종료함
- HTTP 메시지 헤더의 끝은 빈 라인으로 구분할 수 있으며 빈 라인 이후에는 메시지 바디가 나옴
- 여기서는 메시지 바디를 전달하지 않으므로 메시지 바디의 정보는 읽지 않음
/favicon.ico
웹 브라우저에서 해당 사이트의 작은 아이콘을 추가로 요청할 수 있는데 여기서는 사용하지 않으므로 무시하였음
url 앞에 있는 icon이며 서버에서 내려줄 icon이 있다면 응답해 주면 됨
sleep(5000);
예제가 너무 단순해서 응답이 빠르므로 서버에서 요청을 처리하는데 5초의 시간이 걸린다고 가정하기 위함
responseToClient()
HTTP 응답 메시지를 생성해서 클라이언트에 전달하며 시작라인, 헤더, HTTP 메시지 바디를 전달함
HTTP 공식 스펙에서 다음 라인은 \r\n(캐리지 리턴 + 라인피드)로 표현하는데 \n만 사용해도 대부분의 웹 브라우저에서 문제없이 작동함
마지막에 오토플러시가 false이므로 writer.flush()를 호출해서 데이터를 전송함
ServerMainV1
- 해당 서버를 실행하고 브라우저에서 localhost:12345로 접속해 보면 Hello World 문구가 크게 출력되는 것을 확인할 수 있음
- 페이지 소스 보기를 하면 서버에서 내려준 <h1>Hello World</h1> HTML 문구를 확인할 수 있음
package was.v1;
public class ServerMainV1 {
private final static int PORT = 12345;
public static void main(String[] args) throws IOException {
HttpServerV1 server = new HttpServerV1(PORT);
server.start();
}
}
ServerMainV1 출력 로그 - HTTP 요청 메시지
- http://localhost:12345를 요청하면 웹 브라우저가 HTTP 요청 메시지를 만들어서 서버에 전달함
- 시작라인
- GET: GET 메서드 (조회)
- /: 요청 경로, 별도의 요청 경로가 없으면 /를 사용함
- HTTP/1.1: HTTP 버전
- 헤더
- Host: 접속하는 서버 정보
- User-Agent: 웹 브라우저의 정보
- Accept: 웹 브라우저가 전달받을 수 있는 HTTP 응답 메시지 바디 형태
- Accept-Encoding: 웹 브라우저가 전달 받을 수 있는 인코딩 형태
- Accept-Language: 웹 브라우저가 전달 받을 수 있는 언어 형태
17:11:19.097 [ main] 서버 시작 port: 12345
17:11:27.864 [ main] HTTP 요청 정보 출력
GET / HTTP/1.1
Host: localhost:12345
Connection: keep-alive
sec-ch-ua: "Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko,en-US;q=0.9,en;q=0.8
ServerMainV1 출력 로그 - HTTP 응답 메시지
- 시작라인
- HTTP/1.1: HTTP 버전
- 200: 성공
- OK: 200에 대한 설명
- 헤더
- Content-Type: HTTP 메시지 바디의 데이터 형태, 여기서는 HTML을 사용
- Content-Length: HTTP메시지 바디의 데이터 길이
- 바디
- <h1>Hello World</h1>
17:11:27.864 [ main] HTTP 응답 생성중...
17:11:32.870 [ main] HTTP 응답 정보 출력
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 20
<h1>Hello World</h1>
17:11:32.872 [ main] HTTP 응답 전달 완료
17:11:32.955 [ main] favicon 요청
17:11:32.956 [ main] favicon 요청
17:13:56.314 [ main] favicon 요청
** 참고
- 요청 메시지와 응답 메시지에 대한 자세한 내용은 https://nagul2.tistory.com/manage/posts?category=696431의 글들을 참조
문제
서버는 동시에 수많은 사용자의 요청을 처리할 수 있어야 하는데 현재 서버는 한 번에 하나의 요청만 처리할 수 있는 문제가 있음
다른 웹 브라우저 2개를 동시에 열어서 localhost:12345로 접속해 보면 첫 번째 요청이 모두 처리되고 나서 두 번째 요청이 되는 것을 확인할 수 있음
HTTP 서버 - 동시 요청
스레드를 사용해서 동시에 여러 요청을 처리할 수 있도록 서버를 개선
HttpRequestHandlerV2
package was.v2;
public class HttpRequestHandlerV2 implements Runnable {
private final Socket socket;
public HttpRequestHandlerV2(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
process();
} catch (Exception e) {
log(e);
}
}
private void process() throws IOException {
// ... 내부 코드 동일, process 메서드의 인자는 제거
}
// 기존 메서드의 코드 동일
}
- 이름 그대로 클라이언트가 전달한 HTTP 요청을 처리하는 HttpRequestHandlerV2 클래스
- Runnable을 구현하여 동시에 요청한 수만큼 별도의 스레드에서 HttpRequestHandler가 수행됨
- 기존에 HttpServerV2에 있는 메서드들이 여기에 작성됨
HttpServerV2
package was.v2;
public class HttpServerV2 {
private final ExecutorService es = Executors.newFixedThreadPool(10);
private final int port;
public HttpServerV2(int port) {
this.port = port;
}
public void start() throws IOException {
ServerSocket serverSocket = new ServerSocket(port);
log("서버 시작 port: " + port);
while (true) {
Socket socket = serverSocket.accept();
es.submit(new HttpRequestHandlerV2(socket));
}
}
}
- ExecutorService: 스레드 풀을 사용함, 예제이므로 newFixedThreadPool(10)을 사용하여 최대 10개의 스레드를 사용할 수 있으므로 10개의 요청을 동시에 처리할 수 있음
- 상황에 따라 다르겠지만 실무에서는 보통 수백 개 정도의 스레드를 사용함
- es.submit(new HttpRequestHandlerV2(socket)): 스레드 풀에 HttpRequestHandlerV2 작업을 요청하면 스레드 풀에 있는 스레드가 HttpRequestHandlerV2의 run()을 수행함
** 참고
- ExecutorService는 편리하게 스레드를 생성하고 관리해 줌
- 자세한 내용은 https://nagul2.tistory.com/435를 참고
ServerMainV2
package was.v2;
public class ServerMainV2 {
private final static int PORT = 12345;
public static void main(String[] args) throws IOException {
HttpServerV2 server = new HttpServerV2(PORT);
server.start();
}
}
- HttpServerV2를 사용하도록 수정하고 실행하면 이제는 각 클라이언트의 요청을 별도의 스레드에서 처리하게 되므로 각 클라이언트의 요청을 동시에 처리할 수 있음
- 다른 브라우저 2개를 동시에 열어서 사이트를 실행해 보면 5초가 지난 후 서버의 응답이 오는 것을 확인할 수 있음
실행 결과
- 실행 결과를 보면 여러 스레드가 동시에 실행되는 것을 확인할 수 있음
12:54:12.174 [ main] 서버 시작 port: 12345
12:54:15.022 [pool-1-thread-1] HTTP 요청 정보 출력
GET / HTTP/1.1
Host: localhost:12345
...
12:54:15.023 [pool-1-thread-1] HTTP 응답 생성중...
12:54:15.522 [pool-1-thread-2] HTTP 요청 정보 출력
GET / HTTP/1.1
Host: localhost:12345
...
12:54:15.523 [pool-1-thread-2] HTTP 응답 생성중...
12:54:20.032 [pool-1-thread-1] HTTP 응답 정보 출력
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 20
<h1>Hello World</h1>
12:54:20.034 [pool-1-thread-1] HTTP 응답 전달 완료
12:54:20.523 [pool-1-thread-2] HTTP 응답 정보 출력
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 20
<h1>Hello World</h1>
12:54:20.524 [pool-1-thread-2] HTTP 응답 전달 완료
12:54:20.561 [pool-1-thread-3] favicon 요청
12:54:20.563 [pool-1-thread-4] favicon 요청
HTTP 서버 - 기능 추가
HTTP 서버들은 URL 경로를 사용해서 각각의 기능을 제공함
직접 만든 HTTP 서버에 아래의 기능이 동작하도록 코드를 작성해 보기
- home: /, 첫 화면
- site1: /site1, 페이지 화면1
- site2: /site2, 페이지 화면2
- search: /search 기능 검색 화면, 클라이언트에서 서버로 검색어를 전달
- notFound: 잘못된 URL을 호출했을 때 전달하는 화면
HttpRequestHandlerV3
package was.v3;
public class HttpRequestHandlerV3 implements Runnable {
private final Socket socket;
public HttpRequestHandlerV3(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
process();
} catch (Exception e) {
log(e);
}
}
private void process() throws IOException {
try (socket;
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {
String requestString = requestToString(reader);
if (requestString.contains("/favicon.ico")) {
log("favicon 요청");
return;
}
log("HTTP 요청 정보 출력");
System.out.println(requestString);
log("HTTP 응답 생성중...");
if (requestString.startsWith("GET /site1")) {
site1(writer);
} else if (requestString.startsWith("GET /site2")) {
site2(writer);
} else if (requestString.startsWith("GET /search")) {
search(writer, requestString);
} else if (requestString.startsWith("GET / ")) {
home(writer);
} else {
notFound(writer);
}
log("HTTP 응답 전달 완료");
}
}
private void home(PrintWriter writer) {
// 원칙적으로 Content-Length를 계산해서 전달해야 하지만 예제를 단순하게 설명하기 위해 생략하였음
writer.println("HTTP/1.1 200 OK");
writer.println("Content-Type: text/html; charset=UTF-8");
writer.println();
writer.println("<h1>home</h1>");
writer.println("<ul>");
writer.println("<li><a href='/site1'>site1</a></li>");
writer.println("<li><a href='/site2'>site2</a></li>");
writer.println("<li><a href='/search?q=hello'>검색</a></li>");
writer.println("</ul>");
writer.flush();
}
private void site1(PrintWriter writer) {
writer.println("HTTP/1.1 200 OK");
writer.println("Content-Type: text/html; charset=UTF-8");
writer.println();
writer.println("<h1>site1</h1>");
writer.flush();
}
private void site2(PrintWriter writer) {
writer.println("HTTP/1.1 200 OK");
writer.println("Content-Type: text/html; charset=UTF-8");
writer.println();
writer.println("<h1>site2</h1>");
writer.flush();
}
private void notFound(PrintWriter writer) {
writer.println("HTTP/1.1 404 Not Found");
writer.println("Content-Type: text/html; charset=UTF-8");
writer.println();
writer.println("<h1>404 페이지를 찾을 수 없습니다.</h1>");
writer.flush();
}
private void search(PrintWriter writer, String requestString) {
int startIndex = requestString.indexOf("q=");
int endIndex = requestString.indexOf(" ", startIndex + 2);
String query = requestString.substring(startIndex + 2, endIndex);
String decode = URLDecoder.decode(query, UTF_8);
writer.println("HTTP/1.1 404 Not Found");
writer.println("Content-Type: text/html; charset=UTF-8");
writer.println();
writer.println("<h1>Search</h1>");
writer.println("<ul>");
writer.println("<li>query: " + query + "</li>");
writer.println("<li>decode: " + decode + "</li>");
writer.println("</ul>");
}
private String requestToString(BufferedReader reader) throws IOException {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
break;
}
sb.append(line).append("\n");
}
return sb.toString();
}
}
- HTTP 요청 메시지의 시작 라인을 파싱하고 요청 URL에 맞추어 응답을 전달함
- GET / -> home() 호출
- GET /site1 -> site1() 호출
- GET /site2 -> site2() 호출
- 그 외 -> notFound() 호출
- 응답 시 원칙적으로 헤더에 메시지 바디의 크기를 계산해서 Content-Length를 전달해야 하지만 예제의 단순화를 위해 생략함
검색 - search()
- GET /Search?q=입력값 -> search() 호출
- URL 부분에서 ? 이후의 부분에 key=value&key2=value2 포맷으로 서버에 데이터를 전달할 수 있는데 이 부분을 파싱 하면 요청하는 검색어를 알 수 있음
- 예제에서는 매우 원초적인 방법으로 indexOf와 substring()을 활용하여 value의 값을 파싱 하였는데 실제 검색을 하는 것은 아니고 요청하는 검색을 간단히 출력하도록 작성하였음
- URLDecoder는 바로 뒤에서 설명함(인코딩)
HttpServerV3, ServerMainV3
- 코드는 모두 동일하며 HttpServerV3에서는 HttpRequestHandlerV3을 사용하도록, ServerMainV3에서는 HttpServerV3를 사용하도록 수정하고 서버를 실행하고 localhost:12345로 접속하면 아래와 같이 페이지가 출력됨
- site1, site2페이지에 접속하면 정상적으로 응답된 페이지가 출력되는 것을 확인할 수 있음
- 검색에 접속하면 URL에서 q=hello의 hello 문구가 출력되는 것을 확인할 수 있는데, 여기에 hello 대신 다른 문자를 입력하면 해당 문자가 출력되는 것을 확인할 수 있으며, 한글로 입력하면 query에서는 깨져서 나오지만 decode에서는 정상적으로 한글이 출력되는 것을 확인할 수 있음
- localhost:12345/hello11111 처럼 url/ 뒤에 아무 값이나 입력해서 접속해 보면 notFound() 메서드가 호출되어 페이지를 찾을 수 없다는 문구가 출력되는 것도 확인할 수 있음
URL 인코딩
URL이 ASCII를 사용하는 이유
HTTP 메시지 바디는 UTF-8과 같이 다른 인코딩을 사용할 수 있지만 HTTP 메시지에서 시작 라인(URL을 포함)과 HTTP 헤더의 이름은 항상 ASCII를 사용해야 함
UTF-8이 표준화된 시대에 URL은 ASCII만 사용해야 하는 이유는 아래와 같음
HTTP에서 URL이 ASCII 문자를 사용하는 이유
- 인터넷이 처음 설계되던 시기(1980~1990년대)에는 대부분의 컴퓨터 시스템이 ASCII 문자 집합을 사용했음
- 전 세계에서 사용하는 다양한 컴퓨터 시스템과 네트워크 장비 간의 호환성을 보장하기 위해 URL은 단일한 문자 인코딩 체계를 사용해야 했는데, 그 당시 모든 시스템이 비-ASCII 문자를 처리할 수 없었기 때문에 ASCII는 가장 보편적으로 일관된 선택이었음
- HTTP URL이 ASCII만을 지원하는 이유는 초기 인터넷의 기술적 제약과 전 세계적인 호환성을 유지하기 위한 선택임
- 순수한 UTF-8로 URL을 표현하려면 전 세계 모든 네트워크 장비, 서버, 클라이언트 소프트웨어가 이를 지원해야 하지만 여전히 많은 시스템에서 ASCII 기반 표준에 의존하고 있기 때문에 순수한 UTF-8 URL을 사용하면 호환성 문제가 발생할 수 있음
- HTTP 스펙은 매우 보수적이고 호환성을 가장 우선시함
퍼센트(%) 인코딩
GET /search?q=%EA%B0%80%EB%82%98%EB%8B%A4 HTTP/1.1
V3 버전의 서버를 실행하고 url에 q=가나다를 입력하면 위와 같은 서버 출력 로그와 웹 브라우저 화면이 출력됨
한글을 UTF-8 인코딩으로 표현하면 한 글자에 3byte의 데이터를 사용하는데, 가, 나, 다를 UTF-8 인코딩의 16진수로 표현하면 아래와 같음
- 가: EA,B0,80 (3byte)
- 나: EB,82,98 (3byte)
- 다: EB,8B,A4 (3byte)
URL은 ASCII 문자만 표현할 수 있으므로 UTF-8 문자를 표현할 수 없기 때문에 UTF-8을 16진수로 표현한 각각의 바이트 문자 앞에 %(퍼센트)를 붙이면 UTF-8의 가는 %EA%B0%80이 됨
%가 붙여진 바이트 문자들은 모두 ASCII에 포함되는 문자이므로 이렇게 하면 약간 억지스럽기는 하지만 ASCII 문자를 사용해서 16진수로 표현된 UTF-8을 표현할 수 있는데 이것이 퍼센트(%) 인코딩임
% 인코딩 후에 클라이언트에서 서버로 데이터를 전달하면 서버는 각각의 %를 제거하고 EA, B0, 80이라는 각 문자를 얻어서 16진수 byte로 변경한 후 이 3개의 byte를 모아서 UTF-8로 디코딩하면 "가"라는 글자를 얻을 수 있음
% 인코딩, 디코딩 진행 과정
- 1. 클라이언트: "가" 전송 희망
- 2. 클라이언트 % 인코딩: %EA%B0%80
- "가"를 UTF-8로 인코딩
- EA,B0,80 3byte 획득
- 각 byte를 16진수 문자로 표현하고 각각의 앞에 %를 붙임
- 3. 클라이언트 -> 서버 전송: q=%EA%B0%80
- 4. 서버: %EA%B0%80 ASCII 문자를 전달 받음
- %가 붙은 경우 디코딩해야 하는 문자로 인식함
- EA,B0,80을 byte로 변환, 3byte를 획득
- EA,B0,80을 UTF-8로 디코딩하여 문자"가"를 획득함
PercentEncodingMain - % 인코딩
- 문자 "가"를 인코딩해보면 % 인코딩 되어 출력되는 것을 확인할 수 있으며, % 인코딩 된 문자들을 UTF_8로 다시 디코딩해보면 정상적으로 문자열 "가"가 출력되는 것을 확인할 수 있음
- "A"를 인코딩 해보면 인코딩할 필요가 없기 때문에 그대로 "A"가 출력되는 것을 확인할 수 있음
package was.v3;
public class PercentEncodingMain {
public static void main(String[] args) {
String encode = URLEncoder.encode("가", UTF_8);
System.out.println("encode = " + encode);
String decode = URLDecoder.decode(encode, UTF_8);
System.out.println("decode = " + decode);
String noEncode = URLEncoder.encode("A", UTF_8);
System.out.println("noEncode = " + noEncode);
}
}
/*
encode = %EA%B0%80
decode = 가
noEncode = A
*/
% 인코딩 정리
- % 인코딩은 데이터 크기에서 보면 효율이 떨어짐
- 문자 "가"는 단지 3byte만 필요하지만 % 인코딩을 사용하면 각 byte에 %가 붙어서 무려 9byte가 되어버림
- 그러나 HTTP는 호환성을 최우선으로 두기 때문에 매우 보수적이므로 이렇게라도 동작하게 만든 것이며 이 부분이 크게 문제 되지 않는 이유는 URL이나 HTTP 헤더에는 내용이 많이 없으나 진짜 내용이 많은 메시지 바디는 UTF-8로 데이터를 전송할 수 있기 때문임
HTTP 서버2
HTTP 서버 - 요청, 응답
규칙에 맞춰 구조화시키기
작성한 HTTP 요청 메시지와 응답 메시지를 잘 보면 각각 아래처럼 정해진 규칙이 있음
- GET, POST와 같은 HTTP 메서드
- URL
- 헤더
- HTTP 버전, Content-Type, Content-Length
이런 규칙에 맞추어 객체로 만들면 단순히 String 문자로 다루는 것보다 훨씬 더 구조적이고 객체지향적인 편리한 코드를 만들 수 있음
HttpRequest - HTTP 요청 메시지
package was.httpserver;
public class HttpRequest {
private String method;
private String path;
private final Map<String, String> queryParameters = new HashMap<>();
private final Map<String, String> headers = new HashMap<>();
public HttpRequest(BufferedReader reader) throws IOException {
parseRequestLine(reader);
parseHeaders(reader);
// 메시지 바디는 이후에 처리
}
private void parseRequestLine(BufferedReader reader) throws IOException {
String requestLine = reader.readLine();
if (requestLine == null) {
throw new IOException("EOF: No request line received");
}
String[] parts = requestLine.split(" ");
if (parts.length != 3) {
throw new IOException("Invalid request line: " + requestLine);
}
method = parts[0];
String[] pathParts = parts[1].split("\\?"); // ?로 split
path = pathParts[0];
if (pathParts.length > 1) {
parseQueryParameters(pathParts[1]);
}
}
private void parseQueryParameters(String queryString) {
for (String param : queryString.split("&")) {
String[] keyValue = param.split("=");
String key = URLDecoder.decode(keyValue[0], UTF_8);
// value가 1개 이상이면 디코딩, 없으면 빈 값을 반환
String value = keyValue.length > 1 ? URLDecoder.decode(keyValue[1], UTF_8) : "";
queryParameters.put(key, value);
}
}
private void parseHeaders(BufferedReader reader) throws IOException {
String line;
while (!(line = reader.readLine()).isEmpty()) {
String[] headerParts = line.split(":");
// trim() 앞, 뒤의 공백을 제거
headers.put(headerParts[0].trim(), headerParts[1].trim());
}
}
public String getMethod() {
return method;
}
public String getPath() {
return path;
}
public String getParameter(String name) {
return queryParameters.get(name);
}
public String getHeader(String name) {
return headers.get(name);
}
@Override
public String toString() {
return "HttpRequest{" +
"method='" + method + '\'' +
", path='" + path + '\'' +
", queryParameters=" + queryParameters +
", headers=" + headers +
'}';
}
}
reader.readLine(): 클라이언트가 연결만 하고 데이터 데이터 전송 없이 연결을 종료하는 경우 null이 반환되는데 이때 throw new IOException으로 예외를 던지도록 하였음
- 일부 브라우저의 경우 성능 최적화를 위해 TCP 연결을 추가로 하나 더 하는 경우가 있는데 이때 추가 연결을 사용하지 않고 그대로 종료하면 TCP 연결은 하지만 데이터는 전송하지 않고 연결을 끊게 됨
- 크게 중요한 내용은 아니므로 참고만 해도 됨
시작 라인을 통해 method, path, queryParameters를 구할 수 있음
- method: GET
- path: /search
- queryParameters: q=hello
query, header의 경우 key=value 형식이기 때문에 Map을 사용하면 이후에 편리하게 데이터를 조회할 수 있음
- /search?q=hello&type=text와 같은 내용이 있다면 queryParameters의 Map에 아래와 같이 저장됨
- queryParameters: {q=hello, type=text}
퍼센트 디코딩도 URLDecoder.decode()를 사용하여 처리한 다음 Map에 보관하므로 HttpRequest 객체를 사용하는 쪽에서는 퍼센트 디코딩을 고민하지 않아도 됨
- /search?q=%EA%B0%80
- queryParameters: q=가
HTTP 명세에서 헤더가 끝나는 부분은 빈 라인으로 구분하므로 아래처럼 while의 조건을 입력하면 모든 헤더의 정보를 Map에 담을 수 있음
- while (!(line = reader.readLine()).isEmpty()) {
이렇게 하면 시작 라인의 다양한 정보와 헤더를 객체로 구조화할 수 있음
메시지 바디 부분은 아직 파싱 하지 않았는데, 뒤에서 별도로 설명함
HTTPResponse - HTTP 응답 메시지
package was.httpserver;
public class HttpResponse {
private final PrintWriter writer;
private int statusCode = 200;
private final StringBuilder bodyBuilder = new StringBuilder();
private String contentType = "text/html; charset=UTF-8";
public HttpResponse(PrintWriter writer) {
this.writer = writer;
}
public void setStatusCode(int statusCode) {
this.statusCode = statusCode;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public void writeBody(String body) {
bodyBuilder.append(body);
}
public void flush() {
int contentLength = bodyBuilder.toString().getBytes(UTF_8).length;
writer.println("HTTP/1.1 " + statusCode + " " + getReasonPhrase(statusCode));
writer.println("Content-Type: " + contentType);
writer.println("Content-Length: " + contentLength);
writer.println();
writer.println(bodyBuilder);
writer.flush();
}
private String getReasonPhrase(int statusCode) {
switch (statusCode) {
case 200:
return "OK";
case 404:
return "Not Found";
case 500:
return "Internal Server Error";
default:
return "Unknown Status";
}
}
}
시작 라인
- HTTP 버전: HTTP/1.1
- 응답 코드: 200
- 응답 코드의 간단한 설명: OK
응답 헤더
- Content-Type: HTTP 메시지 바디에 들어있는 내용의 종류
- Content-Length: HTTP 메시지 바디의 길이
HTTP 응답을 객체로 만들면 시작 라인, 응답 헤더를 구성하는 내용을 반복하지 않고 편리하게 사용할 수 있음
HttpRequestHandlerV4
package was.v4;
public class HttpRequestHandlerV4 implements Runnable {
private final Socket socket;
public HttpRequestHandlerV4(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
process();
} catch (Exception e) {
log(e);
}
}
private void process() throws IOException {
try (socket;
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {
HttpRequest request = new HttpRequest(reader); // 요청
HttpResponse response = new HttpResponse(writer); // 응답
if (request.getPath().equals("/favicon.ico")) {
log("favicon 요청");
return;
}
log("HTTP 요청 정보 출력");
System.out.println(request);
if (request.getPath().equals("/site1")) {
site1(response);
} else if (request.getPath().equals("/site2")) {
site2(response);
} else if (request.getPath().equals("/search")) {
search(request, response);
} else if (request.getPath().equals("/")) {
home(response);
} else {
notFound(response);
}
response.flush();
log("HTTP 응답 전달 완료");
}
}
private void home(HttpResponse response) {
response.writeBody("<h1>home</h1>");
response.writeBody("<ul>");
response.writeBody("<li><a href='/site1'>site1</a></li>");
response.writeBody("<li><a href='/site2'>site2</a></li>");
response.writeBody("<li><a href='/search?q=hello'>검색</a></li>");
response.writeBody("</ul>");
}
private void site1(HttpResponse response) {
response.writeBody("<h1>site1</h1>");
}
private void site2(HttpResponse response) {
response.writeBody("<h1>site2</h1>");
}
private void search(HttpRequest request, HttpResponse response) {
String query = request.getParameter("q");
response.writeBody("<h1>Search</h1>");
response.writeBody("<ul>");
response.writeBody("<li>query: " + query + "</li>");
response.writeBody("</ul>");
}
private void notFound(HttpResponse response) {
response.setStatusCode(404);
response.writeBody("<h1>404 페이지를 찾을 수 없습니다.</h1>");
}
}
- 클라이언트의 요청이 오면 요청 정보를 기반으로 HttpRequest 객체를 만들어두고 이때 HttpResponse도 함께 만듦
- HttpRequest를 통해서 필요한 정보를 편리하게 찾을 수 있음
- /search의 경우 이미 HttpRequest에서 디코딩을 다 처리해 두었으므로 HttpRequest를 사용하는 입장에서는 퍼센트 디코딩을 고민하지 않아도 됨
- 응답의 경우 HttpResponse를 사용하여 HTTP 메시지 바디에 출력할 부분만 적어주면 나머지는 HttpResponse 객체가 대신 처리해 줌
- response.flush()를 꼭 호출해줘야 실제 응답이 클라이언트에 전달됨
HttpServerV4, ServerMainV4
- 각 코드에서 V4 버전을 사용하도록 변경하고 실행해 보면 V3와 동일하게 실행되는 것을 확인할 수 있음
- 검색에 들어가 보면 디코딩이 완료된 query를 확인할 수 있음
정리
HTTPRequest, HttpResponse 객체가 HTTP 요청과 응답을 구조화한 덕분에 많은 중복을 제거하고, 또 코드도 매우 효과적으로 리팩토링 할 수 있었음
지금까지 학습한 내용을 잘 생각해 보면, 전체적인 코드가 크게 2가지로 분류되는 것을 확인할 수 있음
- HTTP 서버와 관련된 부분
- HttpServer, HttpRequestHandler, HttpRequest, HttpResponse
- 서비스 개발을 위한 로직
- home(), site1(), site2(), search(), notFound()
만약 웹을 통한 회원 관리 프로그램 같은 서비스를 새로 만들어야 한다면, 기존 코드에서 HTTP 서버와 관련된 부분은 거의 재사용하고 서비스 개발을 위한 로직만 추가하면 됨
그리고 HTTP 서버와 관련된 부분을 정말 잘 만든다면 HTTP 서버와 관련된 부분은 다른 개발자들이 사용할 수 있도록 오픈소스로 만들거나 따로 판매를 해도 될 것임(이미 이것을 사용하여 웹 개발을 하고 있음)
HTTP 서버 - 커맨드 패턴
HTTP 서버와 관련된 부분을 구조화하여 서비스 개발을 위한 로직과 명확하게 분리를 진행
핵심은 HTTP 서버와 관련된 부분은 코드 변경 없이 재사용이 가능해야 한다는 점임
HTTP 서버와 관련된 부분인 HttpServer, HttpRequestHandler를 잘 정리하여 HttpRequest, HttpResponse가 들어있는 was.httpserver 패키지로 분리를 진행
HttpServlet - 커맨드 패턴 도입
HttpRequestHandlerV4에서 if - else if - else 문으로 기능을 구분하는 로직에서 커맨드 패턴을 도입하면 좋음
커맨드 패턴을 사용하면 확장성이라는 장점과 동시에 HTTP 서버와 관련된 부분과 서비스 개발을 위한 로직을 분리하는 것처럼 기능 분리에도 도움이 됨
package was.httpserver;
public interface HttpServlet {
void service(HttpRequest request, HttpResponse response) throws IOException;
}
- HttpServlet 인터페이스: HTTP, Server, Applet의 줄임말(애플릿: HTTP 서버에서 실행되는 작은 자바 프로그램)
- 이 인터페이스의 service() 메서드가 있는데 여기에 서비스 개발과 관련된 부분을 구현하면 됨
- 매개변수로 HttpRequest, HttpResponse가 전달되어 HttpRequest로 HTTP 요청 정보를 꺼내고 HttpResponse를 통해 필요한 응답을 할 수 있음
HomeServlet, Site1Servlet, Site2Serviet, SearchServlet - 서비스 서블릿
- 각 서비스 서블릿은 현재 프로젝트에서만 사용하는 개별 서비스를 위한 로직이므로 별도의 v5.servlet 패키지에 두었음
- HttpRequestHandlerV4의 각 메서드에서 동작하는 기능(home, 사이트이동, search)들을 HttpServlet 인터페이스를 구현한 클래스마다 그대로 적용되도록 작성함
package was.v5.servlet;
public class HomeServlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>home</h1>");
response.writeBody("<ul>");
response.writeBody("<li><a href='/site1'>site1</a></li>");
response.writeBody("<li><a href='/site2'>site2</a></li>");
response.writeBody("<li><a href='/search?q=hello'>검색</a></li>");
response.writeBody("</ul>");
}
}
public class Site1Servlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>site1</h1>");
}
}
public class Site2Servlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>site2</h1>");
}
}
public class SearchServlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
String query = request.getParameter("q");
response.writeBody("<h1>Search</h1>");
response.writeBody("<ul>");
response.writeBody("<li>query: " + query + "</li>");
response.writeBody("</ul>");
}
}
NotFoundServlet, InternalErrorServlet, DiscardServlet - 공용 서블릿
- 여러 프로젝트에서 공용으로 사용할 서블릿이므로 httpserver.servlet 패키지에 두었음
- NotFoundServlet: 페이지를 찾을 수 없을 때 사용하는 서블릿
- InternalErrorServlet: HTTP에서 500 응답은 서버 내부에 오류가 있다는 뜻으로 서버 오류가 있을 때 사용
- DiscardServlet: 아무 기능이 없는 껍데기 서블릿으로 아무 일도 하지 않고 요청을 무시할 때 사용, /favicon.ico의 요청을 무시하는 용도로 사용되었음
- PageNotFoundException: 페이지를 찾지 못했을 때 발생하는 예외를 새로 생성함
package was.httpserver.servlet;
public class NotFoundServlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
response.setStatusCode(404);
response.writeBody("<h1>404 페이지를 찾을 수 없습니다.</h1>");
}
}
public class InternalErrorServlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
response.setStatusCode(500);
response.writeBody("<h1>Internal Error</h1>");
}
}
public class DiscardServlet implements HttpServlet {
@Override
public void service(HttpRequest request, HttpResponse response) {
// empty
}
}
public class PageNotFoundException extends RuntimeException {
public PageNotFoundException(String message) {
super(message);
}
}
ServletManager
- Servlet을 관리하고 실행하는 클래스로 설정을 쉽게 변경할 수 있도록 유연하게 설계되어 있음
- httpserver패키지에서 공통으로 사용됨
package was.httpserver
public class ServletManager {
private final Map<String, HttpServlet> servletMap = new HashMap<>();
private HttpServlet defaultServlet;
private HttpServlet notFoundErrorServlet = new NotFoundServlet();
private HttpServlet internalErrorServlet = new InternalErrorServlet();
public ServletManager() {
}
public void add(String path, HttpServlet servlet) {
servletMap.put(path, servlet);
}
public void setDefaultServlet(HttpServlet defaultServlet) {
this.defaultServlet = defaultServlet;
}
public void setNotFoundErrorServlet(HttpServlet notFoundErrorServlet) {
this.notFoundErrorServlet = notFoundErrorServlet;
}
public void setInternalErrorServlet(HttpServlet internalErrorServlet) {
this.internalErrorServlet = internalErrorServlet;
}
public void execute(HttpRequest request, HttpResponse response) throws IOException {
try {
HttpServlet servlet = servletMap.getOrDefault(request.getPath(), defaultServlet);
if (servlet == null) {
throw new PageNotFoundException("request url= " + request.getPath());
}
servlet.service(request, response);
} catch (PageNotFoundException e) {
e.printStackTrace();
notFoundErrorServlet.service(request, response);
} catch (Exception e) {
e.printStackTrace();
internalErrorServlet.service(request, response);
}
}
}
servletMap
- key=value 형식으로 구성되어 있으며 URL의 요청 경로가 key임
- defaultServlet: HttpServlet을 찾지 못할 때 기본으로 실행됨
- notFoundErrorServlet: PageNotFoundException이 발생할 때 실행됨
- 해당 예외는 URL 요청 경로를 servletMap에서 찾을 수 없고 defaultServlet도 없는 경우에 발생함
- 해당 코드는 채팅 프로그램 만들기 V4에서 커맨드 패턴을 도입했을 때처럼 NullObjectPattern으로 default 커맨드를 만들면 더 깔끔하게 처리할 수 있음
- internalErrorServlet: 처리할 수 없는 예외가 발생하는 경우 실행됨
HttpRequestHandler
- httpserver 패키지에서 공용으로 사용하며 기본에 비즈니스 로직과 함께 복잡하게 작성되어 있던 코드가 분리되어 HttpRequestHandler의 역할이 단순해졌음
- HttpRequest, HttpResponse를 만들고 servletManager에 전달하기만 하면 됨
package was.httpserver;
public class HttpRequestHandler implements Runnable {
private final Socket socket;
private final ServletManager servletManager;
public HttpRequestHandler(Socket socket, ServletManager servletManager) {
this.socket = socket;
this.servletManager = servletManager;
}
@Override
public void run() {
try {
process();
} catch (Exception e) {
log(e);
}
}
private void process() throws IOException {
try (socket;
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), UTF_8));
PrintWriter writer = new PrintWriter(socket.getOutputStream(), false, UTF_8)) {
HttpRequest request = new HttpRequest(reader);
HttpResponse response = new HttpResponse(writer);
log("HTTP 요청: " + request);
servletManager.execute(request, response);
response.flush();
log("HTTP 응답 완료");
}
}
}
HttpServer
- 기존과 거의 같으며 ServletManager를 외부에서 주입받는 코드가 추가되었고 위에서 작성한 HttpRequestHandler를 사용하도록 변경되었음
- httpserver 패키지에서 공용으로 사용함
package was.httpserver;
public class HttpServer {
private final ExecutorService es = Executors.newFixedThreadPool(10);
private final int port;
private final ServletManager servletManager;
public HttpServer(int port, ServletManager servletManager) {
this.port = port;
this.servletManager = servletManager;
}
public void start() throws IOException {
ServerSocket serverSocket = new ServerSocket(port);
log("서버 시작 port: " + port);
while (true) {
Socket socket = serverSocket.accept();
es.submit(new HttpRequestHandler(socket, servletManager));
}
}
}
ServerMainV5
- 서비스 개발을 위해 필요한 서블릿들을 서블릿 매니저에 등록하고 HttpServer를 생성하면서 서블릿 매니저를 전달하면 됨
- /favicon.ico의 경우 아무 일도 하지 않고 요청을 무시하는 DiscardServlet을 사용하였으며 해당 서버를 실행하고 브라우저에 접속하면 기존과 동일하게 모두 동작하는 것을 확인할 수 있음
package was.v5;
public class ServerMainV5 {
private final static int PORT = 12345;
public static void main(String[] args) throws IOException {
ServletManager servletManager = new ServletManager();
servletManager.add("/", new HomeServlet());
servletManager.add("/site1", new Site1Servlet());
servletManager.add("/site2", new Site2Servlet());
servletManager.add("/search", new SearchServlet());
servletManager.add("/favicon.ico", new DiscardServlet());
HttpServer server = new HttpServer(PORT, servletManager);
server.start();
}
}
정리
이제 HTTP 서버와 서비스 개발을 위한 로직이 명확하게 분리되어 있음
- HTTP 서버와 관련된 부분 - was.httpserver 패키지
- 서비스 개발을 위한 로직 - v5.servlet 패키지
이후에 다른 HTTP 기반의 프로젝트를 시작해야 한다면 HTTP 서버와 관련된 was.httpserver 패키지의 코드를 그대로 재사용하면 됨
그리고 해당 서비스에 필요한 서브릿을 구현하고 서블릿 매니저에 등록한 다음에 서버를 실행하면 됨
여기서 중요한 부분은 새로운 HTTP 서비스(프로젝트)를 만들어도 was.httpserver 부분의 코드를 전혀 변하지 않고 그대로 재사용할 수 있음
웹 애플리케이션 서버의 역사
직접 만든 was.httpserver 패키지를 사용하면 누구나 손쉽게 HTTP 서비스를 개발할 수 있음
복잡한 네트워크, 멀티스레드, HTTP 메시지 파싱에 대한 부분을 모두 여기서 해결해 주며 이를 사용하는 개발자들은 단순히 HttpServlet의 구현체만 만들면 필요한 기능을 손쉽게 구현할 수 있음
웹 애플리케이션 서버
실무 개발자가 목표라면 웹 애플리케이션 서버(Web Application Server), 줄여서 WAS라는 단어를 많이 듣게 될 것임
Web Server가 아니라 중간에 Application이 들어가는 이유는 웹 서버의 역할을 하면서 추가로 애플리케이션(프로그램 코드)을 수행할 수 있는 서버라는 뜻임
정리하면 웹(HTTP)을 기반으로 작동하는 서버인데, 이 서버를 통해서 프로그램의 코드도 실행할 수 있는 서버라는 뜻으로 여기서 말하는 프로그램의 코드는 앞서 직접 작성한 서블릿 구현체들임
우리가 작성한 서버는 HTTP 요청을 처리하는데 이때 프로그램의 코드를 실행해서 HTTP 요청을 처리하는데 이것이 바로 웹 애플리케이션 서버(WAS) 임
HTTP와 웹이 처음 등장하면서 많은 회사에서 직접 HTTP 서버와 비슷한 기능을 개발하기 시작했음
그러나 문제가 발생하는데 각각의 서버 간에 호환성이 전혀 없었으므로 A회사의 서버를 사용하다가 B회사의 서버로 변경하려면 인터페이스, 클래스가 모두 다르기 때문에 코드를 너무 많이 수정해야 했음
서블릿과 웹 애플리케이션 서버
이런 문제를 해결하기 위해 1990년대 자바 진영에서 서블릿(Servlet)이라는 표준이 등장하게 되는데 이것이 앞서 우리가 만든 그 서블릿임
서블릿은 Servlet, HttpServlet, ServletRequest, ServletResponse를 포함한 많은 표준을 제공하며 HTTP 서버를 만드는 회사들은 모두 서블릿을 기반으로 기능을 제공함
처음에는 javax.servlet 패키지를 사용했는데 이후에 jakarta.servlet으로 변경되었음
서블릿을 제공하는 주요 자바 웹 애플리케이션 서버(WAS)는 아래와 같음
- 오픈소스
- Apache Tomcat
- Jetty
- GlassFish
- Undertow
- 상용
- IBM WebSphere
- Oracle WebLogic
** 참고
- 보통 자바 진영에서 웹 애플리케이션 서버라고 하면 서블릿 기능을 포함하는 서버를 뜻함
- 하지만 서블릿 기능을 포함하지 않아도 프로그램 코드를 수행할 수 있다면 웹 애플리케이션 서버라 할 수 있음
표준화의 장점
HTTP 서버를 만드는 회사들이 서블릿을 기반으로 기능을 제공한 덕분에 개발자는 jakarta.servlet.Servlet 인터페이스를 구현하면 됨
그리고 Apache Tomcat 같은 애플리케이션 서버에서 작성한 Servlet 구현체를 실행할 수 있음
그러다 만약 성능이나 부가 기능이 더 필요해서 상용 WAS로 변경하거나 다른 오픈소스로 WAS를 변경해도 기능 변경 없이 구현한 서블릿들을 그대로 사용할 수 있음
이것이 바로 표준화의 큰 장점인데 개발자는 코드의 변경이 거의 없이 다른 애플리케이션 서버를 선택할 수 있음
애플리케이션 서버를 만드는 입장에서는 사용자를 잃지 않으면서 더 나은 기능을 제공하는데 집중할 수 있음
즉, 표준화된 서블릿 스펙 덕분에 애플리케이션 서버를 제공하는 회사들은 각자의 경쟁력을 키우기 위해 성능 최적화나 부가 기능, 관리 도구 등의 차별화 요소에 집중할 수 있고 개발자들은 서버에 종속되지 않는 코드를 작성할 수 있는 자유를 얻게 됨
이와 같은 표준화의 이점은 개발 생태계 전반에 걸쳐 효율성과 생산성을 높여줌
애플리케이션 서버의 선택에 따른 리스크가 줄어들고 서버 교체나 환경 변화를 쉽게 받아들일 수 있게 되며 이는 곧 유지 보수 비용의 감소와 장기적인 안정성 확보로 이어짐
특히 대규모 시스템을 운영하는 기업들에게는 이러한 표준화된 기술 스택이 비용 절감과 더불어 운영의 유연성을 크게 높여줌
결국 서블릿 표준은 다양한 벤더들이 상호 운용 가능한 환경을 제공할 수 있게 만들어 주며 이는 개발자와 기업 모두에게 큰 이점을 제공함
이런 표준화 덕분에 자바 웹 애플리케이션 생태계는 크게 발전할 수 있었음