[Spring] 31. 세션 기반 로그인 처리 흐름

[Spring] 31. 세션 기반 로그인 처리 흐름

이전 글에서는 FilterInterceptor의 차이를 살펴봤습니다. Filter는 Spring MVC에 들어오기 전 Servlet 영역에서 동작하고, Interceptor는 Spring MVC 내부에서 Controller 실행 전후에 동작합니다.

이번 글에서는 세션 기반 로그인 처리 흐름을 정리하겠습니다. 로그인 기능은 웹 프로젝트에서 거의 항상 등장합니다. 회원이 아이디와 비밀번호를 입력하고, 서버가 회원 정보를 확인한 뒤, 로그인 상태를 유지해야 합니다.

핵심은 간단합니다. 로그인에 성공하면 서버의 HttpSession에 로그인 사용자 정보를 저장하고, 이후 요청에서는 세션에 저장된 값을 확인해서 로그인 상태를 판단합니다.


1. 세션 기반 로그인이란?

세션 기반 로그인은 서버가 로그인 상태를 기억하는 방식입니다. 사용자가 로그인에 성공하면 서버는 세션에 사용자 정보를 저장합니다. 브라우저는 세션을 식별할 수 있는 쿠키를 가지고 있고, 이후 요청마다 그 쿠키를 함께 보냅니다.

1. 사용자가 로그인 요청
2. 서버가 아이디와 비밀번호 확인
3. 성공하면 서버 세션에 로그인 정보 저장
4. 브라우저는 세션 식별 쿠키를 보관
5. 이후 요청마다 쿠키를 보내 로그인 상태 유지

즉, 로그인 상태 자체는 서버의 세션에 저장됩니다. 브라우저는 세션을 찾기 위한 식별 정보만 가지고 있습니다.


2. HttpSession이란?

HttpSession은 서버가 사용자별 상태 정보를 저장할 수 있는 공간입니다. 로그인 사용자 정보, 장바구니 임시 정보, 인증 상태 같은 데이터를 저장할 수 있습니다.

import jakarta.servlet.http.HttpSession;

세션에 값을 저장할 때는 setAttribute()를 사용합니다.

session.setAttribute("loginMember", loginMember);

세션에서 값을 꺼낼 때는 getAttribute()를 사용합니다.

LoginMember loginMember = (LoginMember) session.getAttribute("loginMember");

여기서 "loginMember"는 세션에 저장할 때 사용하는 이름입니다. 프로젝트 안에서 일관되게 사용해야 합니다.


3. 로그인 처리 전체 흐름

세션 기반 로그인 흐름을 전체적으로 보면 다음과 같습니다.

GET /member/login
 → 로그인 화면 보여주기

POST /member/login
 → 아이디, 비밀번호 받기
 → Service에서 회원 확인
 → 로그인 성공 시 세션에 사용자 정보 저장
 → 메인 화면으로 redirect

로그인 필요한 요청
 → Interceptor에서 세션 확인
 → loginMember가 없으면 로그인 화면으로 redirect
 → loginMember가 있으면 Controller 실행

즉, 로그인 Controller는 세션에 값을 저장하고, Interceptor는 그 세션 값을 확인합니다. 이 두 흐름이 연결되어야 로그인 기능이 완성됩니다.


4. 로그인 요청 DTO 만들기

로그인 화면에서는 아이디와 비밀번호를 입력받습니다. 이 값을 담기 위한 DTO를 만들 수 있습니다.

public class MemberLoginDto {

    private String memberId;
    private String memberPw;

    public String getMemberId() {
        return memberId;
    }

    public void setMemberId(String memberId) {
        this.memberId = memberId;
    }

    public String getMemberPw() {
        return memberPw;
    }

    public void setMemberPw(String memberPw) {
        this.memberPw = memberPw;
    }
}

HTML form의 name 값과 DTO 필드명이 같으면 Spring이 값을 자동으로 바인딩할 수 있습니다.

<form action="/member/login" method="post">
    <input type="text" name="memberId" placeholder="아이디">
    <input type="password" name="memberPw" placeholder="비밀번호">
    <button type="submit">로그인</button>
</form>
name="memberId"
 → MemberLoginDto.memberId

name="memberPw"
 → MemberLoginDto.memberPw

5. 세션에 저장할 LoginMember 만들기

로그인 성공 후 세션에는 모든 회원 정보를 다 넣기보다, 필요한 최소 정보만 담는 객체를 저장하는 것이 좋습니다. 예를 들어 회원 번호, 아이디, 이름, 권한 정도를 담을 수 있습니다.

public class LoginMember {

    private Long memberNo;
    private String memberId;
    private String memberName;
    private String role;

    public LoginMember(Long memberNo, String memberId, String memberName, String role) {
        this.memberNo = memberNo;
        this.memberId = memberId;
        this.memberName = memberName;
        this.role = role;
    }

    public Long getMemberNo() {
        return memberNo;
    }

    public String getMemberId() {
        return memberId;
    }

    public String getMemberName() {
        return memberName;
    }

    public String getRole() {
        return role;
    }
}

세션에 비밀번호 같은 민감한 정보는 저장하지 않는 것이 좋습니다. 로그인 상태 확인에 필요한 최소 정보만 저장하는 습관이 중요합니다.

세션에 저장하기 좋은 값 회원 번호, 아이디, 이름, 권한
세션에 저장하지 않는 것이 좋은 값 비밀번호, 주민번호, 카드번호 같은 민감 정보

6. MemberService에서 로그인 확인하기

로그인 처리는 Controller에서 직접 DB를 확인하기보다 Service에 맡기는 것이 좋습니다. Controller는 요청을 받고, Service는 회원 확인 로직을 담당합니다.

@Service
public class MemberService {

    private final MemberMapper memberMapper;

    public MemberService(MemberMapper memberMapper) {
        this.memberMapper = memberMapper;
    }

    public LoginMember login(MemberLoginDto memberLoginDto) {
        MemberDto member = memberMapper.findByMemberId(memberLoginDto.getMemberId());

        if (member == null) {
            return null;
        }

        if (!member.getMemberPw().equals(memberLoginDto.getMemberPw())) {
            return null;
        }

        return new LoginMember(
                member.getMemberNo(),
                member.getMemberId(),
                member.getMemberName(),
                member.getRole()
        );
    }
}

위 코드는 이해를 위한 단순 예시입니다. 실제 프로젝트에서는 비밀번호를 평문으로 비교하지 않고, 암호화된 비밀번호를 비교해야 합니다. 비밀번호 암호화는 이후 보안 파트에서 따로 정리하는 것이 좋습니다.

회원 없음
 → 로그인 실패

비밀번호 불일치
 → 로그인 실패

회원 있음 + 비밀번호 일치
 → LoginMember 생성 후 반환

7. 로그인 Controller 만들기

이제 로그인 화면과 로그인 처리를 담당하는 Controller를 만들 수 있습니다.

@Controller
@RequestMapping("/member")
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @GetMapping("/login")
    public String loginForm() {
        return "member/login";
    }

    @PostMapping("/login")
    public String login(
            MemberLoginDto memberLoginDto,
            HttpSession session,
            Model model
    ) {
        LoginMember loginMember = memberService.login(memberLoginDto);

        if (loginMember == null) {
            model.addAttribute("loginError", "아이디 또는 비밀번호가 올바르지 않습니다.");
            return "member/login";
        }

        session.setAttribute("loginMember", loginMember);

        return "redirect:/";
    }
}

로그인 실패 시에는 다시 로그인 화면을 반환합니다. 로그인 성공 시에는 세션에 loginMember를 저장한 뒤 메인 화면으로 이동합니다.

로그인 실패
 → member/login View 반환
 → 오류 메시지 출력

로그인 성공
 → session.setAttribute("loginMember", loginMember)
 → redirect:/

8. 로그인 실패 메시지 보여주기

로그인 실패 시 사용자가 왜 실패했는지 알 수 있도록 메시지를 보여주는 것이 좋습니다.

<form action="/member/login" method="post">
    <input type="text" name="memberId" placeholder="아이디">
    <input type="password" name="memberPw" placeholder="비밀번호">

    <p th:if="${loginError}" th:text="${loginError}">
        로그인 오류
    </p>

    <button type="submit">로그인</button>
</form>

JSP를 사용한다면 다음처럼 출력할 수 있습니다.

<c:if test="${not empty loginError}">
    <p>${loginError}</p>
</c:if>

로그인 실패 시에는 redirect보다 View를 그대로 반환하는 것이 오류 메시지를 보여주기 쉽습니다.


9. 로그아웃 처리하기

로그아웃은 세션을 제거하는 방식으로 처리할 수 있습니다. 세션 전체를 무효화하려면 invalidate()를 사용합니다.

@PostMapping("/logout")
public String logout(HttpSession session) {
    session.invalidate();
    return "redirect:/";
}

로그아웃 링크를 단순 GET으로 만들 수도 있지만, 상태를 변경하는 요청이므로 POST로 처리하는 것이 더 안전합니다.

<form action="/member/logout" method="post">
    <button type="submit">로그아웃</button>
</form>

로그아웃 흐름은 다음과 같습니다.

POST /member/logout
 → session.invalidate()
 → 세션 제거
 → 메인 화면으로 redirect

10. 화면에서 로그인 상태 확인하기

세션에 loginMember가 저장되어 있으면 화면에서 로그인 상태에 따라 다른 메뉴를 보여줄 수 있습니다.

<div th:if="${session.loginMember == null}">
    <a href="/member/login">로그인</a>
    <a href="/member/join">회원가입</a>
</div>

<div th:if="${session.loginMember != null}">
    <span th:text="${session.loginMember.memberName}">회원명</span>님
    <form action="/member/logout" method="post">
        <button type="submit">로그아웃</button>
    </form>
</div>

JSP에서는 세션 스코프를 통해 접근할 수 있습니다.

<c:choose>
    <c:when test="${empty sessionScope.loginMember}">
        <a href="/member/login">로그인</a>
        <a href="/member/join">회원가입</a>
    </c:when>
    <c:otherwise>
        ${sessionScope.loginMember.memberName}님
        <form action="/member/logout" method="post">
            <button type="submit">로그아웃</button>
        </form>
    </c:otherwise>
</c:choose>

이렇게 하면 로그인 전에는 로그인/회원가입 메뉴를 보여주고, 로그인 후에는 회원명과 로그아웃 버튼을 보여줄 수 있습니다.


11. Interceptor에서 로그인 체크하기

로그인한 사용자만 접근해야 하는 URL은 Interceptor에서 세션을 확인하면 됩니다.

public class LoginCheckInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler
    ) throws Exception {
        HttpSession session = request.getSession(false);

        if (session == null || session.getAttribute("loginMember") == null) {
            response.sendRedirect("/member/login");
            return false;
        }

        return true;
    }
}

이 Interceptor는 Controller 실행 전에 세션을 확인합니다. 로그인 정보가 없으면 로그인 페이지로 이동시키고, false를 반환해 Controller 실행을 막습니다.

세션 없음
 → 로그인 페이지로 이동
 → Controller 실행 중단

세션 있음 + loginMember 있음
 → Controller 실행

12. Interceptor 등록하기

로그인 체크 Interceptor는 WebMvcConfigurer 설정 클래스에서 등록합니다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginCheckInterceptor())
                .addPathPatterns("/mypage/**", "/board/write", "/board/*/edit")
                .excludePathPatterns(
                        "/member/login",
                        "/member/join",
                        "/css/**",
                        "/js/**",
                        "/images/**",
                        "/favicon.ico"
                );
    }
}

이 설정에서는 마이페이지, 게시글 작성, 게시글 수정에 로그인 체크를 적용합니다. 로그인 페이지, 회원가입 페이지, 정적 리소스는 제외합니다.

적용 경로 /mypage/**, /board/write, /board/*/edit
제외 경로 /member/login, /member/join, 정적 리소스

13. getSession()과 getSession(false) 차이

세션을 다룰 때 getSession()getSession(false)의 차이를 알아두면 좋습니다.

메서드 동작
request.getSession() 세션이 있으면 가져오고, 없으면 새로 만듭니다.
request.getSession(false) 세션이 있으면 가져오고, 없으면 null을 반환합니다.

로그인 체크처럼 세션이 있는지 확인만 해야 하는 상황에서는 getSession(false)가 좋습니다. 로그인하지 않은 사용자에게 새 세션을 만들 필요가 없기 때문입니다.

HttpSession session = request.getSession(false);

반대로 로그인 성공 시 세션에 값을 저장해야 하는 상황에서는 Controller 메서드에서 HttpSession을 받아 사용하면 됩니다.


14. 세션 만료 시간 설정

세션은 일정 시간 동안 요청이 없으면 만료될 수 있습니다. Spring Boot에서는 application.properties에서 세션 타임아웃을 설정할 수 있습니다.

server.servlet.session.timeout=30m

위 설정은 세션 타임아웃을 30분으로 설정하는 예시입니다. 사용자가 30분 동안 요청을 보내지 않으면 세션이 만료될 수 있습니다.

세션이 만료되면 loginMember도 사라지므로, 로그인 체크 Interceptor에서 다시 로그인 페이지로 이동시킬 수 있습니다.


15. 자주 하는 실수

1) 세션에 비밀번호를 저장하는 경우

세션에는 로그인 유지에 필요한 최소 정보만 저장하는 것이 좋습니다. 비밀번호 같은 민감한 값은 저장하지 않는 것이 안전합니다.

세션 저장 권장
 → memberNo, memberId, memberName, role

세션 저장 비권장
 → memberPw, 주민번호, 카드번호

2) 로그인 실패 후 redirect하는 경우

로그인 실패 메시지를 보여줘야 한다면 redirect보다 로그인 View를 그대로 반환하는 것이 편합니다.

model.addAttribute("loginError", "아이디 또는 비밀번호가 올바르지 않습니다.");
return "member/login";

3) 로그아웃을 GET 링크로만 처리하는 경우

로그아웃은 로그인 상태를 변경하는 요청입니다. 단순 링크보다 POST form으로 처리하는 것이 더 안전합니다.

<form action="/member/logout" method="post">
    <button type="submit">로그아웃</button>
</form>

4) Interceptor에서 로그인 페이지를 제외하지 않는 경우

로그인 체크 Interceptor를 전체 경로에 적용하면서 로그인 페이지를 제외하지 않으면 로그인 페이지도 계속 차단될 수 있습니다.

.excludePathPatterns("/member/login", "/member/join")

5) 정적 리소스를 제외하지 않는 경우

CSS, JS, 이미지 요청까지 로그인 체크에 걸리면 화면이 깨질 수 있습니다. 정적 리소스 경로는 제외하는 것이 좋습니다.

.excludePathPatterns("/css/**", "/js/**", "/images/**", "/favicon.ico")

6) 비밀번호를 평문으로 비교하는 경우

학습 예제에서는 단순 비교로 흐름을 이해할 수 있지만, 실제 프로젝트에서는 비밀번호를 반드시 암호화해서 저장하고 비교해야 합니다.

학습 예제
 → memberPw.equals(inputPw)

실제 프로젝트
 → PasswordEncoder 등을 사용해 암호화 비교

16. 전체 흐름 정리

세션 기반 로그인 흐름을 다시 정리하면 다음과 같습니다.

1. 사용자가 로그인 화면에 접근한다.
2. 아이디와 비밀번호를 입력한다.
3. POST /member/login 요청을 보낸다.
4. Controller가 MemberLoginDto를 받는다.
5. Service가 회원 정보를 확인한다.
6. 로그인 실패 시 다시 로그인 화면을 반환한다.
7. 로그인 성공 시 LoginMember 객체를 만든다.
8. session.setAttribute("loginMember", loginMember)를 실행한다.
9. 이후 보호된 URL 요청은 Interceptor가 세션을 확인한다.
10. loginMember가 없으면 로그인 화면으로 보낸다.
11. loginMember가 있으면 Controller를 실행한다.
12. 로그아웃 시 session.invalidate()로 세션을 제거한다.

정리

세션 기반 로그인은 서버의 HttpSession에 로그인 사용자 정보를 저장하고, 이후 요청에서 세션 값을 확인해 로그인 상태를 유지하는 방식입니다. 로그인 성공 시 session.setAttribute()로 값을 저장하고, 로그아웃 시 session.invalidate()로 세션을 제거합니다.

HttpSession 사용자별 상태 정보를 서버에 저장하는 공간입니다.
setAttribute() 세션에 값을 저장합니다.
getAttribute() 세션에서 값을 꺼냅니다.
invalidate() 세션을 무효화합니다. 로그아웃에 자주 사용합니다.
getSession(false) 기존 세션이 있을 때만 가져오고, 없으면 null을 반환합니다.

처음에는 다음 기준을 꼭 기억하면 됩니다.

로그인 성공
 → session.setAttribute("loginMember", loginMember)

로그인 여부 확인
 → session.getAttribute("loginMember")

로그아웃
 → session.invalidate()

보호된 URL 접근 제어
 → Interceptor에서 세션 확인

세션에 저장할 값
 → 필요한 최소 사용자 정보만 저장

연습해보기

아래 요구사항을 기준으로 세션 기반 로그인 구조를 직접 설계해보세요.

요구사항

1. GET /member/login 요청 시 로그인 화면을 보여준다.
2. POST /member/login 요청 시 아이디와 비밀번호를 확인한다.
3. 로그인 성공 시 세션에 loginMember를 저장한다.
4. 로그인 실패 시 오류 메시지와 함께 로그인 화면을 다시 보여준다.
5. POST /member/logout 요청 시 세션을 제거한다.
6. /mypage/** 요청은 로그인한 사용자만 접근할 수 있다.
7. Interceptor에서 loginMember가 없으면 /member/login으로 이동시킨다.

다음 글에서는 Cookie와 Session의 차이를 정리하겠습니다. 브라우저가 세션을 어떻게 유지하는지 이해하려면 쿠키와 세션의 관계를 함께 알아야 합니다.