[아이티센 부트캠프] Spring Security

이언덕·2026년 5월 17일

아이티센 부트캠프

목록 보기
98/115
post-thumbnail

Spring Security 기본 개념

Spring Security는 Spring 기반 웹 애플리케이션에서 보안 처리를 담당하는 하위 프레임워크이다.
여기서 보안 처리는 단순히 로그인 화면을 만드는 것만 의미하지 않는다.
사용자가 누구인지 확인하고, 로그인한 사용자가 어떤 기능까지 사용할 수 있는지 검사하고, 로그아웃과 세션 관리까지 처리하는 전체 흐름을 포함한다.


예를 들어 쇼핑몰 사이트를 생각하면 된다.
상품 목록은 누구나 볼 수 있어도, 주문 내역이나 내 정보 수정 화면은 로그인한 사용자만 볼 수 있어야 한다.
관리자 페이지는 일반 사용자가 아니라 관리자 권한을 가진 사용자만 접근할 수 있어야 한다.
이런 흐름을 처리하는 것이 보안이다.


Spring Security는 이런 보안 검사를 애플리케이션 앞단에서 공통으로 처리할 수 있게 도와준다.
그래서 개발자는 모든 Controller마다 로그인 여부를 직접 검사하지 않고, 보안 규칙을 설정해 요청을 먼저 검사하게 만들 수 있다.


Spring Security의 핵심은 요청이 실제 기능을 처리하는 Controller에 도착하기 전에 인증과 인가를 먼저 검사한다는 점이다.


현재 실습에서 사용하는 버전 확인

버전을 먼저 확인해야 하는 이유

현재 실습 환경에서는 Spring Framework, Spring Boot, Spring Security 버전을 먼저 확인했다.
버전 확인은 단순한 부가 작업이 아니다.
보안 설정 문법은 버전에 따라 달라질 수 있기 때문이다.


특히 Spring Security는 버전이 올라가면서 설정 방식이 많이 바뀌었다.
예전 방식의 설정 코드를 그대로 따라 하면 현재 버전에서 동작하지 않거나 권장되지 않는 코드가 될 수 있다.
그래서 실습을 시작하기 전에 현재 프로젝트가 어떤 버전 기준으로 실행되는지 확인해야 한다.


현재 실습 환경에서는 Spring Framework 7.0.7, Spring Boot 4.0.6, Spring Security 7.0.5 기준으로 실행된다.
이후 설정 코드는 이 버전 흐름에 맞춰 이해해야 한다.


버전 기준을 맞춰야 설정 문법이 헷갈리지 않는다

Spring Security 설정 코드는 버전에 따라 작성 방식이 달라질 수 있다.
예전 자료에서는 더 이상 권장되지 않는 설정 방식이 나올 수 있고, 현재 버전에서는 람다 형식 설정이 중심이 된다.


따라서 앞으로 나오는 SecurityFilterChain, formLogin(), authorizeHttpRequests() 같은 설정은 현재 실습 버전 기준으로 이해해야 한다.
버전 기준을 먼저 잡아두면, 나중에 예전 설정 방식과 현재 설정 방식이 섞여도 헷갈리지 않는다.


Spring Security가 필요한 이유

직접 보안 코드를 작성하면 생기는 문제

웹 애플리케이션에는 보호해야 할 기능이 있다.
예를 들어 마이페이지, 관리자 페이지, 주문 내역, 회원 정보 수정 화면은 아무나 접근하면 안 된다.
사용자가 로그인했는지 확인해야 하고, 로그인한 사용자에게 해당 기능을 사용할 권한이 있는지도 확인해야 한다.


이 검사를 직접 구현하려면 모든 요청마다 비슷한 보안 코드를 작성해야 한다.
예를 들어 관리자 기능을 처리하는 Controller마다 “현재 사용자가 관리자인가?”를 확인하는 코드를 넣어야 한다.
이 방식은 코드가 길어지고, 같은 검사를 여러 곳에 반복하게 만든다.


더 큰 문제는 실수이다.
어떤 Controller 하나에서 권한 검사를 빠뜨리면 일반 사용자가 관리자 기능에 접근할 수 있다.
보안 처리는 반복 코드가 많을수록 누락 위험이 커진다.


예를 들어 관리자 페이지가 세 개 있다고 생각하면 된다.
회원 관리 화면, 신고 관리 화면, 게시글 삭제 화면이 각각 다른 Controller에 있다면 세 곳 모두에서 관리자 권한을 검사해야 한다.
이 중 한 곳이라도 검사를 빼먹으면 일반 사용자가 관리자 기능을 사용할 수 있는 문제가 생길 수 있다.


Spring Security가 보안 처리를 공통화한다

Spring Security는 보안 검사를 공통 흐름으로 분리한다.
개발자는 “이 주소는 로그인한 사용자만 접근할 수 있다”, “이 주소는 관리자 권한이 필요하다”처럼 규칙을 설정한다.
그러면 Spring Security가 요청을 먼저 검사하고, 통과한 요청만 실제 기능으로 보낸다.


쉽게 말하면 Spring Security는 건물 입구의 보안 게이트와 비슷하다.
건물 안쪽의 각 사무실마다 출입 검사를 따로 두는 것이 아니라, 입구에서 먼저 출입 가능 여부를 확인한다.
웹 애플리케이션에서도 요청이 실제 기능으로 들어가기 전에 앞단에서 먼저 검사하는 구조가 더 안전하다.


정리하면 Spring Security가 담당하는 대표 기능은 다음과 같다.

  • 로그인 처리를 담당한다.
  • 로그아웃 처리를 담당한다.
  • 로그인 상태를 유지하기 위한 세션 관리를 담당한다.
  • 사용자 권한을 확인한다.
  • 보호된 리소스에 접근할 수 있는지 검사한다.
  • 인증되지 않은 사용자를 로그인 화면으로 보낸다.

이 기능들은 따로따로 움직이는 것이 아니다.
사용자 요청이 들어오면 먼저 로그인 여부를 확인하고, 로그인되어 있다면 해당 사용자가 요청한 기능을 사용할 수 있는지 다시 확인한다.
이 흐름이 Spring Security의 기본 보안 처리 흐름이다.


인증과 인가

Authentication은 사용자가 누구인지 확인하는 과정이다

Authentication은 인증이라는 뜻이다.
인증은 사용자가 시스템에 접근할 때, 이 사용자가 등록된 사용자인지 확인하는 과정이다.
가장 대표적인 예시는 로그인이다.


사용자가 아이디와 비밀번호를 입력하면 서버는 저장된 사용자 정보와 비교한다.
아이디가 존재하고 비밀번호도 맞으면 서버는 이 사용자를 유효한 사용자로 인정한다.
이 과정이 인증이다.


일상 예시로 보면 출입증 확인과 비슷하다.
건물 입구에서 출입증을 보여주면 시스템은 “이 사람이 등록된 사람인가?”를 먼저 확인한다.
이 확인이 끝나야 건물 안으로 들어갈 수 있다.


웹 사이트에서는 로그인 화면이 이 역할을 한다.
사용자가 아이디와 비밀번호를 입력하면 서버는 “이 아이디가 실제로 존재하는가?”, “비밀번호가 맞는가?”를 확인한다.
이 검사를 통과하면 사용자는 인증된 사용자로 처리된다.


쉽게 말하면 인증은 “너 누구야?”에 대한 확인이다.
Spring Security에서는 이 인증 과정이 먼저 수행된다.
인증이 성공해야 다음 단계인 인가로 넘어갈 수 있다.


Authorization은 어디까지 접근할 수 있는지 확인하는 과정이다

Authorization은 인가라는 뜻이다.
인가는 인증된 사용자가 특정 기능이나 리소스에 접근할 수 있는지 확인하는 과정이다.


로그인에 성공했다고 해서 모든 기능을 사용할 수 있는 것은 아니다.
일반 사용자는 일반 사용자 기능만 사용할 수 있고, 관리자는 관리자 기능까지 사용할 수 있다.
이처럼 사용자에게 허용된 범위를 확인하는 과정이 인가이다.


예를 들어 어떤 사이트에 일반 사용자와 관리자가 있다고 생각하면 된다.
일반 사용자는 자신의 정보 조회와 게시글 작성은 할 수 있다.
하지만 전체 회원 삭제나 신고 처리 같은 관리자 기능은 사용할 수 없다.
로그인에 성공한 사용자라도 권한이 부족하면 해당 기능에 접근하지 못해야 한다.
이 검사가 인가이다.


일상 예시로 보면 회사 건물 출입과 비슷하다.
출입증으로 본인 확인을 마쳤더라도 모든 회의실이나 서버실에 들어갈 수 있는 것은 아니다.
일반 직원은 일반 사무 공간에만 들어갈 수 있고, 서버실은 권한이 있는 담당자만 들어갈 수 있다.
이때 “어디까지 들어갈 수 있는가?”를 확인하는 과정이 인가이다.


쉽게 말하면 인가는 “너 이거 해도 돼?”에 대한 확인이다.
그래서 인증과 인가는 비슷해 보이지만 역할이 다르다.


먼저 Authentication으로 사용자가 누구인지 확인한다.
그 다음 Authorization으로 인증된 사용자가 요청한 기능에 접근할 수 있는지 확인한다.


인증과 인가의 차이

인증은 사용자 자체를 확인하는 과정이다.
로그인할 때 아이디와 비밀번호를 검사하는 흐름이 인증에 해당한다.


인가는 사용자의 권한을 확인하는 과정이다.
로그인한 사용자가 일반 사용자인지, 관리자 권한을 가진 사용자인지에 따라 접근 가능한 기능을 나누는 흐름이 인가에 해당한다.


예를 들어 로그인 화면에서 user01과 비밀번호를 입력해 로그인에 성공하는 것은 인증이다.
그 다음 user01이 관리자 페이지에 접근하려고 할 때, 이 사용자가 관리자 권한을 가지고 있는지 확인하는 것은 인가이다.


인증은 등록된 사용자인지 확인하는 과정이고, 인가는 인증된 사용자에게 허용된 기능을 판단하는 과정이다.
로그인은 인증에 해당하고, 사용자 등급이나 권한에 따라 기능을 제한하는 것은 인가에 해당한다.


인증은 인가보다 먼저 실행된다.
로그인하지 않은 사용자는 아직 누구인지 확인되지 않았기 때문에 권한 검사 단계로 넘어갈 수 없다.


Principal과 Credential

Principal은 접근하려는 사용자이다

Principal은 보호된 리소스에 접근하려는 주체를 의미한다.
쉽게 말하면 로그인하려는 사용자이다.
로그인 상황에서는 보통 아이디가 Principal 역할을 한다.


예를 들어 사용자가 로그인 폼에 user라는 아이디를 입력했다면, Spring Security는 이 아이디를 기준으로 사용자를 찾는다.
즉, Principal은 “누가 접근하려고 하는가?”를 나타내는 값이다.


사이트 입장에서는 아이디가 있어야 어떤 사용자를 확인해야 하는지 알 수 있다.
비밀번호가 맞는지 검사하려면 먼저 어떤 사용자의 비밀번호와 비교할지 알아야 한다.
그래서 아이디처럼 사용자를 식별하는 정보가 필요하다.


Credential은 사용자를 증명하는 비밀 정보이다

Credential은 사용자가 본인임을 증명하기 위해 제출하는 비밀 정보이다.
로그인 상황에서는 보통 비밀번호가 Credential 역할을 한다.


아이디만 입력했다고 해서 사용자를 인증할 수는 없다.
누구나 다른 사람의 아이디를 입력할 수 있기 때문이다.
그래서 비밀번호 같은 비밀 정보를 함께 확인해야 한다.
즉, Credential은 “그 사람이 맞다는 것을 무엇으로 증명하는가?”를 나타내는 값이다.


예를 들어 은행 계좌번호만 안다고 해서 돈을 인출할 수는 없다.
계좌번호는 대상을 찾는 정보이고, 비밀번호는 본인임을 증명하는 정보이다.
로그인에서도 아이디와 비밀번호는 이와 비슷한 관계로 이해하면 된다.


Principal과 Credential이 함께 필요한 이유

아이디는 사용자를 찾기 위한 정보이다.
비밀번호는 그 사용자가 맞는지 검증하기 위한 정보이다.
아이디만 있으면 사용자를 찾을 수는 있지만 본인 여부를 확인할 수 없다.
비밀번호만 있으면 어떤 사용자의 비밀번호인지 알 수 없다.
그래서 인증에는 Principal과 Credential이 함께 필요하다.


정리하면 다음과 같다.

  • Principal은 “누가 접근하려고 하는가?”를 나타낸다.
  • Credential은 “그 사람이 맞다는 것을 무엇으로 증명하는가?”를 나타낸다.
  • 로그인 흐름에서는 아이디가 Principal, 비밀번호가 Credential 역할을 한다.

Spring Security는 이 두 정보를 바탕으로 로그인 요청을 검사한다.
아이디로 사용자를 찾고, 입력된 비밀번호가 저장된 비밀번호와 맞는지 확인한다.
이 검사가 성공하면 인증이 완료된다.


세션과 쿠키 기반 인증

로그인 상태를 유지해야 하는 이유

사용자가 한 번 로그인했다고 해서 서버가 아무 정보 없이 계속 그 사용자를 기억하는 것은 아니다.
웹 요청은 기본적으로 각각 독립적으로 들어온다.
그래서 서버는 “이 요청이 방금 로그인한 사용자에게서 온 요청인가?”를 구분할 방법이 필요하다.


예를 들어 사용자가 로그인한 뒤 마이페이지로 이동하고, 다시 주문 내역으로 이동한다고 생각하면 된다.
브라우저는 요청을 여러 번 보낸다.
서버가 이전 로그인 상태를 기억하지 못하면 사용자는 페이지를 이동할 때마다 계속 아이디와 비밀번호를 입력해야 한다.
이런 불편을 막기 위해 로그인 상태를 유지하는 방식이 필요하다.


이때 사용되는 방식이 세션과 쿠키이다.
세션은 서버 쪽에서 로그인 상태를 기억하기 위한 공간이다.
쿠키는 브라우저가 서버에 요청을 보낼 때 함께 전달하는 작은 정보이다.


세션과 쿠키가 함께 동작하는 흐름

사용자가 로그인에 성공하면 서버는 로그인 상태를 세션에 저장한다.
그 다음 브라우저는 요청을 보낼 때 세션을 찾을 수 있는 쿠키 정보를 함께 보낸다.
서버는 이 정보를 보고 이전에 로그인한 사용자의 요청인지 확인할 수 있다.


쉽게 말하면 세션은 서버 쪽 보관함이고, 쿠키는 그 보관함을 찾기 위한 표식이다.
브라우저가 쿠키를 보내면 서버는 그 쿠키를 보고 세션을 찾는다.
세션 안에 인증 정보가 있으면 서버는 “이 사용자는 로그인한 사용자다”라고 판단할 수 있다.


즉, 로그인은 한 번만 했지만 이후 요청에서도 로그인 상태가 유지되는 이유는 세션과 쿠키 흐름이 함께 동작하기 때문이다.
Spring Security는 이런 세션 기반 인증 흐름도 함께 관리한다.


Spring Security의 Filter 기반 동작

Filter는 Controller 앞에서 요청을 먼저 검사하는 장치이다

웹 요청은 바로 Controller 메서드로 들어가지 않는다.
요청은 먼저 서버 앞단의 여러 Filter를 통과한다.


Filter는 요청이 실제 처리 로직으로 들어가기 전에 먼저 실행되는 장치이다.
요청을 검사할 수도 있고, 요청을 막을 수도 있고, 다른 곳으로 보낼 수도 있다.
인증 정보가 없으면 요청을 막고 기본 로그인 화면으로 이동시킬 수도 있다.
이런 이동은 redirect, 즉 사용자를 다른 주소로 다시 보내는 처리라고 이해하면 된다.


예를 들어 공항 보안 검색대를 생각하면 된다.
비행기를 타기 전에 탑승구로 바로 가는 것이 아니라, 먼저 보안 검색대를 통과해야 한다.
문제가 없으면 안쪽으로 들어가고, 문제가 있으면 통과할 수 없다.
Filter는 웹 요청에서 이런 검색대 역할을 한다.


보안 처리는 Controller에 도착하기 전에 처리되는 것이 안전하다.
인증되지 않은 사용자가 Controller까지 들어온 뒤 막는 것보다, 앞단에서 먼저 막는 편이 구조적으로 더 안정적이다.


DispatcherServlet보다 앞에서 동작한다

Spring Security는 Spring MVC와 완전히 같은 위치에서 동작하지 않는다.
DispatcherServlet보다 앞단의 Filter 구간에서 먼저 동작한다.
DispatcherServlet은 들어온 요청을 어떤 Controller가 처리할지 찾아주는 Spring MVC의 중앙 입구이다.
그런데 Spring Security는 이 입구보다 먼저 요청을 검사한다.


요청은 Controller로 바로 가지 않는다.
먼저 Filter를 지나고, 그 과정에서 Spring Security의 보안 필터들이 인증과 인가를 검사한다.
검사를 통과한 요청만 DispatcherServlet을 거쳐 알맞은 Controller와 메서드로 전달된다.


Spring Security는 DispatcherServlet으로 요청이 넘어가기 전에 보안 필터를 통해 요청을 먼저 검사한다.
그래서 각 Controller마다 로그인 여부를 반복해서 검사하지 않아도 된다.


Spring Security 기본 개념 정리

Spring Security는 인증, 인가, 세션 관리, 로그아웃, 권한 검사를 담당하는 보안 프레임워크이다.
인증은 사용자가 누구인지 확인하는 과정이고, 인가는 인증된 사용자가 특정 기능을 사용할 수 있는지 확인하는 과정이다.


인증에는 Principal과 Credential이 필요하다.
로그인 흐름에서는 아이디가 Principal, 비밀번호가 Credential 역할을 한다.


Spring Security는 Filter 기반으로 동작한다.
요청이 Controller에 도착하기 전에 먼저 보안 필터가 요청을 검사한다.
이 구조 덕분에 보안 검사를 애플리케이션 앞단에서 공통으로 처리할 수 있다.



security1: 의존성만 추가해도 적용되는 기본 보안

security1은 Spring Security를 직접 설정하기 전에 기본 자동 보안이 어떻게 동작하는지 확인하는 예제이다.
이 단계에서는 로그인 화면을 직접 만들지 않는다.
사용자 정보도 직접 등록하지 않는다.
그런데도 Spring Security 의존성을 추가하고 프로젝트를 실행하면 기본 로그인 화면과 기본 계정이 자동으로 제공된다.


security1의 목적은 코드를 많이 작성하는 것이 아니다.
의존성만 추가해도 Spring Security의 기본 보안 설정이 자동으로 적용된다는 사실을 확인하는 것이다.


프로젝트 생성과 실행 환경 확인

Spring Initializr에서 security1 프로젝트 만들기

security1 예제는 Spring Initializr에서 프로젝트를 생성하는 것부터 시작한다.
프로젝트 이름은 security1로 지정한다.


의존성에는 Spring Web과 Spring Security를 추가한다.
Spring Web은 웹 요청을 처리하기 위해 필요하다.
Spring Security는 인증과 인가를 적용하기 위해 필요하다.


security1 프로젝트에는 웹 요청 처리를 위한 Spring Web과 보안 처리를 위한 Spring Security를 함께 추가한다.
이 두 의존성을 추가하면 웹 애플리케이션 실행과 기본 보안 적용을 함께 확인할 수 있다.


Spring Security 의존성이 없으면 웹 요청은 일반적인 Spring MVC 흐름으로 처리된다.
하지만 Spring Security 의존성이 추가되면 애플리케이션 시작 시 보안 관련 자동 설정이 함께 적용된다.


별도 설정을 작성하지 않은 security1 상태에서는 기본적으로 모든 요청에 인증이 필요하다.
또한 로그인 화면도 자동으로 제공된다.
그래서 개발자가 로그인 화면을 만들지 않아도 /login 경로로 접근하면 기본 로그인 화면을 볼 수 있다.


기본 자동 설정에는 화면에서 로그인하는 formLogin() 방식과 요청 헤더를 통해 인증 정보를 전달하는 httpBasic() 방식이 함께 포함된다.
다만 security1에서는 브라우저에서 확인하는 기본 로그인 화면을 중심으로 흐름을 확인한다.


IntelliJ IDEA에서 Gradle JVM 확인하기

프로젝트를 만든 뒤에는 IntelliJ IDEA에서 실행 환경을 확인한다.
특히 Gradle JVM 설정을 확인해야 한다.


Gradle JVM은 Gradle이 프로젝트를 빌드하고 실행할 때 사용할 JDK를 의미한다.
프로젝트가 Java 21 기준이라면 Gradle JVM도 Java 21로 맞추는 것이 안전하다.


Gradle JVM은 프로젝트 빌드와 실행에 사용되는 JDK 설정이다.
프로젝트에서 사용하는 Java 버전과 맞지 않으면 실행이나 빌드 과정에서 문제가 생길 수 있으므로 먼저 확인한다.


이 단계는 보안 설정 자체를 작성하는 단계는 아니다.
하지만 실행 환경이 맞지 않으면 프로젝트가 정상적으로 실행되지 않을 수 있다.
그러면 나중에 문제가 생겼을 때 보안 설정 문제인지, 실행 환경 문제인지 구분하기 어렵다.


그래서 Spring Security 동작을 확인하기 전에 먼저 프로젝트가 정상 실행될 수 있는 JDK 환경인지 확인해야 한다.


Security1Application 실행 클래스

Security1Application은 프로젝트 실행 시작 클래스이다

Security1Application은 security1 프로젝트를 실행하는 시작 클래스이다.
이 파일에는 직접 작성한 보안 설정이 없다.
main() 메서드에서 SpringApplication.run()을 호출해 Spring Boot 애플리케이션을 실행하는 기본 구조만 있다.

// Security1Application.java
package com.example.security1;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Security1Application {
    public static void main(String[] args) {
        // Spring Boot 애플리케이션을 실행한다.
        SpringApplication.run(Security1Application.class, args);
    }
}

이 코드에는 SecurityFilterChain, formLogin(), 사용자 계정 설정 코드가 없다.
그런데도 프로젝트를 실행하면 로그인 화면이 나타난다.
이유는 Spring Security 의존성이 추가되어 있고, Spring Boot가 기본 보안 자동 설정을 적용하기 때문이다.


@SpringBootApplication의 역할

@SpringBootApplication은 Spring Boot 애플리케이션의 시작 지점을 표시한다.
이 어노테이션이 붙은 클래스는 컴포넌트 탐색, 자동 설정, 설정 클래스 등록의 중심 역할을 한다.


현재 예제에서는 이 실행 클래스만 있어도 애플리케이션이 시작된다.
그리고 의존성에 포함된 Spring Security 자동 설정이 함께 적용된다.


여기서 중요한 점은 실행 클래스가 보안 설정을 직접 담고 있지 않다는 것이다.
Security1Application은 애플리케이션을 시작할 뿐이다.
보안 설정은 Spring Security 의존성과 Spring Boot 자동 설정에 의해 기본값으로 적용된다.


자동 설정으로 적용되는 기본 보안

기본 보안 자동 설정의 의미

Spring Security 관련 의존성이 추가되면 애플리케이션이 실행될 때 보안 관련 설정이 자동으로 수행된다.
이 자동 설정 덕분에 별도의 보안 설정 코드를 작성하지 않아도 기본적인 웹 보안 기능이 현재 시스템에 연동된다.


자동 설정 상태에서 기본적으로 적용되는 내용은 다음과 같다.

  • 모든 요청에 대해 인증 여부를 검증한다.
  • 인증이 승인되어야 자원에 접근할 수 있다.
  • 기본 로그인 방식으로 formLogin()을 제공한다.
  • 기본 인증 방식으로 httpBasic()도 제공한다.
  • 인증을 시도할 수 있는 로그인 페이지가 자동으로 생성된다.
  • 기본 사용자 계정이 하나 제공된다.

이때 기본 사용자명은 user이다.
비밀번호는 고정되어 있지 않고, 서버가 실행될 때 콘솔에 자동 생성되어 출력된다.


formLogin과 httpBasic의 차이

formLogin()은 사용자가 브라우저 화면에서 사용자명과 비밀번호를 입력해 로그인하는 방식이다.
security1에서 /login으로 접근했을 때 보이는 기본 로그인 화면이 이 흐름에 해당한다.


httpBasic()은 별도의 로그인 화면을 보여주는 방식이 아니다.
브라우저나 API 도구가 제공하는 기본 인증 창이나 요청 헤더를 통해 사용자명과 비밀번호를 전달하는 방식이다.
이 방식은 HTTP 표준에 있는 가장 단순한 인증 방식이다.


둘 다 인증 방식이지만 사용되는 모습이 다르다.
formLogin()은 화면 기반 로그인이고, httpBasic()은 요청 헤더 기반 로그인이라고 이해하면 된다.


security1에서는 기본 로그인 화면을 확인하는 것이 목적이므로 formLogin() 흐름을 중심으로 보면 된다.
httpBasic()은 자동 설정에 함께 포함되지만, 실제 웹 서비스 로그인 화면을 만들 때 중심이 되는 흐름은 보통 formLogin()이다.


직접 만든 보안 설정이 아니라 자동 보안이다

이 흐름은 개발자가 보안 설정을 전혀 하지 않았는데도 보안이 걸리는 이유를 보여준다.
즉, security1에서 확인하는 보안은 개발자가 직접 만든 보안이 아니라, Spring Boot와 Spring Security가 제공하는 기본 자동 보안이다.


이 단계에서 중요한 것은 “코드가 없는데 왜 보안이 동작하지?”라는 의문을 해결하는 것이다.
정답은 의존성이다.
Spring Security 의존성이 추가되었기 때문에 Spring Boot가 기본 보안 설정을 자동으로 구성한다.


예를 들어 집에 자동 잠금장치가 설치되어 있으면, 문을 닫았을 때 직접 자물쇠를 걸지 않아도 기본 잠금이 걸릴 수 있다.
security1의 보안도 이와 비슷하다.
개발자가 보안 설정 파일을 직접 만들지 않아도, Spring Security 의존성이 들어오는 순간 기본 보안 장치가 자동으로 켜진다.


자동 생성 비밀번호 확인

기본 계정은 user이다

Spring Security 의존성을 추가하고 별도의 사용자 설정을 하지 않으면 기본 사용자 계정이 자동으로 제공된다.
기본 사용자명은 user이다.


하지만 비밀번호는 고정되어 있지 않다.
프로젝트를 실행할 때 콘솔에 자동 생성된 비밀번호가 출력된다.
이 비밀번호를 사용해야 기본 로그인 화면에서 로그인할 수 있다.


콘솔에서 Using generated security password 문장을 찾으면 자동 생성된 비밀번호를 확인할 수 있다.
이 비밀번호는 기본 사용자 user로 로그인할 때 사용한다.


자동 생성 비밀번호는 현재 실행 중인 콘솔에 출력된 값을 사용해야 한다.
이전 실행에서 봤던 비밀번호를 그대로 입력하면 로그인에 실패할 수 있다.


자동 생성 비밀번호를 사용할 때 주의할 점

자동 생성 비밀번호는 개발용 기본 비밀번호이다.
실행할 때마다 새로 출력될 수 있으므로 이전에 사용한 비밀번호를 그대로 입력하면 로그인에 실패할 수 있다.
현재 실행 중인 콘솔에 출력된 값을 확인하고 그 값을 로그인 화면에 입력해야 한다.


자동 생성 비밀번호가 콘솔에 출력되는 이유는 아직 개발자가 사용자 정보를 직접 정의하지 않았기 때문이다.
실제 서비스에서는 실행할 때마다 바뀌는 임시 비밀번호를 사용하지 않는다.
사용자 정보를 직접 관리하거나, DB에 저장된 회원 정보를 기준으로 인증을 처리한다.


즉, 자동 생성 비밀번호는 실제 회원용 비밀번호가 아니다.
Spring Security가 처음 적용되었는지 확인하기 위한 개발용 기본 비밀번호이다.
그래서 이후 예제에서는 이 자동 비밀번호 대신 개발자가 직접 사용자 정보를 정의하는 흐름으로 넘어간다.


기본 로그인 화면 확인

/login으로 접근하면 기본 로그인 화면이 나온다

프로젝트를 실행한 뒤 브라우저에서 /login 경로로 접근하면 기본 로그인 화면이 나타난다.
이 로그인 화면은 직접 만든 화면이 아니다.
Spring Security가 자동으로 제공하는 화면이다.


사용자명에는 user를 입력한다.
비밀번호에는 콘솔에 출력된 자동 생성 비밀번호를 입력한다.
입력값이 맞으면 인증이 성공한다.


기본 로그인 화면에서는 사용자명 user와 콘솔에 출력된 자동 생성 비밀번호를 입력한다.
이 단계에서는 로그인 화면도, 사용자 정보도 직접 만들지 않았지만 Spring Security 자동 설정으로 인증 흐름이 동작한다.


기본 로그인 화면이 생기는 이유

Spring Security는 기본적으로 폼 로그인 방식을 제공한다.
폼 로그인은 사용자가 화면에서 사용자명과 비밀번호를 입력해 로그인하는 방식이다.


개발자가 별도의 로그인 페이지를 만들지 않으면 Spring Security가 기본 로그인 페이지를 생성한다.
그래서 security1에서는 로그인 페이지 파일이 없어도 로그인 화면을 볼 수 있다.


이 흐름에서 중요한 점은 /login 요청을 내가 만든 Controller가 처리하지 않았다는 것이다.
현재 프로젝트에는 로그인 화면용 Controller나 HTML 파일이 없다.
그런데도 로그인 화면이 보이는 이유는 Spring Security가 기본 로그인 화면을 자동으로 만들어 주기 때문이다.


로그인 후 Whitelabel Error Page가 나타나는 이유

로그인 성공과 페이지 존재 여부는 다른 문제이다

로그인에 성공하면 인증은 통과한 것이다.
하지만 인증이 성공했다고 해서 요청한 페이지가 반드시 존재하는 것은 아니다.


현재 security1 프로젝트에는 / 경로를 처리하는 Controller나 화면이 없다.
그래서 로그인은 성공했지만, 이동한 경로를 처리할 대상이 없어 404 오류가 발생한다.
이때 보이는 화면이 Whitelabel Error Page이다.


로그인 인증은 성공했지만 / 요청을 처리할 페이지나 Controller가 없어서 404 오류가 발생한다.
이 화면은 로그인 실패 화면이 아니라, 인증 후 이동한 경로에 처리 대상이 없다는 의미이다.


404 오류를 로그인 실패로 오해하면 안 된다

Whitelabel Error Page가 보이면 무조건 로그인 실패라고 생각하면 안 된다.
이 예제에서는 로그인 자체는 성공했다.
문제는 로그인 후 이동한 경로에 매핑된 페이지가 없다는 점이다.


즉, 인증 문제와 페이지 매핑 문제를 구분해야 한다.
인증 문제라면 로그인 화면으로 다시 돌아가거나 실패 메시지가 나타난다.
반면 404는 요청한 주소를 처리할 대상이 없을 때 발생한다.


예를 들어 건물 출입증 검사는 통과했지만, 들어가려는 방이 아직 만들어져 있지 않은 상황과 비슷하다.
출입증 검사는 성공했으므로 인증은 성공이다.
하지만 목적지인 방이 없기 때문에 이동할 수 없는 것이다.
security1의 Whitelabel Error Page도 이와 같은 흐름이다.


security1의 Whitelabel Error Page는 보안 설정 실패가 아니라, 인증 성공 후 이동할 화면이 아직 없기 때문에 발생한 결과이다.


security1에서 확인한 핵심 흐름

실행 흐름 정리

security1에서는 Spring Security 의존성만 추가해도 기본 보안 설정이 자동으로 적용된다는 것을 확인한다.
직접 로그인 화면을 만들지 않아도 /login으로 접근하면 기본 로그인 화면이 나타난다.
직접 사용자 정보를 등록하지 않아도 기본 사용자 user가 제공된다.
비밀번호는 서버 실행 시 콘솔에 자동 생성되어 출력된다.


이 흐름을 순서대로 정리하면 다음과 같다.

  • Spring Initializr에서 Spring Web과 Spring Security 의존성을 추가한다.
  • IntelliJ IDEA에서 Gradle JVM을 프로젝트의 Java 버전에 맞게 확인한다.
  • Security1Application을 실행한다.
  • Spring Boot가 Spring Security 자동 설정을 적용한다.
  • 기본적으로 모든 요청에 인증이 필요해진다.
  • /login으로 접근하면 기본 로그인 화면이 나타난다.
  • 사용자명 user와 콘솔의 자동 생성 비밀번호로 로그인한다.
  • 로그인 인증은 성공한다.
  • 하지만 / 경로를 처리하는 화면이나 Controller가 없으면 Whitelabel Error Page가 나타난다.

이 예제의 핵심은 Controller나 보안 설정 코드를 직접 작성하지 않았는데도 기본 보안 흐름이 동작한다는 점이다.


security1의 한계와 다음 단계

security1은 기본 자동 설정을 확인하는 예제이다.
이 방식만으로는 실제 서비스 사용자 정보를 관리하기 어렵다.
사용자명도 기본값이고, 비밀번호도 실행할 때 자동 생성되는 임시 값이다.


또한 어떤 경로는 누구나 접근 가능하게 하고, 어떤 경로는 로그인한 사용자만 접근 가능하게 하는 세밀한 설정도 아직 하지 않았다.
그래서 다음 단계에서는 자동 생성 사용자 대신 개발자가 직접 사용자 정보를 정의한다.


이 흐름이 security2이다.
security2에서는 InMemoryUserDetailsManager나 설정 파일을 사용해 사용자명, 비밀번호, 권한을 직접 등록하는 방식으로 넘어간다.




Spring Security 필터 처리 구조

Spring Security는 요청이 Controller에 도착한 뒤에 보안을 검사하는 방식이 아니다.
요청이 Spring MVC의 중앙 입구인 DispatcherServlet으로 넘어가기 전에 먼저 보안 검사를 수행한다.


이 구조를 이해해야 뒤에서 나오는 SecurityFilterChain, formLogin(), UserDetailsService, 권한 검사 흐름을 자연스럽게 이해할 수 있다.
Spring Security는 하나의 코드가 모든 보안을 처리하는 방식이 아니라, 여러 Filter가 순서대로 요청을 검사하는 방식으로 동작한다.


Spring Security의 보안 처리는 Filter 기반으로 동작하며, DispatcherServlet보다 앞에서 요청을 먼저 검사한다.


Servlet Filter 기반으로 동작하는 이유

Filter는 Controller로 가기 전에 요청을 먼저 검사한다

Filter는 웹 요청이 실제 기능을 처리하는 Controller로 들어가기 전에 먼저 실행되는 장치이다.
요청을 검사할 수도 있고, 요청을 막을 수도 있고, 다른 주소로 보낼 수도 있다.


보안 처리는 실제 기능보다 먼저 실행되는 것이 안전하다.
예를 들어 로그인하지 않은 사용자가 마이페이지에 접근하려고 한다면, 마이페이지를 처리하는 Controller까지 요청이 들어간 뒤 막는 것보다 앞단에서 먼저 막는 편이 더 안정적이다.


Spring Security는 이 Filter 구조를 이용한다.
그래서 로그인 여부, 권한 여부, 로그아웃 요청, 인증 실패 처리 같은 보안 흐름이 Controller 코드와 분리되어 먼저 실행된다.


요청은 기존 FilterChain을 지나면서 여러 Filter를 차례대로 통과한다.
Spring Security도 이 흐름 안에 들어와 보안 검사를 먼저 수행한다.


Spring MVC와 분리되어 동작한다

Spring MVC에서 요청을 실제 Controller로 연결해 주는 중심 객체는 DispatcherServlet이다.
DispatcherServlet은 요청 주소를 보고 어떤 Controller의 어떤 메서드가 처리할지 찾아준다.


하지만 Spring Security는 이 DispatcherServlet보다 앞에서 동작한다.
즉, 요청이 Controller를 찾는 단계에 도착하기 전에 이미 보안 검사를 받을 수 있다.


이 구조 덕분에 인증되지 않은 요청은 Controller까지 가지 않고 로그인 화면으로 이동할 수 있다.
권한이 부족한 요청도 실제 기능 실행 전에 차단할 수 있다.


쉽게 말하면 DispatcherServlet은 건물 내부의 안내 데스크이고, Spring Security Filter는 건물 입구의 보안 검색대이다.
안내 데스크가 어느 사무실로 갈지 알려주기 전에, 보안 검색대가 먼저 출입 가능한 사람인지 확인하는 구조이다.


인증되지 않은 요청은 로그인 화면으로 이동한다

Spring Security가 적용된 상태에서 인증이 필요한 리소스에 접근하면 먼저 보안 필터가 요청을 확인한다.
인증 정보가 없으면 요청을 그대로 통과시키지 않는다.
대신 로그인 화면으로 이동시킨다.


이때 이동은 redirect로 처리된다.
redirect는 사용자를 다른 주소로 다시 보내는 방식이다.
예를 들어 /mypage에 접근하려 했지만 로그인하지 않은 상태라면, Spring Security가 /login으로 보내는 식이다.


기본 설정에서는 내장 로그인 페이지가 제공된다.
로그아웃도 /logout 요청을 통해 기본 로그아웃 기능을 사용할 수 있다.


정리하면 기본 흐름은 다음과 같다.

  • 사용자가 보호된 리소스를 요청한다.
  • 요청이 DispatcherServlet에 도착하기 전에 Spring Security Filter를 지난다.
  • 인증 정보가 없으면 로그인 화면으로 이동한다.
  • 인증 정보가 있으면 권한을 확인한다.
  • 권한까지 통과하면 요청이 DispatcherServlet으로 넘어간다.

이 흐름 때문에 각 Controller마다 로그인 여부를 직접 검사하지 않아도 된다.
보안 검사는 앞단의 Filter 구조에서 공통으로 처리된다.


DelegatingFilterProxy의 역할

Servlet Filter와 Spring Bean은 관리 주체가 다르다

여기서 한 가지 중요한 문제가 있다.
Filter는 원래 Servlet Container가 관리하는 영역이다.
반면 Spring에서 사용하는 객체는 보통 Spring Container가 관리하는 Spring Bean이다.


Servlet Container는 웹 요청과 응답을 처리하는 서버 쪽 실행 환경이다.
Spring Container는 Spring 애플리케이션 안에서 객체를 만들고 관리하는 공간이다.


즉, 요청은 Servlet Filter 쪽으로 먼저 들어오지만, 실제 보안 로직에 필요한 객체들은 Spring Bean으로 관리된다.
관리 주체가 다르기 때문에 둘 사이를 연결해 주는 중간 객체가 필요하다.


DelegatingFilterProxy는 Servlet Filter와 Spring Bean을 연결한다

DelegatingFilterProxy는 Servlet Filter와 Spring Bean 사이를 연결하는 매개체이다.
이름 그대로 직접 모든 보안 처리를 하는 것이 아니라, 실제 보안 처리를 담당하는 Spring Bean에게 작업을 위임한다.


쉽게 말하면 DelegatingFilterProxy는 접수 창구 같은 역할이다.
외부 요청은 먼저 이 창구로 들어온다.
그리고 실제 처리는 내부의 보안 담당 객체에게 넘긴다.


이 구조 덕분에 Spring Security는 Servlet Filter 흐름 안에서 동작하면서도, 내부 보안 객체들은 Spring Bean으로 관리할 수 있다.


DelegatingFilterProxy는 Servlet Filter 위치에서 요청을 받지만, 실제 처리는 Spring Bean으로 등록된 보안 객체에게 넘긴다.
그래서 Spring Security는 웹 요청 앞단에서 동작하면서도 Spring의 객체 관리 기능을 함께 사용할 수 있다.


DelegatingFilterProxy가 필요한 이유

Spring Security 설정을 작성하면 여러 보안 관련 객체가 Spring Bean으로 만들어진다.
하지만 웹 요청은 먼저 Servlet Filter 체인으로 들어온다.


만약 두 구조를 연결하지 못하면 Spring Bean으로 만들어진 보안 객체가 요청 앞단에서 동작하기 어렵다.
그래서 DelegatingFilterProxy가 필요하다.


정리하면 역할은 다음과 같다.

  • 웹 요청은 먼저 Servlet Filter 흐름으로 들어온다.
  • DelegatingFilterProxy가 요청을 받는다.
  • 실제 보안 처리는 Spring Bean으로 등록된 보안 객체에게 넘긴다.
  • 그 결과 Spring Security가 Filter 기반으로 동작할 수 있다.

이 흐름을 알면 Spring Security가 왜 Spring MVC보다 앞에서 동작하면서도 Spring Bean 설정을 사용할 수 있는지 이해할 수 있다.


FilterChainProxy와 SecurityFilterChain

FilterChainProxy는 실제 보안 필터 묶음을 실행한다

DelegatingFilterProxy가 요청을 넘기면, 실제 Spring Security 보안 필터 흐름을 실행하는 객체가 등장한다.
그 객체가 FilterChainProxy이다.


FilterChainProxy는 요청에 맞는 SecurityFilterChain을 찾아 실행한다.
여기서 SecurityFilterChain은 Spring Security가 사용하는 보안 필터 묶음이다.


즉, DelegatingFilterProxy는 연결 입구이고, FilterChainProxy는 실제 보안 필터 묶음을 실행하는 중심 객체라고 이해하면 된다.


요청은 DelegatingFilterProxy를 통해 FilterChainProxy로 전달된다.
FilterChainProxy는 요청에 맞는 SecurityFilterChain을 선택하고, 그 안에 들어 있는 보안 필터들을 순서대로 실행한다.


SecurityFilterChain은 보안 필터들의 묶음이다

SecurityFilterChain은 여러 보안 필터가 순서대로 묶여 있는 구조이다.
로그인, 로그아웃, 인증 정보 저장, 예외 처리, 최종 권한 검사를 하나의 필터가 전부 처리하지 않는다.


각 필터는 맡은 역할이 다르다.
로그아웃 요청을 처리하는 필터가 있고, 로그인 요청을 처리하는 필터가 있다.
인증 정보를 준비하고 정리하는 필터도 있고, 인증되지 않은 사용자를 익명 사용자로 처리하는 필터도 있다.
마지막에는 현재 사용자가 요청한 리소스에 접근할 수 있는지 검사하는 필터도 있다.


이렇게 역할이 나뉘어 있기 때문에 Spring Security는 복잡한 보안 흐름을 단계별로 처리할 수 있다.


SecurityFilterChain 안에는 여러 Security Filter가 들어 있다.
각 필터는 인증, 로그아웃, 예외 처리, 인가 검사처럼 자기 역할을 나누어 수행한다.


요청 흐름을 한 줄로 정리하기

Spring Security의 큰 요청 흐름은 다음처럼 정리할 수 있다.


클라이언트 요청 → DelegatingFilterProxy → FilterChainProxy → SecurityFilterChain → 실제 보안 필터 실행 → DispatcherServlet → Controller


여기서 중요한 점은 DispatcherServlet이 바로 나오지 않는다는 것이다.
요청은 먼저 Spring Security의 보안 필터 흐름을 통과한다.
그 다음에야 Spring MVC의 요청 처리 흐름으로 넘어간다.


DelegatingFilterProxy는 연결 입구이고, FilterChainProxy는 실제 보안 필터 묶음을 실행하며, SecurityFilterChain은 보안 필터들의 모음이다.


여러 SecurityFilterChain을 사용할 수 있는 구조

요청마다 보안 규칙이 달라질 수 있다

모든 요청이 같은 보안 규칙을 사용할 필요는 없다.
예를 들어 화면 요청과 API 요청은 인증 방식이 다를 수 있다.
관리자 요청과 일반 사용자 요청도 접근 조건이 다를 수 있다.


화면 요청은 로그인 화면을 통해 인증하는 formLogin()이 적합할 수 있다.
반면 API 요청은 화면이 없기 때문에 토큰 기반 인증을 사용할 수도 있다.
이처럼 요청 성격이 다르면 보안 규칙도 달라질 수 있다.


그래서 Spring Security는 여러 개의 SecurityFilterChain을 사용할 수 있다.
각 체인은 특정 요청 범위에 맞게 적용될 수 있다.


요청 경로가 다르면 서로 다른 SecurityFilterChain을 적용할 수 있다.
이 구조 덕분에 화면 요청, API 요청, 관리자 요청처럼 성격이 다른 요청에 서로 다른 보안 규칙을 적용할 수 있다.


FilterChainProxy가 알맞은 체인을 선택한다

여러 SecurityFilterChain이 있을 때 모든 체인이 항상 한꺼번에 실행되는 것은 아니다.
FilterChainProxy가 현재 요청에 맞는 체인을 선택한다.


예를 들어 /api/** 요청에는 API용 보안 체인을 적용하고, /admin/** 요청에는 관리자용 보안 체인을 적용하는 방식이 가능하다.
이런 구조는 프로젝트 규모가 커지고 요청 성격이 다양해질수록 중요해진다.


지금 단계에서는 여러 체인을 직접 만들지는 않는다.
다만 Spring Security가 하나의 보안 필터 묶음만 고정적으로 사용하는 것이 아니라, 요청에 따라 다른 보안 필터 묶음을 선택할 수 있다는 점을 이해하면 된다.


기본으로 사용되는 Security Filter

필터 이름을 외우기보다 역할별로 나누어 이해한다

Spring Security에는 기본으로 사용되는 여러 Filter가 있다.
처음부터 이름을 전부 외우려고 하면 어렵다.
처음에는 역할별로 묶어서 이해하는 것이 좋다.


크게 보면 기본 필터들은 세 가지 흐름으로 나눌 수 있다.
첫째, 보안 처리를 위한 기본 환경을 준비하는 필터가 있다.
둘째, 로그인과 로그아웃 같은 인증 관련 필터가 있다.
셋째, 인증된 사용자가 요청한 기능에 접근할 수 있는지 검사하는 인가 관련 필터가 있다.


인프라스트럭처 필터

인프라스트럭처 필터는 보안 처리를 위한 기본 환경을 준비하는 필터이다.
여기서 인프라스트럭처는 다른 보안 필터들이 안정적으로 동작할 수 있게 해 주는 기반이라고 이해하면 된다.


대표 필터는 다음과 같다.

  • DisableEncodeUrlFilter는 URL에 세션 정보가 붙는 방식을 막는 역할을 한다.
  • WebAsyncManagerIntegrationFilter는 비동기 처리에서도 보안 정보가 이어질 수 있게 돕는다.
  • SecurityContextHolderFilter는 요청 처리 중 사용할 보안 정보를 준비하고 정리한다.
  • HeaderWriterFilter는 응답에 보안 관련 헤더를 추가한다.

이 필터들은 로그인 요청 자체를 직접 처리한다기보다, 보안 처리가 안전하게 진행될 수 있는 기본 환경을 만든다.


인증 관련 필터

인증 관련 필터는 사용자가 누구인지 확인하거나 로그인과 로그아웃 흐름을 처리한다.
인증은 “이 사용자가 등록된 사용자인가?”를 확인하는 과정이다.


대표 필터는 다음과 같다.

  • LogoutFilter는 로그아웃 요청을 처리한다.
  • UsernamePasswordAuthenticationFilter는 폼 로그인 요청을 처리한다.
  • RequestCacheAwareFilter는 로그인 전 사용자가 접근하려던 요청을 기억했다가 로그인 후 다시 이어갈 수 있게 돕는다.
  • SecurityContextHolderAwareRequestFilter는 요청 객체에서 보안 관련 기능을 사용할 수 있게 돕는다.

특히 UsernamePasswordAuthenticationFilter는 뒤에서 폼 로그인 흐름을 이해할 때 중요하다.
로그인 화면에서 입력한 사용자명과 비밀번호를 꺼내 인증 흐름으로 넘기는 핵심 필터이다.


인가 관련 필터

인가 관련 필터는 인증된 사용자가 현재 요청에 접근할 권한이 있는지 확인한다.
인가는 “이 사용자가 이 기능을 사용해도 되는가?”를 확인하는 과정이다.


대표 필터는 다음과 같다.

  • AnonymousAuthenticationFilter는 로그인하지 않은 사용자를 익명 사용자로 처리한다.
  • ExceptionTranslationFilter는 인증과 인가 과정에서 발생한 예외를 알맞은 응답으로 바꾼다.
  • AuthorizationFilter는 최종적으로 현재 사용자가 요청한 리소스에 접근할 수 있는지 검사한다.

AuthorizationFilter는 최종 관문에 가깝다.
인증 정보가 있더라도 현재 요청에 필요한 권한이 없으면 접근이 막힐 수 있다.


Spring Security 필터 처리 구조 정리

Spring Security는 Filter 기반으로 동작한다.
요청은 DispatcherServlet에 바로 들어가지 않고, 먼저 보안 필터 흐름을 통과한다.


DelegatingFilterProxy는 Servlet Filter와 Spring Bean을 연결한다.
FilterChainProxy는 요청에 맞는 SecurityFilterChain을 선택하고 실행한다.
SecurityFilterChain은 실제 보안 필터들의 묶음이다.


보안 필터들은 역할별로 나뉜다.
기본 환경을 준비하는 필터, 로그인과 로그아웃을 처리하는 인증 필터, 최종 접근 권한을 확인하는 인가 필터가 순서대로 요청 처리에 참여한다.


Spring Security의 전체 구조는 클라이언트 요청 → DelegatingFilterProxy → FilterChainProxy → SecurityFilterChain → 보안 필터 실행 → DispatcherServlet 흐름으로 이해하면 된다.
이 구조를 이해하면 다음 단계에서 자동 설정이 어떻게 기본 보안 체인을 만들고, 로그인 요청이 어떤 인증 흐름으로 처리되는지 더 쉽게 이해할 수 있다.



자동 설정과 기본 인증 흐름

security1에서는 보안 설정 클래스를 직접 작성하지 않았는데도 기본 로그인 화면이 나타났다.
이것은 우연히 생긴 결과가 아니다.
Spring Security 의존성이 추가되면 Spring Boot가 기본 보안 설정을 자동으로 구성하기 때문이다.


자동 설정은 기본 SecurityFilterChain을 만들고, 모든 요청에 인증을 요구하며, 기본 로그인 화면과 기본 사용자 계정을 준비한다.
이 흐름을 이해해야 security2에서 사용자 정보를 직접 정의했을 때 무엇이 바뀌는지 알 수 있다.


자동 설정은 개발자가 직접 보안 설정을 작성하지 않았을 때 적용되는 기본 보안 흐름이다.


서버 기동 시 기본 보안 설정이 초기화된다

서버가 시작되면 Spring Security 초기화 작업이 진행된다

Spring Security 의존성이 추가된 프로젝트를 실행하면 서버가 기동되면서 보안 초기화 작업이 함께 진행된다.
이때 기본적인 웹 보안 기능이 현재 시스템에 자동으로 연동된다.


이 말은 개발자가 보안 설정 코드를 하나도 작성하지 않아도, 애플리케이션 시작 시점에 기본 보안 설정이 준비된다는 뜻이다.
그래서 security1에서는 직접 로그인 화면을 만들지 않았는데도 기본 로그인 화면이 나타났다.


서버가 기동되면 Spring Security의 초기화 작업과 기본 보안 설정이 이루어진다.
별도의 설정이나 코드를 작성하지 않아도 기본적인 웹 보안 기능이 현재 시스템에 연동된다.


별도 설정이 없으면 기본 사용자 정보가 준비된다

별도의 사용자 설정이 없으면 Spring Security는 기본 사용자 계정을 준비한다.
기본 사용자명은 user이고, 비밀번호는 서버가 기동될 때 랜덤 문자열로 자동 생성된다.


이 기본 계정은 실제 서비스에서 사용하는 계정이 아니다.
처음 Spring Security가 정상적으로 적용되었는지 확인하기 위한 개발용 기본 계정이다.


별도 사용자 설정이 없으면 사용자명은 user로 제공된다.
패스워드는 서버 기동 시 랜덤 문자열로 자동 생성되며, 실행 콘솔에서 확인해야 한다.


자동 설정은 기본 SecurityFilterChain을 만든다

의존성만 추가해도 보안이 동작한 이유

security1에서 별도의 보안 설정 코드를 작성하지 않았는데도 로그인 화면이 나왔다.
그 이유는 Spring Security 의존성이 추가되면 Spring Boot가 기본 보안 설정을 자동으로 구성하기 때문이다.


자동 설정은 기본 SecurityFilterChain을 만든다.
그리고 기본 설정에서는 모든 요청에 인증을 요구한다.
또한 화면에서 로그인하는 formLogin() 방식과 요청 헤더 기반 인증 방식인 httpBasic() 방식도 기본으로 제공된다.


이 말은 보안 코드가 전혀 없는 것이 아니라, 개발자가 직접 작성하지 않았을 뿐 기본 보안 코드가 자동으로 준비되었다는 뜻이다.


Spring Security 의존성이 추가되면 자동 설정이 실행되고, 기본 보안 설정을 만들기 위한 내부 구조가 준비된다.
이 자동 설정 덕분에 security1에서는 별도 설정 없이도 기본 로그인 화면이 동작했다.


SecurityFilterChain을 직접 만들면 자동 기본 체인은 물러난다

자동 설정은 개발자가 아무 설정도 하지 않았을 때 기본값을 제공하는 흐름이다.
하지만 개발자가 SecurityFilterChain 타입의 Bean을 직접 등록하면, 자동 설정으로 만들어지던 기본 체인이 중심이 되지 않는다.


이때부터는 개발자가 HttpSecurity에 작성한 설정이 요청 보안 규칙의 기준이 된다.
예를 들어 뒤에서 /images/**는 누구나 접근하게 하고, 나머지는 로그인한 사용자만 접근하게 만드는 설정을 직접 작성할 수 있다.


SecurityFilterChain을 직접 정의하면 보안이 꺼지는 것이 아니라, 자동 기본 규칙 대신 개발자가 작성한 규칙으로 보안 흐름이 바뀐다.


SecurityBuilder와 HttpSecurity의 역할

SecurityBuilder는 보안 객체를 만드는 빌더이다

Builder는 여러 설정을 차곡차곡 모아서 최종 객체를 만드는 구조이다.
Spring Security에서도 보안 설정을 바로 하나의 객체로 끝내지 않는다.
필요한 설정들을 모으고, 그 설정들을 바탕으로 최종 보안 객체를 만든다.


SecurityBuilder는 이런 보안 객체를 만들기 위한 최상위 빌더 역할을 한다.
웹 보안을 구성하는 설정 객체와 필터 구조를 만드는 데 사용된다.
구현체로는 WebSecurity, HttpSecurity 등이 있다.


처음에는 SecurityBuilder를 직접 다룬다고 생각하지 않아도 된다.
다만 Spring Security 내부에서는 보안 설정을 만들기 위해 빌더 구조를 사용한다는 정도로 이해하면 된다.


HttpSecurity는 웹 요청 보안 설정을 만드는 핵심 객체이다

HttpSecurity는 웹 요청과 관련된 보안 규칙을 설정하는 객체이다.
어떤 요청은 누구나 접근 가능한지, 어떤 요청은 로그인한 사용자만 접근 가능한지, 어떤 로그인 방식을 사용할지 같은 설정을 작성할 때 사용한다.


뒤에서 직접 작성하게 될 설정은 대부분 HttpSecurity를 통해 이루어진다.
예를 들어 /login은 누구나 접근 가능하게 하고, /todos/**는 특정 권한이 있는 사용자만 접근하게 만드는 설정을 HttpSecurity로 작성한다.


즉, HttpSecurity는 웹 요청 보안 규칙을 만드는 설정 도구이다.
그리고 이 설정 결과가 최종적으로 SecurityFilterChain이 된다.


SecurityConfigurer는 세부 보안 기능을 설정한다

SecurityConfigurer는 기능별 설정 담당자이다

SecurityConfigurer는 보안 기능별 세부 설정을 담당한다.
예를 들어 폼 로그인 설정, 로그아웃 설정, 보안 헤더 설정, 세션 관리 설정 등이 각각 세부 설정으로 나뉜다.


이런 설정들은 최종적으로 필터를 만든다.
폼 로그인 설정은 로그인 요청을 처리하는 필터와 연결되고, 로그아웃 설정은 로그아웃 요청을 처리하는 필터와 연결된다.
보안 헤더 설정은 응답에 보안 헤더를 붙이는 필터와 연결된다.


HttpSecurity 안에는 여러 SecurityConfigurer가 들어 있다.
각 설정 객체는 필요한 보안 필터를 만들고, 이 필터들이 모여 최종 보안 처리 흐름을 구성한다.


HttpSecurity는 최종적으로 SecurityFilterChain을 만든다

개발자가 HttpSecurity에 보안 규칙을 작성하면, HttpSecurity는 내부 설정들을 바탕으로 필요한 필터들을 준비한다.
그리고 마지막에 build() 과정을 거치면 SecurityFilterChain이 만들어진다.


즉, 개발자가 작성하는 설정 코드는 단순한 옵션 목록이 아니다.
그 설정은 실제 요청을 검사할 보안 필터 묶음으로 변환된다.


HttpSecurity의 설정은 build() 과정을 거쳐 실제 보안 필터가 들어 있는 SecurityFilterChain으로 완성된다.
그래서 뒤에서 작성하는 보안 설정은 결국 요청을 검사하는 필터 흐름에 영향을 준다.


기본 사용자 자동 설정

UserDetailsServiceAutoConfiguration은 기본 사용자 정보를 준비한다

Spring Security 의존성이 추가되면 Spring Boot는 기본 사용자 정보를 자동으로 준비할 수 있다.
이때 중심이 되는 자동 설정이 UserDetailsServiceAutoConfiguration이다.


이 자동 설정은 사용자 정보 조회용 Bean이 없을 때 동작한다.
그래서 개발자가 별도로 사용자 정보를 등록하지 않으면 기본 사용자명 user와 실행할 때 생성되는 비밀번호가 사용된다.


Spring Boot는 개발자가 사용자 정보 조회용 Bean을 등록하지 않았을 때 기본 사용자 정보를 자동으로 만든다.
기본 자동 설정에서는 UserDetailsServiceAutoConfiguration이 동작하고, 내부적으로 InMemoryUserDetailsManager가 준비된다.
이때 기본 사용자명은 user이고, 비밀번호는 실행할 때 생성된 값이다.


반대로 개발자가 UserDetailsService 계열 Bean이나 InMemoryUserDetailsManager 같은 사용자 정보 조회 객체를 직접 등록하면 자동 설정은 더 이상 중심 설정으로 사용되지 않는다.
그래서 security2에서 InMemoryUserDetailsManager를 직접 등록하면 직접 만든 사용자 정보가 인증 기준이 된다.


기본 사용자 정보는 실습용이다

기본 사용자명 user와 자동 생성 비밀번호는 실제 서비스용 계정이 아니다.
Spring Security가 적용되었는지 빠르게 확인하기 위한 개발용 기본 계정이다.


실제 서비스에서는 사용자가 회원가입을 하고, 그 정보가 DB에 저장된다.
로그인할 때는 입력된 사용자명으로 DB에서 사용자 정보를 조회하고, 저장된 비밀번호와 입력 비밀번호를 비교한다.


하지만 처음부터 DB 인증으로 들어가면 구조가 복잡해진다.
그래서 먼저 security1에서는 자동 기본 사용자를 확인하고, security2에서는 개발자가 직접 사용자 정보를 정의하는 방식으로 넘어간다.


로그인 요청이 처리되는 기본 흐름

로그인 요청은 UsernamePasswordAuthenticationFilter가 먼저 처리한다

사용자가 로그인 화면에서 사용자명과 비밀번호를 입력하면 요청은 바로 Controller로 가지 않는다.
폼 로그인 요청은 UsernamePasswordAuthenticationFilter가 먼저 가로챈다.


이 필터는 요청에서 사용자명과 비밀번호를 꺼낸다.
그 다음 인증에 사용할 Authentication 토큰을 만든다.
여기서 토큰은 인증에 필요한 정보를 잠시 담아 다음 단계로 넘기는 객체라고 이해하면 된다.


폼 로그인 요청은 Controller가 직접 처리하지 않는다.
UsernamePasswordAuthenticationFilter가 요청에서 사용자명과 비밀번호를 꺼내고, ProviderManager와 DaoAuthenticationProvider를 거쳐 사용자 정보 조회와 비밀번호 검증이 진행된다.
인증에 성공하면 인증 정보가 SecurityContextHolder에 저장된다.


AuthenticationManager와 AuthenticationProvider가 인증을 이어서 처리한다

UsernamePasswordAuthenticationFilter가 인증 요청 객체를 만들면, 이 요청은 AuthenticationManager에게 전달된다.
AuthenticationManager는 인증 처리를 시작하는 입구 역할을 한다.


하지만 AuthenticationManager가 모든 검사를 혼자 직접 처리하는 것은 아니다.
실제 검증은 AuthenticationProvider가 담당한다.
기본 폼 로그인에서는 보통 DaoAuthenticationProvider가 사용자 정보를 조회하고 비밀번호를 비교한다.


이 구조가 필요한 이유는 인증 방식이 하나만 있는 것이 아니기 때문이다.
아이디와 비밀번호로 로그인할 수도 있고, 다른 방식으로 인증할 수도 있다.
그래서 인증 요청을 받는 입구와 실제 검증 담당자를 나누어 둔다.


UserDetailsService는 사용자 정보를 찾아오는 역할이다

DaoAuthenticationProvider는 사용자가 입력한 사용자명으로 사용자 정보를 찾아야 한다.
이때 사용하는 객체가 UserDetailsService이다.


UserDetailsService는 사용자명을 받아서 UserDetails 객체를 반환한다.
UserDetails는 Spring Security가 이해할 수 있는 사용자 정보 형태이다.
여기에는 사용자명, 비밀번호, 권한 정보가 들어간다.


처음에는 이 구조를 이렇게 이해하면 된다.

  • 사용자가 로그인 화면에서 사용자명을 입력한다.
  • Spring Security가 그 사용자명으로 사용자 정보를 찾으려고 한다.
  • 사용자 정보를 찾는 역할은 UserDetailsService가 담당한다.
  • 찾은 사용자 정보는 UserDetails 형태로 반환된다.

이 흐름은 security2에서는 메모리 사용자 정보로 연결되고, 나중에는 DB 조회 방식으로 확장된다.


PasswordEncoder는 비밀번호 비교를 담당한다

입력 비밀번호와 저장 비밀번호는 단순 문자열 비교로 끝내지 않는다

실제 서비스에서는 비밀번호를 원문 그대로 저장하지 않는다.
사용자가 입력한 비밀번호는 평문이고, 저장소에 있는 비밀번호는 암호화된 값이어야 한다.
그래서 두 값을 단순 문자열 비교로 검사하지 않는다.


PasswordEncoder는 사용자가 입력한 평문 비밀번호와 저장된 암호화 비밀번호가 같은 의미인지 비교한다.
이때 대표적으로 matches() 메서드가 사용된다.


사용자가 입력한 평문 비밀번호와 저장소에 있는 암호화된 비밀번호는 PasswordEncoder.matches()를 통해 비교된다.
이 비교 과정은 Spring Security 내부의 DaoAuthenticationProvider가 담당하고, 개발자는 암호화된 비밀번호를 담은 UserDetails와 PasswordEncoder Bean을 준비한다.


security2에서는 MySecurityConfig 활성화 시 noop을 사용한다

실제 서비스에서는 BCryptPasswordEncoder처럼 안전한 비밀번호 암호화 방식을 사용해야 한다.
하지만 MySecurityConfig를 활성화해서 메모리 사용자 정보를 테스트할 때는 학습 편의를 위해 {noop}을 사용한다.


현재 코드 기준으로는 MySecurityConfig의 @Configuration이 주석 처리되어 있다.
그래서 현재 그대로 실행하면 {noop}1111을 사용하는 duke 계정이 아니라, application.yml의 unico / 1234 계정이 로그인 기준이 된다.


{noop}은 비밀번호를 암호화하지 않고 그대로 비교하겠다는 표시이다.
예를 들어 MySecurityConfig를 활성화한 상태에서 {noop}1111이라고 작성하면 로그인 화면에서 1111을 입력했을 때 그대로 비교된다.


{noop}은 학습용 예제에서 비밀번호를 그대로 비교하기 위한 표시이고, 실제 서비스에서는 사용하면 안 된다.


인증 성공 정보는 SecurityContextHolder에 저장된다

SecurityContextHolder는 로그인 상태를 기억하는 흐름과 연결된다

인증이 성공하면 Spring Security는 인증 성공 정보를 저장한다.
이 정보가 있어야 이후 요청에서 다시 아이디와 비밀번호를 입력하지 않아도 로그인한 사용자로 판단할 수 있다.


인증 성공 정보는 Authentication 객체에 담긴다.
그리고 이 Authentication 객체는 SecurityContext 안에 저장된다.
SecurityContext는 다시 SecurityContextHolder를 통해 관리된다.


쉽게 말하면 SecurityContextHolder는 현재 보안 정보를 꺼내기 위한 보관 위치라고 이해하면 된다.
이곳에 인증 정보가 있어야 이후 요청에서 사용자가 로그인한 상태인지 확인할 수 있다.


인증 흐름을 전체 순서로 정리한다

로그인 요청이 처리되는 흐름은 다음과 같다.

  • 사용자가 로그인 화면에서 사용자명과 비밀번호를 입력한다.
  • 로그인 요청이 서버로 전송된다.
  • UsernamePasswordAuthenticationFilter가 요청을 가로챈다.
  • 사용자명과 비밀번호로 인증 요청 객체를 만든다.
  • AuthenticationManager가 인증 처리를 시작한다.
  • AuthenticationProvider가 실제 인증 검증을 수행한다.
  • UserDetailsService가 사용자 정보를 조회한다.
  • PasswordEncoder가 입력 비밀번호와 저장 비밀번호를 비교한다.
  • 인증에 성공하면 Authentication 객체가 만들어진다.
  • 인증 정보가 SecurityContextHolder에 저장된다.

이 흐름을 알면 security2에서 사용자 정보를 직접 정의했을 때 왜 InMemoryUserDetailsManager가 필요한지 이해할 수 있다.
사용자 정보를 어디에서 가져오느냐만 바뀌고, 기본 인증 흐름은 Spring Security가 계속 이어서 처리한다.


자동 설정과 기본 인증 흐름 정리

자동 설정은 개발자가 직접 보안 설정을 작성하지 않았을 때 기본 보안 체인을 준비한다.
기본적으로 모든 요청에 인증을 요구하고, 기본 로그인 화면과 기본 사용자 계정을 제공한다.


로그인 요청은 Controller가 직접 처리하지 않는다.
UsernamePasswordAuthenticationFilter가 로그인 요청을 가로채고, AuthenticationManager, AuthenticationProvider, UserDetailsService, PasswordEncoder 흐름을 거쳐 인증을 처리한다.


인증이 성공하면 인증 정보는 SecurityContextHolder에 저장된다.
그래서 이후 요청에서도 로그인 상태를 확인할 수 있다.


security2는 자동 생성 사용자 대신 개발자가 직접 사용자 정보를 정의해서 이 기본 인증 흐름에 연결하는 단계이다.



security2: 사용자 정보를 직접 정의하는 방식

security2는 자동 생성 사용자 대신 개발자가 직접 사용자 정보를 정의하는 예제이다.
security1에서는 기본 사용자명 user와 콘솔에 출력된 자동 생성 비밀번호를 사용했다.
하지만 security2에서는 직접 지정한 사용자 정보로 로그인할 수 있게 만든다.


이 단계의 핵심은 사용자 정보를 어디에서 가져오느냐이다.
자동 설정에서는 Spring Security가 기본 사용자 user를 준비했다.
security2에서는 개발자가 직접 사용자명, 비밀번호, 권한을 정해서 인증 흐름에 연결한다.


security2의 핵심은 자동 생성 사용자에서 벗어나 개발자가 직접 정의한 사용자 정보로 인증을 처리하는 것이다.


security2 프로젝트 실행 구조

Security2Application은 프로젝트 실행 시작 클래스이다

Security2Application은 security2 프로젝트를 실행하는 시작 클래스이다.
이 파일에는 직접적인 보안 설정 코드가 없다.
main() 메서드에서 SpringApplication.run()을 호출해 Spring Boot 애플리케이션을 실행하는 기본 구조만 있다.

// Security2Application.java
package com.example.security2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Security2Application {
    public static void main(String[] args) {
        // Spring Boot 애플리케이션을 실행한다.
        SpringApplication.run(Security2Application.class, args);
    }
}

Security2Application은 애플리케이션을 시작하는 역할만 한다.
사용자 정보를 직접 등록하는 코드는 이 파일에 없다.


사용자 정보는 application.yml에서 설정하거나, MySecurityConfig를 활성화했을 때 InMemoryUserDetailsManager를 통해 등록한다.
따라서 security2에서는 실행 클래스와 사용자 정보 설정 파일을 구분해서 봐야 한다.


security2에서 바뀌는 핵심

security1과 비교한 변화

security1에서는 기본 사용자명 user를 사용했다.
비밀번호는 콘솔에 자동 생성된 값을 사용했다.


security2에서는 사용자를 직접 정의한다.
설정 파일 방식에서는 사용자명 unico, 비밀번호 1234, 권한 USER를 사용한다.
설정 클래스 방식에서는 사용자명 duke, 비밀번호 1111, 권한 ROLE_USER를 사용할 수 있다.


즉, 자동 생성 사용자에서 개발자가 정의한 사용자로 넘어간 것이다.
이것이 security2의 핵심 변화이다.


기본 자동 보안은 테스트용 사용자 정보를 제공한다.
하지만 직접 사용자 정보를 정의하면 원하는 사용자명과 비밀번호, 권한을 가진 계정으로 로그인 흐름을 확인할 수 있다.


사용자 정보를 직접 정의하는 두 가지 방식

security2에서는 사용자 정보를 직접 정의하는 흐름을 확인한다.
방식은 크게 두 가지로 볼 수 있다.


첫 번째는 application.yml에 사용자 정보를 작성하는 방식이다.
이 방식은 코드가 아니라 설정 파일에서 기본 사용자 정보를 지정한다.
간단한 테스트에서는 설정 파일만으로도 기본 사용자 정보를 바꿀 수 있다.


두 번째는 설정 클래스에서 InMemoryUserDetailsManager를 사용하는 방식이다.
이 방식은 사용자 정보를 메모리에 직접 등록한다.
DB 없이 빠르게 로그인 테스트를 할 수 있다.


다만 두 방식 모두 실제 서비스의 최종 구조는 아니다.
실제 서비스에서는 보통 DB에 사용자 정보를 저장하고, 로그인할 때 DB에서 사용자 정보를 조회한다.
security2는 그 단계로 가기 전에 사용자 정보를 직접 바꿔보는 연습 단계이다.


application.yml에서 사용자 정보를 정의한다

application.yml은 기본 사용자 정보를 설정한다

현재 security2의 application.yml에는 애플리케이션 이름, 기본 사용자 정보, 서버 포트가 작성되어 있다.
여기서는 사용자명 unico, 비밀번호 1234, 권한 USER를 가진 사용자를 설정한다.

# application.yml
spring :
    application :
      name : security2
    security :
      user:
        name : unico
        password : 1234
        roles : USER
server:
    port: 8088

이 설정은 security2 애플리케이션의 기본 사용자 정보를 unico / 1234로 바꾼다.
서버 포트는 8088이므로 브라우저에서는 localhost:8088로 접근한다.


spring.security.user 설정의 의미

spring.security.user.name은 기본 사용자명을 의미한다.
여기서는 unico가 사용자명이 된다.


spring.security.user.password는 기본 사용자 비밀번호를 의미한다.
여기서는 1234가 비밀번호가 된다.


spring.security.user.roles는 기본 사용자 권한을 의미한다.
여기서는 USER 권한을 가진다.
roles: USER는 역할 이름을 작성하는 방식이므로, 내부 권한으로는 ROLE_USER처럼 사용된다고 이해하면 된다.


이 방식은 코드에 사용자 객체를 직접 만들지 않아도 된다는 장점이 있다.
하지만 복잡한 사용자 관리에는 적합하지 않다.
설정 파일에 한 명의 기본 사용자를 적어 두는 방식이기 때문이다.


MySecurityConfig에서 사용자 정보를 직접 등록할 수 있다

현재 MySecurityConfig는 비활성화된 상태이다

현재 MySecurityConfig 코드에는 @Configuration이 주석 처리되어 있다.
즉, 현재 상태 그대로 실행하면 이 설정 클래스는 Spring 설정 클래스로 등록되지 않는다.


따라서 현재 실행 기준으로는 MySecurityConfig의 duke / 1111 사용자가 자동으로 적용되지 않는다.
현재 상태에서는 application.yml에 작성된 unico / 1234 설정을 기준으로 로그인한다.

// MySecurityConfig.java
package com.example.security2.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
//@Configuration
public class MySecurityConfig {
    @Bean
    public InMemoryUserDetailsManager inMemoryUserDetailsManager(){
        // Spring Security가 사용할 사용자 정보 생성
        UserDetails user = User.withUsername("duke")
                // 비밀번호를 암호화하지 않고 그대로 비교
                .password("{noop}1111")
                // 사용자에게 ROLE_USER 권한 부여
                .authorities("ROLE_USER")
                // 설정한 정보로 UserDetails 객체 생성
                .build();
        // 메모리에 사용자 정보를 저장하는 객체 반환
        return new InMemoryUserDetailsManager(user);
    }
}

이 코드 자체는 사용자명 duke, 비밀번호 1111, 권한 ROLE_USER를 가진 사용자를 메모리에 등록하는 구조이다.
하지만 @Configuration이 주석 처리되어 있으므로 현재 상태에서는 이 설정이 활성화되지 않는다.


현재 코드 기준으로는 MySecurityConfig가 비활성화되어 있으므로 로그인 기준은 application.yml의 unico / 1234이다.


import가 있어도 @Configuration이 주석이면 설정 클래스가 아니다

현재 코드에는 Configuration과 EnableWebSecurity 관련 import가 남아 있다.
하지만 import는 해당 클래스를 사용할 수 있게 가져오는 문장일 뿐이다.
import가 있다고 해서 설정 클래스가 자동으로 활성화되는 것은 아니다.


Spring이 이 클래스를 설정 클래스로 읽으려면 클래스 위에 @Configuration이 실제로 붙어 있어야 한다.
현재 코드는 // @Configuration처럼 주석 처리되어 있으므로, MySecurityConfig는 설정 클래스로 등록되지 않는다.


그래서 현재 코드 그대로 실행하면 inMemoryUserDetailsManager() 메서드의 @Bean도 등록되지 않는다.
결과적으로 duke / 1111 계정도 현재 실행 기준에서는 로그인 사용자로 사용되지 않는다.


@Configuration을 활성화하면 duke 사용자가 등록된다

@Configuration 주석을 풀면 MySecurityConfig는 설정 클래스로 등록된다.
그러면 @Bean이 붙은 inMemoryUserDetailsManager() 메서드가 실행되고, 반환된 InMemoryUserDetailsManager가 Spring Bean으로 등록된다.


이때부터는 Spring Security가 사용자 정보를 조회할 때 InMemoryUserDetailsManager를 사용할 수 있다.
이 Manager 안에는 duke / 1111 / ROLE_USER 사용자 정보가 들어 있다.


즉, 적용 상태는 다음처럼 구분해야 한다.

  • MySecurityConfig 비활성화 상태: application.yml의 unico / 1234로 로그인한다.
  • MySecurityConfig 활성화 상태: InMemoryUserDetailsManager의 duke / 1111로 로그인한다.

두 방식을 동시에 같은 로그인 기준처럼 설명하면 안 된다.
현재 어떤 설정이 활성화되어 있는지에 따라 실제 로그인 계정이 달라진다.


InMemoryUserDetailsManager는 메모리에 사용자 정보를 저장한다

InMemoryUserDetailsManager의 의미

InMemoryUserDetailsManager는 사용자 정보를 메모리에 저장하는 객체이다.
여기서 메모리는 애플리케이션이 실행되는 동안만 유지되는 임시 저장 공간이다.
프로젝트를 종료하면 메모리에 있던 정보는 사라진다.


그래서 이 방식은 실제 회원 정보를 영구적으로 저장하는 방식은 아니다.
대신 DB를 연결하기 전에 로그인 흐름을 빠르게 확인할 때 사용하기 좋다.


예를 들어 “사용자명 duke, 비밀번호 1111인 사용자가 로그인할 수 있는가?”를 간단히 확인하고 싶을 때 사용할 수 있다.


InMemoryUserDetailsManager는 UserDetailsManager의 구현 클래스이다.
그래서 사용자 정보를 단순히 저장하는 것뿐만 아니라, 사용자를 새로 만들거나 수정하거나 삭제하는 역할도 지원한다.
다만 이 예제에서는 로그인 테스트를 위해 사용자 한 명을 메모리에 등록하는 용도로 사용한다.


InMemoryUserDetailsManager를 사용하면 DB 없이 메모리 안에 사용자 정보를 등록할 수 있다.
이 방식은 로그인 흐름을 빠르게 테스트하기 위한 간단한 사용자 저장 방식이다.


UserDetails는 Spring Security가 이해하는 사용자 정보 형태이다

Spring Security는 아무 객체나 사용자 정보로 사용하지 않는다.
인증에 사용할 사용자 정보는 Spring Security가 이해할 수 있는 형태여야 한다.
그 형태가 UserDetails이다.


UserDetails에는 사용자명, 비밀번호, 권한 같은 인증에 필요한 정보가 들어간다.
로그인할 때 Spring Security는 이 정보를 기준으로 사용자를 확인하고 권한을 판단한다.


즉, 우리가 사용자 정보를 직접 만든다고 해도 최종적으로는 UserDetails 형태로 만들어야 한다.
그래야 Spring Security가 이 사용자를 인증에 사용할 수 있다.


MySecurityConfig 코드 흐름을 자세히 보기

User.withUsername은 사용자명을 설정한다

User.withUsername("duke")는 사용자명을 duke로 설정한다는 뜻이다.
로그인 화면에서 사용자명 입력 칸에 duke를 입력하면, Spring Security는 이 사용자 정보를 찾는다.


여기서 duke는 Principal로 볼 수 있다.
즉, “누가 로그인하려고 하는가?”를 나타내는 값이다.


password는 사용자의 비밀번호를 설정한다

.password("{noop}1111")은 사용자의 비밀번호를 설정하는 부분이다.
실제 입력할 비밀번호는 1111이다.
앞에 붙은 {noop}은 비밀번호를 암호화하지 않고 그대로 비교하겠다는 의미이다.


원래 비밀번호는 암호화해서 저장해야 한다.
하지만 지금은 MySecurityConfig를 활성화했을 때 메모리 사용자 예제를 간단히 확인하기 위해 {noop}을 사용한다.


{noop}은 학습용 예제에서 비밀번호를 그대로 비교하기 위한 표시이다.
실제 서비스에서는 비밀번호를 그대로 저장하면 안 되고, 뒤에서 배우는 PasswordEncoder 같은 암호화 방식을 사용해야 한다.


authorities는 사용자의 권한을 설정한다

.authorities("ROLE_USER")는 이 사용자에게 ROLE_USER 권한을 부여한다는 뜻이다.
권한은 인가 단계에서 사용된다.


로그인 자체는 사용자명과 비밀번호가 맞는지 확인하는 인증 과정이다.
하지만 로그인 후 어떤 기능에 접근할 수 있는지는 권한으로 판단한다.
따라서 사용자 정보에는 권한도 함께 들어가야 한다.


지금은 일반 사용자 권한인 ROLE_USER를 부여한다.
뒤에서 권한 처리 예제로 넘어가면 ADMIN, USER 같은 권한에 따라 화면과 기능을 다르게 제어하게 된다.


build는 설정한 사용자 정보를 완성한다

.build()는 앞에서 설정한 사용자명, 비밀번호, 권한 정보를 바탕으로 UserDetails 객체를 만든다.
즉, Spring Security가 인증에 사용할 수 있는 사용자 정보 형태로 완성하는 단계이다.


정리하면 흐름은 다음과 같다.

  • User.withUsername("duke")로 사용자명을 정한다.
  • .password("{noop}1111")로 비밀번호를 정한다.
  • .authorities("ROLE_USER")로 권한을 정한다.
  • .build()로 UserDetails 객체를 만든다.
  • InMemoryUserDetailsManager에 이 사용자 정보를 등록한다.

이제 MySecurityConfig가 활성화된 상태라면 Spring Security는 로그인 요청이 들어왔을 때 메모리에 등록된 duke 사용자 정보를 기준으로 인증을 처리할 수 있다.


security2 로그인 결과 확인하기

application.yml 사용자로 로그인하기

현재 코드 기준으로 MySecurityConfig는 비활성화되어 있다.
따라서 현재 실행 상태에서는 application.yml에 작성된 사용자 정보가 로그인 기준이 된다.


브라우저에서 localhost:8088/login에 접근한 뒤 사용자명에 unico, 비밀번호에 1234를 입력한다.
입력 정보가 설정 파일의 사용자 정보와 일치하면 인증이 성공한다.


application.yml만 적용한 상태에서는 설정 파일에 작성한 unico / 1234가 기본 사용자 정보로 사용된다.
이 방식은 코드에 사용자 객체를 직접 만들지 않고, 설정 파일만으로 기본 로그인 사용자를 바꾸는 흐름이다.


unico 로그인 성공 후 404 화면이 나올 수 있다

unico / 1234로 로그인한 뒤 Whitelabel Error Page가 나타날 수 있다.
이 화면은 로그인 실패가 아니다.
로그인 인증은 성공했지만, 로그인 후 이동한 / 경로를 처리할 화면이나 Controller가 없기 때문에 404 화면이 나타난 것이다.


unico로 로그인했을 때 보이는 Whitelabel Error Page는 로그인 실패가 아니다.
인증은 성공했지만, 인증 성공 후 이동할 기본 페이지가 아직 없다는 뜻이다.


MySecurityConfig를 활성화한 경우 duke로 로그인한다

MySecurityConfig의 @Configuration 주석을 풀어 활성화하면 로그인 기준이 달라진다.
이 상태에서는 InMemoryUserDetailsManager에 등록된 duke / 1111 사용자를 기준으로 로그인할 수 있다.


브라우저에서 localhost:8088/login에 접근한 뒤 사용자명에 duke, 비밀번호에 1111을 입력한다.


MySecurityConfig에서 duke / 1111 사용자를 메모리에 등록했기 때문에, 설정 클래스를 활성화한 상태에서는 이 계정으로 로그인한다.
입력한 사용자 정보가 InMemoryUserDetailsManager에 등록된 정보와 일치하면 인증이 성공한다.


duke 로그인 성공 후에도 404 화면이 나올 수 있다

duke / 1111로 로그인한 뒤에도 Whitelabel Error Page가 나타날 수 있다.
이 화면 역시 로그인 실패가 아니다.
로그인은 성공했지만, 로그인 후 이동한 / 경로를 처리할 Controller나 화면이 없기 때문에 404가 발생한 것이다.


로그인은 Spring Security 단계에서 성공했다.
하지만 그 다음 Spring MVC 단계에서 / 요청을 처리할 대상이 없어서 404 오류가 발생한다.
따라서 이 결과는 인증 실패가 아니라, 로그인 성공 후 이동할 페이지가 아직 없다는 의미이다.


security2에서 로그인 흐름이 어떻게 바뀌는지 이해한다

security1과 비교한 변화

security1에서는 기본 사용자명 user를 사용했다.
비밀번호는 콘솔에 자동 생성된 값을 사용했다.


security2에서는 사용자를 직접 정의한다.
현재 코드처럼 MySecurityConfig가 비활성화된 상태에서는 application.yml의 unico / 1234를 사용한다.
MySecurityConfig를 활성화하면 duke / 1111 / ROLE_USER 사용자를 메모리에 등록해서 사용할 수 있다.


즉, 자동 생성 사용자에서 개발자가 정의한 사용자로 넘어간 것이다.
이것이 security2의 핵심 변화이다.


security2에서는 기본 자동 생성 사용자 대신 개발자가 직접 정의한 사용자 정보로 로그인 흐름을 확인한다.
사용자 정보는 설정 파일에서 지정할 수도 있고, 설정 클래스를 활성화해 메모리에 등록할 수도 있다.


로그인 테스트에서 확인해야 할 것

security2의 로그인 테스트에서 중요한 것은 단순히 화면이 넘어갔는지가 아니다.
어떤 사용자 정보가 실제 인증에 사용되었는지 확인해야 한다.


현재 코드 기준처럼 MySecurityConfig가 비활성화된 상태라면 unico / 1234로 테스트한다.
MySecurityConfig의 @Configuration을 활성화한 상태라면 duke / 1111로 테스트한다.


로그인 후 Whitelabel Error Page가 보이더라도 그것은 인증 실패가 아니라, 이동한 경로에 처리할 페이지가 없기 때문에 발생한 결과일 수 있다.
그래서 로그인 결과 화면과 인증 기준을 분리해서 봐야 한다.


security2 핵심 정리

사용자 정보를 직접 정의하는 이유

security1의 기본 사용자 정보는 테스트용 자동 설정이다.
실제 프로젝트에서는 사용자명, 비밀번호, 권한을 개발자가 원하는 방식으로 관리해야 한다.
그래서 security2에서는 자동 생성 사용자 대신 직접 정의한 사용자 정보를 사용한다.


처음에는 DB를 연결하지 않고 application.yml에 기본 사용자 정보를 작성할 수 있다.
또는 InMemoryUserDetailsManager로 사용자 정보를 메모리에 등록할 수도 있다.
두 방식 모두 로그인 인증 흐름을 빠르게 확인하기 위한 학습용 구조이다.


반드시 기억해야 할 흐름

  • Security2Application은 security2 프로젝트를 실행하는 시작 클래스이다.
  • 현재 MySecurityConfig는 @Configuration이 주석 처리되어 있어 비활성화 상태이다.
  • import가 남아 있어도 @Configuration이 주석이면 설정 클래스로 등록되지 않는다.
  • 현재 코드 기준 로그인 사용자는 application.yml의 unico / 1234이다.
  • @Configuration을 활성화하면 MySecurityConfig가 설정 클래스로 등록된다.
  • @Bean은 반환 객체를 Spring Bean으로 등록한다.
  • UserDetails는 Spring Security가 이해하는 사용자 정보 형태이다.
  • User.withUsername("duke")는 사용자명을 설정한다.
  • .password("{noop}1111")은 비밀번호를 설정한다.
  • .authorities("ROLE_USER")는 권한을 설정한다.
  • InMemoryUserDetailsManager는 사용자 정보를 메모리에 저장한다.
  • application.yml에서도 기본 사용자 정보를 설정할 수 있다.
  • MySecurityConfig 비활성화 상태에서는 unico / 1234로 확인한다.
  • MySecurityConfig 활성화 상태에서는 duke / 1111로 확인한다.

security2의 핵심은 자동 생성 사용자에서 벗어나 개발자가 직접 정의한 사용자 정보로 인증을 처리하는 것이다.
이 흐름을 이해하면 다음 단계에서 SecurityFilterChain을 직접 설정하고, 요청 경로별 접근 권한을 제어하는 흐름으로 자연스럽게 넘어갈 수 있다.




SecurityFilterChain과 URL 접근 제어 이론

security1과 security2에서는 Spring Security가 기본 보안을 자동으로 적용하는 흐름과 사용자 정보를 직접 정의하는 흐름을 확인했다.
이제부터는 한 단계 더 나아가서 어떤 요청은 로그인 없이 접근하게 하고, 어떤 요청은 로그인한 사용자만 접근하게 나누는 방법을 확인한다.


웹 애플리케이션에서는 모든 요청을 똑같이 막거나 똑같이 열어 두지 않는다.
이미지, 소개 페이지, 공지 페이지처럼 누구나 볼 수 있어야 하는 리소스가 있다.
반대로 마이페이지, 주문 내역, 관리자 화면처럼 로그인한 사용자만 볼 수 있어야 하는 리소스도 있다.


이렇게 요청 주소별로 접근 가능 여부를 나누는 핵심 구조가 SecurityFilterChain이다.
SecurityFilterChain은 요청이 들어왔을 때 어떤 보안 규칙을 적용할지 정하는 보안 필터 묶음이다.


SecurityFilterChain을 직접 정의한다는 의미

자동 보안 규칙에서 직접 작성한 보안 규칙으로 넘어간다

Spring Security 의존성만 추가하면 기본 보안 설정이 자동으로 적용된다.
기본 상태에서는 대부분의 요청에 인증이 필요하고, 로그인하지 않은 사용자는 기본 로그인 화면으로 이동한다.


하지만 실제 프로젝트에서는 기본 규칙만으로 충분하지 않다.
어떤 주소는 로그인 없이 접근 가능해야 하고, 어떤 주소는 반드시 로그인해야 접근 가능해야 한다.
그래서 개발자가 직접 보안 규칙을 작성해야 한다.


SecurityFilterChain을 직접 Bean으로 등록하면 자동 기본 규칙 대신 개발자가 작성한 규칙이 적용된다.
이때 보안이 꺼지는 것이 아니다.
보안 기준이 자동 기본값에서 직접 작성한 설정으로 바뀌는 것이다.


이 그림은 직접 만든 SecurityFilterChain이 요청별 접근 규칙을 가질 수 있다는 것을 보여준다.
자동 설정만 사용할 때는 기본 규칙이 적용되지만, 직접 체인을 만들면 /images/**, /*.html, 나머지 요청처럼 접근 기준을 나눌 수 있다.


이미지에서 중요한 부분은 “요청이 들어오면 바로 Controller로 가지 않고, 먼저 보안 규칙을 가진 체인을 지난다”는 점이다.
그래서 Controller 안에서 직접 로그인 여부를 검사하지 않아도, 앞단의 SecurityFilterChain이 먼저 요청을 걸러낼 수 있다.


URL 접근 제어는 요청 주소별로 허용 범위를 나누는 작업이다

URL 접근 제어는 요청 주소에 따라 접근 가능 여부를 나누는 작업이다.
여기서 URL은 사용자가 브라우저 주소창에 입력하거나, 화면에서 링크를 눌렀을 때 서버로 전달되는 요청 경로를 의미한다.


예를 들어 다음처럼 나눌 수 있다.

  • /images/duke3d.png는 이미지 파일이므로 로그인 없이 접근 가능하게 한다.
  • /exam1.html은 정적 HTML 파일이므로 로그인 없이 접근 가능하게 한다.
  • /exam2.html도 정적 HTML 파일이므로 로그인 없이 접근 가능하게 한다.
  • /는 Controller를 거쳐 Thymeleaf 화면을 응답하는 요청이므로 로그인한 사용자만 접근 가능하게 한다.
  • /exam3.htm은 허용 규칙에 맞지 않으므로 로그인한 사용자만 접근 가능하게 한다.

이렇게 요청 경로를 기준으로 접근 가능 여부를 나누는 것이 URL 접근 제어이다.


초보자가 헷갈리기 쉬운 부분은 “파일이 단순한 화면이냐 아니냐”가 아니라 “요청 주소가 보안 규칙과 일치하느냐”이다.
exam3.htm도 단순한 HTML 파일처럼 보이지만, 현재 규칙은 /*.html만 허용한다.
그래서 .htm 확장자인 /exam3.htm은 공개 대상이 아니다.


authorizeHttpRequests로 요청별 접근 규칙을 작성한다

authorizeHttpRequests는 요청 인가 규칙을 설정한다

authorizeHttpRequests()는 요청별 인가 규칙을 설정하는 메서드이다.
인가란 인증된 사용자가 특정 기능이나 리소스에 접근할 수 있는지 확인하는 과정이다.


쉽게 말하면 authorizeHttpRequests()는 “어떤 주소는 열어 두고, 어떤 주소는 로그인해야 들어오게 할지” 정하는 부분이다.
이 설정 안에서 requestMatchers(), permitAll(), authenticated() 같은 메서드를 사용한다.


security3에서는 이 메서드를 사용해 공개 리소스와 보호 리소스를 나눈다.
공개 리소스는 로그인 없이 접근하게 하고, 보호 리소스는 로그인 후 접근하게 만든다.


requestMatchers는 특정 요청 주소를 고르는 역할이다

requestMatchers()는 보안 규칙을 적용할 요청 경로를 고르는 메서드이다.
예를 들어 /images/**는 /images/로 시작하는 모든 하위 요청을 의미한다.


**는 그 아래 여러 경로를 모두 포함한다는 뜻으로 이해하면 된다.
그래서 /images/duke3d.png, /images/icon/test.png처럼 /images/ 아래에 있는 요청은 모두 이 규칙에 걸린다.


/*.html은 루트 경로 바로 아래에 있는 .html 파일을 의미한다.
예를 들어 /exam1.html, /exam2.html은 여기에 해당한다.
하지만 /exam3.htm은 확장자가 .htm이므로 여기에 해당하지 않는다.


비슷해 보이는 경로를 비교하면 더 확실하다.
/exam1.html은 .html로 끝나기 때문에 허용된다.
/exam3.htm은 .htm으로 끝나기 때문에 허용되지 않는다.
/folder/exam1.html은 루트 바로 아래가 아니라 하위 폴더 안에 있으므로 /*.html 규칙과 다르게 볼 수 있다.


permitAll은 누구나 접근 가능하다는 뜻이다

permitAll()은 해당 요청을 누구나 접근할 수 있게 허용한다는 뜻이다.
로그인하지 않은 사용자도 접근할 수 있다.


예를 들어 /images/**에 permitAll()을 설정하면 이미지 파일은 로그인하지 않아도 볼 수 있다.
/*.html에 permitAll()을 설정하면 루트 경로의 .html 정적 파일도 로그인 없이 볼 수 있다.


이 설정은 공개 리소스에 사용한다.
공개 리소스란 로그인 여부와 관계없이 누구나 접근해도 되는 파일이나 화면을 의미한다.


예를 들어 사이트 로고, 상품 대표 이미지, 서비스 소개 페이지는 로그인 전에도 보여야 하는 경우가 많다.
이런 리소스까지 전부 막아 버리면 로그인 화면 자체의 이미지도 깨질 수 있고, 사용자가 서비스를 둘러볼 수도 없다.
그래서 공개해도 되는 리소스는 permitAll()로 명확히 열어 둔다.


authenticated는 로그인한 사용자만 접근 가능하다는 뜻이다

authenticated()는 인증된 사용자만 접근할 수 있다는 뜻이다.
여기서 인증된 사용자는 로그인에 성공한 사용자를 의미한다.


예를 들어 anyRequest().authenticated()라고 작성하면 앞에서 따로 허용하지 않은 나머지 모든 요청은 로그인해야 접근할 수 있다.
즉, 허용 목록에 들어간 요청만 공개하고, 나머지는 기본적으로 보호하는 방식이다.


이 방식은 실제 프로젝트에서도 자주 쓰인다.
먼저 공개해야 하는 정적 리소스나 공개 페이지를 permitAll()로 열어 둔다.
그리고 나머지 요청은 authenticated()로 막는다.
이렇게 하면 실수로 중요한 페이지가 공개되는 일을 줄일 수 있다.


보안 설정에서는 공개할 경로를 먼저 지정하고, 마지막에 나머지 요청을 인증 필요로 막는 흐름이 안전하다.


formLogin은 기본 로그인 화면을 사용하게 한다

formLogin은 화면 기반 로그인 방식을 켠다

formLogin()은 사용자가 로그인 화면에서 사용자명과 비밀번호를 입력해 인증하는 방식을 설정한다.


security3에서는 별도의 커스텀 로그인 화면을 만들지 않는다.
그래서 Customizer.withDefaults()를 사용해 Spring Security가 제공하는 기본 로그인 화면을 그대로 사용한다.


이 말은 로그인 기능을 직접 구현하지 않았다는 뜻이 아니다.
로그인 요청은 여전히 Spring Security 필터가 처리한다.
다만 로그인 화면과 기본 흐름을 Spring Security 기본값으로 사용한다는 의미이다.


이 이미지는 로그인 요청이 바로 Controller로 가지 않고 Spring Security의 인증 처리 구조로 들어간다는 점을 보여준다.
사용자가 로그인 화면에서 사용자명과 비밀번호를 입력하면, 그 요청은 보안 필터에서 먼저 처리된다.


여기서 중요한 점은 로그인 처리를 담당하는 위치이다.
로그인 화면에서 입력한 값은 일반적인 Controller 메서드가 먼저 받는 것이 아니라, UsernamePasswordAuthenticationFilter 같은 보안 필터가 먼저 처리한다.
그래서 로그인 기능을 따로 Controller에 작성하지 않아도 기본 로그인 흐름이 동작할 수 있다.


이 이미지는 인증 성공 이후의 흐름을 보여준다.
로그인이 성공하면 인증 정보가 저장되고, 이후 보호된 요청에 접근할 때 이 인증 정보를 기준으로 접근 가능 여부를 판단한다.


쉽게 말하면 로그인 성공 정보는 “이 사용자는 방금 인증을 통과했다”는 표시이다.
이 표시가 있어야 사용자가 / 같은 보호 리소스에 접근할 때 다시 로그인 화면으로 보내지지 않는다.


Customizer.withDefaults는 기본 설정을 사용한다는 뜻이다

Customizer.withDefaults()는 해당 기능을 기본 설정으로 사용하겠다는 의미이다.


예를 들어 formLogin(Customizer.withDefaults())는 폼 로그인 기능을 기본값으로 사용하겠다는 뜻이다.
그래서 별도의 로그인 페이지를 지정하지 않아도 /login으로 접근하면 기본 로그인 화면이 나타난다.


이 설정 덕분에 security3에서는 요청별 접근 제어에 집중할 수 있다.
로그인 화면을 직접 만드는 내용은 이후 커스텀 로그인 예제에서 다루는 흐름으로 넘어간다.


설정 순서가 중요한 이유

구체적인 허용 규칙을 먼저 작성한다

보안 규칙을 작성할 때는 구체적인 허용 규칙을 먼저 작성해야 한다.
예를 들어 /images/**, /*.html처럼 공개할 요청을 먼저 지정한다.


그 다음 나머지 요청을 anyRequest().authenticated()로 막는다.
이렇게 하면 허용한 요청만 공개되고, 그 외 요청은 로그인해야 접근할 수 있다.


만약 모든 요청을 먼저 인증 필요로 막아 버리면, 뒤에 공개 규칙을 작성해도 의도대로 동작하지 않을 수 있다.
그래서 requestMatchers()로 공개할 경로를 먼저 정하고, 마지막에 anyRequest()로 나머지를 처리하는 흐름이 중요하다.


security3의 접근 제어 흐름

security3의 접근 제어 흐름은 다음처럼 이해하면 된다.

  • /images/** 요청은 누구나 접근 가능하다.
  • /*.html 요청은 누구나 접근 가능하다.
  • 그 외 모든 요청은 로그인해야 접근 가능하다.
  • 로그인 화면은 Spring Security 기본 로그인 화면을 사용한다.

이제 이 이론을 실제 security3 프로젝트 코드에 적용해 본다.




security3: URL 접근 제어 실습

security3는 SecurityFilterChain을 직접 정의해서 요청 주소별 접근 가능 여부를 나누는 예제이다.
이 예제에서는 이미지 파일과 루트 경로의 .html 파일은 로그인 없이 접근할 수 있게 한다.
그 외 요청은 로그인한 사용자만 접근할 수 있게 만든다.


즉, security3의 핵심은 사용자 정보를 새로 만드는 것이 아니라, 요청 경로별 접근 규칙을 직접 작성하는 것이다.


security3에서는 SecurityFilterChain을 직접 등록하고, requestMatchers()로 공개 요청과 보호 요청을 구분한다.


security3 프로젝트 실행 구조

Security3Application은 프로젝트 실행 시작 클래스이다

Security3Application은 security3 프로젝트를 실행하는 시작 클래스이다.
이 파일은 직접적인 보안 규칙을 작성하는 파일이 아니다.
main() 메서드에서 SpringApplication.run()을 호출해 애플리케이션을 실행한다.

// Security3Application.java
package com.example.security3;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Security3Application {

    public static void main(String[] args) {
        // Spring Boot 애플리케이션을 실행한다.
        SpringApplication.run(Security3Application.class, args);
    }

}

이 코드의 역할은 애플리케이션을 시작하는 것이다.
보안 규칙은 Security3Application이 아니라 MySecurityConfig에서 작성한다.


실행 클래스는 프로젝트의 출발점이다.
하지만 출발점에 모든 설정이 들어가는 것은 아니다.
Spring Boot 애플리케이션을 실행한 뒤, Spring은 등록된 설정 클래스와 Bean을 찾아 보안 체인, Controller, 화면 처리 흐름을 함께 구성한다.


application.yml은 사용자 정보와 실행 환경을 설정한다

security3의 application.yml에는 애플리케이션 이름, 기본 사용자 정보, 보안 로그 레벨, 서버 포트가 들어 있다.

# application.yml
spring :
  application :
    name : security3
  security :
    user:
      name : unico
      password : 1234
      roles : USER

logging:
  level:
    org:
      springframework:
        security : TRACE

server:
  port: 8088

이 설정에서 기본 로그인 사용자는 unico이고, 비밀번호는 1234이다.
권한은 USER이다.
서버 포트는 8088이므로 브라우저에서는 localhost:8088로 접근한다.


logging.level.org.springframework.security를 TRACE로 설정하면 Spring Security 내부 동작 로그를 더 자세히 볼 수 있다.
처음에는 로그가 많아서 복잡해 보일 수 있다.
하지만 어떤 요청이 어떤 보안 필터를 지나고, 접근 제어가 어떻게 판단되는지 확인할 때 도움이 된다.


예를 들어 /exam1.html에 접근했을 때는 공개 규칙에 걸려 바로 응답된다.
반대로 /에 접근했을 때는 인증이 필요한 요청이므로 로그인 화면으로 이동한다.
이런 차이를 로그로도 확인할 수 있다.


MySecurityConfig에서 SecurityFilterChain을 직접 만든다

설정 클래스와 Bean 구조

MySecurityConfig는 security3의 보안 설정 클래스이다.
@Configuration이 붙어 있으므로 Spring은 이 클래스를 설정 클래스로 읽는다.


filterChain() 메서드에는 @Bean이 붙어 있다.
따라서 이 메서드가 반환하는 SecurityFilterChain은 Spring Bean으로 등록된다.

// MySecurityConfig.java
package com.example.security3.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class MySecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 요청별 접근 규칙을 설정한다.
        http.authorizeHttpRequests(requests -> requests
                    // 이미지와 루트의 html 파일은 로그인 없이 허용한다.
                    .requestMatchers("/images/**", "/*.html").permitAll()
                    // 위에서 허용하지 않은 나머지 요청은 인증을 요구한다.
                    .anyRequest().authenticated() )
              // Spring Security 기본 로그인 화면을 사용한다.
              .formLogin(Customizer.withDefaults());

        // 설정한 내용을 SecurityFilterChain으로 완성한다.
        SecurityFilterChain chain = http.build();
        // 생성된 보안 필터 목록을 콘솔에 출력한다.
        chain.getFilters().forEach(System.out::println);
        // 완성된 보안 필터 체인을 Spring Bean으로 반환한다.
        return chain;
    }
}

이 코드는 security3에서 가장 중요한 코드이다.
HttpSecurity에 요청별 접근 규칙을 작성하고, 마지막에 http.build()를 호출해 SecurityFilterChain을 만든다.


이 이미지는 코드로 작성한 보안 설정이 실제 SecurityFilterChain으로 만들어지는 흐름을 보여준다.
SecurityFilterChain을 직접 정의하면 자동 기본 규칙 대신 개발자가 작성한 규칙이 적용된다.


이미지에서 확인해야 할 핵심은 HttpSecurity가 단순히 옵션을 저장하는 객체가 아니라, 최종적으로 보안 필터 체인을 만드는 설정 도구라는 점이다.
requestMatchers()로 공개할 요청을 정하고, anyRequest().authenticated()로 나머지를 막은 뒤, http.build()로 실제 체인이 만들어진다.


chain.getFilters()로 생성된 보안 필터를 확인한다

SecurityFilterChain chain = http.build();는 앞에서 작성한 보안 설정을 실제 필터 체인으로 완성한다.
그 다음 chain.getFilters().forEach(System.out::println);은 생성된 보안 필터 목록을 콘솔에 출력한다.


이 코드는 기능 구현에 꼭 필요한 코드는 아니다.
하지만 학습 단계에서는 현재 설정으로 어떤 보안 필터들이 만들어졌는지 확인할 수 있어서 유용하다.


이 이미지는 SecurityFilterChain 안에 여러 보안 필터가 순서대로 들어 있다는 것을 보여준다.
각 필터는 로그인, 로그아웃, 보안 컨텍스트 관리, 예외 처리, 인가 검사처럼 역할을 나누어 요청을 처리한다.


여기서 필터 이름을 처음부터 전부 외울 필요는 없다.
중요한 것은 “요청 하나가 여러 보안 필터를 순서대로 지나간다”는 구조이다.
예를 들어 어떤 필터는 로그인 요청을 처리하고, 어떤 필터는 인증 정보를 저장하며, 어떤 필터는 최종적으로 접근 권한을 확인한다.


그래서 chain.getFilters() 출력은 현재 내 보안 설정이 어떤 필터 흐름으로 바뀌었는지 확인하는 학습용 단서로 보면 된다.


HttpSecurity는 보안 규칙을 작성하는 객체이다

HttpSecurity는 웹 요청 보안 규칙을 작성하는 객체이다.
이 객체를 통해 어떤 요청을 허용할지, 어떤 요청에 로그인이 필요한지, 어떤 로그인 방식을 사용할지 설정한다.


현재 코드에서는 authorizeHttpRequests()로 요청별 인가 규칙을 작성한다.
그 다음 formLogin(Customizer.withDefaults())로 기본 로그인 화면을 사용하게 한다.


즉, HttpSecurity는 설정을 담는 도구이고, http.build()를 호출하면 실제 보안 필터 묶음인 SecurityFilterChain이 만들어진다.


requestMatchers 규칙 자세히 보기

/images/**는 이미지 폴더 아래 모든 요청을 허용한다

requestMatchers("/images/**", "/*.html").permitAll()에서 첫 번째 규칙은 /images/**이다.
이 규칙은 /images/로 시작하는 모든 하위 요청을 의미한다.


예를 들어 이미지 파일이 /images/duke3d.png 경로에 있다면 로그인하지 않아도 접근할 수 있다.
이미지는 공개 리소스로 보는 경우가 많기 때문에 이 예제에서는 로그인 없이 접근 가능하게 설정한다.


이 이미지는 /images/** 규칙이 실제로 적용된 결과이다.
/images/duke3d.png 요청은 permitAll() 대상이므로 로그인하지 않아도 이미지가 바로 표시된다.


이 장면에서 중요한 점은 이미지가 보인다는 사실 자체보다, 로그인 화면으로 이동하지 않았다는 점이다.
만약 /images/**를 허용하지 않았다면 이미지 요청도 보호 리소스로 판단되어 로그인 화면으로 이동할 수 있다.
그러면 화면에서 필요한 이미지가 깨지거나, 로그인 전 페이지가 정상적으로 보이지 않을 수 있다.


따라서 이미지, CSS, JavaScript 같은 정적 리소스는 프로젝트에 따라 공개 리소스로 열어 두는 경우가 많다.
이번 예제에서는 그중 이미지 경로를 /images/**로 허용한 것이다.


/*.html은 루트 경로의 html 파일을 허용한다

두 번째 규칙은 /*.html이다.
이 규칙은 루트 경로 바로 아래에 있는 .html 파일을 의미한다.


예를 들어 /exam1.html, /exam2.html은 여기에 해당한다.
따라서 이 두 파일은 로그인하지 않아도 접근할 수 있다.


exam1.html은 정적 HTML 파일이다.
이 파일은 루트 경로의 .html 파일이므로 /*.html 규칙에 걸린다.

<!-- exam1.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>HTML학습</title>
</head>
<body>
    HTML을 학습하자...
    <hr>
    HTML을 학습하자...<br>
    <hr>
    <h1>HTML을 학습하자...</h1>
    <h2>HTML을 학습하자...</h2>
    <h3>HTML을 학습하자...</h3>
</body>
</html>

이 이미지는 /exam1.html이 로그인 없이 열리는 결과를 보여준다.
exam1.html은 루트 경로의 .html 파일이므로 /*.html 규칙에 포함된다.


여기서 중요한 점은 exam1.html이 Controller를 거쳐 응답되는 화면이 아니라 정적 파일이라는 점이다.
정적 파일은 서버가 파일을 그대로 찾아 응답하는 방식으로 볼 수 있다.
현재 보안 규칙은 이 정적 파일 요청을 공개 요청으로 처리한다.


exam2.html도 루트 경로의 .html 파일이다.
그래서 exam2.html 역시 로그인 없이 접근할 수 있다.

<!-- exam2.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>HTML학습</title>
</head>
<body>
    <h1>HTML을 학습하자...</h1>
    <hr>
    <ul>
        <li>HTML을 학습하자...</li>
        <li>HTML을 학습하자...</li>
        <li>HTML을 학습하자...</li>
    </ul>
</body>
</html>

이 이미지는 /exam2.html도 로그인 없이 접근되는 결과를 보여준다.
exam1.html과 마찬가지로 /*.html 규칙에 포함되므로 공개 정적 파일로 처리된다.


두 이미지를 같이 보면 Spring Security가 파일 내용을 보고 판단하는 것이 아니라 요청 경로를 보고 판단한다는 점을 알 수 있다.
exam1.html과 exam2.html의 내용은 다르지만, 둘 다 루트 경로의 .html 파일이라는 공통점이 있다.
그래서 둘 다 같은 보안 규칙에 걸린다.


exam3.htm은 html 규칙에 걸리지 않는다

exam3.htm은 이름은 비슷하지만 확장자가 .html이 아니라 .htm이다.
현재 보안 규칙은 /*.html만 허용한다.
따라서 /exam3.htm 요청은 공개 요청으로 처리되지 않는다.

<!-- exam3.htm -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>인증 절차를 수행하면 볼수 있는 HTML 페이지</h1>
</body>
</html>

이 파일은 내용상으로는 단순한 HTML 페이지이다.
하지만 접근 제어 기준은 파일 내용이 아니라 요청 경로와 확장자이다.


그래서 /exam3.htm은 /*.html 규칙에 맞지 않는다.
결과적으로 anyRequest().authenticated() 규칙에 걸리며, 로그인한 사용자만 접근할 수 있다.


이 이미지는 /exam3.htm이 공개 리소스가 아니라 보호 리소스로 처리되는 결과를 보여준다.
exam3.htm은 .html이 아니라 .htm이므로 /*.html 규칙에 걸리지 않는다.


여기서 헷갈리면 안 되는 부분은 exam3.htm도 화면 파일이라는 점이다.
화면 파일처럼 보여도 현재 보안 규칙이 허용한 대상은 /*.html뿐이다.
그래서 /exam3.htm은 나머지 요청으로 분류되고, anyRequest().authenticated()에 의해 인증이 필요하다.


/*.html은 .html 확장자만 허용한다. .htm 파일까지 허용하는 규칙이 아니다.


anyRequest().authenticated()의 역할

앞에서 허용하지 않은 나머지 요청을 막는다

anyRequest().authenticated()는 앞에서 따로 허용하지 않은 나머지 모든 요청에 인증을 요구한다.


현재 코드에서는 /images/**와 /*.html만 permitAll()로 열어 두었다.
그 외 요청은 모두 authenticated() 규칙을 따른다.


예를 들어 /, /exam3.htm, /admin, /todo 같은 요청은 앞의 허용 규칙에 맞지 않는다.
그래서 로그인하지 않은 상태로 접근하면 기본 로그인 화면으로 이동한다.


이 규칙은 “공개할 것을 먼저 정하고, 나머지는 보호한다”는 방식이다.
실제 프로젝트에서도 이 방식이 안전하다.
왜냐하면 공개할 경로를 명확히 지정하고, 그 밖의 요청은 기본적으로 보호할 수 있기 때문이다.


기본적으로 막고 필요한 것만 여는 구조이다

이 예제의 보안 구조는 “필요한 공개 리소스만 열고 나머지는 막는 방식”이다.
이 방식은 안전한 접근 제어 흐름이다.


공개할 리소스는 permitAll()로 명확히 지정한다.
그리고 그 외 요청은 authenticated()로 인증을 요구한다.


이렇게 하면 개발자가 실수로 중요한 화면을 공개해 두는 일을 줄일 수 있다.
예를 들어 나중에 /mypage, /orders, /admin 같은 요청이 추가되어도, 따로 공개 규칙에 넣지 않는 이상 기본적으로 인증이 필요하게 된다.


HomeController와 home.html 흐름

/ 요청은 HomeController가 처리한다

HomeController는 / 요청을 처리한다.
메서드가 실행되면 콘솔에 HOME을 출력하고, home이라는 뷰 이름을 반환한다.

// HomeController.java
package com.example.security3.controller;

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

@Controller
public class HomeController {

    @GetMapping("/")
    public String home() {
        // 컨트롤러 메서드 실행 여부를 콘솔에서 확인한다.
        System.out.println("HOME");
        // templates/home.html을 찾아 응답한다.
        return "home";
    }
}

여기서 / 요청은 /images/**도 아니고 /*.html도 아니다.
따라서 anyRequest().authenticated() 규칙에 걸린다.


즉, 로그인하지 않은 상태에서 /에 접근하면 HomeController로 바로 들어가지 않는다.
먼저 Spring Security가 로그인 화면으로 이동시킨다.
로그인에 성공한 뒤에야 HomeController의 home() 메서드가 실행된다.


return "home"은 templates/home.html로 연결된다

home() 메서드가 반환하는 문자열 home은 파일명을 그대로 응답한다는 뜻이 아니다.
Spring MVC와 Thymeleaf 설정에 의해 templates/home.html을 찾아 화면으로 응답한다.

<!-- home.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>홈</title>
</head>
<body>
    <h1>타임리프가 응답해요</h1>
    <hr>
    <h2>인증절차를 성공적으로 수행했군요~~~</h2>
</body>
</html>

home.html은 로그인 성공 후 / 요청이 실제로 처리되었을 때 볼 수 있는 화면이다.
이 화면이 보인다는 것은 인증 절차를 통과했고, HomeController가 정상적으로 실행되었다는 뜻이다.


정적 HTML 파일인 /exam1.html, /exam2.html과 다르게 home.html은 Controller가 반환하는 템플릿이다.
그래서 브라우저에서 /home.html로 직접 접근하는 흐름이 아니라, / 요청을 통해 HomeController를 거쳐 응답되는 흐름으로 이해해야 한다.


이 차이가 중요하다.
exam1.html과 exam2.html은 /*.html 규칙에 따라 공개된다.
하지만 / 요청은 Controller를 거치는 요청이고, 현재 공개 규칙에 포함되어 있지 않다.
그래서 /는 로그인 후에만 접근된다.


로그인 테스트 흐름

공개 리소스는 로그인 없이 접근된다

먼저 로그인하지 않은 상태에서 공개 리소스에 접근한다.


테스트 대상은 다음과 같다.

  • /images/duke3d.png
  • /exam1.html
  • /exam2.html

이 요청들은 requestMatchers("/images/**", "/*.html").permitAll() 규칙에 걸린다.
따라서 로그인 화면으로 이동하지 않고 바로 응답된다.


이 결과를 통해 permitAll()이 적용된 요청은 인증 없이 접근 가능하다는 것을 확인할 수 있다.


보호 리소스는 로그인 화면으로 이동한다

다음으로 로그인하지 않은 상태에서 보호 리소스에 접근한다.


테스트 대상은 다음과 같다.

  • /
  • /exam3.htm

이 요청들은 /images/**도 아니고 /*.html도 아니다.
따라서 anyRequest().authenticated() 규칙에 걸린다.
로그인하지 않은 상태라면 기본 로그인 화면으로 이동한다.


이 결과를 통해 허용 규칙에 없는 요청은 로그인해야 접근 가능하다는 것을 확인할 수 있다.


unico 계정으로 로그인하면 보호 리소스에 접근할 수 있다

로그인 화면이 나타나면 application.yml에 설정된 사용자 정보를 입력한다.
사용자명은 unico이고, 비밀번호는 1234이다.


로그인에 성공하면 이전에 접근하려던 보호 리소스로 이동할 수 있다.
예를 들어 /에 접근하려다가 로그인 화면으로 이동했다면, 로그인 성공 후 HomeController가 실행되고 home.html이 응답된다.


이 영상은 / 요청이 보호 리소스로 처리되는 흐름을 보여준다.
로그인하지 않은 상태에서 /에 접근하면 먼저 기본 로그인 화면으로 이동한다.
그 다음 unico / 1234로 인증에 성공하면 HomeController를 거쳐 home.html이 응답된다.


영상에서 최종적으로 타임리프가 응답해요, 인증절차를 성공적으로 수행했군요~~~ 화면이 보이는 이유는 / 요청이 보안 필터를 통과했기 때문이다.
즉, 이 화면은 단순히 페이지가 열린 것이 아니라 인증을 거친 뒤 보호 리소스에 접근했다는 결과이다.


이때 콘솔에는 HOME이 출력될 수 있다.
이 출력은 / 요청이 보안 필터를 통과한 뒤 실제 HomeController까지 도착했다는 것을 확인하는 단서이다.


로그아웃 흐름 확인

기본 보안 필터 체인에는 로그아웃 처리 흐름도 포함된다

현재 MySecurityConfig 코드에서 .logout()을 직접 작성하지는 않았다.
하지만 Spring Security는 기본 보안 설정 안에서 로그아웃 처리 흐름도 함께 제공한다.


chain.getFilters().forEach(System.out::println)로 보안 필터 목록을 출력하면, 현재 설정으로 만들어진 보안 필터들이 어떤 순서로 동작하는지 확인할 수 있다.
이 흐름 안에는 로그인뿐 아니라 로그아웃 요청을 처리하는 필터도 포함될 수 있다.


로그아웃을 하면 현재 인증 상태가 사라진다.
인증 상태가 사라졌다는 것은 사용자가 다시 보호 리소스에 접근할 때 로그인 절차를 다시 거쳐야 한다는 뜻이다.


이 영상은 로그아웃 후 인증 상태가 사라지는 흐름을 보여준다.
로그아웃 전에는 보호 리소스에 접근할 수 있지만, 로그아웃 후에는 다시 보호 리소스에 접근할 때 로그인 화면으로 이동한다.


여기서 중요한 점은 로그아웃이 단순히 화면만 이동하는 기능이 아니라는 것이다.
로그아웃은 현재 사용자의 인증 정보를 제거하는 처리이다.
그래서 로그아웃 후에는 이전에 인증을 통과했던 사용자라도 다시 보호 리소스에 접근하려면 로그인해야 한다.


이 흐름을 보면 Spring Security가 단순히 로그인만 처리하는 것이 아니라, 인증 상태 유지와 로그아웃 후 인증 제거까지 함께 관리한다는 것을 알 수 있다.


security3 핵심 정리

URL 접근 제어 기준

security3에서는 SecurityFilterChain을 직접 정의한다.
그 안에서 authorizeHttpRequests()를 사용해 요청별 접근 규칙을 작성한다.


핵심 규칙은 다음과 같다.

  • /images/**는 로그인 없이 접근할 수 있다.
  • /*.html은 로그인 없이 접근할 수 있다.
  • /exam3.htm은 .html이 아니므로 공개 규칙에 걸리지 않는다.
  • / 요청은 공개 규칙에 걸리지 않으므로 로그인 후 접근한다.
  • 나머지 요청은 로그인한 사용자만 접근할 수 있다.
  • 기본 로그인 화면은 formLogin(Customizer.withDefaults())로 사용한다.
  • 로그인 계정은 application.yml의 unico / 1234이다.

이 흐름은 자동 보안 설정을 넘어서 개발자가 직접 보안 규칙을 작성하는 첫 단계이다.


반드시 기억해야 할 흐름

  • Security3Application은 프로젝트 실행 시작 클래스이다.
  • MySecurityConfig는 보안 설정 클래스이다.
  • @Configuration이 붙어 있으므로 현재 설정 클래스는 활성화된다.
  • @Bean이 붙은 filterChain()은 SecurityFilterChain을 Spring Bean으로 등록한다.
  • HttpSecurity는 웹 요청 보안 규칙을 작성하는 객체이다.
  • requestMatchers("/images/**", "/*.html").permitAll()은 이미지와 루트 .html 파일을 공개한다.
  • anyRequest().authenticated()는 나머지 모든 요청에 인증을 요구한다.
  • formLogin(Customizer.withDefaults())는 기본 로그인 화면을 사용한다.
  • http.build()는 작성한 설정을 실제 SecurityFilterChain으로 완성한다.
  • chain.getFilters().forEach(System.out::println)은 생성된 보안 필터 목록을 콘솔에 출력한다.
  • / 요청은 로그인 후 HomeController를 거쳐 home.html로 응답된다.
  • 로그아웃하면 인증 상태가 사라지고, 보호 리소스 접근 시 다시 로그인해야 한다.

security3의 핵심은 요청 경로별로 공개 리소스와 보호 리소스를 나누는 것이다.
이 흐름을 이해하면 다음 단계에서 로그인 화면을 직접 만들고, 로그아웃 처리까지 직접 설정하는 흐름으로 넘어갈 수 있다.




커스텀 로그인 폼과 로그아웃 이론

security3에서는 Spring Security가 제공하는 기본 로그인 화면을 사용했다.
기본 로그인 화면은 빠르게 인증 흐름을 확인하기에는 좋지만, 실제 프로젝트에서는 그대로 사용하기 어렵다.


실제 서비스에서는 사이트 디자인에 맞는 로그인 화면이 필요하다.
입력칸 이름도 프로젝트에서 정한 이름으로 맞춰야 하고, 로그인 실패 메시지나 로그아웃 메시지도 화면에 직접 보여줘야 한다.
그래서 security4에서는 기본 로그인 화면 대신 직접 만든 로그인 화면을 사용한다.


커스텀 로그인 폼의 핵심은 로그인 화면은 개발자가 만들고, 로그인 인증 처리는 Spring Security 필터가 담당하게 연결하는 것이다.


기본 자동 설정과 직접 정의의 차이

SecurityFilterChain을 직접 정의하면 기본 자동 설정 대신 개발자 설정이 기준이 된다

Spring Boot는 Spring Security 의존성이 있으면 기본 보안 설정을 자동으로 만든다.
이 기본 설정에서는 모든 요청에 인증을 요구하고, 기본 로그인 화면을 자동으로 제공한다.


하지만 실제 프로젝트에서는 기본값만으로는 부족하다.
로그인 화면 주소를 직접 정해야 하고, 로그인 성공 후 이동할 주소도 정해야 한다.
로그아웃 후 어떤 화면으로 보낼지도 프로젝트에 맞게 정해야 한다.


이때 개발자는 SecurityFilterChain을 직접 Bean으로 등록한다.
그러면 Spring Boot가 만든 기본 보안 규칙이 중심이 아니라, 개발자가 작성한 SecurityConfig 설정이 보안 기준이 된다.


SecurityFilterChain을 직접 정의하지 않으면 Spring Boot가 기본 보안 설정을 자동으로 만든다.
반대로 개발자가 SecurityFilterChain을 Bean으로 등록하면 직접 작성한 보안 규칙이 적용된다.


이 표에서 중요한 부분은 자동 설정과 직접 정의의 차이이다.
자동 설정에서는 기본 로그인 화면과 기본 인증 규칙이 제공된다.
직접 정의에서는 formLogin(), logout(), csrf(), authorizeHttpRequests() 같은 설정을 개발자가 원하는 흐름으로 조합한다.


security4는 직접 만든 로그인 화면을 사용해야 하므로 SecurityFilterChain을 직접 정의하는 방식으로 진행한다.
즉, 이제부터는 기본 로그인 화면이 아니라 loginPage("/login")으로 지정한 직접 만든 로그인 화면이 사용된다.


HttpSecurity 설정은 인증 API와 인가 API로 나누어 볼 수 있다

인증 API는 로그인과 로그아웃을 처리한다

HttpSecurity에는 여러 보안 설정이 들어간다.
처음 보면 메서드가 많아서 복잡해 보이지만, 크게 인증과 인가로 나누어 보면 이해하기 쉽다.


인증 API는 사용자가 누구인지 확인하는 흐름과 관련된 설정이다.
예를 들어 formLogin(), logout(), csrf(), httpBasic(), sessionManagement() 같은 설정이 여기에 가깝다.
로그인 화면, 로그인 요청 처리, 로그아웃 처리, 세션 관리처럼 사용자 인증 상태를 만들고 유지하는 기능을 다룬다.


인가 API는 인증된 사용자가 어떤 요청에 접근할 수 있는지 정하는 설정이다.
예를 들어 authorizeHttpRequests(), requestMatchers(), permitAll(), authenticated(), hasRole() 같은 설정이 여기에 해당한다.


HttpSecurity 설정은 로그인과 로그아웃 같은 인증 설정, 요청 주소별 접근 권한을 나누는 인가 설정으로 나누어 볼 수 있다.
security4에서는 직접 만든 로그인 화면을 사용하기 위해 인증 설정 중 formLogin()과 logout()을 자세히 사용한다.


이 이미지를 볼 때는 formLogin()과 logout()은 인증 상태를 만들고 제거하는 흐름이고, authorizeHttpRequests()는 어떤 요청을 허용할지 판단하는 흐름이라는 점을 구분하면 된다.
즉, 로그인 화면을 직접 만들더라도 전체 보안 설정 안에서는 인증 설정과 인가 설정이 함께 움직인다.


SecurityConfig 전체 설정 흐름

security4의 설정은 폼 로그인 설정과 로그아웃 설정으로 나뉜다

security4의 SecurityConfig는 크게 세 부분으로 볼 수 있다.
첫째, csrf() 설정이다.
둘째, formLogin() 설정이다.
셋째, logout() 설정이다.


csrf((csrf) -> csrf.disable())은 실습 편의를 위해 CSRF 방어를 끄는 설정이다.
formLogin()은 직접 만든 로그인 화면과 로그인 처리 주소를 연결한다.
logout()은 로그아웃 요청 주소와 로그아웃 성공 후 이동할 주소를 정한다.


security4의 핵심 설정은 formLogin()과 logout()이다.
formLogin() 안에서는 커스텀 로그인 페이지, 로그인 처리 주소, 사용자명 입력 필드 이름, 비밀번호 입력 필드 이름, 성공 주소, 실패 주소를 정한다.
logout() 안에서는 로그아웃 처리 주소, 로그아웃 성공 주소, 세션 무효화, 쿠키 삭제를 정한다.


파란 점선 부분은 사용자가 직접 만든 로그인 폼과 연결되는 설정이다.
빨간 점선 부분은 사용자가 직접 정의한 로그아웃 흐름과 연결되는 설정이다.
위쪽의 csrf.disable() 설명은 이번 실습에서 GET 요청과 POST 요청 흐름을 단순하게 확인하기 위한 설정이다.


커스텀 로그인 폼에서는 로그인 화면 주소, 로그인 처리 주소, 입력 필드 이름이 모두 서로 맞아야 한다.


loginPage와 loginProcessingUrl의 차이

loginPage는 로그인 화면을 보여주는 주소이다

loginPage("/login")은 로그인 화면으로 사용할 주소를 지정한다.
사용자가 로그인하지 않은 상태로 보호 리소스에 접근하면 Spring Security는 이 주소로 사용자를 보낸다.


즉, /login은 로그인 화면을 보여주는 GET 요청 주소이다.
이 주소는 Controller가 처리한다.
LoginController는 /login 요청을 받아 login.html 화면을 반환한다.


예를 들어 사용자가 로그인하지 않은 상태로 /에 접근하면, /는 보호 리소스이므로 바로 회원 페이지로 가지 않는다.
먼저 /login으로 이동해서 로그인 화면을 보여준다.


loginProcessingUrl은 로그인 인증을 처리할 주소이다

loginProcessingUrl("/authentication")은 로그인 인증 요청을 처리할 주소이다.
사용자가 로그인 화면에서 아이디와 비밀번호를 입력하고 로그인 버튼을 누르면, form이 이 주소로 제출된다.


여기서 중요한 점은 /authentication을 처리하는 Controller를 직접 만들지 않는다는 것이다.
/authentication 요청은 Spring Security 필터가 가로채서 처리한다.


즉, /login과 /authentication은 역할이 다르다.

  • /login은 로그인 화면을 보여주는 주소이다.
  • /authentication은 로그인 정보를 제출하는 주소이다.
  • /authentication은 Controller가 아니라 Spring Security 필터가 처리한다.

커스텀 로그인에서 가장 중요한 구분은 화면을 보여주는 주소와 인증을 처리하는 주소가 다르다는 점이다.


usernameParameter와 passwordParameter가 필요한 이유

로그인 폼의 input name과 Spring Security 설정이 맞아야 한다

Spring Security는 로그인 요청에서 사용자명과 비밀번호를 꺼내야 한다.
그런데 HTML 폼의 입력 필드 이름이 프로젝트마다 다를 수 있다.


기본적으로 Spring Security는 사용자명 필드 이름을 username, 비밀번호 필드 이름을 password로 기대한다.
하지만 security4의 로그인 화면에서는 입력 필드 이름을 usernameInput, passwordInput으로 사용한다.


그래서 보안 설정에서 이 이름을 알려줘야 한다.
이때 사용하는 설정이 usernameParameter("usernameInput")와 passwordParameter("passwordInput")이다.


이름이 맞지 않으면 입력값을 제대로 읽지 못한다

로그인 화면에서 사용자가 값을 입력해도, Spring Security가 어떤 이름의 필드를 사용자명으로 읽어야 하는지 모르면 인증이 제대로 진행되지 않는다.


예를 들어 로그인 폼의 사용자명 입력칸 이름은 usernameInput인데, 보안 설정에서 여전히 기본값인 username을 찾고 있다면 문제가 생긴다.
화면에서는 값을 입력했지만, 보안 필터 입장에서는 사용자명 값이 비어 있는 것처럼 처리될 수 있다.


따라서 커스텀 로그인 폼에서는 HTML의 입력 필드 이름과 Spring Security 설정을 반드시 맞춰야 한다.


정리하면 다음과 같다.

  • 로그인 화면의 사용자명 입력 필드는 usernameInput이다.
  • 로그인 화면의 비밀번호 입력 필드는 passwordInput이다.
  • 보안 설정에서도 사용자명 파라미터를 usernameInput으로 지정한다.
  • 보안 설정에서도 비밀번호 파라미터를 passwordInput으로 지정한다.

이렇게 맞춰야 Spring Security가 로그인 요청에서 사용자명과 비밀번호를 정확히 꺼낼 수 있다.


인증되지 않은 요청은 로그인 화면으로 이동한다

보호 리소스에 접근하면 로그인 페이지로 리다이렉트된다

로그인하지 않은 사용자가 보호 리소스에 접근하면 요청은 바로 Controller까지 가지 않는다.
먼저 SecurityFilterChain 안의 필터들이 요청을 검사한다.


접근 권한 검사 단계에서 인증이 필요하다고 판단되면 접근 예외가 발생한다.
이 예외는 예외 처리 필터를 통해 로그인 시작 흐름으로 이어진다.
그 결과 사용자는 로그인 페이지로 이동한다.


인증되지 않은 사용자가 보호 리소스에 접근하면 AuthorizationFilter가 접근 권한을 검사한다.
인증이 필요하면 예외 처리 흐름을 거쳐 로그인 페이지로 이동한다.


이 이미지는 로그인하지 않은 사용자가 보호된 /user 같은 요청에 접근했을 때의 흐름을 보여준다.
요청은 바로 서버의 기능으로 들어가지 않고, 먼저 SecurityFilterChain에서 권한 검사를 받는다.
인증 정보가 없으면 로그인 화면으로 리다이렉트되고, 사용자는 사용자명과 비밀번호를 입력해 인증을 시도한다.


security4에서는 보호 리소스에 접근했을 때 이동할 로그인 페이지를 loginPage("/login")으로 직접 지정한다.
그래서 기본 로그인 화면이 아니라 직접 만든 login.html 화면이 나타난다.


로그인 요청은 UsernamePasswordAuthenticationFilter가 처리한다

로그인 폼 제출은 Controller가 직접 처리하지 않는다

직접 만든 로그인 화면에서 계정과 암호를 입력한 뒤 로그인 버튼을 누르면 POST 요청이 전송된다.
이 요청은 일반적인 Controller 메서드가 직접 처리하는 요청이 아니다.


Spring Security는 로그인 처리 주소로 들어온 요청을 UsernamePasswordAuthenticationFilter에서 가로챈다.
이 필터는 요청이 로그인 요청인지 확인하고, 요청 안에 들어 있는 계정과 암호를 꺼낸다.
그다음 인증에 사용할 UsernamePasswordAuthenticationToken을 만든다.


Token은 인증에 필요한 정보를 임시로 담아 전달하는 객체라고 이해하면 된다.
처음 만들어진 Token에는 사용자가 입력한 계정과 암호가 들어 있다.
아직 인증된 사용자를 의미하지는 않는다.


UsernamePasswordAuthenticationFilter는 로그인 요청을 받아 사용자명과 비밀번호를 꺼낸다.
그 값으로 인증 전 UsernamePasswordAuthenticationToken을 만들고, 인증 성공 여부에 따라 성공 흐름과 실패 흐름을 나누어 처리한다.


이 이미지를 볼 때 중요한 점은 로그인 요청이 /authentication으로 들어온 뒤 바로 Controller로 가지 않는다는 점이다.
직접 만든 로그인 화면은 입력값을 보내는 역할만 하고, 실제 인증 처리는 Spring Security 필터가 담당한다.


인증 성공과 인증 실패는 서로 다른 흐름으로 이동한다

입력한 계정과 암호가 맞으면 인증에 성공한다.
인증에 성공하면 세션 관련 작업이 수행되고, 인증 성공 핸들러가 실행된다.
그 결과 defaultSuccessUrl("/", true) 설정에 따라 /로 이동한다.


반대로 인증에 실패하면 인증 정보가 저장되지 않는다.
실패 처리가 실행되고, 인증 실패 핸들러가 호출된다.
그 결과 failureUrl("/login?error") 설정에 따라 다시 로그인 화면으로 이동한다.


이 흐름을 알면 로그인 성공 시 /로 이동하고, 실패 시 /login?error로 이동하는 설정도 자연스럽게 이해할 수 있다.
SecurityContextHolder와 UserDetailsService의 자세한 내부 구조는 다음 단계인 security5에서 다룬다.


로그아웃 설정 흐름

logoutUrl은 로그아웃을 처리할 주소이다

logoutUrl("/logout")은 로그아웃 요청을 처리할 주소이다.
사용자가 로그아웃 버튼을 누르면 /logout으로 이동하고, Spring Security가 로그아웃 처리를 수행한다.


이 요청도 일반 Controller가 직접 처리하는 흐름이 아니다.
로그아웃 처리는 Spring Security 필터가 담당한다.


member.html의 로그아웃 버튼은 http://localhost:8088/logout으로 이동한다.
이 주소가 SecurityConfig의 logoutUrl("/logout") 설정과 맞기 때문에 로그아웃 처리가 실행된다.


/logout은 단순히 일반 보호 페이지처럼 Controller로 들어가는 주소가 아니다.
Spring Security의 로그아웃 필터가 잡아서 인증 정보를 제거하는 주소이다.
그래서 로그아웃 처리는 화면 이동이 아니라 보안 상태를 정리하는 처리로 봐야 한다.


logoutSuccessUrl은 로그아웃 성공 후 이동할 주소이다

logoutSuccessUrl("/login?logout")은 로그아웃 성공 후 이동할 주소이다.
로그아웃이 완료되면 다시 로그인 화면으로 이동한다.
이때 ?logout 파라미터가 붙는다.


login.html에서는 param.logout이 있는지 확인한다.
있으면 “로그아웃했습니다.” 메시지를 보여준다.


즉, 로그아웃 성공 흐름은 다음처럼 이어진다.

  • 사용자가 회원 페이지에서 로그아웃 버튼을 누른다.
  • /logout 요청이 발생한다.
  • Spring Security가 로그아웃 처리를 수행한다.
  • 세션이 무효화된다.
  • JSESSIONID 쿠키가 삭제된다.
  • /login?logout으로 이동한다.
  • 로그인 화면에서 로그아웃 완료 메시지를 보여준다.

로그아웃은 단순히 화면만 이동하는 것이 아니라, 인증 상태를 제거하는 보안 처리이다.


세션 무효화와 쿠키 삭제

invalidateHttpSession은 세션을 무효화한다

invalidateHttpSession(true)는 로그아웃할 때 현재 세션을 무효화한다는 뜻이다.
세션은 서버가 로그인 상태를 기억하기 위해 사용하는 저장 공간이다.


사용자가 로그인하면 서버는 인증 정보를 세션과 연결해서 관리한다.
그런데 로그아웃 후에도 세션이 그대로 남아 있으면 이전 인증 상태가 계속 남아 있는 것처럼 보일 수 있다.
그래서 로그아웃 시 세션을 무효화해야 한다.


쉽게 말하면 세션 무효화는 “서버 쪽 로그인 기록을 지운다”는 흐름으로 이해하면 된다.


deleteCookies는 브라우저의 세션 쿠키를 삭제한다

deleteCookies("JSESSIONID")는 로그아웃할 때 JSESSIONID 쿠키를 삭제한다는 뜻이다.
JSESSIONID는 브라우저가 서버의 세션을 찾을 때 사용하는 대표적인 세션 쿠키이다.


서버 쪽 세션만 무효화하고 브라우저 쪽 쿠키를 그대로 두면, 브라우저가 예전 세션 식별자를 계속 들고 있을 수 있다.
그래서 로그아웃 시 JSESSIONID 쿠키도 함께 삭제한다.


정리하면 로그아웃은 두 방향으로 정리된다.

  • 서버 쪽에서는 세션을 무효화한다.
  • 브라우저 쪽에서는 JSESSIONID 쿠키를 삭제한다.

이렇게 해야 로그아웃 후 보호 리소스에 다시 접근할 때 새로 로그인해야 하는 상태가 된다.


CSRF 비활성화의 의미

CSRF는 사이트 간 요청 위조 공격을 막기 위한 보안 기능이다

CSRF는 Cross Site Request Forgery의 줄임말이다.
한국어로는 사이트 간 요청 위조라고 한다.
쉽게 말하면 사용자가 의도하지 않은 요청을 다른 사이트가 대신 보내게 만드는 공격을 막기 위한 보안 기능이다.


Spring Security는 기본적으로 CSRF 방어를 제공한다.
특히 POST, PUT, DELETE처럼 서버 상태를 바꾸는 요청에서는 CSRF 토큰을 요구할 수 있다.


로그인 폼도 POST 요청으로 인증 정보를 제출한다.
CSRF가 켜져 있는데 폼에 토큰이 없으면 요청이 막힐 수 있다.


또한 현재 member.html의 로그아웃 버튼은 location.href로 /logout 주소로 이동한다.
즉, form을 POST로 제출하는 방식이 아니라 버튼 클릭으로 주소를 이동하는 방식이다.
그래서 이번 실습에서는 흐름을 단순하게 확인하기 위해 CSRF를 비활성화한다.


security4에서는 실습 편의를 위해 CSRF를 끈다

security4 코드에서는 .csrf((csrf) -> csrf.disable())을 사용한다.
이 설정은 CSRF 방어 기능을 비활성화한다.


이 예제에서는 로그인 폼과 로그아웃 흐름을 단순하게 확인하기 위해 CSRF를 끈다.
그래서 로그인 폼에 별도의 CSRF 토큰을 넣지 않아도 /authentication으로 POST 요청을 보낼 수 있다.
또한 로그아웃 버튼으로 /logout 주소에 직접 이동하는 흐름도 쉽게 확인할 수 있다.


다만 실제 서비스에서 무조건 CSRF를 끄는 것은 좋은 방식이 아니다.
실제 서비스에서는 보안 요구사항에 맞게 CSRF 설정을 신중하게 다뤄야 한다.


CSRF 비활성화는 실습 편의를 위한 설정으로 이해해야 하며, 실제 서비스에서는 보안 기준에 따라 판단해야 한다.


커스텀 로그인 폼과 로그아웃 이론 정리

커스텀 로그인 폼에서는 로그인 화면을 개발자가 직접 만든다.
하지만 로그인 인증 처리는 여전히 Spring Security 필터가 담당한다.


loginPage("/login")은 로그인 화면을 보여주는 주소이다.
loginProcessingUrl("/authentication")은 로그인 요청을 처리할 주소이다.
usernameParameter()와 passwordParameter()는 로그인 폼의 입력 필드 이름을 Spring Security에 알려주는 설정이다.


로그인에 성공하면 defaultSuccessUrl()에 설정한 주소로 이동한다.
로그인에 실패하면 failureUrl()에 설정한 주소로 이동하고, 로그인 화면에서 에러 메시지를 보여줄 수 있다.


로그아웃은 logoutUrl()로 처리하고, 성공 후 logoutSuccessUrl()로 이동한다.
이때 세션 무효화와 쿠키 삭제를 함께 처리하면 인증 상태를 확실히 제거할 수 있다.




security4: 직접 만든 로그인 화면과 로그아웃 실습

security4는 Spring Security의 기본 로그인 화면을 사용하지 않고, 직접 만든 login.html 화면으로 로그인하는 예제이다.
또한 로그인 성공 후 회원 페이지로 이동하고, 로그아웃 후 다시 로그인 화면으로 돌아오는 흐름까지 확인한다.


이 예제에서 중요한 것은 로그인 화면과 인증 처리 주소를 분리해서 이해하는 것이다.
사용자가 보는 화면은 login.html이고, 인증 요청을 처리하는 주소는 /authentication이다.


security4의 핵심은 직접 만든 로그인 화면을 Spring Security 인증 필터와 정확히 연결하는 것이다.


security4 프로젝트 실행 구조

Security4Application은 프로젝트 실행 시작 클래스이다

Security4Application은 security4 프로젝트를 실행하는 시작 클래스이다.
이 파일에는 직접적인 로그인 설정이나 로그아웃 설정이 들어 있지 않다.
main() 메서드에서 SpringApplication.run()을 호출해 애플리케이션을 실행한다.

// Security4Application.java
package com.example.security4;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Security4Application {

    public static void main(String[] args) {
        // Spring Boot 애플리케이션을 실행한다.
        SpringApplication.run(Security4Application.class, args);
    }

}

이 코드는 애플리케이션을 시작하는 역할만 한다.
보안 설정은 SecurityConfig에서 작성하고, 화면 이동은 LoginController와 MemberController에서 처리한다.


application.yml은 기본 사용자와 포트를 설정한다

application.yml에는 애플리케이션 이름, 기본 사용자 정보, 서버 포트가 설정되어 있다.

# application.yml
spring :
  application :
    name : security4
  security :
   user:
     name : unico
     password : 1234
     roles : USER

server:
  port: 8088

이 설정에서 로그인 사용자는 unico이고, 비밀번호는 1234이다.
권한은 USER이다.
서버 포트는 8088이므로 브라우저에서는 localhost:8088로 접근한다.


이 사용자 정보는 Spring Security가 인증할 때 사용한다.
따라서 커스텀 로그인 화면에서 계정에 unico, 암호에 1234를 입력하면 인증에 성공할 수 있다.


SecurityConfig에서 커스텀 로그인과 로그아웃을 설정한다

SecurityConfig는 보안 규칙을 작성하는 설정 클래스이다

SecurityConfig는 security4의 보안 설정 클래스이다.
@Configuration이 붙어 있으므로 Spring은 이 클래스를 설정 클래스로 읽는다.


filterChain() 메서드에는 @Bean이 붙어 있다.
이 메서드가 반환하는 SecurityFilterChain이 Spring Bean으로 등록되고, 요청 보안 처리에 사용된다.

// SecurityConfig.java
package com.example.security4.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // HTTP 요청에 대한 보안 설정
            .authorizeHttpRequests(requests -> requests
                    // /login은 인증 없이 접근 허용
                    .requestMatchers("/login").permitAll()
                    // 그 밖의 요청은 인증 필요
                    .anyRequest().authenticated())
            // 실습 편의를 위해 CSRF 방어 기능 비활성화
            .csrf((csrf) -> csrf.disable())
            // 폼 기반 로그인 설정
            .formLogin(form -> form
                    // 커스텀 로그인 페이지 URL 지정
                    .loginPage("/login")
                    // 로그인 인증 처리 URL 지정
                    .loginProcessingUrl("/authentication")
                    // 사용자명 input name 지정
                    .usernameParameter("usernameInput")
                    // 비밀번호 input name 지정
                    .passwordParameter("passwordInput")
                    // 로그인 성공 시 이동할 URL 지정
                    .defaultSuccessUrl("/", true)
                    // 로그인 실패 시 이동할 URL 지정
                    .failureUrl("/login?error"))
            // 로그아웃 설정
            .logout(logout -> logout
                    // 로그아웃 처리 URL 지정
                    .logoutUrl("/logout")
                    // 로그아웃 성공 시 이동할 URL 지정
                    .logoutSuccessUrl("/login?logout")
                    // 로그아웃 시 세션 무효화
                    .invalidateHttpSession(true)
                    // 로그아웃 시 세션 쿠키 삭제
                    .deleteCookies("JSESSIONID")
           );
        // 완성된 보안 필터 체인을 반환한다.
        return http.build();
    }
}

이 설정은 security4의 전체 로그인과 로그아웃 흐름을 결정한다.
/login은 누구나 접근할 수 있고, 나머지 요청은 로그인해야 접근할 수 있다.


또한 기본 로그인 화면을 쓰지 않고 /login 주소의 직접 만든 로그인 화면을 사용한다.
로그인 요청은 /authentication으로 보내고, 성공하면 /로 이동한다.
실패하면 /login?error로 돌아간다.


/login만 공개하고 나머지는 보호한다

requestMatchers("/login").permitAll()은 /login 요청을 로그인 없이 접근 가능하게 만든다.
로그인 화면은 인증되지 않은 사용자가 볼 수 있어야 한다.
그래서 반드시 공개해야 한다.


반대로 anyRequest().authenticated()는 /login을 제외한 나머지 요청에 인증을 요구하는 기본 규칙이다.
따라서 / 회원 페이지나 /images/duke3d.png 같은 요청은 로그인 후 접근하는 보호 리소스로 볼 수 있다.


다만 /logout은 단순한 화면 요청이 아니라 Spring Security가 로그아웃 처리 주소로 따로 잡아 둔 요청이다.
logoutUrl("/logout") 설정에 의해 로그아웃 필터가 처리한다.


security3에서는 /images/**와 /*.html을 공개했다.
하지만 security4에서는 /login만 공개한다.
그래서 이미지 파일도 로그인 후 회원 페이지에서 링크를 눌러 확인하는 흐름으로 바뀐다.


LoginForm은 로그인 화면 입력 필드 연결에 사용되는 객체이다

LoginForm에는 usernameInput과 passwordInput이 있다

LoginForm은 login.html에서 입력 필드를 연결할 때 사용하는 폼 객체이다.
사용자명 입력 필드는 usernameInput과 연결되고, 비밀번호 입력 필드는 passwordInput과 연결된다.

// LoginForm.java
package com.example.security4.form;

import lombok.Data;

@Data
public class LoginForm {
    // 사용자명 입력값을 연결한다.
    private String usernameInput;
    // 비밀번호 입력값을 연결한다.
    private String passwordInput;
}

@Data는 Lombok이 제공하는 어노테이션이다.
이 어노테이션을 붙이면 getter, setter 같은 기본 메서드를 자동으로 만들어 준다.


여기서 주의할 점은 LoginForm이 인증을 직접 처리하는 객체는 아니라는 점이다.
LoginForm은 로그인 화면에서 th:object="${loginForm}", th:field="*{usernameInput}", th:field="*{passwordInput}"를 사용할 수 있게 도와주는 폼 객체이다.


로그인 버튼을 누르면 값은 /authentication으로 제출된다.
그 다음에는 Controller가 LoginForm을 받아서 검사하는 것이 아니라, Spring Security 로그인 필터가 요청 파라미터를 읽고 인증을 처리한다.


th:field와 SecurityConfig의 parameter 이름이 연결된다

th:field="*{usernameInput}"은 입력 필드를 LoginForm의 usernameInput과 연결한다.
Thymeleaf는 이 설정을 바탕으로 input의 name 값도 usernameInput으로 만든다.


Spring Security는 로그인 요청에서 사용자명 파라미터 이름을 usernameInput으로 찾도록 설정되어 있다.
그래서 로그인 화면에서 입력한 계정 값을 보안 필터가 사용자명으로 읽을 수 있다.


비밀번호도 마찬가지이다.
th:field="*{passwordInput}"은 비밀번호 입력 필드를 passwordInput과 연결한다.
SecurityConfig에서도 passwordParameter("passwordInput")으로 설정되어 있다.


따라서 화면, 폼 객체, 보안 설정이 모두 같은 이름으로 연결된다.
이 이름이 맞아야 /authentication으로 제출된 요청에서 Spring Security가 사용자명과 비밀번호를 정확히 꺼낼 수 있다.


LoginController는 로그인 화면을 반환한다

/login 요청은 login.html로 연결된다

LoginController는 /login 요청을 처리한다.
이 요청은 인증되지 않은 사용자도 접근할 수 있어야 하므로 SecurityConfig에서 permitAll()로 열어 두었다.

// LoginController.java
package com.example.security4.controller;

import com.example.security4.form.LoginForm;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;

@Controller
public class LoginController {

    @GetMapping("/login")
    public String showLogin(@ModelAttribute LoginForm form) {
        // login.html 화면을 반환한다.
        return "login";
    }
}

showLogin() 메서드는 login이라는 뷰 이름을 반환한다.
Spring MVC와 Thymeleaf는 이 값을 바탕으로 templates/login.html을 찾아 응답한다.


@ModelAttribute LoginForm form이 필요한 이유

@ModelAttribute LoginForm form은 로그인 화면에서 사용할 폼 객체를 준비하는 역할을 한다.
login.html에서는 th:object="${loginForm}"을 사용한다.
이 객체가 있어야 th:field="*{usernameInput}", th:field="*{passwordInput}" 같은 필드 연결이 자연스럽게 이루어진다.


여기서 loginForm이라는 이름은 LoginForm 클래스 이름에서 앞 글자를 소문자로 바꾼 이름으로 이해하면 된다.
그래서 LoginController가 LoginForm 객체를 모델에 준비하고, login.html이 그 객체를 사용해 입력 필드를 연결한다.


이 흐름을 정리하면 다음과 같다.

  • 사용자가 /login에 접근한다.
  • LoginController가 LoginForm 객체를 준비한다.
  • login.html이 loginForm 객체를 사용한다.
  • 사용자는 usernameInput, passwordInput에 값을 입력한다.
  • 로그인 버튼을 누르면 /authentication으로 값이 전송된다.

로그인 화면을 보여주는 단계와 인증을 처리하는 단계는 서로 다르다.
LoginController는 화면만 보여주고, 인증 처리는 Spring Security가 담당한다.


login.html은 직접 만든 로그인 화면이다

에러 메시지와 로그아웃 메시지를 표시한다

login.html은 사용자가 직접 보는 로그인 화면이다.
이 화면에서는 로그인 실패 메시지와 로그아웃 완료 메시지를 조건부로 보여준다.

// login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>로그인</title>
</head>
<body>
    <h2>로그인 화면</h2>
    <!-- 로그인 실패 시 표시 -->
    <div th:if="${param.error}">
        <p style="color: red;">사용자명이나 비밀번호가 올바르지 않습니다.</p>
    </div>
    <!-- 로그아웃 성공 시 표시 -->
    <div th:if="${param.logout}">
        <p style="color: blue">로그아웃했습니다.</p>
    </div>
    <form th:action="@{/authentication}" method="post" th:object="${loginForm}">
        <div>
            <label for="usernameInput">계정</label>
            <input type="text" th:field="*{usernameInput}">
        </div>
        <div>
            <label for="passwordInput">암호</label>
            <input type="password" th:field="*{passwordInput}">
        </div>
        <hr>
        <div>
            <input type="submit" value="로그인">
        </div>
    </form>
</body>
</html>

th:if="${param.error}"는 요청 주소에 error 파라미터가 있을 때만 메시지를 보여준다.
로그인 실패 시 SecurityConfig에서 /login?error로 이동하도록 설정했기 때문에 이 조건이 참이 된다.


th:if="${param.logout}"도 마찬가지이다.
로그아웃 성공 시 /login?logout으로 이동하므로, 이 조건이 참이 되어 로그아웃 메시지가 보인다.


form의 action은 로그인 처리 주소와 같아야 한다

form의 th:action="@{/authentication}"은 로그인 요청을 /authentication으로 보내겠다는 뜻이다.
이 주소는 SecurityConfig의 loginProcessingUrl("/authentication")과 반드시 같아야 한다.


사용자가 로그인 버튼을 누르면 브라우저는 POST /authentication 요청을 보낸다.
이 요청은 Controller가 처리하지 않는다.
Spring Security의 로그인 필터가 요청을 가로채고 사용자명과 비밀번호를 검사한다.


정리하면 다음과 같다.

  • 화면 주소는 /login이다.
  • 로그인 처리 주소는 /authentication이다.
  • form의 action은 /authentication이다.
  • SecurityConfig의 loginProcessingUrl()도 /authentication이다.

이 주소가 서로 맞지 않으면 로그인 폼을 제출해도 Spring Security가 의도한 방식으로 인증 요청을 처리하지 못한다.


MemberController와 member.html은 로그인 성공 후 화면이다

/ 요청은 member.html로 연결된다

MemberController는 / 요청을 처리한다.
로그인에 성공하면 defaultSuccessUrl("/", true) 설정에 의해 /로 이동한다.
그 결과 MemberController의 member() 메서드가 실행된다.

// MemberController.java
package com.example.security4.controller;

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

@Controller
public class MemberController {

    @GetMapping("/")
    public String member() {
        // member.html 화면을 반환한다.
        return "member";
    }
}

member() 메서드는 member라는 뷰 이름을 반환한다.
Spring MVC와 Thymeleaf는 templates/member.html을 찾아 응답한다.


/ 요청은 SecurityConfig에서 공개하지 않았다.
따라서 로그인하지 않은 상태에서는 접근할 수 없다.
로그인에 성공해야 회원 페이지를 볼 수 있다.


member.html에는 이미지 링크와 로그아웃 버튼이 있다

member.html은 로그인 성공 후 보이는 회원 페이지이다.
이 화면에는 /images/duke3d.png로 이동하는 링크와 /logout으로 이동하는 로그아웃 버튼이 있다.

// member.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>회원페이지</title>
    <style>
        a {
            text-decoration : none;
        }
    </style>
</head>
<body>
    <h2>로그인을 했군요!!</h2>
    <hr>
    <a href="/images/duke3d.png">로그인했으니 볼 수 있어요... 클릭해 보세요..</a><br>
    <hr>
    <button onclick="location.href='http://localhost:8088/logout'">로그아웃</button>
</body>
</html>

회원 페이지의 링크는 /images/duke3d.png로 이동한다.
security3에서는 /images/**를 공개했지만, security4에서는 /login만 공개했다.
그래서 이 이미지도 로그인 후 접근하는 보호 리소스로 볼 수 있다.


로그아웃 버튼은 /logout으로 이동한다.
현재 설정에서는 /logout 요청을 Spring Security의 로그아웃 필터가 처리한다.
따라서 MemberController에 로그아웃 메서드를 따로 만들지 않아도 된다.


security4 실행 결과 확인

직접 만든 로그인 화면에서 전체 인증 흐름을 확인한다

브라우저에서 localhost:8088/login으로 접근하면 Spring Security 기본 로그인 화면이 아니라 직접 만든 login.html 화면이 나타난다.
이 화면은 LoginController의 /login 요청 처리 메서드가 반환한 화면이다.


먼저 잘못된 계정을 입력하면 인증에 실패한다.
인증에 실패하면 failureUrl("/login?error") 설정에 따라 다시 로그인 화면으로 이동한다.
이때 주소에 error 파라미터가 붙고, login.html의 th:if="${param.error}" 조건이 동작해서 “사용자명이나 비밀번호가 올바르지 않습니다.” 메시지가 표시된다.


그다음 올바른 계정인 unico / 1234를 입력하면 로그인 요청이 /authentication으로 전송된다.
이 요청은 Controller가 처리하지 않는다.
Spring Security의 UsernamePasswordAuthenticationFilter가 요청을 가로채고, usernameInput과 passwordInput 값을 꺼내 인증을 진행한다.


인증에 성공하면 defaultSuccessUrl("/", true) 설정에 따라 /로 이동한다.
그다음 MemberController가 실행되고 member.html 화면이 나타난다.


마지막으로 회원 페이지에서 로그아웃 버튼을 누르면 /logout 요청이 발생한다.
로그아웃이 성공하면 logoutSuccessUrl("/login?logout") 설정에 따라 로그인 화면으로 돌아가고, “로그아웃했습니다.” 메시지가 표시된다.


첫 번째 실행 결과는 로그인 실패, 로그인 성공, 로그아웃 흐름을 한 번에 보여준다.
/login은 직접 만든 로그인 화면이고, /authentication은 Spring Security가 처리하는 로그인 요청 주소이다.
로그인 성공 후에는 /를 거쳐 member.html이 표시되고, 로그아웃 후에는 /login?logout으로 돌아간다.


로그인하지 않은 상태에서 이미지 URL 접근 확인

인증되지 않은 상태에서는 이미지 URL도 로그인 화면으로 이동한다

현재 SecurityConfig에서는 /login만 permitAll()로 열려 있다.
그 외 요청은 모두 anyRequest().authenticated()에 걸린다.


따라서 /images/duke3d.png도 인증 대상이다.
로그인하지 않은 상태에서 브라우저 주소창에 localhost:8088/images/duke3d.png를 입력하면 이미지가 바로 보이지 않는다.
대신 Spring Security가 로그인 화면으로 이동시킨다.


두 번째 실행 결과는 로그인하지 않은 상태에서 이미지 URL에 직접 접근했을 때의 흐름이다.
이미지 요청도 인증 대상이기 때문에, 인증 정보가 없으면 duke3d.png가 바로 열리지 않고 로그인 화면으로 이동한다.


이 흐름은 security3와 비교하면 더 잘 이해된다.
security3에서는 /images/**를 permitAll()로 열어 두었기 때문에 이미지를 로그인 없이 볼 수 있었다.
하지만 security4에서는 /login만 공개했기 때문에 이미지 요청도 보호 리소스가 된다.


로그인한 상태에서 이미지 URL 접근 확인

인증된 상태에서는 이미지 URL을 볼 수 있다

로그인에 성공하면 인증 정보가 서버에 저장된다.
이후 같은 브라우저에서 다시 /images/duke3d.png로 접근하면 Spring Security는 이미 로그인한 사용자라고 판단한다.


그래서 이번에는 로그인 화면으로 이동하지 않고 이미지가 표시된다.
즉, 같은 이미지 URL이라도 로그인 전과 로그인 후 결과가 달라진다.


세 번째 실행 결과는 로그인한 상태에서 이미지 URL에 접근했을 때의 흐름이다.
인증 정보가 있는 상태이므로 /images/duke3d.png 요청이 통과되고 이미지가 표시된다.


이 결과를 통해 Spring Security가 단순히 주소만 보는 것이 아니라, 현재 요청을 보낸 사용자의 인증 상태도 함께 확인한다는 점을 알 수 있다.
같은 /images/duke3d.png 요청이라도 로그인 전에는 로그인 화면으로 이동하고, 로그인 후에는 이미지가 응답된다.


security4 핵심 정리

직접 만든 로그인 화면을 사용하는 흐름

security4에서는 Spring Security 기본 로그인 화면을 사용하지 않는다.
LoginController가 /login 요청을 받아 직접 만든 login.html을 보여준다.


로그인 폼은 /authentication으로 제출된다.
이 요청은 Controller가 아니라 Spring Security 필터가 처리한다.
사용자명 파라미터는 usernameInput, 비밀번호 파라미터는 passwordInput으로 읽는다.


로그인 성공 시 /로 이동하고, MemberController가 member.html을 반환한다.
로그인 실패 시 /login?error로 이동하고, 로그인 화면에 실패 메시지가 표시된다.
로그아웃 성공 시 /login?logout으로 이동하고, 로그인 화면에 로그아웃 메시지가 표시된다.


반드시 기억해야 할 흐름

  • Security4Application은 프로젝트 실행 시작 클래스이다.
  • SecurityConfig는 커스텀 로그인과 로그아웃 보안 설정을 작성한다.
  • /login은 로그인하지 않은 사용자도 접근할 수 있다.
  • 그 밖의 요청은 인증이 필요하다.
  • /logout은 로그아웃 필터가 처리하는 주소이다.
  • loginPage("/login")은 직접 만든 로그인 화면 주소이다.
  • loginProcessingUrl("/authentication")은 로그인 인증 처리 주소이다.
  • usernameParameter("usernameInput")은 사용자명 입력 필드 이름을 맞춘다.
  • passwordParameter("passwordInput")은 비밀번호 입력 필드 이름을 맞춘다.
  • defaultSuccessUrl("/", true)는 로그인 성공 후 회원 페이지로 이동하게 한다.
  • failureUrl("/login?error")는 로그인 실패 후 에러 메시지를 보여주게 한다.
  • logoutUrl("/logout")은 로그아웃 처리 주소이다.
  • logoutSuccessUrl("/login?logout")은 로그아웃 성공 후 메시지를 보여주게 한다.
  • invalidateHttpSession(true)는 로그아웃 시 세션을 무효화한다.
  • deleteCookies("JSESSIONID")는 로그아웃 시 세션 쿠키를 삭제한다.
  • /images/duke3d.png는 현재 설정에서 /login이 아니므로 인증 대상이다.

security4의 핵심은 직접 만든 로그인 화면을 Spring Security 인증 필터와 정확히 연결하는 것이다.
이 흐름을 이해하면 다음 단계에서 인증 객체와 사용자 정보 구조를 더 깊게 이해할 수 있다.




인증 객체와 UserDetailsService 이론

security4에서는 직접 만든 로그인 화면을 사용하고, 로그인 요청을 /authentication으로 보내는 흐름을 확인했다.
이제는 그 로그인 요청이 내부에서 어떤 객체를 거쳐 인증되는지 이해해야 한다.


로그인은 단순히 아이디와 비밀번호를 비교하고 끝나는 과정이 아니다.
Spring Security는 입력값을 인증 요청 객체로 만들고, 사용자 정보를 조회하고, 비밀번호를 검증하고, 인증 성공 정보를 저장한다.


security5의 핵심은 로그인 요청이 들어왔을 때 사용자 정보를 어떤 객체로 다루고, 인증 성공 정보를 어디에 저장하는지 이해하는 것이다.


Authentication은 인증 정보를 담는 객체이다

인증 전 Authentication과 인증 후 Authentication

Authentication은 인증 정보를 담는 객체이다.
인증이라는 말은 사용자가 누구인지 확인하는 과정이라는 뜻이다.


로그인 요청이 처음 들어왔을 때는 아직 인증이 끝난 상태가 아니다.
이때 만들어지는 Authentication은 사용자가 입력한 사용자명과 비밀번호를 담고 있는 인증 요청 객체에 가깝다.
쉽게 말하면 “이 정보로 로그인할 수 있는지 검사해 주세요”라는 신청서 역할을 한다.


인증이 성공하면 인증 완료 상태의 Authentication 객체가 만들어진다.
이 객체에는 로그인한 사용자 정보, 권한 정보, 인증 완료 여부가 들어간다.
쉽게 말하면 “이 사용자는 인증에 성공했다”는 결과 객체이다.


따라서 Authentication은 인증 전과 인증 후에 의미가 달라진다.
인증 전에는 검사 대상이고, 인증 후에는 검사 결과이다.


Principal, Credentials, GrantedAuthority를 구분한다

Authentication 안에는 인증과 관련된 핵심 정보가 들어 있다.
대표적으로 Principal, Credentials, GrantedAuthority가 있다.


Principal은 접근 주체를 의미한다.
로그인 흐름에서는 사용자를 나타내는 정보이다.
인증 전에는 사용자가 입력한 사용자명일 수 있고, 인증 후에는 UserDetails 객체가 들어가는 경우가 많다.


Credentials는 사용자를 증명하는 비밀 정보이다.
로그인 흐름에서는 비밀번호가 여기에 해당한다.
인증이 끝난 뒤에는 보안상 비워지거나 직접 사용하지 않도록 처리될 수 있다.


GrantedAuthority는 인증된 사용자가 가진 권한 정보이다.
예를 들어 ROLE_USER, ROLE_ADMIN 같은 값이 권한에 해당한다.
이 권한은 이후 인가 단계에서 사용된다.


정리하면 다음과 같다.

  • Principal은 로그인한 사용자 정보이다.
  • Credentials는 사용자를 증명하는 비밀 정보이다.
  • GrantedAuthority는 사용자가 가진 권한 목록이다.

인증은 사용자가 누구인지 확인하는 과정이고, 권한은 인증된 사용자가 어떤 기능에 접근할 수 있는지 판단할 때 사용된다.


UsernamePasswordAuthenticationToken은 폼 로그인 값을 담는 인증 토큰이다

UsernamePasswordAuthenticationToken이 필요한 이유

UsernamePasswordAuthenticationToken은 Authentication을 구현한 인증 객체이다.
폼 로그인에서 사용자가 입력한 사용자명과 비밀번호를 담을 때 사용된다.


여기서 Token은 로그인 정보를 잠시 담아 다음 인증 단계로 넘기는 객체라고 이해하면 된다.
사용자가 로그인 화면에서 계정과 암호를 입력하면, Spring Security는 이 값을 바로 비교하지 않는다.
먼저 사용자명과 비밀번호를 담은 인증 요청 객체를 만든다.
그 객체가 UsernamePasswordAuthenticationToken이다.


이 객체에서 사용자명은 Principal 역할을 한다.
비밀번호는 Credentials 역할을 한다.
즉, “누가 로그인하려고 하는가?”와 “무엇으로 본인임을 증명하는가?”를 하나의 인증 객체에 담는 것이다.


인증 전 토큰과 인증 완료 토큰

UsernamePasswordAuthenticationToken은 인증 전에도 사용되고, 인증 후에도 사용될 수 있다.


인증 전 토큰은 사용자가 입력한 사용자명과 비밀번호를 담고 있다.
아직 검증되지 않았으므로 인증된 사용자라고 볼 수 없다.


인증 완료 토큰은 검증을 통과한 사용자 정보와 권한 목록을 담는다.
이때 비밀번호 같은 민감한 정보는 더 이상 직접 사용하지 않도록 처리될 수 있다.


초보자 기준으로 보면 다음처럼 구분하면 된다.

  • 인증 전 토큰은 로그인 검사 요청서이다.
  • 인증 완료 토큰은 로그인 성공 결과표이다.

이 차이를 알아야 Spring Security가 왜 사용자 입력값을 바로 세션에 저장하지 않고, 인증 과정을 거쳐 완료된 인증 객체를 저장하는지 이해할 수 있다.


UserDetailsService는 사용자 정보를 불러오는 인터페이스이다

UserDetailsService가 필요한 이유

UserDetailsService는 Spring Security가 인증할 때 사용자 정보를 불러오기 위해 사용하는 인터페이스이다.
인터페이스는 어떤 메서드를 반드시 만들어야 하는지 정해 둔 규칙이다.


로그인 화면에서 사용자가 계정을 입력하면 Spring Security는 그 계정으로 사용자를 찾아야 한다.
하지만 사용자 정보를 어디에서 찾을지는 프로젝트마다 다르다.
어떤 프로젝트는 메모리에서 찾을 수 있고, 어떤 프로젝트는 DB에서 찾을 수 있다.
또 다른 프로젝트는 외부 인증 서버에서 사용자 정보를 가져올 수도 있다.


그래서 Spring Security는 사용자 조회 방식을 하나로 고정하지 않는다.
대신 UserDetailsService라는 공통 규칙을 정해 두고, 개발자가 그 규칙에 맞춰 사용자 조회 로직을 작성하게 한다.


UserDetailsService는 사용자명을 기준으로 사용자 정보를 찾아 UserDetails로 반환하는 조회 창구이다.


loadUserByUsername은 사용자명을 기준으로 사용자 정보를 찾는다

UserDetailsService에서 핵심 메서드는 loadUserByUsername()이다.
이 메서드는 로그인 화면에서 입력된 사용자명을 받아서 인증에 사용할 사용자 정보를 반환한다.


예를 들어 사용자가 로그인 화면에 duke를 입력하면 Spring Security는 내부 인증 흐름에서 loadUserByUsername("duke")를 호출한다.
그러면 개발자가 만든 UserDetailsService 구현 클래스는 duke라는 사용자 정보를 찾아서 반환해야 한다.


여기서 중요한 점은 반환값이다.
이 메서드는 일반 회원 객체를 그대로 반환하는 것이 아니다.
반드시 Spring Security가 이해할 수 있는 UserDetails 형태로 반환해야 한다.


UserDetailsService는 사용자를 직접 인증하는 객체가 아니라, 인증에 필요한 사용자 정보를 찾아서 UserDetails로 반환하는 객체이다.


UserDetails는 인증에 필요한 사용자 정보 형식이다

UserDetails가 담는 정보

UserDetails는 Spring Security가 인증에 사용할 사용자 정보 형식이다.
사용자명, 비밀번호, 권한, 계정 상태 정보를 제공한다.


일반 회원 객체는 프로젝트마다 모양이 다를 수 있다.
어떤 프로젝트는 loginId를 사용하고, 어떤 프로젝트는 email을 로그인 아이디로 사용할 수 있다.
또 닉네임, 주소, 전화번호처럼 인증과 직접 관련 없는 정보도 들어갈 수 있다.


하지만 Spring Security가 인증할 때 필요한 정보는 정해져 있다.
사용자명, 비밀번호, 권한, 계정 사용 가능 여부가 필요하다.
이 정해진 정보를 제공하는 표준 형태가 UserDetails이다.


UserDetails는 getUsername(), getPassword(), getAuthorities()처럼 인증에 필요한 값을 제공한다.


일반 회원 객체와 UserDetails는 역할이 다르다

일반 회원 객체는 서비스에서 필요한 회원 정보를 담는다.
예를 들어 이름, 닉네임, 주소, 가입일, 전화번호 같은 값이 들어갈 수 있다.


UserDetails는 보안 인증에 필요한 사용자 정보를 담는다.
사용자명, 비밀번호, 권한, 계정 상태처럼 로그인과 권한 검사에 필요한 값이 중심이다.


둘은 비슷해 보이지만 완전히 같은 역할이 아니다.
일반 회원 객체는 애플리케이션의 회원 데이터이고, UserDetails는 Spring Security가 인증에 사용할 보안 사용자 정보이다.


그래서 실제 서비스에서는 DB에서 회원 정보를 조회한 뒤, 그 정보를 UserDetails 형태로 바꿔서 반환하는 흐름이 자주 사용된다.


User 클래스를 상속하면 UserDetails 구현이 쉬워진다

Spring Security는 UserDetails를 이미 구현한 User 클래스를 제공한다.
이 클래스를 상속하면 UserDetails의 여러 메서드를 직접 전부 구현하지 않아도 된다.


User 클래스는 사용자명, 비밀번호, 권한 목록을 받아서 기본 인증 사용자 객체로 동작한다.
그래서 생성자에서 super(username, password, authorities)를 호출하면 부모 클래스가 인증에 필요한 기본 정보를 관리한다.


security5에서 만드는 LoginUser는 이 방식을 사용한다.
현재 단계에서는 복잡한 사용자 정보를 추가하지 않는다.
먼저 User 클래스를 상속해서 Spring Security가 사용할 수 있는 커스텀 사용자 객체를 만드는 흐름을 확인한다.


이번 단계의 커스텀 UserDetails는 복잡한 회원 정보를 담기 위한 것이 아니라, 인증 사용자 객체를 직접 만들어 인증 흐름에 연결하는 연습이다.


로그인 요청은 인증 흐름을 거쳐 처리된다

로그인 요청이 들어왔을 때의 큰 흐름

사용자가 로그인 화면에서 계정과 비밀번호를 입력하면 요청이 서버로 전송된다.
이 요청은 바로 Controller가 처리하지 않는다.
Spring Security의 인증 필터가 먼저 요청을 가로챈다.


인증 필터는 입력된 사용자명과 비밀번호로 인증 요청 객체를 만든다.
그 다음 인증 처리를 시작하는 AuthenticationManager에게 넘긴다.
AuthenticationManager는 실제 인증을 처리할 AuthenticationProvider에게 검증을 맡긴다.


기본 폼 로그인에서는 보통 DaoAuthenticationProvider가 사용자 정보 조회와 비밀번호 검증 흐름을 담당한다.
이 과정에서 UserDetailsService가 호출되고, 반환된 UserDetails의 비밀번호와 사용자가 입력한 비밀번호가 비교된다.


로그인 요청은 AuthenticationManager, AuthenticationProvider, UserDetailsService, PasswordEncoder를 거쳐 인증 성공 여부가 결정된다.


단계별 인증 흐름

인증 처리 흐름은 다음 순서로 이해하면 된다.

  • 사용자가 로그인 화면에서 계정과 비밀번호를 입력한다.
  • 로그인 요청이 /authentication으로 전송된다.
  • UsernamePasswordAuthenticationFilter가 요청을 가로챈다.
  • 입력된 사용자명과 비밀번호로 인증 전 UsernamePasswordAuthenticationToken을 만든다.
  • AuthenticationManager가 인증 처리를 시작한다.
  • AuthenticationProvider가 실제 인증 검증을 수행한다.
  • UserDetailsService.loadUserByUsername()이 호출된다.
  • 개발자가 만든 사용자 조회 로직이 사용자 정보를 찾는다.
  • 찾은 사용자 정보가 UserDetails 형태로 반환된다.
  • PasswordEncoder가 입력 비밀번호와 저장 비밀번호를 비교한다.
  • 인증에 성공하면 인증 완료 상태의 Authentication 객체가 만들어진다.
  • 인증 정보가 SecurityContext에 저장된다.

이 흐름에서 개발자가 직접 구현하는 핵심은 사용자 정보를 찾아 UserDetails로 반환하는 부분이다.
그 이후 비밀번호 비교와 인증 성공 정보 저장은 Spring Security가 이어서 처리한다.


PasswordEncoder는 비밀번호 비교 방식을 담당한다

PasswordEncoder가 필요한 이유

PasswordEncoder는 비밀번호를 비교할 때 사용하는 객체이다.
사용자가 입력한 평문 비밀번호와 저장된 비밀번호가 같은 의미인지 확인한다.


실제 서비스에서는 비밀번호를 원문 그대로 저장하지 않는다.
BCrypt 같은 방식으로 암호화해서 저장한다.
로그인할 때는 사용자가 입력한 비밀번호와 저장된 암호화 비밀번호를 PasswordEncoder.matches()로 비교한다.


security5에서는 아직 암호화된 비밀번호를 사용하지 않는다.
실습 흐름을 단순하게 보기 위해 비밀번호를 문자열 그대로 비교하는 방식을 사용한다.
이때 등록하는 객체가 NoOpPasswordEncoder이다.


다만 이 방식은 학습용이다.
실제 서비스에서는 비밀번호를 그대로 저장하거나 그대로 비교하면 안 된다.


NoOpPasswordEncoder는 인증 흐름을 단순하게 보기 위한 실습용 설정이다. 실제 서비스용 비밀번호 저장 방식으로 이해하면 안 된다.


인증 성공 정보는 SecurityContextHolder에 저장된다

SecurityContext는 인증 정보를 담는 공간이다

인증이 성공하면 인증 완료 상태의 Authentication 객체가 만들어진다.
이 객체는 그냥 사라지는 것이 아니라 SecurityContext에 저장된다.


SecurityContext는 현재 보안 정보를 담는 공간이다.
여기에는 현재 사용자의 인증 정보가 들어간다.
즉, SecurityContext를 보면 현재 요청에서 로그인한 사용자가 누구인지 알 수 있다.


SecurityContextHolder는 이 SecurityContext를 보관하고 꺼내는 통로이다.
이 구조 덕분에 이후 요청 처리 과정에서 현재 로그인한 사용자 정보를 확인할 수 있다.


SecurityContextHolder에서 인증 정보를 꺼낼 수 있다

인증 정보가 저장되면 이후 코드에서 현재 로그인한 사용자 정보를 꺼낼 수 있다.
대표적으로 SecurityContextHolder.getContext()를 통해 현재 보안 컨텍스트에 접근할 수 있다.

// SecurityContextAccessExample.java
// 현재 보안 정보를 담은 SecurityContext를 꺼낸다.
SecurityContext context = SecurityContextHolder.getContext();
// SecurityContext 안에서 인증 정보를 꺼낸다.
Authentication authentication = context.getAuthentication();
// 로그인한 사용자 정보를 꺼낸다.
authentication.getPrincipal();
// 사용자의 권한 목록을 꺼낸다.
authentication.getAuthorities();
// 인증에 사용된 자격 증명 정보를 확인한다.
authentication.getCredentials();
// 요청과 관련된 상세 정보를 확인한다.
authentication.getDetails();
// 인증 완료 여부를 확인한다.
authentication.isAuthenticated();

이 코드는 인증 성공 후 저장된 인증 정보를 직접 꺼내는 방식이다.
하지만 매번 이렇게 직접 꺼내는 방식은 코드가 길어질 수 있다.
그래서 Controller에서는 더 간단하게 @AuthenticationPrincipal을 사용할 수 있다.


@AuthenticationPrincipal은 로그인 사용자 정보를 바로 받는다

컨트롤러에서 로그인 사용자 정보를 받는 간단한 방법

@AuthenticationPrincipal은 현재 로그인한 사용자 정보를 Controller 메서드의 매개변수로 바로 주입받게 해 주는 어노테이션이다.
어노테이션은 코드에 특별한 의미를 붙이는 표시라고 이해하면 된다.


인증이 성공하면 Authentication 안의 Principal에는 로그인한 사용자 정보가 들어간다.
@AuthenticationPrincipal은 이 Principal 값을 꺼내서 메서드 매개변수로 전달해 준다.


그래서 Controller에서 SecurityContextHolder를 직접 꺼내지 않아도 현재 로그인한 사용자의 사용자명을 사용할 수 있다.

// AuthenticationPrincipalExample.java
@GetMapping("/")
public String member(@AuthenticationPrincipal User user, Model model) {
    // 현재 로그인한 사용자명을 화면에 전달한다.
    model.addAttribute("username", user.getUsername());
    // member.html 화면을 반환한다.
    return "member";
}

이 흐름은 security5 실습에서 그대로 사용된다.
로그인에 성공한 뒤 MemberController는 @AuthenticationPrincipal로 현재 사용자 정보를 받고, 그 사용자명을 화면에 출력한다.


UserDetailsService는 DB 인증으로 확장된다

실제 서비스에서는 DB에서 사용자 정보를 조회한다

지금은 코드 안에서 duke, olaf 같은 사용자 정보를 직접 반환할 수 있다.
하지만 실제 서비스에서는 대부분 사용자 정보를 DB에 저장한다.
회원가입한 사용자의 아이디, 암호화된 비밀번호, 권한 정보가 DB에 저장된다.


로그인할 때는 사용자가 입력한 사용자명으로 DB에서 회원 정보를 찾아야 한다.
이때 UserDetailsService가 사용된다.
개발자는 loadUserByUsername() 안에서 Repository나 Mapper를 호출해 사용자 정보를 조회한다.


조회한 회원 정보는 그대로 반환하지 않는다.
Spring Security가 이해할 수 있도록 UserDetails 형태로 바꿔 반환한다.
그 다음 Spring Security는 비밀번호를 비교하고 권한을 확인한다.


실제 서비스에서는 UserDetailsService가 사용자명을 기준으로 DB에서 회원 정보를 조회하고, 조회 결과를 UserDetails 형태로 반환한다.


security5와 security6의 연결 흐름

security5에서는 아직 DB를 사용하지 않는다.
대신 if문으로 사용자명을 비교해서 duke, olaf 사용자 정보를 직접 반환한다.


이 방식은 실제 서비스 코드로 쓰기에는 부족하다.
사용자가 많아질 때마다 if문을 계속 늘릴 수 없기 때문이다.


하지만 학습 순서에서는 중요하다.
먼저 UserDetailsService가 어떤 역할을 하는지 코드 안에서 확인하고, 그 다음 단계에서 같은 위치를 DB 조회로 바꾸면 흐름이 자연스럽게 이어진다.


즉, security5에서 직접 반환하던 사용자 정보는 security6에서 H2 Database와 MyBatis를 통해 조회하는 구조로 확장된다.


인증 객체와 UserDetailsService 이론 정리

Authentication은 인증 정보를 담는 객체이다.
인증 전에는 로그인 검사를 요청하는 객체에 가깝고, 인증 후에는 인증 성공 결과를 담는 객체에 가깝다.


UsernamePasswordAuthenticationToken은 폼 로그인에서 입력된 사용자명과 비밀번호를 담는 인증 객체이다.
인증 전에는 입력값을 담고, 인증 후에는 인증된 사용자 정보와 권한을 담는다.


UserDetailsService는 사용자명을 기준으로 사용자 정보를 조회하는 인터페이스이다.
핵심 메서드는 loadUserByUsername()이다.
이 메서드는 인증에 필요한 사용자 정보를 UserDetails 형태로 반환한다.


UserDetails는 Spring Security가 이해하는 사용자 정보 형식이다.
사용자명, 비밀번호, 권한, 계정 상태 정보를 제공한다.


로그인 요청은 UsernamePasswordAuthenticationFilter, AuthenticationManager, AuthenticationProvider, UserDetailsService, PasswordEncoder 흐름을 거쳐 처리된다.
인증에 성공하면 인증 완료 상태의 Authentication 객체가 만들어지고, 이 객체는 SecurityContext에 저장된다.


SecurityContextHolder를 통해 현재 인증 정보를 꺼낼 수 있고, Controller에서는 @AuthenticationPrincipal로 로그인한 사용자 정보를 쉽게 받을 수 있다.


결국 security5 이론의 핵심은 프로젝트의 사용자 정보를 Spring Security가 이해할 수 있는 UserDetails 형태로 제공하고, 인증 성공 후 그 정보가 Principal로 사용되는 흐름을 이해하는 것이다.



security5: 커스텀 UserDetails 객체 생성 실습

security5는 직접 만든 로그인 화면은 그대로 사용하고, 사용자 정보를 조회하는 방식만 직접 구현하는 예제이다.
security4에서는 application.yml의 기본 사용자 설정을 사용했다.
security5에서는 UserDetailsService를 직접 구현해서 duke, olaf 사용자를 코드 안에서 찾아 반환한다.


이 예제에서 개발자가 직접 작성하는 핵심은 세 가지이다.
첫째, UserDetailsService를 구현해서 사용자명을 기준으로 사용자 정보를 반환한다.
둘째, UserDetails로 사용할 LoginUser 클래스를 만든다.
셋째, 비밀번호 비교에 사용할 PasswordEncoder를 Bean으로 등록한다.


security5의 핵심은 사용자 조회는 개발자가 구현하고, 인증 검증과 인증 정보 저장은 Spring Security가 이어서 처리한다는 점이다.


security5에서 바뀌는 핵심

security4와 security5의 차이

security4에서는 직접 만든 로그인 화면을 사용했다.
로그인 화면 주소는 /login이고, 로그인 처리 주소는 /authentication이었다.
로그아웃도 /logout으로 처리했다.


security5에서도 이 로그인 화면과 로그아웃 흐름은 거의 그대로 유지된다.
달라지는 부분은 로그인 사용자 정보를 어디에서 가져오느냐이다.


security4에서는 application.yml에 작성된 기본 사용자 정보로 로그인했다.
하지만 security5에서는 application.yml에 로그인 계정을 적지 않는다.
대신 LoginUserDatailsServiceImpl이 사용자명을 기준으로 duke, olaf 사용자를 찾아 LoginUser로 반환한다.


정리하면 다음과 같다.

  • security4는 설정 파일의 기본 사용자 정보로 로그인한다.
  • security5는 개발자가 만든 UserDetailsService가 사용자 정보를 반환한다.
  • security4는 사용자 조회 로직을 직접 작성하지 않는다.
  • security5는 loadUserByUsername() 안에 사용자 조회 로직을 직접 작성한다.

즉, security5는 나중에 DB에서 사용자를 조회하는 구조로 넘어가기 전 단계이다.
현재는 실제 DB를 연결하지 않고 코드 안에서 duke, olaf 사용자를 직접 반환한다.


security5의 전체 흐름

security5의 흐름은 다음처럼 이어진다.

  • 사용자가 /login으로 접근한다.
  • 직접 만든 login.html 화면이 열린다.
  • 사용자가 계정과 비밀번호를 입력한다.
  • 로그인 폼이 /authentication으로 제출된다.
  • Spring Security가 로그인 요청을 가로챈다.
  • LoginUserDatailsServiceImpl.loadUserByUsername()이 호출된다.
  • 사용자명이 duke이면 duke / 1111 정보를 가진 LoginUser를 반환한다.
  • 사용자명이 olaf이면 olaf / 2222 정보를 가진 LoginUser를 반환한다.
  • PasswordEncoder가 입력 비밀번호와 반환된 사용자 비밀번호를 비교한다.
  • 인증에 성공하면 /로 이동한다.
  • MemberController가 로그인한 사용자명을 화면에 전달한다.
  • member.html에 로그인한 사용자명이 출력된다.

이 흐름에서 중요한 부분은 로그인 가능한 사용자가 application.yml에 있는 것이 아니라, LoginUserDatailsServiceImpl 코드 안에서 만들어지고 있다는 점이다.


application.yml에서 실행 설정을 확인한다

security5에서는 기본 사용자 정보를 application.yml에 작성하지 않는다

security5의 application.yml에는 애플리케이션 이름과 서버 포트만 작성되어 있다.
security2, security3, security4처럼 spring.security.user.name, spring.security.user.password를 사용하지 않는다.

# application.yml
spring:
  application:
    name: security5
server:
  port: 8088

이 설정에서 server.port는 8088이다.
따라서 브라우저에서는 localhost:8088로 접속한다.


중요한 점은 로그인 사용자 정보가 이 파일에 없다는 것이다.
security5에서는 로그인 계정을 설정 파일에서 읽지 않는다.
로그인 계정은 LoginUserDatailsServiceImpl 코드 안에서 반환하는 duke / 1111, olaf / 2222이다.


security5에서는 로그인 사용자 정보를 설정 파일이 아니라 UserDetailsService 구현 클래스에서 직접 반환한다.


Security5Application은 프로젝트 실행 시작 클래스이다

실행 클래스의 역할

Security5Application은 security5 프로젝트를 실행하는 시작 클래스이다.
이 클래스는 로그인 사용자를 찾거나 비밀번호를 비교하지 않는다.
애플리케이션을 시작하는 역할만 한다.

// Security5Application.java
package com.example.security5;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Security5Application {

    public static void main(String[] args) {
        // Spring Boot 애플리케이션을 실행한다.
        SpringApplication.run(Security5Application.class, args);
    }

}

보안 설정은 SecurityConfig에서 작성한다.
사용자 조회는 LoginUserDatailsServiceImpl에서 작성한다.
비밀번호 비교 방식은 PasswordConfig에서 등록한다.


즉, 실행 클래스는 출발점이고, 인증 흐름의 실제 구성은 다른 클래스들이 나누어 담당한다.


LoginUser는 Spring Security가 사용할 사용자 정보 객체이다

LoginUser를 따로 만드는 이유

Spring Security는 인증에 사용할 사용자 정보를 UserDetails 형태로 다룬다.
그래서 로그인 사용자를 직접 만들 때도 Spring Security가 이해할 수 있는 형태로 만들어야 한다.


security5에서는 LoginUser 클래스를 만든다.
이 클래스는 Spring Security가 제공하는 User 클래스를 상속한다.
User는 이미 UserDetails를 구현한 클래스이다.
따라서 LoginUser도 인증에 사용할 수 있는 사용자 정보 객체가 된다.


직접 UserDetails 인터페이스의 모든 메서드를 구현할 수도 있다.
하지만 처음부터 모든 메서드를 직접 구현하면 코드가 길어지고 복잡해진다.
그래서 이 예제에서는 기본 구현이 들어 있는 User 클래스를 상속해서 간단하게 만든다.


LoginUser 코드

// LoginUser.java
package com.example.security5.entity;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

/**
 * 사용자의 인증 정보를 나타내는 UserDetails 구현 클래스
 */
public class LoginUser extends User {

    public LoginUser(String username,
                     String password,
                     Collection<? extends GrantedAuthority> authorities) {
        // 부모 User 클래스에 사용자명, 비밀번호, 권한 목록을 전달한다.
        super(username, password, authorities);
    }

}

LoginUser 생성자는 username, password, authorities를 받는다.
이 값들은 그대로 부모 클래스인 User의 생성자로 전달된다.


여기서 username은 로그인할 사용자명이다.
password는 저장된 비밀번호이다.
authorities는 사용자의 권한 목록이다.


super(username, password, authorities)를 호출하면 부모 클래스가 UserDetails에 필요한 기본 동작을 처리한다.
그래서 계정 활성화 여부, 계정 만료 여부 같은 기본 상태 메서드를 직접 구현하지 않아도 된다.


User 클래스를 상속하면 얻는 장점

User 클래스를 상속하면 UserDetails 구현을 간단하게 끝낼 수 있다.
UserDetails는 사용자명, 비밀번호, 권한뿐 아니라 계정 상태를 확인하는 여러 메서드를 가진다.


직접 구현한다면 다음과 같은 메서드들을 모두 처리해야 한다.

  • getUsername()
  • getPassword()
  • getAuthorities()
  • isEnabled()
  • isAccountNonLocked()
  • isAccountNonExpired()
  • isCredentialsNonExpired()

하지만 User 클래스를 상속하면 이런 기본 메서드는 부모 클래스가 처리한다.
그래서 현재 예제에서는 생성자에서 사용자명, 비밀번호, 권한만 넘기면 된다.


LoginUserDatailsServiceImpl은 사용자 정보를 직접 조회한다

UserDetailsService를 구현한다

LoginUserDatailsServiceImpl은 UserDetailsService를 구현한 클래스이다.
현재 코드 기준 클래스명은 LoginUserDatailsServiceImpl이다.
이름에 Datails라고 되어 있지만, 역할은 로그인 사용자 상세 정보를 조회하는 서비스이다.


UserDetailsService에서 반드시 구현해야 하는 메서드는 loadUserByUsername()이다.
이 메서드는 로그인 화면에서 입력된 사용자명을 받아서, 인증에 사용할 UserDetails 객체를 반환한다.


즉, loadUserByUsername()은 단순히 사용자명이 맞는지만 확인하는 메서드가 아니다.
사용자명, 비밀번호, 권한 목록이 들어 있는 인증용 사용자 객체를 반환해야 한다.
그래야 Spring Security가 그 다음 단계에서 비밀번호를 비교할 수 있다.


LoginUserDatailsServiceImpl 코드

// LoginUserDatailsServiceImpl.java
package com.example.security5.service;

import java.util.Collections;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.example.security5.entity.LoginUser;

/**
 * UserDetailsService 구현 클래스
 */
@Service
public class LoginUserDatailsServiceImpl implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        // 입력된 사용자명이 duke인지 확인한다.
        if (username.equals("duke")) {
            // duke 사용자 정보를 UserDetails 형태로 반환한다.
            return new LoginUser("duke",
                    "1111",
                    Collections.emptyList());
        } else if (username.equals("olaf")) {
            // 입력된 사용자명이 olaf인지 확인한다.
            return new LoginUser("olaf",
                    "2222",
                    Collections.emptyList());
        } else {
            // 사용자가 없으면 인증 실패로 이어질 예외를 발생시킨다.
            throw new UsernameNotFoundException(
                    username + " => 사용자명이 존재하지 않습니다.");
        }
    }

}

이 코드는 입력된 사용자명에 따라 다른 LoginUser 객체를 반환한다.
사용자명이 duke이면 비밀번호가 1111인 사용자 정보를 반환한다.
사용자명이 olaf이면 비밀번호가 2222인 사용자 정보를 반환한다.


그 외 사용자명이 들어오면 UsernameNotFoundException을 발생시킨다.
이 예외는 해당 사용자명이 존재하지 않는다는 뜻이다.
Spring Security는 이 흐름을 인증 실패로 처리한다.


LoginUserDatailsServiceImpl은 입력된 사용자명을 기준으로 duke 또는 olaf의 UserDetails를 반환한다.
현재는 if문으로 사용자를 구분하지만, 실제 서비스에서는 이 위치가 DB 조회 로직으로 바뀐다.


duke와 olaf를 코드 안에서 반환하는 이유

현재 예제는 DB를 연결하기 전 단계이다.
그래서 실제 회원 테이블에서 사용자를 조회하지 않는다.
대신 if문으로 사용자명을 직접 비교해서 사용자 정보를 반환한다.


이 방식은 실제 서비스에 적합한 방식은 아니다.
사용자가 많아질 때마다 if문을 계속 추가할 수는 없기 때문이다.
하지만 인증 흐름을 학습하기에는 좋다.
사용자명이 들어오고, 그 사용자명에 맞는 UserDetails를 반환해야 한다는 구조가 잘 보이기 때문이다.


나중에 DB를 연결하면 이 부분은 다음 흐름으로 바뀐다.

  • 입력된 username을 받는다.
  • Repository나 Mapper로 DB에서 사용자를 조회한다.
  • 조회된 회원 정보를 UserDetails 형태로 바꾼다.
  • 그 UserDetails를 반환한다.

따라서 현재 if문은 실제 서비스 코드라기보다, DB 조회가 들어갈 자리를 미리 보여주는 학습용 구조이다.


권한 목록을 비워 둔 이유

LoginUser를 만들 때 세 번째 값으로 Collections.emptyList()를 넘긴다.
이 값은 권한 목록을 비워 둔다는 뜻이다.


현재 SecurityConfig에서는 권한별 접근 제어를 사용하지 않는다.
hasRole()이나 hasAuthority()로 권한을 검사하지 않고, anyRequest().authenticated()만 사용한다.


authenticated()는 권한 이름을 검사하는 설정이 아니다.
인증에 성공한 사용자인지만 확인한다.
그래서 권한 목록이 비어 있어도 로그인에 성공하면 보호된 페이지에 접근할 수 있다.


다만 실제 서비스에서는 권한 목록이 중요하다.
관리자 페이지처럼 특정 권한이 필요한 기능을 만들려면 GrantedAuthority에 권한 정보를 담아야 한다.


현재 예제에서 권한 목록이 비어 있어도 되는 이유는 권한 검사가 아니라 로그인 성공 여부만 확인하기 때문이다.


PasswordConfig는 비밀번호 비교 방식을 등록한다

PasswordEncoder가 필요한 이유

Spring Security는 비밀번호를 비교할 때 PasswordEncoder를 사용한다.
사용자가 입력한 비밀번호와 UserDetails에 들어 있는 비밀번호를 비교해야 하기 때문이다.


실제 서비스에서는 비밀번호를 원문 그대로 저장하지 않는다.
BCrypt 같은 방식으로 암호화해서 저장한다.
그리고 로그인할 때 입력한 평문 비밀번호와 저장된 암호화 비밀번호를 PasswordEncoder.matches()로 비교한다.


하지만 security5에서는 아직 암호화된 비밀번호를 사용하지 않는다.
학습 흐름을 단순하게 보기 위해 NoOpPasswordEncoder를 사용한다.
NoOpPasswordEncoder는 비밀번호를 암호화하지 않고 문자열 그대로 비교한다.


개발자가 구현하는 부분은 loadUserByUsername()에서 비밀번호가 담긴 UserDetails를 반환하는 것과 PasswordEncoder를 Bean으로 등록하는 것이다.
비밀번호 비교 자체는 DaoAuthenticationProvider가 PasswordEncoder.matches()를 사용해 처리한다.


PasswordConfig 코드

// PasswordConfig.java
package com.example.security5.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 비밀번호를 암호화하지 않고 그대로 비교하는 인코더를 반환한다.
        return NoOpPasswordEncoder.getInstance();
    }

}

이 코드는 PasswordEncoder 타입의 Bean을 등록한다.
반환하는 객체는 NoOpPasswordEncoder이다.


그래서 LoginUserDatailsServiceImpl에서 비밀번호를 "1111", "2222"처럼 그대로 반환해도 로그인 입력값과 비교할 수 있다.
예를 들어 사용자가 duke / 1111을 입력하면, 저장 비밀번호 "1111"과 입력 비밀번호 "1111"이 그대로 비교된다.


security2의 noop과 security5의 NoOpPasswordEncoder 차이

security2에서는 비밀번호 문자열 앞에 {noop}을 붙였다.
예를 들어 {noop}1111처럼 작성했다.
이 방식은 해당 비밀번호를 암호화하지 않고 비교하겠다는 표시를 비밀번호 문자열에 직접 넣는 방식이다.


security5에서는 비밀번호 문자열에 {noop}을 붙이지 않는다.
대신 PasswordConfig에서 NoOpPasswordEncoder를 Bean으로 등록한다.
그래서 저장 비밀번호를 "1111", "2222"처럼 그대로 반환해도 비교가 가능하다.


정리하면 차이는 다음과 같다.

  • security2는 비밀번호 값에 {noop}을 직접 붙인다.
  • security5는 NoOpPasswordEncoder를 Bean으로 등록한다.
  • security2는 사용자 정보 생성 코드 안에서 인코딩 방식을 표시한다.
  • security5는 비밀번호 비교 방식 자체를 PasswordEncoder Bean으로 제공한다.

둘 다 학습용으로 암호화 없이 비교하는 방식이다.
실제 서비스에서는 NoOpPasswordEncoder 대신 BCryptPasswordEncoder 같은 안전한 방식으로 바꿔야 한다.


SecurityConfig는 로그인 흐름을 설정한다

security5의 SecurityConfig는 security4와 거의 같다

security5의 SecurityConfig는 security4와 거의 같은 흐름을 가진다.
직접 만든 로그인 화면을 사용하고, 로그인 요청을 /authentication으로 받는다.
로그인 성공 시 /로 이동하고, 실패 시 /login?error로 이동한다.
로그아웃 성공 시 /login?logout으로 이동한다.


다만 security5에는 UserDetailsService 구현 클래스와 PasswordEncoder Bean이 추가로 존재한다.
SecurityConfig에서 이 둘을 직접 호출하지 않아도 된다.
로그인 요청이 들어오면 Spring Security의 인증 흐름이 등록된 UserDetailsService와 PasswordEncoder를 사용해 사용자 조회와 비밀번호 비교를 진행한다.


초보자가 여기서 헷갈리기 쉽다.
filterChain() 안에서 loadUserByUsername()을 직접 호출하는 코드가 보이지 않아도 사용자 조회가 빠진 것이 아니다.
로그인 요청이 UsernamePasswordAuthenticationFilter를 거쳐 인증 흐름에 들어가면 내부적으로 호출된다.


SecurityConfig 코드

// SecurityConfig.java
package com.example.security5.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // HTTP 요청에 대한 보안 설정
            .authorizeHttpRequests(requests -> requests
                    // /login은 인증 없이 접근할 수 있다.
                    .requestMatchers("/login").permitAll()
                    // 그 밖의 요청은 인증이 필요하다.
                    .anyRequest().authenticated())
            // 실습 편의를 위해 CSRF 방어 기능을 비활성화한다.
            .csrf((csrf) -> csrf.disable())
            // 폼 기반 로그인 설정
            .formLogin(form -> form
                    // 직접 만든 로그인 페이지 URL
                    .loginPage("/login")
                    // 로그인 form이 제출될 처리 URL
                    .loginProcessingUrl("/authentication")
                    // 사용자명 input name 지정
                    .usernameParameter("usernameInput")
                    // 비밀번호 input name 지정
                    .passwordParameter("passwordInput")
                    // 로그인 성공 시 이동할 URL
                    .defaultSuccessUrl("/", true)
                    // 로그인 실패 시 이동할 URL
                    .failureUrl("/login?error"))
            // 로그아웃 설정
            .logout(logout -> logout
                    // 로그아웃을 처리할 URL
                    .logoutUrl("/logout")
                    // 로그아웃 성공 시 이동할 URL
                    .logoutSuccessUrl("/login?logout")
                    // 로그아웃 시 세션 무효화
                    .invalidateHttpSession(true)
                    // 로그아웃 시 세션 쿠키 삭제
                    .deleteCookies("JSESSIONID"));

        // 설정한 내용으로 SecurityFilterChain을 만든다.
        return http.build();
    }

}

requestMatchers("/login").permitAll()은 로그인 화면을 인증 없이 열어 둔다.
로그인 화면은 아직 로그인하지 않은 사용자도 볼 수 있어야 하기 때문이다.


anyRequest().authenticated()는 /login을 제외한 나머지 요청에 인증을 요구한다.
따라서 /, /images/duke3d.png 같은 요청은 로그인 후에만 접근할 수 있다.


loginProcessingUrl("/authentication")은 로그인 폼이 제출될 주소이다.
이 주소는 Controller가 처리하지 않는다.
Spring Security의 UsernamePasswordAuthenticationFilter가 처리한다.


usernameParameter("usernameInput"), passwordParameter("passwordInput")은 로그인 폼의 입력 이름과 맞아야 한다.
이 이름이 맞아야 Spring Security가 요청에서 사용자명과 비밀번호를 꺼낼 수 있다.


userDetailsService와 passwordEncoder 필드는 이 코드 안에서 직접 호출되지 않는다.
이 예제에서 더 중요한 것은 LoginUserDatailsServiceImpl이 @Service로 등록되어 있고, PasswordConfig가 PasswordEncoder를 Bean으로 등록한다는 점이다.
이 두 객체가 있어야 인증 흐름에서 사용자 조회와 비밀번호 비교가 가능하다.


LoginForm과 login.html은 입력 이름을 맞춰야 한다

LoginForm 코드

LoginForm은 로그인 화면에서 사용할 입력값을 담는 객체이다.
usernameInput은 사용자명 입력값이고, passwordInput은 비밀번호 입력값이다.

// LoginForm.java
package com.example.security5.form;

import lombok.Data;

@Data
public class LoginForm {
    /** 사용자명 */
    private String usernameInput;
    /** 비밀번호 */
    private String passwordInput;
}

LoginForm의 필드 이름은 login.html의 th:field와 연결된다.
또한 SecurityConfig의 usernameParameter(), passwordParameter()와도 맞아야 한다.


즉, 세 곳의 이름이 같은 흐름으로 맞아야 한다.

  • LoginForm.usernameInput
  • login.html의 th:field="*{usernameInput}"
  • SecurityConfig의 usernameParameter("usernameInput")

비밀번호도 마찬가지이다.

  • LoginForm.passwordInput
  • login.html의 th:field="*{passwordInput}"
  • SecurityConfig의 passwordParameter("passwordInput")

이 이름이 어긋나면 사용자가 값을 입력해도 Spring Security가 로그인 요청에서 값을 제대로 읽지 못한다.


LoginController 코드

// LoginController.java
package com.example.security5.controller;

import com.example.security5.form.LoginForm;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;

@Controller
public class LoginController {

    @GetMapping("/login")
    public String showLogin(@ModelAttribute LoginForm form) {
        // login.html에서 사용할 LoginForm 객체를 준비한다.
        return "login";
    }

}

LoginController는 /login 요청을 받아 login.html 화면을 보여준다.
로그인 처리를 직접 하지는 않는다.


@ModelAttribute LoginForm form은 login.html에서 사용할 LoginForm 객체를 준비하는 역할을 한다.
그래서 화면에서 th:object="${loginForm}"를 사용할 수 있다.


중요한 점은 /login과 /authentication을 구분하는 것이다.
/login은 화면을 보여주는 주소이다.
/authentication은 로그인 폼이 제출되는 주소이다.
이 제출 요청은 LoginController가 아니라 Spring Security가 처리한다.


login.html 코드

// login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>로그인</title>
</head>
<body>
    <h2>로그인 화면</h2>
    <!-- 로그인 실패 시 표시 -->
    <div th:if="${param.error}">
        <p style="color: red;">사용자명이나 비밀번호가 올바르지 않습니다.</p>
    </div>
    <!-- 로그아웃 후 표시 -->
    <div th:if="${param.logout}">
        <p style="color: blue">로그아웃했습니다.</p>
    </div>
    <form th:action="@{/authentication}" method="post" th:object="${loginForm}">
        <div>
            <!-- 사용자명 입력 -->
            <label for="usernameInput">계정</label>
            <input type="text" th:field="*{usernameInput}">
        </div>
        <div>
            <!-- 비밀번호 입력 -->
            <label for="passwordInput">암호</label>
            <input type="password" th:field="*{passwordInput}">
        </div>
        <hr>
        <div>
            <!-- 로그인 요청 전송 -->
            <input type="submit" value="로그인">
        </div>
    </form>
</body>
</html>

th:action="@{/authentication}"은 로그인 폼을 /authentication으로 제출한다는 뜻이다.
이 주소는 SecurityConfig의 loginProcessingUrl("/authentication")과 같아야 한다.


th:field="*{usernameInput}"은 사용자명 입력칸을 LoginForm.usernameInput과 연결한다.
th:field="*{passwordInput}"은 비밀번호 입력칸을 LoginForm.passwordInput과 연결한다.


로그인에 실패하면 /login?error로 돌아온다.
그래서 th:if="${param.error}" 조건이 참이 되고 실패 메시지가 표시된다.


로그아웃에 성공하면 /login?logout으로 돌아온다.
그래서 th:if="${param.logout}" 조건이 참이 되고 로그아웃 메시지가 표시된다.


MemberController는 로그인한 사용자 정보를 화면에 넘긴다

@AuthenticationPrincipal로 현재 로그인한 사용자 정보를 받는다

로그인에 성공하면 인증된 사용자 정보가 SecurityContextHolder에 저장된다.
이 사용자 정보는 Controller에서 꺼내 사용할 수 있다.


security5에서는 @AuthenticationPrincipal을 사용한다.
@AuthenticationPrincipal은 현재 인증된 사용자의 Principal 객체를 메서드 매개변수로 받게 해 준다.


현재 LoginUser는 User 클래스를 상속한다.
그래서 MemberController에서는 부모 타입인 User로 로그인한 사용자 정보를 받을 수 있다.


MemberController 코드

// MemberController.java
package com.example.security5.controller;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class MemberController {

    @GetMapping("/")
    public String member(@AuthenticationPrincipal User user, Model model) {
        // 현재 로그인한 사용자 정보를 콘솔에 출력한다.
        System.out.println(user);
        // 화면에서 사용할 사용자명을 모델에 저장한다.
        model.addAttribute("username", user.getUsername());
        // member.html 화면을 반환한다.
        return "member";
    }

}

@AuthenticationPrincipal User user는 현재 로그인한 사용자 정보를 받는 부분이다.
duke로 로그인했다면 user.getUsername()은 duke를 반환한다.
olaf로 로그인했다면 user.getUsername()은 olaf를 반환한다.


model.addAttribute("username", user.getUsername())는 사용자명을 화면에 전달한다.
그래서 member.html에서 [[${ username }]]으로 로그인한 사용자명을 출력할 수 있다.


member.html 코드

// member.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>회원페이지</title>
    <style>
        a {
            text-decoration : none;
        }
    </style>
</head>
<body>
    <h2>로그인을 했군요!! [[${ username }]] 님~~~</h2>
    <hr>
    <a href="/images/duke3d.png">로그인했으니 볼 수 있어요... 클릭해 보세요..</a><br>
    <hr>
    <button onclick="location.href='http://localhost:8088/logout'">로그아웃</button>
</body>
</html>

[[${ username }]]은 Model에 저장된 username 값을 화면에 출력한다.
즉, MemberController에서 넘긴 로그인 사용자명이 회원 페이지에 표시된다.


이미지 링크는 /images/duke3d.png로 이동한다.
현재 설정에서는 /login만 공개되어 있으므로 이미지 요청도 로그인한 상태에서만 볼 수 있다.


로그아웃 버튼은 /logout으로 이동한다.
현재 예제는 csrf.disable()을 사용하기 때문에 버튼의 location.href 방식으로 로그아웃 흐름을 테스트할 수 있다.


security5 실행 결과 확인

실행 영상은 로그인 성공, 로그아웃, 로그인 실패, 다른 사용자 로그인 성공 흐름을 보여준다

브라우저에서 localhost:8088/login으로 접근하면 직접 만든 로그인 화면이 나타난다.
먼저 사용자명에 duke, 비밀번호에 1111을 입력하면 로그인 요청이 /authentication으로 전송된다.


그 다음 Spring Security는 LoginUserDatailsServiceImpl.loadUserByUsername("duke")를 호출한다.
이 메서드는 duke / 1111 정보를 가진 LoginUser 객체를 반환한다.


PasswordEncoder는 입력 비밀번호 1111과 저장 비밀번호 1111을 비교한다.
현재 NoOpPasswordEncoder를 사용하므로 문자열이 그대로 비교된다.
값이 일치하면 인증에 성공한다.


인증에 성공하면 /로 이동하고 MemberController가 실행된다.
MemberController는 @AuthenticationPrincipal로 로그인 사용자 정보를 받고, 사용자명 duke를 Model에 저장한다.
그래서 member.html에는 로그인을 했군요!! duke 님~~~처럼 표시된다.


그 다음 로그아웃 버튼을 누르면 /logout 요청이 발생한다.
로그아웃이 성공하면 logoutSuccessUrl("/login?logout") 설정에 따라 /login?logout으로 이동한다.
이때 login.html의 th:if="${param.logout}" 조건이 동작해서 로그아웃했습니다. 메시지가 표시된다.


이후 잘못된 계정 또는 비밀번호로 로그인을 시도하면 인증에 실패한다.
인증에 실패하면 failureUrl("/login?error") 설정에 따라 /login?error로 이동한다.
이때 login.html의 th:if="${param.error}" 조건이 동작해서 사용자명이나 비밀번호가 올바르지 않습니다. 메시지가 표시된다.


마지막으로 사용자명에 olaf, 비밀번호에 2222를 입력하면 다시 로그인 요청이 /authentication으로 전송된다.
이번에는 LoginUserDatailsServiceImpl.loadUserByUsername("olaf")가 호출된다.
이 메서드는 olaf / 2222 정보를 가진 LoginUser 객체를 반환한다.


NoOpPasswordEncoder가 입력 비밀번호 2222와 저장 비밀번호 2222를 그대로 비교하고, 값이 일치하면 인증에 성공한다.
인증에 성공하면 /로 이동하고, 회원 페이지에는 로그인을 했군요!! olaf 님~~~처럼 표시된다.


실행 영상은 duke / 1111 로그인 성공, 로그아웃, 잘못된 계정 또는 비밀번호 입력으로 인한 로그인 실패, olaf / 2222 로그인 성공 흐름을 순서대로 보여준다.
security5에서는 LoginUserDatailsServiceImpl이 입력된 사용자명에 따라 duke 또는 olaf의 UserDetails를 반환하고, 인증 성공 후 @AuthenticationPrincipal로 꺼낸 사용자명이 member.html에 표시된다.


security5 핵심 정리

개발자가 구현하는 부분과 Spring Security가 처리하는 부분

security5에서는 인증 흐름 전체를 개발자가 직접 구현하지 않는다.
개발자가 구현하는 부분은 사용자명을 기준으로 사용자 정보를 반환하는 부분이다.


구체적으로 개발자는 UserDetailsService를 구현한다.
그리고 loadUserByUsername() 안에서 사용자명에 맞는 UserDetails를 반환한다.
현재 예제에서는 duke와 olaf를 코드 안에서 직접 비교해 반환한다.


비밀번호 비교는 Spring Security가 처리한다.
이때 PasswordEncoder가 필요하다.
현재 예제에서는 학습용으로 NoOpPasswordEncoder를 Bean으로 등록했다.


인증에 성공하면 인증 정보는 SecurityContextHolder에 저장된다.
그 후 Controller에서는 @AuthenticationPrincipal로 현재 로그인한 사용자 정보를 꺼낼 수 있다.


반드시 기억해야 할 내용

  • application.yml에는 로그인 사용자 정보가 아니라 애플리케이션 이름과 서버 포트만 작성되어 있다.
  • security5의 로그인 계정은 application.yml이 아니라 LoginUserDatailsServiceImpl에서 반환한다.
  • UserDetailsService는 사용자 정보를 조회하는 인터페이스이다.
  • 핵심 메서드는 loadUserByUsername()이다.
  • loadUserByUsername()은 사용자명을 받아 UserDetails를 반환한다.
  • LoginUserDatailsServiceImpl은 현재 코드 기준 UserDetailsService 구현 클래스이다.
  • duke로 로그인하면 duke / 1111 사용자 정보가 반환된다.
  • olaf로 로그인하면 olaf / 2222 사용자 정보가 반환된다.
  • 그 외 사용자명은 UsernameNotFoundException으로 처리된다.
  • LoginUser는 User를 상속한 사용자 정보 객체이다.
  • User는 UserDetails를 구현한 Spring Security 기본 클래스이다.
  • Collections.emptyList()는 권한 목록을 비워 둔다는 뜻이다.
  • 현재 예제는 권한 검사가 아니라 인증 성공 여부 확인이 핵심이다.
  • PasswordConfig는 PasswordEncoder를 Bean으로 등록한다.
  • 현재 예제에서는 학습용으로 NoOpPasswordEncoder를 사용한다.
  • NoOpPasswordEncoder는 비밀번호를 암호화하지 않고 그대로 비교한다.
  • security2는 {noop}을 비밀번호 앞에 붙였다.
  • security5는 NoOpPasswordEncoder를 Bean으로 등록한다.
  • SecurityConfig의 UserDetailsService, PasswordEncoder 필드는 직접 호출용이 아니라 인증 흐름에서 사용할 재료이다.
  • @AuthenticationPrincipal을 사용하면 현재 로그인한 사용자 정보를 Controller에서 받을 수 있다.
  • LoginUser는 User를 상속하므로 @AuthenticationPrincipal User user로 받을 수 있다.
  • user.getUsername()으로 로그인한 사용자명을 꺼낼 수 있다.

security5의 핵심은 UserDetailsService로 사용자 정보를 직접 반환하고, 그 이후 인증 검증은 Spring Security가 처리하게 만드는 것이다.
이 흐름을 이해하면 다음 단계에서 코드 안의 사용자 정보 대신 H2 Database와 MyBatis로 사용자 정보를 조회하는 구조로 자연스럽게 확장할 수 있다.




H2 Database 기반 인증 이론

security6는 security5에서 코드 안에 직접 작성했던 사용자 정보를 DB에서 조회하는 구조로 확장하는 단계이다.
security5에서는 LoginUserDatailsServiceImpl 안에서 duke, olaf 사용자를 직접 만들었다.
하지만 실제 서비스에서는 사용자 정보를 코드 안에 직접 적지 않는다.
사용자 정보는 보통 DB에 저장하고, 로그인 요청이 들어오면 DB에서 사용자 정보를 조회한다.


security6에서는 이 흐름을 연습하기 위해 H2 Database와 MyBatis를 사용한다.
H2 Database는 실습용으로 가볍게 사용할 수 있는 Java 기반 데이터베이스이다.
MyBatis는 직접 작성한 SQL로 DB 데이터를 조회할 수 있게 도와주는 프레임워크이다.


security6의 핵심은 UserDetailsService가 코드 안의 고정 사용자 정보가 아니라 DB에서 조회한 사용자 정보를 반환하도록 바뀐다는 점이다.


security5와 security6의 차이

security5는 코드 안에서 사용자 정보를 직접 만들었다

security5에서는 LoginUserDatailsServiceImpl 안에 사용자 정보가 직접 들어 있었다.
사용자명이 duke이면 duke / 1111 정보를 가진 LoginUser를 반환했다.
사용자명이 olaf이면 olaf / 2222 정보를 가진 LoginUser를 반환했다.


이 방식은 인증 흐름을 이해하기에는 좋다.
사용자명이 들어오면 UserDetailsService가 UserDetails를 반환해야 한다는 구조가 바로 보이기 때문이다.


하지만 실제 서비스에는 적합하지 않다.
사용자가 늘어날 때마다 if문을 추가할 수 없고, 회원가입으로 생성된 사용자 정보도 코드에 직접 넣을 수 없기 때문이다.


security6는 DB에서 사용자 정보를 조회한다

security6에서는 사용자 정보를 코드 안에 직접 적지 않는다.
사용자 정보를 DB 테이블에 저장하고, 로그인 요청이 들어오면 입력된 사용자명으로 DB를 조회한다.


조회 결과가 있으면 그 값을 UserDetails 형태로 바꿔서 반환한다.
조회 결과가 없으면 사용자가 존재하지 않는 것으로 보고 인증 실패로 이어진다.


즉, security6의 흐름은 다음처럼 바뀐다.

  • 사용자가 로그인 화면에서 사용자명과 비밀번호를 입력한다.
  • 로그인 요청이 /authentication으로 전송된다.
  • Spring Security가 UserDetailsService.loadUserByUsername()을 호출한다.
  • UserDetailsService 구현체가 DB에서 사용자 정보를 조회한다.
  • 조회된 사용자 정보를 UserDetails 객체로 바꿔 반환한다.
  • PasswordEncoder가 입력 비밀번호와 저장 비밀번호를 비교한다.
  • 인증에 성공하면 SecurityContextHolder에 인증 정보가 저장된다.

security5의 고정 사용자 반환 코드가 security6에서는 DB 조회 코드로 바뀐다.


build.gradle에서 필요한 의존성을 확인한다

H2 Database와 MyBatis 의존성이 필요하다

security6에서는 H2 Database를 사용하기 때문에 build.gradle에 H2 의존성이 필요하다.
또 사용자 정보를 DB에서 조회해야 하므로 MyBatis 의존성도 함께 필요하다.


security6에서는 H2 Database와 MyBatis를 함께 사용해 인증 정보를 DB에서 조회한다.


runtimeOnly 'com.h2database:h2'에서 runtimeOnly는 실행할 때 필요한 의존성이라는 뜻이다.
코드를 작성할 때 직접 호출하는 라이브러리는 아니지만, 애플리케이션 실행 중 H2 Database에 연결하기 위해 필요하다.


mybatis-spring-boot-starter는 Spring Boot에서 MyBatis를 쉽게 사용할 수 있게 해 준다.
이 예제에서는 AuthenticationMapper가 SQL을 실행해서 authentications 테이블에서 사용자 정보를 조회한다.

// build.gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '4.0.6'
	id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(21)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	// H2 Console 사용을 위한 의존성
	implementation 'org.springframework.boot:spring-boot-h2console'
	// Spring Security 사용
	implementation 'org.springframework.boot:spring-boot-starter-security'
	// Thymeleaf 화면 처리
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	// Spring MVC 웹 요청 처리
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	// MyBatis 연동
	implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:4.0.1'
	// Thymeleaf에서 Spring Security 기능 사용
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
	// Lombok 컴파일 전용 설정
	compileOnly 'org.projectlombok:lombok'
	// 개발 편의 기능
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	// H2 Database 실행 시점 사용
	runtimeOnly 'com.h2database:h2'
	// Lombok 어노테이션 처리
	annotationProcessor 'org.projectlombok:lombok'
	// 테스트 의존성
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:4.0.1'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
	useJUnitPlatform()
}

이 의존성에서 security6 인증 흐름과 직접 연결되는 것은 Spring Security, MyBatis, H2 Database, Thymeleaf이다.
Spring Security는 인증 처리를 담당하고, MyBatis는 DB 조회를 담당한다.
H2 Database는 실습용 인증 데이터를 저장하고, Thymeleaf는 로그인 화면과 회원 화면을 보여준다.


H2 Database는 실습용으로 사용하기 좋은 데이터베이스이다

H2 Database의 의미

H2 Database는 Java로 작성된 관계형 데이터베이스 관리 시스템이다.
관계형 데이터베이스는 데이터를 표처럼 행과 열 구조로 저장하는 데이터베이스를 의미한다.


H2 Database는 가볍고 설정이 간단해서 실습에서 자주 사용된다.
별도 데이터베이스 서버를 설치하지 않아도 프로젝트 안에서 데이터베이스 동작을 확인할 수 있다.


H2 Database는 Java 기반 관계형 데이터베이스이며, 실습에서는 In-Memory 방식으로 사용할 수 있다.


In-Memory는 데이터를 파일이 아니라 메모리에 저장하는 방식을 의미한다.
메모리는 애플리케이션이 실행되는 동안 사용하는 임시 저장 공간이다.
그래서 애플리케이션을 종료하면 메모리에 있던 데이터가 사라질 수 있다.


In-Memory Database는 실행할 때마다 초기화된다

H2 Database를 In-Memory 방식으로 사용하면 애플리케이션이 실행되는 동안에만 데이터가 유지된다.
애플리케이션을 종료하면 데이터가 초기화될 수 있다.


운영 서비스에서는 이 방식이 적합하지 않을 수 있다.
회원가입 정보나 게시글 데이터는 서버를 다시 실행해도 유지되어야 하기 때문이다.


하지만 실습에서는 오히려 편하다.
애플리케이션을 실행할 때마다 schema.sql과 data.sql로 정해진 테이블과 데이터를 새로 만들 수 있기 때문이다.
그래서 매번 같은 상태에서 로그인 테스트를 반복할 수 있다.


정리하면 In-Memory Database의 특징은 다음과 같다.

  • 애플리케이션 실행 중에 메모리에 데이터가 만들어진다.
  • 애플리케이션이 종료되면 데이터가 초기화될 수 있다.
  • 실습에서는 매번 같은 초기 데이터로 테스트하기 좋다.
  • 운영 서비스의 영구 저장소로 사용하기에는 적합하지 않다.

security6에서는 이 특징을 이용해서 인증 테이블과 테스트 사용자 데이터를 준비한다.


application.yml에서 H2 연결 정보를 설정한다

H2 Console과 datasource를 설정한다

H2 Database 의존성을 추가했다고 해서 바로 원하는 방식으로 사용할 수 있는 것은 아니다.
애플리케이션이 어떤 데이터베이스에 연결할지 설정해야 한다.
또 브라우저에서 DB 상태를 확인할 수 있도록 H2 Console도 켜야 한다.


이 설정은 application.yml에 작성한다.
security6에서는 H2 Console을 켜고, datasource 정보를 설정해서 애플리케이션이 H2 Database에 연결할 수 있게 만든다.

// application.yml
spring :
  application :
    name : security6
    # H2 데이터베이스 콘솔을 활성화하기 위한 설정
  h2 :
    console :
      enabled : true

  # 데이터베이스 접속 URL
  datasource :
    url : jdbc:h2:mem:testdb

    # H2 데이터베이스 드라이버 클래스
    driver-class-name : org.h2.Driver

    # 데이터베이스 접속 사용자 이름
    username : test

    # 데이터베이스 접속 비밀번호는 비워 둔다.
    password :

server :
  port : 8088

spring.h2.console.enabled를 true로 설정하면 브라우저에서 H2 Console에 접근할 수 있다.
H2 Console은 DB 테이블과 데이터를 직접 확인할 수 있는 화면이다.


datasource.url의 jdbc:h2:mem:testdb는 H2 Database를 메모리 방식으로 사용한다는 뜻이다.
driver-class-name은 H2 Database 드라이버를 지정하는 설정이다.
username은 test이고, password는 비워 둔다.


server.port가 8088이므로 브라우저에서는 localhost:8088로 접속한다.
H2 Console도 이 포트를 기준으로 접근한다.


H2 Console은 SecurityConfig 설정도 함께 필요하다

H2 Console을 사용하려면 application.yml에서 콘솔을 켜는 것만으로 끝나지 않는다.
Spring Security가 적용된 프로젝트에서는 /h2-console/** 요청도 보안 필터를 지난다.


그래서 SecurityConfig에서 /h2-console/**을 permitAll() 대상으로 열어 둔다.
또 H2 Console은 내부 화면을 frame 구조로 사용하므로, 같은 출처에서는 frame을 허용하도록 frameOptions 설정도 함께 필요하다.


정리하면 H2 Console 사용에는 두 단계가 필요하다.

  • application.yml에서 spring.h2.console.enabled를 true로 설정한다.
  • SecurityConfig에서 /h2-console/** 접근을 허용하고 frameOptions를 조정한다.

H2 Console은 데이터베이스 확인 화면이므로, 보안 설정에서 접근 허용과 frame 허용을 함께 처리해야 정상적으로 사용할 수 있다.


schema.sql과 data.sql이 초기 DB 상태를 만든다

schema.sql은 테이블 구조를 만든다

schema.sql은 테이블 구조를 만드는 파일이다.
애플리케이션이 실행될 때 필요한 테이블을 먼저 준비한다.


현재 예제에서는 todos 테이블과 authentications 테이블을 만든다.
인증 흐름에서 직접 중요한 테이블은 authentications이다.
이 테이블에는 로그인 사용자명과 비밀번호가 저장된다.
현재 단계에서는 권한 컬럼이 없다.

// schema.sql
-- 테이블이 존재하면 삭제
DROP TABLE IF EXISTS todos;
DROP TABLE IF EXISTS authentications;

-- 테이블 만들기
CREATE TABLE todos (
	-- id: 기본키
	id serial PRIMARY KEY,
	-- 할 일 내용
	todo varchar(255) NOT NULL,
	-- 상세 설명
	detail text,
	-- 작성 일자
	created_at timestamp without time zone,
	-- 수정 일자
	updated_at timestamp without time zone
);

-- 인증 정보를 저장하는 테이블
CREATE TABLE authentications (
	-- 사용자명: 기본키
	username VARCHAR(50) PRIMARY KEY,
	-- 비밀번호
	password VARCHAR(255) NOT NULL
);

DROP TABLE IF EXISTS는 같은 이름의 테이블이 이미 있으면 먼저 삭제하겠다는 뜻이다.
실습에서는 실행할 때마다 깨끗한 상태에서 테이블을 다시 만들기 위해 사용한다.


authentications.username은 로그인할 때 입력하는 사용자명이다.
기본키이기 때문에 같은 사용자명이 중복될 수 없다.


authentications.password는 저장된 비밀번호이다.
이 값은 평문 비밀번호가 아니라 BCrypt로 해시 처리된 문자열이다.


현재 security6의 authentications 테이블에는 권한 컬럼이 없다.
그래서 뒤에서 LoginUser를 만들 때 권한 목록은 Collections.emptyList()로 비워 둔다.
현재 단계의 핵심은 권한 제어가 아니라 DB에서 사용자명과 비밀번호를 조회해 인증하는 흐름이다.


data.sql은 초기 데이터를 넣는다

data.sql은 테이블에 초기 데이터를 넣는 파일이다.
security6에서는 todos 테스트 데이터와 로그인 테스트에 사용할 admin 인증 데이터를 넣는다.

// data.sql
-- 첫 번째 데이터 등록
INSERT INTO todos (todo, detail, created_at, updated_at)
VALUES
('쇼핑', '마트에서 식재료 구입하기', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
-- 두 번째 데이터 등록
INSERT INTO todos (todo, detail, created_at, updated_at)
VALUES
('도서관에 가기', '책 빌리기', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
-- 세 번째 데이터 등록
INSERT INTO todos (todo, detail, created_at, updated_at)
VALUES
('헬스장 가기', '운동하기', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

-- 인증 테이블에 더미 데이터를 추가 : 패스워드 : adminpass
INSERT INTO authentications (username, password) VALUES ('admin', '$2a$10$b4kM8vzoH1dlO8pm7YL48e35mZgmprtUo2Ct6cS/2Z85or6EwjCiC');

admin 사용자의 실제 입력 비밀번호는 adminpass이다.
하지만 DB에 저장된 값은 adminpass가 아니다.
BCrypt로 해시 처리된 문자열이 저장되어 있다.


로그인할 때 사용자는 adminpass를 입력한다.
그 다음 BCryptPasswordEncoder.matches()가 입력한 adminpass와 DB에 저장된 해시 문자열이 같은 비밀번호에서 나온 값인지 확인한다.


security6에서는 비밀번호를 평문으로 저장하지 않고 BCrypt 해시 문자열로 저장한다.


PasswordGenerator는 data.sql에 넣을 해시 비밀번호를 만든다

BCrypt 해시 문자열을 미리 생성한다

data.sql에 들어가는 비밀번호 값은 직접 입력하는 평문 비밀번호가 아니다.
adminpass를 BCrypt 방식으로 해시 처리한 문자열이다.


이 해시 문자열을 만들기 위해 PasswordGenerator를 사용한다.
PasswordGenerator는 BCryptPasswordEncoder로 adminpass를 암호화한 뒤 콘솔에 출력한다.
그 출력값을 data.sql의 password 값으로 넣는다.

// PasswordGenerator.java
package com.example.security6;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

/**
* 해시화된 문자열을 반환하는 클래스
*/
public class PasswordGenerator {
	public static void main(String[] args) {
		// BCrypt 인스턴스화
		BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
		// 입력값
		String rawPassword = "adminpass";
		// 비밀번호 해시화
		String encodedPassword = encoder.encode(rawPassword);
		// 출력
		System.out.println("해시화된 비밀번호: " + encodedPassword);
	}
}

encoder.encode(rawPassword)는 평문 비밀번호를 해시 문자열로 바꾼다.
여기서는 adminpass가 해시 문자열로 바뀐다.


PasswordGenerator를 실행하면 adminpass를 BCrypt 방식으로 해시 처리한 문자열이 콘솔에 출력된다.
이 출력값을 data.sql의 password 값으로 사용할 수 있다.


BCrypt는 같은 비밀번호를 해시 처리해도 매번 같은 문자열이 나오지 않을 수 있다.
내부적으로 salt를 사용하기 때문이다.
salt는 같은 비밀번호라도 다른 해시값이 나오도록 섞어 주는 값이다.


그래서 위 이미지의 해시 문자열과 data.sql에 들어간 해시 문자열이 달라도 틀린 것이 아니다.
중요한 것은 두 해시 문자열이 모두 평문 비밀번호 adminpass를 기준으로 만들어진 값이라는 점이다.
BCryptPasswordEncoder.matches()는 저장된 해시 문자열 안의 정보를 이용해 입력 비밀번호가 맞는지 검증할 수 있다.


정리하면 흐름은 다음과 같다.

  • PasswordGenerator에서 평문 비밀번호 adminpass를 준비한다.
  • BCryptPasswordEncoder.encode()로 해시 문자열을 만든다.
  • 출력된 해시 문자열을 data.sql의 password 값으로 넣는다.
  • 사용자는 로그인 화면에 adminpass를 입력한다.
  • BCryptPasswordEncoder.matches()가 입력값과 저장 해시를 비교한다.

이 구조 덕분에 DB에는 평문 비밀번호를 저장하지 않고도 로그인 인증을 처리할 수 있다.


H2 Database 기반 인증 이론 정리

security6에서는 사용자 정보를 코드 안에서 직접 반환하지 않는다.
대신 H2 Database에 사용자 정보를 저장하고, 로그인 요청이 들어오면 DB에서 사용자 정보를 조회한다.


이 흐름을 만들기 위해 build.gradle에 H2 Database와 MyBatis 의존성을 추가한다.
그리고 application.yml에서 H2 연결 설정과 H2 Console 설정을 작성한다.
또 schema.sql로 테이블을 만들고, data.sql로 테스트 데이터를 넣는다.


정리하면 security6의 준비 흐름은 다음과 같다.

  • build.gradle에 H2 Database 의존성을 추가한다.
  • build.gradle에 MyBatis 의존성을 추가한다.
  • application.yml에서 H2 연결 정보를 설정한다.
  • application.yml에서 H2 Console을 사용할 수 있게 설정한다.
  • SecurityConfig에서 /h2-console/** 접근 허용과 frameOptions 설정을 연결한다.
  • schema.sql로 인증 정보를 저장할 authentications 테이블을 만든다.
  • data.sql로 로그인 테스트에 사용할 admin 데이터를 넣는다.
  • admin의 실제 입력 비밀번호는 adminpass이다.
  • DB에는 adminpass가 아니라 BCrypt 해시 문자열이 저장된다.
  • PasswordGenerator는 adminpass를 BCrypt 해시 문자열로 바꾸는 보조 코드이다.
  • UserDetailsService는 코드 안의 if문 대신 DB에서 사용자 정보를 조회한다.
  • 조회한 사용자 정보를 UserDetails로 바꿔 Spring Security에 반환한다.

security6는 UserDetailsService의 사용자 조회 대상을 코드 내부 데이터에서 DB 데이터로 바꾸는 단계이다.
이 흐름을 이해하면 다음 코드에서 H2 Database, MyBatis, Mapper, 인증 테이블이 왜 필요한지 자연스럽게 이어진다.



security6: H2 Database와 MyBatis로 사용자 정보 조회하기

security6는 security5에서 코드 안에 직접 작성했던 사용자 정보를 DB에서 조회하는 구조로 바꾸는 예제이다.
security5에서는 LoginUserDatailsServiceImpl 안에서 duke, olaf 사용자를 직접 반환했다.
security6에서는 authentications 테이블에 저장된 사용자 정보를 MyBatis로 조회해서 UserDetails로 반환한다.


이 흐름을 만들기 위해 H2 Database, schema.sql, data.sql, AuthenticationMapper, LoginUserDatailsServiceImpl이 함께 사용된다.
사용자는 로그인 화면에서 admin / adminpass를 입력하고, 서버는 DB에 저장된 admin의 암호화 비밀번호와 입력 비밀번호를 비교한다.


security6의 핵심은 UserDetailsService가 코드 안의 고정 사용자 정보가 아니라 DB에서 조회한 사용자 정보를 반환한다는 점이다.


security6 전체 흐름

코드 안의 사용자 정보가 DB 조회로 바뀐다

security5에서는 사용자명을 if문으로 비교했다.
duke이면 duke / 1111 정보를 반환하고, olaf이면 olaf / 2222 정보를 반환했다.


security6에서는 이 방식이 사라진다.
사용자명과 비밀번호는 authentications 테이블에 저장된다.
로그인 요청이 들어오면 입력된 사용자명으로 DB를 조회하고, 조회 결과를 LoginUser 객체로 바꿔 반환한다.


전체 흐름은 다음과 같다.

  • schema.sql이 authentications 테이블을 만든다.
  • data.sql이 admin 사용자와 암호화된 비밀번호를 넣는다.
  • 사용자가 로그인 화면에서 admin / adminpass를 입력한다.
  • 로그인 요청은 /authentication으로 전송된다.
  • Spring Security가 UserDetailsService.loadUserByUsername()을 호출한다.
  • LoginUserDatailsServiceImpl이 AuthenticationMapper를 호출한다.
  • AuthenticationMapper가 authentications 테이블에서 admin 정보를 조회한다.
  • 조회 결과를 Authentication 객체에 담는다.
  • LoginUserDatailsServiceImpl이 Authentication을 LoginUser로 바꿔 반환한다.
  • BCryptPasswordEncoder가 입력 비밀번호와 저장된 해시 비밀번호를 비교한다.
  • 인증에 성공하면 SecurityContextHolder에 인증 정보가 저장된다.

개발자가 직접 처리하는 부분은 DB에서 사용자 정보를 조회하고 UserDetails로 반환하는 부분이다.
비밀번호 비교와 인증 성공 처리는 Spring Security가 이어서 담당한다.


Security6Application은 MyBatis Mapper 위치를 알려준다

@MapperScan은 Mapper 인터페이스를 찾게 해 준다

Security6Application은 Spring Boot 애플리케이션의 시작 클래스이다.
여기서 중요한 설정은 @MapperScan이다.


@MapperScan은 MyBatis의 Mapper 인터페이스가 어디에 있는지 Spring에게 알려준다.
security6에서는 AuthenticationMapper가 com.example.security6.repository 패키지에 있다.
그래서 @MapperScan(value={"com.example.security6.repository"})로 해당 패키지를 지정한다.

// Security6Application.java
package com.example.security6;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan(value={"com.example.security6.repository"})
public class Security6Application {

	public static void main(String[] args) {
		// Spring Boot 애플리케이션을 실행한다.
		SpringApplication.run(Security6Application.class, args);
	}

}

AuthenticationMapper는 DB에서 사용자 인증 정보를 조회하는 인터페이스이다.
이 인터페이스가 Spring Bean으로 등록되어야 LoginUserDatailsServiceImpl에서 주입받아 사용할 수 있다.


@MapperScan이 없으면 Mapper를 찾지 못해서 AuthenticationMapper 주입 과정에서 문제가 생길 수 있다.
따라서 security6에서는 @MapperScan으로 Mapper 위치를 먼저 알려주는 흐름이 필요하다.


PasswordConfig는 BCryptPasswordEncoder를 등록한다

security6에서는 NoOpPasswordEncoder를 사용하지 않는다

security5에서는 학습용으로 NoOpPasswordEncoder를 사용했다.
NoOpPasswordEncoder는 비밀번호를 암호화하지 않고 그대로 비교하는 방식이다.


security6에서는 BCryptPasswordEncoder를 사용한다.
DB에 저장된 비밀번호가 BCrypt 해시 문자열이기 때문이다.


사용자가 로그인 화면에 입력하는 값은 평문 비밀번호 adminpass이다.
반면 DB에 저장된 값은 BCrypt 해시 문자열이다.
따라서 단순 문자열 비교가 아니라 BCryptPasswordEncoder.matches() 비교가 필요하다.


PasswordConfig 코드

// PasswordConfig.java
package com.example.security6.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordConfig {
	@Bean
	public PasswordEncoder passwordEncoder() {
		// BCrypt 방식으로 비밀번호를 비교하는 인코더를 반환한다.
		return new BCryptPasswordEncoder();
	}
}

PasswordConfig는 PasswordEncoder 타입의 Bean을 등록한다.
반환 객체는 BCryptPasswordEncoder이다.


이 Bean이 있어야 Spring Security 인증 흐름에서 입력 비밀번호와 DB에 저장된 해시 비밀번호를 비교할 수 있다.


security6에서는 DB에 암호화된 비밀번호가 저장되어 있으므로 BCryptPasswordEncoder를 사용해야 한다.


Authentication은 DB 조회 결과를 담는 객체이다

authentications 테이블의 한 건을 담는다

Authentication 클래스는 authentications 테이블에서 조회한 결과를 담는 객체이다.
현재 테이블에는 username, password 컬럼만 있다.
그래서 Authentication 클래스도 username, password 필드만 가진다.


이 객체는 로그인 성공 정보를 뜻하는 Spring Security의 Authentication과 이름이 같다.
그래서 초보자가 헷갈릴 수 있다.


여기서의 Authentication은 com.example.security6.entity.Authentication이다.
즉, DB 조회 결과를 담기 위해 만든 프로젝트 내부 객체이다.
반면 Spring Security의 Authentication은 인증 성공 정보를 담는 보안 객체이다.
이 둘은 이름은 같지만 역할이 다르다.


Authentication 코드

// Authentication.java
package com.example.security6.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Authentication {
	/** 사용자명 */
	private String username;
	/** 비밀번호 */
	private String password;
}

@Data는 getter, setter, toString() 같은 메서드를 자동으로 만들어 주는 Lombok 어노테이션이다.
@NoArgsConstructor는 기본 생성자를 만든다.
@AllArgsConstructor는 모든 필드를 받는 생성자를 만든다.


AuthenticationMapper가 DB에서 username, password를 조회하면 그 결과가 이 객체에 담긴다.
그 다음 LoginUserDatailsServiceImpl은 이 객체에서 사용자명과 비밀번호를 꺼내 LoginUser를 만든다.


AuthenticationMapper는 username으로 인증 정보를 조회한다

Mapper는 SQL을 실행하는 인터페이스이다

AuthenticationMapper는 MyBatis에서 사용할 Mapper 인터페이스이다.
Mapper는 SQL을 실행해서 DB 데이터를 조회하는 역할을 한다.


security6에서는 로그인 화면에서 입력된 사용자명을 기준으로 authentications 테이블을 조회해야 한다.
그래서 selectByUsername() 메서드를 만든다.

// AuthenticationMapper.java
package com.example.security6.repository;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

import com.example.security6.entity.Authentication;

@Mapper
public interface AuthenticationMapper {

	/**
	* 사용자명으로 로그인 정보를 조회
	*/
	@Select("SELECT username, password FROM authentications WHERE username =#{username}")
	Authentication selectByUsername(String username);
}

@Mapper는 이 인터페이스가 MyBatis Mapper라는 뜻이다.
@Select에는 실행할 SQL을 작성한다.


SELECT username, password FROM authentications WHERE username =#{username}는 authentications 테이블에서 입력된 사용자명과 일치하는 데이터를 조회한다는 뜻이다.


여기서 #{username}은 메서드 매개변수로 받은 username 값이 들어가는 자리이다.
예를 들어 사용자가 admin을 입력하면 조건은 WHERE username = 'admin'처럼 동작한다.


조회 결과가 있으면 Authentication 객체로 반환된다.
조회 결과가 없으면 null이 반환될 수 있다.
이 null 여부는 뒤에서 LoginUserDatailsServiceImpl이 검사한다.


LoginUser는 Spring Security가 사용할 UserDetails 객체이다

DB 조회 결과를 최종적으로 UserDetails로 바꿔야 한다

AuthenticationMapper가 조회한 결과는 프로젝트 내부 객체인 Authentication이다.
하지만 Spring Security가 인증에 사용할 수 있는 최종 형태는 UserDetails이다.


그래서 조회 결과를 그대로 반환하면 안 된다.
LoginUser 객체로 바꿔서 반환해야 한다.


LoginUser는 Spring Security가 제공하는 User 클래스를 상속한다.
User는 이미 UserDetails를 구현한 클래스이다.
따라서 LoginUser는 Spring Security가 인증에 사용할 수 있는 사용자 정보 객체가 된다.


LoginUser 코드

// LoginUser.java
package com.example.security6.entity;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

/**
* 사용자의 인증 정보를 나타내는 UserDetails 구현 클래스
*/
public class LoginUser extends User {
	/** 최소한의 정보만 담은 UserDetails 구현 클래스인 User를 생성 */
	public LoginUser(String username,
		String password,
		Collection<? extends GrantedAuthority> authorities) {
		// 부모 User 클래스에 사용자명, 비밀번호, 권한 목록을 전달한다.
		super(username, password, authorities);
	}
}

LoginUser 생성자는 사용자명, 비밀번호, 권한 목록을 받는다.
이 값을 부모 클래스인 User에게 넘긴다.


현재 security6에서는 authentications 테이블에 권한 컬럼이 없다.
그래서 권한 목록은 뒤에서 Collections.emptyList()로 비워 둔다.


anyRequest().authenticated()는 권한 이름을 검사하지 않는다.
로그인에 성공한 사용자이기만 하면 접근을 허용한다.
그래서 현재 예제에서는 권한 목록이 비어 있어도 회원 화면에 접근할 수 있다.


LoginUserDatailsServiceImpl은 DB 조회 결과를 UserDetails로 반환한다

UserDetailsService 구현체가 DB 조회를 담당한다

LoginUserDatailsServiceImpl은 UserDetailsService를 구현한 클래스이다.
현재 코드 기준 클래스명은 LoginUserDatailsServiceImpl이다.
이름에 Datails라고 되어 있지만, 역할은 로그인 사용자 상세 정보를 조회하는 서비스이다.


security5와 가장 크게 달라지는 부분이 여기다.
security5에서는 if문으로 duke, olaf를 직접 반환했다.
security6에서는 AuthenticationMapper로 DB를 조회한다.


LoginUserDatailsServiceImpl 코드

// LoginUserDatailsServiceImpl.java
package com.example.security6.service;

import java.util.Collections;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.example.security6.entity.Authentication;
import com.example.security6.entity.LoginUser;
import com.example.security6.repository.AuthenticationMapper;

import lombok.RequiredArgsConstructor;

/**
 * 커스텀 인증 서비스
 */
@Service
@RequiredArgsConstructor
public class LoginUserDatailsServiceImpl implements UserDetailsService {
	/** DI */
	private final AuthenticationMapper authenticationMapper;

	@Override
	public UserDetails loadUserByUsername(String username)
			throws UsernameNotFoundException {
		// 인증 테이블에서 데이터를 가져온다.
		Authentication authentication = authenticationMapper.selectByUsername(username);
		// 대상 데이터가 있으면 UserDetails의 구현 클래스를 반환한다.
		if (authentication != null) {
			return new LoginUser(authentication.getUsername(),
					authentication.getPassword(),
					Collections.emptyList()
			);
		} else {
			// 사용자가 없으면 인증 실패로 이어질 예외를 발생시킨다.
			throw new UsernameNotFoundException(
					username + " => 사용자명이 존재하지 않습니다.");
		}
	}
}

authenticationMapper.selectByUsername(username)은 입력된 사용자명으로 authentications 테이블을 조회한다.
예를 들어 사용자가 admin을 입력하면 admin 사용자 정보를 찾는다.


조회 결과가 있으면 Authentication 객체가 반환된다.
이 객체에는 username과 password가 들어 있다.


그 다음 new LoginUser(authentication.getUsername(), authentication.getPassword(), Collections.emptyList())를 반환한다.
이렇게 하면 DB에서 조회한 사용자 정보가 Spring Security가 사용할 수 있는 UserDetails 형태로 바뀐다.


조회 결과가 없으면 UsernameNotFoundException을 발생시킨다.
이 예외는 해당 사용자명이 존재하지 않는다는 뜻이고, 인증 실패 흐름으로 이어진다.


security5와 security6의 UserDetailsService 차이

security5와 security6는 둘 다 UserDetailsService를 직접 구현한다.
하지만 사용자 정보를 가져오는 위치가 다르다.


security5에서는 코드 안의 if문으로 사용자 정보를 만들었다.
security6에서는 DB에서 사용자 정보를 조회한다.


차이를 정리하면 다음과 같다.

  • security5: if (username.equals("duke"))처럼 코드 안에서 비교한다.
  • security6: AuthenticationMapper.selectByUsername(username)으로 DB를 조회한다.
  • security5: 비밀번호가 코드 안에 "1111", "2222"로 들어 있다.
  • security6: 비밀번호가 authentications 테이블에 해시 문자열로 저장되어 있다.
  • security5: NoOpPasswordEncoder로 문자열을 그대로 비교한다.
  • security6: BCryptPasswordEncoder로 평문 입력값과 해시 문자열을 비교한다.

security6는 사용자 정보 저장 위치와 비밀번호 비교 방식이 실제 서비스 구조에 더 가까워진 단계이다.


SecurityConfig는 로그인 흐름과 H2 Console 접근을 설정한다

/login과 /h2-console/**은 인증 없이 접근 가능하게 한다

security6의 SecurityConfig는 security5와 거의 비슷하다.
직접 만든 로그인 화면을 사용하고, 로그인 요청을 /authentication으로 받는다.


다만 security6에서는 H2 Console도 사용해야 한다.
그래서 /h2-console/** 요청을 permitAll() 대상으로 열어 둔다.


또 H2 Console은 내부적으로 frame 구조를 사용한다.
Spring Security는 보안을 위해 기본적으로 frame 표시를 제한할 수 있다.
그래서 같은 출처에서는 frame을 허용하도록 frameOptions 설정을 추가한다.


SecurityConfig 코드

// SecurityConfig.java
package com.example.security6.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import lombok.RequiredArgsConstructor;

@Configuration
//@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

	private final UserDetailsService userDetailsService;
	private final PasswordEncoder passwordEncoder;

	// SecurityFilterChain의 빈 정의
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http
			// HTTP 요청에 대한 보안 설정
			.authorizeHttpRequests(requests -> requests
					// /login과 /h2-console/**은 인증 없이 접근할 수 있다.
					.requestMatchers("/login", "/h2-console/**").permitAll()
					// 그 밖의 요청은 인증이 필요하다.
					.anyRequest().authenticated())
			// 실습 편의를 위해 CSRF 방어 기능을 비활성화한다.
			.csrf((csrf) -> csrf.disable())
			// 폼 기반 로그인 설정
			.formLogin(form -> form
					// 직접 만든 로그인 페이지 URL
					.loginPage("/login")
					// 로그인 처리 URL
					.loginProcessingUrl("/authentication")
					// 사용자명의 name 속성 지정
					.usernameParameter("usernameInput")
					// 비밀번호의 name 속성 지정
					.passwordParameter("passwordInput")
					// 로그인 성공 시 이동할 URL
					.defaultSuccessUrl("/", true)
					// 로그인 실패 시 이동할 URL
					.failureUrl("/login?error"))
			// 로그아웃 설정
			.logout(logout -> logout
					// 로그아웃을 처리할 URL
					.logoutUrl("/logout")
					// 로그아웃 성공 시 이동할 URL
					.logoutSuccessUrl("/login?logout")
					// 로그아웃 시 세션 무효화
					.invalidateHttpSession(true)
					// 로그아웃 시 쿠키 삭제
					.deleteCookies("JSESSIONID")
		    )
			.headers(headers -> headers
					// H2 Console 화면을 같은 출처에서 frame으로 열 수 있게 한다.
					.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
			);
		// 설정한 내용으로 SecurityFilterChain을 만든다.
		return http.build();
	}
}

requestMatchers("/login", "/h2-console/**").permitAll()은 로그인 화면과 H2 Console 요청을 인증 없이 열어 둔다.
로그인 화면은 로그인하지 않은 사용자도 볼 수 있어야 한다.
H2 Console은 실습 중 DB 데이터를 확인하기 위해 접근해야 하므로 열어 둔다.


anyRequest().authenticated()는 그 밖의 요청에는 인증을 요구한다.
따라서 /, /images/duke3d.png 같은 요청은 로그인 후에만 접근할 수 있다.


csrf.disable()은 실습 편의를 위한 설정이다.
H2 Console 사용과 직접 만든 로그인/로그아웃 흐름을 단순하게 확인하기 위해 비활성화한다.
실제 서비스에서는 CSRF를 끄는 방식보다 _csrf 토큰을 사용하는 방식이 안전하다.


frameOptions(...sameOrigin)은 H2 Console 화면을 정상적으로 보기 위한 설정이다.
같은 출처에서 열리는 frame은 허용하겠다는 뜻이다.


SecurityConfig에는 UserDetailsService와 PasswordEncoder가 주입되어 있다.
하지만 filterChain() 안에서 직접 호출하지는 않는다.
이 두 객체는 Spring Security 인증 흐름에서 사용자 조회와 비밀번호 비교에 사용되는 재료로 준비된다.


LoginForm과 login.html은 security5 흐름을 그대로 사용한다

LoginForm 코드

LoginForm은 로그인 화면에서 입력한 값을 담는 객체이다.
사용자명 입력값은 usernameInput에 들어가고, 비밀번호 입력값은 passwordInput에 들어간다.

// LoginForm.java
package com.example.security6.form;

import lombok.Data;

@Data
public class LoginForm {
	/** 사용자명 */
	private String usernameInput;
	/** 비밀번호 */
	private String passwordInput;
}

LoginForm의 필드 이름은 login.html의 th:field와 연결된다.
또한 SecurityConfig의 usernameParameter("usernameInput"), passwordParameter("passwordInput")와도 맞아야 한다.


이 이름이 맞아야 Spring Security가 로그인 요청에서 사용자명과 비밀번호를 정확히 꺼낼 수 있다.


LoginController 코드

LoginController는 /login 요청이 들어왔을 때 직접 만든 로그인 화면을 보여준다.
로그인 처리를 직접 하지는 않는다.

// LoginController.java
package com.example.security6.controller;

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

import com.example.security6.form.LoginForm;

@Controller
public class LoginController {

	@GetMapping("/login")
	public String showLogin(@ModelAttribute LoginForm form) {
		// login.html에서 사용할 LoginForm 객체를 준비한다.
		return "login";
	}
}

/login은 화면을 보여주는 주소이다.
/authentication은 로그인 폼이 제출되는 주소이다.
/authentication 요청은 LoginController가 아니라 Spring Security가 처리한다.


login.html 코드

// login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
	<title>로그인</title>
</head>
<body>
	<h2>로그인 화면</h2>
	<!-- 로그인 실패 시 표시 -->
	<div th:if="${param.error}">
		<p style="color: red;">사용자명이나 비밀번호가 올바르지 않습니다.</p>
	</div>
	<!-- 로그아웃 후 표시 -->
	<div th:if="${param.logout}">
		<p style="color: blue">로그아웃했습니다.</p>
	</div>
	<form th:action="@{/authentication}" method="post" th:object="${loginForm}">
		<div>
			<!-- 사용자명 입력 -->
			<label for="usernameInput">계정</label>
			<input type="text" th:field="*{usernameInput}">
		</div>
		<div>
			<!-- 비밀번호 입력 -->
			<label for="passwordInput">암호</label>
			<input type="password" th:field="*{passwordInput}">
		</div>
		<hr>
		<div>
			<!-- 로그인 요청 전송 -->
			<input type="submit" value="로그인">
		</div>
	</form>
</body>
</html>

th:action="@{/authentication}"은 로그인 폼을 /authentication으로 제출한다는 뜻이다.
이 주소는 SecurityConfig의 loginProcessingUrl("/authentication")과 같아야 한다.


사용자명 입력칸의 th:field="*{usernameInput}"은 usernameParameter("usernameInput")과 연결된다.
비밀번호 입력칸의 th:field="*{passwordInput}"은 passwordParameter("passwordInput")과 연결된다.


로그인 실패 시 /login?error로 돌아오고, 로그아웃 성공 시 /login?logout으로 돌아온다.
그래서 login.html에서는 param.error, param.logout을 보고 메시지를 출력한다.


MamberController와 member.html은 로그인 성공 후 사용자명을 출력한다

현재 코드 기준 클래스명은 MamberController이다

업로드된 코드 기준으로 컨트롤러 클래스명은 MamberController이다.
일반적으로는 MemberController라는 이름이 더 자연스럽지만, 현재 코드 기준 클래스명은 MamberController로 되어 있다.


이 컨트롤러는 / 요청을 처리한다.
/ 요청은 인증이 필요한 요청이다.
로그인에 성공한 뒤 /로 이동하면 이 컨트롤러가 실행된다.


MamberController 코드

// MamberController.java
package com.example.security6.controller;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class MamberController {

	@GetMapping("/")
	public String member(@AuthenticationPrincipal User user, Model model) {
		// 화면에서 사용할 사용자명을 모델에 저장한다.
		model.addAttribute("username", user.getUsername());
		// member.html 화면을 반환한다.
		return "member";
	}
}

@AuthenticationPrincipal User user는 현재 로그인한 사용자 정보를 받는다.
admin으로 로그인하면 user.getUsername()은 admin을 반환한다.


model.addAttribute("username", user.getUsername())는 화면에 출력할 사용자명을 Model에 저장한다.
그래서 member.html에서 [[${ username }]]으로 로그인한 사용자명을 출력할 수 있다.


member.html 코드

// member.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
	<title>회원페이지</title>
	<style>
		a {
			text-decoration : none;
		}
	</style>
</head>
<body>
	<h2>로그인을 했군요!! [[${ username }]] 님~~~</h2>
	<hr>
	<a href="/images/duke3d.png">로그인했으니 볼 수 있어요...클릭해 보세요..</a><br>
	<hr>
	<button onclick="location.href='http://localhost:8088/logout'">로그아웃</button>
</body>
</html>

[[${ username }]]은 Model에 저장된 username 값을 화면에 출력한다.
따라서 admin으로 로그인하면 회원 페이지에 admin이 표시된다.


이미지 링크는 /images/duke3d.png로 이동한다.
현재 설정에서는 /login, /h2-console/**만 공개되어 있으므로 이미지 요청은 인증 후에 접근할 수 있다.


로그아웃 버튼은 /logout으로 이동한다.
현재 예제에서는 csrf.disable()을 사용하기 때문에 버튼의 location.href 방식으로 로그아웃 흐름을 테스트할 수 있다.


업로드된 member.html 코드에는 style 부분에 }|처럼 보이는 문자가 있었다.
최종 코드에서는 }만 남기고 |는 제거해야 한다.


H2 Console에서 테이블 데이터를 확인한다

H2 Console 접속 정보

application.yml에서 H2 Console을 켰기 때문에 브라우저에서 H2 Console에 접근할 수 있다.
서버 포트가 8088이므로 접속 주소는 localhost:8088/h2-console이다.


접속할 때 중요한 값은 JDBC URL이다.
application.yml의 datasource.url과 같은 값을 입력해야 한다.
현재 값은 jdbc:h2:mem:testdb이다.


접속 정보는 다음과 같이 정리할 수 있다.

  • 접속 주소: localhost:8088/h2-console
  • JDBC URL: jdbc:h2:mem:testdb
  • 사용자명: test
  • 비밀번호: 비워 둠

H2 Console에 접속하면 schema.sql과 data.sql로 만들어진 테이블과 초기 데이터를 직접 확인할 수 있다.


authentications 테이블에서 admin 데이터를 확인한다

authentications 테이블에는 로그인 테스트에 사용할 admin 사용자가 들어 있다.
이 사용자의 평문 비밀번호는 adminpass이다.
하지만 테이블에는 adminpass가 아니라 BCrypt 해시 문자열이 저장되어 있다.


확인할 핵심은 다음과 같다.

  • username 값이 admin인지 확인한다.
  • password 값이 평문 adminpass가 아닌 해시 문자열인지 확인한다.
  • 해시 문자열은 $2a$로 시작하는 BCrypt 형식이다.

이 데이터가 있어야 로그인 화면에서 admin / adminpass로 인증 테스트를 할 수 있다.


authentications 테이블에는 admin 사용자와 BCrypt 해시 비밀번호가 저장되어 있다.
로그인할 때는 사용자가 평문 adminpass를 입력하고, Spring Security가 저장된 해시 문자열과 비교한다.


TODOS 테이블에서 초기 데이터를 확인한다

TODOS 테이블은 로그인 인증에 직접 사용하는 테이블은 아니다.
하지만 schema.sql과 data.sql이 정상적으로 실행됐는지 확인할 수 있는 테스트 테이블이다.


data.sql에는 쇼핑, 도서관에 가기, 헬스장 가기 데이터가 들어 있다.
H2 Console에서 SELECT * FROM TODOS를 실행하면 이 초기 데이터가 실제로 들어갔는지 확인할 수 있다.


이 확인이 중요한 이유는 TODOS 데이터가 보인다는 것은 애플리케이션 실행 시 schema.sql로 테이블이 만들어지고, data.sql로 초기 데이터가 들어갔다는 뜻이기 때문이다.
같은 방식으로 authentications 테이블의 admin 데이터도 초기 데이터로 들어간다.


SELECT * FROM TODOS 실행 결과로 data.sql에 작성한 쇼핑, 도서관에 가기, 헬스장 가기 데이터가 조회된다.
이 화면은 H2 Database가 실행되고, 초기 SQL 파일이 정상적으로 적용됐는지 확인하는 결과이다.


정리하면 TODOS 테이블은 인증 테이블은 아니지만, H2 Database 초기화 흐름을 확인하는 보조 테이블이다.
반면 로그인 인증에 직접 사용되는 테이블은 authentications 테이블이다.


security6 실행 결과 확인

admin으로 로그인하면 DB 조회와 BCrypt 비교가 실행된다

브라우저에서 localhost:8088/login으로 접근하면 직접 만든 로그인 화면이 나타난다.
사용자명에 admin, 비밀번호에 adminpass를 입력하면 로그인 요청이 /authentication으로 전송된다.


그 다음 Spring Security는 LoginUserDatailsServiceImpl.loadUserByUsername("admin")을 호출한다.
이 메서드 안에서는 AuthenticationMapper.selectByUsername("admin")이 실행된다.


AuthenticationMapper는 authentications 테이블에서 admin 사용자의 username, password를 조회한다.
조회 결과가 있으면 Authentication 객체에 담긴다.


LoginUserDatailsServiceImpl은 조회된 Authentication 객체를 LoginUser 객체로 바꿔 반환한다.
이때 반환되는 비밀번호는 DB에 저장된 BCrypt 해시 문자열이다.


그 다음 BCryptPasswordEncoder가 사용자가 입력한 평문 비밀번호 adminpass와 DB에서 조회한 해시 비밀번호를 비교한다.
두 값이 같은 비밀번호에서 나온 것이라고 판단되면 인증에 성공한다.


인증에 성공하면 /로 이동하고 MamberController가 실행된다.
MamberController는 @AuthenticationPrincipal로 로그인 사용자 정보를 받고, 사용자명 admin을 Model에 저장한다.
그래서 member.html에는 로그인을 했군요!! admin 님~~~처럼 표시된다.


로그아웃하면 /login?logout으로 돌아간다.
이때 login.html의 th:if="${param.logout}" 조건이 동작해서 로그아웃 메시지가 표시된다.


잘못된 사용자명이나 비밀번호를 입력하면 인증에 실패한다.
인증에 실패하면 failureUrl("/login?error") 설정에 따라 /login?error로 이동하고, 로그인 실패 메시지가 표시된다.


실행 결과는 admin / adminpass 로그인 성공, 로그아웃, 잘못된 로그인 입력으로 인한 인증 실패 흐름을 보여준다.
security6에서는 코드 안의 고정 사용자 정보가 아니라 DB에 저장된 사용자 정보를 조회해서 인증한다.


security6 핵심 정리

코드 안의 사용자 정보가 DB 데이터로 바뀐다

security6에서는 더 이상 코드 안에서 duke, olaf를 직접 반환하지 않는다.
사용자 정보는 authentications 테이블에 저장된다.


UserDetailsService 구현체인 LoginUserDatailsServiceImpl은 AuthenticationMapper를 사용해 DB를 조회한다.
조회 결과가 있으면 LoginUser로 바꿔 반환한다.
조회 결과가 없으면 UsernameNotFoundException을 발생시킨다.


비밀번호는 평문으로 저장하지 않는다.
PasswordGenerator로 adminpass를 BCrypt 해시 문자열로 바꿔 data.sql에 넣는다.
로그인할 때는 사용자가 평문 adminpass를 입력하고, BCryptPasswordEncoder가 입력값과 저장 해시를 비교한다.


반드시 기억해야 할 내용

  • security6는 사용자 정보를 DB에서 조회한다.
  • H2 Database는 실습용 In-Memory 데이터베이스이다.
  • schema.sql은 테이블 구조를 만든다.
  • data.sql은 초기 데이터를 넣는다.
  • authentications 테이블은 로그인 인증 정보를 저장한다.
  • TODOS 테이블은 초기 SQL 적용 여부를 확인할 수 있는 보조 테이블이다.
  • 현재 authentications 테이블에는 username, password만 있다.
  • 현재 단계에서는 권한 컬럼이 없다.
  • admin의 실제 입력 비밀번호는 adminpass이다.
  • DB에는 adminpass가 아니라 BCrypt 해시 문자열이 저장된다.
  • PasswordGenerator는 adminpass를 BCrypt 해시 문자열로 바꿔 준다.
  • BCrypt는 salt를 사용하므로 실행할 때마다 해시 문자열이 달라질 수 있다.
  • PasswordConfig는 BCryptPasswordEncoder를 Bean으로 등록한다.
  • Authentication 클래스는 DB 조회 결과를 담는 프로젝트 내부 객체이다.
  • Spring Security의 Authentication 객체와 이름은 같지만 역할은 다르다.
  • AuthenticationMapper는 사용자명으로 authentications 테이블을 조회한다.
  • LoginUserDatailsServiceImpl은 AuthenticationMapper로 조회한 결과를 LoginUser로 바꿔 반환한다.
  • LoginUser는 User를 상속한 UserDetails 구현 객체이다.
  • 권한 목록은 Collections.emptyList()로 비워 둔다.
  • 현재 예제는 권한 검사가 아니라 인증 성공 여부 확인이 핵심이다.
  • /h2-console/**은 H2 Console 접근을 위해 permitAll()로 열어 둔다.
  • frameOptions(...sameOrigin)은 H2 Console 화면을 정상적으로 보기 위한 설정이다.
  • admin / adminpass로 로그인하면 DB 조회와 BCrypt 비밀번호 비교가 실행된다.

security6의 핵심은 UserDetailsService가 DB에서 사용자 정보를 조회하고, Spring Security가 입력 비밀번호와 저장된 해시 비밀번호를 비교해 인증을 처리하는 것이다.
이 흐름을 이해하면 이후 권한 컬럼을 추가하거나 사용자 표시명을 추가하는 방식으로 인증 정보를 더 확장할 수 있다.




SecurityFilterChain 기본 필터 세부 역할

SecurityFilterChain은 Spring Security에서 요청을 여러 보안 필터에 순서대로 통과시키는 구조이다.
이 구간에서는 기본 사용자 설정이나 자동 로그인 화면이 아니라, 보안 필터들이 어떤 순서와 역할로 요청을 처리하는지를 중심으로 정리한다.


SecurityFilterChain 안에는 많은 필터가 들어 있다.
처음부터 필터 이름을 전부 외우려고 하면 흐름이 끊긴다.
먼저 요청이 어떤 흐름으로 보안 필터에 도착하는지 보고, 그 다음 각 필터가 어떤 역할을 맡는지 나누어 이해해야 한다.


SecurityFilterChain의 핵심은 하나의 요청을 여러 보안 필터에 순서대로 통과시켜 인증, 로그아웃, 보안 header, CSRF, 예외 처리, 인가 판단을 나누어 처리하는 것이다.


SecurityFilterChain은 여러 보안 필터가 연결된 구조이다

웹 요청은 바로 Controller로 가지 않는다.
먼저 Tomcat의 FilterChain을 지나고, 그 안에서 Spring Security 보안 필터 체인으로 연결된다.


여기서 Filter는 요청이 실제 기능으로 들어가기 전에 먼저 거치는 중간 처리 단계이다.
로그인 여부 확인, 권한 확인, 로그아웃 처리 같은 공통 보안 작업을 Controller보다 앞에서 처리할 수 있다.


요청은 Tomcat의 FilterChain을 지나고, 그 안의 DelegatingFilterProxy를 통해 Spring Security의 보안 필터 체인으로 연결된다.


이 그림에서 중요한 점은 Controller가 가장 먼저 실행되지 않는다는 것이다.
요청은 먼저 여러 필터를 거치고, 그중 Spring Security 필터들이 인증과 인가를 검사한다.


DelegatingFilterProxy와 FilterChainProxy의 역할

DelegatingFilterProxy는 Tomcat 영역의 Servlet Filter 흐름과 Spring 영역의 보안 객체를 연결한다.
Proxy는 직접 처리하지 않고 다른 대상에게 일을 넘기는 대리 객체라고 이해하면 된다.


DelegatingFilterProxy는 실제 보안 검사를 직접 하지 않는다.
대신 Spring Bean으로 등록된 FilterChainProxy에게 요청 처리를 넘긴다.


FilterChainProxy는 현재 요청에 맞는 SecurityFilterChain을 선택하고 실행한다.
그리고 SecurityFilterChain 안에 들어 있는 실제 보안 필터들이 순서대로 동작한다.

구분위치역할다음 흐름
FilterChainTomcat웹 요청이 지나가는 기본 필터 흐름이다.여러 일반 필터 중 DelegatingFilterProxy를 만난다.
DelegatingFilterProxyTomcat FilterChain 안Tomcat 필터처럼 동작하지만 실제 처리는 Spring Bean에게 위임한다.FilterChainProxy로 요청을 넘긴다.
FilterChainProxySpring Bean실제 SecurityFilterChain을 찾아 실행하는 관리자이다.요청에 맞는 SecurityFilterChain을 실행한다.
SecurityFilterChainSpring Security 내부여러 보안 필터가 순서대로 연결된 체인이다.로그인, 로그아웃, CSRF, 인가 필터들이 실행된다.
실제 보안 필터SecurityFilterChain 안각 필터가 자신이 맡은 보안 처리를 담당한다.보안 검사를 통과하면 다음 필터 또는 Controller 흐름으로 넘어간다.

DelegatingFilterProxy는 연결 역할이다.
FilterChainProxy는 실행 관리자이다.
SecurityFilterChain은 실제 보안 필터들이 모여 있는 구조이다.


클라이언트 요청은 DelegatingFilterProxy를 거쳐 FilterChainProxy로 전달된다.
그 다음 실제 SecurityFilterChain 안의 여러 보안 필터가 실행된다.


여러 SecurityFilterChain도 사용할 수 있다

Spring Security는 하나의 SecurityFilterChain만 사용할 수도 있고, 여러 개를 사용할 수도 있다.
요청의 성격이 다르면 서로 다른 보안 규칙이 필요할 수 있기 때문이다.


예를 들어 일반 화면 요청은 formLogin() 방식이 적합할 수 있다.
반면 API 요청은 화면이 없기 때문에 토큰 기반 인증이 필요할 수 있다.
관리자 요청은 일반 사용자 요청보다 더 강한 권한 검사가 필요할 수도 있다.


이럴 때 요청 경로에 따라 서로 다른 SecurityFilterChain을 적용할 수 있다.
어떤 체인을 사용할지 고르는 객체가 FilterChainProxy이다.


요청 경로가 다르면 서로 다른 SecurityFilterChain을 적용할 수 있다.
FilterChainProxy는 현재 요청에 맞는 보안 필터 체인을 선택한다.


현재 실습에서는 여러 체인을 직접 나누지는 않는다.
다만 Spring Security가 하나의 고정된 필터 묶음만 사용하는 것이 아니라, 요청에 따라 다른 보안 필터 묶음을 선택할 수 있다는 구조는 알아두면 된다.


Filter 클래스 계층 구조

Spring Security의 실제 필터들은 기본 Filter 구조에서 출발한다.
그 위에 Spring과 통합하기 위한 구조가 추가되고, 요청마다 한 번만 실행되도록 보장하는 구조로 확장된다.


흐름은 다음처럼 볼 수 있다.


Filter → GenericFilterBean → OncePerRequestFilter

클래스실행 메서드특징
FilterdoFilter()Servlet 표준 인터페이스이다.
GenericFilterBeandoFilter()Spring 통합 기능을 포함한다.
OncePerRequestFilterdoFilterInternal()요청당 한 번만 실행되도록 보장한다.

Filter는 가장 기본이 되는 Servlet 필터 인터페이스이다.
GenericFilterBean은 Spring 환경과 더 잘 연결된 필터 기반 클래스이다.
OncePerRequestFilter는 하나의 요청에서 필터가 중복 실행되지 않도록 보장한다.


특히 OncePerRequestFilter는 내부적으로 doFilter()를 오버라이드하고, 실제 구현은 doFilterInternal()에서 한 번만 실행되도록 제어한다.


SecurityFilterChain 객체 생성 시 함께 생성되는 필터 객체들

SecurityFilterChain을 만들면 여러 보안 필터 객체가 함께 준비된다.
이 필터들은 하나의 요청을 단계별로 처리한다.


로그아웃 요청은 LogoutFilter가 처리한다.
폼 로그인 요청은 UsernamePasswordAuthenticationFilter가 처리한다.
최종 접근 가능 여부는 뒤쪽의 AuthorizationFilter가 판단한다.


SecurityFilterChain 객체가 생성될 때 여러 보안 필터 객체가 함께 준비된다.
이 필터들은 순서대로 요청을 처리하면서 인증, 로그아웃, 예외 처리, 인가 검사를 수행한다.


PDF 기준으로 생성되는 주요 필터 목록은 다음과 같다.

  • DisableEncodeUrlFilter
  • WebAsyncManagerIntegrationFilter
  • SecurityContextHolderFilter
  • HeaderWriterFilter
  • CsrfFilter
  • LogoutFilter
  • UsernamePasswordAuthenticationFilter
  • DefaultLoginPageGeneratingFilter
  • DefaultLogoutPageGeneratingFilter
  • BasicAuthenticationFilter
  • RequestCacheAwareFilter
  • SecurityContextHolderAwareRequestFilter
  • AnonymousAuthenticationFilter
  • ExceptionTranslationFilter
  • AuthorizationFilter

필터 이름을 처음부터 전부 외울 필요는 없다.
먼저 역할별로 묶어서 이해해야 한다.
필터 이름은 많지만 결국 요청을 안전하게 처리하기 위해 각 단계에서 맡은 일을 나누어 수행하는 구조이다.


SecurityFilterChain 안의 주요 필터 역할

각 필터는 요청 흐름에서 맡은 일이 다르다.
초보자 기준에서는 필터 이름을 먼저 외우기보다, 어떤 상황에서 어떤 필터가 필요한지 연결해서 이해하면 된다.

필터역할쉽게 이해하기
DisableEncodeUrlFilterURL에 세션 정보가 붙지 않도록 막는다.세션 정보가 주소에 노출되는 것을 막는 필터이다.
WebAsyncManagerIntegrationFilter비동기 요청에서도 보안 컨텍스트가 이어지도록 돕는다.비동기 처리 중에도 로그인 정보를 잃지 않게 연결한다.
SecurityContextHolderFilter요청 중 사용할 SecurityContext를 준비하고 관리한다.현재 요청에서 인증 정보를 담을 공간을 준비한다.
HeaderWriterFilter응답에 보안 관련 header를 추가한다.브라우저가 응답을 안전하게 처리하도록 보안 규칙을 붙인다.
CorsFilter다른 출처에서 들어오는 요청을 허용할지 검사한다.현재 화면 주소와 서버 주소가 다를 때 요청 허용 여부를 판단한다.
CsrfFilterCSRF 토큰을 검사한다.사용자가 의도하지 않은 요청을 막기 위한 필터이다.
LogoutFilter로그아웃 요청을 처리한다./logout 요청을 만나면 세션 무효화와 쿠키 삭제 등을 처리한다.
UsernamePasswordAuthenticationFilter폼 로그인 요청을 처리한다.로그인 form에서 사용자명과 비밀번호를 꺼내 인증 흐름을 시작한다.
DefaultResourcesFilter기본 보안 화면에서 사용하는 정적 리소스를 처리한다.기본 로그인 화면에 필요한 CSS 같은 리소스와 연결된다.
DefaultLoginPageGeneratingFilter기본 로그인 화면을 만든다.직접 만든 로그인 화면이 없을 때 기본 로그인 화면을 보여줄 수 있다.
DefaultLogoutPageGeneratingFilter기본 로그아웃 화면을 만든다.기본 로그아웃 확인 화면을 보여줄 수 있다.
BasicAuthenticationFilterHTTP Basic 인증 요청을 처리한다.요청 header에 담긴 인증 정보를 처리한다.
RequestCacheAwareFilter인증 전 요청했던 주소를 기억하고 활용한다.로그인 후 원래 가려던 주소로 돌아가게 도와준다.
SecurityContextHolderAwareRequestFilter요청 객체에서 보안 관련 기능을 사용할 수 있게 감싼다.요청에서 로그인 사용자나 권한 정보를 더 쉽게 다룰 수 있게 한다.
AnonymousAuthenticationFilter인증되지 않은 사용자를 익명 사용자로 처리한다.로그인하지 않은 사용자도 보안 흐름 안에서는 익명 인증 객체로 다룬다.
ExceptionTranslationFilter인증과 인가 예외를 처리한다.인증이 필요하면 로그인 흐름으로 보내고, 권한이 없으면 접근 거부 흐름으로 바꾼다.
AuthorizationFilter최종 인가 판단을 처리한다.이 요청이 현재 사용자에게 허용되는지 마지막으로 판단한다.

이 표에서 모든 필터 이름을 외울 필요는 없다.
처음에는 SecurityContextHolderFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter를 흐름 중심으로 먼저 이해하면 된다.


필터를 역할별로 묶어 이해한다

필터는 역할별로 나누면 훨씬 이해하기 쉽다.
각 필터가 따로따로 동작하는 것이 아니라, 요청 하나가 여러 보안 단계를 차례대로 통과한다고 보면 된다.

구분포함되는 필터핵심 역할
인프라스트럭처 필터DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter보안 처리를 위한 기본 환경을 준비한다.
보안 보호 필터HeaderWriterFilter, CorsFilter, CsrfFilter응답을 안전하게 만들고, 다른 출처 요청과 의도하지 않은 요청을 검사한다.
인증 관련 필터LogoutFilter, UsernamePasswordAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, BasicAuthenticationFilter로그아웃, 폼 로그인, 요청 캐시, 요청 객체 보안 기능, HTTP Basic 인증을 처리한다.
기본 화면 지원 필터DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter기본 로그인 화면, 로그아웃 화면, 기본 화면 리소스 처리를 지원한다.
인가 관련 필터AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter익명 사용자 처리, 예외 변환, 최종 접근 권한 검사를 담당한다.

필터 목록은 단순 암기용 목록이 아니라, 요청이 어떤 보안 단계를 통과하는지 보여주는 실행 흐름이다.


인프라스트럭처 필터

인프라스트럭처 필터는 보안 처리가 자연스럽게 진행되도록 바탕을 준비하는 필터이다.
직접 로그인 성공이나 실패를 판단하기보다는, 보안 컨텍스트 준비나 요청 처리 환경을 만드는 기반 작업을 맡는다.

필터핵심 역할
DisableEncodeUrlFilterURL에 세션 정보가 붙지 않게 막는다.
WebAsyncManagerIntegrationFilter비동기 요청에서도 보안 컨텍스트가 이어지도록 돕는다.
SecurityContextHolderFilter요청에서 사용할 SecurityContext를 준비하고 관리한다.

SecurityContextHolderFilter는 현재 요청에서 사용할 보안 정보 공간과 관련된다.
로그인에 성공한 사용자 정보는 인증 객체에 담기고, 이후 보안 컨텍스트에서 사용된다.


DisableEncodeUrlFilter는 세션 정보가 URL에 붙는 방식을 막는다.
세션 정보가 주소에 노출되면 보안상 위험할 수 있기 때문이다.


WebAsyncManagerIntegrationFilter는 비동기 처리에서도 보안 컨텍스트가 이어지도록 돕는다.
비동기 처리는 요청 흐름이 일반 동기 처리와 다르게 이어질 수 있으므로, 로그인 정보가 끊기지 않게 연결해 주는 역할이 필요하다.


보안 보호 필터

보안 보호 필터는 요청과 응답을 더 안전하게 만들기 위해 동작한다.
대표적으로 응답에 보안 header를 추가하거나, 다른 출처 요청을 검사하거나, CSRF 공격을 막는 역할을 한다.

필터핵심 역할
HeaderWriterFilter응답에 보안 관련 header를 추가한다.
CorsFilter다른 출처에서 들어오는 요청을 허용할지 검사한다.
CsrfFilterCSRF 토큰을 검사해 의도하지 않은 요청을 막는다.

HeaderWriterFilter는 HTTP 응답에 여러 보안 관련 header를 자동으로 삽입한다.
이 보안 header는 브라우저가 응답을 더 안전하게 처리하도록 돕는다.


CorsFilter는 현재 화면 주소와 서버 주소가 다를 때 중요해진다.
예를 들어 프론트엔드가 localhost:3000에서 실행되고, 백엔드가 localhost:8088에서 실행되면 브라우저는 이를 다른 출처로 볼 수 있다.
이때 서버가 허용하지 않은 요청은 브라우저에서 막힐 수 있다.


CsrfFilter는 CSRF 공격을 막기 위한 필터이다.
CSRF는 Cross-Site Request Forgery의 줄임말이다.
쉽게 말하면 사용자가 의도하지 않은 요청을 공격자가 대신 보내게 만드는 공격이다.


사용자가 어떤 서비스에 로그인한 상태라면 브라우저는 해당 서비스로 요청을 보낼 때 쿠키를 자동으로 함께 보낼 수 있다.
공격자는 이 점을 이용해서 사용자가 의도하지 않은 요청을 보내게 만들 수 있다.


CSRF 공격 흐름은 다음처럼 이해할 수 있다.

  • 사용자가 서비스 A에 로그인해 세션이 유지되고 있다.
  • 공격자가 악성 사이트 B에 서비스 A로 보내는 요청을 숨겨 둔다.
  • 사용자가 사이트 B를 방문하거나 링크를 클릭한다.
  • 브라우저는 서비스 A의 쿠키를 자동으로 첨부해 요청을 보낸다.
  • 서버는 정당한 사용자 요청으로 오해할 수 있다.

이런 문제를 막기 위해 CsrfFilter는 요청에 정상적인 CSRF 토큰이 있는지 확인한다.
CSRF 토큰은 서버가 정상 화면에서 발급한 값이다.
공격 사이트는 이 값을 알기 어렵기 때문에, 토큰이 없는 요청을 차단할 수 있다.


HeaderWriterFilter와 H2 Console 설정

HeaderWriterFilter는 응답에 보안 관련 header를 붙인다.
security6, security7에서 H2 Console을 사용하기 위해 frameOptions를 조정한 것도 이 흐름과 연결된다.


H2 Console은 내부적으로 frame 구조를 사용한다.
그래서 같은 출처에서는 frame을 허용하도록 설정해야 화면이 정상적으로 보인다.

// HeaderWriterFilterExample.java
http
    // 응답 header 설정
    .headers(headers -> headers
            // 같은 출처의 frame 표시를 허용한다.
            .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
    );

HeaderWriterFilter는 OncePerRequestFilter를 상속하여 요청당 한 번만 실행되도록 보장된다.
즉, 같은 요청에서 같은 보안 header 처리가 여러 번 반복되지 않도록 한다.


인증 관련 필터

인증 관련 필터는 사용자가 누구인지 확인하는 흐름과 연결된다.
로그인, 로그아웃, 요청 캐시, 요청 객체 보안 기능 연결, HTTP Basic 인증이 여기에 들어간다.

필터핵심 역할
LogoutFilter로그아웃 요청을 처리한다.
UsernamePasswordAuthenticationFilter폼 로그인 요청에서 사용자명과 비밀번호를 꺼내 인증을 시작한다.
RequestCacheAwareFilter인증 전 접근하려던 요청을 기억한다.
SecurityContextHolderAwareRequestFilter보안 기능을 사용할 수 있도록 요청 객체를 감싼다.
BasicAuthenticationFilterHTTP Basic 인증 요청을 처리한다.

LogoutFilter는 /logout 요청을 처리한다.
로그아웃은 단순히 화면만 이동하는 것이 아니라 세션 무효화와 쿠키 삭제 흐름까지 포함한다.


UsernamePasswordAuthenticationFilter는 직접 만든 로그인 화면의 form 제출 요청을 처리한다.
요청에서 사용자명과 비밀번호를 꺼내 인증 흐름을 시작한다.


RequestCacheAwareFilter는 로그인 전 사용자가 접근하려던 요청을 기억하는 데 관여한다.
예를 들어 로그인하지 않은 상태에서 보호된 주소에 접근했다면, 로그인 후 원래 가려던 주소로 이어질 수 있게 돕는다.


SecurityContextHolderAwareRequestFilter는 요청 객체에서 보안 관련 기능을 사용할 수 있게 감싼다.
요청에서 로그인 사용자나 권한 정보를 더 쉽게 다룰 수 있게 해 주는 보조 역할이다.


BasicAuthenticationFilter는 HTTP Basic 인증 요청을 처리한다.
HTTP Basic 인증은 로그인 화면을 보여주는 방식이 아니라, 요청 header에 인증 정보를 담아 보내는 방식이다.


기본 화면 지원 필터

기본 화면 지원 필터는 개발자가 로그인 화면이나 로그아웃 확인 화면을 따로 만들지 않았을 때 기본 화면을 제공하는 흐름과 연결된다.

필터핵심 역할
DefaultResourcesFilter기본 보안 화면에서 사용하는 정적 리소스를 처리한다.
DefaultLoginPageGeneratingFilter기본 로그인 화면을 만들어 줄 수 있다.
DefaultLogoutPageGeneratingFilter기본 로그아웃 화면을 만들어 줄 수 있다.

DefaultResourcesFilter는 기본 로그인 화면에서 필요한 CSS 같은 정적 리소스와 연결된다.
직접 만든 로그인 화면을 사용하면 이 필터가 눈에 잘 보이지 않을 수 있지만, 기본 보안 화면을 사용할 때는 화면 리소스 처리에 관여할 수 있다.


DefaultLoginPageGeneratingFilter는 직접 만든 로그인 화면이 없을 때 기본 로그인 화면을 만들어 준다.
DefaultLogoutPageGeneratingFilter는 기본 로그아웃 확인 화면을 만들어 줄 수 있다.


security4, security5, security6, security7에서는 직접 만든 login.html을 사용했다.
그래서 로그인 화면을 보여주는 역할은 LoginController와 login.html이 맡는다.
다만 로그인 요청 처리 자체는 여전히 UsernamePasswordAuthenticationFilter가 담당한다.


로그인 화면을 보여주는 일과 로그인 인증 요청을 처리하는 일은 다르다.


인가 관련 필터

인가 관련 필터는 인증된 사용자가 현재 요청에 접근할 권한이 있는지 확인한다.
인가는 “이 사용자가 이 기능을 사용해도 되는가?”를 확인하는 과정이다.

필터핵심 역할
AnonymousAuthenticationFilter로그인하지 않은 사용자를 익명 사용자로 처리한다.
ExceptionTranslationFilter인증 실패나 접근 거부 같은 예외를 적절한 흐름으로 바꾼다.
AuthorizationFilter현재 사용자가 요청한 주소에 접근할 수 있는지 최종 판단한다.

AnonymousAuthenticationFilter는 로그인하지 않은 사용자도 보안 흐름 안에서 익명 사용자로 다룰 수 있게 한다.
아무 정보가 없는 상태로 두지 않고, 익명 인증 객체 형태로 관리하는 것이다.


ExceptionTranslationFilter는 인증과 인가 과정에서 발생한 예외를 알맞은 응답 흐름으로 바꾼다.
로그인이 필요하면 로그인 흐름으로 보내고, 권한이 부족하면 접근 거부 흐름으로 연결한다.


AuthorizationFilter는 최종 관문에 가깝다.
인증 정보가 있더라도 현재 요청에 필요한 권한이 없으면 접근이 막힐 수 있다.


UsernamePasswordAuthenticationFilter 인증 흐름

폼 로그인 요청에서 가장 중요한 필터는 UsernamePasswordAuthenticationFilter이다.
직접 만든 로그인 화면에서 form이 제출되면 이 필터가 요청을 처리한다.


예를 들어 지금 예제에서는 로그인 화면의 form이 /authentication으로 제출된다.
그리고 SecurityConfig에서 loginProcessingUrl("/authentication")을 설정했다.
그래서 /authentication 요청은 Controller가 아니라 UsernamePasswordAuthenticationFilter가 처리한다.


이 필터는 요청에서 사용자명과 비밀번호를 꺼낸다.
예제에서는 usernameParameter("usernameInput"), passwordParameter("passwordInput")을 사용했기 때문에 로그인 화면의 입력 이름도 usernameInput, passwordInput이어야 한다.


UsernamePasswordAuthenticationFilter는 현재 요청이 로그인 요청인지 먼저 확인한다.
로그인 요청이면 사용자명과 비밀번호로 인증 전 UsernamePasswordAuthenticationToken을 만들고, AuthenticationManager에게 인증 처리를 넘긴다.
AuthenticationManager는 DaoAuthenticationProvider를 통해 사용자 정보 조회와 비밀번호 검증을 수행한다.
인증에 성공하면 인증 완료 Authentication 객체가 만들어지고, 이 정보가 SecurityContextHolder의 SecurityContext에 저장된다.


/login은 로그인 화면을 보여주는 주소이고, /authentication은 로그인 인증을 처리하는 주소이다.
이 둘을 구분해야 직접 만든 로그인 화면과 Spring Security 인증 흐름을 헷갈리지 않는다.


UsernamePasswordAuthenticationFilter 인증 처리 순서

UsernamePasswordAuthenticationFilter는 사용자명과 비밀번호를 꺼낸 뒤 인증 전 객체를 만든다.
보통 이 객체는 아직 인증이 끝나지 않은 UsernamePasswordAuthenticationToken이다.


그 다음 인증 처리를 AuthenticationManager에게 넘긴다.
AuthenticationManager는 실제 검증을 담당할 AuthenticationProvider에게 인증 처리를 맡긴다.
기본 폼 로그인 흐름에서는 DaoAuthenticationProvider가 UserDetailsService와 PasswordEncoder를 사용해 사용자 조회와 비밀번호 검증을 수행한다.

순서처리 내용담당 객체
1현재 요청이 로그인 요청인지 확인한다.UsernamePasswordAuthenticationFilter
2로그인 요청이면 사용자명과 비밀번호를 꺼낸다.usernameParameter, passwordParameter
3인증 전 토큰을 만든다.UsernamePasswordAuthenticationToken
4인증 처리를 위임한다.AuthenticationManager
5실제 사용자 조회와 비밀번호 검증을 수행한다.DaoAuthenticationProvider
6사용자 정보를 조회한다.UserDetailsService
7입력 비밀번호와 저장 비밀번호를 비교한다.PasswordEncoder
8인증 성공 정보를 저장한다.SecurityContextHolder

즉, UsernamePasswordAuthenticationFilter는 로그인 요청을 시작점에서 잡아 주는 필터이다.
사용자 조회와 비밀번호 비교까지 모두 직접 처리하는 하나의 필터라고 이해하면 안 된다.
인증 흐름의 첫 관문이라고 이해하면 된다.


UsernamePasswordAuthenticationFilter 주요 메서드

UsernamePasswordAuthenticationFilter에는 로그인 요청을 처리하기 위한 여러 메서드 흐름이 연결된다.
초보자 기준에서는 메서드 이름을 외우기보다, 각 메서드가 어느 단계에서 쓰이는지 먼저 이해하는 것이 좋다.

메서드 또는 흐름역할쉽게 이해하기
requiresAuthentication()현재 요청이 로그인 처리 요청인지 판단한다.이 요청이 /authentication처럼 로그인 처리 주소인지 확인하는 단계이다.
attemptAuthentication()사용자명과 비밀번호를 꺼내 인증을 시도한다.입력값으로 인증 전 토큰을 만들고 AuthenticationManager에게 넘긴다.
successfulAuthentication()인증 성공 후 처리를 담당한다.인증 정보를 SecurityContextHolder에 저장하고 성공 후 이동 흐름을 실행한다.
unsuccessfulAuthentication()인증 실패 후 처리를 담당한다.인증 정보를 저장하지 않고 실패 후 이동 흐름을 실행한다.

이 메서드들은 하나의 로그인 요청 안에서 순서대로 의미를 가진다.
먼저 로그인 처리 요청인지 확인하고, 맞으면 인증을 시도한다.
성공하면 성공 처리로 가고, 실패하면 실패 처리로 간다.


UsernamePasswordAuthenticationFilter 관련 클래스

폼 로그인 인증은 하나의 객체가 전부 처리하지 않는다.
UsernamePasswordAuthenticationFilter는 요청을 받아 인증 흐름을 시작하고, 실제 사용자 조회와 비밀번호 비교는 다른 객체들이 나누어 처리한다.

클래스 또는 인터페이스역할현재 예제와 연결
UsernamePasswordAuthenticationFilter로그인 form 제출 요청을 가로챈다./authentication 요청을 처리한다.
UsernamePasswordAuthenticationToken사용자명과 비밀번호를 담는 인증 객체이다.인증 전에는 입력값을 담고, 인증 후에는 인증 완료 정보를 담는다.
AuthenticationManager인증 처리를 시작하는 입구이다.인증 요청을 적절한 Provider에게 넘긴다.
DaoAuthenticationProvider사용자 조회와 비밀번호 검증을 담당한다.UserDetailsService와 PasswordEncoder를 사용한다.
UserDetailsService사용자명으로 사용자 정보를 조회한다.security5는 코드에서 조회하고, security6와 security7은 DB에서 조회한다.
UserDetails인증에 사용할 사용자 정보 형태이다.LoginUser가 UserDetails 역할을 한다.
PasswordEncoder입력 비밀번호와 저장 비밀번호를 비교한다.security6, security7에서는 BCryptPasswordEncoder를 사용한다.
SecurityContextHolder인증 성공 정보를 보관한다.인증 성공 후 이후 요청에서 로그인 상태를 유지할 수 있게 한다.

이 표에서 가장 중요한 흐름은 Filter → AuthenticationManager → DaoAuthenticationProvider → UserDetailsService → PasswordEncoder → SecurityContextHolder이다.
이 순서를 알면 로그인 요청이 어디에서 시작되고, 어디에서 사용자 정보를 조회하며, 어디에 인증 결과가 저장되는지 이해할 수 있다.


formLogin 설정은 로그인 필터 흐름을 활성화한다

SecurityFilterChain을 직접 정의하면 로그인 방식도 명시적으로 설정해야 한다.
폼 로그인 방식을 사용하려면 formLogin() 설정이 필요하다.


formLogin()은 폼 기반 로그인 인증을 활성화하는 설정이다.
이 설정이 있어야 UsernamePasswordAuthenticationFilter가 로그인 요청을 처리하는 흐름으로 연결된다.

// FormLoginDefaultExample.java
http
    // 기본 폼 로그인 설정을 사용한다.
    .formLogin(Customizer.withDefaults());

직접 만든 로그인 화면을 사용하면 로그인 페이지 주소와 입력 필드 이름을 맞춰야 한다.
예를 들어 로그인 화면의 사용자명 입력 필드 이름이 usernameInput이면, SecurityConfig에서도 같은 이름을 지정해야 한다.

// FormLoginCustomExample.java
http
    // 폼 로그인 설정
    .formLogin(form -> form
            // 직접 만든 로그인 화면 주소
            .loginPage("/login")
            // 로그인 form 제출을 처리할 주소
            .loginProcessingUrl("/authentication")
            // 사용자명 input name
            .usernameParameter("usernameInput")
            // 비밀번호 input name
            .passwordParameter("passwordInput")
            // 로그인 성공 시 이동할 주소
            .defaultSuccessUrl("/", true)
            // 로그인 실패 시 이동할 주소
            .failureUrl("/login?error"));

loginPage("/login")은 로그인 화면을 보여줄 주소이다.
이 주소는 LoginController가 처리한다.


loginProcessingUrl("/authentication")은 로그인 form이 제출될 주소이다.
이 주소는 Controller가 아니라 UsernamePasswordAuthenticationFilter가 처리한다.


usernameParameter("usernameInput")과 passwordParameter("passwordInput")은 요청에서 어떤 값을 사용자명과 비밀번호로 읽을지 정하는 설정이다.
이 이름이 login.html의 입력 필드 이름과 맞아야 한다.


직접 만든 로그인 화면을 사용할 때는 login.html의 입력 이름과 SecurityConfig의 파라미터 이름이 반드시 맞아야 한다.


인증 성공과 인증 실패 흐름

사용자명과 비밀번호가 맞으면 인증에 성공한다.
이때 실제 검증은 DaoAuthenticationProvider가 담당한다.


검증에 성공하면 인증 완료 상태의 Authentication 객체가 만들어진다.
인증 완료 정보에는 로그인한 사용자 정보와 권한 정보가 들어간다.
그 뒤 성공 처리 흐름에서 이 인증 정보가 SecurityContextHolder를 통해 관리되는 SecurityContext 안에 저장된다.

단계인증 성공 처리 내용
1사용자명과 비밀번호 검증에 성공한다.
2인증 완료 Authentication 객체가 만들어진다.
3인증 정보가 SecurityContextHolder에 저장된다.
4성공 처리 흐름이 실행된다.
5예제에서는 / 주소로 이동한다.

사용자명이 없거나 비밀번호가 맞지 않으면 인증에 실패한다.
인증에 실패하면 인증 정보는 SecurityContextHolder에 저장되지 않는다.
그 대신 실패 처리 흐름이 실행된다.

단계인증 실패 처리 내용
1사용자명이 없거나 비밀번호가 맞지 않는다.
2인증 예외가 발생한다.
3인증 정보는 저장되지 않는다.
4실패 처리 흐름이 실행된다.
5예제에서는 /login?error로 이동한다.

이 흐름 때문에 잘못된 계정이나 비밀번호를 입력하면 다시 로그인 화면에서 실패 메시지를 확인하게 된다.


SecurityFilterChain 기본 필터 핵심 정리

SecurityFilterChain은 여러 보안 필터가 순서대로 연결된 구조이다.
요청은 바로 Controller로 가지 않고, DelegatingFilterProxy, FilterChainProxy, SecurityFilterChain을 거쳐 보안 검사를 받는다.


DelegatingFilterProxy는 Servlet Filter와 Spring Bean을 연결한다.
FilterChainProxy는 현재 요청에 맞는 SecurityFilterChain을 선택하고 실행한다.
SecurityFilterChain 안에서는 실제 보안 필터들이 순서대로 동작한다.


반드시 기억해야 할 내용은 다음과 같다.

  • SecurityFilterChain은 보안 필터들의 묶음이다.
  • DisableEncodeUrlFilter는 URL에 세션 정보가 붙는 것을 막는다.
  • WebAsyncManagerIntegrationFilter는 비동기 처리에서 보안 컨텍스트가 이어지도록 돕는다.
  • SecurityContextHolderFilter는 요청에서 사용할 SecurityContext를 준비한다.
  • HeaderWriterFilter는 보안 관련 응답 header를 추가한다.
  • CorsFilter는 다른 출처 요청의 허용 여부를 검사한다.
  • CsrfFilter는 사용자가 의도하지 않은 요청을 막기 위한 CSRF 토큰 검사를 담당한다.
  • LogoutFilter는 로그아웃 요청을 처리한다.
  • UsernamePasswordAuthenticationFilter는 폼 로그인 요청을 처리한다.
  • DefaultResourcesFilter는 기본 보안 화면의 정적 리소스 처리를 지원한다.
  • DefaultLoginPageGeneratingFilter는 기본 로그인 화면을 생성할 수 있다.
  • DefaultLogoutPageGeneratingFilter는 기본 로그아웃 화면을 생성할 수 있다.
  • BasicAuthenticationFilter는 HTTP Basic 인증 요청을 처리한다.
  • RequestCacheAwareFilter는 로그인 전 접근하려던 요청을 기억하는 데 관여한다.
  • SecurityContextHolderAwareRequestFilter는 요청 객체에서 보안 기능을 사용할 수 있게 돕는다.
  • AnonymousAuthenticationFilter는 로그인하지 않은 사용자를 익명 사용자로 다룬다.
  • ExceptionTranslationFilter는 인증과 인가 예외를 적절한 흐름으로 바꾼다.
  • AuthorizationFilter는 최종 인가 판단을 담당한다.

필터 이름은 암기 대상이 아니라 요청이 어떤 보안 단계를 통과하는지 보여주는 실행 흐름이다.
이 흐름을 이해하면 다음 단계인 security7에서 권한 정보를 추가하고 ADMIN, USER 접근 제어를 다루는 구조도 자연스럽게 이어진다.




security7: 권한 기반 화면 표시와 관리자 접근 제어하기

security7은 security6에서 만든 DB 기반 로그인 인증 흐름에 권한 정보를 추가하는 예제이다.
security6에서는 authentications 테이블에서 사용자명과 비밀번호만 조회했다.
security7에서는 여기에 authority 값을 함께 조회한다.


이제 로그인한 사용자가 단순히 인증된 사용자인지만 확인하지 않는다.
ADMIN 권한을 가진 사용자인지, USER 권한을 가진 사용자인지까지 확인한다.
그 권한은 GrantedAuthority 목록으로 바뀌어 LoginUser에 담긴다.


security7의 핵심은 DB에서 조회한 권한 값을 Spring Security가 이해할 수 있는 권한 목록으로 바꾸고, 그 권한으로 화면 표시와 서버 접근 제한을 함께 처리하는 것이다.


security7 전체 흐름

security7의 로그인 흐름은 security6와 비슷하다.
사용자가 로그인 화면에서 계정과 암호를 입력하면 로그인 요청은 /authentication으로 전송된다.
이 요청은 Controller가 아니라 UsernamePasswordAuthenticationFilter가 처리한다.


그 다음 Spring Security는 UserDetailsService.loadUserByUsername()을 호출한다.
현재 예제에서는 LoginUserDatailsServiceImpl이 이 역할을 한다.
LoginUserDatailsServiceImpl은 AuthenticationMapper를 사용해 authentications 테이블에서 사용자 정보를 조회한다.


security7에서 달라진 점은 조회하는 값이다.
security6에서는 username, password만 조회했다.
security7에서는 username, password, authority를 함께 조회한다.

순서흐름핵심 역할
1사용자가 로그인 화면에서 계정과 암호를 입력한다.로그인 요청 준비
2로그인 form이 /authentication으로 제출된다.UsernamePasswordAuthenticationFilter가 처리
3loadUserByUsername()이 호출된다.사용자 조회 시작
4AuthenticationMapper가 DB를 조회한다.username, password, authority 조회
5조회 결과가 Authentication 객체에 담긴다.프로젝트 내부 조회 결과 객체
6Role 값이 GrantedAuthority 목록으로 바뀐다.권한 변환
7LoginUser 객체가 반환된다.UserDetails 반환
8PasswordEncoder가 비밀번호를 검증한다.인증 처리
9인증 성공 후 권한 정보가 인증 객체에 포함된다.화면 표시와 접근 제어에 사용

즉, security7은 security6의 DB 조회 인증 흐름에 권한 정보를 추가한 단계이다.


인증과 인가는 서로 다르다

인증은 사용자가 누구인지 확인하는 과정이다.
로그인 화면에서 사용자명과 비밀번호를 입력하고, 그 값이 저장된 사용자 정보와 맞는지 확인하는 흐름이 인증이다.


예를 들어 admin / adminpass를 입력했을 때 DB에 저장된 admin 사용자 정보와 비밀번호가 맞으면 인증에 성공한다.
이때 Spring Security는 “이 사용자는 로그인에 성공한 사용자이다”라고 판단한다.


인증의 핵심 질문은 다음과 같다.

  • 이 사용자는 존재하는 사용자인가?
  • 입력한 비밀번호가 저장된 비밀번호와 맞는가?
  • 로그인에 성공한 사용자로 볼 수 있는가?

인가는 인증된 사용자가 특정 기능이나 화면에 접근할 수 있는지 확인하는 과정이다.
로그인에 성공했다고 해서 모든 화면과 기능에 접근할 수 있는 것은 아니다.


예를 들어 일반 사용자는 일반 사용자 화면을 볼 수 있지만, 관리자 전용 기능에는 접근하지 못하게 할 수 있다.
이때 필요한 것이 권한 검사이다.


인가의 핵심 질문은 다음과 같다.

  • 이 사용자는 로그인한 사용자인가?
  • 이 사용자는 ADMIN 권한을 가지고 있는가?
  • 이 사용자는 USER 권한을 가지고 있는가?
  • 이 요청이나 화면 영역을 사용할 수 있는 권한이 있는가?

인증은 “누구인가”를 확인하는 과정이고, 인가는 “무엇을 할 수 있는가”를 확인하는 과정이다.


security6와 security7의 차이

security6에서는 authentications 테이블에서 username, password만 조회했다.
조회된 사용자가 있고 비밀번호가 맞으면 로그인에 성공했다.


하지만 이 단계에서는 권한 컬럼이 없었다.
그래서 LoginUser를 만들 때 권한 목록을 Collections.emptyList()로 비워 두었다.
anyRequest().authenticated()는 권한 이름을 검사하는 것이 아니라 로그인 성공 여부만 확인하기 때문에 이 구조로도 동작했다.

구분security6security7
사용자 정보 조회DB에서 조회DB에서 조회
조회 컬럼username, passwordusername, password, authority
비밀번호 저장 방식BCrypt 해시 문자열BCrypt 해시 문자열
권한 컬럼없음authority
권한 목록Collections.emptyList()GrantedAuthority 목록으로 생성
접근 기준로그인 성공 여부로그인 성공 여부 + 권한 조건
서버 접근 제한로그인한 사용자만 접근/todos/**는 ADMIN 권한 필요

security7에서는 DB에서 조회한 권한 값을 GrantedAuthority로 바꿔야 권한 기반 처리가 가능하다.


Role은 권한 값을 제한한다

Role은 권한 값을 표현하는 enum이다.
enum은 정해진 값 중 하나만 사용할 수 있게 제한하는 타입이다.


권한을 문자열로만 다루면 오타가 생길 수 있다.
예를 들어 ADMIN을 ADMN으로 잘못 쓰거나, USER를 USRE로 잘못 쓸 수 있다.
이런 문제를 줄이기 위해 권한 값을 Role로 제한한다.

// Role.java
package com.example.security7.entity;

public enum Role {
    ADMIN, USER
}

Role.ADMIN은 관리자 권한을 의미한다.
Role.USER는 일반 사용자 권한을 의미한다.


현재 예제에서는 이 두 권한만 사용한다.
admin 사용자는 ADMIN 권한을 가지고, user 사용자는 USER 권한을 가진다.


schema.sql과 data.sql은 권한 데이터를 준비한다

security7에서는 인증 테이블 구조가 바뀐다.
security6에서는 username, password만 있었다.
security7에서는 여기에 authority 컬럼이 추가된다.

// schema.sql
-- 권한용 ENUM 타입
CREATE TYPE role AS ENUM ('ADMIN', 'USER');

-- 인증 정보를 저장하는 테이블
CREATE TABLE authentications (
    -- 사용자명: 기본키
    username VARCHAR(50) PRIMARY KEY,
    -- 비밀번호
    password VARCHAR(255) NOT NULL,
    -- 권한
    authority role NOT NULL
);

CREATE TYPE role AS ENUM ('ADMIN', 'USER')는 role이라는 권한 타입을 만든다는 뜻이다.
이 타입에는 ADMIN, USER 값만 들어갈 수 있다.


authority role NOT NULL은 권한 컬럼을 반드시 저장해야 한다는 뜻이다.
사용자에게 권한이 없으면 권한 기반 화면 표시나 인가 처리를 할 수 없기 때문이다.


이제 authentications 테이블 한 건에는 사용자명, 비밀번호, 권한이 함께 저장된다.


data.sql에는 todos 초기 데이터와 로그인 테스트용 사용자 데이터가 함께 들어간다.
todos 데이터는 H2 Console에서 초기 SQL 실행 여부를 확인할 때 사용할 수 있고, authentications 데이터는 로그인 인증과 권한 확인에 사용된다.

// data.sql
-- 첫 번째 데이터 등록
INSERT INTO todos (todo, detail, created_at, updated_at)
VALUES
('쇼핑', '마트에서 식재료 구입하기', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
-- 두 번째 데이터 등록
INSERT INTO todos (todo, detail, created_at, updated_at)
VALUES
('도서관에 가기', '책 빌리기', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
-- 세 번째 데이터 등록
INSERT INTO todos (todo, detail, created_at, updated_at)
VALUES
('헬스장 가기', '운동하기', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

-- 인증 테이블에 더미 데이터를 추가
-- password:adminpass
INSERT INTO authentications (username, password, authority) VALUES
    ('admin', '$2a$10$bgJaXkEIfLJdgR6sU2849OPm4y7sZeSlEiyjsBhmqFfn0/2eAAKAK', 'ADMIN');
-- password: userpass
INSERT INTO authentications (username, password, authority) VALUES
    ('user', '$2a$10$Kqsv3UG.FSPOEWQ236DnJeSA/4UDrK2FmAoeDQws1YSpXNPx8Y8GO', 'USER');

사용자가 로그인 화면에 입력하는 비밀번호는 평문이다.
admin은 adminpass를 입력한다.
user는 userpass를 입력한다.


하지만 DB에 저장되는 비밀번호는 평문이 아니다.
두 사용자 모두 BCrypt로 해시 처리된 비밀번호가 저장된다.


여기서 중요한 점은 권한 값이다.
admin 데이터에는 ADMIN이 저장되어 있고, user 데이터에는 USER가 저장되어 있다.
이 값이 뒤에서 GrantedAuthority로 바뀌어 화면 표시와 인가 처리에 사용된다.


PasswordGenerator는 테스트 비밀번호 해시값을 만든다

security7에서도 비밀번호는 평문으로 저장하지 않는다.
admin은 로그인할 때 adminpass를 입력하고, user는 userpass를 입력한다.
하지만 DB에는 두 비밀번호가 그대로 저장되지 않는다.
BCrypt로 해시 처리된 문자열이 저장된다.


PasswordGenerator는 평문 비밀번호를 BCrypt 해시 문자열로 바꿔 출력하는 보조 코드이다.
adminpass 해시가 필요하면 rawPassword에 adminpass를 넣고 실행한다.
userpass 해시가 필요하면 rawPassword 값을 userpass로 바꿔 실행하면 된다.

// PasswordGenerator.java
package com.example.security7;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class PasswordGenerator {
    public static void main(String[] args) {
        // BCrypt 인코더를 만든다.
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        // 해시로 바꿀 평문 비밀번호를 준비한다.
        String rawPassword = "adminpass";
        // 평문 비밀번호를 BCrypt 해시 문자열로 바꾼다.
        String encodedPassword = encoder.encode(rawPassword);
        // data.sql에 넣을 해시 문자열을 출력한다.
        System.out.println("해시화된 비밀번호: " + encodedPassword);
    }
}

BCrypt는 내부적으로 salt를 사용한다.
그래서 같은 비밀번호를 해시 처리해도 실행할 때마다 결과 문자열이 달라질 수 있다.
하지만 BCryptPasswordEncoder.matches()는 저장된 해시 문자열을 기준으로 입력 비밀번호가 맞는지 검증할 수 있다.


Security7Application은 Mapper 위치를 등록한다

Security7Application은 Spring Boot 애플리케이션의 시작 클래스이다.
여기서 중요한 부분은 @MapperScan이다.


@MapperScan은 MyBatis Mapper 인터페이스가 있는 패키지를 Spring에게 알려주는 설정이다.
현재 AuthenticationMapper는 com.example.security7.repository 패키지에 있다.
그래서 @MapperScan(value={"com.example.security7.repository"})로 해당 위치를 지정한다.

// Security7Application.java
package com.example.security7;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan(value={"com.example.security7.repository"})
public class Security7Application {

    public static void main(String[] args) {
        // Spring Boot 애플리케이션을 실행한다.
        SpringApplication.run(Security7Application.class, args);
    }
}

AuthenticationMapper가 Spring Bean으로 등록되어야 LoginUserDatailsServiceImpl에서 주입받아 사용할 수 있다.
@MapperScan이 없으면 AuthenticationMapper를 찾지 못해 주입 문제가 생길 수 있다.


따라서 security7에서도 Mapper 위치를 등록하는 설정이 필요하다.


Authentication은 DB 조회 결과를 담는다

Authentication 클래스는 authentications 테이블에서 조회한 결과를 담는 프로젝트 내부 객체이다.
이름은 Spring Security의 Authentication과 같지만 역할은 다르다.


여기서의 Authentication은 DB에서 조회한 사용자 정보를 담기 위한 클래스이다.
Spring Security의 Authentication은 인증 성공 정보를 담는 보안 객체이다.
초보자는 이름이 같아서 헷갈릴 수 있으므로 구분해야 한다.


security7의 Authentication에는 username, password, authority가 들어 있다.

// Authentication.java
package com.example.security7.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Authentication {
    /** 사용자명 */
    private String username;
    /** 비밀번호 */
    private String password;
    /** 권한 */
    private Role authority;
}

username은 로그인할 사용자명이다.
password는 DB에 저장된 비밀번호이다.
현재 예제에서는 평문이 아니라 BCrypt 해시 문자열이다.


authority는 사용자의 권한이다.
타입은 Role이므로 값은 ADMIN 또는 USER가 된다.


이 객체는 최종 인증 객체가 아니다.
AuthenticationMapper가 조회한 결과를 잠시 담고, 이후 LoginUserDatailsServiceImpl에서 LoginUser로 바꾸는 데 사용된다.


AuthenticationMapper는 권한까지 함께 조회한다

AuthenticationMapper는 MyBatis가 사용하는 Mapper 인터페이스이다.
Mapper는 SQL을 실행해서 DB 데이터를 조회하는 역할을 한다.


security7에서는 사용자명으로 인증 정보를 조회해야 한다.
이때 비밀번호뿐 아니라 권한도 함께 가져와야 한다.
그래야 로그인한 사용자가 어떤 권한을 가졌는지 Spring Security가 알 수 있다.

// AuthenticationMapper.java
package com.example.security7.repository;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

import com.example.security7.entity.Authentication;

@Mapper
public interface AuthenticationMapper {

    /**
     * 사용자명으로 로그인 정보를 조회
     */
    @Select("SELECT username, password, authority FROM authentications WHERE username = #{username}")
    Authentication selectByUsername(String username);
}

@Mapper는 이 인터페이스가 MyBatis Mapper라는 뜻이다.
@Select에는 실행할 SQL을 직접 작성한다.


SELECT username, password, authority FROM authentications WHERE username = #{username}는 authentications 테이블에서 입력된 사용자명에 해당하는 사용자명, 비밀번호, 권한을 조회한다는 뜻이다.


예를 들어 사용자가 admin을 입력하면 username이 admin인 데이터를 찾는다.
조회 결과에는 admin의 비밀번호와 ADMIN 권한이 함께 들어 있다.


조회 결과가 있으면 Authentication 객체가 반환된다.
조회 결과가 없으면 null이 반환될 수 있고, 이 경우 뒤에서 UsernameNotFoundException이 발생한다.


LoginUser는 UserDetails로 사용할 사용자 객체이다

LoginUser는 Spring Security가 인증에 사용할 사용자 정보 객체이다.
Spring Security는 사용자 정보를 UserDetails 형태로 다룬다.


LoginUser는 Spring Security가 제공하는 User 클래스를 상속한다.
User는 이미 UserDetails를 구현한 클래스이다.
따라서 LoginUser도 UserDetails로 사용할 수 있다.

// LoginUser.java
package com.example.security7.entity;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

/**
 * 사용자의 인증 정보를 나타내는 UserDetails 구현 클래스
 */
public class LoginUser extends User {
    /**
     * 최소한의 정보만 담은 UserDetails 구현 클래스인 User를 생성
     */
    public LoginUser(String username,
                     String password,
                     Collection<? extends GrantedAuthority> authorities) {
        // 부모 User 클래스에 사용자명, 비밀번호, 권한 목록을 전달한다.
        super(username, password, authorities);
    }
}

생성자에서 받는 값은 username, password, authorities이다.
username은 로그인 사용자명이다.
password는 저장된 비밀번호이다.
authorities는 권한 목록이다.


security6에서는 권한 목록이 비어 있었다.
하지만 security7에서는 DB에서 조회한 권한을 GrantedAuthority 목록으로 바꿔 넣는다.
이 차이가 권한 기반 화면 표시와 접근 제어의 시작점이다.


LoginUserDatailsServiceImpl은 권한 목록을 만들어 반환한다

LoginUserDatailsServiceImpl은 UserDetailsService를 구현한 클래스이다.
이 클래스는 로그인 요청 중 사용자명을 기준으로 사용자 정보를 조회한다.


security7에서는 AuthenticationMapper로 DB를 조회한다.
조회 결과에는 사용자명, 비밀번호, 권한이 들어 있다.
그 다음 권한 값을 GrantedAuthority 목록으로 바꿔 LoginUser에 담는다.

// LoginUserDatailsServiceImpl.java
package com.example.security7.service;

import java.util.ArrayList;
import java.util.List;

import com.example.security7.entity.Role;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.example.security7.entity.Authentication;
import com.example.security7.entity.LoginUser;
import com.example.security7.repository.AuthenticationMapper;

import lombok.RequiredArgsConstructor;

/**
 * 커스텀 인증 서비스
 */
@Service
@RequiredArgsConstructor
public class LoginUserDatailsServiceImpl implements UserDetailsService {
    /** DI */
    private final AuthenticationMapper authenticationMapper;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        // 인증 테이블에서 데이터를 가져온다.
        Authentication authentication = authenticationMapper.selectByUsername(username);

        // 대상 데이터가 있으면 UserDetails의 구현 클래스를 반환한다.
        if (authentication != null) {
            return new LoginUser(authentication.getUsername(),
                    authentication.getPassword(),
                    getAuthorityList(authentication.getAuthority())
            );
        } else {
            // 사용자가 없으면 인증 실패로 이어질 예외를 발생시킨다.
            throw new UsernameNotFoundException(
                    username + " => 사용자명이 존재하지 않습니다.");
        }
    }

    /**
     * 권한 정보 목록에서 권한 정보 가져오기
     */
    private List<GrantedAuthority> getAuthorityList(Role role) {
        // 권한 리스트를 만든다.
        List<GrantedAuthority> authorities = new ArrayList<>();
        // 열거형에서 권한을 가져와 GrantedAuthority로 바꾼다.
        authorities.add(new SimpleGrantedAuthority(role.name()));
        // ADMIN 역할인 경우 USER 권한도 추가로 부여한다.
        if (role == Role.ADMIN) {
            authorities.add(
                    new SimpleGrantedAuthority(Role.USER.toString()));
        }
        // 완성된 권한 목록을 반환한다.
        return authorities;
    }
}

authenticationMapper.selectByUsername(username)은 입력된 사용자명으로 authentications 테이블을 조회한다.
조회 결과가 있으면 Authentication 객체가 반환된다.


new LoginUser(...)를 만들 때 세 번째 값으로 getAuthorityList(authentication.getAuthority())를 넣는다.
이 부분이 security6와 가장 크게 다르다.
security6에서는 권한 목록을 비워 두었지만, security7에서는 권한 목록을 직접 만든다.


getAuthorityList는 Role을 GrantedAuthority로 바꾼다

Spring Security는 권한을 GrantedAuthority 형태로 이해한다.
그래서 Role.ADMIN이나 Role.USER를 그대로 넣지 않고, SimpleGrantedAuthority로 바꿔야 한다.


authorities.add(new SimpleGrantedAuthority(role.name()))는 현재 사용자의 권한을 권한 목록에 추가한다.
예를 들어 role이 Role.USER이면 USER 권한이 추가된다.
role이 Role.ADMIN이면 ADMIN 권한이 추가된다.


그 다음 role == Role.ADMIN이면 USER 권한도 추가한다.
관리자는 관리자 기능뿐 아니라 일반 사용자 영역도 볼 수 있어야 하기 때문이다.

로그인 사용자DB 권한최종 GrantedAuthority 목록
adminADMINADMIN, USER
userUSERUSER

security7에서는 Role 값을 GrantedAuthority 목록으로 바꿔야 Spring Security가 권한을 기준으로 판단할 수 있다.


PasswordConfig는 BCryptPasswordEncoder를 등록한다

security7에서도 비밀번호는 BCrypt 해시 문자열로 저장된다.
따라서 비밀번호 비교에는 BCryptPasswordEncoder가 필요하다.


사용자는 로그인 화면에 평문 비밀번호를 입력한다.
예를 들어 admin은 adminpass, user는 userpass를 입력한다.
하지만 DB에는 평문이 아니라 해시 문자열이 저장되어 있다.


BCryptPasswordEncoder는 입력 비밀번호와 저장된 해시 문자열이 같은 비밀번호에서 나온 것인지 검증한다.

// PasswordConfig.java
package com.example.security7.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt 방식으로 비밀번호를 비교하는 인코더를 반환한다.
        return new BCryptPasswordEncoder();
    }
}

PasswordConfig는 PasswordEncoder 타입의 Bean을 등록한다.
이 Bean은 Spring Security 인증 흐름에서 비밀번호 검증에 사용된다.


security5에서는 NoOpPasswordEncoder를 사용했다.
하지만 security6, security7에서는 BCryptPasswordEncoder를 사용한다.
그 이유는 DB에 저장된 비밀번호가 BCrypt 해시 문자열이기 때문이다.


SecurityConfig는 관리자 주소 접근을 제한한다

SecurityConfig는 요청 주소별 보안 규칙을 설정한다.
security7에서 가장 중요한 설정은 /todos/** 접근 제한이다.


/login과 /h2-console/**은 인증 없이 접근할 수 있다.
/todos/**는 ADMIN 권한이 있어야 접근할 수 있다.
그 밖의 요청은 로그인한 사용자만 접근할 수 있다.

// SecurityConfig.java
package com.example.security7.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import lombok.RequiredArgsConstructor;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    // SecurityFilterChain의 빈 정의
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // HTTP 요청에 대한 보안 설정
            .authorizeHttpRequests(requests -> requests
                    // /login과 /h2-console/**은 인증 없이 접근할 수 있다.
                    .requestMatchers("/login", "/h2-console/**").permitAll()
                    // /todos/**에는 ADMIN 권한을 가진 사용자만 접근할 수 있다.
                    .requestMatchers("/todos/**").hasAuthority("ADMIN")
                    // 그 밖의 요청은 인증이 필요하다.
                    .anyRequest().authenticated())
            // 실습 편의를 위해 CSRF 방어 기능을 비활성화한다.
            .csrf((csrf) -> csrf.disable())
            // 폼 기반 로그인 설정
            .formLogin(form -> form
                    // 직접 만든 로그인 페이지 URL
                    .loginPage("/login")
                    // 로그인 처리 URL
                    .loginProcessingUrl("/authentication")
                    // 사용자명의 name 속성 지정
                    .usernameParameter("usernameInput")
                    // 비밀번호의 name 속성 지정
                    .passwordParameter("passwordInput")
                    // 로그인 성공 시 이동할 URL
                    .defaultSuccessUrl("/", true)
                    // 로그인 실패 시 이동할 URL
                    .failureUrl("/login?error"))
            // 로그아웃 설정
            .logout(logout -> logout
                    // 로그아웃을 처리할 URL
                    .logoutUrl("/logout")
                    // 로그아웃 성공 시 이동할 URL
                    .logoutSuccessUrl("/login?logout")
                    // 로그아웃 시 세션 무효화
                    .invalidateHttpSession(true)
                    // 로그아웃 시 쿠키 삭제
                    .deleteCookies("JSESSIONID")
            )
            .headers(headers -> headers
                    // H2 Console 화면을 같은 출처에서 frame으로 열 수 있게 한다.
                    .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
            );
        // 설정한 내용으로 SecurityFilterChain을 만든다.
        return http.build();
    }
}

requestMatchers("/login", "/h2-console/**").permitAll()은 로그인 화면과 H2 Console 접근을 열어 둔다.
로그인하지 않은 사용자도 로그인 화면에 접근해야 하고, 실습 중에는 H2 Console에서 데이터를 확인해야 하기 때문이다.


requestMatchers("/todos/**").hasAuthority("ADMIN")은 /todos/**로 시작하는 요청은 ADMIN 권한이 있어야 한다는 뜻이다.
여기서 중요한 점은 hasAuthority("ADMIN")이다.
현재 예제는 권한 문자열을 ADMIN, USER로 만들기 때문에 ROLE_ADMIN이 아니라 ADMIN을 비교한다.


anyRequest().authenticated()는 나머지 요청에는 로그인만 요구한다.
예를 들어 / 메뉴 화면은 로그인한 사용자라면 접근할 수 있다.


sec:authorize가 화면에서 링크를 숨기는 역할이라면, SecurityConfig의 hasAuthority("ADMIN")는 서버에서 /todos/** 요청 자체를 제한하는 역할이다.


LoginForm과 LoginController는 로그인 화면을 준비한다

LoginForm은 로그인 화면에서 입력하는 계정과 암호 값을 담는 객체이다.
사용자명은 usernameInput에 들어가고, 비밀번호는 passwordInput에 들어간다.

// LoginForm.java
package com.example.security7.form;

import lombok.Data;

@Data
public class LoginForm {
    /** 사용자명 */
    private String usernameInput;
    /** 비밀번호 */
    private String passwordInput;
}

이 필드 이름은 login.html의 th:field와 연결된다.
또한 SecurityConfig의 usernameParameter("usernameInput"), passwordParameter("passwordInput")와도 맞아야 한다.


이름이 서로 다르면 Spring Security가 로그인 요청에서 사용자명과 비밀번호를 제대로 꺼내지 못한다.


LoginController는 /login 요청이 들어왔을 때 로그인 화면을 보여준다.
로그인 인증 처리를 직접 하지 않는다.

// LoginController.java
package com.example.security7.controller;

import com.example.security7.form.LoginForm;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;

@Controller
public class LoginController {
    @GetMapping("/login")
    public String showLogin(@ModelAttribute LoginForm form) {
        // login.html에서 사용할 LoginForm 객체를 준비한다.
        return "login";
    }
}

/login은 로그인 화면을 보여주는 주소이다.
실제 로그인 처리 주소는 /authentication이다.
/authentication 요청은 LoginController가 아니라 UsernamePasswordAuthenticationFilter가 처리한다.


login.html은 로그인 요청을 authentication으로 보낸다

login.html은 로그인 화면이다.
계정과 암호를 입력하고 로그인 버튼을 누르면 /authentication으로 요청을 보낸다.

// login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>로그인</title>
</head>
<body>
    <h2>로그인 화면</h2>
    <!-- 에러 표시 -->
    <div th:if="${param.error}">
        <p style="color: red;">사용자명이나 비밀번호가 올바르지 않습니다.</p>
    </div>
    <!-- 로그아웃 표시 -->
    <div th:if="${param.logout}">
        <p style="color: blue">로그아웃했습니다.</p>
    </div>
    <form th:action="@{/authentication}" method="post" th:object="${loginForm}">
        <div>
            <!-- 사용자명 입력 -->
            <label for="usernameInput">계정</label>
            <input type="text" th:field="*{usernameInput}">
        </div>
        <div>
            <!-- 비밀번호 입력 -->
            <label for="passwordInput">암호</label>
            <input type="password" th:field="*{passwordInput}">
        </div>
        <hr>
        <div>
            <!-- 로그인 요청 전송 -->
            <input type="submit" value="로그인">
        </div>
    </form>
</body>
</html>

th:action="@{/authentication}"은 로그인 form이 /authentication으로 제출된다는 뜻이다.
이 주소는 SecurityConfig의 loginProcessingUrl("/authentication")과 같아야 한다.


th:field="*{usernameInput}"은 사용자명 입력값을 usernameInput 이름으로 전송한다.
th:field="*{passwordInput}"은 비밀번호 입력값을 passwordInput 이름으로 전송한다.
이 이름들은 SecurityConfig의 usernameParameter(), passwordParameter()와 맞아야 한다.


로그인에 실패하면 /login?error로 돌아온다.
그래서 param.error가 존재하고, 실패 메시지가 표시된다.


로그아웃에 성공하면 /login?logout으로 돌아온다.
그래서 param.logout이 존재하고, 로그아웃 메시지가 표시된다.


MemberController는 메뉴 화면과 ToDo 목록 화면을 연결한다

MemberController는 로그인 후 이동할 화면을 처리한다.
/ 요청은 menu.html을 반환한다.

// MemberController.java
package com.example.security7.controller;

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

@Controller
public class MemberController {
    @GetMapping("/")
    public String menu() {
        // 메뉴 화면을 반환한다.
        return "menu";
    }

    @GetMapping("/todos")
    public String todos() {
        // ToDo 목록 화면을 반환한다.
        return "todo/list";
    }
}

로그인에 성공하면 SecurityConfig의 defaultSuccessUrl("/", true) 설정에 따라 /로 이동한다.
그러면 MemberController의 menu() 메서드가 실행되고 menu.html이 열린다.


/todos 요청은 todos() 메서드가 처리하고 todo/list.html을 반환한다.
하지만 이 주소는 SecurityConfig에서 /todos/**에 ADMIN 권한을 요구하도록 설정되어 있다.
그래서 ADMIN 권한이 없는 사용자는 접근할 수 없다.


현재 코드 기준으로 MemberController는 todos 데이터를 Model에 담지 않는다.
따라서 todo/list.html은 이 글에서 DB 목록 출력 완성 화면이라기보다, 관리자 권한이 있어야 들어갈 수 있는 화면으로 이해하는 것이 안전하다.


menu.html은 로그인 후 보이는 메뉴 화면이다.
이 화면에서는 Thymeleaf의 sec 속성을 사용해 권한별로 보이는 내용을 다르게 만든다.


sec:authorize, sec:authentication을 사용하려면 HTML 태그에 sec 네임스페이스를 선언해야 한다.

// menu.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
<head>
    <title>메뉴</title>
</head>
<body>
    <h2>메뉴 화면</h2>
    <hr>
    <a sec:authorize="hasAuthority('ADMIN')" th:href="@{/todos}">
        ToDo 목록
    </a>
    <br>
    <div sec:authorize="hasAuthority('USER')">
        【일반 권한으로 표시되는 부분】
    </div>
    <div sec:authentication="name">
        로그인 정보의 name 값으로 변경됨
    </div>
    <div sec:authorize="isAuthenticated()">
        인증된 경우에만 표시
    </div>
    <br>
    <!-- 로그아웃 -->
    <form th:action="@{/logout}" method="post">
        <input type="submit" value="로그아웃">
    </form>
</body>
</html>

xmlns:sec는 sec 속성을 사용하기 위한 선언이다.
이 선언이 있어야 sec:authorize, sec:authentication을 사용할 수 있다.


sec:authorize="hasAuthority('ADMIN')"은 현재 로그인한 사용자에게 ADMIN 권한이 있을 때만 해당 링크를 보여준다.
그래서 admin 사용자는 ToDo 목록 링크를 볼 수 있고, user 사용자는 볼 수 없다.


sec:authorize="hasAuthority('USER')"는 USER 권한이 있을 때만 해당 영역을 보여준다.
admin은 getAuthorityList()에서 USER 권한도 함께 받았기 때문에 이 영역을 볼 수 있다.
user도 USER 권한을 가지고 있으므로 이 영역을 볼 수 있다.


sec:authentication="name"은 현재 로그인한 사용자명을 출력한다.
admin으로 로그인하면 admin이 출력되고, user로 로그인하면 user가 출력된다.


sec:authorize="isAuthenticated()"는 로그인한 사용자에게만 해당 영역을 보여준다.
admin, user 모두 로그인에 성공하면 이 영역을 볼 수 있다.


sec:authentication으로 권한 목록도 확인할 수 있다

sec:authentication은 현재 로그인한 사용자의 인증 정보를 화면에 출력할 때 사용한다.
현재 menu.html 코드에는 sec:authentication="name"이 들어 있다.
이 값은 현재 로그인한 사용자명을 출력한다.


권한 목록을 화면에서 확인하고 싶다면 principal.authorities도 사용할 수 있다.
현재 코드에는 직접 들어가 있지 않지만, 권한 확인용으로 아래처럼 작성할 수 있다.

// menu-authorities-example.html
<span sec:authentication="principal.authorities">
    권한 목록
</span>

admin으로 로그인하면 ADMIN, USER 권한이 함께 확인될 수 있다.
user로 로그인하면 USER 권한만 확인될 수 있다.


다만 이 코드는 권한 목록을 화면에 출력하는 확인용이다.
권한에 따라 화면을 숨기거나 보여주는 처리는 sec:authorize가 담당한다.


화면 표시와 서버 접근 제한은 함께 봐야 한다

menu.html에서 sec:authorize="hasAuthority('ADMIN')"로 ToDo 목록 링크를 숨길 수 있다.
하지만 링크를 숨기는 것만으로 서버 주소가 보호되는 것은 아니다.


사용자가 주소창에 /todos를 직접 입력할 수도 있기 때문이다.
그래서 서버 쪽에서도 SecurityConfig로 접근을 제한해야 한다.
현재 코드에서는 /todos/**에 hasAuthority("ADMIN")을 설정했다.

구분담당 위치역할
화면 표시 제어menu.html의 sec:authorize권한에 따라 링크나 영역을 보여주거나 숨긴다.
서버 접근 제한SecurityConfig의 requestMatchers()권한이 없는 사용자의 요청 자체를 막는다.

권한 처리는 화면에서 숨기는 것과 서버에서 막는 것을 함께 봐야 한다.
security7에서는 이 두 흐름을 모두 확인할 수 있다.


todo/list.html은 관리자만 접근 가능한 화면이다

todo/list.html은 ToDo 목록을 보여주는 화면 구조를 가진다.
현재 MemberController에서 /todos 요청이 들어오면 todo/list를 반환한다.


하지만 이 주소는 SecurityConfig에서 ADMIN 권한으로 제한되어 있다.
따라서 이 화면은 ADMIN 권한 접근 제어를 확인하기 위한 화면으로 사용할 수 있다.

// list.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>ToDo 목록</title>
</head>
<body>
    <h2>ToDo 목록</h2>
    <!-- 플래시 메시지 표시 -->
    <p th:if="${message}" th:text="${message}"
       style="color: blue;">완료 메시지
    </p>
    <p th:if="${errorMessage}"
       th:text="${errorMessage}" style="color: red;">
        오류 메시지
    </p>
    <table border="1" width="300">
        <thead>
            <tr>
                <th>ID</th>
                <th>ToDo</th>
                <th>상세 보기</th>
            </tr>
        </thead>
        <tbody>
            <tr th:each="todo : ${todos}">
                <td th:text="${todo.id}"></td>
                <td th:text="${todo.todo}"></td>
                <td>
                    <a th:href="@{/todos/{id}(id=${todo.id})}">
                        상세 보기
                    </a>
                </td>
            </tr>
        </tbody>
    </table>
    <a th:href="@{/todos/form}">새 할일 등록</a>
    <br>
    <!-- 메뉴 화면 링크 -->
    <a th:href="@{/}">메뉴 화면으로</a>
</body>
</html>

th:each="todo : ${todos}"는 todos 목록을 반복해서 출력하는 부분이다.
다만 현재 코드 기준으로 MemberController는 todos 값을 Model에 담는 로직을 가지고 있지 않다.
그래서 이 글에서는 ToDo 목록 데이터 출력 자체보다, /todos 주소가 ADMIN 권한으로 보호된다는 점이 핵심이다.


현재 코드 기준으로 todo/list.html은 관리자 접근 제어 확인용 화면으로 설명하는 것이 맞다.


security7 실행 결과 확인

H2 Console에서 AUTHENTICATIONS 테이블을 조회하면 admin, user 사용자와 각 권한을 확인할 수 있다.
admin은 ADMIN 권한을 가지고, user는 USER 권한을 가진다.
비밀번호는 평문이 아니라 BCrypt 해시 문자열로 저장된다.


AUTHENTICATIONS 테이블에는 로그인 사용자와 권한 정보가 함께 저장된다.
이 권한 값은 로그인 후 GrantedAuthority 목록으로 바뀌어 화면 표시와 접근 제어에 사용된다.


TODOS 테이블을 조회하면 초기 SQL로 등록된 ToDo 데이터를 확인할 수 있다.
이 데이터는 data.sql이 실행되었는지 확인하는 데 도움이 된다.


TODOS 테이블에는 초기 테스트 데이터가 저장되어 있다.
다만 현재 MemberController에는 이 데이터를 조회해서 Model에 담는 코드가 없으므로, 이 화면은 DB 초기 데이터 확인 이미지로 설명하는 것이 맞다.


admin 로그인 결과 확인

admin / adminpass로 로그인하면 ADMIN 권한과 USER 권한을 함께 가진다.
그래서 메뉴 화면에서 ToDo 목록 링크와 일반 권한 영역이 모두 보인다.
또 SecurityConfig에서 /todos/** 접근 조건을 hasAuthority("ADMIN")으로 설정했기 때문에 /todos 화면에도 접근할 수 있다.


admin 사용자는 ADMIN 권한을 가지고 있으므로 ToDo 목록 링크가 보이고, /todos 화면에도 접근할 수 있다.


admin으로 로그인했을 때 흐름은 다음과 같다.

확인 항목결과
로그인 계정admin / adminpass
DB 권한ADMIN
최종 권한 목록ADMIN, USER
ToDo 목록 링크보임
일반 권한 영역보임
/todos 접근가능



user 로그인 결과 확인

user / userpass로 로그인하면 USER 권한만 가진다.
그래서 메뉴 화면에서 일반 권한 영역은 보이지만, ADMIN 권한이 필요한 ToDo 목록 링크는 보이지 않는다.
또 주소창에 /todos를 직접 입력해도 SecurityConfig의 hasAuthority("ADMIN") 조건 때문에 접근이 막힌다.


user 사용자는 USER 권한만 가지고 있으므로 일반 권한 영역은 보이지만 ToDo 목록 링크는 보이지 않는다.
또 /todos 주소를 직접 입력해도 서버에서 접근이 제한된다.


user로 로그인했을 때 흐름은 다음과 같다.

확인 항목결과
로그인 계정user / userpass
DB 권한USER
최종 권한 목록USER
ToDo 목록 링크보이지 않음
일반 권한 영역보임
/todos 접근불가능



security7 핵심 정리

security7에서는 권한 정보가 DB에서 시작된다.
authentications 테이블의 authority 값이 Authentication 객체에 담긴다.
그 값은 LoginUserDatailsServiceImpl에서 GrantedAuthority 목록으로 바뀐다.


권한 목록이 LoginUser에 담기면 Spring Security는 로그인한 사용자의 권한을 알 수 있다.
그 권한은 두 곳에서 사용된다.


첫째, menu.html에서 sec:authorize를 통해 화면에 보이는 내용을 다르게 만든다.
둘째, SecurityConfig에서 /todos/** 요청 자체를 ADMIN 권한으로 제한한다.


반드시 기억해야 할 내용은 다음과 같다.

  • security7은 security6의 DB 로그인 인증 흐름에 권한을 추가한 예제이다.
  • Role은 ADMIN, USER 권한 값을 제한하는 enum이다.
  • Authentication 객체에는 username, password, authority가 들어 있다.
  • AuthenticationMapper는 username, password, authority를 조회한다.
  • LoginUserDatailsServiceImpl은 Role을 GrantedAuthority 목록으로 바꾼다.
  • ADMIN 사용자는 ADMIN, USER 권한을 함께 가진다.
  • USER 사용자는 USER 권한만 가진다.
  • PasswordConfig는 BCryptPasswordEncoder를 등록한다.
  • SecurityConfig는 /todos/**를 ADMIN 권한으로 제한한다.
  • menu.html은 sec:authorize로 권한별 화면 표시를 제어한다.
  • sec:authentication="name"은 현재 로그인한 사용자명을 출력한다.
  • sec:authentication="principal.authorities"를 사용하면 현재 로그인한 사용자의 권한 목록도 확인할 수 있다.
  • hasAuthority("ADMIN")은 ADMIN 권한 문자열을 직접 비교한다.
  • 현재 예제는 ROLE_ADMIN이 아니라 ADMIN을 권한 문자열로 사용한다.
  • 화면에서 링크를 숨기는 것과 서버에서 주소 접근을 막는 것은 다르다.
  • 현재 코드 기준 todo/list.html은 ADMIN 접근 제어 확인용 화면으로 보는 것이 맞다.

security7의 핵심은 DB에서 조회한 권한을 GrantedAuthority로 바꾸고, 그 권한을 화면 표시와 서버 접근 제한에 함께 사용하는 것이다.




security8: 커스텀 사용자 정보와 접근 거부 처리하기

security8은 security7에서 만든 권한 기반 접근 제어 흐름을 한 단계 더 확장하는 예제이다.
security7에서는 DB에서 username, password, authority를 조회하고, 그 권한으로 화면 표시와 /todos/** 접근 제한을 처리했다.


security8에서는 여기에 displayname을 추가한다.
displayname은 로그인 아이디와 별도로 화면에 보여 줄 표시 이름이다.
예를 들어 로그인 아이디는 admin이지만, 화면에는 관리자처럼 보여 줄 수 있다.


또 하나의 핵심은 접근 거부 화면이다.
user 사용자가 /todos 주소를 직접 입력하면 ADMIN 권한이 없기 때문에 접근이 거부된다.
이때 기본 오류 화면으로 끝내지 않고, 직접 만든 403 화면으로 연결한다.


security8의 핵심은 커스텀 사용자 정보에 displayname을 추가하고, 권한이 부족한 요청을 직접 만든 접근 거부 화면으로 연결하는 것이다.


security7과 security8의 차이

security7에서는 권한 기반 접근 제어를 확인했다.
admin은 ADMIN 권한을 가지고 있으므로 ToDo 목록 링크가 보이고 /todos 화면에도 접근할 수 있었다.
반면 user는 USER 권한만 가지고 있으므로 ToDo 목록 링크가 보이지 않고, /todos에 직접 접근해도 차단되었다.


security8에서는 이 흐름을 유지하면서 두 가지가 추가된다.
첫째, 인증 테이블에 displayname 컬럼이 추가된다.
둘째, 접근이 거부되었을 때 보여 줄 403 화면이 추가된다.

구분security7security8
인증 테이블 조회 정보username, password, authorityusername, password, authority, displayname
커스텀 사용자 정보로그인 아이디, 비밀번호, 권한로그인 아이디, 비밀번호, 권한, 표시 이름
/todos/** 접근 조건ADMIN 권한 필요ADMIN 권한 필요
admin 접근가능가능
user 접근차단차단
접근 거부 결과기본 오류 흐름직접 만든 403 화면 표시

security8은 새로운 권한 규칙을 배우는 단계가 아니다.
기존 권한 규칙은 그대로 두고, 인증 사용자 정보와 접근 거부 결과 화면을 더 구체적으로 만드는 단계이다.


displayname을 추가하는 이유

username은 로그인할 때 사용하는 사용자명이다.
예를 들어 admin, user 같은 값이다.


하지만 실제 화면에서는 로그인 아이디를 그대로 보여주기보다 사용자가 이해하기 쉬운 이름을 보여주고 싶을 수 있다.
예를 들어 admin 대신 관리자, user 대신 일반 사용자처럼 표시할 수 있다.


이때 사용하는 값이 displayname이다.
displayname은 인증 자체에 필요한 비밀번호나 권한과는 다르다.
로그인한 사용자를 화면에서 더 보기 좋게 표현하기 위한 추가 사용자 정보이다.


displayname은 로그인 검증을 위한 값이 아니라, 인증된 사용자 객체에 함께 담아 화면에서 사용할 수 있는 추가 정보이다.


schema.sql은 displayname 컬럼을 추가한다

security8에서는 authentications 테이블에 displayname 컬럼이 추가된다.
security7의 테이블은 username, password, authority를 가지고 있었다.
security8에서는 여기에 표시 이름을 저장하는 displayname이 추가된다.

// schema.sql
-- 권한용 ENUM 타입
CREATE TYPE role AS ENUM ('ADMIN', 'USER');

-- 인증 정보를 저장하는 테이블
CREATE TABLE authentications (
    -- 사용자명: 기본키
    username VARCHAR(50) PRIMARY KEY,
    -- 비밀번호
    password VARCHAR(255) NOT NULL,
    -- 권한
    authority role NOT NULL,
    -- 표시명
    displayname VARCHAR(50) NOT NULL
);

displayname VARCHAR(50) NOT NULL은 표시 이름을 반드시 저장한다는 뜻이다.
이 값은 로그인 성공 후 LoginUser 객체에 함께 담긴다.


여기서 중요한 점은 displayname이 UserDetails의 기본 필드가 아니라는 것이다.
Spring Security가 기본으로 제공하는 User 클래스는 사용자명, 비밀번호, 권한 목록을 중심으로 다룬다.
그래서 displayname처럼 프로젝트에서 추가로 필요한 값은 직접 필드로 만들어야 한다.


data.sql은 표시 이름까지 함께 저장한다

data.sql에는 로그인 테스트용 사용자 데이터가 들어간다.
security8에서는 admin, user 사용자에 대해 권한뿐 아니라 표시 이름도 함께 저장한다.

// data.sql
-- password: adminpass
INSERT INTO authentications (username, password, authority, displayname)
VALUES (
    'admin',
    '$2a$10$bgJaXkEIfLJdgR6sU2849OPm4y7sZeSlEiyjsBhmqFfn0/2eAAKAK',
    'ADMIN',
    '관리자'
);

-- password: userpass
INSERT INTO authentications (username, password, authority, displayname)
VALUES (
    'user',
    '$2a$10$Kqsv3UG.FSPOEWQ236DnJeSA/4UDrK2FmAoeDQws1YSpXNPx8Y8GO',
    'USER',
    '일반 사용자'
);

admin 사용자는 ADMIN 권한과 관리자 표시 이름을 가진다.
user 사용자는 USER 권한과 일반 사용자 표시 이름을 가진다.


사용자가 로그인할 때 입력하는 값은 여전히 username과 비밀번호이다.
displayname은 로그인 입력값이 아니다.
로그인에 성공한 뒤 화면에서 사용할 수 있도록 사용자 객체에 함께 담기는 값이다.


Authentication은 DB 조회 결과를 담는다

Authentication 클래스는 authentications 테이블에서 조회한 결과를 담는 프로젝트 내부 객체이다.
Spring Security의 인증 완료 객체인 Authentication과 이름은 같지만 역할은 다르다.


여기서의 Authentication은 DB 조회 결과를 잠시 담는 객체이다.
security8에서는 displayname 필드가 추가된다.

// Authentication.java
package com.example.security8.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Authentication {
    /** 사용자명 */
    private String username;
    /** 비밀번호 */
    private String password;
    /** 권한 */
    private Role authority;
    /** 표시명 */
    private String displayname;
}

username은 로그인 아이디이다.
password는 DB에 저장된 BCrypt 해시 비밀번호이다.
authority는 ADMIN 또는 USER 권한이다.
displayname은 화면에 보여 줄 표시 이름이다.


이 객체는 최종 로그인 사용자 객체가 아니다.
AuthenticationMapper가 조회한 값을 담고, 이후 LoginUserDatailsServiceImpl에서 LoginUser로 바꾸는 데 사용된다.


AuthenticationMapper는 displayname까지 조회한다

AuthenticationMapper는 사용자명으로 인증 정보를 조회한다.
security8에서는 username, password, authority뿐 아니라 displayname도 함께 조회해야 한다.


그래야 LoginUserDatailsServiceImpl에서 LoginUser를 만들 때 표시 이름까지 전달할 수 있다.

// AuthenticationMapper.java
package com.example.security8.repository;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

import com.example.security8.entity.Authentication;

@Mapper
public interface AuthenticationMapper {

    /**
     * 사용자명으로 로그인 정보를 조회
     */
    @Select("SELECT username, password, authority, displayname FROM authentications WHERE username = #{username}")
    Authentication selectByUsername(String username);
}

SELECT 문에서 displayname을 빼면 Authentication 객체에 표시 이름이 들어오지 않는다.
그러면 LoginUser에 표시 이름을 넣을 수 없다.


DB에 컬럼을 추가했으면 조회 SQL, 조회 결과 객체, 최종 사용자 객체까지 같은 흐름으로 함께 수정해야 한다.


LoginUser는 displayname을 추가로 가진다

LoginUser는 Spring Security가 인증에 사용할 사용자 정보 객체이다.
기존에는 User 클래스를 상속하고, 사용자명, 비밀번호, 권한 목록만 부모 생성자로 넘겼다.


security8에서는 여기에 displayname 필드를 직접 추가한다.
User 클래스가 기본으로 제공하지 않는 값을 프로젝트에서 직접 보관해야 하기 때문이다.

// LoginUser.java
package com.example.security8.entity;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

/**
 * 사용자의 인증 정보를 나타내는 UserDetails 구현 클래스
 */
public class LoginUser extends User {
    /** 표시명 */
    private String displayname;

    public LoginUser(String username,
                     String password,
                     Collection<? extends GrantedAuthority> authorities,
                     String displayname) {
        // 부모 User 클래스에 사용자명, 비밀번호, 권한 목록을 전달한다.
        super(username, password, authorities);
        // 프로젝트에서 추가로 사용할 표시 이름을 저장한다.
        this.displayname = displayname;
    }

    /**
     * 표시명 반환
     */
    public String getDisplayname() {
        // 화면에서 principal.displayname으로 접근할 수 있다.
        return displayname;
    }
}

super(username, password, authorities)는 부모 클래스인 User에 인증 기본 정보를 넘긴다.
이 값들은 Spring Security가 인증과 권한 판단에 사용한다.


this.displayname = displayname은 프로젝트에서 추가로 사용할 표시 이름을 저장한다.
이 값은 인증 판단 자체보다는 화면 출력에 사용된다.


getDisplayname()은 표시 이름을 꺼내기 위한 메서드이다.
이 메서드가 있어야 화면에서 principal.displayname 형태로 접근할 수 있다.


LoginUserDatailsServiceImpl은 displayname까지 LoginUser에 전달한다

LoginUserDatailsServiceImpl은 사용자명을 기준으로 DB에서 사용자 정보를 조회하고, 그 결과를 UserDetails 형태로 반환한다.


security8에서는 조회 결과에 displayname이 추가되었기 때문에, LoginUser를 만들 때도 authentication.getDisplayname()을 함께 넘겨야 한다.

// LoginUserDatailsServiceImpl.java
package com.example.security8.service;

import java.util.ArrayList;
import java.util.List;

import com.example.security8.entity.Role;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.example.security8.entity.Authentication;
import com.example.security8.entity.LoginUser;
import com.example.security8.repository.AuthenticationMapper;

import lombok.RequiredArgsConstructor;

/**
 * 커스텀 인증 서비스
 */
@Service
@RequiredArgsConstructor
public class LoginUserDatailsServiceImpl implements UserDetailsService {
    /** DI */
    private final AuthenticationMapper authenticationMapper;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        // 인증 테이블에서 데이터를 가져온다.
        Authentication authentication = authenticationMapper.selectByUsername(username);

        // 대상 데이터가 있으면 UserDetails의 구현 클래스를 반환한다.
        if (authentication != null) {
            return new LoginUser(authentication.getUsername(),
                    authentication.getPassword(),
                    getAuthorityList(authentication.getAuthority()),
                    authentication.getDisplayname()
            );
        } else {
            // 사용자가 없으면 인증 실패로 이어질 예외를 발생시킨다.
            throw new UsernameNotFoundException(
                    username + " => 사용자명이 존재하지 않습니다.");
        }
    }

    /**
     * 권한 정보 목록에서 권한 정보 가져오기
     */
    private List<GrantedAuthority> getAuthorityList(Role role) {
        // 권한 리스트를 만든다.
        List<GrantedAuthority> authorities = new ArrayList<>();
        // 열거형에서 권한을 가져와 GrantedAuthority로 바꾼다.
        authorities.add(new SimpleGrantedAuthority(role.name()));
        // ADMIN 역할인 경우 USER 권한도 추가로 부여한다.
        if (role == Role.ADMIN) {
            authorities.add(
                    new SimpleGrantedAuthority(Role.USER.toString()));
        }
        // 완성된 권한 목록을 반환한다.
        return authorities;
    }
}

authenticationMapper.selectByUsername(username)은 DB에서 사용자 정보를 조회한다.
이제 조회 결과에는 displayname도 포함된다.


new LoginUser(...)의 마지막 인자로 authentication.getDisplayname()을 넘긴다.
이렇게 해야 로그인 성공 후 인증 객체 안의 principal이 표시 이름까지 가지게 된다.


즉, 흐름은 다음과 같다.

단계처리 내용
1DB에서 displayname을 함께 조회한다.
2조회 결과를 Authentication 객체에 담는다.
3LoginUserDatailsServiceImpl이 Authentication 값을 읽는다.
4LoginUser 생성자에 displayname을 넘긴다.
5LoginUser의 getDisplayname()으로 표시 이름을 사용할 수 있다.



security8에서는 LoginUser에 displayname이 들어 있다.
따라서 화면에서는 로그인 아이디뿐 아니라 표시 이름도 출력할 수 있다.


Thymeleaf의 sec:authentication은 현재 인증된 사용자 정보를 화면에 출력할 때 사용한다.
principal은 현재 로그인한 사용자 객체를 의미한다.
현재 예제에서는 이 principal이 LoginUser 객체라고 이해하면 된다.

// menu-displayname-example.html
<span sec:authentication="principal.displayname">
    표시 이름
</span>

admin으로 로그인하면 관리자가 출력될 수 있다.
user로 로그인하면 일반 사용자가 출력될 수 있다.


여기서 principal.displayname이 동작하려면 LoginUser 안에 getDisplayname()이 있어야 한다.
화면에서 필드처럼 보이지만 실제로는 getDisplayname() 메서드를 통해 값을 읽는 흐름이다.


화면에서 principal.displayname을 사용하려면, 인증된 사용자 객체인 LoginUser에 displayname 필드와 getDisplayname() 메서드가 있어야 한다.


로그인 사용자 정보를 꺼내는 방법

Spring Security에서 인증에 성공하면 로그인 사용자 정보가 보안 컨텍스트에 저장된다.
보안 컨텍스트는 현재 요청에서 사용할 인증 정보를 담는 저장 공간이라고 이해하면 된다.


로그인한 사용자 정보를 꺼내는 방법은 여러 가지가 있다.
화면에서는 sec:authentication을 사용할 수 있고, Controller에서는 SecurityContextHolder, Principal, Authentication, @AuthenticationPrincipal을 사용할 수 있다.

방법사용 위치특징
SecurityContextHolder일반 Java 코드현재 보안 컨텍스트에서 인증 객체를 직접 꺼낸다.
Principal 매개변수Controller 메서드로그인 사용자 이름처럼 기본 정보를 간단히 받을 수 있다.
Authentication 매개변수Controller 메서드인증 객체 전체를 받을 수 있다.
@AuthenticationPrincipalController 메서드현재 로그인한 사용자 객체를 바로 주입받을 수 있다.

처음에는 @AuthenticationPrincipal이 가장 이해하기 쉽다.
현재 로그인한 사용자 객체를 Controller 메서드의 매개변수로 바로 받을 수 있기 때문이다.


@AuthenticationPrincipal은 인증이 끝난 현재 로그인 사용자 객체를 Controller에서 바로 꺼내 쓰는 방법이다.


SecurityContextHolder에서 직접 꺼내는 방법

SecurityContextHolder는 현재 보안 컨텍스트에 접근할 때 사용한다.
여기서 인증 객체를 꺼내면 현재 로그인한 사용자 정보를 확인할 수 있다.

// SecurityContextHolderExample.java
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;

public class SecurityContextHolderExample {
    public void printLoginUser() {
        // 현재 인증 객체에서 principal을 꺼낸다.
        Object principal = SecurityContextHolder.getContext()
                .getAuthentication()
                .getPrincipal();

        // principal을 User 타입으로 변환한다.
        User currentUser = (User) principal;

        // 현재 로그인한 사용자명을 출력한다.
        System.out.println(currentUser.getUsername());
    }
}

getAuthentication()은 현재 인증 정보를 꺼낸다.
getPrincipal()은 인증된 사용자 객체를 꺼낸다.


다만 이 방식은 직접 형변환이 필요하다.
또 현재 인증 객체 안에 어떤 타입이 들어 있는지 알고 있어야 한다.
그래서 Controller에서는 보통 더 간단한 방식인 @AuthenticationPrincipal을 많이 사용한다.


Controller 매개변수로 Principal이나 Authentication을 받는 방법

Controller 메서드에서는 Principal이나 Authentication을 매개변수로 받을 수 있다.
Principal은 로그인 사용자 이름을 간단히 확인할 때 사용할 수 있다.
Authentication은 사용자 이름뿐 아니라 권한 정보까지 확인할 때 사용할 수 있다.

// LoginUserControllerExample.java
import java.security.Principal;

import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginUserControllerExample {

    @GetMapping("/principal")
    public String principalPage(Principal principal) {
        // 현재 로그인한 사용자명을 확인한다.
        System.out.println(principal.getName());
        // 화면 이름을 반환한다.
        return "menu";
    }

    @GetMapping("/authentication")
    public String authenticationPage(Authentication authentication) {
        // 현재 로그인한 사용자명을 확인한다.
        System.out.println(authentication.getName());
        // 현재 로그인한 사용자의 권한 목록을 확인한다.
        System.out.println(authentication.getAuthorities());
        // 화면 이름을 반환한다.
        return "menu";
    }
}

Principal은 사용자 이름만 간단히 볼 때 편하다.
Authentication은 인증 객체 전체를 받을 수 있으므로 권한 목록까지 확인할 수 있다.


하지만 displayname처럼 직접 추가한 필드를 사용하려면, 현재 principal이 어떤 사용자 객체인지 알아야 한다.
security8에서는 LoginUser에 displayname을 추가했으므로, @AuthenticationPrincipal LoginUser loginUser처럼 받는 방식이 더 자연스럽다.


@AuthenticationPrincipal로 LoginUser를 바로 받는 방법

@AuthenticationPrincipal은 현재 로그인한 사용자 객체를 바로 매개변수로 받게 해 준다.
security8에서는 인증 성공 후 LoginUser 객체가 principal로 들어간다.
따라서 Controller에서 LoginUser를 바로 받을 수 있다.

// MemberControllerAuthenticationPrincipalExample.java
package com.example.security8.controller;

import java.time.LocalDateTime;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import com.example.security8.entity.LoginUser;

@Controller
public class MemberControllerAuthenticationPrincipalExample {

    @GetMapping("/memberpage")
    public String dashboardPage(@AuthenticationPrincipal LoginUser user) {
        // 현재 로그인한 사용자명을 확인한다.
        System.out.println(user.getUsername());
        // 현재 로그인한 사용자의 권한 목록을 확인한다.
        System.out.println(user.getAuthorities());
        // security8에서 추가한 표시 이름을 확인한다.
        System.out.println(user.getDisplayname());
        // 접근 시각을 함께 확인한다.
        System.out.println(LocalDateTime.now());
        // 회원 페이지 화면을 반환한다.
        return "member_page";
    }
}

@AuthenticationPrincipal LoginUser user는 현재 로그인한 사용자 객체를 user 변수로 받는다는 뜻이다.
이 객체는 LoginUserDatailsServiceImpl에서 반환한 LoginUser이다.


그래서 getUsername()으로 로그인 아이디를 꺼낼 수 있다.
getAuthorities()로 권한 목록을 꺼낼 수 있다.
getDisplayname()으로 security8에서 추가한 표시 이름도 꺼낼 수 있다.


displayname처럼 직접 추가한 사용자 정보는 LoginUser에 필드와 getter를 만들어 두어야 @AuthenticationPrincipal로 꺼내 사용할 수 있다.


접근 거부 화면이 필요한 이유

security7에서는 user가 /todos에 접근하면 서버에서 접근이 막혔다.
이때 권한 검사는 제대로 동작한다.
하지만 사용자가 보는 화면이 기본 오류 화면이면 왜 막혔는지 이해하기 어렵다.


security8에서는 접근이 거부되었을 때 직접 만든 403 화면을 보여준다.
403은 서버가 요청을 이해했지만, 현재 사용자에게 접근 권한이 없을 때 사용하는 상태 코드이다.


여기서 중요한 점은 로그인 실패와 접근 거부가 다르다는 것이다.
로그인 실패는 사용자명이나 비밀번호가 틀린 상황이다.
접근 거부는 로그인은 했지만 필요한 권한이 없는 상황이다.

구분의미예시
로그인 실패사용자명이나 비밀번호가 맞지 않는다./login?error로 이동한다.
접근 거부로그인은 했지만 필요한 권한이 없다./todos 접근 시 403 화면이 표시된다.

user는 로그인에 성공한 사용자이다.
하지만 ADMIN 권한이 없기 때문에 /todos에 접근할 수 없다.
그래서 이 상황은 로그인 실패가 아니라 접근 거부이다.


SecurityConfig에서 접근 제한과 접근 거부 화면을 함께 설정한다

security8에서도 /todos/** 주소는 ADMIN 권한이 있어야 접근할 수 있다.
이 접근 조건 자체는 security7과 같다.


달라지는 부분은 권한이 부족한 사용자가 접근했을 때의 처리이다.
security7에서는 접근이 막히는 것만 확인했다면, security8에서는 직접 만든 403 화면으로 이동하게 만든다.

// SecurityConfig.java
package com.example.security8.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import lombok.RequiredArgsConstructor;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 요청 주소별 접근 권한 설정
            .authorizeHttpRequests(requests -> requests
                    // 로그인 화면과 H2 Console은 로그인 없이 접근할 수 있다.
                    .requestMatchers("/login", "/h2-console/**").permitAll()
                    // ToDo 관련 화면은 ADMIN 권한이 있어야 접근할 수 있다.
                    .requestMatchers("/todos/**").hasAuthority("ADMIN")
                    // 그 밖의 요청은 로그인한 사용자만 접근할 수 있다.
                    .anyRequest().authenticated())
            // 실습 편의를 위해 CSRF 방어 기능을 비활성화한다.
            .csrf((csrf) -> csrf.disable())
            // 폼 로그인 설정
            .formLogin(form -> form
                    // 직접 만든 로그인 화면 주소
                    .loginPage("/login")
                    // 로그인 인증 처리 주소
                    .loginProcessingUrl("/authentication")
                    // 사용자명 파라미터 이름
                    .usernameParameter("usernameInput")
                    // 비밀번호 파라미터 이름
                    .passwordParameter("passwordInput")
                    // 로그인 성공 후 이동할 주소
                    .defaultSuccessUrl("/", true)
                    // 로그인 실패 후 이동할 주소
                    .failureUrl("/login?error"))
            // 로그아웃 설정
            .logout(logout -> logout
                    // 로그아웃 처리 주소
                    .logoutUrl("/logout")
                    // 로그아웃 성공 후 이동할 주소
                    .logoutSuccessUrl("/login?logout")
                    // 로그아웃 시 세션 무효화
                    .invalidateHttpSession(true)
                    // 로그아웃 시 쿠키 삭제
                    .deleteCookies("JSESSIONID"))
            // 접근 거부 처리 설정
            .exceptionHandling(exception -> exception
                    // 권한이 부족하면 직접 만든 403 화면으로 이동한다.
                    .accessDeniedPage("/error/403"))
            // H2 Console 화면을 frame으로 열 수 있게 설정한다.
            .headers(headers -> headers
                    .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin));

        // 설정한 보안 규칙으로 SecurityFilterChain을 만든다.
        return http.build();
    }
}

requestMatchers("/todos/**").hasAuthority("ADMIN")은 /todos/** 요청에 필요한 권한을 정한다.
즉, 이 주소는 ADMIN 권한을 가진 사용자만 접근할 수 있다.


.exceptionHandling(...accessDeniedPage("/error/403"))는 권한이 부족할 때 어디로 보낼지 정한다.
즉, 접근 조건을 정하는 코드가 아니라 접근이 거부된 뒤 보여 줄 화면을 정하는 코드이다.


hasAuthority("ADMIN")은 접근 조건을 정하고, accessDeniedPage("/error/403")는 접근이 거부됐을 때 보여 줄 화면을 정한다.


403 화면으로 이동하는 경로를 준비한다

accessDeniedPage("/error/403")를 설정했다면, /error/403 요청을 처리할 흐름이 필요하다.
이 주소가 실제 화면으로 연결되어야 사용자가 직접 만든 403 화면을 볼 수 있다.


예를 들어 /error/403 요청을 error/403.html로 연결하는 Controller를 둘 수 있다.

// ErrorController.java
package com.example.security8.controller;

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

@Controller
public class ErrorController {

    @GetMapping("/error/403")
    public String accessDenied() {
        // 접근 거부 화면을 반환한다.
        return "error/403";
    }
}

return "error/403"은 templates/error/403.html 화면을 반환한다는 뜻이다.
즉, 접근이 거부된 사용자는 /error/403 주소로 이동하고, 이 컨트롤러를 통해 403 화면을 보게 된다.


403 화면은 접근 거부 결과를 사용자에게 보여준다

403 화면은 권한이 부족해서 접근할 수 없다는 사실을 사용자에게 알려주는 화면이다.
단순 오류 페이지보다 사용자에게 더 친절하다.
또 메뉴 화면으로 돌아갈 수 있는 링크를 제공하면 사용자가 다음 행동을 할 수 있다.

// 403.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>접근 거부</title>
</head>
<body>
    <h2>403 - 접근 권한이 없습니다</h2>
    <p>현재 사용자에게 이 화면에 접근할 권한이 없습니다.</p>
    <a th:href="@{/}">메뉴 화면으로</a>
</body>
</html>

403 - 접근 권한이 없습니다는 현재 요청이 권한 부족으로 막혔다는 의미를 사용자에게 보여준다.
메뉴 화면으로 링크는 다시 / 요청으로 이동하게 한다.


/ 요청은 로그인한 사용자가 접근할 수 있는 메뉴 화면이다.
따라서 접근이 거부된 사용자는 관리자 화면으로 갈 수는 없지만, 메뉴 화면으로 돌아가 다른 기능을 사용할 수 있다.


security8의 접근 거부 처리 흐름

security8에서 user가 /todos에 직접 접근했을 때 흐름은 다음과 같다.
user는 이미 로그인에 성공했지만 USER 권한만 가진다.
/todos/**에는 ADMIN 권한이 필요하므로 접근이 거부된다.

단계처리 내용
1user / userpass로 로그인한다.
2LoginUserDatailsServiceImpl이 USER 권한을 가진 LoginUser를 반환한다.
3인증에 성공하고 인증 정보가 SecurityContextHolder에 저장된다.
4사용자가 주소창에 /todos를 직접 입력한다.
5SecurityConfig의 /todos/** 규칙이 실행된다.
6user에게 ADMIN 권한이 없으므로 접근이 거부된다.
7접근 거부 설정에 따라 /error/403으로 이동한다.
8사용자는 메뉴 화면으로 링크를 눌러 /로 돌아갈 수 있다.

이 흐름에서 핵심은 user가 로그인하지 않은 사용자가 아니라는 점이다.
로그인은 성공했지만 필요한 권한이 없기 때문에 접근이 거부된다.


따라서 /login?error로 가는 것이 아니라 403 화면으로 가는 흐름이 맞다.


admin은 /todos 화면에 접근할 수 있다

admin으로 로그인하면 DB에서 ADMIN 권한이 조회된다.
그리고 LoginUserDatailsServiceImpl에서 ADMIN 권한을 GrantedAuthority로 바꾸고, 추가로 USER 권한도 함께 부여한다.


그래서 admin은 최종적으로 ADMIN, USER 권한을 가진다.
SecurityConfig에서 /todos/** 요청은 ADMIN 권한이 있어야 접근할 수 있도록 설정되어 있다.
따라서 admin은 /todos 화면에 접근할 수 있다.

확인 항목결과
로그인 계정admin / adminpass
보유 권한ADMIN, USER
메뉴 화면의 ToDo 목록 링크보임
/todos 접근가능

admin이 ToDo 목록 링크를 클릭하면 /todos 요청이 발생한다.
이 요청은 ADMIN 권한 조건을 만족하므로 todo/list.html 화면으로 이동한다.


admin 사용자는 ADMIN 권한을 가지고 있으므로 메뉴 화면에서 ToDo 목록 링크가 보이고, /todos 화면에도 접근할 수 있다.


user는 /todos 접근 시 403 화면이 표시된다

user로 로그인하면 DB에서 USER 권한이 조회된다.
최종 권한 목록에도 USER 권한만 들어간다.


그래서 menu.html에서는 일반 권한 영역은 보이지만, ADMIN 권한이 필요한 ToDo 목록 링크는 보이지 않는다.
하지만 사용자가 주소창에 /todos를 직접 입력할 수도 있다.


이 경우 서버의 SecurityConfig에서 /todos/** 요청을 다시 검사한다.
user는 ADMIN 권한이 없기 때문에 접근이 거부된다.


security8에서는 이 접근 거부 결과를 직접 만든 403 화면으로 보여준다.
화면에는 403 - 접근 권한이 없습니다라는 문구와 메뉴 화면으로 돌아가는 링크가 표시된다.

확인 항목결과
로그인 계정user / userpass
보유 권한USER
메뉴 화면의 ToDo 목록 링크보이지 않음
/todos 직접 접근접근 거부
접근 거부 화면403 - 접근 권한이 없습니다

user 사용자는 USER 권한만 가지고 있으므로 /todos 주소를 직접 입력해도 접근할 수 없다.
접근이 거부되면 403 화면이 표시되고, 메뉴 화면으로 돌아갈 수 있다.


핵심 정리

security8에서 중요한 점은 /todos/** 접근 조건 자체가 바뀐 것이 아니라는 점이다.
여전히 /todos/**는 ADMIN 권한이 있어야 접근할 수 있다.


달라진 점은 권한이 없는 사용자가 접근했을 때의 결과 화면이다.
기본 오류 화면으로 끝내지 않고, 접근 거부 전용 화면을 보여준다.


정리하면 다음과 같다.

상황원인결과
admin이 /todos 접근ADMIN 권한 있음접근 가능
user가 메뉴 화면 확인USER 권한만 있음일반 권한 영역만 보임
user가 /todos 직접 접근ADMIN 권한 없음접근 거부
접근 거부 시 화면권한 부족403 - 접근 권한이 없습니다 화면 표시
이후 이동사용자 편의 처리메뉴 화면으로 링크 제공

반드시 기억해야 할 내용은 다음과 같다.

  • security8은 security7의 권한 기반 접근 제어 흐름을 유지한다.
  • /todos/**는 여전히 ADMIN 권한이 있어야 접근할 수 있다.
  • displayname은 로그인 아이디와 별도로 화면에 보여 줄 표시 이름이다.
  • displayname을 사용하려면 DB 컬럼, 조회 객체, LoginUser, LoginUserDatailsServiceImpl 흐름이 모두 이어져야 한다.
  • LoginUser에 getDisplayname()이 있어야 화면에서 principal.displayname으로 접근할 수 있다.
  • 로그인 사용자 정보는 SecurityContextHolder, Principal, Authentication, @AuthenticationPrincipal로 꺼낼 수 있다.
  • 권한이 부족한 사용자가 보호 주소에 접근하면 접근 거부 상황이 된다.
  • 접근 거부는 로그인 실패가 아니라 권한 부족이다.
  • hasAuthority("ADMIN")은 접근 조건을 정한다.
  • accessDeniedPage("/error/403")는 접근이 거부됐을 때 보여 줄 화면을 정한다.
  • 접근 거부 화면을 만들면 사용자가 왜 막혔는지 이해하기 쉽다.
  • 403 화면에 메뉴 화면으로 링크를 제공하면 사용자가 다시 안전한 화면으로 돌아갈 수 있다.

security8은 커스텀 사용자 정보에 displayname을 추가하고, 권한 부족 상황을 사용자가 이해할 수 있는 403 화면으로 연결하는 예제이다.

0개의 댓글