Spring Security를 공부하다 보면 Authentication, Authorization이라는 단어가 계속 나온다.
한글로 옮기면 둘 다 "인증"처럼 보이는데, 사실은 완전히 다른 개념이다.
인증(Authentication)은 "이 사용자가 누구인가"를 확인하는 과정이다. 로그인이 대표적인 인증 과정이다. 아이디와 비밀번호, 또는 JWT 토큰으로 "이 요청을 보낸 사람이 진짜 그 사람인지"를 검증한다.
인가(Authorization)는 "이 사용자가 이 작업을 할 권한이 있는가"를 확인하는 과정이다. 로그인한 사용자라도 모든 기능을 쓸 수 있는 건 아니다. 일반 사용자는 본인 글만 수정할 수 있고, 관리자만 회원 목록을 조회할 수 있는 것처럼 권한에 따라 접근 가능한 범위를 다르게 제어하는 과정이다.
한 문장으로 정리하면 이렇다.
인증 (Authentication) → "누구세요?"
인가 (Authorization) → "그래서 이걸 할 수 있나요?"
이 둘이 왜 분리되어 있어야 할까?
인증만 있고 인가가 없다면 어떻게 될까? 로그인한 사용자라면 누구나 모든 API에 접근할 수 있게 된다. 일반 사용자가 다른 사람의 주문 정보를 보거나, 관리자 페이지에 접근할 수 있다는 뜻이다.
인가만 있고 인증이 없다면 어떻게 될까? "이 사람이 ADMIN인지 확인하자"라고 했을 때, 그 "이 사람"이 누구인지조차 확인이 안 된 상태다. 권한 검사를 할 대상 자체가 불명확하다.
인증은 인가의 전제 조건이다. 먼저 누구인지 확인되어야, 그 사람에게 어떤 권한이 있는지 판단할 수 있다. 이 두 단계를 분리해서 처리하는 것이 보안 설계의 기본이다.
실무에서 이 둘이 적용되는 대표적인 상황이다.
PUT /api/posts/1 (게시글 수정) 요청으로 인증과 인가가 어떻게 순서대로 일어나는지 보자.
[Client]
|
| PUT /api/posts/1 + Authorization: Bearer {JWT}
↓
[JwtAuthFilter] ← 인증 단계
|
| ① JWT 토큰 추출
| ② 토큰 유효성 검증 (서명, 만료시간)
| ③ 토큰에서 사용자 정보(userId, role) 추출
| ④ Authentication 객체 생성
| ⑤ SecurityContextHolder에 저장
|
| → 토큰이 없거나 유효하지 않으면 401 Unauthorized
↓
[AuthorizationFilter] ← 인가 단계
|
| ⑥ SecurityContext에서 Authentication 꺼냄
| ⑦ 요청 URL/Method에 필요한 권한 확인
| ⑧ 사용자의 권한(Authorities)과 비교
|
| → 인증은 됐지만 권한이 부족하면 403 Forbidden
↓
[DispatcherServlet → Controller]
|
| ⑨ 메서드 레벨 인가 (@PreAuthorize 등)
| "이 게시글이 본인 글인가?" 같은 비즈니스 레벨 권한 체크
|
| → 비즈니스 로직상 권한 없으면 403 또는 예외
↓
[Service → DB 처리]
↓
[Client]
HTTP Response
인증은 한 번 확인되면 끝이지만, 인가는 요청마다, 심지어 한 요청 안에서도 여러 레벨로 검사될 수 있다. URL 레벨, 메서드 레벨, 비즈니스 로직 레벨까지 단계적으로 적용된다.
인증이 완료되면 SecurityContext에 저장되는 객체다.
UserDetails 객체)GrantedAuthority 컬렉션)
인가 판단의 기준이 되는 권한 정보다.
보통 ROLE_USER, ROLE_ADMIN 같은 역할(Role) 기반으로 부여한다.
new SimpleGrantedAuthority("ROLE_ADMIN")
인증을 실제로 수행하는 컴포넌트다.
아이디/비밀번호가 일치하는지, JWT가 유효한지 등을 검증해서
인증 성공 시 Authentication 객체를 만들어 반환한다.
인가를 판단하는 컴포넌트다.
현재 사용자의 Authorities와 요청에 필요한 권한을 비교해서
접근 허용 여부를 결정한다.
Spring Security 6.x부터는 AuthorizationManager가 이 역할을 담당한다.
SecurityFilterChain에서 requestMatchers()로 경로별 권한 설정@PreAuthorize, @PostAuthorize, @Secured로 메서드 단위 권한 설정Spring Security에서 인증과 인가는 각각 별도의 Filter가 처리한다.
FilterChain 순서 (일부):
JwtAuthFilter (커스텀)
→ 인증 담당. SecurityContext에 Authentication 저장
↓
AuthorizationFilter
→ 인가 담당. SecurityContext의 Authentication을 보고 권한 검사
URL 기반 인가는 SecurityFilterChain 설정에서 한다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 메서드 레벨 보안 활성화 (@PreAuthorize 등)
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 인증 없이 접근 가능 (인증 자체가 필요 없음)
.requestMatchers("/api/login", "/api/signup").permitAll()
// 인증만 필요 (로그인한 사용자 누구나)
.requestMatchers("/api/posts/**").authenticated()
// 특정 권한 필요 (인가)
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtProvider.validate(token)) {
Long userId = jwtProvider.getUserId(token);
String role = jwtProvider.getRole(token); // 예: "ROLE_USER"
List<GrantedAuthority> authorities =
List.of(new SimpleGrantedAuthority(role));
// 인증 완료 — Authentication 객체 생성 및 저장
Authentication authentication =
new UsernamePasswordAuthenticationToken(userId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 토큰이 없거나 유효하지 않으면 인증되지 않은 상태로 그냥 통과
// → 이후 인가 단계에서 401 처리됨
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;
}
}
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() // 누구나 조회 가능
.requestMatchers(HttpMethod.POST, "/api/posts").authenticated() // 로그인만 하면 작성 가능
.requestMatchers("/api/admin/**").hasRole("ADMIN") // ADMIN만 접근
.requestMatchers("/api/manager/**").hasAnyRole("ADMIN", "MANAGER") // 둘 중 하나
.anyRequest().authenticated()
)
@RestController
@RequestMapping("/api/admin")
public class AdminController {
// ROLE_ADMIN이 아니면 메서드 실행 전 차단 (403)
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/users")
public ResponseEntity<List<UserResponse>> getAllUsers() {
return ResponseEntity.ok(userService.findAll());
}
}
URL이나 Role만으로는 "이 게시글이 내 글인가?" 같은 검사를 할 수 없다. 이런 건 비즈니스 로직 안에서 직접 검사해야 한다.
@Service
@RequiredArgsConstructor
@Transactional
public class PostService {
private final PostRepository postRepository;
public void updatePost(Long postId, PostUpdateRequest request) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new EntityNotFoundException("게시글 없음"));
Long currentUserId = SecurityUtils.getCurrentUserId(); // 인증 정보에서 추출
// 인가: 본인 글인지 확인
if (!post.getAuthorId().equals(currentUserId)) {
throw new AccessDeniedException("본인 게시글만 수정할 수 있습니다.");
}
post.update(request.title(), request.content());
}
}
@PreAuthorize에서도 직접 작성한 빈을 호출해서 검사할 수 있다.
@PreAuthorize("@postSecurity.isOwner(#postId, principal)")
@PutMapping("/{postId}")
public ResponseEntity<Void> updatePost(@PathVariable Long postId,
@RequestBody PostUpdateRequest request) {
postService.updatePost(postId, request);
return ResponseEntity.ok().build();
}
이름 자체가 헷갈리기 쉬운데, 401은 "신원 확인 실패", 403은 "권한 부족"으로 기억하면 된다.
로그인에 성공했다고 모든 작업이 가능한 건 아니다. 인증은 "신원 확인"이고, 인가는 "그 신원으로 무엇을 할 수 있는가"다. 이 둘을 같은 거라고 생각하면 권한 체크를 빠뜨리는 보안 취약점이 생긴다.
hasRole("USER")로 로그인한 사용자만 접근하게 했더라도,
"이 리소스가 본인 것인가"는 별도로 검사해야 한다.
URL/Role 기반 인가는 "이 종류의 작업을 할 수 있는가"를 검사하고,
비즈니스 레벨 인가는 "이 특정 리소스에 대해 할 수 있는가"를 검사한다.
두 레벨 모두 필요하다.
사용자에게 필요한 최소한의 권한만 부여하는 것이 기본 원칙이다.
기본값을 차단(denyAll)으로 두고, 필요한 경로만 명시적으로 허용하는 방식이
실수로 보안이 뚫리는 걸 방지한다.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/login", "/api/signup", "/api/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated() // 명시 안 된 나머지는 최소한 인증 필요
)
작은 서비스에서는 ROLE_USER, ROLE_ADMIN 정도로 충분하지만,
서비스가 커지면 역할(Role)과 세부 권한(Permission)을 분리하는 게 유연하다.
Role: MANAGER
- Permission: POST_READ
- Permission: POST_UPDATE
- Permission: USER_READ
역할이 여러 권한의 묶음이 되도록 설계하면, 새로운 역할이 추가되어도 기존 권한 조합을 재사용할 수 있다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.exceptionHandling(exception -> exception
// 인증 실패 → 401
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\": \"인증이 필요합니다.\"}");
})
// 인가 실패 → 403
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\": \"접근 권한이 없습니다.\"}");
})
);
return http.build();
}
}
클라이언트가 두 상황을 구분할 수 있어야 적절한 처리를 할 수 있다. 401이면 로그인 페이지로 리다이렉트하고, 403이면 권한 부족 안내를 보여주는 식이다.
SecurityContext 저장 → 인가 검사 순서로 처리된다처음엔 그냥 "로그인하면 다 되는 거 아닌가?" 정도로 생각했는데, 인증과 인가를 분리해서 보니까 보안 설계의 기본 틀이 보였다.
"누구인지 확인하는 것"과 "그 사람이 무엇을 할 수 있는지 확인하는 것"은 완전히 다른 질문이다. 이 둘을 분리해서 생각하면 401과 403의 차이도 명확해지고, 왜 URL 레벨 인가만으로는 부족하고 비즈니스 로직에서 추가 검사가 필요한지도 이해된다. 보안은 한 곳에서 한 번에 끝나는 게 아니라 여러 레이어에서 단계적으로 검증되는 거라는 걸 느꼈다.