Intro

Cookie와 Session의 차이는 CS Study를 하고 있다면 지겹도록 많이 봐왔을 겁니다. 심지어 직무 기술 면접 문제에서도 Cookie와 Session의 차이와 각각의 특징을 적어보라는 경험도 있었어요. (대기업 면접이었습니다 ㅎ-ㅎ)
세션도 결국에 쿠키를 쓰기에 뗄 수 없는 사이입니다. 둘의 차이를 가볍게 짚고 넘어가자면, 사용자 정보를 클라이언트/서버 둘 중에 어디에 두고 있느냐 정도로 요약할 수 있겠습니다.
사전 작업
먼저 유저 테이블을 만들어보겠습니다.
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
| testdb |
+--------------------+
5 rows in set (0.058 sec)
mysql> use testdb;
Database changed
저번 포스트에서 testdb를 사용했으니 이번에도 testdb를 고르고 유저 테이블을 만들 겁니다.
mysql> CREATE TABLE users (
-> id BIGINT PRIMARY KEY AUTO_INCREMENT,
-> login_id VARCHAR(50) UNIQUE NOT NULL,
-> password VARCHAR(60) NOT NULL,
-> name VARCHAR(100) NOT NULL,
-> created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-> );
Query OK, 0 rows affected (0.025 sec)
users 테이블을 만드는 쿼리입니다.
음, 도메인 클래스도 하나 있으면 좋겠죠?
package com.madirony.user;
import java.util.Date;
public class User {
private Long id;
private String loginId;
private String password;
private String name;
private Date createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getLoginId() {
return loginId;
}
public void setLoginId(String loginId) {
this.loginId = loginId;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Date getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Date createdAt) {
this.createdAt = createdAt;
}
}
코드가 좀 지저분해 보이네요. 게터(getter), 세터(setter) 등의 보일러플레이트 코드를 줄여주는 라이브러리 Lombok을 사용합시다.
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</dependency>
package com.madirony.user;
import java.util.Date;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class User {
private Long id;
private String loginId;
private String password;
private String name;
private Date createdAt;
}
지저분한 코드를 어노테이션 하나로 해결할 수 있어 깔끔합니다.
본론에서 본격적으로 로그인을 구현하죠.
본론
Login & Logout
1) 먼저 Mybatis 매퍼를 작성합니다.
UserMapper.java
package com.madirony.user;
import org.apache.ibatis.annotations.Param;
public interface UserMapper {
User findByLogin(@Param("loginId") String loginId,
@Param("password") String password);
int insert(User user);
}
UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.madirony.user.UserMapper">
<select id="findByLogin" resultType="com.madirony.user.User">
SELECT
id,
login_id AS loginId,
password,
name,
created_at AS createdAt
FROM users
WHERE login_id = #{loginId}
AND password = #{password}
</select>
<insert id="insert" parameterType="com.madirony.user.User"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO users(login_id, password, name)
VALUES (#{loginId}, #{password}, #{name})
</insert>
</mapper>
mybatis-config.xml
<mappers>
<mapper resource="mappers/HealthMapper.xml"/>
<package name="com.madirony.user"/>
</mappers>
참고로 xml 파일이 패키지 경로에 위치해야 패키지 스캔이 동작합니다. 이전 포스트에서는 매퍼를 그대로 연결했어서 상관없었는데, 이번에는 패키지 단위로 스캔을 해야 하기 때문이에요.
2) 그다음, 로그인 서블릿을 만들어봅시다.
package com.madirony.web;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import javax.servlet.ServletException;
import java.io.IOException;
import org.apache.ibatis.session.SqlSession;
import com.madirony.config.MyBatisUtil;
import com.madirony.user.User;
import com.madirony.user.UserMapper;
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String loginId = req.getParameter("loginId");
String password = req.getParameter("password");
String remember = req.getParameter("remember");
try (SqlSession s = MyBatisUtil.openSession(true)) {
UserMapper mapper = s.getMapper(UserMapper.class);
User user = mapper.findByLogin(loginId, password);
if (user != null) {
req.getSession().setAttribute("loginUser", user);
Cookie c = new Cookie("loginId", loginId);
c.setPath("/");
c.setHttpOnly(true);
if ("on".equals(remember)) c.setMaxAge(60*60*24*30); else c.setMaxAge(0);
resp.addCookie(c);
resp.sendRedirect(req.getContextPath() + "/");
}
else {
req.setAttribute("error", "아이디/비밀번호를 확인해주세요.");
req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp);
}
}
}
}
3) 이제 View가 있어야겠죠? 로그인 JSP를 생성합니다.
login.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<h2>로그인</h2>
<c:if test="${not empty error}">
<p style="color:red;"><c:out value="${error}"/></p>
</c:if>
<form method="post" action="${pageContext.request.contextPath}/login">
<div><input name="loginId" value="${cookie.loginId.value}" placeholder="ID"></div>
<div><input type="password" name="password" placeholder="PW"></div>
<label>
<input type="checkbox" name="remember" ${cookie.loginId ne null ? 'checked' : ''}>
ID 기억
</label>
<button>로그인</button>
</form>
<html> 태그가 빠졌는데, 나중에 header나 footer를 include 하는 식으로 추가하겠습니다.
4) 이번에 만드는 게시판은 비밀 게시판이라, 로그인 사용자만 이용할 수 있도록 보호 필터를 걸어줄 것입니다.
AuthFilter.java
package com.madirony.web;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.*;
import java.io.IOException;
@WebFilter({"/board/*"})
public class AuthFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
HttpSession session = (HttpSession) req.getSession();
boolean loggedIn = (session != null && session.getAttribute("loginUser") != null);
if(!loggedIn) {
resp.sendRedirect(req.getContextPath() + "/login");
return;
}
chain.doFilter(request, response);
}
}
상속과는 달라요! Filter interface의 구현체를 만드는 작업입니다.
JWT Filter를 다뤄본 사람이라면 비슷한 걸 경험했을지도?
5) 로그아웃도 간단하니 그냥 이참에 만듭시다.
package com.madirony.web;
import java.io.IOException;
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;
@WebServlet("/logout")
public class LogoutServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
HttpSession s = req.getSession(false);
if(s != null) s.invalidate();
resp.sendRedirect(req.getContextPath() + "/login");
}
}
로그아웃 요청이 오면 세션을 무효화하고, login page로 보내버리도록 합시다.
로그아웃 테스트는 마지막에 해보도록 해요. 지금은 바쁘니까.
INSERT INTO users(login_id, password, name)
-> VALUE ('admin', '1234', '관리자');
테스트용 관리자 계정을 하나 만들었습니다!
지금은 테스트 환경이라 괜찮지만, 관리자 계정의 아이디와 비밀번호를 누구나 생각해 낼 수 있는 형태로 사용해서는 안됩니다.



그러면 관리자 계정으로 로그인을 해보도록 하죠. 로그인에 성공하면 root("/")로 리다이렉트 될 겁니다.
...보시다시피, admin/1234는 크롬도 경고할 만큼 보안에 취약합니다.
그리고 또 뭐가 문제냐, 지금은 세션 고정 공격에 취약한 상태입니다.
세션 고정 공격에서는 공격자가 임의로 생성하거나 획득한 세션 ID를 피해자에게 강제로 사용하게 만들어 서버가 피해자를 공격자로 오인하게 하는 것이 핵심입니다. 이 과정에서 피해자가 해당 세션 ID로 서버에 접속했을 때, 서버 입장에서는 이미 인증된 사용자로 판단하기 때문에, 피해자는 별도로 로그인 과정 없이 공격자의 권한을 가진 상태로 서비스를 이용하게 되죠.
그렇기 때문에 이러한 세션 고정 공격을 방지하려면 로그인할 때마다 세션 ID를 재발급받는 등의 적절한 처리가 필요합니다.
세션 고정 공격을 개선한 LoginServlet.java
package com.madirony.web;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import javax.servlet.ServletException;
import java.io.IOException;
import org.apache.ibatis.session.SqlSession;
import com.madirony.config.MyBatisUtil;
import com.madirony.user.User;
import com.madirony.user.UserMapper;
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String loginId = req.getParameter("loginId");
String password = req.getParameter("password");
String remember = req.getParameter("remember");
try (SqlSession s = MyBatisUtil.openSession(true)) {
UserMapper mapper = s.getMapper(UserMapper.class);
User user = mapper.findByLogin(loginId, password);
if (user != null) {
HttpSession session = req.getSession();
String before = session.getId();
req.changeSessionId();
String after = session.getId();
System.out.println("Session rotated : " + before + " -> " + after);
session.setAttribute("loginUser", user);
Cookie c = new Cookie("loginId", user.getLoginId());
c.setPath("/");
c.setHttpOnly(true);
if ("on".equals(remember)) c.setMaxAge(60*60*24*30); else c.setMaxAge(0);
resp.addCookie(c);
resp.sendRedirect(req.getContextPath() + "/");
}
else {
req.setAttribute("error", "아이디/비밀번호를 확인해주세요.");
req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp);
}
}
}
}
세션 ID가 정말 바뀌었는지를 확인하기 위해 Console에 출력하는 코드를 넣었습니다.
다시 한번 로그인해보면, 세션 ID 값이 바뀐 것을 확인할 수 있습니다.

아, 그리고 굳이 콘솔에 찍지 않아도 브라우저에 내장되어 있는 개발자 도구에서 쿠키 값을 확인하는 방법도 있습니다.
개발자 도구 → Application → Cookies → JSESSIONID

JSESSIONID는 톰캣 컨테이너에서 세션을 유지하기 위해 발급하는 키에요.
.. 뭔가 빠진 느낌이 들었는데 세션 유효시간을 두는 것을 잊고 있었습니다. 2가지 방법이 있어요.
1. 앱 전역 설정 (권장)
<session-config>
<session-timeout>30</session-timeout> <!-- 분 단위: 30분 -->
<tracking-mode>COOKIE</tracking-mode> <!-- URL에 jsessionid 붙는 것 방지 -->
</session-config>
web.xml에 분 단위로 세션 유효시간을 지정할 수가 있어요. 시간을 지정해두지 않는다면 Tomcat 자체적으로 기본 30분으로 설정합니다.
여기에 설정해 두면 해당 웹앱 전체에 적용됩니다.
2. 코드로 개별 설정
HttpSession session = req.getSession(); // 있으면 재사용
session.setMaxInactiveInterval(60 * 60); // 초 단위: 1시간
이 방법은 로그인 성공 직후 등 세션마다 다르게 유효시간을 지정하고 싶을 때 사용하는 방법이에요.
그 왜 지원서를 내거나, 공공기관 사이트를 이용할 때 세션 시간을 연장할 수 있는 버튼이 있는데 그럴 때 사용하면 좋지 않을까 싶습니다!
참고로 -1은 무기한 세션 값이고, 권장하지 않습니다.
Sign up
???? 회원가입이 없는데 로그인이 있으면 뭐 함??
아까 UserMapper를 작성할 때 insert 메서드를 작성해 두었습니다. 이게 가입 메서드입니다.
그리고 여기에 메서드를 하나 더 추가할 건데, ID 중복 체크를 위한 메서드를 추가합시다.
0) 사전 작업
UserMapper.java
package com.madirony.user;
import org.apache.ibatis.annotations.Param;
public interface UserMapper {
User findByLogin(@Param("loginId") String loginId,
@Param("password") String password);
int insert(User user);
User findByLoginId(@Param("loginId") String loginId);
}
로그인 테스트 할 때는 FindByLogin을 사용했지만, 이제는 findByLoginId를 사용할 것입니다.
그 이유는 뒤에서 비밀번호 암호화 처리할 때 알려드리겠습니다.
UserMapper.xml
<select id="findByLoginId" resultType="com.madirony.user.User">
SELECT
id,
login_id AS loginId, password,
name,
created_at AS createdAt
FROM users
WHERE login_id = #{loginId}
</select>
jBCrypt 의존성 추가
<!-- https://mvnrepository.com/artifact/org.mindrot/jbcrypt -->
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
그리고 비밀번호는 평문으로 DB에 저장하는 건 보안에 좋지 않습니다. jBCrypt 의존성을 추가합시다.
EncodingFilter.java
package com.madirony.web;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
@WebFilter("/*")
public class EncodingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
chain.doFilter(request, response);
}
}
닉네임이 한글일 경우, 텍스트 깨짐 현상이 있어 요청 인코딩 필터를 추가했습니다.
1) 이제 회원가입 서블릿을 작성합니다.
SignupServlet.java
package com.madirony.web;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import javax.servlet.ServletException;
import java.io.IOException;
import org.apache.ibatis.session.SqlSession;
import org.mindrot.jbcrypt.BCrypt;
import com.madirony.config.MyBatisUtil;
import com.madirony.user.User;
import com.madirony.user.UserMapper;
@WebServlet("/signup")
public class SignupServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.getRequestDispatcher("/WEB-INF/views/signup.jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String loginId = req.getParameter("loginId");
String name = req.getParameter("name");
String pw = req.getParameter("password");
String pw2 = req.getParameter("password2");
if (isBlank(loginId) || isBlank(name) || isBlank(pw) || isBlank(pw2)) {
req.setAttribute("error", "모든 항목을 입력하세요.");
req.getRequestDispatcher("/WEB-INF/views/signup.jsp").forward(req, resp);
return;
}
if (!pw.equals(pw2)) {
req.setAttribute("error", "비밀번호 확인이 일치하지 않습니다.");
req.getRequestDispatcher("/WEB-INF/views/signup.jsp").forward(req, resp);
return;
}
try (SqlSession s = MyBatisUtil.openSession(true)) {
UserMapper mapper = s.getMapper(UserMapper.class);
if (mapper.findByLoginId(loginId) != null) {
req.setAttribute("error", "이미 사용 중인 아이디입니다.");
req.getRequestDispatcher("/WEB-INF/views/signup.jsp").forward(req, resp);
return;
}
String hash = BCrypt.hashpw(pw, BCrypt.gensalt(12));
User u = new User();
u.setLoginId(loginId);
u.setName(name);
u.setPassword(hash);
mapper.insert(u);
resp.sendRedirect(req.getContextPath() + "/login?registered=1");
}
}
private boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
}
Blank 같은 건 프론트에서 걸러주면 좋겠다는 소망이 있겠지만, 백엔드 측에서는 프론트를 믿지 말라는 말이 있습니다. (프론트 혐오 발언 아님) 서버 측에서도 최소한의 검증은 하는 로직을 작성합시다.
여기서 가장 중요한 것은 비밀번호는 암호화 후 DB에 저장하는 것입니다.
추가로, 쿼리 스트링을 하나 추가했습니다. 리다이렉트 하는데 회원가입 성공 시, 알림 메시지를 출력하기 위함입니다.
2) 회원가입 폼 JSP
signup.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<meta charset="UTF-8">
<h2>회원가입</h2>
<c:if test="${not empty error}">
<p style="color:red;"><c:out value="${error}"/></p>
</c:if>
<form method="post" action="${pageContext.request.contextPath}/signup">
<div><input name="loginId" value="${param.loginId}" placeholder="아이디" required></div>
<div><input name="name" value="${param.name}" placeholder="이름" required></div>
<div><input type="password" name="password" placeholder="비밀번호" required></div>
<div><input type="password" name="password2" placeholder="비밀번호 확인" required></div>
<button>가입</button>
</form>
<p><a href="${pageContext.request.contextPath}">메인페이지</a></p>
3) 추가 작업
LoginServlet.java (코드 수정/추가)
UserMapper mapper = s.getMapper(UserMapper.class);
User user = mapper.findByLoginId(loginId);
if (user != null && BCrypt.checkpw(password, user.getPassword())) {
HttpSession session = req.getSession();
String before = session.getId();
req.changeSessionId();
String after = session.getId();
session.setAttribute("loginUser", user);
findByLoginId를 통해 유저를 조회하고, 암호화된 패스워드와 사용자가 입력한 패스워드를 checkpw 메서드에 인자로 넣어 서로 매칭되는지 확인하는 코드를 추가했습니다.
login.jsp (코드 추가)
<c:if test="${param.registered eq '1'}">
<p style="color:green;">가입이 완료되었습니다. 로그인하세요.</p>
</c:if>




mysql> select * from users;
+----+----------+--------------------------------------------------------------+---------------+---------------------+
| id | login_id | password | name | created_at |
+----+----------+--------------------------------------------------------------+---------------+---------------------+
| 1 | admin | 1234 | 관리자 | 2025-09-09 09:41:55 |
| 2 | user | $2a$12$VaFyeVbMM/MnHBSJoqSd6uT/ix3he3/X60WOwjODMi6CfFAaIZ2pG | ì ì 1 | 2025-09-09 11:02:42 |
| 3 | user2 | $2a$12$K84xiHEc2LgRm9raTabZYeZkaU8WIF2.3cmMM0eTVcPhmK/0a4uom | ì ì 2 | 2025-09-09 11:22:21 |
| 4 | user3 | $2a$12$4wqpPFFJn2OPEKtQfUaehOGEYHFYRNKbamUoaodJ1YQS5I2AOHeiC | 유저3 | 2025-09-09 11:25:04 |
+----+----------+--------------------------------------------------------------+---------------+---------------------+
4 rows in set (0.003 sec)
users 테이블을 조회해보면 삽질의 결과를 볼 수 있습니다. 이제 어드민 계정은 새로 만들어 사용해야 합니다.
로그인 만들 때, 암호화되지 않은 평문을 그대로 DB에 넣었으니까요.
그리고 user와 user2는 name이 깨져있는데 원래는 유저 1과 유저 2였습니다.
EncodingFilter를 따로 둔 이유입니다. 필터 도입 이후 유저 3부터는 UTF-8로 적용되어 있습니다.
마지막으로...
4) 직관적인 UI/UX를 위해 index 페이지를 수정하고, nav bar를 추가합시다.
header.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<c:set var="ctx" value="${pageContext.request.contextPath}"/>
<nav style="display:flex; gap:12px; align-items:center; padding:10px; border-bottom:1px solid #ddd;">
<a href="${ctx}/">Home</a>
<a href="${ctx}/board/list">Board</a>
<span style="flex:1"></span>
<c:choose>
<c:when test="${empty sessionScope.loginUser}">
<a href="${ctx}/login">로그인</a>
<a href="${ctx}/signup">회원가입</a>
</c:when>
<c:otherwise>
<span>유저 이름 : <c:out value="${sessionScope.loginUser.name}"/></span>
<form method="post" action="${ctx}/logout" style="display:inline;">
<button type="submit">로그아웃</button>
</form>
</c:otherwise>
</c:choose>
</nav>
index.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8" errorPage="/WEB-INF/views/errorPageTest.jsp"%>
<%@ page import="java.util.*" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Board Main Page</title>
</head>
<body>
<%@ include file="./header.jsp" %>
<h3>JSP 게시판</h3>
<c:out value="${param.msg}" default="현재 시각"/>
<p><fmt:formatDate value="<%= new Date() %>" pattern="yyy-MM-dd HH:mm:ss"/> </p>
<%@ include file="./footer.jsp" %>
</body>
</html>


이로써 로그아웃 테스트까지. 이번 수업은 여기까지!

P.S.
다음 포스트에서는 게시판을 만드는 여정이 되겠네요.
로그인/회원가입도 (사소한 디테일 챙기느라) 시간이 좀 걸리긴 했는데 재밌게 마무리할 수 있었습니다.
게시판.. 어디까지 구현할지 아직 정하진 않았는데 일단 시작해 보죠.
'Study > Java' 카테고리의 다른 글
| [JSP] 게시판 만들기 (0) | 2025.09.10 |
|---|---|
| [JDBC] JDBC 연결 테스트와 MyBatis 설정 해보기 (0) | 2025.09.09 |
| [JSP] JSP와 친해지기 (0) | 2025.09.09 |
| [JSP] Java/JSP 프로젝트를 위한 eclipse 환경 설정 (0) | 2025.09.08 |
| [Java] HashMap의 데이터 관리?! Linked List와 Red-Black Tree의 전환 과정! (7) | 2024.12.01 |