[ Spring ]/SpringMVC 2편

[Spring] 로그인 처리1 - 쿠키, 세션

쿠릉쿠릉 쾅쾅 2022. 3. 12. 03:39
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 / valueCookie 클래스 생성자의 인수로 넘겨주어 생성한다.
  •  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 속성을 통해 전송된 쿠키 정보중 keymemberId인 쿠키값을 찾아 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와 세션에 보관할 값(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

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com

 

https://catsbi.oopy.io/0c27061c-204c-4fbf-acfd-418bdc855fd8

 

6. 로그인 처리 1 - 쿠키, 세션

목차

catsbi.oopy.io

 

 

728x90