-
Notifications
You must be signed in to change notification settings - Fork 0
이영민 2주차 WAS
package codesquad;
import codesquad.server.WebServer;
public class Main {
public static void main(String[] args) {
WebServer webServer = new WebServer(8080, 5, 10);
webServer.start();
}
}
메인에서는 그저 WebServer 를 생성하고 실행하는 역할만 하고 로그나 예외에 대한 처리도 그 무엇도 하지 않습니다. Main 클래스는 그저 실행만을 담당하고 있습니다.
웹 서버 코드를 작성하면서 크게 세 가지를 고려했습니다. 첫째, 테스트할 수 있는지 여부, 둘째, 각 요청마다 스레드를 어떻게 관리할 것인지, 셋째, 서버 소켓에 대한 예외를 누가 담당해야 하는지입니다.
public WebServer(int port, int backlog, int threadPoolSize) {
this.PORT = port;
this.BACKLOG = backlog;
this.THREAD_POOL_SIZE = threadPoolSize;
}
생성자에서 포트 번호, 백로그, 스레드풀 크기를 받아 서버 인스턴스를 생성합니다. 서로 다른 포트와 설정으로 서버를 테스트할 수 있도록 static을 사용하지 않고 인스턴스 형태로 만들었습니다.
public void start() {
try {
running = true;
ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
logger.info("Starting web server on port {}", PORT);
try (ServerSocket serverSocket = new ServerSocket(PORT, BACKLOG)) {
while (running) {
threadPool.submit(new HttpProcessor(serverSocket.accept()));
}
}
threadPool.shutdown();
} catch (IOException e) {
logger.error("서버가 비정상적으로 종료되었습니다.");
logger.error(e.getMessage(), e);
}
}
로그를 기록하고, 생성자에서 지정한 스레드풀 크기로 스레드풀을 생성합니다. ExecutorService를 사용해 스레드 생성과 소멸로 인한 자원 낭비를 줄이고, 이미 생성된 스레드로 각 HTTP 요청을 처리합니다.
서버소켓이 Accept() 하고 Socket 을 반환하여 클라이언트 소켓을 HttpProcessor 에게 할당합니다.
ServerSocket 생성과 실행 중 발생하는 예외를 처리합니다. ServerSocket은 WebServer가 관리해야 하는 리소스이므로, 여기서 예외를 처리합니다. WebServer를 실행시키는 Main에서는 예외 처리를 하지 않습니다.
이렇게 코드를 작성하여 서버의 테스트 가능성을 높이고, 스레드 관리의 효율성을 개선하며, 책임 분리를 하였습니다.
HttpProcessor를 작성하면서 고려한 사항은 클라이언트 소켓의 예외 처리, HttpStatusException의 예외 처리, 그리고 HTTP 요청을 파싱하고 핸들러로 처리하여 HTTP 응답을 반환하는 핵심 로직입니다.
@Override
public void run() {
try (OutputStream clientOutput = socket.getOutputStream();
BufferedReader clientInputReader = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
// 클라이언트 연결 로그 출력
logger.info("Client connected");
try {
// HTTP Request
HttpRequest httpRequest = HttpRequestParser.parse(clientInputReader);
logger.debug(httpRequest.toString());
// HTTP Response
// Dynamic
HttpResponse httpResponse = httpDynamicHandler.handle(httpRequest);
// Static
if (httpResponse == null) {
httpResponse = httpStaticHandler.handle(httpRequest);
}
clientOutput.write(httpResponse.getBytes());
} catch (HttpStatusException e) {
logger.error("HTTP 상태 코드 예외 발생: " + e.getStatus().getCode() + " " + e.getMessage());
HttpResponse errorResponse = new HttpResponse("HTTP/1.1", e.getStatus(), Map.of(),
e.getStatus().getReasonPhrase().getBytes());
clientOutput.write(errorResponse.getBytes());
}
// write flush
clientOutput.flush();
} catch (IOException e) {
logger.error("클라이언트 소켓의 write 또는 flush 에 실패했습니다.");
logger.error(e.getMessage());
}
}
try-with-resources 구문을 통해 OutputStream과 BufferedReader를 자동으로 닫습니다.
HTTP 요청 파싱과 핸들러 처리에서 발생할 수 있는 HttpStatusException을 공통적으로 처리하도록 했습니다.
클라이언트 소켓 예외는 상위 try 블록에서 처리하여, 리소스 관리와 예외 처리를 명확히 분리했습니다.
HttpProcessor의 책임은 HTTP 요청을 처리하고, 예외를 적절히 관리하는 것입니다. 핵심 로직은 요청 -> 파싱 -> 동적 핸들러 -> 정적 핸들러 -> 응답의 흐름을 따릅니다. keep alive 기능은 다음 주에 도전해볼 계획입니다.
RuntimeException 을 상속 받는 예외입니다. Enum 클래스인 HttpStatus 를 받아서 (ex 400, 404 등) 생성합니다. 해당 클래스로 여러 상태에 대한 예외를 공통적으로 처리할 수 있습니다.
public class HttpStatusException extends RuntimeException {
private final HttpStatus status;
private final String message;
public HttpStatusException(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
public HttpStatus getStatus() {
return status;
}
public String getMessage() {
return message;
}
}
HttpStaticHandler는 정적 파일 요청을 처리하며, 파일 예외 처리, URL 매핑, 파일을 클래스 경로에서 찾는 작업을 수행합니다.
public HttpResponse handle(HttpRequest httpRequest) {
String url = httpRequest.url();
String path = urlMapping.getOrDefault(url, url);
String resourcePath = STATIC_ROOT_PATH + path;
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(resourcePath)) {
if (inputStream == null) {
throw new HttpStatusException(HttpStatus.NOT_FOUND, "File not found");
}
byte[] body = readAllBytes(inputStream);
String version = httpRequest.version();
String extension = resourcePath.substring(resourcePath.lastIndexOf(".") + 1);
String mimeType = MIME_TYPES.getOrDefault(extension, "application/octet-stream");
Map<String, List<String>> headers = new HashMap<>();
headers.put("Content-Type", List.of(mimeType));
headers.put("Content-Length", List.of(String.valueOf(body.length)));
return new HttpResponse(version, HttpStatus.OK, headers, body);
} catch (IOException e) {
throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to read the file");
}
}
요청된 파일이 존재하지 않을 경우 HttpStatusException을 발생시켜 404 상태 코드를 반환합니다. 파일을 읽는 과정에서 발생하는 IOException을 처리하여 500 상태 코드를 반환합니다.
URL 매핑을 통해 요청된 URL을 실제 파일 경로로 변환합니다. urlMapping을 사용하여 URL을 파일 경로로 매핑합니다. 매핑된 경로가 존재하지 않으면 기본 URL을 사용합니다.
getClass().getClassLoader().getResourceAsStream(resourcePath)를 사용하여 클래스 경로에서 파일을 찾습니다. 이 방법을 사용하면 애플리케이션의 클래스 경로 내에 있는 정적 리소스를 쉽게 로드할 수 있습니다. 상대 경로에서 찾는 방식에서 클래스 경로로 변경하여 어떤 위치에서 jar 파일을 실행해도 동일한 위치의 파일을 찾을 수 있도록 했습니다.
파일 확장자를 기반으로 MIME_TYPES 맵에서 MIME 타입을 결정합니다. 확장자가 매핑되어 있지 않으면 기본값으로 application/octet-stream을 사용합니다. application/octet-stream 을 주는 이유는 모든 바이너리 파일을 표현할 수 있는 일반적인 MIME 타입으로, 파일의 정확한 타입을 모를 때 사용하기 적합하기 때문입니다.
응답 본문을 읽고, Content-Type과 Content-Length 헤더를 설정하여 HTTP 응답을 생성합니다.
이러한 고려사항을 통해 HttpStaticHandler는 정적 파일 요청을 안정적으로 처리하고, 다양한 예외 상황에 적절히 대응할 수 있습니다.
Static의 반대 의미로 Dynamic이라는 용어를 사용했습니다. 이번 주에는 구조에 대한 고민이 있어서 당장의 구현보다는 향후 계획에 대해 작성해보겠습니다.
서버가 처리해야 하는 동적 요청이 추가될 때마다 HttpDynamicHandler의 코드를 변경하지 않을 수 있는 방식을 고려하고 있습니다. 예를 들어, 내부적으로 List 필드를 가지고 있으며, 런타임에 추가 로직에 대한 클래스 또는 메서드를 넣는 방식입니다.
- 특정 메서드를 오버라이드하여 URL에 매핑된 클래스의 오버라이드 메서드를 실행하는 방식.
- 다형성을 사용하기보다는 각 클래스나 메서드에서 Function을 만들고 List에 넣어, URL이 왔을 때 Function을 실행하는 방식.
- 확장성: 새로운 동적 요청을 처리하기 위해 코드를 수정할 필요 없이, 런타임에 동적으로 처리 로직을 추가할 수 있도록.
- 유연성: Handler 인터페이스와 Function 클래스를 활용하여 다양한 동적 요청을 유연하게 처리할 수 있도록.
아직 정확히 어떻게 할 지는 고민중입니다. 정답은 없겠지만 적절한 방식을 선택해보려고 합니다.