▲2025.01.08 - [개발] - [Java, Spring] 로그인 기능 구현해보기 (쿠키를 통한 인증)
[Java, Spring] 로그인 기능 구현해보기 (쿠키를 통한 인증)
우리가 로그인을 얘기할 때 들어보는 쿠키, 세션, Jwt와 같은 도구들은 모두 Http 프로토콜의 stateless 특성에 기인한다. telnet, ssh와 같은 네트워크 프로토콜은 한번 접속을 하면 서버는 사용자가 누
redcalender.tistory.com
이전 포스팅에서 Http 프로토콜을 따르는 환경에서의 인증 과정과 쿠키를 사용한 간단한 인증 방식에 대해 알아보았다.
단순히 쿠키만을 사용해 인증을 진행할 때의 문제점에 대한 해결로 Session(세션) 로그인 방식과 Jwt를 이용한 로그인 방식을 언급했는데 오늘은 둘 중 Session방식을 다룰 것이다.
쿠키만을 사용할 때는 1) DB에 저장되어 있는 검증된 userId만 접근하고, 2) 그 안에서도 다른 userId로 접근 못하게 해야하는 두 가지의 큰 이슈가 존재했다.
이를 해결하기 위해 매번 요청할 때마다 DB에 접근해 사용자를 인증하는 방식은 사용자가 많아지게 되면 필히 DB성능에 부하를 유발하게 된다.
DB접근을 막게 되면 저절로 그 책임은 서버에게 가중되고, 따라서 서버에 사용자의 정보를 담아두는 방식을 떠올리는 것은 자연스러운 생각이다.
이 방식이 Session을 이용한 인증 방식인데, 서버는 사용자의 최초 로그인 시 서버 내의 session저장소 혹은 메모리에 userId와 1:1로 대응하는 session을 생성해 집어넣는다.
이 session은 쿠키에 담겨 유저에게 전해지고, 인증 과정 마다 사용자는 sessionId를 담은 쿠키를 서버에게 전달하고, 서버는 이를 sessionDB의 userId와 비교해 인증을 진행한다.
코드로 확인해보자.
1) 직접 서버에 sessionDB를 만들고, sessionManager로 이를 관리
// 사용자 최초 로그인
@PostMapping("/login")
public String login(@ModelAttribute LoginRequestDTO loginRequest,
HttpServletResponse response) throws Exception {
User user = loginService.login(loginRequest);
if(user == null) {
throw new Exception("아이디 혹은 비밀번호가 일치하지 않습니다.");
}
// 올바른 로그인이 진행 될 경우, 세션을 생성한다.
sessionManager.createSession((user.getId()), response);
return "redirect:/home";
}
▲ 최초 로그인 시, sessionManager는 userId를 인자로 받아 새로운 세션을 생성한다.
@Component
public class SessionManager {
// 세션 DB
private static Map<String, Long> sessionDB = new ConcurrentHashMap<>();
// 최초 로그인 시 세션을 생성해 세션 DB에 저장
public void createSession(Long userId, HttpServletResponse response) {
// 랜덤한 세션값 생성
String token = UUID.randomUUID().toString();
// 세션 DB에 <세션, userId> 형태로 저장
sessionDB.put(token ,userId);
// 세션을 담은 쿠키를 response 객체에 넣어 보냄
Cookie cookie = new Cookie("LOGIN_ID", token);
response.addCookie(cookie);
}
// 쿠키에서 세션값을 가져온다
public Long getSession(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request);
// 세션 검증 로직
if(sessionCookie == null || !sessionDB.containsKey(sessionCookie.getValue())) {
return null;
}
// 세션DB에서 세션Id에 맞는 userId를 반환
return sessionDB.get(sessionCookie.getValue());
}
// 쿠키목록에서 "LOGIN_ID" 쿠키를 가져온다
public Cookie findCookie(HttpServletRequest request) {
if(request.getCookies() == null) {
return null;
}
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals("LOGIN_ID"))
.findFirst()
.orElse(null);
}
// 사용자 로그아웃시 세션 만료
public void sessionExpire(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request);
if(sessionCookie != null) {
sessionDB.remove(sessionCookie.getValue());
}
}
}
▲ 서버 내의 세션을 관리하는 sessionManager는 사용자별 세션의 생성, 세션 조회, 세션 만료등의 비즈니스 로직을 담당한다.
※세션 DB를 ConcurrentHashMap으로 둔 이유는 세션 DB는 여러 사용자의 요청을 처리해야 하기 때문에 발생할 수 있는 동시성 이슈들을 막기 위함이다.
HashMap을 사용할 경우 createSession의 put이나 sessionExpire의 remove 작업이 동시에 수행될 때, 한 작업이 무시되거나 덮어씌워질 수 있고, 작업순서가 꼬여 Race Condition이 발생할 수 있다.
▲ 로그인 후 쿠키의 Value값에 sessionId가 들어가 있음을 확인할 수 있다.
// 다른 페이지 접속 (새로운 인증 요청)
@GetMapping(value = "")
public String findLocationPage(HttpServletRequest request, Model model) {
// 세션이 존재하지 않거나 유효하지 않은 세션을 전달하면 로그인을 풀어버린다
if(sessionManager.getSession(request)==null) {
return "/home";
}
model.addAttribute("controllerLocationRequest", new ControllerLocationRequestDTO());
return "findLocation";
}
▲ 이제 이 sessionId로 다른 페이지에 대한 인증 요청을 할 수 있다.
2) HttpSession, @SessionAttribute 사용
@PostMapping("/login")
public String login(@ModelAttribute LoginRequestDTO loginRequest,
HttpServletRequest request) throws Exception {
User user = loginService.login(loginRequest);
if(user == null) {
throw new Exception("아이디 혹은 비밀번호가 일치하지 않습니다.");
}
// 서버에 세션 생성 or 기존 세션 반환
HttpSession session = request.getSession();
// 서버 내부적으로는 <JSESSIONID 값, <"LOGIN_ID", userId>> 로 저장됨
session.setAttribute("LOGIN_ID", user.getId());
// 만료시간 설정
session.setMaxInactiveInterval(60);
return "redirect:/home";
▲ sessionDB를 직접 생성하지 않고 java EE의 Servlet API에서 제공하는 HttpSession을 사용하면 톰캣 서버 내에서 알아서 세션을 관리해 편하게 세션을 사용할 수 있다.
@GetMapping(value = "")
public String findLocationPage(@SessionAttribute(name = "LOGIN_ID", required = false) Long userId, Model model) {
if(userId == null) {
return "home";
}
model.addAttribute("controllerLocationRequest", new ControllerLocationRequestDTO());
return "findLocation";
}
▲ SpringBoot의 @SessionAttribute 어노테이션을 통해 세션정보를 가져와 인증을 진행하는 간단한 로직을 작성할 수 있다.
▲ 톰캣에서는 서버 관리자가 따로 지정해주지 않으면 session을 담은 CookieName을 JSESSIONID로 둔다고 한다.
여기서 궁금증이 몇 가지 생겼다.
1) HttpSession을 사용할 때, 세션은 톰캣 서버 내부에서 어떻게 관리되고 있는지
2) session.setAttribute( "LOGIN_ID", user.getId() )에서 모든 userId의 key가 "LOGIN_ID"로 동일한데 어떻게 식별하지?
먼저 1)을 살펴보자.
Httpsession 인터페이스의 구현체인 StandardSession으로 가보면
기본적으로 session저장소로 ConcurrentHashMap을 사용함을 알 수 있다.
attributes라는 이름으로 ConcurrentHashMap을 선언하고 초기화했음을 알 수 있고,
// 서버에 세션 생성 or 기존 세션 반환
HttpSession session = request.getSession();
앞서 최초 로그인 과정에서 위 코드가 실행될 때 ConcurrentHasnMap 타입의 session객체 하나가 생성되거나 반환된다.
(실제로는 facade패턴으로 인해 StandardSessionFacade 클래스의 facade를 반환한다.)
// 서버 내부적으로는 <JSESSIONID 값, <"LOGIN_ID", userId>> 로 저장됨
session.setAttribute("LOGIN_ID", user.getId());
다음 코드에서는 session의 key로 userId임을 나타내는 "LOGIN_ID", value로 userId를 넣어준다.
여기서 2) 번 궁금증에 대한 해답을 말하자면,
당장은 모든 유저가 session의 key로 동일한 "LOGIN_ID"를 사용하는 것처럼 보이지만, 실제로는 서버 내부적으로 JSESSIONID 값 (userId와 1:1 대응되는 sessionId 값)을 생성해 <JSESSIONID 값, <"LOGIN_ID", userId>>의 형태로 저장한다.
그렇다면 굳이 두 개의 맵을 사용하지 않고 <JSESSIONID 값, userId>의 형태로 저장하면 되지 않을까?라고 생각할 수 있다.
이러한 방식은 세션의 확장성을 고려하지 않는 방식이다.
만약 <JSESSIONID 값, userId>의 형태로 둔다면, 장바구니, 쿠폰 등 세션에 넣어주고 싶은 정보들이 생겼을 때, 이를 반영할 수 없다.
따라서 <JSEESIONID 값, <"LOGIN_ID", userId>> 의 형태로 두어 새로운 정보들을 아래와 같이 추가해 줄 수 있다.
session.setAttribute("LOGIN_ID", user.getId());
session.setAttribute("CART", List.of("apple", "banana"));
그렇다면 JSESSIONID는 어떻게 만들어질까?
JSESSIONID를 만드는 코드는 Manager 인터페이스의 구현체인 ManagerBase에서 확인할 수 있는데,
맨 밑을 보면 인자로 받은 sessionId가 null일 시, 새로운 sessionId를 생성하고, session.setId(id) 명령어를 통해 session에 넣어준다.
이렇게 유저 한 명을 구분할 수 있는 세션이 만들어지고, 톰캣 서버의 메모리 혹은 redis와 같은 sessionDB에 저장해 둔다.
이렇게 session방식으로 검증된 user만 접근하게 하고, 본인이 아닌 다른 userId로 접근하는 것도 sessionId의 무작위성을 통해 어느 정도 방어했다. 또한 메모리, sessionDB와 같은 저장소를 두어 통해 DB의 성능 부하 또한 개선해 냈다.
3) 세션과 Jwt
그러나 세션 DB에는 여러 단점도 존재한다.
1) 접속하는 유저가 많아질수록 서버에서 관리해야 하는 세션이 증가한다. 따라서 메모리 사용량이 높아져 서버의 부하가 발생할 수 있다.
2) 서버의 확장 시 서버 간 세션 공유가 힘들어진다. 그래서 세션 클러스터링 혹은 Sticky Session방식을 사용하기도 한다.
3) Http프로토콜의 핵심 특성인 Stateless를 만족하지 못한다. 서버가 stateful 한 방식으로 운영된다면 서버 장애 시 모든 세션 정보가 손실될 가능성이 있다.
Jwt인증 방식은 이러한 문제를 해결하기 위해 등장했다.
다음 포스팅에서는 Jwt인증 방식을 자세하게 뜯어보고, 세션방식과 Jwt방식을 비교해 볼 것이다.
'개발' 카테고리의 다른 글
학습은 수직적으로, 협업은 수평적으로 (3) | 2025.04.03 |
---|---|
[Docker] SpringBoot, Mysql 도커로 띄우고 연동하기 (4) | 2025.01.23 |
[Java, Spring] 로그인 기능 구현해보기 (쿠키를 통한 인증) (2) | 2025.01.08 |