1. HTTP 특징
HTTP 프로토콜의 경우 서버가 클라이언트의 요청(request)을 받으면 클라이언트로 응답(response)를 보낸 후 연결을 끊어버립니다.
이러한 비연결지향적 특성으로 인하여 서버는 클라이언트의 이전 요청을 알지 못하며, 클라이언트의 상태정보를 유지하기 위해서는 요청을 보낼때마다 인증과정을 거쳐야합니다.
쿠키와 세션은 이러한 단점을 보완하여 클라이언트의 상태를 유지하기위해 사용됩니다.
2. 쿠키(Cookie)
쿠키는 클라이언트의 로컬에 저장되는 key와 value로 이루어진 작은 데이터 파일입니다. 서버가 Set-Cookie 응답 헤더를 통해 클라이언트의 쿠키 저장소에 쿠키를 저장하고, 클라이언트는 해당 서버에 요청을 보낼때 쿠키 저장소에서 쿠키를 조회하여 Cookie 요청 헤더를 통해 서버에 쿠키 데이터를 전달합니다.
1) 쿠키의 종류
쿠키에는 영속 쿠키와 세션 쿠키가 있습니다.
영속 쿠키의 경우 만료 날짜를 입력하여 해당 날짜까지 쿠키를 유지합니다. 세션 쿠키의 경우 만료 날짜를 생략하여 브라우저 종료시까지만 쿠키를 유지합니다.
2) 쿠키의 생성과 조회
로그인시 서버에서 닉네임 쿠키를 생성해보도록 하겠습니다.
우선 로그인 기능을 위해 memberMapper에 login 쿼리를 추가합니다. 이메일과 패스워드가 동일한 튜플을 가져옵니다.
memberMapper.xml
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="kro.rubisco.dao.MemberDAO">
<resultMap id="getMember" type="MemberDTO"> <association property="group" column="group_id" select="getGroup" /> </resultMap>
<insert id="create"> insert into member (password, email, name, nick_name, group_id) values (#{password}, #{email}, #{name}, #{nickName}, #{groupId}) </insert>
<select id="read" resultMap="getMember"> select * from member where member_id = #{memberId} </select>
<select id="getMemberByEmail" resultMap="getMember"> select * from member where email = #{email} </select>
<select id="getGroup" resultType="GroupDTO"> select * from member_group where group_id=#{groupId} </select>
<update id="update"> update member set password=#{password}, email=#{email}, name=#{name}, nick_name= #{nickName}, where member_id = #{memberId} </update>
<delete id="delete"> delete from member where member_id = #{memberId} </delete>
<select id="listAll" resultMap="getMember"> <![CDATA[ select * from member where m.member_id > 0 order by m.member_id desc ]]> </select>
<select id="login" resultMap="getMember"> select * from member where email = #{email} and password = #{password} </select>
</mapper> |
memberDAO에 login 메소드를 추가합니다.
/kro/rubisco/dao/MemberDAO.java
package kro.rubisco.dao;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import kro.rubisco.dto.LoginDTO; import kro.rubisco.dto.MemberDTO;
@Mapper public interface MemberDAO {
public void create(MemberDTO member) throws Exception; public MemberDTO read(Long memberId) throws Exception; public MemberDTO getMemberByEmail(String email) throws Exception;
public void update(MemberDTO member) throws Exception;
public void delete(Long memberId) throws Exception;
public List<MemberDTO> listAll() throws Exception; public MemberDTO login(LoginDTO loginForm) throws Exception; } |
서비스에 login 메소드를 추가하고 구현합니다.
/kro/rubisco/service/MemberService.java
package kro.rubisco.service;
import java.util.List;
import kro.rubisco.dto.LoginDTO; import kro.rubisco.dto.MemberDTO;
public interface MemberService { public void regist(MemberDTO member) throws Exception;
public MemberDTO read(Long memberId) throws Exception; public MemberDTO read(String email) throws Exception;
public void modify(MemberDTO member) throws Exception;
public void remove(Long memberId) throws Exception;
public List<MemberDTO> listAll() throws Exception; public MemberDTO login(LoginDTO loginForm) throws Exception; } |
/kro/rubisco/service/impl/MemberServiceImpl.java
package kro.rubisco.service.impl;
import java.util.List;
import org.apache.ibatis.session.SqlSession; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional;
import kro.rubisco.dao.MemberDAO; import kro.rubisco.dto.LoginDTO; import kro.rubisco.dto.MemberDTO; import kro.rubisco.service.MemberService;
@Service @Transactional(readOnly = true) public class MemberServiceImpl implements MemberService {
private final MemberDAO memberDAO; @Autowired public MemberServiceImpl(SqlSession sqlSession) { this.memberDAO = sqlSession.getMapper(MemberDAO.class); } @Override public void regist(MemberDTO member) throws Exception { memberDAO.create(member); }
@Override public MemberDTO read(Long memberId) throws Exception { return memberDAO.read(memberId); } @Override public MemberDTO read(String email) throws Exception { return memberDAO.getMemberByEmail(email); }
@Override public void modify(MemberDTO member) throws Exception { memberDAO.update(member); }
@Override public void remove(Long memberId) throws Exception { memberDAO.delete(memberId); }
@Override public List<MemberDTO> listAll() throws Exception { return memberDAO.listAll(); }
@Override public MemberDTO login(LoginDTO loginForm) throws Exception { return memberDAO.login(loginForm); } } |
로그인 정보를 전달하기위한 LoginDTO를 작성합니다.
/kro/rubisco/dto/LoginDTO.java
package kro.rubisco.dto;
import javax.validation.constraints.NotBlank;
import lombok.Getter; import lombok.Setter;
@Getter @Setter public class LoginDTO {
@NotBlank(message = "이메일을 입력하세요.") private String email; @NotBlank(message = "패스워드를 입력하세요.") private String password; } |
로그인 요청을 매핑하기위한 LoginController를 작성합니다.
/kro/rubisco/controller/LoginController.java
package kro.rubisco.controller;
import java.util.Locale;
import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletResponse; import javax.validation.Valid;
import org.springframework.context.MessageSource; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping;
import kro.rubisco.config.BindExceptionWithViewName; import kro.rubisco.dto.LoginDTO; import kro.rubisco.dto.MemberDTO; import kro.rubisco.service.MemberService; import lombok.RequiredArgsConstructor;
@Controller @RequiredArgsConstructor @RequestMapping("/login") public class LoginController { private final MessageSource messageSource; private final MemberService memberService; @GetMapping() public void getLoginView() {}
@PostMapping() public String login( @Valid @ModelAttribute LoginDTO loginForm, BindingResult bindingResult, Locale locale, HttpServletResponse response ) throws Exception { MemberDTO member = memberService.login(loginForm); if(member == null) { bindingResult.reject("loginFail", "아이디 또는 비밀번호가 일치하지 않습니다."); } if(bindingResult.hasErrors()) { throw new BindExceptionWithViewName(bindingResult, "login", messageSource, locale); } if(member != null) { Cookie nickNameCookie = new Cookie("nickName", member.getNickName()); response.addCookie(nickNameCookie); } return "redirect:/"; } } |
검증에 대한 설명은 지난번 글을 참고하고, 검증을 통과하면 Cookie 객체를 생성하여 HttpServletResponse 객체의 addCookie 메소드를 통해 응답 헤더에 Set-Cookie를 추가해줍니다.
이제 로그인폼 템플릿을 추가해주세요. tailwindcss를 통해 간단하게 폼을 작성했습니다.
/src/main/webapp/WEB-INF/views/login.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script> <title>게시글 목록</title> </head> <body> <main> <div class="flex min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="w-full max-w-md space-y-8"> <div> <img class="mx-auto h-12 w-auto" src="https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=600" alt="Your Company"> <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">로그인</h2> <p class="mt-2 text-center text-sm text-gray-600 text-rose-500"> ${errors.globalMessage} </p> </div> <form class="mt-8 space-y-6" action="#" method="POST"> <input type="hidden" name="remember" value="true"> <div class="-space-y-px rounded-md shadow-sm"> <div> <label for="email" class="sr-only">Email address</label> <input id="email" name="email" type="email" autocomplete="email" required class="relative block w-full appearance-none rounded-none rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" placeholder="Email"> </div> <div> <label for="password" class="sr-only">Password</label> <input id="password" name="password" type="password" autocomplete="current-password" required class="relative block w-full appearance-none rounded-none rounded-b-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" placeholder="Password"> </div> </div> <div class="flex items-center justify-between"> <div class="flex items-center"> <input id="remember-me" name="remember-me" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"> <label for="remember-me" class="ml-2 block text-sm text-gray-900">Remember me</label> </div> <div class="text-sm"> <a href="#" class="font-medium text-indigo-600 hover:text-indigo-500">Forgot your password?</a> </div> </div> <div> <button type="submit" class="group relative flex w-full justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"> <span class="absolute inset-y-0 left-0 flex items-center pl-3"> <!-- Heroicon name: mini/lock-closed --> <svg class="h-5 w-5 text-indigo-500 group-hover:text-indigo-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" /> </svg> </span> 로그인 </button> </div> </form> </div> </div> </main> </body> </html> |
이제 쿠키 정보를 조회하기위해 HomeController를 추가해주세요. HttpServletRequest 객체의 getCookies 메소드를 통해 쿠키를 조회할 수도 있지만, @CookieValue 어노테이션을 통해 간단하게 쿠키를 조회할 수 있습니다.
/kro/rubisco/controller/HomeController.java
package kro.rubisco.controller;
import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.RequestMapping;
@Controller public class HomeController {
@RequestMapping() public String home(@CookieValue(name = "nickName", required = false) String nickName, Model model) { model.addAttribute("nickName", nickName); return "home"; } } |
쿠키를 조회할 템플릿을 작성합니다.
/src/main/webapp/WEB-INF/views/home.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<html> <head> <title>Home</title> </head> <body> <h1> Hello world! </h1>
<P><c:if test="${nickName ne null}">${nickName}님, 환영합니다.</c:if></P> </body> </html> |
localhost:8080/login 으로 이동하면 로그인폼이 출력되고, 정상적으로 로그인되면 메인페이지로 리다이렉트되어 닉네임이 출력되는 것을 확인할 수 있습니다.
이 경우 만료 날짜를 입력하지 않았으므로 세션 쿠키가 되므로, 브라우저를 닫았다가 다시 열게 되면 쿠기가 존재하지 않습니다. 영속 쿠키로 만들기 위해서는 쿠키에 setMaxAge 메소드를 통해 만료 날짜를 입력해줍니다. 매개변수는 초단위를 기준으로 입력합니다. 예를 들어 아래 코드의 경우 일주일간 쿠키를 유지합니다.
/kro/rubisco/controller/LoginController.java
package kro.rubisco.controller;
import java.util.Locale;
import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletResponse; import javax.validation.Valid;
import org.springframework.context.MessageSource; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping;
import kro.rubisco.config.BindExceptionWithViewName; import kro.rubisco.dto.LoginDTO; import kro.rubisco.dto.MemberDTO; import kro.rubisco.service.MemberService; import lombok.RequiredArgsConstructor;
@Controller @RequiredArgsConstructor @RequestMapping("/login") public class LoginController { private final MessageSource messageSource; private final MemberService memberService; @GetMapping() public void getLoginView() {}
@PostMapping() public String login( @Valid @ModelAttribute LoginDTO loginForm, BindingResult bindingResult, Locale locale, HttpServletResponse response ) throws Exception { MemberDTO member = memberService.login(loginForm); if(member == null) { bindingResult.reject("loginFail", "아이디 또는 비밀번호가 일치하지 않습니다."); } if(bindingResult.hasErrors()) { throw new BindExceptionWithViewName(bindingResult, "login", messageSource, locale); } if(member != null) { Cookie nickNameCookie = new Cookie("nickName", member.getNickName()); nickNameCookie.setMaxAge(60*60*24*7); response.addCookie(nickNameCookie); } return "redirect:/"; } } |
브라우저를 껏다가 다시 켜도 쿠키가 유지됨을 확인할 수 있습니다. 쿠키를 삭제하려면 만료 날짜를 0으로 설정하여 응답 헤더에 추가하게 되면 쿠키를 바로 만료시켜 삭제할 수 있게 됩니다.
우선 LoginController에 logout 메소드를 추가합니다.
/kro/rubisco/controller/LoginController.java
package kro.rubisco.controller;
import java.util.Locale;
import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletResponse; import javax.validation.Valid;
import org.springframework.context.MessageSource; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping;
import kro.rubisco.config.BindExceptionWithViewName; import kro.rubisco.dto.LoginDTO; import kro.rubisco.dto.MemberDTO; import kro.rubisco.service.MemberService; import lombok.RequiredArgsConstructor;
@Controller @RequiredArgsConstructor public class LoginController { private final MessageSource messageSource; private final MemberService memberService; @GetMapping("/login") public void getLoginView() {}
@PostMapping("/login") public String login( @Valid @ModelAttribute LoginDTO loginForm, BindingResult bindingResult, Locale locale, HttpServletResponse response ) throws Exception { MemberDTO member = memberService.login(loginForm); if(member == null) { bindingResult.reject("loginFail", "아이디 또는 비밀번호가 일치하지 않습니다."); } if(bindingResult.hasErrors()) { throw new BindExceptionWithViewName(bindingResult, "login", messageSource, locale); } if(member != null) { Cookie nickNameCookie = new Cookie("nickName", member.getNickName()); nickNameCookie.setMaxAge(60*60*24*7); response.addCookie(nickNameCookie); } return "redirect:/"; } @GetMapping("/logout") public String logout(HttpServletResponse response) { Cookie nickNameCookie = new Cookie("nickName", null); nickNameCookie.setMaxAge(0); response.addCookie(nickNameCookie); return "redirect:/"; } } |
메인 템플릿도 다음과 같이 수정합니다.
/src/main/webapp/WEB-INF/views/home.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<html> <head> <title>Home</title> </head> <body> <h1> Hello world! </h1>
<c:if test="${nickName ne null}"> <P>${nickName}님, 환영합니다.</P> <a href="/logout">로그아웃</a> </c:if> </body> </html> |
3. 세션(Session)
쿠키의 경우 클라이언트에서 값을 임의로 변경할 수 있고, 쿠키에 보관된 정보를 타인이 훔쳐갈 수 있기때문에 보안상 문제가 있습니다. 이런 문제점을 해결하기 위해서는 정보를 클라이언트가 아니라 서버에서 관리하고 외부로 노출되지 않도록 해야합니다.
세션은 서버가 임의의 토큰값을 쿠키에 저장하고 해당 토큰값을 매핑하여 클라이언트의 상태를 유지할 수 있습니다.
즉, 세션을 사용하면 클라이언트에서 정보를 저장하지 않고 쿠키를 통해 추정 불가능한 세션 아이디를 주고 받기 때문에 보안상 안전해집니다.
세션을 관리하기 위한 매니저 객체를 만들어야 하지만, 스프링에서는 세션을 관리하는 HttpSession 객체를 제공합니다. HttpServletRequest 객체의 getSession 메소드를 통해 세션을 가져올 수 있습니다.
1) 세션 메소드
스프링에서 제공하는 세션 객체의 메소드는 다음과 같습니다.
리턴타입 |
메소드 |
기능 |
long |
getCreationTime() |
세션이 지속된 시간을 ms 단위로 반환 |
int |
getMaxInactiveInterval() |
세션의 유지시간을 초단위로 반환 |
String |
getId() |
세션의 식별자 ID를 반환 |
void |
setAttribute(String name, Object value) |
세션에 객체 저장 |
Object |
getAttribute(String name) |
키값에 해당하는 객체 반환 |
void |
removeAttribute(String name) |
키값에 해당하는 객체 삭제 |
void |
setMaxInteractiveInterval() |
세션의 유지시간 설정 |
boolean |
isNew() |
새로 생성된 세션인지 확인 |
void |
invalidate() |
세션 제거 |
2) 세션의 생성과 조회
LoginController의 login 메소드를 수정하겠습니다.
/kro/rubisco/controller/LoginController.java
package kro.rubisco.controller;
import java.util.Locale;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import javax.validation.Valid;
import org.springframework.context.MessageSource; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping;
import kro.rubisco.config.BindExceptionWithViewName; import kro.rubisco.dto.LoginDTO; import kro.rubisco.dto.MemberDTO; import kro.rubisco.service.MemberService; import lombok.RequiredArgsConstructor;
@Controller @RequiredArgsConstructor public class LoginController { private final MessageSource messageSource; private final MemberService memberService; @GetMapping("/login") public void getLoginView() {}
@PostMapping("/login") public String login( @Valid @ModelAttribute LoginDTO loginForm, BindingResult bindingResult, Locale locale, HttpServletRequest request ) throws Exception { MemberDTO member = memberService.login(loginForm); if(member == null) { bindingResult.reject("loginFail", "아이디 또는 비밀번호가 일치하지 않습니다."); } if(bindingResult.hasErrors()) { throw new BindExceptionWithViewName(bindingResult, "login", messageSource, locale); } if(member != null) { HttpSession session = request.getSession(); session.setAttribute("member", member); } return "redirect:/"; } @GetMapping("/logout") public String logout(HttpServletRequest request) { HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } return "redirect:/"; } } |
로그인에 성공하면 세션을 가져오고, 해당 세션에 member 정보를 저장합니다.
HttpServletRequest 객체의 getSession 메소드에 매개변수로 false를 준다면 세션이 있는 경우 세션을 반환, 세션이 없는 경우 null을 반환합니다. 기본값은 true로 세션이 없으면 새로운 세션을 생성하여 반환합니다.
로그아웃시 세션의 invalidate 메소드를 통해 세션의 연결을 끊어주면 됩니다.
세션이 저장되었는지 확인하기 위해 HomeController를 수정합니다. 세션에 저장된 데이터는 세션의 getAttribute 메소드를 통해 가져올 수 있습니다.
/kro/rubisco/controller/HomeController.java
package kro.rubisco.controller;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession;
import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping;
import kro.rubisco.dto.MemberDTO;
@Controller public class HomeController {
@RequestMapping() public String home(HttpServletRequest request, Model model) { HttpSession session = request.getSession(false); if(session != null) { MemberDTO member =(MemberDTO) session.getAttribute("member"); model.addAttribute("nickName", member.getNickName()); }
return "home"; } } |
@SessionAttribute 어노테이션을 통해서도 간단하게 세션 데이터를 가져올 수 있습니다.
/kro/rubisco/controller/HomeController.java
package kro.rubisco.controller;
import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.SessionAttribute;
import kro.rubisco.dto.MemberDTO;
@Controller public class HomeController {
@RequestMapping() public String home( @SessionAttribute(name = "member", required = false) MemberDTO member, Model model ) { if(member != null) { model.addAttribute("nickName", member.getNickName()); }
return "home"; } } |
|