본문 바로가기

Project/기능 개발

[프로젝트] 순수 자바로 웹 애플리케이션 서버(WAS) 구현하기

1. 개요

프로젝트 진행 동기

저는 여느 때와 다름없이 스프링을 학습하고 있었습니다. 그런데 문득 의문이 들었습니다.

  1. 어째서 아무 설정을 건드리지 않고도 프로젝트가 기본적으로 TCP/IP 계층의 8080 port를 사용하고 있는 것인지
  2. Application 계층의 HTTP 메시지를 스펙을 따라 직접 파싱 하지 않고도 개발을 편리하게 할 수 있는지

위 궁금증을 해결하기 위해 구글링을 하던 중 Servlet 이라는 기술을 알게 되었습니다. 서블릿은 HTTP 요청 메시지를 읽어 들여 스펙에 맞게 파싱 하고, 응답을 내려줄 때도 스펙에 맞게 알아서 HTTP 응답 메시지를 작성해 준다는 것이었습니다. 그래서 서블릿 없이 순수한 자바로 WAS를 구현해보려 합니다.

 

프로젝트 달성 목표

  1. 개발한 웹 어플리케이션 서버(WAS)를 원하는 PORT에 지정하여 띄우기
  2. HTTP 메시지를 직접 확인하고 파싱 하기
  3. 동적 HTML 파일을 직접 제공함을 통해 서블릿 기술의 등장 이전의 불편함과 등장 이후의 편리함을 체감하기

 

2. 프로젝트 요구사항 & API

이 프로젝트는 비즈니스 로직보다는 클라이언트와 서버 간 HTTP 통신에 초점을 맞추었기 때문에, 간단히 로또 번호 추첨기 서비스를 개발하려고 합니다. 따라서 요구사항은 해당 웹페이지 로딩, 추첨 버튼 클릭 두 가지만 둘 것입니다.

순번 HTTP 메서드 URL 설명
1 GET localhost:4936/main 로또 추첨기 메인 화면
2 POST localhost:4936/play-lottery 로또 추첨 실행하기

 

1번의 경우 메인 화면을 로딩하기 때문에 GET 요청을 하는 것이 명백했는데, 2번의 경우 GET을 사용할지 POST를 사용할지 고민이 되었습니다.

 

서버에서 생성된 리소스(랜덤 숫자 7개)를 GET 요청으로 조회한다도 애매하고 그렇다고 등록은 더더욱 아니었는데요, HTTP 메서드로 리소스에 대한 행위를 표현하기 애매할 경우 POST 메서드와 Control_URI를 사용한다는 것을 알게 되었습니다. (혹시 api 스펙 정의가 잘못되었다면 알려주세요🤔)

 

또한 제외할 숫자를 선택해서 요청한다는 기능이 추가될 때를 생각한다면 (물론 GET으로 쿼리 파라미터에 숫자 데이터를 담을 수 있지만) POST 메서드로 바디에 담는 것이 URL에 노출되지 않아 깔끔할 것 같아 좋겠다고 판단했습니다.

 

3. 프로그램 클래스 구조 설계

애플리케이션 로직 흐름

  1. 서버 소켓을 열고 클라이언트 연결을 기다린다. 연결이 체결되면 클라이언트의 요청을 읽는다.
  2. HTTP 요청 메시지를 파싱 하여 추출한 데이터들을 애플리케이션 내부에서 사용할 수 있도록 객체에 담아둔다.
  3. API 스펙(HTTP 메서드, 요청 URL)에 따라 알맞은 로직(메인화면을 보낼지, 로또번호를 추첨할지)을 호출한다.
  4. 로직을 실행하며 추후 HTTP 응답 메시지를 작성할 때, 보내야 할 응답 데이터들을 객체에 담아둔다.
  5. 응답 데이터들을 꺼내 HTTP 응답 메시지로 작성한다.
  6. 클라이언트에게 HTTP 응답 메시지를 전달하고, 연결을 끊는다.

클라이언트의 요청에 대한 전체적인 애플리케이션 로직 흐름을 작성해 보았는데요, main 함수에서 위 6단계를 모두 작성한다면 코드의 길이가 길어짐과 동시에 유지보수가 힘들 것입니다. 따라서 하나의 역할로 묶을 수 있는 단계들을 모아 구분해 보았습니다.

 

역할 분리

  1. 소켓 통신 역할 👉 단순히 클라이언트와 연결을 체결하고, 메시지를 주고받습니다. (1, 6번)
  2. HTTP 메시지 파싱 역할 👉 HTTP 요청 메시지를 파싱해 애플리케이션 내부에서 사용할 수 있도록 객체를 생성하고, 로직 실행 결과 응답으로 전달할 데이터를 담은 객체를 HTTP 응답 메시지로 작성합니다. (2, 5번)
  3. 알맞은 로직 호출 역할 👉 HTTP 메서드와 요청 URL을 보고 알맞은 로직을 호출해 줍니다. (3번)
  4. 비즈니스 로직 실행 역할 👉 로또 로직을 실행하고, 응답할 데이터를 객체에 담습니다. (4번)

프로그램 구조

 

 

 

4. 기능 개발

이제 본격적으로 기능을 구현해 보도록 하겠습니다.

Main - 소켓 통신부

public class Main {
	public static void main(String[] args) throws Exception {
		System.out.println("Server is now running on port 4936");
		System.out.println("연결 대기 중...");

		DelegateController delegate = new DelegateController();
		
		while(true) {
			ServerSocket socket = new ServerSocket(4936);
			Socket client = socket.accept();
			System.out.println("--- 연결 성공 ---");
			
			InputStream in = client.getInputStream();
			OutputStream out = client.getOutputStream();
		
			BufferedReader br = new BufferedReader(new InputStreamReader(in));
			StringBuilder sb = new StringBuilder();
			
			String line;
			while((line=br.readLine())!=null) { // HTTP 메시지를 한 줄씩 끊어 읽는다.
				sb.append(line).append("\n");
				if(line.length()<1) {
					break;
				}
			}

			String respMessage = delegate.doDelegate(sb.toString()); // DelegateController 에게 메시지를 그대로 전달한다.
			out.write(respMessage.getBytes());
			
			br.close();
			out.close();
			socket.close();
			System.out.println("--- 연결 종료 ---");
		}
	}
}

 

HttpRequest & HttpResponse

@Getter
@Setter
public class HttpRequest {
    String method;
    String path;
    String version;
    Map<String, String> header;
    String body;

    public HttpRequest() {
        this.header = new HashMap<>();
    }

    public void setHeader(String name , String value) {
        this.header.put(name, value);
    }
}
@Getter
@Setter
public class HttpResponse {
	String version;
	String status;
	Map<String, String> header;
	String body;

	public HttpResponse() {
		this.header = new HashMap<>();
	}

	public void setHeader(String name , String value) {
		this.header.put(name, value);
	}

	public void setStatus(int code) {
		switch (code) {
			case 200:
				this.status = "200 OK";
				break;
			case 400:
				this.status = "400 Bad Request";
				break;
			default:
				this.status = "500 Internal Server Error";
		}
	}
}

 

DelegateController - API 스펙에 따라 알맞은 로직 호출부

public class DelegateController {
	
	HttpRequest req;
	HttpResponse resp;
	HttpParser parser;

	Map<RestData, Controller> controllers = new HashMap<>();
	Map<RestData, String> statics = new HashMap<>();

	public DelegateController() {
		parser = new HttpParser();
		req = new HttpRequest();
		resp = new HttpResponse();

		controllers.put(new RestData("/main","GET"), new MainController()); // 1. 미리 API 별로 엔드포인트 객체를 생성해서 등록한다.
		controllers.put(new RestData("/play-lottery", "POST"), new LotteryController());
		statics.put(new RestData("/style.css", "GET"), "생략..");
	}

	public String doDelegate(String httpRequest) {
		// 2. HTTP 요청 메시지를 HttpRequest 객체로 파싱한다.
		parser.parseMessage(req, httpRequest);
		
		// 3. API 스펙에 알맞은 컨트롤러, 정적파일을 찾아준다.
		Controller controller = controllers.get(new RestData(req.getPath(), req.getMethod()));
		String staticFile = statics.get(new RestData(req.getPath(), req.getMethod()));

		// 4-1. 엔드포인트를 찾았다면 controller 로직을 실행한다.
		if(controller != null) {
			controller.doLogic(req, resp);
		}

		// 4-2. 정적파일 요청이라면 static 파일을 resp에 등록한다.
		if(staticFile != null) {
			resp.setVersion("HTTP/1.1");
			resp.setStatus(200);
			resp.setBody(staticFile);
		}

		// 4-3. 요청이 API 스펙에 어긋난다면 400, 로직 실행 X
		if(controller == null && staticFile == null) {
			setBadRequest(resp);
		}

		// 5. HttpResponse 객체를 HTTP 응답 메시지로 작성한다.
		return parser.writeMessage(resp);
	}

	// 6. 400 Bad Request 응답 페이지다.
	private void setBadRequest(HttpResponse resp) {
		resp.setVersion("HTTP/1.1");
		resp.setStatus(400);
		resp.setBody("<html>\n" +
				"<head>\n" +
				"\t<title>로또 추첨기</title>\n" +
				"<meta charset=\"utf-8\">" +
				"</head>\n" +
				"<body>\n" +
				"\t<h2>400 Bad Request</h2>\n" +
				"\t<div>\n" +
				"\t\t<p>잘못된 요청입니다. 메인화면으로 돌아가세요.</p>\n" +
				"\t\t<a href=\"http://localhost:4936/play-lottery\">메인</a>\n" +
				"\t</div>\n" +
				"</body>\n" +
				"</html>");
	}

	// 7. Map의 키로 사용될, API 스펙 검증을 위한 객체를 생성했다.
	private class RestData {
		String path;
		String method;
		
		public RestData(String path, String method) {
			this.path = path;
			this.method = method;
		}

		@Override
		public int hashCode() {
			return Objects.hash(this.path, this.method);
		}

		@Override
		public boolean equals(Object obj) {
			return obj.hashCode() == this.hashCode();
		}
	}
}

 

HttpParser - HTTP 메시지 파싱부

수신한 HTTP 요청 메시지를 스펙에 따라 파싱 하거나, 애플리케이션 내부 로직 실행 후 HTTP 응답 메시지를 작성합니다.

  1. start-line
  2. header
  3. body

이렇게 세 가지 부분을 파싱 및 작성해야 합니다.

public class HttpParser {

	// 1. HTTP 요청 메시지 파싱 메서드
	public void parseMessage(HttpRequest req, String httpRequest){
		String[] strings = httpRequest.split("\n");

		// start-line
		String[] startLine = strings[0].split(" ");
		req.setMethod(startLine[0]);
		req.setPath(startLine[1]);
		req.setVersion(startLine[2]);

		// header
		for(int i=1; i< strings.length; i++) {
			String[] header = strings[i].split(": ");
			String name = header[0];
			String value = header[1];
			req.setHeader(name, value);
		}
	}
	
	// 2. HTTP 응답 메시지 작성 메서드
	public String writeMessage(HttpResponse resp) {
		StringBuilder sb = new StringBuilder();
		// status-line
		sb.append(resp.getVersion()).append(" ").append(resp.getStatus()).append("\n");
		// header
		for(Map.Entry<String, String> entry : resp.getHeader().entrySet()) {
			sb.append(entry.getKey()).append(": ").append(entry.getValue()).append("\n");
		}
		// CRLF 공백
		sb.append("\n");
		// body
		sb.append(resp.getBody());

		return sb.toString();
	}
}

 

 

Controller - 비즈니스 로직 실행부

핵심 애플리케이션 로직인 랜덤 번호 추출 기능을 개발할 것입니다. 1부터 45까지 숫자 중 7개를 중복 없이 랜덤으로 추출하면 됩니다.

public class LotteryController implements Controller {
	
	int[] lottery = new int[7];
	
	@Override
	public void doLogic(HttpRequest req, HttpResponse resp) {
		randomNumber();

		resp.setVersion("HTTP/1.1");
		resp.setStatus(200);
		resp.setHeader("Content-Type","text/html");
		resp.setBody(upperHtml + bodyData() + lowerHtml);
	}

	// 1. 랜덤 로또 번호 추출 로직 -> 애플리케이션 내 가장 주요한 비즈니스 로직
	private void randomNumber() {
		boolean isDuplicate = false;
		for(int i=0; i<7; i++) {
			int curNum = (int)(Math.random() * 45) + 1; // 1~45 랜덤 숫자
			for(int j=0; j<i; j++) {
				if(lottery[j] == curNum) { // 이전에 나온 숫자와 동일하다면
					i--; // 방금 뽑기를 무효처리
					isDuplicate = true;
					break;
				}
			}
			
			if(!isDuplicate) lottery[i] = curNum;
			isDuplicate = false;
		}
	}
	
	// 2. HTTP 응답 메시지에 실릴 body 데이터
	private String bodyData() {
		String result = "			<h3>";
		for(int i=0; i<7; i++) {
			result += lottery[i] + " ";
		}
		return result;
	}
	
	String upperHtml = "<html>\r\n"
			+ "<head>\r\n"
			+ "	<title>로또 추첨기</title>\r\n"
			+ "\t<meta charset=\"utf-8\">"
			+ "\t<link rel=\"stylesheet\" href=\"style.css\">"
			+ "</head>\r\n"
			+ "<body>\r\n"
			+ "	<h2>로또 추첨기</h2>\r\n"
			+ "	<div>\r\n"
			+ "		<form action=\"http://localhost:4936/play-lottery\" method=\"post\">\r\n"
			+ "\r\n";
	
	String lowerHtml = "			<input type=\"submit\" value=\"추첨!\">\r\n"
			+ "		</form>\r\n"
			+ "	</div>\r\n"
			+ "</body>\r\n"
			+ "</html>";
			
}

 

5. 결과 및 회고

결과

우선 처음 기획한 대로 메인 화면, 플레이 화면을 구현에 성공했습니다! 거기에 더해 클라이언트의 요청이 API 스펙에 맞지 않는 경우 400 Bad Request 상태 코드와 에러 페이지를 함께 반환했습니다.

메인 화면
추첨 화면
400 Bad Request

 

사실 "로또 추첨기"는 서버 측에서 동적으로 HTML을 생성해서 내려주지 않아도, 정적 HTML과 JS 만으로도 클라이언트 측 브라우저 자체에서 구현할 수 있는 기능입니다. 하지만 이번 프로젝트의 요지는 서블릿 기능을 직접 구현해 보는 것으로, 비즈니스 로직보다는 HTTP 통신에 집중하는 것에 충분히 의미가 있었습니다.

 

어려웠던 점

HTTP 요청 메시지 파싱

 

HTTP 스펙에 따라 텍스트일 뿐인 메시지를 파싱하고 이어 붙이고 하는 과정이 너무 힘들었습니다. 정작 가장 중요한 로또 추첨 로직에 투자한 시간보다 HTTP 메시지 파싱 작업에 시간을 더 많이 쏟고 있는.. 배보다 배꼽이 더 큰 상황이 발생했습니다🥲 특히 뷰 템플릿이 없어서 HTML을 직접 타자 쳐야 한다는 것도 매우 불편했습니다.

 

무엇보다 프로젝트의 구조적인 고민이 가장 어려웠는데요, 유사한 책임들을 클래스 별로 분배해 보았는데, 괜찮은지 모르겠습니다🤔 

 

깨달은 점

API 스펙 대로 올바른 요청을 한 경우에만 Controller 엔드포인트와 static 정적 파일에 접근할 수 있도록 구현했는데요, 여기서 Map의 key로 RestData라는 객체를 사용했습니다. Map은 equals(obj) 메서드로 key가 일치하는지 아닌지를 파악하는데,  분명 동일한 path와 method를 입력했음에도 null이 반환되는 문제를 맞닥뜨렸습니다. 여기서 Object의 equal(obj) 메서드는 hashCode() int 값으로 비교한다는 것을 깨달았고, 메모리 참조 말고 두 객체의 값의 동등성을 비교하기 위해서는 두 메서드를 오버라이드 해야 함을 깨달았습니다.

 

또한 HttpRequest & HttpResponse와 같이 객체 간 데이터를 전송하기 위한 객체를 만들어보았는데, 이러한 객체를 DTO라고 부르는 것을 깨달았습니다. 이제야 왜 Spring에서 계층 간 매개변수로 전달하던 객체를 왜 DTO라고 하는지 확 와닿는 계기가 되었습니다😅

 

한 번은 HttpResponse 객체에 응답 헤더를 아무것도 지정하지 않고 HTTP 메시지를 작성하려 했다가 NPE 에러를 만났는데요, 객체를 생성할 때 초기화를 해주지 않아 자동으로 레퍼런스 타입인 header 참조변수가 null로 초기화되었던 것이었습니다. 실수로라도 이런 부분들을 놓치지 않도록 주의해야겠다고 느꼈습니다.

 

마지막으로 웹 브라우저가 HTML을 렌더링 할 때 필요한 파일들(css, js, favicon 등)을 읽어 한 연결 내에서 여러 번 요청한다는 사실을 깨달았습니다. 정적 파일들에 대한 요청 API를 간과했음을 알게 되었습니다.

 

해결해야 할 문제

  1. InputStream에서 CRLF를 읽고 난 후 body 데이터를 읽지 못하고 있는 점
  2. Controller에 다중 책임(비즈니스 로직, 응답 설정)이 부여된 점
  3. 예외처리, 유효성 검증, 인증 & 인가 처리 등 기능을 추가하게 되면 어떤 클래스를 어느 위치(계층)에 두어야 할지

 

마무리

API 별 엔드포인트 관리, 정적 파일에 대한 API 관리, 요청 헤더 별 분기 처리, 예외처리나 유효성 검증, 인증과 인가와 같은 공통 처리 로직을 프레임워크 없이 손수 개발할 생각을 하면 너무 기쁩니다. 스프링 프레임워크 없이 WAS 개발을 해보니 기술의 발전에 감사함을 느꼈습니다.

 

마지막으로 순수 자바로 개발해 보니 자바 기본기가 부족하단 것을 느꼈습니다. 자바 공부를 철저히 해야겠습니다.