
예전 프로젝트에서 Security+JWT를 적용해 둔 코드가 있었는데, 새 프로젝트에서도 “그대로 가져가도 되는지”, 그리고 실제로 실무에서 어떤 방식이 많이 쓰이는지(로그인 vs 일반 요청)까지 한 번에 정리하고 싶었다.
추가로, IntelliJ에서 amend가 뭔지 헷갈렸던 부분과, Spring Security 7에서 matcher 방식이 바뀐 것도 같이 정리한다.
git commit --amend는 가장 최근 커밋 1개를 덮어써서 새 커밋으로 다시 만드는 것이다.
즉,
최근 커밋 메시지를 고치거나
최근 커밋에 파일 변경사항을 더 포함시키거나
둘 다 동시에 할 때 쓴다.
커밋이 하나 더 생기는 게 아니라, 직전 커밋이 교체되고 커밋 해시가 바뀐다.
IDE에서 amend를 켜면 “새 커밋 생성”이 아니라 “직전 커밋 수정 모드”라서, 직전 커밋 메시지가 화면에 뜬다.
여기서 할 수 있는 건 2가지:
메시지 수정 후 커밋 → 직전 커밋 메시지가 바뀜(커밋 해시도 변경)
amend 전에 파일을 수정하고(staged 여부는 도구/설정에 따라 다름) amend로 커밋하면
→ 그 변경사항이 직전 커밋에 “합쳐져서” 들어감
중요한 포인트: amend 화면에서 직전 커밋 내용을 직접 편집하는 게 아니라,
현재 워킹트리/스테이지에 있는 변경사항을 직전 커밋에 합치는 방식이다.
이미 push된 커밋을 amend하면 원격 히스토리와 달라짐 → 보통 force push가 필요
팀 작업이면 위험(히스토리 꼬임)
반대로,
실무에서 제일 흔한 혼용 패턴은 아래 구조다.
로그인은 “사용자가 진짜인지”를 검증해야 하니 보통 다음 흐름을 탄다.
AuthenticationManager.authenticate(...)
내부적으로 적절한 AuthenticationProvider 선택(자주 DaoAuthenticationProvider)
UserDetailsService로 사용자 로드
PasswordEncoder로 비밀번호 매칭
즉, 로그인 요청은 manager/provider 라인을 타는 게 자연스럽다.
그래서 이런 빈이 필요해질 수 있다.
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
이 빈이 “필요한 경우”
내가 /auth/login 같은 API에서 직접 authenticate(...)를 호출할 때
Username/Password 인증을 직접 태우는 커스텀 흐름이 있을 때
이 빈이 “굳이 필요 없는 경우”
JWT 검증만 하고, 로그인 인증을 따로 직접 태우지 않을 때(예: OAuth2만)
코드에서 AuthenticationManager를 직접 호출하지 않을 때
로그인 이후 요청은 이미 토큰을 가지고 오니,
서버는 토큰 유효성만 확인
유효하면 Authentication을 만들어
SecurityContextHolder에 세팅
즉,
로그인 = authenticate(인증 수행)
이후 요청 = validate(검증) + context 세팅
이 “혼용”이 실무에서 흔한 이유는 각 단계가 맡는 역할이 다르기 때문이다.
결론: 흔하다
요청에서 Authorization 헤더 읽기
Bearer 토큰 존재 여부 판단
토큰 검증/파싱 로직을 호출
인증 객체 생성 → SecurityContextHolder에 설정
Access/Refresh 토큰 생성
서명키 로딩
Claims 파싱/검증, 만료 체크 등
프로젝트가 커지면 Provider(Service) 레이어를 추가해 getAuthentication(token) 같은 고수준 API로 확장하기도 한다.
하지만 필터+유틸 2분리만으로도 실무에서 충분히 자주 쓰인다.
이번엔 Provider 레이어를 추가하여 3 분리로 해볼 예정이다.
프로젝트가 커지면 JwtUtil이 “토큰 생성 + 파싱 + 검증 + 인증 객체 생성까지” 다 떠안게 되고, 이 상태에서 refresh/OAuth2/권한 정책까지 붙으면 유지보수가 급격히 어려워진다.
그래서 실무에선 아래처럼 역할을 더 명확히 쪼개는 3분리가 자주 쓰인다.
요청에서 토큰을 추출
스킵/허용 경로면 건너뛰기
토큰이 있으면 Provider에 위임
Provider가 만든 Authentication을 SecurityContextHolder에 세팅
➡️ HTTP/서블릿 책임만 가진다.
토큰 검증 및 claims 파싱(파서에 위임)
claims에서 userId/email 등 식별자를 꺼냄
DB에서 UserDetails 로딩
Authentication 객체 생성해서 반환 (getAuthentication(token))
➡️ “토큰 → 인증 객체” 변환을 한 곳에 모은다.
서명키 기반 파싱
claims 추출
만료/서명 검증
(access/refresh 분리 파싱)
➡️ JWT 라이브러리(JJWT) API 의존을 여기로 몰아넣는다.
이 메서드는 “토큰 문자열”을 받아서 Spring Security의 Authentication을 만들어 반환하는 역할이다.
흐름은 보통:
token → claims 파싱
claims에서 sub(권장: userId) 추출
UserDetailsService로 사용자 로딩
UsernamePasswordAuthenticationToken 생성 후 반환
즉, 기존에 JwtFilter가 직접 하던 “DB 조회 + Authentication 생성”을 Provider로 옮겨서 필터를 얇게 만든다.
구조는 재사용하기 좋지만, 내용은 프로젝트마다 조정 포인트가 있다.
토큰 문자열에 "Bearer "를 포함해서 만들면, 저장/전송 경로가 늘어날 때 헷갈릴 수 있다.
보통은 토큰은 raw 문자열로 생성하고, 헤더에서 "Bearer "를 붙이는 쪽이 관리가 쉽다.
토큰이 없을 때 NPE를 던지면 디버깅/예외 분기가 지저분해질 수 있다.
보통 IllegalArgumentException 또는 커스텀 예외를 쓴다.
email은 변경될 수 있다. 그래서 토큰에 email을 넣고 email로 조회하면
“이메일 변경 후 재로그인 안 한 사용자” 같은 추가 케이스가 생긴다.
실무에선 보통:
sub에 userId(불변 식별자) 를 넣고
userId로 조회하는 방식을 선호한다.
필터에서 sendError()로 직접 끝내면
프로젝트에서 커스텀 EntryPoint/DeniedHandler로 만들어 둔
“에러 응답 포맷 통일” 흐름을 우회할 수 있다.
팀 스타일에 따라
필터는 예외를 던지고 EntryPoint로 JSON 통일
또는 필터가 직접 JSON 응답 작성
둘 중 하나로 “일관성”을 맞추는 게 중요하다.
결론부터: 가장 큰 변화는 “파싱/검증 API”다.
토큰 생성(create)은 영향이 상대적으로 적고, extractClaims 같은 파싱 코드가 많이 바뀐다.
(기본 구성은 동일: api + impl + jackson)
공식 README에서도 이 구성을 안내한다.
dependencies {
implementation "io.jsonwebtoken:jjwt-api:0.13.0"
runtimeOnly "io.jsonwebtoken:jjwt-impl:0.13.0"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.13.0"
}
(버전은 예시. 최신 라인업을 쓰면 됨)
기존:
Jwts.parserBuilder()...
변경:
Jwts.parser()...
기존:
Jwts.parserBuilder().setSigningKey(key)...
변경:
Jwts.parser().verifyWith(secretKey)...
여기서 key 타입이 javax.crypto.SecretKey(대칭키)인 게 일반적이다.
기존:
.parseClaimsJws(token).getBody()
변경:
.parseSignedClaims(token).getPayload()
파싱된 claims를 수정하려는 코드가 있으면 깨질 수 있다.
(보통은 파싱된 claims는 읽기만 하고 수정하지 않으니 영향이 없을 때가 많다.)
SignatureAlgorithm.HS256 같은 enum을 계속 써도 당장 동작은 하는데, 경고가 뜰 수 있다.
점진적으로 최신 방식(라이브러리 권장 방식)으로 바꾸는 게 좋다.
버전 업 시 바뀌는 게 대부분 parser/verify 쪽 API라서,
앞에서 말한 3분리 구조에서 Parser 한 곳만 고치면 전체 영향이 최소화된다.
JJWT 업그레이드로 바뀌는 코드는 거의 “토큰 파싱/검증” 부분이다.
그걸 JwtTokenParser로 몰아두면,
Filter / Provider는 거의 손댈 게 없다.
즉, 라이브러리 변경을 한 파일(또는 한 클래스)에 가둬버리는 효과가 있다.
내 필터는 이미
하지만, 여러 이슈가 발생할 수 있다.
permitAll/API 문서/헬스체크 같은 요청도 매번 필터가 실행돼서
헤더 읽고
조건 검사하고
(추가 로직이 생기면 더)
이게 쌓이면 성능도 성능인데, 필터가 커질수록 낭비가 커져.
지금은 “헤더 없으면 바로 통과”라 안전해 보이지만,
필터가 커지면서 흔히 이런 게 추가돼:
특정 헤더/쿠키도 검사
access token 없으면 refresh token 검사
로그/메트릭 추가
예외 처리 강화
이때 permitAll 경로에서 괜히 에러를 내거나 “쓸데없는 401”을 내는 실수가 나오기 쉬워.
특히 OAuth2는 리다이렉트/콜백이 있어서,
“여기는 JWT 필터가 아예 관여하지 않는다”를 코드로 명시해두면
문제 생겼을 때 원인 범위를 빨리 줄일 수 있음.
추천한 이유는 동작이 아니라 유지보수/안정성 때문이다.
permitAll 경로에서 불필요한 로직 실행을 줄이고
나중에 refresh/OAuth2 같은 예외 플로우가 늘 때
JWT 필터가 괜히 개입해서 흐름을 깨는 실수를 줄이기 위해서다.
특히
refresh 엔드포인트는 access token이 없거나 만료여도 정상 동작해야 하고
OAuth2 콜백/리다이렉트 경로는 JWT 필터가 끼면 디버깅이 힘들어진다
그래서 “스킵 규칙”을 명확히 하는 게 커질수록 이득이다.
Security 7에서는 matcher 전략이 바뀌었다.
그래서 HandlerMappingIntrospector / MvcRequestMatcher가 아니라, PathPatternRequestMatcher를 쓰는 게 자연스럽다.
@Bean
public RequestMatcher publicEndpoints() {
var p = PathPatternRequestMatcher.withDefaults().basePath("/api");
return new OrRequestMatcher(
p.matcher("/auth/**")
// 필요하면 여기에 추가
);
}
@Bean
public Filter jwtFilter(RequestMatcher publicEndpoints) {
return new JwtFilter(publicEndpoints);
}
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final RequestMatcher skipMatcher;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return skipMatcher.matches(request);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// JWT 검증 로직 ...
chain.doFilter(request, response);
}
}
여기서 헷갈리기 쉬운 포인트는 이거다.
@Bean은 “객체를 생성해서 스프링 컨테이너에 등록”하는 선언이다.
애플리케이션 시작 시점에 스프링이 필요한 순서대로 메서드를 호출해 빈을 만들고 관리한다.
@Bean
public Filter jwtFilter(RequestMatcher publicEndpoints) {
return new JwtFilter(publicEndpoints);
}
여기서 RequestMatcher publicEndpoints는
컨테이너에 등록된 RequestMatcher 빈을 스프링이 찾아서
자동으로 파라미터에 넣어주는 것이다.
즉 “내가 어딘가에서 실행해야”가 아니라,