Bean을 수동으로 등록하는 방법
- Bean 수동 등록이란?
- @Component를 사용하면 @ComponentScan에 의해 자동으로 스캔되어 해당 클래스를 Bean으로 등록해 줍니다.
- 일반적으로 @Component를 사용하여 Bean을 자동으로 등록하는 것이 좋습니다.
- 프로젝트의 규모가 커질수록 등록할 Bean들이 많아지기 때문에 자동등록을 사용하면 편리합니다.
- 비즈니스 로직과 관련된 클래스들은 그 수가 많기 때문에 @Controller, @Service와 같은 애너테이션들을 사용해서 Bean으로 등록하고 관리하면 개발 생산성에 유리합니다.
그렇다면 Bean 수동 등록은 언제 사용될까요?
- 기술적인 문제나 공통적인 관심사를 처리할 때 사용하는 객체들을 수동으로 등록하는 것이 좋습니다.
- 공통 로그처리와 같은 비즈니스 로직을 지원하기 위한 부가적이고 공통적인 기능들을 기술 지원 Bean이라 부르고 수동등록 합니다.
- 비즈니스 로직 Bean 보다는 그 수가 적기 때문에 수동으로 등록하기 부담스럽지 않습니다.
- 또한 수동등록된 Bean에서 문제가 발생했을 때 해당 위치를 파악하기 쉽다는 장점이 있습니다.
🏗️ @Component vs @Configuration 한눈에 보기
- @Component는 클래스 자체를 빈으로 등록할 때 쓰고, @Configuration은 설정 클래스 안에서 메서드를 통해 수동으로 빈을 등록할 때 사용합니다.
| 구분 | @Component | @Configuration |
| 등록 방식 | 클래스 단위로 등록 | 메서드(@Bean) 단위로 등록 |
| 주요 용도 | 일반적인 비즈니스 로직 클래스 (Service, Repository 등) |
외부 라이브러리 객체나 복잡한 설정이 필요한 객체 등록 |
| 스캔 대상 | @ComponentScan에 의해 자동으로 검색됨 | @ComponentScan 대상이면서 내부 @Bean을 실행함 |
| CGLIB 프록시 | 적용되지 않음 | 적용됨 (싱글톤 보장) |
| 비유 | "내가 직접 만든 내 방 물건" | "조립 설명서(주문서)" |
- @Component: "나 빈이야! 알아서 등록해 줘!"
- 클래스 위에 이 애노테이션을 붙이면, 스프링이 "아, 이 클래스는 내가 관리해야겠구나" 하고 자동으로 객체를 생성해서 보관함에 넣습니다. 개발자가 일일이 "이거 생성해"라고 말할 필요가 없는 자동 방식입니다.
- @Configuration & @Bean: "이건 이렇게 만들어야 해!"
- 주로 내가 만든 클래스가 아니라 수정할 수 없는 외부 라이브러리를 빈으로 만들 때 사용합니다. 혹은 조건에 따라 객체를 다르게 생성해야 할 때, 메서드 안에 로직을 짜서 반환하는 수동 방식입니다.
💡 결정적인 차이: '싱글톤' 보장
- @Configuration은 내부적으로 특별한 처리를 해서, @Bean 메서드를 여러 번 호출해도 항상 똑같은 객체(싱글톤)가 반환되도록 보장합니다. 반면 일반 @Component 클래스 안에서 @Bean을 쓰면 호출할 때마다 새로운 객체가 생겨날 위험이 있어요.
- 정리하자면:
- 내가 만든 클래스를 편하게 등록하고 싶다? 👉 @Component (또는 @Service, @Controller 등)
- 외부 라이브러리를 등록하거나 정교한 설정이 필요하다? 👉 @Configuration + @Bean
Bean 수동 등록하는 방법
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- Bean으로 등록하고자 하는 객체를 반환하는 메서드를 선언하고 @Bean을 설정합니다.
- Bean을 등록하는 메서드가 속한 해당 클래스에 @Configuration을 설정합니다.
- Spring 서버가 뜰 때 Spring IoC 컨테이너에 'Bean'으로 저장됩니다.
// 1. @Bean 설정된 메서드 호출
PasswordEncoder passwordEncoder = passwordConfig.passwordEncoder();
// 2. Spring IoC 컨테이너에 빈 (passwordEncoder) 저장
// passwordEncoder -> Spring IoC 컨테이너
- 'Bean' 이름: @Bean 이 설정된 메서드명
- public PasswordEncoder passwordEncoder() {..} → passwordEncoder
Bean 등록해 보기
- 비밀번호를 암호화할 때 사용하는 PasswordEncoder의 구현체 BCryptPasswordEncoder를 Bean으로 수동등록 해보겠습니다.
package com.sparta.springauth.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() {
return new BCryptPasswordEncoder();
}
}
- 등록한 passwordEncoder ‘Bean’을 사용하여 문자열을 암호화해 보겠습니다.
package com.sparta.springauth;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
@SpringBootTest
public class PasswordEncoderTest {
@Autowired
PasswordEncoder passwordEncoder;
@Test
@DisplayName("수동 등록한 passwordEncoder를 주입 받아와 문자열 암호화")
void test1() {
String password = "Robbie's password";
// 암호화
String encodePassword = passwordEncoder.encode(password);
System.out.println("encodePassword = " + encodePassword);
String inputPassword = "Robbie";
// 해시된 비밀번호와 사용자가 입력한 비밀번호를 해싱한 값을 비교
boolean matches = passwordEncoder.matches(inputPassword, encodePassword);
System.out.println("matches = " + matches); // 암호화할 때 사용된 값과 다른 문자열과 비교했기 때문에 false
}
}
같은 타입의 Bean이 2개라면?
같은 타입 Bean 등록
Food Interface
package com.sparta.springauth.food;
public interface Food {
void eat();
}
Chicken
package com.sparta.springauth.food;
import org.springframework.stereotype.Component;
@Component
public class Chicken implements Food {
@Override
public void eat() {
System.out.println("치킨을 먹습니다.");
}
}
Pizza
package com.sparta.springauth.food;
import org.springframework.stereotype.Component;
@Component
public class Pizza implements Food {
@Override
public void eat() {
System.out.println("피자를 먹습니다.");
}
}
- Food 타입의 Bean 객체 Chicken, Pizza를 등록했습니다.
@SpringBootTest
public class BeanTest {
@Autowired
Food food;
}
- 테스트를 위해 테스트 코드를 작성했습니다.

- 이때, Food food; 필드에 @Autowired를 사용하여 Bean 객체를 주입하려고 시도합니다.
- 주입을 할 수 없다는 오류가 발생했습니다.
- 해석해 보자면 "Food 타입의 Bean 객체가 하나 이상 있습니다."라고 알려주고 있습니다.
- 즉, food 필드에 Bean을 주입해줘야 하는데 같은 타입의 Bean 객체가 하나 이상이기 때문에 어떤 Bean을 등록해줘야 할지 몰라 오류가 발생한 것입니다.
- 어떻게 하면 이를 해결할 수 있을지 확인해 보겠습니다.
등록된 Bean 이름 명시하기
@SpringBootTest
public class BeanTest {
@Autowired
Food pizza;
@Autowired
Food chicken;
}
- 이렇게 등록된 Bean 이름 pizza, chicken을 정확하게 명시해 주면 해결할 수 있습니다.
- 여기서 우리가 알 수 있는 점은 @Autowired가 기본적으로는 Bean Type(Food)으로 DI를 지원하며 연결이 되지 않을 경우 Bean Name(pizza, chicken)으로 찾는다는 것을 알 수 있습니다.
@Primary 사용하기
@Component
@Primary
public class Chicken implements Food {
@Override
public void eat() {
System.out.println("치킨을 먹습니다.");
}
}
- Chicken 클래스에 @Primary를 추가합니다.
@SpringBootTest
public class BeanTest {
@Autowired
Food food;
}
- @Primary가 추가되면 같은 타입의 Bean이 여러 개 있더라도 우선 @Primary가 설정된 Bean 객체를 주입해 줍니다.
@Qualifier 사용하기
@Component
@Qualifier("pizza")
public class Pizza implements Food {
@Override
public void eat() {
System.out.println("피자를 먹습니다.");
}
}
- Pizza 클래스에@Qualifier("pizza")를 추가합니다.
@SpringBootTest
public class BeanTest {
@Autowired
@Qualifier("pizza")
Food food;
}
- 주입하고자 하는 필드에도 @Qualifier("pizza")를 추가해 주면 해당 Bean 객체가 주입됩니다.
@SpringBootTest
public class BeanTest {
@Autowired
@Qualifier("pizza")
Food food;
@Test
@DisplayName("Primary 와 Qualifier 우선순위 확인")
void test1() {
// 현재 Chicken 은 Primary 가 적용된 상태
// Pizza는 Qualifier 가 추가된 상태입니다.
food.eat();
}
}
- 같은 타입의 Bean들에 Qualifier와 Primary가 동시에 적용되어 있다면 Qualifier의 우선순위가 더 높습니다.
- 하지만 Qualifier는 적용하기 위해서 주입받고자 하는 곳에 해당 Qualifier를 반드시 추가해야 합니다.
- 따라서 같은 타입의 Bean이 여러 개 있을 때는 범용적으로 사용되는 Bean 객체에는 Primary를 설정하고 지엽적으로 사용되는 Bean 객체에는 Qualifier를 사용하는 것이 좋습니다.
인증과 인가

- 인증(Authentication)
- 인증은 해당 유저가 실제 유저인지 인증하는 개념입니다.
- 여러분의 스마트폰에 지문인식, 이용하는 사이트에 로그인 등과 같이, 실제 그 유저가 맞는지를 확인하는 절차입니다.
- 인가(Authorization)
- 인가는 해당 유저가 특정 리소스에 접근이 가능한지 허가를 확인하는 개념입니다. 예를 들어 관리자 페이지-관리자 권한 같은 것들을 들 수 있습니다.
📌 우리가 인증과 인가를 헷갈려하는 이유는 로그인만 생각해서입니다. 우리가 자주 하는 로그인은 인증을 할 때(비밀번호 입력하고 제출할 때)이고 회원/비회원 여부에 따라 다른 권한을 받는 것이 인가입니다.
“웹 애플리케이션 인증”은 어떠한 특수성이 있을까?

- 일반적으로 서버-클라이언트 구조로 되어있고, 실제로 이 두 가지 요소는 아주 멀리 떨어져 있습니다.
- 그리고 Http라는 프로토콜을 이용하여 통신하는데, 그 통신은 비연결성(Connectionless) 무상태(Stateless)로 이루어집니다.
👉 비연결성(Connectionless)은 서버와 클라이언트가 연결되어 있지 않다는 것입니다. 채팅이나 게임 같은 것들을 하지 않는 이상 서버와 클라이언트는 실제로 연결되어 있지 않습니다. 그 이유는 리소스를 절약하기 위해서 인데, 만약 서버와 클라이언트가 실제로 계속 연결되어 있다면 클라이언트는 그렇다고 쳐도, 서버의 비용이 기하급수적으로 늘어나기 때문입니다. 그래서 서버는 실제로 하나의 요청에 하나의 응답을 내버리고 연결을 끊어버리고 있다고 생각하시면 좋습니다.
👉 무상태(Stateless)는 서버가 클라이언트의 상태를 저장하지 않는다는 것입니다. 기존의 상태를 저장하는 것들도 마찬가지로 서버의 비용과 부담을 증가시키는 것 이기 때문에 기존의 상태가 없다고 가정하는 프로토콜을 이용해 구현되어 있습니다. 실제로 서버는 클라이언트가 직전에, 혹은 그전에 어떠한 요청을 보냈는지 관심도 없고 전혀 알지 못합니다.
- 어떻게 비연결성, 무상태 프로토콜에서 “유저가 인증되었다”라는 정보를 유지시켜야 한다는 과제를 어떻게 해결했는지 관점에서 공부하기!
인증의 방식
📌 일반적으로 웹 애플리케이션은 아래 두 가지 방법을 통해서 인증을 처리합니다.
쿠키-세션 방식의 인증

📌 쿠키-세션 방식은 서버가 ‘특정 유저가 로그인되었다’는 상태를 저장하는 방식입니다.
- 인증과 관련된 아주 약간의 정보만 서버가 가지고 있게 되고 유저의 이전 상태의 전부는 아니더라도 인증과 관련된 최소한의 정보는 저장해서 로그인을 유지시킨다는 개념입니다.
- 아래는 그림의 각 번호에 따른 설명입니다
- 사용자가 로그인 요청을 보냅니다.
- 서버는 DB의 유저 테이블을 뒤져서 아이디 비밀번호를 대조해 봐야겠죠?
- 실제 유저 테이블의 정보와 일치한다면 인증을 통과한 것으로 보고 “세션 저장소”에 해당 유저가 로그인되었다는 정보를 넣습니다.
- 세션 저장소에서는 유저의 정보와는 관련 없는 난수인 session-id를 발급합니다.
- 서버는 로그인 요청의 응답으로 session-id를 내어줍니다.
- 클라이언트는 그 session-id를 쿠키라는 저장소에 보관하고 앞으로의 요청마다 세션아이디를 같이 보냅니다. (주로 HTTP header에 담아서 보냅니다!)
- 클라이언트의 요청에서 쿠키를 발견했다면 서버는 세션 저장소에서 쿠키를 검증합니다.
- 만약 유저정보를 받아왔다면 이 사용자는 로그인이 되어있는 사용자겠죠?
- 이후에는 로그인된 유저에 따른 응답을 내어줍니다.
JWT 기반 인증

📌 JWT(JSON Web Token)란 인증에 필요한 정보들을 암호화시킨 토큰을 의미합니다. JWT 기반 인증은 쿠키/세션 방식과 유사하게 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별합니다.
- 아래는 그림의 각 번호에 따른 설명입니다
- 사용자가 로그인 요청을 보냅니다.
- 서버는 DB의 유저 테이블을 뒤져서 아이디 비밀번호를 대조해 봐야겠죠?
- 실제 유저테이블의 정보와 일치한다면 인증을 통과한 것으로 보고 유저의 정보를 JWT로 암호화해서 내보냅니다.
- 서버는 로그인 요청의 응답으로 jwt 토큰을 내어줍니다.
- 클라이언트는 그 토큰을 저장소에 보관하고 앞으로의 요청마다 토큰을 같이 보냅니다.
- 클라이언트의 요청에서 토큰을 발견했다면 서버는 토큰을 검증합니다.
- 이후에는 로그인된 유저에 따른 응답을 내어줍니다.
쿠키와 세션
👉 쿠키와 세션 모두 HTTP에 상태 정보를 유지(Stateful) 하기 위해 사용됩니다. 즉, 쿠키와 세션을 통해 서버에서는 클라이언트 별로 인증 및 인가를 할 수 있게 됩니다.
📌 쿠키
- 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일입니다.
- 클라이언트인 웹 브라우저에 저장된 '쿠키'를 확인해 보죠.
- 크롬 브라우저 기준으로 '개발자도구'를 열어 보세요.
- Application - Storage - Cookies에 도메인 별로 저장되어 있는 게 확인됩니다.

- 구성요소
- Name (이름): 쿠키를 구별하는 데 사용되는 키 (중복될 수 없음)
- Value (값): 쿠키의 값
- Domain (도메인): 쿠키가 저장된 도메인
- Path (경로): 쿠키가 사용되는 경로
- Expires (만료기한): 쿠키의 만료기한 (만료기한 지나면 삭제됩니다.)
📌 세션
- 서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용됩니다.
- 서버에서 클라이언트 별로 유일무이한 '세션 ID'를 부여한 후 클라이언트 별 필요한 정보를 서버에 저장합니다.
- 서버에서 생성한 '세션 ID'는 클라이언트의 쿠키값('세션 쿠키'라고 부름)으로 저장되어 클라이언트 식별에 사용됩니다.
- 세션 동작 방식

위 그림에서와 같이 서버는 세션 ID를 사용하여 세션을 유지합니다.
- 클라이언트가 서버에 1번 요청
- 서버가 세션 ID를 생성하고, 쿠키에 담아 응답 헤더에 전달
- 세션 ID 형태: "SESSIONID = 12A345"
- 클라이언트가 쿠키에 세션 ID를 저장 ('세션쿠키')
- 클라이언트가 서버에 2번 요청
- 쿠키값 (세션 ID) 포함하여 요청
- 서버가 세션 ID를 확인하고, 1번 요청과 같은 클라이언트임을 인지
쿠키와 세션 비교
| 구분 | 쿠키 (Cookie) | 세션 (Session) |
| 설명 | 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일 | 서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용 |
| 저장 위치 | 클라이언트 (웹 브라우저) | 웹 서버 |
| 사용 예 | 사이트 팝업의 “오늘 다시보지 않기” 정보 저장 | 로그인 정보 저장 |
| 만료 시점 | 쿠키 저장 시 만료일 설정 가능 (브라우저 종료 시도 유지 가능) |
다음 조건 중 하나 만족 시 만료 1. 브라우저 종료 시까지 2. 클라이언트 로그아웃 시까지 3. 서버에 설정한 유지기간까지 해당 클라이언트 요청이 없는 경우 |
| 용량 제한 | 브라우저별로 다름 (크롬 기준) - 하나의 도메인 당 180개 - 하나의 쿠키 당 4KB(=4096byte) |
개수 제한 없음 (단, 세션 저장소 크기 이상 저장 불가) |
| 보안 | 취약 (클라이언트에서 쉽게 변경/삭제/가로채기 가능) | 비교적 안전 (서버에 저장되기 때문에 상대적으로 안전) |
쿠키 다루기
쿠키 생성
public static void addCookie(String cookieValue, HttpServletResponse res) {
try {
cookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, cookieValue); // Name-Value
cookie.setPath("/");
cookie.setMaxAge(30 * 60);
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e.getMessage());
}
}
- new Cookie(AUTHORIZATION_HEADER, cookieValue);
- Cookie에 저장될 Name과 Value를 생성자로 받는 Cookie 객체를 생성합니다.
- setPath("/"), setMaxAge(30 * 60)
- Path와 만료시간을 지정합니다.
- HttpServletResponse 객체에 생성한 Cookie 객체를 추가하여 브라우저로 반환합니다.
- 이렇게 반환된 Cookie는 브라우저의 Cookie 저장소에 저장됩니다.
- Cookie 생성은 범용적으로 사용될 수 있기 때문에 static 메서드로 선언합니다.
쿠키 읽기
@GetMapping("/get-cookie")
public String getCookie(@CookieValue(AUTHORIZATION_HEADER) String value) {
System.out.println("value = " + value);
return "getCookie : " + value;
}
- @CookieValue("Cookie의 Name")
- Cookie의 Name 정보를 전달해 주면 해당 정보를 토대로 Cookie의 Value를 가져옵니다.
세션 다루기
- Servlet에서는 유일무이한 '세션 ID'를 간편하게 만들 수 있는 HttpSession을 제공해 줍니다.
HttpSession 생성
@GetMapping("/create-session")
public String createSession(HttpServletRequest req) {
// 세션이 존재할 경우 세션 반환, 없을 경우 새로운 세션을 생성한 후 반환
HttpSession session = req.getSession(true);
// 세션에 저장될 정보 Name - Value 를 추가합니다.
session.setAttribute(AUTHORIZATION_HEADER, "Robbie Auth");
return "createSession";
}
- HttpServletRequest를 사용하여 세션을 생성 및 반환할 수 있습니다.
- req.getSession(true)
- 세션이 존재할 경우 세션을 반환하고 없을 경우 새로운 세션을 생성합니다.
- 세션에 저장할 정보를 Name-Value 형식으로 추가합니다.
- 반환된 세션은 브라우저 Cookie 저장소에 ‘JSESSIONID’라는 Name으로 Value에 저장됩니다.
HttpSession 읽기
@GetMapping("/get-session")
public String getSession(HttpServletRequest req) {
// 세션이 존재할 경우 세션 반환, 없을 경우 null 반환
HttpSession session = req.getSession(false);
String value = (String) session.getAttribute(AUTHORIZATION_HEADER); // 가져온 세션에 저장된 Value 를 Name 을 사용하여 가져옵니다.
System.out.println("value = " + value);
return "getSession : " + value;
}
- req.getSession(false)
- 세션이 존재할 경우 세션을 반환하고 없을 경우 null을 반환합니다.
- session.getAttribute(”세션에 저장된 정보 Name”)
- Name을 사용하여 세션에 저장된 Value를 가져옵니다.
JWT

🍪 JWT(Json Web Token)란 JSON 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token입니다. 즉, 토큰의 한 종류라고 생각하시면 됩니다. 일반적으로 쿠키 저장소를 사용하여 JWT를 저장합니다.
JWT 사용 이유
1) 서버가 1대인 경우

- Session1 이 모든 Client의 로그인 정보를 소유하고 있습니다.
2) 서버가 2대 이상인 경우
- 서버의 대용량 트래픽 처리를 위해 서버 2대 이상 운영이 필요할 수 있습니다.

- Session 마다 다른 Client 로그인 정보를 가지고 있을 수 있습니다.
- Session1: Client1, Client2, Client3
- Session2: Client4
- Session3: Client5, Client6
- 만약 Client 1의 로그인 정보를 가지고 있지 않은 Sever2 나 Server3에 API 요청을 하게 되면 문제가 발생하지 않을까?
- 해결방법
- Sticky Session: Client 마다 요청 Server 고정합니다.
- 세션 저장소 생성하여 모든 세션을 저장합니다.
- 해결방법
3) 세션 저장소 생성

- Session storage가 모든 Client의 로그인 정보 소유하고 있기 때문에 모든 서버에서 모든 Client의 API 요청을 처리할 수 있습니다.
4) JWT 사용
- 로그인 정보를 Server에 저장하지 않고, Client에 로그인 정보를 JWT로 암호화하여 저장 → JWT 통해 인증/인가

- 모든 서버에서 동일한 Secret Key 소유합니다.
- Secret Key 통한 암호화 / 위조 검증 (복호화 시)

- JWT 장/단점
- 장점
- 동시 접속자가 많을 때 서버 측 부하 낮춤
- Client, Sever 가 다른 도메인을 사용할 때
- 예) 카카오 OAuth2 로그인 시 JWT Token 사용
- 단점
- 구현의 복잡도 증가
- JWT에 담는 내용이 커질수록 네트워크 비용 증가 (클라이언트 → 서버)
- 기 생성된 JWT를 일부만 만료시킬 방법이 없음
- Secret key 유출 시 JWT 조작 가능
- 장점
JWT 사용 흐름
1) Client가 username, password로 로그인 성공 시
- 서버에서 "로그인 정보" → JWT로 암호화 (Secret Key 사용)

- 서버에서 직접 쿠키를 생성해 JWT를 담아 Client 응답에 전달
- JWT 전달방법은 개발자가 정함
- 응답 Header에 아래 형태로 JWT 전달
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");
// Response 객체에 Cookie 추가
res.addCookie(cookie);

- 브라우저 쿠키 저장소에 자동으로 JWT 저장됨

2) Client에서 JWT 통해 인증방법
- 서버에서 API 요청 시마다 쿠키에 포함된 JWT를 찾아서 사용
쿠키를 찾는 코드
// HttpServletRequest 에서 Cookie Value : JWT 가져오기
public String getTokenFromRequest(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if(cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
try {
return URLDecoder.decode(cookie.getValue(), "UTF-8"); // Encode 되어 넘어간 Value 다시 Decode
} catch (UnsupportedEncodingException e) {
return null;
}
}
}
}
return null;
}
- 쿠키에 담긴 정보가 여러 개일 수 있기 때문에 그중 이름이 JWT가 담긴 쿠키의 이름과 동일한지 확인하여 JWT를 가져옵니다.
- Server
- Client가 전달한 JWT 위조 여부 검증 (Secret Key 사용)
- JWT 유효기간이 지나지 않았는지 검증
- 검증 성공 시,
- JWT → 에서 사용자 정보를 가져와 확인
- ex) GET /api/products : JWT 보낸 사용자의 관심상품 목록 조회
JWT 구조
- JWT는 누구나 평문으로 복호화 가능합니다.
- 하지만 Secret Key가 없으면 JWT 수정 불가능합니다.
- → 결국 JWT는 Read only 데이터입니다.
JSON Web Tokens - jwt.io
JSON Web Token (JWT) is a compact URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is digitally signed using JSON Web Signature (JWS).
www.jwt.io

- 1️⃣ Header
{
"alg": "HS256",
"typ": "JWT"
}
- 2️⃣ Payload
{
"sub": "1234567890",
"username": "카즈하",
"admin": true
}
- 3️⃣ Signature
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
- Payload에 실제 유저의 정보가 들어있고, HEADER와 VERIFY SIGNATURE 부분은 암호화 관련된 정보 양식이라고 생각하시면 됩니다.
JWT 다루기
JwtUtil 만들기
📌 Util 클래스란 특정 매개 변수(파라미터)에 대한 작업을 수행하는 메서드들이 존재하는 클래스를 뜻합니다.
쉽게 설명하자면 다른 객체에 의존하지 않고 하나의 모듈로서 동작하는 클래스라고 생각하시면 좋습니다.
- JWT 관련 기능들을 가진 JwtUtil이라는 클래스를 만들어 JWT 관련 기능을 수행시킬 예정입니다.
- <JWT 관련 기능>
- JWT 생성
- 생성된 JWT를 Cookie에 저장
- Cookie에 들어있던 JWT 토큰을 Substring
- JWT 검증
- JWT에서 사용자 정보 가져오기
토큰 생성에 필요한 데이터
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
- Base64로 Encode 된 Secret Key를 properties에 작성해 두고 @Value를 통해 가져옵니다.
- JWT를 생성할 때 가져온 Secret Key로 암호화합니다.
- 이때 Encode 된 Secret Key를 Decode 해서 사용합니다.
- Key는 Decode 된 Secret Key를 담는 객체입니다.
- @PostConstruct는 딱 한 번만 받아오면 되는 값을 사용할 때마다 요청을 새로 호출하는 실수를 방지하기 위해 사용됩니다. JwtUtil 클래스의 생성자 호출 이후에 실행되어 Key 필드에 값을 주입해 줍니다.
- 암호화 알고리즘은 HS256 알고리즘을 사용합니다.
- Bearer란 JWT 혹은 OAuth에 대한 토큰을 사용한다는 표시입니다.
- 로깅이란 애플리케이션이 동작하는 동안 프로젝트의 상태나 동작 정보를 시간순으로 기록하는 것을 의미합니다.
- Logback 로깅 프레임워크를 사용해서 로깅을 진행하도록 하겠습니다.
📌 사용자의 권한의 종류를 Enum을 사용해서 관리합니다.
- JWT를 생성할 때 사용자의 정보로 해당 사용자의 권한을 넣어줄 때 사용합니다.
public enum UserRoleEnum {
USER(Authority.USER), // 사용자 권한
ADMIN(Authority.ADMIN); // 관리자 권한
private final String authority;
UserRoleEnum(String authority) {
this.authority = authority;
}
public String getAuthority() {
return this.authority;
}
public static class Authority {
public static final String USER = "ROLE_USER";
public static final String ADMIN = "ROLE_ADMIN";
}
}
1.JWT 생성
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
- JWT의 subject에 사용자의 식별값 즉, ID를 넣습니다.
- JWT에 사용자의 권한 정보를 넣습니다. key-value 형식으로 key 값을 통해 확인할 수 있습니다.
- 토큰 만료시간을 넣습니다. ms 기준입니다.
- issuedAt에 발급일을 넣습니다.
- signWith에 secretKey 값을 담고 있는 key와 암호화 알고리즘을 값을 넣어줍니다.
- ket와 암호화 알고리즘을 사용하여 JWT를 암호화합니다
2.JWT Cookie에 저장
// JWT Cookie 에 저장
public void addJwtToCookie(String token, HttpServletResponse res) {
try {
token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
logger.error(e.getMessage());
}
}
3. 받아온 Cookie의 Value인 JWT 토큰 substring
// JWT 토큰 substring
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
- StringUtils.hasText를 사용하여 공백, null을 확인하고 startsWith을 사용하여 토큰의 시작값이 Bearer이 맞는지 확인합니다.
- 맞다면 순수 JWT를 반환하기 위해 substring을 사용하여 Bearer을 잘라냅니다.
4.JWT 검증
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
logger.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
- Jwts.parserBuilder()를 사용하여 JWT를 파싱 할 수 있습니다.
- JWT가 위변조 되지 않았는지 secretKey(key) 값을 넣어 확인합니다.
5.JWT에서 사용자 정보 가져오기
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
- JWT의 구조 중 Payload 부분에는 토큰에 담긴 정보가 들어있습니다.
- 여기에 담긴 정보의 한 ‘조각’을 클레임(claim)이라고 부르고, 이는 key-value의 한 쌍으로 이뤄져 있습니다. 토큰에는 여러 개의 클레임들을 넣을 수 있습니다.
- Jwts.parserBuilder()와 secretKey를 사용하여 JWT의 Claims를 가져와 담겨 있는 사용자의 정보를 사용합니다.
JwtUtil 완성
package com.sparta.springauth.jwt;
import com.sparta.springauth.entity.UserRoleEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
@Component
public class JwtUtil {
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
// JWT Cookie 에 저장
public void addJwtToCookie(String token, HttpServletResponse res) {
try {
token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
logger.error(e.getMessage());
}
}
// JWT 토큰 substring
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
logger.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
JWT 테스트
@GetMapping("/create-jwt")
public String createJwt(HttpServletResponse res) {
// Jwt 생성
String token = jwtUtil.createToken("Robbie", UserRoleEnum.USER);
// Jwt 쿠키 저장
jwtUtil.addJwtToCookie(token, res);
return "createJwt : " + token;
}
@GetMapping("/get-jwt")
public String getJwt(@CookieValue(JwtUtil.AUTHORIZATION_HEADER) String tokenValue) {
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if(!jwtUtil.validateToken(token)){
throw new IllegalArgumentException("Token Error");
}
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
// 사용자 username
String username = info.getSubject();
System.out.println("username = " + username);
// 사용자 권한
String authority = (String) info.get(JwtUtil.AUTHORIZATION_KEY);
System.out.println("authority = " + authority);
return "getJwt : " + username + ", " + authority;
}
회원가입 구현
회원 DB에 매핑되는 @Entity 클래스 구현
- @Enumerated(value = EnumType.STRING)
- EnumType을 DB 컬럼에 저장할 때 사용하는 애너테이션입니다.
- EnumType.STRING 옵션을 사용하면 Enum의 이름을 DB에 그대로 저장합니다.
- USER(Authority.USER) → USER
관리자 회원 가입 인가 방법
- '관리자 가입 토큰' 입력 필요
- 랜덤하게 생성된 토큰 사용: "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC"
- 💡 잠깐! 실제로 '관리자' 권한을 이렇게 엉성하게 부여해 주는 경우는 드뭅니다. 해커가 해당 암호를 갈취하게 되면, 관리자 권한을 너무 쉽게 획득할 수 있게 되겠죠?
- 보통 현업에서는
- '관리자' 권한을 부여할 수 있는 관리자 페이지 구현
- 승인자에 의한 결재 과정 구현 → 관리자 권한 부여
- 보통 현업에서는
패스워드 암호화 이해
🔥 회원 등록 시 '비밀번호'는 사용자가 입력한 문자 그대로 DB에 등록하면 안 됩니다. '정보통신망법, 개인정보보호법'에 의해 비밀번호 암호화(Encryption)가 의무입니다.
예를 들어보겠습니다.
- 앨리스가 여러분의 사이트에 회원가입을 하며 아이디, 패스워드를 입력하였습니다.
- 아이디: alice
- 패스워드: nobodynobody

- 아무도 알 수 없기를 바라며 적은 패스워드를 아래와 같이 DB에 평문 그대로 저장해 두었다고 해보겠습니다.
- 만약 해커에 의해 회원정보가 갈취당한다면 앨리스의 패스워드는 모두가 알게 됩니다.
- 꼭 해커뿐만 아니겠죠. DB 조회가 가능한 내부 관계자들도 앨리스의 패스워드를 보자마자 영원히 기억해 버릴지도 모릅니다.
- 그래서 아래와 같이 암호화 후 패스워드 저장이 필요합니다.
- 평문 → (암호화 알고리즘) → 암호문
- "nobodynobody" → "$2a$10$.."

- 만약 해커가 DB에 있는 앨리스의 패스워드 정보를 갈취하더라도 실제 암호를 알 수 없습니다. 그래서 복호화가 불가능한 '단방향' 암호 알고리즘 사용이 필요합니다
양방향 ↔ 단방향
- 양방향 암호 알고리즘
- 암호화: 평문 → (암호화 알고리즘) → 암호문
- 복호화: 암호문 → (암호화 알고리즘) → 평문
- 단방향 암호 알고리즘
- 암호화: 평문 → (암호화 알고리즘) → 암호문
- 복호화: 불가 (
암호문 → (암호화 알고리즘) → 평문)
- Password 확인절차
- 사용자가 로그인을 위해 "아이디, 패스워드 (평문)" 입력 → 서버에 로그인 요청
- 서버에서 패스워드 (평문)을 암호화
- 평문 → (암호화 알고리즘) → 암호문
- DB에 저장된 "아이디, 패스워드 (암호문)"와 일치 여부 확인
- 사용자가 로그인을 위해 "아이디, 패스워드 (평문)" 입력 → 서버에 로그인 요청
Password Matching
🔥 Spring Security라는 프레임워크에서 제공하는 비밀번호 암호화 기능을 사용해 보겠습니다. 일전에 Bean 수동등록 예제로 봤던 PasswordEncoder가 해당 Security에서 제공하는 비밀번호 암호화 메서드입니다. 사용자가 입력한 비밀번호를 암호화되어 저장된 비밀번호와 비교하여 일치여부를 확인해 주는 기능도 가지고 있어 많이 사용됩니다.
// 사용예시
// 비밀번호 확인
if(!passwordEncoder.matches("사용자가 입력한 비밀번호", "저장된 비밀번호")) {
throw new IllegalAccessError("비밀번호가 일치하지 않습니다.");
}
- boolean matches(CharSequence rawPassword, String encodedPassword);
- rawPassword : 사용자가 입력한 비밀번호
- encodedPassword : 암호화되어 DB에 저장된 비밀번호
필터

- Filter란 Web 애플리케이션에서 관리되는 영역으로 Client로 터 오는 요청과 응답에 대해 최초/최종 단계의 위치이며 이를 통해 요청과 응답의 정보를 변경하거나 부가적인 기능을 추가할 수 있습니다.
- 주로 범용적으로 처리해야 하는 작업들, 예를 들어 로깅 및 보안 처리에 활용합니다.
- 또한 인증, 인가와 관련된 로직들을 처리할 수도 있습니다.
- Filter를 사용하면 인증, 인가와 관련된 로직을 비즈니스 로직과 분리하여 관리할 수 있다는 장점이 있습니다.
🚪결론적으로 필터는:"개별 컨트롤러들이 일일이 신분증 검사를 하지 않게, 건물 입구에서 미리 검사해서 통과된 사람에게만 이름표를 달아주는 시스템"을 만들기 위해 사용합니다!
➡️ "이 요청이 우리 서비스에 들어올 자격이 있는가?"를 따지는 '필터링'의 개념
- 필터는 스프링(Spring)이라는 프레임워크가 시작되기도 전, 즉 웹 서버(Tomcat) 수준에서 동작하는 가장 바깥쪽 문입니다.
- 특징: 스프링의 구체적인 내용(어떤 컨트롤러가 있는지 등)을 잘 모릅니다.
- 역할: "수상한 사람인가?(보안)", "우리나라 말을 쓰는가?(인코딩)"처럼 아주 기본적이고 공통적인 검사만 하고 들여보냅니다.
- 비유: 성문 앞에서 "신분증 없으면 아예 성안으로 발도 못 붙이게" 막는 병사와 같습니다.
- 그래서 보통 로그인 여부 확인(Spring Security 등)은 가장 앞단인 필터에서 처리해서 서버 부하를 줄이고, 실제 데이터 처리는 디스패처 서블릿을 거쳐 컨트롤러로 가는 구조를 사용한답니다!
- 관심사 분리 (Clean Code): "보안은 보안 전문가(필터)가, 요리는 요리사(컨트롤러)가!" → 코드가 깔끔해지고 유지보수가 쉬워짐.
- 부하 절감 (Efficiency): "잘못된 손님은 주방 근처에도 못 오게 입구에서 쫓아내자!" → 서버의 자원(CPU, DB 연결 등)을 아낌.
Filter Chain

- Filter는 한 개만 존재하는 것이 아니라 이렇게 여러 개가 Chain 형식으로 묶여서 처리될 수 있습니다.
⛓️ 필터 체인(Filter Chain): "연쇄 검문 시스템"
➡️ 성(Application)에 들어가기 위해 통과해야 하는 검문소가 여러 개 있고, 이들이 사슬(Chain)처럼 연결되어 있는 구조를 말합니다
- 필터들은 서로의 존재를 자세히 알 필요가 없습니다. 그저 내 앞에 온 손님을 검사하고, 통과면 다음 고리(Next Link)로 던져주기만 하면 되거든요. 이 연결 구조가 사슬과 같아서 Chain이라고 부르는 겁니다.
1) 왜 굳이 여러 개로 나누나요? (관심사의 세분화)
- 필터 하나가 인코딩도 하고, 보안도 보고, 로그도 남기면 코드가 너무 복잡해지겠죠? 그래서 '한 놈은 한 놈만 팬다'는 원칙으로 기능을 나눕니다.
- 1번 필터 (인코딩 필터): "너 한국말(UTF-8) 쓰니? 아니면 제대로 바꿔!"
- 2번 필터 (로깅 필터): "누가 언제 들어왔는지 장부에 기록 남겨!"
- 3번 필터 (보안 필터): "너 신분증(토큰) 유효해? 수상한 짓 안 해?"
2) 어떻게 작동하나요? (바톤 터치)
- 체인의 핵심은 '다음 사람에게 넘겨주기'입니다. 자바 코드에서는 chain.doFilter(request, response)라는 메서드를 사용해요.
- Request가 들어오면 1번 필터가 자기 할 일을 합니다.
- 이상이 없으면 chain.doFilter()를 호출해서 2번 필터에게 "자, 다음!" 하고 넘깁니다.
- 마지막 필터까지 통과해야 드디어 디스패처 서블릿(진짜 성 안)에 도착합니다.
- 응답(Response)이 나갈 때는 역순으로 필터들을 거쳐서 나갑니다.
🚩 요약하자면:
- 필터 체인은 여러 개의 필터가 정해진 순서대로 늘어서 있는 것.
- 각 필터는 자기 맡은 일만 하고 doFilter()로 다음 필터에 바톤을 넘김.
- 순서가 매우 중요함! (예: 인코딩을 먼저 해야 글자가 안 깨진 상태로 보안 검사를 하겠죠?)
Filter 적용
- 📌 요청 URL의 인가 처리 및 인증 처리를 진행할 수 있는 Filter를 구현해 보겠습니다. 추가로 요청 URL을 로깅해 주는 Filter도 구현해 보겠습니다.
Request URL Logging
@Slf4j(topic = "LoggingFilter")
@Component
@Order(1)
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 전처리
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
log.info(url);
chain.doFilter(request, response); // 다음 Filter 로 이동
// 후처리
log.info("비즈니스 로직 완료");
}
}
- @Order(1)로 필터의 순서를 지정합니다.
- chain.doFilter(request, response); 다음 Filter로 이동시킵니다.
- log.info("비즈니스 로직 완료");
- 작업이 다 완료된 후 Client에 응답 전 로그가 작성된 것을 확인할 수 있습니다.
AuthFilter : 인증 및 인가 처리 필터
@Slf4j(topic = "AuthFilter")
@Component
@Order(2)
public class AuthFilter implements Filter {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
public AuthFilter(UserRepository userRepository, JwtUtil jwtUtil) {
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
if (StringUtils.hasText(url) &&
(url.startsWith("/api/user") || url.startsWith("/css") || url.startsWith("/js"))
) {
// 회원가입, 로그인 관련 API 는 인증 필요없이 요청 진행
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
// 나머지 API 요청은 인증 처리 진행
// 토큰 확인
String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest);
if (StringUtils.hasText(tokenValue)) { // 토큰이 존재하면 검증 시작
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if (!jwtUtil.validateToken(token)) {
throw new IllegalArgumentException("Token Error");
}
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
User user = userRepository.findByUsername(info.getSubject()).orElseThrow(() ->
new NullPointerException("Not Found User")
);
request.setAttribute("user", user);
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
throw new IllegalArgumentException("Not Found Token");
}
}
}
}
- httpServletRequest.getRequestURI() 요청 URL을 가져와서 구분합니다. (인가)
- "/api/user", "/css", "/js"로 시작하는 URL은 인증 처리에서 제외시킵니다.
- 그 외 URL은 인증 처리를 진행합니다.
- jwtUtil.getTokenFromRequest(httpServletRequest);
- httpServletRequest에서 Cookie 목록을 가져와 JWT가 저장된 Cookie를 찾습니다.
- getTokenFromRequest 메서드를 JwtUtil에 구현합니다.
- jwtUtil.getTokenFromRequest(httpServletRequest);
// HttpServletRequest 에서 Cookie Value : JWT 가져오기
public String getTokenFromRequest(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if(cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
try {
return URLDecoder.decode(cookie.getValue(), "UTF-8"); // Encode 되어 넘어간 Value 다시 Decode
} catch (UnsupportedEncodingException e) {
return null;
}
}
}
}
return null;
}
- tokenValue가 존재하면 토큰 파싱, 검증을 진행하고 사용자 정보를 가져옵니다.
- 가져온 사용자 username을 사용해서 DB에 사용자가 존재하는지 확인하고 존재하면 인증이 완료된 것입니다.
- 사용자 정보가 필요한 Controller API에 인증완료된 User 객체를 전달해 줍니다.
@Controller
@RequestMapping("/api")
public class ProductController {
@GetMapping("/products")
public String getProducts(HttpServletRequest req) {
System.out.println("ProductController.getProducts : 인증 완료");
User user = (User) req.getAttribute("user");
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
}
- 사용자 본인이 등록한 제품만 조회하는 기능의 API라 가정해 보겠습니다.
- Filter에서 인증 처리되어 넘어온 User 객체를 사용하면 API 요청을 한 해당 사용자가 등록한 제품만 조회할 수 있습니다.
📦 [요약] 핵심 원리: "하나의 요청, 하나의 바구니"
- 가장 중요한 포인트는 필터(Filter)가 받는 request 객체와 컨트롤러(Controller)가 받는 req 객체가 "완전히 같은 녀석"이라는 점입니다.
1단계: 필터에서 데이터 담기 (setAttribute)
- AuthFilter 코드를 보면 이 부분이 핵심입니다.
request.setAttribute("user", user); // "user"라는 이름표를 붙여서 user 객체를 바구니에 담음
chain.doFilter(request, response); // 그 바구니를 들고 다음 단계로 이동!
- 비유: 성문 지기(필터)가 들어오는 손님(Request)의 손에 '인증 완료'라는 봉투(user)를 들려주는 것과 같습니다.
- request는 일종의 공용 저장소(바구니) 역할을 합니다.
2단계: 바구니 전달하기 (chain.doFilter)
- chain.doFilter(request, response)를 호출하는 순간, 내가 방금 user를 담아둔 그 바구니 그대로 다음 필터나 디스패처 서블릿으로 전달됩니다.
- 새로운 바구니를 만드는 게 아니라, 쓰던 바구니를 옆 사람에게 패스하는 거예요.
3단계: 컨트롤러에서 꺼내기 (getAttribute)
public String getProducts(HttpServletRequest req) {
User user = (User) req.getAttribute("user"); // 바구니에서 "user" 이름표가 붙은 내용물을 꺼냄
}
- 결과: 필터에서 담았던 그 user 객체가 컨트롤러에서 그대로 튀어나오게 됩니다!
📝 요약
- Q. 필터에서 검증한 유저 정보가 어떻게 컨트롤러까지 전달되나요?
- 동일 객체 공유: 하나의 HTTP 요청이 들어와서 응답이 나갈 때까지, HttpServletRequest 객체는 단 하나만 생성되어 필터와 컨트롤러 사이를 돌아다닙니다. (같은 메모리 주소를 공유함)
- Request Attribute 활용: 필터는 request.setAttribute("키", 값)를 통해 요청 객체의 임시 저장소에 데이터를 저장할 수 있습니다.
- 데이터의 수명: 이 데이터는 해당 요청이 끝나면 자동으로 사라집니다. (이것을 'Request Scope'라고 부릅니다.)
- 결론: 필터가 request라는 바구니에 물건을 담고 다음 단계로 넘기면(chain.doFilter), 컨트롤러는 그 바구니를 받아서 물건을 꺼내 쓰기만 하면 되는 구조입니다.
- 마지막 필터 이후의 행방
- 마지막 필터의 chain.doFilter()가 호출되면 제어권이 스프링(DispatcherServlet)으로 넘어간다. 이때부터 비로소 우리가 짠 컨트롤러 로직이 실행될 준비를 한다.
- Request Scope의 범위 (수명)
- 시작: 웹 서버가 요청을 받는 순간
- 끝: 웹 서버가 응답을 완료하는 순간
- 범위: 모든 Filter, DispatcherServlet, Interceptor, Controller를 관통하는 하나의 거대한 생명주기다.
- 따라서 필터에서 담은 데이터는 컨트롤러뿐만 아니라, 응답이 나가는 길에 있는 필터에서도 다시 꺼내 볼 수 있다!
'Spring Security' 프레임워크
👉 'Spring Security' 프레임워크는 Spring 서버에 필요한 인증 및 인가를 위해 많은 기능을 제공해 줌으로써 개발의 수고를 덜어 줍니다. 마치 'Spring' 프레임워크가 웹 서버 구현에 편의를 제공해 주는 것과 같습니다.
⚠️ AuthFilter (인증/인가 필터)
- 안 써도 됩니다(대체됩니다). 시큐리티를 쓰면 JwtAuthenticationFilter 같은 시큐리티 전용 필터를 만들거나 설정하게 됩니다. 직접 HttpServletRequest에서 쿠키를 꺼내고 request.setAttribute를 하는 방식 대신, 시큐리티가 제공하는 SecurityContextHolder라는 표준 저장소에 유저 정보를 담게 됩니다.
🛡️ CSRF (사이트 간 요청 위조)
- 1. CSRF 공격이란? (개념)
- 정의: 사용자가 로그인된 상태(브라우저에 쿠키가 남은 상태)를 이용해, 공격자가 사용자 의도와 상관없이 보안이 중요한 요청(비밀번호 변경, 송금 등)을 서버에 보내게 만드는 공격.
- 핵심: 브라우저가 요청을 보낼 때 쿠키를 자동으로 실어 보낸다는 점을 악용함.
- 2. 왜 REST API에서는 disable() 처리를 하나요?
| 구분 | 전통적인 웹 (Session/Cookie) |
REST API (JWT/Token)
|
| 인증 방식 | 브라우저 쿠키에 세션 ID 저장 |
Authorization 헤더에 JWT 저장
|
| 위험성 | 브라우저가 쿠키를 자동으로 전송 (위험) |
헤더는 스크립트로 직접 넣어야 함 (안전)
|
| CSRF 보호 | 필수 (CSRF 토큰 검사 필요) |
불필요 (보통 비활성화)
|
- 결론: 우리는 쿠키가 아니라 JWT 토큰을 헤더에 담아 직접 보내는 방식을 쓰기 때문에, 브라우저가 자동으로 처리하는 CSRF 공격으로부터 안전합니다. 그래서 번거로운 설정을 끄는 것입니다.
- 💡 "전통적인 세션 기반 인증은 브라우저가 쿠키를 자동 전송하므로 CSRF 공격에 취약하지만, JWT를 사용하는 REST API는 인증 정보를 헤더에 직접 담기 때문에 CSRF 보호를 비활성화해도 안전합니다."
Spring Security의 default 로그인 기능

- Username: user
- Password: Spring 로그 확인 (서버 시작 시마다 변경됨)

Spring Security 이해하기
Spring Security - Filter Chain
- Spring에서 모든 호출은 DispatcherServlet을 통과하게 되고 이후에 각 요청을 담당하는 Controller로 분배됩니다.
- 이때, 각 요청에 대해서 공통적으로 처리해야 할 필요가 있을 때 DispatcherServlet 이전에 단계가 필요하며 이것이 Filter입니다.

- Spring Security도 인증 및 인가를 처리하기 위해 Filter를 사용하는데
- Spring Security는 FilterChainProxy를 통해서 상세로직을 구현하고 있습니다.
Form Login 기반 인증

- Form Login 기반 인증은 인증이 필요한 URL 요청이 들어왔을 때 인증이 되지 않았다면 로그인 페이지를 반환하는 형태입니다.
UsernamePasswordAuthenticationFilter

- UsernamePasswordAuthenticationFilter는 Spring Security의 필터인 AbstractAuthenticationProcessingFilter를 상속한 Filter입니다.
- 기본적으로 Form Login 기반을 사용할 때 username과 password 확인하여 인증합니다.
- 인증 과정
- 사용자가 username과 password를 제출하면 UsernamePasswordAuthenticationFilter는 인증된 사용자의 정보가 담기는 인증 객체인 Authentication의 종류 중 하나인 UsernamePasswordAuthenticationToken을 만들어 AuthenticationManager에게 넘겨 인증을 시도합니다.
- 실패하면 SecurityContextHolder를 비웁니다.
- 성공하면 SecurityContextHolder에 Authentication를 세팅합니다.
📋 UsernamePasswordAuthenticationFilter 인증 과정 역할표
- "필터가 ID/PW를 받아서(1), 토큰(티켓)을 만들고(2), 매니저에게 검사받은 뒤(3), 통과되면 사물함(Holder)에 신분증을 넣어준다(5)!"
| 단계 | 주인공 (객체/클래스) | 역할 (비유) | 하는 일 |
| 1 | UsernamePasswordAuthenticationFilter | 접수 창구 | 사용자가 보낸 ID/PW를 받아서 '인증용 티켓'을 만듦 |
| 2 | UsernamePasswordAuthenticationToken | 인증용 티켓 (미완성) |
아직 확인되지 않은 ID/PW 정보가 담긴 종이 뭉치 |
| 3 | AuthenticationManager | 인증 최종 책임자 | "이 티켓 진짜야?"라고 물어보고 인증을 승인함 |
| 4 | Authentication (인증 객체) | 완성된 신분증 | 인증이 완료된 후, 사용자의 권한 정보가 담긴 최종 신분증 |
| 5 | SecurityContextHolder | 개인 사물함 | 인증 성공 시, 이 신분증을 보관해두는 장소 (로그인 유지) |
🔄 인증 성공 vs 실패 흐름도
- 1️⃣ 인증 시도 (Attempt Authentication)
- 사용자가 로그인을 시도하면 필터가 가로챕니다.
- ID와 PW를 꺼내서 UsernamePasswordAuthenticationToken이라는 객체를 만듭니다. (아직 인증 전이라 '미완성' 상태예요.)
- 이 토큰을 들고 AuthenticationManager에게 "이 사람 맞는지 확인 좀 해줘!"라고 요청합니다.
- 2️⃣ 결과에 따른 처리
| 결과 | 필터가 하는 일 | 비유 |
| 성공 (Success) | SecurityContextHolder에 인증 객체를 저장합니다. | "통과! 사물함에 신분증 넣어줄게. 이제 자유롭게 다녀!" |
| 실패 (Failure) | SecurityContextHolder를 깨끗이 비웁니다. | "탈락! 사물함 비워. 넌 아무 데도 못 가." |
SecurityContextHolder

- SecurityContext는 인증이 완료된 사용자의 상세 정보(Authentication)를 저장합니다.
- SecurityContext는 SecurityContextHolder로 접근할 수 있습니다.
// 예시코드
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
context.setAuthentication(authentication); // SecurityContext 에 인증 객체 Authentication 를 저장합니다.
SecurityContextHolder.setContext(context);
Authentication

- 현재 인증된 사용자를 나타내며 SecurityContext에서 가져올 수 있습니다.
- principal : 사용자를 식별합니다.
- Username/Password 방식으로 인증할 때 일반적으로 UserDetails 인스턴스입니다.
- credentials : 주로 비밀번호, 대부분 사용자 인증에 사용한 후 비웁니다.
- authorities : 사용자에게 부여한 권한을 GrantedAuthority로 추상화하여 사용합니다.
<UserDetails>
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
🔥 UsernamePasswordAuthenticationToken는 Authentication을 implements 한 AbstractAuthenticationToken의 하위 클래스로, 인증객체를 만드는 데 사용됩니다.
UserDetailsService
- UserDetailsService는 username/password 인증방식을 사용할 때 사용자를 조회하고 검증한 후 UserDetails를 반환합니다. Custom하여 Bean으로 등록 후 사용 가능합니다.
UserDetailsService: "DB에서 사람 찾아오기"
- 로그인을 시도하면 시큐리티는 "누구누구라는 사람이 DB에 있는지 좀 찾아와 봐!"라고 명령을 내립니다.
- 이때 loadUserByUsername(String username) 메서드가 실행됩니다.
- 우리는 이 메서드 안에서 userRepository.findByUsername(username) 코드를 짜서 DB를 뒤집니다.
- 사용자가 있으면? 그 정보를 UserDetails에 담아서 시큐리티에게 넘겨줍니다.
UserDetails
- 검증된 UserDetails는 UsernamePasswordAuthenticationToken 타입의 Authentication를 만들 때 사용되며 해당 인증객체는 SecurityContextHolder에 세팅됩니다. Custom하여 사용 가능합니다.
UserDetails: "시큐리티가 이해하는 신분증"
- 시큐리티는 인증을 할 때 User 엔티티를 직접 들여다보지 않아요. 대신 UserDetails라는 규격만 봅니다.
- "이 사람 비밀번호가 뭐야?" 👉 getPassword()
- "이 사람 아이디가 뭐야?" 👉 getUsername()
- 이 사람 계정 잠겼어?" 👉 isAccountNonLocked()
- 이렇게 정해진 질문에 답할 수 있는 객체가 필요해서, 보통 우리 User 엔티티에 UserDetails를 상속받거나(implements), 따로 UserDetailsImpl 같은 클래스를 만듭니다.
| 구분 | 방법 1: User 엔티티에 직접 구현 | 방법 2: 별도 UserDetailsImpl 클래스 생성 |
| 구현 형태 | public class User implements UserDetails | public class UserDetailsImpl implements UserDetails |
| 클래스 구성 | 하나의 클래스가 DB 엔티티와 보안 객체 역할을 겸함 | 엔티티와 보안 전용 객체를 완전히 분리함 |
| 장점 (Pros) | 코드가 간결하고 구조가 단순하여 파악하기 쉬움 | 관심사 분리(SoC)가 명확하여 유지보수가 유리함 |
| 단점 (Cons) | 엔티티 클래스에 시큐리티 전용 로직이 섞여 지저분해짐 | 클래스를 추가로 생성하고 데이터를 옮겨 담는 과정이 필요함 |
| 비유 | 한 사람이 '주민등록증' 역할까지 직접 하는 것 | 사람(엔티티)의 정보를 바탕으로 '별도의 신분증'을 발급하는 것 |
| 추천 상황 | 규모가 작거나 빠르게 기능을 구현해야 할 때 | 프로젝트가 크고 클린 코드를 지향할 때 (권장 방식) |
💡 요약하자면
🤝엔티티와 시큐리티의 중간 다리 역할
- "시큐리티는 네가 만든 User 테이블이 어떻게 생겼는지 몰라. 그래서 UserDetailsService라는 창구에서 정보를 찾아오고, UserDetails라는 시큐리티 전용 신분증으로 바꿔서 확인하는 거야!"
- 결국 우리 DB 데이터와 스프링 시큐리티 엔진 사이의 규격을 맞춰주는 과정이라고 생각하면 됩니다. 이제 이 신분증(UserDetails)이 발급되면, 아까 말한 AuthenticationManager가 "오케이, 신분증 확인 완료!" 하고 로그인을 승인해 주는 구조인 거예요! 🚀
| 구분 | 이름 | 역할 (비유) | 왜 필요한가? |
| 데이터 | User 엔티티 | 실제 우리 DB 정보 | 내가 만든 User 테이블의 데이터 (id, username, password 등) |
| 인터페이스 | UserDetails | 시큐리티용 신분증 양식 | 시큐리티가 "비밀번호 어디 있어?", "권한이 뭐야?"라고 물을 때 대답할 수 있는 표준 규격 |
| 서비스 | UserDetailsService | 신분증 발급 창구 | DB에서 우리 User를 찾아와서 시큐리티용 UserDetails로 변환해주는 서비스 |
- 🛠️ JWT는 그냥 "신분증 종이"일 뿐입니다. 그 종이에 무슨 내용을 적을지(DB 조회), 그리고 그 종이를 가진 사람을 어떻게 대접할지(인가/권한)를 스프링 시큐리티 체계 안에서 관리하려면 UserDetailsService와 UserDetails라는 형식을 맞춰줘야 하는 것이죠.
Spring Security : 로그인
로그인 처리 과정 이해
- 스프링 시큐리티 사용 전

- 스프링 시큐리티 사용 후

- Client의 요청은 모두 Spring Security를 거치게 됩니다.
- Spring Security 역할: 인증/인가
- 성공 시: Controller로 Client 요청 전달
- Client 요청 + 사용자 정보 (UserDetails)
- 실패 시: Controller로 Client 요청 전달되지 않음
- Client에게 Error Response 보냄
- 성공 시: Controller로 Client 요청 전달
- 로그인 처리 과정

- [요청 낚아채기] .loginProcessingUrl("/api/user/login")로 들어오는 로그인을 UsernamePasswordAuthenticationFilter가 딱 기다리고 있다가 낚아챕니다.
- [검사 지시] 필터가 낚아챈 아이디/비밀번호를 내부 매니저(AuthenticationManager)에게 넘기면, 매니저가 UserDetailsService를 부릅니다.
- [신분증 발급] UserDetailsService가 DB를 조회해서 비밀번호가 맞는지 확인한 뒤, UserDetailsImpl이라는 시큐리티 전용 신분증을 뚝딱 만들어냅니다.
- [금고에 보관] 최종적으로 이 신분증을 SecurityContextHolder 내부에 있는 인증 객체(Authentication)의 Principal 자리에 안전하게 쏙 저장해 줍니다.
- 이렇게 금고의 Principal 자리에 잘 저장해 두었기 때문에, 나중에 게시판이나 상품 목록 컨트롤러에서 @AuthenticationPrincipal이라는 단축키 하나만 쓰면 그 신분증을 바로바로 꺼내 쓸 수 있는 거랍니다.
📌 상세 처리 과정 설명
1) Client
- 로그인 시도
- 로그인 시도할 username, password 정보를 HTTP body로 전달 (POST 요청)
- 로그인 시도 URL은 WebSecurityConfig 클래스에서 변경 가능
- 아래와 같이 설정 시 "POST /api/user/login"로 설정됩니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
// 로그인 사용
http.formLogin((formLogin) ->
formLogin
// 로그인 처리 (POST /api/user/login)
.loginProcessingUrl("/api/user/login").permitAll()
);
return http.build();
}
- @Bean으로 SecurityFilterChain을 만든다는 것은 "우리 서버에 보안 검색대 라인을 쫙 설치하겠다!"는 뜻입니다.
- 그리고 그 설정 안에 .loginProcessingUrl(...)을 적어두면, 그 검색대 라인 안에 서 있던 신분증 담당 경비원(UsernamePasswordAuthenticationFilter)이 "아, 저 문으로 들어오는 사람들을 내가 검사하면 되는구나!" 하고 일을 시작하는 구조랍니다.
- .loginProcessingUrl("/api/user/login"). 저 한 줄의 설정을 켜두면 내부적으로 이런 일이 일어납니다.
- 가로채기: 사용자가 /api/user/login 주소로 아이디/비밀번호를 보냅니다.
- 자동 검사: 스프링 시큐리티 내부에 숨겨져 있는 기본 필터(UsernamePasswordAuthenticationFilter)가 이 요청을 낚아챕니다.
- 자동 생성: 스프링이 알아서 강의에서 만든 UserDetailsService를 실행해 DB를 조회하고 UserDetailsImpl을 생성합니다.
- 자동 저장: 스프링이 알아서 시큐리티 보관함(SecurityContextHolder)의 Principal 자리에 이 UserDetailsImpl을 저장해 줍니다.
🎛️ HttpSecurity (http 매개변수), 왜 필요할까?
- HttpSecurity는 스프링 시큐리티가 개발자에게 쥐여주는 '만능 보안 리모컨(설정 도구)'입니다.
- 즉, HttpSecurity는 특정 URL의 접근 권한, 로그인 방식, CSRF 비활성화 등 웹 보안과 관련된 세부 기능들을 개발자가 직접 설정할 수 있도록 제공되는 설정용 객체
- 딱 깔끔하게 말하자면 스프링 시큐리티에서 웹 보안 설정을 편하게 하라고 제공하는 '설정 전용 객체'입니다.
- 개발자가 이 객체의 메서드들(csrf(), authorizeHttpRequests(), formLogin() 등)을 호출해서 입맛대로 보안 세팅을 마치고, 마지막에 .build()를 실행해서 최종 결과물(SecurityFilterChain)을 뽑아내는 용도로 쓰입니다.
- 💡 http 리모컨으로 할 수 있는 핵심 조작들
- http.csrf((csrf) -> csrf.disable()) 👉 "CSRF 주머니 검사 기능 꺼줘!"
- http.authorizeHttpRequests(...) 👉 "어떤 주소는 VIP 패스로 통과시키고, 나머지는 신분증(인증) 검사해 줘!"
- http.formLogin(...) 👉 "기본 폼 로그인 기능 켜주고, 로그인 처리 주소는 이걸로 설정해 줘!"
- ✨ 결론: http 객체가 없으면 우리는 스프링 시큐리티의 옵션을 입맛대로 바꿀 방법이 없습니다. 스프링에게 "이 리모컨 버튼대로 설정해서 SecurityFilterChain을 최종 완성해 줘!"라고 명령을 내리기 위해 반드시 필요한 조작 도구입니다!
🛡️ WebSecurityConfig 클래스가 필요한 이유
- WebSecurityConfig는 우리 서버의 '보안 중앙 규칙서(설정 파일)' 역할을 합니다.
- 스프링 시큐리티를 프로젝트에 추가하면, 기본적으로 서버의 모든 접근을 콱 막아버립니다. (아무도 못 들어오게 잠가버림).
- 따라서, "이 주소(/api/user/**)는 로그인 없이 통과시켜 주고, 다른 주소는 막아라", "폼 로그인을 사용하겠다" 같은 우리 서비스만의 맞춤형 보안 규칙을 스프링 시큐리티에게 알려주기 위해 반드시 만들어야 하는 필수 클래스입니다.
⛓️ 왜 SecurityFilterChain을 사용할까?
- 스프링 시큐리티의 본질은 '여러 필터(Filter)들의 묶음(Chain)'입니다. SecurityFilterChain은 우리가 작성한 보안 규칙(CSRF 끄기, 특정 URL 허용 등)들을 모아서 하나의 완성된 '필터 세트'로 조립해 주는 역할을 합니다.
- 즉, @Bean으로 SecurityFilterChain을 만들어 스프링에게 던져주면, 스프링이 "아, 개발자가 짜준 이 규칙대로 필터들을 배치해서 문지기를 세우면 되겠구나!" 하고 우리 서버에 딱 맞는 보안 방어막을 최종적으로 완성하게 됩니다.
| 구분 | 상세 내용 | 비고 (핵심 포인트) |
| 요청 주체 | Client (사용자 브라우저 등) | 사용자가 로그인 버튼을 클릭하여 시도함 |
| 전달 데이터 | username, password | HTTP 요청의 Body(본문) 영역에 담아서 보냄 |
| 요청 방식 | POST 메서드 | 중요 정보(비밀번호)가 URL에 노출되지 않도록 POST 방식 사용 |
| 요청 URL | /api/user/login | 클라이언트가 로그인 데이터를 보내는 목적지 주소 |
| 스프링 설정 | http.formLogin().loginProcessingUrl("/api/user/login") | WebSecurityConfig에서 로그인 처리 담당 주소를 지정하는 옵션 |
| 마법의 결과 | 시큐리티 필터가 요청을 가로채어 인증 처리 | 우리가 직접 컨트롤러(@PostMapping)에 로그인 로직을 짜지 않아도 시큐리티가 대신 처리해 줌! |
2) 인증 관리자 (Authentication Manager)
- UserDetailsService에게 username을 전달하고 회원상세 정보를 요청
3) UserDetailsService
- 회원 DB에서 회원 조회
- 회원 정보가 존재하지 않을 시 → Error 발생
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
- 조회된 회원 정보(user)를 UserDetails로 변환
UserDetails userDetails = new UserDetailsImpl(user)
- UserDetails를 "인증 관리자"에게 전달
4) "인증 관리자"가 인증 처리
- 아래 2개의 username, password 일치 여부 확인
- Client가 로그인 시도한 username, password
- UserDetailsService가 전달해 준 UserDetails의 username, password
- password 비교 시
- Client가 보낸 password는 평문이고, UserDetails의 password는 암호문
- Client가 보낸 password를 암호화해서 비교
- 인증 성공 시 → 세션에 로그인 정보 저장
- 인증 실패 시 → Error 발생
로그인 구현
1. 로그인 처리 URL 설정
- 우리가 직접 Filter를 구현해서 URL 요청에 따른 인가를 설정한다면 코드가 매우 복잡해지고 유지보수 비용이 많이 들 수 있습니다.
- Spring Security를 사용하면 이러한 인가 처리가 굉장히 편해집니다.
- requestMatchers("/api/user/**").permitAll()
- 이 요청들은 로그인, 회원가입 관련 요청이기 때문에 비회원/회원 상관없이 누구나 접근이 가능해야 합니다.
- 이렇게 인증이 필요 없는 URL들을 간편하게 허가할 수 있습니다.
- anyRequest().authenticated()
- 인증이 필요한 URL들도 간편하게 처리할 수 있습니다
package com.sparta.springauth.config;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
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.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
// 로그인 사용
http.formLogin((formLogin) ->
formLogin
// 로그인 View 제공 (GET /api/user/login-page)
.loginPage("/api/user/login-page")
// 로그인 처리 (POST /api/user/login)
.loginProcessingUrl("/api/user/login")
// 로그인 처리 후 성공 시 URL
.defaultSuccessUrl("/")
// 로그인 처리 후 실패 시 URL
.failureUrl("/api/user/login-page?error")
.permitAll()
);
return http.build();
}
}
2. DB의 회원 정보 조회 → Spring Security의 "인증 관리자" 에게 전달
UserDetailsService 구현
package com.sparta.springauth.security;
import com.sparta.springauth.entity.User;
import com.sparta.springauth.repository.UserRepository;
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;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user);
}
}
UserDetails 구현
package com.sparta.springauth.security;
import com.sparta.springauth.entity.User;
import com.sparta.springauth.entity.UserRoleEnum;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
- UserDetailsService와 UserDetails를 직접 구현해서 사용하게 되면 Security의 default 로그인 기능을 사용하지 않겠다는 설정이 되어 Security의 password를 더 이상 제공하지 않는 것을 확인할 수 있습니다.
- POST "/api/user/login"을 로그인 인증 URL로 설정했기 때문에 이제 해당 요청이 들어오면 우리가 직접 구현한 UserDetailsService를 통해 인증 확인 작업이 이뤄지고 인증 객체에 직접 구현한 UserDetails가 담기게 됩니다.
@AuthenticationPrincipal
@Controller
@RequestMapping("/api")
public class ProductController {
@GetMapping("/products")
public String getProducts(@AuthenticationPrincipal UserDetailsImpl userDetails) {
// Authentication 의 Principal 에 저장된 UserDetailsImpl 을 가져옵니다.
User user = userDetails.getUser();
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
}
- @AuthenticationPrincipal
- Authentication의 Principal에 저장된 UserDetailsImpl을 가져올 수 있습니다.
- UserDetailsImpl에 저장된 인증된 사용자인 User 객체를 사용할 수 있습니다.
@AuthenticationPrincipal 사용해서 메인 페이지 사용자 이름 반영하기
@Controller
public class HomeController {
@GetMapping("/")
public String home(Model model, @AuthenticationPrincipal UserDetailsImpl userDetails) {
// 페이지 동적 처리 : 사용자 이름
model.addAttribute("username", userDetails.getUser().getUsername());
return "index";
}
}
Spring Security : JWT 로그인

UsernamePasswordAuthenticationFilter의 역할
- → 사용자의 기능 사용을 위한 인증(authentication) 처리 담당
- 기본 동작 과정:
- 사용자가 username과 password를 전송
- Authentication 토큰 생성
- Authentication Manager를 통해 인증 확인
- 필터 상속 및 커스터마이징
- UsernamePasswordAuthenticationFilter를 상속받아 직접 구현할 예정
- 상속받은 필터 내에서 인증 작업을 직접 처리할 계획
- 커스터마이징이 필요한 이유
- 기본 필터는 세션 방식의 인증을 사용함
- JWT 토큰을 생성해야 하므로 기본 필터를 그대로 사용할 수 없음
- 세션 방식 대신 JWT 방식의 인증을 구현하기 위해 필터를 커스터마이징
❓JwtAuthenticationFilter (인증) vs JwtAuthorizationFilter (인가)
| 구분 | JwtAuthenticationFilter (인증) | JwtAuthorizationFilter (인가) |
| 언제? | 로그인할 때 딱 한 번 | 로그인 이후 모든 요청마다 |
| 무엇을? | ID/PW를 받아서 맞는지 확인 | JWT 토큰을 받아서 진짜인지 확인 |
| 결과 | JWT 토큰을 생성해서 줌 | 인증 객체를 만들어 서버 메모리에 저장 |
JWT 인증 처리 (Filter)
- JwtAuthenticationFilter : 로그인 진행 및 JWT 생성
- "사용자가 보낸 아이디/비밀번호를 확인하고, 맞으면 JWT 토큰을 구워주는 과정"
- Controller, Service, Repository, 즉 DispatcherServlet을 지나고 나서 Controller 이후에서 우리 비즈니스 로직이 있는 곳에서 인증, 인가 작업을 하는 게 아니라, 이렇게 Filter 단에서 인증, 인가 처리를 전부 할 것.
- 목적: 비즈니스 로직과 인증, 인가 처리 로직을 분리
package com.sparta.springauth.jwt;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.springauth.dto.LoginRequestDto;
import com.sparta.springauth.entity.UserRoleEnum;
import com.sparta.springauth.security.UserDetailsImpl;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("로그인 시도");
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
jwtUtil.addJwtToCookie(token, response);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.info("로그인 실패");
response.setStatus(401);
}
}
📑 JwtAuthenticationFilter 핵심 개념 정리
| 분류 | 주요 개념/문법 | 역할 및 설명 (필기용) |
| 상속 | UsernamePasswordAuthenticationFilter | 스프링 시큐리티의 기본 로그인 필터를 확장함. (폼 로그인 대신 JWT 로그인을 위해 커스텀) |
| 설정 | setFilterProcessesUrl("/api/user/login") | 이 필터가 작동할 로그인 주소를 지정함. 이 주소로 POST 요청이 오면 필터가 가동됨. |
| 인증 시도 | attemptAuthentication | 사용자가 보낸 ID/PW를 읽어와서 "인증 시도"를 하는 메서드. (JSON 데이터를 객체로 변환함) |
| JSON 변환 | ObjectMapper | HTTP Body에 담긴 JSON 데이터를 자바 객체(LoginRequestDto)로 변환해주는 도구. |
| 인증 관리 | AuthenticationManager | 시큐리티의 '인증 대장'. ID/PW를 들고 실제 DB와 대조하여 인증을 최종 승인함. |
| 성공 처리 | successfulAuthentication | 로그인이 성공했을 때 실행됨. 여기서 JWT를 생성하고 쿠키에 담아 응답함. |
| 실패 처리 | unsuccessfulAuthentication | 로그인이 실패했을 때 실행됨. 401(Unauthorized) 상태 코드를 반환함. |
| 인증 객체 | Authentication | 인증이 완료된 후, 사용자 정보(Principal)를 담고 있는 시큐리티 전용 바구니. |
- 🛠️ 코드 내 주요 흐름
- 입력: 사용자가 /api/user/login으로 JSON(ID, PW)을 보냄.
- 추출: attemptAuthentication에서 ObjectMapper로 데이터를 읽어 AuthenticationManager에게 던짐.
- 검증: 시큐리티가 DB(UserDetailsService)를 뒤져서 비밀번호가 맞는지 확인.
- 발급: 비밀번호가 맞으면 successfulAuthentication 실행 → JWT 토큰 생성 → 쿠키 저장.
- 완료: 브라우저는 이제 로그인 증서(JWT)를 가진 상태가 됨!
- "로그인 시 JWT는 어떤 시점에, 어디서 생성해서 클라이언트에게 전달하나요?"
- "로그인 필터의 successfulAuthentication 메서드에서 인증이 성공하면, JwtUtil을 사용해 토큰을 만들고 쿠키에 담아 보냅니다."
| 구분 | 기본 필터 (폼 로그인) | 우리가 만들 커스텀 필터 (JWT) |
| 로그인 성공 시 | 서버 메모리(세션)에 사용자 정보를 저장함 | 세션을 아예 안 만듦! |
| 클라이언트에게 주는 것 | 창구 번호표 같은 '세션 ID(JSESSIONID)'를 쿠키로 줌 | 우리가 직접 암호화해서 만든 'JWT 문자열'을 줌 |
| 다음 요청 시 검사 방법 | 클라이언트가 번호표를 가져오면, 서버 메모리를 뒤져서 누군지 찾음 | 클라이언트가 JWT를 가져오면, 서버 메모리를 안 뒤지고 서명만 풀어서 확인함 |
JWT 인가 처리 (Filter)
- JwtAuthorizationFilter : API에 전달되는 JWT 유효성 검증 및 인가 처리
- "이미 로그인을 마친 사람이 다시 방문했을 때, 그 사람이 들고 있는 JWT 토큰이 진짜인지 검사하는 필터"
package com.sparta.springauth.jwt;
import com.sparta.springauth.security.UserDetailsServiceImpl;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getTokenFromRequest(req);
if (StringUtils.hasText(tokenValue)) {
// JWT 토큰 substring
tokenValue = jwtUtil.substringToken(tokenValue);
log.info(tokenValue);
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
// 인증 처리
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(username);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
📑 JwtAuthorizationFilter 핵심 개념 정리
| 분류 | 주요 개념/문법 | 역할 및 설명 (필기용) |
| 상속 | OncePerRequestFilter | 모든 요청에 대해 딱 한 번만 실행되도록 보장하는 필터입니다. |
| 핵심 로직 | doFilterInternal | 필터의 실제 로직이 실행되는 곳입니다. 토큰을 꺼내고, 검증하고, 인증을 완료합니다. |
| 토큰 검증 | jwtUtil.validateToken | 토큰이 위조되지 않았는지, 만료되지는 않았는지 유효성을 확인합니다. |
| 정보 추출 | Claims info | 토큰 안에 숨겨진 유저 정보(이름, 권한 등)를 꺼냅니다. |
| 인증 보관함 | SecurityContextHolder | "이 사람 인증됐어!"라는 사실을 저장하는 전역 보관소입니다. (여기 저장되어야 컨트롤러까지 무사 통과!) |
| 바톤 터치 | filterChain.doFilter | 검사가 끝나면(성공하든, 토큰이 없든) 다음 필터로 요청을 넘겨주는 필수 단계입니다. |
- 이 코드는 사용자가 어떤 페이지(예: 장바구니, 마이페이지)를 누를 때마다 실행됩니다.
- 토큰 찾기: 요청(Cookie)에서 JWT 토큰을 꺼내옵니다.
- 껍데기 제거: "Bearer " 같은 접두사를 떼어내고 순수한 토큰값만 남깁니다. (substringToken)
- 진위 판별: 이 토큰이 우리가 만든 게 맞는지 검사합니다. (validateToken)
- 장부 확인: 토큰에 적힌 이름으로 DB를 조회해서 진짜 유저인지 확인합니다. (loadUserByUsername)
- 통과 도장: 유저가 확인되면 SecurityContext라는 곳에 유저 정보를 넣어둡니다. (이게 있어야 스프링이 "아, 이 사람 로그인된 상태구나"라고 인식합니다.)
- 입장: 이제 다음 필터나 컨트롤러로 이동합니다!
이 필터의 목적은 "매번 로그인 페이지로 보내지 않고, 토큰만 보고 이 사람이 누구인지 알아내서 서버가 기억하게 만드는 것"입니다.
가장 중요한 코드는 SecurityContextHolder 부분이에요. 여기에 정보를 담아야만 뒤에 있는 컨트롤러에서 @AuthenticationPrincipal 같은 어노테이션으로 유저 정보를 편하게 꺼내 쓸 수 있게 됩니다
WebSecurityConfig 필터 등록
- 이 코드는 스프링 시큐리티의 '설계도'이자 '중앙 통제실'입니다. 우리가 만든 필터들을 어디에 배치할지, 어떤 주소를 열어줄지를 여기서 다 결정해요.
- 프로젝트의 '보안 정책서'라고 보시면 돼요. 나중에 "어? 왜 이 주소는 로그인을 안 했는데 들어가지지?" 싶을 때 가장 먼저 열어봐야 하는 곳이 바로 이 WebSecurityConfig입니다.
package com.sparta.springauth.config;
import com.sparta.springauth.jwt.JwtAuthorizationFilter;
import com.sparta.springauth.jwt.JwtAuthenticationFilter;
import com.sparta.springauth.jwt.JwtUtil;
import com.sparta.springauth.security.UserDetailsServiceImpl;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService, AuthenticationConfiguration authenticationConfiguration) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.authenticationConfiguration = authenticationConfiguration;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page").permitAll()
);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
🏗️ WebSecurityConfig 핵심 설정 정리
| 분류 | 주요 설정 및 코드 | 역할 및 설명 (필기용) |
| 어노테이션 | @EnableWebSecurity | 스프링 시큐리티의 기본 기능을 활성화하고, 내 커스텀 설정을 적용하겠다는 선언. |
| 세션 정책 | SessionCreationPolicy.STATELESS | JWT 방식의 핵심. 서버가 세션을 생성하거나 유지하지 않도록 설정함. (무상태성) |
| 권한 제어 | requestMatchers(...).permitAll() | 로그인 페이지나 정적 리소스(CSS, JS) 등 인증 없이 접근 가능한 주소를 지정함. |
| 필터 순서 1 | addFilterBefore(인가, 인증.class) | 인가 필터를 인증 필터보다 앞에 둠. (이미 토큰이 있는 사람은 바로 통과시키기 위해) |
| 필터 순서 2 | addFilterBefore(인증, Username...class) | 커스텀 인증 필터를 시큐리티 기본 폼 로그인 필터 자리에 끼워 넣음. |
| CSRF | csrf().disable() | REST API 방식이므로 CSRF 보안 설정을 끔. (아까 정리한 내용!) |
| Bean 등록 | AuthenticationManager | 시큐리티의 인증 대장을 스프링 컨테이너에 등록하여 필터에서 쓸 수 있게 함. |
- 🚦 보안 필터의 '줄 세우기' (중요!)
- 이 설정 파일에서 가장 중요한 건 addFilterBefore를 통한 순서 배치입니다.
- JwtAuthorizationFilter (인가): "이미 토큰(신분증) 있는 사람 손 들어!" -> 있으면 바로 통과.
- wtAuthenticationFilter (인증): "토큰 없어? 그럼 아이디/비번 대봐. 새로 만들어줄게."
- UsernamePasswordAuthenticationFilter: 스프링 시큐리티가 기본으로 제공하는 로그인 문지기 (우리는 이걸 우리 필터로 대체함).
- 이 설정 파일에서 가장 중요한 건 addFilterBefore를 통한 순서 배치입니다.
접근 불가 페이지 만들기
API 접근 권한 제어 이해
- 👉 '일반 사용자'는 관리자 페이지에 접속이 인가되지 않아야 합니다!
1. Spring Security에 "권한 (Authority)" 설정방법

- 회원 상세정보 (UserDetailsImpl)를 통해 "권한 (Authority)" 설정 가능합니다.
- 권한을 1개 이상 설정 가능합니다.
- "권한 이름" 규칙 "ROLE_"로 시작해야 함
- 예) "ADMIN" 권한 부여 → "ROLE_ADMIN"
- dP) "USER" 권한 부여 → "ROLE_USER"
public enum UserRoleEnum {
USER(Authority.USER), // 사용자 권한
ADMIN(Authority.ADMIN); // 관리자 권한
private final String authority;
UserRoleEnum(String authority) {
this.authority = authority;
}
public String getAuthority() {
return this.authority;
}
public static class Authority {
public static final String USER = "ROLE_USER";
public static final String ADMIN = "ROLE_ADMIN";
}
}
public class UserDetailsImpl implements UserDetails {
// ...
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
SimpleGrantedAuthority adminAuthority = new SimpleGrantedAuthority("ROLE_ADMIN");
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(adminAuthority);
return authorities;
}
}
- new SimpleGrantedAuthority("ROLE_ADMIN");
- 예시 코드는 ROLE_ADMIN 으로 고정되어 있지만 아래와 같이 실제 코드에서는 사용자에 저장되어 있는 role의 authority 값을 사용하여 동적으로 저장됩니다.
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
- UserDetailsImpl 저장된 authorities 값을 사용하여 간편하게 권한을 제어할 수 있습니다.
2. Spring Security를 이용한 API 별 권한 제어 방법
- Controller에 "@Secured" 애너테이션으로 권한 설정이 가능합니다.
- @Secured("권한 이름") 선언 (권한 1개 이상 설정 가능)
@Secured(UserRoleEnum.Authority.ADMIN) // 관리자용
@GetMapping("/products/secured")
public String getProductsByAdmin(@AuthenticationPrincipal UserDetailsImpl userDetails) {
System.out.println("userDetails.getUsername() = " + userDetails.getUsername());
for (GrantedAuthority authority : userDetails.getAuthorities()) {
System.out.println("authority.getAuthority() = " + authority.getAuthority());
}
return "redirect:/";
}
"@Secured" 애너테이션 활성화 방법
@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
@EnableMethodSecurity(securedEnabled = true) // @Secured 애너테이션 활성화
public class WebSecurityConfig {
- @EnableMethodSecurity(securedEnabled = true)
Validation
📌 프로그래밍을 하는데에 있어서 가장 중요한 부분 중 하나입니다.
- 특히나 Java는 null 값에 대한 접근에 대해 NullPointerException 오류가 발행하기 때문에 이러한 부분을 예방하기 위해 Validation 즉, 검증 과정이 필요합니다.
- Spring에서는 null 확인뿐 아니라 문자의 길이 측정과 같은 다른 검증 과정도 쉽게 처리할 수 있도록 Bean Validation 제공하고 있습니다.
Bean Validation
- 간편하게 사용할 수 있는 여러 애너테이션을 제공해줍니다.
| @NotNull | null 불가 |
| @NotEmpty | null, “” 불가 |
| @NotBlank | null, “”. “ “ 불가 |
| @Size | 문자 길이 측정 |
| @Max | 최대값 |
| @Min | 최소값 |
| @Positive | 양수 |
| @Negative | 음수 |
| E-mail 형식 | |
| @Pattern | 정규 표현식 |
Validation 적용
- @Valid
- Bean Validation을 적용한 해당 Object validation 실행
@PostMapping("/validation")
@ResponseBody
public ProductRequestDto testValid(@RequestBody @Valid ProductRequestDto requestDto) {
return requestDto;
}
Validation 예외처리
- 회원가입 진행 시 데이터 검증 시 오류가 발생했을 때 로그인 페이지가 아니라 회원가입 페이지로 이동하려면 Validation 예외를 처리해야 합니다.
BindingResult
- 예외가 발생하면 BindingResult 객체에 오류에 대한 정보가 담깁니다.
- 파라미터로 BindingResult 객체를 받아올 수 있습니다.
@PostMapping("/user/signup")
public String signup(@Valid SignupRequestDto requestDto, BindingResult bindingResult) {
// Validation 예외처리
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
if(fieldErrors.size() > 0) {
for (FieldError fieldError : bindingResult.getFieldErrors()) {
log.error(fieldError.getField() + " 필드 : " + fieldError.getDefaultMessage());
}
return "redirect:/api/user/signup";
}
userService.signup(requestDto);
return "redirect:/api/user/login-page";
}
- bindingResult.getFieldErrors()
- 발생한 오류들에 대한 정보가 담긴 List<FieldError> 리스트를 가져옵니다.
정규표현식
// @ 기호를 확인합니다. 기호 앞과 뒤 문자는 신경쓰지 않습니다.
String regx1 = "^(.+)@(.+)$";
// @ 기호 앞에 오는 방식에 제한을 추가합니다.
// A-Z, a-z, 0-9, ., _ 를 사용할 수 있습니다.
String regx2 = "^[A-Za-z0-9+_.-]+@(.+)$";
// 이메일 형식에 허용되는 문자를 모두 사용할 수 있습니다.
String regx3 = "^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$";
- ❓ Bean Validation의 애너테이션 @Pattern을 사용해서 회원가입 Email 데이터 검증을 진행합니다.
- Email 형식 : 계정@도메인.최상위도메인
package com.sparta.springauth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class SignupRequestDto {
@NotBlank
private String username;
@NotBlank
private String password;
@Pattern(regexp = "^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$")
@NotBlank
private String email;
private boolean admin = false;
private String adminToken = "";
}
'Back-End > Spring' 카테고리의 다른 글
| 프로젝트 관리 심화: 챕터 2 (CI/CD) (0) | 2026.05.14 |
|---|---|
| 프로젝트 관리 심화: 챕터 1 (Docker) (0) | 2026.05.04 |
| MSA (Microservice Architecture) (0) | 2026.04.14 |
| Spring 숙련: 챕터 2 (0) | 2026.04.08 |
| Spring 입문 (0) | 2026.04.06 |