1. 무상태에서 과거를 기억하는 법

  • HTTP는 무상태이므로 과거 요청 기록을 알 수 없음

 

  • 세션 트랙킹: 과거 요청 기록을 추적하는 기법
  • HTTP는 세션 트랙킹에 쿠키를 이용

 

  • 쿠키: 문자열로 만들어진 데이터 조각
  • 쿠키는 서버와 브라우저 사이에서 요청이나 응답시에 주고받는 형태로 사용됨
  • 쿠키의 가장 기본적인 형태는 이름과 값으로 구성된 구조
  • 브라우저에서  개발자 도구의 애플리케이션 메뉴에서 확인 가능
  • 쿠키를 주고받는 기본적인 시나리오
    • 브라우저에서 최초로 서버 호출 시 해당 서버에서 발행한 쿠키가 없다면 브라우저는 아무것도 전송 x
    • 서버에서 응답 메세지 보낼 때, 브라우저에 쿠키를 보내는데 이때 'Set Cookie'라는 HTTP 헤더 이용
    • 브라우저는 쿠키를 받은 후 이에 대한 정보를 읽고, 파일 형태로 보관할 지 메모리상에서만 처리할 지 결정(쿠키에 있는 유효기간(만료기간)을 보고 결정)
    • 브라우저에 보관하는 쿠키는 다음에 다시 브라우저가 서버에 요청할 때 HTTP 헤더에 'Cookie'라는 헤더 이름과 함께 전달(쿠키에는 경로를 지정할 수 있어서 해당 경로에 맞는 쿠키 전송)
    • 서버에서는 필요에 따라서 브라우저가 보낸 쿠키를 읽고 사용

 

 1) 쿠키를 생성하는 방법

  • 서버에서 자동으로 생성하는 쿠키: 응답 메세지를 작성할 때 정해진 쿠키가 없는 경우 자동으로 발행(WAS에서 발행되며 WAS마다 고유한 이름 사용(톰캣은 JSESSIONID))
    • 서버에서 발행하는 쿠키는 기본적으로 브라우저 메모리 상에 보관, 브라우저 종료 시 쿠키는 삭제됨
    • 서버에서 발행하는 쿠키의 경로는 '/'
  • 개발자가 생성하는 쿠키: 개발자가 생성하는 쿠키는 서버에서 생성되는 쿠키와 다음이 다름
    • 이름 원하는대로 지정 가능
    • 유효기간 지정가능(유효기간 지정 시, 브라우저가 이를 파일 형태로 보관)
    • 반드시 직접 응답에 추가해 주어야 함
    • 경로나 도메인 등을 지정 가능(특정 서버의 경로를 호출하는 경우에만 쿠키 사용)

 

 

2. Servlet 컨텍스트와 세션 저장소

  • 하나의 톰캣은 여러 웹 애플리케이션을 실행할 수 있음
    • 실제 운영 시 웹 애플리케이션 마다 별도의 도메인으로 분리해서 운영됨
    • 프로젝트 실행 경로를 '/'외에 다른 이름으로 각각 지정해서 실행하면 하나의 톰캣에서 여러 애플리케이션 실행 가능
    • 각 애플리케이션은 자신만 사용하는 고유 메모리 영역을 생성하여 이 공간에 Servlet이나 JSP 등을 인스턴스로 만들어 서비스 제공, 이 영역을 Servlet API에서는 Servlet 컨텍스트라고 함
  • 애플리케이션 생성 시 톰캣이 발행하는 쿠키를 관리하기 위한 메모리 영역이 하나 더 생성됨(세션 저장소)
    • 세션 저장소는 기본적으로 키와 값을 보관하는 구조
    • 톰캣에서 JSESSIONID가 키가 됨
    • 새로운 JSESSIONID 쿠키가 만들어질 때마다 메모리 공간을 차지하므로 톰캣은 주기적으로 세션 저장소를 조사하며 사용하지 않는 값 정리(session-timeout 설정 이용, 기본 30분 주기마다 지정된 시간보다 오래된 값 삭제)

 

 1) 세션을 통한 상태 유지 메커니즘

  • HttpServletRequest의 getSession()이라는 메서드 실행
    → 톰캣에서는 JSESSIONID 이름의 쿠키가 요철할 때 있었는지 확인
    → 없다면 새로운 값을 만들어 세션 저장소에 보관

    • A1234와 B111은 해당 공간에 login 정보가 존재하며, 서버에서 프로그램 작성 시 이를 이용해서 해당 사용자가 로그인했다는 것을 인정하는 방식

 

 2) HttpServletRequest의 getSession()

  • HttpServletRequest의 getSession()은 브라우저가 보내는 정보를 이용해 다음의 작업 수행
    • JSESSIONID가 없는 경우: 세션 저장소에 새로운 번호로 공간을 만들고 해당 공간에 접근할 수 있는 객체 반환(새로운 번호는 브라우저에 JSESSIONID의 값으로 전송(세션 쿠키))
    • JSESSIONID가 있는 경우: 세션 저장소에 JSESSIONID 값을 이용해서 할당된 공간을 찾고 이 공간에 접근할 수 있는 객체 반환
  • getSession()의 결과물은 세션 저장소 내의 공간, 이 공간을 의미하는 타입은 HttpSession타입이며 해당 공간은 세션 컨텍스트 또는 세션이라고 함

  • HttpSession 타입의 객체를 이용하면 현재 사용자만의 공간에 원하는 객체를 저장 / 수정 / 삭제할 수 있음
  • isNew() 메서드 등으로 새롭게 공간을 만들 것인지 기존 공간 재사용할 것인지 구분 가능

 

 

3. 세션을 이용한 로그인 체크

  • 세션을 이용한 로그인 체크 시나리오
    • 사용자가 로그인에 성공하면 HttpSession을 이용해 해당 사용자의 공간(세션 컨텍스트)에 특정 객체를 이름과 함께 저장
    • 로그인 체크가 필요한 컨트롤러에서 현재 사용자의 공간에 지정된 이름으로 객체가 저장되었는지 확인
      → 객체가 존재한다면 해당 사용자는 로그인된 사용자로 간주
      → 아니면 로그인 페이지로 이동

 


실습

 1) 등록할 때, 로그인 체크 하기

  - 로그인한 사용자만 Todo를 등록할 수 있다고 가정

  - TodoRegisterController에서 doGet() 수정

// TodoRegisterController

...

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
log.info("/todo/register GET .......");

HttpSession session = req.getSession();

// 기존에 JSESSIONID가 없는 새로운 사용자
if (session.isNew()) {
  log.info("User with new JSESSIONID cookie");
  resp.sendRedirect("/login");
  return;
}

// JSESSIONID는 있지만 해당 세션 켄텍스트에 loginInfo라는 이름으로 저장된 객체가 없는 경우
if (session.getAttribute("loginInfo") == null) {
  log.info("User without login information");
  resp.sendRedirect("/login");
  return;
}

// 정상적인 경우
req.getRequestDispatcher("/WEB-INF/todo/register.jsp").forward(req, resp);
}

...

  - 브라우저는 '/login'으로 redirect 됨

  - HttpServletRequest의 getSession()을 호출하여 새로운 값이 생성되어 브라우저로 전송되어 저장

(JSESSIONID가 없는 채로 'todo/register' 경로에 접속했을 때, '/login'으로 redirect되고 '/login'에서 개발자 도구를 통해 register의 응답 헤더를 보면 'Set-Cookie' 헤더가 전송된 것을 확인할 수 있음 ↓)

  - 로그에는 '기존에 JSESSIONID가 없는 사용자'에 대한 처리를 했을 때 나오도록 설정한 메세지가 출력됨

 

 2) 로그인 처리 Controller 작성

  - 로그인은 '/login' 경로에서 GET방식으로 로그인 화면 보여주고, POST 방식으로 실제 로그인 처리하도록 구성

  - controller 패키지 내에 LoginController 클래스와 WEB-INF 폴더 내에 로그인 화면을 나타낼 login.jsp 파일 생성

// LoginController
package org.zerock.w2.controller;

import lombok.extern.java.Log;
import lombok.extern.log4j.Log4j2;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet("/login")
@Log
public class LoginController extends HttpServlet  {

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("login get.............");
    req.getRequestDispatcher("/WEB-INF/login.jsp").forward(req, resp);
  }
}
<!-- login.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>

<form action = "/login" method = "post">
  <input type = "text" name = "mid">
  <input type = "text" name = "mpw">
  <button type = "submit">LOGIN</button>
</form>

</body>
</html>

 

  - 로그인 처리와 HttpSession의 setAttribute()

  - LoginController에서 POST 방식으로 파라미터 수집, HttpSession에 'loginInfo' 이름을 이용해서 간단한 문자열을 저장하도록 구성

// LoginController
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
log.info("login post.............");

String mid = req.getParameter("mid");
String mpw = req.getParameter("mpw");

String str = mid + mpw;
HttpSession session = req.getSession();
// HttpSession을 이용하여 setAttribute() 메서드를 사용자 공간에 loginInfo 라는 이름으로 문자열을 보관
session.setAttribute("loginInfo", str);
resp.sendRedirect("/todo/list");
}

 

  - 결과

login 화면에서 아무 id와 pw를 입력하고 login 버튼을 누르면 정상적으로 tood/list로 redirect 됨
로그인 한 사용자만 들어갈 수 있는 todo/register 경로에서 정상적으로 접근 가능


 

4. 필터를 이용한 로그인 체크

  • 로그인 여부를 체크해야 하는 Controller마다 동일하게 체크하는 로직을 작성하면 같은 코드를 계속 작성해야 하므로 대부분 필터를 이용해 처리
  • 필터: 특정 Servlet이나 JSP 등에 도달하는 과정에서 필터링하는 역할을 위해 존재하는 Servlet API
  • @WebFilter 어노테이션을 이용해 특정 경로에 접근 시 필터가 동작하도록 설계하면 동일 로직을 필터로 분리
  • 필터는 한 개 이상, 여러 개 적용 가능
  • 프로젝트에 filter 패키지 구성하고 LoginCheckFilter 클래스 추가
// LoginCheckFilter
package org.zerock.w2.filter;

import lombok.extern.log4j.Log4j2;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import java.io.IOException;

// '/todo..'로 시작하는 모든 경로에 필터링 시도
@WebFilter(urlPatterns = {"/todo/*"})
@Log4j2
public class LoginCheckFilter implements Filter {

  // doFilter()는 필터가 필터링이 필요한 로직을 구현하는 부분 
  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    log.info("Login check filter....");
    
    // 다음 필터나 목적지(Servlet, JSP)로 갈 수 있도록 FilterChain의 doFilter()를 실행
    // 문제가 생겨서 더 이상 진행할 수 없다면 다음 단계로 진행하지 않고 다른 방식으로 redirect 처리 
    chain.doFilter(request, response);
  }
}

 


실습

 3) 로그인 체크 구현

  - LoginCheckFilter에서 '/todo/...'로 시작하는 모든 경로에 접근 시 동작하도록 설정(위의 코드 구체화)

// LoginCheckFilter
package org.zerock.w2.filter;

import lombok.extern.log4j.Log4j2;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

// '/todo..'로 시작하는 모든 경로에 필터링 시도
@WebFilter(urlPatterns = {"/todo/*"})
@Log4j2
public class LoginCheckFilter implements Filter {

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

    log.info("Login check filter....");

    // doFilter에서 HttpServletRequest와 HttpServletResponse보다 상위 타입의 파라미터 사용
    // 따라서 HTTP 관련 작업을 위해 (HttpServlerRequest)request처럼 다운캐스팅 필요
    HttpServletRequest req = (HttpServletRequest)request;
    HttpServletResponse resp = (HttpServletResponse)response;

    HttpSession session = req.getSession();

    // LoginController에서 로그인했을 때 세션에 loginInfo라는 이름으로 저장하도록 하였음
    // 이 loginInfo가 없다면 로그인하지 않은 것으로 판단되므로 LoginCheckFilter에 의해 '/login' 경로로 이동
    if (session.getAttribute("loginInfo") == null) {
      resp.sendRedirect("/login");
      return;
    }
    
    // 어떤 값이든 '/login'에서 로긍니 정보를 전달해서 로그인이 처리되면 그 이후에는 '/todo/...' 경로 이용가능 
    chain.doFilter(request, response);
  }
}

 

 4) UTF-8 처리 필터

  - POST 방식으로 '/todo/register'를 통해 전달되는 문자열은 한글이 깨지므로 HttpServletRequest의 데이터를 SetCharacterEncoding("UTF-8")을 적용해 해결

  - filter 패키지에 UTF8Filter 추가

// UTF8Filter
package org.zerock.w2.filter;

import lombok.extern.log4j.Log4j2;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@WebFilter(urlPatterns = {"/*"})
@Log4j2
public class UTF8Filter implements Filter {

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    log.info("UTF8 filter....");
    HttpServletRequest req = (HttpServletRequest)request;
    req.setCharacterEncoding("UTF-8");

    chain.doFilter(request, response);
  }
}

 

5. 세션을 이용하는 로그아웃 처리

  • HttpSession을 이용하는 경우 로그아웃 처리는 로그인 확인 시 사용했던 정보를 삭제시키는 방식 또는 현재 HttpSession이 더이상 유효하지 않다고 invalidate() 시키는 방식 이용
  • 프로젝트에 LogoutController 추가, '/logout' 경로를 처리하도록 구성
// LogoutController
package org.zerock.w2.controller;

import lombok.extern.log4j.Log4j2;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

@WebServlet("/logout")
@Log4j2
public class LogoutController extends HttpServlet {

  // 로그아웃 처리는 POST 방식으로만 처리되도록 함
  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("log out...................");
    HttpSession session = req.getSession();

    // loginInfo라는 이름을 삭제
    session.removeAttribute("loginIngo");
    // 현재 HttpSession이 더 이상 유효하지 않은 것으로 만들기
    session.invalidate();

    // 로그아웃 이후에는 "/"경로로 redirect
    resp.sendRedirect("/");
  }
}
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib uri = "http://java.sun.com/jsp/jstl/core" prefix ="c" %>
<html>
<head>
    <title>Todo List</title>
</head>
<body>
<h1>Todo List</h1>
<ul>
    <c:forEach items = "${dtoList}" var = "dto">
        <li>
            <span><a href = "/todo/read?tno=${dto.tno}">${dto.tno}</a></span>
            <span>${dto.title}</span>
            <span>${dto.dueDate}</span>
            <span>${dto.finished? "DONE": "NOT YET"}</span>
        </li>
    </c:forEach>
</ul>
<!-- 목록화면에서 logout을 post 방식으로 처리할 버튼 추가 -->
<form action = "/logout" method = "post">
    <button>LOGOUT</button>
</form>
</body>
</html>

목록화면에 LOGOUT 버튼이 생기고, 버튼 클릭 시 loginInfo 키가 삭제되고, 현재 HttpSession에서 유효하지 않은 것이 되며, 화면은 초기 화면으로 redirect 됨

 

 

6. 데이터베이스에서 회원 정보 이용하기

  • 데이터베이스 연결 후 회원 정보를 저장한 tbl_member 테이블 생성
  • 회원정보도 임의로 생성
create table tbl_member(
    mid varchar(50) primary key,
    mpw varchar(50) not null,
    mname varchar(100) not null
);

insert into tbl_member (mid, mpw, mname) values ('user00', '1111', '사용자0');
insert into tbl_member (mid, mpw, mname) values ('user01', '1111', '사용자1');
insert into tbl_member (mid, mpw, mname) values ('user02', '1111', '사용자2');

 


실습

 5) 자바에서 회원 데이터 처리하기

  - 자바에서 회원 정보를 객체로 처리하도록 VO / DAO 등을 구현

  - MemberVO와 MemberDAO 구현

// MemberVO
package org.zerock.w2.domain;

import lombok.*;

@Getter
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberVO {

  private String mid;
  private String mpw;
  private String mname;
}
// MemberDAO
package org.zerock.w2.dao;

import lombok.Cleanup;
import org.zerock.w2.domain.MemberVO;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public class MemberDAO {

  public MemberVO getWithPassword (String mid, String mpw) throws Exception {

    String query = "select mid, mpw, mname from tbl_member where mid = ? and mpw = ?";
    MemberVO memberVO = null;

    @Cleanup Connection connection = ConnectionUtil.INSTANCE.getConnection();
    @Cleanup PreparedStatement preparedStatement = connection.prepareStatement(query);

    preparedStatement.setString(1, mid);
    preparedStatement.setString(2, mpw);

    @Cleanup ResultSet resultSet = preparedStatement.executeQuery();

    resultSet.next();

    memberVO = MemberVO.builder()
        .mid(resultSet.getString(1))
        .mpw(resultSet.getString(2))
        .mname(resultSet.getString(3))
        .build();

    return memberVO;
  }
}

 

  - 서비스 계층과 Controller에서 사용할 MemberDTO와 MemberService 구현

// MemberDTO
package org.zerock.w2.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberDTO {

  private String mid;
  private String mpw;
  private String mname;
}

  - MemberService는 여러 곳에서 동일한 객체를 사용할 수 있도록 enum으로 하나의 객체만 구성하고 MemberDAO를 이용하도록 구성

// MemberService
package org.zerock.w2.service;

import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.zerock.w2.dao.MemberDAO;
import org.zerock.w2.domain.MemberVO;
import org.zerock.w2.dto.MemberDTO;
import org.zerock.w2.util.MapperUtil;

@Log4j2
public enum MemberService {
  INSTANCE;

  private MemberDAO dao;
  private ModelMapper modelMapper;

  MemberService() {
    dao = new MemberDAO();
    modelMapper = MapperUtil.INSTANCE.get();
  }
  
  // 로그인 처리를 위한 login() 메서드 작성
  public MemberDTO login(String mid, String mpw) throws Exception {
    MemberVO vo = dao.getWithPassword(mid, mpw);
    MemberDTO memberDTO = modelMapper.map(vo, MemberDTO.class);
    return memberDTO;
  }
}

 

 6) Controller에서 로그인 연동

  - LoginController의 doPost()에 MemberService를 연동해서 실제 로그인 되도록 수정

// loginController

...

  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    log.info("login post.............");

    String mid = req.getParameter("mid");
    String mpw = req.getParameter("mpw");

    // 정상적으로 로그인 된 경우, HttpSession을 이용해서 'loginInfo'라는 이름으로 객체 저장
    try {
      MemberDTO memberDTO = MemberService.INSTANCE.login(mid, mpw);
      HttpSession session = req.getSession();
      session.setAttribute("loginInfo", memberDTO);
      resp.sendRedirect("/todo/list");
    } catch (Exception e) {   // 예외 발생 시 '/login'으로 이동, result라는 파라미터를 전달해서 문제가 발생했다는 사실 전달
      resp.sendRedirect("/login?result=error");
    }

...

 

  - EL에서 쿼리 스트링 처리

  - '/WEB-INF/login.jsp'에는 EL에서 기본으로 제공하는 param이라는 객체를 이용해서 result라는 이름으로 전달한 값 확인 가능

<!-- login.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!-- JSTL을 이용 -->
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix = "c" %>
<html>
<head>
    <title>Title</title>
</head>
<body>

<!-- ${param.result}를 이용해서 에러 발생 시 다른 메세지 출력 -->
<c:if test = "${param.result == 'error'}">
    <h1>로그인 에러</h1>
</c:if>

<form action = "/login" method = "post">
  <input type = "text" name = "mid">
  <input type = "text" name = "mpw">
  <button type = "submit">LOGIN</button>
</form>

</body>
</html>

  - tbl_member 테이블에 없는 id와 pw 입력시

 

  - EL의 Scope와 HttpSession 접근하기

  - EL을 이용해서 HttpServletRequest에 setAttribute()로 저장한 객체를 사용할 수 있음

  - EL이 HttpServletRequest에 저장된 객체를 찾을 수 없다면, 자동으로 HttpSession에 저장된 객체를 찾아냄(EL의 Scope)

  - EL의 Scope를 이용해서 접근하는 변수의 종류

  • Page Scope: JSP에서 EL을 이용해 <c:set>으로 저장한 변수
  • Request Scope: HttpServletRequest에 setAttribute()로 저장한 변수
  • Session Scope: HttpSession을 이용해서 setAttribute()로 저장한 변수
  • Application Scope: ServletContext를 이용해서 setAttribute()로 저장한 변수

  ex) EL로 ${obj}라고 하면 앞의 Scope들이 순차적으로 page → request → session → application 순으로 'obj' 라는 이름의 객체 찾는 방식

 

  - 위의 예제에서 'loginInfo'라는 이름으로 MemberDTO를 저장했다면 JSP에는 기존 방식대로 ${loginInfo}라는 이름으로 접근 가능

 

  - '/WEB-INF/todo/list.jsp'는 로그인 한 사용자만 접근 가능한 경로이므로 다음과 같은 코드로 현재 로그인한 사용자의 이름 출력가능

<h2>${loginInfo}</h2>
<h3>${loginInfo.mname}</h3>

+ Recent posts