김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
domain : Item, ItemRepository 등의 파일이 위치, 핵심 비지니스 영역
web : Controller, ItemSaveForm 등의 파일이 위치, 웹과 관련된 기술
web은 domain을 알고 있지만( 의존하지만 ) domain은 web을 모르도록( 참조하지 않도록 ) 설계해야한다
로그인 상태를 유지하기 위해 매번 쿼리 파라미터를 보내는 것은 좋은 방법이 아님
➡️ 쿠키 사용
로그인 성공 시 서버에서 HTTP 응답에 쿠키를 담아 브라우저에 전달하면, 브라우저는 앞으로 모든 요청에 쿠키 정보를 자동으로 포함시킨다
영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료 시까지만 유지
// LoginController
public String login(HttpServletResponse response, @Validated @ModelAttribute LoginForm form, BindingResult bindingResult) {
// 로그인 성공 시
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
return "redirect:/";
}
로그인을 성공하면 쿠키를 생성한다
쿠키에 시간 정보를 주지 않았으니 세션 쿠키 ( 브라우저 종료 시 모두 종료 )
쿠키 이름은 memberId / 값은 DB에 저장된 회원의 ID ( not 로그인 ID )
생성한 쿠키를 HttpServletResponse
에 담는다
응답을 확인했을 때 서버가 보낸( 클라이언트가 받은 ) Response Header를 보면 Set-Cookie
에 memberId=1 처럼 쿠키가 들어있다
새로고침을 눌렀을 때 클라이언트가 보내는 Request Header를 보면 Cookie
에 memberId=1처럼 쿠키가 들어있다
// LoginController
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
expireCookie(response, "memberId");
return "redirect:/";
}
private void expireCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
memberId라는 이름의 쿠키를 새로 만들고 MaxAge를 0으로 설정한다
새로 만든 쿠키를 응답 메세지에 담아서 보낸다
결과적으로 로그아웃을 누르면 쿠키가 사라지게 된다
// HomeController
@GetMapping("/")
public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
// 쿠키가 없는 경우
if (memberId == null) {
return "home";
}
// 쿠키는 있지만 회원이 없는 경우
Member loginMember = memberRepository.findById(memberId);
if (loginMember == null) {
return "home";
}
// 쿠키도 있고 회원도 있는 경우
model.addAttribute("member", loginMember);
return "loginHome";
}
클라이언트가 보낸 request에 담긴 쿠키를 이용한다
@CookieValue
를 사용해서 쿠키를 조회
required = false
로 설정하여 쿠키가 없는 사용자도 접근할 수 있도록 한다쿠키 값은 임의로 변경할 수 있다
쿠키에 보관된 정보는 훔쳐갈 수 있다
쿠키의 정보는 웹 브라우저에도 보관되고, 네트워크 요청마다 서버로 전달된다
나의 로컬 PC 혹은 네트워크 전송 구간에서 다른 사람이 볼 수 있다
해커가 쿠키를 훔쳐가면 평생 사용할 수 있다
쿠키에 memberId와 같이 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출한다
서버에서 토큰과 사용자 id를 매핑해서 인식하도록 하고 서버에서 토큰을 관리한다
토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다
해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지한다
해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다
쿠키의 보안 문제를 해결하려면 중요한 정보를 모두 서버에 저장하고, 서버와 클라이언트는 추정 불가능한 임의의 식별자 값으로 연결해야한다
서버에서 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다
서버는 세션 저장소를 만들어 관리한다 ( key, value를 가진 형태 )
아이디, 비밀번호를 입력해서 로그인
서버로 입력한 정보가 넘어온다
회원 저장소에서 찾았을 때 회원도 있고 비밀번호도 일치
세션 저장소에 UUID를 통해 토큰( 랜덤 값 )을 생성해서 세션 저장소의 key( sessionId )로 사용하고 value는 회원을 저장
쿠키의 value에 sessionId를 넣어서 클라이언트에게 전달
클라이언트가 요청할 때 sessionId를 가진 쿠키가 항상 포함된다
서버는 쿠키 정보로 세션 저장소를 조회해서 로그인 시 보관한 세션 정보를 사용 ( 쿠키의 value가 세션 저장소의 key에 해당 )
세션 생성
sessionId 생성( 임의의 추정 불가능한 랜덤 값 )
세션 저장소에 sessionId와 보관할 값 저장
sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
세션 조회
세션 만료
@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "mySessionId"; // 쿠키 이름
private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
...
}
@Component
를 통해 스프링 빈으로 등록되게 한다
동시성 문제를 위해 세션 저장소를 ConcurrentHashMap
으로 구현
세션 생성, 세션 조회, 세션 만료에 해당하는 메서드를 정의
public void createSession(Object value, HttpServletResponse response) {
// sessionId 생성하고 값을 세션에 저장
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
// 쿠키 생성
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(mySessionCookie);
}
세션 저장소의 value에 저장할 Object를 파라미터로 받는다
String sessionId = UUID.randomUUID().toString();
: 절대 중복되지 않는 임의의 값 생성 ( 세션 저장소의 key로 사용 )
생성된 sessionId( 랜덤 토큰 )를 쿠키의 value에 넣고, HttpServletResponse 객체에 쿠키를 넣는다
public Object getSession(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie == null) {
return null;
}
return sessionStore.get(sessionCookie.getValue());
}
클라이언트가 보낸 request에 있는 쿠키를 사용해서 조회
findCookie()
Cookie[] cookies = request.getCookies();
: HTTP 요청에 있는 모든 쿠키를 꺼낸다
전달한 SESSION_COOKIE_NAME에 해당하는 쿠키가 있는지 찾아서 반환한다
sessionStore.get(sessionCookie.getValue())
: 쿠키의 value가 세션 저장소의 key이기 때문에 세션 저장소에서 해당하는 Object를 반환findCookie()
로 쿠키를 찾고 null이 아니면 remove()
를 통해 세션 저장소에서 제거public void expire(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie != null) {
sessionStore.remove(sessionCookie.getValue());
}
}
// LoginController
public String login(HttpServletResponse response, @Validated @ModelAttribute LoginForm form, BindingResult bindingResult) {
// 로그인 성공 시
sessionManager.createSession(loginMember, response);
return "redirect:/";
}
기존처럼 직접 쿠키를 만들지 않고 sessionManager를 통해 세션을 생성
세션을 생성하는 메서드를 호출하면 내부에서 세션 저장소에 저장하고, 쿠키도 생성
생성된 쿠키를 HttpServletResponse에 담아준다
@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
sessionManager.expire(request);
return "redirect:/";
}
// HomeController
@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {
// 세션 관리자에 저장된 회원 정보 조회
Member member = (Member) sessionManager.getSession(request);
if (member == null) {
return "home";
}
model.addAttribute("member", member);
return "loginHome";
}
사용자가 따라 다른 화면을 출력하는 부분에서 쿠키를 직접 활용하는 것이 아닌 sessionManager를 활용
sessionManager가 requeset에서 쿠키를 찾고 쿠키에서 세션 저장소의 key에 해당하는 값을 반환
sessionManager가 세션 저장소의 value에 담긴 Object를 반환
서블릿이 세션을 위해 HttpSession
이라는 기능을 제공
HttpSession
이 위에서 만든 SessionManager
와 같은 동작을 수행
서블릿을 통해 HttpSession
을 생성하면 JSESSIONID
라는 이름의 쿠키를 생성, value는 역시 추정 불가능한 랜덤값
// LoginController
public String login(HttpServletResponse response, @Validated @ModelAttribute LoginForm form, BindingResult bindingResult) {
// 로그인 성공 시
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
request.getSession()
세션이 있으면 기존 세션을 반환
세션이 없는 경우 () 안에 true(default)를 작성하면 새로운 세션을 생성해서 반환
fasle이면 null을 반환
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember)
: 세션에 데이터를 보관getSession()
은 request에서 얻어온 쿠키의 value에 저장된 랜덤세션id값으로 세선 저장소에서 랜덤세션id이 일치하는 세션을 찾는 것이고,
getSession()
을 통해 찾아진 세션 내부에 setAttribute()
로 key값을 SessionConst.LOGIN_MEMBER로 하고 value에 member에 대한 정보를 저장한다
즉, 실제로 세션들을 보관하는 세션 저장소가 하나 있다 ( 랜덤세션id를 key로, Map을 value로 )
이 랜덤세션id를 통해 특정 사용자만이 사용하는 Map 객체를 가지고 온다 ( 특정 세선을 가지고 온다 )
Map 객체가 SessionConst.LOGIN_MEMBER를 key로, loginMember를 value로 가지고 있다
랜덤세션id는 tomcat이 생성한다
// LoginController
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return "redirect:/";
}
// HomeController
@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {
HttpSession session = request.getSession(false);
if (session == null) {
return "home";
}
Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);
// 세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
// 세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
session.getAttribute(SessionConst.LOGIN_MEMBER)
: 찾아진 세션 내부에서 SessionConst.LOGIN_MEMBER를 key값으로 value를 찾는다// HomeController
@GetMapping("/")
public String homeLoginV3Spring(Model model,
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember) {
// 세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
// 세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
세션 저장소에서 세션을 찾고, 찾아진 세션에서 member를 가져오는 과정을 @SessionAttribute
로 간단하게 처리 가능
@SessionAttribute
는 세션을 생성하지 않는다
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
로그인을 처음 시도하면 위처럼 url에 jsessionid가 포함되어 있다
이렇게 url에 포함시키는 방법은 웹 브라우저가 쿠키를 지원하지 않을 때 사용하고 매 요청마다 붙여주어야 한다
타임리프 같은 템플릿 엔진을 통해 링크를 걸면 자동으로 url에 jsessionid를 포함시켜준다
웹 브라우저가 쿠키를 지원해도 최초 1회는 url에 jsessionid를 포함시킨다
처음에는 웹 브라우저가 쿠키를 지원하는지 지원하지 않는지 판단하지 못하기 때문에 쿠키도 전달하고 url에 포함시키는 것도 한다
최초에도 수행되지 않게 하려면 application.properties에 아래 코드를 작성한다
server.servlet.session.tracking-modes=cookie
sessionId
: 세션Id, jsessionid 의 값maxInactiveInterval
세션의 유효 시간 ( 초 )
비활성화 시키는 최대 시간 간격
creationTime
: 세션 생성일시lastAccessedTime
세션과 연결된 사용자가 최근에 서버에 접근한 시간
클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청한 경우에 갱신
isNew
: 새로 생성된 세션인지 ( true ), 이미 과거에 만들어진 세션인지 여부 ( false )사용자가 로그아웃 버튼을 눌러야 session.invalidate()
가 호출되어 세션이 삭제되는데 대부분의 사용자는 그냥 웹 브라우저를 종료
이런 경우, HTTP는 비연결성이므로 서버는 사용자가 웹 브라우저를 종료한 것인지 알 수 없어서 언제 삭제해야하는지 판단하기 어렵다
세션은 메모리를 사용하기 때문에 적절하게 지워주어야 한다
세션을 마지막 요청한 시간 기준으로 30분 정도를 유지해준다 ➜ 사용자가 계속 사용한다면 세션의 생존 시간이 계속 30분으로 늘어나게 된다
HttpSession이 사용하는 방식
세션의 타임아웃 시간은 해당 세션과 관련된 jsessionid 를 전달하는 HTTP 요청이 있으면 현재 시간으로 다시 초기화
이렇게 초기화 되면 세션 타임아웃으로 설정한 시간동안 세션을 추가로 사용할 수 있다
session.getLastAccessedTime()
: 최근 세션 접근 시간
LastAccessedTime
이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거
글로벌 타임아웃 설정
application.properties에 아래처럼 설정
server.servlet.session.timeout=60
: 60초 ( 1분 )
글로벌 타임아웃은 분 단위로 설정해야함 ( 60의 배수로 설정 )
특정 세션의 타임아웃 설정
session.setMaxInactiveInterval(1800);
: 1800초