[스프링] 로그인 처리 1- 쿠키, 세션

2025. 9. 28. 17:20·스프링

웹 서비스를 만들어본 사람이라면 누구나 한 번쯤 이런 고민을 하게 된다.
“로그인한 사용자의 상태를 어떻게 유지하지?”

예를 들어 온라인 쇼핑몰을 떠올려보자.

  • 로그인하지 않은 사용자가 장바구니에 물건을 담을 수 있다면?
  • 회원만 볼 수 있는 주문 내역을 다른 사람이 열람할 수 있다면?
  • 특정 사용자의 권한이 필요한 페이지에 아무나 접근할 수 있다면?

이건 보안상 치명적일 뿐만 아니라 서비스 자체가 정상적으로 운영될 수 없다.
따라서 대부분의 웹 서비스는 로그인하지 않은 사용자는 막고, 로그인한 사용자만 특정 기능을 쓸 수 있게 만든다.

그럼 여기서 중요한 질문이 생긴다.

로그인은 한 번 성공했는데, 그다음 요청부터는 어떻게 “로그인한 사용자”라는 사실을 기억할까?

 

HTTP라는 프로토콜은 본질적으로 무상태(stateless) 라는 특징을 가지고 있다.
즉, 클라이언트가 어떤 요청을 보내든 서버는 이전에 같은 클라이언트가 요청을 보냈는지 알 수 없다.
로그인에 성공했다고 해도, 다음 요청이 들어오면 그게 동일한 사용자라는 보장이 전혀 없는 것이다.

그래서 나온 개념이 바로 쿠키(Cookie) 와 세션(Session) 이다.
오늘은 이 두 가지를 차례로 살펴보고, 스프링에서 이를 어떻게 지원하는지 알아보자.

 

1. 쿠키를 이용한 로그인 처리

1-1. 아이디어

쿠키는 브라우저가 서버로부터 받은 작은 데이터를 저장해두었다가,
이후 요청마다 자동으로 다시 서버에 전송하는 기능이다.

 

이 특징을 활용하면 로그인 상태를 유지할 수 있다.

흐름은 이렇다:

  1. 사용자가 로그인에 성공한다.
  2. 서버는 사용자의 식별값(예: memberId)을 쿠키에 담아 응답한다.
  3. 브라우저는 이 쿠키를 보관하고, 이후 요청마다 서버에 자동으로 전송한다.
  4. 서버는 쿠키 값만 확인해서 사용자를 식별한다.

이를 코드로 구현해보자.

 

1-2. 로그인 성공 시 쿠키 발급

@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", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    // 로그인 성공 → 쿠키 생성
    Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
    response.addCookie(idCookie);

    return "redirect:/";
}
  • 로그인에 성공하면 memberId 쿠키를 생성해서 응답에 추가한다.
  • 브라우저는 이 쿠키를 저장하고, 이후 모든 요청마다 서버에 memberId를 보내준다.
  • 서버는 이 값으로 DB에서 회원을 조회하면 된다

 

1-3. 홈 화면에서 쿠키 확인

@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를 사용하면 쿠키 값을 쉽게 꺼낼 수 있다.
  • 쿠키가 없거나, 회원 조회에 실패하면 home 화면을 보여준다.
  • 쿠키가 있고 정상 회원이면 loginHome 화면을 보여주며, member 정보를 모델에 담는다.

 

1-4. 로그아웃 시 쿠키 만료

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
    Cookie cookie = new Cookie("memberId", null);
    cookie.setMaxAge(0); // 즉시 만료
    response.addCookie(cookie);
    return "redirect:/";
}
  • 로그아웃은 사실상 쿠키를 삭제하는 과정이다.
  • setMaxAge(0)으로 지정하면 브라우저는 해당 쿠키를 즉시 제거한다.

 

3. 쿠키 방식의 문제점

쿠키만으로 로그인 상태를 관리하면 처음엔 편하지만 보안적으로 심각한 약점이 생긴다. 먼저 쿠키 값은 클라이언트 쪽에 저장되기 때문에 사용자가 개발자 도구로 값을 마음대로 바꿀 수 있다. 예를 들어 memberId=1을 memberId=2로 바꿔버리면, 서버가 그 값을 그대로 신뢰하는 경우 다른 계정으로 접근해 버린다. 즉, 쿠키에 회원 식별자를 그대로 두면 권한을 쉽게 탈취당할 수 있다.

 

또 한 가지 큰 문제는 쿠키 탈취다. 네트워크에서 가로채거나, 사이트에 XSS 같은 취약점이 있으면 악성 스크립트가 쿠키를 읽어 전송할 수 있다. 탈취된 쿠키는 브라우저가 자동으로 첨부하기 때문에 공격자는 별다른 인증 과정 없이 그 사용자의 세션을 그대로 도용할 수 있다. 그래서 쿠키 유출 한 번이면 계정 전체가 위험해진다.

 

그리고 쿠키는 브라우저가 자동으로 전송한다는 특성 때문에 CSRF(사이트 간 요청 위조) 공격에도 취약하다. 공격자가 만들어 둔 악성 페이지에서 사용자가 모르게 요청을 보내면, 브라우저는 자동으로 쿠키를 붙여 보내므로 서버는 정상 요청으로 처리해 버릴 수 있다. 이 때문에 단순히 쿠키만 믿고 중요한 작업을 허용하면 안 된다.

 

즉 요약하면

  1. 쿠키 값 조작 가능
    • 개발자 도구에서 memberId=1을 memberId=2로 바꿀 수 있다.
    • 그러면 다른 사용자 계정처럼 동작한다.
  2. 쿠키 탈취 가능
    • 네트워크 구간에서 쿠키가 털리면, 해커가 그 값으로 계속 로그인할 수 있다.

따라서 쿠키에 중요한 정보를 직접 넣는 건 위험하다. 이런 문제점 때문에 세션이라는 단어가 등장한다.

 

 

3. 세션으로 로그인 처리

3-1. 아이디어

세션은 쿠키의 단점을 보완한다.
쿠키는 그대로 쓰되, 쿠키 안에는 세션ID만 저장한다.
실제 사용자 정보는 서버의 세션 저장소에 둔다.

흐름은 이렇게 바뀐다.

  1. 로그인 성공 → 서버가 세션ID(UUID)를 생성
  2. 서버: (세션ID → 사용자 정보) 저장
  3. 클라이언트: 세션ID만 쿠키로 저장
  4. 요청마다 세션ID를 보냄
  5. 서버: 세션 저장소에서 사용자 정보 조회

이를 직접 구현해보자.

 

3-2. 세션 매니저 직접 구현

@Component
public class SessionManager {
    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();

    // 세션 생성
    public void createSession(Object value, HttpServletResponse response) {
        String sessionId = UUID.randomUUID().toString();
        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());
        }
    }

    private Cookie findCookie(HttpServletRequest request, String name) {
        if (request.getCookies() == null) return null;
        return Arrays.stream(request.getCookies())
                .filter(c -> c.getName().equals(name))
                .findAny()
                .orElse(null);
    }
}
  • createSession: 세션ID를 만들고, (세션ID → 사용자 정보)를 저장한다.
  • getSession: 요청 쿠키에서 세션ID를 꺼내 저장소에서 사용자 정보를 찾는다.
  • expire: 세션ID를 삭제한다.

3-3. 세션 적용하기

@PostMapping("/login")
public String loginV2(@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", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    // 로그인 성공 → 세션 생성
    sessionManager.createSession(loginMember, response);

    return "redirect:/";
}

 

 

홈 화면

@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {
    Member loginMember = (Member) sessionManager.getSession(request);

    if (loginMember == null) {
        return "home"; // 세션 없음 → 비로그인 사용자
    }

    model.addAttribute("member", loginMember);
    return "loginHome"; // 세션 유지 중
}

 

로그아웃

@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
    sessionManager.expire(request);
    return "redirect:/";
}

세션을 사용해서 서버에서 중요한 정보를 관리하게 되었다. 덕분에 다음과 같은 보안 문제들을 해결할 수 있다. 쿠키 값을 변조 가능, 예상 불가능한 복잡한 세션Id를 사용한다. 쿠키에 보관하는 정보는 클라이언트 해킹시 털릴 가능성이 있다.

 

세션Id가 털려도 여기에는 중요한 정보가 없 다. 쿠키 탈취 후 사용 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 세션의 만료시간을 짧게 (예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 세션을 강제로 제거하면 된다.

 

사실 세션이라는 것이 뭔가 특별한 것이 아니라 단지 쿠키를 사용하는데, 서버에서 데이터를 유지하는 방법일 뿐이라는 것을 이해했을 것이다. 그런데 프로젝트마다 이러한 세션 개념을 직접 개발하는 것은 상당히 불편할 것이다. 그래서 서블릿도 세션 개념을 지원 한다.

 

 

4. 스프링이 제공하는 HttpSession

실제로는 이런 세션 매니저를 직접 만들 필요가 없다.
서블릿이 기본적으로 HttpSession이라는 기능을 제공한다.

 

로그인 - HttpSession

@PostMapping("/login")
public String loginV3(@Valid @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("loginMember", loginMember);

    return "redirect:/";
}

 

홈 화면 - HttpSession

@GetMapping("/")
public String homeLoginV3Spring(
    @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember,
    Model model) {

    // 세션에 회원 데이터가 없으면 home
    if (loginMember == null) {
        return "home";
    }

    // 세션이 유지되면 로그인으로 이동
    model.addAttribute("member", loginMember);
    return "loginHome";
}

 

로그아웃 - HttpSession

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate(); // 세션 만료
    }
    return "redirect:/";
}

 

 

마무리하며:

오늘은 로그인 처리 과정을 단계별로 정리해봤다.
처음에는 쿠키를 이용해 로그인 상태를 유지하는 방법을 살펴봤고, 그 과정에서 쿠키 값 변조나 탈취 같은 보안 문제가 발생할 수 있다는 점도 확인했다.


이런 문제를 해결하기 위해 세션 방식을 도입했고, 직접 세션 매니저를 구현하면서 세션이 어떤 원리로 동작하는지 이해할 수 있었다.


마지막으로는 스프링이 제공하는 HttpSession, @SessionAttribute 기능을 이용해 더 간단하고 안전하게 로그인 기능을 적용하는 방법까지 다뤘다.

즉, 쿠키 → 세션 직접 구현 → 스프링 제공 세션 순서로 발전하면서 왜 세션이 필요한지, 또 실무에서 어떻게 사용하는 게 좋은지를 알게 된 것이다.

 

다음 시간에는 여기서 한 단계 더 나아가서, 필터(Filter)와 인터셉터(Interceptor)를 활용해 로그인 여부를 공통적으로 체크하는 방법을 알아볼 예정이다. 이를 통해 컨트롤러마다 중복되는 로그인 검사 로직을 제거하고, 더 깔끔한 구조로 애플리케이션을 설계할 수 있다.

 

 

감사합니다.

'스프링' 카테고리의 다른 글

[스프링] 로그인 처리 3 - 스프링 인터셉터(Interceptor)  (0) 2025.09.29
[스프링] 로그인 처리 2 - 서블릿 필터  (0) 2025.09.29
[스프링] Validation에 대하여  (0) 2025.09.27
[스프링] 메시지, 국제화  (0) 2025.09.19
[스프링] 스프링 MVC - 기본기능 (마지막) HTTP 요청 메시지: text와 JSON  (1) 2025.09.03
'스프링' 카테고리의 다른 글
  • [스프링] 로그인 처리 3 - 스프링 인터셉터(Interceptor)
  • [스프링] 로그인 처리 2 - 서블릿 필터
  • [스프링] Validation에 대하여
  • [스프링] 메시지, 국제화
0kingki_
0kingki_
자바 + 스프링 웹 개발
  • 0kingki_
    0kingki_
    0kingki_
  • 전체
    오늘
    어제
    • 분류 전체보기 (134)
      • 코딩 테스트 (54)
      • 자바 (21)
      • 스프링 (27)
      • 타임리프 (16)
      • 스프링 데이터 JPA (8)
      • 최적화 (2)
      • QueryDSL (4)
      • AWS (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    QueryDSL
    dfs
    mvc
    최적화
    SOLID
    JPA
    코딩테스트
    스프링
    fetch join
    쿼리
    재귀
    예외 처리
    BFS
    쿼리dsl
    LocalDateTime
    타임리프
    예외처리
    thymeleaf
    spring
    스프링 컨테이너
    자바
    코딩 테스트
    불변객체
    Java
    객체지향
    다형성
    SpringDataJpa
    백준
    컬렉션
    스프링 데이터 JPA
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
0kingki_
[스프링] 로그인 처리 1- 쿠키, 세션
상단으로

티스토리툴바