독서/📚

[Next Step] 3~4장 HTTP 웹서버 구현을 통해 HTTP 이해하기(No Framework)

leejinwoo1126 2023. 11. 15. 11:21
반응형

 


로컬 개발 환경 구축 

https://dev-ljw1126.tistory.com/294

 

[Next Step] 3.3 원격 서버에 배포 (p84) 정리

목차 요구사항 로컬 개발 환경에 설치한 HTTP 웹 서버를 물리적으로 떨어져 있는 원격 서버에 배포해 정상적으로 동작하는지 테스트한다. 이때 HTTP 웹 서버 배포 작업은 root 계정이 아닌 배포를

dev-ljw1126.tistory.com

 


실습 프로젝트 저장소

실습의 경우 처음에 fork 받았는데, 깃 허브 잔디가 심어지지 않아 기술 블로그 참고(링크)하여 저장소 설정을 변경하도록 함

 

web-application-server (3 ~ 6장)

https://github.com/slipp/web-application-server

 

GitHub - slipp/web-application-server: 웹 애플리케이션 서버 실습을 위한 뼈대

웹 애플리케이션 서버 실습을 위한 뼈대. Contribute to slipp/web-application-server development by creating an account on GitHub.

github.com

 


웹 서버 실습 요구사항

1. index.html 응답하기(p96)

Http Request Header 예시

GET /index.html HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Accept: */*

 

요청 헤더를 Parsing 하여 Map 자료 구조에 담은 후 지정된 위치의 index.html 파일을 읽어와 응답할 수 있도록 함

 

RequestHandler 클래스

public void run() {

    try(InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {
          // 1. 요청 헤더 Parsing
          BufferedReader br = new BufferedReader(new IntputStreamReader(in, StandardCharsets.UTF_8));
          String line = br.readLine();

          if(line == null) {
            return;
          }

          String[] tokens = line.split(" ");
          String url = tokens[1]; // 요청 주소

          Map<String, String> headerMap = new HashMap();
          while(!"".equals(line = br.readLine())) {
            log.debug("{}", line);

            String[] headerTokens = line.split(": ");
            headerMap.put(headerTokens[0], headerTokens[1]);
          }
    
          // 2. index.html 응답 처리
          DataOutputStream dos = new DataOutputStream(out);
          byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
          response200Header(dos, body.length);
          responseBody(dos, body);
    
    } catch(IOException e) {
    	log.error(e.getMessage());
    }
}

 

 

response200Header() 와 responseBody() 메서드

private void response200Header(DataOutputStream dos, int lengthOfBodyContent) {
	try {
    	dos.writeBytes("HTTP/1.1 200 OK\r\n");
        dos.writeBytes("Content-Type: text/html;charset=utf-8\r\n");
        dos.writeBytes("Content-Length: "+ lengthOfBodyContent +"\r\n");
        dos.writeBytes("\r\n"); // response body와 구분하기 위해 한 줄 띄우기*
    } catch(IOException e) {
    	log.error(e.getMessage());
    }
}

private void responseBody(DataOutputStream dos, byte[] body) {
	try {
    	dos.write(body, 0, body.length);
        dos.flush();
    } catch(IOException e) {
    	log.error(e.getMessage());
    }
}

 


2. GET 방식으로 회원가입하기

① "회원가입" 메뉴를 클릭하면 http://localhost:8080/usrt/form.html 이동하여 회원가입 가능

② queryString 형태로 사용자가 입력한 값을 파싱해 model.User 클래스에 저장

GET /user/creeate?userId=javajigi&password=password&name=unknown
HTTP/1.1

*HttpRequestUtils.parseQuerString(String) 을 사용하면 Map<String 키, String 값> 형태로 반환해준다. 이를 활용하도록 하자

 

 

db.DataBase 클래스

Map 자료구조 활용

public class DataBase {
    private static Map<String, User> users;

    static {
        users = Maps.newHashMap();
        users.put("test1", new User("test1", "1234", "테스터1", ""));
        users.put("test2", new User("test2", "1234", "테스터2", ""));
        users.put("test3", new User("test3", "1234", "테스터3", ""));
    }

    public static void addUser(User user) {
        users.put(user.getUserId(), user);
    }

    public static User findUserById(String userId) {
        return users.get(userId);
    }

    public static Collection<User> findAll() {
        return users.values();
    }
}

 

model.User 클래스

public class User {
    private String userId;
    private String password;
    private String name;
    private String email;

    public User(String userId, String password, String name, String email) {
        this.userId = userId;
        this.password = password;
        this.name = name;
        this.email = email;
    }
	
    // getter 생략
}

 

RequestHandler 클래스

*.html 요청사항과 GET /user/create 를 구분하기 위해 조건문 분기 작성

public void run() {

    try(..) {
        // parsing 생략 

        DataOutputStream dos = new DataOutputStream(out);
        if(url.startsWith("/user/create") {
            int idx = url.indexOf("?");
            String queryString = url.subString(idx + 1);
            Map<String, String> userMap = HttpRequestUtils.parseQueryString(queryString);

            User user = new User(userMap.get("userId"), userMap.get("password"), 
            userMap.get("name"), userMap.get("email"));
            DataBase.addUser(user);

            log.debug("User : {}", user);
        } else {
            byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
            response200Header(dos, body.length);
            responseBody(dos, body);
        }

    } catch(..) {
    
    }
}

*우선 유효성 검사나 성공시 리다이렉트 처리는 하지 않음


3. POST 방식으로 회원가입하기

http://localhost:8080/usrt/form.html 파일의 form 태그의 method를 post로 수정한 후 회원가입이 정상동작하도록 구현

② request body 에 데이터 담긴다

POST /user/create HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Content-Length: 59
Content-Type: application/x-www-form-urlencoded
Accept: */*

userId=javajigi&password=password&name=unknown

*Header와 Body 사이에 한 줄을 띄어서 구분한다

 

RequestHandler 클래스

-요구사항2의 if 조건문 안에서 0 ~ Content-Length만큼 BufferReader에 있는 Request Body 데이터를 읽어와 처리하도록 변경

-이때 IOUtils 클래스는 실습에서 제공되는 클래스이다

public void run() {

    try(..) {
        // parsing 생략 

        DataOutputStream dos = new DataOutputStream(out);
        if(url.startsWith("/user/create") {
            // 여기만 변경
            String requestBody = IOUtils.readData(br, Integer.parseInt(headerMap.get("Content-Length"));
            Map<String, String> userMap = HttpRequestUtils.parseQueryString(requestBody);

            User user = new User(userMap.get("userId"), userMap.get("password"), 
            userMap.get("name"), userMap.get("email"));
            DataBase.addUser(user);

            log.debug("User : {}", user);
        } else {
            byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
            response200Header(dos, body.length);
            responseBody(dos, body);
        }

    } catch(..) {
    
    }
}

4. 302 status code 적용

- "회원가입" 완료 후 /index.html 페이지로 이동하도록 하도록 한다 (redirect)

- 이때 브라우저의 URL도 /user/create가 아니라 /index.html로 변경해야 한다

 

참고. https://en.wikipedia.org/wiki/HTTP_302

 

 

① 302 상태 코드 사용 전

회원 가입 후 200 상태 코드로 forwarding(index.html)하게 될 경우, 화면은 index.html로 변하지만 브라우저 주소창 URI는 "/user/create"가 유지 되고 있음이때 새로고침할 경우 중복 회원가입 발생

 

 

② 302 상태 코드 사용 후

302 상태 코드 응답시 웹 브라우저는 Location 정보로 redirect 수행 → 브라우저 주소가 /index.html로 변경되고 엔터 눌러진 상태와 같아짐

참고. redirect 이유가 궁금합니다(인프런)

 

 

RequestHandler 클래스

회원가입 완료 후 응답 코드 302로 하여 /index.html로 redirect 시킨다

private void response302Hedaer(DataOutputStream dos) {
	try {
    	dos.writeBytes("HTTP/1.1 302 Found\r\n");
        dos.writeBytes("Location: /index.html\r\n");
        dos.writeBytes("\r\n");
    } catch(IOException e) {
    	log.error(e.getMessage());
    }
}

 

 

public void run() {

    try(..) {
        // parsing 생략 

        DataOutputStream dos = new DataOutputStream(out);
        if(url.startsWith("/user/create") {
            String requestBody = IOUtils.readData(br, Integer.parseInt(headerMap.get("Content-Length"));
            Map<String, String> userMap = HttpRequestUtils.parseQueryString(requestBody);

            User user = new User(userMap.get("userId"), userMap.get("password"), 
            userMap.get("name"), userMap.get("email"));
            DataBase.addUser(user);

            log.debug("User : {}", user);
            response302Header(dos); // 여기 추가 *
        } else {
            byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
            response200Header(dos, body.length);
            responseBody(dos, body);
        }

    } catch(..) {
    
    }
}

 


5. 로그인 하기

"로그인" 메뉴를 클릭하면 http://localhost:8080/user/login.html 로 이동해 로그인 할 수 있다

 로그인 성공시 응답 헤더에 Set-Cookie: logined=true 추가 후 /index.html로 이동해야 한다(이때 응답 코드 302 Found)

 로그인 실패시 응답 헤더에 Set-Cookie: logined=false 추가, 이때 응답 코드는 401 Unauthorized이고, /user/login_failed.html로 이동해야 한다

 

RequestHandler 클래스

마찬가지로 조건문 분기를 통해 처리하도록 한다. 로그인 페이지로 가는 URL과 로그인 POST 요청을 잘 구분하도록하자

public void run() {

    try(..) {
        // parsing 생략 

        DataOutputStream dos = new DataOutputStream(out);
        if(url.startsWith("/user/create") {
            // 중략.. 
        } else if(url.startsWith("/user/login" && "POST".equals(tokens[0])) {
            String requestBody = IOUtils.readData(br, Integer.paraseInt(headerMap.get("Content-Length"));
            Map<String, String> userMap = HttpRequestUtils.parseQueryString(requestBody);
        
            User user = DataBase.findUserById(userMap.get("userId"));
            if(user == null) {
            	responseLoginFail(dos);
            } 
        
            // User model에 method를 만드는게 좀 더 나을 거 같다
            if(user.getPassword().equals(userMap.get("password")) {
               response302HeaderWithCookie(dos);
            } else {
               responseLoginFail(dos);
            }
        
        } else {
            byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
            response200Header(dos, body.length);
            responseBody(dos, body);
        }

    } catch(..) {
    
    }
}

 

 

response302HeaderWithCookie() 와 responseLoginFail() 메서드

private void response302HeaderWithCookie(DataOutputStream dos) {
   try {
      dos.writeBytes("HTTP/1.1 302 Found\r\n");
      dos.writeBytes("Location: /inedx.html\r\n");
      dos.writeBytes("Set-Cookie: logined=true\r\n");
      dos.writeBytes("\r\n");
   } catch(IOException e) {
   	  log.error(e.getMessage());
   }
}


private void responseLoginFail(DataOutputStream dos) {
  byte[] body = Files.readAllBytes(new File("./webapp/user/login_failed.html").toPath());
  response401HeaderWithCookie(dos);
  responseBody(dos, body);
}

private void response401HeaderWithCookie(DataOutputStream dos) {
   try {
      dos.writeBytes("HTTP/1.1 401 Unauthorized\r\n");
      dos.writeBytes("Content-Type: text/html;charset=utf-8;\r\n");
      dos.writeBytes("Set-Cookie: logined=false\r\n");
      dos.writeBytes("\r\n");
   } catch(IOException e) {
   	  log.error(e.getMessage());
   }
}

 


6. 사용자 목록 출력

http://localhost:8080/user/list 로 접근했을 때

① 접근하고 있는 사용자가 "로그인 상태"인 경우(Cookie: logined=true) 사용자 목록을 출력한다

만약 로그인 하지 않은 경우 /login.html로 302 Found Redirect 이동한다

 

RequestHandler 클래스

Cookie 값을 Parsing 하기 위해 HttpRequestUtils.parseCookies(..) 메서드를 활용한다

public void run() {

    try(..) {
        // parsing 생략 

        DataOutputStream dos = new DataOutputStream(out);
        if(url.startsWith("/user/create") {
            // 중략.. 
        } else if(url.startsWith("/user/login" && "POST".equals(tokens[0])) {
            // 중략..
        } else if(url.startsWith("/user/list")) {
            Map<String, String> cookieMap = HttpRequestUtils.parseCookies(headerMap.get("Cookie"));
            
            boolean logined = Boolean.parseBoolean(cookieMap.get("logined"));
            if(logined) { // 로그인 한 경우
            	StringBuilder sb = new StringBuilder();
                List<User> users = new ArrayList<>(DataBase.findAll());
                sb.append("<table>");
                users.forEach(user -> sb.append("<tr><td>" + user.getName() + "<td></tr>"));
                sb.append("</table>");
                
                String body = sb.toString();
                response200Header(dos, body.length());
                responseBody(dos, body.getBytes());
            } else { // 로그인 하지 않은 경우
            	response302Header(dos);
            }
        } else {
            byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
            response200Header(dos, body.length);
            responseBody(dos, body);
        }

    } catch(..) {
    
    }
}

7. CSS 지원하기 

Http Request Header 예시

GET /css/style.css HTTP/1.1
Host: localhost:8080
Accept: text/css, */*;q=0.1
Connection: keep-alive

 

RequestHandler 클래스에서 .css로 끝나는 url의 경우 css 파일을 읽어 byte 코드로 응답해주면 되었다. 이때 응답 헤더의 Content-type: text/css으로 전송한다.

public class RequestHandler extends Thread {
    // 중략 
  
    public void run() {
        log.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(),
                connection.getPort());

        try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream()) {
            BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));

            // header parsing .. 

            // 조건문 분기.. 

            } else if(url.endsWith(".css")) { 
                log.debug("request css : {}", url);
                byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
                response200HeaderWithCss(dos, body.length);
                responseBody(dos, body);
            } else {
                //..
            } catch (IOException e) {
                log.error(e.getMessage());
            }
        }
    }

    private void response200HeaderWithCss(DataOutputStream dos, int lengthOfBodyContent) {
        try {
            dos.writeBytes("HTTP/1.1 200 OK \r\n");
            dos.writeBytes("Content-Type: text/css;charset=utf-8\r\n");
            dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n");
            dos.writeBytes("\r\n");
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }
    
    private void responseBody(DataOutputStream dos, byte[] body) {
        try {
            dos.write(body, 0, body.length);
            dos.flush();
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }
    
}

결과 화면

 

솔직히 말해서 쉽지 않았다.. 

 

개발 경력은 있지만, 직접 요청/응답 처리를 어떻게 처리해야 할 지 알지 못했기 때문에 시간 소비를 오래 하였다. 

" HttpServletRequest, HttpServletResponse 어딨어?! "

 

결국에는 byte 데이터를 Response Body 에 넣고, Response Header에 메타를 적절하게 작성하면 브라우저에서 처리된다는 사실을 알 수 있었다

반응형