본문 바로가기
Spring Boot

[Spring Security 입문하기] 5. 인증/인가 에러 커스터마이징 & 커스텀 로그인 페이지

by ZIAHO 2025. 4. 2.
[목차] Spring Security 입문하기

[Spring Security 입문하기] 1. 개요와 기본 동작 원리

[Spring Security 입문하기] 2. 인증(Authentication)과 인가(Authorization)

[Spring Security 입문하기] 3. 인증 / 인가 구현해보기

[Spring Security 입문하기] 4. 로그 설정하기(SLF4J)

 

해당 실습에 사용된 전체 프로젝트 소스 코드는 아래 GitHub 에서 확인하실 수 있습니다.

🔗 GitHub - zziaho/spring-security-study


안녕하세요.

 

이번 글에서는 다음과 같은 내용을 실습을 통해 구현하고 정리해보려 합니다:

  1. 커스텀 로그인 페이지 생성
  2. 인증 실패 커스터마이징
    • 실패 횟수 저장 및 계정 잠금 처리
    • 로그인 실패 사유 구분 (비밀번호 오류 / 계정 잠김 등)
    • 사용자에게 명확한 에러 메시지 전달
  3. 인가 실패 커스터마이징
    • 접근 권한이 없는 경우 403 에러 페이지로 리다이렉트
  4. 로그 설정 및 활용 (SLF4J + Logback)

✅ 1. 커스텀 로그인 페이지 생성

인증/인가 이후의 상황을 처리하기 위해 Spring Security가 기본적으로 제공하는 로그인 페이지가 아닌 커스텀 로그인 페이지를 생성합니다.


📄 login.html

경로 : /resources/templates/login.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>로그인</title>
</head>
<body style="padding-left: 50px">
<h2>🔐 로그인 페이지</h2>

<!-- 로그인 실패 시 메시지 표시 -->
<div th:if="${param.error}">
  <p style="color: red;">❌ 아이디 또는 비밀번호가 올바르지 않습니다.</p>
</div>

<!-- 로그아웃 했을 때 메시지 표시 -->
<div th:if="${param.logout}">
  <p style="color: green;">✅ 로그아웃 되었습니다.</p>
</div>

<!-- 로그인 폼 -->
<form th:action="@{/login}" method="post">
  <div>
    <label>아이디: </label>
    <input type="text" name="username" required />
  </div>
  <div>
    <label>비밀번호: </label>
    <input type="password" name="password" required />
  </div>
  <button type="submit">로그인</button>
</form>
</body>
</html>
📌 Spring Security 기본 설정
- 로그인 요청 URL은 기본적으로 "/login" 이다. (`UsernamePasswordAuthenticationFilter`가 해당 요청을 가로챔)
- 로그인 form의 input name 은 반드시 "username", "password" 여야 한다. (기본 설정일 경우) - 만약 이를 바꾸고 싶다면 SecurityConfig 에서 다음과 같이 명시해야 한다.
👉 `.usernameParameter("customUsername").passwordParameter("customPassword")`

📌 1.1 SecurityConfig.java  수정

⚙️ SecurityConfig.java

.authorizeHttpRequests(auth -> auth
        .requestMatchers("/adminView").hasRole("ADMIN")
        .requestMatchers("/userView").hasRole("USER")
        .requestMatchers("/", "/homeView").authenticated()
        .anyRequest().permitAll()
)
.formLogin(form -> form
        .loginPage("/login") // 로그인 기본 페이지 설정
        .defaultSuccessUrl("/homeView", true)
        .permitAll()
)
📌 Spring Security 기본 설정
- 로그인 요청 URL은 기본적으로 "/login" 입니다.. (`UsernamePasswordAuthenticationFilter`가 해당 요청을 가로챔)
- 로그인 form의 input name 은 반드시 "username", "password" 여야 합니다. (기본 설정일 경우)
- 만약 이를 바꾸고 싶다면 SecurityConfig 에서 다음과 같이 명시해야 합니다.
👉 `.usernameParameter("customUsername").passwordParameter("customPassword")`

📌 1.2 HomeController.java 수정

📁 HomeController.java

package com.study.spring_security_study;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {

	private static final Logger log = (Logger) LoggerFactory.getLogger(HomeController.class);

    	@GetMapping("/")
	public String rootView() {
		return "home";
	}

	@GetMapping("/login")
	public String loginView() {
		return "login";
	}
    
    // 그 외 메소드 생략

}
🌟 참고
Mapping을 loginView로 하지 않은 이유는
위 처럼 config를 세팅했을 경우 failur에 관한 설정을 해주지 않았기 때문에 Spring Security가 기본적으로
인증 실패했을 경우 쿼리 스트링(/login?error=true)으로 파라미터를 전송해주기 때문입니다.
만약 Mapping을 loginView로 했다면 /login 을 찾지 못하기 때문에 에러가 발생합니다.

🧑‍💻 1.3 커스텀 로그인 페이지 확인

루트 화면 접근 시
틀린 아이디나 비밀번호를 입력했을 경우


✅ 2. 인증 실패 커스터마이징

인증(로그인) 실패 처리를 커스터마이징 해보도록 하겠습니다.

Spring Security에서 인증 실패를 커스터마이징 하기 위해서는 AuthenticationFailureHandler 인터페이스를 사용합니다.

 

인증에 실패했을 경우

  • 실패 로그를 남기고 싶다.
  • 실패 횟수를 세고 3번 실패했을 경우 계정을 잠그고 싶다

등 인증 실패했을때 필요한 처리를 하도록 도와주는 것이 바로 AuthenticationFailureHandler 인터페이스 입니다.

AuthenticationFailureHandler 를 구현(implements)한 인터페이스를 생성하여 입맛대로 수정하여 사용할 수 있습니다.


⚙️ CustomAuthenticationFailureHandler.java (전체 소스)

package com.study.spring_security_study.handler;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

	private static final Logger log = LoggerFactory.getLogger(CustomAuthenticationFailureHandler.class);
	private final Map<String, Integer> failCounts = new HashMap<>();
	private final int MAX_FAIL_COUNT = 3;

	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
		String username = request.getParameter("username");

		int newFailCount = failCounts.getOrDefault(username, 0) + 1;
		failCounts.put(username, newFailCount);

		log.warn("로그인 실패 - 사용자: {}, 실패 횟수: {}", username, newFailCount);

		if (newFailCount >= MAX_FAIL_COUNT) {
			log.error("계정 잠금처리: {}", username);
			response.sendRedirect("/login?error=locked");
		} else {
			response.sendRedirect("/login?error=true");
		}

	}

}

 

인증 실패 처리 흐름
1. 사용자가 로그인 시도 (/login)
2. AuthenticationManager → 인증 실패 발생
3. 등록한 failureHandler 가 호출됨
4. 실패 횟수 +1, 로그 출력
5. 조건에 따라:
- 잠금: /login?error=locked
- 일반 실패: /login?error=true

 

✅ 1. 기본 세팅

private final Map<String, Integer> failCounts = new HashMap<>();
private final int MAX_FAIL_COUNT = 3;
  • failCounts ➡️ 아이디별 로그인 실패 횟수를 저장( In-Memory 방식)
  • MAX_FAIL_COUNT ➡️ 최대 실패 허용 횟수. 해당 횟수를 초과하면 계정 잠금 처리

✅ 2. 메소드 설정

@Override
public void onAuthenticationFailure(HttpServletRequest request,
                	            HttpServletResponse response,
                                    AuthenticationException exception)
                        throws IOException, ServletException {
  • 로그인 실패 시 자동으로 호출되는 메소드
  • request ➡️ 사용자가 입력한 요청 정보(아이디, 비밀번호 등)
  • response ➡️ 실패 후 클라이언트에 응답을 보낼 수 있는 객체
  • exception ➡️ 로그인 실패 이유가 담긴 예외 객체
해당 프로젝트에서는 In-Memory 방식으로 진행되기 때문에
계정 정보를 DB에서 조회해 온 이후에 exception을 던져주지 못하기 때문에
아래 소스코드에서 상황에 따라 강제적으로 exception을 발생시켜줄 예정입니다.
DB에서 조회하는 방식을 사용한다면,
instanceof 로 exception 종류를 분기하여 다르게 처리할 수 있습니다.
ex : BadCredentialsException, LockedException, DisabledException 등

 

✅ 3. 실패 횟수 증가 처리

String username = request.getParameter("username");

int newFailCount = failCounts.getOrDefault(username, 0) + 1;
failCounts.put(username, newFailCount);

log.warn("로그인 실패 - 사용자: {}, 실패 횟수: {}", username, newFailCount);
  • 로그인 시 입력된 사용자 ID를 요청 파라미터에서 꺼냄.
  • 기존 실패횟수를 조회하고 +1 해서 다시 저장. 실패할 때마다 횟수가 누적되도록 설정
  • 로그를 통해 현재 사용자와 실패 횟수 출력

✅ 4. 계정 잠금 처리

if (newFailCount >= MAX_FAIL_COUNT) {
    log.error("계정 잠금처리: {}", username);
    response.sendRedirect("/login?error=locked");
} else {
    response.sendRedirect("/login?error=true");
}
  • 누적된 실패 횟수가 최대 실패 허용 횟수와 같아지면 계정 잠금처리 로그를 출력하고 response에 locked error을 파라미터에 담아서 보낸다.
💡실제 DB를 사용하고 있다면?:
이 예제는 In-Memory 기반이기 때문에 실제로 계정을 잠그진 않습니다.
하지만 추후 DB 연동 시 사용자 테이블에 is_locked 필드를 생성하고,
CustomAuthenticationProvider 에서 해당 값을 체크하여 LockedException을 직접 던지도록 확장할 수 있습니다.

⚙️ SecurityConfig.java 수정

.formLogin(form -> form
        .loginPage("/login")
        .defaultSuccessUrl("/homeView", true)
        .failureHandler(new CustomAuthenticationFailureHandler())
        .permitAll()
)
  • failureHandler ➡️ 방금 만든 CustomAuthenticationFailureHandler를 적용하여 로그인 실패시에 적용되도록 합니다.

📄 login.html 수정

<!-- 로그인 실패 시 메시지 표시 -->
<div th:if="${param.error}">
  <p th:switch="${param.error[0]}">
    <span style="color: red" th:case="'locked'">🚫 잠긴 계정입니다.</span>
    <span style="color: red;" th:case="*">❌ 아이디 또는 비밀번호가 올바르지 않습니다.</span>
  </p>
</div>
  • 계정 잠금처리를 했을때 문구를 다르게 노출하기 위해 분기처리를 추가합니다.
Spring에서는 쿼리 파라미터가 여러개일 수 있기 때문에 내부적으로 항상 배열로 받게 되어있습니다.
그래서 [0]을 붙여 첫 번째 값만 체크하도록 설정하였습니다.

🧑‍💻 확인

✅ 1. 실패 횟수가 최대 허용치를 넘지 않았을 때

 

✅ 2. 실패 횟수가 최대 허용치를 넘었을 때


✅ 3. 인가 실패 커스터마이징

Spring Security는 인가(Authorization)에 실패했을 경우 기본적으로 403 Forbidden 에러를 반환합니다.

하지만 사용자 경험을 위해 403 에러 페이지를 생성하고 리다이렉트 시켜주는것이 일반적입니다.

 

이렇게 인가에 실패했을 경우 처리를 해주는 핸들러가 AccessDeniedHandler 입니다.


📌 AccessDeniedHandler란?

  • 인가에 실패했을 때 실행되는 핸들러
  • 인증은 성공했으나 ROLE_ADMIN 이 아닌 사용자가 /adminView 접근 시 실행
  • 직접 구현하여 원하는 처리 이후 원하는 페이지로 리다이렉트 가능

⚙️ CustomAccessDeniedHandler.java

package com.study.spring_security_study.handler;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

public class CustomAccessDeniedHandler implements AccessDeniedHandler {

	private static final Logger log = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);

	@Override
	public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
			throws IOException, ServletException {
		log.warn("인가 실패 - 요청 URI: {}", request.getRequestURI());
		response.sendRedirect("/error/403");
	}

}

⚙️ SecurityConfig.java 에 등록

http
	...
        .exceptionHandling(exception ->
            exception.accessDeniedHandler(new CustomAccessDeniedHandler())
        );

📄 error_403.html

경로 : /resources/templates/error/error_403.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>접근 불가</title>
</head>
<body>
  <h2 style="color: red;">🚫 권한이 없습니다.</h2>
  <a href="/homeView">홈으로 돌아가기</a>
</body>
</html>

📁 ErrorViewController.java 생성

CustomAccessDeniedHandler 에서 리다이렉트를 시키는 경로를 매핑해주기 위한 ErrorViewController를 생성합니다.

package com.study.spring_security_study;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@RequestMapping("/errorView")
@Controller
public class ErrorViewController {

	@GetMapping("/403")
	public String error403() {
		return "/error/error_403";
	}

}
Spring Boot에서 /error 경로는 기본 에러 페이지와 중복 될 수도 있기 때문에
error 화면으로 리다이렉트를 시켜주는 ViewController로 명명하였습니다.

🧑‍💻 확인

user로 로그인
ADMIN 페이지 클릭
error_403.html
로그도 확인할 수 있습니다.


✅ 마무리

커스텀 로그인 페이지를 생성하고, 인증과 인가 실패시 후속되는 처리 과정을 커스텀하는 과정을 실습해보았습니다.

In-Memory 방식으로 하다보니 생각했던거보다 커스텀하는 부분에 있어서 더 다양하게 처리해보는게 어려운것같네요.

DB를 연결해서 실습하는 방향으로 바꾸는것도 고려해보아야겠습니다.

 

다음 시간에는 Session에 대해서 알아보고 인증/인가시 Session을 가지고 처리하는 과정을 알아보도록 하겠습니다.

특히 로그인 유지, 동시 로그인 제한, 세션 고정 공격 방지 등에 대한 내용을 알아보도록 하겠습니다.