스프링 MVC 2편 (6), 로그인 1 - 쿠키, 세션

2025. 12. 21. 17:43Spring RoadMap/Spring MVC

 

백엔드 웹 개발 핵심 기술, 스프링 MVC - 로그인 처리 1 (쿠키, 세션)

검증(Validation)을 통해 데이터의 무결성을 지켰다면, 이제는 "누가 이 요청을 보냈는가?"를 식별할 차례다.

로그인 기능을 구현해보자.

1. 로그인 요구사항 및 도메인 설계

가장 먼저 회원을 가입시키고, 로그인하는 핵심 비즈니스 로직이 필요하다.

 

Member 도메인

@Data
public class Member {
    private Long id;
    @NotEmpty
    private String loginId; // 로그인 ID
    @NotEmpty
    private String name;    // 사용자 이름
    @NotEmpty
    private String password;
}

 

 

LoginService (핵심 로직)

로그인의 핵심은 "입력받은 아이디로 회원을 찾고, 그 회원의 비밀번호가 입력받은 비밀번호와 일치하는지" 확인하는 것이다.

@Service
@RequiredArgsConstructor
public class LoginService {

    private final MemberRepository memberRepository;

    /**
     * @return null이면 로그인 실패
     */
    public Member login(String loginId, String password) {
        return memberRepository.findByLoginId(loginId)
                .filter(m -> m.getPassword().equals(password))
                .orElse(null);
    }
}
  • Optional과 람다를 사용하여 깔끔하게 처리했다. 아이디가 없거나 비밀번호가 틀리면 null을 반환한다.

2. 로그인 컨트롤러 (기본 폼 처리)

쿠키나 세션을 적용하기 전, 기본적인 로그인 폼과 처리 로직을 만든다.

LoginForm 객체 (DTO)

@Data
public class LoginForm {
    @NotEmpty
    private String loginId;
    @NotEmpty
    private String password;
}

 

LoginController

@Controller
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;
	
    // 로그인 폼으로 이동
    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm") LoginForm form) {
        return "login/loginForm";
    }

	// 로그인 요청
    @PostMapping("/login")
    public String login(@Validated @ModelAttribute LoginForm form, BindingResult bindingResult) {
        
        // 1. 입력 폼 검증 (Bean Validation)
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }

        // 2. 로그인 로직 실행
        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

        // 3. 로그인 실패 처리 (글로벌 오류)
        if (loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }

        // 4. 로그인 성공 처리 (TODO: 쿠키/세션 처리 필요)
        return "redirect:/";
    }
}

 

여기까지는 로그인을 "시도"하는 것까지다. 성공 후에 메인 페이지로 튕기긴 하지만, 서버는 이 사용자가 로그인했다는 사실을 기억하지 못한다. HTTP는 무상태(Stateless) 프로토콜이기 때문이다. 이를 해결하기 위해 쿠키가 등장한다.


3. 쿠키(Cookie)를 사용한 로그인

 

서버에서 로그인에 성공하면 HTTP 응답 헤더에 쿠키를 담아 보낸다. 그러면 웹 브라우저는 이후 요청부터 해당 쿠키를 계속해서 서버로 보내준다.

 

  • 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지.
  • 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료 시까지만 유지. (로그인은 보통 이것 사용)

LoginController - 쿠키 생성

// 로그인 성공 로직 내부
// 쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);

return "redirect:/";

 

HttpServletResponse에 쿠키를 담아서 보낸다. 이제 클라이언트는 memberId=1 같은 쿠키를 들고 다닌다.

 

HomeController - 쿠키 확인

로그인한 사용자와 안 한 사용자에게 다른 홈 화면을 보여주자.

@GetMapping("/")
public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
    
    // 1. 쿠키가 없는 사용자 -> 로그인 안 한 상태
    if (memberId == null) {
        return "home";
    }

    // 2. 쿠키가 있어도 실제 회원이 존재하는지 확인 (필수!)
    Member loginMember = memberRepository.findById(memberId);
    if (loginMember == null) {
        return "home";
    }

    // 3. 로그인 성공한 사용자 -> 회원 전용 홈
    model.addAttribute("member", loginMember);
    return "loginHome";
}
  • @CookieValue: 스프링이 제공하는 편리한 쿠키 조회 애노테이션.

Logout 처리 - 쿠키 삭제

로그아웃은 서버에서 쿠키를 없애는 것이 아니라, 유효시간이 0인 같은 이름의 쿠키를 덮어씌워서 브라우저가 삭제하게 만드는 것이다.

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
    Cookie cookie = new Cookie("memberId", null);
    cookie.setMaxAge(0); // 시간 0 -> 삭제
    response.addCookie(cookie);
    return "redirect:/";
}

4. 쿠키와 보안 문제 (중요!)

위 방식은 치명적인 보안 문제가 있다.

  1. 쿠키 값 변조: 클라이언트가 개발자 도구를 열어 memberId=2로 바꾸면 다른 사람 이름으로 로그인된다.
  2. 쿠키 정보 탈취: memberId라는 중요한 개인 식별 정보가 네트워크를 타고 그대로 노출된다.
  3. 해커의 평생 사용: 한 번 훔친 쿠키로 평생 악용할 수 있다.

이 문제를 해결하려면 중요한 정보(ID)는 서버에 저장하고, 클라이언트에게는 사용자 별로 예측 불가능한 임의의 식별자(토큰)만 줘야 한다.

이것이 바로 세션(Session) 메커니즘이다.

 

 


5. 세션 직접 만들기 (원리 이해)

 

스프링이 제공하는 것을 쓰기 전에, 원리를 이해하기 위해 직접 구현해보자.

SessionManager

@Component
public class SessionManager {

    public static final String SESSION_COOKIE_NAME = "mySessionId";
    
    // 동시성 문제를 위해 ConcurrentHashMap 사용
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();

    /**
     * 세션 생성
     */
    public void createSession(Object value, HttpServletResponse response) {
        // 1. 세션 ID 생성 (임의의 추정 불가능한 랜덤 값)
        String sessionId = UUID.randomUUID().toString();

        // 2. 세션 저장소에 저장 (key: sessionId, value: 회원객체)
        sessionStore.put(sessionId, value);

        // 3. 응답 쿠키 생성 (sessionId를 클라이언트에 전달)
        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());
        }
    }
    
    // findCookie 유틸 메서드 생략...
}

이제 클라이언트는 mySessionId=uuid-random-value만 가지고 있다. 실제 중요한 회원 정보는 서버의 Map에 있다. 해커가 UUID를 추측하는 것은 불가능하다.


6. 서블릿 HTTP 세션 1 (HttpSession)

 

우리가 만든 SessionManager와 똑같은 기능을 서블릿이 이미 제공한다. 그것이 바로 HttpSession이다.

  • 세션 생성: request.getSession(true) (기본값)
    • 세션이 있으면 기존 세션 반환.
    • 없으면 새로 생성해서 반환.
  • 세션 조회: request.getSession(false)
    • 세션이 있으면 기존 세션 반환.
    • 없으면 null 반환. (로그인 안 한 사용자를 위해 중요!)
  • 세션 저장: session.setAttribute(key, value)
  • 세션 삭제: session.invalidate()

LoginController - HttpSession 적용

@PostMapping("/login")
public String loginV3(@Validated @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request) {
    
    // ... 검증 및 로그인 로직 생략 ...

    // 로그인 성공 처리
    // 세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
    HttpSession session = request.getSession(); // default: true
    
    // 세션에 로그인 회원 정보 보관
    session.setAttribute("loginMember", loginMember);

    return "redirect:/";
}

이제 스프링(서블릿 컨테이너)이 알아서 JSESSIONID라는 이름의 쿠키를 생성해서 클라이언트에게 내려준다.

 

LogoutController - HttpSession 적용

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
    // 세션을 삭제한다.
    HttpSession session = request.getSession(false); // 없으면 null
    if (session != null) {
        session.invalidate();
    }
    return "redirect:/";
}

 

HomeController - HttpSession 적용

@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {
    
    // 세션은 메모리를 쓰기 때문에, 의미 없는 세션을 만들지 않기 위해 false 옵션 사용
    HttpSession session = request.getSession(false);
    
    if (session == null) {
        return "home";
    }

    // 세션에 저장된 회원 조회
    Member loginMember = (Member) session.getAttribute("loginMember");
    if (loginMember == null) {
        return "home";
    }

    // 로그인 된 사용자
    model.addAttribute("member", loginMember);
    return "loginHome";
}

7. 서블릿 HTTP 세션 2 (@SessionAttribute)

스프링은 세션을 더 편하게 조회할 수 있도록 @SessionAttribute를 제공한다.

 

HomeController - 최종 버전

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

    // 세션을 찾고, getAttribute하고, 캐스팅하는 과정을 애노테이션 하나로 끝냄
    if (loginMember == null) {
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}
  • required = false: 로그인하지 않은 사용자도 홈에 들어올 수 있어야 하므로 필수.
  • 주의: 이 기능은 세션을 '생성'하지는 않는다. '조회'만 간편하게 해준다.

8. 세션 정보와 타임아웃 설정

 

세션은 서버 메모리에 저장되므로 무한정 보관하면 메모리 터진다. 사용자가 로그아웃을 안 하고 브라우저를 껐을 때가 문제다. 따라서 타임아웃(만료 시간) 설정이 필수다.

 

application.properties

server.servlet.session.timeout=1800
  • 초 단위 설정이다. (1800초 = 30분)
  • 글로벌 설정이며, 특정 세션만 다르게 하려면 코드에서 session.setMaxInactiveInterval(1800);을 호출하면 된다.

세션의 타임아웃 원리 (LastAccessedTime) 사용자가 요청을 보낼 때마다(페이지 이동, 클릭 등) 세션의 최근 접근 시간(session.getLastAccessedTime())이 갱신된다. 이 시간으로부터 30분이 지나면 톰캣이 세션을 삭제한다. 즉, 사용자가 활발하게 활동하면 세션은 계속 유지된다.


9. 정리

 

로그인 기능은 상태 유지(Stateful)가 핵심이다.

  1. 쿠키: 클라이언트 쪽에 데이터를 저장한다. 보안에 매우 취약하므로 중요 정보는 절대 담으면 안 된다.
  2. 세션: 서버 쪽에 데이터를 저장하고, 클라이언트에게는 추정 불가능한 랜덤 ID(토큰)만 쿠키로 전달한다.
  3. HttpSession: 서블릿이 제공하는 검증된 세션 기술이다. JSESSIONID 쿠키를 사용하며, 타임아웃 관리 등 복잡한 기능을 자동으로 처리해준다.
  4. @SessionAttribute: 스프링이 제공하는 편리한 세션 조회 기능이다.

이제 로그인 처리를 위한 기초 공사는 끝났다. 다음 섹션에서는 로그인을 하지 않은 사용자가 회원 전용 페이지에 접근하려 할 때, 컨트롤러마다 if (login == null)을 넣는 노가다를 없애주는 필터(Filter)인터셉터(Interceptor)에 대해 알아본다.