티스토리 뷰
들어가며
지금은 웹 (Web)의 시대이다. 수 많은 서비스가 웹을 통해 이뤄진다.
웹 브라우져를 통하여, 마치 애플리케이션으로써 동작하는 것처럼 보인다. 이를 웹 애플리케이션이라 칭하자.
현대의 웹 프로그래밍을 위한 도구는 매우 많고 생태계또한 잘 구성되어있다. (Nginx, Tomcat 등을 들어본 적 있을 것이다.)
Java와 Spring framework 는 아주 인기있는 조합이다. 그로 인해, 프레임워크의 사용법을 알고 서비스로직만 작성할 줄 안다면 웹 애플리케이션을 만드는 것은 굉장히 쉬워졌다.
하지만, 이러한 추상화 내부에서 무엇이 일어나는지 알아야만 한다.
이번 포스팅에서는 통해 웹 서버 그리고 웹 애플리케이션 서버를 구현한다. 해당 과정을 경험하면서 웹 애플리케이션 개발에 필요한 필수 지식을 습득할 수 있을 것이다.
Course
"Pure 자바로 나만의 웹서버 만들기 PART 1 / 박민서", "Pure 자바로 나만의 웹서버 만들기 PART 2 / 박민서" 를 따라간다.
Part 1에서는 웹 서버(Web Server, WS)를 구현한다.
Part 2에서는 Part 1에서 제작한 웹서버를 바탕으로 서블릿 컨테이너를 추가한 웹 애플리케이션 서버(Web Application Server, WAS)를 구현한다.
HTTP/1.1 의 일부를 만족하도록 구현한다.
Java의 표준 라이브러리를 이용해서 구현한다.
CGI(Common Gateway Interface)의 개념이 아닌, 서블릿(Servlet)의 서빙 형태로 구축할 것이다.
사전 지식
- Java
- 문법을 알고, 사용 가능한 수준
- 완전히 Object-Orient(개체지향)스럽게 작성하지는 않을 것이다
- 시스템 프로그래밍 기본적인 이해
- 파일 쓰기, 통신 등
- 문법을 알고, 사용 가능한 수준
- HTML
- 무엇인지 알고, 간단하게라도 작성할 수 있는 정도면 충분한듯 하다
개발 환경
- Apple M1 pro
- macOS, Sonoma 14.3
- Java, 11
- IntelliJ IDEA
- Java를 프로그래밍 할수 있는 환경이면 된다. vscode 등
- docker
- 상용 제품의 케이스를 확인하기 위해 아래의 두 제품을 간단하게 테스트한다.
- Google Chrome, 버전 125.0.6422.14(공식 빌드) beta (arm64)
- Nginx
- Tomcat
Part 1: 웹 서버 (Web Server, WS)
1. 들어가기에 앞서
Nginx와 같은 웹 서버 프로그램을 만든다. 기초 기능만 구성한다면, 간단하다.
웹 서버 개발을 위한 기초지식으로 2장을 다룬다.
2. 웹서버 개발관련 배경지식
웹 브라우저의 주소창에 http://www.google.com/ 을 입력하면, http://www.google.com/ 을 서버에 대한 request로 송신하고, 해당 웹 서버는 송신된 request에 대해 response로 index.html 파일을 반환한다.
대부분의 웹페이지에는 이미지가 어딘가에 하나 이상 포함되어있다. 그 이미지 파일은 브라우저가 HTML 속의 <img>태그 등을 해석한 뒤에 따로 request를 하는 것이다.
예를 들어, 어떤 웹 페이지에 이미지가 3개 포함되어있고, 하나의 CSS 파일을 사용있다고 가정하자. 브라우저로부터 웹 서버에 HTML로 한번, 이미지로 3번, CSS로 1번, 총 5번의 request를 보낸다.
웹 서버는 브라우저로부터 request를 수신받아, 그에 상응하는 결과로서 파일을 반환하는 일만 하는 프로그램이라해도 과언이 아니다.
리치 웹 서버라고해도, 본질은 변하지 않는다.
웹 브라우저를 만드는 것은 난이도가 있으나, 웹 서버를 만드는 것은 그만큼 난이도가 있는 일은 아니다.
2.1 URL 구성
URL(Uniform Resource Locator)을 번역하면, "통합 자원 위치 지시자"이다. 정리하면, URL은 인터넷 상의 자원(웹 페이지, 이미지, 동영상 등 인터넷 상에 존재하는 모든 자원을 의미)의 위치를 지시하는 표준화된 형식이다.
http://www.google.com/ 이라는 URL로 예시를 들었다. 해당 URL을 사용하여, URL의 구성을 설명한다.
- 스킴(scheme)의 종류에는 http, file 등이 있다.
- 도메인명은 ICANN(Internet Corporation for Assigned Names and Numbers)이라는 조직이 전세계의 도메인이 중복되지 않도록 관리한다.
- 도메인은 통상 레지스터라고 불리우는 업자를 통해 신청하여 취득한다.
- 그리고 하나의 웹서버에는 www, 메일서버에는 mail이라는 호스트명을 붙인다. 이렇게 호스트명과 도메인으로 유일한 호스트를 특정할 수 있게된다. 이것을 FQDN(Fully Qualified Domain Name)이라 부른다
- 도메인에는 여러개의 호스트가 존재할 수 있는데, FQDN이 아니라 FQHN(FQ Host Name)이여야 하는게 아닌가하는 생각할 수 있지만,
- 도메인명은 계층구조로 되어있다.
- com : 제 1레벨 도메인
- google : 제 2레벨 도메인
- www : 제 3레벨 도메인
- 호스트명까지 포함한 것이 도메인명이다. 그럼에도 편하게부를때는 뒤의 두단계의 도메인을 일컬어 도메인명으로 부르는 일이 많기에, 그림에도 그렇게 표현한 것이다.
- 도메인명은 계층구조로 되어있다.
- 레지스터로부터 취득한 도메인을 가지고 서브 도메인을 만드는 것도 가능하다.
- 예를 들어, www.sub.example.com 이 된다.
- 최근에는 호스트명을 제외한 형태도 지원하는 웹사이트들이 생기고 있다.
- http://www.example.com -> http://example.com
- 이는 FQDN을 IP(Internet Protocol) 주소로 변환하는 구조인 DNS(Domain Name System)의 지정에 따른 것이다.
- 인터넷 상의 통신은 IP 주소를 사용해서 수행되는 것이기에, example.com이라는 도메인명으로 example.com의 웹서버 IP 어드레스를 반환하도록 DNS 서버에 설정해두면 될 것을 유추할 수 있을 것이다.
- 당연하게도 FQDN 대신, IP 주소를 지정하여 접근도 가능하다.
- 예를 들어, http://192.168.1.1/index.html
- 포트번호를 통해, 프로세스를 특정한다. 웹 서버는 통상 80번 포트를 사용하기에, 웹서버요청은 포트를 생략하면 80번 포트를 요청한다
2.2 웹 서버 / 클라이언트 개발
cf) 책에서 "웹 서버 / 클라이언트 개발"이라는 표현을 썻다. 하지만, 아직 소켓 통신에 관한 것이기에, 나는 "Tcp 통신", 혹은 "통신"이라 칭하겠다
통신을 우선 해보자.
브라우저와 웹 서버간 통신은 네트워크를 경우한다. TCP(Transmission Control Protocol)를 사용하여 클라이언트와 서버로써 임의의 바이트 열을 서로 주고받는다
TCP를 사용한 통신을 수행하기 위해, 소켓(Socket)이라고 하는 라이브러리를 사용한다. 소켓은, 원래는 BSD계열의 유닉스에서 C언어용으로 개발된 것이지만, 현재는 대부분의 OS에서 대부분의 프로그래밍 언어에서 사용하는 것이 가능하다
소켓에 의한 통신은, 다음과 같은 절차로 수행된다
- 서버측에서 소켓을 생성한 뒤, 클라이언트의 접속을 대기
- 클라이언트측에서 소켓을 생성한 뒤, 서버 호스트와 포트를 지정해서 접속을 시도
이러한 절차로, 서버와 클라이언트 간에 임의의 데이터를 쌍방향으로 보내는 것이 가능한 전송로가 만들어진다.
접속이 완료되면, 그 뒤로는 통상적인 파일 작성이나 표준출력과 동일한 방법으로 네트워크에 의한 통신이 가능해진다.
위 그림3에서는 서버와 클라이언트가 1:1로 되어있지만, 실제로는 1대의 서버는 여러개의 클라이언트와 통신이 가능하지않나? 라는 생각이 들것이다.
서버는 대기하고 있는 소켓이 클라이언트와 연결되면, 새롭게 다른 소켓을 만들고 다시 대기상태로 들어간다. 스레드로 구현할 수 있다. 이에 관에서 "3.4 웹페이지가 정상적으로 표시되도록 하기"에서 다룬다
서버와 클라이언트를 만들어서 테스트해보자.
아래와 같은 형태로 프로그램을 구현할 것이다.
디렉터리 구조는 아래와 같이 구성했다.
$ tree
.
└── src
└── network
서버, 클라이언트 순으로 코드를 첨부한다
src/network/TcpServer.java
package network;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class TcpServer {
public static void main(String[] argv) throws Exception {
try (ServerSocket server = new ServerSocket(8001);
FileOutputStream fos = new FileOutputStream("server_recv.txt");
FileInputStream fis = new FileInputStream("server_send.txt")) {
System.out.println("클라이언트로부터의 접속을 기다리고 있습니다.");
Socket socket = server.accept();
System.out.println("클라이언트 접속");
int ch;
//클라이언트로부터 수신한 내용을 server_recv.txt에 출력
InputStream input = socket.getInputStream();
//클라이언트는, 종료 마크로 0을 송신한다.
while ((ch = input.read()) != 0) {
fos.write(ch);
}
//server_send.txt 내용을 클라이언트에 송신
OutputStream output = socket.getOutputStream();
while ((ch = fis.read()) != -1) {
output.write(ch);
}
socket.close();
System.out.println("통신이 종료되었습니다.");
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
src/network/TcpClient.java
package network;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
public class TcpClient {
public static void main(String[] args) throws Exception {
try (Socket socket = new Socket("localhost", 8001);
FileInputStream fis = new FileInputStream("client_send.txt");
FileOutputStream fos = new FileOutputStream("client_recv.txt")) {
int ch;
// client_send.txt 내용을 서버에 송신
OutputStream output = socket.getOutputStream();
while ((ch = fis.read()) != -1) {
output.write(ch);
}
//종료를 표시하기 위해 제로를 송신
output.write(0);
//서버로부터의 리턴을 client_recv.txt에 출력
InputStream input = socket.getInputStream();
while ((ch = input.read()) != -1) {
fos.write(ch);
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
서버 프로그램 TcpServer에서 ServerSocket 인스턴스를 포트번호 8001로 생성해서 accept() 메서드로 클라이언트로부터의 접속을 기다리는 대기모드에 들어간다. accept() 메서드는 클라이언트로부터 접속될 때까지 리턴되지 않는다
클라이언트 프로그램 TcpClient에서 localhost(127.0.01)의 8001번 포트에 접속하기위해 소켓을 생성한다. 이 연결로 서버측 프로그램이 accept() 메서드를 통해 반화값으로서 Socket클래스의 인스턴스를 취득한다.
이로써 서버와 클라이언트 간에 전송로가 생겼다.
이후에는, 각 소켓으로부터 스트림을 취득하여 쌍방향 데이터 통신이 가능하다
종료마크로는 0을 두었다. 프로그램상 약속값에 불과하다. 텍스트파일에서 0을 포함하지 않을 가능성이 높기에 그렇게 설정했다.
코드상에서 파일이 이미 존재함을 가정한 코드로, 파일을 미리 준비해두자.
.
├── client_recv.txt
├── client_send.txt
├── server_recv.txt
├── server_send.txt
└── src
└── network
├── TcpClient.java
└── TcpServer.java
server_send.txt
this is server
client_send.txt
this is client
그리고 서버를 먼저 실행해두고, 클라이언트를 실행하면 아래와 같은 결과를 확인할 수 있다.
TcpServer 콘솔 메시지
/Library/Java/JavaVirtualMachines/temurin-11.jdk/Contents/Home/bin/java -javaagent:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar=49782:/Applications/IntelliJ IDEA.app/Contents/bin -Dfile.encoding=UTF-8 -classpath ~/study/WebApplicationFromScratch/out/production/WebApplicationFromScratch network.TcpServer
클라이언트로부터의 접속을 기다리고 있습니다.
클라이언트 접속
통신이 종료되었습니다.
Process finished with exit code 0
server_recv.txt
this is client
client_recv.txt
this is server
2.3 웹 브라우저(상용 제품-클라이언트)로 웹 서버에 접속하기
cf) 책에서 "웹 서버에 접속하기"라는 표현을 썻다. 하지만, 아직 소켓 통신에 관한 것이기에, "서버"로 표현하겠다.
실제 상용 제품의 케이스를 보자.
TcpServer 프로그램을 켜두고, 상용 브라우저로 request를 보내는 것이다.
웹 브라우저가 웹 서버에 어떻게 요청을 보내는지 확인할 수 있다.
스킴이 http 이고, 현재 서버는 소켓통신(TCP)기반이므로 HTTP request를 확인 할 수 있는것이다.
TcpServer.java를 기동하고, 크롬에서 요청을 보내보자.
http://localhost:8001/index.html
웹 브라우저의 주소창에서 요청을 하면, TcpServer 콘솔창에서 "클라이언트 접속" 까지 뜨고 종료되지 않는다.
브라우저와 서버간의 종료 약속이 아직 정해지지 않아서 그렇다. 서버 프로세스를 종료시키면 된다.
나는 크롬을 이용하였다. 아래와 같은 데이터를 얻을 수 있다
server_recv.txt
GET /index.html HTTP/1.1
Host: localhost:8001
Connection: keep-alive
sec-ch-ua: "Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"
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/125.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-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
HTTP request 중 1번째 줄을 "request line" 이라 부른다. 2~15번째 줄을 "request 헤더(header)"라 부른다.
request line과 request 헤더는 텍스트형식이고, 개행코드는 CR(Carriage return, \n)+LF(Line feed, \r)로 되어있다.
해당 요청은 HTTP의 GET 메서드를 의미하고, 웹 서버에 대해서 최상위 디렉토리의 index.html을 조회할 것을 요구하는 것이다.
마지막 헤더의 아랫줄은 공백이다.
참고로, GET 메서드는 바디(body)가 없다.
2.4 웹 클라이언트로 웹 서버(상용 제품-서버)에 접속하기
cf) 책에서 "웹 클라이언트"라는 표현을 썻다. 하지만, 아직 소켓 통신에 관한 것이기에, "클라이언트"로 표현하겠다.
실제 상용 제품의 케이스를 보자.
TcpClient 프로그램을 켜두고, 상용 웹 서버로 request를 보내는 것이다.
먼저, 웹 서버를 준비하자. 웹 서버 상용 제품으로 Nginx을 쓰겠다. 도커(docker)를 이용하여 띄우겠다.
docker run -d --name nginx -p 8080:80 nginx
컨테이너를 Host의 8080 포트로 매핑해두었다.
"2.3 웹 브라우저(상용 제품-클라이언트)로 웹 서버에 접속하기" 에서 획득한, 크롬 브라우저에서 보내는 HTTP request(server_recv.txt)를 이용하자. 브라우저에서와 동일한 request를 서버에 던지는 것이다.
해당 데이터를 client_send.txt 로 복사해두자.
client_send.txt
GET /index.html HTTP/1.1
Host: localhost:8001
Connection: keep-alive
sec-ch-ua: "Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"
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/125.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-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
nginx에 request를 보내도록, TcpClient.java를 수정하자
network/TcpClient.java
package network;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
public class TcpClient {
public static void main(String[] args) throws Exception {
- try (Socket socket = new Socket("localhost", 8001);
+ try (Socket socket = new Socket("localhost", 8080);
FileInputStream fis = new FileInputStream("client_send.txt");
FileOutputStream fos = new FileOutputStream("client_recv.txt")) {
int ch;
// client_send.txt 내용을 서버에 송신
OutputStream output = socket.getOutputStream();
while ((ch = fis.read()) != -1) {
output.write(ch);
}
- //종료를 표시하기 위해 제로를 송신
- output.write(0);
//서버로부터의 리턴을 client_recv.txt에 출력
InputStream input = socket.getInputStream();
while ((ch = input.read()) != -1) {
fos.write(ch);
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
그리고 TcpClient.java를 동작시켜보자. nginx에서의 응답이 client_recv.txt에 기록된다. 바로, HTTP response 이다.
client_recv.txt
HTTP/1.1 200 OK
Server: nginx/1.25.5
Date: Thu, 02 May 2024 02:24:00 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 16 Apr 2024 14:29:59 GMT
Connection: keep-alive
ETag: "661e8b67-267"
Accept-Ranges: bytes
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
첫번째 줄은 status line이고, 2~10번째 줄이 response 헤더이다. response 헤더도 공백라인으로 종료된다.
그 뒤 12번째 줄 부터는 response 바디이다.
🚧 중간 Remark 1
웹 브라우저의 request, 웹 서버의 response를 확인했다.
2.5 HTTP (Hypertext Transfer Protocol)
2.5.1 HTTP란 무엇인가?
HTTP는 Hypertext Transfer Protocol의 약자이다. "하이퍼텍스트를 전송하는 프로토콜"이라는 뜻이다.
여기서의 프로토콜은, 네트워크 세계에서의 통신 규약을 말한다. HTTP reqeust를 예로들면, 개행문자를 CR+LF로 한다던가 등의 약속을 하는 것이다.
HTTP의 이런 규약을 정하는 곳은 IETF(Internet Engineering Task Force)라는 조직이고, 이 규약은 RFC(Request For Comments)라고 하는 형태로 공개하고 있다.
HTTP는 현재 HTTP/3.0까지 공개되어있지만, HTTP/1.1 에 맞춰서 개발을 진행하자. 웹 애플리케이션의 개발방법론 전반을 이해하기에는 충분하다.
HTTP/1.1의 RFC는 오랜시간 RFC2616이였으나, 개정되어 RFC 7230 ~ RFC 7235로 규정하고있다.
HTTP/1.1의 request에는 8개의 메서드가 정의되어 있다.
보통 GET, POST를 주로 쓴다.
해당 프로젝트의 WS에서는 GET, WAS에서는 GET과 POST를 대응하도록 구현한다.
2.5.2 HTTP Status Code
HTTP response의 첫번째 줄인 status 라인에는 HTTP status code가 출력되어있다.
HTTP/1.1 200 OK
// ...중략
위의 예시에서 200이 status code이다.
HTTP status code는 첫째자리를 "클래스"라고 부르며, 1xx~5xx까지 5 종류가 존재한다
- 1xx
- Informational
- 2xx
- Successful
- 3xx
- Redirection
- 4xx
- Client Error
- 5xx
- Server Error
2.5.3 RFC란 무엇인가
RFC는 "Request For Comments"의 약자이다. "코멘트 모집" 또는 "의견 요청서" 또는 "의견을 요청하는 문서"로 번역가능하다. 여러 설이 있는듯 하나, 저자가 언급한 바에 따르면 인터넷 연구개발이 미국 국방부의 연구자금으로 연구된 결과이기에 결과를 민간에 널리 공개할수 없었고 연구결과에 대한 코멘트를 모집하는 형태로 공개해서 붙여진 것이라고한다. 또한, 해당 분야의 전문가들이 새로운 아이디어, 프로토콜, 표준 등을 공유하고 다른 사람들의 의견을 듣기 위해 발표하는 문서 형식이라고도 이해할 수 있다.
RFC는 확정된 표준만을 취급하지는 않는다. RFC는 크게 7종류로 분류되며, 그 중 표준화에 관련된 것은 3가지 step이 존재한다.
- Proposed Standard (PS, 표준화의 제창)
- Draft Standard (DS, 표준화의 초고)
- Standard (STD, 표준)
토론을 통해 진행되며, 위의 ste을 거치며 표준이 된다.
단계를 밟아보자.
RFC에 게재하려면, 인터넷 트래프트(Internet Draft)라는 단계가 있고 해당 단계를 거치려면 IESG(Internet Engineering Steering Group)의 승인을 얻어야 한다.
Draft Standard를 밟기위해서는, 최소 2개의 독립된 상호운용성이 있는 서돌 다른 코드 베이스의 구현물과 충분한 운용경험을 요구한다.
충분한 운용실적을 인정 받으면 Standard가 된다.
표준화 과정 이외의 4개의 RFC 는 다음과 같다.
- 정보 (Info : Information)
- 널리 알려져있을만한 정보로 공개되는 것을 의미한다.
- April fool's joke RFC 등이 여기에 속한다
- 실험 (Exp : Experimental)
- 연구성과나 실험 결과를 공개한다
- 역사 (Hist : Historical)
- 다른 사양을 취해서 대체 되거나 하여 현재 사용되고있지 않는 RFC를 의미
- 현상 (BCP : Best Current Practice)
- 인터넷 관련 조직의 규약이나, 기술적인 추천사항 등을 말한다
2.5.4 이미지를 표시하기 (Content-Type)
페이지에 이미지 등이 포함되어 있는 경우는, 브라우저가 HTML을 해석해서 별도로 해당 이미지 등의 리소스를 취득하려 시도한다.
HTML을 취득했을때와 이미지를 취득했을때의 브라우저 동작은 다르다.
HTML, 이미지, 텍스트파일과 같은 데이터 종류는, 웹 서버로부터, HTTP resonse 헤더의 "Content-Type"에 따라 반환된다.
"2.4 웹 클라이언트로 웹 서버(상용 제품-서버)에 접속하기"에서의 response 헤더를 보면 "text/html"이다.
HTTP/1.1 200 OK
Server: nginx/1.25.5
Date: Thu, 02 May 2024 02:24:00 GMT
Content-Type: text/html
// ...중략
브라우저는 이것을 근거로, 응답받은 데이터가 HTML인지 이미지인지 등을 판단한다.
웹 서버가 이미지를 반환했다면 어떤 Content-Type을 부여할까? nginx를 통해 알아보자.
이미지를 반환하기 위해, 웹 서버에 이미지 파일을 배치해두자. nginx의 도큐먼트 루트(Document Root)에 이미지파일을 배치하는 것이다. 도큐먼트 루트는 웹사이트에 공개되는 HTML 파일 등을 배치하는 최상위 디렉토리를 말한다.
도커 컨테이너에 마운트용 디렉터리를 구성하여 구글 로고를 저장해두었다. 단순히 위키피디아에서 찾은 로고이다.
.
├── image
│ └── Google_2015_logo.svg
├── client_recv.txt
├── client_send.txt
├── server_recv.txt
├── server_send.txt
└── src
└── network
├── TcpClient.java
└── TcpServer.java
docker run -d --name nginx -v ~/study/MyWS/WebApplicationFromScratch/image:/usr/share/nginx/html/image -p 8080:80 nginx
client_send.txt 의 request line을 수정하자.
-GET /index.html HTTP/1.1
+GET /image/Google_2015_logo.svg HTTP/1.1
Host: localhost:8001
Connection: keep-alive
sec-ch-ua: "Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"
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/125.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-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
이미지를 요청하고, Nginx에서의 response를 확인하자
HTTP/1.1 200 OK
Server: nginx/1.25.5
Date: Thu, 02 May 2024 04:03:48 GMT
Content-Type: image/svg+xml
Content-Length: 1906
Last-Modified: Thu, 02 May 2024 03:41:18 GMT
Connection: keep-alive
ETag: "66330b5e-772"
Accept-Ranges: bytes
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 92" width="272" height="92"><path fill="#EA4335" d="M115.75 47.18c0 12.77-9.99 22.18-22.25 22.18s-22.25-9.41-22.25-22.18C71.25 34.32 81.24 25 93.5 25s22.25 9.32 22.25 22.18zm-9.74 0c0-7.98-5.79-13.44-12.51-13.44S80.99 39.2 80.99 47.18c0 7.9 5.79 13.44 12.51 13.44s12.51-5.55 12.51-13.44z"/><path fill="#FBBC05" d="M163.75 47.18c0 12.77-9.99 22.18-22.25 22.18s-22.25-9.41-22.25-22.18c0-12.85 9.99-22.18 22.25-22.18s22.25 9.32 22.25 22.18zm-9.74 0c0-7.98-5.79-13.44-12.51-13.44s-12.51 5.46-12.51 13.44c0 7.9 5.79 13.44 12.51 13.44s12.51-5.55 12.51-13.44z"/><path fill="#4285F4" d="M209.75 26.34v39.82c0 16.38-9.66 23.07-21.08 23.07-10.75 0-17.22-7.19-19.66-13.07l8.48-3.53c1.51 3.61 5.21 7.87 11.17 7.87 7.31 0 11.84-4.51 11.84-13v-3.19h-.34c-2.18 2.69-6.38 5.04-11.68 5.04-11.09 0-21.25-9.66-21.25-22.09 0-12.52 10.16-22.26 21.25-22.26 5.29 0 9.49 2.35 11.68 4.96h.34v-3.61h9.25zm-8.56 20.92c0-7.81-5.21-13.52-11.84-13.52-6.72 0-12.35 5.71-12.35 13.52 0 7.73 5.63 13.36 12.35 13.36 6.63 0 11.84-5.63 11.84-13.36z"/><path fill="#34A853" d="M225 3v65h-9.5V3h9.5z"/><path fill="#EA4335" d="M262.02 54.48l7.56 5.04c-2.44 3.61-8.32 9.83-18.48 9.83-12.6 0-22.01-9.74-22.01-22.18 0-13.19 9.49-22.18 20.92-22.18 11.51 0 17.14 9.16 18.98 14.11l1.01 2.52-29.65 12.28c2.27 4.45 5.8 6.72 10.75 6.72 4.96 0 8.4-2.44 10.92-6.14zm-23.27-7.98l19.82-8.23c-1.09-2.77-4.37-4.7-8.23-4.7-4.95 0-11.84 4.37-11.59 12.93z"/><path fill="#4285F4" d="M35.29 41.41V32H67c.31 1.64.47 3.58.47 5.68 0 7.06-1.93 15.79-8.15 22.01-6.05 6.3-13.78 9.66-24.02 9.66C16.32 69.35.36 53.89.36 34.91.36 15.93 16.32.47 35.3.47c10.5 0 17.98 4.12 23.6 9.49l-6.64 6.64c-4.03-3.78-9.49-6.72-16.97-6.72-13.86 0-24.7 11.17-24.7 25.03 0 13.86 10.84 25.03 24.7 25.03 8.99 0 14.11-3.61 17.39-6.89 2.66-2.66 4.41-6.46 5.1-11.65l-22.49.01z"/></svg>
"Content-Type: image/svg+xml" 을 확인할 수있다. 이를보고 브라우저가 해석하는 것이다.
서버측에서는, 파일의 종류를 확장자로 구별한다. nginx의 경우 확장자와 Content-Type의 대응표는 디폴트로 "mime.types"라는 설정파일에 정의되어 있다.
root@6c67db0d8415:/# cat /etc/nginx/mime.types
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/avif avif;
image/png png;
image/svg+xml svg svgz;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/webp webp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
font/woff woff;
font/woff2 woff2;
application/java-archive jar war ear;
application/json json;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.apple.mpegurl m3u8;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/vnd.ms-excel xls;
application/vnd.ms-fontobject eot;
application/vnd.ms-powerpoint ppt;
application/vnd.oasis.opendocument.graphics odg;
application/vnd.oasis.opendocument.presentation odp;
application/vnd.oasis.opendocument.spreadsheet ods;
application/vnd.oasis.opendocument.text odt;
application/vnd.openxmlformats-officedocument.presentationml.presentation
pptx;
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
xlsx;
application/vnd.openxmlformats-officedocument.wordprocessingml.document
docx;
application/vnd.wap.wmlc wmlc;
application/wasm wasm;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/xspf+xml xspf;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream iso img;
application/octet-stream msi msp msm;
audio/midi mid midi kar;
audio/mpeg mp3;
audio/ogg ogg;
audio/x-m4a m4a;
audio/x-realaudio ra;
video/3gpp 3gpp 3gp;
video/mp2t ts;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}
이 중, 이미지 포멧에 대해서는 19~27 번째 줄에서 확인할 수 있다.
2.5.5 Nginx의 Access Log 보기
nginx의 기본 설정으로는, /var/log/nginx/access.log에 로그를 기록한다.
참고로 나는 도커를 이용하고 있다. 도커에서는 심볼릭 링크를 통해 /dev/stdout을 가리키고 있다. 이는 표준 출력을 나타내는 파일이다. 이것은 로그가 표준 출력으로 Redirection되고 있음을 나타내는 것이고, 엔진엑스는 로그를 직접 파일로 기록하는 것이 아니라, 표준 출력으로 보낸다는 의미이다.
host 터미널에서 아래의 명령어로 확인해보자
docker logs -f nginx
192.168.65.1 - - [02/May/2024:04:49:23 +0000] "GET /image/Google_2015_logo.svg HTTP/1.1" 200 1906 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" "-"
192.168.65.1 - - [02/May/2024:04:49:44 +0000] "GET /index.html HTTP/1.1" 304 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" "-"
책에서의 사진으로 설명이 대체되는 것 같다
4개에 대해서 설명만 해보겠다.
- 클라이언트 아이덴티티
- RFC 1413에서 규정된 클라이언트 식별자를 의미한다.
- 하지만, 클라이언트에서 동작하는 ident라고 하는 프로그램이 반환시키는 식별자에 불과하기에 일반적으로는 인증 등에 사용할 . 수있는 정보가 되지 못한다.
- 따라서 nginx에서는 이 정보를 취득하려하지 않기에, 디폴트로 -로 출력한다
- 인증기능 사용 시의 유저 아이디
- HTTP 인증 기능이 구현되어있고, 그 인증기능을 사용한 경우는, 이곳에 유저 아이디가 출력된다.
- 현재는 인증기능을 사용하고 있지 않기 때문에, -로 출력하고 있다.
- Referer
- 어떤 페이지의 링크를 클릭해서 다른 페이지로 이동했을때, 이동하기 전의 페이지 URL을 의미한다.
- Referer은 HTTP request 헤더로 브라우저에서 서버로 송신된다.
- 헤더의 이름은 Referer 이지만, 올바른 스펠리은 Refferrer 이다. 초기에 스펠링을 잘못기재했으나, 호환성때문에 지금까지 정정되지 않은채 사용한다
- User Agent
- 열람에 사용된 브라우저의 종류를 나타낸다
- HTTP request 헤더로 브라우저에서 서버로 송신된다.
- Mozilla라고하면, Firefox를 개발하고 있는 Mozilla Foundatioin을 가리킨다.
- 하지만 Mozilla 라고 하는 것은 Firefox 이전의 (약 30여년 전) 웹 브라우저인 Netscape를 개발할 때의 코드네임을 가리킨다.
- Netscape가 거의 독점했고, 유저 에이전트로 Mozilla를 포함한 문자열을 출력하고 있었다.
- 유저 에이전트에 Mozilla가 포함되어있지 않으면, Netscape로 접속하라는 에러를 출력했기에, 이를 회피하기위해 Mozilla를 포함시킨것이라한다.
- 상세한 이유에 대해 다음 사이트에서 다루고 있다. https://webaim.org/blog/user-agent-string-history/
⭐️3. 웹서버 만들기 (SmallCat/0.1)
이제 웹 서버를 구현해보자.
3.1 웹서버는 무엇을 반환시키면 되는 것인가?
Nginx 와 같은 반환을 하자. (HTTP response)
구체적으로는
- status 라인
- response 헤더
- 공백라인
- request 라인으로 요구된 경로의 파일 내용
3.2 레스폰스 헤더의 취사선택
RFC 2119에 필수인지 아닌지를 판단하는 “요구레벨”에 대해 MUST, REQUIRED, SHOULD, MAY 4개의 키워드로 분류하고있다.
https://datatracker.ietf.org/doc/html/rfc2119
MUST, REQUIRED는 필수이고, SHOULD, RECOMMENDED는 필수는 아니다.
구현할 웹 서버에서는 헤더에 어떤 항목을 반환할지, 책에서는 아래와 같이 검토했다.
정리하면, 반환할 response 헤더의 항목은 아래와 같다.
- Date
- Server
- Connection
- Content-Type
3.3 HTML 파일을 반환하기
TcpServer.java를 베이스로, 기본기능을 하는 웹서버를 작성하자.
request 라인을 해석해서 path를 취득하고 response 헤더와 response 바디를 반환하는 웹서버다.
HTML 파일을 nginx의 기본 파일 재사용했다. 복사해두고 컨테이너에 다시 마운트 시켰다
.
├── client_recv.txt
├── client_send.txt
├── server_recv.txt
├── server_send.txt
├── html
│ ├── 50x.html
│ ├── image
│ │ └── Google_2015_logo.svg
│ └── index.html
└── src
├── network
│ ├── TcpClient.java
│ └── TcpServer.java
└── web
└── smallcat
└── ver1
└── Main.java
html/50x.html
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
html {
color-scheme: light dark;
}
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>An error occurred.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>If you are the system administrator of this resource then you should check
the error log for details.</p>
<p><em>Faithfully yours, nginx.</em></p>
</body>
</html>
html/index.html
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html {
color-scheme: light dark;
}
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>
For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.
</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
src/web/smallcat/ver1/Main.java
package web.smallcat.ver1;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Locale;
import java.util.TimeZone;
public class Main {
private static final String DOCUMENT_ROOT = System.getProperty("user.home") + "/study/WebApplicationFromScratch/html";
//InputStream에서 바이트열을 행단위로 읽어들이는 유틸리티
private static String readLine(InputStream input) throws Exception {
int ch;
String ret = "";
while ((ch = input.read()) != 1) {
if (ch == '\r') {
//nothing do
} else if (ch == '\n') {
break;
} else {
ret += (char) ch;
}
}
if (ch == 1) {
return null;
} else {
return ret;
}
}
//1행의 문자열을 바이트열로 OutputStream으로 쓰는
//유틸리티
private static void writeLine(OutputStream output, String str) throws Exception {
for (char ch : str.toCharArray()) {
output.write((int) ch);
}
output.write((int) '\r');
output.write((int) '\n');
}
//현재시각을 HTTP 표준 포맷에 맞게 날짜 문자열을 반환
private static String getDateStringUtc() {
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
DateFormat df = new SimpleDateFormat("EEE,ddMMMyyyyHH:mm:ss", Locale.US);
df.setTimeZone(cal.getTimeZone());
return df.format(cal.getTime()) + "GMT";
}
public static void main(String[] args) {
try (ServerSocket server = new ServerSocket(8001)) {
Socket socket = server.accept();
InputStream input = socket.getInputStream();
String line;
String path = null;
while ((line = readLine(input)) != null) {
if (line == "") {
break;
}
if (line.startsWith("GET")) {
path = line.split(" ")[1];
}
}
OutputStream output = socket.getOutputStream();
//레스폰스 헤더를 반환
writeLine(output, "HTTP/1.1 200 OK");
writeLine(output, "Date:" + getDateStringUtc());
writeLine(output, "Server:Modoki/0.1");
writeLine(output, "Connection: close");
writeLine(output, "Contenttype:text/html");
writeLine(output, "");
//레스폰스 바디를 반환
try (FileInputStream fis = new FileInputStream(DOCUMENT_ROOT + path)) {
int ch;
while ((ch = fis.read()) != -1) {
output.write(ch);
}
}
socket.close();
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
SmallCat/0.1 을 기동시키고 브라우저에서 http://localhost:8001/index.html로 액세스하면 아래와 같이 표시된다.
3.4 웹 페이지를 정상적으로 표시하기
지금까지 작성한 웹서버로는, 하나의 HTML 파일을 반환시킬 수 있다.
한 브라우저에서 읽고자 하는 파일을 한번만 리퀘스트할 수 있는 구조 때문데, 일반적인 웹페이지와 같이 이미지나 CSS가 포함된 웹페이지는 정상적으로 표시할 수가 없다.
게다가 현재 코딩된 SmallCat/0.1의 response 바디의 Content-Type은 “text/html”로 고정되어 있기에 HTML 텍스트 이외의 CSS나 이미지 등의 리소스는 브라우저에서 해석이 불가능하다.
따라서 다음 스텝의 구현 목표는 2가지이다
- TCP 접속을 멀티스레드로 처리해서 병렬로 접속이 가능하도록 할 것
- 이미지나 CSS 등, 파일 타입에 따른 Content-Type 설정
src/web/smallcat/ver1/Main.java
package web.smallcat.ver1;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
try (ServerSocket server = new ServerSocket(8001)) {
for (; ; ) {
Socket socket = server.accept();
ServerThread serverThread = new ServerThread(socket);
Thread thread = new Thread(serverThread);
thread.start();
}
}
}
}
- TCP 접속을 대기하다가 accept()되면, ServerThread에 그 소켓을 건내고 다른 스레드로 기동시킨다
- accept()는 무한 루프속에 있기때문에, ServerThread를 기동시키면, 다시 다음 접속(accept())에 대해 대기모드로 들어간다.
src/web/smallcat/ver1/ServerThread.java
package web.smallcat.ver1;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Locale;
import java.util.TimeZone;
public class ServerThread implements Runnable {
private static final String DOCUMENT_ROOT = System.getProperty("user.home") + "/study/WebApplicationFromScratch/html";
private Socket socket;
//InputStream으로 부터 바이트열을 행단위로 읽어 들이는 유틸리티 메소드
private static String readLine(InputStream input) throws Exception {
int ch;
String ret = "";
while ((ch = input.read()) != -1) {
if (ch == '\r') {
//아무것도 안함
} else if (ch == '\n') {
break;
} else {
ret += (char) ch;
}
}
if (ch == -1) {
return null;
} else {
return ret;
}
}
//한 행의 문자열을 바이트열로서 OutputStream에 쓰는
//유틸리티
private static void writeLine(OutputStream output, String str) throws Exception {
for (char ch : str.toCharArray()) {
output.write((int) ch);
}
output.write((int) '\r');
output.write((int) '\n');
}
//현재시각 부터 HTTP 표준에 맞춘 포맷의 날짜문자열을 반환
private static String getDateStringUtc() {
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.US);
df.setTimeZone(cal.getTimeZone());
return df.format(cal.getTime()) + " GMT";
}
//확장자와 ContentType의 대응표
private static final HashMap<String, String> contentTypeMap = new HashMap<>() {
{
put("html", "text/html");
put("htm", "text/html");
put("txt", "text/plain");
put("css", "text/css");
put("png", "image/png");
put("jpg", "image/jpeg");
put("jpeg", "image/jpeg");
put("gif", "image/gif");
put("svg", "image/svg+xml");
}
};
//파일 확장자에 따른 ContentType을 반환
private static String getContentType(String ext) {
String ret = contentTypeMap.get(ext.toLowerCase());
if (ret == null) {
return "application/octet-stream";
} else {
return ret;
}
}
@Override
public void run() {
OutputStream output;
try {
InputStream input = socket.getInputStream();
String line;
String path = null;
String ext = null;
while ((line = readLine(input)) != null) {
if (line == "") break;
if (line.startsWith("GET")) {
path = line.split(" ")[1];
String[] tmp = path.split("\\.");
ext = tmp[tmp.length - 1];
}
}
output = socket.getOutputStream();
//레스폰스 헤더를 반환
writeLine(output, "HTTP/1.1 200 OK");
writeLine(output, "Date:" + getDateStringUtc());
writeLine(output, "Server:SmallCat/0.1");
writeLine(output, "Connection: close");
writeLine(output, "ContentType:" + getContentType(ext));
writeLine(output, "");
//레스폰스 바디를 반환
try (FileInputStream fis1 = new FileInputStream(DOCUMENT_ROOT + path)) {
int ch;
while ((ch = fis1.read()) != -1) {
output.write(ch);
}
}
} catch (Exception ex) {
ex.printStackTrace();
} finally {
try {
socket.close();
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
ServerThread(Socket socket) {
this.socket = socket;
}
}
- accept()까지 처리가 없는 것을 빼면, SmallCat01.java하고 기본적으로 처리하는 내용은 동일하지만, 반환하는 파일의 확장자에 맞춘 Content-Type을 지정하고 있다.
- Content-Type은 확장자에 의해 식별하고 있다. 확장자와 Content-Type의 대응표는 Nginx에서 그러한것처럼 외부파일로 분리하지않고 소스안에 HashMap으로 관리하고 있다.
이미지를 포함하는 HTML 파일을 작성하여 테스트 해보자.
cf) svg는 Content-Type 필드를 채워줘도, 브라우저에서 깨진다. 추가적인 정보가 필요한듯하다. 따라서 png 이미지파일로 테스트를 대체한다. 이미지는 구글에서 찾은 파일이다.
html/index2.html
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html {
color-scheme: light dark;
}
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<img src="image/google_logo.png"/>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>
For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.
</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
html/index.html 에 한줄 추가한 것이다. <body> 태그의 시작부분에 <img> 태그를 추가한 것이다.
src/web/smallcat/ver1/Main.java를 동작시키고, 브라우저에서 http://localhost:8001/index2.html 을 입력하면 아래와 같이 이미지까지 출력되는 것을 볼 수 있다.
기본적인 기능을 하는 웹서버를 구현했다.
4. 웹서버 완성도 높이기 (SmallCat/0.2)
하지만 아직 제대로된 웹 서버의 구실을 하기는 어렵다.
프로그램 보강의 구체적 목표
- 파일이 존재하지 않을 경우, 404 Not Found를 반환한다
- Directory Traversal 취약점에 대응한다
- http://example.com/www 같이 디렉토리만 지정된 경우나, 그 말미에 슬래시(/)가 없는 경우, 또는 http://example.com 같이 도메인만 지정한 경우에 대응한다.
- URL Encoding에 대응한다
4.1 404 Not Found 반환
보통의 웹서버에서는 어떻게 처리하는가? nginx로 확인해보자
보낼 request를 수정하자.
client_send.txt
-GET /image/Google_2015_logo.svg HTTP/1.1
+GET /not-exist-file HTTP/1.1
-Host: localhost:8001
+Host: localhost:8080
Connection: keep-alive
sec-ch-ua: "Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"
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/125.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-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
src/network/TcpClient.java 를 동작시킨다.
결과를 보자. client_recv.txt 를 보면 아래와 같이 반환되어있다.
HTTP/1.1 404 Not Found
Server: nginx/1.25.5
Date: Thu, 02 May 2024 13:18:25 GMT
Content-Type: text/html
Content-Length: 555
Connection: keep-alive
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.25.5</center>
</body>
</html>
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
<!-- a padding to disable MSIE and Chrome friendly error page -->
브라우저로 확인해보자. 브라우저에서는 아래와 같이 해석된다.
4.2 Directory Traversal 공격 취약점에 대응하기
현재 작성된 웹서버 프로그램에는 도큐먼트 루트 폴더 상위의 파일을 들여다 보는 것이 가능하다는 취약점이 있다.
생각해볼게 몇가지 존재한다.
간단하게 생각해보면, ../ 가 포함된 request를 에러취급만 하면 될것같다. 하지만 Windows라면 ../ 뿐 아니라 ..\로도 동일한 효과이다.
또한 또한 URL encode를 decode 이전에 체크해버리면, ../의 encode 형태인 %2e%2e%2f가 검출되지 않는다.
따라서 SmallCat에서는 자바 클래스 라이브러리인 java.nio.file 패키지의 Path 클래스의 toRealPath() 메서드를 이용해서, 유입된 path를 절대path로 변환 후, 변환한 절대path가 도큐먼트 루트로 시작하는지 확인할 것이다.
뼈대 코드는 아래와 같다
// 클라이언트로부터 건내진 Path가 변수 path에 저장된 것을 상정
FileSystem fs = FileSystems.getDefault();
Path pathObj = fs.getPath(DOCUMENT_ROOT + path);
Path realPath;
try {
realPath = pathObj.toRealPath();
} catch (NoSuchFileException ex) {
// toRealPath() 메서드는, 지정된 경로의 파일이 존재하지 않으면 NoSuchFileException을 반환하기에
// 여기서 404 Not Found시의 처리를 수행
}
if (!realPath.startsWith(DOCUMENT_ROOT)) {
// 클라이언트에서 지정된 경로가 도큐먼트 루트 이하에 없음
// Directory Traversal 공격을 당하고 있는가?
}
4.3 디렉토리를 지정한 경우 대응하기
네이버 금융 사이트의 경우 주소창에 http://finance.naver.com 을 입력하면 탑페이지(index.html)가 보인다.
네이버의 금융 뉴스의 탑페이지를 표시하려면 http://finance.naver.com/news/ 를 입력하면된다.
둘다 index.html을 따로 지정하지 않아도 서버측에서 디렉토리까지만 지정된 경우에는 디폴트로 index.html을 보게끔 설정되어 있기 때문이다.
nginx의 경우 nginx.conf 에서 설정한다.
root@1815b73d21b2:/# cat /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
include /etc/nginx/conf.d/*.conf;
}
root@1815b73d21b2:/# cat etc/nginx/conf.d/default.conf
server {
listen 80;
listen [::]:80;
server_name localhost;
#access_log /var/log/nginx/host.access.log main;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
default.conf 에서 아래의 부분을 확인 할 수 있다.
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
근데 이제, http://finance.naver.com/news 와 같이 디렉토리 끝에 /을 붙이지 않고 입력된 경우에는 이야기가 달라진다.
개발자 도구로 확인해보자
status code로 301 Moved Permanently 가 브라우저로 반환된다. 영구적으로 이동했다는 뜻이다.
이동된 URL 은 response header의 Location header에 의해 지시되고, 브라우저는 해당 위치로 이동한다. 이같은 지시를 가리켜 리다이렉트(redirect)라고 한다. 헤더의 Location 필드로 어디로 리다이렉트하는지 나와있다.
그런데 "news" 만으로 파일명인지, 디렉토리명인지 판단할수가 있을까? UNIX 계열의 OS에서는 확장자가 필수가 아니다. 따라서 확장자가 없다고 디렉토리라고 단정할수는 없다.
웹 서버는 “파일이 아니라, 디렉토리인 경우에는 이쪽을 보라” 는 의미의 지시를 브라우저에게 내리는 것이다.
4.4 URL encode에 대응하기
한국어나 공백을 포함한 URL은 그대로는 서버에 보낼 수 없다.
그래서 브라우저는, encode 해서 서버에 보낸다
크롬으로 "http://localhost:8001/한국어 디렉토리/한국어 파일명.cgi?lang=한국어" 를 입력해 테스트 해보자.
server_recv.txt
GET /%ED%95%9C%EA%B5%AD%EC%96%B4%20%EB%94%94%EB%A0%89%ED%86%A0%EB%A6%AC/%ED%95%9C%EA%B5%AD%EC%96%B4%20%ED%8C%8C%EC%9D%BC%EB%AA%85.cgi?lang=%ED%95%9C%EA%B5%AD%EC%96%B4 HTTP/1.1
Host: localhost:8001
Connection: keep-alive
sec-ch-ua: "Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"
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/125.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-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
URL encode란 간단히 말하면, 원래 문자열을 바이트 단위로 해석한 뒤, % 뒤에 그 16진수를 붙인것이다. 따라서, 한국어에서는 원래 문자열이 어떤 엔코딩이였는지에 따라서 결과가 달라진다. (UTF-8, EUC-KR 등)
참고로 책에서는 사파리와 IE의 예시를 보여준다. 둘을 비교해보면 입력받은 URL 값이 쿼리스트링 이전까진 같고 이후는 다른것을 볼 수있다. 이는 둘다 쿼리스트링 이전까지 UTF-8로 encode된 것을 URL encode하는 것이다.
현재 만들어진 SmallCat은 리퀘스트 스트링을 해석하고있지 않기 때문에, path 부분에 대해서는 UTF-8로 URL decode 할 필요가 있다. 이런 URL 인코딩 규격과 관련해서는 W3C의 HTML 4.01 specification 부록 B에 다음과 같이 기재되어있다
번역은 아래와 같다.
이러한 케이스에서 ASCII 문자를 제어하기위해, 유저 에이전트가 이하의 규칙에 따를 것을 추천한다.
- 각 문자를, 1바이트 또는 그 이상의 UTF-8(RFC 2279참고)로 표현한다
- 그 바이트열을 URI의 이스케이프 메카니즘에 의해 이스케이프한다. (즉, 각 바이트를, %HH로 변환한다. HH 부분은, 그 바이트 값의 16진수 표기임)
4.5 SmallCat/0.2
4장에서 언급되었던, SmallCat/0.1에서 보완해야할 목표들을 구현했다.
보완해야 할 목표들을 다시 상기해보자.
- 파일이 존재하지 않을 경우, 404 Not Found를 반환한다
- Directory Traversal 취약점에 대응한다
- http://example.com/www 같이 디렉토리만 지정된 경우나, 그 말미에 슬래시(/)가 없는 경우, 또는 http://example.com 같이 도메인만 지정한 경우에 대응한다.
- URL Encoding에 대응한다
ver2 와 관련있는 패키지 구조만 추려보자. 아래의 계층 구조이다.
.
├── html
│ ├── 50x.html
│ ├── error
│ │ └── 404.html
│ ├── image
│ │ ├── Google_2015_logo.svg
│ │ └── google_logo.png
│ ├── index.html
│ └── index2.html
│ └── 한국어.html
└── src
└── web
└── smallcat
└── ver2
├── Main.java
├── MyURLDecoder.java
├── SendResponse.java
├── ServerThread.java
└── Util.java
src/web/smallcat/ver2/Main.java
package web.smallcat.ver2;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] argv) throws Exception {
try (ServerSocket server = new ServerSocket(8001)) {
for (; ; ) {
Socket socket = server.accept();
ServerThread serverThread = new ServerThread(socket);
Thread thread = new Thread(serverThread);
thread.start();
}
}
}
}
src/web/smallcat/ver2/ServerThread.java
package web.smallcat.ver2;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
class ServerThread implements Runnable {
private static final String DOCUMENT_ROOT = System.getProperty("user.home") + "/study/WebApplicationFromScratch/html";
private static final String ERROR_DOCUMENT = DOCUMENT_ROOT + "/error";
private static final String SERVER_NAME = "localhost:8001";
private Socket socket;
ServerThread(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
OutputStream output = null;
try {
InputStream input = socket.getInputStream();
String line;
String path = null;
String ext = null;
String host = null;
while ((line = Util.readLine(input)) != null) {
if (line.equals("")) break;
if (line.startsWith("GET")) {
path = MyURLDecoder.decode(line.split(" ")[1], "UTF-8");
String[] tmp = path.split("\\.");
ext = tmp[tmp.length - 1];
} else if (line.startsWith("Host:")) {
host = line.substring("Host: ".length());
}
}
if (path == null) return;
if (path.endsWith("/")) {
path += "index.html";
ext = "html";
}
output = new BufferedOutputStream(socket.getOutputStream());
FileSystem fs = FileSystems.getDefault();
Path pathObj = fs.getPath(DOCUMENT_ROOT + path);
Path realPath;
try {
realPath = pathObj.toRealPath();
} catch (NoSuchFileException ex) {
SendResponse.sendNotFoundResponse(output, ERROR_DOCUMENT);
return;
}
if (!realPath.startsWith(DOCUMENT_ROOT)) {
SendResponse.sendNotFoundResponse(output, ERROR_DOCUMENT);
return;
} else if (Files.isDirectory(realPath)) {
String location = "http://" + ((host != null) ? host : SERVER_NAME) + path + "/";
SendResponse.sendMovePermanentlyResponse(output, location);
return;
}
try (InputStream fis = new BufferedInputStream(Files.newInputStream(realPath))) {
SendResponse.sendOkResponse(output, fis, ext);
} catch (FileNotFoundException ex) {
SendResponse.sendNotFoundResponse(output, ERROR_DOCUMENT);
}
} catch (Exception ex) {
ex.printStackTrace();
} finally {
try {
if (output != null) {
output.close();
}
socket.close();
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}
- 50번째 줄 부분에서, 지정된 path가 / 로 끝나면, index.html 을 추가하고 있다.
- 60번째 줄 부분에서, Path.toRealPath() 메서드로 절대path로 변환한다.
- 68번째 줄 부분에서, 입력된 path가 파일이 아닌 디렉토리인 경우 리다이렉트를 처리한다.
src/web/smallcat/ver2/SendResponse.java
package web.smallcat.ver2;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.OutputStream;
class SendResponse {
static void sendOkResponse(OutputStream output, InputStream fis, String ext) throws Exception {
Util.writeLine(output, "HTTP/1.1 200 OK");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: SmallCat/0.2");
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "Content-type: " + Util.getContentType(ext));
Util.writeLine(output, "");
int ch;
while ((ch = fis.read()) != -1) {
output.write(ch);
}
}
static void sendMovePermanentlyResponse(OutputStream output, String location) throws Exception {
Util.writeLine(output, "HTTP/1.1 301 Moved Permanently");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: SmallCat/0.2");
Util.writeLine(output, "Location: " + location);
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "");
}
static void sendNotFoundResponse(OutputStream output, String errorDocumentRoot) throws Exception {
Util.writeLine(output, "HTTP/1.1 404 Not Found");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: SmallCat/0.2");
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "Content-type: text/html");
Util.writeLine(output, "");
try (InputStream fis = new BufferedInputStream(new FileInputStream(errorDocumentRoot + "/404.html"))) {
int ch;
while ((ch = fis.read()) != -1) {
output.write(ch);
}
}
}
}
src/web/smallcat/ver2/MyURLDecoder.java
package web.smallcat.ver2;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
public class MyURLDecoder {
// 16진수 2자리를 ASCII코드로 나타내는 byte를, int로 변환
private static int hex2int(byte b1, byte b2) {
int digit;
if (b1 >= 'A') {
// 0xDF와의 &로 소문자를 대문자로 변환
digit = (b1 & 0xDF) - 'A' + 10;
} else {
digit = (b1 - '0');
}
digit *= 16;
if (b2 >= 'A') {
digit += (b2 & 0xDF) - 'A' + 10;
} else {
digit += b2 - '0';
}
return digit;
}
public static String decode(String src, String enc) throws UnsupportedEncodingException {
byte[] srcBytes = src.getBytes("ISO_8859_1");
// 변환된 쪽이 길어질 일이 없기 때문에, srcByte 길이의 배열을 일단 확보.
byte[] destBytes = new byte[srcBytes.length];
int destIdx = 0;
for (int srcIdx = 0; srcIdx < srcBytes.length; srcIdx++) {
if (srcBytes[srcIdx] == (byte) '%') {
destBytes[destIdx] = (byte) hex2int(srcBytes[srcIdx + 1], srcBytes[srcIdx + 2]);
srcIdx += 2;
} else {
destBytes[destIdx] = srcBytes[srcIdx];
}
destIdx++;
}
byte[] destBytes2 = Arrays.copyOf(destBytes, destIdx);
return new String(destBytes2, enc);
}
}
java의 URLDecoder.decode() 는 특정 인코더의 경우 2바이트째가 인코딩되어있지 않을 경우, 오동작하는 경우가 있다.
따라서 바이트 단위로 디코드 되도록 했다.
src/web/smallcat/ver2/Util.java
package web.smallcat.ver2;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Locale;
import java.util.TimeZone;
class Util {
// 확장자와 Content-Type의 대응표
static final HashMap<String, String> contentTypeMap = new HashMap<>() {
private static final long serialVersionUID = 1L;
{
put("html", "text/html");
put("htm", "text/html");
put("txt", "text/plain");
put("css", "text/css");
put("png", "image/png");
put("jpg", "image/jpeg");
put("jpeg", "image/jpeg");
put("gif", "image/gif");
put("svg", "image/svg+xml");
}
};
// InputStream에서 바이트열을 행단위로 읽는 유틸리티 메소드
static String readLine(InputStream input) throws Exception {
int ch;
String ret = "";
while ((ch = input.read()) != -1) {
if (ch == '\r') {
// 아무것도 안함
} else if (ch == '\n') {
break;
} else {
ret += (char) ch;
}
}
if (ch == -1) {
return null;
} else {
return ret;
}
}
// 1행의 문자열을, 바이트열로 OutputStream에 쓰는 유틸리티 메소드
static void writeLine(OutputStream output, String str) throws Exception {
for (char ch : str.toCharArray()) {
output.write((int) ch);
}
output.write((int) '\r');
output.write((int) '\n');
}
// 현재시각으로부터, HTTP 표준에 맞춰 포맷팅된 날짜 문자열을 반환
static String getDateStringUtc() {
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.US);
df.setTimeZone(cal.getTimeZone());
return df.format(cal.getTime()) + " GMT";
}
// 확장자를 받아서, Content-Type을 반환
static String getContentType(String ext) {
String ret = contentTypeMap.get(ext.toLowerCase());
if (ret == null) {
return "application/octet-stream";
} else {
return ret;
}
}
}
자바 소스 외에, 404 에러메세지 출력을 위한 HTML 파일도 작성하자.
html/error/404.html
<html>
<head>
<meta CONTENT="text/html;charset=UTF-8" HTTP-EQUIV="Content-Type">
<title>파일이 발견되지 않았습니다.</title>
</head>
<body>
<p>파일이 발견되지 않았습니다.</p>
<p>파일이 발견되지 않았습니다.파일이 발견되지 않았습니다.파일이 발견되지 않았습니다.파일이 발견되지 않았습니다.파일이 발견되지 않았습니다.파일이 발견되지 않았습니다.</p>
</body>
</html>
- 404 test
- 301 test
- URL에 한국어 포함
⭐️ Part1 Remark
Part 2: 웹 애플리케이션 서버(Web Application Server, WAS)
1. 들어가기 앞서
Part 1에서 제작한 웹서버 (SmallCat/0.2)를 바탕으로, 서블릿 컨테이너(Servlet Container)를 추가하여 웹 애플리케이션 서버를 개발한다.
2. 웹 애플리케이션 서버
⭐️2.1. 웹 애플리케이션 서버란 무엇인가?
WAS는 동적으로 HTML 등을 생성하는 서버 프로그램을 말한다. 이 특징은, 정적인 컨텐츠를 서빙하는 WS와 대비된다.
cf) 동적 콘텐츠와 정적 콘텐츠의 차이는 아래와 같다.
- 동적 - 페이지를 동적으로 생성하느냐
- 정적 - 서버에 배된 파일 그대로 반환하느냐
자바 특정적으로 말해보자.
개발하고자하는 “웹 애플리케이션 서버”(Web Application Server, WAS)라 하는것은 기본적으로 Java 기반의 웹 애플리케이션의 기초로 널리 사용되고 있는 서블릿(Servlet)을 실행 시키기 위한 실행환경 즉, 서블릿 컨테이너를 의미한다.
서블릿 컨테이너에 대해 설명해보자.
- 컨테이너란 라이프사이클을 관리하는 역할이라고 볼 수있다.
- 따라서 서블릿 컨테이너라 함은, 서블릿의 생성부터 소멸까지의 라이프 사이클을 관리하는 역할이다
- 서블릿 컨테이너는 WS 와 소켓을 만들고 통신하는 과정을 대신 처리해준다. 개발자는 비지니스 로직에만 집중하면 된다
- 서블릿 개체(object)를 싱글톤으로 관리한다
- 싱글톤이란, 쉽게 말해서 하나의 개체로 관리한다는 뜻이다.
- 싱글톤으로 관리하고, 멀티스레딩으로 구성되기때문에, 상태를 유지하게 설계하면 안된다. Thread safety하지 않게된다
2.1.1. 웹 애플리케이션 서버의 기본동작
Part1에서 개발한 WS는, 브라우저로부터 request를 수신해서 파일을 반환하는 기능을 하는 애플리케이션이다.
WAS라 해도 그 행위 자체는 WS와 크게 다르지 않다.
다른점은 프로그램 로직에 의해 동적으로 작성해서 반환함. 아무리 리치 WAS라도 핵심 역할은 그뿐이다.
웹 애플리케이션 프로그래밍을 위한 프레임워크로, Spring framework가 가장 많이 쓰인다. 그런 프레임워크 또한 결국에는 ‘클라이언트에 반환하는 HTML 등을 생성하는 프로그램’을 좀 더 간편하게 그리고 유지보수가 용이하게 개발할 수 있도록 도움을 주는 개발방법론에 지나지 않는다.
그러한 방법론은 일단 무시하고, “동적으로 HTML을 생성하는 부분”을 완성시키는 것을 목표로 하자.
2.1.2. Get Method Query String
WAS에서도 GET 메서드는 빈번하게 사용된다. 동적이라는 특징 하에, 파라미터가 더 적극적으로 쓰일 수 있을 것이다. (검색 키워드 등)
구글에 "hello google"을 검색했을때의 URL이다.
- ? 뒷부분이 쿼리 스트링이다.
- = 기호로 key 와 value를 구분한다
- & 기호로 key-value 조합을 구분한다
2.1.3. Post Method
GET 메서드의 쿼리 스트링은 연결문자열 형식을 전달되므로, 대량의 데이터를 송신하기에 적합하지 않다.
프로토콜에 대한 규약을 정의한 RFC 2616이나, RFC 3986에서는 쿼리 스트링의 문자열 길이에 대한 제한에 대한 정의는 딱히 존재하지 않는다. 하지만 실제 구현체(브라우져)는 구현해두고 있다 (ex. 사파리 브라우저는 80,000자) 따라서 대량의 데이터를 쿼리스트링으로 송신하는 것은 데이터 소실 가능성이 다분하다고 볼 수 있다.
따라서, 컨텐츠 본문을 올리거나 첨부파일을 업로드하는 경우에는 POST를 쓰자.
POST가 무엇인지 알아보자
2.2. POST 수신하기
HTTP request에는 총 8개 메서드가 존재하지만, GET 메서드와 POST 메서드가 압도적이다.
POST는 브라우저로부터 어떤 데이터를 서버에 송신할때 사용한다
2.2.1. HTML <Form> Tag
브라우저에서 어떤 데이터를 서버에 POST 하기 위해서는, HTML의 <form> 태그를 사용한다.
html/form.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>POST 메서드 테스트</title>
</head>
<body>
<form action="http://localhost:8001/post_method_text" method="post">
텍스트 박스: <input type="text" name="text_name"/><br/>
패스워드: <input type="password" name="password_name"/><br/>
텍스트 영역<br/>
<textarea name="textarea_name" rows="4" cols="40"></textarea>
<table border="1">
<tr>
<td>
라디오 버튼: <br/>
<input type="radio" name="radio_name" value="radio1">1
<input type="radio" name="radio_name" value="radio2">2
<input type="radio" name="radio_name" value="radio3">3
<input type="radio" name="radio_name" value="radio4">4
</td>
</tr>
<tr>
<td>
체크박스: <br/>
<input type="checkbox" name="check_name" value="check1">1
<input type="checkbox" name="check_name" value="check2">2
<input type="checkbox" name="check_name" value="check3">3
<input type="checkbox" name="check_name" value="check4">4
</td>
</tr>
</table>
<input type="hidden" name="hidden_name" value="hidden_value"/>
파일 업로드: <input type="file" name="file_name"/><br/>
<input type="submit" name="submit_name" value="보내기">
</form>
</body>
</html>
브라우저로 열어보면 아래와 같다
최근의 웹 애플리케이션의 화면구성은 복잡한 계층구조로 화면이 설계되고 있기에, <form> 태그의 구조를 탈피한 방법이 사용되고 있다. 하지만, 화면개발이 목적이 아닌 WAS 개발을 목적으로 하기에 POST 메서드를 구현하기 위한 용도로는 단순한 <form> 태그만으로도 충분하다
2.2.2. POST로 전송되는 데이터
브라우저가 보내는 데이터를 확인해보자.
TcpServer.java를 똑같이 이용하자.
단, POST로 바이너리 데이터를 송신할때 브라우저가 0을 보낼 수도 있기때문에 기존 코드에서 0으로 종료마크로 약속했던 부분을 -1로 바꾸겠다.
또한, server_send.txt를 클라이언트에 반환하는 기능이 필요없기 때문에 해당 부분도 수정하겠다.
src/network/TcpServer.java
package network;
-import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
-import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class TcpServer {
public static void main(String[] argv) throws Exception {
try (ServerSocket server = new ServerSocket(8001);
- FileOutputStream fos = new FileOutputStream("server_recv.txt");
- FileInputStream fis = new FileInputStream("server_send.txt")) {
+ FileOutputStream fos = new FileOutputStream("server_recv.txt")) {
System.out.println("클라이언트로부터의 접속을 기다리고 있습니다.");
Socket socket = server.accept();
System.out.println("클라이언트 접속");
int ch;
//클라이언트로부터 수신한 내용을 server_recv.txt에 출력
InputStream input = socket.getInputStream();
- //클라이언트는, 종료 마크로 0을 송신한다.
- while ((ch = input.read()) != 0) {
+ while ((ch = input.read()) != -1) {
fos.write(ch);
}
- //server_send.txt 내용을 클라이언트에 송신
- OutputStream output = socket.getOutputStream();
- while ((ch = fis.read()) != -1) {
- output.write(ch);
- }
socket.close();
System.out.println("통신이 종료되었습니다.");
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
TcpServer.java 를 동작시키고, 브라우저에서 form.html을 열어 내용을 채우고 보내보자.
server_recv.txt
POST /post_method_text HTTP/1.1
Host: localhost:8001
Connection: keep-alive
Content-Length: 316
Cache-Control: max-age=0
sec-ch-ua: "Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
Origin: null
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.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: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
text_name=%ED%85%8D%EC%8A%A4%ED%8A%B8%EB%B0%95%EC%8A%A4&password_name=1234&textarea_name=%E3%84%B1%E3%84%B4%E3%84%B7%E3%84%B9+1%0D%0A%E3%85%81%E3%85%82%E3%85%85%E3%85%87+2&radio_name=radio2&check_name=check2&check_name=check4&hidden_name=hidden_value&file_name=google_logo.png&submit_name=%EB%B3%B4%EB%82%B4%EA%B8%B0
POST의 HTTP request 정보를 확인 할 수 있다.
특징을 확인해보자.
- 파라미터의 Key Value 분리
- 바디를 보자
- <input>요소에 입력된 Key Value 쌍들을 &문자로 연결해서 송신하고,
- <input>요소의 Key Value는 =문자로 연결해서 송신한다.
- 바디의 앞부분 일부분을 보면 아래와 같다는 소리다.
- text_name=%ED%85%8D%EC%8A%A4%ED%8A%B8%EB%B0%95%EC%8A%A4
- &
- password_name=1234
- 바디를 보자
- 입력 값에 대한 encoding
- 입력 값은 %로 encoding된다. Part1의 "4.4 URL encode에 대응하기"에서 취급한 URL encoding방법과 비슷하지만, Key Value 분리시에 적용하는 방법(퍼센트 encoding 방법)은 차이가 있다.
- 차이점은, 기존 URL encoding에서 공백을 +로 치환하는 반면 퍼센트 encoding에서는 %20으로 치환한다는 것이다
- 이는, "application/x-www-form-urlencoded"에서 규정된 encoding 방법이다.
- 퍼센트 encoding의 정의를 정하는 RFC3986에서 정의하고 있지만, "application/x-www-form-urlencoded"에 대해서는 HTML2.0의 정의인 RFC1866에서 별도로 정의하고있다.
- 또한 Text Area(<textarea> 태그)를 통해 송신되는 데이터를 보면, 개행값이 %0D%0A이다. 이는 CR+LF 이다.
- 입력 값은 %로 encoding된다. Part1의 "4.4 URL encode에 대응하기"에서 취급한 URL encoding방법과 비슷하지만, Key Value 분리시에 적용하는 방법(퍼센트 encoding 방법)은 차이가 있다.
- 라디오버튼 및 체크박스 태그요소의 입력 값
- 선택된 요소에 한해서만 value 속성 값이 송신된다.
- Hidden 태그요소의 입력값
- 일반 <input> 태그는, 사용자가 화면에서 입력한 내용이 송신되지만, type="hidden"인 Hidden 태그는 HTML 내용에 임의로 기술되어 있는 value 속성의 값이 송신된다.
- Hidden 태그는 페이지간 데이터를 주고 받는 용도로 사용된다.
- 예를들어,
- 게시판에서 입력화면에서 확인화면으로 이동하고, 마지막으로 입력 완료화면으로 화면이 이동되는 것을 상정한다면, 입력화면에서 입력된 값을 확인화면 내에서 일단 hidden 태그에 대입시켜두고, 입력된 값에 문제가 없을 때는 hidden 태그에 대입된 값을 그대로 POST시켜 데이터베이스에 저장하는 프로세스를 구현할 수 있을 것이다.
- 이처럼 hidden 태그에는 어떠한 액션을 취하기 전에 일시적으로 데이터를 화면에 저장시키는 용도로 사용한다.
- 웹 서버는, GET 방식, POST 방식에 상관없이 어떤 값이든 브라우저에서 요청된 request를 처리해야한다.
- 사용자 A가 게시판 입력화면에서 송신 버튼을 클릭하고, 확인화면에서 다시 저장버튼을 클릭했다고 가정할 때, 이 2개의 버튼 클릭은 완전히 독립된 request로 서버에 도착한다. (이러한 형태를 stateless라 한다)
- 이러한 stateless 특성을 갖는 웹 애플리케이션 내에서 화면에서 입력된 데이터를 다음 화면에 전달시키기위한 방법으로 Hidden태그가 활용된다.
- 예를들어,
- 물론, 일시적으로 입력된 데이터를 유지시키기 위한 방법에는 Hidden 태그 외에도 쿠키 또는 세션을 이용한 방법도 존재한다
- file 태그의 입력값
- 입력 폼에서 .png 파일을 선택했지만, 서버에는 파일명만 송신되고 파일 내용까지는 송신되지 않았다. 이와 관련해서는 "2.2.3. multipart/form-data"에서 설명한다.
2.2.3. multipart/form-data
form 태그로 송신된 데이터는 &로 구분되고, =으로 쌍을 맺고 있었다.
하지만, & 구분자(delimter) 형식은, 파일 업로드 시에는 적합한 방법이 아닐 것이다.
파일을 서버에 업로드 할 때에는, <form> 태그에 ‘enctype’ 속성을 enctype=”multitype/form-data” 와 같이 지정하게된다.
html/form2.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>POST 메서드 테스트</title>
</head>
<body>
<form action="http://localhost:8001/posttest/PostTest" method="post" enctype="multipart/form-data">
텍스트 박스: <input type="text" name="text_name"/><br/>
패스워드: <input type="password" name="password_name"/><br/>
텍스트 영역<br/>
<textarea name="textarea_name" rows="4" cols="40"></textarea>
<table border="1">
<tr>
<td>
라디오 버튼: <br/>
<input type="radio" name="radio_name" value="radio1">1
<input type="radio" name="radio_name" value="radio2">2
<input type="radio" name="radio_name" value="radio3">3
<input type="radio" name="radio_name" value="radio4">4
</td>
</tr>
<tr>
<td>
체크박스: <br/>
<input type="checkbox" name="check_name" value="check1">1
<input type="checkbox" name="check_name" value="check2">2
<input type="checkbox" name="check_name" value="check3">3
<input type="checkbox" name="check_name" value="check4">4
</td>
</tr>
</table>
<input type="hidden" name="hidden_name" value="hidden_value"/>
파일 업로드: <input type="file" name="file_name"/><br/>
<input type="submit" name="submit_name" value="보내기">
</form>
</body>
</html>
form.html 의 <form>태그에 enctype 속성을 추가했다.
마찬가지로, 테스트해보자.
server_recv.txt
POST /posttest/PostTest HTTP/1.1
Host: localhost:8001
Connection: keep-alive
Content-Length: 4586
Cache-Control: max-age=0
sec-ch-ua: "Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
Origin: null
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryQqGWKTzdAnvSP5DO
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.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: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
------WebKitFormBoundaryQqGWKTzdAnvSP5DO
Content-Disposition: form-data; name="text_name"
텍스트박스
------WebKitFormBoundaryQqGWKTzdAnvSP5DO
Content-Disposition: form-data; name="password_name"
1234
------WebKitFormBoundaryQqGWKTzdAnvSP5DO
Content-Disposition: form-data; name="textarea_name"
ㄱㄴㄷㄹ 1
ㅁㅂㅅㅇ 2
------WebKitFormBoundaryQqGWKTzdAnvSP5DO
Content-Disposition: form-data; name="radio_name"
radio2
------WebKitFormBoundaryQqGWKTzdAnvSP5DO
Content-Disposition: form-data; name="check_name"
check2
------WebKitFormBoundaryQqGWKTzdAnvSP5DO
Content-Disposition: form-data; name="check_name"
check4
------WebKitFormBoundaryQqGWKTzdAnvSP5DO
Content-Disposition: form-data; name="hidden_name"
hidden_value
------WebKitFormBoundaryQqGWKTzdAnvSP5DO
Content-Disposition: form-data; name="file_name"; filename="google_logo.png"
Content-Type: image/png
�PNG
IHDR+��)8D�PLTE������4�SD������B7���A7�B54�UD��A��D�����C5=}�������6z�1y��D35�ND��������E6C���������0!�;/�8,�:2�����!�H�C���������������������뢗鈄�g_�H4�-�G<ꥠ陕�k\�D*�ph����A&췴걞�f\�mh闎�cd�.'凊�u묮�ǿᑑ�@?����[S����ͽ����bT念�6#�ML��w�X2�uu���u ���?A�����L'��>�c�������"��n������ۦ�����2r��V�������ϸ������]�����u�ߢ��)b�x��`�:�����)�ˡ��.F�B���H�g\�N�⻂ȕN�s5�����<�����W�k8��?��=��:��6�z3����������#�IDATx��w���+�Z�2ؑ-ۊdDz���0�e��R��JKym! �H�4^�i���ջW�ٖ�G"�R�=����|��>t�"&L�0a &L�0a &L�0a��7��$�v��qu3nM��T@�1�@8n�n�G3�0]��ę��O�����G DǠ��~r��ܩOˢ(6PDQ*�6w��̬�<=�[�&&8}���"I����RIg�����hZ�O��Xn��T*��CIR�KRy��5G�
���NjbC�9N�ұ�S�K��K��W=+&����q�CߪgF-�_�Q_w�� �ί~�(Q���0�J�8I�= ��ɏ�
��E�QJ�T�P}�=*5nwj�s�q )5���/e�J�s��S�(Y=�"J�X̉z�T�|�pV~Q�aϙz�qs�{-ϝ�x"���Z
F?G3~�S0ϭpM�9:5�cNJ�ӄ���R��./�(��u*ɖU!&}���Z��S�)<�<���$�F��j�Z�P���l���,,/��T�b���T� V�YO��x�+�V����&�bE���ym��vf�cE�!�ڒ�N<��"�:L+�./���:'�R�h�����0+(,q�r�u+30��%~g��<����u��
W�w]�wV�K)+V4zV,V@���R�����3��!�;+̡Њ�%��(�4�R���K���Ie�,pV�/��YQ�RC�~��ɩ:�����s�%Q�[�ҭ�D�����J�
,�k�8iq��_�Q?1W�蔊�k��_�1�ܻo �)Q47Ҋ*��������
~=aA��V�<���
|%�*#�[��)0�����HI�T����xvr<��)�e%�su�HAuj>y��n��8�[G\��`�D&U�)�Ԥr�!�Zp�4�H���$��An�ڑoȊ/�pҒMݔR1�<�n�+�MR��O�I�%+�_K�z�P�
�*��u�.]�1�.O��]��6�74�2�J��nώ�C���[7ZAx�r�L�K���NJa���d��A]�/-@�
Pm�`���l��V��7�JzV�.��F���E!+��`I��UU�� x��YE�15�i5�Th� ��$�b�;�,uNj���u�nU���\�"��jU����pe�[]�O`+ܳ�B��cIY�V)/�V�<�D��u�,յ*��9݊'I�YA�J��ʳ��UG���j�
s�w)e5x�+�P����-�N+;3���*�V��J~�o��g��VA�Z'+:��&��E'�`�� بT�a+6O�]�py���C+�^���<9�f�Hw���\�"�+TVzVl��Q�'2ǜ�8�}g��$�ҫ�g��J&� ��
�uk�Y��|^}e�LT+G\k��X�Y�땔b�l�*�td5!��
lP���Q)V�7��5��_Dg8ɯ��:�+l��G+֠ >�M/�:��1�&
�Y-Z)M����}XW/�ȗ�$������rA�b��������-�����;�V��J�J��*�۠�^���8l�eh5�e#��á�&��������f�V����X�qΰ�7�"7�2>�"�
��]���� o��W��Vi�Y�5d�gnZ�nQ�����Ȋ�8�U��J�Й`
I���?�rgc/��LdFe��*���=/�x��YU����sFW�$+z���V}hE������o�r��
�m<����U|ӽ�|��d��*�,�n+Mpd�th�sVo}XW�ʰV,�C��D"Y��Fب!,�Y�� �v�+ܴzĒ�V��AE"���Ng�q�+��A�����Qè=��~�Ե��W]�P�M+�j�
b����Uu�VhBR�G����M�Z=��W������d����癄�UtbşV�k�~BB=�HvKp|�K^XK$�A���]�\�§9��[a���_�
�B��7��?�U��-�ܴB���W'���b���H���B'�9�s��[�[+�����h��j�d���� x���XǣE��V`���zS����N.-�3Q�E�bb��
|�?�h���ڿ/\�ȩV�={�-0]r=@\�/�](a����t1�?
E=;���t�3�O�T��ڪ���`z-W,X�>���V
�p3ܱ���r�l`5!��
�������F��Z)MP�*w���(+4)m��?Cx?-�V�Q�x9�M*/���5�p�L����}���9M$�������&�j�H*4��Qoh�ì����RL$��X�<������ �rv��u����7[JK����a�x.� ���B/�P+4Ê��V���_��%+�)�0;?G��ɢ��_zbU��a��e��n�/囻[��:�D���x17d�Κ���0��
�M[�*&���v��T�n�>Ȳ�s�h���g���UrU� �[A�]=v� ��&�(�lv�d<�oUt{�����l-�Z̿s����[�K�`�R�qN5h�����.��q�ŷ�zD%�c>Z�R~�&Ǻ�\n
�Y�4c
[����ʭ� ��9�N]���~�Ɠ�b�8� `Vl�ΰ��
�֯k�\"���އĊ����J�f���o�bFK+�P�F,�Z�Ҳ1QUi+��#�ImZ�����o�+�k�-�XFU��2M�
A�R3zކU��S�T2PV�
���+�V�"��s��p���e��VY���ց�"@�V�G���u�U�����V[�P�S�J�h��,��P�k@��hE��4���:����[FR��R�5��y�j��v~A�J5[�����M�;�5HE+%�
-�H��t.�5|�@[���[̨����Ý�&{���]`���<>M���ڑ�*"ۏU�V���=嫪��v�w�U�ޫ|m�������1�Of���n����݆V�����u�]A�}7O��n�~5���C+�9�V��U�q�
����9�bX���1�]+6�:渋a��S�w1|`#����V�ZYOhe=����V�ZYOhe=����V�ZYOhe=����V���y�r�IEND�B`�
------WebKitFormBoundaryQqGWKTzdAnvSP5DO
Content-Disposition: form-data; name="submit_name"
보내기
------WebKitFormBoundaryQqGWKTzdAnvSP5DO--
multipart/form-data의 specification 은 RFC7578에서 정의하고 있다.
Content-Type request 필드를 보면 boundary=----WebKitFormBoundaryQqGWKTzdAnvSP5DO 라고 되어있다.
실제 경계선에서는 하이픈이 2개 더 쓰여져있다. (------WebKitFormBoundaryQqGWKTzdAnvSP5DO)
끝 경계선의 말미에는 하이픈 2개가 추가된다. (------WebKitFormBoundaryQqGWKTzdAnvSP5DO--)
이에 관해서는 RFC2046에서 그렇게 정의되어있다.
2.3. SERVLET
POST 가 어떤 기능을 하는지 이해했으니, 서블릿 컨테이너를 만들어보자.
2.3.1. Servlet이란 무엇인가?
서블릿 컨테이너를 만들기 앞서, 우선 서블릿이 무엇인지 이해할 필요가 있다.
최근에는 Spring Framework 등을 이용해서 웹 애플리케이션을 개발하는 방식을 많이 취하기때문에, 서블릿을 실제로 구현해서 개발하는 경우가 드물다.
서블릿이란 HTTP request에 대응하는 response를 생성하기 위해 Java로 구현된 프로그램을 의미한다. 프로그램은 기본적으로 HttpServlet 클래스를 상속해서 작성한다.
HttpServlet 클래스에는, doGet()이나, doPost() 등의 메서드가 있고, 이 메서드들은 그 이름대로 각 GET 메서드, POST메서드를 통해 수신된 입력 파라미터를 바탕으로 뭔가를 처리하는 기능을 수행하는 메서드이다.
웹 애플리케이션 프로그램은 기본적으로 doGet()이나 doPost()를 Override해서 구현한다.
즉, 서블릿 인터페이스에 맞게 구현을 하면, 서블릿 컨테이너가 실행시켜주는 관계인 것이다.
서로간의 규약이라고 이해하면 쉽다.
규모가 있는 웹 애플리케이션을 서블릿으로만 구현한다고 하자.
게시판, 블로그, 쇼핑몰 사이트 등 어느정도 규모가 있다면, 여러개의 서블릿을 조합해서 구현해야할 필요가 있다.
서블릿이 여러개 존재할 경우에는 유입된 URL과 서블릿을 매핑시킬 필요가 있다. 이는 설정파일(web.xml)을 통해 대응한다.
서블릿 인스턴스는 서블릿 클래스당 1개의 인스턴스로, 해당 클래스에 최초로 액세스가 발생했을때 생성된다.
과거에는 웹 애플리케이션이라고하면 CGI(Common Gateway Interface)를 말하는 것이었다.
CGI라는 것은, 쿼리 스트링을 환경변수에 설정해서, request body를 표준입력으로 취급하는 형태로, 외부 프로세스를 기동시키는 단순한 구조였다. CGI는, request가 있을때마다 새로운 프로세스를 기동시키는 구조인데 이러한 방식은 당연하게도 서버에 부하를 가중시키는 구조이므로, 어느정도 규모가 있는 애플리케이션을 개발하기에는 부적합하다.
서블릿에서는 인스턴스 재사용을 통해 위의 단점을 극복한다. 또한 서블릿 구조 자체는 CGI와 큰 차이가 없을 정도로 단순하다.
2.3.2. Tomcat 인스톨
본격적으로 서블릿 컨테이너의 구현에 앞서, 서블릿 컨테이너로 가장 널리 사용되는 Tomcat을 통해 서블릿이 어떤 것인지 확인해보자.
기존 8080포트로 동작중인 nginx용 컨테이너를 내려주고, tomcat용 컨테이너를 띄우자.
docker run -d --name tomcat -p 8080:8080 tomcat
동작을 확인해보자. 브라우저에서 http://localhost:8080 로 접근하면 아래와 같은 모습을 볼 수 있다.
2.3.3. Tomcat용 게시판 만들기
tomcat 상에서 동작하는 게시판 서블릿 프로그램을 만들어 보자
우선, 서블릿 클래스를 참조하기 위한 작업이 필요하다.
servlet API는 JDK의 표준라이브러리로 포함되어있진 않다. 따라서 tomcat의 servlet-api.jar을 쓰면된다.
현재 tomcat을 도커 컨테이너로 기동시켰기에 host에서 작업하고자 servlet-api.jar을 복사해오자.
도커에서 복제 명령어를 제공하므로 사용하자 (https://virtualtech.tistory.com/316)
Container -> Host 로 복제하는 경우이다.
docker cp containerID:container경로 host경로
docker cp tomcat:/usr/local/tomcat/lib/servlet-api.jar ~/study/WebApplicationFromScratch/lib
IntelliJ 에서 작업중이므로, IntelliJ에 Jar 의존성을 알려주자
Project Structure -> Modules -> Dependencies 에서 servlet-api.jar을 추가해주자.
이제 서블릿을 작성해보자.
게시판 웹 애플리케이션의 이름은 testBoard로 하겠다.
src/tomcat/ReadArticle.java
package board;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Iterator;
public class ReadArticle extends HttpServlet {
private static final long serialVersionUID = -7662069142361041281L;
public ReadArticle() {
}
private String escapeHtml(String src) {
return src.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<html>");
out.println("<head>");
out.println("<title>테스트용 게시판</title>");
out.println("</head>");
out.println("<body>");
out.println("<h1>테스트용 게시판</h1>");
out.println("<form action='/testbbs/PostBBS' method='post'>");
out.println("제목 : <input type='text' name='title' size='60'><br/>");
out.println("작성자 : <input type='text' name='handle'><br/>");
out.println("<textarea name='message' rows='4' cols='60'></textarea><br/>");
out.println("<input type='submit'/>");
out.println("</form>");
out.println("<hr/>");
Iterator var5 = Article.articleList.iterator();
while(var5.hasNext()) {
Article article = (Article)var5.next();
out.println("<p>[" + this.escapeHtml(article.title) + "] " + this.escapeHtml(article.handle) + " 님 " + this.escapeHtml(article.date.toString()) + "</p>");
out.println("<p>");
out.println(this.escapeHtml(article.message).replace("\r\n", "<br/>"));
out.println("</p><hr/>");
}
out.println("</body>");
out.println("</html>");
}
}
src/tomcat/PostArticle.java
package board;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
public class PostArticle extends HttpServlet {
private static final long serialVersionUID = 2669311655973587819L;
public PostArticle() {
}
public void doPost(HttpServletRequest request, HttpServletResponse response) throws UnsupportedEncodingException, IOException {
request.setCharacterEncoding("UTF-8");
Article newArticle = new Article(request.getParameter("title"),
request.getParameter("handle"),
request.getParameter("message"));
Article.articleList.add(0, newArticle);
response.sendRedirect("/testBoard/ReadArticle");
}
}
src/tomcat/Article.java
package board;
import java.util.ArrayList;
import java.util.Date;
public class Article {
public static ArrayList<Article> articleList = new ArrayList();
String title;
String handle;
String message;
Date date;
Article(String title, String handle, String message) {
this.title = title;
this.handle = handle;
this.message = message;
this.date = new Date();
}
}
배포를 해보자. tomcat에 컴파일한 서블릿(class 파일)을 배치시키는 것이다. 당연하게도, 서블릿 컨테이너가 동작되게끔 올바른 위치에 배치시켜야한다.
웹 애플리케이션 배포 절차를 순서대로 설명하면 아래와 같다.
- tomcat 하위 디렉토리의 webapps 디렉토리 밑에, testBoard 이름으로 디렉토리를 생성한다. 해당 이름이 게시판 서비스의 웹 애플리케이션 이름이 된다. 웹 애플리케이션명은 URL 의 일부로도 포함되기 때문에, 디렉토리명을 변경하려면, PostArticle.java의 Redirect 주소 부분도 변경해줘야한다.
- testBoard 디렉토리 밑에 WEB-INF 디렉토리를 생성한다. 해당 이름은 tomcat에서 고정값으로 인식하는 이름이므로 지켜줘야한다.
- WEB-INF 디렉토리 밑에 web.xml 파일을 생성한다
- WEB0INF 디렉토리 안에 classes 디렉토리를 생성하고, 그 안에 서블릿을 컴파일한 클래스 파일을 배치한다
도커 컨테이너로 기동중이므로, 볼륨 마운트 하여 다시 띄우자
docker run -d --name tomcat -p 8080:8080 -v ~/study/WebApplicationFromScratch/webapps:/usr/local/tomcat/webapps tomcat
터미널에서 직접 컴파일시에는 classpath를 주면 된다.
javac -classpath ~/study/WebApplicationFromScratch/lib/servlet-api.jar -d ~/study/WebApplicationFromScratch/webapps/testboard/WEB-INF/classes ~/study/WebApplicationFromScratch/src/board/*.java
webapps/testboard/WEB-INF/web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
version="5.0"
metadata-complete="true">
<servlet>
<servlet-name>ReadArticle</servlet-name>
<servlet-class>board.ReadArticle</servlet-class>
</servlet>
<servlet>
<servlet-name>PostArticle</servlet-name>
<servlet-class>board.PostArticle</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>ReadArticle</servlet-name>
<url-pattern>/ReadArticle</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>PostArticle</servlet-name>
<url-pattern>/PostArticle</url-pattern>
</servlet-mapping>
</web-app>
ReadArticle만 봐보자.
- <servlet-name>이 1개의 서블릿을 특정하는 키 값으로 인식한다.
- <servlet-class>로 ReadArticle 서블릿에 대응하는 클래스명이 ReadArticle이라는 것을 정의한다.
- <url-pattern>로 그 서블리에 대응하는 URL을 정의한다.
- url-pattern 에서 지정하는 URL 은 /ReadArticle 뿐이지만 앞에는 웹 애플리케이션 명이 붙는다.
- /testBoard/ReadArticle
- 전체 URL은 다음과 같다
- http://localhost:8080/testBoard/ReadArticle
- url-pattern 에서 지정하는 URL 은 /ReadArticle 뿐이지만 앞에는 웹 애플리케이션 명이 붙는다.
2.3.4. JSP
위까지 해서 게시판 관련 서블릿이 완성되었다.
게시물을 화면에 표시하는 행위를 ReadArticle.java 의 doGet() 메서드안에서 out.println()을 통해 처리하고있기때문에, 화면의 디자인 등이 변경되거나 하면, 소스를 수정하고 다시 컴파일 하는 등 여간 불편한 일이 아닐 수 없다.
과거에는 CGI와 Perl 등의 언어로 웹 애플리케이션을 개발할 때는 , 백엔드와 프론트엔드의 분리가 불가능했기에 어쩔수 없었지만, Java의 경우는 JSP(Java Server Page)를 사용하면 ASP나 PHP같이 HTML 태그를 임베디드 형태로 삽입하여 웹 애플리케이션을 개발하는 것이 가능하기 때문에, ReadArticle.java를 JSP로 전환해보자
tomcat에 배치할 webapps 관련해서 계층구조는 아래와 같다.
└── webapps
├── testboard
│ └── WEB-INF
│ ├── classes
│ │ └── board
│ │ ├── Article.class
│ │ ├── PostArticle.class
│ │ └── ReadArticle.class
│ └── web.xml
└── testboard_jsp
├── ReadBoard.jsp
└── WEB-INF
├── classes
│ └── board
│ ├── Article.class
│ ├── PostArticle.class
│ └── ReadArticle.class
└── web.xml
webapps/testboard_jsp/ReadArticle.jsp
<%@ page contentType="text/html;charset=UTF-8"
pageEncoding="UTF-8" %>
<%@ page import="board.Article" %>
<%!
// HTML에서 키워드로 사용되는 문자를 이스케이프 처리하는 유틸리티 메소드
private String escapeHtml(String src) {
return src.replace("&", "&").replace("<", "<")
.replace(">", ">").replace("\"", """)
.replace("'", "'");
}
%>
<html>
<head>
<title>테스트용 게시판</title>
</head>
<body>
<h1>테스트용 게시판</h1>
<form action="/testBoard_jsp/PostArticle" method="POST">
제목 : <input type="text" name="title" size="60"/><br/>
작성자 : <input type="text" name="handle"/><br/>
<textarea name="message" rows="4" cols="60"></textarea><br/>
<input type="submit"/>
</form>
<hr/>
<%
for (Article article : Article.articleList) {
%>
<p>『<%= escapeHtml(article.title) %>』
<%= escapeHtml(article.handle) %> 님
<%= escapeHtml(article.date.toString()) %>
</p>
<p>
<%= escapeHtml(article.message).replace("\r\n", "<br/>") %>
</p>
<hr/>
<%
}
%>
</body>
</html>
게시판이 정상적으로 동작하려면, PostArticle클래스와 Article 클래스가 필요하지만, 이 클래스를 배치하기에는 약간의 태크닉이 필요하다.
Java 시스템에 관련한 이야기인데, 간단히 얘기하자면 다음과 같다.
- Article.java 를 package board; 를 작성해준다. (이미 해둠)
- 그리고 JSP 는 Article.java와는 다른 패키지에 배치되기 때문에, 각 멤버변수를 public으로 두어야 액세스 가능해진다.
package board;
import java.util.ArrayList;
import java.util.Date;
public class Article {
public static ArrayList<Article> articleList = new ArrayList();
public String title;
public String handle;
public String message;
public Date date;
Article(String title, String handle, String message) {
this.title = title;
this.handle = handle;
this.message = message;
this.date = new Date();
}
}
PostArticle.java의 경우, 게시물 저장 후 게시물 표시화면으로 리다이렉트한다.
package board;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
public class PostArticle extends HttpServlet {
private static final long serialVersionUID = 2669311655973587819L;
public PostArticle() {
}
public void doPost(HttpServletRequest request, HttpServletResponse response) throws UnsupportedEncodingException, IOException {
request.setCharacterEncoding("UTF-8");
Article newArticle = new Article(request.getParameter("title"), request.getParameter("handle"), request.getParameter("message"));
Article.articleList.add(0, newArticle);
response.sendRedirect("/testBoard_jsp/ReadBoard.jsp");
}
}
컴파일해서 배치해보자.
http://localhost:8080/testboard_jsp/ReadBoard.jsp 으로 접근한 모습이다.
cf) JSP는 어떻게 실행되는 것일까?
readBoard.jsp는 일단 자동으로 서블릿 소스로 변환된 뒤에 컴파일되서 실행된다.
root@af43cab2ff66:/usr/local/tomcat/work/Catalina/localhost/testboard_jsp/org/apache/jsp# ls
ReadBoard_jsp.class ReadBoard_jsp.java
root@af43cab2ff66:/usr/local/tomcat/work/Catalina/localhost/testboard_jsp/org/apache/jsp# cat ReadBoard_jsp.java
/*
* Generated by the Jasper component of Apache Tomcat
* Version: Apache Tomcat/10.1.23
* Generated at: 2024-05-03 09:55:58 UTC
* Note: The last modified time of this file was set to
* the last modified time of the source file after
* generation to assist with modification tracking.
*/
package org.apache.jsp;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import jakarta.servlet.jsp.*;
import board.Article;
public final class ReadBoard_jsp extends org.apache.jasper.runtime.HttpJspBase
implements org.apache.jasper.runtime.JspSourceDependent,
org.apache.jasper.runtime.JspSourceImports,
org.apache.jasper.runtime.JspSourceDirectives {
// HTML에서 키워드로 사용되는 문자를 이스케이프 처리하는 유틸리티 메소드
private String escapeHtml(String src) {
return src.replace("&", "&").replace("<", "<")
.replace(">", ">").replace("\"", """)
.replace("'", "'");
}
private static final jakarta.servlet.jsp.JspFactory _jspxFactory =
jakarta.servlet.jsp.JspFactory.getDefaultFactory();
private static java.util.Map<java.lang.String,java.lang.Long> _jspx_dependants;
private static final java.util.Set<java.lang.String> _jspx_imports_packages;
private static final java.util.Set<java.lang.String> _jspx_imports_classes;
static {
_jspx_imports_packages = new java.util.LinkedHashSet<>(3);
_jspx_imports_packages.add("jakarta.servlet");
_jspx_imports_packages.add("jakarta.servlet.http");
_jspx_imports_packages.add("jakarta.servlet.jsp");
_jspx_imports_classes = new java.util.LinkedHashSet<>(1);
_jspx_imports_classes.add("board.Article");
}
private volatile jakarta.el.ExpressionFactory _el_expressionfactory;
private volatile org.apache.tomcat.InstanceManager _jsp_instancemanager;
public java.util.Map<java.lang.String,java.lang.Long> getDependants() {
return _jspx_dependants;
}
public java.util.Set<java.lang.String> getPackageImports() {
return _jspx_imports_packages;
}
public java.util.Set<java.lang.String> getClassImports() {
return _jspx_imports_classes;
}
public boolean getErrorOnELNotFound() {
return false;
}
public jakarta.el.ExpressionFactory _jsp_getExpressionFactory() {
if (_el_expressionfactory == null) {
synchronized (this) {
if (_el_expressionfactory == null) {
_el_expressionfactory = _jspxFactory.getJspApplicationContext(getServletConfig().getServletContext()).getExpressionFactory();
}
}
}
return _el_expressionfactory;
}
public org.apache.tomcat.InstanceManager _jsp_getInstanceManager() {
if (_jsp_instancemanager == null) {
synchronized (this) {
if (_jsp_instancemanager == null) {
_jsp_instancemanager = org.apache.jasper.runtime.InstanceManagerFactory.getInstanceManager(getServletConfig());
}
}
}
return _jsp_instancemanager;
}
public void _jspInit() {
}
public void _jspDestroy() {
}
public void _jspService(final jakarta.servlet.http.HttpServletRequest request, final jakarta.servlet.http.HttpServletResponse response)
throws java.io.IOException, jakarta.servlet.ServletException {
if (!jakarta.servlet.DispatcherType.ERROR.equals(request.getDispatcherType())) {
final java.lang.String _jspx_method = request.getMethod();
if ("OPTIONS".equals(_jspx_method)) {
response.setHeader("Allow","GET, HEAD, POST, OPTIONS");
return;
}
if (!"GET".equals(_jspx_method) && !"POST".equals(_jspx_method) && !"HEAD".equals(_jspx_method)) {
response.setHeader("Allow","GET, HEAD, POST, OPTIONS");
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, "JSPs only permit GET, POST or HEAD. Jasper also permits OPTIONS");
return;
}
}
final jakarta.servlet.jsp.PageContext pageContext;
jakarta.servlet.http.HttpSession session = null;
final jakarta.servlet.ServletContext application;
final jakarta.servlet.ServletConfig config;
jakarta.servlet.jsp.JspWriter out = null;
final java.lang.Object page = this;
jakarta.servlet.jsp.JspWriter _jspx_out = null;
jakarta.servlet.jsp.PageContext _jspx_page_context = null;
try {
response.setContentType("text/html;charset=UTF-8");
pageContext = _jspxFactory.getPageContext(this, request, response,
null, true, 8192, true);
_jspx_page_context = pageContext;
application = pageContext.getServletContext();
config = pageContext.getServletConfig();
session = pageContext.getSession();
out = pageContext.getOut();
_jspx_out = out;
out.write("\r\n");
out.write("\r\n");
out.write("\r\n");
out.write("<html>\r\n");
out.write("<head>\r\n");
out.write(" <title>테스트용 게시판</title>\r\n");
out.write("</head>\r\n");
out.write("<body>\r\n");
out.write("<h1>테스트용 게시판</h1>\r\n");
out.write("<form action=\"/testBoard_jsp/PostArticle\" method=\"POST\">\r\n");
out.write(" 제목 : <input type=\"text\" name=\"title\" size=\"60\"/><br/>\r\n");
out.write(" 작성자 : <input type=\"text\" name=\"handle\"/><br/>\r\n");
out.write(" <textarea name=\"message\" rows=\"4\" cols=\"60\"></textarea><br/>\r\n");
out.write(" <input type=\"submit\"/>\r\n");
out.write("</form>\r\n");
out.write("<hr/>\r\n");
for (Article article : Article.articleList) {
out.write("\r\n");
out.write("<p>『");
out.print( escapeHtml(article.title) );
out.write("』 \r\n");
out.write(" ");
out.print( escapeHtml(article.handle) );
out.write(" 님 \r\n");
out.write(" ");
out.print( escapeHtml(article.date.toString()) );
out.write("\r\n");
out.write("</p>\r\n");
out.write("<p>\r\n");
out.write(" ");
out.print( escapeHtml(article.message).replace("\r\n", "<br/>") );
out.write("\r\n");
out.write("</p>\r\n");
out.write("<hr/>\r\n");
}
out.write("\r\n");
out.write("</body>\r\n");
out.write("</html>");
} catch (java.lang.Throwable t) {
if (!(t instanceof jakarta.servlet.jsp.SkipPageException)){
out = _jspx_out;
if (out != null && out.getBufferSize() != 0)
try {
if (response.isCommitted()) {
out.flush();
} else {
out.clearBuffer();
}
} catch (java.io.IOException e) {}
if (_jspx_page_context != null) _jspx_page_context.handlePageException(t);
else throw new ServletException(t);
}
} finally {
_jspxFactory.releasePageContext(_jspx_page_context);
}
}
}
변환된 JSP소스는 Tomcat이 인스톨된 폴더 하위의 아래 위치한 ReadBoard_jsp.java 파일명으로 생성되어있다.
out.write()로 연속된 부분이, 서블릿과 동일한 구조로 response에 대한 결과를 반환하는 처리이다.
또한 해당 JSP소스에서는, request, response, out 등의 변수가 처음부터 사용할 수 있는 것으로 보인다. 이는 JSP 파일이 자바소스로 변환될 때 org.apache.jsp 패키지에 배치되기 때문으로, Article 클래스를 board 패키지에 배치시킨 뒤 임포트 시킨 이유이기도 하다.
🚧 중간 Remark
⭐️3. ‘SmallCat/0.3’ 구현
이제 동작과정을 알았고, WAS 구현해보자
3.1. ‘SmallCat/0.3’에서 구현할 SERVLET API
지금까지 Tomcat 상에서 동작하는 게시판 관련 서블릿을 만들어보았다.
이러한 서블릿을 동작시키기 위한 서블릿 컨테이너를 만들어보자.
서블릿 관련 spec은, Java Servlet API로 정의되어 있지만, 여기서는 모든 기능을 갖춘 서블릿 컨테이너를 만드는 것이 아니라, 서블릿 컨테이너의 구조를 이해할 수 있을 정도의 샘플 프로그램을 개발하는 것이 목적이므로, 지금까지 개발한 게시판 서블릿을 동작시킬 수 있는 수준으로 최소한의 API만을 구현한 서블릿 컨테이너를 만들 것이다.
- HttpServlet 클래스
- void doGet(HttpServletRequest req, HttpServletResponse resp)
- void doPost(HttpServletRequest req, HttpServletResponse resp)
- void doService(HttpServletRequest req, HttpServletResponse resp)
- HttpServletRequest Interface
- String getMethod()
- String getParameter(String name)
- String[] getParameterValues(String name)
- void setCharacterEncoding(String env)
- HttpServletResponse Interface
- void setContentType(String contentType)
- void setCharacterEncoding(String charset)
- printWriter getWriter()
- void sendRedirect(String location)
- void setStatus(int sc)
- 상수 SC_OK 및 SC_FOUND
- ServletException 클래스
3.2. ‘SmallCat/0.3’의 구현
- ‘com.smallcat.webserver’ package
- 웹서버로서 필요한 기능을 제공하는 클래스 격납
- ‘com.smallcat.servlet’ package 및 ‘com.smallcat.servlet.http’ package
- Servlet API로서, 서블릿 프로그램이 이용하는 인터페이스를 격납
- 참고로, 서비스를 목적으로 정식 배포하는 서블릿 컨테이너였다면, 관련 클래스들을 javax.servlet 패키지에 격납시켜야한다.
- 하지만 SmallCat/0.3은 샘플용 서블릿 컨테이너이므로, 그런 룰을 무시하고 임의의 패키지에 격납시킨것
- ‘com.smallcat.servletImpl’ package
- servlet 패키지 및 servlet.http 패키지의 인터페이스를 구현한 클래스 등, 서블릿 API의 구현체를 격납
- ‘com.smallcat.util’ package
- 서블릿 컨테이너 처리에 필요한 각종 유틸리티성 클래스를 격납
설명은 책을 따라 정리했으나, 프로젝트로 정리할때는 기존 versioning 하던대로 작성하자.
SmallCat/0.3은 ver3에 해당한다.
.
├── src
│ └── web
│ └── smallcat
│ ├── ver1
│ │ ├── Main.java
│ │ └── ServerThread.java
│ ├── ver2
│ │ ├── Main.java
│ │ ├── MyURLDecoder.java
│ │ ├── SendResponse.java
│ │ ├── ServerThread.java
│ │ └── Util.java
│ └── ver3
│ ├── servlet
│ │ ├── ServletException.java
│ │ └── http
│ │ ├── HttpServlet.java
│ │ ├── HttpServletRequest.java
│ │ └── HttpServletResponse.java
│ ├── servletImpl
│ │ ├── HttpServletRequestImpl.java
│ │ ├── HttpServletResponseImpl.java
│ │ ├── ServletInfo.java
│ │ ├── ServletService.java
│ │ └── WebApplication.java
│ ├── util
│ │ ├── Constants.java
│ │ ├── MyURLDecoder.java
│ │ ├── SendResponse.java
│ │ └── Util.java
│ └── webserver
│ ├── Main.java
│ └── ServerThread.java
3.2.1. ‘com.smallcat.webserver’ package
src/web/smallcat/ver3/webserver/Main.java
package web.smallcat.ver3.webserver;
import web.smallcat.ver3.servletImpl.WebApplication;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] argv) throws Exception {
WebApplication app = WebApplication.createInstance("testBoard2");
app.addServlet("/ReadArticle", "ReadArticle");
app.addServlet("/PostArticle", "PostArticle");
try (ServerSocket server = new ServerSocket(8001)) {
for (; ; ) {
Socket socket = server.accept();
ServerThread serverThread = new ServerThread(socket);
Thread thread = new Thread(serverThread);
thread.start();
}
}
}
}
WebApplication 클래스를 생성해서, 서블릿 등록을 수행한다.
이 부분은 Tomcat에서는 web.xml을 통해 서블릿을 정의하는 부분에 해당한다. 부가적 기능이므로 패스. 기본기능에 집중해보자
src/web/smallcat/ver3/webserver/ServerThread.java
package web.smallcat.ver3.webserver;
import web.smallcat.ver3.servletImpl.ServletInfo;
import web.smallcat.ver3.servletImpl.ServletService;
import web.smallcat.ver3.servletImpl.WebApplication;
import web.smallcat.ver3.util.Constants;
import web.smallcat.ver3.util.MyURLDecoder;
import web.smallcat.ver3.util.SendResponse;
import web.smallcat.ver3.util.Util;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
public class ServerThread implements Runnable {
private static final String DOCUMENT_ROOT = System.getProperty("user.home") + "/study/WebApplicationFromScratch/html";
private static final String ERROR_DOCUMENT = DOCUMENT_ROOT + "/error";
private Socket socket;
ServerThread(Socket socket) {
this.socket = socket;
}
private static void addRequestHeader(Map<String, String> requestHeader, String line) {
int colonPos = line.indexOf(':');
if (colonPos == -1) return;
String headerName = line.substring(0, colonPos).toUpperCase();
String headerValue = line.substring(colonPos + 1).trim();
requestHeader.put(headerName, headerValue);
}
@Override
public void run() {
OutputStream output = null;
try {
InputStream input = socket.getInputStream();
String line;
String requestLine = null;
String method = null;
Map<String, String> requestHeader = new HashMap<>();
while ((line = Util.readLine(input)) != null) {
if (line.equals("")) {
break;
}
if (line.startsWith("GET")) {
method = "GET";
requestLine = line;
} else if (line.startsWith("POST")) {
method = "POST";
requestLine = line;
} else {
addRequestHeader(requestHeader, line);
}
}
if (requestLine == null) return;
String reqUri = requestLine.split(" ")[1];
String[] pathAndQuery = reqUri.split("\\?");
String path = MyURLDecoder.decode(pathAndQuery[0], "UTF-8");
String query = null;
if (pathAndQuery.length > 1) {
query = pathAndQuery[1];
}
output = new BufferedOutputStream(socket.getOutputStream());
String appDir = path.substring(1).split("/")[0];
WebApplication webApp = WebApplication.searchWebApplication(appDir);
if (webApp != null) {
ServletInfo servletInfo = webApp.searchServlet(path.substring(appDir.length() + 1));
if (servletInfo != null) {
ServletService.doService(method, query, servletInfo, requestHeader, input, output);
return;
}
}
String ext = null;
String[] tmp = reqUri.split("\\.");
ext = tmp[tmp.length - 1];
if (path.endsWith("/")) {
path += "index.html";
ext = "html";
}
FileSystem fs = FileSystems.getDefault();
Path pathObj = fs.getPath(DOCUMENT_ROOT + path);
Path realPath;
try {
realPath = pathObj.toRealPath();
} catch (NoSuchFileException ex) {
SendResponse.sendNotFoundResponse(output, ERROR_DOCUMENT);
return;
}
if (!realPath.startsWith(DOCUMENT_ROOT)) {
SendResponse.sendNotFoundResponse(output, ERROR_DOCUMENT);
return;
} else if (Files.isDirectory(realPath)) {
String host = requestHeader.get("HOST");
String location = "http://" + ((host != null) ? host : Constants.SERVER_NAME) + path + "/";
SendResponse.sendMovePermanentlyResponse(output, location);
return;
}
try (InputStream fis = new BufferedInputStream(Files.newInputStream(realPath))) {
SendResponse.sendOkResponse(output, fis, ext);
} catch (FileNotFoundException ex) {
SendResponse.sendNotFoundResponse(output, ERROR_DOCUMENT);
}
} catch (Exception ex) {
ex.printStackTrace();
} finally {
try {
if (output != null) {
output.close();
}
socket.close();
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}
- POST 메서드도 대응할 수 있도록 수정됨
- request header에 대해서, 종래는 Host 헤더만 참조하는 식의 제한된 대응만 하고있었기에, HashMap을 이용해서 폭넓게 대응할 수 있도록함. 또한 이와 관련된 메서드로 addRequestHeader() 메서드를 정의하고 있음. request 헤더의 이름은 본래, 대문자와 소문자를 구별하지 않기에, 여기서 대문자로 정규화시킨다
- request path의 최초 디렉터리가 main 메서드에서 등록한 웹 애플리케이션 이름과 일치하고 그 뒤의 path가 웹 애플리케이션에 등록한 서블릿의 path와 일치하면, 서블릭 처리(ServletService 클래스의 doService() 메서드)를 호출하도록 하고있다.
서블릿 컨테이너로서 이부분이 가장 중요한 코어이다
3.2.2. ‘com.smallcat.servletImpl’ package
src/web/smallcat/ver3/servletImpl/WebApplication.java
package web.smallcat.ver3.servletImpl;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
public class WebApplication {
private static String WEBAPPS_DIR = System.getProperty("user.home") + "/study/WebApplicationFromScratch/webapps";
private static Map<String, WebApplication> webAppCollection = new HashMap<>();
String directory;
ClassLoader classLoader;
private Map<String, ServletInfo> servletCollection = new HashMap<>();
private WebApplication(String dir) throws MalformedURLException {
this.directory = dir;
FileSystem fs = FileSystems.getDefault();
Path pathObj = fs.getPath(WEBAPPS_DIR + File.separator + dir);
this.classLoader = URLClassLoader.newInstance(new URL[]{pathObj.toUri().toURL()});
}
public static WebApplication createInstance(String dir) throws MalformedURLException {
WebApplication newApp = new WebApplication(dir);
webAppCollection.put(dir, newApp);
return newApp;
}
public static WebApplication searchWebApplication(String dir) {
return webAppCollection.get(dir);
}
public void addServlet(String urlPattern, String servletClassName) {
this.servletCollection.put(urlPattern, new ServletInfo(this, urlPattern, servletClassName));
}
public ServletInfo searchServlet(String path) {
return servletCollection.get(path);
}
}
- 하나의 웹 애플리케이션을 표현함. WebApplication 안에는 static HashMap을 갖고있고, testBoard와 같은 웹 애플리케이션명(디렉토리명)을 key로해서 복수의 WebApplication을 정의할 수 있도록 되어있다.
- 하나의 웹 애플리케이션에는 여러개의 서블릿이 포함되어있다. servletCollection 을 보면 WebApplication 클래스별로 HashMap으로 ServletInfo를 보유하고있음을 볼 수 있다.
- Main.java에서 addServlet() 메서드를 호출해서 WebApplication에 서블릿을 등록하는 것이, 바로 해당 HashMap에 등록하는 행위이다.
- ServerThread.java에서 request URL 이 서블릿 path와 일치하면 서블릿 처리를 호출하는 코드가 있는데, 거기서 사용한 searchServlet() 메서드의 구현 코드가 있다. 로직은 단순하다.
- web.xml의 url-pattern 에 의한 정의에서는 * 를 사용한 와일드카드 등을 사용할 수 있지만, SmallCat/0.3에서는 와일드 카드를 대응하고 있지 않다.
- 서블릿은 처음 한번 액세스 되는 시점에 동적으로 로드되면 이 클래스 로딩과 관련된 코드는 classLoader 멤버변수를 보라. 구체적 사용방법은 후술한다.
src/web/smallcat/ver3/servletImpl/ServletInfo.java
package web.smallcat.ver3.servletImpl;
import web.smallcat.ver3.servlet.http.HttpServlet;
public class ServletInfo {
WebApplication webApp;
String urlPattern;
String servletClassName;
HttpServlet servlet;
public ServletInfo(WebApplication webApp, String urlPattern, String servletClassName) {
this.webApp = webApp;
this.urlPattern = urlPattern;
this.servletClassName = servletClassName;
}
}
Main.java에서는 등록된 URL 패턴이나 서블릿 클래스명 외, 부모인 WebApplication 및 HttpServlet을 갖는데, 이 HttpServlet이 실제로 서블릿 관련 처리를 수행하는 클래스이다.
src/web/smallcat/ver3/servletImpl/ServletService.java
package web.smallcat.ver3.servletImpl;
import web.smallcat.ver3.servlet.http.HttpServlet;
import web.smallcat.ver3.servlet.http.HttpServletRequest;
import web.smallcat.ver3.servlet.http.HttpServletResponse;
import web.smallcat.ver3.util.Constants;
import web.smallcat.ver3.util.SendResponse;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
public class ServletService {
private static HttpServlet createServlet(ServletInfo info) throws Exception {
Class<?> clazz = info.webApp.classLoader.loadClass(info.servletClassName);
return (HttpServlet) clazz.newInstance();
}
private static Map<String, String[]> stringToMap(String str) {
Map<String, String[]> parameterMap = new HashMap<String, String[]>();
if (str != null) {
String[] paramArray = str.split("&");
for (String param : paramArray) {
String[] keyValue = param.split("=", -1);
if (parameterMap.containsKey(keyValue[0])) {
String[] array = parameterMap.get(keyValue[0]);
String[] newArray = new String[array.length + 1];
System.arraycopy(array, 0, newArray, 0, array.length);
newArray[array.length] = keyValue[1];
parameterMap.put(keyValue[0], newArray);
} else {
parameterMap.put(keyValue[0], new String[]{keyValue[1]});
}
}
}
return parameterMap;
}
private static String readToSize(InputStream input, int size) throws Exception {
int ch;
StringBuilder sb = new StringBuilder();
int readSize = 0;
while (readSize < size && (ch = input.read()) != -1) {
sb.append((char) ch);
readSize++;
}
return sb.toString();
}
public static void doService(String method,
String query,
ServletInfo info,
Map<String, String> requestHeader,
InputStream input,
OutputStream output) throws Exception {
if (info.servlet == null) {
info.servlet = createServlet(info);
}
ByteArrayOutputStream outputBuffer = new ByteArrayOutputStream();
HttpServletResponseImpl resp = new HttpServletResponseImpl(outputBuffer);
HttpServletRequest req;
if (method.equals("GET")) {
Map<String, String[]> map;
map = stringToMap(query);
req = new HttpServletRequestImpl("GET", map);
} else if (method.equals("POST")) {
int contentLength = Integer.parseInt(requestHeader.get("CONTENT-LENGTH"));
Map<String, String[]> map;
String line = readToSize(input, contentLength);
map = stringToMap(line);
req = new HttpServletRequestImpl("POST", map);
} else {
throw new AssertionError("BAD METHOD:" + method);
}
info.servlet.service(req, resp);
if (resp.status == HttpServletResponse.SC_OK) {
SendResponse.sendOkResponseHeader(output, resp.contentType);
resp.printWriter.flush();
byte[] outputBytes = outputBuffer.toByteArray();
for (byte b : outputBytes) {
output.write((int) b);
}
} else if (resp.status == HttpServletResponse.SC_FOUND) {
String redirectLocation;
if (resp.redirectLocation.startsWith("/")) {
String host = requestHeader.get("HOST");
redirectLocation = "http://" + ((host != null) ? host : Constants.SERVER_NAME) + resp.redirectLocation;
} else {
redirectLocation = resp.redirectLocation;
}
SendResponse.sendFoundResponse(output, redirectLocation);
}
}
}
- 서블릿 처리의 본체역할을 하는 클래스이다.
- 해당 서블릿 최초 호출시, 아직 서블릿의 인스턴스가 생성되어 있지 않은 경우는, createServlet() 메서드에 의해 클래스 파일을 동적으로 로드하고, 서블릿 인스턴스를 생성한다. (doService() 의 구현 초반부 )
- createServlet() 메서드는 URLClassLoader 클래스를 사용해서, 서블릿 클래스를 로드하고, 클래스의 newInstance()메서드로, 서블릿의 인스턴스를 생성하고 있다.
- 즉, Tomcat에서는 서블릿 프로그램은 Sercurity Manager에 의해 샌드박스 안에서 동작했지만, SmallCat/0.3 에서는 간단하게 서블릿 로딩 기능을 구현하기 위해 샌드박스 기능에 대응하고 있지 않다.
- ByteArrayOutputStream 선언
- 서블릿이 출력하는 response를 일단 버퍼링하기위한 버퍼이다.
- 도중에 예외발생시 변경 가능하게 하기위한 것이다.
src/web/smallcat/ver3/servletImpl/HttpServletRequestImpl.java
package web.smallcat.ver3.servletImpl;
import web.smallcat.ver3.servlet.http.HttpServletRequest;
import web.smallcat.ver3.util.MyURLDecoder;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.util.Map;
public class HttpServletRequestImpl implements HttpServletRequest {
private String method;
private String characterEncoding = "ISO-8859-1";
private Map<String, String[]> parameterMap;
HttpServletRequestImpl(String method, Map<String, String[]> parameterMap) {
this.method = method;
this.parameterMap = parameterMap;
}
@Override
public String getMethod() {
return this.method;
}
@Override
public String getParameter(String name) {
String[] values = getParameterValues(name);
if (values == null) {
return null;
}
return values[0];
}
@Override
public String[] getParameterValues(String name) {
String[] values = this.parameterMap.get(name);
if (values == null) {
return null;
}
String[] decoded = new String[values.length];
try {
for (int i = 0; i < values.length; i++) {
decoded[i] = MyURLDecoder.decode(values[i], this.characterEncoding);
}
} catch (UnsupportedEncodingException ex) {
throw new AssertionError(ex);
}
return decoded;
}
@Override
public void setCharacterEncoding(String env) throws UnsupportedEncodingException {
if (!Charset.isSupported(env)) {
throw new UnsupportedEncodingException("encoding.." + env);
}
this.characterEncoding = env;
}
}
src/web/smallcat/ver3/servletImpl/HttpServletResponseImpl.java
package web.smallcat.ver3.servletImpl;
import web.smallcat.ver3.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
public class HttpServletResponseImpl implements HttpServletResponse {
String contentType = "application/octet-stream";
PrintWriter printWriter;
int status;
String redirectLocation;
private String characterEncoding = "ISO-8859-1";
private OutputStream outputStream;
HttpServletResponseImpl(OutputStream output) {
this.outputStream = output;
this.status = SC_OK;
}
@Override
public void setContentType(String contentType) {
this.contentType = contentType;
String[] temp = contentType.split(" *; *");
if (temp.length > 1) {
String[] keyValue = temp[1].split("=");
if (keyValue.length == 2 && keyValue[0].equals("charset")) {
setCharacterEncoding(keyValue[1]);
}
}
}
@Override
public void setCharacterEncoding(String charset) {
this.characterEncoding = charset;
}
@Override
public PrintWriter getWriter() throws IOException {
this.printWriter = new PrintWriter(new OutputStreamWriter(outputStream, this.characterEncoding));
return this.printWriter;
}
@Override
public void sendRedirect(String location) {
this.redirectLocation = location;
setStatus(SC_FOUND);
}
@Override
public void setStatus(int sc) {
this.status = sc;
}
}
3.2.3. ‘com.smallcat.util’ package
src/web/smallcat/ver3/util/Util.java
package web.smallcat.ver3.util;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Locale;
import java.util.TimeZone;
public class Util {
// 확장자와 Content-Type 대응표
private static final HashMap<String, String> contentTypeMap = new HashMap<>() {
{
put("html", "text/html");
put("htm", "text/html");
put("txt", "text/plain");
put("css", "text/css");
put("png", "image/png");
put("jpg", "image/jpeg");
put("jpeg", "image/jpeg");
put("gif", "image/gif");
put("svg", "image/svg+xml");
}
};
// InputStream에서 바이트 배열을 행단위로 읽어들이는 메소드
public static String readLine(InputStream input) throws Exception {
int ch;
String ret = "";
while ((ch = input.read()) != -1) {
if (ch == '\r') {
// do nothing
} else if (ch == '\n') {
break;
} else {
ret += (char) ch;
}
}
if (ch == -1) {
return null;
} else {
return ret;
}
}
// 한행의 문자열을, 바이트열로 OutputStream에 쓰는 메소드
public static void writeLine(OutputStream output, String str) throws IOException {
for (char ch : str.toCharArray()) {
output.write((int) ch);
}
output.write((int) '\r');
output.write((int) '\n');
}
// 현일시를 HTTP 표준포맷에 맞춘 일시형태로 문자열을 반환
public static String getDateStringUtc() {
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.US);
df.setTimeZone(cal.getTimeZone());
return df.format(cal.getTime()) + " GMT";
}
// 확장자에 부합하는 Content-Type을 반환
public static String getContentType(String ext) {
String ret = contentTypeMap.get(ext.toLowerCase());
if (ret == null) {
return "application/octet-stream";
} else {
return ret;
}
}
}
src/web/smallcat/ver3/util/MyURLDecoder.java
package web.smallcat.ver3.util;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
public class MyURLDecoder {
// 16진수 2자리를 ASCII코드로 나타내는 바이트를 int로 변환
private static int hex2int(byte b1, byte b2) {
int digit;
if (b1 >= 'A') {
// 0xDF와의 &으로 소문자를 대문자로 변환
digit = (b1 & 0xDF) - 'A' + 10;
} else {
digit = (b1 - '0');
}
digit *= 16;
if (b2 >= 'A') {
digit += (b2 & 0xDF) - 'A' + 10;
} else {
digit += b2 - '0';
}
return digit;
}
public static String decode(String src, String enc) throws UnsupportedEncodingException {
byte[] srcBytes = src.getBytes("ISO_8859_1");
// 변환된 뒤가 길어질 일은 없기 때문에, srcBytes의 길이의 배열을 일단 확보할 것
byte[] destBytes = new byte[srcBytes.length];
int destIdx = 0;
for (int srcIdx = 0; srcIdx < srcBytes.length; srcIdx++) {
if (srcBytes[srcIdx] == (byte) '%') {
destBytes[destIdx] = (byte) hex2int(srcBytes[srcIdx + 1], srcBytes[srcIdx + 2]);
srcIdx += 2;
} else {
destBytes[destIdx] = srcBytes[srcIdx];
}
destIdx++;
}
byte[] destBytes2 = Arrays.copyOf(destBytes, destIdx);
return new String(destBytes2, enc);
}
}
src/web/smallcat/ver3/util/SendResponse.java
package web.smallcat.ver3.util;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class SendResponse {
public static void sendOkResponseHeader(OutputStream output, String contentType) throws IOException {
Util.writeLine(output, "HTTP/1.1 200 OK");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: SmallCat/0.3");
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "Content-type: " + contentType);
Util.writeLine(output, "");
}
public static void sendOkResponse(OutputStream output, InputStream fis, String ext) throws Exception {
Util.writeLine(output, "HTTP/1.1 200 OK");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: SmallCat/0.3");
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "Content-type: " + Util.getContentType(ext));
Util.writeLine(output, "");
int ch;
while ((ch = fis.read()) != -1) {
output.write(ch);
}
}
public static void sendMovePermanentlyResponse(OutputStream output, String location) throws Exception {
Util.writeLine(output, "HTTP/1.1 301 Moved Permanently");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: SmallCat/0.3");
Util.writeLine(output, "Location: " + location);
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "");
}
public static void sendFoundResponse(OutputStream output, String location) throws Exception {
Util.writeLine(output, "HTTP/1.1 302 Found");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: SmallCat/0.3");
Util.writeLine(output, "Location: " + location);
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "");
}
public static void sendNotFoundResponse(OutputStream output, String errorDocumentRoot) throws Exception {
Util.writeLine(output, "HTTP/1.1 404 Not Found");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: SmallCat/0.3");
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "Content-type: text/html");
Util.writeLine(output, "");
try (InputStream fis = new BufferedInputStream(new FileInputStream(errorDocumentRoot + "/404.html"))) {
int ch;
while ((ch = fis.read()) != -1) {
output.write(ch);
}
}
}
}
src/web/smallcat/ver3/util/Constants.java
package web.smallcat.ver3.util;
public class Constants {
public static final String SERVER_NAME = "localhost:8001";
}
3.2.4. ‘com.smallcat.servlet’ package
src/web/smallcat/ver3/servlet/ServletException.java
package web.smallcat.ver3.servlet;
public class ServletException extends Exception {
public ServletException(String message) {
super(message);
}
public ServletException(String message, Throwable rootCause) {
super(message, rootCause);
}
public ServletException(Throwable rootCause) {
super(rootCause);
}
}
3.2.5. ‘com.smallcat.servlet.http’ package
src/web/smallcat/ver3/servlet/http/HttpServlet.java
package web.smallcat.ver3.servlet.http;
import web.smallcat.ver3.servlet.ServletException;
public class HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, java.io.IOException {
}
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, java.io.IOException {
}
public void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, java.io.IOException {
if (req.getMethod().equals("GET")) {
doGet(req, resp);
} else if (req.getMethod().equals("POST")) {
doPost(req, resp);
}
}
}
src/web/smallcat/ver3/servlet/http/HttpServletRequest.java
package web.smallcat.ver3.servlet.http;
import java.io.UnsupportedEncodingException;
public interface HttpServletRequest {
String getMethod();
String getParameter(String name);
String[] getParameterValues(String name);
void setCharacterEncoding(String env) throws UnsupportedEncodingException;
}
src/web/smallcat/ver3/servlet/http/HttpServletResponse.java
package web.smallcat.ver3.servlet.http;
import java.io.IOException;
import java.io.PrintWriter;
public interface HttpServletResponse {
static final int SC_OK = 200;
static final int SC_FOUND = 302;
void setContentType(String contentType);
void setCharacterEncoding(String charset);
PrintWriter getWriter() throws IOException;
void sendRedirect(String location);
void setStatus(int sc);
}
3.3. ‘SmallCat/0.3’에 게시판 배포
서블릿 API를 SmallCat/0.3 용으로 참조하도록 수정하자.
src/board/PostArticle.java
package board;
-import jakarta.servlet.http.HttpServlet;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
+import web.smallcat.ver3.servlet.http.HttpServlet;
+import web.smallcat.ver3.servlet.http.HttpServletRequest;
+import web.smallcat.ver3.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
public class PostArticle extends HttpServlet {
private static final long serialVersionUID = 2669311655973587819L;
public PostArticle() {
}
public void doPost(HttpServletRequest request, HttpServletResponse response) throws UnsupportedEncodingException, IOException {
request.setCharacterEncoding("UTF-8");
Article newArticle = new Article(request.getParameter("title"),
request.getParameter("handle"),
request.getParameter("message"));
Article.articleList.add(0, newArticle);
response.sendRedirect("/testBoard_jsp/ReadBoard.jsp");
}
}
src/board/ReadArticle.java
package board;
-import jakarta.servlet.http.HttpServlet;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
+import web.smallcat.ver3.servlet.http.HttpServlet;
+import web.smallcat.ver3.servlet.http.HttpServletRequest;
+import web.smallcat.ver3.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Iterator;
public class ReadArticle extends HttpServlet {
private static final long serialVersionUID = -7662069142361041281L;
public ReadArticle() {
}
private String escapeHtml(String src) {
return src.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<html>");
out.println("<head>");
out.println("<title>테스트용 게시판</title>");
out.println("</head>");
out.println("<body>");
out.println("<h1>테스트용 게시판</h1>");
out.println("<form action='/testbbs/PostBBS' method='post'>");
out.println("제목 : <input type='text' name='title' size='60'><br/>");
out.println("작성자 : <input type='text' name='handle'><br/>");
out.println("<textarea name='message' rows='4' cols='60'></textarea><br/>");
out.println("<input type='submit'/>");
out.println("</form>");
out.println("<hr/>");
Iterator var5 = Article.articleList.iterator();
while (var5.hasNext()) {
Article article = (Article) var5.next();
out.println("<p>[" + this.escapeHtml(article.title) + "] " + this.escapeHtml(article.handle) + " 님 " + this.escapeHtml(article.date.toString()) + "</p>");
out.println("<p>");
out.println(this.escapeHtml(article.message).replace("\r\n", "<br/>"));
out.println("</p><hr/>");
}
out.println("</body>");
out.println("</html>");
}
}
컴파일 후, 해당 클래스들을 webapps 디렉토리 하위에 둔다.
javac -cp src src/board/*.java -d webapps/testboard2
관련있는 계층만 나타내면 아래와 같다.
.
├── html
│ ├── 50x.html
│ ├── error
│ │ └── 404.html
│ ├── form.html
│ ├── form2.html
│ ├── image
│ │ ├── Google_2015_logo.svg
│ │ └── google_logo.png
│ ├── index.html
│ ├── index2.html
│ └── 한국어.html
├── src
│ ├── board
│ │ ├── Article.java
│ │ ├── PostArticle.java
│ │ └── ReadArticle.java
│ └── web
│ └── smallcat
│ └── ver3
│ ├── servlet
│ │ ├── ServletException.java
│ │ └── http
│ │ ├── HttpServlet.java
│ │ ├── HttpServletRequest.java
│ │ └── HttpServletResponse.java
│ ├── servletImpl
│ │ ├── HttpServletRequestImpl.java
│ │ ├── HttpServletResponseImpl.java
│ │ ├── ServletInfo.java
│ │ ├── ServletService.java
│ │ └── WebApplication.java
│ ├── util
│ │ ├── Constants.java
│ │ ├── MyURLDecoder.java
│ │ ├── SendResponse.java
│ │ └── Util.java
│ └── webserver
│ ├── Main.java
│ └── ServerThread.java
└── webapps
├── testboard2
│ ├── board
│ │ ├── Article.class
│ │ ├── PostArticle.class
│ │ └── ReadArticle.class
│ └── web
│ └── smallcat
│ └── ver3
│ └── servlet
│ ├── ServletException.class
│ └── http
│ ├── HttpServlet.class
│ ├── HttpServletRequest.class
│ └── HttpServletResponse.class
http://localhost:8001/testBoard2/ReadArticle 을 접속한 모습이다.
지금까지 서블릿를 기반으로한 웹 애플리케이션을 동작시키기 위한 간이 서블릿 컨테이너 SmallCat/0.3을 만들었다.
많은 기능이 빠져있지만, 서블릿 컨테이너의 매커니즘에 대한 이해를 높이는데 도움이 되었을 것이다.
후기
- Total
- Today
- Yesterday
- JPA
- OOP
- Spring MVC
- core c++
- 연관관계 편의 메서드
- tomcat11
- 객체 변조 방어
- PS
- S4
- servlet
- CPU
- condition variable
- 백준
- thread
- generic sort
- pocu
- 엔티티 설계 주의점
- tree
- reader-writer lock
- Dispatcher Servlet
- S1
- generic swap
- 이진탐색
- 톰캣11
- C
- Java
- 개발 공부 자료
- sleep lock
- 논문추천
- Memory
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 31 |