지금 TaskBoard는 누구에게나 활짝 열려 있다. 한번 생각해 보자. localhost:3000에서 부르든, curl로 때리든, 지구 반대편 누군가가 우연히 주소를 알아내든, 들어오는 요청은 죄다 컨트롤러까지 무사통과한다. 할 일을 만들고, 고치고, 지우는 일을 아무나 할 수 있다. 내 할 일 목록을 옆자리 동료가 마음대로 지워 버려도 서버는 군말 없이 200을 돌려준다. 생각만 해도 아찔하지 않은가?
그렇다면 이런 의문이 자연스럽게 따라온다. “이 요청을 보낸 게 대체 누구이고, 그 사람이 이 일을 할 권한이 있나”를 서버는 어떻게 가려낼 수 있을까? 그리고 더 궁금한 건 이것이다. 그 검사를 어디서 해야 할까? 컨트롤러마다 맨 앞에 “너 누구야?”를 묻는 if 문을 박아 넣어야 하나? 그건 아무리 봐도 번거롭고 빠뜨리기 쉽다.
보안 코드를 짜기 전에, 먼저 그 멘탈 모델부터 세워 보자. 인증과 인가가 무엇이고 어떻게 다른지, 그리고 Spring Security가 요청을 어떤 방식으로 가로채는지. 이 둘만 손에 잡히면 다음 두 장(JWT·세션)이 훨씬 수월해진다. 실제 토큰이나 세션 구현은 잠시 미뤄 두자. 오늘은 그림을 그리는 날이다.
너 누구야, 그리고 그걸 해도 돼? — 인증과 인가
보안 이야기를 시작하면 가장 먼저 헷갈리는 두 단어가 있다. 인증(authentication)과 인가(authorization)다. 영어로 보면 둘 다 ‘auth’로 시작해서 더 헷갈린다. 하지만 가르는 기준은 의외로 단순하다.
회사 건물에 들어가는 상황을 그려 보자. 로비에서 경비원이 사원증을 보자고 한다. 사원증을 대서 “저는 이 회사 직원 김아무개입니다”가 확인되는 것, 이게 인증이다. “네가 누구인지”를 증명하는 단계다. 그런데 사원증이 통과됐다고 해서 모든 문이 열리는 건 아니다. 일반 직원 카드로는 서버실 문이 안 열린다. “김아무개라는 건 알겠는데, 너는 서버실에 들어갈 권한은 없어.” 이게 인가다. “네가 무엇을 할 수 있는지”를 따지는 단계다.
정리하면 이렇다. 인증은 신원 확인이고(“너 누구야?”), 인가는 권한 확인이다(“그걸 해도 돼?”). 순서도 자연스럽게 정해진다. 누구인지 모르면 무엇을 할 수 있는지도 따질 수 없으니, 인증이 먼저고 인가가 나중이다.
로그인한 사용자 A가 사용자 B의 할 일을 지우려다 막혔다. 어느 단계에서 막힌 걸까?
검사는 컨트롤러에 닿기 전에 — 필터체인이라는 새 그림
자, 이제 핵심 질문으로 돌아가자. 그 신원 확인과 권한 확인을 어디서 할까?
앞서 잠깐 떠올린 방법, 즉 컨트롤러마다 맨 앞에 검사 코드를 박는 방법을 다시 보자. 이건 5장에서 검증을 if 덩어리로 흩뿌리던 것과 똑같은 실수다. 엔드포인트가 늘어날수록 같은 검사가 복사되고, 어느 하나에서 빠뜨리는 순간 그게 곧 보안 구멍이 된다. 컨트롤러는 “할 일을 만든다”는 본업에만 집중해야 하는데, 매번 “너 누구야?”부터 묻느라 지저분해진다. 뒷맛이 영 찜찜하다.
그렇다면 어떻게 해야 할까? 검사를 컨트롤러 바깥으로, 그것도 요청이 컨트롤러에 닿기 전으로 끌어내면 된다. 이 지점에서 Spring Security의 핵심 개념이 등장한다. 필터체인(filter chain)이다.
이 그림은 프런트 개발자에게 그리 낯설지 않다. Next.js를 써 봤다면 middleware.ts를 떠올려 보자. 페이지나 API 라우트에 요청이 도달하기 전에 가로채서 “이 사람 로그인했나? 안 했으면 로그인 페이지로 돌려보내자”를 처리하던 그 파일 말이다. (Express의 app.use(authMiddleware)로 줄줄이 끼우던 미들웨어 체인도 같은 발상이다.) 요청이 최종 핸들러에 닿기 전에, 줄지어 선 미들웨어들이 차례로 요청을 만지고 통과시킬지 말지를 정한다.
middleware.ts / Express app.use(authMiddleware)SecurityFilterChain의 필터들ExceptionTranslationFilter가 401이나 403으로 바꾼다.Spring Security의 필터체인도 정확히 같은 모양이다. 요청 하나가 들어오면, 컨트롤러에 닿기 전에 줄지어 선 필터들이 차례로 요청을 가로챈다. 어떤 필터는 “이 요청에 인증 정보가 들어 있나”를 살피고, 어떤 필터는 “이 경로에 접근할 권한이 있나”를 따진다. 줄을 통과한 요청만 비로소 컨트롤러에 도착한다. 통과하지 못한 요청은 컨트롤러 근처에도 못 가고 401(인증 안 됨)이나 403(권한 없음)을 받고 되돌아간다.
요청 → [필터1] → [필터2] → [인증 필터] → [인가 필터] → ... → 컨트롤러
↑
여기서 막히면 컨트롤러는 구경도 못 한다
이 그림이 손에 잡히면 보안이 한결 덜 무섭다. Spring Security는 “마법 어노테이션 덩어리”가 아니라, 요청 앞에 쳐 둔 검문소들의 줄이다. 우리가 할 일은 그 검문소에 “무엇을 통과시키고 무엇을 막을지”를 일러 주는 것뿐이다. 다음 장에서 JWT를, 그다음 장에서 세션을 다룰 텐데, 그건 결국 “이 줄 어딘가에 어떤 검문소를 끼워 넣느냐”의 이야기다. 줄 자체의 모양은 지금 그려 둔 이 그림 그대로다.
ch09-1 Spring Security를 넣기만 해도 전부 잠긴다명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun시작 로그에 임시 비밀번호가 찍힌다. 띄울 때마다 바뀐다.
Using generated security password: 465feece-d912-4f7a-83cc-4bfec2b64080
This generated password is for development use only. Your security configuration must be updated before running your app
curl -i http://localhost:8080/api/tasksHTTP/1.1 401
WWW-Authenticate: Basic realm="Realm"
컨트롤러는 그대로인데 401이다. 요청이 컨트롤러에 닿기 전에 필터체인에서 막혔다. /api/users/summary도, 다른 모든 경로도 401이다. 기본값은 "일단 전부 잠근다"다.
사용자 이름 user와 로그에 찍힌 비밀번호로 다시 요청한다.
curl -i -u user:<생성된 비밀번호> http://localhost:8080/api/tasksHTTP/1.1 200
[{"id":1,"title":"JPA 익히기",…}]
브라우저로 http://localhost:8080/api/tasks를 열면 기본 로그인 폼(/login)으로 이동한다. 기본 설정은 폼 로그인과 Basic 인증을 둘 다 켠다.
최소 설정 — 무엇을 열고 무엇을 막을지만 선언하자
spring-boot-starter-security 의존성만 추가하고 설정은 한 줄도 안 썼다. 원래 잘 되던 GET /api/tasks는?
당황스러울 수 있지만, 이건 오히려 친절한 기본값이다. Security의 철학은 “일단 전부 잠그고, 열어 줄 것만 명시적으로 열어라”이기 때문이다. 안전한 쪽으로 기울어 있는 셈이다. 그러니 이제 잠긴 문 가운데 “무엇을 열고 무엇을 닫을지”를 직접 선언하면 된다.
그 선언을 담는 그릇이 SecurityFilterChain 빈이다. 아주 단순한 형태부터 보자.
package com.example.taskboard.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
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/tasks/**").authenticated()
.anyRequest().permitAll()
)
.httpBasic(basic -> {}); // 지금은 가장 단순한 기본 인증으로 감만 잡는다
return http.build();
}
}
코드가 짧으니 한 줄씩 같이 읽어 보자. 우리가 만드는 건 SecurityFilterChain이라는 빈 하나다. 4장에서 익힌 그 빈, 맞다. Security에게 “내 필터체인은 이렇게 구성해줘”라고 건네는 설계도라고 생각하면 된다.
authorizeHttpRequests는 “요청을 인가하는 규칙을 여기 적겠다”는 뜻이다. 그 안에서 requestMatchers("/api/tasks/**")로 경로를 집어 “이 경로로 오는 요청은”이라고 운을 떼고, .authenticated()로 “인증된 사용자만 통과시켜라”라고 못 박는다. 그리고 .anyRequest().permitAll()로 “그 밖의 나머지 요청은 다 열어 둬라”라고 마무리한다. 읽어 내려가면 거의 문장처럼 읽힌다. “할 일 API는 로그인한 사람만, 나머지는 누구나.”
두 규칙의 순서를 바꿔 .anyRequest().permitAll()을 먼저 쓰고 .requestMatchers("/api/tasks/**").authenticated()를 그 뒤에 쓰면?
마지막의 httpBasic은 지금 단계에서 “가장 단순한 인증 방식으로 일단 감만 잡자”는 뜻이다. 브라우저가 사용자 이름과 비밀번호를 물어 실어 보내는, 가장 원시적인 방식이다. 실전에서 쓸 방식은 아니다. 진짜 인증인 JWT와 세션은 다음 두 장의 몫이다. 여기서는 “필터체인에 인가 규칙을 선언한다는 게 이런 모양이구나”만 눈에 익히면 충분하다.
원문 보정 · 앱 보충이 설정으로
curl -u user:<비밀번호> -X POST localhost:8080/api/tasks …처럼 할 일을 만들어 보면, 비밀번호가 맞아도 403이 돌아온다. Spring Security는 CSRF 보호를 기본으로 켜 두어서, POST·PUT·DELETE처럼 상태를 바꾸는 요청에 CSRF 토큰이 없으면 인증 필터보다 앞선CsrfFilter에서 막는다. 이 장의 설정은 GET으로 확인하자. 10장에서 토큰 인증으로 바꾸면서 CSRF 보호를 끄는데(쿠키 없이 헤더로 토큰을 보내는 방식이라), CSRF가 어떤 공격이고 언제 꺼도 되는지는 11장에서 다시 만난다.
이 장의 SecurityFilterChain 설정 그대로입니다. 단, 403을 보려고 ADMIN 규칙 한 줄을 가정으로 더했습니다. 자격 증명은 Spring Boot가 만들어 주는 기본 사용자 user(권한 없음) 기준입니다.
SecurityFilterChain (실제로는 필터가 열 개 남짓, 이 장에 필요한 것만)
- CsrfFilter
- BasicAuthenticationFilter
- AnonymousAuthenticationFilter
- ExceptionTranslationFilter
- AuthorizationFilter
- DispatcherServlet → 컨트롤러
설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 앱 보충: 가정
.requestMatchers("/api/tasks/**").authenticated()
.anyRequest().permitAll()
)
.httpBasic(basic -> {});- 해볼 것
- 자격 증명 없이
/api/tasks를 불러 401을 받고, 401을 만든 필터 확인하기 - 틀린 비밀번호로, AuthorizationFilter에 닿기도 전에 401 받기
- permitAll 경로가 자격 증명 없이 필터체인을 끝까지 통과하는 것 보기
- 로그인한 사용자로 AuthorizationFilter의 403 받기
- 맞는 비밀번호인데 POST가 403으로 막히는 것 보기
ch09-2 무엇을 열고 무엇을 막을지 선언하기명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl -i http://localhost:8080/api/tasksHTTP/1.1 401
WWW-Authenticate: Basic realm="Realm"
WWW-Authenticate 헤더는 "Basic 방식으로 자격을 보내라"는 서버의 안내다. 폼 로그인을 켜지 않았으니 이제 브라우저로 열어도 /login으로 가지 않고 401이다.
열어 둔 경로는 인증 없이 된다.
curl -i http://localhost:8080/api/users/summary # 200생성된 비밀번호로 할 일 목록을 받는다.
curl -i -u user:<생성된 비밀번호> http://localhost:8080/api/tasks # 200같은 자격으로 할 일을 만들어 본다.
curl -i -u user:<생성된 비밀번호> -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "보안 뒤의 POST"}'HTTP/1.1 403
{"timestamp":"…","status":403,"error":"Forbidden","path":"/api/tasks"}
비밀번호가 맞는데 403이다. 「앱 보충」 9장 3절의 원문 보정 상자대로, Spring Security는 CSRF 보호를 기본으로 켠다. 상태를 바꾸는 요청(POST·PUT·PATCH·DELETE)에 CSRF 토큰이 없으면 막는다. 이 장의 확인은 GET으로 한다. 10장에서 토큰 인증으로 바꾸며 CSRF를 끈다.
더 보기: 요청이 거친 필터
application.yml의 logging.level 아래에 한 줄을 더하고, 위 POST를 다시 보낸다.
org.springframework.security: TRACEInvoking DisableEncodeUrlFilter (1/13)
Invoking WebAsyncManagerIntegrationFilter (2/13)
Invoking SecurityContextHolderFilter (3/13)
Invoking HeaderWriterFilter (4/13)
Invoking CorsFilter (5/13)
Invoking CsrfFilter (6/13)
Invalid CSRF token found for http://localhost:8080/api/tasks
13개 필터 중 6번째 CsrfFilter에서 멈췄다. Basic 인증을 확인하는 필터는 그 뒤에 있어서, 비밀번호가 맞는지 따져 보기도 전에 막힌 것이다. 이게 9장 2절의 그림이다.
요청 → [필터1] → [필터2] → … → [CsrfFilter] ✗ [인증 필터] → [인가 필터] → 컨트롤러
GET으로 보내 보면 13번째까지 모두 지나간다. 확인했으면 git checkout -- .으로 되돌린다.
AuthenticationEntryPoint(401)와 AccessDeniedHandler(403)ExceptionTranslationFilter가 정한다. 지금 사용자가 익명이면 401, 인증된 사용자면 403이다. 틀린 비밀번호처럼 인증 필터에서 실패하면 그 자리에서 바로 401이다.여기서 한 가지만 예고편처럼 흘려 두자. AI에게 “Spring Security 설정 코드를 짜줘”라고 하면, 십중팔구 WebSecurityConfigurerAdapter를 상속하는 구버전 코드를 자신 있게 내놓는다. 그건 Spring Security 6.x에서 아예 사라진 옛 패턴이다(이 책에서 쓰는 Spring Boot 3.x에는 이 6.x가 들어 있다). 위에서 쓴 SecurityFilterChain 빈 방식이 지금 표준이다. 이 신선도 함정을 before/after로 정면에서 터뜨려 잡는 건 다음 장, JWT를 구현하면서다. 거기가 진짜 클라이맥스다. 오늘은 “AI가 주는 Security 코드는 특히 더 의심하고 봐야 한다”는 예고만 마음에 담아 두면 된다.
비밀번호는 절대 날것으로 두지 말자 — BCrypt
인증 이야기가 나온 김에, 코드는 아직 안 짜더라도 개념 하나는 미리 심어 두자. 사용자를 인증하려면 결국 비밀번호를 어딘가에 저장해 둬야 한다. 그런데 비밀번호를 입력받은 그대로, 즉 날것의 평문으로 데이터베이스에 저장하면 어떻게 될까?
끔찍한 일이다. 만에 하나 DB가 통째로 유출되면, 모든 사용자의 비밀번호가 그대로 적의 손에 들어간다. 게다가 사람들은 같은 비밀번호를 여기저기서 돌려쓰는 경우가 많으니, 우리 서비스의 유출이 그 사람의 다른 계정까지 줄줄이 무너뜨린다. 상상만으로도 아찔하다.
그래서 비밀번호는 절대 날것으로 저장하지 않는다. 대신 한 방향으로만 뭉개 버리는 함수를 통과시켜 알아볼 수 없는 형태로 만든 뒤 저장한다. 이걸 해싱이라 하고, Spring Security 진영에서 비밀번호 해싱의 사실상 표준으로 쓰는 게 BCrypt다. BCrypt의 영리한 점은, 일부러 계산을 느리게 만들어 무차별 대입 공격을 어렵게 한다는 데 있다. 빠른 게 미덕인 세상에서 일부러 느린 함수라니 묘하지만, 보안에서는 그 느림이 곧 방패다.
쓰는 모양도 어렵지 않다. PasswordEncoder라는 빈을 하나 등록해 두고, 비밀번호를 저장할 때 인코딩하는 일과 로그인할 때 들어온 비밀번호를 대조하는 일을 이 인코더에게 맡기면 된다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
원문 보정 · 앱 보충이 빈을 3절의
SecurityConfig에 넣는 순간, 지금까지 쓰던 기본 사용자user의 생성된 비밀번호로는 로그인되지 않는다(401). Spring Boot는PasswordEncoder빈이 없을 때만 생성된 비밀번호에 평문 표시({noop})를 붙여 두는데, BCrypt 인코더가 생기면 그 평문을 BCrypt 해시로 여겨 대조하다 실패하기 때문이다(로그에Encoded password does not look like BCrypt경고가 남는다). 3절의curl확인은 이 빈을 넣기 전에 해 두자.
지금은 이 인코더를 본격적으로 쓰진 않는다. 다음 장에서 로그인을 구현할 때 비로소 활약한다. 다만 “비밀번호는 해싱해서 저장한다, 그 도구가 BCrypt다” 이 한 문장만은 지금 단단히 새겨 두자. 입문자가 가장 흔히 저지르는 보안 실수가 비밀번호를 평문으로 다루는 일이다. 이 함정만 피해도 절반은 한 셈이다.
같은 비밀번호 pass1234를 passwordEncoder.encode()로 두 번 인코딩하면?
ch09-3 비밀번호는 BCrypt로명령과 기대 출력 펼치기
실행해 보기
./gradlew test --tests '*PasswordEncoderTest' -i --rerun출력에 해시 두 개가 찍힌다. 같은 비밀번호인데 다르다.
$2a$10$YFhUzIt0y1u1.TkoeQ99gOPXu4hM6mcCCAyLiOkMdVDKLQZy9KELi
$2a$10$Is0MGieKZG8DtCq5siwWU.fzcE7H5Rp/Fwr70pqqrTrgGmy6f9Ane
인코딩할 때마다 무작위 솔트를 섞기 때문이다. $2a$는 알고리즘, 10은 비용(반복 횟수의 지수), 그 뒤 22자가 솔트다. 솔트가 해시 안에 들어 있어서 matches("pass1234", 해시)는 둘 다 true다. 그래서 DB에는 해시만 저장하고, 로그인할 때 matches로 대조한다.
이제 서버를 띄워 ch09-2의 확인을 다시 해 본다.
./gradlew bootRun
curl -i -u user:<생성된 비밀번호> http://localhost:8080/api/tasksHTTP/1.1 401
비밀번호가 맞는데 401이다. 서버 로그에는 경고가 남는다.
Encoded password does not look like BCrypt
「앱 보충」 9장 4절의 원문 보정 상자대로다. 생성된 비밀번호는 평문으로 저장돼 있다. PasswordEncoder 빈이 없을 때는 Spring Boot가 평문 표시({noop})를 붙여 두지만, BCrypt 인코더가 생기면 그 평문을 BCrypt 해시로 여겨 대조하다 실패한다.
이 단계부터 10장 로그인을 만들기 전까지는 /api/tasks에 들어갈 방법이 없다. 정상이다. 9장의 curl -u 확인은 ch09-2에서 한다.
마무리
오늘은 코드를 거의 짜지 않았다. 대신 앞으로 두 장을 버틸 그림 두 장을 손에 넣었다. 하나는 인증과 인가의 구분이다. “너 누구야?”(신원)와 “그걸 해도 돼?”(권한)는 다른 질문이다. 다른 하나는 필터체인이다. Spring Security는 요청이 컨트롤러에 닿기 전에 줄지어 가로채는 검문소들의 줄이고, 그 모양은 Next.js·Express 미들웨어 체인과 같다. 그 검문소에 “무엇을 열고 무엇을 막을지”를 일러 주는 그릇이 SecurityFilterChain 빈이고, 비밀번호는 BCrypt로 해싱해 저장한다는 원칙까지 미리 심었다.
기억해 두자. 보안이 무섭게 느껴지는 건 대개 “요청이 어디서 어떻게 검사되는지”가 안 보이기 때문이다. 그 흐름을 그림으로 그려 두면, 그다음부터는 “이 줄 어디에 어떤 필터를 끼울까”의 문제로 단순해진다.
다음 장에서는 드디어 그 줄에 진짜 인증 필터를 끼운다. 로그인한 사용자를 서버가 상태 없이(stateless) 매 요청마다 어떻게 알아보는지, JWT로 직접 구현해 본다. 그리고 그 과정에서 오늘 살짝 예고한 신선도 함정을 정면으로 마주해 6.x 방식으로 고쳐 낸다. 이 책 전체를 관통하는 신선도 서사의 클라이맥스다. 그럼, 오늘 그린 그림을 들고 다음 장으로 넘어가 보자.
1. 인증과 인가 중 먼저 일어나는 것은?
2. 이 장의 설정에서 틀린 비밀번호로 GET /api/tasks를 부르면 401을 내는 곳은?
3. 로그인한 사용자가 권한 없는 경로를 부르면?
4. DB에 저장하는 비밀번호는?
이 장의 실습은 단계마다 커밋으로 준비해 두었고, 각 단계는 본문의 해당 자리에 있습니다. 처음이라면 레포부터 받습니다.
git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
cd toby-react-to-spring-taskboard
git checkout ch09-1ch09-1Spring Security를 넣기만 해도 전부 잠긴다ch09-2무엇을 열고 무엇을 막을지 선언하기ch09-3비밀번호는 BCrypt로