Spring 세션 로그인 - Spring sesyeon logeu-in

로그인 유지, 세션

로그인 유지를 하지 않은 로그인은 無와 가깝습니다. id와 pwd를 검증하고 다음 페이지로 이동시킨다 한들, 로그인 유지되지 않으면 거의 조회밖에 할 수 있는 것이 없습니다. 그러면 사실상 로그인을 하지 않은 것과 다름이 없고, 로그인을 하지 않은 사용자도 url으로 직접적으로 로그인을 한 후 이동하는 페이지로 접속할 수 있겠죠.

대부분의 웹사이트에는 로그인 유지가 필요합니다. 로그인했을 때 그 정보를 세션에 담고 세션을 이용해서 로그인 여부를 검증할 수 있고, 세션을 이용해서 회원의 정보를 언제든지 불러올 수 있을 것입니다.

세션은 일정 시간동안 같은 클라이언트(브라우저)에서 들어오는 일련의 요구를 하나의 상태로 보고, 그 상태를 유지하는 기술을 말합니다. 사용자가 웹서버에 접속한 후 브라우저를 종료할 때 까지 세션이 유지됩니다.

프로젝트 준비

1. MemberDao 클래스 - 로그인 할 때 DBMS에 SELECT 명령

public class MemberDao {

	private JdbcTemplate jdbcTemplate;

	public MemberDao(DataSource dataSource) {
		this.jdbcTemplate = new JdbcTemplate(dataSource);
	}

	public Member selectByEmail(String email) {
		List<Member> results = jdbcTemplate.query(
			"select * from MEMBER where EMAIL = ?",
			new RowMapper<Member>() {
				@Override
				public Member mapRow(ResultSet rs, int rowNum) throws SQLException {
					Member member = new Member(
							rs.getString("EMAIL"),
							rs.getString("PASSWORD"),
					member.setId(rs.getLong("ID"));
					return member;
				}
			}, email);

		return results.isEmpty() ? null : results.get(0);
	}
    
    ... 생략
}

2. Member 클래스 - DB에서 조회한 내용을 보관할 클래스 (비밀번호 일치 검사 메서드를 포함)

public class Member {

	private Long id;
	private String email;
	private String password;

	public Member(String email, String password) {
		this.email = email;
		this.password = password;
	}

	void setId(Long id) {
		this.id = id;
	}

	public Long getId() {
		return id;
	}

	public String getEmail() {
		return email;
	}

	public String getPassword() {
		return password;
	}

	public boolean matchPassword(String password) {
		return this.password.equals(password);
	}

}

3. AutoInfo 클래스 - 로그인 성공 후 인증 상태를 세션에 보관할 때 사용

public class AuthInfo {

	private Long id;
	private String email;

	public AuthInfo(Long id, String email) {
		this.id = id;
		this.email = email;
	}

	public Long getId() {
		return id;
	}

	public String getEmail() {
		return email;
	}

}

4. AuthService 클래스 - 로그인 실패 시 예외를 뿌리고, 성공 시 그 회원 정보로 AutoInfo 클래스를 생성함

public class AuthService {

	private MemberDao memberDao;

	public void setMemberDao(MemberDao memberDao) {
		this.memberDao = memberDao;
	}

	public AuthInfo authenticate(String email, String password) {
		Member member = memberDao.selectByEmail(email);
		if (member == null) {
			throw new WrongIdPasswordException();
		}
		if (!member.matchPassword(password)) {
			throw new WrongIdPasswordException();
		}
		return new AuthInfo(member.getId(),
				member.getEmail()
	}

}

5. LoginCommand 클래스 - 로그인 폼에 입력된 값을 담을 커맨드 클래스

public class LoginCommand {

	private String email;
	private String password;

	public String getEmail() {
		return email;
	}

	public void setEmail(String email) {
		this.email = email;
	}

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

}

6. ControllerConfig 클래스 - 컨트롤러 설정 클래스

@Configuration
public class ControllerConfig {

	@Autowired
	private AuthService authService;

	@Bean
	public LoginController loginController() {
		LoginController controller = new LoginController();
		controller.setAuthService(authService);
		return controller;
	}

}

7. MemberConfig 클래스 - 멤버 설정 클래스

@Configuration
@EnableTransactionManagement
public class MemberConfig {

	@Bean(destroyMethod = "close")
	public DataSource dataSource() {
		DataSource ds = new DataSource();
		ds.setDriverClassName("com.mysql.jdbc.Driver");
		ds.setUrl("jdbc:mysql://localhost/schema?characterEncoding=utf8");
		ds.setUsername("id");
		ds.setPassword("password");
		ds.setInitialSize(2);
		ds.setMaxActive(10);
		ds.setTestWhileIdle(true);
		ds.setMinEvictableIdleTimeMillis(60000 * 3);
		ds.setTimeBetweenEvictionRunsMillis(10 * 1000);
		return ds;
	}

	@Bean
	public PlatformTransactionManager transactionManager() {
		DataSourceTransactionManager tm = new DataSourceTransactionManager();
		tm.setDataSource(dataSource());
		return tm;
	}

	@Bean
	public MemberDao memberDao() {
		return new MemberDao(dataSource());
	}

	@Bean
	public AuthService authService() {
		AuthService authService = new AuthService();
		authService.setMemberDao(memberDao());
		return authService;
	}
}

예제를 위한 준비는 여기까지이고, 이제 로그인 유지를 위해 컨트롤러를 구현해야 하는데 방법은 크게 HttpSession(세션)을 이용한 방법과 Cookie(쿠키)를 이용한 방법이 있습니다.

먼저 HttpSession를 이용한 방법을 보겠습니다.

HttpSession

1. 요청 매핑 애노테이션을 적용한 메서드에 파라미터로 HttpSession타입 객체를 추가하는 방법

// 1번
@PostMapping
public String submit(LoginCommand loginCommand, Errors errors, HttpSession session){
    ... 생략
}

// 2번
@PostMapping
public String submit(LoginCommand loginCommand, Errors errors, HttpServletRequest req){
	HttpSession session = req.getSession();
    ... 생략
}

첫 번째 예제에서 매핑 애노테이션을 적용한 메서드에 HttpSession 파라미터를 추가하면 메서드를 호출할 때 MVC가 HttpSession 객체를 생성하고 전달합니다. 이미 존재한다면 전달만 합니다.

두 번째 예제에서는 getSession() 메서드를 이용해서 HttpSession을 생성하거나 호출합니다. 첫 번째 예제와 달리 HttpSession을 필요할 때만 생성하는 것이 가능합니다.

try{
    Autoinfo authInfo = authService.authenticate(
        loginCommand.getEmail(),
        loginCommand.getPassword());
    
    session.setAttribute("authInfo", authInfo);
    return "login/loginSuccess";
} catch(IdPasswordNotMatchingException e){
    return "login/loginFrom";
}   

로그인에 성공하면 HttpSession의 "autoInfo" 속성에 인증 정보 객체 autoInfo를 저장합니다.

session.invalidate();

로그아웃을 했을 때 세션을 제거하는 코드입니다. LogoutController는 생략했습니다.

<!-- main.jsp -->
<%@ page contentType="text/html; charset=utf-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html>
<head>
    <title>메인</title>
</head>
<body>
    <c:if test="${empty authInfo}">
    <p>
        <a href="<c:url value="/login" />">[로그인하러가기]</a>
    </p>
    </c:if>
    
    <c:if test="${! empty authInfo}">
    <p>${authInfo.name}님</p>
    </c:if>
</body>
</html>

View인 main.jsp입니다. HttpSession에 authInfo(세션 정보)가 비어있다면 로그인을 활성화하고, 그렇지 않다면 로그인한 유저의 name을 출력합니다.

현재 HttpSession에 로그인한 정보(authInfo)를 담았습니다. 따라서, 사용자가 로그인을 하고 브라우저 안에서 이동하거나 시간이 지나도 HttpSession은 정보를 담고 있을 것입니다. 그럼 이 사실을 이용해서 프로그램을 구현하면 됩니다.

@GetMapping
public String orderProduct(
    @ModelAttribute("command") OrderProductCommand opc,
    HttpSession session){
    AuthInfo authInfo = (AuthInfo) session.getAttribute("authInfo");
    if(authInfo == null){
        return "redirect:/login";
    }
    return "product/order";
}

예를 들어, 보통 메인 화면은 로그인이 되어 있지 않아도 들어갈 수 있고, 상품 주문 버튼을 누르면 로그인 여부를 판단해야 합니다. HttpSession이 정보를 담고 있지 않다면 로그인이 되어 있지 않으므로 로그인창으로 이동시켜주고, 정보를 담고 있다면 로그인이 되어 있으므로 해당 페이지로 넘어가게 해줄 수 있습니다.

다만, 이 방식을 사용하면 하나의 url에 대한 요청으로만 위 기능이 구현됩니다. 따라서, 해당 페이지가 아닌 다른 페이지를 이용할 때는 기능이 적용되지 않습니다.

HandleInterceptor

스프링 프레임워크가 제공하는 HandleInterceptor 인터페이스를 이용하면 로그인 유지에 관련된 기능들을 원하는 범위의 컨트롤러들에게 적용할 수 있고, 기능을 편리하게 구현할 수 있습니다. 인터페이스의 각 메서드는 아무 기능도 구현하지 않은 자바 8 Default method이므로 필요하지 않은 메서드는 구현하지 않아도 됩니다. 메서드 목록입니다.

  • preHandle() - 리턴값이 false면 컨트롤러는 HandlerInterceptor를 실행하지 않는다.
  • postHandle() - 컨트롤러가 익셉션 없이 실행되었을 때 그 후에 이 메서드가 실행된다.
  • afterCompletion() - 뷰가 클라이언트(브라우저)에 Response를 던진 후에 실행된다. 익셉션이 발생하면 익셉션을 전송한다.
public class AuthHandlerInterceptor implements HandlerInterceptor {

	@Override
	public boolean preHandle(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler) throws Exception {
        HttpSession session = request.getSession(false);
        if (session != null) {
            Object authInfo = session.getAttribute("authInfo");
            if (authInfo != null) {
                return true;
			}
        }
        response.sendRedirect(request.getContextPath() + "/login");
        return false;
	}

}

preHandle()을 사용한 예시입니다. request.getSession(false)의 인자가 true면 HttpSession이 존재하지 않을 때 세션을 새로 생성하고 false면 세션을 생성하지 않고 null을 반환합니다.

따라서, HttpSession이 존재하고 authInfo가 들어있다면 preHandle()메서드는 true를 리턴합니다. 컨트롤러는 정상적으로 작동하고 컨트롤러에서 로직을 실행하고 경로로 보내줄 것입니다.

반대로 세션이 존재하지 않거나, 세션에 authInfo(로그인 정보)가 없다면 로그인을 하는 경로로 리다이렉트 시키고 false를 리턴합니다. 그러면 컨트롤러에서 구현된 내용은 작동하지 않습니다.

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
    
    @Bean
	public AuthCheckInterceptor authCheckInterceptor() {
		return new AuthCheckInterceptor();
	}
    @Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(authCheckInterceptor())
			.addPathPatterns("/product/order/**")
                     // .excludePathPatterns("/product/order/review/**")
	}
}

HandleInterceptor의 구현이 끝났으면 Mvc설정 클래스에서 빈으로 등록합니다.

등록한 빈 객체를 addInterceptors()메서드로 InterceptorRegistry 타입 파라미터의 addInterceptor() 메서드의 인자로 넣고 addPathPatterns로 인터셉터를 적용할 범위를 지정하면됩니다. 경로는 Ant 패턴으로, 두 개 이상을 지정하려면 콤마로 구분합니다. (Ant 패턴에서 **은 0개부터 그 이상의경로. 폴더도 포함)

excludePathPatterns()을 이용하면 인터셉트 범위에서 특정 경로를 제외할 수 있습니다.