자바 아키텍처·Spring 학습 노트 목차

Spring Security — 요청이 컨트롤러에 닿기 전에

인증과 인가를 컨트롤러마다 직접 코딩한다고 상상해 보자. 메서드 하나만 깜빡 빠뜨려도 무방비로 뚫린 구멍이 된다. 그래서 Spring Security는 발상을 바꾼다 — 보안을 컨트롤러 안이 아니라, 요청이 컨트롤러에 닿기 전에 거치는 길목에서 일괄 처리하는 것이다. 그 길목이 바로 보안 필터 체인이다. 이 편은 "누구인가(인증)"와 "무엇을 할 수 있나(인가)"를 어떻게 나눠 처리하는지, 그리고 그 순서와 내부가 어떻게 도는지를 끝까지 본다.

컨트롤러 앞의 관문 — 필터 체인의 내부

모든 HTTP 요청은 서블릿 필터들을 지나 컨트롤러로 간다. Spring Security는 그 길목에 자기만의 보안 필터들을 끼워 넣는다.

다이어그램 로딩 중…

서블릿 컨테이너(Tomcat)와 Spring 빈 세계를 잇는 **DelegatingFilterProxy**가 springSecurityFilterChain이라는 빈 — 곧 FilterChainProxy — 을 찾아 위임하면, 그 프록시가 순서대로 정렬된 보안 필터들을 차례로 실행한다. 그 순서가 정해져 있는데, 대략 SecurityContext 복원 → HeaderWriter → CsrfFilter → Logout → 인증 필터(UsernamePasswordAuthenticationFilter 등) → ExceptionTranslationFilter → AuthorizationFilter 순이다. 여기서 순서가 핵심인데, 무엇보다 인증이 인가보다 먼저여야 한다 — 누구인지 모르면 무슨 권한이 있는지 따질 수가 없기 때문이다. 그리고 보안 규칙이 여러 벌이면 요청에 *처음 매칭되는 한 벌(SecurityFilterChain)*만 동작한다. 사실 이 필터 체인 구조 자체가 jdp의 책임 연쇄(Chain of Responsibility) 패턴이라, 커스텀 필터addFilterBefore원하는 위치에 끼워 확장한다. (출처: Spring Security — Architecture.)

먼저, 누구인가 — 인증

체인의 앞쪽에서 인증이 일어난다. 그 흐름은 이렇다.

다이어그램 로딩 중…

인증 필터가 요청에서 자격증명(아이디·비밀번호나 JWT)을 꺼내 **AuthenticationManager**에 넘긴다. 매니저는 실제 검증을 AuthenticationProvider(여러 개일 수 있다)에게 위임하고, 프로바이더는 UserDetailsService로 사용자를 조회해 자격을 확인한다 — 이때 실제 비밀번호 일치 검증PasswordEncoder가 한다. 검증에 성공하면 주체와 권한을 담은 **Authentication**이 **SecurityContext**에 저장된다. 이 SecurityContext를 SecurityContextHolder가 **ThreadLocal**로 보관해(jcode의 그 ThreadLocal) 같은 요청 스레드 어디서나 현재 사용자를 꺼낼 수 있다 — 다만 관측성 편의 MDC처럼, 비동기·스레드 풀로 넘어가면 전파되지 않아 사용자가 사라질 수 있으니 별도 전파 설정이 필요하다.

비밀번호 검증에서 한 가지 깊게 볼 게 있다. 비밀번호는 느린 해시로 저장한다고 했는데(jcode 사고), 왜 느려야 안전할까? BCrypt의 *work factor(cost)*는 해시 반복을 2^cost번 하게 만든다 — cost를 1 올릴 때마다 시간이 두 배로 늘어난다(보통 12~14면 한 번에 250ms~1s). 정상 로그인은 한 번이라 괜찮지만, 유출된 해시를 무차별 대입하려는 공격자에겐 한 번에 1초가 치명적이라 — 느림이 곧 방어다. Spring은 DelegatingPasswordEncoder해시 앞의 {bcrypt} 식별자를 보고 알고리즘을 판별·전환한다. (출처: BCrypt work factor, OWASP Password Storage.)

그다음, 무엇을 할 수 있나 — 인가와 예외

인증이 끝나 누구인지가 확정되면, 체인 끝의 **AuthorizationFilter**가 그 사람이 이 자원에 접근할 권한이 있는지를 따진다.

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers("/public/**").permitAll()
    .anyRequest().authenticated());

URL 기준 외에 메서드 단위로도 건다 — @PreAuthorize("hasRole('ADMIN')")(실행 전), @PostAuthorize(반환값 기준). 여기서 인증·인가 실패의 갈림이 중요한데, 그 사이의 **ExceptionTranslationFilter**가 둘을 구분해 위임한다 — 미인증이면 AuthenticationEntryPoint로 보내 401(로그인 유도)을, 인증은 됐으나 권한 부족이면 AccessDeniedHandler로 보내 403을 낸다. "401은 누구인지 모름, 403은 알지만 권한 없음"으로 외우면 된다.

사용자를 어떻게 기억하나 — 세션 vs JWT, 그리고 그 검증

인증을 마친 사용자를 다음 요청에서 어떻게 기억하느냐가 다음 갈림길이다. 전통적으로는 서버에 세션을 저장해 두고 쿠키로 잇지만, 서비스를 여러 인스턴스로 확장하면 세션 공유가 골치다. 그래서 분산에서는 stateless JWT가 정석이 된다 — 토큰 자체에 정보가 있어 각 노드가 서명만 검증하면 되니 세션 공유가 필요 없다.

검증의 내부를 보면 Spring Boot의 OAuth2 Resource Server가 무엇을 하는지 분명해진다. JwtDecoder가 — 인가 서버의 JWKS(JSON Web Key Set) 엔드포인트에서 받아 둔 공개키로 토큰의 서명을 검증하고, *만료(exp)·유효 시작(nbf)·발급자(iss)*를 확인하며, 토큰의 scope를 SCOPE_ 접두어가 붙은 권한으로 매핑한다(JwtAuthenticationProvider가 이를 수행). 그래서 우리는 커스텀 필터를 직접 짤 필요 없이 설정만으로 JWT 검증을 얻는다. 설정의 핵심은 SessionCreationPolicy.STATELESS(세션 미생성) + CSRF off다. (출처: Spring Security — OAuth2 Resource Server JWT.)

CSRF는 왜 끄고, 언제 켜나

CSRF를 끄는 게 위험해 보이지만 상황에 달렸다. CSRF 공격브라우저가 세션 쿠키를 자동으로 실어 보내는 성질을 노린 것이다 — 공격 사이트가 "송금" 요청을 몰래 만들어도 피해자의 쿠키가 자동으로 붙어 서버가 속는다. Spring은 *동기화 토큰 패턴(synchronizer token)*으로 막는다 — 서버가 예측 불가한 CSRF 토큰을 폼·헤더로 내려주고, 상태 변경 요청에 그 토큰이 함께 와야 수락한다. 공격자는 그 토큰 값을 알 수 없어 유효한 요청을 만들지 못한다. (출처: Spring Security CSRF.) 그래서 *세션+쿠키 기반(브라우저 폼)*이면 켜고, stateless JWT API면 — 세션 쿠키를 안 쓰니 막을 대상이 없어 끈다. "무조건 끈다"가 아니다.

여기에 마지막 원칙을 더한다 — 인가는 *기본을 거부(deny by default)*로. anyRequest().authenticated()명시하지 않은 경로를 막아, 새 엔드포인트가 무방비로 열리는 실수를 원천 차단한다.

정리하면, Spring Security는 보안을 컨트롤러 앞 필터 체인에서 일괄 처리한다(DelegatingFilterProxyFilterChainProxy→순서 있는 필터들, 책임 연쇄). 먼저 인증으로 누구인지 확정해 SecurityContext(ThreadLocal)에 담고(비밀번호는 BCrypt의 느림이 곧 방어), 그다음 인가로 권한을 따지며(URL·메서드, 실패는 401/403으로 갈림), 사용자를 기억하는 방식은 분산이면 stateless JWT(OAuth2 Resource Server가 JWKS 공개키로 서명·exp·iss 검증) + STATELESS + CSRF off를 고른다 — 그 위에 CSRF는 상황 판단, 인가는 deny-by-default라는 원칙을 얹는다.

웹 — MVC·REST·검증·예외·WebSocket/STOMPJDBC — 자바가 DB와 말하는 가장 낮은 층