[Spring Security] 인증과 인가 흐름 정리

들어가며

백엔드 개발을 하다 보면 로그인 기능을 한 번쯤은 구현하게 된다.

처음에는 단순히 아이디와 비밀번호를 확인하고, 맞으면 로그인 성공이라고 생각했다.
하지만 Spring Security를 공부하면서 로그인은 생각보다 많은 흐름으로 이루어진다는 것을 알게 되었다.

인증과 인가, Security Filter Chain, SecurityContext, 세션 방식과 JWT 방식의 차이를 이해하지 못하면 코드를 복사해서 붙여도 문제가 생겼을 때 원인을 찾기 어렵다.

그래서 이번 글에서는 Spring Security를 공부하며 정리한 인증과 인가의 기본 흐름을 기록해보려고 한다.

인증과 인가

인증과 인가는 자주 같이 쓰이지만 의미가 다르다.

인증은 사용자가 누구인지 확인하는 과정이다.

1
Authentication = 당신은 누구인가?

예를 들어 사용자가 이메일과 비밀번호를 입력했을 때, 서버는 이 정보가 실제 회원 정보와 일치하는지 확인한다. 일치한다면 이 사용자는 인증된 사용자로 볼 수 있다.

반면 인가는 인증된 사용자가 어떤 자원에 접근할 수 있는지 판단하는 과정이다.

1
Authorization = 당신은 이 자원에 접근할 권한이 있는가?

예를 들어 로그인한 사용자라 하더라도 관리자 페이지에는 접근할 수 없을 수 있다.
또 게시글 수정 API에서는 게시글 작성자만 수정할 수 있도록 제한할 수 있다.

정리하면 다음과 같다.

구분 의미 예시
인증 사용자가 누구인지 확인 로그인
인가 접근 권한이 있는지 확인 관리자 API 접근 제한

Spring Security Filter Chain

Spring Security의 핵심은 Filter Chain이다.

클라이언트 요청이 Controller에 도달하기 전에 여러 Security Filter를 거친다.
이 Filter들이 인증 정보를 확인하고, 권한을 검사하고, 예외를 처리한다.

흐름을 단순화하면 다음과 같다.

1
2
3
4
5
Client Request
  -> Security Filter Chain
  -> Authentication
  -> Authorization
  -> Controller

즉, Controller에 도착한 시점에는 이미 인증과 인가 판단이 어느 정도 끝난 상태다.

JWT를 사용하는 프로젝트에서는 보통 커스텀 JWT Filter를 추가한다.
이 Filter에서 요청 헤더나 Cookie에 담긴 토큰을 읽고, 토큰이 유효하다면 SecurityContext에 인증 정보를 저장한다.

SecurityContext

Spring Security는 인증된 사용자 정보를 SecurityContext에 저장한다.

SecurityContext 안에는 Authentication 객체가 들어간다.
이 객체에는 사용자 정보와 권한 정보가 포함된다.

1
2
3
4
5
SecurityContext
  -> Authentication
      -> Principal
      -> Authorities
      -> Authenticated 여부

Controller나 Service에서 현재 로그인한 사용자를 가져올 수 있는 이유도 이 구조 덕분이다.

예를 들어 @AuthenticationPrincipal을 사용하면 현재 인증된 사용자 정보를 받아올 수 있다.

1
2
3
public ResponseEntity<?> myPage(@AuthenticationPrincipal CustomUser user) {
    ...
}

처음에는 이 애노테이션이 마법처럼 느껴졌지만, 결국 Filter에서 인증 정보를 만들고 SecurityContext에 저장했기 때문에 가능한 흐름이었다.

세션 방식과 JWT 방식

로그인 구현 방식은 크게 세션 기반과 토큰 기반으로 나누어 볼 수 있다.

세션 방식은 서버가 로그인 상태를 저장한다.

1
로그인 성공 -> 서버 세션 저장 -> 클라이언트는 세션 ID Cookie 보관

장점은 서버가 세션을 관리하기 때문에 강제로 로그아웃시키거나 세션을 삭제하기 쉽다는 점이다.
단점은 서버가 상태를 가지고 있어야 하므로 서버가 여러 대가 되면 세션 공유 전략이 필요하다는 점이다.

JWT 방식은 로그인 상태를 토큰에 담아 클라이언트가 보관한다.

1
로그인 성공 -> JWT 발급 -> 클라이언트가 요청마다 JWT 전달

장점은 서버가 세션 상태를 저장하지 않아도 된다는 점이다.
단점은 이미 발급된 AccessToken을 즉시 무효화하기 어렵다는 점이다.

그래서 실제 프로젝트에서는 AccessToken은 짧게, RefreshToken은 서버에서 관리하는 방식으로 많이 설계한다.

인증 실패와 인가 실패

Spring Security를 공부할 때 헷갈렸던 부분 중 하나는 인증 실패와 인가 실패의 차이다.

인증 실패는 사용자가 누구인지 확인되지 않은 상태다.

1
401 Unauthorized

예를 들어 토큰이 없거나, 만료되었거나, 위조된 경우가 여기에 해당한다.

인가 실패는 사용자가 인증되었지만 권한이 부족한 상태다.

1
403 Forbidden

예를 들어 일반 사용자가 관리자 API에 접근하려는 경우다.

이 둘을 구분해야 프론트엔드에서도 적절하게 처리할 수 있다.

  • 401: 다시 로그인하거나 토큰 재발급 시도
  • 403: 권한 없음 안내

프로젝트와 연결해서 생각하기

이 개념을 정리한 뒤 JWT 로그인 기능을 구현하면서 훨씬 이해가 쉬워졌다.

예전에는 “토큰이 있으면 로그인된 것” 정도로만 생각했다.
하지만 실제 흐름은 다음과 같았다.

  1. 요청에서 토큰을 꺼낸다.
  2. 토큰 유효성을 검증한다.
  3. 사용자 정보를 조회하거나 토큰에서 꺼낸다.
  4. Authentication 객체를 만든다.
  5. SecurityContext에 저장한다.
  6. 이후 인가 규칙에 따라 접근 가능 여부를 판단한다.

이 흐름을 알고 나니 Filter, EntryPoint, AccessDeniedHandler가 각각 왜 필요한지도 조금씩 이해되었다.

마무리

Spring Security는 처음 보면 설정이 많고 어렵게 느껴진다.

하지만 핵심은 요청이 Controller에 도달하기 전에 Filter Chain을 거치고, 그 과정에서 인증 정보를 만들고, 권한을 확인한다는 것이다.

이번 정리를 통해 이후 로그인, JWT, RefreshToken, OAuth2를 공부할 때 필요한 기반을 잡을 수 있었다.

앞으로 인증 기능을 구현할 때는 단순히 코드가 동작하는지보다, 요청이 어떤 Filter를 지나고 SecurityContext에 어떤 정보가 저장되는지 먼저 확인해보려고 한다.

Leave a comment