0강. 최종 목표 문장 해체

한 줄 결론

Spring Security 로그인은 Controller가 직접 성공/실패를 판단하는 구조가 아니라, Authentication -> AuthenticationManager -> AuthenticationProvider -> SecurityContext -> AuthorizationFilter로 책임을 분리하는 구조다.

3줄 요약

  • MVC 직접 로그인은 Controller가 사용자 조회, 비밀번호 비교, 인증 상태 저장까지 떠안기 쉽다.
  • Security 방식은 인증 요청을 Authentication으로 표현하고, 검증 책임을 Manager와 Provider로 분리한다.
  • JWT 방식에서는 서버 세션에 인증 상태를 저장하지 않으므로 매 요청마다 토큰을 검증해 Authentication을 다시 구성해야 한다.

체크포인트

  • Controller가 로그인 API를 가질 수는 있지만 인증 검증 책임을 독점하면 안 된다는 점을 설명할 수 있는가?
  • 인증 전 Authentication과 인증 후 Authentication의 의미 차이를 구분할 수 있는가?
  • AuthenticationManagerAuthenticationProvider의 역할을 분리해서 말할 수 있는가?
  • 인증과 인가의 차이를 SecurityContextAuthorizationFilter 흐름으로 설명할 수 있는가?
  • JWT 로그아웃과 강제 만료가 세션 로그아웃보다 어려운 이유를 말할 수 있는가?

전체 흐름

  1. MVC 직접 로그인의 문제를 확인한다.
  2. username/password를 Authentication 인증 요청 객체로 바꾸는 이유를 이해한다.
  3. AuthenticationManager가 검증을 직접 하지 않고 Provider에게 위임하는 구조를 본다.
  4. 인증 성공 후 SecurityContextHolder에 저장되는 사용자 정보를 확인한다.
  5. 이후 요청에서 인가가 어떻게 권한과 URL 규칙을 비교하는지 본다.
  6. JWT 방식에서 매 요청 재인증과 별도 무효화 전략이 필요한 이유를 정리한다.

분리 후보

현재 문서는 최종 목표 문장을 한 번에 해체하는 성격이므로 유지한다. 다만 복습성이 떨어지면 MVC 직접 로그인에서 Security 인증 구조로 전환, SecurityContext와 인가 흐름, JWT 재인증과 무효화 전략의 3개 문서로 나눌 수 있다.

결론

결론은 이렇다. 이해되면 1강으로 넘어가면 된다.

Spring Security를 적용한 로그인은
Controller가 직접 로그인 성공/실패를 판단하는 기능이 아니다.
 
사용자의 username/password 같은 자격 증명을
Authentication이라는 인증 요청 객체로 만들고,
 
AuthenticationManager가 인증 요청을 받아
처리 가능한 AuthenticationProvider에게 검증을 위임한다.
 
검증이 성공하면 인증 완료된 Authentication이 만들어지고,
이 정보는 SecurityContext에 담겨
SecurityContextHolder를 통해 현재 요청에서 접근할 수 있게 된다.
 
그 뒤 AuthorizationFilter가
현재 사용자의 권한과 요청 URL의 보안 규칙을 비교해서
요청을 허용할지 거부할지 판단한다.
 
JWT 방식에서는 서버 세션에 인증 상태를 저장하지 않으므로,
매 요청마다 access token을 검증해서 Authentication을 다시 만들어야 한다.
 
또한 JWT access token은 만료 전까지 계속 유효할 수 있으므로,
로그아웃/탈취/강제 만료 대응을 위해
refresh token 저장소,
access token jti denylist,
tokenVersion 같은 별도 무효화 전략이 필요하다.

교재의 최종 이해 목표도 이 흐름이다. 즉, Controller 직접 판단이 아니라 Authentication 중심의 인증 구조, 그리고 JWT에서는 매 요청 재인증과 별도 무효화 전략이 필요하다는 것을 이해하는 게 최종 목표다.

결론에 도달해보자!

전형적인 MVC 기반 로그인의 문제점

처음 MVC 구조를 배우고 로그인 흐름을 설명하면 보통 이렇게 말하게 된다.

1. 사용자가 아이디/비밀번호 입력
2. Controller가 요청을 받음
3. DB에서 사용자 조회
4. 비밀번호가 맞는지 if문으로 비교
5. 맞으면 로그인 성공
6. 틀리면 로그인 실패

전형적인 직접 로그인 코드

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
    User user = userMapper.findByUsername(request.username());
 
    if (user == null) {
        return ResponseEntity.status(401).build();
    }
 
    if (!request.password().equals(user.getPassword())) {
        return ResponseEntity.status(401).build();
    }
 
    return ResponseEntity.ok("login success");
}

이 코드는 이해하기 쉽다.

하지만 Spring Security 관점에서는 Controller가 너무 많은 일을 하고 있다.

Controller가 하는 일:
- 로그인 요청 받기
- 사용자 DB 조회하기
- 비밀번호 직접 비교하기
- 로그인 성공/실패 판단하기
- 권한 만들기
- 인증 상태 저장하기

문제는 단순히 코드가 길다는 것이 아니다.

핵심 문제는 인증이라는 보안 책임이 Controller 안에 흩어진다는 것이다.


Security를 이용하면 책임을 나눌 수 있다

Spring Security를 사용하면 Controller가 인증 검증을 직접 하지 않는다.

Controller는 로그인 요청을 받은 뒤, 아이디/비밀번호를 Spring Security가 이해할 수 있는 객체로 포장한다.

그 객체가 Authentication이다.

아이디/비밀번호를 보냈군.
→ Authentication이라는 인증 요청 객체로 만들자.
 
검증은 Controller가 직접 하지 말자.
→ AuthenticationManager에게 맡기자.

코드 친화적으로 말하면 Controller는 아래 일만 한다.

- 로그인 요청 받기
- username/password를 Authentication으로 포장하기
- AuthenticationManager 호출하기
- 성공 결과를 바탕으로 응답 만들기

Spring Security 방식 로그인 코드

@PostMapping("/api/auth/login")
public TokenResponse login(@RequestBody LoginRequest request) {
    Authentication input =
        new UsernamePasswordAuthenticationToken(
            request.username(),
            request.password()
        );
 
    Authentication result =
        authenticationManager.authenticate(input);
 
    return tokenService.issue(result);
}

이제 Controller는 아래 일을 하지 않는다.

Controller가 하지 않는 일:
- DB password_hash 직접 조회
- raw password 직접 비교
- 로그인 성공/실패를 if문으로 최종 판단

여기서 중요한 문장은 이것이다.

Controller가 로그인 API를 가질 수는 있다.
하지만 인증 검증 책임을 Controller가 독점하면 안 된다.

Authentication은 무엇인가?

Authentication을 단순히 “자격 증명”이라고만 이해하면 조금 부족하다.

더 정확히는 이렇다.

Authentication은
인증 전에는 “검증해 달라는 요청 객체”이고,
인증 후에는 “검증 완료된 사용자 정보 객체”다.

사용자가 자신을 증명하기 위해 제출하는 정보는 여러 가지가 있다.

상황제출하는 자격 증명
일반 로그인username + password
JWT API 요청Bearer access token
OAuth 로그인authorization code
API Key 방식API key

Spring Security는 이런 인증 재료를 Authentication 객체로 표현한다.

일반 로그인에서는 보통 이렇게 만든다.

Authentication input =
    new UsernamePasswordAuthenticationToken(username, rawPassword);

이 시점의 의미는 이렇다.

이 사용자가 보낸 username/password가 맞는지 검증해 주세요.

즉, 인증 전 Authentication은 검증 요청이다.

그런데 인증이 성공하면 의미가 바뀐다.

Authentication result =
    authenticationManager.authenticate(input);

이 결과 객체의 의미는 이렇다.

이 사용자는 검증 완료된 사용자입니다.
이 사용자는 ROLE_USER 권한을 가집니다.

정리하면 아래와 같다.

시점Authentication의 의미예시
인증 전검증 요청 객체username/password가 맞는지 확인해 주세요
인증 후인증 완료 사용자 객체이 사용자는 joseph이고 ROLE_USER 권한을 가집니다

Authentication 내부에는 대략 이런 정보가 들어간다.

Authentication
- principal: 사용자 식별 정보
- credentials: 비밀번호, 토큰 같은 자격 증명
- authorities: 권한 목록
- authenticated: 인증 완료 여부

따라서 Authentication = 자격 증명이라고만 외우면 부족하다.

더 정확히는:

Authentication은 자격 증명을 포함해서
현재 인증 요청 또는 인증 완료 사용자를 표현하는 Spring Security의 중심 객체다.

인증 요청 접수 담당자와 실제 검증 담당자는 다르다

앞에서 Controller가 이 코드를 호출했다.

Authentication result =
    authenticationManager.authenticate(input);

여기서 AuthenticationManager는 인증 요청을 받는 진입점이다.

하지만 실제 검증 로직을 전부 혼자 처리한다기보다는, 처리 가능한 AuthenticationProvider에게 위임한다.

AuthenticationManager
= 인증 요청을 받는 진입점
 
AuthenticationProvider
= 특정 인증 방식을 실제로 검증하는 담당자

비유하면 병원 접수와 비슷하다.

AuthenticationManager:
"인증 요청이 왔네.
이건 username/password 방식이군.
이 방식은 DaoAuthenticationProvider가 처리할 수 있겠다.
그쪽에 맡기자."
 
AuthenticationProvider:
"DB에서 사용자를 찾고,
PasswordEncoder로 비밀번호를 비교하고,
권한을 구성해서 인증 결과를 만들자."

username/password 로그인이라면 대략 이런 흐름이다.

AuthenticationManager
→ ProviderManager
→ DaoAuthenticationProvider
→ UserDetailsService
→ MyBatis Mapper
→ users 테이블 조회
→ PasswordEncoder.matches()
→ 인증 성공 Authentication 반환

핵심은 이것이다.

Controller가 직접 검증하지 않는다.
AuthenticationManager가 요청을 받고,
AuthenticationProvider가 실제 검증을 담당한다.

인증과 인가는 다르다

로그인에 성공했다고 모든 요청이 허용되는 것은 아니다.

예를 들어 어떤 사용자가 로그인했다고 해서 관리자 페이지에 접근할 수 있는 것은 아니다.

인증 Authentication:
너 누구야?
 
인가 Authorization:
너 이 요청 해도 돼?

예를 들어 사용자가 로그인되어 있고 권한이 ROLE_USER라고 하자.

현재 사용자: joseph
현재 권한: ROLE_USER

그런데 요청은 관리자 API다.

GET /api/admin/report

보안 규칙은 이렇다.

.requestMatchers("/api/admin/**").hasRole("ADMIN")

이 경우 사용자는 인증은 되었지만, 필요한 권한이 없다.

인증은 됨.
하지만 ROLE_ADMIN이 없음.
따라서 접근 거부.

이 인가 판단을 하는 대표 필터가 AuthorizationFilter다.

AuthenticationProvider가 “너 누구냐”를 검증한다.
AuthorizationFilter가 “너 이 요청 해도 되냐”를 판단한다.

인증된 사용자 정보는 어디에 저장되는가?

인증이 성공하면 그 결과를 현재 요청에서 사용할 수 있어야 한다.

Controller나 Service는 이런 질문에 답해야 할 수 있다.

현재 요청을 보낸 사용자가 누구지?
이 사용자는 어떤 권한을 가지고 있지?

이 정보를 담는 곳이 SecurityContext다.

그리고 SecurityContext에 접근하게 해주는 도구가 SecurityContextHolder다.

SecurityContext
= 현재 인증 정보를 담는 컨테이너
 
SecurityContextHolder
= SecurityContext에 접근하는 도구

현재 인증 사용자 조회

Authentication auth =
    SecurityContextHolder.getContext().getAuthentication();
 
String username = auth.getName();

주의할 점이 있다.

SpringContext가 아니라 SecurityContext다.

틀린 표현:
SpringContext에 인증 정보가 저장된다.
 
정확한 표현:
SecurityContext에 Authentication이 저장되고,
SecurityContextHolder를 통해 접근한다.

세션 방식에서는 서버가 인증 상태를 기억한다

세션 방식에서는 로그인 성공 결과를 서버 쪽 세션에 저장한다.

로그인 성공
→ 서버 세션에 SecurityContext 저장
→ 클라이언트는 session id cookie 보관
→ 다음 요청 때 session id cookie 전송
→ 서버는 session id로 인증 상태 복원

즉, 서버가 로그인 상태를 기억한다.

세션 방식
= 서버가 인증 상태를 저장하는 방식

흐름은 이렇다.

[로그인 요청]
username/password 검증 성공
→ SecurityContext를 서버 세션에 저장
 
[다음 요청]
session id cookie 전송
→ 서버가 세션에서 SecurityContext 복원
→ 현재 사용자를 알 수 있음

세션 방식의 장점은 로그아웃이 상대적으로 쉽다는 것이다.

로그아웃
→ 서버 세션 삭제
→ session id가 다시 와도 인증 정보 없음

JWT 방식에서는 매 요청마다 Authentication을 다시 만든다

JWT 방식은 세션처럼 서버가 인증 상태를 저장하지 않는 것이 일반적이다.

로그인 성공 시 서버는 access token을 발급한다.

로그인 성공
→ 서버가 access token 발급
→ 클라이언트가 token 보관

그다음 보호 API를 호출할 때 클라이언트는 토큰을 보낸다.

GET /api/me
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

서버는 이 토큰을 매 요청마다 검증한다.

1. Authorization header에서 Bearer token 추출
2. JWT 서명 검증
3. exp 만료 확인
4. claim 읽기
5. claim을 authority로 변환
6. Authentication 생성
7. SecurityContextHolder에 저장
8. AuthorizationFilter가 권한 판단

정리하면 이렇다.

JWT 방식
= 서버 세션에서 인증 상태를 복원하지 않는다.
= 매 요청마다 토큰을 검증해서 Authentication을 다시 만든다.

여기서 중요한 오해가 있다.

JWT stateless
= SecurityContextHolder를 안 쓴다는 뜻이 아니다.
 
JWT stateless
= 요청과 요청 사이에 서버 세션으로 인증 상태를 저장하지 않는다는 뜻이다.

즉, JWT 방식에서도 현재 요청을 처리하는 동안에는 SecurityContextHolder를 사용한다.

다만 다음 요청에서는 다시 JWT를 검증해서 Authentication을 새로 만든다.


JWT 로그아웃은 세션 로그아웃보다 어렵다

세션 방식의 로그아웃은 비교적 단순하다.

로그아웃
→ 서버 세션 삭제
→ session id가 다시 와도 인증 정보 없음

하지만 JWT access token은 이미 클라이언트에게 발급된 문자열이다.

이 토큰은 보통 그 자체 안에 이런 정보를 가진다.

- 누가 발급했는지
- 누구에게 발급됐는지
- 언제 발급됐는지
- 언제 만료되는지
- 토큰 고유 ID가 무엇인지
- 서명이 유효한지

그래서 access token이 아래 조건을 만족하면 계속 사용될 수 있다.

JWT access token의 특징:
- 서명이 맞고
- 만료 시간이 지나지 않았고
- 서버가 별도 차단 검사를 하지 않으면
- 토큰을 가진 사람은 계속 사용할 수 있다.

즉, 사용자가 로그아웃하면서 브라우저에서 토큰을 삭제해도, 누군가 그 토큰을 이미 복사해두었다면 만료 전까지 사용할 수 있다.

그래서 JWT에서는 별도 전략이 필요하다.

전략의도
짧은 access token 만료탈취된 access token의 사용 가능 시간을 줄임
refresh token DB 저장refresh token을 서버에서 폐기 가능하게 만듦
refresh token rotation재발급 때마다 refresh token을 교체해 탈취 재사용을 탐지
access token jti denylist특정 access token ID를 차단 목록에 등록
tokenVersion사용자 단위로 기존 토큰 전체를 무효화

여기서 JWT의 장단점이 같이 드러난다.

JWT의 장점:
- 서버 세션 저장소에 덜 의존할 수 있다.
- 매 요청을 토큰 검증 중심으로 처리할 수 있다.
- 서버 확장에 유리한 구조를 만들 수 있다.
 
JWT의 단점:
- 이미 발급된 access token을 즉시 무효화하기 어렵다.
- 로그아웃/탈취/강제 만료를 제대로 하려면 서버 쪽 상태가 다시 필요하다.

결국 핵심은 이것이다.

JWT는 stateless하다는 장점이 있지만,
보안상 필요한 순간에는 일부 state를 다시 서버에 도입해야 한다.

0강 전체 흐름 요약

로그인 요청 흐름은 이렇게 정리할 수 있다.

[로그인 요청]
username/password 제출

Controller

UsernamePasswordAuthenticationToken 생성

AuthenticationManager.authenticate()

AuthenticationProvider가 실제 검증

UserDetailsService + MyBatis + PasswordEncoder

인증 성공 Authentication 반환

JWT 발급 또는 세션 저장

JWT 보호 API 요청 흐름은 이렇게 정리할 수 있다.

[보호 API 요청]
Authorization: Bearer access-token

Spring Security FilterChain

JWT 검증

Authentication 재구성

SecurityContextHolder에 저장

AuthorizationFilter 권한 판단

Controller 도착

JWT 로그아웃/무효화 흐름은 이렇게 생각해야 한다.

[JWT 로그아웃]
클라이언트 토큰 삭제
        +
refresh token 폐기
        +
필요하면 access token jti denylist 등록
        +
필요하면 tokenVersion 증가

자주 하는 오해와 교정

오해교정
Controller에 login API가 있으면 Spring Security 방식이 아니다login API는 Controller에 있어도 된다. 다만 검증을 직접 하지 않고 AuthenticationManager에 위임해야 한다
Authentication은 그냥 자격 증명이다Authentication은 인증 전에는 요청 객체, 인증 후에는 인증 완료 사용자 객체다
AuthenticationManager가 모든 검증을 직접 한다대표 구현체인 ProviderManager가 처리 가능한 AuthenticationProvider에게 검증을 위임한다
인증되면 모든 요청이 가능하다인증과 인가는 다르다. AuthorizationFilter가 권한을 따로 판단한다
인증 정보는 SpringContext에 저장된다SecurityContext에 Authentication이 저장되고, SecurityContextHolder로 접근한다
JWT면 SecurityContextHolder를 안 쓴다JWT도 현재 요청 중에는 SecurityContextHolder를 쓴다
JWT는 stateless니까 로그아웃도 쉽다이미 발급된 access token은 만료 전까지 살아 있을 수 있어서 별도 무효화 전략이 필요하다
클라이언트에서 토큰 삭제하면 서버 로그아웃이 끝난다탈취된 복사본이 있으면 계속 사용될 수 있으므로 서버 측 폐기 전략이 필요하다

핵심 정리

Spring Security 로그인은
Controller가 비밀번호를 직접 비교해서 성공/실패를 판단하는 코드가 아니다.
 
Controller는 username/password를 Authentication으로 만들고,
AuthenticationManager에게 검증을 위임한다.
 
AuthenticationManager는 처리 가능한 AuthenticationProvider에게 검증을 맡긴다.
 
검증이 성공하면 인증 완료된 Authentication이 만들어지고,
이 정보는 SecurityContext에 담겨
SecurityContextHolder를 통해 현재 요청에서 접근된다.
 
그 뒤 AuthorizationFilter가
현재 Authentication의 authorities와 요청 보안 규칙을 비교해
접근 허용 여부를 판단한다.
 
JWT 방식에서는 서버 세션에서 인증 상태를 복원하지 않으므로,
매 요청마다 access token을 검증해 Authentication을 다시 만든다.
 
그리고 JWT access token은 만료 전까지 계속 유효할 수 있으므로,
로그아웃/탈취/강제 만료를 위해
refresh token 저장소,
jti denylist,
tokenVersion 같은 별도 무효화 전략이 필요하다.

확인 질문

아래 질문에 답할 수 있으면 0강은 넘어가면 된다.

1. “Controller가 로그인 성공/실패를 직접 판단하지 않는다”는 말은
   Controller에 login API를 만들면 안 된다는 뜻인가?
 
2. Authentication은 인증 전과 인증 후에 각각 어떤 의미인가?
 
3. AuthenticationManager와 AuthenticationProvider는 각각 어떤 역할인가?
 
4. 인증과 인가는 어떻게 다른가?
 
5. 인증 성공 결과는 SpringContext에 저장되는가,
   SecurityContext에 저장되는가?
 
6. JWT 방식에서 stateless라는 말은
   SecurityContextHolder를 아예 안 쓴다는 뜻인가?
 
7. JWT 로그아웃에서 클라이언트 토큰 삭제만으로 부족한 이유는 무엇인가?