[CodeSquad] 웹 서버 프로젝트 회고

naneun·2022년 7월 14일
3

CodeSquad

목록 보기
5/5
post-thumbnail

프로젝트 개요

  • 프로젝트 주제 : 웹 서버 구현하기

  • 프로젝트 기간 : 3/21 ~ 4/1

  • 개발 방식 : 페어 프로그래밍

프로젝트 회고

  • 시도한 내용

      이전 CS10 마지막 개인 미션인 HTTP 요청, 응답에서 작성했던 코드와 구조를 기반으로 페어인 @아더 와 미션을 수행해가며 프로젝트를 진행했다.

    1) HttpRequest (요청 메시지) Class

    public class HttpRequest {
    
        private static final String REQUEST_LINE_DELIMITER = " ";
        private static final String QUERYSTRING_DELIMITER = "?";
    
        private String method;
        private String requestURI;
        private String protocol;
        private Parameters params;
        private Headers headers;
        private Map<String, String> cookies;
        private final HttpSession session = HttpSession.getInstance();
    
        public HttpRequest(String requestLine, Map<String, String> headers, String requestMessageBody) {
            parseRequestLine(requestLine);
            setHeadersAndCookies(headers);
            parseRequestMessageBody(requestMessageBody);
        }
    
        private void parseRequestLine(String requestLine) {
            String[] requestLineTokens = requestLine.split(REQUEST_LINE_DELIMITER);
            this.method = requestLineTokens[0];
            extractQuery(requestLineTokens[1]);
            this.protocol = requestLineTokens[2];
        }
    
        private void extractQuery(String requestURI) {
            int queryStringDelimiterIndex = requestURI.indexOf(QUERYSTRING_DELIMITER);
            queryStringDelimiterIndex = queryStringDelimiterIndex != -1 ? queryStringDelimiterIndex : requestURI.length();
            this.requestURI = requestURI.substring(0, queryStringDelimiterIndex);
    
            if (queryStringDelimiterIndex != requestURI.length()) {
                this.params = new Parameters(
                        HttpRequestUtils.parseQueryString(requestURI.substring(queryStringDelimiterIndex + 1))
                );
            }
        }
    
        private void setHeadersAndCookies(Map<String, String> headers) {
            this.headers = new Headers(headers);
            cookies = Strings.isNullOrEmpty(headers.get("Cookie")) ? Collections.emptyMap() : HttpRequestUtils.parseCookies(headers.get("Cookie"));
        }
    
        private void parseRequestMessageBody(String requestMessageBody) {
            if (!Strings.isNullOrEmpty(requestMessageBody)) {
                this.params = new Parameters(HttpRequestUtils.parseQueryString(requestMessageBody));
            }
        }
    
        public String getMethod() {
            return method;
        }
    
        public String getRequestURI() {
            return requestURI;
        }
    
        public String getProtocol() {
            return protocol;
        }
    
        public String getParameter(String name) {
            return params.getValue(name);
        }
    
        public Map<String, String> getCookies() {
            return cookies;
        }
    
        public HttpSession getSession() {
            return session;
        }
    }

      HttpRequest 클래스는 클라이언트가 전송한 Http 요청 메시지에 담긴 텍스트 정보들을 하나의 객체로 관리할 수 있도록 설계된 클래스이다. 아래와 같이 3개의 메서드로 역할을 분리하여 인스턴스를 생성하도록 했다.

    • void parseRequestLine(String requestLine);
      • method, request url, protocol 로 분리한다.
      • request url 에서 extractQuery 메서드로 query string 을 추출한다.
    • void setHeadersAndCookies(Map<String, String> headers);
      • header 값들 저장하고, cookie 값이 존재한다면 별도로 저장하여 관리한다.
    • void parseRequestMessageBody(String requestMessageBody);
      • 요청 메시지에 request body 값이 존재한다면 파싱하여 저장한다.

    2) HttpResponse (응답 메시지) Class

    public class HttpResponse {
    
        private final String protocol;
        private HttpStatus status;
        private final Headers headers = new Headers();
        private final Cookies cookies = new Cookies();
        private byte[] body = "".getBytes();
    
        public HttpResponse(String protocol) {
            this.protocol = protocol;
        }
    
        public HttpStatus getStatus() {
            return status;
        }
    
        public void setHeader(String name, String value) {
            headers.setHeader(name, value);
        }
    
        public void addCookie(Cookie cookie) {
            cookies.add(cookie);
        }
    
        public void setStatus(HttpStatus status) {
            this.status = status;
        }
    
        public void setBody(byte[] body) {
            this.body = body;
        }
    
        public byte[] toByteArray() {
            String httpRequestMessage = protocol + " " + status + System.lineSeparator() +
                    headers.getHeaderMessage() + System.lineSeparator() +
                    cookies.toSetCookieMessage() + Strings.repeat(System.lineSeparator(), 2) +
                    new String(Arrays.copyOf(body, body.length));
    
            return httpRequestMessage.getBytes(StandardCharsets.UTF_8);
        }
    }

      toByteArray 메서드를 사용하여 형식에 맞게 Response Message 를 구성하고 해당 문자열을 byte[] 로 변환하여 반환한다. HttpResponse 클래스에게 인스턴스 필드들로 메시지를 구조화하는 책임을 주는 것이다. 이에 따라 뒤에서 설명할 Handler 가 OutputStream 을 사용하여 클라이언트에게 응답할 때 HttpResponse 인스턴스에 대해 별도로 조작할 필요가 없어진다. setHeader, addCookie, setStatus, setBody 메서드로 Header, Status 등 응답 객체의 필드 값들을 외부에서 유연하게 변경할 수 있도록 해줬다.

    3) Handler

    🔹 HandlerMethod

    @FunctionalInterface
    public interface HandlerMethod {
    
        HttpResponse service(HttpRequest request) throws IOException;
    }

      HandlerMethod 라는 함수형 인터페이스 에 HttpRequest 를 인자로 받고, HttpResponse 를 반환하는 메서드를 선언한다. 해당 함수형 인터페이스는 현재 웹 서버에서 스프링 프레임워크를 적용한 웹 어플리케이션처럼 특정 Url 에 매핑된 Controller 메서드의 역할을 담당할 객체가 필요했기 때문에 작성했다.

    🔹 HandlerMethodMapper (Version 1.0)

    public class HandlerMethodMapper {
    
        private static final Logger logger = LoggerFactory.getLogger(HandlerMethodMapper.class);
    
        private static final Map<String, HandlerMethod> mapper = new HashMap<>();
    
        private static final HandlerMethod resourceHandlerMethod = (request) -> {
            HttpResponse response = new HttpResponse();
            response.setStatusLine(request.getProtocol(), HttpStatus.OK);
    
            FileReader resources = new FileReader("src/main/resources/env.properties");
            Properties properties = new Properties();
            properties.load(resources);
    
            byte[] body = Files.readAllBytes(new File(properties.getProperty("webapp_path") + request.getRequestURI()).toPath());
            response.setHeader("Content-Length", Integer.toString(body.length));
            response.setBody(body);
    
            return response;
        };
    
        static {
            mapper.put("/user/create", (request) -> {
                User user = new User(request.getParameter("userId"), request.getParameter("password"),
                    request.getParameter("name"), request.getParameter("email"));
    
                DataBase.addUser(user);
                logger.debug("user: {}", DataBase.findAll());
    
                HttpResponse response = new HttpResponse();
                response.setStatusLine(request.getProtocol(), HttpStatus.OK);
    
                return response;
            });
        }
    
        public static HandlerMethod getHandlerMethod(String uri) {
            return mapper.getOrDefault(uri, resourceHandlerMethod);
        }
    }

      HandlerMethodMapper 라는 일급객체에 '.html', '.css' 와 같은 정적 리소스 들을 반환해주는 HandlerMethod (resourceHandlerMethod) 의 동작을 정의하여 할당해둔다. getHandlerMethod 메서드의 인자로 요청 uri 를 주어 mapper 에서 해당 uri 에 매핑된 HandlerMethod 를 찾아 반환하여 아래의 RequestHandler 에서 해당 메서드를 수행하도록 로직을 구성했다.

      물론 이러한 방식은 핸들러를 새로 추가 하거나 삭제 시 외부에서 변경이 불가능하다. 따라서, 뒤에서 언급할 자체 WebServerContext, WebConfiguer 클래스들을 생성하여 전체적인 구조를 개선함과 동시에 HandlerMethodMapper (Version 2.0) 에서 static 블록을 제거했다.

    🔹 RequestHandler (Version 1.0)

    public class RequestHandler extends Thread {
    
        private static final Logger logger = LoggerFactory.getLogger(RequestHandler.class);
    
        private Socket connection;
    
        public RequestHandler(Socket connectionSocket) {
            this.connection = connectionSocket;
        }
    
        /**
         * 1. `receiveRequest` 메서드를 사용하여 클라이언트가 보낸 데이터 스트림을 읽어 요청 객체로 변환한다.
         * 2. `HandlerMethodMapper` 로 요청 URI 값으로 매핑된 `HandlerMethod` 를 찾아 해당 요청을 수행하도록 한다.
         * 3. `HandlerMethod` 가 반환한 결과 값을 받아 클라이언트에게 응답한다.
         *
         */
        public void run() {
            logger.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(), connection.getPort());
    
            try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {
                HttpRequest request = receiveRequest(in);
    
                HandlerMethod handlerMethod = HandlerMethodMapper.getHandlerMethod(request.getRequestURI());
                HttpResponse response = handlerMethod.service(request);
    
                sendResponse(out, response);
            } catch (IOException e) {
                logger.error(e.getMessage());
            }
        }
    
        /**
         * 클라이언트가 보낸 데이터 스트림을 `RequestLine`, `RequestHeaders`, (+ RequestMessageBody) 로 구분 지어 읽어들인다.
         * 읽어들인 메시지들을 사용하여 HttpRequest 객체를 생성하고 이를 반환한다.
         *
         * @param in
         * @return `InputStream` 에서 읽어온 데이터로 HttpRequest 객체를 생성하여 반환한다.
         * @throws IOException
         */
        private HttpRequest receiveRequest(InputStream in) throws IOException {
            BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
            String line = URLDecoder.decode(br.readLine(), StandardCharsets.UTF_8);
            String requestLine = line;
    
            logger.debug("request line : {}", line);
    
            List<HttpRequestUtils.Pair> headers = new ArrayList<>();
            while (!Strings.isNullOrEmpty(line)) {
                line = URLDecoder.decode(br.readLine(), StandardCharsets.UTF_8);
    
                HttpRequestUtils.Pair pair = HttpRequestUtils.parseHeader(line);
                headers.add(pair);
    
                logger.debug("header : {}", line);
            }
    
            return new HttpRequest(requestLine, headers);
        }
    
        /**
         * 매칭시킨 `HandlerMethod` 가 반환한 결과 값을 OutputStream 을 통해 클라이언트에게 응답한다.
         *
         * @param out
         * @param response
         */
        private void sendResponse(OutputStream out, HttpResponse response) {
            DataOutputStream dos = new DataOutputStream(out);
            try {
                dos.write(response.toByteArray());
                dos.flush();
            } catch (IOException e) {
                logger.error(e.getMessage());
            }
        }
    }

      작성해놓은 javaDocs 주석에 RequestHandler 의 동작과정이 다 설명되어있다. receiveRequest, sendResponse 와 같이 요청마다 공통적으로 사용되는 메서드를 Util 클래스로 따로 분리할까 고민하던 중, 리뷰어님의 리뷰 에서 영감을 받아,

    🔹 WebServer

    public class WebServer {
        private static final Logger log = LoggerFactory.getLogger(WebServer.class);
        private static final int DEFAULT_PORT = 8080;
    
        public static void main(String args[]) throws Exception {
            int port = 0;
            if (args == null || args.length == 0) {
                port = DEFAULT_PORT;
            } else {
                port = Integer.parseInt(args[0]);
            }
    
            try (ServerSocket listenSocket = new ServerSocket(port)) {
                log.info("Web Application Server started {} port.", port);
    
                Socket connection;
                while ((connection = listenSocket.accept()) != null) {
                    RequestHandler requestHandler = new RequestHandler(connection);
                    requestHandler.start();
                }
            }
        }
    }

      이렇게 웹 서버 클래스가 클라이언트로부터 요청이 올 때마다 Thread 를 상속받은 RequestHandler 를 생성하여 처리하는 구조에서,

    public class WebServer {
    
        private static final Logger log = LoggerFactory.getLogger(WebServer.class);
    
        private static final int DEFAULT_PORT = 8080;
    
        private static final FrontHandler frontHandler = FrontHandler.getInstance();
    
        public static void main(String[] args) throws Exception {
            int port = 0;
            if (args == null || args.length == 0) {
                port = DEFAULT_PORT;
            } else {
                port = Integer.parseInt(args[0]);
            }
    
            WebServerConfig.getInstance();
    
            try (ServerSocket listenSocket = new ServerSocket(port)) {
                log.info("Web Application Server started {} port.", port);
    
                Socket connection;
                while ((connection = listenSocket.accept()) != null) {
                    frontHandler.assign(connection);
                }
            }
        }
    }

      스프링 프레임워크의 DispatcherServlet 역할을 하는 FrontHandler 가 모든 요청을 받고, 각 요청들은 java.util.concurrent 패키지 내의 ExecutorService 을 사용하여 스레드를 할당받아 처리되도록 구현했다. java.util.concurrent.ExecutorService 의 사용으로 인해 다음과 같은 장점을 얻을 수 있다.

    1. 먼저 스레드를 미리 생성 해두고 요청이 올 때마다 할당 하는 방식이기 때문에 스레드를 생성하고 종료하는 비용이 들지 않아 응답 속도가 빠르다. (+ 컨텍스트 스위칭 비용을 최소화할 수 있다.)
    1. 또한 스레드 풀에 스레드의 최대 개수를 이미 지정해 놓은 상태이기 때문에 아무리 많은 요청이 오더라도 서버가 크래쉬될 정도로 CPU 와 메모리를 사용하지 않을 수 있다. 다시 말해 최대 스레드를 넘어서는 요청이 들어오면 거절 하거나 대기 하도록 설정할 수 있어 안전 하게 서비스를 운영 할 수 있다.
    1. 개발자 관점에서는 스레드 관련 로직은 concurrent 패키지의 ExecutorService 가 처리해주고 테스크 관련 로직만 신경써도 되기 때문에 생산성이 증가 한다.

    🔹 FrontHandler

    public class FrontHandler {
    
        private static final Logger logger = LoggerFactory.getLogger(FrontHandler.class);
    
        private static volatile FrontHandler frontHandler;
    
        private final ExecutorService executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
    
        private final Handler handler = Handler.getInstance();
    
        private FrontHandler() {
        }
    
        public static FrontHandler getInstance() {
            if (frontHandler == null) {
                synchronized (FrontHandler.class) {
                    if (frontHandler == null) {
                        frontHandler = new FrontHandler();
                    }
                }
            }
            return frontHandler;
        }
    
        public void assign(Socket connection) {
            CompletableFuture.runAsync(() -> process(connection), executor);
        }
    
        private void process(Socket connection) {
            try (InputStream in = connection.getInputStream();
                 OutputStream out = connection.getOutputStream()) {
    
                sendResponse(out, handler.service(receiveRequest(in)));
            } catch (IOException e) {
                logger.error(e.getMessage());
            }
        }
    
        /**
         * 클라이언트가 보낸 데이터 스트림을 'RequestLine', 'RequestHeaders', (+ RequestMessageBody) 로 구분 지어 읽어들인다.
         * 읽어들인 메시지들을 사용하여 HttpRequest 객체를 생성하고 이를 반환한다.
         *
         * @param in
         * @return 'InputStream' 에서 읽어온 데이터로 HttpRequest 객체를 생성하여 반환한다.
         * @throws IOException
         */
        public HttpRequest receiveRequest(InputStream in) throws IOException {
            BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
    
            String line = URLDecoder.decode(br.readLine(), StandardCharsets.UTF_8);
            String requestLine = line;
    
            logger.debug("request line : {}", line);
    
            Map<String, String> headers = new HashMap<>();
    
            while (true) {
                line = URLDecoder.decode(br.readLine(), StandardCharsets.UTF_8);
    
                if (Strings.isNullOrEmpty(line)) {
                    break;
                }
    
                Pair<String, String> pair = HttpRequestUtils.parseHeader(line);
                headers.put(pair.getKey(), pair.getValue());
    
                logger.debug("header : {}", line);
            }
    
            String requestMessageBody = URLDecoder.decode(IOUtils.readData(br, getContentLength(headers)), StandardCharsets.UTF_8);
    
            return new HttpRequest(requestLine, headers, requestMessageBody);
        }
    
        private int getContentLength(Map<String, String> headers) {
            return Integer.parseInt(Optional.ofNullable(headers.get("Content-Length")).orElse(String.valueOf(0)));
        }
    
        /**
         * 매칭시킨 'HandlerMethod' 가 반환한 결과 값을 OutputStream 을 통해 클라이언트에게 응답한다.
         *
         * @param out
         * @param response
         */
        private void sendResponse(OutputStream out, HttpResponse response) {
            DataOutputStream dos = new DataOutputStream(out);
            try {
                dos.write(response.toByteArray());
                dos.flush();
            } catch (IOException e) {
                logger.error(e.getMessage());
            }
        }
    }

      이러한 구조 변경으로 인해 receiveRequest, sendResponse 메서드를 모든 요청을 받아서 처리하는 FrontHandler 클래스로 이동시킬 수 있었다.

    🔹 Handler

    public class Handler {
    
        private static volatile Handler handler;
    
        private final HandlerMethodMapper handlerMethodMapper = HandlerMethodMapper.getInstance();
    
        private Handler() {
        }
    
        public static Handler getInstance() {
            if (handler == null) {
                synchronized (Handler.class) {
                    if (handler == null) {
                        handler = new Handler();
                    }
                }
            }
            return handler;
        }
    
        /**
         * 1. 'HandlerMethodMapper' 로 요청 URI 값으로 매핑된 'HandlerMethod' 를 찾아 해당 요청을 수행하도록 한다.
         * 2. 'HandlerMethod' 가 반환한 결과 값을 받아 FrontHandler 에게 전달한다.
         */
        public HttpResponse service(HttpRequest request) throws IOException {
            HandlerMethod handlerMethod = handlerMethodMapper.getHandlerMethod(
                    new Pair<>(
                            request.getMethod(),
                            request.getRequestURI()
                    )
            );
            return handlerMethod.service(request);
        }
    }

      작성 당시 요청 url 이 /user/* 뿐이 없었기에 HandlerMapper 를 다음 단계에서 작성하였고, 모든 요청이 /user 로 시작한다는 전제 하에 위와 같은 Handler 클래스만을 만들어두고, FrontHandler 에서 이를 사용하였다. (+ 마지막 제출 단계에선 ResourceHandler 의 역할과 구분하기 위해 UserHandler 로 변경되었다.)

      클라이언트로부터 Http 요청이 오면 WebServer -> FrontHandler -> Handler -> HandlerMethod 순서로 전달되고 각 클래스들은

    • FrontHandler = DispatcherServlet
    • Handler = Controller
    • HandlerMethod = Controller Method

      위와 같은 스프링 프레임워크 계층들의 역할을 참고하며 구현했다. 지금까지의 코드에서는 Handler 가 하나였지만, 마지막 리뷰를 받을 당시의 코드는 HandlerMapper 클래스를 별도로 생성하고, Handler RequestMapping 와 HandlerMethod RequestMapping 를 분리 하여 Controller 클래스 레벨에서 @RequestMapping 설정을 해준 것과 같은 기능을 구현해봤다.

    🔹 HandlerMapper

    public class HandlerMapper {
    
        private static volatile HandlerMapper handlerMapper;
    
        private final HandlerRegistry handlerRegistry = HandlerRegistry.getInstance();
    
        private final Map<String, Handler> mapper = new HashMap<>();
    
        private HandlerMapper() {
        }
    
        public static HandlerMapper getInstance() {
            if (handlerMapper == null) {
                synchronized (HandlerMapper.class) {
                    if (handlerMapper == null) {
                        handlerMapper = new HandlerMapper();
                    }
                }
            }
            return handlerMapper;
        }
    
        public Handler getHandler(String url) {
            return mapper.get(url);
        }
    
        public void mappingHandler(String url, int handlerIndex) {
            mapper.put(url, handlerRegistry.getHandler(handlerIndex));
        }
    }

    4) BeanFactory, WebServerContext, WebServerConfigurer

      HandlerMethodMapper (Version 1.0) 에선 아래와 같은 static 블록으로 직접 요청 Url 과 HandlerMethod 를 매핑해주었다.

    static {
        mapper.put("/user/create", (request) -> {
            User user = new User(request.getParameter("userId"), request.getParameter("password"),
            request.getParameter("name"), request.getParameter("email"));
    
            DataBase.addUser(user);
            logger.debug("user: {}", DataBase.findAll());
    
            HttpResponse response = new HttpResponse();
            response.setStatusLine(request.getProtocol(), HttpStatus.OK);
    
            return response;
        });
    }

    🔹 HandlerMethodMapper (Version 2.0)

    public class HandlerMethodMapper {
    
        private static volatile HandlerMethodMapper handlerMethodMapper;
    
        private final HandlerMethodRegistry handlerMethodRegistry = HandlerMethodRegistry.getInstance();
    
        private final Map<Pair<String, String>, HandlerMethod> mapper = new HashMap<>();
    
        private HandlerMethodMapper() {
        }
    
        public static HandlerMethodMapper getInstance() {
            if (handlerMethodMapper == null) {
                synchronized (HandlerMethodMapper.class) {
                    if (handlerMethodMapper == null) {
                        handlerMethodMapper = new HandlerMethodMapper();
                    }
                }
            }
            return handlerMethodMapper;
        }
    
        public HandlerMethod getHandlerMethod(Pair<String, String> pair) {
            return mapper.getOrDefault(pair, handlerMethodRegistry.getHandlerMethod(0));
        }
    
        public void mappingHandlerMethod(Pair<String, String> pair, int handlerMethodIndex) {
            mapper.put(pair, handlerMethodRegistry.getHandlerMethod(handlerMethodIndex));
        }
    }

      하지만 위의 코드에서는 해당 블록이 사라지고 mappingHandlerMethod 메서드를 호출하는 쪽 에서 요청 Url 과 HandlerMethod 를 매핑하도록 구조를 개선했다.

    🔹 HandlerMethodRegistry

    public class HandlerMethodRegistry {
    
        private static volatile HandlerMethodRegistry handlerMethodRegistry;
    
        private final List<HandlerMethod> registry = new ArrayList<>();
    
        private HandlerMethodRegistry() {
        }
    
        public static HandlerMethodRegistry getInstance() {
            if (handlerMethodRegistry == null) {
                synchronized (HandlerMethodRegistry.class) {
                    if (handlerMethodRegistry == null) {
                        handlerMethodRegistry = new HandlerMethodRegistry();
                    }
                }
            }
            return handlerMethodRegistry;
        }
    
        public void addHandlerMethod(HandlerMethod handlerMethod) {
            registry.add(handlerMethod);
        }
    
        public HandlerMethod getHandlerMethod(int index) {
            return registry.get(index);
        }
    }

      조금 더 정확히는 요청 Url 을 HandlerMethodRegistry 에 등록된 HandlerMethod 중 하나와 매핑시킬 수 있도록 구현했다. 이러한 방식으로 변경할 수 있게 된 것은 아래의 클래스들 덕분이다.

    🔹 BeanFactory

    public class BeanFactory {
    
        public Object getBean(String beanName) throws NoSuchFieldException, ClassNotFoundException, NoSuchMethodException,
                InvocationTargetException, IllegalAccessException {
    
            Class<?> context = Class.forName(this.getClass().getTypeName());
            Field field = context.getDeclaredField(beanName);
    
            Class<?> clazz = Class.forName(field.getType().getTypeName());
            Method method = clazz.getDeclaredMethod("getInstance");
    
            return method.invoke(null);
        }
    }

      짧지만 가장 정성들여서 작성해본 클래스다. 스프링 프레임워크의 빈을 생성하고 관리하는 BeanFactory 를 어떻게하면 구현해볼 수 있을까? 구현 방식에 대해 고민을 하다가 현재 프로젝트에서 FrontHandler, ~~Mapper 등과 같이 하나의 기능을 담당하는 클래스들은 모두 싱글톤 으로 구현했고, 각 클래스마다 하나씩만 생성된 인스턴스들은 정적 메서드getInstance 로 반환받아 사용할 수 있다는 점에 착안했다.

    🔹 WebServerContext

    public class WebServerContext extends BeanFactory {
    
        final FrontHandler frontHandler;
    
        /*
         * Registry
         */
        final HandlerRegistry handlerRegistry;
        final HandlerMethodMapperRegistry handlerMethodMapperRegistry;
    
        /*
         * Mapper
         */
        final HandlerMapper handlerMapper;
    
        /*
         * Handler
         */
        final ResourceHandler resourceHandler;
        final UserHandler userHandler;
    
        public WebServerContext() {
            super();
    
            frontHandler = FrontHandler.getInstance();
    
            handlerRegistry = HandlerRegistry.getInstance();
            handlerMethodMapperRegistry = HandlerMethodMapperRegistry.getInstance();
    
            handlerMapper = HandlerMapper.getInstance();
    
            resourceHandler = ResourceHandler.getInstance();
            userHandler = UserHandler.getInstance();
        }
    }

      스프링 컨텍스트 를 표현하고자 작성한 WebServerContext 클래스다. BeanFactory 를 상속받은 해당 클래스는 지금까지 작성해놓은 모든 싱글톤 패턴의 클래스들을 getInstance 메서드로 초기화하고, 각 클래스 타입의 필드가 참조하도록 값을 할당해준다. 참조 값이 할당되어 있는 각각의 멤버 필드 명이 빈 이름 이 되는 느낌을 주었다. 이에 따라 상속받은 BeanFactory 의 getBean 메서드로 마치 Context 에 등록된 빈을 가져오는 효과를 주었다.

    🔹 WebServerConfigurer

    public interface WebServerConfigurer {
    
      /**
       * 사용할 'Handler' 를 'HandlerRegistry' 에 등록합니다. 현재는 UserHandler 만 등록하고 있습니다.
       * HandlerRegistry 에 등록된 순서에 따라 Handler 의 index 값이 결정됩니다.
       *
       * @param handlerRegistry
       * @throws Exception
       *
       */
      default void addHandler(HandlerRegistry handlerRegistry) throws Exception {
      }
    
      /**
       * HandlerMapper 에 'URL' 과 HandlerRegistry 에 저장된 Handler 의 'index' 값을 매핑합니다.
       * ex. UserHandler 는 UserHandler 는 '/users' 과 매핑되며 HandlerRegistry 에 0 번째 index 에 저장되어있으므로 - handlerMapper.mappingHandler("/user", 0);
       *
       * @param handlerMapper
       *
       */
      default void configureHandlerMapper(HandlerMapper handlerMapper) {
      }
    
      /**
       * Handler 와 연동될 'HandlerMethodMapper' 를 HandlerMethodMapperRegistry 등록합니다.
       * Handler 와 마찬가지로 등록된 순서에 따라 index 값이 결정되므로 이 값을 사용하여 Handler 와 HandlerMethodMapper 를 바인딩할 수 있습니다.
       *
       * HandlerMethodMapper 는 특정 Handler 가 담당할 메서드들의 집합입니다.
       * HandlerMethod 는 함수형 인터페이스로 구현되어있으며, 각자 매핑된 'HttpMethod' 와 'URL' 이 존재합니다.
       * UserHandler 에 바인딩된 HandlerMethodMapper 에는 GET /create, POST /create, POST /login, GET /logout 으로 매핑된 HandlerMethod 가 존재합니다.
       *
       * @param handlerMethodMapperRegistry
       *
       */
      default void addHandlerMethodMapper(HandlerMethodMapperRegistry handlerMethodMapperRegistry) {
      }
    
      /**
       * HandlerRegistry 에 등록된 Handler 를 모두 순회하며 index 값으로 HandlerMethodMapperRegistry 에서 각자의 HandlerMethodMapper 를 찾아 바인딩합니다. (setter 주입 방식 느낌)
       * ResourceHandler 를 제외한 모든 Handler 는 Handler 인터페이스를 구현하고 있으며 HandlerMethod 로 동작을 결정합니다.
       *
       * @param handlerRegistry
       * @param handlerMethodMapperRegistry
       *
       */
      default void bindMethodsToHandler(HandlerRegistry handlerRegistry, HandlerMethodMapperRegistry handlerMethodMapperRegistry) {
      }
    }

      마지막으로 스프링의 WebMvcConfigurer 인터페이스의 형태를 흉내내보았다. 실제 WebMvcConfigurer 인터페이스에는 선언되어 있지 않지만, 현재 프로젝트에서 필요한 기능들을 구성하기 위해 임의로 작성해봤다.

    🔹 WebServerConfig

    public class WebServerConfig implements WebServerConfigurer {
    
        private static final Logger logger = LoggerFactory.getLogger(WebServerConfig.class);
    
        private static volatile WebServerConfig webServerConfig;
    
        final WebServerContext webServerContext = new WebServerContext();
    
        private WebServerConfig() throws Exception {
    
            logger.debug("WebServerConfig() start");
    
            addHandler((HandlerRegistry) webServerContext.getBean("handlerRegistry"));
            configureHandlerMapper((HandlerMapper) webServerContext.getBean("handlerMapper"));
    
            addHandlerMethodMapper((HandlerMethodMapperRegistry) webServerContext.getBean("handlerMethodMapperRegistry"));
    
            bindMethodsToHandler(
                    (HandlerRegistry) webServerContext.getBean("handlerRegistry"),
                    (HandlerMethodMapperRegistry) webServerContext.getBean("handlerMethodMapperRegistry")
            );
    
            logger.debug("WebServerConfig() end");
        }
    
        public static WebServerConfig getInstance() throws Exception {
            if (webServerConfig == null) {
                synchronized (WebServerConfig.class) {
                    if (webServerConfig == null) {
                        webServerConfig = new WebServerConfig();
                    }
                }
            }
            return webServerConfig;
        }
    
        @Override
        public void addHandler(HandlerRegistry handlerRegistry) throws Exception {
    
            logger.debug("addHandlerMethod() start");
    
            handlerRegistry.addHandler((UserHandler) webServerContext.getBean("userHandler"));
    
            logger.debug("addHandlerMethod() end");
        }
    
        @Override
        public void configureHandlerMapper(HandlerMapper handlerMapper) {
    
            logger.debug("configureHandlerMethod() start");
    
            handlerMapper.mappingHandler("/user", 0);
    
            logger.debug("configureHandlerMethod() start");
        }
    
        @Override
        public void addHandlerMethodMapper(HandlerMethodMapperRegistry handlerMethodMapperRegistry) {
    
            logger.debug("addHandlerMethodMapper() start");
    
            handlerMethodMapperRegistry.addHandlerMethod(
                    new HandlerMethodMapper(
                            Map.of(
                                    new Pair<>(HttpMethod.GET.name(), "/create"),
                                    (request) -> {
    
                                        User user = new User(
                                                request.getParameter("userId"),
                                                request.getParameter("password"),
                                                request.getParameter("name"),
                                                request.getParameter("email")
                                        );
    
                                        DataBase.addUser(user);
                                        logger.debug("user: {}", DataBase.findAll());
    
                                        HttpResponse response = new HttpResponse(request.getProtocol());
                                        response.setStatus(HttpStatus.CREATED);
    
                                        return response;
                                    },
    
                                    new Pair<>(HttpMethod.POST.name(), "/create"),
                                    (request) -> {
    
                                        if (DataBase.findUserById(request.getParameter("userId")) != null) {
                                            HttpResponse response = new HttpResponse(request.getProtocol());
                                            response.setHeader("Location", "/user/form.html");
                                            response.setStatus(HttpStatus.FOUND);
    
                                            return response;
                                        }
    
                                        User user = new User(
                                                request.getParameter("userId"),
                                                request.getParameter("password"),
                                                request.getParameter("name"),
                                                request.getParameter("email")
                                        );
    
                                        DataBase.addUser(user);
                                        logger.debug("user: {}", DataBase.findAll());
    
                                        HttpResponse response = new HttpResponse(request.getProtocol());
                                        response.setHeader("Location", "/index.html");
                                        response.setStatus(HttpStatus.FOUND);
    
                                        return response;
                                    },
    
                                    new Pair<>(HttpMethod.POST.name(), "/login"),
                                    (request) -> {
    
                                        logger.debug("cookies: {}", request.getCookies());
    
                                        User user = DataBase.findUserById(request.getParameter("userId"));
    
                                        logger.debug("user: {}", user);
    
                                        HttpResponse response = new HttpResponse(request.getProtocol());
                                        if (!user.getPassword().equals(request.getParameter("password"))) {
                                            response.setHeader("Location", "/user/login_failed.html");
                                            response.setStatus(HttpStatus.FOUND);
    
                                            return response;
                                        }
    
                                        HttpSession session = request.getSession();
                                        session.setAttribute("sessionId", UUID.randomUUID().toString());
    
                                        Cookie cookie = new Cookie("sessionId", (String) session.getAttribute("sessionId"));
                                        cookie.setPath("/");
    
                                        response.addCookie(cookie);
                                        response.setHeader("Location", "/index.html");
                                        response.setStatus(HttpStatus.FOUND);
    
                                        return response;
                                    },
    
                                    new Pair<>(HttpMethod.GET.name(), "/logout"),
                                    (request) -> {
    
                                        logger.debug("cookies: {}", request.getCookies());
    
                                        HttpSession session = request.getSession();
    
                                        Cookie cookie = new Cookie("sessionId", (String) session.getAttribute("sessionId"));
                                        cookie.setMaxAge(0);
                                        cookie.setPath("/");
    
                                        HttpResponse response = new HttpResponse(request.getProtocol());
                                        response.addCookie(cookie);
                                        response.setHeader("Location", "/index.html");
                                        response.setStatus(HttpStatus.FOUND);
    
                                        session.removeAttribute("sessionId");
    
                                        logger.debug("cookies: {}", request.getCookies());
    
                                        return response;
                                    }
                            )
                    )
            );
    
            logger.debug("addHandlerMethodMapper() end");
        }
    
        @Override
        public void bindMethodsToHandler(HandlerRegistry handlerRegistry,
                                         HandlerMethodMapperRegistry handlerMethodMapperRegistry) {
    
            int handlerCount = handlerRegistry.size();
            for (int index = 0; index < handlerCount; ++index) {
                Handler handler = handlerRegistry.getHandler(index);
                HandlerMethodMapper handlerMethodMapper = handlerMethodMapperRegistry.getHandlerMethod(index);
                handler.bindHandlerMethodMapper(handlerMethodMapper);
            }
        }
    }

      WebServerConfigurer 인터페이스의 메서드로 WebServerContext 에 등록된 빈들을 사용하여 웹 서버의 전체적인 환경을 구성할 수 있게 되었다. addHandlerMethodMapper 메서드에서 직접 람다식으로 HandlerMethod 를 등록하는 방식은 여전히 변경되어야할 사항이다.

결론

  • 최대한 많은 것을 시도해보려고 노력해 본 프로젝트였다. 다만, WebServerConfigurer 인터페이스의 메서드를 정의하는 과정에서 많은 부분들을 수동으로 작성하고 있어서 그 부분이 많이 아쉬웠다. 환경을 구성하는 역할을 담당하는 클래스이긴 하지만, 람다식으로 직접 객체를 생성한다든지, 특정 HandlerMethod 를 요청 Url 과 메서드로 매핑하고, 또 다시 HandlerMethod 와 Handler 를 매핑시켜주는 작업으로 인해 Config 파일이 지나치게 비대해졌다. 💦 내공이 쌓여 다시 프로젝트를 봤을 때 깔끔하게 코드를 정리할 수 있었으면 좋겠다!

  • 팀 프로젝트로 넘어가기 전에 가장 재미있게 진행했던 마지막 프로젝트였다! 페어 @아더 와 함께해서 즐거웠다!
profile
riako

4개의 댓글

comment-user-thumbnail
2022년 7월 14일

잘 읽고 갑니다 선생님;; 역시👍🏻

1개의 답글
comment-user-thumbnail
2022년 7월 17일

키야 👍

1개의 답글