티스토리 뷰
스프링 시큐리티 완전 처음부터 1편
처음 만나는 SecurityFilterChain, 진짜 하나도 모른다는 가정으로
솔직히 말하면, 나는 스프링 시큐리티 처음 쓸 때 완전 멘붕이었다.
“아니, 그냥 로그인만 되면 되는 건데… 왜 이렇게 복잡하지?”
근데 그 복잡함 뒤에 있는 **“구조”**를 한 번 이해하고 나니까, 왜 이걸 안 쓰면 안 되는지 좀 보이더라.
이번 글은 진짜로
“스프링 시큐리티 1도 모른다”
라고 가정하고 쓰겠다.
- 왜 쓰는지
- 요청이 들어오면 내부에서 무슨 일이 벌어지는지
- 최소 설정으로 “진짜 돌아가는 코드”를 만들면서
- 각 코드 한 줄씩 다 해석해 보는 게 목표다.
0. 이 글에서 사용하는 환경 먼저 짚고 가자
이건 앞으로 나오는 코드가 그대로 따라 해도 돌아가게 만들기 위한 전제다.
- JDK: 17 (스프링 부트 3.x 기준)
- Spring Boot: 3.x (2.x 아님)
- 빌드 도구: Gradle (Maven 써도 되는데, 예제는 Gradle)
- 의존성:
- spring-boot-starter-web
- spring-boot-starter-security
버전이 다르면 코드가 미묘하게 안 맞을 수 있다. 특히 스프링 시큐리티는 5.7 이후로 WebSecurityConfigurerAdapter 가 삭제됐기 때문에, 예전에 블로그 글 찾아보고 따라 하면 100% 꼬인다.
우리는 **“요즘 방식”**만 쓸 거다.
1. 스프링 시큐리티, 왜 굳이 써야 하나?
주니어 때 많이 보던 코드가 이거였다.
@WebFilter("/*")
public class LoginCheckFilter implements Filter {
// 세션에 user 없으면 /login 으로 리다이렉트하는 방식
}
이렇게 직접 필터 만들어서 로그인 체크하고, 세션에 유저 객체 넣고, 권한도 if 문으로 분기하고…
이걸 “어떻게든” 구현하면 사실 동작은 한다.
문제는 여기서다.
- CSRF, 세션 고정 공격, 브루트포스 공격 같은 건 전혀 고려 안 된 상태
- 개발자가 직접 만든 인증/인가 로직은 구멍이 생기기 쉽다
- 서비스가 커질수록, “직접 만든 보안 코드”가 유지보수 지옥으로 변한다
그래서 현실적인 결론은 이거다.
- 보안은 직접 구현하는 게 아니라
- 검증된 프레임워크를 가져다 쓰는 게 맞다
- 그게 스프링 시큐리티다
스프링 시큐리티는 아래를 “기본값”으로 제공한다.
- 인증/인가(로그인/권한) 구조
- 비밀번호 암호화(BCrypt)
- 세션 관리, 쿠키, CSRF 방어
- 필터 체인 기반의 일관된 처리 흐름
- 확장 가능한 구조 (커스텀 인증/토큰/JWT 등)
그래서 “복잡해 보이는 이유”는 사실 이거 하나다.
온갖 경우를 다 처리할 수 있게 유연하게 만들어놔서…
우리는 오늘 그 중에서 가장 기본적인 한 줄기만 잡고 간다.
2. 스프링 시큐리티의 큰 그림: 요청 한 번에 무슨 일이 일어나는가
우선 머릿속에 이 흐름이 한 줄로 서 있어야 한다.
클라이언트 요청 → 여러 개의 시큐리티 필터들 → 인증/인가 판단 → 컨트롤러
조금 더 풀어보면:
- 사용자가 GET /private/hello 같은 요청을 보낸다.
- 스프링 MVC 컨트롤러에 도달하기 전에,
SecurityFilterChain에 등록된 필터들이 줄줄이 실행된다. - 이 필터들 중 일부가
- “이 요청은 인증이 필요한가?”
- “지금 사용자는 로그인된 상태인가?”
- “권한은 충분한가?”
이런 걸 판단한다.
- 인증이 안 되어 있고, 인증이 필요한 URL이면,
→ 로그인 페이지로 리다이렉트 - 인증이 되어 있고, 권한도 충분하면
→ 그제서야 우리가 작성한 @RestController 메서드가 호출된다.
이걸 꼭 기억하자.
“스프링 시큐리티는 컨트롤러 앞에 서서 모든 요청을 지켜보는 필터 레이어다.”
3. 진짜 프로젝트를 하나 만든다고 치고, 맨 처음부터 코드 짜보자
3-1. build.gradle 설정
아주 최소한의 코드만 넣겠다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.test {
useJUnitPlatform()
}
여기서 중요한 건 딱 두 줄이다.
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
- spring-boot-starter-web
→ 기본적인 웹 MVC, 내장 톰캣, JSON 직렬화 등 - spring-boot-starter-security
→ 스프링 시큐리티 핵심 기능, 필터, 인증/인가 구조 등
이 두 개만 있으면
“기본 로그인 화면이 뜨는 웹 애플리케이션”을 만들 수 있다.
3-2. 애플리케이션 진입점 만들기
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoSecurityApplication {
public static void main(String[] args) {
SpringApplication.run(DemoSecurityApplication.class, args);
}
}
이건 그냥 스프링 부트 기본 템플릿이라고 보면 된다.
이름은 뭐든 상관없다. (DemoSecurityApplication 말고 다른 걸 써도 됨)
4. SecurityConfig – 요즘 방식의 기본 보안 설정
이제 핵심인 SecurityConfig를 만든다.
위치: com.example.demo.config.SecurityConfig
(패키지는 원하는 대로 쓰되, @SpringBootApplication이 있는 패키지 하위에 두면 된다)
4-1. 전체 코드
package com.example.demo.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 SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. CSRF 설정
.csrf(csrf -> csrf.disable())
// 2. URL 별 권한(인가) 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll() // 이 경로는 모두 허용
.anyRequest().authenticated() // 나머지는 인증 필요
)
// 3. 기본 로그인 폼 사용
.formLogin(Customizer.withDefaults());
return http.build();
}
}
위 코드 그대로 복붙해도 된다.
이제 이걸 한 줄씩 해석해 보자.
4-2. @Configuration, @Bean, SecurityFilterChain 이게 뭔데?
@Configuration
public class SecurityConfig {
- @Configuration
→ “이 클래스 안에는 스프링 빈 설정이 들어있다”라는 의미
→ 예전에 XML에 쓰던 설정을 자바 코드로 옮겨온 것이라고 보면 된다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
- @Bean
→ 이 메서드가 반환하는 객체를 스프링 컨테이너에 등록해라 - 리턴 타입: SecurityFilterChain
→ 이게 바로 “스프링 시큐리티의 필터 체인 설정” 그 자체다. - 파라미터: HttpSecurity http
→ 스프링이 알아서 주입해주는 설정 빌더 같은 거라고 생각하면 된다.
→ 이 객체에 체이닝으로 설정을 붙여 나가고, 마지막에 http.build()를 호출하면 SecurityFilterChain이 만들어진다.
옛날에는 이렇게 썼다.
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception { ... }
}
지금은 이 방식이 deprecated → 삭제 되었다.
그래서 우리는 SecurityFilterChain을 Bean으로 등록하는 방식만 쓴다.
4-3. CSRF 설정
http
.csrf(csrf -> csrf.disable())
- CSRF는 웹 브라우저 기반 폼에서 중요하다.
- 하지만 우리가 지금 만드는 건 학습용 API/간단 서비스라, 일단 비활성화해서 헷갈리는 요소를 줄이는 게 좋다.
- 나중에 폼 기반, 세션 기반 웹 앱을 제대로 만들 때 다시 켜고 설명할 수 있다.
중요한 건 “지금은 왜 끄는지”를 알고 끄는 것.
REST API 기반, 쿠키/폼 로그인 안 쓰고, JWT 같은 토큰 기반 인증을 사용할 예정인 경우,
보통 CSRF를 끄고 시작한다.
4-4. authorizeHttpRequests – 어떤 URL에 인증이 필요한지 선언하는 부분
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
이 부분이 스프링 시큐리티의 “인가(Authorization)” 핵심이다.
- authorizeHttpRequests(...)
→ “들어오는 HTTP 요청들에 대해, 어떤 규칙으로 접근을 허용/차단할지 정의하겠다” - requestMatchers("/public/**").permitAll()
→ /public/ 로 시작하는 모든 요청은
“인증 없이 누구나 접근 가능”이라는 의미다. - anyRequest().authenticated()
→ 위에서 명시적으로 허용한 URL을 제외한 나머지 모든 요청은
“인증(로그인)이 반드시 필요하다”는 의미.
즉 지금 설정은 이렇게 읽힌다.
- /public/** → 로그인 안 해도 됨
- 그 외 모든 URL → 반드시 로그인해야만 접근 가능
이 설정 하나만으로, 우리는
“로그인 여부에 따라 접근이 갈리는 API”를 만든 셈이다.
4-5. formLogin – 기본 폼 로그인 사용
.formLogin(Customizer.withDefaults());
이 한 줄의 의미는 간단하다.
“스프링 시큐리티가 제공하는 기본 로그인 폼 사용하겠다.”
여기서 자동으로 제공되는 것들:
- /login GET → 기본 로그인 페이지
- /login POST → 아이디/비밀번호 처리
- 로그인 실패 시 → 다시 /login?error 로 리다이렉트
- 로그인 성공 시 → 원래 가려고 했던 URL로 리다이렉트
아무 설정 안 해도 이 동작이 다 들어간다.
초반 학습 구간에서는 이 기본 폼이 오히려 더 낫다.
- 우리가 HTML 로그인 페이지를 직접 만들 필요가 없고
- 인증 흐름을 눈으로 쉽게 따라갈 수 있다
나중에 JWT로 가거나, SPA(React, Vue) 프론트와 연동할 때는 이걸 끄고 API 기반 인증으로 갈 거다.
그건 다음 편 이후의 이야기고, 지금은 “구조”를 보는 게 목표다.
4-6. 마지막으로 http.build()
return http.build();
앞에서 우리가 체이닝으로 붙여왔던 설정들이 이 시점에서 하나의 SecurityFilterChain 객체로 만들어진다.
그리고 이 객체가 @Bean으로 스프링에 등록된다.
5. 테스트용 컨트롤러 – 실제로 URL별로 접근 차이를 만들어 보자
이제 /public/hello 와 /private/hello 두 개의 API를 만들어서
설정이 진짜로 먹히는지 확인해보자.
위치: com.example.demo.controller.TestController
package com.example.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TestController {
@GetMapping("/public/hello")
public String publicHello() {
return "누구나 접근 가능한 공개 API 입니다.";
}
@GetMapping("/private/hello")
public String privateHello() {
return "로그인한 사용자만 접근 가능한 비공개 API 입니다.";
}
}
설명:
- @RestController
→ 반환 값이 그대로 HTTP 응답 바디로 내려간다.
(뷰 템플릿이 아니라 문자열/JSON) - /public/hello
→ 아까 SecurityConfig에서 permitAll() 했으니까, 로그인 없이 접근 가능 - /private/hello
→ anyRequest().authenticated() 규칙에 걸린다.
→ 로그인해야만 접근 가능
6. 실제로 서버를 띄우고 하나씩 찍어보자
- 애플리케이션 실행
(IDE에서 DemoSecurityApplication 실행) - 콘솔 로그를 잘 보면 이런 메시지가 보인다.이게 바로 임시로 생성된 “user” 계정의 비밀번호다.
- username: user
- password: 콘솔에 찍힌 저 복잡한 문자열
- Using generated security password: 3f9e3b9f-xxxx-xxxx-xxxx-xxxxxxxxxxxx
- 브라우저에서 아래 URL을 하나씩 호출해보자.
6-1. /public/hello
http://localhost:8080/public/hello
결과:
- 바로 “누구나 접근 가능한 공개 API 입니다.” 라는 문자열이 뜨면 정상이다.
- 이 URL은 permitAll() 로 열어뒀기 때문에, 로그인 체크를 하긴 하지만, 통과시켜준다.
6-2. /private/hello
http://localhost:8080/private/hello
결과:
- 바로 응답이 안 오고, /login 페이지로 리다이렉트된다.
- 스프링 시큐리티 기본 로그인 폼이 보인다.
- username: user / password: 콘솔에 출력된 값 입력
- 로그인 성공하면 다시 /private/hello로 이동
- 이번엔 “로그인한 사용자만 접근 가능한 비공개 API 입니다.”라는 문자열이 뜸
이 한 번의 경험이 굉장히 중요하다.
“아, 컨트롤러 전에 시큐리티가 끼어들어서
인증 여부에 따라 컨트롤러 진입이 막히기도, 허용되기도 하는구나.”
이 감각이 잡혀야 다음 단계(커스텀 로그인, JWT 인증 등)가 덜 헷갈린다.
7. 그럼 이때 내부에서 진짜 어떤 일이 벌어지는지, 흐름을 조금 더 깊게 보자
조금 더 “밀도 있게” 들여다보자.
GET /private/hello 를 처음 호출했을 때의 흐름을 따라가 보면:
- 브라우저 → 서버에 GET /private/hello 요청
- 스프링 MVC로 가기 전에, SecurityFilterChain에 있는 필터들이 차례로 실행
- 그 중 인가(Authorization) 관련 필터가 다음을 판단한다.
- 이 URL(/private/hello)은 인증이 필요한 URL인가?
- 현재 SecurityContextHolder에 인증 정보가 있는가?
- 현재는 아직 로그인 안 한 상태 → SecurityContextHolder 비어 있음
- 인증이 필요한데 인증 정보가 없음 → 스프링 시큐리티가 직접 대응
- 예외를 던지고
- ExceptionTranslationFilter가 이 예외를 가로챈 다음
- AuthenticationEntryPoint를 통해 로그인 페이지로 리다이렉트 시킨다
- 브라우저는 /login 페이지를 띄운다.
여기까지가 “첫 번째 요청”.
로그인 폼 제출 후 흐름
- 사용자가 /login에 POST로 username/password 전송
- UsernamePasswordAuthenticationFilter가 이 요청을 가로챈다.
- 이 필터는 내부에서 AuthenticationManager를 호출하고,
그 안에서 AuthenticationProvider → UserDetailsService → DB 조회 흐름이 돌아간다.
(지금은 기본 인메모리 유저이기 때문에 우리가 구현하지 않아도 된다) - 인증 성공 시:
- Authentication 객체가 만들어지고
- SecurityContextHolder에 저장된다
- 이 정보는 보통 HTTP 세션에 함께 저장된다
- 로그인 성공 후, 원래 가려던 URL(/private/hello)로 다시 리다이렉트
- 두 번째 요청에서:
- 같은 /private/hello를 호출하지만
- 이번엔 SecurityContextHolder에 인증 정보가 이미 들어 있기 때문에
- “인증된 사용자”로 판단하고 컨트롤러 실행을 허용한다
지금은 이 내부 구성요소(UsernamePasswordAuthenticationFilter, AuthenticationManager, UserDetailsService 등)를
“아, 저런 것들이 어딘가에서 돌아가고 있구나” 정도만 느낌으로 잡아두면 된다.
다음 편에서 하나씩 꺼내서 직접 구현해 볼 거라 굳이 지금 완벽히 이해하려고 압박받을 필요 없다.
8. 주니어가 여기서 많이 던지는 질문들, 미리 정리
Q1. 기본 계정 user / 콘솔 비밀번호, 이거 실제 서비스에서도 쓰나요?
절대 안 된다.
이건 어디까지나 “학습용 임시 계정”일 뿐이다.
실서비스에서는:
- 우리가 직접 User 엔티티를 만들고
- 비밀번호를 BCrypt로 암호화해서 저장하고
- UserDetailsService를 구현해서 DB에서 유저를 조회해야 한다
이건 2편에서 낱낱이 뜯는다.
Q2. 왜 CSRF를 disable 했나요? 보안 위험 아닌가요?
정확히 말하면:
- 폼 로그인 + 세션 기반 웹 앱에서는
CSRF를 켜고 CSRF 토큰을 같이 써야 한다. - REST API + JWT 기반 구조에서는
CSRF 공격 벡터가 다르고, 대부분 CSRF를 끄고 대신 다른 방식(토큰, SameSite, CORS 등)으로 방어한다.
지금 우리는 “구조 이해 + 최소 예제”가 목표라서
일단 꺼두고, 나중에 필요한 시점에 다시 켜는 게 좋다.
Q3. WebSecurityConfigurerAdapter 쓰면 안 되나요?
스프링 부트 3.x / 스프링 시큐리티 최신 버전에서는
이미 deprecated → 제거 수순이라, 새 프로젝트에선 아예 안 쓰는 게 맞다.
- 예전 스타일:
- public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { ... } }
- 요즘 스타일:
- @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { ... }
이 글에서는 전부 “요즘 스타일”만 쓴다.
Q4. formLogin 말고, JSON 로그인이나 JWT 로그인은 언제 배우나요?
솔직히 이게 실무에서 더 많이 쓰인다.
근데 구조부터 안 잡고 JWT로 바로 들어가면,
“왜 이게 돌아가는지” 모른 채로 복붙만 하게 된다.
그래서 순서대로 갈 거다.
- 기본 formLogin으로 로그인 흐름 이해
- UserDetailsService, PasswordEncoder, AuthenticationManager 직접 구현
- 그 다음에야 JSON 로그인, JWT 토큰 기반으로 넘어간다
9. 이번 글에서 꼭 가져가야 할 핵심 정리
- 스프링 시큐리티는 “컨트롤러 앞에서 모두를 검사하는 필터 레이어”다.
- URL 별로 인증/인가 규칙은 authorizeHttpRequests로 설정한다.
- SecurityFilterChain을 Bean으로 등록하는 게 지금 기준의 정석이다.
- /public/** → permitAll(), 그 외 → authenticated()
이 조합만으로도 꽤 많은 걸 할 수 있다. - 기본 로그인 폼은 “구조를 이해하기 위한 학습용 도구”라고 생각하면 된다.
10. 다음 글에서 할 것 (예고)
2편에서는 드디어 진짜 “우리 서비스 유저”를 기준으로 로그인 흐름을 만들 거다.
- User 엔티티 설계 (테이블 구조)
- 비밀번호 BCrypt 암호화
- UserDetails, UserDetailsService 직접 구현
- AuthenticationManager가 이걸 어떻게 호출하는지 흐름 잡기
- “로그인 요청 → DB 조회 → 인증 성공 → 세션/컨텍스트 저장”까지 전 과정 따라가기
여기까지 오면, 시큐리티의 절반은 이해했다고 봐도 된다.
참고(출처)
- Spring Security Reference (공식 문서)
- Spring Boot Reference – Spring Security 섹션
- 예전에 내가 운영하던 사내 백엔드 교육 자료 + 실서비스 코드 구조 경험 기반
스프링시큐리티, springsecurity, springboot, 백엔드개발, 주니어개발자, 인증인가, securityfilterchain, formlogin, 웹보안, 개발블로그
'study > spring+spring-boot' 카테고리의 다른 글
| Spring 프레임워크에서 @Autowired를 대체하는 의존성 주입 방법 (0) | 2025.03.07 |
|---|---|
| [Spring] tiles 설정 (0) | 2024.06.11 |
| [SPRING BOOT] DatatypeConverter (0) | 2024.05.21 |
| [Spring boot] mariadb + jpa 대문자로 만들기 (0) | 2024.05.17 |
| [Spring] Study #1 (0) | 2023.02.06 |
- Total
- Today
- Yesterday
- 딥러닝
- 쿠버네티스
- llm
- kotlin
- Redis
- JAX
- REACT
- 백엔드개발
- CI/CD
- Next.js
- node.js
- fastapi
- flax
- 개발블로그
- 웹개발
- JWT
- seo 최적화 10개
- rag
- 프론트엔드개발
- Prisma
- nextJS
- PostgreSQL
- SEO최적화
- 압박면접
- DevOps
- Python
- Docker
- ai철학
- Express
- NestJS
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
