컨트롤러 코드는 그대로인데, starter 하나 넣었더니 모든 API가 갑자기 로그인 화면으로 막혀요.지난 글에서는 MongoDB와 Elasticsearch를 봤어요. 저장소를 고를 때도 “무엇이 더 빠른가요?”보다 “어떤 문제를 풀고 있나요?”를 먼저 물어봐야 했죠. Spring Security도 비슷해요. 처음에는 이런 식으로 보이기 쉬워요.
“@PreAuthorize 붙이고, 로그인 설정 조금 하면 되는 거 아닌가요?”
그런데 실제로는 로그인 Annotation보다 먼저 움직이는 것이 있어요. 바로 filter chain이에요.
Spring MVC 요청은 컨트롤러로 바로 들어오지 않아요. Servlet filter를 먼저 지나고, 그 안에서 Spring Security가 요청을 검사해요. 그래서 보안 문제를 읽을 때는 “이 컨트롤러에 어떤 Annotation이 붙었나?”보다 먼저 물어봐야 해요.
- 이 요청이 Spring Security filter chain을 지나나요?
- 어떤
SecurityFilterChain이 선택되나요? - 사용자가 누구인지 확인했나요?
- 확인된 사용자가 이 URL을 볼 권한이 있나요?
- 브라우저가 자동으로 보내는 cookie 때문에 CSRF 방어가 필요한가요?
- 다른 origin에서 오는 요청이라면 CORS는 어디서 허용하나요?
이 글은 Spring Boot 4.x의 Spring Security auto-configuration 문서와 Spring Security 7.x 계열의 Servlet architecture, request authorization, CSRF, password storage 문서를 기준으로 작성했어요. Spring Boot 3.x 프로젝트에서도 filter chain,
SecurityFilterChain, 인증과 인가 분리라는 핵심 모델은 거의 같은 방향으로 읽을 수 있어요.starter 하나가 들어오면 요청의 입구가 달라져요
아주 작은 API를 생각해볼게요.spring-boot-starter-security가 classpath에 들어오면 Spring Boot는 기본 보안 구성을 준비해요. 공식 문서 기준으로 web application은 기본적으로 보호되고, 기본 사용자는 user, 비밀번호는 실행 로그에 생성되어 출력돼요. 이 기본 비밀번호는 개발용이에요.
처음 보는 사람 입장에서는 갑자기 이렇게 느껴져요.
“왜 내가 만든 API가 잠겼죠? 컨트롤러는 바꾼 게 없는데요.”컨트롤러가 바뀐 게 아니에요. 컨트롤러 앞에 보안 filter chain이 생긴 거예요. 이 그림에서 중요한 점은 Spring Security가 컨트롤러 안쪽 기능이 아니라는 거예요. 요청이 Spring MVC에 도착하기 전에 Servlet filter 단계에서 먼저 움직여요. 그래서 보안 설정이 잘못되면 컨트롤러 method에 breakpoint를 걸어도 아예 도착하지 않을 수 있어요. 이때는 컨트롤러보다 filter chain을 먼저 봐야 해요.
FilterChainProxy가 어떤 보안 줄을 탈지 고르는 문지기예요
Spring Security의 Servlet 지원은 FilterChainProxy를 중심으로 읽을 수 있어요. FilterChainProxy는 요청을 받아서 현재 요청에 맞는 SecurityFilterChain을 고르고, 그 chain 안의 보안 filter들을 실행해요.
SecurityFilterChain은 하나만 있을 수도 있고 여러 개일 수도 있어요.
예를 들어 API와 관리자 화면을 다르게 보호하고 싶다고 해볼게요.
securityMatcher("/api/**")예요. /api/** 요청은 첫 번째 chain이 맡고, 나머지 web 요청은 두 번째 chain이 맡아요.
Spring Security 문서는 여러 SecurityFilterChain이 있을 때 처음 매칭된 chain만 실행된다고 설명해요. 이 말은 순서가 중요하다는 뜻이에요. 넓은 규칙이 앞에 있으면 뒤의 세밀한 규칙은 기회조차 못 얻을 수 있어요.
인증과 인가는 서로 다른 질문이에요
Spring Security를 처음 배울 때 가장 많이 섞이는 말이 인증과 인가예요.
로그인에 성공했다는 건 인증이 된 거예요. 하지만 인증된 사용자가 모든 관리자 API를 호출해도 된다는 뜻은 아니에요. 그건 인가가 결정해요.
예를 들어 이런 규칙을 볼게요.
Spring Security의 request authorization에서는
AuthorizationFilter가 authorizeHttpRequests에 적힌 pattern과 rule을 순서대로 보고, 처음 맞는 규칙을 적용해요.
그래서 순서가 중요해요.
/api/public/** 규칙은 의미가 없어질 수 있어요. 이미 anyRequest()가 먼저 잡아버렸기 때문이에요.
비밀번호는 저장하는 값이 아니라 비교할 수 있게 바꿔둔 값이에요
인증 이야기를 하면 결국 사용자와 비밀번호가 나와요. 여기서도 초보자가 자주 하는 위험한 생각이 있어요.“DB에 비밀번호를 저장했다가 로그인할 때 비교하면 되지 않나요?”비밀번호 원문을 저장하면 안 돼요. Spring Security 문서는
PasswordEncoder가 비밀번호를 안전하게 저장하기 위해 일방향 변환을 수행한다고 설명해요. 일방향이라는 말은 다시 원래 비밀번호로 복원하지 않는다는 뜻이에요.
현대적인 password storage에서는 bcrypt, PBKDF2, scrypt, argon2 같은 adaptive one-way function을 써요. 일부러 계산 비용을 들여서 공격자가 대량으로 추측하기 어렵게 만드는 방식이에요.
Spring Security에서는 보통 DelegatingPasswordEncoder를 많이 만나요.
CSRF는 “로그인했으니 안전하다”의 반대편에 있어요
CSRF(Cross-Site Request Forgery)는 처음 들으면 이름부터 멀게 느껴져요. 하지만 장면은 단순해요.- 사용자가
bank.example.com에 로그인해 있어요. - 브라우저는 그 사이트의 session cookie를 자동으로 보낼 수 있어요.
- 사용자가 악성 페이지를 열었어요.
- 악성 페이지가 사용자의 브라우저를 시켜
POST /transfer같은 요청을 보내게 해요. - 서버는 cookie만 보고 “로그인한 사용자 요청이네”라고 착각할 수 있어요.
POST 요청 등에 대해 CSRF 보호를 기본으로 제공해요. HTML form 기반 서비스라면 이 기본값이 중요한 보호막이에요.
이 그림에서 인증은 이미 되어 있을 수 있어요. 문제는 “정말 우리 화면에서 사용자가 의도한 요청인가요?”예요. CSRF token은 그 질문에 답하기 위한 장치예요.
REST API에서는 이야기가 조금 갈라져요.
그래서 “REST API니까 무조건 CSRF를 꺼요”라고 외우면 안 돼요. 브라우저가 인증 정보를 자동으로 붙이는 구조인지를 먼저 봐야 해요.
CORS는 보안 권한이 아니라 브라우저의 출처 규칙이에요
CORS도 Spring Security 글에서 자주 같이 나와요. 그런데 CORS는 로그인 권한과 같은 말이 아니에요. 예를 들어 frontend가https://app.example.com에서 열리고, API는 https://api.example.com에 있다고 해볼게요. 브라우저는 다른 origin으로 요청을 보낼 때 CORS 규칙을 확인해요.
서버가 “이 origin에서 오는 요청을 허용한다”고 응답하지 않으면 브라우저가 응답을 frontend JavaScript에 넘겨주지 않아요.
Spring Security를 쓰는 프로젝트에서는 CORS 처리가 security filter chain과 만나는 지점이 있어요. Preflight 요청인 OPTIONS가 인증 전에 거절되면 frontend에서는 “CORS 에러”처럼 보이지만 실제 원인은 보안 filter가 먼저 막은 것일 수 있어요.
CorsConfigurationSource 같은 곳에서 정해야 해요.
여기서 기억할 점은 하나예요.
CORS를 열었다고 권한 검사가 끝난 게 아니에요. 반대로 권한이 있어도 CORS 응답이 맞지 않으면 브라우저 frontend는 응답을 읽지 못할 수 있어요.
운영 설정은 “일단 다 막고 예외를 열기”가 기본이에요
처음 보안 설정을 만들 때 가장 위험한 코드는 보통 이런 모양이에요.- 공개해도 되는 endpoint를 먼저 명시해요.
- 관리자 endpoint처럼 더 좁은 권한을 먼저 명시해요.
- 나머지는 인증을 요구하거나 거부해요.
permitAll()에 추가해야 해요.
실무에서는 여기에 더 많은 질문이 붙어요.
막혔을 때는 컨트롤러보다 filter chain을 먼저 확인해요
Spring Security 문제는 증상이 비슷하게 보여요.- 401 Unauthorized가 나요.
- 403 Forbidden이 나요.
- 로그인 화면으로 redirect돼요.
- CORS 에러처럼 보여요.
- 컨트롤러 breakpoint가 안 걸려요.
Spring Security는 startup DEBUG log에서 구성된 filter 목록을 확인할 수 있고, 요청마다 security event를 더 자세히 찍도록 설정할 수도 있어요. 운영에서는 민감 정보가 로그에 섞이지 않게 조심해야 하지만, 로컬 디버깅에서는 filter chain을 보는 게 큰 단서가 돼요.
참고한 링크
- Spring Boot Reference - Spring Security
- Spring Security Reference - Servlet Architecture
- Spring Security Reference - Authorize HttpServletRequests
- Spring Security Reference - Cross Site Request Forgery
- Spring Security Reference - Password Storage
자, 정리해볼까요?
- Spring Security는 컨트롤러 안쪽이 아니라 요청 앞단의 filter chain에서 먼저 움직여요.
FilterChainProxy는 요청에 맞는SecurityFilterChain을 고르고, 처음 매칭된 chain만 실행해요.- 인증(authentication)은 “누구인가”이고, 인가(authorization)는 “이 일을 해도 되는가”예요.
- CSRF는 cookie 기반 브라우저 요청에서 특히 중요하고, CORS는 권한이 아니라 브라우저 origin 규칙이에요.
- 보안 설정은 공개 endpoint를 명시하고, 나머지를 닫는 방향으로 읽는 편이 안전해요.