Spring Cookie, Session

강정우·2023년 12월 24일
0

Spring-boot

목록 보기
44/73

Package 구조 설계

  • 만약 우리가 처음부터 프로젝트를 작성한다고 할 때
    패키지 구조를 아래와 같이 domainweb으로 따로 구조화 해 두는 것이 좋다.

  • hello.login

    • domain
      • item
      • member
      • login
    • web
      • item
      • member
      • login
  • 여기서 domain이 가장 중요하다.
    domain = 화면, UI, 기술 인프라 등등의 영역은 제외한 시스템이 구현해야 하는 핵심 비즈니스 업무 영역을 말한다.
    즉, 향후 web을 다른 기술(thymeleaf가 아닌 front-end framework)로 바꾸어도 도메인은 그대로 유지할 수 있어야 한다는 것이다.

  • 이렇게 하려면 webdomain을 의존하지만, domainweb을 의존하지 않는다고 표현한다.
    예를 들어 web 패키지를 모두 삭제해도 domain에는 전
    혀 영향이 없도록 의존관계를 설계하는 것이 중요하다. 반대로 이야기하면 domain은 web을 참조하면 안된다.

Cookie

  • 이제 본격적으로 쿠키에 대해 자세히 알아보자. 쿠키에는 영속 쿠키와 세션 쿠키가 있다.
    영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
    세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지
@PostMapping("/login")
public String login(@Valid @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", "Miss match ID or Password");
        return "login/loginForm";
    }

    Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
    response.addCookie(idCookie);
    
    return "redirect:/";
}
  • 쿠키 생성은 그냥 new Cookie로 생성하면 된다.

login 전

  • 이땐 cookie에 들어있는게 아무것도 없다.

login 응답

  • 요청 후 responseHeader에 cookie가 담아져 온다.

login 후

  • cookie가 set이 되면 이제 모든 req에서 cookie를 헤더에 담아서 보내기 시작한다.
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {

    private final MemberRepository memberRepository;

    // @GetMapping("/")
    public String home() {
        return "home";
    }

    @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";
    }
}
  • 로그인 쿠키( memberId )가 없는 사용자는 기존 home 으로 보낸다. 추가로 로그인 쿠키가 있어도 회원이 없으면
    home 으로 보낸다. 로그인 쿠키( memberId )가 있는 사용자는 로그인 사용자 전용 홈 화면인 loginHome 으로 보낸다. 추가로 홈 화면에 화원 관련 정보도 출력해야 해서 member 데이터도 모델에 담아서 전달한다.

logout

  • 이제 항상 쿠키를 싣어서 보내기 때문에 브라우저를 끄지 않는 이상 항상 로그인 상태가 된다.
    그래서 쿠키를 삭제하기위해 Age를 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);
}

  • 그리고 이제 위 사진을 보면 set-cookieexpires 속성값도 추가되어있는 것을 볼 수 있는데 이건 다른 브라우저의 하위 버전과의 호환성을 위해 과거시간으로 보내는 값도 서버에서 한꺼번에 보내준다.

보안

  1. 쿠키 값은 임의로 변경할 수 있다.
    • 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
  2. 쿠키에 보관된 정보는 훔쳐갈 수 있다.
    • 만약 쿠키에 개인정보나, 신용카드 정보가 있다면?
      이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.
      쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있다.
  3. 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
    • 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.

대안

  1. 쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다.
  2. 서버에서 토큰을 관리한다. 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다.
    해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분) 유지한다.
    또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다.

  • 이 두가지 방법을 한 큐에 해결할 수 있다.
    바로 uuid와 함께 sessionStorage를 HaspMap 형태로 보관하면 된다.
    이는 모든 문제를 한큐에 해결할 수 있다.
  1. 쿠키 값을 변조 가능 -> 예상 불가능한 복잡한 세션Id를 사용한다.
  2. 쿠키에 보관하는 정보는 클라이언트 해킹시 털릴 가능성이 있다 -> 세션Id가 털려도 여기에는 중요한 정보가 없다.
  3. 쿠키 탈취 후 사용 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 세션의 만료시간을 짧게 (예: 30분) 유지한다 -> 또는 해킹이 의심되는 경우 서버에서 해당 세션을 강제로 제거하면 된다.

session management storage 생성

public static final String SESSION_COOKIE_NAME = "mySessionId";
private Map<String, Objects> sessionStore = new ConcurrentHashMap;

/**
 * 세션 생성
 * 1. 랜덤값의 sessionID 생성
 * 2. 생성한 sessionID 저장
 * 3. 생성한 sessionID를 쿠키를 통하여 client에 전달
 */
public void createSession(Objects value, HttpServletResponse response) {
    String sessionID = UUID.randomUUID().toString();
    sessionStore.put(sessionID, value);
    Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionID);
    response.addCookie(mySessionCookie);
}
  • 이는 여려 컨트롤러, 쓰레드에서 접글할 수 있기 때문에
  1. 컴포넌트 어노테이션을 추가하여 싱글톤으로 스프링 컨테이너가 관리하도록 한다.
  2. ConcurrentHashMap을 사용하여 여러 쓰레드에서 동시에 접근할 때 동시성 문제를 해결한다.
  • 참고 opt + cmd + c 로 특정 변수를 static하개 바꿀 수 있다.

session management storage에서 조회

version 1

public Object getSession(HttpServletRequest request) {
    Cookie[] cookies = request.getCookies();
    if (cookies == null) {
        return null;
    }
    for (Cookie cookie : cookies) {
        if (cookie.getName().equals(SESSION_COOKIE_NAME)) {
            return sessionStore.get(cookie.getValue());
        }
    }
    return null;
}
  • 원래 request.getCookies() 메서드의 결과값이 배열로 나오기 때문에 로직이 굉장히 못 생겼다. 그래서 해당 속성에 쿠키를 가져오는 메서드를 따로 생성하자.
    참고 opt + cmd + m 로 매서드를 별도로 리팩토링해주었다.
public Object getSession(HttpServletRequest request) {
    Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
    if (sessionCookie == null) {
        return null;
    }
    return sessionStore.get(sessionCookie.getValue());
}

private 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);
}

session management storage에서 삭제

/**
 * 세션에서 삭제
 */
public void expire(HttpServletRequest request) {
    Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
    if (sessionCookie != null) {
        sessionStore.remove(sessionCookie.getValue());
    }
}

테스팅

  • 위와같이 세션 스토리지에서 CRUD를 만들어봤다면 잘 동작하는지 testing을 거쳐야하는데 문제는 로직상에 HttpServletRequest가 있다는 것이다. 이는 인터페이스인데 이를 구현한 구현체를 제공하기는 하는데 다 시원찮다.
    그럼 어떻게 테스트 코드를 작성할까?
    바로 MockHttpServletRequest 가 있다.
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();
    }
}

정리
사실 세션이라는 것이 뭔가 특별한 것이 아니라 단지 쿠키를 사용하는데, 서버에서 데이터를 유지하는 방법일 뿐이라는 것이다.
그런데 프로젝트마다 이러한 세션 개념을 직접 개발하는 것은 상당히 불편할 것이다.
그래서 servlet도 세션 개념을 지원한다.
이제 직접 만드는 세션 말고, 서블릿이 공식 지원하는 세션을 알아보자.
서블릿이 공식 지원하는 세션은 우리가 직접 만든 세션과 동작 방식이 거의 같다.
추가로 세션을 일정시간 사용하지 않으면 해당 세션을 삭제하는 기능을 제공한다.

공식 sessionStorage(HttpSession)

  • 서블릿이 제공하는 HttpSession 도 결국 우리가 직접 만든 SessionManager 와 같은 방식으로 동작한다.
    서블릿을 통해 HttpSession 을 생성하면 다음과 같은 쿠키를 생성한다.
    쿠키 이름이 JSESSIONID 이고, 값은 추정 불가능한 랜덤 값이다.
    Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05

상수 변수 생성

public abstract class SessionConst {
    public static final String LOGIN_MEMBER = "loginMember";
}
  • 이렇게 상수로 사용할 값은 abstaract class 라든지 혹은 interface로 작성하여 유지해두면 좋다.

equest.getSession()

  • 얘는 HttpServletRequest가 필요하다.
HttpSession session = request.getSession();
  • getSession() 메서드로 나온 값을 보면 우리가 작성했던 모든 일련의 과정을 거져 자동으로 튀어나온다.

create 파라미터

세션의 create 파라미터(옵션)에 대해 알아보자.

  • request.getSession(true)
    세션이 있으면 기존 세션을 반환한다.
    세션이 없으면 새로운 세션을 생성해서 반환한다.
    true가 기본값이다.

  • request.getSession(false)
    세션이 있으면 기존 세션을 반환한다.
    세션이 없으면 새로운 세션을 생성하지 않는다. null 을 반환한다.
    사용예 -> 로그아웃 때, 컨트롤러에서 사용자가 최초 로그인할 때, 즉, 세션도 메모리를 사용하기 때문에 세션을 생성할 의도가 있어야 생성한다.

  • 그리고 요청 해더를 보면 JSESSIONID가 함께 날라아가는 것을 확인할 수 있다.

.invalidate();

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate();
    }
    return "redirect:/";
}
  • 해당 세션의 데이터를 모두 삭제해버린다.

controller

  • 컨트롤러는 더 간편하게 작성할 수 있다. 스프링에서 어노테이션을 제공해준다.
@GetMapping("/")
public String homeLoginV3Spring(@SessionAttribute(name=SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model) {
//  HttpSession session = request.getSession(false);
//  if (session == null) {
//      return "home";
//  }
//  Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);

    if (loginMember == null) {
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}
  • 즉, 저기 위에있는 @SessionAttribute 어노테이션 하나로 원래는 값을 찾아서 set 해와야하는 일련의 과정들을 생략하고 바로 값을 꺼내서 갖다 쓸 수 있다는 점이 좋다.

  • 참고로 이 어노테이션의 create 옵션false이다.

tracking mode

  • 트래킹 모드라는 것이 존재한다. 바로 최초 로그인 시 뒤에 파라미터처럼 jsessionid가 붙어서 나오는 건데
    사용자 입장에서는 이게 뭔지도 모를 것이고 굳이 보여줘서 혼선을 빚을 필요는 없기 때문에 없애주는 것이 좋다.

  • 이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다.
    이 방법을 사용하려면 URL에 이 값을 계속 포함해서 전달해야 한다.
    참고로 타임리프 같은 템플릿은 엔진을 통해서 링크를 걸면 jsessionid를 URL에 자동으로 포함해준다.

  • 서버 입장에서 웹 브라우저가 쿠키를 지원하는지 하지 않는지 최초에는 판단하지 못하므로, 쿠키 값도 전달하고, URL에 jsessionid 도 함께 전달해준다.
    하지만 이 방법은 모든 url에 개발자가 jssessionid를 싣어서 보내주도록 해야하는데 구현방법이 굉장히 현실성이 없기 때문에 아예 사용하지 않는것이 좋다.

  • 해제 방법은 URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면 다음 옵션을 넣어주면 된다. 이렇게 하면 URL에 jsessionid 가 노출되지 않는다.

application.properties

server.servlet.session.tracking-modes=cookie

session timeout

  • 우선 session 정보를 한 번 찍어보자.
@Slf4j
@RestController
public class SessionInfoController {
    @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("maxInactiveInterval={}", session.getMaxInactiveInterval());
        log.info("creationTime={}", new Date(session.getCreationTime()));
        log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
        log.info("isNew={}", session.isNew());

        return "세션 출력";
    }
}

  • sessionId: jsessionId와 동일한
    maxInactiveInterval: 비활성화 시키는 최대 인터벌 즉, 세션의 유효기간 -> 1800s 즉, 30분
    creationTime: 세션 생성일자
    lastAccessedTime: 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로sessionId ( JSESSIONID )를 요청한 경우에 갱신된다.
    isNew: 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId( JSESSIONID )를 요청해서 조회된 세션인지 여부

문제점

  • 세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate() 가 호출 되는 경우에 삭제된다.
    그런데 대부분의 사용자는 로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료한다.

  • 문제는 HTTP가 비 연결성(ConnectionLess)이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다.
    따라서 서버에서 세션데이터를 언제 삭제해야 하는지 판단하기가 어렵다.

  • 이 경우 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.

  1. 세션과 관련된 쿠키( JSESSIONID )를 탈취 당했을 경우 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다.
  2. 세션은 기본적으로 메모리에 생성된다. 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우만 생성해서 사용해야 한다. 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다.

세션의 종료 시점

  • 세션 생성 시점이 아니라 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 것 이다.
    이렇게 하면 사용자가 서비스를 사용하고 있으면, 세션의 생존 시간이 30분으로 계속 늘어나게 된다.
    따라서 30분 마다 로그인해야 하는 번거로움이 사라진다. HttpSession 은 이 방식을 사용한다.

세션의 timeout을 설정하는 법

application.properties

server.servlet.session.timeout=60 : 60초
server.servlet.session.timeout=1800 : 1800초(30분) 
  • 글로벌 설정은 분 단위로 설정해야 한다. -> 60이하는 안 된다. -> 만약 더 작은 값으로 했다면 자동으로 60초 (1분)가 들어감.

특정 세션 단위로 시간 설정

  • 예를들어 보안상 중요하니 유효시간을 5분으로 해야돼! 한다면
session.setMaxInactiveInterval(300); // 300초(5분)

LastAccessedTime 이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거한다.

서블릿의 HttpSession 이 제공하는 타임아웃 기능 덕분에 세션을 안전하고 편리하게 사용할 수 있다. 마지막으로 주의 할 점은
1. 세션에는 최소한의 데이터만 보관해야 한다는 점이다.
보관한 데이터 용량 x 사용자 수로 세션의 메모리 사용량이 급격하게 늘어나서 장애로 이어질 수 있다.
따라서 보관한 데이터 용량을 줄이기 위해 객체를 통째로 저장하는 것이 아닌, fit한 dto를 따로 만들어서 해당 값을 저장하는 것이 좋다.
2. 세션의 시간을 너무 길게 가져가면 메모리 사용이 계속 누적 될 수 있으므로 적당한 시간을 선택하는 것이 필요하다.
기본이 30분이라는 것을 기준으로 고민하면 된다.

profile
智(지)! 德(덕)! 體(체)!

0개의 댓글