728x90
패키지 구조 설계
- hello.login
- domain
- item
- member
- login
- web
- item
- member
- login
🔍 도메인이 가장 중요하다.
- 도메인은 화면, UI, 기술 인프라 등등의 영역을 제외한 시스템이 구현해야하는 핵심 비즈니스 업무 영역을 말한다.
- 향후 web을 다른 기술로 바꿔도 도메인은 그대로 유지해야 한다.
- web은 domain을 의존하지만, domain은 web을 의존하지 않는다.
로그인 - 쿠키 사용
- 서버에서 로그인 성공시 쿠키를 담아 브라우저에 전달하면 브라우저는 해당 쿠키를 저장해두고 해당 사이트에 접속할 때마다 지속해서 해당하는 쿠키를 보내준다.
🔍 쿠키 종류
- 상황에 따라 쿠키의 생명 주기를 설정해 사용할 수 있다.
- 영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
- 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료까지 유지
- 브라우저 종료시 로그아웃이 된다.
🔍 쿠키 핸들링
- java.servlet.http에는 Cookie라는 클래스를 제공한다. 이 클래스를 이용해서 클라이언트에 응답할 쿠키정보를 쉽게 핸들링할 수 있다.
💡 서버에서 쿠키 생성하기
📌 컨트롤러 (로그인 성공시 세션 쿠키 생성)
@PostMapping("/login")
public String login(@Validated @ModelAttribute LoginForm form,
BindingResult bindingResult,
HttpServletResponse response) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 쿠키에 시간 정보를 주지 않으면 세션 쿠키 (브라우저 종료시 모두 종료)
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
return "redirect:/";
}
- new Cookie("memberId", String.valueOf(loginMember.getId()))
- key / value 를 Cookie 클래스 생성자의 인수로 넘겨주어 생성한다.
- response.addCookie(idCookie)
- 생성된 쿠키(ex: idCookie)를 서버 응답 객체(HttpServletResponse)에 addCookie() 메서드를 이용해 담는다.
- 그러면 실제로 웹 브라우저에서는 Set-Cookie 프로퍼티에 쿠키정보가 담겨서 반환된다.
✔ 쿠키 생성 로직
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
- 로그인에 성공하면 쿠키를 생성하고 HttpServletResponse 객체에 담는다.
- 쿠키 이름은 memberId이고, 값은 회원의 id로 담는다.
- 웹 브라우저는 종료 전까지 회원의 id를 서버에서 계속 보내줄 것이다.
💡 서버에서 쿠키 조회하기
@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";
}
- @CookieValue(name="memberId", required=false) Long memberId
- 쿠키를 편리하게 조회할 수 있도록 도와주는 애노테이션이다.
- name 속성을 통해 전송된 쿠키 정보중 key가 memberId인 쿠키값을 찾아 memberId 변수에 할당해준다.
- required 속성 값이 false이기에 쿠키정보가 없는 비회원도 접근 가능하다.
- 로그인 성공시 로그인 사용 전용 홈 화면인 loginHome 페이지로 보낸다. 추가로 홈 화면에 회원 관련 정보도 출력해야 해서 member 데이터를 모델에 담아서 전달한다.
📌 loginHome.HTML (로그인 성공시 홈페이지)
<h4 class="mb-3" th:text="|로그인: ${member.name}|">로그인 사용자 이름</h4>
- 로그인 성공시 사용자 이름을 출력한다.
💡 서버에서 쿠키 없애기 (로그아웃)
- 로그아웃 기능은 쿠키를 삭제하는 것이 아니라 종료 날짜를 0으로 줘서 바로 만료시킴으로써 삭제할 수 있다.
📌 컨트롤러 (쿠키 만료)
@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);
}
- 로그아웃도 응답 쿠키를 생성한다.
- 응답 쿠키의 정보를 보면 Max-Age=0 으로 되어있어서 해당 쿠키는 즉시 종료된다.
쿠키의 보안 문제
- 쿠키는 보안 문제점을 가지고 있다.
- 쿠키 값을 임의대로 변경할 수 있다.
- 쿠키에 보관된 정보(memberId)를 타인이 훔쳐갈 수 있다.
- 한 번 도용된 쿠키 정보는 계속 악용될 수 있다.
- 대안
- 쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑을해서 인식하도록 한다. 그리고 서버에서 토큰을 관리한다.
- 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능해야 한다.
- 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분) 유지하도록 한다.
- 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거해야 한다.
- 중요한 정보는 클라이언트의 쿠키 저장소에 저장하지 말고 서버에 저장해야 한다. 그리고 클라이언트와 서버는 추청 불가능한 임의의 식별자 값으로 서로 연결해야한다.
로그인 - 세션 사용
- 세션은 서버에 중요한 정보를 보관하고 연결을 유지하는 방법이다.
- 중요한 정보는 클라이언트의 쿠키 저장소에 저장하지 말고 서버에 저장해야 한다.
- 그리고 클라이언트와 서버는 추청 불가능한 임의의 식별자 값으로 서로 연결해야한다.
🔍 세션 동작 방식
- 중요한 정보는 서버의 세션 저장소에 key / value로 저장한 뒤 브라우저에서는 key값만 가지고 있도록 한다.
더보기
- 사용자가 아아디, 비밀번호 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.
- 로그인이 성공할 시 서버에서 추정 불가능한 세션 ID를 생성한다.
- 세션 ID를 발급할 때 UUID로 제공한다.
- 예) Cookie : mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61
- UUID는 네트워크 상에서 고유성이 보장되는 id를 만들기 위한 표준 규약이다.
- 세션 ID를 발급할 때 UUID로 제공한다.
- 생성된 세션 ID와 세션에 보관할 값(memberA)을 서버의 세션 저장소에 보관한다.
- 결국 클라이언트와 서버는 쿠키로 연결 되어야 한다.
- 서버는 클라이언트에 mySeesionId라는 이름으로 세션 ID만 쿠키에 담아서 전달한다.
- 클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관한다.
- 여기서 중요한 포인트는 회원과 관련된 정보는 클라이언트에 전달하지 않는다.
- 오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.
- 클라이언트는 요청시 항상 mySessionId 쿠키를 전달한다.
- 서버에서는 클라이언트가 전달한 mySessionId 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.
🧷 정리
- 세션을 통해 쿠키의 보안 문제를 해결할 수 있다.
문제 | 해결 |
쿠키 값 변조 가능 | 예상 불가능한 세션 ID를 사용하여 변조 불가능 |
쿠키에 보관한 정보는 클라이언트 해킹시 털릴 가능성이 있다. | 세션 ID가 털려도 여기에는 중요한 정보가 없다. |
쿠키를 탈취한 후 영구적으로 사용 | 토큰이 털려도 세션 만료 시간을 짧게 설정하여 시간이 지나면 사용할 수 없다. |
🔍 세션 직접 만들어보기
- 세션 생성
- 세션 키는 중복이 안되며 추정 불가능한 랜덤 값이다.
- 세션 키(key)에 매칭될 값(value)가 있어야 한다.
- 이렇게 생성된 세션 키(key)를 응답 쿠키에 저장해 서버가 클라이언트에 전달한다.
- 세션 조회
- 클라이언트가 요청한 세션 ID인 쿠키 값으로 세션 저장소에 저장된 값을 조회할 수 있다.
- 세션 만료
- 클라이언트가 요청한 세션 ID인 쿠키 값으로 세션 저장소에 보관한 세션 엔트리를 제거해야 한다.
📌 SessionManager (세션 관리)
package hello.login.web.session;
import org.springframework.stereotype.Component;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "mySessionId";
private final Map<String, Object> sessionStore = new ConcurrentHashMap<>();
/**
* 세션 생성
* * sessionId 생성 (임의의 추정 불가능한 랜덤 값)
* * 세션 저장소에 sessionId와 보관할 값 저장
* * sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
*/
public void createSession(Object value, HttpServletResponse response) {
// 세션 id를 생성하고, 값을 세션에 저장
String sessionId = UUID.randomUUID().toString(); // UUID 생성
sessionStore.put(sessionId, value);
// 쿠키 생성
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
// 쿠키를 클라이언트에 응답으로 보내기
response.addCookie(mySessionCookie);
}
/**
* 세션 조회
*/
public Object getSession(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie == null) {
return null;
}
return sessionStore.get(sessionCookie.getValue());
}
/**
* 세션 만료
*/
public void expire(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie != null) {
sessionStore.remove(sessionCookie.getValue());
}
}
public Cookie findCookie(HttpServletRequest request, String cookieName) {
if (request.getCookies() == null) {
return null;
}
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(cookieName))
.findAny()
.orElse(null);
}
}
📌 SessionManagerTest (테스트 코드)
package hello.login.validation.web.session;
import hello.login.domain.member.Member;
import hello.login.web.session.SessionManager;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
class SessionManagerTest {
SessionManager sessionManager = new SessionManager();
@Test
void sessionTest() {
// 세션 생성
MockHttpServletResponse response = new MockHttpServletResponse(); // 가짜 응답 생성
Member member = new Member();
sessionManager.createSession(member, response);
// 요청에 응답 쿠키 생성
MockHttpServletRequest request = new MockHttpServletRequest(); // 가짜 요청 생성
request.setCookies(response.getCookies());
// 세션 조회
Object result = sessionManager.getSession(request);
assertThat(result).isEqualTo(member);
// 세션 만료
sessionManager.expire(request);
Object expired = sessionManager.getSession(request);
assertThat(expired).isNull();
}
}
- HttpServletRequest, HttpservletResponse 객체를 직접 사용할 수 없기에 테스트에서는 비슷한 역할을 해주는 가짜 MockHttpServletRequest, MockHttpServletResponse 객체를 사용했다.
📌 컨트롤러 (로그인 - 세션)
@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";
}
📌 컨트롤러 (로그아웃 - 세션)
@PostMapping("logout")
public String logoutV2(HttpServletRequest request) {
sessionManager.expire(request);
return "redirect:/";
}
📌 컨트롤러 (홈화면)
@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";
}
로그인 처리하기 - 서블릿 HTTP 세션1 (HttpSession)
- 서블릿은 세션 매니저 역할인 HttpSession 객체를 제공한다.
- 서블릿을 통해 HttpSession을 생성하면 쿠키이름이 JSESSIONID인 쿠키를 생성한다.
- HttpSession을 이용하면 세션 생성 / 조회 / 삭제를 편하게 사용할 수 있고 추적 불가능한 키를 가진 쿠키를 생성할 수 있다.
- 이 때 쿠키 이름은 JESSESION 이며 Http Only이기에 클라이언트에서 조작할 수 없다.
📌 SessionConst (세션 조회용 상수)
package hello.login.web;
public interface SessionConst {
final String LOGIN_MEMBER = "loginMember";
}
- HttpSession에 데이터를 보관하고 조회할 때, 같은 이름이 중복되어 사용되므로 인터페이스 상수를 하나 정의했다.
📌 컨트롤러 (로그인)
@PostMapping("/login")
public String loginV3(@Validated @ModelAttribute LoginForm form,
BindingResult bindingResult,
HttpServletRequest request) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 로그인 성공 처리
// 세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성
HttpSession session = request.getSession();
// 세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
- public HttpSession getSession(boolean create)
- default 값은 true
- create 값이 true 인 경우
- 세션이 있으면 기존 세션을 반환이다.
- 세션이 없으면 새로운 세션을 생성해서 반환한다.
- default 값이 true이므로 request.getSession() 와 동일하다.
- create 값이 false 인 경우
- 세션이 있으면 기존 세션을 반환한다.
- 세션이 없으면 새로운 세션을 생성하지 않는다. null을 반환한다.
- session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember)
- request.setAttribute() 메서드를 통해서 세션에 데이터를 보관할 수 있다.
- 하나의 세션에 여러 값을 저장할 수도 있다.
📌 컨트롤러 (로그아웃)
@PostMapping("logout")
public String logoutV3(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
session.invalidate();
}
return "redirect:/";
}
- session.invalidate() : 세션을 제거한다.
📌 컨트롤러 (홈 화면)
@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";
}
- request.getSessio(false)
- true로 하면 로그인 하지 않아도 의미없는 세션이 만들어진다.
- 세션을 찾아서 사용해야 한다면 false로 해서 세션을 생성하지 말아야 한다.
- session.getAttribute(SessionConst.LOGIN_MEMBER)
- 로그인 시점에 세션에 보관한 회원 객체를 찾는다.
로그인 처리하기 - 서블릿 HTTP 세션2 ( @SessionAttribute )
- 스프링은 세션을 더 편하게 사용하도록 @SessionAttribute 애노테이션을 지원한다.
📌 컨트롤러 (홈 화면)
@GetMapping("/")
public String homeLoginV4(
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)Member loginMember,
Model model) {
// 세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
// 세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
- @SessionAttribute(name = "loginMember", required = false) Member loginMember
- 로그인 된 사용자를 찾을 때 사용한다.
- 이 기능은 세션을 새로 생성하지 않는다.
🔍 TrackingModes
- 로그인을 처음 시도하면 URL에 jessionid를 포함되어 있다.
- 이는 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해 세션을 유지하는 방법이다.
- 이를 없애기 위해서 스프링 설정 파일(application.properties)에 추가 설정을 해야한다.
server.servlet.session.tracking-modes=cookie
세션 정보와 타임아웃 설정
🔍 HttpSession에서 제공하는 세션 정보
@GetMapping("/session-info")
public String sessionInfo(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return "세션이 없습니다.";
}
// 세션 데이터 출력
session.getAttributeNames().asIterator()
.forEachRemaining(name -> log.info("session name={}, value={}", name, session.getAttribute(name)));
log.info("sessionId={}", session.getId());
log.info("getMaxInactiveInterval={}", session.getMaxInactiveInterval());
log.info("createTime={}", new Date(session.getCreationTime()));
log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
log.info("isNew={}", session.isNew());
return "세션 출력";
}
- sessionId
- 세션 아이디(JSESSIONID) 값
- maxInactiveInterval
- 세션의 유효 시간 (ex: 1800초)
- creationTime
- 세션 생성일시
- lastAccessedTime
- 세션과 연결된 사용자가 최근에 서버에 접속한 시간
- 클라이언트에서 서버로 sessionId를 요청한 경우 갱싱된다.
- isNew
- 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고 클라이언트가 서버에게 sessionId를 요청해서 조회된 세션인지 구별한다.
🔍 세션 타임아웃 설정
세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate() 메서드가 호출되어야 삭제된다. 그런데 대부분 사용자는 로그아웃을 선택하지 않고 그냥 웹 브라우저를 종료한다. 문제는 HTTP는 비연결성(ConnectionLess)이므로 서버입장에서는 해당 사용자가 웹 브라우저릉 종료한 것인지 아닌지를 인식할 수 없다. 따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기 어렵다.
세션을 무한정 보관하면 문제가 발생할 수 있다.
- 세션과 관련된 쿠키(JSESSIONID)를 탈취 당했을 경우 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다.
- 세션은 기본적으로 메모리에 생성된다. 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우만 생성해서 사용해야한다. 10만 명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다.
세션 종료 시점
- 세션 종료 시점은 사용자의 마지막 요청 시간을 기준으로 30분 정도로 잡으면 된다.
💡 스프링 부트로 글로벌 설정
server.servlet.session.timeout=1800
- application.properties 파일에서 위의 문장을 적으면 해당 타임아웃 시간이 정해진다.
💡 특정 세션 단위로 시간 설정
HttpSession session = request.getSession();
session.setMaxInactiveInterval(1800);
💡 실무 팁
- 세션에 최소한의 데이터만 보관해야한다.
- 회원 정보 전부가 아닌 id 또는 이메일 등으로 최소한의 정보만 저장할 것.
- (보관한 데이터 용량 * 사용자 수)로 세션의 메모리 사용량이 급격하게 늘어나서 장애로 이어질 수 있다.
- 세션의 유효 시간을 너무 길게 가져가면 메모리 사용이 계속 누적되므로 적당한 시간을 선택하는 것이 필요하다.
👀 참고 자료
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
https://catsbi.oopy.io/0c27061c-204c-4fbf-acfd-418bdc855fd8
728x90
'[ Spring ] > SpringMVC 2편' 카테고리의 다른 글
[Spring] 예외 처리와 오류 페이지 (0) | 2022.03.15 |
---|---|
[Spring] 로그인 처리2 - 필터, 인터셉터 (0) | 2022.03.13 |
[Spring] 검증2 - Bean Validation (0) | 2022.03.11 |
[Spring] 검증1 - Validation (0) | 2022.03.07 |
[Spring] 메시지, 국제화 (0) | 2022.03.06 |