Spring Security와 인증/인가 완벽 가이드: 필터 체인부터 JWT, OAuth2까지

Spring Security와 인증/인가 완벽 가이드: 필터 체인부터 JWT, OAuth2까지

이전 글에서 Spring Boot의 자동 구성과 빈 생명주기, AOP 프록시의 동작 원리를 다루었다. 이번 글에서는 Spring 생태계에서 가장 복잡하면서도 면접에서 가장 자주 출제되는 주제인 Spring Security를 깊이 있게 정리한다.

“로그인을 구현하세요”는 단순해 보이지만, 그 이면에는 인증(Authentication)과 인가(Authorization)의 분리, Security Filter Chain의 동작 순서, SecurityContext와 ThreadLocal의 관계, JWT와 Session의 아키텍처적 트레이드오프, CSRF/CORS 방어 메커니즘 등 수많은 개념이 얽혀 있다. 이 글은 Spring Security의 내부 아키텍처부터 JWT 구현, OAuth2 흐름까지를 코드와 다이어그램으로 정리한다.


1. 인증(Authentication)과 인가(Authorization)

1.1 정의와 차이

이 두 개념을 혼용하는 경우가 많지만, 본질적으로 다른 질문에 답한다.

1
2
3
4
5
6
7
인증 (Authentication) — "너 누구야?"
  → 사용자의 신원을 확인하는 과정
  → 로그인, 토큰 검증, 인증서 확인

인가 (Authorization) — "너 이거 해도 돼?"
  → 인증된 사용자가 특정 자원에 접근할 권한이 있는지 확인하는 과정
  → 관리자만 접근 가능, ROLE_ADMIN 검사
1
2
3
4
5
6
7
8
9
10
11
12
실생활 비유:

  공항 보안 검색:
  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
  │  여권 확인     │ ──► │  탑승권 확인    │ ──► │  비행기 탑승   │
  │  (인증)       │     │  (인가)        │     │  (자원 접근)   │
  │  "신원 확인"   │     │  "이 비행기에    │     │              │
  │              │     │   탈 권한 확인"  │     │              │
  └──────────────┘     └──────────────┘     └──────────────┘

  인증 실패: "여권이 위조되었습니다" → 401 Unauthorized
  인가 실패: "일등석 탑승권이 아닙니다" → 403 Forbidden
구분 인증 (Authentication) 인가 (Authorization)
질문 누구인가? 무엇을 할 수 있는가?
시점 먼저 수행 인증 후 수행
실패 시 401 Unauthorized 403 Forbidden
Spring Security AuthenticationManager AccessDecisionManager
예시 로그인, JWT 검증 ROLE_ADMIN 검사

HTTP 상태코드 주의: 401 Unauthorized는 이름과 달리 “인증 실패”를 의미한다. 인가 실패는 403 Forbidden이다. 면접에서 이 차이를 물어보는 경우가 많다.


2. Spring Security 아키텍처

2.1 전체 구조

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
32
33
34
35
Spring Security 아키텍처 개관:

  HTTP 요청
      │
      ▼
  ┌─────────────────────────────────────────────────────────┐
  │                   Servlet Container (Tomcat)             │
  │                                                         │
  │  ┌───────────────────────────────────────────────────┐  │
  │  │              DelegatingFilterProxy                 │  │
  │  │  (Servlet Filter → Spring Bean에 위임)              │  │
  │  └───────────────────┬───────────────────────────────┘  │
  │                      │                                   │
  │  ┌───────────────────▼───────────────────────────────┐  │
  │  │           FilterChainProxy                         │  │
  │  │  (SecurityFilterChain 관리)                        │  │
  │  │                                                   │  │
  │  │  ┌─────────────────────────────────────────────┐  │  │
  │  │  │         SecurityFilterChain                  │  │  │
  │  │  │                                             │  │  │
  │  │  │  [SecurityContextPersistenceFilter]          │  │  │
  │  │  │  [CsrfFilter]                              │  │  │
  │  │  │  [LogoutFilter]                             │  │  │
  │  │  │  [UsernamePasswordAuthenticationFilter]      │  │  │
  │  │  │  [BasicAuthenticationFilter]                │  │  │
  │  │  │  [BearerTokenAuthenticationFilter]          │  │  │
  │  │  │  [ExceptionTranslationFilter]               │  │  │
  │  │  │  [FilterSecurityInterceptor / AuthorizationFilter] │  │
  │  │  └─────────────────────────────────────────────┘  │  │
  │  └───────────────────────────────────────────────────┘  │
  │                      │                                   │
  │                      ▼                                   │
  │              DispatcherServlet                           │
  │              (@Controller 호출)                          │
  └─────────────────────────────────────────────────────────┘

2.2 DelegatingFilterProxy와 FilterChainProxy

Spring Security는 Servlet Filter 기반으로 동작한다. 그런데 Servlet Filter는 Spring Bean이 아니므로 Spring의 DI를 받을 수 없다. 이 문제를 DelegatingFilterProxy가 해결한다.

1
2
3
4
5
6
7
8
9
10
11
12
DelegatingFilterProxy의 역할:

  Servlet Container (Spring 밖)              Spring Container (Spring 안)
  ┌──────────────────────────┐            ┌────────────────────────┐
  │                          │            │                        │
  │  DelegatingFilterProxy   │ ──위임──►  │  FilterChainProxy      │
  │  (Servlet Filter)        │            │  (Spring Bean)         │
  │                          │            │                        │
  │  "springSecurityFilter   │            │  SecurityFilterChain   │
  │   Chain"이라는 이름의      │            │  들을 관리             │
  │   Spring Bean에 위임      │            │                        │
  └──────────────────────────┘            └────────────────────────┘

FilterChainProxy는 URL 패턴에 따라 적절한 SecurityFilterChain을 선택한다. 여러 SecurityFilterChain을 등록하여 URL별로 다른 보안 정책을 적용할 수 있다.

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
32
33
34
35
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // /api/** 요청에 대한 보안 설정 (JWT 기반)
    @Bean
    @Order(1)
    public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    // 그 외 요청에 대한 보안 설정 (세션 기반)
    @Bean
    @Order(2)
    public SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login", "/css/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/home"))
            .build();
    }
}

2.3 주요 Security Filter 순서와 역할

필터는 순서가 매우 중요하다. Spring Security는 약 15개의 기본 필터를 정해진 순서대로 실행한다.

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
Security Filter 실행 순서 (주요 필터):

  요청 →

  ① SecurityContextHolderFilter
     → SecurityContext를 SecurityContextHolder에 로드/저장

  ② CsrfFilter
     → CSRF 토큰 검증 (POST, PUT, DELETE 요청)

  ③ LogoutFilter
     → /logout 요청 처리, 세션 무효화, 쿠키 삭제

  ④ UsernamePasswordAuthenticationFilter
     → /login POST 요청에서 username/password 추출 → 인증 시도

  ⑤ BasicAuthenticationFilter
     → Authorization: Basic 헤더에서 인증 정보 추출

  ⑥ BearerTokenAuthenticationFilter (또는 커스텀 JWT 필터)
     → Authorization: Bearer 토큰 검증

  ⑦ AuthorizationFilter (Spring Security 6+)
     → URL/메서드 기반 인가 검사

  ⑧ ExceptionTranslationFilter
     → 인증/인가 예외를 HTTP 응답으로 변환 (401, 403)

  → DispatcherServlet

3. 인증 처리 흐름

3.1 AuthenticationManager와 AuthenticationProvider

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
32
33
34
인증 처리 아키텍처:

  UsernamePasswordAuthenticationFilter
      │
      │ UsernamePasswordAuthenticationToken (미인증)
      │   - principal: "username"
      │   - credentials: "password"
      ▼
  AuthenticationManager (ProviderManager)
      │
      │ 등록된 AuthenticationProvider를 순회
      ▼
  ┌─────────────────────────────────────────────┐
  │ AuthenticationProvider (예: DaoAuthenticationProvider)  │
  │                                             │
  │  ① UserDetailsService.loadUserByUsername()   │
  │     → DB에서 사용자 정보 조회                   │
  │                                             │
  │  ② PasswordEncoder.matches()                │
  │     → 비밀번호 검증                            │
  │                                             │
  │  ③ 성공 시: Authentication 객체 반환 (인증됨)   │
  │     실패 시: AuthenticationException 발생      │
  └─────────────────────────────────────────────┘
      │
      ▼
  UsernamePasswordAuthenticationToken (인증 완료)
    - principal: UserDetails 객체
    - credentials: null (보안상 비밀번호 제거)
    - authorities: [ROLE_USER, ROLE_ADMIN]
      │
      ▼
  SecurityContextHolder.getContext().setAuthentication(authentication)
  → ThreadLocal에 저장

3.2 UserDetailsService 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(
                        "사용자를 찾을 수 없습니다: " + username));

        return User.builder()
                .username(member.getEmail())
                .password(member.getPassword())  // BCrypt 해시
                .roles(member.getRole().name())   // "ADMIN" → ROLE_ADMIN
                .build();
    }
}

3.3 PasswordEncoder — 비밀번호 해싱

1
2
3
4
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
BCrypt 해싱 과정:

  평문: "mypassword123"

  BCrypt 해시: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
               ↑   ↑   ↑                                    ↑
               │   │   │                                    │
          알고리즘 코스트 솔트(22자)                          해시값
          (2a)  (10)  (랜덤)

  특징:
  - 같은 평문이라도 솔트가 다르면 다른 해시 → 레인보우 테이블 공격 무효화
  - 코스트 팩터로 해싱 속도 조절 (10 = 2^10 = 1024 라운드)
  - 단방향: 해시 → 평문 복원 불가능

  검증:
  matches("mypassword123", "$2a$10$N9qo8u...") → true
  → 입력된 평문을 저장된 솔트로 해싱하여 저장된 해시와 비교

면접에서 “비밀번호를 왜 해싱하나요?”라는 질문에:

DB가 유출되더라도 원본 비밀번호를 알 수 없어야 합니다. 단순 해시(SHA-256)는 레인보우 테이블 공격에 취약하므로 BCrypt처럼 솔트 + 반복 해싱을 사용합니다. BCrypt는 코스트 팩터로 해싱 속도를 조절할 수 있어 GPU 브루트포스 공격에도 강합니다.


4. SecurityContext와 ThreadLocal

4.1 SecurityContextHolder의 구조

인증 정보는 ThreadLocal에 저장된다. 같은 요청을 처리하는 동안, 어디서든 현재 인증된 사용자 정보에 접근할 수 있다.

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
32
SecurityContextHolder 구조:

  Thread-1 (요청 A 처리)
  ┌─────────────────────────────────────────────┐
  │  ThreadLocal<SecurityContext>                │
  │  ┌───────────────────────────────────────┐  │
  │  │  SecurityContext                      │  │
  │  │  ┌─────────────────────────────────┐  │  │
  │  │  │  Authentication                 │  │  │
  │  │  │    principal: UserDetails("홍길동")│  │  │
  │  │  │    authorities: [ROLE_USER]     │  │  │
  │  │  │    authenticated: true          │  │  │
  │  │  └─────────────────────────────────┘  │  │
  │  └───────────────────────────────────────┘  │
  └─────────────────────────────────────────────┘

  Thread-2 (요청 B 처리)
  ┌─────────────────────────────────────────────┐
  │  ThreadLocal<SecurityContext>                │
  │  ┌───────────────────────────────────────┐  │
  │  │  SecurityContext                      │  │
  │  │  ┌─────────────────────────────────┐  │  │
  │  │  │  Authentication                 │  │  │
  │  │  │    principal: UserDetails("김영희")│  │  │
  │  │  │    authorities: [ROLE_ADMIN]    │  │  │
  │  │  │    authenticated: true          │  │  │
  │  │  └─────────────────────────────────┘  │  │
  │  └───────────────────────────────────────┘  │
  └─────────────────────────────────────────────┘

  각 스레드가 독립적인 SecurityContext를 가짐
  → 요청 A의 사용자 정보가 요청 B에 영향 없음

4.2 현재 인증 정보 접근

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 방법 1: SecurityContextHolder에서 직접
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();

// 방법 2: @AuthenticationPrincipal (권장)
@GetMapping("/me")
public ResponseEntity<UserResponse> me(
        @AuthenticationPrincipal UserDetails userDetails) {
    return ResponseEntity.ok(new UserResponse(userDetails.getUsername()));
}

// 방법 3: 커스텀 UserDetails와 @AuthenticationPrincipal
@GetMapping("/me")
public ResponseEntity<UserResponse> me(
        @AuthenticationPrincipal CustomUserDetails user) {
    return ResponseEntity.ok(new UserResponse(user.getMemberId(), user.getEmail()));
}

4.3 SecurityContext의 생명주기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
세션 기반 인증에서의 SecurityContext 흐름:

  요청 시작
      │
  ① SecurityContextHolderFilter
     → HttpSession에서 SecurityContext 로드
     → ThreadLocal에 저장
      │
  ② 컨트롤러/서비스에서 SecurityContextHolder.getContext() 사용
      │
  ③ 요청 완료
     → SecurityContext를 HttpSession에 저장
     → ThreadLocal 정리 (clear)

  JWT 기반 (Stateless)에서:
  ① JWT 필터에서 토큰 검증 → SecurityContext 생성 → ThreadLocal에 저장
  ② 컨트롤러에서 사용
  ③ 요청 완료 → ThreadLocal 정리 (세션에 저장 안 함)

5. 세션(Session) 기반 인증

5.1 동작 흐름

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
세션 기반 인증 흐름:

  Client                    Server                    Session Store
    │                         │                           │
    │ POST /login             │                           │
    │ {email, password}       │                           │
    │ ─────────────────►      │                           │
    │                         │ 인증 성공                   │
    │                         │ 세션 생성                   │
    │                         │ ──────────────────────►    │
    │                         │ JSESSIONID=abc123          │
    │                         │                           │
    │ Set-Cookie:             │                           │
    │ JSESSIONID=abc123       │                           │
    │ ◄─────────────────      │                           │
    │                         │                           │
    │ GET /api/data           │                           │
    │ Cookie: JSESSIONID=     │                           │
    │ abc123                  │                           │
    │ ─────────────────►      │ 세션 조회                   │
    │                         │ ──────────────────────►    │
    │                         │ 사용자 정보 반환             │
    │                         │ ◄──────────────────────    │
    │                         │                           │
    │ 200 OK + 데이터          │                           │
    │ ◄─────────────────      │                           │

5.2 세션의 장단점

장점 단점
즉시 무효화 가능 (로그아웃, 강제 탈퇴) Stateful — 서버가 상태를 관리
구현이 단순 (Spring 기본 지원) 다중 서버 시 세션 공유 필요 (Redis 등)
민감 정보가 서버에만 존재 세션 저장소 장애 시 전체 인증 실패
CSRF 방어가 가능 서버 메모리/저장소 사용

6. JWT (JSON Web Token) 기반 인증

6.1 JWT의 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
JWT 구조 (Header.Payload.Signature):

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3MTA0MDAwMDAsImV4cCI6MTcxMDQwMzYwMH0.abc123signature

┌──────────────────┬──────────────────────────┬──────────────────┐
│     Header        │       Payload            │    Signature     │
│  (Base64 인코딩)   │    (Base64 인코딩)         │  (HMAC/RSA 서명) │
├──────────────────┼──────────────────────────┼──────────────────┤
│ {                │ {                        │ HMACSHA256(      │
│   "alg":"HS256", │   "sub":"user@...",      │   base64(header) │
│   "typ":"JWT"    │   "role":"USER",         │   + "." +        │
│ }                │   "iat":1710400000,      │   base64(payload)│
│                  │   "exp":1710403600       │   , secret)      │
│                  │ }                        │                  │
└──────────────────┴──────────────────────────┴──────────────────┘
         │                    │                       │
    암호화 알고리즘       클레임(사용자 정보)         위변조 검증용

6.2 Access Token + Refresh Token 전략

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
32
33
34
Access Token + Refresh Token 흐름:

  Client                        Server                      Redis
    │                              │                           │
    │ POST /auth/login             │                           │
    │ ──────────────────►          │                           │
    │                              │                           │
    │ Access Token (15분)           │                           │
    │ Refresh Token (7일)          │                           │
    │ ◄──────────────────          │ Refresh Token 저장         │
    │                              │ ────────────────────────► │
    │                              │                           │
    │ GET /api/data                │                           │
    │ Authorization: Bearer {AT}   │                           │
    │ ──────────────────►          │                           │
    │                              │ AT 검증 (서명 + 만료)       │
    │ 200 OK                       │                           │
    │ ◄──────────────────          │                           │
    │                              │                           │
    │ ... 15분 후 AT 만료 ...       │                           │
    │                              │                           │
    │ GET /api/data                │                           │
    │ Authorization: Bearer {AT}   │                           │
    │ ──────────────────►          │                           │
    │ 401 Unauthorized             │ AT 만료                    │
    │ ◄──────────────────          │                           │
    │                              │                           │
    │ POST /auth/refresh           │                           │
    │ Refresh Token: {RT}          │                           │
    │ ──────────────────►          │ Redis에서 RT 확인          │
    │                              │ ────────────────────────► │
    │                              │ ◄──────────────────────── │
    │ 새 Access Token              │                           │
    │ ◄──────────────────          │                           │

6.3 JWT 구현 (Spring Boot)

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.access-token-validity}")
    private long accessTokenValidity;  // 15분

    @Value("${jwt.refresh-token-validity}")
    private long refreshTokenValidity; // 7일

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    // Access Token 생성
    public String createAccessToken(String email, String role) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + accessTokenValidity);

        return Jwts.builder()
                .subject(email)
                .claim("role", role)
                .issuedAt(now)
                .expiration(expiry)
                .signWith(getSigningKey())
                .compact();
    }

    // Refresh Token 생성
    public String createRefreshToken(String email) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + refreshTokenValidity);

        return Jwts.builder()
                .subject(email)
                .issuedAt(now)
                .expiration(expiry)
                .signWith(getSigningKey())
                .compact();
    }

    // 토큰에서 클레임 추출
    public Claims getClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    // 토큰 유효성 검증
    public boolean validateToken(String token) {
        try {
            getClaims(token);
            return true;
        } catch (ExpiredJwtException e) {
            throw new TokenExpiredException("토큰이 만료되었습니다");
        } catch (JwtException e) {
            throw new InvalidTokenException("유효하지 않은 토큰입니다");
        }
    }

    public String getEmail(String token) {
        return getClaims(token).getSubject();
    }
}

6.4 JWT 인증 필터

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain)
            throws ServletException, IOException {

        // 1. 헤더에서 토큰 추출
        String token = resolveToken(request);

        // 2. 토큰이 있고 유효하면
        if (token != null && jwtTokenProvider.validateToken(token)) {

            // 3. 토큰에서 사용자 정보 추출
            String email = jwtTokenProvider.getEmail(token);

            // 4. UserDetails 로드
            UserDetails userDetails = userDetailsService.loadUserByUsername(email);

            // 5. Authentication 객체 생성
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());

            authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request));

            // 6. SecurityContext에 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 7. 다음 필터로 진행
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if (bearer != null && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

6.5 Session vs JWT 비교

비교 항목 Session JWT
상태 관리 Stateful (서버에 세션 저장) Stateless (토큰에 정보 포함)
저장 위치 서버 (메모리/Redis) 클라이언트 (쿠키/localStorage)
확장성 세션 공유 필요 (Sticky Session/Redis) 별도 공유 불필요
즉시 무효화 가능 (세션 삭제) 어렵다 (블랙리스트 필요)
토큰 크기 작음 (JSESSIONID만) 큼 (클레임 포함)
CSRF 취약 (쿠키 자동 전송) 안전 (Authorization 헤더)
XSS 세션ID 탈취 위험 토큰 탈취 위험 (localStorage)
다중 서버 추가 설정 필요 설정 불필요
모바일 지원 쿠키 의존 헤더 기반으로 유연

면접 핵심 답변: “세션은 서버가 상태를 관리하므로 즉시 무효화가 가능하지만 다중 서버에서 공유가 필요합니다. JWT는 Stateless로 확장성이 좋지만 강제 만료가 어렵습니다. 실무에서는 짧은 Access Token + 긴 Refresh Token(Redis 저장) 조합으로 두 방식의 장점을 취합니다.”


7. 인가 (Authorization) 처리

7.1 URL 기반 인가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            // 순서 중요: 구체적인 규칙이 먼저!
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
            .requestMatchers("/api/auth/**").permitAll()
            .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
            .requestMatchers(HttpMethod.POST, "/api/posts/**").authenticated()
            .anyRequest().authenticated()
        )
        .build();
}

7.2 메서드 기반 인가 (@PreAuthorize)

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
@Configuration
@EnableMethodSecurity  // Spring Security 6+
public class MethodSecurityConfig { }

@Service
public class AdminService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteUser(Long userId) {
        // ROLE_ADMIN만 호출 가능
    }

    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
    public UserDto getUser(Long userId) {
        // 관리자이거나 본인만 조회 가능
    }

    @PreAuthorize("@authorizationService.canAccess(#orderId, authentication)")
    public OrderDto getOrder(Long orderId) {
        // 커스텀 인가 로직
    }

    @PostAuthorize("returnObject.owner == authentication.name")
    public Resource getResource(Long resourceId) {
        // 반환값의 owner가 본인인 경우에만 반환 허용
    }
}

7.3 Role vs Authority

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
// Role: 접두사 "ROLE_"가 자동으로 붙는 권한
// hasRole("ADMIN") → 내부적으로 "ROLE_ADMIN"을 검사

// Authority: 접두사 없이 그대로 사용되는 세밀한 권한
// hasAuthority("USER_DELETE") → "USER_DELETE" 검사

// 실무에서의 사용:
// Role → 큰 범주 (USER, ADMIN, MANAGER)
// Authority → 세밀한 권한 (READ_POST, WRITE_POST, DELETE_USER)

@Entity
public class Member {
    @Enumerated(EnumType.STRING)
    private Role role;  // ADMIN, USER

    @ElementCollection(fetch = FetchType.EAGER)
    private Set<String> permissions;  // READ_POST, WRITE_POST, DELETE_USER
}

// UserDetails 생성 시
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> authorities = new ArrayList<>();
    authorities.add(new SimpleGrantedAuthority("ROLE_" + role.name()));
    permissions.forEach(p ->
        authorities.add(new SimpleGrantedAuthority(p)));
    return authorities;
}

8. CSRF와 CORS

8.1 CSRF (Cross-Site Request Forgery)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CSRF 공격 시나리오:

  사용자가 bank.com에 로그인한 상태 (세션 쿠키 보유)
      │
      │ 악성 사이트(evil.com) 방문
      │
      ▼
  evil.com의 HTML:
  <form action="https://bank.com/transfer" method="POST">
    <input type="hidden" name="to" value="hacker" />
    <input type="hidden" name="amount" value="1000000" />
  </form>
  <script>document.forms[0].submit();</script>

  → 브라우저가 bank.com에 POST 요청 전송
  → bank.com의 세션 쿠키가 자동으로 포함됨!
  → 서버는 정상 요청으로 판단 → 송금 실행

Spring Security의 CSRF 방어:

1
2
3
4
5
6
7
8
9
10
11
12
13
// CSRF 토큰 방식 (세션 기반 인증에서 사용)
// Spring Security가 자동으로 CSRF 토큰을 생성하고 검증

// Thymeleaf에서 자동 삽입
<form method="POST" action="/transfer">
    <input type="hidden" name="_csrf" th:value="${_csrf.token}" />
    <!-- 또는 th:action을 사용하면 자동 삽입 -->
</form>

// REST API (JWT 기반)에서는 CSRF 비활성화
// JWT는 Cookie가 아닌 Authorization 헤더로 전송되므로
// 브라우저가 자동으로 포함하지 않음 → CSRF 불필요
http.csrf(csrf -> csrf.disable());

8.2 CORS (Cross-Origin Resource Sharing)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CORS 동작 과정 (Preflight 요청):

  브라우저 (http://frontend.com)         서버 (http://api.com)
      │                                    │
      │ OPTIONS /api/data                  │ ← Preflight 요청
      │ Origin: http://frontend.com        │
      │ Access-Control-Request-Method: POST│
      │ ──────────────────────────────►    │
      │                                    │
      │ Access-Control-Allow-Origin:       │
      │   http://frontend.com              │
      │ Access-Control-Allow-Methods:      │
      │   GET, POST, PUT                   │
      │ Access-Control-Max-Age: 3600       │
      │ ◄──────────────────────────────    │
      │                                    │
      │ POST /api/data                     │ ← 실제 요청
      │ Origin: http://frontend.com        │
      │ ──────────────────────────────►    │
      │                                    │
      │ 200 OK + 데이터                     │
      │ ◄──────────────────────────────    │
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Spring Security에서 CORS 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .cors(cors -> cors.configurationSource(corsConfigurationSource()))
        .build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("http://localhost:3000", "https://myapp.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);
    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

9. OAuth2 인증

9.1 OAuth2 역할

1
2
3
4
5
6
OAuth2의 4가지 역할:

  Resource Owner (자원 소유자)     = 사용자 (나)
  Client (클라이언트)              = 우리 서비스 (MyApp)
  Authorization Server (인가 서버) = Google, Kakao, Naver
  Resource Server (자원 서버)      = Google API, Kakao API

9.2 Authorization Code Grant — 가장 안전한 방식

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
32
33
34
35
36
37
38
39
40
41
42
43
44
OAuth2 Authorization Code 흐름:

  사용자              MyApp (Client)          Google (Auth Server)
    │                    │                         │
  ① │ "구글 로그인" 클릭   │                         │
    │ ──────────────►    │                         │
    │                    │                         │
  ② │                    │ 리다이렉트:               │
    │ ◄──────────────    │ https://accounts.google │
    │                    │ .com/oauth/authorize     │
    │                    │ ?client_id=xxx           │
    │                    │ &redirect_uri=https://   │
    │                    │  myapp.com/callback      │
    │                    │ &response_type=code      │
    │                    │ &scope=email profile     │
    │                    │                         │
  ③ │ 구글 로그인 화면 표시  │                         │
    │ ──────────────────────────────────────────►  │
    │                                              │
  ④ │ 사용자가 로그인 + 권한 동의                       │
    │                                              │
  ⑤ │ 리다이렉트:                                    │
    │ https://myapp.com/callback?code=AUTH_CODE     │
    │ ◄──────────────────────────────────────────   │
    │                    │                         │
  ⑥ │                    │ POST /oauth/token        │
    │                    │ code=AUTH_CODE           │
    │                    │ client_id=xxx            │
    │                    │ client_secret=yyy        │
    │                    │ ────────────────────►    │
    │                    │                         │
  ⑦ │                    │ Access Token 반환         │
    │                    │ ◄────────────────────    │
    │                    │                         │
  ⑧ │                    │ GET /userinfo            │
    │                    │ Authorization: Bearer AT │
    │                    │ ────────────────────►    │
    │                    │                         │
  ⑨ │                    │ 사용자 정보 (이메일, 이름)   │
    │                    │ ◄────────────────────    │
    │                    │                         │
  ⑩ │ 로그인 완료          │                         │
    │ (우리 서비스 JWT 발급) │                         │
    │ ◄──────────────    │                         │

왜 Authorization Code를 거치는가? Access Token을 브라우저(프론트)에 노출하지 않기 위해서이다. Authorization Code는 단기 유효하고 한 번만 사용 가능하며, 서버 간(MyApp → Google) 통신으로 교환하므로 안전하다.

9.3 Spring Boot OAuth2 로그인 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: email, profile
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            scope: profile_nickname, account_email
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
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
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Map<String, Object> attributes = oAuth2User.getAttributes();

        // 소셜 로그인 제공자별 사용자 정보 추출
        OAuthAttributes oAuthAttributes = OAuthAttributes.of(registrationId, attributes);

        // DB에서 회원 조회 또는 신규 가입
        Member member = memberRepository.findByEmail(oAuthAttributes.getEmail())
                .orElseGet(() -> memberRepository.save(
                        Member.builder()
                                .email(oAuthAttributes.getEmail())
                                .name(oAuthAttributes.getName())
                                .provider(registrationId)
                                .role(Role.USER)
                                .build()));

        return new CustomOAuth2User(member, attributes);
    }
}

10. 예외 처리

10.1 인증/인가 예외 핸들링

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
32
33
34
35
36
37
38
39
// 인증 실패 시 (401)
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                          HttpServletResponse response,
                          AuthenticationException authException)
            throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("""
            {"status": 401, "message": "인증이 필요합니다"}
            """);
    }
}

// 인가 실패 시 (403)
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                        HttpServletResponse response,
                        AccessDeniedException accessDeniedException)
            throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("""
            {"status": 403, "message": "접근 권한이 없습니다"}
            """);
    }
}

// SecurityFilterChain에 등록
http
    .exceptionHandling(ex -> ex
        .authenticationEntryPoint(customAuthenticationEntryPoint)
        .accessDeniedHandler(customAccessDeniedHandler));

11. 면접에서 자주 나오는 Spring Security 질문

Q1: 인증(Authentication)과 인가(Authorization)의 차이를 설명하세요.

인증은 “사용자가 누구인지 확인하는 과정”이고, 인가는 “인증된 사용자가 특정 자원에 접근할 권한이 있는지 확인하는 과정”입니다. 인증이 먼저 수행되고, 인증 실패 시 401, 인가 실패 시 403을 반환합니다. Spring Security에서는 AuthenticationManager가 인증을, AuthorizationFilter가 인가를 처리합니다.

Q2: Spring Security의 필터 체인 동작 과정을 설명하세요.

DelegatingFilterProxy가 Servlet Filter로서 요청을 받아 Spring Bean인 FilterChainProxy에 위임합니다. FilterChainProxy는 URL 패턴에 맞는 SecurityFilterChain을 선택하고, 약 15개의 보안 필터를 순서대로 실행합니다. 주요 필터로는 SecurityContextHolderFilter(인증 정보 로드), CsrfFilter(CSRF 검증), UsernamePasswordAuthenticationFilter(로그인 처리), AuthorizationFilter(인가 검사)가 있습니다. 모든 필터를 통과하면 DispatcherServlet으로 전달됩니다.

Q3: JWT와 Session의 차이를 설명하고, 실무에서 어떻게 선택하나요?

세션은 서버가 상태를 관리하는 Stateful 방식으로 즉시 무효화가 가능하지만 다중 서버에서 세션 공유가 필요합니다. JWT는 토큰에 정보를 포함하는 Stateless 방식으로 확장성이 좋지만 강제 만료가 어렵습니다. 실무에서는 짧은 만료의 Access Token(15분)과 긴 만료의 Refresh Token(7일, Redis 저장)을 조합하여 Stateless의 확장성과 즉시 무효화(Refresh Token 삭제)를 모두 확보합니다.

Q4: CSRF란 무엇이고, JWT에서는 왜 비활성화하나요?

CSRF(Cross-Site Request Forgery)는 사용자가 로그인한 상태에서 악성 사이트가 사용자의 브라우저를 이용해 위조 요청을 보내는 공격입니다. 세션 기반 인증에서는 브라우저가 쿠키를 자동으로 포함하므로 취약합니다. JWT는 Authorization 헤더로 전송되며 브라우저가 자동으로 포함하지 않으므로 CSRF 공격이 불가능합니다. 따라서 JWT 기반 API에서는 CSRF를 비활성화합니다.

Q5: SecurityContext는 어디에 저장되나요?

SecurityContext는 ThreadLocal에 저장됩니다. SecurityContextHolder가 ThreadLocal를 관리하며, 같은 요청(같은 스레드)을 처리하는 동안 어디서든 SecurityContextHolder.getContext().getAuthentication()으로 현재 인증 정보에 접근할 수 있습니다. 요청이 끝나면 ThreadLocal을 정리하여 메모리 누수를 방지합니다.

Q6: BCrypt를 사용하는 이유는?

단순 해시(SHA-256)는 같은 입력에 항상 같은 출력을 내므로 레인보우 테이블 공격에 취약합니다. BCrypt는 랜덤 솔트를 추가하여 같은 비밀번호도 다른 해시를 생성하고, 코스트 팩터로 해싱 속도를 조절하여 GPU 브루트포스 공격을 어렵게 만듭니다. Spring Security의 BCryptPasswordEncoder는 기본 코스트 10(2^10 라운드)을 사용합니다.

Q7: OAuth2 Authorization Code 방식에서 왜 코드를 거치나요?

Access Token을 브라우저(프론트)에 노출하지 않기 위해서입니다. Authorization Code는 URL 파라미터로 전달되므로 노출되지만, 단기 유효하고 한 번만 사용 가능합니다. 이 코드를 서버 간(백엔드 → OAuth 제공자) 통신으로 Access Token과 교환하므로, Access Token은 네트워크 상에서 안전하게 전달됩니다. 프론트에서 직접 토큰을 받는 Implicit 방식은 이 보안이 없어 현재 권장되지 않습니다.

Q8: CORS 에러는 왜 발생하고, 어떻게 해결하나요?

브라우저의 Same-Origin Policy에 의해, 다른 도메인(Origin)으로의 요청이 기본적으로 차단됩니다. 프론트(localhost:3000)에서 백엔드(localhost:8080)로 요청하면 Origin이 다르므로 CORS 에러가 발생합니다. 서버에서 Access-Control-Allow-Origin 헤더를 응답에 포함하여 허용할 Origin을 명시해야 합니다. Spring Security에서는 CorsConfigurationSource 빈으로 설정합니다.

Q9: @PreAuthorize와 URL 기반 인가의 차이는?

URL 기반 인가(authorizeHttpRequests)는 SecurityFilterChain에서 URL 패턴으로 접근을 제어합니다. @PreAuthorize는 메서드 레벨에서 SpEL 표현식으로 세밀한 인가를 처리합니다. URL 기반은 전역적이고 설정 한 곳에서 관리하기 쉽고, @PreAuthorize는 비즈니스 로직에 가까운 곳에서 복잡한 조건(본인 확인, 리소스 소유자 검사 등)을 표현할 수 있습니다. 실무에서는 둘을 조합하여 사용합니다.

Q10: JWT를 localStorage에 저장하면 안 되는 이유는?

localStorage는 JavaScript로 접근 가능하므로 XSS(Cross-Site Scripting) 공격에 취약합니다. 악성 스크립트가 localStorage에서 토큰을 탈취할 수 있습니다. 보안이 중요한 경우 httpOnly 쿠키에 저장하면 JavaScript 접근이 차단됩니다. 다만 쿠키를 사용하면 CSRF 방어가 다시 필요해지므로, SameSite 속성이나 CSRF 토큰으로 보완합니다.


정리

Spring Security의 핵심을 정리하면 다음과 같다.

인증과 인가는 본질적으로 다른 개념이다. 인증은 신원 확인(401), 인가는 권한 확인(403)이다. Spring Security에서는 AuthenticationManager/AuthenticationProvider가 인증을, AuthorizationFilter/@PreAuthorize가 인가를 처리한다.

Security Filter Chain이 Spring Security의 핵심 아키텍처이다. DelegatingFilterProxy → FilterChainProxy → 약 15개의 보안 필터가 순서대로 실행되며, 각 필터가 CSRF 방어, 인증, 인가 등 특정 역할을 수행한다. 인증 결과는 SecurityContext에 담겨 ThreadLocal에 저장된다.

JWT vs Session: 세션은 Stateful로 즉시 무효화가 가능하고, JWT는 Stateless로 확장성이 좋다. 실무에서는 Access Token(짧은 만료) + Refresh Token(Redis 저장, 긴 만료)을 조합하여 양쪽의 장점을 취한다.

보안 방어: CSRF는 쿠키 기반 인증(세션)에서 필요하고 JWT에서는 비활성화한다. CORS는 브라우저의 Same-Origin Policy에 의해 발생하며, 서버에서 허용 Origin을 설정해야 한다. 비밀번호는 BCrypt로 해싱하고, OAuth2는 Authorization Code 방식으로 Access Token을 안전하게 교환한다.