Spring Security로 JWT 로그인을 붙이면서 "인증"과 "인가"를 자꾸 뭉뚱그려 생각했다.
이번 과정의 핵심은 "로그인 기능을 만드는 것"이 아니라,
"너 누구야(인증)"와 "너 이거 해도 돼?(인가)"가 서로 다른 단계라는 걸 이해하는 것이었다.
두 개념은 이름도 비슷하고(둘 다 Auth...) 401/403이 헷갈리지만, 질문 자체가 다르다.
| 구분 | 인증 (Authentication) | 인가 (Authorization) |
|---|---|---|
| 던지는 질문 | "너 누구야?" | "너 이거 해도 돼?" |
| 하는 일 | 신원 확인 (JWT 토큰 검증) | 권한 확인 (접근 허용/차단) |
| 실패 시 | 401 Unauthorized | 403 Forbidden |
| 우리 코드 | JwtAuthenticationFilter | authorizeHttpRequests의 규칙 |
요청이 컨트롤러까지 가는 흐름으로 보면 인증이 먼저, 인가가 나중이다.
요청 (Authorization: Bearer xxx)
│
① JwtAuthenticationFilter ← 인증: 토큰 까서 "너 누구야" 확인
│ (유효하면 SecurityContext에 사용자 정보 심음)
▼
② authorizeHttpRequests ← 인가: 이 경로를 "너 자격으로" 접근 가능?
│ permitAll → 통과 / authenticated → 인증돼 있어야 통과
▼
③ Controller
즉 필터가 "신분증을 확인"(인증)하고, 그 결과를 규칙표가 보고 "출입 허가"(인가)를 내주는 구조다.
토큰을 꺼내 검증하고, 통과하면 "이 사람이 누구인지"를 SecurityContext에 심는다.
if (token != null && jwtUtil.isValid(token)) { // 신분증 진짜냐 = 인증
Long memberId = jwtUtil.getMemberId(token);
CustomUserPrincipal principal = new CustomUserPrincipal(memberId, email, "USER");
UsernamePasswordAuthenticationToken authentication =
UsernamePasswordAuthenticationToken.authenticated(principal, null,
List.of(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context); // "이 사람 누구다"를 등록
}
filterChain.doFilter(request, response);
어느 경로를 누구에게 열지 결정한다. permitAll과 authenticated가 인가의 핵심이다.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 인증 없이 OK
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() // 상품 조회는 공개
.anyRequest().authenticated() // 그 외 전부 인증 필요
)
permitAll() = 인가 통과 — 인증 안 돼 있어도 접근 허용authenticated() = 인증돼 있어야 인가 통과 — 신분증 확인된 사람만| 문제 | 원인 | 해결 |
|---|---|---|
GET /api//products?category=... 가 401 UNAUTHORIZED | URL에 //(슬래시 2개) → permitAll의 /api/products/** 패턴에 매칭 실패 → anyRequest().authenticated()로 떨어짐 → 인증 안 된 요청이라 차단 | URL을 /api/products(슬래시 하나)로 고침 |
빈 검색 결과(200)를 기대했는데 401이 나서 "API가 깨졌나?" 했지만, 실제로는 인증/인가 흐름이 정확히 작동한 것이었다. 오타로 경로가 어긋나 "공개 경로 목록"에 안 걸렸고, 그러면 규칙표의 마지막 줄 authenticated()가 적용돼 토큰 없는 요청을 막은 것이다.
401은 "API 버그"가 아니라 "인가 규칙이 이 경로를 보호 대상으로 판단했다"는 신호일 수 있다.
인증/인가는 경로 매칭에 그대로 좌우된다.
그리고 이때 401 응답을 실제로 만들어주는 건 컨트롤러도 @RestControllerAdvice도 아니라 JwtAuthenticationEntryPoint였다.
.exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))
// JwtAuthenticationEntryPoint
public void commence(..., AuthenticationException authException) {
ErrorCode errorCode = ErrorCode.UNAUTHORIZED; // 401
response.setStatus(errorCode.getStatus().value());
response.getWriter().write(jsonMapper.writeValueAsString(ErrorResponse.from(errorCode)));
}
인가 단계에서 "인증이 필요한데 인증이 안 됨"으로 막힌 요청은 컨트롤러에 도달하기 전에 EntryPoint가 401을 만들어 돌려보낸다.
anyRequest().authenticated()로 나머지는 전부 잠금"이다. 새 API를 추가하면 기본이 "인증 필요"라, 깜빡하고 보호를 안 걸어 뚫리는 사고를 막아준다.authorizeHttpRequests는 위에서부터 먼저 매칭되는 규칙을 적용하므로, 구체적인 permitAll을 먼저 쓰고 anyRequest()를 맨 마지막에 둔다.인증은 "너 누구야"를 확인하는 단계(우리 코드에선 JwtAuthenticationFilter)이고,
인가는 "그 신분으로 이걸 해도 돼?"를 판단하는 단계(authorizeHttpRequests의 permitAll/authenticated)다.
요청은 인증 → 인가 순으로 흐르고, 인가에서 막히면 JwtAuthenticationEntryPoint가 401을 만들어 돌려준다.
오늘 겪은 // 오타 401 사건이 이 흐름 전체를 한 번에 보여준 좋은 사례였다.
인증(누구야, 401)과 인가(해도 돼, 403)는 다른 단계다. 필터가 신분을 확인하고,
permitAll/authenticated규칙표가 출입을 허가하며, 막히면 EntryPoint가 401을 낸다.