[자바 웹 프로그래밍 Next-Step] 3주차 CH06: 서블릿/JSP를 활용해 동적인 웹 애플리케이션 개발하기

이호석·2023년 2월 13일
0
post-thumbnail

자바 웹 프로그래밍 Next-Step - 박재성 저자 책으로 스터디를 하며 진행했던 내용들을 기록하고 있습니다.

3주차에 진행했던 Chapter 06의 목표는 다음과 같습니다.

  • Chapter 06: 서블릿/JSP를 활용해 동적인 웹 애플리케이션 및 MVC 프레임워크 구현하기

모든 코드들은 다음 저장소에서 확인할 수 있습니다.
https://github.com/Java-web-programming-Next-Step/next-step-web-programming/tree/HiiWee/6
프로젝트명: jwp-basic-gradle

추가로 세션 구현에 해당되는 6.2 ~ 6.3절은 web-application-server-gradle에서 진행했습니다.

📌 3주차 6장

🛑 이슈 목록

style.css 미적용 이슈❗️

  • 문제

  • 과정

    • 네트워크를 살펴보니 로드되어야 할 bootstrap.min.css파일은 응답 됐지만, style.css 파일은 응답되지 않음
  • 해결

    • 중간에 script 구문에 문제가 있었음, 해당 스크립트는 Internet Explorer 9버전에서 HTML5를 지원하게 도와주는 구문이므로 Chrome 브라우저를 사용하는 본인에게는 필요가 없음, 또한 해당 구문으로 인해 다음 link 태그가 읽히지 않고 있었다.

Default Compile Output Path 이슈❗️

  • 문제
    • 기존 Java 프로젝트에서는 Project Structure에서 컴파일 파일인 class파일의 경로를 설정할 수 있지만 gradle을 이용해 build한 프로젝트는 다음과 같은 경고문과 함께 변경할 수 없음
  • 과정
    • how to change output path in gradle로 구글링 다음과 같은 글을 발견

  • 해결
    • build.gradle의 수정을 통해 default output path를 변경할 수 있다.

JSTL 적용 불가 이슈❗️

  • 문제
    • 회원가입 → 정보입력 → user/create로 POST 요청시 발생
    • Exception Message
      • org.apache.jasper.JasperException: The absolute uri: [http://java.sun.com/jsp/jstl/core](http://java.sun.com/jsp/jstl/core) cannot be resolved in either web.xml or the jar files deployed with this application
  • 과정
    • 왜 그런진 모르겠으나? WEB-INF/lib 폴더에서 jstl jar 파일이 존재하지 않아 발생하는 문제였음 본인은 build.gradle에 jstl에 대한 의존성을 추가했지만 톰캣에서 해당 의존성을 사용하지 못하는것으로 보입니다.
  • 해결
    • 단순하게 WEB-INF/lib/ 위치에 jstl-1.2.jar 파일을 추가함

Tomcat 실행시 오류 해결❗️

IDE에서 embedded tomcat을 직접 실행할 때 발생하는 에러 해결

  • 원인
    • JSTL jar 파일을 WEB-INF/lib/ 에 추가하면서부터 발생함
  • 해결
    • 해당 jar 파일을 삭제하면 정상동작하고, 이후 다시 추가해도 정상 동작함 빌드시 의존성을 불러올때 완전하게 불러오지 못하면 발생하는 오류라 하는데 정확한 이유를 모르겠습니다.

View 파일 은닉시 CSS 적용 불가 이슈❗️

  • 문제

    • View 파일 은닉을 위해 WEB-INF/views/ 경로로 모든 jsp파일을 옮겼을때 CSS가 적용이 안되는 이슈가 발생

    • response header에서 Content-Type 헤더가 text/html로 전송되어서 적용이 안되는줄 알았으나 css용 서블릿을 통해 Content-Type을 text/css로 변경해도 적용이 되지 않는다.

  • 원인

    • HomeController에서 value 매핑을 “/”로 해주었기 때문에 모든 자원들은 해당 컨트롤러를 통과한다. 따라서 css 파일임에도 html파일이 응답되었다.
  • 해결

    • HomeController의 mapping을 “”로 해주면 정상적으로 css가 적용된다.

✅ 6.1 서블릿/JSP로 회원관리 기능 다시 개발하기

https://github.com/slipp/jwp-basic

위 프로젝트의 step0-getting-started 브랜치에서 파일을 가져옴

✅ 6.1.2 개인정보수정 실습

사용자 회원가입시 동작

  1. 사용자가 회원가입을 하게되면 /user/create로 POST 요청이 입력되고 메모리 DB에 회원가입 정보가 저장된다. 이후 /user/list로 redirect하고 회원가입된 사용자의 정보가 브라우저에 출력된다.

요구사항

  1. 수정 버튼 클릭시 회원 정보 수정을 위한 update.jsp를 화면에 출력
    • userId에 해당되는 부분은 readOnly이며 외에 부분들은 수정할 수 있다.
  2. 회원정보 수정하기를 누르면 기존에 DB에 저장된 user의 정보가 update되어야 한다.
    • 현재 DB는 메모리를 이용하고 있으므로 쿼리가 아닌 다른 방식이 필요

HttpServletRequest로 정보를 수정하려는 회원 정보 저장의 어려움

/user/list —GET?userId=${value}→ /user/update -FORWARD→ update.jsp -POST→ /user/update

  • Request의 스코프는 하나의 요청이 들어오고 응답이 나가기까지 유지된다. 따라서 서로 다른 요청은 정보를 공유할 수 없다.
  • 수정 버튼을 클릭하게 되면 새로운 요청이 웹서버에 전달되므로 HttpServletRequest 객체로 user 정보를 공유할 수 없음
  • HttpSession을 이용하면 가능하지만, 뒤에 실습때 나올것 같아 일단은 url 쿼리스트링으로 수정할 userId를 전송

쿼리가 아닌 회원정보 저장

기존의 존재하는 user를 삭제하고, updatedUser를 저장하는 방식으로 구현

  • 기존의 user를 변경하려 시도했지만 Model 객체인 User는 불변해야 하므로 좋지 않은 선택이라 판단됨

✅ 6.1.3 로그인/로그아웃 기능 실습

로그인 성공(POST /user/login)

  • 로그인을 시도하게 되면 form data를 통해 아이디와 비밀번호가 넘어온다. 따라서 service 계층에서 로그인에 대한 검증을 진행하고 로그인이 가능하다면 HttpSession에 “user”라는 key값으로 로그인 유저의 정보를 저장한다.
  • HttpSession에 값을 저장했다면 다시 처음 메인페이지로 리다이렉트 된다.
  • Navigation bar는 다음과 같이 세션에 해당 값이 있는지 파악하여 로그인과 로그아웃을 구분한다.

로그인 실패 (POST /user/login)

  • service 계층에서 진행됨, 실패하게 되면 HttpSession에 로그인 시도에 관한 boolean 값을 저장하고 메인페이지로 리다이렉트 된다. 로그인 실패의 경우는 다음과 같다.

    • 아이디, 비밀번호 중 하나라도 값이 입력되지 않는 경우
    • DB에 존재하는 아이디의 유저가 없는 경우
    • 패스워드가 일치하지 않는 경우
  • GET /user/login

    • 세션에 값을 체크하고 로그인을 시도한 경우라면 로그인 실패화면을, 아니라면 정상적인 로그인 화면을 출력한다.

로그아웃 (GET /user/logout)

  • Navigation bar에서 로그아웃을 클릭하면 GET /user/logout 요청된다. 단순히 HttpSession을 날려주고, 메인 페이지로 리다이렉트한다.

✅ 6.1.4 회원 목록 및 개인정보 수정 보안 강화 실습

현재 사용자 목록은 모든 사람이 보고, 수정할 수 있다. 이를 로그인한 사용자가 자기 자신의 정보만 변경할 수 있도록 수정해보자

보안 강화 전: 단순히 모든 유저를 담아서 뿌려줌

보안 강화 후: 로그인이 되어있는지 세션을 통해 확인하고, 포워딩한다. 로그인이 안돼있으면 로그인 페이지로 이동한다.

✅ 6.2.2 ~ 6.3 세션 구현하기

HttpSession

각 요청마다 서로 다른 세션이 사용된다. 세션은 사용자 한 명을 위한 저장소이며 서로다른 사용자가 접속되면 서로 다른 세션이 생성되어야 한다.
위의 세션에 담을 데이터를 저장할 저장소와 해당 세션의 아이디를 가진다. 그 외에는 요구사항을 충족한다.

HttpSessions

각 사용자마다의 세션을 저장하기 위해 정적인 세션 저장소이다. 모든 사용자는 서로다른 UUID값을 통해 구분된다.
getSession()을 통해 세션을 반환하는데 만약 존재하지 않는 key라면 새로운 세션을 만들어 반환한다.

사실 실제 세션은 웹애플리케이션이 실행되자마자 생성되진 않는다.
하지만 편의를 위해 requestDispatch가 되기 직전에 해당 작업을 통해 현재 사용자의 요청에 대한 SessionId를 생성한다. 이 방법은 하나의 문제점을 가지고 있는데 이는 뒤에서 설명하겠다.

위에서 쿠키에 심게되면 SESSION_ID_NAME을 Key값으로 각 세션을 구분하는 임의의 변수 UUID가 value값으로 들어가 외부에서 쿠키를 직접 보아도 실제 데이터를 캡슐화 할 수 있다.
실제 데이터는 UUID를 key값으로 이용해 서버 내부에서 접근할 수 있다.

기존 쿠키를 이용한 로그인 및 사용자 목록 확인을 세션으로 변경

  • LoginController (POST /user/login)

    모든 로그인에 대한 조건을 만족하게되면 세션을 통해 현재 user에 대한 정보를 저장한다.

  • UserListController (GET /user/list)

    현재 세션의 UUID 값을 통해 세션에 저장되어있는 user 객체를 불러와 로그인을 검증한다. 단순히 null 체크로 검증하고 있지만, 실제 로직은 이보다 더 철저하게 검증하는편이 좋다.

🔴 로그인시 현재 세션이 가지고 있는 문제점

현재 세션이 가지고 있는 문제점을 말하기전에 우선 세션에 대한 UUID 값이 생성되는 시기를 알아야한다. 세션은 다음과 같은 순서로 생성된다.

정상적인 흐름

  • FrontController에서 UUID 생성 후 response header에 Set-Cookie를 통해 세션 쿠키가 심어진다.
    • Set-Cookie: MYJSESSIONID=uuid난수
  • 이후 GET /user/login을 통해 로그인 페이지를 보여준다.
    • 이때 Set-Cookie를 통해 SESSIONID값이 브라우저에게 전달됨
  • 사용자는 로그인 값을 입력하고 POST /user/login을 요청한다.
    • 웹브라우저에 저장되어있던 쿠키 값을 Cookie 헤더에 심어 request함
  • request.getSession()은 request header에서 MYJSESSIONID 값을 찾는다.
    • 값을 찾아서 해당 세션 객체에 user를 저장한다.
  • 이후 GET /user/list 요청시 사용자 목록 정상 출력

위의 순서는 검증이 전부 통과했을때 이루어진다. 여기서는 GET이후 Set-Cookie가 웹브라우저가 전달되고 POST 요청시 웹 브라우저는 Cookie 값을 전달하기에 request header에서 MYJSESSIONID 값을 파싱할 수 있다.

**만약 GET을 통해 로그인한다면? 문제가 발생한다.**

문제를 가진 흐름

  • FrontController에서 UUID 생성 후 response header에 Set-Cookie를 통해 세션 쿠키가 심어진다.
    • Set-Cookie: MYJSESSIONID=uuid난수
  • 이후 GET /user/login?queryString을 통해 로그인을 요청한다.
    • 이때 Set-Cookie를 통해 SESSIONID값이 브라우저에게 전달됨
  • 서버는 parameter를 파싱하고 로그인을 한다.
    • request.getSession()은 request header에서 MYJSESSIONID값을 찾지만 존재하지 않으므로 null이 세션의 키값으로 저장된다.
  • 로그인을 했지만 GET /user/list로 접근하게 되면 사용자 목록을 볼 수 없다.

다음은 문제가 되는 부분을 실제 코드로 작성한 것이다.
(GET /user/login?userId=id&password=password)

해결방안?

가장 문제가 되는 부분은 FrontController에서 생성된 세션의 UUID 값을 request가 즉시 알 수 없다는 부분이다. 이런 부분은 Request와 Response가 서로 알 수 있도록 의존성을 주면 해결할 수 있을것 같다. 하지만 더 좋은 방법이 있지 않을까 싶습니다 여러분은 어떻게 생각하시나용?

✅ 6.4 ~ 6.5 MVC 프레임워크 구현하기

@WebServlet의 loadOnStartUp 속성

서블릿간 초기화에서 우선순위가 필요한 경우 0에 가까운 숫자일수록 높은 우선순위로 먼저 초기화된다.
DispatcherServlet은 모든 서블릿에 대한 요청을 받아야 하므로 항상 먼저 초기화 돼야 하므로 속성을 1로 지정한다.

더하여 서블릿은 클라이언트의 요청이 최초로 발생하는 시점에 서블릿 인스턴스가 생성되는데 해당 설정을 이용하면 서블릿 컨테이너가 시작하는 시점에 서블릿 인스턴스 생성과 초기화가 진행된다.

정적 리소스를 필터링하는 필터 구성하기 (이슈❗️)

  • 초기 코드
    @WebFilter(urlPatterns = {"/css/*", "/js/*", "/fonts/*", "/images/*", "/favicon.ico"})
    public class ResourceFilter implements Filter {
        private static final Logger log = LoggerFactory.getLogger(ResourceFilter.class);
    
        @Override
        public void init(final FilterConfig filterConfig) throws ServletException {
    
        }
    
        @Override
        public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)
                throws IOException, ServletException {
            HttpServletRequest httpServletRequest = (HttpServletRequest) request;
            request.getRequestDispatcher(httpServletRequest.getRequestURI()).forward(request, response);
        }
    
        @Override
        public void destroy() {
    
        }
    }
  • 정적인 리소스는 DispatcherServlet으로 진행되지 않고 Filter를 통해 즉시 응답해주어야 한다. 따라서 간단하게 다음과 같이 필터를 구성했다.
  • 하지만 실제 해당 필터를 이용해 요청을 내리면 정적인 리소스가 DispatcherServlet을 타고 컨트롤러 매핑을 찾게 되는 문제를 발견했습니다.

원인

  • HttpServletRequest에서 가져온 RequestDispatcher는 정적 리소스용이 아니다!
  • 결국 해답을 찾지 못해 박재성님의 저장소를 확인했고 내 코드와 다른 부분을 찾을 수 있었다.

과정

  • 박재성님의 코드는 서블릿 컨텐스트에서 default라는 이름을 가지는 디스패처를 가져온다 그러면 default의 이름을 가진 디스패처의 역할을 getNamedDispatcher 메소드를 디버깅해 알아보면 다음과 같다.

findChild 메소드

위의 메소드는 name 매개변수에 전달된 default라는 이름을 가지고 children이라는 저장소에서 값을 찾는다.

children에 저장되어 있는 인스턴스들


children에 저장된 인스턴스를 살펴보면 우리가 만들었던 서블릿 뿐만 아니라 default와 jsp라는 서블릿이 보인다. 아마 jsp는 View 파일을 처리할때 사용되는 서블릿인것 같고 default의 역할은 아직도 구분이 되지 않는다.

“default”라는 이름을 가진 org…DefaultServlet


해당 default는 DefaultServlet이라는 클래스명을 가진 서블릿으로 구성되어 있다.

DefaultServlet의 역할


첫 줄 해석: 대부분의 웹 응용 프로그램에 대한 기본 리소스 서비스 서블릿으로, HTML 페이지 및 이미지와 같은 정적 리소스를 제공하는 데 사용됩니다.

DefaultServlet의 반환


즉, “default”라는 이름을 가진 DefaultServlet은 톰캣에서 정적 리소스를 처리하는 서블릿이다. 따라서 해당 서블릿을 이용해 정적 리소스를 처리하면 간단하게 처리할 수 있다.

결과

결과적으로 다음과 같이 코드를 구성하면 간단하게 정적 리소스를 필터링할 수 있다.
(urlPatterns를 이용해 경로를 지정한 부분은 새로운 정적 리소스의 추가가 없을것 같아 사용함)

@WebFilter(urlPatterns = {"/css/*", "/js/*", "/fonts/*", "/images/*", > "/favicon.ico"})
public class ResourceFilter implements Filter {

    @Override
    public void init(final FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void doFilter(final ServletRequest request, final >ServletResponse response, final FilterChain chain)
            throws IOException, ServletException {
        request.getServletContext().getNamedDispatcher("default").forward(request, response);
    }

   @Override
    public void destroy() {

   }
}

DispatcherServlet과 RequestMapping 구현

public class RequestMapping {
    private static final Logger log = LoggerFactory.getLogger(RequestMapping.class);
    private static final Map<String, Controller> handlerMapping = new HashMap<>();

    static {
        handlerMapping.put("/", new HomeController());
        handlerMapping.put("/user/create", new CreateUserController());
        handlerMapping.put("/user/list", new ListUserController());
        handlerMapping.put("/user/login", new LoginUserController());
        handlerMapping.put("/user/logout", new LogoutUserController());
        handlerMapping.put("/user/profile", new ProfileController());
        handlerMapping.put("/user/update", new UpdateUserFormController());
    }

    public Controller getHandlerMapping(final String requestURI) {
        log.debug("requestURI={}", requestURI);
        return handlerMapping.get(requestURI);
    }
}

사용자의 요청 → 필터 → DispatcherServlet에서 RequestMapping 이용해 핸들러 찾음 → handler(DB 조회까지) → DispatcherServlet은 결과값을 이용해 응답 결정
디스패처 서블릿은 모든 url에 대한 요청을 처리한다. RequestMapping은 핸들러들의 매핑을 부가적으로 관리하여 DispatcherServlet에게 매핑 핸들러를 넘겨준다.

이들을 사용하면서 가장 크게 얻을 수 있는 장점은, 요청에 대한 일괄적인 처리와 응답에 대한 중복적인 코드가 한 곳으로 응집화 된다는 부분이다.
실제로 각 컨트롤러들의 코드들의 중복을 제거할 수 있다.

  • 기존 forward, redirect
    • response.sendRedirect("/user/login");, request.getRequestDispatcher("/WEB-INF/views/user/profile.jsp").forward(request, response);
  • 변경된 forward, redirect
    • return "redirect:/user/login";, return "user/profile";

✔︎ 생각해 볼만한 것!

사용자에게 view를 직접적으로 요청할 수 있도록 요청할 수 있게 해야할까?

profile
꾸준함이 주는 변화를 믿습니다.

0개의 댓글